Compare commits

...

4 Commits

Author SHA1 Message Date
Owen 9ff863db5e Continue to clean things up 2026-02-10 18:30:01 -08:00
Owen e2ac6e6d4d Communication improvements 2026-02-10 17:04:22 -08:00
Owen df4101875a Show warnings and specifics when downgrading 2026-02-10 16:35:09 -08:00
Owen 3f5c788d48 Disable features when downgrading 2026-02-10 16:11:19 -08:00
10 changed files with 684 additions and 119 deletions
+6
View File
@@ -1541,6 +1541,11 @@
"billingProcessing": "Processing...",
"billingConfirmUpgradeButton": "Confirm Upgrade",
"billingConfirmDowngradeButton": "Confirm Downgrade",
"billingLimitViolationWarning": "Usage Exceeds New Plan Limits",
"billingLimitViolationDescription": "Your current usage exceeds the limits of this plan. After downgrading, all actions will be disabled until you reduce usage within the new limits. Please review the features below that are currently over the limits. Limits in violation:",
"billingFeatureLossWarning": "Feature Availability Notice",
"billingFeatureLossDescription": "By downgrading, features not available in the new plan will be automatically disabled. Some settings and configurations may be lost. Please review the pricing matrix to understand which features will no longer be available.",
"billingUsageExceedsLimit": "Current usage ({current}) exceeds limit ({limit})",
"signUpTerms": {
"IAgreeToThe": "I agree to the",
"termsOfService": "terms of service",
@@ -1955,6 +1960,7 @@
"orgAuthBackToSignIn": "Back to standard sign in",
"orgAuthNoAccount": "Don't have an account?",
"subscriptionRequiredToUse": "A subscription is required to use this feature.",
"mustUpgradeToUse": "You must upgrade your subscription to use this feature.",
"idpDisabled": "Identity providers are disabled.",
"orgAuthPageDisabled": "Organization auth page is disabled.",
"domainRestartedDescription": "Domain verification restarted successfully",
+10 -10
View File
@@ -2,19 +2,19 @@ import { Tier } from "@server/types/Tiers";
export enum TierFeature {
OrgOidc = "orgOidc",
LoginPageDomain = "loginPageDomain",
DeviceApprovals = "deviceApprovals",
LoginPageBranding = "loginPageBranding",
LoginPageDomain = "loginPageDomain", // handle downgrade by removing custom domain
DeviceApprovals = "deviceApprovals", // handle downgrade by disabling device approvals
LoginPageBranding = "loginPageBranding", // handle downgrade by setting to default branding
LogExport = "logExport",
AccessLogs = "accessLogs",
ActionLogs = "actionLogs",
AccessLogs = "accessLogs", // set the retention period to none on downgrade
ActionLogs = "actionLogs", // set the retention period to none on downgrade
RotateCredentials = "rotateCredentials",
MaintencePage = "maintencePage",
MaintencePage = "maintencePage", // handle downgrade
DevicePosture = "devicePosture",
TwoFactorEnforcement = "twoFactorEnforcement",
SessionDurationPolicies = "sessionDurationPolicies",
PasswordExpirationPolicies = "passwordExpirationPolicies",
AutoProvisioning = "autoProvisioning"
TwoFactorEnforcement = "twoFactorEnforcement", // handle downgrade by setting to optional
SessionDurationPolicies = "sessionDurationPolicies", // handle downgrade by setting to default duration
PasswordExpirationPolicies = "passwordExpirationPolicies", // handle downgrade by setting to default duration
AutoProvisioning = "autoProvisioning" // handle downgrade by disabling auto provisioning
}
export const tierMatrix: Record<TierFeature, Tier[]> = {
-4
View File
@@ -1,10 +1,6 @@
import { Request, Response, NextFunction } from "express";
import { db, orgs } from "@server/db";
import { userOrgs } from "@server/db";
import { and, eq } from "drizzle-orm";
import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode";
import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy";
import { usageService } from "@server/lib/billing/usageService";
import { build } from "@server/build";
@@ -0,0 +1,297 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
* You may not use this file except in compliance with the License.
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
*
* This file is not licensed under the AGPLv3.
*/
import { SubscriptionType } from "./hooks/getSubType";
import { TierFeature, tierMatrix } from "@server/lib/billing/tierMatrix";
import { Tier } from "@server/types/Tiers";
import logger from "@server/logger";
import { db, idp, idpOrg, loginPage, loginPageBranding, loginPageBrandingOrg, loginPageOrg, orgs, resources, roles } from "@server/db";
import { eq } from "drizzle-orm";
export async function handleTierChange(
orgId: string,
newTier: SubscriptionType | null,
previousTier?: SubscriptionType | null
): Promise<void> {
logger.info(
`Handling tier change for org ${orgId}: ${previousTier || "none"} -> ${newTier || "free"}`
);
// License subscriptions are handled separately and don't use the tier matrix
if (newTier === "license") {
logger.debug(
`New tier is license for org ${orgId}, no feature lifecycle handling needed`
);
return;
}
// If newTier is null, treat as free tier - disable all features
if (newTier === null) {
logger.info(
`Org ${orgId} is reverting to free tier, disabling all paid features`
);
// Disable all features in the tier matrix
for (const [featureKey] of Object.entries(tierMatrix)) {
const feature = featureKey as TierFeature;
logger.info(
`Feature ${feature} is not available in free tier for org ${orgId}. Disabling...`
);
await disableFeature(orgId, feature);
}
logger.info(
`Completed free tier feature lifecycle handling for org ${orgId}`
);
return;
}
// Get the tier (cast as Tier since we've ruled out "license" and null)
const tier = newTier as Tier;
// Check each feature in the tier matrix
for (const [featureKey, allowedTiers] of Object.entries(tierMatrix)) {
const feature = featureKey as TierFeature;
const isFeatureAvailable = allowedTiers.includes(tier);
if (!isFeatureAvailable) {
logger.info(
`Feature ${feature} is not available in tier ${tier} for org ${orgId}. Disabling...`
);
await disableFeature(orgId, feature);
} else {
logger.debug(
`Feature ${feature} is available in tier ${tier} for org ${orgId}`
);
}
}
logger.info(
`Completed tier change feature lifecycle handling for org ${orgId}`
);
}
async function disableFeature(
orgId: string,
feature: TierFeature
): Promise<void> {
try {
switch (feature) {
case TierFeature.OrgOidc:
await disableOrgOidc(orgId);
break;
case TierFeature.LoginPageDomain:
await disableLoginPageDomain(orgId);
break;
case TierFeature.DeviceApprovals:
await disableDeviceApprovals(orgId);
break;
case TierFeature.LoginPageBranding:
await disableLoginPageBranding(orgId);
break;
case TierFeature.LogExport:
await disableLogExport(orgId);
break;
case TierFeature.AccessLogs:
await disableAccessLogs(orgId);
break;
case TierFeature.ActionLogs:
await disableActionLogs(orgId);
break;
case TierFeature.RotateCredentials:
await disableRotateCredentials(orgId);
break;
case TierFeature.MaintencePage:
await disableMaintencePage(orgId);
break;
case TierFeature.DevicePosture:
await disableDevicePosture(orgId);
break;
case TierFeature.TwoFactorEnforcement:
await disableTwoFactorEnforcement(orgId);
break;
case TierFeature.SessionDurationPolicies:
await disableSessionDurationPolicies(orgId);
break;
case TierFeature.PasswordExpirationPolicies:
await disablePasswordExpirationPolicies(orgId);
break;
case TierFeature.AutoProvisioning:
await disableAutoProvisioning(orgId);
break;
default:
logger.warn(
`Unknown feature ${feature} for org ${orgId}, skipping`
);
}
logger.info(
`Successfully disabled feature ${feature} for org ${orgId}`
);
} catch (error) {
logger.error(
`Error disabling feature ${feature} for org ${orgId}:`,
error
);
throw error;
}
}
async function disableOrgOidc(orgId: string): Promise<void> {}
async function disableDeviceApprovals(orgId: string): Promise<void> {
await db
.update(roles)
.set({ requireDeviceApproval: false })
.where(eq(roles.orgId, orgId));
logger.info(`Disabled device approvals on all roles for org ${orgId}`);
}
async function disableLoginPageBranding(orgId: string): Promise<void> {
const [existingBranding] = await db
.select()
.from(loginPageBrandingOrg)
.where(eq(loginPageBrandingOrg.orgId, orgId));
if (existingBranding) {
await db
.delete(loginPageBranding)
.where(
eq(
loginPageBranding.loginPageBrandingId,
existingBranding.loginPageBrandingId
)
);
logger.info(`Disabled login page branding for org ${orgId}`);
}
}
async function disableLoginPageDomain(orgId: string): Promise<void> {
const [existingLoginPage] = await db
.select()
.from(loginPageOrg)
.where(eq(loginPageOrg.orgId, orgId))
.innerJoin(
loginPage,
eq(loginPage.loginPageId, loginPageOrg.loginPageId)
);
if (existingLoginPage) {
await db
.delete(loginPageOrg)
.where(eq(loginPageOrg.orgId, orgId));
await db
.delete(loginPage)
.where(
eq(
loginPage.loginPageId,
existingLoginPage.loginPageOrg.loginPageId
)
);
logger.info(`Disabled login page domain for org ${orgId}`);
}
}
async function disableLogExport(orgId: string): Promise<void> {}
async function disableAccessLogs(orgId: string): Promise<void> {
await db
.update(orgs)
.set({ settingsLogRetentionDaysAccess: 0 })
.where(eq(orgs.orgId, orgId));
logger.info(`Disabled access logs for org ${orgId}`);
}
async function disableActionLogs(orgId: string): Promise<void> {
await db
.update(orgs)
.set({ settingsLogRetentionDaysAction: 0 })
.where(eq(orgs.orgId, orgId));
logger.info(`Disabled action logs for org ${orgId}`);
}
async function disableRotateCredentials(orgId: string): Promise<void> {}
async function disableMaintencePage(orgId: string): Promise<void> {
await db
.update(resources)
.set({
maintenanceModeEnabled: false
})
.where(eq(resources.orgId, orgId));
logger.info(`Disabled maintenance page on all resources for org ${orgId}`);
}
async function disableDevicePosture(orgId: string): Promise<void> {}
async function disableTwoFactorEnforcement(orgId: string): Promise<void> {
await db
.update(orgs)
.set({ requireTwoFactor: false })
.where(eq(orgs.orgId, orgId));
logger.info(`Disabled two-factor enforcement for org ${orgId}`);
}
async function disableSessionDurationPolicies(orgId: string): Promise<void> {
await db
.update(orgs)
.set({ maxSessionLengthHours: null })
.where(eq(orgs.orgId, orgId));
logger.info(`Disabled session duration policies for org ${orgId}`);
}
async function disablePasswordExpirationPolicies(orgId: string): Promise<void> {
await db
.update(orgs)
.set({ passwordExpiryDays: null })
.where(eq(orgs.orgId, orgId));
logger.info(`Disabled password expiration policies for org ${orgId}`);
}
async function disableAutoProvisioning(orgId: string): Promise<void> {
// Get all IDP IDs for this org through the idpOrg join table
const orgIdps = await db
.select({ idpId: idpOrg.idpId })
.from(idpOrg)
.where(eq(idpOrg.orgId, orgId));
// Update autoProvision to false for all IDPs in this org
for (const { idpId } of orgIdps) {
await db
.update(idp)
.set({ autoProvision: false })
.where(eq(idp.idpId, idpId));
}
}
@@ -32,6 +32,7 @@ import { sendEmail } from "@server/emails";
import EnterpriseEditionKeyGenerated from "@server/emails/templates/EnterpriseEditionKeyGenerated";
import config from "@server/lib/config";
import { getFeatureIdByPriceId } from "@server/lib/billing/features";
import { handleTierChange } from "../featureLifecycle";
export async function handleSubscriptionCreated(
subscription: Stripe.Subscription
@@ -150,6 +151,12 @@ export async function handleSubscriptionCreated(
type
);
// Handle initial tier setup - disable features not available in this tier
logger.info(
`Setting up initial tier features for org ${customer.orgId} with type ${type}`
);
await handleTierChange(customer.orgId, type);
const [orgUserRes] = await db
.select()
.from(userOrgs)
@@ -27,6 +27,7 @@ import { AudienceIds, moveEmailToAudience } from "#private/lib/resend";
import { getSubType } from "./getSubType";
import stripe from "#private/lib/stripe";
import privateConfig from "#private/lib/config";
import { handleTierChange } from "../featureLifecycle";
export async function handleSubscriptionDeleted(
subscription: Stripe.Subscription
@@ -87,6 +88,12 @@ export async function handleSubscriptionDeleted(
type
);
// Handle feature lifecycle for cancellation - disable all tier-specific features
logger.info(
`Disabling tier-specific features for org ${customer.orgId} due to subscription deletion`
);
await handleTierChange(customer.orgId, null, type);
const [orgUserRes] = await db
.select()
.from(userOrgs)
@@ -26,8 +26,9 @@ import logger from "@server/logger";
import { getFeatureIdByMetricId, getFeatureIdByPriceId } from "@server/lib/billing/features";
import stripe from "#private/lib/stripe";
import { handleSubscriptionLifesycle } from "../subscriptionLifecycle";
import { getSubType } from "./getSubType";
import { getSubType, SubscriptionType } from "./getSubType";
import privateConfig from "#private/lib/config";
import { handleTierChange } from "../featureLifecycle";
export async function handleSubscriptionUpdated(
subscription: Stripe.Subscription,
@@ -65,6 +66,7 @@ export async function handleSubscriptionUpdated(
.limit(1);
const type = getSubType(fullSubscription);
const previousType = existingSubscription.type as SubscriptionType | null;
await db
.update(subscriptions)
@@ -79,6 +81,14 @@ export async function handleSubscriptionUpdated(
})
.where(eq(subscriptions.subscriptionId, subscription.id));
// Handle tier change if the subscription type changed
if (type && type !== previousType) {
logger.info(
`Tier change detected for org ${customer.orgId}: ${previousType} -> ${type}`
);
await handleTierChange(customer.orgId, type, previousType ?? undefined);
}
// Upsert subscription items
if (Array.isArray(fullSubscription.items?.data)) {
// First, get existing items to preserve featureId when there's no match
@@ -178,7 +188,7 @@ export async function handleSubscriptionUpdated(
const orgId = customer.orgId;
if (!orgId) {
logger.warn(
logger.debug(
`No orgId found in subscription metadata for subscription ${subscription.id}. Skipping usage reset.`
);
continue;
@@ -268,6 +278,18 @@ export async function handleSubscriptionUpdated(
subscription.status,
type
);
// Handle feature lifecycle when subscription is canceled or becomes unpaid
if (
subscription.status === "canceled" ||
subscription.status === "unpaid" ||
subscription.status === "incomplete_expired"
) {
logger.info(
`Subscription ${subscription.id} for org ${customer.orgId} is ${subscription.status}, disabling paid features`
);
await handleTierChange(customer.orgId, null, previousType ?? undefined);
}
} else if (type === "license") {
if (subscription.status === "canceled" || subscription.status == "unpaid" || subscription.status == "incomplete_expired") {
try {
@@ -34,7 +34,17 @@ import {
CredenzaTitle
} from "@app/components/Credenza";
import { cn } from "@app/lib/cn";
import { CreditCard, ExternalLink, Check } from "lucide-react";
import { CreditCard, ExternalLink, Check, AlertTriangle } from "lucide-react";
import {
Alert,
AlertTitle,
AlertDescription
} from "@app/components/ui/alert";
import {
Tooltip,
TooltipTrigger,
TooltipContent
} from "@app/components/ui/tooltip";
import {
GetOrgSubscriptionResponse,
GetOrgUsageResponse
@@ -43,6 +53,7 @@ import { useTranslations } from "use-intl";
import Link from "next/link";
import { Tier } from "@server/types/Tiers";
import {
freeLimitSet,
tier1LimitSet,
tier2LimitSet,
tier3LimitSet
@@ -64,7 +75,7 @@ const planOptions: PlanOption[] = [
{
id: "starter",
name: "Starter",
price: "Starter",
price: "Free",
tierType: null
},
{
@@ -98,9 +109,15 @@ const planOptions: PlanOption[] = [
// Tier limits mapping derived from limit sets
const tierLimits: Record<
Tier,
Tier | "starter",
{ users: number; sites: number; domains: number; remoteNodes: number }
> = {
starter: {
users: freeLimitSet[FeatureId.USERS]?.value ?? 0,
sites: freeLimitSet[FeatureId.SITES]?.value ?? 0,
domains: freeLimitSet[FeatureId.DOMAINS]?.value ?? 0,
remoteNodes: freeLimitSet[FeatureId.REMOTE_EXIT_NODES]?.value ?? 0
},
tier1: {
users: tier1LimitSet[FeatureId.USERS]?.value ?? 0,
sites: tier1LimitSet[FeatureId.SITES]?.value ?? 0,
@@ -166,7 +183,7 @@ export default function BillingPage() {
// Confirmation dialog state
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
const [pendingTier, setPendingTier] = useState<{
tier: Tier;
tier: Tier | "starter";
action: "upgrade" | "downgrade";
planName: string;
price: string;
@@ -385,7 +402,10 @@ export default function BillingPage() {
pendingTier.action === "upgrade" ||
pendingTier.action === "downgrade"
) {
if (hasSubscription) {
// If downgrading to starter (free tier), go to Stripe portal
if (pendingTier.tier === "starter") {
handleModifySubscription();
} else if (hasSubscription) {
handleChangeTier(pendingTier.tier);
} else {
handleStartSubscription(pendingTier.tier);
@@ -397,7 +417,7 @@ export default function BillingPage() {
};
const showTierConfirmation = (
tier: Tier,
tier: Tier | "starter",
action: "upgrade" | "downgrade",
planName: string,
price: string
@@ -464,6 +484,14 @@ export default function BillingPage() {
plan.name,
plan.price + (" " + plan.priceDetail || "")
);
} else if (plan.id === "starter") {
// Show confirmation for downgrading to starter (free tier)
showTierConfirmation(
"starter",
"downgrade",
plan.name,
plan.price
);
} else {
handleModifySubscription();
}
@@ -504,6 +532,13 @@ export default function BillingPage() {
return limit?.value ?? null;
};
// Check if usage exceeds limit for a specific feature
const isOverLimit = (featureId: string): boolean => {
const usage = getUsageValue(featureId);
const limit = getLimitValue(featureId);
return limit !== null && usage > limit;
};
// Calculate current usage cost for display
const getUserCount = () => getUsageValue(USERS);
const getPricePerUser = () => {
@@ -530,6 +565,63 @@ export default function BillingPage() {
return licenseSubscription.items.length;
};
// Check if downgrading to a tier would violate current usage limits
const checkLimitViolations = (targetTier: Tier | "starter"): Array<{
feature: string;
currentUsage: number;
newLimit: number;
}> => {
const violations: Array<{
feature: string;
currentUsage: number;
newLimit: number;
}> = [];
const limits = tierLimits[targetTier];
// Check users
const usersUsage = getUsageValue(USERS);
if (limits.users > 0 && usersUsage > limits.users) {
violations.push({
feature: "Users",
currentUsage: usersUsage,
newLimit: limits.users
});
}
// Check sites
const sitesUsage = getUsageValue(SITES);
if (limits.sites > 0 && sitesUsage > limits.sites) {
violations.push({
feature: "Sites",
currentUsage: sitesUsage,
newLimit: limits.sites
});
}
// Check domains
const domainsUsage = getUsageValue(DOMAINS);
if (limits.domains > 0 && domainsUsage > limits.domains) {
violations.push({
feature: "Domains",
currentUsage: domainsUsage,
newLimit: limits.domains
});
}
// Check remote nodes
const remoteNodesUsage = getUsageValue(REMOTE_EXIT_NODES);
if (limits.remoteNodes > 0 && remoteNodesUsage > limits.remoteNodes) {
violations.push({
feature: "Remote Exit Nodes",
currentUsage: remoteNodesUsage,
newLimit: limits.remoteNodes
});
}
return violations;
};
if (subscriptionLoading) {
return (
<div className="flex justify-center items-center h-64">
@@ -666,11 +758,33 @@ export default function BillingPage() {
{t("billingUsers") || "Users"}
</InfoSectionTitle>
<InfoSectionContent className="text-sm">
{getLimitValue(USERS) ??
t("billingUnlimited") ??
"∞"}{" "}
{getLimitValue(USERS) !== null &&
"users"}
{isOverLimit(USERS) ? (
<Tooltip>
<TooltipTrigger className="flex items-center gap-1">
<AlertTriangle className="h-3 w-3 text-orange-400" />
<span className={cn(
"text-orange-600 dark:text-orange-400 font-medium"
)}>
{getLimitValue(USERS) ??
t("billingUnlimited") ??
"∞"}{" "}
{getLimitValue(USERS) !== null &&
"users"}
</span>
</TooltipTrigger>
<TooltipContent>
<p>{t("billingUsageExceedsLimit", { current: getUsageValue(USERS), limit: getLimitValue(USERS) ?? 0 }) || `Current usage (${getUsageValue(USERS)}) exceeds limit (${getLimitValue(USERS)})`}</p>
</TooltipContent>
</Tooltip>
) : (
<>
{getLimitValue(USERS) ??
t("billingUnlimited") ??
"∞"}{" "}
{getLimitValue(USERS) !== null &&
"users"}
</>
)}
</InfoSectionContent>
</InfoSection>
<InfoSection>
@@ -678,11 +792,33 @@ export default function BillingPage() {
{t("billingSites") || "Sites"}
</InfoSectionTitle>
<InfoSectionContent className="text-sm">
{getLimitValue(SITES) ??
t("billingUnlimited") ??
"∞"}{" "}
{getLimitValue(SITES) !== null &&
"sites"}
{isOverLimit(SITES) ? (
<Tooltip>
<TooltipTrigger className="flex items-center gap-1">
<AlertTriangle className="h-3 w-3 text-orange-400" />
<span className={cn(
"text-orange-600 dark:text-orange-400 font-medium"
)}>
{getLimitValue(SITES) ??
t("billingUnlimited") ??
"∞"}{" "}
{getLimitValue(SITES) !== null &&
"sites"}
</span>
</TooltipTrigger>
<TooltipContent>
<p>{t("billingUsageExceedsLimit", { current: getUsageValue(SITES), limit: getLimitValue(SITES) ?? 0 }) || `Current usage (${getUsageValue(SITES)}) exceeds limit (${getLimitValue(SITES)})`}</p>
</TooltipContent>
</Tooltip>
) : (
<>
{getLimitValue(SITES) ??
t("billingUnlimited") ??
"∞"}{" "}
{getLimitValue(SITES) !== null &&
"sites"}
</>
)}
</InfoSectionContent>
</InfoSection>
<InfoSection>
@@ -690,11 +826,33 @@ export default function BillingPage() {
{t("billingDomains") || "Domains"}
</InfoSectionTitle>
<InfoSectionContent className="text-sm">
{getLimitValue(DOMAINS) ??
t("billingUnlimited") ??
"∞"}{" "}
{getLimitValue(DOMAINS) !== null &&
"domains"}
{isOverLimit(DOMAINS) ? (
<Tooltip>
<TooltipTrigger className="flex items-center gap-1">
<AlertTriangle className="h-3 w-3 text-orange-400" />
<span className={cn(
"text-orange-600 dark:text-orange-400 font-medium"
)}>
{getLimitValue(DOMAINS) ??
t("billingUnlimited") ??
"∞"}{" "}
{getLimitValue(DOMAINS) !== null &&
"domains"}
</span>
</TooltipTrigger>
<TooltipContent>
<p>{t("billingUsageExceedsLimit", { current: getUsageValue(DOMAINS), limit: getLimitValue(DOMAINS) ?? 0 }) || `Current usage (${getUsageValue(DOMAINS)}) exceeds limit (${getLimitValue(DOMAINS)})`}</p>
</TooltipContent>
</Tooltip>
) : (
<>
{getLimitValue(DOMAINS) ??
t("billingUnlimited") ??
"∞"}{" "}
{getLimitValue(DOMAINS) !== null &&
"domains"}
</>
)}
</InfoSectionContent>
</InfoSection>
<InfoSection>
@@ -703,11 +861,33 @@ export default function BillingPage() {
"Remote Nodes"}
</InfoSectionTitle>
<InfoSectionContent className="text-sm">
{getLimitValue(REMOTE_EXIT_NODES) ??
t("billingUnlimited") ??
"∞"}{" "}
{getLimitValue(REMOTE_EXIT_NODES) !==
null && "remote nodes"}
{isOverLimit(REMOTE_EXIT_NODES) ? (
<Tooltip>
<TooltipTrigger className="flex items-center gap-1">
<AlertTriangle className="h-3 w-3 text-orange-400" />
<span className={cn(
"text-orange-600 dark:text-orange-400 font-medium"
)}>
{getLimitValue(REMOTE_EXIT_NODES) ??
t("billingUnlimited") ??
"∞"}{" "}
{getLimitValue(REMOTE_EXIT_NODES) !==
null && "remote nodes"}
</span>
</TooltipTrigger>
<TooltipContent>
<p>{t("billingUsageExceedsLimit", { current: getUsageValue(REMOTE_EXIT_NODES), limit: getLimitValue(REMOTE_EXIT_NODES) ?? 0 }) || `Current usage (${getUsageValue(REMOTE_EXIT_NODES)}) exceeds limit (${getLimitValue(REMOTE_EXIT_NODES)})`}</p>
</TooltipContent>
</Tooltip>
) : (
<>
{getLimitValue(REMOTE_EXIT_NODES) ??
t("billingUnlimited") ??
"∞"}{" "}
{getLimitValue(REMOTE_EXIT_NODES) !==
null && "remote nodes"}
</>
)}
</InfoSectionContent>
</InfoSection>
</InfoSections>
@@ -729,31 +909,34 @@ export default function BillingPage() {
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<div className="flex flex-col md:flex-row items-start md:items-center justify-between gap-4 border rounded-lg p-4 bg-muted/30">
<div>
<div className="text-sm text-muted-foreground mb-1">
{t("billingCurrentKeys") || "Current Keys"}
</div>
<div className="flex items-baseline gap-2">
<span className="text-3xl font-bold">
{getLicenseKeyCount()}
</span>
<span className="text-lg">
{getLicenseKeyCount() === 1
? "key"
: "keys"}
</span>
<div className="w-full md:w-1/2">
<div className="flex flex-col md:flex-row items-start md:items-center justify-between gap-4 border rounded-lg p-4">
<div>
<div className="text-sm text-muted-foreground mb-1">
{t("billingCurrentKeys") || "Current Keys"}
</div>
<div className="flex items-baseline gap-2">
<span className="text-3xl font-bold">
{getLicenseKeyCount()}
</span>
<span className="text-lg">
{getLicenseKeyCount() === 1
? "key"
: "keys"}
</span>
</div>
</div>
<Button
variant="outline"
onClick={handleModifySubscription}
disabled={isLoading}
loading={isLoading}
>
<CreditCard className="mr-2 h-4 w-4" />
{t("billingModifyCurrentPlan") ||
"Modify Current Plan"}
</Button>
</div>
<Button
variant="outline"
onClick={handleModifySubscription}
disabled={isLoading}
>
<CreditCard className="mr-2 h-4 w-4" />
{t("billingModifyCurrentPlan") ||
"Modify Current Plan"}
</Button>
</div>
</SettingsSectionBody>
</SettingsSection>
@@ -784,65 +967,111 @@ export default function BillingPage() {
<CredenzaBody>
{pendingTier && pendingTier.tier && (
<div className="space-y-4">
<div className="border rounded-lg p-4 bg-muted/30">
<div className="font-semibold text-lg mb-2">
<div className="border rounded-lg p-4">
<div className="text-2xl">
{pendingTier.planName}
</div>
<div className="text-2xl font-bold">
{pendingTier.price}
<div className="mt-1">
<span className="text-xl">
{pendingTier.price}
</span>
</div>
</div>
<div>
<h4 className="font-semibold mb-3">
{t("billingPlanIncludes") ||
"Plan Includes:"}
</h4>
<div className="space-y-2">
<div className="flex items-center gap-2">
<Check className="h-4 w-4 text-green-600" />
<span>
{
tierLimits[pendingTier.tier]
.users
}{" "}
{t("billingUsers") || "Users"}
</span>
</div>
<div className="flex items-center gap-2">
<Check className="h-4 w-4 text-green-600" />
<span>
{
tierLimits[pendingTier.tier]
.sites
}{" "}
{t("billingSites") || "Sites"}
</span>
</div>
<div className="flex items-center gap-2">
<Check className="h-4 w-4 text-green-600" />
<span>
{
tierLimits[pendingTier.tier]
.domains
}{" "}
{t("billingDomains") ||
"Domains"}
</span>
</div>
<div className="flex items-center gap-2">
<Check className="h-4 w-4 text-green-600" />
<span>
{
tierLimits[pendingTier.tier]
.remoteNodes
}{" "}
{t("billingRemoteNodes") ||
"Remote Nodes"}
</span>
{tierLimits[pendingTier.tier] && (
<div>
<h4 className="font-semibold mb-3">
{t("billingPlanIncludes") ||
"Plan Includes:"}
</h4>
<div className="space-y-2">
<div className="flex items-center gap-2">
<Check className="h-4 w-4 text-green-600" />
<span>
{
tierLimits[pendingTier.tier]
.users
}{" "}
{t("billingUsers") || "Users"}
</span>
</div>
<div className="flex items-center gap-2">
<Check className="h-4 w-4 text-green-600" />
<span>
{
tierLimits[pendingTier.tier]
.sites
}{" "}
{t("billingSites") || "Sites"}
</span>
</div>
<div className="flex items-center gap-2">
<Check className="h-4 w-4 text-green-600" />
<span>
{
tierLimits[pendingTier.tier]
.domains
}{" "}
{t("billingDomains") ||
"Domains"}
</span>
</div>
<div className="flex items-center gap-2">
<Check className="h-4 w-4 text-green-600" />
<span>
{
tierLimits[pendingTier.tier]
.remoteNodes
}{" "}
{t("billingRemoteNodes") ||
"Remote Nodes"}
</span>
</div>
</div>
</div>
</div>
)}
{/* Warning for limit violations when downgrading */}
{pendingTier.action === "downgrade" && (() => {
const violations = checkLimitViolations(pendingTier.tier);
if (violations.length > 0) {
return (
<Alert variant="destructive">
<AlertTriangle className="h-4 w-4" />
<AlertTitle>
{t("billingLimitViolationWarning") || "Usage Exceeds New Plan Limits"}
</AlertTitle>
<AlertDescription>
<p className="mb-3">
{t("billingLimitViolationDescription") || "Your current usage exceeds the limits of this plan. The following features will be disabled until you reduce usage:"}
</p>
<ul className="space-y-2">
{violations.map((violation, index) => (
<li key={index} className="flex items-center gap-2">
<span className="font-medium">{violation.feature}:</span>
<span>Currently using {violation.currentUsage}, new limit is {violation.newLimit}</span>
</li>
))}
</ul>
</AlertDescription>
</Alert>
);
}
return null;
})()}
{/* Warning for feature loss when downgrading */}
{pendingTier.action === "downgrade" && (
<Alert variant="warning">
<AlertTriangle className="h-4 w-4" />
<AlertTitle>
{t("billingFeatureLossWarning") || "Feature Availability Notice"}
</AlertTitle>
<AlertDescription>
{t("billingFeatureLossDescription") || "By downgrading, features not available in the new plan will be automatically disabled. Some settings and configurations may be lost. Please review the pricing matrix to understand which features will no longer be available."}
</AlertDescription>
</Alert>
)}
</div>
)}
</CredenzaBody>
+2 -2
View File
@@ -21,7 +21,7 @@ type Props = {
export function PaidFeaturesAlert({ tiers }: Props) {
const t = useTranslations();
const { hasSaasSubscription, hasEnterpriseLicense } = usePaidStatus();
const { hasSaasSubscription, hasEnterpriseLicense, isActive } = usePaidStatus();
const { env } = useEnvContext();
if (env.flags.disableEnterpriseFeatures) {
@@ -35,7 +35,7 @@ export function PaidFeaturesAlert({ tiers }: Props) {
<CardContent className={bannerContentClassName}>
<div className={bannerRowClassName}>
<KeyRound className="size-4 shrink-0 text-primary" />
<span>{t("subscriptionRequiredToUse")}</span>
<span>{isActive ? t("mustUpgradeToUse") : t("subscriptionRequiredToUse")}</span>
</div>
</CardContent>
</Card>
+1
View File
@@ -33,6 +33,7 @@ export function usePaidStatus() {
hasEnterpriseLicense,
hasSaasSubscription,
isPaidUser,
isActive: tierData?.active,
subscriptionTier: tierData?.tier
};
}