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

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