diff --git a/frontend/src/app/layout/index.tsx b/frontend/src/app/layout/index.tsx index 1ebace91..372aa246 100644 --- a/frontend/src/app/layout/index.tsx +++ b/frontend/src/app/layout/index.tsx @@ -1,6 +1,7 @@ import type { ReactNode } from 'react' import { Outlet } from 'react-router' +import { AccountPanel } from '@/features/account-panel' import { AppHeader } from './app-header' /** 跨页面常驻导航属于应用外壳,由 app 层统一承载。 */ @@ -21,6 +22,7 @@ export function AppShell({ children }: AppShellProps) { 顶栏悬浮不占布局高度,内容页的避让由 PageContainer 统一让出,满幅页面自己让。 */}
{children}
+ ) } diff --git a/frontend/src/features/account-panel/index.test.tsx b/frontend/src/features/account-panel/index.test.tsx new file mode 100644 index 00000000..8a9bfed7 --- /dev/null +++ b/frontend/src/features/account-panel/index.test.tsx @@ -0,0 +1,303 @@ +// @vitest-environment jsdom +import { type ReactNode } from 'react' +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { MemoryRouter, Route, Routes, useLocation } from 'react-router' + +import { AppShell } from '@/app/layout' +import type { AuthTokens, UserApis } from '@/entities' +import { AuthSessionProvider } from '@/features/auth-session' +import { AccountPanel } from './index' + +const user = { + id: '7', + email: 'reader@example.com', + nickname: 'Reader', + emailVerifiedAt: '2026-08-07T01:02:03Z', + statusCode: 0, +} + +function tokens(): AuthTokens { + return { accessToken: 'access-token', refreshToken: 'refresh-token', user } +} + +function deferred() { + let resolve!: (value: T) => void + let reject!: (reason?: unknown) => void + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise + reject = rejectPromise + }) + return { promise, resolve, reject } +} + +function createApis(): UserApis & Record> { + return { + sendCode: vi.fn(async () => undefined), + register: vi.fn(async () => tokens()), + login: vi.fn(async () => tokens()), + loginByCode: vi.fn(async () => tokens()), + refresh: vi.fn(async () => tokens()), + logout: vi.fn(async () => undefined), + me: vi.fn(async () => user), + changePassword: vi.fn(async () => undefined), + } +} + +function LocationProbe() { + const location = useLocation() + return {`${location.pathname}${location.search}`} +} + +function renderPanel(entry = '/?account=login', apis = createApis()) { + const wrapper = ({ children }: { children: ReactNode }) => ( + + + + + {children} + + + } + /> + + + + ) + + return { apis, ...render(, { wrapper }) } +} + +function fillCodeLogin(email = 'reader@example.com', code = '123456') { + fireEvent.change(screen.getByLabelText('邮箱'), { target: { value: email } }) + fireEvent.change(screen.getByLabelText('验证码'), { target: { value: code } }) +} + +afterEach(() => { + cleanup() + window.localStorage.clear() + vi.useRealTimers() +}) + +describe('AccountPanel', () => { + it('opens only for the exact account=login query and focuses the email field', async () => { + const hidden = renderPanel('/?account=register') + expect(screen.queryByRole('dialog')).toBeNull() + hidden.unmount() + + renderPanel('/?account=login') + + expect(screen.getByRole('dialog', { name: '登录 Windup' })).toBeTruthy() + expect(screen.getByText('未注册的邮箱将在验证后自动创建账号。')).toBeTruthy() + await waitFor(() => expect(document.activeElement).toBe(screen.getByLabelText('邮箱'))) + }) + + it('closes on Escape without discarding unrelated query state', async () => { + renderPanel('/?account=login&returnTo=%2Fprojects&source=header') + + fireEvent.keyDown(document, { key: 'Escape' }) + + await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()) + expect(screen.getByTestId('location').textContent).toBe('/?returnTo=%2Fprojects&source=header') + }) + + it('sends login codes and keeps the cooldown with the receiving email across modes', async () => { + const { apis } = renderPanel() + fireEvent.change(screen.getByLabelText('邮箱'), { + target: { value: 'reader@example.com' }, + }) + + fireEvent.click(screen.getByRole('button', { name: '发送验证码' })) + await waitFor(() => + expect(apis.sendCode).toHaveBeenCalledWith({ + email: 'reader@example.com', + purpose: 'login', + }), + ) + expect(screen.getByRole('button', { name: '60 秒后重发' }).hasAttribute('disabled')).toBe(true) + + fireEvent.click(screen.getByRole('tab', { name: '注册' })) + expect(screen.getByRole('button', { name: '60 秒后重发' }).hasAttribute('disabled')).toBe(true) + + fireEvent.change(screen.getByLabelText('邮箱'), { target: { value: 'new@example.com' } }) + expect(screen.getByRole('button', { name: '发送验证码' }).hasAttribute('disabled')).toBe(false) + }) + + it('validates a numeric six-character code before submitting', async () => { + const { apis } = renderPanel() + fillCodeLogin('reader@example.com', '12ab56') + + fireEvent.submit(screen.getByRole('button', { name: '登录' }).closest('form')!) + + expect((await screen.findByRole('alert')).textContent).toContain('验证码需为 6 位数字') + expect(apis.loginByCode).not.toHaveBeenCalled() + }) + + it('shows backend errors inline, preserves input, and prevents repeat submits', async () => { + const pending = deferred() + const apis = createApis() + apis.loginByCode.mockReturnValue(pending.promise) + renderPanel('/?account=login', apis) + fillCodeLogin() + const form = screen.getByRole('button', { name: '登录' }).closest('form')! + + fireEvent.submit(form) + fireEvent.submit(form) + expect(apis.loginByCode).toHaveBeenCalledTimes(1) + + await act(async () => pending.reject(new Error('验证码已过期'))) + + expect((await screen.findByRole('alert')).textContent).toContain('验证码已过期') + expect((screen.getByLabelText('邮箱') as HTMLInputElement).value).toBe('reader@example.com') + expect((screen.getByLabelText('验证码') as HTMLInputElement).value).toBe('123456') + expect(screen.getByRole('button', { name: '登录' }).hasAttribute('disabled')).toBe(false) + }) + + it('uses the approved conditional copy before a safe return navigation', async () => { + vi.useFakeTimers() + const { apis } = renderPanel('/?account=login&returnTo=%2Fprojects%3Fview%3Drecent') + fillCodeLogin() + + fireEvent.submit(screen.getByRole('button', { name: '登录' }).closest('form')!) + await act(async () => Promise.resolve()) + + expect(apis.loginByCode).toHaveBeenCalledWith({ + email: 'reader@example.com', + code: '123456', + }) + expect( + screen.getByText('登录成功。如果这是你首次使用该邮箱,我们已为你创建账号。').textContent, + ).toContain('登录成功。如果这是你首次使用该邮箱,我们已为你创建账号。') + + await act(async () => vi.advanceTimersByTimeAsync(900)) + expect(screen.getByTestId('location').textContent).toBe('/projects?view=recent') + }) + + it('does not navigate after the panel closes while a submission is pending', async () => { + vi.useFakeTimers() + const pending = deferred() + const apis = createApis() + apis.loginByCode.mockReturnValue(pending.promise) + renderPanel('/?account=login&returnTo=%2Fprojects', apis) + fillCodeLogin() + + fireEvent.submit(screen.getByRole('button', { name: '登录' }).closest('form')!) + fireEvent.keyDown(document, { key: 'Escape' }) + expect(screen.queryByRole('dialog')).toBeNull() + expect(screen.getByTestId('location').textContent).toBe('/?returnTo=%2Fprojects') + + await act(async () => pending.resolve(tokens())) + await act(async () => vi.advanceTimersByTimeAsync(900)) + + expect(screen.getByTestId('location').textContent).toBe('/?returnTo=%2Fprojects') + }) + + it('falls back to the home page when returnTo is unsafe', async () => { + vi.useFakeTimers() + renderPanel('/?account=login&returnTo=%2F%2Fevil.example') + fillCodeLogin() + + fireEvent.submit(screen.getByRole('button', { name: '登录' }).closest('form')!) + await act(async () => Promise.resolve()) + await act(async () => vi.advanceTimersByTimeAsync(900)) + + expect(screen.getByTestId('location').textContent).toBe('/') + }) + + it('submits password login with a login-purpose code and keeps recovery visibly unavailable', async () => { + const { apis } = renderPanel() + fireEvent.click(screen.getByRole('tab', { name: '密码登录' })) + expect(screen.getByText('忘记密码')).toBeTruthy() + expect(screen.getByText('暂未开放')).toBeTruthy() + + fireEvent.change(screen.getByLabelText('邮箱'), { target: { value: 'reader@example.com' } }) + fireEvent.change(screen.getByLabelText('密码'), { target: { value: 'password-123' } }) + fireEvent.change(screen.getByLabelText('验证码'), { target: { value: '123456' } }) + fireEvent.click(screen.getByRole('button', { name: '发送验证码' })) + await waitFor(() => + expect(apis.sendCode).toHaveBeenCalledWith({ + email: 'reader@example.com', + purpose: 'login', + }), + ) + + fireEvent.submit(screen.getByRole('button', { name: '登录' }).closest('form')!) + await waitFor(() => + expect(apis.login).toHaveBeenCalledWith({ + email: 'reader@example.com', + password: 'password-123', + code: '123456', + }), + ) + }) + + it('validates registration fields and sends register-purpose codes', async () => { + const { apis } = renderPanel() + fireEvent.click(screen.getByRole('tab', { name: '注册' })) + fireEvent.change(screen.getByLabelText('邮箱'), { target: { value: 'new@example.com' } }) + fireEvent.change(screen.getByLabelText('昵称(选填)'), { + target: { value: 'N'.repeat(51) }, + }) + fireEvent.change(screen.getByLabelText('密码'), { target: { value: 'short' } }) + fireEvent.change(screen.getByLabelText('验证码'), { target: { value: '123456' } }) + + fireEvent.submit(screen.getByRole('button', { name: '创建账号' }).closest('form')!) + expect((await screen.findByRole('alert')).textContent).toContain('密码需为 8–128 位') + expect(apis.register).not.toHaveBeenCalled() + + fireEvent.change(screen.getByLabelText('密码'), { target: { value: 'password-123' } }) + fireEvent.submit(screen.getByRole('button', { name: '创建账号' }).closest('form')!) + expect((await screen.findByRole('alert')).textContent).toContain('昵称不能超过 50 个字符') + + fireEvent.change(screen.getByLabelText('昵称(选填)'), { target: { value: '' } }) + fireEvent.click(screen.getByRole('button', { name: '发送验证码' })) + await waitFor(() => + expect(apis.sendCode).toHaveBeenCalledWith({ + email: 'new@example.com', + purpose: 'register', + }), + ) + fireEvent.submit(screen.getByRole('button', { name: '创建账号' }).closest('form')!) + await waitFor(() => + expect(apis.register).toHaveBeenCalledWith({ + email: 'new@example.com', + password: 'password-123', + code: '123456', + }), + ) + }) +}) + +describe('AppShell account panel host', () => { + it('does not require an auth context while the panel is closed', () => { + render( + + +

当前页面

+
+
, + ) + + expect(screen.getByText('当前页面')).toBeTruthy() + expect(screen.queryByRole('dialog')).toBeNull() + }) + + it('renders the query-driven dialog over shell content', () => { + const apis = createApis() + render( + + + +

当前页面

+
+
+
, + ) + + expect(screen.getByText('当前页面')).toBeTruthy() + expect(screen.getByRole('dialog', { name: '登录 Windup' })).toBeTruthy() + }) +}) diff --git a/frontend/src/features/account-panel/index.tsx b/frontend/src/features/account-panel/index.tsx new file mode 100644 index 00000000..2d418b1e --- /dev/null +++ b/frontend/src/features/account-panel/index.tsx @@ -0,0 +1,446 @@ +import { + useEffect, + useId, + useMemo, + useRef, + useState, + type FormEvent, + type KeyboardEvent as ReactKeyboardEvent, + type MouseEvent, +} from 'react' +import { useNavigate, useSearchParams } from 'react-router' + +import { useAuthSession } from '@/features/auth-session' +import { sanitizeInternalPath } from '@/shared/navigation' + +type AccountMode = 'code' | 'password' | 'register' + +const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ +const CODE_PATTERN = /^\d{6}$/ +const SUCCESS_NAVIGATION_DELAY_MS = 900 + +const modeCopy: Record< + AccountMode, + { tab: string; title: string; description: string; submit: string } +> = { + code: { + tab: '邮箱验证码', + title: '登录 Windup', + description: '用邮箱验证码继续,免去记忆密码。', + submit: '登录', + }, + password: { + tab: '密码登录', + title: '登录 Windup', + description: '使用密码和邮箱验证码确认身份。', + submit: '登录', + }, + register: { + tab: '注册', + title: '创建 Windup 账号', + description: '创建账号后即可继续保存与管理角色资产。', + submit: '创建账号', + }, +} + +function errorMessage(error: unknown): string { + return error instanceof Error && error.message ? error.message : '操作失败,请稍后重试' +} + +function emailKey(email: string): string { + return email.trim().toLowerCase() +} + +/** 查询参数驱动的认证入口,不创建独立登录页面。 */ +export function AccountPanel() { + const [searchParams] = useSearchParams() + if (searchParams.get('account') !== 'login') return null + + return +} + +/** 只有面板真正打开时才读取会话,关闭状态不把认证 Context 强加给应用外壳。 */ +function AccountPanelDialog() { + const [searchParams, setSearchParams] = useSearchParams() + const navigate = useNavigate() + const session = useAuthSession() + const [mode, setMode] = useState('code') + const [email, setEmail] = useState('') + const [password, setPassword] = useState('') + const [code, setCode] = useState('') + const [nickname, setNickname] = useState('') + const [error, setError] = useState(null) + const [success, setSuccess] = useState(null) + const [isSendingCode, setIsSendingCode] = useState(false) + const [isSubmitting, setIsSubmitting] = useState(false) + const [cooldowns, setCooldowns] = useState>(() => new Map()) + const [now, setNow] = useState(Date.now()) + const emailInputRef = useRef(null) + const dialogRef = useRef(null) + const navigationTimerRef = useRef(null) + const dismissedRef = useRef(false) + const titleId = useId() + const descriptionId = useId() + const emailId = useId() + const nicknameId = useId() + const passwordId = useId() + const codeId = useId() + const copy = modeCopy[mode] + const normalizedEmail = email.trim() + const cooldownSeconds = Math.max( + 0, + Math.ceil(((cooldowns.get(emailKey(email)) ?? 0) - now) / 1_000), + ) + + const returnTarget = useMemo( + () => sanitizeInternalPath(searchParams.get('returnTo')) ?? '/', + [searchParams], + ) + + useEffect(() => { + if (cooldowns.size === 0) return + const timer = window.setInterval(() => { + const currentTime = Date.now() + setNow(currentTime) + setCooldowns((previous) => { + const active = new Map([...previous].filter(([, expiresAt]) => expiresAt > currentTime)) + return active.size === previous.size ? previous : active + }) + }, 1_000) + return () => window.clearInterval(timer) + }, [cooldowns.size]) + + useEffect(() => { + const previouslyFocused = document.activeElement + const frame = window.requestAnimationFrame(() => emailInputRef.current?.focus()) + const previousOverflow = document.body.style.overflow + document.body.style.overflow = 'hidden' + + return () => { + window.cancelAnimationFrame(frame) + document.body.style.overflow = previousOverflow + if (previouslyFocused instanceof HTMLElement) previouslyFocused.focus() + } + }, []) + + useEffect( + () => () => { + dismissedRef.current = true + if (navigationTimerRef.current) window.clearTimeout(navigationTimerRef.current) + }, + [], + ) + + function close() { + dismissedRef.current = true + if (navigationTimerRef.current) window.clearTimeout(navigationTimerRef.current) + const next = new URLSearchParams(searchParams) + next.delete('account') + setSearchParams(next, { replace: true }) + } + + function selectMode(nextMode: AccountMode) { + setMode(nextMode) + setError(null) + setSuccess(null) + window.requestAnimationFrame(() => emailInputRef.current?.focus()) + } + + async function sendCode() { + if (isSendingCode || cooldownSeconds > 0) return + if (!EMAIL_PATTERN.test(normalizedEmail)) { + setError('请输入有效邮箱地址') + return + } + + setError(null) + setSuccess(null) + setIsSendingCode(true) + try { + await session.sendCode({ + email: normalizedEmail, + purpose: mode === 'register' ? 'register' : 'login', + }) + const sentAt = Date.now() + setNow(sentAt) + setCooldowns((previous) => new Map(previous).set(emailKey(normalizedEmail), sentAt + 60_000)) + setSuccess('验证码已发送,请在 5 分钟内使用。') + } catch (sendError) { + setError(errorMessage(sendError)) + } finally { + setIsSendingCode(false) + } + } + + function validate(): string | null { + if (!EMAIL_PATTERN.test(normalizedEmail)) return '请输入有效邮箱地址' + if (mode !== 'code' && (password.length < 8 || password.length > 128)) { + return '密码需为 8–128 位' + } + if (!CODE_PATTERN.test(code)) return '验证码需为 6 位数字' + if (mode === 'register' && nickname.length > 50) return '昵称不能超过 50 个字符' + return null + } + + async function submit(event: FormEvent) { + event.preventDefault() + if (isSubmitting) return + const validationError = validate() + if (validationError) { + setError(validationError) + return + } + + setError(null) + setSuccess(null) + setIsSubmitting(true) + try { + let successMessage: string + if (mode === 'code') { + await session.loginByCode({ email: normalizedEmail, code }) + successMessage = '登录成功。如果这是你首次使用该邮箱,我们已为你创建账号。' + } else if (mode === 'password') { + await session.login({ email: normalizedEmail, password, code }) + successMessage = '登录成功,正在继续。' + } else { + await session.register({ + email: normalizedEmail, + password, + code, + ...(nickname.trim() ? { nickname: nickname.trim() } : {}), + }) + successMessage = '账号已创建,正在继续。' + } + + if (dismissedRef.current) return + setSuccess(successMessage) + navigationTimerRef.current = window.setTimeout( + () => navigate(returnTarget, { replace: true }), + SUCCESS_NAVIGATION_DELAY_MS, + ) + } catch (submitError) { + if (dismissedRef.current) return + setError(errorMessage(submitError)) + setIsSubmitting(false) + } + } + + function onDocumentKeyDown(event: globalThis.KeyboardEvent) { + if (event.key === 'Escape') close() + } + + useEffect(() => { + document.addEventListener('keydown', onDocumentKeyDown) + return () => document.removeEventListener('keydown', onDocumentKeyDown) + }) + + function trapFocus(event: ReactKeyboardEvent) { + if (event.key !== 'Tab') return + const focusable = dialogRef.current?.querySelectorAll( + 'button:not([disabled]), input:not([disabled]), [tabindex]:not([tabindex="-1"])', + ) + if (!focusable?.length) return + const first = focusable[0] + const last = focusable[focusable.length - 1] + if (event.shiftKey && document.activeElement === first) { + event.preventDefault() + last.focus() + } else if (!event.shiftKey && document.activeElement === last) { + event.preventDefault() + first.focus() + } + } + + function closeFromBackdrop(event: MouseEvent) { + if (event.target === event.currentTarget) close() + } + + const fieldClass = + 'min-h-11 w-full rounded-xl border border-[#98a39b] bg-white px-3.5 text-base text-[#1c231e] outline-none transition-[border-color,box-shadow] placeholder:text-[#8a948c] focus:border-[#284331] focus:ring-2 focus:ring-[#284331]/18 disabled:cursor-not-allowed disabled:bg-[#f1f3f1]' + const tabClass = + 'min-h-11 flex-1 rounded-lg px-3 text-sm font-semibold transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#284331]' + + return ( +
+
+ + +
+

+ Windup account +

+

+ {copy.title} +

+

+ {copy.description} +

+
+ +
+ {(Object.keys(modeCopy) as AccountMode[]).map((itemMode) => ( + + ))} +
+ +
+ + + {mode === 'register' && ( +
+ + setNickname(event.target.value)} + disabled={isSubmitting} + className={fieldClass} + placeholder="怎么称呼你" + /> + 最多 50 个字符 +
+ )} + + {mode !== 'code' && ( +
+ + + {mode === 'password' && ( + + 忘记密码 · 暂未开放 + + )} + + setPassword(event.target.value)} + disabled={isSubmitting} + className={fieldClass} + placeholder="8–128 位" + /> +
+ )} + +
+ + + setCode(event.target.value)} + disabled={isSubmitting} + className={fieldClass} + placeholder="6 位数字" + /> + + +
+ + {mode === 'code' && ( +

+ 未注册的邮箱将在验证后自动创建账号。 +

+ )} + + {error && ( +

+ {error} +

+ )} + {success && ( +

+ {success} +

+ )} + + +
+
+
+ ) +} diff --git a/frontend/src/index.css b/frontend/src/index.css index fd6020e9..846eda30 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -73,6 +73,26 @@ body, } } +@keyframes account-dialog-backdrop { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +@keyframes account-dialog-panel { + from { + opacity: 0; + transform: translateY(12px) scale(0.985); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + .projects-intro { animation: projects-intro 420ms cubic-bezier(0.22, 1, 0.36, 1) both; } @@ -98,6 +118,14 @@ body, animation: action-reveal 520ms cubic-bezier(0.2, 0.9, 0.25, 1) both; } +.account-dialog-backdrop { + animation: account-dialog-backdrop 180ms ease-out both; +} + +.account-dialog-panel { + animation: account-dialog-panel 240ms cubic-bezier(0.22, 1, 0.36, 1) both; +} + @media (prefers-reduced-motion: reduce) { .projects-intro, .projects-card-enter, @@ -105,6 +133,8 @@ body, .projects-dialog-panel, .route-transition, .action-reveal, + .account-dialog-backdrop, + .account-dialog-panel, .project-pixel-mark i { animation: none; } diff --git a/frontend/src/shared/navigation/index.test.ts b/frontend/src/shared/navigation/index.test.ts new file mode 100644 index 00000000..5246a85a --- /dev/null +++ b/frontend/src/shared/navigation/index.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest' + +import { sanitizeInternalPath } from './index' + +describe('sanitizeInternalPath', () => { + const origin = 'https://windup.example' + + it('keeps a same-origin path with query and hash', () => { + expect(sanitizeInternalPath('/projects?view=recent#asset-2', origin)).toBe( + '/projects?view=recent#asset-2', + ) + }) + + it.each([null, '', 'projects', 'https://attacker.example/path'])( + 'rejects an absent or non-rooted value: %s', + (value) => { + expect(sanitizeInternalPath(value, origin)).toBeNull() + }, + ) + + it.each(['//attacker.example/path', '///attacker.example/path', '/\\attacker.example/path'])( + 'rejects protocol-relative and backslash forms: %s', + (value) => { + expect(sanitizeInternalPath(value, origin)).toBeNull() + }, + ) +}) diff --git a/frontend/src/shared/navigation/index.ts b/frontend/src/shared/navigation/index.ts new file mode 100644 index 00000000..208ebffd --- /dev/null +++ b/frontend/src/shared/navigation/index.ts @@ -0,0 +1,21 @@ +/** + * 将不可信的回跳值收窄为当前站点的绝对路径。 + * 双斜杠与反斜杠在不同 URL 解析器里可能被当作外部主机,因此在解析前直接拒绝。 + */ +export function sanitizeInternalPath(value: string | null, origin?: string): string | null { + if (!value || !value.startsWith('/') || value.startsWith('//') || value.includes('\\')) { + return null + } + + const currentOrigin = origin ?? globalThis.location?.origin + if (!currentOrigin) return null + + try { + const expectedOrigin = new URL(currentOrigin).origin + const parsed = new URL(value, expectedOrigin) + if (parsed.origin !== expectedOrigin) return null + return `${parsed.pathname}${parsed.search}${parsed.hash}` + } catch { + return null + } +}