From fe5d58b13b3081f9b71fcac7778e1f27704dd36c Mon Sep 17 00:00:00 2001 From: Denozordec Date: Sun, 18 Oct 2026 00:17:43 +0700 Subject: [PATCH] =?UTF-8?q?feat(design-preview):=20=D0=BA=D0=B0=D1=82?= =?UTF-8?q?=D0=B0=D0=BB=D0=BE=D0=B3=20=D0=BF=D0=BE=D0=BB=D1=8C=D0=B7=D0=BE?= =?UTF-8?q?=D0=B2=D0=B0=D1=82=D0=B5=D0=BB=D0=B5=D0=B9=20=D0=B8=20=D0=BC?= =?UTF-8?q?=D0=B0=D1=82=D1=80=D0=B8=D1=86=D0=B0=20=D0=B4=D0=BE=D1=81=D1=82?= =?UTF-8?q?=D1=83=D0=BF=D0=B0=20=D0=BA=D0=B0=D0=BA=20=D0=B2=20=D0=BA=D0=B0?= =?UTF-8?q?=D0=BD=D0=B2=D0=B0=D1=81=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Колонки: участник, статус, роль, приложения, passkey, активность · IP; виды по статусу, массовое включение/отключение, меню строки, главная кнопка «Создать пользователя» в шапке страницы, вкладка «Доступ по приложениям». Co-Authored-By: Claude Sonnet 5.5 --- apps/web/src/design-preview/mock.ts | 11 ++ .../src/design-preview/pages/users-page.tsx | 91 ++++++++---- apps/web/src/design-preview/users-grid.tsx | 139 ++++++++++++++++++ 3 files changed, 212 insertions(+), 29 deletions(-) create mode 100644 apps/web/src/design-preview/users-grid.tsx diff --git a/apps/web/src/design-preview/mock.ts b/apps/web/src/design-preview/mock.ts index 893d342..ca21d51 100644 --- a/apps/web/src/design-preview/mock.ts +++ b/apps/web/src/design-preview/mock.ts @@ -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 = { + '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' }, +} diff --git a/apps/web/src/design-preview/pages/users-page.tsx b/apps/web/src/design-preview/pages/users-page.tsx index f12eccf..3e65c30 100644 --- a/apps/web/src/design-preview/pages/users-page.tsx +++ b/apps/web/src/design-preview/pages/users-page.tsx @@ -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={ + + } /> - setCreateOpen(true)} - onOpenAudit={onOpenAudit} - onOpenAccess={onOpenAccess} - onDeactivate={onDeactivate} - onDelete={onDelete} - onResetPasskeys={onResetPasskeys} - onBulkSetRole={onBulkSetRole} - onBulkDeactivate={onBulkDeactivate} - /> + + + Участники · {users.length} + Доступ по приложениям · {ALL_APP_IDS.length} + + + + + + r.id} + /> + + @@ -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[] = [ + { id: 'app', title: 'Приложение', size: 240, sortValue: (r) => r.name, cell: (r) => }, + { id: 'state', title: 'Статус', size: 160, cell: (r) => {r.tone === 'wn' ? 'Мало админов' : 'Выдан'} }, + { id: 'total', title: 'Пользователей', size: 140, sortValue: (r) => r.total, cell: (r) => {r.total} }, + { id: 'read', title: 'Чтение', size: 100, cell: (r) => {r.read} }, + { id: 'write', title: 'Запись', size: 100, cell: (r) => {r.write} }, + { id: 'admin', title: 'Админ', size: 100, cell: (r) => {r.admin} }, +] diff --git a/apps/web/src/design-preview/users-grid.tsx b/apps/web/src/design-preview/users-grid.tsx new file mode 100644 index 0000000..8eb717e --- /dev/null +++ b/apps/web/src/design-preview/users-grid.tsx @@ -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[]>( + () => [ + { + id: 'name', + title: 'Участник', + size: 280, + sortValue: (u) => u.email, + cell: (u) => , + }, + { id: 'status', title: 'Статус', size: 140, cell: (u) => {userStatus(u).label} }, + { id: 'role', title: 'Роль', size: 130, sortValue: (u) => (u.is_admin ? 'a' : 'u'), cell: (u) => {u.is_admin ? 'Админ' : 'Пользователь'} }, + { id: 'apps', title: 'Приложения', size: 220, cell: (u) => {appsSummary(u)} }, + { id: 'passkey', title: 'Passkey', size: 100, sortValue: (u) => u.passkey_count, cell: (u) => {passkeyLabel(u.passkey_count)} }, + { id: 'activity', title: 'Активность · IP', size: 240, cell: (u) => {USER_META[u.id]?.activity ?? '—'} }, + { + id: 'menu', + title: '', + size: 56, + cell: (u) => ( +
e.stopPropagation()}> + + } + > + + + + handlers.onOpenAccess(u)}> + + Права доступа + + handlers.onOpenJournal(u)}> + + Журнал + + handlers.onToggleDisabled(u)}> + {u.disabled ? : } + {u.disabled ? 'Включить' : 'Отключить'} + + handlers.onResetPasskeys(u)}> + + Сбросить passkeys… + + + handlers.onDelete(u)}> + + Удалить… + + + +
+ ), + }, + ], + [handlers], + ) + + return ( + 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) => ( + <> + + + + )} + /> + ) +}