Feature/50 auth modal routes #58
@@ -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>
|
||||
|
||||
@@ -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 { 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 & 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>
|
||||
)
|
||||
}
|
||||
]}
|
||||
<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>
|
||||
);
|
||||
|
||||
@@ -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 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 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
@@ -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')}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user