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 <p class="hint">
682 {$_('security.addPasswordHint')}
683 </p>
684 <a href={getFullUrl(routes.settings)} class="section-link">
685 {$_('security.goToSettings')}
686 </a>
687 {/if}
688 </section>
689
690 <section>
691 <h2>{$_('security.trustedDevices')}</h2>
692 <p class="description">
693 {$_('security.trustedDevicesDescription')}
694 </p>
695 <a href={getFullUrl(routes.trustedDevices)} class="section-link">
696 {$_('security.manageTrustedDevices')} →
697 </a>
698 </section>
699 </div>
700
701 {#if hasMfa}
702 <section>
703 <h2>{$_('security.appCompatibility')}</h2>
704 <p class="description">
705 {$_('security.legacyLoginDescription')}
706 </p>
707
708 {#if !legacyLoginLoading}
709 <div class="toggle-row">
710 <div class="toggle-info">
711 <span class="toggle-label">{$_('security.legacyLogin')}</span>
712 <span class="toggle-description">
713 {#if allowLegacyLogin}
714 {$_('security.legacyLoginOn')}
715 {:else}
716 {$_('security.legacyLoginOff')}
717 {/if}
718 </span>
719 </div>
720 <button
721 type="button"
722 class="toggle-button {allowLegacyLogin ? 'on' : 'off'}"
723 onclick={handleToggleLegacyLogin}
724 disabled={legacyLoginUpdating}
725 aria-label={allowLegacyLogin ? $_('security.disableLegacyLogin') : $_('security.enableLegacyLogin')}
726 >
727 <span class="toggle-slider"></span>
728 </button>
729 </div>
730
731 {#if totpEnabled}
732 <div class="warning-box">
733 <strong>{$_('security.legacyLoginWarning')}</strong>
734 <p>{$_('security.totpPasswordWarning')}</p>
735 <ol>
736 <li><strong>{$_('security.totpPasswordOption1Label')}</strong> {$_('security.totpPasswordOption1Text')} <a href={getFullUrl(routes.settings)}>{$_('security.totpPasswordOption1Link')}</a> {$_('security.totpPasswordOption1Suffix')}</li>
737 <li><strong>{$_('security.totpPasswordOption2Label')}</strong> {$_('security.totpPasswordOption2Text')} <a href={getFullUrl(routes.settings)}>{$_('security.totpPasswordOption2Link')}</a> {$_('security.totpPasswordOption2Suffix')}</li>
738 </ol>
739 </div>
740 {/if}
741
742 <div class="info-box-inline">
743 <strong>{$_('security.legacyAppsTitle')}</strong>
744 <p>{$_('security.legacyAppsDescription')}</p>
745 </div>
746 {/if}
747 </section>
748 {/if}
749 {/if}
750</div>
751
752<ReauthModal
753 bind:show={showReauthModal}
754 availableMethods={reauthMethods}
755 onSuccess={handleReauthSuccess}
756 onCancel={handleReauthCancel}
757/>
758
759<style>
760 .page {
761 max-width: var(--width-lg);
762 margin: 0 auto;
763 padding: var(--space-7);
764 }
765
766 header {
767 margin-bottom: var(--space-7);
768 }
769
770 .sections-grid {
771 display: flex;
772 flex-direction: column;
773 gap: var(--space-6);
774 margin-bottom: var(--space-6);
775 }
776
777 @media (min-width: 800px) {
778 .sections-grid {
779 columns: 2;
780 column-gap: var(--space-6);
781 display: block;
782 }
783
784 .sections-grid section {
785 break-inside: avoid;
786 margin-bottom: var(--space-6);
787 }
788 }
789
790 .back {
791 color: var(--text-secondary);
792 text-decoration: none;
793 font-size: var(--text-sm);
794 }
795
796 .back:hover {
797 color: var(--accent);
798 }
799
800 h1 {
801 margin: var(--space-2) 0 0 0;
802 }
803
804 section {
805 padding: var(--space-6);
806 background: var(--bg-secondary);
807 border-radius: var(--radius-xl);
808 margin-bottom: var(--space-6);
809 height: fit-content;
810 }
811
812 section h2 {
813 margin: 0 0 var(--space-2) 0;
814 font-size: var(--text-lg);
815 }
816
817 .description {
818 color: var(--text-secondary);
819 font-size: var(--text-sm);
820 margin-bottom: var(--space-6);
821 }
822
823 .status {
824 display: flex;
825 align-items: center;
826 gap: var(--space-2);
827 padding: var(--space-3);
828 border-radius: var(--radius-md);
829 margin-bottom: var(--space-4);
830 }
831
832 .status.enabled {
833 background: var(--success-bg);
834 border: 1px solid var(--success-border);
835 color: var(--success-text);
836 }
837
838 .status.disabled {
839 background: var(--warning-bg);
840 border: 1px solid var(--border-color);
841 color: var(--warning-text);
842 }
843
844 .status.passkey-only {
845 background: linear-gradient(135deg, rgba(77, 166, 255, 0.15), rgba(128, 90, 213, 0.15));
846 border: 1px solid var(--accent);
847 color: var(--accent);
848 }
849
850 .totp-actions {
851 display: flex;
852 gap: var(--space-2);
853 flex-wrap: wrap;
854 }
855
856 .code-input {
857 font-size: var(--text-2xl);
858 letter-spacing: 0.5em;
859 text-align: center;
860 max-width: 200px;
861 margin: 0 auto;
862 display: block;
863 }
864
865 .actions {
866 display: flex;
867 gap: var(--space-2);
868 margin-top: var(--space-4);
869 }
870
871 .inline-form {
872 margin-top: var(--space-4);
873 padding: var(--space-4);
874 background: var(--bg-card);
875 border: 1px solid var(--border-color);
876 border-radius: var(--radius-lg);
877 }
878
879 .inline-form h3 {
880 margin: 0 0 var(--space-2) 0;
881 font-size: var(--text-base);
882 }
883
884 .danger-form {
885 border-color: var(--error-border);
886 background: var(--error-bg);
887 }
888
889 .warning-text {
890 color: var(--error-text);
891 font-size: var(--text-sm);
892 margin-bottom: var(--space-4);
893 }
894
895 .setup-step {
896 padding: var(--space-4);
897 background: var(--bg-card);
898 border: 1px solid var(--border-color);
899 border-radius: var(--radius-lg);
900 }
901
902 .setup-step h3 {
903 margin: 0 0 var(--space-2) 0;
904 }
905
906 .setup-step p {
907 color: var(--text-secondary);
908 font-size: var(--text-sm);
909 margin-bottom: var(--space-4);
910 }
911
912 .qr-container {
913 display: flex;
914 justify-content: center;
915 margin: var(--space-6) 0;
916 }
917
918 .qr-code {
919 width: 200px;
920 height: 200px;
921 image-rendering: pixelated;
922 }
923
924 .manual-entry {
925 margin-bottom: var(--space-4);
926 font-size: var(--text-sm);
927 }
928
929 .manual-entry summary {
930 cursor: pointer;
931 color: var(--accent);
932 }
933
934 .secret-code {
935 display: block;
936 margin-top: var(--space-2);
937 padding: var(--space-2);
938 background: var(--bg-input);
939 border-radius: var(--radius-md);
940 word-break: break-all;
941 font-size: var(--text-xs);
942 }
943
944 .backup-codes {
945 display: grid;
946 grid-template-columns: repeat(2, 1fr);
947 gap: var(--space-2);
948 margin: var(--space-4) 0;
949 }
950
951 .backup-code {
952 padding: var(--space-2);
953 background: var(--bg-input);
954 border-radius: var(--radius-md);
955 text-align: center;
956 font-size: var(--text-sm);
957 font-family: ui-monospace, monospace;
958 }
959
960 .passkey-list {
961 display: flex;
962 flex-direction: column;
963 gap: var(--space-2);
964 margin-bottom: var(--space-4);
965 }
966
967 .passkey-item {
968 display: flex;
969 justify-content: space-between;
970 align-items: center;
971 padding: var(--space-3);
972 background: var(--bg-card);
973 border: 1px solid var(--border-color);
974 border-radius: var(--radius-lg);
975 gap: var(--space-4);
976 }
977
978 .passkey-info {
979 display: flex;
980 flex-direction: column;
981 gap: var(--space-1);
982 flex: 1;
983 min-width: 0;
984 }
985
986 .passkey-name {
987 font-weight: var(--font-medium);
988 overflow: hidden;
989 text-overflow: ellipsis;
990 white-space: nowrap;
991 }
992
993 .passkey-meta {
994 font-size: var(--text-xs);
995 color: var(--text-secondary);
996 }
997
998 .passkey-actions {
999 display: flex;
1000 gap: var(--space-2);
1001 flex-shrink: 0;
1002 }
1003
1004 .passkey-edit {
1005 display: flex;
1006 flex: 1;
1007 gap: var(--space-2);
1008 align-items: center;
1009 }
1010
1011 .passkey-name-input {
1012 flex: 1;
1013 padding: var(--space-2);
1014 font-size: var(--text-sm);
1015 }
1016
1017 .passkey-edit-actions {
1018 display: flex;
1019 gap: var(--space-1);
1020 }
1021
1022 button.small {
1023 padding: var(--space-2) var(--space-3);
1024 font-size: var(--text-xs);
1025 }
1026
1027 .add-passkey {
1028 margin-top: var(--space-4);
1029 padding-top: var(--space-4);
1030 border-top: 1px solid var(--border-color);
1031 }
1032
1033 .add-passkey .field {
1034 margin-bottom: var(--space-3);
1035 }
1036
1037 .section-link {
1038 display: inline-block;
1039 color: var(--accent);
1040 text-decoration: none;
1041 font-weight: var(--font-medium);
1042 }
1043
1044 .section-link:hover {
1045 text-decoration: underline;
1046 }
1047
1048 .hint {
1049 font-size: var(--text-sm);
1050 color: var(--text-secondary);
1051 margin: 0;
1052 }
1053
1054 .info-box-inline {
1055 background: var(--bg-card);
1056 border: 1px solid var(--border-color);
1057 border-radius: var(--radius-lg);
1058 padding: var(--space-4);
1059 margin-bottom: var(--space-4);
1060 font-size: var(--text-sm);
1061 }
1062
1063 .info-box-inline strong {
1064 display: block;
1065 margin-bottom: var(--space-2);
1066 }
1067
1068 .info-box-inline ul {
1069 margin: 0;
1070 padding-left: var(--space-5);
1071 color: var(--text-secondary);
1072 }
1073
1074 .info-box-inline li {
1075 margin-bottom: var(--space-1);
1076 }
1077
1078 .info-box-inline p {
1079 margin: 0;
1080 color: var(--text-secondary);
1081 }
1082
1083 .toggle-row {
1084 display: flex;
1085 justify-content: space-between;
1086 align-items: flex-start;
1087 gap: var(--space-4);
1088 padding: var(--space-4);
1089 background: var(--bg-card);
1090 border: 1px solid var(--border-color);
1091 border-radius: var(--radius-lg);
1092 margin-bottom: var(--space-4);
1093 }
1094
1095 .toggle-info {
1096 display: flex;
1097 flex-direction: column;
1098 gap: var(--space-1);
1099 }
1100
1101 .toggle-label {
1102 font-weight: var(--font-medium);
1103 }
1104
1105 .toggle-description {
1106 font-size: var(--text-sm);
1107 color: var(--text-secondary);
1108 }
1109
1110 .toggle-button {
1111 position: relative;
1112 width: 50px;
1113 height: 26px;
1114 padding: 0;
1115 border: none;
1116 border-radius: 13px;
1117 cursor: pointer;
1118 transition: background var(--transition-fast);
1119 flex-shrink: 0;
1120 }
1121
1122 .toggle-button.on {
1123 background: var(--success-text);
1124 }
1125
1126 .toggle-button.off {
1127 background: var(--text-secondary);
1128 }
1129
1130 .toggle-button:disabled {
1131 opacity: 0.6;
1132 cursor: not-allowed;
1133 }
1134
1135 .toggle-slider {
1136 position: absolute;
1137 top: 3px;
1138 width: 20px;
1139 height: 20px;
1140 background: white;
1141 border-radius: 50%;
1142 transition: left var(--transition-fast);
1143 }
1144
1145 .toggle-button.on .toggle-slider {
1146 left: 27px;
1147 }
1148
1149 .toggle-button.off .toggle-slider {
1150 left: 3px;
1151 }
1152
1153 .warning-box {
1154 background: var(--warning-bg);
1155 border: 1px solid var(--warning-border);
1156 border-left: 4px solid var(--warning-text);
1157 border-radius: var(--radius-lg);
1158 padding: var(--space-4);
1159 margin-bottom: var(--space-4);
1160 }
1161
1162 .warning-box strong {
1163 display: block;
1164 margin-bottom: var(--space-2);
1165 color: var(--warning-text);
1166 }
1167
1168 .warning-box p {
1169 margin: 0 0 var(--space-3) 0;
1170 font-size: var(--text-sm);
1171 color: var(--text-primary);
1172 }
1173
1174 .warning-box ol {
1175 margin: 0;
1176 padding-left: var(--space-5);
1177 font-size: var(--text-sm);
1178 }
1179
1180 .warning-box li {
1181 margin-bottom: var(--space-2);
1182 }
1183
1184 .warning-box a {
1185 color: var(--accent);
1186 }
1187
1188 .skeleton-grid {
1189 display: grid;
1190 grid-template-columns: repeat(2, 1fr);
1191 gap: var(--space-6);
1192 }
1193
1194 .skeleton-section {
1195 height: 200px;
1196 background: var(--bg-secondary);
1197 border-radius: var(--radius-xl);
1198 animation: skeleton-pulse 1.5s ease-in-out infinite;
1199 }
1200
1201 @keyframes skeleton-pulse {
1202 0%, 100% { opacity: 1; }
1203 50% { opacity: 0.5; }
1204 }
1205
1206 @media (max-width: 900px) {
1207 .skeleton-grid {
1208 grid-template-columns: 1fr;
1209 }
1210 }
1211</style>