From f758297a6edd2f8e5d0b88003b75957a6905404f Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 29 May 2026 12:32:02 +0800 Subject: [PATCH] fix(frontend): AuthProvider state drift and double-management - Remove render-time fallback to module store (auth-session) for roles - Consolidate login/refresh/clear logic into reusable helpers - Prevent UI logout flicker on transient /auth/userinfo failures - Add test to verify module store changes don't pollute provider state Refs: review-fix-closure-2026-05-28 AuthProvider state convergence --- .../src/app/providers/AuthProvider.test.tsx | 20 ++++ .../admin/src/app/providers/AuthProvider.tsx | 93 ++++++++++--------- 2 files changed, 68 insertions(+), 45 deletions(-) diff --git a/frontend/admin/src/app/providers/AuthProvider.test.tsx b/frontend/admin/src/app/providers/AuthProvider.test.tsx index 2b2ed53..8158ea3 100644 --- a/frontend/admin/src/app/providers/AuthProvider.test.tsx +++ b/frontend/admin/src/app/providers/AuthProvider.test.tsx @@ -239,6 +239,26 @@ describe('AuthProvider', () => { expect(screen.getByTestId('roles')).toHaveTextContent('admin') }) + it('keeps provider roles stable when the module session store changes after mount', async () => { + storedAccessToken = 'cached-access-token' + storedUser = operatorUser + storedRoles = [] + isAccessTokenExpiredMock.mockReturnValue(false) + + const view = renderAuthProvider() + await waitForProviderIdle() + expect(screen.getByTestId('roles').textContent).toBe('') + + storedRoles = adminRoles + view.rerender( + + + , + ) + + expect(screen.getByTestId('roles').textContent).toBe('') + }) + it('clears the local session when auth state has no current user and no backend session cookie exists', async () => { storedAccessToken = 'dangling-access-token' isAuthenticatedMock.mockReturnValue(true) diff --git a/frontend/admin/src/app/providers/AuthProvider.tsx b/frontend/admin/src/app/providers/AuthProvider.tsx index 072aa06..5be8db3 100644 --- a/frontend/admin/src/app/providers/AuthProvider.tsx +++ b/frontend/admin/src/app/providers/AuthProvider.tsx @@ -46,11 +46,9 @@ export function AuthProvider({ children }: AuthProviderProps) { const [roles, setRoles] = useState(getCurrentRoles()) const [isLoading, setIsLoading] = useState(true) const navigate = useNavigate() - const effectiveUser = user ?? getCurrentUser() - const effectiveRoles = roles.length > 0 ? roles : getCurrentRoles() // 判断是否为管理员 - const isAdmin = effectiveRoles.some((role) => role.code === 'admin') + const isAdmin = roles.some((role) => role.code === 'admin') /** * 获取用户角色 @@ -64,6 +62,31 @@ export function AuthProvider({ children }: AuthProviderProps) { } }, []) + const applyAuthState = useCallback((nextUser: SessionUser | null, nextRoles: Role[]) => { + setUser(nextUser) + setRoles(nextRoles) + }, []) + + const clearLocalAuthState = useCallback(() => { + applyAuthState(null, []) + }, [applyAuthState]) + + const persistSessionUser = useCallback((nextUser: SessionUser) => { + setCurrentUser(nextUser) + setUser(nextUser) + }, []) + + const persistSessionRoles = useCallback((nextRoles: Role[]) => { + setCurrentRoles(nextRoles) + setRoles(nextRoles) + }, []) + + const loadRolesForUser = useCallback(async (userId: number): Promise => { + const userRoles = await fetchUserRoles(userId) + persistSessionRoles(userRoles) + return userRoles + }, [fetchUserRoles, persistSessionRoles]) + /** * 登录成功回调 */ @@ -71,19 +94,14 @@ export function AuthProvider({ children }: AuthProviderProps) { // 保存 tokens setAccessToken(tokenBundle.access_token, tokenBundle.expires_in) setRefreshToken(tokenBundle.refresh_token) - - // 保存用户信息 - setCurrentUser(tokenBundle.user) - setUser(tokenBundle.user) - - // 获取角色 - const userRoles = await fetchUserRoles(tokenBundle.user.id) - setCurrentRoles(userRoles) - setRoles(userRoles) - + + // 保存用户信息与角色 + persistSessionUser(tokenBundle.user) + await loadRolesForUser(tokenBundle.user.id) + // 初始化 CSRF Token await initCSRFToken() - }, [fetchUserRoles]) + }, [loadRolesForUser, persistSessionUser]) /** * 刷新用户信息 @@ -91,18 +109,12 @@ export function AuthProvider({ children }: AuthProviderProps) { const refreshUser = useCallback(async () => { try { const userInfo = await get('/auth/userinfo') - setCurrentUser(userInfo) - setUser(userInfo) - - const userRoles = await fetchUserRoles(userInfo.id) - setCurrentRoles(userRoles) - setRoles(userRoles) + persistSessionUser(userInfo) + await loadRolesForUser(userInfo.id) } catch { - // 刷新失败,清除会话 - setUser(null) - setRoles([]) + // 保留当前 provider 状态,避免短暂的 userinfo 抖动清空已登录会话 } - }, [fetchUserRoles]) + }, [loadRolesForUser, persistSessionUser]) /** * 登出 @@ -117,11 +129,10 @@ export function AuthProvider({ children }: AuthProviderProps) { clearRefreshToken() clearSession() clearCSRFToken() - setUser(null) - setRoles([]) + clearLocalAuthState() navigate('/login') } - }, [navigate]) + }, [clearLocalAuthState, navigate]) /** * 会话恢复(应用启动时,只运行一次) @@ -132,10 +143,9 @@ export function AuthProvider({ children }: AuthProviderProps) { if (isAuthenticated() && !isAccessTokenExpired()) { const currentUser = getCurrentUser() const currentRoles = getCurrentRoles() - + if (currentUser) { - setUser(currentUser) - setRoles(currentRoles) + applyAuthState(currentUser, currentRoles) await initCSRFToken() setIsLoading(false) return @@ -145,8 +155,7 @@ export function AuthProvider({ children }: AuthProviderProps) { if (!hasSessionPresenceCookie()) { clearRefreshToken() clearSession() - setUser(null) - setRoles([]) + clearLocalAuthState() setIsLoading(false) return } @@ -158,21 +167,15 @@ export function AuthProvider({ children }: AuthProviderProps) { setAccessToken(result.access_token, result.expires_in) setRefreshToken(result.refresh_token) - // 保存用户信息 - setCurrentUser(result.user) - setUser(result.user) - - // 获取角色 - const userRoles = await fetchUserRoles(result.user.id) - setCurrentRoles(userRoles) - setRoles(userRoles) + // 保存用户信息与角色 + persistSessionUser(result.user) + await loadRolesForUser(result.user.id) await initCSRFToken() } catch { // 刷新失败,清除会话 clearRefreshToken() clearSession() - setUser(null) - setRoles([]) + clearLocalAuthState() } setIsLoading(false) @@ -183,10 +186,10 @@ export function AuthProvider({ children }: AuthProviderProps) { }, []) // 只在挂载时运行一次,不依赖 location.pathname const value: AuthContextValue = { - user: effectiveUser, - roles: effectiveRoles, + user, + roles, isAdmin, - isAuthenticated: effectiveUser !== null, + isAuthenticated: user !== null, isLoading, onLoginSuccess, logout,