this repo has no description
1<script lang="ts">
2 import { getAuthState, getValidToken } from '../lib/auth.svelte'
3 import { navigate, routes, getFullUrl } from '../lib/router.svelte'
4 import { api, ApiError } from '../lib/api'
5 import ReauthModal from '../components/ReauthModal.svelte'
6 import { _ } from '../lib/i18n'
7 import { formatDate as formatDateUtil } from '../lib/date'
8 import type { Session } from '../lib/types/api'
9 import {
10 prepareCreationOptions,
11 serializeAttestationResponse,
12 type WebAuthnCreationOptionsResponse,
13 } from '../lib/webauthn'
14 import { toast } from '../lib/toast.svelte'
15
16 const auth = $derived(getAuthState())
17
18 function getSession(): Session | null {
19 return auth.kind === 'authenticated' ? auth.session : null
20 }
21
22 function isLoading(): boolean {
23 return auth.kind === 'loading'
24 }
25
26 const session = $derived(getSession())
27 const authLoading = $derived(isLoading())
28
29 let loading = $state(true)
30 let totpEnabled = $state(false)
31 let hasBackupCodes = $state(false)
32 let setupStep = $state<'idle' | 'qr' | 'verify' | 'backup'>('idle')
33 let qrBase64 = $state('')
34 let totpUri = $state('')
35 let verifyCodeRaw = $state('')
36 let verifyCode = $derived(verifyCodeRaw.replace(/\s/g, ''))
37 let verifyLoading = $state(false)
38 let backupCodes = $state<string[]>([])
39 let disablePassword = $state('')
40 let disableCode = $state('')
41 let disableLoading = $state(false)
42 let showDisableForm = $state(false)
43 let regenPassword = $state('')
44 let regenCode = $state('')
45 let regenLoading = $state(false)
46 let showRegenForm = $state(false)
47
48 interface Passkey {
49 id: string
50 credentialId: string
51 friendlyName: string | null
52 createdAt: string
53 lastUsed: string | null
54 }
55 let passkeys = $state<Passkey[]>([])
56 let passkeysLoading = $state(true)
57 let addingPasskey = $state(false)
58 let newPasskeyName = $state('')
59 let editingPasskeyId = $state<string | null>(null)
60 let editPasskeyName = $state('')
61
62 let hasPassword = $state(true)
63 let passwordLoading = $state(true)
64 let showRemovePasswordForm = $state(false)
65 let removePasswordLoading = $state(false)
66
67 let allowLegacyLogin = $state(true)
68 let hasMfa = $state(false)
69 let legacyLoginLoading = $state(true)
70 let legacyLoginUpdating = $state(false)
71
72 let showReauthModal = $state(false)
73 let reauthMethods = $state<string[]>(['password'])
74 let pendingAction = $state<(() => Promise<void>) | null>(null)
75
76 $effect(() => {
77 if (!authLoading && !session) {
78 navigate(routes.login)
79 }
80 })
81
82 $effect(() => {
83 if (session) {
84 loadTotpStatus()
85 loadPasskeys()
86 loadPasswordStatus()
87 loadLegacyLoginPreference()
88 }
89 })
90
91 async function loadPasswordStatus() {
92 if (!session) return
93 passwordLoading = true
94 try {
95 const status = await api.getPasswordStatus(session.accessJwt)
96 hasPassword = status.hasPassword
97 } catch {
98 hasPassword = true
99 } finally {
100 passwordLoading = false
101 }
102 }
103
104 async function loadLegacyLoginPreference() {
105 if (!session) return
106 legacyLoginLoading = true
107 try {
108 const pref = await api.getLegacyLoginPreference(session.accessJwt)
109 allowLegacyLogin = pref.allowLegacyLogin
110 hasMfa = pref.hasMfa
111 } catch {
112 allowLegacyLogin = true
113 hasMfa = false
114 } finally {
115 legacyLoginLoading = false
116 }
117 }
118
119 async function handleToggleLegacyLogin() {
120 if (!session) return
121 legacyLoginUpdating = true
122 try {
123 const result = await api.updateLegacyLoginPreference(session.accessJwt, !allowLegacyLogin)
124 allowLegacyLogin = result.allowLegacyLogin
125 toast.success(allowLegacyLogin
126 ? $_('security.legacyLoginEnabled')
127 : $_('security.legacyLoginDisabled'))
128 } catch (e) {
129 if (e instanceof ApiError) {
130 if (e.error === 'ReauthRequired' || e.error === 'MfaVerificationRequired') {
131 reauthMethods = e.reauthMethods || ['password']
132 pendingAction = handleToggleLegacyLogin
133 showReauthModal = true
134 } else {
135 toast.error(e.message)
136 }
137 } else {
138 toast.error($_('security.failedToUpdatePreference'))
139 }
140 } finally {
141 legacyLoginUpdating = false
142 }
143 }
144
145 async function handleRemovePassword() {
146 if (!session) return
147 removePasswordLoading = true
148 try {
149 const token = await getValidToken()
150 if (!token) {
151 toast.error($_('security.sessionExpired'))
152 return
153 }
154 await api.removePassword(token)
155 hasPassword = false
156 showRemovePasswordForm = false
157 toast.success($_('security.passwordRemoved'))
158 } catch (e) {
159 if (e instanceof ApiError) {
160 if (e.error === 'ReauthRequired') {
161 reauthMethods = e.reauthMethods || ['password']
162 pendingAction = handleRemovePassword
163 showReauthModal = true
164 } else {
165 toast.error(e.message)
166 }
167 } else {
168 toast.error($_('security.failedToRemovePassword'))
169 }
170 } finally {
171 removePasswordLoading = false
172 }
173 }
174
175 function handleReauthSuccess() {
176 if (pendingAction) {
177 pendingAction()
178 pendingAction = null
179 }
180 }
181
182 function handleReauthCancel() {
183 pendingAction = null
184 }
185
186 async function loadTotpStatus() {
187 if (!session) return
188 loading = true
189 try {
190 const status = await api.getTotpStatus(session.accessJwt)
191 totpEnabled = status.enabled
192 hasBackupCodes = status.hasBackupCodes
193 } catch {
194 toast.error($_('security.failedToLoadTotpStatus'))
195 } finally {
196 loading = false
197 }
198 }
199
200 async function handleStartSetup() {
201 if (!session) return
202 verifyLoading = true
203 try {
204 const result = await api.createTotpSecret(session.accessJwt)
205 qrBase64 = result.qrBase64
206 totpUri = result.uri
207 setupStep = 'qr'
208 } catch (e) {
209 toast.error(e instanceof ApiError ? e.message : 'Failed to generate TOTP secret')
210 } finally {
211 verifyLoading = false
212 }
213 }
214
215 async function handleVerifySetup(e: Event) {
216 e.preventDefault()
217 if (!session || !verifyCode) return
218 verifyLoading = true
219 try {
220 const result = await api.enableTotp(session.accessJwt, verifyCode)
221 backupCodes = result.backupCodes
222 setupStep = 'backup'
223 totpEnabled = true
224 hasBackupCodes = true
225 verifyCodeRaw = ''
226 } catch (e) {
227 toast.error(e instanceof ApiError ? e.message : 'Invalid code. Please try again.')
228 } finally {
229 verifyLoading = false
230 }
231 }
232
233 function handleFinishSetup() {
234 setupStep = 'idle'
235 backupCodes = []
236 qrBase64 = ''
237 totpUri = ''
238 toast.success($_('security.totpEnabledSuccess'))
239 }
240
241 async function handleDisable(e: Event) {
242 e.preventDefault()
243 if (!session || !disablePassword || !disableCode) return
244 disableLoading = true
245 try {
246 await api.disableTotp(session.accessJwt, disablePassword, disableCode)
247 totpEnabled = false
248 hasBackupCodes = false
249 showDisableForm = false
250 disablePassword = ''
251 disableCode = ''
252 toast.success($_('security.totpDisabledSuccess'))
253 } catch (e) {
254 toast.error(e instanceof ApiError ? e.message : 'Failed to disable TOTP')
255 } finally {
256 disableLoading = false
257 }
258 }
259
260 async function handleRegenerate(e: Event) {
261 e.preventDefault()
262 if (!session || !regenPassword || !regenCode) return
263 regenLoading = true
264 try {
265 const result = await api.regenerateBackupCodes(session.accessJwt, regenPassword, regenCode)
266 backupCodes = result.backupCodes
267 setupStep = 'backup'
268 showRegenForm = false
269 regenPassword = ''
270 regenCode = ''
271 } catch (e) {
272 toast.error(e instanceof ApiError ? e.message : 'Failed to regenerate backup codes')
273 } finally {
274 regenLoading = false
275 }
276 }
277
278 function copyBackupCodes() {
279 const text = backupCodes.join('\n')
280 navigator.clipboard.writeText(text)
281 toast.success($_('security.backupCodesCopied'))
282 }
283
284 async function loadPasskeys() {
285 if (!session) return
286 passkeysLoading = true
287 try {
288 const result = await api.listPasskeys(session.accessJwt)
289 passkeys = result.passkeys
290 } catch {
291 toast.error($_('security.failedToLoadPasskeys'))
292 } finally {
293 passkeysLoading = false
294 }
295 }
296
297 async function handleAddPasskey() {
298 if (!session) return
299 if (!window.PublicKeyCredential) {
300 toast.error($_('security.passkeysNotSupported'))
301 return
302 }
303 addingPasskey = true
304 try {
305 const { options } = await api.startPasskeyRegistration(session.accessJwt, newPasskeyName || undefined)
306 const publicKeyOptions = prepareCreationOptions(options as unknown as WebAuthnCreationOptionsResponse)
307 const credential = await navigator.credentials.create({
308 publicKey: publicKeyOptions
309 })
310 if (!credential) {
311 toast.error($_('security.passkeyCreationCancelled'))
312 return
313 }
314 const credentialResponse = serializeAttestationResponse(credential as PublicKeyCredential)
315 await api.finishPasskeyRegistration(session.accessJwt, credentialResponse, newPasskeyName || undefined)
316 await loadPasskeys()
317 newPasskeyName = ''
318 toast.success($_('security.passkeyAddedSuccess'))
319 } catch (e) {
320 if (e instanceof DOMException && e.name === 'NotAllowedError') {
321 toast.error($_('security.passkeyCreationCancelled'))
322 } else {
323 toast.error(e instanceof ApiError ? e.message : 'Failed to add passkey')
324 }
325 } finally {
326 addingPasskey = false
327 }
328 }
329
330 async function handleDeletePasskey(id: string) {
331 if (!session) return
332 const passkey = passkeys.find(p => p.id === id)
333 const name = passkey?.friendlyName || 'this passkey'
334 if (!confirm($_('security.deletePasskeyConfirm', { values: { name } }))) return
335 try {
336 await api.deletePasskey(session.accessJwt, id)
337 await loadPasskeys()
338 toast.success($_('security.passkeyDeleted'))
339 } catch (e) {
340 toast.error(e instanceof ApiError ? e.message : 'Failed to delete passkey')
341 }
342 }
343
344 async function handleSavePasskeyName() {
345 if (!session || !editingPasskeyId || !editPasskeyName.trim()) return
346 try {
347 await api.updatePasskey(session.accessJwt, editingPasskeyId, editPasskeyName.trim())
348 await loadPasskeys()
349 editingPasskeyId = null
350 editPasskeyName = ''
351 toast.success($_('security.passkeyRenamed'))
352 } catch (e) {
353 toast.error(e instanceof ApiError ? e.message : 'Failed to rename passkey')
354 }
355 }
356
357 function startEditPasskey(passkey: Passkey) {
358 editingPasskeyId = passkey.id
359 editPasskeyName = passkey.friendlyName || ''
360 }
361
362 function cancelEditPasskey() {
363 editingPasskeyId = null
364 editPasskeyName = ''
365 }
366
367 function formatDate(dateStr: string): string {
368 return formatDateUtil(dateStr)
369 }
370</script>
371
372<div class="page">
373 <header>
374 <a href={getFullUrl(routes.dashboard)} class="back">{$_('common.backToDashboard')}</a>
375 <h1>{$_('security.title')}</h1>
376 </header>
377
378 {#if loading}
379 <div class="skeleton-grid">
380 {#each Array(4) as _}
381 <div class="skeleton-section"></div>
382 {/each}
383 </div>
384 {:else}
385 <div class="sections-grid">
386 <section>
387 <h2>{$_('security.totp')}</h2>
388 <p class="description">
389 {$_('security.totpDescription')}
390 </p>
391
392 {#if setupStep === 'idle'}
393 {#if totpEnabled}
394 <div class="status enabled">
395 <span>{$_('security.totpEnabled')}</span>
396 </div>
397
398 {#if !showDisableForm && !showRegenForm}
399 <div class="totp-actions">
400 <button type="button" class="secondary" onclick={() => showRegenForm = true}>
401 {$_('security.regenerateBackupCodes')}
402 </button>
403 <button type="button" class="danger-outline" onclick={() => showDisableForm = true}>
404 {$_('security.disableTotp')}
405 </button>
406 </div>
407 {/if}
408
409 {#if showRegenForm}
410 <form onsubmit={handleRegenerate} class="inline-form">
411 <h3>{$_('security.regenerateBackupCodes')}</h3>
412 <p class="warning-text">{$_('security.regenerateConfirm')}</p>
413 <div class="field">
414 <label for="regen-password">{$_('security.password')}</label>
415 <input
416 id="regen-password"
417 type="password"
418 bind:value={regenPassword}
419 placeholder={$_('security.enterPassword')}
420 disabled={regenLoading}
421 required
422 />
423 </div>
424 <div class="field">
425 <label for="regen-code">{$_('security.totpCode')}</label>
426 <input
427 id="regen-code"
428 type="text"
429 bind:value={regenCode}
430 placeholder="{$_('security.totpCodePlaceholder')}"
431 disabled={regenLoading}
432 required
433 maxlength="6"
434 pattern="[0-9]{6}"
435 inputmode="numeric"
436 />
437 </div>
438 <div class="actions">
439 <button type="button" class="secondary" onclick={() => { showRegenForm = false; regenPassword = ''; regenCode = '' }}>
440 {$_('common.cancel')}
441 </button>
442 <button type="submit" disabled={regenLoading || !regenPassword || regenCode.length !== 6}>
443 {regenLoading ? $_('security.regenerating') : $_('security.regenerateBackupCodes')}
444 </button>
445 </div>
446 </form>
447 {/if}
448
449 {#if showDisableForm}
450 <form onsubmit={handleDisable} class="inline-form danger-form">
451 <h3>{$_('security.disableTotp')}</h3>
452 <p class="warning-text">{$_('security.disableTotpWarning')}</p>
453 <div class="field">
454 <label for="disable-password">{$_('security.password')}</label>
455 <input
456 id="disable-password"
457 type="password"
458 bind:value={disablePassword}
459 placeholder={$_('security.enterPassword')}
460 disabled={disableLoading}
461 required
462 />
463 </div>
464 <div class="field">
465 <label for="disable-code">{$_('security.totpCode')}</label>
466 <input
467 id="disable-code"
468 type="text"
469 bind:value={disableCode}
470 placeholder="{$_('security.totpCodePlaceholder')}"
471 disabled={disableLoading}
472 required
473 maxlength="6"
474 pattern="[0-9]{6}"
475 inputmode="numeric"
476 />
477 </div>
478 <div class="actions">
479 <button type="button" class="secondary" onclick={() => { showDisableForm = false; disablePassword = ''; disableCode = '' }}>
480 {$_('common.cancel')}
481 </button>
482 <button type="submit" class="danger" disabled={disableLoading || !disablePassword || disableCode.length !== 6}>
483 {disableLoading ? $_('security.disabling') : $_('security.disableTotp')}
484 </button>
485 </div>
486 </form>
487 {/if}
488 {:else}
489 <div class="status disabled">
490 <span>{$_('security.totpDisabled')}</span>
491 </div>
492 <button onclick={handleStartSetup} disabled={verifyLoading}>
493 {$_('security.enableTotp')}
494 </button>
495 {/if}
496 {:else if setupStep === 'qr'}
497 <div class="setup-step">
498 <h3>{$_('security.totpSetup')}</h3>
499 <p>{$_('security.totpSetupInstructions')}</p>
500 <div class="qr-container">
501 <img src="data:image/png;base64,{qrBase64}" alt="TOTP QR Code" class="qr-code" />
502 </div>
503 <details class="manual-entry">
504 <summary>{$_('security.cantScan')}</summary>
505 <code class="secret-code">{totpUri.split('secret=')[1]?.split('&')[0] || ''}</code>
506 </details>
507 <button onclick={() => setupStep = 'verify'}>
508 {$_('security.next')}
509 </button>
510 </div>
511 {:else if setupStep === 'verify'}
512 <div class="setup-step">
513 <h3>{$_('security.totpSetup')}</h3>
514 <p>{$_('security.totpCodePlaceholder')}</p>
515 <form onsubmit={handleVerifySetup}>
516 <div class="field">
517 <input
518 type="text"
519 bind:value={verifyCodeRaw}
520 placeholder="000000"
521 disabled={verifyLoading}
522 inputmode="numeric"
523 class="code-input"
524 />
525 </div>
526 <div class="actions">
527 <button type="button" class="secondary" onclick={() => { setupStep = 'qr' }}>
528 {$_('common.back')}
529 </button>
530 <button type="submit" disabled={verifyLoading || verifyCode.length !== 6}>
531 {$_('security.verifyAndEnable')}
532 </button>
533 </div>
534 </form>
535 </div>
536 {:else if setupStep === 'backup'}
537 <div class="setup-step">
538 <h3>{$_('security.backupCodes')}</h3>
539 <p class="warning-text">
540 {$_('security.backupCodesDescription')}
541 </p>
542 <div class="backup-codes">
543 {#each backupCodes as code}
544 <code class="backup-code">{code}</code>
545 {/each}
546 </div>
547 <div class="actions">
548 <button type="button" class="secondary" onclick={copyBackupCodes}>
549 {$_('security.copyToClipboard')}
550 </button>
551 <button onclick={handleFinishSetup}>
552 {$_('security.savedMyCodes')}
553 </button>
554 </div>
555 </div>
556 {/if}
557 </section>
558
559 <section>
560 <h2>{$_('security.passkeys')}</h2>
561 <p class="description">
562 {$_('security.passkeysDescription')}
563 </p>
564
565 {#if !passkeysLoading}
566 {#if passkeys.length > 0}
567 <div class="passkey-list">
568 {#each passkeys as passkey}
569 <div class="passkey-item">
570 {#if editingPasskeyId === passkey.id}
571 <div class="passkey-edit">
572 <input
573 type="text"
574 bind:value={editPasskeyName}
575 placeholder="{$_('security.passkeyName')}"
576 class="passkey-name-input"
577 />
578 <div class="passkey-edit-actions">
579 <button type="button" class="small" onclick={handleSavePasskeyName}>{$_('common.save')}</button>
580 <button type="button" class="small secondary" onclick={cancelEditPasskey}>{$_('common.cancel')}</button>
581 </div>
582 </div>
583 {:else}
584 <div class="passkey-info">
585 <span class="passkey-name">{passkey.friendlyName || $_('security.unnamedPasskey')}</span>
586 <span class="passkey-meta">
587 {$_('security.added')} {formatDate(passkey.createdAt)}
588 {#if passkey.lastUsed}
589 · {$_('security.lastUsed')} {formatDate(passkey.lastUsed)}
590 {/if}
591 </span>
592 </div>
593 <div class="passkey-actions">
594 <button type="button" class="small secondary" onclick={() => startEditPasskey(passkey)}>
595 {$_('security.rename')}
596 </button>
597 {#if hasPassword || passkeys.length > 1}
598 <button type="button" class="small danger-outline" onclick={() => handleDeletePasskey(passkey.id)}>
599 {$_('security.deletePasskey')}
600 </button>
601 {/if}
602 </div>
603 {/if}
604 </div>
605 {/each}
606 </div>
607 {:else}
608 <div class="status disabled">
609 <span>{$_('security.noPasskeys')}</span>
610 </div>
611 {/if}
612
613 <div class="add-passkey">
614 <div class="field">
615 <label for="passkey-name">{$_('security.passkeyName')}</label>
616 <input
617 id="passkey-name"
618 type="text"
619 bind:value={newPasskeyName}
620 placeholder="{$_('security.passkeyNamePlaceholder')}"
621 disabled={addingPasskey}
622 />
623 </div>
624 <button onclick={handleAddPasskey} disabled={addingPasskey}>
625 {addingPasskey ? $_('security.adding') : $_('security.addPasskey')}
626 </button>
627 </div>
628 {/if}
629 </section>
630
631 <section>
632 <h2>{$_('security.password')}</h2>
633 <p class="description">
634 {$_('security.passwordDescription')}
635 </p>
636
637 {#if !passwordLoading && hasPassword}
638 <div class="status enabled">
639 <span>{$_('security.passwordStatus')}</span>
640 </div>
641
642 {#if passkeys.length > 0}
643 {#if !showRemovePasswordForm}
644 <button type="button" class="danger-outline" onclick={() => showRemovePasswordForm = true}>
645 {$_('security.removePassword')}
646 </button>
647 {:else}
648 <div class="inline-form danger-form">
649 <h3>{$_('security.removePassword')}</h3>
650 <p class="warning-text">
651 {$_('security.removePasswordWarning')}
652 </p>
653 <div class="info-box-inline">
654 <strong>{$_('security.beforeProceeding')}</strong>
655 <ul>
656 <li>{$_('security.beforeProceedingItem1')}</li>
657 <li>{$_('security.beforeProceedingItem2')}</li>
658 <li>{$_('security.beforeProceedingItem3')}</li>
659 </ul>
660 </div>
661 <div class="actions">
662 <button type="button" class="secondary" onclick={() => showRemovePasswordForm = false}>
663 {$_('common.cancel')}
664 </button>
665 <button type="button" class="danger" onclick={handleRemovePassword} disabled={removePasswordLoading}>
666 {removePasswordLoading ? $_('security.removing') : $_('security.removePassword')}
667 </button>
668 </div>
669 </div>
670 {/if}
671 {:else}
672 <p class="hint">{$_('security.addPasskeyFirst')}</p>
673 {/if}
674 {:else}
675 <div class="status passkey-only">
676 <span>{$_('security.noPassword')}</span>
677 </div>
678 <p class="hint">
679 {$_('security.passkeyOnlyHint')}
680 </p>
681 {/if}
682 </section>
683
684 <section>
685 <h2>{$_('security.trustedDevices')}</h2>
686 <p class="description">
687 {$_('security.trustedDevicesDescription')}
688 </p>
689 <a href={getFullUrl(routes.trustedDevices)} class="section-link">
690 {$_('security.manageTrustedDevices')} →
691 </a>
692 </section>
693 </div>
694
695 {#if hasMfa}
696 <section>
697 <h2>{$_('security.appCompatibility')}</h2>
698 <p class="description">
699 {$_('security.legacyLoginDescription')}
700 </p>
701
702 {#if !legacyLoginLoading}
703 <div class="toggle-row">
704 <div class="toggle-info">
705 <span class="toggle-label">{$_('security.legacyLogin')}</span>
706 <span class="toggle-description">
707 {#if allowLegacyLogin}
708 {$_('security.legacyLoginOn')}
709 {:else}
710 {$_('security.legacyLoginOff')}
711 {/if}
712 </span>
713 </div>
714 <button
715 type="button"
716 class="toggle-button {allowLegacyLogin ? 'on' : 'off'}"
717 onclick={handleToggleLegacyLogin}
718 disabled={legacyLoginUpdating}
719 aria-label={allowLegacyLogin ? $_('security.disableLegacyLogin') : $_('security.enableLegacyLogin')}
720 >
721 <span class="toggle-slider"></span>
722 </button>
723 </div>
724
725 {#if totpEnabled}
726 <div class="warning-box">
727 <strong>{$_('security.legacyLoginWarning')}</strong>
728 <p>{$_('security.totpPasswordWarning')}</p>
729 <ol>
730 <li><strong>{$_('security.totpPasswordOption1Label')}</strong> {$_('security.totpPasswordOption1Text')} <a href={getFullUrl(routes.settings)}>{$_('security.totpPasswordOption1Link')}</a> {$_('security.totpPasswordOption1Suffix')}</li>
731 <li><strong>{$_('security.totpPasswordOption2Label')}</strong> {$_('security.totpPasswordOption2Text')} <a href={getFullUrl(routes.settings)}>{$_('security.totpPasswordOption2Link')}</a> {$_('security.totpPasswordOption2Suffix')}</li>
732 </ol>
733 </div>
734 {/if}
735
736 <div class="info-box-inline">
737 <strong>{$_('security.legacyAppsTitle')}</strong>
738 <p>{$_('security.legacyAppsDescription')}</p>
739 </div>
740 {/if}
741 </section>
742 {/if}
743 {/if}
744</div>
745
746<ReauthModal
747 bind:show={showReauthModal}
748 availableMethods={reauthMethods}
749 onSuccess={handleReauthSuccess}
750 onCancel={handleReauthCancel}
751/>
752
753<style>
754 .page {
755 max-width: var(--width-lg);
756 margin: 0 auto;
757 padding: var(--space-7);
758 }
759
760 header {
761 margin-bottom: var(--space-7);
762 }
763
764 .sections-grid {
765 display: flex;
766 flex-direction: column;
767 gap: var(--space-6);
768 margin-bottom: var(--space-6);
769 }
770
771 @media (min-width: 800px) {
772 .sections-grid {
773 columns: 2;
774 column-gap: var(--space-6);
775 display: block;
776 }
777
778 .sections-grid section {
779 break-inside: avoid;
780 margin-bottom: var(--space-6);
781 }
782 }
783
784 .back {
785 color: var(--text-secondary);
786 text-decoration: none;
787 font-size: var(--text-sm);
788 }
789
790 .back:hover {
791 color: var(--accent);
792 }
793
794 h1 {
795 margin: var(--space-2) 0 0 0;
796 }
797
798 section {
799 padding: var(--space-6);
800 background: var(--bg-secondary);
801 border-radius: var(--radius-xl);
802 margin-bottom: var(--space-6);
803 height: fit-content;
804 }
805
806 section h2 {
807 margin: 0 0 var(--space-2) 0;
808 font-size: var(--text-lg);
809 }
810
811 .description {
812 color: var(--text-secondary);
813 font-size: var(--text-sm);
814 margin-bottom: var(--space-6);
815 }
816
817 .status {
818 display: flex;
819 align-items: center;
820 gap: var(--space-2);
821 padding: var(--space-3);
822 border-radius: var(--radius-md);
823 margin-bottom: var(--space-4);
824 }
825
826 .status.enabled {
827 background: var(--success-bg);
828 border: 1px solid var(--success-border);
829 color: var(--success-text);
830 }
831
832 .status.disabled {
833 background: var(--warning-bg);
834 border: 1px solid var(--border-color);
835 color: var(--warning-text);
836 }
837
838 .status.passkey-only {
839 background: linear-gradient(135deg, rgba(77, 166, 255, 0.15), rgba(128, 90, 213, 0.15));
840 border: 1px solid var(--accent);
841 color: var(--accent);
842 }
843
844 .totp-actions {
845 display: flex;
846 gap: var(--space-2);
847 flex-wrap: wrap;
848 }
849
850 .code-input {
851 font-size: var(--text-2xl);
852 letter-spacing: 0.5em;
853 text-align: center;
854 max-width: 200px;
855 margin: 0 auto;
856 display: block;
857 }
858
859 .actions {
860 display: flex;
861 gap: var(--space-2);
862 margin-top: var(--space-4);
863 }
864
865 .inline-form {
866 margin-top: var(--space-4);
867 padding: var(--space-4);
868 background: var(--bg-card);
869 border: 1px solid var(--border-color);
870 border-radius: var(--radius-lg);
871 }
872
873 .inline-form h3 {
874 margin: 0 0 var(--space-2) 0;
875 font-size: var(--text-base);
876 }
877
878 .danger-form {
879 border-color: var(--error-border);
880 background: var(--error-bg);
881 }
882
883 .warning-text {
884 color: var(--error-text);
885 font-size: var(--text-sm);
886 margin-bottom: var(--space-4);
887 }
888
889 .setup-step {
890 padding: var(--space-4);
891 background: var(--bg-card);
892 border: 1px solid var(--border-color);
893 border-radius: var(--radius-lg);
894 }
895
896 .setup-step h3 {
897 margin: 0 0 var(--space-2) 0;
898 }
899
900 .setup-step p {
901 color: var(--text-secondary);
902 font-size: var(--text-sm);
903 margin-bottom: var(--space-4);
904 }
905
906 .qr-container {
907 display: flex;
908 justify-content: center;
909 margin: var(--space-6) 0;
910 }
911
912 .qr-code {
913 width: 200px;
914 height: 200px;
915 image-rendering: pixelated;
916 }
917
918 .manual-entry {
919 margin-bottom: var(--space-4);
920 font-size: var(--text-sm);
921 }
922
923 .manual-entry summary {
924 cursor: pointer;
925 color: var(--accent);
926 }
927
928 .secret-code {
929 display: block;
930 margin-top: var(--space-2);
931 padding: var(--space-2);
932 background: var(--bg-input);
933 border-radius: var(--radius-md);
934 word-break: break-all;
935 font-size: var(--text-xs);
936 }
937
938 .backup-codes {
939 display: grid;
940 grid-template-columns: repeat(2, 1fr);
941 gap: var(--space-2);
942 margin: var(--space-4) 0;
943 }
944
945 .backup-code {
946 padding: var(--space-2);
947 background: var(--bg-input);
948 border-radius: var(--radius-md);
949 text-align: center;
950 font-size: var(--text-sm);
951 font-family: ui-monospace, monospace;
952 }
953
954 .passkey-list {
955 display: flex;
956 flex-direction: column;
957 gap: var(--space-2);
958 margin-bottom: var(--space-4);
959 }
960
961 .passkey-item {
962 display: flex;
963 justify-content: space-between;
964 align-items: center;
965 padding: var(--space-3);
966 background: var(--bg-card);
967 border: 1px solid var(--border-color);
968 border-radius: var(--radius-lg);
969 gap: var(--space-4);
970 }
971
972 .passkey-info {
973 display: flex;
974 flex-direction: column;
975 gap: var(--space-1);
976 flex: 1;
977 min-width: 0;
978 }
979
980 .passkey-name {
981 font-weight: var(--font-medium);
982 overflow: hidden;
983 text-overflow: ellipsis;
984 white-space: nowrap;
985 }
986
987 .passkey-meta {
988 font-size: var(--text-xs);
989 color: var(--text-secondary);
990 }
991
992 .passkey-actions {
993 display: flex;
994 gap: var(--space-2);
995 flex-shrink: 0;
996 }
997
998 .passkey-edit {
999 display: flex;
1000 flex: 1;
1001 gap: var(--space-2);
1002 align-items: center;
1003 }
1004
1005 .passkey-name-input {
1006 flex: 1;
1007 padding: var(--space-2);
1008 font-size: var(--text-sm);
1009 }
1010
1011 .passkey-edit-actions {
1012 display: flex;
1013 gap: var(--space-1);
1014 }
1015
1016 button.small {
1017 padding: var(--space-2) var(--space-3);
1018 font-size: var(--text-xs);
1019 }
1020
1021 .add-passkey {
1022 margin-top: var(--space-4);
1023 padding-top: var(--space-4);
1024 border-top: 1px solid var(--border-color);
1025 }
1026
1027 .add-passkey .field {
1028 margin-bottom: var(--space-3);
1029 }
1030
1031 .section-link {
1032 display: inline-block;
1033 color: var(--accent);
1034 text-decoration: none;
1035 font-weight: var(--font-medium);
1036 }
1037
1038 .section-link:hover {
1039 text-decoration: underline;
1040 }
1041
1042 .hint {
1043 font-size: var(--text-sm);
1044 color: var(--text-secondary);
1045 margin: 0;
1046 }
1047
1048 .info-box-inline {
1049 background: var(--bg-card);
1050 border: 1px solid var(--border-color);
1051 border-radius: var(--radius-lg);
1052 padding: var(--space-4);
1053 margin-bottom: var(--space-4);
1054 font-size: var(--text-sm);
1055 }
1056
1057 .info-box-inline strong {
1058 display: block;
1059 margin-bottom: var(--space-2);
1060 }
1061
1062 .info-box-inline ul {
1063 margin: 0;
1064 padding-left: var(--space-5);
1065 color: var(--text-secondary);
1066 }
1067
1068 .info-box-inline li {
1069 margin-bottom: var(--space-1);
1070 }
1071
1072 .info-box-inline p {
1073 margin: 0;
1074 color: var(--text-secondary);
1075 }
1076
1077 .toggle-row {
1078 display: flex;
1079 justify-content: space-between;
1080 align-items: flex-start;
1081 gap: var(--space-4);
1082 padding: var(--space-4);
1083 background: var(--bg-card);
1084 border: 1px solid var(--border-color);
1085 border-radius: var(--radius-lg);
1086 margin-bottom: var(--space-4);
1087 }
1088
1089 .toggle-info {
1090 display: flex;
1091 flex-direction: column;
1092 gap: var(--space-1);
1093 }
1094
1095 .toggle-label {
1096 font-weight: var(--font-medium);
1097 }
1098
1099 .toggle-description {
1100 font-size: var(--text-sm);
1101 color: var(--text-secondary);
1102 }
1103
1104 .toggle-button {
1105 position: relative;
1106 width: 50px;
1107 height: 26px;
1108 padding: 0;
1109 border: none;
1110 border-radius: 13px;
1111 cursor: pointer;
1112 transition: background var(--transition-fast);
1113 flex-shrink: 0;
1114 }
1115
1116 .toggle-button.on {
1117 background: var(--success-text);
1118 }
1119
1120 .toggle-button.off {
1121 background: var(--text-secondary);
1122 }
1123
1124 .toggle-button:disabled {
1125 opacity: 0.6;
1126 cursor: not-allowed;
1127 }
1128
1129 .toggle-slider {
1130 position: absolute;
1131 top: 3px;
1132 width: 20px;
1133 height: 20px;
1134 background: white;
1135 border-radius: 50%;
1136 transition: left var(--transition-fast);
1137 }
1138
1139 .toggle-button.on .toggle-slider {
1140 left: 27px;
1141 }
1142
1143 .toggle-button.off .toggle-slider {
1144 left: 3px;
1145 }
1146
1147 .warning-box {
1148 background: var(--warning-bg);
1149 border: 1px solid var(--warning-border);
1150 border-left: 4px solid var(--warning-text);
1151 border-radius: var(--radius-lg);
1152 padding: var(--space-4);
1153 margin-bottom: var(--space-4);
1154 }
1155
1156 .warning-box strong {
1157 display: block;
1158 margin-bottom: var(--space-2);
1159 color: var(--warning-text);
1160 }
1161
1162 .warning-box p {
1163 margin: 0 0 var(--space-3) 0;
1164 font-size: var(--text-sm);
1165 color: var(--text-primary);
1166 }
1167
1168 .warning-box ol {
1169 margin: 0;
1170 padding-left: var(--space-5);
1171 font-size: var(--text-sm);
1172 }
1173
1174 .warning-box li {
1175 margin-bottom: var(--space-2);
1176 }
1177
1178 .warning-box a {
1179 color: var(--accent);
1180 }
1181
1182 .skeleton-grid {
1183 display: grid;
1184 grid-template-columns: repeat(2, 1fr);
1185 gap: var(--space-6);
1186 }
1187
1188 .skeleton-section {
1189 height: 200px;
1190 background: var(--bg-secondary);
1191 border-radius: var(--radius-xl);
1192 animation: skeleton-pulse 1.5s ease-in-out infinite;
1193 }
1194
1195 @keyframes skeleton-pulse {
1196 0%, 100% { opacity: 1; }
1197 50% { opacity: 0.5; }
1198 }
1199
1200 @media (max-width: 900px) {
1201 .skeleton-grid {
1202 grid-template-columns: 1fr;
1203 }
1204 }
1205</style>