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:
@@ -7,6 +7,21 @@ and this project follows [Semantic Versioning](https://semver.org/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- **POPIA toolkit, part 2: the admin side.** A new **Admin → Privacy (POPIA)** page with four tabs:
|
||||
- **Privacy notice editor.** The privacy notice is now editable. It starts from the built-in text (updated for 1.38.0's privacy page, news opt-in, unsubscribe and STOP), one box per section; add, remove and reorder sections, save drafts, preview, and **publish numbered versions**. Placeholders such as `{org}`, `{ioName}`, `{ioEmail}`, `{medicalRetentionDays}` and `{operators}` are filled in when the notice is shown, so it follows Site settings and the operator register. Earlier versions stay readable on the privacy page (`/legal/privacy?version=N`).
|
||||
- **Re-accepting the notice.** When publishing, tick "Significant change" (and say what changed) to ask everyone to accept the new version: they're asked once, after their next login, with a link to delete their account instead. Sign-up records which version was accepted. Acceptances are counted per version.
|
||||
- **Operators** — the register of outside services that handle personal information (Yoco, PayFast, WAWP, WhatsApp, email and hosting providers, Google Maps, Sentry), with what each receives and where. Those in use are listed in the notice.
|
||||
- **Consent log** — every news opt-in/opt-out (and how), every privacy notice acceptance, and every parental consent answer, searchable by person.
|
||||
- **Access log** — who downloaded, emailed or opened personal information in bulk: report PDFs/Excel files (and reports emailed or WhatsApped), attendance registers, certificate downloads, room/group lists, attendee form answers, the parental consent and medical lists, database backups, and every "view as" session. With how many records each time.
|
||||
- The contact page shows the Information Officer (when set in Site settings → Legal), with a link to the privacy notice.
|
||||
- "Download my data" includes the privacy notice versions you accepted.
|
||||
|
||||
### Changed
|
||||
|
||||
- Deleting someone's personal data also removes the IP addresses kept with their consent and notice records (the records stay as proof).
|
||||
|
||||
## [1.38.0] - 2026-10-23
|
||||
|
||||
### Added
|
||||
|
||||
+21
-1
@@ -572,6 +572,26 @@ Event updates say why they were sent and link to the preferences. A STOP reply o
|
||||
WhatsApp turns off WhatsApp news (through WAWP's `message` webhook, checked against a
|
||||
secret in the webhook URL).
|
||||
|
||||
**Privacy notice** (`/legal/privacy`) — edited in **Admin → Privacy (POPIA)**
|
||||
(`/dashboard/admin/privacy`): sections start from the built-in text; drafts, preview,
|
||||
and published, numbered versions (earlier ones stay readable with `?version=N`).
|
||||
Placeholders (`{org}`, `{ioName}`, `{ioEmail}`, `{website}`, `{orgEmail}`,
|
||||
`{medicalRetentionDays}`, `{operators}`) are filled in from Site settings and the
|
||||
operator register. A version marked as a significant change is accepted once by everyone
|
||||
after their next login; sign-up records the version accepted. The contact page shows the
|
||||
Information Officer.
|
||||
|
||||
**Registers and logs** (same admin page):
|
||||
|
||||
- **Operators** — the services that process personal information (payment, WhatsApp,
|
||||
email, hosting, maps, error monitoring), what each receives and where; those in use
|
||||
are listed in the notice.
|
||||
- **Consent log** — news opt-ins/opt-outs, privacy notice acceptances and parental consent
|
||||
answers, searchable.
|
||||
- **Access log** — every bulk export or view of personal information (reports, attendance
|
||||
registers, certificates, group lists, form answers, consent and medical lists, backups)
|
||||
and every "view as" session, with the number of records.
|
||||
|
||||
**For admins** — **Data requests** (`/dashboard/admin/data-requests`): correction and
|
||||
deletion requests with a 30-day due date, emailed to the Information Officer and
|
||||
admins, reminded daily when due within a week. Completing a deletion anonymises the
|
||||
@@ -663,4 +683,4 @@ can't start, the same jobs run on in-process timers.
|
||||
| `/dashboard/user` (+ `/payments`, `/donate`, `/forms`, `/profile`, `/privacy`, `/reset-password`) | User | My Events |
|
||||
| `/dashboard/staff` (+ `/ticket-scanning`, `/event-tickets`) | Staff | Staff tools |
|
||||
| `/dashboard/supervisor` (+ `/events`, `/event-options`, `/at-the-door`, `/manual`, `/manual-registration`, `/payments`, `/coupons`, `/waitlist`, `/attendance`, `/consent`, `/offline-scans`, `/allocations`, `/email-attendees`, `/whatsapp-attendees`, `/forms`, `/reports`) | Supervisor | Supervisor tools |
|
||||
| `/dashboard/admin` (+ `/users`, `/registrations`, `/forms`, `/cashup`, `/cashup/[id]`, `/audit-log`, `/data-requests`, `/settings`) | Admin | Admin tools |
|
||||
| `/dashboard/admin` (+ `/users`, `/registrations`, `/forms`, `/cashup`, `/cashup/[id]`, `/audit-log`, `/data-requests`, `/privacy`, `/settings`) | Admin | Admin tools |
|
||||
|
||||
+2
-1
@@ -79,7 +79,7 @@ Managed in the Payments tab (through `/api/payments/providers/settings` and `/ap
|
||||
| `legal_io_name` | `"[not yet configured]"` | Same reasoning as `legal_operator_name`. |
|
||||
| `legal_io_email` | `"io@example.com"` | Rendered as a `mailto:` link, so the placeholder stays a syntactically valid address on the IANA-reserved `example.com` domain rather than a bracketed non-email string that would break the link. |
|
||||
| `legal_website_url` | `"example.com"` | Same reasoning — used to build an `https://` link. |
|
||||
| `legal_effective_date` | `"[not yet set]"` | Plain text only, no link — a bracketed placeholder is fine here. |
|
||||
| `legal_effective_date` | `"[not yet set]"` | Plain text only, no link — a bracketed placeholder is fine here. Only used by the built-in privacy notice: once a version is published in Admin → Privacy, its publish date is the effective date. |
|
||||
| `notify_user_on_impersonation` | `"false"` | When `"true"`, a user is emailed (with the reason) whenever an admin views their account with Impersonate. Set under Site Settings → Legal → Support access. |
|
||||
| `consent_medical_retention_days` | `30` | Youth events: medical and emergency details from parental consent forms are deleted this many days after the event ends (daily 09:00 job). The consent record itself — who agreed, when, to which text — is kept. Set under Site Settings → Legal. |
|
||||
| `org_email` (as used on legal pages specifically) | `"privacy@example.com"` | Different fallback than `org_email`'s general default (blank) — also rendered as a `mailto:` link, so it needs to stay a valid-looking address. |
|
||||
@@ -97,6 +97,7 @@ Managed in the Payments tab (through `/api/payments/providers/settings` and `/ap
|
||||
|---|---|---|
|
||||
| `legal_io_email` (Information Officer, Site Settings → Legal) | — | Also where new privacy requests and the daily "requests due" reminder are emailed, together with all admins. The `io@example.com` placeholder is skipped. |
|
||||
| `PRIVACY_JOBS_ENABLED` (env) | `true` | The daily 08:00 `privacy` job that reminds admins of requests due within a week or overdue. |
|
||||
| `privacy_notice_draft` | — | The unpublished draft from Admin → Privacy → Privacy notice (JSON). Published versions are in the `PrivacyNotice` table. With no published version the built-in notice (`backend/src/utils/privacyNotice.js`) is shown. The notice's org name, Information Officer, website, emails and medical retention days come from the settings above when it's shown. |
|
||||
|
||||
People's News & announcements opt-in is per account (`User.newsEmail`, `User.newsWhatsApp`), not a site setting. Unsubscribe links are signed with `JWT_SECRET` and don't expire — changing `JWT_SECRET` breaks links in emails already sent.
|
||||
|
||||
|
||||
@@ -568,6 +568,25 @@ The data subject's tools, unsubscribe links and the admin data-requests inbox (`
|
||||
| POST | `/api/privacy/requests/:id/complete` | admin | `{ response? }` (required for corrections; emailed). Deletion: anonymises the account — 409 while it has blockers. Audited as `data_request_completed` (+ `user_anonymised`) |
|
||||
| POST | `/api/privacy/requests/:id/reject` | admin | `{ response, cancelled?: bool }` — reject (or mark withdrawn); a deletion reopens the account. Audited as `data_request_rejected` |
|
||||
|
||||
Privacy notice, operators and logs (`src/utils/privacyNotice.js`, `src/utils/accessLog.js`, `src/controllers/privacyAdminController.js`):
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| GET | `/api/privacy/notice?version=N` | public | The notice (current, or version N) with placeholders filled in: `{ version, builtIn, current, effectiveDate, changeSummary, sections: [{ id, title, body }], versions }`. Version 0 = the built-in notice, only while nothing is published |
|
||||
| POST | `/api/privacy/me/notice/accept` | user | Accept the current version (recorded in `NoticeAcceptance`, `User.noticeVersionAccepted`). 403 while impersonating |
|
||||
| GET | `/api/privacy/notice/draft` | admin | Editor state: `sections` (raw, with placeholders), `rendered`, `isDraft`, `savedAt`, `versions` (with acceptance counts), `builtIn` (the built-in sections), `placeholders` |
|
||||
| PUT | `/api/privacy/notice/draft` | admin | `{ sections }` — save the draft (AppSetting `privacy_notice_draft`) |
|
||||
| DELETE | `/api/privacy/notice/draft` | admin | Discard the draft |
|
||||
| POST | `/api/privacy/notice/publish` | admin | `{ sections, changeSummary?, requiresAcceptance? }` — publish the next version and clear the draft. `requiresAcceptance` needs a `changeSummary`. Audited as `privacy_notice_published` |
|
||||
| GET / POST | `/api/privacy/operators` | admin | The operator register / add one `{ name, purpose, dataShared?, location?, website?, active? }` |
|
||||
| PUT / DELETE | `/api/privacy/operators/:id` | admin | Change / remove an operator (audited as `operator_*`) |
|
||||
| GET | `/api/privacy/consent-log?type=news\|notice\|parental&q=&page=` | admin | News opt-in changes, notice acceptances, or parental consent answers (50 per page) |
|
||||
| GET | `/api/privacy/access-log?action=&actorId=&from=&to=&page=` | admin | Bulk exports/views of personal information, with `actions` (labels) and `actors` for the filters |
|
||||
|
||||
Placeholders in notice sections: `{org}`, `{orgEmail}`, `{website}`, `{ioName}`, `{ioEmail}`, `{medicalRetentionDays}`, and `{operators}` on a line of its own (one bullet per active operator). Login and `GET /api/users/profile` return `noticePromptPending` — true when a version with `requiresAcceptance` is newer than the one the person accepted. Sign-up records an acceptance of the current version (source `signup`).
|
||||
|
||||
Access log actions (`DataAccessLog.action`): `report_pdf`, `report_excel`, `report_email`, `report_whatsapp` (`/api/reports/*`, with the row count), `attendance_export`, `certificates_download`, `group_lists` (or `medical_list` with `?medical=true`), `form_responses` (first page of a list), `consent_list`, `medical_list`, `backup_download`, `impersonation`.
|
||||
|
||||
Notes:
|
||||
- Requests are due 30 days after they're made. The `privacy` job (08:00, `PRIVACY_JOBS_ENABLED=false` to disable) emails the Information Officer (`legal_io_email`) and admins about open requests due within 7 days or overdue.
|
||||
- **Anonymising** (also `POST /api/users/:id/anonymize`) keeps payments, registrations, tickets and check-ins, and removes: the account's name/email/phone/password, the details of attendees on their registrations and their own attendee rows elsewhere, parental-consent details, form answers and drafts, feedback authorship and invites, waiting waitlist places, pending transfer requests, names on transfers and certificates, sponsor names/messages, login history, password resets and 2FA challenges, and payer fields in provider webhook payloads and checkouts.
|
||||
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
-- AlterEnum
|
||||
-- This migration adds more than one value to an enum.
|
||||
-- With PostgreSQL versions 11 and earlier, this is not possible
|
||||
-- in a single migration. This can be worked around by creating
|
||||
-- multiple migrations, each migration adding only one value to
|
||||
-- the enum.
|
||||
|
||||
|
||||
ALTER TYPE "AdminAuditAction" ADD VALUE 'privacy_notice_published';
|
||||
ALTER TYPE "AdminAuditAction" ADD VALUE 'operator_created';
|
||||
ALTER TYPE "AdminAuditAction" ADD VALUE 'operator_updated';
|
||||
ALTER TYPE "AdminAuditAction" ADD VALUE 'operator_deleted';
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "User" ADD COLUMN "noticeVersionAccepted" INTEGER;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "PrivacyNotice" (
|
||||
"id" TEXT NOT NULL,
|
||||
"version" INTEGER NOT NULL,
|
||||
"sections" JSONB NOT NULL,
|
||||
"changeSummary" TEXT,
|
||||
"requiresAcceptance" BOOLEAN NOT NULL DEFAULT false,
|
||||
"publishedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"publishedById" TEXT,
|
||||
|
||||
CONSTRAINT "PrivacyNotice_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "NoticeAcceptance" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT,
|
||||
"version" INTEGER NOT NULL,
|
||||
"source" TEXT NOT NULL,
|
||||
"ip" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "NoticeAcceptance_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Operator" (
|
||||
"id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"purpose" TEXT NOT NULL,
|
||||
"dataShared" TEXT NOT NULL,
|
||||
"location" TEXT,
|
||||
"website" TEXT,
|
||||
"active" BOOLEAN NOT NULL DEFAULT true,
|
||||
"order" INTEGER NOT NULL DEFAULT 0,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "Operator_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "DataAccessLog" (
|
||||
"id" TEXT NOT NULL,
|
||||
"actorId" TEXT,
|
||||
"actorRole" TEXT NOT NULL,
|
||||
"action" TEXT NOT NULL,
|
||||
"what" TEXT NOT NULL,
|
||||
"eventId" TEXT,
|
||||
"records" INTEGER,
|
||||
"ip" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "DataAccessLog_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "PrivacyNotice_version_key" ON "PrivacyNotice"("version");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "NoticeAcceptance_userId_createdAt_idx" ON "NoticeAcceptance"("userId", "createdAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "NoticeAcceptance_version_idx" ON "NoticeAcceptance"("version");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "DataAccessLog_createdAt_idx" ON "DataAccessLog"("createdAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "DataAccessLog_actorId_createdAt_idx" ON "DataAccessLog"("actorId", "createdAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "DataAccessLog_action_createdAt_idx" ON "DataAccessLog"("action", "createdAt");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "PrivacyNotice" ADD CONSTRAINT "PrivacyNotice_publishedById_fkey" FOREIGN KEY ("publishedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "NoticeAcceptance" ADD CONSTRAINT "NoticeAcceptance_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "DataAccessLog" ADD CONSTRAINT "DataAccessLog_actorId_fkey" FOREIGN KEY ("actorId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- The operator register starts with the services Hope Events itself uses. Admins correct the
|
||||
-- details (e.g. which email and hosting provider) in Admin → Privacy → Operators.
|
||||
INSERT INTO "Operator" ("id", "name", "purpose", "dataShared", "location", "website", "active", "order", "updatedAt") VALUES
|
||||
(gen_random_uuid()::text, 'Yoco', 'Card payments', 'Payment amount and reference; card details are entered on Yoco''s own page', 'South Africa', 'https://www.yoco.com', true, 1, CURRENT_TIMESTAMP),
|
||||
(gen_random_uuid()::text, 'PayFast', 'Online payments', 'Name, email address, payment amount and reference', 'South Africa', 'https://payfast.io', true, 2, CURRENT_TIMESTAMP),
|
||||
(gen_random_uuid()::text, 'WAWP', 'Delivering WhatsApp messages', 'Mobile number and message content', NULL, 'https://wawp.net', true, 3, CURRENT_TIMESTAMP),
|
||||
(gen_random_uuid()::text, 'WhatsApp (Meta Platforms)', 'The WhatsApp messaging service', 'Mobile number and message content', NULL, 'https://www.whatsapp.com/legal/privacy-policy', true, 4, CURRENT_TIMESTAMP),
|
||||
(gen_random_uuid()::text, 'Email delivery provider', 'Sending emails (the SMTP server in Site settings)', 'Name, email address and message content', NULL, NULL, true, 5, CURRENT_TIMESTAMP),
|
||||
(gen_random_uuid()::text, 'Hosting provider', 'The servers this site and its database run on', 'All information held by the site', 'South Africa', NULL, true, 6, CURRENT_TIMESTAMP),
|
||||
(gen_random_uuid()::text, 'Google Maps', 'Maps on event pages', 'Your device''s IP address and browser details when a map loads', NULL, 'https://policies.google.com/privacy', true, 7, CURRENT_TIMESTAMP),
|
||||
(gen_random_uuid()::text, 'Sentry', 'Error monitoring (only when it is set up)', 'Technical error reports, which can include the signed-in user''s id', NULL, 'https://sentry.io', false, 8, CURRENT_TIMESTAMP);
|
||||
@@ -81,6 +81,9 @@ model User {
|
||||
newsWhatsApp Boolean @default(false)
|
||||
// When the person last answered the news opt-in question. Null = ask at next login.
|
||||
newsAskedAt DateTime?
|
||||
// The privacy notice version they last accepted (0 = the built-in notice). Null = never
|
||||
// recorded. Asked again when a version that needs re-acceptance is newer.
|
||||
noticeVersionAccepted Int?
|
||||
registrations Registration[]
|
||||
payments Payment[]
|
||||
paymentsRecorded Payment[] @relation("PaymentRecordedBy")
|
||||
@@ -130,6 +133,9 @@ model User {
|
||||
communicationConsents CommunicationConsent[]
|
||||
dataRequests DataRequest[]
|
||||
dataRequestsHandled DataRequest[] @relation("DataRequestHandledBy")
|
||||
noticesPublished PrivacyNotice[]
|
||||
noticeAcceptances NoticeAcceptance[]
|
||||
dataAccessLogs DataAccessLog[]
|
||||
}
|
||||
|
||||
model Event {
|
||||
@@ -583,6 +589,10 @@ enum AdminAuditAction {
|
||||
data_request_completed
|
||||
data_request_rejected
|
||||
user_anonymised
|
||||
privacy_notice_published
|
||||
operator_created
|
||||
operator_updated
|
||||
operator_deleted
|
||||
}
|
||||
|
||||
// Append-only audit trail for admin/supervisor-initiated actions with money or
|
||||
@@ -1651,3 +1661,73 @@ model DataRequest {
|
||||
@@index([status, dueAt])
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
// A published version of the privacy notice (versions start at 1). With none published,
|
||||
// the built-in notice from utils/privacyNotice.js is shown as version 0. Section bodies
|
||||
// keep their {placeholders} (org name, Information Officer, operators…), filled in when
|
||||
// shown, so the notice follows Site settings and the operator register.
|
||||
model PrivacyNotice {
|
||||
id String @id @default(uuid())
|
||||
version Int @unique
|
||||
sections Json // [{ id, title, body }]
|
||||
changeSummary String?
|
||||
// Everyone is asked to accept this version again after logging in.
|
||||
requiresAcceptance Boolean @default(false)
|
||||
publishedAt DateTime @default(now())
|
||||
publishedById String?
|
||||
publishedBy User? @relation(fields: [publishedById], references: [id], onDelete: SetNull)
|
||||
}
|
||||
|
||||
// Someone accepted a version of the privacy notice. Append-only proof, like
|
||||
// CommunicationConsent.
|
||||
model NoticeAcceptance {
|
||||
id String @id @default(uuid())
|
||||
userId String?
|
||||
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
|
||||
version Int
|
||||
source String // signup | prompt
|
||||
ip String?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([userId, createdAt])
|
||||
@@index([version])
|
||||
}
|
||||
|
||||
// A third party that processes personal information for the church (POPIA "operator"):
|
||||
// payment providers, WhatsApp and email delivery, hosting… Active ones are listed in the
|
||||
// privacy notice.
|
||||
model Operator {
|
||||
id String @id @default(uuid())
|
||||
name String
|
||||
purpose String
|
||||
dataShared String
|
||||
location String?
|
||||
website String?
|
||||
active Boolean @default(true)
|
||||
order Int @default(0)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
// Someone exported or viewed personal information in bulk: report exports, the attendance
|
||||
// register, certificate downloads, group lists, form answers, the consent and medical lists,
|
||||
// backups, and viewing the site as another user.
|
||||
model DataAccessLog {
|
||||
id String @id @default(uuid())
|
||||
actorId String?
|
||||
actor User? @relation(fields: [actorId], references: [id], onDelete: SetNull)
|
||||
actorRole String
|
||||
// report_pdf | report_excel | report_email | report_whatsapp | attendance_export |
|
||||
// certificates_download | group_lists | form_responses | consent_list | medical_list |
|
||||
// backup_download | impersonation
|
||||
action String
|
||||
what String
|
||||
eventId String?
|
||||
records Int?
|
||||
ip String?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([createdAt])
|
||||
@@index([actorId, createdAt])
|
||||
@@index([action, createdAt])
|
||||
}
|
||||
|
||||
@@ -287,6 +287,10 @@ const getSetLists = async (req, res) => {
|
||||
if (!set) throw allocationError('Allocation set not found', 404);
|
||||
const withMedical = req.query.medical === 'true';
|
||||
const members = await groupMembers(set.groups.map(g => g.id), { withMedical });
|
||||
require('../utils/accessLog').logDataAccess(req, {
|
||||
action: withMedical ? 'medical_list' : 'group_lists', what: `${set.name} lists — ${set.event?.title || 'event'}${withMedical ? ' (with medical details)' : ''}`,
|
||||
eventId: set.eventId, records: members.length,
|
||||
});
|
||||
if (withMedical) {
|
||||
logAdminAction({ actorId: req.user.id, actorRole: req.user.role, action: 'medical_list_viewed', targetType: 'AllocationSet', targetId: set.id, metadata: { eventId: set.eventId, attendees: members.length }, ip: getClientIp(req) });
|
||||
}
|
||||
|
||||
@@ -175,6 +175,10 @@ const exportRegister = async (req, res) => {
|
||||
try {
|
||||
const reg = await buildRegister(req.params.eventId);
|
||||
const cols = reg.columns.filter(c => !c.cancelled);
|
||||
require('../utils/accessLog').logDataAccess(req, {
|
||||
action: 'attendance_export', what: `Attendance register — ${reg.event.title} (${req.query.format === 'pdf' ? 'PDF' : 'Excel'})`,
|
||||
eventId: req.params.eventId, records: reg.rows.length,
|
||||
});
|
||||
if (req.query.format === 'pdf') {
|
||||
const doc = new PDFDocument({ size: 'A4', layout: 'landscape', margin: 36 });
|
||||
res.setHeader('Content-Type', 'application/pdf');
|
||||
@@ -320,6 +324,7 @@ const downloadEventCertificates = async (req, res) => {
|
||||
const certs = await prisma.certificate.findMany({ where: { eventId: event.id, revokedAt: null }, orderBy: { name: 'asc' } });
|
||||
if (certs.length === 0) throw badRequest('No certificates have been issued for this event yet.');
|
||||
const pdf = await certificates.renderCertificatesPdf(certs, event);
|
||||
require('../utils/accessLog').logDataAccess(req, { action: 'certificates_download', what: `Certificates — ${event.title}`, eventId: event.id, records: certs.length });
|
||||
res.setHeader('Content-Type', 'application/pdf');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${`Certificates - ${event.title}`.replace(/[^a-z0-9 -]/gi, '').slice(0, 80)}.pdf"`);
|
||||
res.send(pdf);
|
||||
|
||||
@@ -44,6 +44,7 @@ const downloadBackup = async (req, res) => {
|
||||
res.status(404);
|
||||
throw new Error('Backup not found');
|
||||
}
|
||||
require('../utils/accessLog').logDataAccess(req, { action: 'backup_download', what: `Database backup ${filename}` });
|
||||
res.download(filePath, filename);
|
||||
} catch (e) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(e) });
|
||||
|
||||
@@ -156,6 +156,7 @@ const listEventConsent = async (req, res) => {
|
||||
const { event, rows } = await consentRows(req.params.eventId);
|
||||
if (!event) { res.status(404); throw new Error('Event not found'); }
|
||||
const counts = rows.reduce((c, r) => { c[r.status] = (c[r.status] || 0) + 1; return c; }, {});
|
||||
require('../utils/accessLog').logDataAccess(req, { action: 'consent_list', what: `Parental consent — ${event.title}`, eventId: event.id, records: rows.length });
|
||||
res.json({
|
||||
event: { id: event.id, title: event.title, startDate: event.startDate, requiresParentalConsent: event.requiresParentalConsent, consentTextVersion: event.consentTextVersion },
|
||||
counts,
|
||||
@@ -255,6 +256,7 @@ const medicalList = async (req, res) => {
|
||||
targetType: 'Event', targetId: event.id, metadata: { attendees: list.length },
|
||||
ip: getClientIp(req),
|
||||
});
|
||||
require('../utils/accessLog').logDataAccess(req, { action: 'medical_list', what: `Medical list — ${event.title}`, eventId: event.id, records: list.length });
|
||||
res.json({ event: { id: event.id, title: event.title, startDate: event.startDate }, rows: list });
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
|
||||
@@ -43,6 +43,17 @@ const listFormResponses = async (req, res) => {
|
||||
|
||||
const nextCursor = responses.length === take ? responses[responses.length - 1].id : null;
|
||||
|
||||
// The first page of a list (not one registration's own form, nor the next pages of the
|
||||
// same list) is logged as a bulk view of form answers.
|
||||
if (!registrationId && !cursor && responses.length) {
|
||||
require('../utils/accessLog').logDataAccess(req, {
|
||||
action: 'form_responses',
|
||||
what: `Attendee form answers${responses[0].registration?.event && eventId ? ` — ${responses[0].registration.event.title}` : ''}`,
|
||||
eventId: eventId ? String(eventId) : null,
|
||||
records: responses.length,
|
||||
});
|
||||
}
|
||||
|
||||
res.json({ items: responses, nextCursor });
|
||||
} catch (error) {
|
||||
res.status(400).json({ message: error.message });
|
||||
|
||||
@@ -30,6 +30,7 @@ const startImpersonation = async (req, res) => {
|
||||
actorId: req.user.id, actorRole: req.user.role, action: 'impersonation_started', targetType: 'User', targetId: target.id,
|
||||
metadata: { sessionId: session.id, reason: session.reason, targetName: target.name, expiresAt }, ip: getClientIp(req),
|
||||
});
|
||||
require('../utils/accessLog').logDataAccess(req, { action: 'impersonation', what: `Viewed as ${target.name} — ${session.reason}` });
|
||||
|
||||
// Optional: tell the user (Site Settings → Security).
|
||||
const { getSettingSync } = require('../utils/settingsCache');
|
||||
|
||||
@@ -0,0 +1,299 @@
|
||||
const prisma = require('../config/db');
|
||||
const { safeErrorMessage } = require('../utils/errorUtils');
|
||||
const { getClientIp } = require('../utils/requestUtils');
|
||||
const { logAdminAction } = require('../utils/adminAudit');
|
||||
const { ACCESS_ACTIONS } = require('../utils/accessLog');
|
||||
const notice = require('../utils/privacyNotice');
|
||||
|
||||
const fail = (res, error) => res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
const page = req => Math.max(parseInt(req.query.page, 10) || 1, 1);
|
||||
|
||||
// ─── Privacy notice ───────────────────────────────────────────────────────────
|
||||
|
||||
// @desc The privacy notice as published (current, or ?version=N), placeholders filled in
|
||||
// @route GET /api/privacy/notice
|
||||
// @access Public
|
||||
const getNotice = async (req, res) => {
|
||||
try {
|
||||
const version = req.query.version != null ? parseInt(req.query.version, 10) : null;
|
||||
if (req.query.version != null && !(version >= 0)) { res.status(400); throw new Error('Invalid version'); }
|
||||
const result = await notice.publicNotice(version);
|
||||
if (!result) { res.status(404); throw new Error('That version of the privacy notice does not exist.'); }
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Accept the current privacy notice
|
||||
// @route POST /api/privacy/me/notice/accept
|
||||
// @access Private
|
||||
const acceptMyNotice = async (req, res) => {
|
||||
try {
|
||||
if (req.impersonation) { res.status(403); throw new Error('Not available while viewing as another user.'); }
|
||||
const version = await notice.acceptNotice(req.user.id, { source: 'prompt', ip: getClientIp(req) });
|
||||
res.json({ version });
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
async function versionList() {
|
||||
const [versions, counts] = await Promise.all([
|
||||
prisma.privacyNotice.findMany({
|
||||
orderBy: { version: 'desc' },
|
||||
select: { version: true, publishedAt: true, changeSummary: true, requiresAcceptance: true, publishedBy: { select: { name: true } } },
|
||||
}),
|
||||
prisma.noticeAcceptance.groupBy({ by: ['version'], _count: { _all: true } }),
|
||||
]);
|
||||
const byVersion = new Map(counts.map(c => [c.version, c._count._all]));
|
||||
return versions.map(v => ({ ...v, publishedBy: v.publishedBy?.name || null, acceptances: byVersion.get(v.version) || 0 }));
|
||||
}
|
||||
|
||||
// @desc The editor: the draft (or current text), how it reads, and the published versions
|
||||
// @route GET /api/privacy/notice/draft
|
||||
// @access Private/Admin
|
||||
const getNoticeDraft = async (req, res) => {
|
||||
try {
|
||||
const draft = await notice.getDraft();
|
||||
const ctx = await notice.noticeContext();
|
||||
res.json({
|
||||
...draft,
|
||||
rendered: notice.renderSections(draft.sections, ctx),
|
||||
placeholders: notice.PLACEHOLDERS,
|
||||
versions: await versionList(),
|
||||
builtIn: notice.defaultSections(),
|
||||
});
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Save the draft (body.sections)
|
||||
// @route PUT /api/privacy/notice/draft
|
||||
// @access Private/Admin
|
||||
const saveNoticeDraft = async (req, res) => {
|
||||
try {
|
||||
const sections = await notice.saveDraft(req.body?.sections);
|
||||
res.json({ sections, rendered: notice.renderSections(sections, await notice.noticeContext()), isDraft: true, savedAt: new Date() });
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Throw the draft away (back to the current published text)
|
||||
// @route DELETE /api/privacy/notice/draft
|
||||
// @access Private/Admin
|
||||
const discardNoticeDraft = async (req, res) => {
|
||||
try {
|
||||
await notice.discardDraft();
|
||||
res.json({ message: 'Draft discarded.' });
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Publish body.sections as the next version (body.changeSummary, body.requiresAcceptance)
|
||||
// @route POST /api/privacy/notice/publish
|
||||
// @access Private/Admin
|
||||
const publishNotice = async (req, res) => {
|
||||
try {
|
||||
const { sections, changeSummary, requiresAcceptance } = req.body || {};
|
||||
if (requiresAcceptance && !String(changeSummary || '').trim()) {
|
||||
res.status(400);
|
||||
throw new Error('Say what changed — people see it when they are asked to accept the new version.');
|
||||
}
|
||||
const published = await notice.publish({ sections, changeSummary, requiresAcceptance, publishedById: req.user.id });
|
||||
logAdminAction({
|
||||
actorId: req.user.id, actorRole: req.user.role, action: 'privacy_notice_published', targetType: 'privacy_notice', targetId: published.id,
|
||||
metadata: { version: published.version, requiresAcceptance: published.requiresAcceptance, changeSummary: published.changeSummary }, ip: getClientIp(req),
|
||||
});
|
||||
res.status(201).json({ version: published.version, publishedAt: published.publishedAt, requiresAcceptance: published.requiresAcceptance });
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── Operators ────────────────────────────────────────────────────────────────
|
||||
|
||||
const OPERATOR_FIELDS = ['name', 'purpose', 'dataShared', 'location', 'website'];
|
||||
|
||||
function operatorData(body, { partial = false } = {}) {
|
||||
const data = {};
|
||||
for (const f of OPERATOR_FIELDS) {
|
||||
if (body?.[f] === undefined) continue;
|
||||
const v = body[f] == null ? '' : String(body[f]).trim().slice(0, f === 'name' ? 200 : 1000);
|
||||
data[f] = v || (f === 'location' || f === 'website' ? null : '');
|
||||
}
|
||||
if (typeof body?.active === 'boolean') data.active = body.active;
|
||||
if (Number.isInteger(body?.order)) data.order = body.order;
|
||||
if ((!partial || 'name' in data) && !data.name) throw new Error('Give the operator a name.');
|
||||
if ((!partial || 'purpose' in data) && !data.purpose) throw new Error('Say what the operator does for you.');
|
||||
if (data.website && !/^https?:\/\//i.test(data.website)) data.website = `https://${data.website}`;
|
||||
return data;
|
||||
}
|
||||
|
||||
// @desc The operator register
|
||||
// @route GET /api/privacy/operators
|
||||
// @access Private/Admin
|
||||
const listOperators = async (req, res) => {
|
||||
try {
|
||||
res.json(await prisma.operator.findMany({ orderBy: [{ order: 'asc' }, { name: 'asc' }] }));
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @route POST /api/privacy/operators
|
||||
const createOperator = async (req, res) => {
|
||||
try {
|
||||
const data = operatorData(req.body);
|
||||
if (data.order === undefined) data.order = (await prisma.operator.count()) + 1;
|
||||
const op = await prisma.operator.create({ data });
|
||||
logAdminAction({ actorId: req.user.id, actorRole: req.user.role, action: 'operator_created', targetType: 'operator', targetId: op.id, metadata: { name: op.name }, ip: getClientIp(req) });
|
||||
res.status(201).json(op);
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @route PUT /api/privacy/operators/:id
|
||||
const updateOperator = async (req, res) => {
|
||||
try {
|
||||
const existing = await prisma.operator.findUnique({ where: { id: req.params.id } });
|
||||
if (!existing) { res.status(404); throw new Error('Operator not found'); }
|
||||
const op = await prisma.operator.update({ where: { id: existing.id }, data: operatorData(req.body, { partial: true }) });
|
||||
logAdminAction({ actorId: req.user.id, actorRole: req.user.role, action: 'operator_updated', targetType: 'operator', targetId: op.id, metadata: { name: op.name, active: op.active }, ip: getClientIp(req) });
|
||||
res.json(op);
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @route DELETE /api/privacy/operators/:id
|
||||
const deleteOperator = async (req, res) => {
|
||||
try {
|
||||
const existing = await prisma.operator.findUnique({ where: { id: req.params.id } });
|
||||
if (!existing) { res.status(404); throw new Error('Operator not found'); }
|
||||
await prisma.operator.delete({ where: { id: existing.id } });
|
||||
logAdminAction({ actorId: req.user.id, actorRole: req.user.role, action: 'operator_deleted', targetType: 'operator', targetId: existing.id, metadata: { name: existing.name }, ip: getClientIp(req) });
|
||||
res.json({ message: 'Operator removed.' });
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── Consent log ──────────────────────────────────────────────────────────────
|
||||
|
||||
// Filter on the person's name or email.
|
||||
function personWhere(q) {
|
||||
return q ? { OR: [{ name: { contains: q, mode: 'insensitive' } }, { email: { contains: q, mode: 'insensitive' } }] } : undefined;
|
||||
}
|
||||
|
||||
// @desc Proof of consent: ?type=news (news opt-in changes), notice (privacy notice
|
||||
// acceptances) or parental (parental consent answers); ?q= name/email; ?page=
|
||||
// @route GET /api/privacy/consent-log
|
||||
// @access Private/Admin
|
||||
const consentLog = async (req, res) => {
|
||||
try {
|
||||
const type = String(req.query.type || 'news');
|
||||
const q = String(req.query.q || '').trim();
|
||||
const skip = (page(req) - 1) * PAGE_SIZE;
|
||||
const user = { select: { name: true, email: true } };
|
||||
|
||||
if (type === 'news') {
|
||||
const where = q ? { OR: [{ user: personWhere(q) }, { address: { contains: q, mode: 'insensitive' } }] } : {};
|
||||
const [rows, total] = await Promise.all([
|
||||
prisma.communicationConsent.findMany({ where, orderBy: { createdAt: 'desc' }, skip, take: PAGE_SIZE, include: { user } }),
|
||||
prisma.communicationConsent.count({ where }),
|
||||
]);
|
||||
return res.json({
|
||||
total, pageSize: PAGE_SIZE,
|
||||
rows: rows.map(r => ({ id: r.id, at: r.createdAt, name: r.user?.name || null, email: r.user?.email || r.address, channel: r.channel, granted: r.granted, source: r.source, ip: r.ip })),
|
||||
});
|
||||
}
|
||||
if (type === 'notice') {
|
||||
const where = q ? { user: personWhere(q) } : {};
|
||||
const [rows, total] = await Promise.all([
|
||||
prisma.noticeAcceptance.findMany({ where, orderBy: { createdAt: 'desc' }, skip, take: PAGE_SIZE, include: { user } }),
|
||||
prisma.noticeAcceptance.count({ where }),
|
||||
]);
|
||||
return res.json({
|
||||
total, pageSize: PAGE_SIZE,
|
||||
rows: rows.map(r => ({ id: r.id, at: r.createdAt, name: r.user?.name || null, email: r.user?.email || null, version: r.version, source: r.source, ip: r.ip })),
|
||||
});
|
||||
}
|
||||
if (type === 'parental') {
|
||||
const where = {
|
||||
status: { in: ['given', 'declined'] },
|
||||
...(q ? { OR: [{ attendee: { name: { contains: q, mode: 'insensitive' } } }, { parentName: { contains: q, mode: 'insensitive' } }, { parentEmail: { contains: q, mode: 'insensitive' } }, { signedName: { contains: q, mode: 'insensitive' } }] } : {}),
|
||||
};
|
||||
const [rows, total] = await Promise.all([
|
||||
prisma.consentRequest.findMany({
|
||||
where, orderBy: { respondedAt: 'desc' }, skip, take: PAGE_SIZE,
|
||||
select: { id: true, status: true, signedName: true, parentName: true, textVersion: true, photoConsent: true, respondedAt: true, ip: true, medicalPurgedAt: true, attendee: { select: { name: true } }, event: { select: { title: true } } },
|
||||
}),
|
||||
prisma.consentRequest.count({ where }),
|
||||
]);
|
||||
return res.json({
|
||||
total, pageSize: PAGE_SIZE,
|
||||
rows: rows.map(r => ({
|
||||
id: r.id, at: r.respondedAt, child: r.attendee?.name, event: r.event?.title, status: r.status, signedName: r.signedName,
|
||||
parentName: r.parentName, textVersion: r.textVersion, photoConsent: r.photoConsent, ip: r.ip, medicalDeleted: !!r.medicalPurgedAt,
|
||||
})),
|
||||
});
|
||||
}
|
||||
res.status(400);
|
||||
throw new Error('type must be news, notice or parental');
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── Access log ───────────────────────────────────────────────────────────────
|
||||
|
||||
// @desc Who exported or viewed personal information in bulk (?action=, ?actorId=, ?from=, ?to=, ?page=)
|
||||
// @route GET /api/privacy/access-log
|
||||
// @access Private/Admin
|
||||
const accessLog = async (req, res) => {
|
||||
try {
|
||||
const where = {};
|
||||
if (req.query.action) where.action = String(req.query.action);
|
||||
if (req.query.actorId) where.actorId = String(req.query.actorId);
|
||||
if (req.query.from || req.query.to) {
|
||||
where.createdAt = {};
|
||||
if (req.query.from) where.createdAt.gte = new Date(String(req.query.from));
|
||||
if (req.query.to) where.createdAt.lte = new Date(String(req.query.to));
|
||||
}
|
||||
const [rows, total, actors] = await Promise.all([
|
||||
prisma.dataAccessLog.findMany({ where, orderBy: { createdAt: 'desc' }, skip: (page(req) - 1) * PAGE_SIZE, take: PAGE_SIZE, include: { actor: { select: { name: true } } } }),
|
||||
prisma.dataAccessLog.count({ where }),
|
||||
prisma.dataAccessLog.findMany({ where: { actorId: { not: null } }, distinct: ['actorId'], select: { actorId: true, actor: { select: { name: true } } } }),
|
||||
]);
|
||||
res.json({
|
||||
total, pageSize: PAGE_SIZE,
|
||||
actions: ACCESS_ACTIONS,
|
||||
actors: actors.map(a => ({ id: a.actorId, name: a.actor?.name || 'Unknown' })),
|
||||
rows: rows.map(r => ({ id: r.id, at: r.createdAt, actor: r.actor?.name || 'Deleted user', actorRole: r.actorRole, action: r.action, what: r.what, records: r.records, eventId: r.eventId, ip: r.ip })),
|
||||
});
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
getNotice,
|
||||
acceptMyNotice,
|
||||
getNoticeDraft,
|
||||
saveNoticeDraft,
|
||||
discardNoticeDraft,
|
||||
publishNotice,
|
||||
listOperators,
|
||||
createOperator,
|
||||
updateOperator,
|
||||
deleteOperator,
|
||||
consentLog,
|
||||
accessLog,
|
||||
};
|
||||
@@ -397,6 +397,13 @@ function writeTableRows(ws, startRow, columns, rows) {
|
||||
return { nextRow: r, dataStartRow };
|
||||
}
|
||||
|
||||
// POPIA access log: who took which report out of the system, and how many rows.
|
||||
function logReport(req, action) {
|
||||
const { logDataAccess, reportRowCount } = require('../utils/accessLog');
|
||||
const p = req.body || {};
|
||||
logDataAccess(req, { action, what: [p.title || 'Report', p.subtitle].filter(Boolean).join(' — '), records: reportRowCount(p) });
|
||||
}
|
||||
|
||||
// POST /api/reports/pdf
|
||||
// body: { title, subtitle?, kind: 'table'|'layered', table?, layered?, stats?, chart?, note?, orientation? }
|
||||
const generatePdf = async (req, res) => {
|
||||
@@ -410,6 +417,7 @@ const generatePdf = async (req, res) => {
|
||||
doc.pipe(res);
|
||||
drawReportPdf(doc, { title, subtitle, kind, table, layered, stats, chart, note, extraTables });
|
||||
doc.end();
|
||||
logReport(req, 'report_pdf');
|
||||
} catch (e) {
|
||||
res.status(400).json({ message: e.message });
|
||||
}
|
||||
@@ -426,6 +434,7 @@ const generateExcel = async (req, res) => {
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
|
||||
await wb.xlsx.write(res);
|
||||
res.end();
|
||||
logReport(req, 'report_excel');
|
||||
} catch (e) {
|
||||
res.status(400).json({ message: e.message });
|
||||
}
|
||||
@@ -462,6 +471,7 @@ const emailPdf = async (req, res) => {
|
||||
|
||||
try { fs.unlinkSync(filePath); } catch {}
|
||||
|
||||
logReport(req, 'report_email');
|
||||
res.json({ message: `Report emailed to ${user.email}` });
|
||||
} catch (e) {
|
||||
res.status(400).json({ message: e.message });
|
||||
@@ -489,6 +499,7 @@ const whatsappPdf = async (req, res) => {
|
||||
const filename = `${(title || 'report').replace(/[^a-z0-9]/gi, '_').toLowerCase()}.pdf`;
|
||||
await sendPdf(user.phoneNumber, filePath, filename, caption || title || 'Report');
|
||||
|
||||
logReport(req, 'report_whatsapp');
|
||||
res.json({ message: `Report sent to your WhatsApp` });
|
||||
} catch (e) {
|
||||
res.status(400).json({ message: e.message });
|
||||
|
||||
@@ -140,6 +140,7 @@ async function sendWelcomeEmail(user) {
|
||||
const LOGIN_USER_SELECT = {
|
||||
id: true, name: true, email: true, role: true, tokenVersion: true, isActive: true,
|
||||
phoneNumber: true, notificationPreference: true, twoFactorEnabled: true, twoFactorMethod: true, newsAskedAt: true,
|
||||
noticeVersionAccepted: true,
|
||||
};
|
||||
|
||||
// Errors from utils/twoFactor carry their own HTTP status; everything else follows
|
||||
@@ -150,7 +151,9 @@ function errorStatus(error, res) {
|
||||
|
||||
// Final step of a successful login (after the password, and the 2FA code when
|
||||
// required): notify, record the activity, and issue the session token.
|
||||
function completeLogin(user, req, res, extra = {}) {
|
||||
async function completeLogin(user, req, res, extra = {}) {
|
||||
const noticePromptPending = await require('../utils/privacyNotice').noticePromptPending(user).catch(() => false);
|
||||
|
||||
// Send login notification in the background
|
||||
sendLoginNotification(user, req).catch(() => {});
|
||||
logSecurityEvent({ userId: user.id, type: 'login', ip: getClientIp(req), userAgent: req.headers['user-agent'] }).catch(() => {});
|
||||
@@ -161,8 +164,10 @@ function completeLogin(user, req, res, extra = {}) {
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
token: generateToken(user.id, user.role, user.tokenVersion),
|
||||
// POPIA: ask about News & announcements once (see NewsPrompt on the frontend).
|
||||
// POPIA: ask about News & announcements once (see NewsPrompt on the frontend), and to
|
||||
// accept a privacy notice version marked as a significant change (NoticePrompt).
|
||||
newsPromptPending: !user.newsAskedAt,
|
||||
noticePromptPending,
|
||||
...extra
|
||||
});
|
||||
}
|
||||
@@ -224,6 +229,9 @@ const registerUser = async (req, res) => {
|
||||
{ email: newsOptIn === true, whatsapp: newsOptIn === true },
|
||||
{ source: 'signup', ip: getClientIp(req), answered: true, logAll: true },
|
||||
).catch(e => console.warn('[signup] news preference not saved:', e?.message || e));
|
||||
// The sign-up form requires ticking "I agree to the Terms of Use and Privacy Policy".
|
||||
await require('../utils/privacyNotice').acceptNotice(user.id, { source: 'signup', ip: getClientIp(req) })
|
||||
.catch(e => console.warn('[signup] notice acceptance not saved:', e?.message || e));
|
||||
|
||||
|
||||
// Send welcome email in the background — don't block the response
|
||||
@@ -351,7 +359,7 @@ const loginUser = async (req, res) => {
|
||||
}
|
||||
}
|
||||
|
||||
completeLogin(updated, req, res);
|
||||
await completeLogin(updated, req, res);
|
||||
} catch (error) {
|
||||
res.status(errorStatus(error, res)).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
@@ -386,7 +394,7 @@ const verifyLoginTwoFactor = async (req, res) => {
|
||||
if (rememberDevice === true) {
|
||||
extra.deviceToken = await issueTrustedDevice(user.id, req.headers['user-agent']);
|
||||
}
|
||||
completeLogin(user, req, res, extra);
|
||||
await completeLogin(user, req, res, extra);
|
||||
} catch (error) {
|
||||
res.status(errorStatus(error, res)).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
@@ -527,14 +535,17 @@ const getUserProfile = async (req, res) => {
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
isActive: true,
|
||||
newsAskedAt: true
|
||||
newsAskedAt: true,
|
||||
noticeVersionAccepted: true
|
||||
}
|
||||
});
|
||||
|
||||
if (user) {
|
||||
const { newsAskedAt, ...rest } = user;
|
||||
const { newsAskedAt, noticeVersionAccepted, ...rest } = user;
|
||||
// newsPromptPending: they haven't answered the news opt-in question yet (asked once).
|
||||
res.json({ ...rest, twoFactorRequired: requiresTwoFactor(user), newsPromptPending: !newsAskedAt && !req.impersonation });
|
||||
// noticePromptPending: a privacy notice version they must accept has been published.
|
||||
const noticePromptPending = !req.impersonation && await require('../utils/privacyNotice').noticePromptPending({ noticeVersionAccepted });
|
||||
res.json({ ...rest, twoFactorRequired: requiresTwoFactor(user), newsPromptPending: !newsAskedAt && !req.impersonation, noticePromptPending });
|
||||
} else {
|
||||
res.status(404);
|
||||
throw new Error('User not found');
|
||||
|
||||
@@ -911,6 +911,29 @@ app.get('/docs', async (req, res) => {
|
||||
{ method:'POST', path:'/api/privacy/requests/:id/reject', auth:'admin', desc:'Reject a request, or mark it withdrawn (cancelled:true); a deletion request reopens the account',
|
||||
request:{ body:{ response:'We could not find a registration with that name.', cancelled:false } },
|
||||
responses:[{ status:200, desc:'Closed', body:{ request:{ status:'rejected' } }}]},
|
||||
{ method:'GET', path:'/api/privacy/notice', auth:'public', desc:'The privacy notice (current, or ?version=N) with org details and the operator list filled in. Version 0 = built-in, until one is published',
|
||||
responses:[{ status:200, desc:'Success', body:{ version:2, builtIn:false, current:true, effectiveDate:'23 October 2026', changeSummary:'We added WhatsApp news.', sections:[{ id:'introduction', title:'1. Introduction', body:'Hope Church values your privacy…' }], versions:[{ version:2, publishedAt:'2026-10-23T10:00:00.000Z', requiresAcceptance:true }] }}]},
|
||||
{ method:'POST', path:'/api/privacy/me/notice/accept', auth:'user', desc:'Accept the current privacy notice version',
|
||||
responses:[{ status:200, desc:'Accepted', body:{ version:2 }}]},
|
||||
{ method:'GET', path:'/api/privacy/notice/draft', auth:'admin', desc:'Notice editor: draft (or current) sections, rendered preview, published versions with acceptance counts, built-in text',
|
||||
responses:[{ status:200, desc:'Success', body:{ sections:[], rendered:[], isDraft:false, savedAt:null, versions:[{ version:1, acceptances:12 }] }}]},
|
||||
{ method:'PUT', path:'/api/privacy/notice/draft', auth:'admin', desc:'Save the draft ({ sections: [{ id?, title, body }] })',
|
||||
request:{ body:{ sections:[{ title:'1. Introduction', body:'{org} values your privacy.' }] } },
|
||||
responses:[{ status:200, desc:'Saved', body:{ isDraft:true }}]},
|
||||
{ method:'DELETE', path:'/api/privacy/notice/draft', auth:'admin', desc:'Discard the draft',
|
||||
responses:[{ status:200, desc:'Discarded', body:{ message:'Draft discarded.' }}]},
|
||||
{ method:'POST', path:'/api/privacy/notice/publish', auth:'admin', desc:'Publish the next version; requiresAcceptance asks everyone to accept it after their next login (needs changeSummary)',
|
||||
request:{ body:{ sections:[{ title:'1. Introduction', body:'{org} values your privacy.' }], changeSummary:'We added WhatsApp news.', requiresAcceptance:true } },
|
||||
responses:[{ status:201, desc:'Published', body:{ version:2, requiresAcceptance:true }}]},
|
||||
{ method:'GET', path:'/api/privacy/operators', auth:'admin', desc:'The operator register (third parties that process personal information)',
|
||||
responses:[{ status:200, desc:'Success', body:[{ id:'op-uuid-...', name:'PayFast', purpose:'Online payments', dataShared:'Name, email address, payment amount and reference', location:'South Africa', website:'https://payfast.io', active:true }] }]},
|
||||
{ method:'POST', path:'/api/privacy/operators', auth:'admin', desc:'Add an operator (PUT/DELETE /api/privacy/operators/:id to change or remove)',
|
||||
request:{ body:{ name:'Mailgun', purpose:'Sending emails', dataShared:'Name, email address, message content', location:'EU' } },
|
||||
responses:[{ status:201, desc:'Created' }]},
|
||||
{ method:'GET', path:'/api/privacy/consent-log', auth:'admin', desc:'Consent records: ?type=news|notice|parental, ?q= name/email, ?page=',
|
||||
responses:[{ status:200, desc:'Success', body:{ total:1, pageSize:50, rows:[{ at:'2026-10-23T08:00:00.000Z', name:'Jane Doe', email:'jane@example.com', channel:'email', granted:false, source:'unsubscribe_link' }] }}]},
|
||||
{ method:'GET', path:'/api/privacy/access-log', auth:'admin', desc:'Bulk exports/views of personal information: ?action=, ?actorId=, ?from=, ?to=, ?page=',
|
||||
responses:[{ status:200, desc:'Success', body:{ total:1, pageSize:50, rows:[{ at:'2026-10-23T08:00:00.000Z', actor:'Sam Supervisor', action:'report_pdf', what:'Camp 2026 attendees', records:48 }] }}]},
|
||||
]},
|
||||
|
||||
{ title: 'Broadcasts (Email)', base: '/api/broadcasts', endpoints: [
|
||||
|
||||
@@ -14,6 +14,20 @@ const {
|
||||
completeRequest,
|
||||
rejectRequest,
|
||||
} = require('../controllers/privacyController');
|
||||
const {
|
||||
getNotice,
|
||||
acceptMyNotice,
|
||||
getNoticeDraft,
|
||||
saveNoticeDraft,
|
||||
discardNoticeDraft,
|
||||
publishNotice,
|
||||
listOperators,
|
||||
createOperator,
|
||||
updateOperator,
|
||||
deleteOperator,
|
||||
consentLog,
|
||||
accessLog,
|
||||
} = require('../controllers/privacyAdminController');
|
||||
const { protect, admin } = require('../middleware/authMiddleware');
|
||||
|
||||
// Exports are heavy-ish (every record the person has) — a few per 10 minutes is plenty.
|
||||
@@ -36,4 +50,20 @@ router.get('/requests', protect, admin, listRequests);
|
||||
router.post('/requests/:id/complete', protect, admin, completeRequest);
|
||||
router.post('/requests/:id/reject', protect, admin, rejectRequest);
|
||||
|
||||
// Privacy notice: public reading, acceptance, and the admin editor
|
||||
router.get('/notice', getNotice);
|
||||
router.post('/me/notice/accept', protect, acceptMyNotice);
|
||||
router.get('/notice/draft', protect, admin, getNoticeDraft);
|
||||
router.put('/notice/draft', protect, admin, saveNoticeDraft);
|
||||
router.delete('/notice/draft', protect, admin, discardNoticeDraft);
|
||||
router.post('/notice/publish', protect, admin, publishNotice);
|
||||
|
||||
// Admin: operator register, consent log, access log
|
||||
router.get('/operators', protect, admin, listOperators);
|
||||
router.post('/operators', protect, admin, createOperator);
|
||||
router.put('/operators/:id', protect, admin, updateOperator);
|
||||
router.delete('/operators/:id', protect, admin, deleteOperator);
|
||||
router.get('/consent-log', protect, admin, consentLog);
|
||||
router.get('/access-log', protect, admin, accessLog);
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
const prisma = require('../config/db');
|
||||
const { getClientIp } = require('./requestUtils');
|
||||
|
||||
// What each DataAccessLog action means, for the admin page.
|
||||
const ACCESS_ACTIONS = {
|
||||
report_pdf: 'Report downloaded (PDF)',
|
||||
report_excel: 'Report downloaded (Excel)',
|
||||
report_email: 'Report emailed',
|
||||
report_whatsapp: 'Report sent by WhatsApp',
|
||||
attendance_export: 'Attendance register exported',
|
||||
certificates_download: 'Certificates downloaded',
|
||||
group_lists: 'Room/group lists opened',
|
||||
form_responses: 'Attendee form answers viewed',
|
||||
consent_list: 'Parental consent list viewed',
|
||||
medical_list: 'Medical list viewed',
|
||||
backup_download: 'Database backup downloaded',
|
||||
impersonation: 'Viewed the site as another user',
|
||||
};
|
||||
|
||||
/**
|
||||
* POPIA access log: someone exported or viewed personal information in bulk. Fire-and-
|
||||
* forget like logAdminAction — a logging failure never blocks the export itself.
|
||||
*/
|
||||
async function logDataAccess(req, { action, what, eventId = null, records = null }) {
|
||||
try {
|
||||
await prisma.dataAccessLog.create({
|
||||
data: {
|
||||
actorId: req.user?.id || null,
|
||||
actorRole: req.user?.role || 'unknown',
|
||||
action,
|
||||
what: String(what || ACCESS_ACTIONS[action] || action).slice(0, 500),
|
||||
eventId: eventId || null,
|
||||
records: Number.isFinite(records) ? records : null,
|
||||
ip: getClientIp(req),
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('Failed to log data access:', e?.message);
|
||||
}
|
||||
}
|
||||
|
||||
/** Rows in a report payload (main table or layered sections, plus extra tables). */
|
||||
function reportRowCount(payload) {
|
||||
const p = payload || {};
|
||||
let n = Array.isArray(p.table?.rows) ? p.table.rows.length : 0;
|
||||
if (Array.isArray(p.layered?.sections)) n += p.layered.sections.reduce((s, sec) => s + (Array.isArray(sec.items) ? sec.items.length : 0), 0);
|
||||
if (Array.isArray(p.extraTables)) n += p.extraTables.reduce((s, t) => s + (Array.isArray(t.rows) ? t.rows.length : 0), 0);
|
||||
return n;
|
||||
}
|
||||
|
||||
module.exports = { ACCESS_ACTIONS, logDataAccess, reportRowCount };
|
||||
@@ -446,6 +446,10 @@ async function anonymiseUser(userId, db = prisma) {
|
||||
const checkouts = await tx.providerCheckout.findMany({ where: { metadata: { path: ['userId'], equals: userId } }, select: { id: true, request: true } });
|
||||
for (const c of checkouts) await tx.providerCheckout.update({ where: { id: c.id }, data: { request: scrubPersonal(c.request) } });
|
||||
|
||||
// Consent and notice records stay as proof, without the IP address they came from.
|
||||
await tx.communicationConsent.updateMany({ where: { userId }, data: { ip: null } });
|
||||
await tx.noticeAcceptance.updateMany({ where: { userId }, data: { ip: null } });
|
||||
|
||||
await tx.securityEvent.deleteMany({ where: { userId } });
|
||||
await tx.passwordReset.deleteMany({ where: { userId } });
|
||||
await tx.twoFactorChallenge.deleteMany({ where: { userId } });
|
||||
@@ -472,7 +476,7 @@ async function exportUserData(userId, db = prisma) {
|
||||
});
|
||||
if (!user) return null;
|
||||
|
||||
const [registrations, payments, tickets, waitlist, certificates, feedback, consents, security, requests] = await Promise.all([
|
||||
const [registrations, payments, tickets, waitlist, certificates, feedback, consents, security, requests, notices] = await Promise.all([
|
||||
db.registration.findMany({
|
||||
where: { userId },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
@@ -495,6 +499,7 @@ async function exportUserData(userId, db = prisma) {
|
||||
consentHistory(userId, db),
|
||||
db.securityEvent.findMany({ where: { userId }, orderBy: { createdAt: 'desc' }, take: 100, select: { type: true, ip: true, device: true, createdAt: true } }),
|
||||
db.dataRequest.findMany({ where: { userId }, orderBy: { createdAt: 'desc' }, select: { type: true, status: true, details: true, response: true, createdAt: true, closedAt: true } }),
|
||||
db.noticeAcceptance.findMany({ where: { userId }, orderBy: { createdAt: 'desc' }, select: { version: true, source: true, createdAt: true } }),
|
||||
]);
|
||||
|
||||
return {
|
||||
@@ -505,6 +510,7 @@ async function exportUserData(userId, db = prisma) {
|
||||
news: { email: user.newsEmail, whatsapp: user.newsWhatsApp, answeredAt: user.newsAskedAt },
|
||||
history: consents.map(c => ({ channel: c.channel, granted: c.granted, source: c.source, at: c.createdAt })),
|
||||
},
|
||||
privacyNoticeAccepted: notices.map(n => ({ version: n.version, how: n.source, at: n.createdAt })),
|
||||
registrations: registrations.map(r => ({
|
||||
id: r.id,
|
||||
event: r.event?.title,
|
||||
|
||||
@@ -0,0 +1,434 @@
|
||||
/**
|
||||
* The privacy notice: the built-in text (version 0), published versions (PrivacyNotice),
|
||||
* the admin's draft, and who has accepted which version.
|
||||
*
|
||||
* A notice is a list of sections { id, title, body }. A body is plain text, one paragraph
|
||||
* per line; a line starting with "• " is a bullet. Placeholders are filled in when the
|
||||
* notice is shown, so it follows Site settings and the operator register:
|
||||
* {org} {orgEmail} {website} {ioName} {ioEmail} {medicalRetentionDays}
|
||||
* {operators} — on a line of its own: one bullet per active operator.
|
||||
*/
|
||||
|
||||
const prisma = require('../config/db');
|
||||
|
||||
const PLACEHOLDERS = ['org', 'orgEmail', 'website', 'ioName', 'ioEmail', 'medicalRetentionDays', 'operators'];
|
||||
const MAX_SECTIONS = 40;
|
||||
const MAX_TITLE = 200;
|
||||
const MAX_BODY = 20000;
|
||||
const DRAFT_KEY = 'privacy_notice_draft';
|
||||
|
||||
const DEFAULT_SECTIONS = [
|
||||
{
|
||||
id: 'summary',
|
||||
title: 'Plain English Summary',
|
||||
body: [
|
||||
"We respect your privacy — here's what you need to know:",
|
||||
'• We only collect info needed to run events: registrations, payments, tickets and check-in.',
|
||||
'• We store your info safely on South African servers.',
|
||||
"• We don't sell your data, and only share it with the services listed in section 9, which process payments and deliver messages for us.",
|
||||
'• 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.',
|
||||
'• 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.',
|
||||
'• Online payments go through Yoco or PayFast; we never see your card details.',
|
||||
'• We send tickets, updates and security alerts by email and/or WhatsApp. News and announcements are sent only if you ask for them, and you can unsubscribe at any time.',
|
||||
'• Visit statistics are only collected if you click "Allow" on the cookie notice — no third-party analytics.',
|
||||
'• On the Privacy page in your account you can download your data, ask for corrections, and delete your account.',
|
||||
].join('\n'),
|
||||
},
|
||||
{
|
||||
id: 'introduction',
|
||||
title: '1. Introduction',
|
||||
body: [
|
||||
"{org} ('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 notice explains what data we collect, how we use it, and your rights.',
|
||||
].join('\n'),
|
||||
},
|
||||
{
|
||||
id: 'information',
|
||||
title: '2. Information We Collect',
|
||||
body: [
|
||||
'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.",
|
||||
'• Your choices: whether you want news and announcements, which version of this notice you accepted, and when you made each choice.',
|
||||
'• 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.',
|
||||
].join('\n'),
|
||||
},
|
||||
{
|
||||
id: 'others',
|
||||
title: '3. People You Register or Transfer Tickets To',
|
||||
body: [
|
||||
'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).',
|
||||
].join('\n'),
|
||||
},
|
||||
{
|
||||
id: 'use',
|
||||
title: '4. How We Use Your Information',
|
||||
body: [
|
||||
'We use your information to:',
|
||||
'• Process event registrations, payments, discounts and refunds.',
|
||||
'• Send updates about events you registered for — confirmations, reminders and changes — by email and/or WhatsApp (based on your preference).',
|
||||
'• Send news and announcements about upcoming events, only if you asked for them. Every such email has an unsubscribe link, and you can reply STOP on WhatsApp.',
|
||||
'• 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.',
|
||||
].join('\n'),
|
||||
},
|
||||
{
|
||||
id: 'whatsapp',
|
||||
title: '5. WhatsApp Communications',
|
||||
body: [
|
||||
"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.',
|
||||
'• News and announcements, only if you asked for them on WhatsApp. Reply STOP to any of these to stop them.',
|
||||
'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.',
|
||||
'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.",
|
||||
].join('\n'),
|
||||
},
|
||||
{
|
||||
id: 'storage',
|
||||
title: '6. Where We Store Your Data',
|
||||
body: [
|
||||
'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.',
|
||||
].join('\n'),
|
||||
},
|
||||
{
|
||||
id: 'retention',
|
||||
title: '7. Data Retention',
|
||||
body: [
|
||||
'We retain your personal data for up to 5 years after your last event registration, or until you ask us to delete your account — whichever comes first.',
|
||||
'Medical and emergency-contact details from parental consent forms are deleted {medicalRetentionDays} days after the event ends. 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), of who exported personal information, and other audit records are kept for accountability.',
|
||||
'You can ask for your account to be deleted from the Privacy page in your account. Your account is closed straight away, and your personal information — your name and contact details, the people you registered, your form answers — is deleted within 30 days (after any upcoming event you registered for). Payment and registration records are kept without your name, because the law requires us to keep financial records.',
|
||||
'Backups of our database still contain deleted information until they are replaced, which happens automatically within a few weeks.',
|
||||
].join('\n'),
|
||||
},
|
||||
{
|
||||
id: 'children',
|
||||
title: '8. Children, Parental Consent and Health Information',
|
||||
body: [
|
||||
'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.',
|
||||
].join('\n'),
|
||||
},
|
||||
{
|
||||
id: 'sharing',
|
||||
title: '9. Who Can See Your Information',
|
||||
body: [
|
||||
'We do not sell or rent your personal information. Outside our organisation, it is shared only with these service providers ("operators"), each only for its purpose:',
|
||||
'{operators}',
|
||||
'• 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. Every export of personal information (reports, attendance registers, lists) is recorded.',
|
||||
'• 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.',
|
||||
].join('\n'),
|
||||
},
|
||||
{
|
||||
id: 'rights',
|
||||
title: '10. Your Rights (under POPIA)',
|
||||
body: [
|
||||
'Under POPIA, you have the right to:',
|
||||
'• Access your personal information.',
|
||||
'• Request correction or deletion of your data.',
|
||||
'• Withdraw consent for processing, including for news and announcements.',
|
||||
'• Object to direct marketing.',
|
||||
'• Lodge a complaint with the Information Regulator of South Africa.',
|
||||
'The Privacy page in your account lets you download your data, ask for a correction, delete your account and choose which messages you receive. We answer requests within 30 days.',
|
||||
'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.',
|
||||
].join('\n'),
|
||||
},
|
||||
{
|
||||
id: 'cookies',
|
||||
title: '11. Cookie Policy (Cookies and Browser Storage)',
|
||||
body: [
|
||||
"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.',
|
||||
].join('\n'),
|
||||
},
|
||||
{
|
||||
id: 'security',
|
||||
title: '12. Security',
|
||||
body: [
|
||||
'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, ticket transfers and unsubscribing are personal. Only a secure fingerprint of most links 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.',
|
||||
].join('\n'),
|
||||
},
|
||||
{
|
||||
id: 'changes',
|
||||
title: '13. Changes to This Notice',
|
||||
body: [
|
||||
'We may update this notice from time to time. Each update is published as a new version with its date, and earlier versions stay available on this page.',
|
||||
'If a change is significant, we ask you to accept the new version the next time you log in.',
|
||||
].join('\n'),
|
||||
},
|
||||
{
|
||||
id: 'officer',
|
||||
title: '14. Information Officer',
|
||||
body: [
|
||||
'The Information Officer responsible for overseeing compliance with POPIA on behalf of {org} 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.',
|
||||
].join('\n'),
|
||||
},
|
||||
{
|
||||
id: 'contact',
|
||||
title: '15. Contact',
|
||||
body: [
|
||||
'For general privacy-related questions or requests, you can also contact us at:',
|
||||
'📧 {orgEmail}',
|
||||
].join('\n'),
|
||||
},
|
||||
];
|
||||
|
||||
function defaultSections() {
|
||||
return DEFAULT_SECTIONS.map(s => ({ ...s }));
|
||||
}
|
||||
|
||||
function slugify(text) {
|
||||
return String(text || '').toLowerCase().replace(/^\d+\.\s*/, '').replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 40) || 'section';
|
||||
}
|
||||
|
||||
/** Checks and tidies sections from the editor. Throws a message fit for the admin. */
|
||||
function cleanSections(input) {
|
||||
if (!Array.isArray(input) || input.length === 0) throw new Error('The notice needs at least one section.');
|
||||
if (input.length > MAX_SECTIONS) throw new Error(`At most ${MAX_SECTIONS} sections.`);
|
||||
const seen = new Set();
|
||||
return input.map((s, i) => {
|
||||
const title = String(s?.title || '').trim();
|
||||
const body = String(s?.body || '').replace(/\r\n/g, '\n').trim();
|
||||
if (!title) throw new Error(`Section ${i + 1} needs a title.`);
|
||||
if (title.length > MAX_TITLE) throw new Error(`The title of "${title.slice(0, 40)}…" is too long.`);
|
||||
if (body.length > MAX_BODY) throw new Error(`"${title}" is too long.`);
|
||||
let id = slugify(s?.id || title);
|
||||
while (seen.has(id)) id = `${id}-${i + 1}`;
|
||||
seen.add(id);
|
||||
return { id, title, body };
|
||||
});
|
||||
}
|
||||
|
||||
/** Settings and operators the placeholders are filled from. */
|
||||
async function noticeContext(db = prisma) {
|
||||
const { getSetting } = require('./settingsCache');
|
||||
const [org, orgEmail, website, ioName, ioEmail, medicalDays, operators] = await Promise.all([
|
||||
getSetting('org_name', process.env.ORG_NAME || 'Cross Code'),
|
||||
getSetting('org_email', ''),
|
||||
getSetting('legal_website_url', ''),
|
||||
getSetting('legal_io_name', ''),
|
||||
getSetting('legal_io_email', ''),
|
||||
getSetting('consent_medical_retention_days', ''),
|
||||
db.operator.findMany({ where: { active: true }, orderBy: [{ order: 'asc' }, { name: 'asc' }] }),
|
||||
]);
|
||||
// Unset legal fields show an obviously-unfinished placeholder, never someone's real details.
|
||||
return {
|
||||
org,
|
||||
orgEmail: orgEmail || 'privacy@example.com',
|
||||
website: website || 'example.com',
|
||||
ioName: ioName || '[not yet configured]',
|
||||
ioEmail: ioEmail || 'io@example.com',
|
||||
medicalRetentionDays: String(parseInt(medicalDays, 10) || 30),
|
||||
operators,
|
||||
};
|
||||
}
|
||||
|
||||
function operatorLine(o) {
|
||||
return `• ${o.name}${o.location ? ` (${o.location})` : ''} — ${o.purpose}. ${o.dataShared ? `Receives: ${o.dataShared}.` : ''}`.trim();
|
||||
}
|
||||
|
||||
/** Sections with their placeholders filled in. */
|
||||
function renderSections(sections, ctx) {
|
||||
return sections.map(s => {
|
||||
const body = String(s.body || '')
|
||||
.split('\n')
|
||||
.flatMap(line => (line.trim() === '{operators}' ? (ctx.operators.length ? ctx.operators.map(operatorLine) : ['• (No service providers listed.)']) : [line]))
|
||||
.map(line => line.replace(/\{(\w+)\}/g, (m, key) => (key !== 'operators' && PLACEHOLDERS.includes(key) ? ctx[key] : m)))
|
||||
.join('\n');
|
||||
return { id: s.id, title: s.title, body };
|
||||
});
|
||||
}
|
||||
|
||||
async function latestNotice(db = prisma) {
|
||||
return db.privacyNotice.findFirst({ orderBy: { version: 'desc' } });
|
||||
}
|
||||
|
||||
/** The newest version people must have accepted, or null when none asks for it. */
|
||||
async function requiredVersion(db = prisma) {
|
||||
const row = await db.privacyNotice.findFirst({ where: { requiresAcceptance: true }, orderBy: { version: 'desc' }, select: { version: true } });
|
||||
return row ? row.version : null;
|
||||
}
|
||||
|
||||
/** Whether someone must (re-)accept the notice before carrying on. */
|
||||
async function noticePromptPending(user, db = prisma) {
|
||||
const required = await requiredVersion(db);
|
||||
if (required == null) return false;
|
||||
return (user.noticeVersionAccepted ?? -1) < required;
|
||||
}
|
||||
|
||||
/**
|
||||
* A notice for the public page: the given version (or the current one), rendered, with
|
||||
* the list of versions. Version 0 is the built-in notice.
|
||||
*/
|
||||
async function publicNotice(version, db = prisma) {
|
||||
const versions = await db.privacyNotice.findMany({
|
||||
orderBy: { version: 'desc' },
|
||||
select: { version: true, publishedAt: true, changeSummary: true, requiresAcceptance: true },
|
||||
});
|
||||
const ctx = await noticeContext(db);
|
||||
const wanted = version == null ? versions[0]?.version ?? 0 : version;
|
||||
|
||||
if (wanted === 0) {
|
||||
if (version != null && versions.length) return null; // the built-in text isn't a past version
|
||||
const { getSetting } = require('./settingsCache');
|
||||
return {
|
||||
version: 0,
|
||||
builtIn: true,
|
||||
current: true,
|
||||
publishedAt: null,
|
||||
effectiveDate: (await getSetting('legal_effective_date', '')) || '[not yet set]',
|
||||
changeSummary: null,
|
||||
sections: renderSections(DEFAULT_SECTIONS, ctx),
|
||||
versions,
|
||||
};
|
||||
}
|
||||
const notice = await db.privacyNotice.findUnique({ where: { version: wanted } });
|
||||
if (!notice) return null;
|
||||
return {
|
||||
version: notice.version,
|
||||
builtIn: false,
|
||||
current: notice.version === versions[0]?.version,
|
||||
publishedAt: notice.publishedAt,
|
||||
effectiveDate: notice.publishedAt.toLocaleDateString('en-ZA', { day: 'numeric', month: 'long', year: 'numeric', timeZone: 'Africa/Johannesburg' }),
|
||||
changeSummary: notice.changeSummary,
|
||||
sections: renderSections(notice.sections, ctx),
|
||||
versions,
|
||||
};
|
||||
}
|
||||
|
||||
/** The version a new acceptance is for: the latest published, or 0 (built-in). */
|
||||
async function currentVersion(db = prisma) {
|
||||
return (await latestNotice(db))?.version ?? 0;
|
||||
}
|
||||
|
||||
/** Record that someone accepted the current notice. */
|
||||
async function acceptNotice(userId, { source, ip = null } = {}, db = prisma) {
|
||||
const version = await currentVersion(db);
|
||||
await db.user.update({ where: { id: userId }, data: { noticeVersionAccepted: version } });
|
||||
await db.noticeAcceptance.create({ data: { userId, version, source, ip } });
|
||||
return version;
|
||||
}
|
||||
|
||||
// ─── Draft (admin) ────────────────────────────────────────────────────────────
|
||||
|
||||
/** The draft being edited, or — when there is none — the current notice's sections. */
|
||||
async function getDraft(db = prisma) {
|
||||
const row = await db.appSetting.findUnique({ where: { key: DRAFT_KEY } });
|
||||
if (row?.value) {
|
||||
try {
|
||||
const draft = JSON.parse(row.value);
|
||||
if (Array.isArray(draft.sections)) return { sections: draft.sections, savedAt: draft.savedAt || null, isDraft: true };
|
||||
} catch { /* fall through to the current notice */ }
|
||||
}
|
||||
const latest = await latestNotice(db);
|
||||
return { sections: latest ? latest.sections : defaultSections(), savedAt: null, isDraft: false };
|
||||
}
|
||||
|
||||
async function saveDraft(sections, db = prisma) {
|
||||
const clean = cleanSections(sections);
|
||||
const value = JSON.stringify({ sections: clean, savedAt: new Date().toISOString() });
|
||||
await db.appSetting.upsert({ where: { key: DRAFT_KEY }, update: { value }, create: { key: DRAFT_KEY, value } });
|
||||
return clean;
|
||||
}
|
||||
|
||||
async function discardDraft(db = prisma) {
|
||||
await db.appSetting.deleteMany({ where: { key: DRAFT_KEY } });
|
||||
}
|
||||
|
||||
/** Publish the given sections as the next version, and clear the draft. */
|
||||
async function publish({ sections, changeSummary, requiresAcceptance, publishedById }, db = prisma) {
|
||||
const clean = cleanSections(sections);
|
||||
const notice = await db.$transaction(async tx => {
|
||||
const latest = await tx.privacyNotice.findFirst({ orderBy: { version: 'desc' }, select: { version: true } });
|
||||
return tx.privacyNotice.create({
|
||||
data: {
|
||||
version: (latest?.version ?? 0) + 1,
|
||||
sections: clean,
|
||||
changeSummary: changeSummary ? String(changeSummary).trim().slice(0, 2000) : null,
|
||||
requiresAcceptance: !!requiresAcceptance,
|
||||
publishedById: publishedById || null,
|
||||
},
|
||||
});
|
||||
});
|
||||
await discardDraft(db);
|
||||
return notice;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
PLACEHOLDERS,
|
||||
defaultSections,
|
||||
cleanSections,
|
||||
noticeContext,
|
||||
renderSections,
|
||||
operatorLine,
|
||||
requiredVersion,
|
||||
noticePromptPending,
|
||||
publicNotice,
|
||||
currentVersion,
|
||||
acceptNotice,
|
||||
getDraft,
|
||||
saveDraft,
|
||||
discardDraft,
|
||||
publish,
|
||||
};
|
||||
@@ -0,0 +1,109 @@
|
||||
jest.mock('../src/config/db', () => ({
|
||||
privacyNotice: { findFirst: jest.fn(), findMany: jest.fn(), findUnique: jest.fn() },
|
||||
operator: { findMany: jest.fn() },
|
||||
appSetting: { findMany: jest.fn().mockResolvedValue([]), findUnique: jest.fn() },
|
||||
}));
|
||||
|
||||
const prisma = require('../src/config/db');
|
||||
const notice = require('../src/utils/privacyNotice');
|
||||
const { reportRowCount } = require('../src/utils/accessLog');
|
||||
|
||||
const ctx = {
|
||||
org: 'Hope Church', orgEmail: 'office@hope.test', website: 'hope.test', ioName: 'Ann IO', ioEmail: 'io@hope.test', medicalRetentionDays: '30',
|
||||
operators: [
|
||||
{ name: 'PayFast', location: 'South Africa', purpose: 'Online payments', dataShared: 'Name, email address' },
|
||||
{ name: 'WAWP', location: null, purpose: 'Delivering WhatsApp messages', dataShared: 'Mobile number' },
|
||||
],
|
||||
};
|
||||
|
||||
beforeEach(() => jest.clearAllMocks());
|
||||
|
||||
describe('renderSections', () => {
|
||||
test('fills placeholders and expands {operators} into one bullet each', () => {
|
||||
const [s] = notice.renderSections([{ id: 'a', title: 'A', body: 'Run by {org} — ask {ioName} ({ioEmail}).\n{operators}\nUnknown {nope} stays.' }], ctx);
|
||||
expect(s.body.split('\n')).toEqual([
|
||||
'Run by Hope Church — ask Ann IO (io@hope.test).',
|
||||
'• PayFast (South Africa) — Online payments. Receives: Name, email address.',
|
||||
'• WAWP — Delivering WhatsApp messages. Receives: Mobile number.',
|
||||
'Unknown {nope} stays.',
|
||||
]);
|
||||
});
|
||||
|
||||
test('{operators} inside a sentence is left alone', () => {
|
||||
const [s] = notice.renderSections([{ id: 'a', title: 'A', body: 'See {operators} below' }], ctx);
|
||||
expect(s.body).toBe('See {operators} below');
|
||||
});
|
||||
|
||||
test('the built-in notice has no unfilled placeholders', () => {
|
||||
const rendered = notice.renderSections(notice.defaultSections(), ctx);
|
||||
for (const s of rendered) expect(s.body).not.toMatch(/\{\w+\}/);
|
||||
expect(rendered.find(s => s.id === 'sharing').body).toContain('• PayFast (South Africa)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('cleanSections', () => {
|
||||
test('trims, makes ids from titles and keeps them unique', () => {
|
||||
const out = notice.cleanSections([
|
||||
{ title: ' 1. Introduction ', body: 'Hi\r\nthere ' },
|
||||
{ title: 'Introduction', body: '' },
|
||||
]);
|
||||
expect(out).toEqual([
|
||||
{ id: 'introduction', title: '1. Introduction', body: 'Hi\nthere' },
|
||||
{ id: 'introduction-2', title: 'Introduction', body: '' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('refuses an empty notice or a section without a title', () => {
|
||||
expect(() => notice.cleanSections([])).toThrow(/at least one section/);
|
||||
expect(() => notice.cleanSections([{ title: ' ', body: 'x' }])).toThrow(/needs a title/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('noticePromptPending', () => {
|
||||
test('nobody is asked while no version needs re-acceptance', async () => {
|
||||
prisma.privacyNotice.findFirst.mockResolvedValue(null);
|
||||
expect(await notice.noticePromptPending({ noticeVersionAccepted: null })).toBe(false);
|
||||
});
|
||||
|
||||
test('asked when they accepted an older version than the newest one that needs it', async () => {
|
||||
prisma.privacyNotice.findFirst.mockResolvedValue({ version: 3 });
|
||||
expect(await notice.noticePromptPending({ noticeVersionAccepted: 2 })).toBe(true);
|
||||
expect(await notice.noticePromptPending({ noticeVersionAccepted: null })).toBe(true);
|
||||
expect(await notice.noticePromptPending({ noticeVersionAccepted: 3 })).toBe(false);
|
||||
expect(await notice.noticePromptPending({ noticeVersionAccepted: 5 })).toBe(false);
|
||||
expect(prisma.privacyNotice.findFirst.mock.calls[0][0].where).toEqual({ requiresAcceptance: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('publicNotice', () => {
|
||||
beforeEach(() => prisma.operator.findMany.mockResolvedValue([]));
|
||||
|
||||
test('the built-in notice is version 0 while nothing is published', async () => {
|
||||
prisma.privacyNotice.findMany.mockResolvedValue([]);
|
||||
const n = await notice.publicNotice(null);
|
||||
expect(n.version).toBe(0);
|
||||
expect(n.builtIn).toBe(true);
|
||||
expect(n.sections.length).toBeGreaterThan(10);
|
||||
});
|
||||
|
||||
test('once published, version 0 is no longer available as a past version', async () => {
|
||||
prisma.privacyNotice.findMany.mockResolvedValue([{ version: 1, publishedAt: new Date() }]);
|
||||
expect(await notice.publicNotice(0)).toBeNull();
|
||||
});
|
||||
|
||||
test('a published version, marked current only when it is the newest', async () => {
|
||||
prisma.privacyNotice.findMany.mockResolvedValue([{ version: 2 }, { version: 1 }]);
|
||||
prisma.privacyNotice.findUnique.mockResolvedValue({ version: 1, publishedAt: new Date('2026-10-01T10:00:00Z'), changeSummary: 'x', sections: [{ id: 'a', title: 'A', body: 'Hello' }] });
|
||||
const n = await notice.publicNotice(1);
|
||||
expect(n).toMatchObject({ version: 1, builtIn: false, current: false, changeSummary: 'x' });
|
||||
expect(n.sections[0].body).toBe('Hello');
|
||||
});
|
||||
});
|
||||
|
||||
describe('reportRowCount', () => {
|
||||
test('counts table rows, layered items and extra tables', () => {
|
||||
expect(reportRowCount({ table: { rows: [[1], [2]] }, extraTables: [{ rows: [[1]] }] })).toBe(3);
|
||||
expect(reportRowCount({ layered: { sections: [{ items: ['a', 'b'] }, { items: ['c'] }] } })).toBe(3);
|
||||
expect(reportRowCount(null)).toBe(0);
|
||||
});
|
||||
});
|
||||
+19
-7
@@ -1,6 +1,6 @@
|
||||
# Handover — where things stand and how to carry on
|
||||
|
||||
Last updated: 2026-10-23, after **1.38.0**. The plan itself is in
|
||||
Last updated: 2026-10-23, after **1.39.0**. The plan itself is in
|
||||
[ROADMAP-v2.md](./ROADMAP-v2.md) (decisions table at the end); the previous round of
|
||||
features is in [FEATURES-ROUND-1.md](./FEATURES-ROUND-1.md).
|
||||
|
||||
@@ -8,6 +8,7 @@ features is in [FEATURES-ROUND-1.md](./FEATURES-ROUND-1.md).
|
||||
|
||||
| Version | What |
|
||||
|---------|------|
|
||||
| 1.39.0 | POPIA toolkit part 2 — Admin → Privacy: privacy notice editor (built-in text as the start, placeholders, drafts, numbered versions, re-acceptance after login), operator register, consent log, access log; Information Officer on the contact page |
|
||||
| 1.38.0 | POPIA toolkit part 1 — Privacy page (news opt-in + history, download my data, correction and deletion requests), Event update vs News & announcements on every bulk send, unsubscribe links + one-click headers, WhatsApp STOP, one-time news question, Admin → Data requests, thorough anonymising |
|
||||
| 1.32.0 | Early-bird ribbon on event cards |
|
||||
| 1.33.0 | Multi-day events phase A — sessions, late joining + late pricing, per-session check-in, attendance register, session reminders, certificates (4 templates, verify page) |
|
||||
@@ -23,12 +24,12 @@ features is in [FEATURES-ROUND-1.md](./FEATURES-ROUND-1.md).
|
||||
|
||||
## Next up
|
||||
|
||||
1. **1.39.0 — POPIA toolkit part 2** (spec §3 in the roadmap): Information Officer on the
|
||||
privacy notice/contact page, privacy notice editor with versions and re-acceptance, consent
|
||||
log page (the data is already in `CommunicationConsent`), retention policies + nightly job,
|
||||
operator register, access log (exports, attendee lists, medical list, impersonation),
|
||||
breach register, POPIA readiness page.
|
||||
2. **1.40.0 + Hub v1** — install/update tooling and the Hope Events Hub (separate repo
|
||||
1. **1.40.0 — POPIA toolkit part 3** (spec §3 in the roadmap, decisions table): retention
|
||||
policies per data category + nightly job (inactive accounts anonymised after 5 years, **on by
|
||||
default**, warning email 30 days before, never staff/supervisor/admin), breach register with
|
||||
notification templates, children's-data flags in reports, POPIA readiness page with a score.
|
||||
The notice's retention section should then fill in from the policies (a new placeholder).
|
||||
2. **1.41.0 + Hub v1** — install/update tooling and the Hope Events Hub (separate repo
|
||||
`joshua/hope-events-hub`).
|
||||
|
||||
## Working on this repo
|
||||
@@ -134,6 +135,17 @@ features is in [FEATURES-ROUND-1.md](./FEATURES-ROUND-1.md).
|
||||
`components/shared/NewsPrompt.tsx` (dashboard layout), `MessageKindPicker.tsx`.
|
||||
- **Anything new that stores personal data** must be added to `anonymiseUser` and
|
||||
`exportUserData`.
|
||||
- Privacy notice: `utils/privacyNotice.js` (built-in sections, placeholders, versions, draft in
|
||||
AppSetting `privacy_notice_draft`, `noticePromptPending`); public page `app/legal/privacy`
|
||||
(renders whatever `/api/privacy/notice` returns), admin `app/dashboard/admin/privacy` +
|
||||
`components/admin/privacy/*`, prompt `components/shared/NoticePrompt.tsx` (via
|
||||
`PrivacyPrompts.tsx`: notice first, then the news question). **Changing the built-in text
|
||||
changes the live notice only for installs that haven't published a version yet.**
|
||||
- Access log: `utils/accessLog.js` (`logDataAccess(req, { action, what, eventId, records })`).
|
||||
**Any new bulk export or list of personal data should call it** and get a label in
|
||||
`ACCESS_ACTIONS`. Operators: `Operator` table, seeded in the 1.39.0 migration.
|
||||
- e2e: `privacy-notice.spec.ts` publishes a version that needs accepting, then accepts it for all
|
||||
seeded users in `afterAll` so later spec files don't meet the prompt.
|
||||
|
||||
## Known gaps / follow-ups
|
||||
|
||||
|
||||
+13
-9
@@ -20,8 +20,8 @@ picker at the door and session choice on "All sessions" tickets; 1.36.0 payment
|
||||
(Yoco moved behind it, Site settings → Payments); 1.37.0 PayFast and the "Pay online" page;
|
||||
1.37.1 "Pay online" hidden when it can't be used; 1.37.2 payment status checks for missing
|
||||
notifications. SnapScan and Zapper are dropped — Yoco and PayFast are enough (2026-10-23);
|
||||
1.38.0 POPIA toolkit part 1 (privacy page, unsubscribe, WhatsApp STOP, data requests).
|
||||
**Next: 1.39.0 POPIA toolkit part 2 (admin tools).**
|
||||
1.38.0 POPIA toolkit part 1 (privacy page, unsubscribe, WhatsApp STOP, data requests); 1.39.0 part 2 (privacy notice editor and versions, operator register, consent and access logs).
|
||||
**Next: 1.40.0 POPIA toolkit part 3 (retention, breach register, readiness).** Hub moved to 1.41.0.
|
||||
|
||||
**Repositories:**
|
||||
|
||||
@@ -733,13 +733,13 @@ keep adding to.
|
||||
| 2 | 1.34.0 ✅ | CI + Playwright E2E + job queue | Safety net before the riskiest work (payments). Session reminders move to the queue |
|
||||
| 3 | 1.35.0 ✅ | Multi-day events — phase B (pick sessions, per-session capacity) | Builds on phase A, now with E2E coverage |
|
||||
| 4 | 1.36.0 ✅ – 1.37.2 | Payment provider interface (Yoco moved behind it) → PayFast, then hiding "Pay online" when it can't be used and payment status checks (SnapScan and Zapper dropped) | Payment code gets split out of the big controller as part of this |
|
||||
| 5 | 1.38.0 ✅ – 1.39.0 | **POPIA toolkit** (1.38.0: user privacy centre, unsubscribe, WhatsApp STOP, data requests inbox; 1.39.0: admin tools, retention jobs, readiness page) | Main selling point; retention jobs use the job queue |
|
||||
| 6 | 1.40.0 + Hub v1 | Install/update scripts, `/health`, licence step in `/setup`, "Connect to Hub", heartbeat, licence states — plus the **Hub** app (separate repo) | Must exist before the first church is sold; Hope Family Church registers as install #1 |
|
||||
| 7 | 1.41.0 | **Multi-campus** | Kids rooms and volunteer roles are per campus, so campuses come first |
|
||||
| 8 | 1.42.0 – 1.43.0 | Kids check-in, Volunteer rosters | Depend on sessions and campuses |
|
||||
| 9 | 1.44.0 – 1.46.0 | Event series & cloning, Wallet passes, Outbound webhooks | Smaller, independent wins |
|
||||
| 10 | 1.47.0 – 1.49.0 | Admin dashboard/Ctrl+K/bulk actions, Afrikaans, Accessibility pass | Polish before the big release |
|
||||
| 11 | 1.50.0 | TypeScript backend + OpenAPI + `/api/v2` alongside v1 | The breaking-change groundwork |
|
||||
| 5 | 1.38.0 ✅ 1.39.0 ✅ – 1.40.0 | **POPIA toolkit** (1.38.0: user privacy centre, unsubscribe, WhatsApp STOP, data requests inbox; 1.39.0: privacy notice editor + versions, operator register, consent log, access log; 1.40.0: retention jobs, breach register, children’s-data flags, readiness page) | Main selling point; retention jobs use the job queue |
|
||||
| 6 | 1.41.0 + Hub v1 | Install/update scripts, `/health`, licence step in `/setup`, "Connect to Hub", heartbeat, licence states — plus the **Hub** app (separate repo) | Must exist before the first church is sold; Hope Family Church registers as install #1 |
|
||||
| 7 | 1.42.0 | **Multi-campus** | Kids rooms and volunteer roles are per campus, so campuses come first |
|
||||
| 8 | 1.43.0 – 1.44.0 | Kids check-in, Volunteer rosters | Depend on sessions and campuses |
|
||||
| 9 | 1.45.0 – 1.47.0 | Event series & cloning, Wallet passes, Outbound webhooks | Smaller, independent wins |
|
||||
| 10 | 1.48.0 – 1.50.0 | Admin dashboard/Ctrl+K/bulk actions, Afrikaans, Accessibility pass | Polish before the big release |
|
||||
| 11 | 1.51.0 | TypeScript backend + OpenAPI + `/api/v2` alongside v1 | The breaking-change groundwork |
|
||||
| 12 | **2.0.0** | v1 API removed, migration guide, marketing site/docs for selling | The release |
|
||||
|
||||
Each phase should add matching help content (`frontend/src/content/help/`),
|
||||
@@ -776,6 +776,10 @@ feature.
|
||||
| — | POPIA: message kinds | Bulk messages are either **Event updates** (to registrants — still sent after unsubscribing) or **News & announcements** (marketing — opt-in, one-click unsubscribe). Every bulk email links to the preferences (2026-10-23) |
|
||||
| — | POPIA: existing users | Kept opted in to news (logged as "existing account"), asked once at their next login to confirm or opt out. New sign-ups choose with an unticked box (2026-10-23) |
|
||||
| — | POPIA: deleting an account | A request: the account is deactivated at once, personal info is anonymised when an admin approves. Waits while the person has an open registration for an upcoming event (2026-10-23) |
|
||||
| — | POPIA part 2 split | 1.39.0: privacy notice editor + versions + re-acceptance, operator register, Information Officer on the contact page, consent log, access log. 1.40.0: retention policies + nightly job, breach register, children's-data flags, readiness page. Hub moves to 1.41.0 (2026-10-23) |
|
||||
| — | POPIA: privacy notice | Today's notice becomes the starting template, one editable box per section; org name, Information Officer, retention periods and the operator list fill in automatically. Publishing saves a numbered version (2026-10-23) |
|
||||
| — | POPIA: re-accepting the notice | The admin decides per version ("significant change — ask everyone to accept again"); people are asked once after login (2026-10-23) |
|
||||
| — | POPIA: inactive accounts | Anonymised after 5 years without a login or registration, **on by default**, with a warning email 30 days before; staff/supervisor/admin accounts never (2026-10-23) |
|
||||
| — | POPIA: WhatsApp STOP | In 1.38.0, via WAWP's incoming `message` webhook with a secret in the URL (WAWP doesn't sign webhooks) (2026-10-23) |
|
||||
|
||||
## Still open
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { test, expect, seed, loginAs, api, API, type Role } from "./fixtures";
|
||||
|
||||
type Section = { id: string; title: string; body: string };
|
||||
type Draft = { sections: Section[]; versions: { version: number; acceptances: number }[] };
|
||||
type Notice = { version: number; builtIn: boolean; sections: Section[] };
|
||||
type Operator = { id: string; name: string; active: boolean };
|
||||
type AccessLog = { rows: { action: string; what: string; records: number | null; actor: string }[] };
|
||||
type ConsentLog = { rows: { version?: number; source: string; email: string | null }[] };
|
||||
|
||||
test.describe("privacy notice, operators and access log (POPIA)", () => {
|
||||
// A version that needs accepting is published below; accept it for every seeded account
|
||||
// afterwards so later test files don't meet the prompt.
|
||||
test.afterAll(async ({ request }) => {
|
||||
const tokens = [...(["admin", "supervisor", "staff", "user"] as Role[]).map(r => seed().users[r].token), seed().newsPromptUser.token];
|
||||
for (const token of tokens) {
|
||||
await request.post(`${API}/api/privacy/me/notice/accept`, { headers: { Authorization: `Bearer ${token}` } });
|
||||
}
|
||||
});
|
||||
|
||||
test("the built-in notice is shown until a version is published, with the operator register filled in", async ({ page }) => {
|
||||
const notice = await (await page.request.get(`${API}/api/privacy/notice`)).json() as Notice;
|
||||
expect(notice.version).toBe(0);
|
||||
expect(notice.sections.find(s => s.id === "sharing")?.body).toContain("• PayFast (South Africa)");
|
||||
|
||||
await page.goto("/legal/privacy");
|
||||
await expect(page.getByRole("heading", { name: "Privacy Policy" })).toBeVisible();
|
||||
await expect(page.getByText("• PayFast (South Africa)", { exact: false })).toBeVisible();
|
||||
});
|
||||
|
||||
test("an admin edits and publishes; operators feed the notice", async ({ page }) => {
|
||||
// A new operator appears in the notice's {operators} list.
|
||||
const op = await api<Operator>(page, "admin", "POST", "/api/privacy/operators", { name: "E2E Mailer", purpose: "Sending emails", dataShared: "Email address", location: "Ireland" });
|
||||
|
||||
await loginAs(page, "admin");
|
||||
await page.goto("/dashboard/admin/privacy");
|
||||
const intro = page.getByLabel("1. Introduction text");
|
||||
await expect(intro).toBeVisible();
|
||||
await intro.fill("{org} looks after your information carefully. (E2E edit)");
|
||||
await page.getByRole("button", { name: "Save draft" }).click();
|
||||
await expect(page.getByText("Draft saved.")).toBeVisible();
|
||||
|
||||
// Nothing public changes until it's published.
|
||||
expect(((await (await page.request.get(`${API}/api/privacy/notice`)).json()) as Notice).version).toBe(0);
|
||||
|
||||
await page.getByRole("button", { name: "Publish…" }).click();
|
||||
await page.getByPlaceholder("e.g. We now use PayFast for online payments.").fill("Clearer introduction.");
|
||||
await page.getByRole("button", { name: "Publish", exact: true }).click();
|
||||
await expect(page.getByText("Version 1 is published.")).toBeVisible();
|
||||
|
||||
await page.goto("/legal/privacy");
|
||||
await expect(page.getByText("E2E Church looks after your information carefully. (E2E edit)")).toBeVisible();
|
||||
await expect(page.getByText("• E2E Mailer (Ireland) — Sending emails.", { exact: false })).toBeVisible();
|
||||
await expect(page.getByText(/Version 1/).first()).toBeVisible();
|
||||
|
||||
await api(page, "admin", "DELETE", `/api/privacy/operators/${op.id}`);
|
||||
});
|
||||
|
||||
test("a significant change is accepted once, after login", async ({ page }) => {
|
||||
const draft = await api<Draft>(page, "admin", "GET", "/api/privacy/notice/draft");
|
||||
// Re-acceptance needs a summary of what changed.
|
||||
await expect(api(page, "admin", "POST", "/api/privacy/notice/publish", { sections: draft.sections, requiresAcceptance: true })).rejects.toThrow(/400/);
|
||||
const published = await api<{ version: number }>(page, "admin", "POST", "/api/privacy/notice/publish", { sections: draft.sections, requiresAcceptance: true, changeSummary: "We added WhatsApp news." });
|
||||
|
||||
await loginAs(page, "user");
|
||||
await page.goto("/dashboard/user");
|
||||
const dialog = page.getByRole("dialog", { name: "We've updated our privacy notice" });
|
||||
await expect(dialog).toBeVisible();
|
||||
await expect(dialog.getByText("We added WhatsApp news.")).toBeVisible();
|
||||
await dialog.getByRole("button", { name: "I accept" }).click();
|
||||
await expect(dialog).toBeHidden();
|
||||
await page.reload();
|
||||
await expect(page.getByText("My Events").first()).toBeVisible();
|
||||
await expect(page.getByRole("dialog", { name: "We've updated our privacy notice" })).toHaveCount(0);
|
||||
|
||||
const log = await api<ConsentLog>(page, "admin", "GET", `/api/privacy/consent-log?type=notice&q=${encodeURIComponent(seed().users.user.email)}`);
|
||||
expect(log.rows[0]).toMatchObject({ version: published.version, source: "prompt" });
|
||||
});
|
||||
|
||||
test("bulk exports are in the access log", async ({ page }) => {
|
||||
const res = await page.request.post(`${API}/api/reports/pdf`, {
|
||||
headers: { Authorization: `Bearer ${seed().users.supervisor.token}`, "Content-Type": "application/json" },
|
||||
data: JSON.stringify({ title: "E2E attendee list", kind: "table", table: { columns: ["Name"], rows: [["A"], ["B"], ["C"]] } }),
|
||||
});
|
||||
expect(res.ok()).toBe(true);
|
||||
await api(page, "supervisor", "GET", `/api/consent/events/${seed().events.free}`);
|
||||
|
||||
const log = await api<AccessLog>(page, "admin", "GET", "/api/privacy/access-log");
|
||||
expect(log.rows.find(r => r.action === "report_pdf")).toMatchObject({ what: "E2E attendee list", records: 3, actor: "Sam Supervisor" });
|
||||
expect(log.rows.some(r => r.action === "consent_list")).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -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's handled.
|
||||
People'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'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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'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'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 "Allow" 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 & 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 "• " 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'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 "operators"). The ones in
|
||||
use are listed in the privacy notice wherever it says <code>{"{operators}"}</code>. Check each one'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'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'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 />;
|
||||
}
|
||||
@@ -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 "Significant change" and say what changed when people should accept it again — they'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 "in use" 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 "view as" session.
|
||||
</GuideItem>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
/** Help content for /dashboard/admin/data-requests. */
|
||||
export const adminDataRequestsHelpContent: HelpContent = {
|
||||
title: "Data requests",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 & {
|
||||
|
||||
Reference in New Issue
Block a user