文件
hyb-oa/client/src/auth/AuthContext.tsx
T
wangsli 2566289d37 feat(client): 首次登录改密守卫与错误文案(F35,F38)
- 登录响应支持 mustChangePassword 并强制跳转改密页
- 新增改密页面与全局路由守卫
- 完善 AUTH_002/SYS_429/VALID_00x 等错误提示
2026-02-09 18:16:22 +08:00

186 行
5.1 KiB
TypeScript

import {
createContext,
type PropsWithChildren,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { configureHttpAuth } from "../api/http";
import type { AuthState, MeResponse } from "../api/types";
import { changePassword as changePasswordApi } from "../api/auth";
import {
fetchCurrentEmployee,
loginWithPassword,
logoutSession,
refreshSession,
} from "./authService";
import { resolvePermissionsForRoles } from "./permissions";
type AuthContextValue = AuthState & {
login: (identifier: string, password: string) => Promise<{ mustChangePassword: boolean }>;
changePassword: (oldPassword: string, newPassword: string) => Promise<void>;
logout: () => Promise<void>;
bootstrapByRefresh: () => Promise<void>;
clearSession: () => void;
};
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";
function readMustChangePasswordFromStorage() {
return typeof window !== "undefined" && window.localStorage.getItem(MUST_CHANGE_PASSWORD_STORAGE_KEY) === "1";
}
function writeMustChangePasswordToStorage(value: boolean) {
if (typeof window === "undefined") return;
if (value) {
window.localStorage.setItem(MUST_CHANGE_PASSWORD_STORAGE_KEY, "1");
} else {
window.localStorage.removeItem(MUST_CHANGE_PASSWORD_STORAGE_KEY);
}
}
function buildStateWithUser(
accessToken: string,
me: MeResponse,
mustChangePassword: boolean,
): AuthState {
return {
accessToken,
me,
permissions: resolvePermissionsForRoles(me.roleCodes ?? []),
mustChangePassword,
ready: true,
};
}
export function AuthProvider({ children }: PropsWithChildren) {
const [state, setState] = useState<AuthState>(EMPTY_STATE);
const tokenRef = useRef<string | null>(null);
useEffect(() => {
tokenRef.current = state.accessToken;
}, [state.accessToken]);
const clearSession = useCallback(() => {
tokenRef.current = null;
writeMustChangePasswordToStorage(false);
setState((previous) => ({
...previous,
accessToken: null,
me: null,
permissions: [],
mustChangePassword: false,
ready: true,
}));
}, []);
const commitAuthenticatedUser = useCallback(
(accessToken: string, me: MeResponse, mustChangePassword: boolean) => {
tokenRef.current = accessToken;
writeMustChangePasswordToStorage(mustChangePassword);
setState(buildStateWithUser(accessToken, me, mustChangePassword));
},
[],
);
const bootstrapByRefresh = useCallback(async () => {
try {
const refreshed = await refreshSession();
const me = await fetchCurrentEmployee(refreshed.accessToken);
const mustChangePassword = readMustChangePasswordFromStorage();
commitAuthenticatedUser(refreshed.accessToken, me, mustChangePassword);
} catch {
clearSession();
}
}, [clearSession, commitAuthenticatedUser]);
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);
return { mustChangePassword };
},
[commitAuthenticatedUser],
);
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);
},
[commitAuthenticatedUser],
);
const logout = useCallback(async () => {
try {
await logoutSession();
} finally {
clearSession();
}
}, [clearSession]);
useEffect(() => {
configureHttpAuth({
getAccessToken: () => tokenRef.current,
refreshAccessToken: async () => {
try {
const refreshed = await refreshSession();
tokenRef.current = refreshed.accessToken;
setState((previous) => ({
...previous,
accessToken: refreshed.accessToken,
ready: true,
}));
return refreshed.accessToken;
} catch {
clearSession();
return null;
}
},
clearSession,
});
}, [clearSession]);
useEffect(() => {
void bootstrapByRefresh();
}, [bootstrapByRefresh]);
const value = useMemo<AuthContextValue>(
() => ({
...state,
login,
changePassword,
logout,
bootstrapByRefresh,
clearSession,
}),
[bootstrapByRefresh, changePassword, clearSession, login, logout, 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;
}