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