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 }
24
25 let loading = $state(true)
26 let error = $state<string | null>(null)
27 let submitting = $state(false)
28 let consentData = $state<ConsentData | null>(null)
29 let scopeSelections = $state<Record<string, boolean>>({})
30 let rememberChoice = $state(false)
31
32 function getRequestUri(): string | null {
33 const params = new URLSearchParams(window.location.hash.split('?')[1] || '')
34 return params.get('request_uri')
35 }
36
37 async function fetchConsentData() {
38 const requestUri = getRequestUri()
39 if (!requestUri) {
40 error = $_('oauth.error.genericError')
41 loading = false
42 return
43 }
44
45 try {
46 const response = await fetch(`/oauth/authorize/consent?request_uri=${encodeURIComponent(requestUri)}`)
47 if (!response.ok) {
48 const data = await response.json()
49 error = data.error_description || data.error || $_('oauth.error.genericError')
50 loading = false
51 return
52 }
53 const data: ConsentData = await response.json()
54 consentData = data
55
56 for (const scope of data.scopes) {
57 if (scope.required) {
58 scopeSelections[scope.scope] = true
59 } else if (scope.granted !== null) {
60 scopeSelections[scope.scope] = scope.granted
61 } else {
62 scopeSelections[scope.scope] = true
63 }
64 }
65
66 if (!data.show_consent) {
67 await submitConsent()
68 }
69 } catch {
70 error = $_('oauth.error.genericError')
71 } finally {
72 loading = false
73 }
74 }
75
76 async function submitConsent() {
77 if (!consentData) return
78
79 submitting = true
80 const approvedScopes = Object.entries(scopeSelections)
81 .filter(([_, approved]) => approved)
82 .map(([scope]) => scope)
83
84 try {
85 const response = await fetch('/oauth/authorize/consent', {
86 method: 'POST',
87 headers: { 'Content-Type': 'application/json' },
88 body: JSON.stringify({
89 request_uri: consentData.request_uri,
90 approved_scopes: approvedScopes,
91 remember: rememberChoice
92 })
93 })
94
95 if (!response.ok) {
96 const data = await response.json()
97 error = data.error_description || data.error || $_('oauth.error.genericError')
98 submitting = false
99 return
100 }
101
102 const data = await response.json()
103 if (data.redirect_uri) {
104 window.location.href = data.redirect_uri
105 }
106 } catch {
107 error = $_('oauth.error.genericError')
108 submitting = false
109 }
110 }
111
112 async function handleDeny() {
113 if (!consentData) return
114
115 submitting = true
116 try {
117 const response = await fetch('/oauth/authorize/deny', {
118 method: 'POST',
119 headers: { 'Content-Type': 'application/json' },
120 body: JSON.stringify({ request_uri: consentData.request_uri })
121 })
122
123 if (response.redirected) {
124 window.location.href = response.url
125 }
126 } catch {
127 error = $_('oauth.error.genericError')
128 submitting = false
129 }
130 }
131
132 function handleScopeToggle(scope: string) {
133 const scopeInfo = consentData?.scopes.find(s => s.scope === scope)
134 if (scopeInfo?.required) return
135 scopeSelections[scope] = !scopeSelections[scope]
136 }
137
138 function groupScopesByCategory(scopes: ScopeInfo[]): Record<string, ScopeInfo[]> {
139 const groups: Record<string, ScopeInfo[]> = {}
140 for (const scope of scopes) {
141 if (!groups[scope.category]) {
142 groups[scope.category] = []
143 }
144 groups[scope.category].push(scope)
145 }
146 return groups
147 }
148
149 $effect(() => {
150 fetchConsentData()
151 })
152
153 let scopeGroups = $derived(consentData ? groupScopesByCategory(consentData.scopes) : {})
154</script>
155
156<div class="consent-container">
157 {#if loading}
158 <div class="loading">
159 <p>{$_('common.loading')}</p>
160 </div>
161 {:else if error}
162 <div class="error-container">
163 <h1>{$_('oauth.error.title')}</h1>
164 <div class="error">{error}</div>
165 <button type="button" onclick={() => navigate('/login')}>
166 {$_('verify.backToLogin')}
167 </button>
168 </div>
169 {:else if consentData}
170 <div class="split-layout sidebar-left">
171 <div class="client-panel">
172 <div class="client-info">
173 {#if consentData.logo_uri}
174 <img src={consentData.logo_uri} alt="" class="client-logo" />
175 {/if}
176 <h1>{consentData.client_name || $_('oauth.consent.title')}</h1>
177 <p class="subtitle">{$_('oauth.consent.appWantsAccess', { values: { app: '' } })}</p>
178 {#if consentData.client_uri}
179 <a href={consentData.client_uri} target="_blank" rel="noopener noreferrer" class="client-link">
180 {consentData.client_uri}
181 </a>
182 {/if}
183 </div>
184
185 <div class="account-info">
186 <span class="label">{$_('oauth.consent.signingInAs')}</span>
187 <span class="did">{consentData.did}</span>
188 </div>
189 </div>
190
191 <div class="permissions-panel">
192 <div class="scopes-section">
193 <h2>{$_('oauth.consent.permissionsRequested')}</h2>
194 {#each Object.entries(scopeGroups) as [category, scopes]}
195 <div class="scope-group">
196 <h3 class="category-title">{category}</h3>
197 {#each scopes as scope}
198 <label class="scope-item" class:required={scope.required}>
199 <input
200 type="checkbox"
201 checked={scopeSelections[scope.scope]}
202 disabled={scope.required || submitting}
203 onchange={() => handleScopeToggle(scope.scope)}
204 />
205 <div class="scope-info">
206 <span class="scope-name">{scope.display_name}</span>
207 <span class="scope-description">{scope.description}</span>
208 {#if scope.required}
209 <span class="required-badge">{$_('oauth.consent.required')}</span>
210 {/if}
211 </div>
212 </label>
213 {/each}
214 </div>
215 {/each}
216 </div>
217
218 <label class="remember-choice">
219 <input type="checkbox" bind:checked={rememberChoice} disabled={submitting} />
220 <span>{$_('oauth.consent.rememberChoiceLabel')}</span>
221 </label>
222 </div>
223 </div>
224
225 <div class="actions">
226 <button type="button" class="deny-btn" onclick={handleDeny} disabled={submitting}>
227 {$_('oauth.consent.deny')}
228 </button>
229 <button type="button" class="approve-btn" onclick={submitConsent} disabled={submitting}>
230 {submitting ? $_('oauth.consent.authorizing') : $_('oauth.consent.authorize')}
231 </button>
232 </div>
233 {/if}
234</div>
235
236<style>
237 .consent-container {
238 max-width: var(--width-lg);
239 margin: var(--space-7) auto;
240 padding: var(--space-7);
241 }
242
243 .loading {
244 display: flex;
245 align-items: center;
246 justify-content: center;
247 min-height: 200px;
248 color: var(--text-secondary);
249 }
250
251 .error-container {
252 text-align: center;
253 max-width: var(--width-sm);
254 margin: 0 auto;
255 }
256
257 .error {
258 padding: var(--space-3);
259 background: var(--error-bg);
260 border: 1px solid var(--error-border);
261 border-radius: var(--radius-md);
262 color: var(--error-text);
263 margin-bottom: var(--space-4);
264 }
265
266 .client-panel {
267 display: flex;
268 flex-direction: column;
269 gap: var(--space-5);
270 }
271
272 .permissions-panel {
273 min-width: 0;
274 }
275
276 .client-info {
277 text-align: center;
278 padding: var(--space-6);
279 background: var(--bg-secondary);
280 border-radius: var(--radius-xl);
281 }
282
283 @media (min-width: 800px) {
284 .client-info {
285 text-align: left;
286 }
287 }
288
289 .client-logo {
290 width: 64px;
291 height: 64px;
292 border-radius: var(--radius-xl);
293 margin-bottom: var(--space-4);
294 }
295
296 .client-info h1 {
297 margin: 0 0 var(--space-1) 0;
298 font-size: var(--text-xl);
299 }
300
301 .subtitle {
302 color: var(--text-secondary);
303 margin: 0;
304 }
305
306 .client-link {
307 display: inline-block;
308 margin-top: var(--space-2);
309 font-size: var(--text-sm);
310 color: var(--accent);
311 text-decoration: none;
312 }
313
314 .client-link:hover {
315 text-decoration: underline;
316 }
317
318 .account-info {
319 display: flex;
320 flex-direction: column;
321 gap: var(--space-1);
322 padding: var(--space-4);
323 background: var(--bg-secondary);
324 border-radius: var(--radius-xl);
325 margin-bottom: var(--space-6);
326 }
327
328 .account-info .label {
329 font-size: var(--text-xs);
330 color: var(--text-muted);
331 text-transform: uppercase;
332 letter-spacing: 0.05em;
333 }
334
335 .account-info .did {
336 font-family: monospace;
337 font-size: var(--text-sm);
338 color: var(--text-primary);
339 word-break: break-all;
340 }
341
342 .scopes-section {
343 margin-bottom: var(--space-6);
344 }
345
346 .scopes-section h2 {
347 font-size: var(--text-base);
348 margin: 0 0 var(--space-4) 0;
349 color: var(--text-secondary);
350 }
351
352 .scope-group {
353 margin-bottom: var(--space-4);
354 }
355
356 .category-title {
357 font-size: var(--text-sm);
358 font-weight: var(--font-semibold);
359 color: var(--text-primary);
360 margin: 0 0 var(--space-2) 0;
361 padding-bottom: var(--space-1);
362 border-bottom: 1px solid var(--border-color);
363 }
364
365 .scope-item {
366 display: flex;
367 gap: var(--space-3);
368 padding: var(--space-3);
369 background: var(--bg-card);
370 border: 1px solid var(--border-color);
371 border-radius: var(--radius-lg);
372 margin-bottom: var(--space-2);
373 cursor: pointer;
374 transition: border-color var(--transition-fast);
375 }
376
377 .scope-item:hover:not(.required) {
378 border-color: var(--accent);
379 }
380
381 .scope-item.required {
382 background: var(--bg-secondary);
383 }
384
385 .scope-item input[type="checkbox"] {
386 flex-shrink: 0;
387 width: 18px;
388 height: 18px;
389 margin-top: 2px;
390 }
391
392 .scope-info {
393 flex: 1;
394 display: flex;
395 flex-direction: column;
396 gap: 2px;
397 }
398
399 .scope-name {
400 font-weight: var(--font-medium);
401 color: var(--text-primary);
402 }
403
404 .scope-description {
405 font-size: var(--text-sm);
406 color: var(--text-secondary);
407 }
408
409 .required-badge {
410 display: inline-block;
411 font-size: 0.625rem;
412 padding: 2px var(--space-2);
413 background: var(--warning-bg);
414 color: var(--warning-text);
415 border-radius: var(--radius-sm);
416 text-transform: uppercase;
417 letter-spacing: 0.05em;
418 margin-top: var(--space-1);
419 width: fit-content;
420 }
421
422 .remember-choice {
423 display: flex;
424 align-items: center;
425 gap: var(--space-2);
426 margin-top: var(--space-5);
427 cursor: pointer;
428 color: var(--text-secondary);
429 font-size: var(--text-sm);
430 }
431
432 .remember-choice input {
433 width: 16px;
434 height: 16px;
435 }
436
437 .actions {
438 display: flex;
439 gap: var(--space-4);
440 margin-top: var(--space-6);
441 }
442
443 @media (min-width: 800px) {
444 .actions {
445 max-width: 400px;
446 margin-left: auto;
447 }
448 }
449
450 .actions button {
451 flex: 1;
452 padding: var(--space-3);
453 border: none;
454 border-radius: var(--radius-lg);
455 font-size: var(--text-base);
456 font-weight: var(--font-medium);
457 cursor: pointer;
458 transition: background-color var(--transition-fast);
459 }
460
461 .actions button:disabled {
462 opacity: 0.6;
463 cursor: not-allowed;
464 }
465
466 .deny-btn {
467 background: var(--bg-secondary);
468 color: var(--text-primary);
469 border: 1px solid var(--border-color);
470 }
471
472 .deny-btn:hover:not(:disabled) {
473 background: var(--error-bg);
474 border-color: var(--error-border);
475 color: var(--error-text);
476 }
477
478 .approve-btn {
479 background: var(--accent);
480 color: var(--text-inverse);
481 }
482
483 .approve-btn:hover:not(:disabled) {
484 background: var(--accent-hover);
485 }
486</style>