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