From 874aefadc36965bd9b1d3b62a0eb10c20ff1a086 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sun, 1 Nov 2026 17:05:24 -0800 Subject: [PATCH 1/4] feat(v2): sign-up and account recovery follow their frames Sign-up, invitation required, verify email, forgot password and reset password now match the d215 sign-up frames, on the same stacked card as sign-in: - one notice row (V2AuthNotice) for errors and confirmations, which marks only the fields it is about (an invitation error marks the code) - sign-up: providers first, "optional" in the code label, terms under the key, the sign-in line keeps the deep link; no subtitle - invitation required: the waitlist sits on the "or" rule, its result is a status row above a plain key, and the address stays for a second send - verify email: the last navy MUI card becomes the v2 card; a token is sent once (not again on a language change or StrictMode's double effect), and a missing token fails at once instead of "verifying" forever - reset: a check the page makes rings both fields; a server failure carries its recovery link and leaves the fields alone; no token is one line and a key The legacy /register, /register/invite-required and /verify-email routes redirect with their query, and the legacy MUI Register page is retired. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015AKgpMD6Qh13f7fj6mbGT3 --- frontend/src/App.tsx | 11 +- frontend/src/components/Pages.smoke.test.tsx | 18 - frontend/src/components/Register.test.tsx | 70 ---- frontend/src/components/Register.tsx | 259 --------------- .../components/RegistrationInviteRequired.tsx | 28 +- frontend/src/components/VerifyEmail.test.tsx | 51 --- frontend/src/components/VerifyEmail.tsx | 102 +++--- frontend/src/i18n/locales/en.json | 18 +- frontend/src/i18n/locales/zh-CN.json | 16 +- .../v2/__tests__/V2AuthPagesLayout.test.tsx | 311 ++++++++++++++++++ frontend/src/v2/components/V2AuthNotice.tsx | 16 + .../src/v2/components/V2ForgotPassword.tsx | 22 +- frontend/src/v2/components/V2Login.tsx | 7 +- frontend/src/v2/components/V2Register.tsx | 60 ++-- .../src/v2/components/V2ResetPassword.tsx | 53 ++- frontend/src/v2/v2.css | 93 ++++-- 16 files changed, 568 insertions(+), 567 deletions(-) delete mode 100644 frontend/src/components/Register.test.tsx delete mode 100644 frontend/src/components/Register.tsx delete mode 100644 frontend/src/components/VerifyEmail.test.tsx create mode 100644 frontend/src/v2/__tests__/V2AuthPagesLayout.test.tsx create mode 100644 frontend/src/v2/components/V2AuthNotice.tsx diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 627a48f3e6..e9344cefdd 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -32,12 +32,9 @@ import { V2PrivacyPage, V2TermsPage } from './v2/legal/V2LegalPage'; // covered by the single Suspense boundary in App() below. Canonical public // landing, comparison and use-case routes stay reachable by URL exactly as // before — only the moment their code arrives changed. -const Register = React.lazy(() => import('./components/Register')); -const RegistrationInviteRequired = React.lazy(() => import('./components/RegistrationInviteRequired')); const GuidePage = React.lazy(() => import('./components/landing/GuidePage')); const GuidesIndexPage = React.lazy(() => import('./components/landing/GuidesIndexPage')); const UseCasePage = React.lazy(() => import('./components/landing/UseCasePage')); -const VerifyEmail = React.lazy(() => import('./components/VerifyEmail')); const DiscordCallback = React.lazy(() => import('./components/DiscordCallback')); const V2App = React.lazy(() => import('./v2/V2App')); const V2LandingPage = React.lazy(() => import('./v2/landing/V2LandingPage')); @@ -304,11 +301,11 @@ function App(): React.ReactElement { } /> } /> } /> - {/* /login has no page of its own: it goes to /v2/login with its query. */} + {/* The old auth paths have no pages of their own: each goes to its v2 page with its query. */} } /> - } /> - } /> - } /> + } /> + } /> + } /> {/* Device-code sign-in (#1405): the backend's /api/auth/device/start answers with `verifyUrl: /cli/authorize`. Deleting this route 404'd every `commonly login` (#1534, 2026-09-04 → 09-08). */} diff --git a/frontend/src/components/Pages.smoke.test.tsx b/frontend/src/components/Pages.smoke.test.tsx index bf466fc634..488e9b2978 100644 --- a/frontend/src/components/Pages.smoke.test.tsx +++ b/frontend/src/components/Pages.smoke.test.tsx @@ -3,8 +3,6 @@ import React from 'react'; import ReactDOM from 'react-dom/client'; import TestUtils from 'react-dom/test-utils'; import { act } from 'react-dom/test-utils'; -import Register from './Register'; -import VerifyEmail from './VerifyEmail'; import UserProfile from './UserProfile'; import { useAuth } from '../context/AuthContext'; import { useAppContext } from '../context/AppContext'; @@ -84,22 +82,6 @@ function render(comp) { return container.textContent; } -test('Register renders and submits', async () => { - axios.get.mockResolvedValueOnce({ data: { inviteOnly: false } }); - axios.post.mockResolvedValue({ data: { message: 'ok' } }); - await TestUtils.act(async () => { root.render(); }); - const form = container.querySelector('form'); - await TestUtils.act(async () => { TestUtils.Simulate.submit(form); }); - expect(axios.post).toHaveBeenCalled(); -}); - -test('VerifyEmail fetches token', async () => { - axios.get.mockResolvedValue({ data: { message: 'verified' } }); - await TestUtils.act(async () => { root.render(); }); - await TestUtils.act(async () => Promise.resolve()); - expect(container.textContent).toContain('verified'); -}); - test('UserProfile renders', async () => { axios.get.mockResolvedValueOnce({ data: { username: 'u' } }); await TestUtils.act(async () => { root.render(); }); diff --git a/frontend/src/components/Register.test.tsx b/frontend/src/components/Register.test.tsx deleted file mode 100644 index 9ccea5202f..0000000000 --- a/frontend/src/components/Register.test.tsx +++ /dev/null @@ -1,70 +0,0 @@ -// @ts-nocheck -import React from 'react'; -import ReactDOM from 'react-dom/client'; -import TestUtils from 'react-dom/test-utils'; -import Register from './Register'; -import { BrowserRouter } from 'react-router-dom'; -const axios = require('axios').default; - -jest.mock('axios', () => ({ - __esModule: true, - default: { - get: jest.fn(), - post: jest.fn(), - defaults: {}, - interceptors: { - request: { use: jest.fn() }, - }, - } -})); - -let container; -let root; - -beforeEach(() => { - container = document.createElement('div'); - document.body.appendChild(container); - root = ReactDOM.createRoot(container); -}); - -afterEach(() => { - root.unmount(); - container.remove(); - container = null; -}); - -async function renderAndSubmit() { - await TestUtils.act(async () => { - root.render( - - - - ); - }); - const [user, email, pass] = container.querySelectorAll('input'); - TestUtils.act(() => { TestUtils.Simulate.change(user, { target: { value: 'u' } }); }); - TestUtils.act(() => { TestUtils.Simulate.change(email, { target: { value: 'e' } }); }); - TestUtils.act(() => { TestUtils.Simulate.change(pass, { target: { value: 'p' } }); }); - const form = container.querySelector('form'); - await TestUtils.act(async () => { TestUtils.Simulate.submit(form); }); -} - -test('shows success message on registration', async () => { - axios.get.mockResolvedValueOnce({ data: { inviteOnly: false } }); - axios.post.mockResolvedValueOnce({ data: { message: 'ok' } }); - await renderAndSubmit(); - expect(axios.post).toHaveBeenCalledWith('/api/auth/register', { - username: 'u', - email: 'e', - password: 'p', - invitationCode: '', - }); - expect(container.textContent).toContain('ok'); -}); - -test('shows error when registration fails', async () => { - axios.get.mockResolvedValueOnce({ data: { inviteOnly: false } }); - axios.post.mockRejectedValueOnce({ response: { data: { error: 'fail' } } }); - await renderAndSubmit(); - expect(container.textContent).toContain('fail'); -}); diff --git a/frontend/src/components/Register.tsx b/frontend/src/components/Register.tsx deleted file mode 100644 index ebfe48ef14..0000000000 --- a/frontend/src/components/Register.tsx +++ /dev/null @@ -1,259 +0,0 @@ -import React, { useEffect, useMemo, useState } from 'react'; -import axios from '../utils/axiosConfig'; -import { Box, TextField, Button, Typography, Container, Paper } from '@mui/material'; -import { Link, Navigate, useSearchParams } from 'react-router-dom'; -import commonlyLogo from '../assets/commonly-logo.png'; - -interface RegistrationPolicy { - loaded: boolean; - inviteOnly: boolean; -} - -const Register: React.FC = () => { - const [searchParams] = useSearchParams(); - const [username, setUsername] = useState(''); - const [email, setEmail] = useState(''); - const [password, setPassword] = useState(''); - const [invitationCode, setInvitationCode] = useState(searchParams.get('invite') || ''); - const [message, setMessage] = useState(''); - const [isError, setIsError] = useState(false); - const [policy, setPolicy] = useState({ - loaded: false, - inviteOnly: false, - }); - - useEffect(() => { - let isMounted = true; - axios.get('/api/auth/registration-policy') - .then((res) => { - if (!isMounted) return; - setPolicy({ - loaded: true, - inviteOnly: Boolean(res.data?.inviteOnly), - }); - }) - .catch(() => { - if (!isMounted) return; - setPolicy({ loaded: true, inviteOnly: false }); - }); - return () => { - isMounted = false; - }; - }, []); - - const hasInviteFromUrl = useMemo(() => Boolean(searchParams.get('invite')), [searchParams]); - - if (policy.loaded && policy.inviteOnly && !hasInviteFromUrl) { - return ; - } - - const onSubmit = async (e: React.FormEvent): Promise => { - e.preventDefault(); - setMessage(''); - setIsError(false); - - try { - const res = await axios.post(`/api/auth/register`, { - username, - email, - password, - invitationCode: invitationCode.trim(), - }); - setMessage(res.data.message); - } catch (err) { - setIsError(true); - const e = err as { response?: { data?: { error?: string } } }; - setMessage(e.response?.data?.error || "Registration failed."); - } - }; - - return ( - - - - - - - - Commonly - - - - Set up your shared memory space. - - - Create your account to start capturing team decisions, summaries, and skills in one place. - - - {[ - 'Start a pod and invite your teammates', - 'Turn activity into searchable team memory', - 'Power agents with trusted context', - ].map((item) => ( - - • {item} - - ))} - - - - - Register - - - Create your account to join or launch a pod. - -
- {policy.inviteOnly && ( - setInvitationCode(e.target.value)} - required - helperText="Registration is invite-only. Enter a valid invitation code." - InputLabelProps={{ style: { color: 'rgba(226, 232, 240, 0.9)' } }} - FormHelperTextProps={{ style: { color: 'rgba(148, 163, 184, 0.85)' } }} - sx={{ - input: { color: '#f8fafc' }, - '& .MuiOutlinedInput-root': { - backgroundColor: 'rgba(15, 23, 42, 0.8)', - '& fieldset': { borderColor: 'rgba(148, 163, 184, 0.3)' }, - '&:hover fieldset': { borderColor: 'rgba(148, 163, 184, 0.5)' }, - '&.Mui-focused fieldset': { borderColor: '#60a5fa' }, - }, - }} - /> - )} - setUsername(e.target.value)} - required - InputLabelProps={{ style: { color: 'rgba(226, 232, 240, 0.9)' } }} - sx={{ - input: { color: '#f8fafc' }, - '& .MuiOutlinedInput-root': { - backgroundColor: 'rgba(15, 23, 42, 0.8)', - '& fieldset': { borderColor: 'rgba(148, 163, 184, 0.3)' }, - '&:hover fieldset': { borderColor: 'rgba(148, 163, 184, 0.5)' }, - '&.Mui-focused fieldset': { borderColor: '#60a5fa' }, - }, - }} - /> - setEmail(e.target.value)} - required - InputLabelProps={{ style: { color: 'rgba(226, 232, 240, 0.9)' } }} - sx={{ - input: { color: '#f8fafc' }, - '& .MuiOutlinedInput-root': { - backgroundColor: 'rgba(15, 23, 42, 0.8)', - '& fieldset': { borderColor: 'rgba(148, 163, 184, 0.3)' }, - '&:hover fieldset': { borderColor: 'rgba(148, 163, 184, 0.5)' }, - '&.Mui-focused fieldset': { borderColor: '#60a5fa' }, - }, - }} - /> - setPassword(e.target.value)} - required - InputLabelProps={{ style: { color: 'rgba(226, 232, 240, 0.9)' } }} - sx={{ - input: { color: '#f8fafc' }, - '& .MuiOutlinedInput-root': { - backgroundColor: 'rgba(15, 23, 42, 0.8)', - '& fieldset': { borderColor: 'rgba(148, 163, 184, 0.3)' }, - '&:hover fieldset': { borderColor: 'rgba(148, 163, 184, 0.5)' }, - '&.Mui-focused fieldset': { borderColor: '#60a5fa' }, - }, - }} - /> - - {message && ( - - {message} - - )} - - Already have an account? Login here - - -
-
-
-
- ); -}; - -export default Register; diff --git a/frontend/src/components/RegistrationInviteRequired.tsx b/frontend/src/components/RegistrationInviteRequired.tsx index 9eca4d1af0..4d1cab8bbc 100644 --- a/frontend/src/components/RegistrationInviteRequired.tsx +++ b/frontend/src/components/RegistrationInviteRequired.tsx @@ -3,13 +3,15 @@ import { Link, useNavigate } from 'react-router-dom'; import axios from '../utils/axiosConfig'; import { useTranslation } from 'react-i18next'; import V2AuthBrand from '../v2/components/V2AuthBrand'; +import V2AuthNotice from '../v2/components/V2AuthNotice'; import { registerPathWithInvite } from '../v2/utils/pendingInvite'; // v2-native invite-required gate. Pairs with V2Login / V2Register (reuses the // .v2-login card styles) so every auth surface matches after v2 became the // default. Two forms in one card: enter an invite code → hand off to -// /v2/register, or submit a waitlist request for admin review. Logic is -// unchanged from the legacy MUI version — only the presentation is v2. +// /v2/register, or submit a waitlist request for admin review. The layout is +// the d215 invitation-required frame: the code first, the waitlist below the +// "or" rule, its result in one row above its key. const RegistrationInviteRequired: React.FC = () => { const { t } = useTranslation(); @@ -53,7 +55,7 @@ const RegistrationInviteRequired: React.FC = () => { return (
-
+

{t('auth.inviteRequired.title')}

@@ -76,7 +78,7 @@ const RegistrationInviteRequired: React.FC = () => { -

{t('auth.inviteRequired.joinWaitlist')}
+
{t('auth.inviteRequired.joinWaitlist')}
+ {waitlistError && {waitlistError}} + {waitlistSuccess && {waitlistSuccess}} - {waitlistError &&
{waitlistError}
} - {waitlistSuccess &&
{waitlistSuccess}
}
-
+

{t('auth.register.alreadyHaveAccount')} {' '} {t('auth.login.submit')} -

+

); diff --git a/frontend/src/components/VerifyEmail.test.tsx b/frontend/src/components/VerifyEmail.test.tsx deleted file mode 100644 index 7ceb22cd2a..0000000000 --- a/frontend/src/components/VerifyEmail.test.tsx +++ /dev/null @@ -1,51 +0,0 @@ -// @ts-nocheck -import React from 'react'; -import ReactDOM from 'react-dom/client'; -import TestUtils from 'react-dom/test-utils'; -import VerifyEmail from './VerifyEmail'; -import { useSearchParams } from 'react-router-dom'; -const axios = require('axios').default; - -jest.mock('axios', () => ({ - __esModule: true, - default: { get: jest.fn() } -})); -jest.mock('react-router-dom', () => ({ useSearchParams: jest.fn() })); - -let container; -let root; - -beforeEach(() => { - container = document.createElement('div'); - document.body.appendChild(container); - root = ReactDOM.createRoot(container); -}); - -afterEach(() => { - root.unmount(); - container.remove(); - container = null; -}); - -async function renderComponent(token) { - useSearchParams.mockReturnValue([{ get: () => token }]); - await TestUtils.act(async () => { root.render(); }); - await TestUtils.act(async () => Promise.resolve()); -} - -test('verifies email and shows success message', async () => { - axios.get.mockResolvedValueOnce({ data: { message: 'verified' } }); - await renderComponent('t1'); - expect(axios.get).toHaveBeenCalledWith('/api/auth/verify-email?token=t1'); - expect(container.textContent).toContain('verified'); - const loginLink = container.querySelector('a[href="/login"]'); - expect(loginLink).not.toBeNull(); -}); - -test('shows error when verification fails', async () => { - axios.get.mockRejectedValueOnce({ response: { data: { error: 'bad' } } }); - await renderComponent('t2'); - expect(container.textContent).toContain('bad'); - const loginLink = container.querySelector('a[href="/login"]'); - expect(loginLink).not.toBeNull(); -}); diff --git a/frontend/src/components/VerifyEmail.tsx b/frontend/src/components/VerifyEmail.tsx index 83d47f2152..e140119991 100644 --- a/frontend/src/components/VerifyEmail.tsx +++ b/frontend/src/components/VerifyEmail.tsx @@ -1,62 +1,70 @@ -import React, { useEffect, useState } from 'react'; -import axios from 'axios'; -import { useSearchParams } from 'react-router-dom'; -import { Box, Button, Typography } from '@mui/material'; +import React, { useEffect, useRef, useState } from 'react'; +import { useNavigate, useSearchParams } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; +import axios from '../utils/axiosConfig'; +import V2AuthBrand from '../v2/components/V2AuthBrand'; +import V2AuthNotice from '../v2/components/V2AuthNotice'; + +// The link in the verification email lands here (/verify-email?token=…, sent +// on to /v2/verify-email). This was the last navy MUI card on the auth routes; +// it is now the d215 auth card: one "verifying" line, then the server's own +// answer in the notice row. A verified address goes on with the filled key; a +// failure's way out is a plain key, since signing in is not the next step. + +interface Result { + ok: boolean; + message?: string; +} const VerifyEmail: React.FC = () => { const { t } = useTranslation(); - const [message, setMessage] = useState(''); + const navigate = useNavigate(); const [searchParams] = useSearchParams(); const token = searchParams.get('token'); + const [result, setResult] = useState(null); + // A token is spent by its first use, so it is sent once: not again when the + // language changes, and not twice under StrictMode's double effect. + const sent = useRef(null); useEffect(() => { - if (token) { - axios - .get<{ message: string }>(`/api/auth/verify-email?token=${token}`) - .then((res) => setMessage(res.data.message)) - .catch((err: unknown) => { - const e = err as { response?: { data?: { error?: string } } }; - setMessage(e.response?.data?.error || t('auth.verify.errors.failed')); - }); + if (!token) { + setResult({ ok: false }); + return; } - }, [t, token]); + if (sent.current === token) return; + sent.current = token; + axios + .get<{ message?: string }>(`/api/auth/verify-email?token=${encodeURIComponent(token)}`) + .then((res) => setResult({ ok: true, message: res.data?.message })) + .catch((err: unknown) => { + const e = err as { response?: { data?: { error?: string } } }; + setResult({ ok: false, message: e.response?.data?.error }); + }); + }, [token]); return ( - - - - {t('auth.verify.title')} - - - {message || t('auth.verify.verifying')} - - {Boolean(message) && ( - +
+
+ +

{t('auth.verify.title')}

+ {!result ? ( +

{t('auth.verify.verifying')}

+ ) : ( + <> + + {result.message || (result.ok ? t('auth.verify.verified') : t('auth.verify.errors.failed'))} + + + )} - - +
+
); }; diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index c2aa0c16bf..c5aa10f9ea 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -357,7 +357,8 @@ "auth": { "fields": { "email": "Email", - "password": "Password" + "password": "Password", + "optional": "optional" }, "verificationBanner": { "message": "Verify your email to join the Community pod — link sent to {{email}}.", @@ -391,9 +392,7 @@ }, "register": { "title": "Create your account", - "subtitle": "Join the shared space where agents and humans collaborate.", - "invitationCode": "Invitation code (optional)", - "invitationCodeRequired": "Invitation code", + "invitationCode": "Invitation code", "invitationPlaceholder": "Unlocks hiring from the catalog", "invitationHint": "No code? Leave blank — you can still run an agent here or bring your own, and redeem a code later in Settings.", "submit": "Create account", @@ -458,8 +457,8 @@ "code": "Invitation code", "continue": "Continue to registration", "joinWaitlist": "Need access? Join the waitlist", - "nameOptional": "Name (optional)", - "useCaseOptional": "Use case (optional)", + "name": "Name", + "useCase": "Use case", "submitting": "Submitting…", "requestAccess": "Request waitlist access", "waitlistSubmitted": "Waitlist request submitted.", @@ -468,9 +467,10 @@ } }, "verify": { - "title": "Email Verification", - "verifying": "Verifying your email...", - "goToLogin": "Go to Login", + "title": "Verify your email", + "verifying": "Verifying your email…", + "verified": "Email verified.", + "goToSignIn": "Go to sign in", "errors": { "failed": "Verification failed." } diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json index d1bb362c31..595f37c7e1 100644 --- a/frontend/src/i18n/locales/zh-CN.json +++ b/frontend/src/i18n/locales/zh-CN.json @@ -357,7 +357,8 @@ "auth": { "fields": { "email": "邮箱", - "password": "密码" + "password": "密码", + "optional": "选填" }, "verificationBanner": { "message": "验证邮箱后即可加入 Community Pod,链接已发送至 {{email}}。", @@ -391,9 +392,7 @@ }, "register": { "title": "注册", - "subtitle": "加入这个让智能体与人共同协作的空间。", - "invitationCode": "邀请码(选填)", - "invitationCodeRequired": "邀请码", + "invitationCode": "邀请码", "invitationPlaceholder": "解锁应用市场中的智能体", "invitationHint": "没有邀请码?可以留空——你仍可选择「在这里运行」或接入自己的智能体,之后再到设置中兑换邀请码。", "submit": "注册", @@ -458,8 +457,8 @@ "code": "邀请码", "continue": "继续注册", "joinWaitlist": "没有邀请?加入候补名单", - "nameOptional": "姓名(选填)", - "useCaseOptional": "使用场景(选填)", + "name": "姓名", + "useCase": "使用场景", "submitting": "正在提交…", "requestAccess": "申请候补资格", "waitlistSubmitted": "候补申请已提交。", @@ -468,9 +467,10 @@ } }, "verify": { - "title": "邮箱验证", + "title": "验证邮箱", "verifying": "正在验证邮箱…", - "goToLogin": "前往登录", + "verified": "邮箱已验证。", + "goToSignIn": "前往登录", "errors": { "failed": "验证失败。" } diff --git a/frontend/src/v2/__tests__/V2AuthPagesLayout.test.tsx b/frontend/src/v2/__tests__/V2AuthPagesLayout.test.tsx new file mode 100644 index 0000000000..3100650906 --- /dev/null +++ b/frontend/src/v2/__tests__/V2AuthPagesLayout.test.tsx @@ -0,0 +1,311 @@ +// @ts-nocheck +// Sign-up and account recovery follow the d215 sign-up frames: one alert or +// status row (V2AuthNotice) that marks only the fields it is about, providers +// before the form, "optional" in the label, and a plain key for a second path. +import fs from 'fs'; +import path from 'path'; +import React from 'react'; +import { act, fireEvent, render, screen } from '@testing-library/react'; +import { MemoryRouter, Routes, Route, useLocation } from 'react-router-dom'; +import V2Register from '../components/V2Register'; +import V2ForgotPassword from '../components/V2ForgotPassword'; +import V2ResetPassword from '../components/V2ResetPassword'; +import RegistrationInviteRequired from '../../components/RegistrationInviteRequired'; +import VerifyEmail from '../../components/VerifyEmail'; +import { AuthContext } from '../../context/AuthContext'; + +const axios = require('axios').default; + +jest.mock('axios', () => { + const mock = { + get: jest.fn(), + post: jest.fn(), + patch: jest.fn(), + delete: jest.fn(), + defaults: { baseURL: '', headers: { common: {} } }, + interceptors: { + request: { use: jest.fn(), eject: jest.fn() }, + response: { use: jest.fn(), eject: jest.fn() }, + }, + }; + return { __esModule: true, default: mock, ...mock }; +}); + +const PROVIDERS = { data: { providers: [{ id: 'github', label: 'GitHub' }, { id: 'google', label: 'Google' }] } }; + +// Answers the two GETs every sign-up render makes; `rest` handles anything else. +const routeGets = (policy = { inviteOnly: false }, rest = () => Promise.reject(new Error('unexpected GET'))) => { + axios.get.mockImplementation((url) => { + if (url.includes('/api/auth/oauth/providers') || url.includes('providers')) return Promise.resolve(PROVIDERS); + if (url.includes('registration-policy')) return Promise.resolve({ data: policy }); + return rest(url); + }); +}; + +const Where = () => { + const { pathname, search } = useLocation(); + return
{`at ${pathname}${search}`}
; +}; + +const mount = (element, entry, login = jest.fn().mockRejectedValue(new Error('no session'))) => render( + + + + + } /> + + + , +); + +const before = (a: Node, b: Node) => Boolean(a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING); + +afterEach(() => { jest.clearAllMocks(); localStorage.clear(); sessionStorage.clear(); }); + +describe('sign up', () => { + const fill = () => { + fireEvent.change(screen.getByLabelText(/^email$/i), { target: { value: 'alex@studio.dev' } }); + fireEvent.change(screen.getByLabelText(/^password$/i), { target: { value: 'longenough' } }); + }; + + test('providers come first, then "or", then the form; no subtitle', async () => { + routeGets(); + mount(, '/v2/register'); + const google = await screen.findByRole('link', { name: /continue with google/i }); + expect(before(google, screen.getByText('or'))).toBe(true); + expect(before(screen.getByText('or'), screen.getByLabelText(/^email$/i))).toBe(true); + expect(screen.queryByText(/shared space where agents and humans/i)).toBeNull(); + }); + + test('the optional code says so in its label, under open registration only', async () => { + routeGets(); + mount(, '/v2/register'); + const code = await screen.findByLabelText(/invitation code/i); + expect(code.closest('label').querySelector('em')).toHaveTextContent('optional'); + }); + + test('under invite-only the code label carries no "optional"', async () => { + routeGets({ inviteOnly: true }); + mount(, '/v2/register#invite=abc123'); + const code = await screen.findByLabelText(/invitation code/i); + expect(code.closest('label').querySelector('em')).toBeNull(); + }); + + test('a failed sign-up is one alert at the top that marks email and password', async () => { + routeGets(); + axios.post.mockRejectedValue({ response: { data: { error: 'Registration failed.' } } }); + mount(, '/v2/register'); + const github = await screen.findByRole('link', { name: /continue with github/i }); + fill(); + await act(async () => { fireEvent.click(screen.getByRole('button', { name: /create account/i })); }); + const alert = screen.getByRole('alert'); + expect(alert).toHaveTextContent('Registration failed.'); + expect(before(alert, github)).toBe(true); + expect(screen.getByLabelText(/^email$/i)).toHaveAttribute('aria-invalid', 'true'); + expect(screen.getByLabelText(/^password$/i)).toHaveAttribute('aria-invalid', 'true'); + expect(screen.getByLabelText(/invitation code/i)).not.toHaveAttribute('aria-invalid'); + }); + + test('a rejected code marks the code, not the email and password', async () => { + routeGets(); + axios.post.mockRejectedValue({ response: { data: { code: 'INVITATION_INVALID' } } }); + mount(, '/v2/register'); + await screen.findByLabelText(/invitation code/i); + fill(); + await act(async () => { fireEvent.click(screen.getByRole('button', { name: /create account/i })); }); + expect(screen.getByLabelText(/invitation code/i)).toHaveAttribute('aria-invalid', 'true'); + expect(screen.getByLabelText(/^email$/i)).not.toHaveAttribute('aria-invalid'); + }); + + test('terms sit under the key, and the last line signs in keeping the deep link', async () => { + routeGets(); + mount(, '/v2/register?next=%2Fv2%2Fpods%2Fp1'); + const signIn = await screen.findByRole('link', { name: /^sign in$/i }); + const terms = screen.getByRole('link', { name: /terms/i }); + expect(before(screen.getByRole('button', { name: /create account/i }), terms)).toBe(true); + expect(before(terms, signIn)).toBe(true); + expect(signIn).toHaveAttribute('href', '/v2/login?next=%2Fv2%2Fpods%2Fp1'); + }); + + test('the check-your-email screen keeps the reminder line and adds the key on', async () => { + routeGets(); + axios.post.mockResolvedValue({ data: { message: 'Registered. Check your email to verify.' } }); + mount(, '/v2/register'); + await screen.findByLabelText(/invitation code/i); + fill(); + await act(async () => { fireEvent.click(screen.getByRole('button', { name: /create account/i })); }); + expect(screen.getByRole('heading', { name: /you're in/i })).toBeInTheDocument(); + expect(screen.getByRole('link', { name: /sign in now/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /continue to sign in/i })).toBeInTheDocument(); + }); +}); + +describe('invitation required', () => { + test('the code comes first, the waitlist sits below the "or" rule, its key is plain', async () => { + mount(, '/v2/register/invite-required'); + const code = screen.getByLabelText(/^invitation code$/i); + const rule = screen.getByText(/need access\? join the waitlist/i); + expect(rule).toHaveClass('v2-login__or'); + expect(before(code, rule)).toBe(true); + expect(before(rule, screen.getByLabelText(/^email$/i))).toBe(true); + expect(screen.getByLabelText(/^name/i).closest('label').querySelector('em')).toHaveTextContent('optional'); + expect(screen.getByLabelText(/^use case/i).closest('label').querySelector('em')).toHaveTextContent('optional'); + expect(screen.getByRole('button', { name: /request waitlist access/i })).toHaveClass('v2-login__submit--ghost'); + }); + + test('a sent request is a status row above its key, and the address stays for a second send', async () => { + axios.post.mockResolvedValue({ data: {} }); + mount(, '/v2/register/invite-required'); + fireEvent.change(screen.getByLabelText(/^email$/i), { target: { value: 'alex@studio.dev' } }); + await act(async () => { fireEvent.click(screen.getByRole('button', { name: /request waitlist access/i })); }); + const status = screen.getByRole('status'); + expect(status).toHaveTextContent('Waitlist request submitted.'); + expect(before(status, screen.getByRole('button', { name: /request waitlist access/i }))).toBe(true); + expect(screen.getByLabelText(/^email$/i)).toHaveValue('alex@studio.dev'); + }); + + test('a failed request is an alert row', async () => { + axios.post.mockRejectedValue({ response: { data: { error: 'Nope' } } }); + mount(, '/v2/register/invite-required'); + fireEvent.change(screen.getByLabelText(/^email$/i), { target: { value: 'alex@studio.dev' } }); + await act(async () => { fireEvent.click(screen.getByRole('button', { name: /request waitlist access/i })); }); + expect(screen.getByRole('alert')).toHaveTextContent('Nope'); + }); +}); + +describe('verify email', () => { + test('a verified address shows the server line as a status and goes on with the filled key', async () => { + axios.get.mockResolvedValue({ data: { message: 'Email verified successfully' } }); + mount(, '/v2/verify-email?token=a+b/c'); + expect(screen.getByText(/verifying your email/i)).toBeInTheDocument(); + expect(await screen.findByRole('status')).toHaveTextContent('Email verified successfully'); + expect(axios.get).toHaveBeenCalledWith('/api/auth/verify-email?token=a%20b%2Fc'); + const go = screen.getByRole('button', { name: /go to sign in/i }); + expect(go).not.toHaveClass('v2-login__submit--ghost'); + fireEvent.click(go); + expect(screen.getByText('at /v2/login')).toBeInTheDocument(); + }); + + test('a failure is the alert row, and the way out is a plain key', async () => { + axios.get.mockRejectedValue({ response: { data: { error: 'Invalid or expired token' } } }); + mount(, '/v2/verify-email?token=t2'); + expect(await screen.findByRole('alert')).toHaveTextContent('Invalid or expired token'); + expect(screen.getByRole('button', { name: /go to sign in/i })).toHaveClass('v2-login__submit--ghost'); + }); + + test('without a token it fails at once and asks nothing of the server', () => { + mount(, '/v2/verify-email'); + expect(screen.getByRole('alert')).toHaveTextContent('Verification failed.'); + expect(axios.get).not.toHaveBeenCalled(); + }); + + test('a token is sent once, even under StrictMode', async () => { + axios.get.mockResolvedValue({ data: { message: 'ok' } }); + mount(, '/v2/verify-email?token=once'); + await screen.findByRole('status'); + expect(axios.get).toHaveBeenCalledTimes(1); + }); +}); + +describe('forgot password', () => { + test('a failure is the alert above the field, and marks it', async () => { + axios.post.mockRejectedValue({}); + mount(, '/v2/forgot-password'); + fireEvent.change(screen.getByLabelText(/^email$/i), { target: { value: 'alex@studio.dev' } }); + await act(async () => { fireEvent.click(screen.getByRole('button', { name: /send reset link/i })); }); + const alert = screen.getByRole('alert'); + expect(before(alert, screen.getByLabelText(/^email$/i))).toBe(true); + expect(screen.getByLabelText(/^email$/i)).toHaveAttribute('aria-invalid', 'true'); + }); + + test('once sent, the form goes and one status row says so', async () => { + axios.post.mockResolvedValue({ data: {} }); + mount(, '/v2/forgot-password'); + fireEvent.change(screen.getByLabelText(/^email$/i), { target: { value: 'alex@studio.dev' } }); + await act(async () => { fireEvent.click(screen.getByRole('button', { name: /send reset link/i })); }); + expect(screen.getByRole('status')).toHaveTextContent(/valid for one hour/i); + expect(screen.queryByLabelText(/^email$/i)).toBeNull(); + expect(screen.getByRole('link', { name: /sign in/i })).toHaveAttribute('href', '/v2/login'); + }); +}); + +describe('reset password', () => { + const fill = (a, b) => { + fireEvent.change(screen.getByLabelText(/^new password$/i), { target: { value: a } }); + fireEvent.change(screen.getByLabelText(/^confirm password$/i), { target: { value: b } }); + }; + + test('a mismatch rings both fields, with no link to a new email', async () => { + mount(, '/v2/reset-password?token=t'); + fill('longenough1', 'longenough2'); + await act(async () => { fireEvent.click(screen.getByRole('button', { name: /update password/i })); }); + expect(screen.getByRole('alert')).toHaveTextContent('Passwords do not match.'); + expect(screen.getByLabelText(/^new password$/i)).toHaveAttribute('aria-invalid', 'true'); + expect(screen.getByLabelText(/^confirm password$/i)).toHaveAttribute('aria-invalid', 'true'); + expect(screen.queryByRole('link', { name: /request a new link/i })).toBeNull(); + expect(axios.post).not.toHaveBeenCalled(); + }); + + test('an expired link carries its recovery link in the alert and leaves the fields alone', async () => { + axios.post.mockRejectedValue({}); + mount(, '/v2/reset-password?token=t'); + fill('longenough1', 'longenough1'); + await act(async () => { fireEvent.click(screen.getByRole('button', { name: /update password/i })); }); + const alert = screen.getByRole('alert'); + expect(alert).toHaveTextContent(/the link may have expired/i); + expect(alert.querySelector('a')).toHaveAttribute('href', '/v2/forgot-password'); + expect(screen.getByLabelText(/^new password$/i)).not.toHaveAttribute('aria-invalid'); + }); + + test('updated is a status row and the filled key goes to sign in', async () => { + axios.post.mockResolvedValue({ data: {} }); + mount(, '/v2/reset-password?token=t'); + fill('longenough1', 'longenough1'); + await act(async () => { fireEvent.click(screen.getByRole('button', { name: /update password/i })); }); + expect(screen.getByRole('status')).toHaveTextContent(/password updated/i); + fireEvent.click(screen.getByRole('button', { name: /go to sign in/i })); + expect(screen.getByText('at /v2/login')).toBeInTheDocument(); + }); + + test('opened without the email link, it is one line and one key to request one', () => { + mount(, '/v2/reset-password'); + expect(screen.queryByLabelText(/^new password$/i)).toBeNull(); + fireEvent.click(screen.getByRole('button', { name: /send me a reset link/i })); + expect(screen.getByText('at /v2/forgot-password')).toBeInTheDocument(); + }); +}); + +describe('auth card styles', () => { + const v2 = fs.readFileSync(path.join(__dirname, '../v2.css'), 'utf8').replace(/\/\*[\s\S]*?\*\//g, ''); + const rule = (sel: string) => { + const i = v2.indexOf(`\n${sel} {`); + expect(i).toBeGreaterThan(-1); + return v2.slice(i, v2.indexOf('}', i)); + }; + + test('a form inside a lifted card lends its rows to the card grid', () => { + expect(rule('.v2-login__card--stack > form')).toMatch(/display: contents/); + }); + + test('the second path is a key, not a second fill', () => { + const ghost = rule('.v2-root button.v2-login__submit--ghost'); + expect(ghost).toMatch(/linear-gradient\(180deg, var\(--v2-key-a\), var\(--v2-key-b\)\)/); + expect(ghost).toMatch(/border-color: var\(--v2-key-edge\)/); + }); + + test('"optional" in a label is the muted mono tag at the far end of the label', () => { + expect(rule('.v2-login__label')).toMatch(/justify-content: space-between/); + const em = rule('.v2-login__label em'); + expect(em).toMatch(/var\(--v2-font-mono\)/); + expect(em).toMatch(/font-style: normal/); + }); + + test('errors and confirmations share one row; the green success box and the bordered divider are gone', () => { + expect(rule('.v2-login__notice')).toMatch(/box-shadow: inset 0 0 0 1px var\(--v2-border\)/); + expect(v2).not.toMatch(/\.v2-login__(error|success|divider)\b/); + }); +}); diff --git a/frontend/src/v2/components/V2AuthNotice.tsx b/frontend/src/v2/components/V2AuthNotice.tsx new file mode 100644 index 0000000000..9775617d9b --- /dev/null +++ b/frontend/src/v2/components/V2AuthNotice.tsx @@ -0,0 +1,16 @@ +import React from 'react'; +import { ShellGlyph } from '../icons/glyphs'; + +// The one message row on every auth card, drawn in the d215 sign-in and +// sign-up frames: an alert for a failure, a status for a confirmation. The +// tint is the same; the glyph and the role tell them apart, never a colour. +const V2AuthNotice: React.FC<{ kind?: 'alert' | 'status'; children: React.ReactNode }> = ({ + kind = 'alert', children, +}) => ( +
+ + {children} +
+); + +export default V2AuthNotice; diff --git a/frontend/src/v2/components/V2ForgotPassword.tsx b/frontend/src/v2/components/V2ForgotPassword.tsx index 015cb1b0c6..928d7aa3ae 100644 --- a/frontend/src/v2/components/V2ForgotPassword.tsx +++ b/frontend/src/v2/components/V2ForgotPassword.tsx @@ -2,11 +2,14 @@ import React, { useState } from 'react'; import { Link } from 'react-router-dom'; import axios from '../../utils/axiosConfig'; import V2AuthBrand from './V2AuthBrand'; +import V2AuthNotice from './V2AuthNotice'; import { Trans, useTranslation } from 'react-i18next'; // Forgot-password request form. The backend always answers generically // (no account enumeration), so the success state is unconditional once -// the request lands. +// the request lands. Layout: the d215 forgot-password frames. The +// instructions are the page's one prose line; after sending, the form goes and +// one status row says so. const V2ForgotPassword: React.FC = () => { const { t } = useTranslation(); const [email, setEmail] = useState(''); @@ -31,26 +34,25 @@ const V2ForgotPassword: React.FC = () => { return (
-
+

{t('auth.forgot.title')}

{done ? ( <> -

- {t('auth.forgot.sent')} -

-
+ {t('auth.forgot.sent')} +

}} /> -

+

) : ( <>

{t('auth.forgot.instructions')}

+ {error && {error}} - {error &&
{error}
} -
+

}} /> -

+

)} diff --git a/frontend/src/v2/components/V2Login.tsx b/frontend/src/v2/components/V2Login.tsx index 60791a5608..0e7b6ef0ce 100644 --- a/frontend/src/v2/components/V2Login.tsx +++ b/frontend/src/v2/components/V2Login.tsx @@ -6,7 +6,7 @@ import V2OAuthButtons from './V2OAuthButtons'; import V2AuthBrand from './V2AuthBrand'; import { pathOfLocation, safeNextPath } from '../utils/safeNextPath'; import { forgetInviteOAuthStart } from '../utils/pendingInvite'; -import { ShellGlyph } from '../icons/glyphs'; +import V2AuthNotice from './V2AuthNotice'; interface LocationState { from?: { pathname?: string; search?: string; hash?: string }; @@ -88,10 +88,7 @@ const V2Login: React.FC = () => {

{t('auth.login.title')}

{errorMessage && ( -
- - {errorMessage} -
+ {errorMessage} )} diff --git a/frontend/src/v2/components/V2Register.tsx b/frontend/src/v2/components/V2Register.tsx index 4b45539c73..8605473863 100644 --- a/frontend/src/v2/components/V2Register.tsx +++ b/frontend/src/v2/components/V2Register.tsx @@ -5,6 +5,7 @@ import { import axios from '../../utils/axiosConfig'; import V2OAuthButtons from './V2OAuthButtons'; import V2AuthBrand from './V2AuthBrand'; +import V2AuthNotice from './V2AuthNotice'; import { Trans, useTranslation } from 'react-i18next'; import { useAuth } from '../../context/AuthContext'; import { safeNextPath } from '../utils/safeNextPath'; @@ -23,6 +24,8 @@ import { // // The .v2-login__card class goes on the
/
directly (like V2Login) — // a bare picks up a dark global background, so it must carry the card. +// The layout follows the d215 sign-up frames: providers first, then the form; +// one alert row at the top that marks the fields it is about. interface RegistrationPolicy { loaded: boolean; @@ -54,6 +57,9 @@ const V2Register: React.FC = () => { const loginHref = nextPath ? `/v2/login?next=${encodeURIComponent(nextPath)}` : '/v2/login'; const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(null); + // Which fields the error is about: an invitation error is about the code, + // anything else about the email and password. + const [errorField, setErrorField] = useState<'credentials' | 'invite' | null>(null); const [done, setDone] = useState(null); const [verifyPending, setVerifyPending] = useState(false); const [teamVerificationPending, setTeamVerificationPending] = useState(false); @@ -84,6 +90,7 @@ const V2Register: React.FC = () => { const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); setError(null); + setErrorField(null); setTeamVerificationPending(false); setSubmitting(true); try { @@ -135,10 +142,13 @@ const V2Register: React.FC = () => { // Forget the bad code so a reload doesn't carry it back into the form. forgetInvite(); setError(t('auth.register.errors.invitationInvalid')); + setErrorField('invite'); } else if (code === 'INVITATION_REQUIRED') { setError(t('auth.register.errors.invitationRequired')); + setErrorField('invite'); } else { setError(e1.response?.data?.error || e1.response?.data?.msg || t('auth.register.errors.failed')); + setErrorField('credentials'); } } finally { setSubmitting(false); @@ -148,7 +158,7 @@ const V2Register: React.FC = () => { if (done) { return (
-
+

{verifyPending ? t('auth.register.success.checkEmailTitle') : t('auth.register.success.createdTitle')} @@ -158,7 +168,7 @@ const V2Register: React.FC = () => { ? t('auth.register.success.checkEmailMessage', { email: email.trim() }) : t('auth.register.success.ready')}

- {verifyPending ? ( + {verifyPending && (

{ components={{ signIn: }} />

- ) : ( - )} +

); } + const credentialsBad = errorField === 'credentials' || undefined; + return (
- +

{t('auth.register.title')}

-

- {t('auth.register.subtitle')} -

+ + {error && {error}} + + @@ -210,6 +223,7 @@ const V2Register: React.FC = () => { autoComplete="new-password" value={password} onChange={(e) => setPassword(e.target.value)} + aria-invalid={credentialsBad} required /> @@ -223,7 +237,8 @@ const V2Register: React.FC = () => { {policy.loaded && (
); diff --git a/frontend/src/v2/components/V2ResetPassword.tsx b/frontend/src/v2/components/V2ResetPassword.tsx index c82302ec35..361669ff04 100644 --- a/frontend/src/v2/components/V2ResetPassword.tsx +++ b/frontend/src/v2/components/V2ResetPassword.tsx @@ -1,12 +1,16 @@ import React, { useState } from 'react'; -import { Link, useSearchParams } from 'react-router-dom'; +import { Link, useNavigate, useSearchParams } from 'react-router-dom'; import axios from '../../utils/axiosConfig'; import V2AuthBrand from './V2AuthBrand'; +import V2AuthNotice from './V2AuthNotice'; import { Trans, useTranslation } from 'react-i18next'; // Landing pad for the emailed reset link (/v2/reset-password?token=...). // Token consumption is server-side; a used or expired token surfaces the // backend's "Invalid or expired reset link" with a path back to /forgot. +// Layout: the d215 new-password frames. A check the page makes itself (too +// short, mismatch) rings both fields; a server failure carries its recovery +// link inside the alert and leaves the fields alone. const V2ResetPassword: React.FC = () => { const { t } = useTranslation(); const [searchParams] = useSearchParams(); @@ -16,16 +20,21 @@ const V2ResetPassword: React.FC = () => { const [submitting, setSubmitting] = useState(false); const [done, setDone] = useState(false); const [error, setError] = useState(null); + const [fieldsBad, setFieldsBad] = useState(false); + const navigate = useNavigate(); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); setError(null); + setFieldsBad(false); if (password.length < 8) { setError(t('auth.reset.errors.tooShort')); + setFieldsBad(true); return; } if (password !== confirm) { setError(t('auth.reset.errors.mismatch')); + setFieldsBad(true); return; } setSubmitting(true); @@ -40,29 +49,45 @@ const V2ResetPassword: React.FC = () => { } }; + const marked = fieldsBad || undefined; + return (
-
+

{t('auth.reset.title')}

{done ? ( <> -

{t('auth.reset.updated')}

- + {t('auth.reset.updated')} + ) : !token ? ( <>

{t('auth.reset.missingToken')}

-
- {t('auth.reset.sendNewLink')} -
+ ) : ( <> + {error && ( + + {error} + {!fieldsBad && ( + <> + {' '} + }} + /> + + )} + + )} @@ -82,22 +108,13 @@ const V2ResetPassword: React.FC = () => { autoComplete="new-password" value={confirm} onChange={(e) => setConfirm(e.target.value)} + aria-invalid={marked} required /> - {error && ( -
- {error} - {' '} - }} - /> -
- )} )} diff --git a/frontend/src/v2/v2.css b/frontend/src/v2/v2.css index aa80558c0c..55d1bb16dd 100644 --- a/frontend/src/v2/v2.css +++ b/frontend/src/v2/v2.css @@ -1979,19 +1979,40 @@ body.modern-ui.v2-canvas { box-shadow: var(--v2-shadow-pane); } -/* Sign-in stacks its rows on one 16px rhythm, as drawn. Other auth pages - still space their rows with margins until they are lifted. */ +/* Each auth card stacks its rows on one 16px rhythm, as drawn. A form inside + the card (invite-required has two) lends its rows to that grid. */ .v2-login__card--stack { display: grid; gap: 16px; } +.v2-login__card--stack > form { + display: contents; +} .v2-login__card--stack .v2-login__brand, .v2-login__card--stack .v2-login__field, .v2-login__card--stack .v2-login__or, .v2-login__card--stack .v2-login__oauth, -.v2-login__card--stack .v2-login__error { +.v2-login__card--stack .v2-login__notice, +.v2-login__card--stack .v2-login__subtitle, +.v2-login__card--stack .v2-login__hint, +.v2-login__card--stack .v2-login__legal, +.v2-login__card--stack .v2-login__field-hint { margin: 0; } +/* The one prose line a page keeps (the frames' .sub), and a plain hint + under it: no box on a lifted card. */ +.v2-login__card--stack .v2-login__subtitle { + font-size: 13.5px; + line-height: 1.5; +} +.v2-login__card--stack .v2-login__hint { + padding: 0; + border-radius: 0; + background: none; + font-size: 13px; + line-height: 1.5; + color: var(--v2-text-secondary); +} .v2-login__card--stack .v2-login__title { margin: 4px 0 0; } @@ -2043,11 +2064,22 @@ body.modern-ui.v2-canvas { } .v2-login__label { + display: flex; + align-items: baseline; + justify-content: space-between; font-size: 12.5px; font-weight: 600; color: var(--v2-text-primary); } +/* "optional" rides in the label, not the field, at its far end. */ +.v2-login__label em { + margin-left: 8px; + font: 500 11px/1 var(--v2-font-mono); + font-style: normal; + color: var(--v2-text-muted); +} + /* "Forgot password?" sits on the password label's line. */ .v2-login__label-row { display: flex; @@ -2104,8 +2136,9 @@ body.modern-ui.v2-canvas { cursor: not-allowed; } -/* One quiet row with the alert mark, as the auth frames draw errors. */ -.v2-login__error { +/* One quiet row, as the auth frames draw both errors (alert mark) and + confirmations (check mark). V2AuthNotice renders it. */ +.v2-login__notice { display: flex; align-items: flex-start; gap: 10px; @@ -2119,7 +2152,7 @@ body.modern-ui.v2-canvas { line-height: 19px; } -.v2-login__error svg { +.v2-login__notice svg { flex: none; margin-top: 1.5px; } @@ -2130,10 +2163,8 @@ body.modern-ui.v2-canvas { text-align: right; } -/* "or" separator between password form and social buttons. NOT - .v2-login__divider — that name is already taken by the invite-required - page's bordered section break (defined later in this file, so it would - win the cascade and stack a second hairline on top of this one). */ +/* The "or" rule between the providers and the form. On invite-required it + carries the waitlist line in place of "or". */ .v2-login__or { display: flex; align-items: center; @@ -2194,10 +2225,18 @@ body.modern-ui.v2-canvas { display: block; margin-top: 6px; font-size: 12px; - line-height: 1.45; + line-height: 1.5; color: var(--v2-text-tertiary); } +/* The terms line: muted, under the key. */ +.v2-login__legal { + font-size: 12px; + line-height: 1.5; + color: var(--v2-text-muted); + text-align: center; +} + .v2-login__hint { margin-top: 18px; padding: 10px 12px; @@ -2232,7 +2271,10 @@ body.modern-ui.v2-canvas { color: var(--v2-text-secondary); } -.v2-root .v2-login__alt a.v2-login__link { +.v2-root .v2-login__alt a.v2-login__link, +.v2-root .v2-login__legal a.v2-login__link, +.v2-root .v2-login__hint a.v2-login__link, +.v2-root .v2-login__notice a.v2-login__link { font-weight: 600; } .v2-root a.v2-login__link:hover { @@ -2251,31 +2293,20 @@ body.modern-ui.v2-canvas { margin: 0; box-shadow: none; } -.v2-login__success { - margin-top: 10px; - padding: 8px 12px; - border-radius: var(--v2-radius); - background: var(--v2-success-soft); - color: var(--v2-success); - font-size: 12px; -} +/* The second path on a page is a key, not a second fill: the waitlist, and + the way out of a failed verification. */ .v2-root button.v2-login__submit--ghost { - background: transparent; + border-color: var(--v2-key-edge); + background: linear-gradient(180deg, var(--v2-key-a), var(--v2-key-b)); + box-shadow: var(--v2-key-inset); color: var(--v2-text-primary); - border: 1px solid var(--v2-border); } .v2-root button.v2-login__submit--ghost:hover:not(:disabled) { - background: var(--v2-surface-hover); + background: linear-gradient(180deg, var(--v2-key-a), var(--v2-key-b)); border-color: var(--v2-border-strong); } -.v2-login__divider { - margin: 22px 0 14px; - padding-top: 18px; - border-top: 1px solid var(--v2-border-soft); - font-size: 13px; - font-weight: 600; - color: var(--v2-text-secondary); - text-align: left; +.v2-root button.v2-login__submit--ghost:focus-visible { + box-shadow: var(--v2-key-inset), var(--v2-focus-ring); } /* The 390 auth frames are full-bleed: the card is the screen. This block sits From 9c943a3050828d1bda44b7d105aa5e460ee0410d Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sun, 1 Nov 2026 17:08:52 -0800 Subject: [PATCH 2/4] fix(v2): auth pages scroll when the card is taller than the window; artboard draws 44px phone controls and tags each recovery screen Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015AKgpMD6Qh13f7fj6mbGT3 --- docs/design/artboards/d215/index.html | 12 +++++++----- .../src/v2/__tests__/V2AuthPagesLayout.test.tsx | 8 ++++++++ frontend/src/v2/v2.css | 14 +++++++++++--- 3 files changed, 26 insertions(+), 8 deletions(-) diff --git a/docs/design/artboards/d215/index.html b/docs/design/artboards/d215/index.html index a6fc5945c9..349d09c284 100644 --- a/docs/design/artboards/d215/index.html +++ b/docs/design/artboards/d215/index.html @@ -713,6 +713,8 @@ .shead.sub2 h3 { margin: 0; font: 700 15px/1.2 var(--f-display); letter-spacing: -0.01em; } .app.m .authpage { background: var(--sf); place-items: start stretch; padding: 0; } .app.m .authcard { width: auto; box-shadow: none; border-radius: 0; padding: 28px 20px; } +/* Phone auth controls keep the 44px touch floor the shipped CSS holds. */ +.app.m .authcard .b, .app.m .authcard .field { height: 44px; }

Same look. Less said.

Sign up and account recovery

/register + /v2/register, /v2/register/invite-required, /verify-email, /v2/forgot-password, /v2/reset-password
register components 2 → 1fields on the page 3 → 3subtitles 5 → 1

Sign up

/register and /v2/register, one page
Sign up · 1440providers first, then three fields
commonly

Create your account

or
Email
alex@studio.dev
Password
••••••••••
Invitation code optional
Unlocks hiring from the catalog
No code? Leave blank — you can still run an agent here or bring your own, and redeem a code later in Settings.
Already have an account? Sign in
Registration failed · 1440server line above the form
commonly

Create your account

or
Email
alex@studio.dev
Password
••••••••••
Invitation code optional
Unlocks hiring from the catalog
No code? Leave blank — you can still run an agent here or bring your own, and redeem a code later in Settings.
Already have an account? Sign in
Sign up · 390
commonly

Create your account

or
Email
alex@studio.dev
Password
••••••••••
Invitation code optional
Unlocks hiring from the catalog
No code? Leave blank — you can still run an agent here or bring your own, and redeem a code later in Settings.
Already have an account? Sign in
Registration failed · 390
commonly

Create your account

or
Email
alex@studio.dev
Password
••••••••••
Invitation code optional
Unlocks hiring from the catalog
No code? Leave blank — you can still run an agent here or bring your own, and redeem a code later in Settings.
Already have an account? Sign in
  • One page replaces both, as with sign in. The tagline under the title goes; the page is its own label.
  • Providers first, then the form. Live draws them below the button; here they follow the sign-in order so the two pages read as one family. The optional code stays, with its one line of help under the field (it says the code is not needed to start).
  • Errors are one row above the form, both fields ringed in ink, as on sign in. The terms line is muted and sits under the key; the sign-in link is the last line.
-

Invitation required

/v2/register/invite-required (and /register/invite-required)
Invitation required · 1440code first, waitlist below
commonly

Invitation required

New account registration is invite-only right now. Enter your invitation code to continue, or join the waitlist for admin review.

Invitation code
Paste your code
Need access? Join the waitlist
Email
alex@studio.dev
Name optional
Alex Rivera
Use case optional
Running agents next to my team
Already have an account? Sign in
Waitlist sent · 1440confirmation above the ghost key
commonly

Invitation required

New account registration is invite-only right now. Enter your invitation code to continue, or join the waitlist for admin review.

Invitation code
Paste your code
Need access? Join the waitlist
Email
alex@studio.dev
Name optional
Alex Rivera
Use case optional
Running agents next to my team
Waitlist request submitted.
Already have an account? Sign in
Invitation required · 390
commonly

Invitation required

New account registration is invite-only right now. Enter your invitation code to continue, or join the waitlist for admin review.

Invitation code
Paste your code
Need access? Join the waitlist
Email
alex@studio.dev
Name optional
Alex Rivera
Use case optional
Running agents next to my team
Already have an account? Sign in
Waitlist sent · 390
commonly

Invitation required

New account registration is invite-only right now. Enter your invitation code to continue, or join the waitlist for admin review.

Invitation code
Paste your code
Need access? Join the waitlist
Email
alex@studio.dev
Name optional
Alex Rivera
Use case optional
Running agents next to my team
Waitlist request submitted.
Already have an account? Sign in
  • One filled key on the page: Continue to registration. The waitlist key is bordered, since it is the second path, not the first.
  • The divider is the existing "or" rule carrying the waitlist line, not a new element. Name and use case are marked optional in the label, not in the field.
  • Submitted is a status row (check, not green), and the form stays so a second address can be sent.
-

Check your email

the register success state, plus /verify-email
Signed in, unverified · 1440reminder banner follows
commonly

You're in

Verify your email to join the Community pod — link sent to alex@studio.dev.

You can sign in now; a reminder stays visible until you verify.

Verify first · 1440team-gated instances
commonly

You're in

Verify your email to join the Community pod — link sent to alex@studio.dev.

Verify your email first, then sign in.

Signed in, unverified · 390
commonly

You're in

Verify your email to join the Community pod — link sent to alex@studio.dev.

You can sign in now; a reminder stays visible until you verify.

Verify first · 390
commonly

You're in

Verify your email to join the Community pod — link sent to alex@studio.dev.

Verify your email first, then sign in.

Account created · 390no verification step
commonly

Account created

Your account is ready. Sign in to continue.

  • Two reads of one screen. Instances that allow sign-in before verifying get the first; team-gated ones get the second. The copy is the live copy: only the verify-to-join-the-Community-pod line, not a wall (Sam, TASK-103).
  • "Account created" is the third case (no email step), kept for completeness; today the screen is picked by matching the server's prose, so this should become a field on the response.
-

Verify email

/verify-email?token=…
Verifying · 1440
commonly

Verify your email

Verifying your email…

Verified · 1440server line
commonly

Verify your email

Email verified successfully
Verifying · 390
commonly

Verify your email

Verifying your email…

Verified · 390
commonly

Verify your email

Email verified successfully
Failed · 390
commonly

Verify your email

  • Today this is a navy MUI card on a #0b1220 page, the one dark flash left on the auth routes (Sam ruled no dark flash, 08-24). Here it is the same card as every other auth page, on the page background.
  • Failure is the shared alert row; the way out is a bordered key, since nothing on this page is the next step to take. The success line is the server's own.
-

Forgot password

/v2/forgot-password
Forgot password · 1440
commonly

Reset your password

Enter your account email and we'll send you a reset link. If you signed up with Google or GitHub, you can also just sign in with them.

Email
alex@studio.dev
Remembered it? Sign in
Reset link sent · 1440
commonly

Reset your password

If that email has an account, a reset link is on its way. The link is valid for one hour.
Back to sign in
Forgot password · 390
commonly

Reset your password

Enter your account email and we'll send you a reset link. If you signed up with Google or GitHub, you can also just sign in with them.

Email
alex@studio.dev
Remembered it? Sign in
Failed · 390
commonly

Reset your password

Enter your account email and we'll send you a reset link. If you signed up with Google or GitHub, you can also just sign in with them.

Email
alex@studio.dev
Remembered it? Sign in
Sent · 390
commonly

Reset your password

If that email has an account, a reset link is on its way. The link is valid for one hour.
Back to sign in
  • The instructions line stays because it carries the one non-obvious fact (provider accounts can just sign in). It is the only prose on the page.
  • After sending, the form goes and one row says it, with the one-hour validity. Back to sign in is the last line, as everywhere.
-

Choose a new password

/v2/reset-password?token=…
New password · 1440
commonly

Choose a new password

New password
••••••••••
Confirm password
••••••••••
Updated · 1440
commonly

Choose a new password

Password updated. You can sign in now.
New password · 390
commonly

Choose a new password

New password
••••••••••
Confirm password
••••••••••
Mismatch · 390
commonly

Choose a new password

New password
••••••••••
Confirm password
••••••••
Link expired · 390request a new one inline
commonly

Choose a new password

New password
••••••••••
Confirm password
••••••••••
No token · 390page opened without the email link
commonly

Choose a new password

This page needs the reset link from your email. Request a new one below.

  • Both fields ring while an error is up, same as sign in. Too short and mismatch share the row; the expired-link failure carries its recovery link inside the sentence.
  • No token is a page of its own: one line and one key, no form to fill in.
+

Invitation required

/v2/register/invite-required (and /register/invite-required)
Invitation required · 1440code first, waitlist below
commonly

Invitation required

New account registration is invite-only right now. Enter your invitation code to continue, or join the waitlist for admin review.

Invitation code
Paste your code
Need access? Join the waitlist
Email
alex@studio.dev
Name optional
Alex Rivera
Use case optional
Running agents next to my team
Already have an account? Sign in
Waitlist sent · 1440confirmation above the ghost key
commonly

Invitation required

New account registration is invite-only right now. Enter your invitation code to continue, or join the waitlist for admin review.

Invitation code
Paste your code
Need access? Join the waitlist
Email
alex@studio.dev
Name optional
Alex Rivera
Use case optional
Running agents next to my team
Waitlist request submitted.
Already have an account? Sign in
Invitation required · 390
commonly

Invitation required

New account registration is invite-only right now. Enter your invitation code to continue, or join the waitlist for admin review.

Invitation code
Paste your code
Need access? Join the waitlist
Email
alex@studio.dev
Name optional
Alex Rivera
Use case optional
Running agents next to my team
Already have an account? Sign in
Waitlist sent · 390
commonly

Invitation required

New account registration is invite-only right now. Enter your invitation code to continue, or join the waitlist for admin review.

Invitation code
Paste your code
Need access? Join the waitlist
Email
alex@studio.dev
Name optional
Alex Rivera
Use case optional
Running agents next to my team
Waitlist request submitted.
Already have an account? Sign in
  • One filled key on the page: Continue to registration. The waitlist key is bordered, since it is the second path, not the first.
  • The divider is the existing "or" rule carrying the waitlist line, not a new element. Name and use case are marked optional in the label, not in the field.
  • Submitted is a status row (check, not green), and the form stays so a second address can be sent.
+

Check your email

the register success state, plus /verify-email
Signed in, unverified · 1440reminder banner follows
commonly

You're in

Verify your email to join the Community pod — link sent to alex@studio.dev.

You can sign in now; a reminder stays visible until you verify.

Verify first · 1440team-gated instances
commonly

You're in

Verify your email to join the Community pod — link sent to alex@studio.dev.

Verify your email first, then sign in.

Signed in, unverified · 390
commonly

You're in

Verify your email to join the Community pod — link sent to alex@studio.dev.

You can sign in now; a reminder stays visible until you verify.

Verify first · 390
commonly

You're in

Verify your email to join the Community pod — link sent to alex@studio.dev.

Verify your email first, then sign in.

Account created · 390no verification step
commonly

Account created

Your account is ready. Sign in to continue.

  • Two reads of one screen. Instances that allow sign-in before verifying get the first; team-gated ones get the second. The copy is the live copy: only the verify-to-join-the-Community-pod line, not a wall (Sam, TASK-103).
  • "Account created" is the third case (no email step), kept for completeness; today the screen is picked by matching the server's prose, so this should become a field on the response.
+

Verify email

/verify-email?token=…
Verifying · 1440
commonly

Verify your email

Verifying your email…

Verified · 1440server line
commonly

Verify your email

Email verified successfully
Verifying · 390
commonly

Verify your email

Verifying your email…

Verified · 390
commonly

Verify your email

Email verified successfully
Failed · 390
commonly

Verify your email

  • Today this is a navy MUI card on a #0b1220 page, the one dark flash left on the auth routes (Sam ruled no dark flash, 08-24). Here it is the same card as every other auth page, on the page background.
  • Failure is the shared alert row; the way out is a bordered key, since nothing on this page is the next step to take. The success line is the server's own.
+

Forgot password

/v2/forgot-password
Forgot password · 1440
commonly

Reset your password

Enter your account email and we'll send you a reset link. If you signed up with Google or GitHub, you can also just sign in with them.

Email
alex@studio.dev
Remembered it? Sign in
Reset link sent · 1440
commonly

Reset your password

If that email has an account, a reset link is on its way. The link is valid for one hour.
Back to sign in
Forgot password · 390
commonly

Reset your password

Enter your account email and we'll send you a reset link. If you signed up with Google or GitHub, you can also just sign in with them.

Email
alex@studio.dev
Remembered it? Sign in
Failed · 390
commonly

Reset your password

Enter your account email and we'll send you a reset link. If you signed up with Google or GitHub, you can also just sign in with them.

Email
alex@studio.dev
Remembered it? Sign in
Sent · 390
commonly

Reset your password

If that email has an account, a reset link is on its way. The link is valid for one hour.
Back to sign in
  • The instructions line stays because it carries the one non-obvious fact (provider accounts can just sign in). It is the only prose on the page.
  • After sending, the form goes and one row says it, with the one-hour validity. Back to sign in is the last line, as everywhere.
+

Choose a new password

/v2/reset-password?token=…
New password · 1440
commonly

Choose a new password

New password
••••••••••
Confirm password
••••••••••
Updated · 1440
commonly

Choose a new password

Password updated. You can sign in now.
New password · 390
commonly

Choose a new password

New password
••••••••••
Confirm password
••••••••••
Mismatch · 390
commonly

Choose a new password

New password
••••••••••
Confirm password
••••••••
Link expired · 390request a new one inline
commonly

Choose a new password

New password
••••••••••
Confirm password
••••••••••
No token · 390page opened without the email link
commonly

Choose a new password

This page needs the reset link from your email. Request a new one below.

  • Both fields ring while an error is up, same as sign in. Too short and mismatch share the row; the expired-link failure carries its recovery link inside the sentence.
  • No token is a page of its own: one line and one key, no form to fill in.
diff --git a/frontend/src/v2/__tests__/V2AuthPagesLayout.test.tsx b/frontend/src/v2/__tests__/V2AuthPagesLayout.test.tsx index 3100650906..1d4ad515aa 100644 --- a/frontend/src/v2/__tests__/V2AuthPagesLayout.test.tsx +++ b/frontend/src/v2/__tests__/V2AuthPagesLayout.test.tsx @@ -287,6 +287,14 @@ describe('auth card styles', () => { return v2.slice(i, v2.indexOf('}', i)); }; + test('the auth page scrolls, and the card centres with auto margins', () => { + const page = rule('.v2-login'); + expect(page).toMatch(/overflow-y: auto/); + expect(page).toMatch(/min-height: 0/); + expect(page).not.toMatch(/place-items: center/); + expect(rule('.v2-login__card')).toMatch(/margin: auto/); + }); + test('a form inside a lifted card lends its rows to the card grid', () => { expect(rule('.v2-login__card--stack > form')).toMatch(/display: contents/); }); diff --git a/frontend/src/v2/v2.css b/frontend/src/v2/v2.css index 55d1bb16dd..46cb44b0b1 100644 --- a/frontend/src/v2/v2.css +++ b/frontend/src/v2/v2.css @@ -1960,10 +1960,17 @@ body.modern-ui.v2-canvas { /* ---------- Login ---------- */ +/* The auth page is its own scroll container: .v2-root and html hide overflow, + so a card taller than the window (a short laptop, a phone with the keyboard + up) would otherwise lose its last rows. The card centres with auto margins, + which fall to zero when it overflows; place-items: center would push its top + out of reach instead. */ .v2-login { flex: 1; - display: grid; - place-items: center; + min-height: 0; + display: flex; + flex-direction: column; + overflow-y: auto; padding: 24px; background: var(--v2-page-bg); } @@ -1973,6 +1980,7 @@ body.modern-ui.v2-canvas { .v2-login__card { width: 100%; max-width: 400px; + margin: auto; background: var(--v2-surface); border-radius: 14px; padding: 32px; @@ -2313,12 +2321,12 @@ body.modern-ui.v2-canvas { after the base rules above, which it overrides at equal specificity. */ @media (max-width: 480px) { .v2-login { - place-items: start stretch; padding: 0; background: var(--v2-surface); } .v2-login__card { + margin: 0; max-width: none; box-shadow: none; border-radius: 0; From 3f79c2df3c19752316be62ca52303643ffd1755b Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sun, 1 Nov 2026 18:21:07 -0800 Subject: [PATCH 3/4] fix(v2): auth notices mark only fields at fault, and a status row is announced - Forgot password: the backend answers generically, so a failure there is never about the address. The email field is no longer marked. - Sign-up: a network or server failure marks no field; only a 4xx marks email and password. - A status row mounts empty and takes its text a frame later. A live region is announced when its content changes, not for content it mounts with. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015AKgpMD6Qh13f7fj6mbGT3 --- .../v2/__tests__/V2AuthPagesLayout.test.tsx | 46 +++++++++++++++---- frontend/src/v2/components/V2AuthNotice.tsx | 24 +++++++--- .../src/v2/components/V2ForgotPassword.tsx | 1 - frontend/src/v2/components/V2Register.tsx | 6 ++- 4 files changed, 59 insertions(+), 18 deletions(-) diff --git a/frontend/src/v2/__tests__/V2AuthPagesLayout.test.tsx b/frontend/src/v2/__tests__/V2AuthPagesLayout.test.tsx index 1d4ad515aa..e98a3887d8 100644 --- a/frontend/src/v2/__tests__/V2AuthPagesLayout.test.tsx +++ b/frontend/src/v2/__tests__/V2AuthPagesLayout.test.tsx @@ -5,11 +5,12 @@ import fs from 'fs'; import path from 'path'; import React from 'react'; -import { act, fireEvent, render, screen } from '@testing-library/react'; +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; import { MemoryRouter, Routes, Route, useLocation } from 'react-router-dom'; import V2Register from '../components/V2Register'; import V2ForgotPassword from '../components/V2ForgotPassword'; import V2ResetPassword from '../components/V2ResetPassword'; +import V2AuthNotice from '../components/V2AuthNotice'; import RegistrationInviteRequired from '../../components/RegistrationInviteRequired'; import VerifyEmail from '../../components/VerifyEmail'; import { AuthContext } from '../../context/AuthContext'; @@ -97,7 +98,7 @@ describe('sign up', () => { test('a failed sign-up is one alert at the top that marks email and password', async () => { routeGets(); - axios.post.mockRejectedValue({ response: { data: { error: 'Registration failed.' } } }); + axios.post.mockRejectedValue({ response: { status: 400, data: { error: 'Registration failed.' } } }); mount(, '/v2/register'); const github = await screen.findByRole('link', { name: /continue with github/i }); fill(); @@ -110,6 +111,21 @@ describe('sign up', () => { expect(screen.getByLabelText(/invitation code/i)).not.toHaveAttribute('aria-invalid'); }); + test.each([ + ['a network failure', {}], + ['a server failure', { response: { status: 503, data: { error: 'Try again shortly.' } } }], + ])('%s is an alert that marks no field', async (_, rejection) => { + routeGets(); + axios.post.mockRejectedValue(rejection); + mount(, '/v2/register'); + await screen.findByRole('link', { name: /continue with github/i }); + fill(); + await act(async () => { fireEvent.click(screen.getByRole('button', { name: /create account/i })); }); + expect(screen.getByRole('alert')).toBeInTheDocument(); + expect(screen.getByLabelText(/^email$/i)).not.toHaveAttribute('aria-invalid'); + expect(screen.getByLabelText(/^password$/i)).not.toHaveAttribute('aria-invalid'); + }); + test('a rejected code marks the code, not the email and password', async () => { routeGets(); axios.post.mockRejectedValue({ response: { data: { code: 'INVITATION_INVALID' } } }); @@ -163,7 +179,7 @@ describe('invitation required', () => { fireEvent.change(screen.getByLabelText(/^email$/i), { target: { value: 'alex@studio.dev' } }); await act(async () => { fireEvent.click(screen.getByRole('button', { name: /request waitlist access/i })); }); const status = screen.getByRole('status'); - expect(status).toHaveTextContent('Waitlist request submitted.'); + await waitFor(() => expect(status).toHaveTextContent('Waitlist request submitted.')); expect(before(status, screen.getByRole('button', { name: /request waitlist access/i }))).toBe(true); expect(screen.getByLabelText(/^email$/i)).toHaveValue('alex@studio.dev'); }); @@ -182,7 +198,8 @@ describe('verify email', () => { axios.get.mockResolvedValue({ data: { message: 'Email verified successfully' } }); mount(, '/v2/verify-email?token=a+b/c'); expect(screen.getByText(/verifying your email/i)).toBeInTheDocument(); - expect(await screen.findByRole('status')).toHaveTextContent('Email verified successfully'); + const verified = await screen.findByRole('status'); + await waitFor(() => expect(verified).toHaveTextContent('Email verified successfully')); expect(axios.get).toHaveBeenCalledWith('/api/auth/verify-email?token=a%20b%2Fc'); const go = screen.getByRole('button', { name: /go to sign in/i }); expect(go).not.toHaveClass('v2-login__submit--ghost'); @@ -211,15 +228,28 @@ describe('verify email', () => { }); }); +describe('V2AuthNotice', () => { + test('a status row mounts empty and takes its text a frame later, so it is announced', async () => { + render(Sent.); + expect(screen.getByRole('status')).toHaveTextContent(/^$/); + await waitFor(() => expect(screen.getByRole('status')).toHaveTextContent('Sent.')); + }); + + test('an alert carries its text from the start', () => { + render(Failed.); + expect(screen.getByRole('alert')).toHaveTextContent('Failed.'); + }); +}); + describe('forgot password', () => { - test('a failure is the alert above the field, and marks it', async () => { + test('a failure is the alert above the field, which stays unmarked (the answer is never about the address)', async () => { axios.post.mockRejectedValue({}); mount(, '/v2/forgot-password'); fireEvent.change(screen.getByLabelText(/^email$/i), { target: { value: 'alex@studio.dev' } }); await act(async () => { fireEvent.click(screen.getByRole('button', { name: /send reset link/i })); }); const alert = screen.getByRole('alert'); expect(before(alert, screen.getByLabelText(/^email$/i))).toBe(true); - expect(screen.getByLabelText(/^email$/i)).toHaveAttribute('aria-invalid', 'true'); + expect(screen.getByLabelText(/^email$/i)).not.toHaveAttribute('aria-invalid'); }); test('once sent, the form goes and one status row says so', async () => { @@ -227,7 +257,7 @@ describe('forgot password', () => { mount(, '/v2/forgot-password'); fireEvent.change(screen.getByLabelText(/^email$/i), { target: { value: 'alex@studio.dev' } }); await act(async () => { fireEvent.click(screen.getByRole('button', { name: /send reset link/i })); }); - expect(screen.getByRole('status')).toHaveTextContent(/valid for one hour/i); + await waitFor(() => expect(screen.getByRole('status')).toHaveTextContent(/valid for one hour/i)); expect(screen.queryByLabelText(/^email$/i)).toBeNull(); expect(screen.getByRole('link', { name: /sign in/i })).toHaveAttribute('href', '/v2/login'); }); @@ -266,7 +296,7 @@ describe('reset password', () => { mount(, '/v2/reset-password?token=t'); fill('longenough1', 'longenough1'); await act(async () => { fireEvent.click(screen.getByRole('button', { name: /update password/i })); }); - expect(screen.getByRole('status')).toHaveTextContent(/password updated/i); + await waitFor(() => expect(screen.getByRole('status')).toHaveTextContent(/password updated/i)); fireEvent.click(screen.getByRole('button', { name: /go to sign in/i })); expect(screen.getByText('at /v2/login')).toBeInTheDocument(); }); diff --git a/frontend/src/v2/components/V2AuthNotice.tsx b/frontend/src/v2/components/V2AuthNotice.tsx index 9775617d9b..16dc6e6f98 100644 --- a/frontend/src/v2/components/V2AuthNotice.tsx +++ b/frontend/src/v2/components/V2AuthNotice.tsx @@ -1,16 +1,26 @@ -import React from 'react'; +import React, { useEffect, useState } from 'react'; import { ShellGlyph } from '../icons/glyphs'; // The one message row on every auth card, drawn in the d215 sign-in and // sign-up frames: an alert for a failure, a status for a confirmation. The // tint is the same; the glyph and the role tell them apart, never a colour. +// A status region is announced when its content changes, not for content it +// mounts with, so a status row mounts empty and takes its text a frame later. const V2AuthNotice: React.FC<{ kind?: 'alert' | 'status'; children: React.ReactNode }> = ({ kind = 'alert', children, -}) => ( -
- - {children} -
-); +}) => { + const [filled, setFilled] = useState(kind === 'alert'); + useEffect(() => { + if (kind !== 'status') return undefined; + const frame = window.requestAnimationFrame(() => setFilled(true)); + return () => window.cancelAnimationFrame(frame); + }, [kind]); + return ( +
+ + {filled ? children : null} +
+ ); +}; export default V2AuthNotice; diff --git a/frontend/src/v2/components/V2ForgotPassword.tsx b/frontend/src/v2/components/V2ForgotPassword.tsx index 928d7aa3ae..aad0213534 100644 --- a/frontend/src/v2/components/V2ForgotPassword.tsx +++ b/frontend/src/v2/components/V2ForgotPassword.tsx @@ -61,7 +61,6 @@ const V2ForgotPassword: React.FC = () => { autoComplete="email" value={email} onChange={(e) => setEmail(e.target.value)} - aria-invalid={Boolean(error) || undefined} required /> diff --git a/frontend/src/v2/components/V2Register.tsx b/frontend/src/v2/components/V2Register.tsx index 8605473863..6009f85f89 100644 --- a/frontend/src/v2/components/V2Register.tsx +++ b/frontend/src/v2/components/V2Register.tsx @@ -136,7 +136,7 @@ const V2Register: React.FC = () => { setVerifyPending(/check your email|verify your email/i.test(message)); setDone(message); } catch (err) { - const e1 = err as { response?: { data?: { error?: string; msg?: string; code?: string } } }; + const e1 = err as { response?: { status?: number; data?: { error?: string; msg?: string; code?: string } } }; const code = e1.response?.data?.code; if (code === 'INVITATION_INVALID') { // Forget the bad code so a reload doesn't carry it back into the form. @@ -148,7 +148,9 @@ const V2Register: React.FC = () => { setErrorField('invite'); } else { setError(e1.response?.data?.error || e1.response?.data?.msg || t('auth.register.errors.failed')); - setErrorField('credentials'); + // Only a 4xx is about what was typed; a network or server failure marks no field. + const status = e1.response?.status || 0; + setErrorField(status >= 400 && status < 500 ? 'credentials' : null); } } finally { setSubmitting(false); From 6173b372e841e786a672500dfcd2d852cf021f21 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sun, 1 Nov 2026 18:22:06 -0800 Subject: [PATCH 4/4] fix(v2): a rate-limited sign-up marks no field; a notice never stays empty A 429 is a 4xx but not about what was typed, so it no longer marks email and password. A status row that becomes an alert before its first frame now fills at once instead of staying empty. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015AKgpMD6Qh13f7fj6mbGT3 --- frontend/src/v2/__tests__/V2AuthPagesLayout.test.tsx | 7 +++++++ frontend/src/v2/components/V2AuthNotice.tsx | 2 +- frontend/src/v2/components/V2Register.tsx | 5 +++-- 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/frontend/src/v2/__tests__/V2AuthPagesLayout.test.tsx b/frontend/src/v2/__tests__/V2AuthPagesLayout.test.tsx index e98a3887d8..0f70bc8db8 100644 --- a/frontend/src/v2/__tests__/V2AuthPagesLayout.test.tsx +++ b/frontend/src/v2/__tests__/V2AuthPagesLayout.test.tsx @@ -114,6 +114,7 @@ describe('sign up', () => { test.each([ ['a network failure', {}], ['a server failure', { response: { status: 503, data: { error: 'Try again shortly.' } } }], + ['a rate limit', { response: { status: 429, data: { error: 'Too many attempts.' } } }], ])('%s is an alert that marks no field', async (_, rejection) => { routeGets(); axios.post.mockRejectedValue(rejection); @@ -235,6 +236,12 @@ describe('V2AuthNotice', () => { await waitFor(() => expect(screen.getByRole('status')).toHaveTextContent('Sent.')); }); + test('a status row that turns into an alert before its frame still shows its text', async () => { + const { rerender } = render(Sent.); + rerender(Failed.); + await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('Failed.')); + }); + test('an alert carries its text from the start', () => { render(Failed.); expect(screen.getByRole('alert')).toHaveTextContent('Failed.'); diff --git a/frontend/src/v2/components/V2AuthNotice.tsx b/frontend/src/v2/components/V2AuthNotice.tsx index 16dc6e6f98..bc677e5fb2 100644 --- a/frontend/src/v2/components/V2AuthNotice.tsx +++ b/frontend/src/v2/components/V2AuthNotice.tsx @@ -11,7 +11,7 @@ const V2AuthNotice: React.FC<{ kind?: 'alert' | 'status'; children: React.ReactN }) => { const [filled, setFilled] = useState(kind === 'alert'); useEffect(() => { - if (kind !== 'status') return undefined; + if (kind !== 'status') { setFilled(true); return undefined; } const frame = window.requestAnimationFrame(() => setFilled(true)); return () => window.cancelAnimationFrame(frame); }, [kind]); diff --git a/frontend/src/v2/components/V2Register.tsx b/frontend/src/v2/components/V2Register.tsx index 6009f85f89..c87d5cf650 100644 --- a/frontend/src/v2/components/V2Register.tsx +++ b/frontend/src/v2/components/V2Register.tsx @@ -148,9 +148,10 @@ const V2Register: React.FC = () => { setErrorField('invite'); } else { setError(e1.response?.data?.error || e1.response?.data?.msg || t('auth.register.errors.failed')); - // Only a 4xx is about what was typed; a network or server failure marks no field. + // Only a 4xx is about what was typed; a network or server failure, or a + // rate limit (429), marks no field. const status = e1.response?.status || 0; - setErrorField(status >= 400 && status < 500 ? 'credentials' : null); + setErrorField(status >= 400 && status < 500 && status !== 429 ? 'credentials' : null); } } finally { setSubmitting(false);