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>