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