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:
2
pom.xml
2
pom.xml
@@ -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 -->
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
152
src/main/resources/META-INF/resources/pages/public/kpi.xhtml
Normal file
152
src/main/resources/META-INF/resources/pages/public/kpi.xhtml
Normal 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>
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user