import { useState, useEffect, useRef, type ReactNode } from 'react'; import { AuthContext, type AuthState, type AuthUser } from './useAuth'; import { setTokenGetter, setPreviewTokenGetter, invalidateAuthenticatedRequests, fetchPreviewControl } from './api-client'; const AUTH_API = '/api/auth'; export default function AuthProvider({ children }: { children: ReactNode }) { const [state, setState] = useState({ isLoading: true, isAuthenticated: false, user: null, error: null, }); const tokenRef = useRef(null); const authStarted = useRef(false); const previewTokenRef = useRef(null); const actorRef = useRef(null); const transitionRef = useRef(0); const [identityVersion, setIdentityVersion] = useState(0); const [preview, setPreview] = useState<{ user: AuthUser; expiresAt: string } | null>(null); const [canPreview, setCanPreview] = useState(false); const [previewNotice, setPreviewNotice] = useState(null); function resetPreview(message: string | null = null) { transitionRef.current += 1; previewTokenRef.current = null; sessionStorage.removeItem('bi_preview_token'); invalidateAuthenticatedRequests(); setIdentityVersion(v => v + 1); setPreview(null); setPreviewNotice(message); } async function loadPreviewContext(user: AuthUser | null) { actorRef.current = user; try { const context = await fetchPreviewControl<{ canPreview: boolean; actor: AuthUser }>('context'); actorRef.current = context.actor; setCanPreview(context.canPreview); if (context.canPreview) { const savedPreview = sessionStorage.getItem('bi_preview_token'); if (savedPreview) { previewTokenRef.current = savedPreview; try { const current = await fetchPreviewControl<{ user: AuthUser; expiresAt: string }>('current', undefined, true); setPreview(current); } catch { resetPreview('上次权限视角已失效,已返回本人视角。'); } } } else { resetPreview(); } } catch { resetPreview(); setCanPreview(false); } } async function finishAuthentication(user: AuthUser | null) { await loadPreviewContext(user); if (!tokenRef.current) return; setState({ isLoading: false, isAuthenticated: true, user: actorRef.current, error: null }); } async function startPreview(userId: string) { const transition = ++transitionRef.current; const next = await fetchPreviewControl<{ previewToken: string; user: AuthUser; expiresAt: string }>('start', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ userId }), }); if (transition !== transitionRef.current) return; invalidateAuthenticatedRequests(); previewTokenRef.current = next.previewToken; sessionStorage.setItem('bi_preview_token', next.previewToken); setPreview({ user: next.user, expiresAt: next.expiresAt }); setIdentityVersion(v => v + 1); setPreviewNotice(null); } async function stopPreview() { const stopping = fetchPreviewControl('stop', { method: 'POST' }, true); resetPreview(); await stopping.catch(() => { setPreviewNotice('已退出本地预览;服务端凭证将自动到期。'); }); } useEffect(() => { if (!preview) return; const token = previewTokenRef.current; const validate = async () => { try { const current = await fetchPreviewControl<{ user: AuthUser; expiresAt: string }>('current', undefined, true); if (token !== previewTokenRef.current) return; if (JSON.stringify(current.user) !== JSON.stringify(preview.user)) { invalidateAuthenticatedRequests(); setPreview(current); setIdentityVersion(v => v + 1); } } catch { if (token === previewTokenRef.current) resetPreview('权限视角验证失败,已返回本人视角,请重新选择。'); } }; const timer = window.setInterval(validate, 60_000); const expiry = window.setTimeout(() => resetPreview('权限视角已到期,已返回本人视角。'), Math.max(0, Date.parse(preview.expiresAt) - Date.now())); window.addEventListener('focus', validate); return () => { clearInterval(timer); clearTimeout(expiry); window.removeEventListener('focus', validate); }; }, [preview]); useEffect(() => { // 设置全局 token getter setTokenGetter(() => tokenRef.current); setPreviewTokenGetter(() => previewTokenRef.current); // 监听 401 事件 const onUnauthorized = () => { resetPreview(); setCanPreview(false); tokenRef.current = null; sessionStorage.removeItem('bi_jwt'); sessionStorage.removeItem('bi_user'); actorRef.current = null; setState({ isLoading: false, isAuthenticated: false, user: null, error: '会话已过期' }); }; window.addEventListener('auth:unauthorized', onUnauthorized); const onPreviewEnded = (event: Event) => { if ((event as CustomEvent).detail === 'PREVIEW_REVOKED') setCanPreview(false); resetPreview('预览权限已失效,已返回本人视角。'); }; const onReadOnly = () => setPreviewNotice('权限视角仅供查看数据,请退出预览后进行修改操作。'); window.addEventListener('auth:preview-ended', onPreviewEnded); window.addEventListener('auth:preview-readonly', onReadOnly); // StrictMode 重新安装监听,但一次性 jumpToken 仅交换一次。 if (!authStarted.current) { authStarted.current = true; void authenticate(); } return () => { window.removeEventListener('auth:unauthorized', onUnauthorized); window.removeEventListener('auth:preview-ended', onPreviewEnded); window.removeEventListener('auth:preview-readonly', onReadOnly); }; }, []); async function authenticate() { // 本地开发免登录开关:.env 里设 VITE_DEV_BYPASS_AUTH=1 启用,仅 dev 生效 if (import.meta.env.DEV && import.meta.env.VITE_DEV_BYPASS_AUTH === '1') { const devUser: AuthUser = { userId: 'dev-local', userName: '本地开发', permissionLevel: 'full', depName: '', roles: ['所有权限', 'BI-SCHEDULE-OPT', 'BI-ADMIN-FEEDBACK', 'BI-LEADER-ENERGY'], }; // 预览授权仍由后端独立开发开关决定,不使用真实用户令牌。 tokenRef.current = null; await loadPreviewContext(devUser); actorRef.current ||= devUser; setState({ isLoading: false, isAuthenticated: true, user: actorRef.current, error: null }); return; } // 1. 检查 sessionStorage 中是否有 JWT const savedToken = sessionStorage.getItem('bi_jwt'); if (savedToken) { tokenRef.current = savedToken; // 从受保护的身份接口验证 JWT;不使用本地缓存用户决定权限。 try { const res = await fetch('/api/auth/me', { headers: { Authorization: `Bearer ${savedToken}` }, }); if (res.ok) { const verifiedUser: AuthUser = await res.json(); if (!verifiedUser.userId) throw new Error('无效的用户身份'); await finishAuthentication(verifiedUser); return; } } catch { /* token 无效,继续流程 */ } tokenRef.current = null; resetPreview(); sessionStorage.removeItem('bi_jwt'); sessionStorage.removeItem('bi_user'); } // 2. 从 URL 提取 jumpToken const params = new URLSearchParams(window.location.search); const jumpToken = params.get('jumpToken'); if (!jumpToken) { setState({ isLoading: false, isAuthenticated: false, user: null, error: '请从业务系统跳转访问' }); return; } try { // 3. 一步完成:jumpToken → 用户信息 + JWT const res = await fetch(`${AUTH_API}/exchange?jumpToken=${encodeURIComponent(jumpToken)}`); const data = await res.json(); if (!res.ok || !data.token) { setState({ isLoading: false, isAuthenticated: false, user: null, error: data.message || '跳转令牌无效或已过期' }); return; } // 4. 存储 JWT tokenRef.current = data.token; sessionStorage.setItem('bi_jwt', data.token); sessionStorage.setItem('bi_user', JSON.stringify(data.user)); // 6. 清除 URL 中的 jumpToken params.delete('jumpToken'); const cleanUrl = params.toString() ? `${window.location.pathname}?${params.toString()}${window.location.hash}` : `${window.location.pathname}${window.location.hash}`; window.history.replaceState({}, '', cleanUrl); await finishAuthentication(data.user); } catch (e) { setState({ isLoading: false, isAuthenticated: false, user: null, error: '认证过程出错' }); } } return ( setPreviewNotice(null) }}> {children} ); }