this repo has no description
1<script lang="ts"> 2 import { navigate } from '../lib/router.svelte' 3 import { _ } from '../lib/i18n' 4 5 let delegatedDid = $state<string | null>(null) 6 let delegatedHandle = $state<string | null>(null) 7 let controllerIdentifier = $state('') 8 let controllerDid = $state<string | null>(null) 9 let password = $state('') 10 let rememberDevice = $state(false) 11 let submitting = $state(false) 12 let loading = $state(true) 13 let error = $state<string | null>(null) 14 let hasPasskeys = $state(false) 15 let hasTotp = $state(false) 16 let passkeySupported = $state(false) 17 let step = $state<'identifier' | 'password'>('identifier') 18 19 $effect(() => { 20 passkeySupported = window.PublicKeyCredential !== undefined 21 }) 22 23 function getRequestUri(): string | null { 24 const params = new URLSearchParams(window.location.search) 25 return params.get('request_uri') 26 } 27 28 function getDelegatedDid(): string | null { 29 const params = new URLSearchParams(window.location.search) 30 return params.get('delegated_did') 31 } 32 33 $effect(() => { 34 loadDelegationInfo() 35 }) 36 37 async function loadDelegationInfo() { 38 const requestUri = getRequestUri() 39 delegatedDid = getDelegatedDid() 40 41 if (!requestUri || !delegatedDid) { 42 error = $_('oauthDelegation.missingParams') 43 loading = false 44 return 45 } 46 47 try { 48 const response = await fetch(`/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(delegatedDid.replace('did:', ''))}`) 49 if (response.ok) { 50 const data = await response.json() 51 delegatedHandle = data.handle || delegatedDid 52 } else { 53 const handleResponse = await fetch(`/xrpc/com.atproto.repo.describeRepo?repo=${encodeURIComponent(delegatedDid)}`) 54 if (handleResponse.ok) { 55 const data = await handleResponse.json() 56 delegatedHandle = data.handle || delegatedDid 57 } else { 58 delegatedHandle = delegatedDid 59 } 60 } 61 } catch { 62 delegatedHandle = delegatedDid 63 } finally { 64 loading = false 65 } 66 } 67 68 async function handleIdentifierSubmit(e: Event) { 69 e.preventDefault() 70 if (!controllerIdentifier.trim()) return 71 72 submitting = true 73 error = null 74 75 try { 76 let resolvedDid = controllerIdentifier.trim() 77 if (!resolvedDid.startsWith('did:')) { 78 resolvedDid = resolvedDid.replace(/^@/, '') 79 const response = await fetch(`/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(resolvedDid)}`) 80 if (!response.ok) { 81 error = $_('oauthDelegation.controllerNotFound') 82 submitting = false 83 return 84 } 85 const data = await response.json() 86 resolvedDid = data.did 87 } 88 89 controllerDid = resolvedDid 90 91 const securityResponse = await fetch(`/oauth/security-status?identifier=${encodeURIComponent(controllerIdentifier.trim().replace(/^@/, ''))}`) 92 if (securityResponse.ok) { 93 const data = await securityResponse.json() 94 hasPasskeys = passkeySupported && data.hasPasskeys === true 95 hasTotp = data.hasTotp === true 96 } 97 98 step = 'password' 99 } catch { 100 error = $_('oauthDelegation.controllerNotFound') 101 } finally { 102 submitting = false 103 } 104 } 105 106 function arrayBufferToBase64Url(buffer: ArrayBuffer): string { 107 const bytes = new Uint8Array(buffer) 108 let binary = '' 109 for (let i = 0; i < bytes.byteLength; i++) { 110 binary += String.fromCharCode(bytes[i]) 111 } 112 return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '') 113 } 114 115 function base64UrlToArrayBuffer(base64url: string): ArrayBuffer { 116 const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/') 117 const padded = base64 + '='.repeat((4 - base64.length % 4) % 4) 118 const binary = atob(padded) 119 const bytes = new Uint8Array(binary.length) 120 for (let i = 0; i < binary.length; i++) { 121 bytes[i] = binary.charCodeAt(i) 122 } 123 return bytes.buffer 124 } 125 126 function prepareCredentialRequestOptions(options: any): PublicKeyCredentialRequestOptions { 127 return { 128 ...options, 129 challenge: base64UrlToArrayBuffer(options.challenge), 130 allowCredentials: options.allowCredentials?.map((cred: any) => ({ 131 ...cred, 132 id: base64UrlToArrayBuffer(cred.id) 133 })) || [] 134 } 135 } 136 137 async function handlePasskeyLogin() { 138 const requestUri = getRequestUri() 139 if (!requestUri || !controllerDid || !delegatedDid) { 140 error = $_('oauthDelegation.missingInfo') 141 return 142 } 143 144 submitting = true 145 error = null 146 147 try { 148 const startResponse = await fetch('/oauth/passkey/start', { 149 method: 'POST', 150 headers: { 151 'Content-Type': 'application/json', 152 'Accept': 'application/json' 153 }, 154 body: JSON.stringify({ 155 request_uri: requestUri, 156 identifier: controllerIdentifier.trim().replace(/^@/, '') 157 }) 158 }) 159 160 if (!startResponse.ok) { 161 const data = await startResponse.json() 162 error = data.error_description || data.error || $_('oauthDelegation.failedPasskeyStart') 163 submitting = false 164 return 165 } 166 167 const { options } = await startResponse.json() 168 169 const credential = await navigator.credentials.get({ 170 publicKey: prepareCredentialRequestOptions(options.publicKey) 171 }) as PublicKeyCredential | null 172 173 if (!credential) { 174 error = $_('oauthDelegation.passkeyCancelled') 175 submitting = false 176 return 177 } 178 179 const assertionResponse = credential.response as AuthenticatorAssertionResponse 180 const credentialData = { 181 id: credential.id, 182 type: credential.type, 183 rawId: arrayBufferToBase64Url(credential.rawId), 184 response: { 185 clientDataJSON: arrayBufferToBase64Url(assertionResponse.clientDataJSON), 186 authenticatorData: arrayBufferToBase64Url(assertionResponse.authenticatorData), 187 signature: arrayBufferToBase64Url(assertionResponse.signature), 188 userHandle: assertionResponse.userHandle ? arrayBufferToBase64Url(assertionResponse.userHandle) : null 189 } 190 } 191 192 const finishResponse = await fetch('/oauth/passkey/finish', { 193 method: 'POST', 194 headers: { 195 'Content-Type': 'application/json', 196 'Accept': 'application/json' 197 }, 198 body: JSON.stringify({ 199 request_uri: requestUri, 200 identifier: controllerIdentifier.trim().replace(/^@/, ''), 201 credential: credentialData, 202 delegated_did: delegatedDid, 203 controller_did: controllerDid 204 }) 205 }) 206 207 const data = await finishResponse.json() 208 209 if (!finishResponse.ok || data.success === false || data.error) { 210 error = data.error_description || data.error || $_('oauthDelegation.passkeyFailed') 211 submitting = false 212 return 213 } 214 215 if (data.needs_totp) { 216 navigate(`/oauth/totp?request_uri=${encodeURIComponent(requestUri)}`) 217 return 218 } 219 220 if (data.needs_2fa) { 221 navigate(`/oauth/2fa?request_uri=${encodeURIComponent(requestUri)}&channel=${encodeURIComponent(data.channel || '')}`) 222 return 223 } 224 225 if (data.redirect_uri) { 226 window.location.href = data.redirect_uri 227 return 228 } 229 230 error = $_('oauthDelegation.unexpectedResponse') 231 submitting = false 232 } catch (e) { 233 console.error('Passkey login error:', e) 234 error = $_('oauthDelegation.authFailed') 235 submitting = false 236 } 237 } 238 239 async function handlePasswordSubmit(e: Event) { 240 e.preventDefault() 241 const requestUri = getRequestUri() 242 if (!requestUri || !controllerDid || !delegatedDid) { 243 error = $_('oauthDelegation.missingInfo') 244 return 245 } 246 247 submitting = true 248 error = null 249 250 try { 251 const response = await fetch('/oauth/delegation/auth', { 252 method: 'POST', 253 headers: { 254 'Content-Type': 'application/json', 255 'Accept': 'application/json' 256 }, 257 body: JSON.stringify({ 258 request_uri: requestUri, 259 delegated_did: delegatedDid, 260 controller_did: controllerDid, 261 password, 262 remember_device: rememberDevice 263 }) 264 }) 265 266 const data = await response.json() 267 268 if (!response.ok || data.success === false || data.error) { 269 error = data.error_description || data.error || $_('oauthDelegation.authFailed') 270 submitting = false 271 return 272 } 273 274 if (data.needs_totp) { 275 navigate(`/oauth/totp?request_uri=${encodeURIComponent(requestUri)}`) 276 return 277 } 278 279 if (data.needs_2fa) { 280 navigate(`/oauth/2fa?request_uri=${encodeURIComponent(requestUri)}&channel=${encodeURIComponent(data.channel || '')}`) 281 return 282 } 283 284 if (data.redirect_uri) { 285 window.location.href = data.redirect_uri 286 return 287 } 288 289 error = $_('oauthDelegation.unexpectedResponse') 290 submitting = false 291 } catch { 292 error = $_('oauthDelegation.authFailed') 293 submitting = false 294 } 295 } 296 297 async function handleCancel() { 298 const requestUri = getRequestUri() 299 if (!requestUri) { 300 window.history.back() 301 return 302 } 303 304 submitting = true 305 try { 306 const response = await fetch('/oauth/authorize/deny', { 307 method: 'POST', 308 headers: { 309 'Content-Type': 'application/json', 310 'Accept': 'application/json' 311 }, 312 body: JSON.stringify({ request_uri: requestUri }) 313 }) 314 315 const data = await response.json() 316 if (data.redirect_uri) { 317 window.location.href = data.redirect_uri 318 } 319 } catch { 320 window.history.back() 321 } 322 } 323 324 function goBack() { 325 step = 'identifier' 326 password = '' 327 error = null 328 } 329</script> 330 331<div class="delegation-container"> 332 {#if loading} 333 <div class="loading"> 334 <p>{$_('oauthDelegation.loading')}</p> 335 </div> 336 {:else if step === 'identifier'} 337 <header class="page-header"> 338 <h1>{$_('oauthDelegation.title')}</h1> 339 <p class="subtitle"> 340 {$_('oauthDelegation.isDelegated', { values: { handle: delegatedHandle } })} 341 <br />{$_('oauthDelegation.enterControllerHandle')} 342 </p> 343 </header> 344 345 {#if error} 346 <div class="error">{error}</div> 347 {/if} 348 349 <form onsubmit={handleIdentifierSubmit}> 350 <div class="field"> 351 <label for="controller-identifier">{$_('oauthDelegation.controllerHandle')}</label> 352 <input 353 id="controller-identifier" 354 type="text" 355 bind:value={controllerIdentifier} 356 disabled={submitting} 357 required 358 autocomplete="username" 359 placeholder={$_('oauthDelegation.handlePlaceholder')} 360 /> 361 </div> 362 363 <div class="actions"> 364 <button type="button" class="cancel-btn" onclick={handleCancel} disabled={submitting}> 365 {$_('common.cancel')} 366 </button> 367 <button type="submit" class="submit-btn" disabled={submitting || !controllerIdentifier.trim()}> 368 {submitting ? $_('oauthDelegation.checking') : $_('common.continue')} 369 </button> 370 </div> 371 </form> 372 {:else if step === 'password'} 373 <header class="page-header"> 374 <h1>{$_('oauthDelegation.signInAsController')}</h1> 375 <p class="subtitle"> 376 {$_('oauthDelegation.authenticateAs', { values: { controller: '@' + controllerIdentifier.replace(/^@/, ''), delegated: delegatedHandle } })} 377 </p> 378 </header> 379 380 {#if error} 381 <div class="error">{error}</div> 382 {/if} 383 384 <button class="back-link" onclick={goBack} disabled={submitting}> 385 &larr; {$_('oauthDelegation.useDifferentController')} 386 </button> 387 388 <form onsubmit={handlePasswordSubmit}> 389 {#if passkeySupported && hasPasskeys} 390 <div class="auth-methods"> 391 <div class="passkey-method"> 392 <h3>{$_('oauthDelegation.signInWithPasskey')}</h3> 393 <button 394 type="button" 395 class="passkey-btn" 396 onclick={handlePasskeyLogin} 397 disabled={submitting} 398 > 399 <svg class="passkey-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> 400 <path d="M15 7a4 4 0 1 0-8 0 4 4 0 0 0 8 0z" /> 401 <path d="M17 17v4l3-2-3-2z" /> 402 <path d="M12 11c-4 0-6 2-6 4v4h9" /> 403 </svg> 404 <span class="passkey-text"> 405 {submitting ? $_('oauthDelegation.authenticating') : $_('oauthDelegation.usePasskey')} 406 </span> 407 </button> 408 </div> 409 410 <div class="method-divider"> 411 <span>{$_('oauthDelegation.or')}</span> 412 </div> 413 414 <div class="password-method"> 415 <h3>{$_('oauthDelegation.password')}</h3> 416 <div class="field"> 417 <input 418 type="password" 419 bind:value={password} 420 disabled={submitting} 421 required 422 autocomplete="current-password" 423 placeholder={$_('oauthDelegation.enterPassword')} 424 /> 425 </div> 426 427 <label class="remember-device"> 428 <input type="checkbox" bind:checked={rememberDevice} disabled={submitting} /> 429 <span>{$_('oauthDelegation.rememberDevice')}</span> 430 </label> 431 432 <button type="submit" class="submit-btn" disabled={submitting || !password}> 433 {submitting ? $_('oauthDelegation.signingIn') : $_('oauthDelegation.signIn')} 434 </button> 435 </div> 436 </div> 437 {:else} 438 <div class="field"> 439 <label for="password">{$_('oauthDelegation.password')}</label> 440 <input 441 id="password" 442 type="password" 443 bind:value={password} 444 disabled={submitting} 445 required 446 autocomplete="current-password" 447 /> 448 </div> 449 450 <label class="remember-device"> 451 <input type="checkbox" bind:checked={rememberDevice} disabled={submitting} /> 452 <span>{$_('oauthDelegation.rememberDevice')}</span> 453 </label> 454 455 <div class="actions"> 456 <button type="button" class="cancel-btn" onclick={handleCancel} disabled={submitting}> 457 {$_('common.cancel')} 458 </button> 459 <button type="submit" class="submit-btn" disabled={submitting || !password}> 460 {submitting ? $_('oauthDelegation.signingIn') : $_('oauthDelegation.signIn')} 461 </button> 462 </div> 463 {/if} 464 </form> 465 {:else} 466 <header class="page-header"> 467 <h1>{$_('oauthDelegation.title')}</h1> 468 </header> 469 <div class="error">{error || $_('oauthDelegation.unableToLoad')}</div> 470 <div class="actions"> 471 <button type="button" class="cancel-btn" onclick={handleCancel}> 472 {$_('oauthDelegation.goBack')} 473 </button> 474 </div> 475 {/if} 476</div> 477 478<style> 479 .delegation-container { 480 max-width: var(--width-md); 481 margin: var(--space-9) auto; 482 padding: var(--space-7); 483 } 484 485 .loading { 486 display: flex; 487 align-items: center; 488 justify-content: center; 489 min-height: 200px; 490 color: var(--text-secondary); 491 } 492 493 .page-header { 494 margin-bottom: var(--space-6); 495 } 496 497 h1 { 498 margin: 0 0 var(--space-2) 0; 499 } 500 501 .subtitle { 502 color: var(--text-secondary); 503 margin: 0; 504 line-height: 1.6; 505 } 506 507 .back-link { 508 display: inline-flex; 509 align-items: center; 510 padding: var(--space-2) 0; 511 background: none; 512 border: none; 513 color: var(--accent); 514 font-size: var(--text-sm); 515 cursor: pointer; 516 margin-bottom: var(--space-4); 517 } 518 519 .back-link:hover:not(:disabled) { 520 text-decoration: underline; 521 } 522 523 .back-link:disabled { 524 opacity: 0.6; 525 cursor: not-allowed; 526 } 527 528 form { 529 display: flex; 530 flex-direction: column; 531 gap: var(--space-4); 532 } 533 534 .auth-methods { 535 display: grid; 536 grid-template-columns: 1fr; 537 gap: var(--space-5); 538 margin-top: var(--space-4); 539 } 540 541 @media (min-width: 600px) { 542 .auth-methods { 543 grid-template-columns: 1fr auto 1fr; 544 align-items: start; 545 } 546 } 547 548 .passkey-method, 549 .password-method { 550 display: flex; 551 flex-direction: column; 552 gap: var(--space-4); 553 padding: var(--space-5); 554 background: var(--bg-secondary); 555 border-radius: var(--radius-xl); 556 } 557 558 .passkey-method h3, 559 .password-method h3 { 560 margin: 0; 561 font-size: var(--text-sm); 562 font-weight: var(--font-semibold); 563 color: var(--text-secondary); 564 text-transform: uppercase; 565 letter-spacing: 0.05em; 566 } 567 568 .method-divider { 569 display: flex; 570 align-items: center; 571 justify-content: center; 572 color: var(--text-muted); 573 font-size: var(--text-sm); 574 } 575 576 @media (min-width: 600px) { 577 .method-divider { 578 flex-direction: column; 579 padding: 0 var(--space-3); 580 } 581 582 .method-divider::before, 583 .method-divider::after { 584 content: ''; 585 width: 1px; 586 height: var(--space-6); 587 background: var(--border-color); 588 } 589 590 .method-divider span { 591 writing-mode: vertical-rl; 592 text-orientation: mixed; 593 transform: rotate(180deg); 594 padding: var(--space-2) 0; 595 } 596 } 597 598 @media (max-width: 599px) { 599 .method-divider { 600 gap: var(--space-4); 601 } 602 603 .method-divider::before, 604 .method-divider::after { 605 content: ''; 606 flex: 1; 607 height: 1px; 608 background: var(--border-color); 609 } 610 } 611 612 .field { 613 display: flex; 614 flex-direction: column; 615 gap: var(--space-1); 616 } 617 618 label { 619 font-size: var(--text-sm); 620 font-weight: var(--font-medium); 621 } 622 623 input[type="password"], 624 input[type="text"] { 625 padding: var(--space-3); 626 border: 1px solid var(--border-color); 627 border-radius: var(--radius-md); 628 font-size: var(--text-base); 629 background: var(--bg-input); 630 color: var(--text-primary); 631 } 632 633 input:focus { 634 outline: none; 635 border-color: var(--accent); 636 } 637 638 .remember-device { 639 display: flex; 640 align-items: center; 641 gap: var(--space-2); 642 cursor: pointer; 643 color: var(--text-secondary); 644 font-size: var(--text-sm); 645 } 646 647 .remember-device input { 648 width: 16px; 649 height: 16px; 650 } 651 652 .error { 653 padding: var(--space-3); 654 background: var(--error-bg); 655 border: 1px solid var(--error-border); 656 border-radius: var(--radius-md); 657 color: var(--error-text); 658 margin-bottom: var(--space-4); 659 } 660 661 .actions { 662 display: flex; 663 gap: var(--space-4); 664 margin-top: var(--space-2); 665 } 666 667 .actions button { 668 flex: 1; 669 padding: var(--space-3); 670 border: none; 671 border-radius: var(--radius-md); 672 font-size: var(--text-base); 673 cursor: pointer; 674 transition: background-color var(--transition-fast); 675 } 676 677 .actions button:disabled { 678 opacity: 0.6; 679 cursor: not-allowed; 680 } 681 682 .cancel-btn { 683 background: var(--bg-secondary); 684 color: var(--text-primary); 685 border: 1px solid var(--border-color); 686 } 687 688 .cancel-btn:hover:not(:disabled) { 689 background: var(--error-bg); 690 border-color: var(--error-border); 691 color: var(--error-text); 692 } 693 694 .submit-btn { 695 background: var(--accent); 696 color: var(--text-inverse); 697 } 698 699 .submit-btn:hover:not(:disabled) { 700 background: var(--accent-hover); 701 } 702 703 .passkey-btn { 704 display: flex; 705 align-items: center; 706 justify-content: center; 707 gap: var(--space-2); 708 width: 100%; 709 padding: var(--space-3); 710 background: var(--accent); 711 color: var(--text-inverse); 712 border: 1px solid var(--accent); 713 border-radius: var(--radius-md); 714 font-size: var(--text-base); 715 cursor: pointer; 716 transition: background-color var(--transition-fast), border-color var(--transition-fast); 717 } 718 719 .passkey-btn:hover:not(:disabled) { 720 background: var(--accent-hover); 721 border-color: var(--accent-hover); 722 } 723 724 .passkey-btn:disabled { 725 opacity: 0.6; 726 cursor: not-allowed; 727 } 728 729 .passkey-icon { 730 width: 20px; 731 height: 20px; 732 } 733 734 .passkey-text { 735 flex: 1; 736 text-align: left; 737 } 738</style>