Files
gerbeur/src/contexts/AuthProvider.tsx
2026-06-30 13:41:36 +02:00

110 lines
3.7 KiB
TypeScript

import { type ReactNode, useEffect, useState } from "react";
import { AuthContext, type AuthContextValue } from "./AuthContext.ts";
import {
type AuthResponse,
deserializeAuthResponse,
deserializePublicUser,
} from "../model.ts";
import { API_URL } from "../config/api.ts";
function isTokenExpired(token: string): boolean {
try {
const payload = JSON.parse(atob(token.split(".")[1]));
return typeof payload.exp === "number" && payload.exp * 1000 < Date.now();
} catch {
return true;
}
}
export function AuthProvider({ children }: { children: ReactNode }) {
const [authResponse, setAuthResponse] = useState<AuthResponse | null>(() => {
const stored = localStorage.getItem("authResponse");
if (!stored) return null;
let parsed;
try {
parsed = deserializeAuthResponse(JSON.parse(stored));
} catch {
localStorage.removeItem("authResponse");
return null;
}
if (isTokenExpired(parsed.token)) {
localStorage.removeItem("authResponse");
return null;
}
// Sessions persisted before roles existed have no `role`; the server also
// rejects their tokens, but discard them here so the UI never renders with
// a roleless user.
if (!parsed.user.role) {
localStorage.removeItem("authResponse");
return null;
}
return parsed;
});
// The `exp` pre-check above only catches expiry; it cannot detect a token the
// server rejects for another reason (e.g. a changed GERBEUR_JWT_SECRET, which
// leaves the signature invalid but the token unexpired). Ask the server — the
// only authority on validity — and clear the session if it returns 401, so the
// UI never shows a false "logged in" state. Re-check when the tab regains
// focus to catch tokens invalidated by a server restart while we were away.
const token = authResponse?.token;
useEffect(() => {
if (!token) return;
const validate = async () => {
try {
const res = await fetch(`${API_URL}/api/users/me`, {
headers: { Authorization: `Bearer ${token}` },
cache: "no-store",
});
if (res.status === 401) {
localStorage.removeItem("authResponse");
setAuthResponse(null);
return;
}
// The stored user is a snapshot from login time; the role (and other
// profile fields) can change server-side — e.g. an admin promoting this
// user. Sync from the authoritative /me response so the UI reflects role
// changes without forcing a logout/login cycle.
if (res.ok) {
const json = await res.json();
if (json?.success && json.data) {
const freshUser = deserializePublicUser(json.data);
setAuthResponse((prev) => {
if (!prev || prev.user.role === freshUser.role) return prev;
const updated = { ...prev, user: freshUser };
localStorage.setItem("authResponse", JSON.stringify(updated));
return updated;
});
}
}
} catch {
// Network error — don't log out an offline user; only an explicit 401 clears the session.
}
};
validate();
const onVisible = () => {
if (document.visibilityState === "visible") validate();
};
globalThis.addEventListener("focus", validate);
document.addEventListener("visibilitychange", onVisible);
return () => {
globalThis.removeEventListener("focus", validate);
document.removeEventListener("visibilitychange", onVisible);
};
}, [token]);
const value: AuthContextValue = { authResponse, setAuthResponse };
return (
<AuthContext.Provider value={value}>
{children}
</AuthContext.Provider>
);
}