All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 41s
110 lines
3.7 KiB
TypeScript
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>
|
|
);
|
|
}
|