diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index f908111..00e7812 100755 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -137,8 +137,12 @@ export default function App() { ) : ( <> - - + + + + + + )} diff --git a/frontend/src/customer/AuthForm.tsx b/frontend/src/customer/AuthForm.tsx new file mode 100644 index 0000000..34bab4f --- /dev/null +++ b/frontend/src/customer/AuthForm.tsx @@ -0,0 +1,139 @@ +import { useState } from 'react'; +import Form from 'antd/es/form'; +import Input from 'antd/es/input'; +import Button from 'antd/es/button'; +import Checkbox from 'antd/es/checkbox'; +import Tabs from 'antd/es/tabs'; +import Alert from 'antd/es/alert'; +import Typography from 'antd/es/typography'; +import { registerCustomer, loginCustomer } from './customerApi'; +import { useCustomerAuth } from './CustomerAuthContext'; + +const { Text } = Typography; + +export type AuthMode = 'register' | 'login'; + +// Must stay identical to MARKETING_CONSENT_TEXT in backend/src/utils.ts, which +// is what gets stored verbatim against the customer's consent record. The point +// of storing it is that the record says what the customer actually saw, so a +// label that differs from the stored string defeats the whole mechanism. Before +// this was shared there were three wordings in play — this one, a shorter one in +// the cart prompt, and the string the server actually recorded — and none of +// them matched. +export const MARKETING_CONSENT_TEXT = + 'I want to receive occasional emails about new one-of-a-kind items from Redefined Designs. I can unsubscribe at any time.'; + +interface Props { + mode: AuthMode; + onModeChange: (mode: AuthMode) => void; + onForgotPassword: () => void; + // Called once the session exists. What that means differs by caller: the + // route closes back to the page behind it, while the cart and favorite + // prompts resume the action the customer was interrupted doing. + onSuccess: () => void; +} + +// The one implementation of signing in and registering. It was previously +// written twice — once as the /login and /register pages, once inside the +// prompt shown when a signed-out visitor adds to the cart — which had already +// drifted in consent wording and in which links each offered. +export default function AuthForm({ mode, onModeChange, onForgotPassword, onSuccess }: Props) { + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + const { refresh } = useCustomerAuth(); + + async function submit(action: () => Promise) { + setLoading(true); + setError(null); + try { + await action(); + refresh(); + onSuccess(); + } catch (err) { + setError((err as Error).message); + } finally { + setLoading(false); + } + } + + return ( + <> + {error && } + { + // A stale error from the other tab would read as though it applied to + // the form now on screen. + setError(null); + onModeChange(key as AuthMode); + }} + items={[ + { + key: 'register', + label: 'Create Account', + children: ( +
+ submit(() => + registerCustomer(values.email, values.password, values.name, !!values.marketingConsent) + ) + } + > + + + + + + + + + + + {MARKETING_CONSENT_TEXT} + + + + {/* Opens in a new tab deliberately: following it in place would + discard a part-filled signup form, and /privacy has no way + back of its own yet (#52). */} + By creating an account you agree to our{' '} + Privacy Policy. + +
+ ) + }, + { + key: 'login', + label: 'Log In', + children: ( +
submit(() => loginCustomer(values.email, values.password))} + > + + + + + + + + +
+ ) + } + ]} + /> + + ); +} diff --git a/frontend/src/customer/AuthPromptModal.tsx b/frontend/src/customer/AuthPromptModal.tsx index 54cffe7..4c5e1f5 100644 --- a/frontend/src/customer/AuthPromptModal.tsx +++ b/frontend/src/customer/AuthPromptModal.tsx @@ -1,7 +1,7 @@ import { useState } from 'react'; -import { Modal, Form, Input, Button, Checkbox, Tabs, Alert } from 'antd'; -import { registerCustomer, loginCustomer } from './customerApi'; -import { useCustomerAuth } from './CustomerAuthContext'; +import Modal from 'antd/es/modal'; +import { useNavigate, useLocation } from 'react-router-dom'; +import AuthForm, { AuthMode } from './AuthForm'; interface Props { open: boolean; @@ -9,40 +9,15 @@ interface Props { onSuccess: () => void; } +// The prompt shown when a signed-out visitor does something that needs an +// account — adding to the cart, favoriting, or filtering by favorites. It is +// only the framing: the form itself is shared with the /login and /register +// routes, so the two cannot drift apart in validation, copy, or consent +// wording again. export default function AuthPromptModal({ open, onClose, onSuccess }: Props) { - const [tab, setTab] = useState<'register' | 'login'>('register'); - const [error, setError] = useState(null); - const [loading, setLoading] = useState(false); - const [form] = Form.useForm(); - const { refresh } = useCustomerAuth(); - - async function handleRegister(values: any) { - setLoading(true); - setError(null); - try { - await registerCustomer(values.email, values.password, values.name, !!values.marketingConsent); - refresh(); - onSuccess(); - } catch (err) { - setError((err as Error).message); - } finally { - setLoading(false); - } - } - - async function handleLogin(values: any) { - setLoading(true); - setError(null); - try { - await loginCustomer(values.email, values.password); - refresh(); - onSuccess(); - } catch (err) { - setError((err as Error).message); - } finally { - setLoading(false); - } - } + const [mode, setMode] = useState('register'); + const navigate = useNavigate(); + const location = useLocation(); return ( - {error && } - setTab(k as 'register' | 'login')} - items={[ - { - key: 'register', - label: 'Create Account', - children: ( -
- - - - - - - - - - - Send me occasional emails about new one-of-a-kind items. - - -
- ) - }, - { - key: 'login', - label: 'Log In', - children: ( -
- - - - - - - -
- ) - } - ]} + { + onClose(); + navigate('/forgot-password', { state: { background: location } }); + }} + onSuccess={onSuccess} />
); diff --git a/frontend/src/customer/AuthRouteModal.tsx b/frontend/src/customer/AuthRouteModal.tsx new file mode 100644 index 0000000..fc29867 --- /dev/null +++ b/frontend/src/customer/AuthRouteModal.tsx @@ -0,0 +1,42 @@ +import Modal from 'antd/es/modal'; +import AuthForm, { AuthMode } from './AuthForm'; + +interface Props { + mode: AuthMode; + onClose: () => void; + // Moving between the auth routes, supplied by the router so the rule about + // keeping the whole detour to one history entry lives in one place. + onNavigate: (path: string) => void; +} + +const TITLES: Record = { + register: 'Create an account', + login: 'Log in' +}; + +// /login and /register as modals over the page behind them, so a customer who +// clicks Log in while browsing and changes their mind is not stranded. Both +// stay real routes: /reset-password links to /login, and customers may have +// bookmarks. +export default function AuthRouteModal({ mode, onClose, onNavigate }: Props) { + return ( + + onNavigate(next === 'login' ? '/login' : '/register')} + onForgotPassword={() => onNavigate('/forgot-password')} + // Closing returns to the page behind, now signed in. Someone who signs + // in while browsing wants to carry on browsing rather than be moved to + // their account page. + onSuccess={onClose} + /> + + ); +} diff --git a/frontend/src/customer/ForgotPassword.tsx b/frontend/src/customer/ForgotPassword.tsx index ca72c66..5f4c048 100644 --- a/frontend/src/customer/ForgotPassword.tsx +++ b/frontend/src/customer/ForgotPassword.tsx @@ -3,14 +3,20 @@ import Form from 'antd/es/form'; import Input from 'antd/es/input'; import Button from 'antd/es/button'; import Typography from 'antd/es/typography'; -import Card from 'antd/es/card'; +import Modal from 'antd/es/modal'; import Alert from 'antd/es/alert'; -import { Link } from 'react-router-dom'; import { requestPasswordReset } from './customerApi'; -const { Title, Paragraph, Text } = Typography; +const { Paragraph, Text } = Typography; -export default function ForgotPassword() { +interface Props { + onClose: () => void; + // Steps back to sign-in without leaving a history entry behind, the same way + // the auth modal switches between its own tabs. + onBackToSignIn: () => void; +} + +export default function ForgotPassword({ onClose, onBackToSignIn }: Props) { const [sent, setSent] = useState(false); const [error, setError] = useState(null); const [loading, setLoading] = useState(false); @@ -29,8 +35,14 @@ export default function ForgotPassword() { } return ( - - Reset your password + {sent ? ( <> @@ -43,7 +55,9 @@ export default function ForgotPassword() { description="If an account exists for that address, we've sent a link to reset the password. The link expires in one hour." /> - Back to sign in + ) : ( @@ -67,10 +81,13 @@ export default function ForgotPassword() { - Remembered it? Sign in + Remembered it?{' '} + )} - + ); } diff --git a/frontend/src/customer/Login.tsx b/frontend/src/customer/Login.tsx deleted file mode 100755 index 680187a..0000000 --- a/frontend/src/customer/Login.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import { useState } from 'react'; -import { Form, Input, Button, Typography, Card, Alert } from 'antd'; -import { useNavigate, Link } from 'react-router-dom'; -import { loginCustomer } from './customerApi'; -import { useCustomerAuth } from './CustomerAuthContext'; - -const { Title, Text } = Typography; - -export default function Login() { - const [error, setError] = useState(null); - const [loading, setLoading] = useState(false); - const navigate = useNavigate(); - const { refresh } = useCustomerAuth(); - - async function onFinish(values: any) { - setLoading(true); - setError(null); - try { - await loginCustomer(values.email, values.password); - refresh(); - navigate('/account'); - } catch (err) { - setError((err as Error).message); - } finally { - setLoading(false); - } - } - - return ( -
- - Log in - {error && } -
- - - - - - - - - -
- - Forgot password? -
- No account yet? Create one -
-
-
- ); -} diff --git a/frontend/src/customer/Register.tsx b/frontend/src/customer/Register.tsx deleted file mode 100755 index ea17815..0000000 --- a/frontend/src/customer/Register.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import { useState } from 'react'; -import { Form, Input, Button, Checkbox, Typography, Card, Alert } from 'antd'; -import { useNavigate, Link } from 'react-router-dom'; -import { registerCustomer } from './customerApi'; -import { useCustomerAuth } from './CustomerAuthContext'; - -const { Title, Text } = Typography; - -export default function Register() { - const [error, setError] = useState(null); - const [loading, setLoading] = useState(false); - const navigate = useNavigate(); - const { refresh } = useCustomerAuth(); - - async function onFinish(values: any) { - setLoading(true); - setError(null); - try { - await registerCustomer(values.email, values.password, values.name, !!values.marketingConsent); - refresh(); - navigate('/account'); - } catch (err) { - setError((err as Error).message); - } finally { - setLoading(false); - } - } - - return ( -
- - Create an account - {error && } -
- - - - - - - - - - - - Send me occasional emails about new one-of-a-kind items. I can unsubscribe at any time. - - - - - -
- - Already have an account? Log in - -
- - By creating an account you agree to our Privacy Policy. - -
-
- ); -} diff --git a/frontend/src/customer/ResetPassword.tsx b/frontend/src/customer/ResetPassword.tsx index 35cf8db..d29676d 100644 --- a/frontend/src/customer/ResetPassword.tsx +++ b/frontend/src/customer/ResetPassword.tsx @@ -3,28 +3,32 @@ import Form from 'antd/es/form'; import Input from 'antd/es/input'; import Button from 'antd/es/button'; import Typography from 'antd/es/typography'; -import Card from 'antd/es/card'; +import Modal from 'antd/es/modal'; import Alert from 'antd/es/alert'; -import { Link, useNavigate, useSearchParams } from 'react-router-dom'; +import { useSearchParams } from 'react-router-dom'; import { resetPassword } from './customerApi'; import { useCustomerAuth } from './CustomerAuthContext'; -const { Title, Paragraph } = Typography; +const { Paragraph } = Typography; -export default function ResetPassword() { +interface Props { + onClose: () => void; + onRequestNewLink: () => void; + onBackToSignIn: () => void; +} + +export default function ResetPassword({ onClose, onRequestNewLink, onBackToSignIn }: Props) { const [searchParams] = useSearchParams(); const token = searchParams.get('token'); const [error, setError] = useState(null); const [loading, setLoading] = useState(false); - const navigate = useNavigate(); const { refresh } = useCustomerAuth(); // A link without a token can't do anything, so say so rather than showing a // form that is guaranteed to fail on submit. if (!token) { return ( - - Reset your password + - Request a new link + - + ); } @@ -44,9 +50,10 @@ export default function ResetPassword() { try { await resetPassword(token as string, values.password); // The server signs the customer in as part of the reset, so pick up the - // new session before navigating. + // new session before closing. Closing lands on the storefront: the link + // came from an email, so there is no page behind to return to. refresh(); - navigate('/account', { replace: true }); + onClose(); } catch (err) { setError((err as Error).message); } finally { @@ -55,8 +62,14 @@ export default function ResetPassword() { } return ( - - Choose a new password + Signing in elsewhere will end — you'll stay signed in on this device. @@ -91,7 +104,9 @@ export default function ResetPassword() { - Back to sign in - + + ); } diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 8ebbc7f..5418eba 100755 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -6,8 +6,7 @@ import { ConfigProvider, theme as antdTheme } from 'antd'; import 'antd/dist/reset.css'; import App from './App'; import Admin from './admin/Admin'; -import Login from './customer/Login'; -import Register from './customer/Register'; +import AuthRouteModal from './customer/AuthRouteModal'; import Account from './customer/Account'; import PrivacyPolicy from './customer/PrivacyPolicy'; import VerifyEmail from './customer/VerifyEmail'; @@ -32,6 +31,12 @@ const REDUCED_MOTION_QUERY = '(prefers-reduced-motion: reduce)'; // case, and a modal floating on nothing has nowhere to close back to. const STOREFRONT_BACKDROP: Partial = { pathname: '/', search: '', hash: '' }; +// Routes presented as a modal over the page the customer was already on rather +// than as a page of their own. Each stays a real, linkable URL — bookmarkable, +// refreshable, and closed by the browser's Back button — while never being +// somewhere with no way out. +const MODAL_ROUTES = ['/account', '/login', '/register', '/forgot-password', '/reset-password']; + // Respects the OS-level "reduce motion" accessibility setting by turning off // antd's transitions. Beyond the accessibility win, animated popups are a // standing source of flake in end-to-end tests, which drive the app with this @@ -59,36 +64,55 @@ function AppRoutes() { const location = useLocation(); const navigate = useNavigate(); const state = location.state as { background?: Location } | null; - const isAccount = location.pathname === '/account'; + const modalPath = MODAL_ROUTES.includes(location.pathname) ? location.pathname : null; // In-app navigation names the page to render behind. Anything else — a - // bookmark, an email link, the redirect after registering — falls back to the - // storefront, so closing always lands somewhere real. + // bookmark, an email link, a link in a password reset message — falls back to + // the storefront, so closing always lands somewhere real. const background = state?.background; - const backdrop = isAccount ? background ?? { ...location, ...STOREFRONT_BACKDROP } : location; + const backdrop = modalPath ? background ?? { ...location, ...STOREFRONT_BACKDROP } : location; - function closeAccount() { + function closeModal() { // Back, when there is somewhere to go back to, so closing the modal and // pressing Back do the same thing and neither leaves a dead entry behind. if (background) navigate(-1); else navigate('/', { replace: true }); } + // Steps within the auth flow replace rather than push, so the whole detour + // stays a single history entry and closing returns to where it started + // instead of walking back through every tab that was looked at. + function goWithinAuth(path: string) { + navigate(path, { state: background ? { background } : undefined, replace: true }); + } + return ( <> } /> } /> - } /> - } /> } /> } /> } /> - } /> - } /> {/* Rendered outside the Routes above, which are showing the backdrop. */} - {isAccount && } + {modalPath === '/account' && } + {modalPath === '/login' && ( + + )} + {modalPath === '/register' && ( + + )} + {modalPath === '/forgot-password' && ( + goWithinAuth('/login')} /> + )} + {modalPath === '/reset-password' && ( + goWithinAuth('/forgot-password')} + onBackToSignIn={() => goWithinAuth('/login')} + /> + )} ); } diff --git a/frontend/tests/e2e/account-modal.spec.ts b/frontend/tests/e2e/account-modal.spec.ts index b3a096c..744769d 100644 --- a/frontend/tests/e2e/account-modal.spec.ts +++ b/frontend/tests/e2e/account-modal.spec.ts @@ -4,20 +4,19 @@ const PASSWORD = 'supersecret123'; const uniqueEmail = () => `account-${Date.now().toString(36)}${Math.random().toString(36).slice(2, 7)}@example.com`; -// Registering lands on /account, which is now the modal over the storefront. +// Registering closes the auth modal and returns to the storefront, signed in. // Returns the address so a test can assert the right account is shown. -async function registerAndCloseAccount(page: Page): Promise { +// +// The wait is generous because registration is a bcrypt round-trip rather than +// a render: about half a second unloaded, and past Playwright's 5s default when +// the suite's workers all register at once. +async function registerCustomer(page: Page): Promise { const email = uniqueEmail(); await page.goto('/register'); await page.getByRole('textbox', { name: 'Email' }).fill(email); await page.getByLabel('Password').fill(PASSWORD); await page.getByRole('button', { name: 'Create account' }).click(); - // Registration is a bcrypt round-trip, not a render. Unloaded it takes about - // half a second; with the suite's workers all registering at once it can pass - // Playwright's 5s default, which shows up as a failure on whichever test lost - // the race rather than as the load problem it is. - await expect(page).toHaveURL(/\/account/, { timeout: 20000 }); - await closeAccount(page); + await expect(page.getByRole('button', { name: 'My Account' })).toBeVisible({ timeout: 20000 }); return email; } @@ -30,7 +29,7 @@ async function closeAccount(page: Page) { test.describe('My Account opens as a modal', () => { test('opens over the storefront and closes back to it, filters and all', async ({ page }) => { - await registerAndCloseAccount(page); + await registerCustomer(page); // A filtered view, to prove closing restores where the customer actually // was rather than a bare storefront. @@ -47,7 +46,7 @@ test.describe('My Account opens as a modal', () => { }); test('the browser back button closes it, the same as the close control', async ({ page }) => { - await registerAndCloseAccount(page); + await registerCustomer(page); await page.goto('/?max_price=50000'); await page.getByRole('button', { name: 'My Account' }).click(); @@ -60,7 +59,7 @@ test.describe('My Account opens as a modal', () => { }); test('a direct visit renders the storefront behind it, so closing lands somewhere real', async ({ page }) => { - await registerAndCloseAccount(page); + await registerCustomer(page); // A bookmark, or the link in a verification email. There is no page behind // in this case, which is what used to make /account a dead end. @@ -75,7 +74,7 @@ test.describe('My Account opens as a modal', () => { }); test('survives a reload, since it is a route rather than view state', async ({ page }) => { - const email = await registerAndCloseAccount(page); + const email = await registerCustomer(page); await page.goto('/?max_price=50000'); await page.getByRole('button', { name: 'My Account' }).click(); @@ -88,7 +87,7 @@ test.describe('My Account opens as a modal', () => { }); test('shows the signed-in account and its settings', async ({ page }) => { - const email = await registerAndCloseAccount(page); + const email = await registerCustomer(page); await page.goto('/account'); @@ -101,7 +100,7 @@ test.describe('My Account opens as a modal', () => { }); test('deleting the account does not leave the page behind it looking signed in', async ({ page }) => { - await registerAndCloseAccount(page); + await registerCustomer(page); await page.goto('/account'); await accountModal(page).getByRole('button', { name: 'Delete my account' }).click(); @@ -116,7 +115,7 @@ test.describe('My Account opens as a modal', () => { }); test('stays usable on a phone, with the close control in reach', async ({ page }) => { - await registerAndCloseAccount(page); + await registerCustomer(page); await page.setViewportSize({ width: 390, height: 664 }); await page.goto('/account'); diff --git a/frontend/tests/e2e/admin-disable-customer.spec.ts b/frontend/tests/e2e/admin-disable-customer.spec.ts index d5abde5..f1312d2 100644 --- a/frontend/tests/e2e/admin-disable-customer.spec.ts +++ b/frontend/tests/e2e/admin-disable-customer.spec.ts @@ -8,7 +8,10 @@ async function register(page: Page, email: string) { await page.getByRole('textbox', { name: 'Email' }).fill(email); await page.getByLabel('Password').fill(PASSWORD); await page.getByRole('button', { name: 'Create account' }).click(); - await expect(page).toHaveURL(/\/account/); + // Registering now closes the auth modal and returns to the page behind it, so + // the header rather than the URL is what proves the session exists. The wait + // is generous because this is a bcrypt round-trip rather than a render. + await expect(page.getByRole('button', { name: 'My Account' })).toBeVisible({ timeout: 20000 }); } async function customerRow(page: Page, email: string) { @@ -39,7 +42,9 @@ test.describe('Disabling a customer account', () => { await page.goto('/login'); await page.getByRole('textbox', { name: 'Email' }).fill(email); await page.getByLabel('Password').fill(PASSWORD); - await page.getByRole('button', { name: 'Log in' }).click(); + // Scoped to the modal: the storefront rendered behind it has a "Log in" + // button of its own, which is what opened this one. + await page.getByRole('dialog', { name: 'Log in' }).getByRole('button', { name: 'Log in' }).click(); await expect(page.getByText(/disabled/i)).toBeVisible(); }); @@ -77,8 +82,10 @@ test.describe('Disabling a customer account', () => { await page.goto('/login'); await page.getByRole('textbox', { name: 'Email' }).fill(email); await page.getByLabel('Password').fill(PASSWORD); - await page.getByRole('button', { name: 'Log in' }).click(); - await expect(page).toHaveURL(/\/account/); + // Scoped to the modal: the storefront rendered behind it has a "Log in" + // button of its own, which is what opened this one. + await page.getByRole('dialog', { name: 'Log in' }).getByRole('button', { name: 'Log in' }).click(); + await expect(page.getByRole('button', { name: 'My Account' })).toBeVisible({ timeout: 20000 }); }); test('the confirmation warns that held items will be released', async ({ page, request }) => { diff --git a/frontend/tests/e2e/admin-reserved-items.spec.ts b/frontend/tests/e2e/admin-reserved-items.spec.ts index 4c3771f..4b9b3e3 100644 --- a/frontend/tests/e2e/admin-reserved-items.spec.ts +++ b/frontend/tests/e2e/admin-reserved-items.spec.ts @@ -17,7 +17,7 @@ async function reserveItem(page: import('@playwright/test').Page, itemName: stri await page.getByRole('textbox', { name: 'Email' }).fill(email); await page.getByLabel('Password').fill('supersecret123'); await page.getByRole('button', { name: 'Create account' }).click(); - await expect(page).toHaveURL(/\/account/); + await expect(page.getByRole('button', { name: 'My Account' })).toBeVisible({ timeout: 20000 }); const added = await page.request.post(`/api/cart/items/${itemId}`); expect(added.status()).toBe(201); @@ -83,7 +83,7 @@ test.describe('Admin reserved items', () => { await page.getByRole('textbox', { name: 'Email' }).fill(email); await page.getByLabel('Password').fill('supersecret123'); await page.getByRole('button', { name: 'Create account' }).click(); - await expect(page).toHaveURL(/\/account/); + await expect(page.getByRole('button', { name: 'My Account' })).toBeVisible({ timeout: 20000 }); await page.goto('/admin'); await page.getByRole('tab', { name: 'Customers' }).click(); diff --git a/frontend/tests/e2e/auth.spec.ts b/frontend/tests/e2e/auth.spec.ts index 7fd81bd..5ba3608 100755 --- a/frontend/tests/e2e/auth.spec.ts +++ b/frontend/tests/e2e/auth.spec.ts @@ -1,64 +1,92 @@ -import { test, expect } from '@playwright/test'; +import { test, expect, Page } from '@playwright/test'; + +const PASSWORD = 'supersecret123'; function uniqueEmail(): string { return `playwright-${Date.now()}-${Math.floor(Math.random() * 10000)}@example.com`; } +// Registering closes the auth modal and returns the customer to the page behind +// it — the storefront, for a direct visit to /register — rather than navigating +// to /account. So the header, not the URL, is what proves the session exists. +// +// The wait is generous because this is a bcrypt round-trip rather than a render, +// and the suite's workers all register at once. +async function register(page: Page, email: string) { + await page.goto('/register'); + await page.getByRole('textbox', { name: 'Email' }).fill(email); + await page.getByLabel('Password').fill(PASSWORD); + await page.getByRole('button', { name: 'Create account' }).click(); + await expect(page.getByRole('button', { name: 'My Account' })).toBeVisible({ timeout: 20000 }); +} + +// Log out lives inside the account view, which is a modal over the storefront. +async function openAccount(page: Page) { + await page.goto('/account'); + await expect(page.getByRole('dialog', { name: 'My Account' })).toBeVisible(); +} + test.describe('Customer accounts', () => { test('marketing consent checkbox is unchecked by default', async ({ page }) => { await page.goto('/register'); await expect(page.getByRole('checkbox')).not.toBeChecked(); }); - test('registers a new account and lands on the account page', async ({ page }) => { - const email = uniqueEmail(); + test('the consent label is the exact wording the server records', async ({ page }) => { await page.goto('/register'); - await page.getByRole('textbox', { name: 'Email' }).fill(email); - await page.getByLabel('Password').fill('supersecret123'); - await page.getByRole('button', { name: 'Create account' }).click(); - await expect(page).toHaveURL(/\/account/); + + // The stored consent text is kept verbatim so the record says what the + // customer actually saw. Three different wordings were in circulation + // before the sign-in form was shared between the routes and the cart + // prompt, and none of them matched what was stored. + const consent = + 'I want to receive occasional emails about new one-of-a-kind items from Redefined Designs. I can unsubscribe at any time.'; + await expect(page.getByRole('dialog', { name: 'Create an account' })).toContainText(consent); + }); + + test('registering signs the customer in and returns them where they were', async ({ page }) => { + const email = uniqueEmail(); + await register(page, email); + + // Back on the storefront, signed in — not moved to the account page. + await expect(page).toHaveURL(/\/$/); + await openAccount(page); await expect(page.getByText(email)).toBeVisible(); }); test('rejects login with the wrong password', async ({ page }) => { const email = uniqueEmail(); - await page.goto('/register'); - await page.getByRole('textbox', { name: 'Email' }).fill(email); - await page.getByLabel('Password').fill('supersecret123'); - await page.getByRole('button', { name: 'Create account' }).click(); - await expect(page).toHaveURL(/\/account/); - + await register(page, email); + await openAccount(page); await page.getByRole('button', { name: 'Log out' }).click(); + await page.goto('/login'); await page.getByRole('textbox', { name: 'Email' }).fill(email); await page.getByLabel('Password').fill('wrong-password'); - await page.getByRole('button', { name: 'Log in' }).click(); + // Scoped to the modal: the storefront rendered behind it has a "Log in" + // button of its own, which is what opened this one. + await page.getByRole('dialog', { name: 'Log in' }).getByRole('button', { name: 'Log in' }).click(); + await expect(page.getByText('invalid email or password')).toBeVisible(); }); test('logging out returns to the home page and resets the header', async ({ page }) => { - const email = uniqueEmail(); - await page.goto('/register'); - await page.getByRole('textbox', { name: 'Email' }).fill(email); - await page.getByLabel('Password').fill('supersecret123'); - await page.getByRole('button', { name: 'Create account' }).click(); - await expect(page).toHaveURL(/\/account/); + await register(page, uniqueEmail()); + await openAccount(page); await page.getByRole('button', { name: 'Log out' }).click(); - await expect(page).toHaveURL(/\/$/); + // A server round-trip followed by re-rendering the storefront behind the + // modal, so the 5s default is too tight when workers run concurrently. + await expect(page).toHaveURL(/\/$/, { timeout: 20000 }); await expect(page.getByRole('button', { name: 'Log in' })).toBeVisible(); await expect(page.getByRole('button', { name: 'Sign up' })).toBeVisible(); await expect(page.getByRole('button', { name: 'My Account' })).toBeHidden(); }); test('the logged-out header survives a reload', async ({ page }) => { - const email = uniqueEmail(); - await page.goto('/register'); - await page.getByRole('textbox', { name: 'Email' }).fill(email); - await page.getByLabel('Password').fill('supersecret123'); - await page.getByRole('button', { name: 'Create account' }).click(); - await expect(page).toHaveURL(/\/account/); + await register(page, uniqueEmail()); + await openAccount(page); await page.getByRole('button', { name: 'Log out' }).click(); await expect(page.getByRole('button', { name: 'Log in' })).toBeVisible(); @@ -71,27 +99,19 @@ test.describe('Customer accounts', () => { }); test('logging out does not leave the account page on the back stack', async ({ page }) => { - const email = uniqueEmail(); - await page.goto('/register'); - await page.getByRole('textbox', { name: 'Email' }).fill(email); - await page.getByLabel('Password').fill('supersecret123'); - await page.getByRole('button', { name: 'Create account' }).click(); - await expect(page).toHaveURL(/\/account/); + await register(page, uniqueEmail()); + await openAccount(page); await page.getByRole('button', { name: 'Log out' }).click(); - await expect(page).toHaveURL(/\/$/); + await expect(page).toHaveURL(/\/$/, { timeout: 20000 }); await page.goBack(); await expect(page).not.toHaveURL(/\/account/); }); test('a failed logout says so instead of appearing to succeed', async ({ page }) => { - const email = uniqueEmail(); - await page.goto('/register'); - await page.getByRole('textbox', { name: 'Email' }).fill(email); - await page.getByLabel('Password').fill('supersecret123'); - await page.getByRole('button', { name: 'Create account' }).click(); - await expect(page).toHaveURL(/\/account/); + await register(page, uniqueEmail()); + await openAccount(page); await page.route('**/api/customers/logout', (route) => route.fulfill({ status: 500, contentType: 'application/json', body: '{"error":"internal error"}' }) @@ -105,3 +125,71 @@ test.describe('Customer accounts', () => { await expect(page).toHaveURL(/\/account/); }); }); + +test.describe('Auth routes are not dead ends', () => { + test('opening Log in from the header closes back to where browsing left off', async ({ page }) => { + await page.goto('/?max_price=50000'); + await page.getByRole('button', { name: 'Log in' }).click(); + + const modal = page.getByRole('dialog', { name: 'Log in' }); + await expect(modal).toBeVisible(); + await expect(page).toHaveURL(/\/login/); + + await modal.getByRole('button', { name: 'Close' }).click(); + + await expect(modal).toBeHidden(); + await expect(page).toHaveURL(/max_price=50000/); + }); + + test('a direct visit opens over the storefront rather than a blank page', async ({ page }) => { + await page.goto('/login'); + + await expect(page.getByRole('dialog', { name: 'Log in' })).toBeVisible(); + await expect(page.getByRole('heading', { name: 'Redefined Designs' })).toBeVisible(); + }); + + test('switching between sign in and sign up keeps one history entry', async ({ page }) => { + await page.goto('/?max_price=50000'); + await page.getByRole('button', { name: 'Log in' }).click(); + + await page.getByRole('tab', { name: 'Create Account' }).click(); + await expect(page).toHaveURL(/\/register/); + await page.getByRole('tab', { name: 'Log In' }).click(); + await expect(page).toHaveURL(/\/login/); + + // Back returns to browsing rather than walking through each tab visited. + await page.goBack(); + await expect(page).toHaveURL(/max_price=50000/); + await expect(page.getByRole('dialog', { name: 'Log in' })).toBeHidden(); + }); + + test('signing in from the header returns to the page behind, signed in', async ({ page }) => { + const email = uniqueEmail(); + await register(page, email); + await page.goto('/account'); + await page.getByRole('button', { name: 'Log out' }).click(); + await expect(page.getByRole('button', { name: 'Log in' })).toBeVisible(); + + await page.goto('/?max_price=50000'); + await page.getByRole('button', { name: 'Log in' }).click(); + const modal = page.getByRole('dialog', { name: 'Log in' }); + await modal.getByRole('textbox', { name: 'Email' }).fill(email); + await modal.getByLabel('Password').fill(PASSWORD); + await modal.getByRole('button', { name: 'Log in' }).click(); + + await expect(page.getByRole('button', { name: 'My Account' })).toBeVisible({ timeout: 20000 }); + await expect(page).toHaveURL(/max_price=50000/); + }); + + test('reaching password recovery from the login form keeps a way back', async ({ page }) => { + await page.goto('/login'); + await page.getByRole('button', { name: 'Forgot password?' }).click(); + + const modal = page.getByRole('dialog', { name: 'Reset your password' }); + await expect(modal).toBeVisible(); + await expect(page).toHaveURL(/\/forgot-password/); + + await modal.getByRole('button', { name: 'Sign in' }).click(); + await expect(page.getByRole('dialog', { name: 'Log in' })).toBeVisible(); + }); +}); diff --git a/frontend/tests/e2e/favorites-filter.spec.ts b/frontend/tests/e2e/favorites-filter.spec.ts index b47f921..0ac2baa 100644 --- a/frontend/tests/e2e/favorites-filter.spec.ts +++ b/frontend/tests/e2e/favorites-filter.spec.ts @@ -30,7 +30,10 @@ async function register(page: Page) { await page.getByRole('textbox', { name: 'Email' }).fill(uniqueEmail()); await page.getByLabel('Password').fill(PASSWORD); await page.getByRole('button', { name: 'Create account' }).click(); - await expect(page).toHaveURL(/\/account/); + // Registering now closes the auth modal and returns to the page behind it, so + // the header rather than the URL is what proves the session exists. The wait + // is generous because this is a bcrypt round-trip rather than a render. + await expect(page.getByRole('button', { name: 'My Account' })).toBeVisible({ timeout: 20000 }); } // The session is still resolving for a moment after a remount, and the diff --git a/frontend/tests/e2e/favorites.spec.ts b/frontend/tests/e2e/favorites.spec.ts index a680492..751df93 100644 --- a/frontend/tests/e2e/favorites.spec.ts +++ b/frontend/tests/e2e/favorites.spec.ts @@ -20,7 +20,10 @@ async function register(page: Page, email: string) { await page.getByRole('textbox', { name: 'Email' }).fill(email); await page.getByLabel('Password').fill(PASSWORD); await page.getByRole('button', { name: 'Create account' }).click(); - await expect(page).toHaveURL(/\/account/); + // Registering now closes the auth modal and returns to the page behind it, so + // the header rather than the URL is what proves the session exists. The wait + // is generous because this is a bcrypt round-trip rather than a render. + await expect(page.getByRole('button', { name: 'My Account' })).toBeVisible({ timeout: 20000 }); } // Going to the storefront remounts the app, so the session is briefly still diff --git a/frontend/tests/e2e/password-reset.spec.ts b/frontend/tests/e2e/password-reset.spec.ts index 5af3745..189ba79 100644 --- a/frontend/tests/e2e/password-reset.spec.ts +++ b/frontend/tests/e2e/password-reset.spec.ts @@ -11,21 +11,31 @@ async function register(page: Page, email: string) { await page.getByRole('textbox', { name: 'Email' }).fill(email); await page.getByLabel('Password').fill(PASSWORD); await page.getByRole('button', { name: 'Create account' }).click(); - await expect(page).toHaveURL(/\/account/); + // Registering now closes the auth modal and returns to the page behind it, so + // the header rather than the URL is what proves the session exists. The wait + // is generous because this is a bcrypt round-trip rather than a render. + await expect(page.getByRole('button', { name: 'My Account' })).toBeVisible({ timeout: 20000 }); } +// Log out lives inside the account view, which is a modal over the storefront. async function logout(page: Page) { - await page.getByRole('button', { name: 'Log out' }).click(); - await expect(page).toHaveURL(/\/$/); + await page.goto('/account'); + await page.getByRole('dialog', { name: 'My Account' }) + .getByRole('button', { name: 'Log out' }).click(); + // A server round-trip followed by re-rendering the storefront behind the + // modal, so the 5s default is too tight when workers run concurrently. + await expect(page).toHaveURL(/\/$/, { timeout: 20000 }); } test.describe('Password reset', () => { test('the login page offers a way to recover a forgotten password', async ({ page }) => { await page.goto('/login'); - await page.getByRole('link', { name: 'Forgot password?' }).click(); + // Recovery is now reached from the login modal rather than a link on a + // page, and its title is the modal's rather than a heading. + await page.getByRole('button', { name: 'Forgot password?' }).click(); await expect(page).toHaveURL(/\/forgot-password/); - await expect(page.getByRole('heading', { name: 'Reset your password' })).toBeVisible(); + await expect(page.getByRole('dialog', { name: 'Reset your password' })).toBeVisible(); }); test('requesting a reset confirms without revealing whether the account exists', async ({ page }) => { @@ -82,8 +92,10 @@ test.describe('Password reset', () => { await page.getByLabel('Confirm new password').fill(NEW_PASSWORD); await page.getByRole('button', { name: 'Set new password' }).click(); - // The reset signs them in, so they land on the account page. - await expect(page).toHaveURL(/\/account/); + // The reset signs them in and closes back to the storefront — the link came + // from an email, so there is no page behind it to return to. + await expect(page.getByRole('button', { name: 'My Account' })).toBeVisible({ timeout: 20000 }); + await page.goto('/account'); await expect(page.getByText(email)).toBeVisible(); // And the new password actually works on a fresh sign-in. @@ -91,8 +103,10 @@ test.describe('Password reset', () => { await page.goto('/login'); await page.getByRole('textbox', { name: 'Email' }).fill(email); await page.getByLabel('Password').fill(NEW_PASSWORD); - await page.getByRole('button', { name: 'Log in' }).click(); - await expect(page).toHaveURL(/\/account/); + // Scoped to the modal: the storefront rendered behind it has a "Log in" + // button of its own, which is what opened this one. + await page.getByRole('dialog', { name: 'Log in' }).getByRole('button', { name: 'Log in' }).click(); + await expect(page.getByRole('button', { name: 'My Account' })).toBeVisible({ timeout: 20000 }); }); test('the old password stops working after a reset', async ({ page, request }) => { @@ -107,7 +121,9 @@ test.describe('Password reset', () => { await page.goto('/login'); await page.getByRole('textbox', { name: 'Email' }).fill(email); await page.getByLabel('Password').fill(PASSWORD); - await page.getByRole('button', { name: 'Log in' }).click(); + // Scoped to the modal: the storefront rendered behind it has a "Log in" + // button of its own, which is what opened this one. + await page.getByRole('dialog', { name: 'Log in' }).getByRole('button', { name: 'Log in' }).click(); await expect(page.getByText('invalid email or password')).toBeVisible(); });