From d04a6258112378af33eff989e82cac9f10ac2990 Mon Sep 17 00:00:00 2001
From: dahoud <41957584+DahoudG@users.noreply.github.com>
Date: Sat, 25 Apr 2026 16:50:03 +0000
Subject: [PATCH] =?UTF-8?q?feat(sprint-17=20web=202026-04-25):=20page=20pu?=
=?UTF-8?q?blique=20/pages/public/kpi.xhtml=20=E2=80=94=20consultation=20K?=
=?UTF-8?q?PI=20sign=C3=A9e?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Page anonyme accessible aux autorités externes via URL signée (HMAC-SHA256 backend).
DRY strict — pas de DTO miroir, réutilise KpiPublicSnapshot officiel api 1.0.10.
PublicKpiRestClient (@RegisterRestClient sans AuthHeaderFactory — endpoint anonyme)
- consulter(token) → KpiPublicSnapshot
PublicKpiBean @ViewScoped
- charger() lit query param token via FacesContext / viewParam, appelle endpoint
- Gestion erreurs HTTP : 401 → "Token invalide ou expiré", 404 → "Organisation introuvable"
- Helpers couleurScore (>=80 success / >=60 warning / <60 danger), couleurStatut
Page /pages/public/kpi.xhtml
- Template public-template.xhtml (sans auth, sans menu)
- f:viewParam token + f:viewAction publicKpiBean.charger
- Card KPI : score coloré central + 8 indicateurs en grid (Compliance Officer, AG, AIRMS, CMU, KYC, formation LBC/FT, UBO, CAC)
- Badge transparency footer : "Toute consultation est tracée dans l'audit trail"
- Whitelisté via existing /pages/public/* dans application.properties
Bump api 1.0.9 → 1.0.10
Tests (8/8) PublicKpiBean
- couleurScore × 4 (success/warning/danger/null)
- couleurStatut × 6 cas
- isCharge avec/sans snapshot
---
pom.xml | 2 +-
.../client/service/PublicKpiRestClient.java | 25 +++
.../unionflow/client/view/PublicKpiBean.java | 97 +++++++++++
.../META-INF/resources/pages/public/kpi.xhtml | 152 ++++++++++++++++++
.../client/view/PublicKpiBeanTest.java | 79 +++++++++
5 files changed, 354 insertions(+), 1 deletion(-)
create mode 100644 src/main/java/dev/lions/unionflow/client/service/PublicKpiRestClient.java
create mode 100644 src/main/java/dev/lions/unionflow/client/view/PublicKpiBean.java
create mode 100644 src/main/resources/META-INF/resources/pages/public/kpi.xhtml
create mode 100644 src/test/java/dev/lions/unionflow/client/view/PublicKpiBeanTest.java
diff --git a/pom.xml b/pom.xml
index b58e0d0..8e94e19 100644
--- a/pom.xml
+++ b/pom.xml
@@ -142,7 +142,7 @@
dev.lions.unionflow
unionflow-server-api
- 1.0.9
+ 1.0.10
diff --git a/src/main/java/dev/lions/unionflow/client/service/PublicKpiRestClient.java b/src/main/java/dev/lions/unionflow/client/service/PublicKpiRestClient.java
new file mode 100644
index 0000000..8b2de4a
--- /dev/null
+++ b/src/main/java/dev/lions/unionflow/client/service/PublicKpiRestClient.java
@@ -0,0 +1,25 @@
+package dev.lions.unionflow.client.service;
+
+import dev.lions.unionflow.server.api.dto.compliance.response.KpiPublicSnapshot;
+import jakarta.ws.rs.Consumes;
+import jakarta.ws.rs.GET;
+import jakarta.ws.rs.Path;
+import jakarta.ws.rs.Produces;
+import jakarta.ws.rs.QueryParam;
+import jakarta.ws.rs.core.MediaType;
+import org.eclipse.microprofile.rest.client.inject.RegisterRestClient;
+
+/**
+ * Client REST public KPI (Sprint 17 — pas de header auth, token signé en query).
+ *
+ *
Note : pas de {@code @RegisterClientHeaders(AuthHeaderFactory.class)} car endpoint anonyme.
+ */
+@RegisterRestClient(configKey = "unionflow-api")
+@Path("/api/public/kpi")
+@Consumes(MediaType.APPLICATION_JSON)
+@Produces(MediaType.APPLICATION_JSON)
+public interface PublicKpiRestClient {
+
+ @GET
+ KpiPublicSnapshot consulter(@QueryParam("token") String token);
+}
diff --git a/src/main/java/dev/lions/unionflow/client/view/PublicKpiBean.java b/src/main/java/dev/lions/unionflow/client/view/PublicKpiBean.java
new file mode 100644
index 0000000..11bf2b2
--- /dev/null
+++ b/src/main/java/dev/lions/unionflow/client/view/PublicKpiBean.java
@@ -0,0 +1,97 @@
+package dev.lions.unionflow.client.view;
+
+import dev.lions.unionflow.client.service.PublicKpiRestClient;
+import dev.lions.unionflow.server.api.dto.compliance.response.KpiPublicSnapshot;
+import jakarta.faces.context.FacesContext;
+import jakarta.faces.view.ViewScoped;
+import jakarta.inject.Inject;
+import jakarta.inject.Named;
+import java.io.Serializable;
+import org.eclipse.microprofile.rest.client.inject.RestClient;
+import org.jboss.logging.Logger;
+
+/**
+ * Bean public consultation KPI (Sprint 17) — pas d'authentification requise.
+ *
+ *
Lit le query param {@code token} dès l'init via {@code @ViewScoped} + viewAction,
+ * appelle l'endpoint public, expose le snapshot à la page.
+ *
+ * @since 2026-04-25 (Sprint 17)
+ */
+@Named
+@ViewScoped
+public class PublicKpiBean implements Serializable {
+
+ private static final long serialVersionUID = 1L;
+ private static final Logger LOG = Logger.getLogger(PublicKpiBean.class);
+
+ @Inject @RestClient PublicKpiRestClient client;
+
+ private String token;
+ private KpiPublicSnapshot snapshot;
+ private String erreur;
+
+ /** Appelé via {@code }. */
+ public void charger() {
+ erreur = null;
+ snapshot = null;
+
+ if (token == null || token.isBlank()) {
+ // Token vient du query param via
+ token = FacesContext.getCurrentInstance()
+ .getExternalContext()
+ .getRequestParameterMap()
+ .get("token");
+ }
+
+ if (token == null || token.isBlank()) {
+ erreur = "Aucun token fourni dans l'URL.";
+ return;
+ }
+
+ try {
+ snapshot = client.consulter(token);
+ LOG.infof("PublicKpi chargé — score=%d", snapshot == null ? -1 : snapshot.scoreGlobal());
+ } catch (jakarta.ws.rs.WebApplicationException wae) {
+ int status = wae.getResponse().getStatus();
+ if (status == 401) {
+ erreur = "Token invalide ou expiré.";
+ } else if (status == 404) {
+ erreur = "Organisation introuvable.";
+ } else {
+ erreur = "Erreur HTTP " + status;
+ }
+ LOG.warnf("PublicKpi HTTP %d : %s", status, wae.getMessage());
+ } catch (Exception e) {
+ erreur = "Erreur de chargement : " + e.getMessage();
+ LOG.errorf(e, "PublicKpi exception");
+ }
+ }
+
+ /** Couleur sémantique du score (success / warning / danger). */
+ public String getCouleurScore() {
+ if (snapshot == null) return "secondary";
+ int s = snapshot.scoreGlobal();
+ if (s >= 80) return "success";
+ if (s >= 60) return "warning";
+ return "danger";
+ }
+
+ public String getCouleurStatut(String statut) {
+ if (statut == null) return "secondary";
+ return switch (statut) {
+ case "OK" -> "success";
+ case "RETARD" -> "danger";
+ case "EN_ATTENTE" -> "warning";
+ case "OBLIGATOIRE" -> "info";
+ default -> "secondary";
+ };
+ }
+
+ // Getters / setters
+ public String getToken() { return token; }
+ public void setToken(String token) { this.token = token; }
+ public KpiPublicSnapshot getSnapshot() { return snapshot; }
+ public String getErreur() { return erreur; }
+ public boolean isCharge() { return snapshot != null; }
+}
diff --git a/src/main/resources/META-INF/resources/pages/public/kpi.xhtml b/src/main/resources/META-INF/resources/pages/public/kpi.xhtml
new file mode 100644
index 0000000..a2f1a01
--- /dev/null
+++ b/src/main/resources/META-INF/resources/pages/public/kpi.xhtml
@@ -0,0 +1,152 @@
+
+
+
+ UnionFlow - KPI publics
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Indicateurs publics de conformité
+
+
+ Vue read-only destinée aux autorités externes (BCEAO, ARTCI, CENTIF).
+
+
+
+
+
+
+
+
Accès impossible
+
#{publicKpiBean.erreur}
+
+
+
+
+
+
+
+
+
Score conformité
+
+ #{publicKpiBean.snapshot.scoreGlobal}
+
+
/ 100
+
+
+
+
+
Organisation
+
#{publicKpiBean.snapshot.organisationNom}
+
+ Référentiel comptable :
+ #{publicKpiBean.snapshot.referentielComptable}
+
+
+ Snapshot généré le : #{publicKpiBean.snapshot.dateGeneration}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Dirigeants enrôlés CMU
+
#{publicKpiBean.snapshot.dirigeantsAvecCmu}
+
+
+
+
+
+
Taux KYC à jour
+
#{publicKpiBean.snapshot.tauxKycAJourPct} %
+
+
+
+
+
+
Taux formation LBC/FT
+
#{publicKpiBean.snapshot.tauxFormationLbcFtPct} %
+
+
+
+
+
+
Couverture UBO
+
#{publicKpiBean.snapshot.couvertureUboPct} %
+
+
+
+
+
+
+
Commissaire aux comptes
+
+
+
+
+
+
+
+
+
+ Vue partagée temporairement via lien signé. Toute consultation est tracée
+ dans l'audit trail UnionFlow.
+
+
+
+
+
+
+
diff --git a/src/test/java/dev/lions/unionflow/client/view/PublicKpiBeanTest.java b/src/test/java/dev/lions/unionflow/client/view/PublicKpiBeanTest.java
new file mode 100644
index 0000000..166be17
--- /dev/null
+++ b/src/test/java/dev/lions/unionflow/client/view/PublicKpiBeanTest.java
@@ -0,0 +1,79 @@
+package dev.lions.unionflow.client.view;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import dev.lions.unionflow.server.api.dto.compliance.response.KpiPublicSnapshot;
+import java.lang.reflect.Field;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+class PublicKpiBeanTest {
+
+ private PublicKpiBean bean;
+
+ @BeforeEach
+ void setUp() {
+ bean = new PublicKpiBean();
+ }
+
+ private void setSnapshot(KpiPublicSnapshot s) throws Exception {
+ Field f = PublicKpiBean.class.getDeclaredField("snapshot");
+ f.setAccessible(true);
+ f.set(bean, s);
+ }
+
+ // ── couleurScore ─────────────────────────────────────────────────────
+
+ @Test
+ @DisplayName("couleurScore — score >= 80 → success")
+ void couleurSuccess() throws Exception {
+ setSnapshot(KpiPublicSnapshot.builder().scoreGlobal(85).build());
+ assertEquals("success", bean.getCouleurScore());
+ }
+
+ @Test
+ @DisplayName("couleurScore — 60..79 → warning")
+ void couleurWarning() throws Exception {
+ setSnapshot(KpiPublicSnapshot.builder().scoreGlobal(70).build());
+ assertEquals("warning", bean.getCouleurScore());
+ }
+
+ @Test
+ @DisplayName("couleurScore — < 60 → danger")
+ void couleurDanger() throws Exception {
+ setSnapshot(KpiPublicSnapshot.builder().scoreGlobal(50).build());
+ assertEquals("danger", bean.getCouleurScore());
+ }
+
+ @Test
+ @DisplayName("couleurScore — null snapshot → secondary")
+ void couleurNull() {
+ assertEquals("secondary", bean.getCouleurScore());
+ }
+
+ // ── couleurStatut ────────────────────────────────────────────────────
+
+ @Test
+ @DisplayName("couleurStatut — règles")
+ void couleurStatut() {
+ assertEquals("success", bean.getCouleurStatut("OK"));
+ assertEquals("danger", bean.getCouleurStatut("RETARD"));
+ assertEquals("warning", bean.getCouleurStatut("EN_ATTENTE"));
+ assertEquals("info", bean.getCouleurStatut("OBLIGATOIRE"));
+ assertEquals("secondary", bean.getCouleurStatut(null));
+ assertEquals("secondary", bean.getCouleurStatut("AUTRE"));
+ }
+
+ // ── État charge ──────────────────────────────────────────────────────
+
+ @Test
+ @DisplayName("isCharge — false par défaut, true quand snapshot présent")
+ void isCharge() throws Exception {
+ assertFalse(bean.isCharge());
+ setSnapshot(KpiPublicSnapshot.builder().scoreGlobal(75).build());
+ assertTrue(bean.isCharge());
+ }
+}