Skip to content

[Dashboard] Restrict billing actions to team owners only #7274

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions apps/dashboard/src/@/components/billing.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -84,12 +84,13 @@ export function BillingPortalButton(props: {
props.buttonProps?.onClick?.(e);
}}
>
<Link
<a
href={buildBillingPortalUrl({ teamSlug: props.teamSlug })}
target="_blank"
rel="noreferrer"
>
{props.children}
</Link>
</a>
</Button>
);
}
24 changes: 21 additions & 3 deletions apps/dashboard/src/@/components/ui/button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,17 +45,35 @@ export interface ButtonProps
}

const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ 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. <a>), 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<HTMLButtonElement>).type ||
("button" as const),
}
: undefined;

return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
className={cn(
buttonVariants({ variant, size, className }),
disabledClass,
)}
ref={ref}
aria-disabled={disabled ? true : undefined}
disabled={disabled}
{...props}
{...btnOnlyProps}
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,14 @@ export function PlanInfoCardClient(props: {
team: Team;
openPlanSheetButtonByDefault: boolean;
highlightPlan: Team["billingPlan"] | undefined;
isOwnerAccount: boolean;
}) {
return (
<PlanInfoCardUI
openPlanSheetButtonByDefault={props.openPlanSheetButtonByDefault}
team={props.team}
subscriptions={props.subscriptions}
isOwnerAccount={props.isOwnerAccount}
getTeam={async () => {
const res = await apiServerProxy<{
result: Team;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ function Story(props: {
getTeam={teamTeamStub}
highlightPlan={undefined}
openPlanSheetButtonByDefault={false}
isOwnerAccount={true}
/>
</BadgeContainer>

Expand All @@ -133,6 +134,7 @@ function Story(props: {
getTeam={teamTeamStub}
highlightPlan={undefined}
openPlanSheetButtonByDefault={false}
isOwnerAccount={true}
/>
</BadgeContainer>

Expand All @@ -143,6 +145,7 @@ function Story(props: {
getTeam={teamTeamStub}
highlightPlan={undefined}
openPlanSheetButtonByDefault={false}
isOwnerAccount={true}
/>
</BadgeContainer>

Expand All @@ -153,6 +156,7 @@ function Story(props: {
getTeam={teamTeamStub}
highlightPlan={undefined}
openPlanSheetButtonByDefault={false}
isOwnerAccount={true}
/>
</BadgeContainer>

Expand All @@ -163,6 +167,7 @@ function Story(props: {
getTeam={teamTeamStub}
highlightPlan={undefined}
openPlanSheetButtonByDefault={false}
isOwnerAccount={true}
/>
</BadgeContainer>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -30,6 +31,7 @@ export function PlanInfoCardUI(props: {
getTeam: () => Promise<Team>;
openPlanSheetButtonByDefault: boolean;
highlightPlan: Team["billingPlan"] | undefined;
isOwnerAccount: boolean;
}) {
const { subscriptions, team, openPlanSheetButtonByDefault } = props;
const validPlan = getValidTeamPlan(team);
Expand Down Expand Up @@ -112,32 +114,57 @@ export function PlanInfoCardUI(props: {

{props.team.billingPlan !== "free" && (
<div className="flex items-center gap-3">
<Button
variant="outline"
size="sm"
className="gap-2 bg-background"
onClick={() => {
setIsPlanSheetOpen(true);
}}
<ToolTipLabel
label={
props.isOwnerAccount
? null
: "Only team owners can change plans."
}
>
<SquarePenIcon className="size-4 text-muted-foreground" />
Change Plan
</Button>
<div>
<Button
variant="outline"
size="sm"
className="gap-2 bg-background"
onClick={() => {
setIsPlanSheetOpen(true);
}}
disabled={!props.isOwnerAccount}
>
<SquarePenIcon className="size-4 text-muted-foreground" />
Change Plan
</Button>
</div>
</ToolTipLabel>

{props.team.planCancellationDate ? (
<RenewSubscriptionButton
teamId={props.team.id}
getTeam={props.getTeam}
/>
) : (
<CancelPlanButton
teamId={props.team.id}
teamSlug={props.team.slug}
billingStatus={props.team.billingStatus}
currentPlan={props.team.billingPlan}
getTeam={props.getTeam}
/>
)}
<ToolTipLabel
label={
props.isOwnerAccount
? null
: props.team.planCancellationDate
? "Only team owners can renew plans."
: "Only team owners can cancel plans."
}
>
<div>
{props.team.planCancellationDate ? (
<RenewSubscriptionButton
teamId={props.team.id}
getTeam={props.getTeam}
disabled={!props.isOwnerAccount}
/>
) : (
<CancelPlanButton
teamId={props.team.id}
teamSlug={props.team.slug}
billingStatus={props.team.billingStatus}
currentPlan={props.team.billingPlan}
getTeam={props.getTeam}
disabled={!props.isOwnerAccount}
/>
)}
</div>
</ToolTipLabel>
</div>
)}
</div>
Expand All @@ -153,16 +180,28 @@ export function PlanInfoCardUI(props: {
To unlock additional usage, upgrade your plan to Starter or
Growth.
</p>

<div className="mt-4">
<Button
variant="default"
size="sm"
onClick={() => {
setIsPlanSheetOpen(true);
}}
<ToolTipLabel
label={
props.isOwnerAccount
? null
: "Only team owners can change plans."
}
>
Select a plan
</Button>
<div>
<Button
disabled={!props.isOwnerAccount}
variant="default"
size="sm"
onClick={() => {
setIsPlanSheetOpen(true);
}}
>
Select a plan
</Button>
</div>
</ToolTipLabel>
</div>
</div>
) : (
Expand Down Expand Up @@ -203,17 +242,28 @@ export function PlanInfoCardUI(props: {
</Button>

{/* manage team billing */}
<BillingPortalButton
teamSlug={team.slug}
buttonProps={{
variant: "outline",
size: "sm",
className: "bg-background gap-2",
}}
<ToolTipLabel
label={
props.isOwnerAccount
? null
: "Only team owners can manage billing."
}
>
<CreditCardIcon className="size-4 text-muted-foreground" />
Manage Billing
</BillingPortalButton>
<div>
<BillingPortalButton
teamSlug={team.slug}
buttonProps={{
variant: "outline",
size: "sm",
className: "bg-background gap-2",
disabled: !props.isOwnerAccount,
}}
>
<CreditCardIcon className="size-4 text-muted-foreground" />
Manage Billing
</BillingPortalButton>
</div>
</ToolTipLabel>
</div>
</div>
)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -28,11 +28,13 @@ const predefinedAmounts = [
interface CreditBalanceSectionProps {
balancePromise: Promise<number>;
teamSlug: string;
isOwnerAccount: boolean;
}

export function CreditBalanceSection({
balancePromise,
teamSlug,
isOwnerAccount,
}: CreditBalanceSectionProps) {
const [selectedAmount, setSelectedAmount] = useState<string>(
predefinedAmounts[0].value,
Expand Down Expand Up @@ -114,17 +116,30 @@ export function CreditBalanceSection({
</Suspense>
</ErrorBoundary>

<Button asChild className="w-full" size="lg">
<Link
href={`/checkout/${teamSlug}/topup?amount=${selectedAmount}`}
prefetch={false}
target="_blank"
>
<ThirdwebMiniLogo className="mr-2 h-4 w-4" />
Top Up With Crypto
<ArrowRightIcon className="ml-2 h-4 w-4" />
</Link>
</Button>
<ToolTipLabel
label={
isOwnerAccount ? null : "Only team owners can top up credits."
}
>
<div>
<Button
asChild
className="w-full"
size="lg"
disabled={!isOwnerAccount}
>
<a
href={`/checkout/${teamSlug}/topup?amount=${selectedAmount}`}
target="_blank"
rel="noopener noreferrer"
>
<ThirdwebMiniLogo className="mr-2 h-4 w-4" />
Top Up With Crypto
<ArrowRightIcon className="ml-2 h-4 w-4" />
</a>
</Button>
</div>
</ToolTipLabel>
</div>
</div>
</CardContent>
Expand Down
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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) {
Expand Down Expand Up @@ -66,6 +71,7 @@ export default async function Page(props: {
subscriptions={subscriptions}
openPlanSheetButtonByDefault={searchParams.showPlans === "true"}
highlightPlan={highlightPlan}
isOwnerAccount={isOwnerAccount}
/>
</div>

Expand All @@ -74,6 +80,7 @@ export default async function Page(props: {
<CreditBalanceSection
teamSlug={team.slug}
balancePromise={getStripeBalance(team.stripeCustomerId)}
isOwnerAccount={isOwnerAccount}
/>
)}

Expand Down
Loading
Loading