Feature/50 auth modal routes #58

Merged
bermudalamb merged 2 commits from feature/50-auth-modal-routes into main 2026-08-18 17:27:53 -05:00
16 changed files with 491 additions and 306 deletions
Showing only changes of commit 5ebb366074 - Show all commits
+6 -2
View File
@@ -137,8 +137,12 @@ export default function App() {
</Link>
) : (
<>
<Link to="/login"><Button>Log in</Button></Link>
<Link to="/register"><Button type="primary">Sign up</Button></Link>
<Link to="/login" state={{ background: location }}>
<Button>Log in</Button>
</Link>
<Link to="/register" state={{ background: location }}>
<Button type="primary">Sign up</Button>
</Link>
</>
)}
</div>
+139
View File
@@ -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<string | null>(null);
const [loading, setLoading] = useState(false);
const { refresh } = useCustomerAuth();
async function submit(action: () => Promise<unknown>) {
setLoading(true);
setError(null);
try {
await action();
refresh();
onSuccess();
} catch (err) {
setError((err as Error).message);
} finally {
setLoading(false);
}
}
return (
<>
{error && <Alert type="error" showIcon message={error} style={{ marginBottom: 16 }} />}
<Tabs
activeKey={mode}
onChange={(key) => {
// 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: (
<Form
layout="vertical"
onFinish={(values) =>
submit(() =>
registerCustomer(values.email, values.password, values.name, !!values.marketingConsent)
)
}
>
<Form.Item name="name" label="Name">
<Input autoComplete="name" />
</Form.Item>
<Form.Item name="email" label="Email" rules={[{ required: true, type: 'email' }]}>
<Input autoComplete="email" />
</Form.Item>
<Form.Item
name="password"
label="Password"
rules={[{ required: true, min: 8, message: 'At least 8 characters' }]}
>
<Input.Password autoComplete="new-password" />
</Form.Item>
<Form.Item name="marketingConsent" valuePropName="checked" initialValue={false}>
<Checkbox>{MARKETING_CONSENT_TEXT}</Checkbox>
</Form.Item>
<Button type="primary" htmlType="submit" block loading={loading}>
Create account
</Button>
<Text type="secondary" style={{ fontSize: 12, display: 'block', marginTop: 12 }}>
{/* 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{' '}
<a href="/privacy" target="_blank" rel="noopener noreferrer">Privacy Policy</a>.
</Text>
</Form>
)
},
{
key: 'login',
label: 'Log In',
children: (
<Form
layout="vertical"
onFinish={(values) => submit(() => loginCustomer(values.email, values.password))}
>
<Form.Item name="email" label="Email" rules={[{ required: true, type: 'email' }]}>
<Input autoComplete="email" />
</Form.Item>
<Form.Item name="password" label="Password" rules={[{ required: true }]}>
<Input.Password autoComplete="current-password" />
</Form.Item>
<Button type="primary" htmlType="submit" block loading={loading}>
Log in
</Button>
<Button type="link" style={{ paddingInline: 0, marginTop: 8 }} onClick={onForgotPassword}>
Forgot password?
</Button>
</Form>
)
}
]}
/>
</>
);
}
+23 -79
View File
@@ -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<string | null>(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<AuthMode>('register');
const navigate = useNavigate();
const location = useLocation();
return (
<Modal
@@ -50,50 +25,19 @@ export default function AuthPromptModal({ open, onClose, onSuccess }: Props) {
open={open}
onCancel={onClose}
footer={null}
destroyOnClose
destroyOnHidden
>
{error && <Alert type="error" message={error} style={{ marginBottom: 16 }} />}
<Tabs
activeKey={tab}
onChange={(k) => setTab(k as 'register' | 'login')}
items={[
{
key: 'register',
label: 'Create Account',
children: (
<Form form={form} layout="vertical" onFinish={handleRegister}>
<Form.Item name="name" label="Name">
<Input />
</Form.Item>
<Form.Item name="email" label="Email" rules={[{ required: true, type: 'email' }]}>
<Input />
</Form.Item>
<Form.Item name="password" label="Password" rules={[{ required: true, min: 8, message: 'At least 8 characters' }]}>
<Input.Password />
</Form.Item>
<Form.Item name="marketingConsent" valuePropName="checked" initialValue={false}>
<Checkbox>Send me occasional emails about new one-of-a-kind items.</Checkbox>
</Form.Item>
<Button type="primary" htmlType="submit" block loading={loading}>Create account &amp; continue</Button>
</Form>
)
},
{
key: 'login',
label: 'Log In',
children: (
<Form layout="vertical" onFinish={handleLogin}>
<Form.Item name="email" label="Email" rules={[{ required: true, type: 'email' }]}>
<Input />
</Form.Item>
<Form.Item name="password" label="Password" rules={[{ required: true }]}>
<Input.Password />
</Form.Item>
<Button type="primary" htmlType="submit" block loading={loading}>Log in &amp; continue</Button>
</Form>
)
}
]}
<AuthForm
mode={mode}
onModeChange={setMode}
// Recovery is a route rather than another tab, so leave this prompt and
// open it over whatever is behind — the interrupted action is abandoned
// either way, and stacking a second modal on this one would be worse.
onForgotPassword={() => {
onClose();
navigate('/forgot-password', { state: { background: location } });
}}
onSuccess={onSuccess}
/>
</Modal>
);
+42
View File
@@ -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<AuthMode, string> = {
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 (
<Modal
title={TITLES[mode]}
open
onCancel={onClose}
footer={null}
style={{ maxWidth: 'calc(100vw - 32px)' }}
destroyOnHidden
>
<AuthForm
mode={mode}
onModeChange={(next) => 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}
/>
</Modal>
);
}
+26 -9
View File
@@ -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<string | null>(null);
const [loading, setLoading] = useState(false);
@@ -29,8 +35,14 @@ export default function ForgotPassword() {
}
return (
<Card style={{ maxWidth: 420, margin: '64px auto' }}>
<Title level={3}>Reset your password</Title>
<Modal
title="Reset your password"
open
onCancel={onClose}
footer={null}
style={{ maxWidth: 'calc(100vw - 32px)' }}
destroyOnHidden
>
{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."
/>
<Paragraph style={{ marginTop: 16 }}>
<Link to="/login">Back to sign in</Link>
<Button type="link" style={{ paddingInline: 0 }} onClick={onBackToSignIn}>
Back to sign in
</Button>
</Paragraph>
</>
) : (
@@ -67,10 +81,13 @@ export default function ForgotPassword() {
</Form.Item>
</Form>
<Text type="secondary">
Remembered it? <Link to="/login">Sign in</Link>
Remembered it?{' '}
<Button type="link" style={{ paddingInline: 0 }} onClick={onBackToSignIn}>
Sign in
</Button>
</Text>
</>
)}
</Card>
</Modal>
);
}
-53
View File
@@ -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<string | null>(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 (
<div style={{ maxWidth: 420, margin: '48px auto', padding: '0 16px' }}>
<Card>
<Title level={3}>Log in</Title>
{error && <Alert type="error" message={error} style={{ marginBottom: 16 }} />}
<Form layout="vertical" onFinish={onFinish}>
<Form.Item name="email" label="Email" rules={[{ required: true, type: 'email' }]}>
<Input />
</Form.Item>
<Form.Item name="password" label="Password" rules={[{ required: true }]}>
<Input.Password />
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit" block loading={loading}>Log in</Button>
</Form.Item>
</Form>
<Text type="secondary">
<Link to="/forgot-password">Forgot password?</Link>
<br />
No account yet? <Link to="/register">Create one</Link>
</Text>
</Card>
</div>
);
}
-63
View File
@@ -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<string | null>(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 (
<div style={{ maxWidth: 420, margin: '48px auto', padding: '0 16px' }}>
<Card>
<Title level={3}>Create an account</Title>
{error && <Alert type="error" message={error} style={{ marginBottom: 16 }} />}
<Form layout="vertical" onFinish={onFinish}>
<Form.Item name="name" label="Name">
<Input />
</Form.Item>
<Form.Item name="email" label="Email" rules={[{ required: true, type: 'email' }]}>
<Input />
</Form.Item>
<Form.Item name="password" label="Password" rules={[{ required: true, min: 8, message: 'At least 8 characters' }]}>
<Input.Password />
</Form.Item>
<Form.Item name="marketingConsent" valuePropName="checked" initialValue={false}>
<Checkbox>
Send me occasional emails about new one-of-a-kind items. I can unsubscribe at any time.
</Checkbox>
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit" block loading={loading}>Create account</Button>
</Form.Item>
</Form>
<Text type="secondary">
Already have an account? <Link to="/login">Log in</Link>
</Text>
<br />
<Text type="secondary" style={{ fontSize: 12 }}>
By creating an account you agree to our <Link to="/privacy">Privacy Policy</Link>.
</Text>
</Card>
</div>
);
}
+30 -15
View File
@@ -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<string | null>(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 (
<Card style={{ maxWidth: 420, margin: '64px auto' }}>
<Title level={3}>Reset your password</Title>
<Modal title="Reset your password" open onCancel={onClose} footer={null} destroyOnHidden>
<Alert
type="error"
showIcon
@@ -32,9 +36,11 @@ export default function ResetPassword() {
description="It's missing its reset token. Request a new link and use the most recent email."
/>
<Paragraph style={{ marginTop: 16 }}>
<Link to="/forgot-password">Request a new link</Link>
<Button type="link" style={{ paddingInline: 0 }} onClick={onRequestNewLink}>
Request a new link
</Button>
</Paragraph>
</Card>
</Modal>
);
}
@@ -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 (
<Card style={{ maxWidth: 420, margin: '64px auto' }}>
<Title level={3}>Choose a new password</Title>
<Modal
title="Choose a new password"
open
onCancel={onClose}
footer={null}
style={{ maxWidth: 'calc(100vw - 32px)' }}
destroyOnHidden
>
<Paragraph type="secondary">
Signing in elsewhere will end you'll stay signed in on this device.
</Paragraph>
@@ -91,7 +104,9 @@ export default function ResetPassword() {
</Button>
</Form.Item>
</Form>
<Link to="/login">Back to sign in</Link>
</Card>
<Button type="link" style={{ paddingInline: 0 }} onClick={onBackToSignIn}>
Back to sign in
</Button>
</Modal>
);
}
+36 -12
View File
@@ -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<Location> = { 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 (
<>
<Routes location={backdrop as Location}>
<Route path="/" element={<App />} />
<Route path="/admin" element={<Admin />} />
<Route path="/login" element={<Login />} />
<Route path="/register" element={<Register />} />
<Route path="/cart" element={<Cart />} />
<Route path="/privacy" element={<PrivacyPolicy />} />
<Route path="/verify-email" element={<VerifyEmail />} />
<Route path="/forgot-password" element={<ForgotPassword />} />
<Route path="/reset-password" element={<ResetPassword />} />
</Routes>
{/* Rendered outside the Routes above, which are showing the backdrop. */}
{isAccount && <Account onClose={closeAccount} />}
{modalPath === '/account' && <Account onClose={closeModal} />}
{modalPath === '/login' && (
<AuthRouteModal mode="login" onClose={closeModal} onNavigate={goWithinAuth} />
)}
{modalPath === '/register' && (
<AuthRouteModal mode="register" onClose={closeModal} onNavigate={goWithinAuth} />
)}
{modalPath === '/forgot-password' && (
<ForgotPassword onClose={closeModal} onBackToSignIn={() => goWithinAuth('/login')} />
)}
{modalPath === '/reset-password' && (
<ResetPassword
onClose={closeModal}
onRequestNewLink={() => goWithinAuth('/forgot-password')}
onBackToSignIn={() => goWithinAuth('/login')}
/>
)}
</>
);
}
+14 -15
View File
@@ -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<string> {
//
// 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<string> {
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');
@@ -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 }) => {
@@ -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();
+128 -40
View File
@@ -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();
});
});
+4 -1
View File
@@ -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
+4 -1
View File
@@ -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
+26 -10
View File
@@ -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();
});