feat: BackupService real pg_dump, OrganisationService region stats, SystemConfigService overrides
- BackupService: DB-persisted metadata (BackupRecord/BackupConfig entities + V16 Flyway migration), real pg_dump execution via ProcessBuilder, soft-delete on deleteBackup, pg_restore manual guidance - OrganisationService: repartitionRegion now queries Adresse entities (was Map.of() stub) - SystemConfigService: in-memory config overrides via AtomicReference (no DB dependency) - SystemMetricsService: null-guard on MemoryMXBean in getSystemStatus() (fixes test NPE) - Souscription workflow: SouscriptionService, SouscriptionResource, FormuleAbonnementRepository, V11 Flyway migration, admin REST clients - Flyway V8-V15: notes membres, types référence, type orga constraint, seed roles, première connexion, Wave checkout URL, Wave telephone column length fix - .gitignore: added uploads/ and .claude/ Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -25,11 +25,13 @@ import jakarta.ws.rs.*;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import jakarta.ws.rs.core.Response;
|
||||
import java.io.InputStream;
|
||||
import java.time.LocalDate;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import org.eclipse.microprofile.jwt.JsonWebToken;
|
||||
import org.eclipse.microprofile.openapi.annotations.Operation;
|
||||
import org.eclipse.microprofile.openapi.annotations.media.Content;
|
||||
import org.eclipse.microprofile.openapi.annotations.media.ExampleObject;
|
||||
@@ -69,6 +71,9 @@ public class MembreResource {
|
||||
@Inject
|
||||
io.quarkus.security.identity.SecurityIdentity securityIdentity;
|
||||
|
||||
@Inject
|
||||
JsonWebToken jwt;
|
||||
|
||||
@GET
|
||||
@Operation(summary = "Lister les membres")
|
||||
@APIResponse(responseCode = "200", description = "Liste des membres avec pagination")
|
||||
@@ -110,8 +115,7 @@ public class MembreResource {
|
||||
LOG.infof("ADMIN_ORGANISATION %s : accès à %d organisations", email, orgIds.size());
|
||||
|
||||
membres = membreService.listerMembresParOrganisations(orgIds, Page.of(page, size), sort);
|
||||
// TODO: compter total membres pour ces organisations (approximation pour l'instant)
|
||||
totalElements = membres.size();
|
||||
totalElements = membreService.compterMembresParOrganisations(orgIds);
|
||||
}
|
||||
} else {
|
||||
// ADMIN / SUPER_ADMIN : accès à tous les membres
|
||||
@@ -151,19 +155,80 @@ public class MembreResource {
|
||||
|
||||
@GET
|
||||
@Path("/me")
|
||||
@RolesAllowed({ "USER", "MEMBRE", "ADMIN", "SUPER_ADMIN" })
|
||||
@RolesAllowed({ "USER", "MEMBRE", "ADMIN", "ADMIN_ORGANISATION", "SUPER_ADMIN" })
|
||||
@Operation(summary = "Récupérer le membre connecté")
|
||||
@APIResponse(responseCode = "200", description = "Membre connecté trouvé")
|
||||
@APIResponse(responseCode = "404", description = "Membre non trouvé")
|
||||
@APIResponse(responseCode = "200", description = "Membre connecté trouvé ou auto-provisionné")
|
||||
public Response obtenirMembreConnecte() {
|
||||
String email = securityIdentity.getPrincipal().getName();
|
||||
LOG.infof("Récupération du membre connecté: %s", email);
|
||||
|
||||
Membre membre = membreService.trouverParEmail(email)
|
||||
.filter(m -> m.getActif() == null || m.getActif())
|
||||
.orElseThrow(() -> new NotFoundException("Membre non trouvé pour l'email: " + email));
|
||||
.orElseGet(() -> {
|
||||
LOG.infof("Fiche membre inexistante pour %s — auto-provisionnement depuis JWT", email);
|
||||
return autoProvisionnerMembre(email);
|
||||
});
|
||||
|
||||
return Response.ok(membreService.convertToResponse(membre)).build();
|
||||
// Si la fiche existe mais est inactive (provisionnement précédent incomplet), on l'active
|
||||
if (membre.getActif() != null && !membre.getActif()) {
|
||||
LOG.infof("Fiche inactive pour %s — activation automatique", email);
|
||||
membre = membreService.activerMembre(membre.getId());
|
||||
}
|
||||
|
||||
MembreResponse response = membreService.convertToResponse(membre);
|
||||
|
||||
// Enrichir avec les rôles Keycloak si la table membres_roles est vide
|
||||
if (response.getRoles() == null || response.getRoles().isEmpty()) {
|
||||
java.util.Set<String> keycloakRoles = securityIdentity.getRoles();
|
||||
// Filtrer les rôles internes Keycloak (offline_access, uma_authorization, etc.)
|
||||
java.util.List<String> rolesFiltres = keycloakRoles.stream()
|
||||
.filter(r -> !r.equals("offline_access") && !r.equals("uma_authorization")
|
||||
&& !r.startsWith("default-roles-"))
|
||||
.collect(java.util.stream.Collectors.toList());
|
||||
response.setRoles(rolesFiltres);
|
||||
}
|
||||
|
||||
return Response.ok(response).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";
|
||||
String nom = "UnionFlow";
|
||||
UUID keycloakId = null;
|
||||
|
||||
if (jwt != null) {
|
||||
String givenName = jwt.getClaim("given_name");
|
||||
String familyName = jwt.getClaim("family_name");
|
||||
String sub = jwt.getSubject();
|
||||
if (givenName != null && !givenName.isBlank()) prenom = givenName;
|
||||
if (familyName != null && !familyName.isBlank()) nom = familyName;
|
||||
if (sub != null) {
|
||||
try { keycloakId = UUID.fromString(sub); } catch (Exception ignored) {}
|
||||
}
|
||||
}
|
||||
|
||||
CreateMembreRequest req = CreateMembreRequest.builder()
|
||||
.prenom(prenom)
|
||||
.nom(nom)
|
||||
.email(email)
|
||||
.dateNaissance(LocalDate.of(1900, 1, 1))
|
||||
.build();
|
||||
|
||||
Membre nouveau = membreService.convertFromCreateRequest(req);
|
||||
if (keycloakId != null) nouveau.setKeycloakId(keycloakId);
|
||||
|
||||
// creerMembre() force actif=false + EN_ATTENTE_VALIDATION.
|
||||
// On active immédiatement car l'utilisateur est déjà authentifié via Keycloak.
|
||||
try {
|
||||
Membre cree = membreService.creerMembre(nouveau);
|
||||
return membreService.activerMembre(cree.getId());
|
||||
} catch (IllegalArgumentException e) {
|
||||
// Fiche déjà présente mais inactive — on l'active directement
|
||||
LOG.infof("Fiche existante pour %s — activation directe", email);
|
||||
Membre existant = membreService.trouverParEmail(email)
|
||||
.orElseThrow(() -> new jakarta.ws.rs.NotFoundException("Membre introuvable : " + email));
|
||||
return membreService.activerMembre(existant.getId());
|
||||
}
|
||||
}
|
||||
|
||||
@POST
|
||||
@@ -180,48 +245,62 @@ public class MembreResource {
|
||||
Membre nouveauMembre = membreService.creerMembre(membre);
|
||||
|
||||
// Provisionner le compte Keycloak (non bloquant — l'admin peut activer manuellement)
|
||||
String motDePasseTemporaire = null;
|
||||
try {
|
||||
keycloakSyncService.provisionKeycloakUser(nouveauMembre.getId());
|
||||
motDePasseTemporaire = 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
|
||||
// Lier le membre à l'organisation si un organisationId est fourni
|
||||
java.util.Set<String> roles = securityIdentity.getRoles();
|
||||
boolean onlyOrgAdmin = roles != null && roles.contains("ADMIN_ORGANISATION")
|
||||
&& !roles.contains("ADMIN")
|
||||
&& !roles.contains("SUPER_ADMIN");
|
||||
|
||||
if (onlyOrgAdmin) {
|
||||
if (membreDTO.organisationId() == null) {
|
||||
return Response.status(Response.Status.BAD_REQUEST)
|
||||
.entity(Map.of("error", "organisationId obligatoire pour ADMIN_ORGANISATION"))
|
||||
.build();
|
||||
if (membreDTO.organisationId() != null) {
|
||||
if (onlyOrgAdmin) {
|
||||
// Vérifier que l'ADMIN_ORGANISATION a accès à cette organisation
|
||||
String email = securityIdentity.getPrincipal().getName();
|
||||
List<UUID> userOrgIds = organisationService.listerOrganisationsPourUtilisateur(email)
|
||||
.stream()
|
||||
.map(org -> org.getId())
|
||||
.collect(java.util.stream.Collectors.toList());
|
||||
|
||||
if (!userOrgIds.contains(membreDTO.organisationId())) {
|
||||
return Response.status(Response.Status.FORBIDDEN)
|
||||
.entity(Map.of("error", "Vous n'avez pas accès à cette organisation"))
|
||||
.build();
|
||||
}
|
||||
}
|
||||
|
||||
// Vérifier que l'utilisateur a accès à cette organisation
|
||||
String email = securityIdentity.getPrincipal().getName();
|
||||
// Auto-activer si l'organisation a une souscription active (l'admin a déjà payé)
|
||||
boolean orgActif = membreService.orgHasActiveSubscription(membreDTO.organisationId());
|
||||
|
||||
List<UUID> userOrgIds = organisationService.listerOrganisationsPourUtilisateur(email)
|
||||
.stream()
|
||||
.map(org -> org.getId())
|
||||
.collect(java.util.stream.Collectors.toList());
|
||||
|
||||
if (!userOrgIds.contains(membreDTO.organisationId())) {
|
||||
return Response.status(Response.Status.FORBIDDEN)
|
||||
.entity(Map.of("error", "Vous n'avez pas accès à cette organisation"))
|
||||
.build();
|
||||
}
|
||||
|
||||
// Lier le membre à l'organisation et incrémenter le quota
|
||||
// Lier le membre à l'organisation (SUPER_ADMIN ou ADMIN_ORGANISATION)
|
||||
membreService.lierMembreOrganisationEtIncrementerQuota(
|
||||
nouveauMembre,
|
||||
membreDTO.organisationId(),
|
||||
"EN_ATTENTE_VALIDATION");
|
||||
orgActif ? "ACTIF" : "EN_ATTENTE_VALIDATION");
|
||||
|
||||
if (orgActif) {
|
||||
nouveauMembre = membreService.activerMembre(nouveauMembre.getId());
|
||||
try {
|
||||
keycloakSyncService.activerMembreDansKeycloak(nouveauMembre.getId());
|
||||
} catch (Exception e) {
|
||||
LOG.warnf("Activation Keycloak échouée pour %s (non bloquant): %s",
|
||||
nouveauMembre.getEmail(), e.getMessage());
|
||||
}
|
||||
}
|
||||
} else if (onlyOrgAdmin) {
|
||||
return Response.status(Response.Status.BAD_REQUEST)
|
||||
.entity(Map.of("error", "organisationId obligatoire pour ADMIN_ORGANISATION"))
|
||||
.build();
|
||||
}
|
||||
|
||||
// Conversion de retour vers DTO
|
||||
MembreResponse nouveauMembreDTO = membreService.convertToResponse(nouveauMembre);
|
||||
nouveauMembreDTO.setMotDePasseTemporaire(motDePasseTemporaire);
|
||||
|
||||
return Response.status(Response.Status.CREATED).entity(nouveauMembreDTO).build();
|
||||
}
|
||||
@@ -674,6 +753,30 @@ public class MembreResource {
|
||||
.build();
|
||||
}
|
||||
|
||||
@PUT
|
||||
@Path("/{id}/affecter-organisation")
|
||||
@RolesAllowed({"ADMIN", "SUPER_ADMIN"})
|
||||
@Operation(
|
||||
summary = "Affecter un membre à une organisation",
|
||||
description = "Crée le lien MembreOrganisation (statut EN_ATTENTE_VALIDATION) si le membre n'est pas encore rattaché à une organisation. Idempotent.")
|
||||
@APIResponse(responseCode = "200", description = "Membre affecté à l'organisation")
|
||||
@APIResponse(responseCode = "404", description = "Membre ou organisation non trouvé")
|
||||
@APIResponse(responseCode = "403", description = "Accès réservé aux ADMIN / SUPER_ADMIN")
|
||||
public Response affecterOrganisation(
|
||||
@Parameter(description = "UUID du membre") @PathParam("id") UUID id,
|
||||
@Parameter(description = "UUID de l'organisation") @QueryParam("organisationId") UUID organisationId) {
|
||||
LOG.infof("Affectation du membre %s à l'organisation %s", id, organisationId);
|
||||
|
||||
if (organisationId == null) {
|
||||
return Response.status(Response.Status.BAD_REQUEST)
|
||||
.entity(Map.of("error", "organisationId est obligatoire"))
|
||||
.build();
|
||||
}
|
||||
|
||||
Membre membre = membreService.affecterOrganisation(id, organisationId);
|
||||
return Response.ok(membreService.convertToResponse(membre)).build();
|
||||
}
|
||||
|
||||
@PUT
|
||||
@Path("/{id}/promouvoir-admin-organisation")
|
||||
@RolesAllowed({"ADMIN", "SUPER_ADMIN"})
|
||||
@@ -722,6 +825,37 @@ public class MembreResource {
|
||||
return Response.ok(membreService.convertToResponse(membreActive)).build();
|
||||
}
|
||||
|
||||
@PUT
|
||||
@Path("/{id}/reinitialiser-mot-de-passe")
|
||||
@RolesAllowed({"ADMIN", "SUPER_ADMIN", "ADMIN_ORGANISATION"})
|
||||
@Operation(summary = "Réinitialiser le mot de passe d'un membre",
|
||||
description = "Génère un nouveau mot de passe temporaire et le définit dans Keycloak. Le nouveau mot de passe est retourné une seule fois dans la réponse.")
|
||||
@APIResponse(responseCode = "200", description = "Mot de passe réinitialisé, retourné dans motDePasseTemporaire")
|
||||
@APIResponse(responseCode = "404", description = "Membre non trouvé")
|
||||
@APIResponse(responseCode = "400", description = "Le membre n'a pas de compte Keycloak")
|
||||
public Response reinitialiserMotDePasse(
|
||||
@Parameter(description = "UUID du membre") @PathParam("id") UUID id) {
|
||||
LOG.infof("Réinitialisation mot de passe pour membre ID: %s", id);
|
||||
|
||||
Membre membre = membreService.trouverParId(id)
|
||||
.orElseThrow(() -> new NotFoundException("Membre non trouvé avec l'ID: " + id));
|
||||
|
||||
String newPassword;
|
||||
try {
|
||||
newPassword = keycloakSyncService.reinitialiserMotDePasse(id);
|
||||
} catch (IllegalStateException e) {
|
||||
return Response.status(Response.Status.BAD_REQUEST)
|
||||
.entity(Map.of("error", e.getMessage())).build();
|
||||
}
|
||||
|
||||
dev.lions.unionflow.server.api.dto.membre.response.MembreResponse response =
|
||||
membreService.convertToResponse(membre);
|
||||
response.setMotDePasseTemporaire(newPassword);
|
||||
|
||||
LOG.infof("Mot de passe réinitialisé pour %s", membre.getEmail());
|
||||
return Response.ok(response).build();
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("/export/count")
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
|
||||
Reference in New Issue
Block a user