This commit is contained in:
2026-05-13 00:28:07 +08:00
parent ef4f91a75e
commit 01c5ab985a
101 changed files with 10635 additions and 2292 deletions

View File

@@ -6,6 +6,7 @@ import { useState } from 'react';
import { beforeEach, expect, test, vi } from 'vitest';
import type { AuthUser } from '../../services/authService';
import { LEGAL_CONSENT_STORAGE_KEY } from '../common/legalDocuments';
import { AuthGate } from './AuthGate';
import { useAuthUi } from './AuthUiContext';
@@ -95,6 +96,7 @@ const mockUser: AuthUser = {
beforeEach(() => {
vi.clearAllMocks();
window.localStorage.clear();
window.history.replaceState(null, '', '/');
authMocks.consumeAuthCallbackResult.mockReturnValue(null);
authMocks.ensureStoredAccessToken.mockResolvedValue('jwt-existing-token');
@@ -141,6 +143,15 @@ beforeEach(() => {
authMocks.startWechatLogin.mockResolvedValue(undefined);
});
async function acceptLegalConsent(
user: ReturnType<typeof userEvent.setup>,
dialog: HTMLElement,
) {
await user.click(
within(dialog).getByRole('switch', { name: '同意法律协议' }),
);
}
function ProtectedActionButton({
onAuthenticated,
}: {
@@ -346,6 +357,7 @@ test('auth gate opens a login modal for protected actions and resumes after logi
await user.type(within(dialog).getByLabelText('手机号'), '13800000000');
await user.type(within(dialog).getByLabelText('验证码'), '123456');
await acceptLegalConsent(user, dialog);
await user.click(within(dialog).getByRole('button', { name: '登录' }));
await waitFor(() => {
@@ -360,6 +372,70 @@ test('auth gate opens a login modal for protected actions and resumes after logi
expect(screen.queryByRole('dialog', { name: '账号入口' })).toBeNull();
});
test('login modal requires first-time legal consent before sms login', 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.type(within(dialog).getByLabelText('验证码'), '123456');
const loginButton = within(dialog).getByRole('button', { name: '登录' });
const legalSwitch = within(dialog).getByRole('switch', {
name: '同意法律协议',
});
expect((loginButton as HTMLButtonElement).disabled).toBe(true);
expect(legalSwitch.getAttribute('aria-checked')).toBe('false');
await user.click(
within(dialog).getByRole('button', { name: '《用户协议》' }),
);
expect(
await screen.findByRole('dialog', { name: '用户协议' }),
).toBeTruthy();
await user.click(screen.getByRole('button', { name: '我知道了' }));
expect(legalSwitch.getAttribute('aria-checked')).toBe('false');
await user.click(legalSwitch);
expect(legalSwitch.getAttribute('aria-checked')).toBe('true');
expect(window.localStorage.getItem(LEGAL_CONSENT_STORAGE_KEY)).toBe('true');
expect((loginButton as HTMLButtonElement).disabled).toBe(false);
});
test('login modal defaults legal consent to checked after stored confirmation', async () => {
const user = userEvent.setup();
window.localStorage.setItem(LEGAL_CONSENT_STORAGE_KEY, 'true');
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: '账号入口' });
const legalSwitch = within(dialog).getByRole('switch', {
name: '同意法律协议',
});
expect(legalSwitch.getAttribute('aria-checked')).toBe('true');
});
test('phone login result is not overwritten by an older guest hydrate', async () => {
const user = userEvent.setup();
const onAuthenticated = vi.fn();
@@ -387,6 +463,7 @@ test('phone login result is not overwritten by an older guest hydrate', async ()
const dialog = screen.getByRole('dialog', { name: '账号入口' });
await user.type(within(dialog).getByLabelText('手机号'), '13800000000');
await user.type(within(dialog).getByLabelText('验证码'), '123456');
await acceptLegalConsent(user, dialog);
await user.click(within(dialog).getByRole('button', { name: '登录' }));
expect(await screen.findByText('当前用户:测试玩家')).toBeTruthy();
@@ -425,6 +502,7 @@ test('auth gate hides register entry and opens invite modal for new sms account'
expect(within(dialog).queryByLabelText('邀请码')).toBeNull();
await user.type(within(dialog).getByLabelText('手机号'), '13800000000');
await user.type(within(dialog).getByLabelText('验证码'), '123456');
await acceptLegalConsent(user, dialog);
await user.click(within(dialog).getByRole('button', { name: '登录' }));
await waitFor(() => {
@@ -475,6 +553,7 @@ test('registration invite modal can skip when invite code is empty', async () =>
const dialog = screen.getByRole('dialog', { name: '账号入口' });
await user.type(within(dialog).getByLabelText('手机号'), '13800000000');
await user.type(within(dialog).getByLabelText('验证码'), '123456');
await acceptLegalConsent(user, dialog);
await user.click(within(dialog).getByRole('button', { name: '登录' }));
const inviteDialog = await screen.findByRole('dialog', {
@@ -700,6 +779,7 @@ test('auth gate separates sms and password login by tabs', async () => {
await user.type(within(dialog).getByLabelText('手机号'), '13800000000');
await user.type(within(dialog).getByLabelText('密码'), 'passw0rd');
await acceptLegalConsent(user, dialog);
await user.click(within(dialog).getByRole('button', { name: '登录' }));
await waitFor(() => {

View File

@@ -1,5 +1,5 @@
import { X } from 'lucide-react';
import { useEffect, useState } from 'react';
import { Check, X } from 'lucide-react';
import { type ReactNode, useEffect, useState } from 'react';
import type { PlatformTheme } from '../../../packages/shared/src/contracts/runtime';
import type {
@@ -7,6 +7,13 @@ import type {
AuthLoginMethod,
} from '../../services/authService';
import { getStoredLastLoginPhone } from '../../services/authService';
import { LegalDocumentModal } from '../common/LegalDocumentModal';
import {
getLegalDocument,
type LegalDocumentId,
persistLegalConsent,
readStoredLegalConsent,
} from '../common/legalDocuments';
import { CaptchaChallengeField } from './CaptchaChallengeField';
type SmsScene = 'login' | 'reset_password';
@@ -70,6 +77,9 @@ export function LoginScreen({
const [cooldownSeconds, setCooldownSeconds] = useState(0);
const [resetCooldownSeconds, setResetCooldownSeconds] = useState(0);
const [hint, setHint] = useState('');
const [legalConsentChecked, setLegalConsentChecked] = useState(false);
const [activeLegalDocumentId, setActiveLegalDocumentId] =
useState<LegalDocumentId | null>(null);
const passwordLoginEnabled = availableLoginMethods.includes('password');
const phoneLoginEnabled = availableLoginMethods.includes('phone');
const wechatLoginEnabled = availableLoginMethods.includes('wechat');
@@ -92,6 +102,8 @@ export function LoginScreen({
setCooldownSeconds(0);
setResetCooldownSeconds(0);
setHint('');
setLegalConsentChecked(readStoredLegalConsent());
setActiveLegalDocumentId(null);
setActiveLoginTab(phoneLoginEnabled ? 'phone' : 'password');
}, [isOpen, phoneLoginEnabled]);
@@ -143,89 +155,117 @@ export function LoginScreen({
}
const submitDisabled = loggingIn || sendingCode;
const activeLegalDocument = activeLegalDocumentId
? getLegalDocument(activeLegalDocumentId)
: null;
const toggleLegalConsent = () => {
setLegalConsentChecked((current) => {
const nextChecked = !current;
if (nextChecked) {
persistLegalConsent();
}
return nextChecked;
});
};
const legalConsentRow = (
<LegalConsentRow
checked={legalConsentChecked}
onToggle={toggleLegalConsent}
onOpenDocument={setActiveLegalDocumentId}
/>
);
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()}
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 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
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>
<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 ? (
<div
className={`grid gap-2 ${
passwordLoginEnabled ? 'grid-cols-2' : 'grid-cols-1'
}`}
role="tablist"
aria-label="登录方式"
>
<LoginTabButton
active={activeLoginTab === 'phone'}
onClick={() => setActiveLoginTab('phone')}
{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 ? (
<div
className={`grid gap-2 ${
passwordLoginEnabled ? 'grid-cols-2' : 'grid-cols-1'
}`}
role="tablist"
aria-label="登录方式"
>
</LoginTabButton>
{passwordLoginEnabled ? (
<LoginTabButton
active={activeLoginTab === 'password'}
onClick={() => setActiveLoginTab('password')}
active={activeLoginTab === 'phone'}
onClick={() => setActiveLoginTab('phone')}
>
</LoginTabButton>
) : null}
</div>
) : null}
{passwordLoginEnabled ? (
<LoginTabButton
active={activeLoginTab === 'password'}
onClick={() => setActiveLoginTab('password')}
>
</LoginTabButton>
) : null}
</div>
) : null}
{passwordLoginEnabled && activeLoginTab === 'password' ? (
<form
className="flex flex-col gap-4"
onSubmit={(event) => {
event.preventDefault();
if (
submitDisabled ||
!phone.trim() ||
!password.trim() ||
!legalConsentChecked
) {
return;
}
void onPasswordSubmit(phone, password);
}}
>
@@ -253,12 +293,16 @@ export function LoginScreen({
</label>
{error ? <ErrorBanner message={error} /> : null}
{legalConsentRow}
<div className="flex flex-col gap-2">
<button
type="submit"
disabled={
submitDisabled || !phone.trim() || !password.trim()
submitDisabled ||
!phone.trim() ||
!password.trim() ||
!legalConsentChecked
}
className="platform-button platform-button--primary h-12 px-4 text-base disabled:cursor-not-allowed disabled:opacity-60"
>
@@ -296,6 +340,8 @@ export function LoginScreen({
hint={hint}
submitLabel="登录"
enabled={phoneLoginEnabled}
legalConsentChecked={legalConsentChecked}
legalConsentNode={legalConsentRow}
showPhoneField
onPhoneChange={setPhone}
onCodeChange={setCode}
@@ -323,13 +369,89 @@ export function LoginScreen({
</div>
) : null}
</div>
)}
</div>
)}
</div>
</div>
<LegalDocumentModal
document={activeLegalDocument}
open={Boolean(activeLegalDocument)}
platformTheme={platformTheme}
onClose={() => setActiveLegalDocumentId(null)}
/>
</>
);
}
function LegalConsentRow({
checked,
onToggle,
onOpenDocument,
}: {
checked: boolean;
onToggle: () => void;
onOpenDocument: (documentId: LegalDocumentId) => void;
}) {
const openDocument = (documentId: LegalDocumentId) => {
onOpenDocument(documentId);
};
return (
<div className="flex items-start gap-2.5 text-xs leading-5 text-[var(--platform-text-base)]">
<button
type="button"
role="switch"
aria-checked={checked}
aria-label="同意法律协议"
onClick={onToggle}
className={`mt-0.5 flex h-5 w-9 shrink-0 items-center rounded-full border p-0.5 transition ${
checked
? 'justify-end border-[var(--platform-button-primary-border)] [background:var(--platform-profile-action-fill)]'
: 'justify-start border-[var(--platform-subpanel-border)] [background:var(--platform-button-secondary-fill)]'
}`}
>
<span className="flex h-4 w-4 items-center justify-center rounded-full bg-[var(--platform-button-primary-text)] text-[var(--platform-cool-text)] shadow-sm">
{checked ? <Check className="h-3 w-3" /> : null}
</span>
</button>
<div>
<LegalLink
label="《用户协议》"
onClick={() => openDocument('user-agreement')}
/>
<LegalLink
label="《隐私政策》"
onClick={() => openDocument('privacy-policy')}
/>
<LegalLink
label="《免责声明》"
onClick={() => openDocument('disclaimer')}
/>
</div>
</div>
);
}
function LegalLink({
label,
onClick,
}: {
label: string;
onClick: () => void;
}) {
return (
<button
type="button"
className="mx-0.5 align-baseline font-semibold text-[var(--platform-cool-text)] underline-offset-2 hover:underline"
onClick={onClick}
>
{label}
</button>
);
}
function LoginTabButton({
active,
children,
@@ -371,6 +493,8 @@ function PhoneCodeForm({
hint,
submitLabel,
enabled,
legalConsentChecked,
legalConsentNode,
showPhoneField,
onPhoneChange,
onCodeChange,
@@ -389,6 +513,8 @@ function PhoneCodeForm({
hint: string;
submitLabel: string;
enabled: boolean;
legalConsentChecked: boolean;
legalConsentNode: ReactNode;
showPhoneField: boolean;
onPhoneChange: (value: string) => void;
onCodeChange: (value: string) => void;
@@ -400,11 +526,17 @@ function PhoneCodeForm({
return null;
}
const submitBlocked =
loggingIn || !phone.trim() || !code.trim() || !legalConsentChecked;
return (
<form
className="flex flex-col gap-4"
onSubmit={(event) => {
event.preventDefault();
if (submitBlocked) {
return;
}
void onSubmit();
}}
>
@@ -455,10 +587,11 @@ function PhoneCodeForm({
{hint ? <SuccessBanner message={hint} /> : null}
{error ? <ErrorBanner message={error} /> : null}
{legalConsentNode}
<button
type="submit"
disabled={loggingIn || !phone.trim() || !code.trim()}
disabled={submitBlocked}
className="platform-button platform-button--primary h-12 px-4 text-base disabled:cursor-not-allowed disabled:opacity-60"
>
{loggingIn ? '处理中' : submitLabel}