This commit is contained in:
2026-04-22 22:01:07 +08:00
parent d8716d70b0
commit b317c2a8ea
37 changed files with 1821 additions and 515 deletions

View File

@@ -9,6 +9,7 @@ import { AuthGate } from './AuthGate';
import { useAuthUi } from './AuthUiContext';
const authMocks = vi.hoisted(() => ({
ensureStoredAccessToken: vi.fn(),
ensureAutoAuthUser: vi.fn(),
getAuthLoginOptions: vi.fn(),
getCurrentAuthUser: vi.fn(),
@@ -20,6 +21,7 @@ const authMocks = vi.hoisted(() => ({
vi.mock('../../services/apiClient', () => ({
AUTH_STATE_EVENT: 'genarrative-auth-state-changed',
ensureStoredAccessToken: authMocks.ensureStoredAccessToken,
}));
vi.mock('../../services/authService', () => ({
@@ -76,6 +78,7 @@ const mockUser: AuthUser = {
beforeEach(() => {
vi.clearAllMocks();
authMocks.consumeAuthCallbackResult.mockReturnValue(null);
authMocks.ensureStoredAccessToken.mockResolvedValue('jwt-existing-token');
authMocks.getCurrentAuthUser.mockResolvedValue({
user: null,
availableLoginMethods: ['phone'],
@@ -127,6 +130,33 @@ test('auth gate keeps platform content visible when phone login is available', a
expect(authMocks.ensureAutoAuthUser).not.toHaveBeenCalled();
});
test('auth gate waits for access token refresh before exposing restored user content', async () => {
let resolveToken!: (token: string) => void;
const tokenPromise = new Promise<string>((resolve) => {
resolveToken = resolve;
});
authMocks.ensureStoredAccessToken.mockReturnValue(tokenPromise);
authMocks.getCurrentAuthUser.mockResolvedValue({
user: mockUser,
availableLoginMethods: ['phone'],
});
render(
<AuthGate>
<div></div>
</AuthGate>,
);
expect(screen.getByText('正在校验登录状态...')).toBeTruthy();
expect(authMocks.getCurrentAuthUser).not.toHaveBeenCalled();
resolveToken('jwt-restored-token');
expect(await screen.findByText('应用内容')).toBeTruthy();
expect(authMocks.ensureStoredAccessToken).toHaveBeenCalledTimes(1);
expect(authMocks.getCurrentAuthUser).toHaveBeenCalledTimes(1);
});
test('auth gate does not auto-create a guest account when dev guest switch is not explicitly enabled', async () => {
authMocks.getAuthLoginOptions.mockResolvedValue({
availableLoginMethods: [],
@@ -171,6 +201,7 @@ test('auth gate opens a login modal for protected actions and resumes after logi
'13800000000',
'123456',
);
expect(authMocks.getCurrentAuthUser).toHaveBeenCalledTimes(1);
expect(onAuthenticated).toHaveBeenCalledTimes(1);
});

View File

@@ -10,6 +10,7 @@ import {
import { useGameSettings } from '../../hooks/useGameSettings';
import {
AUTH_STATE_EVENT,
ensureStoredAccessToken,
} from '../../services/apiClient';
import {
type AuthAuditLogEntry,
@@ -89,10 +90,15 @@ export function AuthGate({ children }: AuthGateProps) {
const [changePhoneCaptchaChallenge, setChangePhoneCaptchaChallenge] =
useState<AuthCaptchaChallenge | null>(null);
const pendingProtectedActionRef = useRef<(() => void) | null>(null);
const settings = useGameSettings(user?.id ?? null);
const readyUser = status === 'ready' ? user : null;
const settings = useGameSettings(readyUser?.id ?? null);
const platformThemeClass = `platform-theme--${settings.platformTheme}`;
const readyUser = status === 'ready' ? user : null;
const activateReadyUser = useCallback((nextUser: AuthUser) => {
// 受保护业务 hook 只在 readyUser 暴露后启动,必须先保证请求层能带 Bearer token。
setUser(nextUser);
setStatus('ready');
}, []);
const closeLoginModal = useCallback(() => {
pendingProtectedActionRef.current = null;
@@ -154,8 +160,8 @@ export function AuthGate({ children }: AuthGateProps) {
return;
}
setUser(nextUser);
setStatus('ready');
await ensureStoredAccessToken();
activateReadyUser(nextUser);
setError('');
} catch (autoAuthError) {
if (!isActive) {
@@ -229,6 +235,7 @@ export function AuthGate({ children }: AuthGateProps) {
}
try {
await ensureStoredAccessToken();
const nextSession = await getCurrentAuthUser();
if (!isActive) {
return;
@@ -270,7 +277,7 @@ export function AuthGate({ children }: AuthGateProps) {
isActive = false;
window.removeEventListener(AUTH_STATE_EVENT, handleAuthStateChange);
};
}, []);
}, [activateReadyUser]);
useEffect(() => {
if (!readyUser) {
@@ -458,8 +465,7 @@ export function AuthGate({ children }: AuthGateProps) {
try {
const nextUser = await bindWechatPhone(phone, code);
setBindCaptchaChallenge(null);
setUser(nextUser);
setStatus('ready');
activateReadyUser(nextUser);
} catch (bindError) {
setError(
bindError instanceof Error
@@ -665,8 +671,7 @@ export function AuthGate({ children }: AuthGateProps) {
try {
const nextUser = await loginWithPhoneCode(phone, code);
setLoginCaptchaChallenge(null);
setUser(nextUser);
setStatus('ready');
activateReadyUser(nextUser);
} catch (loginError) {
setError(
loginError instanceof Error

View File

@@ -151,7 +151,7 @@ export function LoginScreen({
});
setCooldownSeconds(result.cooldownSeconds);
setHint(
`验证码已发送,有效期约 ${Math.max(1, Math.round(result.expiresInSeconds / 60))} 分钟。`,
`短信请求已提交,请留意手机短信。验证码有效期约 ${Math.max(1, Math.round(result.expiresInSeconds / 60))} 分钟。`,
);
setCaptchaAnswer('');
} catch {