feat(admin): move any member to any tier, gated behind the admin's 2FA (BUNYIP-431) #431

Merged
longjacksonle merged 1 commit from feat/BUNYIP-431-admin-tier-change-2fa into main 2026-08-01 22:13:53 +02:00

What

An admin can move any member to any configured tier (lifetime, early adopter, standard, free) from the user detail page, in either direction, regardless of the tier the member currently holds. The two lifetime-specific buttons are replaced by a single tier selector; applying a change requires the acting admin's own 2FA code.

Finding on BUNYIP-118 (acceptance criterion 1)

No server-side route to set an arbitrary tier existed. The only tier setters were grant_lifetime (sets lifetime), revoke_lifetime (sets standard), and grant_membership (always free - it ignores any tier argument). So this PR builds both the route and the UI.

Slot accounting

Tier slot usage is a live COUNT over the subscription_tier column (UserRepository::count_tier_assignments), which counts admin-granted rows and does NOT filter on email_verified (the BUNYIP-96 / BUNYIP-105 fixes). A move is therefore a single UPDATE that debits the source tier and credits the destination atomically - there is no separate counter to keep in sync. The new UserRepository::admin_set_subscription_tier marks the row as an admin override, sets lifetime_member for lifetime/free, and computes the trial window for early_adopter/standard from the configured trial days.

API

POST /v1/admin/users/{id}/tier with { tier, totp_code }. The handler:

  • verifies the admin's TOTP BEFORE any mutation (a bad or absent code returns a validation error and changes nothing);
  • moves the tier via the single UPDATE;
  • mirrors grant_lifetime for a move TO lifetime (mints the $0 invoice subscription);
  • revokes the member's sessions and announces the claims change on any real tier change (tier feeds has_member_access);
  • writes an AdminTierChanged audit entry with actor, target, and before/after tiers.

The move plan and the strict tier parser are pure functions, so upgrade / downgrade / cancelled-2FA are unit-testable without a DB.

Web

The tier selector lists every tier regardless of the member's current tier and preselects the current one; the form collects the admin's 2FA code and posts to the API, bouncing back with a toast on a bad code.

Design decisions (flagged for review)

  • Admin moves do NOT enforce the slot cap, matching the existing grant_lifetime which also lets an admin override the cap. The live count then correctly reflects used > slots for admin-placed members.
  • Moving TO lifetime mints the $0 invoice subscription (like grant_lifetime); moving AWAY does not cancel it, matching the existing revoke_lifetime (Stripe cleanup was already the caller's responsibility and the current revoke does not do it). No new Stripe cancellation behaviour is introduced.
  • An admin override to standard/early_adopter sets subscription_status='active' and subscription_override_by=admin, i.e. an "admin-granted" active membership on that tier.

Acceptance criteria

  • Whether a server-side tier update route already exists is established and recorded (none did; see above).
  • Admin can select any configured tier for any user from the users page.
  • Options offered do not vary with the user's current tier (any-to-any, including downgrades).
  • The change requires a valid two-factor code before it is applied.
  • Tier slot counts are correct after a move in either direction, including for an unverified user.
  • A failed or cancelled 2FA leaves the user's tier and all slot counts unchanged.
  • The change is written to the audit log with actor, target and before/after values.
  • Tests covering an upgrade, a downgrade, and a cancelled 2FA.

Verification

just check-container green: fmt + clippy -D warnings + full suite (172 web tests, 107 api handler tests incl. the new tier tests, 312 domain).

End to end on the dev stack: a wrong 2FA code left the tier and slot counts unchanged; a correct code moved Standard to Early Adopter (early-adopter used count 0 to 1) and wrote the audit row (admin_tier_changed, from_tier=standard, to_tier=early_adopter, sessions_revoked=true); the reverse move credited the slot back to 0; and an unverified member still occupied a slot after a move. There is no DB-backed integration harness in this repo (no sqlx::test), so the accounting is covered by the existing live-count SQL test plus the pure move-plan tests and this manual run.

🤖 Generated with Claude Code

https://claude.ai/code/session_018TXaT3P192nDsZzbzHETb9

## What An admin can move any member to any configured tier (lifetime, early adopter, standard, free) from the user detail page, in either direction, regardless of the tier the member currently holds. The two lifetime-specific buttons are replaced by a single tier selector; applying a change requires the acting admin's own 2FA code. ## Finding on BUNYIP-118 (acceptance criterion 1) No server-side route to set an arbitrary tier existed. The only tier setters were `grant_lifetime` (sets lifetime), `revoke_lifetime` (sets standard), and `grant_membership` (always free - it ignores any tier argument). So this PR builds both the route and the UI. ## Slot accounting Tier slot usage is a live `COUNT` over the `subscription_tier` column (`UserRepository::count_tier_assignments`), which counts admin-granted rows and does NOT filter on `email_verified` (the BUNYIP-96 / BUNYIP-105 fixes). A move is therefore a single UPDATE that debits the source tier and credits the destination atomically - there is no separate counter to keep in sync. The new `UserRepository::admin_set_subscription_tier` marks the row as an admin override, sets `lifetime_member` for lifetime/free, and computes the trial window for early_adopter/standard from the configured trial days. ## API `POST /v1/admin/users/{id}/tier` with `{ tier, totp_code }`. The handler: - verifies the admin's TOTP BEFORE any mutation (a bad or absent code returns a validation error and changes nothing); - moves the tier via the single UPDATE; - mirrors `grant_lifetime` for a move TO lifetime (mints the $0 invoice subscription); - revokes the member's sessions and announces the claims change on any real tier change (tier feeds `has_member_access`); - writes an `AdminTierChanged` audit entry with actor, target, and before/after tiers. The move plan and the strict tier parser are pure functions, so upgrade / downgrade / cancelled-2FA are unit-testable without a DB. ## Web The tier selector lists every tier regardless of the member's current tier and preselects the current one; the form collects the admin's 2FA code and posts to the API, bouncing back with a toast on a bad code. ## Design decisions (flagged for review) - Admin moves do NOT enforce the slot cap, matching the existing `grant_lifetime` which also lets an admin override the cap. The live count then correctly reflects `used > slots` for admin-placed members. - Moving TO lifetime mints the $0 invoice subscription (like `grant_lifetime`); moving AWAY does not cancel it, matching the existing `revoke_lifetime` (Stripe cleanup was already the caller's responsibility and the current revoke does not do it). No new Stripe cancellation behaviour is introduced. - An admin override to standard/early_adopter sets `subscription_status='active'` and `subscription_override_by=admin`, i.e. an "admin-granted" active membership on that tier. ## Acceptance criteria - [x] Whether a server-side tier update route already exists is established and recorded (none did; see above). - [x] Admin can select any configured tier for any user from the users page. - [x] Options offered do not vary with the user's current tier (any-to-any, including downgrades). - [x] The change requires a valid two-factor code before it is applied. - [x] Tier slot counts are correct after a move in either direction, including for an unverified user. - [x] A failed or cancelled 2FA leaves the user's tier and all slot counts unchanged. - [x] The change is written to the audit log with actor, target and before/after values. - [x] Tests covering an upgrade, a downgrade, and a cancelled 2FA. ## Verification `just check-container` green: fmt + clippy `-D warnings` + full suite (172 web tests, 107 api handler tests incl. the new tier tests, 312 domain). End to end on the dev stack: a wrong 2FA code left the tier and slot counts unchanged; a correct code moved Standard to Early Adopter (early-adopter used count 0 to 1) and wrote the audit row (`admin_tier_changed`, `from_tier=standard`, `to_tier=early_adopter`, `sessions_revoked=true`); the reverse move credited the slot back to 0; and an unverified member still occupied a slot after a move. There is no DB-backed integration harness in this repo (no `sqlx::test`), so the accounting is covered by the existing live-count SQL test plus the pure move-plan tests and this manual run. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_018TXaT3P192nDsZzbzHETb9
feat(admin): move any member to any tier, gated behind the admin's 2FA (BUNYIP-431)
All checks were successful
E2E PR gate / Install + reachability (no deployment secrets) (pull_request) Successful in 28s
Check / fmt + clippy + build + tests (pull_request) Successful in 9m17s
Create release / Create release from merged PR (pull_request) Has been skipped
0f92dd92a7
An admin can now move any member to any configured tier (lifetime, early adopter, standard, free) from the user detail page, in either direction, regardless of the tier the member currently holds. The two lifetime-specific buttons are replaced by a single tier selector; applying a change requires the acting admin's own 2FA code, a stronger gate than the shared confirm dialog because a tier move has billing consequences.

Finding on BUNYIP-118 (recorded on the issue): no server-side route to set an arbitrary tier existed. The only tier setters were grant_lifetime (lifetime), revoke_lifetime (standard) and grant_membership (always free, ignores any tier argument). So this builds both the route and the UI.

Slot accounting: tier slot usage is a live COUNT over the subscription_tier column (UserRepository::count_tier_assignments, which counts admin-granted rows and does NOT filter on email_verified per BUNYIP-96 / BUNYIP-105). A move is therefore a single UPDATE that debits the source tier and credits the destination atomically, with no separate counter to keep in sync. The new UserRepository::admin_set_subscription_tier marks the row as an admin override, sets lifetime_member for lifetime/free, and computes the trial window for early_adopter/standard from the configured trial days.

API: POST /v1/admin/users/{id}/tier { tier, totp_code }. The handler verifies the admin's TOTP BEFORE any mutation (a bad or absent code returns a validation error and changes nothing), moves the tier, mirrors grant_lifetime for a move TO lifetime (the $0 invoice subscription), revokes the member's sessions and announces the claims change on any real tier change (tier feeds has_member_access), and writes an AdminTierChanged audit entry with actor, target and before/after tiers. The move plan (whether to mint the lifetime invoice, whether to revoke sessions) and the strict tier parser are pure functions so upgrade / downgrade / cancelled-2FA are unit-testable without a database.

Web: the tier selector lists every tier regardless of the member's current tier and preselects the current one; the form collects the admin's 2FA code and posts to the API, bouncing back with a toast on a bad code.

Tests: api - the tier parser (accepts the four tiers, rejects unknown rather than silently downgrading) and the move plan (upgrade to a non-lifetime tier, upgrade to lifetime mints the invoice, downgrade from lifetime, same-tier no-op, and a failed 2FA yields Err so nothing is applied). web - the selector offers every tier regardless of current, requires a 2FA code, posts to the tier route, and preselects the current tier.

Verified end to end on the dev stack: a wrong 2FA code left the tier and slot counts unchanged; a correct code moved Standard to Early Adopter (early-adopter used count 0 to 1) and wrote the audit row; the reverse move credited the slot back to 0; and an unverified member still occupied a slot after a move (BUNYIP-105). No DB-backed integration harness exists in this repo (no sqlx::test), so the accounting is covered by the existing live-count SQL test plus the pure move-plan tests and the manual end-to-end run above.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018TXaT3P192nDsZzbzHETb9
longjacksonle deleted branch feat/BUNYIP-431-admin-tier-change-2fa 2026-08-01 22:13:53 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
psa-systems/bunyip!431
No description provided.