feat(design-preview): каталог пользователей и матрица доступа как в канвасе
Колонки: участник, статус, роль, приложения, passkey, активность · IP; виды по статусу, массовое включение/отключение, меню строки, главная кнопка «Создать пользователя» в шапке страницы, вкладка «Доступ по приложениям». Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
This commit is contained in:
1 parent
67a0510eeb
commit
fe5d58b13b
3 files changed
+212
-29
No files matched your search
@@ -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> },
|
||||
]
|
||||
@@ -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>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user