mirror of
https://tangled.org/tranquil.farm/tranquil-pds
synced 2026-08-31 13:17:05 +00:00
refactor(frontend): extract dashboard sections
This commit is contained in:
@@ -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>
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user