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
Альтернатива паролю на портале; SSO приложений без изменений. Co-authored-by: Cursor <cursoragent@cursor.com>
214 lines
7.2 KiB
TypeScript
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>
|
|
</>
|
|
)
|
|
}
|