Merge branch 'provisioning-resources' into dev

This commit is contained in:
Owen
2026-07-15 20:45:39 -04:00
52 changed files with 1177 additions and 180 deletions
+88 -14
View File
@@ -1,6 +1,16 @@
"use client";
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
import {
Credenza,
CredenzaBody,
CredenzaContent,
CredenzaDescription,
CredenzaFooter,
CredenzaHeader,
CredenzaTitle
} from "@app/components/Credenza";
import SiteResourcesOverview from "@app/components/SiteResourcesOverview";
import { Badge } from "@app/components/ui/badge";
import { Button } from "@app/components/ui/button";
import {
@@ -24,6 +34,7 @@ import {
ArrowUp10Icon,
ArrowUpRight,
Check,
ChevronDown,
ChevronsUpDownIcon,
MoreHorizontal,
X
@@ -65,8 +76,10 @@ export default function PendingSitesTable({
const [isRefreshing, startTransition] = useTransition();
const [approvingIds, setApprovingIds] = useState<Set<number>>(new Set());
const [rejectingIds, setRejectingIds] = useState<Set<number>>(new Set());
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [isRejectModalOpen, setIsRejectModalOpen] = useState(false);
const [selectedSite, setSelectedSite] = useState<SiteRow | null>(null);
const [resourcesDialogSite, setResourcesDialogSite] =
useState<SiteRow | null>(null);
const api = createApiClient(useEnvContext());
const t = useTranslations();
@@ -111,7 +124,7 @@ export default function PendingSitesTable({
async function approveSite(siteId: number) {
setApprovingIds((prev) => new Set(prev).add(siteId));
try {
await api.post(`/site/${siteId}`, { status: "approved" });
await api.post(`/site/${siteId}/approve`);
toast({
title: t("success"),
description: t("siteApproveSuccess"),
@@ -136,20 +149,20 @@ export default function PendingSitesTable({
async function rejectSite(siteId: number) {
setRejectingIds((prev) => new Set(prev).add(siteId));
try {
await api.delete(`/site/${siteId}`);
await api.post(`/site/${siteId}/reject`);
toast({
title: t("success"),
description: t("siteDeleted"),
description: t("siteRejectSuccess"),
variant: "default"
});
setIsDeleteModalOpen(false);
setIsRejectModalOpen(false);
setSelectedSite(null);
router.refresh();
} catch (e) {
toast({
variant: "destructive",
title: t("siteErrorDelete"),
description: formatAxiosError(e, t("siteErrorDelete"))
title: t("siteRejectError"),
description: formatAxiosError(e, t("siteRejectError"))
});
} finally {
setRejectingIds((prev) => {
@@ -342,6 +355,29 @@ export default function PendingSitesTable({
}
}
},
{
id: "resources",
accessorKey: "resourceCount",
friendlyName: t("resources"),
header: () => <span className="p-3">{t("resources")}</span>,
cell: ({ row }) => {
const siteRow = row.original;
return (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setResourcesDialogSite(siteRow)}
className="flex h-8 items-center gap-2 px-0 font-normal"
>
<span className="text-sm tabular-nums">
{siteRow.resourceCount} {t("resources")}
</span>
<ChevronDown className="h-3 w-3 shrink-0" />
</Button>
);
}
},
{
accessorKey: "exitNode",
friendlyName: t("exitNode"),
@@ -445,7 +481,7 @@ export default function PendingSitesTable({
disabled={isApproving || isRejecting}
onClick={() => {
setSelectedSite(siteRow);
setIsDeleteModalOpen(true);
setIsRejectModalOpen(true);
}}
>
<X className="mr-2 w-4 h-4" />
@@ -491,25 +527,63 @@ export default function PendingSitesTable({
return (
<>
<Credenza
open={Boolean(resourcesDialogSite)}
onOpenChange={(open) => {
if (!open) setResourcesDialogSite(null);
}}
>
<CredenzaContent className="md:max-w-7xl">
<CredenzaHeader>
<CredenzaTitle>{t("siteResourcesTab")}</CredenzaTitle>
<CredenzaDescription>
{t("siteResourcesDialogDescription")}
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody>
{resourcesDialogSite != null && (
<SiteResourcesOverview
orgIdOverride={orgId}
siteId={resourcesDialogSite.id}
initialPublicData={null}
initialPrivateData={null}
initialPublicForbidden={false}
initialPrivateForbidden={false}
showViewAllLinks={false}
/>
)}
</CredenzaBody>
<CredenzaFooter>
<Button
type="button"
variant="outline"
onClick={() => setResourcesDialogSite(null)}
>
{t("close")}
</Button>
</CredenzaFooter>
</CredenzaContent>
</Credenza>
{selectedSite && (
<ConfirmDeleteDialog
open={isDeleteModalOpen}
open={isRejectModalOpen}
setOpen={(val) => {
setIsDeleteModalOpen(val);
setIsRejectModalOpen(val);
if (!val) {
setSelectedSite(null);
}
}}
dialog={
<div className="space-y-2">
<p>{t("siteQuestionRemove")}</p>
<p>{t("siteMessageRemove")}</p>
<p>{t("siteQuestionReject")}</p>
<p>{t("siteMessageReject")}</p>
</div>
}
buttonText={t("siteConfirmDelete")}
buttonText={t("siteConfirmReject")}
onConfirm={async () => rejectSite(selectedSite.id)}
string={selectedSite.name}
title={t("siteDelete")}
title={t("siteReject")}
/>
)}
<ControlledDataTable
+3 -1
View File
@@ -55,6 +55,7 @@ function getActionsCategories(root: boolean) {
[t("actionGetSite")]: "getSite",
[t("actionListSites")]: "listSites",
[t("actionUpdateSite")]: "updateSite",
[t("actionUpdateSiteApprovals")]: "updateSiteApprovals",
[t("actionListSiteRoles")]: "listSiteRoles"
},
@@ -78,7 +79,8 @@ function getActionsCategories(root: boolean) {
[t("actionGetSiteResource")]: "getSiteResource",
[t("actionListSiteResources")]: "listSiteResources",
[t("actionUpdateSiteResource")]: "updateSiteResource",
[t("actionCreateResourceSessionToken")]: "createResourceSessionToken"
[t("actionCreateResourceSessionToken")]:
"createResourceSessionToken"
},
Target: {
@@ -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,306 @@
"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 resolvedInitialTcp = initialTcp !== undefined ? initialTcp : "*";
const resolvedInitialUdp = initialUdp !== undefined ? initialUdp : "*";
const [tcpPortMode, setTcpPortMode] = useState<PortMode>(() =>
getPortModeFromString(resolvedInitialTcp)
);
const [udpPortMode, setUdpPortMode] = useState<PortMode>(() =>
getPortModeFromString(resolvedInitialUdp)
);
const [tcpCustomPorts, setTcpCustomPorts] = useState(() =>
resolvedInitialTcp && resolvedInitialTcp !== "*"
? resolvedInitialTcp
: ""
);
const [udpCustomPorts, setUdpCustomPorts] = useState(() =>
resolvedInitialUdp && resolvedInitialUdp !== "*"
? resolvedInitialUdp
: ""
);
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 "@app/components/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>
)}
/>
);
}
+333
View File
@@ -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 "@app/components/PrivateResourceDestinationFields";
import { PrivateResourceSitesField } from "@app/components/PrivateResourceSitesField";
import { inferSshPamMode } from "@app/lib/privateResourceForm";
import { getSshUseMultiSiteTargetForm } from "@app/lib/privateResourceUtils";
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>;
}
+108 -1
View File
@@ -23,6 +23,7 @@ import {
PopoverContent,
PopoverTrigger
} from "@app/components/ui/popover";
import { Switch } from "@app/components/ui/switch";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useNavigationContext } from "@app/hooks/useNavigationContext";
import { useOptimisticLabels } from "@app/hooks/useOptimisticLabels";
@@ -36,7 +37,9 @@ import { getPrivateResourceSettingsHref } from "@app/lib/launcherResourceAdminHr
import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn";
import { build } from "@server/build";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
import { UpdateSiteResourceResponse } from "@server/routers/siteResource";
import type { PaginationState } from "@tanstack/react-table";
import { AxiosResponse } from "axios";
import {
ArrowDown01Icon,
ArrowRight,
@@ -49,8 +52,17 @@ import {
import { useTranslations } from "next-intl";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { startTransition, useMemo, useState, useTransition } from "react";
import {
startTransition,
useMemo,
useOptimistic,
useRef,
useState,
useTransition,
type ComponentRef
} from "react";
import { useDebouncedCallback } from "use-debounce";
import z from "zod";
import { ColumnFilterButton } from "./ColumnFilterButton";
import { LabelColumnFilterButton } from "./LabelColumnFilterButton";
import { LabelsTableCell } from "./LabelsTableCell";
@@ -114,6 +126,11 @@ function isSafeUrlForLink(href: string): boolean {
}
}
const booleanSearchFilterSchema = z
.enum(["true", "false"])
.optional()
.catch(undefined);
type ClientResourcesTableProps = {
internalResources: InternalResourceRow[];
orgId: string;
@@ -174,6 +191,30 @@ export default function PrivateResourcesTable({
});
};
async function toggleInternalResourceEnabled(
val: boolean,
resourceId: number
) {
try {
await api.post<AxiosResponse<UpdateSiteResourceResponse>>(
`site-resource/${resourceId}`,
{
enabled: val
}
);
router.refresh();
} catch (e) {
toast({
variant: "destructive",
title: t("resourcesErrorUpdate"),
description: formatAxiosError(
e,
t("resourcesErrorUpdateDescription")
)
});
}
}
const deleteInternalResource = async (
resourceId: number,
siteId: number
@@ -429,6 +470,36 @@ export default function PrivateResourcesTable({
);
}
},
{
accessorKey: "enabled",
friendlyName: t("enabled"),
header: () => (
<ColumnFilterButton
options={[
{ value: "true", label: t("enabled") },
{ value: "false", label: t("disabled") }
]}
selectedValue={booleanSearchFilterSchema.parse(
searchParams.get("enabled")
)}
onValueChange={(value) =>
handleFilterChange("enabled", value)
}
searchPlaceholder={t("searchPlaceholder")}
emptyMessage={t("emptySearchOptions")}
label={t("enabled")}
className="p-3"
/>
),
cell: ({ row }) => (
<InternalResourceEnabledForm
resource={row.original}
onToggleInternalResourceEnabled={
toggleInternalResourceEnabled
}
/>
)
},
{
id: "labels",
accessorKey: "labels",
@@ -643,3 +714,39 @@ function ClientResourceLabelCell({
/>
);
}
type InternalResourceEnabledFormProps = {
resource: InternalResourceRow;
onToggleInternalResourceEnabled: (
val: boolean,
resourceId: number
) => Promise<void>;
};
function InternalResourceEnabledForm({
resource,
onToggleInternalResourceEnabled
}: InternalResourceEnabledFormProps) {
const [optimisticEnabled, setOptimisticEnabled] = useOptimistic(
resource.enabled
);
const formRef = useRef<ComponentRef<"form">>(null);
async function submitAction(formData: FormData) {
const newEnabled = !(formData.get("enabled") === "on");
setOptimisticEnabled(newEnabled);
await onToggleInternalResourceEnabled(newEnabled, resource.id);
}
return (
<form action={submitAction} ref={formRef}>
<Switch
checked={optimisticEnabled}
disabled={optimisticEnabled !== resource.enabled}
name="enabled"
onCheckedChange={() => formRef.current?.requestSubmit()}
/>
</form>
);
}
+22 -13
View File
@@ -174,8 +174,8 @@ type OverviewRow = {
type OverviewColumnProps = {
title: string;
description: string;
viewAllHref: string;
viewAllLabel: string;
viewAllHref?: string;
viewAllLabel?: string;
emptyLabel: string;
isForbidden: boolean;
isFetching: boolean;
@@ -212,12 +212,14 @@ function OverviewColumn({
{description}
</p>
</div>
<Link
href={viewAllHref}
className="shrink-0 text-muted-foreground text-sm hover:underline"
>
{viewAllLabel}
</Link>
{viewAllHref && viewAllLabel ? (
<Link
href={viewAllHref}
className="shrink-0 text-muted-foreground text-sm hover:underline"
>
{viewAllLabel}
</Link>
) : null}
</div>
</div>
);
@@ -319,6 +321,8 @@ type SiteResourcesOverviewProps = {
initialPrivateForbidden: boolean;
/** When not under `/[orgId]/...` routes, pass org id explicitly (e.g. credenza on sites list). */
orgIdOverride?: string;
/** When false, hides links to the org resources tables filtered by this site. */
showViewAllLinks?: boolean;
};
export default function SiteResourcesOverview({
@@ -327,7 +331,8 @@ export default function SiteResourcesOverview({
initialPrivateData,
initialPublicForbidden,
initialPrivateForbidden,
orgIdOverride
orgIdOverride,
showViewAllLinks = true
}: SiteResourcesOverviewProps) {
const t = useTranslations();
const params = useParams<{ orgId: string }>();
@@ -467,8 +472,10 @@ export default function SiteResourcesOverview({
key="public"
title={t("siteResourcesSectionPublic")}
description={t("siteResourcesSectionPublicDescription")}
viewAllHref={publicViewAllHref}
viewAllLabel={t("siteResourcesViewAllPublic")}
viewAllHref={showViewAllLinks ? publicViewAllHref : undefined}
viewAllLabel={
showViewAllLinks ? t("siteResourcesViewAllPublic") : undefined
}
emptyLabel={t("siteResourcesEmptyPublic")}
isForbidden={publicForbidden}
isFetching={publicQuery.isFetching}
@@ -484,8 +491,10 @@ export default function SiteResourcesOverview({
key="private"
title={t("siteResourcesSectionPrivate")}
description={t("siteResourcesSectionPrivateDescription")}
viewAllHref={privateViewAllHref}
viewAllLabel={t("siteResourcesViewAllPrivate")}
viewAllHref={showViewAllLinks ? privateViewAllHref : undefined}
viewAllLabel={
showViewAllLinks ? t("siteResourcesViewAllPrivate") : undefined
}
emptyLabel={t("siteResourcesEmptyPrivate")}
isForbidden={privateForbidden}
isFetching={privateQuery.isFetching}