Admin: disable a customer account #33

Closed
opened 2026-08-17 17:09:36 -05:00 by bermudalamb · 2 comments
Owner

Split out of #31 (item 3).

Goal

An admin can disable a customer's account at their discretion, and re-enable it.

Scope

There is currently no disabled, suspended, or inactive concept anywhere in the schema — this introduces the first one.

The important part is that disabling must apply to existing sessions, not just new logins. A customer holding a valid rd_session cookie has to lose access at their next request; blocking only the login form would leave an already-signed-in customer fully operational.

Design questions to resolve before implementing

  • What happens to items the customer currently holds in their cart — released immediately, or left to the expiry sweep?
  • Is the customer told they have been disabled, or does it present as a generic sign-in failure?
  • Does disabling affect their order history and data-export/deletion rights? (GDPR self-service endpoints exist today.)
  • Does a disabled account still receive marketing email, or is it excluded regardless of consent?

Why this is worth doing early

It defines a rule — a disabled account cannot authenticate — that password reset, favorites, and passkey login all have to honor. Establishing it once is cheaper than retrofitting it into each.

Split out of #31 (item 3). ## Goal An admin can disable a customer's account at their discretion, and re-enable it. ## Scope There is currently no disabled, suspended, or inactive concept anywhere in the schema — this introduces the first one. The important part is that disabling must apply to **existing sessions**, not just new logins. A customer holding a valid `rd_session` cookie has to lose access at their next request; blocking only the login form would leave an already-signed-in customer fully operational. ## Design questions to resolve before implementing - What happens to items the customer currently holds in their cart — released immediately, or left to the expiry sweep? - Is the customer told they have been disabled, or does it present as a generic sign-in failure? - Does disabling affect their order history and data-export/deletion rights? (GDPR self-service endpoints exist today.) - Does a disabled account still receive marketing email, or is it excluded regardless of consent? ## Why this is worth doing early It defines a rule — a disabled account cannot authenticate — that password reset, favorites, and passkey login all have to honor. Establishing it once is cheaper than retrofitting it into each.
bermudalamb added this to the Initial Build project 2026-08-17 17:21:03 -05:00
bermudalamb self-assigned this 2026-08-17 17:22:09 -05:00
bermudalamb moved this to In Progress in Initial Build on 2026-08-17 18:55:58 -05:00
bermudalamb moved this to Ready for Review in Initial Build on 2026-08-17 18:56:29 -05:00
bermudalamb moved this to Send back in Initial Build on 2026-08-18 10:24:59 -05:00
Author
Owner

Design decisions

Question Decision
Items held in the cart Released immediately, in the same transaction as the disable
What the customer sees at sign-in "This account has been disabled" — explicit, not a generic credential failure
Self-service GDPR export and deletion Blocked along with everything else; such requests are handled manually
Storage disabled_at TIMESTAMPTZ NULL rather than a boolean — reversible, and records when for free

Why release the held items

A disabled account cannot check out, so leaving its reservations in place keeps one-of-a-kind stock off the storefront for up to the cart expiry window (currently 24 hours) for no purpose. The release reuses the logic already built for #27.

Why an explicit sign-in message

It confirms the address has an account, which is an enumeration signal — and that sits awkwardly beside the deliberately non-enumerating password reset in #32. The trade was made the other way here because the alternative is worse for a real person: a disabled customer given "invalid email or password" will reset their password, succeed at the reset, still be locked out, and reasonably conclude the site is broken. Note /register already reveals account existence through its 409, so the enumeration surface is not new.

Consequence worth recording

Disabling blocks every authenticated endpoint, including GET /api/customers/me/export and DELETE /api/customers/me. A right that is currently self-service becomes dependent on an admin servicing it by hand. That is acceptable at this volume, but worth checking the privacy policy does not promise self-service access unconditionally.

Marketing email was raised and is largely moot in practice: the only scheduled send is the cart reminder, and disabling empties the cart, so there is nothing to remind about. If campaign sending is ever added, disabled accounts should be excluded regardless of their consent flag — the consent record itself stays untouched.

Enforcement

attachCustomer currently validates the session token and expiry alone; it never reads the customer row. So disabling has to be enforced there, not only at login — otherwise an existing rd_session cookie continues to work. Registration, login, and password reset all mint sessions, so a single check in the middleware covers every path rather than three separate ones. Sessions are also deleted at the moment of disabling, so eviction is immediate rather than waiting for the next request.

## Design decisions | Question | Decision | | --- | --- | | Items held in the cart | **Released immediately**, in the same transaction as the disable | | What the customer sees at sign-in | **"This account has been disabled"** — explicit, not a generic credential failure | | Self-service GDPR export and deletion | **Blocked along with everything else**; such requests are handled manually | | Storage | `disabled_at TIMESTAMPTZ NULL` rather than a boolean — reversible, and records *when* for free | ### Why release the held items A disabled account cannot check out, so leaving its reservations in place keeps one-of-a-kind stock off the storefront for up to the cart expiry window (currently 24 hours) for no purpose. The release reuses the logic already built for #27. ### Why an explicit sign-in message It confirms the address has an account, which is an enumeration signal — and that sits awkwardly beside the deliberately non-enumerating password reset in #32. The trade was made the other way here because the alternative is worse for a real person: a disabled customer given "invalid email or password" will reset their password, succeed at the reset, still be locked out, and reasonably conclude the site is broken. Note `/register` already reveals account existence through its `409`, so the enumeration surface is not new. ### Consequence worth recording Disabling blocks every authenticated endpoint, including `GET /api/customers/me/export` and `DELETE /api/customers/me`. A right that is currently self-service becomes dependent on an admin servicing it by hand. That is acceptable at this volume, but **worth checking the privacy policy does not promise self-service access unconditionally**. Marketing email was raised and is largely moot in practice: the only scheduled send is the cart reminder, and disabling empties the cart, so there is nothing to remind about. If campaign sending is ever added, disabled accounts should be excluded regardless of their consent flag — the consent record itself stays untouched. ### Enforcement `attachCustomer` currently validates the session token and expiry alone; it never reads the customer row. So disabling has to be enforced there, not only at login — otherwise an existing `rd_session` cookie continues to work. Registration, login, and password reset all mint sessions, so a single check in the middleware covers every path rather than three separate ones. Sessions are also deleted at the moment of disabling, so eviction is immediate rather than waiting for the next request.
Author
Owner

Implemented

Branch feature/disable-customer-account, one commit, not yet pushed. Includes a migration, so QA and production will apply it on container start.

Enforcement

attachCustomer now joins customers and refuses a disabled account. That was the substantive part: it previously validated only the session token and its expiry and never read the customer row, so an existing rd_session cookie would have kept working for up to 30 days. Register, login and password reset all mint sessions, so one check in the middleware covers every path instead of three. Disabling also deletes the session rows outright, so eviction is immediate rather than at the next request.

Behaviour

Sign-in while disabled 403 this account has been disabled
Wrong password while disabled Still 401 invalid email or password — the disable check runs only after the password verifies, so it is not a bulk membership oracle
Existing sessions Deleted at the moment of disabling
Held items Released in the same transaction, guarded on reserved so a sold item is never resurrected
Password reset request Answers 200 as always, but issues no token
Reset token issued before the disable 403 — it cannot be used to mint a session
Re-enabling Restores sign-in; released items are not returned, as they may since have sold
Registering the same address again Still 409, so disabling cannot be undone by signing up again

Admin UI

A Status column (ACTIVE / DISABLED) and a Disable / Re-enable button per row. The confirmation names the consequences before you commit: that they will be signed out everywhere, how many reserved items will be released, and that self-service data export and deletion stop working.

GDPR consequence, restated

Disabling blocks every authenticated endpoint including GET /api/customers/me/export and DELETE /api/customers/me. Worth checking the privacy policy does not promise unconditional self-service access, since servicing those requests now depends on an admin doing it by hand.

Unrelated bug found and fixed

The e2e run surfaced a genuine defect in the admin inventory filters from #27: the price fields fire a request per keystroke with no sequencing, so an older response could arrive after a newer one and repaint stale rows. Typing 500 issues three requests, and whichever resolved last won. Only the most recently issued request may now set state. The storefront masks the same hazard behind a debounce; the admin had none.

Verification

Suite Result
Backend unit 52 passed
Backend integration 104 passed (15 new)
Frontend e2e 57 passed (4 new), stable over three consecutive runs
tsc --noEmit backend / frontend clean
npm run build clean

Integration coverage includes session eviction, item release, the sold-item guard, the reset-token interaction, re-registration, and that re-enabling does not restore released items.

Next

This unblocks #34 (favorites) and #36 (passkeys), both of which have to honour the same rule — which now exists in one place rather than needing to be written into each.

## Implemented Branch `feature/disable-customer-account`, one commit, not yet pushed. Includes a migration, so QA and production will apply it on container start. ### Enforcement `attachCustomer` now joins `customers` and refuses a disabled account. That was the substantive part: it previously validated only the session token and its expiry and never read the customer row, so an existing `rd_session` cookie would have kept working for up to 30 days. Register, login and password reset all mint sessions, so one check in the middleware covers every path instead of three. Disabling also deletes the session rows outright, so eviction is immediate rather than at the next request. ### Behaviour | | | | --- | --- | | Sign-in while disabled | `403 this account has been disabled` | | Wrong password while disabled | Still `401 invalid email or password` — the disable check runs only after the password verifies, so it is not a bulk membership oracle | | Existing sessions | Deleted at the moment of disabling | | Held items | Released in the same transaction, guarded on `reserved` so a sold item is never resurrected | | Password reset request | Answers `200` as always, but issues no token | | Reset token issued before the disable | `403` — it cannot be used to mint a session | | Re-enabling | Restores sign-in; released items are **not** returned, as they may since have sold | | Registering the same address again | Still `409`, so disabling cannot be undone by signing up again | ### Admin UI A **Status** column (ACTIVE / DISABLED) and a Disable / Re-enable button per row. The confirmation names the consequences before you commit: that they will be signed out everywhere, how many reserved items will be released, and that self-service data export and deletion stop working. ### GDPR consequence, restated Disabling blocks every authenticated endpoint including `GET /api/customers/me/export` and `DELETE /api/customers/me`. **Worth checking the privacy policy does not promise unconditional self-service access**, since servicing those requests now depends on an admin doing it by hand. ### Unrelated bug found and fixed The e2e run surfaced a genuine defect in the admin inventory filters from #27: the price fields fire a request per keystroke with no sequencing, so an older response could arrive after a newer one and repaint stale rows. Typing `500` issues three requests, and whichever resolved last won. Only the most recently issued request may now set state. The storefront masks the same hazard behind a debounce; the admin had none. ### Verification | Suite | Result | | --- | --- | | Backend unit | 52 passed | | Backend integration | 104 passed (15 new) | | Frontend e2e | 57 passed (4 new), stable over three consecutive runs | | `tsc --noEmit` backend / frontend | clean | | `npm run build` | clean | Integration coverage includes session eviction, item release, the sold-item guard, the reset-token interaction, re-registration, and that re-enabling does not restore released items. ### Next This unblocks #34 (favorites) and #36 (passkeys), both of which have to honour the same rule — which now exists in one place rather than needing to be written into each.
bermudalamb moved this to Ready for Release in Initial Build on 2026-08-18 11:19:17 -05:00
bermudalamb moved this to Released in Initial Build on 2026-08-18 11:19:34 -05:00
Sign in to join this conversation.
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: bermudalamb/redefined-designs#33