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', 'Session expired. Please log in again.')
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 <section>
418 <h2>{$_('security.totp')}</h2>
419 <p class="description">
420 {$_('security.totpDescription')}
421 </p>
422
423 {#if setupStep === 'idle'}
424 {#if totpEnabled}
425 <div class="status enabled">
426 <span>{$_('security.totpEnabled')}</span>
427 </div>
428
429 {#if !showDisableForm && !showRegenForm}
430 <div class="totp-actions">
431 <button type="button" class="secondary" onclick={() => showRegenForm = true}>
432 {$_('security.regenerateBackupCodes')}
433 </button>
434 <button type="button" class="danger-outline" onclick={() => showDisableForm = true}>
435 {$_('security.disableTotp')}
436 </button>
437 </div>
438 {/if}
439
440 {#if showRegenForm}
441 <form onsubmit={handleRegenerate} class="inline-form">
442 <h3>{$_('security.regenerateBackupCodes')}</h3>
443 <p class="warning-text">{$_('security.regenerateConfirm')}</p>
444 <div class="field">
445 <label for="regen-password">{$_('security.password')}</label>
446 <input
447 id="regen-password"
448 type="password"
449 bind:value={regenPassword}
450 placeholder={$_('security.enterPassword')}
451 disabled={regenLoading}
452 required
453 />
454 </div>
455 <div class="field">
456 <label for="regen-code">{$_('security.totpCode')}</label>
457 <input
458 id="regen-code"
459 type="text"
460 bind:value={regenCode}
461 placeholder="{$_('security.totpCodePlaceholder')}"
462 disabled={regenLoading}
463 required
464 maxlength="6"
465 pattern="[0-9]{6}"
466 inputmode="numeric"
467 />
468 </div>
469 <div class="actions">
470 <button type="button" class="secondary" onclick={() => { showRegenForm = false; regenPassword = ''; regenCode = '' }}>
471 {$_('common.cancel')}
472 </button>
473 <button type="submit" disabled={regenLoading || !regenPassword || regenCode.length !== 6}>
474 {regenLoading ? $_('security.regenerating') : $_('security.regenerateBackupCodes')}
475 </button>
476 </div>
477 </form>
478 {/if}
479
480 {#if showDisableForm}
481 <form onsubmit={handleDisable} class="inline-form danger-form">
482 <h3>{$_('security.disableTotp')}</h3>
483 <p class="warning-text">{$_('security.disableTotpWarning')}</p>
484 <div class="field">
485 <label for="disable-password">{$_('security.password')}</label>
486 <input
487 id="disable-password"
488 type="password"
489 bind:value={disablePassword}
490 placeholder={$_('security.enterPassword')}
491 disabled={disableLoading}
492 required
493 />
494 </div>
495 <div class="field">
496 <label for="disable-code">{$_('security.totpCode')}</label>
497 <input
498 id="disable-code"
499 type="text"
500 bind:value={disableCode}
501 placeholder="{$_('security.totpCodePlaceholder')}"
502 disabled={disableLoading}
503 required
504 maxlength="6"
505 pattern="[0-9]{6}"
506 inputmode="numeric"
507 />
508 </div>
509 <div class="actions">
510 <button type="button" class="secondary" onclick={() => { showDisableForm = false; disablePassword = ''; disableCode = '' }}>
511 {$_('common.cancel')}
512 </button>
513 <button type="submit" class="danger" disabled={disableLoading || !disablePassword || disableCode.length !== 6}>
514 {disableLoading ? $_('security.disabling') : $_('security.disableTotp')}
515 </button>
516 </div>
517 </form>
518 {/if}
519 {:else}
520 <div class="status disabled">
521 <span>{$_('security.totpDisabled')}</span>
522 </div>
523 <button onclick={handleStartSetup} disabled={verifyLoading}>
524 {$_('security.enableTotp')}
525 </button>
526 {/if}
527 {:else if setupStep === 'qr'}
528 <div class="setup-step">
529 <h3>{$_('security.totpSetup')}</h3>
530 <p>{$_('security.totpSetupInstructions')}</p>
531 <div class="qr-container">
532 <img src="data:image/png;base64,{qrBase64}" alt="TOTP QR Code" class="qr-code" />
533 </div>
534 <details class="manual-entry">
535 <summary>{$_('security.cantScan')}</summary>
536 <code class="secret-code">{totpUri.split('secret=')[1]?.split('&')[0] || ''}</code>
537 </details>
538 <button onclick={() => setupStep = 'verify'}>
539 {$_('security.next')}
540 </button>
541 </div>
542 {:else if setupStep === 'verify'}
543 <div class="setup-step">
544 <h3>{$_('security.totpSetup')}</h3>
545 <p>{$_('security.totpCodePlaceholder')}</p>
546 <form onsubmit={handleVerifySetup}>
547 <div class="field">
548 <input
549 type="text"
550 bind:value={verifyCodeRaw}
551 placeholder="000000"
552 disabled={verifyLoading}
553 inputmode="numeric"
554 class="code-input"
555 />
556 </div>
557 <div class="actions">
558 <button type="button" class="secondary" onclick={() => { setupStep = 'qr' }}>
559 {$_('common.back')}
560 </button>
561 <button type="submit" disabled={verifyLoading || verifyCode.length !== 6}>
562 {$_('security.verifyAndEnable')}
563 </button>
564 </div>
565 </form>
566 </div>
567 {:else if setupStep === 'backup'}
568 <div class="setup-step">
569 <h3>{$_('security.backupCodes')}</h3>
570 <p class="warning-text">
571 {$_('security.backupCodesDescription')}
572 </p>
573 <div class="backup-codes">
574 {#each backupCodes as code}
575 <code class="backup-code">{code}</code>
576 {/each}
577 </div>
578 <div class="actions">
579 <button type="button" class="secondary" onclick={copyBackupCodes}>
580 {$_('security.copyToClipboard')}
581 </button>
582 <button onclick={handleFinishSetup}>
583 {$_('security.savedMyCodes')}
584 </button>
585 </div>
586 </div>
587 {/if}
588 </section>
589
590 <section>
591 <h2>{$_('security.passkeys')}</h2>
592 <p class="description">
593 {$_('security.passkeysDescription')}
594 </p>
595
596 {#if passkeysLoading}
597 <div class="loading">{$_('security.loadingPasskeys')}</div>
598 {:else}
599 {#if passkeys.length > 0}
600 <div class="passkey-list">
601 {#each passkeys as passkey}
602 <div class="passkey-item">
603 {#if editingPasskeyId === passkey.id}
604 <div class="passkey-edit">
605 <input
606 type="text"
607 bind:value={editPasskeyName}
608 placeholder="{$_('security.passkeyName')}"
609 class="passkey-name-input"
610 />
611 <div class="passkey-edit-actions">
612 <button type="button" class="small" onclick={handleSavePasskeyName}>{$_('common.save')}</button>
613 <button type="button" class="small secondary" onclick={cancelEditPasskey}>{$_('common.cancel')}</button>
614 </div>
615 </div>
616 {:else}
617 <div class="passkey-info">
618 <span class="passkey-name">{passkey.friendlyName || $_('security.unnamedPasskey')}</span>
619 <span class="passkey-meta">
620 {$_('security.added')} {formatDate(passkey.createdAt)}
621 {#if passkey.lastUsed}
622 · {$_('security.lastUsed')} {formatDate(passkey.lastUsed)}
623 {/if}
624 </span>
625 </div>
626 <div class="passkey-actions">
627 <button type="button" class="small secondary" onclick={() => startEditPasskey(passkey)}>
628 {$_('security.rename')}
629 </button>
630 {#if hasPassword || passkeys.length > 1}
631 <button type="button" class="small danger-outline" onclick={() => handleDeletePasskey(passkey.id)}>
632 {$_('security.deletePasskey')}
633 </button>
634 {/if}
635 </div>
636 {/if}
637 </div>
638 {/each}
639 </div>
640 {:else}
641 <div class="status disabled">
642 <span>{$_('security.noPasskeys')}</span>
643 </div>
644 {/if}
645
646 <div class="add-passkey">
647 <div class="field">
648 <label for="passkey-name">{$_('security.passkeyName')}</label>
649 <input
650 id="passkey-name"
651 type="text"
652 bind:value={newPasskeyName}
653 placeholder="{$_('security.passkeyNamePlaceholder')}"
654 disabled={addingPasskey}
655 />
656 </div>
657 <button onclick={handleAddPasskey} disabled={addingPasskey}>
658 {addingPasskey ? $_('security.adding') : $_('security.addPasskey')}
659 </button>
660 </div>
661 {/if}
662 </section>
663
664 <section>
665 <h2>{$_('security.password')}</h2>
666 <p class="description">
667 {$_('security.passwordDescription')}
668 </p>
669
670 {#if passwordLoading}
671 <div class="loading">{$_('common.loading')}</div>
672 {:else if hasPassword}
673 <div class="status enabled">
674 <span>{$_('security.passwordStatus')}</span>
675 </div>
676
677 {#if passkeys.length > 0}
678 {#if !showRemovePasswordForm}
679 <button type="button" class="danger-outline" onclick={() => showRemovePasswordForm = true}>
680 {$_('security.removePassword')}
681 </button>
682 {:else}
683 <div class="inline-form danger-form">
684 <h3>{$_('security.removePassword')}</h3>
685 <p class="warning-text">
686 {$_('security.removePasswordWarning')}
687 </p>
688 <div class="info-box-inline">
689 <strong>{$_('security.beforeProceeding')}</strong>
690 <ul>
691 <li>{$_('security.beforeProceedingItem1')}</li>
692 <li>{$_('security.beforeProceedingItem2')}</li>
693 <li>{$_('security.beforeProceedingItem3')}</li>
694 </ul>
695 </div>
696 <div class="actions">
697 <button type="button" class="secondary" onclick={() => showRemovePasswordForm = false}>
698 {$_('common.cancel')}
699 </button>
700 <button type="button" class="danger" onclick={handleRemovePassword} disabled={removePasswordLoading}>
701 {removePasswordLoading ? $_('security.removing') : $_('security.removePassword')}
702 </button>
703 </div>
704 </div>
705 {/if}
706 {:else}
707 <p class="hint">{$_('security.addPasskeyFirst')}</p>
708 {/if}
709 {:else}
710 <div class="status passkey-only">
711 <span>{$_('security.noPassword')}</span>
712 </div>
713 <p class="hint">
714 {$_('security.passkeyOnlyHint')}
715 </p>
716 {/if}
717 </section>
718
719 <section>
720 <h2>{$_('security.trustedDevices')}</h2>
721 <p class="description">
722 {$_('security.trustedDevicesDescription')}
723 </p>
724 <a href="#/trusted-devices" class="section-link">
725 {$_('security.manageTrustedDevices')} →
726 </a>
727 </section>
728
729 {#if hasMfa}
730 <section>
731 <h2>{$_('security.appCompatibility')}</h2>
732 <p class="description">
733 {$_('security.legacyLoginDescription')}
734 </p>
735
736 {#if legacyLoginLoading}
737 <div class="loading">{$_('common.loading')}</div>
738 {:else}
739 <div class="toggle-row">
740 <div class="toggle-info">
741 <span class="toggle-label">{$_('security.legacyLogin')}</span>
742 <span class="toggle-description">
743 {#if allowLegacyLogin}
744 {$_('security.legacyLoginOn')}
745 {:else}
746 {$_('security.legacyLoginOff')}
747 {/if}
748 </span>
749 </div>
750 <button
751 type="button"
752 class="toggle-button {allowLegacyLogin ? 'on' : 'off'}"
753 onclick={handleToggleLegacyLogin}
754 disabled={legacyLoginUpdating}
755 aria-label={allowLegacyLogin ? $_('security.disableLegacyLogin') : $_('security.enableLegacyLogin')}
756 >
757 <span class="toggle-slider"></span>
758 </button>
759 </div>
760
761 {#if totpEnabled}
762 <div class="warning-box">
763 <strong>{$_('security.legacyLoginWarning')}</strong>
764 <p>{$_('security.totpPasswordWarning')}</p>
765 <ol>
766 <li><strong>{$_('security.totpPasswordOption1Label')}</strong> {$_('security.totpPasswordOption1Text')} <a href="#/settings">{$_('security.totpPasswordOption1Link')}</a> {$_('security.totpPasswordOption1Suffix')}</li>
767 <li><strong>{$_('security.totpPasswordOption2Label')}</strong> {$_('security.totpPasswordOption2Text')} <a href="#/settings">{$_('security.totpPasswordOption2Link')}</a> {$_('security.totpPasswordOption2Suffix')}</li>
768 </ol>
769 </div>
770 {/if}
771
772 <div class="info-box-inline">
773 <strong>{$_('security.legacyAppsTitle')}</strong>
774 <p>{$_('security.legacyAppsDescription')}</p>
775 </div>
776 {/if}
777 </section>
778 {/if}
779 {/if}
780</div>
781
782<ReauthModal
783 bind:show={showReauthModal}
784 availableMethods={reauthMethods}
785 onSuccess={handleReauthSuccess}
786 onCancel={handleReauthCancel}
787/>
788
789<style>
790 .page {
791 max-width: var(--width-md);
792 margin: 0 auto;
793 padding: var(--space-7);
794 }
795
796 header {
797 margin-bottom: var(--space-7);
798 }
799
800 .back {
801 color: var(--text-secondary);
802 text-decoration: none;
803 font-size: var(--text-sm);
804 }
805
806 .back:hover {
807 color: var(--accent);
808 }
809
810 h1 {
811 margin: var(--space-2) 0 0 0;
812 }
813
814 .loading {
815 text-align: center;
816 color: var(--text-secondary);
817 padding: var(--space-7);
818 }
819
820 section {
821 padding: var(--space-6);
822 background: var(--bg-secondary);
823 border-radius: var(--radius-xl);
824 margin-bottom: var(--space-6);
825 }
826
827 section h2 {
828 margin: 0 0 var(--space-2) 0;
829 font-size: var(--text-lg);
830 }
831
832 .description {
833 color: var(--text-secondary);
834 font-size: var(--text-sm);
835 margin-bottom: var(--space-6);
836 }
837
838 .status {
839 display: flex;
840 align-items: center;
841 gap: var(--space-2);
842 padding: var(--space-3);
843 border-radius: var(--radius-md);
844 margin-bottom: var(--space-4);
845 }
846
847 .status.enabled {
848 background: var(--success-bg);
849 border: 1px solid var(--success-border);
850 color: var(--success-text);
851 }
852
853 .status.disabled {
854 background: var(--warning-bg);
855 border: 1px solid var(--border-color);
856 color: var(--warning-text);
857 }
858
859 .status.passkey-only {
860 background: linear-gradient(135deg, rgba(77, 166, 255, 0.15), rgba(128, 90, 213, 0.15));
861 border: 1px solid var(--accent);
862 color: var(--accent);
863 }
864
865 .totp-actions {
866 display: flex;
867 gap: var(--space-2);
868 flex-wrap: wrap;
869 }
870
871 .code-input {
872 font-size: var(--text-2xl);
873 letter-spacing: 0.5em;
874 text-align: center;
875 max-width: 200px;
876 margin: 0 auto;
877 display: block;
878 }
879
880 .actions {
881 display: flex;
882 gap: var(--space-2);
883 margin-top: var(--space-4);
884 }
885
886 .inline-form {
887 margin-top: var(--space-4);
888 padding: var(--space-4);
889 background: var(--bg-card);
890 border: 1px solid var(--border-color);
891 border-radius: var(--radius-lg);
892 }
893
894 .inline-form h3 {
895 margin: 0 0 var(--space-2) 0;
896 font-size: var(--text-base);
897 }
898
899 .danger-form {
900 border-color: var(--error-border);
901 background: var(--error-bg);
902 }
903
904 .warning-text {
905 color: var(--error-text);
906 font-size: var(--text-sm);
907 margin-bottom: var(--space-4);
908 }
909
910 .setup-step {
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 .setup-step h3 {
918 margin: 0 0 var(--space-2) 0;
919 }
920
921 .setup-step p {
922 color: var(--text-secondary);
923 font-size: var(--text-sm);
924 margin-bottom: var(--space-4);
925 }
926
927 .qr-container {
928 display: flex;
929 justify-content: center;
930 margin: var(--space-6) 0;
931 }
932
933 .qr-code {
934 width: 200px;
935 height: 200px;
936 image-rendering: pixelated;
937 }
938
939 .manual-entry {
940 margin-bottom: var(--space-4);
941 font-size: var(--text-sm);
942 }
943
944 .manual-entry summary {
945 cursor: pointer;
946 color: var(--accent);
947 }
948
949 .secret-code {
950 display: block;
951 margin-top: var(--space-2);
952 padding: var(--space-2);
953 background: var(--bg-input);
954 border-radius: var(--radius-md);
955 word-break: break-all;
956 font-size: var(--text-xs);
957 }
958
959 .backup-codes {
960 display: grid;
961 grid-template-columns: repeat(2, 1fr);
962 gap: var(--space-2);
963 margin: var(--space-4) 0;
964 }
965
966 .backup-code {
967 padding: var(--space-2);
968 background: var(--bg-input);
969 border-radius: var(--radius-md);
970 text-align: center;
971 font-size: var(--text-sm);
972 font-family: ui-monospace, monospace;
973 }
974
975 .passkey-list {
976 display: flex;
977 flex-direction: column;
978 gap: var(--space-2);
979 margin-bottom: var(--space-4);
980 }
981
982 .passkey-item {
983 display: flex;
984 justify-content: space-between;
985 align-items: center;
986 padding: var(--space-3);
987 background: var(--bg-card);
988 border: 1px solid var(--border-color);
989 border-radius: var(--radius-lg);
990 gap: var(--space-4);
991 }
992
993 .passkey-info {
994 display: flex;
995 flex-direction: column;
996 gap: var(--space-1);
997 flex: 1;
998 min-width: 0;
999 }
1000
1001 .passkey-name {
1002 font-weight: var(--font-medium);
1003 overflow: hidden;
1004 text-overflow: ellipsis;
1005 white-space: nowrap;
1006 }
1007
1008 .passkey-meta {
1009 font-size: var(--text-xs);
1010 color: var(--text-secondary);
1011 }
1012
1013 .passkey-actions {
1014 display: flex;
1015 gap: var(--space-2);
1016 flex-shrink: 0;
1017 }
1018
1019 .passkey-edit {
1020 display: flex;
1021 flex: 1;
1022 gap: var(--space-2);
1023 align-items: center;
1024 }
1025
1026 .passkey-name-input {
1027 flex: 1;
1028 padding: var(--space-2);
1029 font-size: var(--text-sm);
1030 }
1031
1032 .passkey-edit-actions {
1033 display: flex;
1034 gap: var(--space-1);
1035 }
1036
1037 button.small {
1038 padding: var(--space-2) var(--space-3);
1039 font-size: var(--text-xs);
1040 }
1041
1042 .add-passkey {
1043 margin-top: var(--space-4);
1044 padding-top: var(--space-4);
1045 border-top: 1px solid var(--border-color);
1046 }
1047
1048 .add-passkey .field {
1049 margin-bottom: var(--space-3);
1050 }
1051
1052 .section-link {
1053 display: inline-block;
1054 color: var(--accent);
1055 text-decoration: none;
1056 font-weight: var(--font-medium);
1057 }
1058
1059 .section-link:hover {
1060 text-decoration: underline;
1061 }
1062
1063 .hint {
1064 font-size: var(--text-sm);
1065 color: var(--text-secondary);
1066 margin: 0;
1067 }
1068
1069 .info-box-inline {
1070 background: var(--bg-card);
1071 border: 1px solid var(--border-color);
1072 border-radius: var(--radius-lg);
1073 padding: var(--space-4);
1074 margin-bottom: var(--space-4);
1075 font-size: var(--text-sm);
1076 }
1077
1078 .info-box-inline strong {
1079 display: block;
1080 margin-bottom: var(--space-2);
1081 }
1082
1083 .info-box-inline ul {
1084 margin: 0;
1085 padding-left: var(--space-5);
1086 color: var(--text-secondary);
1087 }
1088
1089 .info-box-inline li {
1090 margin-bottom: var(--space-1);
1091 }
1092
1093 .info-box-inline p {
1094 margin: 0;
1095 color: var(--text-secondary);
1096 }
1097
1098 .toggle-row {
1099 display: flex;
1100 justify-content: space-between;
1101 align-items: flex-start;
1102 gap: var(--space-4);
1103 padding: var(--space-4);
1104 background: var(--bg-card);
1105 border: 1px solid var(--border-color);
1106 border-radius: var(--radius-lg);
1107 margin-bottom: var(--space-4);
1108 }
1109
1110 .toggle-info {
1111 display: flex;
1112 flex-direction: column;
1113 gap: var(--space-1);
1114 }
1115
1116 .toggle-label {
1117 font-weight: var(--font-medium);
1118 }
1119
1120 .toggle-description {
1121 font-size: var(--text-sm);
1122 color: var(--text-secondary);
1123 }
1124
1125 .toggle-button {
1126 position: relative;
1127 width: 50px;
1128 height: 26px;
1129 padding: 0;
1130 border: none;
1131 border-radius: 13px;
1132 cursor: pointer;
1133 transition: background var(--transition-fast);
1134 flex-shrink: 0;
1135 }
1136
1137 .toggle-button.on {
1138 background: var(--success-text);
1139 }
1140
1141 .toggle-button.off {
1142 background: var(--text-secondary);
1143 }
1144
1145 .toggle-button:disabled {
1146 opacity: 0.6;
1147 cursor: not-allowed;
1148 }
1149
1150 .toggle-slider {
1151 position: absolute;
1152 top: 3px;
1153 width: 20px;
1154 height: 20px;
1155 background: white;
1156 border-radius: 50%;
1157 transition: left var(--transition-fast);
1158 }
1159
1160 .toggle-button.on .toggle-slider {
1161 left: 27px;
1162 }
1163
1164 .toggle-button.off .toggle-slider {
1165 left: 3px;
1166 }
1167
1168 .warning-box {
1169 background: var(--warning-bg);
1170 border: 1px solid var(--warning-border);
1171 border-left: 4px solid var(--warning-text);
1172 border-radius: var(--radius-lg);
1173 padding: var(--space-4);
1174 margin-bottom: var(--space-4);
1175 }
1176
1177 .warning-box strong {
1178 display: block;
1179 margin-bottom: var(--space-2);
1180 color: var(--warning-text);
1181 }
1182
1183 .warning-box p {
1184 margin: 0 0 var(--space-3) 0;
1185 font-size: var(--text-sm);
1186 color: var(--text-primary);
1187 }
1188
1189 .warning-box ol {
1190 margin: 0;
1191 padding-left: var(--space-5);
1192 font-size: var(--text-sm);
1193 }
1194
1195 .warning-box li {
1196 margin-bottom: var(--space-2);
1197 }
1198
1199 .warning-box a {
1200 color: var(--accent);
1201 }
1202</style>