From 022add9acc92b4b96a234b39733d208e034be220 Mon Sep 17 00:00:00 2001 From: Corentin Date: Thu, 7 May 2026 22:42:39 +0200 Subject: [PATCH] feat(rbac): R2.2 frontend pages settings RBAC dynamique avec PermissionMatrix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 :* 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 --- ACADENICE_PATCHES.md | 114 +++++++ apps/client/package.json | 11 +- .../public/locales/en-US/translation.json | 83 ++++- .../public/locales/fr-FR/translation.json | 85 +++++- apps/client/src/App.tsx | 10 + .../components/settings/settings-sidebar.tsx | 14 + .../rbac/__tests__/permission-matrix.test.tsx | 114 +++++++ .../rbac/__tests__/role-detail.page.test.tsx | 160 ++++++++++ .../rbac/__tests__/roles-list.page.test.tsx | 120 ++++++++ .../acadenice/rbac/__tests__/test-utils.tsx | 39 +++ .../rbac/__tests__/user-roles-panel.test.tsx | 149 +++++++++ .../rbac/components/delete-role-modal.tsx | 83 +++++ .../rbac/components/permission-matrix.tsx | 277 +++++++++++++++++ .../acadenice/rbac/components/role-form.tsx | 98 ++++++ .../acadenice/rbac/components/role-row.tsx | 69 +++++ .../rbac/hooks/use-acadenice-permissions.ts | 81 +++++ .../acadenice/rbac/pages/role-detail.page.tsx | 279 +++++++++++++++++ .../acadenice/rbac/pages/roles-list.page.tsx | 183 +++++++++++ .../acadenice/rbac/pages/user-roles-panel.tsx | 288 ++++++++++++++++++ .../rbac/queries/permissions-query.ts | 19 ++ .../acadenice/rbac/queries/roles-query.ts | 139 +++++++++ .../rbac/queries/user-roles-query.ts | 81 +++++ .../acadenice/rbac/services/rbac-service.ts | 89 ++++++ .../rbac/styles/permission-matrix.module.css | 4 + .../rbac/styles/role-detail.module.css | 7 + .../acadenice/rbac/types/rbac.types.ts | 75 +++++ apps/client/src/test-setup.ts | 30 ++ apps/client/vitest.config.ts | 20 ++ 28 files changed, 2717 insertions(+), 4 deletions(-) create mode 100644 apps/client/src/features/acadenice/rbac/__tests__/permission-matrix.test.tsx create mode 100644 apps/client/src/features/acadenice/rbac/__tests__/role-detail.page.test.tsx create mode 100644 apps/client/src/features/acadenice/rbac/__tests__/roles-list.page.test.tsx create mode 100644 apps/client/src/features/acadenice/rbac/__tests__/test-utils.tsx create mode 100644 apps/client/src/features/acadenice/rbac/__tests__/user-roles-panel.test.tsx create mode 100644 apps/client/src/features/acadenice/rbac/components/delete-role-modal.tsx create mode 100644 apps/client/src/features/acadenice/rbac/components/permission-matrix.tsx create mode 100644 apps/client/src/features/acadenice/rbac/components/role-form.tsx create mode 100644 apps/client/src/features/acadenice/rbac/components/role-row.tsx create mode 100644 apps/client/src/features/acadenice/rbac/hooks/use-acadenice-permissions.ts create mode 100644 apps/client/src/features/acadenice/rbac/pages/role-detail.page.tsx create mode 100644 apps/client/src/features/acadenice/rbac/pages/roles-list.page.tsx create mode 100644 apps/client/src/features/acadenice/rbac/pages/user-roles-panel.tsx create mode 100644 apps/client/src/features/acadenice/rbac/queries/permissions-query.ts create mode 100644 apps/client/src/features/acadenice/rbac/queries/roles-query.ts create mode 100644 apps/client/src/features/acadenice/rbac/queries/user-roles-query.ts create mode 100644 apps/client/src/features/acadenice/rbac/services/rbac-service.ts create mode 100644 apps/client/src/features/acadenice/rbac/styles/permission-matrix.module.css create mode 100644 apps/client/src/features/acadenice/rbac/styles/role-detail.module.css create mode 100644 apps/client/src/features/acadenice/rbac/types/rbac.types.ts create mode 100644 apps/client/src/test-setup.ts create mode 100644 apps/client/vitest.config.ts diff --git a/ACADENICE_PATCHES.md b/ACADENICE_PATCHES.md index 7d0a1e30..2f644c30 100644 --- a/ACADENICE_PATCHES.md +++ b/ACADENICE_PATCHES.md @@ -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 +- `:*` 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 `` 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) diff --git a/apps/client/package.json b/apps/client/package.json index 404df47e..ed46542a 100644 --- a/apps/client/package.json +++ b/apps/client/package.json @@ -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" } } diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json index 8cccd2ad..c0e59a19 100644 --- a/apps/client/public/locales/en-US/translation.json +++ b/apps/client/public/locales/en-US/translation.json @@ -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." } diff --git a/apps/client/public/locales/fr-FR/translation.json b/apps/client/public/locales/fr-FR/translation.json index 90172476..2a593cbb 100644 --- a/apps/client/public/locales/fr-FR/translation.json +++ b/apps/client/public/locales/fr-FR/translation.json @@ -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." } diff --git a/apps/client/src/App.tsx b/apps/client/src/App.tsx index a75afc22..f07a02c6 100644 --- a/apps/client/src/App.tsx +++ b/apps/client/src/App.tsx @@ -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() { } /> } /> } /> + {/* Acadenice R2.2 — RBAC dynamique */} + } /> + } /> + } + /> {!isCloud() && } />} {isCloud() && } />} diff --git a/apps/client/src/components/settings/settings-sidebar.tsx b/apps/client/src/components/settings/settings-sidebar.tsx index 542cad91..1f89b745 100644 --- a/apps/client/src/components/settings/settings-sidebar.tsx +++ b/apps/client/src/components/settings/settings-sidebar.tsx @@ -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; }; diff --git a/apps/client/src/features/acadenice/rbac/__tests__/permission-matrix.test.tsx b/apps/client/src/features/acadenice/rbac/__tests__/permission-matrix.test.tsx new file mode 100644 index 00000000..7c6b0998 --- /dev/null +++ b/apps/client/src/features/acadenice/rbac/__tests__/permission-matrix.test.tsx @@ -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( + + + , + ); + 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( + + {}} + /> + , + ); + expect(screen.getByTestId("group-count-pages").textContent).toMatch( + /all granted/i, + ); + }); +}); diff --git a/apps/client/src/features/acadenice/rbac/__tests__/role-detail.page.test.tsx b/apps/client/src/features/acadenice/rbac/__tests__/role-detail.page.test.tsx new file mode 100644 index 00000000..e1262c69 --- /dev/null +++ b/apps/client/src/features/acadenice/rbac/__tests__/role-detail.page.test.tsx @@ -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 ( + + + } /> + + + ); +} + +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")); + }); +}); diff --git a/apps/client/src/features/acadenice/rbac/__tests__/roles-list.page.test.tsx b/apps/client/src/features/acadenice/rbac/__tests__/roles-list.page.test.tsx new file mode 100644 index 00000000..4242e1b8 --- /dev/null +++ b/apps/client/src/features/acadenice/rbac/__tests__/roles-list.page.test.tsx @@ -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( + + + , + ); + expect(document.querySelector(".mantine-Loader-root")).toBeTruthy(); + }); + + it("renders system and custom rows with their badges", async () => { + render( + + + , + ); + 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( + + + , + ); + 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( + + + , + ); + 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( + + + , + ); + 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(); + }); + }); +}); diff --git a/apps/client/src/features/acadenice/rbac/__tests__/test-utils.tsx b/apps/client/src/features/acadenice/rbac/__tests__/test-utils.tsx new file mode 100644 index 00000000..fa9a4a94 --- /dev/null +++ b/apps/client/src/features/acadenice/rbac/__tests__/test-utils.tsx @@ -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 ( + + + + + + {children} + + + + + ); +} diff --git a/apps/client/src/features/acadenice/rbac/__tests__/user-roles-panel.test.tsx b/apps/client/src/features/acadenice/rbac/__tests__/user-roles-panel.test.tsx new file mode 100644 index 00000000..25dba012 --- /dev/null +++ b/apps/client/src/features/acadenice/rbac/__tests__/user-roles-panel.test.tsx @@ -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( + + + , + ); + await waitFor(() => + expect(screen.getByText("Formateur")).toBeInTheDocument(), + ); + }); + + it("renders the deduplicated effective permissions union as badges", async () => { + render( + + + , + ); + 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( + + + , + ); + 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( + + + , + ); + 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( + + + , + ); + 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(); + }); +}); diff --git a/apps/client/src/features/acadenice/rbac/components/delete-role-modal.tsx b/apps/client/src/features/acadenice/rbac/components/delete-role-modal.tsx new file mode 100644 index 00000000..c68e58e6 --- /dev/null +++ b/apps/client/src/features/acadenice/rbac/components/delete-role-modal.tsx @@ -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 ( + + + + {t( + "This will remove the role and unassign it from every member. This action cannot be undone.", + )} + + + {t("Type")} {roleName} {t("to confirm:")} + + setTyped(e.currentTarget.value)} + placeholder={roleName} + aria-label={t("Confirm role name")} + autoFocus + data-testid="delete-role-confirm-input" + /> + + + + + + + ); +} + +export default DeleteRoleModal; diff --git a/apps/client/src/features/acadenice/rbac/components/permission-matrix.tsx b/apps/client/src/features/acadenice/rbac/components/permission-matrix.tsx new file mode 100644 index 00000000..9cc1d61f --- /dev/null +++ b/apps/client/src/features/acadenice/rbac/components/permission-matrix.tsx @@ -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 `:*` 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; diff --git a/apps/client/src/features/acadenice/rbac/components/role-form.tsx b/apps/client/src/features/acadenice/rbac/components/role-form.tsx new file mode 100644 index 00000000..fe519de8 --- /dev/null +++ b/apps/client/src/features/acadenice/rbac/components/role-form.tsx @@ -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 ( +
+ + +