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>