Files
auth-portal/apps/web/src/components/reui-kit/passkey-settings-panel.tsx
T
DenozordecandCursor 46dc2f714c
quality / commitlint (push) Skipped
quality / changes (push) Successful in 8s
quality / docker-check (push) Skipped
quality / web (push) Successful in 1m11s
quality / api (push) Successful in 47s
CD / quality (push) Successful in 2m10s
CD / publish (push) Successful in 1m50s
feat(auth): добавить вход по passkey
Альтернатива паролю на портале; SSO приложений без изменений.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-19 10:24:46 +07:00

214 lines
7.2 KiB
TypeScript

/**
* Passkey management — DNA settings-16 / settings-2 / settings-10.
* Preview: https://reui.io/preview/base/settings-16 · https://reui.io/preview/base/settings-2 · https://reui.io/preview/base/settings-10
* Docs: https://reui.io/docs/components/base/frame
*/
import { useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { FingerprintIcon, PlusIcon, Trash2Icon } from 'lucide-react'
import {
startRegistration,
browserSupportsWebAuthn,
} from '@simplewebauthn/browser'
import type { PublicKeyCredentialCreationOptionsJSON } from '@simplewebauthn/browser'
import type { PasskeyCredential } from '@authportal/shared'
import {
Frame,
FrameDescription,
FrameHeader,
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
import { Badge } from '@/components/reui/badge'
import { Button } from '@authportal/ui/components/button'
import {
Item,
ItemActions,
ItemContent,
ItemDescription,
ItemMedia,
ItemTitle,
} from '@authportal/ui/components/item'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@authportal/ui/components/alert-dialog'
import { Skeleton } from '@authportal/ui/components/skeleton'
import { toast } from 'sonner'
import { ApiError } from '@/lib/api-client'
import {
deletePasskey,
passkeysQueryKey,
passkeysQueryOptions,
webauthnRegister,
webauthnRegisterOptions,
} from '@/queries/webauthn'
function formatWhen(iso: string | null) {
if (!iso) return 'ещё не использовался'
const date = new Date(iso)
if (Number.isNaN(date.getTime())) return '—'
return new Intl.DateTimeFormat('ru-RU', {
day: 'numeric',
month: 'short',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
}).format(date)
}
export function PasskeySettingsPanel() {
const queryClient = useQueryClient()
const { data: passkeys = [], isLoading } = useQuery(passkeysQueryOptions)
const [pendingDelete, setPendingDelete] = useState<PasskeyCredential | null>(
null,
)
const supported = browserSupportsWebAuthn()
const registerMutation = useMutation({
mutationFn: async () => {
const { challenge_id, options } = await webauthnRegisterOptions()
const attResp = await startRegistration({
optionsJSON:
options as unknown as PublicKeyCredentialCreationOptionsJSON,
})
return webauthnRegister(challenge_id, attResp)
},
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: passkeysQueryKey })
toast.success('Passkey добавлен')
},
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 })
toast.success('Passkey удалён')
setPendingDelete(null)
},
onError: (err) => {
toast.error(
err instanceof ApiError ? err.message : 'Не удалось удалить passkey',
)
},
})
return (
<>
<Frame className="w-full">
<FrameHeader className="flex-row items-start justify-between gap-3">
<div className="flex flex-col gap-1">
<FrameTitle>Passkeys</FrameTitle>
<FrameDescription>
Вход без пароля: Windows Hello, Face ID, ключ безопасности
</FrameDescription>
</div>
<Button
type="button"
size="sm"
disabled={!supported || registerMutation.isPending}
onClick={() => registerMutation.mutate()}
>
<PlusIcon aria-hidden="true" />
{registerMutation.isPending ? 'Ожидание…' : 'Добавить passkey'}
</Button>
</FrameHeader>
<FramePanel className="flex flex-col gap-2">
{!supported ? (
<p className="text-muted-foreground text-sm">
Этот браузер не поддерживает WebAuthn.
</p>
) : null}
{isLoading ? (
<div className="flex flex-col gap-2">
<Skeleton className="h-16 w-full rounded-xl" />
<Skeleton className="h-16 w-full rounded-xl" />
</div>
) : passkeys.length === 0 ? (
<p className="text-muted-foreground text-sm">
Ключи не зарегистрированы. Добавьте passkey, чтобы входить без
пароля.
</p>
) : (
passkeys.map((item) => (
<Item key={item.id} variant="outline" className="items-start">
<ItemMedia variant="icon">
<FingerprintIcon className="size-4" aria-hidden="true" />
</ItemMedia>
<ItemContent>
<ItemTitle className="flex flex-wrap items-center gap-2">
{item.name}
{item.device_type === 'multiDevice' ? (
<Badge variant="info-outline" size="sm">
Синхронизируется
</Badge>
) : null}
</ItemTitle>
<ItemDescription>
Добавлен {formatWhen(item.created_at)} · вход{' '}
{formatWhen(item.last_used_at)}
</ItemDescription>
</ItemContent>
<ItemActions>
<Button
type="button"
size="sm"
variant="ghost"
aria-label={`Удалить ${item.name}`}
onClick={() => setPendingDelete(item)}
>
<Trash2Icon aria-hidden="true" />
Удалить
</Button>
</ItemActions>
</Item>
))
)}
</FramePanel>
</Frame>
<AlertDialog
open={Boolean(pendingDelete)}
onOpenChange={(open) => {
if (!open) setPendingDelete(null)
}}
>
<AlertDialogContent size="sm">
<AlertDialogHeader>
<AlertDialogTitle>Удалить passkey?</AlertDialogTitle>
<AlertDialogDescription>
{pendingDelete
? `«${pendingDelete.name}» больше нельзя будет использовать для входа.`
: null}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Отмена</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
disabled={deleteMutation.isPending}
onClick={() => {
if (pendingDelete) deleteMutation.mutate(pendingDelete.id)
}}
>
Удалить
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)
}