Skip to content

Commit 61178d9

Browse files
committed
[Dashboard] Restrict billing actions to team owners only
1 parent b25d2af commit 61178d9

File tree

11 files changed

+166
-67
lines changed

11 files changed

+166
-67
lines changed

apps/dashboard/src/@/components/billing.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -84,12 +84,13 @@ export function BillingPortalButton(props: {
8484
props.buttonProps?.onClick?.(e);
8585
}}
8686
>
87-
<Link
87+
<a
8888
href={buildBillingPortalUrl({ teamSlug: props.teamSlug })}
8989
target="_blank"
90+
rel="noreferrer"
9091
>
9192
{props.children}
92-
</Link>
93+
</a>
9394
</Button>
9495
);
9596
}

apps/dashboard/src/@/components/ui/button.tsx

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -45,19 +45,27 @@ export interface ButtonProps
4545
}
4646

4747
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
48-
({ className, variant, size, asChild = false, ...props }, ref) => {
48+
({ className, variant, size, asChild = false, disabled, ...props }, ref) => {
4949
const Comp = asChild ? Slot : "button";
50-
const btnOnlyProps =
51-
Comp === "button"
52-
? { type: props.type || ("button" as const) }
53-
: undefined;
50+
51+
// "button" elements automatically handle the `disabled` attribute.
52+
// For non-button elements rendered via `asChild` (e.g. <a>), we still want
53+
// to visually convey the disabled state and prevent user interaction.
54+
// We do that by conditionally adding the same utility classes that the
55+
// `disabled:` pseudo-variant would normally apply and by setting
56+
// `aria-disabled` for accessibility.
57+
const disabledClass = disabled ? "pointer-events-none opacity-50" : "";
5458

5559
return (
5660
<Comp
57-
className={cn(buttonVariants({ variant, size, className }))}
61+
className={cn(
62+
buttonVariants({ variant, size, className }),
63+
disabledClass,
64+
)}
5865
ref={ref}
66+
aria-disabled={disabled ? true : undefined}
67+
disabled={disabled}
5968
{...props}
60-
{...btnOnlyProps}
6169
/>
6270
);
6371
},

apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/settings/billing/components/PlanInfoCard.client.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,14 @@ export function PlanInfoCardClient(props: {
99
team: Team;
1010
openPlanSheetButtonByDefault: boolean;
1111
highlightPlan: Team["billingPlan"] | undefined;
12+
isOwnerAccount: boolean;
1213
}) {
1314
return (
1415
<PlanInfoCardUI
1516
openPlanSheetButtonByDefault={props.openPlanSheetButtonByDefault}
1617
team={props.team}
1718
subscriptions={props.subscriptions}
19+
isOwnerAccount={props.isOwnerAccount}
1820
getTeam={async () => {
1921
const res = await apiServerProxy<{
2022
result: Team;

apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/settings/billing/components/PlanInfoCard.stories.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,7 @@ function Story(props: {
120120
getTeam={teamTeamStub}
121121
highlightPlan={undefined}
122122
openPlanSheetButtonByDefault={false}
123+
isOwnerAccount={true}
123124
/>
124125
</BadgeContainer>
125126

@@ -133,6 +134,7 @@ function Story(props: {
133134
getTeam={teamTeamStub}
134135
highlightPlan={undefined}
135136
openPlanSheetButtonByDefault={false}
137+
isOwnerAccount={true}
136138
/>
137139
</BadgeContainer>
138140

@@ -143,6 +145,7 @@ function Story(props: {
143145
getTeam={teamTeamStub}
144146
highlightPlan={undefined}
145147
openPlanSheetButtonByDefault={false}
148+
isOwnerAccount={true}
146149
/>
147150
</BadgeContainer>
148151

@@ -153,6 +156,7 @@ function Story(props: {
153156
getTeam={teamTeamStub}
154157
highlightPlan={undefined}
155158
openPlanSheetButtonByDefault={false}
159+
isOwnerAccount={true}
156160
/>
157161
</BadgeContainer>
158162

@@ -163,6 +167,7 @@ function Story(props: {
163167
getTeam={teamTeamStub}
164168
highlightPlan={undefined}
165169
openPlanSheetButtonByDefault={false}
170+
isOwnerAccount={true}
166171
/>
167172
</BadgeContainer>
168173
</div>

apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/settings/billing/components/PlanInfoCard.tsx

Lines changed: 43 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
SheetHeader,
1414
SheetTitle,
1515
} from "@/components/ui/sheet";
16+
import { ToolTipLabel } from "@/components/ui/tooltip";
1617
import { CancelPlanButton } from "components/settings/Account/Billing/CancelPlanModal/CancelPlanModal";
1718
import { BillingPricing } from "components/settings/Account/Billing/Pricing";
1819
import { differenceInDays, isAfter } from "date-fns";
@@ -30,6 +31,7 @@ export function PlanInfoCardUI(props: {
3031
getTeam: () => Promise<Team>;
3132
openPlanSheetButtonByDefault: boolean;
3233
highlightPlan: Team["billingPlan"] | undefined;
34+
isOwnerAccount: boolean;
3335
}) {
3436
const { subscriptions, team, openPlanSheetButtonByDefault } = props;
3537
const validPlan = getValidTeamPlan(team);
@@ -110,7 +112,7 @@ export function PlanInfoCardUI(props: {
110112
)}
111113
</div>
112114

113-
{props.team.billingPlan !== "free" && (
115+
{props.team.billingPlan !== "free" && props.isOwnerAccount && (
114116
<div className="flex items-center gap-3">
115117
<Button
116118
variant="outline"
@@ -153,16 +155,28 @@ export function PlanInfoCardUI(props: {
153155
To unlock additional usage, upgrade your plan to Starter or
154156
Growth.
155157
</p>
158+
156159
<div className="mt-4">
157-
<Button
158-
variant="default"
159-
size="sm"
160-
onClick={() => {
161-
setIsPlanSheetOpen(true);
162-
}}
160+
<ToolTipLabel
161+
label={
162+
props.isOwnerAccount
163+
? null
164+
: "Only team owners can change plans."
165+
}
163166
>
164-
Select a plan
165-
</Button>
167+
<div>
168+
<Button
169+
disabled={!props.isOwnerAccount}
170+
variant="default"
171+
size="sm"
172+
onClick={() => {
173+
setIsPlanSheetOpen(true);
174+
}}
175+
>
176+
Select a plan
177+
</Button>
178+
</div>
179+
</ToolTipLabel>
166180
</div>
167181
</div>
168182
) : (
@@ -203,17 +217,27 @@ export function PlanInfoCardUI(props: {
203217
</Button>
204218

205219
{/* manage team billing */}
206-
<BillingPortalButton
207-
teamSlug={team.slug}
208-
buttonProps={{
209-
variant: "outline",
210-
size: "sm",
211-
className: "bg-background gap-2",
212-
}}
220+
<ToolTipLabel
221+
label={
222+
props.isOwnerAccount
223+
? null
224+
: "Only team owners can manage billing."
225+
}
213226
>
214-
<CreditCardIcon className="size-4 text-muted-foreground" />
215-
Manage Billing
216-
</BillingPortalButton>
227+
<div>
228+
<BillingPortalButton
229+
teamSlug={team.slug}
230+
buttonProps={{
231+
variant: "outline",
232+
size: "sm",
233+
className: "bg-background gap-2",
234+
}}
235+
>
236+
<CreditCardIcon className="size-4 text-muted-foreground" />
237+
Manage Billing
238+
</BillingPortalButton>
239+
</div>
240+
</ToolTipLabel>
217241
</div>
218242
</div>
219243
)}

apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/settings/billing/components/credit-balance-section.client.tsx

Lines changed: 27 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,8 @@ import { Label } from "@/components/ui/label";
1212
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
1313
import { Separator } from "@/components/ui/separator";
1414
import { Skeleton } from "@/components/ui/skeleton";
15+
import { ToolTipLabel } from "@/components/ui/tooltip";
1516
import { ArrowRightIcon, DollarSignIcon } from "lucide-react";
16-
import Link from "next/link";
1717
import { Suspense, use, useState } from "react";
1818
import { ErrorBoundary } from "react-error-boundary";
1919
import { ThirdwebMiniLogo } from "../../../../../../../components/ThirdwebMiniLogo";
@@ -28,11 +28,13 @@ const predefinedAmounts = [
2828
interface CreditBalanceSectionProps {
2929
balancePromise: Promise<number>;
3030
teamSlug: string;
31+
isOwnerAccount: boolean;
3132
}
3233

3334
export function CreditBalanceSection({
3435
balancePromise,
3536
teamSlug,
37+
isOwnerAccount,
3638
}: CreditBalanceSectionProps) {
3739
const [selectedAmount, setSelectedAmount] = useState<string>(
3840
predefinedAmounts[0].value,
@@ -114,17 +116,30 @@ export function CreditBalanceSection({
114116
</Suspense>
115117
</ErrorBoundary>
116118

117-
<Button asChild className="w-full" size="lg">
118-
<Link
119-
href={`/checkout/${teamSlug}/topup?amount=${selectedAmount}`}
120-
prefetch={false}
121-
target="_blank"
122-
>
123-
<ThirdwebMiniLogo className="mr-2 h-4 w-4" />
124-
Top Up With Crypto
125-
<ArrowRightIcon className="ml-2 h-4 w-4" />
126-
</Link>
127-
</Button>
119+
<ToolTipLabel
120+
label={
121+
isOwnerAccount ? null : "Only team owners can top up credits."
122+
}
123+
>
124+
<div>
125+
<Button
126+
asChild
127+
className="w-full"
128+
size="lg"
129+
disabled={!isOwnerAccount}
130+
>
131+
<a
132+
href={`/checkout/${teamSlug}/topup?amount=${selectedAmount}`}
133+
target="_blank"
134+
rel="noopener noreferrer"
135+
>
136+
<ThirdwebMiniLogo className="mr-2 h-4 w-4" />
137+
Top Up With Crypto
138+
<ArrowRightIcon className="ml-2 h-4 w-4" />
139+
</a>
140+
</Button>
141+
</div>
142+
</ToolTipLabel>
128143
</div>
129144
</div>
130145
</CardContent>

apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/settings/billing/page.tsx

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { getStripeBalance } from "@/actions/stripe-actions";
22
import { type Team, getTeamBySlug } from "@/api/team";
3+
import { getMemberById } from "@/api/team-members";
34
import { getTeamSubscriptions } from "@/api/team-subscription";
45
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
56
import { redirect } from "next/navigation";
@@ -25,12 +26,16 @@ export default async function Page(props: {
2526
]);
2627
const pagePath = `/team/${params.team_slug}/settings/billing`;
2728

28-
const [account, team, authToken] = await Promise.all([
29-
getValidAccount(pagePath),
29+
const account = await getValidAccount(pagePath);
30+
31+
const [team, authToken, teamMember] = await Promise.all([
3032
getTeamBySlug(params.team_slug),
3133
getAuthToken(),
34+
getMemberById(params.team_slug, account.id),
3235
]);
3336

37+
const isOwnerAccount = teamMember?.role === "OWNER";
38+
3439
if (!team) {
3540
redirect("/team");
3641
}
@@ -66,6 +71,7 @@ export default async function Page(props: {
6671
subscriptions={subscriptions}
6772
openPlanSheetButtonByDefault={searchParams.showPlans === "true"}
6873
highlightPlan={highlightPlan}
74+
isOwnerAccount={isOwnerAccount}
6975
/>
7076
</div>
7177

@@ -74,6 +80,7 @@ export default async function Page(props: {
7480
<CreditBalanceSection
7581
teamSlug={team.slug}
7682
balancePromise={getStripeBalance(team.stripeCustomerId)}
83+
isOwnerAccount={isOwnerAccount}
7784
/>
7885
)}
7986

0 commit comments

Comments
 (0)