this repo has no description
1<script lang="ts"> 2 import { navigate, routes, getFullUrl } 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 is_delegation?: boolean 24 controller_did?: string 25 controller_handle?: string 26 delegation_level?: string 27 } 28 29 let loading = $state(true) 30 let error = $state<string | null>(null) 31 let submitting = $state(false) 32 let consentData = $state<ConsentData | null>(null) 33 let scopeSelections = $state<Record<string, boolean>>({}) 34 let rememberChoice = $state(false) 35 36 function getRequestUri(): string | null { 37 const params = new URLSearchParams(window.location.search) 38 return params.get('request_uri') 39 } 40 41 async function fetchConsentData() { 42 const requestUri = getRequestUri() 43 if (!requestUri) { 44 error = $_('oauth.error.genericError') 45 loading = false 46 return 47 } 48 49 try { 50 const response = await fetch(`/oauth/authorize/consent?request_uri=${encodeURIComponent(requestUri)}`) 51 if (!response.ok) { 52 const data = await response.json() 53 error = data.error_description || data.error || $_('oauth.error.genericError') 54 loading = false 55 return 56 } 57 const data: ConsentData = await response.json() 58 consentData = data 59 60 scopeSelections = Object.fromEntries( 61 data.scopes.map((scope) => [ 62 scope.scope, 63 scope.required ? true : scope.granted ?? true, 64 ]) 65 ) 66 67 if (!data.show_consent) { 68 await submitConsent() 69 } 70 } catch { 71 error = $_('oauth.error.genericError') 72 } finally { 73 loading = false 74 } 75 } 76 77 async function submitConsent() { 78 if (!consentData) return 79 80 submitting = true 81 let approvedScopes = Object.entries(scopeSelections) 82 .filter(([_, approved]) => approved) 83 .map(([scope]) => scope) 84 85 if (approvedScopes.length === 0 && consentData.scopes.length === 0) { 86 approvedScopes = ['atproto'] 87 } 88 89 try { 90 const response = await fetch('/oauth/authorize/consent', { 91 method: 'POST', 92 headers: { 'Content-Type': 'application/json' }, 93 body: JSON.stringify({ 94 request_uri: consentData.request_uri, 95 approved_scopes: approvedScopes, 96 remember: rememberChoice, 97 }), 98 }) 99 100 if (!response.ok) { 101 const data = await response.json() 102 error = data.error_description || data.error || $_('oauth.error.genericError') 103 submitting = false 104 return 105 } 106 107 const data = await response.json() 108 if (data.redirect_uri) { 109 window.location.href = data.redirect_uri 110 } 111 } catch { 112 error = $_('oauth.error.genericError') 113 submitting = false 114 } 115 } 116 117 async function handleDeny() { 118 if (!consentData) return 119 120 submitting = true 121 try { 122 const response = await fetch('/oauth/authorize/deny', { 123 method: 'POST', 124 headers: { 'Content-Type': 'application/json' }, 125 body: JSON.stringify({ request_uri: consentData.request_uri }) 126 }) 127 128 if (response.redirected) { 129 window.location.href = response.url 130 } 131 } catch { 132 error = $_('oauth.error.genericError') 133 submitting = false 134 } 135 } 136 137 function handleScopeToggle(scope: string) { 138 const scopeInfo = consentData?.scopes.find(s => s.scope === scope) 139 if (scopeInfo?.required) return 140 scopeSelections[scope] = !scopeSelections[scope] 141 } 142 143 function groupScopesByCategory(scopes: ScopeInfo[]): Record<string, ScopeInfo[]> { 144 return scopes.reduce( 145 (groups, scope) => ({ 146 ...groups, 147 [scope.category]: [...(groups[scope.category] ?? []), scope], 148 }), 149 {} as Record<string, ScopeInfo[]> 150 ) 151 } 152 153 $effect(() => { 154 fetchConsentData() 155 }) 156 157 let scopeGroups = $derived(consentData ? groupScopesByCategory(consentData.scopes) : {}) 158</script> 159 160<div class="consent-container"> 161 {#if loading} 162 <div class="loading"></div> 163 {:else if error} 164 <div class="error-container"> 165 <h1>{$_('oauth.error.title')}</h1> 166 <div class="error">{error}</div> 167 <button type="button" onclick={() => navigate(routes.login)}> 168 {$_('common.backToLogin')} 169 </button> 170 </div> 171 {:else if consentData} 172 <div class="split-layout sidebar-left"> 173 <div class="client-panel"> 174 <div class="client-info"> 175 {#if consentData.logo_uri} 176 <img src={consentData.logo_uri} alt="" class="client-logo" /> 177 {/if} 178 <h1>{consentData.client_name || $_('oauth.consent.title')}</h1> 179 <p class="subtitle">{$_('oauth.consent.appWantsAccess', { values: { app: '' } })}</p> 180 {#if consentData.client_uri} 181 <a href={consentData.client_uri} target="_blank" rel="noopener noreferrer" class="client-link"> 182 {consentData.client_uri} 183 </a> 184 {/if} 185 </div> 186 187 <div class="account-info"> 188 {#if consentData.is_delegation} 189 <div class="delegation-badge">{$_('oauthConsent.delegatedAccess')}</div> 190 <div class="delegation-info"> 191 <div class="info-row"> 192 <span class="label">{$_('oauthConsent.actingAs')}</span> 193 <span class="did">{consentData.did}</span> 194 </div> 195 <div class="info-row"> 196 <span class="label">{$_('oauthConsent.controller')}</span> 197 <span class="handle">@{consentData.controller_handle || consentData.controller_did}</span> 198 </div> 199 <div class="info-row"> 200 <span class="label">{$_('oauthConsent.accessLevel')}</span> 201 <span class="level-badge level-{consentData.delegation_level?.toLowerCase()}">{consentData.delegation_level}</span> 202 </div> 203 </div> 204 {#if consentData.delegation_level && consentData.delegation_level !== 'Owner'} 205 <div class="permissions-notice"> 206 <div class="notice-header"> 207 <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg> 208 <span>{$_('oauthConsent.permissionsLimited')}</span> 209 </div> 210 <p class="notice-text"> 211 {#if consentData.delegation_level === 'Viewer'} 212 {$_('oauthConsent.viewerLimitedDesc')} 213 {:else if consentData.delegation_level === 'Editor'} 214 {$_('oauthConsent.editorLimitedDesc')} 215 {:else} 216 {$_('oauthConsent.permissionsLimitedDesc', { values: { level: consentData.delegation_level } })} 217 {/if} 218 </p> 219 </div> 220 {/if} 221 {:else} 222 <span class="label">{$_('oauth.consent.signingInAs')}</span> 223 <span class="did">{consentData.did}</span> 224 {/if} 225 </div> 226 </div> 227 228 <div class="permissions-panel"> 229 <div class="scopes-section"> 230 <h2>{$_('oauth.consent.permissionsRequested')}</h2> 231 {#if consentData.scopes.length === 0} 232 <div class="read-only-notice"> 233 <div class="scope-item read-only"> 234 <div class="scope-info"> 235 <span class="scope-name">{$_('oauthConsent.readOnlyAccess')}</span> 236 <span class="scope-description">{$_('oauthConsent.readOnlyDesc')}</span> 237 </div> 238 </div> 239 </div> 240 {:else} 241 {#each Object.entries(scopeGroups) as [category, scopes]} 242 <div class="scope-group"> 243 <h3 class="category-title">{category}</h3> 244 {#each scopes as scope} 245 <label class="scope-item" class:required={scope.required}> 246 <input 247 type="checkbox" 248 checked={scopeSelections[scope.scope]} 249 disabled={scope.required || submitting} 250 onchange={() => handleScopeToggle(scope.scope)} 251 /> 252 <div class="scope-info"> 253 <span class="scope-name">{scope.display_name}</span> 254 <span class="scope-description">{scope.description}</span> 255 {#if scope.required} 256 <span class="required-badge">{$_('oauth.consent.required')}</span> 257 {/if} 258 </div> 259 </label> 260 {/each} 261 </div> 262 {/each} 263 {/if} 264 </div> 265 266 <label class="remember-choice"> 267 <input type="checkbox" bind:checked={rememberChoice} disabled={submitting} /> 268 <span>{$_('oauth.consent.rememberChoiceLabel')}</span> 269 </label> 270 </div> 271 </div> 272 273 <div class="actions"> 274 <button type="button" class="deny-btn" onclick={handleDeny} disabled={submitting}> 275 {$_('oauth.consent.deny')} 276 </button> 277 <button type="button" class="approve-btn" onclick={submitConsent} disabled={submitting}> 278 {submitting ? $_('oauth.consent.authorizing') : $_('oauth.consent.authorize')} 279 </button> 280 </div> 281 {/if} 282</div> 283 284<style> 285 .consent-container { 286 max-width: var(--width-lg); 287 margin: var(--space-7) auto; 288 padding: var(--space-7); 289 } 290 291 .loading { 292 display: flex; 293 align-items: center; 294 justify-content: center; 295 min-height: 200px; 296 color: var(--text-secondary); 297 } 298 299 .error-container { 300 text-align: center; 301 max-width: var(--width-sm); 302 margin: 0 auto; 303 } 304 305 .error { 306 padding: var(--space-3); 307 background: var(--error-bg); 308 border: 1px solid var(--error-border); 309 border-radius: var(--radius-md); 310 color: var(--error-text); 311 margin-bottom: var(--space-4); 312 } 313 314 .client-panel { 315 display: flex; 316 flex-direction: column; 317 gap: var(--space-5); 318 } 319 320 .permissions-panel { 321 min-width: 0; 322 } 323 324 .client-info { 325 text-align: center; 326 padding: var(--space-6); 327 background: var(--bg-secondary); 328 border-radius: var(--radius-xl); 329 } 330 331 @media (min-width: 800px) { 332 .client-info { 333 text-align: left; 334 } 335 } 336 337 .client-logo { 338 width: 64px; 339 height: 64px; 340 border-radius: var(--radius-xl); 341 margin-bottom: var(--space-4); 342 } 343 344 .client-info h1 { 345 margin: 0 0 var(--space-1) 0; 346 font-size: var(--text-xl); 347 } 348 349 .subtitle { 350 color: var(--text-secondary); 351 margin: 0; 352 } 353 354 .client-link { 355 display: inline-block; 356 margin-top: var(--space-2); 357 font-size: var(--text-sm); 358 color: var(--accent); 359 text-decoration: none; 360 } 361 362 .client-link:hover { 363 text-decoration: underline; 364 } 365 366 .account-info { 367 display: flex; 368 flex-direction: column; 369 gap: var(--space-1); 370 padding: var(--space-4); 371 background: var(--bg-secondary); 372 border-radius: var(--radius-xl); 373 margin-bottom: var(--space-6); 374 } 375 376 .account-info .label { 377 font-size: var(--text-xs); 378 color: var(--text-muted); 379 text-transform: uppercase; 380 letter-spacing: 0.05em; 381 } 382 383 .account-info .did { 384 font-family: monospace; 385 font-size: var(--text-sm); 386 color: var(--text-primary); 387 word-break: break-all; 388 } 389 390 .delegation-badge { 391 display: inline-block; 392 padding: var(--space-1) var(--space-2); 393 background: var(--accent); 394 color: var(--text-inverse); 395 border-radius: var(--radius-md); 396 font-size: var(--text-xs); 397 font-weight: var(--font-semibold); 398 text-transform: uppercase; 399 letter-spacing: 0.05em; 400 margin-bottom: var(--space-3); 401 } 402 403 .delegation-info { 404 display: flex; 405 flex-direction: column; 406 gap: var(--space-2); 407 } 408 409 .delegation-info .info-row { 410 display: flex; 411 flex-direction: column; 412 gap: 2px; 413 } 414 415 .delegation-info .handle { 416 font-weight: var(--font-medium); 417 color: var(--text-primary); 418 } 419 420 .level-badge { 421 display: inline-block; 422 padding: 2px var(--space-2); 423 background: var(--bg-tertiary); 424 color: var(--text-primary); 425 border-radius: var(--radius-sm); 426 font-size: var(--text-sm); 427 font-weight: var(--font-medium); 428 } 429 430 .level-badge.level-owner { 431 background: var(--success-bg); 432 color: var(--success-text); 433 } 434 435 .level-badge.level-admin { 436 background: var(--accent); 437 color: var(--text-inverse); 438 } 439 440 .level-badge.level-editor { 441 background: var(--warning-bg); 442 color: var(--warning-text); 443 } 444 445 .level-badge.level-viewer { 446 background: var(--bg-tertiary); 447 color: var(--text-secondary); 448 } 449 450 .permissions-notice { 451 margin-top: var(--space-3); 452 padding: var(--space-3); 453 background: var(--warning-bg); 454 border: 1px solid var(--warning-border); 455 border-radius: var(--radius-md); 456 } 457 458 .notice-header { 459 display: flex; 460 align-items: center; 461 gap: var(--space-2); 462 font-weight: var(--font-semibold); 463 color: var(--warning-text); 464 margin-bottom: var(--space-2); 465 } 466 467 .notice-header svg { 468 flex-shrink: 0; 469 } 470 471 .notice-text { 472 margin: 0; 473 font-size: var(--text-sm); 474 color: var(--warning-text); 475 line-height: 1.5; 476 } 477 478 .scopes-section { 479 margin-bottom: var(--space-6); 480 } 481 482 .scopes-section h2 { 483 font-size: var(--text-base); 484 margin: 0 0 var(--space-4) 0; 485 color: var(--text-secondary); 486 } 487 488 .scope-group { 489 margin-bottom: var(--space-4); 490 } 491 492 .category-title { 493 font-size: var(--text-sm); 494 font-weight: var(--font-semibold); 495 color: var(--text-primary); 496 margin: 0 0 var(--space-2) 0; 497 padding-bottom: var(--space-1); 498 border-bottom: 1px solid var(--border-color); 499 } 500 501 .scope-item { 502 display: flex; 503 gap: var(--space-3); 504 padding: var(--space-3); 505 background: var(--bg-card); 506 border: 1px solid var(--border-color); 507 border-radius: var(--radius-lg); 508 margin-bottom: var(--space-2); 509 cursor: pointer; 510 transition: border-color var(--transition-fast); 511 } 512 513 .scope-item:hover:not(.required) { 514 border-color: var(--accent); 515 } 516 517 .scope-item.required { 518 background: var(--bg-secondary); 519 } 520 521 .scope-item.read-only { 522 background: var(--bg-secondary); 523 border-style: dashed; 524 } 525 526 .scope-item input[type="checkbox"] { 527 flex-shrink: 0; 528 width: 18px; 529 height: 18px; 530 margin-top: 2px; 531 } 532 533 .scope-info { 534 flex: 1; 535 display: flex; 536 flex-direction: column; 537 gap: 2px; 538 } 539 540 .scope-name { 541 font-weight: var(--font-medium); 542 color: var(--text-primary); 543 } 544 545 .scope-description { 546 font-size: var(--text-sm); 547 color: var(--text-secondary); 548 } 549 550 .required-badge { 551 display: inline-block; 552 font-size: 0.625rem; 553 padding: 2px var(--space-2); 554 background: var(--warning-bg); 555 color: var(--warning-text); 556 border-radius: var(--radius-sm); 557 text-transform: uppercase; 558 letter-spacing: 0.05em; 559 margin-top: var(--space-1); 560 width: fit-content; 561 } 562 563 .remember-choice { 564 display: flex; 565 align-items: center; 566 gap: var(--space-2); 567 margin-top: var(--space-5); 568 cursor: pointer; 569 color: var(--text-secondary); 570 font-size: var(--text-sm); 571 } 572 573 .remember-choice input { 574 width: 16px; 575 height: 16px; 576 } 577 578 .actions { 579 display: flex; 580 gap: var(--space-4); 581 margin-top: var(--space-6); 582 } 583 584 @media (min-width: 800px) { 585 .actions { 586 max-width: 400px; 587 margin-left: auto; 588 } 589 } 590 591 .actions button { 592 flex: 1; 593 padding: var(--space-3); 594 border: none; 595 border-radius: var(--radius-lg); 596 font-size: var(--text-base); 597 font-weight: var(--font-medium); 598 cursor: pointer; 599 transition: background-color var(--transition-fast); 600 } 601 602 .actions button:disabled { 603 opacity: 0.6; 604 cursor: not-allowed; 605 } 606 607 .deny-btn { 608 background: var(--bg-secondary); 609 color: var(--text-primary); 610 border: 1px solid var(--border-color); 611 } 612 613 .deny-btn:hover:not(:disabled) { 614 background: var(--error-bg); 615 border-color: var(--error-border); 616 color: var(--error-text); 617 } 618 619 .approve-btn { 620 background: var(--accent); 621 color: var(--text-inverse); 622 } 623 624 .approve-btn:hover:not(:disabled) { 625 background: var(--accent-hover); 626 } 627</style>