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:
parent
bcd861126f
commit
022add9acc
28 changed files with 2717 additions and 4 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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"));
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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");
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
139
apps/client/src/features/acadenice/rbac/queries/roles-query.ts
Normal file
139
apps/client/src/features/acadenice/rbac/queries/roles-query.ts
Normal 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",
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -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",
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -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}`);
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
75
apps/client/src/features/acadenice/rbac/types/rbac.types.ts
Normal file
75
apps/client/src/features/acadenice/rbac/types/rbac.types.ts
Normal 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;
|
||||
}
|
||||
30
apps/client/src/test-setup.ts
Normal file
30
apps/client/src/test-setup.ts
Normal 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() {}
|
||||
};
|
||||
}
|
||||
}
|
||||
20
apps/client/vitest.config.ts
Normal file
20
apps/client/vitest.config.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue