From 49dbe333db9953264c8d9a44fc5cc5b9010c2134 Mon Sep 17 00:00:00 2001 From: Gilad Resisi Date: Wed, 1 Jul 2026 16:39:02 +0700 Subject: [PATCH] =?UTF-8?q?feat:=20admin=20"Switch=20User"=20=E2=80=94=20s?= =?UTF-8?q?wap=20login=20between=20two=20accounts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Co-Authored-By: Claude Fable 5 --- .../src/api/routes/users.controller.ts | 46 +++++ .../src/components/layout/impersonate.tsx | 161 +++++++++++++++++- .../src/components/layout/new-modal.tsx | 2 +- .../database/prisma/users/users.repository.ts | 71 +++++++- .../database/prisma/users/users.service.ts | 51 +++++- .../src/services/stripe.service.ts | 36 ++++ 6 files changed, 358 insertions(+), 9 deletions(-) diff --git a/apps/backend/src/api/routes/users.controller.ts b/apps/backend/src/api/routes/users.controller.ts index 98e888d72..26f3441e3 100644 --- a/apps/backend/src/api/routes/users.controller.ts +++ b/apps/backend/src/api/routes/users.controller.ts @@ -16,6 +16,7 @@ import { GetOrgFromRequest } from '@gitroom/nestjs-libraries/user/org.from.reque import { StripeService } from '@gitroom/nestjs-libraries/services/stripe.service'; import { Response, Request } from 'express'; import { AuthService } from '@gitroom/backend/services/auth/auth.service'; +import { AuthService as AuthChecker } from '@gitroom/helpers/auth/auth.service'; import { OrganizationService } from '@gitroom/nestjs-libraries/database/prisma/organizations/organization.service'; import { CheckPolicies } from '@gitroom/backend/services/auth/permissions/permissions.ability'; import { getCookieUrlFromDomain } from '@gitroom/helpers/subdomain/subdomain.management'; @@ -183,6 +184,51 @@ export class UsersController { } } + @Post('/switch') + async switchUser( + @GetUserFromRequest() user: User, + @Body('id') id: string, + @Req() req: Request + ) { + if (!user.isSuperAdmin) { + throw new HttpException('Unauthorized', 400); + } + + // `user` is the impersonated account, so the admin id comes from the token. + // Require an active impersonation session and never allow the admin's own + // account in the swap — either would trade away the admin's login. + const adminId = this.getRequestUserId(req); + if ( + !id || + !adminId || + id === user.id || + adminId === user.id || + adminId === id + ) { + throw new HttpException('Invalid user to switch to', 400); + } + + const { kept, switched } = await this._userService.switchUser( + user.id, + id, + adminId + ); + + await this._stripeService.syncCustomerEmailsAfterSwitch([kept, switched]); + + return { success: true }; + } + + private getRequestUserId(req: Request): string | null { + try { + const auth = (req.headers.auth as string) || req.cookies?.auth; + const payload = AuthChecker.verifyJWT(auth) as { id?: string } | null; + return payload?.id || null; + } catch { + return null; + } + } + @Post('/personal') async changePersonal( @GetUserFromRequest() user: User, diff --git a/apps/frontend/src/components/layout/impersonate.tsx b/apps/frontend/src/components/layout/impersonate.tsx index 6f907640d..0a48a0ea5 100644 --- a/apps/frontend/src/components/layout/impersonate.tsx +++ b/apps/frontend/src/components/layout/impersonate.tsx @@ -10,6 +10,7 @@ import { useVariables } from '@gitroom/react/helpers/variable.context'; import { setCookie } from '@gitroom/frontend/components/layout/layout.context'; import { useT } from '@gitroom/react/translation/get.transation.service.client'; import { useModals } from '@gitroom/frontend/components/layout/new-modal'; +import { useToaster } from '@gitroom/react/toaster/toaster'; import { Button } from '@gitroom/react/form/button'; import { ImportDebugPostModal } from '@gitroom/frontend/components/launches/import-debug-post.modal'; @@ -463,6 +464,155 @@ const ImportDebugPost = () => { ); }; +const SwitchUser = () => { + const fetch = useFetch(); + const t = useT(); + const toaster = useToaster(); + const currentUser = useUser(); + const [name, setName] = useState(''); + const [selected, setSelected] = useState<{ + id: string; + name: string; + email: string; + } | null>(null); + const [switching, setSwitching] = useState(false); + + const load = useCallback(async () => { + if (!name) { + return []; + } + return await (await fetch(`/user/impersonate?name=${name}`)).json(); + }, [name]); + + const { data } = useSWR(`/switch-search-${name}`, load, { + refreshWhenHidden: false, + revalidateOnMount: true, + revalidateOnReconnect: false, + revalidateOnFocus: false, + refreshWhenOffline: false, + revalidateIfStale: false, + refreshInterval: 0, + }); + + const mapData = useMemo(() => { + // one row per user-organization: dedupe by user id, drop the impersonated user + const seen = new Set(); + return (data || []) + .filter((curr: any) => curr.user.id !== currentUser?.id) + .filter((curr: any) => { + if (seen.has(curr.user.id)) { + return false; + } + seen.add(curr.user.id); + return true; + }) + .map((curr: any) => ({ + id: curr.user.id, + name: curr.user.name, + email: curr.user.email, + })); + }, [data, currentUser?.id]); + + const pick = useCallback( + (item: { id: string; name: string; email: string }) => () => { + setSelected(item); + setName(''); + }, + [] + ); + + const doSwitch = useCallback(async () => { + if (!selected) { + return; + } + if ( + !(await deleteDialog( + t( + 'switch_user_confirm', + `This will replace the current account's login with ${selected.email}. All data and the subscription stay with the account — only the login changes, and the new login gains its full access. Switch back to revert.` + ), + t('yes_switch', 'Yes, switch'), + t('switch_user_title', 'Switch User?'), + t('no_cancel', 'No, cancel') + )) + ) { + return; + } + setSwitching(true); + try { + const res = await fetch('/user/switch', { + method: 'POST', + body: JSON.stringify({ id: selected.id }), + }); + // customFetch does not throw on HTTP errors + if (!res.ok) { + throw new Error(await res.text().catch(() => '')); + } + window.location.reload(); + } catch { + setSwitching(false); + toaster.show( + t('switch_user_failed', 'The user switch failed and nothing was changed'), + 'warning' + ); + } + }, [selected]); + + return ( +
+
+ { + setSelected(null); + setName(e.target.value); + }} + /> +
+ + {!!mapData?.length && !selected && ( + <> +
setName('')} + /> +
+ {mapData.map((item: any) => ( +
+ {t('user_1', 'user:')} + {item.id.split('-').at(-1)} -{' '} + {item.name ? `${item.name} - ` : ''} + {item.email} +
+ ))} +
+ + )} +
+ ); +}; + export const Impersonate = () => { const fetch = useFetch(); const [name, setName] = useState(''); @@ -524,11 +674,15 @@ export const Impersonate = () => { return (
-
+
{user?.impersonate ? ( -
-
+
+
{t('currently_impersonating', 'Currently Impersonating')}
@@ -541,6 +695,7 @@ export const Impersonate = () => {
{user?.tier?.current === 'FREE' && } {billingEnabled && } +
) : (
diff --git a/apps/frontend/src/components/layout/new-modal.tsx b/apps/frontend/src/components/layout/new-modal.tsx index 090f43660..d59bf0ff7 100644 --- a/apps/frontend/src/components/layout/new-modal.tsx +++ b/apps/frontend/src/components/layout/new-modal.tsx @@ -341,7 +341,7 @@ export const DecisionModal: FC<{ const { closeCurrent } = useModals(); return (
-
{description}
+
{description}