Разделены экраны «Входы» и «Изменения»; логин пишет IP/UA и last_login_ip; SSO handoff и revoke refresh-сессий; улучшены audit-карточки. Co-authored-by: Cursor <cursoragent@cursor.com>
1035 lines
31 KiB
TypeScript
1035 lines
31 KiB
TypeScript
/**
|
|
* Admin users grid — DNA solution-users-1 (members-grid, columns, bulk-action-bar).
|
|
* Preview: https://reui.io/preview/base/solution-users-1
|
|
* Docs: https://reui.io/docs/components/base/filters · https://reui.io/docs/components/base/data-grid
|
|
*/
|
|
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
import type { AdminUser, AppId } from '@authportal/shared'
|
|
import { APPS } from '@authportal/shared'
|
|
import {
|
|
CircleDotIcon,
|
|
FilterIcon,
|
|
FunnelXIcon,
|
|
LockIcon,
|
|
MailIcon,
|
|
MoreHorizontalIcon,
|
|
PauseCircleIcon,
|
|
PencilIcon,
|
|
ScrollTextIcon,
|
|
Settings2Icon,
|
|
ShieldCheckIcon,
|
|
Trash2Icon,
|
|
TriangleAlertIcon,
|
|
UserIcon,
|
|
UserPlusIcon,
|
|
} from 'lucide-react'
|
|
import {
|
|
getCoreRowModel,
|
|
getFilteredRowModel,
|
|
getPaginationRowModel,
|
|
getSortedRowModel,
|
|
type ColumnDef,
|
|
type PaginationState,
|
|
type Row,
|
|
type RowSelectionState,
|
|
type SortingState,
|
|
type VisibilityState,
|
|
useReactTable,
|
|
} from '@tanstack/react-table'
|
|
import { Badge } from '@/components/reui/badge'
|
|
import { DataGrid } from '@/components/reui/data-grid/data-grid'
|
|
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
|
import { DataGridColumnVisibility } from '@/components/reui/data-grid/data-grid-column-visibility'
|
|
import { DataGridPagination } from '@/components/reui/data-grid/data-grid-pagination'
|
|
import { DataGridScrollArea } from '@/components/reui/data-grid/data-grid-scroll-area'
|
|
import {
|
|
DataGridTable,
|
|
DataGridTableRowSelect,
|
|
DataGridTableRowSelectAll,
|
|
} from '@/components/reui/data-grid/data-grid-table'
|
|
import {
|
|
createFilter,
|
|
Filters,
|
|
type Filter,
|
|
type FilterFieldConfig,
|
|
} from '@/components/reui/filters'
|
|
import {
|
|
Frame,
|
|
FrameDescription,
|
|
FrameFooter,
|
|
FrameHeader,
|
|
FramePanel,
|
|
FrameTitle,
|
|
} from '@/components/reui/frame'
|
|
import {
|
|
AlertDialog,
|
|
AlertDialogAction,
|
|
AlertDialogCancel,
|
|
AlertDialogContent,
|
|
AlertDialogDescription,
|
|
AlertDialogFooter,
|
|
AlertDialogHeader,
|
|
AlertDialogTitle,
|
|
} from '@authportal/ui/components/alert-dialog'
|
|
import {
|
|
Avatar,
|
|
AvatarFallback,
|
|
} from '@authportal/ui/components/avatar'
|
|
import { Button } from '@authportal/ui/components/button'
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuGroup,
|
|
DropdownMenuItem,
|
|
DropdownMenuSeparator,
|
|
DropdownMenuTrigger,
|
|
} from '@authportal/ui/components/dropdown-menu'
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectGroup,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from '@authportal/ui/components/select'
|
|
import { Separator } from '@authportal/ui/components/separator'
|
|
import { Skeleton } from '@authportal/ui/components/skeleton'
|
|
import { TooltipProvider } from '@authportal/ui/components/tooltip'
|
|
|
|
const APP_TITLE: Record<AppId, string> = Object.fromEntries(
|
|
APPS.map((app) => [app.id, app.title]),
|
|
) as Record<AppId, string>
|
|
|
|
const BULK_ROLE_OPTIONS = [
|
|
{ value: 'admin', label: 'Админ' },
|
|
{ value: 'user', label: 'Пользователь' },
|
|
] as const
|
|
|
|
type BulkRole = (typeof BULK_ROLE_OPTIONS)[number]['value']
|
|
|
|
export interface AdminUsersGridProps {
|
|
users: AdminUser[]
|
|
isLoading?: boolean
|
|
onCreateClick: () => void
|
|
onOpenAudit: (user: AdminUser) => void
|
|
onOpenAccess: (user: AdminUser) => void
|
|
onDeactivate: (user: AdminUser) => void
|
|
onDelete: (user: AdminUser) => void
|
|
onBulkSetRole: (userIds: string[], isAdmin: boolean) => void
|
|
onBulkDeactivate: (userIds: string[]) => void
|
|
}
|
|
|
|
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 serializeActiveFiltersKey(active: Filter[]) {
|
|
return JSON.stringify(
|
|
active.map((f) => ({
|
|
field: f.field,
|
|
operator: f.operator,
|
|
values: f.values,
|
|
})),
|
|
)
|
|
}
|
|
|
|
function filterFieldValue(item: AdminUser, field: string): unknown {
|
|
if (field === 'name') return item.name
|
|
if (field === 'email') return item.email
|
|
if (field === 'role') return item.is_admin ? 'admin' : 'user'
|
|
if (field === 'status') return item.disabled ? 'disabled' : 'active'
|
|
if (field === 'apps') return item.apps.join(' ')
|
|
return undefined
|
|
}
|
|
|
|
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) => {
|
|
if (field === 'apps') {
|
|
const selected = values.map(String)
|
|
switch (operator) {
|
|
case 'is':
|
|
return (
|
|
selected.length > 0 && item.apps.includes(selected[0] as AppId)
|
|
)
|
|
case 'is_any_of':
|
|
return selected.some((v) => item.apps.includes(v as AppId))
|
|
case 'is_not_any_of':
|
|
return !selected.some((v) => item.apps.includes(v as AppId))
|
|
case 'contains': {
|
|
const tokens = values.map((v) => String(v).trim()).filter(Boolean)
|
|
if (tokens.length === 0) return true
|
|
return tokens.some((token) =>
|
|
item.apps.some((appId) =>
|
|
APP_TITLE[appId].toLowerCase().includes(token.toLowerCase()),
|
|
),
|
|
)
|
|
}
|
|
default:
|
|
return true
|
|
}
|
|
}
|
|
|
|
const raw = filterFieldValue(item, field)
|
|
const fieldValue = raw != null ? raw : ''
|
|
|
|
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 'ends_with':
|
|
return values.some((v) =>
|
|
String(fieldValue).toLowerCase().endsWith(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 formatLastActive(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)
|
|
}
|
|
|
|
function renderSelectedCount(values: unknown[]) {
|
|
if (values.length === 0) return 'Выберите…'
|
|
if (values.length > 1) return `${values.length} выбрано`
|
|
return null
|
|
}
|
|
|
|
function DotSeparator() {
|
|
return (
|
|
<span
|
|
className="bg-muted-foreground/40 size-1 shrink-0 rounded-full"
|
|
aria-hidden="true"
|
|
/>
|
|
)
|
|
}
|
|
|
|
function RoleBadge({ isAdmin }: { isAdmin: boolean }) {
|
|
return isAdmin ? (
|
|
<Badge variant="info-outline">Админ</Badge>
|
|
) : (
|
|
<Badge variant="secondary">Пользователь</Badge>
|
|
)
|
|
}
|
|
|
|
function StatusBadge({ disabled }: { disabled: boolean }) {
|
|
return disabled ? (
|
|
<Badge variant="outline">
|
|
<span className="bg-muted-foreground size-1.5 shrink-0 rounded-full!" />
|
|
Отключён
|
|
</Badge>
|
|
) : (
|
|
<Badge variant="outline">
|
|
<span className="bg-success size-1.5 shrink-0 rounded-full!" />
|
|
Активен
|
|
</Badge>
|
|
)
|
|
}
|
|
|
|
const AppTags = memo(function AppTags({ apps }: { apps: AppId[] }) {
|
|
if (apps.length === 0) {
|
|
return <span className="text-muted-foreground text-sm">—</span>
|
|
}
|
|
return (
|
|
<div className="flex flex-wrap items-center gap-1">
|
|
{apps.map((appId) => (
|
|
<Badge key={appId} variant="secondary" className="border-0">
|
|
{appId.toUpperCase()}
|
|
</Badge>
|
|
))}
|
|
</div>
|
|
)
|
|
})
|
|
|
|
const MemberCell = memo(function MemberCell({ row }: { row: Row<AdminUser> }) {
|
|
const user = row.original
|
|
return (
|
|
<div className="flex min-w-0 items-center gap-2">
|
|
<Avatar className="size-8 shrink-0">
|
|
<AvatarFallback className="text-xs">
|
|
{userInitials(user.name, user.email)}
|
|
</AvatarFallback>
|
|
</Avatar>
|
|
<div className="min-w-0">
|
|
<div className="text-foreground line-clamp-1 font-medium">
|
|
{user.name}
|
|
</div>
|
|
<div
|
|
className="text-muted-foreground line-clamp-1 text-xs"
|
|
title={user.email}
|
|
>
|
|
{user.email}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
})
|
|
|
|
function AdminUsersBulkActionBar({
|
|
selectedCount,
|
|
roleValue,
|
|
onRoleChange,
|
|
onChangeRole,
|
|
onDeactivate,
|
|
onClear,
|
|
}: {
|
|
selectedCount: number
|
|
roleValue: BulkRole
|
|
onRoleChange: (value: BulkRole) => void
|
|
onChangeRole: () => void
|
|
onDeactivate: () => void
|
|
onClear: () => void
|
|
}) {
|
|
return (
|
|
<div className="bg-muted/25 flex flex-col gap-3 px-(--frame-panel-header-px) py-(--frame-panel-header-py) lg:flex-row lg:items-center lg:justify-between">
|
|
<div className="flex min-w-0 flex-col gap-0.5">
|
|
<span className="text-sm font-medium">
|
|
Выбрано: {selectedCount}
|
|
</span>
|
|
<span className="text-muted-foreground text-xs">
|
|
Массовое изменение роли или отключение доступа.
|
|
</span>
|
|
</div>
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<Select
|
|
value={roleValue}
|
|
onValueChange={(value) => {
|
|
if (!value) return
|
|
onRoleChange(value as BulkRole)
|
|
}}
|
|
items={BULK_ROLE_OPTIONS.map((o) => ({
|
|
value: o.value,
|
|
label: o.label,
|
|
}))}
|
|
>
|
|
<SelectTrigger size="sm" className="w-[164px]">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent align="end">
|
|
<SelectGroup>
|
|
{BULK_ROLE_OPTIONS.map((option) => (
|
|
<SelectItem key={option.value} value={option.value}>
|
|
{option.label}
|
|
</SelectItem>
|
|
))}
|
|
</SelectGroup>
|
|
</SelectContent>
|
|
</Select>
|
|
<Button type="button" size="sm" variant="outline" onClick={onChangeRole}>
|
|
<PencilIcon data-icon="inline-start" aria-hidden="true" />
|
|
Сменить роль
|
|
</Button>
|
|
<Button type="button" size="sm" onClick={onDeactivate}>
|
|
<PauseCircleIcon data-icon="inline-start" aria-hidden="true" />
|
|
Отключить
|
|
</Button>
|
|
<Button type="button" size="sm" variant="ghost" onClick={onClear}>
|
|
Сбросить
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function ActionsCell({
|
|
row,
|
|
onOpenAudit,
|
|
onOpenAccess,
|
|
onDeactivate,
|
|
onDelete,
|
|
}: {
|
|
row: Row<AdminUser>
|
|
onOpenAudit: (user: AdminUser) => void
|
|
onOpenAccess: (user: AdminUser) => void
|
|
onDeactivate: (user: AdminUser) => void
|
|
onDelete: (user: AdminUser) => void
|
|
}) {
|
|
const [deleteOpen, setDeleteOpen] = useState(false)
|
|
const user = row.original
|
|
|
|
return (
|
|
<div onClick={(e) => e.stopPropagation()} onKeyDown={(e) => e.stopPropagation()}>
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger
|
|
render={
|
|
<Button
|
|
size="icon"
|
|
variant="ghost"
|
|
className="size-7"
|
|
aria-label="Действия"
|
|
/>
|
|
}
|
|
>
|
|
<MoreHorizontalIcon aria-hidden="true" />
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent side="bottom" align="end" className="w-44">
|
|
<DropdownMenuGroup>
|
|
<DropdownMenuItem onClick={() => onOpenAudit(user)}>
|
|
<ScrollTextIcon className="size-4" aria-hidden="true" />
|
|
Журнал
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem onClick={() => onOpenAccess(user)}>
|
|
<ShieldCheckIcon className="size-4" aria-hidden="true" />
|
|
Права
|
|
</DropdownMenuItem>
|
|
{!user.disabled ? (
|
|
<DropdownMenuItem onClick={() => onDeactivate(user)}>
|
|
<PauseCircleIcon className="size-4" aria-hidden="true" />
|
|
Отключить
|
|
</DropdownMenuItem>
|
|
) : null}
|
|
<DropdownMenuSeparator />
|
|
<DropdownMenuItem
|
|
variant="destructive"
|
|
onClick={() => setDeleteOpen(true)}
|
|
>
|
|
<Trash2Icon className="size-4" aria-hidden="true" />
|
|
Удалить
|
|
</DropdownMenuItem>
|
|
</DropdownMenuGroup>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
|
|
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
|
|
<AlertDialogContent size="sm">
|
|
<AlertDialogHeader>
|
|
<AlertDialogTitle>Удалить пользователя?</AlertDialogTitle>
|
|
<AlertDialogDescription>
|
|
Учётная запись{' '}
|
|
<span className="text-foreground font-medium">{user.email}</span>{' '}
|
|
будет удалена без возможности восстановления.
|
|
</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
<AlertDialogFooter>
|
|
<AlertDialogCancel>Отмена</AlertDialogCancel>
|
|
<AlertDialogAction
|
|
variant="destructive"
|
|
onClick={() => {
|
|
setDeleteOpen(false)
|
|
onDelete(user)
|
|
}}
|
|
>
|
|
Удалить
|
|
</AlertDialogAction>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function createAdminUserColumns(handlers: {
|
|
onOpenAudit: (user: AdminUser) => void
|
|
onOpenAccess: (user: AdminUser) => void
|
|
onDeactivate: (user: AdminUser) => void
|
|
onDelete: (user: AdminUser) => void
|
|
}): ColumnDef<AdminUser>[] {
|
|
return [
|
|
{
|
|
id: 'select',
|
|
header: () => (
|
|
<div onClick={(e) => e.stopPropagation()}>
|
|
<DataGridTableRowSelectAll />
|
|
</div>
|
|
),
|
|
cell: ({ row }) => (
|
|
<div onClick={(e) => e.stopPropagation()}>
|
|
<DataGridTableRowSelect row={row} />
|
|
</div>
|
|
),
|
|
enableSorting: false,
|
|
size: 40,
|
|
enableResizing: false,
|
|
enableHiding: false,
|
|
meta: {
|
|
skeleton: <Skeleton className="mx-auto size-5" />,
|
|
headerClassName:
|
|
'[--data-grid-header-cell-ps:var(--frame-panel-header-px)]',
|
|
cellClassName: '[--data-grid-body-cell-ps:var(--frame-panel-px)]',
|
|
},
|
|
},
|
|
{
|
|
accessorKey: 'name',
|
|
id: 'name',
|
|
header: ({ column }) => (
|
|
<DataGridColumnHeader title="Участник" visibility column={column} />
|
|
),
|
|
cell: ({ row }) => <MemberCell row={row} />,
|
|
enableSorting: true,
|
|
enableHiding: false,
|
|
enableResizing: true,
|
|
minSize: 220,
|
|
meta: {
|
|
headerTitle: 'Участник',
|
|
autoSize: true,
|
|
skeleton: (
|
|
<div className="flex min-w-0 items-center gap-2">
|
|
<Skeleton className="size-8 shrink-0 rounded-full" />
|
|
<div className="flex min-w-0 flex-col gap-0.5">
|
|
<Skeleton className="h-3.5 w-32" />
|
|
<Skeleton className="h-3 w-40" />
|
|
</div>
|
|
</div>
|
|
),
|
|
},
|
|
},
|
|
{
|
|
id: 'role',
|
|
accessorFn: (row) => (row.is_admin ? 'admin' : 'user'),
|
|
header: ({ column }) => (
|
|
<DataGridColumnHeader title="Роль" visibility column={column} />
|
|
),
|
|
cell: ({ row }) => <RoleBadge isAdmin={row.original.is_admin} />,
|
|
size: 130,
|
|
enableSorting: true,
|
|
enableHiding: true,
|
|
enableResizing: true,
|
|
meta: {
|
|
headerTitle: 'Роль',
|
|
skeleton: <Skeleton className="h-6 w-20 rounded-full" />,
|
|
},
|
|
},
|
|
{
|
|
id: 'apps',
|
|
accessorFn: (row) => row.apps.length,
|
|
header: ({ column }) => (
|
|
<DataGridColumnHeader title="Apps" visibility column={column} />
|
|
),
|
|
cell: ({ row }) => <AppTags apps={row.original.apps} />,
|
|
size: 160,
|
|
enableSorting: false,
|
|
enableHiding: true,
|
|
enableResizing: true,
|
|
meta: {
|
|
headerTitle: 'Apps',
|
|
skeleton: (
|
|
<div className="flex flex-wrap items-center gap-1">
|
|
<Skeleton className="h-5 w-12 rounded-full" />
|
|
<Skeleton className="h-5 w-12 rounded-full" />
|
|
</div>
|
|
),
|
|
},
|
|
},
|
|
{
|
|
id: 'status',
|
|
accessorFn: (row) => (row.disabled ? 'disabled' : 'active'),
|
|
header: ({ column }) => (
|
|
<DataGridColumnHeader title="Статус" visibility column={column} />
|
|
),
|
|
cell: ({ row }) => <StatusBadge disabled={row.original.disabled} />,
|
|
size: 130,
|
|
enableSorting: true,
|
|
enableHiding: true,
|
|
enableResizing: true,
|
|
meta: {
|
|
headerTitle: 'Статус',
|
|
skeleton: <Skeleton className="h-6 w-24 rounded-full" />,
|
|
},
|
|
},
|
|
{
|
|
id: 'auth',
|
|
header: ({ column }) => (
|
|
<DataGridColumnHeader title="Auth" visibility column={column} />
|
|
),
|
|
cell: () => (
|
|
<Badge variant="warning-outline">
|
|
<LockIcon aria-hidden="true" />
|
|
Локальный
|
|
</Badge>
|
|
),
|
|
size: 120,
|
|
enableSorting: false,
|
|
enableHiding: true,
|
|
enableResizing: true,
|
|
meta: {
|
|
headerTitle: 'Auth',
|
|
skeleton: <Skeleton className="h-5 w-20 rounded-full" />,
|
|
},
|
|
},
|
|
{
|
|
id: 'twoFactor',
|
|
header: ({ column }) => (
|
|
<DataGridColumnHeader title="2FA" visibility column={column} />
|
|
),
|
|
cell: () => (
|
|
<Badge variant="destructive-outline">
|
|
<TriangleAlertIcon aria-hidden="true" />
|
|
Выкл
|
|
</Badge>
|
|
),
|
|
size: 100,
|
|
enableSorting: false,
|
|
enableHiding: true,
|
|
enableResizing: true,
|
|
meta: {
|
|
headerTitle: '2FA',
|
|
skeleton: <Skeleton className="h-5 w-16 rounded-full" />,
|
|
},
|
|
},
|
|
{
|
|
accessorKey: 'last_login_at',
|
|
id: 'lastActive',
|
|
header: ({ column }) => (
|
|
<DataGridColumnHeader
|
|
title="Last Active"
|
|
visibility
|
|
column={column}
|
|
/>
|
|
),
|
|
cell: ({ row }) => (
|
|
<span className="text-muted-foreground text-sm">
|
|
{formatLastActive(row.original.last_login_at)}
|
|
</span>
|
|
),
|
|
size: 150,
|
|
enableSorting: true,
|
|
enableHiding: true,
|
|
enableResizing: true,
|
|
meta: {
|
|
headerTitle: 'Last Active',
|
|
skeleton: <Skeleton className="h-4 w-28" />,
|
|
},
|
|
},
|
|
{
|
|
accessorKey: 'last_login_ip',
|
|
id: 'lastIp',
|
|
header: ({ column }) => (
|
|
<DataGridColumnHeader title="Последний IP" visibility column={column} />
|
|
),
|
|
cell: ({ row }) => (
|
|
<span className="text-muted-foreground font-mono text-xs tabular-nums">
|
|
{row.original.last_login_ip ?? '—'}
|
|
</span>
|
|
),
|
|
size: 140,
|
|
enableSorting: true,
|
|
enableHiding: true,
|
|
enableResizing: true,
|
|
meta: {
|
|
headerTitle: 'Последний IP',
|
|
skeleton: <Skeleton className="h-4 w-24" />,
|
|
},
|
|
},
|
|
{
|
|
id: 'actions',
|
|
header: '',
|
|
cell: ({ row }) => (
|
|
<ActionsCell
|
|
row={row}
|
|
onOpenAudit={handlers.onOpenAudit}
|
|
onOpenAccess={handlers.onOpenAccess}
|
|
onDeactivate={handlers.onDeactivate}
|
|
onDelete={handlers.onDelete}
|
|
/>
|
|
),
|
|
size: 60,
|
|
enableSorting: false,
|
|
enableHiding: false,
|
|
enableResizing: false,
|
|
meta: {
|
|
skeleton: <Skeleton className="mx-auto size-7 rounded-md" />,
|
|
headerClassName:
|
|
'[--data-grid-header-cell-pe:var(--frame-panel-header-px)]',
|
|
cellClassName: '[--data-grid-body-cell-pe:var(--frame-panel-px)]',
|
|
},
|
|
},
|
|
]
|
|
}
|
|
|
|
export function AdminUsersGrid({
|
|
users,
|
|
isLoading = false,
|
|
onCreateClick,
|
|
onOpenAudit,
|
|
onOpenAccess,
|
|
onDeactivate,
|
|
onDelete,
|
|
onBulkSetRole,
|
|
onBulkDeactivate,
|
|
}: AdminUsersGridProps) {
|
|
const [pagination, setPagination] = useState<PaginationState>({
|
|
pageIndex: 0,
|
|
pageSize: 10,
|
|
})
|
|
const [sorting, setSorting] = useState<SortingState>([
|
|
{ id: 'name', desc: false },
|
|
])
|
|
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({})
|
|
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
|
|
const [filters, setFilters] = useState<Filter[]>(createDefaultFilters)
|
|
const [bulkRole, setBulkRole] = useState<BulkRole>('user')
|
|
const [filteredData, setFilteredData] = useState<AdminUser[]>(users)
|
|
const [filterLoading, setFilterLoading] = useState(false)
|
|
const isInitialLoad = useRef(true)
|
|
const lastAppliedActiveKey = useRef<string>(
|
|
serializeActiveFiltersKey(getActiveFilters(createDefaultFilters())),
|
|
)
|
|
|
|
const stats = useMemo(
|
|
() => ({
|
|
total: users.length,
|
|
admins: users.filter((u) => u.is_admin).length,
|
|
disabled: users.filter((u) => u.disabled).length,
|
|
}),
|
|
[users],
|
|
)
|
|
|
|
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',
|
|
searchable: false,
|
|
className: 'w-[160px]',
|
|
options: BULK_ROLE_OPTIONS.map((o) => ({
|
|
value: o.value,
|
|
label: o.label,
|
|
})),
|
|
customValueRenderer: (values) => {
|
|
const state = renderSelectedCount(values)
|
|
if (state) return state
|
|
return (
|
|
<RoleBadge isAdmin={(values[0] as BulkRole) === 'admin'} />
|
|
)
|
|
},
|
|
},
|
|
{
|
|
key: 'status',
|
|
label: 'Статус',
|
|
icon: <CircleDotIcon className="size-3.5" aria-hidden />,
|
|
type: 'select',
|
|
searchable: false,
|
|
className: 'w-[150px]',
|
|
options: [
|
|
{ value: 'active', label: 'Активен' },
|
|
{ value: 'disabled', label: 'Отключён' },
|
|
],
|
|
customValueRenderer: (values) => {
|
|
const state = renderSelectedCount(values)
|
|
if (state) return state
|
|
return (
|
|
<StatusBadge disabled={(values[0] as string) === 'disabled'} />
|
|
)
|
|
},
|
|
},
|
|
],
|
|
[],
|
|
)
|
|
|
|
const applyFilters = useCallback(
|
|
(newFilters: Filter[]) => applyFiltersToUsers(users, newFilters),
|
|
[users],
|
|
)
|
|
|
|
const simulateAsyncFiltering = useCallback(
|
|
async (newFilters: Filter[]) => {
|
|
setFilterLoading(true)
|
|
await new Promise((resolve) => setTimeout(resolve, 200))
|
|
setFilteredData(applyFilters(newFilters))
|
|
setFilterLoading(false)
|
|
},
|
|
[applyFilters],
|
|
)
|
|
|
|
const handleFiltersChange = useCallback(
|
|
(newFilters: Filter[]) => {
|
|
setFilters(newFilters)
|
|
const newActive = getActiveFilters(newFilters)
|
|
const nextKey = serializeActiveFiltersKey(newActive)
|
|
if (nextKey === lastAppliedActiveKey.current) return
|
|
lastAppliedActiveKey.current = nextKey
|
|
setPagination((prev) => ({ ...prev, pageIndex: 0 }))
|
|
setRowSelection({})
|
|
void simulateAsyncFiltering(newFilters)
|
|
},
|
|
[simulateAsyncFiltering],
|
|
)
|
|
|
|
useEffect(() => {
|
|
if (isInitialLoad.current) {
|
|
setFilteredData(applyFilters(filters))
|
|
isInitialLoad.current = false
|
|
return
|
|
}
|
|
setFilteredData(applyFilters(filters))
|
|
lastAppliedActiveKey.current = serializeActiveFiltersKey(
|
|
getActiveFilters(filters),
|
|
)
|
|
}, [users, filters, applyFilters])
|
|
|
|
const columns = useMemo(
|
|
() =>
|
|
createAdminUserColumns({
|
|
onOpenAudit,
|
|
onOpenAccess,
|
|
onDeactivate,
|
|
onDelete,
|
|
}),
|
|
[onOpenAudit, onOpenAccess, onDeactivate, onDelete],
|
|
)
|
|
|
|
const [columnOrder, setColumnOrder] = useState<string[]>(
|
|
() => columns.map((c) => c.id as string),
|
|
)
|
|
|
|
const table = useReactTable({
|
|
columns,
|
|
data: filteredData,
|
|
pageCount: Math.ceil(filteredData.length / pagination.pageSize),
|
|
getRowId: (row) => row.id,
|
|
state: {
|
|
pagination,
|
|
sorting,
|
|
columnOrder,
|
|
columnVisibility,
|
|
rowSelection,
|
|
},
|
|
enableRowSelection: true,
|
|
columnResizeMode: 'onChange',
|
|
onColumnOrderChange: setColumnOrder,
|
|
onColumnVisibilityChange: setColumnVisibility,
|
|
onPaginationChange: setPagination,
|
|
onSortingChange: setSorting,
|
|
onRowSelectionChange: setRowSelection,
|
|
getCoreRowModel: getCoreRowModel(),
|
|
getFilteredRowModel: getFilteredRowModel(),
|
|
getPaginationRowModel: getPaginationRowModel(),
|
|
getSortedRowModel: getSortedRowModel(),
|
|
})
|
|
|
|
const selectedCount = table.getSelectedRowModel().rows.length
|
|
const selectedIds = table
|
|
.getSelectedRowModel()
|
|
.rows.map((row) => row.original.id)
|
|
const showClearButton = filters.length > 0
|
|
const gridLoading = isLoading || filterLoading
|
|
|
|
const handleClearSelection = useCallback(() => setRowSelection({}), [])
|
|
|
|
const handleChangeRole = useCallback(() => {
|
|
if (selectedCount === 0) return
|
|
onBulkSetRole(selectedIds, bulkRole === 'admin')
|
|
setRowSelection({})
|
|
}, [bulkRole, onBulkSetRole, selectedCount, selectedIds])
|
|
|
|
const handleBulkDeactivate = useCallback(() => {
|
|
if (selectedCount === 0) return
|
|
onBulkDeactivate(selectedIds)
|
|
setRowSelection({})
|
|
}, [onBulkDeactivate, selectedCount, selectedIds])
|
|
|
|
const handleClearFilters = useCallback(() => {
|
|
const next = createDefaultFilters()
|
|
lastAppliedActiveKey.current = serializeActiveFiltersKey(
|
|
getActiveFilters(next),
|
|
)
|
|
setFilters(next)
|
|
setRowSelection({})
|
|
void simulateAsyncFiltering(next)
|
|
}, [simulateAsyncFiltering])
|
|
|
|
return (
|
|
<TooltipProvider delay={200}>
|
|
<DataGrid
|
|
table={table}
|
|
isLoading={gridLoading}
|
|
loadingMode="skeleton"
|
|
recordCount={filteredData.length}
|
|
onRowClick={onOpenAudit}
|
|
emptyMessage={
|
|
!gridLoading && filteredData.length === 0
|
|
? 'Нет пользователей по текущим фильтрам. Сбросьте фильтры или измените условия.'
|
|
: undefined
|
|
}
|
|
tableLayout={{
|
|
columnsResizable: true,
|
|
columnsMovable: true,
|
|
columnsVisibility: true,
|
|
headerSticky: true,
|
|
dense: true,
|
|
}}
|
|
>
|
|
<Frame spacing="sm" className="w-full">
|
|
<FrameHeader className="flex-row items-center justify-between gap-3">
|
|
<div className="flex flex-col gap-0.5">
|
|
<FrameTitle id="page-heading" className="text-balance">
|
|
Пользователи
|
|
</FrameTitle>
|
|
<FrameDescription className="flex items-center gap-1.5 text-xs text-pretty">
|
|
<span>{stats.total} учётных записей</span>
|
|
<DotSeparator />
|
|
<span>{stats.admins} админов</span>
|
|
{stats.disabled > 0 ? (
|
|
<>
|
|
<DotSeparator />
|
|
<span>{stats.disabled} отключено</span>
|
|
</>
|
|
) : null}
|
|
</FrameDescription>
|
|
</div>
|
|
<Button type="button" size="default" onClick={onCreateClick}>
|
|
<UserPlusIcon aria-hidden="true" />
|
|
Создать
|
|
</Button>
|
|
</FrameHeader>
|
|
<FramePanel className="p-0 shadow-none">
|
|
<div className="flex flex-wrap items-center justify-between gap-2 px-(--frame-panel-header-px) py-(--frame-panel-header-py)">
|
|
<Filters
|
|
filters={filters}
|
|
fields={filterFields}
|
|
onChange={handleFiltersChange}
|
|
size="default"
|
|
trigger={
|
|
<Button
|
|
type="button"
|
|
size="default"
|
|
variant="outline"
|
|
aria-label="Фильтры"
|
|
>
|
|
<FilterIcon aria-hidden />
|
|
Filters
|
|
</Button>
|
|
}
|
|
/>
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
{showClearButton ? (
|
|
<Button
|
|
type="button"
|
|
size="default"
|
|
variant="outline"
|
|
className="shrink-0"
|
|
onClick={handleClearFilters}
|
|
disabled={gridLoading}
|
|
>
|
|
<FunnelXIcon aria-hidden />
|
|
Clear
|
|
</Button>
|
|
) : null}
|
|
<DataGridColumnVisibility
|
|
table={table}
|
|
trigger={
|
|
<Button
|
|
type="button"
|
|
size="default"
|
|
variant="outline"
|
|
aria-label="Настройки таблицы"
|
|
>
|
|
<Settings2Icon aria-hidden="true" />
|
|
View settings
|
|
</Button>
|
|
}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<Separator />
|
|
{selectedCount > 0 ? (
|
|
<>
|
|
<AdminUsersBulkActionBar
|
|
selectedCount={selectedCount}
|
|
roleValue={bulkRole}
|
|
onRoleChange={setBulkRole}
|
|
onChangeRole={handleChangeRole}
|
|
onDeactivate={handleBulkDeactivate}
|
|
onClear={handleClearSelection}
|
|
/>
|
|
<Separator />
|
|
</>
|
|
) : null}
|
|
<DataGridScrollArea>
|
|
<DataGridTable />
|
|
</DataGridScrollArea>
|
|
<Separator />
|
|
<FrameFooter>
|
|
<DataGridPagination sizes={[10, 20, 30]} />
|
|
</FrameFooter>
|
|
</FramePanel>
|
|
</Frame>
|
|
</DataGrid>
|
|
</TooltipProvider>
|
|
)
|
|
}
|