Feature/50 auth modal routes #58
@@ -137,8 +137,12 @@ export default function App() {
|
|||||||
</Link>
|
</Link>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Link to="/login"><Button>Log in</Button></Link>
|
<Link to="/login" state={{ background: location }}>
|
||||||
<Link to="/register"><Button type="primary">Sign up</Button></Link>
|
<Button>Log in</Button>
|
||||||
|
</Link>
|
||||||
|
<Link to="/register" state={{ background: location }}>
|
||||||
|
<Button type="primary">Sign up</Button>
|
||||||
|
</Link>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Modal, Form, Input, Button, Checkbox, Tabs, Alert } from 'antd';
|
import Modal from 'antd/es/modal';
|
||||||
import { registerCustomer, loginCustomer } from './customerApi';
|
import { useNavigate, useLocation } from 'react-router-dom';
|
||||||
import { useCustomerAuth } from './CustomerAuthContext';
|
import AuthForm, { AuthMode } from './AuthForm';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -9,40 +9,15 @@ interface Props {
|
|||||||
onSuccess: () => void;
|
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) {
|
export default function AuthPromptModal({ open, onClose, onSuccess }: Props) {
|
||||||
const [tab, setTab] = useState<'register' | 'login'>('register');
|
const [mode, setMode] = useState<AuthMode>('register');
|
||||||
const [error, setError] = useState<string | null>(null);
|
const navigate = useNavigate();
|
||||||
const [loading, setLoading] = useState(false);
|
const location = useLocation();
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
@@ -50,50 +25,19 @@ export default function AuthPromptModal({ open, onClose, onSuccess }: Props) {
|
|||||||
open={open}
|
open={open}
|
||||||
onCancel={onClose}
|
onCancel={onClose}
|
||||||
footer={null}
|
footer={null}
|
||||||
destroyOnClose
|
destroyOnHidden
|
||||||
>
|
>
|
||||||
{error && <Alert type="error" message={error} style={{ marginBottom: 16 }} />}
|
<AuthForm
|
||||||
<Tabs
|
mode={mode}
|
||||||
activeKey={tab}
|
onModeChange={setMode}
|
||||||
onChange={(k) => setTab(k as 'register' | 'login')}
|
// Recovery is a route rather than another tab, so leave this prompt and
|
||||||
items={[
|
// open it over whatever is behind — the interrupted action is abandoned
|
||||||
{
|
// either way, and stacking a second modal on this one would be worse.
|
||||||
key: 'register',
|
onForgotPassword={() => {
|
||||||
label: 'Create Account',
|
onClose();
|
||||||
children: (
|
navigate('/forgot-password', { state: { background: location } });
|
||||||
<Form form={form} layout="vertical" onFinish={handleRegister}>
|
}}
|
||||||
<Form.Item name="name" label="Name">
|
onSuccess={onSuccess}
|
||||||
<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 & 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 & continue</Button>
|
|
||||||
</Form>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
]}
|
|
||||||
/>
|
/>
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,14 +3,20 @@ import Form from 'antd/es/form';
|
|||||||
import Input from 'antd/es/input';
|
import Input from 'antd/es/input';
|
||||||
import Button from 'antd/es/button';
|
import Button from 'antd/es/button';
|
||||||
import Typography from 'antd/es/typography';
|
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 Alert from 'antd/es/alert';
|
||||||
import { Link } from 'react-router-dom';
|
|
||||||
import { requestPasswordReset } from './customerApi';
|
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 [sent, setSent] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@@ -29,8 +35,14 @@ export default function ForgotPassword() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card style={{ maxWidth: 420, margin: '64px auto' }}>
|
<Modal
|
||||||
<Title level={3}>Reset your password</Title>
|
title="Reset your password"
|
||||||
|
open
|
||||||
|
onCancel={onClose}
|
||||||
|
footer={null}
|
||||||
|
style={{ maxWidth: 'calc(100vw - 32px)' }}
|
||||||
|
destroyOnHidden
|
||||||
|
>
|
||||||
|
|
||||||
{sent ? (
|
{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."
|
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 }}>
|
<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>
|
</Paragraph>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
@@ -67,10 +81,13 @@ export default function ForgotPassword() {
|
|||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Form>
|
</Form>
|
||||||
<Text type="secondary">
|
<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>
|
</Text>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Modal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -3,28 +3,32 @@ import Form from 'antd/es/form';
|
|||||||
import Input from 'antd/es/input';
|
import Input from 'antd/es/input';
|
||||||
import Button from 'antd/es/button';
|
import Button from 'antd/es/button';
|
||||||
import Typography from 'antd/es/typography';
|
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 Alert from 'antd/es/alert';
|
||||||
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
|
import { useSearchParams } from 'react-router-dom';
|
||||||
import { resetPassword } from './customerApi';
|
import { resetPassword } from './customerApi';
|
||||||
import { useCustomerAuth } from './CustomerAuthContext';
|
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 [searchParams] = useSearchParams();
|
||||||
const token = searchParams.get('token');
|
const token = searchParams.get('token');
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const navigate = useNavigate();
|
|
||||||
const { refresh } = useCustomerAuth();
|
const { refresh } = useCustomerAuth();
|
||||||
|
|
||||||
// A link without a token can't do anything, so say so rather than showing a
|
// A link without a token can't do anything, so say so rather than showing a
|
||||||
// form that is guaranteed to fail on submit.
|
// form that is guaranteed to fail on submit.
|
||||||
if (!token) {
|
if (!token) {
|
||||||
return (
|
return (
|
||||||
<Card style={{ maxWidth: 420, margin: '64px auto' }}>
|
<Modal title="Reset your password" open onCancel={onClose} footer={null} destroyOnHidden>
|
||||||
<Title level={3}>Reset your password</Title>
|
|
||||||
<Alert
|
<Alert
|
||||||
type="error"
|
type="error"
|
||||||
showIcon
|
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."
|
description="It's missing its reset token. Request a new link and use the most recent email."
|
||||||
/>
|
/>
|
||||||
<Paragraph style={{ marginTop: 16 }}>
|
<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>
|
</Paragraph>
|
||||||
</Card>
|
</Modal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,9 +50,10 @@ export default function ResetPassword() {
|
|||||||
try {
|
try {
|
||||||
await resetPassword(token as string, values.password);
|
await resetPassword(token as string, values.password);
|
||||||
// The server signs the customer in as part of the reset, so pick up the
|
// 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();
|
refresh();
|
||||||
navigate('/account', { replace: true });
|
onClose();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError((err as Error).message);
|
setError((err as Error).message);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -55,8 +62,14 @@ export default function ResetPassword() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card style={{ maxWidth: 420, margin: '64px auto' }}>
|
<Modal
|
||||||
<Title level={3}>Choose a new password</Title>
|
title="Choose a new password"
|
||||||
|
open
|
||||||
|
onCancel={onClose}
|
||||||
|
footer={null}
|
||||||
|
style={{ maxWidth: 'calc(100vw - 32px)' }}
|
||||||
|
destroyOnHidden
|
||||||
|
>
|
||||||
<Paragraph type="secondary">
|
<Paragraph type="secondary">
|
||||||
Signing in elsewhere will end — you'll stay signed in on this device.
|
Signing in elsewhere will end — you'll stay signed in on this device.
|
||||||
</Paragraph>
|
</Paragraph>
|
||||||
@@ -91,7 +104,9 @@ export default function ResetPassword() {
|
|||||||
</Button>
|
</Button>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Form>
|
</Form>
|
||||||
<Link to="/login">Back to sign in</Link>
|
<Button type="link" style={{ paddingInline: 0 }} onClick={onBackToSignIn}>
|
||||||
</Card>
|
Back to sign in
|
||||||
|
</Button>
|
||||||
|
</Modal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+36
-12
@@ -6,8 +6,7 @@ import { ConfigProvider, theme as antdTheme } from 'antd';
|
|||||||
import 'antd/dist/reset.css';
|
import 'antd/dist/reset.css';
|
||||||
import App from './App';
|
import App from './App';
|
||||||
import Admin from './admin/Admin';
|
import Admin from './admin/Admin';
|
||||||
import Login from './customer/Login';
|
import AuthRouteModal from './customer/AuthRouteModal';
|
||||||
import Register from './customer/Register';
|
|
||||||
import Account from './customer/Account';
|
import Account from './customer/Account';
|
||||||
import PrivacyPolicy from './customer/PrivacyPolicy';
|
import PrivacyPolicy from './customer/PrivacyPolicy';
|
||||||
import VerifyEmail from './customer/VerifyEmail';
|
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.
|
// case, and a modal floating on nothing has nowhere to close back to.
|
||||||
const STOREFRONT_BACKDROP: Partial<Location> = { pathname: '/', search: '', hash: '' };
|
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
|
// Respects the OS-level "reduce motion" accessibility setting by turning off
|
||||||
// antd's transitions. Beyond the accessibility win, animated popups are a
|
// 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
|
// 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 location = useLocation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const state = location.state as { background?: Location } | null;
|
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
|
// In-app navigation names the page to render behind. Anything else — a
|
||||||
// bookmark, an email link, the redirect after registering — falls back to the
|
// bookmark, an email link, a link in a password reset message — falls back to
|
||||||
// storefront, so closing always lands somewhere real.
|
// the storefront, so closing always lands somewhere real.
|
||||||
const background = state?.background;
|
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
|
// 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.
|
// pressing Back do the same thing and neither leaves a dead entry behind.
|
||||||
if (background) navigate(-1);
|
if (background) navigate(-1);
|
||||||
else navigate('/', { replace: true });
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<Routes location={backdrop as Location}>
|
<Routes location={backdrop as Location}>
|
||||||
<Route path="/" element={<App />} />
|
<Route path="/" element={<App />} />
|
||||||
<Route path="/admin" element={<Admin />} />
|
<Route path="/admin" element={<Admin />} />
|
||||||
<Route path="/login" element={<Login />} />
|
|
||||||
<Route path="/register" element={<Register />} />
|
|
||||||
<Route path="/cart" element={<Cart />} />
|
<Route path="/cart" element={<Cart />} />
|
||||||
<Route path="/privacy" element={<PrivacyPolicy />} />
|
<Route path="/privacy" element={<PrivacyPolicy />} />
|
||||||
<Route path="/verify-email" element={<VerifyEmail />} />
|
<Route path="/verify-email" element={<VerifyEmail />} />
|
||||||
<Route path="/forgot-password" element={<ForgotPassword />} />
|
|
||||||
<Route path="/reset-password" element={<ResetPassword />} />
|
|
||||||
</Routes>
|
</Routes>
|
||||||
{/* Rendered outside the Routes above, which are showing the backdrop. */}
|
{/* 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')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,20 +4,19 @@ const PASSWORD = 'supersecret123';
|
|||||||
|
|
||||||
const uniqueEmail = () => `account-${Date.now().toString(36)}${Math.random().toString(36).slice(2, 7)}@example.com`;
|
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.
|
// 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();
|
const email = uniqueEmail();
|
||||||
await page.goto('/register');
|
await page.goto('/register');
|
||||||
await page.getByRole('textbox', { name: 'Email' }).fill(email);
|
await page.getByRole('textbox', { name: 'Email' }).fill(email);
|
||||||
await page.getByLabel('Password').fill(PASSWORD);
|
await page.getByLabel('Password').fill(PASSWORD);
|
||||||
await page.getByRole('button', { name: 'Create account' }).click();
|
await page.getByRole('button', { name: 'Create account' }).click();
|
||||||
// Registration is a bcrypt round-trip, not a render. Unloaded it takes about
|
await expect(page.getByRole('button', { name: 'My Account' })).toBeVisible({ timeout: 20000 });
|
||||||
// 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);
|
|
||||||
return email;
|
return email;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,7 +29,7 @@ async function closeAccount(page: Page) {
|
|||||||
|
|
||||||
test.describe('My Account opens as a modal', () => {
|
test.describe('My Account opens as a modal', () => {
|
||||||
test('opens over the storefront and closes back to it, filters and all', async ({ page }) => {
|
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
|
// A filtered view, to prove closing restores where the customer actually
|
||||||
// was rather than a bare storefront.
|
// 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 }) => {
|
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.goto('/?max_price=50000');
|
||||||
await page.getByRole('button', { name: 'My Account' }).click();
|
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 }) => {
|
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
|
// 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.
|
// 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 }) => {
|
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.goto('/?max_price=50000');
|
||||||
await page.getByRole('button', { name: 'My Account' }).click();
|
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 }) => {
|
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');
|
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 }) => {
|
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 page.goto('/account');
|
||||||
|
|
||||||
await accountModal(page).getByRole('button', { name: 'Delete my account' }).click();
|
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 }) => {
|
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.setViewportSize({ width: 390, height: 664 });
|
||||||
await page.goto('/account');
|
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.getByRole('textbox', { name: 'Email' }).fill(email);
|
||||||
await page.getByLabel('Password').fill(PASSWORD);
|
await page.getByLabel('Password').fill(PASSWORD);
|
||||||
await page.getByRole('button', { name: 'Create account' }).click();
|
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) {
|
async function customerRow(page: Page, email: string) {
|
||||||
@@ -39,7 +42,9 @@ test.describe('Disabling a customer account', () => {
|
|||||||
await page.goto('/login');
|
await page.goto('/login');
|
||||||
await page.getByRole('textbox', { name: 'Email' }).fill(email);
|
await page.getByRole('textbox', { name: 'Email' }).fill(email);
|
||||||
await page.getByLabel('Password').fill(PASSWORD);
|
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();
|
await expect(page.getByText(/disabled/i)).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -77,8 +82,10 @@ test.describe('Disabling a customer account', () => {
|
|||||||
await page.goto('/login');
|
await page.goto('/login');
|
||||||
await page.getByRole('textbox', { name: 'Email' }).fill(email);
|
await page.getByRole('textbox', { name: 'Email' }).fill(email);
|
||||||
await page.getByLabel('Password').fill(PASSWORD);
|
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"
|
||||||
await expect(page).toHaveURL(/\/account/);
|
// 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 }) => {
|
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.getByRole('textbox', { name: 'Email' }).fill(email);
|
||||||
await page.getByLabel('Password').fill('supersecret123');
|
await page.getByLabel('Password').fill('supersecret123');
|
||||||
await page.getByRole('button', { name: 'Create account' }).click();
|
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}`);
|
const added = await page.request.post(`/api/cart/items/${itemId}`);
|
||||||
expect(added.status()).toBe(201);
|
expect(added.status()).toBe(201);
|
||||||
@@ -83,7 +83,7 @@ test.describe('Admin reserved items', () => {
|
|||||||
await page.getByRole('textbox', { name: 'Email' }).fill(email);
|
await page.getByRole('textbox', { name: 'Email' }).fill(email);
|
||||||
await page.getByLabel('Password').fill('supersecret123');
|
await page.getByLabel('Password').fill('supersecret123');
|
||||||
await page.getByRole('button', { name: 'Create account' }).click();
|
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.goto('/admin');
|
||||||
await page.getByRole('tab', { name: 'Customers' }).click();
|
await page.getByRole('tab', { name: 'Customers' }).click();
|
||||||
|
|||||||
+128
-40
@@ -1,64 +1,92 @@
|
|||||||
import { test, expect } from '@playwright/test';
|
import { test, expect, Page } from '@playwright/test';
|
||||||
|
|
||||||
|
const PASSWORD = 'supersecret123';
|
||||||
|
|
||||||
function uniqueEmail(): string {
|
function uniqueEmail(): string {
|
||||||
return `playwright-${Date.now()}-${Math.floor(Math.random() * 10000)}@example.com`;
|
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.describe('Customer accounts', () => {
|
||||||
test('marketing consent checkbox is unchecked by default', async ({ page }) => {
|
test('marketing consent checkbox is unchecked by default', async ({ page }) => {
|
||||||
await page.goto('/register');
|
await page.goto('/register');
|
||||||
await expect(page.getByRole('checkbox')).not.toBeChecked();
|
await expect(page.getByRole('checkbox')).not.toBeChecked();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('registers a new account and lands on the account page', async ({ page }) => {
|
test('the consent label is the exact wording the server records', async ({ page }) => {
|
||||||
const email = uniqueEmail();
|
|
||||||
await page.goto('/register');
|
await page.goto('/register');
|
||||||
await page.getByRole('textbox', { name: 'Email' }).fill(email);
|
|
||||||
await page.getByLabel('Password').fill('supersecret123');
|
// The stored consent text is kept verbatim so the record says what the
|
||||||
await page.getByRole('button', { name: 'Create account' }).click();
|
// customer actually saw. Three different wordings were in circulation
|
||||||
await expect(page).toHaveURL(/\/account/);
|
// 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();
|
await expect(page.getByText(email)).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('rejects login with the wrong password', async ({ page }) => {
|
test('rejects login with the wrong password', async ({ page }) => {
|
||||||
const email = uniqueEmail();
|
const email = uniqueEmail();
|
||||||
await page.goto('/register');
|
await register(page, email);
|
||||||
await page.getByRole('textbox', { name: 'Email' }).fill(email);
|
await openAccount(page);
|
||||||
await page.getByLabel('Password').fill('supersecret123');
|
|
||||||
await page.getByRole('button', { name: 'Create account' }).click();
|
|
||||||
await expect(page).toHaveURL(/\/account/);
|
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Log out' }).click();
|
await page.getByRole('button', { name: 'Log out' }).click();
|
||||||
|
|
||||||
await page.goto('/login');
|
await page.goto('/login');
|
||||||
await page.getByRole('textbox', { name: 'Email' }).fill(email);
|
await page.getByRole('textbox', { name: 'Email' }).fill(email);
|
||||||
await page.getByLabel('Password').fill('wrong-password');
|
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();
|
await expect(page.getByText('invalid email or password')).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('logging out returns to the home page and resets the header', async ({ page }) => {
|
test('logging out returns to the home page and resets the header', async ({ page }) => {
|
||||||
const email = uniqueEmail();
|
await register(page, uniqueEmail());
|
||||||
await page.goto('/register');
|
await openAccount(page);
|
||||||
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 page.getByRole('button', { name: 'Log out' }).click();
|
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: 'Log in' })).toBeVisible();
|
||||||
await expect(page.getByRole('button', { name: 'Sign up' })).toBeVisible();
|
await expect(page.getByRole('button', { name: 'Sign up' })).toBeVisible();
|
||||||
await expect(page.getByRole('button', { name: 'My Account' })).toBeHidden();
|
await expect(page.getByRole('button', { name: 'My Account' })).toBeHidden();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('the logged-out header survives a reload', async ({ page }) => {
|
test('the logged-out header survives a reload', async ({ page }) => {
|
||||||
const email = uniqueEmail();
|
await register(page, uniqueEmail());
|
||||||
await page.goto('/register');
|
await openAccount(page);
|
||||||
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 page.getByRole('button', { name: 'Log out' }).click();
|
await page.getByRole('button', { name: 'Log out' }).click();
|
||||||
await expect(page.getByRole('button', { name: 'Log in' })).toBeVisible();
|
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 }) => {
|
test('logging out does not leave the account page on the back stack', async ({ page }) => {
|
||||||
const email = uniqueEmail();
|
await register(page, uniqueEmail());
|
||||||
await page.goto('/register');
|
await openAccount(page);
|
||||||
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 page.getByRole('button', { name: 'Log out' }).click();
|
await page.getByRole('button', { name: 'Log out' }).click();
|
||||||
await expect(page).toHaveURL(/\/$/);
|
await expect(page).toHaveURL(/\/$/, { timeout: 20000 });
|
||||||
|
|
||||||
await page.goBack();
|
await page.goBack();
|
||||||
await expect(page).not.toHaveURL(/\/account/);
|
await expect(page).not.toHaveURL(/\/account/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('a failed logout says so instead of appearing to succeed', async ({ page }) => {
|
test('a failed logout says so instead of appearing to succeed', async ({ page }) => {
|
||||||
const email = uniqueEmail();
|
await register(page, uniqueEmail());
|
||||||
await page.goto('/register');
|
await openAccount(page);
|
||||||
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 page.route('**/api/customers/logout', (route) =>
|
await page.route('**/api/customers/logout', (route) =>
|
||||||
route.fulfill({ status: 500, contentType: 'application/json', body: '{"error":"internal error"}' })
|
route.fulfill({ status: 500, contentType: 'application/json', body: '{"error":"internal error"}' })
|
||||||
@@ -105,3 +125,71 @@ test.describe('Customer accounts', () => {
|
|||||||
await expect(page).toHaveURL(/\/account/);
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -30,7 +30,10 @@ async function register(page: Page) {
|
|||||||
await page.getByRole('textbox', { name: 'Email' }).fill(uniqueEmail());
|
await page.getByRole('textbox', { name: 'Email' }).fill(uniqueEmail());
|
||||||
await page.getByLabel('Password').fill(PASSWORD);
|
await page.getByLabel('Password').fill(PASSWORD);
|
||||||
await page.getByRole('button', { name: 'Create account' }).click();
|
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
|
// The session is still resolving for a moment after a remount, and the
|
||||||
|
|||||||
@@ -20,7 +20,10 @@ async function register(page: Page, email: string) {
|
|||||||
await page.getByRole('textbox', { name: 'Email' }).fill(email);
|
await page.getByRole('textbox', { name: 'Email' }).fill(email);
|
||||||
await page.getByLabel('Password').fill(PASSWORD);
|
await page.getByLabel('Password').fill(PASSWORD);
|
||||||
await page.getByRole('button', { name: 'Create account' }).click();
|
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
|
// Going to the storefront remounts the app, so the session is briefly still
|
||||||
|
|||||||
@@ -11,21 +11,31 @@ async function register(page: Page, email: string) {
|
|||||||
await page.getByRole('textbox', { name: 'Email' }).fill(email);
|
await page.getByRole('textbox', { name: 'Email' }).fill(email);
|
||||||
await page.getByLabel('Password').fill(PASSWORD);
|
await page.getByLabel('Password').fill(PASSWORD);
|
||||||
await page.getByRole('button', { name: 'Create account' }).click();
|
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) {
|
async function logout(page: Page) {
|
||||||
await page.getByRole('button', { name: 'Log out' }).click();
|
await page.goto('/account');
|
||||||
await expect(page).toHaveURL(/\/$/);
|
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.describe('Password reset', () => {
|
||||||
test('the login page offers a way to recover a forgotten password', async ({ page }) => {
|
test('the login page offers a way to recover a forgotten password', async ({ page }) => {
|
||||||
await page.goto('/login');
|
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).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 }) => {
|
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.getByLabel('Confirm new password').fill(NEW_PASSWORD);
|
||||||
await page.getByRole('button', { name: 'Set new password' }).click();
|
await page.getByRole('button', { name: 'Set new password' }).click();
|
||||||
|
|
||||||
// The reset signs them in, so they land on the account page.
|
// The reset signs them in and closes back to the storefront — the link came
|
||||||
await expect(page).toHaveURL(/\/account/);
|
// 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();
|
await expect(page.getByText(email)).toBeVisible();
|
||||||
|
|
||||||
// And the new password actually works on a fresh sign-in.
|
// 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.goto('/login');
|
||||||
await page.getByRole('textbox', { name: 'Email' }).fill(email);
|
await page.getByRole('textbox', { name: 'Email' }).fill(email);
|
||||||
await page.getByLabel('Password').fill(NEW_PASSWORD);
|
await page.getByLabel('Password').fill(NEW_PASSWORD);
|
||||||
await page.getByRole('button', { name: 'Log in' }).click();
|
// Scoped to the modal: the storefront rendered behind it has a "Log in"
|
||||||
await expect(page).toHaveURL(/\/account/);
|
// 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 }) => {
|
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.goto('/login');
|
||||||
await page.getByRole('textbox', { name: 'Email' }).fill(email);
|
await page.getByRole('textbox', { name: 'Email' }).fill(email);
|
||||||
await page.getByLabel('Password').fill(PASSWORD);
|
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();
|
await expect(page.getByText('invalid email or password')).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user