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
This commit is contained in:
Your Name
2026-05-29 12:32:02 +08:00
parent 8a45548ed8
commit f758297a6e
2 changed files with 68 additions and 45 deletions

View File

@@ -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(
<AuthProvider>
<Probe />
</AuthProvider>,
)
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)

View File

@@ -46,11 +46,9 @@ export function AuthProvider({ children }: AuthProviderProps) {
const [roles, setRoles] = useState<Role[]>(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<Role[]> => {
const userRoles = await fetchUserRoles(userId)
persistSessionRoles(userRoles)
return userRoles
}, [fetchUserRoles, persistSessionRoles])
/**
* 登录成功回调
*/
@@ -72,18 +95,13 @@ export function AuthProvider({ children }: AuthProviderProps) {
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<SessionUser>('/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])
/**
* 会话恢复(应用启动时,只运行一次)
@@ -134,8 +145,7 @@ export function AuthProvider({ children }: AuthProviderProps) {
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,