Skip to content

Commit 7950a92

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

File tree

8 files changed

+146
-67
lines changed

8 files changed

+146
-67
lines changed

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: 28 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ export function PlanInfoCardUI(props: {
3030
getTeam: () => Promise<Team>;
3131
openPlanSheetButtonByDefault: boolean;
3232
highlightPlan: Team["billingPlan"] | undefined;
33+
isOwnerAccount: boolean;
3334
}) {
3435
const { subscriptions, team, openPlanSheetButtonByDefault } = props;
3536
const validPlan = getValidTeamPlan(team);
@@ -110,7 +111,7 @@ export function PlanInfoCardUI(props: {
110111
)}
111112
</div>
112113

113-
{props.team.billingPlan !== "free" && (
114+
{props.team.billingPlan !== "free" && props.isOwnerAccount && (
114115
<div className="flex items-center gap-3">
115116
<Button
116117
variant="outline"
@@ -153,17 +154,19 @@ export function PlanInfoCardUI(props: {
153154
To unlock additional usage, upgrade your plan to Starter or
154155
Growth.
155156
</p>
156-
<div className="mt-4">
157-
<Button
158-
variant="default"
159-
size="sm"
160-
onClick={() => {
161-
setIsPlanSheetOpen(true);
162-
}}
163-
>
164-
Select a plan
165-
</Button>
166-
</div>
157+
{props.isOwnerAccount && (
158+
<div className="mt-4">
159+
<Button
160+
variant="default"
161+
size="sm"
162+
onClick={() => {
163+
setIsPlanSheetOpen(true);
164+
}}
165+
>
166+
Select a plan
167+
</Button>
168+
</div>
169+
)}
167170
</div>
168171
) : (
169172
<BillingInfo subscriptions={subscriptions} />
@@ -203,17 +206,19 @@ export function PlanInfoCardUI(props: {
203206
</Button>
204207

205208
{/* manage team billing */}
206-
<BillingPortalButton
207-
teamSlug={team.slug}
208-
buttonProps={{
209-
variant: "outline",
210-
size: "sm",
211-
className: "bg-background gap-2",
212-
}}
213-
>
214-
<CreditCardIcon className="size-4 text-muted-foreground" />
215-
Manage Billing
216-
</BillingPortalButton>
209+
{props.isOwnerAccount && (
210+
<BillingPortalButton
211+
teamSlug={team.slug}
212+
buttonProps={{
213+
variant: "outline",
214+
size: "sm",
215+
className: "bg-background gap-2",
216+
}}
217+
>
218+
<CreditCardIcon className="size-4 text-muted-foreground" />
219+
Manage Billing
220+
</BillingPortalButton>
221+
)}
217222
</div>
218223
</div>
219224
)}

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
@@ -13,9 +13,9 @@ import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
1313
import { Separator } from "@/components/ui/separator";
1414
import { Skeleton } from "@/components/ui/skeleton";
1515
import { ArrowRightIcon, DollarSignIcon } from "lucide-react";
16-
import Link from "next/link";
1716
import { Suspense, use, useState } from "react";
1817
import { ErrorBoundary } from "react-error-boundary";
18+
import { ToolTipLabel } from "../../../../../../../../../@/components/ui/tooltip";
1919
import { ThirdwebMiniLogo } from "../../../../../../../components/ThirdwebMiniLogo";
2020

2121
const predefinedAmounts = [
@@ -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

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

Lines changed: 52 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,10 @@ import {
1818
DownloadIcon,
1919
ReceiptIcon,
2020
} from "lucide-react";
21-
import Link from "next/link";
2221
import { useQueryState } from "nuqs";
2322
import { useTransition } from "react";
2423
import type Stripe from "stripe";
24+
import { ToolTipLabel } from "../../../../../../../../../@/components/ui/tooltip";
2525
import { ThirdwebMiniLogo } from "../../../../../../../components/ThirdwebMiniLogo";
2626
import { searchParams } from "../search-params";
2727

@@ -30,6 +30,7 @@ export function BillingHistory(props: {
3030
invoices: Stripe.Invoice[];
3131
status: "all" | "past_due" | "open";
3232
hasMore: boolean;
33+
isOwnerAccount: boolean;
3334
}) {
3435
const [isLoading, startTransition] = useTransition();
3536
const [cursor, setCursor] = useQueryState(
@@ -128,27 +129,58 @@ export function BillingHistory(props: {
128129
{invoice.status === "open" && (
129130
<>
130131
{/* always show the crypto payment button */}
131-
<Button variant="default" size="sm" asChild>
132-
<Link
133-
target="_blank"
134-
href={`/checkout/${props.teamSlug}/invoice?invoice_id=${invoice.id}`}
135-
>
136-
<ThirdwebMiniLogo className="mr-2 h-4 w-4" />
137-
Pay with crypto
138-
</Link>
139-
</Button>
132+
<ToolTipLabel
133+
label={
134+
props.isOwnerAccount
135+
? null
136+
: "Only team owners can pay invoices."
137+
}
138+
>
139+
<div>
140+
<Button
141+
variant="default"
142+
size="sm"
143+
asChild
144+
disabled={!props.isOwnerAccount}
145+
>
146+
<a
147+
target="_blank"
148+
href={`/checkout/${props.teamSlug}/invoice?invoice_id=${invoice.id}`}
149+
rel="noopener noreferrer"
150+
>
151+
<ThirdwebMiniLogo className="mr-2 h-4 w-4" />
152+
Pay with crypto
153+
</a>
154+
</Button>
155+
</div>
156+
</ToolTipLabel>
140157
{/* if we have a hosted invoice url, show that */}
141158
{invoice.hosted_invoice_url && (
142-
<Button variant="outline" size="sm" asChild>
143-
<a
144-
href={invoice.hosted_invoice_url}
145-
target="_blank"
146-
rel="noopener noreferrer"
147-
>
148-
<CreditCardIcon className="mr-2 h-4 w-4" />
149-
Pay with Card
150-
</a>
151-
</Button>
159+
<ToolTipLabel
160+
label={
161+
props.isOwnerAccount
162+
? null
163+
: "Only team owners can pay invoices."
164+
}
165+
>
166+
<div>
167+
<Button
168+
variant="outline"
169+
size="sm"
170+
asChild
171+
disabled={!props.isOwnerAccount}
172+
>
173+
<a
174+
href={invoice.hosted_invoice_url}
175+
target="_blank"
176+
rel="noopener noreferrer"
177+
>
178+
<CreditCardIcon className="mr-2 h-4 w-4" />
179+
Pay with Card
180+
</a>
181+
</Button>
182+
</div>
183+
</ToolTipLabel>
152184
)}
153185
</>
154186
)}

0 commit comments

Comments
 (0)