feat(admin): disable and re-enable customer accounts (#33)
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:
@@ -7,7 +7,7 @@ const router = Router();
|
||||
router.get('/', asyncRoute(async (_req: Request, res: Response) => {
|
||||
const { rows } = await pool.query(`
|
||||
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,
|
||||
COALESCE(SUM(o.amount_cents) FILTER (WHERE o.status = 'completed'), 0) AS total_spent_cents,
|
||||
MAX(o.created_at) AS last_order_at,
|
||||
@@ -27,6 +27,71 @@ router.get('/', asyncRoute(async (_req: Request, res: Response) => {
|
||||
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) => {
|
||||
const { rows } = await pool.query(
|
||||
`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 customer = rows[0];
|
||||
|
||||
if (customer) {
|
||||
if (customer && !customer.disabled_at) {
|
||||
// Supersede any outstanding token, so a link cannot be resurrected later
|
||||
// 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]);
|
||||
@@ -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' });
|
||||
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 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))) {
|
||||
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);
|
||||
setSessionCookie(res, sessionToken);
|
||||
res.json(publicCustomer(customer));
|
||||
|
||||
Reference in New Issue
Block a user