- Pages /settings/roles (liste + filtres + create), /settings/roles/:id (matrix permissions + danger zone), /settings/users/:userId/roles (multi-select + preview union) - PermissionMatrix : groupes Mantine cards, wildcard <group>:* qui grise les individuals, admin:* qui court-circuite tout, indeterminate states, tooltips avec descriptions du catalogue - React Query hooks pour CRUD roles + assignations user-roles, notifications Mantine sur succes / erreurs avec extraction du message backend - Hook useAcadenicePermissions : best-effort lecture du claim JWT R2.1, fallback sur role natif Docmost (defense en profondeur — backend reste source de verite) - i18n complet FR + EN (~80 cles) - Vitest + Testing Library introduits dans apps/client (devDeps + config + setup) - 22 tests couvrant matrix wildcards, list filters, detail save/delete flow, multi-select assignments - Patches upstream minimaux : 3 routes ajoutees au router, 1 entree sidebar (visible si canManageRoles) - Documente comme Patch 004 dans ACADENICE_PATCHES.md
277 lines
8.4 KiB
TypeScript
277 lines
8.4 KiB
TypeScript
import { useMemo } from "react";
|
|
import {
|
|
Alert,
|
|
Badge,
|
|
Card,
|
|
Checkbox,
|
|
Group,
|
|
Stack,
|
|
Text,
|
|
Tooltip,
|
|
} from "@mantine/core";
|
|
import { IconShieldLock, IconWand } from "@tabler/icons-react";
|
|
import { useTranslation } from "react-i18next";
|
|
import {
|
|
ADMIN_WILDCARD,
|
|
IPermissionDescriptor,
|
|
IPermissionGroupView,
|
|
} from "@/features/acadenice/rbac/types/rbac.types";
|
|
import classes from "@/features/acadenice/rbac/styles/permission-matrix.module.css";
|
|
|
|
export interface PermissionMatrixProps {
|
|
/** full closed catalogue from GET /acadenice/permissions */
|
|
catalog: IPermissionDescriptor[];
|
|
/** currently selected permission keys (the controlled set the parent owns) */
|
|
value: string[];
|
|
onChange: (next: string[]) => void;
|
|
/** when true, every input is read-only (used for system roles) */
|
|
disabled?: boolean;
|
|
/** describes why the matrix is disabled, shown as an Alert */
|
|
disabledReason?: string;
|
|
}
|
|
|
|
/**
|
|
* Groups the catalogue by `group` field, separating `<group>:*` wildcards from
|
|
* the atomic permissions, and pulls the global `admin:*` out as its own row.
|
|
*/
|
|
function buildGroupViews(
|
|
catalog: IPermissionDescriptor[],
|
|
): { groups: IPermissionGroupView[]; adminWildcard: IPermissionDescriptor | null } {
|
|
const byGroup = new Map<string, IPermissionGroupView>();
|
|
let adminWildcard: IPermissionDescriptor | null = null;
|
|
for (const p of catalog) {
|
|
if (p.key === ADMIN_WILDCARD) {
|
|
adminWildcard = p;
|
|
continue;
|
|
}
|
|
if (!byGroup.has(p.group)) {
|
|
byGroup.set(p.group, { group: p.group, items: [], hasGroupWildcard: false });
|
|
}
|
|
const view = byGroup.get(p.group)!;
|
|
if (p.key.endsWith(":*")) {
|
|
view.hasGroupWildcard = true;
|
|
} else {
|
|
view.items.push(p);
|
|
}
|
|
}
|
|
// stable order : declared catalogue order is preserved within a group.
|
|
return { groups: Array.from(byGroup.values()), adminWildcard };
|
|
}
|
|
|
|
export function PermissionMatrix({
|
|
catalog,
|
|
value,
|
|
onChange,
|
|
disabled,
|
|
disabledReason,
|
|
}: PermissionMatrixProps) {
|
|
const { t } = useTranslation();
|
|
const valueSet = useMemo(() => new Set(value), [value]);
|
|
const { groups, adminWildcard } = useMemo(
|
|
() => buildGroupViews(catalog),
|
|
[catalog],
|
|
);
|
|
|
|
const hasAdmin = valueSet.has(ADMIN_WILDCARD);
|
|
|
|
const isGroupWildcardSelected = (group: string) =>
|
|
valueSet.has(`${group}:*`);
|
|
|
|
const selectedItemsInGroup = (g: IPermissionGroupView) =>
|
|
g.items.filter((it) => valueSet.has(it.key)).length;
|
|
|
|
const replaceSet = (next: Set<string>) => {
|
|
onChange(Array.from(next).sort());
|
|
};
|
|
|
|
const toggleAdmin = () => {
|
|
if (disabled) return;
|
|
if (hasAdmin) {
|
|
const next = new Set(valueSet);
|
|
next.delete(ADMIN_WILDCARD);
|
|
replaceSet(next);
|
|
} else {
|
|
// admin:* short-circuits everything ; keep only the wildcard so the
|
|
// resulting set is the canonical representation the backend stores.
|
|
replaceSet(new Set([ADMIN_WILDCARD]));
|
|
}
|
|
};
|
|
|
|
const toggleGroupWildcard = (group: string) => {
|
|
if (disabled || hasAdmin) return;
|
|
const wildcard = `${group}:*`;
|
|
const next = new Set(valueSet);
|
|
if (next.has(wildcard)) {
|
|
next.delete(wildcard);
|
|
} else {
|
|
next.add(wildcard);
|
|
// remove individuals — they are subsumed by the wildcard
|
|
for (const k of Array.from(next)) {
|
|
if (k.startsWith(`${group}:`) && k !== wildcard) {
|
|
next.delete(k);
|
|
}
|
|
}
|
|
}
|
|
replaceSet(next);
|
|
};
|
|
|
|
const togglePerm = (key: string) => {
|
|
if (disabled || hasAdmin) return;
|
|
const colonIdx = key.indexOf(":");
|
|
const group = colonIdx > 0 ? key.slice(0, colonIdx) : null;
|
|
if (group && isGroupWildcardSelected(group)) return;
|
|
const next = new Set(valueSet);
|
|
if (next.has(key)) {
|
|
next.delete(key);
|
|
} else {
|
|
next.add(key);
|
|
}
|
|
replaceSet(next);
|
|
};
|
|
|
|
return (
|
|
<Stack gap="md">
|
|
{disabled && disabledReason ? (
|
|
<Alert
|
|
icon={<IconShieldLock size={16} />}
|
|
color="yellow"
|
|
variant="light"
|
|
aria-live="polite"
|
|
>
|
|
{disabledReason}
|
|
</Alert>
|
|
) : null}
|
|
|
|
{adminWildcard ? (
|
|
<Card
|
|
withBorder
|
|
padding="md"
|
|
className={classes.adminCard}
|
|
data-testid="admin-wildcard-card"
|
|
>
|
|
<Group justify="space-between" wrap="nowrap">
|
|
<Group gap="sm">
|
|
<IconWand size={20} aria-hidden />
|
|
<div>
|
|
<Text fw={600}>{t("Workspace owner")}</Text>
|
|
<Text size="xs" c="dimmed">
|
|
{adminWildcard.description}
|
|
</Text>
|
|
</div>
|
|
</Group>
|
|
<Tooltip
|
|
label={t(
|
|
"Selecting this overrides every other permission. Use sparingly.",
|
|
)}
|
|
withArrow
|
|
>
|
|
<Checkbox
|
|
aria-label={t("Toggle admin wildcard")}
|
|
checked={hasAdmin}
|
|
onChange={toggleAdmin}
|
|
disabled={disabled}
|
|
data-testid="cb-admin-wildcard"
|
|
/>
|
|
</Tooltip>
|
|
</Group>
|
|
</Card>
|
|
) : null}
|
|
|
|
{groups.map((g) => {
|
|
const selectedCount = selectedItemsInGroup(g);
|
|
const groupWildcardOn = isGroupWildcardSelected(g.group);
|
|
const indeterminate =
|
|
!groupWildcardOn &&
|
|
!hasAdmin &&
|
|
selectedCount > 0 &&
|
|
selectedCount < g.items.length;
|
|
const groupAllOn = groupWildcardOn || hasAdmin;
|
|
const childrenDisabled = disabled || hasAdmin || groupWildcardOn;
|
|
|
|
return (
|
|
<Card
|
|
key={g.group}
|
|
withBorder
|
|
padding="md"
|
|
data-testid={`group-card-${g.group}`}
|
|
>
|
|
<Group justify="space-between" mb="xs" wrap="nowrap">
|
|
<Group gap="xs">
|
|
<Text fw={600} tt="capitalize">
|
|
{g.group}
|
|
</Text>
|
|
<Badge
|
|
size="sm"
|
|
variant="light"
|
|
color={groupAllOn ? "blue" : "gray"}
|
|
data-testid={`group-count-${g.group}`}
|
|
>
|
|
{hasAdmin
|
|
? t("inherited via admin")
|
|
: groupWildcardOn
|
|
? t("all granted")
|
|
: `${selectedCount}/${g.items.length}`}
|
|
</Badge>
|
|
</Group>
|
|
|
|
{g.hasGroupWildcard ? (
|
|
<Tooltip
|
|
label={t(
|
|
"Grant every permission in this group, including future ones.",
|
|
)}
|
|
withArrow
|
|
>
|
|
<Checkbox
|
|
label={t("All {{group}}", { group: g.group })}
|
|
aria-label={t("Toggle wildcard for {{group}}", {
|
|
group: g.group,
|
|
})}
|
|
checked={groupWildcardOn || hasAdmin}
|
|
indeterminate={indeterminate}
|
|
onChange={() => toggleGroupWildcard(g.group)}
|
|
disabled={disabled || hasAdmin}
|
|
data-testid={`cb-group-wildcard-${g.group}`}
|
|
/>
|
|
</Tooltip>
|
|
) : null}
|
|
</Group>
|
|
|
|
<Stack gap="xs" pl="sm">
|
|
{g.items.map((item) => {
|
|
const checked =
|
|
hasAdmin || groupWildcardOn || valueSet.has(item.key);
|
|
return (
|
|
<Tooltip
|
|
key={item.key}
|
|
label={item.description}
|
|
position="right"
|
|
openDelay={300}
|
|
withArrow
|
|
>
|
|
<Checkbox
|
|
label={
|
|
<Group gap={6} wrap="nowrap">
|
|
<Text size="sm">{item.key}</Text>
|
|
<Text size="xs" c="dimmed">
|
|
— {item.description}
|
|
</Text>
|
|
</Group>
|
|
}
|
|
aria-label={item.key}
|
|
checked={checked}
|
|
onChange={() => togglePerm(item.key)}
|
|
disabled={childrenDisabled}
|
|
data-testid={`cb-perm-${item.key}`}
|
|
/>
|
|
</Tooltip>
|
|
);
|
|
})}
|
|
</Stack>
|
|
</Card>
|
|
);
|
|
})}
|
|
</Stack>
|
|
);
|
|
}
|
|
|
|
export default PermissionMatrix;
|