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