From a521db91a2f89ef3c2a34042b6552c0d985bb292 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Mon, 6 Jul 2026 21:48:29 -0400 Subject: [PATCH] migrate private resources to page --- messages/en-US.json | 8 + .../siteResource/createSiteResource.ts | 2 +- .../siteResource/updateSiteResource.ts | 2 +- src/app/[orgId]/settings/logs/access/page.tsx | 6 +- .../[orgId]/settings/logs/connection/page.tsx | 6 +- .../[orgId]/settings/logs/request/page.tsx | 6 +- .../private/PrivateResourceAccessFields.tsx | 112 + .../PrivateResourceDestinationFields.tsx | 175 ++ .../private/PrivateResourceHttpFields.tsx | 327 +++ .../PrivateResourceMultiSiteRoutingHelp.tsx | 24 + .../private/PrivateResourcePortRanges.tsx | 300 +++ .../private/PrivateResourceSitesField.tsx | 133 + .../private/PrivateResourceSshFields.tsx | 333 +++ .../private/[niceId]/access/page.tsx | 170 ++ .../resources/private/[niceId]/cidr/page.tsx | 138 ++ .../private/[niceId]/general/page.tsx | 133 + .../resources/private/[niceId]/host/page.tsx | 147 ++ .../resources/private/[niceId]/http/page.tsx | 152 ++ .../resources/private/[niceId]/layout.tsx | 93 + .../resources/private/[niceId]/page.tsx | 15 + .../resources/private/[niceId]/ssh/page.tsx | 229 ++ .../resources/private/create/page.tsx | 615 +++++ .../resources/private/formControlUtils.ts | 24 + .../settings/resources/private/page.tsx | 1 + .../resources/private/privateResourceUtils.ts | 36 + .../resources/private/useSaveSiteResource.ts | 106 + .../resources/public/[niceId]/ssh/page.tsx | 158 +- .../settings/resources/public/create/page.tsx | 4 +- .../CreatePrivateResourceDialog.tsx | 206 -- src/components/EditPrivateResourceDialog.tsx | 225 -- src/components/InfoSection.tsx | 6 +- src/components/PrivateResourceForm.tsx | 2147 ----------------- src/components/PrivateResourcesTable.tsx | 77 +- src/components/SiteResourceInfoBox.tsx | 232 ++ src/components/SiteResourcesOverview.tsx | 21 +- src/components/SshServerSettingsFields.tsx | 201 ++ src/components/StrategySelect.tsx | 78 +- .../LauncherResourcePanel.tsx | 119 +- src/contexts/siteResourceContext.ts | 21 + src/hooks/useSiteResourceContext.ts | 12 + src/lib/fetchSiteResourceByNiceId.ts | 58 + src/lib/launcherResourceAdminHref.ts | 14 +- src/lib/privateResourceForm.ts | 656 +++++ src/providers/SiteResourceProvider.tsx | 163 ++ 44 files changed, 4775 insertions(+), 2916 deletions(-) create mode 100644 src/app/[orgId]/settings/resources/private/PrivateResourceAccessFields.tsx create mode 100644 src/app/[orgId]/settings/resources/private/PrivateResourceDestinationFields.tsx create mode 100644 src/app/[orgId]/settings/resources/private/PrivateResourceHttpFields.tsx create mode 100644 src/app/[orgId]/settings/resources/private/PrivateResourceMultiSiteRoutingHelp.tsx create mode 100644 src/app/[orgId]/settings/resources/private/PrivateResourcePortRanges.tsx create mode 100644 src/app/[orgId]/settings/resources/private/PrivateResourceSitesField.tsx create mode 100644 src/app/[orgId]/settings/resources/private/PrivateResourceSshFields.tsx create mode 100644 src/app/[orgId]/settings/resources/private/[niceId]/access/page.tsx create mode 100644 src/app/[orgId]/settings/resources/private/[niceId]/cidr/page.tsx create mode 100644 src/app/[orgId]/settings/resources/private/[niceId]/general/page.tsx create mode 100644 src/app/[orgId]/settings/resources/private/[niceId]/host/page.tsx create mode 100644 src/app/[orgId]/settings/resources/private/[niceId]/http/page.tsx create mode 100644 src/app/[orgId]/settings/resources/private/[niceId]/layout.tsx create mode 100644 src/app/[orgId]/settings/resources/private/[niceId]/page.tsx create mode 100644 src/app/[orgId]/settings/resources/private/[niceId]/ssh/page.tsx create mode 100644 src/app/[orgId]/settings/resources/private/create/page.tsx create mode 100644 src/app/[orgId]/settings/resources/private/formControlUtils.ts create mode 100644 src/app/[orgId]/settings/resources/private/privateResourceUtils.ts create mode 100644 src/app/[orgId]/settings/resources/private/useSaveSiteResource.ts delete mode 100644 src/components/CreatePrivateResourceDialog.tsx delete mode 100644 src/components/EditPrivateResourceDialog.tsx delete mode 100644 src/components/PrivateResourceForm.tsx create mode 100644 src/components/SiteResourceInfoBox.tsx create mode 100644 src/components/SshServerSettingsFields.tsx create mode 100644 src/contexts/siteResourceContext.ts create mode 100644 src/hooks/useSiteResourceContext.ts create mode 100644 src/lib/fetchSiteResourceByNiceId.ts create mode 100644 src/lib/privateResourceForm.ts create mode 100644 src/providers/SiteResourceProvider.tsx diff --git a/messages/en-US.json b/messages/en-US.json index 64526b405..003fd74a4 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -266,6 +266,8 @@ "resourceRawDescriptionCloud": "Proxy requests over raw TCP/UDP using a port number. Requires sites to connect to a remote node.", "resourceCreate": "Create Resource", "resourceCreateDescription": "Follow the steps below to create a new resource", + "resourcePublicCreate": "Create Public Resource", + "resourcePublicCreateDescription": "Follow the steps below to create a new public resource that is accessible through a web browser", "resourceCreateGeneralDescription": "Configure the basic resource settings including the name and the type", "resourceSeeAll": "See All Resources", "resourceCreateGeneral": "General", @@ -2317,6 +2319,12 @@ "createInternalResourceDialogClose": "Close", "createInternalResourceDialogCreateClientResource": "Create Private Resource", "createInternalResourceDialogCreateClientResourceDescription": "Create a new resource that will only be accessible to clients connected to the organization", + "privateResourceCreatePageSeeAll": "See All Private Resources", + "privateResourceAllowIcmpPing": "Allow ICMP Ping", + "privateResourceNetworkAccess": "Network Access", + "privateResourceNetworkAccessDescription": "Control TCP/UDP port access and whether ICMP ping is allowed for this resource.", + "hostSettings": "Host Settings", + "cidrSettings": "CIDR Settings", "createInternalResourceDialogResourceProperties": "Resource Properties", "createInternalResourceDialogName": "Name", "createInternalResourceDialogSite": "Site", diff --git a/server/routers/siteResource/createSiteResource.ts b/server/routers/siteResource/createSiteResource.ts index d7adb5c03..ace221831 100644 --- a/server/routers/siteResource/createSiteResource.ts +++ b/server/routers/siteResource/createSiteResource.ts @@ -55,7 +55,7 @@ const createSiteResourceSchema = z siteIds: z.array(z.int()).optional(), siteId: z.number().int().positive().optional(), // DEPRECATED: for backward compatibility, we will convert this to siteIds array if provided destinationPort: z.int().positive().optional(), - destination: z.string().min(1).optional(), + destination: z.string().min(1).nullish(), enabled: z.boolean().default(true), alias: z .string() diff --git a/server/routers/siteResource/updateSiteResource.ts b/server/routers/siteResource/updateSiteResource.ts index 22159f1c6..27b6c880d 100644 --- a/server/routers/siteResource/updateSiteResource.ts +++ b/server/routers/siteResource/updateSiteResource.ts @@ -54,7 +54,7 @@ const updateSiteResourceSchema = z ssl: z.boolean().optional(), scheme: z.enum(["http", "https"]).nullish(), destinationPort: z.int().positive().nullish(), - destination: z.string().min(1).optional(), + destination: z.string().min(1).nullish(), enabled: z.boolean().optional(), alias: z .string() diff --git a/src/app/[orgId]/settings/logs/access/page.tsx b/src/app/[orgId]/settings/logs/access/page.tsx index 6ac2a30e8..72661a15e 100644 --- a/src/app/[orgId]/settings/logs/access/page.tsx +++ b/src/app/[orgId]/settings/logs/access/page.tsx @@ -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"; @@ -343,7 +344,10 @@ export default function GeneralPage() { diff --git a/src/app/[orgId]/settings/logs/connection/page.tsx b/src/app/[orgId]/settings/logs/connection/page.tsx index 528ba9a37..fdf588e7a 100644 --- a/src/app/[orgId]/settings/logs/connection/page.tsx +++ b/src/app/[orgId]/settings/logs/connection/page.tsx @@ -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 ( + + + + { + onSelectedSitesChange([site]); + field.onChange([site.siteId]); + }} + /> + + + ) : ( + + + + + + + + { + onSelectedSitesChange(sites); + field.onChange( + sites.map((s) => s.siteId) + ); + }} + /> + + + )} + + {!singleSite && selectedSites.length > 1 ? ( + + ) : null} + + )} + /> + ); +} diff --git a/src/app/[orgId]/settings/resources/private/PrivateResourceSshFields.tsx b/src/app/[orgId]/settings/resources/private/PrivateResourceSshFields.tsx new file mode 100644 index 000000000..e61ed2292 --- /dev/null +++ b/src/app/[orgId]/settings/resources/private/PrivateResourceSshFields.tsx @@ -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; + setValue: UseFormSetValue; + watch: UseFormWatch; + 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 : ( + + ); + + const standardSshTargetRow = + orgId && !isNative ? ( +
+ + ( + + {t(destinationLabelKey)} + + + field.onChange( + e.target.value === "" + ? null + : e.target.value + ) + } + /> + + + + )} + /> + ( + + {t(destinationPortLabelKey)} + + { + const raw = e.target.value; + if (raw === "") { + field.onChange(null); + return; + } + const n = Number(raw); + field.onChange( + Number.isFinite(n) ? n : null + ); + }} + /> + + + + )} + /> +
+ ) : null; + + const sshSettingsFields = showSshSettings ? ( + + ) : null; + + const destinationSection = ( + <> + + + + {t("sshServerDestination")} + + + {t("sshServerDestinationDescription")} + + + + + {isNative && orgId ? ( + <> + + + + + + + + ) : null} + + {!isNative && orgId ? ( + + {standardSshTargetRow} + + ) : null} + + {!isNative && !hideAlias ? ( + {aliasField} + ) : null} + + ); + + const content: ReactNode = ( + <> + {showPaidFeaturesAlert && layout === "default" && ( + + + + )} + {sshSettingsFields} + {destinationSection} + + ); + + if (embedInParentGrid) { + return content; + } + + return {content}; +} diff --git a/src/app/[orgId]/settings/resources/private/[niceId]/access/page.tsx b/src/app/[orgId]/settings/resources/private/[niceId]/access/page.tsx new file mode 100644 index 000000000..e09366329 --- /dev/null +++ b/src/app/[orgId]/settings/resources/private/[niceId]/access/page.tsx @@ -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 ( + + + + + {t("authentication")} + + + {t( + "editInternalResourceDialogAccessControlDescription" + )} + + + + + +
+ + + + +
+
+ + + + +
+
+ ); +} diff --git a/src/app/[orgId]/settings/resources/private/[niceId]/cidr/page.tsx b/src/app/[orgId]/settings/resources/private/[niceId]/cidr/page.tsx new file mode 100644 index 000000000..8064ec883 --- /dev/null +++ b/src/app/[orgId]/settings/resources/private/[niceId]/cidr/page.tsx @@ -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; + + const form = useForm({ + 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 ( + + + + + {t("cidrSettings")} + + + {t( + "editInternalResourceDialogDestinationCidrDescription" + )} + + + + + +
+ + + + + + + + + + + + + + +
+ +
+
+ + + + +
+
+ ); +} diff --git a/src/app/[orgId]/settings/resources/private/[niceId]/general/page.tsx b/src/app/[orgId]/settings/resources/private/[niceId]/general/page.tsx new file mode 100644 index 000000000..80cdc41ea --- /dev/null +++ b/src/app/[orgId]/settings/resources/private/[niceId]/general/page.tsx @@ -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; + + const form = useForm({ + 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 ( + + + + + {t("resourceGeneral")} + + + {t("resourceGeneralDescription")} + + + + + +
+ + + + ( + + + {t( + "editInternalResourceDialogName" + )} + + + + + + + )} + /> + + + ( + + + {t("identifier")} + + + + + + + )} + /> + + +
+ +
+
+ + + + +
+
+ ); +} diff --git a/src/app/[orgId]/settings/resources/private/[niceId]/host/page.tsx b/src/app/[orgId]/settings/resources/private/[niceId]/host/page.tsx new file mode 100644 index 000000000..73d4207cd --- /dev/null +++ b/src/app/[orgId]/settings/resources/private/[niceId]/host/page.tsx @@ -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; + + const form = useForm({ + 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 ( + + + + + {t("hostSettings")} + + + {t("editInternalResourceDialogDestinationDescription")} + + + + + +
+ + + + + + + + + + + + + + +
+ +
+
+ + + + +
+
+ ); +} diff --git a/src/app/[orgId]/settings/resources/private/[niceId]/http/page.tsx b/src/app/[orgId]/settings/resources/private/[niceId]/http/page.tsx new file mode 100644 index 000000000..d69210ef5 --- /dev/null +++ b/src/app/[orgId]/settings/resources/private/[niceId]/http/page.tsx @@ -0,0 +1,152 @@ +"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; + + const form = useForm({ + 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 ( + + + + + {t("httpSettings")} + + + {t( + "editInternalResourceDialogHttpConfigurationDescription" + )} + + + + + +
+ + + + + + + + + + +
+ +
+
+ + + + +
+
+ ); +} diff --git a/src/app/[orgId]/settings/resources/private/[niceId]/layout.tsx b/src/app/[orgId]/settings/resources/private/[niceId]/layout.tsx new file mode 100644 index 000000000..a1e97e188 --- /dev/null +++ b/src/app/[orgId]/settings/resources/private/[niceId]/layout.tsx @@ -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 ( + <> + + + + +
+ + + {children} + +
+
+
+ + ); +} diff --git a/src/app/[orgId]/settings/resources/private/[niceId]/page.tsx b/src/app/[orgId]/settings/resources/private/[niceId]/page.tsx new file mode 100644 index 000000000..9a7b7e5b3 --- /dev/null +++ b/src/app/[orgId]/settings/resources/private/[niceId]/page.tsx @@ -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` + ); +} diff --git a/src/app/[orgId]/settings/resources/private/[niceId]/ssh/page.tsx b/src/app/[orgId]/settings/resources/private/[niceId]/ssh/page.tsx new file mode 100644 index 000000000..6de4082b6 --- /dev/null +++ b/src/app/[orgId]/settings/resources/private/[niceId]/ssh/page.tsx @@ -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; + + const form = useForm({ + 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 ( + + + + + + {t("sshSettings")} + + + {t("editInternalResourceDialogDestinationDescription")} + + + +
+
+ + + + + form.setValue( + "authDaemonPort", + value, + { shouldValidate: true } + ) + } + authDaemonPortError={ + form.formState.errors.authDaemonPort + ?.message + } + sshServerMode={sshServerMode} + serverModeDisplay="badge" + /> + + + + + + + + + +
+ +
+
+
+ ); +} diff --git a/src/app/[orgId]/settings/resources/private/create/page.tsx b/src/app/[orgId]/settings/resources/private/create/page.tsx new file mode 100644 index 000000000..f8109fd66 --- /dev/null +++ b/src/app/[orgId]/settings/resources/private/create/page.tsx @@ -0,0 +1,615 @@ +"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 { + 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([]); + + const formSchema = useMemo(() => createCreateFormSchema(t), [t]); + type FormValues = z.infer; + + const form = useForm({ + 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", + disableIcmp: false + } + }); + + useEffect(() => { + if (!siteIdNumber) return; + + void api + .get>(`/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[] = [ + { 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> + >( + `/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) + .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 ( + <> +
+ + +
+ +
+ + {/* General */} + + + + {t("resourceCreateGeneral")} + + + {t("resourceCreateGeneralDescription")} + + + + + + + ( + + + {t("name")} + + + + + + + {t( + "resourceNameDescription" + )} + + + )} + /> + + + + ( + + + {t("type")} + + + 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} + /> + + + )} + /> + + + {mode === "http" && ( + + + { + 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 + ); + }} + /> + + + {t( + "resourceDomainDescription" + )} + + + + )} + + {(mode === "host" || + (mode === "ssh" && !isNativeSsh)) && ( + + + + )} + + + + + + {/* Host destination */} + {mode === "host" && ( + + + + {t("hostSettings")} + + + {t( + "editInternalResourceDialogDestinationDescription" + )} + + + + + + + + + + + + + + + + )} + + {/* CIDR destination */} + {mode === "cidr" && ( + + + + {t("cidrSettings")} + + + {t( + "editInternalResourceDialogDestinationCidrDescription" + )} + + + + + + + + + + + + + + + + )} + + {/* HTTP configuration */} + {mode === "http" && ( + + + + + {t("httpSettings")} + + + {t( + "editInternalResourceDialogHttpConfigurationDescription" + )} + + +
+ + + + + + + + + + + + +
+
+ )} + + {/* SSH server */} + {mode === "ssh" && ( + + + + + {t("sshServer")} + + + {t("sshServerDescription")} + + +
+ + + + + +
+
+ )} + +
+ + +
+
+ + + ); +} diff --git a/src/app/[orgId]/settings/resources/private/formControlUtils.ts b/src/app/[orgId]/settings/resources/private/formControlUtils.ts new file mode 100644 index 000000000..748a1fab0 --- /dev/null +++ b/src/app/[orgId]/settings/resources/private/formControlUtils.ts @@ -0,0 +1,24 @@ +import type { + Control, + FieldValues, + UseFormSetValue, + UseFormWatch +} from "react-hook-form"; + +export function asAnyControl( + control: Control +): Control { + return control as Control; +} + +export function asAnySetValue( + setValue: UseFormSetValue +): UseFormSetValue { + return setValue as UseFormSetValue; +} + +export function asAnyWatch( + watch: UseFormWatch +): UseFormWatch { + return watch as UseFormWatch; +} diff --git a/src/app/[orgId]/settings/resources/private/page.tsx b/src/app/[orgId]/settings/resources/private/page.tsx index 23ff86296..10ef118f2 100644 --- a/src/app/[orgId]/settings/resources/private/page.tsx +++ b/src/app/[orgId]/settings/resources/private/page.tsx @@ -122,6 +122,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, diff --git a/src/app/[orgId]/settings/resources/private/privateResourceUtils.ts b/src/app/[orgId]/settings/resources/private/privateResourceUtils.ts new file mode 100644 index 000000000..37cc63756 --- /dev/null +++ b/src/app/[orgId]/settings/resources/private/privateResourceUtils.ts @@ -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 +): 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"; +} diff --git a/src/app/[orgId]/settings/resources/private/useSaveSiteResource.ts b/src/app/[orgId]/settings/resources/private/useSaveSiteResource.ts new file mode 100644 index 000000000..0515c4673 --- /dev/null +++ b/src/app/[orgId]/settings/resources/private/useSaveSiteResource.ts @@ -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, + 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 }; +} diff --git a/src/app/[orgId]/settings/resources/public/[niceId]/ssh/page.tsx b/src/app/[orgId]/settings/resources/public/[niceId]/ssh/page.tsx index 8696e1967..7054740cf 100644 --- a/src/app/[orgId]/settings/resources/public/[niceId]/ssh/page.tsx +++ b/src/app/[orgId]/settings/resources/public/[niceId]/ssh/page.tsx @@ -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({ - -
-

- {t("sshServerMode")} -

- - {sshServerMode == "standard" - ? t("sshServerModeStandard") - : t("sshServerModePangolin")} - -
-
- - -
-

- {t("sshAuthenticationMethod")} -

- - value={pamMode} - options={authMethodOptions} - onChange={(value) => - form.setValue("pamMode", value, { - shouldValidate: true - }) - } - cols={2} - /> -
-
- - {showDaemonLocation && ( - -
-

- {t("sshAuthDaemonLocation")} -

- - value={standardDaemonLocation} - options={daemonLocationOptions} - onChange={(value) => - form.setValue( - "standardDaemonLocation", - value, - { - shouldValidate: true - } - ) - } - cols={2} - /> -

- {t("sshDaemonDisclaimer")}{" "} - - {t("learnMore")} - - -

-
-
- )} - - {showDaemonPort && ( - - ( - - - {t("sshDaemonPort")} - - - - - - - )} - /> - - )} + + 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" + /> diff --git a/src/app/[orgId]/settings/resources/public/create/page.tsx b/src/app/[orgId]/settings/resources/public/create/page.tsx index be02b8e07..43d9528b9 100644 --- a/src/app/[orgId]/settings/resources/public/create/page.tsx +++ b/src/app/[orgId]/settings/resources/public/create/page.tsx @@ -777,8 +777,8 @@ export default function Page() { <>
- - - - - - ); -} diff --git a/src/components/EditPrivateResourceDialog.tsx b/src/components/EditPrivateResourceDialog.tsx deleted file mode 100644 index 077c85ee0..000000000 --- a/src/components/EditPrivateResourceDialog.tsx +++ /dev/null @@ -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 ( - { - if (!isOpen) setOpen(false); - }} - > - - - - {t("editInternalResourceDialogEditClientResource")} - - - {t( - "editInternalResourceDialogUpdateResourceProperties", - { - resourceName: resource.name - } - )} - - - - - startTransition(() => handleSubmit(values)) - } - onSubmitDisabledChange={setIsHttpModeDisabled} - /> - - - - - - - - - - ); -} diff --git a/src/components/InfoSection.tsx b/src/components/InfoSection.tsx index c26303112..989311c0b 100644 --- a/src/components/InfoSection.tsx +++ b/src/components/InfoSection.tsx @@ -12,7 +12,7 @@ export function InfoSections({ cols?: number; /** content (default): fixed gap, columns hug content, left-aligned; fill: equal-width columns across the row */ columnSizing?: "fill" | "content"; - /** panel: 2 columns until xl for narrow containers such as side panels */ + /** panel: fixed 2-column grid for narrow containers such as side panels */ layout?: "default" | "panel"; }) { const n = cols || 1; @@ -24,11 +24,11 @@ export function InfoSections({ className={cn( "grid w-full min-w-0 gap-4", layout === "panel" - ? "grid-cols-2 xl:grid-cols-(--columns) xl:items-start xl:space-x-16" + ? "grid-cols-2 items-start" : "grid-cols-2 md:grid-cols-(--columns) md:items-start md:space-x-16", columnSizing === "content" && (layout === "panel" - ? "xl:justify-items-start xl:justify-start" + ? "justify-items-start justify-start" : "md:justify-items-start md:justify-start") )} style={{ diff --git a/src/components/PrivateResourceForm.tsx b/src/components/PrivateResourceForm.tsx deleted file mode 100644 index 23fd8a7b2..000000000 --- a/src/components/PrivateResourceForm.tsx +++ /dev/null @@ -1,2147 +0,0 @@ -"use client"; - -import { - SettingsSubsectionDescription, - SettingsSubsectionHeader, - SettingsSubsectionTitle -} from "@app/components/Settings"; -import { HorizontalTabs } from "@app/components/HorizontalTabs"; -import { - OptionSelect, - type OptionSelectOption -} from "@app/components/OptionSelect"; -import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert"; -import { StrategySelect } from "@app/components/StrategySelect"; -import { Tag, TagInput } from "@app/components/tags/tag-input"; -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 { - Popover, - PopoverContent, - PopoverTrigger -} from "@app/components/ui/popover"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue -} from "@app/components/ui/select"; -import { Switch } from "@app/components/ui/switch"; -import { useEnvContext } from "@app/hooks/useEnvContext"; -import { usePaidStatus } from "@app/hooks/usePaidStatus"; -import { cn } from "@app/lib/cn"; -import { getUserDisplayName } from "@app/lib/getUserDisplayName"; -import { orgQueries, resourceQueries } from "@app/lib/queries"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { tierMatrix } from "@server/lib/billing/tierMatrix"; -import { UserType } from "@server/types/UserTypes"; -import { useQuery } from "@tanstack/react-query"; -import { - ArrowDownIcon, - ChevronDownIcon, - ChevronsUpDown, - ExternalLink -} from "lucide-react"; -import { useTranslations } from "next-intl"; -import { useEffect, useRef, useState } from "react"; -import { useForm } from "react-hook-form"; -import { z } from "zod"; -import { - MultiSitesSelector, - formatMultiSitesSelectorLabel -} from "./multi-site-selector"; -import { SitesSelector } from "./site-selector"; -import type { Selectedsite } from "./site-selector"; - -import { MachinesSelector } from "./machines-selector"; -import DomainPicker from "@app/components/DomainPicker"; -import { SwitchInput } from "@app/components/SwitchInput"; -import CertificateStatus from "@app/components/CertificateStatus"; -import { UsersSelector } from "./users-selector"; -import { RolesSelector } from "./roles-selector"; -import { build } from "@server/build"; - -// --- Helpers (shared) --- - -const isValidPortRangeString = (val: string | undefined | null): boolean => { - if (!val || val.trim() === "" || val.trim() === "*") return true; - const parts = val.split(",").map((p) => p.trim()); - for (const part of parts) { - if (part === "") return false; - if (part.includes("-")) { - const [start, end] = part.split("-").map((p) => p.trim()); - if (!start || !end) return false; - const startPort = parseInt(start, 10); - const endPort = parseInt(end, 10); - if (isNaN(startPort) || isNaN(endPort)) return false; - if ( - startPort < 1 || - startPort > 65535 || - endPort < 1 || - endPort > 65535 - ) - return false; - if (startPort > endPort) return false; - } else { - const port = parseInt(part, 10); - if (isNaN(port) || port < 1 || port > 65535) return false; - } - } - return true; -}; - -const getPortRangeValidationMessage = (t: (key: string) => string) => - t("editInternalResourceDialogPortRangeValidationError"); - -const createPortRangeStringSchema = (t: (key: string) => string) => - z - .string() - .optional() - .nullable() - .refine((val) => isValidPortRangeString(val), { - message: getPortRangeValidationMessage(t) - }); - -export type PortMode = "all" | "blocked" | "custom"; -export const getPortModeFromString = ( - val: string | undefined | null -): PortMode => { - if (val === "*") return "all"; - if (!val || val.trim() === "") return "blocked"; - return "custom"; -}; - -export const getPortStringFromMode = ( - mode: PortMode, - customValue: string -): string | undefined => { - if (mode === "all") return "*"; - if (mode === "blocked") return ""; - return customValue; -}; - -export const isHostname = (destination: string | null): boolean => - !!destination && /[a-zA-Z]/.test(destination); - -export const cleanForFQDN = (name: string): string => - name - .toLowerCase() - .replace(/[^a-z0-9.-]/g, "-") - .replace(/[-]+/g, "-") - .replace(/^-|-$/g, "") - .replace(/^\.|\.$/g, ""); - -// --- Types --- - -export type InternalResourceMode = "host" | "cidr" | "http" | "ssh"; - -export type InternalResourceData = { - id: number; - name: string; - orgId: string; - siteNames: string[]; - mode: InternalResourceMode; - siteIds: number[]; - niceId: string; - destination: string | null; - alias?: string | null; - tcpPortRangeString?: string | null; - udpPortRangeString?: string | null; - disableIcmp?: boolean; - authDaemonMode?: "site" | "remote" | "native" | null; - authDaemonPort?: number | null; - pamMode?: "passthrough" | "push" | null; - destinationPort?: number | null; - scheme?: "http" | "https" | null; - ssl?: boolean; - subdomain?: string | null; - domainId?: string | null; - fullDomain?: string | null; -}; - -const tagSchema = z.object({ id: z.string(), text: z.string() }); - -function buildSelectedSitesForResource( - resource: InternalResourceData -): Selectedsite[] { - return resource.siteIds.map((siteId, idx) => ({ - name: resource.siteNames[idx] ?? "", - siteId, - type: "newt" as const - })); -} - -export type InternalResourceFormValues = { - name: string; - siteIds: number[]; - mode: InternalResourceMode; - destination: string | null; - alias?: string | null; - niceId?: string; - tcpPortRangeString?: string | null; - udpPortRangeString?: string | null; - disableIcmp?: boolean; - authDaemonMode?: "site" | "remote" | "native" | null; - authDaemonPort?: number | null; - pamMode?: "passthrough" | "push" | null; - destinationPort?: number | null; - scheme?: "http" | "https"; - ssl?: boolean; - httpConfigSubdomain?: string | null; - httpConfigDomainId?: string | null; - httpConfigFullDomain?: string | null; - roles?: z.infer[]; - users?: z.infer[]; - clients?: z.infer[]; -}; - -type InternalResourceFormProps = { - variant: "create" | "edit"; - resource?: InternalResourceData; - open?: boolean; - orgId: string; - siteResourceId?: number; - formId: string; - onSubmit: (values: InternalResourceFormValues) => void | Promise; - onSubmitDisabledChange?: (disabled: boolean) => void; - initialSites?: Selectedsite[]; -}; - -export function PrivateResourceForm({ - variant, - resource, - open, - orgId, - siteResourceId, - formId, - onSubmit, - onSubmitDisabledChange, - initialSites = [] -}: InternalResourceFormProps) { - const t = useTranslations(); - const { env } = useEnvContext(); - const { isPaidUser } = usePaidStatus(); - const disableEnterpriseFeatures = env.flags.disableEnterpriseFeatures; - const sshSectionDisabled = !isPaidUser(tierMatrix.advancedPrivateResources); - const httpSectionDisabled = !isPaidUser( - tierMatrix.advancedPrivateResources - ); - - const nameRequiredKey = - variant === "create" - ? "createInternalResourceDialogNameRequired" - : "editInternalResourceDialogNameRequired"; - const nameMaxKey = - variant === "create" - ? "createInternalResourceDialogNameMaxLength" - : "editInternalResourceDialogNameMaxLength"; - const siteRequiredKey = - variant === "create" - ? "createInternalResourceDialogPleaseSelectSite" - : undefined; - const nameLabelKey = - variant === "create" - ? "createInternalResourceDialogName" - : "editInternalResourceDialogName"; - const modeLabelKey = - variant === "create" - ? "createInternalResourceDialogMode" - : "editInternalResourceDialogMode"; - const modeHostKey = - variant === "create" - ? "createInternalResourceDialogModeHost" - : "editInternalResourceDialogModeHost"; - const modeCidrKey = - variant === "create" - ? "createInternalResourceDialogModeCidr" - : "editInternalResourceDialogModeCidr"; - const modeHttpKey = - variant === "create" - ? "createInternalResourceDialogModeHttp" - : "editInternalResourceDialogModeHttp"; - const modeSshKey = - variant === "create" - ? "createInternalResourceDialogModeSsh" - : "editInternalResourceDialogModeSsh"; - const schemeLabelKey = - variant === "create" - ? "createInternalResourceDialogScheme" - : "editInternalResourceDialogScheme"; - const enableSslLabelKey = - variant === "create" - ? "createInternalResourceDialogEnableSsl" - : "editInternalResourceDialogEnableSsl"; - const enableSslDescriptionKey = - variant === "create" - ? "createInternalResourceDialogEnableSslDescription" - : "editInternalResourceDialogEnableSslDescription"; - const destinationLabelKey = - variant === "create" - ? "createInternalResourceDialogDestination" - : "editInternalResourceDialogDestination"; - const destinationRequiredKey = - variant === "create" - ? "createInternalResourceDialogDestinationRequired" - : undefined; - const aliasLabelKey = - variant === "create" - ? "createInternalResourceDialogAlias" - : "editInternalResourceDialogAlias"; - const destinationPortLabelKey = - variant === "create" - ? "createInternalResourceDialogModePort" - : "editInternalResourceDialogModePort"; - const httpConfigurationTitleKey = - variant === "create" - ? "createInternalResourceDialogHttpConfiguration" - : "editInternalResourceDialogHttpConfiguration"; - const httpConfigurationDescriptionKey = - variant === "create" - ? "createInternalResourceDialogHttpConfigurationDescription" - : "editInternalResourceDialogHttpConfigurationDescription"; - - const siteIdsSchema = siteRequiredKey - ? z.array(z.number().int().positive()).min(1, t(siteRequiredKey)) - : z.array(z.number().int().positive()).min(1); - - const formSchema = z - .object({ - name: z.string().min(1, t(nameRequiredKey)).max(255, t(nameMaxKey)), - siteIds: siteIdsSchema, - mode: z.enum(["host", "cidr", "http", "ssh"]), - destination: z.string().nullish(), - alias: z.string().nullish(), - destinationPort: z - .number() - .int() - .min(1) - .max(65535) - .optional() - .nullable(), - scheme: z.enum(["http", "https"]).optional(), - ssl: z.boolean().optional(), - httpConfigSubdomain: z.string().nullish(), - httpConfigDomainId: z.string().nullish(), - httpConfigFullDomain: z.string().nullish(), - niceId: z - .string() - .min(1) - .max(255) - .regex(/^[a-zA-Z0-9-]+$/) - .optional(), - tcpPortRangeString: createPortRangeStringSchema(t), - udpPortRangeString: createPortRangeStringSchema(t), - disableIcmp: z.boolean().optional(), - authDaemonMode: z - .enum(["site", "remote", "native"]) - .optional() - .nullable(), - authDaemonPort: z.number().int().positive().optional().nullable(), - pamMode: z.enum(["passthrough", "push"]).optional().nullable(), - roles: z.array(tagSchema).optional(), - users: z.array(tagSchema).optional(), - clients: z - .array( - z.object({ - clientId: z.number(), - name: z.string() - }) - ) - .optional() - }) - .superRefine((data, ctx) => { - const isNativeSsh = - data.mode === "ssh" && data.authDaemonMode === "native"; - const trimmedDestination = data.destination?.trim(); - if ( - !isNativeSsh && - (!trimmedDestination || trimmedDestination.length < 1) - ) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: destinationRequiredKey - ? t(destinationRequiredKey) - : "Destination is required", - path: ["destination"] - }); - } - if (data.mode === "ssh" && !isNativeSsh) { - if ( - data.destinationPort == null || - !Number.isFinite(data.destinationPort) || - data.destinationPort < 1 - ) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: t("internalResourceHttpPortRequired"), - path: ["destinationPort"] - }); - } - } - if (data.mode !== "http") return; - if (!data.scheme) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: t("internalResourceDownstreamSchemeRequired"), - path: ["scheme"] - }); - } - if ( - !isNativeSsh && - (data.destinationPort == null || - !Number.isFinite(data.destinationPort) || - data.destinationPort < 1) - ) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: t("internalResourceHttpPortRequired"), - path: ["destinationPort"] - }); - } - }); - - type FormData = z.infer; - - const clientsQuery = useQuery( - orgQueries.machineClients({ orgId, perPage: 1 }) - ); - const resourceRolesQuery = useQuery({ - ...resourceQueries.siteResourceRoles({ - siteResourceId: siteResourceId ?? 0 - }), - enabled: siteResourceId != null - }); - const resourceUsersQuery = useQuery({ - ...resourceQueries.siteResourceUsers({ - siteResourceId: siteResourceId ?? 0 - }), - enabled: siteResourceId != null - }); - const resourceClientsQuery = useQuery({ - ...resourceQueries.siteResourceClients({ - siteResourceId: siteResourceId ?? 0 - }), - enabled: siteResourceId != null - }); - - const allClients = (clientsQuery.data ?? []) - .filter((c) => !c.userId) - .map((c) => ({ id: c.clientId.toString(), text: c.name })); - - let formRoles: FormData["roles"] = []; - let formUsers: FormData["users"] = []; - let existingClients: FormData["clients"] = []; - if (siteResourceId != null) { - const rolesData = resourceRolesQuery.data; - const usersData = resourceUsersQuery.data; - const clientsData = resourceClientsQuery.data; - if (rolesData) { - formRoles = (rolesData as { roleId: number; name: string }[]) - .map((i) => ({ id: i.roleId.toString(), text: i.name })) - .filter((r) => r.text !== "Admin"); - } - if (usersData) { - formUsers = ( - usersData as { - userId: string; - email?: string; - username?: string; - type?: string; - idpName?: string; - }[] - ).map((i) => ({ - id: i.userId.toString(), - text: `${getUserDisplayName({ email: i.email, username: i.username })}${i.type !== UserType.Internal ? ` (${i.idpName})` : ""}` - })); - } - if (clientsData) { - existingClients = [ - ...(clientsData as { clientId: number; name: string }[]) - ]; - } - } - - const loadingRolesUsers = - clientsQuery.isLoading || - (siteResourceId != null && - (resourceRolesQuery.isLoading || - resourceUsersQuery.isLoading || - resourceClientsQuery.isLoading)); - - const hasMachineClients = allClients.length > 0; - - const [sshServerMode, setSshServerMode] = useState<"standard" | "native">( - () => { - if (variant === "edit" && resource) { - return resource.authDaemonMode === "native" - ? "native" - : "standard"; - } - return "native"; - } - ); - - const [tcpPortMode, setTcpPortMode] = useState(() => - variant === "edit" && resource - ? getPortModeFromString(resource.tcpPortRangeString) - : "all" - ); - const [udpPortMode, setUdpPortMode] = useState(() => - variant === "edit" && resource - ? getPortModeFromString(resource.udpPortRangeString) - : "all" - ); - const [tcpCustomPorts, setTcpCustomPorts] = useState(() => - variant === "edit" && - resource && - resource.tcpPortRangeString && - resource.tcpPortRangeString !== "*" - ? resource.tcpPortRangeString - : "" - ); - const [udpCustomPorts, setUdpCustomPorts] = useState(() => - variant === "edit" && - resource && - resource.udpPortRangeString && - resource.udpPortRangeString !== "*" - ? resource.udpPortRangeString - : "" - ); - - const defaultValues: FormData = - variant === "edit" && resource - ? { - name: resource.name, - siteIds: resource.siteIds, - mode: resource.mode ?? "host", - destination: resource.destination ?? "", - alias: resource.alias ?? null, - tcpPortRangeString: resource.tcpPortRangeString ?? "*", - udpPortRangeString: resource.udpPortRangeString ?? "*", - disableIcmp: resource.disableIcmp ?? false, - authDaemonMode: - resource.authDaemonMode === "native" - ? "native" - : (resource.authDaemonMode ?? "site"), - authDaemonPort: resource.authDaemonPort ?? null, - pamMode: resource.pamMode ?? "passthrough", - destinationPort: resource.destinationPort ?? null, - scheme: resource.scheme ?? "http", - ssl: resource.ssl ?? false, - httpConfigSubdomain: resource.subdomain ?? null, - httpConfigDomainId: resource.domainId ?? null, - httpConfigFullDomain: resource.fullDomain ?? null, - niceId: resource.niceId, - roles: [], - users: [], - clients: [] - } - : { - name: "", - siteIds: [], - mode: "host", - destination: "", - alias: null, - destinationPort: null, - scheme: "http", - ssl: true, - httpConfigSubdomain: null, - httpConfigDomainId: null, - httpConfigFullDomain: null, - tcpPortRangeString: "*", - udpPortRangeString: "*", - disableIcmp: false, - authDaemonMode: "native", - authDaemonPort: null, - pamMode: "passthrough", - roles: [], - users: [], - clients: [] - }; - - const [selectedSites, setSelectedSites] = useState(() => - variant === "edit" && resource - ? buildSelectedSitesForResource(resource) - : [] - ); - - const form = useForm({ - resolver: zodResolver(formSchema), - defaultValues - }); - - const mode = form.watch("mode"); - const aliasValue = form.watch("alias"); - const httpConfigSubdomain = form.watch("httpConfigSubdomain"); - const httpConfigDomainId = form.watch("httpConfigDomainId"); - const httpConfigFullDomain = form.watch("httpConfigFullDomain"); - const isHttpMode = mode === "http"; - const isSshMode = mode === "ssh"; - const authDaemonMode = form.watch("authDaemonMode") ?? "site"; - const pamMode = form.watch("pamMode") ?? "passthrough"; - const isNative = sshServerMode === "native"; - const showDaemonLocation = - mode === "ssh" && !isNative && pamMode === "push"; - const showDaemonPort = - mode === "ssh" && - !isNative && - pamMode === "push" && - authDaemonMode === "remote"; - const aliasEndsWithLocal = - typeof aliasValue === "string" && - aliasValue.trim().toLowerCase().endsWith(".local"); - const hasInitialized = useRef(false); - const previousResourceId = useRef(null); - const initialSitesRef = useRef(initialSites); - initialSitesRef.current = initialSites; - - useEffect(() => { - const tcpValue = getPortStringFromMode(tcpPortMode, tcpCustomPorts); - form.setValue("tcpPortRangeString", tcpValue); - }, [tcpPortMode, tcpCustomPorts, form]); - - useEffect(() => { - const udpValue = getPortStringFromMode(udpPortMode, udpCustomPorts); - form.setValue("udpPortRangeString", udpValue); - }, [udpPortMode, udpCustomPorts, form]); - - // Reset when create dialog opens - useEffect(() => { - if (variant === "create" && open) { - const prefillSites = - initialSitesRef.current.length > 0 - ? initialSitesRef.current - : []; - form.reset({ - name: "", - siteIds: prefillSites.map((s) => s.siteId), - mode: "host", - destination: "", - alias: null, - destinationPort: null, - scheme: "http", - ssl: true, - httpConfigSubdomain: null, - httpConfigDomainId: null, - httpConfigFullDomain: null, - tcpPortRangeString: "*", - udpPortRangeString: "*", - disableIcmp: false, - authDaemonMode: "native", - authDaemonPort: null, - pamMode: "passthrough", - roles: [], - users: [], - clients: [] - }); - setSelectedSites(prefillSites); - setSshServerMode("native"); - setTcpPortMode("all"); - setUdpPortMode("all"); - setTcpCustomPorts(""); - setUdpCustomPorts(""); - } - }, [variant, open, form]); - - // Reset when edit dialog opens / resource changes - useEffect(() => { - if (variant === "edit" && resource) { - const resourceChanged = previousResourceId.current !== resource.id; - if (resourceChanged) { - form.reset({ - name: resource.name, - siteIds: resource.siteIds, - mode: resource.mode ?? "host", - destination: resource.destination ?? "", - alias: resource.alias ?? null, - destinationPort: resource.destinationPort ?? null, - scheme: resource.scheme ?? "http", - ssl: resource.ssl ?? false, - httpConfigSubdomain: resource.subdomain ?? null, - httpConfigDomainId: resource.domainId ?? null, - httpConfigFullDomain: resource.fullDomain ?? null, - tcpPortRangeString: resource.tcpPortRangeString ?? "*", - udpPortRangeString: resource.udpPortRangeString ?? "*", - disableIcmp: resource.disableIcmp ?? false, - authDaemonMode: - resource.authDaemonMode === "native" - ? "native" - : (resource.authDaemonMode ?? "site"), - authDaemonPort: resource.authDaemonPort ?? null, - pamMode: resource.pamMode ?? "passthrough", - roles: [], - users: [], - clients: [] - }); - setSshServerMode( - resource.authDaemonMode === "native" ? "native" : "standard" - ); - setSelectedSites(buildSelectedSitesForResource(resource)); - setTcpPortMode( - getPortModeFromString(resource.tcpPortRangeString) - ); - setUdpPortMode( - getPortModeFromString(resource.udpPortRangeString) - ); - setTcpCustomPorts( - resource.tcpPortRangeString && - resource.tcpPortRangeString !== "*" - ? resource.tcpPortRangeString - : "" - ); - setUdpCustomPorts( - resource.udpPortRangeString && - resource.udpPortRangeString !== "*" - ? resource.udpPortRangeString - : "" - ); - previousResourceId.current = resource.id; - } - } - }, [variant, resource, form]); - - // When edit dialog closes, clear previousResourceId so next open (for any resource) resets from fresh data - useEffect(() => { - if (variant === "edit" && open === false) { - previousResourceId.current = null; - } - }, [variant, open]); - - // Populate roles/users/clients when edit data is loaded - useEffect(() => { - if ( - variant === "edit" && - siteResourceId != null && - !loadingRolesUsers && - !hasInitialized.current - ) { - hasInitialized.current = true; - form.setValue("roles", formRoles); - form.setValue("users", formUsers); - form.setValue("clients", existingClients); - } - }, [ - variant, - siteResourceId, - loadingRolesUsers, - formRoles, - formUsers, - existingClients, - form - ]); - - useEffect(() => { - onSubmitDisabledChange?.( - (isHttpMode && httpSectionDisabled) || - (isSshMode && sshSectionDisabled) - ); - }, [ - isHttpMode, - httpSectionDisabled, - isSshMode, - sshSectionDisabled, - onSubmitDisabledChange - ]); - - return ( -
- { - const siteIds = values.siteIds; - const trimmedDestination = values.destination?.trim(); - const isSshMode = values.mode === "ssh"; - onSubmit({ - ...values, - siteIds, - destination: - trimmedDestination && trimmedDestination.length > 0 - ? trimmedDestination - : null, - tcpPortRangeString: isSshMode - ? undefined - : values.tcpPortRangeString, - udpPortRangeString: isSshMode - ? undefined - : values.udpPortRangeString, - clients: (values.clients ?? []).map((c) => ({ - id: c.clientId.toString(), - text: c.name - })) - }); - })} - className="space-y-6" - id={formId} - > -
- ( - - {t(nameLabelKey)} - - - - - - )} - /> - {variant === "edit" && ( - ( - - {t("identifier")} - - - - - - )} - /> - )} -
- - -
-
-
- -
- {t( - "editInternalResourceDialogDestinationDescription" - )} -
-
-
-
-
- ( - - - {t("sites")} - - {mode === "ssh" && - (sshServerMode === - "native" || - (pamMode === "push" && - authDaemonMode === - "site")) ? ( - - - - - - - - { - setSelectedSites( - [ - site - ] - ); - field.onChange( - [ - site.siteId - ] - ); - }} - /> - - - ) : ( - - - - - - - - { - setSelectedSites( - sites - ); - field.onChange( - sites.map( - ( - s - ) => - s.siteId - ) - ); - }} - /> - - - )} - - - )} - /> -
-
- { - const modeOptions: OptionSelectOption[] = - [ - { - value: "host", - label: t( - modeHostKey - ) - }, - { - value: "cidr", - label: t( - modeCidrKey - ) - }, - ...(!disableEnterpriseFeatures - ? [ - { - value: "http" as const, - label: t( - modeHttpKey - ) - }, - { - value: "ssh" as const, - label: t( - modeSshKey - ) - } - ] - : []) - ]; - return ( - - - {t(modeLabelKey)} - - - options={ - modeOptions - } - value={field.value} - onChange={( - newMode - ) => { - field.onChange( - newMode - ); - if ( - newMode === - "ssh" - ) { - form.setValue( - "destinationPort", - 22 - ); - } else if ( - newMode === - "http" - ) { - form.setValue( - "destinationPort", - 443 - ); - } else { - form.setValue( - "destinationPort", - null - ); - } - }} - cols={2} - /> - - - ); - }} - /> -
-
- {selectedSites.length > 1 && ( -

- {t( - "internalResourceFormMultiSiteRoutingHelp" - )}{" "} - - {t( - "internalResourceFormMultiSiteRoutingHelpLearnMore" - )} - - - . -

- )} -
-
- {mode === "http" && ( -
- ( - - - {t(schemeLabelKey)} - - - - - )} - /> -
- )} - {((mode === "ssh" && - sshServerMode !== "native") || - mode === "http" || - mode === "host" || - mode === "cidr") && ( -
- ( - - - {t(destinationLabelKey)} - - - - field.onChange( - e.target - .value === - "" - ? null - : e - .target - .value - ) - } - /> - - - - )} - /> -
- )} - {(mode === "host" || mode === "ssh") && ( -
- ( - - - {t(aliasLabelKey)} - - - - - {aliasEndsWithLocal && ( -

- {t( - "internalResourceAliasLocalWarning" - )} -

- )} - -
- )} - /> -
- )} - {(mode === "http" || - (mode === "ssh" && - sshServerMode !== "native")) && ( -
- ( - - - {t( - destinationPortLabelKey - )} - - - { - const raw = - e.target - .value; - if ( - raw === "" - ) { - field.onChange( - null - ); - return; - } - const n = - Number(raw); - field.onChange( - Number.isFinite( - n - ) - ? n - : null - ); - }} - /> - - - - )} - /> -
- )} -
-
- - {(isHttpMode || isSshMode) && ( - - )} - - {isHttpMode ? ( -
-
- -
- {t(httpConfigurationDescriptionKey)} -
-
-
- { - if (res === null) { - 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 - ); - }} - /> -
-
- ( - - - - - - )} - /> - {variant === "edit" && - resource?.domainId && - httpConfigFullDomain && - httpConfigDomainId === - resource.domainId && - httpConfigFullDomain === - resource.fullDomain && - build != "oss" && - form.watch("ssl") && ( -
- - {t("certificateStatus")}: - - -
- )} -
-
- ) : ( -
- {mode !== "ssh" && ( - <> -
- -
- {t( - "editInternalResourceDialogPortRestrictionsDescription" - )} -
-
-
-
- - {t( - "editInternalResourceDialogTcp" - )} - -
-
- ( - -
- - {tcpPortMode === - "custom" ? ( - - - setTcpCustomPorts( - e - .target - .value - ) - } - /> - - ) : ( - - )} -
- -
- )} - /> -
-
-
-
- - {t( - "editInternalResourceDialogUdp" - )} - -
-
- ( - -
- - {udpPortMode === - "custom" ? ( - - - setUdpCustomPorts( - e - .target - .value - ) - } - /> - - ) : ( - - )} -
- -
- )} - /> -
-
-
-
- - {t( - "editInternalResourceDialogIcmp" - )} - -
-
- ( - -
- - - field.onChange( - !checked - ) - } - /> - - - {field.value - ? t( - "blocked" - ) - : t( - "allowed" - )} - -
- -
- )} - /> -
-
- - )} -
- )} -
- -
-
- -
- {t( - "editInternalResourceDialogAccessControlDescription" - )} -
-
- {loadingRolesUsers ? ( -
- {t("loading")} -
- ) : ( -
- ( - - {t("roles")} - - { - form.setValue( - "roles", - newUsers as [ - Tag, - ...Tag[] - ] - ); - }} - /> - - - - )} - /> - ( - - {t("users")} - { - form.setValue( - "users", - newUsers as [ - Tag, - ...Tag[] - ] - ); - }} - /> - - - )} - /> - {hasMachineClients && ( - ( - - - {t("machineClients")} - - { - form.setValue( - "clients", - machines - ); - }} - /> - - - )} - /> - )} -
- )} -
- - {/* SSH Access tab (ssh mode only) */} - {!disableEnterpriseFeatures && mode === "ssh" && ( -
- - - {/* Mode */} -
-

- {t("sshServerMode")} -

- - value={sshServerMode} - options={[ - { - id: "native", - title: t("sshServerModePangolin"), - description: t( - "sshServerModeNativeDescription" - ), - disabled: sshSectionDisabled - }, - { - id: "standard", - title: t("sshServerModeStandard"), - description: t( - "sshServerModeStandardDescription" - ), - disabled: sshSectionDisabled - } - ]} - onChange={(v) => { - if (sshSectionDisabled) return; - setSshServerMode(v); - if (v === "native") { - form.setValue( - "authDaemonMode", - "native" - ); - form.setValue( - "authDaemonPort", - null - ); - // Trim to single site - if (selectedSites.length > 1) { - const first = - selectedSites.slice(0, 1); - setSelectedSites(first); - form.setValue( - "siteIds", - first.map((s) => s.siteId) - ); - } - } else { - form.setValue( - "authDaemonMode", - "site" - ); - } - }} - cols={2} - /> -
- -
-

- {t("sshAuthenticationMethod")} -

- ( - - - - value={ - field.value ?? - "passthrough" - } - options={[ - { - id: "passthrough", - title: t( - "sshAuthMethodManual" - ), - description: t( - "sshAuthMethodManualDescription" - ), - disabled: - sshSectionDisabled - }, - { - id: "push", - title: t( - "sshAuthMethodAutomated" - ), - description: t( - "sshAuthMethodAutomatedDescription" - ), - disabled: - sshSectionDisabled - } - ]} - onChange={(v) => { - if (sshSectionDisabled) - return; - field.onChange(v); - if ( - v === "passthrough" - ) { - form.setValue( - "authDaemonPort", - null - ); - } else if ( - v === "push" - ) { - // push + site (default) = single site - const curAuthMode = - form.getValues( - "authDaemonMode" - ); - if ( - curAuthMode !== - "remote" && - selectedSites.length > - 1 - ) { - const first = - selectedSites.slice( - 0, - 1 - ); - setSelectedSites( - first - ); - form.setValue( - "siteIds", - first.map( - (s) => - s.siteId - ) - ); - } - } - }} - cols={2} - /> - - - - )} - /> -
- - {/* Daemon Location (standard + push) */} - {showDaemonLocation && ( -
-

- {t("sshAuthDaemonLocation")} -

- ( - - - - value={ - (field.value as - | "site" - | "remote") ?? - "site" - } - options={[ - { - id: "site", - title: t( - "internalResourceAuthDaemonSite" - ), - description: t( - "sshDaemonLocationSiteDescription" - ), - disabled: - sshSectionDisabled - }, - { - id: "remote", - title: t( - "sshDaemonLocationRemote" - ), - description: t( - "sshDaemonLocationRemoteDescription" - ), - disabled: - sshSectionDisabled - } - ]} - onChange={(v) => { - if ( - sshSectionDisabled - ) - return; - field.onChange(v); - if (v === "site") { - form.setValue( - "authDaemonPort", - null - ); - // site daemon = single site - if ( - selectedSites.length > - 1 - ) { - const first = - selectedSites.slice( - 0, - 1 - ); - setSelectedSites( - first - ); - form.setValue( - "siteIds", - first.map( - ( - s - ) => - s.siteId - ) - ); - } - } - }} - cols={2} - /> - - - - )} - /> -

- {t("sshDaemonDisclaimer")}{" "} - - {t("learnMore")} - - -

-
- )} - - {/* Daemon Port (standard + push + remote) */} - {showDaemonPort && ( -
- ( - - - {t("sshDaemonPort")} - - - { - if ( - sshSectionDisabled - ) - return; - const v = - e.target.value; - if (v === "") { - field.onChange( - null - ); - return; - } - const num = - parseInt(v, 10); - field.onChange( - Number.isNaN( - num - ) - ? null - : num - ); - }} - /> - - - - )} - /> -
- )} -
- )} -
-
- - ); -} diff --git a/src/components/PrivateResourcesTable.tsx b/src/components/PrivateResourcesTable.tsx index ff854a1a8..38f58ba94 100644 --- a/src/components/PrivateResourcesTable.tsx +++ b/src/components/PrivateResourcesTable.tsx @@ -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; @@ -140,13 +142,10 @@ export default function PrivateResourcesTable({ const api = createApiClient({ env }); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + const [isNavigatingToAddPage, startNavigation] = useTransition(); const [selectedInternalResource, setSelectedInternalResource] = useState(); - const [isEditDialogOpen, setIsEditDialogOpen] = useState(false); - const [editingResource, setEditingResource] = - useState(); - const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false); const [isRefreshing, startRefreshTransition] = useTransition(); @@ -450,6 +449,17 @@ export default function PrivateResourcesTable({ + + + {t("viewSettings")} + + { setSelectedInternalResource( @@ -464,15 +474,17 @@ export default function PrivateResourcesTable({ - + +
); } @@ -580,8 +592,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} @@ -597,34 +616,6 @@ export default function PrivateResourcesTable({ stickyLeftColumn="name" stickyRightColumn="actions" /> - - {editingResource && ( - { - // Delay refresh to allow modal to close smoothly - setTimeout(() => { - router.refresh(); - setEditingResource(null); - }, 150); - }} - /> - )} - - { - // Delay refresh to allow modal to close smoothly - setTimeout(() => { - router.refresh(); - }, 150); - }} - /> ); } diff --git a/src/components/SiteResourceInfoBox.tsx b/src/components/SiteResourceInfoBox.tsx new file mode 100644 index 000000000..36b7f29ad --- /dev/null +++ b/src/components/SiteResourceInfoBox.tsx @@ -0,0 +1,232 @@ +"use client"; + +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 { useTranslations } from "next-intl"; + +type SiteResourceInfoInput = { + mode: PrivateResourceMode; + destination: string | null; + destinationPort: number | null; + scheme: "http" | "https" | null; + ssl: boolean; + 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 -; + } + + const href = accessUrl ?? undefined; + const canLink = Boolean(href && isSafeUrlForLink(href)); + + if (canLink && href) { + return ( + + ); + } + + return ( + + ); +} + +export function SiteResourceInfoSections({ + siteResource, + access, + variant, + accessClassName +}: SiteResourceInfoSectionsProps) { + const t = useTranslations(); + const isPanel = variant === "panel"; + + const modeLabel: Record = { + 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 numSections = + 2 + (showDestination ? 1 : 0) + (showAlias ? 1 : 0) + (isPanel ? 1 : 0); + + const sections = ( + + + {t("type")} + + {modeLabel[siteResource.mode]} + + + + + {t("access")} + + + + + + {showDestination ? ( + + + {t("editInternalResourceDialogDestination")} + + + {destination || "-"} + + + ) : null} + + {showAlias ? ( + + + {t("editInternalResourceDialogAlias")} + + + {siteResource.alias?.trim() ? siteResource.alias : "-"} + + + ) : null} + + {isPanel ? ( + + {t("portRestrictions")} + + {!portRestrictions.hasNonDefaultPorts ? ( + + {t("resourceLauncherNoPortRestrictions")} + + ) : ( +
+ {portRestrictions.tcp.state !== "all" ? ( +
+ {t("resourceLauncherTcp")}:{" "} + {portRestrictions.tcp.state === + "blocked" + ? t("blocked") + : portRestrictions.tcp.ports} +
+ ) : null} + {portRestrictions.udp.state !== "all" ? ( +
+ {t("resourceLauncherUdp")}:{" "} + {portRestrictions.udp.state === + "blocked" + ? t("blocked") + : portRestrictions.udp.ports} +
+ ) : null} +
+ )} +
+
+ ) : null} +
+ ); + + if (isPanel) { + return sections; + } + + return ( + + {sections} + + ); +} + +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 ( + + ); +} diff --git a/src/components/SiteResourcesOverview.tsx b/src/components/SiteResourcesOverview.tsx index 6502f331a..d861a870c 100644 --- a/src/components/SiteResourcesOverview.tsx +++ b/src/components/SiteResourcesOverview.tsx @@ -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: , - name: row.name, - access: , - editHref: `/${orgId}/settings/resources/private?${qs.toString()}` - }; - }); + const privateRows = privateList.map((row) => ({ + key: row.siteResourceId, + meta: , + name: row.name, + access: , + editHref: getPrivateResourceSettingsHref(orgId, row.niceId) + })); if (showEmptyPlaceholder) { return ( diff --git a/src/components/SshServerSettingsFields.tsx b/src/components/SshServerSettingsFields.tsx new file mode 100644 index 000000000..8441b25f0 --- /dev/null +++ b/src/components/SshServerSettingsFields.tsx @@ -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 ( + <> + +
+

+ {t("sshServerMode")} +

+ {serverModeDisplay === "badge" ? ( + + {sshServerMode === "standard" + ? t("sshServerModeStandard") + : t("sshServerModePangolin")} + + ) : ( + + idPrefix={`${idPrefix}-mode`} + value={sshServerMode} + options={modeOptions} + onChange={(value) => onServerModeChange?.(value)} + cols={2} + /> + )} +
+
+ + +
+

+ {t("sshAuthenticationMethod")} +

+ + idPrefix={`${idPrefix}-auth`} + value={pamMode} + options={authMethodOptions} + onChange={onPamModeChange} + cols={2} + /> +
+
+ + {showDaemonLocation && ( + +
+

+ {t("sshAuthDaemonLocation")} +

+ + idPrefix={`${idPrefix}-daemon`} + value={standardDaemonLocation} + options={daemonLocationOptions} + onChange={onStandardDaemonLocationChange} + cols={2} + /> +

+ {t("sshDaemonDisclaimer")}{" "} + + {t("learnMore")} + + +

+
+
+ )} + + {showDaemonPort && ( + +
+ + + onAuthDaemonPortChange(e.target.value) + } + /> + {authDaemonPortError ? ( +

+ {authDaemonPortError} +

+ ) : null} +
+
+ )} + + ); +} diff --git a/src/components/StrategySelect.tsx b/src/components/StrategySelect.tsx index b4cd961d4..81e9356f2 100644 --- a/src/components/StrategySelect.tsx +++ b/src/components/StrategySelect.tsx @@ -18,6 +18,7 @@ interface StrategySelectProps { defaultValue?: TValue; onChange?: (value: TValue) => void; cols?: number; + idPrefix?: string; } export function StrategySelect({ @@ -25,7 +26,8 @@ export function StrategySelect({ value: controlledValue, defaultValue, onChange, - cols = 1 + cols = 1, + idPrefix = "strategy" }: StrategySelectProps) { const [uncontrolledSelected, setUncontrolledSelected] = useState< TValue | undefined @@ -49,43 +51,49 @@ export function StrategySelect({ }} className="grid md:grid-cols-(--cols) gap-4" > - {options.map((option: StrategyOption) => ( -