Merge branch 'dev' into multi-role

This commit is contained in:
miloschwartz
2026-03-24 22:01:13 -07:00
266 changed files with 7813 additions and 5880 deletions

View File

@@ -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,
@@ -210,7 +253,8 @@ export default function BillingPage() {
({ subscription }) =>
subscription?.type === "tier1" ||
subscription?.type === "tier2" ||
subscription?.type === "tier3"
subscription?.type === "tier3" ||
subscription?.type === "enterprise"
);
setTierSubscription(tierSub || null);
@@ -439,6 +483,8 @@ export default function BillingPage() {
// Get current plan ID from tier
const getCurrentPlanId = (): PlanId => {
if (!hasSubscription || !currentTier) return "basic";
// Handle enterprise subscription type directly
if (currentTier === "enterprise") return "enterprise";
const plan = planOptions.find((p) => p.tierType === currentTier);
return plan?.id || "basic";
};
@@ -460,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;
@@ -506,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: () => {},
@@ -629,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;
@@ -684,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,
@@ -709,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>
@@ -769,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>
@@ -781,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>
) : (
@@ -806,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>
@@ -883,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>
) : (
@@ -902,8 +1004,8 @@ export default function BillingPage() {
{getLimitValue(USERS) ??
t("billingUnlimited") ??
"∞"}{" "}
{getLimitValue(USERS) !== null &&
"users"}
{getLimitValue(USERS) !==
null && "users"}
</>
)}
</InfoSectionContent>
@@ -917,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>
) : (
@@ -936,8 +1058,8 @@ export default function BillingPage() {
{getLimitValue(SITES) ??
t("billingUnlimited") ??
"∞"}{" "}
{getLimitValue(SITES) !== null &&
"sites"}
{getLimitValue(SITES) !==
null && "sites"}
</>
)}
</InfoSectionContent>
@@ -951,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>
) : (
@@ -970,8 +1114,8 @@ export default function BillingPage() {
{getLimitValue(DOMAINS) ??
t("billingUnlimited") ??
"∞"}{" "}
{getLimitValue(DOMAINS) !== null &&
"domains"}
{getLimitValue(DOMAINS) !==
null && "domains"}
</>
)}
</InfoSectionContent>
@@ -986,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>
) : (
@@ -1005,8 +1171,9 @@ export default function BillingPage() {
{getLimitValue(ORGINIZATIONS) ??
t("billingUnlimited") ??
"∞"}{" "}
{getLimitValue(ORGINIZATIONS) !==
null && "orgs"}
{getLimitValue(
ORGINIZATIONS
) !== null && "orgs"}
</>
)}
</InfoSectionContent>
@@ -1021,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>
@@ -1069,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">
@@ -1134,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"}
@@ -1199,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>
)}

View File

@@ -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} />

View File

@@ -187,7 +187,11 @@ export default function ResourceAuthenticationPage() {
number | null
>(null);
const [ssoEnabled, setSsoEnabled] = useState(resource.sso);
const [ssoEnabled, setSsoEnabled] = useState(resource.sso ?? false);
useEffect(() => {
setSsoEnabled(resource.sso ?? false);
}, [resource.sso]);
const [selectedIdpId, setSelectedIdpId] = useState<number | null>(
resource.skipToIdpId || null
@@ -472,7 +476,7 @@ export default function ResourceAuthenticationPage() {
<SwitchInput
id="sso-toggle"
label={t("ssoUse")}
defaultChecked={resource.sso}
checked={ssoEnabled}
onCheckedChange={(val) => setSsoEnabled(val)}
/>
@@ -800,8 +804,13 @@ function OneTimePasswordFormSection({
}: OneTimePasswordFormSectionProps) {
const { env } = useEnvContext();
const [whitelistEnabled, setWhitelistEnabled] = useState(
resource.emailWhitelistEnabled
resource.emailWhitelistEnabled ?? false
);
useEffect(() => {
setWhitelistEnabled(resource.emailWhitelistEnabled);
}, [resource.emailWhitelistEnabled]);
const queryClient = useQueryClient();
const [loadingSaveWhitelist, startTransition] = useTransition();
@@ -894,7 +903,7 @@ function OneTimePasswordFormSection({
<SwitchInput
id="whitelist-toggle"
label={t("otpEmailWhitelist")}
defaultChecked={resource.emailWhitelistEnabled}
checked={whitelistEnabled}
onCheckedChange={setWhitelistEnabled}
disabled={!env.email.emailEnabled}
/>

View File

@@ -89,7 +89,14 @@ import {
} from "lucide-react";
import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation";
import { use, useActionState, useCallback, useEffect, useMemo, useState } from "react";
import {
use,
useActionState,
useCallback,
useEffect,
useMemo,
useState
} from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
@@ -184,29 +191,35 @@ function ProxyResourceTargetsForm({
setDockerStates((prev) => new Map(prev.set(siteId, dockerState)));
};
const refreshContainersForSite = useCallback(async (siteId: number) => {
const dockerManager = new DockerManager(api, siteId);
const containers = await dockerManager.fetchContainers();
const refreshContainersForSite = useCallback(
async (siteId: number) => {
const dockerManager = new DockerManager(api, siteId);
const containers = await dockerManager.fetchContainers();
setDockerStates((prev) => {
const newMap = new Map(prev);
const existingState = newMap.get(siteId);
if (existingState) {
newMap.set(siteId, { ...existingState, containers });
}
return newMap;
});
}, [api]);
setDockerStates((prev) => {
const newMap = new Map(prev);
const existingState = newMap.get(siteId);
if (existingState) {
newMap.set(siteId, { ...existingState, containers });
}
return newMap;
});
},
[api]
);
const getDockerStateForSite = useCallback((siteId: number): DockerState => {
return (
dockerStates.get(siteId) || {
isEnabled: false,
isAvailable: false,
containers: []
}
);
}, [dockerStates]);
const getDockerStateForSite = useCallback(
(siteId: number): DockerState => {
return (
dockerStates.get(siteId) || {
isEnabled: false,
isAvailable: false,
containers: []
}
);
},
[dockerStates]
);
const [isAdvancedMode, setIsAdvancedMode] = useState(() => {
if (typeof window !== "undefined") {
@@ -220,7 +233,9 @@ function ProxyResourceTargetsForm({
const removeTarget = useCallback((targetId: number) => {
setTargets((prevTargets) => {
const targetToRemove = prevTargets.find((target) => target.targetId === targetId);
const targetToRemove = prevTargets.find(
(target) => target.targetId === targetId
);
if (targetToRemove && !targetToRemove.new) {
setTargetsToRemove((prev) => [...prev, targetId]);
}
@@ -228,21 +243,24 @@ function ProxyResourceTargetsForm({
});
}, []);
const updateTarget = useCallback((targetId: number, data: Partial<LocalTarget>) => {
setTargets((prevTargets) => {
const site = sites.find((site) => site.siteId === data.siteId);
return prevTargets.map((target) =>
target.targetId === targetId
? {
...target,
...data,
updated: true,
siteType: site ? site.type : target.siteType
}
: target
);
});
}, [sites]);
const updateTarget = useCallback(
(targetId: number, data: Partial<LocalTarget>) => {
setTargets((prevTargets) => {
const site = sites.find((site) => site.siteId === data.siteId);
return prevTargets.map((target) =>
target.targetId === targetId
? {
...target,
...data,
updated: true,
siteType: site ? site.type : target.siteType
}
: target
);
});
},
[sites]
);
const openHealthCheckDialog = useCallback((target: LocalTarget) => {
setSelectedTargetForHealthCheck(target);
@@ -250,7 +268,6 @@ function ProxyResourceTargetsForm({
}, []);
const columns = useMemo((): ColumnDef<LocalTarget>[] => {
const priorityColumn: ColumnDef<LocalTarget> = {
id: "priority",
header: () => (
@@ -581,7 +598,17 @@ function ProxyResourceTargetsForm({
actionsColumn
];
}
}, [isAdvancedMode, isHttp, sites, updateTarget, getDockerStateForSite, refreshContainersForSite, openHealthCheckDialog, removeTarget, t]);
}, [
isAdvancedMode,
isHttp,
sites,
updateTarget,
getDockerStateForSite,
refreshContainersForSite,
openHealthCheckDialog,
removeTarget,
t
]);
function addNewTarget() {
const isHttp = resource.http;

View File

@@ -113,7 +113,12 @@ export default function ResourceRules(props: {
const [rulesToRemove, setRulesToRemove] = useState<number[]>([]);
const [loading, setLoading] = useState(false);
const [pageLoading, setPageLoading] = useState(true);
const [rulesEnabled, setRulesEnabled] = useState(resource.applyRules);
const [rulesEnabled, setRulesEnabled] = useState(resource.applyRules ?? false);
useEffect(() => {
setRulesEnabled(resource.applyRules);
}, [resource.applyRules]);
const [openCountrySelect, setOpenCountrySelect] = useState(false);
const [countrySelectValue, setCountrySelectValue] = useState("");
const [openAddRuleCountrySelect, setOpenAddRuleCountrySelect] =
@@ -836,7 +841,7 @@ export default function ResourceRules(props: {
<SwitchInput
id="rules-toggle"
label={t("rulesEnable")}
defaultChecked={rulesEnabled}
checked={rulesEnabled}
onCheckedChange={(val) => setRulesEnabled(val)}
/>
</div>

View File

@@ -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";
@@ -61,9 +62,11 @@ import { DockerManager, DockerState } from "@app/lib/docker";
import { orgQueries } from "@app/lib/queries";
import { finalizeSubdomainSanitize } from "@app/lib/subdomain-utils";
import { zodResolver } from "@hookform/resolvers/zod";
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 {
@@ -80,6 +83,7 @@ import {
CircleCheck,
CircleX,
Info,
InfoIcon,
Plus,
Settings,
SquareArrowOutUpRight
@@ -209,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<string>("");
@@ -223,6 +234,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");
@@ -288,15 +320,25 @@ export default function Page() {
},
...(!env.flags.allowRawResources
? []
: [
{
id: "raw" as ResourceType,
title: t("resourceRaw"),
description: 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: {
@@ -558,7 +600,7 @@ export default function Page() {
toast({
variant: "destructive",
title: t("resourceErrorCreate"),
description: t("resourceErrorCreateMessageDescription")
description: formatAxiosError(e, t("resourceErrorCreateMessageDescription"))
});
}
@@ -983,34 +1025,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}>
@@ -1066,6 +1109,9 @@ export default function Page() {
<SettingsSectionBody>
<DomainPicker
orgId={orgId as string}
warnOnProvidedDomain={
remoteExitNodes.length >= 1
}
onDomainChange={(res) => {
if (!res) return;

View File

@@ -3,11 +3,12 @@ import { redirect } from "next/navigation";
import DeviceLoginForm from "@/components/DeviceLoginForm";
import { getUserDisplayName } from "@app/lib/getUserDisplayName";
import { cache } from "react";
import { cleanRedirect } from "@app/lib/cleanRedirect";
export const dynamic = "force-dynamic";
type Props = {
searchParams: Promise<{ code?: string; user?: string }>;
searchParams: Promise<{ code?: string; user?: string; authPath?: string }>;
};
function deviceRedirectSearchParams(params: {
@@ -30,11 +31,11 @@ export default async function DeviceLoginPage({ searchParams }: Props) {
if (!user) {
const redirectDestination = `/auth/login/device${deviceRedirectSearchParams({ code, user: params.user })}`;
const loginUrl = new URL("/auth/login", "http://x");
const authPath = cleanRedirect(params.authPath || "/auth/login");
const loginUrl = new URL(authPath, "http://x");
loginUrl.searchParams.set("forceLogin", "true");
loginUrl.searchParams.set("redirect", redirectDestination);
if (defaultUser) loginUrl.searchParams.set("user", defaultUser);
console.log("loginUrl", loginUrl.pathname + loginUrl.search);
redirect(loginUrl.pathname + loginUrl.search);
}

View File

@@ -107,7 +107,7 @@ export const orgNavSections = (
]
},
{
heading: "access",
heading: "accessControl",
items: [
{
title: "sidebarTeam",

View File

@@ -15,7 +15,15 @@ import { InfoPopup } from "@app/components/ui/info-popup";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import { ArrowUpDown, ArrowUpRight, MoreHorizontal } from "lucide-react";
import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn";
import {
ArrowDown01Icon,
ArrowUp10Icon,
ArrowUpDown,
ArrowUpRight,
ChevronsUpDownIcon,
MoreHorizontal
} from "lucide-react";
import { useTranslations } from "next-intl";
import Link from "next/link";
import { useRouter } from "next/navigation";
@@ -133,7 +141,26 @@ export default function ClientResourcesTable({
accessorKey: "name",
enableHiding: false,
friendlyName: t("name"),
header: () => <span className="p-3">{t("name")}</span>
header: () => {
const nameOrder = getSortDirection("name", searchParams);
const Icon =
nameOrder === "asc"
? ArrowDown01Icon
: nameOrder === "desc"
? ArrowUp10Icon
: ChevronsUpDownIcon;
return (
<Button
variant="ghost"
className="p-3"
onClick={() => toggleSort("name")}
>
{t("name")}
<Icon className="ml-2 h-4 w-4" />
</Button>
);
}
},
{
id: "niceId",
@@ -329,6 +356,14 @@ export default function ClientResourcesTable({
});
}
function toggleSort(column: string) {
const newSearch = getNextSortOrder(column, searchParams);
filter({
searchParams: newSearch
});
}
const handlePaginationChange = (newPage: PaginationState) => {
searchParams.set("page", (newPage.pageIndex + 1).toString());
searchParams.set("pageSize", newPage.pageSize.toString());

View File

@@ -171,8 +171,7 @@ const DockerContainersTable: FC<{
...Object.values(container.networks)
.map((n) => n.ipAddress)
.filter(Boolean),
...getExposedPorts(container).map((p) => p.toString()),
...Object.entries(container.labels).flat()
...getExposedPorts(container).map((p) => p.toString())
];
return searchableFields.some((field) =>

View File

@@ -31,6 +31,18 @@ const CopyToClipboard = ({
return (
<div className="flex items-center space-x-2 min-w-0 max-w-full">
<button
type="button"
className="h-6 w-6 p-0 flex items-center justify-center cursor-pointer flex-shrink-0"
onClick={handleCopy}
>
{!copied ? (
<Copy className="h-4 w-4" />
) : (
<Check className="text-green-500 h-4 w-4" />
)}
<span className="sr-only">{t("copyText")}</span>
</button>
{isLink ? (
<Link
href={text}
@@ -54,18 +66,6 @@ const CopyToClipboard = ({
{displayValue}
</span>
)}
<button
type="button"
className="h-6 w-6 p-0 flex items-center justify-center cursor-pointer flex-shrink-0"
onClick={handleCopy}
>
{!copied ? (
<Copy className="h-4 w-4" />
) : (
<Check className="text-green-500 h-4 w-4" />
)}
<span className="sr-only">{t("copyText")}</span>
</button>
</div>
);
};

View File

@@ -20,7 +20,7 @@ import {
import { toast } from "@app/hooks/useToast";
import { zodResolver } from "@hookform/resolvers/zod";
import { AxiosResponse } from "axios";
import { useEffect, useState } from "react";
import { useState, useMemo } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import CopyTextBox from "@app/components/CopyTextBox";
@@ -39,7 +39,8 @@ import { formatAxiosError } from "@app/lib/api";
import { cn } from "@app/lib/cn";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { ListResourcesResponse } from "@server/routers/resource";
import { useQuery } from "@tanstack/react-query";
import { orgQueries } from "@app/lib/queries";
import {
Popover,
PopoverContent,
@@ -94,14 +95,22 @@ export default function CreateShareLinkForm({
const [isOpen, setIsOpen] = useState(false);
const t = useTranslations();
const [resources, setResources] = useState<
{
resourceId: number;
name: string;
niceId: string;
resourceUrl: string;
}[]
>([]);
const { data: allResources = [] } = useQuery(
orgQueries.resources({ orgId: org?.org.orgId ?? "" })
);
const resources = useMemo(
() =>
allResources
.filter((r) => r.http)
.map((r) => ({
resourceId: r.resourceId,
name: r.name,
niceId: r.niceId,
resourceUrl: `${r.ssl ? "https://" : "http://"}${toUnicode(r.fullDomain || "")}/`
})),
[allResources]
);
const formSchema = z.object({
resourceId: z.number({ message: t("shareErrorSelectResource") }),
@@ -130,47 +139,6 @@ export default function CreateShareLinkForm({
}
});
useEffect(() => {
if (!open) {
return;
}
async function fetchResources() {
const res = await api
.get<
AxiosResponse<ListResourcesResponse>
>(`/org/${org?.org.orgId}/resources`)
.catch((e) => {
console.error(e);
toast({
variant: "destructive",
title: t("shareErrorFetchResource"),
description: formatAxiosError(
e,
t("shareErrorFetchResourceDescription")
)
});
});
if (res?.status === 200) {
setResources(
res.data.data.resources
.filter((r) => {
return r.http;
})
.map((r) => ({
resourceId: r.resourceId,
name: r.name,
niceId: r.niceId,
resourceUrl: `${r.ssl ? "https://" : "http://"}${toUnicode(r.fullDomain || "")}/`
}))
);
}
}
fetchResources();
}, [open]);
async function onSubmit(values: z.infer<typeof formSchema>) {
setLoading(true);

View File

@@ -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}

View File

@@ -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>
);
}

View File

@@ -79,6 +79,7 @@ interface DomainPickerProps {
defaultFullDomain?: string | null;
defaultSubdomain?: string | null;
defaultDomainId?: string | null;
warnOnProvidedDomain?: boolean;
}
export default function DomainPicker({
@@ -88,7 +89,8 @@ export default function DomainPicker({
hideFreeDomain = false,
defaultSubdomain,
defaultFullDomain,
defaultDomainId
defaultDomainId,
warnOnProvidedDomain = false
}: DomainPickerProps) {
const { env } = useEnvContext();
const api = createApiClient({ env });
@@ -689,6 +691,14 @@ export default function DomainPicker({
{showProvidedDomainSearch && (
<div className="space-y-4">
{warnOnProvidedDomain && (
<Alert variant="warning">
<AlertCircle className="h-4 w-4" />
<AlertDescription>
{t("domainPickerRemoteExitNodeWarning")}
</AlertDescription>
</Alert>
)}
{isChecking && (
<div className="flex items-center justify-center p-8">
<div className="flex items-center space-x-2 text-sm text-muted-foreground">

View File

@@ -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>;
}
}

View File

@@ -640,7 +640,7 @@ export function InternalResourceForm({
title: t("editInternalResourceDialogAccessPolicy"),
href: "#"
},
...(disableEnterpriseFeatures
...(disableEnterpriseFeatures || mode === "cidr"
? []
: [{ title: t("sshAccess"), href: "#" }])
]}
@@ -1188,138 +1188,152 @@ export function InternalResourceForm({
</div>
{/* SSH Access tab */}
{!disableEnterpriseFeatures && (
<div className="space-y-4 mt-4">
<PaidFeaturesAlert tiers={tierMatrix.sshPam} />
<div className="mb-8">
<label className="font-medium block">
{t("internalResourceAuthDaemonStrategy")}
</label>
<div className="text-sm text-muted-foreground">
{t.rich(
"internalResourceAuthDaemonDescription",
{
docsLink: (chunks) => (
<a
href={
"https://docs.pangolin.net/manage/ssh#setup-choose-your-architecture"
}
target="_blank"
rel="noopener noreferrer"
className={
"text-primary inline-flex items-center gap-1"
}
>
{chunks}
<ExternalLink className="size-3.5 shrink-0" />
</a>
)
}
)}
{!disableEnterpriseFeatures && mode !== "cidr" && (
<div className="space-y-4 mt-4">
<PaidFeaturesAlert tiers={tierMatrix.sshPam} />
<div className="mb-8">
<label className="font-medium block">
{t("internalResourceAuthDaemonStrategy")}
</label>
<div className="text-sm text-muted-foreground">
{t.rich(
"internalResourceAuthDaemonDescription",
{
docsLink: (chunks) => (
<a
href={
"https://docs.pangolin.net/manage/ssh#setup-choose-your-architecture"
}
target="_blank"
rel="noopener noreferrer"
className={
"text-primary inline-flex items-center gap-1"
}
>
{chunks}
<ExternalLink className="size-3.5 shrink-0" />
</a>
)
}
)}
</div>
</div>
</div>
<div className="space-y-4">
<FormField
control={form.control}
name="authDaemonMode"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"internalResourceAuthDaemonStrategyLabel"
)}
</FormLabel>
<FormControl>
<StrategySelect<"site" | "remote">
value={field.value ?? undefined}
options={[
{
id: "site",
title: t(
"internalResourceAuthDaemonSite"
),
description: t(
"internalResourceAuthDaemonSiteDescription"
),
disabled: sshSectionDisabled
},
{
id: "remote",
title: t(
"internalResourceAuthDaemonRemote"
),
description: t(
"internalResourceAuthDaemonRemoteDescription"
),
disabled: sshSectionDisabled
}
]}
onChange={(v) => {
if (sshSectionDisabled) return;
field.onChange(v);
if (v === "site") {
form.setValue(
"authDaemonPort",
null
);
}
}}
cols={2}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{authDaemonMode === "remote" && (
<div className="space-y-4">
<FormField
control={form.control}
name="authDaemonPort"
name="authDaemonMode"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"internalResourceAuthDaemonPort"
"internalResourceAuthDaemonStrategyLabel"
)}
</FormLabel>
<FormControl>
<Input
type="number"
min={1}
max={65535}
placeholder="22123"
{...field}
disabled={sshSectionDisabled}
value={field.value ?? ""}
onChange={(e) => {
if (sshSectionDisabled) return;
const v =
e.target.value;
if (v === "") {
field.onChange(
<StrategySelect<
"site" | "remote"
>
value={
field.value ?? undefined
}
options={[
{
id: "site",
title: t(
"internalResourceAuthDaemonSite"
),
description: t(
"internalResourceAuthDaemonSiteDescription"
),
disabled:
sshSectionDisabled
},
{
id: "remote",
title: t(
"internalResourceAuthDaemonRemote"
),
description: t(
"internalResourceAuthDaemonRemoteDescription"
),
disabled:
sshSectionDisabled
}
]}
onChange={(v) => {
if (sshSectionDisabled)
return;
field.onChange(v);
if (v === "site") {
form.setValue(
"authDaemonPort",
null
);
return;
}
const num = parseInt(
v,
10
);
field.onChange(
Number.isNaN(num)
? null
: num
);
}}
cols={2}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
{authDaemonMode === "remote" && (
<FormField
control={form.control}
name="authDaemonPort"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"internalResourceAuthDaemonPort"
)}
</FormLabel>
<FormControl>
<Input
type="number"
min={1}
max={65535}
placeholder="22123"
{...field}
disabled={
sshSectionDisabled
}
value={
field.value ?? ""
}
onChange={(e) => {
if (
sshSectionDisabled
)
return;
const v =
e.target.value;
if (v === "") {
field.onChange(
null
);
return;
}
const num =
parseInt(v, 10);
field.onChange(
Number.isNaN(
num
)
? null
: num
);
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
</div>
</div>
</div>
)}
</HorizontalTabs>
</form>

View File

@@ -69,15 +69,16 @@ export function LayoutMobileMenu({
<SheetDescription className="sr-only">
{t("navbarDescription")}
</SheetDescription>
<div className="flex-1 overflow-y-auto relative">
<div className="px-1">
<div className="w-full border-b border-border">
<div className="px-1 shrink-0">
<OrgSelector
orgId={orgId}
orgs={orgs}
/>
</div>
<div className="w-full border-b border-border" />
<div className="px-3 pt-3">
</div>
<div className="flex-1 overflow-y-auto relative">
<div className="px-3">
{!isAdminPage &&
user.serverAdmin && (
<div className="mb-1">

View File

@@ -204,7 +204,26 @@ export default function MachineClientsTable({
accessorKey: "name",
enableHiding: false,
friendlyName: t("name"),
header: () => <span className="px-3">{t("name")}</span>,
header: () => {
const nameOrder = getSortDirection("name", searchParams);
const Icon =
nameOrder === "asc"
? ArrowDown01Icon
: nameOrder === "desc"
? ArrowUp10Icon
: ChevronsUpDownIcon;
return (
<Button
variant="ghost"
onClick={() => toggleSort("name")}
className="px-3"
>
{t("name")}
<Icon className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const r = row.original;
return (

View File

@@ -129,6 +129,11 @@ const ResourceInfo = ({ resource }: { resource: Resource }) => {
resource.pincode ||
resource.whitelist;
const hasAnyInfo =
Boolean(resource.siteName) || Boolean(hasAuthMethods) || !resource.enabled;
if (!hasAnyInfo) return null;
const infoContent = (
<div className="flex flex-col gap-3">
{/* Site Information */}
@@ -828,6 +833,12 @@ export default function MemberResourcesPortal({
</span>
</div>
)}
<div>
<span className="font-medium">Destination:</span>
<span className="ml-2 text-muted-foreground">
{siteResource.destination}
</span>
</div>
{siteResource.alias && (
<div>
<span className="font-medium">Alias:</span>
@@ -836,14 +847,6 @@ export default function MemberResourcesPortal({
</span>
</div>
)}
{siteResource.aliasAddress && (
<div>
<span className="font-medium">Alias Address:</span>
<span className="ml-2 text-muted-foreground">
{siteResource.aliasAddress}
</span>
</div>
)}
<div>
<span className="font-medium">Status:</span>
<span className={`ml-2 ${siteResource.enabled ? 'text-green-600' : 'text-red-600'}`}>

View File

@@ -29,6 +29,7 @@ import { usePathname, useRouter } from "next/navigation";
import { useMemo, useState } from "react";
import { useUserContext } from "@app/hooks/useUserContext";
import { useTranslations } from "next-intl";
import { build } from "@server/build";
interface OrgSelectorProps {
orgId?: string;
@@ -50,6 +51,11 @@ export function OrgSelector({
const selectedOrg = orgs?.find((org) => org.orgId === orgId);
let canCreateOrg = !env.flags.disableUserCreateOrg || user.serverAdmin;
if (build === "saas" && user.type !== "internal") {
canCreateOrg = false;
}
const sortedOrgs = useMemo(() => {
if (!orgs?.length) return orgs ?? [];
return [...orgs].sort((a, b) => {
@@ -161,7 +167,7 @@ export function OrgSelector({
</CommandGroup>
</CommandList>
</Command>
{(!env.flags.disableUserCreateOrg || user.serverAdmin) && (
{canCreateOrg && (
<div className="p-2 border-t border-border">
<Button
variant="ghost"

View File

@@ -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>

View File

@@ -26,12 +26,12 @@ function getActionsCategories(root: boolean) {
[t("actionGetOrg")]: "getOrg",
[t("actionUpdateOrg")]: "updateOrg",
[t("actionGetOrgUser")]: "getOrgUser",
[t("actionResetSiteBandwidth")]: "resetSiteBandwidth",
[t("actionInviteUser")]: "inviteUser",
[t("actionRemoveInvitation")]: "removeInvitation",
[t("actionListInvitations")]: "listInvitations",
[t("actionRemoveUser")]: "removeUser",
[t("actionListUsers")]: "listUsers",
[t("actionListOrgDomains")]: "listOrgDomains",
[t("updateOrgUser")]: "updateOrgUser",
[t("createOrgUser")]: "createOrgUser",
[t("actionApplyBlueprint")]: "applyBlueprint",
@@ -39,6 +39,16 @@ function getActionsCategories(root: boolean) {
[t("actionGetBlueprint")]: "getBlueprint"
},
Domain: {
[t("actionListOrgDomains")]: "listOrgDomains",
[t("actionGetDomain")]: "getDomain",
[t("actionCreateOrgDomain")]: "createOrgDomain",
[t("actionUpdateOrgDomain")]: "updateOrgDomain",
[t("actionDeleteOrgDomain")]: "deleteOrgDomain",
[t("actionGetDNSRecords")]: "getDNSRecords",
[t("actionRestartOrgDomain")]: "restartOrgDomain"
},
Site: {
[t("actionCreateSite")]: "createSite",
[t("actionDeleteSite")]: "deleteSite",

View File

@@ -14,15 +14,19 @@ import { InfoPopup } from "@app/components/ui/info-popup";
import { Switch } from "@app/components/ui/switch";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useNavigationContext } from "@app/hooks/useNavigationContext";
import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn";
import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import { UpdateResourceResponse } from "@server/routers/resource";
import type { PaginationState } from "@tanstack/react-table";
import { AxiosResponse } from "axios";
import {
ArrowDown01Icon,
ArrowRight,
ArrowUp10Icon,
CheckCircle2,
ChevronDown,
ChevronsUpDownIcon,
Clock,
MoreHorizontal,
ShieldCheck,
@@ -318,7 +322,26 @@ export default function ProxyResourcesTable({
accessorKey: "name",
enableHiding: false,
friendlyName: t("name"),
header: () => <span className="p-3">{t("name")}</span>
header: () => {
const nameOrder = getSortDirection("name", searchParams);
const Icon =
nameOrder === "asc"
? ArrowDown01Icon
: nameOrder === "desc"
? ArrowUp10Icon
: ChevronsUpDownIcon;
return (
<Button
variant="ghost"
className="p-3"
onClick={() => toggleSort("name")}
>
{t("name")}
<Icon className="ml-2 h-4 w-4" />
</Button>
);
}
},
{
id: "niceId",
@@ -563,6 +586,14 @@ export default function ProxyResourcesTable({
});
}
function toggleSort(column: string) {
const newSearch = getNextSortOrder(column, searchParams);
filter({
searchParams: newSearch
});
}
const handlePaginationChange = (newPage: PaginationState) => {
searchParams.set("page", (newPage.pageIndex + 1).toString());
searchParams.set("pageSize", newPage.pageSize.toString());

View File

@@ -141,7 +141,24 @@ export default function SitesTable({
accessorKey: "name",
enableHiding: false,
header: () => {
return <span className="p-3">{t("name")}</span>;
const nameOrder = getSortDirection("name", searchParams);
const Icon =
nameOrder === "asc"
? ArrowDown01Icon
: nameOrder === "desc"
? ArrowUp10Icon
: ChevronsUpDownIcon;
return (
<Button
variant="ghost"
className="p-3"
onClick={() => toggleSort("name")}
>
{t("name")}
<Icon className="ml-2 h-4 w-4" />
</Button>
);
}
},
{

View File

@@ -101,6 +101,7 @@ export function NewtSiteInstallCommands({
`helm install newt fossorial/newt \\
--create-namespace \\
--set newtInstances[0].name="main-tunnel" \\
--set newtInstances[0].enabled=true \\
--set-string newtInstances[0].auth.keys.endpointKey="${endpoint}" \\
--set-string newtInstances[0].auth.keys.idKey="${id}" \\
--set-string newtInstances[0].auth.keys.secretKey="${secret}"`
@@ -185,59 +186,72 @@ WantedBy=default.target`
className="mt-4"
/>
<div className="pt-4">
<p className="font-bold mb-3">
{t("siteConfiguration")}
</p>
<div className="flex items-center space-x-2 mb-2">
<CheckboxWithLabel
id="acceptClients"
aria-describedby="acceptClients-desc"
checked={acceptClients}
onCheckedChange={(checked) => {
const value = checked as boolean;
setAcceptClients(value);
}}
label={t("siteAcceptClientConnections")}
/>
</div>
<p
id="acceptClients-desc"
className="text-sm text-muted-foreground"
>
{t("siteAcceptClientConnectionsDescription")}
</p>
<div className="pt-4">
<p className="font-bold mb-3">{t("siteConfiguration")}</p>
<div className="flex items-center space-x-2 mb-2">
<CheckboxWithLabel
id="acceptClients"
aria-describedby="acceptClients-desc"
checked={acceptClients}
onCheckedChange={(checked) => {
const value = checked as boolean;
setAcceptClients(value);
}}
label={t("siteAcceptClientConnections")}
/>
</div>
<p
id="acceptClients-desc"
className="text-sm text-muted-foreground"
>
{t("siteAcceptClientConnectionsDescription")}
</p>
</div>
<div className="pt-4">
<p className="font-bold mb-3">{t("commands")}</p>
<div className="mt-2 space-y-3">
{commands.map((item, index) => {
const commandText =
typeof item === "string"
? item
: item.command;
const title =
typeof item === "string"
? undefined
: item.title;
<div className="pt-4">
<p className="font-bold mb-3">{t("commands")}</p>
{platform === "kubernetes" && (
<p className="text-sm text-muted-foreground mb-3">
For more and up to date Kubernetes installation
information, see{" "}
<a
href="https://docs.pangolin.net/manage/sites/install-kubernetes"
target="_blank"
rel="noreferrer"
className="underline"
>
docs.pangolin.net/manage/sites/install-kubernetes
</a>
.
</p>
)}
<div className="mt-2 space-y-3">
{commands.map((item, index) => {
const commandText =
typeof item === "string" ? item : item.command;
const title =
typeof item === "string"
? undefined
: item.title;
return (
<div key={index}>
{title && (
<p className="text-sm font-medium mb-1.5">
{title}
</p>
)}
<CopyTextBox
text={commandText}
outline={true}
/>
</div>
);
})}
</div>
const key = `${title ?? ""}::${commandText}`;
return (
<div key={key}>
{title && (
<p className="text-sm font-medium mb-1.5">
{title}
</p>
)}
<CopyTextBox
text={commandText}
outline={true}
/>
</div>
);
})}
</div>
</div>
</SettingsSectionBody>
</SettingsSection>
);

View File

@@ -2,31 +2,20 @@ import { headers } from "next/headers";
export async function authCookieHeader() {
const otherHeaders = await headers();
const otherHeadersObject = Object.fromEntries(otherHeaders.entries());
const otherHeadersObject = Object.fromEntries(
Array.from(otherHeaders.entries()).map(([k, v]) => [k.toLowerCase(), v])
);
return {
headers: {
cookie:
otherHeadersObject["cookie"] || otherHeadersObject["Cookie"],
host: otherHeadersObject["host"] || otherHeadersObject["Host"],
"user-agent":
otherHeadersObject["user-agent"] ||
otherHeadersObject["User-Agent"],
"x-forwarded-for":
otherHeadersObject["x-forwarded-for"] ||
otherHeadersObject["X-Forwarded-For"],
"x-forwarded-host":
otherHeadersObject["fx-forwarded-host"] ||
otherHeadersObject["Fx-Forwarded-Host"],
"x-forwarded-port":
otherHeadersObject["x-forwarded-port"] ||
otherHeadersObject["X-Forwarded-Port"],
"x-forwarded-proto":
otherHeadersObject["x-forwarded-proto"] ||
otherHeadersObject["X-Forwarded-Proto"],
"x-real-ip":
otherHeadersObject["x-real-ip"] ||
otherHeadersObject["X-Real-IP"]
cookie: otherHeadersObject["cookie"],
host: otherHeadersObject["host"],
"user-agent": otherHeadersObject["user-agent"],
"x-forwarded-for": otherHeadersObject["x-forwarded-for"],
"x-forwarded-host": otherHeadersObject["x-forwarded-host"],
"x-forwarded-port": otherHeadersObject["x-forwarded-port"],
"x-forwarded-proto": otherHeadersObject["x-forwarded-proto"],
"x-real-ip": otherHeadersObject["x-real-ip"]
}
};
}

View File

@@ -4,7 +4,8 @@ import type { ListClientsResponse } from "@server/routers/client";
import type { ListDomainsResponse } from "@server/routers/domain";
import type {
GetResourceWhitelistResponse,
ListResourceNamesResponse
ListResourceNamesResponse,
ListResourcesResponse
} from "@server/routers/resource";
import type { ListRolesResponse } from "@server/routers/role";
import type { ListSitesResponse } from "@server/routers/site";
@@ -90,23 +91,13 @@ export const productUpdatesQueries = {
})
};
export const clientFilterSchema = z.object({
pageSize: z.int().prefault(1000).optional()
});
export const orgQueries = {
clients: ({
orgId,
filters
}: {
orgId: string;
filters?: z.infer<typeof clientFilterSchema>;
}) =>
clients: ({ orgId }: { orgId: string }) =>
queryOptions({
queryKey: ["ORG", orgId, "CLIENTS", filters] as const,
queryKey: ["ORG", orgId, "CLIENTS"] as const,
queryFn: async ({ signal, meta }) => {
const sp = new URLSearchParams({
pageSize: (filters?.pageSize ?? 1000).toString()
pageSize: "10000"
});
const res = await meta!.api.get<
@@ -143,9 +134,13 @@ export const orgQueries = {
queryOptions({
queryKey: ["ORG", orgId, "SITES"] as const,
queryFn: async ({ signal, meta }) => {
const sp = new URLSearchParams({
pageSize: "10000"
});
const res = await meta!.api.get<
AxiosResponse<ListSitesResponse>
>(`/org/${orgId}/sites`, { signal });
>(`/org/${orgId}/sites?${sp.toString()}`, { signal });
return res.data.data.sites;
}
}),
@@ -182,6 +177,22 @@ export const orgQueries = {
);
return res.data.data.idps;
}
}),
resources: ({ orgId }: { orgId: string }) =>
queryOptions({
queryKey: ["ORG", orgId, "RESOURCES"] as const,
queryFn: async ({ signal, meta }) => {
const sp = new URLSearchParams({
pageSize: "10000"
});
const res = await meta!.api.get<
AxiosResponse<ListResourcesResponse>
>(`/org/${orgId}/resources?${sp.toString()}`, { signal });
return res.data.data.resources;
}
})
};