1
This commit is contained in:
@@ -13,6 +13,7 @@ const authMocks = vi.hoisted(() => ({
|
||||
authEntry: vi.fn(),
|
||||
changePassword: vi.fn(),
|
||||
ensureStoredAccessToken: vi.fn(),
|
||||
getStoredAccessToken: vi.fn(),
|
||||
refreshStoredAccessToken: vi.fn(),
|
||||
getAuthLoginOptions: vi.fn(),
|
||||
getCurrentAuthUser: vi.fn(),
|
||||
@@ -29,6 +30,7 @@ const authMocks = vi.hoisted(() => ({
|
||||
vi.mock('../../services/apiClient', () => ({
|
||||
AUTH_STATE_EVENT: 'genarrative-auth-state-changed',
|
||||
ensureStoredAccessToken: authMocks.ensureStoredAccessToken,
|
||||
getStoredAccessToken: authMocks.getStoredAccessToken,
|
||||
refreshStoredAccessToken: authMocks.refreshStoredAccessToken,
|
||||
}));
|
||||
|
||||
@@ -96,6 +98,7 @@ beforeEach(() => {
|
||||
window.history.replaceState(null, '', '/');
|
||||
authMocks.consumeAuthCallbackResult.mockReturnValue(null);
|
||||
authMocks.ensureStoredAccessToken.mockResolvedValue('jwt-existing-token');
|
||||
authMocks.getStoredAccessToken.mockReturnValue('');
|
||||
authMocks.refreshStoredAccessToken.mockResolvedValue('jwt-refreshed-token');
|
||||
authMocks.getCurrentAuthUser.mockResolvedValue({
|
||||
user: null,
|
||||
@@ -231,10 +234,37 @@ test('auth gate waits for refresh cookie rotation before exposing restored user
|
||||
|
||||
expect(await screen.findByText('应用内容')).toBeTruthy();
|
||||
expect(authMocks.refreshStoredAccessToken).toHaveBeenCalledTimes(1);
|
||||
expect(authMocks.refreshStoredAccessToken).toHaveBeenCalledWith({
|
||||
clearOnFailure: true,
|
||||
});
|
||||
expect(authMocks.ensureStoredAccessToken).not.toHaveBeenCalled();
|
||||
expect(authMocks.getCurrentAuthUser).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('auth gate keeps a valid local token login when refresh rotation fails after reload', async () => {
|
||||
authMocks.getStoredAccessToken.mockReturnValue('jwt-existing-token');
|
||||
authMocks.refreshStoredAccessToken.mockRejectedValue(
|
||||
new Error('refresh cookie 失效'),
|
||||
);
|
||||
authMocks.getCurrentAuthUser.mockResolvedValue({
|
||||
user: mockUser,
|
||||
availableLoginMethods: ['phone'],
|
||||
});
|
||||
|
||||
render(
|
||||
<AuthGate>
|
||||
<LogoutStateProbe />
|
||||
</AuthGate>,
|
||||
);
|
||||
|
||||
expect(await screen.findByText('当前用户:测试玩家')).toBeTruthy();
|
||||
expect(screen.getByText('私有数据:可读取')).toBeTruthy();
|
||||
expect(authMocks.refreshStoredAccessToken).toHaveBeenCalledWith({
|
||||
clearOnFailure: false,
|
||||
});
|
||||
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: [],
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
import { useGameSettings } from '../../hooks/useGameSettings';
|
||||
import {
|
||||
AUTH_STATE_EVENT,
|
||||
getStoredAccessToken,
|
||||
refreshStoredAccessToken,
|
||||
} from '../../services/apiClient';
|
||||
import {
|
||||
@@ -18,6 +19,7 @@ import {
|
||||
authEntry,
|
||||
type AuthLoginMethod,
|
||||
type AuthRiskBlockSummary,
|
||||
type AuthSessionSnapshot,
|
||||
type AuthSessionSummary,
|
||||
type AuthUser,
|
||||
bindWechatPhone,
|
||||
@@ -81,6 +83,18 @@ function normalizeAvailableLoginMethods(
|
||||
: FALLBACK_LOGIN_METHODS;
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -163,6 +177,56 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
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 {
|
||||
@@ -316,26 +380,21 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
}
|
||||
|
||||
try {
|
||||
// 中文注释:打开已登录页面也要主动轮换 refresh cookie。
|
||||
// 后端只在 refresh/session 成功续期时写每日登录埋点;如果本地 access token 尚未过期,
|
||||
// 仅调用 /auth/me 不会进入续期链路,导致“打开网页”没有登录埋点。
|
||||
await refreshStoredAccessToken();
|
||||
const restoredSession = await restoreAuthSession();
|
||||
if (!isCurrentHydrate()) {
|
||||
return;
|
||||
}
|
||||
const nextSession = await getCurrentAuthUser();
|
||||
if (!isCurrentHydrate()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!nextSession.user) {
|
||||
if (restoredSession.kind === 'guest') {
|
||||
setAvailableLoginMethods(
|
||||
normalizeAvailableLoginMethods(nextSession.availableLoginMethods),
|
||||
normalizeAvailableLoginMethods(
|
||||
restoredSession.session?.availableLoginMethods,
|
||||
),
|
||||
);
|
||||
await resolveGuestFallback();
|
||||
return;
|
||||
}
|
||||
|
||||
const nextSession = restoredSession.session;
|
||||
setUser(nextSession.user);
|
||||
setAvailableLoginMethods(
|
||||
normalizeAvailableLoginMethods(nextSession.availableLoginMethods),
|
||||
@@ -368,7 +427,7 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
isActive = false;
|
||||
window.removeEventListener(AUTH_STATE_EVENT, handleAuthStateChange);
|
||||
};
|
||||
}, [activateReadyUser]);
|
||||
}, [restoreAuthSession]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!readyUser) {
|
||||
|
||||
Reference in New Issue
Block a user