Merge pull request 'feat(admin): disable and re-enable customer accounts (#33)' (#47) from feature/disable-customer-account into main
Reviewed-on: #47
This commit was merged in pull request #47.
This commit is contained in:
@@ -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;
|
||||||
|
`);
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user