From 29dd88f6d9d5e7e4273151c445a907e02e6383ce Mon Sep 17 00:00:00 2001 From: huyan Date: Fri, 7 Aug 2026 11:46:09 +0800 Subject: [PATCH 1/5] feat(navigation): sanitize account return paths Account flows need to preserve internal destinations without permitting external redirects. Add a same-origin path sanitizer that rejects non-rooted, protocol-relative, and backslash forms. Login flows can now reuse a narrow, tested return-path boundary. --- frontend/src/shared/navigation/index.test.ts | 27 ++++++++++++++++++++ frontend/src/shared/navigation/index.ts | 21 +++++++++++++++ 2 files changed, 48 insertions(+) create mode 100644 frontend/src/shared/navigation/index.test.ts create mode 100644 frontend/src/shared/navigation/index.ts 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 + } +} From 7f620fe83f69c09e81614551dc63565da8cb4d7b Mon Sep 17 00:00:00 2001 From: huyan Date: Fri, 7 Aug 2026 11:46:57 +0800 Subject: [PATCH 2/5] feat(account-panel): add authentication dialog The existing session actions had no query-driven interface for users to sign in or register. Add code login, password login, registration, cooldown, validation, inline feedback, and safe return navigation. The application shell now hosts the dialog while its closed state remains independent of auth context. --- frontend/src/app/layout/index.tsx | 2 + .../src/features/account-panel/index.test.tsx | 284 +++++++++++ frontend/src/features/account-panel/index.tsx | 439 ++++++++++++++++++ 3 files changed, 725 insertions(+) create mode 100644 frontend/src/features/account-panel/index.test.tsx create mode 100644 frontend/src/features/account-panel/index.tsx 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..72a896ea --- /dev/null +++ b/frontend/src/features/account-panel/index.test.tsx @@ -0,0 +1,284 @@ +// @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('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..021c97a2 --- /dev/null +++ b/frontend/src/features/account-panel/index.tsx @@ -0,0 +1,439 @@ +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 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( + () => () => { + if (navigationTimerRef.current) window.clearTimeout(navigationTimerRef.current) + }, + [], + ) + + function close() { + 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 { + if (mode === 'code') { + await session.loginByCode({ email: normalizedEmail, code }) + setSuccess('登录成功。如果这是你首次使用该邮箱,我们已为你创建账号。') + } else if (mode === 'password') { + await session.login({ email: normalizedEmail, password, code }) + setSuccess('登录成功,正在继续。') + } else { + await session.register({ + email: normalizedEmail, + password, + code, + ...(nickname.trim() ? { nickname: nickname.trim() } : {}), + }) + setSuccess('账号已创建,正在继续。') + } + + navigationTimerRef.current = window.setTimeout( + () => navigate(returnTarget, { replace: true }), + SUCCESS_NAVIGATION_DELAY_MS, + ) + } catch (submitError) { + 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} +

+ )} + + +
+
+
+ ) +} From 4c002990cb791204b11673f6c41f3703d6551d5b Mon Sep 17 00:00:00 2001 From: huyan Date: Fri, 7 Aug 2026 11:47:14 +0800 Subject: [PATCH 3/5] style(account-panel): animate dialog entry The account dialog should appear as a lightweight layer over the current page. Add restrained backdrop and panel entry motion with the existing application timing language. Reduced-motion users continue to receive the same interface without animation. --- frontend/src/index.css | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) 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; } From 357e22e0f3a4fd2b839908752041ae7cdf7dc5d0 Mon Sep 17 00:00:00 2001 From: huyan Date: Fri, 7 Aug 2026 15:23:19 +0800 Subject: [PATCH 4/5] fix(account-panel): stop redirects after dismissal Pending authentication can finish after the account dialog has closed. Track dismissal across close and unmount before continuing the async submit flow. Closed dialogs no longer show success state or schedule a delayed navigation. --- frontend/src/features/account-panel/index.tsx | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/frontend/src/features/account-panel/index.tsx b/frontend/src/features/account-panel/index.tsx index 021c97a2..2d418b1e 100644 --- a/frontend/src/features/account-panel/index.tsx +++ b/frontend/src/features/account-panel/index.tsx @@ -78,6 +78,7 @@ function AccountPanelDialog() { 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() @@ -124,12 +125,14 @@ function AccountPanelDialog() { 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') @@ -192,12 +195,13 @@ function AccountPanelDialog() { setSuccess(null) setIsSubmitting(true) try { + let successMessage: string if (mode === 'code') { await session.loginByCode({ email: normalizedEmail, code }) - setSuccess('登录成功。如果这是你首次使用该邮箱,我们已为你创建账号。') + successMessage = '登录成功。如果这是你首次使用该邮箱,我们已为你创建账号。' } else if (mode === 'password') { await session.login({ email: normalizedEmail, password, code }) - setSuccess('登录成功,正在继续。') + successMessage = '登录成功,正在继续。' } else { await session.register({ email: normalizedEmail, @@ -205,14 +209,17 @@ function AccountPanelDialog() { code, ...(nickname.trim() ? { nickname: nickname.trim() } : {}), }) - setSuccess('账号已创建,正在继续。') + 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) } From 6be5aac7df9c7943654a7ce83828026ece8de21c Mon Sep 17 00:00:00 2001 From: huyan Date: Fri, 7 Aug 2026 15:23:28 +0800 Subject: [PATCH 5/5] test(account-panel): cover dismissal during submit The async close race lacked a regression case. Resolve a deferred code login after dismissing the dialog and advance the redirect timer. The route now remains unchanged after the abandoned submission completes. --- .../src/features/account-panel/index.test.tsx | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/frontend/src/features/account-panel/index.test.tsx b/frontend/src/features/account-panel/index.test.tsx index 72a896ea..8a9bfed7 100644 --- a/frontend/src/features/account-panel/index.test.tsx +++ b/frontend/src/features/account-panel/index.test.tsx @@ -176,6 +176,25 @@ describe('AccountPanel', () => { 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')