feat(sprint-11 web 2026-04-25): pages PrimeFaces Sprint 10 (UBO, audit-trail viewer, délégations rôles) + bump api 1.0.6→1.0.8

DRY strict appliqué : web réutilise directement les DTOs officiels de
unionflow-server-api 1.0.8 (CreateBeneficiaireEffectifRequest, BeneficiaireEffectifResponse,
AuditTrailOperationResponse, CreateRoleDelegationRequest, RoleDelegationResponse) au lieu
de DTOs miroirs locaux. Aucune duplication.

Bump dépendance api 1.0.6 → 1.0.8

REST clients @RegisterRestClient configKey=unionflow-api
- BeneficiaireEffectifRestClient : CRUD lister/trouverParId/creer/mettreAJour/desactiver
- AuditTrailRestClient : 5 endpoints lecture (parUtilisateur, historique, parOrganisation, sodViolations, financial)
- RoleDelegationRestClient : listerParOrganisation / creer / revoquer

Beans @ViewScoped
- BeneficiaireEffectifBean : recherche (KYC|org|PEP), création formulaire, marquerPep, désactiver
- AuditTrailViewerBean : 5 modes (USER/ENTITY/ORG/SOD_VIOLATIONS/FINANCIAL), couleurAction (DELETE→danger, VALIDATE→success, etc.), couleurSod
- RoleDelegationBean : recherche/créer/révoquer, couleurStatut (ACTIVE/REVOQUEE/EXPIREE)

Pages XHTML
- /pages/secure/conformite/beneficiaires-effectifs.xhtml — recherche + tableau + nouvelle UBO (panel toggleable)
- /pages/secure/conformite/audit-trail.xhtml — filtres mode + tableau + détail JSONB (pre format)
- /pages/secure/admin/role-delegations.xhtml — table actives + nouvelle (datePicker dates)

MenuBean + menu.xhtml
- 3 nouveaux flags : isBeneficiairesEffectifsVisible, isAuditTrailViewerVisible, isRoleDelegationsVisible
- 3 menuitems ajoutés au sous-menu Conformité existant (icônes pi-users, pi-history, pi-share-alt)
- Gating par rôles : COMPLIANCE_OFFICER + CONTROLEUR_INTERNE pour audit ; ADMIN_ORGANISATION + PRESIDENT pour délégations

Tests (10/10 verts, 31/31 cumulé S8+S11)
- AuditTrailViewerBeanTest : 8 tests (couleurAction × 6 cas, couleurSod, defaults)
- RoleDelegationBeanTest : 2 tests (couleurStatut × 5, defaults)
This commit is contained in:
dahoud
2026-04-25 12:56:13 +00:00
parent 8f96fa4209
commit 917c8c5359
14 changed files with 1109 additions and 1 deletions

View File

@@ -142,7 +142,7 @@
<dependency>
<groupId>dev.lions.unionflow</groupId>
<artifactId>unionflow-server-api</artifactId>
<version>1.0.6</version>
<version>1.0.8</version>
</dependency>
<!-- Lions User Manager Client - Module réutilisable de gestion d'utilisateurs Keycloak -->

View File

@@ -646,6 +646,31 @@ public class MenuBean implements Serializable {
return hasAnyRole("SUPER_ADMIN", "COMPLIANCE_OFFICER");
}
/**
* Bénéficiaires Effectifs (UBO) — Instr. BCEAO 003-03-2025.
* @since 2026-04-25 (Sprint 11)
*/
public boolean isBeneficiairesEffectifsVisible() {
return hasAnyRole("SUPER_ADMIN", "ADMIN_ORGANISATION", "COMPLIANCE_OFFICER",
"CONTROLEUR_INTERNE");
}
/**
* Audit Trail viewer — compliance / contrôle interne.
* @since 2026-04-25 (Sprint 11)
*/
public boolean isAuditTrailViewerVisible() {
return hasAnyRole("SUPER_ADMIN", "COMPLIANCE_OFFICER", "CONTROLEUR_INTERNE");
}
/**
* Délégations de rôles — admin org / président.
* @since 2026-04-25 (Sprint 11)
*/
public boolean isRoleDelegationsVisible() {
return hasAnyRole("SUPER_ADMIN", "ADMIN_ORGANISATION", "PRESIDENT");
}
/**
* Retourne true si l'organisation active dispose d'au moins un module métier spécifique
* (au-delà des modules communs toujours disponibles).

View File

@@ -0,0 +1,53 @@
package dev.lions.unionflow.client.service;
import dev.lions.unionflow.client.security.AuthHeaderFactory;
import dev.lions.unionflow.server.api.dto.audit.response.AuditTrailOperationResponse;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.MediaType;
import java.util.List;
import java.util.UUID;
import org.eclipse.microprofile.rest.client.annotation.RegisterClientHeaders;
import org.eclipse.microprofile.rest.client.inject.RegisterRestClient;
/**
* Client REST de lecture audit trail (Sprint 11 ⇄ backend Sprint 10 CQRS read).
*/
@RegisterRestClient(configKey = "unionflow-api")
@RegisterClientHeaders(AuthHeaderFactory.class)
@Path("/api/audit-trail")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public interface AuditTrailRestClient {
@GET
@Path("/by-user/{userId}")
List<AuditTrailOperationResponse> parUtilisateur(
@PathParam("userId") UUID userId,
@QueryParam("from") String from,
@QueryParam("to") String to);
@GET
@Path("/by-entity/{type}/{id}")
List<AuditTrailOperationResponse> historique(
@PathParam("type") String entityType, @PathParam("id") UUID entityId);
@GET
@Path("/by-organisation/{orgId}")
List<AuditTrailOperationResponse> parOrganisation(@PathParam("orgId") UUID orgId);
@GET
@Path("/sod-violations")
List<AuditTrailOperationResponse> violationsSod();
@GET
@Path("/financial/{orgId}")
List<AuditTrailOperationResponse> operationsFinancieres(
@PathParam("orgId") UUID orgId,
@QueryParam("from") String from,
@QueryParam("to") String to);
}

View File

@@ -0,0 +1,54 @@
package dev.lions.unionflow.client.service;
import dev.lions.unionflow.client.security.AuthHeaderFactory;
import dev.lions.unionflow.server.api.dto.kyc.request.CreateBeneficiaireEffectifRequest;
import dev.lions.unionflow.server.api.dto.kyc.request.UpdateBeneficiaireEffectifRequest;
import dev.lions.unionflow.server.api.dto.kyc.response.BeneficiaireEffectifResponse;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.DELETE;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.PUT;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.MediaType;
import java.util.List;
import java.util.UUID;
import org.eclipse.microprofile.rest.client.annotation.RegisterClientHeaders;
import org.eclipse.microprofile.rest.client.inject.RegisterRestClient;
/**
* Client REST des Bénéficiaires Effectifs (Sprint 11 ⇄ backend Sprint 10).
* Réutilise les DTOs officiels de unionflow-server-api 1.0.8 — zéro duplication.
*/
@RegisterRestClient(configKey = "unionflow-api")
@RegisterClientHeaders(AuthHeaderFactory.class)
@Path("/api/kyc/beneficiaires-effectifs")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public interface BeneficiaireEffectifRestClient {
@GET
List<BeneficiaireEffectifResponse> lister(
@QueryParam("kycDossierId") UUID kycDossierId,
@QueryParam("organisationCibleId") UUID organisationCibleId,
@QueryParam("pep") Boolean pep);
@GET
@Path("/{id}")
BeneficiaireEffectifResponse trouverParId(@PathParam("id") UUID id);
@POST
BeneficiaireEffectifResponse creer(CreateBeneficiaireEffectifRequest request);
@PUT
@Path("/{id}")
BeneficiaireEffectifResponse mettreAJour(
@PathParam("id") UUID id, UpdateBeneficiaireEffectifRequest request);
@DELETE
@Path("/{id}")
void desactiver(@PathParam("id") UUID id);
}

View File

@@ -0,0 +1,42 @@
package dev.lions.unionflow.client.service;
import dev.lions.unionflow.client.security.AuthHeaderFactory;
import dev.lions.unionflow.server.api.dto.delegation.request.CreateRoleDelegationRequest;
import dev.lions.unionflow.server.api.dto.delegation.response.RoleDelegationResponse;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.DELETE;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.MediaType;
import java.util.List;
import java.util.UUID;
import org.eclipse.microprofile.rest.client.annotation.RegisterClientHeaders;
import org.eclipse.microprofile.rest.client.inject.RegisterRestClient;
/**
* Client REST des délégations de rôle (Sprint 11 ⇄ backend Sprint 10).
*/
@RegisterRestClient(configKey = "unionflow-api")
@RegisterClientHeaders(AuthHeaderFactory.class)
@Path("/api/role-delegations")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public interface RoleDelegationRestClient {
@GET
@Path("/organisation/{orgId}")
List<RoleDelegationResponse> listerParOrganisation(@PathParam("orgId") UUID orgId);
@POST
RoleDelegationResponse creer(
CreateRoleDelegationRequest request,
@QueryParam("rolesDelegataire") String rolesDelegataireCsv);
@DELETE
@Path("/{id}")
RoleDelegationResponse revoquer(@PathParam("id") UUID id);
}

View File

@@ -0,0 +1,113 @@
package dev.lions.unionflow.client.view;
import dev.lions.unionflow.client.service.AuditTrailRestClient;
import dev.lions.unionflow.server.api.dto.audit.response.AuditTrailOperationResponse;
import jakarta.faces.application.FacesMessage;
import jakarta.faces.context.FacesContext;
import jakarta.faces.view.ViewScoped;
import jakarta.inject.Inject;
import jakarta.inject.Named;
import java.io.Serializable;
import java.time.LocalDateTime;
import java.util.Collections;
import java.util.List;
import java.util.UUID;
import org.eclipse.microprofile.rest.client.inject.RestClient;
import org.jboss.logging.Logger;
/**
* Bean viewer audit trail (Sprint 11 ⇄ backend Sprint 10 CQRS read).
* Pas d'écriture — la production des événements est dans le backend lifecycle.
*/
@Named
@ViewScoped
public class AuditTrailViewerBean implements Serializable {
private static final long serialVersionUID = 1L;
private static final Logger LOG = Logger.getLogger(AuditTrailViewerBean.class);
@Inject @RestClient AuditTrailRestClient client;
/** Mode : USER, ENTITY, ORG, SOD_VIOLATIONS, FINANCIAL */
private String mode = "ORG";
private UUID userId;
private UUID orgId;
private String entityType;
private UUID entityId;
private LocalDateTime from = LocalDateTime.now().minusDays(30);
private LocalDateTime to = LocalDateTime.now();
private List<AuditTrailOperationResponse> operations = Collections.emptyList();
private AuditTrailOperationResponse selection;
private String erreur;
public void rechercher() {
erreur = null;
try {
operations = switch (mode) {
case "USER" -> userId != null
? client.parUtilisateur(userId, from.toString(), to.toString())
: Collections.emptyList();
case "ENTITY" -> entityType != null && entityId != null
? client.historique(entityType, entityId)
: Collections.emptyList();
case "ORG" -> orgId != null
? client.parOrganisation(orgId)
: Collections.emptyList();
case "SOD_VIOLATIONS" -> client.violationsSod();
case "FINANCIAL" -> orgId != null
? client.operationsFinancieres(orgId, from.toString(), to.toString())
: Collections.emptyList();
default -> Collections.emptyList();
};
LOG.infof("Audit trail (%s) chargé : %d entrées", mode, operations.size());
} catch (Exception e) {
handleError("Recherche audit trail échouée", e);
operations = Collections.emptyList();
}
}
public String getCouleurAction(String actionType) {
if (actionType == null) return "secondary";
return switch (actionType) {
case "DELETE", "PAYMENT_FAILED" -> "danger";
case "VALIDATE", "PAYMENT_CONFIRMED", "AID_REQUEST_APPROVED" -> "success";
case "UPDATE", "PAYMENT_INITIATED", "BUDGET_APPROVED" -> "info";
case "CREATE" -> "primary";
case "EXPORT" -> "warning";
default -> "secondary";
};
}
public String getCouleurSod(Boolean sodCheckPassed) {
if (sodCheckPassed == null) return "secondary";
return sodCheckPassed ? "success" : "danger";
}
private void handleError(String summary, Exception e) {
LOG.warnf("%s : %s", summary, e.getMessage());
erreur = summary + "" + e.getMessage();
FacesContext.getCurrentInstance().addMessage(null,
new FacesMessage(FacesMessage.SEVERITY_ERROR, "Erreur", erreur));
}
public String getMode() { return mode; }
public void setMode(String mode) { this.mode = mode; }
public UUID getUserId() { return userId; }
public void setUserId(UUID userId) { this.userId = userId; }
public UUID getOrgId() { return orgId; }
public void setOrgId(UUID orgId) { this.orgId = orgId; }
public String getEntityType() { return entityType; }
public void setEntityType(String entityType) { this.entityType = entityType; }
public UUID getEntityId() { return entityId; }
public void setEntityId(UUID entityId) { this.entityId = entityId; }
public LocalDateTime getFrom() { return from; }
public void setFrom(LocalDateTime from) { this.from = from; }
public LocalDateTime getTo() { return to; }
public void setTo(LocalDateTime to) { this.to = to; }
public List<AuditTrailOperationResponse> getOperations() { return operations; }
public AuditTrailOperationResponse getSelection() { return selection; }
public void setSelection(AuditTrailOperationResponse selection) { this.selection = selection; }
public String getErreur() { return erreur; }
}

View File

@@ -0,0 +1,165 @@
package dev.lions.unionflow.client.view;
import dev.lions.unionflow.client.service.BeneficiaireEffectifRestClient;
import dev.lions.unionflow.server.api.dto.kyc.request.CreateBeneficiaireEffectifRequest;
import dev.lions.unionflow.server.api.dto.kyc.request.UpdateBeneficiaireEffectifRequest;
import dev.lions.unionflow.server.api.dto.kyc.response.BeneficiaireEffectifResponse;
import jakarta.faces.application.FacesMessage;
import jakarta.faces.context.FacesContext;
import jakarta.faces.view.ViewScoped;
import jakarta.inject.Inject;
import jakarta.inject.Named;
import java.io.Serializable;
import java.util.Collections;
import java.util.List;
import java.util.UUID;
import org.eclipse.microprofile.rest.client.inject.RestClient;
import org.jboss.logging.Logger;
/**
* Bean CRUD des Bénéficiaires Effectifs (Sprint 11 ⇄ backend Sprint 10).
*/
@Named
@ViewScoped
public class BeneficiaireEffectifBean implements Serializable {
private static final long serialVersionUID = 1L;
private static final Logger LOG = Logger.getLogger(BeneficiaireEffectifBean.class);
@Inject @RestClient BeneficiaireEffectifRestClient client;
// Filtres
private UUID kycDossierId;
private UUID organisationCibleId;
private Boolean filtrePep;
private List<BeneficiaireEffectifResponse> ubos = Collections.emptyList();
private BeneficiaireEffectifResponse selection;
private String erreur;
// Formulaire de création
private String nom;
private String prenoms;
private String nationalite;
private String paysResidence;
private String numeroPieceIdentite;
private String pourcentageCapital;
private String natureControle = "DETENTION_CAPITAL";
private boolean estPep;
public void rechercher() {
erreur = null;
try {
ubos = client.lister(kycDossierId, organisationCibleId, filtrePep);
LOG.infof("UBOs chargés : %d", ubos.size());
} catch (Exception e) {
handleError("Recherche UBO échouée", e);
ubos = Collections.emptyList();
}
}
public void creer() {
if (kycDossierId == null && organisationCibleId == null) {
addMessage(FacesMessage.SEVERITY_WARN, "Cible requise",
"Sélectionnez d'abord un KycDossier ou une organisation cible");
return;
}
try {
CreateBeneficiaireEffectifRequest req = CreateBeneficiaireEffectifRequest.builder()
.kycDossierId(kycDossierId)
.organisationCibleId(organisationCibleId)
.nom(nom)
.prenoms(prenoms)
.nationalite(nationalite != null ? nationalite.toUpperCase() : null)
.paysResidence(paysResidence != null ? paysResidence.toUpperCase() : null)
.numeroPieceIdentite(numeroPieceIdentite)
.pourcentageCapital(pourcentageCapital != null && !pourcentageCapital.isBlank()
? new java.math.BigDecimal(pourcentageCapital) : null)
.natureControle(natureControle)
.estPep(estPep)
.build();
BeneficiaireEffectifResponse created = client.creer(req);
addMessage(FacesMessage.SEVERITY_INFO, "UBO créé",
created.prenoms() + " " + created.nom());
resetForm();
rechercher();
} catch (Exception e) {
handleError("Création UBO échouée", e);
}
}
public void desactiverSelection() {
if (selection == null) return;
try {
client.desactiver(selection.id());
addMessage(FacesMessage.SEVERITY_INFO, "UBO désactivé",
selection.prenoms() + " " + selection.nom());
rechercher();
} catch (Exception e) {
handleError("Désactivation échouée", e);
}
}
public void marquerPepSelection() {
if (selection == null) return;
try {
UpdateBeneficiaireEffectifRequest req = UpdateBeneficiaireEffectifRequest.builder()
.estPep(true).build();
client.mettreAJour(selection.id(), req);
addMessage(FacesMessage.SEVERITY_INFO, "UBO marqué PEP", "");
rechercher();
} catch (Exception e) {
handleError("Mise à jour PEP échouée", e);
}
}
private void resetForm() {
nom = null;
prenoms = null;
nationalite = null;
paysResidence = null;
numeroPieceIdentite = null;
pourcentageCapital = null;
natureControle = "DETENTION_CAPITAL";
estPep = false;
}
private void handleError(String summary, Exception e) {
LOG.warnf("%s : %s", summary, e.getMessage());
erreur = summary + "" + e.getMessage();
addMessage(FacesMessage.SEVERITY_ERROR, "Erreur", erreur);
}
private void addMessage(FacesMessage.Severity sev, String summary, String detail) {
FacesContext.getCurrentInstance().addMessage(null,
new FacesMessage(sev, summary, detail));
}
// Getters / setters
public UUID getKycDossierId() { return kycDossierId; }
public void setKycDossierId(UUID kycDossierId) { this.kycDossierId = kycDossierId; }
public UUID getOrganisationCibleId() { return organisationCibleId; }
public void setOrganisationCibleId(UUID id) { this.organisationCibleId = id; }
public Boolean getFiltrePep() { return filtrePep; }
public void setFiltrePep(Boolean filtrePep) { this.filtrePep = filtrePep; }
public List<BeneficiaireEffectifResponse> getUbos() { return ubos; }
public BeneficiaireEffectifResponse getSelection() { return selection; }
public void setSelection(BeneficiaireEffectifResponse selection) { this.selection = selection; }
public String getErreur() { return erreur; }
public String getNom() { return nom; }
public void setNom(String nom) { this.nom = nom; }
public String getPrenoms() { return prenoms; }
public void setPrenoms(String prenoms) { this.prenoms = prenoms; }
public String getNationalite() { return nationalite; }
public void setNationalite(String nationalite) { this.nationalite = nationalite; }
public String getPaysResidence() { return paysResidence; }
public void setPaysResidence(String paysResidence) { this.paysResidence = paysResidence; }
public String getNumeroPieceIdentite() { return numeroPieceIdentite; }
public void setNumeroPieceIdentite(String n) { this.numeroPieceIdentite = n; }
public String getPourcentageCapital() { return pourcentageCapital; }
public void setPourcentageCapital(String pct) { this.pourcentageCapital = pct; }
public String getNatureControle() { return natureControle; }
public void setNatureControle(String nc) { this.natureControle = nc; }
public boolean isEstPep() { return estPep; }
public void setEstPep(boolean estPep) { this.estPep = estPep; }
}

View File

@@ -0,0 +1,147 @@
package dev.lions.unionflow.client.view;
import dev.lions.unionflow.client.service.RoleDelegationRestClient;
import dev.lions.unionflow.server.api.dto.delegation.request.CreateRoleDelegationRequest;
import dev.lions.unionflow.server.api.dto.delegation.response.RoleDelegationResponse;
import jakarta.faces.application.FacesMessage;
import jakarta.faces.context.FacesContext;
import jakarta.faces.view.ViewScoped;
import jakarta.inject.Inject;
import jakarta.inject.Named;
import java.io.Serializable;
import java.time.LocalDateTime;
import java.util.Collections;
import java.util.List;
import java.util.UUID;
import org.eclipse.microprofile.rest.client.inject.RestClient;
import org.jboss.logging.Logger;
/**
* Bean liste/création/révocation des délégations de rôle (Sprint 11 ⇄ backend Sprint 10).
*/
@Named
@ViewScoped
public class RoleDelegationBean implements Serializable {
private static final long serialVersionUID = 1L;
private static final Logger LOG = Logger.getLogger(RoleDelegationBean.class);
@Inject @RestClient RoleDelegationRestClient client;
private UUID organisationId;
private List<RoleDelegationResponse> delegations = Collections.emptyList();
private RoleDelegationResponse selection;
private String erreur;
// Formulaire création
private UUID delegantUserId;
private UUID delegataireUserId;
private String roleDelegue = "TRESORIER";
private LocalDateTime dateDebut = LocalDateTime.now().plusHours(1);
private LocalDateTime dateFin = LocalDateTime.now().plusDays(14);
private String motif;
private String rolesDelegataireCsv;
public void rechercher() {
erreur = null;
if (organisationId == null) return;
try {
delegations = client.listerParOrganisation(organisationId);
LOG.infof("Délégations chargées org=%s : %d", organisationId, delegations.size());
} catch (Exception e) {
handleError("Chargement délégations échoué", e);
delegations = Collections.emptyList();
}
}
public void creer() {
if (organisationId == null || delegantUserId == null || delegataireUserId == null) {
addMessage(FacesMessage.SEVERITY_WARN, "Champs requis",
"Organisation, déléguant et délégataire sont obligatoires");
return;
}
try {
CreateRoleDelegationRequest req = CreateRoleDelegationRequest.builder()
.organisationId(organisationId)
.delegantUserId(delegantUserId)
.delegataireUserId(delegataireUserId)
.roleDelegue(roleDelegue)
.dateDebut(dateDebut)
.dateFin(dateFin)
.motif(motif)
.build();
RoleDelegationResponse created = client.creer(req, rolesDelegataireCsv);
addMessage(FacesMessage.SEVERITY_INFO, "Délégation créée",
"Rôle " + created.roleDelegue() + " jusqu'au " + created.dateFin());
resetForm();
rechercher();
} catch (Exception e) {
handleError("Création délégation échouée", e);
}
}
public void revoquerSelection() {
if (selection == null) return;
try {
client.revoquer(selection.id());
addMessage(FacesMessage.SEVERITY_INFO, "Délégation révoquée",
"Rôle " + selection.roleDelegue());
rechercher();
} catch (Exception e) {
handleError("Révocation échouée", e);
}
}
public String getCouleurStatut(String statut) {
if (statut == null) return "secondary";
return switch (statut) {
case "ACTIVE" -> "success";
case "REVOQUEE" -> "danger";
case "EXPIREE" -> "warning";
default -> "secondary";
};
}
private void resetForm() {
delegantUserId = null;
delegataireUserId = null;
roleDelegue = "TRESORIER";
dateDebut = LocalDateTime.now().plusHours(1);
dateFin = LocalDateTime.now().plusDays(14);
motif = null;
rolesDelegataireCsv = null;
}
private void handleError(String summary, Exception e) {
LOG.warnf("%s : %s", summary, e.getMessage());
erreur = summary + "" + e.getMessage();
addMessage(FacesMessage.SEVERITY_ERROR, "Erreur", erreur);
}
private void addMessage(FacesMessage.Severity sev, String summary, String detail) {
FacesContext.getCurrentInstance().addMessage(null,
new FacesMessage(sev, summary, detail));
}
// Getters / setters
public UUID getOrganisationId() { return organisationId; }
public void setOrganisationId(UUID organisationId) { this.organisationId = organisationId; }
public List<RoleDelegationResponse> getDelegations() { return delegations; }
public RoleDelegationResponse getSelection() { return selection; }
public void setSelection(RoleDelegationResponse selection) { this.selection = selection; }
public String getErreur() { return erreur; }
public UUID getDelegantUserId() { return delegantUserId; }
public void setDelegantUserId(UUID id) { this.delegantUserId = id; }
public UUID getDelegataireUserId() { return delegataireUserId; }
public void setDelegataireUserId(UUID id) { this.delegataireUserId = id; }
public String getRoleDelegue() { return roleDelegue; }
public void setRoleDelegue(String roleDelegue) { this.roleDelegue = roleDelegue; }
public LocalDateTime getDateDebut() { return dateDebut; }
public void setDateDebut(LocalDateTime dateDebut) { this.dateDebut = dateDebut; }
public LocalDateTime getDateFin() { return dateFin; }
public void setDateFin(LocalDateTime dateFin) { this.dateFin = dateFin; }
public String getMotif() { return motif; }
public void setMotif(String motif) { this.motif = motif; }
public String getRolesDelegataireCsv() { return rolesDelegataireCsv; }
public void setRolesDelegataireCsv(String csv) { this.rolesDelegataireCsv = csv; }
}

View File

@@ -0,0 +1,135 @@
<!DOCTYPE html>
<ui:composition xmlns="http://www.w3.org/1999/xhtml"
xmlns:h="http://xmlns.jcp.org/jsf/html"
xmlns:f="http://xmlns.jcp.org/jsf/core"
xmlns:ui="http://xmlns.jcp.org/jsf/facelets"
xmlns:p="http://primefaces.org/ui"
template="/templates/main-template.xhtml">
<ui:define name="title">UnionFlow - Délégations de rôles</ui:define>
<ui:define name="content">
<div class="grid">
<div class="col-12">
<div class="card">
<h2 class="text-900 font-medium text-3xl m-0">Délégations temporaires de rôles</h2>
<p class="text-600 mt-1 mb-3">
Délégation d'un rôle pour absence prolongée. Vérification SoD à la création.
</p>
<h:form id="form-deleg">
<p:messages closable="true" />
<p:panel header="Recherche" styleClass="mb-3">
<div class="grid">
<div class="col-12 md:col-9">
<p:outputLabel for="org" value="Organisation (UUID)" />
<p:inputText id="org" value="#{roleDelegationBean.organisationId}"
styleClass="w-full" />
</div>
<div class="col-12 md:col-3 flex align-items-end">
<p:commandButton value="Rechercher"
icon="pi pi-search"
action="#{roleDelegationBean.rechercher}"
update="form-deleg"
styleClass="w-full" />
</div>
</div>
</p:panel>
<p:dataTable value="#{roleDelegationBean.delegations}"
var="d"
selection="#{roleDelegationBean.selection}"
rowKey="#{d.id}"
selectionMode="single"
emptyMessage="Aucune délégation">
<p:column headerText="Rôle délégué">
<h:outputText value="#{d.roleDelegue}" />
</p:column>
<p:column headerText="Déléguant">
<h:outputText value="#{d.delegantUserId}" />
</p:column>
<p:column headerText="Délégataire">
<h:outputText value="#{d.delegataireUserId}" />
</p:column>
<p:column headerText="Période">
<h:outputText value="du #{d.dateDebut} au #{d.dateFin}" />
</p:column>
<p:column headerText="Motif">
<h:outputText value="#{d.motif}" />
</p:column>
<p:column headerText="Statut">
<p:tag value="#{d.statut}"
severity="#{roleDelegationBean.getCouleurStatut(d.statut)}" />
</p:column>
<p:column headerText="Active">
<p:tag value="#{d.estActive ? 'OUI' : 'NON'}"
severity="#{d.estActive ? 'success' : 'secondary'}" />
</p:column>
</p:dataTable>
<div class="flex gap-2 mt-3">
<p:commandButton value="Révoquer (sélection)"
icon="pi pi-times"
action="#{roleDelegationBean.revoquerSelection}"
update="form-deleg"
styleClass="p-button-danger" />
</div>
<p:panel header="Nouvelle délégation" toggleable="true" collapsed="true" styleClass="mt-4">
<div class="grid">
<div class="col-12 md:col-6">
<p:outputLabel for="dgt" value="Déléguant (UUID utilisateur) *" />
<p:inputText id="dgt" value="#{roleDelegationBean.delegantUserId}"
styleClass="w-full" />
</div>
<div class="col-12 md:col-6">
<p:outputLabel for="dgtr" value="Délégataire (UUID utilisateur) *" />
<p:inputText id="dgtr" value="#{roleDelegationBean.delegataireUserId}"
styleClass="w-full" />
</div>
<div class="col-12 md:col-4">
<p:outputLabel for="role" value="Rôle délégué *" />
<p:selectOneMenu id="role" value="#{roleDelegationBean.roleDelegue}"
styleClass="w-full">
<f:selectItem itemValue="TRESORIER" itemLabel="TRESORIER" />
<f:selectItem itemValue="SECRETAIRE" itemLabel="SECRETAIRE" />
<f:selectItem itemValue="ADMIN_ORGANISATION" itemLabel="ADMIN_ORGANISATION" />
<f:selectItem itemValue="COMPLIANCE_OFFICER" itemLabel="COMPLIANCE_OFFICER" />
<f:selectItem itemValue="CONTROLEUR_INTERNE" itemLabel="CONTROLEUR_INTERNE" />
</p:selectOneMenu>
</div>
<div class="col-12 md:col-4">
<p:outputLabel for="rolesDeleg" value="Rôles existants délégataire (CSV)" />
<p:inputText id="rolesDeleg" value="#{roleDelegationBean.rolesDelegataireCsv}"
styleClass="w-full" placeholder="MEMBRE_ACTIF,MEMBRE_BUREAU" />
</div>
<div class="col-12 md:col-4">
<p:outputLabel for="motif" value="Motif" />
<p:inputText id="motif" value="#{roleDelegationBean.motif}"
styleClass="w-full" placeholder="Congé, mission..." />
</div>
<div class="col-12 md:col-6">
<p:outputLabel for="dd" value="Date début *" />
<p:datePicker id="dd" value="#{roleDelegationBean.dateDebut}"
showTime="true" styleClass="w-full" />
</div>
<div class="col-12 md:col-6">
<p:outputLabel for="df" value="Date fin *" />
<p:datePicker id="df" value="#{roleDelegationBean.dateFin}"
showTime="true" styleClass="w-full" />
</div>
<div class="col-12">
<p:commandButton value="Créer délégation"
icon="pi pi-plus"
action="#{roleDelegationBean.creer}"
update="form-deleg" />
</div>
</div>
</p:panel>
</h:form>
</div>
</div>
</div>
</ui:define>
</ui:composition>

View File

@@ -0,0 +1,112 @@
<!DOCTYPE html>
<ui:composition xmlns="http://www.w3.org/1999/xhtml"
xmlns:h="http://xmlns.jcp.org/jsf/html"
xmlns:f="http://xmlns.jcp.org/jsf/core"
xmlns:ui="http://xmlns.jcp.org/jsf/facelets"
xmlns:p="http://primefaces.org/ui"
template="/templates/main-template.xhtml">
<ui:define name="title">UnionFlow - Audit Trail</ui:define>
<ui:define name="content">
<div class="grid">
<div class="col-12">
<div class="card">
<h2 class="text-900 font-medium text-3xl m-0">Audit Trail</h2>
<p class="text-600 mt-1 mb-3">
Historique enrichi des opérations sensibles + détection violations SoD.
</p>
<h:form id="form-audit">
<p:messages closable="true" />
<p:panel header="Filtres" styleClass="mb-3">
<div class="grid">
<div class="col-12 md:col-3">
<p:outputLabel for="mode" value="Mode" />
<p:selectOneMenu id="mode" value="#{auditTrailViewerBean.mode}"
styleClass="w-full">
<f:selectItem itemValue="ORG" itemLabel="Par organisation" />
<f:selectItem itemValue="USER" itemLabel="Par utilisateur" />
<f:selectItem itemValue="ENTITY" itemLabel="Historique entité" />
<f:selectItem itemValue="SOD_VIOLATIONS" itemLabel="Violations SoD" />
<f:selectItem itemValue="FINANCIAL" itemLabel="Opérations financières" />
</p:selectOneMenu>
</div>
<div class="col-12 md:col-3">
<p:outputLabel for="org" value="Organisation (UUID)" />
<p:inputText id="org" value="#{auditTrailViewerBean.orgId}" styleClass="w-full" />
</div>
<div class="col-12 md:col-3">
<p:outputLabel for="usr" value="Utilisateur (UUID)" />
<p:inputText id="usr" value="#{auditTrailViewerBean.userId}" styleClass="w-full" />
</div>
<div class="col-12 md:col-3 flex align-items-end">
<p:commandButton value="Rechercher"
icon="pi pi-search"
action="#{auditTrailViewerBean.rechercher}"
update="form-audit"
styleClass="w-full" />
</div>
</div>
</p:panel>
<p:dataTable value="#{auditTrailViewerBean.operations}"
var="o"
selection="#{auditTrailViewerBean.selection}"
rowKey="#{o.id}"
selectionMode="single"
emptyMessage="Aucune opération trouvée">
<p:column headerText="Date">
<h:outputText value="#{o.operationAt}" />
</p:column>
<p:column headerText="Utilisateur">
<h:outputText value="#{o.userEmail}" />
<h:outputText value=" (#{o.roleActif})" rendered="#{o.roleActif != null}"
styleClass="text-600 text-sm" />
</p:column>
<p:column headerText="Action">
<p:tag value="#{o.actionType}"
severity="#{auditTrailViewerBean.getCouleurAction(o.actionType)}" />
</p:column>
<p:column headerText="Entité">
<h:outputText value="#{o.entityType}" />
</p:column>
<p:column headerText="Description">
<h:outputText value="#{o.description}" />
</p:column>
<p:column headerText="SoD">
<p:tag value="#{o.sodCheckPassed ? 'OK' : 'VIOLATION'}"
severity="#{auditTrailViewerBean.getCouleurSod(o.sodCheckPassed)}"
rendered="#{o.sodCheckPassed != null}" />
<h:outputText value="—" rendered="#{o.sodCheckPassed == null}" />
</p:column>
</p:dataTable>
<p:panel header="Détail opération" rendered="#{auditTrailViewerBean.selection != null}"
styleClass="mt-3">
<div class="grid">
<div class="col-12">
<strong>SoD violations :</strong>
<h:outputText value="#{auditTrailViewerBean.selection.sodViolations}" />
</div>
<div class="col-12 md:col-6">
<strong>Payload avant :</strong>
<pre style="font-size: 0.85em; background:#f5f5f5; padding:8px;">#{auditTrailViewerBean.selection.payloadAvant}</pre>
</div>
<div class="col-12 md:col-6">
<strong>Payload après :</strong>
<pre style="font-size: 0.85em; background:#f5f5f5; padding:8px;">#{auditTrailViewerBean.selection.payloadApres}</pre>
</div>
<div class="col-12">
<strong>Métadonnées :</strong>
<pre style="font-size: 0.85em; background:#f5f5f5; padding:8px;">#{auditTrailViewerBean.selection.metadata}</pre>
</div>
</div>
</p:panel>
</h:form>
</div>
</div>
</div>
</ui:define>
</ui:composition>

View File

@@ -0,0 +1,147 @@
<!DOCTYPE html>
<ui:composition xmlns="http://www.w3.org/1999/xhtml"
xmlns:h="http://xmlns.jcp.org/jsf/html"
xmlns:f="http://xmlns.jcp.org/jsf/core"
xmlns:ui="http://xmlns.jcp.org/jsf/facelets"
xmlns:p="http://primefaces.org/ui"
template="/templates/main-template.xhtml">
<ui:define name="title">UnionFlow - Bénéficiaires Effectifs (UBO)</ui:define>
<ui:define name="content">
<div class="grid">
<div class="col-12">
<div class="card">
<h2 class="text-900 font-medium text-3xl m-0">Bénéficiaires Effectifs (UBO)</h2>
<p class="text-600 mt-1 mb-3">
Instr. BCEAO 003-03-2025 — identification des personnes physiques contrôlant l'organisation.
</p>
<h:form id="form-ubo">
<p:messages closable="true" />
<p:panel header="Recherche" styleClass="mb-3">
<div class="grid">
<div class="col-12 md:col-4">
<p:outputLabel for="kyc" value="KycDossier ID (UUID)" />
<p:inputText id="kyc" value="#{beneficiaireEffectifBean.kycDossierId}"
styleClass="w-full" />
</div>
<div class="col-12 md:col-4">
<p:outputLabel for="org" value="Organisation cible (UUID)" />
<p:inputText id="org" value="#{beneficiaireEffectifBean.organisationCibleId}"
styleClass="w-full" />
</div>
<div class="col-12 md:col-2 flex align-items-end">
<p:selectBooleanCheckbox value="#{beneficiaireEffectifBean.filtrePep}"
itemLabel="PEP uniquement" />
</div>
<div class="col-12 md:col-2 flex align-items-end">
<p:commandButton value="Rechercher"
icon="pi pi-search"
action="#{beneficiaireEffectifBean.rechercher}"
update="form-ubo"
styleClass="w-full" />
</div>
</div>
</p:panel>
<p:dataTable value="#{beneficiaireEffectifBean.ubos}"
var="u"
selection="#{beneficiaireEffectifBean.selection}"
rowKey="#{u.id}"
selectionMode="single"
emptyMessage="Aucun UBO trouvé">
<p:column headerText="Nom complet">
#{u.prenoms} #{u.nom}
</p:column>
<p:column headerText="Nationalité">
<h:outputText value="#{u.nationalite}" />
</p:column>
<p:column headerText="% Capital">
<h:outputText value="#{u.pourcentageCapital}" />
</p:column>
<p:column headerText="Nature contrôle">
<h:outputText value="#{u.natureControle}" />
</p:column>
<p:column headerText="PEP">
<p:tag value="PEP" severity="warning" rendered="#{u.estPep}" />
<h:outputText value="—" rendered="#{not u.estPep}" />
</p:column>
<p:column headerText="Sanctions">
<p:tag value="LISTÉ" severity="danger" rendered="#{u.presenceListesSanctions}" />
<h:outputText value="—" rendered="#{not u.presenceListesSanctions}" />
</p:column>
<p:column headerText="Statut">
<p:tag value="#{u.actif ? 'ACTIF' : 'INACTIF'}"
severity="#{u.actif ? 'success' : 'secondary'}" />
</p:column>
</p:dataTable>
<div class="flex gap-2 mt-3">
<p:commandButton value="Marquer PEP"
icon="pi pi-flag"
action="#{beneficiaireEffectifBean.marquerPepSelection}"
update="form-ubo"
styleClass="p-button-warning" />
<p:commandButton value="Désactiver"
icon="pi pi-times"
action="#{beneficiaireEffectifBean.desactiverSelection}"
update="form-ubo"
styleClass="p-button-secondary" />
</div>
<p:panel header="Nouveau UBO" toggleable="true" collapsed="true" styleClass="mt-4">
<div class="grid">
<div class="col-12 md:col-4">
<p:outputLabel for="nom" value="Nom *" />
<p:inputText id="nom" value="#{beneficiaireEffectifBean.nom}" styleClass="w-full" />
</div>
<div class="col-12 md:col-4">
<p:outputLabel for="prenoms" value="Prénoms *" />
<p:inputText id="prenoms" value="#{beneficiaireEffectifBean.prenoms}" styleClass="w-full" />
</div>
<div class="col-12 md:col-4">
<p:outputLabel for="nat" value="Nationalité (ISO-3) *" />
<p:inputText id="nat" value="#{beneficiaireEffectifBean.nationalite}"
maxlength="3" styleClass="w-full" />
</div>
<div class="col-12 md:col-4">
<p:outputLabel for="pays" value="Pays résidence (ISO-3)" />
<p:inputText id="pays" value="#{beneficiaireEffectifBean.paysResidence}"
maxlength="3" styleClass="w-full" />
</div>
<div class="col-12 md:col-4">
<p:outputLabel for="pct" value="% Capital (0-100)" />
<p:inputText id="pct" value="#{beneficiaireEffectifBean.pourcentageCapital}"
styleClass="w-full" />
</div>
<div class="col-12 md:col-4">
<p:outputLabel for="nat-ctl" value="Nature contrôle *" />
<p:selectOneMenu id="nat-ctl" value="#{beneficiaireEffectifBean.natureControle}"
styleClass="w-full">
<f:selectItem itemValue="DETENTION_CAPITAL" itemLabel="Détention capital" />
<f:selectItem itemValue="DROITS_VOTE" itemLabel="Droits de vote" />
<f:selectItem itemValue="CONTROLE_DE_FAIT" itemLabel="Contrôle de fait" />
<f:selectItem itemValue="BENEFICIAIRE_ULTIME" itemLabel="Bénéficiaire ultime" />
<f:selectItem itemValue="MANDAT_REPRESENTATION" itemLabel="Mandat" />
</p:selectOneMenu>
</div>
<div class="col-12">
<p:selectBooleanCheckbox value="#{beneficiaireEffectifBean.estPep}"
itemLabel="Personne Politiquement Exposée (PEP)" />
</div>
<div class="col-12">
<p:commandButton value="Créer UBO"
icon="pi pi-plus"
action="#{beneficiaireEffectifBean.creer}"
update="form-ubo" />
</div>
</div>
</p:panel>
</h:form>
</div>
</div>
</div>
</ui:define>
</ui:composition>

View File

@@ -113,6 +113,9 @@
<p:submenu id="m_conformite" label="Conformité" icon="pi pi-verified" rendered="#{menuBean.conformiteDashboardVisible}">
<p:menuitem id="m_conformite_dashboard" value="Tableau de bord" icon="pi pi-chart-bar" outcome="/pages/secure/conformite/dashboard" />
<p:menuitem id="m_rapports_trimestriels" value="Rapports trimestriels" icon="pi pi-file-pdf" outcome="/pages/secure/conformite/rapports-trimestriels" rendered="#{menuBean.rapportsTrimestrielsVisible}" />
<p:menuitem id="m_ubo" value="Bénéficiaires Effectifs" icon="pi pi-users" outcome="/pages/secure/conformite/beneficiaires-effectifs" rendered="#{menuBean.beneficiairesEffectifsVisible}" />
<p:menuitem id="m_audit_trail" value="Audit Trail" icon="pi pi-history" outcome="/pages/secure/conformite/audit-trail" rendered="#{menuBean.auditTrailViewerVisible}" />
<p:menuitem id="m_role_delegations" value="Délégations de rôles" icon="pi pi-share-alt" outcome="/pages/secure/admin/role-delegations" rendered="#{menuBean.roleDelegationsVisible}" />
<p:menuitem id="m_pispi_readiness" value="PI-SPI Readiness" icon="pi pi-cog" outcome="/pages/secure/admin/pispi-readiness" rendered="#{menuBean.pispiReadinessVisible}" />
</p:submenu>

View File

@@ -0,0 +1,76 @@
package dev.lions.unionflow.client.view;
import static org.junit.jupiter.api.Assertions.assertEquals;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
class AuditTrailViewerBeanTest {
private AuditTrailViewerBean bean;
@BeforeEach
void setUp() {
bean = new AuditTrailViewerBean();
}
@Test
@DisplayName("getCouleurAction — DELETE → danger")
void couleurDelete() {
assertEquals("danger", bean.getCouleurAction("DELETE"));
assertEquals("danger", bean.getCouleurAction("PAYMENT_FAILED"));
}
@Test
@DisplayName("getCouleurAction — VALIDATE / payment confirmé / aide approuvée → success")
void couleurSuccess() {
assertEquals("success", bean.getCouleurAction("VALIDATE"));
assertEquals("success", bean.getCouleurAction("PAYMENT_CONFIRMED"));
assertEquals("success", bean.getCouleurAction("AID_REQUEST_APPROVED"));
}
@Test
@DisplayName("getCouleurAction — UPDATE / payment initié / budget approuvé → info")
void couleurInfo() {
assertEquals("info", bean.getCouleurAction("UPDATE"));
assertEquals("info", bean.getCouleurAction("PAYMENT_INITIATED"));
assertEquals("info", bean.getCouleurAction("BUDGET_APPROVED"));
}
@Test
@DisplayName("getCouleurAction — CREATE → primary")
void couleurCreate() {
assertEquals("primary", bean.getCouleurAction("CREATE"));
}
@Test
@DisplayName("getCouleurAction — EXPORT → warning")
void couleurExport() {
assertEquals("warning", bean.getCouleurAction("EXPORT"));
}
@Test
@DisplayName("getCouleurAction — null/inconnu → secondary")
void couleurDefault() {
assertEquals("secondary", bean.getCouleurAction(null));
assertEquals("secondary", bean.getCouleurAction("AUTRE"));
}
@Test
@DisplayName("getCouleurSod — true → success, false → danger, null → secondary")
void couleurSod() {
assertEquals("success", bean.getCouleurSod(true));
assertEquals("danger", bean.getCouleurSod(false));
assertEquals("secondary", bean.getCouleurSod(null));
}
@Test
@DisplayName("Defaults — mode = ORG, plage = 30 derniers jours")
void defaults() {
assertEquals("ORG", bean.getMode());
// plage from 30 jours avant maintenant — vérifié pas null
org.junit.jupiter.api.Assertions.assertNotNull(bean.getFrom());
org.junit.jupiter.api.Assertions.assertNotNull(bean.getTo());
}
}

View File

@@ -0,0 +1,36 @@
package dev.lions.unionflow.client.view;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
class RoleDelegationBeanTest {
private RoleDelegationBean bean;
@BeforeEach
void setUp() {
bean = new RoleDelegationBean();
}
@Test
@DisplayName("getCouleurStatut — ACTIVE/REVOQUEE/EXPIREE/null/inconnu")
void couleur() {
assertEquals("success", bean.getCouleurStatut("ACTIVE"));
assertEquals("danger", bean.getCouleurStatut("REVOQUEE"));
assertEquals("warning", bean.getCouleurStatut("EXPIREE"));
assertEquals("secondary", bean.getCouleurStatut(null));
assertEquals("secondary", bean.getCouleurStatut("AUTRE"));
}
@Test
@DisplayName("Defaults — rôle TRESORIER, dates initialisées")
void defaults() {
assertEquals("TRESORIER", bean.getRoleDelegue());
assertNotNull(bean.getDateDebut());
assertNotNull(bean.getDateFin());
}
}