diff --git a/apps/dashboard/app/(app)/[...not-found]/page.tsx b/apps/dashboard/app/(app)/[...not-found]/page.tsx index ec81f140da..c5fd6bdfde 100644 --- a/apps/dashboard/app/(app)/[...not-found]/page.tsx +++ b/apps/dashboard/app/(app)/[...not-found]/page.tsx @@ -1,14 +1,23 @@ +"use client"; import { Button, Empty } from "@unkey/ui"; -import Link from "next/link"; +import { useRouter } from "next/navigation"; + export default function NotFound() { + const router = useRouter(); + return ( 404 Not Found We couldn't find the page that you're looking for! - - - + ); diff --git a/apps/dashboard/app/(app)/[workspaceSlug]/settings/billing/client.tsx b/apps/dashboard/app/(app)/[workspaceSlug]/settings/billing/client.tsx index f997f841bd..b7b9b97836 100644 --- a/apps/dashboard/app/(app)/[workspaceSlug]/settings/billing/client.tsx +++ b/apps/dashboard/app/(app)/[workspaceSlug]/settings/billing/client.tsx @@ -1,346 +1,156 @@ "use client"; -import type { Workspace } from "@/lib/db"; -import { formatNumber } from "@/lib/fmt"; +import { useWorkspaceNavigation } from "@/hooks/use-workspace-navigation"; import { trpc } from "@/lib/trpc/client"; -import { cn } from "@/lib/utils"; -import { Button, Empty, SettingCard, toast } from "@unkey/ui"; -import ms from "ms"; -import Link from "next/link"; +import { Button, Empty, SettingCard } from "@unkey/ui"; import { useRouter } from "next/navigation"; +import { useState } from "react"; import type Stripe from "stripe"; import { WorkspaceNavbar } from "../workspace-navbar"; -import { Confirm } from "./components/confirmation"; +import { CancelAlert } from "./components/cancel-alert"; +import { CancelPlan } from "./components/cancel-plan"; +import { CurrentPlanCard } from "./components/current-plan-card"; +import { FreeTierAlert } from "./components/free-tier-alert"; +import { PlanSelectionModal } from "./components/plan-selection-modal"; import { Shell } from "./components/shell"; +import { SubscriptionStatus } from "./components/subscription-status"; import { Usage } from "./components/usage"; -type Props = { - hasPreviousSubscriptions: boolean; - usage: { - current: number; - max: number; - }; - workspace: Workspace; - subscription?: { - id: string; - status: Stripe.Subscription.Status; - cancelAt?: number; - }; - currentProductId?: string; - products: Array<{ - id: string; - name: string; - priceId: string; - dollar: number; - quotas: { - requestsPerMonth: number; - }; - }>; -}; +const MAX_QUOTA = 150000; -const useBillingMutations = () => { +export const Client: React.FC = () => { const router = useRouter(); + const workspace = useWorkspaceNavigation(); + const [showPlanModal, setShowPlanModal] = useState(false); - const createSubscription = trpc.stripe.createSubscription.useMutation({ - onSuccess: () => { - router.refresh(); - toast.info("Subscription started"); - }, - onError: (err) => { - toast.error(err.message); - }, - }); - const updateSubscription = trpc.stripe.updateSubscription.useMutation({ - onSuccess: () => { - router.refresh(); - toast.info("Plan changed"); - }, - onError: (err) => { - toast.error(err.message); - }, - }); - const cancelSubscription = trpc.stripe.cancelSubscription.useMutation({ - onSuccess: () => { - router.refresh(); - toast.info("Subscription cancelled"); - }, - onError: (err) => { - toast.error(err.message); - }, - }); - const uncancelSubscription = trpc.stripe.uncancelSubscription.useMutation({ - onSuccess: () => { - router.refresh(); - toast.info("Subscription resumed"); - }, - onError: (err) => { - toast.error(err.message); - }, + // Fetch billing info using new tRPC route + // Query is automatically keyed by workspace context and will refetch on workspace change + const { + data: billingInfo, + isLoading: billingLoading, + error: billingError, + } = trpc.stripe.getBillingInfo.useQuery(undefined, { + // Cache for 30 seconds to reduce unnecessary refetches + // TRPC automatically scopes by workspace via requireWorkspace middleware + staleTime: 30_000, // 30 seconds }); - return { - createSubscription, - updateSubscription, - cancelSubscription, - uncancelSubscription, - }; -}; + // Handle loading states - don't render until we have billing info + if (billingLoading || !billingInfo) { + return ( +
+ + +
+
+
+ +
+ ); + } + + // Handle error states + if (billingError) { + return ( +
+ + + Failed to load billing information + + There was an error loading your billing information. Please try again later. + + +
+ ); + } + + // Extract data from tRPC responses + const products = billingInfo.products; + const subscription = billingInfo.subscription; + const currentProductId = billingInfo.currentProductId; + + const allowUpdate = subscription && ["active", "trialing"].includes(subscription.status); -export const Client: React.FC = (props) => { - const mutations = useBillingMutations(); - const allowUpdate = - props.subscription && ["active", "trialing"].includes(props.subscription.status); - const allowCancel = - props.subscription && props.subscription.status === "active" && !props.subscription.cancelAt; - const isFreeTier = !props.subscription || props.subscription.status !== "active"; - const selectedProductIndex = allowUpdate - ? props.products.findIndex((p) => p.id === props.currentProductId) - : -1; + const isFreeTier = !subscription || !["active", "trialing"].includes(subscription.status); + const allowCancel = subscription && subscription.status === "active" && !subscription.cancelAt; + const currentProduct = allowUpdate ? products.find((p) => p.id === currentProductId) : undefined; return (
- {props.subscription ? ( + {subscription ? ( ) : null} - + {isFreeTier ? : null} - + - {props.workspace.stripeCustomerId ? ( -
- {props.products.map((p, i) => { - const isSelected = selectedProductIndex === i; - const isNextSelected = selectedProductIndex === i + 1; - return ( -
0 && !isSelected, - "border-b-0": isNextSelected, - "rounded-b-xl": i === props.products.length - 1, - "border-info-7 bg-info-3": isSelected, - }, - )} - > -
{p.name}
-
- - {formatNumber(p.quotas.requestsPerMonth)} - - requests -
-
-
- ${p.dollar} - /mo -
+ {workspace.stripeCustomerId ? ( + <> + setShowPlanModal(true)} + /> - {props.subscription ? ( - selectedProductIndex ? "Upgrade" : "Downgrade"} to ${p.name}`} - description={`Changing to ${ - p.name - } updates your request quota to ${formatNumber( - p.quotas.requestsPerMonth, - )} per month immediately.`} - onConfirm={async () => { - if (!props.currentProductId) { - console.error( - "Cannot update subscription: currentProductId is missing", - ); - toast.error( - "Unable to update subscription. Please refresh and try again.", - ); - return; - } - await mutations.updateSubscription.mutateAsync({ - oldProductId: props.currentProductId, - newProductId: p.id, - }); - }} - trigger={(onClick) => ( - - )} - /> - ) : ( - - mutations.createSubscription.mutateAsync({ - productId: p.id, - }) - } - trigger={(onClick) => ( - - )} - /> - )} -
-
- ); - })} -
+ + ) : (
-
)} -
- {props.workspace.stripeCustomerId ? ( - -
- -
-
- ) : null} + {workspace.stripeCustomerId ? ( + +
+ +
+
+ ) : null} - {props.subscription && allowCancel ? ( - -
- mutations.cancelSubscription.mutateAsync()} - trigger={(onClick) => ( - - )} - /> -
-
- ) : null} -
+ {allowCancel ? : null}
); }; - -const FreeTierAlert: React.FC = () => { - return ( - - You are on the Free tier. - - The Free tier includes 150k requests of free usage. -
- To unlock additional usage and add team members, upgrade to Pro.{" "} - - See Pricing - -
-
- ); -}; - -const CancelAlert: React.FC<{ cancelAt?: number }> = (props) => { - const mutations = useBillingMutations(); - - if (!props.cancelAt) { - return null; - } - - return ( - - Your subscription ends in - {ms(props.cancelAt - Date.now(), { long: true })}{" "} - on {new Date(props.cancelAt).toLocaleDateString()} - . -

- } - border="both" - className="border-warning-7 bg-warning-2" - > -
- -
-
- ); -}; -const SubscriptionStatus: React.FC<{ - status: Stripe.Subscription.Status; - trialUntil?: number; - workspaceId: string; - workspaceSlug: string; -}> = (props) => { - switch (props.status) { - case "active": - return null; - - case "incomplete": - case "incomplete_expired": - case "unpaid": - case "past_due": - return ( - -
- -
-
- ); - case "paused": - case "canceled": - } - return null; -}; diff --git a/apps/dashboard/app/(app)/[workspaceSlug]/settings/billing/components/cancel-alert.tsx b/apps/dashboard/app/(app)/[workspaceSlug]/settings/billing/components/cancel-alert.tsx new file mode 100644 index 0000000000..269349a6cc --- /dev/null +++ b/apps/dashboard/app/(app)/[workspaceSlug]/settings/billing/components/cancel-alert.tsx @@ -0,0 +1,65 @@ +"use client"; +import { trpc } from "@/lib/trpc/client"; +import { Button, SettingCard, toast } from "@unkey/ui"; +import ms from "ms"; +import { useRouter } from "next/navigation"; + +export const CancelAlert: React.FC<{ cancelAt?: number }> = (props) => { + const router = useRouter(); + const trpcUtils = trpc.useUtils(); + const uncancelSubscription = trpc.stripe.uncancelSubscription.useMutation({ + onSuccess: async () => { + // Revalidate helper: invalidate AND explicitly refetch to ensure UI updates + await Promise.all([ + trpcUtils.workspace.getCurrent.invalidate(), + trpcUtils.billing.queryUsage.invalidate(), + trpcUtils.stripe.getBillingInfo.invalidate(), + trpcUtils.workspace.getCurrent.refetch(), + trpcUtils.stripe.getBillingInfo.refetch(), + ]); + router.refresh(); + toast.info("Subscription resumed"); + }, + onError: (err) => { + toast.error("Failed to resume subscription. Please try again or contact support@unkey.dev."); + console.error("Subscription resumption error:", err); + }, + }); + + if (!props.cancelAt) { + return null; + } + + const timeRemaining = props.cancelAt - Date.now(); + // If cancellation date has passed, don't show the alert + if (timeRemaining <= 0) { + return null; + } + + return ( + + Your subscription ends in + {ms(timeRemaining, { long: true })} on{" "} + {new Date(props.cancelAt).toLocaleDateString()}. +

+ } + border="both" + className="border-warning-7 bg-warning-2 w-full" + contentWidth="w-full lg:w-[320px]" + > +
+ +
+
+ ); +}; diff --git a/apps/dashboard/app/(app)/[workspaceSlug]/settings/billing/components/cancel-plan.tsx b/apps/dashboard/app/(app)/[workspaceSlug]/settings/billing/components/cancel-plan.tsx new file mode 100644 index 0000000000..ec51bbf613 --- /dev/null +++ b/apps/dashboard/app/(app)/[workspaceSlug]/settings/billing/components/cancel-plan.tsx @@ -0,0 +1,52 @@ +"use client"; +import { trpc } from "@/lib/trpc/client"; +import { Button, SettingCard, toast } from "@unkey/ui"; +import { useRouter } from "next/navigation"; +import { Confirm } from "./confirmation"; + +export const CancelPlan: React.FC = () => { + const trpcUtils = trpc.useUtils(); + const router = useRouter(); + + const cancelSubscription = trpc.stripe.cancelSubscription.useMutation({ + onSuccess: async () => { + // Revalidate helper: invalidate AND explicitly refetch to ensure UI updates + await Promise.all([ + trpcUtils.workspace.getCurrent.invalidate(), + trpcUtils.billing.queryUsage.invalidate(), + trpcUtils.stripe.getBillingInfo.invalidate(), + trpcUtils.workspace.getCurrent.refetch(), + trpcUtils.billing.queryUsage.refetch(), + trpcUtils.stripe.getBillingInfo.refetch(), + ]); + router.refresh(); + toast.info("Subscription cancelled"); + }, + onError: (err) => { + toast.error(err.message); + }, + }); + + return ( + +
+ cancelSubscription.mutateAsync()} + trigger={(onClick) => ( + + )} + /> +
+
+ ); +}; diff --git a/apps/dashboard/app/(app)/[workspaceSlug]/settings/billing/components/current-plan-card.tsx b/apps/dashboard/app/(app)/[workspaceSlug]/settings/billing/components/current-plan-card.tsx new file mode 100644 index 0000000000..d813c77875 --- /dev/null +++ b/apps/dashboard/app/(app)/[workspaceSlug]/settings/billing/components/current-plan-card.tsx @@ -0,0 +1,65 @@ +"use client"; +import { formatNumber } from "@/lib/fmt"; +import { Button, SettingCard } from "@unkey/ui"; +import { useCallback } from "react"; + +type CurrentPlanCardProps = { + currentProduct?: { + name: string; + dollar: number; + quotas?: { requestsPerMonth: number }; + }; + onChangePlan?: () => void; +}; + +export const CurrentPlanCard = ({ currentProduct, onChangePlan }: CurrentPlanCardProps) => { + const handleChangePlan = useCallback(() => { + onChangePlan?.(); + }, [onChangePlan]); + return ( + Your active subscription plan
} + border="both" + className="w-full min-w-[200px]" + contentWidth="w-full" + > +
+
+ +
+ +
+ + ); +}; + +type ProductHelperProps = { + currentProduct?: { + name: string; + dollar: number; + quotas?: { requestsPerMonth: number }; + }; +}; +const ProductHelper: React.FC = ({ currentProduct }) => { + return ( +
+
+

{currentProduct?.name || "Free Plan"}

+ Active +
+
+ + {formatNumber(currentProduct?.quotas?.requestsPerMonth ?? 150000)} requests/month + + ${currentProduct?.dollar ?? 0}/mo +
+
+ ); +}; diff --git a/apps/dashboard/app/(app)/[workspaceSlug]/settings/billing/components/free-tier-alert.tsx b/apps/dashboard/app/(app)/[workspaceSlug]/settings/billing/components/free-tier-alert.tsx new file mode 100644 index 0000000000..77abf29d98 --- /dev/null +++ b/apps/dashboard/app/(app)/[workspaceSlug]/settings/billing/components/free-tier-alert.tsx @@ -0,0 +1,25 @@ +"use client"; +import { Empty } from "@unkey/ui"; +import Link from "next/link"; +import type React from "react"; + +export const FreeTierAlert: React.FC = () => { + return ( + + You are on the Free tier. + + The Free tier includes 150k requests of free usage. +
+ To unlock additional usage and add team members, upgrade to Pro.{" "} + + See Pricing + +
+
+ ); +}; diff --git a/apps/dashboard/app/(app)/[workspaceSlug]/settings/billing/components/plan-selection-modal.tsx b/apps/dashboard/app/(app)/[workspaceSlug]/settings/billing/components/plan-selection-modal.tsx new file mode 100644 index 0000000000..fd6f7b9cfb --- /dev/null +++ b/apps/dashboard/app/(app)/[workspaceSlug]/settings/billing/components/plan-selection-modal.tsx @@ -0,0 +1,266 @@ +"use client"; + +import { formatNumber } from "@/lib/fmt"; +import { trpc } from "@/lib/trpc/client"; +import { cn } from "@/lib/utils"; +import { Button, DialogContainer, toast } from "@unkey/ui"; +import { useRouter } from "next/navigation"; +import { useCallback, useEffect, useState } from "react"; + +type PlanSelectionModalProps = { + isOpen: boolean; + onOpenChange?: (open: boolean) => void; + products: Array<{ + id: string; + name: string; + priceId: string; + dollar: number; + quotas: { + requestsPerMonth: number; + }; + }>; + workspaceSlug: string; + currentProductId?: string; + isChangingPlan?: boolean; +}; + +export const PlanSelectionModal = ({ + isOpen, + onOpenChange, + products, + workspaceSlug, + currentProductId, + isChangingPlan = false, +}: PlanSelectionModalProps) => { + const [selectedProductId, setSelectedProductId] = useState( + currentProductId ?? null, + ); + const [isLoading, setIsLoading] = useState(false); + const [hasMounted, setHasMounted] = useState(false); + const router = useRouter(); + const trpcUtils = trpc.useUtils(); + + // Set hasMounted flag after initial mount to prevent hydration mismatch + useEffect(() => { + setHasMounted(true); + }, []); + + const handleOpenChange = useCallback( + (open: boolean) => { + onOpenChange?.(open); + }, + [onOpenChange], + ); + + const revalidateData = useCallback(async () => { + await Promise.all([ + trpcUtils.stripe.getBillingInfo.invalidate(), + trpcUtils.billing.queryUsage.invalidate(), + trpcUtils.workspace.getCurrent.invalidate(), + trpcUtils.workspace.getCurrent.refetch(), + ]); + }, [trpcUtils]); + + const createSubscription = trpc.stripe.createSubscription.useMutation({ + onSuccess: async () => { + handleOpenChange(false); + setIsLoading(false); + toast.success("Plan activated successfully!"); + await revalidateData(); + router.push(`/${workspaceSlug}/settings/billing`); + }, + onError: (err) => { + setIsLoading(false); + toast.error(err.message); + }, + }); + + const updateSubscription = trpc.stripe.updateSubscription.useMutation({ + onSuccess: async () => { + handleOpenChange(false); + setIsLoading(false); + toast.success("Plan changed successfully!"); + await revalidateData(); + }, + onError: (err) => { + setIsLoading(false); + toast.error(err.message); + }, + }); + + const handleSelectPlan = async () => { + if (!selectedProductId) { + return; + } + + setIsLoading(true); + if (isChangingPlan && currentProductId) { + // Update existing subscription + await updateSubscription.mutateAsync({ + oldProductId: currentProductId, + newProductId: selectedProductId, + }); + } else { + // Create new subscription + await createSubscription.mutateAsync({ + productId: selectedProductId, + }); + } + }; + + const handleSkip = async () => { + if (!isChangingPlan) { + await revalidateData(); + // Wait for workspace data to be refetched before navigation + toast.info("Payment method added - you can upgrade anytime from billing settings!"); + router.push(`/${workspaceSlug}/settings/billing`); + } + handleOpenChange(false); + }; + + const selectedProduct = products.find((p) => p.id === selectedProductId); + + // Don't render modal content until after hydration + if (!hasMounted) { + return ( + onOpenChange?.(open)} + title={isChangingPlan ? "Change Your Plan" : "Choose Your Plan"} + subTitle={ + isChangingPlan + ? "Select a new plan to switch to" + : "Select a plan to get started with your new payment method" + } + showCloseWarning={true} + onAttemptClose={() => {}} + footer={
} + > +
+
+
+
+
+
+
+ + ); + } + + return ( + + + +
+ } + > +
+
+ {/* Paid Plans */} + {products.map((product) => ( +
+ + ); +}; diff --git a/apps/dashboard/app/(app)/[workspaceSlug]/settings/billing/components/shell.tsx b/apps/dashboard/app/(app)/[workspaceSlug]/settings/billing/components/shell.tsx index d9e7c82115..68fe36098f 100644 --- a/apps/dashboard/app/(app)/[workspaceSlug]/settings/billing/components/shell.tsx +++ b/apps/dashboard/app/(app)/[workspaceSlug]/settings/billing/components/shell.tsx @@ -1,16 +1,10 @@ "use client"; import type { PropsWithChildren } from "react"; -interface ShellProps extends PropsWithChildren { - title?: string; -} -export const Shell = ({ children, title = "Billing Settings" }: ShellProps) => { +export const Shell = ({ children }: PropsWithChildren) => { return (
-
-

- {title} -

+
{children}
diff --git a/apps/dashboard/app/(app)/[workspaceSlug]/settings/billing/components/subscription-status.tsx b/apps/dashboard/app/(app)/[workspaceSlug]/settings/billing/components/subscription-status.tsx new file mode 100644 index 0000000000..813b2cf3a9 --- /dev/null +++ b/apps/dashboard/app/(app)/[workspaceSlug]/settings/billing/components/subscription-status.tsx @@ -0,0 +1,35 @@ +"use client"; +import { Button, SettingCard } from "@unkey/ui"; +import { useRouter } from "next/navigation"; +import type { Stripe } from "stripe"; + +export const SubscriptionStatus: React.FC<{ + status: Stripe.Subscription.Status; + workspaceSlug: string; +}> = (props) => { + const router = useRouter(); + + const statusList = ["incomplete", "incomplete_expired", "unpaid", "past_due"]; + + if (statusList.includes(props.status)) { + return ( + +
+ +
+
+ ); + } + return null; +}; diff --git a/apps/dashboard/app/(app)/[workspaceSlug]/settings/billing/components/usage.tsx b/apps/dashboard/app/(app)/[workspaceSlug]/settings/billing/components/usage.tsx index baab5ce9c2..816543eae2 100644 --- a/apps/dashboard/app/(app)/[workspaceSlug]/settings/billing/components/usage.tsx +++ b/apps/dashboard/app/(app)/[workspaceSlug]/settings/billing/components/usage.tsx @@ -1,7 +1,91 @@ +"use client"; import { formatNumber } from "@/lib/fmt"; +import { trpc } from "@/lib/trpc/client"; import { SettingCard } from "@unkey/ui"; -export const Usage: React.FC<{ current: number; max: number }> = ({ current, max }) => { +export const Usage: React.FC<{ + quota: number; +}> = ({ quota }) => { + const { + data: usage, + isLoading, + error, + refetch, + } = trpc.billing.queryUsage.useQuery(undefined, { + // Cache for 30 seconds to reduce unnecessary refetches + // TRPC automatically scopes by workspace via requireWorkspace middleware + staleTime: 30_000, // 30 seconds + }); + + if (isLoading) { + return ( + +
+
+
+
+ + ); + } + + if (error) { + return ( + +
+

Failed to load usage: {error.message}

+ +
+
+ ); + } + + if (!usage) { + return ( + +
+

No usage data available

+
+
+ ); + } + + // Safely extract and validate numeric values with fallbacks + const verifications = + typeof usage.billableVerifications === "number" && !Number.isNaN(usage.billableVerifications) + ? usage.billableVerifications + : 0; + const ratelimits = + typeof usage.billableRatelimits === "number" && !Number.isNaN(usage.billableRatelimits) + ? usage.billableRatelimits + : 0; + const current = verifications + ratelimits; + const max = quota; + const percent = max > 0 ? Math.round((current / max) * 100) : 0; + return ( = ({ current, max >

- {formatNumber(current)} / {formatNumber(max)} ({Math.round((current / max) * 100)}%) + {formatNumber(current)} / {formatNumber(max)} ({percent}%)

@@ -34,7 +118,7 @@ export const ProgressCircle: React.FC<{ const strokeWidth = 3; const normalizedRadius = radius - strokeWidth / 2; const circumference = normalizedRadius * 2 * Math.PI; - const offset = circumference - (safeValue / max) * circumference; + const offset = max > 0 ? circumference - (safeValue / max) * circumference : circumference; return ( <>
diff --git a/apps/dashboard/app/(app)/[workspaceSlug]/settings/billing/page.tsx b/apps/dashboard/app/(app)/[workspaceSlug]/settings/billing/page.tsx index ad1f83bcb1..8f98c000b2 100644 --- a/apps/dashboard/app/(app)/[workspaceSlug]/settings/billing/page.tsx +++ b/apps/dashboard/app/(app)/[workspaceSlug]/settings/billing/page.tsx @@ -1,76 +1,54 @@ -import { getAuth } from "@/lib/auth"; -import { clickhouse } from "@/lib/clickhouse"; -import { db } from "@/lib/db"; -import { stripeEnv } from "@/lib/env"; +"use client"; +import { PageLoading } from "@/components/dashboard/page-loading"; import { formatNumber } from "@/lib/fmt"; +import { trpc } from "@/lib/trpc/client"; +import { useWorkspace } from "@/providers/workspace-provider"; import { Button, Empty, Input, SettingCard } from "@unkey/ui"; import Link from "next/link"; -import { redirect } from "next/navigation"; -import { Suspense } from "react"; -import Stripe from "stripe"; import { WorkspaceNavbar } from "../workspace-navbar"; import { Client } from "./client"; import { Shell } from "./components/shell"; -export const dynamic = "force-dynamic"; +export default function BillingPage() { + const { workspace, isLoading: isWorkspaceLoading } = useWorkspace(); -export default async function BillingPage() { - const { orgId } = await getAuth(); + // Derive isLegacy from workspace data + const isLegacy = workspace?.subscriptions && Object.keys(workspace.subscriptions).length > 0; - const workspace = await db.query.workspaces.findFirst({ - where: (table, { and, eq, isNull }) => and(eq(table.orgId, orgId), isNull(table.deletedAtM)), - with: { - quotas: true, - }, + const { + data: usage, + isLoading: usageLoading, + isError, + error, + } = trpc.billing.queryUsage.useQuery(undefined, { + // Only enable query when workspace is loaded AND it's a legacy subscription + enabled: Boolean(workspace && isLegacy), }); - if (!workspace) { - return redirect("/new"); + // Derive loading state: loading if workspace is loading OR (if legacy, usage is loading) + const isLoading = isWorkspaceLoading || !workspace || (isLegacy && usageLoading); + + // Wait for workspace to load before proceeding + if (isLoading) { + return ; } - const e = stripeEnv(); - if (!e) { + + if (isError) { return ( -
- - - Stripe is not configured - - If you are selfhosting Unkey, you need to configure Stripe in your environment - variables. - - -
+ + Failed to load usage data + + {error?.message || + "There was an error loading your usage information. Please try again later."} + + ); } - - const stripe = new Stripe(e.STRIPE_SECRET_KEY, { - apiVersion: "2023-10-16", - typescript: true, - }); - - const startOfMonth = new Date(); - startOfMonth.setUTCDate(1); - startOfMonth.setUTCHours(0, 0, 0, 0); - - const year = startOfMonth.getUTCFullYear(); - const month = startOfMonth.getUTCMonth() + 1; - - const [usedVerifications, usedRatelimits] = await Promise.all([ - clickhouse.billing.billableVerifications({ - workspaceId: workspace.id, - year, - month, - }), - clickhouse.billing.billableRatelimits({ - workspaceId: workspace.id, - year, - month, - }), - ]); - - const isLegacy = workspace.subscriptions && Object.keys(workspace.subscriptions).length > 0; - if (isLegacy) { + // Fetch usage data for legacy display + const verifications = usage?.billableVerifications || 0; + const ratelimits = usage?.billableRatelimits || 0; + return ( @@ -81,7 +59,7 @@ export default async function BillingPage() { border="top" >
- +
- +
@@ -124,87 +102,6 @@ export default async function BillingPage() { ); } - const [products, subscription, hasPreviousSubscriptions] = await Promise.all([ - stripe.products - .list({ - active: true, - ids: e.STRIPE_PRODUCT_IDS_PRO, - limit: 100, - expand: ["data.default_price"], - }) - .then((res) => res.data.map(mapProduct).sort((a, b) => a.dollar - b.dollar)), - workspace.stripeSubscriptionId - ? await stripe.subscriptions.retrieve(workspace.stripeSubscriptionId) - : undefined, - - workspace.stripeCustomerId - ? await stripe.subscriptions - .list({ - customer: workspace.stripeCustomerId, - status: "canceled", - }) - .then((res) => res.data.length > 0) - : false, - ]); - - return ( - - - -
- -
- } - > - -
- ); + // For non-legacy workspaces, use the Client component with live data + return ; } - -const mapProduct = (p: Stripe.Product) => { - if (!p.default_price) { - throw new Error(`Product ${p.id} is missing default_price`); - } - - const price = typeof p.default_price === "string" ? null : (p.default_price as Stripe.Price); - - if (!price) { - throw new Error(`Product ${p.id} default_price must be expanded`); - } - - if (price.unit_amount === null || price.unit_amount === undefined) { - throw new Error(`Product ${p.id} price is missing unit_amount`); - } - - const quotaValue = Number.parseInt(p.metadata.quota_requests_per_month, 10); - - return { - id: p.id, - name: p.name, - priceId: price.id, - dollar: price.unit_amount / 100, - quotas: { - requestsPerMonth: quotaValue, - }, - }; -}; diff --git a/apps/dashboard/app/(app)/[workspaceSlug]/settings/billing/stripe/checkout/page.tsx b/apps/dashboard/app/(app)/[workspaceSlug]/settings/billing/stripe/checkout/page.tsx index 7986bd4a69..a797f4a3ed 100644 --- a/apps/dashboard/app/(app)/[workspaceSlug]/settings/billing/stripe/checkout/page.tsx +++ b/apps/dashboard/app/(app)/[workspaceSlug]/settings/billing/stripe/checkout/page.tsx @@ -1,9 +1,9 @@ import { getAuth } from "@/lib/auth"; import { db } from "@/lib/db"; -import { stripeEnv } from "@/lib/env"; +import { getStripeClient } from "@/lib/stripe"; import { Code, Empty } from "@unkey/ui"; import { redirect } from "next/navigation"; -import Stripe from "stripe"; +import type Stripe from "stripe"; export const dynamic = "force-dynamic"; @@ -16,8 +16,11 @@ export default async function StripeRedirect() { if (!ws) { return redirect("/new"); } - const e = stripeEnv(); - if (!e) { + + let stripe: Stripe; + try { + stripe = getStripeClient(); + } catch (_error) { return ( Stripe is not configured @@ -28,11 +31,6 @@ export default async function StripeRedirect() { ); } - const stripe = new Stripe(e.STRIPE_SECRET_KEY, { - apiVersion: "2023-10-16", - typescript: true, - }); - const baseUrl = process.env.VERCEL ? process.env.VERCEL_TARGET_ENV === "production" ? "https://app.unkey.com" diff --git a/apps/dashboard/app/(app)/[workspaceSlug]/settings/billing/stripe/portal/page.tsx b/apps/dashboard/app/(app)/[workspaceSlug]/settings/billing/stripe/portal/page.tsx index e0b76ce1a8..79c65b49b8 100644 --- a/apps/dashboard/app/(app)/[workspaceSlug]/settings/billing/stripe/portal/page.tsx +++ b/apps/dashboard/app/(app)/[workspaceSlug]/settings/billing/stripe/portal/page.tsx @@ -1,9 +1,9 @@ import { getAuth } from "@/lib/auth"; import { db } from "@/lib/db"; -import { stripeEnv } from "@/lib/env"; +import { getStripeClient } from "@/lib/stripe"; import { Code, Empty } from "@unkey/ui"; import { redirect } from "next/navigation"; -import Stripe from "stripe"; +import type Stripe from "stripe"; export const dynamic = "force-dynamic"; @@ -22,9 +22,10 @@ export default async function StripeRedirect() { return redirect("/new"); } - const e = stripeEnv(); - - if (!e) { + let stripe: Stripe; + try { + stripe = getStripeClient(); + } catch (_error) { return ( Stripe is not configured @@ -35,11 +36,6 @@ export default async function StripeRedirect() { ); } - const stripe = new Stripe(e.STRIPE_SECRET_KEY, { - apiVersion: "2023-10-16", - typescript: true, - }); - const baseUrl = process.env.VERCEL ? process.env.VERCEL_TARGET_ENV === "production" ? "https://app.unkey.com" diff --git a/apps/dashboard/app/(app)/[workspaceSlug]/settings/general/page.tsx b/apps/dashboard/app/(app)/[workspaceSlug]/settings/general/page.tsx index d7b6fa82ce..11cf15f778 100644 --- a/apps/dashboard/app/(app)/[workspaceSlug]/settings/general/page.tsx +++ b/apps/dashboard/app/(app)/[workspaceSlug]/settings/general/page.tsx @@ -16,10 +16,7 @@ export default function SettingsPage() {
-
-
- Workspace Settings -
+
{/* */} diff --git a/apps/dashboard/app/api/webhooks/stripe/route.ts b/apps/dashboard/app/api/webhooks/stripe/route.ts index b4a77cc088..ed329fef1c 100644 --- a/apps/dashboard/app/api/webhooks/stripe/route.ts +++ b/apps/dashboard/app/api/webhooks/stripe/route.ts @@ -2,7 +2,8 @@ import { insertAuditLogs } from "@/lib/audit"; import { db, eq, schema } from "@/lib/db"; import { stripeEnv } from "@/lib/env"; import { freeTierQuotas } from "@/lib/quotas"; -import Stripe from "stripe"; +import { getStripeClient } from "@/lib/stripe"; +import type Stripe from "stripe"; export const runtime = "nodejs"; @@ -20,17 +21,7 @@ export const POST = async (req: Request): Promise => { ); } - const stripeSecretKey = stripeEnv()?.STRIPE_SECRET_KEY; - if (!stripeSecretKey) { - throw new Error( - "STRIPE_SECRET_KEY environment variable is not set. This is required for Stripe API operations.", - ); - } - - const stripe = new Stripe(stripeSecretKey, { - apiVersion: "2023-10-16", - typescript: true, - }); + const stripe: Stripe = getStripeClient(); const event = stripe.webhooks.constructEvent( await req.text(), diff --git a/apps/dashboard/app/new/hooks/use-workspace-step.tsx b/apps/dashboard/app/new/hooks/use-workspace-step.tsx index 3aa41e51ea..455a27bf51 100644 --- a/apps/dashboard/app/new/hooks/use-workspace-step.tsx +++ b/apps/dashboard/app/new/hooks/use-workspace-step.tsx @@ -71,6 +71,7 @@ export const useWorkspaceStep = (props: Props): OnboardingStep => { await utils.workspace.getCurrent.invalidate(); await utils.api.invalidate(); await utils.ratelimit.invalidate(); + await utils.stripe.invalidate(); // Force a router refresh to ensure the server-side layout // re-renders with the new session context and fresh workspace data router.refresh(); diff --git a/apps/dashboard/app/success/client.tsx b/apps/dashboard/app/success/client.tsx index ab25dcfd00..1575d75485 100644 --- a/apps/dashboard/app/success/client.tsx +++ b/apps/dashboard/app/success/client.tsx @@ -1,19 +1,63 @@ "use client"; +import dynamic from "next/dynamic"; import { useRouter } from "next/navigation"; -import { useEffect } from "react"; +import { useEffect, useState } from "react"; -export function SuccessClient({ workSpaceSlug }: { workSpaceSlug?: string }) { +const PlanSelectionModal = dynamic( + () => + import("../(app)/[workspaceSlug]/settings/billing/components/plan-selection-modal").then( + (mod) => ({ + default: mod.PlanSelectionModal, + }), + ), + { + ssr: false, + loading: () => null, + }, +); + +type Props = { + workSpaceSlug?: string; + showPlanSelection?: boolean; + products?: Array<{ + id: string; + name: string; + priceId: string; + dollar: number; + quotas: { + requestsPerMonth: number; + }; + }>; +}; + +export function SuccessClient({ workSpaceSlug, showPlanSelection, products }: Props) { const router = useRouter(); + const [showModal, setShowModal] = useState(!!(showPlanSelection && products && workSpaceSlug)); useEffect(() => { + // If showing modal, don't redirect + if (showPlanSelection && products && workSpaceSlug) { + return; + } + + // Redirect based on workspace availability if (workSpaceSlug) { router.push(`/${workSpaceSlug}/settings/billing`); } else { - // Redirect to root when no workspace slug is available - // This will typically redirect to workspace selection or onboarding router.push("/"); } - }, [router, workSpaceSlug]); + }, [router, workSpaceSlug, showPlanSelection, products]); + + if (showPlanSelection && products && workSpaceSlug) { + return ( + + ); + } return <>; } diff --git a/apps/dashboard/app/success/page.tsx b/apps/dashboard/app/success/page.tsx index c0e6fd23a7..fd4022417e 100644 --- a/apps/dashboard/app/success/page.tsx +++ b/apps/dashboard/app/success/page.tsx @@ -1,119 +1,275 @@ -import { db, eq, schema } from "@/lib/db"; -import { stripeEnv } from "@/lib/env"; +"use client"; + +import { PageLoading } from "@/components/dashboard/page-loading"; +import { trpc } from "@/lib/trpc/client"; import { Empty } from "@unkey/ui"; -import { redirect } from "next/navigation"; -import Stripe from "stripe"; +import { useSearchParams } from "next/navigation"; +import { Suspense, useEffect, useState } from "react"; import { SuccessClient } from "./client"; -type Props = { - searchParams: { - session_id?: string; - }; +type ProcessedData = { + workspaceSlug?: string; + showPlanSelection?: boolean; + products?: Array<{ + id: string; + name: string; + priceId: string; + dollar: number; + quotas: { + requestsPerMonth: number; + }; + }>; }; -export default async function SuccessPage(props: Props) { - // If no session_id, just redirect back to billing - // This will make a user login if they are not logged in - // This will also redirect to the billing page if the user is logged in - if (!props.searchParams.session_id) { - return ; - } +function SuccessContent() { + const searchParams = useSearchParams(); + const sessionId = searchParams?.get("session_id") ?? null; - // Process the Stripe session and update workspace - const e = stripeEnv(); - if (!e) { - return ( - - Stripe is not configured - - If you are selfhosting Unkey, you need to configure Stripe in your environment variables. - - - ); - } + const [processedData, setProcessedData] = useState({}); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); - const stripe = new Stripe(e.STRIPE_SECRET_KEY, { - apiVersion: "2023-10-16", - typescript: true, - }); + const updateCustomerMutation = trpc.stripe.updateCustomer.useMutation(); + const updateWorkspaceStripeCustomerMutation = + trpc.stripe.updateWorkspaceStripeCustomer.useMutation(); - try { - const session = await stripe.checkout.sessions.retrieve(props.searchParams.session_id); + const trpcUtils = trpc.useUtils(); - if (!session) { - console.warn("Stripe session not found"); - return redirect("/auth/sign-in"); - } + useEffect(() => { + // Track if component is still mounted to prevent state updates after unmount + let isMounted = true; - const workspaceReference = session.client_reference_id; - if (!workspaceReference) { - console.warn("Stripe session client_reference_id not found"); - return ; + if (!sessionId) { + setProcessedData({}); + setLoading(false); + return; } - const ws = await db.query.workspaces.findFirst({ - where: (table, { and, eq, isNull }) => - and(eq(table.id, workspaceReference), isNull(table.deletedAtM)), - }); + const processStripeSession = async ( + updateCustomerFn: typeof updateCustomerMutation.mutateAsync, + updateWorkspaceFn: typeof updateWorkspaceStripeCustomerMutation.mutateAsync, + ) => { + try { + if (!isMounted) { + return; + } + setLoading(true); - if (!ws) { - console.warn("Workspace not found"); - return ; - } + // Get checkout session + const sessionResponse = await trpcUtils.stripe.getCheckoutSession.fetch({ + sessionId: sessionId, + }); - const customer = await stripe.customers.retrieve(session.customer as string); - if (!customer || !session.setup_intent) { - console.warn("Stripe customer not found"); - return ; - } + if (!sessionResponse) { + console.warn("Stripe session not found"); + if (!isMounted) { + return; + } + setProcessedData({}); + setLoading(false); + return; + } - const setupIntent = await stripe.setupIntents.retrieve(session.setup_intent.toString()); + const workspaceId = sessionResponse.client_reference_id; + if (!workspaceId) { + console.warn("Stripe session client_reference_id not found"); + if (!isMounted) { + return; + } + setProcessedData({}); + setLoading(false); + return; + } - if (!setupIntent.payment_method) { - console.warn("Stripe payment method not found"); - return ; - } + // Get workspace details to get the slug + const workspace = await trpcUtils.workspace.getById.fetch({ + workspaceId: workspaceId, + }); - // Update customer with default payment method - await stripe.customers.update(customer.id, { - invoice_settings: { - default_payment_method: setupIntent.payment_method.toString(), - }, - }); - - // Update workspace with stripe customer ID - try { - await db - .update(schema.workspaces) - .set({ - stripeCustomerId: customer.id, - }) - .where(eq(schema.workspaces.id, ws.id)); - } catch (error) { - console.error("Failed to update workspace:", error); - return ( - - Failed to update workspace - - There was an error updating your workspace with payment information. Please contact - support@unkey.dev. - - - ); - } + if (!isMounted) { + return; + } + + // Check if we have customer and setup intent + if (!sessionResponse.customer || !sessionResponse.setup_intent) { + console.warn("Stripe customer or setup intent not found"); + if (!isMounted) { + return; + } + setProcessedData({ workspaceSlug: workspace.slug }); + setLoading(false); + return; + } + + // Get customer details + const customer = await trpcUtils.stripe.getCustomer.fetch({ + customerId: sessionResponse.customer, + }); + + if (!isMounted) { + return; + } + + // Get setup intent details + const setupIntent = await trpcUtils.stripe.getSetupIntent.fetch({ + setupIntentId: sessionResponse.setup_intent, + }); + + if (!isMounted) { + return; + } - // Success - redirect to billing page with workspace slug - return ; - } catch (error) { - console.error("Error processing Stripe session:", error); + if (!customer || !setupIntent?.payment_method) { + console.warn("Customer or payment method not found"); + if (!isMounted) { + return; + } + setProcessedData({ workspaceSlug: workspace.slug }); + setLoading(false); + return; + } + + // Update customer with default payment method + try { + await updateCustomerFn({ + customerId: customer.id, + paymentMethod: setupIntent.payment_method, + }); + + if (!isMounted) { + return; + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : "Unknown error"; + console.error("Failed to update customer with payment method:", { + error: errorMessage, + customerId: "redacted", // Don't log PII + hasPaymentMethod: !!setupIntent.payment_method, + }); + if (!isMounted) { + return; + } + setError(`Failed to set up payment method: ${errorMessage}`); + setLoading(false); + return; + } + + // Update workspace with stripe customer ID + try { + await updateWorkspaceFn({ + stripeCustomerId: customer.id, + }); + + if (!isMounted) { + return; + } + + await trpcUtils.workspace.invalidate(); + await trpcUtils.stripe.invalidate(); + await trpcUtils.billing.invalidate(); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : "Unknown error"; + console.error("Failed to update workspace with payment method:", { error: errorMessage }); + if (!isMounted) { + return; + } + setError("Failed to update workspace with payment information"); + setLoading(false); + return; + } + + // Check if this is a first-time user by getting billing info + try { + const billingInfo = await trpcUtils.stripe.getBillingInfo.fetch(); + + if (!isMounted) { + return; + } + + const isFirstTimeUser = !billingInfo.hasPreviousSubscriptions; + + if (isFirstTimeUser) { + // Use products from billingInfo instead of making a redundant fetch + if (billingInfo.products && billingInfo.products.length > 0) { + setProcessedData({ + workspaceSlug: workspace.slug, + showPlanSelection: true, + products: billingInfo.products, + }); + } else { + // Fall back to regular billing page if products are empty or undefined + setProcessedData({ workspaceSlug: workspace.slug }); + } + } else { + setProcessedData({ workspaceSlug: workspace.slug }); + } + } catch (error) { + console.error("Failed to get billing info:", error); + // Fall back to regular billing page + if (!isMounted) { + return; + } + setProcessedData({ workspaceSlug: workspace.slug }); + } + + if (!isMounted) { + return; + } + setLoading(false); + } catch (error) { + console.error("Error processing Stripe session:", error); + if (!isMounted) { + return; + } + setError("Failed to process payment session"); + setLoading(false); + } + }; + + processStripeSession( + updateCustomerMutation.mutateAsync, + updateWorkspaceStripeCustomerMutation.mutateAsync, + ); + + // Cleanup function to prevent state updates after unmount + return () => { + isMounted = false; + }; + }, [ + sessionId, + trpcUtils, + updateCustomerMutation.mutateAsync, + updateWorkspaceStripeCustomerMutation.mutateAsync, + ]); + + if (loading) { + return ; + } + + if (error) { return ( - Failed to update workspace + Payment Processing Error - There was an error updating your workspace with payment information. Please contact - support@unkey.dev. + {error}. Please contact support@unkey.dev if this issue persists. ); } + + return ( + + ); +} + +export default function SuccessPage() { + return ( + }> + + + ); } diff --git a/apps/dashboard/components/dashboard/page-loading.tsx b/apps/dashboard/components/dashboard/page-loading.tsx new file mode 100644 index 0000000000..551a6064f2 --- /dev/null +++ b/apps/dashboard/components/dashboard/page-loading.tsx @@ -0,0 +1,26 @@ +import { Loading } from "@unkey/ui"; + +type Props = { + message?: string; + spinnerType?: "dots" | "spinner"; + size?: number; + className?: string; +}; + +const PageLoading = ({ + message = "Loading...", + spinnerType = "spinner", + size = 24, + className, +}: Props) => { + return ( +
+
+ +

{message}

+
+
+ ); +}; + +export { PageLoading }; diff --git a/apps/dashboard/lib/stripe.ts b/apps/dashboard/lib/stripe.ts new file mode 100644 index 0000000000..106d06cbd2 --- /dev/null +++ b/apps/dashboard/lib/stripe.ts @@ -0,0 +1,31 @@ +import { TRPCError } from "@trpc/server"; +import Stripe from "stripe"; +import { stripeEnv } from "./env"; + +let stripeClient: Stripe | null = null; + +/** + * Get a singleton Stripe client instance. + * Throws an error if Stripe is not configured. + * The client is cached and reused across requests. + */ +export function getStripeClient(): Stripe { + if (stripeClient) { + return stripeClient; + } + + const e = stripeEnv(); + if (!e) { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Stripe is not configured", + }); + } + + stripeClient = new Stripe(e.STRIPE_SECRET_KEY, { + apiVersion: "2023-10-16", + typescript: true, + }); + + return stripeClient; +} diff --git a/apps/dashboard/lib/trpc/routers/billing/query-usage/index.ts b/apps/dashboard/lib/trpc/routers/billing/query-usage/index.ts index e0cd9711e2..f9b5387b16 100644 --- a/apps/dashboard/lib/trpc/routers/billing/query-usage/index.ts +++ b/apps/dashboard/lib/trpc/routers/billing/query-usage/index.ts @@ -33,8 +33,8 @@ export const queryUsage = t.procedure } return { - billableRatelimits, - billableVerifications, + billableRatelimits: billableRatelimits, + billableVerifications: billableVerifications, billableTotal: billableRatelimits + billableVerifications, }; }); diff --git a/apps/dashboard/lib/trpc/routers/index.ts b/apps/dashboard/lib/trpc/routers/index.ts index 5427d128d4..65edc1eb38 100644 --- a/apps/dashboard/lib/trpc/routers/index.ts +++ b/apps/dashboard/lib/trpc/routers/index.ts @@ -116,12 +116,20 @@ import { rootKeysLlmSearch } from "./settings/root-keys/llm-search"; import { queryRootKeys } from "./settings/root-keys/query"; import { cancelSubscription } from "./stripe/cancelSubscription"; import { createSubscription } from "./stripe/createSubscription"; +import { getBillingInfo } from "./stripe/getBillingInfo"; +import { getCheckoutSession } from "./stripe/getCheckoutSession"; +import { getCustomer } from "./stripe/getCustomer"; +import { getProducts } from "./stripe/getProducts"; +import { getSetupIntent } from "./stripe/getSetupIntent"; import { uncancelSubscription } from "./stripe/uncancelSubscription"; +import { updateCustomer } from "./stripe/updateCustomer"; import { updateSubscription } from "./stripe/updateSubscription"; +import { updateWorkspaceStripeCustomer } from "./stripe/updateWorkspace"; import { getCurrentUser, listMemberships, switchOrg } from "./user"; import { vercelRouter } from "./vercel"; import { changeWorkspaceName } from "./workspace/changeName"; import { createWorkspace } from "./workspace/create"; +import { getWorkspaceById } from "./workspace/getById"; import { getCurrentWorkspace } from "./workspace/getCurrent"; import { onboardingKeyCreation } from "./workspace/onboarding"; import { optWorkspaceIntoBeta } from "./workspace/optIntoBeta"; @@ -203,6 +211,7 @@ export const router = t.router({ workspace: t.router({ create: createWorkspace, getCurrent: getCurrentWorkspace, + getById: getWorkspaceById, updateName: changeWorkspaceName, optIntoBeta: optWorkspaceIntoBeta, onboarding: onboardingKeyCreation, @@ -212,6 +221,13 @@ export const router = t.router({ updateSubscription, cancelSubscription, uncancelSubscription, + getBillingInfo, + updateCustomer, + getCheckoutSession, + getCustomer, + getProducts, + getSetupIntent, + updateWorkspaceStripeCustomer, }), vercel: vercelRouter, plain: t.router({ diff --git a/apps/dashboard/lib/trpc/routers/stripe/cancelSubscription.ts b/apps/dashboard/lib/trpc/routers/stripe/cancelSubscription.ts index 7504d87396..c7de050020 100644 --- a/apps/dashboard/lib/trpc/routers/stripe/cancelSubscription.ts +++ b/apps/dashboard/lib/trpc/routers/stripe/cancelSubscription.ts @@ -1,17 +1,11 @@ import { auth } from "@/lib/auth/server"; -import { stripeEnv } from "@/lib/env"; +import { getStripeClient } from "@/lib/stripe"; import { TRPCError } from "@trpc/server"; -import Stripe from "stripe"; import { requireUser, requireWorkspace, t } from "../../trpc"; export const cancelSubscription = t.procedure .use(requireUser) .use(requireWorkspace) .mutation(async ({ ctx }) => { - const e = stripeEnv(); - if (!e) { - throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Stripe is not set up" }); - } - const memberships = await auth.getOrganizationMemberList(ctx.workspace.orgId).catch((err) => { console.error(err); throw new TRPCError({ @@ -27,10 +21,7 @@ export const cancelSubscription = t.procedure }); } - const stripe = new Stripe(e.STRIPE_SECRET_KEY, { - apiVersion: "2023-10-16", - typescript: true, - }); + const stripe = getStripeClient(); if (!ctx.workspace.stripeCustomerId) { throw new TRPCError({ diff --git a/apps/dashboard/lib/trpc/routers/stripe/createSubscription.ts b/apps/dashboard/lib/trpc/routers/stripe/createSubscription.ts index a31967229e..2b8766d72d 100644 --- a/apps/dashboard/lib/trpc/routers/stripe/createSubscription.ts +++ b/apps/dashboard/lib/trpc/routers/stripe/createSubscription.ts @@ -1,11 +1,12 @@ import { insertAuditLogs } from "@/lib/audit"; import { db, eq, schema } from "@/lib/db"; import { stripeEnv } from "@/lib/env"; +import { getStripeClient } from "@/lib/stripe"; import { invalidateWorkspaceCache } from "@/lib/workspace-cache"; import { TRPCError } from "@trpc/server"; -import Stripe from "stripe"; import { z } from "zod"; import { requireUser, requireWorkspace, t } from "../../trpc"; +import { clearWorkspaceCache } from "../workspace/getCurrent"; export const createSubscription = t.procedure .use(requireUser) .use(requireWorkspace) @@ -15,6 +16,7 @@ export const createSubscription = t.procedure }), ) .mutation(async ({ ctx, input }) => { + const stripe = getStripeClient(); const e = stripeEnv(); if (!e) { throw new TRPCError({ @@ -23,11 +25,6 @@ export const createSubscription = t.procedure }); } - const stripe = new Stripe(e.STRIPE_SECRET_KEY, { - apiVersion: "2023-10-16", - typescript: true, - }); - const product = await stripe.products.retrieve(input.productId); if (!product) { @@ -119,4 +116,7 @@ export const createSubscription = t.procedure // Invalidate workspace cache after subscription creation await invalidateWorkspaceCache(ctx.tenant.id); + + // Also clear the tRPC workspace cache to ensure fresh data on next request + clearWorkspaceCache(ctx.tenant.id); }); diff --git a/apps/dashboard/lib/trpc/routers/stripe/getBillingInfo.ts b/apps/dashboard/lib/trpc/routers/stripe/getBillingInfo.ts new file mode 100644 index 0000000000..1c19c9aa57 --- /dev/null +++ b/apps/dashboard/lib/trpc/routers/stripe/getBillingInfo.ts @@ -0,0 +1,82 @@ +import { stripeEnv } from "@/lib/env"; +import { getStripeClient } from "@/lib/stripe"; +import { ratelimit, requireWorkspace, t, withRatelimit } from "@/lib/trpc/trpc"; +import { TRPCError } from "@trpc/server"; +import { z } from "zod"; +import { mapProduct } from "../utils/stripe"; + +const productSchema = z.object({ + id: z.string(), + name: z.string(), + priceId: z.string(), + dollar: z.number(), + quotas: z.object({ + requestsPerMonth: z.number(), + }), +}); + +const subscriptionSchema = z + .object({ + id: z.string(), + status: z.string(), + cancelAt: z.number().optional(), + }) + .optional(); + +const billingInfoSchema = z.object({ + products: z.array(productSchema), + subscription: subscriptionSchema, + hasPreviousSubscriptions: z.boolean(), + currentProductId: z.string().optional(), +}); + +export const getBillingInfo = t.procedure + .use(requireWorkspace) + .use(withRatelimit(ratelimit.read)) + .output(billingInfoSchema) + .query(async ({ ctx }) => { + const stripe = getStripeClient(); + const e = stripeEnv(); + if (!e) { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Stripe is not configured", + }); + } + + const [products, subscription, hasPreviousSubscriptions] = await Promise.all([ + stripe.products + .list({ + active: true, + ids: e.STRIPE_PRODUCT_IDS_PRO, + limit: 100, + expand: ["data.default_price"], + }) + .then((res) => res.data.map(mapProduct).sort((a, b) => a.dollar - b.dollar)), + ctx.workspace.stripeSubscriptionId + ? await stripe.subscriptions.retrieve(ctx.workspace.stripeSubscriptionId) + : undefined, + + ctx.workspace.stripeCustomerId + ? await stripe.subscriptions + .list({ + customer: ctx.workspace.stripeCustomerId, + status: "canceled", + }) + .then((res) => res.data.length > 0) + : false, + ]); + + return { + products, + subscription: subscription + ? { + id: subscription.id, + status: subscription.status, + cancelAt: subscription.cancel_at ? subscription.cancel_at * 1000 : undefined, + } + : undefined, + hasPreviousSubscriptions, + currentProductId: subscription?.items.data.at(0)?.plan.product?.toString() ?? undefined, + }; + }); diff --git a/apps/dashboard/lib/trpc/routers/stripe/getCheckoutSession.ts b/apps/dashboard/lib/trpc/routers/stripe/getCheckoutSession.ts new file mode 100644 index 0000000000..e5101b2288 --- /dev/null +++ b/apps/dashboard/lib/trpc/routers/stripe/getCheckoutSession.ts @@ -0,0 +1,58 @@ +import { getStripeClient } from "@/lib/stripe"; +import { ratelimit, requireWorkspace, t, withRatelimit } from "@/lib/trpc/trpc"; +import { TRPCError } from "@trpc/server"; +import Stripe from "stripe"; +import { z } from "zod"; + +const checkoutSessionSchema = z.object({ + id: z.string(), + customer: z.string().nullable(), + client_reference_id: z.string().nullable(), + setup_intent: z.string().nullable(), + payment_status: z.string(), + status: z.string(), +}); + +export const getCheckoutSession = t.procedure + .use(requireWorkspace) + .use(withRatelimit(ratelimit.read)) + .input( + z.object({ + sessionId: z.string(), + }), + ) + .output(checkoutSessionSchema) + .query(async ({ input }) => { + const stripe = getStripeClient(); + + try { + const session = await stripe.checkout.sessions.retrieve(input.sessionId); + + if (!session) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Checkout session not found", + }); + } + + return { + id: session.id, + customer: session.customer ? session.customer.toString() : null, + client_reference_id: session.client_reference_id, + setup_intent: session.setup_intent ? session.setup_intent.toString() : null, + payment_status: session.payment_status, + status: session.status || "", + }; + } catch (error) { + if (error instanceof Stripe.errors.StripeError) { + throw new TRPCError({ + code: "NOT_FOUND", + message: `Stripe error: ${error.message}`, + }); + } + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Failed to retrieve checkout session", + }); + } + }); diff --git a/apps/dashboard/lib/trpc/routers/stripe/getCustomer.ts b/apps/dashboard/lib/trpc/routers/stripe/getCustomer.ts new file mode 100644 index 0000000000..3ffce3d857 --- /dev/null +++ b/apps/dashboard/lib/trpc/routers/stripe/getCustomer.ts @@ -0,0 +1,80 @@ +import { getStripeClient } from "@/lib/stripe"; +import { handleStripeError } from "@/lib/trpc/routers/utils/stripe"; +import { ratelimit, requireWorkspace, t, withRatelimit } from "@/lib/trpc/trpc"; +import { TRPCError } from "@trpc/server"; +import Stripe from "stripe"; +import { z } from "zod"; + +const customerSchema = z.object({ + id: z.string(), + email: z.string().nullable(), + name: z.string().nullable(), + invoice_settings: z + .object({ + default_payment_method: z.string().nullable(), + }) + .nullable(), +}); + +export const getCustomer = t.procedure + .use(requireWorkspace) + .use(withRatelimit(ratelimit.read)) + .input( + z.object({ + customerId: z.string(), + }), + ) + .output(customerSchema) + .query(async ({ input }) => { + const stripe = getStripeClient(); + + try { + const customer = await stripe.customers.retrieve(input.customerId); + + if (customer.deleted) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Customer has been deleted", + }); + } + + // Extract default payment method ID, handling both string and expanded object + let defaultPaymentMethodId: string | null = null; + if (customer.invoice_settings?.default_payment_method) { + const paymentMethod = customer.invoice_settings.default_payment_method; + if (typeof paymentMethod === "string") { + defaultPaymentMethodId = paymentMethod; + } else if (typeof paymentMethod === "object" && paymentMethod.id) { + // Expanded PaymentMethod object + defaultPaymentMethodId = paymentMethod.id; + } + } + + return { + id: customer.id, + email: customer.email, + name: customer.name ?? null, + invoice_settings: customer.invoice_settings + ? { + default_payment_method: defaultPaymentMethodId, + } + : null, + }; + } catch (error) { + // If error is already a TRPCError, rethrow unchanged + if (error instanceof TRPCError) { + throw error; + } + + // Map Stripe errors to appropriate TRPC error codes + if (error instanceof Stripe.errors.StripeError) { + handleStripeError(error); + } + + // Handle unknown errors + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Failed to retrieve customer", + }); + } + }); diff --git a/apps/dashboard/lib/trpc/routers/stripe/getProducts.ts b/apps/dashboard/lib/trpc/routers/stripe/getProducts.ts new file mode 100644 index 0000000000..59df3eec99 --- /dev/null +++ b/apps/dashboard/lib/trpc/routers/stripe/getProducts.ts @@ -0,0 +1,42 @@ +import { stripeEnv } from "@/lib/env"; +import { getStripeClient } from "@/lib/stripe"; +import { ratelimit, requireWorkspace, t, withRatelimit } from "@/lib/trpc/trpc"; +import { TRPCError } from "@trpc/server"; +import { z } from "zod"; +import { mapProduct } from "../utils/stripe"; + +const productSchema = z.object({ + id: z.string(), + name: z.string(), + priceId: z.string(), + dollar: z.number(), + quotas: z.object({ + requestsPerMonth: z.number(), + }), +}); + +export const getProducts = t.procedure + .use(requireWorkspace) + .use(withRatelimit(ratelimit.read)) + .output(z.array(productSchema)) + .query(async () => { + const stripe = getStripeClient(); + const e = stripeEnv(); + if (!e) { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Stripe is not configured", + }); + } + + const products = await stripe.products + .list({ + active: true, + ids: e.STRIPE_PRODUCT_IDS_PRO, + limit: 100, + expand: ["data.default_price"], + }) + .then((res) => res.data.map(mapProduct).sort((a, b) => a.dollar - b.dollar)); + + return products; + }); diff --git a/apps/dashboard/lib/trpc/routers/stripe/getSetupIntent.ts b/apps/dashboard/lib/trpc/routers/stripe/getSetupIntent.ts new file mode 100644 index 0000000000..182b08bc8e --- /dev/null +++ b/apps/dashboard/lib/trpc/routers/stripe/getSetupIntent.ts @@ -0,0 +1,69 @@ +import { getStripeClient } from "@/lib/stripe"; +import { handleStripeError } from "@/lib/trpc/routers/utils/stripe"; +import { ratelimit, requireWorkspace, t, withRatelimit } from "@/lib/trpc/trpc"; +import { TRPCError } from "@trpc/server"; +import Stripe from "stripe"; +import { z } from "zod"; + +const setupIntentSchema = z.object({ + id: z.string(), + client_secret: z.string().nullable(), + payment_method: z.string().nullable(), + status: z.string(), + usage: z.string(), +}); + +export const getSetupIntent = t.procedure + .use(requireWorkspace) + .use(withRatelimit(ratelimit.read)) + .input( + z.object({ + setupIntentId: z.string(), + }), + ) + .output(setupIntentSchema) + .query(async ({ input }) => { + const stripe = getStripeClient(); + + try { + const setupIntent = await stripe.setupIntents.retrieve(input.setupIntentId); + + // Extract payment method ID, handling both string and expanded object + let paymentMethodId: string | null = null; + if (setupIntent.payment_method) { + if (typeof setupIntent.payment_method === "string") { + paymentMethodId = setupIntent.payment_method; + } else if ( + typeof setupIntent.payment_method === "object" && + setupIntent.payment_method.id + ) { + // Expanded PaymentMethod object + paymentMethodId = setupIntent.payment_method.id; + } + } + + return { + id: setupIntent.id, + client_secret: setupIntent.client_secret, + payment_method: paymentMethodId, + status: setupIntent.status, + usage: setupIntent.usage, + }; + } catch (error) { + // If error is already a TRPCError, rethrow unchanged + if (error instanceof TRPCError) { + throw error; + } + + // Map Stripe errors to appropriate TRPC error codes + if (error instanceof Stripe.errors.StripeError) { + handleStripeError(error); + } + + // Handle unknown errors + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Failed to retrieve setup intent", + }); + } + }); diff --git a/apps/dashboard/lib/trpc/routers/stripe/uncancelSubscription.ts b/apps/dashboard/lib/trpc/routers/stripe/uncancelSubscription.ts index 8f9f12ba04..1e36545b2f 100644 --- a/apps/dashboard/lib/trpc/routers/stripe/uncancelSubscription.ts +++ b/apps/dashboard/lib/trpc/routers/stripe/uncancelSubscription.ts @@ -1,20 +1,11 @@ -import { stripeEnv } from "@/lib/env"; +import { getStripeClient } from "@/lib/stripe"; import { TRPCError } from "@trpc/server"; -import Stripe from "stripe"; import { requireUser, requireWorkspace, t } from "../../trpc"; export const uncancelSubscription = t.procedure .use(requireUser) .use(requireWorkspace) .mutation(async ({ ctx }) => { - const e = stripeEnv(); - if (!e) { - throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Stripe is not set up" }); - } - - const stripe = new Stripe(e.STRIPE_SECRET_KEY, { - apiVersion: "2023-10-16", - typescript: true, - }); + const stripe = getStripeClient(); if (!ctx.workspace.stripeCustomerId) { throw new TRPCError({ diff --git a/apps/dashboard/lib/trpc/routers/stripe/updateCustomer.ts b/apps/dashboard/lib/trpc/routers/stripe/updateCustomer.ts new file mode 100644 index 0000000000..ae38c4fe73 --- /dev/null +++ b/apps/dashboard/lib/trpc/routers/stripe/updateCustomer.ts @@ -0,0 +1,59 @@ +import { getStripeClient } from "@/lib/stripe"; +import { handleStripeError } from "@/lib/trpc/routers/utils/stripe"; +import { ratelimit, requireWorkspace, t, withRatelimit } from "@/lib/trpc/trpc"; +import { TRPCError } from "@trpc/server"; +import Stripe from "stripe"; +import { z } from "zod"; + +const updateCustomerInputSchema = z.object({ + customerId: z.string(), + paymentMethod: z.string(), +}); + +const customerSchema = z.object({ + id: z.string(), +}); + +export const updateCustomer = t.procedure + .use(requireWorkspace) + .use(withRatelimit(ratelimit.update)) + .input(updateCustomerInputSchema) + .output(customerSchema) + .mutation(async ({ input }) => { + const stripe = getStripeClient(); + + try { + const customer = await stripe.customers.update(input.customerId, { + invoice_settings: { + default_payment_method: input.paymentMethod, + }, + }); + + if (!customer) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Customer not found or has been deleted", + }); + } + + return { + id: customer.id, + }; + } catch (error) { + // If error is already a TRPCError, rethrow unchanged + if (error instanceof TRPCError) { + throw error; + } + + // Handle Stripe errors + if (error instanceof Stripe.errors.StripeError) { + handleStripeError(error); + } + + // Handle unknown errors + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Failed to update customer", + }); + } + }); diff --git a/apps/dashboard/lib/trpc/routers/stripe/updateSubscription.ts b/apps/dashboard/lib/trpc/routers/stripe/updateSubscription.ts index 82a3b3faf4..d96d200aab 100644 --- a/apps/dashboard/lib/trpc/routers/stripe/updateSubscription.ts +++ b/apps/dashboard/lib/trpc/routers/stripe/updateSubscription.ts @@ -1,9 +1,8 @@ import { insertAuditLogs } from "@/lib/audit"; import { db, eq, schema } from "@/lib/db"; -import { stripeEnv } from "@/lib/env"; +import { getStripeClient } from "@/lib/stripe"; import { invalidateWorkspaceCache } from "@/lib/workspace-cache"; import { TRPCError } from "@trpc/server"; -import Stripe from "stripe"; import { z } from "zod"; import { requireUser, requireWorkspace, t } from "../../trpc"; export const updateSubscription = t.procedure @@ -16,18 +15,7 @@ export const updateSubscription = t.procedure }), ) .mutation(async ({ ctx, input }) => { - const e = stripeEnv(); - if (!e) { - throw new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: "Stripe is not set up", - }); - } - - const stripe = new Stripe(e.STRIPE_SECRET_KEY, { - apiVersion: "2023-10-16", - typescript: true, - }); + const stripe = getStripeClient(); if (!ctx.workspace.stripeCustomerId) { throw new TRPCError({ diff --git a/apps/dashboard/lib/trpc/routers/stripe/updateWorkspace.ts b/apps/dashboard/lib/trpc/routers/stripe/updateWorkspace.ts new file mode 100644 index 0000000000..44545188c6 --- /dev/null +++ b/apps/dashboard/lib/trpc/routers/stripe/updateWorkspace.ts @@ -0,0 +1,60 @@ +import { insertAuditLogs } from "@/lib/audit"; +import { db, eq, schema } from "@/lib/db"; +import { invalidateWorkspaceCache } from "@/lib/workspace-cache"; +import { TRPCError } from "@trpc/server"; +import { z } from "zod"; +import { requireUser, requireWorkspace, t } from "../../trpc"; +import { clearWorkspaceCache } from "../workspace/getCurrent"; + +export const updateWorkspaceStripeCustomer = t.procedure + .use(requireUser) + .use(requireWorkspace) + .input( + z.object({ + stripeCustomerId: z.string().min(1, "Stripe customer ID is required"), + }), + ) + .mutation(async ({ ctx, input }) => { + await db + .transaction(async (tx) => { + await tx + .update(schema.workspaces) + .set({ + stripeCustomerId: input.stripeCustomerId, + }) + .where(eq(schema.workspaces.id, ctx.workspace.id)); + + await insertAuditLogs(tx, { + workspaceId: ctx.workspace.id, + actor: { type: "user", id: ctx.user.id }, + event: "workspace.update", + description: "Updated Stripe customer ID", + resources: [ + { + type: "workspace", + id: ctx.workspace.id, + name: ctx.workspace.name, + }, + ], + context: { + location: ctx.audit.location, + userAgent: ctx.audit.userAgent, + }, + }); + }) + .catch((_err) => { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: + "We are unable to update the workspace Stripe customer. Please try again or contact support@unkey.dev", + }); + }); + // Invalidate workspace cache after successful update + await invalidateWorkspaceCache(ctx.tenant.id); + + // Also clear the tRPC workspace cache to ensure fresh data on next request + clearWorkspaceCache(ctx.tenant.id); + return { + success: true, + }; + }); diff --git a/apps/dashboard/lib/trpc/routers/utils/stripe.ts b/apps/dashboard/lib/trpc/routers/utils/stripe.ts new file mode 100644 index 0000000000..0042cda86e --- /dev/null +++ b/apps/dashboard/lib/trpc/routers/utils/stripe.ts @@ -0,0 +1,77 @@ +import { TRPCError } from "@trpc/server"; +import Stripe from "stripe"; + +export const mapProduct = (p: Stripe.Product) => { + if (!p.default_price) { + throw new Error(`Product ${p.id} is missing default_price`); + } + + const price = typeof p.default_price === "string" ? null : (p.default_price as Stripe.Price); + + if (!price) { + throw new Error(`Product ${p.id} default_price must be expanded`); + } + + if (price.unit_amount === null || price.unit_amount === undefined) { + throw new Error(`Product ${p.id} price is missing unit_amount`); + } + + const quotaRaw = p.metadata?.quota_requests_per_month; + + // Validate that the metadata value is a non-empty string of digits + if (!quotaRaw || typeof quotaRaw !== "string" || !/^\d+$/.test(quotaRaw)) { + throw new Error( + `Product ${p.id} metadata.quota_requests_per_month must be a non-empty string of digits, got: ${quotaRaw}`, + ); + } + + // Parse into integer only after regex validation + const quotaValue = Number.parseInt(quotaRaw, 10); + + // Ensure the parsed integer is >= 0 + if (quotaValue < 0) { + throw new Error( + `Product ${p.id} metadata.quota_requests_per_month must be >= 0, got: ${quotaRaw}`, + ); + } + + return { + id: p.id, + name: p.name, + priceId: price.id, + dollar: price.unit_amount / 100, + quotas: { + requestsPerMonth: quotaValue, + }, + }; +}; + +export const handleStripeError = (error: Stripe.errors.StripeError) => { + const stripeError = error; + let code: TRPCError["code"]; + + // Map Stripe error types to TRPC error codes + if (error instanceof Stripe.errors.StripeAuthenticationError) { + code = "UNAUTHORIZED"; + } else if (error instanceof Stripe.errors.StripeRateLimitError) { + code = "TOO_MANY_REQUESTS"; + } else if (error instanceof Stripe.errors.StripeInvalidRequestError) { + code = "BAD_REQUEST"; + } else if (error instanceof Stripe.errors.StripePermissionError) { + code = "FORBIDDEN"; + } else if (stripeError.statusCode === 404 || stripeError.code === "resource_missing") { + code = "NOT_FOUND"; + } else if (error instanceof Stripe.errors.StripeAPIError) { + code = "INTERNAL_SERVER_ERROR"; + } else if (error instanceof Stripe.errors.StripeConnectionError) { + code = "INTERNAL_SERVER_ERROR"; + } else { + // Default for other Stripe errors + code = "INTERNAL_SERVER_ERROR"; + } + + throw new TRPCError({ + code, + message: `Stripe error: ${stripeError.message}`, + }); +}; diff --git a/apps/dashboard/lib/trpc/routers/workspace/getById.ts b/apps/dashboard/lib/trpc/routers/workspace/getById.ts new file mode 100644 index 0000000000..d0b0abaeda --- /dev/null +++ b/apps/dashboard/lib/trpc/routers/workspace/getById.ts @@ -0,0 +1,44 @@ +import { db } from "@/lib/db"; +import { TRPCError } from "@trpc/server"; +import { z } from "zod"; +import { requireUser, t } from "../../trpc"; + +export const getWorkspaceById = t.procedure + .use(requireUser) + .input( + z.object({ + workspaceId: z.string().min(1, "Workspace ID is required"), + }), + ) + .query(async ({ input }) => { + try { + const workspace = await db.query.workspaces.findFirst({ + where: (table, { eq, and, isNull }) => + and(eq(table.id, input.workspaceId), isNull(table.deletedAtM)), + with: { + quotas: true, + }, + }); + + if (!workspace) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Workspace not found", + }); + } + + return workspace; + } catch (error) { + // If it's already a TRPCError, re-throw it + if (error instanceof TRPCError) { + throw error; + } + + console.error("Error fetching workspace by ID:", error); + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Failed to fetch workspace data", + cause: error, + }); + } + }); diff --git a/internal/ui/src/components/dialog/dialog-container.tsx b/internal/ui/src/components/dialog/dialog-container.tsx index 31ba80c829..8163ea36a1 100644 --- a/internal/ui/src/components/dialog/dialog-container.tsx +++ b/internal/ui/src/components/dialog/dialog-container.tsx @@ -19,6 +19,8 @@ type DialogContainerProps = PropsWithChildren<{ contentClassName?: string; preventAutoFocus?: boolean; subTitle?: string; + showCloseWarning?: boolean; + onAttemptClose?: () => void; }>; const DialogContainer = ({ @@ -31,6 +33,8 @@ const DialogContainer = ({ footer, contentClassName, preventAutoFocus = true, + showCloseWarning = false, + onAttemptClose, }: DialogContainerProps) => { return ( @@ -47,6 +51,8 @@ const DialogContainer = ({ e.preventDefault(); } }} + showCloseWarning={showCloseWarning} + onAttemptClose={onAttemptClose} > {children}