Files
gerbeur/src/hooks/useAuth.ts
2026-06-27 19:44:35 +00:00

81 lines
2.1 KiB
TypeScript

import { useCallback, useContext } from "react";
import { AuthContext } from "../contexts/AuthContext.ts";
import { type AuthResponse } from "../model.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 const useAuth = () => {
const { authResponse, setAuthResponse } = useContext(AuthContext);
const login = useCallback((response: AuthResponse) => {
setAuthResponse(response);
localStorage.setItem("authResponse", JSON.stringify(response));
}, [setAuthResponse]);
const logout = useCallback(() => {
setAuthResponse(null);
localStorage.removeItem("authResponse");
}, [setAuthResponse]);
const authFetch = useCallback(async (
input: RequestInfo,
init: RequestInit = {},
) => {
const token = authResponse?.token;
if (token && isTokenExpired(token)) {
logout();
// Return a synthetic 401 so callers handle it consistently
return new Response(null, { status: 401 });
}
const isFormData = init.body instanceof FormData;
const res = await fetch(input, {
...init,
headers: {
...(init.headers ?? {}),
// Only attach a token when we actually have one — avoids sending a
// `Bearer undefined` header that the server would reject as invalid.
...(token ? { Authorization: `Bearer ${token}` } : {}),
// Let the browser set Content-Type for FormData (it includes the boundary)
...(isFormData ? {} : { "Content-Type": "application/json" }),
},
});
if (res.status === 401) {
logout();
}
return res;
}, [authResponse?.token, logout]);
return {
user: authResponse?.user ?? null,
token: authResponse?.token ?? null,
login,
logout,
authFetch,
};
};
export const useRequiredAuth = () => {
const { user, token, login, logout, authFetch } = useAuth();
if (!user) {
throw new Error(
"Invariant: useRequiredAuth called outside a protected route",
);
}
return { user, token, login, logout, authFetch };
};