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