feat(admin): Switch User β swap login between two existing accounts#1668
feat(admin): Switch User β swap login between two existing accounts#1668giladresisi wants to merge 4 commits into
Conversation
Admin-granted subscriptions store the user id in Organization.paymentId (not a real `cus_...` customer), so every billing path that fed paymentId to Stripe threw "No such customer". Guard them all to skip Stripe when there is no real `cus_` customer: - getCharges returns no charges for any non-`cus_` paymentId. - prorate returns a zero price (it fires on billing-page load per plan card, which was the main source of error logs). - checkDiscount / applyDiscount return false (no coupon to offer/apply). - finishTrial returns early (nothing to finish). - /billing/portal, /billing/cancel and /billing/cancel-subscription reject with a clean 400 instead of a Stripe error (the UI also hides their buttons). Also on the display side: /self exposes `hasStripeCustomer` (paymentId starts with `cus_`), and the billing page only renders the "Update Payment Method / Invoices History" button (which opens the Stripe billing portal) when it is true. Admin-granted and never-subscribed orgs have no real customer, so the button is hidden and the portal is never reached. Validated by impersonating an admin-granted account (paymentId = user id): the billing page now loads with no Stripe error logs (previously flooded by the per-plan prorate call), and the Update Payment Method button is hidden. A real `cus_` subscriber still sees the button and the portal opens normally. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
An admin could see and pick their own account in the impersonation list and "impersonate" themselves. getImpersonateUser now takes the requesting user id and excludes it from the results, so an admin can neither impersonate nor (via the same search) switch to themselves. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The impersonation bar could grant a free/admin subscription but not undo it.
Adds a super-admin "Remove Subscription" action next to the grant selector.
- New POST /billing/remove-subscription (super-admin) β
removeAdminGrantedSubscription: downgrades the org to FREE, drops the local
subscription row via the existing deleteSubscription path, and clears the
org's fake paymentId β all with NO Stripe calls. Clearing paymentId is
required: admin grants store the user id in paymentId, and leaving it would
make later Stripe flows (checkout, discount checks) call Stripe with a
non-existent customer and error ("No such customer").
- Guarded to admin-granted subscriptions only: those store the user id in
Organization.paymentId, so any paymentId starting with `cus_` is a real
Stripe customer and is rejected (use cancel-subscription instead) β a real
paid subscription can never be removed here.
- /self now returns `adminGrantedSubscription`, and the impersonation bar shows
the remove control only when it is true, so a genuine Stripe subscription is
never accidentally removed.
- Since an admin-granted subscription has no Stripe subscription to cancel, the
now-redundant Cancel controls are hidden when `adminGrantedSubscription` is
true: the impersonation bar's "Cancel Subscription" (Remove is used instead)
and the billing page's self-service "Cancel subscription" button. Their
backend endpoints (in the previous commit) already reject non-`cus_` orgs
with a clean 400 as defense-in-depth.
Validated by impersonating an admin-granted account: the Remove control shows
while both Cancel controls (impersonation bar and billing page) are hidden;
impersonating a real `cus_` subscriber hides Remove and still shows the Cancel
controls, which continue to work.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds a super-admin action (in the impersonation bar) to move one account's login onto another existing account, keeping each Postiz entity β its organization, subscription, channels and media β in place and only trading the login credentials, so each email now reaches the other's data. Key decisions: - Swap, not email-edit: (email, providerName) is a unique index checked per-statement, so we can't trade two emails directly. The repository parks one account on a unique throwaway email, then fills each freed slot in order, all inside a single Postgres interactive transaction (with a deterministic FOR UPDATE lock). This is atomic and restart/redeploy-safe β a crash before commit rolls back cleanly and the throwaway email never persists β and allows unlimited parallel switches on disjoint accounts. - Full credential tuple moves together: email, password (bcrypt hash), providerName, providerId, account, connectedAccount, and `activated`. `activated` is email-verification state, so it belongs to the login: an unverified email stays unverified whichever account uses it, and a verified email needs no re-activation. - Stripe: customer emails are synced after the swap, but only for real Stripe customers (paymentId starting with `cus_`). Admin-granted subscriptions store the user id in paymentId, so they are skipped to avoid failed Stripe requests; subscription entitlement still follows the login because it lives on the organization. - Awareness: both accounts get an email stating the login changed and that the subscription was NOT changed (cancel separately if intended). Any side left on an unverified email is sent a fresh activation link. The admin action is written to an audit log line with the real admin id (read from the auth token, since the request user is the impersonated account). Active sessions are not force-logged-out (no such mechanism exists); the swap applies on next login and the notifications cover the gap. - Multi-user organizations: the swap only rewrites the two User rows, never memberships or org rows, so multi-member orgs on either or both sides (or a shared org) can never error. The org-scoped side effects are guarded so they don't spill onto co-members: (a) the in-app "your login changed" notice is posted only for orgs where the switched user is the sole member β a shared org's other members must not see it β while the email always reaches the switched login; (b) the Stripe billing-email sync runs owner-only (SUPERADMIN) so a non-owner's switch can't rewrite a shared org's receipts address; and (c) that sync is de-duplicated per customer so two co-owners of the same org don't race two concurrent updates. - Frontend: Switch User lives in the impersonation bar, disabled until a target is picked, with an "Are you sure?" confirmation that notes the new login gains the account's full access (including owner/admin roles in shared orgs) while those organizations and their members are unchanged. The impersonated user is excluded from the picker, results are de-duplicated by user id, and name-less users no longer render "null". Fully tested end-to-end, including a swap between an admin-granted subscription and a real Stripe subscription: credentials traded while each org/subscription stayed put, and the round trip (switch then switch back) restored the exact original state. The bidirectional Stripe customer-email sync was verified directly in the Stripe test dashboard on stripe.com β the customer's email followed the login on the swap and reverted on the switch back. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
β Snyk checks have passed. No issues have been found so far.
π» Catch issues earlier using the plugins for VS Code, JetBrains IDEs, Visual Studio, and Eclipse. |
| throw new HttpException('Unauthorized', 400); | ||
| } | ||
|
|
||
| return this._userService.getImpersonateUser(name); | ||
| return this._userService.getImpersonateUser(name, user.id); | ||
| } | ||
|
|
||
| @Post('/impersonate') |
There was a problem hiding this comment.
Bug: When an admin is impersonating a user, the 'Switch User' search incorrectly includes the admin's own account, allowing them to swap credentials with the impersonated user.
Severity: HIGH
Suggested Fix
The original admin's user ID needs to be tracked throughout the impersonation session. The auth middleware should attach the original admin's user object to the request, for example as req.originalUser. This ID should then be passed to getImpersonateUser to ensure the admin's own account is excluded from the search results, preventing the switch.
Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent. Verify if this is a real issue. If it is, propose a fix; if not, explain why it's
not valid.
Location: apps/backend/src/api/routes/users.controller.ts#L171-L177
Potential issue: When an admin impersonates a user, the auth middleware replaces
`req.user` with the impersonated user's data but does not preserve the original admin's
identity. Consequently, when the admin uses the 'Switch User' feature, the
`getImpersonateUser` function is called with the impersonated user's ID to exclude from
search results. This fails to exclude the admin's own account, allowing it to appear in
the search. If the admin selects their own account, the `POST /user/switch` endpoint
validation passes because it only checks against the impersonated user's ID, resulting
in a successful but unintended credential swap between the admin and the impersonated
user.
Also affects:
apps/backend/src/api/routes/users.controller.ts:204-270
Did we get this right? π / π to inform future reviews.
Why this is needed
Occasionally a user wants to replace the login on their account with the login of another account they own β for example, they signed up with Google but now want to sign in with email/password, or they keep two separate accounts and want one account's credentials to reach the other's data β without losing anything (organization, subscription, channels, media, history). Today an admin has no safe way to do this.
This PR adds a super-admin "Switch User" action in the impersonation bar that swaps the login identity between two existing accounts while each account keeps all of its data in place, so each login now reaches the other's account. It is fully reversible β switching the same two back restores the original state.
What is swapped (and what is not)
Only the login identity on the two
Userrows moves: email, password (bcrypt hash), OAuth provider/id, and theactivated(email-verification) flag. Everything that makes up the account β itsUser.id, organization memberships, roles, subscription, channels, media β stays exactly where it is. After the switch, signing in with account A's new email authenticates as account A and reaches all of A's data; the two people effectively swap seats.Multi-user organizations
Because the swap rewrites only the two
Userrows (never memberships orOrganizationrows), multi-member orgs on either or both sides β including a shared org β cannot cause errors or move any data. The org-scoped side effects are guarded so nothing spills onto co-members:SUPERADMIN), so a non-owner's switch cannot rewrite a shared org's receipts email;The other members' accounts, roles, and data are never touched.
How the swap is atomic
(email, providerName)is a unique index checked per-statement, so two emails cannot be traded directly. The swap runs inside a single Postgres interactive transaction with a deterministicFOR UPDATElock on both rows: it parks one account on a unique throwaway email, then fills each freed slot in order. This is atomic and restart/redeploy-safe β a crash before commit rolls back cleanly and the throwaway email never persists β and disjoint switches can run in parallel.Why the three preceding commits
The main feature sits on top of three smaller commits that fix pre-existing gaps in the admin/billing surface it touches. They are kept separate so each is reviewable and revertable on its own:
Organization.paymentId(not a realcus_...customer), so every billing path that fed it to Stripe threw "No such customer" (flooding the logs on billing-page load). This guards them all to skip Stripe when there is no real customer, hides the billing-portal button, and rejects the portal/cancel endpoints with a clean 400.These are cleanup and fixes of real errors and logical/semantic gaps in the same admin area; Switch User builds on the corrected behavior.
Testing
Validated end-to-end (details in the individual commit messages): credential swap with round-trip restore, an admin-granted β real-Stripe swap, the bidirectional Stripe email sync verified directly in the Stripe test dashboard, admin-granted billing paths producing no Stripe errors, and the cancel-control hides.
Deferred (not in this PR): replacing the
paymentIdoverload for admin grants with an explicitisAdminGrantedflag (so a churned user's realcus_customer is preserved on grant) requires a Prisma migration and is left as a follow-up.π€ Generated with Claude Code