feat(design-preview): каталог пользователей и матрица доступа как в канвасе

Колонки: участник, статус, роль, приложения, passkey, активность · IP; виды по статусу,
массовое включение/отключение, меню строки, главная кнопка «Создать пользователя» в шапке
страницы, вкладка «Доступ по приложениям».

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
This commit is contained in:
DenozordecandClaude Sonnet 5.5 committed 2026-10-18 00:17:43 +07:00
1 parent 67a0510eeb
commit fe5d58b13b
3 files changed
+212 -29

No files matched your search

+11
View File
@@ -436,3 +436,14 @@ export const OIDC_CAPABILITIES: Array<{ label: string; value: string }> = [
export const MOCK_CLIENT_SECRET = 'kP9x-3vQ_t7Lw2Zr0mYdN8aUe5HcJf1B'
export const MOCK_SWITCHER = defaultAppSwitcherConfig()
/** Подписи и активность строк каталога — как в канвасе. */
export const USER_META: Record<string, { sub: string; activity: string }> = {
'u-1': { sub: 'Admin · создан 01.02.2026', activity: 'сегодня 09:41 · 203.0.113.10' },
'u-2': { sub: 'Денис · создан 01.02.2026', activity: 'вчера 22:15 · 198.51.100.4' },
'u-3': { sub: 'Operations · создан 14.03.2026', activity: '14:02 · 203.0.113.58' },
'u-4': { sub: 'Анна · создан 02.05.2026', activity: 'вчера 18:40 · 192.0.2.7' },
'u-5': { sub: 'Павел · создан 19.06.2026', activity: '5 дн назад · 192.0.2.19' },
'u-6': { sub: 'Сервисная запись · OIDC', activity: '3 ч назад · 10.0.0.5' },
'u-7': { sub: 'Бывший сотрудник · отключён 12.10', activity: '15 дн назад · 192.0.2.88' },
}
@@ -3,15 +3,19 @@ import { useCallback, useEffect, useRef, useState } from 'react'
import { Link } from '@tanstack/react-router'
import { toast } from 'sonner'
import type { AdminUser, CreateUserRequest } from '@authportal/shared'
import { AdminUsersGrid } from '@/components/reui-kit/admin-users-grid'
import { CreateUserSheet } from '@/components/reui-kit/create-user-sheet'
import { KpiStatGrid } from '@/components/reui-kit/kpi-stat-grid'
import { Button } from '@authportal/ui/components/button'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@authportal/ui/components/tabs'
import { UserPlusIcon } from 'lucide-react'
import { AccessDialog } from '../access-dialog'
import { diffAccess, type AccessValue } from '../access-model'
import { CodeBlock, KvList, PageHead, PreviewSheet, SignalAlert, TypedConfirm } from '../bits'
import { kpi } from '../kpi'
import { MOCK_USERS, USER_JOURNAL } from '../mock'
import { ALL_APP_IDS, APP_SHORT, MOCK_USERS, USER_JOURNAL, type Tone } from '../mock'
import { PreviewSectionGrid, TwoLineCell, type GridColumn } from '../section-grid'
import { StatusBadge } from '../bits'
import { UsersGrid, type UsersGridHandlers } from '../users-grid'
import { usePreviewStore } from '../preview-store'
export function UsersPage() {
@@ -54,21 +58,6 @@ export function UsersPage() {
[patch],
)
const onBulkDeactivate = useCallback(
(ids: string[]) =>
patch(ids, (u) => ({ ...u, disabled: true }), `Отключено пользователей: ${ids.length}`, () => ['− disabled: false', '+ disabled: true']),
[patch],
)
const onBulkSetRole = useCallback(
(ids: string[], isAdmin: boolean) =>
patch(ids, (u) => ({ ...u, is_admin: isAdmin }), `Роль «${isAdmin ? 'Админ' : 'Пользователь'}» для ${ids.length}`, (b) => [
`− is_admin: ${String(b.is_admin)}`,
`+ is_admin: ${String(isAdmin)}`,
]),
[patch],
)
const onCreate = useCallback(
(values: CreateUserRequest) => {
const created: AdminUser = {
@@ -103,8 +92,28 @@ export function UsersPage() {
const onOpenAudit = useCallback((user: AdminUser) => setJournalFor(user), [])
const onOpenAccess = useCallback((user: AdminUser) => setAccessFor(user), [])
const handlers: UsersGridHandlers = {
onOpenAccess: onOpenAccess,
onOpenJournal: onOpenAudit,
onToggleDisabled: onDeactivate,
onResetPasskeys,
onDelete,
onBulkDisabled: (ids, disabled) =>
patch(ids, (u) => ({ ...u, disabled }), `${disabled ? 'Отключено' : 'Включено'} пользователей: ${ids.length}`, () => [
`− disabled: ${String(!disabled)}`,
`+ disabled: ${String(disabled)}`,
]),
}
const matrix: MatrixRow[] = ALL_APP_IDS.map((id) => {
const mine = users.filter((u) => u.apps.includes(id))
const level = (action: string) => mine.filter((u) => u.is_admin || u.permissions.some((k) => k.startsWith(`${id}:`) && k.endsWith(`:${action}`))).length
const admins = mine.filter((u) => u.is_admin || u.permissions.some((k) => k.startsWith(`${id}:`) && k.endsWith(':admin'))).length
return { id, name: APP_SHORT[id], total: mine.length, read: level('read'), write: level('write'), admin: admins, tone: admins < 2 ? 'wn' : 'ok' }
})
const admins = users.filter((u) => u.is_admin).length
const noPasskey = users.filter((u) => u.passkey_count === 0).length
const noPasskey = users.filter((u) => !u.disabled && u.passkey_count === 0).length
const disabled = users.filter((u) => u.disabled).length
return (
@@ -113,6 +122,12 @@ export function UsersPage() {
title="Пользователи"
sub="Каталог учётных записей · роли, приложения, passkeys · изменения прав записываются в журнал"
source={`GET /api/v1/admin/users · ${users.length} учётных записей`}
actions={
<Button onClick={() => setCreateOpen(true)}>
<UserPlusIcon aria-hidden />
Создать пользователя
</Button>
}
/>
<SignalAlert
tone="er"
@@ -134,17 +149,24 @@ export function UsersPage() {
kpi('off', 'Отключённых', String(disabled), { icon: UserXIcon, badge: 'вход закрыт', sub: 'не удаляются' }),
]}
/>
<AdminUsersGrid
users={users}
onCreateClick={() => setCreateOpen(true)}
onOpenAudit={onOpenAudit}
onOpenAccess={onOpenAccess}
onDeactivate={onDeactivate}
onDelete={onDelete}
onResetPasskeys={onResetPasskeys}
onBulkSetRole={onBulkSetRole}
onBulkDeactivate={onBulkDeactivate}
/>
<Tabs defaultValue="users" className="min-w-0">
<TabsList>
<TabsTrigger value="users">Участники · {users.length}</TabsTrigger>
<TabsTrigger value="matrix">Доступ по приложениям · {ALL_APP_IDS.length}</TabsTrigger>
</TabsList>
<TabsContent value="users" className="min-w-0">
<UsersGrid users={users} handlers={handlers} />
</TabsContent>
<TabsContent value="matrix" className="min-w-0">
<PreviewSectionGrid
title="Матрица доступа"
description="Сколько пользователей имеет доступ к каждому приложению и с каким уровнем"
rows={matrix}
columns={matrixColumns}
getRowId={(r) => r.id}
/>
</TabsContent>
</Tabs>
<CreateUserSheet open={createOpen} onOpenChange={setCreateOpen} pending={false} error={null} onSubmit={onCreate} />
@@ -230,3 +252,14 @@ export function UsersPage() {
</>
)
}
type MatrixRow = { id: string; name: string; total: number; read: number; write: number; admin: number; tone: Tone }
const matrixColumns: GridColumn<MatrixRow>[] = [
{ id: 'app', title: 'Приложение', size: 240, sortValue: (r) => r.name, cell: (r) => <TwoLineCell dot={r.tone} title={r.name} sub={r.id} /> },
{ id: 'state', title: 'Статус', size: 160, cell: (r) => <StatusBadge tone={r.tone}>{r.tone === 'wn' ? 'Мало админов' : 'Выдан'}</StatusBadge> },
{ id: 'total', title: 'Пользователей', size: 140, sortValue: (r) => r.total, cell: (r) => <span className="text-sm tabular-nums">{r.total}</span> },
{ id: 'read', title: 'Чтение', size: 100, cell: (r) => <span className="text-sm tabular-nums">{r.read}</span> },
{ id: 'write', title: 'Запись', size: 100, cell: (r) => <span className="text-sm tabular-nums">{r.write}</span> },
{ id: 'admin', title: 'Админ', size: 100, cell: (r) => <span className="text-sm tabular-nums">{r.admin}</span> },
]
+139
View File
@@ -0,0 +1,139 @@
import { useMemo } from 'react'
import {
MoreHorizontalIcon,
PauseCircleIcon,
PlayCircleIcon,
ScrollTextIcon,
ShieldCheckIcon,
Trash2Icon,
FingerprintIcon,
} from 'lucide-react'
import type { AdminUser } from '@authportal/shared'
import { Button } from '@authportal/ui/components/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@authportal/ui/components/dropdown-menu'
import { StatusBadge } from './bits'
import { ALL_APP_IDS, APP_SHORT, USER_META, type Tone } from './mock'
import { PreviewSectionGrid, TwoLineCell, type GridColumn } from './section-grid'
function userStatus(u: AdminUser): { tone: Tone; label: string } {
if (u.disabled) return { tone: 'mu', label: 'Отключён' }
if (u.passkey_count === 0) return { tone: 'wn', label: 'Без passkey' }
return { tone: 'ok', label: 'Активен' }
}
function appsSummary(u: AdminUser): string {
if (u.apps.length === 0) return '—'
if (u.apps.length === ALL_APP_IDS.length) return `Все ${ALL_APP_IDS.length}`
const names = u.apps.slice(0, 3).map((id) => (id === 'mm' ? 'MM' : APP_SHORT[id]))
return u.apps.length > 3 ? `${names.join(', ')} +${u.apps.length - 3}` : names.join(', ')
}
function passkeyLabel(n: number) {
if (n === 0) return 'нет'
return n === 1 ? '1 ключ' : `${n} ключа`
}
export type UsersGridHandlers = {
onOpenAccess: (u: AdminUser) => void
onOpenJournal: (u: AdminUser) => void
onToggleDisabled: (u: AdminUser) => void
onResetPasskeys: (u: AdminUser) => void
onDelete: (u: AdminUser) => void
onBulkDisabled: (ids: string[], disabled: boolean) => void
}
/** Каталог пользователей по канвасу: участник, статус, роль, приложения, passkey, активность. */
export function UsersGrid({ users, handlers }: { users: AdminUser[]; handlers: UsersGridHandlers }) {
const columns = useMemo<GridColumn<AdminUser>[]>(
() => [
{
id: 'name',
title: 'Участник',
size: 280,
sortValue: (u) => u.email,
cell: (u) => <TwoLineCell dot={userStatus(u).tone} title={u.email} sub={USER_META[u.id]?.sub ?? u.name} />,
},
{ id: 'status', title: 'Статус', size: 140, cell: (u) => <StatusBadge tone={userStatus(u).tone}>{userStatus(u).label}</StatusBadge> },
{ id: 'role', title: 'Роль', size: 130, sortValue: (u) => (u.is_admin ? 'a' : 'u'), cell: (u) => <span className="text-sm">{u.is_admin ? 'Админ' : 'Пользователь'}</span> },
{ id: 'apps', title: 'Приложения', size: 220, cell: (u) => <span className="block truncate text-sm">{appsSummary(u)}</span> },
{ id: 'passkey', title: 'Passkey', size: 100, sortValue: (u) => u.passkey_count, cell: (u) => <span className="text-sm">{passkeyLabel(u.passkey_count)}</span> },
{ id: 'activity', title: 'Активность · IP', size: 240, cell: (u) => <span className="font-mono text-[13px] whitespace-nowrap">{USER_META[u.id]?.activity ?? '—'}</span> },
{
id: 'menu',
title: '',
size: 56,
cell: (u) => (
<div onClick={(e) => e.stopPropagation()}>
<DropdownMenu>
<DropdownMenuTrigger
render={<Button type="button" variant="ghost" size="icon-sm" aria-label={`Действия: ${u.email}`} />}
>
<MoreHorizontalIcon aria-hidden />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-52">
<DropdownMenuItem onClick={() => handlers.onOpenAccess(u)}>
<ShieldCheckIcon aria-hidden />
Права доступа
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handlers.onOpenJournal(u)}>
<ScrollTextIcon aria-hidden />
Журнал
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handlers.onToggleDisabled(u)}>
{u.disabled ? <PlayCircleIcon aria-hidden /> : <PauseCircleIcon aria-hidden />}
{u.disabled ? 'Включить' : 'Отключить'}
</DropdownMenuItem>
<DropdownMenuItem disabled={u.passkey_count === 0} onClick={() => handlers.onResetPasskeys(u)}>
<FingerprintIcon aria-hidden />
Сбросить passkeys…
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive" onClick={() => handlers.onDelete(u)}>
<Trash2Icon aria-hidden />
Удалить…
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
),
},
],
[handlers],
)
return (
<PreviewSectionGrid
title="Пользователи портала"
description="Поиск по email и имени · виды по статусу · строка открывает права доступа"
rows={users}
columns={columns}
getRowId={(u) => u.id}
selectable
searchText={(u) => `${u.email} ${u.name}`}
searchPlaceholder="Email или имя"
views={[
{ label: 'Все', match: () => true },
{ label: 'Активные', match: (u) => !u.disabled },
{ label: 'Без passkey', match: (u) => !u.disabled && u.passkey_count === 0 },
{ label: 'Отключённые', match: (u) => u.disabled },
]}
onRowClick={handlers.onOpenAccess}
bulkActions={(ids, clear) => (
<>
<Button type="button" size="sm" variant="outline" onClick={() => { handlers.onBulkDisabled(ids, false); clear() }}>
Включить
</Button>
<Button type="button" size="sm" variant="outline" onClick={() => { handlers.onBulkDisabled(ids, true); clear() }}>
Отключить
</Button>
</>
)}
/>
)
}