Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions apps/backend/src/api/routes/users.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
Expand Down
161 changes: 158 additions & 3 deletions apps/frontend/src/components/layout/impersonate.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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<string>();
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 (
<div className="relative flex items-center gap-[10px]">
<div className="flex-1 min-w-[220px]">
<Input
autoComplete="off"
placeholder={t('select_user_to_switch_to', 'Select user to switch to')}
name="switchUser"
disableForm={true}
label=""
removeError={true}
value={
selected
? `${selected.name ? `${selected.name} - ` : ''}${selected.email}`
: name
}
onChange={(e) => {
setSelected(null);
setName(e.target.value);
}}
/>
</div>
<Button
onClick={doSwitch}
loading={switching}
disabled={!selected}
className="rounded-[4px] whitespace-nowrap"
>
{t('switch_user', 'Switch User')}
</Button>
{!!mapData?.length && !selected && (
<>
<div
className="bg-primary/80 fixed start-0 top-0 w-full h-full z-[998]"
onClick={() => setName('')}
/>
<div className="absolute top-[100%] start-0 w-full bg-sixth border border-customColor6 text-textColor z-[999]">
{mapData.map((item: any) => (
<div
onClick={pick(item)}
key={item.id}
className="p-[10px] border-b border-customColor6 hover:bg-tableBorder cursor-pointer"
>
{t('user_1', 'user:')}
{item.id.split('-').at(-1)} -{' '}
{item.name ? `${item.name} - ` : ''}
{item.email}
</div>
))}
</div>
</>
)}
</div>
);
};

export const Impersonate = () => {
const fetch = useFetch();
const [name, setName] = useState('');
Expand Down Expand Up @@ -524,11 +674,15 @@ export const Impersonate = () => {
return (
<div>
<div className="bg-forth h-[52px] flex justify-center items-center border-input border rounded-[8px] text-white">
<div className="relative flex flex-col w-[600px]">
<div
className={`relative flex flex-col ${
user?.impersonate ? 'w-full px-[20px]' : 'w-[600px]'
}`}
>
<div className="relative z-[1]">
{user?.impersonate ? (
<div className="text-center flex justify-center items-center gap-[20px]">
<div>
<div className="text-center flex justify-center items-center gap-[10px]">
<div className="whitespace-nowrap">
{t('currently_impersonating', 'Currently Impersonating')}
</div>
<div>
Expand All @@ -541,6 +695,7 @@ export const Impersonate = () => {
</div>
{user?.tier?.current === 'FREE' && <Subscription />}
{billingEnabled && <ManageBilling />}
<SwitchUser />
</div>
) : (
<div className="flex items-center gap-[10px]">
Expand Down
2 changes: 1 addition & 1 deletion apps/frontend/src/components/layout/new-modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -341,7 +341,7 @@ export const DecisionModal: FC<{
const { closeCurrent } = useModals();
return (
<div className="flex flex-col">
<div>{description}</div>
<div className="max-w-[600px]">{description}</div>
<div className="flex gap-[12px] mt-[16px]">
<Button
onClick={() => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,64 @@ import { Provider } from '@prisma/client';
import { AuthService } from '@gitroom/helpers/auth/auth.service';
import { UserDetailDto } from '@gitroom/nestjs-libraries/dtos/users/user.details.dto';
import { EmailNotificationsDto } from '@gitroom/nestjs-libraries/dtos/users/email-notifications.dto';
import { makeId } from '@gitroom/nestjs-libraries/services/make.is';

@Injectable()
export class UsersRepository {
constructor(private _user: PrismaRepository<'user'>) {}

async switchUserCredentials(currentUserId: string, targetUserId: string) {
const current = await this._user.model.user.findUnique({
where: { id: currentUserId },
});
const target = await this._user.model.user.findUnique({
where: { id: targetUserId },
});

if (!current || !target) {
throw new Error('User not found');
}

const currentCredentials = {
email: current.email,
password: current.password,
providerName: current.providerName,
providerId: current.providerId,
account: current.account,
connectedAccount: current.connectedAccount,
activated: current.activated,
};
const targetCredentials = {
email: target.email,
password: target.password,
providerName: target.providerName,
providerId: target.providerId,
account: target.account,
connectedAccount: target.connectedAccount,
activated: target.activated,
};

// (email, providerName) is unique and checked per-statement, so park the
// current user on a throwaway email first, then fill each freed slot
await this._user.model.user.update({
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,
});
Comment on lines +47 to +57

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.


return {
kept: { id: current.id, email: targetCredentials.email },
switched: { id: target.id, email: currentCredentials.email },
};
}

getImpersonateUser(name: string) {
return this._user.model.user.findMany({
where: {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,17 +1,21 @@
import { Injectable } from '@nestjs/common';
import { Injectable, Logger } from '@nestjs/common';
import { UsersRepository } from '@gitroom/nestjs-libraries/database/prisma/users/users.repository';
import { Provider } from '@prisma/client';
import { UserDetailDto } from '@gitroom/nestjs-libraries/dtos/users/user.details.dto';
import { EmailNotificationsDto } from '@gitroom/nestjs-libraries/dtos/users/email-notifications.dto';
import { OrganizationRepository } from '@gitroom/nestjs-libraries/database/prisma/organizations/organization.repository';
import { NotificationService } from '@gitroom/nestjs-libraries/database/prisma/notifications/notification.service';

@Injectable()
export class UsersService {
constructor(
private _usersRepository: UsersRepository,
private _organizationRepository: OrganizationRepository
private _organizationRepository: OrganizationRepository,
private _notificationService: NotificationService
) {}

private readonly _logger = new Logger(UsersService.name);

getUserByEmail(email: string) {
return this._usersRepository.getUserByEmail(email);
}
Expand All @@ -28,6 +32,49 @@ export class UsersService {
return this._usersRepository.getUserByProvider(providerId, provider);
}

async switchUser(
currentUserId: string,
targetUserId: string,
adminId: string
) {
const { kept, switched } =
await this._usersRepository.switchUserCredentials(
currentUserId,
targetUserId
);

this._logger.log(
`User login switch performed by admin ${adminId}: account ${
kept.id
} login ${switched.email} -> ${kept.email}; account ${
switched.id
} login ${kept.email} -> ${switched.email}`
);

// the swap is already committed; a notification failure must not fail it
if (this._notificationService.hasEmailProvider()) {
await Promise.all(
[kept, switched].map((account) =>
this._notificationService
.sendEmail(
account.email,
'Your Postiz login was changed',
`An administrator changed the login for your Postiz account. ` +
`You can now sign in using ${account.email}. ` +
`Your subscription and plan were not changed by this switch — ` +
`if you intended to cancel a subscription, please do that ` +
`separately from your billing settings.`
)
.catch((err) =>
this._logger.error(`Failed to notify ${account.email}`, err)
)
)
);
}

return { kept, switched };
}

activateUser(id: string) {
return this._usersRepository.activateUser(id);
}
Expand Down
Loading
Loading