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: (
+
+
+
+
+
+
+
+
+
+
+ {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: (
+
+
+
+
+
+
+
+
+
+ )
+ }
+ ]}
+ />
+ >
+ );
+}
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 (
-
-
- 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();
});