Skip to content

feat(admin): Switch User β€” swap login between two existing accounts#1668

Open
giladresisi wants to merge 4 commits into
mainfrom
switch-user
Open

feat(admin): Switch User β€” swap login between two existing accounts#1668
giladresisi wants to merge 4 commits into
mainfrom
switch-user

Conversation

@giladresisi

Copy link
Copy Markdown
Collaborator

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 User rows moves: email, password (bcrypt hash), OAuth provider/id, and the activated (email-verification) flag. Everything that makes up the account β€” its User.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 User rows (never memberships or Organization rows), 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:

  • 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); the email always reaches the switched login;
  • the Stripe billing-email sync is owner-only (SUPERADMIN), so a non-owner's switch cannot rewrite a shared org's receipts email;
  • that sync is de-duplicated per customer, so two co-owners of the same org never race concurrent updates.

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 deterministic FOR UPDATE lock 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:

  1. fix(admin): don't hit Stripe for admin-granted subscriptions β€” admin-granted subscriptions store the user id in Organization.paymentId (not a real cus_... 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.
  2. fix(admin): exclude the current admin from the impersonation search β€” the impersonation/switch picker listed the acting admin themselves; this excludes them so an admin cannot impersonate or switch into their own account.
  3. feat(admin): allow removing an admin-granted subscription β€” the impersonation bar could grant a free/admin subscription but not undo it; this adds a super-admin "Remove Subscription" action (DB-only, no Stripe) and hides the now-redundant Cancel controls for admin-granted subs.

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 paymentId overload for admin grants with an explicit isAdminGranted flag (so a churned user's real cus_ customer is preserved on grant) requires a Prisma migration and is left as a follow-up.

πŸ€– Generated with Claude Code

giladresisi and others added 4 commits July 2, 2026 17:19
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>
@postiz-agent

postiz-agent Bot commented Jul 2, 2026

Copy link
Copy Markdown

βœ… Snyk checks have passed. No issues have been found so far.

Status Scan Engine Critical High Medium Low Total (0)
βœ… Open Source Security 0 0 0 0 0 issues
βœ… Licenses 0 0 0 0 0 issues
βœ… Code Security 0 0 0 0 0 issues

πŸ’» Catch issues earlier using the plugins for VS Code, JetBrains IDEs, Visual Studio, and Eclipse.

Comment on lines 171 to 177
throw new HttpException('Unauthorized', 400);
}

return this._userService.getImpersonateUser(name);
return this._userService.getImpersonateUser(name, user.id);
}

@Post('/impersonate')

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant