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 <h1>{$_('oauth.login.title')}</h1> 319 <p class="subtitle"> 320 {#if clientName} 321 {$_('oauth.login.subtitle')} <strong>{clientName}</strong> 322 {:else} 323 {$_('oauth.login.subtitle')} 324 {/if} 325 </p> 326 327 {#if error} 328 <div class="error">{error}</div> 329 {/if} 330 331 <form onsubmit={handleSubmit}> 332 <div class="field"> 333 <label for="username">{$_('register.handle')}</label> 334 <input 335 id="username" 336 type="text" 337 bind:value={username} 338 placeholder={$_('register.emailPlaceholder')} 339 disabled={submitting} 340 required 341 autocomplete="username" 342 /> 343 </div> 344 345 {#if passkeySupported && username.length >= 3} 346 <button 347 type="button" 348 class="passkey-btn" 349 class:passkey-unavailable={!hasPasskeys || checkingSecurityStatus || !securityStatusChecked} 350 onclick={handlePasskeyLogin} 351 disabled={submitting || !hasPasskeys || !username || checkingSecurityStatus || !securityStatusChecked} 352 title={checkingSecurityStatus ? $_('oauth.login.passkeyHintChecking') : hasPasskeys ? $_('oauth.login.passkeyHintAvailable') : $_('oauth.login.passkeyHintNotAvailable')} 353 > 354 <svg class="passkey-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> 355 <path d="M15 7a4 4 0 1 0-8 0 4 4 0 0 0 8 0z" /> 356 <path d="M17 17v4l3-2-3-2z" /> 357 <path d="M12 11c-4 0-6 2-6 4v4h9" /> 358 </svg> 359 <span class="passkey-text"> 360 {#if submitting} 361 {$_('oauth.login.authenticating')} 362 {:else if checkingSecurityStatus || !securityStatusChecked} 363 {$_('oauth.login.checkingPasskey')} 364 {:else if hasPasskeys} 365 {$_('oauth.login.signInWithPasskey')} 366 {:else} 367 {$_('oauth.login.passkeyNotSetUp')} 368 {/if} 369 </span> 370 </button> 371 372 <div class="auth-divider"> 373 <span>{$_('oauth.login.orUsePassword')}</span> 374 </div> 375 {/if} 376 377 <div class="field"> 378 <label for="password">{$_('oauth.login.password')}</label> 379 <input 380 id="password" 381 type="password" 382 bind:value={password} 383 disabled={submitting} 384 required 385 autocomplete="current-password" 386 /> 387 </div> 388 389 <label class="remember-device"> 390 <input type="checkbox" bind:checked={rememberDevice} disabled={submitting} /> 391 <span>{$_('oauth.login.rememberDevice')}</span> 392 </label> 393 394 <div class="actions"> 395 <button type="button" class="cancel-btn" onclick={handleCancel} disabled={submitting}> 396 {$_('common.cancel')} 397 </button> 398 <button type="submit" class="submit-btn" disabled={submitting || !username || !password}> 399 {submitting ? $_('oauth.login.signingIn') : $_('oauth.login.title')} 400 </button> 401 </div> 402 </form> 403 404 <p class="help-links"> 405 <a href="#/reset-password">{$_('login.forgotPassword')}</a> &middot; <a href="#/request-passkey-recovery">{$_('login.lostPasskey')}</a> 406 </p> 407</div> 408 409<style> 410 .help-links { 411 text-align: center; 412 margin-top: var(--space-4); 413 font-size: var(--text-sm); 414 } 415 416 .help-links a { 417 color: var(--accent); 418 text-decoration: none; 419 } 420 421 .help-links a:hover { 422 text-decoration: underline; 423 } 424 425 .oauth-login-container { 426 max-width: var(--width-sm); 427 margin: var(--space-9) auto; 428 padding: var(--space-7); 429 } 430 431 h1 { 432 margin: 0 0 var(--space-2) 0; 433 } 434 435 .subtitle { 436 color: var(--text-secondary); 437 margin: 0 0 var(--space-7) 0; 438 } 439 440 form { 441 display: flex; 442 flex-direction: column; 443 gap: var(--space-4); 444 } 445 446 .field { 447 display: flex; 448 flex-direction: column; 449 gap: var(--space-1); 450 } 451 452 label { 453 font-size: var(--text-sm); 454 font-weight: var(--font-medium); 455 } 456 457 input[type="text"], 458 input[type="password"] { 459 padding: var(--space-3); 460 border: 1px solid var(--border-color); 461 border-radius: var(--radius-md); 462 font-size: var(--text-base); 463 background: var(--bg-input); 464 color: var(--text-primary); 465 } 466 467 input:focus { 468 outline: none; 469 border-color: var(--accent); 470 } 471 472 .remember-device { 473 display: flex; 474 align-items: center; 475 gap: var(--space-2); 476 cursor: pointer; 477 color: var(--text-secondary); 478 font-size: var(--text-sm); 479 } 480 481 .remember-device input { 482 width: 16px; 483 height: 16px; 484 } 485 486 .error { 487 padding: var(--space-3); 488 background: var(--error-bg); 489 border: 1px solid var(--error-border); 490 border-radius: var(--radius-md); 491 color: var(--error-text); 492 margin-bottom: var(--space-4); 493 } 494 495 .actions { 496 display: flex; 497 gap: var(--space-4); 498 margin-top: var(--space-2); 499 } 500 501 .actions button { 502 flex: 1; 503 padding: var(--space-3); 504 border: none; 505 border-radius: var(--radius-md); 506 font-size: var(--text-base); 507 cursor: pointer; 508 transition: background-color var(--transition-fast); 509 } 510 511 .actions button:disabled { 512 opacity: 0.6; 513 cursor: not-allowed; 514 } 515 516 .cancel-btn { 517 background: var(--bg-secondary); 518 color: var(--text-primary); 519 border: 1px solid var(--border-color); 520 } 521 522 .cancel-btn:hover:not(:disabled) { 523 background: var(--error-bg); 524 border-color: var(--error-border); 525 color: var(--error-text); 526 } 527 528 .submit-btn { 529 background: var(--accent); 530 color: var(--text-inverse); 531 } 532 533 .submit-btn:hover:not(:disabled) { 534 background: var(--accent-hover); 535 } 536 537 .auth-divider { 538 display: flex; 539 align-items: center; 540 gap: var(--space-4); 541 margin: var(--space-2) 0; 542 } 543 544 .auth-divider::before, 545 .auth-divider::after { 546 content: ''; 547 flex: 1; 548 height: 1px; 549 background: var(--border-color); 550 } 551 552 .auth-divider span { 553 color: var(--text-secondary); 554 font-size: var(--text-sm); 555 } 556 557 .passkey-btn { 558 display: flex; 559 align-items: center; 560 justify-content: center; 561 gap: var(--space-2); 562 width: 100%; 563 padding: var(--space-3); 564 background: var(--accent); 565 color: var(--text-inverse); 566 border: 1px solid var(--accent); 567 border-radius: var(--radius-md); 568 font-size: var(--text-base); 569 cursor: pointer; 570 transition: background-color var(--transition-fast), border-color var(--transition-fast), opacity var(--transition-fast); 571 } 572 573 .passkey-btn:hover:not(:disabled) { 574 background: var(--accent-hover); 575 border-color: var(--accent-hover); 576 } 577 578 .passkey-btn:disabled { 579 opacity: 0.6; 580 cursor: not-allowed; 581 } 582 583 .passkey-btn.passkey-unavailable { 584 background: var(--bg-secondary); 585 color: var(--text-secondary); 586 border-color: var(--border-color); 587 } 588 589 .passkey-icon { 590 width: 20px; 591 height: 20px; 592 } 593 594 .passkey-text { 595 flex: 1; 596 text-align: left; 597 } 598</style>