init with react+axum+spacetimedb
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
2026-04-26 18:06:23 +08:00
commit cbc27bad4a
20199 changed files with 883714 additions and 0 deletions

View File

@@ -0,0 +1,763 @@
import {
type ReactNode,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { useGameSettings } from '../../hooks/useGameSettings';
import {
AUTH_STATE_EVENT,
ensureStoredAccessToken,
} from '../../services/apiClient';
import {
type AuthAuditLogEntry,
type AuthCaptchaChallenge,
authEntry,
type AuthLoginMethod,
type AuthRiskBlockSummary,
type AuthSessionSummary,
type AuthUser,
bindWechatPhone,
changePassword,
changePhoneNumber,
consumeAuthCallbackResult,
getAuthAuditLogs,
getAuthLoginOptions,
getAuthRiskBlocks,
getAuthSessions,
getCaptchaChallengeFromError,
getCurrentAuthUser,
liftAuthRiskBlock,
loginWithPhoneCode,
logoutAllAuthSessions,
logoutAuthUser,
resetPassword,
revokeAuthSession,
sendPhoneLoginCode,
setStoredLastLoginPhone,
startWechatLogin,
} from '../../services/authService';
import { AccountModal } from './AccountModal';
import { AuthUiContext, type PlatformSettingsSection } from './AuthUiContext';
import { BindPhoneScreen } from './BindPhoneScreen';
import { LoginScreen } from './LoginScreen';
type AuthGateProps = {
children: ReactNode;
};
type AuthStatus =
| 'checking'
| 'recovering'
| 'unauthenticated'
| 'pending_bind_phone'
| 'ready'
| 'error';
export function AuthGate({ children }: AuthGateProps) {
const [status, setStatus] = useState<AuthStatus>('checking');
const [user, setUser] = useState<AuthUser | null>(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 [showSettingsModal, setShowSettingsModal] = useState(false);
const [initialSettingsSection, setInitialSettingsSection] =
useState<PlatformSettingsSection | null>(null);
const [sessions, setSessions] = useState<AuthSessionSummary[]>([]);
const [loadingSessions, setLoadingSessions] = useState(false);
const [auditLogs, setAuditLogs] = useState<AuthAuditLogEntry[]>([]);
const [loadingAuditLogs, setLoadingAuditLogs] = useState(false);
const [riskBlocks, setRiskBlocks] = useState<AuthRiskBlockSummary[]>([]);
const [loadingRiskBlocks, setLoadingRiskBlocks] = useState(false);
const [loginCaptchaChallenge, setLoginCaptchaChallenge] =
useState<AuthCaptchaChallenge | null>(null);
const [bindCaptchaChallenge, setBindCaptchaChallenge] =
useState<AuthCaptchaChallenge | null>(null);
const [changePhoneCaptchaChallenge, setChangePhoneCaptchaChallenge] =
useState<AuthCaptchaChallenge | null>(null);
const pendingProtectedActionRef = useRef<(() => void) | null>(null);
const hasRenderedPlatformContentRef = useRef(false);
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。
setUser(nextUser);
setStatus('ready');
}, []);
const clearLocalAuthenticatedState = useCallback(() => {
// 退出动作必须先收回前端鉴权上下文,再等待后端吊销完成。
// 否则平台壳层会在无刷新状态下继续暴露旧用户的私有作品缓存。
pendingProtectedActionRef.current = null;
setUser(null);
setStatus('unauthenticated');
setShowLoginModal(false);
setShowSettingsModal(false);
setInitialSettingsSection(null);
setSessions([]);
setAuditLogs([]);
setRiskBlocks([]);
setLoginCaptchaChallenge(null);
setBindCaptchaChallenge(null);
setChangePhoneCaptchaChallenge(null);
setError('');
}, []);
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 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) {
setInitialSettingsSection(section ?? null);
setShowSettingsModal(true);
return;
}
openLoginModal();
},
[openLoginModal, readyUser],
);
const openAccountModal = useCallback(() => {
openSettingsModal('account');
}, [openSettingsModal]);
useEffect(() => {
let isActive = true;
const hydrate = async () => {
const loadLoginOptions = async () => {
const options = await getAuthLoginOptions();
if (!isActive) {
return null;
}
setAvailableLoginMethods(options.availableLoginMethods);
return options;
};
const resolveGuestFallback = async () => {
try {
const options = await loadLoginOptions();
if (!isActive) {
return;
}
setUser(null);
setStatus('unauthenticated');
} catch (optionsError) {
if (!isActive) {
return;
}
setAvailableLoginMethods([]);
setUser(null);
setError(
optionsError instanceof Error
? optionsError.message
: '读取登录方式失败,请稍后再试。',
);
setStatus('unauthenticated');
}
};
const callbackResult = consumeAuthCallbackResult();
if (callbackResult?.error && isActive) {
setError(callbackResult.error);
setShowLoginModal(true);
}
try {
await ensureStoredAccessToken();
const nextSession = await getCurrentAuthUser();
if (!isActive) {
return;
}
if (!nextSession.user) {
setAvailableLoginMethods(nextSession.availableLoginMethods);
await resolveGuestFallback();
return;
}
setUser(nextSession.user);
setAvailableLoginMethods(nextSession.availableLoginMethods);
setStatus(
nextSession.user.bindingStatus === 'pending_bind_phone'
? 'pending_bind_phone'
: 'ready',
);
setError(callbackResult?.error ?? '');
} catch {
if (!isActive) {
return;
}
await resolveGuestFallback();
}
};
void hydrate();
const handleAuthStateChange = () => {
setStatus('checking');
void hydrate();
};
window.addEventListener(AUTH_STATE_EVENT, handleAuthStateChange);
return () => {
isActive = false;
window.removeEventListener(AUTH_STATE_EVENT, handleAuthStateChange);
};
}, [activateReadyUser]);
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,
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 (
<div
className={`platform-theme ${platformThemeClass} flex min-h-screen items-center justify-center bg-[var(--platform-body-fill)] text-sm text-[var(--platform-text-base)]`}
>
...
</div>
);
}
if (status === 'recovering' && !canKeepPlatformContentMounted) {
return (
<div
className={`platform-theme ${platformThemeClass} flex min-h-screen items-center justify-center bg-[var(--platform-body-fill)] text-sm text-[var(--platform-text-base)]`}
>
...
</div>
);
}
if (status === 'pending_bind_phone' && user) {
return (
<BindPhoneScreen
user={user}
platformTheme={settings.platformTheme}
sendingCode={sendingCode}
binding={bindingPhone}
error={error}
captchaChallenge={bindCaptchaChallenge}
onSendCode={async (phone, captcha) => {
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 (
<div
className={`platform-theme ${platformThemeClass} flex min-h-screen items-center justify-center bg-[var(--platform-body-fill)] px-6 text-[var(--platform-text-base)]`}
>
<div className="platform-auth-card max-w-md rounded-3xl px-6 py-7 text-center">
<div className="text-base font-medium text-[var(--platform-text-strong)]">
</div>
<div className="mt-3 text-sm leading-6 text-[var(--platform-text-base)]">
{error || '账号恢复失败,请刷新页面后重试。'}
</div>
<button
type="button"
className="platform-button platform-button--primary mt-5"
onClick={() => {
window.location.reload();
}}
>
</button>
</div>
</div>
);
}
return (
<AuthUiContext.Provider value={authUiValue}>
<div className="relative">
<div className={`platform-theme ${platformThemeClass}`}>
{readyUser ? (
<AccountModal
user={readyUser}
isOpen={showSettingsModal}
initialSection={initialSettingsSection}
platformTheme={settings.platformTheme}
riskBlocks={riskBlocks}
sessions={sessions}
auditLogs={auditLogs}
loadingRiskBlocks={loadingRiskBlocks}
loadingSessions={loadingSessions}
loadingAuditLogs={loadingAuditLogs}
isHydratingSettings={settings.isHydratingSettings}
isPersistingSettings={settings.isPersistingSettings}
settingsError={settings.settingsError}
onClose={() => setShowSettingsModal(false)}
onPlatformThemeChange={settings.setPlatformTheme}
onLogout={logoutCurrentSession}
onRefreshRiskBlocks={async () => {
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 (sessionId) => {
try {
await revokeAuthSession(sessionId);
setSessions((current) =>
current.filter(
(session) => session.sessionId !== sessionId,
),
);
setAuditLogs(await getAuthAuditLogs());
} catch (revokeError) {
setError(
revokeError instanceof Error
? revokeError.message
: '移除登录设备失败,请稍后再试。',
);
}
}}
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) => {
const nextUser = await changePassword(
currentPassword,
newPassword,
);
setUser(nextUser);
}}
/>
) : null}
<LoginScreen
isOpen={showLoginModal}
platformTheme={settings.platformTheme}
availableLoginMethods={availableLoginMethods}
sendingCode={sendingCode}
loggingIn={loggingIn}
wechatLoading={wechatLoading}
error={error}
captchaChallenge={loginCaptchaChallenge}
onClose={closeLoginModal}
onSendCode={async (phone, scene, captcha) => {
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 nextUser = await loginWithPhoneCode(phone, code);
setStoredLastLoginPhone(phone);
setLoginCaptchaChallenge(null);
activateReadyUser(nextUser);
} 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);
}
}}
/>
</div>
{children}
</div>
</AuthUiContext.Provider>
);
}