Story CP-003 : Entité User + Flux d'invitation¶
Module : control-plane Slice : Slice 3 from architecture Brand context : [BOTH] Papillon HR Suite + ALTARYS ENTERPRISE HR Suite Priority : 3 Depends on : CP-001, CP-002 Estimated complexity : M
Objectif¶
Construire les fondations du module identité : entité User, contrôle d'accès basé sur les rôles avec 5 rôles par défaut, flux d'invitation d'utilisateurs (nouvel utilisateur ou utilisateur déjà présent sur la plateforme), et appartenance multi-tenant des utilisateurs. Ce slice crée le modèle de données User, Role, Permission et le processus d'intégration des utilisateurs dans un tenant par invitation. La résolution des permissions et les améliorations UI sont dans CP-014 et CP-015.
Périmètre Backend¶
Entités & Records¶
Entité User (base plateforme — cross-tenant) :
| Champ | Type | Contraintes |
|---|---|---|
| id | UUID v7 | PK, généré côté serveur |
| String | NOT NULL, unique globalement, max 254 | |
| first_name | String | NOT NULL, min 2, max 100 |
| last_name | String | NOT NULL, min 2, max 100 |
| phone | String | Nullable, max 15 |
| status | UserStatus enum | NOT NULL |
| home_tenant_id | UUID | NOT NULL, FK vers Tenant |
| created_date | Instant | NOT NULL |
| last_login_date | Instant | Nullable |
Enum UserStatus :
Entité UserTenantMembership (base plateforme) :
| Champ | Type | Contraintes |
|---|---|---|
| id | UUID v7 | PK |
| user_id | UUID | NOT NULL, FK vers User |
| tenant_id | UUID | NOT NULL, FK vers Tenant |
| joined_date | Instant | NOT NULL |
Table de jointure MembershipRole (base plateforme) :
| Champ | Type | Contraintes |
|---|---|---|
| membership_id | UUID | FK vers UserTenantMembership |
| role_id | UUID | FK vers Role |
Entité Role (base plateforme, globale — 5 rôles par défaut partagés entre tous les tenants) :
| Champ | Type | Contraintes |
|---|---|---|
| id | UUID v7 | PK |
| name | String | NOT NULL, max 100 |
| code | String | NOT NULL (DG, RH_MANAGER, COMPTABLE, EMPLOYE, MANAGER) |
| is_system | boolean | NOT NULL, défaut true |
| created_date | Instant | NOT NULL |
Entité Permission (base plateforme, données de référence) :
| Champ | Type | Contraintes |
|---|---|---|
| id | UUID v7 | PK |
| code | String | NOT NULL, unique (ex. "cp.employee.create") |
| module_code | String | NOT NULL (ex. "CP", "ABSMGT") |
| description | String | NOT NULL |
Table de jointure RolePermission :
| Champ | Type | Contraintes |
|---|---|---|
| role_id | UUID | FK vers Role |
| permission_id | UUID | FK vers Permission |
Rôles par défaut et permissions (initialisés par tenant — depuis PRD FR-CP-034) :
| Code rôle | Nom du rôle | Permissions clés | Périmètre |
|---|---|---|---|
| DG | Directeur Général | Accès complet à tous les modules actifs du tenant | Tenant entier |
| RH_MANAGER | Responsable RH | Gestion employés, absences, présences, supervision paie | Tenant entier |
| COMPTABLE | Comptable | Budget, engagements, notes de frais, paie (lecture), comptabilité | Tenant entier |
| EMPLOYE | Employé | Libre-service : ses propres congés, ses propres bulletins, son propre profil (lecture), pointage | Données personnelles uniquement |
| MANAGER | Manager (Chef de Service) | Approbations équipe : approuver les congés de ses subordonnés directs, visualiser les données équipe | Subordonnés directs |
Format des permissions : {module_code}.{entity}.{action} — format composite selon la décision D4 (ex. cp.employee.create, absence.leave_request.approve). Chaque permission est atomique et composite — la chaîne complète est le code de permission stocké dans l'entité Permission. Pas de support des wildcards (cp.*) — les permissions sont explicites et énumérées.
DTOs :
public record UserResponse(UUID id, String email, String firstName, String lastName, String phone, UserStatus status) {}
public record UserListResponse(List<UserResponse> users, int total, int activeCount) {}
public record InviteUserRequest(
@NotBlank String email,
@NotEmpty List<String> roleCodes // ex. ["EMPLOYE", "MANAGER"]
) {}
Remarque :
MyPermissionsResponse,MyTenantsResponse,TenantMembershipDtosont définis dans CP-014 et CP-011 respectivement.
Couche Repository¶
UserRepository.java— Platform-scoped :findById(UUID) → Optional<User>findByEmail(String) → Optional<User>existsByEmail(String) → booleanUserTenantMembershipRepository.java— Platform-scoped :findByUserIdAndTenantId(UUID, UUID) → Optional<UserTenantMembership>findByUserId(UUID) → List<UserTenantMembership>findByTenantId(UUID) → List<UserTenantMembership>RoleRepository.java— Platform-scoped :findByTenantIdAndCode(UUID, String) → Optional<Role>findByTenantId(UUID) → List<Role>PermissionRepository.java— Platform-scoped :findByRoleIds(Set<UUID>) → List<Permission>
Couche Service¶
UserService.java :
- listUsers(UUID tenantId) → UserListResponse — Liste tous les utilisateurs du tenant courant avec leurs rôles et statuts
- inviteUser(UUID tenantId, InviteUserRequest) → UserResponse — Flux d'invitation :
1. Vérifier si l'email existe globalement
2. SI nouvel utilisateur : créer User (status=INVITED), créer l'appartenance, créer l'utilisateur Keycloak, envoyer l'email d'invitation
3. SI utilisateur existant (dans un autre tenant) : créer uniquement l'appartenance, envoyer la notification "ajouté à l'entreprise"
4. Publier l'événement UserInvited
Remarque : PermissionService et PermissionCacheService sont dans CP-014.
KeycloakAdminService.java :
- createUser(String email, String firstName, String lastName) → void — Crée l'utilisateur dans le realm Keycloak
- sendPasswordSetupEmail(String email) → void — Déclenche l'email Keycloak "définir le mot de passe"
- sendPasswordResetEmail(String email) → void — Déclenche le flux de réinitialisation de mot de passe Keycloak
- Utilise l'API REST Admin Keycloak avec les credentials du compte de service
Endpoints API¶
| Méthode | Chemin | Corps de requête | Réponse | Auth |
|---|---|---|---|---|
| GET | /api/v1/users | — | UserListResponse | ROLE_DG, ROLE_RH_MANAGER |
| POST | /api/v1/users/invite | InviteUserRequest | UserResponse | ROLE_DG, ROLE_RH_MANAGER |
Remarque :
GET /api/v1/auth/my-permissionsest dans CP-014.GET /api/v1/auth/my-tenantsetPOST /api/v1/auth/switch-tenantsont dans CP-011.
Règles de validation¶
email: format email valide, max 254 caractèresroleCodes: tous doivent être des codes de rôle valides pour le tenant- Impossible d'inviter un utilisateur déjà membre de ce tenant
- Impossible de retirer le dernier DG d'un tenant
Considérations multi-tenant¶
- L'entité User est dans la base plateforme (cross-tenant — un utilisateur peut appartenir à plusieurs tenants)
- UserTenantMembership est dans la base plateforme (jointure cross-tenant)
- Les rôles sont globaux à la plateforme dans la base plateforme (5 rôles par défaut partagés par tous les tenants, liés via UserTenantMembership.roles[])
- La résolution des permissions (permissions rôle ∩ modules actifs ∩ statut tenant) est dans CP-014
Migrations Flyway¶
V002__create_users_roles_permissions.sql— Base plateforme : tablesusers,user_tenant_memberships,membership_roles,roles,permissions,role_permissions+ initialisation des permissions par défaut + initialisation des rôles par tenant existant
Périmètre Frontend¶
Pages & Routes¶
/admin/users— Page liste de gestion des utilisateurs- Flux d'invitation : bottom sheet (mobile) / panneau latéral (bureau) depuis la page de gestion des utilisateurs
Composants¶
UserManagementPage.tsx— Liste des utilisateurs du tenant avec badges de statutUserCard.tsx— Élément de liste utilisateur (vue mobile)InviteUserSheet.tsx— Bottom sheet pour l'invitation d'un utilisateur (email + sélection de rôle)RoleSelector.tsx— Groupe radio pour la sélection du rôle avec descriptions
États UI (TOUS REQUIS)¶
Page Gestion des utilisateurs :
| État | Comportement | Texte en Français |
|---|---|---|
| Loading | Skeleton : 5 espaces réservés de carte utilisateur | — |
| Empty | Illustration + message + bouton CTA | "Invitez vos collaborateurs pour qu'ils puissent accéder à Papillon." CTA : "Inviter un utilisateur" |
| Error | Toast rouge | "Une erreur est survenue. Veuillez réessayer." |
| Offline | Message hors ligne pleine page (gestion utilisateurs nécessite la connectivité) | "Gestion des utilisateurs non disponible hors ligne." |
| Success (invite) | Toast vert | "Invitation envoyée à {email}" |
Comportement responsive¶
- 360px (mobile) : Liste de cartes utilisateurs. Chaque carte affiche : avatar/icône, nom (ou email si INVITED), badge de rôle, point de statut. Bouton "+" dans l'en-tête pour inviter.
- 768px (tablette) : Même layout de cartes mais plus large.
- 1024px+ (bureau) : Vue tableau : Email, Nom, Rôle, Statut, Actions (re-invitation si expiré).
Interactions¶
- Flux d'invitation : Appui sur "+" → bottom sheet avec champ email + sélection de rôle radio → "Envoyer l'invitation"
- Re-invitation : Pour les invitations expirées, afficher le lien "Expiré — Renvoyer →" sur la carte
- Descriptions des rôles affichées en ligne dans le sélecteur radio :
Remarque : L'UX de détection d'utilisateur multi-tenant (message d'info pour les utilisateurs déjà sur la plateforme) est dans CP-015. - DG : "Accès complet" - RH Manager : "Gestion employés, congés, paie" - Comptable : "Budget, engagements, notes de frais" - Employé : "Libre-service uniquement" - Manager : "Approbations équipe"
Critères d'acceptation¶
AC-017: List users for tenant
Given I am authenticated as DG of tenant "Acme SARL" with 3 users
When I call GET /api/v1/users
Then I receive all 3 users with their roles and statuses
And the response includes total=3, activeCount=2 (if 1 is INVITED)
AC-018: Invite new user
Given I am authenticated as DG
When I call POST /api/v1/users/invite with email="[email protected]" and roleCodes=["EMPLOYE"]
Then a user is created with status INVITED
And a membership is created linking the user to my tenant
And Keycloak receives a createUser call
And an invitation email is sent
AC-019: Invite existing user from another tenant
Given a user "[email protected]" exists with membership in tenant "Pharma Plus"
When I (DG of "Acme SARL") invite "[email protected]" with role COMPTABLE
Then a new membership is created for "Acme SARL" (no new User created)
And the user is notified "Vous avez été ajouté à l'entreprise Acme SARL"
And the user sees "Acme SARL" in their tenant switcher
ACs déplacés : AC-020, AC-021, AC-022 → CP-014. AC-023 → CP-011. AC-024, AC-025 → CP-015.
Règles OHADA & Réglementaires¶
- Loi 2013-450 (Art. 36) : Les données utilisateur sont des données personnelles. L'email, first_name, last_name, phone doivent être traités conformément à la loi CI sur la protection des données.
- Données utilisateur cross-tenant : Les informations personnelles d'un utilisateur (email, nom) ne sont PAS tenant-scopées — elles sont au niveau plateforme. L'appartenance (quel tenant, quel rôle) est la partie tenant-spécifique. C'est conforme puisque l'utilisateur a consenti à chaque relation tenant via l'acceptation de l'invitation.
Standards & Conventions¶
docs/standards/java-spring-guidelines.md— §Entities, §Service layer, §Keycloak integrationdocs/standards/database-guidelines.md— §Platform vs tenant DB separation, §Join tablesdocs/standards/api-guidelines.md— §Authentication endpoints, §Standard envelopedocs/standards/react-typescript-guidelines.md— §List components, §Bottom sheet patterndocs/standards/offline-sync-guidelines.md— §Online-only pages
Exigences de test¶
Tests unitaires¶
- UserService : flux d'invitation d'un nouvel utilisateur
- UserService : flux d'invitation d'un utilisateur existant (multi-tenant)
- UserService : rejeter l'invitation d'une appartenance en doublon
- UserService : liste des utilisateurs pour un tenant
Tests d'intégration¶
- POST /api/v1/users/invite — flux complet avec mock Keycloak
- GET /api/v1/users — isolation tenant (les utilisateurs du tenant A non visibles par le tenant B)
- POST /api/v1/users/invite — appartenance en doublon → 409
Ce que le QA validera¶
- La page liste des utilisateurs s'affiche correctement sur tous les breakpoints
- Le flux d'invitation fonctionne de bout en bout (bottom sheet, email envoyé)
- Les descriptions des rôles s'affichent correctement dans le formulaire d'invitation
- L'invitation expirée affiche le lien de re-invitation
- La page affiche "non disponible hors ligne" quand hors ligne
Hors périmètre¶
- Résolution des permissions + restriction par module — CP-014
- Badges de statut liste utilisateurs + UX de détection multi-tenant — CP-015
- Changement de tenant (endpoint + UI) — CP-011
- Rôles personnalisés — V2. Le MVP a uniquement 5 rôles par défaut fixes.
- Interface de désactivation/suppression d'utilisateur — V2
- Politique de mot de passe — Keycloak s'en charge
- Gestion des utilisateurs en console admin — CP-012
Définition de Done¶
- [ ] Tous les endpoints backend implémentés et retournant les réponses correctes
- [ ] Toutes les pages frontend s'affichent correctement à 360px, 768px, 1024px
- [ ] Les 5 états UI implémentés (loading, empty, error, offline, success)
- [ ] Les micro-textes français correspondent exactement à la spec design
- [ ] Tous les critères d'acceptation passent manuellement
- [ ] Tests unitaires écrits et passants
- [ ] Tests d'intégration écrits et passants
- [ ] Isolation multi-tenant vérifiée (aucune fuite de données inter-tenant)
- [ ] Écriture hors ligne + sync fonctionnelle (Papillon) — N/A (gestion utilisateurs toujours en ligne)
- [ ] Pas de
anyTypeScript — tous les types explicites - [ ] Pas de types Java bruts — tous les génériques explicites
- [ ] Les réponses API respectent le format d'enveloppe standard
- [ ] Entrées d'audit créées pour chaque mutation de données