1
This commit is contained in:
@@ -10,11 +10,14 @@ import { AuthGate } from './AuthGate';
|
||||
import { useAuthUi } from './AuthUiContext';
|
||||
|
||||
const authMocks = vi.hoisted(() => ({
|
||||
authEntry: vi.fn(),
|
||||
changePassword: vi.fn(),
|
||||
ensureStoredAccessToken: vi.fn(),
|
||||
ensureAutoAuthUser: vi.fn(),
|
||||
getAuthLoginOptions: vi.fn(),
|
||||
getCurrentAuthUser: vi.fn(),
|
||||
loginWithPhoneCode: vi.fn(),
|
||||
resetPassword: vi.fn(),
|
||||
sendPhoneLoginCode: vi.fn(),
|
||||
startWechatLogin: vi.fn(),
|
||||
consumeAuthCallbackResult: vi.fn(),
|
||||
@@ -26,10 +29,13 @@ vi.mock('../../services/apiClient', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('../../services/authService', () => ({
|
||||
authEntry: authMocks.authEntry,
|
||||
bindWechatPhone: vi.fn(),
|
||||
changePassword: authMocks.changePassword,
|
||||
changePhoneNumber: vi.fn(),
|
||||
consumeAuthCallbackResult: authMocks.consumeAuthCallbackResult,
|
||||
ensureAutoAuthUser: authMocks.ensureAutoAuthUser,
|
||||
getStoredLastLoginPhone: vi.fn(() => ''),
|
||||
getAuthAuditLogs: vi.fn(),
|
||||
getAuthLoginOptions: authMocks.getAuthLoginOptions,
|
||||
getAuthRiskBlocks: vi.fn(),
|
||||
@@ -40,8 +46,10 @@ vi.mock('../../services/authService', () => ({
|
||||
loginWithPhoneCode: authMocks.loginWithPhoneCode,
|
||||
logoutAllAuthSessions: vi.fn(),
|
||||
logoutAuthUser: vi.fn(),
|
||||
resetPassword: authMocks.resetPassword,
|
||||
revokeAuthSession: vi.fn(),
|
||||
sendPhoneLoginCode: authMocks.sendPhoneLoginCode,
|
||||
setStoredLastLoginPhone: vi.fn(),
|
||||
startWechatLogin: authMocks.startWechatLogin,
|
||||
}));
|
||||
|
||||
@@ -86,6 +94,9 @@ beforeEach(() => {
|
||||
availableLoginMethods: ['phone'],
|
||||
});
|
||||
authMocks.loginWithPhoneCode.mockResolvedValue(mockUser);
|
||||
authMocks.authEntry.mockResolvedValue(mockUser);
|
||||
authMocks.changePassword.mockResolvedValue(mockUser);
|
||||
authMocks.resetPassword.mockResolvedValue(mockUser);
|
||||
authMocks.sendPhoneLoginCode.mockResolvedValue({
|
||||
cooldownSeconds: 60,
|
||||
expiresInSeconds: 300,
|
||||
@@ -203,13 +214,13 @@ test('auth gate opens a login modal for protected actions and resumes after logi
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: '进入作品' }));
|
||||
|
||||
const dialog = screen.getByRole('dialog', { 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 user.click(within(dialog).getByRole('button', { name: '注册/登录' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(authMocks.loginWithPhoneCode).toHaveBeenCalledWith(
|
||||
@@ -220,7 +231,7 @@ test('auth gate opens a login modal for protected actions and resumes after logi
|
||||
expect(onAuthenticated).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
expect(screen.queryByRole('dialog', { name: '登录账号' })).toBeNull();
|
||||
expect(screen.queryByRole('dialog', { name: '账号入口' })).toBeNull();
|
||||
});
|
||||
|
||||
test('auth state refresh keeps mounted platform content and local tab state', async () => {
|
||||
@@ -280,7 +291,7 @@ test('auth gate shows sms send feedback in the login modal', async () => {
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: '进入作品' }));
|
||||
|
||||
const dialog = screen.getByRole('dialog', { name: '登录账号' });
|
||||
const dialog = screen.getByRole('dialog', { name: '账号入口' });
|
||||
await user.type(within(dialog).getByLabelText('手机号'), '13800000000');
|
||||
await user.click(within(dialog).getByRole('button', { name: '获取验证码' }));
|
||||
|
||||
@@ -296,7 +307,48 @@ test('auth gate shows sms send feedback in the login modal', async () => {
|
||||
});
|
||||
|
||||
expect(
|
||||
within(dialog).getByText('短信请求已提交,请留意手机短信。验证码有效期约 5 分钟。'),
|
||||
within(dialog).getByText('短信请求已提交,验证码有效期约 5 分钟。'),
|
||||
).toBeTruthy();
|
||||
expect(within(dialog).getByRole('button', { name: '60s' })).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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,6 +10,7 @@ import { getStoredLastLoginPhone } from '../../services/authService';
|
||||
import { CaptchaChallengeField } from './CaptchaChallengeField';
|
||||
|
||||
type SmsScene = 'login' | 'reset_password';
|
||||
type LoginTab = 'phone' | 'password';
|
||||
|
||||
type LoginScreenProps = {
|
||||
isOpen: boolean;
|
||||
@@ -72,6 +73,18 @@ export function LoginScreen({
|
||||
const passwordLoginEnabled = availableLoginMethods.includes('password');
|
||||
const phoneLoginEnabled = availableLoginMethods.includes('phone');
|
||||
const wechatLoginEnabled = availableLoginMethods.includes('wechat');
|
||||
const [activeLoginTab, setActiveLoginTab] = useState<LoginTab>('phone');
|
||||
|
||||
useEffect(() => {
|
||||
if (activeLoginTab === 'phone' && !phoneLoginEnabled && passwordLoginEnabled) {
|
||||
setActiveLoginTab('password');
|
||||
return;
|
||||
}
|
||||
|
||||
if (activeLoginTab === 'password' && !passwordLoginEnabled && phoneLoginEnabled) {
|
||||
setActiveLoginTab('phone');
|
||||
}
|
||||
}, [activeLoginTab, passwordLoginEnabled, phoneLoginEnabled]);
|
||||
|
||||
useEffect(() => {
|
||||
if (cooldownSeconds <= 0) {
|
||||
@@ -152,8 +165,29 @@ export function LoginScreen({
|
||||
onSubmit={() => onResetPassword(resetPhone, resetCode, resetPasswordValue)}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4 px-5 py-5">
|
||||
{passwordLoginEnabled ? (
|
||||
<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) => {
|
||||
@@ -162,14 +196,13 @@ export function LoginScreen({
|
||||
}}
|
||||
>
|
||||
<label className="grid gap-2 text-sm text-[var(--platform-text-base)]">
|
||||
<span>手机号</span>
|
||||
<span>手机号/邮箱</span>
|
||||
<input
|
||||
className="platform-input"
|
||||
autoComplete="tel"
|
||||
inputMode="numeric"
|
||||
value={phone}
|
||||
onChange={(event) => setPhone(event.target.value)}
|
||||
placeholder="13800000000"
|
||||
placeholder="手机号或邮箱"
|
||||
/>
|
||||
</label>
|
||||
<label className="grid gap-2 text-sm text-[var(--platform-text-base)]">
|
||||
@@ -213,7 +246,7 @@ export function LoginScreen({
|
||||
</form>
|
||||
) : null}
|
||||
|
||||
{phoneLoginEnabled ? (
|
||||
{phoneLoginEnabled && activeLoginTab === 'phone' ? (
|
||||
<PhoneCodeForm
|
||||
phone={phone}
|
||||
code={code}
|
||||
@@ -226,7 +259,7 @@ export function LoginScreen({
|
||||
hint={hint}
|
||||
submitLabel="注册/登录"
|
||||
enabled={phoneLoginEnabled}
|
||||
showPhoneField={!passwordLoginEnabled}
|
||||
showPhoneField
|
||||
onPhoneChange={setPhone}
|
||||
onCodeChange={setCode}
|
||||
onCaptchaAnswerChange={setCaptchaAnswer}
|
||||
@@ -258,6 +291,35 @@ export function LoginScreen({
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user