feat(auth): добавить вход по passkey
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
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 <[email protected]>
This commit is contained in:
@@ -19,6 +19,7 @@
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@hookform/resolvers": "^5.4.0",
|
||||
"@simplewebauthn/browser": "^13.3.0",
|
||||
"@tailwindcss/vite": "^4.3.1",
|
||||
"@tanstack/react-query": "^5.101.0",
|
||||
"@tanstack/react-router": "^1.170.15",
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Link, useRouterState } from '@tanstack/react-router'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import {
|
||||
AppWindowIcon,
|
||||
FingerprintIcon,
|
||||
HistoryIcon,
|
||||
KeyRoundIcon,
|
||||
LayoutGridIcon,
|
||||
@@ -53,6 +54,16 @@ export function AppSidebar() {
|
||||
<span>Приложения</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
tooltip="Безопасность"
|
||||
isActive={isActive(pathname, '/account', true)}
|
||||
render={<Link to="/account" />}
|
||||
>
|
||||
<FingerprintIcon className="size-4" />
|
||||
<span>Безопасность</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
ChevronsUpDownIcon,
|
||||
FingerprintIcon,
|
||||
LogOutIcon,
|
||||
MonitorIcon,
|
||||
MoonIcon,
|
||||
@@ -8,6 +9,7 @@ import {
|
||||
SunIcon,
|
||||
} from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
import { useTheme } from 'next-themes'
|
||||
|
||||
import { Avatar, AvatarFallback } from '@authportal/ui/components/avatar'
|
||||
@@ -109,6 +111,7 @@ export function NavUser() {
|
||||
const { isMobile } = useSidebar()
|
||||
const { data: me } = useQuery(meQueryOptions)
|
||||
const queryClient = useQueryClient()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const name = me?.name?.trim() || 'Пользователь'
|
||||
const email = me?.email?.trim() || ''
|
||||
@@ -175,6 +178,12 @@ export function NavUser() {
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem
|
||||
onClick={() => void navigate({ to: '/account' })}
|
||||
>
|
||||
<FingerprintIcon aria-hidden />
|
||||
Безопасность
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="cursor-default focus:bg-transparent">
|
||||
<PaletteIcon aria-hidden />
|
||||
Тема
|
||||
|
||||
@@ -1,8 +1,20 @@
|
||||
import { useState, type FormEvent } from 'react'
|
||||
import { useEffect, useState, type FormEvent } from 'react'
|
||||
import { useNavigate, useSearch } from '@tanstack/react-router'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { EyeIcon, EyeOffIcon } from 'lucide-react'
|
||||
import { buildSsoRedirectUrl, isPortalOidcAuthorizeUrl, isReturnToAllowed } from '@authportal/shared'
|
||||
import { EyeIcon, EyeOffIcon, FingerprintIcon } from 'lucide-react'
|
||||
import {
|
||||
browserSupportsWebAuthn,
|
||||
browserSupportsWebAuthnAutofill,
|
||||
startAuthentication,
|
||||
WebAuthnAbortService,
|
||||
} from '@simplewebauthn/browser'
|
||||
import type { PublicKeyCredentialRequestOptionsJSON } from '@simplewebauthn/browser'
|
||||
import {
|
||||
buildSsoRedirectUrl,
|
||||
isPortalOidcAuthorizeUrl,
|
||||
isReturnToAllowed,
|
||||
type LoginResponse,
|
||||
} from '@authportal/shared'
|
||||
import { Button } from '@authportal/ui/components/button'
|
||||
import { Field, FieldGroup, FieldLabel } from '@authportal/ui/components/field'
|
||||
import { Input } from '@authportal/ui/components/input'
|
||||
@@ -12,6 +24,7 @@ import {
|
||||
InputGroupButton,
|
||||
InputGroupInput,
|
||||
} from '@authportal/ui/components/input-group'
|
||||
import { Separator } from '@authportal/ui/components/separator'
|
||||
import {
|
||||
Alert,
|
||||
AlertDescription,
|
||||
@@ -20,6 +33,7 @@ import {
|
||||
import { ensureAuthConfig, setToken } from '@/lib/auth'
|
||||
import { ApiError } from '@/lib/api-client'
|
||||
import { login, meQueryKey } from '@/queries/auth'
|
||||
import { webauthnLogin, webauthnLoginOptions } from '@/queries/webauthn'
|
||||
import { AuthLogo } from '@/components/blocks/auth-18/components/auth-logo'
|
||||
|
||||
export function PortalLoginForm() {
|
||||
@@ -29,42 +43,110 @@ export function PortalLoginForm() {
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [pending, setPending] = useState(false)
|
||||
const [passkeySupported, setPasskeySupported] = useState(false)
|
||||
|
||||
async function applySession(res: LoginResponse) {
|
||||
setToken(res.access_token)
|
||||
queryClient.setQueryData(meQueryKey, res.user)
|
||||
|
||||
const returnTo = search.return_to
|
||||
const { returnToAllowlist: allowlist, issuer } = await ensureAuthConfig()
|
||||
if (returnTo && isReturnToAllowed(returnTo, allowlist)) {
|
||||
if (isPortalOidcAuthorizeUrl(returnTo, issuer)) {
|
||||
window.location.href = returnTo
|
||||
return
|
||||
}
|
||||
window.location.href = buildSsoRedirectUrl(
|
||||
returnTo,
|
||||
res.access_token,
|
||||
res.expires_at,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (res.user.is_admin) {
|
||||
await navigate({ to: '/admin' })
|
||||
} else {
|
||||
await navigate({ to: '/apps' })
|
||||
}
|
||||
}
|
||||
|
||||
async function runPasskeyLogin() {
|
||||
const { challenge_id, options } = await webauthnLoginOptions()
|
||||
const assertion = await startAuthentication({
|
||||
optionsJSON: options as unknown as PublicKeyCredentialRequestOptionsJSON,
|
||||
})
|
||||
const res = await webauthnLogin(challenge_id, assertion, search.return_to)
|
||||
await applySession(res)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!browserSupportsWebAuthn()) return
|
||||
setPasskeySupported(true)
|
||||
let cancelled = false
|
||||
|
||||
async function startConditional() {
|
||||
if (!(await browserSupportsWebAuthnAutofill())) return
|
||||
try {
|
||||
const { challenge_id, options } = await webauthnLoginOptions()
|
||||
if (cancelled) return
|
||||
const assertion = await startAuthentication({
|
||||
optionsJSON:
|
||||
options as unknown as PublicKeyCredentialRequestOptionsJSON,
|
||||
useBrowserAutofill: true,
|
||||
})
|
||||
if (cancelled) return
|
||||
setPending(true)
|
||||
setError(null)
|
||||
const res = await webauthnLogin(
|
||||
challenge_id,
|
||||
assertion,
|
||||
search.return_to,
|
||||
)
|
||||
await applySession(res)
|
||||
} catch {
|
||||
/* abort / unsupported / user dismissed */
|
||||
} finally {
|
||||
if (!cancelled) setPending(false)
|
||||
}
|
||||
}
|
||||
|
||||
void startConditional()
|
||||
return () => {
|
||||
cancelled = true
|
||||
WebAuthnAbortService.cancelCeremony()
|
||||
}
|
||||
// Login page mount only — return_to is stable for the visit.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault()
|
||||
WebAuthnAbortService.cancelCeremony()
|
||||
setError(null)
|
||||
setPending(true)
|
||||
const form = new FormData(event.currentTarget)
|
||||
const email = String(form.get('email') ?? '')
|
||||
const password = String(form.get('password') ?? '')
|
||||
const returnTo = search.return_to
|
||||
try {
|
||||
const res = await login(email, password, returnTo)
|
||||
setToken(res.access_token)
|
||||
queryClient.setQueryData(meQueryKey, res.user)
|
||||
const res = await login(email, password, search.return_to)
|
||||
await applySession(res)
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : 'Не удалось войти')
|
||||
} finally {
|
||||
setPending(false)
|
||||
}
|
||||
}
|
||||
|
||||
const { returnToAllowlist: allowlist, issuer } = await ensureAuthConfig()
|
||||
if (returnTo && isReturnToAllowed(returnTo, allowlist)) {
|
||||
if (isPortalOidcAuthorizeUrl(returnTo, issuer)) {
|
||||
window.location.href = returnTo
|
||||
return
|
||||
}
|
||||
window.location.href = buildSsoRedirectUrl(
|
||||
returnTo,
|
||||
res.access_token,
|
||||
res.expires_at,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (res.user.is_admin) {
|
||||
await navigate({ to: '/admin' })
|
||||
} else {
|
||||
await navigate({ to: '/apps' })
|
||||
}
|
||||
async function handlePasskeyClick() {
|
||||
WebAuthnAbortService.cancelCeremony()
|
||||
setError(null)
|
||||
setPending(true)
|
||||
try {
|
||||
await runPasskeyLogin()
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof ApiError ? err.message : 'Не удалось войти',
|
||||
err instanceof ApiError ? err.message : 'Не удалось войти с passkey',
|
||||
)
|
||||
} finally {
|
||||
setPending(false)
|
||||
@@ -101,7 +183,7 @@ export function PortalLoginForm() {
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
autoComplete="username"
|
||||
autoComplete="username webauthn"
|
||||
placeholder="[email protected]"
|
||||
className="bg-background"
|
||||
required
|
||||
@@ -137,6 +219,26 @@ export function PortalLoginForm() {
|
||||
{pending ? 'Вход…' : 'Войти'}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{passkeySupported ? (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<Separator className="flex-1" />
|
||||
<span className="text-muted-foreground text-xs">или</span>
|
||||
<Separator className="flex-1" />
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
disabled={pending}
|
||||
onClick={() => void handlePasskeyClick()}
|
||||
>
|
||||
<FingerprintIcon aria-hidden="true" />
|
||||
Войти с passkey
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
CircleDotIcon,
|
||||
FilterIcon,
|
||||
FunnelXIcon,
|
||||
FingerprintIcon,
|
||||
LockIcon,
|
||||
MailIcon,
|
||||
MoreHorizontalIcon,
|
||||
@@ -115,6 +116,7 @@ export interface AdminUsersGridProps {
|
||||
onOpenAccess: (user: AdminUser) => void
|
||||
onDeactivate: (user: AdminUser) => void
|
||||
onDelete: (user: AdminUser) => void
|
||||
onResetPasskeys: (user: AdminUser) => void
|
||||
onBulkSetRole: (userIds: string[], isAdmin: boolean) => void
|
||||
onBulkDeactivate: (userIds: string[]) => void
|
||||
}
|
||||
@@ -403,14 +405,17 @@ function ActionsCell({
|
||||
onOpenAccess,
|
||||
onDeactivate,
|
||||
onDelete,
|
||||
onResetPasskeys,
|
||||
}: {
|
||||
row: Row<AdminUser>
|
||||
onOpenAudit: (user: AdminUser) => void
|
||||
onOpenAccess: (user: AdminUser) => void
|
||||
onDeactivate: (user: AdminUser) => void
|
||||
onDelete: (user: AdminUser) => void
|
||||
onResetPasskeys: (user: AdminUser) => void
|
||||
}) {
|
||||
const [deleteOpen, setDeleteOpen] = useState(false)
|
||||
const [resetOpen, setResetOpen] = useState(false)
|
||||
const user = row.original
|
||||
|
||||
return (
|
||||
@@ -444,6 +449,12 @@ function ActionsCell({
|
||||
Отключить
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{(user.passkey_count ?? 0) > 0 ? (
|
||||
<DropdownMenuItem onClick={() => setResetOpen(true)}>
|
||||
<FingerprintIcon className="size-4" aria-hidden="true" />
|
||||
Сбросить passkeys
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
@@ -456,6 +467,31 @@ function ActionsCell({
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<AlertDialog open={resetOpen} onOpenChange={setResetOpen}>
|
||||
<AlertDialogContent size="sm">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Сбросить passkeys?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Все ключи пользователя{' '}
|
||||
<span className="text-foreground font-medium">{user.email}</span>{' '}
|
||||
будут удалены. Вход останется по паролю.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Отмена</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
variant="destructive"
|
||||
onClick={() => {
|
||||
setResetOpen(false)
|
||||
onResetPasskeys(user)
|
||||
}}
|
||||
>
|
||||
Сбросить
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
|
||||
<AlertDialogContent size="sm">
|
||||
<AlertDialogHeader>
|
||||
@@ -489,6 +525,7 @@ function createAdminUserColumns(handlers: {
|
||||
onOpenAccess: (user: AdminUser) => void
|
||||
onDeactivate: (user: AdminUser) => void
|
||||
onDelete: (user: AdminUser) => void
|
||||
onResetPasskeys: (user: AdminUser) => void
|
||||
}): ColumnDef<AdminUser>[] {
|
||||
return [
|
||||
{
|
||||
@@ -614,15 +651,22 @@ function createAdminUserColumns(handlers: {
|
||||
},
|
||||
{
|
||||
id: 'twoFactor',
|
||||
accessorFn: (row) => (row.passkey_count ?? 0) > 0,
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader title="2FA" visibility column={column} />
|
||||
),
|
||||
cell: () => (
|
||||
<Badge variant="destructive-outline">
|
||||
<TriangleAlertIcon aria-hidden="true" />
|
||||
Выкл
|
||||
</Badge>
|
||||
),
|
||||
cell: ({ row }) =>
|
||||
(row.original.passkey_count ?? 0) > 0 ? (
|
||||
<Badge variant="success-outline">
|
||||
<FingerprintIcon aria-hidden="true" />
|
||||
Passkey
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="destructive-outline">
|
||||
<TriangleAlertIcon aria-hidden="true" />
|
||||
Выкл
|
||||
</Badge>
|
||||
),
|
||||
size: 100,
|
||||
enableSorting: false,
|
||||
enableHiding: true,
|
||||
@@ -686,6 +730,7 @@ function createAdminUserColumns(handlers: {
|
||||
onOpenAccess={handlers.onOpenAccess}
|
||||
onDeactivate={handlers.onDeactivate}
|
||||
onDelete={handlers.onDelete}
|
||||
onResetPasskeys={handlers.onResetPasskeys}
|
||||
/>
|
||||
),
|
||||
size: 60,
|
||||
@@ -710,6 +755,7 @@ export function AdminUsersGrid({
|
||||
onOpenAccess,
|
||||
onDeactivate,
|
||||
onDelete,
|
||||
onResetPasskeys,
|
||||
onBulkSetRole,
|
||||
onBulkDeactivate,
|
||||
}: AdminUsersGridProps) {
|
||||
@@ -848,8 +894,9 @@ export function AdminUsersGrid({
|
||||
onOpenAccess,
|
||||
onDeactivate,
|
||||
onDelete,
|
||||
onResetPasskeys,
|
||||
}),
|
||||
[onOpenAudit, onOpenAccess, onDeactivate, onDelete],
|
||||
[onOpenAudit, onOpenAccess, onDeactivate, onDelete, onResetPasskeys],
|
||||
)
|
||||
|
||||
const [columnOrder, setColumnOrder] = useState<string[]>(
|
||||
|
||||
@@ -16,3 +16,4 @@ export { UserAuditSheet } from './user-audit-sheet'
|
||||
export { CreateUserSheet } from './create-user-sheet'
|
||||
export { CreateOidcClientSheet } from './create-oidc-client-sheet'
|
||||
export { AdminUsersGrid, type AdminUsersGridProps } from './admin-users-grid'
|
||||
export { PasskeySettingsPanel } from './passkey-settings-panel'
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
/**
|
||||
* 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { queryOptions } from '@tanstack/react-query'
|
||||
import type {
|
||||
LoginResponse,
|
||||
PasskeyCredential,
|
||||
WebauthnOptionsResponse,
|
||||
} from '@authportal/shared'
|
||||
import { api } from '@/lib/api-client'
|
||||
|
||||
export const passkeysQueryKey = ['webauthn', 'credentials'] as const
|
||||
|
||||
export const passkeysQueryOptions = queryOptions({
|
||||
queryKey: passkeysQueryKey,
|
||||
queryFn: () => api.get<PasskeyCredential[]>('/api/v1/webauthn/credentials'),
|
||||
})
|
||||
|
||||
export function webauthnLoginOptions() {
|
||||
return api.post<WebauthnOptionsResponse>('/api/v1/webauthn/login/options')
|
||||
}
|
||||
|
||||
export function webauthnLogin(
|
||||
challengeId: string,
|
||||
response: unknown,
|
||||
returnTo?: string,
|
||||
) {
|
||||
return api.post<LoginResponse>('/api/v1/webauthn/login', {
|
||||
challenge_id: challengeId,
|
||||
response,
|
||||
...(returnTo ? { return_to: returnTo } : {}),
|
||||
})
|
||||
}
|
||||
|
||||
export function webauthnRegisterOptions() {
|
||||
return api.post<WebauthnOptionsResponse>('/api/v1/webauthn/register/options')
|
||||
}
|
||||
|
||||
export function webauthnRegister(
|
||||
challengeId: string,
|
||||
response: unknown,
|
||||
name?: string,
|
||||
) {
|
||||
return api.post<PasskeyCredential>('/api/v1/webauthn/register', {
|
||||
challenge_id: challengeId,
|
||||
response,
|
||||
...(name ? { name } : {}),
|
||||
})
|
||||
}
|
||||
|
||||
export function renamePasskey(id: string, name: string) {
|
||||
return api.patch<PasskeyCredential>(`/api/v1/webauthn/credentials/${id}`, {
|
||||
name,
|
||||
})
|
||||
}
|
||||
|
||||
export function deletePasskey(id: string) {
|
||||
return api.delete<{ ok: boolean }>(`/api/v1/webauthn/credentials/${id}`)
|
||||
}
|
||||
|
||||
export function adminResetPasskeys(userId: string) {
|
||||
return api.delete<{
|
||||
ok: boolean
|
||||
removed: number
|
||||
}>(`/api/v1/admin/users/${userId}/passkeys`)
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import { Route as rootRouteImport } from './routes/__root'
|
||||
import { Route as IndexRouteImport } from './routes/index'
|
||||
import { Route as AuthRouteImport } from './routes/_auth'
|
||||
import { Route as LogoutRouteImport } from './routes/logout'
|
||||
import { Route as AuthAccountRouteImport } from './routes/_auth.account'
|
||||
import { Route as AuthAdminRouteImport } from './routes/_auth.admin'
|
||||
import { Route as AuthAppsRouteImport } from './routes/_auth.apps'
|
||||
import { Route as AuthAdminIndexRouteImport } from './routes/_auth.admin.index'
|
||||
@@ -35,6 +36,11 @@ const LogoutRoute = LogoutRouteImport.update({
|
||||
path: '/logout',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const AuthAccountRoute = AuthAccountRouteImport.update({
|
||||
id: '/account',
|
||||
path: '/account',
|
||||
getParentRoute: () => AuthRoute,
|
||||
} as any)
|
||||
const AuthAdminRoute = AuthAdminRouteImport.update({
|
||||
id: '/admin',
|
||||
path: '/admin',
|
||||
@@ -79,6 +85,7 @@ const AuthAdminUsersUserIdRoute = AuthAdminUsersUserIdRouteImport.update({
|
||||
export interface FileRoutesByFullPath {
|
||||
'/': typeof IndexRoute
|
||||
'/logout': typeof LogoutRoute
|
||||
'/account': typeof AuthAccountRoute
|
||||
'/admin': typeof AuthAdminRouteWithChildren
|
||||
'/apps': typeof AuthAppsRoute
|
||||
'/admin/apps': typeof AuthAdminAppsRoute
|
||||
@@ -91,6 +98,7 @@ export interface FileRoutesByFullPath {
|
||||
export interface FileRoutesByTo {
|
||||
'/': typeof IndexRoute
|
||||
'/logout': typeof LogoutRoute
|
||||
'/account': typeof AuthAccountRoute
|
||||
'/apps': typeof AuthAppsRoute
|
||||
'/admin/apps': typeof AuthAdminAppsRoute
|
||||
'/admin/audit': typeof AuthAdminAuditRoute
|
||||
@@ -104,6 +112,7 @@ export interface FileRoutesById {
|
||||
'/': typeof IndexRoute
|
||||
'/_auth': typeof AuthRouteWithChildren
|
||||
'/logout': typeof LogoutRoute
|
||||
'/_auth/account': typeof AuthAccountRoute
|
||||
'/_auth/admin': typeof AuthAdminRouteWithChildren
|
||||
'/_auth/apps': typeof AuthAppsRoute
|
||||
'/_auth/admin/apps': typeof AuthAdminAppsRoute
|
||||
@@ -118,6 +127,7 @@ export interface FileRouteTypes {
|
||||
fullPaths:
|
||||
| '/'
|
||||
| '/logout'
|
||||
| '/account'
|
||||
| '/admin'
|
||||
| '/apps'
|
||||
| '/admin/apps'
|
||||
@@ -130,6 +140,7 @@ export interface FileRouteTypes {
|
||||
to:
|
||||
| '/'
|
||||
| '/logout'
|
||||
| '/account'
|
||||
| '/apps'
|
||||
| '/admin/apps'
|
||||
| '/admin/audit'
|
||||
@@ -142,6 +153,7 @@ export interface FileRouteTypes {
|
||||
| '/'
|
||||
| '/_auth'
|
||||
| '/logout'
|
||||
| '/_auth/account'
|
||||
| '/_auth/admin'
|
||||
| '/_auth/apps'
|
||||
| '/_auth/admin/apps'
|
||||
@@ -181,6 +193,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof LogoutRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/_auth/account': {
|
||||
id: '/_auth/account'
|
||||
path: '/account'
|
||||
fullPath: '/account'
|
||||
preLoaderRoute: typeof AuthAccountRouteImport
|
||||
parentRoute: typeof AuthRoute
|
||||
}
|
||||
'/_auth/admin': {
|
||||
id: '/_auth/admin'
|
||||
path: '/admin'
|
||||
@@ -263,11 +282,13 @@ const AuthAdminRouteWithChildren = AuthAdminRoute._addFileChildren(
|
||||
)
|
||||
|
||||
interface AuthRouteChildren {
|
||||
AuthAccountRoute: typeof AuthAccountRoute
|
||||
AuthAdminRoute: typeof AuthAdminRouteWithChildren
|
||||
AuthAppsRoute: typeof AuthAppsRoute
|
||||
}
|
||||
|
||||
const AuthRouteChildren: AuthRouteChildren = {
|
||||
AuthAccountRoute: AuthAccountRoute,
|
||||
AuthAdminRoute: AuthAdminRouteWithChildren,
|
||||
AuthAppsRoute: AuthAppsRoute,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Account security — passkeys.
|
||||
* Preview: https://reui.io/preview/base/settings-10 · https://reui.io/preview/base/settings-16
|
||||
*/
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { PageShell } from '@/components/page-shell'
|
||||
import { PasskeySettingsPanel } from '@/components/reui-kit/passkey-settings-panel'
|
||||
|
||||
export const Route = createFileRoute('/_auth/account')({
|
||||
component: AccountPage,
|
||||
})
|
||||
|
||||
function AccountPage() {
|
||||
return (
|
||||
<PageShell>
|
||||
<div className="flex flex-col gap-1">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Безопасность</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Passkey как альтернатива паролю. Пароль остаётся запасным входом.
|
||||
</p>
|
||||
</div>
|
||||
<PasskeySettingsPanel />
|
||||
</PageShell>
|
||||
)
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import { UserAccessSheet } from '@/components/reui-kit/user-access-sheet'
|
||||
import { UserAuditSheet } from '@/components/reui-kit/user-audit-sheet'
|
||||
import { api, ApiError } from '@/lib/api-client'
|
||||
import { usersQueryKey, usersQueryOptions } from '@/queries/auth'
|
||||
import { adminResetPasskeys } from '@/queries/webauthn'
|
||||
import { Button } from '@authportal/ui/components/button'
|
||||
|
||||
export const Route = createFileRoute('/_auth/admin/')({
|
||||
@@ -104,6 +105,29 @@ function AdminUsersPage() {
|
||||
[deleteMutation],
|
||||
)
|
||||
|
||||
const resetPasskeysMutation = useMutation({
|
||||
mutationFn: (id: string) => adminResetPasskeys(id),
|
||||
onSuccess: async (_data, id) => {
|
||||
await invalidateUsers()
|
||||
const user = users.find((u) => u.id === id)
|
||||
toast.success('Passkeys сброшены', {
|
||||
description: user?.email,
|
||||
})
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(
|
||||
err instanceof ApiError ? err.message : 'Не удалось сбросить passkeys',
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
const handleResetPasskeys = useCallback(
|
||||
(user: AdminUser) => {
|
||||
resetPasskeysMutation.mutate(user.id)
|
||||
},
|
||||
[resetPasskeysMutation],
|
||||
)
|
||||
|
||||
const handleBulkSetRole = useCallback(
|
||||
(userIds: string[], isAdmin: boolean) => {
|
||||
Promise.all(
|
||||
@@ -170,6 +194,7 @@ function AdminUsersPage() {
|
||||
onOpenAccess={(user) => setAccessUserId(user.id)}
|
||||
onDeactivate={handleDeactivate}
|
||||
onDelete={handleDelete}
|
||||
onResetPasskeys={handleResetPasskeys}
|
||||
onBulkSetRole={handleBulkSetRole}
|
||||
onBulkDeactivate={handleBulkDeactivate}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user