From cf4747d381a383fbbd60f1415d9a916bda4f3236 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Tue, 10 Feb 2026 21:19:14 -0800 Subject: [PATCH] add subscription violation banner --- messages/en-US.json | 2 + .../routers/billing/getOrgSubscriptions.ts | 14 +++++- server/routers/billing/types.ts | 2 + src/app/[orgId]/layout.tsx | 2 + src/components/SubscriptionViolation.tsx | 50 +++++++++++++++++++ src/contexts/subscriptionStatusContext.ts | 2 + src/providers/SubscriptionStatusProvider.tsx | 5 +- 7 files changed, 75 insertions(+), 2 deletions(-) create mode 100644 src/components/SubscriptionViolation.tsx diff --git a/messages/en-US.json b/messages/en-US.json index e6510fba..32f04f0c 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -18,6 +18,8 @@ "componentsMember": "You're a member of {count, plural, =0 {no organization} one {one organization} other {# organizations}}.", "componentsInvalidKey": "Invalid or expired license keys detected. Follow license terms to continue using all features.", "dismiss": "Dismiss", + "subscriptionViolationMessage": "You're beyond your limits for your current plan. Correct the problem by removing sites, users, or other resources to stay within your plan.", + "subscriptionViolationViewBilling": "View billing", "componentsLicenseViolation": "License Violation: This server is using {usedSites} sites which exceeds its licensed limit of {maxSites} sites. Follow license terms to continue using all features.", "componentsSupporterMessage": "Thank you for supporting Pangolin as a {tier}!", "inviteErrorNotValid": "We're sorry, but it looks like the invite you're trying to access has not been accepted or is no longer valid.", diff --git a/server/private/routers/billing/getOrgSubscriptions.ts b/server/private/routers/billing/getOrgSubscriptions.ts index 40b029e4..d2ee8c5b 100644 --- a/server/private/routers/billing/getOrgSubscriptions.ts +++ b/server/private/routers/billing/getOrgSubscriptions.ts @@ -23,6 +23,8 @@ import logger from "@server/logger"; import { fromZodError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; import { GetOrgSubscriptionResponse } from "@server/routers/billing/types"; +import { usageService } from "@server/lib/billing/usageService"; +import { build } from "@server/build"; // Import tables for billing import { @@ -70,9 +72,19 @@ export async function getOrgSubscriptions( throw err; } + let limitsExceeded = false; + if (build === "saas") { + try { + limitsExceeded = await usageService.checkLimitSet(orgId); + } catch (err) { + logger.error("Error checking limits for org %s: %s", orgId, err); + } + } + return response(res, { data: { - subscriptions + subscriptions, + ...(build === "saas" ? { limitsExceeded } : {}) }, success: true, error: false, diff --git a/server/routers/billing/types.ts b/server/routers/billing/types.ts index f29b7e14..52cee8fe 100644 --- a/server/routers/billing/types.ts +++ b/server/routers/billing/types.ts @@ -2,6 +2,8 @@ import { Limit, Subscription, SubscriptionItem, Usage } from "@server/db"; export type GetOrgSubscriptionResponse = { subscriptions: Array<{ subscription: Subscription; items: SubscriptionItem[] }>; + /** When build === saas, true if org has exceeded plan limits (sites, users, etc.) */ + limitsExceeded?: boolean; }; export type GetOrgUsageResponse = { diff --git a/src/app/[orgId]/layout.tsx b/src/app/[orgId]/layout.tsx index 3d4b6054..8dc28001 100644 --- a/src/app/[orgId]/layout.tsx +++ b/src/app/[orgId]/layout.tsx @@ -19,6 +19,7 @@ import OrgPolicyResult from "@app/components/OrgPolicyResult"; import UserProvider from "@app/providers/UserProvider"; import { Layout } from "@app/components/Layout"; import ApplyInternalRedirect from "@app/components/ApplyInternalRedirect"; +import SubscriptionViolation from "@app/components/SubscriptionViolation"; export default async function OrgLayout(props: { children: React.ReactNode; @@ -108,6 +109,7 @@ export default async function OrgLayout(props: { > {props.children} + {build === "saas" && } ); diff --git a/src/components/SubscriptionViolation.tsx b/src/components/SubscriptionViolation.tsx new file mode 100644 index 00000000..4ec1c4fd --- /dev/null +++ b/src/components/SubscriptionViolation.tsx @@ -0,0 +1,50 @@ +"use client"; + +import { Button } from "@app/components/ui/button"; +import { useSubscriptionStatusContext } from "@app/hooks/useSubscriptionStatusContext"; +import { useState } from "react"; +import Link from "next/link"; +import { useParams } from "next/navigation"; +import { useTranslations } from "next-intl"; + +export default function SubscriptionViolation() { + const context = useSubscriptionStatusContext(); + const [isDismissed, setIsDismissed] = useState(false); + const params = useParams(); + const orgId = params?.orgId as string | undefined; + const t = useTranslations(); + + if (!context?.limitsExceeded || isDismissed) return null; + + const billingHref = orgId ? `/${orgId}/settings/billing` : "/"; + + return ( +
+
+

+ {t("subscriptionViolationMessage")} +

+
+ + +
+
+
+ ); +} diff --git a/src/contexts/subscriptionStatusContext.ts b/src/contexts/subscriptionStatusContext.ts index 95946350..73503da4 100644 --- a/src/contexts/subscriptionStatusContext.ts +++ b/src/contexts/subscriptionStatusContext.ts @@ -8,6 +8,8 @@ type SubscriptionStatusContextType = { getTier: () => { tier: Tier | null; active: boolean }; isSubscribed: () => boolean; subscribed: boolean; + /** True when org has exceeded plan limits (sites, users, etc.). Only set when build === saas. */ + limitsExceeded: boolean; }; const SubscriptionStatusContext = createContext< diff --git a/src/providers/SubscriptionStatusProvider.tsx b/src/providers/SubscriptionStatusProvider.tsx index 9a5050f6..27e90fff 100644 --- a/src/providers/SubscriptionStatusProvider.tsx +++ b/src/providers/SubscriptionStatusProvider.tsx @@ -68,6 +68,8 @@ export function SubscriptionStatusProvider({ const [subscribed, setSubscribed] = useState(isSubscribed()); + const limitsExceeded = subscriptionStatusState?.limitsExceeded ?? false; + return ( {children}