M24: implement privacy governance
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"] },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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 d’audit"
|
||||
"audit": "Historique d’audit",
|
||||
"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 d’audit complète.",
|
||||
"managerOnly": "Ces fonctions de gouvernance sont réservées aux responsables des opérations.",
|
||||
"loading": "Chargement de la politique…",
|
||||
"error": "L’action n’a 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 l’audit",
|
||||
"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 d’audit CSV limité. Chaque export est journalisé.","customer":"Télécharger le dossier","audit":"Télécharger l’audit CSV"},
|
||||
"anonymize": {"title":"Anonymiser un client","detail":"Irréversible. Les réservations actives ou de moins de {{days}} jours bloquent l’action.","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."}
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user