diff --git a/apps/backend/src/api/routes/users.controller.ts b/apps/backend/src/api/routes/users.controller.ts index 98e888d72c..26f3441e3b 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 6f907640d0..0a48a0ea52 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 090f43660a..d59bf0ff79 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}