Skip to content

Commit 53e5947

Browse files
committed
fix(webapp): disable seat purchase without manage:billing
The team page's seat purchase button now disables itself with an explanatory tooltip when the current role can't manage billing, matching the server-side check the action already enforces.
1 parent af7097f commit 53e5947

1 file changed

Lines changed: 32 additions & 10 deletions

File tree

  • apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.team

apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.team/route.tsx

Lines changed: 32 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import {
1111
} from "@remix-run/react";
1212
import { json } from "@remix-run/server-runtime";
1313
import { tryCatch } from "@trigger.dev/core/utils";
14-
import { useEffect, useRef, useState } from "react";
14+
import { cloneElement, useEffect, useRef, useState } from "react";
1515
import { type UseDataFunctionReturn, typedjson, useTypedLoaderData } from "remix-typedjson";
1616
import invariant from "tiny-invariant";
1717
import { z } from "zod";
@@ -30,6 +30,7 @@ import {
3030
AlertTrigger,
3131
} from "~/components/primitives/Alert";
3232
import { Button, ButtonContent, LinkButton } from "~/components/primitives/Buttons";
33+
import { PermissionButton } from "~/components/primitives/PermissionButton";
3334
import { DateTime } from "~/components/primitives/DateTime";
3435
import { Dialog, DialogContent, DialogHeader, DialogTrigger } from "~/components/primitives/Dialog";
3536
import { Fieldset } from "~/components/primitives/Fieldset";
@@ -119,10 +120,12 @@ export const loader = dashboardLoader(
119120
}
120121

121122
// Pre-compute manage authority server-side so the UI gating matches
122-
// the action gating (the action enforces it independently).
123+
// the action gating (the action enforces it independently). Seat
124+
// purchases are a billing operation, so they gate on manage:billing.
123125
const canManageMembers = ability.can("manage", { type: "members" });
126+
const canManageBilling = ability.can("manage", { type: "billing" });
124127

125-
return typedjson({ ...result, canManageMembers });
128+
return typedjson({ ...result, canManageMembers, canManageBilling });
126129
}
127130
);
128131

@@ -318,6 +321,7 @@ export default function Page() {
318321
assignableRoleIds,
319322
memberRoles,
320323
canManageMembers,
324+
canManageBilling,
321325
} = useTypedLoaderData<typeof loader>();
322326
// Build a userId → roleId map so the dropdown's defaultValue matches
323327
// each member's current assignment without re-querying.
@@ -532,6 +536,7 @@ export default function Page() {
532536
usedSeats={limits.used}
533537
maxQuota={maxSeatQuota}
534538
planSeatLimit={planSeatLimit}
539+
canManageBilling={canManageBilling}
535540
/>
536541
) : canUpgrade ? (
537542
showSelfServe ? (
@@ -864,6 +869,7 @@ export function PurchaseSeatsModal({
864869
maxQuota,
865870
planSeatLimit,
866871
triggerButton,
872+
canManageBilling = true,
867873
}: {
868874
seatPricing: {
869875
stepSize: number;
@@ -874,6 +880,7 @@ export function PurchaseSeatsModal({
874880
maxQuota: number;
875881
planSeatLimit: number;
876882
triggerButton?: React.ReactElement;
883+
canManageBilling?: boolean;
877884
}) {
878885
const showSelfServe = useShowSelfServe();
879886
const fetcher = useFetcher();
@@ -933,15 +940,30 @@ export function PurchaseSeatsModal({
933940
);
934941
}
935942

943+
// Buying seats is a billing action — disable the trigger (and explain why)
944+
// when the role can't manage billing. The action enforces it independently.
945+
const noBillingTooltip = "You don't have permission to manage billing";
946+
const trigger = canManageBilling ? (
947+
triggerButton ?? (
948+
<Button variant="primary/small" onClick={() => setOpen(true)}>
949+
{title}
950+
</Button>
951+
)
952+
) : triggerButton ? (
953+
cloneElement(triggerButton, { disabled: true, tooltip: noBillingTooltip })
954+
) : (
955+
<PermissionButton
956+
variant="primary/small"
957+
hasPermission={false}
958+
noPermissionTooltip={noBillingTooltip}
959+
>
960+
{title}
961+
</PermissionButton>
962+
);
963+
936964
return (
937965
<Dialog open={open} onOpenChange={setOpen}>
938-
<DialogTrigger asChild>
939-
{triggerButton ?? (
940-
<Button variant="primary/small" onClick={() => setOpen(true)}>
941-
{title}
942-
</Button>
943-
)}
944-
</DialogTrigger>
966+
<DialogTrigger asChild>{trigger}</DialogTrigger>
945967
<DialogContent>
946968
<DialogHeader>{title}</DialogHeader>
947969
<fetcher.Form method="post" action={organizationTeamPath(organization)} {...form.props}>

0 commit comments

Comments
 (0)