import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState, } from 'react'; import { useGameSettings } from '../../hooks/useGameSettings'; import { AUTH_STATE_EVENT, getStoredAccessToken, refreshStoredAccessToken, } from '../../services/apiClient'; import { type AuthAuditLogEntry, type AuthCaptchaChallenge, authEntry, type AuthLoginMethod, type AuthRiskBlockSummary, type AuthSessionSnapshot, type AuthSessionSummary, type AuthUser, bindWechatPhone, changePassword, changePhoneNumber, consumeAuthCallbackResult, getAuthAuditLogs, getAuthLoginOptions, getAuthRiskBlocks, getAuthSessions, getCaptchaChallengeFromError, getCurrentAuthUser, liftAuthRiskBlock, loginWithPhoneCode, logoutAllAuthSessions, logoutAuthUser, redeemRegistrationInviteCode, resetPassword, revokeAuthSessions, sendPhoneLoginCode, setStoredLastLoginPhone, startWechatLogin, } from '../../services/authService'; import { AccountModal } from './AccountModal'; import { AuthUiContext, type PlatformSettingsSection } from './AuthUiContext'; import { BindPhoneScreen } from './BindPhoneScreen'; import { LoginScreen } from './LoginScreen'; import { RegistrationInviteModal } from './RegistrationInviteModal'; type AuthGateProps = { children: ReactNode; }; type AuthStatus = | 'checking' | 'recovering' | 'unauthenticated' | 'pending_bind_phone' | 'ready' | 'error'; const REQUIRED_LOGIN_METHODS: AuthLoginMethod[] = ['phone', 'password']; function readInviteCodeFromLocation(): string { const params = new URLSearchParams(window.location.search || ''); return (params.get('inviteCode') || params.get('invite_code') || '') .trim() .replace(/[^0-9a-z]/gi, '') .toUpperCase(); } function normalizeAvailableLoginMethods( methods: AuthLoginMethod[] | null | undefined, ): AuthLoginMethod[] { const normalizedMethods = Array.from(new Set(methods ?? [])); // 登录面板的核心入口必须稳定展示,login-options 只补充微信等环境相关入口。 return Array.from( new Set([ ...REQUIRED_LOGIN_METHODS, ...normalizedMethods, ]), ); } type AuthHydrateSessionResult = | { kind: 'authenticated'; session: AuthSessionSnapshot & { user: AuthUser; }; } | { kind: 'guest'; session: AuthSessionSnapshot | null; }; export function AuthGate({ children }: AuthGateProps) { const [status, setStatus] = useState('checking'); const [user, setUser] = useState(null); const [availableLoginMethods, setAvailableLoginMethods] = useState< AuthLoginMethod[] >([]); const [error, setError] = useState(''); const [sendingCode, setSendingCode] = useState(false); const [loggingIn, setLoggingIn] = useState(false); const [bindingPhone, setBindingPhone] = useState(false); const [wechatLoading, setWechatLoading] = useState(false); const [showLoginModal, setShowLoginModal] = useState(false); const [pendingInviteCode, setPendingInviteCode] = useState(''); const [showRegistrationInviteModal, setShowRegistrationInviteModal] = useState(false); const [submittingRegistrationInvite, setSubmittingRegistrationInvite] = useState(false); const [registrationInviteError, setRegistrationInviteError] = useState(''); const [showSettingsModal, setShowSettingsModal] = useState(false); const [settingsEntryMode, setSettingsEntryMode] = useState< 'settings' | 'account' >('settings'); const [initialSettingsSection, setInitialSettingsSection] = useState(null); const [sessions, setSessions] = useState([]); const [loadingSessions, setLoadingSessions] = useState(false); const [revokingSessionIds, setRevokingSessionIds] = useState([]); const [auditLogs, setAuditLogs] = useState([]); const [loadingAuditLogs, setLoadingAuditLogs] = useState(false); const [riskBlocks, setRiskBlocks] = useState([]); const [loadingRiskBlocks, setLoadingRiskBlocks] = useState(false); const [loginCaptchaChallenge, setLoginCaptchaChallenge] = useState(null); const [bindCaptchaChallenge, setBindCaptchaChallenge] = useState(null); const [changePhoneCaptchaChallenge, setChangePhoneCaptchaChallenge] = useState(null); const pendingProtectedActionRef = useRef<(() => void) | null>(null); const autoOpenedInviteCodeRef = useRef(null); const hasRenderedPlatformContentRef = useRef(false); const authHydrateVersionRef = useRef(0); const canKeepPlatformContentMounted = hasRenderedPlatformContentRef.current && (status === 'checking' || status === 'recovering'); const readyUser = status === 'ready' || canKeepPlatformContentMounted ? user : null; const settings = useGameSettings(readyUser?.id ?? null); const platformThemeClass = `platform-theme--${settings.platformTheme}`; if (status === 'ready' || status === 'unauthenticated') { hasRenderedPlatformContentRef.current = true; } const activateReadyUser = useCallback((nextUser: AuthUser) => { // 受保护业务 hook 只在 readyUser 暴露后启动,必须先保证请求层能带 Bearer token。 authHydrateVersionRef.current += 1; setUser(nextUser); setStatus('ready'); }, []); const clearLocalAuthenticatedState = useCallback(() => { // 退出动作必须先收回前端鉴权上下文,再等待后端吊销完成。 // 否则平台壳层会在无刷新状态下继续暴露旧用户的私有作品缓存。 authHydrateVersionRef.current += 1; pendingProtectedActionRef.current = null; setUser(null); setStatus('unauthenticated'); setShowLoginModal(false); setShowRegistrationInviteModal(false); setShowSettingsModal(false); setSettingsEntryMode('settings'); setInitialSettingsSection(null); setSessions([]); setRevokingSessionIds([]); setAuditLogs([]); setRiskBlocks([]); setLoginCaptchaChallenge(null); setBindCaptchaChallenge(null); setChangePhoneCaptchaChallenge(null); setPendingInviteCode(''); setRegistrationInviteError(''); setError(''); }, []); const restoreAuthSession = useCallback(async () => { const hadLocalAccessToken = Boolean(getStoredAccessToken()); if (hadLocalAccessToken) { try { const session = await getCurrentAuthUser(); if (session.user) { const confirmedUser = session.user; // 中文注释:已有 access token 能确认当前账号时,refresh 只作为续期和每日登录埋点补强。 // refresh cookie 临时失效或代理抖动不能反向抹掉这次已确认的登录态。 void refreshStoredAccessToken({ clearOnFailure: false }).catch( () => undefined, ); return { kind: 'authenticated', session: { ...session, user: confirmedUser, }, } satisfies AuthHydrateSessionResult; } return { kind: 'guest', session, } satisfies AuthHydrateSessionResult; } catch { // 本地 token 可能已过期或被吊销,再尝试通过 refresh cookie 补票。 } } await refreshStoredAccessToken({ clearOnFailure: true }); const session = await getCurrentAuthUser(); if (session.user) { const confirmedUser = session.user; return { kind: 'authenticated', session: { ...session, user: confirmedUser, }, } satisfies AuthHydrateSessionResult; } return { kind: 'guest', session, } satisfies AuthHydrateSessionResult; }, []); const logoutCurrentSession = useCallback(async () => { clearLocalAuthenticatedState(); try { await logoutAuthUser(); } catch (logoutError) { setError( logoutError instanceof Error ? logoutError.message : '退出登录失败,请刷新页面确认状态。', ); } }, [clearLocalAuthenticatedState]); const logoutAllSessions = useCallback(async () => { clearLocalAuthenticatedState(); try { await logoutAllAuthSessions(); } catch (logoutError) { setError( logoutError instanceof Error ? logoutError.message : '退出全部设备失败,请刷新页面确认状态。', ); } }, [clearLocalAuthenticatedState]); const closeLoginModal = useCallback(() => { pendingProtectedActionRef.current = null; setShowLoginModal(false); setLoginCaptchaChallenge(null); setError(''); }, []); const closeRegistrationInviteModal = useCallback(() => { setShowRegistrationInviteModal(false); setRegistrationInviteError(''); setPendingInviteCode(''); }, []); const closeSettingsModal = useCallback(() => { setShowSettingsModal(false); setSettingsEntryMode('settings'); setInitialSettingsSection(null); }, []); const openLoginModal = useCallback( (postLoginAction?: (() => void) | null) => { if (readyUser) { postLoginAction?.(); return; } pendingProtectedActionRef.current = postLoginAction ?? null; setShowLoginModal(true); }, [readyUser], ); const requireAuth = useCallback( (action: () => void) => { openLoginModal(action); }, [openLoginModal], ); const openSettingsModal = useCallback( (section?: PlatformSettingsSection) => { if (readyUser) { setSettingsEntryMode('settings'); setInitialSettingsSection(section ?? null); setShowSettingsModal(true); return; } openLoginModal(); }, [openLoginModal, readyUser], ); const openAccountModal = useCallback(() => { if (readyUser) { setSettingsEntryMode('account'); setInitialSettingsSection('account'); setShowSettingsModal(true); return; } openLoginModal(); }, [openLoginModal, readyUser]); useEffect(() => { if (status !== 'unauthenticated' || readyUser || showLoginModal) { return; } const inviteCode = readInviteCodeFromLocation(); if (!inviteCode) { return; } if (autoOpenedInviteCodeRef.current === inviteCode) { return; } autoOpenedInviteCodeRef.current = inviteCode; setPendingInviteCode(inviteCode); }, [readyUser, showLoginModal, status]); useEffect(() => { let isActive = true; const hydrate = async (hydrateToken: number) => { const isCurrentHydrate = () => isActive && hydrateToken === authHydrateVersionRef.current; const callbackResult = consumeAuthCallbackResult(); const loadLoginOptions = async () => { const options = await getAuthLoginOptions(); if (!isCurrentHydrate()) { return null; } setAvailableLoginMethods( normalizeAvailableLoginMethods(options.availableLoginMethods), ); return options; }; const resolveGuestFallback = async () => { try { await loadLoginOptions(); if (!isCurrentHydrate()) { return; } setUser(null); setStatus('unauthenticated'); } catch (optionsError) { if (!isCurrentHydrate()) { return; } setAvailableLoginMethods(REQUIRED_LOGIN_METHODS); setUser(null); // 中文注释:登录方式接口失败时按产品约定保留验证码和密码登录入口; // 这里不展示接口读取错误,避免用户误以为登录本身不可用。 setError(callbackResult?.error ?? ''); setStatus('unauthenticated'); } }; if (callbackResult?.error && isCurrentHydrate()) { setError(callbackResult.error); setShowLoginModal(true); } try { const restoredSession = await restoreAuthSession(); if (!isCurrentHydrate()) { return; } if (restoredSession.kind === 'guest') { setAvailableLoginMethods( normalizeAvailableLoginMethods( restoredSession.session?.availableLoginMethods, ), ); await resolveGuestFallback(); return; } const nextSession = restoredSession.session; setUser(nextSession.user); setAvailableLoginMethods( normalizeAvailableLoginMethods(nextSession.availableLoginMethods), ); setStatus( nextSession.user.bindingStatus === 'pending_bind_phone' ? 'pending_bind_phone' : 'ready', ); setError(callbackResult?.error ?? ''); } catch { if (!isCurrentHydrate()) { return; } await resolveGuestFallback(); } }; void hydrate(++authHydrateVersionRef.current); const handleAuthStateChange = () => { setStatus('checking'); void hydrate(++authHydrateVersionRef.current); }; window.addEventListener(AUTH_STATE_EVENT, handleAuthStateChange); return () => { isActive = false; window.removeEventListener(AUTH_STATE_EVENT, handleAuthStateChange); }; }, [restoreAuthSession]); useEffect(() => { if (!readyUser) { setShowSettingsModal(false); return; } setShowLoginModal(false); const pendingAction = pendingProtectedActionRef.current; pendingProtectedActionRef.current = null; pendingAction?.(); }, [readyUser]); useEffect(() => { if (!showSettingsModal || status !== 'ready') { return; } let isActive = true; setLoadingRiskBlocks(true); setLoadingSessions(true); setLoadingAuditLogs(true); void getAuthRiskBlocks() .then((nextBlocks) => { if (!isActive) { return; } setRiskBlocks(nextBlocks); }) .catch((blockError) => { if (!isActive) { return; } setError( blockError instanceof Error ? blockError.message : '读取安全状态失败,请稍后再试。', ); }) .finally(() => { if (!isActive) { return; } setLoadingRiskBlocks(false); }); void getAuthSessions() .then((nextSessions) => { if (!isActive) { return; } setSessions(nextSessions); }) .catch((sessionError) => { if (!isActive) { return; } setError( sessionError instanceof Error ? sessionError.message : '读取登录设备失败,请稍后再试。', ); }) .finally(() => { if (!isActive) { return; } setLoadingSessions(false); }); void getAuthAuditLogs() .then((nextLogs) => { if (!isActive) { return; } setAuditLogs(nextLogs); }) .catch((auditError) => { if (!isActive) { return; } setError( auditError instanceof Error ? auditError.message : '读取账号操作记录失败,请稍后再试。', ); }) .finally(() => { if (!isActive) { return; } setLoadingAuditLogs(false); }); return () => { isActive = false; }; }, [showSettingsModal, status]); const authUiValue = useMemo( () => ({ user: readyUser, // 平台内容在 checking/recovering 阶段可以继续挂载,避免闪烁; // 但受保护请求只能在真实 ready 且存在用户时再启动。 canAccessProtectedData: status === 'ready' && Boolean(readyUser), openLoginModal, requireAuth, openSettingsModal, openAccountModal, setCurrentUser: setUser, logout: logoutCurrentSession, musicVolume: settings.musicVolume, setMusicVolume: settings.setMusicVolume, platformTheme: settings.platformTheme, setPlatformTheme: settings.setPlatformTheme, isHydratingSettings: settings.isHydratingSettings, isPersistingSettings: settings.isPersistingSettings, settingsError: settings.settingsError, }), [ openAccountModal, openLoginModal, openSettingsModal, readyUser, requireAuth, logoutCurrentSession, status, settings.isHydratingSettings, settings.isPersistingSettings, settings.musicVolume, settings.platformTheme, settings.setMusicVolume, settings.setPlatformTheme, settings.settingsError, ], ); if (status === 'checking' && !canKeepPlatformContentMounted) { return (
正在校验登录状态...
); } if (status === 'recovering' && !canKeepPlatformContentMounted) { return (
正在恢复登录状态...
); } if (status === 'pending_bind_phone' && user) { return ( { setSendingCode(true); setError(''); try { const result = await sendPhoneLoginCode( phone, 'bind_phone', captcha, ); setBindCaptchaChallenge(null); return result; } catch (sendError) { const captchaChallenge = getCaptchaChallengeFromError(sendError); if (captchaChallenge) { setBindCaptchaChallenge(captchaChallenge); } setError( sendError instanceof Error ? sendError.message : '发送验证码失败,请稍后再试。', ); throw sendError; } finally { setSendingCode(false); } }} onSubmit={async (phone, code) => { setBindingPhone(true); setError(''); try { const nextUser = await bindWechatPhone(phone, code); setBindCaptchaChallenge(null); activateReadyUser(nextUser); } catch (bindError) { setError( bindError instanceof Error ? bindError.message : '绑定手机号失败,请稍后再试。', ); } finally { setBindingPhone(false); } }} onLogout={async () => { await logoutCurrentSession(); }} /> ); } if ( status !== 'ready' && status !== 'unauthenticated' && !canKeepPlatformContentMounted ) { return (
登录状态异常
{error || '账号恢复失败,请刷新页面后重试。'}
); } return (
{readyUser ? ( { setLoadingRiskBlocks(true); try { setRiskBlocks(await getAuthRiskBlocks()); } catch (blockError) { setError( blockError instanceof Error ? blockError.message : '读取安全状态失败,请稍后再试。', ); } finally { setLoadingRiskBlocks(false); } }} onLiftRiskBlock={async (scopeType) => { try { await liftAuthRiskBlock(scopeType); setRiskBlocks(await getAuthRiskBlocks()); setAuditLogs(await getAuthAuditLogs()); } catch (liftError) { setError( liftError instanceof Error ? liftError.message : '解除保护失败,请稍后再试。', ); } }} onRefreshSessions={async () => { setLoadingSessions(true); try { setSessions(await getAuthSessions()); } catch (sessionError) { setError( sessionError instanceof Error ? sessionError.message : '读取登录设备失败,请稍后再试。', ); } finally { setLoadingSessions(false); } }} onRefreshAuditLogs={async () => { setLoadingAuditLogs(true); try { setAuditLogs(await getAuthAuditLogs()); } catch (auditError) { setError( auditError instanceof Error ? auditError.message : '读取账号操作记录失败,请稍后再试。', ); } finally { setLoadingAuditLogs(false); } }} onRevokeSession={async (session) => { const sessionIds = session.sessionIds.length > 0 ? session.sessionIds : [session.sessionId]; setRevokingSessionIds((current) => Array.from(new Set([...current, session.sessionId])), ); try { await revokeAuthSessions(sessionIds); setSessions(await getAuthSessions()); setAuditLogs(await getAuthAuditLogs()); } catch (revokeError) { setError( revokeError instanceof Error ? revokeError.message : '移除登录设备失败,请稍后再试。', ); } finally { setRevokingSessionIds((current) => current.filter((id) => id !== session.sessionId), ); } }} onLogoutAll={logoutAllSessions} changePhoneCaptchaChallenge={changePhoneCaptchaChallenge} onSendChangePhoneCode={async (phone, captcha) => { try { const result = await sendPhoneLoginCode( phone, 'change_phone', captcha, ); setChangePhoneCaptchaChallenge(null); return result; } catch (sendError) { const captchaChallenge = getCaptchaChallengeFromError(sendError); if (captchaChallenge) { setChangePhoneCaptchaChallenge(captchaChallenge); } throw sendError; } }} onChangePhone={async (phone, code) => { const nextUser = await changePhoneNumber(phone, code); setChangePhoneCaptchaChallenge(null); setUser(nextUser); }} onChangePassword={async (currentPassword, newPassword) => { await changePassword(currentPassword, newPassword); clearLocalAuthenticatedState(); }} /> ) : null} { setSendingCode(true); setError(''); try { const result = await sendPhoneLoginCode(phone, scene, captcha); setLoginCaptchaChallenge(null); return result; } catch (sendError) { const captchaChallenge = getCaptchaChallengeFromError(sendError); if (captchaChallenge) { setLoginCaptchaChallenge(captchaChallenge); } setError( sendError instanceof Error ? sendError.message : '发送验证码失败,请稍后再试。', ); throw sendError; } finally { setSendingCode(false); } }} onPhoneSubmit={async (phone, code) => { setLoggingIn(true); setError(''); try { const response = await loginWithPhoneCode(phone, code); setStoredLastLoginPhone(phone); setLoginCaptchaChallenge(null); setShowRegistrationInviteModal(response.created); setRegistrationInviteError(''); activateReadyUser(response.user); } catch (loginError) { setError( loginError instanceof Error ? loginError.message : '登录失败,请稍后再试。', ); } finally { setLoggingIn(false); } }} onPasswordSubmit={async (phone, password) => { setLoggingIn(true); setError(''); try { const nextUser = await authEntry(phone, password); setStoredLastLoginPhone(phone); activateReadyUser(nextUser); } catch (loginError) { setError( loginError instanceof Error ? loginError.message : '登录失败,请稍后再试。', ); } finally { setLoggingIn(false); } }} onResetPassword={async (phone, code, newPassword) => { setLoggingIn(true); setError(''); try { const nextUser = await resetPassword(phone, code, newPassword); setStoredLastLoginPhone(phone); activateReadyUser(nextUser); } catch (resetError) { setError( resetError instanceof Error ? resetError.message : '重置密码失败,请稍后再试。', ); } finally { setLoggingIn(false); } }} onStartWechatLogin={async () => { setWechatLoading(true); setError(''); try { await startWechatLogin(); } catch (wechatError) { setError( wechatError instanceof Error ? wechatError.message : '微信登录暂不可用,请稍后再试。', ); } finally { setWechatLoading(false); } }} /> { setSubmittingRegistrationInvite(true); setRegistrationInviteError(''); try { await redeemRegistrationInviteCode(inviteCode); closeRegistrationInviteModal(); } catch (inviteError) { setRegistrationInviteError( inviteError instanceof Error ? inviteError.message : '填写邀请码失败,请稍后再试。', ); } finally { setSubmittingRegistrationInvite(false); } }} />
{children}
); }