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 }
512
513 .scope-item:hover:not(.required) {
514 border-color: var(--accent);
515 }
516
517 .scope-item.required {
518 background: var(--bg-secondary);
519 }
520
521 .scope-item.read-only {
522 background: var(--bg-secondary);
523 border-style: dashed;
524 }
525
526 .scope-item input[type="checkbox"] {
527 flex-shrink: 0;
528 width: 18px;
529 height: 18px;
530 margin-top: 2px;
531 }
532
533 .scope-info {
534 flex: 1;
535 display: flex;
536 flex-direction: column;
537 gap: 2px;
538 }
539
540 .scope-name {
541 font-weight: var(--font-medium);
542 color: var(--text-primary);
543 }
544
545 .scope-description {
546 font-size: var(--text-sm);
547 color: var(--text-secondary);
548 }
549
550 .required-badge {
551 display: inline-block;
552 font-size: 0.625rem;
553 padding: 2px var(--space-2);
554 background: var(--warning-bg);
555 color: var(--warning-text);
556 border-radius: var(--radius-sm);
557 text-transform: uppercase;
558 letter-spacing: 0.05em;
559 margin-top: var(--space-1);
560 width: fit-content;
561 }
562
563 .remember-choice {
564 display: flex;
565 align-items: center;
566 gap: var(--space-2);
567 margin-top: var(--space-5);
568 cursor: pointer;
569 color: var(--text-secondary);
570 font-size: var(--text-sm);
571 }
572
573 .remember-choice input {
574 width: 16px;
575 height: 16px;
576 }
577
578 .actions {
579 display: flex;
580 gap: var(--space-4);
581 margin-top: var(--space-6);
582 }
583
584 @media (min-width: 800px) {
585 .actions {
586 max-width: 400px;
587 margin-left: auto;
588 }
589 }
590
591 .actions button {
592 flex: 1;
593 padding: var(--space-3);
594 border: none;
595 border-radius: var(--radius-lg);
596 font-size: var(--text-base);
597 font-weight: var(--font-medium);
598 cursor: pointer;
599 transition: background-color var(--transition-fast);
600 }
601
602 .actions button:disabled {
603 opacity: 0.6;
604 cursor: not-allowed;
605 }
606
607 .deny-btn {
608 background: var(--bg-secondary);
609 color: var(--text-primary);
610 border: 1px solid var(--border-color);
611 }
612
613 .deny-btn:hover:not(:disabled) {
614 background: var(--error-bg);
615 border-color: var(--error-border);
616 color: var(--error-text);
617 }
618
619 .approve-btn {
620 background: var(--accent);
621 color: var(--text-inverse);
622 }
623
624 .approve-btn:hover:not(:disabled) {
625 background: var(--accent-hover);
626 }
627</style>