P1-02: OAuth ExchangeCode and GetUserInfo now accept context parameter
to properly propagate request context to HTTP calls
P1-16: AuthProvider isAuthenticated now uses single source of truth
(effectiveUser !== null) instead of double-checking both
React state and module-level function
202 lines
4.9 KiB
TypeScript
202 lines
4.9 KiB
TypeScript
/**
|
||
* AuthProvider - 全局会话上下文
|
||
*
|
||
* 提供:
|
||
* - 会话状态(user, roles, isAdmin)
|
||
* - 登录/登出方法
|
||
* - 会话恢复(启动时自动刷新)
|
||
*/
|
||
|
||
import {
|
||
useEffect,
|
||
useState,
|
||
useCallback,
|
||
type ReactNode,
|
||
} from 'react'
|
||
import { useNavigate } from 'react-router-dom'
|
||
import type { SessionUser, Role, TokenBundle } from '@/types'
|
||
import { get } from '@/lib/http'
|
||
import {
|
||
setRefreshToken,
|
||
clearRefreshToken,
|
||
hasSessionPresenceCookie,
|
||
} from '@/lib/storage'
|
||
import {
|
||
setAccessToken,
|
||
getCurrentUser,
|
||
setCurrentUser,
|
||
getCurrentRoles,
|
||
setCurrentRoles,
|
||
clearSession,
|
||
isAuthenticated,
|
||
isAccessTokenExpired,
|
||
} from '@/lib/http/auth-session'
|
||
import { initCSRFToken, clearCSRFToken } from '@/lib/http/csrf'
|
||
import { logout as logoutRequest, refreshSession } from '@/services/auth'
|
||
import { AuthContext, type AuthContextValue } from './auth-context'
|
||
|
||
// ==================== Provider ====================
|
||
|
||
interface AuthProviderProps {
|
||
children: ReactNode
|
||
}
|
||
|
||
export function AuthProvider({ children }: AuthProviderProps) {
|
||
const [user, setUser] = useState<SessionUser | null>(getCurrentUser())
|
||
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 fetchUserRoles = useCallback(async (userId: number): Promise<Role[]> => {
|
||
try {
|
||
const result = await get<Role[]>(`/users/${userId}/roles`)
|
||
return result
|
||
} catch {
|
||
return []
|
||
}
|
||
}, [])
|
||
|
||
/**
|
||
* 登录成功回调
|
||
*/
|
||
const onLoginSuccess = useCallback(async (tokenBundle: TokenBundle) => {
|
||
// 保存 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)
|
||
|
||
// 初始化 CSRF Token
|
||
await initCSRFToken()
|
||
}, [fetchUserRoles])
|
||
|
||
/**
|
||
* 刷新用户信息
|
||
*/
|
||
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)
|
||
} catch {
|
||
// 刷新失败,清除会话
|
||
setUser(null)
|
||
setRoles([])
|
||
}
|
||
}, [fetchUserRoles])
|
||
|
||
/**
|
||
* 登出
|
||
*/
|
||
const logout = useCallback(async () => {
|
||
try {
|
||
await logoutRequest()
|
||
} catch {
|
||
// 忽略登出请求错误
|
||
} finally {
|
||
// 无论请求成功与否,都清除本地会话和 CSRF Token
|
||
clearRefreshToken()
|
||
clearSession()
|
||
clearCSRFToken()
|
||
setUser(null)
|
||
setRoles([])
|
||
navigate('/login')
|
||
}
|
||
}, [navigate])
|
||
|
||
/**
|
||
* 会话恢复(应用启动时,只运行一次)
|
||
*/
|
||
useEffect(() => {
|
||
const restoreSession = async () => {
|
||
// 如果已有 access_token 且未过期,直接使用
|
||
if (isAuthenticated() && !isAccessTokenExpired()) {
|
||
const currentUser = getCurrentUser()
|
||
const currentRoles = getCurrentRoles()
|
||
|
||
if (currentUser) {
|
||
setUser(currentUser)
|
||
setRoles(currentRoles)
|
||
await initCSRFToken()
|
||
setIsLoading(false)
|
||
return
|
||
}
|
||
}
|
||
|
||
if (!hasSessionPresenceCookie()) {
|
||
clearRefreshToken()
|
||
clearSession()
|
||
setUser(null)
|
||
setRoles([])
|
||
setIsLoading(false)
|
||
return
|
||
}
|
||
|
||
try {
|
||
const result = await refreshSession()
|
||
|
||
// 保存 tokens
|
||
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)
|
||
await initCSRFToken()
|
||
} catch {
|
||
// 刷新失败,清除会话
|
||
clearRefreshToken()
|
||
clearSession()
|
||
setUser(null)
|
||
setRoles([])
|
||
}
|
||
|
||
setIsLoading(false)
|
||
}
|
||
|
||
restoreSession()
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, []) // 只在挂载时运行一次,不依赖 location.pathname
|
||
|
||
const value: AuthContextValue = {
|
||
user: effectiveUser,
|
||
roles: effectiveRoles,
|
||
isAdmin,
|
||
isAuthenticated: effectiveUser !== null,
|
||
isLoading,
|
||
onLoginSuccess,
|
||
logout,
|
||
refreshUser,
|
||
}
|
||
|
||
return (
|
||
<AuthContext.Provider value={value}>
|
||
{children}
|
||
</AuthContext.Provider>
|
||
)
|
||
}
|