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