Compare commits

..

1 Commits

Author SHA1 Message Date
dependabot[bot]
bc0d5ed2c7 Bump the dev-minor-updates group across 1 directory with 5 updates
Bumps the dev-minor-updates group with 5 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [@dotenvx/dotenvx](https://github.com/dotenvx/dotenvx) | `1.52.0` | `1.53.0` |
| [@tailwindcss/postcss](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/@tailwindcss-postcss) | `4.1.18` | `4.2.1` |
| [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) | `25.2.3` | `25.3.5` |
| [tailwindcss](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/tailwindcss) | `4.1.18` | `4.2.1` |
| [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint) | `8.55.0` | `8.56.1` |



Updates `@dotenvx/dotenvx` from 1.52.0 to 1.53.0
- [Release notes](https://github.com/dotenvx/dotenvx/releases)
- [Changelog](https://github.com/dotenvx/dotenvx/blob/main/CHANGELOG.md)
- [Commits](https://github.com/dotenvx/dotenvx/compare/v1.52.0...v1.53.0)

Updates `@tailwindcss/postcss` from 4.1.18 to 4.2.1
- [Release notes](https://github.com/tailwindlabs/tailwindcss/releases)
- [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/tailwindlabs/tailwindcss/commits/v4.2.1/packages/@tailwindcss-postcss)

Updates `@types/node` from 25.2.3 to 25.3.5
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

Updates `tailwindcss` from 4.1.18 to 4.2.1
- [Release notes](https://github.com/tailwindlabs/tailwindcss/releases)
- [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/tailwindlabs/tailwindcss/commits/v4.2.1/packages/tailwindcss)

Updates `typescript-eslint` from 8.55.0 to 8.56.1
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/typescript-eslint/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.56.1/packages/typescript-eslint)

---
updated-dependencies:
- dependency-name: "@dotenvx/dotenvx"
  dependency-version: 1.53.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-minor-updates
- dependency-name: "@tailwindcss/postcss"
  dependency-version: 4.2.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-minor-updates
- dependency-name: "@types/node"
  dependency-version: 25.3.5
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-minor-updates
- dependency-name: tailwindcss
  dependency-version: 4.2.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-minor-updates
- dependency-name: typescript-eslint
  dependency-version: 8.56.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-minor-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-06 01:36:23 +00:00
9 changed files with 631 additions and 696 deletions

619
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -110,6 +110,7 @@
"react-icons": "5.5.0",
"recharts": "2.15.4",
"reodotdev": "1.0.0",
"resend": "6.9.2",
"semver": "7.7.4",
"sshpk": "^1.18.0",
"stripe": "20.3.1",
@@ -130,10 +131,10 @@
"zod-validation-error": "5.0.0"
},
"devDependencies": {
"@dotenvx/dotenvx": "1.52.0",
"@dotenvx/dotenvx": "1.53.0",
"@esbuild-plugins/tsconfig-paths": "0.1.2",
"@react-email/preview-server": "5.2.8",
"@tailwindcss/postcss": "4.1.18",
"@tailwindcss/postcss": "4.2.1",
"@tanstack/react-query-devtools": "5.91.3",
"@types/better-sqlite3": "7.6.13",
"@types/cookie-parser": "1.4.10",
@@ -145,7 +146,7 @@
"@types/jmespath": "0.15.2",
"@types/js-yaml": "4.0.9",
"@types/jsonwebtoken": "9.0.10",
"@types/node": "25.2.3",
"@types/node": "25.3.5",
"@types/nodemailer": "7.0.11",
"@types/nprogress": "0.2.3",
"@types/pg": "8.16.0",
@@ -166,10 +167,10 @@
"postcss": "8.5.6",
"prettier": "3.8.1",
"react-email": "5.2.8",
"tailwindcss": "4.1.18",
"tailwindcss": "4.2.1",
"tsc-alias": "1.8.16",
"tsx": "4.21.0",
"typescript": "5.9.3",
"typescript-eslint": "8.55.0"
"typescript-eslint": "8.56.1"
}
}

16
server/lib/resend.ts Normal file
View File

@@ -0,0 +1,16 @@
export enum AudienceIds {
SignUps = "",
Subscribed = "",
Churned = "",
Newsletter = ""
}
let resend;
export default resend;
export async function moveEmailToAudience(
email: string,
audienceId: AudienceIds
) {
return;
}

View File

@@ -38,6 +38,10 @@ export const privateConfigSchema = z.object({
.string()
.optional()
.transform(getEnvOrYaml("SERVER_ENCRYPTION_KEY")),
resend_api_key: z
.string()
.optional()
.transform(getEnvOrYaml("RESEND_API_KEY")),
reo_client_id: z
.string()
.optional()

View File

@@ -0,0 +1,127 @@
/*
* 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 { Resend } from "resend";
import privateConfig from "#private/lib/config";
import logger from "@server/logger";
export enum AudienceIds {
SignUps = "6c4e77b2-0851-4bd6-bac8-f51f91360f1a",
Subscribed = "870b43fd-387f-44de-8fc1-707335f30b20",
Churned = "f3ae92bd-2fdb-4d77-8746-2118afd62549",
Newsletter = "5500c431-191c-42f0-a5d4-8b6d445b4ea0"
}
const resend = new Resend(
privateConfig.getRawPrivateConfig().server.resend_api_key || "missing"
);
export default resend;
export async function moveEmailToAudience(
email: string,
audienceId: AudienceIds
) {
if (process.env.ENVIRONMENT !== "prod") {
logger.debug(
`Skipping moving email ${email} to audience ${audienceId} in non-prod environment`
);
return;
}
const { error, data } = await retryWithBackoff(async () => {
const { data, error } = await resend.contacts.create({
email,
unsubscribed: false,
audienceId
});
if (error) {
throw new Error(
`Error adding email ${email} to audience ${audienceId}: ${error}`
);
}
return { error, data };
});
if (error) {
logger.error(
`Error adding email ${email} to audience ${audienceId}: ${error}`
);
return;
}
if (data) {
logger.debug(
`Added email ${email} to audience ${audienceId} with contact ID ${data.id}`
);
}
const otherAudiences = Object.values(AudienceIds).filter(
(id) => id !== audienceId
);
for (const otherAudienceId of otherAudiences) {
const { error, data } = await retryWithBackoff(async () => {
const { data, error } = await resend.contacts.remove({
email,
audienceId: otherAudienceId
});
if (error) {
throw new Error(
`Error removing email ${email} from audience ${otherAudienceId}: ${error}`
);
}
return { error, data };
});
if (error) {
logger.error(
`Error removing email ${email} from audience ${otherAudienceId}: ${error}`
);
}
if (data) {
logger.info(
`Removed email ${email} from audience ${otherAudienceId}`
);
}
}
}
type RetryOptions = {
retries?: number;
initialDelayMs?: number;
factor?: number;
};
export async function retryWithBackoff<T>(
fn: () => Promise<T>,
options: RetryOptions = {}
): Promise<T> {
const { retries = 5, initialDelayMs = 500, factor = 2 } = options;
let attempt = 0;
let delay = initialDelayMs;
while (true) {
try {
return await fn();
} catch (err) {
attempt++;
if (attempt > retries) throw err;
await new Promise((resolve) => setTimeout(resolve, delay));
delay *= factor;
}
}
}

View File

@@ -24,6 +24,7 @@ import { eq, and } from "drizzle-orm";
import logger from "@server/logger";
import stripe from "#private/lib/stripe";
import { handleSubscriptionLifesycle } from "../subscriptionLifecycle";
import { AudienceIds, moveEmailToAudience } from "#private/lib/resend";
import { getSubType } from "./getSubType";
import privateConfig from "#private/lib/config";
import { getLicensePriceSet, LicenseId } from "@server/lib/billing/licenses";
@@ -171,7 +172,7 @@ export async function handleSubscriptionCreated(
const email = orgUserRes.user.email;
if (email) {
// TODO: update user in Sendy
moveEmailToAudience(email, AudienceIds.Subscribed);
}
}
} else if (type === "license") {

View File

@@ -23,6 +23,7 @@ import {
import { eq, and } from "drizzle-orm";
import logger from "@server/logger";
import { handleSubscriptionLifesycle } from "../subscriptionLifecycle";
import { AudienceIds, moveEmailToAudience } from "#private/lib/resend";
import { getSubType } from "./getSubType";
import stripe from "#private/lib/stripe";
import privateConfig from "#private/lib/config";
@@ -108,7 +109,7 @@ export async function handleSubscriptionDeleted(
const email = orgUserRes.user.email;
if (email) {
// TODO: update user in Sendy
moveEmailToAudience(email, AudienceIds.Churned);
}
}
} else if (type === "license") {

View File

@@ -22,6 +22,7 @@ import { checkValidInvite } from "@server/auth/checkValidInvite";
import { passwordSchema } from "@server/auth/passwordSchema";
import { UserType } from "@server/types/UserTypes";
import { build } from "@server/build";
import resend, { AudienceIds, moveEmailToAudience } from "#dynamic/lib/resend";
export const signupBodySchema = z.object({
email: z.email().toLowerCase(),
@@ -212,7 +213,7 @@ export async function signup(
logger.debug(
`User ${email} opted in to marketing emails during signup.`
);
// TODO: update user in Sendy
moveEmailToAudience(email, AudienceIds.SignUps);
}
if (config.getRawConfig().flags?.require_email_verification) {

View File

@@ -35,7 +35,11 @@ import {
} from "@app/components/Credenza";
import { cn } from "@app/lib/cn";
import { CreditCard, ExternalLink, Check, AlertTriangle } from "lucide-react";
import { Alert, AlertTitle, AlertDescription } from "@app/components/ui/alert";
import {
Alert,
AlertTitle,
AlertDescription
} from "@app/components/ui/alert";
import {
Tooltip,
TooltipTrigger,
@@ -65,7 +69,6 @@ type PlanOption = {
price: string;
priceDetail?: string;
tierType: Tier | null;
features: string[];
};
const planOptions: PlanOption[] = [
@@ -73,87 +76,41 @@ const planOptions: PlanOption[] = [
id: "basic",
name: "Basic",
price: "Free",
tierType: null,
features: [
"Basic Pangolin features",
"Free provided domains",
"Web-based proxy resources",
"Private resources and clients",
"Peer-to-peer connections"
]
tierType: null
},
{
id: "home",
name: "Home",
price: "$12.50",
priceDetail: "/ month",
tierType: "tier1",
features: [
"Everything in Basic",
"OAuth2/OIDC, Google, & Azure SSO",
"Bring your own identity provider",
"Pangolin SSH",
"Custom branding",
"Device admin approvals"
]
tierType: "tier1"
},
{
id: "team",
name: "Team",
price: "$4",
priceDetail: "per user / month",
tierType: "tier2",
features: [
"Everything in Basic",
"Custom domains",
"OAuth2/OIDC, Google, & Azure SSO",
"Access and action audit logs",
"Device posture information"
]
tierType: "tier2"
},
{
id: "business",
name: "Business",
price: "$9",
priceDetail: "per user / month",
tierType: "tier3",
features: [
"Everything in Team",
"Multiple organizations (multi-tenancy)",
"Auto-provisioning via IdP",
"Pangolin SSH",
"Device approvals",
"Custom branding",
"Business support"
]
tierType: "tier3"
},
{
id: "enterprise",
name: "Enterprise",
price: "Custom",
tierType: null,
features: [
"Everything in Business",
"Custom limits",
"Priority support and SLA",
"Log push and export",
"Private and Gov-Cloud deployment options",
"Dedicated, premium relay/exit nodes",
"Pay by invoice "
]
tierType: null
}
];
// Tier limits mapping derived from limit sets
const tierLimits: Record<
Tier | "basic",
{
users: number;
sites: number;
domains: number;
remoteNodes: number;
organizations: number;
}
{ users: number; sites: number; domains: number; remoteNodes: number; organizations: number }
> = {
basic: {
users: freeLimitSet[FeatureId.USERS]?.value ?? 0,
@@ -506,43 +463,31 @@ export default function BillingPage() {
const isProblematicState = hasProblematicSubscription();
// Get user-friendly subscription status message
const getSubscriptionStatusMessage = (): {
title: string;
description: string;
} | null => {
const getSubscriptionStatusMessage = (): { title: string; description: string } | null => {
if (!tierSubscription?.subscription || !isProblematicState) return null;
const status = tierSubscription.subscription.status;
switch (status) {
case "past_due":
return {
title: t("billingPastDueTitle") || "Payment Past Due",
description:
t("billingPastDueDescription") ||
"Your payment is past due. Please update your payment method to continue using your current plan features. If not resolved, your subscription will be canceled and you'll be reverted to the free tier."
description: t("billingPastDueDescription") || "Your payment is past due. Please update your payment method to continue using your current plan features. If not resolved, your subscription will be canceled and you'll be reverted to the free tier."
};
case "unpaid":
return {
title: t("billingUnpaidTitle") || "Subscription Unpaid",
description:
t("billingUnpaidDescription") ||
"Your subscription is unpaid and you have been reverted to the free tier. Please update your payment method to restore your subscription."
description: t("billingUnpaidDescription") || "Your subscription is unpaid and you have been reverted to the free tier. Please update your payment method to restore your subscription."
};
case "incomplete":
return {
title: t("billingIncompleteTitle") || "Payment Incomplete",
description:
t("billingIncompleteDescription") ||
"Your payment is incomplete. Please complete the payment process to activate your subscription."
description: t("billingIncompleteDescription") || "Your payment is incomplete. Please complete the payment process to activate your subscription."
};
case "incomplete_expired":
return {
title:
t("billingIncompleteExpiredTitle") || "Payment Expired",
description:
t("billingIncompleteExpiredDescription") ||
"Your payment was never completed and has expired. You have been reverted to the free tier. Please subscribe again to restore access to paid features."
title: t("billingIncompleteExpiredTitle") || "Payment Expired",
description: t("billingIncompleteExpiredDescription") || "Your payment was never completed and has expired. You have been reverted to the free tier. Please subscribe again to restore access to paid features."
};
default:
return null;
@@ -564,11 +509,7 @@ export default function BillingPage() {
if (plan.id === currentPlanId) {
// If it's the basic plan (basic with no subscription), show as current but disabled
if (
plan.id === "basic" &&
!hasSubscription &&
!isProblematicState
) {
if (plan.id === "basic" && !hasSubscription && !isProblematicState) {
return {
label: "Current Plan",
action: () => {},
@@ -691,9 +632,7 @@ export default function BillingPage() {
};
// Check if downgrading to a tier would violate current usage limits
const checkLimitViolations = (
targetTier: Tier | "basic"
): Array<{
const checkLimitViolations = (targetTier: Tier | "basic"): Array<{
feature: string;
currentUsage: number;
newLimit: number;
@@ -748,10 +687,7 @@ export default function BillingPage() {
// Check organizations
const organizationsUsage = getUsageValue(ORGINIZATIONS);
if (
limits.organizations > 0 &&
organizationsUsage > limits.organizations
) {
if (limits.organizations > 0 && organizationsUsage > limits.organizations) {
violations.push({
feature: "Organizations",
currentUsage: organizationsUsage,
@@ -776,15 +712,17 @@ export default function BillingPage() {
{isProblematicState && statusMessage && (
<Alert variant="destructive" className="mb-6">
<AlertTriangle className="h-4 w-4" />
<AlertTitle>{statusMessage.title}</AlertTitle>
<AlertTitle>
{statusMessage.title}
</AlertTitle>
<AlertDescription>
{statusMessage.description}{" "}
{statusMessage.description}
{" "}
<button
onClick={handleModifySubscription}
className="underline font-semibold hover:no-underline"
>
{t("billingManageSubscription") ||
"Manage your subscription"}
{t("billingManageSubscription") || "Manage your subscription"}
</button>
</AlertDescription>
</Alert>
@@ -834,10 +772,7 @@ export default function BillingPage() {
</div>
</div>
<div className="mt-4">
{isProblematicState &&
planAction.disabled &&
!isCurrentPlan &&
plan.id !== "enterprise" ? (
{isProblematicState && planAction.disabled && !isCurrentPlan && plan.id !== "enterprise" ? (
<Tooltip>
<TooltipTrigger asChild>
<div>
@@ -849,29 +784,18 @@ export default function BillingPage() {
}
size="sm"
className="w-full"
onClick={
planAction.action
}
onClick={planAction.action}
disabled={
isLoading ||
planAction.disabled
}
loading={
isLoading &&
isCurrentPlan
isLoading || planAction.disabled
}
loading={isLoading && isCurrentPlan}
>
{planAction.label}
</Button>
</div>
</TooltipTrigger>
<TooltipContent>
<p>
{t(
"billingResolvePaymentIssue"
) ||
"Please resolve your payment issue before upgrading or downgrading"}
</p>
<p>{t("billingResolvePaymentIssue") || "Please resolve your payment issue before upgrading or downgrading"}</p>
</TooltipContent>
</Tooltip>
) : (
@@ -885,12 +809,9 @@ export default function BillingPage() {
className="w-full"
onClick={planAction.action}
disabled={
isLoading ||
planAction.disabled
}
loading={
isLoading && isCurrentPlan
isLoading || planAction.disabled
}
loading={isLoading && isCurrentPlan}
>
{planAction.label}
</Button>
@@ -965,38 +886,18 @@ export default function BillingPage() {
<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"
)}
>
<span className={cn(
"text-orange-600 dark:text-orange-400 font-medium"
)}>
{getLimitValue(USERS) ??
t(
"billingUnlimited"
) ??
t("billingUnlimited") ??
"∞"}{" "}
{getLimitValue(
USERS
) !== null && "users"}
{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>
<p>{t("billingUsageExceedsLimit", { current: getUsageValue(USERS), limit: getLimitValue(USERS) ?? 0 }) || `Current usage (${getUsageValue(USERS)}) exceeds limit (${getLimitValue(USERS)})`}</p>
</TooltipContent>
</Tooltip>
) : (
@@ -1004,8 +905,8 @@ export default function BillingPage() {
{getLimitValue(USERS) ??
t("billingUnlimited") ??
"∞"}{" "}
{getLimitValue(USERS) !==
null && "users"}
{getLimitValue(USERS) !== null &&
"users"}
</>
)}
</InfoSectionContent>
@@ -1019,38 +920,18 @@ export default function BillingPage() {
<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"
)}
>
<span className={cn(
"text-orange-600 dark:text-orange-400 font-medium"
)}>
{getLimitValue(SITES) ??
t(
"billingUnlimited"
) ??
t("billingUnlimited") ??
"∞"}{" "}
{getLimitValue(
SITES
) !== null && "sites"}
{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>
<p>{t("billingUsageExceedsLimit", { current: getUsageValue(SITES), limit: getLimitValue(SITES) ?? 0 }) || `Current usage (${getUsageValue(SITES)}) exceeds limit (${getLimitValue(SITES)})`}</p>
</TooltipContent>
</Tooltip>
) : (
@@ -1058,8 +939,8 @@ export default function BillingPage() {
{getLimitValue(SITES) ??
t("billingUnlimited") ??
"∞"}{" "}
{getLimitValue(SITES) !==
null && "sites"}
{getLimitValue(SITES) !== null &&
"sites"}
</>
)}
</InfoSectionContent>
@@ -1073,40 +954,18 @@ export default function BillingPage() {
<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"
) ??
<span className={cn(
"text-orange-600 dark:text-orange-400 font-medium"
)}>
{getLimitValue(DOMAINS) ??
t("billingUnlimited") ??
"∞"}{" "}
{getLimitValue(
DOMAINS
) !== null && "domains"}
{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>
<p>{t("billingUsageExceedsLimit", { current: getUsageValue(DOMAINS), limit: getLimitValue(DOMAINS) ?? 0 }) || `Current usage (${getUsageValue(DOMAINS)}) exceeds limit (${getLimitValue(DOMAINS)})`}</p>
</TooltipContent>
</Tooltip>
) : (
@@ -1114,8 +973,8 @@ export default function BillingPage() {
{getLimitValue(DOMAINS) ??
t("billingUnlimited") ??
"∞"}{" "}
{getLimitValue(DOMAINS) !==
null && "domains"}
{getLimitValue(DOMAINS) !== null &&
"domains"}
</>
)}
</InfoSectionContent>
@@ -1130,40 +989,18 @@ export default function BillingPage() {
<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(
ORGINIZATIONS
) ??
t(
"billingUnlimited"
) ??
<span className={cn(
"text-orange-600 dark:text-orange-400 font-medium"
)}>
{getLimitValue(ORGINIZATIONS) ??
t("billingUnlimited") ??
"∞"}{" "}
{getLimitValue(
ORGINIZATIONS
) !== null && "orgs"}
{getLimitValue(ORGINIZATIONS) !==
null && "orgs"}
</span>
</TooltipTrigger>
<TooltipContent>
<p>
{t(
"billingUsageExceedsLimit",
{
current:
getUsageValue(
ORGINIZATIONS
),
limit:
getLimitValue(
ORGINIZATIONS
) ?? 0
}
) ||
`Current usage (${getUsageValue(ORGINIZATIONS)}) exceeds limit (${getLimitValue(ORGINIZATIONS)})`}
</p>
<p>{t("billingUsageExceedsLimit", { current: getUsageValue(ORGINIZATIONS), limit: getLimitValue(ORGINIZATIONS) ?? 0 }) || `Current usage (${getUsageValue(ORGINIZATIONS)}) exceeds limit (${getLimitValue(ORGINIZATIONS)})`}</p>
</TooltipContent>
</Tooltip>
) : (
@@ -1171,9 +1008,8 @@ export default function BillingPage() {
{getLimitValue(ORGINIZATIONS) ??
t("billingUnlimited") ??
"∞"}{" "}
{getLimitValue(
ORGINIZATIONS
) !== null && "orgs"}
{getLimitValue(ORGINIZATIONS) !==
null && "orgs"}
</>
)}
</InfoSectionContent>
@@ -1188,52 +1024,27 @@ export default function BillingPage() {
<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"
) ??
<span className={cn(
"text-orange-600 dark:text-orange-400 font-medium"
)}>
{getLimitValue(REMOTE_EXIT_NODES) ??
t("billingUnlimited") ??
"∞"}{" "}
{getLimitValue(
REMOTE_EXIT_NODES
) !== null && "nodes"}
{getLimitValue(REMOTE_EXIT_NODES) !==
null && "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>
<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
) ??
{getLimitValue(REMOTE_EXIT_NODES) ??
t("billingUnlimited") ??
"∞"}{" "}
{getLimitValue(
REMOTE_EXIT_NODES
) !== null && "nodes"}
{getLimitValue(REMOTE_EXIT_NODES) !==
null && "nodes"}
</>
)}
</InfoSectionContent>
@@ -1261,8 +1072,7 @@ export default function BillingPage() {
<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"}
{t("billingCurrentKeys") || "Current Keys"}
</div>
<div className="flex items-baseline gap-2">
<span className="text-3xl font-bold">
@@ -1327,101 +1137,61 @@ export default function BillingPage() {
</div>
</div>
{/* Features with check marks */}
{(() => {
const plan = planOptions.find(
(p) =>
p.tierType === pendingTier.tier ||
(pendingTier.tier === "basic" &&
p.id === "basic")
);
return plan?.features?.length ? (
<div>
<h4 className="font-semibold mb-3">
{"What's included:"}
</h4>
<div className="space-y-2">
{plan.features.map(
(feature, i) => (
<div
key={i}
className="flex items-center gap-2"
>
<Check className="h-4 w-4 text-green-600 shrink-0" />
<span>
{feature}
</span>
</div>
)
)}
</div>
</div>
) : null;
})()}
{/* Limits without check marks */}
{tierLimits[pendingTier.tier] && (
<div>
<h4 className="font-semibold mb-3">
{"Up to:"}
{t("billingPlanIncludes") ||
"Plan Includes:"}
</h4>
<div className="space-y-2 text-sm text-muted-foreground">
<div className="space-y-2">
<div className="flex items-center gap-2">
<span className="w-1.5 h-1.5 rounded-full bg-muted-foreground/50 shrink-0" />
<Check className="h-4 w-4 text-green-600" />
<span>
{
tierLimits[
pendingTier.tier
].users
tierLimits[pendingTier.tier]
.users
}{" "}
{t("billingUsers") ||
"Users"}
{t("billingUsers") || "Users"}
</span>
</div>
<div className="flex items-center gap-2">
<span className="w-1.5 h-1.5 rounded-full bg-muted-foreground/50 shrink-0" />
<Check className="h-4 w-4 text-green-600" />
<span>
{
tierLimits[
pendingTier.tier
].sites
tierLimits[pendingTier.tier]
.sites
}{" "}
{t("billingSites") ||
"Sites"}
{t("billingSites") || "Sites"}
</span>
</div>
<div className="flex items-center gap-2">
<span className="w-1.5 h-1.5 rounded-full bg-muted-foreground/50 shrink-0" />
<Check className="h-4 w-4 text-green-600" />
<span>
{
tierLimits[
pendingTier.tier
].domains
tierLimits[pendingTier.tier]
.domains
}{" "}
{t("billingDomains") ||
"Domains"}
</span>
</div>
<div className="flex items-center gap-2">
<span className="w-1.5 h-1.5 rounded-full bg-muted-foreground/50 shrink-0" />
<Check className="h-4 w-4 text-green-600" />
<span>
{
tierLimits[
pendingTier.tier
].organizations
tierLimits[pendingTier.tier]
.organizations
}{" "}
{t(
"billingOrganizations"
) || "Organizations"}
{t("billingOrganizations") ||
"Organizations"}
</span>
</div>
<div className="flex items-center gap-2">
<span className="w-1.5 h-1.5 rounded-full bg-muted-foreground/50 shrink-0" />
<Check className="h-4 w-4 text-green-600" />
<span>
{
tierLimits[
pendingTier.tier
].remoteNodes
tierLimits[pendingTier.tier]
.remoteNodes
}{" "}
{t("billingRemoteNodes") ||
"Remote Nodes"}
@@ -1432,84 +1202,43 @@ export default function BillingPage() {
)}
{/* Warning for limit violations when downgrading */}
{pendingTier.action === "downgrade" &&
(() => {
const violations = checkLimitViolations(
pendingTier.tier
{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>
);
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;
})()}
}
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"}
{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."}
{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>
)}