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 { useState } from 'react';
|
||||
import { useRef, useState } from 'react';
|
||||
import { ApiError, api } from '../../api/client';
|
||||
import type { AdminUser, BusinessSegment, RegionalOffice, Role, UserCreate } from '../../api/types';
|
||||
import { useMountedRef, useNotifyError } from './hooks';
|
||||
@ -39,8 +39,15 @@ export function CreateUserModal({ open, roles, offices, onClose, onCreated }: Pr
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const notifyError = useNotifyError();
|
||||
const mounted = useMountedRef();
|
||||
// Замок от двойного клика: onFinish вызывается после асинхронной проверки полей, и второй клик успел бы отправить
|
||||
// второй POST до того, как `submitting` отключит кнопку; ref меняется сразу
|
||||
const submitLock = useRef(false);
|
||||
|
||||
const submit = async (values: FormValues) => {
|
||||
if (submitLock.current) {
|
||||
return;
|
||||
}
|
||||
submitLock.current = true;
|
||||
setSubmitting(true);
|
||||
const body: UserCreate = {
|
||||
email: values.email.trim(),
|
||||
@ -65,6 +72,7 @@ export function CreateUserModal({ open, roles, offices, onClose, onCreated }: Pr
|
||||
notifyError(e);
|
||||
}
|
||||
} finally {
|
||||
submitLock.current = false;
|
||||
if (mounted.current) {
|
||||
setSubmitting(false);
|
||||
}
|
||||
|
||||
@ -12,8 +12,14 @@ const loadMatrix = async (): Promise<MatrixData> => {
|
||||
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 {
|
||||
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` для каждой
|
||||
* изменённой роли — по очереди, а не параллельно: проверка «не снят ли последний USERS_ADMIN» на сервере идёт по
|
||||
* текущему состоянию БД, и параллельные запросы могли бы пройти её одновременно. После сохранения (даже частично
|
||||
* неудачного) черновик сбрасывается и матрица перечитывается: на экране — то, что реально сохранено.
|
||||
* неудачного) роли из ответов сервера сразу подставляются в матрицу, черновик сбрасывается и матрица перечитывается:
|
||||
* на экране — то, что реально сохранено, даже если перечитать не удалось.
|
||||
*/
|
||||
export function PermissionMatrix() {
|
||||
const matrix = useAsyncData(loadMatrix);
|
||||
const [draft, setDraft] = useState<Draft>({});
|
||||
const [draft, setDraft] = useState<Draft>(EMPTY_DRAFT);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const savingLock = useRef(false);
|
||||
const { message } = App.useApp();
|
||||
@ -37,22 +44,22 @@ export function PermissionMatrix() {
|
||||
|
||||
const roles = matrix.data?.roles ?? [];
|
||||
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) => {
|
||||
// считаем от предыдущего черновика (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 next = checked ? [...without, permissionCode] : without;
|
||||
const updated = { ...prev };
|
||||
const updated = new Map(prev);
|
||||
// вернули как было на сервере — роль больше не считается изменённой
|
||||
if (sameSet(next, role.permissions)) {
|
||||
delete updated[role.code];
|
||||
updated.delete(role.code);
|
||||
} else {
|
||||
updated[role.code] = next;
|
||||
updated.set(role.code, next);
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
@ -65,9 +72,15 @@ export function PermissionMatrix() {
|
||||
savingLock.current = true;
|
||||
setSaving(true);
|
||||
const failed: string[] = [];
|
||||
// роли из ответов успешных PUT: подставим их в матрицу, не дожидаясь перечитывания
|
||||
const saved = new Map<string, Role>();
|
||||
for (const role of changedRoles) {
|
||||
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) {
|
||||
failed.push(`${role.code}: ${e instanceof ApiError ? e.message : 'не удалось выполнить запрос'}`);
|
||||
}
|
||||
@ -76,14 +89,15 @@ export function PermissionMatrix() {
|
||||
if (!mounted.current) {
|
||||
return;
|
||||
}
|
||||
const savedCount = changedRoles.length - failed.length;
|
||||
if (savedCount > 0) {
|
||||
message.success(`Сохранено ролей: ${savedCount}`);
|
||||
if (saved.size > 0) {
|
||||
message.success(`Сохранено ролей: ${saved.size}`);
|
||||
}
|
||||
if (failed.length > 0) {
|
||||
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();
|
||||
if (mounted.current) {
|
||||
setSaving(false);
|
||||
@ -99,7 +113,7 @@ export function PermissionMatrix() {
|
||||
<span>{role.code}</span>
|
||||
{role.is_system && <Tag>системная</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>
|
||||
</Tooltip>
|
||||
),
|
||||
@ -135,7 +149,7 @@ export function PermissionMatrix() {
|
||||
<Button type='primary' onClick={() => void save()} loading={saving} disabled={changedRoles.length === 0}>
|
||||
Сохранить
|
||||
</Button>
|
||||
<Button onClick={() => setDraft({})} disabled={saving || changedRoles.length === 0}>
|
||||
<Button onClick={() => setDraft(EMPTY_DRAFT)} disabled={saving || changedRoles.length === 0}>
|
||||
Отменить изменения
|
||||
</Button>
|
||||
{changedRoles.length > 0 && <Typography.Text type='secondary'>Изменено ролей: {changedRoles.length}</Typography.Text>}
|
||||
|
||||
@ -93,8 +93,15 @@ export function UsersTable() {
|
||||
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 }))}
|
||||
// в строке — коды ролей (коротко), в выпадающем списке — код и название. Неактивную роль нельзя назначить
|
||||
// заново (сервер ответит 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}`}
|
||||
onChange={(codes: string[]) => void changeRoles(user, codes)}
|
||||
/>
|
||||
|
||||
@ -37,7 +37,10 @@ export function useMountedRef(): { readonly current: boolean } {
|
||||
}
|
||||
|
||||
type AsyncData<T> = {
|
||||
/** Последние данные с сервера; `null` — ещё не загружены или загрузка не удалась. */
|
||||
/**
|
||||
* Последние успешно полученные данные. `null` — первая загрузка ещё идёт или не удалась; неудачный `reload()`
|
||||
* оставляет прежние данные (ошибка показывается уведомлением).
|
||||
*/
|
||||
data: T | null;
|
||||
/** Замена данных без запроса (например, строка из ответа `PUT`). */
|
||||
setData: Dispatch<SetStateAction<T | null>>;
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { HomeOutlined, LeftOutlined, RightOutlined, TeamOutlined } from '@ant-design/icons';
|
||||
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 { useCurrentUser } from '../../auth/MeContext';
|
||||
import { USERS_ADMIN } from '../../auth/permissions';
|
||||
@ -83,6 +83,8 @@ const Section = memo(function Section({ isCollapsed, group }: { isCollapsed: boo
|
||||
export function Sidebar() {
|
||||
const [isCollapsed, setIsCollapsed] = useState(false);
|
||||
const me = useCurrentUser();
|
||||
// useMemo: группы пересчитываются только при смене прав, иначе memo(Section) получал бы новый объект на каждой отрисовке
|
||||
const groups = useMemo(() => visibleGroups(me.permissions), [me.permissions]);
|
||||
|
||||
return (
|
||||
<Layout.Sider
|
||||
@ -98,7 +100,7 @@ export function Sidebar() {
|
||||
{isCollapsed ? <RightOutlined /> : <LeftOutlined />}
|
||||
</button>
|
||||
</div>
|
||||
{visibleGroups(me.permissions).map((g) => (
|
||||
{groups.map((g) => (
|
||||
<Section key={g.key} group={g} isCollapsed={isCollapsed} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user