Files
hope-events/frontend/src/contexts/AuthContext.tsx
T
joshuaandClaude Opus 5.5 545f3d913e Add shared API types and type the smaller frontend pages
Expand src/types with User, Event, EventOption, Registration, Payment,
Ticket, paginated results and dashboard stats, modelled on the Prisma
schema. apiFetch now defaults to unknown instead of any. Dashboard
loaders are wrapped in useCallback so their effects can list them as
dependencies.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 11:10:01 +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" }) => 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" }) => {
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;
}