feat(admin): ingest аудита из apps и users 1:1 с Sheet журнала
Добавлен POST /api/v1/ingest/audit, фильтры source_app/user_id, last_login_at; таблица пользователей по solution-users-1 с журналом в Sheet. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
||||
import type { AuditLogEntry, AuditSeverity } from '@authportal/shared'
|
||||
import type { AuditLogEntry, AuditSeverity, AuditSourceApp } from '@authportal/shared'
|
||||
import {
|
||||
KeyRoundIcon,
|
||||
LogInIcon,
|
||||
@@ -32,6 +32,18 @@ export const RANGE_OPTIONS = [
|
||||
|
||||
export type AuditRange = (typeof RANGE_OPTIONS)[number]['value']
|
||||
|
||||
export const SOURCE_APP_OPTIONS: {
|
||||
value: AuditSourceApp | 'all'
|
||||
label: string
|
||||
}[] = [
|
||||
{ value: 'all', label: 'Все приложения' },
|
||||
{ value: 'portal', label: 'Auth Portal' },
|
||||
{ value: 'vps', label: 'VPS Tracker' },
|
||||
{ value: 'cfdm', label: 'CFDM' },
|
||||
{ value: 'bgp', label: 'EvoBGP' },
|
||||
{ value: 'fw', label: 'EvoFirewall' },
|
||||
]
|
||||
|
||||
export const severityVariant: Record<AuditSeverity, BadgeProps['variant']> = {
|
||||
info: 'success-outline',
|
||||
warning: 'warning-outline',
|
||||
@@ -97,6 +109,14 @@ export function matchesFilter(entry: AuditLogEntry, filter: AuditFilterId) {
|
||||
return actionMeta(entry.action).filter === filter
|
||||
}
|
||||
|
||||
export function matchesSourceApp(
|
||||
entry: AuditLogEntry,
|
||||
source: AuditSourceApp | 'all',
|
||||
) {
|
||||
if (source === 'all') return true
|
||||
return entry.source_app === source
|
||||
}
|
||||
|
||||
export function matchesRange(entry: AuditLogEntry, range: AuditRange) {
|
||||
if (range === 'all') return true
|
||||
const ms =
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* Docs: https://reui.io/blocks · Timeline: https://reui.io/docs/components/base/timeline
|
||||
*/
|
||||
import { useMemo, useState } from 'react'
|
||||
import type { AuditLogEntry } from '@authportal/shared'
|
||||
import type { AuditLogEntry, AuditSourceApp } from '@authportal/shared'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
CalendarIcon,
|
||||
@@ -56,6 +56,7 @@ import { Tabs, TabsList, TabsTrigger } from '@authportal/ui/components/tabs'
|
||||
import {
|
||||
AUDIT_FILTER_OPTIONS,
|
||||
RANGE_OPTIONS,
|
||||
SOURCE_APP_OPTIONS,
|
||||
actionMeta,
|
||||
exportAuditCsv,
|
||||
formatEventTime,
|
||||
@@ -63,6 +64,7 @@ import {
|
||||
initials,
|
||||
matchesFilter,
|
||||
matchesRange,
|
||||
matchesSourceApp,
|
||||
severityDotClass,
|
||||
severityLabel,
|
||||
severityVariant,
|
||||
@@ -227,81 +229,128 @@ function EventRow({
|
||||
export function AuditLogTimeline({
|
||||
entries,
|
||||
totalCount,
|
||||
compact = false,
|
||||
lockedUserId,
|
||||
sourceAppFilter,
|
||||
onSourceAppFilterChange,
|
||||
}: {
|
||||
entries: AuditLogEntry[]
|
||||
totalCount: number
|
||||
compact?: boolean
|
||||
lockedUserId?: string
|
||||
sourceAppFilter?: AuditSourceApp | 'all'
|
||||
onSourceAppFilterChange?: (v: AuditSourceApp | 'all') => void
|
||||
}) {
|
||||
const [filter, setFilter] = useState<AuditFilterId>('all')
|
||||
const [range, setRange] = useState<AuditRange>('7d')
|
||||
const [range, setRange] = useState<AuditRange>(compact ? 'all' : '7d')
|
||||
const [localSource, setLocalSource] = useState<AuditSourceApp | 'all'>(
|
||||
sourceAppFilter ?? 'all',
|
||||
)
|
||||
const source = sourceAppFilter ?? localSource
|
||||
const setSource = onSourceAppFilterChange ?? setLocalSource
|
||||
|
||||
const visible = useMemo(
|
||||
() =>
|
||||
entries.filter(
|
||||
(e) => matchesFilter(e, filter) && matchesRange(e, range),
|
||||
(e) =>
|
||||
matchesFilter(e, filter) &&
|
||||
matchesRange(e, range) &&
|
||||
matchesSourceApp(e, source),
|
||||
),
|
||||
[entries, filter, range],
|
||||
[entries, filter, range, source],
|
||||
)
|
||||
|
||||
const days = useMemo(() => groupByDay(visible), [visible])
|
||||
|
||||
return (
|
||||
<section className="w-full" aria-labelledby="audit-log-title">
|
||||
<div className="mb-6 flex flex-col gap-4">
|
||||
<div className="flex flex-wrap items-end justify-between gap-3">
|
||||
<div className="flex min-w-0 flex-col gap-1">
|
||||
<h1
|
||||
id="audit-log-title"
|
||||
className="text-xl font-semibold tracking-tight"
|
||||
>
|
||||
Журнал аудита
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm leading-5">
|
||||
{totalCount} событий · показано {visible.length}
|
||||
</p>
|
||||
</div>
|
||||
<div className={cn('flex flex-col gap-4', compact ? 'mb-4' : 'mb-6')}>
|
||||
{!compact ? (
|
||||
<div className="flex flex-wrap items-end justify-between gap-3">
|
||||
<div className="flex min-w-0 flex-col gap-1">
|
||||
<h1
|
||||
id="audit-log-title"
|
||||
className="text-xl font-semibold tracking-tight"
|
||||
>
|
||||
Журнал аудита
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm leading-5">
|
||||
{totalCount} событий · показано {visible.length}
|
||||
{lockedUserId ? ' · пользователь' : ''}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Select
|
||||
value={range}
|
||||
onValueChange={(value) => {
|
||||
if (value) setRange(value as AuditRange)
|
||||
}}
|
||||
items={[...RANGE_OPTIONS]}
|
||||
>
|
||||
<SelectTrigger size="sm" className="w-44">
|
||||
<CalendarIcon
|
||||
className="text-muted-foreground size-4"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent align="end" alignItemWithTrigger={false}>
|
||||
<SelectGroup>
|
||||
{RANGE_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="flex shrink-0 flex-wrap items-center gap-2">
|
||||
<Select
|
||||
value={source}
|
||||
onValueChange={(value) => {
|
||||
if (value) setSource(value as AuditSourceApp | 'all')
|
||||
}}
|
||||
items={[...SOURCE_APP_OPTIONS]}
|
||||
>
|
||||
<SelectTrigger size="sm" className="w-40">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent align="end" alignItemWithTrigger={false}>
|
||||
<SelectGroup>
|
||||
{SOURCE_APP_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
exportAuditCsv(visible)
|
||||
toast.success('CSV экспортирован', {
|
||||
description: `${visible.length} событий`,
|
||||
})
|
||||
}}
|
||||
>
|
||||
<DownloadIcon aria-hidden="true" />
|
||||
<span className="hidden sm:inline">Export CSV</span>
|
||||
</Button>
|
||||
<Select
|
||||
value={range}
|
||||
onValueChange={(value) => {
|
||||
if (value) setRange(value as AuditRange)
|
||||
}}
|
||||
items={[...RANGE_OPTIONS]}
|
||||
>
|
||||
<SelectTrigger size="sm" className="w-44">
|
||||
<CalendarIcon
|
||||
className="text-muted-foreground size-4"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent align="end" alignItemWithTrigger={false}>
|
||||
<SelectGroup>
|
||||
{RANGE_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
exportAuditCsv(visible)
|
||||
toast.success('CSV экспортирован', {
|
||||
description: `${visible.length} событий`,
|
||||
})
|
||||
}}
|
||||
>
|
||||
<DownloadIcon aria-hidden="true" />
|
||||
<span className="hidden sm:inline">Export CSV</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p
|
||||
id="audit-log-title"
|
||||
className="text-muted-foreground text-sm leading-5"
|
||||
>
|
||||
{visible.length} из {totalCount} событий
|
||||
</p>
|
||||
)}
|
||||
|
||||
<Tabs
|
||||
value={filter}
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
/**
|
||||
* Create user Sheet — auth-portal admin.
|
||||
* Preview: https://reui.io/preview/base/solution-users-1
|
||||
*/
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { CreateUserRequest } from '@authportal/shared'
|
||||
import { XIcon } from 'lucide-react'
|
||||
import {
|
||||
Alert,
|
||||
AlertDescription,
|
||||
AlertTitle,
|
||||
} from '@/components/reui/alert'
|
||||
import { Button } from '@authportal/ui/components/button'
|
||||
import { Checkbox } from '@authportal/ui/components/checkbox'
|
||||
import { Field, FieldGroup, FieldLabel } from '@authportal/ui/components/field'
|
||||
import { Input } from '@authportal/ui/components/input'
|
||||
import {
|
||||
Sheet,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from '@authportal/ui/components/sheet'
|
||||
|
||||
const mutedIconButtonClassName = 'text-muted-foreground hover:text-foreground'
|
||||
|
||||
export function CreateUserSheet({
|
||||
open,
|
||||
onOpenChange,
|
||||
pending,
|
||||
error,
|
||||
onSubmit,
|
||||
}: {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
pending: boolean
|
||||
error: string | null
|
||||
onSubmit: (values: CreateUserRequest) => void
|
||||
}) {
|
||||
const [isAdmin, setIsAdmin] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) setIsAdmin(false)
|
||||
}, [open])
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent
|
||||
side="right"
|
||||
showCloseButton={false}
|
||||
className="inset-y-4 right-4 left-auto flex h-[calc(100svh-2rem)] w-[min(24rem,calc(100vw-2rem))] max-w-none flex-col gap-0 overflow-hidden rounded-xl p-0 outline-none sm:max-w-none"
|
||||
>
|
||||
<SheetHeader className="shrink-0 gap-0 p-0">
|
||||
<div className="flex min-h-12 items-center justify-between gap-2 border-b px-4">
|
||||
<SheetTitle className="min-w-0 truncate text-base font-semibold">
|
||||
Новый пользователь
|
||||
</SheetTitle>
|
||||
<SheetClose
|
||||
render={
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label="Закрыть"
|
||||
className={mutedIconButtonClassName}
|
||||
>
|
||||
<XIcon aria-hidden="true" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<SheetDescription className="sr-only">
|
||||
Создание учётной записи портала
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<form
|
||||
id="create-user-form"
|
||||
className="flex min-h-0 flex-1 flex-col"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
const fd = new FormData(e.currentTarget)
|
||||
onSubmit({
|
||||
email: String(fd.get('email') ?? ''),
|
||||
name: String(fd.get('name') ?? ''),
|
||||
password: String(fd.get('password') ?? ''),
|
||||
is_admin: isAdmin,
|
||||
apps: [],
|
||||
permissions: [],
|
||||
})
|
||||
}}
|
||||
>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-4 py-5">
|
||||
<FieldGroup>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="create-name">Имя</FieldLabel>
|
||||
<Input
|
||||
id="create-name"
|
||||
name="name"
|
||||
autoComplete="name"
|
||||
required
|
||||
/>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="create-email">Email</FieldLabel>
|
||||
<Input
|
||||
id="create-email"
|
||||
name="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
required
|
||||
/>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="create-password">Пароль</FieldLabel>
|
||||
<Input
|
||||
id="create-password"
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
minLength={6}
|
||||
required
|
||||
/>
|
||||
</Field>
|
||||
<Field orientation="horizontal">
|
||||
<Checkbox
|
||||
id="create-is-admin"
|
||||
checked={isAdmin}
|
||||
onCheckedChange={(v) => setIsAdmin(v === true)}
|
||||
/>
|
||||
<FieldLabel htmlFor="create-is-admin" className="font-normal">
|
||||
Администратор портала
|
||||
</FieldLabel>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
|
||||
{error ? (
|
||||
<Alert variant="destructive" className="mt-5">
|
||||
<AlertTitle>Ошибка</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<SheetFooter className="bg-background shrink-0 border-t">
|
||||
<div className="flex w-full gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="min-w-0 flex-1"
|
||||
disabled={pending}
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
Отмена
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
form="create-user-form"
|
||||
className="min-w-0 flex-1"
|
||||
disabled={pending}
|
||||
>
|
||||
{pending ? 'Создание…' : 'Создать'}
|
||||
</Button>
|
||||
</div>
|
||||
</SheetFooter>
|
||||
</form>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
@@ -13,3 +13,6 @@ export { QuickActionGrid, type QuickActionItem } from './quick-action-grid'
|
||||
export type { ResourcePageProps, ResourcePageTab } from './resource-page'
|
||||
export { AppSwitcherAdminEditor } from './app-switcher-admin-editor'
|
||||
export { UserAccessSheet } from './user-access-sheet'
|
||||
export { UserAuditSheet } from './user-audit-sheet'
|
||||
export { CreateUserSheet } from './create-user-sheet'
|
||||
export { AdminUsersGrid, type AdminUsersGridProps } from './admin-users-grid'
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* User-scoped audit Sheet — chrome DNA solution-users-1 MemberDetailSheet.
|
||||
* Preview: https://reui.io/preview/base/solution-users-1
|
||||
* Timeline: https://reui.io/preview/base/solution-users-6
|
||||
*/
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import type { AdminUser } from '@authportal/shared'
|
||||
import { XIcon } from 'lucide-react'
|
||||
import { AuditLogTimeline } from '@/components/reui-kit/audit-log-timeline'
|
||||
import { auditQueryOptions } from '@/queries/audit'
|
||||
import { ApiError } from '@/lib/api-client'
|
||||
import { Button } from '@authportal/ui/components/button'
|
||||
import { ScrollArea } from '@authportal/ui/components/scroll-area'
|
||||
import { Skeleton } from '@authportal/ui/components/skeleton'
|
||||
import {
|
||||
Sheet,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from '@authportal/ui/components/sheet'
|
||||
|
||||
const mutedIconButtonClassName = 'text-muted-foreground hover:text-foreground'
|
||||
|
||||
export function UserAuditSheet({
|
||||
user,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: {
|
||||
user: AdminUser | null
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}) {
|
||||
const userId = user?.id
|
||||
const { data: entries = [], isLoading, error, refetch } = useQuery({
|
||||
...auditQueryOptions({ userId, limit: 200 }),
|
||||
enabled: open && Boolean(userId),
|
||||
})
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent
|
||||
side="right"
|
||||
showCloseButton={false}
|
||||
className="inset-y-4 right-4 left-auto flex h-[calc(100svh-2rem)] w-[min(36rem,calc(100vw-2rem))] max-w-none flex-col gap-0 overflow-hidden rounded-xl p-0 outline-none sm:max-w-none"
|
||||
>
|
||||
<SheetHeader className="shrink-0 gap-0 p-0">
|
||||
<div className="flex min-h-12 items-center justify-between gap-2 border-b px-4">
|
||||
<SheetTitle className="min-w-0 truncate text-base font-semibold">
|
||||
Журнал: {user?.name ?? '…'}
|
||||
</SheetTitle>
|
||||
<SheetClose
|
||||
render={
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label="Закрыть"
|
||||
className={mutedIconButtonClassName}
|
||||
>
|
||||
<XIcon aria-hidden="true" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<SheetDescription className="text-muted-foreground border-b px-4 py-2 text-sm">
|
||||
{user?.email ?? 'События пользователя (актор или цель)'}
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<ScrollArea className="min-h-0 flex-1">
|
||||
<div className="p-4">
|
||||
{error ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-destructive text-sm">
|
||||
{error instanceof ApiError
|
||||
? error.message
|
||||
: 'Ошибка загрузки'}
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-fit"
|
||||
onClick={() => refetch()}
|
||||
>
|
||||
Повторить
|
||||
</Button>
|
||||
</div>
|
||||
) : isLoading ? (
|
||||
<div className="flex flex-col gap-3">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-14 w-full" />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<AuditLogTimeline
|
||||
entries={entries}
|
||||
totalCount={entries.length}
|
||||
compact
|
||||
lockedUserId={userId}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
@@ -1,15 +1,44 @@
|
||||
import { queryOptions } from '@tanstack/react-query'
|
||||
import type { AuditLogEntry, AuditSettings } from '@authportal/shared'
|
||||
import type {
|
||||
AuditLogEntry,
|
||||
AuditSettings,
|
||||
AuditSourceApp,
|
||||
} from '@authportal/shared'
|
||||
import { api } from '@/lib/api-client'
|
||||
|
||||
export const auditQueryKey = ['admin', 'audit'] as const
|
||||
export const auditSettingsQueryKey = ['admin', 'audit', 'settings'] as const
|
||||
|
||||
export const auditQueryOptions = queryOptions({
|
||||
queryKey: auditQueryKey,
|
||||
queryFn: () =>
|
||||
api.get<AuditLogEntry[]>('/api/v1/admin/audit?limit=200'),
|
||||
})
|
||||
export function auditListQueryKey(opts?: {
|
||||
userId?: string
|
||||
sourceApp?: AuditSourceApp | 'all'
|
||||
}) {
|
||||
return [
|
||||
...auditQueryKey,
|
||||
'list',
|
||||
opts?.userId ?? null,
|
||||
opts?.sourceApp ?? 'all',
|
||||
] as const
|
||||
}
|
||||
|
||||
export function auditQueryOptions(opts?: {
|
||||
userId?: string
|
||||
sourceApp?: AuditSourceApp
|
||||
limit?: number
|
||||
}) {
|
||||
const params = new URLSearchParams()
|
||||
params.set('limit', String(opts?.limit ?? 200))
|
||||
if (opts?.userId) params.set('user_id', opts.userId)
|
||||
if (opts?.sourceApp) params.set('source_app', opts.sourceApp)
|
||||
return queryOptions({
|
||||
queryKey: auditListQueryKey({
|
||||
userId: opts?.userId,
|
||||
sourceApp: opts?.sourceApp ?? 'all',
|
||||
}),
|
||||
queryFn: () =>
|
||||
api.get<AuditLogEntry[]>(`/api/v1/admin/audit?${params.toString()}`),
|
||||
})
|
||||
}
|
||||
|
||||
export const auditSettingsQueryOptions = queryOptions({
|
||||
queryKey: auditSettingsQueryKey,
|
||||
|
||||
@@ -30,7 +30,7 @@ function AdminAuditPage() {
|
||||
isLoading,
|
||||
error,
|
||||
refetch,
|
||||
} = useQuery(auditQueryOptions)
|
||||
} = useQuery(auditQueryOptions())
|
||||
const { data: settings } = useQuery(auditSettingsQueryOptions)
|
||||
|
||||
const saveMutation = useMutation({
|
||||
|
||||
@@ -1,453 +1,179 @@
|
||||
/**
|
||||
* Admin users directory — DNA solution-users-1.
|
||||
* Admin users directory — thin wiring over AdminUsersGrid.
|
||||
* Preview: https://reui.io/preview/base/solution-users-1
|
||||
* Docs: https://reui.io/blocks · Filters: https://reui.io/docs/components/base/filters
|
||||
*/
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
getCoreRowModel,
|
||||
getFilteredRowModel,
|
||||
getPaginationRowModel,
|
||||
getSortedRowModel,
|
||||
useReactTable,
|
||||
type ColumnDef,
|
||||
type SortingState,
|
||||
} from '@tanstack/react-table'
|
||||
import {
|
||||
CircleDotIcon,
|
||||
MailIcon,
|
||||
ShieldCheckIcon,
|
||||
UserIcon,
|
||||
XIcon,
|
||||
} from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import type { AdminUser, CreateUserRequest } from '@authportal/shared'
|
||||
import { PageShell } from '@/components/page-shell'
|
||||
import { AdminUsersGrid } from '@/components/reui-kit/admin-users-grid'
|
||||
import { CreateUserSheet } from '@/components/reui-kit/create-user-sheet'
|
||||
import { UserAccessSheet } from '@/components/reui-kit/user-access-sheet'
|
||||
import {
|
||||
Alert,
|
||||
AlertDescription,
|
||||
AlertTitle,
|
||||
} from '@/components/reui/alert'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import {
|
||||
Frame,
|
||||
FrameDescription,
|
||||
FrameFooter,
|
||||
FrameHeader,
|
||||
FramePanel,
|
||||
FrameTitle,
|
||||
} from '@/components/reui/frame'
|
||||
import { DataGrid } from '@/components/reui/data-grid/data-grid'
|
||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||
import { DataGridPagination } from '@/components/reui/data-grid/data-grid-pagination'
|
||||
import { DataGridTable } from '@/components/reui/data-grid/data-grid-table'
|
||||
import {
|
||||
createFilter,
|
||||
Filters,
|
||||
type Filter,
|
||||
type FilterFieldConfig,
|
||||
} from '@/components/reui/filters'
|
||||
import { Avatar, AvatarFallback } from '@authportal/ui/components/avatar'
|
||||
import { Button } from '@authportal/ui/components/button'
|
||||
import { Field, FieldGroup, FieldLabel } from '@authportal/ui/components/field'
|
||||
import { Input } from '@authportal/ui/components/input'
|
||||
import { Checkbox } from '@authportal/ui/components/checkbox'
|
||||
import {
|
||||
Sheet,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from '@authportal/ui/components/sheet'
|
||||
import {
|
||||
Tabs,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from '@authportal/ui/components/tabs'
|
||||
import { Skeleton } from '@authportal/ui/components/skeleton'
|
||||
import { UserAuditSheet } from '@/components/reui-kit/user-audit-sheet'
|
||||
import { api, ApiError } from '@/lib/api-client'
|
||||
import { usersQueryKey, usersQueryOptions } from '@/queries/auth'
|
||||
|
||||
const mutedIconButtonClassName = 'text-muted-foreground hover:text-foreground'
|
||||
import { Button } from '@authportal/ui/components/button'
|
||||
|
||||
export const Route = createFileRoute('/_auth/admin/')({
|
||||
component: AdminUsersPage,
|
||||
})
|
||||
|
||||
type TabId = 'all' | 'admins' | 'disabled'
|
||||
|
||||
function getActiveFilters(filters: Filter[]) {
|
||||
return filters.filter((filter) => {
|
||||
const { values } = filter
|
||||
if (!values || values.length === 0) return false
|
||||
if (
|
||||
values.every((value) => typeof value === 'string' && value.trim() === '')
|
||||
)
|
||||
return false
|
||||
if (values.every((value) => value === null || value === undefined))
|
||||
return false
|
||||
if (values.every((value) => Array.isArray(value) && value.length === 0))
|
||||
return false
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
function applyFiltersToUsers(data: AdminUser[], filters: Filter[]): AdminUser[] {
|
||||
const active = getActiveFilters(filters)
|
||||
let result = [...data]
|
||||
for (const filter of active) {
|
||||
const { field, operator, values } = filter
|
||||
result = result.filter((item) => {
|
||||
let fieldValue: string | boolean = ''
|
||||
if (field === 'name') fieldValue = item.name
|
||||
else if (field === 'email') fieldValue = item.email
|
||||
else if (field === 'role') fieldValue = item.is_admin ? 'admin' : 'user'
|
||||
else if (field === 'status')
|
||||
fieldValue = item.disabled ? 'disabled' : 'active'
|
||||
|
||||
switch (operator) {
|
||||
case 'is':
|
||||
return values.includes(fieldValue)
|
||||
case 'is_not':
|
||||
return !values.includes(fieldValue)
|
||||
case 'is_any_of':
|
||||
return values.some((v) => fieldValue === v)
|
||||
case 'is_not_any_of':
|
||||
return !values.some((v) => fieldValue === v)
|
||||
case 'contains': {
|
||||
const tokens = values.map((v) => String(v).trim()).filter(Boolean)
|
||||
if (tokens.length === 0) return true
|
||||
return tokens.some((token) =>
|
||||
String(fieldValue).toLowerCase().includes(token.toLowerCase()),
|
||||
)
|
||||
}
|
||||
case 'not_contains':
|
||||
return !values.some((v) =>
|
||||
String(fieldValue).toLowerCase().includes(String(v).toLowerCase()),
|
||||
)
|
||||
case 'starts_with':
|
||||
return values.some((v) =>
|
||||
String(fieldValue)
|
||||
.toLowerCase()
|
||||
.startsWith(String(v).toLowerCase()),
|
||||
)
|
||||
case 'empty':
|
||||
return fieldValue === '' || fieldValue == null
|
||||
case 'not_empty':
|
||||
return fieldValue !== '' && fieldValue != null
|
||||
default:
|
||||
return true
|
||||
}
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function createDefaultFilters(): Filter[] {
|
||||
return [createFilter('name', 'contains', [''])]
|
||||
}
|
||||
|
||||
function userInitials(name: string, email: string) {
|
||||
const parts = name.trim().split(/\s+/).filter(Boolean)
|
||||
if (parts.length >= 2) {
|
||||
return `${parts[0]![0] ?? ''}${parts[1]![0] ?? ''}`.toUpperCase()
|
||||
}
|
||||
return (name || email).slice(0, 2).toUpperCase()
|
||||
}
|
||||
|
||||
function AdminUsersPage() {
|
||||
const queryClient = useQueryClient()
|
||||
const { data: users = [], isLoading, error, refetch } = useQuery(usersQueryOptions)
|
||||
const [tab, setTab] = useState<TabId>('all')
|
||||
const [filters, setFilters] = useState<Filter[]>(createDefaultFilters)
|
||||
const [sorting, setSorting] = useState<SortingState>([
|
||||
{ id: 'name', desc: false },
|
||||
])
|
||||
const { data: users = [], isLoading, error, refetch } = useQuery(
|
||||
usersQueryOptions,
|
||||
)
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
const [accessUserId, setAccessUserId] = useState<string | null>(null)
|
||||
const [auditUser, setAuditUser] = useState<AdminUser | null>(null)
|
||||
|
||||
const filterFields: FilterFieldConfig[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
key: 'name',
|
||||
label: 'Имя',
|
||||
icon: <UserIcon className="size-3.5" aria-hidden />,
|
||||
type: 'text',
|
||||
className: 'w-44',
|
||||
placeholder: 'Поиск…',
|
||||
},
|
||||
{
|
||||
key: 'email',
|
||||
label: 'Email',
|
||||
icon: <MailIcon className="size-3.5" aria-hidden />,
|
||||
type: 'text',
|
||||
className: 'w-48',
|
||||
placeholder: 'Поиск…',
|
||||
},
|
||||
{
|
||||
key: 'role',
|
||||
label: 'Роль',
|
||||
icon: <ShieldCheckIcon className="size-3.5" aria-hidden />,
|
||||
type: 'select',
|
||||
options: [
|
||||
{ value: 'admin', label: 'Админ' },
|
||||
{ value: 'user', label: 'Пользователь' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
label: 'Статус',
|
||||
icon: <CircleDotIcon className="size-3.5" aria-hidden />,
|
||||
type: 'select',
|
||||
options: [
|
||||
{ value: 'active', label: 'Активен' },
|
||||
{ value: 'disabled', label: 'Отключён' },
|
||||
],
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
|
||||
const tabFiltered = useMemo(() => {
|
||||
let rows = users
|
||||
if (tab === 'admins') rows = rows.filter((u) => u.is_admin)
|
||||
if (tab === 'disabled') rows = rows.filter((u) => u.disabled)
|
||||
return applyFiltersToUsers(rows, filters)
|
||||
}, [users, tab, filters])
|
||||
|
||||
const counts = useMemo(
|
||||
() => ({
|
||||
all: users.length,
|
||||
admins: users.filter((u) => u.is_admin).length,
|
||||
disabled: users.filter((u) => u.disabled).length,
|
||||
}),
|
||||
[users],
|
||||
)
|
||||
|
||||
const columns = useMemo<ColumnDef<AdminUser, unknown>[]>(
|
||||
() => [
|
||||
{
|
||||
accessorKey: 'name',
|
||||
id: 'name',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader title="Участник" column={column} />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const u = row.original
|
||||
return (
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<Avatar className="size-8">
|
||||
<AvatarFallback className="text-xs">
|
||||
{userInitials(u.name, u.email)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
<span className="truncate font-medium">{u.name}</span>
|
||||
<span className="text-muted-foreground truncate text-xs">
|
||||
{u.email}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'role',
|
||||
accessorFn: (row) => (row.is_admin ? 1 : 0),
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader title="Роль" column={column} />
|
||||
),
|
||||
cell: ({ row }) =>
|
||||
row.original.is_admin ? (
|
||||
<Badge variant="warning-light" size="sm">
|
||||
Админ
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary" size="sm">
|
||||
Пользователь
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'apps',
|
||||
accessorFn: (row) => row.apps.length,
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader title="Apps" column={column} />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="tabular-nums">{row.original.apps.length}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'status',
|
||||
accessorFn: (row) => (row.disabled ? 0 : 1),
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader title="Статус" column={column} />
|
||||
),
|
||||
cell: ({ row }) =>
|
||||
row.original.disabled ? (
|
||||
<Badge variant="destructive-light" size="sm" className="gap-1.5">
|
||||
<span className="bg-destructive size-1.5 shrink-0 rounded-full" />
|
||||
Отключён
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="success-light" size="sm" className="gap-1.5">
|
||||
<span className="bg-success size-1.5 shrink-0 rounded-full" />
|
||||
Активен
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
enableSorting: false,
|
||||
header: '',
|
||||
cell: ({ row }) => (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setAccessUserId(row.original.id)}
|
||||
>
|
||||
Права
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
|
||||
const table = useReactTable({
|
||||
data: tabFiltered,
|
||||
columns,
|
||||
getRowId: (row) => row.id,
|
||||
state: { sorting },
|
||||
onSortingChange: setSorting,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
initialState: { pagination: { pageSize: 10 } },
|
||||
})
|
||||
const invalidateUsers = useCallback(async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: usersQueryKey })
|
||||
}, [queryClient])
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (body: CreateUserRequest) =>
|
||||
api.post<AdminUser>('/api/v1/admin/users', body),
|
||||
onSuccess: async (user) => {
|
||||
await queryClient.invalidateQueries({ queryKey: usersQueryKey })
|
||||
await invalidateUsers()
|
||||
setCreateOpen(false)
|
||||
setAccessUserId(user.id)
|
||||
toast.success('Пользователь создан')
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(
|
||||
err instanceof ApiError ? err.message : 'Не удалось создать пользователя',
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
const patchMutation = useMutation({
|
||||
mutationFn: ({
|
||||
id,
|
||||
...body
|
||||
}: {
|
||||
id: string
|
||||
is_admin?: boolean
|
||||
disabled?: boolean
|
||||
}) => api.patch<AdminUser>(`/api/v1/admin/users/${id}`, body),
|
||||
onSuccess: async () => {
|
||||
await invalidateUsers()
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(
|
||||
err instanceof ApiError ? err.message : 'Не удалось обновить пользователя',
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => api.delete(`/api/v1/admin/users/${id}`),
|
||||
onSuccess: async () => {
|
||||
await invalidateUsers()
|
||||
toast.success('Пользователь удалён')
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(
|
||||
err instanceof ApiError ? err.message : 'Не удалось удалить пользователя',
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
const handleDeactivate = useCallback(
|
||||
(user: AdminUser) => {
|
||||
patchMutation.mutate(
|
||||
{ id: user.id, disabled: true },
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.message('Пользователь отключён', {
|
||||
description: `${user.email} больше не может войти.`,
|
||||
})
|
||||
},
|
||||
},
|
||||
)
|
||||
},
|
||||
[patchMutation],
|
||||
)
|
||||
|
||||
const handleDelete = useCallback(
|
||||
(user: AdminUser) => {
|
||||
deleteMutation.mutate(user.id)
|
||||
},
|
||||
[deleteMutation],
|
||||
)
|
||||
|
||||
const handleBulkSetRole = useCallback(
|
||||
(userIds: string[], isAdmin: boolean) => {
|
||||
Promise.all(
|
||||
userIds.map((id) =>
|
||||
api.patch<AdminUser>(`/api/v1/admin/users/${id}`, {
|
||||
is_admin: isAdmin,
|
||||
}),
|
||||
),
|
||||
)
|
||||
.then(async () => {
|
||||
await invalidateUsers()
|
||||
toast.success(
|
||||
`Роль обновлена для ${userIds.length} пользовател${userIds.length === 1 ? 'я' : 'ей'}`,
|
||||
)
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(
|
||||
err instanceof ApiError ? err.message : 'Ошибка массового обновления',
|
||||
)
|
||||
})
|
||||
},
|
||||
[invalidateUsers],
|
||||
)
|
||||
|
||||
const handleBulkDeactivate = useCallback(
|
||||
(userIds: string[]) => {
|
||||
Promise.all(
|
||||
userIds.map((id) =>
|
||||
api.patch<AdminUser>(`/api/v1/admin/users/${id}`, { disabled: true }),
|
||||
),
|
||||
)
|
||||
.then(async () => {
|
||||
await invalidateUsers()
|
||||
toast.message('Пользователи отключены', {
|
||||
description: `${userIds.length} учётных записей.`,
|
||||
})
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(
|
||||
err instanceof ApiError ? err.message : 'Ошибка массового отключения',
|
||||
)
|
||||
})
|
||||
},
|
||||
[invalidateUsers],
|
||||
)
|
||||
|
||||
return (
|
||||
<PageShell>
|
||||
<Frame dense className="w-full">
|
||||
<FrameHeader className="flex-row items-start justify-between gap-4">
|
||||
<div className="flex flex-col gap-px">
|
||||
<FrameTitle>Пользователи</FrameTitle>
|
||||
<FrameDescription>
|
||||
Управление доступом к приложениям и разделам
|
||||
</FrameDescription>
|
||||
</div>
|
||||
<Button onClick={() => setCreateOpen(true)}>Создать</Button>
|
||||
</FrameHeader>
|
||||
|
||||
<FramePanel className="flex flex-col gap-4 p-0">
|
||||
<div className="flex flex-col gap-3 border-b px-4 pt-3">
|
||||
<Tabs value={tab} onValueChange={(v) => setTab(v as TabId)}>
|
||||
<TabsList variant="line" className="gap-5">
|
||||
{(
|
||||
[
|
||||
['all', 'Все', counts.all],
|
||||
['admins', 'Админы', counts.admins],
|
||||
['disabled', 'Отключённые', counts.disabled],
|
||||
] as const
|
||||
).map(([id, label, count]) => (
|
||||
<TabsTrigger
|
||||
key={id}
|
||||
value={id}
|
||||
className="text-muted-foreground hover:text-foreground h-auto gap-2 px-0 pb-3"
|
||||
>
|
||||
<span>{label}</span>
|
||||
<span className="bg-muted text-muted-foreground rounded-md px-1.5 py-0.5 text-xs tabular-nums">
|
||||
{count}
|
||||
</span>
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
<div className="pb-3">
|
||||
<Filters
|
||||
filters={filters}
|
||||
fields={filterFields}
|
||||
onChange={setFilters}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<div className="flex flex-col gap-2 p-4">
|
||||
<p className="text-destructive text-sm">
|
||||
{error instanceof ApiError ? error.message : 'Ошибка загрузки'}
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-fit"
|
||||
onClick={() => refetch()}
|
||||
>
|
||||
Повторить
|
||||
</Button>
|
||||
</div>
|
||||
) : isLoading ? (
|
||||
<div className="flex flex-col gap-2 p-4">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-10 w-full" />
|
||||
))}
|
||||
</div>
|
||||
) : tabFiltered.length === 0 ? (
|
||||
<div className="text-muted-foreground flex flex-col items-start gap-3 p-6 text-sm">
|
||||
<p>Нет пользователей по текущему фильтру.</p>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setFilters(createDefaultFilters())}
|
||||
>
|
||||
Сбросить фильтры
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setCreateOpen(true)}
|
||||
>
|
||||
Создать пользователя
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<DataGrid
|
||||
table={table}
|
||||
recordCount={tabFiltered.length}
|
||||
tableLayout={{ dense: true, width: 'auto' }}
|
||||
>
|
||||
<div className="relative">
|
||||
<DataGridTable />
|
||||
</div>
|
||||
</DataGrid>
|
||||
)}
|
||||
</FramePanel>
|
||||
{!isLoading && tabFiltered.length > 0 ? (
|
||||
<FrameFooter className="border-t">
|
||||
<DataGrid table={table} recordCount={tabFiltered.length}>
|
||||
<DataGridPagination />
|
||||
</DataGrid>
|
||||
</FrameFooter>
|
||||
) : null}
|
||||
</Frame>
|
||||
{error ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-destructive text-sm">
|
||||
{error instanceof ApiError ? error.message : 'Ошибка загрузки'}
|
||||
</p>
|
||||
<Button variant="outline" size="sm" className="w-fit" onClick={() => refetch()}>
|
||||
Повторить
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<AdminUsersGrid
|
||||
users={users}
|
||||
isLoading={isLoading}
|
||||
onCreateClick={() => setCreateOpen(true)}
|
||||
onOpenAudit={setAuditUser}
|
||||
onOpenAccess={(user) => setAccessUserId(user.id)}
|
||||
onDeactivate={handleDeactivate}
|
||||
onDelete={handleDelete}
|
||||
onBulkSetRole={handleBulkSetRole}
|
||||
onBulkDeactivate={handleBulkDeactivate}
|
||||
/>
|
||||
)}
|
||||
|
||||
<CreateUserSheet
|
||||
open={createOpen}
|
||||
@@ -470,151 +196,14 @@ function AdminUsersPage() {
|
||||
if (!next) setAccessUserId(null)
|
||||
}}
|
||||
/>
|
||||
|
||||
<UserAuditSheet
|
||||
user={auditUser}
|
||||
open={Boolean(auditUser)}
|
||||
onOpenChange={(next) => {
|
||||
if (!next) setAuditUser(null)
|
||||
}}
|
||||
/>
|
||||
</PageShell>
|
||||
)
|
||||
}
|
||||
|
||||
function CreateUserSheet({
|
||||
open,
|
||||
onOpenChange,
|
||||
pending,
|
||||
error,
|
||||
onSubmit,
|
||||
}: {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
pending: boolean
|
||||
error: string | null
|
||||
onSubmit: (values: CreateUserRequest) => void
|
||||
}) {
|
||||
const [isAdmin, setIsAdmin] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) setIsAdmin(false)
|
||||
}, [open])
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent
|
||||
side="right"
|
||||
showCloseButton={false}
|
||||
className="inset-y-4 right-4 left-auto flex h-[calc(100svh-2rem)] w-[min(24rem,calc(100vw-2rem))] max-w-none flex-col gap-0 overflow-hidden rounded-xl p-0 outline-none sm:max-w-none"
|
||||
>
|
||||
<SheetHeader className="shrink-0 gap-0 p-0">
|
||||
<div className="flex min-h-12 items-center justify-between gap-2 border-b px-4">
|
||||
<SheetTitle className="min-w-0 truncate text-base font-semibold">
|
||||
Новый пользователь
|
||||
</SheetTitle>
|
||||
<SheetClose
|
||||
render={
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label="Закрыть"
|
||||
className={mutedIconButtonClassName}
|
||||
>
|
||||
<XIcon aria-hidden="true" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<SheetDescription className="sr-only">
|
||||
Создание учётной записи портала
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<form
|
||||
id="create-user-form"
|
||||
className="flex min-h-0 flex-1 flex-col"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
const fd = new FormData(e.currentTarget)
|
||||
onSubmit({
|
||||
email: String(fd.get('email') ?? ''),
|
||||
name: String(fd.get('name') ?? ''),
|
||||
password: String(fd.get('password') ?? ''),
|
||||
is_admin: isAdmin,
|
||||
apps: [],
|
||||
permissions: [],
|
||||
})
|
||||
}}
|
||||
>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-4 py-5">
|
||||
<FieldGroup>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="create-name">Имя</FieldLabel>
|
||||
<Input
|
||||
id="create-name"
|
||||
name="name"
|
||||
autoComplete="name"
|
||||
required
|
||||
/>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="create-email">Email</FieldLabel>
|
||||
<Input
|
||||
id="create-email"
|
||||
name="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
required
|
||||
/>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="create-password">Пароль</FieldLabel>
|
||||
<Input
|
||||
id="create-password"
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
minLength={6}
|
||||
required
|
||||
/>
|
||||
</Field>
|
||||
<Field orientation="horizontal">
|
||||
<Checkbox
|
||||
id="create-is-admin"
|
||||
checked={isAdmin}
|
||||
onCheckedChange={(v) => setIsAdmin(v === true)}
|
||||
/>
|
||||
<FieldLabel htmlFor="create-is-admin" className="font-normal">
|
||||
Администратор портала
|
||||
</FieldLabel>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
|
||||
{error ? (
|
||||
<Alert variant="destructive" className="mt-5">
|
||||
<AlertTitle>Ошибка</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<SheetFooter className="bg-background shrink-0 border-t">
|
||||
<div className="flex w-full gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="min-w-0 flex-1"
|
||||
disabled={pending}
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
Отмена
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
form="create-user-form"
|
||||
className="min-w-0 flex-1"
|
||||
disabled={pending}
|
||||
>
|
||||
{pending ? 'Создание…' : 'Создать'}
|
||||
</Button>
|
||||
</div>
|
||||
</SheetFooter>
|
||||
</form>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user