Files
auth-portal/apps/web/src/routes/_auth.account.tsx
T

281 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useMemo, useState } from 'react'
import { createFileRoute } from '@tanstack/react-router'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { ClockIcon, FingerprintIcon, LayoutGridIcon, ShieldIcon, TrashIcon } from 'lucide-react'
import { browserSupportsWebAuthn, startRegistration } from '@simplewebauthn/browser'
import type { PublicKeyCredentialCreationOptionsJSON } from '@simplewebauthn/browser'
import { toast } from 'sonner'
import type { PasskeyCredential } from '@authportal/shared'
import { ExplainBanner } from '@/components/portal/explain-banner'
import { KvFrame, KvList, PageHead, PageTab, PageTabsList, PortalSheet, SignalAlert, StatusBadge, TypedConfirm } from '@/components/portal/bits'
import { fmtDate, fmtWhen } from '@/components/portal/format'
import { kpi } from '@/components/portal/kpi'
import { KpiStrip } from '@/components/portal/kpi-strip'
import { SectionGrid, TwoLineCell, type GridColumn } from '@/components/portal/section-grid'
import { Button } from '@authportal/ui/components/button'
import { Field, FieldDescription, FieldLabel } from '@authportal/ui/components/field'
import { Input } from '@authportal/ui/components/input'
import { Skeleton } from '@authportal/ui/components/skeleton'
import { Tabs, TabsContent } from '@authportal/ui/components/tabs'
import { ApiError } from '@/lib/api-client'
import { meQueryOptions } from '@/queries/auth'
import {
deletePasskey,
passkeysQueryKey,
passkeysQueryOptions,
renamePasskey,
webauthnRegister,
webauthnRegisterOptions,
} from '@/queries/webauthn'
export const Route = createFileRoute('/_auth/account')({
component: AccountPage,
})
const STALE_DAYS = 60
type Row = PasskeyCredential & { tone: 'ok' | 'wn'; status: string }
function toRow(k: PasskeyCredential, now: number): Row {
const last = new Date(k.last_used_at ?? k.created_at).getTime()
const stale = now - last > STALE_DAYS * 24 * 60 * 60 * 1000
return { ...k, tone: stale ? 'wn' : 'ok', status: stale ? 'Давно не использовался' : 'Активен' }
}
function kindOf(k: PasskeyCredential) {
return k.device_type === 'multiDevice' ? 'Синхронизируемый' : k.device_type === 'singleDevice' ? 'Ключ устройства' : 'Passkey'
}
function AccountPage() {
const queryClient = useQueryClient()
const { data: me } = useQuery(meQueryOptions)
const { data: passkeys = [], isLoading, isError, refetch, dataUpdatedAt } = useQuery(passkeysQueryOptions)
const supported = browserSupportsWebAuthn()
const [addOpen, setAddOpen] = useState(false)
const [newName, setNewName] = useState('')
const [selected, setSelected] = useState<Row | null>(null)
const [renameValue, setRenameValue] = useState('')
const [deleting, setDeleting] = useState<Row | null>(null)
const rows = useMemo(() => passkeys.map((k) => toRow(k, dataUpdatedAt || Date.parse(k.created_at))), [passkeys, dataUpdatedAt])
const lastUsed = rows.map((r) => r.last_used_at).filter((x): x is string => Boolean(x)).sort().at(-1) ?? null
const registerMutation = useMutation({
mutationFn: async (name: string) => {
const { challenge_id, options } = await webauthnRegisterOptions()
const att = await startRegistration({ optionsJSON: options as unknown as PublicKeyCredentialCreationOptionsJSON })
return webauthnRegister(challenge_id, att, name.trim() || undefined)
},
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: passkeysQueryKey })
setAddOpen(false)
setNewName('')
toast.success('Passkey добавлен')
},
onError: (err) => toast.error(err instanceof ApiError ? err.message : 'Не удалось добавить passkey'),
})
const renameMutation = useMutation({
mutationFn: ({ id, name }: { id: string; name: string }) => renamePasskey(id, name),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: passkeysQueryKey })
setSelected(null)
toast.success('Название сохранено')
},
onError: (err) => toast.error(err instanceof ApiError ? err.message : 'Не удалось переименовать passkey'),
})
const deleteMutation = useMutation({
mutationFn: (id: string) => deletePasskey(id),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: passkeysQueryKey })
setDeleting(null)
toast.success('Passkey удалён')
},
onError: (err) => toast.error(err instanceof ApiError ? err.message : 'Не удалось удалить passkey'),
})
const columns = useMemo<GridColumn<Row>[]>(
() => [
{ id: 'name', title: 'Passkey', size: 280, sortValue: (r) => r.name, cell: (r) => <TwoLineCell dot={r.tone} title={r.name} sub={kindOf(r)} /> },
{ id: 'status', title: 'Статус', size: 210, cell: (r) => <StatusBadge tone={r.tone}>{r.status}</StatusBadge> },
{ id: 'added', title: 'Добавлен', size: 120, sortValue: (r) => r.created_at, cell: (r) => <span className="font-mono text-[12.5px]">{fmtDate(r.created_at)}</span> },
{ id: 'last', title: 'Последний вход', size: 180, sortValue: (r) => r.last_used_at ?? '', cell: (r) => <span className="font-mono text-[12.5px]">{r.last_used_at ? fmtWhen(r.last_used_at) : 'ещё не использовался'}</span> },
],
[],
)
const open = (r: Row) => {
setSelected(r)
setRenameValue(r.name)
}
return (
<>
<PageHead
title="Безопасность"
sub="Passkey как альтернатива паролю · пароль остаётся запасным входом"
actions={
<Button disabled={!supported} onClick={() => setAddOpen(true)}>
Добавить passkey
</Button>
}
/>
{!supported ? (
<SignalAlert tone="wn" title="Браузер не поддерживает passkeys.">
Откройте портал по HTTPS в современном браузере. Вход по паролю остаётся доступным.
</SignalAlert>
) : null}
{supported && !isLoading && rows.length <= 1 ? (
<ExplainBanner
tone="wn"
title={rows.length === 0 ? 'Passkey не добавлен.' : 'Один passkey — единственная копия.'}
text={
rows.length === 0
? 'Сейчас вход возможен только по паролю. Добавьте passkey, чтобы входить отпечатком, Face ID или ключом безопасности.'
: 'Если устройство будет потеряно, вход возможен только по паролю. Добавьте второй ключ на другом устройстве или ключ безопасности.'
}
>
Совет: держите два ключа на разных устройствах, например платформенный и ключ безопасности.
</ExplainBanner>
) : null}
<KpiStrip
aria-label="Показатели"
items={[
kpi('keys', 'Passkeys', String(rows.length), { icon: FingerprintIcon, tone: rows.length ? 'ok' : 'wn', badge: rows.length ? 'активны' : 'не добавлены' }),
kpi('last', 'Последний вход', lastUsed ? fmtWhen(lastUsed) : '—', { icon: ClockIcon, badge: 'по passkey' }),
kpi('apps', 'Приложений', String(me?.apps.length ?? 0), { icon: LayoutGridIcon, badge: 'доступно вам' }),
kpi('role', 'Роль', me?.is_admin ? 'Админ' : 'Пользователь', { icon: ShieldIcon, badge: me?.is_admin ? 'is_admin' : 'по правам' }),
]}
/>
<Tabs defaultValue="keys" className="min-w-0">
<PageTabsList>
<PageTab value="keys" label="Passkeys" count={rows.length} />
<PageTab value="acc" label="Учётная запись" />
</PageTabsList>
<TabsContent value="keys" className="flex min-w-0 flex-col gap-4">
{isLoading ? (
<Skeleton className="h-64 w-full rounded-xl" />
) : isError ? (
<div role="alert" className="border-destructive/40 bg-destructive/10 flex flex-wrap items-center justify-between gap-3 rounded-xl border px-4 py-3 text-sm">
<span>Не удалось загрузить passkeys. Повторите запрос.</span>
<Button variant="outline" onClick={() => void refetch()}>
Повторить
</Button>
</div>
) : (
<SectionGrid
title="Мои passkeys"
description="Вход без пароля: отпечаток, Face ID, PIN или ключ безопасности · удаление необратимо"
rows={rows}
columns={columns}
getRowId={(r) => r.id}
searchText={(r) => `${r.name} ${kindOf(r)}`}
selectable={false}
views={[
{ label: 'Все', match: () => true },
{ label: 'Давно не используются', match: (r) => r.tone === 'wn' },
]}
rowMenu={(r) => ({ onOpen: () => open(r), onDelete: () => setDeleting(r) })}
onRowClick={open}
emptyMessage="Ключи не зарегистрированы. Добавьте passkey, чтобы входить без пароля."
/>
)}
</TabsContent>
<TabsContent value="acc">
<KvFrame
title={me?.email ?? '—'}
description="Данные учётной записи"
items={[
{ label: 'Имя', value: me?.name ?? '—' },
{ label: 'Роль', value: me?.is_admin ? 'Администратор' : 'Пользователь' },
{ label: 'Приложений', value: String(me?.apps.length ?? 0) },
{ label: 'Двухфакторная защита', value: rows.length ? `Passkey ×${rows.length}` : 'Не настроена' },
]}
/>
</TabsContent>
</Tabs>
<PortalSheet
open={addOpen}
onOpenChange={setAddOpen}
title="Добавить passkey"
description="Браузер предложит создать ключ для этого портала"
footer={
<>
<Button variant="outline" onClick={() => setAddOpen(false)}>
Отмена
</Button>
<Button disabled={registerMutation.isPending} onClick={() => registerMutation.mutate(newName)}>
{registerMutation.isPending ? 'Ожидание…' : 'Создать ключ'}
</Button>
</>
}
>
<Field>
<FieldLabel htmlFor="pk-name">Название ключа</FieldLabel>
<Input id="pk-name" value={newName} placeholder="Рабочий ноутбук" onChange={(e) => setNewName(e.target.value)} />
<FieldDescription>Чтобы отличать устройства в списке</FieldDescription>
</Field>
<SignalAlert tone="in" title="Что произойдёт дальше">
Откроется системный диалог: подтвердите отпечатком, Face ID, PIN или ключом безопасности. Закрытый ключ не покидает устройство.
</SignalAlert>
</PortalSheet>
<PortalSheet
open={selected !== null}
onOpenChange={(o) => !o && setSelected(null)}
title="Passkey"
description="Название и сведения о ключе"
footer={
<>
<Button
variant="outline"
className="mr-auto"
onClick={() => {
setDeleting(selected)
setSelected(null)
}}
>
<TrashIcon aria-hidden />
Удалить
</Button>
<Button variant="outline" onClick={() => setSelected(null)}>
Закрыть
</Button>
<Button
disabled={renameMutation.isPending || !renameValue.trim() || renameValue.trim() === selected?.name}
onClick={() => selected && renameMutation.mutate({ id: selected.id, name: renameValue.trim() })}
>
Сохранить
</Button>
</>
}
>
<Field>
<FieldLabel htmlFor="pk-rename">Название</FieldLabel>
<Input id="pk-rename" value={renameValue} onChange={(e) => setRenameValue(e.target.value)} />
</Field>
<KvList
items={[
['Тип', selected ? kindOf(selected) : ''],
['Добавлен', fmtDate(selected?.created_at)],
['Последний вход', selected?.last_used_at ? fmtWhen(selected.last_used_at) : 'ещё не использовался'],
]}
/>
</PortalSheet>
<TypedConfirm
open={deleting !== null}
onOpenChange={(o) => !o && setDeleting(null)}
title="Удалить passkey?"
description={`Ключ «${deleting?.name ?? ''}» будет удалён без возможности восстановления. Войти по нему больше не получится.`}
expected="удалить"
confirmLabel="Удалить ключ"
onConfirm={() => deleting && deleteMutation.mutate(deleting.id)}
/>
</>
)
}