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
This commit is contained in:
Corentin JOGUET 2026-05-07 22:42:39 +02:00
parent bcd861126f
commit 022add9acc
28 changed files with 2717 additions and 4 deletions

View file

@ -271,6 +271,120 @@ Aucun bug bloquant. Le test stub `apps/server/src/core/auth/services/token.servi
---
## Patch 004 — R2.2 : Frontend pages settings RBAC dynamique
**Date** : 2026-05-07
**Scope** : UI admin pour CRUD roles + assignation user-roles + matrix permissions wildcard-aware
**Rationale** : R2.1 a livre l'API REST + 22 permissions catalog. R2.2 consomme cette API cote front. Toute l'UI est isolee dans `apps/client/src/features/acadenice/rbac/` pour minimiser les conflits de rebase upstream. Les patches sur les fichiers Docmost upstream sont strictement minimaux : 1 import + 1 entree sidebar, 3 imports + 3 routes router, 0 modification dans les pages existantes.
### Pages livrees
```
/settings/roles → liste + filtres + create
/settings/roles/:id → identite + matrix permissions + danger zone
/settings/users/:userId/roles → multi-select roles + preview permissions effectives
```
### Composants cles
`PermissionMatrix` — accordeon de cards Mantine, une par groupe (`pages`, `space`, `tables`, `rows`, `attachments`, `users`, `meta`). Trois niveaux de granularite :
- `admin:*` carte dediee : grise toutes les autres permissions quand cochee
- `<group>:*` wildcard par groupe : grise les permissions atomiques du groupe
- Atomic checkboxes : tooltips avec descriptions du catalogue
Indeterminate state Mantine quand le groupe est partiellement coche. Disabled mode pour les system roles (avec Alert explicatif).
### Hook useAcadenicePermissions
Tente de lire le claim `acadenice_permissions[]` que R2.1 pose dans le JWT. Limites connues :
- Le `authToken` est en cookie HttpOnly cote serveur (impossible a lire en JS) ; on tente le cookie non-HttpOnly `authTokens` et l'atom jotai legacy (au cas ou un flow OIDC pose le token cote client)
- Si aucun claim disponible : fallback sur le role natif Docmost (`OWNER` / `ADMIN` -> presume manage-capable pour la sidebar uniquement)
- Le backend reste source de verite : il renvoie 403 si `roles:manage` manque vraiment
### Strategie i18n
Cles ajoutees dans `apps/client/public/locales/en-US/translation.json` et `fr-FR/translation.json` (~80 cles). Pas de namespace separe — le pattern Docmost utilise un seul `translation.json` par langue, on s'aligne. Les autres langues (ja/de/it/etc.) heriteront du fallback en-US tant qu'elles ne sont pas traduites.
### Tests Vitest + Testing Library
`apps/client` n'avait pas de runner de tests. R2.2 introduit Vitest + jsdom + Testing Library :
- `apps/client/vitest.config.ts` — config dediee, alias `@` / `src`
- `apps/client/src/test-setup.ts` — stubs `matchMedia` + `ResizeObserver` (Mantine en a besoin)
- `apps/client/package.json` — scripts `test` + `test:watch` + devDeps (`vitest`, `@testing-library/react`, `@testing-library/user-event`, `@testing-library/jest-dom`, `jsdom`)
Les 4 fichiers de tests dans `features/acadenice/rbac/__tests__/` mockent `rbac-service` et `useAcadenicePermissions` via `vi.mock`. Pas de setup MSW : on intercepte directement les fonctions de service (le boundary front-back).
### Fichiers crees
| Fichier | Role |
|---------|------|
| `apps/client/src/features/acadenice/rbac/types/rbac.types.ts` | Types alignes sur DTOs backend R2.1 |
| `apps/client/src/features/acadenice/rbac/services/rbac-service.ts` | Wrapper REST sur axios (10 endpoints) |
| `apps/client/src/features/acadenice/rbac/queries/permissions-query.ts` | `usePermissionsCatalogQuery` (cache 30 min) |
| `apps/client/src/features/acadenice/rbac/queries/roles-query.ts` | `useRolesQuery`, `useRoleQuery`, `useCreateRoleMutation`, `useUpdateRoleMutation`, `useDeleteRoleMutation`, `useSetRolePermissionsMutation` |
| `apps/client/src/features/acadenice/rbac/queries/user-roles-query.ts` | `useUserRolesQuery`, `useAssignRolesMutation`, `useUnassignRoleMutation` |
| `apps/client/src/features/acadenice/rbac/hooks/use-acadenice-permissions.ts` | Best-effort JWT claim reader + fallback admin natif |
| `apps/client/src/features/acadenice/rbac/components/permission-matrix.tsx` | Composant cle — wildcard-aware, indeterminate, tooltips |
| `apps/client/src/features/acadenice/rbac/components/role-form.tsx` | Form Mantine create/edit name+description avec validation |
| `apps/client/src/features/acadenice/rbac/components/delete-role-modal.tsx` | Confirmation modale avec saisie obligatoire du nom |
| `apps/client/src/features/acadenice/rbac/components/role-row.tsx` | Row table avec badges system/custom |
| `apps/client/src/features/acadenice/rbac/pages/roles-list.page.tsx` | Page `/settings/roles` |
| `apps/client/src/features/acadenice/rbac/pages/role-detail.page.tsx` | Page `/settings/roles/:id` |
| `apps/client/src/features/acadenice/rbac/pages/user-roles-panel.tsx` | Page + composant reutilisable `UserRolesPanel` |
| `apps/client/src/features/acadenice/rbac/styles/permission-matrix.module.css` | Style admin card |
| `apps/client/src/features/acadenice/rbac/styles/role-detail.module.css` | Sections + danger zone |
| `apps/client/src/features/acadenice/rbac/__tests__/test-utils.tsx` | Wrapper providers (QueryClient + Mantine + MemoryRouter) |
| `apps/client/src/features/acadenice/rbac/__tests__/permission-matrix.test.tsx` | 8 tests sur la matrix |
| `apps/client/src/features/acadenice/rbac/__tests__/roles-list.page.test.tsx` | 5 tests sur la liste |
| `apps/client/src/features/acadenice/rbac/__tests__/role-detail.page.test.tsx` | 4 tests sur le detail |
| `apps/client/src/features/acadenice/rbac/__tests__/user-roles-panel.test.tsx` | 5 tests sur les assignments |
| `apps/client/vitest.config.ts` | Config Vitest |
| `apps/client/src/test-setup.ts` | Setup global testing (matchMedia, ResizeObserver) |
### Fichiers modifies (touches minimales)
| Fichier | Modification |
|---------|--------------|
| `apps/client/src/App.tsx` | +3 imports + 3 `<Route>` enfants de `/settings` |
| `apps/client/src/components/settings/settings-sidebar.tsx` | +1 import (`useAcadenicePermissions`) +1 import icon (`IconShieldLock`) +1 entree dans `groupedData.Workspace.items` apres "Groups" +1 ligne dans `canShowItem` (filtre `acadeniceCanManageRoles`) +1 champ TS sur `DataItem` |
| `apps/client/src/i18n/.../translation.json` (en-US, fr-FR) | +80 cles RBAC |
| `apps/client/package.json` | +5 devDeps (vitest, @testing-library/{react,user-event,jest-dom}, jsdom) +2 scripts npm |
### Edge cases couverts UX
- Loading state : Mantine `Loader` centre dans chaque page
- Error state : `Alert` + bouton Retry qui appelle `refetch`
- Empty state : message contextuel ("seed roles will appear" vs "try clearing filters")
- System role : nom locked, delete locked + tooltip explicatif, matrix editable mais avec banner "system protected"
- Anti-escalation : `UserRolesPanel` n'auto-modifie pas le user (le backend rejette de toute facon — l'UI ne tente pas)
- Permission preview : se desactive si `canMutate=false` car les calls `getRole` necessitent `roles:manage`
- Dirty tracking : boutons Save/Discard se desactivent si les drafts == server state (compare ensembles tries)
- A11y : `aria-label` sur tous les inputs / icon buttons, `Helmet` titres, `aria-live="polite"` sur Alerts d'etat
### TODO laisses (non bloquants R2.2)
- Endpoint backend `GET /api/acadenice/permissions/me` pour eviter le hack JWT cookie (R2.3)
- Pagination de la liste des roles (actuellement on assume < 100 roles par workspace, raisonnable)
- Section "Members" dans la page detail role (lookup inverse `roleId -> users`) — necessite un nouvel endpoint backend
- Integration dans la table `WorkspaceMembersTable` existante (un menu "Manage Acadenice roles" inline plutot que la page dediee `/settings/users/:userId/roles`)
- Bulk assign : assigner un role a N users d'un coup
- Audit log des changements de role (qui a assigne quoi a qui — necessite backend R2.3)
- jwt-decode : remplacer le hack cookie par un endpoint dedie quand la backend feature `permissions/me` arrive
### Bugs Docmost detectes
Aucun bug bloquant. L'atom `authTokens` (`apps/client/src/features/auth/atoms/auth-tokens-atom.ts`) semble vestigial : in my tests il n'est pas set par les flows actuels (les tokens vont en cookie HttpOnly cote serveur via `setAuthCookie`). L'atom est conservé pour ne pas casser un eventuel flow OIDC / EE qui le consommerait.
### Verifications skipped
- `pnpm install` : pas execute (convention agent fork)
- TypeScript build : pas execute
- Tests Vitest : ecrits, runners non installes en local (devDeps ajoutes — Corentin install pour run)
- Lint ESLint : pas execute
- E2E manuel sur les pages : impossible sans backend en route + Postgres + Redis
---
### TODO rebrand complet (futur)
- Logo SVG / favicon DocAdenice (actuellement reutilise `/icons/favicon-32x32.png` upstream)

View file

@ -7,7 +7,9 @@
"build": "tsc && vite build",
"lint": "eslint .",
"preview": "vite preview",
"format": "prettier --write \"src/**/*.tsx\" \"src/**/*.ts\""
"format": "prettier --write \"src/**/*.tsx\" \"src/**/*.ts\"",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@casl/react": "^5.0.1",
@ -80,6 +82,11 @@
"prettier": "^3.8.1",
"typescript": "^5.9.3",
"typescript-eslint": "^8.57.1",
"vite": "8.0.5"
"vite": "8.0.5",
"vitest": "^2.1.8",
"@testing-library/react": "^16.1.0",
"@testing-library/user-event": "^14.5.2",
"@testing-library/jest-dom": "^6.6.3",
"jsdom": "^25.0.1"
}
}

View file

@ -928,5 +928,86 @@
"Settings navigation": "Settings navigation",
"AI navigation": "AI navigation",
"Breadcrumb": "Breadcrumb",
"Skip to main content": "Skip to main content"
"Skip to main content": "Skip to main content",
"Roles": "Roles",
"Role": "Role",
"Role detail": "Role detail",
"Role name": "Role name",
"Role created successfully": "Role created successfully",
"Role updated successfully": "Role updated successfully",
"Role deleted successfully": "Role deleted successfully",
"Role removed from user": "Role removed from user",
"Role assignments updated": "Role assignments updated",
"Failed to create role": "Failed to create role",
"Failed to update role": "Failed to update role",
"Failed to delete role": "Failed to delete role",
"Failed to assign roles": "Failed to assign roles",
"Failed to remove role": "Failed to remove role",
"Failed to save permissions": "Failed to save permissions",
"Failed to load roles": "Failed to load roles",
"Failed to load role": "Failed to load role",
"Failed to load role assignments": "Failed to load role assignments",
"Permissions saved": "Permissions saved",
"Permissions": "Permissions",
"Identity": "Identity",
"Save changes": "Save changes",
"Save permissions": "Save permissions",
"Save assignments": "Save assignments",
"Discard": "Discard",
"Retry": "Retry",
"An unknown error occurred": "An unknown error occurred",
"Search roles by name": "Search roles by name",
"Search roles": "Search roles",
"Custom": "Custom",
"system": "system",
"custom": "custom",
"Create role": "Create role",
"Create a new role": "Create a new role",
"Open": "Open",
"Open role {{name}}": "Open role {{name}}",
"System role — name and existence are protected": "System role — name and existence are protected",
"System roles cannot be renamed": "System roles cannot be renamed",
"System roles cannot be deleted": "System roles cannot be deleted",
"Define what members can do across this workspace. Custom roles override defaults; system roles cannot be removed.": "Define what members can do across this workspace. Custom roles override defaults; system roles cannot be removed.",
"No roles match your filters": "No roles match your filters",
"Seed roles will appear once the workspace is initialised.": "Seed roles will appear once the workspace is initialised.",
"Try clearing the search or switching the filter.": "Try clearing the search or switching the filter.",
"Back to roles": "Back to roles",
"Back to members": "Back to members",
"Missing role id in URL": "Missing role id in URL",
"Missing user id in URL": "Missing user id in URL",
"Selected actions are granted to every member who holds this role.": "Selected actions are granted to every member who holds this role.",
"Workspace owner": "Workspace owner",
"Selecting this overrides every other permission. Use sparingly.": "Selecting this overrides every other permission. Use sparingly.",
"Toggle admin wildcard": "Toggle admin wildcard",
"Toggle wildcard for {{group}}": "Toggle wildcard for {{group}}",
"Grant every permission in this group, including future ones.": "Grant every permission in this group, including future ones.",
"All {{group}}": "All {{group}}",
"all granted": "all granted",
"inherited via admin": "inherited via admin",
"You do not have the roles:manage permission. Permissions are read-only.": "You do not have the roles:manage permission. Permissions are read-only.",
"You do not have the roles:manage permission. Assignments are read-only.": "You do not have the roles:manage permission. Assignments are read-only.",
"Requires the roles:manage permission": "Requires the roles:manage permission",
"Danger zone": "Danger zone",
"System roles are protected and cannot be deleted. You can edit their permissions but their existence is guaranteed.": "System roles are protected and cannot be deleted. You can edit their permissions but their existence is guaranteed.",
"Deleting this role removes it from every user. This action cannot be undone.": "Deleting this role removes it from every user. This action cannot be undone.",
"Delete role": "Delete role",
"This will remove the role and unassign it from every member. This action cannot be undone.": "This will remove the role and unassign it from every member. This action cannot be undone.",
"to confirm:": "to confirm:",
"Confirm role name": "Confirm role name",
"Name is required": "Name is required",
"Name is too long (max 120)": "Name is too long (max 120)",
"Description is too long (max 2000)": "Description is too long (max 2000)",
"e.g. Formateur": "e.g. Formateur",
"What this role can do, in plain words": "What this role can do, in plain words",
"User roles": "User roles",
"User role assignments": "User role assignments",
"Assigned roles": "Assigned roles",
"A user inherits the union of every role's permissions. The owner shortcut admin:* overrides everything else.": "A user inherits the union of every role's permissions. The owner shortcut admin:* overrides everything else.",
"Pick one or more roles": "Pick one or more roles",
"Roles assigned to user": "Roles assigned to user",
"Effective permissions preview": "Effective permissions preview",
"Permission preview requires the roles:manage permission to read role definitions.": "Permission preview requires the roles:manage permission to read role definitions.",
"No roles selected.": "No roles selected.",
"No permissions are granted by the selected roles yet.": "No permissions are granted by the selected roles yet."
}

View file

@ -880,5 +880,88 @@
"Try a different search term.": "Essayez un autre terme de recherche.",
"Try again": "Réessayer",
"Untitled chat": "Discussion sans titre",
"What can I help you with?": "Que puis-je faire pour vous aider ?"
"What can I help you with?": "Que puis-je faire pour vous aider ?",
"Roles": "Rôles",
"Role": "Rôle",
"Role detail": "Détail du rôle",
"Role name": "Nom du rôle",
"Role created successfully": "Rôle créé avec succès",
"Role updated successfully": "Rôle mis à jour avec succès",
"Role deleted successfully": "Rôle supprimé avec succès",
"Role removed from user": "Rôle retiré de l'utilisateur",
"Role assignments updated": "Attributions de rôle mises à jour",
"Failed to create role": "Échec de la création du rôle",
"Failed to update role": "Échec de la mise à jour du rôle",
"Failed to delete role": "Échec de la suppression du rôle",
"Failed to assign roles": "Échec de l'attribution des rôles",
"Failed to remove role": "Échec du retrait du rôle",
"Failed to save permissions": "Échec de l'enregistrement des permissions",
"Failed to load roles": "Échec du chargement des rôles",
"Failed to load role": "Échec du chargement du rôle",
"Failed to load role assignments": "Échec du chargement des attributions",
"Permissions saved": "Permissions enregistrées",
"Permissions": "Permissions",
"Identity": "Identité",
"Save changes": "Enregistrer les modifications",
"Save permissions": "Enregistrer les permissions",
"Save assignments": "Enregistrer les attributions",
"Discard": "Annuler les modifications",
"Retry": "Réessayer",
"An unknown error occurred": "Une erreur inconnue est survenue",
"Search roles by name": "Rechercher un rôle par nom",
"Search roles": "Rechercher des rôles",
"All": "Tous",
"System": "Système",
"Custom": "Personnalisés",
"system": "système",
"custom": "personnalisé",
"Create role": "Créer un rôle",
"Create a new role": "Créer un nouveau rôle",
"Open": "Ouvrir",
"Open role {{name}}": "Ouvrir le rôle {{name}}",
"System role — name and existence are protected": "Rôle système — le nom et l'existence sont protégés",
"System roles cannot be renamed": "Les rôles système ne peuvent pas être renommés",
"System roles cannot be deleted": "Les rôles système ne peuvent pas être supprimés",
"Define what members can do across this workspace. Custom roles override defaults; system roles cannot be removed.": "Définissez ce que les membres peuvent faire dans cet espace de travail. Les rôles personnalisés complètent les rôles par défaut ; les rôles système ne peuvent pas être supprimés.",
"No roles match your filters": "Aucun rôle ne correspond à vos filtres",
"Seed roles will appear once the workspace is initialised.": "Les rôles initiaux apparaîtront une fois l'espace de travail initialisé.",
"Try clearing the search or switching the filter.": "Essayez d'effacer la recherche ou de changer de filtre.",
"Back to roles": "Retour aux rôles",
"Back to members": "Retour aux membres",
"Missing role id in URL": "Identifiant de rôle manquant dans l'URL",
"Missing user id in URL": "Identifiant utilisateur manquant dans l'URL",
"Selected actions are granted to every member who holds this role.": "Les actions sélectionnées sont accordées à tous les membres qui portent ce rôle.",
"Workspace owner": "Propriétaire de l'espace",
"Selecting this overrides every other permission. Use sparingly.": "Cocher cette case écrase toutes les autres permissions. À utiliser avec parcimonie.",
"Toggle admin wildcard": "Activer le joker admin",
"Toggle wildcard for {{group}}": "Activer le joker pour {{group}}",
"Grant every permission in this group, including future ones.": "Accorde toutes les permissions de ce groupe, y compris les futures.",
"All {{group}}": "Tout {{group}}",
"all granted": "tout accordé",
"inherited via admin": "hérité via admin",
"You do not have the roles:manage permission. Permissions are read-only.": "Vous n'avez pas la permission roles:manage. Les permissions sont en lecture seule.",
"You do not have the roles:manage permission. Assignments are read-only.": "Vous n'avez pas la permission roles:manage. Les attributions sont en lecture seule.",
"Requires the roles:manage permission": "Nécessite la permission roles:manage",
"Danger zone": "Zone sensible",
"System roles are protected and cannot be deleted. You can edit their permissions but their existence is guaranteed.": "Les rôles système sont protégés et ne peuvent pas être supprimés. Leurs permissions restent éditables mais leur existence est garantie.",
"Deleting this role removes it from every user. This action cannot be undone.": "Supprimer ce rôle le retire de tous les utilisateurs. Cette action est irréversible.",
"Delete role": "Supprimer le rôle",
"This will remove the role and unassign it from every member. This action cannot be undone.": "Le rôle sera supprimé et retiré de tous les membres. Cette action est irréversible.",
"to confirm:": "pour confirmer :",
"Confirm role name": "Confirmer le nom du rôle",
"Name is required": "Le nom est requis",
"Name is too long (max 120)": "Le nom est trop long (max 120)",
"Description is too long (max 2000)": "La description est trop longue (max 2000)",
"e.g. Formateur": "ex. Formateur",
"What this role can do, in plain words": "Ce que ce rôle permet de faire, en quelques mots",
"User roles": "Rôles utilisateur",
"User role assignments": "Attributions de rôle utilisateur",
"Assigned roles": "Rôles attribués",
"A user inherits the union of every role's permissions. The owner shortcut admin:* overrides everything else.": "Un utilisateur hérite de l'union des permissions de tous ses rôles. Le raccourci propriétaire admin:* écrase tout le reste.",
"Pick one or more roles": "Choisissez un ou plusieurs rôles",
"Roles assigned to user": "Rôles attribués à l'utilisateur",
"Effective permissions preview": "Aperçu des permissions effectives",
"Permission preview requires the roles:manage permission to read role definitions.": "L'aperçu des permissions nécessite la permission roles:manage pour lire les définitions de rôle.",
"No roles selected.": "Aucun rôle sélectionné.",
"No permissions are granted by the selected roles yet.": "Les rôles sélectionnés n'accordent aucune permission pour l'instant."
}

View file

@ -45,6 +45,9 @@ import TemplateEditor from "@/ee/template/pages/template-editor";
import FavoritesPage from "@/pages/favorites/favorites-page";
import AiChat from "@/ee/ai-chat/pages/ai-chat.tsx";
import VerifyEmail from "@/ee/pages/verify-email.tsx";
import RolesListPage from "@/features/acadenice/rbac/pages/roles-list.page";
import RoleDetailPage from "@/features/acadenice/rbac/pages/role-detail.page";
import UserRolesPanelPage from "@/features/acadenice/rbac/pages/user-roles-panel";
export default function App() {
const { t } = useTranslation();
@ -123,6 +126,13 @@ export default function App() {
<Route path={"ai/mcp"} element={<AiSettings />} />
<Route path={"audit"} element={<AuditLogs />} />
<Route path={"verifications"} element={<VerifiedPages />} />
{/* Acadenice R2.2 — RBAC dynamique */}
<Route path={"roles"} element={<RolesListPage />} />
<Route path={"roles/:id"} element={<RoleDetailPage />} />
<Route
path={"users/:userId/roles"}
element={<UserRolesPanelPage />}
/>
{!isCloud() && <Route path={"license"} element={<License />} />}
{isCloud() && <Route path={"billing"} element={<Billing />} />}
</Route>

View file

@ -15,7 +15,9 @@ import {
IconSparkles,
IconHistory,
IconShieldCheck,
IconShieldLock,
} from "@tabler/icons-react";
import { useAcadenicePermissions } from "@/features/acadenice/rbac/hooks/use-acadenice-permissions";
import { Link, useLocation } from "react-router-dom";
import classes from "./settings.module.css";
import { useTranslation } from "react-i18next";
@ -51,6 +53,10 @@ type DataItem = {
feature?: string;
role?: "admin" | "owner";
env?: "cloud" | "selfhosted";
// Acadenice R2.2 — visible only when the JWT-derived (or fallback admin)
// permission set says the user can manage roles. Backend remains the
// source of truth (returns 403 otherwise).
acadeniceCanManageRoles?: boolean;
};
type DataGroup = {
@ -96,6 +102,12 @@ const groupedData: DataGroup[] = [
role: "admin",
},
{ label: "Groups", icon: IconUsersGroup, path: "/settings/groups" },
{
label: "Roles",
icon: IconShieldLock,
path: "/settings/roles",
acadeniceCanManageRoles: true,
},
{ label: "Spaces", icon: IconSpaces, path: "/settings/spaces" },
{ label: "Public sharing", icon: IconWorld, path: "/settings/sharing" },
{
@ -145,6 +157,7 @@ export default function SettingsSidebar() {
const [active, setActive] = useState(location.pathname);
const { goBack } = useSettingsNavigation();
const { isAdmin, isOwner } = useUserRole();
const { canManageRoles: acadeniceCanManageRoles } = useAcadenicePermissions();
const [entitlements] = useAtom(entitlementAtom);
const upgradeLabel = useUpgradeLabel();
const [mobileSidebarOpened] = useAtom(mobileSidebarAtom);
@ -162,6 +175,7 @@ export default function SettingsSidebar() {
if (item.env === "selfhosted" && isCloud()) return false;
if (item.role === "admin" && !isAdmin) return false;
if (item.role === "owner" && !isOwner) return false;
if (item.acadeniceCanManageRoles && !acadeniceCanManageRoles) return false;
return true;
};

View file

@ -0,0 +1,114 @@
import { describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { AllProviders } from "./test-utils";
import PermissionMatrix from "@/features/acadenice/rbac/components/permission-matrix";
import { IPermissionDescriptor } from "@/features/acadenice/rbac/types/rbac.types";
const fixtureCatalog: IPermissionDescriptor[] = [
{ key: "pages:read", group: "pages", description: "Read pages" },
{ key: "pages:write", group: "pages", description: "Edit pages" },
{ key: "pages:*", group: "pages", description: "Wildcard pages" },
{ key: "space:read", group: "space", description: "Read spaces" },
{ key: "space:write", group: "space", description: "Edit space" },
{ key: "admin:*", group: "meta", description: "Owner shortcut" },
];
function renderMatrix(
value: string[] = [],
opts: { disabled?: boolean; disabledReason?: string } = {},
) {
const onChange = vi.fn();
const utils = render(
<AllProviders>
<PermissionMatrix
catalog={fixtureCatalog}
value={value}
onChange={onChange}
disabled={opts.disabled}
disabledReason={opts.disabledReason}
/>
</AllProviders>,
);
return { ...utils, onChange };
}
describe("PermissionMatrix", () => {
it("renders one card per group with atomic checkboxes", () => {
renderMatrix();
// groups : pages, space, meta (admin extracted), so 2 group cards
expect(screen.getByTestId("group-card-pages")).toBeInTheDocument();
expect(screen.getByTestId("group-card-space")).toBeInTheDocument();
// admin lives in its own card
expect(screen.getByTestId("admin-wildcard-card")).toBeInTheDocument();
// atomic perms are rendered
expect(screen.getByTestId("cb-perm-pages:read")).toBeInTheDocument();
expect(screen.getByTestId("cb-perm-pages:write")).toBeInTheDocument();
});
it("toggling a single permission emits the new sorted set", async () => {
const user = userEvent.setup();
const { onChange } = renderMatrix(["pages:read"]);
await user.click(screen.getByTestId("cb-perm-pages:write"));
expect(onChange).toHaveBeenCalledWith(["pages:read", "pages:write"]);
});
it("removes a permission when its checkbox is unticked", async () => {
const user = userEvent.setup();
const { onChange } = renderMatrix(["pages:read", "pages:write"]);
await user.click(screen.getByTestId("cb-perm-pages:write"));
expect(onChange).toHaveBeenCalledWith(["pages:read"]);
});
it("checking a group wildcard greys individuals and replaces them", async () => {
const user = userEvent.setup();
const { onChange } = renderMatrix(["pages:read"]);
await user.click(screen.getByTestId("cb-group-wildcard-pages"));
// Only the wildcard remains in the emitted set ; individuals are stripped.
expect(onChange).toHaveBeenCalledWith(["pages:*"]);
});
it("admin:* checks every other input and replaces the set", async () => {
const user = userEvent.setup();
const { onChange } = renderMatrix(["pages:read", "space:write"]);
await user.click(screen.getByTestId("cb-admin-wildcard"));
expect(onChange).toHaveBeenCalledWith(["admin:*"]);
});
it("when admin:* is on, individual permissions appear checked and disabled", () => {
renderMatrix(["admin:*"]);
const cb = screen.getByTestId("cb-perm-pages:read") as HTMLInputElement;
expect(cb.checked).toBe(true);
expect(cb.disabled).toBe(true);
});
it("disabled mode renders inputs read-only and shows the disabled reason", () => {
renderMatrix(["pages:read"], {
disabled: true,
disabledReason: "system role is locked",
});
expect(screen.getByText(/system role is locked/i)).toBeInTheDocument();
const cb = screen.getByTestId("cb-perm-pages:read") as HTMLInputElement;
expect(cb.disabled).toBe(true);
});
it("group badge reads N/total when partially filled and switches to 'all granted' on wildcard", () => {
const { rerender } = renderMatrix(["pages:read"]);
expect(screen.getByTestId("group-count-pages").textContent).toMatch(
/1\/2/,
);
rerender(
<AllProviders>
<PermissionMatrix
catalog={fixtureCatalog}
value={["pages:*"]}
onChange={() => {}}
/>
</AllProviders>,
);
expect(screen.getByTestId("group-count-pages").textContent).toMatch(
/all granted/i,
);
});
});

View file

@ -0,0 +1,160 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Routes, Route } from "react-router-dom";
import { AllProviders, makeQueryClient } from "./test-utils";
import RoleDetailPage from "@/features/acadenice/rbac/pages/role-detail.page";
vi.mock("@/features/acadenice/rbac/services/rbac-service", () => ({
getRole: vi.fn(),
getPermissionsCatalog: vi.fn(),
setRolePermissions: vi.fn(),
updateRole: vi.fn(),
deleteRole: vi.fn(),
}));
vi.mock("@/features/acadenice/rbac/hooks/use-acadenice-permissions", () => ({
useAcadenicePermissions: vi.fn(),
}));
import {
getRole,
getPermissionsCatalog,
setRolePermissions,
deleteRole,
} from "@/features/acadenice/rbac/services/rbac-service";
import { useAcadenicePermissions } from "@/features/acadenice/rbac/hooks/use-acadenice-permissions";
const catalog = [
{ key: "pages:read", group: "pages", description: "Read pages" },
{ key: "pages:write", group: "pages", description: "Edit pages" },
{ key: "admin:*", group: "meta", description: "Owner shortcut" },
];
function setupRoute(roleId = "r2") {
return (
<AllProviders
queryClient={makeQueryClient()}
initialEntries={[`/settings/roles/${roleId}`]}
>
<Routes>
<Route path="/settings/roles/:id" element={<RoleDetailPage />} />
</Routes>
</AllProviders>
);
}
beforeEach(() => {
vi.mocked(getPermissionsCatalog).mockResolvedValue(catalog);
vi.mocked(useAcadenicePermissions).mockReturnValue({
permissions: ["admin:*"],
hasPermission: () => true,
canManageRoles: true,
isJwtClaimAvailable: true,
});
});
describe("RoleDetailPage", () => {
it("renders role identity and disables rename for system roles", async () => {
vi.mocked(getRole).mockResolvedValue({
id: "r1",
workspaceId: "w1",
name: "Owner",
description: "system",
isSystemRole: true,
createdAt: "x",
updatedAt: "x",
permissions: ["admin:*"],
});
render(setupRoute("r1"));
await waitFor(() => screen.getByDisplayValue("Owner"));
const nameInput = screen.getByTestId("role-form-name") as HTMLInputElement;
expect(nameInput.disabled).toBe(true);
});
it("disables the delete button for system roles via tooltip-wrapper", async () => {
vi.mocked(getRole).mockResolvedValue({
id: "r1",
workspaceId: "w1",
name: "Owner",
description: null,
isSystemRole: true,
createdAt: "x",
updatedAt: "x",
permissions: ["admin:*"],
});
render(setupRoute("r1"));
await waitFor(() => screen.getByDisplayValue("Owner"));
const btn = screen.getByTestId("role-delete-btn") as HTMLButtonElement;
expect(btn.disabled).toBe(true);
});
it("Save permissions calls PUT with the new set", async () => {
const user = userEvent.setup();
vi.mocked(getRole).mockResolvedValue({
id: "r2",
workspaceId: "w1",
name: "Formateur",
description: null,
isSystemRole: false,
createdAt: "x",
updatedAt: "x",
permissions: ["pages:read"],
});
vi.mocked(setRolePermissions).mockResolvedValue({
id: "r2",
workspaceId: "w1",
name: "Formateur",
description: null,
isSystemRole: false,
createdAt: "x",
updatedAt: "x",
permissions: ["pages:read", "pages:write"],
});
render(setupRoute("r2"));
await waitFor(() => screen.getByTestId("cb-perm-pages:read"));
// Save is disabled while pristine
expect(
(screen.getByTestId("perms-save-btn") as HTMLButtonElement).disabled,
).toBe(true);
await user.click(screen.getByTestId("cb-perm-pages:write"));
const save = screen.getByTestId("perms-save-btn") as HTMLButtonElement;
expect(save.disabled).toBe(false);
await user.click(save);
await waitFor(() => {
expect(setRolePermissions).toHaveBeenCalledWith("r2", [
"pages:read",
"pages:write",
]);
});
});
it("Delete confirmation modal requires typing the role name", async () => {
const user = userEvent.setup();
vi.mocked(getRole).mockResolvedValue({
id: "r2",
workspaceId: "w1",
name: "Formateur",
description: null,
isSystemRole: false,
createdAt: "x",
updatedAt: "x",
permissions: [],
});
vi.mocked(deleteRole).mockResolvedValue(undefined);
render(setupRoute("r2"));
await waitFor(() => screen.getByDisplayValue("Formateur"));
await user.click(screen.getByTestId("role-delete-btn"));
const confirmBtn = screen.getByTestId(
"delete-role-confirm-btn",
) as HTMLButtonElement;
expect(confirmBtn.disabled).toBe(true);
await user.type(
screen.getByTestId("delete-role-confirm-input"),
"Formateur",
);
expect(confirmBtn.disabled).toBe(false);
await user.click(confirmBtn);
await waitFor(() => expect(deleteRole).toHaveBeenCalledWith("r2"));
});
});

View file

@ -0,0 +1,120 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
import { render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { AllProviders, makeQueryClient } from "./test-utils";
import RolesListPage from "@/features/acadenice/rbac/pages/roles-list.page";
vi.mock("@/features/acadenice/rbac/services/rbac-service", () => ({
listRoles: vi.fn(),
getPermissionsCatalog: vi.fn(),
}));
vi.mock("@/features/acadenice/rbac/hooks/use-acadenice-permissions", () => ({
useAcadenicePermissions: vi.fn(),
}));
import { listRoles } from "@/features/acadenice/rbac/services/rbac-service";
import { useAcadenicePermissions } from "@/features/acadenice/rbac/hooks/use-acadenice-permissions";
const fixtureRoles = [
{
id: "r1",
workspaceId: "w1",
name: "Owner",
description: "Full power",
isSystemRole: true,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
},
{
id: "r2",
workspaceId: "w1",
name: "Formateur",
description: "Custom one",
isSystemRole: false,
createdAt: "2026-01-02T00:00:00.000Z",
updatedAt: "2026-01-02T00:00:00.000Z",
},
];
beforeEach(() => {
vi.mocked(listRoles).mockResolvedValue(fixtureRoles);
vi.mocked(useAcadenicePermissions).mockReturnValue({
permissions: ["roles:manage"],
hasPermission: () => true,
canManageRoles: true,
isJwtClaimAvailable: true,
});
});
describe("RolesListPage", () => {
it("shows a loader while fetching", () => {
vi.mocked(listRoles).mockImplementation(() => new Promise(() => {}));
render(
<AllProviders queryClient={makeQueryClient()}>
<RolesListPage />
</AllProviders>,
);
expect(document.querySelector(".mantine-Loader-root")).toBeTruthy();
});
it("renders system and custom rows with their badges", async () => {
render(
<AllProviders queryClient={makeQueryClient()}>
<RolesListPage />
</AllProviders>,
);
await waitFor(() => {
expect(screen.getByText("Owner")).toBeInTheDocument();
expect(screen.getByText("Formateur")).toBeInTheDocument();
});
expect(screen.getByTestId("role-badge-system-r1")).toBeInTheDocument();
});
it("hides the create button when the user lacks roles:manage", async () => {
vi.mocked(useAcadenicePermissions).mockReturnValue({
permissions: [],
hasPermission: () => false,
canManageRoles: false,
isJwtClaimAvailable: true,
});
render(
<AllProviders queryClient={makeQueryClient()}>
<RolesListPage />
</AllProviders>,
);
await waitFor(() => screen.getByText("Owner"));
expect(screen.queryByTestId("roles-create-btn")).toBeNull();
});
it("filters rows by name via the search input", async () => {
const user = userEvent.setup();
render(
<AllProviders queryClient={makeQueryClient()}>
<RolesListPage />
</AllProviders>,
);
await waitFor(() => screen.getByText("Owner"));
await user.type(screen.getByTestId("roles-search-input"), "format");
await waitFor(() => {
expect(screen.queryByText("Owner")).toBeNull();
expect(screen.getByText("Formateur")).toBeInTheDocument();
});
});
it("filters by type via the segmented control", async () => {
const user = userEvent.setup();
render(
<AllProviders queryClient={makeQueryClient()}>
<RolesListPage />
</AllProviders>,
);
await waitFor(() => screen.getByText("Owner"));
const segment = screen.getByTestId("roles-filter-segment");
await user.click(within(segment).getByText("Custom"));
await waitFor(() => {
expect(screen.queryByText("Owner")).toBeNull();
expect(screen.getByText("Formateur")).toBeInTheDocument();
});
});
});

View file

@ -0,0 +1,39 @@
import { ReactNode } from "react";
import { MantineProvider } from "@mantine/core";
import { ModalsProvider } from "@mantine/modals";
import { Notifications } from "@mantine/notifications";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { MemoryRouter } from "react-router-dom";
export function makeQueryClient(): QueryClient {
return new QueryClient({
defaultOptions: {
queries: { retry: false, gcTime: 0, staleTime: 0 },
mutations: { retry: false },
},
});
}
export function AllProviders({
children,
queryClient,
initialEntries = ["/"],
}: {
children: ReactNode;
queryClient?: QueryClient;
initialEntries?: string[];
}) {
const qc = queryClient ?? makeQueryClient();
return (
<QueryClientProvider client={qc}>
<MemoryRouter initialEntries={initialEntries}>
<MantineProvider>
<ModalsProvider>
<Notifications />
{children}
</ModalsProvider>
</MantineProvider>
</MemoryRouter>
</QueryClientProvider>
);
}

View file

@ -0,0 +1,149 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { AllProviders, makeQueryClient } from "./test-utils";
import { UserRolesPanel } from "@/features/acadenice/rbac/pages/user-roles-panel";
vi.mock("@/features/acadenice/rbac/services/rbac-service", () => ({
listRoles: vi.fn(),
listUserRoles: vi.fn(),
assignRolesToUser: vi.fn(),
unassignRoleFromUser: vi.fn(),
getRole: vi.fn(),
}));
import {
listRoles,
listUserRoles,
assignRolesToUser,
unassignRoleFromUser,
getRole,
} from "@/features/acadenice/rbac/services/rbac-service";
const allRoles = [
{
id: "r1",
workspaceId: "w1",
name: "Owner",
description: null,
isSystemRole: true,
createdAt: "x",
updatedAt: "x",
},
{
id: "r2",
workspaceId: "w1",
name: "Formateur",
description: null,
isSystemRole: false,
createdAt: "x",
updatedAt: "x",
},
{
id: "r3",
workspaceId: "w1",
name: "Apprenant",
description: null,
isSystemRole: false,
createdAt: "x",
updatedAt: "x",
},
];
beforeEach(() => {
vi.mocked(listRoles).mockResolvedValue(allRoles);
vi.mocked(listUserRoles).mockResolvedValue([
{
userId: "u1",
roleId: "r2",
workspaceId: "w1",
assignedBy: null,
assignedAt: "x",
},
]);
vi.mocked(getRole).mockImplementation(async (id: string) => ({
...(allRoles.find((r) => r.id === id) ?? allRoles[0]),
permissions:
id === "r2"
? ["pages:read", "pages:write"]
: id === "r3"
? ["pages:read"]
: ["admin:*"],
}));
vi.mocked(assignRolesToUser).mockResolvedValue({ ok: true });
vi.mocked(unassignRoleFromUser).mockResolvedValue(undefined);
});
describe("UserRolesPanel", () => {
it("hydrates the multi-select with the user's current roles", async () => {
render(
<AllProviders queryClient={makeQueryClient()}>
<UserRolesPanel userId="u1" canMutate />
</AllProviders>,
);
await waitFor(() =>
expect(screen.getByText("Formateur")).toBeInTheDocument(),
);
});
it("renders the deduplicated effective permissions union as badges", async () => {
render(
<AllProviders queryClient={makeQueryClient()}>
<UserRolesPanel userId="u1" canMutate />
</AllProviders>,
);
await waitFor(() => {
// single role r2 -> pages:read + pages:write
const preview = screen.getByTestId("effective-perms-preview");
expect(preview.textContent).toContain("pages:read");
expect(preview.textContent).toContain("pages:write");
});
});
it("Save button stays disabled while the assignment is pristine", async () => {
render(
<AllProviders queryClient={makeQueryClient()}>
<UserRolesPanel userId="u1" canMutate />
</AllProviders>,
);
await waitFor(() => screen.getByText("Formateur"));
const save = screen.getByTestId("user-roles-save-btn") as HTMLButtonElement;
expect(save.disabled).toBe(true);
});
it("disables inputs and shows a notice when canMutate is false", async () => {
render(
<AllProviders queryClient={makeQueryClient()}>
<UserRolesPanel userId="u1" canMutate={false} />
</AllProviders>,
);
await waitFor(() => screen.getByText("Formateur"));
const save = screen.getByTestId("user-roles-save-btn") as HTMLButtonElement;
expect(save.disabled).toBe(true);
expect(
screen.getByText(/roles:manage permission. Assignments are read-only/i),
).toBeInTheDocument();
});
it("Adding a role and saving triggers POST with the diff only", async () => {
const user = userEvent.setup();
render(
<AllProviders queryClient={makeQueryClient()}>
<UserRolesPanel userId="u1" canMutate />
</AllProviders>,
);
await waitFor(() => screen.getByText("Formateur"));
// Open the multi-select dropdown and pick Apprenant.
const input = screen.getByTestId("user-roles-multiselect");
await user.click(input);
const option = await screen.findByText("Apprenant");
await user.click(option);
const save = screen.getByTestId("user-roles-save-btn") as HTMLButtonElement;
await waitFor(() => expect(save.disabled).toBe(false));
await user.click(save);
await waitFor(() =>
expect(assignRolesToUser).toHaveBeenCalledWith("u1", ["r3"]),
);
expect(unassignRoleFromUser).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,83 @@
import { Button, Group, Modal, Stack, Text, TextInput } from "@mantine/core";
import { useState } from "react";
import { useTranslation } from "react-i18next";
export interface DeleteRoleModalProps {
opened: boolean;
roleName: string;
onClose: () => void;
onConfirm: () => void;
isDeleting?: boolean;
}
/**
* Two-step delete confirmation : the user must type the role name to enable
* the "Delete" button. Mantine's `Modal` traps focus and closes on Esc, so
* keyboard users get a coherent flow.
*/
export function DeleteRoleModal({
opened,
roleName,
onClose,
onConfirm,
isDeleting,
}: DeleteRoleModalProps) {
const { t } = useTranslation();
const [typed, setTyped] = useState("");
const matches = typed === roleName;
const handleClose = () => {
setTyped("");
onClose();
};
return (
<Modal
opened={opened}
onClose={handleClose}
title={t("Delete role")}
centered
data-testid="delete-role-modal"
>
<Stack gap="sm">
<Text size="sm">
{t(
"This will remove the role and unassign it from every member. This action cannot be undone.",
)}
</Text>
<Text size="sm">
{t("Type")} <strong>{roleName}</strong> {t("to confirm:")}
</Text>
<TextInput
value={typed}
onChange={(e) => setTyped(e.currentTarget.value)}
placeholder={roleName}
aria-label={t("Confirm role name")}
autoFocus
data-testid="delete-role-confirm-input"
/>
<Group justify="flex-end" mt="md">
<Button
variant="default"
onClick={handleClose}
disabled={isDeleting}
>
{t("Cancel")}
</Button>
<Button
color="red"
disabled={!matches || isDeleting}
loading={isDeleting}
onClick={onConfirm}
data-testid="delete-role-confirm-btn"
>
{t("Delete role")}
</Button>
</Group>
</Stack>
</Modal>
);
}
export default DeleteRoleModal;

View file

@ -0,0 +1,277 @@
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;

View file

@ -0,0 +1,98 @@
import { Button, Group, Stack, Textarea, TextInput } from "@mantine/core";
import { useForm } from "@mantine/form";
import { useTranslation } from "react-i18next";
import { ICreateRolePayload } from "@/features/acadenice/rbac/types/rbac.types";
export interface RoleFormProps {
initialValues?: { name?: string; description?: string };
onSubmit: (values: ICreateRolePayload) => void;
onCancel?: () => void;
isSubmitting?: boolean;
/** when true, the name field is read-only — used for system roles */
lockName?: boolean;
submitLabel?: string;
}
export function RoleForm({
initialValues,
onSubmit,
onCancel,
isSubmitting,
lockName,
submitLabel,
}: RoleFormProps) {
const { t } = useTranslation();
const form = useForm({
initialValues: {
name: initialValues?.name ?? "",
description: initialValues?.description ?? "",
},
validate: {
name: (v) => {
if (!v || v.trim().length === 0) return t("Name is required");
if (v.length > 120) return t("Name is too long (max 120)");
return null;
},
description: (v) =>
v && v.length > 2000 ? t("Description is too long (max 2000)") : null,
},
});
const handleSubmit = form.onSubmit((values) => {
onSubmit({
name: values.name.trim(),
description: values.description.trim() || undefined,
});
});
return (
<form onSubmit={handleSubmit}>
<Stack gap="sm">
<TextInput
label={t("Role name")}
placeholder={t("e.g. Formateur")}
required
disabled={lockName}
description={
lockName
? t("System roles cannot be renamed")
: undefined
}
{...form.getInputProps("name")}
data-testid="role-form-name"
/>
<Textarea
label={t("Description")}
placeholder={t("What this role can do, in plain words")}
autosize
minRows={2}
maxRows={6}
{...form.getInputProps("description")}
data-testid="role-form-description"
/>
<Group justify="flex-end" mt="sm">
{onCancel ? (
<Button
variant="default"
onClick={onCancel}
type="button"
disabled={isSubmitting}
>
{t("Cancel")}
</Button>
) : null}
<Button
type="submit"
loading={isSubmitting}
data-testid="role-form-submit"
>
{submitLabel ?? t("Save")}
</Button>
</Group>
</Stack>
</form>
);
}
export default RoleForm;

View file

@ -0,0 +1,69 @@
import { ActionIcon, Badge, Group, Table, Text, Tooltip } from "@mantine/core";
import { IconChevronRight, IconLock } from "@tabler/icons-react";
import { Link } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { IRole } from "@/features/acadenice/rbac/types/rbac.types";
export interface RoleRowProps {
role: IRole;
}
export function RoleRow({ role }: RoleRowProps) {
const { t } = useTranslation();
const target = `/settings/roles/${role.id}`;
return (
<Table.Tr data-testid={`role-row-${role.id}`}>
<Table.Td>
<Group gap="sm" wrap="nowrap">
<Text fw={500} size="sm">
{role.name}
</Text>
{role.isSystemRole ? (
<Tooltip
label={t("System role — name and existence are protected")}
withArrow
>
<Badge
size="xs"
color="gray"
variant="light"
leftSection={<IconLock size={10} />}
data-testid={`role-badge-system-${role.id}`}
>
{t("system")}
</Badge>
</Tooltip>
) : (
<Badge size="xs" color="blue" variant="light">
{t("custom")}
</Badge>
)}
</Group>
</Table.Td>
<Table.Td>
<Text size="sm" c="dimmed" lineClamp={2}>
{role.description ?? "—"}
</Text>
</Table.Td>
<Table.Td>
<Text size="sm">
{typeof role.memberCount === "number" ? role.memberCount : "—"}
</Text>
</Table.Td>
<Table.Td>
<ActionIcon
component={Link}
to={target}
variant="subtle"
aria-label={t("Open role {{name}}", { name: role.name })}
data-testid={`role-open-${role.id}`}
>
<IconChevronRight size={16} />
</ActionIcon>
</Table.Td>
</Table.Tr>
);
}
export default RoleRow;

View file

@ -0,0 +1,81 @@
import { useMemo } from "react";
import Cookies from "js-cookie";
import { jwtDecode } from "jwt-decode";
import { useAtom } from "jotai";
import useUserRole from "@/hooks/use-user-role.tsx";
import { ADMIN_WILDCARD } from "@/features/acadenice/rbac/types/rbac.types";
import { authTokensAtom } from "@/features/auth/atoms/auth-tokens-atom";
interface IDecodedAcadeniceJwt {
acadenice_permissions?: string[];
exp?: number;
}
/**
* Best-effort reader of the Acadenice permissions claim that R2.1 packs into
* the access JWT (`acadenice_permissions: string[]`).
*
* Limits :
* - Docmost stores the access token in an HttpOnly cookie (`authToken`), which
* is unreadable from JavaScript by design. We can only inspect the
* non-HttpOnly `authTokens` cookie + the legacy jotai atom both are kept
* for client-side flows like the OIDC redirect path. When neither contains a
* JWT, we degrade to the native Docmost role : OWNER / ADMIN are presumed
* manage-capable for navigation purposes only. The backend remains the
* source of truth (returns 403 if the user is not actually authorised), so
* we treat this hook strictly as UX defence-in-depth.
*
* `canManageRoles` is `true` if either :
* - the JWT exposes `roles:manage` or `admin:*`, OR
* - we have no JWT visibility AND the user is Docmost-native admin/owner.
*/
export function useAcadenicePermissions(): {
permissions: string[];
hasPermission: (key: string) => boolean;
canManageRoles: boolean;
isJwtClaimAvailable: boolean;
} {
const { isAdmin, isOwner } = useUserRole();
const [authTokens] = useAtom(authTokensAtom);
const decoded = useMemo<IDecodedAcadeniceJwt | null>(() => {
const token =
(authTokens && typeof authTokens === "object" && (authTokens as { accessToken?: string }).accessToken) ||
(typeof authTokens === "string" ? authTokens : null) ||
Cookies.get("authToken") ||
null;
if (!token || typeof token !== "string") return null;
try {
return jwtDecode<IDecodedAcadeniceJwt>(token);
} catch {
return null;
}
}, [authTokens]);
const permissions = useMemo<string[]>(() => {
if (decoded?.acadenice_permissions && Array.isArray(decoded.acadenice_permissions)) {
return decoded.acadenice_permissions;
}
return [];
}, [decoded]);
const isJwtClaimAvailable =
decoded !== null && Array.isArray(decoded.acadenice_permissions);
const hasPermission = (key: string): boolean => {
if (permissions.includes(ADMIN_WILDCARD)) return true;
if (permissions.includes(key)) return true;
const colonIdx = key.indexOf(":");
if (colonIdx <= 0) return false;
const group = key.slice(0, colonIdx);
return permissions.includes(`${group}:*`);
};
const canManageRoles = isJwtClaimAvailable
? hasPermission("roles:manage")
: // Fallback : honour Docmost-native admin/owner for navigation visibility.
// Backend still enforces the real permission.
isAdmin || isOwner;
return { permissions, hasPermission, canManageRoles, isJwtClaimAvailable };
}

View file

@ -0,0 +1,279 @@
import { useEffect, useMemo, useState } from "react";
import {
Alert,
Button,
Card,
Center,
Divider,
Group,
Loader,
Stack,
Text,
Title,
Tooltip,
} from "@mantine/core";
import { IconAlertCircle, IconArrowLeft, IconTrash } from "@tabler/icons-react";
import { useDisclosure } from "@mantine/hooks";
import { Helmet } from "react-helmet-async";
import { Link, useNavigate, useParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import SettingsTitle from "@/components/settings/settings-title.tsx";
import { getAppName } from "@/lib/config.ts";
import {
useDeleteRoleMutation,
useRoleQuery,
useSetRolePermissionsMutation,
useUpdateRoleMutation,
} from "@/features/acadenice/rbac/queries/roles-query";
import { usePermissionsCatalogQuery } from "@/features/acadenice/rbac/queries/permissions-query";
import { useAcadenicePermissions } from "@/features/acadenice/rbac/hooks/use-acadenice-permissions";
import RoleForm from "@/features/acadenice/rbac/components/role-form";
import PermissionMatrix from "@/features/acadenice/rbac/components/permission-matrix";
import DeleteRoleModal from "@/features/acadenice/rbac/components/delete-role-modal";
import classes from "@/features/acadenice/rbac/styles/role-detail.module.css";
function arraysEqual(a: string[], b: string[]): boolean {
if (a.length !== b.length) return false;
const sa = [...a].sort();
const sb = [...b].sort();
return sa.every((v, i) => v === sb[i]);
}
export default function RoleDetailPage() {
const { t } = useTranslation();
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { canManageRoles } = useAcadenicePermissions();
const roleQuery = useRoleQuery(id);
const catalogQuery = usePermissionsCatalogQuery();
const updateMutation = useUpdateRoleMutation(id ?? "");
const setPermsMutation = useSetRolePermissionsMutation(id ?? "");
const deleteMutation = useDeleteRoleMutation();
const [deleteOpened, deleteHandlers] = useDisclosure(false);
const [draftPerms, setDraftPerms] = useState<string[]>([]);
// Sync the matrix draft with the persisted set whenever the server payload
// refreshes. This makes optimistic UX simple : the parent (this page) is the
// single source of truth between fetches.
useEffect(() => {
if (roleQuery.data?.permissions) {
setDraftPerms(roleQuery.data.permissions);
}
}, [roleQuery.data?.permissions]);
const isDirty = useMemo(() => {
if (!roleQuery.data) return false;
return !arraysEqual(draftPerms, roleQuery.data.permissions);
}, [draftPerms, roleQuery.data]);
if (!id) {
return (
<Alert color="red" icon={<IconAlertCircle size={16} />}>
{t("Missing role id in URL")}
</Alert>
);
}
const isSystem = roleQuery.data?.isSystemRole ?? false;
return (
<>
<Helmet>
<title>
{roleQuery.data?.name ?? t("Role")} - {getAppName()}
</title>
</Helmet>
<Group justify="space-between" mb="sm" wrap="wrap">
<Button
component={Link}
to="/settings/roles"
variant="subtle"
leftSection={<IconArrowLeft size={14} />}
size="xs"
>
{t("Back to roles")}
</Button>
</Group>
<SettingsTitle
title={roleQuery.data?.name ?? t("Role detail")}
/>
{roleQuery.isLoading || catalogQuery.isLoading ? (
<Center py="xl">
<Loader size="sm" />
</Center>
) : roleQuery.isError ? (
<Alert
color="red"
icon={<IconAlertCircle size={16} />}
title={t("Failed to load role")}
>
<Stack gap="xs">
<Text size="sm">
{(roleQuery.error as Error)?.message ??
t("An unknown error occurred")}
</Text>
<Group>
<Button
variant="light"
size="xs"
onClick={() => roleQuery.refetch()}
>
{t("Retry")}
</Button>
<Button
component={Link}
to="/settings/roles"
variant="default"
size="xs"
>
{t("Back to roles")}
</Button>
</Group>
</Stack>
</Alert>
) : !roleQuery.data ? null : (
<>
<Card withBorder padding="md" mb="md">
<Title order={5} mb="xs">
{t("Identity")}
</Title>
<RoleForm
initialValues={{
name: roleQuery.data.name,
description: roleQuery.data.description ?? "",
}}
isSubmitting={updateMutation.isPending}
lockName={isSystem}
submitLabel={t("Save changes")}
onSubmit={(values) =>
updateMutation.mutate({
// System roles : the form locks the input, but we still
// strip the field defensively so we never try to rename.
name: isSystem ? undefined : values.name,
description: values.description ?? null,
})
}
/>
</Card>
<Card withBorder padding="md" className={classes.section}>
<Group justify="space-between" mb="sm" wrap="wrap">
<div>
<Title order={5}>{t("Permissions")}</Title>
<Text size="xs" c="dimmed">
{t(
"Selected actions are granted to every member who holds this role.",
)}
</Text>
</div>
<Group>
<Button
variant="default"
size="sm"
disabled={!isDirty || setPermsMutation.isPending}
onClick={() => setDraftPerms(roleQuery.data!.permissions)}
data-testid="perms-reset-btn"
>
{t("Discard")}
</Button>
<Button
size="sm"
loading={setPermsMutation.isPending}
disabled={!isDirty || !canManageRoles}
onClick={() => setPermsMutation.mutate(draftPerms)}
data-testid="perms-save-btn"
>
{t("Save permissions")}
</Button>
</Group>
</Group>
{catalogQuery.data ? (
<PermissionMatrix
catalog={catalogQuery.data}
value={draftPerms}
onChange={setDraftPerms}
disabled={!canManageRoles}
disabledReason={
!canManageRoles
? t(
"You do not have the roles:manage permission. Permissions are read-only.",
)
: undefined
}
/>
) : (
<Center py="xl">
<Loader size="sm" />
</Center>
)}
</Card>
<Divider my="xl" />
<Card
withBorder
padding="md"
className={classes.dangerCard}
data-testid="role-danger-zone"
>
<Title order={5} c="red" mb="xs">
{t("Danger zone")}
</Title>
<Text size="sm" c="dimmed" mb="md">
{isSystem
? t(
"System roles are protected and cannot be deleted. You can edit their permissions but their existence is guaranteed.",
)
: t(
"Deleting this role removes it from every user. This action cannot be undone.",
)}
</Text>
<Tooltip
label={
isSystem
? t("System roles cannot be deleted")
: !canManageRoles
? t("Requires the roles:manage permission")
: ""
}
disabled={!isSystem && canManageRoles}
withArrow
>
<Button
color="red"
variant="light"
leftSection={<IconTrash size={14} />}
disabled={isSystem || !canManageRoles}
onClick={deleteHandlers.open}
data-testid="role-delete-btn"
>
{t("Delete role")}
</Button>
</Tooltip>
</Card>
<DeleteRoleModal
opened={deleteOpened}
roleName={roleQuery.data.name}
isDeleting={deleteMutation.isPending}
onClose={deleteHandlers.close}
onConfirm={() =>
deleteMutation.mutate(id, {
onSuccess: () => {
deleteHandlers.close();
navigate("/settings/roles");
},
})
}
/>
</>
)}
</>
);
}

View file

@ -0,0 +1,183 @@
import { useMemo, useState } from "react";
import {
Alert,
Button,
Center,
Group,
Loader,
Modal,
SegmentedControl,
Stack,
Table,
Text,
TextInput,
} from "@mantine/core";
import { IconAlertCircle, IconPlus, IconSearch } from "@tabler/icons-react";
import { useDisclosure } from "@mantine/hooks";
import { useNavigate } from "react-router-dom";
import { Helmet } from "react-helmet-async";
import { useTranslation } from "react-i18next";
import SettingsTitle from "@/components/settings/settings-title.tsx";
import { getAppName } from "@/lib/config.ts";
import {
useCreateRoleMutation,
useRolesQuery,
} from "@/features/acadenice/rbac/queries/roles-query";
import RoleRow from "@/features/acadenice/rbac/components/role-row";
import RoleForm from "@/features/acadenice/rbac/components/role-form";
import { useAcadenicePermissions } from "@/features/acadenice/rbac/hooks/use-acadenice-permissions";
type Filter = "all" | "system" | "custom";
export default function RolesListPage() {
const { t } = useTranslation();
const navigate = useNavigate();
const { canManageRoles } = useAcadenicePermissions();
const { data, isLoading, isError, error, refetch, isRefetching } =
useRolesQuery();
const createMutation = useCreateRoleMutation();
const [createOpened, createHandlers] = useDisclosure(false);
const [search, setSearch] = useState("");
const [filter, setFilter] = useState<Filter>("all");
const filtered = useMemo(() => {
const list = data ?? [];
const q = search.trim().toLowerCase();
return list.filter((r) => {
if (filter === "system" && !r.isSystemRole) return false;
if (filter === "custom" && r.isSystemRole) return false;
if (q && !r.name.toLowerCase().includes(q)) return false;
return true;
});
}, [data, search, filter]);
return (
<>
<Helmet>
<title>
{t("Roles")} - {getAppName()}
</title>
</Helmet>
<SettingsTitle title={t("Roles")} />
<Text c="dimmed" size="sm" mb="md">
{t(
"Define what members can do across this workspace. Custom roles override defaults; system roles cannot be removed.",
)}
</Text>
<Group justify="space-between" mb="md" wrap="wrap">
<Group>
<TextInput
placeholder={t("Search roles by name")}
leftSection={<IconSearch size={14} />}
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
aria-label={t("Search roles")}
data-testid="roles-search-input"
w={260}
/>
<SegmentedControl
value={filter}
onChange={(v) => setFilter(v as Filter)}
data={[
{ label: t("All"), value: "all" },
{ label: t("System"), value: "system" },
{ label: t("Custom"), value: "custom" },
]}
data-testid="roles-filter-segment"
/>
</Group>
{canManageRoles ? (
<Button
leftSection={<IconPlus size={16} />}
onClick={createHandlers.open}
data-testid="roles-create-btn"
>
{t("Create role")}
</Button>
) : null}
</Group>
{isError ? (
<Alert
color="red"
icon={<IconAlertCircle size={16} />}
title={t("Failed to load roles")}
>
<Stack gap="xs">
<Text size="sm">
{(error as Error)?.message ?? t("An unknown error occurred")}
</Text>
<Group>
<Button
variant="light"
size="xs"
loading={isRefetching}
onClick={() => refetch()}
>
{t("Retry")}
</Button>
</Group>
</Stack>
</Alert>
) : isLoading ? (
<Center py="xl">
<Loader size="sm" />
</Center>
) : filtered.length === 0 ? (
<Center py="xl">
<Stack align="center" gap="xs">
<Text fw={500}>{t("No roles match your filters")}</Text>
<Text c="dimmed" size="sm">
{data?.length === 0
? t("Seed roles will appear once the workspace is initialised.")
: t("Try clearing the search or switching the filter.")}
</Text>
</Stack>
</Center>
) : (
<Table.ScrollContainer minWidth={600}>
<Table highlightOnHover verticalSpacing="sm" layout="fixed">
<Table.Thead>
<Table.Tr>
<Table.Th>{t("Name")}</Table.Th>
<Table.Th>{t("Description")}</Table.Th>
<Table.Th w={120}>{t("Members")}</Table.Th>
<Table.Th w={60} aria-label={t("Open")} />
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{filtered.map((role) => (
<RoleRow key={role.id} role={role} />
))}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
)}
<Modal
opened={createOpened}
onClose={createHandlers.close}
title={t("Create a new role")}
centered
data-testid="create-role-modal"
>
<RoleForm
submitLabel={t("Create role")}
isSubmitting={createMutation.isPending}
onCancel={createHandlers.close}
onSubmit={(values) =>
createMutation.mutate(values, {
onSuccess: (created) => {
createHandlers.close();
navigate(`/settings/roles/${created.id}`);
},
})
}
/>
</Modal>
</>
);
}

View file

@ -0,0 +1,288 @@
import { useEffect, useMemo, useState } from "react";
import { useQueries } from "@tanstack/react-query";
import {
Alert,
Badge,
Button,
Card,
Center,
Divider,
Group,
Loader,
MultiSelect,
Stack,
Text,
Title,
} from "@mantine/core";
import { IconAlertCircle, IconArrowLeft } from "@tabler/icons-react";
import { Helmet } from "react-helmet-async";
import { Link, useParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import SettingsTitle from "@/components/settings/settings-title.tsx";
import { getAppName } from "@/lib/config.ts";
import { useRolesQuery, roleQueryKey } from "@/features/acadenice/rbac/queries/roles-query";
import {
useAssignRolesMutation,
useUnassignRoleMutation,
useUserRolesQuery,
} from "@/features/acadenice/rbac/queries/user-roles-query";
import { getRole } from "@/features/acadenice/rbac/services/rbac-service";
import { ADMIN_WILDCARD } from "@/features/acadenice/rbac/types/rbac.types";
import { useAcadenicePermissions } from "@/features/acadenice/rbac/hooks/use-acadenice-permissions";
/**
* Embedded panel + standalone page : assign roles to a user.
*
* The Acadenice backend authorises self-listing roles, so a user can preview
* their own assignments but mutation requires `roles:manage` AND a different
* actor (anti-escalation enforced server-side).
*/
export default function UserRolesPanelPage() {
const { t } = useTranslation();
const { userId } = useParams<{ userId: string }>();
const { canManageRoles } = useAcadenicePermissions();
if (!userId) {
return (
<Alert color="red" icon={<IconAlertCircle size={16} />}>
{t("Missing user id in URL")}
</Alert>
);
}
return (
<>
<Helmet>
<title>
{t("User roles")} - {getAppName()}
</title>
</Helmet>
<Group justify="space-between" mb="sm">
<Button
component={Link}
to="/settings/members"
variant="subtle"
leftSection={<IconArrowLeft size={14} />}
size="xs"
>
{t("Back to members")}
</Button>
</Group>
<SettingsTitle title={t("User role assignments")} />
<UserRolesPanel userId={userId} canMutate={canManageRoles} />
</>
);
}
export function UserRolesPanel({
userId,
canMutate,
}: {
userId: string;
canMutate: boolean;
}) {
const { t } = useTranslation();
const rolesQuery = useRolesQuery();
const userRolesQuery = useUserRolesQuery(userId);
const assignMutation = useAssignRolesMutation(userId);
const unassignMutation = useUnassignRoleMutation(userId);
const initialRoleIds = useMemo<string[]>(() => {
return (userRolesQuery.data ?? []).map((a) => a.roleId);
}, [userRolesQuery.data]);
const [selectedRoleIds, setSelectedRoleIds] = useState<string[]>([]);
// Sync local state with server snapshot when it (re-)arrives.
useEffect(() => {
setSelectedRoleIds(initialRoleIds);
}, [initialRoleIds]);
// Fetch the permissions of each *currently selected* role so we can render
// the union preview. We only run these queries when the user has the
// `roles:manage` permission — otherwise the backend rejects with 403 and
// we'd waste cycles on noisy errors.
const detailQueries = useQueries({
queries: (canMutate ? selectedRoleIds : []).map((roleId) => ({
queryKey: roleQueryKey(roleId),
queryFn: () => getRole(roleId),
staleTime: 60 * 1000,
})),
});
const previewPermissions = useMemo<string[]>(() => {
if (!canMutate) return [];
const set = new Set<string>();
for (const q of detailQueries) {
if (q.data?.permissions) {
for (const p of q.data.permissions) set.add(p);
}
}
if (set.has(ADMIN_WILDCARD)) return [ADMIN_WILDCARD];
return Array.from(set).sort();
}, [detailQueries, canMutate]);
const isDirty =
initialRoleIds.length !== selectedRoleIds.length ||
initialRoleIds.some((id) => !selectedRoleIds.includes(id)) ||
selectedRoleIds.some((id) => !initialRoleIds.includes(id));
const isMutationPending =
assignMutation.isPending || unassignMutation.isPending;
const handleSave = () => {
if (!canMutate) return;
const toAdd = selectedRoleIds.filter((id) => !initialRoleIds.includes(id));
const toRemove = initialRoleIds.filter(
(id) => !selectedRoleIds.includes(id),
);
// Run the diff sequentially so error messages map cleanly to the operation
// that failed. Both mutations invalidate the same query key on success.
if (toAdd.length > 0) {
assignMutation.mutate(toAdd);
}
for (const id of toRemove) {
unassignMutation.mutate(id);
}
};
if (rolesQuery.isLoading || userRolesQuery.isLoading) {
return (
<Center py="xl">
<Loader size="sm" />
</Center>
);
}
if (rolesQuery.isError || userRolesQuery.isError) {
return (
<Alert
color="red"
icon={<IconAlertCircle size={16} />}
title={t("Failed to load role assignments")}
>
<Stack gap="xs">
<Text size="sm">
{((rolesQuery.error ?? userRolesQuery.error) as Error)?.message ??
t("An unknown error occurred")}
</Text>
<Group>
<Button
variant="light"
size="xs"
onClick={() => {
rolesQuery.refetch();
userRolesQuery.refetch();
}}
>
{t("Retry")}
</Button>
</Group>
</Stack>
</Alert>
);
}
const options = (rolesQuery.data ?? []).map((r) => ({
value: r.id,
label: r.isSystemRole ? `${r.name} (${t("system")})` : r.name,
}));
return (
<Stack gap="md">
<Card withBorder padding="md">
<Title order={5} mb="xs">
{t("Assigned roles")}
</Title>
<Text size="xs" c="dimmed" mb="sm">
{t(
"A user inherits the union of every role's permissions. The owner shortcut admin:* overrides everything else.",
)}
</Text>
<MultiSelect
data={options}
value={selectedRoleIds}
onChange={setSelectedRoleIds}
placeholder={t("Pick one or more roles")}
searchable
clearable
disabled={!canMutate || isMutationPending}
aria-label={t("Roles assigned to user")}
data-testid="user-roles-multiselect"
/>
{!canMutate ? (
<Alert
mt="sm"
color="yellow"
variant="light"
icon={<IconAlertCircle size={14} />}
>
{t(
"You do not have the roles:manage permission. Assignments are read-only.",
)}
</Alert>
) : null}
<Group justify="flex-end" mt="md">
<Button
variant="default"
disabled={!isDirty || isMutationPending}
onClick={() => setSelectedRoleIds(initialRoleIds)}
data-testid="user-roles-discard-btn"
>
{t("Discard")}
</Button>
<Button
disabled={!canMutate || !isDirty}
loading={isMutationPending}
onClick={handleSave}
data-testid="user-roles-save-btn"
>
{t("Save assignments")}
</Button>
</Group>
</Card>
<Divider />
<Card withBorder padding="md" data-testid="effective-perms-preview">
<Title order={5} mb="xs">
{t("Effective permissions preview")}
</Title>
{!canMutate ? (
<Text size="sm" c="dimmed">
{t(
"Permission preview requires the roles:manage permission to read role definitions.",
)}
</Text>
) : detailQueries.some((q) => q.isLoading) ? (
<Center py="md">
<Loader size="xs" />
</Center>
) : previewPermissions.length === 0 ? (
<Text size="sm" c="dimmed">
{selectedRoleIds.length === 0
? t("No roles selected.")
: t("No permissions are granted by the selected roles yet.")}
</Text>
) : (
<Group gap="xs" wrap="wrap">
{previewPermissions.map((p) => (
<Badge
key={p}
size="sm"
color={p === ADMIN_WILDCARD ? "yellow" : "blue"}
variant="light"
>
{p}
</Badge>
))}
</Group>
)}
</Card>
</Stack>
);
}

View file

@ -0,0 +1,19 @@
import { useQuery, UseQueryResult } from "@tanstack/react-query";
import { getPermissionsCatalog } from "@/features/acadenice/rbac/services/rbac-service";
import { IPermissionDescriptor } from "@/features/acadenice/rbac/types/rbac.types";
export const PERMISSIONS_CATALOG_KEY = ["acadenice", "permissions"] as const;
export function usePermissionsCatalogQuery(): UseQueryResult<
IPermissionDescriptor[],
Error
> {
return useQuery({
queryKey: PERMISSIONS_CATALOG_KEY,
queryFn: () => getPermissionsCatalog(),
// The catalog is closed and shipped with the backend release.
// Refetching it within a session is wasted bandwidth.
staleTime: 30 * 60 * 1000,
gcTime: 60 * 60 * 1000,
});
}

View file

@ -0,0 +1,139 @@
import {
useMutation,
useQuery,
useQueryClient,
UseQueryResult,
} from "@tanstack/react-query";
import { notifications } from "@mantine/notifications";
import { useTranslation } from "react-i18next";
import {
createRole,
deleteRole,
getRole,
listRoles,
setRolePermissions,
updateRole,
} from "@/features/acadenice/rbac/services/rbac-service";
import {
ICreateRolePayload,
IRole,
IRoleWithPermissions,
IUpdateRolePayload,
} from "@/features/acadenice/rbac/types/rbac.types";
export const ROLES_QUERY_KEY = ["acadenice", "roles"] as const;
export const roleQueryKey = (id: string) =>
["acadenice", "role", id] as const;
export function useRolesQuery(): UseQueryResult<IRole[], Error> {
return useQuery({
queryKey: ROLES_QUERY_KEY,
queryFn: () => listRoles(),
staleTime: 60 * 1000,
});
}
export function useRoleQuery(
roleId: string | undefined,
): UseQueryResult<IRoleWithPermissions, Error> {
return useQuery({
queryKey: roleQueryKey(roleId ?? ""),
queryFn: () => getRole(roleId as string),
enabled: !!roleId,
});
}
function extractApiError(error: unknown): string | undefined {
// axios attaches the body on error.response.data
// best-effort, falls back to undefined so the caller can show its own copy
const e = error as {
response?: { data?: { message?: string | string[] } };
message?: string;
};
const msg = e?.response?.data?.message;
if (Array.isArray(msg)) return msg.join(", ");
if (typeof msg === "string") return msg;
return e?.message;
}
export function useCreateRoleMutation() {
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation<IRoleWithPermissions, Error, ICreateRolePayload>({
mutationFn: (payload) => createRole(payload),
onSuccess: (created) => {
notifications.show({ message: t("Role created successfully") });
queryClient.invalidateQueries({ queryKey: ROLES_QUERY_KEY });
queryClient.setQueryData(roleQueryKey(created.id), created);
},
onError: (error) => {
notifications.show({
message:
extractApiError(error) ?? t("Failed to create role"),
color: "red",
});
},
});
}
export function useUpdateRoleMutation(roleId: string) {
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation<IRole, Error, IUpdateRolePayload>({
mutationFn: (payload) => updateRole(roleId, payload),
onSuccess: () => {
notifications.show({ message: t("Role updated successfully") });
queryClient.invalidateQueries({ queryKey: roleQueryKey(roleId) });
queryClient.invalidateQueries({ queryKey: ROLES_QUERY_KEY });
},
onError: (error) => {
notifications.show({
message: extractApiError(error) ?? t("Failed to update role"),
color: "red",
});
},
});
}
export function useDeleteRoleMutation() {
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation<void, Error, string>({
mutationFn: (roleId) => deleteRole(roleId),
onSuccess: (_data, roleId) => {
notifications.show({ message: t("Role deleted successfully") });
queryClient.removeQueries({ queryKey: roleQueryKey(roleId) });
queryClient.invalidateQueries({ queryKey: ROLES_QUERY_KEY });
},
onError: (error) => {
notifications.show({
message: extractApiError(error) ?? t("Failed to delete role"),
color: "red",
});
},
});
}
export function useSetRolePermissionsMutation(roleId: string) {
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation<IRoleWithPermissions, Error, string[]>({
mutationFn: (permissions) => setRolePermissions(roleId, permissions),
onSuccess: (updated) => {
notifications.show({ message: t("Permissions saved") });
queryClient.setQueryData(roleQueryKey(roleId), updated);
queryClient.invalidateQueries({ queryKey: ROLES_QUERY_KEY });
},
onError: (error) => {
notifications.show({
message:
extractApiError(error) ?? t("Failed to save permissions"),
color: "red",
});
},
});
}

View file

@ -0,0 +1,81 @@
import {
useMutation,
useQuery,
useQueryClient,
UseQueryResult,
} from "@tanstack/react-query";
import { notifications } from "@mantine/notifications";
import { useTranslation } from "react-i18next";
import {
assignRolesToUser,
listUserRoles,
unassignRoleFromUser,
} from "@/features/acadenice/rbac/services/rbac-service";
import { IUserRoleAssignment } from "@/features/acadenice/rbac/types/rbac.types";
import { ROLES_QUERY_KEY } from "@/features/acadenice/rbac/queries/roles-query";
export const userRolesQueryKey = (userId: string) =>
["acadenice", "user-roles", userId] as const;
export function useUserRolesQuery(
userId: string | undefined,
): UseQueryResult<IUserRoleAssignment[], Error> {
return useQuery({
queryKey: userRolesQueryKey(userId ?? ""),
queryFn: () => listUserRoles(userId as string),
enabled: !!userId,
staleTime: 30 * 1000,
});
}
function extractApiError(error: unknown): string | undefined {
const e = error as {
response?: { data?: { message?: string | string[] } };
message?: string;
};
const msg = e?.response?.data?.message;
if (Array.isArray(msg)) return msg.join(", ");
if (typeof msg === "string") return msg;
return e?.message;
}
export function useAssignRolesMutation(userId: string) {
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation<{ ok: true }, Error, string[]>({
mutationFn: (roleIds) => assignRolesToUser(userId, roleIds),
onSuccess: () => {
notifications.show({ message: t("Role assignments updated") });
queryClient.invalidateQueries({ queryKey: userRolesQueryKey(userId) });
queryClient.invalidateQueries({ queryKey: ROLES_QUERY_KEY });
},
onError: (error) => {
notifications.show({
message:
extractApiError(error) ?? t("Failed to assign roles"),
color: "red",
});
},
});
}
export function useUnassignRoleMutation(userId: string) {
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation<void, Error, string>({
mutationFn: (roleId) => unassignRoleFromUser(userId, roleId),
onSuccess: () => {
notifications.show({ message: t("Role removed from user") });
queryClient.invalidateQueries({ queryKey: userRolesQueryKey(userId) });
queryClient.invalidateQueries({ queryKey: ROLES_QUERY_KEY });
},
onError: (error) => {
notifications.show({
message: extractApiError(error) ?? t("Failed to remove role"),
color: "red",
});
},
});
}

View file

@ -0,0 +1,89 @@
import api from "@/lib/api-client";
import {
IPermissionDescriptor,
IRole,
IRoleWithPermissions,
IUserRoleAssignment,
ICreateRolePayload,
IUpdateRolePayload,
} from "@/features/acadenice/rbac/types/rbac.types";
/**
* REST client for the Acadenice RBAC API (R2.1 backend).
* Endpoints under /api/acadenice relative to api.baseURL ("/api").
*
* Note : Docmost's axios interceptor returns `response.data` directly, so the
* return value of `api.get(...)` is already the body payload.
*/
export async function getPermissionsCatalog(): Promise<IPermissionDescriptor[]> {
return api.get("/acadenice/permissions") as unknown as Promise<
IPermissionDescriptor[]
>;
}
export async function listRoles(): Promise<IRole[]> {
return api.get("/acadenice/roles") as unknown as Promise<IRole[]>;
}
export async function getRole(roleId: string): Promise<IRoleWithPermissions> {
return api.get(
`/acadenice/roles/${roleId}`,
) as unknown as Promise<IRoleWithPermissions>;
}
export async function createRole(
payload: ICreateRolePayload,
): Promise<IRoleWithPermissions> {
return api.post(
"/acadenice/roles",
payload,
) as unknown as Promise<IRoleWithPermissions>;
}
export async function updateRole(
roleId: string,
payload: IUpdateRolePayload,
): Promise<IRole> {
return api.patch(
`/acadenice/roles/${roleId}`,
payload,
) as unknown as Promise<IRole>;
}
export async function deleteRole(roleId: string): Promise<void> {
await api.delete(`/acadenice/roles/${roleId}`);
}
export async function setRolePermissions(
roleId: string,
permissions: string[],
): Promise<IRoleWithPermissions> {
return api.put(`/acadenice/roles/${roleId}/permissions`, {
permissions,
}) as unknown as Promise<IRoleWithPermissions>;
}
export async function listUserRoles(
userId: string,
): Promise<IUserRoleAssignment[]> {
return api.get(`/acadenice/users/${userId}/roles`) as unknown as Promise<
IUserRoleAssignment[]
>;
}
export async function assignRolesToUser(
userId: string,
roleIds: string[],
): Promise<{ ok: true }> {
return api.post(`/acadenice/users/${userId}/roles`, {
roleIds,
}) as unknown as Promise<{ ok: true }>;
}
export async function unassignRoleFromUser(
userId: string,
roleId: string,
): Promise<void> {
await api.delete(`/acadenice/users/${userId}/roles/${roleId}`);
}

View file

@ -0,0 +1,4 @@
.adminCard {
background: light-dark(var(--mantine-color-yellow-0), var(--mantine-color-dark-6));
border-color: light-dark(var(--mantine-color-yellow-3), var(--mantine-color-yellow-7));
}

View file

@ -0,0 +1,7 @@
.section {
margin-top: var(--mantine-spacing-xl);
}
.dangerCard {
border-color: light-dark(var(--mantine-color-red-3), var(--mantine-color-red-7));
}

View file

@ -0,0 +1,75 @@
/**
* Types front aligned on the Acadenice RBAC backend DTOs (R2.1).
* Source of truth : apps/server/src/core/acadenice/rbac/dto/role.dto.ts
* apps/server/src/core/acadenice/rbac/permissions-catalog.ts
*/
export const ADMIN_WILDCARD = "admin:*";
export const PERMISSION_GROUPS = [
"pages",
"space",
"tables",
"rows",
"attachments",
"users",
"meta",
] as const;
export type PermissionGroup = (typeof PERMISSION_GROUPS)[number];
export interface IPermissionDescriptor {
key: string;
group: string;
description: string;
}
export interface IRole {
id: string;
workspaceId: string;
name: string;
description: string | null;
isSystemRole: boolean;
createdAt: string;
updatedAt: string;
/** populated client-side for the list view ; backend may not project it */
memberCount?: number;
}
export interface IRoleWithPermissions extends IRole {
permissions: string[];
}
export interface IUserRoleAssignment {
userId: string;
roleId: string;
workspaceId: string;
assignedBy: string | null;
assignedAt: string;
role?: IRole;
}
export interface ICreateRolePayload {
name: string;
description?: string;
permissions?: string[];
}
export interface IUpdateRolePayload {
name?: string;
description?: string | null;
}
/**
* Type of node in the permission matrix UI.
* - admin : the global admin:* wildcard (greys everything else when checked)
* - groupAll : a `<group>:*` super-permission (greys individuals of that group)
* - perm : a single atomic permission key
*/
export type IMatrixNodeType = "admin" | "groupAll" | "perm";
export interface IPermissionGroupView {
group: string;
/** atomic permissions belonging to this group, excluding wildcards */
items: IPermissionDescriptor[];
/** true if this group has a `<group>:*` wildcard available in the catalog */
hasGroupWildcard: boolean;
}

View file

@ -0,0 +1,30 @@
import "@testing-library/jest-dom/vitest";
import { afterEach, vi } from "vitest";
import { cleanup } from "@testing-library/react";
afterEach(() => {
cleanup();
});
// Stubs for browser APIs Mantine relies on but jsdom does not provide.
if (typeof window !== "undefined") {
if (!window.matchMedia) {
window.matchMedia = vi.fn().mockImplementation((query: string) => ({
matches: false,
media: query,
onchange: null,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
addListener: vi.fn(),
removeListener: vi.fn(),
dispatchEvent: vi.fn(),
}));
}
if (!(window as unknown as { ResizeObserver?: unknown }).ResizeObserver) {
(window as unknown as { ResizeObserver: unknown }).ResizeObserver = class {
observe() {}
unobserve() {}
disconnect() {}
};
}
}

View file

@ -0,0 +1,20 @@
/// <reference types="vitest" />
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
import * as path from "path";
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
test: {
environment: "jsdom",
globals: true,
setupFiles: ["./src/test-setup.ts"],
include: ["src/**/__tests__/**/*.test.{ts,tsx}"],
css: false,
},
});