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 092ba2d..bbe83b4 100644
--- a/src/main/java/dev/lions/unionflow/server/resource/MembreResource.java
+++ b/src/main/java/dev/lions/unionflow/server/resource/MembreResource.java
@@ -674,6 +674,33 @@ public class MembreResource {
.build();
}
+ @PUT
+ @Path("/{id}/promouvoir-admin-organisation")
+ @RolesAllowed({"ADMIN", "SUPER_ADMIN"})
+ @Operation(
+ summary = "Promouvoir un membre en administrateur d'organisation",
+ description = "Passe le membre en statut ACTIF et lui assigne le rôle ADMIN_ORGANISATION dans Keycloak. "
+ + "Réservé aux super administrateurs de la plateforme. "
+ + "Le compte est immédiatement opérationnel sans validation intermédiaire.")
+ @APIResponse(responseCode = "200", description = "Membre promu administrateur d'organisation")
+ @APIResponse(responseCode = "404", description = "Membre non trouvé")
+ @APIResponse(responseCode = "403", description = "Accès réservé aux ADMIN / SUPER_ADMIN")
+ public Response promouvoirAdminOrganisation(
+ @Parameter(description = "UUID du membre à promouvoir") @PathParam("id") UUID id) {
+ LOG.infof("Promotion admin d'organisation pour le membre ID: %s", id);
+
+ Membre membrePromu = membreService.promouvoirAdminOrganisation(id);
+
+ // Assigner ADMIN_ORGANISATION dans Keycloak (non bloquant)
+ try {
+ keycloakSyncService.promouvoirAdminOrganisationDansKeycloak(id);
+ } catch (Exception e) {
+ LOG.warnf("Promotion Keycloak échouée pour %s (non bloquant): %s", membrePromu.getEmail(), e.getMessage());
+ }
+
+ return Response.ok(membreService.convertToResponse(membrePromu)).build();
+ }
+
@PUT
@Path("/{id}/activer")
@RolesAllowed({"ADMIN", "SUPER_ADMIN"})
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 f117f25..190b48b 100644
--- a/src/main/java/dev/lions/unionflow/server/service/MembreKeycloakSyncService.java
+++ b/src/main/java/dev/lions/unionflow/server/service/MembreKeycloakSyncService.java
@@ -196,6 +196,62 @@ public class MembreKeycloakSyncService {
}
}
+ /**
+ * Promeut un membre au rôle ADMIN_ORGANISATION dans Keycloak.
+ * Appelé après MembreService.promouvoirAdminOrganisation().
+ *
+ *
Si le membre n'a pas encore de compte Keycloak, le provisionne d'abord.
+ * Assigne ensuite ADMIN_ORGANISATION et retire les rôles MEMBRE / MEMBRE_ACTIF
+ * (un admin gère l'organisation — il n'est pas un membre ordinaire).
+ *
+ * @param membreId UUID du membre à promouvoir dans Keycloak
+ * @throws NotFoundException si le membre n'existe pas en base
+ */
+ @Transactional
+ public void promouvoirAdminOrganisationDansKeycloak(java.util.UUID membreId) {
+ LOGGER.info("Promotion Keycloak (rôle ADMIN_ORGANISATION) 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);
+ membre = membreRepository.findByIdOptional(membreId)
+ .orElseThrow(() -> new NotFoundException("Membre non trouvé après provisionnement: " + membreId));
+ }
+
+ String keycloakUserId = membre.getKeycloakId().toString();
+
+ try {
+ UserDTO user = userServiceClient.getUserById(keycloakUserId, DEFAULT_REALM);
+
+ // S'assurer que le compte est activé
+ if (Boolean.FALSE.equals(user.getEnabled())) {
+ user.setEnabled(true);
+ }
+
+ // Construire la liste de rôles : ajouter ADMIN_ORGANISATION, retirer MEMBRE et MEMBRE_ACTIF
+ List roles = user.getRealmRoles() != null
+ ? new java.util.ArrayList<>(user.getRealmRoles())
+ : new java.util.ArrayList<>();
+ roles.remove("MEMBRE");
+ roles.remove("MEMBRE_ACTIF");
+ if (!roles.contains("ADMIN_ORGANISATION")) {
+ roles.add("ADMIN_ORGANISATION");
+ }
+ user.setRealmRoles(roles);
+ userServiceClient.updateUser(keycloakUserId, user, DEFAULT_REALM);
+
+ LOGGER.info("✅ Rôle ADMIN_ORGANISATION assigné dans Keycloak pour " + membre.getNomComplet());
+
+ } catch (Exception e) {
+ LOGGER.severe("❌ Erreur promotion Keycloak pour " + membre.getNomComplet() + ": " + e.getMessage());
+ throw new RuntimeException("Impossible de promouvoir 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 58da914..6194971 100644
--- a/src/main/java/dev/lions/unionflow/server/service/MembreService.java
+++ b/src/main/java/dev/lions/unionflow/server/service/MembreService.java
@@ -118,6 +118,33 @@ public class MembreService {
return membre;
}
+ /**
+ * Promeut un membre au rôle d'administrateur d'organisation.
+ * Passe immédiatement le statut à ACTIF — les admins sont opérationnels sans
+ * validation intermédiaire.
+ * Doit être suivi d'un appel à
+ * MembreKeycloakSyncService.promouvoirAdminOrganisationDansKeycloak()
+ * pour que le rôle ADMIN_ORGANISATION soit assigné dans Keycloak.
+ *
+ * @param membreId UUID du membre à promouvoir
+ * @return Le membre mis à jour
+ * @throws jakarta.ws.rs.NotFoundException si le membre est introuvable
+ */
+ @Transactional
+ public Membre promouvoirAdminOrganisation(UUID membreId) {
+ LOG.infof("Promotion admin d'organisation pour le 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 promu admin d'organisation: %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/MembreKeycloakSyncServiceTest.java b/src/test/java/dev/lions/unionflow/server/service/MembreKeycloakSyncServiceTest.java
index 3be78c6..869d38c 100644
--- a/src/test/java/dev/lions/unionflow/server/service/MembreKeycloakSyncServiceTest.java
+++ b/src/test/java/dev/lions/unionflow/server/service/MembreKeycloakSyncServiceTest.java
@@ -217,6 +217,148 @@ class MembreKeycloakSyncServiceTest {
verify(membreRepository).persist(membre);
}
+ // =========================================================================
+ // promouvoirAdminOrganisationDansKeycloak
+ // =========================================================================
+
+ @Test
+ @DisplayName("promouvoirAdminOrganisation échoue si le membre n'existe pas")
+ void promouvoirAdminOrganisation_failsIfMembreNotFound() {
+ UUID membreId = UUID.randomUUID();
+ when(membreRepository.findByIdOptional(membreId)).thenReturn(Optional.empty());
+
+ assertThatThrownBy(() -> syncService.promouvoirAdminOrganisationDansKeycloak(membreId))
+ .isInstanceOf(NotFoundException.class);
+ }
+
+ @Test
+ @DisplayName("promouvoirAdminOrganisation assigne ADMIN_ORGANISATION et retire MEMBRE/MEMBRE_ACTIF")
+ void promouvoirAdminOrganisation_assignsAdminRoleAndRemovesMemberRoles() {
+ UUID membreId = UUID.randomUUID();
+ UUID keycloakId = UUID.randomUUID();
+
+ Membre membre = new Membre();
+ membre.setId(membreId);
+ membre.setKeycloakId(keycloakId);
+ membre.setEmail("admin@unionflow.dev");
+ membre.setNom("Admin");
+ membre.setPrenom("New");
+
+ when(membreRepository.findByIdOptional(membreId)).thenReturn(Optional.of(membre));
+
+ UserDTO user = new UserDTO();
+ user.setId(keycloakId.toString());
+ user.setEnabled(true);
+ user.setRealmRoles(new java.util.ArrayList<>(java.util.List.of("MEMBRE", "MEMBRE_ACTIF")));
+ user.setRealmName("unionflow");
+ when(userServiceClient.getUserById(eq(keycloakId.toString()), eq("unionflow"))).thenReturn(user);
+ when(userServiceClient.updateUser(anyString(), any(UserDTO.class), anyString())).thenReturn(user);
+
+ syncService.promouvoirAdminOrganisationDansKeycloak(membreId);
+
+ verify(userServiceClient).updateUser(eq(keycloakId.toString()), any(UserDTO.class), eq("unionflow"));
+ assertThat(user.getRealmRoles()).contains("ADMIN_ORGANISATION");
+ assertThat(user.getRealmRoles()).doesNotContain("MEMBRE", "MEMBRE_ACTIF");
+ }
+
+ @Test
+ @DisplayName("promouvoirAdminOrganisation active le compte s'il est désactivé dans Keycloak")
+ void promouvoirAdminOrganisation_enablesDisabledAccount() {
+ UUID membreId = UUID.randomUUID();
+ UUID keycloakId = UUID.randomUUID();
+
+ Membre membre = new Membre();
+ membre.setId(membreId);
+ membre.setKeycloakId(keycloakId);
+ membre.setEmail("disabled-admin@unionflow.dev");
+ membre.setNom("Disabled");
+ membre.setPrenom("Admin");
+
+ when(membreRepository.findByIdOptional(membreId)).thenReturn(Optional.of(membre));
+
+ UserDTO user = new UserDTO();
+ user.setId(keycloakId.toString());
+ user.setEnabled(false); // désactivé
+ user.setRealmRoles(new java.util.ArrayList<>());
+ user.setRealmName("unionflow");
+ when(userServiceClient.getUserById(anyString(), anyString())).thenReturn(user);
+ when(userServiceClient.updateUser(anyString(), any(UserDTO.class), anyString())).thenReturn(user);
+
+ syncService.promouvoirAdminOrganisationDansKeycloak(membreId);
+
+ assertThat(user.getEnabled()).isTrue();
+ verify(userServiceClient).updateUser(anyString(), any(UserDTO.class), anyString());
+ }
+
+ @Test
+ @DisplayName("promouvoirAdminOrganisation provisionne Keycloak si keycloakId est null")
+ void promouvoirAdminOrganisation_provisionesIfNoKeycloakAccount() {
+ UUID membreId = UUID.randomUUID();
+
+ Membre membre = new Membre();
+ membre.setId(membreId);
+ membre.setEmail("no-kc-admin@unionflow.dev");
+ membre.setNom("No");
+ membre.setPrenom("KC");
+ // keycloakId == null
+
+ UUID newKeycloakId = UUID.randomUUID();
+ Membre membreWithKc = new Membre();
+ membreWithKc.setId(membreId);
+ membreWithKc.setEmail("no-kc-admin@unionflow.dev");
+ membreWithKc.setNom("No");
+ membreWithKc.setPrenom("KC");
+ membreWithKc.setKeycloakId(newKeycloakId);
+
+ when(membreRepository.findByIdOptional(membreId))
+ .thenReturn(Optional.of(membre)) // 1er appel (promouvoir) : pas de keycloakId → déclenche provisionnement
+ .thenReturn(Optional.of(membre)) // 2e appel (provisionKeycloakUser interne) : pas de keycloakId
+ .thenReturn(Optional.of(membreWithKc)); // 3e appel : rechargement après provisionnement
+
+ UserSearchResultDTO searchResult = new UserSearchResultDTO();
+ searchResult.setUsers(java.util.Collections.emptyList());
+ when(userServiceClient.searchUsers(any())).thenReturn(searchResult);
+
+ UserDTO createdUser = new UserDTO();
+ createdUser.setId(newKeycloakId.toString());
+ when(userServiceClient.createUser(any(UserDTO.class), anyString())).thenReturn(createdUser);
+
+ UserDTO fetchedUser = new UserDTO();
+ fetchedUser.setId(newKeycloakId.toString());
+ fetchedUser.setEnabled(true);
+ fetchedUser.setRealmRoles(new java.util.ArrayList<>());
+ fetchedUser.setRealmName("unionflow");
+ when(userServiceClient.getUserById(eq(newKeycloakId.toString()), anyString())).thenReturn(fetchedUser);
+ when(userServiceClient.updateUser(anyString(), any(UserDTO.class), anyString())).thenReturn(fetchedUser);
+
+ syncService.promouvoirAdminOrganisationDansKeycloak(membreId);
+
+ verify(userServiceClient).createUser(any(UserDTO.class), eq("unionflow"));
+ verify(userServiceClient).updateUser(eq(newKeycloakId.toString()), any(UserDTO.class), eq("unionflow"));
+ }
+
+ @Test
+ @DisplayName("promouvoirAdminOrganisation lève RuntimeException si l'appel Keycloak échoue")
+ void promouvoirAdminOrganisation_throwsRuntimeExceptionOnKeycloakFailure() {
+ UUID membreId = UUID.randomUUID();
+ UUID keycloakId = UUID.randomUUID();
+
+ Membre membre = new Membre();
+ membre.setId(membreId);
+ membre.setKeycloakId(keycloakId);
+ membre.setEmail("fail-admin@unionflow.dev");
+ membre.setNom("Fail");
+ membre.setPrenom("Admin");
+
+ when(membreRepository.findByIdOptional(membreId)).thenReturn(Optional.of(membre));
+ when(userServiceClient.getUserById(anyString(), anyString()))
+ .thenThrow(new RuntimeException("Keycloak unreachable"));
+
+ assertThatThrownBy(() -> syncService.promouvoirAdminOrganisationDansKeycloak(membreId))
+ .isInstanceOf(RuntimeException.class)
+ .hasMessageContaining("Impossible de promouvoir le compte Keycloak");
+ }
+
// =========================================================================
// syncMembreToKeycloak
// =========================================================================
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 2034d78..edc0762 100644
--- a/src/test/java/dev/lions/unionflow/server/service/MembreServiceTest.java
+++ b/src/test/java/dev/lions/unionflow/server/service/MembreServiceTest.java
@@ -246,6 +246,50 @@ class MembreServiceTest {
}
}
+ // =========================================================================
+ // promouvoirAdminOrganisation
+ // =========================================================================
+
+ @Nested
+ @DisplayName("promouvoirAdminOrganisation")
+ class PromouvoirAdminOrganisationTests {
+
+ @Test
+ @DisplayName("Happy path: membre promu → ACTIF + actif=true immédiatement")
+ void promouvoirAdminOrganisation_setsActifImmediately() {
+ UUID id = UUID.randomUUID();
+ Membre membre = new Membre();
+ membre.setId(id);
+ membre.setEmail("future-admin@unionflow.dev");
+ membre.setNom("Admin");
+ membre.setPrenom("New");
+ membre.setStatutCompte("EN_ATTENTE_VALIDATION");
+ membre.setActif(false);
+
+ when(membreRepository.findByIdOptional(id)).thenReturn(Optional.of(membre));
+ doNothing().when(membreRepository).persist(any(Membre.class));
+
+ Membre result = membreService.promouvoirAdminOrganisation(id);
+
+ assertThat(result.getStatutCompte()).isEqualTo("ACTIF");
+ assertThat(result.getActif()).isTrue();
+ verify(membreRepository).persist(membre);
+ }
+
+ @Test
+ @DisplayName("Membre introuvable: lève NotFoundException")
+ void promouvoirAdminOrganisation_notFound_throws() {
+ UUID id = UUID.randomUUID();
+ when(membreRepository.findByIdOptional(id)).thenReturn(Optional.empty());
+
+ assertThatThrownBy(() -> membreService.promouvoirAdminOrganisation(id))
+ .isInstanceOf(jakarta.ws.rs.NotFoundException.class)
+ .hasMessageContaining("non trouvé");
+
+ verify(membreRepository, never()).persist(any(Membre.class));
+ }
+ }
+
// =========================================================================
// mettreAJourMembre
// =========================================================================