Skip to content

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

Closed
giladresisi wants to merge 1 commit into
mainfrom
swap-logins
Closed

feat(admin): Switch User — swap login between two accounts#1720
giladresisi wants to merge 1 commit into
mainfrom
swap-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.

  • Atomic swap in a single Postgres transaction: a throwaway parking email works around the per-statement (email, providerName) unique index, with a deterministic FOR UPDATE lock ordering.
  • 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 #1668 — same feature, split from the unrelated admin-subscription fixes, with review trims, proper Controller >> Service >> Repository layering, and the Sentry findings fixed.

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 14, 2026
@postiz-contribution

Copy link
Copy Markdown

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

Heuristics that flagged:

  • 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: 14 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 14, 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.

@nevo-david nevo-david left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

fixes are required

private _transaction: PrismaTransaction
) {}

switchUserCredentials(currentUserId: string, targetUserId: string) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

We don't use raw query

import { EmailNotificationsDto } from '@gitroom/nestjs-libraries/dtos/users/email-notifications.dto';
import { makeId } from '@gitroom/nestjs-libraries/services/make.is';

const pickCredentials = ({

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

slop

@giladresisi

Copy link
Copy Markdown
Collaborator Author

Superseded by #1730, which addresses both review comments (raw query and $transaction dropped for plain sequential updates; pickCredentials inlined).

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.

2 participants