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