This commit is contained in:
277
src/components/auth/AccountModal.test.tsx
Normal file
277
src/components/auth/AccountModal.test.tsx
Normal file
@@ -0,0 +1,277 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { render, screen, waitFor, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { expect, test, vi } from 'vitest';
|
||||
|
||||
import type {
|
||||
AuthAuditLogEntry,
|
||||
AuthRiskBlockSummary,
|
||||
AuthSessionSummary,
|
||||
AuthUser,
|
||||
} from '../../services/authService';
|
||||
import { AccountModal } from './AccountModal';
|
||||
|
||||
const baseUser: AuthUser = {
|
||||
id: 'user-1',
|
||||
username: 'tester',
|
||||
displayName: '138****8000',
|
||||
publicUserCode: 'user-tester',
|
||||
phoneNumberMasked: '138****8000',
|
||||
loginMethod: 'phone',
|
||||
bindingStatus: 'active',
|
||||
wechatBound: true,
|
||||
};
|
||||
|
||||
function renderAccountModal(overrides?: {
|
||||
user?: AuthUser;
|
||||
riskBlocks?: AuthRiskBlockSummary[];
|
||||
sessions?: AuthSessionSummary[];
|
||||
auditLogs?: AuthAuditLogEntry[];
|
||||
initialSection?:
|
||||
| 'appearance'
|
||||
| 'account'
|
||||
| 'security'
|
||||
| 'devices'
|
||||
| 'logs'
|
||||
| null;
|
||||
}) {
|
||||
return render(
|
||||
<AccountModal
|
||||
user={overrides?.user ?? baseUser}
|
||||
isOpen
|
||||
initialSection={overrides?.initialSection ?? null}
|
||||
platformTheme="light"
|
||||
riskBlocks={overrides?.riskBlocks ?? []}
|
||||
sessions={overrides?.sessions ?? []}
|
||||
auditLogs={overrides?.auditLogs ?? []}
|
||||
loadingRiskBlocks={false}
|
||||
loadingSessions={false}
|
||||
loadingAuditLogs={false}
|
||||
isHydratingSettings={false}
|
||||
isPersistingSettings={false}
|
||||
settingsError={null}
|
||||
onClose={vi.fn()}
|
||||
onPlatformThemeChange={vi.fn()}
|
||||
onLogout={vi.fn().mockResolvedValue(undefined)}
|
||||
onRefreshRiskBlocks={vi.fn().mockResolvedValue(undefined)}
|
||||
onLiftRiskBlock={vi.fn().mockResolvedValue(undefined)}
|
||||
onRefreshSessions={vi.fn().mockResolvedValue(undefined)}
|
||||
onLogoutAll={vi.fn().mockResolvedValue(undefined)}
|
||||
onRefreshAuditLogs={vi.fn().mockResolvedValue(undefined)}
|
||||
onRevokeSession={vi.fn().mockResolvedValue(undefined)}
|
||||
changePhoneCaptchaChallenge={null}
|
||||
onSendChangePhoneCode={vi.fn().mockResolvedValue({
|
||||
cooldownSeconds: 60,
|
||||
expiresInSeconds: 300,
|
||||
})}
|
||||
onChangePhone={vi.fn().mockResolvedValue(undefined)}
|
||||
onChangePassword={vi.fn().mockResolvedValue(undefined)}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
test('settings header uses a generic title instead of the phone number', () => {
|
||||
renderAccountModal();
|
||||
|
||||
expect(screen.getByRole('dialog', { name: '设置与账号安全' })).toBeTruthy();
|
||||
expect(screen.getByText('设置与账号安全')).toBeTruthy();
|
||||
expect(screen.queryByText('138****8000')).toBeNull();
|
||||
expect(screen.queryByText('选择要管理的内容')).toBeNull();
|
||||
expect(
|
||||
screen.queryByText('主题、账号与设备能力统一在独立面板中管理'),
|
||||
).toBeNull();
|
||||
expect(screen.queryByText(/^安全状态$/)).toBeNull();
|
||||
expect(screen.queryByText(/^登录设备$/)).toBeNull();
|
||||
expect(screen.queryByText(/^操作记录$/)).toBeNull();
|
||||
expect(screen.queryByText('当前账号状态')).toBeNull();
|
||||
expect(screen.queryByText('当前主题')).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: '退出登录' })).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: '退出全部设备' })).toBeNull();
|
||||
});
|
||||
|
||||
test('account actions open in independent panels instead of inline expansion', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
renderAccountModal();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /账号信息/ }));
|
||||
|
||||
const accountDialog = screen.getByRole('dialog', { name: '账号信息' });
|
||||
expect(accountDialog).toBeTruthy();
|
||||
expect(
|
||||
within(accountDialog).getByRole('button', { name: '返回' }),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
within(accountDialog).getByRole('button', { name: '更换手机号' }),
|
||||
).toBeTruthy();
|
||||
expect(screen.queryByLabelText('新手机号')).toBeNull();
|
||||
|
||||
await user.click(
|
||||
within(accountDialog).getByRole('button', { name: '更换手机号' }),
|
||||
);
|
||||
|
||||
const changePhoneDialog = screen.getByRole('dialog', {
|
||||
name: '绑定新手机号',
|
||||
});
|
||||
expect(within(changePhoneDialog).getByLabelText('新手机号')).toBeTruthy();
|
||||
expect(within(changePhoneDialog).getByLabelText('验证码')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('nested settings panels keep back navigation without an extra close action', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
renderAccountModal();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /账号信息/ }));
|
||||
|
||||
const accountDialog = screen.getByRole('dialog', { name: '账号信息' });
|
||||
const accountHeader = accountDialog.firstElementChild as HTMLElement | null;
|
||||
expect(
|
||||
within(accountDialog).getByRole('button', { name: '返回' }),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
accountHeader?.lastElementChild?.textContent?.includes('返回'),
|
||||
).toBe(true);
|
||||
expect(
|
||||
within(accountDialog).queryByRole('button', { name: '关闭' }),
|
||||
).toBeNull();
|
||||
|
||||
await user.click(
|
||||
within(accountDialog).getByRole('button', { name: '更换手机号' }),
|
||||
);
|
||||
|
||||
const changePhoneDialog = screen.getByRole('dialog', {
|
||||
name: '绑定新手机号',
|
||||
});
|
||||
const changePhoneHeader =
|
||||
changePhoneDialog.firstElementChild as HTMLElement | null;
|
||||
expect(
|
||||
within(changePhoneDialog).getByRole('button', { name: '返回' }),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
changePhoneHeader?.lastElementChild?.textContent?.includes('返回'),
|
||||
).toBe(true);
|
||||
expect(
|
||||
within(changePhoneDialog).queryByRole('button', { name: '关闭' }),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
test('settings overlays move focus away from inert triggers and restore it on back', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
renderAccountModal();
|
||||
|
||||
const accountTrigger = screen.getByRole('button', { name: /账号信息/ });
|
||||
expect(document.activeElement).not.toBe(accountTrigger);
|
||||
|
||||
await user.click(accountTrigger);
|
||||
|
||||
const accountDialog = screen.getByRole('dialog', { name: '账号信息' });
|
||||
const accountBackButton = within(accountDialog).getByRole('button', {
|
||||
name: '返回',
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(document.activeElement).toBe(accountBackButton);
|
||||
});
|
||||
|
||||
const changePhoneTrigger = within(accountDialog).getByRole('button', {
|
||||
name: '更换手机号',
|
||||
});
|
||||
await user.click(changePhoneTrigger);
|
||||
|
||||
const changePhoneDialog = screen.getByRole('dialog', {
|
||||
name: '绑定新手机号',
|
||||
});
|
||||
const changePhoneBackButton = within(changePhoneDialog).getByRole('button', {
|
||||
name: '返回',
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(document.activeElement).toBe(changePhoneBackButton);
|
||||
});
|
||||
|
||||
await user.click(changePhoneBackButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(document.activeElement).toBe(changePhoneTrigger);
|
||||
});
|
||||
|
||||
await user.click(accountBackButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(document.activeElement).toBe(accountTrigger);
|
||||
});
|
||||
});
|
||||
|
||||
test('account panel includes merged security devices and audit sections', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
renderAccountModal({
|
||||
riskBlocks: [
|
||||
{
|
||||
scopeType: 'phone',
|
||||
title: '手机号保护',
|
||||
detail: '检测到异常验证行为,已开启保护。',
|
||||
remainingSeconds: 600,
|
||||
expiresAt: '2026-04-20T10:00:00.000Z',
|
||||
},
|
||||
],
|
||||
sessions: [
|
||||
{
|
||||
sessionId: 'session-1',
|
||||
clientType: 'mobile',
|
||||
clientRuntime: 'ios',
|
||||
clientPlatform: 'wechat',
|
||||
clientLabel: 'iPhone 15 Pro',
|
||||
deviceDisplayName: 'iPhone 15 Pro / 微信',
|
||||
miniProgramAppId: null,
|
||||
miniProgramEnv: null,
|
||||
userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X)',
|
||||
isCurrent: true,
|
||||
createdAt: '2026-04-20T07:30:00.000Z',
|
||||
lastSeenAt: '2026-04-20T09:00:00.000Z',
|
||||
expiresAt: '2026-04-27T09:00:00.000Z',
|
||||
ipMasked: '10.0.*.*',
|
||||
},
|
||||
],
|
||||
auditLogs: [
|
||||
{
|
||||
id: 'log-1',
|
||||
eventType: 'phone_login',
|
||||
title: '登录成功',
|
||||
detail: '通过手机号验证码完成登录。',
|
||||
createdAt: '2026-04-20T08:00:00.000Z',
|
||||
ipMasked: '10.0.*.*',
|
||||
userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X)',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /账号信息/ }));
|
||||
|
||||
const accountDialog = screen.getByRole('dialog', { name: '账号信息' });
|
||||
expect(within(accountDialog).getByText('安全状态')).toBeTruthy();
|
||||
expect(within(accountDialog).getByText('登录设备')).toBeTruthy();
|
||||
expect(within(accountDialog).getByText('操作记录')).toBeTruthy();
|
||||
expect(within(accountDialog).getByText('手机号保护')).toBeTruthy();
|
||||
expect(within(accountDialog).getByText('iPhone 15 Pro')).toBeTruthy();
|
||||
expect(within(accountDialog).getByText('登录成功')).toBeTruthy();
|
||||
expect(
|
||||
within(accountDialog).getByRole('button', { name: '退出登录' }),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
within(accountDialog).getByRole('button', { name: '退出全部设备' }),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
test('legacy nested section requests now open the merged account panel', () => {
|
||||
renderAccountModal({ initialSection: 'security' });
|
||||
|
||||
const accountDialog = screen.getByRole('dialog', { name: '账号信息' });
|
||||
expect(accountDialog).toBeTruthy();
|
||||
expect(within(accountDialog).getByText('安全状态')).toBeTruthy();
|
||||
expect(within(accountDialog).getByText('登录设备')).toBeTruthy();
|
||||
expect(within(accountDialog).getByText('操作记录')).toBeTruthy();
|
||||
});
|
||||
1023
src/components/auth/AccountModal.tsx
Normal file
1023
src/components/auth/AccountModal.tsx
Normal file
File diff suppressed because it is too large
Load Diff
472
src/components/auth/AuthGate.test.tsx
Normal file
472
src/components/auth/AuthGate.test.tsx
Normal file
@@ -0,0 +1,472 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { act, render, screen, waitFor, within } from '@testing-library/react';
|
||||
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 { AuthGate } from './AuthGate';
|
||||
import { useAuthUi } from './AuthUiContext';
|
||||
|
||||
const authMocks = vi.hoisted(() => ({
|
||||
authEntry: vi.fn(),
|
||||
changePassword: vi.fn(),
|
||||
ensureStoredAccessToken: vi.fn(),
|
||||
getAuthLoginOptions: vi.fn(),
|
||||
getCurrentAuthUser: vi.fn(),
|
||||
loginWithPhoneCode: vi.fn(),
|
||||
logoutAllAuthSessions: vi.fn(),
|
||||
logoutAuthUser: vi.fn(),
|
||||
resetPassword: vi.fn(),
|
||||
sendPhoneLoginCode: vi.fn(),
|
||||
startWechatLogin: vi.fn(),
|
||||
consumeAuthCallbackResult: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/apiClient', () => ({
|
||||
AUTH_STATE_EVENT: 'genarrative-auth-state-changed',
|
||||
ensureStoredAccessToken: authMocks.ensureStoredAccessToken,
|
||||
}));
|
||||
|
||||
vi.mock('../../services/authService', () => ({
|
||||
authEntry: authMocks.authEntry,
|
||||
bindWechatPhone: vi.fn(),
|
||||
changePassword: authMocks.changePassword,
|
||||
changePhoneNumber: vi.fn(),
|
||||
consumeAuthCallbackResult: authMocks.consumeAuthCallbackResult,
|
||||
getStoredLastLoginPhone: vi.fn(() => ''),
|
||||
getAuthAuditLogs: vi.fn(),
|
||||
getAuthLoginOptions: authMocks.getAuthLoginOptions,
|
||||
getAuthRiskBlocks: vi.fn(),
|
||||
getCurrentAuthUser: authMocks.getCurrentAuthUser,
|
||||
getAuthSessions: vi.fn(),
|
||||
getCaptchaChallengeFromError: vi.fn(() => null),
|
||||
liftAuthRiskBlock: vi.fn(),
|
||||
loginWithPhoneCode: authMocks.loginWithPhoneCode,
|
||||
logoutAllAuthSessions: authMocks.logoutAllAuthSessions,
|
||||
logoutAuthUser: authMocks.logoutAuthUser,
|
||||
resetPassword: authMocks.resetPassword,
|
||||
revokeAuthSession: vi.fn(),
|
||||
sendPhoneLoginCode: authMocks.sendPhoneLoginCode,
|
||||
setStoredLastLoginPhone: vi.fn(),
|
||||
startWechatLogin: authMocks.startWechatLogin,
|
||||
}));
|
||||
|
||||
vi.mock('../../hooks/useGameSettings', () => ({
|
||||
useGameSettings: () => ({
|
||||
musicVolume: 0.42,
|
||||
setMusicVolume: () => {},
|
||||
platformTheme: 'light',
|
||||
setPlatformTheme: () => {},
|
||||
hasHydratedSettings: true,
|
||||
isHydratingSettings: false,
|
||||
isPersistingSettings: false,
|
||||
settingsError: null,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('./AccountModal', () => ({
|
||||
AccountModal: () => null,
|
||||
}));
|
||||
|
||||
vi.mock('./BindPhoneScreen', () => ({
|
||||
BindPhoneScreen: () => <div>绑定手机号</div>,
|
||||
}));
|
||||
|
||||
const mockUser: AuthUser = {
|
||||
id: 'user-1',
|
||||
username: 'tester',
|
||||
displayName: '测试玩家',
|
||||
publicUserCode: 'user-tester',
|
||||
phoneNumberMasked: '138****8000',
|
||||
loginMethod: 'phone',
|
||||
bindingStatus: 'active',
|
||||
wechatBound: false,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
authMocks.consumeAuthCallbackResult.mockReturnValue(null);
|
||||
authMocks.ensureStoredAccessToken.mockResolvedValue('jwt-existing-token');
|
||||
authMocks.getCurrentAuthUser.mockResolvedValue({
|
||||
user: null,
|
||||
availableLoginMethods: ['phone'],
|
||||
});
|
||||
authMocks.loginWithPhoneCode.mockResolvedValue(mockUser);
|
||||
authMocks.authEntry.mockResolvedValue(mockUser);
|
||||
authMocks.changePassword.mockResolvedValue(mockUser);
|
||||
authMocks.logoutAllAuthSessions.mockResolvedValue(undefined);
|
||||
authMocks.logoutAuthUser.mockResolvedValue(undefined);
|
||||
authMocks.resetPassword.mockResolvedValue(mockUser);
|
||||
authMocks.sendPhoneLoginCode.mockResolvedValue({
|
||||
cooldownSeconds: 60,
|
||||
expiresInSeconds: 300,
|
||||
});
|
||||
authMocks.startWechatLogin.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
function ProtectedActionButton({
|
||||
onAuthenticated,
|
||||
}: {
|
||||
onAuthenticated: () => void;
|
||||
}) {
|
||||
const authUi = useAuthUi();
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
authUi?.requireAuth(onAuthenticated);
|
||||
}}
|
||||
>
|
||||
进入作品
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function PlatformTabStateProbe() {
|
||||
const [tab, setTab] = useState<'home' | 'create'>('home');
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div>当前Tab:{tab === 'home' ? '首页' : '创作'}</div>
|
||||
<button type="button" onClick={() => setTab('create')}>
|
||||
创作
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LogoutStateProbe() {
|
||||
const authUi = useAuthUi();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div>当前用户:{authUi?.user?.displayName ?? '未登录'}</div>
|
||||
<div>
|
||||
私有数据:{authUi?.canAccessProtectedData ? '可读取' : '不可读取'}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void authUi?.logout();
|
||||
}}
|
||||
>
|
||||
退出登录
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
test('auth gate keeps platform content visible when phone login is available', async () => {
|
||||
authMocks.getAuthLoginOptions.mockResolvedValue({
|
||||
availableLoginMethods: ['phone'],
|
||||
});
|
||||
|
||||
render(
|
||||
<AuthGate>
|
||||
<div>应用内容</div>
|
||||
</AuthGate>,
|
||||
);
|
||||
|
||||
expect(await screen.findByText('应用内容')).toBeTruthy();
|
||||
expect(screen.queryByRole('button', { name: '登录' })).toBeNull();
|
||||
expect(screen.queryByText('先登录账号,再同步你的冒险进度。')).toBeNull();
|
||||
});
|
||||
|
||||
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: [],
|
||||
});
|
||||
|
||||
render(
|
||||
<AuthGate>
|
||||
<div>应用内容</div>
|
||||
</AuthGate>,
|
||||
);
|
||||
|
||||
expect(await screen.findByText('应用内容')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('auth gate opens a login modal for protected actions and resumes after login', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onAuthenticated = vi.fn();
|
||||
|
||||
authMocks.getAuthLoginOptions.mockResolvedValue({
|
||||
availableLoginMethods: ['phone'],
|
||||
});
|
||||
|
||||
render(
|
||||
<AuthGate>
|
||||
<ProtectedActionButton onAuthenticated={onAuthenticated} />
|
||||
</AuthGate>,
|
||||
);
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: '进入作品' }));
|
||||
|
||||
const dialog = screen.getByRole('dialog', { name: '账号入口' });
|
||||
expect(dialog).toBeTruthy();
|
||||
expect(screen.queryByText('先登录账号,再同步你的冒险进度。')).toBeNull();
|
||||
|
||||
await user.type(within(dialog).getByLabelText('手机号'), '13800000000');
|
||||
await user.type(within(dialog).getByLabelText('验证码'), '123456');
|
||||
await user.click(within(dialog).getByRole('button', { name: '登录' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(authMocks.loginWithPhoneCode).toHaveBeenCalledWith(
|
||||
'13800000000',
|
||||
'123456',
|
||||
);
|
||||
expect(authMocks.getCurrentAuthUser).toHaveBeenCalledTimes(1);
|
||||
expect(onAuthenticated).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
expect(screen.queryByRole('dialog', { name: '账号入口' })).toBeNull();
|
||||
});
|
||||
|
||||
test('auth state refresh keeps mounted platform content and local tab state', async () => {
|
||||
const user = userEvent.setup();
|
||||
authMocks.getCurrentAuthUser.mockResolvedValue({
|
||||
user: mockUser,
|
||||
availableLoginMethods: ['phone'],
|
||||
});
|
||||
|
||||
render(
|
||||
<AuthGate>
|
||||
<PlatformTabStateProbe />
|
||||
</AuthGate>,
|
||||
);
|
||||
|
||||
expect(await screen.findByText('当前Tab:首页')).toBeTruthy();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '创作' }));
|
||||
expect(screen.getByText('当前Tab:创作')).toBeTruthy();
|
||||
|
||||
let resolveToken!: (token: string) => void;
|
||||
const tokenPromise = new Promise<string>((resolve) => {
|
||||
resolveToken = resolve;
|
||||
});
|
||||
authMocks.ensureStoredAccessToken.mockReturnValueOnce(tokenPromise);
|
||||
|
||||
act(() => {
|
||||
window.dispatchEvent(new Event('genarrative-auth-state-changed'));
|
||||
});
|
||||
|
||||
expect(screen.queryByText('正在校验登录状态...')).toBeNull();
|
||||
expect(screen.getByText('当前Tab:创作')).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
resolveToken('jwt-refreshed-token');
|
||||
await tokenPromise;
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(authMocks.getCurrentAuthUser).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
expect(screen.getByText('当前Tab:创作')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('logout withdraws user context before backend request finishes', async () => {
|
||||
const user = userEvent.setup();
|
||||
authMocks.getCurrentAuthUser.mockResolvedValue({
|
||||
user: mockUser,
|
||||
availableLoginMethods: ['phone'],
|
||||
});
|
||||
|
||||
let resolveLogout!: () => void;
|
||||
const logoutPromise = new Promise<void>((resolve) => {
|
||||
resolveLogout = resolve;
|
||||
});
|
||||
authMocks.logoutAuthUser.mockReturnValueOnce(logoutPromise);
|
||||
|
||||
render(
|
||||
<AuthGate>
|
||||
<LogoutStateProbe />
|
||||
</AuthGate>,
|
||||
);
|
||||
|
||||
expect(await screen.findByText('当前用户:测试玩家')).toBeTruthy();
|
||||
expect(screen.getByText('私有数据:可读取')).toBeTruthy();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '退出登录' }));
|
||||
|
||||
expect(await screen.findByText('当前用户:未登录')).toBeTruthy();
|
||||
expect(screen.getByText('私有数据:不可读取')).toBeTruthy();
|
||||
expect(authMocks.logoutAuthUser).toHaveBeenCalledTimes(1);
|
||||
|
||||
await act(async () => {
|
||||
resolveLogout();
|
||||
await logoutPromise;
|
||||
});
|
||||
});
|
||||
|
||||
test('auth gate shows sms send feedback in the login modal', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
authMocks.getAuthLoginOptions.mockResolvedValue({
|
||||
availableLoginMethods: ['phone'],
|
||||
});
|
||||
|
||||
render(
|
||||
<AuthGate>
|
||||
<ProtectedActionButton onAuthenticated={vi.fn()} />
|
||||
</AuthGate>,
|
||||
);
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: '进入作品' }));
|
||||
|
||||
const dialog = screen.getByRole('dialog', { name: '账号入口' });
|
||||
await user.type(within(dialog).getByLabelText('手机号'), '13800000000');
|
||||
await user.click(within(dialog).getByRole('button', { name: '获取验证码' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(authMocks.sendPhoneLoginCode).toHaveBeenCalledWith(
|
||||
'13800000000',
|
||||
'login',
|
||||
{
|
||||
challengeId: undefined,
|
||||
answer: '',
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
expect(
|
||||
within(dialog).getByText('短信请求已提交,验证码有效期约 5 分钟。'),
|
||||
).toBeTruthy();
|
||||
expect(within(dialog).getByRole('button', { name: '60s' })).toBeTruthy();
|
||||
});
|
||||
|
||||
test('login modal resets draft state every time it is reopened', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
authMocks.getAuthLoginOptions.mockResolvedValue({
|
||||
availableLoginMethods: ['phone', 'password'],
|
||||
});
|
||||
|
||||
render(
|
||||
<AuthGate>
|
||||
<ProtectedActionButton onAuthenticated={vi.fn()} />
|
||||
</AuthGate>,
|
||||
);
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: '进入作品' }));
|
||||
|
||||
const firstDialog = screen.getByRole('dialog', { name: '账号入口' });
|
||||
await user.type(within(firstDialog).getByLabelText('手机号'), '13800000000');
|
||||
await user.click(
|
||||
within(firstDialog).getByRole('button', { name: '获取验证码' }),
|
||||
);
|
||||
|
||||
expect(
|
||||
await within(firstDialog).findByText(
|
||||
'短信请求已提交,验证码有效期约 5 分钟。',
|
||||
),
|
||||
).toBeTruthy();
|
||||
await user.type(within(firstDialog).getByLabelText('验证码'), '123456');
|
||||
await user.click(within(firstDialog).getByRole('tab', { name: '密码登录' }));
|
||||
await user.type(within(firstDialog).getByLabelText('密码'), 'passw0rd');
|
||||
await user.click(
|
||||
within(firstDialog).getByRole('button', { name: '忘记密码' }),
|
||||
);
|
||||
|
||||
expect(screen.getByRole('dialog', { name: '重置密码' })).toBeTruthy();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '关闭登录弹窗' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('dialog', { name: '账号入口' })).toBeNull();
|
||||
});
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '进入作品' }));
|
||||
|
||||
const reopenedDialog = screen.getByRole('dialog', { name: '账号入口' });
|
||||
expect(
|
||||
within(reopenedDialog)
|
||||
.getByRole('tab', { name: '短信登录' })
|
||||
.getAttribute('aria-selected'),
|
||||
).toBe('true');
|
||||
expect(
|
||||
(within(reopenedDialog).getByLabelText('手机号') as HTMLInputElement).value,
|
||||
).toBe('');
|
||||
expect(
|
||||
(within(reopenedDialog).getByLabelText('验证码') as HTMLInputElement).value,
|
||||
).toBe('');
|
||||
expect(within(reopenedDialog).queryByLabelText('密码')).toBeNull();
|
||||
expect(
|
||||
within(reopenedDialog).queryByText(
|
||||
'短信请求已提交,验证码有效期约 5 分钟。',
|
||||
),
|
||||
).toBeNull();
|
||||
expect(
|
||||
within(reopenedDialog).getByRole('button', { name: '获取验证码' }),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
test('auth gate separates sms and password login by tabs', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
authMocks.getAuthLoginOptions.mockResolvedValue({
|
||||
availableLoginMethods: ['phone', 'password'],
|
||||
});
|
||||
|
||||
render(
|
||||
<AuthGate>
|
||||
<ProtectedActionButton onAuthenticated={vi.fn()} />
|
||||
</AuthGate>,
|
||||
);
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: '进入作品' }));
|
||||
|
||||
const dialog = screen.getByRole('dialog', { name: '账号入口' });
|
||||
expect(
|
||||
within(dialog)
|
||||
.getByRole('tab', { name: '短信登录' })
|
||||
.getAttribute('aria-selected'),
|
||||
).toBe('true');
|
||||
expect(within(dialog).queryByLabelText('密码')).toBeNull();
|
||||
|
||||
await user.click(within(dialog).getByRole('tab', { name: '密码登录' }));
|
||||
|
||||
expect(
|
||||
within(dialog)
|
||||
.getByRole('tab', { name: '密码登录' })
|
||||
.getAttribute('aria-selected'),
|
||||
).toBe('true');
|
||||
expect(within(dialog).queryByLabelText('验证码')).toBeNull();
|
||||
|
||||
await user.type(within(dialog).getByLabelText('手机号'), '13800000000');
|
||||
await user.type(within(dialog).getByLabelText('密码'), 'passw0rd');
|
||||
await user.click(within(dialog).getByRole('button', { name: '登录' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(authMocks.authEntry).toHaveBeenCalledWith('13800000000', 'passw0rd');
|
||||
});
|
||||
});
|
||||
763
src/components/auth/AuthGate.tsx
Normal file
763
src/components/auth/AuthGate.tsx
Normal file
@@ -0,0 +1,763 @@
|
||||
import {
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import { useGameSettings } from '../../hooks/useGameSettings';
|
||||
import {
|
||||
AUTH_STATE_EVENT,
|
||||
ensureStoredAccessToken,
|
||||
} from '../../services/apiClient';
|
||||
import {
|
||||
type AuthAuditLogEntry,
|
||||
type AuthCaptchaChallenge,
|
||||
authEntry,
|
||||
type AuthLoginMethod,
|
||||
type AuthRiskBlockSummary,
|
||||
type AuthSessionSummary,
|
||||
type AuthUser,
|
||||
bindWechatPhone,
|
||||
changePassword,
|
||||
changePhoneNumber,
|
||||
consumeAuthCallbackResult,
|
||||
getAuthAuditLogs,
|
||||
getAuthLoginOptions,
|
||||
getAuthRiskBlocks,
|
||||
getAuthSessions,
|
||||
getCaptchaChallengeFromError,
|
||||
getCurrentAuthUser,
|
||||
liftAuthRiskBlock,
|
||||
loginWithPhoneCode,
|
||||
logoutAllAuthSessions,
|
||||
logoutAuthUser,
|
||||
resetPassword,
|
||||
revokeAuthSession,
|
||||
sendPhoneLoginCode,
|
||||
setStoredLastLoginPhone,
|
||||
startWechatLogin,
|
||||
} from '../../services/authService';
|
||||
import { AccountModal } from './AccountModal';
|
||||
import { AuthUiContext, type PlatformSettingsSection } from './AuthUiContext';
|
||||
import { BindPhoneScreen } from './BindPhoneScreen';
|
||||
import { LoginScreen } from './LoginScreen';
|
||||
|
||||
type AuthGateProps = {
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
type AuthStatus =
|
||||
| 'checking'
|
||||
| 'recovering'
|
||||
| 'unauthenticated'
|
||||
| 'pending_bind_phone'
|
||||
| 'ready'
|
||||
| 'error';
|
||||
|
||||
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 [showSettingsModal, setShowSettingsModal] = useState(false);
|
||||
const [initialSettingsSection, setInitialSettingsSection] =
|
||||
useState<PlatformSettingsSection | null>(null);
|
||||
const [sessions, setSessions] = useState<AuthSessionSummary[]>([]);
|
||||
const [loadingSessions, setLoadingSessions] = useState(false);
|
||||
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 hasRenderedPlatformContentRef = useRef(false);
|
||||
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。
|
||||
setUser(nextUser);
|
||||
setStatus('ready');
|
||||
}, []);
|
||||
|
||||
const clearLocalAuthenticatedState = useCallback(() => {
|
||||
// 退出动作必须先收回前端鉴权上下文,再等待后端吊销完成。
|
||||
// 否则平台壳层会在无刷新状态下继续暴露旧用户的私有作品缓存。
|
||||
pendingProtectedActionRef.current = null;
|
||||
setUser(null);
|
||||
setStatus('unauthenticated');
|
||||
setShowLoginModal(false);
|
||||
setShowSettingsModal(false);
|
||||
setInitialSettingsSection(null);
|
||||
setSessions([]);
|
||||
setAuditLogs([]);
|
||||
setRiskBlocks([]);
|
||||
setLoginCaptchaChallenge(null);
|
||||
setBindCaptchaChallenge(null);
|
||||
setChangePhoneCaptchaChallenge(null);
|
||||
setError('');
|
||||
}, []);
|
||||
|
||||
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 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) {
|
||||
setInitialSettingsSection(section ?? null);
|
||||
setShowSettingsModal(true);
|
||||
return;
|
||||
}
|
||||
|
||||
openLoginModal();
|
||||
},
|
||||
[openLoginModal, readyUser],
|
||||
);
|
||||
|
||||
const openAccountModal = useCallback(() => {
|
||||
openSettingsModal('account');
|
||||
}, [openSettingsModal]);
|
||||
|
||||
useEffect(() => {
|
||||
let isActive = true;
|
||||
|
||||
const hydrate = async () => {
|
||||
const loadLoginOptions = async () => {
|
||||
const options = await getAuthLoginOptions();
|
||||
if (!isActive) {
|
||||
return null;
|
||||
}
|
||||
|
||||
setAvailableLoginMethods(options.availableLoginMethods);
|
||||
return options;
|
||||
};
|
||||
|
||||
const resolveGuestFallback = async () => {
|
||||
try {
|
||||
const options = await loadLoginOptions();
|
||||
if (!isActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
setUser(null);
|
||||
setStatus('unauthenticated');
|
||||
} catch (optionsError) {
|
||||
if (!isActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
setAvailableLoginMethods([]);
|
||||
setUser(null);
|
||||
setError(
|
||||
optionsError instanceof Error
|
||||
? optionsError.message
|
||||
: '读取登录方式失败,请稍后再试。',
|
||||
);
|
||||
setStatus('unauthenticated');
|
||||
}
|
||||
};
|
||||
|
||||
const callbackResult = consumeAuthCallbackResult();
|
||||
if (callbackResult?.error && isActive) {
|
||||
setError(callbackResult.error);
|
||||
setShowLoginModal(true);
|
||||
}
|
||||
|
||||
try {
|
||||
await ensureStoredAccessToken();
|
||||
const nextSession = await getCurrentAuthUser();
|
||||
if (!isActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!nextSession.user) {
|
||||
setAvailableLoginMethods(nextSession.availableLoginMethods);
|
||||
await resolveGuestFallback();
|
||||
return;
|
||||
}
|
||||
|
||||
setUser(nextSession.user);
|
||||
setAvailableLoginMethods(nextSession.availableLoginMethods);
|
||||
setStatus(
|
||||
nextSession.user.bindingStatus === 'pending_bind_phone'
|
||||
? 'pending_bind_phone'
|
||||
: 'ready',
|
||||
);
|
||||
setError(callbackResult?.error ?? '');
|
||||
} catch {
|
||||
if (!isActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
await resolveGuestFallback();
|
||||
}
|
||||
};
|
||||
|
||||
void hydrate();
|
||||
|
||||
const handleAuthStateChange = () => {
|
||||
setStatus('checking');
|
||||
void hydrate();
|
||||
};
|
||||
|
||||
window.addEventListener(AUTH_STATE_EVENT, handleAuthStateChange);
|
||||
|
||||
return () => {
|
||||
isActive = false;
|
||||
window.removeEventListener(AUTH_STATE_EVENT, handleAuthStateChange);
|
||||
};
|
||||
}, [activateReadyUser]);
|
||||
|
||||
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,
|
||||
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}
|
||||
initialSection={initialSettingsSection}
|
||||
platformTheme={settings.platformTheme}
|
||||
riskBlocks={riskBlocks}
|
||||
sessions={sessions}
|
||||
auditLogs={auditLogs}
|
||||
loadingRiskBlocks={loadingRiskBlocks}
|
||||
loadingSessions={loadingSessions}
|
||||
loadingAuditLogs={loadingAuditLogs}
|
||||
isHydratingSettings={settings.isHydratingSettings}
|
||||
isPersistingSettings={settings.isPersistingSettings}
|
||||
settingsError={settings.settingsError}
|
||||
onClose={() => setShowSettingsModal(false)}
|
||||
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 (sessionId) => {
|
||||
try {
|
||||
await revokeAuthSession(sessionId);
|
||||
setSessions((current) =>
|
||||
current.filter(
|
||||
(session) => session.sessionId !== sessionId,
|
||||
),
|
||||
);
|
||||
setAuditLogs(await getAuthAuditLogs());
|
||||
} catch (revokeError) {
|
||||
setError(
|
||||
revokeError instanceof Error
|
||||
? revokeError.message
|
||||
: '移除登录设备失败,请稍后再试。',
|
||||
);
|
||||
}
|
||||
}}
|
||||
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) => {
|
||||
const nextUser = await changePassword(
|
||||
currentPassword,
|
||||
newPassword,
|
||||
);
|
||||
setUser(nextUser);
|
||||
}}
|
||||
/>
|
||||
) : 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 nextUser = await loginWithPhoneCode(phone, code);
|
||||
setStoredLastLoginPhone(phone);
|
||||
setLoginCaptchaChallenge(null);
|
||||
activateReadyUser(nextUser);
|
||||
} 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);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
</AuthUiContext.Provider>
|
||||
);
|
||||
}
|
||||
34
src/components/auth/AuthUiContext.ts
Normal file
34
src/components/auth/AuthUiContext.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { createContext, useContext } from 'react';
|
||||
|
||||
import type { PlatformTheme } from '../../../packages/shared/src/contracts/runtime';
|
||||
import type { AuthUser } from '../../services/authService';
|
||||
|
||||
export type PlatformSettingsSection =
|
||||
| 'appearance'
|
||||
| 'account'
|
||||
| 'security'
|
||||
| 'devices'
|
||||
| 'logs';
|
||||
|
||||
type AuthUiContextValue = {
|
||||
user: AuthUser | null;
|
||||
canAccessProtectedData: boolean;
|
||||
openLoginModal: (postLoginAction?: (() => void) | null) => void;
|
||||
requireAuth: (action: () => void) => void;
|
||||
openSettingsModal: (section?: PlatformSettingsSection) => void;
|
||||
openAccountModal: () => void;
|
||||
logout: () => Promise<void>;
|
||||
musicVolume: number;
|
||||
setMusicVolume: (value: number) => void;
|
||||
platformTheme: PlatformTheme;
|
||||
setPlatformTheme: (theme: PlatformTheme) => void;
|
||||
isHydratingSettings: boolean;
|
||||
isPersistingSettings: boolean;
|
||||
settingsError: string | null;
|
||||
};
|
||||
|
||||
export const AuthUiContext = createContext<AuthUiContextValue | null>(null);
|
||||
|
||||
export function useAuthUi() {
|
||||
return useContext(AuthUiContext);
|
||||
}
|
||||
182
src/components/auth/BindPhoneScreen.tsx
Normal file
182
src/components/auth/BindPhoneScreen.tsx
Normal file
@@ -0,0 +1,182 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import type { PlatformTheme } from '../../../packages/shared/src/contracts/runtime';
|
||||
import type { AuthCaptchaChallenge, AuthUser } from '../../services/authService';
|
||||
import { CaptchaChallengeField } from './CaptchaChallengeField';
|
||||
|
||||
type BindPhoneScreenProps = {
|
||||
user: AuthUser;
|
||||
platformTheme: PlatformTheme;
|
||||
sendingCode: boolean;
|
||||
binding: boolean;
|
||||
error: string;
|
||||
captchaChallenge: AuthCaptchaChallenge | null;
|
||||
onSendCode: (
|
||||
phone: string,
|
||||
captcha?: {
|
||||
challengeId?: string;
|
||||
answer?: string;
|
||||
},
|
||||
) => Promise<{
|
||||
cooldownSeconds: number;
|
||||
expiresInSeconds: number;
|
||||
}>;
|
||||
onSubmit: (phone: string, code: string) => Promise<void>;
|
||||
onLogout: () => Promise<void>;
|
||||
};
|
||||
|
||||
export function BindPhoneScreen({
|
||||
user,
|
||||
platformTheme,
|
||||
sendingCode,
|
||||
binding,
|
||||
error,
|
||||
captchaChallenge,
|
||||
onSendCode,
|
||||
onSubmit,
|
||||
onLogout,
|
||||
}: BindPhoneScreenProps) {
|
||||
const [phone, setPhone] = useState('');
|
||||
const [code, setCode] = useState('');
|
||||
const [captchaAnswer, setCaptchaAnswer] = useState('');
|
||||
const [cooldownSeconds, setCooldownSeconds] = useState(0);
|
||||
const [hint, setHint] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (cooldownSeconds <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
setCooldownSeconds((current) => Math.max(0, current - 1));
|
||||
}, 1000);
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(timeoutId);
|
||||
};
|
||||
}, [cooldownSeconds]);
|
||||
|
||||
return (
|
||||
<div className={`platform-theme platform-theme--${platformTheme} min-h-screen bg-[var(--platform-body-fill)] px-4 py-6 text-[var(--platform-text-strong)] sm:py-8`}>
|
||||
<div className="mx-auto flex min-h-[calc(100vh-3rem)] w-full max-w-5xl items-center justify-center sm:min-h-[calc(100vh-4rem)]">
|
||||
<div className="platform-auth-card grid w-full max-w-4xl overflow-hidden rounded-[28px] md:grid-cols-[1.05fr_0.95fr]">
|
||||
<div className="border-b border-[var(--platform-subpanel-border)] bg-[linear-gradient(135deg,rgba(255,79,139,0.18),rgba(255,155,120,0.14))] px-6 py-8 md:border-b-0 md:border-r md:px-10 md:py-12">
|
||||
<div className="selection-hero-brand selection-hero-brand--left">
|
||||
<div className="selection-hero-brand__title">叙世</div>
|
||||
<div className="selection-hero-brand__subtitle">视觉叙事 RPG</div>
|
||||
</div>
|
||||
<p className="mt-8 text-[11px] font-semibold tracking-[0.32em] text-[var(--platform-cool-text)]">
|
||||
账号激活
|
||||
</p>
|
||||
<h1 className="mt-3 text-3xl font-semibold tracking-tight text-[var(--platform-text-strong)] md:text-4xl">
|
||||
绑定手机号
|
||||
</h1>
|
||||
<p className="mt-4 max-w-md text-sm leading-7 text-[var(--platform-text-base)]">
|
||||
微信身份已建立,还差最后一步。绑定手机号后,你的账号才会正式激活,并同步到后端存档体系。
|
||||
</p>
|
||||
<div className="platform-subpanel mt-8 rounded-2xl px-4 py-4 text-sm text-[var(--platform-text-base)]">
|
||||
当前登录身份:{user.displayName}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form
|
||||
className="flex flex-col justify-center gap-5 px-6 py-8 md:px-10 md:py-12"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void onSubmit(phone, code);
|
||||
}}
|
||||
>
|
||||
<label className="grid gap-2 text-sm text-[var(--platform-text-base)]">
|
||||
<span>手机号</span>
|
||||
<input
|
||||
className="platform-input"
|
||||
autoComplete="tel"
|
||||
inputMode="numeric"
|
||||
value={phone}
|
||||
onChange={(event) => setPhone(event.target.value)}
|
||||
placeholder="13800000000"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="grid gap-2 text-sm text-[var(--platform-text-base)]">
|
||||
<span>验证码</span>
|
||||
<div className="flex gap-3">
|
||||
<input
|
||||
className="platform-input min-w-0 flex-1"
|
||||
inputMode="numeric"
|
||||
value={code}
|
||||
onChange={(event) => setCode(event.target.value)}
|
||||
placeholder="输入验证码"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={sendingCode || cooldownSeconds > 0 || !phone.trim()}
|
||||
className="platform-button platform-button--secondary h-12 shrink-0 px-4 text-sm disabled:cursor-not-allowed disabled:opacity-55"
|
||||
onClick={() => {
|
||||
void (async () => {
|
||||
try {
|
||||
const result = await onSendCode(phone, {
|
||||
challengeId: captchaChallenge?.challengeId,
|
||||
answer: captchaAnswer,
|
||||
});
|
||||
setCooldownSeconds(result.cooldownSeconds);
|
||||
setHint(
|
||||
`验证码已发送,有效期约 ${Math.max(1, Math.round(result.expiresInSeconds / 60))} 分钟。`,
|
||||
);
|
||||
setCaptchaAnswer('');
|
||||
} catch {
|
||||
setHint('');
|
||||
}
|
||||
})();
|
||||
}}
|
||||
>
|
||||
{sendingCode
|
||||
? '发送中...'
|
||||
: cooldownSeconds > 0
|
||||
? `${cooldownSeconds}s`
|
||||
: '获取验证码'}
|
||||
</button>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{hint ? (
|
||||
<div className="platform-banner platform-banner--success text-sm">
|
||||
{hint}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<CaptchaChallengeField
|
||||
challenge={captchaChallenge}
|
||||
answer={captchaAnswer}
|
||||
onAnswerChange={setCaptchaAnswer}
|
||||
/>
|
||||
|
||||
{error ? (
|
||||
<div className="platform-banner platform-banner--danger text-sm">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={binding || !phone.trim() || !code.trim()}
|
||||
className="platform-button platform-button--primary h-12 px-4 text-base disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{binding ? '正在绑定...' : '绑定手机号并进入游戏'}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="platform-button platform-button--ghost h-11 px-4 text-sm"
|
||||
onClick={() => {
|
||||
void onLogout();
|
||||
}}
|
||||
>
|
||||
返回其他登录方式
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
34
src/components/auth/CaptchaChallengeField.tsx
Normal file
34
src/components/auth/CaptchaChallengeField.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import type { AuthCaptchaChallenge } from '../../services/authService';
|
||||
|
||||
type CaptchaChallengeFieldProps = {
|
||||
challenge: AuthCaptchaChallenge | null;
|
||||
answer: string;
|
||||
onAnswerChange: (value: string) => void;
|
||||
};
|
||||
|
||||
export function CaptchaChallengeField({
|
||||
challenge,
|
||||
answer,
|
||||
onAnswerChange,
|
||||
}: CaptchaChallengeFieldProps) {
|
||||
if (!challenge) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="platform-banner platform-banner--info grid gap-3">
|
||||
<div className="text-sm leading-6">{challenge.promptText}</div>
|
||||
<img
|
||||
src={challenge.imageDataUrl}
|
||||
alt="图形验证码"
|
||||
className="platform-subpanel h-14 w-40 rounded-2xl object-cover"
|
||||
/>
|
||||
<input
|
||||
className="platform-input h-11"
|
||||
value={answer}
|
||||
placeholder="输入图形验证码"
|
||||
onChange={(event) => onAnswerChange(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
609
src/components/auth/LoginScreen.tsx
Normal file
609
src/components/auth/LoginScreen.tsx
Normal file
@@ -0,0 +1,609 @@
|
||||
import { X } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import type { PlatformTheme } from '../../../packages/shared/src/contracts/runtime';
|
||||
import type {
|
||||
AuthCaptchaChallenge,
|
||||
AuthLoginMethod,
|
||||
} from '../../services/authService';
|
||||
import { getStoredLastLoginPhone } from '../../services/authService';
|
||||
import { CaptchaChallengeField } from './CaptchaChallengeField';
|
||||
|
||||
type SmsScene = 'login' | 'reset_password';
|
||||
type LoginTab = 'phone' | 'password';
|
||||
|
||||
type LoginScreenProps = {
|
||||
isOpen: boolean;
|
||||
platformTheme: PlatformTheme;
|
||||
availableLoginMethods: AuthLoginMethod[];
|
||||
sendingCode: boolean;
|
||||
loggingIn: boolean;
|
||||
wechatLoading: boolean;
|
||||
error: string;
|
||||
captchaChallenge: AuthCaptchaChallenge | null;
|
||||
onClose: () => void;
|
||||
onSendCode: (
|
||||
phone: string,
|
||||
scene: SmsScene,
|
||||
captcha?: {
|
||||
challengeId?: string;
|
||||
answer?: string;
|
||||
},
|
||||
) => Promise<{
|
||||
cooldownSeconds: number;
|
||||
expiresInSeconds: number;
|
||||
}>;
|
||||
onPhoneSubmit: (phone: string, code: string) => Promise<void>;
|
||||
onPasswordSubmit: (phone: string, password: string) => Promise<void>;
|
||||
onResetPassword: (
|
||||
phone: string,
|
||||
code: string,
|
||||
newPassword: string,
|
||||
) => Promise<void>;
|
||||
onStartWechatLogin: () => Promise<void>;
|
||||
};
|
||||
|
||||
export function LoginScreen({
|
||||
isOpen,
|
||||
platformTheme,
|
||||
availableLoginMethods,
|
||||
sendingCode,
|
||||
loggingIn,
|
||||
wechatLoading,
|
||||
error,
|
||||
captchaChallenge,
|
||||
onClose,
|
||||
onSendCode,
|
||||
onPhoneSubmit,
|
||||
onPasswordSubmit,
|
||||
onResetPassword,
|
||||
onStartWechatLogin,
|
||||
}: LoginScreenProps) {
|
||||
const [isResetPanelOpen, setIsResetPanelOpen] = useState(false);
|
||||
const [phone, setPhone] = useState(() => getStoredLastLoginPhone());
|
||||
const [password, setPassword] = useState('');
|
||||
const [code, setCode] = useState('');
|
||||
const [resetPhone, setResetPhone] = useState('');
|
||||
const [resetCode, setResetCode] = useState('');
|
||||
const [resetPasswordValue, setResetPasswordValue] = useState('');
|
||||
const [captchaAnswer, setCaptchaAnswer] = useState('');
|
||||
const [cooldownSeconds, setCooldownSeconds] = useState(0);
|
||||
const [resetCooldownSeconds, setResetCooldownSeconds] = useState(0);
|
||||
const [hint, setHint] = useState('');
|
||||
const passwordLoginEnabled = availableLoginMethods.includes('password');
|
||||
const phoneLoginEnabled = availableLoginMethods.includes('phone');
|
||||
const wechatLoginEnabled = availableLoginMethods.includes('wechat');
|
||||
const [activeLoginTab, setActiveLoginTab] = useState<LoginTab>('phone');
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 每次重新打开弹窗都丢弃上一次未完成的表单草稿,只保留最近成功登录手机号回填。
|
||||
setIsResetPanelOpen(false);
|
||||
setPhone(getStoredLastLoginPhone());
|
||||
setPassword('');
|
||||
setCode('');
|
||||
setResetPhone('');
|
||||
setResetCode('');
|
||||
setResetPasswordValue('');
|
||||
setCaptchaAnswer('');
|
||||
setCooldownSeconds(0);
|
||||
setResetCooldownSeconds(0);
|
||||
setHint('');
|
||||
setActiveLoginTab(phoneLoginEnabled ? 'phone' : 'password');
|
||||
}, [isOpen, phoneLoginEnabled]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
activeLoginTab === 'phone' &&
|
||||
!phoneLoginEnabled &&
|
||||
passwordLoginEnabled
|
||||
) {
|
||||
setActiveLoginTab('password');
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
activeLoginTab === 'password' &&
|
||||
!passwordLoginEnabled &&
|
||||
phoneLoginEnabled
|
||||
) {
|
||||
setActiveLoginTab('phone');
|
||||
}
|
||||
}, [activeLoginTab, passwordLoginEnabled, phoneLoginEnabled]);
|
||||
|
||||
useEffect(() => {
|
||||
if (cooldownSeconds <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
setCooldownSeconds((current) => Math.max(0, current - 1));
|
||||
}, 1000);
|
||||
|
||||
return () => window.clearTimeout(timeoutId);
|
||||
}, [cooldownSeconds]);
|
||||
|
||||
useEffect(() => {
|
||||
if (resetCooldownSeconds <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
setResetCooldownSeconds((current) => Math.max(0, current - 1));
|
||||
}, 1000);
|
||||
|
||||
return () => window.clearTimeout(timeoutId);
|
||||
}, [resetCooldownSeconds]);
|
||||
|
||||
if (!isOpen) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const submitDisabled = loggingIn || sendingCode;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`platform-theme platform-theme--${platformTheme} platform-overlay fixed inset-0 z-[120] flex items-end justify-center px-3 py-4 text-[var(--platform-text-strong)] backdrop-blur-sm sm:items-center sm:p-4`}
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="auth-login-dialog-title"
|
||||
className="platform-auth-card w-full max-w-md overflow-hidden rounded-[2rem]"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3 border-b border-[var(--platform-subpanel-border)] px-5 py-4">
|
||||
<div
|
||||
id="auth-login-dialog-title"
|
||||
className="text-lg font-semibold text-[var(--platform-text-strong)]"
|
||||
>
|
||||
{isResetPanelOpen ? '重置密码' : '账号入口'}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="platform-icon-button p-2"
|
||||
aria-label="关闭登录弹窗"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isResetPanelOpen ? (
|
||||
<PasswordResetPanel
|
||||
phone={resetPhone}
|
||||
code={resetCode}
|
||||
password={resetPasswordValue}
|
||||
sendingCode={sendingCode}
|
||||
loggingIn={loggingIn}
|
||||
cooldownSeconds={resetCooldownSeconds}
|
||||
error={error}
|
||||
onPhoneChange={setResetPhone}
|
||||
onCodeChange={setResetCode}
|
||||
onPasswordChange={setResetPasswordValue}
|
||||
onBack={() => setIsResetPanelOpen(false)}
|
||||
onSendCode={async () => {
|
||||
const result = await onSendCode(resetPhone, 'reset_password');
|
||||
setResetCooldownSeconds(result.cooldownSeconds);
|
||||
}}
|
||||
onSubmit={() =>
|
||||
onResetPassword(resetPhone, resetCode, resetPasswordValue)
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col gap-5 px-5 py-5">
|
||||
{phoneLoginEnabled && passwordLoginEnabled ? (
|
||||
<div
|
||||
className="grid grid-cols-2 gap-2"
|
||||
role="tablist"
|
||||
aria-label="登录方式"
|
||||
>
|
||||
<LoginTabButton
|
||||
active={activeLoginTab === 'phone'}
|
||||
onClick={() => setActiveLoginTab('phone')}
|
||||
>
|
||||
短信登录
|
||||
</LoginTabButton>
|
||||
<LoginTabButton
|
||||
active={activeLoginTab === 'password'}
|
||||
onClick={() => setActiveLoginTab('password')}
|
||||
>
|
||||
密码登录
|
||||
</LoginTabButton>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{passwordLoginEnabled && activeLoginTab === 'password' ? (
|
||||
<form
|
||||
className="flex flex-col gap-4"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void onPasswordSubmit(phone, password);
|
||||
}}
|
||||
>
|
||||
<label className="grid gap-2 text-sm text-[var(--platform-text-base)]">
|
||||
<span>手机号</span>
|
||||
<input
|
||||
className="platform-input"
|
||||
autoComplete="tel"
|
||||
inputMode="numeric"
|
||||
value={phone}
|
||||
onChange={(event) => setPhone(event.target.value)}
|
||||
placeholder="13800000000"
|
||||
/>
|
||||
</label>
|
||||
<label className="grid gap-2 text-sm text-[var(--platform-text-base)]">
|
||||
<span>密码</span>
|
||||
<input
|
||||
className="platform-input"
|
||||
autoComplete="current-password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
placeholder="输入密码"
|
||||
/>
|
||||
</label>
|
||||
|
||||
{error ? <ErrorBanner message={error} /> : null}
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={
|
||||
submitDisabled || !phone.trim() || !password.trim()
|
||||
}
|
||||
className="platform-button platform-button--primary h-12 px-4 text-base disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{loggingIn ? '登录中' : '登录'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="self-end text-sm text-[var(--platform-accent)]"
|
||||
onClick={() => setIsResetPanelOpen(true)}
|
||||
>
|
||||
忘记密码
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{wechatLoginEnabled ? (
|
||||
<WechatButton
|
||||
loading={wechatLoading}
|
||||
disabled={submitDisabled}
|
||||
onClick={onStartWechatLogin}
|
||||
/>
|
||||
) : null}
|
||||
</form>
|
||||
) : null}
|
||||
|
||||
{phoneLoginEnabled && activeLoginTab === 'phone' ? (
|
||||
<PhoneCodeForm
|
||||
phone={phone}
|
||||
code={code}
|
||||
captchaAnswer={captchaAnswer}
|
||||
captchaChallenge={captchaChallenge}
|
||||
cooldownSeconds={cooldownSeconds}
|
||||
sendingCode={sendingCode}
|
||||
loggingIn={loggingIn}
|
||||
error={error}
|
||||
hint={hint}
|
||||
submitLabel="登录"
|
||||
enabled={phoneLoginEnabled}
|
||||
showPhoneField
|
||||
onPhoneChange={setPhone}
|
||||
onCodeChange={setCode}
|
||||
onCaptchaAnswerChange={setCaptchaAnswer}
|
||||
onSendCode={async () => {
|
||||
setHint('');
|
||||
const result = await onSendCode(phone, 'login', {
|
||||
challengeId: captchaChallenge?.challengeId,
|
||||
answer: captchaAnswer,
|
||||
});
|
||||
setCooldownSeconds(result.cooldownSeconds);
|
||||
setHint(
|
||||
`短信请求已提交,验证码有效期约 ${Math.max(1, Math.round(result.expiresInSeconds / 60))} 分钟。`,
|
||||
);
|
||||
setCaptchaAnswer('');
|
||||
}}
|
||||
onSubmit={() => onPhoneSubmit(phone, code)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{!passwordLoginEnabled &&
|
||||
!phoneLoginEnabled &&
|
||||
!wechatLoginEnabled ? (
|
||||
<div className="platform-subpanel rounded-2xl px-4 py-4 text-sm text-[var(--platform-text-base)]">
|
||||
当前登录入口暂不可用。
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LoginTabButton({
|
||||
active,
|
||||
children,
|
||||
onClick,
|
||||
}: {
|
||||
active: boolean;
|
||||
children: string;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={active}
|
||||
className={`relative h-12 text-base font-semibold transition-colors sm:text-lg ${
|
||||
active
|
||||
? 'text-[var(--platform-text-strong)]'
|
||||
: 'text-[var(--platform-text-muted)]'
|
||||
}`}
|
||||
onClick={onClick}
|
||||
>
|
||||
<span>{children}</span>
|
||||
{active ? (
|
||||
<span className="absolute bottom-1 left-1/2 h-1 w-12 -translate-x-1/2 rounded-full bg-[var(--platform-accent)]" />
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function PhoneCodeForm({
|
||||
phone,
|
||||
code,
|
||||
captchaAnswer,
|
||||
captchaChallenge,
|
||||
cooldownSeconds,
|
||||
sendingCode,
|
||||
loggingIn,
|
||||
error,
|
||||
hint,
|
||||
submitLabel,
|
||||
enabled,
|
||||
showPhoneField,
|
||||
onPhoneChange,
|
||||
onCodeChange,
|
||||
onCaptchaAnswerChange,
|
||||
onSendCode,
|
||||
onSubmit,
|
||||
}: {
|
||||
phone: string;
|
||||
code: string;
|
||||
captchaAnswer: string;
|
||||
captchaChallenge: AuthCaptchaChallenge | null;
|
||||
cooldownSeconds: number;
|
||||
sendingCode: boolean;
|
||||
loggingIn: boolean;
|
||||
error: string;
|
||||
hint: string;
|
||||
submitLabel: string;
|
||||
enabled: boolean;
|
||||
showPhoneField: boolean;
|
||||
onPhoneChange: (value: string) => void;
|
||||
onCodeChange: (value: string) => void;
|
||||
onCaptchaAnswerChange: (value: string) => void;
|
||||
onSendCode: () => Promise<void>;
|
||||
onSubmit: () => Promise<void>;
|
||||
}) {
|
||||
if (!enabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<form
|
||||
className="flex flex-col gap-4"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void onSubmit();
|
||||
}}
|
||||
>
|
||||
{showPhoneField ? (
|
||||
<label className="grid gap-2 text-sm text-[var(--platform-text-base)]">
|
||||
<span>手机号</span>
|
||||
<input
|
||||
className="platform-input"
|
||||
autoComplete="tel"
|
||||
inputMode="numeric"
|
||||
value={phone}
|
||||
onChange={(event) => onPhoneChange(event.target.value)}
|
||||
placeholder="13800000000"
|
||||
/>
|
||||
</label>
|
||||
) : null}
|
||||
|
||||
<label className="grid gap-2 text-sm text-[var(--platform-text-base)]">
|
||||
<span>验证码</span>
|
||||
<div className="flex gap-3">
|
||||
<input
|
||||
className="platform-input min-w-0 flex-1"
|
||||
inputMode="numeric"
|
||||
value={code}
|
||||
onChange={(event) => onCodeChange(event.target.value)}
|
||||
placeholder="输入验证码"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={sendingCode || cooldownSeconds > 0 || !phone.trim()}
|
||||
className="platform-button platform-button--secondary h-12 shrink-0 px-4 text-sm disabled:cursor-not-allowed disabled:opacity-55"
|
||||
onClick={() => void onSendCode()}
|
||||
>
|
||||
{sendingCode
|
||||
? '发送中'
|
||||
: cooldownSeconds > 0
|
||||
? `${cooldownSeconds}s`
|
||||
: '获取验证码'}
|
||||
</button>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<CaptchaChallengeField
|
||||
challenge={captchaChallenge}
|
||||
answer={captchaAnswer}
|
||||
onAnswerChange={onCaptchaAnswerChange}
|
||||
/>
|
||||
|
||||
{hint ? <SuccessBanner message={hint} /> : null}
|
||||
{error ? <ErrorBanner message={error} /> : null}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loggingIn || !phone.trim() || !code.trim()}
|
||||
className="platform-button platform-button--primary h-12 px-4 text-base disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{loggingIn ? '处理中' : submitLabel}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function PasswordResetPanel({
|
||||
phone,
|
||||
code,
|
||||
password,
|
||||
sendingCode,
|
||||
loggingIn,
|
||||
cooldownSeconds,
|
||||
error,
|
||||
onPhoneChange,
|
||||
onCodeChange,
|
||||
onPasswordChange,
|
||||
onBack,
|
||||
onSendCode,
|
||||
onSubmit,
|
||||
}: {
|
||||
phone: string;
|
||||
code: string;
|
||||
password: string;
|
||||
sendingCode: boolean;
|
||||
loggingIn: boolean;
|
||||
cooldownSeconds: number;
|
||||
error: string;
|
||||
onPhoneChange: (value: string) => void;
|
||||
onCodeChange: (value: string) => void;
|
||||
onPasswordChange: (value: string) => void;
|
||||
onBack: () => void;
|
||||
onSendCode: () => Promise<void>;
|
||||
onSubmit: () => Promise<void>;
|
||||
}) {
|
||||
return (
|
||||
<form
|
||||
className="flex flex-col gap-4 px-5 py-5"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void onSubmit();
|
||||
}}
|
||||
>
|
||||
<label className="grid gap-2 text-sm text-[var(--platform-text-base)]">
|
||||
<span>手机号</span>
|
||||
<input
|
||||
className="platform-input"
|
||||
autoComplete="tel"
|
||||
inputMode="numeric"
|
||||
value={phone}
|
||||
onChange={(event) => onPhoneChange(event.target.value)}
|
||||
placeholder="13800000000"
|
||||
/>
|
||||
</label>
|
||||
<label className="grid gap-2 text-sm text-[var(--platform-text-base)]">
|
||||
<span>验证码</span>
|
||||
<div className="flex gap-3">
|
||||
<input
|
||||
className="platform-input min-w-0 flex-1"
|
||||
inputMode="numeric"
|
||||
value={code}
|
||||
onChange={(event) => onCodeChange(event.target.value)}
|
||||
placeholder="输入验证码"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={sendingCode || cooldownSeconds > 0 || !phone.trim()}
|
||||
className="platform-button platform-button--secondary h-12 shrink-0 px-4 text-sm disabled:cursor-not-allowed disabled:opacity-55"
|
||||
onClick={() => void onSendCode()}
|
||||
>
|
||||
{sendingCode
|
||||
? '发送中'
|
||||
: cooldownSeconds > 0
|
||||
? `${cooldownSeconds}s`
|
||||
: '获取验证码'}
|
||||
</button>
|
||||
</div>
|
||||
</label>
|
||||
<label className="grid gap-2 text-sm text-[var(--platform-text-base)]">
|
||||
<span>新密码</span>
|
||||
<input
|
||||
className="platform-input"
|
||||
autoComplete="new-password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(event) => onPasswordChange(event.target.value)}
|
||||
placeholder="设置新密码"
|
||||
/>
|
||||
</label>
|
||||
|
||||
{error ? <ErrorBanner message={error} /> : null}
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<button
|
||||
type="button"
|
||||
className="platform-button platform-button--secondary h-12 px-4 text-base"
|
||||
onClick={onBack}
|
||||
>
|
||||
返回
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={
|
||||
loggingIn || !phone.trim() || !code.trim() || !password.trim()
|
||||
}
|
||||
className="platform-button platform-button--primary h-12 px-4 text-base disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{loggingIn ? '处理中' : '重置密码'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function WechatButton({
|
||||
loading,
|
||||
disabled,
|
||||
onClick,
|
||||
}: {
|
||||
loading: boolean;
|
||||
disabled: boolean;
|
||||
onClick: () => Promise<void>;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
disabled={loading || disabled}
|
||||
className="platform-button platform-button--secondary h-12 px-4 text-base disabled:cursor-not-allowed disabled:opacity-60"
|
||||
onClick={() => void onClick()}
|
||||
>
|
||||
{loading ? '跳转中' : '微信登录'}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function ErrorBanner({ message }: { message: string }) {
|
||||
return (
|
||||
<div className="platform-banner platform-banner--danger text-sm">
|
||||
{message}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SuccessBanner({ message }: { message: string }) {
|
||||
return (
|
||||
<div className="platform-banner platform-banner--success text-sm">
|
||||
{message}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user