feat(admin): Switch User — swap login between two accounts#1720
Closed
giladresisi wants to merge 1 commit into
Closed
feat(admin): Switch User — swap login between two accounts#1720giladresisi wants to merge 1 commit into
giladresisi wants to merge 1 commit into
Conversation
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>
|
Contribution-checker quality warning Heuristics that flagged:
If this is a genuine contribution, please add detail to your PR description and tighten the diff scope before reviewers look at it. |
✅ 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. |
nevo-david
reviewed
Jul 15, 2026
| private _transaction: PrismaTransaction | ||
| ) {} | ||
|
|
||
| switchUserCredentials(currentUserId: string, targetUserId: string) { |
| import { EmailNotificationsDto } from '@gitroom/nestjs-libraries/dtos/users/email-notifications.dto'; | ||
| import { makeId } from '@gitroom/nestjs-libraries/services/make.is'; | ||
|
|
||
| const pickCredentials = ({ |
Collaborator
Author
|
Superseded by #1730, which addresses both review comments (raw query and $transaction dropped for plain sequential updates; pickCredentials inlined). |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.
(email, providerName)unique index, with a deterministicFOR UPDATElock ordering.cus_customers only (admin-granted subscriptions are skipped), owner-only for shared orgs, deduped per customer.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