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:
parent
d3c2f8ab1f
commit
4d719ecc0e
@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>}
|
||||||
|
|||||||
@ -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)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -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>>;
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user