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