文件
hyb-oa/client/src/auth/AuthContext.tsx
T

508 行
15 KiB
TypeScript

import {
createContext,
type PropsWithChildren,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { configureHttpAuth } from "../api/http";
import type { AuthState, LoginResponse, MeResponse, ProfileCompleteness } 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<void>;
logout: () => Promise<void>;
bootstrapByRefresh: () => Promise<void>;
refreshMe: () => Promise<void>;
clearSession: () => void;
offlineAccessMode: boolean;
};
const AuthContext = createContext<AuthContextValue | null>(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;
const DEFAULT_PROFILE_COMPLETENESS: ProfileCompleteness = {
hasEmergencyContact: false,
hasBankAccount: false,
hasAvatar: false,
hasIdNumber: false,
score: 0,
};
function isWindowAvailable() {
return typeof window !== "undefined";
}
function hasSessionHint() {
return typeof document !== "undefined" && document.cookie.includes("has_session=1");
}
function clearSessionHint() {
if (typeof document === "undefined") {
return;
}
document.cookie = "has_session=; path=/; max-age=0";
}
function normalizeProfileCompleteness(value: unknown): ProfileCompleteness {
if (!value || typeof value !== "object") {
return DEFAULT_PROFILE_COMPLETENESS;
}
const payload = value as Partial<ProfileCompleteness>;
return {
hasEmergencyContact: Boolean(payload.hasEmergencyContact),
hasBankAccount: Boolean(payload.hasBankAccount),
hasAvatar: Boolean(payload.hasAvatar),
hasIdNumber: Boolean(payload.hasIdNumber),
score: typeof payload.score === "number" && Number.isFinite(payload.score) ? payload.score : 0,
};
}
function normalizeMeResponse(value: MeResponse | Partial<MeResponse>) {
return {
...value,
profileCompleteness: normalizeProfileCompleteness(value.profileCompleteness),
} as MeResponse;
}
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<OfflineAuthSnapshot> | 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: normalizeMeResponse(parsed.me as Partial<MeResponse>),
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: normalizeMeResponse(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<AuthState>(EMPTY_STATE);
const [offlineAccessMode, setOfflineAccessMode] = useState(false);
const tokenRef = useRef<string | null>(null);
const meRef = useRef<MeResponse | null>(null);
const bootstrapPromiseRef = useRef<Promise<string | null> | null>(null);
const refreshRequestRef = useRef<Promise<LoginResponse | null> | null>(null);
const proactiveRefreshTimerRef = useRef<ReturnType<typeof setTimeout> | 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 markUnauthenticatedReady = useCallback(() => {
sessionGenerationRef.current += 1;
bootstrapPromiseRef.current = null;
refreshRequestRef.current = null;
clearProactiveRefreshTimer();
tokenRef.current = null;
meRef.current = null;
setOfflineAccessMode(false);
writeMustChangePasswordToStorage(false);
clearOfflineAuthSnapshot();
setState((previous) => ({
...previous,
accessToken: null,
me: null,
permissions: [],
mustChangePassword: false,
ready: true,
}));
}, [clearProactiveRefreshTimer]);
const clearSession = useCallback(() => {
clearSessionHint();
markUnauthenticatedReady();
void clearPwaRuntimeCaches();
}, [markUnauthenticatedReady]);
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 (!hasSessionHint() && (!isWindowAvailable() || window.navigator.onLine !== false)) {
markUnauthenticatedReady();
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, markUnauthenticatedReady, 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<AuthContextValue>(
() => ({
...state,
login,
changePassword,
logout,
bootstrapByRefresh,
refreshMe,
clearSession,
offlineAccessMode,
}),
[bootstrapByRefresh, changePassword, clearSession, login, logout, offlineAccessMode, refreshMe, state],
);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
export function useAuth() {
const context = useContext(AuthContext);
if (!context) {
throw new Error("useAuth must be used within AuthProvider");
}
return context;
}