Files
fquiz/web/src/components/auth-provider.tsx
T
2026-05-16 22:33:53 +08:00

285 lines
7.2 KiB
TypeScript

"use client";
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { getApiBaseUrl, readApiError } from "@/lib/api";
import { withBasePath } from "@/lib/base-path";
import type { AuthTokenResponse, UserPublic } from "@/types/auth";
const AUTH_SESSION_HINT_KEY = "auth.session_hint";
type AuthContextValue = {
user: UserPublic | null;
accessToken: string | null;
getAccessToken: () => string | null;
initializing: boolean;
login: (userId: string, password: string) => Promise<void>;
register: (email: string, username: string, password: string) => Promise<void>;
logout: () => Promise<void>;
refreshAccessToken: () => Promise<boolean>;
fetchWithAuth: (
path: string,
init?: RequestInit,
retryOnUnauthorized?: boolean,
) => Promise<Response>;
hasRole: (roleCode: string) => boolean;
hasPermission: (permissionCode: string) => boolean;
};
const AuthContext = createContext<AuthContextValue | undefined>(undefined);
function withApiPath(path: string): string {
if (path.startsWith("http://") || path.startsWith("https://")) {
return path;
}
return `${getApiBaseUrl()}${path}`;
}
function hasSessionHint(): boolean {
if (typeof window === "undefined") {
return false;
}
return window.localStorage.getItem(AUTH_SESSION_HINT_KEY) === "1";
}
function persistSessionHint(): void {
if (typeof window === "undefined") {
return;
}
window.localStorage.setItem(AUTH_SESSION_HINT_KEY, "1");
}
function clearSessionHint(): void {
if (typeof window === "undefined") {
return;
}
window.localStorage.removeItem(AUTH_SESSION_HINT_KEY);
}
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<UserPublic | null>(null);
const [accessToken, setAccessToken] = useState<string | null>(null);
const [initializing, setInitializing] = useState(true);
const accessTokenRef = useRef<string | null>(null);
const refreshPromiseRef = useRef<Promise<boolean> | null>(null);
const applyToken = useCallback((token: string | null) => {
accessTokenRef.current = token;
setAccessToken(token);
}, []);
const getAccessToken = useCallback(() => accessTokenRef.current, []);
const clearAuth = useCallback(() => {
setUser(null);
applyToken(null);
clearSessionHint();
}, [applyToken]);
const applyAuthPayload = useCallback(
(payload: AuthTokenResponse) => {
setUser(payload.user);
applyToken(payload.access_token);
persistSessionHint();
},
[applyToken],
);
const refreshAccessToken = useCallback(async (): Promise<boolean> => {
if (refreshPromiseRef.current) {
return refreshPromiseRef.current;
}
const refreshTask = (async (): Promise<boolean> => {
const response = await fetch(withApiPath("/api/v1/auth/refresh"), {
method: "POST",
credentials: "include",
});
if (!response.ok) {
clearAuth();
return false;
}
const payload = (await response.json()) as AuthTokenResponse;
applyAuthPayload(payload);
return true;
})();
refreshPromiseRef.current = refreshTask;
try {
return await refreshTask;
} finally {
if (refreshPromiseRef.current === refreshTask) {
refreshPromiseRef.current = null;
}
}
}, [applyAuthPayload, clearAuth]);
const fetchWithAuth = useCallback(
async (
path: string,
init: RequestInit = {},
retryOnUnauthorized = true,
): Promise<Response> => {
const headers = new Headers(init.headers);
const token = accessTokenRef.current;
if (token) {
headers.set("Authorization", `Bearer ${token}`);
}
const response = await fetch(withApiPath(path), {
...init,
headers,
credentials: "include",
});
if (response.status !== 401 || !retryOnUnauthorized) {
return response;
}
const refreshed = await refreshAccessToken();
if (!refreshed) {
return response;
}
const retryHeaders = new Headers(init.headers);
const nextToken = accessTokenRef.current;
if (nextToken) {
retryHeaders.set("Authorization", `Bearer ${nextToken}`);
}
return fetch(withApiPath(path), {
...init,
headers: retryHeaders,
credentials: "include",
});
},
[refreshAccessToken],
);
const login = useCallback(
async (userId: string, password: string) => {
const response = await fetch(withApiPath("/api/v1/auth/login"), {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ user_id: userId, password }),
});
if (!response.ok) {
throw new Error(await readApiError(response));
}
const payload = (await response.json()) as AuthTokenResponse;
applyAuthPayload(payload);
},
[applyAuthPayload],
);
const register = useCallback(
async (email: string, username: string, password: string) => {
const response = await fetch(withApiPath("/api/v1/auth/register"), {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ email, username, password }),
});
if (!response.ok) {
throw new Error(await readApiError(response));
}
const payload = (await response.json()) as AuthTokenResponse;
applyAuthPayload(payload);
},
[applyAuthPayload],
);
const logout = useCallback(async () => {
try {
await fetch(withApiPath("/api/v1/auth/logout"), {
method: "POST",
credentials: "include",
});
} finally {
if (typeof window !== "undefined") {
window.location.replace(withBasePath("/login"));
return;
}
clearAuth();
}
}, [clearAuth]);
useEffect(() => {
const bootstrap = async () => {
if (!hasSessionHint()) {
setInitializing(false);
return;
}
try {
await refreshAccessToken();
} finally {
setInitializing(false);
}
};
void bootstrap();
}, [refreshAccessToken]);
const hasRole = useCallback(
(roleCode: string) => (user ? user.role_codes.includes(roleCode) : false),
[user],
);
const hasPermission = useCallback(
(permissionCode: string) =>
user
? user.role_codes.includes("admin") || user.permission_codes.includes(permissionCode)
: false,
[user],
);
const value = useMemo<AuthContextValue>(
() => ({
user,
accessToken,
getAccessToken,
initializing,
login,
register,
logout,
refreshAccessToken,
fetchWithAuth,
hasRole,
hasPermission,
}),
[
user,
accessToken,
getAccessToken,
initializing,
login,
register,
logout,
refreshAccessToken,
fetchWithAuth,
hasRole,
hasPermission,
],
);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
export function useAuth(): AuthContextValue {
const context = useContext(AuthContext);
if (!context) {
throw new Error("useAuth must be used inside AuthProvider");
}
return context;
}