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="client-info">
171 {#if consentData.logo_uri}
172 <img src={consentData.logo_uri} alt="" class="client-logo" />
173 {/if}
174 <h1>{consentData.client_name || $_('oauth.consent.title')}</h1>
175 <p class="subtitle">{$_('oauth.consent.appWantsAccess', { values: { app: '' } })}</p>
176 {#if consentData.client_uri}
177 <a href={consentData.client_uri} target="_blank" rel="noopener noreferrer" class="client-link">
178 {consentData.client_uri}
179 </a>
180 {/if}
181 </div>
182
183 <div class="account-info">
184 <span class="label">{$_('oauth.consent.signingInAs')}</span>
185 <span class="did">{consentData.did}</span>
186 </div>
187
188 <div class="scopes-section">
189 <h2>{$_('oauth.consent.permissionsRequested')}</h2>
190 {#each Object.entries(scopeGroups) as [category, scopes]}
191 <div class="scope-group">
192 <h3 class="category-title">{category}</h3>
193 {#each scopes as scope}
194 <label class="scope-item" class:required={scope.required}>
195 <input
196 type="checkbox"
197 checked={scopeSelections[scope.scope]}
198 disabled={scope.required || submitting}
199 onchange={() => handleScopeToggle(scope.scope)}
200 />
201 <div class="scope-info">
202 <span class="scope-name">{scope.display_name}</span>
203 <span class="scope-description">{scope.description}</span>
204 {#if scope.required}
205 <span class="required-badge">{$_('oauth.consent.required')}</span>
206 {/if}
207 </div>
208 </label>
209 {/each}
210 </div>
211 {/each}
212 </div>
213
214 <label class="remember-choice">
215 <input type="checkbox" bind:checked={rememberChoice} disabled={submitting} />
216 <span>{$_('oauth.consent.rememberChoiceLabel')}</span>
217 </label>
218
219 <div class="actions">
220 <button type="button" class="deny-btn" onclick={handleDeny} disabled={submitting}>
221 {$_('oauth.consent.deny')}
222 </button>
223 <button type="button" class="approve-btn" onclick={submitConsent} disabled={submitting}>
224 {submitting ? $_('oauth.consent.authorizing') : $_('oauth.consent.authorize')}
225 </button>
226 </div>
227 {/if}
228</div>
229
230<style>
231 .consent-container {
232 max-width: 480px;
233 margin: var(--space-7) auto;
234 padding: var(--space-7);
235 }
236
237 .loading {
238 display: flex;
239 align-items: center;
240 justify-content: center;
241 min-height: 200px;
242 color: var(--text-secondary);
243 }
244
245 .error-container {
246 text-align: center;
247 }
248
249 .error {
250 padding: var(--space-3);
251 background: var(--error-bg);
252 border: 1px solid var(--error-border);
253 border-radius: var(--radius-md);
254 color: var(--error-text);
255 margin-bottom: var(--space-4);
256 }
257
258 .client-info {
259 text-align: center;
260 margin-bottom: var(--space-6);
261 }
262
263 .client-logo {
264 width: 64px;
265 height: 64px;
266 border-radius: var(--radius-xl);
267 margin-bottom: var(--space-4);
268 }
269
270 .client-info h1 {
271 margin: 0 0 var(--space-1) 0;
272 font-size: var(--text-xl);
273 }
274
275 .subtitle {
276 color: var(--text-secondary);
277 margin: 0;
278 }
279
280 .client-link {
281 display: inline-block;
282 margin-top: var(--space-2);
283 font-size: var(--text-sm);
284 color: var(--accent);
285 text-decoration: none;
286 }
287
288 .client-link:hover {
289 text-decoration: underline;
290 }
291
292 .account-info {
293 display: flex;
294 flex-direction: column;
295 gap: var(--space-1);
296 padding: var(--space-4);
297 background: var(--bg-secondary);
298 border-radius: var(--radius-xl);
299 margin-bottom: var(--space-6);
300 }
301
302 .account-info .label {
303 font-size: var(--text-xs);
304 color: var(--text-muted);
305 text-transform: uppercase;
306 letter-spacing: 0.05em;
307 }
308
309 .account-info .did {
310 font-family: monospace;
311 font-size: var(--text-sm);
312 color: var(--text-primary);
313 word-break: break-all;
314 }
315
316 .scopes-section {
317 margin-bottom: var(--space-6);
318 }
319
320 .scopes-section h2 {
321 font-size: var(--text-base);
322 margin: 0 0 var(--space-4) 0;
323 color: var(--text-secondary);
324 }
325
326 .scope-group {
327 margin-bottom: var(--space-4);
328 }
329
330 .category-title {
331 font-size: var(--text-sm);
332 font-weight: var(--font-semibold);
333 color: var(--text-primary);
334 margin: 0 0 var(--space-2) 0;
335 padding-bottom: var(--space-1);
336 border-bottom: 1px solid var(--border-color);
337 }
338
339 .scope-item {
340 display: flex;
341 gap: var(--space-3);
342 padding: var(--space-3);
343 background: var(--bg-card);
344 border: 1px solid var(--border-color);
345 border-radius: var(--radius-lg);
346 margin-bottom: var(--space-2);
347 cursor: pointer;
348 transition: border-color var(--transition-fast);
349 }
350
351 .scope-item:hover:not(.required) {
352 border-color: var(--accent);
353 }
354
355 .scope-item.required {
356 background: var(--bg-secondary);
357 }
358
359 .scope-item input[type="checkbox"] {
360 flex-shrink: 0;
361 width: 18px;
362 height: 18px;
363 margin-top: 2px;
364 }
365
366 .scope-info {
367 flex: 1;
368 display: flex;
369 flex-direction: column;
370 gap: 2px;
371 }
372
373 .scope-name {
374 font-weight: var(--font-medium);
375 color: var(--text-primary);
376 }
377
378 .scope-description {
379 font-size: var(--text-sm);
380 color: var(--text-secondary);
381 }
382
383 .required-badge {
384 display: inline-block;
385 font-size: 0.625rem;
386 padding: 2px var(--space-2);
387 background: var(--warning-bg);
388 color: var(--warning-text);
389 border-radius: var(--radius-sm);
390 text-transform: uppercase;
391 letter-spacing: 0.05em;
392 margin-top: var(--space-1);
393 width: fit-content;
394 }
395
396 .remember-choice {
397 display: flex;
398 align-items: center;
399 gap: var(--space-2);
400 margin-bottom: var(--space-6);
401 cursor: pointer;
402 color: var(--text-secondary);
403 font-size: var(--text-sm);
404 }
405
406 .remember-choice input {
407 width: 16px;
408 height: 16px;
409 }
410
411 .actions {
412 display: flex;
413 gap: var(--space-4);
414 }
415
416 .actions button {
417 flex: 1;
418 padding: var(--space-3);
419 border: none;
420 border-radius: var(--radius-lg);
421 font-size: var(--text-base);
422 font-weight: var(--font-medium);
423 cursor: pointer;
424 transition: background-color var(--transition-fast);
425 }
426
427 .actions button:disabled {
428 opacity: 0.6;
429 cursor: not-allowed;
430 }
431
432 .deny-btn {
433 background: var(--bg-secondary);
434 color: var(--text-primary);
435 border: 1px solid var(--border-color);
436 }
437
438 .deny-btn:hover:not(:disabled) {
439 background: var(--error-bg);
440 border-color: var(--error-border);
441 color: var(--error-text);
442 }
443
444 .approve-btn {
445 background: var(--accent);
446 color: var(--text-inverse);
447 }
448
449 .approve-btn:hover:not(:disabled) {
450 background: var(--accent-hover);
451 }
452</style>