refactor(frontend): extract dashboard sections

This commit is contained in:
Lewis
2026-03-19 14:26:26 +00:00
committed by Tangled
parent 20212a413f
commit 467be99da3
9 changed files with 765 additions and 218 deletions
@@ -1,60 +0,0 @@
<script lang="ts">
import { _ } from '../../lib/i18n'
import type { Session } from '../../lib/types/api'
interface Props {
session: Session
}
let { session }: Props = $props()
</script>
<div class="overview">
<dl>
<dt>{$_('dashboard.handle')}</dt>
<dd>
@{session.handle}
{#if session.isAdmin}
<span class="badge admin">{$_('dashboard.admin')}</span>
{/if}
{#if session.accountKind === 'migrated'}
<span class="badge migrated">{$_('dashboard.migrated')}</span>
{:else if session.accountKind === 'deactivated'}
<span class="badge deactivated">{$_('dashboard.deactivated')}</span>
{/if}
</dd>
<dt>{$_('dashboard.did')}</dt>
<dd class="mono">{session.did}</dd>
{#if session.contactKind === 'channel'}
<dt>{$_('dashboard.primaryContact')}</dt>
<dd>
{#if session.preferredChannel === 'email'}
{session.email || $_('register.email')}
{:else if session.preferredChannel === 'discord'}
{$_('register.discord')}
{:else if session.preferredChannel === 'telegram'}
{$_('register.telegram')}
{:else if session.preferredChannel === 'signal'}
{$_('register.signal')}
{:else}
{session.preferredChannel}
{/if}
{#if session.preferredChannelVerified}
<span class="badge success">{$_('dashboard.verified')}</span>
{:else}
<span class="badge warning">{$_('dashboard.unverified')}</span>
{/if}
</dd>
{:else if session.contactKind === 'email'}
<dt>{$_('register.email')}</dt>
<dd>
{session.email}
{#if session.emailConfirmed}
<span class="badge success">{$_('dashboard.verified')}</span>
{:else}
<span class="badge warning">{$_('dashboard.unverified')}</span>
{/if}
</dd>
{/if}
</dl>
</div>
@@ -11,6 +11,8 @@
setColors as setGlobalColors,
setHasLogo as setGlobalHasLogo
} from '../../lib/serverConfig.svelte'
import LoadMoreSentinel from '../LoadMoreSentinel.svelte'
import { portal } from '../../lib/portal'
interface Props {
session: Session
@@ -32,7 +34,6 @@
indexedAt: string
emailConfirmedAt?: string
deactivatedAt?: string
invitesDisabled?: boolean
}
let stats = $state<ServerStats | null>(null)
@@ -41,6 +42,8 @@
let usersLoading = $state(false)
let searchQuery = $state('')
let usersCursor = $state<string | undefined>(undefined)
let usersHasMore = $state(true)
let searchDebounce: ReturnType<typeof setTimeout> | null = null
let selectedUser = $state<User | null>(null)
let userActionLoading = $state(false)
@@ -63,7 +66,7 @@
let serverConfigLoading = $state(false)
onMount(async () => {
await Promise.all([loadStats(), loadServerConfig()])
await Promise.all([loadStats(), loadServerConfig(), loadUsers(true)])
})
async function loadStats() {
@@ -82,6 +85,7 @@
if (reset) {
users = []
usersCursor = undefined
usersHasMore = true
}
try {
const result = await api.searchAccounts(session.accessJwt, {
@@ -91,6 +95,7 @@
})
users = reset ? result.accounts : [...users, ...result.accounts]
usersCursor = result.cursor
usersHasMore = !!result.cursor
} catch {
toast.error($_('admin.failedToLoadUsers'))
} finally {
@@ -98,9 +103,10 @@
}
}
function handleSearch(e: Event) {
e.preventDefault()
loadUsers(true)
function onSearchInput(value: string) {
searchQuery = value
if (searchDebounce) clearTimeout(searchDebounce)
searchDebounce = setTimeout(() => loadUsers(true), 300)
}
function formatBytes(bytes: number): string {
@@ -216,7 +222,6 @@
indexedAt: details.indexedAt,
emailConfirmedAt: details.emailConfirmedAt,
deactivatedAt: details.deactivatedAt,
invitesDisabled: details.invitesDisabled
}
} catch {
} finally {
@@ -228,26 +233,6 @@
selectedUser = null
}
async function toggleUserInvites() {
if (!selectedUser) return
userActionLoading = true
try {
if (selectedUser.invitesDisabled) {
await api.enableAccountInvites(session.accessJwt, unsafeAsDid(selectedUser.did))
selectedUser = { ...selectedUser, invitesDisabled: false }
toast.success($_('admin.invitesEnabled'))
} else {
await api.disableAccountInvites(session.accessJwt, unsafeAsDid(selectedUser.did))
selectedUser = { ...selectedUser, invitesDisabled: true }
toast.success($_('admin.invitesDisabled'))
}
} catch (e) {
toast.error(e instanceof ApiError ? e.message : $_('admin.failedToToggleInvites'))
} finally {
userActionLoading = false
}
}
async function deleteUserAccount() {
if (!selectedUser) return
if (!confirm($_('admin.deleteConfirm', { values: { handle: selectedUser.handle } }))) return
@@ -372,16 +357,12 @@
<section class="users-section">
<h3>{$_('admin.userManagement')}</h3>
<form class="search-bar" onsubmit={handleSearch}>
<input
type="text"
bind:value={searchQuery}
placeholder={$_('admin.searchPlaceholder')}
/>
<button type="submit" disabled={usersLoading}>
{usersLoading ? $_('common.loading') : $_('admin.search')}
</button>
</form>
<input
type="text"
value={searchQuery}
oninput={(e) => onSearchInput(e.currentTarget.value)}
placeholder={$_('admin.searchPlaceholder')}
/>
{#if users.length === 0 && !usersLoading}
<p class="empty">{$_('admin.searchToSeeUsers')}</p>
@@ -412,18 +393,14 @@
</li>
{/each}
</ul>
{#if usersCursor}
<button type="button" class="load-more" onclick={() => loadUsers(false)} disabled={usersLoading}>
{usersLoading ? $_('common.loading') : $_('admin.loadMore')}
</button>
{/if}
<LoadMoreSentinel hasMore={usersHasMore} loading={usersLoading} onLoadMore={() => loadUsers(false)} />
{/if}
</section>
</div>
{#if selectedUser}
<div class="modal-backdrop" onclick={closeUserDetail} onkeydown={(e) => e.key === 'Escape' && closeUserDetail()} role="presentation">
<div class="modal-backdrop" use:portal onclick={closeUserDetail} onkeydown={(e) => e.key === 'Escape' && closeUserDetail()} role="presentation">
<div class="modal" onclick={(e) => e.stopPropagation()} onkeydown={(e) => e.stopPropagation()} role="dialog" aria-modal="true" tabindex="-1">
<div class="modal-header">
<h2>{$_('admin.userDetails')}</h2>
@@ -437,7 +414,7 @@
<dt>{$_('admin.handle')}</dt>
<dd>@{selectedUser.handle}</dd>
<dt>{$_('admin.did')}</dt>
<dd class="mono">{selectedUser.did}</dd>
<dd class="definition-mono">{selectedUser.did}</dd>
<dt>{$_('admin.email')}</dt>
<dd>{selectedUser.email || '-'}</dd>
<dt>{$_('admin.created')}</dt>
@@ -452,22 +429,8 @@
<span class="badge unverified">{$_('admin.unverified')}</span>
{/if}
</dd>
<dt>{$_('admin.invites')}</dt>
<dd>
{#if selectedUser.invitesDisabled}
<span class="badge deactivated">{$_('admin.disabled')}</span>
{:else}
<span class="badge verified">{$_('admin.enabled')}</span>
{/if}
</dd>
</dl>
<div class="modal-actions">
<button
onclick={toggleUserInvites}
disabled={userActionLoading}
>
{selectedUser.invitesDisabled ? $_('admin.enableInvites') : $_('admin.disableInvites')}
</button>
<button
class="danger"
onclick={deleteUserAccount}
@@ -376,12 +376,12 @@
</div>
<div class="item-details">
<div class="detail">
<span class="label">{$_('delegation.did')}</span>
<span class="value did">{controller.did}</span>
<span class="detail-label">{$_('delegation.did')}</span>
<span class="detail-value detail-value-did">{controller.did}</span>
</div>
<div class="detail">
<span class="label">{$_('delegation.granted')}</span>
<span class="value">{formatDateTime(controller.grantedAt)}</span>
<span class="detail-label">{$_('delegation.granted')}</span>
<span class="detail-value">{formatDateTime(controller.grantedAt)}</span>
</div>
</div>
</div>
@@ -507,12 +507,12 @@
</div>
<div class="item-details">
<div class="detail">
<span class="label">{$_('delegation.did')}</span>
<span class="value did">{account.did}</span>
<span class="detail-label">{$_('delegation.did')}</span>
<span class="detail-value detail-value-did">{account.did}</span>
</div>
<div class="detail">
<span class="label">{$_('delegation.granted')}</span>
<span class="value">{formatDateTime(account.grantedAt)}</span>
<span class="detail-label">{$_('delegation.granted')}</span>
<span class="detail-value">{formatDateTime(account.grantedAt)}</span>
</div>
</div>
</div>
@@ -601,19 +601,19 @@
</div>
<div class="audit-entry-details">
<div class="detail">
<span class="label">{$_('delegation.actor')}</span>
<span class="value did">{entry.actorDid}</span>
<span class="detail-label">{$_('delegation.actor')}</span>
<span class="detail-value detail-value-did">{entry.actorDid}</span>
</div>
{#if entry.delegatedDid}
<div class="detail">
<span class="label">{$_('delegation.target')}</span>
<span class="value did">{entry.delegatedDid}</span>
<span class="detail-label">{$_('delegation.target')}</span>
<span class="detail-value detail-value-did">{entry.delegatedDid}</span>
</div>
{/if}
{#if entry.actionDetails}
<div class="detail">
<span class="label">{$_('delegation.details')}</span>
<span class="value audit-details-value">{formatActionDetails(entry.actionDetails)}</span>
<span class="detail-label">{$_('delegation.details')}</span>
<span class="detail-value audit-details-value">{formatActionDetails(entry.actionDetails)}</span>
</div>
{/if}
</div>
@@ -1,53 +0,0 @@
<script lang="ts">
import { _ } from '../../lib/i18n'
import { navigate, routes } from '../../lib/router.svelte'
import type { Session } from '../../lib/types/api'
interface Props {
session: Session
}
let { session }: Props = $props()
function startMigration(type: 'inbound' | 'offline') {
const url = type === 'offline'
? `${routes.migrate}?flow=offline`
: routes.migrate
navigate(url as typeof routes.migrate)
}
</script>
<div class="migration">
<section>
<h3>{$_('migration.migrateHere')}</h3>
<p class="description">{$_('migration.migrateHereDesc')}</p>
<ul class="feature-list">
<li>{$_('migration.bringDid')}</li>
<li>{$_('migration.transferData')}</li>
<li>{$_('migration.keepFollowers')}</li>
</ul>
<button onclick={() => startMigration('inbound')}>
{$_('migration.inbound.review.startMigration')}
</button>
</section>
<section>
<h3>{$_('migration.offlineRestore')}</h3>
<p class="description">{$_('migration.offlineRestoreDesc')}</p>
<ul class="feature-list">
<li>{$_('migration.offlineFeature1')}</li>
<li>{$_('migration.offlineFeature2')}</li>
<li>{$_('migration.offlineFeature3')}</li>
</ul>
<button class="secondary" onclick={() => startMigration('offline')}>
{$_('migration.offlineRestore')}
</button>
</section>
{#if session.accountKind === 'migrated'}
<section class="info-section">
<h3>{$_('dashboard.migratedTitle')}</h3>
<p>{$_('dashboard.migratedMessage', { values: { pds: session.migratedToPds || 'another PDS' } })}</p>
</section>
{/if}
</div>
@@ -0,0 +1,166 @@
<script lang="ts">
import { onMount } from 'svelte'
import { api, ApiError } from '../../lib/api'
import { _ } from '../../lib/i18n'
import { formatDate } from '../../lib/date'
import { toast } from '../../lib/toast.svelte'
import type { Session } from '../../lib/types/api'
import {
prepareCreationOptions,
serializeAttestationResponse,
type WebAuthnCreationOptionsResponse,
} from '../../lib/webauthn'
interface Props {
session: Session
hasPassword: boolean
onPasskeysChanged?: (count: number) => void
}
let { session, hasPassword, onPasskeysChanged }: Props = $props()
interface Passkey {
id: string
credentialId: string
friendlyName: string | null
createdAt: string
lastUsed: string | null
}
let passkeys = $state<Passkey[]>([])
let loading = $state(true)
let addingPasskey = $state(false)
let newPasskeyName = $state('')
let editingPasskeyId = $state<string | null>(null)
let editPasskeyName = $state('')
onMount(async () => {
await loadPasskeys()
})
async function loadPasskeys() {
loading = true
try {
const result = await api.listPasskeys(session.accessJwt)
passkeys = result.passkeys
onPasskeysChanged?.(passkeys.length)
} catch {
toast.error($_('security.failedToLoadPasskeys'))
} finally {
loading = false
}
}
async function handleAddPasskey() {
if (!window.PublicKeyCredential) {
toast.error($_('security.passkeysNotSupported'))
return
}
addingPasskey = true
try {
const { options } = await api.startPasskeyRegistration(session.accessJwt, newPasskeyName || undefined)
const publicKeyOptions = prepareCreationOptions(options as unknown as WebAuthnCreationOptionsResponse)
const credential = await navigator.credentials.create({ publicKey: publicKeyOptions })
if (!credential) {
toast.error($_('security.passkeyCreationCancelled'))
return
}
const credentialResponse = serializeAttestationResponse(credential as PublicKeyCredential)
await api.finishPasskeyRegistration(session.accessJwt, credentialResponse, newPasskeyName || undefined)
await loadPasskeys()
newPasskeyName = ''
toast.success($_('security.passkeyAddedSuccess'))
} catch (e) {
if (e instanceof DOMException && e.name === 'NotAllowedError') {
toast.error($_('security.passkeyCreationCancelled'))
} else {
toast.error(e instanceof ApiError ? e.message : 'Failed to add passkey')
}
} finally {
addingPasskey = false
}
}
async function handleDeletePasskey(id: string) {
const passkey = passkeys.find(p => p.id === id)
if (!confirm($_('security.deletePasskeyConfirm', { values: { name: passkey?.friendlyName || 'this passkey' } }))) return
try {
await api.deletePasskey(session.accessJwt, id)
await loadPasskeys()
toast.success($_('security.passkeyDeleted'))
} catch (e) {
toast.error(e instanceof ApiError ? e.message : 'Failed to delete passkey')
}
}
async function handleSavePasskeyName() {
if (!editingPasskeyId || !editPasskeyName.trim()) return
try {
await api.updatePasskey(session.accessJwt, editingPasskeyId, editPasskeyName.trim())
await loadPasskeys()
editingPasskeyId = null
editPasskeyName = ''
toast.success($_('security.passkeyRenamed'))
} catch (e) {
toast.error(e instanceof ApiError ? e.message : 'Failed to rename passkey')
}
}
function startEditPasskey(passkey: Passkey) {
editingPasskeyId = passkey.id
editPasskeyName = passkey.friendlyName || ''
}
function cancelEditPasskey() {
editingPasskeyId = null
editPasskeyName = ''
}
</script>
<section>
<h3>{$_('security.passkeys')}</h3>
{#if !loading}
{#if passkeys.length > 0}
<ul class="passkey-list">
{#each passkeys as passkey}
<li class="passkey-item">
{#if editingPasskeyId === passkey.id}
<div class="passkey-edit">
<input type="text" bind:value={editPasskeyName} placeholder={$_('security.passkeyName')} />
<button type="button" class="sm" onclick={handleSavePasskeyName}>{$_('common.save')}</button>
<button type="button" class="sm secondary" onclick={cancelEditPasskey}>{$_('common.cancel')}</button>
</div>
{:else}
<div class="passkey-info">
<span class="passkey-name">{passkey.friendlyName || $_('security.unnamedPasskey')}</span>
<span class="passkey-meta">
{$_('security.added')} {formatDate(passkey.createdAt)}
{#if passkey.lastUsed}
- {$_('security.lastUsed')} {formatDate(passkey.lastUsed)}
{/if}
</span>
</div>
<div class="passkey-actions">
<button type="button" class="sm secondary" onclick={() => startEditPasskey(passkey)}>{$_('security.rename')}</button>
{#if hasPassword || passkeys.length > 1}
<button type="button" class="sm danger-outline" onclick={() => handleDeletePasskey(passkey.id)}>{$_('security.deletePasskey')}</button>
{/if}
</div>
{/if}
</li>
{/each}
</ul>
{:else}
<div class="status warning">{$_('security.noPasskeys')}</div>
{/if}
<div class="add-passkey">
<input type="text" bind:value={newPasskeyName} placeholder={$_('security.passkeyNamePlaceholder')} disabled={addingPasskey} />
<button onclick={handleAddPasskey} disabled={addingPasskey}>
{addingPasskey ? $_('security.adding') : $_('security.addPasskey')}
</button>
</div>
{/if}
</section>
@@ -0,0 +1,265 @@
<script lang="ts">
import { onMount } from 'svelte'
import { getValidToken } from '../../lib/auth.svelte'
import { api, ApiError } from '../../lib/api'
import { _ } from '../../lib/i18n'
import { toast } from '../../lib/toast.svelte'
import type { Session } from '../../lib/types/api'
interface Props {
session: Session
passkeyCount: number
onPasswordChanged?: (hasPassword: boolean) => void
onReauthRequired: (methods: string[], retryAction: () => Promise<void>) => void
}
let { session, passkeyCount, onPasswordChanged, onReauthRequired }: Props = $props()
let hasPassword = $state(true)
let loading = $state(true)
let showChangePasswordForm = $state(false)
let currentPassword = $state('')
let newPassword = $state('')
let confirmNewPassword = $state('')
let changePasswordLoading = $state(false)
let showSetPasswordForm = $state(false)
let setNewPassword = $state('')
let setConfirmPassword = $state('')
let setPasswordLoading = $state(false)
let showRemovePasswordForm = $state(false)
let removePasswordLoading = $state(false)
onMount(async () => {
await loadPasswordStatus()
})
async function loadPasswordStatus() {
loading = true
try {
const status = await api.getPasswordStatus(session.accessJwt)
hasPassword = status.hasPassword
onPasswordChanged?.(hasPassword)
} catch {
hasPassword = true
} finally {
loading = false
}
}
function handleReauthError(e: unknown, fallback: string, retryAction: () => Promise<void>) {
if (e instanceof ApiError) {
if (e.error === 'ReauthRequired') {
onReauthRequired(e.reauthMethods || ['password'], retryAction)
} else {
toast.error(e.message)
}
} else {
toast.error(fallback)
}
}
async function handleChangePassword(e: Event) {
e.preventDefault()
if (!currentPassword || !newPassword || !confirmNewPassword) return
if (newPassword !== confirmNewPassword) {
toast.error($_('security.passwordsDoNotMatch'))
return
}
if (newPassword.length < 8) {
toast.error($_('security.passwordTooShort'))
return
}
changePasswordLoading = true
try {
await api.changePassword(session.accessJwt, currentPassword, newPassword)
toast.success($_('security.passwordChanged'))
currentPassword = ''
newPassword = ''
confirmNewPassword = ''
showChangePasswordForm = false
} catch (e) {
handleReauthError(e, $_('security.failedToChangePassword'), () => handleChangePassword(new Event('submit')))
} finally {
changePasswordLoading = false
}
}
async function handleSetPassword(e: Event) {
e.preventDefault()
if (!setNewPassword || !setConfirmPassword) return
if (setNewPassword !== setConfirmPassword) {
toast.error($_('security.passwordsDoNotMatch'))
return
}
if (setNewPassword.length < 8) {
toast.error($_('security.passwordTooShort'))
return
}
setPasswordLoading = true
try {
await api.setPassword(session.accessJwt, setNewPassword)
hasPassword = true
toast.success($_('security.passwordSet'))
setNewPassword = ''
setConfirmPassword = ''
showSetPasswordForm = false
onPasswordChanged?.(true)
} catch (e) {
handleReauthError(e, $_('security.failedToSetPassword'), () => handleSetPassword(new Event('submit')))
} finally {
setPasswordLoading = false
}
}
async function handleRemovePassword() {
removePasswordLoading = true
try {
const token = await getValidToken()
if (!token) {
toast.error($_('security.sessionExpired'))
return
}
await api.removePassword(token)
hasPassword = false
showRemovePasswordForm = false
toast.success($_('security.passwordRemoved'))
onPasswordChanged?.(false)
} catch (e) {
handleReauthError(e, $_('security.failedToRemovePassword'), handleRemovePassword)
} finally {
removePasswordLoading = false
}
}
</script>
<section>
<h3>{$_('security.password')}</h3>
{#if !loading}
{#if hasPassword}
<div class="status success">{$_('security.passwordStatus')}</div>
{#if !showChangePasswordForm && !showRemovePasswordForm}
<div class="password-actions">
<button type="button" onclick={() => showChangePasswordForm = true}>
{$_('security.changePassword')}
</button>
{#if passkeyCount > 0}
<button type="button" class="danger-outline" onclick={() => showRemovePasswordForm = true}>
{$_('security.removePassword')}
</button>
{/if}
</div>
{/if}
{#if showChangePasswordForm}
<form class="inline-form" onsubmit={handleChangePassword}>
<h4>{$_('security.changePassword')}</h4>
<div>
<label for="current-password">{$_('security.currentPassword')}</label>
<input
id="current-password"
type="password"
bind:value={currentPassword}
placeholder={$_('security.currentPasswordPlaceholder')}
disabled={changePasswordLoading}
required
/>
</div>
<div>
<label for="new-password">{$_('security.newPassword')}</label>
<input
id="new-password"
type="password"
bind:value={newPassword}
placeholder={$_('security.newPasswordPlaceholder')}
disabled={changePasswordLoading}
required
minlength="8"
/>
</div>
<div>
<label for="confirm-password">{$_('security.confirmPassword')}</label>
<input
id="confirm-password"
type="password"
bind:value={confirmNewPassword}
placeholder={$_('security.confirmPasswordPlaceholder')}
disabled={changePasswordLoading}
required
minlength="8"
/>
</div>
<div class="actions">
<button type="button" class="secondary" onclick={() => { showChangePasswordForm = false; currentPassword = ''; newPassword = ''; confirmNewPassword = '' }}>
{$_('common.cancel')}
</button>
<button type="submit" disabled={changePasswordLoading || !currentPassword || !newPassword || !confirmNewPassword}>
{changePasswordLoading ? $_('security.changing') : $_('security.changePassword')}
</button>
</div>
</form>
{/if}
{#if showRemovePasswordForm}
<div class="remove-password-form">
<p class="warning-text">{$_('security.removePasswordWarning')}</p>
<div class="actions">
<button type="button" class="ghost sm" onclick={() => showRemovePasswordForm = false}>
{$_('common.cancel')}
</button>
<button type="button" class="danger sm" onclick={handleRemovePassword} disabled={removePasswordLoading}>
{removePasswordLoading ? $_('security.removing') : $_('security.removePassword')}
</button>
</div>
</div>
{/if}
{:else}
<div class="status info">{$_('security.noPassword')}</div>
{#if !showSetPasswordForm}
<button type="button" onclick={() => showSetPasswordForm = true}>
{$_('security.setPassword')}
</button>
{:else}
<form class="inline-form" onsubmit={handleSetPassword}>
<h4>{$_('security.setPassword')}</h4>
<div>
<label for="set-new-password">{$_('security.newPassword')}</label>
<input
id="set-new-password"
type="password"
bind:value={setNewPassword}
placeholder={$_('security.newPasswordPlaceholder')}
disabled={setPasswordLoading}
required
minlength="8"
/>
</div>
<div>
<label for="set-confirm-password">{$_('security.confirmPassword')}</label>
<input
id="set-confirm-password"
type="password"
bind:value={setConfirmPassword}
placeholder={$_('security.confirmPasswordPlaceholder')}
disabled={setPasswordLoading}
required
minlength="8"
/>
</div>
<div class="actions">
<button type="button" class="secondary" onclick={() => { showSetPasswordForm = false; setNewPassword = ''; setConfirmPassword = '' }}>
{$_('common.cancel')}
</button>
<button type="submit" disabled={setPasswordLoading || !setNewPassword || !setConfirmPassword}>
{setPasswordLoading ? $_('security.setting') : $_('security.setPassword')}
</button>
</div>
</form>
{/if}
{/if}
{/if}
</section>
@@ -116,12 +116,12 @@
</div>
<div class="session-details">
<div class="detail">
<span class="label">{$_('sessions.created')}</span>
<span class="value">{timeAgo(s.createdAt)}</span>
<span class="detail-label">{$_('sessions.created')}</span>
<span class="detail-value">{timeAgo(s.createdAt)}</span>
</div>
<div class="detail">
<span class="label">{$_('sessions.expires')}</span>
<span class="value">{formatDate(s.expiresAt)}</span>
<span class="detail-label">{$_('sessions.expires')}</span>
<span class="detail-value">{formatDate(s.expiresAt)}</span>
</div>
</div>
</div>
@@ -0,0 +1,279 @@
<script lang="ts">
import { onMount } from 'svelte'
import { api, ApiError } from '../../lib/api'
import { _ } from '../../lib/i18n'
import { toast } from '../../lib/toast.svelte'
import type { Session } from '../../lib/types/api'
import {
type TotpSetupState,
idleState,
qrState,
verifyState,
backupState,
goBackToQr,
finish,
type TotpQr,
} from '../../lib/types/totp-state'
interface Props {
session: Session
onStatusChanged?: (enabled: boolean, hasBackupCodes: boolean) => void
}
let { session, onStatusChanged }: Props = $props()
let totpEnabled = $state(false)
let hasBackupCodes = $state(false)
let totpSetup = $state<TotpSetupState>(idleState)
let verifyCodeRaw = $state('')
let verifyCode = $derived(verifyCodeRaw.replace(/\s/g, ''))
let verifyLoading = $state(false)
let disablePassword = $state('')
let disableCode = $state('')
let disableLoading = $state(false)
let showDisableForm = $state(false)
let regenPassword = $state('')
let regenCode = $state('')
let regenLoading = $state(false)
let showRegenForm = $state(false)
onMount(async () => {
await loadTotpStatus()
})
async function loadTotpStatus() {
try {
const status = await api.getTotpStatus(session.accessJwt)
totpEnabled = status.enabled
hasBackupCodes = status.hasBackupCodes
onStatusChanged?.(totpEnabled, hasBackupCodes)
} catch {
toast.error($_('security.failedToLoadTotpStatus'))
}
}
async function handleStartTotpSetup() {
verifyLoading = true
try {
const result = await api.createTotpSecret(session.accessJwt)
totpSetup = qrState(result.qrBase64, result.uri)
} catch (e) {
toast.error(e instanceof ApiError ? e.message : 'Failed to generate TOTP secret')
} finally {
verifyLoading = false
}
}
async function handleVerifyTotp(e: Event) {
e.preventDefault()
if (!verifyCode || totpSetup.step !== 'verify') return
verifyLoading = true
try {
const result = await api.enableTotp(session.accessJwt, verifyCode)
totpSetup = backupState(totpSetup, result.backupCodes)
totpEnabled = true
hasBackupCodes = true
verifyCodeRaw = ''
onStatusChanged?.(true, true)
} catch (e) {
toast.error(e instanceof ApiError ? e.message : 'Invalid code')
} finally {
verifyLoading = false
}
}
function handleFinishSetup() {
if (totpSetup.step !== 'backup') return
totpSetup = finish(totpSetup)
toast.success($_('security.totpEnabledSuccess'))
}
function copyBackupCodes() {
if (totpSetup.step !== 'backup') return
navigator.clipboard.writeText(totpSetup.backupCodes.join('\n'))
toast.success($_('security.backupCodesCopied'))
}
async function handleDisableTotp(e: Event) {
e.preventDefault()
if (!disablePassword || !disableCode) return
disableLoading = true
try {
await api.disableTotp(session.accessJwt, disablePassword, disableCode)
totpEnabled = false
hasBackupCodes = false
showDisableForm = false
disablePassword = ''
disableCode = ''
toast.success($_('security.totpDisabledSuccess'))
onStatusChanged?.(false, false)
} catch (e) {
toast.error(e instanceof ApiError ? e.message : $_('security.failedToDisableTotp'))
} finally {
disableLoading = false
}
}
async function handleRegenerateBackupCodes(e: Event) {
e.preventDefault()
if (!regenPassword || !regenCode) return
regenLoading = true
try {
const result = await api.regenerateBackupCodes(session.accessJwt, regenPassword, regenCode)
const dummyVerify = verifyState(qrState('', ''))
totpSetup = backupState(dummyVerify, result.backupCodes)
showRegenForm = false
regenPassword = ''
regenCode = ''
} catch (e) {
toast.error(e instanceof ApiError ? e.message : $_('security.failedToRegenerateBackupCodes'))
} finally {
regenLoading = false
}
}
</script>
<section>
<h3>{$_('security.totp')}</h3>
{#if totpSetup.step === 'idle'}
{#if totpEnabled}
<div class="status success">{$_('security.totpEnabled')}</div>
{#if !showDisableForm && !showRegenForm}
<div class="totp-actions">
<button type="button" class="secondary" onclick={() => showRegenForm = true}>
{$_('security.regenerateBackupCodes')}
</button>
<button type="button" class="danger-outline" onclick={() => showDisableForm = true}>
{$_('security.disableTotp')}
</button>
</div>
{/if}
{#if showRegenForm}
<form class="inline-form" onsubmit={handleRegenerateBackupCodes}>
<h4>{$_('security.regenerateBackupCodes')}</h4>
<p class="warning-text">{$_('security.regenerateConfirm')}</p>
<div>
<label for="regen-password">{$_('security.password')}</label>
<input
id="regen-password"
type="password"
bind:value={regenPassword}
placeholder={$_('security.enterPassword')}
disabled={regenLoading}
required
/>
</div>
<div>
<label for="regen-code">{$_('security.totpCode')}</label>
<input
id="regen-code"
type="text"
bind:value={regenCode}
placeholder={$_('security.totpCodePlaceholder')}
disabled={regenLoading}
required
maxlength="6"
inputmode="numeric"
/>
</div>
<div class="actions">
<button type="button" class="secondary" onclick={() => { showRegenForm = false; regenPassword = ''; regenCode = '' }}>
{$_('common.cancel')}
</button>
<button type="submit" disabled={regenLoading || !regenPassword || regenCode.length !== 6}>
{regenLoading ? $_('security.regenerating') : $_('security.regenerateBackupCodes')}
</button>
</div>
</form>
{/if}
{#if showDisableForm}
<form class="inline-form danger-form" onsubmit={handleDisableTotp}>
<h4>{$_('security.disableTotp')}</h4>
<p class="warning-text">{$_('security.disableTotpWarning')}</p>
<div>
<label for="disable-password">{$_('security.password')}</label>
<input
id="disable-password"
type="password"
bind:value={disablePassword}
placeholder={$_('security.enterPassword')}
disabled={disableLoading}
required
/>
</div>
<div>
<label for="disable-code">{$_('security.totpCode')}</label>
<input
id="disable-code"
type="text"
bind:value={disableCode}
placeholder={$_('security.totpCodePlaceholder')}
disabled={disableLoading}
required
maxlength="6"
inputmode="numeric"
/>
</div>
<div class="actions">
<button type="button" class="secondary" onclick={() => { showDisableForm = false; disablePassword = ''; disableCode = '' }}>
{$_('common.cancel')}
</button>
<button type="submit" class="danger" disabled={disableLoading || !disablePassword || disableCode.length !== 6}>
{disableLoading ? $_('security.disabling') : $_('security.disableTotp')}
</button>
</div>
</form>
{/if}
{:else}
<div class="status warning">{$_('security.totpDisabled')}</div>
<button onclick={handleStartTotpSetup} disabled={verifyLoading}>
{$_('security.enableTotp')}
</button>
{/if}
{:else if totpSetup.step === 'qr'}
{@const qrData = totpSetup as TotpQr}
<div class="setup-step">
<p>{$_('security.totpSetupInstructions')}</p>
<div class="qr-container">
<img src="data:image/png;base64,{qrData.qrBase64}" alt="TOTP QR Code" class="qr-code" />
</div>
<details class="manual-entry">
<summary>{$_('security.cantScan')}</summary>
<code class="secret-code">{qrData.totpUri.split('secret=')[1]?.split('&')[0] || ''}</code>
</details>
<button onclick={() => totpSetup = verifyState(qrData)}>{$_('security.next')}</button>
</div>
{:else if totpSetup.step === 'verify'}
{@const verifyData = totpSetup}
<div class="setup-step">
<p>{$_('security.totpCodePlaceholder')}</p>
<form onsubmit={handleVerifyTotp}>
<input type="text" bind:value={verifyCodeRaw} placeholder="000000" class="code-input" inputmode="numeric" disabled={verifyLoading} />
<div class="actions">
<button type="button" class="secondary" onclick={() => totpSetup = goBackToQr(verifyData)}>{$_('common.back')}</button>
<button type="submit" disabled={verifyLoading || verifyCode.length !== 6}>{$_('security.verifyAndEnable')}</button>
</div>
</form>
</div>
{:else if totpSetup.step === 'backup'}
<div class="setup-step">
<h4>{$_('security.backupCodes')}</h4>
<p class="warning-text">{$_('security.backupCodesDescription')}</p>
<div class="backup-codes">
{#each totpSetup.backupCodes as code}
<code class="backup-code">{code}</code>
{/each}
</div>
<div class="actions">
<button type="button" class="secondary" onclick={copyBackupCodes}>{$_('security.copyToClipboard')}</button>
<button onclick={handleFinishSetup}>{$_('security.savedMyCodes')}</button>
</div>
</div>
{/if}
</section>
+17 -30
View File
@@ -279,13 +279,19 @@ button.dropdown-item.logout-item {
display: flex;
align-items: center;
gap: var(--space-1);
padding: 0;
padding: var(--space-2) var(--space-3);
background: transparent;
border: none;
color: var(--secondary);
color: var(--text-secondary);
font-size: var(--text-base);
cursor: pointer;
margin-bottom: var(--space-2);
min-height: 44px;
}
.back-button:hover:not(:disabled) {
background: var(--bg-secondary);
color: var(--text-primary);
}
.back-arrow {
@@ -376,11 +382,6 @@ button.dropdown-item.logout-item {
}
}
.overview {
background: var(--bg-secondary);
padding: var(--space-6);
}
.overview dl {
display: grid;
grid-template-columns: auto 1fr;
@@ -429,7 +430,6 @@ button.dropdown-item.logout-item {
}
}
.current {
color: var(--text-secondary);
font-size: var(--text-sm);
@@ -607,7 +607,10 @@ code.record {
}
.password-actions,
.totp-actions,
.totp-actions {
display: flex;
gap: var(--space-3);
}
.remove-password-form {
background: var(--error-bg);
@@ -949,12 +952,12 @@ code.record {
font-size: var(--text-sm);
}
.sessions .detail .label {
.detail-label {
color: var(--text-secondary);
margin-right: var(--space-2);
}
.sessions .detail .value {
.detail-value {
color: var(--text-primary);
}
@@ -1688,16 +1691,7 @@ button.record-item:hover {
font-size: var(--text-sm);
}
.controllers .detail .label {
color: var(--text-secondary);
margin-right: var(--space-2);
}
.controllers .detail .value {
color: var(--text-primary);
}
.controllers .detail .value.did {
.detail-value-did {
font-family: var(--font-mono);
font-size: var(--text-xs);
word-break: break-all;
@@ -2092,8 +2086,6 @@ button.typeahead-item:hover {
gap: var(--space-3);
}
.item-id {
font-weight: var(--font-medium);
font-family: var(--font-mono);
@@ -2174,12 +2166,6 @@ button.typeahead-item:hover {
font-size: var(--text-base);
}
.feature-list {
list-style: none;
padding: 0;
margin: 0 0 var(--space-4) 0;
}
.feature-list li {
padding: var(--space-2) 0;
padding-left: var(--space-4);
@@ -2263,6 +2249,7 @@ button.typeahead-item:hover {
}
button.user-item-btn:hover {
background: var(--bg-secondary);
border-color: var(--secondary);
}
@@ -2406,7 +2393,7 @@ button.remove-logo:hover {
margin-bottom: var(--space-5);
}
.admin .definition-list .mono {
.definition-mono {
font-family: var(--font-mono);
font-size: var(--text-xs);
word-break: break-all;