Merge pull request 'feat(frontend): let a customer change their own name, password and email (#111)' (#120) from feature/111-account-ui into main
Linting / lint (push) Successful in 1m38s
SonarQube Analysis / sonarqube (push) Failing after 13m18s
Tests / backend-unit (push) Successful in 46s
Tests / frontend-e2e (push) Failing after 8m52s

Reviewed-on: #120
This commit was merged in pull request #120.
This commit is contained in:
2026-08-22 10:23:50 -05:00
4 changed files with 385 additions and 0 deletions
+4
View File
@@ -11,6 +11,7 @@ import { useNavigate } from 'react-router-dom';
import { fetchMyOrders, OrderHistoryItem, updateConsent, exportMyData, deleteMyAccount } from './customerApi'; import { fetchMyOrders, OrderHistoryItem, updateConsent, exportMyData, deleteMyAccount } from './customerApi';
import { setFavoriteAlerts } from './favoritesApi'; import { setFavoriteAlerts } from './favoritesApi';
import { useCustomerAuth } from './CustomerAuthContext'; import { useCustomerAuth } from './CustomerAuthContext';
import AccountDetails from './AccountDetails';
const { Title, Text } = Typography; const { Title, Text } = Typography;
@@ -110,6 +111,9 @@ export default function Account({ onClose }: Props) {
</div> </div>
)} )}
<Divider />
<AccountDetails customer={customer} onChanged={refresh} />
<Divider /> <Divider />
<Space align="center"> <Space align="center">
<Switch checked={customer.marketing_consent} onChange={handleConsentToggle} /> <Switch checked={customer.marketing_consent} onChange={handleConsentToggle} />
+207
View File
@@ -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>
);
}
+32
View File
@@ -110,3 +110,35 @@ export function resetPassword(token: string, password: string): Promise<Customer
body: JSON.stringify({ token, password }) body: JSON.stringify({ token, password })
}).then(res => handle<Customer>(res)); }).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));
}
+142
View File
@@ -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);
});
});