- 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
60 lines
2.2 KiB
TypeScript
60 lines
2.2 KiB
TypeScript
"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>
|
|
);
|
|
}
|