Skip to content

feat(admin): Switch User — swap login between two accounts#1730

Open
giladresisi wants to merge 1 commit into
mainfrom
feat/swap-user-logins
Open

feat(admin): Switch User — swap login between two accounts#1730
giladresisi wants to merge 1 commit into
mainfrom
feat/swap-user-logins

Conversation

@giladresisi

Copy link
Copy Markdown
Collaborator

Adds a super-admin Switch User action to the impersonation bar: it swaps the login credentials (email, password, OAuth identity, activation state) between two accounts, while each account keeps its id, organization, subscription, channels and media — so each login now reaches the other's data.

  • Plain sequential updates (repo-style, no transaction / raw SQL): a throwaway parking email works around the per-statement (email, providerName) unique index.
  • Guarded: requires an active impersonation session and rejects the admin's own account as the swap target — fixes both Sentry review findings from feat(admin): Switch User — swap login between two existing accounts #1668 (a direct call or self-pick would have swapped the admin's own login away).
  • Stripe customer emails follow the login — real cus_ customers only (admin-granted subscriptions are skipped), owner-only for shared orgs, deduped per customer.
  • Both accounts get an email that their login changed and that the subscription was not touched.
  • Frontend: search picker + confirmation dialog in the impersonation bar; a failed switch shows a toaster instead of silently reloading.

Supersedes #1720 (same feature) with both review comments addressed: the raw SELECT … FOR UPDATE query and the $transaction wrapper are gone in favor of the plain sequential-update pattern used everywhere else in the repo, and the pickCredentials helper is inlined.

Tested end-to-end (swap + switch back between an admin-granted and a real Stripe subscription); the Stripe customer-email sync was verified in the Stripe test dashboard.

🤖 Generated with Claude Code

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. For multi-user organizations the sync is owner-only
  (SUPERADMIN) so a non-owner's switch can't rewrite a shared org's receipts
  address, and de-duplicated per customer so two co-owners of the same org
  don't race two concurrent updates.

- Awareness: both accounts get an email stating the login changed and that the
  subscription was NOT changed (cancel separately if intended). No in-app
  notification: the admin performs the switch with the user's consent, and
  in-app notices are organization-scoped so they would leak to co-members of
  shared orgs. 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.

- Layering (Controller >> Service >> Repository): the controller only guards
  the request and delegates — UsersService.switchUser does the swap, audit log
  and "login changed" emails; the Stripe customer-email sync lives in
  StripeService (it can't live in UsersService because StripeService already
  injects UsersService, which would create a DI cycle — the controller
  composes the two). The only other request-shaped logic in the controller is
  reading the admin id from the auth token.

- Impersonation guard (fixes both Sentry review findings on PR #1668): the
  endpoint now requires an active impersonation session (request user must
  differ from the token user) and rejects the admin's own account as the swap
  target. Without this, a direct call outside impersonation — or picking
  yourself in the search, which excludes only the impersonated user — would
  swap the admin's own login away, handing the admin account (isSuperAdmin
  stays on the account) to another login.

- 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. A failed switch shows a toaster
  instead of silently reloading. The impersonated user is excluded from the
  picker, results are de-duplicated by user id, and name-less users no longer
  render "null". The picker deliberately duplicates the impersonation-search
  UI instead of extracting a shared component, keeping this PR additive in
  this file (the two selectors differ in id semantics, filtering and pick
  behavior). The shared decision modal's description gets max-w-[600px] so a
  long confirmation wraps instead of stretching the dialog across the screen.

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. (E2E testing predates the review trims in this commit: in-app
notification removed, failure dialog → toaster, shorter confirmation text,
comment/code slimming. Backend and frontend re-typechecked clean after them.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@postiz-contribution postiz-contribution Bot added the contribution:approved Approved contributor label Jul 15, 2026
@postiz-contribution

Copy link
Copy Markdown

Contribution-checker quality warning
Heuristic score: 3/100 (low). This is a non-blocking warning surfaced by the project's quality settings.

Heuristics that flagged:

  • Excessive inline code references: 5 inline refs (>3)
  • PR doesn't use the repo's PR template: 5 required checkbox items missing (max 1, match ≥80%)
  • AI watermark phrase: Matched: "Generated with Claude Code"
  • Commit message too long: Longest: 5110 chars
  • Excessive added comments: 12 added comment lines (ratio 0.04)

If this is a genuine contribution, please add detail to your PR description and tighten the diff scope before reviewers look at it.

@postiz-agent

postiz-agent Bot commented Jul 15, 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 +47 to +57
where: { id: current.id },
data: { email: `switch-${makeId(10)}-${current.email}` },
});
await this._user.model.user.update({
where: { id: target.id },
data: currentCredentials,
});
await this._user.model.user.update({
where: { id: current.id },
data: targetCredentials,
});

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: The three-step credential swap uses sequential update calls instead of a database transaction. A failure between steps can lead to account data corruption and user lockout.
Severity: HIGH

Suggested Fix

Wrap the three sequential _user.model.user.update(...) calls within a single this._user.model.$transaction([...]) block. This will ensure that all three updates either succeed together or fail together, maintaining data consistency and preventing partial updates that lead to account corruption.

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:
libraries/nestjs-libraries/src/database/prisma/users/users.repository.ts#L46-L57

Potential issue: The credential swap process involves three separate database `update`
calls to park the current user's email, assign it to the target user, and then assign
the target's email to the current user. These operations are not wrapped in a database
transaction. If a process crash or connection failure occurs after the second update but
before the third, the current user's email will be left as a temporary, unknown value
(e.g., `switch-random_id-user@email.com`), permanently locking them out. Meanwhile, the
target user will have the current user's original credentials, corrupting both accounts.

Did we get this right? 👍 / 👎 to inform future reviews.

@giladresisi giladresisi Jul 15, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

This is intentional. The original version used a transaction with a row lock, and it was removed per maintainer review (#1720) - the repo convention is plain sequential updates without transactions (see e.g. createOrUpdateSubscription, which does multiple dependent writes the same way). The exposure is minimal: this is an admin-only, manually triggered operation, and a crash between updates leaves the account in a state that's trivially visible and repairable by the same admin (the parked email embeds the original address). Not fixing.

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

Labels

contribution:approved Approved contributor

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant