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:
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user