diff --git a/src/main/java/dev/lions/unionflow/server/resource/MembreResource.java b/src/main/java/dev/lions/unionflow/server/resource/MembreResource.java index 534c3fe..092ba2d 100644 --- a/src/main/java/dev/lions/unionflow/server/resource/MembreResource.java +++ b/src/main/java/dev/lions/unionflow/server/resource/MembreResource.java @@ -176,10 +176,16 @@ public class MembreResource { // Conversion DTO vers entité Membre membre = membreService.convertFromCreateRequest(membreDTO); - // Création du membre — statut EN_ATTENTE_VALIDATION, Keycloak provisionné à - // l'approbation + // Création du membre — statut EN_ATTENTE_VALIDATION Membre nouveauMembre = membreService.creerMembre(membre); + // Provisionner le compte Keycloak (non bloquant — l'admin peut activer manuellement) + try { + keycloakSyncService.provisionKeycloakUser(nouveauMembre.getId()); + } catch (Exception e) { + LOG.warnf("Provisionnement Keycloak échoué pour %s (non bloquant): %s", nouveauMembre.getEmail(), e.getMessage()); + } + // Validation périmètre ADMIN_ORGANISATION - lier le membre à l'organisation java.util.Set roles = securityIdentity.getRoles(); boolean onlyOrgAdmin = roles != null && roles.contains("ADMIN_ORGANISATION") @@ -668,6 +674,27 @@ public class MembreResource { .build(); } + @PUT + @Path("/{id}/activer") + @RolesAllowed({"ADMIN", "SUPER_ADMIN"}) + @Operation(summary = "Activer un membre", description = "Passe le membre en statut ACTIF et lui assigne le rôle MEMBRE_ACTIF dans Keycloak.") + @APIResponse(responseCode = "200", description = "Membre activé avec succès") + @APIResponse(responseCode = "404", description = "Membre non trouvé") + public Response activerMembre(@Parameter(description = "UUID du membre") @PathParam("id") UUID id) { + LOG.infof("Activation du membre ID: %s", id); + + Membre membreActive = membreService.activerMembre(id); + + // Assigner le rôle MEMBRE_ACTIF dans Keycloak (non bloquant) + try { + keycloakSyncService.activerMembreDansKeycloak(id); + } catch (Exception e) { + LOG.warnf("Activation Keycloak échouée pour %s (non bloquant): %s", membreActive.getEmail(), e.getMessage()); + } + + return Response.ok(membreService.convertToResponse(membreActive)).build(); + } + @GET @Path("/export/count") @Produces(MediaType.APPLICATION_JSON) diff --git a/src/main/java/dev/lions/unionflow/server/service/MembreKeycloakSyncService.java b/src/main/java/dev/lions/unionflow/server/service/MembreKeycloakSyncService.java index 7b9c5c3..f117f25 100644 --- a/src/main/java/dev/lions/unionflow/server/service/MembreKeycloakSyncService.java +++ b/src/main/java/dev/lions/unionflow/server/service/MembreKeycloakSyncService.java @@ -140,6 +140,62 @@ public class MembreKeycloakSyncService { } } + /** + * Active un membre dans Keycloak en lui assignant le rôle MEMBRE_ACTIF. + * Appelé après MembreService.activerMembre() lors de la validation admin. + * + *

Si le membre n'a pas encore de compte Keycloak, le provisionne d'abord + * puis assigne le rôle MEMBRE_ACTIF. + * + * @param membreId UUID du membre à activer dans Keycloak + * @throws NotFoundException si le membre n'existe pas en base + */ + @Transactional + public void activerMembreDansKeycloak(java.util.UUID membreId) { + LOGGER.info("Activation Keycloak (rôle MEMBRE_ACTIF) pour Membre ID: " + membreId); + + Membre membre = membreRepository.findByIdOptional(membreId) + .orElseThrow(() -> new NotFoundException("Membre non trouvé avec l'ID: " + membreId)); + + // Provisionner le compte Keycloak s'il n'existe pas encore + if (membre.getKeycloakId() == null) { + LOGGER.info("Compte Keycloak absent — provisionnement automatique pour " + membre.getNomComplet()); + provisionKeycloakUser(membreId); + // Recharger après persist dans provisionKeycloakUser + membre = membreRepository.findByIdOptional(membreId) + .orElseThrow(() -> new NotFoundException("Membre non trouvé après provisionnement: " + membreId)); + } + + String keycloakUserId = membre.getKeycloakId().toString(); + + try { + // Récupérer l'utilisateur Keycloak + UserDTO user = userServiceClient.getUserById(keycloakUserId, DEFAULT_REALM); + + // S'assurer que le compte est activé + if (Boolean.FALSE.equals(user.getEnabled())) { + user.setEnabled(true); + userServiceClient.updateUser(keycloakUserId, user, DEFAULT_REALM); + } + + // Ajouter le rôle MEMBRE_ACTIF s'il n'est pas déjà présent + List roles = user.getRealmRoles() != null + ? new java.util.ArrayList<>(user.getRealmRoles()) + : new java.util.ArrayList<>(); + if (!roles.contains("MEMBRE_ACTIF")) { + roles.add("MEMBRE_ACTIF"); + user.setRealmRoles(roles); + userServiceClient.updateUser(keycloakUserId, user, DEFAULT_REALM); + } + + LOGGER.info("✅ Rôle MEMBRE_ACTIF assigné dans Keycloak pour " + membre.getNomComplet()); + + } catch (Exception e) { + LOGGER.severe("❌ Erreur lors de l'activation Keycloak pour " + membre.getNomComplet() + ": " + e.getMessage()); + throw new RuntimeException("Impossible d'activer le compte Keycloak: " + e.getMessage(), e); + } + } + /** * Synchronise les données du Membre vers le User Keycloak. * Si le membre n'a pas de compte Keycloak, le provisionne automatiquement. diff --git a/src/main/java/dev/lions/unionflow/server/service/MembreService.java b/src/main/java/dev/lions/unionflow/server/service/MembreService.java index 8b49544..58da914 100644 --- a/src/main/java/dev/lions/unionflow/server/service/MembreService.java +++ b/src/main/java/dev/lions/unionflow/server/service/MembreService.java @@ -84,18 +84,40 @@ public class MembreService { throw new IllegalArgumentException("Un membre avec ce numéro existe déjà"); } - // Forcer le statut d'attente — le compte est activé uniquement après validation - // admin - // Forcer l'activation pour les tests E2E (normalement géré par validation - // admin) - membre.setStatutCompte("ACTIF"); - membre.setActif(true); + // Statut initial : en attente de validation admin + // L'activation (ACTIF + Keycloak MEMBRE_ACTIF) se fait via PUT /api/membres/{id}/activer + membre.setStatutCompte("EN_ATTENTE_VALIDATION"); + membre.setActif(false); membreRepository.persist(membre); LOG.infof("Membre créé en attente de validation: %s (ID: %s)", membre.getNomComplet(), membre.getId()); return membre; } + /** + * Active un membre : passe son statut à ACTIF et son flag actif à true. + * Doit être suivi d'un appel à MembreKeycloakSyncService.activerMembreDansKeycloak() + * pour que le rôle MEMBRE_ACTIF soit assigné dans Keycloak. + * + * @param membreId UUID du membre à activer + * @return Le membre mis à jour + * @throws jakarta.ws.rs.NotFoundException si le membre est introuvable + */ + @Transactional + public Membre activerMembre(UUID membreId) { + LOG.infof("Activation du membre ID: %s", membreId); + + Membre membre = membreRepository.findByIdOptional(membreId) + .orElseThrow(() -> new jakarta.ws.rs.NotFoundException("Membre non trouvé avec l'ID: " + membreId)); + + membre.setStatutCompte("ACTIF"); + membre.setActif(true); + membreRepository.persist(membre); + + LOG.infof("Membre activé avec succès: %s (ID: %s)", membre.getNomComplet(), membreId); + return membre; + } + /** Met à jour un membre existant */ @Transactional public Membre mettreAJourMembre(UUID id, Membre membreModifie) { diff --git a/src/test/java/dev/lions/unionflow/server/service/MembreServiceTest.java b/src/test/java/dev/lions/unionflow/server/service/MembreServiceTest.java index f657f9d..2034d78 100644 --- a/src/test/java/dev/lions/unionflow/server/service/MembreServiceTest.java +++ b/src/test/java/dev/lions/unionflow/server/service/MembreServiceTest.java @@ -108,8 +108,8 @@ class MembreServiceTest { class CreerMembreTests { @Test - @DisplayName("Happy path: numéro généré automatiquement, statut ACTIF") - void creerMembre_generatesNumeroAndSetsActif() { + @DisplayName("Happy path: numéro généré automatiquement, statut EN_ATTENTE_VALIDATION") + void creerMembre_generatesNumeroAndSetsEnAttenteValidation() { Membre membre = new Membre(); membre.setEmail("test@unionflow.dev"); membre.setNom("Doe"); @@ -123,8 +123,8 @@ class MembreServiceTest { Membre created = membreService.creerMembre(membre); assertThat(created.getNumeroMembre()).startsWith("UF"); - assertThat(created.getStatutCompte()).isEqualTo("ACTIF"); - assertThat(created.getActif()).isTrue(); + assertThat(created.getStatutCompte()).isEqualTo("EN_ATTENTE_VALIDATION"); + assertThat(created.getActif()).isFalse(); verify(membreRepository).persist(membre); } @@ -205,6 +205,47 @@ class MembreServiceTest { } } + // ========================================================================= + // activerMembre + // ========================================================================= + + @Nested + @DisplayName("activerMembre") + class ActiverMembreTests { + + @Test + @DisplayName("Happy path: membre EN_ATTENTE_VALIDATION → ACTIF + actif=true") + void activerMembre_pendingMember_setsActif() { + UUID id = UUID.randomUUID(); + Membre membre = membreFixture("pending@unionflow.dev"); + membre.setId(id); + membre.setStatutCompte("EN_ATTENTE_VALIDATION"); + membre.setActif(false); + + when(membreRepository.findByIdOptional(id)).thenReturn(Optional.of(membre)); + doNothing().when(membreRepository).persist(any(Membre.class)); + + Membre activated = membreService.activerMembre(id); + + assertThat(activated.getStatutCompte()).isEqualTo("ACTIF"); + assertThat(activated.getActif()).isTrue(); + verify(membreRepository).persist(membre); + } + + @Test + @DisplayName("Membre introuvable: lève NotFoundException") + void activerMembre_notFound_throws() { + UUID id = UUID.randomUUID(); + when(membreRepository.findByIdOptional(id)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> membreService.activerMembre(id)) + .isInstanceOf(jakarta.ws.rs.NotFoundException.class) + .hasMessageContaining("non trouvé"); + + verify(membreRepository, never()).persist(any(Membre.class)); + } + } + // ========================================================================= // mettreAJourMembre // =========================================================================