- 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
64 lines
2.3 KiB
TypeScript
64 lines
2.3 KiB
TypeScript
export type AuthUser = {
|
|
id: string;
|
|
name: string;
|
|
email: string;
|
|
role?: "admin" | "supervisor" | "staff" | "user";
|
|
phoneNumber?: string | null;
|
|
notificationPreference?: "email" | "whatsapp" | "both";
|
|
twoFactorEnabled?: boolean;
|
|
twoFactorMethod?: TwoFactorMethod | null;
|
|
// True for roles that must use 2FA (admin, supervisor) — it can't be turned off.
|
|
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 & {
|
|
token: string;
|
|
// Only present when the user ticked "Trust this device" on the 2FA step.
|
|
deviceToken?: string;
|
|
};
|
|
|
|
export type TwoFactorMethod = "email" | "whatsapp";
|
|
|
|
// What the login endpoint returns instead of a token when the account has 2FA on:
|
|
// a code was sent to `destination` (masked) and must be exchanged for the token.
|
|
export type TwoFactorChallenge = {
|
|
twoFactorRequired?: true;
|
|
challengeId: string;
|
|
method: TwoFactorMethod;
|
|
destination: string;
|
|
// The role requires 2FA and it wasn't on yet: verifying this code turns it on.
|
|
enrolling?: boolean;
|
|
};
|
|
|
|
export function isTwoFactorChallenge(res: unknown): res is TwoFactorChallenge {
|
|
return !!res && typeof res === "object" && (res as TwoFactorChallenge).twoFactorRequired === true;
|
|
}
|
|
|
|
const TOKEN_KEY = "hope_events_token";
|
|
// Remembers this browser after a 2FA code so the next login can skip it. Kept on
|
|
// logout on purpose — it identifies the device, not the session.
|
|
const DEVICE_KEY = "hope_events_device";
|
|
|
|
export function saveDeviceToken(token: string) {
|
|
if (typeof window !== "undefined") localStorage.setItem(DEVICE_KEY, token);
|
|
}
|
|
export function getDeviceToken(): string | null {
|
|
if (typeof window === "undefined") return null;
|
|
return localStorage.getItem(DEVICE_KEY);
|
|
}
|
|
|
|
export function saveToken(token: string) {
|
|
if (typeof window !== "undefined") localStorage.setItem(TOKEN_KEY, token);
|
|
}
|
|
export function getToken(): string | null {
|
|
if (typeof window === "undefined") return null;
|
|
return localStorage.getItem(TOKEN_KEY);
|
|
}
|
|
export function clearToken() {
|
|
if (typeof window !== "undefined") localStorage.removeItem(TOKEN_KEY);
|
|
}
|