Files
kefu_cloud/web/src/stores/auth.tsx
T

222 lines
6.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { createContext, useContext, useState, useCallback, useEffect, type ReactNode } from 'react'
import {
PERMISSIONS_CHANGED_EVENT, SESSION_EXPIRED_EVENT, getToken, onTokenRenewed, setToken, resetSessionExpiredFlag,
} from '@/services/request'
import {
getMe,
login as loginApi,
refreshSession,
updateMyStatus,
mePermissions,
type AgentPresence,
type LoginResult,
} from '@/services/api'
interface AuthState {
user: LoginResult | null
loading: boolean
permissions: Set<string>
permissionsLoaded: boolean
login: (username: string, password: string) => Promise<LoginResult>
logout: () => void
/** 切换本人在线 / 忙碌 / 离线 */
setPresence: (status: AgentPresence) => Promise<void>
refreshPermissions: () => Promise<void>
}
const AuthContext = createContext<AuthState>({
user: null,
loading: false,
permissions: new Set(),
permissionsLoaded: false,
login: async () => { throw new Error('认证上下文未初始化') },
logout: () => {},
setPresence: async () => {},
refreshPermissions: async () => {},
})
function persistUser(u: LoginResult | null) {
if (!u) {
localStorage.removeItem('auth_user')
return
}
localStorage.setItem('auth_user', JSON.stringify(u))
}
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<LoginResult | null>(() => {
const saved = localStorage.getItem('auth_user')
if (saved) {
try {
const u = JSON.parse(saved) as LoginResult
setToken(u.token)
return u
} catch {
return null
}
}
return null
})
const [loading, setLoading] = useState(false)
const [permissions, setPermissions] = useState<Set<string>>(new Set())
const [permissionsLoaded, setPermissionsLoaded] = useState(false)
const refreshPermissions = useCallback(async () => {
if (!user?.token) return
try {
const res = await mePermissions()
if (res.data?.permissions) {
setPermissions(new Set(res.data.permissions))
}
} catch {
setPermissions(new Set())
} finally {
setPermissionsLoaded(true)
}
}, [user?.token])
useEffect(() => {
const handlePermissionsChanged = () => { void refreshPermissions() }
window.addEventListener(PERMISSIONS_CHANGED_EVENT, handlePermissionsChanged)
return () => window.removeEventListener(PERMISSIONS_CHANGED_EVENT, handlePermissionsChanged)
}, [refreshPermissions])
// 刷新后同步服务端真实状态(含 status + permissions)
useEffect(() => {
if (!user?.token) return
let cancelled = false
setPermissionsLoaded(false)
Promise.all([getMe(), mePermissions()])
.then(([meRes, permRes]) => {
if (cancelled) return
if (meRes.data) {
setUser(prev => {
if (!prev) return prev
const next = {
...prev,
nickname: meRes.data.nickname || prev.nickname,
role: meRes.data.role || prev.role,
status: meRes.data.status || prev.status || 'offline',
}
persistUser(next)
return next
})
}
if (permRes.data?.permissions) {
setPermissions(new Set(permRes.data.permissions))
}
setPermissionsLoaded(true)
})
.catch(() => {
if (!cancelled) {
setPermissions(new Set())
setPermissionsLoaded(true)
}
})
return () => { cancelled = true }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [user?.token])
const login = useCallback(async (username: string, password: string) => {
setLoading(true)
try {
const res = await loginApi({ username, password })
const u: LoginResult = { ...res.data, status: res.data.status || 'online' }
resetSessionExpiredFlag()
setToken(u.token)
setUser(u)
persistUser(u)
setPermissionsLoaded(false)
try {
const permissionRes = await mePermissions()
setPermissions(new Set(permissionRes.data?.permissions || []))
} catch {
setPermissions(new Set())
} finally {
setPermissionsLoaded(true)
}
return u
} finally {
setLoading(false)
}
}, [])
// 所有续期入口共用:同步请求模块、React 状态和 localStorage 中的令牌
const applyRenewedToken = useCallback((nextToken: string) => {
if (!nextToken) return
setToken(nextToken)
setUser(prev => {
if (!prev || prev.token === nextToken) return prev
const updated = { ...prev, token: nextToken }
persistUser(updated)
return updated
})
}, [])
// 服务端滑动续期(X-Renewed-Token)
useEffect(() => onTokenRenewed(applyRenewedToken), [applyRenewedToken])
// 定时续期:长挂机场景下每 10 分钟主动换新令牌,避免 WS 重连/请求时已过期
useEffect(() => {
if (!user?.token) return
const sessionToken = user.token
const timer = window.setInterval(() => {
void refreshSession().then(res => {
// 请求期间已退出、重新登录或由其它响应完成续期时,不再应用旧响应。
if (getToken() !== sessionToken) return
applyRenewedToken(res.data?.token || '')
}).catch(() => { /* 401 已由 request 层统一处理;网络错误等待下一轮 */ })
}, 10 * 60 * 1000)
return () => window.clearInterval(timer)
}, [applyRenewedToken, user?.token])
// 会话过期(所有 401 的统一出口):清理登录态并回到登录页
useEffect(() => {
const handleExpired = () => {
setToken('')
setUser(null)
setPermissions(new Set())
setPermissionsLoaded(false)
persistUser(null)
if (window.location.pathname !== '/login') {
window.location.replace('/login?expired=1')
}
}
window.addEventListener(SESSION_EXPIRED_EVENT, handleExpired)
return () => window.removeEventListener(SESSION_EXPIRED_EVENT, handleExpired)
}, [])
const setPresence = useCallback(async (status: AgentPresence) => {
const res = await updateMyStatus(status)
const nextStatus = (res.data?.status || status) as string
setUser(prev => {
if (!prev) return prev
const next = { ...prev, status: nextStatus }
persistUser(next)
return next
})
}, [])
const logout = useCallback(() => {
void updateMyStatus('offline').catch(() => {})
setToken('')
setUser(null)
setPermissions(new Set())
setPermissionsLoaded(false)
persistUser(null)
}, [])
return (
<AuthContext.Provider value={{ user, loading, permissions, permissionsLoaded, login, logout, setPresence, refreshPermissions }}>
{children}
</AuthContext.Provider>
)
}
export const useAuth = () => useContext(AuthContext)
export function usePermission(code: string): boolean {
const { permissions } = useAuth()
return permissions.has(code)
}