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">← 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 · 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>