mirror of
https://github.com/fosrl/pangolin.git
synced 2026-07-08 23:24:54 +02:00
Merge branch 'dev' into feat/command-bar
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
export default function OrgPageLoading() {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<Loader2 className="size-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+38
-11
@@ -1,9 +1,9 @@
|
||||
import { Layout } from "@app/components/Layout";
|
||||
import MemberResourcesPortal from "@app/components/MemberResourcesPortal";
|
||||
import ResourceLauncher from "@app/components/resource-launcher/ResourceLauncher";
|
||||
import { internal } from "@app/lib/api";
|
||||
import { authCookieHeader } from "@app/lib/api/cookies";
|
||||
import { fetchLauncherPageData } from "@app/lib/launcherServerData";
|
||||
import { verifySession } from "@app/lib/auth/verifySession";
|
||||
import { pullEnv } from "@app/lib/pullEnv";
|
||||
import UserProvider from "@app/providers/UserProvider";
|
||||
import { ListUserOrgsResponse } from "@server/routers/org";
|
||||
import { GetOrgOverviewResponse } from "@server/routers/org/getOrgOverview";
|
||||
@@ -13,12 +13,14 @@ import { cache } from "react";
|
||||
|
||||
type OrgPageProps = {
|
||||
params: Promise<{ orgId: string }>;
|
||||
searchParams: Promise<Record<string, string>>;
|
||||
};
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function OrgPage(props: OrgPageProps) {
|
||||
const params = await props.params;
|
||||
const orgId = params.orgId;
|
||||
const env = pullEnv();
|
||||
|
||||
if (!orgId) {
|
||||
redirect(`/`);
|
||||
@@ -40,12 +42,6 @@ export default async function OrgPage(props: OrgPageProps) {
|
||||
overview = res.data.data;
|
||||
} catch (e) {}
|
||||
|
||||
// If user is admin or owner, redirect to settings
|
||||
if (overview?.isAdmin || overview?.isOwner) {
|
||||
redirect(`/${orgId}/settings`);
|
||||
}
|
||||
|
||||
// For non-admin users, show the member resources portal
|
||||
let orgs: ListUserOrgsResponse["orgs"] = [];
|
||||
try {
|
||||
const getOrgs = cache(async () =>
|
||||
@@ -60,10 +56,41 @@ export default async function OrgPage(props: OrgPageProps) {
|
||||
}
|
||||
} catch (e) {}
|
||||
|
||||
const isAdminOrOwner = Boolean(overview?.isAdmin || overview?.isOwner);
|
||||
|
||||
const searchParams = new URLSearchParams(await props.searchParams);
|
||||
const launcherData = overview
|
||||
? await fetchLauncherPageData(
|
||||
orgId,
|
||||
searchParams,
|
||||
await authCookieHeader()
|
||||
)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<UserProvider user={user}>
|
||||
<Layout orgId={orgId} navItems={[]} orgs={orgs}>
|
||||
{overview && <MemberResourcesPortal orgId={orgId} />}
|
||||
<Layout
|
||||
orgId={orgId}
|
||||
orgs={orgs}
|
||||
navItems={[]}
|
||||
showSidebar={false}
|
||||
launcherMode
|
||||
showViewAsAdmin={isAdminOrOwner}
|
||||
>
|
||||
{overview && launcherData ? (
|
||||
<ResourceLauncher
|
||||
orgId={orgId}
|
||||
isAdmin={isAdminOrOwner}
|
||||
views={launcherData.views}
|
||||
defaultViewOverrides={launcherData.defaultViewOverrides}
|
||||
activeViewId={launcherData.activeViewId}
|
||||
config={launcherData.config}
|
||||
savedConfig={launcherData.savedConfig}
|
||||
scale={launcherData.scale}
|
||||
groups={launcherData.groups}
|
||||
groupsPagination={launcherData.groupsPagination}
|
||||
/>
|
||||
) : null}
|
||||
</Layout>
|
||||
</UserProvider>
|
||||
);
|
||||
|
||||
@@ -58,7 +58,7 @@ import {
|
||||
tier2LimitSet,
|
||||
tier3LimitSet
|
||||
} from "@server/lib/billing/limitSet";
|
||||
import { FeatureId } from "@server/lib/billing/features";
|
||||
import { LimitId } from "@server/lib/billing/features";
|
||||
import TrialBillingBanner from "@app/components/TrialBillingBanner";
|
||||
|
||||
// Plan tier definitions matching the mockup
|
||||
@@ -158,42 +158,60 @@ const tierLimits: Record<
|
||||
domains: number;
|
||||
remoteNodes: number;
|
||||
organizations: number;
|
||||
publicResources: number;
|
||||
privateResources: number;
|
||||
machineClients: number;
|
||||
}
|
||||
> = {
|
||||
basic: {
|
||||
users: freeLimitSet[FeatureId.USERS]?.value ?? 0,
|
||||
sites: freeLimitSet[FeatureId.SITES]?.value ?? 0,
|
||||
domains: freeLimitSet[FeatureId.DOMAINS]?.value ?? 0,
|
||||
remoteNodes: freeLimitSet[FeatureId.REMOTE_EXIT_NODES]?.value ?? 0,
|
||||
organizations: freeLimitSet[FeatureId.ORGINIZATIONS]?.value ?? 0
|
||||
users: freeLimitSet[LimitId.USERS]?.value ?? 0,
|
||||
sites: freeLimitSet[LimitId.SITES]?.value ?? 0,
|
||||
domains: freeLimitSet[LimitId.DOMAINS]?.value ?? 0,
|
||||
remoteNodes: freeLimitSet[LimitId.REMOTE_EXIT_NODES]?.value ?? 0,
|
||||
organizations: freeLimitSet[LimitId.ORGANIZATIONS]?.value ?? 0,
|
||||
publicResources: freeLimitSet[LimitId.PUBLIC_RESOURCES]?.value ?? 0,
|
||||
privateResources: freeLimitSet[LimitId.PRIVATE_RESOURCES]?.value ?? 0,
|
||||
machineClients: freeLimitSet[LimitId.MACHINE_CLIENTS]?.value ?? 0
|
||||
},
|
||||
tier1: {
|
||||
users: tier1LimitSet[FeatureId.USERS]?.value ?? 0,
|
||||
sites: tier1LimitSet[FeatureId.SITES]?.value ?? 0,
|
||||
domains: tier1LimitSet[FeatureId.DOMAINS]?.value ?? 0,
|
||||
remoteNodes: tier1LimitSet[FeatureId.REMOTE_EXIT_NODES]?.value ?? 0,
|
||||
organizations: tier1LimitSet[FeatureId.ORGINIZATIONS]?.value ?? 0
|
||||
users: tier1LimitSet[LimitId.USERS]?.value ?? 0,
|
||||
sites: tier1LimitSet[LimitId.SITES]?.value ?? 0,
|
||||
domains: tier1LimitSet[LimitId.DOMAINS]?.value ?? 0,
|
||||
remoteNodes: tier1LimitSet[LimitId.REMOTE_EXIT_NODES]?.value ?? 0,
|
||||
organizations: tier1LimitSet[LimitId.ORGANIZATIONS]?.value ?? 0,
|
||||
publicResources: tier1LimitSet[LimitId.PUBLIC_RESOURCES]?.value ?? 0,
|
||||
privateResources: tier1LimitSet[LimitId.PRIVATE_RESOURCES]?.value ?? 0,
|
||||
machineClients: tier1LimitSet[LimitId.MACHINE_CLIENTS]?.value ?? 0
|
||||
},
|
||||
tier2: {
|
||||
users: tier2LimitSet[FeatureId.USERS]?.value ?? 0,
|
||||
sites: tier2LimitSet[FeatureId.SITES]?.value ?? 0,
|
||||
domains: tier2LimitSet[FeatureId.DOMAINS]?.value ?? 0,
|
||||
remoteNodes: tier2LimitSet[FeatureId.REMOTE_EXIT_NODES]?.value ?? 0,
|
||||
organizations: tier2LimitSet[FeatureId.ORGINIZATIONS]?.value ?? 0
|
||||
users: tier2LimitSet[LimitId.USERS]?.value ?? 0,
|
||||
sites: tier2LimitSet[LimitId.SITES]?.value ?? 0,
|
||||
domains: tier2LimitSet[LimitId.DOMAINS]?.value ?? 0,
|
||||
remoteNodes: tier2LimitSet[LimitId.REMOTE_EXIT_NODES]?.value ?? 0,
|
||||
organizations: tier2LimitSet[LimitId.ORGANIZATIONS]?.value ?? 0,
|
||||
publicResources: tier2LimitSet[LimitId.PUBLIC_RESOURCES]?.value ?? 0,
|
||||
privateResources: tier2LimitSet[LimitId.PRIVATE_RESOURCES]?.value ?? 0,
|
||||
machineClients: tier2LimitSet[LimitId.MACHINE_CLIENTS]?.value ?? 0
|
||||
},
|
||||
tier3: {
|
||||
users: tier3LimitSet[FeatureId.USERS]?.value ?? 0,
|
||||
sites: tier3LimitSet[FeatureId.SITES]?.value ?? 0,
|
||||
domains: tier3LimitSet[FeatureId.DOMAINS]?.value ?? 0,
|
||||
remoteNodes: tier3LimitSet[FeatureId.REMOTE_EXIT_NODES]?.value ?? 0,
|
||||
organizations: tier3LimitSet[FeatureId.ORGINIZATIONS]?.value ?? 0
|
||||
users: tier3LimitSet[LimitId.USERS]?.value ?? 0,
|
||||
sites: tier3LimitSet[LimitId.SITES]?.value ?? 0,
|
||||
domains: tier3LimitSet[LimitId.DOMAINS]?.value ?? 0,
|
||||
remoteNodes: tier3LimitSet[LimitId.REMOTE_EXIT_NODES]?.value ?? 0,
|
||||
organizations: tier3LimitSet[LimitId.ORGANIZATIONS]?.value ?? 0,
|
||||
publicResources: tier3LimitSet[LimitId.PUBLIC_RESOURCES]?.value ?? 0,
|
||||
privateResources: tier3LimitSet[LimitId.PRIVATE_RESOURCES]?.value ?? 0,
|
||||
machineClients: tier3LimitSet[LimitId.MACHINE_CLIENTS]?.value ?? 0
|
||||
},
|
||||
enterprise: {
|
||||
users: 0, // Custom for enterprise
|
||||
sites: 0, // Custom for enterprise
|
||||
domains: 0, // Custom for enterprise
|
||||
remoteNodes: 0, // Custom for enterprise
|
||||
organizations: 0 // Custom for enterprise
|
||||
organizations: 0, // Custom for enterprise
|
||||
publicResources: 0, // Custom for enterprise
|
||||
privateResources: 0, // Custom for enterprise
|
||||
machineClients: 0 // Custom for enterprise
|
||||
}
|
||||
};
|
||||
|
||||
@@ -234,6 +252,9 @@ export default function BillingPage() {
|
||||
const DOMAINS = "domains";
|
||||
const REMOTE_EXIT_NODES = "remoteExitNodes";
|
||||
const ORGINIZATIONS = "organizations";
|
||||
const PUBLIC_RESOURCES = "publicResources";
|
||||
const PRIVATE_RESOURCES = "privateResources";
|
||||
const MACHINE_CLIENTS = "machineClients";
|
||||
|
||||
// Confirmation dialog state
|
||||
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
|
||||
@@ -269,7 +290,13 @@ export default function BillingPage() {
|
||||
setHasSubscription(
|
||||
tierSub.subscription.status === "active"
|
||||
);
|
||||
setIsTrial(tierSub.subscription.expiresAt != null);
|
||||
// expiresAt is only meaningful while the trial hasn't
|
||||
// actually run out yet; a stale row with a past
|
||||
// expiresAt should no longer be treated as a live trial
|
||||
const expiresAt = tierSub.subscription.expiresAt;
|
||||
setIsTrial(
|
||||
expiresAt != null && expiresAt * 1000 > Date.now()
|
||||
);
|
||||
}
|
||||
|
||||
// Find license subscription
|
||||
@@ -797,6 +824,45 @@ export default function BillingPage() {
|
||||
});
|
||||
}
|
||||
|
||||
// Check public resources
|
||||
const publicResourcesUsage = getUsageValue(PUBLIC_RESOURCES);
|
||||
if (
|
||||
limits.publicResources > 0 &&
|
||||
publicResourcesUsage > limits.publicResources
|
||||
) {
|
||||
violations.push({
|
||||
feature: "Public Resources",
|
||||
currentUsage: publicResourcesUsage,
|
||||
newLimit: limits.publicResources
|
||||
});
|
||||
}
|
||||
|
||||
// Check private resources
|
||||
const privateResourcesUsage = getUsageValue(PRIVATE_RESOURCES);
|
||||
if (
|
||||
limits.privateResources > 0 &&
|
||||
privateResourcesUsage > limits.privateResources
|
||||
) {
|
||||
violations.push({
|
||||
feature: "Private Resources",
|
||||
currentUsage: privateResourcesUsage,
|
||||
newLimit: limits.privateResources
|
||||
});
|
||||
}
|
||||
|
||||
// Check machine clients
|
||||
const machineClientsUsage = getUsageValue(MACHINE_CLIENTS);
|
||||
if (
|
||||
limits.machineClients > 0 &&
|
||||
machineClientsUsage > limits.machineClients
|
||||
) {
|
||||
violations.push({
|
||||
feature: "Machine Clients",
|
||||
currentUsage: machineClientsUsage,
|
||||
newLimit: limits.machineClients
|
||||
});
|
||||
}
|
||||
|
||||
return violations;
|
||||
};
|
||||
|
||||
@@ -997,21 +1063,23 @@ export default function BillingPage() {
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
|
||||
{/* Current Usage */}
|
||||
<div className="border rounded-lg p-4">
|
||||
<div className="border rounded-lg p-4 md:col-span-1">
|
||||
<div className="text-sm text-muted-foreground mb-2">
|
||||
{t("billingCurrentUsage") || "Current Usage"}
|
||||
</div>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-3xl font-semibold">
|
||||
{getUserCount()}
|
||||
</span>
|
||||
<span className="text-lg">
|
||||
{t("billingUsers") || "Users"}
|
||||
</span>
|
||||
<div className="flex flex-col items-start gap-1">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-3xl font-semibold">
|
||||
{getUserCount()}
|
||||
</span>
|
||||
<span className="text-lg">
|
||||
{t("billingUsers") || "Users"}
|
||||
</span>
|
||||
</div>
|
||||
{hasSubscription && getPricePerUser() > 0 && (
|
||||
<div className="text-sm text-muted-foreground mt-1">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
x ${getPricePerUser()} / month = $
|
||||
{getUserCount() * getPricePerUser()} /
|
||||
month
|
||||
@@ -1021,11 +1089,11 @@ export default function BillingPage() {
|
||||
</div>
|
||||
|
||||
{/* Maximum Limits */}
|
||||
<div className="border rounded-lg p-4">
|
||||
<div className="border rounded-lg p-4 md:col-span-3">
|
||||
<div className="text-sm text-muted-foreground mb-3">
|
||||
{t("billingMaximumLimits") || "Maximum Limits"}
|
||||
</div>
|
||||
<InfoSections cols={5}>
|
||||
<InfoSections cols={8}>
|
||||
<InfoSection>
|
||||
<InfoSectionTitle className="flex items-center gap-1 text-xs">
|
||||
{t("billingUsers") || "Users"}
|
||||
@@ -1308,6 +1376,168 @@ export default function BillingPage() {
|
||||
)}
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
<InfoSection>
|
||||
<InfoSectionTitle className="flex items-center gap-1 text-xs">
|
||||
{t("billingPublicResources") ||
|
||||
"Public Resources"}
|
||||
</InfoSectionTitle>
|
||||
<InfoSectionContent className="text-sm">
|
||||
{isOverLimit(PUBLIC_RESOURCES) ? (
|
||||
<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(
|
||||
PUBLIC_RESOURCES
|
||||
) ??
|
||||
t(
|
||||
"billingUnlimited"
|
||||
) ??
|
||||
"∞"}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>
|
||||
{t(
|
||||
"billingUsageExceedsLimit",
|
||||
{
|
||||
current:
|
||||
getUsageValue(
|
||||
PUBLIC_RESOURCES
|
||||
),
|
||||
limit:
|
||||
getLimitValue(
|
||||
PUBLIC_RESOURCES
|
||||
) ?? 0
|
||||
}
|
||||
) ||
|
||||
`Current usage (${getUsageValue(PUBLIC_RESOURCES)}) exceeds limit (${getLimitValue(PUBLIC_RESOURCES)})`}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<>
|
||||
{getLimitValue(
|
||||
PUBLIC_RESOURCES
|
||||
) ??
|
||||
t("billingUnlimited") ??
|
||||
"∞"}
|
||||
</>
|
||||
)}
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
<InfoSection>
|
||||
<InfoSectionTitle className="flex items-center gap-1 text-xs">
|
||||
{t("billingPrivateResources") ||
|
||||
"Private Resources"}
|
||||
</InfoSectionTitle>
|
||||
<InfoSectionContent className="text-sm">
|
||||
{isOverLimit(PRIVATE_RESOURCES) ? (
|
||||
<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(
|
||||
PRIVATE_RESOURCES
|
||||
) ??
|
||||
t(
|
||||
"billingUnlimited"
|
||||
) ??
|
||||
"∞"}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>
|
||||
{t(
|
||||
"billingUsageExceedsLimit",
|
||||
{
|
||||
current:
|
||||
getUsageValue(
|
||||
PRIVATE_RESOURCES
|
||||
),
|
||||
limit:
|
||||
getLimitValue(
|
||||
PRIVATE_RESOURCES
|
||||
) ?? 0
|
||||
}
|
||||
) ||
|
||||
`Current usage (${getUsageValue(PRIVATE_RESOURCES)}) exceeds limit (${getLimitValue(PRIVATE_RESOURCES)})`}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<>
|
||||
{getLimitValue(
|
||||
PRIVATE_RESOURCES
|
||||
) ??
|
||||
t("billingUnlimited") ??
|
||||
"∞"}
|
||||
</>
|
||||
)}
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
<InfoSection>
|
||||
<InfoSectionTitle className="flex items-center gap-1 text-xs">
|
||||
{t("billingMachineClients") ||
|
||||
"Machine Clients"}
|
||||
</InfoSectionTitle>
|
||||
<InfoSectionContent className="text-sm">
|
||||
{isOverLimit(MACHINE_CLIENTS) ? (
|
||||
<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(
|
||||
MACHINE_CLIENTS
|
||||
) ??
|
||||
t(
|
||||
"billingUnlimited"
|
||||
) ??
|
||||
"∞"}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>
|
||||
{t(
|
||||
"billingUsageExceedsLimit",
|
||||
{
|
||||
current:
|
||||
getUsageValue(
|
||||
MACHINE_CLIENTS
|
||||
),
|
||||
limit:
|
||||
getLimitValue(
|
||||
MACHINE_CLIENTS
|
||||
) ?? 0
|
||||
}
|
||||
) ||
|
||||
`Current usage (${getUsageValue(MACHINE_CLIENTS)}) exceeds limit (${getLimitValue(MACHINE_CLIENTS)})`}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<>
|
||||
{getLimitValue(
|
||||
MACHINE_CLIENTS
|
||||
) ??
|
||||
t("billingUnlimited") ??
|
||||
"∞"}
|
||||
</>
|
||||
)}
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
</InfoSections>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1507,6 +1737,45 @@ export default function BillingPage() {
|
||||
"Remote Nodes"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-muted-foreground/50 shrink-0" />
|
||||
<span>
|
||||
{
|
||||
tierLimits[
|
||||
pendingTier.tier
|
||||
].publicResources
|
||||
}{" "}
|
||||
{t(
|
||||
"billingPublicResources"
|
||||
) || "Public Resources"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-muted-foreground/50 shrink-0" />
|
||||
<span>
|
||||
{
|
||||
tierLimits[
|
||||
pendingTier.tier
|
||||
].privateResources
|
||||
}{" "}
|
||||
{t(
|
||||
"billingPrivateResources"
|
||||
) || "Private Resources"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-muted-foreground/50 shrink-0" />
|
||||
<span>
|
||||
{
|
||||
tierLimits[
|
||||
pendingTier.tier
|
||||
].machineClients
|
||||
}{" "}
|
||||
{t(
|
||||
"billingMachineClients"
|
||||
) || "Machine Clients"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -37,6 +37,10 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
|
||||
{
|
||||
title: t("credentials"),
|
||||
href: "/{orgId}/settings/remote-exit-nodes/{remoteExitNodeId}/credentials"
|
||||
},
|
||||
{
|
||||
title: "Networking",
|
||||
href: "/{orgId}/settings/remote-exit-nodes/{remoteExitNodeId}/networking"
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
+271
@@ -0,0 +1,271 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
SettingsContainer,
|
||||
SettingsFormCell,
|
||||
SettingsFormGrid,
|
||||
SettingsSection,
|
||||
SettingsSectionBody,
|
||||
SettingsSectionDescription,
|
||||
SettingsSectionFooter,
|
||||
SettingsSectionForm,
|
||||
SettingsSectionHeader,
|
||||
SettingsSectionTitle
|
||||
} from "@app/components/Settings";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { Label } from "@app/components/ui/label";
|
||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { useParams } from "next/navigation";
|
||||
import { AxiosResponse } from "axios";
|
||||
import { useRemoteExitNodeContext } from "@app/hooks/useRemoteExitNodeContext";
|
||||
import { TagInput, type Tag } from "@app/components/tags/tag-input";
|
||||
import { MultiSelectTagInput } from "@app/components/multi-select/multi-select-tag-input";
|
||||
import type { TagValue } from "@app/components/multi-select/multi-select-content";
|
||||
import { orgQueries } from "@app/lib/queries";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useDebounce } from "use-debounce";
|
||||
import type { ListRemoteExitNodeResourcesResponse } from "@server/routers/remoteExitNode";
|
||||
import type { SetRemoteExitNodeResourcesResponse } from "@server/routers/remoteExitNode";
|
||||
import type { ListRemoteExitNodePreferenceLabelsResponse } from "@server/routers/remoteExitNode";
|
||||
import type { SetRemoteExitNodePreferenceLabelsResponse } from "@server/routers/remoteExitNode";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { ExternalLink } from "lucide-react";
|
||||
|
||||
const cidrRegex =
|
||||
/^(([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\.){3}([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])(\/([0-9]|[1-2][0-9]|3[0-2]))$|^(([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]+|::(ffff(:0{1,4})?:)?((25[0-5]|(2[0-4]|1?[0-9])?[0-9])\.){3}(25[0-5]|(2[0-4]|1?[0-9])?[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1?[0-9])?[0-9])\.){3}(25[0-5]|(2[0-4]|1?[0-9])?[0-9]))(\/([0-9]|[1-9][0-9]|1[0-1][0-9]|12[0-8]))$/;
|
||||
|
||||
export default function NetworkingPage() {
|
||||
const t = useTranslations();
|
||||
const { env } = useEnvContext();
|
||||
const api = createApiClient({ env });
|
||||
const { orgId } = useParams<{
|
||||
orgId: string;
|
||||
remoteExitNodeId: string;
|
||||
}>();
|
||||
const { remoteExitNode } = useRemoteExitNodeContext();
|
||||
|
||||
// Subnets state
|
||||
const [subnets, setSubnets] = useState<Tag[]>([]);
|
||||
const [activeTagIndex, setActiveTagIndex] = useState<number | null>(null);
|
||||
const [loadingSubnets, setLoadingSubnets] = useState(true);
|
||||
|
||||
// Labels state
|
||||
const [selectedLabels, setSelectedLabels] = useState<TagValue[]>([]);
|
||||
const [labelSearchQuery, setLabelSearchQuery] = useState("");
|
||||
const [loadingLabels, setLoadingLabels] = useState(true);
|
||||
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const [debouncedLabelQuery] = useDebounce(labelSearchQuery, 150);
|
||||
|
||||
const { data: availableLabels = [] } = useQuery(
|
||||
orgQueries.labels({ orgId, query: debouncedLabelQuery, perPage: 10 })
|
||||
);
|
||||
|
||||
const labelsShown = useMemo<TagValue[]>(() => {
|
||||
const base: TagValue[] = availableLabels.map((l) => ({
|
||||
id: l.labelId.toString(),
|
||||
text: l.name,
|
||||
color: l.color
|
||||
}));
|
||||
if (debouncedLabelQuery.trim().length === 0) {
|
||||
for (const sel of selectedLabels) {
|
||||
if (!base.find((b) => b.id === sel.id)) {
|
||||
base.unshift(sel);
|
||||
}
|
||||
}
|
||||
}
|
||||
return base;
|
||||
}, [availableLabels, selectedLabels, debouncedLabelQuery]);
|
||||
|
||||
useEffect(() => {
|
||||
async function loadSubnets() {
|
||||
try {
|
||||
const res = await api.get<
|
||||
AxiosResponse<ListRemoteExitNodeResourcesResponse>
|
||||
>(
|
||||
`/org/${orgId}/remote-exit-node/${remoteExitNode.remoteExitNodeId}/resources`
|
||||
);
|
||||
setSubnets(
|
||||
res.data.data.resources.map((r) => ({
|
||||
id: r.destination,
|
||||
text: r.destination
|
||||
}))
|
||||
);
|
||||
} catch (error) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("error"),
|
||||
description:
|
||||
formatAxiosError(error) ||
|
||||
t("remoteExitNodeNetworkingSubnetsLoadError")
|
||||
});
|
||||
} finally {
|
||||
setLoadingSubnets(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadLabels() {
|
||||
try {
|
||||
const res = await api.get<
|
||||
AxiosResponse<ListRemoteExitNodePreferenceLabelsResponse>
|
||||
>(
|
||||
`/org/${orgId}/remote-exit-node/${remoteExitNode.remoteExitNodeId}/preference-labels`
|
||||
);
|
||||
setSelectedLabels(
|
||||
res.data.data.labels.map((l) => ({
|
||||
id: l.labelId.toString(),
|
||||
text: l.name,
|
||||
color: l.color
|
||||
}))
|
||||
);
|
||||
} catch (error) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("error"),
|
||||
description:
|
||||
formatAxiosError(error) ||
|
||||
t("remoteExitNodeNetworkingLabelsLoadError")
|
||||
});
|
||||
} finally {
|
||||
setLoadingLabels(false);
|
||||
}
|
||||
}
|
||||
|
||||
loadSubnets();
|
||||
loadLabels();
|
||||
}, [remoteExitNode.remoteExitNodeId]);
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
await Promise.all([
|
||||
api.post<AxiosResponse<SetRemoteExitNodeResourcesResponse>>(
|
||||
`/org/${orgId}/remote-exit-node/${remoteExitNode.remoteExitNodeId}/resources`,
|
||||
{ destinations: subnets.map((s) => s.text) }
|
||||
),
|
||||
api.post<
|
||||
AxiosResponse<SetRemoteExitNodePreferenceLabelsResponse>
|
||||
>(
|
||||
`/org/${orgId}/remote-exit-node/${remoteExitNode.remoteExitNodeId}/preference-labels`,
|
||||
{ labelIds: selectedLabels.map((l) => parseInt(l.id)) }
|
||||
)
|
||||
]);
|
||||
toast({
|
||||
title: t("remoteExitNodeNetworkingSaveSuccessTitle"),
|
||||
description: t("remoteExitNodeNetworkingSaveSuccessDescription")
|
||||
});
|
||||
} catch (error) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("error"),
|
||||
description:
|
||||
formatAxiosError(error) ||
|
||||
t("remoteExitNodeNetworkingSaveError")
|
||||
});
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsContainer>
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
{t("remoteExitNodeNetworkingTitle")}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
{t("remoteExitNodeNetworkingDescription")}
|
||||
<a
|
||||
href="https://docs.pangolin.net/placeholder"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline inline-flex items-center gap-1"
|
||||
>
|
||||
{t("learnMore")}
|
||||
<ExternalLink className="size-3.5 shrink-0" />
|
||||
</a>
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
<SettingsSectionForm variant="half">
|
||||
<SettingsFormGrid>
|
||||
<SettingsFormCell span="half">
|
||||
<div className="grid gap-2">
|
||||
<Label>
|
||||
{t(
|
||||
"remoteExitNodeNetworkingSubnetsTitle"
|
||||
)}
|
||||
</Label>
|
||||
<TagInput
|
||||
tags={subnets}
|
||||
setTags={setSubnets}
|
||||
placeholder={t(
|
||||
"remoteExitNodeNetworkingSubnetsPlaceholder"
|
||||
)}
|
||||
validateTag={(tag) =>
|
||||
cidrRegex.test(tag.trim())
|
||||
}
|
||||
activeTagIndex={activeTagIndex}
|
||||
setActiveTagIndex={setActiveTagIndex}
|
||||
disabled={loadingSubnets}
|
||||
allowDuplicates={false}
|
||||
size="sm"
|
||||
inlineTags={true}
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t.rich(
|
||||
"remoteExitNodeNetworkingSubnetsDescription",
|
||||
{
|
||||
code: (chunks) => (
|
||||
<code>{chunks}</code>
|
||||
)
|
||||
}
|
||||
)}{" "}
|
||||
</p>
|
||||
</div>
|
||||
</SettingsFormCell>
|
||||
<SettingsFormCell span="half">
|
||||
<div className="grid gap-2">
|
||||
<Label>
|
||||
{t(
|
||||
"remoteExitNodeNetworkingLabelsTitle"
|
||||
)}
|
||||
</Label>
|
||||
<MultiSelectTagInput
|
||||
value={selectedLabels}
|
||||
options={labelsShown}
|
||||
onChange={setSelectedLabels}
|
||||
onSearch={setLabelSearchQuery}
|
||||
searchQuery={labelSearchQuery}
|
||||
disabled={loadingLabels}
|
||||
buttonText={t(
|
||||
"remoteExitNodeNetworkingLabelsButtonText"
|
||||
)}
|
||||
searchPlaceholder={t(
|
||||
"remoteExitNodeNetworkingLabelsSearchPlaceholder"
|
||||
)}
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t(
|
||||
"remoteExitNodeNetworkingLabelsDescription"
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</SettingsFormCell>
|
||||
</SettingsFormGrid>
|
||||
</SettingsSectionForm>
|
||||
</SettingsSectionBody>
|
||||
<SettingsSectionFooter>
|
||||
<Button onClick={handleSave} loading={saving}>
|
||||
{t("remoteExitNodeNetworkingSave")}
|
||||
</Button>
|
||||
</SettingsSectionFooter>
|
||||
</SettingsSection>
|
||||
</SettingsContainer>
|
||||
);
|
||||
}
|
||||
@@ -10,6 +10,6 @@ export default async function RemoteExitNodePage(props: {
|
||||
}) {
|
||||
const params = await props.params;
|
||||
redirect(
|
||||
`/${params.orgId}/settings/remote-exit-nodes/${params.remoteExitNodeId}/credentials`
|
||||
`/${params.orgId}/settings/remote-exit-nodes/${params.remoteExitNodeId}/networking`
|
||||
);
|
||||
}
|
||||
|
||||
@@ -35,8 +35,6 @@ import { toast } from "@app/hooks/useToast";
|
||||
import { AxiosResponse } from "axios";
|
||||
import { useParams, useRouter, useSearchParams } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
|
||||
import { InfoIcon } from "lucide-react";
|
||||
import HeaderTitle from "@app/components/SettingsSectionTitle";
|
||||
import { StrategySelect } from "@app/components/StrategySelect";
|
||||
|
||||
|
||||
@@ -111,7 +111,7 @@ export default function AccessControlsPage() {
|
||||
if (values.roles.length === 0) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("accessRoleErrorAdd"),
|
||||
title: t("accessRoleRequired"),
|
||||
description: t("accessRoleSelectPlease")
|
||||
});
|
||||
return;
|
||||
@@ -173,15 +173,13 @@ export default function AccessControlsPage() {
|
||||
if (values.roles.length === 0) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("accessRoleErrorAdd"),
|
||||
title: t("accessRoleRequired"),
|
||||
description: t("accessRoleSelectPlease")
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const willHaveAdminRole = values.roles.some(
|
||||
(r) => r.isAdmin === true
|
||||
);
|
||||
const willHaveAdminRole = values.roles.some((r) => r.isAdmin === true);
|
||||
|
||||
const isRemovingOwnAdmin =
|
||||
sessionUser.userId === user.userId &&
|
||||
@@ -226,7 +224,9 @@ export default function AccessControlsPage() {
|
||||
<SettingsSectionForm>
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={(e) => void handleAccessControlsSubmit(e)}
|
||||
onSubmit={(e) =>
|
||||
void handleAccessControlsSubmit(e)
|
||||
}
|
||||
className="space-y-4"
|
||||
id="access-controls-form"
|
||||
>
|
||||
|
||||
@@ -41,6 +41,13 @@ import { useParams } from "next/navigation";
|
||||
import { FaApple, FaWindows, FaLinux } from "react-icons/fa";
|
||||
import { SiAndroid } from "react-icons/si";
|
||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
import {
|
||||
productUpdatesQueries,
|
||||
type LatestVersionResponse
|
||||
} from "@app/lib/queries";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import semver from "semver";
|
||||
import { InfoPopup } from "@app/components/ui/info-popup";
|
||||
|
||||
function formatTimestamp(timestamp: number | null | undefined): string {
|
||||
if (!timestamp) return "-";
|
||||
@@ -166,6 +173,34 @@ export default function GeneralPage() {
|
||||
}>(null);
|
||||
const [isCheckingCache, setIsCheckingCache] = useState(false);
|
||||
const [isRebuildingCache, setIsRebuildingCache] = useState(false);
|
||||
const data = useQuery(productUpdatesQueries.latestVersion(true));
|
||||
const latestPlatformVersions = data.data?.data;
|
||||
|
||||
const agentVersionMap: Record<string, string> = {
|
||||
"Pangolin Windows": "windows",
|
||||
"Pangolin Android": "android",
|
||||
"Pangolin iOS": "ios",
|
||||
"Pangolin iPadOS": "ios",
|
||||
"Pangolin macOS": "mac",
|
||||
"Pangolin CLI": "cli",
|
||||
"Olm CLI": "olm"
|
||||
};
|
||||
|
||||
let updateAvailable = false;
|
||||
if (client.agent && client.olmVersion && latestPlatformVersions) {
|
||||
const agent = agentVersionMap[
|
||||
client.agent
|
||||
] as keyof LatestVersionResponse;
|
||||
|
||||
if (agent in latestPlatformVersions) {
|
||||
const agentVersion = latestPlatformVersions[agent];
|
||||
|
||||
updateAvailable = semver.lt(
|
||||
client.olmVersion,
|
||||
agentVersion.latestVersion
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// get "imp" from local storage to determine if we should show the verify button (imp = "1" means show)
|
||||
const showVerifyButton =
|
||||
@@ -451,11 +486,21 @@ export default function GeneralPage() {
|
||||
{t("agent")}
|
||||
</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
<Badge variant="secondary">
|
||||
{client.agent +
|
||||
" v" +
|
||||
client.olmVersion}
|
||||
</Badge>
|
||||
<div className="flex items-center">
|
||||
<Badge variant="secondary">
|
||||
{client.agent +
|
||||
" v" +
|
||||
client.olmVersion}
|
||||
</Badge>
|
||||
|
||||
{updateAvailable && (
|
||||
<InfoPopup
|
||||
info={t(
|
||||
"updateAvailableInfo"
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
</div>
|
||||
|
||||
@@ -43,6 +43,7 @@ import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
||||
import { tierMatrix, TierFeature } from "@server/lib/billing/tierMatrix";
|
||||
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
|
||||
import { ExternalLink } from "lucide-react";
|
||||
import { env } from "process";
|
||||
|
||||
// Schema for general organization settings
|
||||
const GeneralFormSchema = z.object({
|
||||
@@ -165,6 +166,7 @@ function DeleteForm({ org }: SectionFormProps) {
|
||||
|
||||
function GeneralSectionForm({ org }: SectionFormProps) {
|
||||
const { updateOrg } = useOrgContext();
|
||||
const { env } = useEnvContext();
|
||||
const form = useForm({
|
||||
resolver: zodResolver(
|
||||
GeneralFormSchema.pick({
|
||||
@@ -265,36 +267,42 @@ function GeneralSectionForm({ org }: SectionFormProps) {
|
||||
<PaidFeaturesAlert
|
||||
tiers={tierMatrix.newtAutoUpdate}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="settingsEnableGlobalNewtAutoUpdate"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<SwitchInput
|
||||
id="settings-enable-global-newt-auto-update"
|
||||
label={t("newtAutoUpdate")}
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
disabled={!hasAutoUpdateFeature}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t("newtAutoUpdateDescription")}{" "}
|
||||
<a
|
||||
href="https://docs.pangolin.net/manage/sites/auto-update"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline inline-flex items-center gap-1"
|
||||
>
|
||||
{t("learnMore")}
|
||||
<ExternalLink className="size-3.5 shrink-0" />
|
||||
</a>
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{!env.flags.disableEnterpriseFeatures && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="settingsEnableGlobalNewtAutoUpdate"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<SwitchInput
|
||||
id="settings-enable-global-newt-auto-update"
|
||||
label={t("newtAutoUpdate")}
|
||||
checked={field.value}
|
||||
onCheckedChange={
|
||||
field.onChange
|
||||
}
|
||||
disabled={
|
||||
!hasAutoUpdateFeature
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t("newtAutoUpdateDescription")}{" "}
|
||||
<a
|
||||
href="https://docs.pangolin.net/manage/sites/auto-update"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline inline-flex items-center gap-1"
|
||||
>
|
||||
{t("learnMore")}
|
||||
<ExternalLink className="size-3.5 shrink-0" />
|
||||
</a>
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</form>
|
||||
</Form>
|
||||
</SettingsSectionForm>
|
||||
|
||||
-4
@@ -3,9 +3,7 @@ import { authCookieHeader } from "@app/lib/api/cookies";
|
||||
import { ListOrgLabelsResponse } from "@server/routers/labels/types";
|
||||
import { AxiosResponse } from "axios";
|
||||
import OrgLabelsTable from "@app/components/OrgLabelsTable";
|
||||
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
|
||||
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
import type { Metadata } from "next";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
|
||||
@@ -51,8 +49,6 @@ export default async function LabelsPage({ params, searchParams }: Props) {
|
||||
description={t("orgLabelsDescription")}
|
||||
/>
|
||||
|
||||
<PaidFeaturesAlert tiers={tierMatrix.labels} />
|
||||
|
||||
<OrgLabelsTable
|
||||
labels={labels}
|
||||
orgId={orgId}
|
||||
@@ -15,6 +15,7 @@ import { ColumnFilterButton } from "@app/components/ColumnFilterButton";
|
||||
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||
import { build } from "@server/build";
|
||||
import { getSevenDaysAgo } from "@app/lib/getSevenDaysAgo";
|
||||
import { getPrivateResourceSettingsHref } from "@app/lib/launcherResourceAdminHref";
|
||||
import axios from "axios";
|
||||
import { useStoredPageSize } from "@app/hooks/useStoredPageSize";
|
||||
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
|
||||
@@ -342,8 +343,11 @@ export default function GeneralPage() {
|
||||
return (
|
||||
<Link
|
||||
href={
|
||||
row.original.type === "ssh"
|
||||
? `/${row.original.orgId}/settings/resources/private?query=${row.original.resourceNiceId}`
|
||||
row.original.siteResourceId != null
|
||||
? getPrivateResourceSettingsHref(
|
||||
row.original.orgId,
|
||||
row.original.resourceNiceId
|
||||
)
|
||||
: `/${row.original.orgId}/settings/resources/public/${row.original.resourceNiceId}`
|
||||
}
|
||||
>
|
||||
@@ -369,7 +373,9 @@ export default function GeneralPage() {
|
||||
value: "whitelistedEmail",
|
||||
label: "Whitelisted Email"
|
||||
},
|
||||
{ value: "ssh", label: "SSH" }
|
||||
{ value: "ssh", label: "SSH" },
|
||||
{ value: "rdp", label: "RDP" },
|
||||
{ value: "vnc", label: "VNC" }
|
||||
]}
|
||||
label={t("type")}
|
||||
selectedValue={filters.type}
|
||||
@@ -384,8 +390,10 @@ export default function GeneralPage() {
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const typeLabel =
|
||||
row.original.type === "ssh"
|
||||
? "SSH"
|
||||
row.original.type === "ssh" ||
|
||||
row.original.type === "rdp" ||
|
||||
row.original.type === "vnc"
|
||||
? row.original.type.toUpperCase()
|
||||
: row.original.type.charAt(0).toUpperCase() +
|
||||
row.original.type.slice(1);
|
||||
return <span>{typeLabel || "-"}</span>;
|
||||
@@ -513,7 +521,15 @@ export default function GeneralPage() {
|
||||
|
||||
function generateSampleAccessLogs(): QueryAccessAuditLogResponse["log"] {
|
||||
const locations = ["US", "DE", "GB", "FR", "JP", "CA", "AU"];
|
||||
const types = ["password", "pincode", "login", "whitelistedEmail", "ssh"];
|
||||
const types = [
|
||||
"password",
|
||||
"pincode",
|
||||
"login",
|
||||
"whitelistedEmail",
|
||||
"ssh",
|
||||
"rdp",
|
||||
"vnc"
|
||||
];
|
||||
const actors = [
|
||||
"alice@example.com",
|
||||
"bob@example.com",
|
||||
@@ -538,6 +554,7 @@ function generateSampleAccessLogs(): QueryAccessAuditLogResponse["log"] {
|
||||
actor,
|
||||
actorId: actor ? `user-${i}` : null,
|
||||
resourceId: Math.floor(Math.random() * 5) + 1,
|
||||
siteResourceId: null,
|
||||
resourceNiceId: `resource-${(i % 3) + 1}`,
|
||||
resourceName: `Resource ${(i % 3) + 1}`,
|
||||
ip: `${Math.floor(Math.random() * 255)}.${Math.floor(Math.random() * 255)}.${Math.floor(Math.random() * 255)}.${Math.floor(Math.random() * 255)}`,
|
||||
|
||||
@@ -11,6 +11,7 @@ import { useStoredPageSize } from "@app/hooks/useStoredPageSize";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import { getSevenDaysAgo } from "@app/lib/getSevenDaysAgo";
|
||||
import { getPrivateResourceSettingsHref } from "@app/lib/launcherResourceAdminHref";
|
||||
import { logQueries } from "@app/lib/queries";
|
||||
import { build } from "@server/build";
|
||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
@@ -323,7 +324,10 @@ export default function ConnectionLogsPage() {
|
||||
if (row.original.resourceName && row.original.resourceNiceId) {
|
||||
return (
|
||||
<Link
|
||||
href={`/${row.original.orgId}/settings/resources/private/?query=${row.original.resourceNiceId}`}
|
||||
href={getPrivateResourceSettingsHref(
|
||||
row.original.orgId,
|
||||
row.original.resourceNiceId
|
||||
)}
|
||||
>
|
||||
<Button variant="outline" size="sm">
|
||||
{row.original.resourceName}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { toast } from "@app/hooks/useToast";
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { getSevenDaysAgo } from "@app/lib/getSevenDaysAgo";
|
||||
import { getPrivateResourceSettingsHref } from "@app/lib/launcherResourceAdminHref";
|
||||
import { logQueries } from "@app/lib/queries";
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
@@ -18,7 +19,6 @@ import Link from "next/link";
|
||||
import { useParams, useRouter, useSearchParams } from "next/navigation";
|
||||
import { useMemo, useState, useTransition } from "react";
|
||||
import { useStoredPageSize } from "@app/hooks/useStoredPageSize";
|
||||
import { build } from "@server/build";
|
||||
import type { QueryRequestAuditLogResponse } from "@server/routers/auditLogs/types";
|
||||
import { ColumnFilterButton } from "@app/components/ColumnFilterButton";
|
||||
|
||||
@@ -122,8 +122,7 @@ export default function GeneralPage() {
|
||||
...logQueries.requests({
|
||||
orgId: orgId as string,
|
||||
filters: queryFilters
|
||||
}),
|
||||
enabled: build !== "oss"
|
||||
})
|
||||
});
|
||||
|
||||
const rows = isLoading ? generateSampleRequestLogs() : (data?.log ?? []);
|
||||
@@ -397,7 +396,10 @@ export default function GeneralPage() {
|
||||
<Link
|
||||
href={
|
||||
row.original.reason == 108 // for now the client will only have reason 108 so we know where to go
|
||||
? `/${row.original.orgId}/settings/resources/private?query=${row.original.resourceNiceId}`
|
||||
? getPrivateResourceSettingsHref(
|
||||
row.original.orgId,
|
||||
row.original.resourceNiceId
|
||||
)
|
||||
: `/${row.original.orgId}/settings/resources/public/${row.original.resourceNiceId}`
|
||||
}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
"use client";
|
||||
|
||||
import { MachinesSelector } from "@app/components/machines-selector";
|
||||
import { RolesSelector } from "@app/components/roles-selector";
|
||||
import { UsersSelector } from "@app/components/users-selector";
|
||||
import { SettingsFormCell, SettingsFormGrid } from "@app/components/Settings";
|
||||
import type { Tag } from "@app/components/tags/tag-input";
|
||||
import {
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from "@app/components/ui/form";
|
||||
import type { PrivateResourceClient } from "@app/lib/privateResourceForm";
|
||||
import { useTranslations } from "next-intl";
|
||||
import type { Control } from "react-hook-form";
|
||||
|
||||
type AccessFormValues = {
|
||||
roles?: Tag[];
|
||||
users?: Tag[];
|
||||
clients?: PrivateResourceClient[];
|
||||
};
|
||||
|
||||
type PrivateResourceAccessFieldsProps = {
|
||||
control: Control<AccessFormValues>;
|
||||
orgId: string;
|
||||
loading?: boolean;
|
||||
hasMachineClients?: boolean;
|
||||
};
|
||||
|
||||
export function PrivateResourceAccessFields({
|
||||
control,
|
||||
orgId,
|
||||
loading = false,
|
||||
hasMachineClients = false
|
||||
}: PrivateResourceAccessFieldsProps) {
|
||||
const t = useTranslations();
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="text-sm text-muted-foreground">{t("loading")}</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsFormGrid>
|
||||
<SettingsFormCell span="full">
|
||||
<FormField
|
||||
control={control}
|
||||
name="roles"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-col items-start">
|
||||
<FormLabel>{t("roles")}</FormLabel>
|
||||
<FormControl>
|
||||
<RolesSelector
|
||||
selectedRoles={field.value ?? []}
|
||||
orgId={orgId}
|
||||
restrictAdminRole
|
||||
onSelectRoles={(newRoles) => {
|
||||
field.onChange(newRoles);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
<SettingsFormCell span="full">
|
||||
<FormField
|
||||
control={control}
|
||||
name="users"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-col items-start">
|
||||
<FormLabel>{t("users")}</FormLabel>
|
||||
<UsersSelector
|
||||
selectedUsers={field.value ?? []}
|
||||
orgId={orgId}
|
||||
onSelectUsers={(newUsers) => {
|
||||
field.onChange(newUsers);
|
||||
}}
|
||||
/>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
{hasMachineClients && (
|
||||
<SettingsFormCell span="full">
|
||||
<FormField
|
||||
control={control}
|
||||
name="clients"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-col items-start">
|
||||
<FormLabel>{t("machineClients")}</FormLabel>
|
||||
<MachinesSelector
|
||||
selectedMachines={field.value ?? []}
|
||||
orgId={orgId}
|
||||
onSelectMachines={(machines) => {
|
||||
field.onChange(machines);
|
||||
}}
|
||||
/>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
)}
|
||||
</SettingsFormGrid>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
"use client";
|
||||
|
||||
import { SettingsFormCell, SettingsFormGrid } from "@app/components/Settings";
|
||||
import {
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from "@app/components/ui/form";
|
||||
import { Input } from "@app/components/ui/input";
|
||||
import { useTranslations } from "next-intl";
|
||||
import type { Control, UseFormWatch } from "react-hook-form";
|
||||
|
||||
type PrivateResourceAliasFieldProps = {
|
||||
control: Control<any>;
|
||||
watch: UseFormWatch<any>;
|
||||
labelPrefix?: "create" | "edit";
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export function PrivateResourceAliasField({
|
||||
control,
|
||||
watch,
|
||||
labelPrefix = "edit",
|
||||
disabled = false
|
||||
}: PrivateResourceAliasFieldProps) {
|
||||
const t = useTranslations();
|
||||
const aliasLabelKey =
|
||||
labelPrefix === "create"
|
||||
? "createInternalResourceDialogAlias"
|
||||
: "editInternalResourceDialogAlias";
|
||||
const aliasDescriptionKey =
|
||||
labelPrefix === "create"
|
||||
? "createInternalResourceDialogAliasDescription"
|
||||
: "editInternalResourceDialogAliasDescription";
|
||||
|
||||
const aliasValue = watch("alias");
|
||||
const aliasEndsWithLocal =
|
||||
typeof aliasValue === "string" &&
|
||||
aliasValue.trim().toLowerCase().endsWith(".local");
|
||||
|
||||
return (
|
||||
<FormField
|
||||
control={control}
|
||||
name="alias"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t(aliasLabelKey)}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
className="w-full"
|
||||
value={field.value ?? ""}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</FormControl>
|
||||
{aliasEndsWithLocal && (
|
||||
<p className="text-xs text-amber-700/80 mt-1">
|
||||
{t("internalResourceAliasLocalWarning")}
|
||||
</p>
|
||||
)}
|
||||
<FormMessage />
|
||||
<FormDescription>{t(aliasDescriptionKey)}</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
type PrivateResourceHostDestinationFieldsProps = {
|
||||
control: Control<any>;
|
||||
watch: UseFormWatch<any>;
|
||||
labelPrefix?: "create" | "edit";
|
||||
hideAlias?: boolean;
|
||||
};
|
||||
|
||||
export function PrivateResourceHostDestinationFields({
|
||||
control,
|
||||
watch,
|
||||
labelPrefix = "edit",
|
||||
hideAlias = false
|
||||
}: PrivateResourceHostDestinationFieldsProps) {
|
||||
const t = useTranslations();
|
||||
const destinationLabelKey =
|
||||
labelPrefix === "create"
|
||||
? "createInternalResourceDialogDestination"
|
||||
: "editInternalResourceDialogDestination";
|
||||
|
||||
const destinationField = (
|
||||
<FormField
|
||||
control={control}
|
||||
name="destination"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t(destinationLabelKey)}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
className="w-full"
|
||||
value={field.value ?? ""}
|
||||
onChange={(e) =>
|
||||
field.onChange(
|
||||
e.target.value === ""
|
||||
? null
|
||||
: e.target.value
|
||||
)
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
|
||||
if (hideAlias) {
|
||||
return destinationField;
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsFormGrid>
|
||||
<SettingsFormCell span="half">{destinationField}</SettingsFormCell>
|
||||
<SettingsFormCell span="half">
|
||||
<PrivateResourceAliasField
|
||||
control={control}
|
||||
watch={watch}
|
||||
labelPrefix={labelPrefix}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
</SettingsFormGrid>
|
||||
);
|
||||
}
|
||||
|
||||
export function PrivateResourceCidrDestinationField({
|
||||
control,
|
||||
labelPrefix = "edit"
|
||||
}: {
|
||||
control: Control<any>;
|
||||
labelPrefix?: "create" | "edit";
|
||||
}) {
|
||||
const t = useTranslations();
|
||||
const destinationLabelKey =
|
||||
labelPrefix === "create"
|
||||
? "createInternalResourceDialogDestination"
|
||||
: "editInternalResourceDialogDestination";
|
||||
|
||||
return (
|
||||
<FormField
|
||||
control={control}
|
||||
name="destination"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t(destinationLabelKey)}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
className="w-full"
|
||||
value={field.value ?? ""}
|
||||
onChange={(e) =>
|
||||
field.onChange(
|
||||
e.target.value === ""
|
||||
? null
|
||||
: e.target.value
|
||||
)
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
"use client";
|
||||
|
||||
import DomainPicker from "@app/components/DomainPicker";
|
||||
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
|
||||
import {
|
||||
SettingsFormCell,
|
||||
SettingsFormGrid,
|
||||
SettingsSubsectionDescription,
|
||||
SettingsSubsectionHeader,
|
||||
SettingsSubsectionTitle
|
||||
} from "@app/components/Settings";
|
||||
import { SwitchInput } from "@app/components/SwitchInput";
|
||||
import {
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from "@app/components/ui/form";
|
||||
import { Input } from "@app/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from "@app/components/ui/select";
|
||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
import { useTranslations } from "next-intl";
|
||||
import type { Control, UseFormSetValue, UseFormWatch } from "react-hook-form";
|
||||
|
||||
type PrivateResourceHttpFieldsProps = {
|
||||
control: Control<any>;
|
||||
setValue: UseFormSetValue<any>;
|
||||
orgId: string;
|
||||
watch: UseFormWatch<any>;
|
||||
disabled?: boolean;
|
||||
siteResourceId?: number;
|
||||
labelPrefix?: "create" | "edit";
|
||||
hideDomainPicker?: boolean;
|
||||
hidePaidFeaturesAlert?: boolean;
|
||||
};
|
||||
|
||||
export function PrivateResourceHttpFields({
|
||||
control,
|
||||
setValue,
|
||||
orgId,
|
||||
watch,
|
||||
disabled = false,
|
||||
siteResourceId,
|
||||
labelPrefix = "edit",
|
||||
hideDomainPicker = false,
|
||||
hidePaidFeaturesAlert = false
|
||||
}: PrivateResourceHttpFieldsProps) {
|
||||
const t = useTranslations();
|
||||
const schemeLabelKey =
|
||||
labelPrefix === "create"
|
||||
? "createInternalResourceDialogScheme"
|
||||
: "editInternalResourceDialogScheme";
|
||||
const destinationLabelKey =
|
||||
labelPrefix === "create"
|
||||
? "createInternalResourceDialogDestination"
|
||||
: "editInternalResourceDialogDestination";
|
||||
const destinationPortLabelKey =
|
||||
labelPrefix === "create"
|
||||
? "createInternalResourceDialogModePort"
|
||||
: "editInternalResourceDialogModePort";
|
||||
const httpConfigurationTitleKey =
|
||||
labelPrefix === "create"
|
||||
? "createInternalResourceDialogHttpConfiguration"
|
||||
: "editInternalResourceDialogHttpConfiguration";
|
||||
const httpConfigurationDescriptionKey =
|
||||
labelPrefix === "create"
|
||||
? "createInternalResourceDialogHttpConfigurationDescription"
|
||||
: "editInternalResourceDialogHttpConfigurationDescription";
|
||||
const enableSslLabelKey =
|
||||
labelPrefix === "create"
|
||||
? "createInternalResourceDialogEnableSsl"
|
||||
: "editInternalResourceDialogEnableSsl";
|
||||
const enableSslDescriptionKey =
|
||||
labelPrefix === "create"
|
||||
? "createInternalResourceDialogEnableSslDescription"
|
||||
: "editInternalResourceDialogEnableSslDescription";
|
||||
|
||||
const httpConfigSubdomain = watch("httpConfigSubdomain");
|
||||
const httpConfigDomainId = watch("httpConfigDomainId");
|
||||
const httpConfigFullDomain = watch("httpConfigFullDomain");
|
||||
|
||||
return (
|
||||
<SettingsFormGrid>
|
||||
{!hidePaidFeaturesAlert && (
|
||||
<SettingsFormCell span="full">
|
||||
<PaidFeaturesAlert
|
||||
tiers={tierMatrix.advancedPrivateResources}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
)}
|
||||
|
||||
<SettingsFormCell span="quarter">
|
||||
<FormField
|
||||
control={control}
|
||||
name="scheme"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t(schemeLabelKey)}</FormLabel>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
value={field.value ?? "http"}
|
||||
disabled={disabled}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="http">http</SelectItem>
|
||||
<SelectItem value="https">https</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
<SettingsFormCell span="half">
|
||||
<FormField
|
||||
control={control}
|
||||
name="destination"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t(destinationLabelKey)}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
className="w-full"
|
||||
value={field.value ?? ""}
|
||||
disabled={disabled}
|
||||
onChange={(e) =>
|
||||
field.onChange(
|
||||
e.target.value === ""
|
||||
? null
|
||||
: e.target.value
|
||||
)
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
<SettingsFormCell span="quarter">
|
||||
<FormField
|
||||
control={control}
|
||||
name="destinationPort"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t(destinationPortLabelKey)}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
className="w-full"
|
||||
type="number"
|
||||
min={1}
|
||||
max={65535}
|
||||
value={field.value ?? ""}
|
||||
disabled={disabled}
|
||||
onChange={(e) => {
|
||||
const raw = e.target.value;
|
||||
if (raw === "") {
|
||||
field.onChange(null);
|
||||
return;
|
||||
}
|
||||
const n = Number(raw);
|
||||
field.onChange(
|
||||
Number.isFinite(n) ? n : null
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
|
||||
{!hideDomainPicker && (
|
||||
<>
|
||||
<SettingsFormCell span="full">
|
||||
<SettingsSubsectionHeader>
|
||||
<SettingsSubsectionTitle>
|
||||
{t(httpConfigurationTitleKey)}
|
||||
</SettingsSubsectionTitle>
|
||||
<SettingsSubsectionDescription>
|
||||
{t(httpConfigurationDescriptionKey)}
|
||||
</SettingsSubsectionDescription>
|
||||
</SettingsSubsectionHeader>
|
||||
</SettingsFormCell>
|
||||
<SettingsFormCell span="full">
|
||||
<div
|
||||
className={
|
||||
disabled
|
||||
? "pointer-events-none opacity-50"
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<DomainPicker
|
||||
key={
|
||||
siteResourceId
|
||||
? `http-domain-${siteResourceId}`
|
||||
: "http-domain-create"
|
||||
}
|
||||
orgId={orgId}
|
||||
cols={2}
|
||||
hideFreeDomain
|
||||
defaultSubdomain={
|
||||
httpConfigSubdomain ?? undefined
|
||||
}
|
||||
defaultDomainId={
|
||||
httpConfigDomainId ?? undefined
|
||||
}
|
||||
defaultFullDomain={
|
||||
httpConfigFullDomain ?? undefined
|
||||
}
|
||||
onDomainChange={(res) => {
|
||||
if (res === null) {
|
||||
setValue("httpConfigSubdomain", null);
|
||||
setValue("httpConfigDomainId", null);
|
||||
setValue("httpConfigFullDomain", null);
|
||||
return;
|
||||
}
|
||||
setValue(
|
||||
"httpConfigSubdomain",
|
||||
res.subdomain ?? null
|
||||
);
|
||||
setValue(
|
||||
"httpConfigDomainId",
|
||||
res.domainId
|
||||
);
|
||||
setValue(
|
||||
"httpConfigFullDomain",
|
||||
res.fullDomain
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</SettingsFormCell>
|
||||
<SettingsFormCell span="half">
|
||||
<FormField
|
||||
control={control}
|
||||
name="ssl"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<SwitchInput
|
||||
id="private-resource-ssl"
|
||||
label={t(enableSslLabelKey)}
|
||||
description={t(
|
||||
enableSslDescriptionKey
|
||||
)}
|
||||
checked={!!field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
</>
|
||||
)}
|
||||
|
||||
{hideDomainPicker && (
|
||||
<SettingsFormCell span="half">
|
||||
<FormField
|
||||
control={control}
|
||||
name="ssl"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<SwitchInput
|
||||
id="private-resource-ssl"
|
||||
label={t(enableSslLabelKey)}
|
||||
description={t(enableSslDescriptionKey)}
|
||||
checked={!!field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
)}
|
||||
</SettingsFormGrid>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
"use client";
|
||||
|
||||
import { ExternalLink } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export function PrivateResourceMultiSiteRoutingHelp() {
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
<p className="text-sm text-muted-foreground mt-2">
|
||||
{t("internalResourceFormMultiSiteRoutingHelp")}{" "}
|
||||
<a
|
||||
href="https://docs.pangolin.net/manage/resources/private/multi-site-routing"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline inline-flex items-center gap-1"
|
||||
>
|
||||
{t("internalResourceFormMultiSiteRoutingHelpLearnMore")}
|
||||
<ExternalLink className="size-3.5 shrink-0" />
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
SettingsFormCell,
|
||||
SettingsFormGrid,
|
||||
SettingsSubsectionDescription,
|
||||
SettingsSubsectionHeader,
|
||||
SettingsSubsectionTitle
|
||||
} from "@app/components/Settings";
|
||||
import { SwitchInput } from "@app/components/SwitchInput";
|
||||
import {
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from "@app/components/ui/form";
|
||||
import { Input } from "@app/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from "@app/components/ui/select";
|
||||
import {
|
||||
getPortModeFromString,
|
||||
getPortStringFromMode,
|
||||
type PortMode
|
||||
} from "@app/lib/privateResourceForm";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import type { Control, UseFormSetValue } from "react-hook-form";
|
||||
|
||||
type PrivateResourceNetworkAccessFieldsProps = {
|
||||
control: Control<any>;
|
||||
setValue: UseFormSetValue<any>;
|
||||
showPortRanges?: boolean;
|
||||
initialTcp?: string | null;
|
||||
initialUdp?: string | null;
|
||||
disabled?: boolean;
|
||||
icmpId?: string;
|
||||
embedInParentGrid?: boolean;
|
||||
};
|
||||
|
||||
export function PrivateResourceAllowIcmpField({
|
||||
control,
|
||||
id = "private-resource-allow-icmp",
|
||||
disabled = false
|
||||
}: {
|
||||
control: Control<any>;
|
||||
id?: string;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
<FormField
|
||||
control={control}
|
||||
name="disableIcmp"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<SwitchInput
|
||||
id={id}
|
||||
label={t("privateResourceAllowIcmpPing")}
|
||||
checked={!field.value}
|
||||
onCheckedChange={(checked) =>
|
||||
field.onChange(!checked)
|
||||
}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function PrivateResourceNetworkAccessHeader() {
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
<SettingsFormCell span="full">
|
||||
<SettingsSubsectionHeader>
|
||||
<SettingsSubsectionTitle>
|
||||
{t("privateResourceNetworkAccess")}
|
||||
</SettingsSubsectionTitle>
|
||||
<SettingsSubsectionDescription>
|
||||
{t("privateResourceNetworkAccessDescription")}
|
||||
</SettingsSubsectionDescription>
|
||||
</SettingsSubsectionHeader>
|
||||
</SettingsFormCell>
|
||||
);
|
||||
}
|
||||
|
||||
export function PrivateResourceNetworkAccessFields({
|
||||
control,
|
||||
setValue,
|
||||
showPortRanges = true,
|
||||
initialTcp,
|
||||
initialUdp,
|
||||
disabled = false,
|
||||
icmpId = "private-resource-allow-icmp",
|
||||
embedInParentGrid = false
|
||||
}: PrivateResourceNetworkAccessFieldsProps) {
|
||||
const t = useTranslations();
|
||||
const [tcpPortMode, setTcpPortMode] = useState<PortMode>(() =>
|
||||
getPortModeFromString(initialTcp)
|
||||
);
|
||||
const [udpPortMode, setUdpPortMode] = useState<PortMode>(() =>
|
||||
getPortModeFromString(initialUdp)
|
||||
);
|
||||
const [tcpCustomPorts, setTcpCustomPorts] = useState(() =>
|
||||
initialTcp && initialTcp !== "*" ? initialTcp : ""
|
||||
);
|
||||
const [udpCustomPorts, setUdpCustomPorts] = useState(() =>
|
||||
initialUdp && initialUdp !== "*" ? initialUdp : ""
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showPortRanges) return;
|
||||
|
||||
setValue(
|
||||
"tcpPortRangeString",
|
||||
getPortStringFromMode(tcpPortMode, tcpCustomPorts)
|
||||
);
|
||||
}, [showPortRanges, tcpPortMode, tcpCustomPorts, setValue]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showPortRanges) return;
|
||||
|
||||
setValue(
|
||||
"udpPortRangeString",
|
||||
getPortStringFromMode(udpPortMode, udpCustomPorts)
|
||||
);
|
||||
}, [showPortRanges, udpPortMode, udpCustomPorts, setValue]);
|
||||
|
||||
const content: ReactNode = (
|
||||
<>
|
||||
<PrivateResourceNetworkAccessHeader />
|
||||
|
||||
{showPortRanges ? (
|
||||
<>
|
||||
<SettingsFormCell span="full">
|
||||
<FormField
|
||||
control={control}
|
||||
name="tcpPortRangeString"
|
||||
render={() => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("editInternalResourceDialogTcp")}
|
||||
</FormLabel>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select
|
||||
value={tcpPortMode}
|
||||
onValueChange={(v: PortMode) =>
|
||||
setTcpPortMode(v)
|
||||
}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger className="w-[110px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">
|
||||
{t("allPorts")}
|
||||
</SelectItem>
|
||||
<SelectItem value="blocked">
|
||||
{t("blocked")}
|
||||
</SelectItem>
|
||||
<SelectItem value="custom">
|
||||
{t("custom")}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{tcpPortMode === "custom" ? (
|
||||
<FormControl>
|
||||
<Input
|
||||
className="flex-1"
|
||||
placeholder="80,443,8000-9000"
|
||||
value={tcpCustomPorts}
|
||||
onChange={(e) =>
|
||||
setTcpCustomPorts(
|
||||
e.target.value
|
||||
)
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
) : (
|
||||
<Input
|
||||
className="flex-1"
|
||||
disabled
|
||||
placeholder={
|
||||
tcpPortMode === "all"
|
||||
? t("allPortsAllowed")
|
||||
: t("allPortsBlocked")
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
|
||||
<SettingsFormCell span="full">
|
||||
<FormField
|
||||
control={control}
|
||||
name="udpPortRangeString"
|
||||
render={() => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("editInternalResourceDialogUdp")}
|
||||
</FormLabel>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select
|
||||
value={udpPortMode}
|
||||
onValueChange={(v: PortMode) =>
|
||||
setUdpPortMode(v)
|
||||
}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger className="w-[110px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">
|
||||
{t("allPorts")}
|
||||
</SelectItem>
|
||||
<SelectItem value="blocked">
|
||||
{t("blocked")}
|
||||
</SelectItem>
|
||||
<SelectItem value="custom">
|
||||
{t("custom")}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{udpPortMode === "custom" ? (
|
||||
<FormControl>
|
||||
<Input
|
||||
className="flex-1"
|
||||
placeholder="53,123,500-600"
|
||||
value={udpCustomPorts}
|
||||
onChange={(e) =>
|
||||
setUdpCustomPorts(
|
||||
e.target.value
|
||||
)
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
) : (
|
||||
<Input
|
||||
className="flex-1"
|
||||
disabled
|
||||
placeholder={
|
||||
udpPortMode === "all"
|
||||
? t("allPortsAllowed")
|
||||
: t("allPortsBlocked")
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<SettingsFormCell span="full">
|
||||
<PrivateResourceAllowIcmpField
|
||||
control={control}
|
||||
id={icmpId}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
</>
|
||||
);
|
||||
|
||||
if (embedInParentGrid) {
|
||||
return content;
|
||||
}
|
||||
|
||||
return <SettingsFormGrid>{content}</SettingsFormGrid>;
|
||||
}
|
||||
|
||||
export function PrivateResourcePortRanges(
|
||||
props: Omit<
|
||||
PrivateResourceNetworkAccessFieldsProps,
|
||||
"showPortRanges" | "embedInParentGrid"
|
||||
>
|
||||
) {
|
||||
return <PrivateResourceNetworkAccessFields showPortRanges {...props} />;
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
MultiSitesSelector,
|
||||
formatMultiSitesSelectorLabel
|
||||
} from "@app/components/multi-site-selector";
|
||||
import { SitesSelector } from "@app/components/site-selector";
|
||||
import type { Selectedsite } from "@app/components/site-selector";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import {
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from "@app/components/ui/form";
|
||||
import { cn } from "@app/lib/cn";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger
|
||||
} from "@app/components/ui/popover";
|
||||
import { ChevronsUpDown } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import type { Control, FieldPath, FieldValues } from "react-hook-form";
|
||||
import { PrivateResourceMultiSiteRoutingHelp } from "./PrivateResourceMultiSiteRoutingHelp";
|
||||
|
||||
type PrivateResourceSitesFieldProps<T extends FieldValues> = {
|
||||
control: Control<T>;
|
||||
orgId: string;
|
||||
selectedSites: Selectedsite[];
|
||||
onSelectedSitesChange: (sites: Selectedsite[]) => void;
|
||||
siteIdsFieldName?: FieldPath<T>;
|
||||
singleSite?: boolean;
|
||||
};
|
||||
|
||||
export function PrivateResourceSitesField<T extends FieldValues>({
|
||||
control,
|
||||
orgId,
|
||||
selectedSites,
|
||||
onSelectedSitesChange,
|
||||
siteIdsFieldName = "siteIds" as FieldPath<T>,
|
||||
singleSite = false
|
||||
}: PrivateResourceSitesFieldProps<T>) {
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
<FormField
|
||||
control={control}
|
||||
name={siteIdsFieldName}
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-col">
|
||||
<FormLabel>{t("sites")}</FormLabel>
|
||||
{singleSite ? (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<FormControl>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
className={cn(
|
||||
"w-full justify-between",
|
||||
selectedSites.length === 0 &&
|
||||
"text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
<span className="truncate text-left">
|
||||
{selectedSites[0]?.name ??
|
||||
t("selectSite")}
|
||||
</span>
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</FormControl>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-full p-0">
|
||||
<SitesSelector
|
||||
orgId={orgId}
|
||||
selectedSite={selectedSites[0] ?? null}
|
||||
filterTypes={["newt"]}
|
||||
onSelectSite={(site) => {
|
||||
onSelectedSitesChange([site]);
|
||||
field.onChange([site.siteId]);
|
||||
}}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
) : (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<FormControl>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
className={cn(
|
||||
"w-full justify-between",
|
||||
selectedSites.length === 0 &&
|
||||
"text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
<span className="truncate text-left">
|
||||
{formatMultiSitesSelectorLabel(
|
||||
selectedSites,
|
||||
t
|
||||
)}
|
||||
</span>
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</FormControl>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-full p-0">
|
||||
<MultiSitesSelector
|
||||
orgId={orgId}
|
||||
selectedSites={selectedSites}
|
||||
filterTypes={["newt"]}
|
||||
onSelectionChange={(sites) => {
|
||||
onSelectedSitesChange(sites);
|
||||
field.onChange(
|
||||
sites.map((s) => s.siteId)
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
<FormMessage />
|
||||
{!singleSite && selectedSites.length > 1 ? (
|
||||
<PrivateResourceMultiSiteRoutingHelp />
|
||||
) : null}
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
SettingsFormCell,
|
||||
SettingsFormGrid,
|
||||
SettingsSubsectionDescription,
|
||||
SettingsSubsectionHeader,
|
||||
SettingsSubsectionTitle
|
||||
} from "@app/components/Settings";
|
||||
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
|
||||
import { SshServerSettingsFields } from "@app/components/SshServerSettingsFields";
|
||||
import { PrivateResourceAliasField } from "./PrivateResourceDestinationFields";
|
||||
import { PrivateResourceSitesField } from "./PrivateResourceSitesField";
|
||||
import { getSshUseMultiSiteTargetForm } from "./privateResourceUtils";
|
||||
import { inferSshPamMode } from "@app/lib/privateResourceForm";
|
||||
import {
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from "@app/components/ui/form";
|
||||
import { Input } from "@app/components/ui/input";
|
||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useState, type ReactNode } from "react";
|
||||
import type { Control, UseFormSetValue, UseFormWatch } from "react-hook-form";
|
||||
import type { Selectedsite } from "@app/components/site-selector";
|
||||
|
||||
type PrivateResourceSshFieldsProps = {
|
||||
control: Control<any>;
|
||||
setValue: UseFormSetValue<any>;
|
||||
watch: UseFormWatch<any>;
|
||||
orgId?: string;
|
||||
disabled?: boolean;
|
||||
selectedSites: Selectedsite[];
|
||||
onSelectedSitesChange: (sites: Selectedsite[]) => void;
|
||||
labelPrefix?: "create" | "edit";
|
||||
showSshSettings?: boolean;
|
||||
layout?: "default" | "wizard";
|
||||
showPaidFeaturesAlert?: boolean;
|
||||
hideAlias?: boolean;
|
||||
embedInParentGrid?: boolean;
|
||||
isNativeSsh?: boolean;
|
||||
};
|
||||
|
||||
export function PrivateResourceSshFields({
|
||||
control,
|
||||
setValue,
|
||||
watch,
|
||||
orgId,
|
||||
disabled = false,
|
||||
selectedSites,
|
||||
onSelectedSitesChange,
|
||||
labelPrefix = "edit",
|
||||
showSshSettings = true,
|
||||
layout = "default",
|
||||
showPaidFeaturesAlert = true,
|
||||
hideAlias = false,
|
||||
embedInParentGrid = false,
|
||||
isNativeSsh: isNativeSshProp
|
||||
}: PrivateResourceSshFieldsProps) {
|
||||
const t = useTranslations();
|
||||
const destinationLabelKey =
|
||||
labelPrefix === "create"
|
||||
? "createInternalResourceDialogDestination"
|
||||
: "editInternalResourceDialogDestination";
|
||||
const destinationPortLabelKey =
|
||||
labelPrefix === "create"
|
||||
? "createInternalResourceDialogModePort"
|
||||
: "editInternalResourceDialogModePort";
|
||||
|
||||
const authDaemonMode = watch("authDaemonMode") ?? "site";
|
||||
const pamMode = inferSshPamMode(authDaemonMode, watch("pamMode"));
|
||||
const standardDaemonLocation =
|
||||
watch("standardDaemonLocation") ??
|
||||
(authDaemonMode === "remote" ? "remote" : "site");
|
||||
const formAuthDaemonPort = watch("authDaemonPort");
|
||||
const [authDaemonPortInput, setAuthDaemonPortInput] = useState(() =>
|
||||
formAuthDaemonPort != null ? String(formAuthDaemonPort) : "22123"
|
||||
);
|
||||
const isEditLayout = layout === "default";
|
||||
|
||||
const [sshServerMode, setSshServerMode] = useState<"standard" | "native">(
|
||||
() => (authDaemonMode === "native" ? "native" : "standard")
|
||||
);
|
||||
|
||||
const isNative =
|
||||
isNativeSshProp ??
|
||||
(isEditLayout
|
||||
? authDaemonMode === "native"
|
||||
: sshServerMode === "native");
|
||||
const useMultiSiteTargetForm = getSshUseMultiSiteTargetForm(
|
||||
isNative,
|
||||
authDaemonMode,
|
||||
pamMode
|
||||
);
|
||||
|
||||
function trimSitesToFirst() {
|
||||
if (selectedSites.length <= 1) return;
|
||||
|
||||
const first = selectedSites.slice(0, 1);
|
||||
onSelectedSitesChange(first);
|
||||
setValue(
|
||||
"siteIds",
|
||||
first.map((s: Selectedsite) => s.siteId),
|
||||
{ shouldValidate: true }
|
||||
);
|
||||
}
|
||||
|
||||
function handlePamModeChange(value: "passthrough" | "push") {
|
||||
if (disabled) return;
|
||||
|
||||
setValue("pamMode", value, { shouldValidate: true });
|
||||
|
||||
if (value === "passthrough") {
|
||||
setValue("authDaemonPort", null, { shouldValidate: true });
|
||||
setAuthDaemonPortInput("22123");
|
||||
return;
|
||||
}
|
||||
|
||||
if (standardDaemonLocation !== "remote" && selectedSites.length > 1) {
|
||||
trimSitesToFirst();
|
||||
}
|
||||
}
|
||||
|
||||
function handleDaemonLocationChange(value: "site" | "remote") {
|
||||
if (disabled) return;
|
||||
|
||||
setValue("standardDaemonLocation", value, { shouldValidate: true });
|
||||
setValue("authDaemonMode", value, { shouldValidate: true });
|
||||
|
||||
if (value === "site") {
|
||||
setValue("authDaemonPort", null, { shouldValidate: true });
|
||||
setAuthDaemonPortInput("22123");
|
||||
trimSitesToFirst();
|
||||
}
|
||||
}
|
||||
|
||||
function handleAuthDaemonPortChange(value: string) {
|
||||
if (disabled) return;
|
||||
|
||||
setAuthDaemonPortInput(value);
|
||||
const trimmed = value.trim();
|
||||
setValue("authDaemonPort", trimmed ? Number(trimmed) : null, {
|
||||
shouldValidate: true
|
||||
});
|
||||
}
|
||||
|
||||
function handleServerModeChange(mode: "standard" | "native") {
|
||||
if (disabled) return;
|
||||
|
||||
setSshServerMode(mode);
|
||||
if (mode === "native") {
|
||||
setValue("authDaemonMode", "native", { shouldValidate: true });
|
||||
setValue("authDaemonPort", null, { shouldValidate: true });
|
||||
setValue("destination", null, { shouldValidate: true });
|
||||
setValue("destinationPort", null, { shouldValidate: true });
|
||||
setAuthDaemonPortInput("22123");
|
||||
trimSitesToFirst();
|
||||
return;
|
||||
}
|
||||
|
||||
setValue("authDaemonMode", standardDaemonLocation, {
|
||||
shouldValidate: true
|
||||
});
|
||||
setValue("destinationPort", 22, { shouldValidate: true });
|
||||
}
|
||||
|
||||
const aliasField = hideAlias ? null : (
|
||||
<PrivateResourceAliasField
|
||||
control={control}
|
||||
watch={watch}
|
||||
labelPrefix={labelPrefix}
|
||||
disabled={disabled}
|
||||
/>
|
||||
);
|
||||
|
||||
const standardSshTargetRow =
|
||||
orgId && !isNative ? (
|
||||
<div className="grid grid-cols-3 gap-4 items-start">
|
||||
<PrivateResourceSitesField
|
||||
control={control}
|
||||
orgId={orgId}
|
||||
selectedSites={selectedSites}
|
||||
onSelectedSitesChange={onSelectedSitesChange}
|
||||
singleSite={!useMultiSiteTargetForm}
|
||||
/>
|
||||
<FormField
|
||||
control={control}
|
||||
name="destination"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t(destinationLabelKey)}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
className="w-full"
|
||||
value={field.value ?? ""}
|
||||
disabled={disabled}
|
||||
onChange={(e) =>
|
||||
field.onChange(
|
||||
e.target.value === ""
|
||||
? null
|
||||
: e.target.value
|
||||
)
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={control}
|
||||
name="destinationPort"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t(destinationPortLabelKey)}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
className="w-full"
|
||||
type="number"
|
||||
min={1}
|
||||
max={65535}
|
||||
value={field.value ?? ""}
|
||||
disabled={disabled}
|
||||
onChange={(e) => {
|
||||
const raw = e.target.value;
|
||||
if (raw === "") {
|
||||
field.onChange(null);
|
||||
return;
|
||||
}
|
||||
const n = Number(raw);
|
||||
field.onChange(
|
||||
Number.isFinite(n) ? n : null
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
const sshSettingsFields = showSshSettings ? (
|
||||
<SshServerSettingsFields
|
||||
idPrefix={
|
||||
layout === "wizard"
|
||||
? "private-ssh-create"
|
||||
: "private-ssh-fields"
|
||||
}
|
||||
pamMode={pamMode}
|
||||
standardDaemonLocation={standardDaemonLocation}
|
||||
authDaemonPort={authDaemonPortInput}
|
||||
onPamModeChange={handlePamModeChange}
|
||||
onStandardDaemonLocationChange={handleDaemonLocationChange}
|
||||
onAuthDaemonPortChange={handleAuthDaemonPortChange}
|
||||
sshServerMode={sshServerMode}
|
||||
serverModeDisplay={layout === "wizard" ? "select" : "badge"}
|
||||
onServerModeChange={handleServerModeChange}
|
||||
/>
|
||||
) : null;
|
||||
|
||||
const destinationSection = (
|
||||
<>
|
||||
<SettingsFormCell span="full">
|
||||
<SettingsSubsectionHeader>
|
||||
<SettingsSubsectionTitle>
|
||||
{t("sshServerDestination")}
|
||||
</SettingsSubsectionTitle>
|
||||
<SettingsSubsectionDescription>
|
||||
{t("sshServerDestinationDescription")}
|
||||
</SettingsSubsectionDescription>
|
||||
</SettingsSubsectionHeader>
|
||||
</SettingsFormCell>
|
||||
|
||||
{isNative && orgId ? (
|
||||
<>
|
||||
<SettingsFormCell span="half">
|
||||
<PrivateResourceSitesField
|
||||
control={control}
|
||||
orgId={orgId}
|
||||
selectedSites={selectedSites}
|
||||
onSelectedSitesChange={onSelectedSitesChange}
|
||||
singleSite
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
<SettingsFormCell span="half">
|
||||
<PrivateResourceAliasField
|
||||
control={control}
|
||||
watch={watch}
|
||||
labelPrefix={labelPrefix}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{!isNative && orgId ? (
|
||||
<SettingsFormCell span="full">
|
||||
{standardSshTargetRow}
|
||||
</SettingsFormCell>
|
||||
) : null}
|
||||
|
||||
{!isNative && !hideAlias ? (
|
||||
<SettingsFormCell span="half">{aliasField}</SettingsFormCell>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
|
||||
const content: ReactNode = (
|
||||
<>
|
||||
{showPaidFeaturesAlert && layout === "default" && (
|
||||
<SettingsFormCell span="full">
|
||||
<PaidFeaturesAlert
|
||||
tiers={tierMatrix.advancedPrivateResources}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
)}
|
||||
{sshSettingsFields}
|
||||
{destinationSection}
|
||||
</>
|
||||
);
|
||||
|
||||
if (embedInParentGrid) {
|
||||
return content;
|
||||
}
|
||||
|
||||
return <SettingsFormGrid>{content}</SettingsFormGrid>;
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
SettingsContainer,
|
||||
SettingsSection,
|
||||
SettingsSectionBody,
|
||||
SettingsSectionDescription,
|
||||
SettingsSectionFooter,
|
||||
SettingsSectionForm,
|
||||
SettingsSectionHeader,
|
||||
SettingsSectionTitle
|
||||
} from "@app/components/Settings";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { Form } from "@app/components/ui/form";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { useSiteResourceContext } from "@app/hooks/useSiteResourceContext";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||
import {
|
||||
accessTagsToIds,
|
||||
buildUpdateSiteResourcePayload,
|
||||
createAccessFormSchema,
|
||||
mergeFormValuesWithResource
|
||||
} from "@app/lib/privateResourceForm";
|
||||
import { resourceQueries, orgQueries } from "@app/lib/queries";
|
||||
import { useAccessFormDefaults } from "@app/providers/SiteResourceProvider";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useActionState, useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { PrivateResourceAccessFields } from "../../PrivateResourceAccessFields";
|
||||
|
||||
export default function PrivateResourceAccessPage() {
|
||||
const t = useTranslations();
|
||||
const { env } = useEnvContext();
|
||||
const api = createApiClient({ env });
|
||||
const queryClient = useQueryClient();
|
||||
const { siteResource, setAccess } = useSiteResourceContext();
|
||||
const { loading, roles, users, clients, hasMachineClients } =
|
||||
useAccessFormDefaults(siteResource.orgId, siteResource.id);
|
||||
|
||||
const machineClientsQuery = useQuery(
|
||||
orgQueries.machineClients({
|
||||
orgId: siteResource.orgId,
|
||||
perPage: 1
|
||||
})
|
||||
);
|
||||
const hasMachineClientsResolved =
|
||||
(machineClientsQuery.data ?? []).filter((c) => !c.userId).length > 0;
|
||||
|
||||
const form = useForm({
|
||||
resolver: zodResolver(createAccessFormSchema()),
|
||||
defaultValues: {
|
||||
roles: [] as typeof roles,
|
||||
users: [] as typeof users,
|
||||
clients: [] as typeof clients
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading) {
|
||||
form.reset({ roles, users, clients });
|
||||
}
|
||||
}, [loading, roles, users, clients, form]);
|
||||
|
||||
const [, formAction, saveLoading] = useActionState(async () => {
|
||||
const isValid = await form.trigger();
|
||||
if (!isValid) return;
|
||||
|
||||
const data = form.getValues();
|
||||
const access = accessTagsToIds({
|
||||
roles: data.roles,
|
||||
users: data.users,
|
||||
clients: data.clients
|
||||
});
|
||||
|
||||
const merged = mergeFormValuesWithResource(siteResource, {});
|
||||
const payload = buildUpdateSiteResourcePayload(merged, access);
|
||||
|
||||
try {
|
||||
await api.post(`/site-resource/${siteResource.id}`, payload);
|
||||
setAccess(access);
|
||||
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries(
|
||||
resourceQueries.siteResourceRoles({
|
||||
siteResourceId: siteResource.id
|
||||
})
|
||||
),
|
||||
queryClient.invalidateQueries(
|
||||
resourceQueries.siteResourceUsers({
|
||||
siteResourceId: siteResource.id
|
||||
})
|
||||
),
|
||||
queryClient.invalidateQueries(
|
||||
resourceQueries.siteResourceClients({
|
||||
siteResourceId: siteResource.id
|
||||
})
|
||||
)
|
||||
]);
|
||||
|
||||
toast({
|
||||
title: t("editInternalResourceDialogSuccess"),
|
||||
description: t(
|
||||
"editInternalResourceDialogInternalResourceUpdatedSuccessfully"
|
||||
)
|
||||
});
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: t("editInternalResourceDialogError"),
|
||||
description: formatAxiosError(
|
||||
error,
|
||||
t(
|
||||
"editInternalResourceDialogFailedToUpdateInternalResource"
|
||||
)
|
||||
),
|
||||
variant: "destructive"
|
||||
});
|
||||
}
|
||||
}, null);
|
||||
|
||||
return (
|
||||
<SettingsContainer>
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
{t("authentication")}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
{t(
|
||||
"editInternalResourceDialogAccessControlDescription"
|
||||
)}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
|
||||
<SettingsSectionBody>
|
||||
<SettingsSectionForm variant="half">
|
||||
<Form {...form}>
|
||||
<form
|
||||
action={formAction}
|
||||
id="private-resource-access-form"
|
||||
>
|
||||
<PrivateResourceAccessFields
|
||||
control={form.control}
|
||||
orgId={siteResource.orgId}
|
||||
loading={loading}
|
||||
hasMachineClients={
|
||||
hasMachineClients ||
|
||||
hasMachineClientsResolved
|
||||
}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
</SettingsSectionForm>
|
||||
</SettingsSectionBody>
|
||||
|
||||
<SettingsSectionFooter>
|
||||
<Button
|
||||
type="submit"
|
||||
form="private-resource-access-form"
|
||||
loading={saveLoading}
|
||||
>
|
||||
{t("saveSettings")}
|
||||
</Button>
|
||||
</SettingsSectionFooter>
|
||||
</SettingsSection>
|
||||
</SettingsContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
SettingsContainer,
|
||||
SettingsFormCell,
|
||||
SettingsFormGrid,
|
||||
SettingsSection,
|
||||
SettingsSectionBody,
|
||||
SettingsSectionDescription,
|
||||
SettingsSectionFooter,
|
||||
SettingsSectionForm,
|
||||
SettingsSectionHeader,
|
||||
SettingsSectionTitle
|
||||
} from "@app/components/Settings";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { Form } from "@app/components/ui/form";
|
||||
import { createCidrFormSchema } from "@app/lib/privateResourceForm";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useActionState, useMemo, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { PrivateResourceSitesField } from "../../PrivateResourceSitesField";
|
||||
import { PrivateResourceCidrDestinationField } from "../../PrivateResourceDestinationFields";
|
||||
import { PrivateResourcePortRanges } from "../../PrivateResourcePortRanges";
|
||||
import { buildSelectedSitesForResource } from "../../privateResourceUtils";
|
||||
import { asAnyControl, asAnySetValue } from "../../formControlUtils";
|
||||
import { useSaveSiteResource } from "../../useSaveSiteResource";
|
||||
|
||||
export default function PrivateResourceCidrPage() {
|
||||
const t = useTranslations();
|
||||
const { save, siteResource } = useSaveSiteResource();
|
||||
const [selectedSites, setSelectedSites] = useState(() =>
|
||||
buildSelectedSitesForResource(siteResource)
|
||||
);
|
||||
|
||||
const formSchema = useMemo(() => createCidrFormSchema(t), [t]);
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
siteIds: siteResource.siteIds,
|
||||
mode: "cidr",
|
||||
destination: siteResource.destination ?? "",
|
||||
tcpPortRangeString: siteResource.tcpPortRangeString ?? "*",
|
||||
udpPortRangeString: siteResource.udpPortRangeString ?? "*",
|
||||
disableIcmp: siteResource.disableIcmp ?? false
|
||||
}
|
||||
});
|
||||
|
||||
const [, formAction, saveLoading] = useActionState(async () => {
|
||||
const isValid = await form.trigger();
|
||||
if (!isValid) return;
|
||||
|
||||
const data = form.getValues();
|
||||
await save({
|
||||
siteIds: data.siteIds,
|
||||
mode: "cidr",
|
||||
destination: data.destination,
|
||||
tcpPortRangeString: data.tcpPortRangeString,
|
||||
udpPortRangeString: data.udpPortRangeString,
|
||||
disableIcmp: data.disableIcmp
|
||||
});
|
||||
}, null);
|
||||
|
||||
return (
|
||||
<SettingsContainer>
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
{t("cidrSettings")}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
{t(
|
||||
"editInternalResourceDialogDestinationCidrDescription"
|
||||
)}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
|
||||
<SettingsSectionBody>
|
||||
<SettingsSectionForm variant="half">
|
||||
<Form {...form}>
|
||||
<form
|
||||
action={formAction}
|
||||
id="private-resource-cidr-form"
|
||||
>
|
||||
<SettingsFormGrid>
|
||||
<SettingsFormCell span="half">
|
||||
<PrivateResourceSitesField
|
||||
control={form.control}
|
||||
orgId={siteResource.orgId}
|
||||
selectedSites={selectedSites}
|
||||
onSelectedSitesChange={
|
||||
setSelectedSites
|
||||
}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
|
||||
<SettingsFormCell span="half">
|
||||
<PrivateResourceCidrDestinationField
|
||||
control={asAnyControl(form.control)}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
|
||||
<SettingsFormCell span="full">
|
||||
<PrivateResourcePortRanges
|
||||
control={asAnyControl(form.control)}
|
||||
setValue={asAnySetValue(
|
||||
form.setValue
|
||||
)}
|
||||
initialTcp={
|
||||
siteResource.tcpPortRangeString
|
||||
}
|
||||
initialUdp={
|
||||
siteResource.udpPortRangeString
|
||||
}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
</SettingsFormGrid>
|
||||
</form>
|
||||
</Form>
|
||||
</SettingsSectionForm>
|
||||
</SettingsSectionBody>
|
||||
|
||||
<SettingsSectionFooter>
|
||||
<Button
|
||||
type="submit"
|
||||
form="private-resource-cidr-form"
|
||||
loading={saveLoading}
|
||||
>
|
||||
{t("saveSettings")}
|
||||
</Button>
|
||||
</SettingsSectionFooter>
|
||||
</SettingsSection>
|
||||
</SettingsContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
SettingsContainer,
|
||||
SettingsFormCell,
|
||||
SettingsFormGrid,
|
||||
SettingsSection,
|
||||
SettingsSectionBody,
|
||||
SettingsSectionDescription,
|
||||
SettingsSectionFooter,
|
||||
SettingsSectionForm,
|
||||
SettingsSectionHeader,
|
||||
SettingsSectionTitle
|
||||
} from "@app/components/Settings";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from "@app/components/ui/form";
|
||||
import { Input } from "@app/components/ui/input";
|
||||
import { createGeneralFormSchema } from "@app/lib/privateResourceForm";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useActionState, useMemo } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { useSaveSiteResource } from "../../useSaveSiteResource";
|
||||
|
||||
export default function PrivateResourceGeneralPage() {
|
||||
const t = useTranslations();
|
||||
const { save, siteResource } = useSaveSiteResource();
|
||||
|
||||
const formSchema = useMemo(() => createGeneralFormSchema(t), [t]);
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
name: siteResource.name,
|
||||
niceId: siteResource.niceId
|
||||
}
|
||||
});
|
||||
|
||||
const [, formAction, saveLoading] = useActionState(async () => {
|
||||
const isValid = await form.trigger();
|
||||
if (!isValid) return;
|
||||
|
||||
const data = form.getValues();
|
||||
await save({
|
||||
name: data.name,
|
||||
niceId: data.niceId
|
||||
});
|
||||
}, null);
|
||||
|
||||
return (
|
||||
<SettingsContainer>
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
{t("resourceGeneral")}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
{t("resourceGeneralDescription")}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
|
||||
<SettingsSectionBody>
|
||||
<SettingsSectionForm variant="half">
|
||||
<Form {...form}>
|
||||
<form
|
||||
action={formAction}
|
||||
id="private-resource-general-form"
|
||||
>
|
||||
<SettingsFormGrid>
|
||||
<SettingsFormCell span="half">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t(
|
||||
"editInternalResourceDialogName"
|
||||
)}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
<SettingsFormCell span="half">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="niceId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("identifier")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
</SettingsFormGrid>
|
||||
</form>
|
||||
</Form>
|
||||
</SettingsSectionForm>
|
||||
</SettingsSectionBody>
|
||||
|
||||
<SettingsSectionFooter>
|
||||
<Button
|
||||
type="submit"
|
||||
form="private-resource-general-form"
|
||||
loading={saveLoading}
|
||||
>
|
||||
{t("saveSettings")}
|
||||
</Button>
|
||||
</SettingsSectionFooter>
|
||||
</SettingsSection>
|
||||
</SettingsContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
SettingsContainer,
|
||||
SettingsFormCell,
|
||||
SettingsFormGrid,
|
||||
SettingsSection,
|
||||
SettingsSectionBody,
|
||||
SettingsSectionDescription,
|
||||
SettingsSectionFooter,
|
||||
SettingsSectionForm,
|
||||
SettingsSectionHeader,
|
||||
SettingsSectionTitle
|
||||
} from "@app/components/Settings";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { Form } from "@app/components/ui/form";
|
||||
import { createHostFormSchema } from "@app/lib/privateResourceForm";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useActionState, useMemo, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { PrivateResourceSitesField } from "../../PrivateResourceSitesField";
|
||||
import { PrivateResourceHostDestinationFields } from "../../PrivateResourceDestinationFields";
|
||||
import { PrivateResourcePortRanges } from "../../PrivateResourcePortRanges";
|
||||
import { buildSelectedSitesForResource } from "../../privateResourceUtils";
|
||||
import {
|
||||
asAnyControl,
|
||||
asAnySetValue,
|
||||
asAnyWatch
|
||||
} from "../../formControlUtils";
|
||||
import { useSaveSiteResource } from "../../useSaveSiteResource";
|
||||
|
||||
export default function PrivateResourceHostPage() {
|
||||
const t = useTranslations();
|
||||
const { save, siteResource } = useSaveSiteResource();
|
||||
const [selectedSites, setSelectedSites] = useState(() =>
|
||||
buildSelectedSitesForResource(siteResource)
|
||||
);
|
||||
|
||||
const formSchema = useMemo(() => createHostFormSchema(t), [t]);
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
siteIds: siteResource.siteIds,
|
||||
mode: "host",
|
||||
destination: siteResource.destination ?? "",
|
||||
alias: siteResource.alias ?? null,
|
||||
tcpPortRangeString: siteResource.tcpPortRangeString ?? "*",
|
||||
udpPortRangeString: siteResource.udpPortRangeString ?? "*",
|
||||
disableIcmp: siteResource.disableIcmp ?? false,
|
||||
authDaemonMode: siteResource.authDaemonMode ?? "site",
|
||||
authDaemonPort: siteResource.authDaemonPort ?? null
|
||||
}
|
||||
});
|
||||
|
||||
const [, formAction, saveLoading] = useActionState(async () => {
|
||||
const isValid = await form.trigger();
|
||||
if (!isValid) return;
|
||||
|
||||
const data = form.getValues();
|
||||
await save({
|
||||
siteIds: data.siteIds,
|
||||
mode: "host",
|
||||
destination: data.destination,
|
||||
alias: data.alias,
|
||||
tcpPortRangeString: data.tcpPortRangeString,
|
||||
udpPortRangeString: data.udpPortRangeString,
|
||||
disableIcmp: data.disableIcmp,
|
||||
authDaemonMode: data.authDaemonMode,
|
||||
authDaemonPort: data.authDaemonPort
|
||||
});
|
||||
}, null);
|
||||
|
||||
return (
|
||||
<SettingsContainer>
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
{t("hostSettings")}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
{t("editInternalResourceDialogDestinationDescription")}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
|
||||
<SettingsSectionBody>
|
||||
<SettingsSectionForm variant="half">
|
||||
<Form {...form}>
|
||||
<form
|
||||
action={formAction}
|
||||
id="private-resource-host-form"
|
||||
>
|
||||
<SettingsFormGrid>
|
||||
<SettingsFormCell span="half">
|
||||
<PrivateResourceSitesField
|
||||
control={form.control}
|
||||
orgId={siteResource.orgId}
|
||||
selectedSites={selectedSites}
|
||||
onSelectedSitesChange={
|
||||
setSelectedSites
|
||||
}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
|
||||
<SettingsFormCell span="full">
|
||||
<PrivateResourceHostDestinationFields
|
||||
control={asAnyControl(form.control)}
|
||||
watch={asAnyWatch(form.watch)}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
|
||||
<SettingsFormCell span="full">
|
||||
<PrivateResourcePortRanges
|
||||
control={asAnyControl(form.control)}
|
||||
setValue={asAnySetValue(
|
||||
form.setValue
|
||||
)}
|
||||
initialTcp={
|
||||
siteResource.tcpPortRangeString
|
||||
}
|
||||
initialUdp={
|
||||
siteResource.udpPortRangeString
|
||||
}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
</SettingsFormGrid>
|
||||
</form>
|
||||
</Form>
|
||||
</SettingsSectionForm>
|
||||
</SettingsSectionBody>
|
||||
|
||||
<SettingsSectionFooter>
|
||||
<Button
|
||||
type="submit"
|
||||
form="private-resource-host-form"
|
||||
loading={saveLoading}
|
||||
>
|
||||
{t("saveSettings")}
|
||||
</Button>
|
||||
</SettingsSectionFooter>
|
||||
</SettingsSection>
|
||||
</SettingsContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
SettingsContainer,
|
||||
SettingsFormCell,
|
||||
SettingsFormGrid,
|
||||
SettingsSection,
|
||||
SettingsSectionBody,
|
||||
SettingsSectionDescription,
|
||||
SettingsSectionFooter,
|
||||
SettingsSectionForm,
|
||||
SettingsSectionHeader,
|
||||
SettingsSectionTitle
|
||||
} from "@app/components/Settings";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { Form } from "@app/components/ui/form";
|
||||
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
||||
import { createHttpFormSchema } from "@app/lib/privateResourceForm";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useActionState, useMemo, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { PrivateResourceSitesField } from "../../PrivateResourceSitesField";
|
||||
import { PrivateResourceHttpFields } from "../../PrivateResourceHttpFields";
|
||||
import { buildSelectedSitesForResource } from "../../privateResourceUtils";
|
||||
import {
|
||||
asAnyControl,
|
||||
asAnySetValue,
|
||||
asAnyWatch
|
||||
} from "../../formControlUtils";
|
||||
import { useSaveSiteResource } from "../../useSaveSiteResource";
|
||||
|
||||
export default function PrivateResourceHttpPage() {
|
||||
const t = useTranslations();
|
||||
const { save, siteResource } = useSaveSiteResource();
|
||||
const { isPaidUser } = usePaidStatus();
|
||||
const httpSectionDisabled = !isPaidUser(
|
||||
tierMatrix.advancedPrivateResources
|
||||
);
|
||||
const [selectedSites, setSelectedSites] = useState(() =>
|
||||
buildSelectedSitesForResource(siteResource)
|
||||
);
|
||||
|
||||
const formSchema = useMemo(() => createHttpFormSchema(t), [t]);
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
siteIds: siteResource.siteIds,
|
||||
mode: "http",
|
||||
destination: siteResource.destination ?? "",
|
||||
destinationPort: siteResource.destinationPort ?? null,
|
||||
scheme: siteResource.scheme ?? "http",
|
||||
ssl: siteResource.ssl ?? false,
|
||||
httpConfigSubdomain: siteResource.subdomain ?? null,
|
||||
httpConfigDomainId: siteResource.domainId ?? null,
|
||||
httpConfigFullDomain: siteResource.fullDomain ?? null
|
||||
}
|
||||
});
|
||||
|
||||
const [, formAction, saveLoading] = useActionState(async () => {
|
||||
const isValid = await form.trigger();
|
||||
if (!isValid) return;
|
||||
|
||||
const data = form.getValues();
|
||||
await save({
|
||||
siteIds: data.siteIds,
|
||||
mode: "http",
|
||||
destination: data.destination,
|
||||
destinationPort: data.destinationPort,
|
||||
scheme: data.scheme,
|
||||
ssl: data.ssl,
|
||||
httpConfigSubdomain: data.httpConfigSubdomain,
|
||||
httpConfigDomainId: data.httpConfigDomainId,
|
||||
httpConfigFullDomain: data.httpConfigFullDomain
|
||||
});
|
||||
}, null);
|
||||
|
||||
return (
|
||||
<SettingsContainer>
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
{t("httpSettings")}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
{t(
|
||||
"editInternalResourceDialogHttpConfigurationDescription"
|
||||
)}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
|
||||
<SettingsSectionBody>
|
||||
<SettingsSectionForm variant="half">
|
||||
<Form {...form}>
|
||||
<form
|
||||
action={formAction}
|
||||
id="private-resource-http-form"
|
||||
>
|
||||
<SettingsFormGrid>
|
||||
<SettingsFormCell span="half">
|
||||
<PrivateResourceSitesField
|
||||
control={form.control}
|
||||
orgId={siteResource.orgId}
|
||||
selectedSites={selectedSites}
|
||||
onSelectedSitesChange={
|
||||
setSelectedSites
|
||||
}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
|
||||
<SettingsFormCell span="full">
|
||||
<PrivateResourceHttpFields
|
||||
control={asAnyControl(form.control)}
|
||||
setValue={asAnySetValue(
|
||||
form.setValue
|
||||
)}
|
||||
orgId={siteResource.orgId}
|
||||
watch={asAnyWatch(form.watch)}
|
||||
disabled={httpSectionDisabled}
|
||||
siteResourceId={siteResource.id}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
</SettingsFormGrid>
|
||||
</form>
|
||||
</Form>
|
||||
</SettingsSectionForm>
|
||||
</SettingsSectionBody>
|
||||
|
||||
<SettingsSectionFooter>
|
||||
<Button
|
||||
type="submit"
|
||||
form="private-resource-http-form"
|
||||
loading={saveLoading}
|
||||
disabled={httpSectionDisabled}
|
||||
>
|
||||
{t("saveSettings")}
|
||||
</Button>
|
||||
</SettingsSectionFooter>
|
||||
</SettingsSection>
|
||||
</SettingsContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { HorizontalTabs } from "@app/components/HorizontalTabs";
|
||||
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||
import { fetchSiteResourceByNiceId } from "@app/lib/fetchSiteResourceByNiceId";
|
||||
import { getCachedOrg } from "@app/lib/api/getCachedOrg";
|
||||
import OrgProvider from "@app/providers/OrgProvider";
|
||||
import SiteResourceProvider from "@app/providers/SiteResourceProvider";
|
||||
import SiteResourceInfoBox from "@app/components/SiteResourceInfoBox";
|
||||
import type { Metadata } from "next";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Private Resource"
|
||||
};
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type PrivateResourceLayoutProps = {
|
||||
children: React.ReactNode;
|
||||
params: Promise<{ niceId: string; orgId: string }>;
|
||||
};
|
||||
|
||||
export default async function PrivateResourceLayout(
|
||||
props: PrivateResourceLayoutProps
|
||||
) {
|
||||
const params = await props.params;
|
||||
const t = await getTranslations();
|
||||
const { children } = props;
|
||||
|
||||
const siteResource = await fetchSiteResourceByNiceId(
|
||||
params.orgId,
|
||||
params.niceId
|
||||
);
|
||||
|
||||
if (!siteResource) {
|
||||
redirect(`/${params.orgId}/settings/resources/private`);
|
||||
}
|
||||
|
||||
let org = null;
|
||||
try {
|
||||
const res = await getCachedOrg(params.orgId);
|
||||
org = res.data.data;
|
||||
} catch {
|
||||
redirect(`/${params.orgId}/settings/resources/private`);
|
||||
}
|
||||
|
||||
if (!org) {
|
||||
redirect(`/${params.orgId}/settings/resources/private`);
|
||||
}
|
||||
|
||||
const modeSettingsKey = `${siteResource.mode}Settings` as
|
||||
| "hostSettings"
|
||||
| "cidrSettings"
|
||||
| "httpSettings"
|
||||
| "sshSettings";
|
||||
|
||||
const navItems = [
|
||||
{
|
||||
title: t("general"),
|
||||
href: `/{orgId}/settings/resources/private/{niceId}/general`
|
||||
},
|
||||
{
|
||||
title: t(modeSettingsKey),
|
||||
href: `/{orgId}/settings/resources/private/{niceId}/${siteResource.mode}`
|
||||
},
|
||||
{
|
||||
title: t("authentication"),
|
||||
href: `/{orgId}/settings/resources/private/{niceId}/access`
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsSectionTitle
|
||||
title={t("resourceSetting", {
|
||||
resourceName: siteResource.name
|
||||
})}
|
||||
description={t("resourceSettingDescription")}
|
||||
/>
|
||||
|
||||
<OrgProvider org={org}>
|
||||
<SiteResourceProvider siteResource={siteResource}>
|
||||
<div className="space-y-6">
|
||||
<SiteResourceInfoBox />
|
||||
<HorizontalTabs items={navItems}>
|
||||
{children}
|
||||
</HorizontalTabs>
|
||||
</div>
|
||||
</SiteResourceProvider>
|
||||
</OrgProvider>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { Metadata } from "next";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Private Resource"
|
||||
};
|
||||
|
||||
export default async function PrivateResourcePage(props: {
|
||||
params: Promise<{ niceId: string; orgId: string }>;
|
||||
}) {
|
||||
const params = await props.params;
|
||||
redirect(
|
||||
`/${params.orgId}/settings/resources/private/${params.niceId}/general`
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
SettingsContainer,
|
||||
SettingsSection,
|
||||
SettingsSectionBody,
|
||||
SettingsSectionDescription,
|
||||
SettingsSectionFooter,
|
||||
SettingsSectionForm,
|
||||
SettingsSectionHeader,
|
||||
SettingsSectionTitle,
|
||||
SettingsFormGrid
|
||||
} from "@app/components/Settings";
|
||||
import { SshServerSettingsFields } from "@app/components/SshServerSettingsFields";
|
||||
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { Form } from "@app/components/ui/form";
|
||||
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
||||
import {
|
||||
createSshFormSchema,
|
||||
inferSshPamMode
|
||||
} from "@app/lib/privateResourceForm";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useActionState, useMemo, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { PrivateResourceSshFields } from "../../PrivateResourceSshFields";
|
||||
import { buildSelectedSitesForResource } from "../../privateResourceUtils";
|
||||
import {
|
||||
asAnyControl,
|
||||
asAnySetValue,
|
||||
asAnyWatch
|
||||
} from "../../formControlUtils";
|
||||
import { useSaveSiteResource } from "../../useSaveSiteResource";
|
||||
import type { Selectedsite } from "@app/components/site-selector";
|
||||
|
||||
export default function PrivateResourceSshPage() {
|
||||
const t = useTranslations();
|
||||
const { save, siteResource } = useSaveSiteResource();
|
||||
const { isPaidUser } = usePaidStatus();
|
||||
const sshSectionDisabled = !isPaidUser(tierMatrix.advancedPrivateResources);
|
||||
const isNative = siteResource.authDaemonMode === "native";
|
||||
const [sshServerMode] = useState<"standard" | "native">(
|
||||
isNative ? "native" : "standard"
|
||||
);
|
||||
const [selectedSites, setSelectedSites] = useState(() =>
|
||||
buildSelectedSitesForResource(siteResource)
|
||||
);
|
||||
|
||||
const formSchema = useMemo(
|
||||
() => createSshFormSchema(t, { isNative }),
|
||||
[t, isNative]
|
||||
);
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
siteIds: siteResource.siteIds,
|
||||
mode: "ssh",
|
||||
destination: siteResource.destination ?? "",
|
||||
alias: siteResource.alias ?? null,
|
||||
destinationPort: siteResource.destinationPort ?? null,
|
||||
pamMode: inferSshPamMode(
|
||||
siteResource.authDaemonMode,
|
||||
siteResource.pamMode
|
||||
),
|
||||
standardDaemonLocation: isNative
|
||||
? "site"
|
||||
: siteResource.authDaemonMode === "remote"
|
||||
? "remote"
|
||||
: "site",
|
||||
authDaemonPort: siteResource.authDaemonPort
|
||||
? String(siteResource.authDaemonPort)
|
||||
: "22123"
|
||||
}
|
||||
});
|
||||
|
||||
const pamMode = form.watch("pamMode");
|
||||
const standardDaemonLocation = form.watch("standardDaemonLocation");
|
||||
const authDaemonPort = form.watch("authDaemonPort");
|
||||
|
||||
function trimSitesToFirst() {
|
||||
if (selectedSites.length <= 1) return;
|
||||
|
||||
const first = selectedSites.slice(0, 1);
|
||||
setSelectedSites(first);
|
||||
form.setValue(
|
||||
"siteIds",
|
||||
first.map((s: Selectedsite) => s.siteId),
|
||||
{ shouldValidate: true }
|
||||
);
|
||||
}
|
||||
|
||||
function handlePamModeChange(value: "passthrough" | "push") {
|
||||
form.setValue("pamMode", value, { shouldValidate: true });
|
||||
|
||||
if (value === "push") {
|
||||
if (
|
||||
standardDaemonLocation !== "remote" &&
|
||||
selectedSites.length > 1
|
||||
) {
|
||||
trimSitesToFirst();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
form.setValue("authDaemonPort", "22123", { shouldValidate: true });
|
||||
}
|
||||
|
||||
function handleDaemonLocationChange(value: "site" | "remote") {
|
||||
form.setValue("standardDaemonLocation", value, {
|
||||
shouldValidate: true
|
||||
});
|
||||
|
||||
if (value === "site") {
|
||||
form.setValue("authDaemonPort", "22123", { shouldValidate: true });
|
||||
trimSitesToFirst();
|
||||
}
|
||||
}
|
||||
|
||||
const [, formAction, saveLoading] = useActionState(async () => {
|
||||
const isValid = await form.trigger();
|
||||
if (!isValid) return;
|
||||
|
||||
const data = form.getValues();
|
||||
const effectiveAuthDaemonMode = isNative
|
||||
? "native"
|
||||
: data.standardDaemonLocation;
|
||||
const effectiveAuthDaemonPort =
|
||||
!isNative &&
|
||||
data.pamMode === "push" &&
|
||||
data.standardDaemonLocation === "remote"
|
||||
? Number(data.authDaemonPort)
|
||||
: null;
|
||||
|
||||
await save({
|
||||
siteIds: data.siteIds,
|
||||
mode: "ssh",
|
||||
destination: isNative ? null : data.destination,
|
||||
alias: data.alias,
|
||||
destinationPort: isNative ? null : data.destinationPort,
|
||||
authDaemonMode: effectiveAuthDaemonMode,
|
||||
authDaemonPort: effectiveAuthDaemonPort,
|
||||
pamMode: data.pamMode
|
||||
});
|
||||
}, null);
|
||||
|
||||
return (
|
||||
<SettingsContainer>
|
||||
<PaidFeaturesAlert tiers={tierMatrix.advancedPrivateResources} />
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
{t("sshSettings")}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
{t("editInternalResourceDialogDestinationDescription")}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
|
||||
<fieldset
|
||||
disabled={sshSectionDisabled}
|
||||
className={
|
||||
sshSectionDisabled
|
||||
? "opacity-50 pointer-events-none"
|
||||
: ""
|
||||
}
|
||||
>
|
||||
<Form {...form}>
|
||||
<SettingsSectionBody>
|
||||
<SettingsSectionForm variant="half">
|
||||
<SettingsFormGrid>
|
||||
<SshServerSettingsFields
|
||||
idPrefix="private-ssh-edit"
|
||||
pamMode={pamMode}
|
||||
standardDaemonLocation={
|
||||
standardDaemonLocation
|
||||
}
|
||||
authDaemonPort={authDaemonPort}
|
||||
onPamModeChange={handlePamModeChange}
|
||||
onStandardDaemonLocationChange={
|
||||
handleDaemonLocationChange
|
||||
}
|
||||
onAuthDaemonPortChange={(value) =>
|
||||
form.setValue(
|
||||
"authDaemonPort",
|
||||
value,
|
||||
{ shouldValidate: true }
|
||||
)
|
||||
}
|
||||
authDaemonPortError={
|
||||
form.formState.errors.authDaemonPort
|
||||
?.message
|
||||
}
|
||||
sshServerMode={sshServerMode}
|
||||
serverModeDisplay="badge"
|
||||
/>
|
||||
<PrivateResourceSshFields
|
||||
control={asAnyControl(form.control)}
|
||||
setValue={asAnySetValue(form.setValue)}
|
||||
watch={asAnyWatch(form.watch)}
|
||||
orgId={siteResource.orgId}
|
||||
selectedSites={selectedSites}
|
||||
onSelectedSitesChange={setSelectedSites}
|
||||
showSshSettings={false}
|
||||
embedInParentGrid
|
||||
showPaidFeaturesAlert={false}
|
||||
isNativeSsh={isNative}
|
||||
/>
|
||||
</SettingsFormGrid>
|
||||
</SettingsSectionForm>
|
||||
</SettingsSectionBody>
|
||||
|
||||
<SettingsSectionFooter>
|
||||
<form action={formAction}>
|
||||
<Button type="submit" loading={saveLoading}>
|
||||
{t("saveSettings")}
|
||||
</Button>
|
||||
</form>
|
||||
</SettingsSectionFooter>
|
||||
</Form>
|
||||
</fieldset>
|
||||
</SettingsSection>
|
||||
</SettingsContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,638 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
SettingsFormCell,
|
||||
SettingsFormGrid,
|
||||
SettingsSection,
|
||||
SettingsSectionBody,
|
||||
SettingsSectionDescription,
|
||||
SettingsSectionForm,
|
||||
SettingsSectionHeader,
|
||||
SettingsSectionTitle
|
||||
} from "@app/components/Settings";
|
||||
import HeaderTitle from "@app/components/SettingsSectionTitle";
|
||||
import {
|
||||
OptionSelect,
|
||||
type OptionSelectOption
|
||||
} from "@app/components/OptionSelect";
|
||||
import DomainPicker from "@app/components/DomainPicker";
|
||||
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from "@app/components/ui/form";
|
||||
import { Input } from "@app/components/ui/input";
|
||||
import type { Selectedsite } from "@app/components/site-selector";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||
import {
|
||||
buildCreateSiteResourcePayload,
|
||||
createCreateFormSchema,
|
||||
type PrivateResourceMode
|
||||
} from "@app/lib/privateResourceForm";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
import type { SiteResource } from "@server/db";
|
||||
import { GetSiteResponse } from "@server/routers/site/getSite";
|
||||
import type ResponseT from "@server/types/Response";
|
||||
import { AxiosResponse } from "axios";
|
||||
import { useTranslations } from "next-intl";
|
||||
import Link from "next/link";
|
||||
import { useParams, useRouter, useSearchParams } from "next/navigation";
|
||||
import { useEffect, useMemo, useState, useTransition } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { PrivateResourceSitesField } from "../PrivateResourceSitesField";
|
||||
import { PrivateResourceHttpFields } from "../PrivateResourceHttpFields";
|
||||
import { PrivateResourceSshFields } from "../PrivateResourceSshFields";
|
||||
import { PrivateResourcePortRanges } from "../PrivateResourcePortRanges";
|
||||
import {
|
||||
PrivateResourceAliasField,
|
||||
PrivateResourceCidrDestinationField,
|
||||
PrivateResourceHostDestinationFields
|
||||
} from "../PrivateResourceDestinationFields";
|
||||
import { asAnyControl, asAnySetValue, asAnyWatch } from "../formControlUtils";
|
||||
|
||||
export default function CreatePrivateResourcePage() {
|
||||
const params = useParams();
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
const t = useTranslations();
|
||||
const { env } = useEnvContext();
|
||||
const api = createApiClient({ env });
|
||||
const orgId = params.orgId as string;
|
||||
const disableEnterpriseFeatures = env.flags.disableEnterpriseFeatures;
|
||||
const { isPaidUser } = usePaidStatus();
|
||||
const httpSectionDisabled = !isPaidUser(
|
||||
tierMatrix.advancedPrivateResources
|
||||
);
|
||||
const sshSectionDisabled = !isPaidUser(tierMatrix.advancedPrivateResources);
|
||||
const [isSubmitting, startTransition] = useTransition();
|
||||
|
||||
const siteIdParam = searchParams.get("siteId");
|
||||
const siteIdNumber =
|
||||
siteIdParam && Number.isInteger(Number(siteIdParam))
|
||||
? Number(siteIdParam)
|
||||
: null;
|
||||
|
||||
const [selectedSites, setSelectedSites] = useState<Selectedsite[]>([]);
|
||||
|
||||
const formSchema = useMemo(() => createCreateFormSchema(t), [t]);
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
name: "",
|
||||
siteIds: [],
|
||||
mode: "host",
|
||||
destination: "",
|
||||
alias: null,
|
||||
destinationPort: null,
|
||||
scheme: "http",
|
||||
ssl: true,
|
||||
httpConfigSubdomain: null,
|
||||
httpConfigDomainId: null,
|
||||
httpConfigFullDomain: null,
|
||||
authDaemonMode: "native",
|
||||
standardDaemonLocation: "site",
|
||||
authDaemonPort: null,
|
||||
pamMode: "passthrough",
|
||||
tcpPortRangeString: "*",
|
||||
udpPortRangeString: "*",
|
||||
disableIcmp: false
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!siteIdNumber) return;
|
||||
|
||||
void api
|
||||
.get<ResponseT<GetSiteResponse>>(`/site/${siteIdNumber}`)
|
||||
.then((res) => {
|
||||
const site = res.data.data;
|
||||
if (!site || site.orgId !== orgId) return;
|
||||
const selected: Selectedsite = {
|
||||
siteId: site.siteId,
|
||||
name: site.name,
|
||||
type: site.type as Selectedsite["type"]
|
||||
};
|
||||
setSelectedSites([selected]);
|
||||
form.setValue("siteIds", [site.siteId]);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, [api, form, orgId, siteIdNumber]);
|
||||
|
||||
const mode = form.watch("mode");
|
||||
const authDaemonMode = form.watch("authDaemonMode");
|
||||
const isNativeSsh = mode === "ssh" && authDaemonMode === "native";
|
||||
|
||||
const modeOptions: OptionSelectOption<PrivateResourceMode>[] = [
|
||||
{ value: "host", label: t("createInternalResourceDialogModeHost") },
|
||||
{ value: "cidr", label: t("createInternalResourceDialogModeCidr") },
|
||||
...(!disableEnterpriseFeatures
|
||||
? [
|
||||
{
|
||||
value: "http" as const,
|
||||
label: t("createInternalResourceDialogModeHttp")
|
||||
},
|
||||
{
|
||||
value: "ssh" as const,
|
||||
label: t("createInternalResourceDialogModeSsh")
|
||||
}
|
||||
]
|
||||
: [])
|
||||
];
|
||||
|
||||
const submitDisabled =
|
||||
isSubmitting ||
|
||||
(mode === "http" && httpSectionDisabled) ||
|
||||
(mode === "ssh" && sshSectionDisabled);
|
||||
|
||||
function onSubmit(values: FormValues) {
|
||||
startTransition(async () => {
|
||||
try {
|
||||
const res = await api.put<
|
||||
AxiosResponse<ResponseT<SiteResource>>
|
||||
>(
|
||||
`/org/${orgId}/site-resource`,
|
||||
buildCreateSiteResourcePayload({
|
||||
...values,
|
||||
destination:
|
||||
values.destination?.trim() &&
|
||||
values.destination.trim().length > 0
|
||||
? values.destination.trim()
|
||||
: null
|
||||
})
|
||||
);
|
||||
|
||||
toast({
|
||||
title: t("createInternalResourceDialogSuccess"),
|
||||
description: t(
|
||||
"createInternalResourceDialogInternalResourceCreatedSuccessfully"
|
||||
)
|
||||
});
|
||||
|
||||
const created = (res.data as unknown as ResponseT<SiteResource>)
|
||||
.data;
|
||||
if (!created) {
|
||||
throw new Error("Failed to create private resource");
|
||||
}
|
||||
|
||||
router.push(
|
||||
`/${orgId}/settings/resources/private/${created.niceId}/${created.mode}`
|
||||
);
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: t("createInternalResourceDialogError"),
|
||||
description: formatAxiosError(
|
||||
error,
|
||||
t(
|
||||
"createInternalResourceDialogFailedToCreateInternalResource"
|
||||
)
|
||||
),
|
||||
variant: "destructive"
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<HeaderTitle
|
||||
title={t(
|
||||
"createInternalResourceDialogCreateClientResource"
|
||||
)}
|
||||
description={t(
|
||||
"createInternalResourceDialogCreateClientResourceDescription"
|
||||
)}
|
||||
/>
|
||||
<Button variant="outline" asChild>
|
||||
<Link href={`/${orgId}/settings/resources/private`}>
|
||||
{t("privateResourceCreatePageSeeAll")}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Form {...form}>
|
||||
<form
|
||||
id="create-private-resource-form"
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="space-y-6"
|
||||
>
|
||||
{/* General */}
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
{t("resourceCreateGeneral")}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
{t("resourceCreateGeneralDescription")}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
<SettingsSectionForm variant="half">
|
||||
<SettingsFormGrid>
|
||||
<SettingsFormCell span="half">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("name")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
<FormDescription>
|
||||
{t(
|
||||
"resourceNameDescription"
|
||||
)}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
|
||||
<SettingsFormCell span="full">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="mode"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("type")}
|
||||
</FormLabel>
|
||||
<OptionSelect<PrivateResourceMode>
|
||||
options={modeOptions}
|
||||
value={field.value}
|
||||
onChange={(newMode) => {
|
||||
field.onChange(
|
||||
newMode
|
||||
);
|
||||
if (
|
||||
newMode ===
|
||||
"ssh"
|
||||
) {
|
||||
form.setValue(
|
||||
"authDaemonMode",
|
||||
"native"
|
||||
);
|
||||
form.setValue(
|
||||
"standardDaemonLocation",
|
||||
"site"
|
||||
);
|
||||
form.setValue(
|
||||
"destination",
|
||||
null
|
||||
);
|
||||
form.setValue(
|
||||
"destinationPort",
|
||||
null
|
||||
);
|
||||
} else if (
|
||||
newMode ===
|
||||
"http"
|
||||
) {
|
||||
form.setValue(
|
||||
"destinationPort",
|
||||
443
|
||||
);
|
||||
} else {
|
||||
form.setValue(
|
||||
"destinationPort",
|
||||
null
|
||||
);
|
||||
}
|
||||
}}
|
||||
cols={4}
|
||||
/>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
|
||||
{mode === "http" && (
|
||||
<SettingsFormCell span="full">
|
||||
<FormItem>
|
||||
<DomainPicker
|
||||
orgId={orgId}
|
||||
cols={2}
|
||||
hideFreeDomain
|
||||
onDomainChange={(res) => {
|
||||
if (!res) {
|
||||
form.setValue(
|
||||
"httpConfigSubdomain",
|
||||
null
|
||||
);
|
||||
form.setValue(
|
||||
"httpConfigDomainId",
|
||||
null
|
||||
);
|
||||
form.setValue(
|
||||
"httpConfigFullDomain",
|
||||
null
|
||||
);
|
||||
return;
|
||||
}
|
||||
form.setValue(
|
||||
"httpConfigSubdomain",
|
||||
res.subdomain ??
|
||||
null
|
||||
);
|
||||
form.setValue(
|
||||
"httpConfigDomainId",
|
||||
res.domainId
|
||||
);
|
||||
form.setValue(
|
||||
"httpConfigFullDomain",
|
||||
res.fullDomain
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<FormMessage />
|
||||
<FormDescription>
|
||||
{t(
|
||||
"resourceDomainDescription"
|
||||
)}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
</SettingsFormCell>
|
||||
)}
|
||||
|
||||
{(mode === "host" ||
|
||||
(mode === "ssh" && !isNativeSsh)) && (
|
||||
<SettingsFormCell span="half">
|
||||
<PrivateResourceAliasField
|
||||
control={asAnyControl(
|
||||
form.control
|
||||
)}
|
||||
watch={asAnyWatch(form.watch)}
|
||||
labelPrefix="create"
|
||||
disabled={
|
||||
mode === "ssh" &&
|
||||
sshSectionDisabled
|
||||
}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
)}
|
||||
</SettingsFormGrid>
|
||||
</SettingsSectionForm>
|
||||
</SettingsSectionBody>
|
||||
</SettingsSection>
|
||||
|
||||
{/* Host destination */}
|
||||
{mode === "host" && (
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
{t("hostSettings")}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
{t(
|
||||
"editInternalResourceDialogDestinationDescription"
|
||||
)}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
<SettingsSectionForm variant="half">
|
||||
<SettingsFormGrid>
|
||||
<SettingsFormCell span="half">
|
||||
<PrivateResourceSitesField
|
||||
control={form.control}
|
||||
orgId={orgId}
|
||||
selectedSites={selectedSites}
|
||||
onSelectedSitesChange={
|
||||
setSelectedSites
|
||||
}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
<SettingsFormCell span="half">
|
||||
<PrivateResourceHostDestinationFields
|
||||
control={asAnyControl(
|
||||
form.control
|
||||
)}
|
||||
watch={asAnyWatch(form.watch)}
|
||||
labelPrefix="create"
|
||||
hideAlias
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
<SettingsFormCell span="full">
|
||||
<PrivateResourcePortRanges
|
||||
control={asAnyControl(
|
||||
form.control
|
||||
)}
|
||||
setValue={asAnySetValue(
|
||||
form.setValue
|
||||
)}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
</SettingsFormGrid>
|
||||
</SettingsSectionForm>
|
||||
</SettingsSectionBody>
|
||||
</SettingsSection>
|
||||
)}
|
||||
|
||||
{/* CIDR destination */}
|
||||
{mode === "cidr" && (
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
{t("cidrSettings")}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
{t(
|
||||
"editInternalResourceDialogDestinationCidrDescription"
|
||||
)}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
<SettingsSectionForm variant="half">
|
||||
<SettingsFormGrid>
|
||||
<SettingsFormCell span="half">
|
||||
<PrivateResourceSitesField
|
||||
control={form.control}
|
||||
orgId={orgId}
|
||||
selectedSites={selectedSites}
|
||||
onSelectedSitesChange={
|
||||
setSelectedSites
|
||||
}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
<SettingsFormCell span="half">
|
||||
<PrivateResourceCidrDestinationField
|
||||
control={asAnyControl(
|
||||
form.control
|
||||
)}
|
||||
labelPrefix="create"
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
<SettingsFormCell span="full">
|
||||
<PrivateResourcePortRanges
|
||||
control={asAnyControl(
|
||||
form.control
|
||||
)}
|
||||
setValue={asAnySetValue(
|
||||
form.setValue
|
||||
)}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
</SettingsFormGrid>
|
||||
</SettingsSectionForm>
|
||||
</SettingsSectionBody>
|
||||
</SettingsSection>
|
||||
)}
|
||||
|
||||
{/* HTTP configuration */}
|
||||
{mode === "http" && (
|
||||
<SettingsSection>
|
||||
<PaidFeaturesAlert
|
||||
tiers={tierMatrix.advancedPrivateResources}
|
||||
/>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
{t("httpSettings")}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
{t(
|
||||
"editInternalResourceDialogHttpConfigurationDescription"
|
||||
)}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<fieldset
|
||||
disabled={httpSectionDisabled}
|
||||
className={
|
||||
httpSectionDisabled
|
||||
? "opacity-50 pointer-events-none"
|
||||
: ""
|
||||
}
|
||||
>
|
||||
<SettingsSectionBody>
|
||||
<SettingsSectionForm variant="half">
|
||||
<SettingsFormGrid>
|
||||
<SettingsFormCell span="half">
|
||||
<PrivateResourceSitesField
|
||||
control={form.control}
|
||||
orgId={orgId}
|
||||
selectedSites={
|
||||
selectedSites
|
||||
}
|
||||
onSelectedSitesChange={
|
||||
setSelectedSites
|
||||
}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
<SettingsFormCell span="full">
|
||||
<PrivateResourceHttpFields
|
||||
control={asAnyControl(
|
||||
form.control
|
||||
)}
|
||||
setValue={asAnySetValue(
|
||||
form.setValue
|
||||
)}
|
||||
orgId={orgId}
|
||||
watch={asAnyWatch(
|
||||
form.watch
|
||||
)}
|
||||
disabled={
|
||||
httpSectionDisabled
|
||||
}
|
||||
labelPrefix="create"
|
||||
hideDomainPicker
|
||||
hidePaidFeaturesAlert
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
</SettingsFormGrid>
|
||||
</SettingsSectionForm>
|
||||
</SettingsSectionBody>
|
||||
</fieldset>
|
||||
</SettingsSection>
|
||||
)}
|
||||
|
||||
{/* SSH server */}
|
||||
{mode === "ssh" && (
|
||||
<SettingsSection>
|
||||
<PaidFeaturesAlert
|
||||
tiers={tierMatrix.advancedPrivateResources}
|
||||
/>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
{t("sshServer")}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
{t("sshServerDescription")}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<fieldset
|
||||
disabled={sshSectionDisabled}
|
||||
className={
|
||||
sshSectionDisabled
|
||||
? "opacity-50 pointer-events-none"
|
||||
: ""
|
||||
}
|
||||
>
|
||||
<SettingsSectionBody>
|
||||
<SettingsSectionForm variant="half">
|
||||
<PrivateResourceSshFields
|
||||
control={asAnyControl(form.control)}
|
||||
setValue={asAnySetValue(
|
||||
form.setValue
|
||||
)}
|
||||
watch={asAnyWatch(form.watch)}
|
||||
orgId={orgId}
|
||||
disabled={sshSectionDisabled}
|
||||
selectedSites={selectedSites}
|
||||
onSelectedSitesChange={
|
||||
setSelectedSites
|
||||
}
|
||||
labelPrefix="create"
|
||||
showSshSettings={true}
|
||||
layout="wizard"
|
||||
showPaidFeaturesAlert={false}
|
||||
hideAlias
|
||||
/>
|
||||
</SettingsSectionForm>
|
||||
</SettingsSectionBody>
|
||||
</fieldset>
|
||||
</SettingsSection>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end space-x-2 mt-8">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
router.push(
|
||||
`/${orgId}/settings/resources/private`
|
||||
)
|
||||
}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{t("createInternalResourceDialogCancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
form="create-private-resource-form"
|
||||
disabled={submitDisabled}
|
||||
loading={isSubmitting}
|
||||
>
|
||||
{t("createInternalResourceDialogCreateResource")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import type {
|
||||
Control,
|
||||
FieldValues,
|
||||
UseFormSetValue,
|
||||
UseFormWatch
|
||||
} from "react-hook-form";
|
||||
|
||||
export function asAnyControl<T extends FieldValues>(
|
||||
control: Control<T>
|
||||
): Control<any> {
|
||||
return control as Control<any>;
|
||||
}
|
||||
|
||||
export function asAnySetValue<T extends FieldValues>(
|
||||
setValue: UseFormSetValue<T>
|
||||
): UseFormSetValue<any> {
|
||||
return setValue as UseFormSetValue<any>;
|
||||
}
|
||||
|
||||
export function asAnyWatch<T extends FieldValues>(
|
||||
watch: UseFormWatch<T>
|
||||
): UseFormWatch<any> {
|
||||
return watch as UseFormWatch<any>;
|
||||
}
|
||||
@@ -84,6 +84,7 @@ export default async function ClientResourcesPage(
|
||||
aliasAddress: siteResource.aliasAddress || null,
|
||||
siteNiceIds: siteResource.siteNiceIds,
|
||||
niceId: siteResource.niceId,
|
||||
enabled: siteResource.enabled,
|
||||
tcpPortRangeString: siteResource.tcpPortRangeString || null,
|
||||
udpPortRangeString: siteResource.udpPortRangeString || null,
|
||||
disableIcmp: siteResource.disableIcmp || false,
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
"use client";
|
||||
|
||||
import type { Selectedsite } from "@app/components/site-selector";
|
||||
import type { SiteResourceData } from "@app/lib/privateResourceForm";
|
||||
|
||||
export function buildSelectedSitesForResource(
|
||||
resource: Pick<SiteResourceData, "siteIds" | "siteNames">
|
||||
): Selectedsite[] {
|
||||
return resource.siteIds.map((siteId, idx) => ({
|
||||
name: resource.siteNames[idx] ?? "",
|
||||
siteId,
|
||||
type: "newt" as const
|
||||
}));
|
||||
}
|
||||
|
||||
export function getSshSingleSiteMode(
|
||||
authDaemonMode?: string | null,
|
||||
pamMode?: string | null
|
||||
): boolean {
|
||||
return (
|
||||
authDaemonMode === "native" ||
|
||||
(pamMode === "push" && authDaemonMode === "site")
|
||||
);
|
||||
}
|
||||
|
||||
export function getSshUseMultiSiteTargetForm(
|
||||
isNative: boolean,
|
||||
authDaemonMode?: string | null,
|
||||
pamMode?: string | null
|
||||
): boolean {
|
||||
if (isNative) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return authDaemonMode !== "site" || pamMode === "passthrough";
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
"use client";
|
||||
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { useSiteResourceContext } from "@app/hooks/useSiteResourceContext";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||
import { getPrivateResourceSettingsHref } from "@app/lib/launcherResourceAdminHref";
|
||||
import {
|
||||
buildUpdateSiteResourcePayload,
|
||||
mergeFormValuesWithResource,
|
||||
type PrivateResourceFormValues
|
||||
} from "@app/lib/privateResourceForm";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
export function useSaveSiteResource() {
|
||||
const t = useTranslations();
|
||||
const router = useRouter();
|
||||
const { env } = useEnvContext();
|
||||
const api = createApiClient({ env });
|
||||
const { siteResource, updateSiteResource, access } =
|
||||
useSiteResourceContext();
|
||||
|
||||
async function save(
|
||||
partial: Partial<PrivateResourceFormValues>,
|
||||
options?: { successMessage?: string }
|
||||
) {
|
||||
const merged = mergeFormValuesWithResource(siteResource, partial);
|
||||
const isNativeSsh =
|
||||
merged.mode === "ssh" && merged.authDaemonMode === "native";
|
||||
const trimmedDestination = merged.destination?.trim();
|
||||
|
||||
const payload = buildUpdateSiteResourcePayload(
|
||||
{
|
||||
...merged,
|
||||
destination: isNativeSsh
|
||||
? null
|
||||
: trimmedDestination && trimmedDestination.length > 0
|
||||
? trimmedDestination
|
||||
: null
|
||||
},
|
||||
access
|
||||
);
|
||||
|
||||
try {
|
||||
await api.post(`/site-resource/${siteResource.id}`, payload);
|
||||
|
||||
updateSiteResource({
|
||||
name: merged.name,
|
||||
niceId: merged.niceId ?? siteResource.niceId,
|
||||
enabled: merged.enabled ?? siteResource.enabled,
|
||||
siteIds: merged.siteIds,
|
||||
mode: merged.mode,
|
||||
destination: merged.destination ?? null,
|
||||
alias: merged.alias ?? null,
|
||||
destinationPort: merged.destinationPort ?? null,
|
||||
scheme: merged.scheme ?? siteResource.scheme,
|
||||
ssl: merged.ssl ?? siteResource.ssl,
|
||||
subdomain: merged.httpConfigSubdomain ?? null,
|
||||
domainId: merged.httpConfigDomainId ?? null,
|
||||
fullDomain: merged.httpConfigFullDomain ?? null,
|
||||
tcpPortRangeString: merged.tcpPortRangeString ?? null,
|
||||
udpPortRangeString: merged.udpPortRangeString ?? null,
|
||||
disableIcmp: merged.disableIcmp ?? false,
|
||||
authDaemonMode: merged.authDaemonMode ?? null,
|
||||
authDaemonPort: merged.authDaemonPort ?? null,
|
||||
pamMode: merged.pamMode ?? null
|
||||
});
|
||||
|
||||
toast({
|
||||
title: t("editInternalResourceDialogSuccess"),
|
||||
description:
|
||||
options?.successMessage ??
|
||||
t(
|
||||
"editInternalResourceDialogInternalResourceUpdatedSuccessfully"
|
||||
)
|
||||
});
|
||||
|
||||
if (merged.niceId && merged.niceId !== siteResource.niceId) {
|
||||
router.replace(
|
||||
getPrivateResourceSettingsHref(
|
||||
siteResource.orgId,
|
||||
merged.niceId
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
router.refresh();
|
||||
return true;
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: t("editInternalResourceDialogError"),
|
||||
description: formatAxiosError(
|
||||
error,
|
||||
t(
|
||||
"editInternalResourceDialogFailedToUpdateInternalResource"
|
||||
)
|
||||
),
|
||||
variant: "destructive"
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return { save, siteResource, access };
|
||||
}
|
||||
@@ -455,7 +455,7 @@ export default function GeneralForm() {
|
||||
)}
|
||||
{ !["tcp", "udp"].includes(
|
||||
resource.mode
|
||||
) && (
|
||||
) && !env.flags.disableEnterpriseFeatures && (
|
||||
<>
|
||||
<SettingsFormCell span="full">
|
||||
<SettingsSubsectionHeader>
|
||||
|
||||
@@ -41,7 +41,7 @@ import {
|
||||
import { AxiosResponse } from "axios";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useActionState } from "react";
|
||||
import { useActionState, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
|
||||
@@ -137,11 +137,21 @@ function ProxyResourceHttpForm({
|
||||
});
|
||||
|
||||
const [, formAction, saveLoading] = useActionState(onSubmit, null);
|
||||
const [headersValid, setHeadersValid] = useState(true);
|
||||
|
||||
async function onSubmit() {
|
||||
const isValid = await form.trigger();
|
||||
if (!isValid) return;
|
||||
|
||||
if (!headersValid) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("settingsErrorUpdate"),
|
||||
description: t("headersValidationError")
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const data = form.getValues();
|
||||
|
||||
const res = await api
|
||||
@@ -318,6 +328,9 @@ function ProxyResourceHttpForm({
|
||||
onChange={
|
||||
field.onChange
|
||||
}
|
||||
onValidityChange={
|
||||
setHeadersValid
|
||||
}
|
||||
rows={4}
|
||||
/>
|
||||
</FormControl>
|
||||
@@ -341,7 +354,7 @@ function ProxyResourceHttpForm({
|
||||
<Button
|
||||
type="submit"
|
||||
loading={saveLoading}
|
||||
disabled={saveLoading}
|
||||
disabled={saveLoading || !headersValid}
|
||||
form="http-settings-form"
|
||||
>
|
||||
{t("saveSettings")}
|
||||
|
||||
@@ -14,14 +14,13 @@ import {
|
||||
SettingsSubsectionHeader,
|
||||
SettingsSubsectionTitle
|
||||
} from "@app/components/Settings";
|
||||
import { StrategySelect, StrategyOption } from "@app/components/StrategySelect";
|
||||
import { SshServerSettingsFields } from "@app/components/SshServerSettingsFields";
|
||||
import { BrowserGatewayTargetForm } from "@app/components/BrowserGatewayTargetForm";
|
||||
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
|
||||
import { SitesSelector } from "@app/components/site-selector";
|
||||
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
||||
import { tierMatrix, TierFeature } from "@server/lib/billing/tierMatrix";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { Input } from "@app/components/ui/input";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
@@ -35,8 +34,7 @@ import {
|
||||
PopoverContent,
|
||||
PopoverTrigger
|
||||
} from "@app/components/ui/popover";
|
||||
import { ChevronsUpDown, ExternalLink } from "lucide-react";
|
||||
import { Badge } from "@app/components/ui/badge";
|
||||
import { ChevronsUpDown } from "lucide-react";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { useResourceContext } from "@app/hooks/useResourceContext";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
@@ -223,6 +221,7 @@ function SshServerForm({
|
||||
|
||||
const pamMode = form.watch("pamMode");
|
||||
const standardDaemonLocation = form.watch("standardDaemonLocation");
|
||||
const authDaemonPort = form.watch("authDaemonPort");
|
||||
const selectedNativeSite = form.watch("selectedNativeSite");
|
||||
|
||||
async function save() {
|
||||
@@ -364,35 +363,6 @@ function SshServerForm({
|
||||
}
|
||||
}
|
||||
|
||||
const authMethodOptions: StrategyOption<"passthrough" | "push">[] = [
|
||||
{
|
||||
id: "passthrough",
|
||||
title: t("sshAuthMethodManual"),
|
||||
description: t("sshAuthMethodManualDescription")
|
||||
},
|
||||
{
|
||||
id: "push",
|
||||
title: t("sshAuthMethodAutomated"),
|
||||
description: t("sshAuthMethodAutomatedDescription")
|
||||
}
|
||||
];
|
||||
|
||||
const daemonLocationOptions: StrategyOption<"site" | "remote">[] = [
|
||||
{
|
||||
id: "site",
|
||||
title: t("internalResourceAuthDaemonSite"),
|
||||
description: t("sshDaemonLocationSiteDescription")
|
||||
},
|
||||
{
|
||||
id: "remote",
|
||||
title: t("sshDaemonLocationRemote"),
|
||||
description: t("sshDaemonLocationRemoteDescription")
|
||||
}
|
||||
];
|
||||
|
||||
const showDaemonLocation = !isNative && pamMode === "push";
|
||||
const showDaemonPort =
|
||||
!isNative && pamMode === "push" && standardDaemonLocation === "remote";
|
||||
const useMultiSiteTargetForm =
|
||||
!isNative &&
|
||||
(standardDaemonLocation !== "site" || pamMode === "passthrough");
|
||||
@@ -413,97 +383,37 @@ function SshServerForm({
|
||||
<SettingsSectionBody>
|
||||
<SettingsSectionForm variant="half">
|
||||
<SettingsFormGrid>
|
||||
<SettingsFormCell span="full">
|
||||
<div className="space-y-2">
|
||||
<p className="font-semibold text-sm">
|
||||
{t("sshServerMode")}
|
||||
</p>
|
||||
<Badge variant="secondary">
|
||||
{sshServerMode == "standard"
|
||||
? t("sshServerModeStandard")
|
||||
: t("sshServerModePangolin")}
|
||||
</Badge>
|
||||
</div>
|
||||
</SettingsFormCell>
|
||||
|
||||
<SettingsFormCell span="full">
|
||||
<div className="space-y-2">
|
||||
<p className="font-semibold text-sm">
|
||||
{t("sshAuthenticationMethod")}
|
||||
</p>
|
||||
<StrategySelect<"passthrough" | "push">
|
||||
value={pamMode}
|
||||
options={authMethodOptions}
|
||||
onChange={(value) =>
|
||||
form.setValue("pamMode", value, {
|
||||
shouldValidate: true
|
||||
})
|
||||
}
|
||||
cols={2}
|
||||
/>
|
||||
</div>
|
||||
</SettingsFormCell>
|
||||
|
||||
{showDaemonLocation && (
|
||||
<SettingsFormCell span="full">
|
||||
<div className="space-y-2">
|
||||
<p className="font-semibold text-sm">
|
||||
{t("sshAuthDaemonLocation")}
|
||||
</p>
|
||||
<StrategySelect<"site" | "remote">
|
||||
value={standardDaemonLocation}
|
||||
options={daemonLocationOptions}
|
||||
onChange={(value) =>
|
||||
form.setValue(
|
||||
"standardDaemonLocation",
|
||||
value,
|
||||
{
|
||||
shouldValidate: true
|
||||
}
|
||||
)
|
||||
}
|
||||
cols={2}
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("sshDaemonDisclaimer")}{" "}
|
||||
<a
|
||||
href="https://docs.pangolin.net/manage/ssh"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline inline-flex items-center gap-1"
|
||||
>
|
||||
{t("learnMore")}
|
||||
<ExternalLink className="size-3.5 shrink-0" />
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</SettingsFormCell>
|
||||
)}
|
||||
|
||||
{showDaemonPort && (
|
||||
<SettingsFormCell span="half">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="authDaemonPort"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("sshDaemonPort")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={65535}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
)}
|
||||
<SshServerSettingsFields
|
||||
idPrefix="public-ssh-edit"
|
||||
pamMode={pamMode}
|
||||
standardDaemonLocation={
|
||||
standardDaemonLocation
|
||||
}
|
||||
authDaemonPort={authDaemonPort}
|
||||
onPamModeChange={(value) =>
|
||||
form.setValue("pamMode", value, {
|
||||
shouldValidate: true
|
||||
})
|
||||
}
|
||||
onStandardDaemonLocationChange={(value) =>
|
||||
form.setValue(
|
||||
"standardDaemonLocation",
|
||||
value,
|
||||
{ shouldValidate: true }
|
||||
)
|
||||
}
|
||||
onAuthDaemonPortChange={(value) =>
|
||||
form.setValue("authDaemonPort", value, {
|
||||
shouldValidate: true
|
||||
})
|
||||
}
|
||||
authDaemonPortError={
|
||||
form.formState.errors.authDaemonPort
|
||||
?.message
|
||||
}
|
||||
sshServerMode={sshServerMode}
|
||||
serverModeDisplay="badge"
|
||||
/>
|
||||
|
||||
<SettingsFormCell span="full">
|
||||
<SettingsSubsectionHeader>
|
||||
|
||||
@@ -777,8 +777,8 @@ export default function Page() {
|
||||
<>
|
||||
<div className="flex justify-between">
|
||||
<HeaderTitle
|
||||
title={t("resourceCreate")}
|
||||
description={t("resourceCreateDescription")}
|
||||
title={t("resourcePublicCreate")}
|
||||
description={t("resourcePublicCreateDescription")}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
|
||||
@@ -253,85 +253,87 @@ export default function GeneralPage() {
|
||||
<PaidFeaturesAlert
|
||||
tiers={tierMatrix.newtAutoUpdate}
|
||||
/>
|
||||
{site && site.type === "newt" && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="autoUpdateEnabled"
|
||||
render={({ field }) => {
|
||||
const isOverriding = form.watch(
|
||||
"autoUpdateOverrideOrg"
|
||||
);
|
||||
return (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<div className="">
|
||||
<SwitchInput
|
||||
id="auto-update-enabled"
|
||||
label={t(
|
||||
"siteAutoUpdateLabel"
|
||||
)}
|
||||
checked={
|
||||
field.value
|
||||
}
|
||||
onCheckedChange={(
|
||||
checked
|
||||
) => {
|
||||
field.onChange(
|
||||
{site &&
|
||||
site.type === "newt" &&
|
||||
!env.flags.disableEnterpriseFeatures && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="autoUpdateEnabled"
|
||||
render={({ field }) => {
|
||||
const isOverriding = form.watch(
|
||||
"autoUpdateOverrideOrg"
|
||||
);
|
||||
return (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<div className="">
|
||||
<SwitchInput
|
||||
id="auto-update-enabled"
|
||||
label={t(
|
||||
"siteAutoUpdateLabel"
|
||||
)}
|
||||
checked={
|
||||
field.value
|
||||
}
|
||||
onCheckedChange={(
|
||||
checked
|
||||
);
|
||||
form.setValue(
|
||||
"autoUpdateOverrideOrg",
|
||||
true
|
||||
);
|
||||
}}
|
||||
disabled={
|
||||
!hasAutoUpdateFeature
|
||||
}
|
||||
/>
|
||||
{isOverriding && (
|
||||
<ButtonUI
|
||||
type="button"
|
||||
variant="link"
|
||||
size="sm"
|
||||
className="text-sm text-muted-foreground px-0"
|
||||
onClick={() => {
|
||||
) => {
|
||||
field.onChange(
|
||||
checked
|
||||
);
|
||||
form.setValue(
|
||||
"autoUpdateOverrideOrg",
|
||||
false
|
||||
);
|
||||
form.setValue(
|
||||
"autoUpdateEnabled",
|
||||
orgAutoUpdate
|
||||
true
|
||||
);
|
||||
}}
|
||||
>
|
||||
{t(
|
||||
"siteAutoUpdateResetToOrg"
|
||||
)}
|
||||
</ButtonUI>
|
||||
)}
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"siteAutoUpdateDescription"
|
||||
)}{" "}
|
||||
<a
|
||||
href="https://docs.pangolin.net/manage/sites/auto-update"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline inline-flex items-center gap-1"
|
||||
>
|
||||
{t("learnMore")}
|
||||
<ExternalLink className="size-3.5 shrink-0" />
|
||||
</a>
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
disabled={
|
||||
!hasAutoUpdateFeature
|
||||
}
|
||||
/>
|
||||
{isOverriding && (
|
||||
<ButtonUI
|
||||
type="button"
|
||||
variant="link"
|
||||
size="sm"
|
||||
className="text-sm text-muted-foreground px-0"
|
||||
onClick={() => {
|
||||
form.setValue(
|
||||
"autoUpdateOverrideOrg",
|
||||
false
|
||||
);
|
||||
form.setValue(
|
||||
"autoUpdateEnabled",
|
||||
orgAutoUpdate
|
||||
);
|
||||
}}
|
||||
>
|
||||
{t(
|
||||
"siteAutoUpdateResetToOrg"
|
||||
)}
|
||||
</ButtonUI>
|
||||
)}
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"siteAutoUpdateDescription"
|
||||
)}{" "}
|
||||
<a
|
||||
href="https://docs.pangolin.net/manage/sites/auto-update"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline inline-flex items-center gap-1"
|
||||
>
|
||||
{t("learnMore")}
|
||||
<ExternalLink className="size-3.5 shrink-0" />
|
||||
</a>
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</form>
|
||||
</Form>
|
||||
</SettingsSectionForm>
|
||||
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
} from "@app/components/ui/form";
|
||||
import HeaderTitle from "@app/components/SettingsSectionTitle";
|
||||
import { z } from "zod";
|
||||
import { createElement, useEffect, useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Input } from "@app/components/ui/input";
|
||||
@@ -37,15 +37,6 @@ import {
|
||||
InfoSections,
|
||||
InfoSectionTitle
|
||||
} from "@app/components/InfoSection";
|
||||
import {
|
||||
FaApple,
|
||||
FaCubes,
|
||||
FaDocker,
|
||||
FaFreebsd,
|
||||
FaWindows
|
||||
} from "react-icons/fa";
|
||||
import { SiNixos, SiKubernetes } from "react-icons/si";
|
||||
import { Checkbox, CheckboxWithLabel } from "@app/components/ui/checkbox";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
|
||||
import { generateKeypair } from "../[niceId]/wireguardConfig";
|
||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||
@@ -570,7 +561,7 @@ export default function Page() {
|
||||
</Button>
|
||||
</SettingsFormCell>
|
||||
{showAdvancedSettings && (
|
||||
<SettingsFormCell span="quarter">
|
||||
<SettingsFormCell span="half">
|
||||
<FormField
|
||||
control={
|
||||
form.control
|
||||
|
||||
@@ -13,6 +13,7 @@ import { Layout } from "@app/components/Layout";
|
||||
import { adminNavSections } from "../navigation";
|
||||
import { pullEnv } from "@app/lib/pullEnv";
|
||||
import SubscriptionStatusProvider from "@app/providers/SubscriptionStatusProvider";
|
||||
import { build } from "@server/build";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -29,6 +30,11 @@ export default async function AdminLayout(props: LayoutProps) {
|
||||
const getUser = cache(verifySession);
|
||||
const user = await getUser();
|
||||
|
||||
// Disable the admin page on saas
|
||||
if (build == "saas") {
|
||||
redirect(`/`);
|
||||
}
|
||||
|
||||
const env = pullEnv();
|
||||
|
||||
if (!user || !user.serverAdmin) {
|
||||
|
||||
@@ -42,7 +42,14 @@ import {
|
||||
SettingsSectionFooter
|
||||
} from "@app/components/Settings";
|
||||
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||
import { ArrowRight, Check, ExternalLink, Heart, InfoIcon, TicketCheck } from "lucide-react";
|
||||
import {
|
||||
ArrowRight,
|
||||
Check,
|
||||
ExternalLink,
|
||||
Heart,
|
||||
InfoIcon,
|
||||
TicketCheck
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import DismissableBanner from "@app/components/DismissableBanner";
|
||||
import CopyTextBox from "@app/components/CopyTextBox";
|
||||
@@ -50,7 +57,7 @@ import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
|
||||
import { SitePriceCalculator } from "@app/components/SitePriceCalculator";
|
||||
import { Checkbox } from "@app/components/ui/checkbox";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
|
||||
import { useSupporterStatusContext } from "@app/hooks/useSupporterStatusContext";
|
||||
// import { useSupporterStatusContext } from "@app/hooks/useSupporterStatusContext";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
const ENTERPRISE_DOCS_URL =
|
||||
@@ -82,7 +89,7 @@ export default function LicensePage() {
|
||||
const [isActivatingLicense, setIsActivatingLicense] = useState(false);
|
||||
const [isDeletingLicense, setIsDeletingLicense] = useState(false);
|
||||
const [isRecheckingLicense, setIsRecheckingLicense] = useState(false);
|
||||
const { supporterStatus } = useSupporterStatusContext();
|
||||
// const { supporterStatus } = useSupporterStatusContext();
|
||||
|
||||
const t = useTranslations();
|
||||
|
||||
@@ -347,9 +354,7 @@ export default function LicensePage() {
|
||||
storageKey="license-banner-dismissed"
|
||||
version={1}
|
||||
title={t("licenseBannerTitle")}
|
||||
titleIcon={
|
||||
<TicketCheck className="w-5 h-5 text-primary" />
|
||||
}
|
||||
titleIcon={<TicketCheck className="w-5 h-5 text-primary" />}
|
||||
description={t("licenseBannerDescription")}
|
||||
>
|
||||
<Link
|
||||
|
||||
+19
-19
@@ -68,15 +68,15 @@ export default async function RootLayout({
|
||||
const env = pullEnv();
|
||||
const locale = await getLocale();
|
||||
|
||||
const supporterData = {
|
||||
visible: true
|
||||
} as any;
|
||||
// const supporterData = {
|
||||
// visible: true
|
||||
// } as any;
|
||||
|
||||
const res = await priv.get<AxiosResponse<IsSupporterKeyVisibleResponse>>(
|
||||
"supporter-key/visible"
|
||||
);
|
||||
supporterData.visible = res.data.data.visible;
|
||||
supporterData.tier = res.data.data.tier;
|
||||
// const res = await priv.get<AxiosResponse<IsSupporterKeyVisibleResponse>>(
|
||||
// "supporter-key/visible"
|
||||
// );
|
||||
// supporterData.visible = res.data.data.visible;
|
||||
// supporterData.tier = res.data.data.tier;
|
||||
|
||||
let licenseStatus: GetLicenseStatusResponse;
|
||||
if (build === "enterprise") {
|
||||
@@ -127,20 +127,20 @@ export default async function RootLayout({
|
||||
<LicenseStatusProvider
|
||||
licenseStatus={licenseStatus}
|
||||
>
|
||||
<SupportStatusProvider
|
||||
{/* <SupportStatusProvider
|
||||
supporterStatus={supporterData}
|
||||
>
|
||||
{/* Main content */}
|
||||
<div className="h-full flex flex-col">
|
||||
<div className="flex-1 overflow-auto">
|
||||
<SplashImage>
|
||||
<LicenseViolation />
|
||||
{children}
|
||||
</SplashImage>
|
||||
> */}
|
||||
{/* Main content */}
|
||||
<div className="h-full flex flex-col">
|
||||
<div className="flex-1 overflow-auto">
|
||||
<SplashImage>
|
||||
<LicenseViolation />
|
||||
</div>
|
||||
{children}
|
||||
</SplashImage>
|
||||
<LicenseViolation />
|
||||
</div>
|
||||
</SupportStatusProvider>
|
||||
</div>
|
||||
{/* </SupportStatusProvider> */}
|
||||
</LicenseStatusProvider>
|
||||
<Toaster />
|
||||
</TanstackQueryProvider>
|
||||
|
||||
@@ -28,7 +28,7 @@ export default async function MaintenanceScreen() {
|
||||
|
||||
try {
|
||||
const headersList = await headers();
|
||||
const host = headersList.get("host") || "";
|
||||
const host = headersList.get("p-host") || headersList.get("host") || "";
|
||||
const hostname = host.split(":")[0];
|
||||
|
||||
const res = await priv.get<AxiosResponse<GetMaintenanceInfoResponse>>(
|
||||
|
||||
@@ -156,10 +156,11 @@ export const orgNavSections = (
|
||||
]
|
||||
: []),
|
||||
// PaidFeaturesAlert
|
||||
...((build === "oss" && !env?.flags.disableEnterpriseFeatures) ||
|
||||
build === "saas" ||
|
||||
env?.app.identityProviderMode === "org" ||
|
||||
(env?.app.identityProviderMode === undefined && build !== "oss")
|
||||
...(!env?.flags.disableEnterpriseFeatures &&
|
||||
(build === "saas" ||
|
||||
env?.app.identityProviderMode === "org" ||
|
||||
(env?.app.identityProviderMode === undefined &&
|
||||
build !== "oss"))
|
||||
? [
|
||||
{
|
||||
title: "sidebarIdentityProviders",
|
||||
|
||||
+9
-1
@@ -109,7 +109,15 @@ export default async function Page(props: {
|
||||
}
|
||||
|
||||
if (targetOrgId && !showOrgPicker) {
|
||||
return <RedirectToOrg targetOrgId={targetOrgId} />;
|
||||
const targetOrg = orgs.find((org) => org.orgId === targetOrgId);
|
||||
return (
|
||||
<RedirectToOrg
|
||||
targetOrgId={targetOrgId}
|
||||
isAdminOrOwner={Boolean(
|
||||
targetOrg?.isAdmin || targetOrg?.isOwner
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -42,6 +42,8 @@ import {
|
||||
loadEncryptedLocalStorage,
|
||||
saveEncryptedLocalStorage
|
||||
} from "@app/lib/secureLocalStorage";
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
|
||||
declare module "react" {
|
||||
namespace JSX {
|
||||
@@ -96,6 +98,7 @@ export default function RdpClient({
|
||||
primaryColor?: string | null;
|
||||
}) {
|
||||
const t = useTranslations();
|
||||
const api = createApiClient(useEnvContext());
|
||||
const STORAGE_KEY = "pangolin_rdp_credentials";
|
||||
const resourceName = target?.name?.trim() || null;
|
||||
|
||||
@@ -311,6 +314,11 @@ export default function RdpClient({
|
||||
values,
|
||||
target.authToken
|
||||
);
|
||||
void api.post(`/org/${target.orgId}/logs/access/attempt`, {
|
||||
resourceId: target.resourceId,
|
||||
action: true,
|
||||
type: "rdp"
|
||||
});
|
||||
setConnecting(false);
|
||||
setShowLogin(false);
|
||||
userInteraction.setVisibility(true);
|
||||
@@ -320,6 +328,11 @@ export default function RdpClient({
|
||||
fileTransferRef.current = null;
|
||||
setShowLogin(true);
|
||||
} catch (err) {
|
||||
void api.post(`/org/${target.orgId}/logs/access/attempt`, {
|
||||
resourceId: target.resourceId,
|
||||
action: false,
|
||||
type: "rdp"
|
||||
});
|
||||
setConnecting(false);
|
||||
setShowLogin(true);
|
||||
if (isIronError(err)) {
|
||||
|
||||
@@ -36,6 +36,8 @@ import {
|
||||
loadEncryptedLocalStorage,
|
||||
saveEncryptedLocalStorage
|
||||
} from "@app/lib/secureLocalStorage";
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
|
||||
type AuthTab = "password" | "privateKey";
|
||||
|
||||
@@ -73,6 +75,7 @@ export default function SshClient({
|
||||
}) {
|
||||
const STORAGE_KEY = "pangolin_ssh_credentials";
|
||||
const t = useTranslations();
|
||||
const api = createApiClient(useEnvContext());
|
||||
const resourceName = target?.name?.trim() || null;
|
||||
|
||||
const passwordTabSchema = z.object({
|
||||
@@ -263,6 +266,17 @@ export default function SshClient({
|
||||
let authConfirmed = false;
|
||||
let authErrorShown = false;
|
||||
let socketOpened = false;
|
||||
let auditLogged = false;
|
||||
|
||||
const logAudit = (action: boolean) => {
|
||||
if (auditLogged || !target) return;
|
||||
auditLogged = true;
|
||||
void api.post(`/org/${target.orgId}/logs/access/attempt`, {
|
||||
resourceId: target.resourceId,
|
||||
action,
|
||||
type: "ssh"
|
||||
});
|
||||
};
|
||||
|
||||
ws.onopen = () => {
|
||||
socketOpened = true;
|
||||
@@ -294,6 +308,7 @@ export default function SshClient({
|
||||
if (msg.type === "data" && msg.data) {
|
||||
if (!authConfirmed) {
|
||||
authConfirmed = true;
|
||||
logAudit(true);
|
||||
setConnecting(false);
|
||||
setConnected(true);
|
||||
}
|
||||
@@ -301,6 +316,7 @@ export default function SshClient({
|
||||
} else if (msg.type === "error") {
|
||||
if (!authConfirmed) {
|
||||
authErrorShown = true;
|
||||
logAudit(false);
|
||||
setConnecting(false);
|
||||
setConnectError(
|
||||
msg.error ?? t("sshErrorAuthFailed")
|
||||
@@ -323,6 +339,7 @@ export default function SshClient({
|
||||
evt.data.text().then((text) => {
|
||||
if (!authConfirmed) {
|
||||
authConfirmed = true;
|
||||
logAudit(true);
|
||||
setConnecting(false);
|
||||
setConnected(true);
|
||||
}
|
||||
@@ -332,6 +349,7 @@ export default function SshClient({
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
logAudit(false);
|
||||
setConnecting(false);
|
||||
setConnected(false);
|
||||
setConnectError(t("sshErrorWebSocket"));
|
||||
@@ -355,6 +373,7 @@ export default function SshClient({
|
||||
);
|
||||
}
|
||||
if (!authConfirmed && !authErrorShown) {
|
||||
logAudit(false);
|
||||
setConnectError(t("sshErrorConnectionClosed"));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -3,7 +3,7 @@ import { priv } from "@app/lib/api";
|
||||
import { generateBrowserGatewayMetadata } from "@app/lib/browserGatewayMetadata";
|
||||
import { getBrowserTargetForRequest } from "@app/lib/getBrowserTargetForRequest";
|
||||
import { loadOrgLoginPageBranding } from "@app/lib/loadOrgLoginPageBranding";
|
||||
import { AxiosResponse } from "axios";
|
||||
import axios, { AxiosResponse } from "axios";
|
||||
import { GetBrowserTargetResponse } from "@server/routers/browserGatewayTarget";
|
||||
import SshClient from "./SshClient";
|
||||
import crypto from "crypto";
|
||||
@@ -152,8 +152,12 @@ export default async function SshPage() {
|
||||
await waitForRoundTripCompletion(messageIds, cookieHeader);
|
||||
} catch (err) {
|
||||
console.error("Error signing SSH key:", err);
|
||||
const detail = err instanceof Error ? err.message : String(err);
|
||||
error = `${t("sshErrorSignKeyFailed")}: ${detail}`;
|
||||
if (axios.isAxiosError(err) && err.response?.status === 403) {
|
||||
error = t("accessDeniedDescription");
|
||||
} else {
|
||||
const detail = err instanceof Error ? err.message : String(err);
|
||||
error = `${t("sshErrorSignKeyFailed")}: ${detail}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -33,12 +33,16 @@ import {
|
||||
loadEncryptedLocalStorage,
|
||||
saveEncryptedLocalStorage
|
||||
} from "@app/lib/secureLocalStorage";
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
|
||||
type VncCredentialsForm = {
|
||||
username: string;
|
||||
password: string;
|
||||
};
|
||||
|
||||
const DEFAULT_VNC_CREDENTIALS: VncCredentialsForm = {
|
||||
username: "",
|
||||
password: ""
|
||||
};
|
||||
|
||||
@@ -52,10 +56,12 @@ export default function VncClient({
|
||||
primaryColor?: string | null;
|
||||
}) {
|
||||
const t = useTranslations();
|
||||
const api = createApiClient(useEnvContext());
|
||||
const STORAGE_KEY = "pangolin_vnc_credentials";
|
||||
const resourceName = target?.name?.trim() || null;
|
||||
|
||||
const formSchema = z.object({
|
||||
username: z.string(),
|
||||
password: z.string()
|
||||
});
|
||||
|
||||
@@ -165,8 +171,11 @@ export default function VncClient({
|
||||
screenRef.current.innerHTML = "";
|
||||
|
||||
const options: Record<string, unknown> = {};
|
||||
if (values.password) {
|
||||
options.credentials = { password: values.password };
|
||||
if (values.username || values.password) {
|
||||
options.credentials = {
|
||||
username: values.username,
|
||||
password: values.password
|
||||
};
|
||||
}
|
||||
|
||||
let rfb: any;
|
||||
@@ -179,6 +188,7 @@ export default function VncClient({
|
||||
}
|
||||
|
||||
let authConfirmed = false;
|
||||
let auditLogged = false;
|
||||
|
||||
rfb.scaleViewport = true;
|
||||
rfb.resizeSession = true;
|
||||
@@ -190,6 +200,12 @@ export default function VncClient({
|
||||
target.authToken
|
||||
);
|
||||
authConfirmed = true;
|
||||
auditLogged = true;
|
||||
void api.post(`/org/${target.orgId}/logs/access/attempt`, {
|
||||
resourceId: target.resourceId,
|
||||
action: true,
|
||||
type: "vnc"
|
||||
});
|
||||
setConnecting(false);
|
||||
setConnected(true);
|
||||
});
|
||||
@@ -201,6 +217,17 @@ export default function VncClient({
|
||||
setConnecting(false);
|
||||
setConnected(false);
|
||||
if (!authConfirmed && !e.detail.clean) {
|
||||
if (!auditLogged) {
|
||||
auditLogged = true;
|
||||
void api.post(
|
||||
`/org/${target.orgId}/logs/access/attempt`,
|
||||
{
|
||||
resourceId: target.resourceId,
|
||||
action: false,
|
||||
type: "vnc"
|
||||
}
|
||||
);
|
||||
}
|
||||
setConnectError(t("sshErrorConnectionClosed"));
|
||||
}
|
||||
}
|
||||
@@ -209,6 +236,12 @@ export default function VncClient({
|
||||
rfb.addEventListener(
|
||||
"securityfailure",
|
||||
(e: { detail: { status: number; reason?: string } }) => {
|
||||
auditLogged = true;
|
||||
void api.post(`/org/${target.orgId}/logs/access/attempt`, {
|
||||
resourceId: target.resourceId,
|
||||
action: false,
|
||||
type: "vnc"
|
||||
});
|
||||
disconnect();
|
||||
setConnectError(
|
||||
e.detail.reason ??
|
||||
@@ -265,6 +298,25 @@ export default function VncClient({
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="space-y-4"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="username"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("vncUsernameOptional")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="text"
|
||||
autoComplete="username"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
|
||||
@@ -1,24 +1,24 @@
|
||||
"use client";
|
||||
|
||||
import { useSupporterStatusContext } from "@app/hooks/useSupporterStatusContext";
|
||||
// import { useSupporterStatusContext } from "@app/hooks/useSupporterStatusContext";
|
||||
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { build } from "@server/build";
|
||||
|
||||
export default function AuthPageFooterNotices() {
|
||||
const t = useTranslations();
|
||||
const { supporterStatus } = useSupporterStatusContext();
|
||||
// const { supporterStatus } = useSupporterStatusContext();
|
||||
const { isUnlocked, licenseStatus } = useLicenseStatusContext();
|
||||
|
||||
return (
|
||||
<>
|
||||
{supporterStatus?.visible && (
|
||||
{/* {supporterStatus?.visible && (
|
||||
<div className="text-center mt-2">
|
||||
<span className="text-sm text-muted-foreground opacity-50">
|
||||
{t("noSupportKey")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
)} */}
|
||||
{build === "enterprise" && !isUnlocked() ? (
|
||||
<div className="text-center mt-2">
|
||||
<span className="text-sm font-medium text-muted-foreground">
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { cn } from "@app/lib/cn";
|
||||
import { Check, Copy } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
@@ -7,12 +8,14 @@ type CopyToClipboardProps = {
|
||||
text: string;
|
||||
displayText?: string;
|
||||
isLink?: boolean;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const CopyToClipboard = ({
|
||||
text,
|
||||
displayText,
|
||||
isLink
|
||||
isLink,
|
||||
className
|
||||
}: CopyToClipboardProps) => {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
@@ -48,14 +51,20 @@ const CopyToClipboard = ({
|
||||
href={text}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="truncate hover:underline text-sm min-w-0 max-w-full"
|
||||
className={cn(
|
||||
"truncate hover:underline text-sm min-w-0 max-w-full",
|
||||
className
|
||||
)}
|
||||
title={text} // Shows full text on hover
|
||||
>
|
||||
{displayValue}
|
||||
</Link>
|
||||
) : (
|
||||
<span
|
||||
className="truncate text-sm min-w-0 max-w-full"
|
||||
className={cn(
|
||||
"truncate text-sm min-w-0 max-w-full",
|
||||
className
|
||||
)}
|
||||
style={{
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
|
||||
@@ -39,7 +39,6 @@ export function CreateOrgLabelDialog({
|
||||
const t = useTranslations();
|
||||
const api = createApiClient(useEnvContext());
|
||||
const { isPaidUser } = usePaidStatus();
|
||||
const canManageLabels = isPaidUser(tierMatrix.labels);
|
||||
const [isSubmitting, startTransition] = useTransition();
|
||||
|
||||
async function createOrgLabel(data: { name: string; color: string }) {
|
||||
@@ -84,11 +83,8 @@ export function CreateOrgLabelDialog({
|
||||
</CredenzaDescription>
|
||||
</CredenzaHeader>
|
||||
<CredenzaBody>
|
||||
<PaidFeaturesAlert tiers={tierMatrix.labels} />
|
||||
<OrgLabelForm
|
||||
disabled={!canManageLabels}
|
||||
onSubmit={(data) => {
|
||||
if (!canManageLabels) return;
|
||||
startTransition(async () => createOrgLabel(data));
|
||||
}}
|
||||
/>
|
||||
@@ -106,7 +102,7 @@ export function CreateOrgLabelDialog({
|
||||
<Button
|
||||
type="submit"
|
||||
form="org-label-form"
|
||||
disabled={isSubmitting || !canManageLabels}
|
||||
disabled={isSubmitting}
|
||||
loading={isSubmitting}
|
||||
>
|
||||
{t("labelCreate")}
|
||||
|
||||
@@ -1,206 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Credenza,
|
||||
CredenzaBody,
|
||||
CredenzaClose,
|
||||
CredenzaContent,
|
||||
CredenzaDescription,
|
||||
CredenzaFooter,
|
||||
CredenzaHeader,
|
||||
CredenzaTitle
|
||||
} from "@app/components/Credenza";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||
import { AxiosResponse } from "axios";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useState, useTransition } from "react";
|
||||
import {
|
||||
cleanForFQDN,
|
||||
PrivateResourceForm,
|
||||
isHostname,
|
||||
type InternalResourceFormValues
|
||||
} from "./PrivateResourceForm";
|
||||
import type { Selectedsite } from "./site-selector";
|
||||
|
||||
type CreateInternalResourceDialogProps = {
|
||||
open: boolean;
|
||||
setOpen: (val: boolean) => void;
|
||||
orgId: string;
|
||||
onSuccess?: () => void;
|
||||
initialSites?: Selectedsite[];
|
||||
};
|
||||
|
||||
export default function CreatePrivateResourceDialog({
|
||||
open,
|
||||
setOpen,
|
||||
orgId,
|
||||
onSuccess,
|
||||
initialSites
|
||||
}: CreateInternalResourceDialogProps) {
|
||||
const t = useTranslations();
|
||||
const api = createApiClient(useEnvContext());
|
||||
const [isHttpModeDisabled, setIsHttpModeDisabled] = useState(false);
|
||||
const [isSubmitting, startTransition] = useTransition();
|
||||
|
||||
function handleSubmit(values: InternalResourceFormValues) {
|
||||
startTransition(async () => {
|
||||
try {
|
||||
let data = { ...values };
|
||||
if (
|
||||
(data.mode === "host" ||
|
||||
data.mode === "http" ||
|
||||
data.mode === "ssh") &&
|
||||
isHostname(data.destination)
|
||||
) {
|
||||
const currentAlias = data.alias?.trim() || "";
|
||||
if (!currentAlias) {
|
||||
let aliasValue = data.destination;
|
||||
if (data.destination?.toLowerCase() === "localhost") {
|
||||
aliasValue = `${cleanForFQDN(data.name)}.internal`;
|
||||
}
|
||||
data = { ...data, alias: aliasValue };
|
||||
}
|
||||
}
|
||||
|
||||
await api.put<
|
||||
AxiosResponse<{ data: { siteResourceId: number } }>
|
||||
>(`/org/${orgId}/site-resource`, {
|
||||
name: data.name,
|
||||
siteIds: data.siteIds,
|
||||
mode: data.mode,
|
||||
destination: data.destination ?? undefined,
|
||||
enabled: true,
|
||||
...(data.mode === "http" && {
|
||||
scheme: data.scheme,
|
||||
ssl: data.ssl ?? false,
|
||||
destinationPort: data.destinationPort ?? undefined,
|
||||
domainId: data.httpConfigDomainId
|
||||
? data.httpConfigDomainId
|
||||
: undefined,
|
||||
subdomain: data.httpConfigSubdomain
|
||||
? data.httpConfigSubdomain
|
||||
: undefined
|
||||
}),
|
||||
...(data.mode === "host" && {
|
||||
alias:
|
||||
data.alias &&
|
||||
typeof data.alias === "string" &&
|
||||
data.alias.trim()
|
||||
? data.alias
|
||||
: undefined,
|
||||
...(data.authDaemonMode != null && {
|
||||
authDaemonMode: data.authDaemonMode
|
||||
}),
|
||||
...(data.authDaemonMode === "remote" &&
|
||||
data.authDaemonPort != null && {
|
||||
authDaemonPort: data.authDaemonPort
|
||||
})
|
||||
}),
|
||||
...(data.mode === "ssh" && {
|
||||
alias:
|
||||
data.alias &&
|
||||
typeof data.alias === "string" &&
|
||||
data.alias.trim()
|
||||
? data.alias
|
||||
: undefined,
|
||||
destinationPort: data.destinationPort ?? undefined,
|
||||
pamMode: data.pamMode ?? undefined,
|
||||
...(data.authDaemonMode != null && {
|
||||
authDaemonMode: data.authDaemonMode
|
||||
}),
|
||||
...(data.authDaemonMode === "remote" &&
|
||||
data.authDaemonPort != null && {
|
||||
authDaemonPort: data.authDaemonPort
|
||||
})
|
||||
}),
|
||||
...((data.mode === "host" || data.mode === "cidr") && {
|
||||
tcpPortRangeString: data.tcpPortRangeString,
|
||||
udpPortRangeString: data.udpPortRangeString,
|
||||
disableIcmp: data.disableIcmp ?? false
|
||||
}),
|
||||
...(data.mode === "ssh" && {
|
||||
disableIcmp: data.disableIcmp ?? false
|
||||
}),
|
||||
roleIds: data.roles
|
||||
? data.roles.map((r) => parseInt(r.id))
|
||||
: [],
|
||||
userIds: data.users ? data.users.map((u) => u.id) : [],
|
||||
clientIds: data.clients
|
||||
? data.clients.map((c) => parseInt(c.id))
|
||||
: []
|
||||
});
|
||||
|
||||
toast({
|
||||
title: t("createInternalResourceDialogSuccess"),
|
||||
description: t(
|
||||
"createInternalResourceDialogInternalResourceCreatedSuccessfully"
|
||||
),
|
||||
variant: "default"
|
||||
});
|
||||
setOpen(false);
|
||||
onSuccess?.();
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: t("createInternalResourceDialogError"),
|
||||
description: formatAxiosError(
|
||||
error,
|
||||
t(
|
||||
"createInternalResourceDialogFailedToCreateInternalResource"
|
||||
)
|
||||
),
|
||||
variant: "destructive"
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Credenza open={open} onOpenChange={setOpen}>
|
||||
<CredenzaContent className="max-w-3xl">
|
||||
<CredenzaHeader>
|
||||
<CredenzaTitle>
|
||||
{t("createInternalResourceDialogCreateClientResource")}
|
||||
</CredenzaTitle>
|
||||
<CredenzaDescription>
|
||||
{t(
|
||||
"createInternalResourceDialogCreateClientResourceDescription"
|
||||
)}
|
||||
</CredenzaDescription>
|
||||
</CredenzaHeader>
|
||||
<CredenzaBody>
|
||||
<PrivateResourceForm
|
||||
variant="create"
|
||||
open={open}
|
||||
orgId={orgId}
|
||||
formId="create-internal-resource-form"
|
||||
onSubmit={handleSubmit}
|
||||
onSubmitDisabledChange={setIsHttpModeDisabled}
|
||||
initialSites={initialSites}
|
||||
/>
|
||||
</CredenzaBody>
|
||||
<CredenzaFooter>
|
||||
<CredenzaClose asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setOpen(false)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{t("createInternalResourceDialogCancel")}
|
||||
</Button>
|
||||
</CredenzaClose>
|
||||
<Button
|
||||
type="submit"
|
||||
form="create-internal-resource-form"
|
||||
disabled={isSubmitting || isHttpModeDisabled}
|
||||
loading={isSubmitting}
|
||||
>
|
||||
{t("createInternalResourceDialogCreateResource")}
|
||||
</Button>
|
||||
</CredenzaFooter>
|
||||
</CredenzaContent>
|
||||
</Credenza>
|
||||
);
|
||||
}
|
||||
@@ -44,8 +44,6 @@ export function EditOrgLabelDialog({
|
||||
}: EditOrgLabelDialogProps) {
|
||||
const t = useTranslations();
|
||||
const api = createApiClient(useEnvContext());
|
||||
const { isPaidUser } = usePaidStatus();
|
||||
const canManageLabels = isPaidUser(tierMatrix.labels);
|
||||
const [isSubmitting, startTransition] = useTransition();
|
||||
|
||||
async function editOrgLabel(data: { name: string; color: string }) {
|
||||
@@ -90,12 +88,9 @@ export function EditOrgLabelDialog({
|
||||
</CredenzaDescription>
|
||||
</CredenzaHeader>
|
||||
<CredenzaBody>
|
||||
<PaidFeaturesAlert tiers={tierMatrix.labels} />
|
||||
<OrgLabelForm
|
||||
disabled={!canManageLabels}
|
||||
defaultValue={label}
|
||||
onSubmit={(data) => {
|
||||
if (!canManageLabels) return;
|
||||
startTransition(async () => editOrgLabel(data));
|
||||
}}
|
||||
/>
|
||||
@@ -113,7 +108,7 @@ export function EditOrgLabelDialog({
|
||||
<Button
|
||||
type="submit"
|
||||
form="org-label-form"
|
||||
disabled={isSubmitting || !canManageLabels}
|
||||
disabled={isSubmitting}
|
||||
loading={isSubmitting}
|
||||
>
|
||||
{t("labelEdit")}
|
||||
|
||||
@@ -1,225 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Credenza,
|
||||
CredenzaBody,
|
||||
CredenzaClose,
|
||||
CredenzaContent,
|
||||
CredenzaDescription,
|
||||
CredenzaFooter,
|
||||
CredenzaHeader,
|
||||
CredenzaTitle
|
||||
} from "@app/components/Credenza";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||
import { resourceQueries } from "@app/lib/queries";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useState, useTransition } from "react";
|
||||
import {
|
||||
cleanForFQDN,
|
||||
PrivateResourceForm,
|
||||
type InternalResourceData,
|
||||
type InternalResourceFormValues,
|
||||
isHostname
|
||||
} from "./PrivateResourceForm";
|
||||
|
||||
type EditInternalResourceDialogProps = {
|
||||
open: boolean;
|
||||
setOpen: (val: boolean) => void;
|
||||
resource: InternalResourceData;
|
||||
orgId: string;
|
||||
onSuccess?: () => void;
|
||||
};
|
||||
|
||||
export default function EditPrivateResourceDialog({
|
||||
open,
|
||||
setOpen,
|
||||
resource,
|
||||
orgId,
|
||||
onSuccess
|
||||
}: EditInternalResourceDialogProps) {
|
||||
const t = useTranslations();
|
||||
const api = createApiClient(useEnvContext());
|
||||
const queryClient = useQueryClient();
|
||||
const [isSubmitting, startTransition] = useTransition();
|
||||
const [isHttpModeDisabled, setIsHttpModeDisabled] = useState(false);
|
||||
|
||||
async function handleSubmit(values: InternalResourceFormValues) {
|
||||
try {
|
||||
let data = { ...values };
|
||||
if (
|
||||
(data.mode === "host" ||
|
||||
data.mode === "http" ||
|
||||
data.mode === "ssh") &&
|
||||
isHostname(data.destination)
|
||||
) {
|
||||
const currentAlias = data.alias?.trim() || "";
|
||||
if (!currentAlias) {
|
||||
let aliasValue = data.destination;
|
||||
if (data.destination?.toLowerCase() === "localhost") {
|
||||
aliasValue = `${cleanForFQDN(data.name)}.internal`;
|
||||
}
|
||||
data = { ...data, alias: aliasValue };
|
||||
}
|
||||
}
|
||||
|
||||
await api.post(`/site-resource/${resource.id}`, {
|
||||
name: data.name,
|
||||
siteIds: data.siteIds,
|
||||
mode: data.mode,
|
||||
niceId: data.niceId,
|
||||
destination: data.destination ?? undefined,
|
||||
...(data.mode === "http" && {
|
||||
scheme: data.scheme,
|
||||
ssl: data.ssl ?? false,
|
||||
destinationPort: data.destinationPort ?? null,
|
||||
domainId: data.httpConfigDomainId
|
||||
? data.httpConfigDomainId
|
||||
: undefined,
|
||||
subdomain: data.httpConfigSubdomain
|
||||
? data.httpConfigSubdomain
|
||||
: undefined
|
||||
}),
|
||||
...(data.mode === "host" && {
|
||||
alias:
|
||||
data.alias &&
|
||||
typeof data.alias === "string" &&
|
||||
data.alias.trim()
|
||||
? data.alias
|
||||
: null,
|
||||
...(data.authDaemonMode != null && {
|
||||
authDaemonMode: data.authDaemonMode
|
||||
}),
|
||||
...(data.authDaemonMode === "remote" && {
|
||||
authDaemonPort: data.authDaemonPort || null
|
||||
})
|
||||
}),
|
||||
...(data.mode === "ssh" && {
|
||||
alias:
|
||||
data.alias &&
|
||||
typeof data.alias === "string" &&
|
||||
data.alias.trim()
|
||||
? data.alias
|
||||
: null,
|
||||
destinationPort: data.destinationPort ?? null,
|
||||
pamMode: data.pamMode ?? undefined,
|
||||
...(data.authDaemonMode != null && {
|
||||
authDaemonMode: data.authDaemonMode
|
||||
}),
|
||||
...(data.authDaemonMode === "remote" && {
|
||||
authDaemonPort: data.authDaemonPort || null
|
||||
})
|
||||
}),
|
||||
...((data.mode === "host" || data.mode === "cidr") && {
|
||||
tcpPortRangeString: data.tcpPortRangeString,
|
||||
udpPortRangeString: data.udpPortRangeString,
|
||||
disableIcmp: data.disableIcmp ?? false
|
||||
}),
|
||||
...(data.mode === "ssh" && {
|
||||
disableIcmp: data.disableIcmp ?? false
|
||||
}),
|
||||
roleIds: (data.roles || []).map((r) => parseInt(r.id)),
|
||||
userIds: (data.users || []).map((u) => u.id),
|
||||
clientIds: (data.clients || []).map((c) => parseInt(c.id))
|
||||
});
|
||||
|
||||
await queryClient.invalidateQueries(
|
||||
resourceQueries.siteResourceRoles({
|
||||
siteResourceId: resource.id
|
||||
})
|
||||
);
|
||||
await queryClient.invalidateQueries(
|
||||
resourceQueries.siteResourceUsers({
|
||||
siteResourceId: resource.id
|
||||
})
|
||||
);
|
||||
await queryClient.invalidateQueries(
|
||||
resourceQueries.siteResourceClients({
|
||||
siteResourceId: resource.id
|
||||
})
|
||||
);
|
||||
|
||||
toast({
|
||||
title: t("editInternalResourceDialogSuccess"),
|
||||
description: t(
|
||||
"editInternalResourceDialogInternalResourceUpdatedSuccessfully"
|
||||
),
|
||||
variant: "default"
|
||||
});
|
||||
setOpen(false);
|
||||
onSuccess?.();
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: t("editInternalResourceDialogError"),
|
||||
description: formatAxiosError(
|
||||
error,
|
||||
t(
|
||||
"editInternalResourceDialogFailedToUpdateInternalResource"
|
||||
)
|
||||
),
|
||||
variant: "destructive"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Credenza
|
||||
open={open}
|
||||
onOpenChange={(isOpen) => {
|
||||
if (!isOpen) setOpen(false);
|
||||
}}
|
||||
>
|
||||
<CredenzaContent className="max-w-3xl">
|
||||
<CredenzaHeader>
|
||||
<CredenzaTitle>
|
||||
{t("editInternalResourceDialogEditClientResource")}
|
||||
</CredenzaTitle>
|
||||
<CredenzaDescription>
|
||||
{t(
|
||||
"editInternalResourceDialogUpdateResourceProperties",
|
||||
{
|
||||
resourceName: resource.name
|
||||
}
|
||||
)}
|
||||
</CredenzaDescription>
|
||||
</CredenzaHeader>
|
||||
<CredenzaBody>
|
||||
<PrivateResourceForm
|
||||
variant="edit"
|
||||
open={open}
|
||||
resource={resource}
|
||||
orgId={orgId}
|
||||
siteResourceId={resource.id}
|
||||
formId="edit-internal-resource-form"
|
||||
onSubmit={(values) =>
|
||||
startTransition(() => handleSubmit(values))
|
||||
}
|
||||
onSubmitDisabledChange={setIsHttpModeDisabled}
|
||||
/>
|
||||
</CredenzaBody>
|
||||
<CredenzaFooter>
|
||||
<CredenzaClose asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setOpen(false)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{t("editInternalResourceDialogCancel")}
|
||||
</Button>
|
||||
</CredenzaClose>
|
||||
<Button
|
||||
type="submit"
|
||||
form="edit-internal-resource-form"
|
||||
disabled={isSubmitting || isHttpModeDisabled}
|
||||
loading={isSubmitting}
|
||||
>
|
||||
{t("editInternalResourceDialogSaveResource")}
|
||||
</Button>
|
||||
</CredenzaFooter>
|
||||
</CredenzaContent>
|
||||
</Credenza>
|
||||
);
|
||||
}
|
||||
@@ -2,24 +2,36 @@
|
||||
|
||||
import { useEffect, useState, useRef } from "react";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
interface HeadersInputProps {
|
||||
value?: { name: string; value: string }[] | null;
|
||||
onChange: (value: { name: string; value: string }[] | null) => void;
|
||||
onValidityChange?: (isValid: boolean) => void;
|
||||
placeholder?: string;
|
||||
rows?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// Mirrors the server side validation in updateResource.ts so that invalid
|
||||
// input is caught (and shown to the user) before it is ever submitted,
|
||||
// instead of being silently dropped in favor of the last known good value.
|
||||
const validHeaderNamePattern = /^[a-zA-Z0-9!#$%&'*+\-.^_`|~]+$/;
|
||||
const validHeaderValuePattern = /^[\t\x20-\x7E]*$/;
|
||||
const templatePattern = /\{\{[^}]+\}\}/;
|
||||
|
||||
export function HeadersInput({
|
||||
value = [],
|
||||
onChange,
|
||||
onValidityChange,
|
||||
placeholder = `X-Example-Header: example-value
|
||||
X-Another-Header: another-value`,
|
||||
rows = 4,
|
||||
className
|
||||
}: HeadersInputProps) {
|
||||
const t = useTranslations();
|
||||
const [internalValue, setInternalValue] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const isUserEditingRef = useRef(false);
|
||||
|
||||
@@ -34,37 +46,56 @@ X-Another-Header: another-value`,
|
||||
.join("\n");
|
||||
};
|
||||
|
||||
// Convert newline-separated string to header objects array
|
||||
const convertToHeadersArray = (
|
||||
// Parse newline-separated text into header objects, validating each line
|
||||
// against the same rules enforced by the server. Returns either the
|
||||
// parsed headers or an error message describing the first invalid line.
|
||||
const parseHeaders = (
|
||||
newlineSeparated: string
|
||||
): { name: string; value: string }[] | null => {
|
||||
if (!newlineSeparated || newlineSeparated.trim() === "") return [];
|
||||
):
|
||||
| { headers: { name: string; value: string }[]; error: null }
|
||||
| { headers: null; error: string } => {
|
||||
if (!newlineSeparated || newlineSeparated.trim() === "") {
|
||||
return { headers: [], error: null };
|
||||
}
|
||||
|
||||
return newlineSeparated
|
||||
const lines = newlineSeparated
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.length > 0 && line.includes(":"))
|
||||
.map((line) => {
|
||||
const colonIndex = line.indexOf(":");
|
||||
const name = line.substring(0, colonIndex).trim();
|
||||
const value = line.substring(colonIndex + 1).trim();
|
||||
.filter((line) => line.length > 0);
|
||||
|
||||
// Ensure header name conforms to HTTP header requirements
|
||||
// Header names should be case-insensitive, contain only ASCII letters, digits, and hyphens
|
||||
const normalizedName = name
|
||||
.replace(/[^a-zA-Z0-9\-]/g, "")
|
||||
.toLowerCase();
|
||||
const headers: { name: string; value: string }[] = [];
|
||||
|
||||
return { name: normalizedName, value };
|
||||
})
|
||||
.filter((header) => header.name.length > 0); // Filter out headers with invalid names
|
||||
for (const line of lines) {
|
||||
const colonIndex = line.indexOf(":");
|
||||
if (colonIndex === -1) {
|
||||
return { headers: null, error: t("headersValidationError") };
|
||||
}
|
||||
|
||||
const name = line.substring(0, colonIndex).trim();
|
||||
const value = line.substring(colonIndex + 1).trim();
|
||||
|
||||
if (
|
||||
!validHeaderNamePattern.test(name) ||
|
||||
!validHeaderValuePattern.test(value) ||
|
||||
templatePattern.test(name) ||
|
||||
templatePattern.test(value)
|
||||
) {
|
||||
return { headers: null, error: t("headersValidationError") };
|
||||
}
|
||||
|
||||
headers.push({ name, value });
|
||||
}
|
||||
|
||||
return { headers, error: null };
|
||||
};
|
||||
|
||||
// Update internal value when external value changes
|
||||
// But only if the user is not currently editing (textarea not focused)
|
||||
useEffect(() => {
|
||||
if (!isUserEditingRef.current) {
|
||||
setInternalValue(convertToNewlineSeparated(value));
|
||||
setInternalValue(convertToNewlineSeparated(value ?? []));
|
||||
setError(null);
|
||||
onValidityChange?.(true);
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
@@ -75,31 +106,20 @@ X-Another-Header: another-value`,
|
||||
// Mark that user is actively editing
|
||||
isUserEditingRef.current = true;
|
||||
|
||||
// Only update parent if the input is in a valid state
|
||||
// Valid states: empty/whitespace only, or contains properly formatted headers
|
||||
const result = parseHeaders(newValue);
|
||||
|
||||
if (newValue.trim() === "") {
|
||||
// Empty input is valid - represents no headers
|
||||
onChange([]);
|
||||
} else {
|
||||
// Check if all non-empty lines are properly formatted (contain ':')
|
||||
const lines = newValue.split("\n");
|
||||
const nonEmptyLines = lines
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.length > 0);
|
||||
|
||||
// If there are no non-empty lines, or all non-empty lines contain ':', it's valid
|
||||
const isValid =
|
||||
nonEmptyLines.length === 0 ||
|
||||
nonEmptyLines.every((line) => line.includes(":"));
|
||||
|
||||
if (isValid) {
|
||||
// Safe to convert and update parent
|
||||
const headersArray = convertToHeadersArray(newValue);
|
||||
onChange(headersArray);
|
||||
}
|
||||
// If not valid, don't call onChange - let user continue typing
|
||||
if (result.error) {
|
||||
// Surface the error and do not touch the last known good value.
|
||||
// Silently dropping the update here (without telling the user)
|
||||
// is what previously let stale data get saved without warning.
|
||||
setError(result.error);
|
||||
onValidityChange?.(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setError(null);
|
||||
onValidityChange?.(true);
|
||||
onChange(result.headers);
|
||||
};
|
||||
|
||||
const handleFocus = () => {
|
||||
@@ -114,15 +134,20 @@ X-Another-Header: another-value`,
|
||||
};
|
||||
|
||||
return (
|
||||
<Textarea
|
||||
ref={textareaRef}
|
||||
value={internalValue}
|
||||
onChange={handleChange}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
placeholder={placeholder}
|
||||
rows={rows}
|
||||
className={className}
|
||||
/>
|
||||
<div>
|
||||
<Textarea
|
||||
ref={textareaRef}
|
||||
value={internalValue}
|
||||
onChange={handleChange}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
placeholder={placeholder}
|
||||
rows={rows}
|
||||
className={className}
|
||||
/>
|
||||
{error && (
|
||||
<p className="text-sm text-destructive mt-1.5">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,12 +5,15 @@ import { cn } from "@app/lib/cn";
|
||||
export function InfoSections({
|
||||
children,
|
||||
cols,
|
||||
columnSizing = "content"
|
||||
columnSizing = "content",
|
||||
layout = "default"
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
cols?: number;
|
||||
/** content (default): fixed gap, columns hug content, left-aligned; fill: equal-width columns across the row */
|
||||
columnSizing?: "fill" | "content";
|
||||
/** panel: fixed 2-column grid for narrow containers such as side panels */
|
||||
layout?: "default" | "panel";
|
||||
}) {
|
||||
const n = cols || 1;
|
||||
const track =
|
||||
@@ -19,9 +22,14 @@ export function InfoSections({
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"grid w-full min-w-0 grid-cols-2 md:grid-cols-(--columns) md:space-x-16 gap-4 md:items-start",
|
||||
"grid w-full min-w-0 gap-4",
|
||||
layout === "panel"
|
||||
? "grid-cols-2 items-start"
|
||||
: "grid-cols-2 md:grid-cols-(--columns) md:items-start md:space-x-16",
|
||||
columnSizing === "content" &&
|
||||
"md:justify-items-start md:justify-start"
|
||||
(layout === "panel"
|
||||
? "justify-items-start justify-start"
|
||||
: "md:justify-items-start md:justify-start")
|
||||
)}
|
||||
style={{
|
||||
// @ts-expect-error dynamic props don't work with tailwind, but we can set the
|
||||
|
||||
@@ -1,14 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList
|
||||
} from "@app/components/ui/command";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
@@ -16,16 +8,15 @@ import {
|
||||
} from "@app/components/ui/popover";
|
||||
import { cn } from "@app/lib/cn";
|
||||
import { dataTableFilterPopoverContentClassName } from "@app/lib/dataTableFilterPopover";
|
||||
import { CheckIcon, Funnel } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useMemo, useState } from "react";
|
||||
import { orgQueries } from "@app/lib/queries";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useDebounce } from "use-debounce";
|
||||
import { Funnel } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { LabelBadge } from "./label-badge";
|
||||
import { LabelOverflowBadge } from "./label-overflow-badge";
|
||||
import { LabelsFilterSelector } from "./LabelsFilterSelector";
|
||||
import { LABEL_COLORS } from "./labels-selector";
|
||||
import { Checkbox } from "./ui/checkbox";
|
||||
|
||||
function areSelectionsEqual(a: string[], b: string[]) {
|
||||
if (a.length !== b.length) {
|
||||
@@ -54,13 +45,9 @@ export function LabelColumnFilterButton({
|
||||
const [draftValues, setDraftValues] = useState<string[]>(selectedValues);
|
||||
const t = useTranslations();
|
||||
|
||||
const [labelSearchQuery, setlabelsSearchQuery] = useState("");
|
||||
const [debouncedQuery] = useDebounce(labelSearchQuery, 150);
|
||||
|
||||
const { data: labels = [] } = useQuery(
|
||||
orgQueries.labels({
|
||||
orgId,
|
||||
query: debouncedQuery,
|
||||
perPage: 500
|
||||
})
|
||||
);
|
||||
@@ -152,53 +139,17 @@ export function LabelColumnFilterButton({
|
||||
className={dataTableFilterPopoverContentClassName}
|
||||
align="start"
|
||||
>
|
||||
<Command shouldFilter={false}>
|
||||
<CommandInput
|
||||
placeholder={t("labelSearch")}
|
||||
value={labelSearchQuery}
|
||||
onValueChange={setlabelsSearchQuery}
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty>{t("labelsNotFound")}</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{draftValues.length > 0 && (
|
||||
<CommandItem
|
||||
onSelect={() => {
|
||||
setDraftValues([]);
|
||||
}}
|
||||
className="text-muted-foreground"
|
||||
>
|
||||
{t("accessFilterClear")}
|
||||
</CommandItem>
|
||||
)}
|
||||
{labels.map((label) => (
|
||||
<CommandItem
|
||||
key={label.name}
|
||||
value={label.name}
|
||||
onSelect={() => {
|
||||
toggle(label.name);
|
||||
}}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Checkbox
|
||||
className="pointer-events-none shrink-0"
|
||||
checked={draftSet.has(label.name)}
|
||||
aria-hidden
|
||||
tabIndex={-1}
|
||||
/>
|
||||
<div
|
||||
className="size-2 rounded-full bg-(--color) flex-none"
|
||||
style={{
|
||||
// @ts-expect-error css color
|
||||
"--color": label.color
|
||||
}}
|
||||
/>
|
||||
{label.name}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
<LabelsFilterSelector
|
||||
orgId={orgId}
|
||||
isSelected={(label) => draftSet.has(label.name)}
|
||||
onToggle={(label) => {
|
||||
toggle(label.name);
|
||||
}}
|
||||
showClear={draftValues.length > 0}
|
||||
onClear={() => {
|
||||
setDraftValues([]);
|
||||
}}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList
|
||||
} from "@app/components/ui/command";
|
||||
import { launcherQueries, orgQueries } from "@app/lib/queries";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useDebounce } from "use-debounce";
|
||||
import { Checkbox } from "./ui/checkbox";
|
||||
|
||||
export type LabelFilterOption = {
|
||||
labelId: number;
|
||||
name: string;
|
||||
color: string;
|
||||
};
|
||||
|
||||
type LabelsFilterSelectorProps = {
|
||||
orgId: string;
|
||||
isSelected: (label: LabelFilterOption) => boolean;
|
||||
onToggle: (label: LabelFilterOption) => void;
|
||||
selectedLabels?: LabelFilterOption[];
|
||||
onClear?: () => void;
|
||||
showClear?: boolean;
|
||||
scope?: "org" | "launcher";
|
||||
};
|
||||
|
||||
export function LabelsFilterSelector({
|
||||
orgId,
|
||||
isSelected,
|
||||
onToggle,
|
||||
selectedLabels = [],
|
||||
onClear,
|
||||
showClear = false,
|
||||
scope = "org"
|
||||
}: LabelsFilterSelectorProps) {
|
||||
const t = useTranslations();
|
||||
const [labelSearchQuery, setlabelsSearchQuery] = useState("");
|
||||
const [debouncedQuery] = useDebounce(labelSearchQuery, 150);
|
||||
|
||||
const orgLabelsQuery = useQuery({
|
||||
...orgQueries.labels({
|
||||
orgId,
|
||||
query: debouncedQuery,
|
||||
perPage: 500
|
||||
}),
|
||||
enabled: scope === "org"
|
||||
});
|
||||
const launcherLabelsQuery = useQuery({
|
||||
...launcherQueries.labels({
|
||||
orgId,
|
||||
query: debouncedQuery,
|
||||
perPage: 20
|
||||
}),
|
||||
enabled: scope === "launcher"
|
||||
});
|
||||
const labels =
|
||||
scope === "launcher"
|
||||
? (launcherLabelsQuery.data ?? [])
|
||||
: (orgLabelsQuery.data ?? []);
|
||||
|
||||
const labelsShown = useMemo(() => {
|
||||
const base = [...labels];
|
||||
if (debouncedQuery.trim().length === 0 && selectedLabels.length > 0) {
|
||||
const selectedNotInBase = selectedLabels.filter(
|
||||
(selected) =>
|
||||
!base.some((label) => label.labelId === selected.labelId)
|
||||
);
|
||||
return [...selectedNotInBase, ...base];
|
||||
}
|
||||
return base;
|
||||
}, [debouncedQuery, labels, selectedLabels]);
|
||||
|
||||
return (
|
||||
<Command shouldFilter={false}>
|
||||
<CommandInput
|
||||
placeholder={t("labelSearch")}
|
||||
value={labelSearchQuery}
|
||||
onValueChange={setlabelsSearchQuery}
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty>{t("labelsNotFound")}</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{showClear && onClear && (
|
||||
<CommandItem
|
||||
onSelect={onClear}
|
||||
className="text-muted-foreground"
|
||||
>
|
||||
{t("accessFilterClear")}
|
||||
</CommandItem>
|
||||
)}
|
||||
{labelsShown.map((label) => (
|
||||
<CommandItem
|
||||
key={label.labelId}
|
||||
value={label.name}
|
||||
onSelect={() => {
|
||||
onToggle(label);
|
||||
}}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Checkbox
|
||||
className="pointer-events-none shrink-0"
|
||||
checked={isSelected(label)}
|
||||
aria-hidden
|
||||
tabIndex={-1}
|
||||
/>
|
||||
<div
|
||||
className="size-2 rounded-full bg-(--color) flex-none"
|
||||
style={{
|
||||
// @ts-expect-error css color
|
||||
"--color": label.color
|
||||
}}
|
||||
/>
|
||||
{label.name}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
);
|
||||
}
|
||||
@@ -21,6 +21,8 @@ interface LayoutProps {
|
||||
showHeader?: boolean;
|
||||
showTopBar?: boolean;
|
||||
defaultSidebarCollapsed?: boolean;
|
||||
launcherMode?: boolean;
|
||||
showViewAsAdmin?: boolean;
|
||||
}
|
||||
|
||||
export async function Layout({
|
||||
@@ -32,7 +34,9 @@ export async function Layout({
|
||||
showSidebar = true,
|
||||
showHeader = true,
|
||||
showTopBar = true,
|
||||
defaultSidebarCollapsed = false
|
||||
defaultSidebarCollapsed = false,
|
||||
launcherMode = false,
|
||||
showViewAsAdmin = false
|
||||
}: LayoutProps) {
|
||||
const allCookies = await cookies();
|
||||
const sidebarStateCookie = allCookies.get("pangolin-sidebar-state")?.value;
|
||||
@@ -75,11 +79,21 @@ export async function Layout({
|
||||
navItems={navItems}
|
||||
showSidebar={showSidebar}
|
||||
showTopBar={showTopBar}
|
||||
launcherMode={launcherMode}
|
||||
showViewAsAdmin={showViewAsAdmin}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Desktop header */}
|
||||
{showHeader && <LayoutHeader showTopBar={showTopBar} />}
|
||||
{showHeader && (
|
||||
<LayoutHeader
|
||||
showTopBar={showTopBar}
|
||||
launcherMode={launcherMode}
|
||||
orgId={orgId}
|
||||
orgs={orgs}
|
||||
showViewAsAdmin={showViewAsAdmin}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Main content */}
|
||||
<main className="flex-1 overflow-y-auto p-3 md:p-6 w-full">
|
||||
|
||||
@@ -8,17 +8,32 @@ import { useTheme } from "next-themes";
|
||||
import BrandingLogo from "./BrandingLogo";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
|
||||
import { ListUserOrgsResponse } from "@server/routers/org";
|
||||
import { LauncherOrgSelector } from "@app/components/resource-launcher/LauncherOrgSelector";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { CommandPaletteTrigger } from "@app/components/command-palette/CommandPaletteTrigger";
|
||||
|
||||
interface LayoutHeaderProps {
|
||||
type LayoutHeaderProps = {
|
||||
showTopBar: boolean;
|
||||
}
|
||||
launcherMode?: boolean;
|
||||
orgId?: string;
|
||||
orgs?: ListUserOrgsResponse["orgs"];
|
||||
showViewAsAdmin?: boolean;
|
||||
};
|
||||
|
||||
export function LayoutHeader({ showTopBar }: LayoutHeaderProps) {
|
||||
export function LayoutHeader({
|
||||
showTopBar,
|
||||
launcherMode = false,
|
||||
orgId,
|
||||
orgs,
|
||||
showViewAsAdmin = false
|
||||
}: LayoutHeaderProps) {
|
||||
const { theme } = useTheme();
|
||||
const [path, setPath] = useState<string>("");
|
||||
const { env } = useEnvContext();
|
||||
const { isUnlocked } = useLicenseStatusContext();
|
||||
const t = useTranslations();
|
||||
|
||||
const logoWidth = isUnlocked()
|
||||
? env.branding.logo?.navbar?.width || 98
|
||||
@@ -54,16 +69,38 @@ export function LayoutHeader({ showTopBar }: LayoutHeaderProps) {
|
||||
<div className="relative z-10 px-6 py-2">
|
||||
<div className="container mx-auto max-w-12xl">
|
||||
<div className="h-16 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Link href="/" className="flex items-center">
|
||||
<div className="flex items-center gap-5 min-w-0">
|
||||
<Link
|
||||
href="/"
|
||||
className="flex items-center shrink-0"
|
||||
>
|
||||
<BrandingLogo
|
||||
width={logoWidth}
|
||||
height={logoHeight}
|
||||
/>
|
||||
</Link>
|
||||
{/* {build === "saas" && (
|
||||
<Badge variant="secondary">Cloud Beta</Badge>
|
||||
)} */}
|
||||
{launcherMode ? (
|
||||
<>
|
||||
<LauncherOrgSelector
|
||||
orgId={orgId}
|
||||
orgs={orgs}
|
||||
/>
|
||||
{showViewAsAdmin && orgId ? (
|
||||
<Button
|
||||
variant="text"
|
||||
size="sm"
|
||||
className="p-0"
|
||||
asChild
|
||||
>
|
||||
<Link href={`/${orgId}/settings`}>
|
||||
{t(
|
||||
"resourceLauncherViewAsAdmin"
|
||||
)}
|
||||
</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{showTopBar && (
|
||||
|
||||
@@ -3,6 +3,14 @@
|
||||
import type { SidebarNavSection } from "@app/app/navigation";
|
||||
import { CommandPaletteTrigger } from "@app/components/command-palette/CommandPaletteTrigger";
|
||||
import { OrgSelector } from "@app/components/OrgSelector";
|
||||
import { cn } from "@app/lib/cn";
|
||||
import { ListUserOrgsResponse } from "@server/routers/org";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { Menu, Server, Settings, SquareMousePointer } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useUserContext } from "@app/hooks/useUserContext";
|
||||
import { useTranslations } from "next-intl";
|
||||
import ProfileIcon from "@app/components/ProfileIcon";
|
||||
import { SidebarNav } from "@app/components/SidebarNav";
|
||||
import ThemeSwitcher from "@app/components/ThemeSwitcher";
|
||||
@@ -29,6 +37,8 @@ interface LayoutMobileMenuProps {
|
||||
navItems: SidebarNavSection[];
|
||||
showSidebar: boolean;
|
||||
showTopBar: boolean;
|
||||
launcherMode?: boolean;
|
||||
showViewAsAdmin?: boolean;
|
||||
}
|
||||
|
||||
export function LayoutMobileMenu({
|
||||
@@ -36,19 +46,33 @@ export function LayoutMobileMenu({
|
||||
orgs,
|
||||
navItems,
|
||||
showSidebar,
|
||||
showTopBar
|
||||
showTopBar,
|
||||
launcherMode = false,
|
||||
showViewAsAdmin = false
|
||||
}: LayoutMobileMenuProps) {
|
||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
||||
const pathname = usePathname();
|
||||
const isAdminPage = pathname?.startsWith("/admin");
|
||||
const { user } = useUserContext();
|
||||
const t = useTranslations();
|
||||
const showMobileNav = showSidebar || launcherMode;
|
||||
const currentOrg = orgs?.find((org) => org.orgId === orgId);
|
||||
const isSettingsPage = Boolean(
|
||||
orgId && pathname?.includes(`/${orgId}/settings`)
|
||||
);
|
||||
const canViewResourceLauncher = Boolean(
|
||||
currentOrg?.isAdmin || currentOrg?.isOwner
|
||||
);
|
||||
|
||||
const mobileNavLinkClassName = cn(
|
||||
"flex items-center rounded transition-colors text-muted-foreground hover:text-foreground text-sm w-full hover:bg-secondary/50 dark:hover:bg-secondary/20 rounded-md px-3 py-1.5"
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="shrink-0 md:hidden sticky top-0 z-50">
|
||||
<div className="h-16 flex items-center px-2">
|
||||
<div className="flex items-center gap-4">
|
||||
{showSidebar && (
|
||||
{showMobileNav && (
|
||||
<div>
|
||||
<Sheet
|
||||
open={isMobileMenuOpen}
|
||||
@@ -69,24 +93,24 @@ export function LayoutMobileMenu({
|
||||
<SheetDescription className="sr-only">
|
||||
{t("navbarDescription")}
|
||||
</SheetDescription>
|
||||
<div className="w-full border-b border-border">
|
||||
<div className="px-1 shrink-0">
|
||||
<OrgSelector
|
||||
orgId={orgId}
|
||||
orgs={orgs}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto relative">
|
||||
<div className="px-3">
|
||||
{!isAdminPage &&
|
||||
user.serverAdmin && (
|
||||
{launcherMode ? (
|
||||
<>
|
||||
<div className="w-full border-b border-border">
|
||||
<div className="px-1 shrink-0">
|
||||
<OrgSelector
|
||||
orgId={orgId}
|
||||
orgs={orgs}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{showViewAsAdmin && orgId ? (
|
||||
<div className="px-3">
|
||||
<div className="mb-1">
|
||||
<Link
|
||||
href="/admin"
|
||||
className={cn(
|
||||
"flex items-center rounded transition-colors text-muted-foreground hover:text-foreground text-sm w-full hover:bg-secondary/50 dark:hover:bg-secondary/20 rounded-md px-3 py-1.5"
|
||||
)}
|
||||
href={`/${orgId}/settings`}
|
||||
className={
|
||||
mobileNavLinkClassName
|
||||
}
|
||||
onClick={() =>
|
||||
setIsMobileMenuOpen(
|
||||
false
|
||||
@@ -94,25 +118,95 @@ export function LayoutMobileMenu({
|
||||
}
|
||||
>
|
||||
<span className="flex-shrink-0 w-5 h-5 flex items-center justify-center text-muted-foreground mr-3">
|
||||
<Server className="h-4 w-4" />
|
||||
<Settings className="h-4 w-4" />
|
||||
</span>
|
||||
<span className="flex-1">
|
||||
{t(
|
||||
"serverAdmin"
|
||||
"resourceLauncherViewAsAdmin"
|
||||
)}
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
<SidebarNav
|
||||
sections={navItems}
|
||||
onItemClick={() =>
|
||||
setIsMobileMenuOpen(false)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="sticky bottom-0 left-0 right-0 h-8 pointer-events-none bg-gradient-to-t from-card to-transparent" />
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="w-full border-b border-border">
|
||||
<div className="px-1 shrink-0">
|
||||
<OrgSelector
|
||||
orgId={orgId}
|
||||
orgs={orgs}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto relative">
|
||||
<div className="px-3">
|
||||
{!isAdminPage &&
|
||||
isSettingsPage &&
|
||||
canViewResourceLauncher &&
|
||||
orgId && (
|
||||
<div className="mb-1">
|
||||
<Link
|
||||
href={`/${orgId}`}
|
||||
className={
|
||||
mobileNavLinkClassName
|
||||
}
|
||||
onClick={() =>
|
||||
setIsMobileMenuOpen(
|
||||
false
|
||||
)
|
||||
}
|
||||
>
|
||||
<span className="flex-shrink-0 w-5 h-5 flex items-center justify-center text-muted-foreground mr-3">
|
||||
<SquareMousePointer className="h-4 w-4" />
|
||||
</span>
|
||||
<span className="flex-1">
|
||||
{t(
|
||||
"resourceSidebarLauncherTitle"
|
||||
)}
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
{!isAdminPage &&
|
||||
user.serverAdmin && (
|
||||
<div className="mb-1">
|
||||
<Link
|
||||
href="/admin"
|
||||
className={
|
||||
mobileNavLinkClassName
|
||||
}
|
||||
onClick={() =>
|
||||
setIsMobileMenuOpen(
|
||||
false
|
||||
)
|
||||
}
|
||||
>
|
||||
<span className="flex-shrink-0 w-5 h-5 flex items-center justify-center text-muted-foreground mr-3">
|
||||
<Server className="h-4 w-4" />
|
||||
</span>
|
||||
<span className="flex-1">
|
||||
{t(
|
||||
"serverAdmin"
|
||||
)}
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
<SidebarNav
|
||||
sections={navItems}
|
||||
onItemClick={() =>
|
||||
setIsMobileMenuOpen(
|
||||
false
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="sticky bottom-0 left-0 right-0 h-8 pointer-events-none bg-gradient-to-t from-card to-transparent" />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
|
||||
@@ -18,7 +18,13 @@ import { approvalQueries } from "@app/lib/queries";
|
||||
import { build } from "@server/build";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { ListUserOrgsResponse } from "@server/routers/org";
|
||||
import { ArrowRight, ExternalLink, PanelRightOpen, Server } from "lucide-react";
|
||||
import {
|
||||
ArrowRight,
|
||||
ExternalLink,
|
||||
PanelRightOpen,
|
||||
Server,
|
||||
SquareMousePointer
|
||||
} from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import dynamic from "next/dynamic";
|
||||
import Link from "next/link";
|
||||
@@ -130,6 +136,13 @@ export function LayoutSidebar({
|
||||
const showTrial =
|
||||
build === "saas" && Boolean(orgId) && subscriptionContext?.isTrial;
|
||||
|
||||
const isSettingsPage = Boolean(
|
||||
orgId && pathname?.includes(`/${orgId}/settings`)
|
||||
);
|
||||
const canViewResourceLauncher = Boolean(
|
||||
currentOrg?.isAdmin || currentOrg?.isOwner
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -152,6 +165,58 @@ export function LayoutSidebar({
|
||||
/>
|
||||
<div className="flex-1 overflow-y-auto relative">
|
||||
<div className="px-2 pt-3">
|
||||
{!isAdminPage &&
|
||||
isSettingsPage &&
|
||||
canViewResourceLauncher &&
|
||||
orgId && (
|
||||
<div
|
||||
className={cn(
|
||||
"shrink-0",
|
||||
isSidebarCollapsed ? "mb-4" : "mb-1"
|
||||
)}
|
||||
>
|
||||
{isSidebarCollapsed ? (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Link
|
||||
href={`/${orgId}`}
|
||||
className={cn(
|
||||
"flex items-center transition-colors text-muted-foreground hover:text-foreground text-sm w-full hover:bg-sidebar-accent dark:hover:bg-sidebar-accent/50 rounded-md px-2 py-2 justify-center"
|
||||
)}
|
||||
>
|
||||
<span className="flex-shrink-0 w-5 h-5 flex items-center justify-center text-muted-foreground">
|
||||
<SquareMousePointer className="h-4 w-4" />
|
||||
</span>
|
||||
</Link>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="right"
|
||||
sideOffset={8}
|
||||
>
|
||||
<p>
|
||||
{t("resourceSidebarLauncherTitle")}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
) : (
|
||||
<Link
|
||||
href={`/${orgId}`}
|
||||
className={cn(
|
||||
"flex items-center transition-colors text-muted-foreground hover:text-foreground text-sm w-full hover:bg-sidebar-accent dark:hover:bg-sidebar-accent/50 rounded-md px-3 py-1.5"
|
||||
)}
|
||||
>
|
||||
<span className="flex-shrink-0 mr-3 w-5 h-5 flex items-center justify-center text-muted-foreground">
|
||||
<SquareMousePointer className="h-4 w-4" />
|
||||
</span>
|
||||
<span className="flex-1">
|
||||
{t("resourceSidebarLauncherTitle")}
|
||||
</span>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!isAdminPage && user.serverAdmin && (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -159,36 +224,44 @@ export function LayoutSidebar({
|
||||
isSidebarCollapsed ? "mb-4" : "mb-1"
|
||||
)}
|
||||
>
|
||||
<Link
|
||||
href="/admin"
|
||||
className={cn(
|
||||
"flex items-center transition-colors text-muted-foreground hover:text-foreground text-sm w-full hover:bg-sidebar-accent dark:hover:bg-sidebar-accent/50 rounded-md",
|
||||
isSidebarCollapsed
|
||||
? "px-2 py-2 justify-center"
|
||||
: "px-3 py-1.5"
|
||||
)}
|
||||
title={
|
||||
isSidebarCollapsed
|
||||
? t("serverAdmin")
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<span
|
||||
{isSidebarCollapsed ? (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Link
|
||||
href="/admin"
|
||||
className={cn(
|
||||
"flex items-center transition-colors text-muted-foreground hover:text-foreground text-sm w-full hover:bg-sidebar-accent dark:hover:bg-sidebar-accent/50 rounded-md px-2 py-2 justify-center"
|
||||
)}
|
||||
>
|
||||
<span className="flex-shrink-0 w-5 h-5 flex items-center justify-center text-muted-foreground">
|
||||
<Server className="h-4 w-4" />
|
||||
</span>
|
||||
</Link>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="right"
|
||||
sideOffset={8}
|
||||
>
|
||||
<p>{t("serverAdmin")}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
) : (
|
||||
<Link
|
||||
href="/admin"
|
||||
className={cn(
|
||||
"flex-shrink-0 w-5 h-5 flex items-center justify-center text-muted-foreground",
|
||||
!isSidebarCollapsed && "mr-3"
|
||||
"flex items-center transition-colors text-muted-foreground hover:text-foreground text-sm w-full hover:bg-sidebar-accent dark:hover:bg-sidebar-accent/50 rounded-md px-3 py-1.5"
|
||||
)}
|
||||
>
|
||||
<Server className="h-4 w-4" />
|
||||
</span>
|
||||
{!isSidebarCollapsed && (
|
||||
<>
|
||||
<span className="flex-1">
|
||||
{t("serverAdmin")}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</Link>
|
||||
<span className="flex-shrink-0 mr-3 w-5 h-5 flex items-center justify-center text-muted-foreground">
|
||||
<Server className="h-4 w-4" />
|
||||
</span>
|
||||
<span className="flex-1">
|
||||
{t("serverAdmin")}
|
||||
</span>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<SidebarNav
|
||||
|
||||
@@ -11,10 +11,10 @@ import {
|
||||
} from "@app/components/ui/dropdown-menu";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { useNavigationContext } from "@app/hooks/useNavigationContext";
|
||||
import { useOptimisticLabels } from "@app/hooks/useOptimisticLabels";
|
||||
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||
import { cn } from "@app/lib/cn";
|
||||
import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn";
|
||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
import type { PaginationState } from "@tanstack/react-table";
|
||||
@@ -31,15 +31,18 @@ import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { startTransition, useMemo, useState, useTransition } from "react";
|
||||
import { useDebouncedCallback } from "use-debounce";
|
||||
import z from "zod";
|
||||
import { ColumnFilterButton } from "./ColumnFilterButton";
|
||||
import { type SelectedLabel } from "./labels-selector";
|
||||
import { LabelColumnFilterButton } from "./LabelColumnFilterButton";
|
||||
import { LabelsTableCell } from "./LabelsTableCell";
|
||||
import { Badge } from "./ui/badge";
|
||||
import { ControlledDataTable } from "./ui/controlled-data-table";
|
||||
import { LabelColumnFilterButton } from "./LabelColumnFilterButton";
|
||||
import { useLocalLabels } from "@app/hooks/useLocalLabels";
|
||||
import { useOptimisticLabels } from "@app/hooks/useOptimisticLabels";
|
||||
import {
|
||||
productUpdatesQueries,
|
||||
type LatestVersionResponse
|
||||
} from "@app/lib/queries";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import semver from "semver";
|
||||
import { InfoPopup } from "./ui/info-popup";
|
||||
|
||||
export type ClientRow = {
|
||||
id: number;
|
||||
@@ -100,7 +103,9 @@ export default function MachineClientsTable({
|
||||
const [isNavigatingToAddPage, startNavigation] = useTransition();
|
||||
|
||||
const { isPaidUser } = usePaidStatus();
|
||||
const isLabelFeatureEnabled = isPaidUser(tierMatrix.labels);
|
||||
const data = useQuery(productUpdatesQueries.latestVersion(true));
|
||||
|
||||
const latestPlatformVersions = data.data?.data;
|
||||
|
||||
const defaultMachineColumnVisibility = {
|
||||
subnet: false,
|
||||
@@ -375,6 +380,37 @@ export default function MachineClientsTable({
|
||||
cell: ({ row }) => {
|
||||
const originalRow = row.original;
|
||||
|
||||
const agentVersionMap: Record<string, string> = {
|
||||
"Pangolin Windows": "windows",
|
||||
"Pangolin Android": "android",
|
||||
"Pangolin iOS": "ios",
|
||||
"Pangolin iPadOS": "ios",
|
||||
"Pangolin macOS": "mac",
|
||||
"Pangolin CLI": "cli",
|
||||
"Olm CLI": "olm"
|
||||
};
|
||||
|
||||
let updateAvailable = false;
|
||||
|
||||
if (
|
||||
originalRow.olmVersion &&
|
||||
originalRow.agent &&
|
||||
latestPlatformVersions
|
||||
) {
|
||||
const agent = agentVersionMap[
|
||||
originalRow.agent
|
||||
] as keyof LatestVersionResponse;
|
||||
|
||||
if (agent in latestPlatformVersions) {
|
||||
const agentVersion = latestPlatformVersions[agent];
|
||||
|
||||
updateAvailable = semver.lt(
|
||||
originalRow.olmVersion,
|
||||
agentVersion.latestVersion
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center space-x-1">
|
||||
{originalRow.agent && originalRow.olmVersion ? (
|
||||
@@ -386,9 +422,9 @@ export default function MachineClientsTable({
|
||||
) : (
|
||||
"-"
|
||||
)}
|
||||
{/*originalRow.olmUpdateAvailable && (
|
||||
<InfoPopup info={t("olmUpdateAvailableInfo")} />
|
||||
)*/}
|
||||
{updateAvailable && (
|
||||
<InfoPopup info={t("updateAvailableInfo")} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -397,11 +433,8 @@ export default function MachineClientsTable({
|
||||
accessorKey: "subnet",
|
||||
friendlyName: t("address"),
|
||||
header: () => <span className="px-3">{t("address")}</span>
|
||||
}
|
||||
];
|
||||
|
||||
if (isLabelFeatureEnabled) {
|
||||
baseColumns.push({
|
||||
},
|
||||
{
|
||||
id: "labels",
|
||||
accessorKey: "labels",
|
||||
header: () => (
|
||||
@@ -421,8 +454,8 @@ export default function MachineClientsTable({
|
||||
orgId={orgId}
|
||||
/>
|
||||
)
|
||||
});
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
// Only include actions column if there are rows without userIds
|
||||
if (hasRowsWithoutUserId) {
|
||||
@@ -504,7 +537,7 @@ export default function MachineClientsTable({
|
||||
}
|
||||
|
||||
return baseColumns;
|
||||
}, [hasRowsWithoutUserId, isLabelFeatureEnabled, orgId, t, searchParams]);
|
||||
}, [hasRowsWithoutUserId, orgId, t, searchParams]);
|
||||
|
||||
function handleFilterChange(
|
||||
column: string,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -19,7 +19,7 @@ export default function OrgInfoCard({}: OrgInfoCardProps) {
|
||||
return (
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
<InfoSections cols={3}>
|
||||
<InfoSections cols={4}>
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>{t("name")}</InfoSectionTitle>
|
||||
<InfoSectionContent>{org.org.name}</InfoSectionContent>
|
||||
@@ -34,6 +34,14 @@ export default function OrgInfoCard({}: OrgInfoCardProps) {
|
||||
{org.org.subnet || t("none")}
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>
|
||||
{t("utilitySubnet")}
|
||||
</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
{org.org.utilitySubnet || t("none")}
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
</InfoSections>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
@@ -21,9 +21,6 @@ import {
|
||||
ControlledDataTable,
|
||||
type ExtendedColumnDef
|
||||
} from "./ui/controlled-data-table";
|
||||
import { LabelBadge } from "./label-badge";
|
||||
import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn";
|
||||
import { cn } from "@app/lib/cn";
|
||||
import ConfirmDeleteDialog from "./ConfirmDeleteDialog";
|
||||
import { CreateOrgLabelDialog } from "./CreateOrgLabelDialog";
|
||||
import { EditOrgLabelDialog } from "./EditOrgLabelDialog";
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import { useRef } from "react";
|
||||
import type { FieldValues, Path, UseFormReturn } from "react-hook-form";
|
||||
import { RolesSelector, type SelectedRole } from "./roles-selector";
|
||||
|
||||
@@ -41,6 +42,46 @@ export default function OrgRolesTagField<TFieldValues extends FieldValues>({
|
||||
disabled
|
||||
}: OrgRolesTagFieldProps<TFieldValues>) {
|
||||
const t = useTranslations();
|
||||
const isPopoverOpenRef = useRef(false);
|
||||
const lastValidRolesRef = useRef<SelectedRole[]>(
|
||||
(form.getValues(name) as SelectedRole[]) ?? []
|
||||
);
|
||||
|
||||
function validateRolesSelection() {
|
||||
const current = form.getValues(name) as SelectedRole[];
|
||||
|
||||
if (current.length === 0 && lastValidRolesRef.current.length > 0) {
|
||||
form.setValue(name, lastValidRolesRef.current as never, {
|
||||
shouldDirty: true
|
||||
});
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("accessRoleRequired"),
|
||||
description: t("accessRoleSelectPlease")
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
if (current.length > 0) {
|
||||
lastValidRolesRef.current = current;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function handlePopoverOpenChange(open: boolean) {
|
||||
isPopoverOpenRef.current = open;
|
||||
|
||||
if (open) {
|
||||
const current = form.getValues(name) as SelectedRole[];
|
||||
if (current.length > 0) {
|
||||
lastValidRolesRef.current = current;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
validateRolesSelection();
|
||||
}
|
||||
|
||||
function setRoleTags(nextValue: SelectedRole[]) {
|
||||
const prev = form.getValues(name) as SelectedRole[];
|
||||
@@ -61,39 +102,44 @@ export default function OrgRolesTagField<TFieldValues extends FieldValues>({
|
||||
return;
|
||||
}
|
||||
|
||||
if (next.length === 0) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("accessRoleErrorAdd"),
|
||||
description: t("accessRoleSelectPlease")
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
form.setValue(name, next as never, { shouldDirty: true });
|
||||
|
||||
if (next.length > 0 && !isPopoverOpenRef.current) {
|
||||
lastValidRolesRef.current = next;
|
||||
} else if (!isPopoverOpenRef.current) {
|
||||
validateRolesSelection();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={name}
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-col items-start">
|
||||
<FormLabel>{label ?? t("roles")}</FormLabel>
|
||||
<FormControl>
|
||||
<RolesSelector
|
||||
orgId={orgId}
|
||||
selectedRoles={field.value ?? []}
|
||||
onSelectRoles={setRoleTags}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</FormControl>
|
||||
{showMultiRolePaywallMessage && (
|
||||
<FormDescription>{paywallMessage}</FormDescription>
|
||||
)}
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
render={({ field }) => {
|
||||
const selectedRoles = (field.value ?? []) as SelectedRole[];
|
||||
if (!isPopoverOpenRef.current && selectedRoles.length > 0) {
|
||||
lastValidRolesRef.current = selectedRoles;
|
||||
}
|
||||
|
||||
return (
|
||||
<FormItem className="flex flex-col items-start">
|
||||
<FormLabel>{label ?? t("roles")}</FormLabel>
|
||||
<FormControl>
|
||||
<RolesSelector
|
||||
orgId={orgId}
|
||||
selectedRoles={selectedRoles}
|
||||
onSelectRoles={setRoleTags}
|
||||
onPopoverOpenChange={handlePopoverOpenChange}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</FormControl>
|
||||
{showMultiRolePaywallMessage && (
|
||||
<FormDescription>{paywallMessage}</FormDescription>
|
||||
)}
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -111,6 +111,20 @@ function getActionsCategories(root: boolean) {
|
||||
[t("actionUpdateResourceRule")]: "updateResourceRule"
|
||||
},
|
||||
|
||||
"Resource Policy": {
|
||||
[t("actionGetResourcePolicy")]: "getResourcePolicy",
|
||||
[t("actionUpdateResourcePolicy")]: "updateResourcePolicy",
|
||||
[t("actionSetResourcePolicyUsers")]: "setResourcePolicyUsers",
|
||||
[t("actionSetResourcePolicyRoles")]: "setResourcePolicyRoles",
|
||||
[t("actionSetResourcePolicyPassword")]: "setResourcePolicyPassword",
|
||||
[t("actionSetResourcePolicyPincode")]: "setResourcePolicyPincode",
|
||||
[t("actionSetResourcePolicyHeaderAuth")]:
|
||||
"setResourcePolicyHeaderAuth",
|
||||
[t("actionSetResourcePolicyWhitelist")]:
|
||||
"setResourcePolicyWhitelist",
|
||||
[t("actionSetResourcePolicyRules")]: "setResourcePolicyRules"
|
||||
},
|
||||
|
||||
Client: {
|
||||
[t("actionCreateClient")]: "createClient",
|
||||
[t("actionDeleteClient")]: "deleteClient",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,8 +2,6 @@
|
||||
|
||||
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
|
||||
import CopyToClipboard from "@app/components/CopyToClipboard";
|
||||
import CreatePrivateResourceDialog from "@app/components/CreatePrivateResourceDialog";
|
||||
import EditPrivateResourceDialog from "@app/components/EditPrivateResourceDialog";
|
||||
import { ResourceAccessCertIndicator } from "@app/components/ResourceAccessCertIndicator";
|
||||
import {
|
||||
ResourceSitesStatusCell,
|
||||
@@ -34,12 +32,14 @@ import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||
import { cn } from "@app/lib/cn";
|
||||
import { dataTableFilterPopoverContentClassName } from "@app/lib/dataTableFilterPopover";
|
||||
import { formatSiteResourceDestinationDisplay } from "@app/lib/formatSiteResourceAccess";
|
||||
import { getPrivateResourceSettingsHref } from "@app/lib/launcherResourceAdminHref";
|
||||
import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn";
|
||||
import { build } from "@server/build";
|
||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
import type { PaginationState } from "@tanstack/react-table";
|
||||
import {
|
||||
ArrowDown01Icon,
|
||||
ArrowRight,
|
||||
ArrowUp10Icon,
|
||||
ArrowUpDown,
|
||||
ChevronsUpDownIcon,
|
||||
@@ -47,6 +47,7 @@ import {
|
||||
MoreHorizontal
|
||||
} from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { startTransition, useMemo, useState, useTransition } from "react";
|
||||
import { useDebouncedCallback } from "use-debounce";
|
||||
@@ -78,6 +79,7 @@ export type InternalResourceRow = {
|
||||
alias: string | null;
|
||||
aliasAddress: string | null;
|
||||
niceId: string;
|
||||
enabled: boolean;
|
||||
tcpPortRangeString: string | null;
|
||||
udpPortRangeString: string | null;
|
||||
disableIcmp: boolean;
|
||||
@@ -138,6 +140,7 @@ export default function PrivateResourcesTable({
|
||||
const api = createApiClient({ env });
|
||||
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
const [isNavigatingToAddPage, startNavigation] = useTransition();
|
||||
|
||||
const [selectedInternalResource, setSelectedInternalResource] =
|
||||
useState<InternalResourceRow | null>(null);
|
||||
@@ -149,7 +152,6 @@ export default function PrivateResourcesTable({
|
||||
const [isRefreshing, startRefreshTransition] = useTransition();
|
||||
|
||||
const { isPaidUser } = usePaidStatus();
|
||||
const isLabelFeatureEnabled = isPaidUser(tierMatrix.labels);
|
||||
|
||||
// useEffect(() => {
|
||||
// const interval = setInterval(() => {
|
||||
@@ -184,11 +186,11 @@ export default function PrivateResourcesTable({
|
||||
});
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(t("resourceErrorDelete"), e);
|
||||
console.error(t("resourceErrorDelte"), e);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("resourceErrorDelte"),
|
||||
description: formatAxiosError(e, t("v"))
|
||||
description: formatAxiosError(e, t("resourceErrorDelte"))
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -427,6 +429,27 @@ export default function PrivateResourcesTable({
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "labels",
|
||||
accessorKey: "labels",
|
||||
header: () => (
|
||||
<LabelColumnFilterButton
|
||||
orgId={orgId}
|
||||
selectedValues={searchParams.getAll("labels")}
|
||||
onSelectedValuesChange={(value) =>
|
||||
handleFilterChange("labels", value)
|
||||
}
|
||||
label={t("labels")}
|
||||
className="p-3"
|
||||
/>
|
||||
),
|
||||
cell: ({ row }: { row: { original: InternalResourceRow } }) => (
|
||||
<ClientResourceLabelCell
|
||||
resource={row.original}
|
||||
orgId={orgId}
|
||||
/>
|
||||
)
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
enableHiding: false,
|
||||
@@ -448,6 +471,17 @@ export default function PrivateResourcesTable({
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<Link
|
||||
className="block w-full"
|
||||
href={getPrivateResourceSettingsHref(
|
||||
resourceRow.orgId,
|
||||
resourceRow.niceId
|
||||
)}
|
||||
>
|
||||
<DropdownMenuItem>
|
||||
{t("viewSettings")}
|
||||
</DropdownMenuItem>
|
||||
</Link>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setSelectedInternalResource(
|
||||
@@ -462,47 +496,25 @@ export default function PrivateResourcesTable({
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Button
|
||||
variant={"outline"}
|
||||
onClick={() => {
|
||||
setEditingResource(resourceRow);
|
||||
setIsEditDialogOpen(true);
|
||||
}}
|
||||
<Link
|
||||
href={getPrivateResourceSettingsHref(
|
||||
resourceRow.orgId,
|
||||
resourceRow.niceId
|
||||
)}
|
||||
>
|
||||
{t("edit")}
|
||||
</Button>
|
||||
<Button variant={"outline"}>
|
||||
{t("edit")}
|
||||
<ArrowRight className="ml-2 w-4 h-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
if (isLabelFeatureEnabled) {
|
||||
cols.splice(cols.length - 1, 0, {
|
||||
id: "labels",
|
||||
accessorKey: "labels",
|
||||
header: () => (
|
||||
<LabelColumnFilterButton
|
||||
orgId={orgId}
|
||||
selectedValues={searchParams.getAll("labels")}
|
||||
onSelectedValuesChange={(value) =>
|
||||
handleFilterChange("labels", value)
|
||||
}
|
||||
label={t("labels")}
|
||||
className="p-3"
|
||||
/>
|
||||
),
|
||||
cell: ({ row }: { row: { original: InternalResourceRow } }) => (
|
||||
<ClientResourceLabelCell
|
||||
resource={row.original}
|
||||
orgId={orgId}
|
||||
/>
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
return cols;
|
||||
}, [isLabelFeatureEnabled, orgId, t, searchParams]);
|
||||
}, [orgId, t, searchParams]);
|
||||
|
||||
function handleFilterChange(
|
||||
column: string,
|
||||
@@ -578,8 +590,15 @@ export default function PrivateResourcesTable({
|
||||
tableId="internal-resources"
|
||||
searchPlaceholder={t("resourcesSearch")}
|
||||
searchQuery={searchParams.get("query")?.toString()}
|
||||
onAdd={() => setIsCreateDialogOpen(true)}
|
||||
onAdd={() =>
|
||||
startNavigation(() =>
|
||||
router.push(
|
||||
`/${orgId}/settings/resources/private/create`
|
||||
)
|
||||
)
|
||||
}
|
||||
addButtonText={t("resourceAdd")}
|
||||
isNavigatingToAddPage={isNavigatingToAddPage}
|
||||
onSearch={handleSearchChange}
|
||||
onRefresh={refreshData}
|
||||
onPaginationChange={handlePaginationChange}
|
||||
@@ -595,34 +614,6 @@ export default function PrivateResourcesTable({
|
||||
stickyLeftColumn="name"
|
||||
stickyRightColumn="actions"
|
||||
/>
|
||||
|
||||
{editingResource && (
|
||||
<EditPrivateResourceDialog
|
||||
open={isEditDialogOpen}
|
||||
setOpen={setIsEditDialogOpen}
|
||||
resource={editingResource}
|
||||
orgId={orgId}
|
||||
onSuccess={() => {
|
||||
// Delay refresh to allow modal to close smoothly
|
||||
setTimeout(() => {
|
||||
router.refresh();
|
||||
setEditingResource(null);
|
||||
}, 150);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<CreatePrivateResourceDialog
|
||||
open={isCreateDialogOpen}
|
||||
setOpen={setIsCreateDialogOpen}
|
||||
orgId={orgId}
|
||||
onSuccess={() => {
|
||||
// Delay refresh to allow modal to close smoothly
|
||||
setTimeout(() => {
|
||||
router.refresh();
|
||||
}, 150);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
type ProductUpdate,
|
||||
productUpdatesQueries
|
||||
} from "@app/lib/queries";
|
||||
import { build } from "@server/build";
|
||||
import { useQueries } from "@tanstack/react-query";
|
||||
import {
|
||||
ArrowRight,
|
||||
@@ -39,22 +40,42 @@ export default function ProductUpdates({
|
||||
}) {
|
||||
const { env } = useEnvContext();
|
||||
|
||||
const productUpdatesEnabled = env.app.notifications.product_updates;
|
||||
const versionCheckEnabled =
|
||||
env.app.notifications.new_releases && build !== "saas";
|
||||
|
||||
const data = useQueries({
|
||||
queries: [
|
||||
productUpdatesQueries.list(
|
||||
env.app.notifications.product_updates,
|
||||
env.app.version
|
||||
),
|
||||
productUpdatesQueries.list(productUpdatesEnabled, env.app.version),
|
||||
productUpdatesQueries.latestVersion(
|
||||
env.app.notifications.new_releases
|
||||
)
|
||||
],
|
||||
combine(result) {
|
||||
if (result[0].isLoading || result[1].isLoading) return null;
|
||||
return {
|
||||
updates: result[0].data?.data ?? [],
|
||||
latestVersion: result[1].data
|
||||
};
|
||||
const [updatesQuery, versionQuery] = result;
|
||||
|
||||
const updatesSettled =
|
||||
!productUpdatesEnabled ||
|
||||
updatesQuery.isFetched ||
|
||||
updatesQuery.isError;
|
||||
const versionSettled =
|
||||
!versionCheckEnabled ||
|
||||
versionQuery.isFetched ||
|
||||
versionQuery.isError;
|
||||
|
||||
if (!updatesSettled || !versionSettled) return null;
|
||||
|
||||
const updates = updatesQuery.isError
|
||||
? []
|
||||
: Array.isArray(updatesQuery.data?.data)
|
||||
? updatesQuery.data.data
|
||||
: [];
|
||||
|
||||
const latestVersion = versionQuery.isError
|
||||
? undefined
|
||||
: versionQuery.data;
|
||||
|
||||
return { updates, latestVersion };
|
||||
}
|
||||
});
|
||||
const t = useTranslations();
|
||||
@@ -76,19 +97,30 @@ export default function ProductUpdates({
|
||||
|
||||
if (!data) return null;
|
||||
|
||||
const latestVersion = data?.latestVersion?.data?.pangolin.latestVersion;
|
||||
const versionResponse = data.latestVersion?.data;
|
||||
const latestVersion = versionResponse?.pangolin?.latestVersion;
|
||||
const currentVersion = env.app.version;
|
||||
|
||||
const showNewVersionPopup = Boolean(
|
||||
let showNewVersionPopup = false;
|
||||
if (
|
||||
latestVersion &&
|
||||
valid(latestVersion) &&
|
||||
valid(currentVersion) &&
|
||||
ignoredVersionUpdate !== latestVersion &&
|
||||
gt(latestVersion, currentVersion)
|
||||
);
|
||||
valid(latestVersion) &&
|
||||
valid(currentVersion) &&
|
||||
ignoredVersionUpdate !== latestVersion
|
||||
) {
|
||||
try {
|
||||
showNewVersionPopup = gt(latestVersion, currentVersion);
|
||||
} catch {
|
||||
showNewVersionPopup = false;
|
||||
}
|
||||
}
|
||||
|
||||
const readUpdateIds = Array.isArray(productUpdatesRead)
|
||||
? productUpdatesRead
|
||||
: [];
|
||||
|
||||
const filteredUpdates = data.updates.filter(
|
||||
(update) => !productUpdatesRead.includes(update.id)
|
||||
(update) => !readUpdateIds.includes(update.id)
|
||||
);
|
||||
|
||||
if (filteredUpdates.length === 0 && !showNewVersionPopup) {
|
||||
@@ -133,17 +165,14 @@ export default function ProductUpdates({
|
||||
show={filteredUpdates.length > 0}
|
||||
onDimissAll={() =>
|
||||
setProductUpdatesRead([
|
||||
...productUpdatesRead,
|
||||
...readUpdateIds,
|
||||
...filteredUpdates.map(
|
||||
(update) => update.id
|
||||
)
|
||||
])
|
||||
}
|
||||
onDimiss={(id) =>
|
||||
setProductUpdatesRead([
|
||||
...productUpdatesRead,
|
||||
id
|
||||
])
|
||||
setProductUpdatesRead([...readUpdateIds, id])
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
@@ -151,11 +180,9 @@ export default function ProductUpdates({
|
||||
</div>
|
||||
|
||||
<NewVersionAvailable
|
||||
version={data.latestVersion?.data}
|
||||
version={versionResponse}
|
||||
onDimiss={() => {
|
||||
setIgnoredVersionUpdate(
|
||||
data.latestVersion?.data?.pangolin.latestVersion ?? null
|
||||
);
|
||||
setIgnoredVersionUpdate(latestVersion ?? null);
|
||||
}}
|
||||
show={showNewVersionPopup}
|
||||
/>
|
||||
@@ -346,6 +373,10 @@ function NewVersionAvailable({
|
||||
}
|
||||
}, [show]);
|
||||
|
||||
if (!version?.pangolin?.latestVersion) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Transition show={open}>
|
||||
{version && (
|
||||
|
||||
@@ -151,7 +151,6 @@ export default function PublicResourcesTable({
|
||||
useState<ResourceRow | null>();
|
||||
|
||||
const { isPaidUser } = usePaidStatus();
|
||||
const isLabelFeatureEnabled = isPaidUser(tierMatrix.labels);
|
||||
|
||||
const [isRefreshing, startTransition] = useTransition();
|
||||
const [isNavigatingToAddPage, startNavigation] = useTransition();
|
||||
@@ -552,6 +551,24 @@ export default function PublicResourcesTable({
|
||||
/>
|
||||
)
|
||||
},
|
||||
{
|
||||
id: "labels",
|
||||
accessorKey: "labels",
|
||||
header: () => (
|
||||
<LabelColumnFilterButton
|
||||
orgId={orgId}
|
||||
selectedValues={searchParams.getAll("labels")}
|
||||
onSelectedValuesChange={(value) =>
|
||||
handleFilterChange("labels", value)
|
||||
}
|
||||
label={t("labels")}
|
||||
className="p-3"
|
||||
/>
|
||||
),
|
||||
cell: ({ row }: { row: { original: ResourceRow } }) => (
|
||||
<ResourceLabelCell resource={row.original} orgId={orgId} />
|
||||
)
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
enableHiding: false,
|
||||
@@ -607,29 +624,8 @@ export default function PublicResourcesTable({
|
||||
}
|
||||
];
|
||||
|
||||
if (isLabelFeatureEnabled) {
|
||||
cols.splice(cols.length - 1, 0, {
|
||||
id: "labels",
|
||||
accessorKey: "labels",
|
||||
header: () => (
|
||||
<LabelColumnFilterButton
|
||||
orgId={orgId}
|
||||
selectedValues={searchParams.getAll("labels")}
|
||||
onSelectedValuesChange={(value) =>
|
||||
handleFilterChange("labels", value)
|
||||
}
|
||||
label={t("labels")}
|
||||
className="p-3"
|
||||
/>
|
||||
),
|
||||
cell: ({ row }: { row: { original: ResourceRow } }) => (
|
||||
<ResourceLabelCell resource={row.original} orgId={orgId} />
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
return cols;
|
||||
}, [isLabelFeatureEnabled, orgId, t, searchParams]);
|
||||
}, [orgId, t, searchParams]);
|
||||
|
||||
function handleFilterChange(
|
||||
column: string,
|
||||
|
||||
@@ -6,20 +6,29 @@ import { getInternalRedirectTarget } from "@app/lib/internalRedirect";
|
||||
|
||||
type RedirectToOrgProps = {
|
||||
targetOrgId: string;
|
||||
isAdminOrOwner?: boolean;
|
||||
};
|
||||
|
||||
export default function RedirectToOrg({ targetOrgId }: RedirectToOrgProps) {
|
||||
export default function RedirectToOrg({
|
||||
targetOrgId,
|
||||
isAdminOrOwner = false
|
||||
}: RedirectToOrgProps) {
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
const target =
|
||||
getInternalRedirectTarget(targetOrgId) ?? `/${targetOrgId}`;
|
||||
getInternalRedirectTarget(targetOrgId) ??
|
||||
(isAdminOrOwner
|
||||
? `/${targetOrgId}/settings`
|
||||
: `/${targetOrgId}`);
|
||||
router.replace(target);
|
||||
} catch {
|
||||
router.replace(`/${targetOrgId}`);
|
||||
router.replace(
|
||||
isAdminOrOwner ? `/${targetOrgId}/settings` : `/${targetOrgId}`
|
||||
);
|
||||
}
|
||||
}, [targetOrgId, router]);
|
||||
}, [targetOrgId, isAdminOrOwner, router]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -90,7 +90,11 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
|
||||
</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
<span className="inline-flex items-center">
|
||||
{resource.ssl ? "HTTPS" : "HTTP"}
|
||||
{resource.mode == "http"
|
||||
? resource.ssl
|
||||
? "HTTPS"
|
||||
: "HTTP"
|
||||
: resource.mode?.toUpperCase()}
|
||||
</span>
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
|
||||
@@ -61,7 +61,7 @@ export function parseUnixGroups(value: string | undefined): string[] {
|
||||
if (!value?.trim()) return [];
|
||||
|
||||
return value
|
||||
.split(/[,\s\n]+/)
|
||||
.split(/\r?\n/)
|
||||
.map((group) => group.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
@@ -69,18 +69,10 @@ export function parseUnixGroups(value: string | undefined): string[] {
|
||||
export function parseSudoCommands(value: string | undefined): string[] {
|
||||
if (!value?.trim()) return [];
|
||||
|
||||
const commands: string[] = [];
|
||||
for (const segment of value.split(/[,\n]+/)) {
|
||||
const trimmed = segment.trim();
|
||||
if (!trimmed) continue;
|
||||
|
||||
for (const part of trimmed.split(/ (?=\/)/)) {
|
||||
const command = part.trim();
|
||||
if (command) commands.push(command);
|
||||
}
|
||||
}
|
||||
|
||||
return commands;
|
||||
return value
|
||||
.split(/\r?\n/)
|
||||
.map((command) => command.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function hasOnlyAbsoluteSudoCommands(value: string | undefined): boolean {
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import { useMediaQuery } from "@app/hooks/useMediaQuery";
|
||||
import { cn } from "@app/lib/cn";
|
||||
import {
|
||||
Sheet,
|
||||
SheetClose,
|
||||
SheetDescription,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetOverlay,
|
||||
SheetPortal,
|
||||
SheetTitle,
|
||||
SheetTrigger
|
||||
} from "./ui/sheet";
|
||||
import * as SheetPrimitive from "@radix-ui/react-dialog";
|
||||
|
||||
type BaseProps = {
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
type RootSidePanelProps = BaseProps & {
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
};
|
||||
|
||||
type SidePanelProps = {
|
||||
className?: string;
|
||||
asChild?: true;
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
const desktop = "(min-width: 768px)";
|
||||
|
||||
const SidePanel = ({ children, ...props }: RootSidePanelProps) => {
|
||||
return <Sheet {...props}>{children}</Sheet>;
|
||||
};
|
||||
|
||||
const SidePanelTrigger = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: SidePanelProps) => {
|
||||
return (
|
||||
<SheetTrigger className={className} {...props}>
|
||||
{children}
|
||||
</SheetTrigger>
|
||||
);
|
||||
};
|
||||
|
||||
const SidePanelClose = ({ className, children, ...props }: SidePanelProps) => {
|
||||
return (
|
||||
<SheetClose className={className} {...props}>
|
||||
{children}
|
||||
</SheetClose>
|
||||
);
|
||||
};
|
||||
|
||||
const SidePanelContent = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: SidePanelProps) => {
|
||||
const isDesktop = useMediaQuery(desktop);
|
||||
|
||||
return (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<SheetPrimitive.Content
|
||||
className={cn(
|
||||
"fixed z-50 flex min-h-0 flex-col gap-4 overflow-hidden border bg-card px-6 pt-6 pb-1 shadow-lg transition ease-in-out",
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out",
|
||||
"data-[state=open]:fade-in-0 data-[state=closed]:fade-out-0",
|
||||
"data-[state=closed]:duration-200 data-[state=open]:duration-300",
|
||||
isDesktop
|
||||
? "inset-y-0 right-0 h-full w-2/5 border-l"
|
||||
: "inset-x-0 bottom-0 max-h-[85dvh] w-full border-t",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
{children}
|
||||
</SheetPrimitive.Content>
|
||||
</SheetPortal>
|
||||
);
|
||||
};
|
||||
|
||||
const SidePanelDescription = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: SidePanelProps) => {
|
||||
return (
|
||||
<SheetDescription className={className} {...props}>
|
||||
{children}
|
||||
</SheetDescription>
|
||||
);
|
||||
};
|
||||
|
||||
const SidePanelHeader = ({ className, children, ...props }: SidePanelProps) => {
|
||||
return (
|
||||
<SheetHeader
|
||||
className={cn("shrink-0 -mx-6 px-6", className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</SheetHeader>
|
||||
);
|
||||
};
|
||||
|
||||
const SidePanelTitle = ({ className, children, ...props }: SidePanelProps) => {
|
||||
return (
|
||||
<SheetTitle className={className} {...props}>
|
||||
{children}
|
||||
</SheetTitle>
|
||||
);
|
||||
};
|
||||
|
||||
const SidePanelBody = ({ className, children, ...props }: SidePanelProps) => {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative min-h-0 min-w-0 flex-1 overflow-y-auto overflow-x-hidden px-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="space-y-4">{children}</div>
|
||||
<div
|
||||
className="sticky bottom-0 left-0 right-0 h-8 pointer-events-none bg-gradient-to-t from-card to-transparent"
|
||||
aria-hidden
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const SidePanelFooter = ({ className, children, ...props }: SidePanelProps) => {
|
||||
return (
|
||||
<SheetFooter
|
||||
className={cn(
|
||||
"-mt-4 shrink-0 border-t border-border py-4 -mx-6 gap-2 px-6 bg-card",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</SheetFooter>
|
||||
);
|
||||
};
|
||||
|
||||
export {
|
||||
SidePanel,
|
||||
SidePanelBody,
|
||||
SidePanelClose,
|
||||
SidePanelContent,
|
||||
SidePanelDescription,
|
||||
SidePanelFooter,
|
||||
SidePanelHeader,
|
||||
SidePanelTitle,
|
||||
SidePanelTrigger
|
||||
};
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
InfoSectionTitle
|
||||
} from "@app/components/InfoSection";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { countryCodeToFlagEmoji } from "@app/lib/countryCodeToFlagEmoji";
|
||||
|
||||
type SiteInfoCardProps = {};
|
||||
|
||||
@@ -52,7 +53,11 @@ export default function SiteInfoCard({}: SiteInfoCardProps) {
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>{t("publicIpEndpoint")}</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
{formatPublicEndpoint(site.endpoint)}
|
||||
{formatPublicEndpoint(site.endpoint)}
|
||||
<span>
|
||||
{site.countryCode &&
|
||||
countryCodeToFlagEmoji(site.countryCode)}
|
||||
</span>
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
) : null;
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
"use client";
|
||||
|
||||
import CertificateStatus from "@app/components/CertificateStatus";
|
||||
import CopyToClipboard from "@app/components/CopyToClipboard";
|
||||
import {
|
||||
InfoSection,
|
||||
InfoSectionContent,
|
||||
InfoSections,
|
||||
InfoSectionTitle
|
||||
} from "@app/components/InfoSection";
|
||||
import { Alert, AlertDescription } from "@app/components/ui/alert";
|
||||
import { useSiteResourceContext } from "@app/hooks/useSiteResourceContext";
|
||||
import { formatPortRestrictionDisplay } from "@app/lib/launcherResourceDetails";
|
||||
import {
|
||||
formatSiteResourceAccess,
|
||||
formatSiteResourceDestinationDisplay,
|
||||
isSafeUrlForLink,
|
||||
type LauncherAccessFields
|
||||
} from "@app/lib/launcherResourceAccess";
|
||||
import type { PrivateResourceMode } from "@app/lib/privateResourceForm";
|
||||
import { build } from "@server/build";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
type SiteResourceInfoInput = {
|
||||
orgId: string;
|
||||
mode: PrivateResourceMode;
|
||||
destination: string | null;
|
||||
destinationPort: number | null;
|
||||
scheme: "http" | "https" | null;
|
||||
ssl: boolean;
|
||||
domainId?: string | null;
|
||||
fullDomain?: string | null;
|
||||
alias?: string | null;
|
||||
aliasAddress?: string | null;
|
||||
authDaemonMode?: "site" | "remote" | "native" | null;
|
||||
tcpPortRangeString?: string | null;
|
||||
udpPortRangeString?: string | null;
|
||||
};
|
||||
|
||||
type SiteResourceInfoBoxVariant = "settings" | "panel";
|
||||
|
||||
type SiteResourceInfoSectionsProps = {
|
||||
siteResource: SiteResourceInfoInput;
|
||||
access: LauncherAccessFields;
|
||||
variant: SiteResourceInfoBoxVariant;
|
||||
accessClassName?: string;
|
||||
};
|
||||
|
||||
function AccessMethodContent({
|
||||
accessDisplay,
|
||||
accessCopyValue,
|
||||
accessUrl,
|
||||
className
|
||||
}: LauncherAccessFields & { className?: string }) {
|
||||
if (!accessDisplay) {
|
||||
return <span>-</span>;
|
||||
}
|
||||
|
||||
const href = accessUrl ?? undefined;
|
||||
const canLink = Boolean(href && isSafeUrlForLink(href));
|
||||
|
||||
if (canLink && href) {
|
||||
return (
|
||||
<CopyToClipboard
|
||||
text={href}
|
||||
displayText={accessDisplay}
|
||||
isLink
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<CopyToClipboard
|
||||
text={accessCopyValue || accessDisplay}
|
||||
displayText={accessDisplay}
|
||||
isLink={false}
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function SiteResourceInfoSections({
|
||||
siteResource,
|
||||
access,
|
||||
variant,
|
||||
accessClassName
|
||||
}: SiteResourceInfoSectionsProps) {
|
||||
const t = useTranslations();
|
||||
const isPanel = variant === "panel";
|
||||
|
||||
const modeLabel: Record<PrivateResourceMode, string> = {
|
||||
host: t("editInternalResourceDialogModeHost"),
|
||||
cidr: t("editInternalResourceDialogModeCidr"),
|
||||
http: t("editInternalResourceDialogModeHttp"),
|
||||
ssh: t("editInternalResourceDialogModeSsh")
|
||||
};
|
||||
|
||||
const destination = formatSiteResourceDestinationDisplay({
|
||||
mode: siteResource.mode,
|
||||
destination: siteResource.destination,
|
||||
destinationPort: siteResource.destinationPort,
|
||||
scheme: siteResource.scheme
|
||||
});
|
||||
|
||||
const portRestrictions = formatPortRestrictionDisplay({
|
||||
tcpPortRangeString: siteResource.tcpPortRangeString ?? "*",
|
||||
udpPortRangeString: siteResource.udpPortRangeString ?? "*"
|
||||
});
|
||||
const showAlias =
|
||||
siteResource.mode !== "cidr" && siteResource.mode !== "http";
|
||||
const showDestination = !(
|
||||
siteResource.mode === "ssh" && siteResource.authDaemonMode === "native"
|
||||
);
|
||||
const showCertificate = !!(
|
||||
siteResource.mode === "http" &&
|
||||
siteResource.ssl &&
|
||||
siteResource.domainId &&
|
||||
siteResource.fullDomain &&
|
||||
build != "oss"
|
||||
);
|
||||
|
||||
const numSections =
|
||||
2 +
|
||||
(showDestination ? 1 : 0) +
|
||||
(showAlias ? 1 : 0) +
|
||||
(showCertificate ? 1 : 0) +
|
||||
(isPanel ? 1 : 0);
|
||||
|
||||
const sections = (
|
||||
<InfoSections cols={numSections} layout={isPanel ? "panel" : "default"}>
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>{t("type")}</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
{modeLabel[siteResource.mode]}
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>{t("access")}</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
<AccessMethodContent
|
||||
accessDisplay={access.accessDisplay}
|
||||
accessCopyValue={access.accessCopyValue}
|
||||
accessUrl={access.accessUrl}
|
||||
className={accessClassName}
|
||||
/>
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
|
||||
{showDestination ? (
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>
|
||||
{t("editInternalResourceDialogDestination")}
|
||||
</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
{destination || "-"}
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
) : null}
|
||||
|
||||
{showAlias ? (
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>
|
||||
{t("editInternalResourceDialogAlias")}
|
||||
</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
{siteResource.alias?.trim() ? siteResource.alias : "-"}
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
) : null}
|
||||
|
||||
{showCertificate ? (
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>
|
||||
{t("certificateStatus", {
|
||||
defaultValue: "Certificate"
|
||||
})}
|
||||
</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
<CertificateStatus
|
||||
orgId={siteResource.orgId}
|
||||
domainId={siteResource.domainId!}
|
||||
fullDomain={siteResource.fullDomain!}
|
||||
autoFetch={true}
|
||||
showLabel={false}
|
||||
polling={true}
|
||||
/>
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
) : null}
|
||||
|
||||
{isPanel ? (
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>{t("portRestrictions")}</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
{!portRestrictions.hasNonDefaultPorts ? (
|
||||
<span>
|
||||
{t("resourceLauncherNoPortRestrictions")}
|
||||
</span>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{portRestrictions.tcp.state !== "all" ? (
|
||||
<div>
|
||||
{t("resourceLauncherTcp")}:{" "}
|
||||
{portRestrictions.tcp.state ===
|
||||
"blocked"
|
||||
? t("blocked")
|
||||
: portRestrictions.tcp.ports}
|
||||
</div>
|
||||
) : null}
|
||||
{portRestrictions.udp.state !== "all" ? (
|
||||
<div>
|
||||
{t("resourceLauncherUdp")}:{" "}
|
||||
{portRestrictions.udp.state ===
|
||||
"blocked"
|
||||
? t("blocked")
|
||||
: portRestrictions.udp.ports}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
) : null}
|
||||
</InfoSections>
|
||||
);
|
||||
|
||||
if (isPanel) {
|
||||
return sections;
|
||||
}
|
||||
|
||||
return (
|
||||
<Alert>
|
||||
<AlertDescription>{sections}</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
type SiteResourceInfoBoxProps = {
|
||||
variant?: "settings";
|
||||
};
|
||||
|
||||
export default function SiteResourceInfoBox({
|
||||
variant = "settings"
|
||||
}: SiteResourceInfoBoxProps) {
|
||||
const { siteResource } = useSiteResourceContext();
|
||||
|
||||
const access = formatSiteResourceAccess({
|
||||
mode: siteResource.mode,
|
||||
destination: siteResource.destination,
|
||||
destinationPort: siteResource.destinationPort,
|
||||
scheme: siteResource.scheme,
|
||||
ssl: siteResource.ssl,
|
||||
fullDomain: siteResource.fullDomain ?? null,
|
||||
alias: siteResource.alias ?? null,
|
||||
aliasAddress: siteResource.aliasAddress ?? null
|
||||
});
|
||||
|
||||
return (
|
||||
<SiteResourceInfoSections
|
||||
siteResource={siteResource}
|
||||
access={access}
|
||||
variant={variant}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { SettingsContainer } from "@app/components/Settings";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import { formatSiteResourceDestinationDisplay } from "@app/lib/formatSiteResourceAccess";
|
||||
import { getPrivateResourceSettingsHref } from "@app/lib/launcherResourceAdminHref";
|
||||
import type { ListAllSiteResourcesByOrgResponse } from "@server/routers/siteResource";
|
||||
import type { ListResourcesResponse } from "@server/routers/resource";
|
||||
import type ResponseT from "@server/types/Response";
|
||||
@@ -432,19 +433,13 @@ export default function SiteResourcesOverview({
|
||||
editHref: `/${orgId}/settings/resources/public/${r.niceId}`
|
||||
}));
|
||||
|
||||
const privateRows = privateList.map((row) => {
|
||||
const qs = new URLSearchParams({
|
||||
siteId: String(siteId),
|
||||
query: row.niceId
|
||||
});
|
||||
return {
|
||||
key: row.siteResourceId,
|
||||
meta: <PrivateResourceMeta row={row} />,
|
||||
name: row.name,
|
||||
access: <PrivateAccessMethod row={row} />,
|
||||
editHref: `/${orgId}/settings/resources/private?${qs.toString()}`
|
||||
};
|
||||
});
|
||||
const privateRows = privateList.map((row) => ({
|
||||
key: row.siteResourceId,
|
||||
meta: <PrivateResourceMeta row={row} />,
|
||||
name: row.name,
|
||||
access: <PrivateAccessMethod row={row} />,
|
||||
editHref: getPrivateResourceSettingsHref(orgId, row.niceId)
|
||||
}));
|
||||
|
||||
if (showEmptyPlaceholder) {
|
||||
return (
|
||||
|
||||
+139
-32
@@ -19,6 +19,7 @@ import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger
|
||||
} from "@app/components/ui/dropdown-menu";
|
||||
import { InfoPopup } from "@app/components/ui/info-popup";
|
||||
@@ -52,9 +53,11 @@ import {
|
||||
|
||||
import { useOptimisticLabels } from "@app/hooks/useOptimisticLabels";
|
||||
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
import { LabelColumnFilterButton } from "./LabelColumnFilterButton";
|
||||
import { LabelsTableCell } from "./LabelsTableCell";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { productUpdatesQueries } from "@app/lib/queries";
|
||||
import semver from "semver";
|
||||
|
||||
export type SiteRow = {
|
||||
id: number;
|
||||
@@ -101,18 +104,25 @@ export default function SitesTable({
|
||||
} = useNavigationContext();
|
||||
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
const [deleteWithResources, setDeleteWithResources] = useState(false);
|
||||
const [selectedSite, setSelectedSite] = useState<SiteRow | null>(null);
|
||||
const [restartingSite, setRestartingSite] = useState<SiteRow | null>(null);
|
||||
const [resourcesDialogSite, setResourcesDialogSite] =
|
||||
useState<SiteRow | null>(null);
|
||||
const [isRefreshing, startTransition] = useTransition();
|
||||
const [isNavigatingToAddPage, startNavigation] = useTransition();
|
||||
|
||||
const { isPaidUser } = usePaidStatus();
|
||||
const isLabelFeatureEnabled = isPaidUser(tierMatrix.labels);
|
||||
|
||||
const api = createApiClient(useEnvContext());
|
||||
const t = useTranslations();
|
||||
|
||||
const { data: latestVersions } = useQuery(
|
||||
productUpdatesQueries.latestVersion(true)
|
||||
);
|
||||
|
||||
const latestNewtVersion = latestVersions?.data?.newt?.latestVersion;
|
||||
|
||||
const booleanSearchFilterSchema = z
|
||||
.enum(["true", "false"])
|
||||
.optional()
|
||||
@@ -148,10 +158,33 @@ export default function SitesTable({
|
||||
});
|
||||
}
|
||||
|
||||
function deleteSite(siteId: number) {
|
||||
async function restartSite(siteId: number) {
|
||||
try {
|
||||
await api.post(`/site/${siteId}/restart`);
|
||||
toast({
|
||||
title: t("siteRestarted"),
|
||||
description: t("siteRestartedDescription")
|
||||
});
|
||||
} catch (e) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("siteErrorRestart"),
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
t("siteErrorRestartDescription")
|
||||
)
|
||||
});
|
||||
} finally {
|
||||
setRestartingSite(null);
|
||||
}
|
||||
}
|
||||
|
||||
function deleteSite(siteId: number, withResources: boolean) {
|
||||
startTransition(async () => {
|
||||
await api
|
||||
.delete(`/site/${siteId}`)
|
||||
.delete(`/site/${siteId}`, {
|
||||
params: { deleteResources: withResources }
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error(t("siteErrorDelete"), e);
|
||||
toast({
|
||||
@@ -326,6 +359,11 @@ export default function SitesTable({
|
||||
cell: ({ row }) => {
|
||||
const originalRow = row.original;
|
||||
|
||||
let updateAvailable =
|
||||
latestNewtVersion &&
|
||||
originalRow.newtVersion &&
|
||||
semver.lt(originalRow.newtVersion, latestNewtVersion);
|
||||
|
||||
if (originalRow.type === "newt") {
|
||||
return (
|
||||
<div className="flex items-center space-x-1">
|
||||
@@ -339,7 +377,7 @@ export default function SitesTable({
|
||||
)}
|
||||
</div>
|
||||
</Badge>
|
||||
{originalRow.newtUpdateAvailable && (
|
||||
{updateAvailable && (
|
||||
<InfoPopup
|
||||
info={t("newtUpdateAvailableInfo")}
|
||||
/>
|
||||
@@ -460,6 +498,23 @@ export default function SitesTable({
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "labels",
|
||||
header: () => (
|
||||
<LabelColumnFilterButton
|
||||
orgId={orgId}
|
||||
selectedValues={searchParams.getAll("labels")}
|
||||
onSelectedValuesChange={(value) =>
|
||||
handleFilterChange("labels", value)
|
||||
}
|
||||
label={t("labels")}
|
||||
className="p-3"
|
||||
/>
|
||||
),
|
||||
cell: ({ row }: { row: { original: SiteRow } }) => (
|
||||
<SiteLabelCell site={row.original} orgId={orgId} />
|
||||
)
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
enableHiding: false,
|
||||
@@ -507,16 +562,47 @@ export default function SitesTable({
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
</Link>
|
||||
<DropdownMenuSeparator />
|
||||
{siteRow.type === "newt" && (
|
||||
<>
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
setRestartingSite(siteRow)
|
||||
}
|
||||
>
|
||||
<span className="text-orange-500">
|
||||
{t("siteRestartButton")}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setSelectedSite(siteRow);
|
||||
setDeleteWithResources(false);
|
||||
setIsDeleteModalOpen(true);
|
||||
}}
|
||||
>
|
||||
<span className="text-red-500">
|
||||
{t("delete")}
|
||||
{t("sitesTableDeleteSite")}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
{siteRow.resourceCount <= 250 && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setSelectedSite(siteRow);
|
||||
setDeleteWithResources(true);
|
||||
setIsDeleteModalOpen(true);
|
||||
}}
|
||||
>
|
||||
<span className="text-red-500">
|
||||
{t(
|
||||
"sitesTableDeleteSiteAndResources"
|
||||
)}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Link
|
||||
@@ -533,28 +619,8 @@ export default function SitesTable({
|
||||
}
|
||||
];
|
||||
|
||||
if (isLabelFeatureEnabled) {
|
||||
cols.splice(cols.length - 1, 0, {
|
||||
accessorKey: "labels",
|
||||
header: () => (
|
||||
<LabelColumnFilterButton
|
||||
orgId={orgId}
|
||||
selectedValues={searchParams.getAll("labels")}
|
||||
onSelectedValuesChange={(value) =>
|
||||
handleFilterChange("labels", value)
|
||||
}
|
||||
label={t("labels")}
|
||||
className="p-3"
|
||||
/>
|
||||
),
|
||||
cell: ({ row }: { row: { original: SiteRow } }) => (
|
||||
<SiteLabelCell site={row.original} orgId={orgId} />
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
return cols;
|
||||
}, [isLabelFeatureEnabled, orgId, t, searchParams]);
|
||||
}, [orgId, t, searchParams, latestNewtVersion]);
|
||||
|
||||
function toggleSort(column: string) {
|
||||
const newSearch = getNextSortOrder(column, searchParams);
|
||||
@@ -619,25 +685,66 @@ export default function SitesTable({
|
||||
</CredenzaContent>
|
||||
</Credenza>
|
||||
|
||||
{restartingSite && (
|
||||
<ConfirmDeleteDialog
|
||||
open={Boolean(restartingSite)}
|
||||
setOpen={(val) => {
|
||||
if (!val) setRestartingSite(null);
|
||||
}}
|
||||
dialog={
|
||||
<p>
|
||||
{t.rich("siteRestartDialogMessage", {
|
||||
name: restartingSite.name,
|
||||
b: (chunks) => <b>{chunks}</b>
|
||||
})}
|
||||
</p>
|
||||
}
|
||||
buttonText={t("siteRestartButton")}
|
||||
onConfirm={() => restartSite(restartingSite.id)}
|
||||
string={restartingSite.name}
|
||||
warningText={t("siteRestartWarning")}
|
||||
title={t("siteRestartTitle")}
|
||||
/>
|
||||
)}
|
||||
|
||||
{selectedSite && (
|
||||
<ConfirmDeleteDialog
|
||||
open={isDeleteModalOpen}
|
||||
setOpen={(val) => {
|
||||
setIsDeleteModalOpen(val);
|
||||
setSelectedSite(null);
|
||||
setDeleteWithResources(false);
|
||||
}}
|
||||
dialog={
|
||||
<div className="space-y-2">
|
||||
<p>{t("siteQuestionRemove")}</p>
|
||||
<p>{t("siteMessageRemove")}</p>
|
||||
<p>
|
||||
{deleteWithResources
|
||||
? t("siteQuestionRemoveAndResources")
|
||||
: t("siteQuestionRemove")}
|
||||
</p>
|
||||
<p>
|
||||
{deleteWithResources
|
||||
? t("siteMessageRemoveAndResources")
|
||||
: t("siteMessageRemove")}
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
buttonText={t("siteConfirmDelete")}
|
||||
buttonText={
|
||||
deleteWithResources
|
||||
? t("siteConfirmDeleteAndResources")
|
||||
: t("siteConfirmDelete")
|
||||
}
|
||||
onConfirm={async () =>
|
||||
startTransition(() => deleteSite(selectedSite!.id))
|
||||
startTransition(() =>
|
||||
deleteSite(selectedSite!.id, deleteWithResources)
|
||||
)
|
||||
}
|
||||
string={selectedSite.name}
|
||||
title={t("siteDelete")}
|
||||
title={
|
||||
deleteWithResources
|
||||
? t("siteDeleteAndResources")
|
||||
: t("siteDelete")
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
"use client";
|
||||
|
||||
import { SettingsFormCell } from "@app/components/Settings";
|
||||
import {
|
||||
StrategySelect,
|
||||
type StrategyOption
|
||||
} from "@app/components/StrategySelect";
|
||||
import { Badge } from "@app/components/ui/badge";
|
||||
import { Input } from "@app/components/ui/input";
|
||||
import { Label } from "@app/components/ui/label";
|
||||
import { ExternalLink } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useMemo } from "react";
|
||||
|
||||
export type SshServerSettingsFormFields = {
|
||||
pamMode: "passthrough" | "push";
|
||||
standardDaemonLocation: "site" | "remote";
|
||||
authDaemonPort: string;
|
||||
};
|
||||
|
||||
type SshServerSettingsFieldsProps = {
|
||||
pamMode: "passthrough" | "push";
|
||||
standardDaemonLocation: "site" | "remote";
|
||||
authDaemonPort: string;
|
||||
onPamModeChange: (value: "passthrough" | "push") => void;
|
||||
onStandardDaemonLocationChange: (value: "site" | "remote") => void;
|
||||
onAuthDaemonPortChange: (value: string) => void;
|
||||
authDaemonPortError?: string;
|
||||
sshServerMode: "standard" | "native";
|
||||
serverModeDisplay: "badge" | "select";
|
||||
onServerModeChange?: (mode: "standard" | "native") => void;
|
||||
sshServerModeOptions?: StrategyOption<"standard" | "native">[];
|
||||
idPrefix?: string;
|
||||
};
|
||||
|
||||
export function SshServerSettingsFields({
|
||||
pamMode,
|
||||
standardDaemonLocation,
|
||||
authDaemonPort,
|
||||
onPamModeChange,
|
||||
onStandardDaemonLocationChange,
|
||||
onAuthDaemonPortChange,
|
||||
authDaemonPortError,
|
||||
sshServerMode,
|
||||
serverModeDisplay,
|
||||
onServerModeChange,
|
||||
sshServerModeOptions,
|
||||
idPrefix = "ssh-server"
|
||||
}: SshServerSettingsFieldsProps) {
|
||||
const t = useTranslations();
|
||||
const isNative = sshServerMode === "native";
|
||||
const showDaemonLocation = !isNative && pamMode === "push";
|
||||
const showDaemonPort =
|
||||
!isNative && pamMode === "push" && standardDaemonLocation === "remote";
|
||||
|
||||
const authMethodOptions = useMemo(
|
||||
(): StrategyOption<"passthrough" | "push">[] => [
|
||||
{
|
||||
id: "passthrough",
|
||||
title: t("sshAuthMethodManual"),
|
||||
description: t("sshAuthMethodManualDescription")
|
||||
},
|
||||
{
|
||||
id: "push",
|
||||
title: t("sshAuthMethodAutomated"),
|
||||
description: t("sshAuthMethodAutomatedDescription")
|
||||
}
|
||||
],
|
||||
[t]
|
||||
);
|
||||
|
||||
const daemonLocationOptions = useMemo(
|
||||
(): StrategyOption<"site" | "remote">[] => [
|
||||
{
|
||||
id: "site",
|
||||
title: t("internalResourceAuthDaemonSite"),
|
||||
description: t("sshDaemonLocationSiteDescription")
|
||||
},
|
||||
{
|
||||
id: "remote",
|
||||
title: t("sshDaemonLocationRemote"),
|
||||
description: t("sshDaemonLocationRemoteDescription")
|
||||
}
|
||||
],
|
||||
[t]
|
||||
);
|
||||
|
||||
const defaultSshServerModeOptions = useMemo(
|
||||
(): StrategyOption<"standard" | "native">[] => [
|
||||
{
|
||||
id: "native",
|
||||
title: t("sshServerModePangolin"),
|
||||
description: t("sshServerModeNativeDescription")
|
||||
},
|
||||
{
|
||||
id: "standard",
|
||||
title: t("sshServerModeStandard"),
|
||||
description: t("sshServerModeStandardDescription")
|
||||
}
|
||||
],
|
||||
[t]
|
||||
);
|
||||
|
||||
const modeOptions = sshServerModeOptions ?? defaultSshServerModeOptions;
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsFormCell span="full">
|
||||
<div className="space-y-2">
|
||||
<p className="font-semibold text-sm">
|
||||
{t("sshServerMode")}
|
||||
</p>
|
||||
{serverModeDisplay === "badge" ? (
|
||||
<Badge variant="secondary">
|
||||
{sshServerMode === "standard"
|
||||
? t("sshServerModeStandard")
|
||||
: t("sshServerModePangolin")}
|
||||
</Badge>
|
||||
) : (
|
||||
<StrategySelect<"standard" | "native">
|
||||
idPrefix={`${idPrefix}-mode`}
|
||||
value={sshServerMode}
|
||||
options={modeOptions}
|
||||
onChange={(value) => onServerModeChange?.(value)}
|
||||
cols={2}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</SettingsFormCell>
|
||||
|
||||
<SettingsFormCell span="full">
|
||||
<div className="space-y-2">
|
||||
<p className="font-semibold text-sm">
|
||||
{t("sshAuthenticationMethod")}
|
||||
</p>
|
||||
<StrategySelect<"passthrough" | "push">
|
||||
idPrefix={`${idPrefix}-auth`}
|
||||
value={pamMode}
|
||||
options={authMethodOptions}
|
||||
onChange={onPamModeChange}
|
||||
cols={2}
|
||||
/>
|
||||
</div>
|
||||
</SettingsFormCell>
|
||||
|
||||
{showDaemonLocation && (
|
||||
<SettingsFormCell span="full">
|
||||
<div className="space-y-2">
|
||||
<p className="font-semibold text-sm">
|
||||
{t("sshAuthDaemonLocation")}
|
||||
</p>
|
||||
<StrategySelect<"site" | "remote">
|
||||
idPrefix={`${idPrefix}-daemon`}
|
||||
value={standardDaemonLocation}
|
||||
options={daemonLocationOptions}
|
||||
onChange={onStandardDaemonLocationChange}
|
||||
cols={2}
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("sshDaemonDisclaimer")}{" "}
|
||||
<a
|
||||
href="https://docs.pangolin.net/manage/ssh"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline inline-flex items-center gap-1"
|
||||
>
|
||||
{t("learnMore")}
|
||||
<ExternalLink className="size-3.5 shrink-0" />
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</SettingsFormCell>
|
||||
)}
|
||||
|
||||
{showDaemonPort && (
|
||||
<SettingsFormCell span="half">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor={`${idPrefix}-daemon-port`}>
|
||||
{t("sshDaemonPort")}
|
||||
</Label>
|
||||
<Input
|
||||
id={`${idPrefix}-daemon-port`}
|
||||
type="number"
|
||||
min={1}
|
||||
max={65535}
|
||||
value={authDaemonPort}
|
||||
onChange={(e) =>
|
||||
onAuthDaemonPortChange(e.target.value)
|
||||
}
|
||||
/>
|
||||
{authDaemonPortError ? (
|
||||
<p className="text-destructive text-sm">
|
||||
{authDaemonPortError}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</SettingsFormCell>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -18,6 +18,7 @@ interface StrategySelectProps<TValue extends string> {
|
||||
defaultValue?: TValue;
|
||||
onChange?: (value: TValue) => void;
|
||||
cols?: number;
|
||||
idPrefix?: string;
|
||||
}
|
||||
|
||||
export function StrategySelect<TValue extends string>({
|
||||
@@ -25,7 +26,8 @@ export function StrategySelect<TValue extends string>({
|
||||
value: controlledValue,
|
||||
defaultValue,
|
||||
onChange,
|
||||
cols = 1
|
||||
cols = 1,
|
||||
idPrefix = "strategy"
|
||||
}: StrategySelectProps<TValue>) {
|
||||
const [uncontrolledSelected, setUncontrolledSelected] = useState<
|
||||
TValue | undefined
|
||||
@@ -49,43 +51,49 @@ export function StrategySelect<TValue extends string>({
|
||||
}}
|
||||
className="grid md:grid-cols-(--cols) gap-4"
|
||||
>
|
||||
{options.map((option: StrategyOption<TValue>) => (
|
||||
<label
|
||||
key={option.id}
|
||||
htmlFor={option.id}
|
||||
data-state={
|
||||
selected === option.id ? "checked" : "unchecked"
|
||||
}
|
||||
className={cn(
|
||||
"relative flex rounded-lg border p-4 transition-colors cursor-pointer",
|
||||
option.disabled
|
||||
? "border-input text-muted-foreground cursor-not-allowed opacity-50"
|
||||
: selected === option.id
|
||||
? "border-primary bg-primary/10 text-primary"
|
||||
: "border-input hover:bg-accent"
|
||||
)}
|
||||
>
|
||||
<RadioGroupItem
|
||||
value={option.id}
|
||||
id={option.id}
|
||||
disabled={option.disabled}
|
||||
className="absolute left-4 top-5 h-4 w-4 border-primary text-primary"
|
||||
/>
|
||||
<div className="flex gap-3 pl-7">
|
||||
{option.icon && (
|
||||
<div className="mt-1">{option.icon}</div>
|
||||
{options.map((option: StrategyOption<TValue>) => {
|
||||
const optionId = `${idPrefix}-${option.id}`;
|
||||
|
||||
return (
|
||||
<label
|
||||
key={option.id}
|
||||
htmlFor={optionId}
|
||||
data-state={
|
||||
selected === option.id ? "checked" : "unchecked"
|
||||
}
|
||||
className={cn(
|
||||
"relative flex rounded-lg border p-4 transition-colors cursor-pointer",
|
||||
option.disabled
|
||||
? "border-input text-muted-foreground cursor-not-allowed opacity-50"
|
||||
: selected === option.id
|
||||
? "border-primary bg-primary/10 text-primary"
|
||||
: "border-input hover:bg-accent"
|
||||
)}
|
||||
<div className="flex-1">
|
||||
<div className="font-medium">{option.title}</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{typeof option.description === "string"
|
||||
? option.description
|
||||
: option.description}
|
||||
>
|
||||
<RadioGroupItem
|
||||
value={option.id}
|
||||
id={optionId}
|
||||
disabled={option.disabled}
|
||||
className="absolute left-4 top-5 h-4 w-4 border-primary text-primary"
|
||||
/>
|
||||
<div className="flex gap-3 pl-7">
|
||||
{option.icon && (
|
||||
<div className="mt-1">{option.icon}</div>
|
||||
)}
|
||||
<div className="flex-1">
|
||||
<div className="font-medium">
|
||||
{option.title}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{typeof option.description === "string"
|
||||
? option.description
|
||||
: option.description}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</RadioGroup>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,33 +9,34 @@ export default function SupporterMessage({ tier }: { tier: string }) {
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
<div className="relative flex items-center space-x-2 whitespace-nowrap group">
|
||||
<span
|
||||
className="cursor-pointer"
|
||||
onClick={(e) => {
|
||||
// Get the bounding box of the element
|
||||
const rect = (
|
||||
e.target as HTMLElement
|
||||
).getBoundingClientRect();
|
||||
<></>
|
||||
// <div className="relative flex items-center space-x-2 whitespace-nowrap group">
|
||||
// <span
|
||||
// className="cursor-pointer"
|
||||
// onClick={(e) => {
|
||||
// // Get the bounding box of the element
|
||||
// const rect = (
|
||||
// e.target as HTMLElement
|
||||
// ).getBoundingClientRect();
|
||||
|
||||
// Trigger confetti centered on the word "Pangolin"
|
||||
confetti({
|
||||
particleCount: 100,
|
||||
spread: 70,
|
||||
origin: {
|
||||
x: (rect.left + rect.width / 2) / window.innerWidth,
|
||||
y: rect.top / window.innerHeight
|
||||
},
|
||||
colors: ["#FFA500", "#FF4500", "#FFD700"]
|
||||
});
|
||||
}}
|
||||
>
|
||||
Pangolin
|
||||
</span>
|
||||
<Star className="w-3 h-3" />
|
||||
<div className="absolute left-1/2 transform -translate-x-1/2 -top-10 hidden group-hover:block text-primary text-sm rounded-md border shadow-md px-4 py-2 pointer-events-none opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
{t("componentsSupporterMessage", { tier: tier })}
|
||||
</div>
|
||||
</div>
|
||||
// // Trigger confetti centered on the word "Pangolin"
|
||||
// confetti({
|
||||
// particleCount: 100,
|
||||
// spread: 70,
|
||||
// origin: {
|
||||
// x: (rect.left + rect.width / 2) / window.innerWidth,
|
||||
// y: rect.top / window.innerHeight
|
||||
// },
|
||||
// colors: ["#FFA500", "#FF4500", "#FFD700"]
|
||||
// });
|
||||
// }}
|
||||
// >
|
||||
// Pangolin
|
||||
// </span>
|
||||
// <Star className="w-3 h-3" />
|
||||
// <div className="absolute left-1/2 transform -translate-x-1/2 -top-10 hidden group-hover:block text-primary text-sm rounded-md border shadow-md px-4 py-2 pointer-events-none opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
// {t("componentsSupporterMessage", { tier: tier })}
|
||||
// </div>
|
||||
// </div>
|
||||
);
|
||||
}
|
||||
|
||||
+110
-110
@@ -3,7 +3,7 @@
|
||||
// THIS IS DEPRECATED AND IS NO LONGER SHOWED TO THE USER WITH THE DISCONTINUATION
|
||||
// OF THE SUPPORTER PROGRAM. IT MAY BE REMOVED IN A FUTURE UPDATE.
|
||||
|
||||
import { useSupporterStatusContext } from "@app/hooks/useSupporterStatusContext";
|
||||
// import { useSupporterStatusContext } from "@app/hooks/useSupporterStatusContext";
|
||||
import { useState, useTransition } from "react";
|
||||
import {
|
||||
Tooltip,
|
||||
@@ -58,134 +58,134 @@ interface SupporterStatusProps {
|
||||
export default function SupporterStatus({
|
||||
isCollapsed = false
|
||||
}: SupporterStatusProps) {
|
||||
const { supporterStatus, updateSupporterStatus } =
|
||||
useSupporterStatusContext();
|
||||
const [supportOpen, setSupportOpen] = useState(false);
|
||||
const [keyOpen, setKeyOpen] = useState(false);
|
||||
const [purchaseOptionsOpen, setPurchaseOptionsOpen] = useState(false);
|
||||
// const { supporterStatus, updateSupporterStatus } =
|
||||
// useSupporterStatusContext();
|
||||
// const [supportOpen, setSupportOpen] = useState(false);
|
||||
// const [keyOpen, setKeyOpen] = useState(false);
|
||||
// const [purchaseOptionsOpen, setPurchaseOptionsOpen] = useState(false);
|
||||
|
||||
const { env } = useEnvContext();
|
||||
const api = createApiClient({ env });
|
||||
const t = useTranslations();
|
||||
// const { env } = useEnvContext();
|
||||
// const api = createApiClient({ env });
|
||||
// const t = useTranslations();
|
||||
|
||||
const formSchema = z.object({
|
||||
githubUsername: z.string().nonempty({
|
||||
error: "GitHub username is required"
|
||||
}),
|
||||
key: z.string().nonempty({
|
||||
error: "Supporter key is required"
|
||||
})
|
||||
});
|
||||
// const formSchema = z.object({
|
||||
// githubUsername: z.string().nonempty({
|
||||
// error: "GitHub username is required"
|
||||
// }),
|
||||
// key: z.string().nonempty({
|
||||
// error: "Supporter key is required"
|
||||
// })
|
||||
// });
|
||||
|
||||
const form = useForm({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
githubUsername: "",
|
||||
key: ""
|
||||
}
|
||||
});
|
||||
// const form = useForm({
|
||||
// resolver: zodResolver(formSchema),
|
||||
// defaultValues: {
|
||||
// githubUsername: "",
|
||||
// key: ""
|
||||
// }
|
||||
// });
|
||||
|
||||
async function hide() {
|
||||
await api.post("/supporter-key/hide");
|
||||
// async function hide() {
|
||||
// await api.post("/supporter-key/hide");
|
||||
|
||||
updateSupporterStatus({
|
||||
visible: false
|
||||
});
|
||||
}
|
||||
// updateSupporterStatus({
|
||||
// visible: false
|
||||
// });
|
||||
// }
|
||||
|
||||
async function onSubmit(values: z.infer<typeof formSchema>) {
|
||||
try {
|
||||
const res = await api.post<
|
||||
AxiosResponse<ValidateSupporterKeyResponse>
|
||||
>("/supporter-key/validate", {
|
||||
githubUsername: values.githubUsername,
|
||||
key: values.key
|
||||
});
|
||||
// async function onSubmit(values: z.infer<typeof formSchema>) {
|
||||
// try {
|
||||
// const res = await api.post<
|
||||
// AxiosResponse<ValidateSupporterKeyResponse>
|
||||
// >("/supporter-key/validate", {
|
||||
// githubUsername: values.githubUsername,
|
||||
// key: values.key
|
||||
// });
|
||||
|
||||
const data = res.data.data;
|
||||
// const data = res.data.data;
|
||||
|
||||
if (!data || !data.valid) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("supportKeyInvalid"),
|
||||
description: t("supportKeyInvalidDescription")
|
||||
});
|
||||
return;
|
||||
}
|
||||
// if (!data || !data.valid) {
|
||||
// toast({
|
||||
// variant: "destructive",
|
||||
// title: t("supportKeyInvalid"),
|
||||
// description: t("supportKeyInvalidDescription")
|
||||
// });
|
||||
// return;
|
||||
// }
|
||||
|
||||
// Trigger the toast
|
||||
toast({
|
||||
variant: "default",
|
||||
title: t("supportKeyValid"),
|
||||
description: t("supportKeyValidDescription")
|
||||
});
|
||||
// // Trigger the toast
|
||||
// toast({
|
||||
// variant: "default",
|
||||
// title: t("supportKeyValid"),
|
||||
// description: t("supportKeyValidDescription")
|
||||
// });
|
||||
|
||||
// Fireworks-style confetti
|
||||
const duration = 5 * 1000; // 5 seconds
|
||||
const animationEnd = Date.now() + duration;
|
||||
const defaults = {
|
||||
startVelocity: 30,
|
||||
spread: 360,
|
||||
ticks: 60,
|
||||
zIndex: 0,
|
||||
colors: ["#FFA500", "#FF4500", "#FFD700"] // Orange hues
|
||||
};
|
||||
// // Fireworks-style confetti
|
||||
// const duration = 5 * 1000; // 5 seconds
|
||||
// const animationEnd = Date.now() + duration;
|
||||
// const defaults = {
|
||||
// startVelocity: 30,
|
||||
// spread: 360,
|
||||
// ticks: 60,
|
||||
// zIndex: 0,
|
||||
// colors: ["#FFA500", "#FF4500", "#FFD700"] // Orange hues
|
||||
// };
|
||||
|
||||
function randomInRange(min: number, max: number) {
|
||||
return Math.random() * (max - min) + min;
|
||||
}
|
||||
// function randomInRange(min: number, max: number) {
|
||||
// return Math.random() * (max - min) + min;
|
||||
// }
|
||||
|
||||
const interval = setInterval(() => {
|
||||
const timeLeft = animationEnd - Date.now();
|
||||
// const interval = setInterval(() => {
|
||||
// const timeLeft = animationEnd - Date.now();
|
||||
|
||||
if (timeLeft <= 0) {
|
||||
clearInterval(interval);
|
||||
return;
|
||||
}
|
||||
// if (timeLeft <= 0) {
|
||||
// clearInterval(interval);
|
||||
// return;
|
||||
// }
|
||||
|
||||
const particleCount = 50 * (timeLeft / duration);
|
||||
// const particleCount = 50 * (timeLeft / duration);
|
||||
|
||||
// Launch confetti from two random horizontal positions
|
||||
confetti({
|
||||
...defaults,
|
||||
particleCount,
|
||||
origin: {
|
||||
x: randomInRange(0.1, 0.3),
|
||||
y: Math.random() - 0.2
|
||||
}
|
||||
});
|
||||
confetti({
|
||||
...defaults,
|
||||
particleCount,
|
||||
origin: {
|
||||
x: randomInRange(0.7, 0.9),
|
||||
y: Math.random() - 0.2
|
||||
}
|
||||
});
|
||||
}, 250);
|
||||
// // Launch confetti from two random horizontal positions
|
||||
// confetti({
|
||||
// ...defaults,
|
||||
// particleCount,
|
||||
// origin: {
|
||||
// x: randomInRange(0.1, 0.3),
|
||||
// y: Math.random() - 0.2
|
||||
// }
|
||||
// });
|
||||
// confetti({
|
||||
// ...defaults,
|
||||
// particleCount,
|
||||
// origin: {
|
||||
// x: randomInRange(0.7, 0.9),
|
||||
// y: Math.random() - 0.2
|
||||
// }
|
||||
// });
|
||||
// }, 250);
|
||||
|
||||
setPurchaseOptionsOpen(false);
|
||||
setKeyOpen(false);
|
||||
// setPurchaseOptionsOpen(false);
|
||||
// setKeyOpen(false);
|
||||
|
||||
updateSupporterStatus({
|
||||
visible: false
|
||||
});
|
||||
} catch (error) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("error"),
|
||||
description: formatAxiosError(
|
||||
error,
|
||||
t("supportKeyErrorValidationDescription")
|
||||
)
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
// updateSupporterStatus({
|
||||
// visible: false
|
||||
// });
|
||||
// } catch (error) {
|
||||
// toast({
|
||||
// variant: "destructive",
|
||||
// title: t("error"),
|
||||
// description: formatAxiosError(
|
||||
// error,
|
||||
// t("supportKeyErrorValidationDescription")
|
||||
// )
|
||||
// });
|
||||
// return;
|
||||
// }
|
||||
// }
|
||||
|
||||
return (
|
||||
<>
|
||||
<Credenza
|
||||
{/* <Credenza
|
||||
open={purchaseOptionsOpen}
|
||||
onOpenChange={(val) => {
|
||||
setPurchaseOptionsOpen(val);
|
||||
@@ -469,7 +469,7 @@ export default function SupporterStatus({
|
||||
{t("supportKeyBuy")}
|
||||
</Button>
|
||||
)
|
||||
) : null}
|
||||
) : null} */}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -38,6 +38,12 @@ import { ColumnFilterButton } from "./ColumnFilterButton";
|
||||
import IdpTypeBadge from "./IdpTypeBadge";
|
||||
import { Badge } from "./ui/badge";
|
||||
import { ControlledDataTable } from "./ui/controlled-data-table";
|
||||
import {
|
||||
productUpdatesQueries,
|
||||
type LatestVersionResponse
|
||||
} from "@app/lib/queries";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import semver from "semver";
|
||||
|
||||
export type ClientRow = {
|
||||
id: number;
|
||||
@@ -100,6 +106,9 @@ export default function UserDevicesTable({
|
||||
searchParams
|
||||
} = useNavigationContext();
|
||||
const [isRefreshing, startTransition] = useTransition();
|
||||
const data = useQuery(productUpdatesQueries.latestVersion(true));
|
||||
|
||||
const latestPlatformVersions = data.data?.data;
|
||||
|
||||
const defaultUserColumnVisibility = {
|
||||
subnet: false,
|
||||
@@ -555,6 +564,37 @@ export default function UserDevicesTable({
|
||||
cell: ({ row }) => {
|
||||
const originalRow = row.original;
|
||||
|
||||
const agentVersionMap: Record<string, string> = {
|
||||
"Pangolin Windows": "windows",
|
||||
"Pangolin Android": "android",
|
||||
"Pangolin iOS": "ios",
|
||||
"Pangolin iPadOS": "ios",
|
||||
"Pangolin macOS": "mac",
|
||||
"Pangolin CLI": "cli",
|
||||
"Olm CLI": "olm"
|
||||
};
|
||||
|
||||
let updateAvailable = false;
|
||||
|
||||
if (
|
||||
originalRow.olmVersion &&
|
||||
originalRow.agent &&
|
||||
latestPlatformVersions
|
||||
) {
|
||||
const agent = agentVersionMap[
|
||||
originalRow.agent
|
||||
] as keyof LatestVersionResponse;
|
||||
|
||||
if (agent in latestPlatformVersions) {
|
||||
const agentVersion = latestPlatformVersions[agent];
|
||||
|
||||
updateAvailable = semver.lt(
|
||||
originalRow.olmVersion,
|
||||
agentVersion.latestVersion
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center space-x-1">
|
||||
{originalRow.agent && originalRow.olmVersion ? (
|
||||
@@ -567,9 +607,9 @@ export default function UserDevicesTable({
|
||||
"-"
|
||||
)}
|
||||
|
||||
{/*originalRow.olmUpdateAvailable && (
|
||||
<InfoPopup info={t("olmUpdateAvailableInfo")} />
|
||||
)*/}
|
||||
{updateAvailable && (
|
||||
<InfoPopup info={t("updateAvailableInfo")} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -714,7 +754,7 @@ export default function UserDevicesTable({
|
||||
}
|
||||
|
||||
return allOptions;
|
||||
}, [t]);
|
||||
}, [t, latestPlatformVersions]);
|
||||
|
||||
function handleFilterChange(
|
||||
column: string,
|
||||
|
||||
@@ -38,6 +38,21 @@ export type LabelsSelectorProps = {
|
||||
toggleLabel: (newlabel: SelectedLabel, action: "detach" | "attach") => void;
|
||||
};
|
||||
|
||||
export function formatLabelsSelectorLabel(
|
||||
selectedLabels: SelectedLabel[],
|
||||
t: (key: string, values?: { count: number }) => string
|
||||
): string {
|
||||
if (selectedLabels.length === 0) {
|
||||
return t("selectLabels");
|
||||
}
|
||||
if (selectedLabels.length === 1) {
|
||||
return selectedLabels[0]!.name;
|
||||
}
|
||||
return t("labelsSelectorLabelsCount", {
|
||||
count: selectedLabels.length
|
||||
});
|
||||
}
|
||||
|
||||
export const LABEL_COLORS = {
|
||||
red: "#ff6467",
|
||||
green: "#05df72",
|
||||
|
||||
@@ -12,7 +12,12 @@ import { CheckIcon } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Checkbox } from "../ui/checkbox";
|
||||
|
||||
export type TagValue = { text: string; id: string; isAdmin?: boolean };
|
||||
export type TagValue = {
|
||||
text: string;
|
||||
id: string;
|
||||
isAdmin?: boolean;
|
||||
color?: string;
|
||||
};
|
||||
|
||||
export type MultiSelectTagsProps<T extends TagValue> = {
|
||||
emptyPlaceholder?: string;
|
||||
@@ -77,6 +82,14 @@ export function MultiSelectContent<T extends TagValue>({
|
||||
aria-hidden
|
||||
tabIndex={-1}
|
||||
/>
|
||||
{option.color && (
|
||||
<span
|
||||
className="size-2 rounded-full flex-none"
|
||||
style={{
|
||||
backgroundColor: option.color
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{`${option.text}`}
|
||||
</CommandItem>
|
||||
);
|
||||
|
||||
@@ -17,11 +17,13 @@ export interface MultiSelectInputProps<
|
||||
> extends MultiSelectTagsProps<T> {
|
||||
buttonText?: string;
|
||||
lockedIds?: Set<string>;
|
||||
onPopoverOpenChange?: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function MultiSelectTagInput<T extends TagValue>({
|
||||
buttonText,
|
||||
lockedIds,
|
||||
onPopoverOpenChange,
|
||||
...props
|
||||
}: MultiSelectInputProps<T>) {
|
||||
const selectedValues = new Set(props.value.map((v) => v.id));
|
||||
@@ -33,6 +35,7 @@ export function MultiSelectTagInput<T extends TagValue>({
|
||||
// clear input when popover is closed
|
||||
props.onSearch("");
|
||||
}
|
||||
onPopoverOpenChange?.(open);
|
||||
}}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
@@ -66,7 +69,17 @@ export function MultiSelectTagInput<T extends TagValue>({
|
||||
)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<span>{option.text}</span>
|
||||
{option.color && (
|
||||
<span
|
||||
className="size-2 rounded-full flex-none ml-1"
|
||||
style={{
|
||||
backgroundColor: option.color
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<span className="max-w-40 text-ellipsis overflow-hidden">
|
||||
{option.text}
|
||||
</span>
|
||||
{isLocked ? (
|
||||
<span className="p-0.5 flex-none">
|
||||
<LockIcon className="size-3" />
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { orgQueries } from "@app/lib/queries";
|
||||
import { launcherQueries, orgQueries } from "@app/lib/queries";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
@@ -19,6 +19,9 @@ export type MultiSitesSelectorProps = {
|
||||
selectedSites: Selectedsite[];
|
||||
onSelectionChange: (sites: Selectedsite[]) => void;
|
||||
filterTypes?: string[];
|
||||
scope?: "org" | "launcher";
|
||||
onClear?: () => void;
|
||||
showClear?: boolean;
|
||||
};
|
||||
|
||||
export function formatMultiSitesSelectorLabel(
|
||||
@@ -40,19 +43,35 @@ export function MultiSitesSelector({
|
||||
orgId,
|
||||
selectedSites,
|
||||
onSelectionChange,
|
||||
filterTypes
|
||||
filterTypes,
|
||||
scope = "org",
|
||||
onClear,
|
||||
showClear = false
|
||||
}: MultiSitesSelectorProps) {
|
||||
const t = useTranslations();
|
||||
const [siteSearchQuery, setSiteSearchQuery] = useState("");
|
||||
const [debouncedQuery] = useDebounce(siteSearchQuery, 150);
|
||||
|
||||
const { data: sites = [] } = useQuery(
|
||||
orgQueries.sites({
|
||||
const orgSitesQuery = useQuery({
|
||||
...orgQueries.sites({
|
||||
orgId,
|
||||
query: debouncedQuery,
|
||||
perPage: 10
|
||||
})
|
||||
);
|
||||
}),
|
||||
enabled: scope === "org"
|
||||
});
|
||||
const launcherSitesQuery = useQuery({
|
||||
...launcherQueries.sites({
|
||||
orgId,
|
||||
query: debouncedQuery,
|
||||
perPage: 20
|
||||
}),
|
||||
enabled: scope === "launcher"
|
||||
});
|
||||
const sites =
|
||||
scope === "launcher"
|
||||
? (launcherSitesQuery.data ?? [])
|
||||
: (orgSitesQuery.data ?? []);
|
||||
|
||||
const sitesShown = useMemo(() => {
|
||||
const base = filterTypes
|
||||
@@ -92,6 +111,14 @@ export function MultiSitesSelector({
|
||||
<CommandList>
|
||||
<CommandEmpty>{t("siteNotFound")}</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{showClear && onClear && (
|
||||
<CommandItem
|
||||
onSelect={onClear}
|
||||
className="text-muted-foreground"
|
||||
>
|
||||
{t("accessFilterClear")}
|
||||
</CommandItem>
|
||||
)}
|
||||
{sitesShown.map((site) => (
|
||||
<CommandItem
|
||||
key={site.siteId}
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
} from "react-icons/fa";
|
||||
import { ExternalLink } from "lucide-react";
|
||||
import { SiKubernetes, SiNixos } from "react-icons/si";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
|
||||
export type CommandItem = string | { title: string; command: string };
|
||||
|
||||
@@ -50,9 +51,12 @@ export function NewtSiteInstallCommands({
|
||||
version = "latest"
|
||||
}: NewtSiteInstallCommandsProps) {
|
||||
const t = useTranslations();
|
||||
const { env } = useEnvContext();
|
||||
|
||||
const [acceptClients, setAcceptClients] = useState(true);
|
||||
const [allowPangolinSsh, setAllowPangolinSsh] = useState(true);
|
||||
const [allowPangolinSsh, setAllowPangolinSsh] = useState(
|
||||
!env.flags.disableEnterpriseFeatures
|
||||
);
|
||||
const [platform, setPlatform] = useState<Platform>("linux");
|
||||
const [architecture, setArchitecture] = useState(
|
||||
() => getArchitectures(platform)[0]
|
||||
@@ -71,7 +75,11 @@ export function NewtSiteInstallCommands({
|
||||
: "";
|
||||
|
||||
const disableSshFlag =
|
||||
supportsSshOption && !allowPangolinSsh ? " --disable-ssh" : "";
|
||||
supportsSshOption &&
|
||||
!allowPangolinSsh &&
|
||||
!env.flags.disableEnterpriseFeatures
|
||||
? " --disable-ssh"
|
||||
: "";
|
||||
const runAsRootPrefix =
|
||||
supportsSshOption && allowPangolinSsh ? "sudo " : "";
|
||||
|
||||
@@ -131,7 +139,6 @@ Restart=always
|
||||
RestartSec=2
|
||||
UMask=0077
|
||||
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
|
||||
[Install]
|
||||
@@ -306,27 +313,29 @@ WantedBy=default.target`
|
||||
>
|
||||
{t("siteAcceptClientConnectionsDescription")}
|
||||
</p>
|
||||
{supportsSshOption && (
|
||||
<>
|
||||
<div className="flex items-center space-x-2 mb-2 mt-2">
|
||||
<CheckboxWithLabel
|
||||
id="allowPangolinSsh"
|
||||
checked={allowPangolinSsh}
|
||||
onCheckedChange={(checked) => {
|
||||
const value = checked as boolean;
|
||||
setAllowPangolinSsh(value);
|
||||
}}
|
||||
label="Allow Pangolin SSH"
|
||||
/>
|
||||
</div>
|
||||
<p
|
||||
id="allowPangolinSsh-desc"
|
||||
className="text-sm text-muted-foreground"
|
||||
>
|
||||
{t("sitePangolinSshDescription")}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
{supportsSshOption &&
|
||||
!env.flags.disableEnterpriseFeatures && (
|
||||
<>
|
||||
<div className="flex items-center space-x-2 mb-2 mt-2">
|
||||
<CheckboxWithLabel
|
||||
id="allowPangolinSsh"
|
||||
checked={allowPangolinSsh}
|
||||
onCheckedChange={(checked) => {
|
||||
const value =
|
||||
checked as boolean;
|
||||
setAllowPangolinSsh(value);
|
||||
}}
|
||||
label="Allow Pangolin SSH"
|
||||
/>
|
||||
</div>
|
||||
<p
|
||||
id="allowPangolinSsh-desc"
|
||||
className="text-sm text-muted-foreground"
|
||||
>
|
||||
{t("sitePangolinSshDescription")}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@app/lib/cn";
|
||||
import { Check, Copy } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useState } from "react";
|
||||
|
||||
type LauncherCopyIconProps = {
|
||||
text: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function LauncherCopyIcon({ text, className }: LauncherCopyIconProps) {
|
||||
const t = useTranslations();
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
if (!text) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"inline-flex size-4 shrink-0 items-center justify-center text-muted-foreground transition-colors hover:text-foreground",
|
||||
className
|
||||
)}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
void navigator.clipboard.writeText(text);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}}
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="size-4 text-green-500" />
|
||||
) : (
|
||||
<Copy className="size-4" />
|
||||
)}
|
||||
<span className="sr-only">{t("copyText")}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { cn } from "@app/lib/cn";
|
||||
import { LayoutGrid, SearchX } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
type LauncherEmptyStateVariant = "empty" | "noResults";
|
||||
|
||||
type LauncherEmptyStateProps = {
|
||||
variant: LauncherEmptyStateVariant;
|
||||
layout: "grid" | "list";
|
||||
query?: string;
|
||||
onClearFilters?: () => void;
|
||||
};
|
||||
|
||||
function GhostResourceGrid() {
|
||||
return (
|
||||
<div
|
||||
className="grid w-full grid-cols-1 gap-2.5 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 [&>*]:min-w-0"
|
||||
aria-hidden
|
||||
>
|
||||
{Array.from({ length: 4 }).map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex min-w-0 flex-col gap-2.5 rounded-xl border border-border/60 bg-muted/20 p-4"
|
||||
>
|
||||
<div className="flex items-center gap-5">
|
||||
<div className="size-10 shrink-0 rounded-lg bg-muted/60" />
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
|
||||
<div className="h-3.5 w-3/5 rounded bg-muted/60" />
|
||||
<div className="h-3 w-2/5 rounded bg-muted/40" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function GhostResourceList() {
|
||||
return (
|
||||
<div className="flex w-full flex-col" aria-hidden>
|
||||
{Array.from({ length: 3 }).map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={cn(
|
||||
"flex items-center gap-4 px-4 py-3",
|
||||
index < 2 && "border-b border-border/60"
|
||||
)}
|
||||
>
|
||||
<div className="size-8 shrink-0 rounded-lg bg-muted/60" />
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
|
||||
<div className="h-3.5 w-2/5 rounded bg-muted/60" />
|
||||
<div className="h-3 w-1/4 rounded bg-muted/40" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function LauncherEmptyState({
|
||||
variant,
|
||||
layout,
|
||||
query,
|
||||
onClearFilters
|
||||
}: LauncherEmptyStateProps) {
|
||||
const t = useTranslations();
|
||||
const isNoResults = variant === "noResults";
|
||||
const Icon = isNoResults ? SearchX : LayoutGrid;
|
||||
const trimmedQuery = query?.trim();
|
||||
|
||||
return (
|
||||
<div className="relative w-full overflow-hidden rounded-xl border border-dashed border-border">
|
||||
<div className="pointer-events-none absolute inset-0 opacity-50">
|
||||
{layout === "grid" ? (
|
||||
<GhostResourceGrid />
|
||||
) : (
|
||||
<GhostResourceList />
|
||||
)}
|
||||
</div>
|
||||
<div className="relative flex min-h-56 flex-col items-center justify-center gap-4 px-6 py-12 text-center">
|
||||
<div className="flex size-12 items-center justify-center rounded-full bg-muted">
|
||||
<Icon className="size-5 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="max-w-md space-y-1.5">
|
||||
<h3 className="text-base font-semibold text-foreground">
|
||||
{isNoResults
|
||||
? t("resourceLauncherEmptyStateNoResultsTitle")
|
||||
: t("resourceLauncherEmptyStateTitle")}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{isNoResults
|
||||
? trimmedQuery
|
||||
? t(
|
||||
"resourceLauncherEmptyStateNoResultsWithQuery",
|
||||
{ query: trimmedQuery }
|
||||
)
|
||||
: t(
|
||||
"resourceLauncherEmptyStateNoResultsDescription"
|
||||
)
|
||||
: t("resourceLauncherEmptyStateDescription")}
|
||||
</p>
|
||||
</div>
|
||||
{isNoResults && onClearFilters ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onClearFilters}
|
||||
>
|
||||
{t("clearAllFilters")}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
formatMultiSitesSelectorLabel,
|
||||
MultiSitesSelector
|
||||
} from "@app/components/multi-site-selector";
|
||||
import {
|
||||
formatLabelsSelectorLabel,
|
||||
LABEL_COLORS,
|
||||
type SelectedLabel
|
||||
} from "@app/components/labels-selector";
|
||||
import { LabelsFilterSelector } from "@app/components/LabelsFilterSelector";
|
||||
import { Badge } from "@app/components/ui/badge";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger
|
||||
} from "@app/components/ui/popover";
|
||||
import { cn } from "@app/lib/cn";
|
||||
import { launcherQueries } from "@app/lib/queries";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { ChevronsUpDown, Funnel } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import type { Selectedsite } from "@app/components/site-selector";
|
||||
|
||||
type LauncherFilterPopoverProps = {
|
||||
orgId: string;
|
||||
selectedSites: Selectedsite[];
|
||||
selectedLabels: SelectedLabel[];
|
||||
onSitesChange: (sites: Selectedsite[]) => void;
|
||||
onLabelsChange: (labels: SelectedLabel[]) => void;
|
||||
};
|
||||
|
||||
export function LauncherFilterPopover({
|
||||
orgId,
|
||||
selectedSites,
|
||||
selectedLabels,
|
||||
onSitesChange,
|
||||
onLabelsChange
|
||||
}: LauncherFilterPopoverProps) {
|
||||
const t = useTranslations();
|
||||
const [sitesOpen, setSitesOpen] = useState(false);
|
||||
const [labelsOpen, setLabelsOpen] = useState(false);
|
||||
|
||||
const { data: labels = [] } = useQuery(
|
||||
launcherQueries.labels({
|
||||
orgId,
|
||||
perPage: 20
|
||||
})
|
||||
);
|
||||
|
||||
const { data: sites = [] } = useQuery(
|
||||
launcherQueries.sites({
|
||||
orgId,
|
||||
perPage: 20
|
||||
})
|
||||
);
|
||||
|
||||
const resolvedSelectedSites: Selectedsite[] = useMemo(
|
||||
() =>
|
||||
selectedSites.map((selected) => {
|
||||
const found = sites.find(
|
||||
(site) => site.siteId === selected.siteId
|
||||
);
|
||||
return found
|
||||
? {
|
||||
siteId: found.siteId,
|
||||
name: found.name,
|
||||
type: found.type,
|
||||
online: found.online
|
||||
}
|
||||
: selected;
|
||||
}),
|
||||
[sites, selectedSites]
|
||||
);
|
||||
|
||||
const selectedLabelIds = useMemo(
|
||||
() => new Set(selectedLabels.map((label) => label.labelId)),
|
||||
[selectedLabels]
|
||||
);
|
||||
|
||||
const resolvedSelectedLabels: SelectedLabel[] = useMemo(
|
||||
() =>
|
||||
selectedLabels.map((selected) => {
|
||||
const found = labels.find(
|
||||
(label) => label.labelId === selected.labelId
|
||||
);
|
||||
return (
|
||||
found ?? {
|
||||
...selected,
|
||||
color: selected.color || LABEL_COLORS.gray
|
||||
}
|
||||
);
|
||||
}),
|
||||
[labels, selectedLabels]
|
||||
);
|
||||
|
||||
const activeFilterCount = selectedSites.length + selectedLabels.length;
|
||||
|
||||
return (
|
||||
<Popover modal={false}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="relative shrink-0"
|
||||
>
|
||||
<Funnel className="size-4" />
|
||||
<span className="sr-only">
|
||||
{activeFilterCount > 0
|
||||
? t("resourceLauncherFilterWithCount", {
|
||||
count: activeFilterCount
|
||||
})
|
||||
: t("resourceLauncherFilter")}
|
||||
</span>
|
||||
{activeFilterCount > 0 && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="absolute -top-1 -right-1 flex h-5 min-w-5 items-center justify-center px-1.5 text-xs"
|
||||
>
|
||||
{activeFilterCount > 99 ? "99+" : activeFilterCount}
|
||||
</Badge>
|
||||
)}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className="w-72">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-semibold">{t("sites")}</p>
|
||||
<Popover open={sitesOpen} onOpenChange={setSitesOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
className={cn(
|
||||
"w-full justify-between font-normal",
|
||||
selectedSites.length === 0 &&
|
||||
"text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
<span className="truncate text-left">
|
||||
{formatMultiSitesSelectorLabel(
|
||||
resolvedSelectedSites,
|
||||
t
|
||||
)}
|
||||
</span>
|
||||
<ChevronsUpDown className="ml-2 size-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-[var(--radix-popover-trigger-width)] p-0"
|
||||
align="start"
|
||||
>
|
||||
<MultiSitesSelector
|
||||
orgId={orgId}
|
||||
selectedSites={resolvedSelectedSites}
|
||||
onSelectionChange={onSitesChange}
|
||||
scope="launcher"
|
||||
showClear={selectedSites.length > 0}
|
||||
onClear={() => {
|
||||
onSitesChange([]);
|
||||
}}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-semibold">{t("labels")}</p>
|
||||
<Popover open={labelsOpen} onOpenChange={setLabelsOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
className={cn(
|
||||
"w-full justify-between font-normal",
|
||||
selectedLabels.length === 0 &&
|
||||
"text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
<span className="truncate text-left">
|
||||
{formatLabelsSelectorLabel(
|
||||
resolvedSelectedLabels,
|
||||
t
|
||||
)}
|
||||
</span>
|
||||
<ChevronsUpDown className="ml-2 size-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-[var(--radix-popover-trigger-width)] p-0"
|
||||
align="start"
|
||||
>
|
||||
<LabelsFilterSelector
|
||||
orgId={orgId}
|
||||
scope="launcher"
|
||||
selectedLabels={resolvedSelectedLabels}
|
||||
isSelected={(label) =>
|
||||
selectedLabelIds.has(label.labelId)
|
||||
}
|
||||
onToggle={(label) => {
|
||||
if (
|
||||
selectedLabelIds.has(label.labelId)
|
||||
) {
|
||||
onLabelsChange(
|
||||
selectedLabels.filter(
|
||||
(item) =>
|
||||
item.labelId !==
|
||||
label.labelId
|
||||
)
|
||||
);
|
||||
} else {
|
||||
onLabelsChange([
|
||||
...selectedLabels,
|
||||
label
|
||||
]);
|
||||
}
|
||||
}}
|
||||
showClear={selectedLabels.length > 0}
|
||||
onClear={() => {
|
||||
onLabelsChange([]);
|
||||
}}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
"use client";
|
||||
|
||||
import type { LauncherActiveViewId } from "@app/lib/launcherLocalStorage";
|
||||
import { hasActiveLauncherFilters } from "@app/lib/launcherScale";
|
||||
import { launcherQueries } from "@app/lib/queries";
|
||||
import type {
|
||||
LauncherResource,
|
||||
LauncherViewConfig
|
||||
} from "@server/routers/launcher/types";
|
||||
import { LAUNCHER_FLAT_GROUP_KEY } from "@server/routers/launcher/types";
|
||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import { LauncherEmptyState } from "./LauncherEmptyState";
|
||||
import { LauncherResourceGrid } from "./LauncherResourceGrid";
|
||||
import { LauncherResourceList } from "./LauncherResourceList";
|
||||
|
||||
type LauncherFlatResourceListProps = {
|
||||
orgId: string;
|
||||
activeViewId: LauncherActiveViewId;
|
||||
config: LauncherViewConfig;
|
||||
onClearFilters?: () => void;
|
||||
onResourceSelect?: (resource: LauncherResource) => void;
|
||||
};
|
||||
|
||||
export function LauncherFlatResourceList({
|
||||
orgId,
|
||||
config,
|
||||
onClearFilters,
|
||||
onResourceSelect
|
||||
}: LauncherFlatResourceListProps) {
|
||||
const loadMoreRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const resourceFilters = useMemo(
|
||||
() => ({
|
||||
query: config.query,
|
||||
groupBy: config.groupBy,
|
||||
groupKey: LAUNCHER_FLAT_GROUP_KEY,
|
||||
siteIds: config.siteIds,
|
||||
labelIds: config.labelIds,
|
||||
sort_by: config.sortBy,
|
||||
order: config.order,
|
||||
pageSize: 20
|
||||
}),
|
||||
[
|
||||
config.groupBy,
|
||||
config.labelIds,
|
||||
config.order,
|
||||
config.query,
|
||||
config.siteIds,
|
||||
config.sortBy
|
||||
]
|
||||
);
|
||||
|
||||
const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isFetching } =
|
||||
useInfiniteQuery({
|
||||
...launcherQueries.resources(orgId, resourceFilters)
|
||||
});
|
||||
|
||||
const resources = data?.pages.flatMap((page) => page.resources) ?? [];
|
||||
|
||||
useEffect(() => {
|
||||
const node = loadMoreRef.current;
|
||||
if (!node || !hasNextPage) {
|
||||
return;
|
||||
}
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0]?.isIntersecting && !isFetchingNextPage) {
|
||||
void fetchNextPage();
|
||||
}
|
||||
},
|
||||
{ rootMargin: "200px" }
|
||||
);
|
||||
|
||||
observer.observe(node);
|
||||
return () => observer.disconnect();
|
||||
}, [fetchNextPage, hasNextPage, isFetchingNextPage]);
|
||||
|
||||
if (resources.length === 0) {
|
||||
if (isFetching) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-16 text-muted-foreground">
|
||||
<Loader2 className="size-6 animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<LauncherEmptyState
|
||||
variant={
|
||||
hasActiveLauncherFilters(config) ? "noResults" : "empty"
|
||||
}
|
||||
layout={config.layout}
|
||||
query={config.query}
|
||||
onClearFilters={onClearFilters}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2.5">
|
||||
{config.layout === "grid" ? (
|
||||
<LauncherResourceGrid
|
||||
resources={resources}
|
||||
showLabels={config.showLabels}
|
||||
onResourceSelect={onResourceSelect}
|
||||
/>
|
||||
) : (
|
||||
<LauncherResourceList
|
||||
resources={resources}
|
||||
showLabels={config.showLabels}
|
||||
onResourceSelect={onResourceSelect}
|
||||
/>
|
||||
)}
|
||||
<div ref={loadMoreRef} className="h-4" />
|
||||
{isFetchingNextPage ? (
|
||||
<div className="flex justify-center py-2">
|
||||
<Loader2 className="size-4 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
"use client";
|
||||
|
||||
import type { LauncherActiveViewId } from "@app/lib/launcherLocalStorage";
|
||||
import { launcherQueries } from "@app/lib/queries";
|
||||
import type {
|
||||
LauncherGroup,
|
||||
LauncherResource,
|
||||
LauncherViewConfig
|
||||
} from "@server/routers/launcher/types";
|
||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import { LauncherEmptyState } from "./LauncherEmptyState";
|
||||
import { LauncherGroupSection } from "./LauncherGroupSection";
|
||||
|
||||
type LauncherGroupListProps = {
|
||||
orgId: string;
|
||||
activeViewId: LauncherActiveViewId;
|
||||
config: LauncherViewConfig;
|
||||
initialGroups: LauncherGroup[];
|
||||
groupsPagination: {
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
};
|
||||
onClearFilters?: () => void;
|
||||
onResourceSelect?: (resource: LauncherResource) => void;
|
||||
};
|
||||
|
||||
function hasActiveLauncherFilters(config: LauncherViewConfig): boolean {
|
||||
return (
|
||||
config.query.trim().length > 0 ||
|
||||
config.siteIds.length > 0 ||
|
||||
config.labelIds.length > 0
|
||||
);
|
||||
}
|
||||
|
||||
export function LauncherGroupList({
|
||||
orgId,
|
||||
activeViewId,
|
||||
config,
|
||||
initialGroups,
|
||||
groupsPagination,
|
||||
onClearFilters,
|
||||
onResourceSelect
|
||||
}: LauncherGroupListProps) {
|
||||
const loadMoreRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const groupFilters = useMemo(
|
||||
() => ({
|
||||
query: config.query,
|
||||
groupBy: config.groupBy,
|
||||
siteIds: config.siteIds,
|
||||
labelIds: config.labelIds,
|
||||
sort_by: config.sortBy,
|
||||
order: config.order,
|
||||
pageSize: 20
|
||||
}),
|
||||
[
|
||||
config.groupBy,
|
||||
config.labelIds,
|
||||
config.order,
|
||||
config.query,
|
||||
config.siteIds,
|
||||
config.sortBy
|
||||
]
|
||||
);
|
||||
|
||||
const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isFetching } =
|
||||
useInfiniteQuery({
|
||||
...launcherQueries.groups(orgId, groupFilters),
|
||||
...(initialGroups.length > 0
|
||||
? {
|
||||
initialData: {
|
||||
pages: [
|
||||
{
|
||||
groups: initialGroups,
|
||||
pagination: groupsPagination
|
||||
}
|
||||
],
|
||||
pageParams: [1]
|
||||
},
|
||||
refetchOnMount: false as const
|
||||
}
|
||||
: {})
|
||||
});
|
||||
|
||||
const groups = data?.pages.flatMap((page) => page.groups) ?? [];
|
||||
|
||||
useEffect(() => {
|
||||
const node = loadMoreRef.current;
|
||||
if (!node || !hasNextPage) {
|
||||
return;
|
||||
}
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0]?.isIntersecting && !isFetchingNextPage) {
|
||||
void fetchNextPage();
|
||||
}
|
||||
},
|
||||
{ rootMargin: "200px" }
|
||||
);
|
||||
|
||||
observer.observe(node);
|
||||
return () => observer.disconnect();
|
||||
}, [fetchNextPage, hasNextPage, isFetchingNextPage]);
|
||||
|
||||
if (groups.length === 0) {
|
||||
if (isFetching) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-16 text-muted-foreground">
|
||||
<Loader2 className="size-6 animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<LauncherEmptyState
|
||||
variant={
|
||||
hasActiveLauncherFilters(config) ? "noResults" : "empty"
|
||||
}
|
||||
layout={config.layout}
|
||||
query={config.query}
|
||||
onClearFilters={onClearFilters}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2.5">
|
||||
{groups.map((group) => (
|
||||
<LauncherGroupSection
|
||||
key={group.groupKey}
|
||||
orgId={orgId}
|
||||
activeViewId={activeViewId}
|
||||
group={group}
|
||||
config={config}
|
||||
onResourceSelect={onResourceSelect}
|
||||
/>
|
||||
))}
|
||||
<div ref={loadMoreRef} className="h-4" />
|
||||
{isFetchingNextPage ? (
|
||||
<div className="flex justify-center py-2">
|
||||
<Loader2 className="size-4 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent
|
||||
} from "@app/components/ui/collapsible";
|
||||
import { cn } from "@app/lib/cn";
|
||||
import {
|
||||
readLauncherGroupOpen,
|
||||
writeLauncherGroupOpen,
|
||||
type LauncherActiveViewId
|
||||
} from "@app/lib/launcherLocalStorage";
|
||||
import { launcherQueries } from "@app/lib/queries";
|
||||
import type {
|
||||
LauncherGroup,
|
||||
LauncherResource,
|
||||
LauncherViewConfig
|
||||
} from "@server/routers/launcher/types";
|
||||
import {
|
||||
LAUNCHER_NO_SITE_GROUP_KEY,
|
||||
LAUNCHER_UNLABELED_GROUP_KEY
|
||||
} from "@server/routers/launcher/types";
|
||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { LauncherGroupTrigger } from "./LauncherGroupTrigger";
|
||||
import { LauncherResourceGrid } from "./LauncherResourceGrid";
|
||||
import { LauncherResourceList } from "./LauncherResourceList";
|
||||
|
||||
type LauncherGroupSectionProps = {
|
||||
orgId: string;
|
||||
activeViewId: LauncherActiveViewId;
|
||||
group: LauncherGroup;
|
||||
config: LauncherViewConfig;
|
||||
initialResources?: LauncherResource[];
|
||||
initialResourcesPagination?: {
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
};
|
||||
defaultOpen?: boolean;
|
||||
onResourceSelect?: (resource: LauncherResource) => void;
|
||||
};
|
||||
|
||||
export function LauncherGroupSection({
|
||||
orgId,
|
||||
activeViewId,
|
||||
group,
|
||||
config,
|
||||
initialResources,
|
||||
initialResourcesPagination,
|
||||
defaultOpen = true,
|
||||
onResourceSelect
|
||||
}: LauncherGroupSectionProps) {
|
||||
const t = useTranslations();
|
||||
const loadMoreRef = useRef<HTMLDivElement | null>(null);
|
||||
const [isOpen, setIsOpen] = useState(() =>
|
||||
readLauncherGroupOpen(
|
||||
orgId,
|
||||
activeViewId,
|
||||
config.groupBy,
|
||||
group.groupKey,
|
||||
defaultOpen
|
||||
)
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setIsOpen(
|
||||
readLauncherGroupOpen(
|
||||
orgId,
|
||||
activeViewId,
|
||||
config.groupBy,
|
||||
group.groupKey,
|
||||
defaultOpen
|
||||
)
|
||||
);
|
||||
}, [activeViewId, config.groupBy, defaultOpen, group.groupKey, orgId]);
|
||||
|
||||
const handleOpenChange = (open: boolean) => {
|
||||
setIsOpen(open);
|
||||
writeLauncherGroupOpen(
|
||||
orgId,
|
||||
activeViewId,
|
||||
config.groupBy,
|
||||
group.groupKey,
|
||||
open
|
||||
);
|
||||
};
|
||||
|
||||
const hasInitialResources = initialResources !== undefined;
|
||||
|
||||
const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading } =
|
||||
useInfiniteQuery({
|
||||
...launcherQueries.resources(orgId, {
|
||||
query: config.query,
|
||||
groupBy: config.groupBy,
|
||||
groupKey: group.groupKey,
|
||||
siteIds: config.siteIds,
|
||||
labelIds: config.labelIds,
|
||||
sort_by: config.sortBy,
|
||||
order: config.order,
|
||||
pageSize: 20
|
||||
}),
|
||||
enabled: isOpen,
|
||||
refetchOnMount: false,
|
||||
...(hasInitialResources
|
||||
? {
|
||||
initialData: {
|
||||
pages: [
|
||||
{
|
||||
resources: initialResources,
|
||||
pagination: initialResourcesPagination ?? {
|
||||
total: initialResources.length,
|
||||
page: 1,
|
||||
pageSize: 20
|
||||
}
|
||||
}
|
||||
],
|
||||
pageParams: [1]
|
||||
}
|
||||
}
|
||||
: {})
|
||||
});
|
||||
|
||||
const resources = data?.pages.flatMap((page) => page.resources) ?? [];
|
||||
const showInitialLoader = isLoading && resources.length === 0;
|
||||
|
||||
useEffect(() => {
|
||||
const node = loadMoreRef.current;
|
||||
if (!node || !hasNextPage || !isOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0]?.isIntersecting && !isFetchingNextPage) {
|
||||
void fetchNextPage();
|
||||
}
|
||||
},
|
||||
{ rootMargin: "200px" }
|
||||
);
|
||||
|
||||
observer.observe(node);
|
||||
return () => observer.disconnect();
|
||||
}, [fetchNextPage, hasNextPage, isFetchingNextPage, isOpen]);
|
||||
|
||||
const groupTitle =
|
||||
group.groupKey === LAUNCHER_UNLABELED_GROUP_KEY
|
||||
? t("resourceLauncherUnlabeled")
|
||||
: group.groupKey === LAUNCHER_NO_SITE_GROUP_KEY
|
||||
? t("resourceLauncherNoSite")
|
||||
: group.name;
|
||||
|
||||
return (
|
||||
<Collapsible
|
||||
open={isOpen}
|
||||
onOpenChange={handleOpenChange}
|
||||
className="flex w-full flex-col gap-2.5"
|
||||
>
|
||||
<LauncherGroupTrigger
|
||||
group={group}
|
||||
title={groupTitle}
|
||||
isOpen={isOpen}
|
||||
/>
|
||||
|
||||
<CollapsibleContent className="w-full">
|
||||
{showInitialLoader ? (
|
||||
<div className="flex items-center justify-center py-10 text-muted-foreground">
|
||||
<Loader2 className="size-5 animate-spin" />
|
||||
</div>
|
||||
) : resources.length === 0 ? (
|
||||
<p className="py-4 text-sm text-muted-foreground">
|
||||
{t("resourceLauncherNoResourcesInGroup")}
|
||||
</p>
|
||||
) : config.layout === "grid" ? (
|
||||
<LauncherResourceGrid
|
||||
resources={resources}
|
||||
showLabels={config.showLabels}
|
||||
onResourceSelect={onResourceSelect}
|
||||
/>
|
||||
) : (
|
||||
<LauncherResourceList
|
||||
resources={resources}
|
||||
showLabels={config.showLabels}
|
||||
onResourceSelect={onResourceSelect}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
ref={loadMoreRef}
|
||||
className={cn("h-4", !hasNextPage && "hidden")}
|
||||
/>
|
||||
{isFetchingNextPage ? (
|
||||
<div className="flex justify-center py-2">
|
||||
<Loader2 className="size-4 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : null}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
"use client";
|
||||
|
||||
import { CollapsibleTrigger } from "@app/components/ui/collapsible";
|
||||
import type { LauncherGroup } from "@server/routers/launcher/types";
|
||||
import { ChevronDown, ChevronLeft } from "lucide-react";
|
||||
|
||||
type LauncherGroupTriggerProps = {
|
||||
group: LauncherGroup;
|
||||
title: string;
|
||||
isOpen: boolean;
|
||||
};
|
||||
|
||||
function LauncherGroupStatusDot({ group }: { group: LauncherGroup }) {
|
||||
if (group.groupType === "label") {
|
||||
return (
|
||||
<span
|
||||
className="size-2 shrink-0 rounded-full"
|
||||
style={{ backgroundColor: group.labelColor }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (group.groupType === "site") {
|
||||
if (
|
||||
(group.siteType === "newt" || group.siteType === "wireguard") &&
|
||||
typeof group.siteOnline === "boolean"
|
||||
) {
|
||||
return (
|
||||
<span
|
||||
className={
|
||||
group.siteOnline
|
||||
? "size-2 shrink-0 rounded-full bg-green-500"
|
||||
: "size-2 shrink-0 rounded-full bg-neutral-500"
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <span className="size-2 shrink-0 rounded-full bg-neutral-500" />;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function LauncherGroupTrigger({
|
||||
group,
|
||||
title,
|
||||
isOpen
|
||||
}: LauncherGroupTriggerProps) {
|
||||
return (
|
||||
<CollapsibleTrigger className="flex w-full items-center gap-2.5 rounded-md bg-accent px-4 py-2.5 text-left transition-colors cursor-pointer">
|
||||
{group.groupType === "site" || group.groupType === "label" ? (
|
||||
<LauncherGroupStatusDot group={group} />
|
||||
) : null}
|
||||
<span className="flex min-w-0 items-center gap-2.5 text-sm font-semibold text-foreground">
|
||||
<span className="truncate">
|
||||
{title} ({group.itemCount})
|
||||
</span>
|
||||
{isOpen ? (
|
||||
<ChevronDown className="size-4 shrink-0 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronLeft className="size-4 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
</span>
|
||||
</CollapsibleTrigger>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
"use client";
|
||||
|
||||
import type { LauncherLabel } from "@server/routers/launcher/types";
|
||||
import { LabelBadge } from "@app/components/label-badge";
|
||||
import { LabelOverflowBadge } from "@app/components/label-overflow-badge";
|
||||
import { cn } from "@app/lib/cn";
|
||||
import { useLayoutEffect, useRef, useState } from "react";
|
||||
|
||||
const MAX_LABEL_ROWS = 2;
|
||||
const SINGLE_ROW_MAX_LABELS = 5;
|
||||
|
||||
type LauncherLabelsRowProps = {
|
||||
labels: LauncherLabel[];
|
||||
className?: string;
|
||||
variant?: "wrap" | "single-row";
|
||||
};
|
||||
|
||||
function countFlexRows(container: HTMLElement): number {
|
||||
const rowTops = new Set<number>();
|
||||
|
||||
for (const child of container.children) {
|
||||
const element = child as HTMLElement;
|
||||
if (element.style.display === "none") {
|
||||
continue;
|
||||
}
|
||||
rowTops.add(element.offsetTop);
|
||||
}
|
||||
|
||||
return rowTops.size;
|
||||
}
|
||||
|
||||
export function LauncherLabelsRow({
|
||||
labels,
|
||||
className,
|
||||
variant = "wrap"
|
||||
}: LauncherLabelsRowProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const measureRef = useRef<HTMLDivElement>(null);
|
||||
const [visibleCount, setVisibleCount] = useState(labels.length);
|
||||
|
||||
const labelKey = labels.map((label) => label.labelId).join(",");
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (variant === "single-row") {
|
||||
return;
|
||||
}
|
||||
|
||||
const container = containerRef.current;
|
||||
const measure = measureRef.current;
|
||||
if (!container || !measure || labels.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const recompute = () => {
|
||||
const width = container.clientWidth;
|
||||
if (width <= 0) {
|
||||
setVisibleCount(labels.length);
|
||||
return;
|
||||
}
|
||||
|
||||
measure.style.width = `${width}px`;
|
||||
|
||||
const labelNodes = measure.querySelectorAll<HTMLElement>(
|
||||
"[data-measure-label]"
|
||||
);
|
||||
const overflowNode = measure.querySelector<HTMLElement>(
|
||||
"[data-measure-overflow]"
|
||||
);
|
||||
|
||||
const fits = (visible: number) => {
|
||||
labelNodes.forEach((node, index) => {
|
||||
node.style.display = index < visible ? "" : "none";
|
||||
});
|
||||
|
||||
if (overflowNode) {
|
||||
const overflowCount = labels.length - visible;
|
||||
if (overflowCount > 0) {
|
||||
overflowNode.style.display = "";
|
||||
} else {
|
||||
overflowNode.style.display = "none";
|
||||
}
|
||||
}
|
||||
|
||||
return countFlexRows(measure) <= MAX_LABEL_ROWS;
|
||||
};
|
||||
|
||||
let best = 0;
|
||||
for (let visible = labels.length; visible >= 0; visible--) {
|
||||
if (fits(visible)) {
|
||||
best = visible;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
setVisibleCount(best);
|
||||
};
|
||||
|
||||
recompute();
|
||||
|
||||
const observer = new ResizeObserver(recompute);
|
||||
observer.observe(container);
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, [labelKey, labels, variant]);
|
||||
|
||||
if (labels.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const resolvedVisibleCount =
|
||||
variant === "single-row"
|
||||
? Math.min(labels.length, SINGLE_ROW_MAX_LABELS)
|
||||
: visibleCount;
|
||||
const visibleLabels = labels.slice(0, resolvedVisibleCount);
|
||||
const overflowLabels = labels.slice(resolvedVisibleCount);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={cn("relative min-w-0 w-full", className)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-1",
|
||||
variant === "single-row" ? "flex-nowrap" : "flex-wrap"
|
||||
)}
|
||||
>
|
||||
{visibleLabels.map((label) => (
|
||||
<LabelBadge
|
||||
key={label.labelId}
|
||||
name={label.name}
|
||||
color={label.color}
|
||||
displayOnly
|
||||
className="shrink-0"
|
||||
/>
|
||||
))}
|
||||
{overflowLabels.length > 0 ? (
|
||||
<LabelOverflowBadge
|
||||
labels={overflowLabels.map((label) => ({
|
||||
color: label.color,
|
||||
name: label.name
|
||||
}))}
|
||||
displayOnly
|
||||
className="shrink-0"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{variant === "wrap" ? (
|
||||
<div
|
||||
ref={measureRef}
|
||||
className="pointer-events-none invisible absolute left-0 top-0 flex flex-wrap items-center gap-1"
|
||||
aria-hidden
|
||||
>
|
||||
{labels.map((label) => (
|
||||
<span key={label.labelId} data-measure-label>
|
||||
<LabelBadge
|
||||
name={label.name}
|
||||
color={label.color}
|
||||
displayOnly
|
||||
className="shrink-0"
|
||||
/>
|
||||
</span>
|
||||
))}
|
||||
<span
|
||||
data-measure-overflow
|
||||
className="inline-flex shrink-0"
|
||||
>
|
||||
<LabelOverflowBadge labels={labels} displayOnly />
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user