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
This commit is contained in:
2026-10-23 19:11:20 +02:00
co-authored by Claude Opus 5.5
parent 40c5c9cc1d
commit d265047c84
40 changed files with 2328 additions and 324 deletions
+22 -1
View File
@@ -1,6 +1,7 @@
"use client";
import { Mail, Phone, MapPin } from "lucide-react";
import Link from "next/link";
import { Mail, Phone, MapPin, ShieldCheck } from "lucide-react";
import { Navbar } from "@/components/layout/Navbar";
import { Footer } from "@/components/layout/Footer";
import { useSiteSettings } from "@/contexts/SiteSettingsContext";
@@ -13,6 +14,8 @@ export default function ContactPage() {
const email = settings.org_email || "";
const phone = settings.org_phone || "";
const address = settings.org_address || "";
const ioName = settings.legal_io_name || "";
const ioEmail = settings.legal_io_email || "";
const hasAny = !!(email || phone || address);
return (
@@ -75,6 +78,24 @@ export default function ContactPage() {
<LocationMap address={address} />
</div>
)}
{/* POPIA: the Information Officer, once one is set in Site Settings → Legal. */}
{ioName && (
<div className="mt-8 flex items-start gap-3 border rounded-xl p-5 bg-white shadow-sm">
<div className="w-10 h-10 rounded-full bg-brand-50 flex items-center justify-center shrink-0">
<ShieldCheck className="w-5 h-5 text-brand-600" />
</div>
<div className="text-sm text-gray-600">
<div className="font-semibold text-gray-900">Privacy and your personal information</div>
<p className="mt-1">
Our Information Officer is <strong>{ioName}</strong>
{ioEmail && <> — <a href={`mailto:${ioEmail}`} className="text-brand-600 hover:underline break-all">{ioEmail}</a></>}.
Contact them about the information we hold about you, or see our{" "}
<Link href="/legal/privacy" className="text-brand-600 hover:underline">privacy notice</Link>.
</p>
</div>
</div>
)}
</main>
<Footer />
</div>
@@ -43,6 +43,10 @@ const ACTIONS = [
"data_request_completed",
"data_request_rejected",
"user_anonymised",
"privacy_notice_published",
"operator_created",
"operator_updated",
"operator_deleted",
] as const;
const ACTION_LABELS: Record<string, string> = {
@@ -78,6 +82,10 @@ const ACTION_LABELS: Record<string, string> = {
data_request_completed: "Privacy request completed",
data_request_rejected: "Privacy request rejected/cancelled",
user_anonymised: "User's personal data deleted",
privacy_notice_published: "Privacy notice published",
operator_created: "Operator added",
operator_updated: "Operator changed",
operator_deleted: "Operator removed",
};
interface AuditLogEntry {
@@ -43,6 +43,7 @@ const QUICK_ACTIONS = [
{ href: "/dashboard/admin/cashup", label: "Post-event Cashup", description: "Set costs, reconcile takings, and close out events", icon: Wallet },
{ href: "/dashboard/admin/audit-log", label: "Audit log", description: "Review refunds, manual registrations, event and settings changes", icon: History },
{ href: "/dashboard/admin/data-requests", label: "Data requests", description: "POPIA: people asking to correct or delete their information", icon: Inbox },
{ href: "/dashboard/admin/privacy", label: "Privacy (POPIA)", description: "Privacy notice, operators, consent and access logs", icon: ShieldCheck },
] as const;
type OverviewMetric = { thisMonth: number; lastMonth: number; pctChange: number | null };
@@ -0,0 +1,72 @@
"use client";
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 { 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";
const TABS = [
{ key: "notice", label: "Privacy notice", icon: ScrollText },
{ key: "operators", label: "Operators", icon: Building2 },
{ key: "consent", label: "Consent log", icon: ListChecks },
{ key: "access", label: "Access log", icon: Eye },
] as const;
type TabKey = (typeof TABS)[number]["key"];
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;
return (
<div className="max-w-5xl mx-auto w-full">
<div className="mb-6">
<BackLink href="/dashboard/admin" />
<div className="flex items-center gap-2 mt-0.5">
<ShieldCheck className="w-6 h-6 text-brand-600" />
<h1 className="text-2xl font-semibold text-gray-900">Privacy (POPIA)</h1>
</div>
<p className="text-sm text-gray-500 mt-1">
Your privacy notice, the services that process personal information for you, and the records that show how it&apos;s handled.
People&apos;s requests are on <Link href="/dashboard/admin/data-requests" className="text-brand-600 hover:underline inline-flex items-center gap-1"><Inbox className="w-3.5 h-3.5" />Data requests</Link>;
the Information Officer is set in <Link href="/dashboard/admin/settings" className="text-brand-600 hover:underline">Site settings → Legal</Link>.
</p>
</div>
<div className="flex flex-wrap gap-2 mb-5" role="tablist">
{TABS.map(t => {
const Icon = t.icon;
return (
<button key={t.key} role="tab" aria-selected={tab === t.key} onClick={() => router.replace(`/dashboard/admin/privacy?tab=${t.key}`)}
className={`inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium ${tab === t.key ? "bg-brand-600 text-white" : "bg-white border text-gray-700 hover:bg-gray-50"}`}>
<Icon className="w-4 h-4" /> {t.label}
</button>
);
})}
</div>
{token && (
<div className="border rounded-xl p-5 bg-white shadow-sm">
{tab === "notice" && <NoticeEditor token={token} />}
{tab === "operators" && <OperatorsPanel token={token} />}
{tab === "consent" && <ConsentLogPanel token={token} />}
{tab === "access" && <AccessLogPanel token={token} />}
</div>
)}
</div>
);
}
export default function PrivacyAdminPage() {
return (
<Suspense fallback={<p className="text-sm text-gray-400">Loading…</p>}>
<PrivacyAdmin />
</Suspense>
);
}
@@ -3,6 +3,7 @@
import React, { Suspense, useCallback, useEffect, useRef, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { useSearchParams } from "next/navigation";
import Link from "next/link";
import { apiFetch, ApiError, API_BASE, resolveToApiOrigin } from "@/lib/api";
import { useSiteSettings } from "@/contexts/SiteSettingsContext";
import { useDismissingState } from "@/hooks/useDismissingState";
@@ -649,7 +650,9 @@ function SiteSettingsPageInner() {
<div className="space-y-4">
<h2 className="text-lg font-semibold text-gray-900">Legal</h2>
<p className="text-sm text-gray-500">
These values populate the Terms of Use and Privacy Policy pages automatically.
These values populate the Terms of Use and Privacy Policy pages automatically. Edit the privacy notice&apos;s
text, and publish new versions, in <Link href="/dashboard/admin/privacy" className="text-brand-600 hover:underline">Privacy (POPIA)</Link>.
The effective date below is only used until you publish your first version.
</p>
<Field label="Operator / responsible party"
+3 -3
View File
@@ -6,7 +6,7 @@ import { usePathname, useRouter } from "next/navigation";
import { Navbar } from "@/components/layout/Navbar";
import { Footer } from "@/components/layout/Footer";
import { Sidebar, MobileSidebar } from "@/components/layout/Sidebar";
import { NewsPrompt } from "@/components/shared/NewsPrompt";
import { PrivacyPrompts } from "@/components/shared/PrivacyPrompts";
// The sidebar shows only on these exact routes — each role's dashboard root,
// plus Profile & Security and Site Settings (matching the redesign
@@ -87,7 +87,7 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
return (
<div className="h-screen flex flex-col overflow-hidden">
<Navbar />
<NewsPrompt />
<PrivacyPrompts />
<MobileSidebar />
<div className="flex-1 flex min-h-0">
<Sidebar />
@@ -103,7 +103,7 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
return (
<div className="min-h-screen flex flex-col">
<Navbar />
<NewsPrompt />
<PrivacyPrompts />
<main className="flex-1 p-6 bg-gray-50">{children}</main>
<Footer />
</div>
+103 -291
View File
@@ -1,307 +1,119 @@
"use client";
import { Suspense, useEffect, useMemo, useState } from "react";
import Link from "next/link";
import { useSearchParams } from "next/navigation";
import { Navbar } from "@/components/layout/Navbar";
import { Footer } from "@/components/layout/Footer";
import { useScrollSpy } from "@/hooks/useScrollSpy";
import { useSiteSettings } from "@/contexts/SiteSettingsContext";
import { CookieSettingsButton } from "@/components/shared/CookieSettingsButton";
import { apiFetch, ApiError } from "@/lib/api";
import { PrivacyNoticeSection, type PublicNotice } from "@/components/shared/PrivacyNoticeSection";
const sections = [
{ id: "summary", title: "Plain English Summary" },
{ id: "introduction",title: "1. Introduction" },
{ id: "information", title: "2. Information We Collect" },
{ id: "others", title: "3. People You Register or Transfer Tickets To" },
{ id: "use", title: "4. How We Use Your Information" },
{ id: "whatsapp", title: "5. WhatsApp Communications" },
{ id: "storage", title: "6. Where We Store Your Data" },
{ id: "retention", title: "7. Data Retention" },
{ id: "children", title: "8. Children, Parental Consent and Health Information" },
{ id: "sharing", title: "9. Who Can See Your Information" },
{ id: "rights", title: "10. Your Rights (under POPIA)" },
{ id: "cookies", title: "11. Cookie Policy (Cookies and Browser Storage)" },
{ id: "security", title: "12. Security" },
{ id: "changes", title: "13. Changes to This Policy" },
{ id: "officer", title: "14. Information Officer" },
{ id: "contact", title: "15. Contact" },
];
const fmtDate = (d: string) => new Date(d).toLocaleDateString(undefined, { day: "numeric", month: "long", year: "numeric" });
/**
* The privacy notice. Its text comes from the backend: the published version edited in
* Admin → Privacy (or the built-in notice before one is published), with the organisation's
* details and operator register filled in. ?version=N shows an earlier version.
*/
function PrivacyNotice() {
const versionParam = useSearchParams().get("version");
const { settings } = useSiteSettings();
const [notice, setNotice] = useState<PublicNotice | null>(null);
const [error, setError] = useState<string | null>(null);
const sectionIds = useMemo(() => (notice?.sections || []).map(s => s.id), [notice]);
const activeId = useScrollSpy(sectionIds);
useEffect(() => {
setNotice(null);
setError(null);
apiFetch<PublicNotice>(`/api/privacy/notice${versionParam ? `?version=${encodeURIComponent(versionParam)}` : ""}`)
.then(setNotice)
.catch(e => setError(e instanceof ApiError ? e.message : "The privacy notice couldn't be loaded. Please try again."));
}, [versionParam]);
const orgName = settings.org_name || "Cross Code";
const orgEmail = settings.org_email || "privacy@example.com";
const websiteUrl = settings.legal_website_url || "example.com";
const past = (notice?.versions || []).filter(v => v.version !== notice?.versions[0]?.version);
return (
<main className="flex-1 max-w-6xl mx-auto px-6 py-12 grid grid-cols-1 lg:grid-cols-[250px_1fr] gap-10 w-full">
<aside className="hidden lg:block text-sm text-gray-600 space-y-2 self-start sticky top-24 h-fit">
<h3 className="font-semibold text-gray-800 mb-3">On this page</h3>
<ul className="space-y-2 border-l border-gray-300 pl-3">
{(notice?.sections || []).map(s => (
<li key={s.id}>
<a href={`#${s.id}`} className={`block hover:text-brand-600 transition-colors ${activeId === s.id ? "text-brand-600 font-semibold" : ""}`}>
{s.title}
</a>
</li>
))}
</ul>
</aside>
<article className="bg-white rounded-2xl shadow p-8 text-gray-700 leading-relaxed">
<h1 className="text-4xl font-bold text-brand-700 mb-2">Privacy Policy</h1>
{error && <p className="text-sm text-red-700 bg-red-50 border border-red-200 rounded-lg p-3">{error}</p>}
{!notice && !error && <p className="text-sm text-gray-400">Loading…</p>}
{notice && (
<>
<p className="text-sm text-gray-500 mb-6">
Effective Date: {notice.effectiveDate}
{!notice.builtIn && <> · Version {notice.version}</>}
</p>
{!notice.current && (
<div className="mb-6 p-3 rounded-lg bg-amber-50 border border-amber-200 text-sm text-amber-900">
This is an earlier version of our privacy notice. <Link href="/legal/privacy" className="font-medium underline">Read the current version</Link>.
</div>
)}
<div className="border-l-4 border-brand-600 pl-4 mb-8 text-gray-700">
<p>
<strong>Website:</strong>{" "}
<a href={`https://${websiteUrl}`} className="text-brand-600 hover:underline">{websiteUrl}</a>
</p>
<p><strong>Responsible Party:</strong> {orgName}</p>
<p>
<strong>Email:</strong>{" "}
<a href={`mailto:${orgEmail}`} className="text-brand-600 hover:underline">{orgEmail}</a>
</p>
</div>
{notice.sections.map((s, i) => <PrivacyNoticeSection key={s.id} section={s} first={i === 0} />)}
{past.length > 0 && (
<section className="mt-12 pt-6 border-t text-sm">
<h3 className="font-semibold text-gray-800 mb-2">Earlier versions</h3>
<ul className="space-y-1">
{notice.versions.map(v => (
<li key={v.version}>
<Link href={v.version === notice.versions[0].version ? "/legal/privacy" : `/legal/privacy?version=${v.version}`} className={`hover:underline ${v.version === notice.version ? "font-semibold text-gray-900" : "text-brand-600"}`}>
Version {v.version}
</Link>{" "}
<span className="text-gray-500">— {fmtDate(v.publishedAt)}{v.changeSummary ? `: ${v.changeSummary}` : ""}</span>
</li>
))}
</ul>
</section>
)}
</>
)}
</article>
</main>
);
}
export default function PrivacyPolicyPage() {
const activeId = useScrollSpy(sections.map((s) => s.id));
const { settings } = useSiteSettings();
// orgName follows the sitewide org_name default (Cross Code) since org_name is a
// required field at setup — this fallback is practically unreachable in production.
const orgName = settings.org_name || "Cross Code";
// The fields below are all OPTIONAL legal-specific settings that can realistically
// stay unset on a live site. Defaulting them to a real person/org's identity would be
// actively misleading on someone else's deployment, so these are generic placeholders
// that obviously need to be filled in via Admin → Site Settings → Legal, rather than
// silently showing Cross Code's or any other real organisation's details.
const orgEmail = settings.org_email || "privacy@example.com";
const websiteUrl = settings.legal_website_url || "example.com";
const ioName = settings.legal_io_name || "[not yet configured]";
const ioEmail = settings.legal_io_email || "io@example.com";
const effectiveDate = settings.legal_effective_date || "[not yet set]";
return (
<div className="min-h-screen flex flex-col bg-gray-50">
<Navbar />
<main className="flex-1 max-w-6xl mx-auto px-6 py-12 grid grid-cols-1 lg:grid-cols-[250px_1fr] gap-10">
{/* Sidebar */}
<aside className="hidden lg:block text-sm text-gray-600 space-y-2 self-start sticky top-24 h-fit">
<h3 className="font-semibold text-gray-800 mb-3">On this page</h3>
<ul className="space-y-2 border-l border-gray-300 pl-3">
{sections.map((section) => (
<li key={section.id}>
<a
href={`#${section.id}`}
className={`block hover:text-brand-600 transition-colors ${
activeId === section.id ? "text-brand-600 font-semibold" : ""
}`}
>
{section.title}
</a>
</li>
))}
</ul>
</aside>
{/* Main Content */}
<article className="bg-white rounded-2xl shadow p-8 text-gray-700 leading-relaxed">
<h1 className="text-4xl font-bold text-brand-700 mb-2">Privacy Policy</h1>
<p className="text-sm text-gray-500 mb-6">Effective Date: {effectiveDate}</p>
<div className="border-l-4 border-brand-600 pl-4 mb-8 text-gray-700">
<p>
<strong>Website:</strong>{" "}
<a href={`https://${websiteUrl}`} className="text-brand-600 hover:underline">
{websiteUrl}
</a>
</p>
<p><strong>Responsible Party:</strong> {orgName}</p>
<p>
<strong>Email:</strong>{" "}
<a href={`mailto:${orgEmail}`} className="text-brand-600 hover:underline">
{orgEmail}
</a>
</p>
</div>
<section id="summary" className="mb-10">
<h2 className="text-2xl font-semibold text-brand-600 mb-4">🟢 Plain English Summary</h2>
<p>We respect your privacy — here&apos;s what you need to know:</p>
<ul className="list-disc ml-6 space-y-1">
<li>We only collect info needed to run events: registrations, payments, tickets and check-in.</li>
<li>We store your info safely on South African servers.</li>
<li>We don&apos;t sell your data, and only share it with the services that process payments and deliver messages.</li>
<li>If you register other people or pass a ticket on, we only use the email or phone number you type to contact them. We never show you whether someone has an account with us.</li>
<li>For youth events, a parent or guardian gives consent. Medical details are encrypted, seen only by the event leaders who need them, and deleted shortly after the event.</li>
<li>Online payments go through Yoco or PayFast; we never see your card details.</li>
<li>We send tickets, updates and security alerts by email and/or WhatsApp (powered by WAWP).</li>
<li>Visit statistics are only collected if you click &quot;Allow&quot; on the cookie notice — no third-party analytics.</li>
<li>You can ask what we hold about you, or delete your account, at any time. We keep data for up to 5 years after your last event registration.</li>
</ul>
</section>
{sections.slice(1).map((section) => (
<Section key={section.id} id={section.id} title={section.title}
orgName={orgName} orgEmail={orgEmail} ioName={ioName} ioEmail={ioEmail} />
))}
</article>
</main>
<Suspense fallback={<main className="flex-1" />}>
<PrivacyNotice />
</Suspense>
<Footer />
</div>
);
}
function Section({
id, title, orgName, orgEmail, ioName, ioEmail,
}: {
id: string;
title: string;
orgName: string;
orgEmail: string;
ioName: string;
ioEmail: string;
}) {
const content: Record<string, string[]> = {
introduction: [
`${orgName} ('we', 'us', 'our') values your privacy and is committed to protecting your personal information in accordance with the Protection of Personal Information Act (POPIA).`,
"This policy explains what data we collect, how we use it, and your rights.",
],
information: [
"We collect the following information when you interact with our platform:",
"• Account information: name, email address, phone number, notification preference and password (stored only as an encrypted hash). If you use two-factor authentication, we keep which method you use; codes are short-lived and never stored in readable form.",
"• Security records: sign-ins, password changes and similar account events, with the time, IP address and device type, so we can alert you and investigate misuse.",
"• Event information: events you register for, ticket selections, discount codes used, attendee form answers, waitlist requests, ticket check-ins (when your ticket was scanned), and room or group allocations.",
"• Details about other people you register (see section 3), and parental consent details for youth events (see section 8).",
"• Payment information: processed by our payment provider (Yoco or PayFast); we receive only payment status, amount and reference numbers. If you sponsor a spot, we record the amount and, unless you give anonymously, the name and message you choose to add.",
"• Feedback: answers you give to post-event feedback forms. If an event's feedback is anonymous, your answers are not linked to your name.",
"• Communication data: emails or messages exchanged with our team.",
"• Visit statistics: only if you allow them on the cookie notice (see section 11).",
"We do not collect or store credit card numbers or bank details.",
"Accounts may be created by an administrator, or when someone registers you or transfers a ticket to you, with a mobile number or email address only. You can activate such an account at any time.",
],
others: [
"You can register other people (for example family members) and give each one their own ticket, or pass your ticket on to someone else.",
"• You type their name and, optionally, their email address or phone number. There is no search or list of existing users to choose from, and we never tell you whether the details you enter belong to an account.",
"• We use those details only to send that person their ticket, their attendee form, a parental consent request (youth events), or — for a transfer — a link to accept the ticket. A transfer only happens once they accept it; they can also decline.",
"• If the email or phone number matches an existing account, the ticket also appears in that person's own account. Otherwise an account may be created for them, which they can activate if they wish.",
"• Some events ask for each person's date of birth or gender (for example, to allocate rooms at a camp).",
"By entering someone else's details, you confirm that you are allowed to share them with us for this purpose (for a child, that you are their parent or guardian or have their permission).",
],
use: [
"We use your information to:",
"• Process event registrations, payments, discounts and refunds.",
"• Communicate event updates, confirmations, or changes via email and/or WhatsApp (based on your preference).",
"• Send security notifications for new logins, password changes, and account actions on your account.",
"• Deliver your event tickets via email attachment and/or WhatsApp PDF (based on your preference).",
"• Check tickets in at the door, including with scanners that keep working without an internet connection.",
"• Offer places from a waitlist, arrange rooms and groups, and ask for parental consent on youth events.",
"• Send you an optional feedback form after an event, with one reminder.",
"• Manage user accounts and event attendance records, and give support when you ask for help.",
"• Improve our events and website (using anonymous statistics, and only with your permission).",
"• Comply with legal obligations.",
],
whatsapp: [
"If you choose 'WhatsApp' or 'Both' as your notification preference, we will use your South African mobile number to send you messages via WhatsApp. These messages may include:",
"• Event registration confirmations and updates.",
"• Payment receipts.",
"• Your event tickets (as a PDF document).",
"• Security alerts such as new login notifications, password changes, and account closure confirmations.",
"WhatsApp communications are delivered through WAWP (wawp.net), a third-party WhatsApp Business API provider. Your mobile number is transmitted to WAWP solely for the purpose of delivering your messages. WAWP does not retain your data beyond what is necessary for message delivery.",
"Security-critical messages (login alerts, password resets, account closures) are always sent via email regardless of your preference, and additionally sent via WhatsApp if your preference includes it.",
"You may change your notification preference at any time from the Profile & Security page in your account. Selecting 'Email only' will stop WhatsApp messages from being sent to you.",
"For accounts registered without a valid email address (e.g. walk-in or at-the-door registrations), WhatsApp will be used as the primary delivery channel for payment confirmations and ticket delivery, regardless of notification preference, since email is unavailable.",
"If your account requires activation and no email address is on file, the activation link will be sent to your WhatsApp number instead.",
"WhatsApp is a product of Meta Platforms, Inc. By receiving WhatsApp messages from us, you are subject to WhatsApp's own Terms of Service and Privacy Policy.",
],
storage: [
"All data is securely stored on cloud servers hosted in South Africa.",
"We use appropriate technical and organisational measures to protect your information from unauthorised access, alteration, or disclosure.",
],
retention: [
"We retain your personal data for up to 5 years after your last event registration, or until you request deletion of your account — whichever comes first.",
"Medical and emergency-contact details from parental consent forms are deleted a set number of days after the event ends (30 days unless the organisation sets a different period). The record of who gave consent, when, and to which wording is kept.",
"Detailed visit statistics are combined into anonymous daily totals after 90 days.",
"Ticket lists downloaded to a staff member's scanner for offline check-in are deleted from that device when offline mode is ended.",
"Records of support access to accounts (see section 9) and other audit records are kept for accountability.",
"You can delete your account at any time from the Profile & Security page in your account. Deletion anonymises your personal information (name, email address, and phone number are replaced with placeholder values) while retaining event and payment records in anonymised form for financial record-keeping purposes.",
"An administrator may also anonymise your account data on your request. To request this, contact our Information Officer (see section 14).",
],
children: [
"Users under 18 may only create an account or register for events with the consent and supervision of a parent or guardian.",
"We do not knowingly collect or process personal information of minors without such consent.",
"For youth events, each attendee's date of birth is required. For anyone under 18, the parent or guardian named at registration is sent a consent request by email and WhatsApp, where they review the event details and give or decline consent. Their typed name, the time, the wording agreed to, and the IP address and device type used are recorded as proof of consent. A young person can't be checked in until consent is given.",
"The consent form may ask for medical information (such as allergies, medication and conditions) and an emergency contact. This is special personal information under POPIA. We collect it only with the parent's or guardian's consent, for the young person's safety at the event. It is stored encrypted and shown only to supervisors who need it, and each time the medical list is viewed this is recorded. It is deleted after the event as described in section 7.",
"A parent or guardian may also say whether photos of the young person may be used.",
],
sharing: [
"We do not sell or rent your personal information. Outside our organisation, it is shared only with:",
"• Yoco and PayFast (South African payment processors), to process payments securely — your name and email may be passed to PayFast to fill in its payment page.",
"• WAWP (wawp.net), a WhatsApp Business API provider, to deliver WhatsApp messages — only your mobile number and the message content, solely for delivery.",
"• Our email delivery provider, to deliver emails.",
"• Anyone we are required to share it with under South African law.",
"Within our organisation, people see only what they need:",
"• Door staff see a ticket's holder name, ticket type and check-in status when scanning.",
"• Group, room or bus leaders see the names, ages and contact numbers of the people in their group. Medical details are shown to supervisors only.",
"• Supervisors and administrators manage registrations, payments and communication for events.",
"• Support access: to help with a problem (for example, a ticket not showing), an administrator may view the site as you see it for up to 30 minutes. This is strictly read-only — nothing can be changed, paid, registered or sent on your behalf. A reason must be given, and the session and every page viewed are recorded. The organisation may choose to email you whenever this happens.",
"• Sponsors never see who was helped by a sponsorship; the public only sees how many spots were sponsored.",
"• Nobody else can see whether you have an account.",
],
rights: [
"Under POPIA, you have the right to:",
"• Access your personal information.",
"• Request correction or deletion of your data.",
"• Withdraw consent for processing.",
"• Lodge a complaint with the Information Regulator of South Africa.",
"To contact the Information Regulator:",
"• Website: www.inforeg.org.za",
"• Email: enquiries@inforeg.org.za",
"To exercise your rights directly with us, contact our Information Officer using the details in section 14 below.",
],
cookies: [
"Our own site does not set tracking cookies. It keeps a few small items in your browser's storage (localStorage, and on staff scanners IndexedDB and an offline page cache). These work much like cookies, so this policy covers them. We do not use third-party analytics, advertising or tracking pixels.",
"Strictly necessary — always used, because the site can't work without them:",
"• hope_events_token — keeps you signed in.",
"• hope_events_user_cache — your last loaded profile, so pages keep working if your connection drops.",
"• hope_events_device — only if you tick \"Trust this device\" when entering a two-factor code, so you aren't asked for a code every time.",
"• hope_events_cookie_consent — remembers your choice on the cookie notice.",
"• banner_dismissed_… — remembers notices you have closed.",
"Staff and administrators only:",
"• hope_events_scan_device, hope_events_offline_lock and the \"hope-events-offline-scan\" database — on a staff member's scanner in offline mode: the device's id, the ticket list for the section being scanned, and scans waiting to upload. They are deleted when offline mode is ended. A copy of the scanner page is also cached so it opens without a connection.",
"• hope_events_admin_token, hope_events_admin_user_cache and hope_events_impersonation — only while an administrator is using read-only support access (section 9), and removed when it ends.",
"Optional — only if you click \"Allow\" on the cookie notice:",
"• hope_events_analytics_sid — a random, anonymous visit id. With it we count, in our own statistics, how many visitors view an event, start registering, and complete a registration. We record only that step, where the visit came from (for example a short link or campaign link) and the type of device — never your name, IP address or browsing history. Detailed records become anonymous daily totals after 90 days. If you choose \"No thanks\", nothing is counted and no visit id is created.",
"Third parties:",
"• Event pages with a location show an embedded Google Maps map. Google may set its own cookies when the map loads (see Google's privacy policy).",
"• Payments open the payment provider's secure checkout page (Yoco or PayFast), which uses its own cookies on the provider's site.",
"You can change your cookie choice at any time with \"Cookie settings\" at the bottom of every page, or with the button above. You can also clear your browser's storage in your browser settings; this signs you out.",
],
security: [
"Your personal data is encrypted during transmission and stored using industry-standard security protocols. Medical and emergency details are additionally encrypted in our database.",
"Passwords are encrypted and cannot be viewed by anyone, including administrators.",
"Two-factor authentication is available to everyone and is required for supervisors and administrators.",
"Links we send for consent, feedback and ticket transfers are personal and time-limited. Only a secure fingerprint of each link is stored, not the link itself.",
"In the event of a data breach that poses a risk to your rights, we will notify you and the Information Regulator of South Africa as required under POPIA section 22, without unreasonable delay.",
],
changes: [
"We may update this Privacy Policy periodically.",
"Any changes will be posted on this page with an updated effective date.",
],
officer: [
`The Information Officer responsible for overseeing compliance with POPIA on behalf of ${orgName} is:`,
`• Name: ${ioName}`,
`• Email: ${ioEmail}`,
"You may contact the Information Officer for any POPIA-related requests, including access to your personal data, requests for correction or deletion, and formal complaints.",
],
contact: [
"For general privacy-related questions or requests, you can also contact us at:",
`📧 ${orgEmail}`,
],
};
return (
<section id={id} className="mb-10 scroll-mt-24">
<h3 className="text-xl font-semibold text-brand-600 mb-3">{title}</h3>
{id === "cookies" && (
<CookieSettingsButton className="mb-3 px-3 py-1.5 text-sm rounded-lg border border-brand-300 text-brand-700 hover:bg-brand-50">
Change my cookie choice
</CookieSettingsButton>
)}
{content[id]?.map((line, i) => {
// Render email addresses as links
const emailMatch = line.match(/[\w.+-]+@[\w-]+\.[\w.]+/);
if (emailMatch) {
const email = emailMatch[0];
const parts = line.split(email);
return (
<p key={i} className="mb-2">
{parts[0]}
<a href={`mailto:${email}`} className="text-brand-600 hover:underline">
{email}
</a>
{parts[1] || ""}
</p>
);
}
if (line.includes("www.inforeg.org.za")) {
return (
<p key={i} className="mb-2">
{line.replace("www.inforeg.org.za", "")}
<a href="https://www.inforeg.org.za" target="_blank" rel="noopener noreferrer" className="text-brand-600 hover:underline">
www.inforeg.org.za
</a>
</p>
);
}
return (
<p key={i} className="mb-2">
{line}
</p>
);
})}
</section>
);
}
@@ -0,0 +1,181 @@
"use client";
import React, { useCallback, useEffect, useState } from "react";
import { apiFetch } from "@/lib/api";
import { errorMessage } from "@/lib/utils";
const fmt = (d: string | null) => (d ? new Date(d).toLocaleString(undefined, { day: "numeric", month: "short", year: "numeric", hour: "2-digit", minute: "2-digit" }) : "—");
const selectCls = "border rounded-lg px-3 py-2 text-sm bg-white";
function Pager({ page, total, pageSize, onPage }: { page: number; total: number; pageSize: number; onPage: (p: number) => void }) {
const pages = Math.max(1, Math.ceil(total / pageSize));
if (pages <= 1) return <p className="text-xs text-gray-500">{total} record{total === 1 ? "" : "s"}</p>;
return (
<div className="flex items-center gap-2 text-sm">
<button onClick={() => onPage(page - 1)} disabled={page <= 1} className="px-2 py-1 rounded border bg-white disabled:opacity-40">Previous</button>
<span className="text-gray-600">Page {page} of {pages} · {total} records</span>
<button onClick={() => onPage(page + 1)} disabled={page >= pages} className="px-2 py-1 rounded border bg-white disabled:opacity-40">Next</button>
</div>
);
}
// ─── Consent log ──────────────────────────────────────────────────────────────
type ConsentType = "news" | "notice" | "parental";
type NewsRow = { id: string; at: string; name: string | null; email: string | null; channel: string; granted: boolean; source: string; ip: string | null };
type NoticeRow = { id: string; at: string; name: string | null; email: string | null; version: number; source: string; ip: string | null };
type ParentalRow = { id: string; at: string | null; child: string; event: string; status: string; signedName: string | null; parentName: string | null; textVersion: number; photoConsent: boolean | null; ip: string | null; medicalDeleted: boolean };
type LogResponse<T> = { rows: T[]; total: number; pageSize: number };
const SOURCE: Record<string, string> = {
signup: "Sign-up", privacy_centre: "Privacy page", prompt: "Asked after login", unsubscribe_link: "Unsubscribe link",
whatsapp_stop: "Replied STOP", existing_account: "Existing account (1.38.0)", admin: "Administrator",
};
/** Admin → Privacy → Consent log: proof of who agreed to what, when and how. */
export function ConsentLogPanel({ token }: { token: string }) {
const [type, setType] = useState<ConsentType>("news");
const [q, setQ] = useState("");
const [query, setQuery] = useState("");
const [page, setPage] = useState(1);
const [data, setData] = useState<LogResponse<NewsRow | NoticeRow | ParentalRow> | null>(null);
const [error, setError] = useState<string | null>(null);
const load = useCallback(async () => {
setData(null);
try {
setData(await apiFetch(`/api/privacy/consent-log?type=${type}&page=${page}${query ? `&q=${encodeURIComponent(query)}` : ""}`, { authToken: token }));
setError(null);
} catch (e) {
setError(errorMessage(e) || "Could not load the consent log.");
}
}, [token, type, page, query]);
useEffect(() => { load(); }, [load]);
return (
<div className="space-y-3">
<p className="text-sm text-gray-600">Who agreed to what, when and how. Entries are never changed or removed (after an account is deleted they stay, without the IP address).</p>
<form onSubmit={e => { e.preventDefault(); setPage(1); setQuery(q.trim()); }} className="flex flex-wrap gap-2">
<select className={selectCls} value={type} onChange={e => { setType(e.target.value as ConsentType); setPage(1); }} aria-label="Kind of consent">
<option value="news">News &amp; announcements opt-in</option>
<option value="notice">Privacy notice accepted</option>
<option value="parental">Parental consent</option>
</select>
<input className="border rounded-lg px-3 py-2 text-sm flex-1 min-w-[12rem]" value={q} onChange={e => setQ(e.target.value)} placeholder="Name or email" aria-label="Search" />
<button className="px-3 py-2 rounded-lg bg-brand-600 text-white text-sm">Search</button>
</form>
{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> : (
<div className="overflow-x-auto border rounded-xl bg-white">
<table className="w-full text-sm">
{type === "news" && (
<>
<thead className="bg-gray-50 text-left text-xs text-gray-500"><tr><th className="p-2">When</th><th className="p-2">Person</th><th className="p-2">Channel</th><th className="p-2">Choice</th><th className="p-2">How</th></tr></thead>
<tbody>{(data.rows as NewsRow[]).map(r => (
<tr key={r.id} className="border-t">
<td className="p-2 whitespace-nowrap">{fmt(r.at)}</td>
<td className="p-2">{r.name || <span className="text-gray-400">No account</span>}<div className="text-xs text-gray-500 break-all">{r.email}</div></td>
<td className="p-2">{r.channel === "whatsapp" ? "WhatsApp" : "Email"}</td>
<td className="p-2">{r.granted ? <span className="text-green-700">Opted in</span> : <span className="text-gray-700">Opted out</span>}</td>
<td className="p-2 text-gray-600">{SOURCE[r.source] || r.source}{r.ip ? <div className="text-xs text-gray-400">{r.ip}</div> : null}</td>
</tr>
))}</tbody>
</>
)}
{type === "notice" && (
<>
<thead className="bg-gray-50 text-left text-xs text-gray-500"><tr><th className="p-2">When</th><th className="p-2">Person</th><th className="p-2">Version</th><th className="p-2">How</th></tr></thead>
<tbody>{(data.rows as NoticeRow[]).map(r => (
<tr key={r.id} className="border-t">
<td className="p-2 whitespace-nowrap">{fmt(r.at)}</td>
<td className="p-2">{r.name}<div className="text-xs text-gray-500 break-all">{r.email}</div></td>
<td className="p-2">{r.version === 0 ? "Built-in" : r.version}</td>
<td className="p-2 text-gray-600">{SOURCE[r.source] || r.source}{r.ip ? <div className="text-xs text-gray-400">{r.ip}</div> : null}</td>
</tr>
))}</tbody>
</>
)}
{type === "parental" && (
<>
<thead className="bg-gray-50 text-left text-xs text-gray-500"><tr><th className="p-2">When</th><th className="p-2">Child · event</th><th className="p-2">Answer</th><th className="p-2">Signed by</th><th className="p-2">Wording</th></tr></thead>
<tbody>{(data.rows as ParentalRow[]).map(r => (
<tr key={r.id} className="border-t">
<td className="p-2 whitespace-nowrap">{fmt(r.at)}</td>
<td className="p-2">{r.child}<div className="text-xs text-gray-500">{r.event}</div></td>
<td className="p-2">{r.status === "given" ? <span className="text-green-700">Given</span> : <span className="text-red-700">Declined</span>}{r.photoConsent != null && <div className="text-xs text-gray-500">Photos: {r.photoConsent ? "yes" : "no"}</div>}</td>
<td className="p-2">{r.signedName || "—"}{r.ip ? <div className="text-xs text-gray-400">{r.ip}</div> : null}</td>
<td className="p-2 text-gray-600">v{r.textVersion}{r.medicalDeleted && <div className="text-xs text-gray-400">medical details deleted</div>}</td>
</tr>
))}</tbody>
</>
)}
</table>
</div>
)}
{data && <Pager page={page} total={data.total} pageSize={data.pageSize} onPage={setPage} />}
</div>
);
}
// ─── Access log ───────────────────────────────────────────────────────────────
type AccessRow = { id: string; at: string; actor: string; actorRole: string; action: string; what: string; records: number | null; ip: string | null };
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 [page, setPage] = useState(1);
const [data, setData] = useState<AccessResponse | null>(null);
const [error, setError] = useState<string | null>(null);
const load = useCallback(async () => {
try {
const params = new URLSearchParams({ page: String(page) });
if (action) params.set("action", action);
if (actorId) params.set("actorId", actorId);
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]);
useEffect(() => { load(); }, [load]);
return (
<div className="space-y-3">
<p className="text-sm text-gray-600">
Every time someone downloads, emails or opens personal information in bulk — reports, attendance registers, certificates, group lists,
form answers, the consent and medical lists, backups — and every time an admin views the site as someone else.
</p>
<div className="flex flex-wrap gap-2">
<select className={selectCls} value={action} onChange={e => { setAction(e.target.value); setPage(1); }} aria-label="What">
<option value="">Everything</option>
{Object.entries(data?.actions || {}).map(([k, label]) => <option key={k} value={k}>{label}</option>)}
</select>
<select className={selectCls} value={actorId} onChange={e => { setActorId(e.target.value); setPage(1); }} aria-label="Who">
<option value="">Everyone</option>
{(data?.actors || []).map(a => <option key={a.id} value={a.id}>{a.name}</option>)}
</select>
</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> : (
<div className="overflow-x-auto border rounded-xl bg-white">
<table className="w-full text-sm">
<thead className="bg-gray-50 text-left text-xs text-gray-500"><tr><th className="p-2">When</th><th className="p-2">Who</th><th className="p-2">What</th><th className="p-2 text-right">Records</th></tr></thead>
<tbody>{data.rows.map(r => (
<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 text-right">{r.records ?? "—"}</td>
</tr>
))}</tbody>
</table>
</div>
)}
{data && <Pager page={page} total={data.total} pageSize={data.pageSize} onPage={setPage} />}
</div>
);
}
@@ -0,0 +1,230 @@
"use client";
import React, { useCallback, useEffect, useState } from "react";
import Link from "next/link";
import { ArrowUp, ArrowDown, Trash2, Plus, RotateCcw, Eye, PencilLine } from "lucide-react";
import { apiFetch } from "@/lib/api";
import { errorMessage } from "@/lib/utils";
import { PrivacyNoticeSection, type NoticeSection } from "@/components/shared/PrivacyNoticeSection";
type Version = { version: number; publishedAt: string; changeSummary: string | null; requiresAcceptance: boolean; publishedBy: string | null; acceptances: number };
type Draft = { sections: NoticeSection[]; rendered: NoticeSection[]; isDraft: boolean; savedAt: string | null; versions: Version[]; builtIn: NoticeSection[] };
type Msg = { type: "ok" | "err"; text: string } | null;
const PLACEHOLDER_HELP: [string, string][] = [
["{org}", "organisation name"],
["{orgEmail}", "organisation email"],
["{website}", "website (Site settings → Legal)"],
["{ioName}", "Information Officer"],
["{ioEmail}", "Information Officer's email"],
["{medicalRetentionDays}", "days before medical details are deleted"],
["{operators}", "on its own line: one bullet per active operator"],
];
const fmt = (d: string) => new Date(d).toLocaleString(undefined, { day: "numeric", month: "short", year: "numeric", hour: "2-digit", minute: "2-digit" });
const inputCls = "w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-brand-500";
/** Admin → Privacy → Privacy notice: edit the sections, preview, publish versions. */
export function NoticeEditor({ token }: { token: string }) {
const [draft, setDraft] = useState<Draft | null>(null);
const [sections, setSections] = useState<NoticeSection[]>([]);
const [dirty, setDirty] = useState(false);
const [preview, setPreview] = useState(false);
const [busy, setBusy] = useState(false);
const [msg, setMsg] = useState<Msg>(null);
const [publishing, setPublishing] = useState(false);
const [changeSummary, setChangeSummary] = useState("");
const [requiresAcceptance, setRequiresAcceptance] = useState(false);
const load = useCallback(async () => {
try {
const d = await apiFetch<Draft>("/api/privacy/notice/draft", { authToken: token });
setDraft(d);
setSections(d.sections);
setDirty(false);
} catch (e) {
setMsg({ type: "err", text: errorMessage(e) || "Could not load the notice." });
}
}, [token]);
useEffect(() => { load(); }, [load]);
const change = (next: NoticeSection[]) => { setSections(next); setDirty(true); };
const update = (i: number, patch: Partial<NoticeSection>) => change(sections.map((s, j) => (j === i ? { ...s, ...patch } : s)));
const move = (i: number, by: number) => {
const next = [...sections];
const [s] = next.splice(i, 1);
next.splice(i + by, 0, s);
change(next);
};
const save = async () => {
setBusy(true);
setMsg(null);
try {
const res = await apiFetch<{ sections: NoticeSection[]; rendered: NoticeSection[]; savedAt: string }>("/api/privacy/notice/draft", { method: "PUT", authToken: token, body: { sections } });
setDraft(d => (d ? { ...d, sections: res.sections, rendered: res.rendered, isDraft: true, savedAt: res.savedAt } : d));
setSections(res.sections);
setDirty(false);
setMsg({ type: "ok", text: "Draft saved. It isn't public until you publish it." });
} catch (e) {
setMsg({ type: "err", text: errorMessage(e) || "Could not save." });
} finally {
setBusy(false);
}
};
const discard = async () => {
if (!confirm("Throw away the draft and go back to the published text?")) return;
setBusy(true);
try {
await apiFetch("/api/privacy/notice/draft", { method: "DELETE", authToken: token });
await load();
setMsg({ type: "ok", text: "Draft discarded." });
} catch (e) {
setMsg({ type: "err", text: errorMessage(e) || "Could not discard." });
} finally {
setBusy(false);
}
};
const publish = async (e: React.FormEvent) => {
e.preventDefault();
setBusy(true);
setMsg(null);
try {
const res = await apiFetch<{ version: number }>("/api/privacy/notice/publish", { method: "POST", authToken: token, body: { sections, changeSummary, requiresAcceptance } });
setPublishing(false);
setChangeSummary("");
setRequiresAcceptance(false);
await load();
setMsg({ type: "ok", text: `Version ${res.version} is published.${requiresAcceptance ? " Everyone will be asked to accept it after they next log in." : ""}` });
} catch (e) {
setMsg({ type: "err", text: errorMessage(e) || "Could not publish." });
} finally {
setBusy(false);
}
};
if (!draft) return msg ? <p className="text-sm text-red-700">{msg.text}</p> : <p className="text-sm text-gray-400">Loading…</p>;
const builtInById = new Map(draft.builtIn.map(s => [s.id, s]));
const current = draft.versions[0];
return (
<div className="space-y-5">
<div className="text-sm text-gray-600">
{current ? (
<>Published: <strong>version {current.version}</strong> ({fmt(current.publishedAt)}). </>
) : (
<>Nothing published yet — the site shows the built-in notice. </>
)}
{draft.isDraft && <span className="text-amber-700">You have an unpublished draft{draft.savedAt ? ` (saved ${fmt(draft.savedAt)})` : ""}. </span>}
<Link href="/legal/privacy" target="_blank" className="text-brand-600 hover:underline">View the public page</Link>
</div>
<details className="text-xs text-gray-500 border rounded-lg p-3 bg-gray-50">
<summary className="cursor-pointer font-medium text-gray-700">How to write it</summary>
<p className="mt-2">One paragraph per line; start a line with &quot;• &quot; for a bullet. These are filled in when the notice is shown, so they stay up to date:</p>
<ul className="mt-1 grid sm:grid-cols-2 gap-x-4">
{PLACEHOLDER_HELP.map(([p, d]) => <li key={p}><code>{p}</code> — {d}</li>)}
</ul>
<p className="mt-2">This is tooling to help you comply, not legal advice — your Information Officer should check the wording.</p>
</details>
<div className="flex flex-wrap gap-2">
<button onClick={() => setPreview(p => !p)} className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg border bg-white text-sm hover:bg-gray-50">
{preview ? <><PencilLine className="w-4 h-4" /> Edit</> : <><Eye className="w-4 h-4" /> Preview</>}
</button>
<button onClick={save} disabled={busy || !dirty} className="px-3 py-1.5 rounded-lg bg-white border border-brand-600 text-brand-700 text-sm font-medium hover:bg-brand-50 disabled:opacity-50">
Save draft
</button>
<button onClick={() => setPublishing(true)} 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">
Publish…
</button>
{draft.isDraft && (
<button onClick={discard} disabled={busy} className="px-3 py-1.5 rounded-lg text-sm text-gray-600 hover:bg-gray-100">Discard draft</button>
)}
{dirty && <span className="self-center text-xs text-amber-700">Unsaved changes</span>}
</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>}
{publishing && (
<form onSubmit={publish} className="border rounded-xl p-4 bg-brand-50/40 space-y-3">
<div className="font-medium text-gray-900">Publish version {(current?.version ?? 0) + 1}</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">What changed</label>
<textarea className={inputCls} rows={2} maxLength={2000} value={changeSummary} onChange={e => setChangeSummary(e.target.value)} placeholder="e.g. We now use PayFast for online payments." />
</div>
<label className="flex items-start gap-2 text-sm text-gray-700">
<input type="checkbox" className="mt-0.5 h-4 w-4 accent-brand-600" checked={requiresAcceptance} onChange={e => setRequiresAcceptance(e.target.checked)} />
<span>
<strong>Significant change</strong> — ask everyone to accept this version the next time they log in.
<span className="block text-xs text-gray-500">Leave unticked for small fixes (wording, typos, contact details).</span>
</span>
</label>
<div className="flex gap-2">
<button type="button" onClick={() => setPublishing(false)} className="px-3 py-1.5 rounded-lg bg-gray-100 text-sm hover:bg-gray-200">Cancel</button>
<button type="submit" disabled={busy || (requiresAcceptance && !changeSummary.trim())} 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 ? "Publishing…" : "Publish"}
</button>
</div>
</form>
)}
{preview ? (
<div className="border rounded-xl p-6 bg-white text-gray-700 leading-relaxed">
{dirty && <p className="mb-4 text-xs text-amber-700">Showing the last saved draft — save to preview your latest edits.</p>}
{draft.rendered.map((s, i) => <PrivacyNoticeSection key={s.id} section={s} first={i === 0} />)}
</div>
) : (
<div className="space-y-3">
{sections.map((s, i) => {
const original = builtInById.get(s.id);
return (
<div key={`${s.id}-${i}`} className="border rounded-xl p-3 bg-white">
<div className="flex items-center gap-2 mb-2">
<input className={`${inputCls} font-medium`} value={s.title} onChange={e => update(i, { title: e.target.value })} aria-label="Section title" />
<button onClick={() => move(i, -1)} disabled={i === 0} className="p-1.5 rounded hover:bg-gray-100 disabled:opacity-30" aria-label="Move up"><ArrowUp className="w-4 h-4" /></button>
<button onClick={() => move(i, 1)} disabled={i === sections.length - 1} className="p-1.5 rounded hover:bg-gray-100 disabled:opacity-30" aria-label="Move down"><ArrowDown className="w-4 h-4" /></button>
{original && (original.body !== s.body || original.title !== s.title) && (
<button onClick={() => update(i, { title: original.title, body: original.body })} className="p-1.5 rounded hover:bg-gray-100" title="Restore the built-in text" aria-label="Restore the built-in text"><RotateCcw className="w-4 h-4" /></button>
)}
<button onClick={() => confirm(`Remove "${s.title}"?`) && change(sections.filter((_, j) => j !== i))} className="p-1.5 rounded hover:bg-red-50 text-red-600" aria-label="Remove section"><Trash2 className="w-4 h-4" /></button>
</div>
<textarea className={`${inputCls} font-mono text-xs`} rows={Math.min(18, Math.max(3, s.body.split("\n").length + 1))} value={s.body} onChange={e => update(i, { body: e.target.value })} aria-label={`${s.title} text`} />
</div>
);
})}
<button onClick={() => change([...sections, { id: `section-${sections.length + 1}`, title: "New section", body: "" }])} 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 section
</button>
</div>
)}
{draft.versions.length > 0 && (
<div>
<h3 className="text-sm font-semibold text-gray-900 mb-2">Published versions</h3>
<div className="overflow-x-auto border rounded-xl bg-white">
<table className="w-full text-sm">
<thead className="bg-gray-50 text-left text-xs text-gray-500">
<tr><th className="p-2">Version</th><th className="p-2">Published</th><th className="p-2">What changed</th><th className="p-2">Re-accept</th><th className="p-2 text-right">Accepted by</th></tr>
</thead>
<tbody>
{draft.versions.map(v => (
<tr key={v.version} className="border-t">
<td className="p-2"><Link href={`/legal/privacy?version=${v.version}`} target="_blank" className="text-brand-600 hover:underline">{v.version}</Link></td>
<td className="p-2 whitespace-nowrap">{fmt(v.publishedAt)}{v.publishedBy ? ` · ${v.publishedBy}` : ""}</td>
<td className="p-2 text-gray-600">{v.changeSummary || "—"}</td>
<td className="p-2">{v.requiresAcceptance ? "Yes" : "No"}</td>
<td className="p-2 text-right">{v.acceptances}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,120 @@
"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>
);
}
@@ -0,0 +1,73 @@
"use client";
import React, { useEffect, useState } from "react";
import Link from "next/link";
import { ScrollText } from "lucide-react";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch } from "@/lib/api";
import { errorMessage } from "@/lib/utils";
import type { PublicNotice } from "@/components/shared/PrivacyNoticeSection";
/**
* POPIA: a privacy notice version marked as a significant change has been published, and
* this person accepted an older one. Asked once after login; it can't be waved away, but the
* notice and the Privacy page (to delete the account instead) stay reachable.
*/
export function NoticePrompt({ onDone }: { onDone: () => void }) {
const { token } = useAuth();
const [notice, setNotice] = useState<PublicNotice | null>(null);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
apiFetch<PublicNotice>("/api/privacy/notice").then(setNotice).catch(() => onDone());
}, [onDone]);
if (!notice) return null;
const accept = async () => {
if (!token) return;
setSaving(true);
setError(null);
try {
await apiFetch("/api/privacy/me/notice/accept", { method: "POST", authToken: token });
onDone();
} catch (e) {
setError(errorMessage(e) || "Could not save. Please try again.");
} finally {
setSaving(false);
}
};
// The newest version that needed accepting, for "what changed".
const changes = notice.versions.find(v => v.requiresAcceptance)?.changeSummary || notice.changeSummary;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" role="dialog" aria-modal="true" aria-labelledby="notice-prompt-title">
<div className="w-full max-w-md rounded-xl bg-white p-6 shadow-xl">
<div className="flex items-center gap-2 mb-3">
<div className="w-9 h-9 rounded-lg flex items-center justify-center bg-brand-50 text-brand-600 shrink-0">
<ScrollText className="w-5 h-5" />
</div>
<h2 id="notice-prompt-title" className="text-lg font-semibold text-gray-900">We&apos;ve updated our privacy notice</h2>
</div>
{changes && (
<div className="mb-3 p-3 rounded-lg bg-gray-50 border text-sm text-gray-700 whitespace-pre-line">
<span className="font-medium">What changed: </span>{changes}
</div>
)}
<p className="text-sm text-gray-600 mb-5">
Please read the <Link href="/legal/privacy" target="_blank" className="text-brand-600 hover:underline">privacy notice</Link> and
accept it to carry on using your account. If you don&apos;t agree, you can{" "}
<Link href="/dashboard/user/privacy" className="text-brand-600 hover:underline">delete your account</Link> instead.
</p>
{error && <p className="text-sm text-red-600 mb-3">{error}</p>}
<div className="flex justify-end">
<button onClick={accept} disabled={saving} className="px-4 py-2 rounded-lg bg-brand-600 text-white text-sm font-medium hover:bg-brand-700 disabled:opacity-50">
{saving ? "Saving…" : "I accept"}
</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,59 @@
"use client";
import React from "react";
import { CookieSettingsButton } from "@/components/shared/CookieSettingsButton";
export type NoticeSection = { id: string; title: string; body: string };
export type PublicNotice = {
version: number;
builtIn: boolean;
current: boolean;
publishedAt: string | null;
effectiveDate: string;
changeSummary: string | null;
sections: NoticeSection[];
versions: { version: number; publishedAt: string; changeSummary: string | null; requiresAcceptance: boolean }[];
};
// Email addresses, web addresses and bare "www." names in notice text become links.
const LINK_RE = /([\w.+-]+@[\w-]+\.[\w.]+|https?:\/\/[^\s)]+|www\.[\w-]+\.[\w.]+)/g;
function linkify(line: string) {
const parts = line.split(LINK_RE);
return parts.map((part, i) => {
if (i % 2 === 0) return part;
const href = part.includes("@") && !part.startsWith("http") ? `mailto:${part}` : part.startsWith("http") ? part : `https://${part}`;
return (
<a key={i} href={href} className="text-brand-600 hover:underline break-all" {...(href.startsWith("http") ? { target: "_blank", rel: "noopener noreferrer" } : {})}>
{part}
</a>
);
});
}
/** One section of the privacy notice: one paragraph per line, "• " lines as bullets. */
export function PrivacyNoticeSection({ section, first = false }: { section: NoticeSection; first?: boolean }) {
const lines = section.body.split("\n").filter(l => l.trim() !== "");
return (
<section id={section.id} className="mb-10 scroll-mt-24">
{first ? (
<h2 className="text-2xl font-semibold text-brand-600 mb-4">{section.title}</h2>
) : (
<h3 className="text-xl font-semibold text-brand-600 mb-3">{section.title}</h3>
)}
{section.id === "cookies" && (
<CookieSettingsButton className="mb-3 px-3 py-1.5 text-sm rounded-lg border border-brand-300 text-brand-700 hover:bg-brand-50">
Change my cookie choice
</CookieSettingsButton>
)}
{lines.map((line, i) =>
line.startsWith("• ") ? (
<p key={i} className="mb-1.5 pl-5 -indent-3">{"• "}{linkify(line.slice(2))}</p>
) : (
<p key={i} className="mb-2">{linkify(line)}</p>
),
)}
</section>
);
}
@@ -0,0 +1,23 @@
"use client";
import React, { useCallback, useState } from "react";
import { usePathname } from "next/navigation";
import { useAuth } from "@/hooks/useAuth";
import { NoticePrompt } from "@/components/shared/NoticePrompt";
import { NewsPrompt } from "@/components/shared/NewsPrompt";
/**
* The one-time POPIA questions after login, one at a time: accept a new privacy notice
* version first, then the news opt-in. Neither shows on the Privacy page itself, so someone
* can always get to "Delete my account".
*/
export function PrivacyPrompts() {
const { user } = useAuth();
const pathname = usePathname();
const [noticeDone, setNoticeDone] = useState(false);
const finishNotice = useCallback(() => setNoticeDone(true), []);
if (pathname === "/dashboard/user/privacy") return null;
if (user?.noticePromptPending && !noticeDone) return <NoticePrompt onDone={finishNotice} />;
return <NewsPrompt />;
}
+45 -1
View File
@@ -1,5 +1,5 @@
import React from "react";
import { Megaphone, Download, PencilLine, Trash2, MailX, Inbox, Clock, ShieldCheck, MessageCircle } from "lucide-react";
import { Megaphone, Download, PencilLine, Trash2, MailX, Inbox, Clock, ShieldCheck, MessageCircle, ScrollText, Send, ListChecks, Building2, Eye } from "lucide-react";
import { GuideItem } from "@/components/shared/GuideItem";
import type { HelpContent } from "./types";
@@ -47,6 +47,50 @@ 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.",
tabs: [
{
key: "notice",
label: "Privacy notice",
icon: ScrollText,
content: (
<div className="space-y-4">
<GuideItem icon={ScrollText} title="Editing" tone="brand">
The notice starts from the built-in text. Change any section, add or remove sections, and save a draft — nothing is public until you publish. Placeholders like {"{org}"}, {"{ioName}"} and {"{operators}"} are filled in from Site settings and the operator register, so they stay up to date.
</GuideItem>
<GuideItem icon={Send} title="Publishing" tone="green">
Each publish is a numbered version with its date, and earlier versions stay readable on the privacy page. Tick &quot;Significant change&quot; and say what changed when people should accept it again — they&apos;re asked once, after they next log in. Leave it unticked for small fixes.
</GuideItem>
<GuideItem icon={ShieldCheck} title="Not legal advice" tone="amber">
The tools help you comply; your Information Officer should check the wording.
</GuideItem>
</div>
),
},
{
key: "records",
label: "Operators & logs",
icon: ListChecks,
content: (
<div className="space-y-4">
<GuideItem icon={Building2} title="Operators" tone="brand">
The services that process personal information for you: payment providers, WhatsApp and email delivery, hosting. Check the details of each — especially your email and hosting provider. Ones marked &quot;in use&quot; are listed in the privacy notice.
</GuideItem>
<GuideItem icon={ListChecks} title="Consent log" tone="green">
Proof of consent: every news opt-in and opt-out (and how — sign-up, unsubscribe link, STOP reply…), every privacy notice acceptance, and every parental consent answer.
</GuideItem>
<GuideItem icon={Eye} title="Access log" tone="violet">
Who downloaded, emailed or opened personal information in bulk — reports, attendance registers, certificates, group lists, form answers, consent and medical lists, backups — and every &quot;view as&quot; session.
</GuideItem>
</div>
),
},
],
};
/** Help content for /dashboard/admin/data-requests. */
export const adminDataRequestsHelpContent: HelpContent = {
title: "Data requests",
+6 -1
View File
@@ -26,7 +26,7 @@ import { supervisorBundlesHelpContent } from "./supervisor-bundles";
import { supervisorConsentHelpContent } from "./supervisor-consent";
import { supervisorWaitlistHelpContent } from "./supervisor-waitlist";
import { supervisorAttendanceHelpContent } from "./supervisor-attendance";
import { userPrivacyHelpContent, adminDataRequestsHelpContent } from "./privacy";
import { userPrivacyHelpContent, adminDataRequestsHelpContent, adminPrivacyHelpContent } from "./privacy";
import { supervisorOfflineScansHelpContent } from "./supervisor-offline-scans";
import { supervisorAllocationsHelpContent, staffMyGroupsHelpContent } from "./supervisor-allocations";
import { supervisorEmailAttendeesHelpContent } from "./supervisor-email-attendees";
@@ -185,6 +185,11 @@ const REGISTRY: HelpRegistryEntry[] = [
matches: pathname => pathname === "/dashboard/user/privacy",
content: userPrivacyHelpContent,
},
{
slug: "admin-privacy",
matches: pathname => pathname === "/dashboard/admin/privacy",
content: adminPrivacyHelpContent,
},
{
slug: "admin-data-requests",
matches: pathname => pathname === "/dashboard/admin/data-requests",
+2
View File
@@ -11,6 +11,8 @@ export type AuthUser = {
twoFactorRequired?: boolean;
// POPIA: hasn't answered the "news and announcements" opt-in question yet (asked once).
newsPromptPending?: boolean;
// POPIA: a privacy notice version they must accept has been published since they last did.
noticePromptPending?: boolean;
};
export type AuthResponse = AuthUser & {