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