R61 A1-A6:Auth token refresh 主动续期

这个提交包含在:
2026-04-12 16:45:49 +08:00
父节点 7b7a56d7c9
当前提交 dabb03e55b
共修改 5 个文件,包含 213 行新增和 45 行删除
+10 -7
查看文件
@@ -1,4 +1,4 @@
import { ApiError, type ApiErrorBody, type ApiSuccess } from "./types";
import { ApiError, SessionExpiredError, type ApiErrorBody, type ApiSuccess } from "./types";
type AccessTokenGetter = () => string | null;
type RefreshAccessToken = () => Promise<string | null>;
@@ -79,6 +79,13 @@ async function getRefreshedToken(): Promise<string | null> {
return refreshPromise;
}
function throwSessionExpired(): never {
if (clearSession) {
clearSession();
}
throw new SessionExpiredError();
}
async function sendRequest<T>(
path: string,
options: ApiRequestOptions,
@@ -118,9 +125,7 @@ async function sendRequest<T>(
});
}
if (clearSession) {
clearSession();
}
throwSessionExpired();
}
const payload = await safeParseJson(response);
@@ -210,9 +215,7 @@ export async function apiRequestBlob(
});
}
if (clearSession) {
clearSession();
}
throwSessionExpired();
}
const payload = await safeParseJson(response);
+12
查看文件
@@ -19,6 +19,7 @@ export type LoginRequest = {
export type LoginResponse = {
accessToken: string;
expiresIn?: number;
mustChangePassword?: boolean;
};
@@ -73,6 +74,17 @@ export function isApiError(value: unknown): value is ApiError {
return value instanceof ApiError;
}
export class SessionExpiredError extends Error {
constructor(message = "会话已过期") {
super(message);
this.name = "SessionExpiredError";
}
}
export function isSessionExpiredError(value: unknown): value is SessionExpiredError {
return value instanceof SessionExpiredError;
}
export type WorkflowTaskSummary = {
id: string;
+142 -37
查看文件
@@ -9,7 +9,7 @@ import {
useState,
} from "react";
import { configureHttpAuth } from "../api/http";
import type { AuthState, MeResponse } from "../api/types";
import type { AuthState, LoginResponse, MeResponse } from "../api/types";
import { changePassword as changePasswordApi } from "../api/auth";
import { clearPwaRuntimeCaches } from "../pwa";
import {
@@ -51,6 +51,8 @@ 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";
@@ -144,14 +146,35 @@ 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();
@@ -164,12 +187,13 @@ export function AuthProvider({ children }: PropsWithChildren) {
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);
@@ -184,12 +208,92 @@ export function AuthProvider({ children }: PropsWithChildren) {
[],
);
const activateOfflineSnapshot = useCallback((snapshot: OfflineAuthSnapshot) => {
tokenRef.current = null;
setOfflineAccessMode(true);
setState(buildStateWithOfflineSnapshot(snapshot));
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) {
@@ -200,11 +304,11 @@ export function AuthProvider({ children }: PropsWithChildren) {
if (!bootstrapPromiseRef.current) {
bootstrapPromiseRef.current = (async () => {
try {
const refreshed = await refreshSession();
const me = await fetchCurrentEmployee(refreshed.accessToken);
const mustChangePassword = readMustChangePasswordFromStorage();
commitAuthenticatedUser(refreshed.accessToken, me, mustChangePassword);
return refreshed.accessToken;
const refreshed = await runRefresh();
if (refreshed?.expiresIn) {
scheduleProactiveRefresh(refreshed.expiresIn);
}
return refreshed?.accessToken ?? null;
} catch {
if (isWindowAvailable() && window.navigator.onLine === false && offlineSnapshot) {
activateOfflineSnapshot(offlineSnapshot);
@@ -219,7 +323,7 @@ export function AuthProvider({ children }: PropsWithChildren) {
}
await bootstrapPromiseRef.current;
}, [activateOfflineSnapshot, clearSession, commitAuthenticatedUser]);
}, [activateOfflineSnapshot, clearSession, runRefresh, scheduleProactiveRefresh]);
const login = useCallback(
async (identifier: string, password: string) => {
@@ -227,9 +331,10 @@ export function AuthProvider({ children }: PropsWithChildren) {
const mustChangePassword = Boolean(result.mustChangePassword);
const me = await fetchCurrentEmployee(result.accessToken);
commitAuthenticatedUser(result.accessToken, me, mustChangePassword);
scheduleProactiveRefresh(result.expiresIn);
return { mustChangePassword };
},
[commitAuthenticatedUser],
[commitAuthenticatedUser, scheduleProactiveRefresh],
);
const changePassword = useCallback(
@@ -237,8 +342,9 @@ export function AuthProvider({ children }: PropsWithChildren) {
const result = await changePasswordApi({ oldPassword, newPassword });
const me = await fetchCurrentEmployee(result.accessToken);
commitAuthenticatedUser(result.accessToken, me, false);
scheduleProactiveRefresh(result.expiresIn);
},
[commitAuthenticatedUser],
[commitAuthenticatedUser, scheduleProactiveRefresh],
);
const logout = useCallback(async () => {
@@ -252,13 +358,17 @@ export function AuthProvider({ children }: PropsWithChildren) {
const refreshMe = useCallback(async () => {
let accessToken = tokenRef.current;
if (!accessToken) {
const refreshed = await refreshSession();
const refreshed = await runRefresh();
if (!refreshed?.accessToken) {
throw new Error("会话已失效");
}
accessToken = refreshed.accessToken;
tokenRef.current = accessToken;
scheduleProactiveRefresh(refreshed.expiresIn);
}
const me = await fetchCurrentEmployee(accessToken);
const permissions = resolvePermissionsForRoles(me.roleCodes ?? []);
meRef.current = me;
setOfflineAccessMode(false);
writeOfflineAuthSnapshot(me, permissions);
setState((previous) => ({
@@ -268,7 +378,7 @@ export function AuthProvider({ children }: PropsWithChildren) {
permissions,
ready: true,
}));
}, []);
}, [runRefresh, scheduleProactiveRefresh]);
useEffect(() => {
configureHttpAuth({
@@ -281,36 +391,31 @@ export function AuthProvider({ children }: PropsWithChildren) {
}
}
try {
const refreshed = await refreshSession();
if (!state.me) {
const me = await fetchCurrentEmployee(refreshed.accessToken);
const mustChangePassword = readMustChangePasswordFromStorage();
commitAuthenticatedUser(refreshed.accessToken, me, mustChangePassword);
return refreshed.accessToken;
}
tokenRef.current = refreshed.accessToken;
setOfflineAccessMode(false);
setState((previous) => ({
...previous,
accessToken: refreshed.accessToken,
ready: true,
}));
return refreshed.accessToken;
} catch {
clearSession();
const refreshed = await runRefresh();
if (!refreshed?.accessToken) {
return null;
}
scheduleProactiveRefresh(refreshed.expiresIn);
return refreshed.accessToken;
},
clearSession,
});
}, [clearSession, commitAuthenticatedUser, state.me]);
}, [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;