diff --git a/docs/superpowers/plans/2026-10-04-slice-1-directories-phase2.md b/docs/superpowers/plans/2026-10-04-slice-1-directories-phase2.md index 599e6af..20484d6 100644 --- a/docs/superpowers/plans/2026-10-04-slice-1-directories-phase2.md +++ b/docs/superpowers/plans/2026-10-04-slice-1-directories-phase2.md @@ -244,9 +244,9 @@ export const api = { get: (p: string) => apiFetch(p), post: (p: string, - `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: , label: 'Пользователи и роли' }] }`. -- [ ] **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, как устроен клиент, где появится РАИСА. -- [ ] **Step 4: Commit** `feat(web): страница администратора — пользователи, роли, матрица прав`. +- [x] **Step 1:** реализовать; **Step 2:** `npx biome check src && npx tsc --noEmit && npm run build` → PASS; ручной сценарий: вход админом, создать пользователя с ролью, сменить роль, снять галочку в матрице и сохранить; убедиться, что ошибка 409 `LAST_ADMIN` показывается уведомлением. +- [x] **Step 3:** самостоятельное ревью TS/React (пользователь слаб во фронте): в описание коммита — 3–5 строк, почему без Redux, как устроен клиент, где появится РАИСА. +- [x] **Step 4: Commit** `feat(web): страница администратора — пользователи, роли, матрица прав`. --- diff --git a/openspec/changes/slice-1-directories/tasks.md b/openspec/changes/slice-1-directories/tasks.md index be706ab..cb05f7c 100644 --- a/openspec/changes/slice-1-directories/tasks.md +++ b/openspec/changes/slice-1-directories/tasks.md @@ -72,7 +72,7 @@ test-integration`). Пути — от корня репозитория. Доп `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 !== '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, ФИО, филиал из `/directories/regional-offices`, роли), таблица «роль × право» с `Checkbox` и кнопкой «Сохранить» (`PUT roles/{code}/permissions`), `message.error` на ошибки API, экран «нет доступа» без `USERS_ADMIN`; маршрут `/admin` в `App.tsx`, пункт «Система → diff --git a/web/src/App.tsx b/web/src/App.tsx index 26d01c2..26cb0a0 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -4,6 +4,7 @@ import { Provider } from 'react-redux'; import { BrowserRouter, Route, Routes } from 'react-router-dom'; import { AuthGate } from './auth/AuthGate'; import { Config } from './conf/config'; +import { Admin } from './layouts/Admin'; import { Main } from './layouts/Main'; import { store } from './store/store'; import { Sidebar } from './widgets/Sidebar/Sidebar'; @@ -19,6 +20,7 @@ function App() { } /> + } /> diff --git a/web/src/auth/permissions.ts b/web/src/auth/permissions.ts new file mode 100644 index 0000000..f6a49b8 --- /dev/null +++ b/web/src/auth/permissions.ts @@ -0,0 +1,3 @@ +// Коды прав, от которых зависит интерфейс (как в `GET /me` → `permissions`). Доступ на самом деле решает сервер: +// проверки на клиенте только прячут то, что пользователю всё равно не разрешат. +export const USERS_ADMIN = 'USERS_ADMIN'; diff --git a/web/src/index.tsx b/web/src/index.tsx index ae87a8b..ef98385 100644 --- a/web/src/index.tsx +++ b/web/src/index.tsx @@ -1,6 +1,6 @@ import { createRoot } from 'react-dom/client'; import './index.css'; -import { ConfigProvider } from 'antd'; +import { App as AntApp, ConfigProvider } from 'antd'; import App from './App'; const rootElement = document.getElementById('root'); @@ -35,7 +35,10 @@ createRoot(rootElement).render( }, }} > - + {/* AntApp даёт message/notification через App.useApp() с темой ConfigProvider; component={false} — без лишнего div */} + + + , // , ); diff --git a/web/src/layouts/Admin.tsx b/web/src/layouts/Admin.tsx new file mode 100644 index 0000000..22499f6 --- /dev/null +++ b/web/src/layouts/Admin.tsx @@ -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 ; + } + + return ( +
+ {/* вкладка «Матрица прав» создаётся (и грузит данные) только при первом открытии */} + }, + { key: 'matrix', label: 'Матрица прав', children: }, + ]} + /> +
+ ); +} diff --git a/web/src/widgets/Admin/CreateUserModal.tsx b/web/src/widgets/Admin/CreateUserModal.tsx new file mode 100644 index 0000000..6976d77 --- /dev/null +++ b/web/src/widgets/Admin/CreateUserModal.tsx @@ -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(); + 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('/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 ( + form.submit()} + onCancel={cancel} + confirmLoading={submitting} + cancelButtonProps={{ disabled: submitting }} + mask={{ closable: !submitting }} + // форма смонтирована всегда (forceRender), поэтому её можно очистить после закрытия окна + forceRender + afterClose={() => form.resetFields()}> + form={form} layout='vertical' onFinish={submit} disabled={submitting}> + + + + + + + + + + + + + + ({ 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) => ( + void changeActive(user, checked)} + /> + ), + }, + ]; + + return ( + <> + + + {page && `Всего пользователей: ${page.total}`} + {page && page.total > page.items.length && ` (показаны первые ${page.items.length})`} + + + + + rowKey='id' + size='small' + columns={columns} + dataSource={page?.items ?? []} + loading={users.loading} + pagination={false} + /> + setCreateOpen(false)} + onCreated={onCreated} + /> + + ); +} diff --git a/web/src/widgets/Admin/hooks.ts b/web/src/widgets/Admin/hooks.ts new file mode 100644 index 0000000..3e47ba9 --- /dev/null +++ b/web/src/widgets/Admin/hooks.ts @@ -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 = { + /** Последние данные с сервера; `null` — ещё не загружены или загрузка не удалась. */ + data: T | null; + /** Замена данных без запроса (например, строка из ответа `PUT`). */ + setData: Dispatch>; + loading: boolean; + /** Повторная загрузка; промис не отклоняется — ошибка уже показана уведомлением. */ + reload: () => Promise; +}; + +/** + * Загрузка данных при появлении компонента и по `reload()`. + * + * `loader` должен быть стабильным (функция уровня модуля или `useCallback`), иначе загрузка пойдёт на каждой отрисовке. + * Защита от устаревших ответов: у каждого запуска свой номер; применяется только ответ последнего запуска. Ответ, + * пришедший после более нового `reload()` или после ухода со страницы, отбрасывается. + */ +export function useAsyncData(loader: () => Promise): AsyncData { + const notifyError = useNotifyError(); + const [data, setData] = useState(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 }; +} diff --git a/web/src/widgets/Sidebar/Sidebar.tsx b/web/src/widgets/Sidebar/Sidebar.tsx index fd32ea5..e4b4063 100644 --- a/web/src/widgets/Sidebar/Sidebar.tsx +++ b/web/src/widgets/Sidebar/Sidebar.tsx @@ -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 { type ReactNode, memo, useState } from 'react'; import { NavLink } from 'react-router-dom'; +import { useCurrentUser } from '../../auth/MeContext'; +import { USERS_ADMIN } from '../../auth/permissions'; import styles from './styles.module.css'; type NavItem = { link: string; label: string; icon: ReactNode; + /** Право из `/me`, без которого пункт не показывается; нет — пункт виден всем. */ + permission?: string; }; type Group = { @@ -23,8 +27,20 @@ const config: Group[] = [ key: 'home', children: [{ link: '/', icon: , label: 'Главная' }], }, + { + title: 'Система', + key: 'system', + children: [{ link: '/admin', icon: , 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 }) { return ( @@ -66,6 +82,7 @@ const Section = memo(function Section({ isCollapsed, group }: { isCollapsed: boo export function Sidebar() { const [isCollapsed, setIsCollapsed] = useState(false); + const me = useCurrentUser(); return ( : } - {config.map((g) => ( + {visibleGroups(me.permissions).map((g) => (
))}