From eb604f19120d0ccdca86cd15df1a8ed7d6a6bb8c Mon Sep 17 00:00:00 2001 From: Gilad Resisi Date: Wed, 1 Jul 2026 17:51:07 +0700 Subject: [PATCH 1/4] fix(admin): don't hit Stripe for admin-granted subscriptions Admin-granted subscriptions store the user id in Organization.paymentId (not a real `cus_...` customer), so every billing path that fed paymentId to Stripe threw "No such customer". Guard them all to skip Stripe when there is no real `cus_` customer: - getCharges returns no charges for any non-`cus_` paymentId. - prorate returns a zero price (it fires on billing-page load per plan card, which was the main source of error logs). - checkDiscount / applyDiscount return false (no coupon to offer/apply). - finishTrial returns early (nothing to finish). - /billing/portal, /billing/cancel and /billing/cancel-subscription reject with a clean 400 instead of a Stripe error (the UI also hides their buttons). Also on the display side: /self exposes `hasStripeCustomer` (paymentId starts with `cus_`), and the billing page only renders the "Update Payment Method / Invoices History" button (which opens the Stripe billing portal) when it is true. Admin-granted and never-subscribed orgs have no real customer, so the button is hidden and the portal is never reached. Validated by impersonating an admin-granted account (paymentId = user id): the billing page now loads with no Stripe error logs (previously flooded by the per-plan prorate call), and the Update Payment Method button is hidden. A real `cus_` subscriber still sees the button and the portal opens normally. Co-Authored-By: Claude Opus 4.8 --- .../src/api/routes/billing.controller.ts | 22 +++++++++++++++ .../src/api/routes/users.controller.ts | 5 ++++ .../billing/main.billing.component.tsx | 14 +++++----- .../src/services/stripe.service.ts | 27 ++++++++++++++++++- 4 files changed, 61 insertions(+), 7 deletions(-) diff --git a/apps/backend/src/api/routes/billing.controller.ts b/apps/backend/src/api/routes/billing.controller.ts index dd6686b158..98a2b378df 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,6 +181,15 @@ 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); } diff --git a/apps/backend/src/api/routes/users.controller.ts b/apps/backend/src/api/routes/users.controller.ts index 98e888d72c..623604aaf3 100644 --- a/apps/backend/src/api/routes/users.controller.ts +++ b/apps/backend/src/api/routes/users.controller.ts @@ -128,6 +128,11 @@ export class UsersController { ? 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 diff --git a/apps/frontend/src/components/billing/main.billing.component.tsx b/apps/frontend/src/components/billing/main.billing.component.tsx index 1d8d05b6bd..d889895443 100644 --- a/apps/frontend/src/components/billing/main.billing.component.tsx +++ b/apps/frontend/src/components/billing/main.billing.component.tsx @@ -539,12 +539,14 @@ export const MainBillingComponent: FC<{ {!!subscription?.id && (
- + {!!(user as any)?.hasStripeCustomer && ( + + )} {isGeneral && !subscription?.cancelAt && ( )} - {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..97ce915342 100644 --- a/apps/frontend/src/components/layout/impersonate.tsx +++ b/apps/frontend/src/components/layout/impersonate.tsx @@ -40,6 +40,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 +226,15 @@ const ChargesModal: FC<{ close: () => void }> = ({ close }) => { {t('refund_selected', 'Refund Selected')} {selected.size > 0 && ` (${selected.size})`} - + {!(user as any)?.adminGrantedSubscription && ( + + )} ); @@ -304,6 +307,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' }, @@ -540,6 +576,9 @@ export const Impersonate = () => { {user?.tier?.current === 'FREE' && } + {(user as any)?.adminGrantedSubscription && ( + + )} {billingEnabled && } ) : ( diff --git a/libraries/nestjs-libraries/src/database/prisma/subscriptions/subscription.repository.ts b/libraries/nestjs-libraries/src/database/prisma/subscriptions/subscription.repository.ts index afaa9b09a4..6a9738147d 100644 --- a/libraries/nestjs-libraries/src/database/prisma/subscriptions/subscription.repository.ts +++ b/libraries/nestjs-libraries/src/database/prisma/subscriptions/subscription.repository.ts @@ -109,6 +109,17 @@ export class SubscriptionRepository { }); } + clearCustomerId(organizationId: string) { + return this._organization.model.organization.update({ + where: { + id: organizationId, + }, + data: { + paymentId: null, + }, + }); + } + async getSubscriptionByOrgId(orgId: string) { return this._subscription.model.subscription.findFirst({ where: { diff --git a/libraries/nestjs-libraries/src/database/prisma/subscriptions/subscription.service.ts b/libraries/nestjs-libraries/src/database/prisma/subscriptions/subscription.service.ts index f607e6d134..359a841658 100644 --- a/libraries/nestjs-libraries/src/database/prisma/subscriptions/subscription.service.ts +++ b/libraries/nestjs-libraries/src/database/prisma/subscriptions/subscription.service.ts @@ -51,6 +51,10 @@ export class SubscriptionService { ); } + clearCustomerId(organizationId: string) { + return this._subscriptionRepository.clearCustomerId(organizationId); + } + async checkSubscription(organizationId: string, subscriptionId: string) { return await this._subscriptionRepository.checkSubscription( organizationId, diff --git a/libraries/nestjs-libraries/src/services/stripe.service.ts b/libraries/nestjs-libraries/src/services/stripe.service.ts index 065f6433b9..da0da928e8 100644 --- a/libraries/nestjs-libraries/src/services/stripe.service.ts +++ b/libraries/nestjs-libraries/src/services/stripe.service.ts @@ -939,6 +939,29 @@ export class StripeService { return { refunded, failed }; } + // Remove an admin-granted subscription. These store the user id in + // `paymentId` (not a real `cus_...` customer), so there is nothing to cancel + // on Stripe — we only downgrade to FREE and drop the local subscription row. + // A real Stripe subscription must go through `cancelSubscription` instead. + async removeAdminGrantedSubscription(organizationId: string) { + const org = await this._organizationService.getOrgById(organizationId); + if (!org?.paymentId) { + throw new Error('No subscription found for this organization'); + } + if (org.paymentId.startsWith('cus_')) { + throw new Error( + 'This organization has a real Stripe customer; use cancel-subscription instead' + ); + } + + // deleteSubscription locates everything by paymentId, so clear the fake + // customer id only after it runs. Leaving it would make later Stripe flows + // (checkout, discount checks) call Stripe with a non-existent customer. + await this._subscriptionService.deleteSubscription(org.paymentId); + await this._subscriptionService.clearCustomerId(org.id); + return { removed: true }; + } + async cancelSubscription(organizationId: string) { const org = await this._organizationService.getOrgById(organizationId); if (!org?.paymentId) { From 20843fc03f8b2af8bb7d097ee145dd325f0d7c5e Mon Sep 17 00:00:00 2001 From: Gilad Resisi Date: Wed, 1 Jul 2026 16:39:02 +0700 Subject: [PATCH 4/4] =?UTF-8?q?feat:=20admin=20"Switch=20User"=20=E2=80=94?= =?UTF-8?q?=20swap=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. - Awareness: both accounts get an email stating the login changed and that the subscription was NOT changed (cancel separately if intended). Any side left on an unverified email is sent a fresh activation link. 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. The org-scoped side effects are guarded so they don't spill onto co-members: (a) the in-app "your login changed" notice is posted only for orgs where the switched user is the sole member — a shared org's other members must not see it — while the email always reaches the switched login; (b) the Stripe billing-email sync runs owner-only (SUPERADMIN) so a non-owner's switch can't rewrite a shared org's receipts address; and (c) that sync is de-duplicated per customer so two co-owners of the same org don't race two concurrent updates. - 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 (including owner/admin roles in shared orgs) while those organizations and their members are unchanged. The impersonated user is excluded from the picker, results are de-duplicated by user id, and name-less users no longer render "null". 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. Co-Authored-By: Claude Opus 4.8 --- .../src/api/routes/users.controller.ts | 157 +++++++++++++++- .../src/components/layout/impersonate.tsx | 176 +++++++++++++++++- .../src/components/layout/new-modal.tsx | 4 +- .../database/prisma/users/users.repository.ts | 94 +++++++++- .../database/prisma/users/users.service.ts | 7 + .../src/services/stripe.service.ts | 11 ++ 6 files changed, 441 insertions(+), 8 deletions(-) diff --git a/apps/backend/src/api/routes/users.controller.ts b/apps/backend/src/api/routes/users.controller.ts index 571dfa83fd..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, @@ -195,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/layout/impersonate.tsx b/apps/frontend/src/components/layout/impersonate.tsx index 97ce915342..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'; @@ -499,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(''); @@ -560,11 +723,15 @@ export const Impersonate = () => { return (
-
+
{user?.impersonate ? ( -
-
+
+
{t('currently_impersonating', 'Currently Impersonating')}
@@ -580,6 +747,7 @@ export const Impersonate = () => { )} {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}