+161
-806
Diff
round #2
+161
-806
frontend/src/components/dashboard/SecurityContent.svelte
+161
-806
frontend/src/components/dashboard/SecurityContent.svelte
···
1
1
<script lang="ts">
2
2
import { onMount } from 'svelte'
3
-
import { getValidToken } from '../../lib/auth.svelte'
4
3
import { api, ApiError } from '../../lib/api'
5
4
import { _ } from '../../lib/i18n'
6
5
import { formatDate } from '../../lib/date'
···
8
7
import { toast } from '../../lib/toast.svelte'
9
8
import ReauthModal from '../ReauthModal.svelte'
10
9
import SsoIcon from '../SsoIcon.svelte'
11
-
import {
12
-
prepareCreationOptions,
13
-
serializeAttestationResponse,
14
-
type WebAuthnCreationOptionsResponse,
15
-
} from '../../lib/webauthn'
16
-
import {
17
-
type TotpSetupState,
18
-
idleState,
19
-
qrState,
20
-
verifyState,
21
-
backupState,
22
-
goBackToQr,
23
-
finish,
24
-
type TotpQr,
25
-
} from '../../lib/types/totp-state'
10
+
import PasskeySection from './PasskeySection.svelte'
11
+
import TotpSection from './TotpSection.svelte'
12
+
import PasswordSection from './PasswordSection.svelte'
26
13
27
14
interface Props {
28
15
session: Session
···
30
17
31
18
let { session }: Props = $props()
32
19
33
-
let loading = $state(true)
34
-
let totpEnabled = $state(false)
35
-
let hasBackupCodes = $state(false)
36
-
let totpSetup = $state<TotpSetupState>(idleState)
37
-
let verifyCodeRaw = $state('')
38
-
let verifyCode = $derived(verifyCodeRaw.replace(/\s/g, ''))
39
-
let verifyLoading = $state(false)
40
-
41
-
interface Passkey {
42
-
id: string
43
-
credentialId: string
44
-
friendlyName: string | null
45
-
createdAt: string
46
-
lastUsed: string | null
47
-
}
48
-
let passkeys = $state<Passkey[]>([])
49
-
let passkeysLoading = $state(true)
50
-
let addingPasskey = $state(false)
51
-
let newPasskeyName = $state('')
52
-
let editingPasskeyId = $state<string | null>(null)
53
-
let editPasskeyName = $state('')
54
-
55
20
let hasPassword = $state(true)
56
-
let passwordLoading = $state(true)
57
-
let showRemovePasswordForm = $state(false)
58
-
let removePasswordLoading = $state(false)
59
-
60
-
let showChangePasswordForm = $state(false)
61
-
let currentPassword = $state('')
62
-
let newPassword = $state('')
63
-
let confirmNewPassword = $state('')
64
-
let changePasswordLoading = $state(false)
65
-
66
-
let showSetPasswordForm = $state(false)
67
-
let setNewPassword = $state('')
68
-
let setConfirmPassword = $state('')
69
-
let setPasswordLoading = $state(false)
70
-
71
-
let disablePassword = $state('')
72
-
let disableCode = $state('')
73
-
let disableLoading = $state(false)
74
-
let showDisableForm = $state(false)
75
-
76
-
let regenPassword = $state('')
77
-
let regenCode = $state('')
78
-
let regenLoading = $state(false)
79
-
let showRegenForm = $state(false)
21
+
let passkeyCount = $state(0)
22
+
let totpEnabled = $state(false)
80
23
81
24
interface SsoProvider {
82
25
provider: string
···
122
65
let reauthMethods = $state<string[]>(['password'])
123
66
let pendingAction = $state<(() => Promise<void>) | null>(null)
124
67
68
+
function handleReauthRequired(methods: string[], retryAction: () => Promise<void>) {
69
+
reauthMethods = methods
70
+
pendingAction = retryAction
71
+
showReauthModal = true
72
+
}
73
+
125
74
function handleReauthSuccess() {
126
75
if (pendingAction) {
127
76
pendingAction()
···
146
95
}
147
96
148
97
await Promise.all([
149
-
loadTotpStatus(),
150
-
loadPasskeys(),
151
-
loadPasswordStatus(),
152
98
loadSsoProviders(),
153
99
loadLinkedAccounts(),
154
100
loadLegacyLoginPreference(),
155
101
loadTrustedDevices()
156
102
])
157
-
loading = false
158
103
})
159
104
160
-
async function loadTotpStatus() {
161
-
try {
162
-
const status = await api.getTotpStatus(session.accessJwt)
163
-
totpEnabled = status.enabled
164
-
hasBackupCodes = status.hasBackupCodes
165
-
} catch {
166
-
toast.error($_('security.failedToLoadTotpStatus'))
167
-
}
168
-
}
169
-
170
-
async function loadPasskeys() {
171
-
passkeysLoading = true
172
-
try {
173
-
const result = await api.listPasskeys(session.accessJwt)
174
-
passkeys = result.passkeys
175
-
} catch {
176
-
toast.error($_('security.failedToLoadPasskeys'))
177
-
} finally {
178
-
passkeysLoading = false
179
-
}
180
-
}
181
-
182
-
async function loadPasswordStatus() {
183
-
passwordLoading = true
184
-
try {
185
-
const status = await api.getPasswordStatus(session.accessJwt)
186
-
hasPassword = status.hasPassword
187
-
} catch {
188
-
hasPassword = true
189
-
} finally {
190
-
passwordLoading = false
191
-
}
192
-
}
193
-
194
105
async function loadSsoProviders() {
195
106
try {
196
107
const response = await fetch('/oauth/sso/providers')
···
293
204
} catch (e) {
294
205
if (e instanceof ApiError) {
295
206
if (e.error === 'ReauthRequired') {
296
-
reauthMethods = e.reauthMethods || ['password']
297
-
pendingAction = () => handleLinkAccount(provider)
298
-
showReauthModal = true
207
+
handleReauthRequired(e.reauthMethods || ['password'], () => handleLinkAccount(provider))
299
208
} else {
300
209
toast.error(e.message || $_('oauth.sso.linkFailed'))
301
210
}
···
318
227
} catch (e) {
319
228
if (e instanceof ApiError) {
320
229
if (e.error === 'ReauthRequired') {
321
-
reauthMethods = e.reauthMethods || ['password']
322
-
pendingAction = () => handleUnlinkAccount(id)
323
-
showReauthModal = true
230
+
handleReauthRequired(e.reauthMethods || ['password'], () => handleUnlinkAccount(id))
324
231
} else {
325
232
toast.error(e.message || $_('oauth.sso.unlinkFailed'))
326
233
}
···
357
264
} catch (e) {
358
265
if (e instanceof ApiError) {
359
266
if (e.error === 'ReauthRequired' || e.error === 'MfaVerificationRequired') {
360
-
reauthMethods = e.reauthMethods || ['password']
361
-
pendingAction = handleToggleLegacyLogin
362
-
showReauthModal = true
267
+
handleReauthRequired(e.reauthMethods || ['password'], handleToggleLegacyLogin)
363
268
} else {
364
269
toast.error(e.message)
365
270
}
···
370
275
legacyLoginUpdating = false
371
276
}
372
277
}
373
-
374
-
async function handleRemovePassword() {
375
-
removePasswordLoading = true
376
-
try {
377
-
const token = await getValidToken()
378
-
if (!token) {
379
-
toast.error($_('security.sessionExpired'))
380
-
return
381
-
}
382
-
await api.removePassword(token)
383
-
hasPassword = false
384
-
showRemovePasswordForm = false
385
-
toast.success($_('security.passwordRemoved'))
386
-
} catch (e) {
387
-
if (e instanceof ApiError) {
388
-
if (e.error === 'ReauthRequired') {
389
-
reauthMethods = e.reauthMethods || ['password']
390
-
pendingAction = handleRemovePassword
391
-
showReauthModal = true
392
-
} else {
393
-
toast.error(e.message)
394
-
}
395
-
} else {
396
-
toast.error($_('security.failedToRemovePassword'))
397
-
}
398
-
} finally {
399
-
removePasswordLoading = false
400
-
}
401
-
}
402
-
403
-
async function handleChangePassword(e: Event) {
404
-
e.preventDefault()
405
-
if (!currentPassword || !newPassword || !confirmNewPassword) return
406
-
if (newPassword !== confirmNewPassword) {
407
-
toast.error($_('security.passwordsDoNotMatch'))
408
-
return
409
-
}
410
-
if (newPassword.length < 8) {
411
-
toast.error($_('security.passwordTooShort'))
412
-
return
413
-
}
414
-
changePasswordLoading = true
415
-
try {
416
-
await api.changePassword(session.accessJwt, currentPassword, newPassword)
417
-
toast.success($_('security.passwordChanged'))
418
-
currentPassword = ''
419
-
newPassword = ''
420
-
confirmNewPassword = ''
421
-
showChangePasswordForm = false
422
-
} catch (e) {
423
-
if (e instanceof ApiError) {
424
-
if (e.error === 'ReauthRequired') {
425
-
reauthMethods = e.reauthMethods || ['password']
426
-
pendingAction = () => handleChangePassword(new Event('submit'))
427
-
showReauthModal = true
428
-
} else {
429
-
toast.error(e.message)
430
-
}
431
-
} else {
432
-
toast.error($_('security.failedToChangePassword'))
433
-
}
434
-
} finally {
435
-
changePasswordLoading = false
436
-
}
437
-
}
438
-
439
-
async function handleSetPassword(e: Event) {
440
-
e.preventDefault()
441
-
if (!setNewPassword || !setConfirmPassword) return
442
-
if (setNewPassword !== setConfirmPassword) {
443
-
toast.error($_('security.passwordsDoNotMatch'))
444
-
return
445
-
}
446
-
if (setNewPassword.length < 8) {
447
-
toast.error($_('security.passwordTooShort'))
448
-
return
449
-
}
450
-
setPasswordLoading = true
451
-
try {
452
-
await api.setPassword(session.accessJwt, setNewPassword)
453
-
hasPassword = true
454
-
toast.success($_('security.passwordSet'))
455
-
setNewPassword = ''
456
-
setConfirmPassword = ''
457
-
showSetPasswordForm = false
458
-
} catch (e) {
459
-
if (e instanceof ApiError) {
460
-
if (e.error === 'ReauthRequired') {
461
-
reauthMethods = e.reauthMethods || ['passkey']
462
-
pendingAction = () => handleSetPassword(new Event('submit'))
463
-
showReauthModal = true
464
-
} else {
465
-
toast.error(e.message)
466
-
}
467
-
} else {
468
-
toast.error($_('security.failedToSetPassword'))
469
-
}
470
-
} finally {
471
-
setPasswordLoading = false
472
-
}
473
-
}
474
-
475
-
async function handleStartTotpSetup() {
476
-
verifyLoading = true
477
-
try {
478
-
const result = await api.createTotpSecret(session.accessJwt)
479
-
totpSetup = qrState(result.qrBase64, result.uri)
480
-
} catch (e) {
481
-
toast.error(e instanceof ApiError ? e.message : 'Failed to generate TOTP secret')
482
-
} finally {
483
-
verifyLoading = false
484
-
}
485
-
}
486
-
487
-
async function handleVerifyTotp(e: Event) {
488
-
e.preventDefault()
489
-
if (!verifyCode || totpSetup.step !== 'verify') return
490
-
verifyLoading = true
491
-
try {
492
-
const result = await api.enableTotp(session.accessJwt, verifyCode)
493
-
totpSetup = backupState(totpSetup, result.backupCodes)
494
-
totpEnabled = true
495
-
hasBackupCodes = true
496
-
verifyCodeRaw = ''
497
-
} catch (e) {
498
-
toast.error(e instanceof ApiError ? e.message : 'Invalid code')
499
-
} finally {
500
-
verifyLoading = false
501
-
}
502
-
}
503
-
504
-
function handleFinishSetup() {
505
-
if (totpSetup.step !== 'backup') return
506
-
totpSetup = finish(totpSetup)
507
-
toast.success($_('security.totpEnabledSuccess'))
508
-
}
509
-
510
-
function copyBackupCodes() {
511
-
if (totpSetup.step !== 'backup') return
512
-
navigator.clipboard.writeText(totpSetup.backupCodes.join('\n'))
513
-
toast.success($_('security.backupCodesCopied'))
514
-
}
515
-
516
-
async function handleDisableTotp(e: Event) {
517
-
e.preventDefault()
518
-
if (!disablePassword || !disableCode) return
519
-
disableLoading = true
520
-
try {
521
-
await api.disableTotp(session.accessJwt, disablePassword, disableCode)
522
-
totpEnabled = false
523
-
hasBackupCodes = false
524
-
showDisableForm = false
525
-
disablePassword = ''
526
-
disableCode = ''
527
-
toast.success($_('security.totpDisabledSuccess'))
528
-
} catch (e) {
529
-
toast.error(e instanceof ApiError ? e.message : $_('security.failedToDisableTotp'))
530
-
} finally {
531
-
disableLoading = false
532
-
}
533
-
}
534
-
535
-
async function handleRegenerateBackupCodes(e: Event) {
536
-
e.preventDefault()
537
-
if (!regenPassword || !regenCode) return
538
-
regenLoading = true
539
-
try {
540
-
const result = await api.regenerateBackupCodes(session.accessJwt, regenPassword, regenCode)
541
-
const dummyVerify = verifyState(qrState('', ''))
542
-
totpSetup = backupState(dummyVerify, result.backupCodes)
543
-
showRegenForm = false
544
-
regenPassword = ''
545
-
regenCode = ''
546
-
} catch (e) {
547
-
toast.error(e instanceof ApiError ? e.message : $_('security.failedToRegenerateBackupCodes'))
548
-
} finally {
549
-
regenLoading = false
550
-
}
551
-
}
552
-
553
-
async function handleAddPasskey() {
554
-
if (!window.PublicKeyCredential) {
555
-
toast.error($_('security.passkeysNotSupported'))
556
-
return
557
-
}
558
-
addingPasskey = true
559
-
try {
560
-
const { options } = await api.startPasskeyRegistration(session.accessJwt, newPasskeyName || undefined)
561
-
const publicKeyOptions = prepareCreationOptions(options as unknown as WebAuthnCreationOptionsResponse)
562
-
const credential = await navigator.credentials.create({ publicKey: publicKeyOptions })
563
-
if (!credential) {
564
-
toast.error($_('security.passkeyCreationCancelled'))
565
-
return
566
-
}
567
-
const credentialResponse = serializeAttestationResponse(credential as PublicKeyCredential)
568
-
await api.finishPasskeyRegistration(session.accessJwt, credentialResponse, newPasskeyName || undefined)
569
-
await loadPasskeys()
570
-
newPasskeyName = ''
571
-
toast.success($_('security.passkeyAddedSuccess'))
572
-
} catch (e) {
573
-
if (e instanceof DOMException && e.name === 'NotAllowedError') {
574
-
toast.error($_('security.passkeyCreationCancelled'))
575
-
} else {
576
-
toast.error(e instanceof ApiError ? e.message : 'Failed to add passkey')
577
-
}
578
-
} finally {
579
-
addingPasskey = false
580
-
}
581
-
}
582
-
583
-
async function handleDeletePasskey(id: string) {
584
-
const passkey = passkeys.find(p => p.id === id)
585
-
if (!confirm($_('security.deletePasskeyConfirm', { values: { name: passkey?.friendlyName || 'this passkey' } }))) return
586
-
try {
587
-
await api.deletePasskey(session.accessJwt, id)
588
-
await loadPasskeys()
589
-
toast.success($_('security.passkeyDeleted'))
590
-
} catch (e) {
591
-
toast.error(e instanceof ApiError ? e.message : 'Failed to delete passkey')
592
-
}
593
-
}
594
-
595
-
async function handleSavePasskeyName() {
596
-
if (!editingPasskeyId || !editPasskeyName.trim()) return
597
-
try {
598
-
await api.updatePasskey(session.accessJwt, editingPasskeyId, editPasskeyName.trim())
599
-
await loadPasskeys()
600
-
editingPasskeyId = null
601
-
editPasskeyName = ''
602
-
toast.success($_('security.passkeyRenamed'))
603
-
} catch (e) {
604
-
toast.error(e instanceof ApiError ? e.message : 'Failed to rename passkey')
605
-
}
606
-
}
607
-
608
-
function startEditPasskey(passkey: Passkey) {
609
-
editingPasskeyId = passkey.id
610
-
editPasskeyName = passkey.friendlyName || ''
611
-
}
612
-
613
-
function cancelEditPasskey() {
614
-
editingPasskeyId = null
615
-
editPasskeyName = ''
616
-
}
617
278
</script>
618
279
619
280
<div class="security">
620
-
{#if loading}
621
-
<div class="loading">{$_('common.loading')}</div>
622
-
{:else}
281
+
<PasskeySection
282
+
{session}
283
+
{hasPassword}
284
+
onPasskeysChanged={(count) => passkeyCount = count}
285
+
/>
286
+
287
+
<TotpSection
288
+
{session}
289
+
onStatusChanged={(enabled, _hasBackup) => totpEnabled = enabled}
290
+
/>
291
+
292
+
<PasswordSection
293
+
{session}
294
+
{passkeyCount}
295
+
onPasswordChanged={(has) => hasPassword = has}
296
+
onReauthRequired={handleReauthRequired}
297
+
/>
298
+
299
+
{#if ssoProviders.length > 0}
623
300
<section>
624
-
<h3>{$_('security.passkeys')}</h3>
301
+
<h3>{$_('oauth.sso.linkedAccounts')}</h3>
625
302
626
-
{#if !passkeysLoading}
627
-
{#if passkeys.length > 0}
628
-
<ul class="passkey-list">
629
-
{#each passkeys as passkey}
630
-
<li class="passkey-item">
631
-
{#if editingPasskeyId === passkey.id}
632
-
<div class="passkey-edit">
633
-
<input type="text" bind:value={editPasskeyName} placeholder={$_('security.passkeyName')} />
634
-
<button type="button" class="sm" onclick={handleSavePasskeyName}>{$_('common.save')}</button>
635
-
<button type="button" class="sm secondary" onclick={cancelEditPasskey}>{$_('common.cancel')}</button>
636
-
</div>
637
-
{:else}
638
-
<div class="passkey-info">
639
-
<span class="passkey-name">{passkey.friendlyName || $_('security.unnamedPasskey')}</span>
640
-
<span class="passkey-meta">
641
-
{$_('security.added')} {formatDate(passkey.createdAt)}
642
-
{#if passkey.lastUsed}
643
-
- {$_('security.lastUsed')} {formatDate(passkey.lastUsed)}
644
-
{/if}
645
-
</span>
646
-
</div>
647
-
<div class="passkey-actions">
648
-
<button type="button" class="sm secondary" onclick={() => startEditPasskey(passkey)}>{$_('security.rename')}</button>
649
-
{#if hasPassword || passkeys.length > 1}
650
-
<button type="button" class="sm danger-outline" onclick={() => handleDeletePasskey(passkey.id)}>{$_('security.deletePasskey')}</button>
651
-
{/if}
652
-
</div>
653
-
{/if}
303
+
{#if linkedAccountsLoading}
304
+
<div class="loading">{$_('common.loading')}</div>
305
+
{:else}
306
+
{#if linkedAccounts.length > 0}
307
+
<ul class="sso-list">
308
+
{#each linkedAccounts as account}
309
+
<li class="sso-item">
310
+
<div class="sso-info">
311
+
<span class="sso-provider">{account.provider_name}</span>
312
+
<span class="sso-id">{account.provider_username}</span>
313
+
<span class="sso-meta">{$_('oauth.sso.linkedAt')} {formatDate(account.created_at)}</span>
314
+
</div>
315
+
<button
316
+
type="button"
317
+
class="sm danger-outline"
318
+
onclick={() => handleUnlinkAccount(account.id)}
319
+
disabled={unlinkingId === account.id}
320
+
>
321
+
{unlinkingId === account.id ? $_('common.loading') : $_('oauth.sso.unlink')}
322
+
</button>
654
323
</li>
655
324
{/each}
656
325
</ul>
657
326
{:else}
658
-
<div class="status warning">{$_('security.noPasskeys')}</div>
327
+
<p class="empty">{$_('oauth.sso.noLinkedAccounts')}</p>
659
328
{/if}
660
329
661
-
<div class="add-passkey">
662
-
<input type="text" bind:value={newPasskeyName} placeholder={$_('security.passkeyNamePlaceholder')} disabled={addingPasskey} />
663
-
<button onclick={handleAddPasskey} disabled={addingPasskey}>
664
-
{addingPasskey ? $_('security.adding') : $_('security.addPasskey')}
665
-
</button>
330
+
<div class="sso-providers">
331
+
<h4>{$_('oauth.sso.linkNewAccount')}</h4>
332
+
<div class="provider-buttons">
333
+
{#each ssoProviders as provider}
334
+
{@const isLinked = linkedAccounts.some(a => a.provider === provider.provider)}
335
+
<button
336
+
type="button"
337
+
class="provider-btn"
338
+
onclick={() => handleLinkAccount(provider.provider)}
339
+
disabled={linkingProvider === provider.provider || isLinked}
340
+
>
341
+
<SsoIcon provider={provider.provider} />
342
+
<span class="provider-name">{linkingProvider === provider.provider ? $_('common.loading') : provider.name}</span>
343
+
{#if isLinked}
344
+
<span class="linked-badge">{$_('oauth.sso.linked')}</span>
345
+
{/if}
346
+
</button>
347
+
{/each}
348
+
</div>
666
349
</div>
667
-
668
350
{/if}
669
351
</section>
352
+
{/if}
670
353
354
+
{#if hasMfa}
671
355
<section>
672
-
<h3>{$_('security.totp')}</h3>
673
-
674
-
{#if totpSetup.step === 'idle'}
675
-
{#if totpEnabled}
676
-
<div class="status success">{$_('security.totpEnabled')}</div>
677
-
678
-
{#if !showDisableForm && !showRegenForm}
679
-
<div class="totp-actions">
680
-
<button type="button" class="secondary" onclick={() => showRegenForm = true}>
681
-
{$_('security.regenerateBackupCodes')}
682
-
</button>
683
-
<button type="button" class="danger-outline" onclick={() => showDisableForm = true}>
684
-
{$_('security.disableTotp')}
685
-
</button>
686
-
</div>
687
-
{/if}
688
-
689
-
{#if showRegenForm}
690
-
<form class="inline-form" onsubmit={handleRegenerateBackupCodes}>
691
-
<h4>{$_('security.regenerateBackupCodes')}</h4>
692
-
<p class="warning-text">{$_('security.regenerateConfirm')}</p>
693
-
<div>
694
-
<label for="regen-password">{$_('security.password')}</label>
695
-
<input
696
-
id="regen-password"
697
-
type="password"
698
-
bind:value={regenPassword}
699
-
placeholder={$_('security.enterPassword')}
700
-
disabled={regenLoading}
701
-
required
702
-
/>
703
-
</div>
704
-
<div>
705
-
<label for="regen-code">{$_('security.totpCode')}</label>
706
-
<input
707
-
id="regen-code"
708
-
type="text"
709
-
bind:value={regenCode}
710
-
placeholder={$_('security.totpCodePlaceholder')}
711
-
disabled={regenLoading}
712
-
required
713
-
maxlength="6"
714
-
inputmode="numeric"
715
-
/>
716
-
</div>
717
-
<div class="actions">
718
-
<button type="button" class="secondary" onclick={() => { showRegenForm = false; regenPassword = ''; regenCode = '' }}>
719
-
{$_('common.cancel')}
720
-
</button>
721
-
<button type="submit" disabled={regenLoading || !regenPassword || regenCode.length !== 6}>
722
-
{regenLoading ? $_('security.regenerating') : $_('security.regenerateBackupCodes')}
723
-
</button>
724
-
</div>
725
-
</form>
726
-
{/if}
727
-
728
-
{#if showDisableForm}
729
-
<form class="inline-form danger-form" onsubmit={handleDisableTotp}>
730
-
<h4>{$_('security.disableTotp')}</h4>
731
-
<p class="warning-text">{$_('security.disableTotpWarning')}</p>
732
-
<div>
733
-
<label for="disable-password">{$_('security.password')}</label>
734
-
<input
735
-
id="disable-password"
736
-
type="password"
737
-
bind:value={disablePassword}
738
-
placeholder={$_('security.enterPassword')}
739
-
disabled={disableLoading}
740
-
required
741
-
/>
742
-
</div>
743
-
<div>
744
-
<label for="disable-code">{$_('security.totpCode')}</label>
745
-
<input
746
-
id="disable-code"
747
-
type="text"
748
-
bind:value={disableCode}
749
-
placeholder={$_('security.totpCodePlaceholder')}
750
-
disabled={disableLoading}
751
-
required
752
-
maxlength="6"
753
-
inputmode="numeric"
754
-
/>
755
-
</div>
756
-
<div class="actions">
757
-
<button type="button" class="secondary" onclick={() => { showDisableForm = false; disablePassword = ''; disableCode = '' }}>
758
-
{$_('common.cancel')}
759
-
</button>
760
-
<button type="submit" class="danger" disabled={disableLoading || !disablePassword || disableCode.length !== 6}>
761
-
{disableLoading ? $_('security.disabling') : $_('security.disableTotp')}
762
-
</button>
763
-
</div>
764
-
</form>
765
-
{/if}
766
-
{:else}
767
-
<div class="status warning">{$_('security.totpDisabled')}</div>
768
-
<button onclick={handleStartTotpSetup} disabled={verifyLoading}>
769
-
{$_('security.enableTotp')}
770
-
</button>
771
-
{/if}
772
-
{:else if totpSetup.step === 'qr'}
773
-
{@const qrData = totpSetup as TotpQr}
774
-
<div class="setup-step">
775
-
<p>{$_('security.totpSetupInstructions')}</p>
776
-
<div class="qr-container">
777
-
<img src="data:image/png;base64,{qrData.qrBase64}" alt="TOTP QR Code" class="qr-code" />
356
+
<h3>{$_('security.appCompatibility')}</h3>
357
+
<p class="section-description">{$_('security.legacyLoginDescription')}</p>
358
+
359
+
{#if !legacyLoginLoading}
360
+
<div class="toggle-row">
361
+
<div class="toggle-info">
362
+
<span class="toggle-label">{$_('security.legacyLogin')}</span>
363
+
<span class="toggle-description">
364
+
{#if allowLegacyLogin}
365
+
{$_('security.legacyLoginOn')}
366
+
{:else}
367
+
{$_('security.legacyLoginOff')}
368
+
{/if}
369
+
</span>
778
370
</div>
779
-
<details class="manual-entry">
780
-
<summary>{$_('security.cantScan')}</summary>
781
-
<code class="secret-code">{qrData.totpUri.split('secret=')[1]?.split('&')[0] || ''}</code>
782
-
</details>
783
-
<button onclick={() => totpSetup = verifyState(qrData)}>{$_('security.next')}</button>
784
-
</div>
785
-
{:else if totpSetup.step === 'verify'}
786
-
{@const verifyData = totpSetup}
787
-
<div class="setup-step">
788
-
<p>{$_('security.totpCodePlaceholder')}</p>
789
-
<form onsubmit={handleVerifyTotp}>
790
-
<input type="text" bind:value={verifyCodeRaw} placeholder="000000" class="code-input" inputmode="numeric" disabled={verifyLoading} />
791
-
<div class="actions">
792
-
<button type="button" class="secondary" onclick={() => totpSetup = goBackToQr(verifyData)}>{$_('common.back')}</button>
793
-
<button type="submit" disabled={verifyLoading || verifyCode.length !== 6}>{$_('security.verifyAndEnable')}</button>
794
-
</div>
795
-
</form>
371
+
<button
372
+
type="button"
373
+
class="toggle-button {allowLegacyLogin ? 'on' : 'off'}"
374
+
onclick={handleToggleLegacyLogin}
375
+
disabled={legacyLoginUpdating}
376
+
aria-label={allowLegacyLogin ? $_('security.disableLegacyLogin') : $_('security.enableLegacyLogin')}
377
+
>
378
+
<span class="toggle-slider"></span>
379
+
</button>
796
380
</div>
797
-
{:else if totpSetup.step === 'backup'}
798
-
<div class="setup-step">
799
-
<h4>{$_('security.backupCodes')}</h4>
800
-
<p class="warning-text">{$_('security.backupCodesDescription')}</p>
801
-
<div class="backup-codes">
802
-
{#each totpSetup.backupCodes as code}
803
-
<code class="backup-code">{code}</code>
804
-
{/each}
805
-
</div>
806
-
<div class="actions">
807
-
<button type="button" class="secondary" onclick={copyBackupCodes}>{$_('security.copyToClipboard')}</button>
808
-
<button onclick={handleFinishSetup}>{$_('security.savedMyCodes')}</button>
381
+
382
+
{#if totpEnabled && allowLegacyLogin}
383
+
<div class="warning-box">
384
+
<strong>{$_('security.legacyLoginWarning')}</strong>
809
385
</div>
386
+
{/if}
387
+
388
+
<div class="info-box">
389
+
<strong>{$_('security.legacyAppsTitle')}</strong>
390
+
<p>{$_('security.legacyAppsDescription')}</p>
810
391
</div>
811
392
{/if}
812
393
</section>
394
+
{/if}
813
395
814
-
<section>
815
-
<h3>{$_('security.password')}</h3>
816
-
{#if !passwordLoading}
817
-
{#if hasPassword}
818
-
<div class="status success">{$_('security.passwordStatus')}</div>
819
-
820
-
{#if !showChangePasswordForm && !showRemovePasswordForm}
821
-
<div class="password-actions">
822
-
<button type="button" onclick={() => showChangePasswordForm = true}>
823
-
{$_('security.changePassword')}
824
-
</button>
825
-
{#if passkeys.length > 0}
826
-
<button type="button" class="danger-outline" onclick={() => showRemovePasswordForm = true}>
827
-
{$_('security.removePassword')}
828
-
</button>
829
-
{/if}
830
-
</div>
831
-
{/if}
832
-
833
-
{#if showChangePasswordForm}
834
-
<form class="inline-form" onsubmit={handleChangePassword}>
835
-
<h4>{$_('security.changePassword')}</h4>
836
-
<div>
837
-
<label for="current-password">{$_('security.currentPassword')}</label>
396
+
<section>
397
+
<h3>{$_('security.trustedDevices')}</h3>
398
+
<p class="section-description">{$_('security.trustedDevicesDescription')}</p>
399
+
400
+
{#if trustedDevicesLoading}
401
+
<div class="loading">{$_('common.loading')}</div>
402
+
{:else if trustedDevices.length === 0}
403
+
<p class="empty-hint">{$_('trustedDevices.noDevices')}</p>
404
+
<p class="hint-text">{$_('trustedDevices.noDevicesHint')}</p>
405
+
{:else}
406
+
<div class="device-list">
407
+
{#each trustedDevices as device}
408
+
<div class="device-card">
409
+
<div class="device-header">
410
+
{#if editingDeviceId === device.id}
838
411
<input
839
-
id="current-password"
840
-
type="password"
841
-
bind:value={currentPassword}
842
-
placeholder={$_('security.currentPasswordPlaceholder')}
843
-
disabled={changePasswordLoading}
844
-
required
845
-
/>
846
-
</div>
847
-
<div>
848
-
<label for="new-password">{$_('security.newPassword')}</label>
849
-
<input
850
-
id="new-password"
851
-
type="password"
852
-
bind:value={newPassword}
853
-
placeholder={$_('security.newPasswordPlaceholder')}
854
-
disabled={changePasswordLoading}
855
-
required
856
-
minlength="8"
857
-
/>
858
-
</div>
859
-
<div>
860
-
<label for="confirm-password">{$_('security.confirmPassword')}</label>
861
-
<input
862
-
id="confirm-password"
863
-
type="password"
864
-
bind:value={confirmNewPassword}
865
-
placeholder={$_('security.confirmPasswordPlaceholder')}
866
-
disabled={changePasswordLoading}
867
-
required
868
-
minlength="8"
412
+
type="text"
413
+
class="edit-name-input"
414
+
bind:value={editDeviceName}
415
+
placeholder={$_('trustedDevices.deviceNamePlaceholder')}
869
416
/>
870
-
</div>
871
-
<div class="actions">
872
-
<button type="button" class="secondary" onclick={() => { showChangePasswordForm = false; currentPassword = ''; newPassword = ''; confirmNewPassword = '' }}>
873
-
{$_('common.cancel')}
874
-
</button>
875
-
<button type="submit" disabled={changePasswordLoading || !currentPassword || !newPassword || !confirmNewPassword}>
876
-
{changePasswordLoading ? $_('security.changing') : $_('security.changePassword')}
877
-
</button>
878
-
</div>
879
-
</form>
880
-
{/if}
881
-
882
-
{#if showRemovePasswordForm}
883
-
<div class="remove-password-form">
884
-
<p class="warning-text">{$_('security.removePasswordWarning')}</p>
885
-
<div class="actions">
886
-
<button type="button" class="ghost sm" onclick={() => showRemovePasswordForm = false}>
887
-
{$_('common.cancel')}
417
+
<div class="edit-actions">
418
+
<button type="button" class="sm" onclick={handleSaveDeviceName}>{$_('common.save')}</button>
419
+
<button type="button" class="sm ghost" onclick={cancelEditDevice}>{$_('common.cancel')}</button>
420
+
</div>
421
+
{:else}
422
+
<span class="device-name">{device.friendlyName || parseUserAgent(device.userAgent)}</span>
423
+
<button type="button" class="icon" onclick={() => startEditDevice(device)} title={$_('security.rename')}>
424
+
✎
888
425
</button>
889
-
<button type="button" class="danger sm" onclick={handleRemovePassword} disabled={removePasswordLoading}>
890
-
{removePasswordLoading ? $_('security.removing') : $_('security.removePassword')}
891
-
</button>
892
-
</div>
426
+
{/if}
893
427
</div>
894
-
{/if}
895
-
{:else}
896
-
<div class="status info">{$_('security.noPassword')}</div>
897
-
898
-
{#if !showSetPasswordForm}
899
-
<button type="button" onclick={() => showSetPasswordForm = true}>
900
-
{$_('security.setPassword')}
901
-
</button>
902
-
{:else}
903
-
<form class="inline-form" onsubmit={handleSetPassword}>
904
-
<h4>{$_('security.setPassword')}</h4>
905
-
<div>
906
-
<label for="set-new-password">{$_('security.newPassword')}</label>
907
-
<input
908
-
id="set-new-password"
909
-
type="password"
910
-
bind:value={setNewPassword}
911
-
placeholder={$_('security.newPasswordPlaceholder')}
912
-
disabled={setPasswordLoading}
913
-
required
914
-
minlength="8"
915
-
/>
916
-
</div>
917
-
<div>
918
-
<label for="set-confirm-password">{$_('security.confirmPassword')}</label>
919
-
<input
920
-
id="set-confirm-password"
921
-
type="password"
922
-
bind:value={setConfirmPassword}
923
-
placeholder={$_('security.confirmPasswordPlaceholder')}
924
-
disabled={setPasswordLoading}
925
-
required
926
-
minlength="8"
927
-
/>
928
-
</div>
929
-
<div class="actions">
930
-
<button type="button" class="secondary" onclick={() => { showSetPasswordForm = false; setNewPassword = ''; setConfirmPassword = '' }}>
931
-
{$_('common.cancel')}
932
-
</button>
933
-
<button type="submit" disabled={setPasswordLoading || !setNewPassword || !setConfirmPassword}>
934
-
{setPasswordLoading ? $_('security.setting') : $_('security.setPassword')}
935
-
</button>
936
-
</div>
937
-
</form>
938
-
{/if}
939
-
{/if}
940
-
{/if}
941
-
</section>
942
-
943
-
{#if ssoProviders.length > 0}
944
-
<section>
945
-
<h3>{$_('oauth.sso.linkedAccounts')}</h3>
946
428
947
-
{#if linkedAccountsLoading}
948
-
<div class="loading">{$_('common.loading')}</div>
949
-
{:else}
950
-
{#if linkedAccounts.length > 0}
951
-
<ul class="sso-list">
952
-
{#each linkedAccounts as account}
953
-
<li class="sso-item">
954
-
<div class="sso-info">
955
-
<span class="sso-provider">{account.provider_name}</span>
956
-
<span class="sso-id">{account.provider_username}</span>
957
-
<span class="sso-meta">{$_('oauth.sso.linkedAt')} {formatDate(account.created_at)}</span>
958
-
</div>
959
-
<button
960
-
type="button"
961
-
class="sm danger-outline"
962
-
onclick={() => handleUnlinkAccount(account.id)}
963
-
disabled={unlinkingId === account.id}
964
-
>
965
-
{unlinkingId === account.id ? $_('common.loading') : $_('oauth.sso.unlink')}
966
-
</button>
967
-
</li>
968
-
{/each}
969
-
</ul>
970
-
{:else}
971
-
<p class="empty">{$_('oauth.sso.noLinkedAccounts')}</p>
972
-
{/if}
973
-
974
-
<div class="sso-providers">
975
-
<h4>{$_('oauth.sso.linkNewAccount')}</h4>
976
-
<div class="provider-buttons">
977
-
{#each ssoProviders as provider}
978
-
{@const isLinked = linkedAccounts.some(a => a.provider === provider.provider)}
979
-
<button
980
-
type="button"
981
-
class="provider-btn"
982
-
onclick={() => handleLinkAccount(provider.provider)}
983
-
disabled={linkingProvider === provider.provider || isLinked}
984
-
>
985
-
<SsoIcon provider={provider.provider} />
986
-
<span class="provider-name">{linkingProvider === provider.provider ? $_('common.loading') : provider.name}</span>
987
-
{#if isLinked}
988
-
<span class="linked-badge">{$_('oauth.sso.linked')}</span>
429
+
<div class="device-details">
430
+
{#if device.userAgent}
431
+
<span class="detail">{parseUserAgent(device.userAgent)}</span>
432
+
{/if}
433
+
{#if device.trustedAt}
434
+
<span class="detail">{$_('trustedDevices.trustedSince')} {formatDate(device.trustedAt)}</span>
435
+
{/if}
436
+
<span class="detail">{$_('trustedDevices.lastSeen')} {formatDate(device.lastSeenAt)}</span>
437
+
{#if device.trustedUntil}
438
+
{@const daysRemaining = getDaysRemaining(device.trustedUntil)}
439
+
<span class="detail" class:expiring-soon={daysRemaining <= 7}>
440
+
{#if daysRemaining <= 0}
441
+
{$_('trustedDevices.expired')}
442
+
{:else if daysRemaining === 1}
443
+
{$_('trustedDevices.tomorrow')}
444
+
{:else}
445
+
{$_('trustedDevices.inDays', { values: { days: daysRemaining } })}
989
446
{/if}
990
-
</button>
991
-
{/each}
447
+
</span>
448
+
{/if}
992
449
</div>
993
-
</div>
994
-
{/if}
995
-
</section>
996
-
{/if}
997
-
998
-
{#if hasMfa}
999
-
<section>
1000
-
<h3>{$_('security.appCompatibility')}</h3>
1001
-
<p class="section-description">{$_('security.legacyLoginDescription')}</p>
1002
450
1003
-
{#if !legacyLoginLoading}
1004
-
<div class="toggle-row">
1005
-
<div class="toggle-info">
1006
-
<span class="toggle-label">{$_('security.legacyLogin')}</span>
1007
-
<span class="toggle-description">
1008
-
{#if allowLegacyLogin}
1009
-
{$_('security.legacyLoginOn')}
1010
-
{:else}
1011
-
{$_('security.legacyLoginOff')}
1012
-
{/if}
1013
-
</span>
1014
-
</div>
1015
-
<button
1016
-
type="button"
1017
-
class="toggle-button {allowLegacyLogin ? 'on' : 'off'}"
1018
-
onclick={handleToggleLegacyLogin}
1019
-
disabled={legacyLoginUpdating}
1020
-
aria-label={allowLegacyLogin ? $_('security.disableLegacyLogin') : $_('security.enableLegacyLogin')}
1021
-
>
1022
-
<span class="toggle-slider"></span>
451
+
<button type="button" class="sm danger-outline" onclick={() => handleRevokeDevice(device.id)}>
452
+
{$_('trustedDevices.revoke')}
1023
453
</button>
1024
454
</div>
1025
-
1026
-
{#if totpEnabled && allowLegacyLogin}
1027
-
<div class="warning-box">
1028
-
<strong>{$_('security.legacyLoginWarning')}</strong>
1029
-
</div>
1030
-
{/if}
1031
-
1032
-
<div class="info-box">
1033
-
<strong>{$_('security.legacyAppsTitle')}</strong>
1034
-
<p>{$_('security.legacyAppsDescription')}</p>
1035
-
</div>
1036
-
{/if}
1037
-
</section>
455
+
{/each}
456
+
</div>
1038
457
{/if}
1039
-
1040
-
<section>
1041
-
<h3>{$_('security.trustedDevices')}</h3>
1042
-
<p class="section-description">{$_('security.trustedDevicesDescription')}</p>
1043
-
1044
-
{#if trustedDevicesLoading}
1045
-
<div class="loading">{$_('common.loading')}</div>
1046
-
{:else if trustedDevices.length === 0}
1047
-
<p class="empty-hint">{$_('trustedDevices.noDevices')}</p>
1048
-
<p class="hint-text">{$_('trustedDevices.noDevicesHint')}</p>
1049
-
{:else}
1050
-
<div class="device-list">
1051
-
{#each trustedDevices as device}
1052
-
<div class="device-card">
1053
-
<div class="device-header">
1054
-
{#if editingDeviceId === device.id}
1055
-
<input
1056
-
type="text"
1057
-
class="edit-name-input"
1058
-
bind:value={editDeviceName}
1059
-
placeholder={$_('trustedDevices.deviceNamePlaceholder')}
1060
-
/>
1061
-
<div class="edit-actions">
1062
-
<button type="button" class="sm" onclick={handleSaveDeviceName}>{$_('common.save')}</button>
1063
-
<button type="button" class="sm ghost" onclick={cancelEditDevice}>{$_('common.cancel')}</button>
1064
-
</div>
1065
-
{:else}
1066
-
<span class="device-name">{device.friendlyName || parseUserAgent(device.userAgent)}</span>
1067
-
<button type="button" class="icon" onclick={() => startEditDevice(device)} title={$_('security.rename')}>
1068
-
✎
1069
-
</button>
1070
-
{/if}
1071
-
</div>
1072
-
1073
-
<div class="device-details">
1074
-
{#if device.userAgent}
1075
-
<span class="detail">{parseUserAgent(device.userAgent)}</span>
1076
-
{/if}
1077
-
{#if device.trustedAt}
1078
-
<span class="detail">{$_('trustedDevices.trustedSince')} {formatDate(device.trustedAt)}</span>
1079
-
{/if}
1080
-
<span class="detail">{$_('trustedDevices.lastSeen')} {formatDate(device.lastSeenAt)}</span>
1081
-
{#if device.trustedUntil}
1082
-
{@const daysRemaining = getDaysRemaining(device.trustedUntil)}
1083
-
<span class="detail" class:expiring-soon={daysRemaining <= 7}>
1084
-
{#if daysRemaining <= 0}
1085
-
{$_('trustedDevices.expired')}
1086
-
{:else if daysRemaining === 1}
1087
-
{$_('trustedDevices.tomorrow')}
1088
-
{:else}
1089
-
{$_('trustedDevices.inDays', { values: { days: daysRemaining } })}
1090
-
{/if}
1091
-
</span>
1092
-
{/if}
1093
-
</div>
1094
-
1095
-
<button type="button" class="sm danger-outline" onclick={() => handleRevokeDevice(device.id)}>
1096
-
{$_('trustedDevices.revoke')}
1097
-
</button>
1098
-
</div>
1099
-
{/each}
1100
-
</div>
1101
-
{/if}
1102
-
</section>
1103
-
{/if}
458
+
</section>
1104
459
</div>
1105
460
1106
461
<ReauthModal
History
3 rounds
0 comments
oyster.cafe
submitted
#2
1 commit
expand
collapse
refactor(frontend): rewrite SecurityContent
expand 0 comments
pull request successfully merged
oyster.cafe
submitted
#1
1 commit
expand
collapse
refactor(frontend): rewrite SecurityContent
expand 0 comments
oyster.cafe
submitted
#0
1 commit
expand
collapse
refactor(frontend): rewrite SecurityContent