From 6945c5387943553148d97e0aa314f6351e3a7ce9 Mon Sep 17 00:00:00 2001 From: "Logan.Li" Date: Mon, 26 Oct 2026 18:06:22 +0800 Subject: [PATCH] fix(frontend): eliminate boot race between role restore and route guard Root cause (pre-existing): user state initialized with default role 'developer' while JWT role restoration happened via render-phase setState ~1 render later; first guard pass evaluated tickets/workflow against the developer writable-datasource gate and bounced admins to dashboard on direct load. Fix: initialize user state lazily from the JWT in one pass (restoreUserFromToken) - first render already carries the correct role. Verified in browser: direct loads of /tickets and /workflow stay put; URL query state survives refresh; expired session redirects to /login?redirect= and login returns to the original page. --- frontend/src/hooks/useAuth.tsx | 466 ++++++++++++++++++--------------- 1 file changed, 262 insertions(+), 204 deletions(-) diff --git a/frontend/src/hooks/useAuth.tsx b/frontend/src/hooks/useAuth.tsx index 1fd8aa1..000059e 100644 --- a/frontend/src/hooks/useAuth.tsx +++ b/frontend/src/hooks/useAuth.tsx @@ -18,12 +18,24 @@ export type { UserRole }; /** 细粒度权限类型 */ export type PermissionAction = - | 'ticket.create' | 'ticket.approve' | 'ticket.execute' | 'ticket.rollback' | 'ticket.delete' - | 'query.create' | 'query.export' - | 'instance.add' | 'instance.edit' | 'instance.delete' - | 'user.manage' | 'role.manage' | 'rule.manage' - | 'workflow.manage' | 'config.manage' - | 'announcement.manage' | 'auto-task.manage' | 'instance-group.manage'; + | 'ticket.create' + | 'ticket.approve' + | 'ticket.execute' + | 'ticket.rollback' + | 'ticket.delete' + | 'query.create' + | 'query.export' + | 'instance.add' + | 'instance.edit' + | 'instance.delete' + | 'user.manage' + | 'role.manage' + | 'rule.manage' + | 'workflow.manage' + | 'config.manage' + | 'announcement.manage' + | 'auto-task.manage' + | 'instance-group.manage'; /** 数据源级权限 */ export interface DataSourcePermission { @@ -75,14 +87,14 @@ interface AuthContextType { instanceId: number, action: 'read' | 'write' | 'delete' | 'export', database?: string, - table?: string + table?: string, ) => boolean; /** 批量检查数据源权限 */ checkDataSourcePermissions: ( instanceId: number, actions: ('read' | 'write' | 'delete' | 'export')[], database?: string, - table?: string + table?: string, ) => Record; /** 刷新用户权限 */ refreshPermissions: () => Promise; @@ -140,38 +152,42 @@ const CACHE_TTL = 5 * 60 * 1000; // 5分钟 const AuthContext = createContext(null); -export function AuthProvider({ children }: { children: React.ReactNode }) { - const [user, setUser] = useState({ - id: defaultUser.id, - username: defaultUser.username, - email: defaultUser.email, - role: defaultUser.role, - roleName: defaultUser.roleName, - avatar: defaultUser.avatar, - groupIds: [], - dataSourcePermissions: [], - customPermissions: {}, - }); +/** 从 JWT 惰性恢复基本用户信息(页面刷新时的首帧即有正确角色,杜绝守卫竞态) */ +function restoreUserFromToken(base: AuthUser): AuthUser { + const token = readStorage('token'); + if (!token) return base; + try { + const payload = JSON.parse(atob(token.split('.')[1])); + const role = payload.role as UserRole; + if (!role) return base; + return { + ...base, + id: Number(payload.sub) || 0, + role, + roleName: roleNameMap[role] || role, + avatar: role.charAt(0).toUpperCase(), + }; + } catch { + return base; + } +} - // 从 token 恢复基本用户信息(页面刷新时) - const [isLoggedIn, setIsLoggedIn] = useState(() => { - const token = readStorage('token'); - if (!token) return false; - try { - const payload = JSON.parse(atob(token.split('.')[1])); - const role = payload.role as UserRole; - if (role) { - setUser(u => ({ - ...u, - id: Number(payload.sub) || 0, - role, - roleName: roleNameMap[role] || role, - avatar: role.charAt(0).toUpperCase(), - })); - } - } catch { /* ignore */ } - return true; - }); +export function AuthProvider({ children }: { children: React.ReactNode }) { + const [user, setUser] = useState(() => + restoreUserFromToken({ + id: defaultUser.id, + username: defaultUser.username, + email: defaultUser.email, + role: defaultUser.role, + roleName: defaultUser.roleName, + avatar: defaultUser.avatar, + groupIds: [], + dataSourcePermissions: [], + customPermissions: {}, + }), + ); + + const [isLoggedIn, setIsLoggedIn] = useState(() => readStorage('token') !== null); const [hasWritableDataSourceAccess, setHasWritableDataSourceAccess] = useState(false); /** 权限缓存引用 */ @@ -199,14 +215,16 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { }, []); const hasWritableAccessInUserPayload = useCallback((u: AuthUser): boolean => { - return Boolean(u.dataSourcePermissions?.some(ds => - ds.actions.some(action => ['write', 'delete', 'readwrite'].includes(String(action))) - )); + return Boolean( + u.dataSourcePermissions?.some((ds) => + ds.actions.some((action) => ['write', 'delete', 'readwrite'].includes(String(action))), + ), + ); }, []); /** 触发权限变更事件 */ const emitPermissionChange = useCallback((updatedUser: AuthUser) => { - listenersRef.current.forEach(listener => listener(updatedUser)); + listenersRef.current.forEach((listener) => listener(updatedUser)); // 触发自定义事件,供其他组件监听 if (typeof window !== 'undefined') { window.dispatchEvent(new CustomEvent(PERMISSION_CHANGE_EVENT, { detail: updatedUser })); @@ -247,7 +265,8 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { } let cancelled = false; - instanceApi.accessible('write') + instanceApi + .accessible('write') .then((instances) => { if (cancelled) return; setHasWritableDataSourceAccess((instances || []).length > 0); @@ -264,47 +283,48 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { }; }, [isLoggedIn, user.id, user.role, user.permissionsUpdatedAt, hasWritableAccessInUserPayload, clearPermissionCache]); - const login = useCallback(async (username: string, password: string): Promise => { - try { - const res = await authApi.login(username, password); - writeStorage('token', res.access_token); - if (res.refresh_token) { - writeStorage('refreshToken', res.refresh_token); + const login = useCallback( + async (username: string, password: string): Promise => { + try { + const res = await authApi.login(username, password); + writeStorage('token', res.access_token); + if (res.refresh_token) { + writeStorage('refreshToken', res.refresh_token); + } + + const u = res.user; + const role = u.role as UserRole; + + // 解析后端返回的细粒度权限 + const dataSourcePermissions: DataSourcePermission[] = u.data_source_permissions + ? JSON.parse(u.data_source_permissions) + : []; + const customPermissions: Record = u.custom_permissions ? JSON.parse(u.custom_permissions) : {}; + + const newUser: AuthUser = { + id: u.id, + username: u.username, + email: u.email || '', + role, + roleName: roleNameMap[role] || role, + avatar: u.display_name?.charAt(0) || username.charAt(0).toUpperCase(), + groupIds: u.group_ids || [], + dataSourcePermissions, + customPermissions, + permissionsUpdatedAt: Date.now(), + }; + + setUser(newUser); + setIsLoggedIn(true); + clearPermissionCache(); + emitPermissionChange(newUser); + return true; + } catch { + return false; } - - const u = res.user; - const role = u.role as UserRole; - - // 解析后端返回的细粒度权限 - const dataSourcePermissions: DataSourcePermission[] = u.data_source_permissions - ? JSON.parse(u.data_source_permissions) - : []; - const customPermissions: Record = u.custom_permissions - ? JSON.parse(u.custom_permissions) - : {}; - - const newUser: AuthUser = { - id: u.id, - username: u.username, - email: u.email || '', - role, - roleName: roleNameMap[role] || role, - avatar: u.display_name?.charAt(0) || username.charAt(0).toUpperCase(), - groupIds: u.group_ids || [], - dataSourcePermissions, - customPermissions, - permissionsUpdatedAt: Date.now(), - }; - - setUser(newUser); - setIsLoggedIn(true); - clearPermissionCache(); - emitPermissionChange(newUser); - return true; - } catch { - return false; - } - }, [clearPermissionCache, emitPermissionChange]); + }, + [clearPermissionCache, emitPermissionChange], + ); const logout = useCallback(() => { // 先取 token 再清理:服务端撤销 refresh token 需要它 @@ -312,7 +332,9 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { removeStorage('token'); removeStorage('refreshToken'); if (token) { - authApi.logout(token).catch(() => { /* 服务端撤销失败不阻塞本地登出 */ }); + authApi.logout(token).catch(() => { + /* 服务端撤销失败不阻塞本地登出 */ + }); } setIsLoggedIn(false); clearPermissionCache(); @@ -330,116 +352,136 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { emitPermissionChange({} as AuthUser); }, [clearPermissionCache, emitPermissionChange]); - const switchRole = useCallback((role: UserRole) => { - setUser(prev => { - const newUser: AuthUser = { - ...prev, - role, - roleName: roleNameMap[role], - username: role === 'admin' ? 'admin' : role === 'dba' ? 'zhangsan' : role === 'developer' ? 'wangwu' : 'visitor', - email: `${role}@company.com`, - avatar: role.charAt(0).toUpperCase(), - }; - clearPermissionCache(); - emitPermissionChange(newUser); - return newUser; - }); - }, [clearPermissionCache, emitPermissionChange]); + const switchRole = useCallback( + (role: UserRole) => { + setUser((prev) => { + const newUser: AuthUser = { + ...prev, + role, + roleName: roleNameMap[role], + username: + role === 'admin' ? 'admin' : role === 'dba' ? 'zhangsan' : role === 'developer' ? 'wangwu' : 'visitor', + email: `${role}@company.com`, + avatar: role.charAt(0).toUpperCase(), + }; + clearPermissionCache(); + emitPermissionChange(newUser); + return newUser; + }); + }, + [clearPermissionCache, emitPermissionChange], + ); /** 菜单权限检查(带缓存) */ - const hasPermission = useCallback((menuId: string): boolean => { - if (!isLoggedIn) return false; + const hasPermission = useCallback( + (menuId: string): boolean => { + if (!isLoggedIn) return false; - const cache = permissionCacheRef.current; - if (isCacheValid() && cache.menu.has(menuId)) { - return cache.menu.get(menuId)!; - } + const cache = permissionCacheRef.current; + if (isCacheValid() && cache.menu.has(menuId)) { + return cache.menu.get(menuId)!; + } - const result = canAccessMenu(menuId, { - role: user.role, - customPermissions: user.customPermissions, - hasWritableDataSourceAccess, - }); - cache.menu.set(menuId, result); - cache.timestamp = Date.now(); - return result; - }, [user.role, user.customPermissions, hasWritableDataSourceAccess, isLoggedIn, isCacheValid]); + const result = canAccessMenu(menuId, { + role: user.role, + customPermissions: user.customPermissions, + hasWritableDataSourceAccess, + }); + cache.menu.set(menuId, result); + cache.timestamp = Date.now(); + return result; + }, + [user.role, user.customPermissions, hasWritableDataSourceAccess, isLoggedIn, isCacheValid], + ); /** 操作权限检查(带缓存,支持自定义权限覆盖) */ - const hasActionPermission = useCallback((action: PermissionAction | string): boolean => { - if (!isLoggedIn) return false; + const hasActionPermission = useCallback( + (action: PermissionAction | string): boolean => { + if (!isLoggedIn) return false; - const cache = permissionCacheRef.current; - if (isCacheValid() && cache.action.has(action)) { - return cache.action.get(action)!; - } + const cache = permissionCacheRef.current; + if (isCacheValid() && cache.action.has(action)) { + return cache.action.get(action)!; + } - const result = canPerformAction(action, { - role: user.role, - customPermissions: user.customPermissions, - hasWritableDataSourceAccess, - }, actionPermissionMap); - cache.action.set(action, result); - cache.timestamp = Date.now(); - return result; - }, [user.role, user.customPermissions, hasWritableDataSourceAccess, isLoggedIn, isCacheValid]); + const result = canPerformAction( + action, + { + role: user.role, + customPermissions: user.customPermissions, + hasWritableDataSourceAccess, + }, + actionPermissionMap, + ); + cache.action.set(action, result); + cache.timestamp = Date.now(); + return result; + }, + [user.role, user.customPermissions, hasWritableDataSourceAccess, isLoggedIn, isCacheValid], + ); /** 数据源级权限检查 */ - const hasDataSourcePermission = useCallback(( - instanceId: number, - action: 'read' | 'write' | 'delete' | 'export', - database?: string, - table?: string - ): boolean => { - if (!isLoggedIn) return false; - if (user.role === 'admin') return true; // 管理员拥有所有数据源权限 + const hasDataSourcePermission = useCallback( + ( + instanceId: number, + action: 'read' | 'write' | 'delete' | 'export', + database?: string, + table?: string, + ): boolean => { + if (!isLoggedIn) return false; + if (user.role === 'admin') return true; // 管理员拥有所有数据源权限 - const cache = permissionCacheRef.current; - const cacheKey = `${instanceId}:${action}:${database || '*'}:${table || '*'}`; - if (isCacheValid() && cache.dataSource.has(cacheKey)) { - return cache.dataSource.get(cacheKey)!; - } + const cache = permissionCacheRef.current; + const cacheKey = `${instanceId}:${action}:${database || '*'}:${table || '*'}`; + if (isCacheValid() && cache.dataSource.has(cacheKey)) { + return cache.dataSource.get(cacheKey)!; + } - const perms = user.dataSourcePermissions || []; - if (perms.length === 0) { - cache.dataSource.set(cacheKey, false); - return false; - } + const perms = user.dataSourcePermissions || []; + if (perms.length === 0) { + cache.dataSource.set(cacheKey, false); + return false; + } - const hasPerm = perms.some((ds: DataSourcePermission) => { - // 检查实例ID - if (ds.instanceId !== instanceId) return false; + const hasPerm = perms.some((ds: DataSourcePermission) => { + // 检查实例ID + if (ds.instanceId !== instanceId) return false; - // 检查操作权限 - if (!ds.actions.includes(action)) return false; + // 检查操作权限 + if (!ds.actions.includes(action)) return false; - // 检查数据库级别 - if (database && ds.database && ds.database !== database) return false; + // 检查数据库级别 + if (database && ds.database && ds.database !== database) return false; - // 检查表级别 - if (table && ds.tables && ds.tables.length > 0 && !ds.tables.includes(table)) return false; + // 检查表级别 + if (table && ds.tables && ds.tables.length > 0 && !ds.tables.includes(table)) return false; - return true; - }); + return true; + }); - cache.dataSource.set(cacheKey, hasPerm); - cache.timestamp = Date.now(); - return hasPerm; - }, [user.dataSourcePermissions, user.role, isLoggedIn, isCacheValid]); + cache.dataSource.set(cacheKey, hasPerm); + cache.timestamp = Date.now(); + return hasPerm; + }, + [user.dataSourcePermissions, user.role, isLoggedIn, isCacheValid], + ); /** 批量检查数据源权限 */ - const checkDataSourcePermissions = useCallback(( - instanceId: number, - actions: ('read' | 'write' | 'delete' | 'export')[], - database?: string, - table?: string - ): Record => { - const result: Record = {}; - for (const action of actions) { - result[action] = hasDataSourcePermission(instanceId, action, database, table); - } - return result; - }, [hasDataSourcePermission]); + const checkDataSourcePermissions = useCallback( + ( + instanceId: number, + actions: ('read' | 'write' | 'delete' | 'export')[], + database?: string, + table?: string, + ): Record => { + const result: Record = {}; + for (const action of actions) { + result[action] = hasDataSourcePermission(instanceId, action, database, table); + } + return result; + }, + [hasDataSourcePermission], + ); /** 从服务器刷新用户权限 */ const refreshPermissions = useCallback(async (): Promise => { @@ -449,9 +491,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { const dataSourcePermissions: DataSourcePermission[] = u.data_source_permissions ? JSON.parse(u.data_source_permissions) : []; - const customPermissions: Record = u.custom_permissions - ? JSON.parse(u.custom_permissions) - : {}; + const customPermissions: Record = u.custom_permissions ? JSON.parse(u.custom_permissions) : {}; const newUser: AuthUser = { ...user, @@ -475,35 +515,53 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { }, [user, clearPermissionCache, emitPermissionChange]); const visibleMenuItems = useMemo(() => { - return menuItems.filter(item => item.roles.includes(user.role) && hasPermission(item.id)); + return menuItems.filter((item) => item.roles.includes(user.role) && hasPermission(item.id)); }, [user.role, hasPermission]); /** 检查是否拥有指定角色或以上级别权限 */ - const hasRoleLevel = useCallback((minRole: UserRole): boolean => { - return roleWeightMap[user.role] >= roleWeightMap[minRole]; - }, [user.role]); + const hasRoleLevel = useCallback( + (minRole: UserRole): boolean => { + return roleWeightMap[user.role] >= roleWeightMap[minRole]; + }, + [user.role], + ); - const value = useMemo(() => ({ - user, - isLoggedIn, - login, - logout, - switchRole, - hasPermission, - hasActionPermission, - hasDataSourcePermission, - checkDataSourcePermissions, - refreshPermissions, - clearPermissionCache, - visibleMenuItems, - isAdmin: user.role === 'admin', - isDBA: user.role === 'dba', - isDeveloper: user.role === 'developer', - isGuest: user.role === 'auditor', - hasRoleLevel, - }), [user, isLoggedIn, login, logout, switchRole, hasPermission, hasActionPermission, - hasDataSourcePermission, checkDataSourcePermissions, refreshPermissions, - clearPermissionCache, visibleMenuItems, hasRoleLevel]); + const value = useMemo( + () => ({ + user, + isLoggedIn, + login, + logout, + switchRole, + hasPermission, + hasActionPermission, + hasDataSourcePermission, + checkDataSourcePermissions, + refreshPermissions, + clearPermissionCache, + visibleMenuItems, + isAdmin: user.role === 'admin', + isDBA: user.role === 'dba', + isDeveloper: user.role === 'developer', + isGuest: user.role === 'auditor', + hasRoleLevel, + }), + [ + user, + isLoggedIn, + login, + logout, + switchRole, + hasPermission, + hasActionPermission, + hasDataSourcePermission, + checkDataSourcePermissions, + refreshPermissions, + clearPermissionCache, + visibleMenuItems, + hasRoleLevel, + ], + ); return {children}; } @@ -525,7 +583,7 @@ export function useDataSourcePermission( instanceId: number, action: 'read' | 'write' | 'delete' | 'export', database?: string, - table?: string + table?: string, ): boolean { const { hasDataSourcePermission } = useAuth(); return hasDataSourcePermission(instanceId, action, database, table);