POPIA toolkit part 3: retention, breach register, readiness

- Retention policies (Admin -> Privacy -> Retention) applied by a nightly
  `retention` job: inactive accounts anonymised after 5 years with a
  30-day warning (on by default; never staff roles or people with an
  upcoming registration), form answers, login history, payer details in
  payment notifications, audit/access logs
- User.lastLoginAt (set on login, backfilled) and inactivityWarnedAt
- Breach register (SecurityBreach) with drafts for the people affected
  and the Information Regulator
- Readiness checklist with a score
- Children's/medical flag in the access log; {retention} placeholder in
  the privacy notice
- Unit tests (retention.test.js), e2e (privacy-retention.spec.ts); docs

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012mt6TCv85T9VKRq3cV7rXq
This commit is contained in:
2026-10-23 20:25:58 +02:00
co-authored by Claude Opus 5.5
parent 6eee10b610
commit a1ecbb9617
32 changed files with 1441 additions and 35 deletions
@@ -47,6 +47,10 @@ const ACTIONS = [
"operator_created",
"operator_updated",
"operator_deleted",
"retention_policies_updated",
"retention_run",
"breach_recorded",
"breach_updated",
] as const;
const ACTION_LABELS: Record<string, string> = {
@@ -86,6 +90,10 @@ const ACTION_LABELS: Record<string, string> = {
operator_created: "Operator added",
operator_updated: "Operator changed",
operator_deleted: "Operator removed",
retention_policies_updated: "Retention periods changed",
retention_run: "Retention job deleted/anonymised data",
breach_recorded: "Security compromise recorded",
breach_updated: "Security compromise updated",
};
interface AuditLogEntry {
@@ -3,18 +3,24 @@
import React, { Suspense } from "react";
import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
import { ShieldCheck, ScrollText, Building2, ListChecks, Eye, Inbox } from "lucide-react";
import { ShieldCheck, ScrollText, Building2, ListChecks, Eye, Inbox, Gauge, Timer, AlertTriangle } from "lucide-react";
import { useAuth } from "@/hooks/useAuth";
import { BackLink } from "@/components/shared/BackLink";
import { NoticeEditor } from "@/components/admin/privacy/NoticeEditor";
import { OperatorsPanel } from "@/components/admin/privacy/OperatorsPanel";
import { ConsentLogPanel, AccessLogPanel } from "@/components/admin/privacy/LogPanels";
import { ReadinessPanel } from "@/components/admin/privacy/ReadinessPanel";
import { RetentionPanel } from "@/components/admin/privacy/RetentionPanel";
import { BreachesPanel } from "@/components/admin/privacy/BreachesPanel";
const TABS = [
{ key: "readiness", label: "Readiness", icon: Gauge },
{ key: "notice", label: "Privacy notice", icon: ScrollText },
{ key: "operators", label: "Operators", icon: Building2 },
{ key: "retention", label: "Retention", icon: Timer },
{ key: "consent", label: "Consent log", icon: ListChecks },
{ key: "access", label: "Access log", icon: Eye },
{ key: "breaches", label: "Breaches", icon: AlertTriangle },
] as const;
type TabKey = (typeof TABS)[number]["key"];
@@ -22,7 +28,7 @@ function PrivacyAdmin() {
const { token } = useAuth();
const router = useRouter();
const params = useSearchParams();
const tab = (TABS.find(t => t.key === params.get("tab"))?.key || "notice") as TabKey;
const tab = (TABS.find(t => t.key === params.get("tab"))?.key || "readiness") as TabKey;
return (
<div className="max-w-5xl mx-auto w-full">
@@ -53,10 +59,13 @@ function PrivacyAdmin() {
{token && (
<div className="border rounded-xl p-5 bg-white shadow-sm">
{tab === "readiness" && <ReadinessPanel token={token} />}
{tab === "notice" && <NoticeEditor token={token} />}
{tab === "operators" && <OperatorsPanel token={token} />}
{tab === "retention" && <RetentionPanel token={token} />}
{tab === "consent" && <ConsentLogPanel token={token} />}
{tab === "access" && <AccessLogPanel token={token} />}
{tab === "breaches" && <BreachesPanel token={token} />}
</div>
)}
</div>
@@ -0,0 +1,220 @@
"use client";
import React, { useCallback, useEffect, useState } from "react";
import { Plus, Copy, Pencil } from "lucide-react";
import { apiFetch } from "@/lib/api";
import { errorMessage } from "@/lib/utils";
import { useSiteSettings } from "@/contexts/SiteSettingsContext";
type Status = "open" | "contained" | "closed";
type Breach = {
id: string; title: string; status: Status; discoveredAt: string; occurredAt: string | null; description: string; dataAffected: string;
peopleAffected: number | null; whoAffected: string | null; consequences: string | null; actionsTaken: string | null; adviceToPeople: string | null;
causedBy: string | null; regulatorNotifiedAt: string | null; peopleNotifiedAt: string | null; createdBy: { name: string } | null; createdAt: string;
};
type Form = Record<"title" | "description" | "dataAffected" | "whoAffected" | "consequences" | "actionsTaken" | "adviceToPeople" | "causedBy" | "discoveredAt" | "occurredAt" | "peopleAffected", string>;
const EMPTY: Form = { title: "", description: "", dataAffected: "", whoAffected: "", consequences: "", actionsTaken: "", adviceToPeople: "", causedBy: "", discoveredAt: "", occurredAt: "", peopleAffected: "" };
const STATUS: Record<Status, { label: string; tone: string }> = {
open: { label: "Open", tone: "bg-red-50 text-red-700" },
contained: { label: "Contained", tone: "bg-amber-50 text-amber-800" },
closed: { label: "Closed", tone: "bg-gray-100 text-gray-600" },
};
const inputCls = "w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-brand-500";
const fmt = (d: string | null) => (d ? new Date(d).toLocaleString(undefined, { day: "numeric", month: "short", year: "numeric", hour: "2-digit", minute: "2-digit" }) : "—");
// <input type="datetime-local"> wants local "YYYY-MM-DDTHH:mm".
const toLocal = (d: string | null) => {
if (!d) return "";
const x = new Date(d);
return new Date(x.getTime() - x.getTimezoneOffset() * 60000).toISOString().slice(0, 16);
};
function BreachForm({ initial, onSave, onCancel, busy }: { initial: Form; onSave: (f: Form) => void; onCancel: () => void; busy: boolean }) {
const [f, setF] = useState(initial);
const field = (k: keyof Form, label: string, opts: { rows?: number; required?: boolean; type?: string; placeholder?: string } = {}) => (
<label className="block text-sm text-gray-700">
{label}{opts.required && " *"}
{opts.rows ? (
<textarea className={inputCls} rows={opts.rows} value={f[k]} required={opts.required} placeholder={opts.placeholder} onChange={e => setF({ ...f, [k]: e.target.value })} />
) : (
<input className={inputCls} type={opts.type || "text"} value={f[k]} required={opts.required} placeholder={opts.placeholder} onChange={e => setF({ ...f, [k]: e.target.value })} />
)}
</label>
);
return (
<form onSubmit={e => { e.preventDefault(); onSave(f); }} className="border rounded-xl p-4 bg-gray-50 space-y-3">
{field("title", "Short title", { required: true, placeholder: "e.g. Attendee list emailed to the wrong group" })}
<div className="grid sm:grid-cols-3 gap-3">
{field("discoveredAt", "Discovered", { type: "datetime-local", required: true })}
{field("occurredAt", "Happened (if known)", { type: "datetime-local" })}
{field("peopleAffected", "People affected (about)", { type: "number" })}
</div>
{field("description", "What happened", { rows: 3, required: true })}
{field("dataAffected", "What information was affected", { rows: 2, required: true, placeholder: "e.g. names, phone numbers and medical details of 40 camp attendees" })}
{field("whoAffected", "Who was affected", { rows: 2, placeholder: "e.g. everyone registered for Youth Camp 2026" })}
{field("consequences", "Possible consequences for them", { rows: 2 })}
{field("actionsTaken", "What you've done or will do about it", { rows: 2 })}
{field("adviceToPeople", "What affected people should do", { rows: 2, placeholder: "e.g. be careful of messages asking for payment" })}
{field("causedBy", "Who caused it (if known)", {})}
<div className="flex gap-2">
<button type="button" onClick={onCancel} className="px-3 py-1.5 rounded-lg bg-white border text-sm hover:bg-gray-100">Cancel</button>
<button type="submit" disabled={busy} className="px-3 py-1.5 rounded-lg bg-brand-600 text-white text-sm font-medium hover:bg-brand-700 disabled:opacity-50">{busy ? "Saving…" : "Save"}</button>
</div>
</form>
);
}
function CopyBlock({ title, text }: { title: string; text: string }) {
const [copied, setCopied] = useState(false);
return (
<div className="border rounded-lg bg-white">
<div className="flex items-center justify-between px-3 py-2 border-b">
<span className="text-sm font-medium text-gray-800">{title}</span>
<button onClick={() => { navigator.clipboard?.writeText(text); setCopied(true); setTimeout(() => setCopied(false), 1500); }} className="inline-flex items-center gap-1 text-xs text-brand-600 hover:underline">
<Copy className="w-3.5 h-3.5" /> {copied ? "Copied" : "Copy"}
</button>
</div>
<pre className="p-3 text-xs text-gray-700 whitespace-pre-wrap font-sans">{text}</pre>
</div>
);
}
/** Admin → Privacy → Breaches: the register of security compromises (POPIA s22). */
export function BreachesPanel({ token }: { token: string }) {
const { settings } = useSiteSettings();
const [list, setList] = useState<Breach[] | null>(null);
const [editing, setEditing] = useState<string | "new" | null>(null);
const [open, setOpen] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const load = useCallback(async () => {
try {
setList(await apiFetch<Breach[]>("/api/privacy/breaches", { authToken: token }));
} catch (e) {
setError(errorMessage(e) || "Could not load the register.");
}
}, [token]);
useEffect(() => { load(); }, [load]);
const send = async (id: string | "new", body: Record<string, unknown>) => {
setBusy(true);
setError(null);
try {
if (id === "new") await apiFetch("/api/privacy/breaches", { method: "POST", authToken: token, body });
else await apiFetch(`/api/privacy/breaches/${id}`, { method: "PUT", authToken: token, body });
setEditing(null);
await load();
} catch (e) {
setError(errorMessage(e) || "Could not save.");
} finally {
setBusy(false);
}
};
const save = (f: Form) => send(editing!, {
...f,
discoveredAt: f.discoveredAt ? new Date(f.discoveredAt).toISOString() : "",
occurredAt: f.occurredAt ? new Date(f.occurredAt).toISOString() : null,
peopleAffected: f.peopleAffected === "" ? null : f.peopleAffected,
});
const org = settings.org_name || "We";
const io = [settings.legal_io_name, settings.legal_io_email].filter(Boolean).join(", ") || "our Information Officer";
const peopleText = (b: Breach) => [
`Dear member,`,
``,
`We are writing to tell you about a security incident that affected your personal information held by ${org}.`,
``,
`What happened: ${b.description}`,
`Information involved: ${b.dataAffected}`,
b.consequences ? `What this could mean for you: ${b.consequences}` : "",
b.actionsTaken ? `What we have done: ${b.actionsTaken}` : "",
b.adviceToPeople ? `What you can do: ${b.adviceToPeople}` : "",
b.causedBy ? `Who was responsible: ${b.causedBy}` : "",
``,
`We are sorry this happened. If you have questions, contact ${io}. You may also complain to the Information Regulator (inforegulator.org.za).`,
].filter((l, i, a) => l !== "" || a[i - 1] !== "").join("\n");
const regulatorText = (b: Breach) => [
`Responsible party: ${settings.legal_operator_name || org}`,
`Information Officer: ${io}`,
`Discovered: ${fmt(b.discoveredAt)}${b.occurredAt ? ` · Happened: ${fmt(b.occurredAt)}` : ""}`,
`Description: ${b.description}`,
`Personal information affected: ${b.dataAffected}`,
`People affected: ${b.peopleAffected ?? "unknown"}${b.whoAffected ? ` — ${b.whoAffected}` : ""}`,
b.consequences ? `Possible consequences: ${b.consequences}` : "",
b.actionsTaken ? `Measures taken: ${b.actionsTaken}` : "",
b.adviceToPeople ? `Advice given to data subjects: ${b.adviceToPeople}` : "",
b.causedBy ? `Identity of the unauthorised person (if known): ${b.causedBy}` : "",
`Data subjects notified: ${b.peopleNotifiedAt ? fmt(b.peopleNotifiedAt) : "not yet"}`,
].filter(Boolean).join("\n");
return (
<div className="space-y-4">
<p className="text-sm text-gray-600">
Record any security compromise — a lost phone with the attendee list, an email to the wrong people, a hacked account. POPIA (section 22)
requires you to tell the Information Regulator and the people affected as soon as reasonably possible. Each entry gives you a draft
for both; report to the Regulator on its website (inforegulator.org.za) and record the date here.
</p>
{error && <p className="text-sm text-red-700 bg-red-50 border border-red-200 rounded-lg p-3">{error}</p>}
{editing === "new" ? (
<BreachForm busy={busy} initial={{ ...EMPTY, discoveredAt: toLocal(new Date().toISOString()) }} onCancel={() => setEditing(null)} onSave={save} />
) : (
<button onClick={() => setEditing("new")} className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-brand-600 text-white text-sm font-medium hover:bg-brand-700">
<Plus className="w-4 h-4" /> Record a security compromise
</button>
)}
{list === null ? <p className="text-sm text-gray-400">Loading…</p> : list.length === 0 ? <p className="text-sm text-gray-500">Nothing recorded.</p> : (
<ul className="space-y-3">
{list.map(b => editing === b.id ? (
<li key={b.id}>
<BreachForm busy={busy} onCancel={() => setEditing(null)} onSave={save} initial={{
title: b.title, description: b.description, dataAffected: b.dataAffected, whoAffected: b.whoAffected || "", consequences: b.consequences || "",
actionsTaken: b.actionsTaken || "", adviceToPeople: b.adviceToPeople || "", causedBy: b.causedBy || "",
discoveredAt: toLocal(b.discoveredAt), occurredAt: toLocal(b.occurredAt), peopleAffected: b.peopleAffected == null ? "" : String(b.peopleAffected),
}} />
</li>
) : (
<li key={b.id} className="border rounded-xl bg-white">
<button onClick={() => setOpen(open === b.id ? null : b.id)} className="w-full flex flex-wrap items-center gap-2 p-3 text-left">
<span className={`text-xs px-2 py-0.5 rounded-full ${STATUS[b.status].tone}`}>{STATUS[b.status].label}</span>
<span className="font-medium text-gray-900">{b.title}</span>
<span className="ml-auto text-xs text-gray-500">
Discovered {fmt(b.discoveredAt)} · Regulator {b.regulatorNotifiedAt ? "told" : <strong className="text-red-700">not told</strong>} · People {b.peopleNotifiedAt ? "told" : "not told"}
</span>
</button>
{open === b.id && (
<div className="border-t p-3 space-y-3 text-sm">
<dl className="grid sm:grid-cols-[10rem_1fr] gap-x-3 gap-y-1 text-gray-700">
<dt className="text-gray-500">What happened</dt><dd className="whitespace-pre-line">{b.description}</dd>
<dt className="text-gray-500">Information</dt><dd className="whitespace-pre-line">{b.dataAffected}</dd>
<dt className="text-gray-500">Who</dt><dd>{b.peopleAffected ?? "?"} people{b.whoAffected ? ` — ${b.whoAffected}` : ""}</dd>
{b.actionsTaken && <><dt className="text-gray-500">Done</dt><dd className="whitespace-pre-line">{b.actionsTaken}</dd></>}
<dt className="text-gray-500">Recorded</dt><dd>{fmt(b.createdAt)}{b.createdBy ? ` by ${b.createdBy.name}` : ""}</dd>
</dl>
<div className="flex flex-wrap gap-2">
<button onClick={() => setEditing(b.id)} className="inline-flex items-center gap-1 px-3 py-1.5 rounded-lg border text-sm hover:bg-gray-50"><Pencil className="w-3.5 h-3.5" /> Edit</button>
{!b.regulatorNotifiedAt && <button disabled={busy} onClick={() => send(b.id, { regulatorNotifiedAt: new Date().toISOString() })} className="px-3 py-1.5 rounded-lg border text-sm hover:bg-gray-50">Regulator told today</button>}
{!b.peopleNotifiedAt && <button disabled={busy} onClick={() => send(b.id, { peopleNotifiedAt: new Date().toISOString() })} className="px-3 py-1.5 rounded-lg border text-sm hover:bg-gray-50">People told today</button>}
{b.status !== "contained" && b.status !== "closed" && <button disabled={busy} onClick={() => send(b.id, { status: "contained" })} className="px-3 py-1.5 rounded-lg border text-sm hover:bg-gray-50">Mark contained</button>}
{b.status !== "closed" ? (
<button disabled={busy} onClick={() => send(b.id, { status: "closed" })} className="px-3 py-1.5 rounded-lg border text-sm hover:bg-gray-50">Close</button>
) : (
<button disabled={busy} onClick={() => send(b.id, { status: "open" })} className="px-3 py-1.5 rounded-lg border text-sm hover:bg-gray-50">Reopen</button>
)}
</div>
<CopyBlock title="Draft message to the people affected" text={peopleText(b)} />
<CopyBlock title="Summary for the Information Regulator's form" text={regulatorText(b)} />
<p className="text-xs text-gray-500">These are drafts — check them with your Information Officer before sending.</p>
</div>
)}
</li>
))}
</ul>
)}
</div>
);
}
@@ -119,13 +119,14 @@ export function ConsentLogPanel({ token }: { token: string }) {
// ─── Access log ───────────────────────────────────────────────────────────────
type AccessRow = { id: string; at: string; actor: string; actorRole: string; action: string; what: string; records: number | null; ip: string | null };
type AccessRow = { id: string; at: string; actor: string; actorRole: string; action: string; what: string; records: number | null; ip: string | null; sensitive: boolean };
type AccessResponse = LogResponse<AccessRow> & { actions: Record<string, string>; actors: { id: string; name: string }[] };
/** Admin → Privacy → Access log: who took personal information out of the system in bulk. */
export function AccessLogPanel({ token }: { token: string }) {
const [action, setAction] = useState("");
const [actorId, setActorId] = useState("");
const [sensitiveOnly, setSensitiveOnly] = useState(false);
const [page, setPage] = useState(1);
const [data, setData] = useState<AccessResponse | null>(null);
const [error, setError] = useState<string | null>(null);
@@ -135,12 +136,13 @@ export function AccessLogPanel({ token }: { token: string }) {
const params = new URLSearchParams({ page: String(page) });
if (action) params.set("action", action);
if (actorId) params.set("actorId", actorId);
if (sensitiveOnly) params.set("sensitive", "true");
setData(await apiFetch<AccessResponse>(`/api/privacy/access-log?${params}`, { authToken: token }));
setError(null);
} catch (e) {
setError(errorMessage(e) || "Could not load the access log.");
}
}, [token, action, actorId, page]);
}, [token, action, actorId, sensitiveOnly, page]);
useEffect(() => { load(); }, [load]);
return (
@@ -158,6 +160,10 @@ export function AccessLogPanel({ token }: { token: string }) {
<option value="">Everyone</option>
{(data?.actors || []).map(a => <option key={a.id} value={a.id}>{a.name}</option>)}
</select>
<label className="flex items-center gap-2 text-sm text-gray-700">
<input type="checkbox" className="h-4 w-4 accent-brand-600" checked={sensitiveOnly} onChange={e => { setSensitiveOnly(e.target.checked); setPage(1); }} />
Children&apos;s or medical information only
</label>
</div>
{error && <p className="text-sm text-red-700">{error}</p>}
{!data ? <p className="text-sm text-gray-400">Loading…</p> : data.rows.length === 0 ? <p className="text-sm text-gray-500">Nothing recorded yet.</p> : (
@@ -168,7 +174,11 @@ export function AccessLogPanel({ token }: { token: string }) {
<tr key={r.id} className="border-t align-top">
<td className="p-2 whitespace-nowrap">{fmt(r.at)}</td>
<td className="p-2">{r.actor}<div className="text-xs text-gray-500">{r.actorRole}{r.ip ? ` · ${r.ip}` : ""}</div></td>
<td className="p-2">{data.actions[r.action] || r.action}<div className="text-xs text-gray-500">{r.what}</div></td>
<td className="p-2">
{data.actions[r.action] || r.action}
{r.sensitive && <span className="ml-2 text-[11px] px-1.5 py-0.5 rounded bg-rose-50 text-rose-700" title="Children's or special personal information">children/medical</span>}
<div className="text-xs text-gray-500">{r.what}</div>
</td>
<td className="p-2 text-right">{r.records ?? "—"}</td>
</tr>
))}</tbody>
@@ -19,6 +19,7 @@ const PLACEHOLDER_HELP: [string, string][] = [
["{ioEmail}", "Information Officer's email"],
["{medicalRetentionDays}", "days before medical details are deleted"],
["{operators}", "on its own line: one bullet per active operator"],
["{retention}", "on its own line: one bullet per retention period"],
];
const fmt = (d: string) => new Date(d).toLocaleString(undefined, { day: "numeric", month: "short", year: "numeric", hour: "2-digit", minute: "2-digit" });
@@ -0,0 +1,73 @@
"use client";
import React, { useCallback, useEffect, useState } from "react";
import Link from "next/link";
import { CheckCircle2, Circle, ArrowRight } from "lucide-react";
import { apiFetch } from "@/lib/api";
import { errorMessage } from "@/lib/utils";
type Check = { key: string; label: string; ok: boolean; detail: string; href: string | null; applies: boolean };
type Readiness = { score: number; passed: number; total: number; checks: Check[] };
/** Admin → Privacy → Readiness: a checklist of what's set up for POPIA, with a score. */
export function ReadinessPanel({ token }: { token: string }) {
const [data, setData] = useState<Readiness | null>(null);
const [error, setError] = useState<string | null>(null);
const load = useCallback(async () => {
try {
setData(await apiFetch<Readiness>("/api/privacy/readiness", { authToken: token }));
} catch (e) {
setError(errorMessage(e) || "Could not load the checklist.");
}
}, [token]);
useEffect(() => { load(); }, [load]);
if (error) return <p className="text-sm text-red-700">{error}</p>;
if (!data) return <p className="text-sm text-gray-400">Loading…</p>;
const tone = data.score >= 90 ? "text-green-700 bg-green-50 border-green-200" : data.score >= 60 ? "text-amber-800 bg-amber-50 border-amber-200" : "text-red-700 bg-red-50 border-red-200";
const todo = data.checks.filter(c => c.applies && !c.ok);
const done = data.checks.filter(c => c.applies && c.ok);
const na = data.checks.filter(c => !c.applies);
const Row = ({ c }: { c: Check }) => (
<li className="flex items-start gap-3 border rounded-xl p-3 bg-white">
{c.ok ? <CheckCircle2 className="w-5 h-5 text-green-600 shrink-0 mt-0.5" /> : <Circle className="w-5 h-5 text-amber-500 shrink-0 mt-0.5" />}
<div className="min-w-0 flex-1 text-sm">
<div className="font-medium text-gray-900">{c.label}</div>
<div className="text-gray-600 break-words">{c.detail}</div>
</div>
{!c.ok && c.href && (
<Link href={c.href} className="shrink-0 inline-flex items-center gap-1 text-sm text-brand-600 hover:underline">
Fix <ArrowRight className="w-3.5 h-3.5" />
</Link>
)}
</li>
);
return (
<div className="space-y-5">
<div className={`flex items-center gap-4 border rounded-xl p-4 ${tone}`}>
<div className="text-4xl font-bold tabular-nums">{data.score}%</div>
<div className="text-sm">
<div className="font-semibold">POPIA readiness</div>
<div>{data.passed} of {data.total} checks done. This helps you comply; it isn&apos;t legal advice — your Information Officer stays responsible.</div>
</div>
</div>
{todo.length > 0 && (
<div>
<h3 className="text-sm font-semibold text-gray-900 mb-2">To do</h3>
<ul className="space-y-2">{todo.map(c => <Row key={c.key} c={c} />)}</ul>
</div>
)}
{done.length > 0 && (
<div>
<h3 className="text-sm font-semibold text-gray-900 mb-2">Done</h3>
<ul className="space-y-2">{done.map(c => <Row key={c.key} c={c} />)}</ul>
</div>
)}
{na.length > 0 && <p className="text-xs text-gray-500">Not counted (doesn&apos;t apply here): {na.map(c => c.label).join(", ")}.</p>}
</div>
);
}
@@ -0,0 +1,144 @@
"use client";
import React, { useCallback, useEffect, useState } from "react";
import Link from "next/link";
import { apiFetch } from "@/lib/api";
import { errorMessage } from "@/lib/utils";
type Policy = { enabled: boolean; months: number };
type Definition = { label: string; description: string; minMonths: number; defaults: Policy };
type State = {
policies: Record<string, Policy>;
definitions: Record<string, Definition>;
reviewedAt: string | null;
reviewedBy: string | null;
preview: Record<string, Record<string, number>>;
lastRun: { at: string; result: Record<string, Record<string, number | string>> } | null;
related: { medicalDays: number; backupCount: number; analyticsDays: number };
};
type Msg = { type: "ok" | "err"; text: string } | null;
const fmt = (d: string) => new Date(d).toLocaleString(undefined, { day: "numeric", month: "short", year: "numeric", hour: "2-digit", minute: "2-digit" });
function previewText(key: string, p: Record<string, number> | undefined) {
if (!p) return "";
if (key === "inactive_accounts") {
if (!p.toWarn && !p.toAnonymise) return "Nobody is affected right now.";
return `Next run: ${p.toWarn} to warn, ${p.toAnonymise} to anonymise (warned 30+ days ago).`;
}
return p.count ? `Next run affects ${p.count} record${p.count === 1 ? "" : "s"}.` : "Nothing is due right now.";
}
/** Admin → Privacy → Retention: how long personal information is kept. */
export function RetentionPanel({ token }: { token: string }) {
const [state, setState] = useState<State | null>(null);
const [policies, setPolicies] = useState<Record<string, Policy>>({});
const [busy, setBusy] = useState(false);
const [msg, setMsg] = useState<Msg>(null);
const apply = (s: State) => { setState(s); setPolicies(s.policies); };
const load = useCallback(async () => {
try {
apply(await apiFetch<State>("/api/privacy/retention", { authToken: token }));
} catch (e) {
setMsg({ type: "err", text: errorMessage(e) || "Could not load retention settings." });
}
}, [token]);
useEffect(() => { load(); }, [load]);
const save = async () => {
const inactive = policies.inactive_accounts;
if (state && inactive?.enabled && !state.policies.inactive_accounts.enabled && !confirm("Switch on deleting inactive accounts? People are warned 30 days before; after that it can't be undone.")) return;
setBusy(true);
setMsg(null);
try {
apply(await apiFetch<State>("/api/privacy/retention", { method: "PUT", authToken: token, body: { policies } }));
setMsg({ type: "ok", text: "Saved. The privacy notice's retention section is updated to match." });
} catch (e) {
setMsg({ type: "err", text: errorMessage(e) || "Could not save." });
} finally {
setBusy(false);
}
};
const runNow = async () => {
if (!confirm("Run the retention job now? It deletes and anonymises everything past its retention period, just like the nightly run.")) return;
setBusy(true);
setMsg(null);
try {
const res = await apiFetch<{ state: State }>("/api/privacy/retention/run", { method: "POST", authToken: token });
apply(res.state);
setMsg({ type: "ok", text: "Done — see \"Last run\" below." });
} catch (e) {
setMsg({ type: "err", text: errorMessage(e) || "The run failed." });
} finally {
setBusy(false);
}
};
if (!state) return msg ? <p className="text-sm text-red-700">{msg.text}</p> : <p className="text-sm text-gray-400">Loading…</p>;
const dirty = JSON.stringify(policies) !== JSON.stringify(state.policies);
return (
<div className="space-y-5">
<p className="text-sm text-gray-600">
POPIA says personal information mustn&apos;t be kept longer than needed. Every night at 03:30 the retention job deletes or anonymises
what&apos;s past these periods. The defaults are a sensible start — check them with your Information Officer and save, even if you change nothing.
{state.reviewedAt ? ` Last saved ${fmt(state.reviewedAt)}${state.reviewedBy ? ` by ${state.reviewedBy}` : ""}.` : " Not reviewed yet."}
</p>
<ul className="space-y-3">
{Object.entries(state.definitions).map(([key, def]) => {
const p = policies[key];
return (
<li key={key} className="border rounded-xl p-4 bg-white">
<div className="flex flex-wrap items-center gap-3">
<label className="flex items-center gap-2 font-medium text-gray-900">
<input type="checkbox" className="h-4 w-4 accent-brand-600" checked={p.enabled} onChange={e => setPolicies({ ...policies, [key]: { ...p, enabled: e.target.checked } })} />
{def.label}
</label>
<label className="ml-auto flex items-center gap-2 text-sm text-gray-700">
after
<input type="number" min={def.minMonths} max={240} className="w-20 border rounded-lg px-2 py-1 text-sm" value={p.months} disabled={!p.enabled}
onChange={e => setPolicies({ ...policies, [key]: { ...p, months: parseInt(e.target.value, 10) || def.minMonths } })} aria-label={`${def.label} months`} />
months
</label>
</div>
<p className="mt-1 text-sm text-gray-600">{def.description}</p>
<p className="mt-1 text-xs text-gray-500">
{p.enabled ? previewText(key, state.preview[key]) : "Off — kept until someone asks for it to be deleted."}
{(p.months !== def.defaults.months || p.enabled !== def.defaults.enabled) && ` (Default: ${def.defaults.enabled ? `${def.defaults.months} months` : "off"}.)`}
</p>
</li>
);
})}
</ul>
<div className="border rounded-xl p-4 bg-gray-50 text-sm text-gray-600 space-y-1">
<div className="font-medium text-gray-800">Set elsewhere</div>
<div>Medical details from parental consent: deleted {state.related.medicalDays} days after the event — <Link href="/dashboard/admin/settings?tab=legal" className="text-brand-600 hover:underline">Site settings → Legal</Link>.</div>
<div>Database backups: the last {state.related.backupCount} are kept — <Link href="/dashboard/admin/settings?tab=backups" className="text-brand-600 hover:underline">Site settings → Backups</Link>.</div>
<div>Visit statistics: rolled up into anonymous daily totals after {state.related.analyticsDays} days.</div>
<div>Consent records and privacy notice acceptances are kept as proof (without the IP address once an account is deleted).</div>
</div>
{msg && <div className={`p-3 rounded-lg text-sm ${msg.type === "ok" ? "bg-green-50 text-green-800 border border-green-200" : "bg-red-50 text-red-800 border border-red-200"}`}>{msg.text}</div>}
<div className="flex flex-wrap gap-2">
<button onClick={save} disabled={busy} className="px-4 py-2 rounded-lg bg-brand-600 text-white text-sm font-medium hover:bg-brand-700 disabled:opacity-50">
{busy ? "Saving…" : dirty ? "Save changes" : "Save (confirm reviewed)"}
</button>
<button onClick={runNow} disabled={busy || dirty} className="px-4 py-2 rounded-lg border bg-white text-sm hover:bg-gray-50 disabled:opacity-50" title={dirty ? "Save first" : undefined}>
Run now
</button>
</div>
{state.lastRun && (
<div className="text-xs text-gray-500">
<span className="font-medium text-gray-700">Last run</span> {fmt(state.lastRun.at)}:{" "}
{Object.entries(state.lastRun.result).length === 0 ? "nothing switched on." : Object.entries(state.lastRun.result).map(([k, r]) => `${state.definitions[k]?.label || k} — ${Object.entries(r).map(([a, b]) => `${a}: ${b}`).join(", ")}`).join("; ")}
</div>
)}
</div>
);
}
+20 -2
View File
@@ -1,5 +1,5 @@
import React from "react";
import { Megaphone, Download, PencilLine, Trash2, MailX, Inbox, Clock, ShieldCheck, MessageCircle, ScrollText, Send, ListChecks, Building2, Eye } from "lucide-react";
import { Megaphone, Download, PencilLine, Trash2, MailX, Inbox, Clock, ShieldCheck, MessageCircle, ScrollText, Send, ListChecks, Building2, Eye, Gauge, Timer, AlertTriangle } from "lucide-react";
import { GuideItem } from "@/components/shared/GuideItem";
import type { HelpContent } from "./types";
@@ -50,8 +50,26 @@ export const userPrivacyHelpContent: HelpContent = {
/** Help content for /dashboard/admin/privacy. */
export const adminPrivacyHelpContent: HelpContent = {
title: "Privacy (POPIA)",
subtitle: "Your privacy notice, operators, and the consent and access records.",
subtitle: "Your privacy notice, operators, retention, breaches and the consent and access records.",
tabs: [
{
key: "readiness",
label: "Readiness & retention",
icon: Gauge,
content: (
<div className="space-y-4">
<GuideItem icon={Gauge} title="Readiness" tone="brand">
A checklist with a score: Information Officer set, notice published, operators described, retention reviewed and running, requests answered on time, breaches reported, unsubscribe and WhatsApp STOP working, backups, two-factor login. Each open item links to where you fix it.
</GuideItem>
<GuideItem icon={Timer} title="Retention" tone="amber">
How long each kind of personal information is kept. Every night at 03:30 the retention job deletes or anonymises what&apos;s older: inactive accounts (after a warning 30 days before — logging in keeps the account), event form answers, login history, payer details from payment notifications, and old audit/access records. The privacy notice&apos;s retention section follows these settings ({"{retention}"}). Review and save them, even if you keep the defaults.
</GuideItem>
<GuideItem icon={AlertTriangle} title="Breaches" tone="rose">
Record any security compromise. POPIA requires telling the Information Regulator and the people affected as soon as reasonably possible; each entry has a draft for both, and you record when you told them. Readiness flags any not reported to the Regulator within 3 days.
</GuideItem>
</div>
),
},
{
key: "notice",
label: "Privacy notice",