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 `:*` 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(); 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) => { 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 ( {disabled && disabledReason ? ( } color="yellow" variant="light" aria-live="polite" > {disabledReason} ) : null} {adminWildcard ? (
{t("Workspace owner")} {adminWildcard.description}
) : 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 ( {g.group} {hasAdmin ? t("inherited via admin") : groupWildcardOn ? t("all granted") : `${selectedCount}/${g.items.length}`} {g.hasGroupWildcard ? ( toggleGroupWildcard(g.group)} disabled={disabled || hasAdmin} data-testid={`cb-group-wildcard-${g.group}`} /> ) : null} {g.items.map((item) => { const checked = hasAdmin || groupWildcardOn || valueSet.has(item.key); return ( {item.key} — {item.description}
} aria-label={item.key} checked={checked} onChange={() => togglePerm(item.key)} disabled={childrenDisabled} data-testid={`cb-perm-${item.key}`} /> ); })} ); })} ); } export default PermissionMatrix;