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)
|
### TODO rebrand complet (futur)
|
||||||
|
|
||||||
- Logo SVG / favicon DocAdenice (actuellement reutilise `/icons/favicon-32x32.png` upstream)
|
- Logo SVG / favicon DocAdenice (actuellement reutilise `/icons/favicon-32x32.png` upstream)
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,9 @@
|
||||||
"build": "tsc && vite build",
|
"build": "tsc && vite build",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"format": "prettier --write \"src/**/*.tsx\" \"src/**/*.ts\""
|
"format": "prettier --write \"src/**/*.tsx\" \"src/**/*.ts\"",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@casl/react": "^5.0.1",
|
"@casl/react": "^5.0.1",
|
||||||
|
|
@ -80,6 +82,11 @@
|
||||||
"prettier": "^3.8.1",
|
"prettier": "^3.8.1",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"typescript-eslint": "^8.57.1",
|
"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",
|
"Settings navigation": "Settings navigation",
|
||||||
"AI navigation": "AI navigation",
|
"AI navigation": "AI navigation",
|
||||||
"Breadcrumb": "Breadcrumb",
|
"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 a different search term.": "Essayez un autre terme de recherche.",
|
||||||
"Try again": "Réessayer",
|
"Try again": "Réessayer",
|
||||||
"Untitled chat": "Discussion sans titre",
|
"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 FavoritesPage from "@/pages/favorites/favorites-page";
|
||||||
import AiChat from "@/ee/ai-chat/pages/ai-chat.tsx";
|
import AiChat from "@/ee/ai-chat/pages/ai-chat.tsx";
|
||||||
import VerifyEmail from "@/ee/pages/verify-email.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() {
|
export default function App() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
@ -123,6 +126,13 @@ export default function App() {
|
||||||
<Route path={"ai/mcp"} element={<AiSettings />} />
|
<Route path={"ai/mcp"} element={<AiSettings />} />
|
||||||
<Route path={"audit"} element={<AuditLogs />} />
|
<Route path={"audit"} element={<AuditLogs />} />
|
||||||
<Route path={"verifications"} element={<VerifiedPages />} />
|
<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={"license"} element={<License />} />}
|
||||||
{isCloud() && <Route path={"billing"} element={<Billing />} />}
|
{isCloud() && <Route path={"billing"} element={<Billing />} />}
|
||||||
</Route>
|
</Route>
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,9 @@ import {
|
||||||
IconSparkles,
|
IconSparkles,
|
||||||
IconHistory,
|
IconHistory,
|
||||||
IconShieldCheck,
|
IconShieldCheck,
|
||||||
|
IconShieldLock,
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
|
import { useAcadenicePermissions } from "@/features/acadenice/rbac/hooks/use-acadenice-permissions";
|
||||||
import { Link, useLocation } from "react-router-dom";
|
import { Link, useLocation } from "react-router-dom";
|
||||||
import classes from "./settings.module.css";
|
import classes from "./settings.module.css";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
@ -51,6 +53,10 @@ type DataItem = {
|
||||||
feature?: string;
|
feature?: string;
|
||||||
role?: "admin" | "owner";
|
role?: "admin" | "owner";
|
||||||
env?: "cloud" | "selfhosted";
|
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 = {
|
type DataGroup = {
|
||||||
|
|
@ -96,6 +102,12 @@ const groupedData: DataGroup[] = [
|
||||||
role: "admin",
|
role: "admin",
|
||||||
},
|
},
|
||||||
{ label: "Groups", icon: IconUsersGroup, path: "/settings/groups" },
|
{ label: "Groups", icon: IconUsersGroup, path: "/settings/groups" },
|
||||||
|
{
|
||||||
|
label: "Roles",
|
||||||
|
icon: IconShieldLock,
|
||||||
|
path: "/settings/roles",
|
||||||
|
acadeniceCanManageRoles: true,
|
||||||
|
},
|
||||||
{ label: "Spaces", icon: IconSpaces, path: "/settings/spaces" },
|
{ label: "Spaces", icon: IconSpaces, path: "/settings/spaces" },
|
||||||
{ label: "Public sharing", icon: IconWorld, path: "/settings/sharing" },
|
{ label: "Public sharing", icon: IconWorld, path: "/settings/sharing" },
|
||||||
{
|
{
|
||||||
|
|
@ -145,6 +157,7 @@ export default function SettingsSidebar() {
|
||||||
const [active, setActive] = useState(location.pathname);
|
const [active, setActive] = useState(location.pathname);
|
||||||
const { goBack } = useSettingsNavigation();
|
const { goBack } = useSettingsNavigation();
|
||||||
const { isAdmin, isOwner } = useUserRole();
|
const { isAdmin, isOwner } = useUserRole();
|
||||||
|
const { canManageRoles: acadeniceCanManageRoles } = useAcadenicePermissions();
|
||||||
const [entitlements] = useAtom(entitlementAtom);
|
const [entitlements] = useAtom(entitlementAtom);
|
||||||
const upgradeLabel = useUpgradeLabel();
|
const upgradeLabel = useUpgradeLabel();
|
||||||
const [mobileSidebarOpened] = useAtom(mobileSidebarAtom);
|
const [mobileSidebarOpened] = useAtom(mobileSidebarAtom);
|
||||||
|
|
@ -162,6 +175,7 @@ export default function SettingsSidebar() {
|
||||||
if (item.env === "selfhosted" && isCloud()) return false;
|
if (item.env === "selfhosted" && isCloud()) return false;
|
||||||
if (item.role === "admin" && !isAdmin) return false;
|
if (item.role === "admin" && !isAdmin) return false;
|
||||||
if (item.role === "owner" && !isOwner) return false;
|
if (item.role === "owner" && !isOwner) return false;
|
||||||
|
if (item.acadeniceCanManageRoles && !acadeniceCanManageRoles) return false;
|
||||||
return true;
|
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