AcadeDoc/apps/client/src/features/acadenice/rbac/components/permission-matrix.tsx
Corentin 022add9acc feat(rbac): R2.2 frontend pages settings RBAC dynamique avec PermissionMatrix
- 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
2026-05-07 22:42:39 +02:00

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;