arm-web/src/widgets/Admin/PermissionMatrix.tsx
2026-10-06 13:28:45 +03:00

169 lines
6.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 };
};
/**
* Черновик: новые наборы прав только тех ролей, которые отличаются от сервера (код роли → коды прав).
* `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));
}
/**
* Матрица «право × роль»: строки — права, колонки — роли, в ячейке — галочка.
*
* Галочки меняют только локальный черновик; «Сохранить» отправляет `PUT /admin/roles/{code}/permissions` для каждой
* изменённой роли — по очереди, а не параллельно: проверка «не снят ли последний USERS_ADMIN» на сервере идёт по
* текущему состоянию БД, и параллельные запросы могли бы пройти её одновременно. После сохранения (даже частично
* неудачного) роли из ответов сервера сразу подставляются в матрицу, черновик сбрасывается и матрица перечитывается:
* на экране — то, что реально сохранено, даже если перечитать не удалось.
*/
export function PermissionMatrix() {
const matrix = useAsyncData(loadMatrix);
const [draft, setDraft] = useState<Draft>(EMPTY_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) => draft.has(role.code));
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.get(role.code) ?? role.permissions;
const without = current.filter((code) => code !== permissionCode);
const next = checked ? [...without, permissionCode] : without;
const updated = new Map(prev);
// вернули как было на сервере — роль больше не считается изменённой
if (sameSet(next, role.permissions)) {
updated.delete(role.code);
} else {
updated.set(role.code, next);
}
return updated;
});
};
const save = async () => {
if (savingLock.current || changedRoles.length === 0) {
return;
}
savingLock.current = true;
setSaving(true);
const failed: string[] = [];
// роли из ответов успешных PUT: подставим их в матрицу, не дожидаясь перечитывания
const saved = new Map<string, Role>();
for (const role of changedRoles) {
try {
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 : 'не удалось выполнить запрос'}`);
}
}
savingLock.current = false;
if (!mounted.current) {
return;
}
if (saved.size > 0) {
message.success(`Сохранено ролей: ${saved.size}`);
}
if (failed.length > 0) {
message.error(`Не сохранены: ${failed.join('; ')}`);
}
// если перечитывание ниже не удастся, в матрице уже будут сохранённые наборы прав, а не прежние
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);
}
};
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>}
{draft.has(role.code) && <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(EMPTY_DRAFT)} 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' }}
/>
</>
);
}