Files
hope-events/frontend/src/app/dashboard/admin/data-requests/page.tsx
T
joshuaandClaude Opus 5.5 594cb35a85 POPIA toolkit part 1: privacy page, unsubscribe, WhatsApp STOP, data requests
- 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
2026-10-23 18:13:47 +02:00

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 &quot;Deleted User&quot;.</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&apos;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>
);
}