-
-
Notifications
You must be signed in to change notification settings - Fork 6.2k
feat(admin): Switch User — swap login between two existing accounts #1668
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
eb604f1
6b6440f
8725355
20843fc
1829ea8
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -3,6 +3,7 @@ import { | |
| Controller, | ||
| Get, | ||
| HttpException, | ||
| Logger, | ||
| Post, | ||
| Query, | ||
| Req, | ||
|
|
@@ -16,6 +17,8 @@ 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 { NotificationService } from '@gitroom/nestjs-libraries/database/prisma/notifications/notification.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'; | ||
|
|
@@ -44,9 +47,12 @@ export class UsersController { | |
| private _authService: AuthService, | ||
| private _orgService: OrganizationService, | ||
| private _userService: UsersService, | ||
| private _trackService: TrackService | ||
| private _trackService: TrackService, | ||
| private _notificationService: NotificationService | ||
| ) {} | ||
|
|
||
| private readonly _logger = new Logger(UsersController.name); | ||
|
|
||
| @Get('/chatbase-token') | ||
| async getChatbaseToken( | ||
| @GetUserFromRequest() user: User, | ||
|
|
@@ -122,12 +128,24 @@ export class UsersController { | |
| role: organization?.users[0]?.role, | ||
| // @ts-ignore | ||
| isLifetime: !!organization?.subscription?.isLifetime, | ||
| // An admin-granted subscription stores the user id in `paymentId` rather | ||
| // than a real Stripe `cus_...` customer; used to expose an admin-only | ||
| // remove action without risking a real paid subscription. | ||
| adminGrantedSubscription: | ||
| // @ts-ignore | ||
| !!organization?.subscription && | ||
| !organization?.paymentId?.startsWith('cus_'), | ||
| admin: !!user.isSuperAdmin, | ||
| impersonate: !!impersonate, | ||
| isTrailing: !process.env.STRIPE_PUBLISHABLE_KEY | ||
| ? false | ||
| : organization?.isTrailing, | ||
| allowTrial: organization?.allowTrial, | ||
| // A real Stripe customer has a `cus_...` paymentId. Admin-granted | ||
| // subscriptions store the user id there instead, and never-subscribed | ||
| // orgs have none, so gate billing-portal UI on this to avoid calling | ||
| // Stripe with a non-existent customer ("No such customer"). | ||
| hasStripeCustomer: !!organization?.paymentId?.startsWith('cus_'), | ||
| streakSince: organization?.streakSince || null, | ||
| publicApi: | ||
| // @ts-ignore | ||
|
|
@@ -153,7 +171,7 @@ export class UsersController { | |
| throw new HttpException('Unauthorized', 400); | ||
| } | ||
|
|
||
| return this._userService.getImpersonateUser(name); | ||
| return this._userService.getImpersonateUser(name, user.id); | ||
| } | ||
|
|
||
| @Post('/impersonate') | ||
|
|
@@ -183,6 +201,155 @@ 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); | ||
| } | ||
|
|
||
| if (!id || id === user.id) { | ||
| throw new HttpException('Invalid user to switch to', 400); | ||
| } | ||
|
Comment on lines
+206
to
+216
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Bug: The Suggested FixAdd a guard at the beginning of the Prompt for AI Agent |
||
|
|
||
| // `user` is the impersonated account; `id` is the account whose login it is | ||
| // being swapped with. The swap keeps both user ids (and therefore their | ||
| // organizations, subscriptions, channels and media) in place and only | ||
| // trades the login credentials, so each login now reaches the other's data. | ||
| const oldEmail = user.email; | ||
| const { kept, switched } = await this._userService.switchUser(user.id, id); | ||
|
|
||
| // Audit: `user` is the impersonated account, so read the real admin id from | ||
| // the underlying auth token. | ||
| const adminId = this.getRequestUserId(req); | ||
| this._logger.log( | ||
| `User login switch performed${ | ||
| adminId ? ` by admin ${adminId}` : '' | ||
| }: account ${kept.id} login ${oldEmail} -> ${kept.email}; account ${ | ||
| switched.id | ||
| } login ${kept.email} -> ${switched.email}` | ||
| ); | ||
|
|
||
| // Mirror the swap onto Stripe: each org's customer now belongs to whoever | ||
| // logs in with the owner's new email. | ||
| await this.syncStripeCustomersEmail([kept, switched]); | ||
|
|
||
| // If a login ended up on a never-verified email, it must be re-verified, so | ||
| // send it a fresh activation link. Failures must not fail the request. | ||
| await Promise.all( | ||
| [kept, switched].map(async (account) => { | ||
| if (account.activated) { | ||
| return; | ||
| } | ||
| try { | ||
| await this._authService.resendActivationEmail(account.email); | ||
| } catch (err) { | ||
| this._logger.error( | ||
| `Failed to send activation email to ${account.email}`, | ||
| err | ||
| ); | ||
| } | ||
| }) | ||
| ); | ||
|
|
||
| // Let both accounts know their login changed. The swap is already | ||
| // committed, so a notification failure must not fail the request. | ||
| try { | ||
| await Promise.all([ | ||
| this.notifyUserOfSwitch(kept.id, kept.email), | ||
| this.notifyUserOfSwitch(switched.id, switched.email), | ||
| ]); | ||
| } catch (err) { | ||
| this._logger.error('Failed to notify users after login switch', err); | ||
| } | ||
|
|
||
| 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; | ||
| } | ||
| } | ||
|
|
||
| private async notifyUserOfSwitch(userId: string, email: string) { | ||
| const subject = 'Your Postiz login was changed'; | ||
| const message = | ||
| `An administrator changed the login for your Postiz account. ` + | ||
| `You can now sign in using ${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.`; | ||
|
|
||
| const organizations = await this._orgService.getOrgsByUserId(userId); | ||
| await Promise.all( | ||
| organizations.map(async (org) => { | ||
| // In-app notifications are organization-scoped, so posting to a shared | ||
| // organization would show "your login changed" to co-members who were | ||
| // not part of the switch. Only post where this user is the sole member; | ||
| // the email below reaches the switched login specifically in every case. | ||
| const team = await this._orgService.getTeam(org.id); | ||
| if ((team?.users?.length || 0) > 1) { | ||
| return; | ||
| } | ||
| await this._notificationService.inAppNotification( | ||
| org.id, | ||
| subject, | ||
| message, | ||
| false, | ||
| false, | ||
| 'info' | ||
| ); | ||
| }) | ||
| ); | ||
|
|
||
| if (this._notificationService.hasEmailProvider()) { | ||
| await this._notificationService.sendEmail(email, subject, message); | ||
| } | ||
| } | ||
|
|
||
| private async syncStripeCustomersEmail( | ||
| accounts: { id: string; email: string }[] | ||
| ) { | ||
| // Map each real Stripe customer to the login email it should now carry. | ||
| // Two guards for multi-user organizations: | ||
| // - Owner-only: only the org owner's (SUPERADMIN) login drives its billing | ||
| // email, so a non-owner member's switch never rewrites a shared org's | ||
| // receipts address. | ||
| // - Dedupe: each customer is updated once even when both switched accounts | ||
| // own the same organization (the first account processed wins), avoiding | ||
| // two concurrent, racing updates to the same customer. | ||
| // Only real Stripe customers are touched — admin-granted subscriptions store | ||
| // the user id in `paymentId` (not a `cus_...` id), so calling Stripe for | ||
| // them only produces failed requests. | ||
| const emailByCustomer = new Map<string, string>(); | ||
| for (const account of accounts) { | ||
| const organizations = await this._orgService.getOrgsByUserId(account.id); | ||
| for (const org of organizations) { | ||
| const role = org.users?.[0]?.role; | ||
| if ( | ||
| role === 'SUPERADMIN' && | ||
| org.paymentId?.startsWith('cus_') && | ||
| !emailByCustomer.has(org.paymentId) | ||
| ) { | ||
| emailByCustomer.set(org.paymentId, account.email); | ||
| } | ||
| } | ||
| } | ||
| await Promise.all( | ||
| [...emailByCustomer].map(([customerId, email]) => | ||
| this._stripeService.updateCustomerEmail(customerId, email) | ||
| ) | ||
| ); | ||
| } | ||
|
|
||
| @Post('/personal') | ||
| async changePersonal( | ||
| @GetUserFromRequest() user: User, | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Bug: When an admin is impersonating a user, the 'Switch User' search incorrectly includes the admin's own account, allowing them to swap credentials with the impersonated user.
Severity: HIGH
Suggested Fix
The original admin's user ID needs to be tracked throughout the impersonation session. The auth middleware should attach the original admin's user object to the request, for example as
req.originalUser. This ID should then be passed togetImpersonateUserto ensure the admin's own account is excluded from the search results, preventing the switch.Prompt for AI Agent
Also affects:
apps/backend/src/api/routes/users.controller.ts:204-270Did we get this right? 👍 / 👎 to inform future reviews.