feat(client): 初始化前端壳与鉴权守卫(P1-5)

- 技术栈:React 18 + TypeScript + Vite + Tailwind

- API 层:ApiError + 401 自动刷新 + single-flight 重试

- 鉴权:内存 Access Token + Cookie Refresh + 启动 bootstrap

- RBAC:权限映射 + RequireAuth/RequirePermission 路由守卫

- 布局:OA 工作台壳层 + 侧边栏 + 5 个占位页面

- 样式:复用 hyb-web 视觉 token(单向同步)

- 文档:新增 client README,补充 server 联调说明
这个提交包含在:
2026-02-07 16:43:57 +08:00
父节点 f527633716
当前提交 c290e03fef
共修改 35 个文件,包含 4127 行新增和 0 行删除
+143
查看文件
@@ -0,0 +1,143 @@
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 {
fetchCurrentEmployee,
loginWithPassword,
logoutSession,
refreshSession,
} from "./authService";
import { resolvePermissionsForRoles } from "./permissions";
type AuthContextValue = AuthState & {
login: (identifier: string, password: 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: [],
ready: false,
};
function buildStateWithUser(accessToken: string, me: MeResponse): AuthState {
return {
accessToken,
me,
permissions: resolvePermissionsForRoles(me.roleCodes ?? []),
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;
setState((previous) => ({
...previous,
accessToken: null,
me: null,
permissions: [],
ready: true,
}));
}, []);
const commitAuthenticatedUser = useCallback((accessToken: string, me: MeResponse) => {
tokenRef.current = accessToken;
setState(buildStateWithUser(accessToken, me));
}, []);
const bootstrapByRefresh = useCallback(async () => {
try {
const refreshed = await refreshSession();
const me = await fetchCurrentEmployee(refreshed.accessToken);
commitAuthenticatedUser(refreshed.accessToken, me);
} catch {
clearSession();
}
}, [clearSession, commitAuthenticatedUser]);
const login = useCallback(
async (identifier: string, password: string) => {
const result = await loginWithPassword({ identifier, password });
const me = await fetchCurrentEmployee(result.accessToken);
commitAuthenticatedUser(result.accessToken, me);
},
[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,
logout,
bootstrapByRefresh,
clearSession,
}),
[bootstrapByRefresh, 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;
}
+47
查看文件
@@ -0,0 +1,47 @@
import { apiRequestData } from "../api/http";
import type { LoginRequest, LoginResponse, MeResponse } from "../api/types";
import { getDeviceFingerprint } from "./deviceFingerprint";
const JSON_HEADERS = {
"content-type": "application/json",
};
export async function loginWithPassword(payload: LoginRequest) {
return apiRequestData<LoginResponse>("/api/auth/login", {
method: "POST",
auth: false,
retryOn401: false,
headers: {
...JSON_HEADERS,
"x-device-fingerprint": getDeviceFingerprint(),
},
body: JSON.stringify(payload),
});
}
export async function refreshSession() {
return apiRequestData<LoginResponse>("/api/auth/refresh", {
method: "POST",
auth: false,
retryOn401: false,
headers: {
"x-device-fingerprint": getDeviceFingerprint(),
},
});
}
export async function logoutSession() {
return apiRequestData<Record<string, never>>("/api/auth/logout", {
method: "POST",
auth: false,
retryOn401: false,
});
}
export async function fetchCurrentEmployee(accessToken: string) {
return apiRequestData<MeResponse>("/api/me", {
auth: true,
retryOn401: false,
accessTokenOverride: accessToken,
});
}
+28
查看文件
@@ -0,0 +1,28 @@
const DEVICE_FINGERPRINT_KEY = "hyb_oa_device_fingerprint";
function fallbackFingerprint() {
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
}
function generateFingerprint() {
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
return crypto.randomUUID();
}
return fallbackFingerprint();
}
export function getDeviceFingerprint() {
if (typeof window === "undefined") {
return "server-render";
}
const existing = window.localStorage.getItem(DEVICE_FINGERPRINT_KEY);
if (existing) {
return existing;
}
const next = generateFingerprint();
window.localStorage.setItem(DEVICE_FINGERPRINT_KEY, next);
return next;
}
+74
查看文件
@@ -0,0 +1,74 @@
const EMPLOYEE_BASE_PERMISSIONS = [
"expense.create",
"expense.view_own",
"expense.edit_own",
"expense.delete_own",
"expense.submit",
"expense.withdraw",
"invoice.upload",
"invoice.view_own",
"expense.proxy_manage",
] as const;
const ROLE_PERMISSIONS: Record<string, readonly string[]> = {
employee: EMPLOYEE_BASE_PERMISSIONS,
manager: [
...EMPLOYEE_BASE_PERMISSIONS,
"expense.approve",
"expense.transfer",
"expense.view_all",
],
ceo: [
...EMPLOYEE_BASE_PERMISSIONS,
"expense.approve",
"expense.transfer",
"expense.view_all",
],
finance: [
...EMPLOYEE_BASE_PERMISSIONS,
"invoice.manage",
"expense.approve",
"expense.finance.pay",
"expense.view_all",
],
finance_mgr: [
...EMPLOYEE_BASE_PERMISSIONS,
"invoice.manage",
"expense.approve",
"expense.finance.pay",
"expense.view_all",
"expense.finance.override_duplicate",
"system.settings",
],
administration: ["expense.proxy_submit", "expense.view_own"],
hr: ["system.users", "expense.view_all"],
admin: ["*"],
};
function rolePermissions(roleCode: string): readonly string[] {
return ROLE_PERMISSIONS[roleCode] ?? [];
}
export function resolvePermissionsForRoles(roleCodes: string[]) {
const permissions = new Set<string>();
for (const roleCode of roleCodes) {
for (const permission of rolePermissions(roleCode)) {
permissions.add(permission);
}
}
return Array.from(permissions);
}
export function hasPermission(permissionCodes: string[], permissionCode: string) {
return permissionCodes.includes("*") || permissionCodes.includes(permissionCode);
}
export function hasAnyPermission(permissionCodes: string[], candidates: string[]) {
if (candidates.length === 0) {
return true;
}
return candidates.some((candidate) => hasPermission(permissionCodes, candidate));
}