- Privacy page: news opt-in per channel with history, download my data (JSON + PDF), correction and deletion requests - Bulk messages are an Event update or News & announcements; news only reaches people who opted in, with unsubscribe links, one-click List-Unsubscribe headers and "Reply STOP" on WhatsApp - WhatsApp STOP via WAWP's message webhook, checked against a secret in the webhook URL - Sign-up opt-in box; existing accounts kept opted in and asked once - Admin -> Data requests with 30-day due dates and a daily reminder job - Deleting an account is a request; anonymising now covers attendees, form answers, consent details, payer details in provider payloads, etc. - Unit tests (privacy.test.js) and e2e (privacy.spec.ts); docs Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012mt6TCv85T9VKRq3cV7rXq
202 lines
11 KiB
TypeScript
202 lines
11 KiB
TypeScript
"use client";
|
|
|
|
import React, { useCallback, useEffect, useState } from "react";
|
|
import Link from "next/link";
|
|
import { useAuth } from "@/hooks/useAuth";
|
|
import { apiFetch } from "@/lib/api";
|
|
import { useDismissingState } from "@/hooks/useDismissingState";
|
|
import { Inbox, AlertTriangle } from "lucide-react";
|
|
import { errorMessage } from "@/lib/utils";
|
|
import { BackLink } from "@/components/shared/BackLink";
|
|
|
|
type RequestType = "access" | "correction" | "deletion";
|
|
type RequestStatus = "open" | "completed" | "rejected" | "cancelled";
|
|
type Blocker = { registrationId: string; status: string; eventId: string; eventTitle: string; endDate: string };
|
|
type DataRequest = {
|
|
id: string; type: RequestType; status: RequestStatus; name: string; email: string; details: string | null; response: string | null;
|
|
dueAt: string; closedAt: string | null; createdAt: string; userId: string | null; handledBy: string | null; overdue: boolean; blockers: Blocker[];
|
|
};
|
|
type Msg = { type: "ok" | "err"; text: string } | null;
|
|
|
|
const TYPE_LABEL: Record<RequestType, string> = { access: "Access", correction: "Correction", deletion: "Delete account" };
|
|
const STATUS_TONE: Record<RequestStatus, string> = {
|
|
open: "bg-amber-50 text-amber-700",
|
|
completed: "bg-green-50 text-green-700",
|
|
rejected: "bg-red-50 text-red-700",
|
|
cancelled: "bg-gray-100 text-gray-600",
|
|
};
|
|
|
|
const fmtDate = (d: string) => new Date(d).toLocaleDateString(undefined, { day: "numeric", month: "short", year: "numeric" });
|
|
|
|
/** One open request with its actions: complete (deletion: anonymise), reject or cancel. */
|
|
function OpenRequest({ request, token, onDone }: { request: DataRequest; token: string; onDone: (msg: Msg) => void }) {
|
|
const [mode, setMode] = useState<"idle" | "complete" | "reject">("idle");
|
|
const [response, setResponse] = useState("");
|
|
const [cancelled, setCancelled] = useState(false);
|
|
const [busy, setBusy] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const isDeletion = request.type === "deletion";
|
|
|
|
const submit = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
if (mode === "complete" && isDeletion && !confirm(`Delete ${request.name}'s personal information? This can't be undone.`)) return;
|
|
setBusy(true);
|
|
setError(null);
|
|
try {
|
|
await apiFetch(`/api/privacy/requests/${request.id}/${mode}`, {
|
|
method: "POST",
|
|
authToken: token,
|
|
body: { response: response || undefined, cancelled: mode === "reject" ? cancelled : undefined },
|
|
});
|
|
onDone({ type: "ok", text: mode === "complete" ? (isDeletion ? `${request.name}'s personal information was deleted.` : "Request completed.") : "Request closed." });
|
|
} catch (e) {
|
|
setError(errorMessage(e) || "Something went wrong.");
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<li className={`border rounded-xl p-4 bg-white shadow-sm ${request.overdue ? "border-red-300" : ""}`}>
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<span className="font-semibold text-gray-900">{TYPE_LABEL[request.type]}</span>
|
|
<span className="text-sm text-gray-700">— {request.name}</span>
|
|
<a href={`mailto:${request.email}`} className="text-sm text-brand-600 hover:underline">{request.email}</a>
|
|
<span className={`ml-auto text-xs px-2 py-0.5 rounded-full ${request.overdue ? "bg-red-50 text-red-700" : "bg-gray-100 text-gray-600"}`}>
|
|
{request.overdue ? "Overdue — was due " : "Due "}{fmtDate(request.dueAt)}
|
|
</span>
|
|
</div>
|
|
<p className="text-xs text-gray-400 mt-1">Received {fmtDate(request.createdAt)}</p>
|
|
{request.details && <p className="text-sm text-gray-700 mt-2 whitespace-pre-line">{request.details}</p>}
|
|
|
|
{isDeletion && (
|
|
request.blockers.length > 0 ? (
|
|
<div className="mt-3 p-3 rounded-lg bg-amber-50 border border-amber-200 text-sm text-amber-900">
|
|
<div className="flex items-center gap-2 font-medium"><AlertTriangle className="w-4 h-4" /> Waiting on registrations for upcoming events</div>
|
|
<ul className="mt-1 list-disc pl-5">
|
|
{request.blockers.map(b => (
|
|
<li key={b.registrationId}>{b.eventTitle} ({b.status}, ends {fmtDate(b.endDate)})</li>
|
|
))}
|
|
</ul>
|
|
<p className="mt-1 text-xs">
|
|
Cancel (and refund where needed) on <Link href="/dashboard/admin/registrations" className="text-brand-600 hover:underline">Registrations</Link> —
|
|
search for {request.email} — or wait until the event is over. The account is already closed.
|
|
</p>
|
|
</div>
|
|
) : (
|
|
<p className="mt-2 text-xs text-gray-500">The account is closed. Completing deletes their name, contact details, attendees, form answers and payer details; payments and registrations stay as "Deleted User".</p>
|
|
)
|
|
)}
|
|
|
|
{mode === "idle" ? (
|
|
<div className="flex flex-wrap gap-2 mt-3">
|
|
<button onClick={() => setMode("complete")} disabled={isDeletion && request.blockers.length > 0} className="px-3 py-1.5 rounded-lg bg-brand-600 text-white text-sm font-medium hover:bg-brand-700 disabled:opacity-50">
|
|
{isDeletion ? "Delete personal data…" : "Mark as done…"}
|
|
</button>
|
|
<button onClick={() => setMode("reject")} className="px-3 py-1.5 rounded-lg border border-gray-300 text-gray-700 text-sm font-medium hover:bg-gray-50">
|
|
Reject or cancel…
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<form onSubmit={submit} className="mt-3 space-y-3">
|
|
<label className="block text-sm font-medium text-gray-700">
|
|
{mode === "reject" ? "Reason (emailed to them)" : request.type === "correction" ? "What was corrected (emailed to them)" : "Message to them (optional)"}
|
|
</label>
|
|
<textarea
|
|
className="w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-brand-500"
|
|
rows={3}
|
|
maxLength={5000}
|
|
value={response}
|
|
onChange={e => setResponse(e.target.value)}
|
|
required={mode === "reject" || request.type === "correction"}
|
|
/>
|
|
{mode === "reject" && (
|
|
<label className="flex items-center gap-2 text-sm text-gray-700">
|
|
<input type="checkbox" className="h-4 w-4 accent-brand-600" checked={cancelled} onChange={e => setCancelled(e.target.checked)} />
|
|
They withdrew it (cancelled, not rejected)
|
|
</label>
|
|
)}
|
|
{mode === "reject" && isDeletion && <p className="text-xs text-gray-500">Their account is opened again so they can log in.</p>}
|
|
<div className="flex gap-2">
|
|
<button type="button" onClick={() => { setMode("idle"); setResponse(""); setError(null); }} className="px-3 py-1.5 rounded-lg bg-gray-100 text-sm font-medium hover:bg-gray-200">Back</button>
|
|
<button type="submit" disabled={busy} className={`px-3 py-1.5 rounded-lg text-white text-sm font-medium disabled:opacity-50 ${mode === "complete" && isDeletion ? "bg-red-600 hover:bg-red-700" : "bg-brand-600 hover:bg-brand-700"}`}>
|
|
{busy ? "Saving…" : mode === "complete" ? (isDeletion ? "Delete personal data" : "Complete") : cancelled ? "Cancel request" : "Reject request"}
|
|
</button>
|
|
</div>
|
|
{error && <p className="text-sm text-red-600">{error}</p>}
|
|
</form>
|
|
)}
|
|
</li>
|
|
);
|
|
}
|
|
|
|
export default function DataRequestsPage() {
|
|
const { token } = useAuth();
|
|
const [view, setView] = useState<"open" | "closed">("open");
|
|
const [requests, setRequests] = useState<DataRequest[] | null>(null);
|
|
const [openCount, setOpenCount] = useState(0);
|
|
const [msg, setMsg] = useDismissingState<Msg>(null);
|
|
|
|
const load = useCallback(async () => {
|
|
if (!token) return;
|
|
try {
|
|
const res = await apiFetch<{ requests: DataRequest[]; openCount: number }>(`/api/privacy/requests?status=${view}`, { authToken: token });
|
|
setRequests(res.requests);
|
|
setOpenCount(res.openCount);
|
|
} catch (e) {
|
|
setMsg({ type: "err", text: errorMessage(e) || "Could not load requests." });
|
|
}
|
|
}, [token, view, setMsg]);
|
|
useEffect(() => { setRequests(null); load(); }, [load]);
|
|
|
|
return (
|
|
<div className="max-w-4xl mx-auto w-full">
|
|
<div className="mb-6">
|
|
<BackLink href="/dashboard/admin" />
|
|
<div className="flex items-center gap-2 mt-0.5">
|
|
<Inbox className="w-6 h-6 text-brand-600" />
|
|
<h1 className="text-2xl font-semibold text-gray-900">Data requests</h1>
|
|
</div>
|
|
<p className="text-sm text-gray-500 mt-1">
|
|
POPIA requests from people about their personal information. Answer within 30 days. Downloads people make
|
|
themselves from their Privacy page aren't listed here.
|
|
</p>
|
|
</div>
|
|
|
|
<div className="flex gap-2 mb-4">
|
|
{(["open", "closed"] as const).map(v => (
|
|
<button key={v} onClick={() => setView(v)} className={`px-3 py-1.5 rounded-lg text-sm font-medium ${view === v ? "bg-brand-600 text-white" : "bg-white border text-gray-700 hover:bg-gray-50"}`}>
|
|
{v === "open" ? `Open${openCount ? ` (${openCount})` : ""}` : "Closed"}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{msg && <div className={`mb-4 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>}
|
|
|
|
{requests === null ? (
|
|
<p className="text-sm text-gray-400">Loading…</p>
|
|
) : requests.length === 0 ? (
|
|
<p className="text-sm text-gray-500">{view === "open" ? "No open requests." : "No closed requests yet."}</p>
|
|
) : view === "open" ? (
|
|
<ul className="space-y-3">
|
|
{requests.map(r => <OpenRequest key={r.id} request={r} token={token!} onDone={m => { setMsg(m); load(); }} />)}
|
|
</ul>
|
|
) : (
|
|
<ul className="space-y-2">
|
|
{requests.map(r => (
|
|
<li key={r.id} className="border rounded-xl p-3 bg-white text-sm">
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<span className="font-medium text-gray-900">{TYPE_LABEL[r.type]}</span>
|
|
<span className="text-gray-700">— {r.name}</span>
|
|
<span className={`text-xs px-2 py-0.5 rounded-full ${STATUS_TONE[r.status]}`}>{r.status}</span>
|
|
<span className="ml-auto text-xs text-gray-400">{r.closedAt ? `Closed ${fmtDate(r.closedAt)}` : ""}{r.handledBy ? ` by ${r.handledBy}` : ""}</span>
|
|
</div>
|
|
{r.response && <p className="text-gray-600 mt-1 whitespace-pre-line">{r.response}</p>}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|