feat(v3.0): implémentation Phases 0-8 — RBAC, lifecycle, multi-org, plans, dashboards

Phase 0 : @RolesAllowed SUPER_ADMIN sur POST/DELETE organisations ; AuthenticationFilter pages super-admin
Phase 2 : OrganisationModuleService, @RequiresModule, ModuleAccessFilter, RoleService, PermissionChecker
Phase 3 : multi-org context switching (OrganisationContextFilter, headers X-Active-Organisation-Id / X-Active-Role)
Phase 4 : feature-gating navigation par typeOrganisation (web MenuBean + mobile MorePage)
Phase 5 : MemberLifecycleService — 8 transitions (activer/suspendre/radier/archiver/inviter/accepter/expirer/rappels)
Phase 6 : FormuleAbonnement Option C (planCommercial, apiAccess, federationAccess, quotas) + SouscriptionOrganisation méthodes quota
Phase 7 : DashboardResource SUPER_ADMIN ajouté ; DashboardBean.checkAccessAndRedirect() ; dashboards distincts par rôle
Phase 8 : MembreResourceLifecycleRbacTest, SouscriptionQuotaOptionCTest, OrganisationContextHolderTest, OrganisationContextFilterMultiOrgTest, MemberLifecycleServiceTest
This commit is contained in:
dahoud
2026-04-06 16:49:47 +00:00
parent 39e98a9cb3
commit aef5548e87
34 changed files with 823 additions and 86 deletions

View File

@@ -9,6 +9,9 @@ import dev.lions.unionflow.server.api.dto.membre.MembreSearchCriteria;
import dev.lions.unionflow.server.api.dto.membre.MembreSearchResultDTO;
import dev.lions.unionflow.server.entity.Membre;
import dev.lions.unionflow.server.entity.Organisation;
import dev.lions.unionflow.server.entity.MembreOrganisation;
import dev.lions.unionflow.server.repository.MembreOrganisationRepository;
import dev.lions.unionflow.server.service.MemberLifecycleService;
import dev.lions.unionflow.server.service.MembreKeycloakSyncService;
import dev.lions.unionflow.server.service.MembreService;
import dev.lions.unionflow.server.service.MembreSuiviService;
@@ -68,6 +71,12 @@ public class MembreResource {
@Inject
OrganisationService organisationService;
@Inject
MemberLifecycleService memberLifecycleService;
@Inject
MembreOrganisationRepository membreOrgRepository;
@Inject
io.quarkus.security.identity.SecurityIdentity securityIdentity;
@@ -75,6 +84,7 @@ public class MembreResource {
JsonWebToken jwt;
@GET
@RolesAllowed({ "ADMIN", "SUPER_ADMIN", "ADMIN_ORGANISATION", "MODERATEUR" })
@Operation(summary = "Lister les membres")
@APIResponse(responseCode = "200", description = "Liste des membres avec pagination")
public PagedResponse<MembreSummaryResponse> listerMembres(
@@ -130,6 +140,7 @@ public class MembreResource {
@GET
@Path("/{id}")
@RolesAllowed({ "ADMIN", "SUPER_ADMIN", "ADMIN_ORGANISATION", "MODERATEUR", "MEMBRE", "USER" })
@Operation(summary = "Récupérer un membre par son ID")
@APIResponse(responseCode = "200", description = "Membre trouvé")
@APIResponse(responseCode = "404", description = "Membre non trouvé")
@@ -190,6 +201,34 @@ public class MembreResource {
return Response.ok(response).build();
}
/**
* Retourne la liste des organisations du membre connecté (pour le sélecteur multi-org).
* Inclut le type, la catégorie et les modules actifs de chaque organisation.
*/
@GET
@Path("/mes-organisations")
@RolesAllowed({ "USER", "MEMBRE", "ADMIN", "ADMIN_ORGANISATION", "SUPER_ADMIN", "MODERATEUR" })
@Operation(summary = "Organisations du membre connecté",
description = "Retourne la liste des organisations auxquelles le membre connecté appartient (multi-org)")
@APIResponse(responseCode = "200", description = "Liste des organisations")
public Response getMesOrganisations() {
String email = securityIdentity.getPrincipal().getName();
try {
var membre = membreService.trouverParEmail(email);
if (membre.isEmpty()) {
return Response.ok(java.util.List.of()).build();
}
// Charger les liens membre-organisation avec les infos d'org
var liens = organisationService.listerOrganisationsParMembre(membre.get().getId());
return Response.ok(liens).build();
} catch (Exception e) {
LOG.errorf(e, "Erreur lors de la récupération des organisations du membre %s", email);
return Response.serverError()
.entity(java.util.Map.of("error", "Erreur serveur"))
.build();
}
}
/** Crée et active une fiche membre depuis les claims JWT lors du premier accès. */
private Membre autoProvisionnerMembre(String email) {
String prenom = "Utilisateur";
@@ -307,6 +346,7 @@ public class MembreResource {
@PUT
@Path("/{id}")
@RolesAllowed({ "ADMIN", "SUPER_ADMIN", "ADMIN_ORGANISATION" })
@Operation(summary = "Mettre à jour un membre existant")
@APIResponse(responseCode = "200", description = "Membre mis à jour avec succès")
@APIResponse(responseCode = "404", description = "Membre non trouvé")
@@ -334,6 +374,7 @@ public class MembreResource {
@DELETE
@Path("/{id}")
@RolesAllowed({ "ADMIN", "SUPER_ADMIN" })
@Operation(summary = "Désactiver un membre")
@APIResponse(responseCode = "204", description = "Membre désactivé avec succès")
@APIResponse(responseCode = "404", description = "Membre non trouvé")
@@ -583,6 +624,7 @@ public class MembreResource {
@POST
@Path("/export/selection")
@RolesAllowed({ "ADMIN", "SUPER_ADMIN", "ADMIN_ORGANISATION" })
@Consumes(MediaType.APPLICATION_JSON)
@Produces("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
@Operation(summary = "Exporter une sélection de membres en Excel")
@@ -692,6 +734,7 @@ public class MembreResource {
@GET
@Path("/export")
@RolesAllowed({ "ADMIN", "SUPER_ADMIN", "ADMIN_ORGANISATION" })
@Produces("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
@Operation(summary = "Exporter des membres en Excel, CSV ou PDF")
@APIResponse(responseCode = "200", description = "Fichier exporté")
@@ -873,4 +916,345 @@ public class MembreResource {
return Response.ok(Map.of("count", membres.size())).build();
}
// =========================================================================
// Endpoints cycle de vie des adhésions (MemberLifecycleService)
// =========================================================================
/**
* Invite un membre existant à rejoindre une organisation.
* Crée un lien MembreOrganisation au statut INVITE avec token + expiration 7j.
*/
@PUT
@Path("/{membreId}/inviter-organisation")
@RolesAllowed({"ADMIN", "SUPER_ADMIN", "ADMIN_ORGANISATION"})
@Operation(summary = "Inviter un membre dans une organisation",
description = "Crée une invitation (statut INVITE) pour un membre existant. Token valable 7 jours.")
@APIResponse(responseCode = "200", description = "Invitation créée")
@APIResponse(responseCode = "404", description = "Membre ou organisation introuvable")
@APIResponse(responseCode = "409", description = "Membre déjà lié à cette organisation")
public Response inviterMembre(
@Parameter(description = "UUID du membre à inviter") @PathParam("membreId") UUID membreId,
@Parameter(description = "UUID de l'organisation") @QueryParam("organisationId") UUID organisationId,
@Parameter(description = "Rôle proposé (optionnel)") @QueryParam("roleOrg") String roleOrg) {
if (organisationId == null) {
return Response.status(Response.Status.BAD_REQUEST)
.entity(Map.of("error", "organisationId est obligatoire")).build();
}
Membre membre = membreService.trouverParId(membreId)
.orElseThrow(() -> new NotFoundException("Membre introuvable : " + membreId));
Organisation organisation = organisationService.trouverParId(organisationId)
.orElseThrow(() -> new NotFoundException("Organisation introuvable : " + organisationId));
UUID adminId = resolveCurrentAdminId();
try {
var lien = memberLifecycleService.inviterMembre(membre, organisation, adminId, roleOrg);
return Response.ok(Map.of(
"membreOrgId", lien.getId(),
"statut", lien.getStatutMembre(),
"tokenInvitation", lien.getTokenInvitation(),
"expiresAt", lien.getDateExpirationInvitation()
)).build();
} catch (IllegalStateException e) {
return Response.status(Response.Status.CONFLICT)
.entity(Map.of("error", e.getMessage())).build();
}
}
/**
* Accepte une invitation via son token (INVITE → EN_ATTENTE_VALIDATION).
* Endpoint public — le membre clique sur le lien reçu par email.
*/
@POST
@Path("/accepter-invitation/{token}")
@PermitAll
@Operation(summary = "Accepter une invitation",
description = "Valide le token d'invitation et passe l'adhésion en EN_ATTENTE_VALIDATION.")
@APIResponse(responseCode = "200", description = "Invitation acceptée")
@APIResponse(responseCode = "400", description = "Token invalide ou expiré")
public Response accepterInvitation(
@Parameter(description = "Token d'invitation") @PathParam("token") String token) {
try {
var lien = memberLifecycleService.accepterInvitation(token);
return Response.ok(Map.of(
"membreOrgId", lien.getId(),
"statut", lien.getStatutMembre(),
"organisation", lien.getOrganisation().getNom()
)).build();
} catch (IllegalArgumentException e) {
return Response.status(Response.Status.NOT_FOUND)
.entity(Map.of("error", e.getMessage())).build();
} catch (IllegalStateException e) {
return Response.status(Response.Status.BAD_REQUEST)
.entity(Map.of("error", e.getMessage())).build();
}
}
/**
* Active une adhésion (EN_ATTENTE_VALIDATION / INVITE / SUSPENDU → ACTIF).
*/
@PUT
@Path("/{membreOrgId}/activer-adhesion")
@RolesAllowed({"ADMIN", "SUPER_ADMIN", "ADMIN_ORGANISATION"})
@Operation(summary = "Activer une adhésion",
description = "Transitions autorisées : EN_ATTENTE_VALIDATION, INVITE, SUSPENDU → ACTIF.")
@APIResponse(responseCode = "200", description = "Adhésion activée")
@APIResponse(responseCode = "404", description = "Lien membre-organisation introuvable")
@APIResponse(responseCode = "409", description = "Transition non autorisée depuis le statut actuel")
public Response activerAdhesion(
@Parameter(description = "UUID du lien membre-organisation") @PathParam("membreOrgId") UUID membreOrgId,
Map<String, String> body) {
String motif = body != null ? body.get("motif") : null;
UUID adminId = resolveCurrentAdminId();
try {
var lien = memberLifecycleService.activerMembre(membreOrgId, adminId, motif);
Map<String, Object> result = new HashMap<>();
result.put("membreOrgId", lien.getId());
result.put("statut", lien.getStatutMembre());
result.put("dateChangementStatut", lien.getDateChangementStatut());
return Response.ok(result).build();
} catch (IllegalArgumentException e) {
return Response.status(Response.Status.NOT_FOUND)
.entity(Map.of("error", e.getMessage())).build();
} catch (IllegalStateException e) {
return Response.status(Response.Status.CONFLICT)
.entity(Map.of("error", e.getMessage())).build();
}
}
/**
* Suspend une adhésion (ACTIF → SUSPENDU).
*/
@PUT
@Path("/{membreOrgId}/suspendre-adhesion")
@RolesAllowed({"ADMIN", "SUPER_ADMIN", "ADMIN_ORGANISATION"})
@Operation(summary = "Suspendre une adhésion", description = "Transition autorisée : ACTIF → SUSPENDU.")
@APIResponse(responseCode = "200", description = "Adhésion suspendue")
@APIResponse(responseCode = "404", description = "Lien membre-organisation introuvable")
@APIResponse(responseCode = "409", description = "Transition non autorisée")
public Response suspendrAdhesion(
@Parameter(description = "UUID du lien membre-organisation") @PathParam("membreOrgId") UUID membreOrgId,
Map<String, String> body) {
String motif = body != null ? body.get("motif") : null;
UUID adminId = resolveCurrentAdminId();
try {
var lien = memberLifecycleService.suspendreMembre(membreOrgId, adminId, motif);
Map<String, Object> result = new HashMap<>();
result.put("membreOrgId", lien.getId());
result.put("statut", lien.getStatutMembre());
result.put("dateChangementStatut", lien.getDateChangementStatut());
return Response.ok(result).build();
} catch (IllegalArgumentException e) {
return Response.status(Response.Status.NOT_FOUND)
.entity(Map.of("error", e.getMessage())).build();
} catch (IllegalStateException e) {
return Response.status(Response.Status.CONFLICT)
.entity(Map.of("error", e.getMessage())).build();
}
}
/**
* Radie un membre d'une organisation (→ RADIE).
*/
@PUT
@Path("/{membreOrgId}/radier-adhesion")
@RolesAllowed({"ADMIN", "SUPER_ADMIN", "ADMIN_ORGANISATION"})
@Operation(summary = "Radier un membre d'une organisation")
@APIResponse(responseCode = "200", description = "Adhésion radiée")
@APIResponse(responseCode = "404", description = "Lien membre-organisation introuvable")
public Response radierAdhesion(
@Parameter(description = "UUID du lien membre-organisation") @PathParam("membreOrgId") UUID membreOrgId,
Map<String, String> body) {
String motif = body != null ? body.get("motif") : null;
UUID adminId = resolveCurrentAdminId();
try {
var lien = memberLifecycleService.radierMembre(membreOrgId, adminId, motif);
Map<String, Object> result = new HashMap<>();
result.put("membreOrgId", lien.getId());
result.put("statut", lien.getStatutMembre());
result.put("dateChangementStatut", lien.getDateChangementStatut());
return Response.ok(result).build();
} catch (IllegalArgumentException e) {
return Response.status(Response.Status.NOT_FOUND)
.entity(Map.of("error", e.getMessage())).build();
}
}
/**
* Archive une adhésion (→ ARCHIVE) sans supprimer l'historique.
*/
@PUT
@Path("/{membreOrgId}/archiver-adhesion")
@RolesAllowed({"ADMIN", "SUPER_ADMIN", "ADMIN_ORGANISATION"})
@Operation(summary = "Archiver une adhésion",
description = "Conserve l'historique sans supprimer le lien membre-organisation.")
@APIResponse(responseCode = "200", description = "Adhésion archivée")
@APIResponse(responseCode = "404", description = "Lien membre-organisation introuvable")
public Response archiverAdhesion(
@Parameter(description = "UUID du lien membre-organisation") @PathParam("membreOrgId") UUID membreOrgId,
Map<String, String> body) {
String motif = body != null ? body.get("motif") : null;
try {
var lien = memberLifecycleService.archiverMembre(membreOrgId, motif);
Map<String, Object> result = new HashMap<>();
result.put("membreOrgId", lien.getId());
result.put("statut", lien.getStatutMembre());
result.put("dateChangementStatut", lien.getDateChangementStatut());
return Response.ok(result).build();
} catch (IllegalArgumentException e) {
return Response.status(Response.Status.NOT_FOUND)
.entity(Map.of("error", e.getMessage())).build();
}
}
// =========================================================================
// Endpoints lifecycle par membreId + organisationId (sans membreOrgId)
// =========================================================================
/**
* Retourne le statut d'adhésion d'un membre dans une organisation.
* Utilisé par le profil membre pour afficher les boutons d'action contextuels.
*/
@GET
@Path("/{membreId}/adhesion")
@RolesAllowed({"ADMIN", "SUPER_ADMIN", "ADMIN_ORGANISATION", "MEMBRE", "USER"})
@Operation(summary = "Statut d'adhésion d'un membre dans une organisation")
@APIResponse(responseCode = "200", description = "Statut d'adhésion")
@APIResponse(responseCode = "404", description = "Aucun lien membre-organisation trouvé")
public Response getAdhesionStatut(
@Parameter(description = "UUID du membre") @PathParam("membreId") UUID membreId,
@Parameter(description = "UUID de l'organisation") @QueryParam("organisationId") UUID organisationId) {
if (organisationId == null) {
return Response.status(Response.Status.BAD_REQUEST)
.entity(Map.of("error", "organisationId requis")).build();
}
return membreOrgRepository.findByMembreIdAndOrganisationId(membreId, organisationId)
.map(lien -> Response.ok(Map.of(
"membreOrgId", lien.getId(),
"statut", lien.getStatutMembre(),
"dateInvitation", lien.getDateInvitation() != null ? lien.getDateInvitation().toString() : "",
"dateExpiration", lien.getDateExpirationInvitation() != null ? lien.getDateExpirationInvitation().toString() : "",
"roleOrg", lien.getRoleOrg() != null ? lien.getRoleOrg() : "",
"motifStatut", lien.getMotifStatut() != null ? lien.getMotifStatut() : ""
)).build())
.orElse(Response.status(Response.Status.NOT_FOUND)
.entity(Map.of("error", "Aucune adhésion trouvée")).build());
}
/**
* Active l'adhésion d'un membre (EN_ATTENTE/INVITE/SUSPENDU → ACTIF)
* en passant par membreId + organisationId plutôt que membreOrgId.
*/
@PUT
@Path("/{membreId}/adhesion/activer")
@RolesAllowed({"ADMIN", "SUPER_ADMIN", "ADMIN_ORGANISATION"})
@Operation(summary = "Activer l'adhésion par membreId + organisationId")
@APIResponse(responseCode = "200", description = "Adhésion activée")
@APIResponse(responseCode = "404", description = "Lien membre-organisation introuvable")
public Response activerAdhesionParMembre(
@Parameter(description = "UUID du membre") @PathParam("membreId") UUID membreId,
@Parameter(description = "UUID de l'organisation") @QueryParam("organisationId") UUID organisationId,
Map<String, String> body) {
if (organisationId == null) {
return Response.status(Response.Status.BAD_REQUEST)
.entity(Map.of("error", "organisationId requis")).build();
}
MembreOrganisation lien = membreOrgRepository
.findByMembreIdAndOrganisationId(membreId, organisationId)
.orElse(null);
if (lien == null) {
return Response.status(Response.Status.NOT_FOUND)
.entity(Map.of("error", "Aucune adhésion trouvée")).build();
}
String motif = body != null ? body.get("motif") : null;
UUID adminId = resolveCurrentAdminId();
try {
var updated = memberLifecycleService.activerMembre(lien.getId(), adminId, motif);
return Response.ok(Map.of("statut", updated.getStatutMembre())).build();
} catch (IllegalStateException e) {
return Response.status(Response.Status.CONFLICT).entity(Map.of("error", e.getMessage())).build();
}
}
/**
* Suspend l'adhésion d'un membre (ACTIF → SUSPENDU) par membreId + organisationId.
*/
@PUT
@Path("/{membreId}/adhesion/suspendre")
@RolesAllowed({"ADMIN", "SUPER_ADMIN", "ADMIN_ORGANISATION"})
@Operation(summary = "Suspendre l'adhésion par membreId + organisationId")
@APIResponse(responseCode = "200", description = "Adhésion suspendue")
@APIResponse(responseCode = "404", description = "Lien membre-organisation introuvable")
public Response suspendrAdhesionParMembre(
@Parameter(description = "UUID du membre") @PathParam("membreId") UUID membreId,
@Parameter(description = "UUID de l'organisation") @QueryParam("organisationId") UUID organisationId,
Map<String, String> body) {
if (organisationId == null) {
return Response.status(Response.Status.BAD_REQUEST)
.entity(Map.of("error", "organisationId requis")).build();
}
MembreOrganisation lien = membreOrgRepository
.findByMembreIdAndOrganisationId(membreId, organisationId)
.orElse(null);
if (lien == null) {
return Response.status(Response.Status.NOT_FOUND)
.entity(Map.of("error", "Aucune adhésion trouvée")).build();
}
String motif = body != null ? body.get("motif") : null;
UUID adminId = resolveCurrentAdminId();
try {
var updated = memberLifecycleService.suspendreMembre(lien.getId(), adminId, motif);
return Response.ok(Map.of("statut", updated.getStatutMembre())).build();
} catch (IllegalStateException e) {
return Response.status(Response.Status.CONFLICT).entity(Map.of("error", e.getMessage())).build();
}
}
/**
* Radie un membre d'une organisation par membreId + organisationId.
*/
@PUT
@Path("/{membreId}/adhesion/radier")
@RolesAllowed({"ADMIN", "SUPER_ADMIN", "ADMIN_ORGANISATION"})
@Operation(summary = "Radier par membreId + organisationId")
@APIResponse(responseCode = "200", description = "Adhésion radiée")
public Response radierAdhesionParMembre(
@Parameter(description = "UUID du membre") @PathParam("membreId") UUID membreId,
@Parameter(description = "UUID de l'organisation") @QueryParam("organisationId") UUID organisationId,
Map<String, String> body) {
if (organisationId == null) {
return Response.status(Response.Status.BAD_REQUEST)
.entity(Map.of("error", "organisationId requis")).build();
}
MembreOrganisation lien = membreOrgRepository
.findByMembreIdAndOrganisationId(membreId, organisationId)
.orElse(null);
if (lien == null) {
return Response.status(Response.Status.NOT_FOUND)
.entity(Map.of("error", "Aucune adhésion trouvée")).build();
}
String motif = body != null ? body.get("motif") : null;
var updated = memberLifecycleService.radierMembre(lien.getId(), resolveCurrentAdminId(), motif);
return Response.ok(Map.of("statut", updated.getStatutMembre())).build();
}
/** Résout l'UUID de l'admin connecté depuis le JWT subject. */
private UUID resolveCurrentAdminId() {
try {
String sub = jwt != null ? jwt.getSubject() : null;
return sub != null ? UUID.fromString(sub) : null;
} catch (Exception e) {
return null;
}
}
}