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