Files
hope-events/frontend/src/components/admin/privacy/OperatorsPanel.tsx
T
joshuaandClaude Opus 5.5 d265047c84 POPIA toolkit part 2: privacy notice editor, operators, consent and access logs
- Admin -> Privacy (POPIA): edit the privacy notice (built-in text as the
  start, placeholders for org/IO/operators), drafts, preview, numbered
  versions; earlier versions readable on /legal/privacy?version=N
- "Significant change" versions are accepted once after login
  (NoticePrompt); sign-up records the version accepted
- Operator register (seeded with the services the app uses), listed in
  the notice via {operators}
- Consent log (news opt-ins, notice acceptances, parental consent) and
  access log (report exports, registers, certificates, group lists, form
  answers, consent/medical lists, backups, impersonation)
- Information Officer on the contact page
- Unit tests (privacyNotice.test.js), e2e (privacy-notice.spec.ts); docs

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012mt6TCv85T9VKRq3cV7rXq
2026-10-23 19:11:20 +02:00

121 lines
7.0 KiB
TypeScript

"use client";
import React, { useCallback, useEffect, useState } from "react";
import { Plus, Pencil, Trash2 } from "lucide-react";
import { apiFetch } from "@/lib/api";
import { errorMessage } from "@/lib/utils";
type Operator = { id: string; name: string; purpose: string; dataShared: string; location: string | null; website: string | null; active: boolean; order: number };
type Form = { name: string; purpose: string; dataShared: string; location: string; website: string; active: boolean };
const EMPTY: Form = { name: "", purpose: "", dataShared: "", location: "", website: "", active: true };
const inputCls = "w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-brand-500";
function OperatorForm({ initial, onSave, onCancel, busy }: { initial: Form; onSave: (f: Form) => void; onCancel: () => void; busy: boolean }) {
const [f, setF] = useState(initial);
const set = (k: keyof Form) => (e: React.ChangeEvent<HTMLInputElement>) => setF({ ...f, [k]: k === "active" ? e.target.checked : e.target.value });
return (
<form onSubmit={e => { e.preventDefault(); onSave(f); }} className="grid sm:grid-cols-2 gap-3 border rounded-xl p-4 bg-gray-50">
<label className="text-sm">Name<input className={inputCls} value={f.name} onChange={set("name")} required maxLength={200} /></label>
<label className="text-sm">What they do for you<input className={inputCls} value={f.purpose} onChange={set("purpose")} required placeholder="e.g. Online payments" /></label>
<label className="text-sm sm:col-span-2">Personal information they receive<input className={inputCls} value={f.dataShared} onChange={set("dataShared")} placeholder="e.g. Name, email address, payment amount" /></label>
<label className="text-sm">Where it&apos;s processed<input className={inputCls} value={f.location} onChange={set("location")} placeholder="e.g. South Africa" /></label>
<label className="text-sm">Website / privacy policy<input className={inputCls} value={f.website} onChange={set("website")} placeholder="https://…" /></label>
<label className="flex items-center gap-2 text-sm sm:col-span-2">
<input type="checkbox" className="h-4 w-4 accent-brand-600" checked={f.active} onChange={set("active")} /> In use (listed in the privacy notice)
</label>
<div className="flex gap-2 sm:col-span-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>
);
}
/** Admin → Privacy → Operators: the third parties that process personal information. */
export function OperatorsPanel({ token }: { token: string }) {
const [ops, setOps] = useState<Operator[] | null>(null);
const [editing, setEditing] = useState<string | "new" | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const load = useCallback(async () => {
try {
setOps(await apiFetch<Operator[]>("/api/privacy/operators", { authToken: token }));
} catch (e) {
setError(errorMessage(e) || "Could not load operators.");
}
}, [token]);
useEffect(() => { load(); }, [load]);
const save = async (f: Form) => {
setBusy(true);
setError(null);
try {
const body = { ...f, location: f.location || null, website: f.website || null };
if (editing === "new") await apiFetch("/api/privacy/operators", { method: "POST", authToken: token, body });
else await apiFetch(`/api/privacy/operators/${editing}`, { method: "PUT", authToken: token, body });
setEditing(null);
await load();
} catch (e) {
setError(errorMessage(e) || "Could not save.");
} finally {
setBusy(false);
}
};
const remove = async (op: Operator) => {
if (!confirm(`Remove ${op.name} from the register? (Untick "In use" instead if you might use them again.)`)) return;
try {
await apiFetch(`/api/privacy/operators/${op.id}`, { method: "DELETE", authToken: token });
await load();
} catch (e) {
setError(errorMessage(e) || "Could not remove.");
}
};
return (
<div className="space-y-4">
<p className="text-sm text-gray-600">
Services outside your organisation that process personal information for you (POPIA calls them &quot;operators&quot;). The ones in
use are listed in the privacy notice wherever it says <code>{"{operators}"}</code>. Check each one&apos;s details — especially
your email and hosting provider — and that you have an agreement with it.
</p>
{error && <p className="text-sm text-red-700 bg-red-50 border border-red-200 rounded-lg p-3">{error}</p>}
{ops === null ? <p className="text-sm text-gray-400">Loading…</p> : (
<ul className="space-y-2">
{ops.map(op => (
<li key={op.id}>
{editing === op.id ? (
<OperatorForm busy={busy} onCancel={() => setEditing(null)} onSave={save}
initial={{ name: op.name, purpose: op.purpose, dataShared: op.dataShared, location: op.location || "", website: op.website || "", active: op.active }} />
) : (
<div className={`flex items-start gap-3 border rounded-xl p-3 bg-white ${op.active ? "" : "opacity-60"}`}>
<div className="min-w-0 flex-1 text-sm">
<div className="font-medium text-gray-900">
{op.name}{op.location ? <span className="font-normal text-gray-500"> · {op.location}</span> : null}
{!op.active && <span className="ml-2 text-xs px-2 py-0.5 rounded-full bg-gray-100 text-gray-600">not in use</span>}
</div>
<div className="text-gray-600">{op.purpose}</div>
{op.dataShared && <div className="text-xs text-gray-500">Receives: {op.dataShared}</div>}
{op.website && <a href={op.website} target="_blank" rel="noopener noreferrer" className="text-xs text-brand-600 hover:underline break-all">{op.website}</a>}
</div>
<button onClick={() => setEditing(op.id)} className="p-1.5 rounded hover:bg-gray-100" aria-label={`Edit ${op.name}`}><Pencil className="w-4 h-4" /></button>
<button onClick={() => remove(op)} className="p-1.5 rounded hover:bg-red-50 text-red-600" aria-label={`Remove ${op.name}`}><Trash2 className="w-4 h-4" /></button>
</div>
)}
</li>
))}
</ul>
)}
{editing === "new" ? (
<OperatorForm busy={busy} initial={EMPTY} onCancel={() => setEditing(null)} onSave={save} />
) : (
<button onClick={() => setEditing("new")} className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg border bg-white text-sm hover:bg-gray-50">
<Plus className="w-4 h-4" /> Add operator
</button>
)}
</div>
);
}