fix(web): /admin — снятие неактивной роли, слияние ответов матрицы, защита от двойной отправки (T14.1–T14.6)

T14.1: неактивная роль, уже назначенная пользователю, больше не disabled в его Select — её можно снять
(новое назначение неактивной роли по-прежнему запрещено). T14.2: матрица подставляет роли из ответов
успешных PUT до перечитывания — при неудачном перечитывании на экране сохранённое, а не прежнее.
T14.3: исправлен комментарий useAsyncData (неудачный reload оставляет прежние данные). T14.4: ref-замок
от двойного POST в окне создания. T14.5: черновик матрицы — Map вместо объекта (ключи прототипа).
T14.6: группы меню в useMemo, чтобы memo(Section) работал.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
This commit is contained in:
Andrey Epifancev 2026-10-05 01:22:30 +04:00
parent d3c2f8ab1f
commit 4d719ecc0e
5 changed files with 57 additions and 23 deletions

View File

@ -1,5 +1,5 @@
import { Form, Input, Modal, Select } from 'antd'; import { Form, Input, Modal, Select } from 'antd';
import { useState } from 'react'; import { useRef, useState } from 'react';
import { ApiError, api } from '../../api/client'; import { ApiError, api } from '../../api/client';
import type { AdminUser, BusinessSegment, RegionalOffice, Role, UserCreate } from '../../api/types'; import type { AdminUser, BusinessSegment, RegionalOffice, Role, UserCreate } from '../../api/types';
import { useMountedRef, useNotifyError } from './hooks'; import { useMountedRef, useNotifyError } from './hooks';
@ -39,8 +39,15 @@ export function CreateUserModal({ open, roles, offices, onClose, onCreated }: Pr
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const notifyError = useNotifyError(); const notifyError = useNotifyError();
const mounted = useMountedRef(); const mounted = useMountedRef();
// Замок от двойного клика: onFinish вызывается после асинхронной проверки полей, и второй клик успел бы отправить
// второй POST до того, как `submitting` отключит кнопку; ref меняется сразу
const submitLock = useRef(false);
const submit = async (values: FormValues) => { const submit = async (values: FormValues) => {
if (submitLock.current) {
return;
}
submitLock.current = true;
setSubmitting(true); setSubmitting(true);
const body: UserCreate = { const body: UserCreate = {
email: values.email.trim(), email: values.email.trim(),
@ -65,6 +72,7 @@ export function CreateUserModal({ open, roles, offices, onClose, onCreated }: Pr
notifyError(e); notifyError(e);
} }
} finally { } finally {
submitLock.current = false;
if (mounted.current) { if (mounted.current) {
setSubmitting(false); setSubmitting(false);
} }

View File

@ -12,8 +12,14 @@ const loadMatrix = async (): Promise<MatrixData> => {
return { roles, permissions }; return { roles, permissions };
}; };
/** Черновик: новые наборы прав только тех ролей, которые отличаются от сервера (код роли → коды прав). */ /**
type Draft = Record<string, string[]>; * Черновик: новые наборы прав только тех ролей, которые отличаются от сервера (код роли → коды прав).
* `Map`, а не объект: у объекта есть «унаследованные» ключи (`constructor`, `toString`), и проверка `code in draft`
* могла бы ошибиться на роли с таким кодом.
*/
type Draft = ReadonlyMap<string, string[]>;
const EMPTY_DRAFT: Draft = new Map();
function sameSet(a: string[], b: string[]): boolean { function sameSet(a: string[], b: string[]): boolean {
return a.length === b.length && a.every((code) => b.includes(code)); return a.length === b.length && a.every((code) => b.includes(code));
@ -25,11 +31,12 @@ function sameSet(a: string[], b: string[]): boolean {
* Галочки меняют только локальный черновик; «Сохранить» отправляет `PUT /admin/roles/{code}/permissions` для каждой * Галочки меняют только локальный черновик; «Сохранить» отправляет `PUT /admin/roles/{code}/permissions` для каждой
* изменённой роли — по очереди, а не параллельно: проверка «не снят ли последний USERS_ADMIN» на сервере идёт по * изменённой роли — по очереди, а не параллельно: проверка «не снят ли последний USERS_ADMIN» на сервере идёт по
* текущему состоянию БД, и параллельные запросы могли бы пройти её одновременно. После сохранения (даже частично * текущему состоянию БД, и параллельные запросы могли бы пройти её одновременно. После сохранения (даже частично
* неудачного) черновик сбрасывается и матрица перечитывается: на экране — то, что реально сохранено. * неудачного) роли из ответов сервера сразу подставляются в матрицу, черновик сбрасывается и матрица перечитывается:
* на экране — то, что реально сохранено, даже если перечитать не удалось.
*/ */
export function PermissionMatrix() { export function PermissionMatrix() {
const matrix = useAsyncData(loadMatrix); const matrix = useAsyncData(loadMatrix);
const [draft, setDraft] = useState<Draft>({}); const [draft, setDraft] = useState<Draft>(EMPTY_DRAFT);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const savingLock = useRef(false); const savingLock = useRef(false);
const { message } = App.useApp(); const { message } = App.useApp();
@ -37,22 +44,22 @@ export function PermissionMatrix() {
const roles = matrix.data?.roles ?? []; const roles = matrix.data?.roles ?? [];
const permissions = matrix.data?.permissions ?? []; const permissions = matrix.data?.permissions ?? [];
const changedRoles = roles.filter((role) => role.code in draft); const changedRoles = roles.filter((role) => draft.has(role.code));
const permissionsOf = (role: Role): string[] => draft[role.code] ?? role.permissions; const permissionsOf = (role: Role): string[] => draft.get(role.code) ?? role.permissions;
const toggle = (role: Role, permissionCode: string, checked: boolean) => { const toggle = (role: Role, permissionCode: string, checked: boolean) => {
// считаем от предыдущего черновика (prev), а не от отрисованного: два быстрых клика не потеряют первый // считаем от предыдущего черновика (prev), а не от отрисованного: два быстрых клика не потеряют первый
setDraft((prev) => { setDraft((prev) => {
const current = prev[role.code] ?? role.permissions; const current = prev.get(role.code) ?? role.permissions;
const without = current.filter((code) => code !== permissionCode); const without = current.filter((code) => code !== permissionCode);
const next = checked ? [...without, permissionCode] : without; const next = checked ? [...without, permissionCode] : without;
const updated = { ...prev }; const updated = new Map(prev);
// вернули как было на сервере — роль больше не считается изменённой // вернули как было на сервере — роль больше не считается изменённой
if (sameSet(next, role.permissions)) { if (sameSet(next, role.permissions)) {
delete updated[role.code]; updated.delete(role.code);
} else { } else {
updated[role.code] = next; updated.set(role.code, next);
} }
return updated; return updated;
}); });
@ -65,9 +72,15 @@ export function PermissionMatrix() {
savingLock.current = true; savingLock.current = true;
setSaving(true); setSaving(true);
const failed: string[] = []; const failed: string[] = [];
// роли из ответов успешных PUT: подставим их в матрицу, не дожидаясь перечитывания
const saved = new Map<string, Role>();
for (const role of changedRoles) { for (const role of changedRoles) {
try { try {
await api.put<Role>(`/admin/roles/${encodeURIComponent(role.code)}/permissions`, { permissions: draft[role.code] }); const permissionCodes = draft.get(role.code) ?? role.permissions;
const updated = await api.put<Role>(`/admin/roles/${encodeURIComponent(role.code)}/permissions`, {
permissions: permissionCodes,
});
saved.set(updated.code, updated);
} catch (e: unknown) { } catch (e: unknown) {
failed.push(`${role.code}: ${e instanceof ApiError ? e.message : 'не удалось выполнить запрос'}`); failed.push(`${role.code}: ${e instanceof ApiError ? e.message : 'не удалось выполнить запрос'}`);
} }
@ -76,14 +89,15 @@ export function PermissionMatrix() {
if (!mounted.current) { if (!mounted.current) {
return; return;
} }
const savedCount = changedRoles.length - failed.length; if (saved.size > 0) {
if (savedCount > 0) { message.success(`Сохранено ролей: ${saved.size}`);
message.success(`Сохранено ролей: ${savedCount}`);
} }
if (failed.length > 0) { if (failed.length > 0) {
message.error(`Не сохранены: ${failed.join('; ')}`); message.error(`Не сохранены: ${failed.join('; ')}`);
} }
setDraft({}); // если перечитывание ниже не удастся, в матрице уже будут сохранённые наборы прав, а не прежние
matrix.setData((prev) => (prev ? { ...prev, roles: prev.roles.map((r) => saved.get(r.code) ?? r) } : prev));
setDraft(EMPTY_DRAFT);
await matrix.reload(); await matrix.reload();
if (mounted.current) { if (mounted.current) {
setSaving(false); setSaving(false);
@ -99,7 +113,7 @@ export function PermissionMatrix() {
<span>{role.code}</span> <span>{role.code}</span>
{role.is_system && <Tag>системная</Tag>} {role.is_system && <Tag>системная</Tag>}
{!role.is_active && <Tag color='default'>неактивна</Tag>} {!role.is_active && <Tag color='default'>неактивна</Tag>}
{role.code in draft && <Tag color='orange'>изменена</Tag>} {draft.has(role.code) && <Tag color='orange'>изменена</Tag>}
</Flex> </Flex>
</Tooltip> </Tooltip>
), ),
@ -135,7 +149,7 @@ export function PermissionMatrix() {
<Button type='primary' onClick={() => void save()} loading={saving} disabled={changedRoles.length === 0}> <Button type='primary' onClick={() => void save()} loading={saving} disabled={changedRoles.length === 0}>
Сохранить Сохранить
</Button> </Button>
<Button onClick={() => setDraft({})} disabled={saving || changedRoles.length === 0}> <Button onClick={() => setDraft(EMPTY_DRAFT)} disabled={saving || changedRoles.length === 0}>
Отменить изменения Отменить изменения
</Button> </Button>
{changedRoles.length > 0 && <Typography.Text type='secondary'>Изменено ролей: {changedRoles.length}</Typography.Text>} {changedRoles.length > 0 && <Typography.Text type='secondary'>Изменено ролей: {changedRoles.length}</Typography.Text>}

View File

@ -93,8 +93,15 @@ export function UsersTable() {
value={user.roles} value={user.roles}
loading={savingUserId === user.id} loading={savingUserId === user.id}
disabled={busy || roles.data === null} disabled={busy || roles.data === null}
// в строке — коды ролей (коротко), в выпадающем списке — код и название // в строке — коды ролей (коротко), в выпадающем списке — код и название. Неактивную роль нельзя назначить
options={roleList.map((r) => ({ value: r.code, label: r.code, title: r.name, disabled: !r.is_active }))} // заново (сервер ответит 422 UNKNOWN_ROLE), но уже назначенную — можно снять: у disabled-опции rc-select не
// рисует крестик на теге, поэтому disabled только для тех неактивных, которых у пользователя нет
options={roleList.map((r) => ({
value: r.code,
label: r.code,
title: r.name,
disabled: !r.is_active && !user.roles.includes(r.code),
}))}
optionRender={(option) => `${option.data.label} — ${option.data.title}`} optionRender={(option) => `${option.data.label} — ${option.data.title}`}
onChange={(codes: string[]) => void changeRoles(user, codes)} onChange={(codes: string[]) => void changeRoles(user, codes)}
/> />

View File

@ -37,7 +37,10 @@ export function useMountedRef(): { readonly current: boolean } {
} }
type AsyncData<T> = { type AsyncData<T> = {
/** Последние данные с сервера; `null` — ещё не загружены или загрузка не удалась. */ /**
* Последние успешно полученные данные. `null` — первая загрузка ещё идёт или не удалась; неудачный `reload()`
* оставляет прежние данные (ошибка показывается уведомлением).
*/
data: T | null; data: T | null;
/** Замена данных без запроса (например, строка из ответа `PUT`). */ /** Замена данных без запроса (например, строка из ответа `PUT`). */
setData: Dispatch<SetStateAction<T | null>>; setData: Dispatch<SetStateAction<T | null>>;

View File

@ -1,6 +1,6 @@
import { HomeOutlined, LeftOutlined, RightOutlined, TeamOutlined } 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, useMemo, useState } from 'react';
import { NavLink } from 'react-router-dom'; import { NavLink } from 'react-router-dom';
import { useCurrentUser } from '../../auth/MeContext'; import { useCurrentUser } from '../../auth/MeContext';
import { USERS_ADMIN } from '../../auth/permissions'; import { USERS_ADMIN } from '../../auth/permissions';
@ -83,6 +83,8 @@ 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(); const me = useCurrentUser();
// useMemo: группы пересчитываются только при смене прав, иначе memo(Section) получал бы новый объект на каждой отрисовке
const groups = useMemo(() => visibleGroups(me.permissions), [me.permissions]);
return ( return (
<Layout.Sider <Layout.Sider
@ -98,7 +100,7 @@ export function Sidebar() {
{isCollapsed ? <RightOutlined /> : <LeftOutlined />} {isCollapsed ? <RightOutlined /> : <LeftOutlined />}
</button> </button>
</div> </div>
{visibleGroups(me.permissions).map((g) => ( {groups.map((g) => (
<Section key={g.key} group={g} isCollapsed={isCollapsed} /> <Section key={g.key} group={g} isCollapsed={isCollapsed} />
))} ))}
</div> </div>