Our Personal Data Server from scratch!
0

Configure Feed

Select the types of activity you want to include in your feed.

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