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