+765
-218
Diff
round #2
-60
frontend/src/components/dashboard/AccountOverview.svelte
-60
frontend/src/components/dashboard/AccountOverview.svelte
···
1
-
<script lang="ts">
2
-
import { _ } from '../../lib/i18n'
3
-
import type { Session } from '../../lib/types/api'
4
-
5
-
interface Props {
6
-
session: Session
7
-
}
8
-
9
-
let { session }: Props = $props()
10
-
</script>
11
-
12
-
<div class="overview">
13
-
<dl>
14
-
<dt>{$_('dashboard.handle')}</dt>
15
-
<dd>
16
-
@{session.handle}
17
-
{#if session.isAdmin}
18
-
<span class="badge admin">{$_('dashboard.admin')}</span>
19
-
{/if}
20
-
{#if session.accountKind === 'migrated'}
21
-
<span class="badge migrated">{$_('dashboard.migrated')}</span>
22
-
{:else if session.accountKind === 'deactivated'}
23
-
<span class="badge deactivated">{$_('dashboard.deactivated')}</span>
24
-
{/if}
25
-
</dd>
26
-
<dt>{$_('dashboard.did')}</dt>
27
-
<dd class="mono">{session.did}</dd>
28
-
{#if session.contactKind === 'channel'}
29
-
<dt>{$_('dashboard.primaryContact')}</dt>
30
-
<dd>
31
-
{#if session.preferredChannel === 'email'}
32
-
{session.email || $_('register.email')}
33
-
{:else if session.preferredChannel === 'discord'}
34
-
{$_('register.discord')}
35
-
{:else if session.preferredChannel === 'telegram'}
36
-
{$_('register.telegram')}
37
-
{:else if session.preferredChannel === 'signal'}
38
-
{$_('register.signal')}
39
-
{:else}
40
-
{session.preferredChannel}
41
-
{/if}
42
-
{#if session.preferredChannelVerified}
43
-
<span class="badge success">{$_('dashboard.verified')}</span>
44
-
{:else}
45
-
<span class="badge warning">{$_('dashboard.unverified')}</span>
46
-
{/if}
47
-
</dd>
48
-
{:else if session.contactKind === 'email'}
49
-
<dt>{$_('register.email')}</dt>
50
-
<dd>
51
-
{session.email}
52
-
{#if session.emailConfirmed}
53
-
<span class="badge success">{$_('dashboard.verified')}</span>
54
-
{:else}
55
-
<span class="badge warning">{$_('dashboard.unverified')}</span>
56
-
{/if}
57
-
</dd>
58
-
{/if}
59
-
</dl>
60
-
</div>
+20
-57
frontend/src/components/dashboard/AdminContent.svelte
+20
-57
frontend/src/components/dashboard/AdminContent.svelte
···
11
11
setColors as setGlobalColors,
12
12
setHasLogo as setGlobalHasLogo
13
13
} from '../../lib/serverConfig.svelte'
14
+
import LoadMoreSentinel from '../LoadMoreSentinel.svelte'
15
+
import { portal } from '../../lib/portal'
14
16
15
17
interface Props {
16
18
session: Session
···
32
34
indexedAt: string
33
35
emailConfirmedAt?: string
34
36
deactivatedAt?: string
35
-
invitesDisabled?: boolean
36
37
}
37
38
38
39
let stats = $state<ServerStats | null>(null)
···
41
42
let usersLoading = $state(false)
42
43
let searchQuery = $state('')
43
44
let usersCursor = $state<string | undefined>(undefined)
45
+
let usersHasMore = $state(true)
46
+
let searchDebounce: ReturnType<typeof setTimeout> | null = null
44
47
45
48
let selectedUser = $state<User | null>(null)
46
49
let userActionLoading = $state(false)
···
63
66
let serverConfigLoading = $state(false)
64
67
65
68
onMount(async () => {
66
-
await Promise.all([loadStats(), loadServerConfig()])
69
+
await Promise.all([loadStats(), loadServerConfig(), loadUsers(true)])
67
70
})
68
71
69
72
async function loadStats() {
···
82
85
if (reset) {
83
86
users = []
84
87
usersCursor = undefined
88
+
usersHasMore = true
85
89
}
86
90
try {
87
91
const result = await api.searchAccounts(session.accessJwt, {
···
91
95
})
92
96
users = reset ? result.accounts : [...users, ...result.accounts]
93
97
usersCursor = result.cursor
98
+
usersHasMore = !!result.cursor
94
99
} catch {
95
100
toast.error($_('admin.failedToLoadUsers'))
96
101
} finally {
···
98
103
}
99
104
}
100
105
101
-
function handleSearch(e: Event) {
102
-
e.preventDefault()
103
-
loadUsers(true)
106
+
function onSearchInput(value: string) {
107
+
searchQuery = value
108
+
if (searchDebounce) clearTimeout(searchDebounce)
109
+
searchDebounce = setTimeout(() => loadUsers(true), 300)
104
110
}
105
111
106
112
function formatBytes(bytes: number): string {
···
216
222
indexedAt: details.indexedAt,
217
223
emailConfirmedAt: details.emailConfirmedAt,
218
224
deactivatedAt: details.deactivatedAt,
219
-
invitesDisabled: details.invitesDisabled
220
225
}
221
226
} catch {
222
227
} finally {
···
228
233
selectedUser = null
229
234
}
230
235
231
-
async function toggleUserInvites() {
232
-
if (!selectedUser) return
233
-
userActionLoading = true
234
-
try {
235
-
if (selectedUser.invitesDisabled) {
236
-
await api.enableAccountInvites(session.accessJwt, unsafeAsDid(selectedUser.did))
237
-
selectedUser = { ...selectedUser, invitesDisabled: false }
238
-
toast.success($_('admin.invitesEnabled'))
239
-
} else {
240
-
await api.disableAccountInvites(session.accessJwt, unsafeAsDid(selectedUser.did))
241
-
selectedUser = { ...selectedUser, invitesDisabled: true }
242
-
toast.success($_('admin.invitesDisabled'))
243
-
}
244
-
} catch (e) {
245
-
toast.error(e instanceof ApiError ? e.message : $_('admin.failedToToggleInvites'))
246
-
} finally {
247
-
userActionLoading = false
248
-
}
249
-
}
250
-
251
236
async function deleteUserAccount() {
252
237
if (!selectedUser) return
253
238
if (!confirm($_('admin.deleteConfirm', { values: { handle: selectedUser.handle } }))) return
···
372
357
<section class="users-section">
373
358
<h3>{$_('admin.userManagement')}</h3>
374
359
375
-
<form class="search-bar" onsubmit={handleSearch}>
376
-
<input
377
-
type="text"
378
-
bind:value={searchQuery}
379
-
placeholder={$_('admin.searchPlaceholder')}
380
-
/>
381
-
<button type="submit" disabled={usersLoading}>
382
-
{usersLoading ? $_('common.loading') : $_('admin.search')}
383
-
</button>
384
-
</form>
360
+
<input
361
+
type="text"
362
+
value={searchQuery}
363
+
oninput={(e) => onSearchInput(e.currentTarget.value)}
364
+
placeholder={$_('admin.searchPlaceholder')}
365
+
/>
385
366
386
367
{#if users.length === 0 && !usersLoading}
387
368
<p class="empty">{$_('admin.searchToSeeUsers')}</p>
···
412
393
</li>
413
394
{/each}
414
395
</ul>
415
-
{#if usersCursor}
416
-
<button type="button" class="load-more" onclick={() => loadUsers(false)} disabled={usersLoading}>
417
-
{usersLoading ? $_('common.loading') : $_('admin.loadMore')}
418
-
</button>
419
-
{/if}
396
+
<LoadMoreSentinel hasMore={usersHasMore} loading={usersLoading} onLoadMore={() => loadUsers(false)} />
420
397
{/if}
421
398
</section>
422
399
423
400
</div>
424
401
425
402
{#if selectedUser}
426
-
<div class="modal-backdrop" onclick={closeUserDetail} onkeydown={(e) => e.key === 'Escape' && closeUserDetail()} role="presentation">
403
+
<div class="modal-backdrop" use:portal onclick={closeUserDetail} onkeydown={(e) => e.key === 'Escape' && closeUserDetail()} role="presentation">
427
404
<div class="modal" onclick={(e) => e.stopPropagation()} onkeydown={(e) => e.stopPropagation()} role="dialog" aria-modal="true" tabindex="-1">
428
405
<div class="modal-header">
429
406
<h2>{$_('admin.userDetails')}</h2>
···
437
414
<dt>{$_('admin.handle')}</dt>
438
415
<dd>@{selectedUser.handle}</dd>
439
416
<dt>{$_('admin.did')}</dt>
440
-
<dd class="mono">{selectedUser.did}</dd>
417
+
<dd class="definition-mono">{selectedUser.did}</dd>
441
418
<dt>{$_('admin.email')}</dt>
442
419
<dd>{selectedUser.email || '-'}</dd>
443
420
<dt>{$_('admin.created')}</dt>
···
452
429
<span class="badge unverified">{$_('admin.unverified')}</span>
453
430
{/if}
454
431
</dd>
455
-
<dt>{$_('admin.invites')}</dt>
456
-
<dd>
457
-
{#if selectedUser.invitesDisabled}
458
-
<span class="badge deactivated">{$_('admin.disabled')}</span>
459
-
{:else}
460
-
<span class="badge verified">{$_('admin.enabled')}</span>
461
-
{/if}
462
-
</dd>
463
432
</dl>
464
433
<div class="modal-actions">
465
-
<button
466
-
onclick={toggleUserInvites}
467
-
disabled={userActionLoading}
468
-
>
469
-
{selectedUser.invitesDisabled ? $_('admin.enableInvites') : $_('admin.disableInvites')}
470
-
</button>
471
434
<button
472
435
class="danger"
473
436
onclick={deleteUserAccount}
+14
-14
frontend/src/components/dashboard/ControllersContent.svelte
+14
-14
frontend/src/components/dashboard/ControllersContent.svelte
···
376
376
</div>
377
377
<div class="item-details">
378
378
<div class="detail">
379
-
<span class="label">{$_('delegation.did')}</span>
380
-
<span class="value did">{controller.did}</span>
379
+
<span class="detail-label">{$_('delegation.did')}</span>
380
+
<span class="detail-value detail-value-did">{controller.did}</span>
381
381
</div>
382
382
<div class="detail">
383
-
<span class="label">{$_('delegation.granted')}</span>
384
-
<span class="value">{formatDateTime(controller.grantedAt)}</span>
383
+
<span class="detail-label">{$_('delegation.granted')}</span>
384
+
<span class="detail-value">{formatDateTime(controller.grantedAt)}</span>
385
385
</div>
386
386
</div>
387
387
</div>
···
507
507
</div>
508
508
<div class="item-details">
509
509
<div class="detail">
510
-
<span class="label">{$_('delegation.did')}</span>
511
-
<span class="value did">{account.did}</span>
510
+
<span class="detail-label">{$_('delegation.did')}</span>
511
+
<span class="detail-value detail-value-did">{account.did}</span>
512
512
</div>
513
513
<div class="detail">
514
-
<span class="label">{$_('delegation.granted')}</span>
515
-
<span class="value">{formatDateTime(account.grantedAt)}</span>
514
+
<span class="detail-label">{$_('delegation.granted')}</span>
515
+
<span class="detail-value">{formatDateTime(account.grantedAt)}</span>
516
516
</div>
517
517
</div>
518
518
</div>
···
601
601
</div>
602
602
<div class="audit-entry-details">
603
603
<div class="detail">
604
-
<span class="label">{$_('delegation.actor')}</span>
605
-
<span class="value did">{entry.actorDid}</span>
604
+
<span class="detail-label">{$_('delegation.actor')}</span>
605
+
<span class="detail-value detail-value-did">{entry.actorDid}</span>
606
606
</div>
607
607
{#if entry.delegatedDid}
608
608
<div class="detail">
609
-
<span class="label">{$_('delegation.target')}</span>
610
-
<span class="value did">{entry.delegatedDid}</span>
609
+
<span class="detail-label">{$_('delegation.target')}</span>
610
+
<span class="detail-value detail-value-did">{entry.delegatedDid}</span>
611
611
</div>
612
612
{/if}
613
613
{#if entry.actionDetails}
614
614
<div class="detail">
615
-
<span class="label">{$_('delegation.details')}</span>
616
-
<span class="value audit-details-value">{formatActionDetails(entry.actionDetails)}</span>
615
+
<span class="detail-label">{$_('delegation.details')}</span>
616
+
<span class="detail-value audit-details-value">{formatActionDetails(entry.actionDetails)}</span>
617
617
</div>
618
618
{/if}
619
619
</div>
-53
frontend/src/components/dashboard/MigrationContent.svelte
-53
frontend/src/components/dashboard/MigrationContent.svelte
···
1
-
<script lang="ts">
2
-
import { _ } from '../../lib/i18n'
3
-
import { navigate, routes } from '../../lib/router.svelte'
4
-
import type { Session } from '../../lib/types/api'
5
-
6
-
interface Props {
7
-
session: Session
8
-
}
9
-
10
-
let { session }: Props = $props()
11
-
12
-
function startMigration(type: 'inbound' | 'offline') {
13
-
const url = type === 'offline'
14
-
? `${routes.migrate}?flow=offline`
15
-
: routes.migrate
16
-
navigate(url as typeof routes.migrate)
17
-
}
18
-
</script>
19
-
20
-
<div class="migration">
21
-
<section>
22
-
<h3>{$_('migration.migrateHere')}</h3>
23
-
<p class="description">{$_('migration.migrateHereDesc')}</p>
24
-
<ul class="feature-list">
25
-
<li>{$_('migration.bringDid')}</li>
26
-
<li>{$_('migration.transferData')}</li>
27
-
<li>{$_('migration.keepFollowers')}</li>
28
-
</ul>
29
-
<button onclick={() => startMigration('inbound')}>
30
-
{$_('migration.inbound.review.startMigration')}
31
-
</button>
32
-
</section>
33
-
34
-
<section>
35
-
<h3>{$_('migration.offlineRestore')}</h3>
36
-
<p class="description">{$_('migration.offlineRestoreDesc')}</p>
37
-
<ul class="feature-list">
38
-
<li>{$_('migration.offlineFeature1')}</li>
39
-
<li>{$_('migration.offlineFeature2')}</li>
40
-
<li>{$_('migration.offlineFeature3')}</li>
41
-
</ul>
42
-
<button class="secondary" onclick={() => startMigration('offline')}>
43
-
{$_('migration.offlineRestore')}
44
-
</button>
45
-
</section>
46
-
47
-
{#if session.accountKind === 'migrated'}
48
-
<section class="info-section">
49
-
<h3>{$_('dashboard.migratedTitle')}</h3>
50
-
<p>{$_('dashboard.migratedMessage', { values: { pds: session.migratedToPds || 'another PDS' } })}</p>
51
-
</section>
52
-
{/if}
53
-
</div>
+166
frontend/src/components/dashboard/PasskeySection.svelte
+166
frontend/src/components/dashboard/PasskeySection.svelte
···
1
+
<script lang="ts">
2
+
import { onMount } from 'svelte'
3
+
import { api, ApiError } from '../../lib/api'
4
+
import { _ } from '../../lib/i18n'
5
+
import { formatDate } from '../../lib/date'
6
+
import { toast } from '../../lib/toast.svelte'
7
+
import type { Session } from '../../lib/types/api'
8
+
import {
9
+
prepareCreationOptions,
10
+
serializeAttestationResponse,
11
+
type WebAuthnCreationOptionsResponse,
12
+
} from '../../lib/webauthn'
13
+
14
+
interface Props {
15
+
session: Session
16
+
hasPassword: boolean
17
+
onPasskeysChanged?: (count: number) => void
18
+
}
19
+
20
+
let { session, hasPassword, onPasskeysChanged }: Props = $props()
21
+
22
+
interface Passkey {
23
+
id: string
24
+
credentialId: string
25
+
friendlyName: string | null
26
+
createdAt: string
27
+
lastUsed: string | null
28
+
}
29
+
30
+
let passkeys = $state<Passkey[]>([])
31
+
let loading = $state(true)
32
+
let addingPasskey = $state(false)
33
+
let newPasskeyName = $state('')
34
+
let editingPasskeyId = $state<string | null>(null)
35
+
let editPasskeyName = $state('')
36
+
37
+
onMount(async () => {
38
+
await loadPasskeys()
39
+
})
40
+
41
+
async function loadPasskeys() {
42
+
loading = true
43
+
try {
44
+
const result = await api.listPasskeys(session.accessJwt)
45
+
passkeys = result.passkeys
46
+
onPasskeysChanged?.(passkeys.length)
47
+
} catch {
48
+
toast.error($_('security.failedToLoadPasskeys'))
49
+
} finally {
50
+
loading = false
51
+
}
52
+
}
53
+
54
+
async function handleAddPasskey() {
55
+
if (!window.PublicKeyCredential) {
56
+
toast.error($_('security.passkeysNotSupported'))
57
+
return
58
+
}
59
+
addingPasskey = true
60
+
try {
61
+
const { options } = await api.startPasskeyRegistration(session.accessJwt, newPasskeyName || undefined)
62
+
const publicKeyOptions = prepareCreationOptions(options as unknown as WebAuthnCreationOptionsResponse)
63
+
const credential = await navigator.credentials.create({ publicKey: publicKeyOptions })
64
+
if (!credential) {
65
+
toast.error($_('security.passkeyCreationCancelled'))
66
+
return
67
+
}
68
+
const credentialResponse = serializeAttestationResponse(credential as PublicKeyCredential)
69
+
await api.finishPasskeyRegistration(session.accessJwt, credentialResponse, newPasskeyName || undefined)
70
+
await loadPasskeys()
71
+
newPasskeyName = ''
72
+
toast.success($_('security.passkeyAddedSuccess'))
73
+
} catch (e) {
74
+
if (e instanceof DOMException && e.name === 'NotAllowedError') {
75
+
toast.error($_('security.passkeyCreationCancelled'))
76
+
} else {
77
+
toast.error(e instanceof ApiError ? e.message : 'Failed to add passkey')
78
+
}
79
+
} finally {
80
+
addingPasskey = false
81
+
}
82
+
}
83
+
84
+
async function handleDeletePasskey(id: string) {
85
+
const passkey = passkeys.find(p => p.id === id)
86
+
if (!confirm($_('security.deletePasskeyConfirm', { values: { name: passkey?.friendlyName || 'this passkey' } }))) return
87
+
try {
88
+
await api.deletePasskey(session.accessJwt, id)
89
+
await loadPasskeys()
90
+
toast.success($_('security.passkeyDeleted'))
91
+
} catch (e) {
92
+
toast.error(e instanceof ApiError ? e.message : 'Failed to delete passkey')
93
+
}
94
+
}
95
+
96
+
async function handleSavePasskeyName() {
97
+
if (!editingPasskeyId || !editPasskeyName.trim()) return
98
+
try {
99
+
await api.updatePasskey(session.accessJwt, editingPasskeyId, editPasskeyName.trim())
100
+
await loadPasskeys()
101
+
editingPasskeyId = null
102
+
editPasskeyName = ''
103
+
toast.success($_('security.passkeyRenamed'))
104
+
} catch (e) {
105
+
toast.error(e instanceof ApiError ? e.message : 'Failed to rename passkey')
106
+
}
107
+
}
108
+
109
+
function startEditPasskey(passkey: Passkey) {
110
+
editingPasskeyId = passkey.id
111
+
editPasskeyName = passkey.friendlyName || ''
112
+
}
113
+
114
+
function cancelEditPasskey() {
115
+
editingPasskeyId = null
116
+
editPasskeyName = ''
117
+
}
118
+
</script>
119
+
120
+
<section>
121
+
<h3>{$_('security.passkeys')}</h3>
122
+
123
+
{#if !loading}
124
+
{#if passkeys.length > 0}
125
+
<ul class="passkey-list">
126
+
{#each passkeys as passkey}
127
+
<li class="passkey-item">
128
+
{#if editingPasskeyId === passkey.id}
129
+
<div class="passkey-edit">
130
+
<input type="text" bind:value={editPasskeyName} placeholder={$_('security.passkeyName')} />
131
+
<button type="button" class="sm" onclick={handleSavePasskeyName}>{$_('common.save')}</button>
132
+
<button type="button" class="sm secondary" onclick={cancelEditPasskey}>{$_('common.cancel')}</button>
133
+
</div>
134
+
{:else}
135
+
<div class="passkey-info">
136
+
<span class="passkey-name">{passkey.friendlyName || $_('security.unnamedPasskey')}</span>
137
+
<span class="passkey-meta">
138
+
{$_('security.added')} {formatDate(passkey.createdAt)}
139
+
{#if passkey.lastUsed}
140
+
- {$_('security.lastUsed')} {formatDate(passkey.lastUsed)}
141
+
{/if}
142
+
</span>
143
+
</div>
144
+
<div class="passkey-actions">
145
+
<button type="button" class="sm secondary" onclick={() => startEditPasskey(passkey)}>{$_('security.rename')}</button>
146
+
{#if hasPassword || passkeys.length > 1}
147
+
<button type="button" class="sm danger-outline" onclick={() => handleDeletePasskey(passkey.id)}>{$_('security.deletePasskey')}</button>
148
+
{/if}
149
+
</div>
150
+
{/if}
151
+
</li>
152
+
{/each}
153
+
</ul>
154
+
{:else}
155
+
<div class="status warning">{$_('security.noPasskeys')}</div>
156
+
{/if}
157
+
158
+
<div class="add-passkey">
159
+
<input type="text" bind:value={newPasskeyName} placeholder={$_('security.passkeyNamePlaceholder')} disabled={addingPasskey} />
160
+
<button onclick={handleAddPasskey} disabled={addingPasskey}>
161
+
{addingPasskey ? $_('security.adding') : $_('security.addPasskey')}
162
+
</button>
163
+
</div>
164
+
165
+
{/if}
166
+
</section>
+265
frontend/src/components/dashboard/PasswordSection.svelte
+265
frontend/src/components/dashboard/PasswordSection.svelte
···
1
+
<script lang="ts">
2
+
import { onMount } from 'svelte'
3
+
import { getValidToken } from '../../lib/auth.svelte'
4
+
import { api, ApiError } from '../../lib/api'
5
+
import { _ } from '../../lib/i18n'
6
+
import { toast } from '../../lib/toast.svelte'
7
+
import type { Session } from '../../lib/types/api'
8
+
9
+
interface Props {
10
+
session: Session
11
+
passkeyCount: number
12
+
onPasswordChanged?: (hasPassword: boolean) => void
13
+
onReauthRequired: (methods: string[], retryAction: () => Promise<void>) => void
14
+
}
15
+
16
+
let { session, passkeyCount, onPasswordChanged, onReauthRequired }: Props = $props()
17
+
18
+
let hasPassword = $state(true)
19
+
let loading = $state(true)
20
+
21
+
let showChangePasswordForm = $state(false)
22
+
let currentPassword = $state('')
23
+
let newPassword = $state('')
24
+
let confirmNewPassword = $state('')
25
+
let changePasswordLoading = $state(false)
26
+
27
+
let showSetPasswordForm = $state(false)
28
+
let setNewPassword = $state('')
29
+
let setConfirmPassword = $state('')
30
+
let setPasswordLoading = $state(false)
31
+
32
+
let showRemovePasswordForm = $state(false)
33
+
let removePasswordLoading = $state(false)
34
+
35
+
onMount(async () => {
36
+
await loadPasswordStatus()
37
+
})
38
+
39
+
async function loadPasswordStatus() {
40
+
loading = true
41
+
try {
42
+
const status = await api.getPasswordStatus(session.accessJwt)
43
+
hasPassword = status.hasPassword
44
+
onPasswordChanged?.(hasPassword)
45
+
} catch {
46
+
hasPassword = true
47
+
} finally {
48
+
loading = false
49
+
}
50
+
}
51
+
52
+
function handleReauthError(e: unknown, fallback: string, retryAction: () => Promise<void>) {
53
+
if (e instanceof ApiError) {
54
+
if (e.error === 'ReauthRequired') {
55
+
onReauthRequired(e.reauthMethods || ['password'], retryAction)
56
+
} else {
57
+
toast.error(e.message)
58
+
}
59
+
} else {
60
+
toast.error(fallback)
61
+
}
62
+
}
63
+
64
+
async function handleChangePassword(e: Event) {
65
+
e.preventDefault()
66
+
if (!currentPassword || !newPassword || !confirmNewPassword) return
67
+
if (newPassword !== confirmNewPassword) {
68
+
toast.error($_('security.passwordsDoNotMatch'))
69
+
return
70
+
}
71
+
if (newPassword.length < 8) {
72
+
toast.error($_('security.passwordTooShort'))
73
+
return
74
+
}
75
+
changePasswordLoading = true
76
+
try {
77
+
await api.changePassword(session.accessJwt, currentPassword, newPassword)
78
+
toast.success($_('security.passwordChanged'))
79
+
currentPassword = ''
80
+
newPassword = ''
81
+
confirmNewPassword = ''
82
+
showChangePasswordForm = false
83
+
} catch (e) {
84
+
handleReauthError(e, $_('security.failedToChangePassword'), () => handleChangePassword(new Event('submit')))
85
+
} finally {
86
+
changePasswordLoading = false
87
+
}
88
+
}
89
+
90
+
async function handleSetPassword(e: Event) {
91
+
e.preventDefault()
92
+
if (!setNewPassword || !setConfirmPassword) return
93
+
if (setNewPassword !== setConfirmPassword) {
94
+
toast.error($_('security.passwordsDoNotMatch'))
95
+
return
96
+
}
97
+
if (setNewPassword.length < 8) {
98
+
toast.error($_('security.passwordTooShort'))
99
+
return
100
+
}
101
+
setPasswordLoading = true
102
+
try {
103
+
await api.setPassword(session.accessJwt, setNewPassword)
104
+
hasPassword = true
105
+
toast.success($_('security.passwordSet'))
106
+
setNewPassword = ''
107
+
setConfirmPassword = ''
108
+
showSetPasswordForm = false
109
+
onPasswordChanged?.(true)
110
+
} catch (e) {
111
+
handleReauthError(e, $_('security.failedToSetPassword'), () => handleSetPassword(new Event('submit')))
112
+
} finally {
113
+
setPasswordLoading = false
114
+
}
115
+
}
116
+
117
+
async function handleRemovePassword() {
118
+
removePasswordLoading = true
119
+
try {
120
+
const token = await getValidToken()
121
+
if (!token) {
122
+
toast.error($_('security.sessionExpired'))
123
+
return
124
+
}
125
+
await api.removePassword(token)
126
+
hasPassword = false
127
+
showRemovePasswordForm = false
128
+
toast.success($_('security.passwordRemoved'))
129
+
onPasswordChanged?.(false)
130
+
} catch (e) {
131
+
handleReauthError(e, $_('security.failedToRemovePassword'), handleRemovePassword)
132
+
} finally {
133
+
removePasswordLoading = false
134
+
}
135
+
}
136
+
</script>
137
+
138
+
<section>
139
+
<h3>{$_('security.password')}</h3>
140
+
{#if !loading}
141
+
{#if hasPassword}
142
+
<div class="status success">{$_('security.passwordStatus')}</div>
143
+
144
+
{#if !showChangePasswordForm && !showRemovePasswordForm}
145
+
<div class="password-actions">
146
+
<button type="button" onclick={() => showChangePasswordForm = true}>
147
+
{$_('security.changePassword')}
148
+
</button>
149
+
{#if passkeyCount > 0}
150
+
<button type="button" class="danger-outline" onclick={() => showRemovePasswordForm = true}>
151
+
{$_('security.removePassword')}
152
+
</button>
153
+
{/if}
154
+
</div>
155
+
{/if}
156
+
157
+
{#if showChangePasswordForm}
158
+
<form class="inline-form" onsubmit={handleChangePassword}>
159
+
<h4>{$_('security.changePassword')}</h4>
160
+
<div>
161
+
<label for="current-password">{$_('security.currentPassword')}</label>
162
+
<input
163
+
id="current-password"
164
+
type="password"
165
+
bind:value={currentPassword}
166
+
placeholder={$_('security.currentPasswordPlaceholder')}
167
+
disabled={changePasswordLoading}
168
+
required
169
+
/>
170
+
</div>
171
+
<div>
172
+
<label for="new-password">{$_('security.newPassword')}</label>
173
+
<input
174
+
id="new-password"
175
+
type="password"
176
+
bind:value={newPassword}
177
+
placeholder={$_('security.newPasswordPlaceholder')}
178
+
disabled={changePasswordLoading}
179
+
required
180
+
minlength="8"
181
+
/>
182
+
</div>
183
+
<div>
184
+
<label for="confirm-password">{$_('security.confirmPassword')}</label>
185
+
<input
186
+
id="confirm-password"
187
+
type="password"
188
+
bind:value={confirmNewPassword}
189
+
placeholder={$_('security.confirmPasswordPlaceholder')}
190
+
disabled={changePasswordLoading}
191
+
required
192
+
minlength="8"
193
+
/>
194
+
</div>
195
+
<div class="actions">
196
+
<button type="button" class="secondary" onclick={() => { showChangePasswordForm = false; currentPassword = ''; newPassword = ''; confirmNewPassword = '' }}>
197
+
{$_('common.cancel')}
198
+
</button>
199
+
<button type="submit" disabled={changePasswordLoading || !currentPassword || !newPassword || !confirmNewPassword}>
200
+
{changePasswordLoading ? $_('security.changing') : $_('security.changePassword')}
201
+
</button>
202
+
</div>
203
+
</form>
204
+
{/if}
205
+
206
+
{#if showRemovePasswordForm}
207
+
<div class="remove-password-form">
208
+
<p class="warning-text">{$_('security.removePasswordWarning')}</p>
209
+
<div class="actions">
210
+
<button type="button" class="ghost sm" onclick={() => showRemovePasswordForm = false}>
211
+
{$_('common.cancel')}
212
+
</button>
213
+
<button type="button" class="danger sm" onclick={handleRemovePassword} disabled={removePasswordLoading}>
214
+
{removePasswordLoading ? $_('security.removing') : $_('security.removePassword')}
215
+
</button>
216
+
</div>
217
+
</div>
218
+
{/if}
219
+
{:else}
220
+
<div class="status info">{$_('security.noPassword')}</div>
221
+
222
+
{#if !showSetPasswordForm}
223
+
<button type="button" onclick={() => showSetPasswordForm = true}>
224
+
{$_('security.setPassword')}
225
+
</button>
226
+
{:else}
227
+
<form class="inline-form" onsubmit={handleSetPassword}>
228
+
<h4>{$_('security.setPassword')}</h4>
229
+
<div>
230
+
<label for="set-new-password">{$_('security.newPassword')}</label>
231
+
<input
232
+
id="set-new-password"
233
+
type="password"
234
+
bind:value={setNewPassword}
235
+
placeholder={$_('security.newPasswordPlaceholder')}
236
+
disabled={setPasswordLoading}
237
+
required
238
+
minlength="8"
239
+
/>
240
+
</div>
241
+
<div>
242
+
<label for="set-confirm-password">{$_('security.confirmPassword')}</label>
243
+
<input
244
+
id="set-confirm-password"
245
+
type="password"
246
+
bind:value={setConfirmPassword}
247
+
placeholder={$_('security.confirmPasswordPlaceholder')}
248
+
disabled={setPasswordLoading}
249
+
required
250
+
minlength="8"
251
+
/>
252
+
</div>
253
+
<div class="actions">
254
+
<button type="button" class="secondary" onclick={() => { showSetPasswordForm = false; setNewPassword = ''; setConfirmPassword = '' }}>
255
+
{$_('common.cancel')}
256
+
</button>
257
+
<button type="submit" disabled={setPasswordLoading || !setNewPassword || !setConfirmPassword}>
258
+
{setPasswordLoading ? $_('security.setting') : $_('security.setPassword')}
259
+
</button>
260
+
</div>
261
+
</form>
262
+
{/if}
263
+
{/if}
264
+
{/if}
265
+
</section>
+4
-4
frontend/src/components/dashboard/SessionsContent.svelte
+4
-4
frontend/src/components/dashboard/SessionsContent.svelte
···
116
116
</div>
117
117
<div class="session-details">
118
118
<div class="detail">
119
-
<span class="label">{$_('sessions.created')}</span>
120
-
<span class="value">{timeAgo(s.createdAt)}</span>
119
+
<span class="detail-label">{$_('sessions.created')}</span>
120
+
<span class="detail-value">{timeAgo(s.createdAt)}</span>
121
121
</div>
122
122
<div class="detail">
123
-
<span class="label">{$_('sessions.expires')}</span>
124
-
<span class="value">{formatDate(s.expiresAt)}</span>
123
+
<span class="detail-label">{$_('sessions.expires')}</span>
124
+
<span class="detail-value">{formatDate(s.expiresAt)}</span>
125
125
</div>
126
126
</div>
127
127
</div>
+279
frontend/src/components/dashboard/TotpSection.svelte
+279
frontend/src/components/dashboard/TotpSection.svelte
···
1
+
<script lang="ts">
2
+
import { onMount } from 'svelte'
3
+
import { api, ApiError } from '../../lib/api'
4
+
import { _ } from '../../lib/i18n'
5
+
import { toast } from '../../lib/toast.svelte'
6
+
import type { Session } from '../../lib/types/api'
7
+
import {
8
+
type TotpSetupState,
9
+
idleState,
10
+
qrState,
11
+
verifyState,
12
+
backupState,
13
+
goBackToQr,
14
+
finish,
15
+
type TotpQr,
16
+
} from '../../lib/types/totp-state'
17
+
18
+
interface Props {
19
+
session: Session
20
+
onStatusChanged?: (enabled: boolean, hasBackupCodes: boolean) => void
21
+
}
22
+
23
+
let { session, onStatusChanged }: Props = $props()
24
+
25
+
let totpEnabled = $state(false)
26
+
let hasBackupCodes = $state(false)
27
+
let totpSetup = $state<TotpSetupState>(idleState)
28
+
let verifyCodeRaw = $state('')
29
+
let verifyCode = $derived(verifyCodeRaw.replace(/\s/g, ''))
30
+
let verifyLoading = $state(false)
31
+
32
+
let disablePassword = $state('')
33
+
let disableCode = $state('')
34
+
let disableLoading = $state(false)
35
+
let showDisableForm = $state(false)
36
+
37
+
let regenPassword = $state('')
38
+
let regenCode = $state('')
39
+
let regenLoading = $state(false)
40
+
let showRegenForm = $state(false)
41
+
42
+
onMount(async () => {
43
+
await loadTotpStatus()
44
+
})
45
+
46
+
async function loadTotpStatus() {
47
+
try {
48
+
const status = await api.getTotpStatus(session.accessJwt)
49
+
totpEnabled = status.enabled
50
+
hasBackupCodes = status.hasBackupCodes
51
+
onStatusChanged?.(totpEnabled, hasBackupCodes)
52
+
} catch {
53
+
toast.error($_('security.failedToLoadTotpStatus'))
54
+
}
55
+
}
56
+
57
+
async function handleStartTotpSetup() {
58
+
verifyLoading = true
59
+
try {
60
+
const result = await api.createTotpSecret(session.accessJwt)
61
+
totpSetup = qrState(result.qrBase64, result.uri)
62
+
} catch (e) {
63
+
toast.error(e instanceof ApiError ? e.message : 'Failed to generate TOTP secret')
64
+
} finally {
65
+
verifyLoading = false
66
+
}
67
+
}
68
+
69
+
async function handleVerifyTotp(e: Event) {
70
+
e.preventDefault()
71
+
if (!verifyCode || totpSetup.step !== 'verify') return
72
+
verifyLoading = true
73
+
try {
74
+
const result = await api.enableTotp(session.accessJwt, verifyCode)
75
+
totpSetup = backupState(totpSetup, result.backupCodes)
76
+
totpEnabled = true
77
+
hasBackupCodes = true
78
+
verifyCodeRaw = ''
79
+
onStatusChanged?.(true, true)
80
+
} catch (e) {
81
+
toast.error(e instanceof ApiError ? e.message : 'Invalid code')
82
+
} finally {
83
+
verifyLoading = false
84
+
}
85
+
}
86
+
87
+
function handleFinishSetup() {
88
+
if (totpSetup.step !== 'backup') return
89
+
totpSetup = finish(totpSetup)
90
+
toast.success($_('security.totpEnabledSuccess'))
91
+
}
92
+
93
+
function copyBackupCodes() {
94
+
if (totpSetup.step !== 'backup') return
95
+
navigator.clipboard.writeText(totpSetup.backupCodes.join('\n'))
96
+
toast.success($_('security.backupCodesCopied'))
97
+
}
98
+
99
+
async function handleDisableTotp(e: Event) {
100
+
e.preventDefault()
101
+
if (!disablePassword || !disableCode) return
102
+
disableLoading = true
103
+
try {
104
+
await api.disableTotp(session.accessJwt, disablePassword, disableCode)
105
+
totpEnabled = false
106
+
hasBackupCodes = false
107
+
showDisableForm = false
108
+
disablePassword = ''
109
+
disableCode = ''
110
+
toast.success($_('security.totpDisabledSuccess'))
111
+
onStatusChanged?.(false, false)
112
+
} catch (e) {
113
+
toast.error(e instanceof ApiError ? e.message : $_('security.failedToDisableTotp'))
114
+
} finally {
115
+
disableLoading = false
116
+
}
117
+
}
118
+
119
+
async function handleRegenerateBackupCodes(e: Event) {
120
+
e.preventDefault()
121
+
if (!regenPassword || !regenCode) return
122
+
regenLoading = true
123
+
try {
124
+
const result = await api.regenerateBackupCodes(session.accessJwt, regenPassword, regenCode)
125
+
const dummyVerify = verifyState(qrState('', ''))
126
+
totpSetup = backupState(dummyVerify, result.backupCodes)
127
+
showRegenForm = false
128
+
regenPassword = ''
129
+
regenCode = ''
130
+
} catch (e) {
131
+
toast.error(e instanceof ApiError ? e.message : $_('security.failedToRegenerateBackupCodes'))
132
+
} finally {
133
+
regenLoading = false
134
+
}
135
+
}
136
+
</script>
137
+
138
+
<section>
139
+
<h3>{$_('security.totp')}</h3>
140
+
141
+
{#if totpSetup.step === 'idle'}
142
+
{#if totpEnabled}
143
+
<div class="status success">{$_('security.totpEnabled')}</div>
144
+
145
+
{#if !showDisableForm && !showRegenForm}
146
+
<div class="totp-actions">
147
+
<button type="button" class="secondary" onclick={() => showRegenForm = true}>
148
+
{$_('security.regenerateBackupCodes')}
149
+
</button>
150
+
<button type="button" class="danger-outline" onclick={() => showDisableForm = true}>
151
+
{$_('security.disableTotp')}
152
+
</button>
153
+
</div>
154
+
{/if}
155
+
156
+
{#if showRegenForm}
157
+
<form class="inline-form" onsubmit={handleRegenerateBackupCodes}>
158
+
<h4>{$_('security.regenerateBackupCodes')}</h4>
159
+
<p class="warning-text">{$_('security.regenerateConfirm')}</p>
160
+
<div>
161
+
<label for="regen-password">{$_('security.password')}</label>
162
+
<input
163
+
id="regen-password"
164
+
type="password"
165
+
bind:value={regenPassword}
166
+
placeholder={$_('security.enterPassword')}
167
+
disabled={regenLoading}
168
+
required
169
+
/>
170
+
</div>
171
+
<div>
172
+
<label for="regen-code">{$_('security.totpCode')}</label>
173
+
<input
174
+
id="regen-code"
175
+
type="text"
176
+
bind:value={regenCode}
177
+
placeholder={$_('security.totpCodePlaceholder')}
178
+
disabled={regenLoading}
179
+
required
180
+
maxlength="6"
181
+
inputmode="numeric"
182
+
/>
183
+
</div>
184
+
<div class="actions">
185
+
<button type="button" class="secondary" onclick={() => { showRegenForm = false; regenPassword = ''; regenCode = '' }}>
186
+
{$_('common.cancel')}
187
+
</button>
188
+
<button type="submit" disabled={regenLoading || !regenPassword || regenCode.length !== 6}>
189
+
{regenLoading ? $_('security.regenerating') : $_('security.regenerateBackupCodes')}
190
+
</button>
191
+
</div>
192
+
</form>
193
+
{/if}
194
+
195
+
{#if showDisableForm}
196
+
<form class="inline-form danger-form" onsubmit={handleDisableTotp}>
197
+
<h4>{$_('security.disableTotp')}</h4>
198
+
<p class="warning-text">{$_('security.disableTotpWarning')}</p>
199
+
<div>
200
+
<label for="disable-password">{$_('security.password')}</label>
201
+
<input
202
+
id="disable-password"
203
+
type="password"
204
+
bind:value={disablePassword}
205
+
placeholder={$_('security.enterPassword')}
206
+
disabled={disableLoading}
207
+
required
208
+
/>
209
+
</div>
210
+
<div>
211
+
<label for="disable-code">{$_('security.totpCode')}</label>
212
+
<input
213
+
id="disable-code"
214
+
type="text"
215
+
bind:value={disableCode}
216
+
placeholder={$_('security.totpCodePlaceholder')}
217
+
disabled={disableLoading}
218
+
required
219
+
maxlength="6"
220
+
inputmode="numeric"
221
+
/>
222
+
</div>
223
+
<div class="actions">
224
+
<button type="button" class="secondary" onclick={() => { showDisableForm = false; disablePassword = ''; disableCode = '' }}>
225
+
{$_('common.cancel')}
226
+
</button>
227
+
<button type="submit" class="danger" disabled={disableLoading || !disablePassword || disableCode.length !== 6}>
228
+
{disableLoading ? $_('security.disabling') : $_('security.disableTotp')}
229
+
</button>
230
+
</div>
231
+
</form>
232
+
{/if}
233
+
{:else}
234
+
<div class="status warning">{$_('security.totpDisabled')}</div>
235
+
<button onclick={handleStartTotpSetup} disabled={verifyLoading}>
236
+
{$_('security.enableTotp')}
237
+
</button>
238
+
{/if}
239
+
{:else if totpSetup.step === 'qr'}
240
+
{@const qrData = totpSetup as TotpQr}
241
+
<div class="setup-step">
242
+
<p>{$_('security.totpSetupInstructions')}</p>
243
+
<div class="qr-container">
244
+
<img src="data:image/png;base64,{qrData.qrBase64}" alt="TOTP QR Code" class="qr-code" />
245
+
</div>
246
+
<details class="manual-entry">
247
+
<summary>{$_('security.cantScan')}</summary>
248
+
<code class="secret-code">{qrData.totpUri.split('secret=')[1]?.split('&')[0] || ''}</code>
249
+
</details>
250
+
<button onclick={() => totpSetup = verifyState(qrData)}>{$_('security.next')}</button>
251
+
</div>
252
+
{:else if totpSetup.step === 'verify'}
253
+
{@const verifyData = totpSetup}
254
+
<div class="setup-step">
255
+
<p>{$_('security.totpCodePlaceholder')}</p>
256
+
<form onsubmit={handleVerifyTotp}>
257
+
<input type="text" bind:value={verifyCodeRaw} placeholder="000000" class="code-input" inputmode="numeric" disabled={verifyLoading} />
258
+
<div class="actions">
259
+
<button type="button" class="secondary" onclick={() => totpSetup = goBackToQr(verifyData)}>{$_('common.back')}</button>
260
+
<button type="submit" disabled={verifyLoading || verifyCode.length !== 6}>{$_('security.verifyAndEnable')}</button>
261
+
</div>
262
+
</form>
263
+
</div>
264
+
{:else if totpSetup.step === 'backup'}
265
+
<div class="setup-step">
266
+
<h4>{$_('security.backupCodes')}</h4>
267
+
<p class="warning-text">{$_('security.backupCodesDescription')}</p>
268
+
<div class="backup-codes">
269
+
{#each totpSetup.backupCodes as code}
270
+
<code class="backup-code">{code}</code>
271
+
{/each}
272
+
</div>
273
+
<div class="actions">
274
+
<button type="button" class="secondary" onclick={copyBackupCodes}>{$_('security.copyToClipboard')}</button>
275
+
<button onclick={handleFinishSetup}>{$_('security.savedMyCodes')}</button>
276
+
</div>
277
+
</div>
278
+
{/if}
279
+
</section>
+17
-30
frontend/src/styles/dashboard.css
+17
-30
frontend/src/styles/dashboard.css
···
279
279
display: flex;
280
280
align-items: center;
281
281
gap: var(--space-1);
282
-
padding: 0;
282
+
padding: var(--space-2) var(--space-3);
283
283
background: transparent;
284
284
border: none;
285
-
color: var(--secondary);
285
+
color: var(--text-secondary);
286
286
font-size: var(--text-base);
287
287
cursor: pointer;
288
288
margin-bottom: var(--space-2);
289
+
min-height: 44px;
290
+
}
291
+
292
+
.back-button:hover:not(:disabled) {
293
+
background: var(--bg-secondary);
294
+
color: var(--text-primary);
289
295
}
290
296
291
297
.back-arrow {
···
376
382
}
377
383
}
378
384
379
-
.overview {
380
-
background: var(--bg-secondary);
381
-
padding: var(--space-6);
382
-
}
383
-
384
385
.overview dl {
385
386
display: grid;
386
387
grid-template-columns: auto 1fr;
···
429
430
}
430
431
}
431
432
432
-
433
433
.current {
434
434
color: var(--text-secondary);
435
435
font-size: var(--text-sm);
···
607
607
}
608
608
609
609
.password-actions,
610
-
.totp-actions,
610
+
.totp-actions {
611
+
display: flex;
612
+
gap: var(--space-3);
613
+
}
611
614
612
615
.remove-password-form {
613
616
background: var(--error-bg);
···
949
952
font-size: var(--text-sm);
950
953
}
951
954
952
-
.sessions .detail .label {
955
+
.detail-label {
953
956
color: var(--text-secondary);
954
957
margin-right: var(--space-2);
955
958
}
956
959
957
-
.sessions .detail .value {
960
+
.detail-value {
958
961
color: var(--text-primary);
959
962
}
960
963
···
1688
1691
font-size: var(--text-sm);
1689
1692
}
1690
1693
1691
-
.controllers .detail .label {
1692
-
color: var(--text-secondary);
1693
-
margin-right: var(--space-2);
1694
-
}
1695
-
1696
-
.controllers .detail .value {
1697
-
color: var(--text-primary);
1698
-
}
1699
-
1700
-
.controllers .detail .value.did {
1694
+
.detail-value-did {
1701
1695
font-family: var(--font-mono);
1702
1696
font-size: var(--text-xs);
1703
1697
word-break: break-all;
···
2092
2086
gap: var(--space-3);
2093
2087
}
2094
2088
2095
-
2096
-
2097
2089
.item-id {
2098
2090
font-weight: var(--font-medium);
2099
2091
font-family: var(--font-mono);
···
2174
2166
font-size: var(--text-base);
2175
2167
}
2176
2168
2177
-
.feature-list {
2178
-
list-style: none;
2179
-
padding: 0;
2180
-
margin: 0 0 var(--space-4) 0;
2181
-
}
2182
-
2183
2169
.feature-list li {
2184
2170
padding: var(--space-2) 0;
2185
2171
padding-left: var(--space-4);
···
2263
2249
}
2264
2250
2265
2251
button.user-item-btn:hover {
2252
+
background: var(--bg-secondary);
2266
2253
border-color: var(--secondary);
2267
2254
}
2268
2255
···
2406
2393
margin-bottom: var(--space-5);
2407
2394
}
2408
2395
2409
-
.admin .definition-list .mono {
2396
+
.definition-mono {
2410
2397
font-family: var(--font-mono);
2411
2398
font-size: var(--text-xs);
2412
2399
word-break: break-all;
History
3 rounds
0 comments
oyster.cafe
submitted
#2
1 commit
expand
collapse
refactor(frontend): extract dashboard sections
expand 0 comments
pull request successfully merged
oyster.cafe
submitted
#1
1 commit
expand
collapse
refactor(frontend): extract dashboard sections
expand 0 comments
oyster.cafe
submitted
#0
1 commit
expand
collapse
refactor(frontend): extract dashboard sections