- 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
158 lines
5.8 KiB
TypeScript
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;
|
|
}
|