修复报错
This commit is contained in:
@@ -72,6 +72,7 @@ test('auth gate renders app content after spacetime auth session is ready', asyn
|
||||
authMocks.getCurrentAuthUser.mockResolvedValue({
|
||||
user: activeUser,
|
||||
availableLoginMethods: ['phone'],
|
||||
recoveryNotice: null,
|
||||
});
|
||||
|
||||
render(
|
||||
@@ -90,6 +91,7 @@ test('auth gate opens phone verification modal for pending sms verification user
|
||||
bindingStatus: 'pending_bind_phone',
|
||||
},
|
||||
availableLoginMethods: ['phone'],
|
||||
recoveryNotice: null,
|
||||
});
|
||||
|
||||
render(
|
||||
@@ -100,3 +102,23 @@ test('auth gate opens phone verification modal for pending sms verification user
|
||||
|
||||
expect(await screen.findByText('完成短信验证')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('auth gate shows login expired notice after anonymous fallback recovery', async () => {
|
||||
authMocks.getCurrentAuthUser.mockResolvedValue({
|
||||
user: activeUser,
|
||||
availableLoginMethods: ['phone'],
|
||||
recoveryNotice: {
|
||||
code: 'login_expired',
|
||||
message: '登录已过期,已切换为匿名账号。',
|
||||
},
|
||||
});
|
||||
|
||||
render(
|
||||
<AuthGate>
|
||||
<div>应用内容</div>
|
||||
</AuthGate>,
|
||||
);
|
||||
|
||||
expect(await screen.findByText('应用内容')).toBeTruthy();
|
||||
expect(await screen.findByText('登录已过期,已切换为匿名账号。')).toBeTruthy();
|
||||
});
|
||||
|
||||
@@ -1,13 +1,6 @@
|
||||
import { type ReactNode, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import {
|
||||
SPACETIME_KICK_EVENT,
|
||||
SPACETIME_SESSION_REVOKED_EVENT,
|
||||
SPACETIME_VERIFICATION_REQUIRED_EVENT,
|
||||
type KickEventDetail,
|
||||
type SessionRevokedDetail,
|
||||
type VerificationRequiredDetail,
|
||||
} from '../../spacetime/client';
|
||||
import { AUTH_STATE_EVENT } from '../../services/apiClient';
|
||||
import {
|
||||
type AuthAuditLogEntry,
|
||||
type AuthCaptchaChallenge,
|
||||
@@ -26,7 +19,14 @@ import {
|
||||
revokeAuthSession,
|
||||
sendPhoneLoginCode,
|
||||
} from '../../services/authService';
|
||||
import { AUTH_STATE_EVENT } from '../../services/apiClient';
|
||||
import {
|
||||
type KickEventDetail,
|
||||
type SessionRevokedDetail,
|
||||
SPACETIME_KICK_EVENT,
|
||||
SPACETIME_SESSION_REVOKED_EVENT,
|
||||
SPACETIME_VERIFICATION_REQUIRED_EVENT,
|
||||
type VerificationRequiredDetail,
|
||||
} from '../../spacetime/client';
|
||||
import { AccountModal } from './AccountModal';
|
||||
import { AuthUiContext } from './AuthUiContext';
|
||||
import { PhoneVerificationModal } from './PhoneVerificationModal';
|
||||
@@ -41,6 +41,7 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
const [status, setStatus] = useState<AuthStatus>('checking');
|
||||
const [user, setUser] = useState<AuthUser | null>(null);
|
||||
const [error, setError] = useState('');
|
||||
const [notice, setNotice] = useState('');
|
||||
const [sendingCode, setSendingCode] = useState(false);
|
||||
const [verifyingPhone, setVerifyingPhone] = useState(false);
|
||||
const [showAccountModal, setShowAccountModal] = useState(false);
|
||||
@@ -80,11 +81,13 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
setUser(nextSession.user);
|
||||
setStatus('ready');
|
||||
setError('');
|
||||
if (nextSession.user.bindingStatus === 'pending_bind_phone') {
|
||||
setNotice(nextSession.recoveryNotice?.message ?? '');
|
||||
const nextUser = nextSession.user;
|
||||
if (nextUser.bindingStatus === 'pending_bind_phone') {
|
||||
setShowVerificationModal(true);
|
||||
setVerificationPrompt((current) =>
|
||||
current ?? {
|
||||
phoneNumberMasked: nextSession.user.phoneNumberMasked,
|
||||
phoneNumberMasked: nextUser.phoneNumberMasked,
|
||||
title: '完成短信验证',
|
||||
detail: '验证手机号后,才能继续进行需要服务端状态写入的操作。',
|
||||
},
|
||||
@@ -97,6 +100,7 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
|
||||
setUser(null);
|
||||
setStatus('error');
|
||||
setNotice('');
|
||||
setError(
|
||||
hydrateError instanceof Error
|
||||
? hydrateError.message
|
||||
@@ -166,6 +170,20 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
};
|
||||
}, [user?.phoneNumberMasked]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!notice) {
|
||||
return;
|
||||
}
|
||||
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
setNotice('');
|
||||
}, 5000);
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(timeoutId);
|
||||
};
|
||||
}, [notice]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showAccountModal || status !== 'ready') {
|
||||
return;
|
||||
@@ -290,6 +308,26 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
return (
|
||||
<AuthUiContext.Provider value={authUiValue}>
|
||||
<div className="relative">
|
||||
{notice ? (
|
||||
<div
|
||||
className="pointer-events-none fixed inset-x-0 top-3 z-[60] flex justify-center px-4"
|
||||
style={{
|
||||
paddingTop: 'calc(env(safe-area-inset-top, 0px) + 0.25rem)',
|
||||
}}
|
||||
>
|
||||
<div className="pointer-events-auto flex w-full max-w-md items-start gap-3 rounded-2xl border border-amber-300/30 bg-[rgba(84,48,12,0.92)] px-4 py-3 text-sm text-amber-50 shadow-[0_18px_50px_rgba(0,0,0,0.35)] backdrop-blur">
|
||||
<div className="min-w-0 flex-1 leading-5">{notice}</div>
|
||||
<button
|
||||
type="button"
|
||||
className="shrink-0 rounded-full border border-amber-100/20 px-3 py-1 text-[11px] text-amber-50 transition hover:border-amber-100/40 hover:bg-white/8"
|
||||
onClick={() => setNotice('')}
|
||||
>
|
||||
知道了
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{showGlobalAccountActions ? (
|
||||
<div className="pointer-events-none fixed right-3 top-3 z-50 flex justify-end">
|
||||
<div className="pointer-events-auto flex items-center gap-2 rounded-full border border-white/10 bg-black/45 px-3 py-2 text-xs text-zinc-200 backdrop-blur">
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { ApiClientError, clearStoredAccessToken, clearStoredAutoAuthCredentials } from './apiClient';
|
||||
import {
|
||||
ApiClientError,
|
||||
clearStoredAccessToken,
|
||||
clearStoredAutoAuthCredentials,
|
||||
setStoredAccessToken,
|
||||
} from './apiClient';
|
||||
import {
|
||||
createAutoAuthCredentials,
|
||||
getCaptchaChallengeFromError,
|
||||
getAuthRiskBlocks,
|
||||
getAuthSessions,
|
||||
getCaptchaChallengeFromError,
|
||||
getCurrentAuthUser,
|
||||
liftAuthRiskBlock,
|
||||
loginWithPhoneCode,
|
||||
@@ -240,6 +245,43 @@ describe('authService with SpacetimeDB', () => {
|
||||
expect(session.user?.displayName).toBe('游客阿青');
|
||||
expect(session.user?.loginMethod).toBe('phone');
|
||||
expect(session.availableLoginMethods).toEqual(['phone', 'wechat']);
|
||||
expect(session.recoveryNotice).toBeNull();
|
||||
});
|
||||
|
||||
it('falls back to anonymous auth when stored token connection stalls', async () => {
|
||||
vi.useFakeTimers();
|
||||
setStoredAccessToken('expired-token', { emit: false });
|
||||
|
||||
const stalledConnection = new Promise<never>(() => {});
|
||||
spacetimeMocks.ensureSpacetimeConnection
|
||||
.mockImplementationOnce(() => stalledConnection)
|
||||
.mockResolvedValueOnce(
|
||||
createConnection({
|
||||
authRows: [
|
||||
createAuthStateRow({
|
||||
displayName: '匿名玩家',
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
try {
|
||||
const sessionPromise = getCurrentAuthUser();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(3500);
|
||||
|
||||
const session = await sessionPromise;
|
||||
|
||||
expect(session.user?.displayName).toBe('匿名玩家');
|
||||
expect(session.recoveryNotice).toEqual({
|
||||
code: 'login_expired',
|
||||
message: '登录已过期,已切换为匿名账号。',
|
||||
});
|
||||
expect(spacetimeMocks.disconnectSpacetimeConnection).toHaveBeenCalled();
|
||||
expect(window.localStorage.getItem('genarrative.auth.access-token.v1')).toBeNull();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('sends phone login code through spacetime procedure', async () => {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import type {
|
||||
AuthAuditLogEntry,
|
||||
AuthCaptchaChallenge,
|
||||
AuthLiftRiskBlockResponse,
|
||||
AuthLoginMethod,
|
||||
AuthLoginOptionsResponse,
|
||||
AuthLiftRiskBlockResponse,
|
||||
AuthLogoutAllResponse,
|
||||
AuthPhoneChangeResponse,
|
||||
AuthPhoneLoginResponse,
|
||||
@@ -14,17 +14,16 @@ import type {
|
||||
AuthWechatBindPhoneResponse,
|
||||
LogoutResponse,
|
||||
} from '../../packages/shared/src/contracts/auth';
|
||||
import {
|
||||
ApiClientError,
|
||||
clearStoredAccessToken,
|
||||
clearStoredAutoAuthCredentials,
|
||||
getStoredAccessToken,
|
||||
} from './apiClient';
|
||||
import {
|
||||
disconnectSpacetimeConnection,
|
||||
ensureSpacetimeConnection,
|
||||
getCurrentSpacetimeSessionId,
|
||||
} from '../spacetime/client';
|
||||
import type {
|
||||
ClientAppConfigView,
|
||||
RequestMeta,
|
||||
SmsAuthScene,
|
||||
} from '../spacetime/generated/types';
|
||||
import {
|
||||
mapAuditLogEntry,
|
||||
mapAuthRiskBlock,
|
||||
@@ -32,11 +31,12 @@ import {
|
||||
mapAuthUser,
|
||||
mapAvailableLoginMethods,
|
||||
} from '../spacetime/mappers';
|
||||
import type {
|
||||
ClientAppConfigView,
|
||||
RequestMeta,
|
||||
SmsAuthScene,
|
||||
} from '../spacetime/generated/types';
|
||||
import {
|
||||
ApiClientError,
|
||||
clearStoredAccessToken,
|
||||
clearStoredAutoAuthCredentials,
|
||||
getStoredAccessToken,
|
||||
} from './apiClient';
|
||||
|
||||
export type { AuthUser } from '../../packages/shared/src/contracts/auth';
|
||||
export type { AuthLoginMethod } from '../../packages/shared/src/contracts/auth';
|
||||
@@ -49,6 +49,10 @@ export type AutoAuthCredentials = {
|
||||
export type AuthSessionSnapshot = {
|
||||
user: import('../../packages/shared/src/contracts/auth').AuthUser | null;
|
||||
availableLoginMethods: AuthLoginMethod[];
|
||||
recoveryNotice: {
|
||||
code: 'login_expired';
|
||||
message: string;
|
||||
} | null;
|
||||
};
|
||||
export type { AuthSessionSummary };
|
||||
export type { AuthCaptchaChallenge };
|
||||
@@ -66,6 +70,8 @@ let pendingAutoAuthUser: Promise<{
|
||||
credentials: AutoAuthCredentials;
|
||||
}> | null = null;
|
||||
|
||||
const TOKEN_RECOVERY_TIMEOUT_MS = 3500;
|
||||
|
||||
function buildRandomSegment(length: number) {
|
||||
const alphabet = 'abcdefghijklmnopqrstuvwxyz0123456789';
|
||||
const cryptoApi = globalThis.crypto;
|
||||
@@ -101,8 +107,10 @@ function buildRequestMeta(): RequestMeta {
|
||||
return {
|
||||
clientType: 'web',
|
||||
userAgent:
|
||||
typeof navigator !== 'undefined' ? navigator.userAgent.trim() || null : null,
|
||||
ip: null,
|
||||
typeof navigator !== 'undefined'
|
||||
? navigator.userAgent.trim() || undefined
|
||||
: undefined,
|
||||
ip: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -121,6 +129,12 @@ function mapSmsScene(
|
||||
|
||||
async function readCurrentSessionFromConnection() {
|
||||
const connection = await ensureSpacetimeConnection();
|
||||
return readCurrentSessionSnapshot(connection);
|
||||
}
|
||||
|
||||
function readCurrentSessionSnapshot(
|
||||
connection: Awaited<ReturnType<typeof ensureSpacetimeConnection>>,
|
||||
) {
|
||||
const authRow = getSingleRow(connection.db.my_auth_state.iter());
|
||||
const configRow = getSingleRow(
|
||||
connection.db.client_app_config.iter(),
|
||||
@@ -129,20 +143,64 @@ async function readCurrentSessionFromConnection() {
|
||||
return {
|
||||
user: authRow ? mapAuthUser(authRow) : null,
|
||||
availableLoginMethods: mapAvailableLoginMethods(configRow),
|
||||
} satisfies AuthSessionSnapshot;
|
||||
} satisfies Omit<AuthSessionSnapshot, 'recoveryNotice'>;
|
||||
}
|
||||
|
||||
async function waitForConnectionWithTimeout(timeoutMs: number) {
|
||||
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
|
||||
return ensureSpacetimeConnection();
|
||||
}
|
||||
|
||||
const connectionPromise = ensureSpacetimeConnection();
|
||||
|
||||
return Promise.race([
|
||||
connectionPromise,
|
||||
new Promise<never>((_, reject) => {
|
||||
const timeoutId = globalThis.setTimeout(() => {
|
||||
reject(new Error('账号连接超时,请稍后重试'));
|
||||
}, timeoutMs);
|
||||
|
||||
void connectionPromise.finally(() => {
|
||||
globalThis.clearTimeout(timeoutId);
|
||||
});
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
async function readCurrentSessionWithConnectionTimeout(timeoutMs: number | null) {
|
||||
const connection =
|
||||
typeof timeoutMs === 'number'
|
||||
? await waitForConnectionWithTimeout(timeoutMs)
|
||||
: await ensureSpacetimeConnection();
|
||||
return readCurrentSessionSnapshot(connection);
|
||||
}
|
||||
|
||||
async function readCurrentSessionWithRetry() {
|
||||
const hasStoredToken = Boolean(getStoredAccessToken());
|
||||
|
||||
try {
|
||||
return await readCurrentSessionFromConnection();
|
||||
const session = await readCurrentSessionWithConnectionTimeout(
|
||||
hasStoredToken ? TOKEN_RECOVERY_TIMEOUT_MS : null,
|
||||
);
|
||||
return {
|
||||
...session,
|
||||
recoveryNotice: null,
|
||||
} satisfies AuthSessionSnapshot;
|
||||
} catch (error) {
|
||||
if (!getStoredAccessToken()) {
|
||||
if (!hasStoredToken) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
disconnectSpacetimeConnection();
|
||||
clearStoredAccessToken({ emit: false });
|
||||
return readCurrentSessionFromConnection();
|
||||
const session = await readCurrentSessionFromConnection();
|
||||
return {
|
||||
...session,
|
||||
recoveryNotice: {
|
||||
code: 'login_expired',
|
||||
message: '登录已过期,已切换为匿名账号。',
|
||||
},
|
||||
} satisfies AuthSessionSnapshot;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,9 @@ import type { Identity } from 'spacetimedb';
|
||||
import { AUTH_STATE_EVENT, clearStoredAccessToken, getStoredAccessToken, setStoredAccessToken } from '../services/apiClient';
|
||||
import { DbConnection } from './generated';
|
||||
|
||||
const DEFAULT_SPACETIME_URI = 'wss://maincloud.spacetimedb.com';
|
||||
const DEFAULT_SPACETIME_DATABASE_NAME = 'xushi-p4wfr';
|
||||
|
||||
export const SPACETIME_VERIFICATION_REQUIRED_EVENT =
|
||||
'genarrative-spacetime-verification-required';
|
||||
export const SPACETIME_KICK_EVENT = 'genarrative-spacetime-kick';
|
||||
@@ -52,7 +55,7 @@ function emitAuthStateChange() {
|
||||
function normalizeSpacetimeUri(rawValue: string) {
|
||||
const trimmed = rawValue.trim();
|
||||
if (!trimmed) {
|
||||
return 'ws://127.0.0.1:3000';
|
||||
return DEFAULT_SPACETIME_URI;
|
||||
}
|
||||
|
||||
if (trimmed.startsWith('ws://') || trimmed.startsWith('wss://')) {
|
||||
@@ -72,13 +75,14 @@ function normalizeSpacetimeUri(rawValue: string) {
|
||||
|
||||
function resolveSpacetimeUri() {
|
||||
return normalizeSpacetimeUri(
|
||||
import.meta.env.VITE_SPACETIME_URI?.trim() || 'ws://127.0.0.1:3000',
|
||||
import.meta.env.VITE_SPACETIME_URI?.trim() || DEFAULT_SPACETIME_URI,
|
||||
);
|
||||
}
|
||||
|
||||
function resolveDatabaseName() {
|
||||
return (
|
||||
import.meta.env.VITE_SPACETIME_DATABASE_NAME?.trim() || 'xushi-p4wfr'
|
||||
import.meta.env.VITE_SPACETIME_DATABASE_NAME?.trim() ||
|
||||
DEFAULT_SPACETIME_DATABASE_NAME
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user