this repo has no description
1<script lang="ts">
2 import { navigate } from '../lib/router.svelte'
3 import { _ } from '../lib/i18n'
4
5 interface ScopeInfo {
6 scope: string
7 category: string
8 required: boolean
9 description: string
10 display_name: string
11 granted: boolean | null
12 }
13
14 interface ConsentData {
15 request_uri: string
16 client_id: string
17 client_name: string | null
18 client_uri: string | null
19 logo_uri: string | null
20 scopes: ScopeInfo[]
21 show_consent: boolean
22 did: string
23 is_delegation?: boolean
24 controller_did?: string
25 controller_handle?: string
26 delegation_level?: string
27 }
28
29 let loading = $state(true)
30 let error = $state<string | null>(null)
31 let submitting = $state(false)
32 let consentData = $state<ConsentData | null>(null)
33 let scopeSelections = $state<Record<string, boolean>>({})
34 let rememberChoice = $state(false)
35
36 function getRequestUri(): string | null {
37 const params = new URLSearchParams(window.location.hash.split('?')[1] || '')
38 return params.get('request_uri')
39 }
40
41 async function fetchConsentData() {
42 const requestUri = getRequestUri()
43 if (!requestUri) {
44 error = $_('oauth.error.genericError')
45 loading = false
46 return
47 }
48
49 try {
50 const response = await fetch(`/oauth/authorize/consent?request_uri=${encodeURIComponent(requestUri)}`)
51 if (!response.ok) {
52 const data = await response.json()
53 error = data.error_description || data.error || $_('oauth.error.genericError')
54 loading = false
55 return
56 }
57 const data: ConsentData = await response.json()
58 consentData = data
59
60 for (const scope of data.scopes) {
61 if (scope.required) {
62 scopeSelections[scope.scope] = true
63 } else if (scope.granted !== null) {
64 scopeSelections[scope.scope] = scope.granted
65 } else {
66 scopeSelections[scope.scope] = true
67 }
68 }
69
70 if (!data.show_consent) {
71 await submitConsent()
72 }
73 } catch {
74 error = $_('oauth.error.genericError')
75 } finally {
76 loading = false
77 }
78 }
79
80 async function submitConsent() {
81 if (!consentData) return
82
83 submitting = true
84 let approvedScopes = Object.entries(scopeSelections)
85 .filter(([_, approved]) => approved)
86 .map(([scope]) => scope)
87
88 if (approvedScopes.length === 0 && consentData.scopes.length === 0) {
89 approvedScopes = ['atproto']
90 }
91
92 try {
93 const response = await fetch('/oauth/authorize/consent', {
94 method: 'POST',
95 headers: { 'Content-Type': 'application/json' },
96 body: JSON.stringify({
97 request_uri: consentData.request_uri,
98 approved_scopes: approvedScopes,
99 remember: rememberChoice
100 })
101 })
102
103 if (!response.ok) {
104 const data = await response.json()
105 error = data.error_description || data.error || $_('oauth.error.genericError')
106 submitting = false
107 return
108 }
109
110 const data = await response.json()
111 if (data.redirect_uri) {
112 window.location.href = data.redirect_uri
113 }
114 } catch {
115 error = $_('oauth.error.genericError')
116 submitting = false
117 }
118 }
119
120 async function handleDeny() {
121 if (!consentData) return
122
123 submitting = true
124 try {
125 const response = await fetch('/oauth/authorize/deny', {
126 method: 'POST',
127 headers: { 'Content-Type': 'application/json' },
128 body: JSON.stringify({ request_uri: consentData.request_uri })
129 })
130
131 if (response.redirected) {
132 window.location.href = response.url
133 }
134 } catch {
135 error = $_('oauth.error.genericError')
136 submitting = false
137 }
138 }
139
140 function handleScopeToggle(scope: string) {
141 const scopeInfo = consentData?.scopes.find(s => s.scope === scope)
142 if (scopeInfo?.required) return
143 scopeSelections[scope] = !scopeSelections[scope]
144 }
145
146 function groupScopesByCategory(scopes: ScopeInfo[]): Record<string, ScopeInfo[]> {
147 const groups: Record<string, ScopeInfo[]> = {}
148 for (const scope of scopes) {
149 if (!groups[scope.category]) {
150 groups[scope.category] = []
151 }
152 groups[scope.category].push(scope)
153 }
154 return groups
155 }
156
157 $effect(() => {
158 fetchConsentData()
159 })
160
161 let scopeGroups = $derived(consentData ? groupScopesByCategory(consentData.scopes) : {})
162</script>
163
164<div class="consent-container">
165 {#if loading}
166 <div class="loading">
167 <p>{$_('common.loading')}</p>
168 </div>
169 {:else if error}
170 <div class="error-container">
171 <h1>{$_('oauth.error.title')}</h1>
172 <div class="error">{error}</div>
173 <button type="button" onclick={() => navigate('/login')}>
174 {$_('common.backToLogin')}
175 </button>
176 </div>
177 {:else if consentData}
178 <div class="split-layout sidebar-left">
179 <div class="client-panel">
180 <div class="client-info">
181 {#if consentData.logo_uri}
182 <img src={consentData.logo_uri} alt="" class="client-logo" />
183 {/if}
184 <h1>{consentData.client_name || $_('oauth.consent.title')}</h1>
185 <p class="subtitle">{$_('oauth.consent.appWantsAccess', { values: { app: '' } })}</p>
186 {#if consentData.client_uri}
187 <a href={consentData.client_uri} target="_blank" rel="noopener noreferrer" class="client-link">
188 {consentData.client_uri}
189 </a>
190 {/if}
191 </div>
192
193 <div class="account-info">
194 {#if consentData.is_delegation}
195 <div class="delegation-badge">{$_('oauthConsent.delegatedAccess')}</div>
196 <div class="delegation-info">
197 <div class="info-row">
198 <span class="label">{$_('oauthConsent.actingAs')}</span>
199 <span class="did">{consentData.did}</span>
200 </div>
201 <div class="info-row">
202 <span class="label">{$_('oauthConsent.controller')}</span>
203 <span class="handle">@{consentData.controller_handle || consentData.controller_did}</span>
204 </div>
205 <div class="info-row">
206 <span class="label">{$_('oauthConsent.accessLevel')}</span>
207 <span class="level-badge level-{consentData.delegation_level?.toLowerCase()}">{consentData.delegation_level}</span>
208 </div>
209 </div>
210 {#if consentData.delegation_level && consentData.delegation_level !== 'Owner'}
211 <div class="permissions-notice">
212 <div class="notice-header">
213 <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
214 <span>{$_('oauthConsent.permissionsLimited')}</span>
215 </div>
216 <p class="notice-text">
217 {#if consentData.delegation_level === 'Viewer'}
218 {$_('oauthConsent.viewerLimitedDesc')}
219 {:else if consentData.delegation_level === 'Editor'}
220 {$_('oauthConsent.editorLimitedDesc')}
221 {:else}
222 {$_('oauthConsent.permissionsLimitedDesc', { values: { level: consentData.delegation_level } })}
223 {/if}
224 </p>
225 </div>
226 {/if}
227 {:else}
228 <span class="label">{$_('oauth.consent.signingInAs')}</span>
229 <span class="did">{consentData.did}</span>
230 {/if}
231 </div>
232 </div>
233
234 <div class="permissions-panel">
235 <div class="scopes-section">
236 <h2>{$_('oauth.consent.permissionsRequested')}</h2>
237 {#if consentData.scopes.length === 0}
238 <div class="read-only-notice">
239 <div class="scope-item read-only">
240 <div class="scope-info">
241 <span class="scope-name">{$_('oauthConsent.readOnlyAccess')}</span>
242 <span class="scope-description">{$_('oauthConsent.readOnlyDesc')}</span>
243 </div>
244 </div>
245 </div>
246 {:else}
247 {#each Object.entries(scopeGroups) as [category, scopes]}
248 <div class="scope-group">
249 <h3 class="category-title">{category}</h3>
250 {#each scopes as scope}
251 <label class="scope-item" class:required={scope.required}>
252 <input
253 type="checkbox"
254 checked={scopeSelections[scope.scope]}
255 disabled={scope.required || submitting}
256 onchange={() => handleScopeToggle(scope.scope)}
257 />
258 <div class="scope-info">
259 <span class="scope-name">{scope.display_name}</span>
260 <span class="scope-description">{scope.description}</span>
261 {#if scope.required}
262 <span class="required-badge">{$_('oauth.consent.required')}</span>
263 {/if}
264 </div>
265 </label>
266 {/each}
267 </div>
268 {/each}
269 {/if}
270 </div>
271
272 <label class="remember-choice">
273 <input type="checkbox" bind:checked={rememberChoice} disabled={submitting} />
274 <span>{$_('oauth.consent.rememberChoiceLabel')}</span>
275 </label>
276 </div>
277 </div>
278
279 <div class="actions">
280 <button type="button" class="deny-btn" onclick={handleDeny} disabled={submitting}>
281 {$_('oauth.consent.deny')}
282 </button>
283 <button type="button" class="approve-btn" onclick={submitConsent} disabled={submitting}>
284 {submitting ? $_('oauth.consent.authorizing') : $_('oauth.consent.authorize')}
285 </button>
286 </div>
287 {/if}
288</div>
289
290<style>
291 .consent-container {
292 max-width: var(--width-lg);
293 margin: var(--space-7) auto;
294 padding: var(--space-7);
295 }
296
297 .loading {
298 display: flex;
299 align-items: center;
300 justify-content: center;
301 min-height: 200px;
302 color: var(--text-secondary);
303 }
304
305 .error-container {
306 text-align: center;
307 max-width: var(--width-sm);
308 margin: 0 auto;
309 }
310
311 .error {
312 padding: var(--space-3);
313 background: var(--error-bg);
314 border: 1px solid var(--error-border);
315 border-radius: var(--radius-md);
316 color: var(--error-text);
317 margin-bottom: var(--space-4);
318 }
319
320 .client-panel {
321 display: flex;
322 flex-direction: column;
323 gap: var(--space-5);
324 }
325
326 .permissions-panel {
327 min-width: 0;
328 }
329
330 .client-info {
331 text-align: center;
332 padding: var(--space-6);
333 background: var(--bg-secondary);
334 border-radius: var(--radius-xl);
335 }
336
337 @media (min-width: 800px) {
338 .client-info {
339 text-align: left;
340 }
341 }
342
343 .client-logo {
344 width: 64px;
345 height: 64px;
346 border-radius: var(--radius-xl);
347 margin-bottom: var(--space-4);
348 }
349
350 .client-info h1 {
351 margin: 0 0 var(--space-1) 0;
352 font-size: var(--text-xl);
353 }
354
355 .subtitle {
356 color: var(--text-secondary);
357 margin: 0;
358 }
359
360 .client-link {
361 display: inline-block;
362 margin-top: var(--space-2);
363 font-size: var(--text-sm);
364 color: var(--accent);
365 text-decoration: none;
366 }
367
368 .client-link:hover {
369 text-decoration: underline;
370 }
371
372 .account-info {
373 display: flex;
374 flex-direction: column;
375 gap: var(--space-1);
376 padding: var(--space-4);
377 background: var(--bg-secondary);
378 border-radius: var(--radius-xl);
379 margin-bottom: var(--space-6);
380 }
381
382 .account-info .label {
383 font-size: var(--text-xs);
384 color: var(--text-muted);
385 text-transform: uppercase;
386 letter-spacing: 0.05em;
387 }
388
389 .account-info .did {
390 font-family: monospace;
391 font-size: var(--text-sm);
392 color: var(--text-primary);
393 word-break: break-all;
394 }
395
396 .delegation-badge {
397 display: inline-block;
398 padding: var(--space-1) var(--space-2);
399 background: var(--accent);
400 color: var(--text-inverse);
401 border-radius: var(--radius-md);
402 font-size: var(--text-xs);
403 font-weight: var(--font-semibold);
404 text-transform: uppercase;
405 letter-spacing: 0.05em;
406 margin-bottom: var(--space-3);
407 }
408
409 .delegation-info {
410 display: flex;
411 flex-direction: column;
412 gap: var(--space-2);
413 }
414
415 .delegation-info .info-row {
416 display: flex;
417 flex-direction: column;
418 gap: 2px;
419 }
420
421 .delegation-info .handle {
422 font-weight: var(--font-medium);
423 color: var(--text-primary);
424 }
425
426 .level-badge {
427 display: inline-block;
428 padding: 2px var(--space-2);
429 background: var(--bg-tertiary);
430 color: var(--text-primary);
431 border-radius: var(--radius-sm);
432 font-size: var(--text-sm);
433 font-weight: var(--font-medium);
434 }
435
436 .level-badge.level-owner {
437 background: var(--success-bg);
438 color: var(--success-text);
439 }
440
441 .level-badge.level-admin {
442 background: var(--accent);
443 color: var(--text-inverse);
444 }
445
446 .level-badge.level-editor {
447 background: var(--warning-bg);
448 color: var(--warning-text);
449 }
450
451 .level-badge.level-viewer {
452 background: var(--bg-tertiary);
453 color: var(--text-secondary);
454 }
455
456 .permissions-notice {
457 margin-top: var(--space-3);
458 padding: var(--space-3);
459 background: var(--warning-bg);
460 border: 1px solid var(--warning-border);
461 border-radius: var(--radius-md);
462 }
463
464 .notice-header {
465 display: flex;
466 align-items: center;
467 gap: var(--space-2);
468 font-weight: var(--font-semibold);
469 color: var(--warning-text);
470 margin-bottom: var(--space-2);
471 }
472
473 .notice-header svg {
474 flex-shrink: 0;
475 }
476
477 .notice-text {
478 margin: 0;
479 font-size: var(--text-sm);
480 color: var(--warning-text);
481 line-height: 1.5;
482 }
483
484 .scopes-section {
485 margin-bottom: var(--space-6);
486 }
487
488 .scopes-section h2 {
489 font-size: var(--text-base);
490 margin: 0 0 var(--space-4) 0;
491 color: var(--text-secondary);
492 }
493
494 .scope-group {
495 margin-bottom: var(--space-4);
496 }
497
498 .category-title {
499 font-size: var(--text-sm);
500 font-weight: var(--font-semibold);
501 color: var(--text-primary);
502 margin: 0 0 var(--space-2) 0;
503 padding-bottom: var(--space-1);
504 border-bottom: 1px solid var(--border-color);
505 }
506
507 .scope-item {
508 display: flex;
509 gap: var(--space-3);
510 padding: var(--space-3);
511 background: var(--bg-card);
512 border: 1px solid var(--border-color);
513 border-radius: var(--radius-lg);
514 margin-bottom: var(--space-2);
515 cursor: pointer;
516 transition: border-color var(--transition-fast);
517 }
518
519 .scope-item:hover:not(.required) {
520 border-color: var(--accent);
521 }
522
523 .scope-item.required {
524 background: var(--bg-secondary);
525 }
526
527 .scope-item.read-only {
528 background: var(--bg-secondary);
529 border-style: dashed;
530 }
531
532 .scope-item input[type="checkbox"] {
533 flex-shrink: 0;
534 width: 18px;
535 height: 18px;
536 margin-top: 2px;
537 }
538
539 .scope-info {
540 flex: 1;
541 display: flex;
542 flex-direction: column;
543 gap: 2px;
544 }
545
546 .scope-name {
547 font-weight: var(--font-medium);
548 color: var(--text-primary);
549 }
550
551 .scope-description {
552 font-size: var(--text-sm);
553 color: var(--text-secondary);
554 }
555
556 .required-badge {
557 display: inline-block;
558 font-size: 0.625rem;
559 padding: 2px var(--space-2);
560 background: var(--warning-bg);
561 color: var(--warning-text);
562 border-radius: var(--radius-sm);
563 text-transform: uppercase;
564 letter-spacing: 0.05em;
565 margin-top: var(--space-1);
566 width: fit-content;
567 }
568
569 .remember-choice {
570 display: flex;
571 align-items: center;
572 gap: var(--space-2);
573 margin-top: var(--space-5);
574 cursor: pointer;
575 color: var(--text-secondary);
576 font-size: var(--text-sm);
577 }
578
579 .remember-choice input {
580 width: 16px;
581 height: 16px;
582 }
583
584 .actions {
585 display: flex;
586 gap: var(--space-4);
587 margin-top: var(--space-6);
588 }
589
590 @media (min-width: 800px) {
591 .actions {
592 max-width: 400px;
593 margin-left: auto;
594 }
595 }
596
597 .actions button {
598 flex: 1;
599 padding: var(--space-3);
600 border: none;
601 border-radius: var(--radius-lg);
602 font-size: var(--text-base);
603 font-weight: var(--font-medium);
604 cursor: pointer;
605 transition: background-color var(--transition-fast);
606 }
607
608 .actions button:disabled {
609 opacity: 0.6;
610 cursor: not-allowed;
611 }
612
613 .deny-btn {
614 background: var(--bg-secondary);
615 color: var(--text-primary);
616 border: 1px solid var(--border-color);
617 }
618
619 .deny-btn:hover:not(:disabled) {
620 background: var(--error-bg);
621 border-color: var(--error-border);
622 color: var(--error-text);
623 }
624
625 .approve-btn {
626 background: var(--accent);
627 color: var(--text-inverse);
628 }
629
630 .approve-btn:hover:not(:disabled) {
631 background: var(--accent-hover);
632 }
633</style>