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:
@@ -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'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'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'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't be kept longer than needed. Every night at 03:30 the retention job deletes or anonymises
|
||||
what'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>
|
||||
);
|
||||
}
|
||||
@@ -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'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'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",
|
||||
|
||||
Reference in New Issue
Block a user