-
+
{
- tierLimits[pendingTier.tier]
- .users
+ tierLimits[
+ pendingTier.tier
+ ].users
}{" "}
- {t("billingUsers") || "Users"}
+ {t("billingUsers") ||
+ "Users"}
-
+
{
- tierLimits[pendingTier.tier]
- .sites
+ tierLimits[
+ pendingTier.tier
+ ].sites
}{" "}
- {t("billingSites") || "Sites"}
+ {t("billingSites") ||
+ "Sites"}
-
+
{
- tierLimits[pendingTier.tier]
- .domains
+ tierLimits[
+ pendingTier.tier
+ ].domains
}{" "}
{t("billingDomains") ||
"Domains"}
-
+
{
- tierLimits[pendingTier.tier]
- .organizations
+ tierLimits[
+ pendingTier.tier
+ ].organizations
}{" "}
- {t("billingOrganizations") ||
- "Organizations"}
+ {t(
+ "billingOrganizations"
+ ) || "Organizations"}
-
+
{
- 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 (
-
-
-
- {t("billingLimitViolationWarning") || "Usage Exceeds New Plan Limits"}
-
-
-
- {t("billingLimitViolationDescription") || "Your current usage exceeds the limits of this plan. The following features will be disabled until you reduce usage:"}
-
-
- {violations.map((violation, index) => (
-
- {violation.feature}:
- Currently using {violation.currentUsage}, new limit is {violation.newLimit}
-
- ))}
-
-
-
+ {pendingTier.action === "downgrade" &&
+ (() => {
+ const violations = checkLimitViolations(
+ pendingTier.tier
);
- }
- return null;
- })()}
+ if (violations.length > 0) {
+ return (
+
+
+
+ {t(
+ "billingLimitViolationWarning"
+ ) ||
+ "Usage Exceeds New Plan Limits"}
+
+
+
+ {t(
+ "billingLimitViolationDescription"
+ ) ||
+ "Your current usage exceeds the limits of this plan. The following features will be disabled until you reduce usage:"}
+
+
+ {violations.map(
+ (
+ violation,
+ index
+ ) => (
+
+
+ {
+ violation.feature
+ }
+ :
+
+
+ Currently
+ using{" "}
+ {
+ violation.currentUsage
+ }
+ ,
+ new
+ limit
+ is{" "}
+ {
+ violation.newLimit
+ }
+
+
+ )
+ )}
+
+
+
+ );
+ }
+ return null;
+ })()}
{/* Warning for feature loss when downgrading */}
{pendingTier.action === "downgrade" && (
- {t("billingFeatureLossWarning") || "Feature Availability Notice"}
+ {t("billingFeatureLossWarning") ||
+ "Feature Availability Notice"}
- {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."}
)}
diff --git a/src/app/[orgId]/settings/domains/[domainId]/page.tsx b/src/app/[orgId]/settings/domains/[domainId]/page.tsx
index 39ad02db2..cf23e81be 100644
--- a/src/app/[orgId]/settings/domains/[domainId]/page.tsx
+++ b/src/app/[orgId]/settings/domains/[domainId]/page.tsx
@@ -69,6 +69,7 @@ export default async function DomainSettingsPage({
failed={domain.failed}
verified={domain.verified}
type={domain.type}
+ errorMessage={domain.errorMessage}
/>
diff --git a/src/app/[orgId]/settings/resources/proxy/create/page.tsx b/src/app/[orgId]/settings/resources/proxy/create/page.tsx
index ff51a311b..4c8cb8443 100644
--- a/src/app/[orgId]/settings/resources/proxy/create/page.tsx
+++ b/src/app/[orgId]/settings/resources/proxy/create/page.tsx
@@ -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
@@ -210,6 +213,13 @@ export default function Page() {
orgQueries.sites({ orgId: orgId as string })
);
+ const [remoteExitNodes, setRemoteExitNodes] = useState<
+ ListRemoteExitNodesResponse["remoteExitNodes"]
+ >([]);
+ const [loadingExitNodes, setLoadingExitNodes] = useState(
+ build === "saas"
+ );
+
const [createLoading, setCreateLoading] = useState(false);
const [showSnippets, setShowSnippets] = useState(false);
const [niceId, setNiceId] = useState("");
@@ -224,6 +234,27 @@ export default function Page() {
useState(null);
const [healthCheckDialogOpen, setHealthCheckDialogOpen] = useState(false);
+ useEffect(() => {
+ if (build !== "saas") return;
+
+ const fetchExitNodes = async () => {
+ try {
+ const res = await api.get<
+ AxiosResponse
+ >(`/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");
@@ -289,15 +320,25 @@ 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: {
@@ -559,7 +600,7 @@ export default function Page() {
toast({
variant: "destructive",
title: t("resourceErrorCreate"),
- description: t("resourceErrorCreateMessageDescription")
+ description: formatAxiosError(e, t("resourceErrorCreateMessageDescription"))
});
}
@@ -984,34 +1025,35 @@ export default function Page() {
- {resourceTypes.length > 1 && (
- <>
-
-
- {t("type")}
-
-
+ {showTypeSelector &&
+ resourceTypes.length > 1 && (
+ <>
+
+
+ {t("type")}
+
+
- {
- baseForm.setValue(
- "http",
- value === "http"
- );
- // Update method default when switching resource type
- addTargetForm.setValue(
- "method",
- value === "http"
- ? "http"
- : null
- );
- }}
- cols={2}
- />
- >
- )}
+ {
+ baseForm.setValue(
+ "http",
+ value === "http"
+ );
+ // Update method default when switching resource type
+ addTargetForm.setValue(
+ "method",
+ value === "http"
+ ? "http"
+ : null
+ );
+ }}
+ cols={2}
+ />
+ >
+ )}
);
}
diff --git a/src/components/DomainsTable.tsx b/src/components/DomainsTable.tsx
index ff23df67b..f5cb1ae74 100644
--- a/src/components/DomainsTable.tsx
+++ b/src/components/DomainsTable.tsx
@@ -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" ? (
{t("manual")}
@@ -183,12 +190,44 @@ export default function DomainsTable({ domains, orgId }: Props) {
{t("verified")}
);
} else if (failed) {
+ if (errorMessage) {
+ return (
+
+
+
+
+ {t("failed", { fallback: "Failed" })}
+
+
+
+ {errorMessage}
+
+
+
+ );
+ }
return (
{t("failed", { fallback: "Failed" })}
);
} else {
+ if (errorMessage) {
+ return (
+
+
+
+
+ {t("pending")}
+
+
+
+ {errorMessage}
+
+
+
+ );
+ }
return
{t("pending")} ;
}
}
diff --git a/src/components/PaidFeaturesAlert.tsx b/src/components/PaidFeaturesAlert.tsx
index adbb49d9e..95179ea78 100644
--- a/src/components/PaidFeaturesAlert.tsx
+++ b/src/components/PaidFeaturesAlert.tsx
@@ -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 (
+
+ {chunks}
+
+
+ );
+ };
+}
+
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
})}
@@ -174,7 +193,8 @@ export function PaidFeaturesAlert({ tiers }: Props) {
{t.rich("ossEnterpriseEditionRequired", {
enterpriseEditionLink:
enterpriseDocsLinkRenderer,
- pangolinCloudLink: pangolinCloudLinkRenderer
+ pangolinCloudLink: pangolinCloudLinkRenderer,
+ bookADemoLink: bookADemoLinkRenderer
})}