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 ( - @@ -107,6 +114,7 @@ function ProPlanCancelPlanSheetContent() { function ImmediateCancelPlanButton(props: { teamId: string; getTeam: () => Promise; + disabled?: boolean; }) { const router = useDashboardRouter(); const [isRoutePending, startTransition] = useTransition(); @@ -144,7 +152,7 @@ function ImmediateCancelPlanButton(props: { variant="outline" size="sm" className="gap-2 bg-background" - disabled={showPlanSpinner} + disabled={showPlanSpinner || props.disabled} asChild > diff --git a/apps/dashboard/src/components/settings/Account/Billing/renew-subscription/renew-subscription-button.tsx b/apps/dashboard/src/components/settings/Account/Billing/renew-subscription/renew-subscription-button.tsx index bcb8eb04c79..e5b4b4e1e95 100644 --- a/apps/dashboard/src/components/settings/Account/Billing/renew-subscription/renew-subscription-button.tsx +++ b/apps/dashboard/src/components/settings/Account/Billing/renew-subscription/renew-subscription-button.tsx @@ -15,6 +15,7 @@ import { tryCatch } from "../../../../../utils/try-catch"; export function RenewSubscriptionButton(props: { teamId: string; getTeam: () => Promise; + disabled?: boolean; }) { const router = useDashboardRouter(); const [isRoutePending, setTransition] = useTransition(); @@ -71,7 +72,7 @@ export function RenewSubscriptionButton(props: { variant="default" size="sm" className="gap-2" - disabled={showSpinner} + disabled={showSpinner || props.disabled} > {showSpinner ? (