-
+
+
@@ -140,7 +140,6 @@ describe('#287 — empty tenant, every score surface agrees', () => {
const gauges = [
'surface-home',
- 'surface-exec-persona',
'surface-exec-analytics',
'surface-score-page',
];
@@ -163,6 +162,14 @@ describe('#287 — empty tenant, every score surface agrees', () => {
);
}
+ // The dashboard card (#901) has its own layout and the same answer.
+ const dash = within(screen.getByTestId('surface-dashboard'));
+ expect(dash.getByTestId('dash-score-value')).toHaveTextContent('—');
+ expect(dash.getByTestId('dash-score-unmeasured')).toHaveTextContent('Non mesuré');
+ expect(dash.getByTestId('dash-score-unmeasured')).toHaveTextContent(
+ 'Ajoutez des risques pour calculer votre score',
+ );
+
// The sidebar says it in words too, never "0/100" or "25/100".
const sidebar = screen.getByTestId('surface-sidebar');
expect(sidebar).toHaveTextContent('Non mesuré');
diff --git a/frontend/src/locales/en.json b/frontend/src/locales/en.json
index e8d543db..44f124ab 100644
--- a/frontend/src/locales/en.json
+++ b/frontend/src/locales/en.json
@@ -990,5 +990,153 @@
"limitTitle": "Nothing was imported: this file exceeds your plan’s asset limit.",
"limitDetail": "The file has {requested} asset(s); your plan allows {remaining} more."
}
+ },
+ "dashboard": {
+ "title": "Dashboard",
+ "eyebrow": "{date} · data as of {time}",
+ "incident": {
+ "running": "running for {elapsed}",
+ "join": "Join the war room",
+ "elapsed": "{h} h {m}",
+ "elapsedMin": "{m} min"
+ },
+ "score": {
+ "label": "Exposure score",
+ "detail": "Details",
+ "of100": "/100",
+ "delta": "{delta} pts in 30 d",
+ "noDelta": "30-day change from next month",
+ "weights": {
+ "one": "{count} factor measured, weights Σ = 1.00",
+ "other": "{count} factors measured, weights Σ = 1.00"
+ },
+ "notMeasured": "not measured",
+ "unmeasuredTitle": "No score yet",
+ "unmeasuredBody": "The score is computed as soon as there are risks, controls, vulnerabilities or incidents to measure.",
+ "factor": {
+ "vulnerability_pressure": "Vulnerabilities",
+ "risk_exposure": "Risks",
+ "control_gaps": "Compliance",
+ "incident_pressure": "Incidents"
+ },
+ "factorHint": "Pressure 0–100 (100 = worst), weight {weight}"
+ },
+ "exposure": {
+ "label": "Annualised exposure (ALE)",
+ "link": "CRQ",
+ "unit": "M {currency}",
+ "residual": "Residual after plan",
+ "p95": "Worst case (P95)",
+ "millions": "{value} M",
+ "empty": "No quantified risk. Enter a risk's single loss and frequency to see its exposure.",
+ "locked": "Financial quantification is part of the {plan} plan.",
+ "lockedCta": "See plans"
+ },
+ "band": {
+ "critical": "Critical",
+ "high": "High",
+ "medium": "Moderate",
+ "low": "Low"
+ },
+ "counters": {
+ "criticalRisks": "Critical risks",
+ "criticalRisksSub": "open or in treatment",
+ "kev": "KEV vulnerabilities",
+ "kevSub": "actively exploited",
+ "incidents": "Active incidents",
+ "incidentsSub": {
+ "exact": {
+ "0": "none critical"
+ },
+ "one": "{count} critical",
+ "other": "{count} critical"
+ },
+ "coverage": "Compliance coverage",
+ "coverageSub": {
+ "exact": {
+ "0": "no framework imported"
+ },
+ "one": "average of {count} framework",
+ "other": "average of {count} frameworks"
+ },
+ "openRisks": "Open risks",
+ "openRisksSub": "closed risks excluded",
+ "withoutMitigation": "Without mitigation",
+ "withoutMitigationSub": "to plan",
+ "inTreatment": "In treatment",
+ "inTreatmentSub": "active plans",
+ "reviews": "Due < 30 d",
+ "reviewsSub": "reviews to run",
+ "gaps": "Non-compliant controls",
+ "gapsSub": "all frameworks",
+ "expiring": "Evidence expiring < 30 d",
+ "expiringSub": "to renew",
+ "noEvidence": "Controls without evidence",
+ "noEvidenceSub": "to document",
+ "nextAudit": "Next audit",
+ "nextAuditNone": "no audit scheduled",
+ "ale": "Annualised exposure",
+ "aleSub": "M {currency}",
+ "reduction": "Targeted reduction",
+ "reductionSub": "M {currency} / yr",
+ "planCost": "Plan cost",
+ "planCostSub": "M {currency}, committed",
+ "appetite": "Risk appetite",
+ "appetiteSub": "M {currency}, set by the board",
+ "appetiteUnset": "to set in quantification",
+ "unavailable": "—"
+ },
+ "trend": {
+ "title": "Exposure score over {count} months",
+ "range": "from {from} to {to}",
+ "building": "The history builds up day by day. First reading: {date}.",
+ "aria": "Monthly exposure score, from {from} in {fromMonth} to {to} in {toMonth}"
+ },
+ "matrix": {
+ "title": "Risk matrix",
+ "open": "Open",
+ "probability": "↑ Probability",
+ "impact": "Impact →",
+ "cell": {
+ "exact": {
+ "0": "Probability {p} × impact {i} · {band} · no risk"
+ },
+ "one": "Probability {p} × impact {i} · {band} · {count} risk",
+ "other": "Probability {p} × impact {i} · {band} · {count} risks"
+ },
+ "empty": "The matrix fills in with your first risk."
+ },
+ "priorities": {
+ "rssi": "Top priorities",
+ "rm": "Risks and plans to move forward",
+ "audit": "Controls and evidence to handle",
+ "exec": "Decisions and risks to follow",
+ "link": "Action center",
+ "empty": "Nothing urgent. Actions show up here as soon as a deadline gets close.",
+ "type": {
+ "overdue_mitigation": "Mitigation",
+ "critical_risk": "Risk",
+ "pending_approval": "Approval",
+ "open_incident": "Incident",
+ "expiring_evidence": "Evidence",
+ "overdue_remediation": "Remediation"
+ },
+ "overdue": {
+ "one": "{count} d overdue",
+ "other": "{count} d overdue"
+ },
+ "today": "Today",
+ "tomorrow": "Tomorrow",
+ "inDays": "In {count} d",
+ "unknownType": "Action required"
+ },
+ "activity": {
+ "title": "Recent activity",
+ "all": "See all",
+ "empty": "Nothing yet. What gets done in the organisation shows up here.",
+ "system": "OpenRisk"
+ },
+ "retry": "Retry",
+ "loadError": "Could not load this block."
}
}
diff --git a/frontend/src/locales/fr.json b/frontend/src/locales/fr.json
index 1c3d27de..5b8dfba2 100644
--- a/frontend/src/locales/fr.json
+++ b/frontend/src/locales/fr.json
@@ -990,5 +990,153 @@
"limitTitle": "Rien n’a été importé : ce fichier dépasse la limite d’actifs de votre plan.",
"limitDetail": "Le fichier contient {requested} actif(s) ; votre plan en permet encore {remaining}."
}
+ },
+ "dashboard": {
+ "title": "Tableau de bord",
+ "eyebrow": "{date} · données à {time}",
+ "incident": {
+ "running": "en cours depuis {elapsed}",
+ "join": "Rejoindre la war room",
+ "elapsed": "{h} h {m}",
+ "elapsedMin": "{m} min"
+ },
+ "score": {
+ "label": "Score d'exposition",
+ "detail": "Détail",
+ "of100": "/100",
+ "delta": "{delta} pts en 30 j",
+ "noDelta": "Variation sur 30 j dès le prochain mois",
+ "weights": {
+ "one": "{count} facteur mesuré, pondérations Σ = 1,00",
+ "other": "{count} facteurs mesurés, pondérations Σ = 1,00"
+ },
+ "notMeasured": "non mesuré",
+ "unmeasuredTitle": "Pas encore de score",
+ "unmeasuredBody": "Le score se calcule dès qu'il y a des risques, des contrôles, des vulnérabilités ou des incidents à mesurer.",
+ "factor": {
+ "vulnerability_pressure": "Vulnérabilités",
+ "risk_exposure": "Risques",
+ "control_gaps": "Conformité",
+ "incident_pressure": "Incidents"
+ },
+ "factorHint": "Pression 0–100 (100 = pire), poids {weight}"
+ },
+ "exposure": {
+ "label": "Exposition annualisée (ALE)",
+ "link": "CRQ",
+ "unit": "M {currency}",
+ "residual": "Résiduelle après plan",
+ "p95": "Pire cas (P95)",
+ "millions": "{value} M",
+ "empty": "Aucun risque quantifié. Renseignez la perte unitaire et la fréquence d'un risque pour voir son exposition.",
+ "locked": "La quantification financière fait partie de l'offre {plan}.",
+ "lockedCta": "Voir les offres"
+ },
+ "band": {
+ "critical": "Critique",
+ "high": "Élevé",
+ "medium": "Modéré",
+ "low": "Faible"
+ },
+ "counters": {
+ "criticalRisks": "Risques critiques",
+ "criticalRisksSub": "ouverts ou en traitement",
+ "kev": "Vulnérabilités KEV",
+ "kevSub": "exploitées activement",
+ "incidents": "Incidents actifs",
+ "incidentsSub": {
+ "exact": {
+ "0": "aucun critique"
+ },
+ "one": "{count} critique",
+ "other": "{count} critiques"
+ },
+ "coverage": "Couverture conformité",
+ "coverageSub": {
+ "exact": {
+ "0": "aucun référentiel importé"
+ },
+ "one": "moyenne de {count} référentiel",
+ "other": "moyenne des {count} référentiels"
+ },
+ "openRisks": "Risques ouverts",
+ "openRisksSub": "hors risques clos",
+ "withoutMitigation": "Sans mitigation",
+ "withoutMitigationSub": "à planifier",
+ "inTreatment": "En traitement",
+ "inTreatmentSub": "plans actifs",
+ "reviews": "Échéances < 30 j",
+ "reviewsSub": "revues à mener",
+ "gaps": "Contrôles non conformes",
+ "gapsSub": "tous référentiels",
+ "expiring": "Preuves expirant < 30 j",
+ "expiringSub": "à renouveler",
+ "noEvidence": "Contrôles sans preuve",
+ "noEvidenceSub": "à documenter",
+ "nextAudit": "Prochain audit",
+ "nextAuditNone": "aucun audit planifié",
+ "ale": "Exposition annualisée",
+ "aleSub": "M {currency}",
+ "reduction": "Réduction visée",
+ "reductionSub": "M {currency} / an",
+ "planCost": "Coût du plan",
+ "planCostSub": "M {currency}, engagé",
+ "appetite": "Appétence",
+ "appetiteSub": "M {currency}, fixée par le conseil",
+ "appetiteUnset": "à fixer dans la quantification",
+ "unavailable": "—"
+ },
+ "trend": {
+ "title": "Score d'exposition sur {count} mois",
+ "range": "de {from} à {to}",
+ "building": "L'historique se construit jour après jour. Premier relevé : {date}.",
+ "aria": "Score d'exposition mensuel, de {from} en {fromMonth} à {to} en {toMonth}"
+ },
+ "matrix": {
+ "title": "Matrice des risques",
+ "open": "Ouvrir",
+ "probability": "↑ Probabilité",
+ "impact": "Impact →",
+ "cell": {
+ "exact": {
+ "0": "Probabilité {p} × impact {i} · {band} · aucun risque"
+ },
+ "one": "Probabilité {p} × impact {i} · {band} · {count} risque",
+ "other": "Probabilité {p} × impact {i} · {band} · {count} risques"
+ },
+ "empty": "La matrice se remplit avec votre premier risque."
+ },
+ "priorities": {
+ "rssi": "À traiter en priorité",
+ "rm": "Risques et plans à faire avancer",
+ "audit": "Contrôles et preuves à traiter",
+ "exec": "Décisions et risques à suivre",
+ "link": "Centre d'actions",
+ "empty": "Rien d'urgent. Les actions apparaissent ici dès qu'une échéance approche.",
+ "type": {
+ "overdue_mitigation": "Mitigation",
+ "critical_risk": "Risque",
+ "pending_approval": "Approbation",
+ "open_incident": "Incident",
+ "expiring_evidence": "Preuve",
+ "overdue_remediation": "Remédiation"
+ },
+ "overdue": {
+ "one": "En retard de {count} j",
+ "other": "En retard de {count} j"
+ },
+ "today": "Aujourd'hui",
+ "tomorrow": "Demain",
+ "inDays": "Dans {count} j",
+ "unknownType": "Action requise"
+ },
+ "activity": {
+ "title": "Activité récente",
+ "all": "Tout voir",
+ "empty": "Rien encore. Ce qui est fait dans l'organisation apparaît ici.",
+ "system": "OpenRisk"
+ },
+ "retry": "Réessayer",
+ "loadError": "Impossible de charger ce bloc."
}
}
diff --git a/migrations/0069_tenant_score_snapshots.down.sql b/migrations/0069_tenant_score_snapshots.down.sql
new file mode 100644
index 00000000..73f79712
--- /dev/null
+++ b/migrations/0069_tenant_score_snapshots.down.sql
@@ -0,0 +1 @@
+DROP TABLE IF EXISTS tenant_score_snapshots;
diff --git a/migrations/0069_tenant_score_snapshots.up.sql b/migrations/0069_tenant_score_snapshots.up.sql
new file mode 100644
index 00000000..70d9f9a7
--- /dev/null
+++ b/migrations/0069_tenant_score_snapshots.up.sql
@@ -0,0 +1,21 @@
+-- #901 — daily history of the tenant exposure score.
+--
+-- The tenant score is computed live and nothing kept its past values, so the
+-- dashboard could not draw a 12-month line or say how much the score moved in
+-- 30 days. One row per tenant per UTC day; a later computation on the same day
+-- updates the row. AutoMigrate builds the same table on boot, so every
+-- statement is idempotent.
+
+CREATE TABLE IF NOT EXISTS tenant_score_snapshots (
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+ tenant_id uuid NOT NULL,
+ day date NOT NULL,
+ value double precision NOT NULL,
+ band varchar(16) NOT NULL,
+ formula_version varchar(16) NOT NULL,
+ created_at timestamptz,
+ updated_at timestamptz
+);
+
+CREATE UNIQUE INDEX IF NOT EXISTS ux_tenant_score_snapshot_day
+ ON tenant_score_snapshots (tenant_id, day);