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