Files
hope-events/frontend/src/contexts/AuthContext.tsx
T
joshuaandClaude Opus 5.5 594cb35a85 POPIA toolkit part 1: privacy page, unsubscribe, WhatsApp STOP, data requests
- Privacy page: news opt-in per channel with history, download my data
  (JSON + PDF), correction and deletion requests
- Bulk messages are an Event update or News & announcements; news only
  reaches people who opted in, with unsubscribe links, one-click
  List-Unsubscribe headers and "Reply STOP" on WhatsApp
- WhatsApp STOP via WAWP's message webhook, checked against a secret in
  the webhook URL
- Sign-up opt-in box; existing accounts kept opted in and asked once
- Admin -> Data requests with 30-day due dates and a daily reminder job
- Deleting an account is a request; anonymising now covers attendees,
  form answers, consent details, payer details in provider payloads, etc.
- Unit tests (privacy.test.js) and e2e (privacy.spec.ts); docs

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012mt6TCv85T9VKRq3cV7rXq
2026-10-23 18:13:47 +02:00

158 lines
5.8 KiB
TypeScript

"use client";
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
import { apiFetch, ApiError } from "@/lib/api";
import { AuthResponse, AuthUser, TwoFactorChallenge, clearToken, getDeviceToken, getToken, isTwoFactorChallenge, saveDeviceToken, saveToken } from "@/lib/auth";
export type AuthContextValue = {
user: AuthUser | null;
token: string | null;
loading: boolean;
login: (email: string, password: string) => Promise<AuthUser | TwoFactorChallenge>;
verifyTwoFactor: (challengeId: string, code: string, rememberDevice: boolean) => Promise<AuthUser>;
resendTwoFactor: (challengeId: string) => Promise<TwoFactorChallenge>;
register: (data: { name: string; email: string; password: string; phoneNumber?: string; notificationPreference?: "email" | "whatsapp" | "both"; newsOptIn?: boolean }) => Promise<void>;
logout: () => void;
updateToken: (token: string) => void;
};
const AuthContext = createContext<AuthContextValue | undefined>(undefined);
// The last profile the server confirmed, so a page reloaded with no connection (e.g. the
// ticket scanner in offline mode) still knows who's signed in. The server checks the
// token on every API call regardless, so this never grants anything by itself.
const USER_CACHE_KEY = "hope_events_user_cache";
function cacheUser(u: AuthUser | null) {
try {
if (u) localStorage.setItem(USER_CACHE_KEY, JSON.stringify(u));
else localStorage.removeItem(USER_CACHE_KEY);
} catch { /* storage unavailable */ }
}
function cachedUser(): AuthUser | null {
try {
const raw = localStorage.getItem(USER_CACHE_KEY);
return raw ? JSON.parse(raw) : null;
} catch {
return null;
}
}
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [token, setToken] = useState<string | null>(null);
const [user, setUser] = useState<AuthUser | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const init = async () => {
try {
const t = getToken();
if (t) {
// Validate the stored token by fetching the user profile.
// Only update React state after a successful response so we never
// hold a half-initialised (token set, user null) state.
const me = await apiFetch<AuthUser>("/api/users/profile", { authToken: t });
setToken(t);
setUser(me);
cacheUser(me);
}
} catch (e) {
// Only wipe the stored token when the server explicitly rejects it (401).
// Transient failures (network down, 5xx) should NOT log the user out — the
// token stays in localStorage so the next page load can retry.
if (e instanceof ApiError && e.status === 401) {
clearToken();
cacheUser(null);
setToken(null);
setUser(null);
} else {
// No connection: carry on as the last confirmed user.
const t = getToken();
const cached = cachedUser();
if (t && cached) {
setToken(t);
setUser(cached);
}
}
} finally {
setLoading(false);
}
};
init();
}, []);
const startSession = useCallback((res: AuthResponse) => {
const { token, deviceToken, ...loggedInUser } = res;
if (deviceToken) saveDeviceToken(deviceToken);
saveToken(token);
setToken(token);
setUser(loggedInUser);
cacheUser(loggedInUser);
return loggedInUser;
}, []);
const login = useCallback(async (email: string, password: string) => {
const res = await apiFetch<AuthResponse | TwoFactorChallenge>("/api/users/login", {
method: "POST",
body: { email, password, deviceToken: getDeviceToken() || undefined },
});
// 2FA accounts get a challenge back instead of a token — nothing is saved
// until verifyTwoFactor exchanges the emailed/WhatsApp code for one.
if (isTwoFactorChallenge(res)) return res;
return startSession(res as AuthResponse);
}, [startSession]);
const verifyTwoFactor = useCallback(async (challengeId: string, code: string, rememberDevice: boolean) => {
const res = await apiFetch<AuthResponse>("/api/users/login/2fa", {
method: "POST",
body: { challengeId, code, rememberDevice },
});
return startSession(res);
}, [startSession]);
const resendTwoFactor = useCallback((challengeId: string) => {
return apiFetch<TwoFactorChallenge>("/api/users/login/2fa/resend", {
method: "POST",
body: { challengeId },
});
}, []);
const register = useCallback(async (data: { name: string; email: string; password: string; phoneNumber?: string; notificationPreference?: "email" | "whatsapp" | "both"; newsOptIn?: boolean }) => {
const res = await apiFetch<AuthResponse>("/api/users", {
method: "POST",
body: data,
});
const { token, ...registeredUser } = res;
saveToken(token);
setToken(token);
setUser(registeredUser);
}, []);
const logout = useCallback(() => {
clearToken();
cacheUser(null);
setToken(null);
setUser(null);
}, []);
// Some endpoints (profile update, password change, session revocation) issue a
// fresh token mid-session. Persist it AND update in-memory state so the app
// keeps using a valid token without requiring a page refresh.
const updateToken = useCallback((t: string) => {
saveToken(t);
setToken(t);
}, []);
const value = useMemo(
() => ({ user, token, loading, login, verifyTwoFactor, resendTwoFactor, register, logout, updateToken }),
[loading, login, logout, register, resendTwoFactor, token, updateToken, user, verifyTwoFactor]
);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
export function useAuthContext() {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error("useAuthContext must be used within AuthProvider");
return ctx;
}