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=<path> and login returns to the original page.
This commit is contained in:
Logan.Li committed 2026-10-26 18:06:22 +08:00
1 parent d867c276d4
commit 6945c53879
1 file changed
+262 -204
+262 -204
View File
@@ -18,12 +18,24 @@ export type { UserRole };
/** 细粒度权限类型 */ /** 细粒度权限类型 */
export type PermissionAction = export type PermissionAction =
| 'ticket.create' | 'ticket.approve' | 'ticket.execute' | 'ticket.rollback' | 'ticket.delete' | 'ticket.create'
| 'query.create' | 'query.export' | 'ticket.approve'
| 'instance.add' | 'instance.edit' | 'instance.delete' | 'ticket.execute'
| 'user.manage' | 'role.manage' | 'rule.manage' | 'ticket.rollback'
| 'workflow.manage' | 'config.manage' | 'ticket.delete'
| 'announcement.manage' | 'auto-task.manage' | 'instance-group.manage'; | '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 { export interface DataSourcePermission {
@@ -75,14 +87,14 @@ interface AuthContextType {
instanceId: number, instanceId: number,
action: 'read' | 'write' | 'delete' | 'export', action: 'read' | 'write' | 'delete' | 'export',
database?: string, database?: string,
table?: string table?: string,
) => boolean; ) => boolean;
/** 批量检查数据源权限 */ /** 批量检查数据源权限 */
checkDataSourcePermissions: ( checkDataSourcePermissions: (
instanceId: number, instanceId: number,
actions: ('read' | 'write' | 'delete' | 'export')[], actions: ('read' | 'write' | 'delete' | 'export')[],
database?: string, database?: string,
table?: string table?: string,
) => Record<string, boolean>; ) => Record<string, boolean>;
/** 刷新用户权限 */ /** 刷新用户权限 */
refreshPermissions: () => Promise<void>; refreshPermissions: () => Promise<void>;
@@ -140,38 +152,42 @@ const CACHE_TTL = 5 * 60 * 1000; // 5分钟
const AuthContext = createContext<AuthContextType | null>(null); const AuthContext = createContext<AuthContextType | null>(null);
export function AuthProvider({ children }: { children: React.ReactNode }) { /** 从 JWT 惰性恢复基本用户信息(页面刷新时的首帧即有正确角色,杜绝守卫竞态) */
const [user, setUser] = useState<AuthUser>({ function restoreUserFromToken(base: AuthUser): AuthUser {
id: defaultUser.id, const token = readStorage('token');
username: defaultUser.username, if (!token) return base;
email: defaultUser.email, try {
role: defaultUser.role, const payload = JSON.parse(atob(token.split('.')[1]));
roleName: defaultUser.roleName, const role = payload.role as UserRole;
avatar: defaultUser.avatar, if (!role) return base;
groupIds: [], return {
dataSourcePermissions: [], ...base,
customPermissions: {}, id: Number(payload.sub) || 0,
}); role,
roleName: roleNameMap[role] || role,
avatar: role.charAt(0).toUpperCase(),
};
} catch {
return base;
}
}
// 从 token 恢复基本用户信息(页面刷新时) export function AuthProvider({ children }: { children: React.ReactNode }) {
const [isLoggedIn, setIsLoggedIn] = useState(() => { const [user, setUser] = useState<AuthUser>(() =>
const token = readStorage('token'); restoreUserFromToken({
if (!token) return false; id: defaultUser.id,
try { username: defaultUser.username,
const payload = JSON.parse(atob(token.split('.')[1])); email: defaultUser.email,
const role = payload.role as UserRole; role: defaultUser.role,
if (role) { roleName: defaultUser.roleName,
setUser(u => ({ avatar: defaultUser.avatar,
...u, groupIds: [],
id: Number(payload.sub) || 0, dataSourcePermissions: [],
role, customPermissions: {},
roleName: roleNameMap[role] || role, }),
avatar: role.charAt(0).toUpperCase(), );
}));
} const [isLoggedIn, setIsLoggedIn] = useState(() => readStorage('token') !== null);
} catch { /* ignore */ }
return true;
});
const [hasWritableDataSourceAccess, setHasWritableDataSourceAccess] = useState(false); const [hasWritableDataSourceAccess, setHasWritableDataSourceAccess] = useState(false);
/** 权限缓存引用 */ /** 权限缓存引用 */
@@ -199,14 +215,16 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
}, []); }, []);
const hasWritableAccessInUserPayload = useCallback((u: AuthUser): boolean => { const hasWritableAccessInUserPayload = useCallback((u: AuthUser): boolean => {
return Boolean(u.dataSourcePermissions?.some(ds => return Boolean(
ds.actions.some(action => ['write', 'delete', 'readwrite'].includes(String(action))) u.dataSourcePermissions?.some((ds) =>
)); ds.actions.some((action) => ['write', 'delete', 'readwrite'].includes(String(action))),
),
);
}, []); }, []);
/** 触发权限变更事件 */ /** 触发权限变更事件 */
const emitPermissionChange = useCallback((updatedUser: AuthUser) => { const emitPermissionChange = useCallback((updatedUser: AuthUser) => {
listenersRef.current.forEach(listener => listener(updatedUser)); listenersRef.current.forEach((listener) => listener(updatedUser));
// 触发自定义事件,供其他组件监听 // 触发自定义事件,供其他组件监听
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
window.dispatchEvent(new CustomEvent(PERMISSION_CHANGE_EVENT, { detail: updatedUser })); window.dispatchEvent(new CustomEvent(PERMISSION_CHANGE_EVENT, { detail: updatedUser }));
@@ -247,7 +265,8 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
} }
let cancelled = false; let cancelled = false;
instanceApi.accessible('write') instanceApi
.accessible('write')
.then((instances) => { .then((instances) => {
if (cancelled) return; if (cancelled) return;
setHasWritableDataSourceAccess((instances || []).length > 0); setHasWritableDataSourceAccess((instances || []).length > 0);
@@ -264,47 +283,48 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
}; };
}, [isLoggedIn, user.id, user.role, user.permissionsUpdatedAt, hasWritableAccessInUserPayload, clearPermissionCache]); }, [isLoggedIn, user.id, user.role, user.permissionsUpdatedAt, hasWritableAccessInUserPayload, clearPermissionCache]);
const login = useCallback(async (username: string, password: string): Promise<boolean> => { const login = useCallback(
try { async (username: string, password: string): Promise<boolean> => {
const res = await authApi.login(username, password); try {
writeStorage('token', res.access_token); const res = await authApi.login(username, password);
if (res.refresh_token) { writeStorage('token', res.access_token);
writeStorage('refreshToken', res.refresh_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<string, boolean> = 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; [clearPermissionCache, emitPermissionChange],
const role = u.role as UserRole; );
// 解析后端返回的细粒度权限
const dataSourcePermissions: DataSourcePermission[] = u.data_source_permissions
? JSON.parse(u.data_source_permissions)
: [];
const customPermissions: Record<string, boolean> = 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]);
const logout = useCallback(() => { const logout = useCallback(() => {
// 先取 token 再清理:服务端撤销 refresh token 需要它 // 先取 token 再清理:服务端撤销 refresh token 需要它
@@ -312,7 +332,9 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
removeStorage('token'); removeStorage('token');
removeStorage('refreshToken'); removeStorage('refreshToken');
if (token) { if (token) {
authApi.logout(token).catch(() => { /* 服务端撤销失败不阻塞本地登出 */ }); authApi.logout(token).catch(() => {
/* 服务端撤销失败不阻塞本地登出 */
});
} }
setIsLoggedIn(false); setIsLoggedIn(false);
clearPermissionCache(); clearPermissionCache();
@@ -330,116 +352,136 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
emitPermissionChange({} as AuthUser); emitPermissionChange({} as AuthUser);
}, [clearPermissionCache, emitPermissionChange]); }, [clearPermissionCache, emitPermissionChange]);
const switchRole = useCallback((role: UserRole) => { const switchRole = useCallback(
setUser(prev => { (role: UserRole) => {
const newUser: AuthUser = { setUser((prev) => {
...prev, const newUser: AuthUser = {
role, ...prev,
roleName: roleNameMap[role], role,
username: role === 'admin' ? 'admin' : role === 'dba' ? 'zhangsan' : role === 'developer' ? 'wangwu' : 'visitor', roleName: roleNameMap[role],
email: `${role}@company.com`, username:
avatar: role.charAt(0).toUpperCase(), role === 'admin' ? 'admin' : role === 'dba' ? 'zhangsan' : role === 'developer' ? 'wangwu' : 'visitor',
}; email: `${role}@company.com`,
clearPermissionCache(); avatar: role.charAt(0).toUpperCase(),
emitPermissionChange(newUser); };
return newUser; clearPermissionCache();
}); emitPermissionChange(newUser);
}, [clearPermissionCache, emitPermissionChange]); return newUser;
});
},
[clearPermissionCache, emitPermissionChange],
);
/** 菜单权限检查(带缓存) */ /** 菜单权限检查(带缓存) */
const hasPermission = useCallback((menuId: string): boolean => { const hasPermission = useCallback(
if (!isLoggedIn) return false; (menuId: string): boolean => {
if (!isLoggedIn) return false;
const cache = permissionCacheRef.current; const cache = permissionCacheRef.current;
if (isCacheValid() && cache.menu.has(menuId)) { if (isCacheValid() && cache.menu.has(menuId)) {
return cache.menu.get(menuId)!; return cache.menu.get(menuId)!;
} }
const result = canAccessMenu(menuId, { const result = canAccessMenu(menuId, {
role: user.role, role: user.role,
customPermissions: user.customPermissions, customPermissions: user.customPermissions,
hasWritableDataSourceAccess, hasWritableDataSourceAccess,
}); });
cache.menu.set(menuId, result); cache.menu.set(menuId, result);
cache.timestamp = Date.now(); cache.timestamp = Date.now();
return result; return result;
}, [user.role, user.customPermissions, hasWritableDataSourceAccess, isLoggedIn, isCacheValid]); },
[user.role, user.customPermissions, hasWritableDataSourceAccess, isLoggedIn, isCacheValid],
);
/** 操作权限检查(带缓存,支持自定义权限覆盖) */ /** 操作权限检查(带缓存,支持自定义权限覆盖) */
const hasActionPermission = useCallback((action: PermissionAction | string): boolean => { const hasActionPermission = useCallback(
if (!isLoggedIn) return false; (action: PermissionAction | string): boolean => {
if (!isLoggedIn) return false;
const cache = permissionCacheRef.current; const cache = permissionCacheRef.current;
if (isCacheValid() && cache.action.has(action)) { if (isCacheValid() && cache.action.has(action)) {
return cache.action.get(action)!; return cache.action.get(action)!;
} }
const result = canPerformAction(action, { const result = canPerformAction(
role: user.role, action,
customPermissions: user.customPermissions, {
hasWritableDataSourceAccess, role: user.role,
}, actionPermissionMap); customPermissions: user.customPermissions,
cache.action.set(action, result); hasWritableDataSourceAccess,
cache.timestamp = Date.now(); },
return result; actionPermissionMap,
}, [user.role, user.customPermissions, hasWritableDataSourceAccess, isLoggedIn, isCacheValid]); );
cache.action.set(action, result);
cache.timestamp = Date.now();
return result;
},
[user.role, user.customPermissions, hasWritableDataSourceAccess, isLoggedIn, isCacheValid],
);
/** 数据源级权限检查 */ /** 数据源级权限检查 */
const hasDataSourcePermission = useCallback(( const hasDataSourcePermission = useCallback(
instanceId: number, (
action: 'read' | 'write' | 'delete' | 'export', instanceId: number,
database?: string, action: 'read' | 'write' | 'delete' | 'export',
table?: string database?: string,
): boolean => { table?: string,
if (!isLoggedIn) return false; ): boolean => {
if (user.role === 'admin') return true; // 管理员拥有所有数据源权限 if (!isLoggedIn) return false;
if (user.role === 'admin') return true; // 管理员拥有所有数据源权限
const cache = permissionCacheRef.current; const cache = permissionCacheRef.current;
const cacheKey = `${instanceId}:${action}:${database || '*'}:${table || '*'}`; const cacheKey = `${instanceId}:${action}:${database || '*'}:${table || '*'}`;
if (isCacheValid() && cache.dataSource.has(cacheKey)) { if (isCacheValid() && cache.dataSource.has(cacheKey)) {
return cache.dataSource.get(cacheKey)!; return cache.dataSource.get(cacheKey)!;
} }
const perms = user.dataSourcePermissions || []; const perms = user.dataSourcePermissions || [];
if (perms.length === 0) { if (perms.length === 0) {
cache.dataSource.set(cacheKey, false); cache.dataSource.set(cacheKey, false);
return false; return false;
} }
const hasPerm = perms.some((ds: DataSourcePermission) => { const hasPerm = perms.some((ds: DataSourcePermission) => {
// 检查实例ID // 检查实例ID
if (ds.instanceId !== instanceId) return false; 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.dataSource.set(cacheKey, hasPerm);
cache.timestamp = Date.now(); cache.timestamp = Date.now();
return hasPerm; return hasPerm;
}, [user.dataSourcePermissions, user.role, isLoggedIn, isCacheValid]); },
[user.dataSourcePermissions, user.role, isLoggedIn, isCacheValid],
);
/** 批量检查数据源权限 */ /** 批量检查数据源权限 */
const checkDataSourcePermissions = useCallback(( const checkDataSourcePermissions = useCallback(
instanceId: number, (
actions: ('read' | 'write' | 'delete' | 'export')[], instanceId: number,
database?: string, actions: ('read' | 'write' | 'delete' | 'export')[],
table?: string database?: string,
): Record<string, boolean> => { table?: string,
const result: Record<string, boolean> = {}; ): Record<string, boolean> => {
for (const action of actions) { const result: Record<string, boolean> = {};
result[action] = hasDataSourcePermission(instanceId, action, database, table); for (const action of actions) {
} result[action] = hasDataSourcePermission(instanceId, action, database, table);
return result; }
}, [hasDataSourcePermission]); return result;
},
[hasDataSourcePermission],
);
/** 从服务器刷新用户权限 */ /** 从服务器刷新用户权限 */
const refreshPermissions = useCallback(async (): Promise<void> => { const refreshPermissions = useCallback(async (): Promise<void> => {
@@ -449,9 +491,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
const dataSourcePermissions: DataSourcePermission[] = u.data_source_permissions const dataSourcePermissions: DataSourcePermission[] = u.data_source_permissions
? JSON.parse(u.data_source_permissions) ? JSON.parse(u.data_source_permissions)
: []; : [];
const customPermissions: Record<string, boolean> = u.custom_permissions const customPermissions: Record<string, boolean> = u.custom_permissions ? JSON.parse(u.custom_permissions) : {};
? JSON.parse(u.custom_permissions)
: {};
const newUser: AuthUser = { const newUser: AuthUser = {
...user, ...user,
@@ -475,35 +515,53 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
}, [user, clearPermissionCache, emitPermissionChange]); }, [user, clearPermissionCache, emitPermissionChange]);
const visibleMenuItems = useMemo(() => { 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]); }, [user.role, hasPermission]);
/** 检查是否拥有指定角色或以上级别权限 */ /** 检查是否拥有指定角色或以上级别权限 */
const hasRoleLevel = useCallback((minRole: UserRole): boolean => { const hasRoleLevel = useCallback(
return roleWeightMap[user.role] >= roleWeightMap[minRole]; (minRole: UserRole): boolean => {
}, [user.role]); return roleWeightMap[user.role] >= roleWeightMap[minRole];
},
[user.role],
);
const value = useMemo(() => ({ const value = useMemo(
user, () => ({
isLoggedIn, user,
login, isLoggedIn,
logout, login,
switchRole, logout,
hasPermission, switchRole,
hasActionPermission, hasPermission,
hasDataSourcePermission, hasActionPermission,
checkDataSourcePermissions, hasDataSourcePermission,
refreshPermissions, checkDataSourcePermissions,
clearPermissionCache, refreshPermissions,
visibleMenuItems, clearPermissionCache,
isAdmin: user.role === 'admin', visibleMenuItems,
isDBA: user.role === 'dba', isAdmin: user.role === 'admin',
isDeveloper: user.role === 'developer', isDBA: user.role === 'dba',
isGuest: user.role === 'auditor', isDeveloper: user.role === 'developer',
hasRoleLevel, isGuest: user.role === 'auditor',
}), [user, isLoggedIn, login, logout, switchRole, hasPermission, hasActionPermission, hasRoleLevel,
hasDataSourcePermission, checkDataSourcePermissions, refreshPermissions, }),
clearPermissionCache, visibleMenuItems, hasRoleLevel]); [
user,
isLoggedIn,
login,
logout,
switchRole,
hasPermission,
hasActionPermission,
hasDataSourcePermission,
checkDataSourcePermissions,
refreshPermissions,
clearPermissionCache,
visibleMenuItems,
hasRoleLevel,
],
);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>; return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
} }
@@ -525,7 +583,7 @@ export function useDataSourcePermission(
instanceId: number, instanceId: number,
action: 'read' | 'write' | 'delete' | 'export', action: 'read' | 'write' | 'delete' | 'export',
database?: string, database?: string,
table?: string table?: string,
): boolean { ): boolean {
const { hasDataSourcePermission } = useAuth(); const { hasDataSourcePermission } = useAuth();
return hasDataSourcePermission(instanceId, action, database, table); return hasDataSourcePermission(instanceId, action, database, table);