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:
@@ -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