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 联调说明
这个提交包含在:
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
在新工单中引用
屏蔽一个用户