Skip to content

Commit a920d1e

Browse files
committed
[Dashboard] Restrict billing actions to team owners only (#7274)
# Improve Billing UI for Non-Owner Team Members This PR enhances the billing UI to properly handle permissions for team members who aren't owners. It: - Restricts billing actions to team owners only - Adds tooltips explaining why certain actions are disabled - Properly disables buttons for non-owner team members - Improves the Button component to handle disabled state for non-button elements ## Key Changes: - Added `isOwnerAccount` flag to billing components to conditionally render or disable actions - Enhanced Button component to properly handle disabled state for non-button elements (like anchor tags) - Added tooltips to explain why actions are disabled for non-owners - Restricted the following actions to team owners only: - Selecting/changing plans - Managing billing - Topping up credits - Paying invoices These changes ensure a better UX for team members who don't have billing permissions while maintaining full functionality for team owners. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - **New Features** - Added owner-only restrictions for billing and payment actions. Only team owners can change plans, manage billing, top up credits, or pay invoices. Non-owner users now see disabled buttons with tooltips explaining these restrictions. - **Accessibility** - Improved accessibility for disabled buttons by adding appropriate ARIA attributes and visual indicators. - **User Interface** - Updated tooltips and button states to clearly communicate permission-based access to billing features. - Enhanced button behavior to consistently reflect disabled states across different elements and contexts. - **Bug Fixes** - Standardized disabled state handling for buttons and interactive elements, ensuring consistent visual and functional behavior. - **Chores** - Replaced internal navigation links with standard anchor elements for external billing-related links, improving security with added link attributes. <!-- end of auto-generated comment: release notes by coderabbit.ai --> <!-- start pr-codex --> --- ## PR-Codex overview This PR primarily focuses on enhancing user experience by adding an `isOwnerAccount` boolean prop to various components. This prop controls the visibility and functionality of certain actions, ensuring that only team owners can perform specific tasks. ### Detailed summary - Changed `<Link>` to `<a>` in `billing.tsx` for proper link behavior. - Added `isOwnerAccount` prop to multiple components and updated their functionality based on ownership status. - Introduced `ToolTipLabel` to provide contextual information for actions restricted to team owners. - Updated button states to reflect whether actions are enabled or disabled based on `isOwnerAccount`. > ✨ Ask PR-Codex anything about this PR by commenting with `/codex {your question}` <!-- end pr-codex -->
1 parent 2329a22 commit a920d1e

File tree

13 files changed

+234
-90
lines changed

13 files changed

+234
-90
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: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -45,17 +45,35 @@ 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+
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" : "";
58+
5059
const btnOnlyProps =
5160
Comp === "button"
52-
? { type: props.type || ("button" as const) }
61+
? {
62+
type:
63+
(props as React.ButtonHTMLAttributes<HTMLButtonElement>).type ||
64+
("button" as const),
65+
}
5366
: undefined;
5467

5568
return (
5669
<Comp
57-
className={cn(buttonVariants({ variant, size, className }))}
70+
className={cn(
71+
buttonVariants({ variant, size, className }),
72+
disabledClass,
73+
)}
5874
ref={ref}
75+
aria-disabled={disabled ? true : undefined}
76+
disabled={disabled}
5977
{...props}
6078
{...btnOnlyProps}
6179
/>

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: 92 additions & 42 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);
@@ -112,32 +114,57 @@ export function PlanInfoCardUI(props: {
112114

113115
{props.team.billingPlan !== "free" && (
114116
<div className="flex items-center gap-3">
115-
<Button
116-
variant="outline"
117-
size="sm"
118-
className="gap-2 bg-background"
119-
onClick={() => {
120-
setIsPlanSheetOpen(true);
121-
}}
117+
<ToolTipLabel
118+
label={
119+
props.isOwnerAccount
120+
? null
121+
: "Only team owners can change plans."
122+
}
122123
>
123-
<SquarePenIcon className="size-4 text-muted-foreground" />
124-
Change Plan
125-
</Button>
124+
<div>
125+
<Button
126+
variant="outline"
127+
size="sm"
128+
className="gap-2 bg-background"
129+
onClick={() => {
130+
setIsPlanSheetOpen(true);
131+
}}
132+
disabled={!props.isOwnerAccount}
133+
>
134+
<SquarePenIcon className="size-4 text-muted-foreground" />
135+
Change Plan
136+
</Button>
137+
</div>
138+
</ToolTipLabel>
126139

127-
{props.team.planCancellationDate ? (
128-
<RenewSubscriptionButton
129-
teamId={props.team.id}
130-
getTeam={props.getTeam}
131-
/>
132-
) : (
133-
<CancelPlanButton
134-
teamId={props.team.id}
135-
teamSlug={props.team.slug}
136-
billingStatus={props.team.billingStatus}
137-
currentPlan={props.team.billingPlan}
138-
getTeam={props.getTeam}
139-
/>
140-
)}
140+
<ToolTipLabel
141+
label={
142+
props.isOwnerAccount
143+
? null
144+
: props.team.planCancellationDate
145+
? "Only team owners can renew plans."
146+
: "Only team owners can cancel plans."
147+
}
148+
>
149+
<div>
150+
{props.team.planCancellationDate ? (
151+
<RenewSubscriptionButton
152+
teamId={props.team.id}
153+
getTeam={props.getTeam}
154+
disabled={!props.isOwnerAccount}
155+
/>
156+
) : (
157+
<CancelPlanButton
158+
teamId={props.team.id}
159+
teamSlug={props.team.slug}
160+
billingStatus={props.team.billingStatus}
161+
currentPlan={props.team.billingPlan}
162+
getTeam={props.getTeam}
163+
disabled={!props.isOwnerAccount}
164+
/>
165+
)}
166+
</div>
167+
</ToolTipLabel>
141168
</div>
142169
)}
143170
</div>
@@ -153,16 +180,28 @@ export function PlanInfoCardUI(props: {
153180
To unlock additional usage, upgrade your plan to Starter or
154181
Growth.
155182
</p>
183+
156184
<div className="mt-4">
157-
<Button
158-
variant="default"
159-
size="sm"
160-
onClick={() => {
161-
setIsPlanSheetOpen(true);
162-
}}
185+
<ToolTipLabel
186+
label={
187+
props.isOwnerAccount
188+
? null
189+
: "Only team owners can change plans."
190+
}
163191
>
164-
Select a plan
165-
</Button>
192+
<div>
193+
<Button
194+
disabled={!props.isOwnerAccount}
195+
variant="default"
196+
size="sm"
197+
onClick={() => {
198+
setIsPlanSheetOpen(true);
199+
}}
200+
>
201+
Select a plan
202+
</Button>
203+
</div>
204+
</ToolTipLabel>
166205
</div>
167206
</div>
168207
) : (
@@ -203,17 +242,28 @@ export function PlanInfoCardUI(props: {
203242
</Button>
204243

205244
{/* manage team billing */}
206-
<BillingPortalButton
207-
teamSlug={team.slug}
208-
buttonProps={{
209-
variant: "outline",
210-
size: "sm",
211-
className: "bg-background gap-2",
212-
}}
245+
<ToolTipLabel
246+
label={
247+
props.isOwnerAccount
248+
? null
249+
: "Only team owners can manage billing."
250+
}
213251
>
214-
<CreditCardIcon className="size-4 text-muted-foreground" />
215-
Manage Billing
216-
</BillingPortalButton>
252+
<div>
253+
<BillingPortalButton
254+
teamSlug={team.slug}
255+
buttonProps={{
256+
variant: "outline",
257+
size: "sm",
258+
className: "bg-background gap-2",
259+
disabled: !props.isOwnerAccount,
260+
}}
261+
>
262+
<CreditCardIcon className="size-4 text-muted-foreground" />
263+
Manage Billing
264+
</BillingPortalButton>
265+
</div>
266+
</ToolTipLabel>
217267
</div>
218268
</div>
219269
)}

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: 10 additions & 3 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,16 +26,20 @@ 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

34-
if (!team) {
37+
if (!team || !teamMember) {
3538
redirect("/team");
3639
}
3740

41+
const isOwnerAccount = teamMember.role === "OWNER";
42+
3843
const subscriptions = await getTeamSubscriptions(team.slug);
3944

4045
if (!subscriptions) {
@@ -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)