mirror of
https://github.com/fosrl/pangolin.git
synced 2026-05-10 06:14:06 +00:00
Merge branch 'dev' into feat/resource-policies
This commit is contained in:
@@ -35,11 +35,7 @@ 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,
|
||||
@@ -69,6 +65,7 @@ type PlanOption = {
|
||||
price: string;
|
||||
priceDetail?: string;
|
||||
tierType: Tier | null;
|
||||
features: string[];
|
||||
};
|
||||
|
||||
const planOptions: PlanOption[] = [
|
||||
@@ -76,41 +73,87 @@ const planOptions: PlanOption[] = [
|
||||
id: "basic",
|
||||
name: "Basic",
|
||||
price: "Free",
|
||||
tierType: null
|
||||
tierType: null,
|
||||
features: [
|
||||
"Basic Pangolin features",
|
||||
"Free provided domains",
|
||||
"Web-based proxy resources",
|
||||
"Private resources and clients",
|
||||
"Peer-to-peer connections"
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "home",
|
||||
name: "Home",
|
||||
price: "$12.50",
|
||||
priceDetail: "/ month",
|
||||
tierType: "tier1"
|
||||
tierType: "tier1",
|
||||
features: [
|
||||
"Everything in Basic",
|
||||
"OAuth2/OIDC, Google, & Azure SSO",
|
||||
"Bring your own identity provider",
|
||||
"Pangolin SSH",
|
||||
"Custom branding",
|
||||
"Device admin approvals"
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "team",
|
||||
name: "Team",
|
||||
price: "$4",
|
||||
priceDetail: "per user / month",
|
||||
tierType: "tier2"
|
||||
tierType: "tier2",
|
||||
features: [
|
||||
"Everything in Basic",
|
||||
"Custom domains",
|
||||
"OAuth2/OIDC, Google, & Azure SSO",
|
||||
"Access and action audit logs",
|
||||
"Device posture information"
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "business",
|
||||
name: "Business",
|
||||
price: "$9",
|
||||
priceDetail: "per user / month",
|
||||
tierType: "tier3"
|
||||
tierType: "tier3",
|
||||
features: [
|
||||
"Everything in Team",
|
||||
"Multiple organizations (multi-tenancy)",
|
||||
"Auto-provisioning via IdP",
|
||||
"Pangolin SSH",
|
||||
"Device approvals",
|
||||
"Custom branding",
|
||||
"Business support"
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "enterprise",
|
||||
name: "Enterprise",
|
||||
price: "Custom",
|
||||
tierType: null
|
||||
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 "
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
// 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,
|
||||
@@ -463,31 +506,43 @@ 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;
|
||||
@@ -509,7 +564,11 @@ 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: () => {},
|
||||
@@ -632,7 +691,9 @@ 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;
|
||||
@@ -687,7 +748,10 @@ 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,
|
||||
@@ -712,17 +776,15 @@ 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>
|
||||
@@ -772,7 +834,10 @@ 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>
|
||||
@@ -784,18 +849,29 @@ export default function BillingPage() {
|
||||
}
|
||||
size="sm"
|
||||
className="w-full"
|
||||
onClick={planAction.action}
|
||||
disabled={
|
||||
isLoading || planAction.disabled
|
||||
onClick={
|
||||
planAction.action
|
||||
}
|
||||
disabled={
|
||||
isLoading ||
|
||||
planAction.disabled
|
||||
}
|
||||
loading={
|
||||
isLoading &&
|
||||
isCurrentPlan
|
||||
}
|
||||
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>
|
||||
) : (
|
||||
@@ -809,9 +885,12 @@ export default function BillingPage() {
|
||||
className="w-full"
|
||||
onClick={planAction.action}
|
||||
disabled={
|
||||
isLoading || planAction.disabled
|
||||
isLoading ||
|
||||
planAction.disabled
|
||||
}
|
||||
loading={
|
||||
isLoading && isCurrentPlan
|
||||
}
|
||||
loading={isLoading && isCurrentPlan}
|
||||
>
|
||||
{planAction.label}
|
||||
</Button>
|
||||
@@ -886,18 +965,38 @@ 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>
|
||||
) : (
|
||||
@@ -905,8 +1004,8 @@ export default function BillingPage() {
|
||||
{getLimitValue(USERS) ??
|
||||
t("billingUnlimited") ??
|
||||
"∞"}{" "}
|
||||
{getLimitValue(USERS) !== null &&
|
||||
"users"}
|
||||
{getLimitValue(USERS) !==
|
||||
null && "users"}
|
||||
</>
|
||||
)}
|
||||
</InfoSectionContent>
|
||||
@@ -920,18 +1019,38 @@ 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>
|
||||
) : (
|
||||
@@ -939,8 +1058,8 @@ export default function BillingPage() {
|
||||
{getLimitValue(SITES) ??
|
||||
t("billingUnlimited") ??
|
||||
"∞"}{" "}
|
||||
{getLimitValue(SITES) !== null &&
|
||||
"sites"}
|
||||
{getLimitValue(SITES) !==
|
||||
null && "sites"}
|
||||
</>
|
||||
)}
|
||||
</InfoSectionContent>
|
||||
@@ -954,18 +1073,40 @@ 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>
|
||||
) : (
|
||||
@@ -973,8 +1114,8 @@ export default function BillingPage() {
|
||||
{getLimitValue(DOMAINS) ??
|
||||
t("billingUnlimited") ??
|
||||
"∞"}{" "}
|
||||
{getLimitValue(DOMAINS) !== null &&
|
||||
"domains"}
|
||||
{getLimitValue(DOMAINS) !==
|
||||
null && "domains"}
|
||||
</>
|
||||
)}
|
||||
</InfoSectionContent>
|
||||
@@ -989,18 +1130,40 @@ 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>
|
||||
) : (
|
||||
@@ -1008,8 +1171,9 @@ export default function BillingPage() {
|
||||
{getLimitValue(ORGINIZATIONS) ??
|
||||
t("billingUnlimited") ??
|
||||
"∞"}{" "}
|
||||
{getLimitValue(ORGINIZATIONS) !==
|
||||
null && "orgs"}
|
||||
{getLimitValue(
|
||||
ORGINIZATIONS
|
||||
) !== null && "orgs"}
|
||||
</>
|
||||
)}
|
||||
</InfoSectionContent>
|
||||
@@ -1024,27 +1188,52 @@ 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>
|
||||
@@ -1072,7 +1261,8 @@ 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">
|
||||
@@ -1137,61 +1327,101 @@ 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">
|
||||
{t("billingPlanIncludes") ||
|
||||
"Plan Includes:"}
|
||||
{"Up to:"}
|
||||
</h4>
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-2 text-sm text-muted-foreground">
|
||||
<div className="flex items-center gap-2">
|
||||
<Check className="h-4 w-4 text-green-600" />
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-muted-foreground/50 shrink-0" />
|
||||
<span>
|
||||
{
|
||||
tierLimits[pendingTier.tier]
|
||||
.users
|
||||
tierLimits[
|
||||
pendingTier.tier
|
||||
].users
|
||||
}{" "}
|
||||
{t("billingUsers") || "Users"}
|
||||
{t("billingUsers") ||
|
||||
"Users"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Check className="h-4 w-4 text-green-600" />
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-muted-foreground/50 shrink-0" />
|
||||
<span>
|
||||
{
|
||||
tierLimits[pendingTier.tier]
|
||||
.sites
|
||||
tierLimits[
|
||||
pendingTier.tier
|
||||
].sites
|
||||
}{" "}
|
||||
{t("billingSites") || "Sites"}
|
||||
{t("billingSites") ||
|
||||
"Sites"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Check className="h-4 w-4 text-green-600" />
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-muted-foreground/50 shrink-0" />
|
||||
<span>
|
||||
{
|
||||
tierLimits[pendingTier.tier]
|
||||
.domains
|
||||
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 className="w-1.5 h-1.5 rounded-full bg-muted-foreground/50 shrink-0" />
|
||||
<span>
|
||||
{
|
||||
tierLimits[pendingTier.tier]
|
||||
.organizations
|
||||
tierLimits[
|
||||
pendingTier.tier
|
||||
].organizations
|
||||
}{" "}
|
||||
{t("billingOrganizations") ||
|
||||
"Organizations"}
|
||||
{t(
|
||||
"billingOrganizations"
|
||||
) || "Organizations"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Check className="h-4 w-4 text-green-600" />
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-muted-foreground/50 shrink-0" />
|
||||
<span>
|
||||
{
|
||||
tierLimits[pendingTier.tier]
|
||||
.remoteNodes
|
||||
tierLimits[
|
||||
pendingTier.tier
|
||||
].remoteNodes
|
||||
}{" "}
|
||||
{t("billingRemoteNodes") ||
|
||||
"Remote Nodes"}
|
||||
@@ -1202,43 +1432,84 @@ export default function BillingPage() {
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
{pendingTier.action === "downgrade" &&
|
||||
(() => {
|
||||
const violations = checkLimitViolations(
|
||||
pendingTier.tier
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})()}
|
||||
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"}
|
||||
{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>
|
||||
)}
|
||||
|
||||
@@ -69,6 +69,7 @@ export default async function DomainSettingsPage({
|
||||
failed={domain.failed}
|
||||
verified={domain.verified}
|
||||
type={domain.type}
|
||||
errorMessage={domain.errorMessage}
|
||||
/>
|
||||
|
||||
<DNSRecordsTable records={dnsRecords} type={domain.type} />
|
||||
|
||||
@@ -54,6 +54,7 @@ import {
|
||||
TooltipProvider,
|
||||
TooltipTrigger
|
||||
} from "@app/components/ui/tooltip";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||
@@ -65,6 +66,7 @@ import { build } from "@server/build";
|
||||
import { Resource } from "@server/db";
|
||||
import { isTargetValid } from "@server/lib/validators";
|
||||
import { ListTargetsResponse } from "@server/routers/target";
|
||||
import { ListRemoteExitNodesResponse } from "@server/routers/remoteExitNode/types";
|
||||
import { ArrayElement } from "@server/types/ArrayElement";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
@@ -81,6 +83,7 @@ import {
|
||||
CircleCheck,
|
||||
CircleX,
|
||||
Info,
|
||||
InfoIcon,
|
||||
Plus,
|
||||
Settings,
|
||||
SquareArrowOutUpRight
|
||||
@@ -216,6 +219,11 @@ export default function Page() {
|
||||
orgQueries.sites({ orgId: orgId as string })
|
||||
);
|
||||
|
||||
const [remoteExitNodes, setRemoteExitNodes] = useState<
|
||||
ListRemoteExitNodesResponse["remoteExitNodes"]
|
||||
>([]);
|
||||
const [loadingExitNodes, setLoadingExitNodes] = useState(build === "saas");
|
||||
|
||||
const [createLoading, startTransition] = useTransition();
|
||||
const [showSnippets, setShowSnippets] = useState(false);
|
||||
const [niceId, setNiceId] = useState<string>("");
|
||||
@@ -230,6 +238,27 @@ export default function Page() {
|
||||
useState<LocalTarget | null>(null);
|
||||
const [healthCheckDialogOpen, setHealthCheckDialogOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (build !== "saas") return;
|
||||
|
||||
const fetchExitNodes = async () => {
|
||||
try {
|
||||
const res = await api.get<
|
||||
AxiosResponse<ListRemoteExitNodesResponse>
|
||||
>(`/org/${orgId}/remote-exit-nodes`);
|
||||
if (res && res.status === 200) {
|
||||
setRemoteExitNodes(res.data.data.remoteExitNodes);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to fetch remote exit nodes:", e);
|
||||
} finally {
|
||||
setLoadingExitNodes(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchExitNodes();
|
||||
}, [orgId]);
|
||||
|
||||
const [isAdvancedMode, setIsAdvancedMode] = useState(() => {
|
||||
if (typeof window !== "undefined") {
|
||||
const saved = localStorage.getItem("create-advanced-mode");
|
||||
@@ -295,18 +324,24 @@ export default function Page() {
|
||||
},
|
||||
...(!env.flags.allowRawResources
|
||||
? []
|
||||
: [
|
||||
{
|
||||
id: "raw" as ResourceType,
|
||||
title: t("resourceRaw"),
|
||||
description:
|
||||
build == "saas"
|
||||
? t("resourceRawDescriptionCloud")
|
||||
: t("resourceRawDescription")
|
||||
}
|
||||
])
|
||||
: build === "saas" && remoteExitNodes.length === 0
|
||||
? []
|
||||
: [
|
||||
{
|
||||
id: "raw" as ResourceType,
|
||||
title: t("resourceRaw"),
|
||||
description:
|
||||
build === "saas"
|
||||
? t("resourceRawDescriptionCloud")
|
||||
: t("resourceRawDescription")
|
||||
}
|
||||
])
|
||||
];
|
||||
|
||||
// In saas mode with no exit nodes, force HTTP
|
||||
const showTypeSelector =
|
||||
build !== "saas" || (!loadingExitNodes && remoteExitNodes.length > 0);
|
||||
|
||||
const baseForm = useForm({
|
||||
resolver: zodResolver(baseResourceFormSchema),
|
||||
defaultValues: {
|
||||
@@ -566,7 +601,10 @@ export default function Page() {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("resourceErrorCreate"),
|
||||
description: t("resourceErrorCreateMessageDescription")
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
t("resourceErrorCreateMessageDescription")
|
||||
)
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -989,34 +1027,35 @@ export default function Page() {
|
||||
</SettingsSectionTitle>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
{resourceTypes.length > 1 && (
|
||||
<>
|
||||
<div className="mb-2">
|
||||
<span className="text-sm font-medium">
|
||||
{t("type")}
|
||||
</span>
|
||||
</div>
|
||||
{showTypeSelector &&
|
||||
resourceTypes.length > 1 && (
|
||||
<>
|
||||
<div className="mb-2">
|
||||
<span className="text-sm font-medium">
|
||||
{t("type")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<StrategySelect
|
||||
options={resourceTypes}
|
||||
defaultValue="http"
|
||||
onChange={(value) => {
|
||||
baseForm.setValue(
|
||||
"http",
|
||||
value === "http"
|
||||
);
|
||||
// Update method default when switching resource type
|
||||
addTargetForm.setValue(
|
||||
"method",
|
||||
value === "http"
|
||||
? "http"
|
||||
: null
|
||||
);
|
||||
}}
|
||||
cols={2}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<StrategySelect
|
||||
options={resourceTypes}
|
||||
defaultValue="http"
|
||||
onChange={(value) => {
|
||||
baseForm.setValue(
|
||||
"http",
|
||||
value === "http"
|
||||
);
|
||||
// Update method default when switching resource type
|
||||
addTargetForm.setValue(
|
||||
"method",
|
||||
value === "http"
|
||||
? "http"
|
||||
: null
|
||||
);
|
||||
}}
|
||||
cols={2}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<SettingsSectionForm>
|
||||
<Form {...baseForm}>
|
||||
|
||||
@@ -84,7 +84,7 @@ const CredenzaContent = ({ className, children, ...props }: CredenzaProps) => {
|
||||
return (
|
||||
<CredenzaContent
|
||||
className={cn(
|
||||
"overflow-y-auto max-h-[100dvh] md:max-h-screen md:top-[clamp(1.5rem,12vh,200px)] md:translate-y-0",
|
||||
"overflow-y-auto max-h-[100dvh] md:max-h-[calc(100vh-clamp(3rem,24vh,400px))] md:top-[clamp(1.5rem,12vh,200px)] md:translate-y-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -10,17 +10,20 @@ import {
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Badge } from "./ui/badge";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
|
||||
type DomainInfoCardProps = {
|
||||
failed: boolean;
|
||||
verified: boolean;
|
||||
type: string | null;
|
||||
errorMessage?: string | null;
|
||||
};
|
||||
|
||||
export default function DomainInfoCard({
|
||||
failed,
|
||||
verified,
|
||||
type
|
||||
type,
|
||||
errorMessage
|
||||
}: DomainInfoCardProps) {
|
||||
const t = useTranslations();
|
||||
const env = useEnvContext();
|
||||
@@ -39,6 +42,7 @@ export default function DomainInfoCard({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
<InfoSections cols={3}>
|
||||
@@ -79,5 +83,19 @@ export default function DomainInfoCard({
|
||||
</InfoSections>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
{errorMessage && (failed || !verified) && (
|
||||
<Alert variant={failed ? "destructive" : "warning"}>
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertTitle>
|
||||
{failed
|
||||
? t("domainErrorTitle", { fallback: "Domain Error" })
|
||||
: t("domainPendingErrorTitle", { fallback: "Verification Issue" })}
|
||||
</AlertTitle>
|
||||
<AlertDescription className="font-mono text-xs break-all">
|
||||
{errorMessage}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -27,6 +27,12 @@ import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger
|
||||
} from "./ui/dropdown-menu";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger
|
||||
} from "./ui/tooltip";
|
||||
import Link from "next/link";
|
||||
|
||||
export type DomainRow = {
|
||||
@@ -39,6 +45,7 @@ export type DomainRow = {
|
||||
configManaged: boolean;
|
||||
certResolver: string;
|
||||
preferWildcardCert: boolean;
|
||||
errorMessage?: string | null;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
@@ -175,7 +182,7 @@ export default function DomainsTable({ domains, orgId }: Props) {
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const { verified, failed, type } = row.original;
|
||||
const { verified, failed, type, errorMessage } = row.original;
|
||||
if (verified) {
|
||||
return type == "wildcard" ? (
|
||||
<Badge variant="outlinePrimary">{t("manual")}</Badge>
|
||||
@@ -183,12 +190,44 @@ export default function DomainsTable({ domains, orgId }: Props) {
|
||||
<Badge variant="green">{t("verified")}</Badge>
|
||||
);
|
||||
} else if (failed) {
|
||||
if (errorMessage) {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Badge variant="red" className="cursor-help">
|
||||
{t("failed", { fallback: "Failed" })}
|
||||
</Badge>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-xs">
|
||||
<p className="break-words">{errorMessage}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Badge variant="red">
|
||||
{t("failed", { fallback: "Failed" })}
|
||||
</Badge>
|
||||
);
|
||||
} else {
|
||||
if (errorMessage) {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Badge variant="yellow" className="cursor-help">
|
||||
{t("pending")}
|
||||
</Badge>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-xs">
|
||||
<p className="break-words">{errorMessage}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
return <Badge variant="yellow">{t("pending")}</Badge>;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,6 +51,7 @@ const docsLinkClassName =
|
||||
const PANGOLIN_CLOUD_SIGNUP_URL = "https://app.pangolin.net/auth/signup/";
|
||||
const ENTERPRISE_DOCS_URL =
|
||||
"https://docs.pangolin.net/self-host/enterprise-edition";
|
||||
const BOOK_A_DEMO_URL = "https://click.fossorial.io/ep922";
|
||||
|
||||
function getTierLinkRenderer(billingHref: string) {
|
||||
return function tierLinkRenderer(chunks: React.ReactNode) {
|
||||
@@ -78,6 +79,22 @@ function getPangolinCloudLinkRenderer() {
|
||||
};
|
||||
}
|
||||
|
||||
function getBookADemoLinkRenderer() {
|
||||
return function bookADemoLinkRenderer(chunks: React.ReactNode) {
|
||||
return (
|
||||
<Link
|
||||
href={BOOK_A_DEMO_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={docsLinkClassName}
|
||||
>
|
||||
{chunks}
|
||||
<ExternalLink className="size-3.5 shrink-0" />
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
function getDocsLinkRenderer(href: string) {
|
||||
return function docsLinkRenderer(chunks: React.ReactNode) {
|
||||
return (
|
||||
@@ -116,6 +133,7 @@ export function PaidFeaturesAlert({ tiers }: Props) {
|
||||
const tierLinkRenderer = getTierLinkRenderer(billingHref);
|
||||
const pangolinCloudLinkRenderer = getPangolinCloudLinkRenderer();
|
||||
const enterpriseDocsLinkRenderer = getDocsLinkRenderer(ENTERPRISE_DOCS_URL);
|
||||
const bookADemoLinkRenderer = getBookADemoLinkRenderer();
|
||||
|
||||
if (env.flags.disableEnterpriseFeatures) {
|
||||
return null;
|
||||
@@ -157,7 +175,8 @@ export function PaidFeaturesAlert({ tiers }: Props) {
|
||||
{t.rich("licenseRequiredToUse", {
|
||||
enterpriseLicenseLink:
|
||||
enterpriseDocsLinkRenderer,
|
||||
pangolinCloudLink: pangolinCloudLinkRenderer
|
||||
pangolinCloudLink: pangolinCloudLinkRenderer,
|
||||
bookADemoLink: bookADemoLinkRenderer
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
@@ -174,7 +193,8 @@ export function PaidFeaturesAlert({ tiers }: Props) {
|
||||
{t.rich("ossEnterpriseEditionRequired", {
|
||||
enterpriseEditionLink:
|
||||
enterpriseDocsLinkRenderer,
|
||||
pangolinCloudLink: pangolinCloudLinkRenderer
|
||||
pangolinCloudLink: pangolinCloudLinkRenderer,
|
||||
bookADemoLink: bookADemoLinkRenderer
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user