package dev.lions.unionflow.server.resource; import dev.lions.unionflow.server.api.dto.membre.CompteAdherentResponse; import dev.lions.unionflow.server.entity.Membre; import dev.lions.unionflow.server.entity.MembreOrganisation; import dev.lions.unionflow.server.entity.SouscriptionOrganisation; import dev.lions.unionflow.server.repository.MembreOrganisationRepository; import dev.lions.unionflow.server.repository.MembreRepository; import dev.lions.unionflow.server.repository.SouscriptionOrganisationRepository; import dev.lions.unionflow.server.service.CompteAdherentService; import dev.lions.unionflow.server.service.MembreKeycloakSyncService; import dev.lions.unionflow.server.service.MembreService; import dev.lions.unionflow.server.service.support.SecuriteHelper; import io.quarkus.security.Authenticated; import jakarta.annotation.security.RolesAllowed; import jakarta.inject.Inject; import jakarta.ws.rs.*; import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.Response; import org.eclipse.microprofile.openapi.annotations.Operation; import org.eclipse.microprofile.openapi.annotations.tags.Tag; import org.jboss.logging.Logger; import java.util.HashMap; import java.util.Map; import java.util.Optional; import java.util.UUID; /** * Endpoint REST pour le compte adhérent du membre connecté. * *

Toutes les routes de ce resource sont protégées et ne retournent * que les données du membre connecté (pas d'accès aux comptes tiers). * *

Exemple de réponse : *

 * GET /api/membres/mon-compte
 * → { "numeroMembre": "MUF-2026-001", "soldeTotalDisponible": 215000, ... }
 * 
*/ @Path("/api/membres") @Produces(MediaType.APPLICATION_JSON) @Consumes(MediaType.APPLICATION_JSON) @Tag(name = "Compte Adhérent", description = "Vue financière unifiée du membre connecté") public class CompteAdherentResource { private static final Logger LOG = Logger.getLogger(CompteAdherentResource.class); @Inject CompteAdherentService compteAdherentService; @Inject SecuriteHelper securiteHelper; @Inject MembreRepository membreRepository; @Inject MembreOrganisationRepository membreOrganisationRepo; @Inject SouscriptionOrganisationRepository souscriptionRepo; @Inject MembreKeycloakSyncService membreKeycloakSyncService; @Inject MembreService membreService; /** * Retourne le compte adhérent complet du membre connecté : * numéro de membre, soldes (cotisations + épargne), capacité d'emprunt, taux d'engagement. */ @GET @Path("/mon-compte") @RolesAllowed({ "USER", "MEMBRE", "ADMIN", "ADMIN_ORGANISATION", "SUPER_ADMIN" }) @Operation( summary = "Compte adhérent du membre connecté", description = "Agrège cotisations, épargne et crédit en une vue financière unifiée." ) public Response getMonCompte() { CompteAdherentResponse compte = compteAdherentService.getMonCompte(); return Response.ok(compte).build(); } /** * Retourne le statut du compte du membre connecté. * *

Endpoint léger appelé par le mobile juste après le login Keycloak * pour détecter les comptes en attente/suspendus/désactivés avant d'accorder l'accès. * *

Si aucun enregistrement membre n'existe (ex. : administrateur créé directement * dans Keycloak sans fiche membre), retourne {@code ACTIF} pour ne pas bloquer les admins. */ @GET @Path("/mon-statut") @Authenticated @Operation( summary = "Statut du compte du membre connecté", description = "Retourne statutCompte : ACTIF, EN_ATTENTE_VALIDATION, SUSPENDU ou DESACTIVE." ) public Response getMonStatut() { String email = securiteHelper.resolveEmail(); if (email == null || email.isBlank()) { return Response.status(Response.Status.UNAUTHORIZED).build(); } Optional membreOpt = membreRepository.findByEmail(email.trim()) .or(() -> membreRepository.findByEmail(email.trim().toLowerCase())); // Pas de fiche membre → administrateur pur → accès autorisé String statutCompte = membreOpt .map(Membre::getStatutCompte) .filter(s -> s != null && !s.isBlank()) .orElse("ACTIF"); // Premier login : Keycloak a déjà forcé UPDATE_PASSWORD dans le Chrome Custom Tab. // Si l'utilisateur possède un token valide (@Authenticated), c'est la preuve que // le changement de mot de passe est complété — mettre à jour la DB et assigner les rôles. // Pour les anciens comptes (sans UPDATE_PASSWORD assigné), le service assigne automatiquement // la required action et retourne REAUTH_REQUIS pour que Flutter déclenche une nouvelle auth. boolean premierLoginComplet = false; boolean reAuthRequired = false; if (membreOpt.isPresent() && Boolean.TRUE.equals(membreOpt.get().getPremiereConnexion())) { Membre m = membreOpt.get(); MembreKeycloakSyncService.PremierLoginResultat resultat = membreKeycloakSyncService.completerPremierLogin(m.getId()); if (resultat == MembreKeycloakSyncService.PremierLoginResultat.COMPLETE) { premierLoginComplet = true; // Relire le statutCompte après activation éventuelle statutCompte = membreRepository.findByIdOptional(m.getId()) .map(Membre::getStatutCompte) .orElse(statutCompte); LOG.infof("Premier login complété au statut pour %s → %s", m.getEmail(), statutCompte); } else if (resultat == MembreKeycloakSyncService.PremierLoginResultat.REAUTH_REQUIS) { reAuthRequired = true; LOG.infof("Réauthentification requise pour %s (UPDATE_PASSWORD assigné)", m.getEmail()); } } // Fallback : auto-activer si EN_ATTENTE_VALIDATION et org a souscription active // (membres sans premiereConnexion=true ou créés avant cette logique) if ("EN_ATTENTE_VALIDATION".equals(statutCompte) && membreOpt.isPresent()) { Membre m = membreOpt.get(); UUID orgId = membreOrganisationRepo.findFirstByMembreId(m.getId()) .map(mo -> mo.getOrganisation().getId()) .orElse(null); if (membreService.orgHasActiveSubscription(orgId)) { LOG.infof("Auto-activation au login de %s (org %s a souscription active)", m.getEmail(), orgId); membreService.activerMembre(m.getId()); try { membreKeycloakSyncService.activerMembreDansKeycloak(m.getId()); } catch (Exception e) { LOG.warnf("Activation Keycloak au login échouée pour %s (non bloquant): %s", m.getEmail(), e.getMessage()); } statutCompte = "ACTIF"; } } Map response = new HashMap<>(); response.put("statutCompte", statutCompte); // changerMotDePasseRequis = false : Keycloak gère nativement le changement de mot de passe // via la required action UPDATE_PASSWORD dans le Chrome Custom Tab (AppAuth). response.put("changerMotDePasseRequis", false); // Indique à Flutter que le token doit être rafraîchi (nouveaux rôles MEMBRE/MEMBRE_ACTIF) response.put("premierLoginComplet", premierLoginComplet); // Indique à Flutter qu'une réauthentification est nécessaire (UPDATE_PASSWORD vient d'être assigné) response.put("reAuthRequired", reAuthRequired); // Enrichir avec l'état d'onboarding pour les comptes en attente if ("EN_ATTENTE_VALIDATION".equals(statutCompte)) { membreOpt.flatMap(m -> membreOrganisationRepo.findFirstByMembreId(m.getId()) .map(MembreOrganisation::getOrganisation)) .ifPresent(org -> { response.put("organisationId", org.getId().toString()); Optional souscOpt = souscriptionRepo.findLatestByOrganisationId(org.getId()); if (souscOpt.isEmpty()) { response.put("onboardingState", "NO_SUBSCRIPTION"); } else { SouscriptionOrganisation sosc = souscOpt.get(); String valState = sosc.getStatutValidation() != null ? sosc.getStatutValidation().name() : "EN_ATTENTE_PAIEMENT"; String onboardingState = switch (valState) { case "EN_ATTENTE_PAIEMENT" -> "AWAITING_PAYMENT"; case "PAIEMENT_INITIE" -> "PAYMENT_INITIATED"; case "PAIEMENT_CONFIRME" -> "AWAITING_VALIDATION"; case "VALIDEE" -> "VALIDATED"; case "REJETEE" -> "REJECTED"; default -> "AWAITING_PAYMENT"; }; response.put("onboardingState", onboardingState); response.put("souscriptionId", sosc.getId().toString()); if (sosc.getWaveSessionId() != null) { response.put("waveSessionId", sosc.getWaveSessionId()); } } response.put("typeOrganisation", org.getTypeOrganisation() != null ? org.getTypeOrganisation() : "ASSOCIATION"); }); if (!response.containsKey("onboardingState")) { response.put("onboardingState", "NO_SUBSCRIPTION"); } } return Response.ok(response).build(); } /** * Permet au membre connecté de changer son mot de passe lors du premier login. * Appelle Keycloak via lions-user-manager et marque {@code premiereConnexion = false}. * *

Body attendu : {@code { "nouveauMotDePasse": "..." }} */ @PUT @Path("/mon-compte/mot-de-passe") @Authenticated @Operation( summary = "Changer le mot de passe au premier login", description = "Met à jour le mot de passe Keycloak et lève le flag premiereConnexion." ) public Response changerMotDePasse(Map body) { String email = securiteHelper.resolveEmail(); if (email == null || email.isBlank()) { return Response.status(Response.Status.UNAUTHORIZED).build(); } String nouveauMotDePasse = body == null ? null : body.get("nouveauMotDePasse"); if (nouveauMotDePasse == null || nouveauMotDePasse.isBlank()) { return Response.status(Response.Status.BAD_REQUEST) .entity(Map.of("message", "Le champ 'nouveauMotDePasse' est requis.")) .build(); } Optional membreOpt = membreRepository.findByEmail(email.trim()) .or(() -> membreRepository.findByEmail(email.trim().toLowerCase())); if (membreOpt.isEmpty()) { return Response.status(Response.Status.NOT_FOUND) .entity(Map.of("message", "Aucun membre trouvé pour ce compte.")) .build(); } membreKeycloakSyncService.changerMotDePassePremierLogin(membreOpt.get().getId(), nouveauMotDePasse); return Response.ok(Map.of("message", "Mot de passe mis à jour avec succès.")).build(); } }