From a920d1ee076310f790baa8f7aac04456113dfb49 Mon Sep 17 00:00:00 2001
From: jnsdls
Date: Wed, 4 Jun 2025 20:50:42 +0000
Subject: [PATCH] [Dashboard] Restrict billing actions to team owners only
(#7274)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
# 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.
## 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.
---
## 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 `` to `` 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}`
---
apps/dashboard/src/@/components/billing.tsx | 5 +-
apps/dashboard/src/@/components/ui/button.tsx | 24 +++-
.../components/PlanInfoCard.client.tsx | 2 +
.../components/PlanInfoCard.stories.tsx | 5 +
.../billing/components/PlanInfoCard.tsx | 134 ++++++++++++------
.../credit-balance-section.client.tsx | 39 +++--
.../(team)/~/settings/billing/page.tsx | 13 +-
.../invoices/components/billing-history.tsx | 72 +++++++---
.../(team)/~/settings/invoices/page.tsx | 11 +-
.../engine/dedicated/(general)/layout.tsx | 2 +-
.../analytics/empty-chart-state.tsx | 2 +-
.../CancelPlanModal/CancelPlanModal.tsx | 12 +-
.../renew-subscription-button.tsx | 3 +-
13 files changed, 234 insertions(+), 90 deletions(-)
diff --git a/apps/dashboard/src/@/components/billing.tsx b/apps/dashboard/src/@/components/billing.tsx
index e2dead0c85f..c36fa083453 100644
--- a/apps/dashboard/src/@/components/billing.tsx
+++ b/apps/dashboard/src/@/components/billing.tsx
@@ -84,12 +84,13 @@ export function BillingPortalButton(props: {
props.buttonProps?.onClick?.(e);
}}
>
-
{props.children}
-
+
);
}
diff --git a/apps/dashboard/src/@/components/ui/button.tsx b/apps/dashboard/src/@/components/ui/button.tsx
index ea25f0e8b27..81b78577aab 100644
--- a/apps/dashboard/src/@/components/ui/button.tsx
+++ b/apps/dashboard/src/@/components/ui/button.tsx
@@ -45,17 +45,35 @@ export interface ButtonProps
}
const Button = React.forwardRef(
- ({ className, variant, size, asChild = false, ...props }, ref) => {
+ ({ className, variant, size, asChild = false, disabled, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
+
+ // "button" elements automatically handle the `disabled` attribute.
+ // For non-button elements rendered via `asChild` (e.g. ), we still want
+ // to visually convey the disabled state and prevent user interaction.
+ // We do that by conditionally adding the same utility classes that the
+ // `disabled:` pseudo-variant would normally apply and by setting
+ // `aria-disabled` for accessibility.
+ const disabledClass = disabled ? "pointer-events-none opacity-50" : "";
+
const btnOnlyProps =
Comp === "button"
- ? { type: props.type || ("button" as const) }
+ ? {
+ type:
+ (props as React.ButtonHTMLAttributes).type ||
+ ("button" as const),
+ }
: undefined;
return (
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/settings/billing/components/PlanInfoCard.client.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/settings/billing/components/PlanInfoCard.client.tsx
index 033a16eafd0..ea38deb8a61 100644
--- a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/settings/billing/components/PlanInfoCard.client.tsx
+++ b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/settings/billing/components/PlanInfoCard.client.tsx
@@ -9,12 +9,14 @@ export function PlanInfoCardClient(props: {
team: Team;
openPlanSheetButtonByDefault: boolean;
highlightPlan: Team["billingPlan"] | undefined;
+ isOwnerAccount: boolean;
}) {
return (
{
const res = await apiServerProxy<{
result: Team;
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/settings/billing/components/PlanInfoCard.stories.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/settings/billing/components/PlanInfoCard.stories.tsx
index 70e140ef374..5eccd417f6f 100644
--- a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/settings/billing/components/PlanInfoCard.stories.tsx
+++ b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/settings/billing/components/PlanInfoCard.stories.tsx
@@ -120,6 +120,7 @@ function Story(props: {
getTeam={teamTeamStub}
highlightPlan={undefined}
openPlanSheetButtonByDefault={false}
+ isOwnerAccount={true}
/>
@@ -133,6 +134,7 @@ function Story(props: {
getTeam={teamTeamStub}
highlightPlan={undefined}
openPlanSheetButtonByDefault={false}
+ isOwnerAccount={true}
/>
@@ -143,6 +145,7 @@ function Story(props: {
getTeam={teamTeamStub}
highlightPlan={undefined}
openPlanSheetButtonByDefault={false}
+ isOwnerAccount={true}
/>
@@ -153,6 +156,7 @@ function Story(props: {
getTeam={teamTeamStub}
highlightPlan={undefined}
openPlanSheetButtonByDefault={false}
+ isOwnerAccount={true}
/>
@@ -163,6 +167,7 @@ function Story(props: {
getTeam={teamTeamStub}
highlightPlan={undefined}
openPlanSheetButtonByDefault={false}
+ isOwnerAccount={true}
/>
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/settings/billing/components/PlanInfoCard.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/settings/billing/components/PlanInfoCard.tsx
index f8ac4b413a5..2c3cfee7107 100644
--- a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/settings/billing/components/PlanInfoCard.tsx
+++ b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/settings/billing/components/PlanInfoCard.tsx
@@ -13,6 +13,7 @@ import {
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
+import { ToolTipLabel } from "@/components/ui/tooltip";
import { CancelPlanButton } from "components/settings/Account/Billing/CancelPlanModal/CancelPlanModal";
import { BillingPricing } from "components/settings/Account/Billing/Pricing";
import { differenceInDays, isAfter } from "date-fns";
@@ -30,6 +31,7 @@ export function PlanInfoCardUI(props: {
getTeam: () => Promise;
openPlanSheetButtonByDefault: boolean;
highlightPlan: Team["billingPlan"] | undefined;
+ isOwnerAccount: boolean;
}) {
const { subscriptions, team, openPlanSheetButtonByDefault } = props;
const validPlan = getValidTeamPlan(team);
@@ -112,32 +114,57 @@ export function PlanInfoCardUI(props: {
{props.team.billingPlan !== "free" && (
-
+
+
+
+
- {props.team.planCancellationDate ? (
-
- ) : (
-
- )}
+
+
+ {props.team.planCancellationDate ? (
+
+ ) : (
+
+ )}
+
+
)}
@@ -153,16 +180,28 @@ export function PlanInfoCardUI(props: {
To unlock additional usage, upgrade your plan to Starter or
Growth.
+
-
+
+
+
+
) : (
@@ -203,17 +242,28 @@ export function PlanInfoCardUI(props: {
{/* manage team billing */}
-
-
- Manage Billing
-
+
+
+
+ Manage Billing
+
+
+
)}
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/settings/billing/components/credit-balance-section.client.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/settings/billing/components/credit-balance-section.client.tsx
index bdd4b9d0328..a969d3ba98a 100644
--- a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/settings/billing/components/credit-balance-section.client.tsx
+++ b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/settings/billing/components/credit-balance-section.client.tsx
@@ -12,8 +12,8 @@ import { Label } from "@/components/ui/label";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Separator } from "@/components/ui/separator";
import { Skeleton } from "@/components/ui/skeleton";
+import { ToolTipLabel } from "@/components/ui/tooltip";
import { ArrowRightIcon, DollarSignIcon } from "lucide-react";
-import Link from "next/link";
import { Suspense, use, useState } from "react";
import { ErrorBoundary } from "react-error-boundary";
import { ThirdwebMiniLogo } from "../../../../../../../components/ThirdwebMiniLogo";
@@ -28,11 +28,13 @@ const predefinedAmounts = [
interface CreditBalanceSectionProps {
balancePromise: Promise;
teamSlug: string;
+ isOwnerAccount: boolean;
}
export function CreditBalanceSection({
balancePromise,
teamSlug,
+ isOwnerAccount,
}: CreditBalanceSectionProps) {
const [selectedAmount, setSelectedAmount] = useState(
predefinedAmounts[0].value,
@@ -114,17 +116,30 @@ export function CreditBalanceSection({
-
+
+
+
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/settings/billing/page.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/settings/billing/page.tsx
index 148a361eda2..b3771da3b2c 100644
--- a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/settings/billing/page.tsx
+++ b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/settings/billing/page.tsx
@@ -1,5 +1,6 @@
import { getStripeBalance } from "@/actions/stripe-actions";
import { type Team, getTeamBySlug } from "@/api/team";
+import { getMemberById } from "@/api/team-members";
import { getTeamSubscriptions } from "@/api/team-subscription";
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
import { redirect } from "next/navigation";
@@ -25,16 +26,20 @@ export default async function Page(props: {
]);
const pagePath = `/team/${params.team_slug}/settings/billing`;
- const [account, team, authToken] = await Promise.all([
- getValidAccount(pagePath),
+ const account = await getValidAccount(pagePath);
+
+ const [team, authToken, teamMember] = await Promise.all([
getTeamBySlug(params.team_slug),
getAuthToken(),
+ getMemberById(params.team_slug, account.id),
]);
- if (!team) {
+ if (!team || !teamMember) {
redirect("/team");
}
+ const isOwnerAccount = teamMember.role === "OWNER";
+
const subscriptions = await getTeamSubscriptions(team.slug);
if (!subscriptions) {
@@ -66,6 +71,7 @@ export default async function Page(props: {
subscriptions={subscriptions}
openPlanSheetButtonByDefault={searchParams.showPlans === "true"}
highlightPlan={highlightPlan}
+ isOwnerAccount={isOwnerAccount}
/>
@@ -74,6 +80,7 @@ export default async function Page(props: {
)}
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/settings/invoices/components/billing-history.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/settings/invoices/components/billing-history.tsx
index a129ed110f1..c4dd2eec107 100644
--- a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/settings/invoices/components/billing-history.tsx
+++ b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/settings/invoices/components/billing-history.tsx
@@ -11,6 +11,7 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table";
+import { ToolTipLabel } from "@/components/ui/tooltip";
import {
ChevronLeftIcon,
ChevronRightIcon,
@@ -18,7 +19,6 @@ import {
DownloadIcon,
ReceiptIcon,
} from "lucide-react";
-import Link from "next/link";
import { useQueryState } from "nuqs";
import { useTransition } from "react";
import type Stripe from "stripe";
@@ -30,6 +30,7 @@ export function BillingHistory(props: {
invoices: Stripe.Invoice[];
status: "all" | "past_due" | "open";
hasMore: boolean;
+ isOwnerAccount: boolean;
}) {
const [isLoading, startTransition] = useTransition();
const [cursor, setCursor] = useQueryState(
@@ -128,27 +129,58 @@ export function BillingHistory(props: {
{invoice.status === "open" && (
<>
{/* always show the crypto payment button */}
-
+
+
+
{/* if we have a hosted invoice url, show that */}
{invoice.hosted_invoice_url && (
-
+
+
+
)}
>
)}
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/settings/invoices/page.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/settings/invoices/page.tsx
index d3ade359e52..e4f9ecb86e5 100644
--- a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/settings/invoices/page.tsx
+++ b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/settings/invoices/page.tsx
@@ -1,5 +1,6 @@
import { getTeamInvoices } from "@/actions/stripe-actions";
import { getTeamBySlug } from "@/api/team";
+import { getMemberById } from "@/api/team-members";
import { redirect } from "next/navigation";
import type { SearchParams } from "nuqs/server";
import { getValidAccount } from "../../../../../../account/settings/getAccount";
@@ -20,16 +21,19 @@ export default async function Page(props: {
const pagePath = `/team/${params.team_slug}/settings/invoices`;
- const [, team] = await Promise.all([
- // only called to verify login status etc
- getValidAccount(pagePath),
+ const account = await getValidAccount(pagePath);
+
+ const [team, teamMember] = await Promise.all([
getTeamBySlug(params.team_slug),
+ getMemberById(params.team_slug, account.id),
]);
if (!team) {
redirect("/team");
}
+ const isOwnerAccount = teamMember?.role === "OWNER";
+
const invoices = await getTeamInvoices(team, {
cursor: searchParams.cursor ?? undefined,
status: searchParams.status ?? undefined,
@@ -54,6 +58,7 @@ export default async function Page(props: {
hasMore={invoices.has_more}
// fall back to "all" if the status is not set
status={searchParams.status ?? "all"}
+ isOwnerAccount={isOwnerAccount}
/>
);
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/engine/dedicated/(general)/layout.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/engine/dedicated/(general)/layout.tsx
index 51a1099dec2..8629445b7b4 100644
--- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/engine/dedicated/(general)/layout.tsx
+++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/engine/dedicated/(general)/layout.tsx
@@ -1,10 +1,10 @@
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
+import { TabPathLinks } from "@/components/ui/tabs";
import { DatabaseIcon, ExternalLinkIcon } from "lucide-react";
import Link from "next/link";
import { EngineIcon } from "../../../../../../../(dashboard)/(chain)/components/server/icons/EngineIcon";
-import { TabPathLinks } from "../../../../../../../../../@/components/ui/tabs";
import { ImportEngineLink } from "./_components";
export default async function Layout(props: {
diff --git a/apps/dashboard/src/components/analytics/empty-chart-state.tsx b/apps/dashboard/src/components/analytics/empty-chart-state.tsx
index 319af5deeff..4c42f7ad954 100644
--- a/apps/dashboard/src/components/analytics/empty-chart-state.tsx
+++ b/apps/dashboard/src/components/analytics/empty-chart-state.tsx
@@ -1,9 +1,9 @@
"use client";
import { LoadingDots } from "@/components/ui/LoadingDots";
import { type ChartConfig, ChartContainer } from "@/components/ui/chart";
+import { cn } from "@/lib/utils";
import { useMemo } from "react";
import { Area, AreaChart, Bar, BarChart } from "recharts";
-import { cn } from "../../@/lib/utils";
type FakeCartData = {
value: number;
diff --git a/apps/dashboard/src/components/settings/Account/Billing/CancelPlanModal/CancelPlanModal.tsx b/apps/dashboard/src/components/settings/Account/Billing/CancelPlanModal/CancelPlanModal.tsx
index eb1452fe2e5..9c8f2eee03e 100644
--- a/apps/dashboard/src/components/settings/Account/Billing/CancelPlanModal/CancelPlanModal.tsx
+++ b/apps/dashboard/src/components/settings/Account/Billing/CancelPlanModal/CancelPlanModal.tsx
@@ -26,6 +26,7 @@ export function CancelPlanButton(props: {
currentPlan: Team["billingPlan"];
billingStatus: Team["billingStatus"];
getTeam: () => Promise;
+ disabled?: boolean;
}) {
// shortcut the sheet in case the user is in the default state
if (props.billingStatus !== "invalidPayment" && props.currentPlan !== "pro") {
@@ -33,6 +34,7 @@ export function CancelPlanButton(props: {
);
}
@@ -40,7 +42,12 @@ export function CancelPlanButton(props: {
return (
-