feat(frontend): let a customer change their own name, password and email (#111)
The three endpoints have been on main since PR #113 with nothing calling them. This adds the UI, which is what the issue is actually about: its title is that PUT /api/customers/me has no caller. The name form sits open on the account view. Changing an email address or a password does not, because both are rare and deliberate, and leaving them expanded would push order history and the account controls below the fold for everyone who never uses them. They go in a collapse instead. Both of those carry a consequence the form cannot show. A new address has to be verified before it can be used to sign in or reset a password, and the old address is told that the change happened. A password change ends every other session. Each is stated above its fields rather than reported afterwards, so the surprise arrives while there is still a chance to back out. The email form asks for the current password. A live session is not enough to move the address a password reset would be sent to, which is the whole reason the server asks for it too. Server refusals are shown as they arrive rather than replaced with something generic: the message names which of the two passwords was wrong, or which name was left blank, and that is the only useful thing to say. The forms live in their own component rather than in Account.tsx. Three forms inline would have roughly doubled that component, and nested JSX bodies count toward the parent's cognitive complexity - the same thing that made Customers() hard to bring back under the threshold in #81. Verification, all against a real backend and database rather than mocks: six new end-to-end tests covering the name surviving a reload, a blank name being refused, the old password ceasing to work while the new one starts working, a wrong current password being refused for both the password and the email change, and an email change marking the account unverified again. The password test asserts the old credential no longer opens the account rather than that the form said something reassuring. The 21 existing account and auth tests still pass, and tsc and ESLint are clean. Closes #111 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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