169 lines
6.9 KiB
TypeScript
169 lines
6.9 KiB
TypeScript
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' }}
|
||
/>
|
||
</>
|
||
);
|
||
}
|