281 lines
14 KiB
TypeScript
281 lines
14 KiB
TypeScript
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)}
|
||
/>
|
||
</>
|
||
)
|
||
}
|