feat(sprint-17 web 2026-04-25): page publique /pages/public/kpi.xhtml — consultation KPI signée

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
This commit is contained in:
dahoud
2026-04-25 16:50:03 +00:00
parent fcaac36a14
commit d04a625811
5 changed files with 354 additions and 1 deletions

View File

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

View File

@@ -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).
*
* <p>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);
}

View File

@@ -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.
*
* <p>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 <f:viewAction action="#{publicKpiBean.charger}" />}. */
public void charger() {
erreur = null;
snapshot = null;
if (token == null || token.isBlank()) {
// Token vient du query param via <f:viewParam>
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; }
}

View File

@@ -0,0 +1,152 @@
<!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/public-template.xhtml">
<ui:define name="title">UnionFlow - KPI publics</ui:define>
<ui:define name="metadata">
<f:metadata>
<f:viewParam name="token" value="#{publicKpiBean.token}" />
<f:viewAction action="#{publicKpiBean.charger}" />
</f:metadata>
</ui:define>
<ui:define name="content">
<div class="grid">
<div class="col-12">
<div class="card">
<div class="text-center mb-4">
<i class="pi pi-shield text-primary" style="font-size: 3rem;" />
<h2 class="text-900 font-medium text-3xl mt-3 mb-2">
Indicateurs publics de conformité
</h2>
<p class="text-600 text-lg m-0">
Vue read-only destinée aux autorités externes (BCEAO, ARTCI, CENTIF).
</p>
</div>
<ui:fragment rendered="#{publicKpiBean.erreur != null}">
<p:messages closable="false" />
<div class="surface-100 border-round p-4 text-center">
<i class="pi pi-exclamation-triangle text-orange-500"
style="font-size: 2rem;" />
<h3 class="text-900 mt-2 mb-1">Accès impossible</h3>
<p class="text-700 m-0">#{publicKpiBean.erreur}</p>
</div>
</ui:fragment>
<ui:fragment rendered="#{publicKpiBean.charge}">
<!-- Score global -->
<div class="grid mb-4">
<div class="col-12 md:col-4">
<div class="surface-100 border-round p-4 text-center">
<div class="text-700 mb-2">Score conformité</div>
<div class="text-#{publicKpiBean.couleurScore eq 'success' ? 'green' : (publicKpiBean.couleurScore eq 'warning' ? 'orange' : 'red')}-600 font-bold"
style="font-size: 3.5rem; line-height: 1;">
#{publicKpiBean.snapshot.scoreGlobal}
</div>
<div class="text-600 mt-1">/ 100</div>
</div>
</div>
<div class="col-12 md:col-8">
<div class="surface-100 border-round p-4 h-full">
<div class="text-700 font-bold mb-2">Organisation</div>
<div class="text-900 text-2xl">#{publicKpiBean.snapshot.organisationNom}</div>
<div class="text-600 mt-3">
Référentiel comptable :
<span class="font-semibold">#{publicKpiBean.snapshot.referentielComptable}</span>
</div>
<div class="text-600 mt-2">
Snapshot généré le : <span class="font-mono">#{publicKpiBean.snapshot.dateGeneration}</span>
</div>
</div>
</div>
</div>
<!-- Indicateurs -->
<div class="grid">
<div class="col-12 md:col-6 lg:col-4">
<div class="surface-50 border-round p-3 h-full">
<div class="flex justify-content-between">
<span class="text-700">Compliance Officer</span>
<p:tag value="#{publicKpiBean.snapshot.complianceOfficerDesigne ? 'Désigné' : 'ABSENT'}"
severity="#{publicKpiBean.snapshot.complianceOfficerDesigne ? 'success' : 'danger'}" />
</div>
</div>
</div>
<div class="col-12 md:col-6 lg:col-4">
<div class="surface-50 border-round p-3 h-full">
<div class="flex justify-content-between">
<span class="text-700">AG annuelle</span>
<p:tag value="#{publicKpiBean.snapshot.agAnnuelleStatut}"
severity="#{publicKpiBean.getCouleurStatut(publicKpiBean.snapshot.agAnnuelleStatut)}" />
</div>
</div>
</div>
<div class="col-12 md:col-6 lg:col-4">
<div class="surface-50 border-round p-3 h-full">
<div class="flex justify-content-between">
<span class="text-700">Rapport AIRMS</span>
<p:tag value="#{publicKpiBean.snapshot.rapportAirmsStatut}"
severity="#{publicKpiBean.getCouleurStatut(publicKpiBean.snapshot.rapportAirmsStatut)}" />
</div>
</div>
</div>
<div class="col-12 md:col-6 lg:col-4">
<div class="surface-50 border-round p-3 h-full">
<div class="text-700">Dirigeants enrôlés CMU</div>
<div class="text-900 text-2xl mt-1">#{publicKpiBean.snapshot.dirigeantsAvecCmu}</div>
</div>
</div>
<div class="col-12 md:col-6 lg:col-4">
<div class="surface-50 border-round p-3 h-full">
<div class="text-700">Taux KYC à jour</div>
<div class="text-900 text-2xl mt-1">#{publicKpiBean.snapshot.tauxKycAJourPct} %</div>
</div>
</div>
<div class="col-12 md:col-6 lg:col-4">
<div class="surface-50 border-round p-3 h-full">
<div class="text-700">Taux formation LBC/FT</div>
<div class="text-900 text-2xl mt-1">#{publicKpiBean.snapshot.tauxFormationLbcFtPct} %</div>
</div>
</div>
<div class="col-12 md:col-6 lg:col-4">
<div class="surface-50 border-round p-3 h-full">
<div class="text-700">Couverture UBO</div>
<div class="text-900 text-2xl mt-1">#{publicKpiBean.snapshot.couvertureUboPct} %</div>
</div>
</div>
<div class="col-12 md:col-6 lg:col-4">
<div class="surface-50 border-round p-3 h-full">
<div class="flex justify-content-between">
<span class="text-700">Commissaire aux comptes</span>
<p:tag value="#{publicKpiBean.snapshot.commissaireAuxComptesStatut}"
severity="#{publicKpiBean.getCouleurStatut(publicKpiBean.snapshot.commissaireAuxComptesStatut)}" />
</div>
</div>
</div>
</div>
<p:divider styleClass="my-4" />
<div class="text-center text-500 text-sm">
<i class="pi pi-info-circle mr-1" />
Vue partagée temporairement via lien signé. Toute consultation est tracée
dans l'audit trail UnionFlow.
</div>
</ui:fragment>
</div>
</div>
</div>
</ui:define>
</ui:composition>

View File

@@ -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());
}
}