458 行
13 KiB
TypeScript
458 行
13 KiB
TypeScript
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<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;
|
|
|
|
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<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: 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<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 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<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;
|
|
}
|