Merge pull request 'Feature/50 auth modal routes' (#58) from feature/50-auth-modal-routes into main
SonarQube Analysis / sonarqube (push) Successful in 3m29s
Tests / backend-unit (push) Successful in 55s
Tests / frontend-e2e (push) Failing after 13m33s

Reviewed-on: #58
This commit was merged in pull request #58.
This commit is contained in:
2026-08-18 17:27:52 -05:00
17 changed files with 496 additions and 307 deletions
+5 -1
View File
@@ -246,6 +246,8 @@ Neither failure mentions the Node version as the cause, and the first one reads
export PATH="/c/Users/tlamb/AppData/Local/nvm/v24.13.1:$PATH"
```
Thom is fine with switching the active version for a test run — `nvm use latest`, then **`nvm use 18.16.1` when finished**, which is not optional since the app's own tooling expects 18.
Unit tests and `tsc` run fine on 18, so a green `npm test` says nothing about whether the other two suites can even start.
### E2E constraints — the local database is never reset
@@ -270,6 +272,7 @@ Integration tests truncate between cases (`resetDb()` in `tests/integration/setu
- **Daily cart reminder emails** fire via `node-cron` inside the app process at 9am container-local time — if the container restarts frequently, reminders could silently stop firing with no alerting on that failure mode.
- **Old single-item checkout route files** (`backend/src/routes/paypal.ts`, `backend/src/routes/demo.ts`) are dead code, unmounted but never deleted — safe cleanup opportunity.
- **`backend/src/routes/shippingAddresses.ts` USPS OAuth token format** was implemented against the current (2026) USPS Addresses API docs at time of writing, using a JSON-body `client_credentials` request — if USPS changes their API again, this is the first place to check.
- **The marketing consent wording is duplicated** between `MARKETING_CONSENT_TEXT` in `backend/src/utils.ts` and the constant of the same name in `frontend/src/customer/AuthForm.tsx`. The server stores its copy verbatim against the customer's consent record, so the whole point is that the record says what the customer actually saw — a label that drifts from the stored string quietly defeats that. This is not hypothetical: before the form was shared there were three wordings in play (the register page's, a shorter one in the cart prompt, and the stored string) and none matched. An e2e test now asserts the rendered label equals the stored wording, which is the only thing spanning the two sides.
- **`TAG_COLORS` is duplicated** between `backend/src/utils.ts` and `frontend/src/admin/Tags.tsx`. The server validates against its copy, so editing one alone makes the admin colour picker offer values that get rejected with a `400`. There's no shared module between backend and frontend in this repo to put it in.
- **The local e2e database accumulates junk indefinitely** — every Playwright run seeds categories, tags, and items that are never cleaned up, so the admin tables and filter drawer fill with `Furniture fmsxb…` noise over time. Harmless, but `npm run db:test:down` + `db:test:up` + `migrate:up` resets it when the clutter starts getting in the way. CI is unaffected (fresh service container per run).
- **The storefront still lists sold items** and the price-range bounds are computed across all items regardless of status. Deliberately left as-is, and now load-bearing: the favorites filter (#35) shows sold favorites on purpose, since an item that just sold is often what the customer came back to look at after the #34 email. Anyone wanting only purchasable stock combines the favorites toggle with the status filter. Worth revisiting if sold stock ever outnumbers available stock — but changing the default would change what a favorites view means.
@@ -282,7 +285,8 @@ Integration tests truncate between cases (`resetDb()` in `tests/integration/setu
- Change admin-configurable settings → `admin_settings` table + `backend/src/routes/adminSettings.ts` + `frontend/src/admin/Settings.tsx`
- Add a new async route → wrap the handler in `asyncRoute()` from `backend/src/asyncRoute.ts`, or a failure will hang the request instead of returning 500
- Change what an item row returns → `backend/src/itemSelect.ts` (one place, used by both the public and admin routes)
- Change how a customer route is framed (modal vs page) → `frontend/src/main.tsx`. `AppRoutes` renders the route table against a *backdrop* location rather than the real one: `/account` is a modal over the page named in `location.state.background`, falling back to the storefront when there is none (a bookmark, an email link, a post-registration redirect). This is the pattern to copy for the sibling dead-end issues (#49, #50) — it came out of #51 — it keeps the URL real and linkable while making sure closing always lands somewhere. The link that opens it must pass `state={{ background: location }}`, or closing goes to the fallback instead of where the customer was.
- Change how a customer route is framed (modal vs page) → `frontend/src/main.tsx`. `AppRoutes` renders the route table against a *backdrop* location rather than the real one: everything in `MODAL_ROUTES` (`/account`, `/login`, `/register`, `/forgot-password`, `/reset-password`) is a modal over the page named in `location.state.background`, falling back to the storefront when there is none (a bookmark, an email link). The link that opens one must pass `state={{ background: location }}`, or closing goes to the fallback instead of where the customer was; steps *within* a flow navigate with `replace` so the whole detour stays one history entry. This is the pattern to copy for the remaining dead-end pages (#52) — it came out of #51 — it keeps the URL real and linkable while making sure closing always lands somewhere. The link that opens it must pass `state={{ background: location }}`, or closing goes to the fallback instead of where the customer was.
- Change sign-in or registration → `frontend/src/customer/AuthForm.tsx`, which is the single implementation. It is rendered both by `AuthRouteModal` (the `/login` and `/register` routes) and by `AuthPromptModal` (the prompt shown when a signed-out visitor adds to the cart, favorites, or filters by favorites). Changing one caller's copy or validation without the other is the drift this deliberately removed.
- Change storefront filtering → `backend/src/itemFilters.ts` (parsing + SQL), `backend/src/routes/filters.ts` (`/api/filters`, the drawer's single fetch), `frontend/src/filters.ts` (state, URL round-trip, tree building), `frontend/src/components/FilterDrawer.tsx`, `frontend/src/components/ActiveFilterChips.tsx`
- Add a filter dimension that depends on who is asking → follow the favorites filter (#35). The identity comes from `req.customerId` (`attachCustomer` runs globally, so it is available on the public `/api/items` too) and is passed into `buildItemFilterSql` as an explicit argument — never parsed from the query string, or a hand-edited URL could name another customer. Each route decides what to do when it cannot satisfy the filter: the storefront answers 401, the admin inventory 400, and the builder throws rather than silently dropping the clause and returning everything.
- Change category/tag management → `backend/src/routes/adminCategories.ts`, `backend/src/routes/adminTags.ts`, `frontend/src/admin/Categories.tsx`, `frontend/src/admin/Tags.tsx`
+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();
});