diff --git a/apps/backend/src/api/routes/billing.controller.ts b/apps/backend/src/api/routes/billing.controller.ts index dd6686b158..39d06be618 100644 --- a/apps/backend/src/api/routes/billing.controller.ts +++ b/apps/backend/src/api/routes/billing.controller.ts @@ -96,6 +96,12 @@ export class BillingController { @Get('/portal') async modifyPayment(@GetOrgFromRequest() org: Organization) { + // Admin-granted/never-subscribed orgs have no real `cus_...` customer, so + // there is no Stripe billing portal to open (the UI hides the button; this + // guards the endpoint against a "No such customer" Stripe error). + if (!org.paymentId?.startsWith('cus_')) { + throw new HttpException('No Stripe customer for this organization', 400); + } const customer = await this._stripeService.getCustomerByOrganizationId( org.id ); @@ -116,6 +122,13 @@ export class BillingController { @GetUserFromRequest() user: User, @Body() body: { feedback: string } ) { + // An admin-granted subscription has no Stripe subscription to cancel (its + // paymentId is the user id, not a `cus_...` customer). The UI hides the + // self-service cancel button for these; removal is an admin-only action. + if (!org.paymentId?.startsWith('cus_')) { + throw new HttpException('No Stripe subscription to cancel', 400); + } + await this._notificationService.sendEmail( process.env.EMAIL_FROM_ADDRESS, 'Subscription Cancelled', @@ -168,9 +181,30 @@ export class BillingController { throw new HttpException('Unauthorized', 400); } + // Admin-granted subscriptions have no Stripe subscription to cancel; the + // admin panel hides this and offers "Remove Subscription" instead. + if (!org.paymentId?.startsWith('cus_')) { + throw new HttpException( + 'Admin-granted subscription; use remove-subscription instead', + 400 + ); + } + return this._stripeService.cancelSubscription(org.id); } + @Post('/remove-subscription') + async removeSubscription( + @GetUserFromRequest() user: User, + @GetOrgFromRequest() org: Organization + ) { + if (!user.isSuperAdmin) { + throw new HttpException('Unauthorized', 400); + } + + return this._stripeService.removeAdminGrantedSubscription(org.id); + } + @Get('/chatbase-refund/preview') chatbaseRefundPreview(@GetOrgFromRequest() org: Organization) { return this._stripeService.chatbaseRefundPreview(org.id); diff --git a/apps/backend/src/api/routes/users.controller.ts b/apps/backend/src/api/routes/users.controller.ts index 98e888d72c..29421918ac 100644 --- a/apps/backend/src/api/routes/users.controller.ts +++ b/apps/backend/src/api/routes/users.controller.ts @@ -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); + } + + // `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(); + 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, diff --git a/apps/frontend/src/components/billing/main.billing.component.tsx b/apps/frontend/src/components/billing/main.billing.component.tsx index 1d8d05b6bd..e3572a506e 100644 --- a/apps/frontend/src/components/billing/main.billing.component.tsx +++ b/apps/frontend/src/components/billing/main.billing.component.tsx @@ -539,21 +539,25 @@ export const MainBillingComponent: FC<{ {!!subscription?.id && (
- - {isGeneral && !subscription?.cancelAt && ( - )} + {isGeneral && + !subscription?.cancelAt && + !(user as any)?.adminGrantedSubscription && ( + + )}
)} {subscription?.cancelAt && isGeneral && ( diff --git a/apps/frontend/src/components/layout/impersonate.tsx b/apps/frontend/src/components/layout/impersonate.tsx index 6f907640d0..27982e19bf 100644 --- a/apps/frontend/src/components/layout/impersonate.tsx +++ b/apps/frontend/src/components/layout/impersonate.tsx @@ -9,7 +9,10 @@ import { deleteDialog } from '@gitroom/react/helpers/delete.dialog'; 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 { + areYouSure, + useModals, +} from '@gitroom/frontend/components/layout/new-modal'; import { Button } from '@gitroom/react/form/button'; import { ImportDebugPostModal } from '@gitroom/frontend/components/launches/import-debug-post.modal'; @@ -40,6 +43,7 @@ const useCharges = () => { const ChargesModal: FC<{ close: () => void }> = ({ close }) => { const fetch = useFetch(); const t = useT(); + const user = useUser(); const { data: charges, mutate } = useCharges(); const [selected, setSelected] = useState>(new Set()); const [refunding, setRefunding] = useState(false); @@ -225,13 +229,15 @@ const ChargesModal: FC<{ close: () => void }> = ({ close }) => { {t('refund_selected', 'Refund Selected')} {selected.size > 0 && ` (${selected.size})`} - + {!(user as any)?.adminGrantedSubscription && ( + + )} ); @@ -304,6 +310,39 @@ export const Subscription = () => { ); }; + +// Shown only for admin-granted subscriptions (see `adminGrantedSubscription` on +// /self). Removing downgrades the account to FREE without touching Stripe, so a +// real paid subscription can never be removed here. +const RemoveSubscription = () => { + const fetch = useFetch(); + const t = useT(); + const remove = useCallback(async () => { + if ( + !(await deleteDialog( + t( + 'remove_admin_subscription_confirm', + 'Remove this admin-granted subscription? The account will return to the FREE plan.' + ), + t('yes_remove', 'Yes, remove'), + t('remove_subscription_title', 'Remove Subscription?'), + t('no_cancel', 'No, cancel') + )) + ) { + return; + } + await fetch('/billing/remove-subscription', { method: 'POST' }); + window.location.reload(); + }, []); + return ( +
+ {t('remove_subscription', 'Remove Subscription')} +
+ ); +}; const colorOptions = [ { value: 'INFO', label: 'Info (Blue)', className: 'bg-blue-600' }, { value: 'WARNING', label: 'Warning (Amber)', className: 'bg-amber-600' }, @@ -463,6 +502,166 @@ const ImportDebugPost = () => { ); }; +const SwitchUser = () => { + const fetch = useFetch(); + const t = useT(); + 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(() => { + // The search returns one row per user-organization; key on the real user id, + // drop the user we're currently impersonating, and de-duplicate users that + // belong to more than one organization. + 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}. The current account keeps all of its data and subscription, only the login is switched. To revert, switch back. + +Note: the new login gains this account's full access, including any owner/admin roles in shared organizations. Nothing about those organizations or their members is changed.` + ), + 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, so only reload on success — + // otherwise a failed switch would silently reload and look like it worked. + if (!res.ok) { + throw new Error(await res.text().catch(() => '')); + } + window.location.reload(); + } catch (err: any) { + setSwitching(false); + await areYouSure({ + title: t('switch_user_failed_title', 'Switch failed'), + description: t( + 'switch_user_failed', + `The user switch failed and nothing was changed${ + err?.message ? `: ${err.message}` : '.' + }` + ), + approveLabel: t('ok', 'OK'), + onlyApprove: true, + }); + } + }, [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 +723,15 @@ export const Impersonate = () => { return (
-
+
{user?.impersonate ? ( -
-
+
+
{t('currently_impersonating', 'Currently Impersonating')}
@@ -540,7 +743,11 @@ export const Impersonate = () => {
{user?.tier?.current === 'FREE' && } + {(user as any)?.adminGrantedSubscription && ( + + )} {billingEnabled && } +
) : (
diff --git a/apps/frontend/src/components/layout/new-modal.tsx b/apps/frontend/src/components/layout/new-modal.tsx index 090f43660a..bee193a612 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}