feat(admin): disable and re-enable customer accounts (#33)
SonarQube Analysis / sonarqube (pull_request) Successful in 3m0s
Tests / backend-unit (pull_request) Successful in 48s
Tests / frontend-e2e (pull_request) Failing after 9m41s

Adds customers.disabled_at, admin disable/enable endpoints, a Status
column and toggle on the Customers tab, and enforcement across every
path that authenticates.

Enforcement lives in attachCustomer, which previously validated only the
session token and its expiry and never read the customer row. Register,
login and password reset all mint sessions, so a single check in the
middleware covers every path rather than three separate ones — and it
means an existing rd_session cookie stops working at once instead of at
its 30-day expiry. Disabling also deletes the sessions outright, so
eviction does not wait for the next request.

Disabling releases the items the customer was holding, in the same
transaction. A disabled account cannot check out, so leaving its
reservations would keep one-of-a-kind stock off the storefront for up to
the cart expiry window for no purpose. Guarded on 'reserved' so a sold
item is never resurrected. Re-enabling restores sign-in but does not give
the items back — they may since have sold.

Sign-in returns an explicit 403 rather than a generic credential failure.
That does confirm the address has an account, which sits awkwardly beside
the deliberately non-enumerating reset in #32; the trade was made the
other way because a disabled customer told "invalid email or password"
resets their password, succeeds, is still locked out, and concludes the
site is broken. The check runs only after the password verifies, so it is
not a bulk membership oracle, and /register already reveals existence.

A reset token issued before the disable no longer mints a session, and no
new tokens are issued for a disabled account — while still answering 200,
so that endpoint stays non-enumerating.

Self-service GDPR export and deletion are blocked along with everything
else, so those requests now need servicing by hand. Worth checking the
privacy policy does not promise unconditional self-service.

Also fixes an unrelated bug the e2e run surfaced: the admin inventory
fired a request per keystroke in the price fields with no sequencing, so
an older response could land after a newer one and repaint stale rows.
Only the most recently issued request may now set state.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-18 10:48:28 -05:00
co-authored by Claude Opus 5
parent 5a9ecefeba
commit 13c010ff51
9 changed files with 507 additions and 5 deletions
@@ -0,0 +1,107 @@
import { test, expect, Page } from '@playwright/test';
const PASSWORD = 'supersecret123';
const uniqueEmail = () => `disable-${Date.now().toString(36)}${Math.random().toString(36).slice(2, 7)}@example.com`;
async function register(page: Page, email: string) {
await page.goto('/register');
await page.getByRole('textbox', { name: 'Email' }).fill(email);
await page.getByLabel('Password').fill(PASSWORD);
await page.getByRole('button', { name: 'Create account' }).click();
await expect(page).toHaveURL(/\/account/);
}
async function customerRow(page: Page, email: string) {
await page.goto('/admin');
await page.getByRole('tab', { name: 'Customers' }).click();
const row = page.getByRole('row').filter({ hasText: email });
await expect(row).toBeVisible();
return row;
}
test.describe('Disabling a customer account', () => {
test('an admin can disable an account and the customer is told at sign-in', async ({ page }) => {
const email = uniqueEmail();
await register(page, email);
const row = await customerRow(page, email);
await expect(row.getByText('ACTIVE')).toBeVisible();
await row.getByRole('button', { name: 'Disable' }).click();
await page.getByRole('dialog').getByRole('button', { name: 'Disable' }).click();
await expect(page.getByText('Account disabled')).toBeVisible();
const updated = page.getByRole('row').filter({ hasText: email });
await expect(updated.getByText('DISABLED')).toBeVisible();
// A generic credential error would send a real customer round the
// password-reset loop forever.
await page.goto('/login');
await page.getByRole('textbox', { name: 'Email' }).fill(email);
await page.getByLabel('Password').fill(PASSWORD);
await page.getByRole('button', { name: 'Log in' }).click();
await expect(page.getByText(/disabled/i)).toBeVisible();
});
test('an existing session stops working immediately', async ({ page, request }) => {
const email = uniqueEmail();
await register(page, email);
// Still signed in from registration, in this same browser context.
await page.goto('/account');
await expect(page.getByText(email)).toBeVisible();
const customers = await (await request.get('/api/admin/customers')).json();
const id = customers.find((c: { email: string }) => c.email === email).id;
await request.post(`/api/admin/customers/${id}/disable`);
// The cookie is unchanged, so this proves the server rejects it rather
// than the browser having discarded it.
await page.goto('/account');
await expect(page).toHaveURL(/\/login/);
});
test('re-enabling restores sign-in', async ({ page, request }) => {
const email = uniqueEmail();
await register(page, email);
const customers = await (await request.get('/api/admin/customers')).json();
const id = customers.find((c: { email: string }) => c.email === email).id;
await request.post(`/api/admin/customers/${id}/disable`);
const row = await customerRow(page, email);
await row.getByRole('button', { name: 'Re-enable' }).click();
await page.getByRole('dialog').getByRole('button', { name: 'Re-enable' }).click();
await expect(page.getByText('Account re-enabled')).toBeVisible();
await page.goto('/login');
await page.getByRole('textbox', { name: 'Email' }).fill(email);
await page.getByLabel('Password').fill(PASSWORD);
await page.getByRole('button', { name: 'Log in' }).click();
await expect(page).toHaveURL(/\/account/);
});
test('the confirmation warns that held items will be released', async ({ page, request }) => {
const email = uniqueEmail();
const itemName = `Held ${Date.now().toString(36)}`;
const created = await request.post('/api/admin/items', {
multipart: { name: itemName, description: '', price: '40', category_id: '', tags: '[]' }
});
const itemId = (await created.json()).id as number;
await register(page, email);
expect((await page.request.post(`/api/cart/items/${itemId}`)).status()).toBe(201);
const row = await customerRow(page, email);
await row.getByRole('button', { name: 'Disable' }).click();
// The consequence has to be visible at the moment of the decision.
await expect(page.getByText(/1 reserved item/)).toBeVisible();
await page.getByRole('dialog').getByRole('button', { name: 'Disable' }).click();
await expect(page.getByText('Account disabled')).toBeVisible();
const item = await (await request.get(`/api/items/${itemId}`)).json();
expect(item.status).toBe('available');
});
});