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