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