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