Aller au contenu

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
email 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:

public enum UserStatus { ACTIVE, DISABLED, INVITED }

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, TenantMembershipDto are defined in CP-014 and CP-011 respectively.

Repository Layer

  • UserRepository.java — Platform-scoped:
  • findById(UUID) → Optional<User>
  • findByEmail(String) → Optional<User>
  • existsByEmail(String) → boolean
  • UserTenantMembershipRepository.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-permissions is in CP-014. GET /api/v1/auth/my-tenants and POST /api/v1/auth/switch-tenant are in CP-011.

Validation Rules

  • email: valid email format, max 254 chars
  • roleCodes: 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_permissions tables + 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 badges
  • UserCard.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 integration
  • docs/standards/database-guidelines.md — §Platform vs tenant DB separation, §Join tables
  • docs/standards/api-guidelines.md — §Authentication endpoints, §Standard envelope
  • docs/standards/react-typescript-guidelines.md — §List components, §Bottom sheet pattern
  • docs/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