Story CP-003: User Entity + Invitation Flow¶
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
Objective¶
Build the identity module foundations: User entity, role-based access control with 5 default roles, user invitation flow (new + existing platform users), and multi-tenant user membership. This slice creates the User, Role, Permission data model and the invitation-based onboarding of users into a tenant. Permission resolution and UI enhancements are in CP-014 and CP-015.
Backend Scope¶
Entities & Records¶
User entity (platform DB — cross-tenant):
| Field | Type | Constraints |
|---|---|---|
| id | UUID v7 | PK, server-generated |
| String | NOT NULL, unique globally, 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 to Tenant |
| created_date | Instant | NOT NULL |
| last_login_date | Instant | Nullable |
UserStatus enum:
UserTenantMembership entity (platform DB):
| Field | Type | Constraints |
|---|---|---|
| id | UUID v7 | PK |
| user_id | UUID | NOT NULL, FK to User |
| tenant_id | UUID | NOT NULL, FK to Tenant |
| joined_date | Instant | NOT NULL |
MembershipRole join table (platform DB):
| Field | Type | Constraints |
|---|---|---|
| membership_id | UUID | FK to UserTenantMembership |
| role_id | UUID | FK to Role |
Role entity (platform DB, platform-global — 5 default roles shared across all tenants):
| Field | Type | Constraints |
|---|---|---|
| id | UUID v7 | PK |
| name | String | NOT NULL, max 100 |
| code | String | NOT NULL (DG, RH_MANAGER, COMPTABLE, EMPLOYEE, MANAGER) |
| is_system | boolean | NOT NULL, default true |
| created_date | Instant | NOT NULL |
Permission entity (platform DB, reference data):
| Field | Type | Constraints |
|---|---|---|
| id | UUID v7 | PK |
| code | String | NOT NULL, unique (e.g., "cp.employee.create") |
| module_code | String | NOT NULL (e.g., "CP", "ABSMGT") |
| description | String | NOT NULL |
RolePermission join table:
| Field | Type | Constraints |
|---|---|---|
| role_id | UUID | FK to Role |
| permission_id | UUID | FK to Permission |
Default roles and permissions (seeded per tenant — from PRD FR-CP-034):
| Role Code | Role Name | Key Permissions | Scope |
|---|---|---|---|
| DG | Directeur Général | Full access to all active modules within tenant | Tenant-wide |
| RH_MANAGER | Responsable RH | Employee management, absence, attendance, payroll oversight | Tenant-wide |
| COMPTABLE | Comptable | Budget, commitments, expenses, payroll (read), accounting | Tenant-wide |
| EMPLOYEE | Employé | Self-service: own leave, own payslips, own profile (read), clock in/out | Own data only |
| MANAGER | Manager (Chef de Service) | Team-scoped approvals: approve leave for direct reports, view team data | Direct reports |
Permission format: {module_code}.{entity}.{action} — composite format per decision D4 (e.g., cp.employee.create, absence.leave_request.approve). Each permission is atomic and composite — the full string is the permission code stored in the Permission entity. No wildcard support (cp.*) — permissions are explicit and enumerated.
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 // e.g., ["EMPLOYE", "MANAGER"]
) {}
Note:
MyPermissionsResponse,MyTenantsResponse,TenantMembershipDtoare defined in CP-014 and CP-011 respectively.
Repository Layer¶
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>
Service Layer¶
UserService.java:
- listUsers(UUID tenantId) → UserListResponse — Lists all users for current tenant with their roles and statuses
- inviteUser(UUID tenantId, InviteUserRequest) → UserResponse — Invitation flow:
1. Check if email exists globally
2. IF new user: create User (status=INVITED), create membership, create Keycloak user, send invitation email
3. IF existing user (in another tenant): create membership only, send "added to company" notification
4. Publish UserInvited event
Note: PermissionService and PermissionCacheService are in CP-014.
KeycloakAdminService.java:
- createUser(String email, String firstName, String lastName) → void — Creates user in Keycloak realm
- sendPasswordSetupEmail(String email) → void — Triggers Keycloak "set password" email
- sendPasswordResetEmail(String email) → void — Triggers Keycloak password reset flow
- Uses Keycloak Admin REST API with service account credentials
API Endpoints¶
| Method | Path | Request Body | Response | Auth |
|---|---|---|---|---|
| GET | /api/v1/users | — | UserListResponse | ROLE_DG, ROLE_RH_MANAGER |
| POST | /api/v1/users/invite | InviteUserRequest | UserResponse | ROLE_DG, ROLE_RH_MANAGER |
Note:
GET /api/v1/auth/my-permissionsis in CP-014.GET /api/v1/auth/my-tenantsandPOST /api/v1/auth/switch-tenantare in CP-011.
Validation Rules¶
email: valid email format, max 254 charsroleCodes: must all be valid role codes for the tenant- Cannot invite a user who is already a member of this tenant
- Cannot remove the last DG from a tenant
Multi-Tenant Considerations¶
- User entity is in platform DB (cross-tenant — a user can belong to multiple tenants)
- UserTenantMembership is in platform DB (cross-tenant join)
- Roles are platform-global in platform DB (5 default roles shared by all tenants, linked via UserTenantMembership.roles[])
- Permission resolution (role permissions ∩ active modules ∩ tenant status) is in CP-014
Flyway Migrations¶
V002__create_users_roles_permissions.sql— Platform DB:users,user_tenant_memberships,membership_roles,roles,permissions,role_permissionstables + seed default permissions + seed roles per existing tenant
Frontend Scope¶
Pages & Routes¶
/admin/users— User Management list page- Invite flow: bottom sheet (mobile) / side panel (desktop) from User Management page
Components¶
UserManagementPage.tsx— List of tenant users with status badgesUserCard.tsx— User list item (mobile view)InviteUserSheet.tsx— Bottom sheet for user invitation (email + role selection)RoleSelector.tsx— Radio group for role selection with descriptions
UI States (ALL REQUIRED)¶
User Management Page:
| State | Behavior | French Copy |
|---|---|---|
| Loading | Skeleton: 5 user card placeholders | — |
| Empty | Illustration + message + CTA button | "Invitez vos collaborateurs pour qu'ils puissent accéder à Papillon." CTA: "Inviter un utilisateur" |
| Error | Red toast | "Une erreur est survenue. Veuillez réessayer." |
| Offline | Full page offline message (user management requires connectivity) | "Gestion des utilisateurs non disponible hors ligne." |
| Success (invite) | Green toast | "Invitation envoyée à {email}" |
Responsive Behavior¶
- 360px (mobile): Card list of users. Each card shows: avatar/icon, name (or email if INVITED), role badge, status dot. "+" button in header to invite.
- 768px (tablet): Same card layout but wider.
- 1024px+ (desktop): Table view: Email, Nom, Rôle, Statut, Actions (re-invite for expired).
Interactions¶
- Invite flow: Tap "+" → bottom sheet with email field + role radio selection → "Envoyer l'invitation"
- Re-invite: For expired invitations, show "Expiré — Renvoyer →" link on the card
- Role descriptions shown inline in radio selector:
Note: Multi-tenant user detection UX (info message for existing platform users) is in 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"
Acceptance Criteria¶
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
Moved ACs: AC-020, AC-021, AC-022 → CP-014. AC-023 → CP-011. AC-024, AC-025 → CP-015.
OHADA & Regulatory Rules¶
- Loi 2013-450 (Art. 36): User data is personal data. Email, first_name, last_name, phone must be handled according to CI data protection law.
- Cross-tenant user data: A user's personal info (email, name) is NOT tenant-scoped — it's platform-level. Membership (which tenant, which role) is the tenant-specific part. This is compliant as the user consented to each tenant relationship via invitation acceptance.
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
Testing Requirements¶
Unit Tests¶
- UserService: invite new user flow
- UserService: invite existing user (multi-tenant) flow
- UserService: reject duplicate membership invitation
- UserService: list users for tenant
Integration Tests¶
- POST /api/v1/users/invite — full flow with Keycloak mock
- GET /api/v1/users — tenant isolation (tenant A users not visible to tenant B)
- POST /api/v1/users/invite — duplicate membership → 409
What QA Will Validate¶
- User list page displays correctly on all breakpoints
- Invite flow works end-to-end (bottom sheet, email sent)
- Role descriptions display correctly in invite form
- Expired invitation shows re-invite link
- Page shows "offline not available" when offline
Out of Scope¶
- Permission resolution + module gating — CP-014
- User list status badges + multi-tenant detection UX — CP-015
- Tenant switching (endpoint + UI) — CP-011
- Custom roles — V2. MVP has fixed 5 default roles only.
- User deactivation/removal UI — V2
- Password policy enforcement — Keycloak handles this
- Admin console user management — CP-012
Definition of Done¶
- [ ] All backend endpoints implemented and returning correct responses
- [ ] All frontend pages render correctly at 360px, 768px, 1024px
- [ ] All 5 UI states implemented (loading, empty, error, offline, success)
- [ ] French micro-copy matches design spec exactly
- [ ] All acceptance criteria pass manually
- [ ] Unit tests written and passing
- [ ] Integration tests written and passing
- [ ] Multi-tenant isolation verified (no cross-tenant data leaks)
- [ ] Offline write+sync works (Papillon) — N/A (user management always online)
- [ ] No TypeScript
any— all types explicit - [ ] No Java raw types — all generics explicit
- [ ] API responses follow standard envelope format
- [ ] Audit trail entries created for every data mutation