Allow local env files to reliably override authentication feature flags (SMS/WeChat) by whitelisting keys in scripts/dev-utils.mjs and adding a unit test. Add SMS checks to scripts/check-api-server-env.mjs. Make server config.parse_bool tolerant of shell-wrapped quoted values (e.g. '"true"') and add tests so SMS_AUTH_ENABLED is parsed correctly when shells supply quotes. Update docs to clarify SMS env behaviour, restart requirements, and add guidance + a CSS fallback for old mobile browsers (QQ/X5) so public cover images render even when aspect-ratio is unsupported. Also include related frontend test and component adjustments and add puzzle onboarding handlers/endpoints in server-rs/crates/api-server/src/puzzle.rs.
948 lines
30 KiB
TypeScript
948 lines
30 KiB
TypeScript
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<AuthLoginMethod>([
|
||
...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<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 [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<PlatformSettingsSection | null>(null);
|
||
const [sessions, setSessions] = useState<AuthSessionSummary[]>([]);
|
||
const [loadingSessions, setLoadingSessions] = useState(false);
|
||
const [revokingSessionIds, setRevokingSessionIds] = useState<string[]>([]);
|
||
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 autoOpenedInviteCodeRef = useRef<string | null>(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 (
|
||
<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}
|
||
entryMode={settingsEntryMode}
|
||
initialSection={initialSettingsSection}
|
||
platformTheme={settings.platformTheme}
|
||
riskBlocks={riskBlocks}
|
||
sessions={sessions}
|
||
auditLogs={auditLogs}
|
||
loadingRiskBlocks={loadingRiskBlocks}
|
||
loadingSessions={loadingSessions}
|
||
loadingAuditLogs={loadingAuditLogs}
|
||
revokingSessionIds={revokingSessionIds}
|
||
isHydratingSettings={settings.isHydratingSettings}
|
||
isPersistingSettings={settings.isPersistingSettings}
|
||
settingsError={settings.settingsError}
|
||
onClose={closeSettingsModal}
|
||
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 (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}
|
||
<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 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);
|
||
}
|
||
}}
|
||
/>
|
||
<RegistrationInviteModal
|
||
isOpen={showRegistrationInviteModal}
|
||
platformTheme={settings.platformTheme}
|
||
initialInviteCode={pendingInviteCode}
|
||
submitting={submittingRegistrationInvite}
|
||
error={registrationInviteError}
|
||
onClose={closeRegistrationInviteModal}
|
||
onSubmit={async (inviteCode) => {
|
||
setSubmittingRegistrationInvite(true);
|
||
setRegistrationInviteError('');
|
||
try {
|
||
await redeemRegistrationInviteCode(inviteCode);
|
||
closeRegistrationInviteModal();
|
||
} catch (inviteError) {
|
||
setRegistrationInviteError(
|
||
inviteError instanceof Error
|
||
? inviteError.message
|
||
: '填写邀请码失败,请稍后再试。',
|
||
);
|
||
} finally {
|
||
setSubmittingRegistrationInvite(false);
|
||
}
|
||
}}
|
||
/>
|
||
</div>
|
||
{children}
|
||
</div>
|
||
</AuthUiContext.Provider>
|
||
);
|
||
}
|