feat(frontend): let a customer change their own name, password and email (#111) #120
@@ -11,6 +11,7 @@ import { useNavigate } from 'react-router-dom';
|
||||
import { fetchMyOrders, OrderHistoryItem, updateConsent, exportMyData, deleteMyAccount } from './customerApi';
|
||||
import { setFavoriteAlerts } from './favoritesApi';
|
||||
import { useCustomerAuth } from './CustomerAuthContext';
|
||||
import AccountDetails from './AccountDetails';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
@@ -110,6 +111,9 @@ export default function Account({ onClose }: Props) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Divider />
|
||||
<AccountDetails customer={customer} onChanged={refresh} />
|
||||
|
||||
<Divider />
|
||||
<Space align="center">
|
||||
<Switch checked={customer.marketing_consent} onChange={handleConsentToggle} />
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
import { useState } from 'react';
|
||||
import Form from 'antd/es/form';
|
||||
import Input from 'antd/es/input';
|
||||
import Button from 'antd/es/button';
|
||||
import Alert from 'antd/es/alert';
|
||||
import Collapse from 'antd/es/collapse';
|
||||
import Typography from 'antd/es/typography';
|
||||
import message from 'antd/es/message';
|
||||
import { Customer, updateMyName, changeMyPassword, changeMyEmail } from './customerApi';
|
||||
|
||||
const { Title, Paragraph } = Typography;
|
||||
|
||||
type Props = Readonly<{
|
||||
customer: Customer;
|
||||
// Re-reads the session so the rest of the account view stops showing the old
|
||||
// name and the old verification state.
|
||||
onChanged: () => void;
|
||||
}>;
|
||||
|
||||
// Changing an email address or a password has a consequence the customer cannot
|
||||
// see from the form: the new address needs verifying and the old one is told,
|
||||
// and a password change signs other devices out. Both are stated above the
|
||||
// fields rather than reported after the fact, so the surprise arrives while
|
||||
// there is still a chance to back out.
|
||||
export default function AccountDetails({ customer, onChanged }: Props) {
|
||||
const [nameError, setNameError] = useState<string | null>(null);
|
||||
const [emailError, setEmailError] = useState<string | null>(null);
|
||||
const [passwordError, setPasswordError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState<'name' | 'email' | 'password' | null>(null);
|
||||
const [passwordForm] = Form.useForm();
|
||||
const [emailForm] = Form.useForm();
|
||||
|
||||
async function saveName(values: { firstName: string; lastName: string }) {
|
||||
setBusy('name');
|
||||
setNameError(null);
|
||||
try {
|
||||
await updateMyName(values.firstName, values.lastName);
|
||||
onChanged();
|
||||
message.success('Name updated');
|
||||
} catch (err) {
|
||||
setNameError((err as Error).message);
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveEmail(values: { emailPassword: string; newEmail: string }) {
|
||||
setBusy('email');
|
||||
setEmailError(null);
|
||||
try {
|
||||
await changeMyEmail(values.emailPassword, values.newEmail);
|
||||
onChanged();
|
||||
emailForm.resetFields();
|
||||
message.success('Email changed. Check the new address for a verification link.');
|
||||
} catch (err) {
|
||||
setEmailError((err as Error).message);
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function savePassword(values: { currentPassword: string; newPassword: string }) {
|
||||
setBusy('password');
|
||||
setPasswordError(null);
|
||||
try {
|
||||
await changeMyPassword(values.currentPassword, values.newPassword);
|
||||
// Nothing to refresh: this session is deliberately the one kept alive.
|
||||
// Clearing the fields matters more, since they hold both passwords.
|
||||
passwordForm.resetFields();
|
||||
message.success('Password changed. Other devices have been signed out.');
|
||||
} catch (err) {
|
||||
setPasswordError((err as Error).message);
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Title level={5}>Your details</Title>
|
||||
{nameError && <Alert type="error" showIcon message={nameError} style={{ marginBottom: 16 }} />}
|
||||
<Form
|
||||
layout="vertical"
|
||||
onFinish={saveName}
|
||||
initialValues={{ firstName: customer.first_name ?? '', lastName: customer.last_name ?? '' }}
|
||||
>
|
||||
<Form.Item
|
||||
name="firstName"
|
||||
label="First name"
|
||||
rules={[{ required: true, whitespace: true, message: 'First name is required' }]}
|
||||
>
|
||||
<Input autoComplete="given-name" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="lastName"
|
||||
label="Last name"
|
||||
rules={[{ required: true, whitespace: true, message: 'Last name is required' }]}
|
||||
>
|
||||
<Input autoComplete="family-name" />
|
||||
</Form.Item>
|
||||
<Form.Item style={{ marginBottom: 0 }}>
|
||||
<Button type="primary" htmlType="submit" loading={busy === 'name'}>
|
||||
Save name
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
{/* Collapsed by default. Both are rare, deliberate actions, and leaving
|
||||
them expanded would push order history and the account controls below
|
||||
the fold for everyone who never uses them. */}
|
||||
<Collapse
|
||||
style={{ marginTop: 16 }}
|
||||
items={[
|
||||
{
|
||||
key: 'email',
|
||||
label: 'Change your email address',
|
||||
children: (
|
||||
<>
|
||||
<Paragraph type="secondary">
|
||||
Your new address needs verifying before it can be used to sign in or reset your
|
||||
password. We will also tell {customer.email} that the address was changed.
|
||||
</Paragraph>
|
||||
{emailError && (
|
||||
<Alert type="error" showIcon message={emailError} style={{ marginBottom: 16 }} />
|
||||
)}
|
||||
<Form layout="vertical" form={emailForm} onFinish={saveEmail}>
|
||||
<Form.Item
|
||||
name="newEmail"
|
||||
label="New email address"
|
||||
rules={[{ required: true, type: 'email', message: 'Enter a valid email address' }]}
|
||||
>
|
||||
<Input autoComplete="email" />
|
||||
</Form.Item>
|
||||
{/* Asked for because a live session alone is not enough to move
|
||||
the address a password reset would be sent to. */}
|
||||
<Form.Item
|
||||
name="emailPassword"
|
||||
label="Your password"
|
||||
rules={[{ required: true, message: 'Your password is required' }]}
|
||||
>
|
||||
<Input.Password autoComplete="current-password" />
|
||||
</Form.Item>
|
||||
<Form.Item style={{ marginBottom: 0 }}>
|
||||
<Button type="primary" htmlType="submit" loading={busy === 'email'}>
|
||||
Change email
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</>
|
||||
)
|
||||
},
|
||||
{
|
||||
key: 'password',
|
||||
label: 'Change your password',
|
||||
children: (
|
||||
<>
|
||||
<Paragraph type="secondary">
|
||||
Signing in elsewhere will end. You will stay signed in on this device.
|
||||
</Paragraph>
|
||||
{passwordError && (
|
||||
<Alert type="error" showIcon message={passwordError} style={{ marginBottom: 16 }} />
|
||||
)}
|
||||
<Form layout="vertical" form={passwordForm} onFinish={savePassword}>
|
||||
<Form.Item
|
||||
name="currentPassword"
|
||||
label="Current password"
|
||||
rules={[{ required: true, message: 'Your current password is required' }]}
|
||||
>
|
||||
<Input.Password autoComplete="current-password" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="newPassword"
|
||||
label="New password"
|
||||
rules={[{ required: true, min: 8, message: 'At least 8 characters' }]}
|
||||
>
|
||||
<Input.Password autoComplete="new-password" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="confirmPassword"
|
||||
label="Confirm new password"
|
||||
dependencies={['newPassword']}
|
||||
rules={[
|
||||
{ required: true, message: 'Confirm the password' },
|
||||
({ getFieldValue }) => ({
|
||||
validator: (_, value) =>
|
||||
!value || getFieldValue('newPassword') === value
|
||||
? Promise.resolve()
|
||||
: Promise.reject(new Error('The passwords do not match'))
|
||||
})
|
||||
]}
|
||||
>
|
||||
<Input.Password autoComplete="new-password" />
|
||||
</Form.Item>
|
||||
<Form.Item style={{ marginBottom: 0 }}>
|
||||
<Button type="primary" htmlType="submit" loading={busy === 'password'}>
|
||||
Change password
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</>
|
||||
)
|
||||
}
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -110,3 +110,35 @@ export function resetPassword(token: string, password: string): Promise<Customer
|
||||
body: JSON.stringify({ token, password })
|
||||
}).then(res => handle<Customer>(res));
|
||||
}
|
||||
|
||||
export function updateMyName(firstName: string, lastName: string): Promise<Customer> {
|
||||
return fetch('/api/customers/me', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ firstName, lastName })
|
||||
}).then(res => handle<Customer>(res));
|
||||
}
|
||||
|
||||
export function changeMyPassword(currentPassword: string, newPassword: string): Promise<void> {
|
||||
// Answers 204 with no body, so handle() would throw parsing JSON on success.
|
||||
// The failure case still has to reject: the server's message names which of
|
||||
// the two passwords was wrong, and that is the only useful thing to show.
|
||||
return fetch('/api/customers/change-password', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ currentPassword, newPassword })
|
||||
}).then(async (res) => {
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data.error || 'Request failed');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function changeMyEmail(currentPassword: string, email: string): Promise<Customer> {
|
||||
return fetch('/api/customers/me/email', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ currentPassword, email })
|
||||
}).then(res => handle<Customer>(res));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
import { test, expect, Page } from './fixtures';
|
||||
|
||||
const PASSWORD = 'supersecret123';
|
||||
|
||||
const uniqueEmail = () => `details-${Date.now().toString(36)}${Math.random().toString(36).slice(2, 7)}@example.com`;
|
||||
|
||||
// Same generous wait as the other account specs: registration is a bcrypt
|
||||
// round-trip, not a render, and runs 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.getByRole('textbox', { name: 'First name' }).fill('Test');
|
||||
await page.getByRole('textbox', { name: 'Last name' }).fill('Customer');
|
||||
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 });
|
||||
return email;
|
||||
}
|
||||
|
||||
const accountModal = (page: Page) => page.getByRole('dialog', { name: 'My Account' });
|
||||
|
||||
async function signIn(page: Page, email: string, password: string) {
|
||||
await page.goto('/login');
|
||||
await page.getByRole('textbox', { name: 'Email' }).fill(email);
|
||||
await page.getByLabel('Password').fill(password);
|
||||
await page.getByRole('dialog', { name: 'Log in' }).getByRole('button', { name: 'Log in' }).click();
|
||||
}
|
||||
|
||||
test.describe('Managing your own details', () => {
|
||||
test('saves a new name, and it survives a reload', async ({ page }) => {
|
||||
await registerCustomer(page);
|
||||
await page.goto('/account');
|
||||
|
||||
const modal = accountModal(page);
|
||||
await modal.getByLabel('First name', { exact: true }).fill('Ada');
|
||||
await modal.getByLabel('Last name', { exact: true }).fill('Lovelace');
|
||||
await modal.getByRole('button', { name: 'Save name' }).click();
|
||||
|
||||
await expect(page.getByText('Name updated')).toBeVisible();
|
||||
|
||||
// Reloaded rather than re-read from component state, which would pass even
|
||||
// if nothing had been persisted.
|
||||
await page.reload();
|
||||
await expect(accountModal(page).getByLabel('First name', { exact: true })).toHaveValue('Ada');
|
||||
await expect(accountModal(page).getByLabel('Last name', { exact: true })).toHaveValue('Lovelace');
|
||||
});
|
||||
|
||||
test('refuses to save a blank name', async ({ page }) => {
|
||||
await registerCustomer(page);
|
||||
await page.goto('/account');
|
||||
|
||||
const modal = accountModal(page);
|
||||
await modal.getByLabel('First name', { exact: true }).fill('');
|
||||
await modal.getByRole('button', { name: 'Save name' }).click();
|
||||
|
||||
await expect(modal.getByText('First name is required')).toBeVisible();
|
||||
});
|
||||
|
||||
// The assertion that matters for a password change: not that the form said
|
||||
// something reassuring, but that the old credential has actually stopped
|
||||
// opening the account.
|
||||
test('changes the password, leaving the old one dead and the new one working', async ({ page }) => {
|
||||
const email = await registerCustomer(page);
|
||||
const newPassword = 'a-brand-new-password';
|
||||
await page.goto('/account');
|
||||
|
||||
const modal = accountModal(page);
|
||||
await modal.getByRole('button', { name: 'Change your password' }).click();
|
||||
await modal.getByLabel('Current password', { exact: true }).fill(PASSWORD);
|
||||
await modal.getByLabel('New password', { exact: true }).fill(newPassword);
|
||||
await modal.getByLabel('Confirm new password', { exact: true }).fill(newPassword);
|
||||
await modal.getByRole('button', { name: 'Change password' }).click();
|
||||
|
||||
await expect(page.getByText('Password changed. Other devices have been signed out.')).toBeVisible();
|
||||
|
||||
// Still signed in here: the session making the change is deliberately the
|
||||
// one session spared. Exact, because "Delete my account" further down this
|
||||
// same modal is otherwise also a match.
|
||||
await expect(page.getByRole('button', { name: 'My Account', exact: true })).toBeVisible();
|
||||
|
||||
// Logging out from inside the open modal, which is where the control lives.
|
||||
await page.getByRole('button', { name: 'Log out' }).click();
|
||||
await expect(page).toHaveURL(/\/$/, { timeout: 20000 });
|
||||
|
||||
await signIn(page, email, PASSWORD);
|
||||
await expect(page.getByText('invalid email or password')).toBeVisible();
|
||||
|
||||
await signIn(page, email, newPassword);
|
||||
await expect(page.getByRole('button', { name: 'My Account', exact: true }))
|
||||
.toBeVisible({ timeout: 20000 });
|
||||
});
|
||||
|
||||
test('refuses a password change when the current password is wrong', async ({ page }) => {
|
||||
await registerCustomer(page);
|
||||
await page.goto('/account');
|
||||
|
||||
const modal = accountModal(page);
|
||||
await modal.getByRole('button', { name: 'Change your password' }).click();
|
||||
await modal.getByLabel('Current password', { exact: true }).fill('not-the-password');
|
||||
await modal.getByLabel('New password', { exact: true }).fill('another-password');
|
||||
await modal.getByLabel('Confirm new password', { exact: true }).fill('another-password');
|
||||
await modal.getByRole('button', { name: 'Change password' }).click();
|
||||
|
||||
await expect(modal.getByText('current password is incorrect')).toBeVisible();
|
||||
});
|
||||
|
||||
test('changes the email address and marks it unverified again', async ({ page }) => {
|
||||
await registerCustomer(page);
|
||||
const nextEmail = uniqueEmail();
|
||||
await page.goto('/account');
|
||||
|
||||
const modal = accountModal(page);
|
||||
await modal.getByRole('button', { name: 'Change your email address' }).click();
|
||||
await modal.getByLabel('New email address', { exact: true }).fill(nextEmail);
|
||||
await modal.getByLabel('Your password', { exact: true }).fill(PASSWORD);
|
||||
await modal.getByRole('button', { name: 'Change email' }).click();
|
||||
|
||||
await expect(page.getByText('Check the new address for a verification link.')).toBeVisible();
|
||||
await expect(modal).toContainText(nextEmail);
|
||||
// A changed address is unverified by definition, and the account view has
|
||||
// to say so or the customer has no way to know a link is waiting.
|
||||
await expect(modal.getByText('Email not verified')).toBeVisible();
|
||||
});
|
||||
|
||||
// A live session is not enough to move the address a password reset goes to,
|
||||
// which is the whole reason the field is there.
|
||||
test('refuses an email change when the password is wrong, and keeps the old address', async ({ page }) => {
|
||||
const email = await registerCustomer(page);
|
||||
await page.goto('/account');
|
||||
|
||||
const modal = accountModal(page);
|
||||
await modal.getByRole('button', { name: 'Change your email address' }).click();
|
||||
await modal.getByLabel('New email address', { exact: true }).fill(uniqueEmail());
|
||||
await modal.getByLabel('Your password', { exact: true }).fill('not-the-password');
|
||||
await modal.getByRole('button', { name: 'Change email' }).click();
|
||||
|
||||
await expect(modal.getByText('current password is incorrect')).toBeVisible();
|
||||
await expect(modal).toContainText(email);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user