diff --git a/frontend/src/customer/Account.tsx b/frontend/src/customer/Account.tsx index 25be6aa..281c9f3 100755 --- a/frontend/src/customer/Account.tsx +++ b/frontend/src/customer/Account.tsx @@ -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) { )} + + + diff --git a/frontend/src/customer/AccountDetails.tsx b/frontend/src/customer/AccountDetails.tsx new file mode 100644 index 0000000..e08d233 --- /dev/null +++ b/frontend/src/customer/AccountDetails.tsx @@ -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(null); + const [emailError, setEmailError] = useState(null); + const [passwordError, setPasswordError] = useState(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 ( +
+ Your details + {nameError && } +
+ + + + + + + + + +
+ + {/* 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. */} + + + 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. + + {emailError && ( + + )} +
+ + + + {/* Asked for because a live session alone is not enough to move + the address a password reset would be sent to. */} + + + + + + +
+ + ) + }, + { + key: 'password', + label: 'Change your password', + children: ( + <> + + Signing in elsewhere will end. You will stay signed in on this device. + + {passwordError && ( + + )} +
+ + + + + + + ({ + validator: (_, value) => + !value || getFieldValue('newPassword') === value + ? Promise.resolve() + : Promise.reject(new Error('The passwords do not match')) + }) + ]} + > + + + + + +
+ + ) + } + ]} + /> +
+ ); +} diff --git a/frontend/src/customer/customerApi.ts b/frontend/src/customer/customerApi.ts index ab0a6d9..8403aec 100755 --- a/frontend/src/customer/customerApi.ts +++ b/frontend/src/customer/customerApi.ts @@ -110,3 +110,35 @@ export function resetPassword(token: string, password: string): Promise handle(res)); } + +export function updateMyName(firstName: string, lastName: string): Promise { + return fetch('/api/customers/me', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ firstName, lastName }) + }).then(res => handle(res)); +} + +export function changeMyPassword(currentPassword: string, newPassword: string): Promise { + // 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 { + return fetch('/api/customers/me/email', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ currentPassword, email }) + }).then(res => handle(res)); +} diff --git a/frontend/tests/e2e/account-details.spec.ts b/frontend/tests/e2e/account-details.spec.ts new file mode 100644 index 0000000..a30384d --- /dev/null +++ b/frontend/tests/e2e/account-details.spec.ts @@ -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 { + 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); + }); +});