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
-

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
-

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.

-

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

-

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
-

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.

+

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
+

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.

+

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

+

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
+

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.

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..0f70bc8db8 --- /dev/null +++ b/frontend/src/v2/__tests__/V2AuthPagesLayout.test.tsx @@ -0,0 +1,356 @@ +// @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, 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'; + +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: { status: 400, 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.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); + 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' } } }); + 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'); + 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'); + }); + + 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(); + 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'); + 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('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('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.'); + }); +}); + +describe('forgot password', () => { + 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)).not.toHaveAttribute('aria-invalid'); + }); + + 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 })); }); + 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'); + }); +}); + +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 })); }); + 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(); + }); + + 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('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/); + }); + + 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..bc677e5fb2 --- /dev/null +++ b/frontend/src/v2/components/V2AuthNotice.tsx @@ -0,0 +1,26 @@ +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, +}) => { + const [filled, setFilled] = useState(kind === 'alert'); + useEffect(() => { + if (kind !== 'status') { setFilled(true); 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 015cb1b0c6..aad0213534 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}}