import { createContext, type PropsWithChildren, useCallback, useContext, useEffect, useMemo, useRef, useState, } from "react"; import { configureHttpAuth } from "../api/http"; import type { AuthState, LoginResponse, MeResponse } from "../api/types"; import { changePassword as changePasswordApi } from "../api/auth"; import { clearPwaRuntimeCaches } from "../pwa"; import { fetchCurrentEmployee, loginWithPassword, logoutSession, refreshSession, } from "./authService"; import { resolvePermissionsForRoles } from "./permissions"; type OfflineAuthSnapshot = { version: 1; me: MeResponse; permissions: string[]; savedAt: string; }; type AuthContextValue = AuthState & { login: (identifier: string, password: string) => Promise<{ mustChangePassword: boolean }>; changePassword: (oldPassword: string, newPassword: string) => Promise; logout: () => Promise; bootstrapByRefresh: () => Promise; refreshMe: () => Promise; clearSession: () => void; offlineAccessMode: boolean; }; const AuthContext = createContext(null); const EMPTY_STATE: AuthState = { accessToken: null, me: null, permissions: [], mustChangePassword: false, ready: false, }; const MUST_CHANGE_PASSWORD_STORAGE_KEY = "hyb-oa.must_change_password"; const OFFLINE_AUTH_SNAPSHOT_STORAGE_KEY = "hyb-oa.auth_snapshot"; const OFFLINE_AUTH_SNAPSHOT_VERSION = 1; const OFFLINE_AUTH_SNAPSHOT_TTL_MS = 24 * 60 * 60 * 1000; const PROACTIVE_REFRESH_RATIO = 0.8; const MIN_REFRESH_DELAY_MS = 1000; function isWindowAvailable() { return typeof window !== "undefined"; } function readMustChangePasswordFromStorage() { return isWindowAvailable() && window.localStorage.getItem(MUST_CHANGE_PASSWORD_STORAGE_KEY) === "1"; } function writeMustChangePasswordToStorage(value: boolean) { if (!isWindowAvailable()) { return; } if (value) { window.localStorage.setItem(MUST_CHANGE_PASSWORD_STORAGE_KEY, "1"); } else { window.localStorage.removeItem(MUST_CHANGE_PASSWORD_STORAGE_KEY); } } function readOfflineAuthSnapshot() { if (!isWindowAvailable()) { return null; } try { const rawValue = window.localStorage.getItem(OFFLINE_AUTH_SNAPSHOT_STORAGE_KEY); if (!rawValue) { return null; } const parsed = JSON.parse(rawValue) as Partial | null; if (!parsed || parsed.version !== OFFLINE_AUTH_SNAPSHOT_VERSION || !parsed.me || !Array.isArray(parsed.permissions) || typeof parsed.savedAt !== "string") { window.localStorage.removeItem(OFFLINE_AUTH_SNAPSHOT_STORAGE_KEY); return null; } const savedAt = new Date(parsed.savedAt); if (Number.isNaN(savedAt.getTime()) || Date.now() - savedAt.getTime() > OFFLINE_AUTH_SNAPSHOT_TTL_MS) { window.localStorage.removeItem(OFFLINE_AUTH_SNAPSHOT_STORAGE_KEY); return null; } return { version: OFFLINE_AUTH_SNAPSHOT_VERSION, me: parsed.me, permissions: parsed.permissions.map((item) => String(item)), savedAt: savedAt.toISOString(), } satisfies OfflineAuthSnapshot; } catch { window.localStorage.removeItem(OFFLINE_AUTH_SNAPSHOT_STORAGE_KEY); return null; } } function writeOfflineAuthSnapshot(me: MeResponse, permissions: string[]) { if (!isWindowAvailable()) { return; } const snapshot: OfflineAuthSnapshot = { version: OFFLINE_AUTH_SNAPSHOT_VERSION, me, permissions, savedAt: new Date().toISOString(), }; window.localStorage.setItem(OFFLINE_AUTH_SNAPSHOT_STORAGE_KEY, JSON.stringify(snapshot)); } function clearOfflineAuthSnapshot() { if (!isWindowAvailable()) { return; } window.localStorage.removeItem(OFFLINE_AUTH_SNAPSHOT_STORAGE_KEY); } function buildStateWithOfflineSnapshot(snapshot: OfflineAuthSnapshot): AuthState { return { accessToken: null, me: snapshot.me, permissions: snapshot.permissions, mustChangePassword: false, ready: true, }; } export function AuthProvider({ children }: PropsWithChildren) { const [state, setState] = useState(EMPTY_STATE); const [offlineAccessMode, setOfflineAccessMode] = useState(false); const tokenRef = useRef(null); const meRef = useRef(null); const bootstrapPromiseRef = useRef | null>(null); const refreshRequestRef = useRef | null>(null); const proactiveRefreshTimerRef = useRef | null>(null); const scheduleProactiveRefreshRef = useRef<(expiresIn?: number) => void>(() => undefined); const sessionGenerationRef = useRef(0); useEffect(() => { tokenRef.current = state.accessToken; }, [state.accessToken]); useEffect(() => { meRef.current = state.me; }, [state.me]); const clearProactiveRefreshTimer = useCallback(() => { if (proactiveRefreshTimerRef.current !== null) { clearTimeout(proactiveRefreshTimerRef.current); proactiveRefreshTimerRef.current = null; } }, []); const clearSession = useCallback(() => { sessionGenerationRef.current += 1; bootstrapPromiseRef.current = null; refreshRequestRef.current = null; clearProactiveRefreshTimer(); tokenRef.current = null; meRef.current = null; setOfflineAccessMode(false); writeMustChangePasswordToStorage(false); clearOfflineAuthSnapshot(); void clearPwaRuntimeCaches(); setState((previous) => ({ ...previous, accessToken: null, me: null, permissions: [], mustChangePassword: false, ready: true, })); }, [clearProactiveRefreshTimer]); const commitAuthenticatedUser = useCallback( (accessToken: string, me: MeResponse, mustChangePassword: boolean) => { const permissions = resolvePermissionsForRoles(me.roleCodes ?? []); tokenRef.current = accessToken; meRef.current = me; setOfflineAccessMode(false); writeMustChangePasswordToStorage(mustChangePassword); writeOfflineAuthSnapshot(me, permissions); setState({ accessToken, me, permissions, mustChangePassword, ready: true, }); }, [], ); const commitAccessToken = useCallback((accessToken: string) => { tokenRef.current = accessToken; setOfflineAccessMode(false); setState((previous) => ({ ...previous, accessToken, ready: true, })); }, []); const runRefresh = useCallback(async () => { if (refreshRequestRef.current) { return refreshRequestRef.current; } const refreshGeneration = sessionGenerationRef.current; const refreshPromise = (async () => { const refreshed = await refreshSession(); if (refreshGeneration !== sessionGenerationRef.current) { return null; } if (!meRef.current) { const me = await fetchCurrentEmployee(refreshed.accessToken); if (refreshGeneration !== sessionGenerationRef.current) { return null; } const mustChangePassword = readMustChangePasswordFromStorage(); commitAuthenticatedUser(refreshed.accessToken, me, mustChangePassword); return refreshed; } commitAccessToken(refreshed.accessToken); return refreshed; })(); refreshRequestRef.current = refreshPromise; void refreshPromise .finally(() => { if (refreshRequestRef.current === refreshPromise) { refreshRequestRef.current = null; } }) .catch(() => undefined); return refreshPromise; }, [commitAccessToken, commitAuthenticatedUser]); const scheduleProactiveRefresh = useCallback( (expiresIn?: number) => { clearProactiveRefreshTimer(); if (!isWindowAvailable() || typeof expiresIn !== "number" || !Number.isFinite(expiresIn) || expiresIn <= 0) { return; } const delayMs = Math.max(MIN_REFRESH_DELAY_MS, Math.floor(expiresIn * 1000 * PROACTIVE_REFRESH_RATIO)); proactiveRefreshTimerRef.current = window.setTimeout(() => { proactiveRefreshTimerRef.current = null; void runRefresh() .then((refreshed) => { if (refreshed?.expiresIn) { scheduleProactiveRefreshRef.current(refreshed.expiresIn); } }) .catch(() => undefined); }, delayMs); }, [clearProactiveRefreshTimer, runRefresh], ); useEffect(() => { scheduleProactiveRefreshRef.current = scheduleProactiveRefresh; }, [scheduleProactiveRefresh]); const activateOfflineSnapshot = useCallback( (snapshot: OfflineAuthSnapshot) => { clearProactiveRefreshTimer(); tokenRef.current = null; meRef.current = snapshot.me; setOfflineAccessMode(true); setState(buildStateWithOfflineSnapshot(snapshot)); }, [clearProactiveRefreshTimer], ); const bootstrapByRefresh = useCallback(async () => { const offlineSnapshot = readOfflineAuthSnapshot(); if (isWindowAvailable() && window.navigator.onLine === false && offlineSnapshot) { activateOfflineSnapshot(offlineSnapshot); return; } if (!bootstrapPromiseRef.current) { bootstrapPromiseRef.current = (async () => { try { const refreshed = await runRefresh(); if (refreshed?.expiresIn) { scheduleProactiveRefresh(refreshed.expiresIn); } return refreshed?.accessToken ?? null; } catch { if (isWindowAvailable() && window.navigator.onLine === false && offlineSnapshot) { activateOfflineSnapshot(offlineSnapshot); return null; } clearSession(); return null; } finally { bootstrapPromiseRef.current = null; } })(); } await bootstrapPromiseRef.current; }, [activateOfflineSnapshot, clearSession, runRefresh, scheduleProactiveRefresh]); const login = useCallback( async (identifier: string, password: string) => { const result = await loginWithPassword({ identifier, password }); const mustChangePassword = Boolean(result.mustChangePassword); const me = await fetchCurrentEmployee(result.accessToken); commitAuthenticatedUser(result.accessToken, me, mustChangePassword); scheduleProactiveRefresh(result.expiresIn); return { mustChangePassword }; }, [commitAuthenticatedUser, scheduleProactiveRefresh], ); const changePassword = useCallback( async (oldPassword: string, newPassword: string) => { const result = await changePasswordApi({ oldPassword, newPassword }); const me = await fetchCurrentEmployee(result.accessToken); commitAuthenticatedUser(result.accessToken, me, false); scheduleProactiveRefresh(result.expiresIn); }, [commitAuthenticatedUser, scheduleProactiveRefresh], ); const logout = useCallback(async () => { try { await logoutSession(); } finally { clearSession(); } }, [clearSession]); const refreshMe = useCallback(async () => { let accessToken = tokenRef.current; if (!accessToken) { const refreshed = await runRefresh(); if (!refreshed?.accessToken) { throw new Error("会话已失效"); } accessToken = refreshed.accessToken; scheduleProactiveRefresh(refreshed.expiresIn); } const me = await fetchCurrentEmployee(accessToken); const permissions = resolvePermissionsForRoles(me.roleCodes ?? []); meRef.current = me; setOfflineAccessMode(false); writeOfflineAuthSnapshot(me, permissions); setState((previous) => ({ ...previous, accessToken, me, permissions, ready: true, })); }, [runRefresh, scheduleProactiveRefresh]); useEffect(() => { configureHttpAuth({ getAccessToken: () => tokenRef.current, refreshAccessToken: async () => { if (bootstrapPromiseRef.current) { const bootstrappedToken = await bootstrapPromiseRef.current; if (bootstrappedToken) { return bootstrappedToken; } } const refreshed = await runRefresh(); if (!refreshed?.accessToken) { return null; } scheduleProactiveRefresh(refreshed.expiresIn); return refreshed.accessToken; }, clearSession, }); }, [clearSession, runRefresh, scheduleProactiveRefresh]); useEffect(() => { void bootstrapByRefresh(); }, [bootstrapByRefresh]); useEffect(() => { return () => { sessionGenerationRef.current += 1; bootstrapPromiseRef.current = null; refreshRequestRef.current = null; clearProactiveRefreshTimer(); }; }, [clearProactiveRefreshTimer]); useEffect(() => { if (!offlineAccessMode) { return undefined; } const handleOnline = () => { void bootstrapByRefresh(); }; window.addEventListener("online", handleOnline); return () => { window.removeEventListener("online", handleOnline); }; }, [bootstrapByRefresh, offlineAccessMode]); const value = useMemo( () => ({ ...state, login, changePassword, logout, bootstrapByRefresh, refreshMe, clearSession, offlineAccessMode, }), [bootstrapByRefresh, changePassword, clearSession, login, logout, offlineAccessMode, refreshMe, state], ); return {children}; } export function useAuth() { const context = useContext(AuthContext); if (!context) { throw new Error("useAuth must be used within AuthProvider"); } return context; }