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,19 @@
exports.up = (pgm) => {
pgm.sql(`
-- Nullable timestamp rather than a boolean: disabling is reversible, and
-- this records when it happened without a second column.
ALTER TABLE customers ADD COLUMN IF NOT EXISTS disabled_at TIMESTAMPTZ;
-- attachCustomer joins customers on every authenticated request to check
-- this, so the lookup wants an index on the active case.
CREATE INDEX IF NOT EXISTS customers_disabled_at_idx
ON customers (disabled_at) WHERE disabled_at IS NOT NULL;
`);
};
exports.down = (pgm) => {
pgm.sql(`
DROP INDEX IF EXISTS customers_disabled_at_idx;
ALTER TABLE customers DROP COLUMN IF EXISTS disabled_at;
`);
};
+8 -1
View File
@@ -12,8 +12,15 @@ declare global {
export async function attachCustomer(req: Request, _res: Response, next: NextFunction): Promise<void> { export async function attachCustomer(req: Request, _res: Response, next: NextFunction): Promise<void> {
const token = req.cookies?.rd_session; const token = req.cookies?.rd_session;
if (!token) return next(); if (!token) return next();
// Joined to customers so a disabled account stops resolving at once, rather
// than when its 30-day cookie eventually expires. Register, login and
// password reset all mint sessions, so checking here covers every path
// instead of three separate ones.
const { rows } = await pool.query( const { rows } = await pool.query(
`SELECT customer_id FROM customer_sessions WHERE token = $1 AND expires_at > now()`, `SELECT s.customer_id
FROM customer_sessions s
JOIN customers c ON c.id = s.customer_id
WHERE s.token = $1 AND s.expires_at > now() AND c.disabled_at IS NULL`,
[token] [token]
); );
if (rows.length) req.customerId = rows[0].customer_id; if (rows.length) req.customerId = rows[0].customer_id;
+66 -1
View File
@@ -7,7 +7,7 @@ const router = Router();
router.get('/', asyncRoute(async (_req: Request, res: Response) => { router.get('/', asyncRoute(async (_req: Request, res: Response) => {
const { rows } = await pool.query(` const { rows } = await pool.query(`
SELECT SELECT
c.id, c.email, c.name, c.email_verified, c.marketing_consent, c.created_at, c.id, c.email, c.name, c.email_verified, c.marketing_consent, c.created_at, c.disabled_at,
COUNT(o.id) FILTER (WHERE o.status = 'completed') AS order_count, COUNT(o.id) FILTER (WHERE o.status = 'completed') AS order_count,
COALESCE(SUM(o.amount_cents) FILTER (WHERE o.status = 'completed'), 0) AS total_spent_cents, COALESCE(SUM(o.amount_cents) FILTER (WHERE o.status = 'completed'), 0) AS total_spent_cents,
MAX(o.created_at) AS last_order_at, MAX(o.created_at) AS last_order_at,
@@ -27,6 +27,71 @@ router.get('/', asyncRoute(async (_req: Request, res: Response) => {
res.json(rows); res.json(rows);
})); }));
// Disabling is reversible, so this records a timestamp rather than flipping a
// boolean — when it happened comes free.
//
// Everything below happens in one transaction. A disable that evicted the
// sessions but left the cart held, or vice versa, would be worse than either
// outcome alone.
router.post('/:id/disable', asyncRoute(async (req: Request, res: Response) => {
const client = await pool.connect();
try {
await client.query('BEGIN');
const { rows } = await client.query(
`UPDATE customers SET disabled_at = now() WHERE id = $1 RETURNING id`,
[req.params.id]
);
if (!rows.length) {
await client.query('ROLLBACK');
return res.status(404).json({ error: 'not found' });
}
// Immediate eviction. attachCustomer also refuses a disabled account, so
// this is belt and braces — but it means the rows are gone rather than
// lingering until their 30-day expiry.
await client.query(`DELETE FROM customer_sessions WHERE customer_id = $1`, [req.params.id]);
// A disabled account cannot check out, so holding one-of-a-kind stock off
// the storefront until the expiry sweep serves nobody. Guarded on
// 'reserved' so a sold item is never resurrected.
const { rows: held } = await client.query(
`DELETE FROM cart_items ci
USING carts ca
WHERE ci.cart_id = ca.id AND ca.customer_id = $1
RETURNING ci.item_id`,
[req.params.id]
);
if (held.length) {
await client.query(
`UPDATE items SET status = 'available', reserved_until = NULL
WHERE id = ANY($1::int[]) AND status = 'reserved'`,
[held.map((row: { item_id: number }) => row.item_id)]
);
}
await client.query('COMMIT');
res.status(204).end();
} catch (err) {
await client.query('ROLLBACK');
throw err;
} finally {
client.release();
}
}));
// Restores sign-in only. Items released by the disable stay released — they may
// well have been sold to someone else in the meantime, and silently re-reserving
// them would be worse than making the customer add them again.
router.post('/:id/enable', asyncRoute(async (req: Request, res: Response) => {
const { rows } = await pool.query(
`UPDATE customers SET disabled_at = NULL WHERE id = $1 RETURNING id`,
[req.params.id]
);
if (!rows.length) return res.status(404).json({ error: 'not found' });
res.status(204).end();
}));
router.get('/:id/reserved', asyncRoute(async (req: Request, res: Response) => { router.get('/:id/reserved', asyncRoute(async (req: Request, res: Response) => {
const { rows } = await pool.query( const { rows } = await pool.query(
`SELECT i.id AS item_id, i.name, i.price_cents, ci.added_at, ci.expires_at `SELECT i.id AS item_id, i.name, i.price_cents, ci.added_at, ci.expires_at
+13 -1
View File
@@ -111,7 +111,7 @@ router.post('/request-password-reset', passwordResetRequestLimiter, asyncRoute(a
const { rows } = await pool.query(`SELECT * FROM customers WHERE email = $1`, [email]); const { rows } = await pool.query(`SELECT * FROM customers WHERE email = $1`, [email]);
const customer = rows[0]; const customer = rows[0];
if (customer) { if (customer && !customer.disabled_at) {
// Supersede any outstanding token, so a link cannot be resurrected later // Supersede any outstanding token, so a link cannot be resurrected later
// from an older message in the customer's inbox. // from an older message in the customer's inbox.
await pool.query(`DELETE FROM customer_tokens WHERE customer_id = $1 AND kind = 'password_reset'`, [customer.id]); await pool.query(`DELETE FROM customer_tokens WHERE customer_id = $1 AND kind = 'password_reset'`, [customer.id]);
@@ -156,6 +156,13 @@ router.post('/reset-password', asyncRoute(async (req: Request, res: Response) =>
if (!rows.length) return res.status(400).json({ error: 'invalid or expired token' }); if (!rows.length) return res.status(400).json({ error: 'invalid or expired token' });
const customerId = rows[0].customer_id; const customerId = rows[0].customer_id;
// A token issued before the account was disabled would otherwise still mint a
// fresh session.
const { rows: owner } = await pool.query(`SELECT disabled_at FROM customers WHERE id = $1`, [customerId]);
if (owner[0]?.disabled_at) {
return res.status(403).json({ error: 'this account has been disabled' });
}
const passwordHash = await bcrypt.hash(String(password), 12); const passwordHash = await bcrypt.hash(String(password), 12);
const client = await pool.connect(); const client = await pool.connect();
@@ -194,6 +201,11 @@ router.post('/login', async (req: Request, res: Response) => {
if (!customer || !(await bcrypt.compare(password || '', customer.password_hash))) { if (!customer || !(await bcrypt.compare(password || '', customer.password_hash))) {
return res.status(401).json({ error: 'invalid email or password' }); return res.status(401).json({ error: 'invalid email or password' });
} }
// Only after the password checks out, so a wrong password still looks like a
// wrong password and this does not become a bulk membership oracle.
if (customer.disabled_at) {
return res.status(403).json({ error: 'this account has been disabled' });
}
const sessionToken = await createSession(customer.id); const sessionToken = await createSession(customer.id);
setSessionCookie(res, sessionToken); setSessionCookie(res, sessionToken);
res.json(publicCustomer(customer)); res.json(publicCustomer(customer));
@@ -0,0 +1,205 @@
import request from 'supertest';
import app from '../../src/app';
import { pool } from '../../src/db';
import { resetDb, closeDb } from './setup/testDb';
beforeEach(async () => {
await resetDb();
});
afterAll(async () => {
await pool.end();
await closeDb();
});
const PASSWORD = 'supersecret123';
async function register(email: string) {
const agent = request.agent(app);
const res = await agent.post('/api/customers/register').send({ email, password: PASSWORD });
expect(res.status).toBe(200);
const { rows } = await pool.query(`SELECT id FROM customers WHERE email = $1`, [email]);
return { agent, id: rows[0].id as number };
}
async function createItem(name: string) {
const { rows } = await pool.query(
`INSERT INTO items (name, price_cents) VALUES ($1, 1000) RETURNING id`,
[name]
);
return rows[0].id as number;
}
describe('disabling a customer', () => {
it('is reported in the admin customer list', async () => {
const { id } = await register('flag@example.com');
await request(app).post(`/api/admin/customers/${id}/disable`).expect(204);
const res = await request(app).get('/api/admin/customers');
const customer = res.body.find((c: { id: number }) => c.id === id);
expect(customer.disabled_at).toBeTruthy();
});
it('refuses the sign-in with an explicit reason, not a credential failure', async () => {
const { id } = await register('told@example.com');
await request(app).post(`/api/admin/customers/${id}/disable`).expect(204);
const res = await request(app)
.post('/api/customers/login')
.send({ email: 'told@example.com', password: PASSWORD });
// A generic 401 would send them round the password-reset loop forever.
expect(res.status).toBe(403);
expect(res.body.error).toMatch(/disabled/i);
});
it('still refuses a sign-in with the wrong password, without revealing the disable', async () => {
const { id } = await register('wrongpw@example.com');
await request(app).post(`/api/admin/customers/${id}/disable`).expect(204);
const res = await request(app)
.post('/api/customers/login')
.send({ email: 'wrongpw@example.com', password: 'not-the-password' });
expect(res.status).toBe(401);
});
it('kills sessions that already existed', async () => {
const { agent, id } = await register('evicted@example.com');
expect((await agent.get('/api/customers/me')).status).toBe(200);
await request(app).post(`/api/admin/customers/${id}/disable`).expect(204);
// The cookie is still held by the agent; it must stop working immediately
// rather than at its 30-day expiry.
expect((await agent.get('/api/customers/me')).status).toBe(401);
});
it('releases items the customer was holding', async () => {
const itemId = await createItem('Held item');
const { agent, id } = await register('holder@example.com');
expect((await agent.post(`/api/cart/items/${itemId}`)).status).toBe(201);
await request(app).post(`/api/admin/customers/${id}/disable`).expect(204);
const item = await request(app).get(`/api/items/${itemId}`);
expect(item.body.status).toBe('available');
const { rows } = await pool.query(`SELECT COUNT(*)::int AS n FROM cart_items WHERE item_id = $1`, [itemId]);
expect(rows[0].n).toBe(0);
});
it('leaves a released item purchasable by someone else', async () => {
const itemId = await createItem('Freed item');
const { agent: first, id } = await register('first@example.com');
await first.post(`/api/cart/items/${itemId}`);
await request(app).post(`/api/admin/customers/${id}/disable`).expect(204);
const { agent: second } = await register('second@example.com');
expect((await second.post(`/api/cart/items/${itemId}`)).status).toBe(201);
});
it('does not resurrect an item that was already sold', async () => {
const itemId = await createItem('Sold item');
const { agent, id } = await register('sold@example.com');
await agent.post(`/api/cart/items/${itemId}`);
await pool.query(`UPDATE items SET status = 'sold' WHERE id = $1`, [itemId]);
await request(app).post(`/api/admin/customers/${id}/disable`).expect(204);
const { rows } = await pool.query(`SELECT status FROM items WHERE id = $1`, [itemId]);
expect(rows[0].status).toBe('sold');
});
it('blocks the self-service data export and account deletion', async () => {
const { agent, id } = await register('gdpr@example.com');
await request(app).post(`/api/admin/customers/${id}/disable`).expect(204);
expect((await agent.get('/api/customers/me/export')).status).toBe(401);
expect((await agent.delete('/api/customers/me')).status).toBe(401);
});
it('sends no reset mail and issues no token for a disabled account', async () => {
const { id } = await register('noreset@example.com');
await request(app).post(`/api/admin/customers/${id}/disable`).expect(204);
// Still 200, so the endpoint stays non-enumerating.
const res = await request(app)
.post('/api/customers/request-password-reset')
.send({ email: 'noreset@example.com' });
expect(res.status).toBe(200);
const { rows } = await pool.query(
`SELECT COUNT(*)::int AS n FROM customer_tokens WHERE customer_id = $1 AND kind = 'password_reset'`,
[id]
);
expect(rows[0].n).toBe(0);
});
it('refuses to complete a reset whose token predates the disable', async () => {
const { id } = await register('midreset@example.com');
await request(app).post('/api/customers/request-password-reset').send({ email: 'midreset@example.com' });
const { rows } = await pool.query(
`SELECT token FROM customer_tokens WHERE customer_id = $1 AND kind = 'password_reset'`,
[id]
);
const token = rows[0].token;
await request(app).post(`/api/admin/customers/${id}/disable`).expect(204);
const res = await request(app)
.post('/api/customers/reset-password')
.send({ token, password: 'a-brand-new-password' });
expect(res.status).toBe(403);
});
it('rejects registering again with the same address', async () => {
const { id } = await register('reregister@example.com');
await request(app).post(`/api/admin/customers/${id}/disable`).expect(204);
// Otherwise disabling is trivially undone by signing up again.
const res = await request(app)
.post('/api/customers/register')
.send({ email: 'reregister@example.com', password: PASSWORD });
expect(res.status).toBe(409);
});
});
describe('re-enabling a customer', () => {
it('restores sign-in', async () => {
const { id } = await register('back@example.com');
await request(app).post(`/api/admin/customers/${id}/disable`).expect(204);
await request(app).post(`/api/admin/customers/${id}/enable`).expect(204);
const res = await request(app)
.post('/api/customers/login')
.send({ email: 'back@example.com', password: PASSWORD });
expect(res.status).toBe(200);
});
it('clears the disabled timestamp', async () => {
const { id } = await register('cleared@example.com');
await request(app).post(`/api/admin/customers/${id}/disable`).expect(204);
await request(app).post(`/api/admin/customers/${id}/enable`).expect(204);
const res = await request(app).get('/api/admin/customers');
const customer = res.body.find((c: { id: number }) => c.id === id);
expect(customer.disabled_at).toBeNull();
});
it('does not give back the items that were released', async () => {
const itemId = await createItem('Not returned');
const { agent, id } = await register('norestore@example.com');
await agent.post(`/api/cart/items/${itemId}`);
await request(app).post(`/api/admin/customers/${id}/disable`).expect(204);
await request(app).post(`/api/admin/customers/${id}/enable`).expect(204);
const item = await request(app).get(`/api/items/${itemId}`);
expect(item.body.status).toBe('available');
});
it('returns 404 for a customer that does not exist', async () => {
expect((await request(app).post('/api/admin/customers/999999/disable')).status).toBe(404);
expect((await request(app).post('/api/admin/customers/999999/enable')).status).toBe(404);
});
});
+11 -2
View File
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { import {
Layout, Table, Button, Form, Input, InputNumber, Upload, Modal, Layout, Table, Button, Form, Input, InputNumber, Upload, Modal,
Space, Tag, Typography, Switch, message, Image as AntImage, theme, Tabs, Space, Tag, Typography, Switch, message, Image as AntImage, theme, Tabs,
@@ -39,7 +39,16 @@ function Inventory() {
const [filters, setFilters] = useState<ItemFilters>(EMPTY_FILTERS); const [filters, setFilters] = useState<ItemFilters>(EMPTY_FILTERS);
const { mode } = useThemeMode(); const { mode } = useThemeMode();
const load = (active: ItemFilters = filters) => fetchAdminItems(active).then(setItems); // Typing in the price fields fires a request per keystroke, so responses can
// arrive out of order and an older one can repaint stale rows over a newer
// result. Only the most recently issued request is allowed to set state.
const latestRequest = useRef(0);
const load = (active: ItemFilters = filters) => {
const seq = ++latestRequest.current;
return fetchAdminItems(active).then(rows => {
if (seq === latestRequest.current) setItems(rows);
});
};
// The item form needs the current category tree and tag list; both change // The item form needs the current category tree and tag list; both change
// from the sibling tabs, so they're refetched whenever the modal opens. // from the sibling tabs, so they're refetched whenever the modal opens.
+65
View File
@@ -3,6 +3,7 @@ import { Table, Drawer, Descriptions, Tag, Typography, Spin, Empty, Modal, Butto
import type { ColumnsType } from 'antd/es/table'; import type { ColumnsType } from 'antd/es/table';
import { import {
fetchCustomers, fetchCustomerDetail, fetchReservedItems, releaseReservedItem, fetchCustomers, fetchCustomerDetail, fetchReservedItems, releaseReservedItem,
setCustomerDisabled,
CustomerSummary, CustomerDetail, ReservedItem CustomerSummary, CustomerDetail, ReservedItem
} from './adminCustomersApi'; } from './adminCustomersApi';
@@ -18,6 +19,7 @@ export default function Customers() {
const [reserved, setReserved] = useState<ReservedItem[]>([]); const [reserved, setReserved] = useState<ReservedItem[]>([]);
const [reservedLoading, setReservedLoading] = useState(false); const [reservedLoading, setReservedLoading] = useState(false);
const [releasing, setReleasing] = useState<number | null>(null); const [releasing, setReleasing] = useState<number | null>(null);
const [togglingId, setTogglingId] = useState<number | null>(null);
function load() { function load() {
return fetchCustomers().then(rows => { setCustomers(rows); setLoading(false); }); return fetchCustomers().then(rows => { setCustomers(rows); setLoading(false); });
@@ -46,6 +48,46 @@ export default function Customers() {
} }
} }
function handleToggleDisabled(customer: CustomerSummary) {
const disabling = !customer.disabled_at;
Modal.confirm({
title: disabling ? `Disable ${customer.email}?` : `Re-enable ${customer.email}?`,
content: disabling ? (
<span>
They will be signed out everywhere immediately and told the account is disabled if they
try to sign in.
{customer.reserved_count > 0 && (
<> Their {customer.reserved_count} reserved item
{customer.reserved_count === 1 ? '' : 's'} will be released back to the storefront.</>
)}
{' '}Self-service data export and account deletion stop working too, so any such request
has to be handled by hand.
</span>
) : (
<span>
They will be able to sign in again. Items released when the account was disabled are not
returned those may already have sold.
</span>
),
okText: disabling ? 'Disable' : 'Re-enable',
okButtonProps: { danger: disabling },
onOk: async () => {
setTogglingId(customer.id);
try {
await setCustomerDisabled(customer.id, disabling);
} catch (err) {
message.error(`Couldn't ${disabling ? 'disable' : 're-enable'}${(err as Error).message}`);
return;
} finally {
setTogglingId(null);
}
message.success(disabling ? 'Account disabled' : 'Account re-enabled');
load();
}
});
}
async function handleRelease(item: ReservedItem) { async function handleRelease(item: ReservedItem) {
if (!reservedFor) return; if (!reservedFor) return;
setReleasing(item.item_id); setReleasing(item.item_id);
@@ -90,6 +132,14 @@ export default function Customers() {
onFilter: (value, row) => row.marketing_consent === value, onFilter: (value, row) => row.marketing_consent === value,
render: (v: boolean) => <Tag color={v ? 'blue' : 'default'}>{v ? 'Yes' : 'No'}</Tag> render: (v: boolean) => <Tag color={v ? 'blue' : 'default'}>{v ? 'Yes' : 'No'}</Tag>
}, },
{
title: 'Status',
dataIndex: 'disabled_at',
render: (disabledAt: string | null) =>
disabledAt
? <Tag color="red">DISABLED</Tag>
: <Tag color="green">ACTIVE</Tag>
},
{ {
title: 'Reserved', title: 'Reserved',
dataIndex: 'reserved_count', dataIndex: 'reserved_count',
@@ -127,6 +177,21 @@ export default function Customers() {
sorter: (a, b) => new Date(a.last_order_at || 0).getTime() - new Date(b.last_order_at || 0).getTime(), sorter: (a, b) => new Date(a.last_order_at || 0).getTime() - new Date(b.last_order_at || 0).getTime(),
render: (v: string | null) => (v ? new Date(v).toLocaleDateString() : '—') render: (v: string | null) => (v ? new Date(v).toLocaleDateString() : '—')
}, },
{
title: '',
key: 'actions',
render: (_: unknown, customer: CustomerSummary) => (
<Button
size="small"
danger={!customer.disabled_at}
loading={togglingId === customer.id}
// The row opens the detail drawer, so this must not bubble.
onClick={(event) => { event.stopPropagation(); handleToggleDisabled(customer); }}
>
{customer.disabled_at ? 'Re-enable' : 'Disable'}
</Button>
)
},
{ {
title: 'Joined', title: 'Joined',
dataIndex: 'created_at', dataIndex: 'created_at',
+13
View File
@@ -9,6 +9,7 @@ export interface CustomerSummary {
total_spent_cents: number; total_spent_cents: number;
last_order_at: string | null; last_order_at: string | null;
reserved_count: number; reserved_count: number;
disabled_at: string | null;
} }
export interface ReservedItem { export interface ReservedItem {
@@ -69,3 +70,15 @@ export async function releaseReservedItem(customerId: number, itemId: number): P
throw new Error(detail.error || 'failed to release item'); throw new Error(detail.error || 'failed to release item');
} }
} }
export async function setCustomerDisabled(customerId: number, disabled: boolean): Promise<void> {
const res = await fetch(`/api/admin/customers/${customerId}/${disabled ? 'disable' : 'enable'}`, {
method: 'POST'
});
// Reporting success for a disable that failed would leave an account the
// admin believes is locked still fully usable.
if (!res.ok) {
const detail = await res.json().catch(() => ({}));
throw new Error(detail.error || `failed to ${disabled ? 'disable' : 'enable'} account`);
}
}
@@ -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');
});
});