this repo has no description
1<script lang="ts"> 2 import { getAuthState } from '../lib/auth.svelte' 3 import { navigate } from '../lib/router.svelte' 4 import { api, ApiError } from '../lib/api' 5 import ReauthModal from '../components/ReauthModal.svelte' 6 7 const auth = getAuthState() 8 let message = $state<{ type: 'success' | 'error'; text: string } | null>(null) 9 let loading = $state(true) 10 let totpEnabled = $state(false) 11 let hasBackupCodes = $state(false) 12 let setupStep = $state<'idle' | 'qr' | 'verify' | 'backup'>('idle') 13 let qrBase64 = $state('') 14 let totpUri = $state('') 15 let verifyCodeRaw = $state('') 16 let verifyCode = $derived(verifyCodeRaw.replace(/\s/g, '')) 17 let verifyLoading = $state(false) 18 let backupCodes = $state<string[]>([]) 19 let disablePassword = $state('') 20 let disableCode = $state('') 21 let disableLoading = $state(false) 22 let showDisableForm = $state(false) 23 let regenPassword = $state('') 24 let regenCode = $state('') 25 let regenLoading = $state(false) 26 let showRegenForm = $state(false) 27 28 interface Passkey { 29 id: string 30 credentialId: string 31 friendlyName: string | null 32 createdAt: string 33 lastUsed: string | null 34 } 35 let passkeys = $state<Passkey[]>([]) 36 let passkeysLoading = $state(true) 37 let addingPasskey = $state(false) 38 let newPasskeyName = $state('') 39 let editingPasskeyId = $state<string | null>(null) 40 let editPasskeyName = $state('') 41 42 let hasPassword = $state(true) 43 let passwordLoading = $state(true) 44 let showRemovePasswordForm = $state(false) 45 let removePasswordLoading = $state(false) 46 47 let showReauthModal = $state(false) 48 let reauthMethods = $state<string[]>(['password']) 49 let pendingAction = $state<(() => Promise<void>) | null>(null) 50 51 $effect(() => { 52 if (!auth.loading && !auth.session) { 53 navigate('/login') 54 } 55 }) 56 57 $effect(() => { 58 if (auth.session) { 59 loadTotpStatus() 60 loadPasskeys() 61 loadPasswordStatus() 62 } 63 }) 64 65 async function loadPasswordStatus() { 66 if (!auth.session) return 67 passwordLoading = true 68 try { 69 const status = await api.getPasswordStatus(auth.session.accessJwt) 70 hasPassword = status.hasPassword 71 } catch { 72 hasPassword = true 73 } finally { 74 passwordLoading = false 75 } 76 } 77 78 async function handleRemovePassword() { 79 if (!auth.session) return 80 removePasswordLoading = true 81 try { 82 await api.removePassword(auth.session.accessJwt) 83 hasPassword = false 84 showRemovePasswordForm = false 85 showMessage('success', 'Password removed. Your account is now passkey-only.') 86 } catch (e) { 87 if (e instanceof ApiError) { 88 if (e.error === 'ReauthRequired') { 89 reauthMethods = e.reauthMethods || ['password'] 90 pendingAction = handleRemovePassword 91 showReauthModal = true 92 } else { 93 showMessage('error', e.message) 94 } 95 } else { 96 showMessage('error', 'Failed to remove password') 97 } 98 } finally { 99 removePasswordLoading = false 100 } 101 } 102 103 function handleReauthSuccess() { 104 if (pendingAction) { 105 pendingAction() 106 pendingAction = null 107 } 108 } 109 110 function handleReauthCancel() { 111 pendingAction = null 112 } 113 114 async function loadTotpStatus() { 115 if (!auth.session) return 116 loading = true 117 try { 118 const status = await api.getTotpStatus(auth.session.accessJwt) 119 totpEnabled = status.enabled 120 hasBackupCodes = status.hasBackupCodes 121 } catch { 122 showMessage('error', 'Failed to load TOTP status') 123 } finally { 124 loading = false 125 } 126 } 127 128 function showMessage(type: 'success' | 'error', text: string) { 129 message = { type, text } 130 setTimeout(() => { 131 if (message?.text === text) message = null 132 }, 5000) 133 } 134 135 async function handleStartSetup() { 136 if (!auth.session) return 137 verifyLoading = true 138 try { 139 const result = await api.createTotpSecret(auth.session.accessJwt) 140 qrBase64 = result.qrBase64 141 totpUri = result.uri 142 setupStep = 'qr' 143 } catch (e) { 144 showMessage('error', e instanceof ApiError ? e.message : 'Failed to generate TOTP secret') 145 } finally { 146 verifyLoading = false 147 } 148 } 149 150 async function handleVerifySetup(e: Event) { 151 e.preventDefault() 152 if (!auth.session || !verifyCode) return 153 verifyLoading = true 154 try { 155 const result = await api.enableTotp(auth.session.accessJwt, verifyCode) 156 backupCodes = result.backupCodes 157 setupStep = 'backup' 158 totpEnabled = true 159 hasBackupCodes = true 160 verifyCodeRaw = '' 161 } catch (e) { 162 showMessage('error', e instanceof ApiError ? e.message : 'Invalid code. Please try again.') 163 } finally { 164 verifyLoading = false 165 } 166 } 167 168 function handleFinishSetup() { 169 setupStep = 'idle' 170 backupCodes = [] 171 qrBase64 = '' 172 totpUri = '' 173 showMessage('success', 'Two-factor authentication enabled successfully') 174 } 175 176 async function handleDisable(e: Event) { 177 e.preventDefault() 178 if (!auth.session || !disablePassword || !disableCode) return 179 disableLoading = true 180 try { 181 await api.disableTotp(auth.session.accessJwt, disablePassword, disableCode) 182 totpEnabled = false 183 hasBackupCodes = false 184 showDisableForm = false 185 disablePassword = '' 186 disableCode = '' 187 showMessage('success', 'Two-factor authentication disabled') 188 } catch (e) { 189 showMessage('error', e instanceof ApiError ? e.message : 'Failed to disable TOTP') 190 } finally { 191 disableLoading = false 192 } 193 } 194 195 async function handleRegenerate(e: Event) { 196 e.preventDefault() 197 if (!auth.session || !regenPassword || !regenCode) return 198 regenLoading = true 199 try { 200 const result = await api.regenerateBackupCodes(auth.session.accessJwt, regenPassword, regenCode) 201 backupCodes = result.backupCodes 202 setupStep = 'backup' 203 showRegenForm = false 204 regenPassword = '' 205 regenCode = '' 206 } catch (e) { 207 showMessage('error', e instanceof ApiError ? e.message : 'Failed to regenerate backup codes') 208 } finally { 209 regenLoading = false 210 } 211 } 212 213 function copyBackupCodes() { 214 const text = backupCodes.join('\n') 215 navigator.clipboard.writeText(text) 216 showMessage('success', 'Backup codes copied to clipboard') 217 } 218 219 async function loadPasskeys() { 220 if (!auth.session) return 221 passkeysLoading = true 222 try { 223 const result = await api.listPasskeys(auth.session.accessJwt) 224 passkeys = result.passkeys 225 } catch { 226 showMessage('error', 'Failed to load passkeys') 227 } finally { 228 passkeysLoading = false 229 } 230 } 231 232 async function handleAddPasskey() { 233 if (!auth.session) return 234 if (!window.PublicKeyCredential) { 235 showMessage('error', 'Passkeys are not supported in this browser') 236 return 237 } 238 addingPasskey = true 239 try { 240 const { options } = await api.startPasskeyRegistration(auth.session.accessJwt, newPasskeyName || undefined) 241 const publicKeyOptions = preparePublicKeyOptions(options) 242 const credential = await navigator.credentials.create({ 243 publicKey: publicKeyOptions 244 }) 245 if (!credential) { 246 showMessage('error', 'Passkey creation was cancelled') 247 return 248 } 249 const credentialResponse = { 250 id: credential.id, 251 type: credential.type, 252 rawId: arrayBufferToBase64Url((credential as PublicKeyCredential).rawId), 253 response: { 254 clientDataJSON: arrayBufferToBase64Url((credential as PublicKeyCredential).response.clientDataJSON), 255 attestationObject: arrayBufferToBase64Url(((credential as PublicKeyCredential).response as AuthenticatorAttestationResponse).attestationObject), 256 }, 257 } 258 await api.finishPasskeyRegistration(auth.session.accessJwt, credentialResponse, newPasskeyName || undefined) 259 await loadPasskeys() 260 newPasskeyName = '' 261 showMessage('success', 'Passkey added successfully') 262 } catch (e) { 263 if (e instanceof DOMException && e.name === 'NotAllowedError') { 264 showMessage('error', 'Passkey creation was cancelled') 265 } else { 266 showMessage('error', e instanceof ApiError ? e.message : 'Failed to add passkey') 267 } 268 } finally { 269 addingPasskey = false 270 } 271 } 272 273 async function handleDeletePasskey(id: string) { 274 if (!auth.session) return 275 if (!confirm('Are you sure you want to delete this passkey?')) return 276 try { 277 await api.deletePasskey(auth.session.accessJwt, id) 278 await loadPasskeys() 279 showMessage('success', 'Passkey deleted') 280 } catch (e) { 281 showMessage('error', e instanceof ApiError ? e.message : 'Failed to delete passkey') 282 } 283 } 284 285 async function handleSavePasskeyName() { 286 if (!auth.session || !editingPasskeyId || !editPasskeyName.trim()) return 287 try { 288 await api.updatePasskey(auth.session.accessJwt, editingPasskeyId, editPasskeyName.trim()) 289 await loadPasskeys() 290 editingPasskeyId = null 291 editPasskeyName = '' 292 showMessage('success', 'Passkey renamed') 293 } catch (e) { 294 showMessage('error', e instanceof ApiError ? e.message : 'Failed to rename passkey') 295 } 296 } 297 298 function startEditPasskey(passkey: Passkey) { 299 editingPasskeyId = passkey.id 300 editPasskeyName = passkey.friendlyName || '' 301 } 302 303 function cancelEditPasskey() { 304 editingPasskeyId = null 305 editPasskeyName = '' 306 } 307 308 function arrayBufferToBase64Url(buffer: ArrayBuffer): string { 309 const bytes = new Uint8Array(buffer) 310 let binary = '' 311 for (let i = 0; i < bytes.byteLength; i++) { 312 binary += String.fromCharCode(bytes[i]) 313 } 314 return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '') 315 } 316 317 function base64UrlToArrayBuffer(base64url: string): ArrayBuffer { 318 const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/') 319 const padded = base64 + '='.repeat((4 - base64.length % 4) % 4) 320 const binary = atob(padded) 321 const bytes = new Uint8Array(binary.length) 322 for (let i = 0; i < binary.length; i++) { 323 bytes[i] = binary.charCodeAt(i) 324 } 325 return bytes.buffer 326 } 327 328 function preparePublicKeyOptions(options: any): PublicKeyCredentialCreationOptions { 329 return { 330 ...options.publicKey, 331 challenge: base64UrlToArrayBuffer(options.publicKey.challenge), 332 user: { 333 ...options.publicKey.user, 334 id: base64UrlToArrayBuffer(options.publicKey.user.id) 335 }, 336 excludeCredentials: options.publicKey.excludeCredentials?.map((cred: any) => ({ 337 ...cred, 338 id: base64UrlToArrayBuffer(cred.id) 339 })) || [] 340 } 341 } 342 343 function formatDate(dateStr: string): string { 344 return new Date(dateStr).toLocaleDateString() 345 } 346</script> 347 348<div class="page"> 349 <header> 350 <a href="#/dashboard" class="back">&larr; Dashboard</a> 351 <h1>Security Settings</h1> 352 </header> 353 354 {#if message} 355 <div class="message {message.type}">{message.text}</div> 356 {/if} 357 358 {#if loading} 359 <div class="loading">Loading...</div> 360 {:else} 361 <section> 362 <h2>Two-Factor Authentication</h2> 363 <p class="description"> 364 Add an extra layer of security to your account using an authenticator app like Google Authenticator, Authy, or 1Password. 365 </p> 366 367 {#if setupStep === 'idle'} 368 {#if totpEnabled} 369 <div class="status enabled"> 370 <span>Two-factor authentication is <strong>enabled</strong></span> 371 </div> 372 373 {#if !showDisableForm && !showRegenForm} 374 <div class="totp-actions"> 375 <button type="button" class="secondary" onclick={() => showRegenForm = true}> 376 Regenerate Backup Codes 377 </button> 378 <button type="button" class="danger-outline" onclick={() => showDisableForm = true}> 379 Disable 2FA 380 </button> 381 </div> 382 {/if} 383 384 {#if showRegenForm} 385 <form onsubmit={handleRegenerate} class="inline-form"> 386 <h3>Regenerate Backup Codes</h3> 387 <p class="warning-text">This will invalidate all existing backup codes.</p> 388 <div class="field"> 389 <label for="regen-password">Password</label> 390 <input 391 id="regen-password" 392 type="password" 393 bind:value={regenPassword} 394 placeholder="Enter your password" 395 disabled={regenLoading} 396 required 397 /> 398 </div> 399 <div class="field"> 400 <label for="regen-code">Authenticator Code</label> 401 <input 402 id="regen-code" 403 type="text" 404 bind:value={regenCode} 405 placeholder="6-digit code" 406 disabled={regenLoading} 407 required 408 maxlength="6" 409 pattern="[0-9]{6}" 410 inputmode="numeric" 411 /> 412 </div> 413 <div class="actions"> 414 <button type="button" class="secondary" onclick={() => { showRegenForm = false; regenPassword = ''; regenCode = '' }}> 415 Cancel 416 </button> 417 <button type="submit" disabled={regenLoading || !regenPassword || regenCode.length !== 6}> 418 {regenLoading ? 'Regenerating...' : 'Regenerate'} 419 </button> 420 </div> 421 </form> 422 {/if} 423 424 {#if showDisableForm} 425 <form onsubmit={handleDisable} class="inline-form danger-form"> 426 <h3>Disable Two-Factor Authentication</h3> 427 <p class="warning-text">This will make your account less secure.</p> 428 <div class="field"> 429 <label for="disable-password">Password</label> 430 <input 431 id="disable-password" 432 type="password" 433 bind:value={disablePassword} 434 placeholder="Enter your password" 435 disabled={disableLoading} 436 required 437 /> 438 </div> 439 <div class="field"> 440 <label for="disable-code">Authenticator Code</label> 441 <input 442 id="disable-code" 443 type="text" 444 bind:value={disableCode} 445 placeholder="6-digit code" 446 disabled={disableLoading} 447 required 448 maxlength="6" 449 pattern="[0-9]{6}" 450 inputmode="numeric" 451 /> 452 </div> 453 <div class="actions"> 454 <button type="button" class="secondary" onclick={() => { showDisableForm = false; disablePassword = ''; disableCode = '' }}> 455 Cancel 456 </button> 457 <button type="submit" class="danger" disabled={disableLoading || !disablePassword || disableCode.length !== 6}> 458 {disableLoading ? 'Disabling...' : 'Disable 2FA'} 459 </button> 460 </div> 461 </form> 462 {/if} 463 {:else} 464 <div class="status disabled"> 465 <span>Two-factor authentication is <strong>not enabled</strong></span> 466 </div> 467 <button onclick={handleStartSetup} disabled={verifyLoading}> 468 {verifyLoading ? 'Setting up...' : 'Set Up Two-Factor Authentication'} 469 </button> 470 {/if} 471 {:else if setupStep === 'qr'} 472 <div class="setup-step"> 473 <h3>Step 1: Scan QR Code</h3> 474 <p>Scan this QR code with your authenticator app:</p> 475 <div class="qr-container"> 476 <img src="data:image/png;base64,{qrBase64}" alt="TOTP QR Code" class="qr-code" /> 477 </div> 478 <details class="manual-entry"> 479 <summary>Can't scan? Enter manually</summary> 480 <code class="secret-code">{totpUri.split('secret=')[1]?.split('&')[0] || ''}</code> 481 </details> 482 <button onclick={() => setupStep = 'verify'}> 483 Next: Verify Code 484 </button> 485 </div> 486 {:else if setupStep === 'verify'} 487 <div class="setup-step"> 488 <h3>Step 2: Verify Setup</h3> 489 <p>Enter the 6-digit code from your authenticator app:</p> 490 <form onsubmit={handleVerifySetup}> 491 <div class="field"> 492 <input 493 type="text" 494 bind:value={verifyCodeRaw} 495 placeholder="000000" 496 disabled={verifyLoading} 497 inputmode="numeric" 498 class="code-input" 499 /> 500 </div> 501 <div class="actions"> 502 <button type="button" class="secondary" onclick={() => { setupStep = 'qr' }}> 503 Back 504 </button> 505 <button type="submit" disabled={verifyLoading || verifyCode.length !== 6}> 506 {verifyLoading ? 'Verifying...' : 'Verify & Enable'} 507 </button> 508 </div> 509 </form> 510 </div> 511 {:else if setupStep === 'backup'} 512 <div class="setup-step"> 513 <h3>Step 3: Save Backup Codes</h3> 514 <p class="warning-text"> 515 Save these backup codes in a secure location. Each code can only be used once. 516 If you lose access to your authenticator app, you'll need these to sign in. 517 </p> 518 <div class="backup-codes"> 519 {#each backupCodes as code} 520 <code class="backup-code">{code}</code> 521 {/each} 522 </div> 523 <div class="actions"> 524 <button type="button" class="secondary" onclick={copyBackupCodes}> 525 Copy to Clipboard 526 </button> 527 <button onclick={handleFinishSetup}> 528 I've Saved My Codes 529 </button> 530 </div> 531 </div> 532 {/if} 533 </section> 534 535 <section> 536 <h2>Passkeys</h2> 537 <p class="description"> 538 Passkeys are a secure, passwordless way to sign in using biometrics (fingerprint or face), a security key, or your device's screen lock. 539 </p> 540 541 {#if passkeysLoading} 542 <div class="loading">Loading passkeys...</div> 543 {:else} 544 {#if passkeys.length > 0} 545 <div class="passkey-list"> 546 {#each passkeys as passkey} 547 <div class="passkey-item"> 548 {#if editingPasskeyId === passkey.id} 549 <div class="passkey-edit"> 550 <input 551 type="text" 552 bind:value={editPasskeyName} 553 placeholder="Passkey name" 554 class="passkey-name-input" 555 /> 556 <div class="passkey-edit-actions"> 557 <button type="button" class="small" onclick={handleSavePasskeyName}>Save</button> 558 <button type="button" class="small secondary" onclick={cancelEditPasskey}>Cancel</button> 559 </div> 560 </div> 561 {:else} 562 <div class="passkey-info"> 563 <span class="passkey-name">{passkey.friendlyName || 'Unnamed passkey'}</span> 564 <span class="passkey-meta"> 565 Added {formatDate(passkey.createdAt)} 566 {#if passkey.lastUsed} 567 &middot; Last used {formatDate(passkey.lastUsed)} 568 {/if} 569 </span> 570 </div> 571 <div class="passkey-actions"> 572 <button type="button" class="small secondary" onclick={() => startEditPasskey(passkey)}> 573 Rename 574 </button> 575 <button type="button" class="small danger-outline" onclick={() => handleDeletePasskey(passkey.id)}> 576 Delete 577 </button> 578 </div> 579 {/if} 580 </div> 581 {/each} 582 </div> 583 {:else} 584 <div class="status disabled"> 585 <span>No passkeys registered</span> 586 </div> 587 {/if} 588 589 <div class="add-passkey"> 590 <div class="field"> 591 <label for="passkey-name">Passkey Name (optional)</label> 592 <input 593 id="passkey-name" 594 type="text" 595 bind:value={newPasskeyName} 596 placeholder="e.g., MacBook Touch ID" 597 disabled={addingPasskey} 598 /> 599 </div> 600 <button onclick={handleAddPasskey} disabled={addingPasskey}> 601 {addingPasskey ? 'Adding Passkey...' : 'Add a Passkey'} 602 </button> 603 </div> 604 {/if} 605 </section> 606 607 <section> 608 <h2>Password</h2> 609 <p class="description"> 610 Manage your account password. If you have passkeys set up, you can optionally remove your password for a fully passwordless experience. 611 </p> 612 613 {#if passwordLoading} 614 <div class="loading">Loading...</div> 615 {:else if hasPassword} 616 <div class="status enabled"> 617 <span>Password authentication is <strong>enabled</strong></span> 618 </div> 619 620 {#if passkeys.length > 0} 621 {#if !showRemovePasswordForm} 622 <button type="button" class="danger-outline" onclick={() => showRemovePasswordForm = true}> 623 Remove Password 624 </button> 625 {:else} 626 <div class="inline-form danger-form"> 627 <h3>Remove Password</h3> 628 <p class="warning-text"> 629 This will make your account passkey-only. You'll only be able to sign in using your registered passkeys. 630 If you lose access to all your passkeys, you can recover your account using your notification channel. 631 </p> 632 <div class="info-box-inline"> 633 <strong>Before proceeding:</strong> 634 <ul> 635 <li>Make sure you have at least one reliable passkey registered</li> 636 <li>Consider registering passkeys on multiple devices</li> 637 <li>Ensure your recovery notification channel is up to date</li> 638 </ul> 639 </div> 640 <div class="actions"> 641 <button type="button" class="secondary" onclick={() => showRemovePasswordForm = false}> 642 Cancel 643 </button> 644 <button type="button" class="danger" onclick={handleRemovePassword} disabled={removePasswordLoading}> 645 {removePasswordLoading ? 'Removing...' : 'Remove Password'} 646 </button> 647 </div> 648 </div> 649 {/if} 650 {:else} 651 <p class="hint">Add at least one passkey before you can remove your password.</p> 652 {/if} 653 {:else} 654 <div class="status passkey-only"> 655 <span>Your account is <strong>passkey-only</strong></span> 656 </div> 657 <p class="hint"> 658 You sign in using passkeys only. If you ever lose access to your passkeys, 659 you can recover your account using the "Lost passkey?" link on the login page. 660 </p> 661 {/if} 662 </section> 663 664 <section> 665 <h2>Trusted Devices</h2> 666 <p class="description"> 667 Manage devices that can skip two-factor authentication when signing in. Trust is granted for 30 days and automatically extends when you use the device. 668 </p> 669 <a href="#/trusted-devices" class="section-link"> 670 Manage Trusted Devices &rarr; 671 </a> 672 </section> 673 {/if} 674</div> 675 676<ReauthModal 677 bind:show={showReauthModal} 678 availableMethods={reauthMethods} 679 onSuccess={handleReauthSuccess} 680 onCancel={handleReauthCancel} 681/> 682 683<style> 684 .page { 685 max-width: 600px; 686 margin: 0 auto; 687 padding: 2rem; 688 } 689 690 header { 691 margin-bottom: 2rem; 692 } 693 694 .back { 695 color: var(--text-secondary); 696 text-decoration: none; 697 font-size: 0.875rem; 698 } 699 700 .back:hover { 701 color: var(--accent); 702 } 703 704 h1 { 705 margin: 0.5rem 0 0 0; 706 } 707 708 .message { 709 padding: 0.75rem; 710 border-radius: 4px; 711 margin-bottom: 1rem; 712 } 713 714 .message.success { 715 background: var(--success-bg); 716 border: 1px solid var(--success-border); 717 color: var(--success-text); 718 } 719 720 .message.error { 721 background: var(--error-bg); 722 border: 1px solid var(--error-border); 723 color: var(--error-text); 724 } 725 726 .loading { 727 text-align: center; 728 color: var(--text-secondary); 729 padding: 2rem; 730 } 731 732 section { 733 padding: 1.5rem; 734 background: var(--bg-secondary); 735 border-radius: 8px; 736 margin-bottom: 1.5rem; 737 } 738 739 section h2 { 740 margin: 0 0 0.5rem 0; 741 font-size: 1.125rem; 742 } 743 744 .description { 745 color: var(--text-secondary); 746 font-size: 0.875rem; 747 margin-bottom: 1.5rem; 748 } 749 750 .status { 751 display: flex; 752 align-items: center; 753 gap: 0.5rem; 754 padding: 0.75rem; 755 border-radius: 4px; 756 margin-bottom: 1rem; 757 } 758 759 .status.enabled { 760 background: var(--success-bg); 761 border: 1px solid var(--success-border); 762 color: var(--success-text); 763 } 764 765 .status.disabled { 766 background: var(--warning-bg); 767 border: 1px solid var(--border-color); 768 color: var(--warning-text); 769 } 770 771 .totp-actions { 772 display: flex; 773 gap: 0.5rem; 774 flex-wrap: wrap; 775 } 776 777 .field { 778 margin-bottom: 1rem; 779 } 780 781 label { 782 display: block; 783 font-size: 0.875rem; 784 font-weight: 500; 785 margin-bottom: 0.25rem; 786 } 787 788 input { 789 width: 100%; 790 padding: 0.75rem; 791 border: 1px solid var(--border-color-light); 792 border-radius: 4px; 793 font-size: 1rem; 794 box-sizing: border-box; 795 background: var(--bg-input); 796 color: var(--text-primary); 797 } 798 799 input:focus { 800 outline: none; 801 border-color: var(--accent); 802 } 803 804 .code-input { 805 font-size: 1.5rem; 806 letter-spacing: 0.5em; 807 text-align: center; 808 max-width: 200px; 809 margin: 0 auto; 810 display: block; 811 } 812 813 button { 814 padding: 0.75rem 1.5rem; 815 background: var(--accent); 816 color: white; 817 border: none; 818 border-radius: 4px; 819 cursor: pointer; 820 font-size: 1rem; 821 } 822 823 button:hover:not(:disabled) { 824 background: var(--accent-hover); 825 } 826 827 button:disabled { 828 opacity: 0.6; 829 cursor: not-allowed; 830 } 831 832 button.secondary { 833 background: transparent; 834 color: var(--text-secondary); 835 border: 1px solid var(--border-color-light); 836 } 837 838 button.secondary:hover:not(:disabled) { 839 background: var(--bg-card); 840 } 841 842 button.danger { 843 background: var(--error-text); 844 } 845 846 button.danger:hover:not(:disabled) { 847 background: #900; 848 } 849 850 button.danger-outline { 851 background: transparent; 852 color: var(--error-text); 853 border: 1px solid var(--error-border); 854 } 855 856 button.danger-outline:hover:not(:disabled) { 857 background: var(--error-bg); 858 } 859 860 .actions { 861 display: flex; 862 gap: 0.5rem; 863 margin-top: 1rem; 864 } 865 866 .inline-form { 867 margin-top: 1rem; 868 padding: 1rem; 869 background: var(--bg-card); 870 border: 1px solid var(--border-color-light); 871 border-radius: 6px; 872 } 873 874 .inline-form h3 { 875 margin: 0 0 0.5rem 0; 876 font-size: 1rem; 877 } 878 879 .danger-form { 880 border-color: var(--error-border); 881 background: var(--error-bg); 882 } 883 884 .warning-text { 885 color: var(--error-text); 886 font-size: 0.875rem; 887 margin-bottom: 1rem; 888 } 889 890 .setup-step { 891 padding: 1rem; 892 background: var(--bg-card); 893 border: 1px solid var(--border-color-light); 894 border-radius: 6px; 895 } 896 897 .setup-step h3 { 898 margin: 0 0 0.5rem 0; 899 } 900 901 .setup-step p { 902 color: var(--text-secondary); 903 font-size: 0.875rem; 904 margin-bottom: 1rem; 905 } 906 907 .qr-container { 908 display: flex; 909 justify-content: center; 910 margin: 1.5rem 0; 911 } 912 913 .qr-code { 914 width: 200px; 915 height: 200px; 916 image-rendering: pixelated; 917 } 918 919 .manual-entry { 920 margin-bottom: 1rem; 921 font-size: 0.875rem; 922 } 923 924 .manual-entry summary { 925 cursor: pointer; 926 color: var(--accent); 927 } 928 929 .secret-code { 930 display: block; 931 margin-top: 0.5rem; 932 padding: 0.5rem; 933 background: var(--bg-input); 934 border-radius: 4px; 935 word-break: break-all; 936 font-size: 0.75rem; 937 } 938 939 .backup-codes { 940 display: grid; 941 grid-template-columns: repeat(2, 1fr); 942 gap: 0.5rem; 943 margin: 1rem 0; 944 } 945 946 .backup-code { 947 padding: 0.5rem; 948 background: var(--bg-input); 949 border-radius: 4px; 950 text-align: center; 951 font-size: 0.875rem; 952 font-family: monospace; 953 } 954 955 .passkey-list { 956 display: flex; 957 flex-direction: column; 958 gap: 0.5rem; 959 margin-bottom: 1rem; 960 } 961 962 .passkey-item { 963 display: flex; 964 justify-content: space-between; 965 align-items: center; 966 padding: 0.75rem; 967 background: var(--bg-card); 968 border: 1px solid var(--border-color-light); 969 border-radius: 6px; 970 gap: 1rem; 971 } 972 973 .passkey-info { 974 display: flex; 975 flex-direction: column; 976 gap: 0.25rem; 977 flex: 1; 978 min-width: 0; 979 } 980 981 .passkey-name { 982 font-weight: 500; 983 overflow: hidden; 984 text-overflow: ellipsis; 985 white-space: nowrap; 986 } 987 988 .passkey-meta { 989 font-size: 0.75rem; 990 color: var(--text-secondary); 991 } 992 993 .passkey-actions { 994 display: flex; 995 gap: 0.5rem; 996 flex-shrink: 0; 997 } 998 999 .passkey-edit { 1000 display: flex; 1001 flex: 1; 1002 gap: 0.5rem; 1003 align-items: center; 1004 } 1005 1006 .passkey-name-input { 1007 flex: 1; 1008 padding: 0.5rem; 1009 font-size: 0.875rem; 1010 } 1011 1012 .passkey-edit-actions { 1013 display: flex; 1014 gap: 0.25rem; 1015 } 1016 1017 button.small { 1018 padding: 0.375rem 0.75rem; 1019 font-size: 0.75rem; 1020 } 1021 1022 .add-passkey { 1023 margin-top: 1rem; 1024 padding-top: 1rem; 1025 border-top: 1px solid var(--border-color-light); 1026 } 1027 1028 .add-passkey .field { 1029 margin-bottom: 0.75rem; 1030 } 1031 1032 .section-link { 1033 display: inline-block; 1034 color: var(--accent); 1035 text-decoration: none; 1036 font-weight: 500; 1037 } 1038 1039 .section-link:hover { 1040 text-decoration: underline; 1041 } 1042 1043 .status.passkey-only { 1044 background: var(--accent); 1045 background: linear-gradient(135deg, rgba(77, 166, 255, 0.15), rgba(128, 90, 213, 0.15)); 1046 border: 1px solid var(--accent); 1047 color: var(--accent); 1048 } 1049 1050 .hint { 1051 font-size: 0.875rem; 1052 color: var(--text-secondary); 1053 margin: 0; 1054 } 1055 1056 .info-box-inline { 1057 background: var(--bg-card); 1058 border: 1px solid var(--border-color); 1059 border-radius: 6px; 1060 padding: 1rem; 1061 margin-bottom: 1rem; 1062 font-size: 0.875rem; 1063 } 1064 1065 .info-box-inline strong { 1066 display: block; 1067 margin-bottom: 0.5rem; 1068 } 1069 1070 .info-box-inline ul { 1071 margin: 0; 1072 padding-left: 1.25rem; 1073 color: var(--text-secondary); 1074 } 1075 1076 .info-box-inline li { 1077 margin-bottom: 0.25rem; 1078 } 1079</style>