feat(admin): disable and re-enable customer accounts (#33) #47

Merged
bermudalamb merged 1 commits from feature/disable-customer-account into main 2026-08-18 11:05:43 -05:00
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');
});
});