fix(auth): tighten refresh session revocation
This commit is contained in:
@@ -31,6 +31,8 @@ function renderAccountModal(overrides?: {
|
||||
riskBlocks?: AuthRiskBlockSummary[];
|
||||
sessions?: AuthSessionSummary[];
|
||||
auditLogs?: AuthAuditLogEntry[];
|
||||
onRevokeSession?: (session: AuthSessionSummary) => Promise<void>;
|
||||
revokingSessionIds?: string[];
|
||||
initialSection?:
|
||||
| 'appearance'
|
||||
| 'account'
|
||||
@@ -63,7 +65,10 @@ function renderAccountModal(overrides?: {
|
||||
onRefreshSessions={vi.fn().mockResolvedValue(undefined)}
|
||||
onLogoutAll={vi.fn().mockResolvedValue(undefined)}
|
||||
onRefreshAuditLogs={vi.fn().mockResolvedValue(undefined)}
|
||||
onRevokeSession={vi.fn().mockResolvedValue(undefined)}
|
||||
onRevokeSession={
|
||||
overrides?.onRevokeSession ?? vi.fn().mockResolvedValue(undefined)
|
||||
}
|
||||
revokingSessionIds={overrides?.revokingSessionIds ?? []}
|
||||
changePhoneCaptchaChallenge={null}
|
||||
onSendChangePhoneCode={vi.fn().mockResolvedValue({
|
||||
cooldownSeconds: 60,
|
||||
@@ -75,6 +80,30 @@ function renderAccountModal(overrides?: {
|
||||
);
|
||||
}
|
||||
|
||||
function buildSession(
|
||||
overrides: Partial<AuthSessionSummary> = {},
|
||||
): AuthSessionSummary {
|
||||
return {
|
||||
sessionId: 'usess_1',
|
||||
sessionIds: ['usess_1'],
|
||||
sessionCount: 1,
|
||||
clientType: 'web_browser',
|
||||
clientRuntime: 'chrome',
|
||||
clientPlatform: 'windows',
|
||||
clientLabel: 'Windows / Chrome',
|
||||
deviceDisplayName: 'Windows / Chrome',
|
||||
miniProgramAppId: null,
|
||||
miniProgramEnv: null,
|
||||
userAgent: 'Mozilla/5.0',
|
||||
ipMasked: '203.0.*.*',
|
||||
isCurrent: false,
|
||||
createdAt: '2026-05-01T10:00:00.000Z',
|
||||
lastSeenAt: '2026-05-01T10:30:00.000Z',
|
||||
expiresAt: '2026-06-01T10:30:00.000Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test('settings header uses a generic title instead of the phone number', () => {
|
||||
renderAccountModal();
|
||||
|
||||
@@ -238,8 +267,10 @@ test('account panel includes merged security devices and audit sections', async
|
||||
},
|
||||
],
|
||||
sessions: [
|
||||
{
|
||||
buildSession({
|
||||
sessionId: 'session-1',
|
||||
sessionIds: ['session-1'],
|
||||
sessionCount: 1,
|
||||
clientType: 'mobile',
|
||||
clientRuntime: 'ios',
|
||||
clientPlatform: 'wechat',
|
||||
@@ -253,7 +284,7 @@ test('account panel includes merged security devices and audit sections', async
|
||||
lastSeenAt: '2026-04-20T09:00:00.000Z',
|
||||
expiresAt: '2026-04-27T09:00:00.000Z',
|
||||
ipMasked: '10.0.*.*',
|
||||
},
|
||||
}),
|
||||
],
|
||||
auditLogs: [
|
||||
{
|
||||
@@ -294,3 +325,77 @@ test('legacy nested section requests now open the merged account panel', () => {
|
||||
expect(within(accountDialog).getByText('登录设备')).toBeTruthy();
|
||||
expect(within(accountDialog).getByText('操作记录')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('current merged session group hides kick action and shows count', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
renderAccountModal({
|
||||
sessions: [
|
||||
buildSession({
|
||||
sessionId: 'usess_current',
|
||||
sessionIds: ['usess_current', 'usess_rotated'],
|
||||
sessionCount: 2,
|
||||
isCurrent: true,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /账号信息/ }));
|
||||
|
||||
const accountDialog = screen.getByRole('dialog', { name: '账号信息' });
|
||||
expect(within(accountDialog).getByText('2 个会话')).toBeTruthy();
|
||||
expect(
|
||||
within(accountDialog).queryByRole('button', { name: '踢下线' }),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
test('remote merged session group can be revoked with loading state', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onRevokeSession = vi.fn().mockResolvedValue(undefined);
|
||||
const remoteSession = buildSession({
|
||||
sessionId: 'usess_remote',
|
||||
sessionIds: ['usess_remote', 'usess_remote_rotated'],
|
||||
sessionCount: 2,
|
||||
});
|
||||
|
||||
renderAccountModal({
|
||||
sessions: [remoteSession],
|
||||
onRevokeSession,
|
||||
revokingSessionIds: ['usess_remote'],
|
||||
});
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /账号信息/ }));
|
||||
|
||||
const accountDialog = screen.getByRole('dialog', { name: '账号信息' });
|
||||
const revokeButton = within(accountDialog).getByRole('button', {
|
||||
name: '处理中...',
|
||||
}) as HTMLButtonElement;
|
||||
expect(revokeButton.disabled).toBe(true);
|
||||
expect(within(accountDialog).getByText('2 个会话')).toBeTruthy();
|
||||
expect(onRevokeSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('remote session revoke passes the grouped session payload', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onRevokeSession = vi.fn().mockResolvedValue(undefined);
|
||||
const remoteSession = buildSession({
|
||||
sessionId: 'usess_remote',
|
||||
sessionIds: ['usess_remote', 'usess_remote_rotated'],
|
||||
sessionCount: 2,
|
||||
});
|
||||
|
||||
renderAccountModal({
|
||||
sessions: [remoteSession],
|
||||
onRevokeSession,
|
||||
});
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /账号信息/ }));
|
||||
await user.click(
|
||||
within(screen.getByRole('dialog', { name: '账号信息' })).getByRole(
|
||||
'button',
|
||||
{ name: '踢下线' },
|
||||
),
|
||||
);
|
||||
|
||||
expect(onRevokeSession).toHaveBeenCalledWith(remoteSession);
|
||||
});
|
||||
|
||||
@@ -40,7 +40,8 @@ type AccountModalProps = {
|
||||
onRefreshSessions: () => Promise<void>;
|
||||
onLogoutAll: () => Promise<void>;
|
||||
onRefreshAuditLogs: () => Promise<void>;
|
||||
onRevokeSession: (sessionId: string) => Promise<void>;
|
||||
onRevokeSession: (session: AuthSessionSummary) => Promise<void>;
|
||||
revokingSessionIds: string[];
|
||||
changePhoneCaptchaChallenge: AuthCaptchaChallenge | null;
|
||||
onSendChangePhoneCode: (
|
||||
phone: string,
|
||||
@@ -298,6 +299,7 @@ export function AccountModal({
|
||||
onLogoutAll,
|
||||
onRefreshAuditLogs,
|
||||
onRevokeSession,
|
||||
revokingSessionIds,
|
||||
changePhoneCaptchaChallenge,
|
||||
onSendChangePhoneCode,
|
||||
onChangePhone,
|
||||
@@ -759,41 +761,55 @@ export function AccountModal({
|
||||
正在读取当前登录设备...
|
||||
</div>
|
||||
) : sessions.length > 0 ? (
|
||||
sessions.map((session) => (
|
||||
<div
|
||||
key={session.sessionId}
|
||||
className="rounded-2xl border border-[var(--platform-subpanel-border)] px-4 py-3 text-sm text-[var(--platform-text-base)]"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span>{session.clientLabel}</span>
|
||||
<span className="platform-pill platform-pill--success px-2.5 py-1 text-[10px]">
|
||||
{session.isCurrent ? '当前设备' : '已登录'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 text-xs leading-5 text-[var(--platform-text-soft)]">
|
||||
最近活跃:{formatSessionTime(session.lastSeenAt)}
|
||||
</div>
|
||||
<div className="text-xs leading-5 text-[var(--platform-text-soft)]">
|
||||
到期时间:{formatSessionTime(session.expiresAt)}
|
||||
</div>
|
||||
{session.ipMasked ? (
|
||||
<div className="text-xs leading-5 text-[var(--platform-text-soft)]">
|
||||
IP:{session.ipMasked}
|
||||
sessions.map((session) => {
|
||||
const isRevoking = revokingSessionIds.includes(
|
||||
session.sessionId,
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={session.sessionId}
|
||||
className="rounded-2xl border border-[var(--platform-subpanel-border)] px-4 py-3 text-sm text-[var(--platform-text-base)]"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span>{session.clientLabel}</span>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{session.sessionCount > 1 ? (
|
||||
<span className="platform-pill platform-pill--neutral px-2.5 py-1 text-[10px]">
|
||||
{session.sessionCount} 个会话
|
||||
</span>
|
||||
) : null}
|
||||
<span className="platform-pill platform-pill--success px-2.5 py-1 text-[10px]">
|
||||
{session.isCurrent ? '当前设备' : '已登录'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{!session.isCurrent ? (
|
||||
<button
|
||||
type="button"
|
||||
className="platform-button platform-button--danger mt-3 min-h-0 h-9 px-3 text-xs"
|
||||
onClick={() => {
|
||||
void onRevokeSession(session.sessionId);
|
||||
}}
|
||||
>
|
||||
踢下线
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
))
|
||||
<div className="mt-2 text-xs leading-5 text-[var(--platform-text-soft)]">
|
||||
最近活跃:{formatSessionTime(session.lastSeenAt)}
|
||||
</div>
|
||||
<div className="text-xs leading-5 text-[var(--platform-text-soft)]">
|
||||
到期时间:{formatSessionTime(session.expiresAt)}
|
||||
</div>
|
||||
{session.ipMasked ? (
|
||||
<div className="text-xs leading-5 text-[var(--platform-text-soft)]">
|
||||
IP:{session.ipMasked}
|
||||
</div>
|
||||
) : null}
|
||||
{!session.isCurrent ? (
|
||||
<button
|
||||
type="button"
|
||||
className="platform-button platform-button--danger mt-3 h-9 min-h-0 px-3 text-xs"
|
||||
disabled={isRevoking}
|
||||
onClick={() => {
|
||||
void onRevokeSession(session);
|
||||
}}
|
||||
>
|
||||
{isRevoking ? '处理中...' : '踢下线'}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div className="rounded-2xl border border-[var(--platform-subpanel-border)] px-4 py-3 text-sm text-[var(--platform-text-soft)]">
|
||||
暂无可展示的登录设备。
|
||||
|
||||
@@ -5,7 +5,7 @@ import userEvent from '@testing-library/user-event';
|
||||
import { useState } from 'react';
|
||||
import { beforeEach, expect, test, vi } from 'vitest';
|
||||
|
||||
import type { AuthUser } from '../../services/authService';
|
||||
import type { AuthSessionSummary, AuthUser } from '../../services/authService';
|
||||
import { LEGAL_CONSENT_STORAGE_KEY } from '../common/legalDocuments';
|
||||
import { AuthGate } from './AuthGate';
|
||||
import { useAuthUi } from './AuthUiContext';
|
||||
@@ -23,6 +23,10 @@ const authMocks = vi.hoisted(() => ({
|
||||
logoutAuthUser: vi.fn(),
|
||||
redeemRegistrationInviteCode: vi.fn(),
|
||||
resetPassword: vi.fn(),
|
||||
getAuthAuditLogs: vi.fn(),
|
||||
getAuthRiskBlocks: vi.fn(),
|
||||
getAuthSessions: vi.fn(),
|
||||
revokeAuthSessions: vi.fn(),
|
||||
sendPhoneLoginCode: vi.fn(),
|
||||
startWechatLogin: vi.fn(),
|
||||
consumeAuthCallbackResult: vi.fn(),
|
||||
@@ -42,11 +46,11 @@ vi.mock('../../services/authService', () => ({
|
||||
changePhoneNumber: vi.fn(),
|
||||
consumeAuthCallbackResult: authMocks.consumeAuthCallbackResult,
|
||||
getStoredLastLoginPhone: vi.fn(() => ''),
|
||||
getAuthAuditLogs: vi.fn(),
|
||||
getAuthAuditLogs: authMocks.getAuthAuditLogs,
|
||||
getAuthLoginOptions: authMocks.getAuthLoginOptions,
|
||||
getAuthRiskBlocks: vi.fn(),
|
||||
getAuthRiskBlocks: authMocks.getAuthRiskBlocks,
|
||||
getCurrentAuthUser: authMocks.getCurrentAuthUser,
|
||||
getAuthSessions: vi.fn(),
|
||||
getAuthSessions: authMocks.getAuthSessions,
|
||||
getCaptchaChallengeFromError: vi.fn(() => null),
|
||||
liftAuthRiskBlock: vi.fn(),
|
||||
loginWithPhoneCode: authMocks.loginWithPhoneCode,
|
||||
@@ -54,7 +58,7 @@ vi.mock('../../services/authService', () => ({
|
||||
logoutAuthUser: authMocks.logoutAuthUser,
|
||||
redeemRegistrationInviteCode: authMocks.redeemRegistrationInviteCode,
|
||||
resetPassword: authMocks.resetPassword,
|
||||
revokeAuthSession: vi.fn(),
|
||||
revokeAuthSessions: authMocks.revokeAuthSessions,
|
||||
sendPhoneLoginCode: authMocks.sendPhoneLoginCode,
|
||||
setStoredLastLoginPhone: vi.fn(),
|
||||
startWechatLogin: authMocks.startWechatLogin,
|
||||
@@ -73,9 +77,12 @@ vi.mock('../../hooks/useGameSettings', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('./AccountModal', () => ({
|
||||
AccountModal: () => null,
|
||||
}));
|
||||
vi.mock('./AccountModal', async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import('./AccountModal')>('./AccountModal');
|
||||
|
||||
return actual;
|
||||
});
|
||||
|
||||
vi.mock('./BindPhoneScreen', () => ({
|
||||
BindPhoneScreen: () => <div>绑定手机号</div>,
|
||||
@@ -116,6 +123,10 @@ beforeEach(() => {
|
||||
authMocks.changePassword.mockResolvedValue(mockUser);
|
||||
authMocks.logoutAllAuthSessions.mockResolvedValue(undefined);
|
||||
authMocks.logoutAuthUser.mockResolvedValue(undefined);
|
||||
authMocks.getAuthAuditLogs.mockResolvedValue([]);
|
||||
authMocks.getAuthRiskBlocks.mockResolvedValue([]);
|
||||
authMocks.getAuthSessions.mockResolvedValue([]);
|
||||
authMocks.revokeAuthSessions.mockResolvedValue(undefined);
|
||||
authMocks.redeemRegistrationInviteCode.mockResolvedValue({
|
||||
center: {
|
||||
inviteCode: 'SY12345678',
|
||||
@@ -205,6 +216,21 @@ function LogoutStateProbe() {
|
||||
);
|
||||
}
|
||||
|
||||
function AccountPanelProbe() {
|
||||
const authUi = useAuthUi();
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
authUi?.openAccountModal();
|
||||
}}
|
||||
>
|
||||
打开账号面板
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
test('auth gate keeps platform content visible when phone login is available', async () => {
|
||||
authMocks.getAuthLoginOptions.mockResolvedValue({
|
||||
availableLoginMethods: ['phone'],
|
||||
@@ -786,3 +812,101 @@ test('auth gate separates sms and password login by tabs', async () => {
|
||||
expect(authMocks.authEntry).toHaveBeenCalledWith('13800000000', 'passw0rd');
|
||||
});
|
||||
});
|
||||
|
||||
test('auth gate revokes merged session group and refreshes sessions', async () => {
|
||||
const user = userEvent.setup();
|
||||
const initialSessions: AuthSessionSummary[] = [
|
||||
{
|
||||
sessionId: 'usess_remote',
|
||||
sessionIds: ['usess_remote', 'usess_remote_rotated'],
|
||||
sessionCount: 2,
|
||||
clientType: 'web_browser',
|
||||
clientRuntime: 'chrome',
|
||||
clientPlatform: 'windows',
|
||||
clientLabel: 'Windows / Chrome',
|
||||
deviceDisplayName: 'Windows / Chrome',
|
||||
miniProgramAppId: null,
|
||||
miniProgramEnv: null,
|
||||
userAgent: 'Mozilla/5.0',
|
||||
ipMasked: '203.0.*.*',
|
||||
isCurrent: false,
|
||||
createdAt: '2026-05-01T10:00:00.000Z',
|
||||
lastSeenAt: '2026-05-01T10:30:00.000Z',
|
||||
expiresAt: '2026-06-01T10:30:00.000Z',
|
||||
},
|
||||
];
|
||||
authMocks.getCurrentAuthUser.mockResolvedValue({
|
||||
user: mockUser,
|
||||
availableLoginMethods: ['phone'],
|
||||
});
|
||||
authMocks.getAuthSessions
|
||||
.mockResolvedValueOnce(initialSessions)
|
||||
.mockResolvedValueOnce([]);
|
||||
|
||||
render(
|
||||
<AuthGate>
|
||||
<AccountPanelProbe />
|
||||
</AuthGate>,
|
||||
);
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: '打开账号面板' }));
|
||||
const accountDialog = await screen.findByRole('dialog', {
|
||||
name: '账号信息',
|
||||
});
|
||||
await user.click(within(accountDialog).getByRole('button', { name: '踢下线' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(authMocks.revokeAuthSessions).toHaveBeenCalledWith([
|
||||
'usess_remote',
|
||||
'usess_remote_rotated',
|
||||
]);
|
||||
expect(authMocks.getAuthSessions).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
test('auth gate clears account state after password change', async () => {
|
||||
const user = userEvent.setup();
|
||||
authMocks.getCurrentAuthUser.mockResolvedValue({
|
||||
user: mockUser,
|
||||
availableLoginMethods: ['phone'],
|
||||
});
|
||||
authMocks.getAuthSessions.mockResolvedValue([]);
|
||||
authMocks.changePassword.mockResolvedValue(mockUser);
|
||||
|
||||
render(
|
||||
<AuthGate>
|
||||
<div>
|
||||
<LogoutStateProbe />
|
||||
<AccountPanelProbe />
|
||||
</div>
|
||||
</AuthGate>,
|
||||
);
|
||||
|
||||
expect(await screen.findByText('当前用户:测试玩家')).toBeTruthy();
|
||||
await user.click(screen.getByRole('button', { name: '打开账号面板' }));
|
||||
|
||||
const accountDialog = await screen.findByRole('dialog', {
|
||||
name: '账号信息',
|
||||
});
|
||||
await user.click(
|
||||
within(accountDialog).getByRole('button', { name: '修改密码' }),
|
||||
);
|
||||
|
||||
const passwordDialog = await screen.findByRole('dialog', {
|
||||
name: '修改登录密码',
|
||||
});
|
||||
await user.type(within(passwordDialog).getByLabelText('当前密码'), 'oldpass1');
|
||||
await user.type(within(passwordDialog).getByLabelText('新密码'), 'newpass1');
|
||||
await user.click(
|
||||
within(passwordDialog).getByRole('button', { name: '确认修改密码' }),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(authMocks.changePassword).toHaveBeenCalledWith(
|
||||
'oldpass1',
|
||||
'newpass1',
|
||||
);
|
||||
expect(screen.getByText('当前用户:未登录')).toBeTruthy();
|
||||
});
|
||||
expect(screen.queryByRole('dialog', { name: '账号信息' })).toBeNull();
|
||||
});
|
||||
|
||||
@@ -38,7 +38,7 @@ import {
|
||||
logoutAuthUser,
|
||||
redeemRegistrationInviteCode,
|
||||
resetPassword,
|
||||
revokeAuthSession,
|
||||
revokeAuthSessions,
|
||||
sendPhoneLoginCode,
|
||||
setStoredLastLoginPhone,
|
||||
startWechatLogin,
|
||||
@@ -121,6 +121,7 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
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[]>([]);
|
||||
@@ -167,6 +168,7 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
setSettingsEntryMode('settings');
|
||||
setInitialSettingsSection(null);
|
||||
setSessions([]);
|
||||
setRevokingSessionIds([]);
|
||||
setAuditLogs([]);
|
||||
setRiskBlocks([]);
|
||||
setLoginCaptchaChallenge(null);
|
||||
@@ -691,6 +693,7 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
loadingRiskBlocks={loadingRiskBlocks}
|
||||
loadingSessions={loadingSessions}
|
||||
loadingAuditLogs={loadingAuditLogs}
|
||||
revokingSessionIds={revokingSessionIds}
|
||||
isHydratingSettings={settings.isHydratingSettings}
|
||||
isPersistingSettings={settings.isPersistingSettings}
|
||||
settingsError={settings.settingsError}
|
||||
@@ -752,14 +755,17 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
setLoadingAuditLogs(false);
|
||||
}
|
||||
}}
|
||||
onRevokeSession={async (sessionId) => {
|
||||
onRevokeSession={async (session) => {
|
||||
const sessionIds =
|
||||
session.sessionIds.length > 0
|
||||
? session.sessionIds
|
||||
: [session.sessionId];
|
||||
setRevokingSessionIds((current) =>
|
||||
Array.from(new Set([...current, session.sessionId])),
|
||||
);
|
||||
try {
|
||||
await revokeAuthSession(sessionId);
|
||||
setSessions((current) =>
|
||||
current.filter(
|
||||
(session) => session.sessionId !== sessionId,
|
||||
),
|
||||
);
|
||||
await revokeAuthSessions(sessionIds);
|
||||
setSessions(await getAuthSessions());
|
||||
setAuditLogs(await getAuthAuditLogs());
|
||||
} catch (revokeError) {
|
||||
setError(
|
||||
@@ -767,6 +773,10 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
? revokeError.message
|
||||
: '移除登录设备失败,请稍后再试。',
|
||||
);
|
||||
} finally {
|
||||
setRevokingSessionIds((current) =>
|
||||
current.filter((id) => id !== session.sessionId),
|
||||
);
|
||||
}
|
||||
}}
|
||||
onLogoutAll={logoutAllSessions}
|
||||
@@ -795,11 +805,8 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
setUser(nextUser);
|
||||
}}
|
||||
onChangePassword={async (currentPassword, newPassword) => {
|
||||
const nextUser = await changePassword(
|
||||
currentPassword,
|
||||
newPassword,
|
||||
);
|
||||
setUser(nextUser);
|
||||
await changePassword(currentPassword, newPassword);
|
||||
clearLocalAuthenticatedState();
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
authEntry,
|
||||
bindWechatPhone,
|
||||
changePhoneNumber,
|
||||
changePassword,
|
||||
consumeAuthCallbackResult,
|
||||
getAuthAuditLogs,
|
||||
getAuthLoginOptions,
|
||||
@@ -33,6 +34,8 @@ import {
|
||||
loginWithPhoneCode,
|
||||
logoutAllAuthSessions,
|
||||
redeemRegistrationInviteCode,
|
||||
revokeAuthSession,
|
||||
revokeAuthSessions,
|
||||
sendPhoneLoginCode,
|
||||
startWechatLogin,
|
||||
updateAuthProfile,
|
||||
@@ -154,6 +157,44 @@ describe('authService', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('change password clears local auth session after backend success', async () => {
|
||||
window.localStorage.setItem(
|
||||
'genarrative:access-token',
|
||||
'jwt-before-password-change',
|
||||
);
|
||||
apiClientMocks.requestJson.mockResolvedValue({
|
||||
user: {
|
||||
id: 'user_1',
|
||||
publicUserCode: 'SY-00000001',
|
||||
username: 'phone_00000001',
|
||||
displayName: '旅人甲',
|
||||
avatarUrl: null,
|
||||
phoneNumberMasked: '138****8000',
|
||||
loginMethod: 'password',
|
||||
bindingStatus: 'active',
|
||||
wechatBound: false,
|
||||
createdAt: '2026-05-01T00:00:00.000Z',
|
||||
},
|
||||
});
|
||||
|
||||
const user = await changePassword(' old-password ', ' new-password ');
|
||||
|
||||
expect(user.id).toBe('user_1');
|
||||
expect(apiClientMocks.requestJson).toHaveBeenCalledWith(
|
||||
'/api/auth/password/change',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
currentPassword: 'old-password',
|
||||
newPassword: 'new-password',
|
||||
}),
|
||||
}),
|
||||
'修改密码失败',
|
||||
);
|
||||
expect(getStoredAccessToken()).toBe('');
|
||||
expect(apiClientMocks.emitAuthStateChange).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('sends phone login code through the auth endpoint', async () => {
|
||||
apiClientMocks.requestJson.mockResolvedValue({
|
||||
ok: true,
|
||||
@@ -475,8 +516,15 @@ describe('authService', () => {
|
||||
sessions: [
|
||||
{
|
||||
sessionId: 'usess_1',
|
||||
sessionIds: ['usess_1', 'usess_2'],
|
||||
sessionCount: 2,
|
||||
clientType: 'browser',
|
||||
clientRuntime: 'chrome',
|
||||
clientPlatform: 'windows',
|
||||
clientLabel: '网页端浏览器',
|
||||
deviceDisplayName: 'Windows / Chrome',
|
||||
miniProgramAppId: null,
|
||||
miniProgramEnv: null,
|
||||
userAgent: 'Mozilla/5.0',
|
||||
ipMasked: '127.0.*.*',
|
||||
isCurrent: true,
|
||||
@@ -490,6 +538,46 @@ describe('authService', () => {
|
||||
const sessions = await getAuthSessions();
|
||||
|
||||
expect(sessions).toHaveLength(1);
|
||||
expect(sessions[0].sessionIds).toEqual(['usess_1', 'usess_2']);
|
||||
expect(sessions[0].sessionCount).toBe(2);
|
||||
});
|
||||
|
||||
it('revokes a single auth session by backend route', async () => {
|
||||
apiClientMocks.requestJson.mockResolvedValue({ ok: true });
|
||||
|
||||
await revokeAuthSession('usess_1');
|
||||
|
||||
expect(apiClientMocks.requestJson).toHaveBeenCalledWith(
|
||||
'/api/auth/sessions/usess_1/revoke',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
}),
|
||||
'移除登录设备失败',
|
||||
);
|
||||
});
|
||||
|
||||
it('revokes grouped auth sessions once per unique session id', async () => {
|
||||
apiClientMocks.requestJson.mockResolvedValue({ ok: true });
|
||||
|
||||
await revokeAuthSessions([' usess_1 ', 'usess_2', 'usess_1', '']);
|
||||
|
||||
expect(apiClientMocks.requestJson).toHaveBeenCalledTimes(2);
|
||||
expect(apiClientMocks.requestJson).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'/api/auth/sessions/usess_1/revoke',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
}),
|
||||
'移除登录设备失败',
|
||||
);
|
||||
expect(apiClientMocks.requestJson).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'/api/auth/sessions/usess_2/revoke',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
}),
|
||||
'移除登录设备失败',
|
||||
);
|
||||
});
|
||||
|
||||
it('loads recent auth audit logs', async () => {
|
||||
|
||||
@@ -289,6 +289,7 @@ export async function changePassword(
|
||||
'修改密码失败',
|
||||
);
|
||||
|
||||
clearAuthSession();
|
||||
return response.user;
|
||||
}
|
||||
|
||||
@@ -441,6 +442,16 @@ export async function revokeAuthSession(sessionId: string) {
|
||||
);
|
||||
}
|
||||
|
||||
export async function revokeAuthSessions(sessionIds: string[]) {
|
||||
const uniqueSessionIds = Array.from(
|
||||
new Set(sessionIds.map((sessionId) => sessionId.trim()).filter(Boolean)),
|
||||
);
|
||||
|
||||
await Promise.all(
|
||||
uniqueSessionIds.map((sessionId) => revokeAuthSession(sessionId)),
|
||||
);
|
||||
}
|
||||
|
||||
export async function getAuthAuditLogs() {
|
||||
const response = await requestJson<AuthAuditLogsResponse>(
|
||||
'/api/auth/audit-logs',
|
||||
|
||||
Reference in New Issue
Block a user