M24: implement privacy governance

This commit is contained in:
NuklearRabbit
2026-08-10 15:56:03 +02:00
parent f0f1be83ae
commit 0935901f11
29 changed files with 920 additions and 11 deletions
+7 -1
View File
@@ -25,7 +25,7 @@ test.beforeEach(async ({ page, request }) => {
await expect(page).toHaveURL(/\/dashboard$/);
});
test("all seven nav items navigate correctly", async ({ page }) => {
test("all manager navigation items navigate correctly", async ({ page }) => {
const items: [string, RegExp][] = [
["Overview", /\/dashboard$/],
["Fleet", /\/vehicles$/],
@@ -34,6 +34,8 @@ test("all seven nav items navigate correctly", async ({ page }) => {
["Knowledge", /\/knowledge$/],
["Integrations", /\/automation$/],
["Audit trail", /\/audit$/],
["Users", /\/users$/],
["Privacy", /\/privacy$/],
];
const primaryNavigation = page.getByRole("navigation", { name: "Primary navigation" });
for (const [label, urlPattern] of items) {
@@ -378,6 +380,7 @@ test("rental employee role has a restricted nav and cannot reach manager-only pa
await expect(page.getByRole("link", { name: "Data quality" })).toHaveCount(0);
await expect(page.getByRole("link", { name: "Integrations" })).toHaveCount(0);
await expect(page.getByRole("link", { name: "Audit trail" })).toHaveCount(0);
await expect(page.getByRole("link", { name: "Privacy" })).toHaveCount(0);
// Direct URL navigation is still blocked server-side and shows the same restricted
// message as a defense-in-depth measure, not just a hidden button.
@@ -394,6 +397,9 @@ test("rental employee role has a restricted nav and cannot reach manager-only pa
await page.goto("/audit");
await expect(page.getByText("Audit history is visible to Operations Managers only.").first()).toBeVisible();
await page.goto("/privacy");
await expect(page.getByText("These governance functions are available to Operations Managers only.")).toBeVisible();
await expect(page.getByRole("button", { name: "Reset demo data" })).toHaveCount(0);
});
+29
View File
@@ -0,0 +1,29 @@
import { expect, test, type APIRequestContext, type Page } from "@playwright/test";
async function resetAndLogin(request: APIRequestContext, page: Page) {
await request.post("/api/v1/demo/login", { data: { role: "operations_manager" } });
await request.post("/api/v1/demo/reset");
await page.goto("/login");
await page.getByRole("button", { name: "Verken als Operationsmanager" }).click();
}
test("privacy centre reports policy and produces an audited CSV export", async ({ page, request }) => {
await resetAndLogin(request, page);
await page.goto("/privacy");
await expect(page.getByRole("heading", { name: "Privacybeheer" })).toBeVisible();
await expect(page.getByText("30 dagen")).toBeVisible();
const downloadPromise = page.waitForEvent("download");
await page.getByRole("link", { name: "Audit CSV downloaden" }).click();
const download = await downloadPromise;
expect(download.suggestedFilename()).toBe("mobilityops-audit.csv");
});
test("privacy centre refuses anonymisation of a customer with an active booking", async ({ page, request }) => {
await resetAndLogin(request, page);
await page.goto("/privacy");
await page.getByLabel("Klantreferentie").first().fill("CUS-0042");
await page.getByLabel("Gemotiveerde reden").fill("Gevalideerd verzoek van de betrokkene");
await page.getByLabel(/Typ CUS-0042 ter bevestiging/).fill("CUS-0042");
await page.getByRole("button", { name: "Definitief anonimiseren" }).click();
await expect(page.getByText(/kon niet worden uitgevoerd/)).toBeVisible();
});
+2
View File
@@ -20,6 +20,7 @@ const Audit = lazy(() => import("./pages/Audit").then((module) => ({ default: mo
const AboutDemo = lazy(() => import("./pages/AboutDemo").then((module) => ({ default: module.AboutDemo })));
const Scenarios = lazy(() => import("./pages/Scenarios").then((module) => ({ default: module.Scenarios })));
const Users = lazy(() => import("./pages/Users").then((module) => ({ default: module.Users })));
const Privacy = lazy(() => import("./pages/Privacy").then((module) => ({ default: module.Privacy })));
function deferredPage(element: ReactNode) {
return <Suspense fallback={<div className="route-loading" role="status"><span className="spinner" /></div>}>{element}</Suspense>;
@@ -51,6 +52,7 @@ export function App() {
<Route path="/knowledge" element={deferredPage(<Knowledge />)} />
<Route path="/audit" element={deferredPage(<Audit />)} />
<Route path="/users" element={deferredPage(<Users />)} />
<Route path="/privacy" element={deferredPage(<Privacy />)} />
<Route path="/about" element={deferredPage(<AboutDemo />)} />
<Route path="/scenarios" element={deferredPage(<Scenarios />)} />
</Route>
+14
View File
@@ -90,6 +90,20 @@ export interface UserRecord {
active: boolean;
}
export interface PrivacyRetention {
minimum_booking_retention_days: number;
audit_retention_days: number;
customers_total: number;
customers_anonymized: number;
customers_eligible: number;
}
export interface CustomerAnonymizeResult {
public_ref: string;
anonymized_at: string;
status: "anonymized" | "already_anonymized";
}
export interface Inspection {
public_ref: string;
booking_ref: string;
+1
View File
@@ -70,6 +70,7 @@ const NAV_GROUPS: Array<{ labelKey: string; items: NavItem[] }> = [
{ to: "/automation", labelKey: "items.integrations", icon: "integrations", roles: ["operations_manager"] },
{ to: "/audit", labelKey: "items.audit", icon: "audit", roles: ["operations_manager"] },
{ to: "/users", labelKey: "items.users", icon: "activity", roles: ["operations_manager"] },
{ to: "/privacy", labelKey: "items.privacy", icon: "shield", roles: ["operations_manager"] },
],
},
];
+7
View File
@@ -16,6 +16,7 @@ import demoNl from "./locales/nl-BE/demo.json";
import errorsNl from "./locales/nl-BE/errors.json";
import accessibilityNl from "./locales/nl-BE/accessibility.json";
import operationsNl from "./locales/nl-BE/operations.json";
import privacyNl from "./locales/nl-BE/privacy.json";
import commonEn from "./locales/en-GB/common.json";
import authEn from "./locales/en-GB/auth.json";
@@ -32,6 +33,7 @@ import demoEn from "./locales/en-GB/demo.json";
import errorsEn from "./locales/en-GB/errors.json";
import accessibilityEn from "./locales/en-GB/accessibility.json";
import operationsEn from "./locales/en-GB/operations.json";
import privacyEn from "./locales/en-GB/privacy.json";
import commonFr from "./locales/fr-BE/common.json";
import authFr from "./locales/fr-BE/auth.json";
@@ -48,6 +50,7 @@ import demoFr from "./locales/fr-BE/demo.json";
import errorsFr from "./locales/fr-BE/errors.json";
import accessibilityFr from "./locales/fr-BE/accessibility.json";
import operationsFr from "./locales/fr-BE/operations.json";
import privacyFr from "./locales/fr-BE/privacy.json";
export const SUPPORTED_LANGUAGES = ["nl-BE", "en-GB", "fr-BE"] as const;
export type SupportedLanguage = (typeof SUPPORTED_LANGUAGES)[number];
@@ -70,6 +73,7 @@ export const NAMESPACES = [
"errors",
"accessibility",
"operations",
"privacy",
] as const;
function readStoredLanguage(): SupportedLanguage {
@@ -122,6 +126,7 @@ void i18n
errors: errorsNl,
accessibility: accessibilityNl,
operations: operationsNl,
privacy: privacyNl,
},
"en-GB": {
common: commonEn,
@@ -139,6 +144,7 @@ void i18n
errors: errorsEn,
accessibility: accessibilityEn,
operations: operationsEn,
privacy: privacyEn,
},
"fr-BE": {
common: commonFr,
@@ -156,6 +162,7 @@ void i18n
errors: errorsFr,
accessibility: accessibilityFr,
operations: operationsFr,
privacy: privacyFr,
},
},
});
@@ -12,7 +12,8 @@
"knowledge": "Knowledge",
"integrations": "Integrations",
"audit": "Audit trail",
"users": "Users"
"users": "Users",
"privacy": "Privacy"
},
"primaryNavLabel": "Primary navigation",
"mobileNavLabel": "Mobile navigation",
@@ -42,7 +43,8 @@
"quality": "Quality workbench",
"knowledge": "Procedure assistant",
"integrations": "Automation and integration status",
"audit": "Audit history"
"audit": "Audit history",
"privacy": "Privacy management"
},
"switchRole": "Switch role",
"switchRoleTitle": "Switch demo role",
@@ -0,0 +1,19 @@
{
"eyebrow": "Assure / Governance",
"title": "Privacy management",
"description": "Export personal data, monitor retention and anonymise safely with a complete audit trail.",
"managerOnly": "These governance functions are available to Operations Managers only.",
"loading": "Loading privacy policy…",
"error": "The privacy action could not be completed. Check the reference and retention period.",
"policyTitle": "Retention policy",
"totalCustomers": "Customers",
"eligibleCustomers": "Eligible",
"anonymizedCustomers": "Anonymised",
"bookingRetention": "Minimum booking retention",
"auditRetention": "Audit retention",
"days_one": "{{count}} day",
"days_other": "{{count}} days",
"customerRef": "Customer reference",
"exports": {"title":"Data exports","detail":"Download a customer file as JSON or a bounded audit export as CSV. Every export is logged.","customer":"Download customer file","audit":"Download audit CSV"},
"anonymize": {"title":"Anonymise customer","detail":"Irreversible. Active bookings and bookings newer than {{days}} days block this action.","reason":"Reasoned justification","confirmation":"Type {{ref}} to confirm","submit":"Anonymise permanently","working":"Anonymising…","anonymized":"{{ref}} was anonymised.","already_anonymized":"{{ref}} was already anonymised."}
}
@@ -12,7 +12,8 @@
"knowledge": "Connaissances",
"integrations": "Intégrations",
"audit": "Piste d'audit",
"users": "Utilisateurs"
"users": "Utilisateurs",
"privacy": "Confidentialité"
},
"primaryNavLabel": "Navigation principale",
"mobileNavLabel": "Navigation mobile",
@@ -42,7 +43,8 @@
"quality": "Atelier qualité",
"knowledge": "Assistant de procédures",
"integrations": "Statut d'automatisation et d'intégration",
"audit": "Historique daudit"
"audit": "Historique daudit",
"privacy": "Gestion de la confidentialité"
},
"switchRole": "Changer de rôle",
"switchRoleTitle": "Changer de rôle de démo",
@@ -0,0 +1,19 @@
{
"eyebrow": "Surveiller / Gouvernance",
"title": "Gestion de la confidentialité",
"description": "Exportez les données personnelles, contrôlez la conservation et anonymisez avec une piste daudit complète.",
"managerOnly": "Ces fonctions de gouvernance sont réservées aux responsables des opérations.",
"loading": "Chargement de la politique…",
"error": "Laction na pas pu être exécutée. Vérifiez la référence et la durée de conservation.",
"policyTitle": "Politique de conservation",
"totalCustomers": "Clients",
"eligibleCustomers": "Éligibles",
"anonymizedCustomers": "Anonymisés",
"bookingRetention": "Conservation minimale des réservations",
"auditRetention": "Conservation de laudit",
"days_one": "{{count}} jour",
"days_other": "{{count}} jours",
"customerRef": "Référence client",
"exports": {"title":"Exports de données","detail":"Téléchargez un dossier client JSON ou un export daudit CSV limité. Chaque export est journalisé.","customer":"Télécharger le dossier","audit":"Télécharger laudit CSV"},
"anonymize": {"title":"Anonymiser un client","detail":"Irréversible. Les réservations actives ou de moins de {{days}} jours bloquent laction.","reason":"Motif justifié","confirmation":"Saisissez {{ref}} pour confirmer","submit":"Anonymiser définitivement","working":"Anonymisation…","anonymized":"{{ref}} a été anonymisé.","already_anonymized":"{{ref}} était déjà anonymisé."}
}
@@ -12,7 +12,8 @@
"knowledge": "Kennis",
"integrations": "Integraties",
"audit": "Auditgeschiedenis",
"users": "Gebruikers"
"users": "Gebruikers",
"privacy": "Privacy"
},
"primaryNavLabel": "Hoofdnavigatie",
"mobileNavLabel": "Mobiele navigatie",
@@ -42,7 +43,8 @@
"quality": "Kwaliteitswerkbank",
"knowledge": "Procedureassistent",
"integrations": "Automatiserings- en integratiestatus",
"audit": "Auditgeschiedenis"
"audit": "Auditgeschiedenis",
"privacy": "Privacybeheer"
},
"switchRole": "Wissel van rol",
"switchRoleTitle": "Wissel van demo-rol",
@@ -0,0 +1,19 @@
{
"eyebrow": "Bewaken / Governance",
"title": "Privacybeheer",
"description": "Exporteer persoonsgegevens, bewaak bewaartermijnen en anonimiseer veilig met een volledig auditspoor.",
"managerOnly": "Deze governancefuncties zijn uitsluitend beschikbaar voor Operationsmanagers.",
"loading": "Privacybeleid laden…",
"error": "De privacyactie kon niet worden uitgevoerd. Controleer de referentie en bewaartermijn.",
"policyTitle": "Bewaarbeleid",
"totalCustomers": "Klanten",
"eligibleCustomers": "Anonimiseerbaar",
"anonymizedCustomers": "Geanonimiseerd",
"bookingRetention": "Minimum boekingsretentie",
"auditRetention": "Auditretentie",
"days_one": "{{count}} dag",
"days_other": "{{count}} dagen",
"customerRef": "Klantreferentie",
"exports": {"title":"Gegevensexport","detail":"Download een klantdossier als JSON of een begrensde auditexport als CSV. Elke export wordt gelogd.","customer":"Klantdossier downloaden","audit":"Audit CSV downloaden"},
"anonymize": {"title":"Klant anonimiseren","detail":"Onomkeerbaar. Actieve boekingen en boekingen jonger dan {{days}} dagen blokkeren deze actie.","reason":"Gemotiveerde reden","confirmation":"Typ {{ref}} ter bevestiging","submit":"Definitief anonimiseren","working":"Anonimiseren…","anonymized":"{{ref}} is geanonimiseerd.","already_anonymized":"{{ref}} was al geanonimiseerd."}
}
+82
View File
@@ -0,0 +1,82 @@
import { useCallback, useEffect, useState, type FormEvent } from "react";
import { useTranslation } from "react-i18next";
import { api } from "../api/client";
import type { CustomerAnonymizeResult, PrivacyRetention } from "../api/types";
import { useAuth } from "../context/AuthContext";
import { ErrorState, LoadingState, PageHeader } from "../components/PageChrome";
export function Privacy() {
const { t } = useTranslation("privacy");
const { user } = useAuth();
const [retention, setRetention] = useState<PrivacyRetention | null>(null);
const [customerRef, setCustomerRef] = useState("");
const [confirmation, setConfirmation] = useState("");
const [reason, setReason] = useState("");
const [message, setMessage] = useState<string | null>(null);
const [error, setError] = useState(false);
const [working, setWorking] = useState(false);
const loadRetention = useCallback(() => {
setError(false);
api.get<PrivacyRetention>("/api/v1/privacy/retention").then(setRetention).catch(() => setError(true));
}, []);
useEffect(loadRetention, [loadRetention]);
async function anonymize(event: FormEvent) {
event.preventDefault();
setWorking(true);
setMessage(null);
setError(false);
try {
const result = await api.post<CustomerAnonymizeResult>(
`/api/v1/privacy/customers/${encodeURIComponent(customerRef)}/anonymize`,
{ confirmation, reason },
);
setMessage(t(`anonymize.${result.status}`, { ref: result.public_ref }));
setConfirmation("");
setReason("");
loadRetention();
} catch {
setError(true);
} finally {
setWorking(false);
}
}
if (user?.role !== "operations_manager") return <div className="page">
<PageHeader eyebrow={t("eyebrow")} title={t("title")} description={t("managerOnly")} />
</div>;
return <div className="page privacy-page">
<PageHeader eyebrow={t("eyebrow")} title={t("title")} description={t("description")} />
{error && <ErrorState message={t("error")} />}
{!retention && !error && <LoadingState label={t("loading")} />}
{retention && <>
<section className="privacy-metrics" aria-label={t("policyTitle")}>
<article><span>{t("totalCustomers")}</span><strong>{retention.customers_total}</strong></article>
<article><span>{t("eligibleCustomers")}</span><strong>{retention.customers_eligible}</strong></article>
<article><span>{t("anonymizedCustomers")}</span><strong>{retention.customers_anonymized}</strong></article>
<article><span>{t("bookingRetention")}</span><strong>{t("days", { count: retention.minimum_booking_retention_days })}</strong></article>
<article><span>{t("auditRetention")}</span><strong>{t("days", { count: retention.audit_retention_days })}</strong></article>
</section>
<div className="privacy-workspaces">
<section className="panel privacy-panel">
<h2>{t("exports.title")}</h2><p>{t("exports.detail")}</p>
<label>{t("customerRef")}<input value={customerRef} onChange={(event) => setCustomerRef(event.target.value.toUpperCase())} placeholder="CUS-0001" /></label>
<div className="form-actions-inline">
<a className={`button ${customerRef ? "" : "is-disabled"}`} aria-disabled={!customerRef} href={customerRef ? `/api/v1/privacy/customers/${encodeURIComponent(customerRef)}/export` : undefined}>{t("exports.customer")}</a>
<a className="button" href="/api/v1/audit/export.csv">{t("exports.audit")}</a>
</div>
</section>
<form className="panel privacy-panel" onSubmit={anonymize}>
<h2>{t("anonymize.title")}</h2><p>{t("anonymize.detail", { days: retention.minimum_booking_retention_days })}</p>
<label>{t("customerRef")}<input value={customerRef} onChange={(event) => setCustomerRef(event.target.value.toUpperCase())} placeholder="CUS-0001" required /></label>
<label>{t("anonymize.reason")}<textarea value={reason} onChange={(event) => setReason(event.target.value)} minLength={8} required /></label>
<label>{t("anonymize.confirmation", { ref: customerRef || "CUS-…" })}<input value={confirmation} onChange={(event) => setConfirmation(event.target.value.toUpperCase())} required /></label>
<button className="button button-danger" disabled={working || confirmation !== customerRef || !customerRef}>{working ? t("anonymize.working") : t("anonymize.submit")}</button>
</form>
</div>
{message && <p className="success" role="status">{message}</p>}
</>}
</div>;
}
+12
View File
@@ -117,6 +117,17 @@ a:hover { color: var(--teal); }
.login-divider::before, .login-divider::after { content: ""; height: 1px; flex: 1; background: var(--line); }
.login-oidc { width: 100%; min-height: 44px; justify-content: center; gap: 8px; background: white; border: 1px solid var(--line-strong); }
.login-oidc svg { width: 17px; }
.privacy-metrics { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 12px; }
.privacy-metrics article { min-height: 92px; padding: 16px; background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); }
.privacy-metrics span { display: block; color: var(--muted); font-size: .68rem; }
.privacy-metrics strong { display: block; margin-top: 10px; font-size: 1.25rem; }
.privacy-workspaces { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
.privacy-panel { display: grid; align-content: start; gap: 14px; padding: 20px; }
.privacy-panel h2, .privacy-panel p { margin: 0; }
.privacy-panel label { display: grid; gap: 6px; }
.privacy-panel textarea { min-height: 92px; resize: vertical; }
.form-actions-inline { display: flex; flex-wrap: wrap; gap: 10px; }
.button.is-disabled { pointer-events: none; opacity: .5; }
.demo-badge { position: relative; }
.demo-badge-trigger { display: flex; align-items: center; gap: 6px; height: 32px; padding: 0 12px; color: #48566a; background: #eaf0f5; border: 1px solid #d7e0e8; border-radius: 999px; font-size: .68rem; font-weight: 700; letter-spacing: .02em; cursor: pointer; }
.demo-badge-trigger:hover { background: #dfe8ef; }
@@ -513,6 +524,7 @@ details summary { cursor: pointer; color: var(--teal-dark); }.data-table details
.demo-guide-restart:disabled { opacity: .6; cursor: not-allowed; }
@media (max-width: 960px) {
.privacy-metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); }.privacy-workspaces { grid-template-columns: 1fr; }
.app-shell { display: block; }.app-workspace { min-height: 100vh; }.sidebar { width: min(286px, 86vw); transform: translateX(-102%); transition: transform .22s ease; box-shadow: var(--shadow-float); }.sidebar.is-open { transform: none; }.nav-scrim { display: block; position: fixed; inset: 0; z-index: 25; width: 100%; height: 100%; padding: 0; background: rgba(5, 12, 22, .48); border: 0; }.mobile-menu { display: grid; }.topbar { padding: 0 20px; }.operator > span:last-child, .global-search kbd { display: none; }.topbar-meta > .language-switcher-compact { display: none; }.sidebar-language { display: block; }.global-search { width: min(460px, 55vw); }.mobile-nav { position: fixed; inset: auto 0 0; z-index: 22; height: 68px; display: grid; grid-template-columns: repeat(6, 1fr); padding-bottom: env(safe-area-inset-bottom); background: rgba(255,255,255,.98); border-top: 1px solid var(--line); }.mobile-nav a, .mobile-nav button { min-width: 44px; min-height: 44px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 4px; color: var(--muted); background: transparent; border: 0; text-decoration: none; font-size: .69rem; font-weight: 700; cursor: pointer; }.mobile-nav svg { width: 18px; height: 18px; }.mobile-nav a.active { color: var(--teal-dark); }.mobile-nav a.active::before { content: ""; position: absolute; top: 0; width: 28px; height: 2px; background: var(--teal); }.app-footer { padding-bottom: 68px; }.operations-grid, .secondary-grid { grid-template-columns: 1fr; }.integration-cards { grid-template-columns: 1fr; }.login-shell { grid-template-columns: 1fr; }.login-story { min-height: 44vh; padding: 28px 8vw; }.login-message { margin: auto 0; }.login-message h1 { font-size: clamp(2.5rem, 9vw, 4rem); }.login-message > p:last-child { margin-top: 15px; }.control-illustration { width: 55vw; opacity: .45; right: -10vw; top: -5vw; }.login-footnote { margin-top: 20px; }.login-access { min-height: 56vh; padding: 42px 8vw 60px; }
}