feat(admin): move any member to any tier, gated behind the admin's 2FA (BUNYIP-431) #431
Loading…
Reference in a new issue
No description provided.
Delete branch "feat/BUNYIP-431-admin-tier-change-2fa"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
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), andgrant_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
COUNTover thesubscription_tiercolumn (UserRepository::count_tier_assignments), which counts admin-granted rows and does NOT filter onemail_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 newUserRepository::admin_set_subscription_tiermarks the row as an admin override, setslifetime_memberfor lifetime/free, and computes the trial window for early_adopter/standard from the configured trial days.API
POST /v1/admin/users/{id}/tierwith{ tier, totp_code }. The handler:grant_lifetimefor a move TO lifetime (mints the $0 invoice subscription);has_member_access);AdminTierChangedaudit 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)
grant_lifetimewhich also lets an admin override the cap. The live count then correctly reflectsused > slotsfor admin-placed members.grant_lifetime); moving AWAY does not cancel it, matching the existingrevoke_lifetime(Stripe cleanup was already the caller's responsibility and the current revoke does not do it). No new Stripe cancellation behaviour is introduced.subscription_status='active'andsubscription_override_by=admin, i.e. an "admin-granted" active membership on that tier.Acceptance criteria
Verification
just check-containergreen: 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 (nosqlx::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
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