feat(web): страница администратора — пользователи, роли, матрица прав
Маршрут /admin (вкладки «Пользователи» и «Матрица прав») и пункт «Система → Пользователи и роли» в меню; без USERS_ADMIN в /me — экран «Нет доступа», таблицы не монтируются и запросов к /admin/* нет, пункт меню скрыт. Почему без Redux: данные экрана нужны только ему, после каждой правки перечитываются с сервера — хватает локального state и маленького хука useAsyncData (отбрасывает устаревшие ответы и ответы после ухода со страницы). Клиент один — api/client.ts (Bearer из sessionStorage, ошибки сервера -> ApiError с кодом и текстом); ошибки показываются через App.useApp().message (корень обёрнут в antd App). Экран показывает только серверное состояние: при отказе (409 LAST_ADMIN и др.) — уведомление и перечитывание, матрица сохраняет роли по очереди. РАИСА появится в auth/: токен даст SSO вместо dev-входа; этот экран и клиент не меняются. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
This commit is contained in:
parent
a7502f6315
commit
d3c2f8ab1f
@ -244,9 +244,9 @@ export const api = { get: <T>(p: string) => apiFetch<T>(p), post: <T>(p: string,
|
|||||||
- `PermissionMatrix.tsx`: `GET /admin/roles`, `GET /admin/permissions`; `Table` строки = права (код, имя), колонки = роли, ячейки `Checkbox`; локальный state изменений; кнопка «Сохранить» → для каждой изменённой роли `PUT /admin/roles/{code}/permissions`; после — перезагрузка; системные роли помечены тегом.
|
- `PermissionMatrix.tsx`: `GET /admin/roles`, `GET /admin/permissions`; `Table` строки = права (код, имя), колонки = роли, ячейки `Checkbox`; локальный state изменений; кнопка «Сохранить» → для каждой изменённой роли `PUT /admin/roles/{code}/permissions`; после — перезагрузка; системные роли помечены тегом.
|
||||||
- Маршрут `/admin` в `App.tsx` внутри `AuthGate`; Sidebar: группа `{ title: 'Система', key: 'system', children: [{ link: '/admin', icon: <TeamOutlined />, label: 'Пользователи и роли' }] }`.
|
- Маршрут `/admin` в `App.tsx` внутри `AuthGate`; Sidebar: группа `{ title: 'Система', key: 'system', children: [{ link: '/admin', icon: <TeamOutlined />, label: 'Пользователи и роли' }] }`.
|
||||||
|
|
||||||
- [ ] **Step 1:** реализовать; **Step 2:** `npx biome check src && npx tsc --noEmit && npm run build` → PASS; ручной сценарий: вход админом, создать пользователя с ролью, сменить роль, снять галочку в матрице и сохранить; убедиться, что ошибка 409 `LAST_ADMIN` показывается уведомлением.
|
- [x] **Step 1:** реализовать; **Step 2:** `npx biome check src && npx tsc --noEmit && npm run build` → PASS; ручной сценарий: вход админом, создать пользователя с ролью, сменить роль, снять галочку в матрице и сохранить; убедиться, что ошибка 409 `LAST_ADMIN` показывается уведомлением.
|
||||||
- [ ] **Step 3:** самостоятельное ревью TS/React (пользователь слаб во фронте): в описание коммита — 3–5 строк, почему без Redux, как устроен клиент, где появится РАИСА.
|
- [x] **Step 3:** самостоятельное ревью TS/React (пользователь слаб во фронте): в описание коммита — 3–5 строк, почему без Redux, как устроен клиент, где появится РАИСА.
|
||||||
- [ ] **Step 4: Commit** `feat(web): страница администратора — пользователи, роли, матрица прав`.
|
- [x] **Step 4: Commit** `feat(web): страница администратора — пользователи, роли, матрица прав`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@ -72,7 +72,7 @@ test-integration`). Пути — от корня репозитория. Доп
|
|||||||
`sessionStorage`, 401 → очистка токена и переход на вход, ошибка `{detail}` → `Error` с текстом), `web/src/api/types.ts`
|
`sessionStorage`, 401 → очистка токена и переход на вход, ошибка `{detail}` → `Error` с текстом), `web/src/api/types.ts`
|
||||||
(DTO `/me`, `/admin/*`), `web/src/auth/DevLogin.tsx` (поле email → `POST /dev/token`, только при `process.env.NODE_ENV !==
|
(DTO `/me`, `/admin/*`), `web/src/auth/DevLogin.tsx` (поле email → `POST /dev/token`, только при `process.env.NODE_ENV !==
|
||||||
'production'`), `web/src/auth/useMe.ts` (загрузка `/me`, права). Проверка: `make web-check`; ручной сценарий в README.
|
'production'`), `web/src/auth/useMe.ts` (загрузка `/me`, права). Проверка: `make web-check`; ручной сценарий в README.
|
||||||
- [ ] 4.3 **Фронт: страница администратора.** `web/src/layouts/Admin.tsx`: antd `Table` пользователей (email, ФИО, филиал, роли —
|
- [x] 4.3 **Фронт: страница администратора.** `web/src/layouts/Admin.tsx`: antd `Table` пользователей (email, ФИО, филиал, роли —
|
||||||
`Select mode="multiple"` с сохранением по `PUT roles`, переключатель `is_active`), `Modal` создания (email, ФИО, филиал из
|
`Select mode="multiple"` с сохранением по `PUT roles`, переключатель `is_active`), `Modal` создания (email, ФИО, филиал из
|
||||||
`/directories/regional-offices`, роли), таблица «роль × право» с `Checkbox` и кнопкой «Сохранить» (`PUT roles/{code}/permissions`),
|
`/directories/regional-offices`, роли), таблица «роль × право» с `Checkbox` и кнопкой «Сохранить» (`PUT roles/{code}/permissions`),
|
||||||
`message.error` на ошибки API, экран «нет доступа» без `USERS_ADMIN`; маршрут `/admin` в `App.tsx`, пункт «Система →
|
`message.error` на ошибки API, экран «нет доступа» без `USERS_ADMIN`; маршрут `/admin` в `App.tsx`, пункт «Система →
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import { Provider } from 'react-redux';
|
|||||||
import { BrowserRouter, Route, Routes } from 'react-router-dom';
|
import { BrowserRouter, Route, Routes } from 'react-router-dom';
|
||||||
import { AuthGate } from './auth/AuthGate';
|
import { AuthGate } from './auth/AuthGate';
|
||||||
import { Config } from './conf/config';
|
import { Config } from './conf/config';
|
||||||
|
import { Admin } from './layouts/Admin';
|
||||||
import { Main } from './layouts/Main';
|
import { Main } from './layouts/Main';
|
||||||
import { store } from './store/store';
|
import { store } from './store/store';
|
||||||
import { Sidebar } from './widgets/Sidebar/Sidebar';
|
import { Sidebar } from './widgets/Sidebar/Sidebar';
|
||||||
@ -19,6 +20,7 @@ function App() {
|
|||||||
<Layout.Content>
|
<Layout.Content>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path='/' element={<Main />} />
|
<Route path='/' element={<Main />} />
|
||||||
|
<Route path='/admin' element={<Admin />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</Layout.Content>
|
</Layout.Content>
|
||||||
</Layout>
|
</Layout>
|
||||||
|
|||||||
3
web/src/auth/permissions.ts
Normal file
3
web/src/auth/permissions.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
// Коды прав, от которых зависит интерфейс (как в `GET /me` → `permissions`). Доступ на самом деле решает сервер:
|
||||||
|
// проверки на клиенте только прячут то, что пользователю всё равно не разрешат.
|
||||||
|
export const USERS_ADMIN = 'USERS_ADMIN';
|
||||||
@ -1,6 +1,6 @@
|
|||||||
import { createRoot } from 'react-dom/client';
|
import { createRoot } from 'react-dom/client';
|
||||||
import './index.css';
|
import './index.css';
|
||||||
import { ConfigProvider } from 'antd';
|
import { App as AntApp, ConfigProvider } from 'antd';
|
||||||
import App from './App';
|
import App from './App';
|
||||||
|
|
||||||
const rootElement = document.getElementById('root');
|
const rootElement = document.getElementById('root');
|
||||||
@ -35,7 +35,10 @@ createRoot(rootElement).render(
|
|||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
{/* AntApp даёт message/notification через App.useApp() с темой ConfigProvider; component={false} — без лишнего div */}
|
||||||
|
<AntApp component={false}>
|
||||||
<App />
|
<App />
|
||||||
|
</AntApp>
|
||||||
</ConfigProvider>,
|
</ConfigProvider>,
|
||||||
// </StrictMode>,
|
// </StrictMode>,
|
||||||
);
|
);
|
||||||
|
|||||||
32
web/src/layouts/Admin.tsx
Normal file
32
web/src/layouts/Admin.tsx
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
import { Result, Tabs } from 'antd';
|
||||||
|
import { useCurrentUser } from '../auth/MeContext';
|
||||||
|
import { USERS_ADMIN } from '../auth/permissions';
|
||||||
|
import { PermissionMatrix } from '../widgets/Admin/PermissionMatrix';
|
||||||
|
import { UsersTable } from '../widgets/Admin/UsersTable';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Экран администратора `/admin`: пользователи и роли, матрица прав.
|
||||||
|
*
|
||||||
|
* Профиль берём из `AuthGate` (`useCurrentUser`), а не новым запросом `/me`. Без права `USERS_ADMIN` таблицы даже не
|
||||||
|
* создаются — значит, их эффекты не запускаются и запросов к `/admin/*` нет. Проверка здесь — только удобство
|
||||||
|
* интерфейса: доступ на самом деле решает сервер (403 на `/admin/*` без права).
|
||||||
|
*/
|
||||||
|
export function Admin() {
|
||||||
|
const me = useCurrentUser();
|
||||||
|
|
||||||
|
if (!me.permissions.includes(USERS_ADMIN)) {
|
||||||
|
return <Result status='403' title='Нет доступа' subTitle='Раздел доступен пользователям с правом администрирования' />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ margin: 10, padding: 16, background: 'white', borderRadius: 10, height: 'calc(100% - 20px)', overflow: 'auto' }}>
|
||||||
|
{/* вкладка «Матрица прав» создаётся (и грузит данные) только при первом открытии */}
|
||||||
|
<Tabs
|
||||||
|
items={[
|
||||||
|
{ key: 'users', label: 'Пользователи', children: <UsersTable /> },
|
||||||
|
{ key: 'matrix', label: 'Матрица прав', children: <PermissionMatrix /> },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
133
web/src/widgets/Admin/CreateUserModal.tsx
Normal file
133
web/src/widgets/Admin/CreateUserModal.tsx
Normal file
@ -0,0 +1,133 @@
|
|||||||
|
import { Form, Input, Modal, Select } from 'antd';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { ApiError, api } from '../../api/client';
|
||||||
|
import type { AdminUser, BusinessSegment, RegionalOffice, Role, UserCreate } from '../../api/types';
|
||||||
|
import { useMountedRef, useNotifyError } from './hooks';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
open: boolean;
|
||||||
|
roles: Role[];
|
||||||
|
offices: RegionalOffice[];
|
||||||
|
onClose: () => void;
|
||||||
|
onCreated: (user: AdminUser) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Значения формы; пустой сегмент (`undefined`) уходит на сервер как `null` — «не задан». */
|
||||||
|
type FormValues = {
|
||||||
|
email: string;
|
||||||
|
full_name: string;
|
||||||
|
employee_no?: string;
|
||||||
|
regional_office_id: string;
|
||||||
|
business_segment?: BusinessSegment;
|
||||||
|
roles?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
// Коды сегмента — как enum `BusinessSegment` бэкенда; «не задан» — очищенное поле
|
||||||
|
const SEGMENT_OPTIONS: { value: BusinessSegment; label: string }[] = [
|
||||||
|
{ value: 'DKB', label: 'DKB' },
|
||||||
|
{ value: 'DMSB', label: 'DMSB' },
|
||||||
|
{ value: 'DM', label: 'DM' },
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Окно создания пользователя (`POST /admin/users`). Проверки формы — на клиенте (формат email, обязательные поля),
|
||||||
|
* окончательная — на сервере: занятый email (409 `EMAIL_EXISTS`) показывается под полем email, прочие ошибки —
|
||||||
|
* уведомлением. После закрытия поля очищаются.
|
||||||
|
*/
|
||||||
|
export function CreateUserModal({ open, roles, offices, onClose, onCreated }: Props) {
|
||||||
|
const [form] = Form.useForm<FormValues>();
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const notifyError = useNotifyError();
|
||||||
|
const mounted = useMountedRef();
|
||||||
|
|
||||||
|
const submit = async (values: FormValues) => {
|
||||||
|
setSubmitting(true);
|
||||||
|
const body: UserCreate = {
|
||||||
|
email: values.email.trim(),
|
||||||
|
full_name: values.full_name.trim(),
|
||||||
|
employee_no: values.employee_no?.trim() || null,
|
||||||
|
regional_office_id: values.regional_office_id,
|
||||||
|
business_segment: values.business_segment ?? null,
|
||||||
|
roles: values.roles ?? [],
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const user = await api.post<AdminUser>('/admin/users', body);
|
||||||
|
if (mounted.current) {
|
||||||
|
onCreated(user);
|
||||||
|
}
|
||||||
|
} catch (e: unknown) {
|
||||||
|
if (!mounted.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e instanceof ApiError && e.code === 'EMAIL_EXISTS') {
|
||||||
|
form.setFields([{ name: 'email', errors: [e.message] }]);
|
||||||
|
} else {
|
||||||
|
notifyError(e);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (mounted.current) {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Пока запрос идёт, окно не закрывается: иначе ответ придёт в уже закрытую форму
|
||||||
|
const cancel = () => {
|
||||||
|
if (!submitting) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
title='Новый пользователь'
|
||||||
|
open={open}
|
||||||
|
okText='Создать'
|
||||||
|
cancelText='Отмена'
|
||||||
|
onOk={() => form.submit()}
|
||||||
|
onCancel={cancel}
|
||||||
|
confirmLoading={submitting}
|
||||||
|
cancelButtonProps={{ disabled: submitting }}
|
||||||
|
mask={{ closable: !submitting }}
|
||||||
|
// форма смонтирована всегда (forceRender), поэтому её можно очистить после закрытия окна
|
||||||
|
forceRender
|
||||||
|
afterClose={() => form.resetFields()}>
|
||||||
|
<Form<FormValues> form={form} layout='vertical' onFinish={submit} disabled={submitting}>
|
||||||
|
<Form.Item
|
||||||
|
name='email'
|
||||||
|
label='Email'
|
||||||
|
rules={[
|
||||||
|
{ required: true, whitespace: true, message: 'Укажите email' },
|
||||||
|
{ type: 'email', message: 'Некорректный email' },
|
||||||
|
]}>
|
||||||
|
<Input placeholder='i.ivanov@example.ru' autoComplete='off' />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name='full_name' label='ФИО' rules={[{ required: true, whitespace: true, message: 'Укажите ФИО' }]}>
|
||||||
|
<Input placeholder='Иванов Иван Иванович' />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name='employee_no' label='Табельный номер'>
|
||||||
|
<Input />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name='regional_office_id' label='Филиал' rules={[{ required: true, message: 'Выберите филиал' }]}>
|
||||||
|
<Select
|
||||||
|
showSearch={{ optionFilterProp: 'label' }}
|
||||||
|
placeholder='Выберите филиал'
|
||||||
|
// в неактивный филиал нового пользователя не заводим
|
||||||
|
options={offices.filter((o) => o.is_active).map((o) => ({ value: o.id, label: o.name }))}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name='business_segment' label='Сегмент'>
|
||||||
|
<Select allowClear placeholder='— (не задан)' options={SEGMENT_OPTIONS} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name='roles' label='Роли'>
|
||||||
|
<Select
|
||||||
|
mode='multiple'
|
||||||
|
placeholder='Без ролей'
|
||||||
|
// назначить можно только активную роль (сервер отвечает 422 UNKNOWN_ROLE на неактивную)
|
||||||
|
options={roles.filter((r) => r.is_active).map((r) => ({ value: r.code, label: `${r.code} — ${r.name}` }))}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
154
web/src/widgets/Admin/PermissionMatrix.tsx
Normal file
154
web/src/widgets/Admin/PermissionMatrix.tsx
Normal file
@ -0,0 +1,154 @@
|
|||||||
|
import { App, Button, Checkbox, Flex, Table, type TableColumnsType, Tag, Tooltip, Typography } from 'antd';
|
||||||
|
import { useRef, useState } from 'react';
|
||||||
|
import { ApiError, api } from '../../api/client';
|
||||||
|
import type { Permission, Role } from '../../api/types';
|
||||||
|
import { useAsyncData, useMountedRef } from './hooks';
|
||||||
|
|
||||||
|
type MatrixData = { roles: Role[]; permissions: Permission[] };
|
||||||
|
|
||||||
|
// Роли и права нужны вместе: без любой из половин матрицу не построить
|
||||||
|
const loadMatrix = async (): Promise<MatrixData> => {
|
||||||
|
const [roles, permissions] = await Promise.all([api.get<Role[]>('/admin/roles'), api.get<Permission[]>('/admin/permissions')]);
|
||||||
|
return { roles, permissions };
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Черновик: новые наборы прав только тех ролей, которые отличаются от сервера (код роли → коды прав). */
|
||||||
|
type Draft = Record<string, string[]>;
|
||||||
|
|
||||||
|
function sameSet(a: string[], b: string[]): boolean {
|
||||||
|
return a.length === b.length && a.every((code) => b.includes(code));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Матрица «право × роль»: строки — права, колонки — роли, в ячейке — галочка.
|
||||||
|
*
|
||||||
|
* Галочки меняют только локальный черновик; «Сохранить» отправляет `PUT /admin/roles/{code}/permissions` для каждой
|
||||||
|
* изменённой роли — по очереди, а не параллельно: проверка «не снят ли последний USERS_ADMIN» на сервере идёт по
|
||||||
|
* текущему состоянию БД, и параллельные запросы могли бы пройти её одновременно. После сохранения (даже частично
|
||||||
|
* неудачного) черновик сбрасывается и матрица перечитывается: на экране — то, что реально сохранено.
|
||||||
|
*/
|
||||||
|
export function PermissionMatrix() {
|
||||||
|
const matrix = useAsyncData(loadMatrix);
|
||||||
|
const [draft, setDraft] = useState<Draft>({});
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const savingLock = useRef(false);
|
||||||
|
const { message } = App.useApp();
|
||||||
|
const mounted = useMountedRef();
|
||||||
|
|
||||||
|
const roles = matrix.data?.roles ?? [];
|
||||||
|
const permissions = matrix.data?.permissions ?? [];
|
||||||
|
const changedRoles = roles.filter((role) => role.code in draft);
|
||||||
|
|
||||||
|
const permissionsOf = (role: Role): string[] => draft[role.code] ?? role.permissions;
|
||||||
|
|
||||||
|
const toggle = (role: Role, permissionCode: string, checked: boolean) => {
|
||||||
|
// считаем от предыдущего черновика (prev), а не от отрисованного: два быстрых клика не потеряют первый
|
||||||
|
setDraft((prev) => {
|
||||||
|
const current = prev[role.code] ?? role.permissions;
|
||||||
|
const without = current.filter((code) => code !== permissionCode);
|
||||||
|
const next = checked ? [...without, permissionCode] : without;
|
||||||
|
const updated = { ...prev };
|
||||||
|
// вернули как было на сервере — роль больше не считается изменённой
|
||||||
|
if (sameSet(next, role.permissions)) {
|
||||||
|
delete updated[role.code];
|
||||||
|
} else {
|
||||||
|
updated[role.code] = next;
|
||||||
|
}
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
if (savingLock.current || changedRoles.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
savingLock.current = true;
|
||||||
|
setSaving(true);
|
||||||
|
const failed: string[] = [];
|
||||||
|
for (const role of changedRoles) {
|
||||||
|
try {
|
||||||
|
await api.put<Role>(`/admin/roles/${encodeURIComponent(role.code)}/permissions`, { permissions: draft[role.code] });
|
||||||
|
} catch (e: unknown) {
|
||||||
|
failed.push(`${role.code}: ${e instanceof ApiError ? e.message : 'не удалось выполнить запрос'}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
savingLock.current = false;
|
||||||
|
if (!mounted.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const savedCount = changedRoles.length - failed.length;
|
||||||
|
if (savedCount > 0) {
|
||||||
|
message.success(`Сохранено ролей: ${savedCount}`);
|
||||||
|
}
|
||||||
|
if (failed.length > 0) {
|
||||||
|
message.error(`Не сохранены: ${failed.join('; ')}`);
|
||||||
|
}
|
||||||
|
setDraft({});
|
||||||
|
await matrix.reload();
|
||||||
|
if (mounted.current) {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const roleColumns: TableColumnsType<Permission> = roles.map((role) => ({
|
||||||
|
key: role.code,
|
||||||
|
align: 'center',
|
||||||
|
title: (
|
||||||
|
<Tooltip title={role.description ?? role.name}>
|
||||||
|
<Flex vertical align='center' gap={2}>
|
||||||
|
<span>{role.code}</span>
|
||||||
|
{role.is_system && <Tag>системная</Tag>}
|
||||||
|
{!role.is_active && <Tag color='default'>неактивна</Tag>}
|
||||||
|
{role.code in draft && <Tag color='orange'>изменена</Tag>}
|
||||||
|
</Flex>
|
||||||
|
</Tooltip>
|
||||||
|
),
|
||||||
|
render: (_, permission) => (
|
||||||
|
<Checkbox
|
||||||
|
aria-label={`${role.code}: ${permission.code}`}
|
||||||
|
checked={permissionsOf(role).includes(permission.code)}
|
||||||
|
disabled={saving || matrix.loading}
|
||||||
|
onChange={(e) => toggle(role, permission.code, e.target.checked)}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const columns: TableColumnsType<Permission> = [
|
||||||
|
{
|
||||||
|
title: 'Право',
|
||||||
|
key: 'permission',
|
||||||
|
fixed: 'left',
|
||||||
|
width: 260,
|
||||||
|
render: (_, permission) => (
|
||||||
|
<>
|
||||||
|
<Typography.Text code>{permission.code}</Typography.Text>
|
||||||
|
<div>{permission.name}</div>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
...roleColumns,
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Flex gap={8} align='center' style={{ marginBottom: 12 }}>
|
||||||
|
<Button type='primary' onClick={() => void save()} loading={saving} disabled={changedRoles.length === 0}>
|
||||||
|
Сохранить
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => setDraft({})} disabled={saving || changedRoles.length === 0}>
|
||||||
|
Отменить изменения
|
||||||
|
</Button>
|
||||||
|
{changedRoles.length > 0 && <Typography.Text type='secondary'>Изменено ролей: {changedRoles.length}</Typography.Text>}
|
||||||
|
</Flex>
|
||||||
|
<Table<Permission>
|
||||||
|
rowKey='code'
|
||||||
|
size='small'
|
||||||
|
columns={columns}
|
||||||
|
dataSource={permissions}
|
||||||
|
loading={matrix.loading}
|
||||||
|
pagination={false}
|
||||||
|
scroll={{ x: 'max-content' }}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
147
web/src/widgets/Admin/UsersTable.tsx
Normal file
147
web/src/widgets/Admin/UsersTable.tsx
Normal file
@ -0,0 +1,147 @@
|
|||||||
|
import { PlusOutlined } from '@ant-design/icons';
|
||||||
|
import { App, Button, Flex, Select, Switch, Table, type TableColumnsType, Typography } from 'antd';
|
||||||
|
import { useRef, useState } from 'react';
|
||||||
|
import { api } from '../../api/client';
|
||||||
|
import type { AdminUser, Page, RegionalOffice, Role } from '../../api/types';
|
||||||
|
import { CreateUserModal } from './CreateUserModal';
|
||||||
|
import { useAsyncData, useMountedRef, useNotifyError } from './hooks';
|
||||||
|
|
||||||
|
// Сервер отдаёт не больше 200 пользователей за запрос; постраничный просмотр появится, когда пользователей станет больше
|
||||||
|
const USERS_LIMIT = 200;
|
||||||
|
|
||||||
|
// Загрузчики — функции уровня модуля: они не меняются между отрисовками (требование useAsyncData)
|
||||||
|
const loadUsers = () => api.get<Page<AdminUser>>(`/admin/users?limit=${USERS_LIMIT}`);
|
||||||
|
const loadRoles = () => api.get<Role[]>('/admin/roles');
|
||||||
|
const loadOffices = () => api.get<RegionalOffice[]>('/directories/regional-offices');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Таблица пользователей: роли меняются прямо в строке (`PUT /admin/users/{id}/roles`), активность — переключателем
|
||||||
|
* (`PATCH /admin/users/{id}`), новый пользователь — через окно создания.
|
||||||
|
*
|
||||||
|
* Состояние на экране всегда серверное: значение в строке меняется только после ответа сервера (берём пользователя из
|
||||||
|
* ответа). При ошибке (например, 409 `LAST_ADMIN`) показываем уведомление и перечитываем список, поэтому контрол
|
||||||
|
* никогда не показывает отвергнутое сервером состояние. Пока одна строка сохраняется, правка остальных заблокирована —
|
||||||
|
* запросы не идут параллельно и не обгоняют друг друга.
|
||||||
|
*/
|
||||||
|
export function UsersTable() {
|
||||||
|
const users = useAsyncData(loadUsers);
|
||||||
|
const roles = useAsyncData(loadRoles);
|
||||||
|
const offices = useAsyncData(loadOffices);
|
||||||
|
const [savingUserId, setSavingUserId] = useState<string | null>(null);
|
||||||
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
|
const notifyError = useNotifyError();
|
||||||
|
const { message } = App.useApp();
|
||||||
|
const mounted = useMountedRef();
|
||||||
|
// Замок от двойного клика: состояние React обновится только к следующей отрисовке, а ref — сразу
|
||||||
|
const savingLock = useRef(false);
|
||||||
|
|
||||||
|
const roleList = roles.data ?? [];
|
||||||
|
const page = users.data;
|
||||||
|
const busy = savingUserId !== null || users.loading;
|
||||||
|
|
||||||
|
// Одна правка строки: запрос → строка из ответа сервера; ошибка → уведомление и перечитывание списка
|
||||||
|
const saveRow = async (userId: string, request: () => Promise<AdminUser>) => {
|
||||||
|
if (savingLock.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
savingLock.current = true;
|
||||||
|
setSavingUserId(userId);
|
||||||
|
try {
|
||||||
|
const updated = await request();
|
||||||
|
if (mounted.current) {
|
||||||
|
users.setData((prev) => (prev ? { ...prev, items: prev.items.map((u) => (u.id === updated.id ? updated : u)) } : prev));
|
||||||
|
}
|
||||||
|
} catch (e: unknown) {
|
||||||
|
if (mounted.current) {
|
||||||
|
notifyError(e);
|
||||||
|
await users.reload();
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
savingLock.current = false;
|
||||||
|
if (mounted.current) {
|
||||||
|
setSavingUserId(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const changeRoles = (user: AdminUser, codes: string[]) =>
|
||||||
|
saveRow(user.id, () => api.put<AdminUser>(`/admin/users/${user.id}/roles`, { roles: codes }));
|
||||||
|
|
||||||
|
const changeActive = (user: AdminUser, isActive: boolean) =>
|
||||||
|
saveRow(user.id, () => api.patch<AdminUser>(`/admin/users/${user.id}`, { is_active: isActive }));
|
||||||
|
|
||||||
|
const onCreated = (user: AdminUser) => {
|
||||||
|
setCreateOpen(false);
|
||||||
|
message.success(`Пользователь ${user.email ?? user.full_name} создан`);
|
||||||
|
void users.reload();
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns: TableColumnsType<AdminUser> = [
|
||||||
|
{ title: 'Email', dataIndex: 'email', render: (email: string | null) => email ?? '—' },
|
||||||
|
{ title: 'ФИО', dataIndex: 'full_name' },
|
||||||
|
{ title: 'Филиал', key: 'office', render: (_, user) => user.regional_office.name },
|
||||||
|
{
|
||||||
|
title: 'Роли',
|
||||||
|
key: 'roles',
|
||||||
|
width: 360,
|
||||||
|
render: (_, user) => (
|
||||||
|
<Select
|
||||||
|
mode='multiple'
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
placeholder='Без ролей'
|
||||||
|
aria-label={`Роли пользователя ${user.email ?? user.full_name}`}
|
||||||
|
value={user.roles}
|
||||||
|
loading={savingUserId === user.id}
|
||||||
|
disabled={busy || roles.data === null}
|
||||||
|
// в строке — коды ролей (коротко), в выпадающем списке — код и название
|
||||||
|
options={roleList.map((r) => ({ value: r.code, label: r.code, title: r.name, disabled: !r.is_active }))}
|
||||||
|
optionRender={(option) => `${option.data.label} — ${option.data.title}`}
|
||||||
|
onChange={(codes: string[]) => void changeRoles(user, codes)}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Активен',
|
||||||
|
key: 'is_active',
|
||||||
|
width: 100,
|
||||||
|
render: (_, user) => (
|
||||||
|
<Switch
|
||||||
|
checked={user.is_active}
|
||||||
|
loading={savingUserId === user.id}
|
||||||
|
disabled={busy}
|
||||||
|
aria-label={`Активен: ${user.email ?? user.full_name}`}
|
||||||
|
onChange={(checked) => void changeActive(user, checked)}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Flex justify='space-between' align='center' style={{ marginBottom: 12 }}>
|
||||||
|
<Typography.Text type='secondary'>
|
||||||
|
{page && `Всего пользователей: ${page.total}`}
|
||||||
|
{page && page.total > page.items.length && ` (показаны первые ${page.items.length})`}
|
||||||
|
</Typography.Text>
|
||||||
|
<Button type='primary' icon={<PlusOutlined />} onClick={() => setCreateOpen(true)} disabled={roles.data === null}>
|
||||||
|
Создать пользователя
|
||||||
|
</Button>
|
||||||
|
</Flex>
|
||||||
|
<Table<AdminUser>
|
||||||
|
rowKey='id'
|
||||||
|
size='small'
|
||||||
|
columns={columns}
|
||||||
|
dataSource={page?.items ?? []}
|
||||||
|
loading={users.loading}
|
||||||
|
pagination={false}
|
||||||
|
/>
|
||||||
|
<CreateUserModal
|
||||||
|
open={createOpen}
|
||||||
|
roles={roleList}
|
||||||
|
offices={offices.data ?? []}
|
||||||
|
onClose={() => setCreateOpen(false)}
|
||||||
|
onCreated={onCreated}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
91
web/src/widgets/Admin/hooks.ts
Normal file
91
web/src/widgets/Admin/hooks.ts
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
import { App } from 'antd';
|
||||||
|
import { type Dispatch, type SetStateAction, useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import { ApiError } from '../../api/client';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Показ ошибки запроса уведомлением antd. `ApiError` уже несёт текст с сервера (например, «Нельзя снять последнего
|
||||||
|
* администратора» для 409 `LAST_ADMIN`); прочие исключения — общий текст. `message` берём из `App.useApp()`, а не
|
||||||
|
* статический `message.error`: так уведомление получает тему из `ConfigProvider` (статический вызов её не видит).
|
||||||
|
*/
|
||||||
|
export function useNotifyError(): (e: unknown) => void {
|
||||||
|
const { message } = App.useApp();
|
||||||
|
return useCallback(
|
||||||
|
(e: unknown) => {
|
||||||
|
// отмена запроса (AbortController) — не ошибка для пользователя
|
||||||
|
if (e instanceof DOMException && e.name === 'AbortError') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
message.error(e instanceof ApiError ? e.message : 'Не удалось выполнить запрос');
|
||||||
|
},
|
||||||
|
[message],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Флаг «компонент ещё на экране». Проверяется после `await` в обработчиках кнопок, чтобы не менять состояние
|
||||||
|
* компонента, который пользователь уже закрыл (ушёл со страницы, пока шёл запрос).
|
||||||
|
*/
|
||||||
|
export function useMountedRef(): { readonly current: boolean } {
|
||||||
|
const mounted = useRef(false);
|
||||||
|
useEffect(() => {
|
||||||
|
mounted.current = true;
|
||||||
|
return () => {
|
||||||
|
mounted.current = false;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
return mounted;
|
||||||
|
}
|
||||||
|
|
||||||
|
type AsyncData<T> = {
|
||||||
|
/** Последние данные с сервера; `null` — ещё не загружены или загрузка не удалась. */
|
||||||
|
data: T | null;
|
||||||
|
/** Замена данных без запроса (например, строка из ответа `PUT`). */
|
||||||
|
setData: Dispatch<SetStateAction<T | null>>;
|
||||||
|
loading: boolean;
|
||||||
|
/** Повторная загрузка; промис не отклоняется — ошибка уже показана уведомлением. */
|
||||||
|
reload: () => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Загрузка данных при появлении компонента и по `reload()`.
|
||||||
|
*
|
||||||
|
* `loader` должен быть стабильным (функция уровня модуля или `useCallback`), иначе загрузка пойдёт на каждой отрисовке.
|
||||||
|
* Защита от устаревших ответов: у каждого запуска свой номер; применяется только ответ последнего запуска. Ответ,
|
||||||
|
* пришедший после более нового `reload()` или после ухода со страницы, отбрасывается.
|
||||||
|
*/
|
||||||
|
export function useAsyncData<T>(loader: () => Promise<T>): AsyncData<T> {
|
||||||
|
const notifyError = useNotifyError();
|
||||||
|
const [data, setData] = useState<T | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const latestRun = useRef(0);
|
||||||
|
|
||||||
|
const reload = useCallback(async () => {
|
||||||
|
latestRun.current += 1;
|
||||||
|
const run = latestRun.current;
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const result = await loader();
|
||||||
|
if (run === latestRun.current) {
|
||||||
|
setData(result);
|
||||||
|
}
|
||||||
|
} catch (e: unknown) {
|
||||||
|
if (run === latestRun.current) {
|
||||||
|
notifyError(e);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (run === latestRun.current) {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [loader, notifyError]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void reload();
|
||||||
|
return () => {
|
||||||
|
// уход со страницы: ответы запросов «в полёте» станут устаревшими и будут отброшены
|
||||||
|
latestRun.current += 1;
|
||||||
|
};
|
||||||
|
}, [reload]);
|
||||||
|
|
||||||
|
return { data, setData, loading, reload };
|
||||||
|
}
|
||||||
@ -1,13 +1,17 @@
|
|||||||
import { HomeOutlined, LeftOutlined, RightOutlined } from '@ant-design/icons';
|
import { HomeOutlined, LeftOutlined, RightOutlined, TeamOutlined } from '@ant-design/icons';
|
||||||
import { Divider, Layout, Tooltip } from 'antd';
|
import { Divider, Layout, Tooltip } from 'antd';
|
||||||
import { type ReactNode, memo, useState } from 'react';
|
import { type ReactNode, memo, useState } from 'react';
|
||||||
import { NavLink } from 'react-router-dom';
|
import { NavLink } from 'react-router-dom';
|
||||||
|
import { useCurrentUser } from '../../auth/MeContext';
|
||||||
|
import { USERS_ADMIN } from '../../auth/permissions';
|
||||||
import styles from './styles.module.css';
|
import styles from './styles.module.css';
|
||||||
|
|
||||||
type NavItem = {
|
type NavItem = {
|
||||||
link: string;
|
link: string;
|
||||||
label: string;
|
label: string;
|
||||||
icon: ReactNode;
|
icon: ReactNode;
|
||||||
|
/** Право из `/me`, без которого пункт не показывается; нет — пункт виден всем. */
|
||||||
|
permission?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type Group = {
|
type Group = {
|
||||||
@ -23,8 +27,20 @@ const config: Group[] = [
|
|||||||
key: 'home',
|
key: 'home',
|
||||||
children: [{ link: '/', icon: <HomeOutlined />, label: 'Главная' }],
|
children: [{ link: '/', icon: <HomeOutlined />, label: 'Главная' }],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: 'Система',
|
||||||
|
key: 'system',
|
||||||
|
children: [{ link: '/admin', icon: <TeamOutlined />, label: 'Пользователи и роли', permission: USERS_ADMIN }],
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/** Меню для пользователя: пункты без нужного права убираются, опустевшие группы — тоже. */
|
||||||
|
function visibleGroups(permissions: string[]): Group[] {
|
||||||
|
return config
|
||||||
|
.map((group) => ({ ...group, children: group.children.filter((i) => !i.permission || permissions.includes(i.permission)) }))
|
||||||
|
.filter((group) => group.children.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
function CollapsedIcon({ icon, title, to }: { icon: ReactNode; title: string; to: string }) {
|
function CollapsedIcon({ icon, title, to }: { icon: ReactNode; title: string; to: string }) {
|
||||||
return (
|
return (
|
||||||
<Tooltip placement='right' title={title}>
|
<Tooltip placement='right' title={title}>
|
||||||
@ -66,6 +82,7 @@ const Section = memo(function Section({ isCollapsed, group }: { isCollapsed: boo
|
|||||||
|
|
||||||
export function Sidebar() {
|
export function Sidebar() {
|
||||||
const [isCollapsed, setIsCollapsed] = useState(false);
|
const [isCollapsed, setIsCollapsed] = useState(false);
|
||||||
|
const me = useCurrentUser();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout.Sider
|
<Layout.Sider
|
||||||
@ -81,7 +98,7 @@ export function Sidebar() {
|
|||||||
{isCollapsed ? <RightOutlined /> : <LeftOutlined />}
|
{isCollapsed ? <RightOutlined /> : <LeftOutlined />}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{config.map((g) => (
|
{visibleGroups(me.permissions).map((g) => (
|
||||||
<Section key={g.key} group={g} isCollapsed={isCollapsed} />
|
<Section key={g.key} group={g} isCollapsed={isCollapsed} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user