From 7c54df7ed1e30324b044e28c64ea3372912d460e Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 22 May 2026 16:09:02 -0700 Subject: [PATCH] Rework page to be functional --- messages/en-US.json | 4 + .../resources/proxy/[niceId]/ssh/page.tsx | 507 +++++++++++------- src/components/BrowserGatewayTargetForm.tsx | 145 +++++ 3 files changed, 476 insertions(+), 180 deletions(-) create mode 100644 src/components/BrowserGatewayTargetForm.tsx diff --git a/messages/en-US.json b/messages/en-US.json index 5d52a136d..41c9a6919 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1975,6 +1975,9 @@ "sshServerDescription": "Set up the authentication method, daemon location, and server destination", "sshServerMode": "Mode", "sshServerModeStandard": "Standard SSH Server", + "sshServerModeStandardDescription": "Uses a Pangolin auth daemon to manage SSH authentication on the site or remote host.", + "sshServerModeNative": "Native SSH Server", + "sshServerModeNativeDescription": "SSH authentication is handled natively by an existing SSH server without a separate auth daemon.", "sshAuthenticationMethod": "Authentication Method", "sshAuthMethodManual": "Manual Authentication", "sshAuthMethodManualDescription": "Requires existing host credentials. Bypasses automatic provisioning.", @@ -1989,6 +1992,7 @@ "sshServerDestination": "Server Destination", "sshServerDestinationDescription": "Configure the destination and port of the SSH server", "destination": "Destination", + "bgTargetMultiSiteDisclaimer": "Selecting multiple sites enables resilient routing and failover for high availability.", "sshAccess": "SSH Access", "roleAllowSsh": "Allow SSH", "roleAllowSshAllow": "Allow", diff --git a/src/app/[orgId]/settings/resources/proxy/[niceId]/ssh/page.tsx b/src/app/[orgId]/settings/resources/proxy/[niceId]/ssh/page.tsx index e86248fac..2f874bd53 100644 --- a/src/app/[orgId]/settings/resources/proxy/[niceId]/ssh/page.tsx +++ b/src/app/[orgId]/settings/resources/proxy/[niceId]/ssh/page.tsx @@ -5,33 +5,63 @@ import { SettingsSection, SettingsSectionBody, SettingsSectionDescription, + SettingsSectionForm, SettingsSectionHeader, SettingsSectionTitle } from "@app/components/Settings"; import { StrategySelect, StrategyOption } from "@app/components/StrategySelect"; +import { BrowserGatewayTargetForm } from "@app/components/BrowserGatewayTargetForm"; +import { + SitesSelector, + type Selectedsite +} from "@app/components/site-selector"; import { Button } from "@app/components/ui/button"; import { Input } from "@app/components/ui/input"; import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue -} from "@app/components/ui/select"; -import { ExternalLink } from "lucide-react"; + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage +} from "@app/components/ui/form"; +import { + Popover, + PopoverContent, + PopoverTrigger +} from "@app/components/ui/popover"; +import { ChevronsUpDown, ExternalLink } from "lucide-react"; import { toast } from "@app/hooks/useToast"; import { useResourceContext } from "@app/hooks/useResourceContext"; import { useEnvContext } from "@app/hooks/useEnvContext"; import { createApiClient } from "@app/lib/api"; import { formatAxiosError } from "@app/lib/api/formatAxiosError"; -import { orgQueries } from "@app/lib/queries"; import { useQuery } from "@tanstack/react-query"; import { useTranslations } from "next-intl"; import { useRouter } from "next/navigation"; import { use, useActionState, useEffect, useState } from "react"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; +import { zodResolver } from "@hookform/resolvers/zod"; import { GetResourceResponse } from "@server/routers/resource"; import type { ResourceContextType } from "@app/contexts/resourceContext"; +type ExistingTarget = { + browserGatewayTargetId: number; + siteId: number; +}; + +const sshFormSchema = z.object({ + authDaemonPort: z.string().refine( + (val) => { + if (!val) return true; + const n = Number(val); + return Number.isInteger(n) && n >= 1 && n <= 65535; + }, + { message: "Port must be between 1 and 65535" } + ) +}); + export default function SshSettingsPage(props: { params: Promise<{ orgId: string }>; }) { @@ -62,24 +92,48 @@ function SshServerForm({ const api = createApiClient(useEnvContext()); const router = useRouter(); + const isNativeInitially = resource.authDaemonMode === "native"; + + const [sshServerMode, setSshServerMode] = useState<"standard" | "native">( + isNativeInitially ? "native" : "standard" + ); + const isNative = sshServerMode === "native"; + const [pamMode, setPamMode] = useState<"passthrough" | "push">( (resource.pamMode as "passthrough" | "push") || "passthrough" ); - const [authDaemonMode, setAuthDaemonMode] = useState<"site" | "remote">( - (resource.authDaemonMode as "site" | "remote") || "site" - ); - const [authDaemonPort, setAuthDaemonPort] = useState( - (resource as any).authDaemonPort - ? String((resource as any).authDaemonPort) - : "22123" + + const [standardDaemonLocation, setStandardDaemonLocation] = useState< + "site" | "remote" + >( + isNativeInitially + ? "site" + : (resource.authDaemonMode as "site" | "remote") || "site" ); + const form = useForm({ + resolver: zodResolver(sshFormSchema), + defaultValues: { + authDaemonPort: (resource as any).authDaemonPort + ? String((resource as any).authDaemonPort) + : "22123" + } + }); + + // Standard mode: multi-site + const [selectedSites, setSelectedSites] = useState([]); const [bgDestination, setBgDestination] = useState(""); const [bgDestinationPort, setBgDestinationPort] = useState("22"); - const [bgSiteId, setBgSiteId] = useState(null); - const [bgTargetId, setBgTargetId] = useState(null); + const [existingTargets, setExistingTargets] = useState( + [] + ); - const { data: sites = [] } = useQuery(orgQueries.sites({ orgId })); + // Native mode: single site + const [selectedNativeSite, setSelectedNativeSite] = + useState(null); + const [nativeExistingTarget, setNativeExistingTarget] = + useState(null); + const [nativeSiteOpen, setNativeSiteOpen] = useState(false); const { data: bgTargetsResponse } = useQuery({ queryKey: ["browserGatewayTargets", resource.resourceId, orgId], @@ -92,6 +146,7 @@ function SshServerForm({ browserGatewayTargetId: number; resourceId: number; siteId: number; + siteName?: string; type: string; destination: string; destinationPort: number; @@ -102,53 +157,154 @@ function SshServerForm({ useEffect(() => { if (!bgTargetsResponse?.targets?.length) return; - const bgt = bgTargetsResponse.targets[0]; - setBgDestination(bgt.destination); - setBgDestinationPort(String(bgt.destinationPort)); - setBgSiteId(bgt.siteId); - setBgTargetId(bgt.browserGatewayTargetId); - }, [bgTargetsResponse]); - - useEffect(() => { - if (sites.length > 0 && bgSiteId === null) { - setBgSiteId(sites[0].siteId); + const targets = bgTargetsResponse.targets; + const first = targets[0]; + if (isNativeInitially) { + setSelectedNativeSite({ + siteId: first.siteId, + name: first.siteName ?? String(first.siteId), + type: "newt" as const + }); + setNativeExistingTarget({ + browserGatewayTargetId: first.browserGatewayTargetId, + siteId: first.siteId + }); + } else { + setBgDestination(first.destination); + setBgDestinationPort(String(first.destinationPort)); + setExistingTargets( + targets.map((t) => ({ + browserGatewayTargetId: t.browserGatewayTargetId, + siteId: t.siteId + })) + ); + setSelectedSites( + targets.map((t) => ({ + siteId: t.siteId, + name: t.siteName ?? String(t.siteId), + type: "newt" as const + })) + ); } - }, [sites, bgSiteId]); + }, [bgTargetsResponse]); const [, formAction, isSubmitting] = useActionState(save, null); async function save() { + const isValid = await form.trigger(); + if (!isValid) return; + + const effectiveMode = isNative ? "native" : standardDaemonLocation; + const portVal = form.getValues().authDaemonPort; + const effectivePort = + !isNative && standardDaemonLocation === "remote" && portVal + ? Number(portVal) + : null; + try { await api.post(`/resource/${resource.resourceId}`, { pamMode, - authDaemonMode, - authDaemonPort: authDaemonPort ? Number(authDaemonPort) : null + authDaemonMode: effectiveMode, + authDaemonPort: effectivePort }); - updateResource({ ...resource, pamMode, authDaemonMode }); + updateResource({ + ...resource, + pamMode, + authDaemonMode: effectiveMode + }); - if (bgDestination && bgDestinationPort) { - if (bgTargetId) { - await api.post( - `/org/${orgId}/browser-gateway-target/${bgTargetId}`, - { - type: "ssh", - destination: bgDestination, - destinationPort: Number(bgDestinationPort), - siteId: bgSiteId - } + if (isNative) { + if (selectedNativeSite) { + if (nativeExistingTarget) { + await api.post( + `/org/${orgId}/browser-gateway-target/${nativeExistingTarget.browserGatewayTargetId}`, + { + type: "ssh", + destination: "localhost", + destinationPort: 22, + siteId: selectedNativeSite.siteId + } + ); + } else { + const res = await api.put( + `/org/${orgId}/resource/${resource.resourceId}/browser-gateway-target`, + { + siteId: selectedNativeSite.siteId, + type: "ssh", + destination: "localhost", + destinationPort: 22 + } + ); + setNativeExistingTarget({ + browserGatewayTargetId: + res.data.data.browserGatewayTargetId, + siteId: selectedNativeSite.siteId + }); + } + } + } else { + if (bgDestination && bgDestinationPort) { + const selectedSiteIds = new Set( + selectedSites.map((s) => s.siteId) ); - } else { - const res = await api.put( - `/org/${orgId}/resource/${resource.resourceId}/browser-gateway-target`, - { - siteId: bgSiteId ?? sites[0]?.siteId, - type: "ssh", - destination: bgDestination, - destinationPort: Number(bgDestinationPort) - } + const existingSiteIds = new Set( + existingTargets.map((t) => t.siteId) ); - setBgTargetId(res.data.data.browserGatewayTargetId); + + const toDelete = existingTargets.filter( + (t) => !selectedSiteIds.has(t.siteId) + ); + await Promise.all( + toDelete.map((t) => + api.delete( + `/org/${orgId}/browser-gateway-target/${t.browserGatewayTargetId}` + ) + ) + ); + + const toUpdate = existingTargets.filter((t) => + selectedSiteIds.has(t.siteId) + ); + await Promise.all( + toUpdate.map((t) => + api.post( + `/org/${orgId}/browser-gateway-target/${t.browserGatewayTargetId}`, + { + type: "ssh", + destination: bgDestination, + destinationPort: Number(bgDestinationPort), + siteId: t.siteId + } + ) + ) + ); + + const toCreate = selectedSites.filter( + (s) => !existingSiteIds.has(s.siteId) + ); + const created = await Promise.all( + toCreate.map((s) => + api.put( + `/org/${orgId}/resource/${resource.resourceId}/browser-gateway-target`, + { + siteId: s.siteId, + type: "ssh", + destination: bgDestination, + destinationPort: Number(bgDestinationPort) + } + ) + ) + ); + + const newTargets: ExistingTarget[] = created.map( + (res, i) => ({ + browserGatewayTargetId: + res.data.data.browserGatewayTargetId, + siteId: toCreate[i].siteId + }) + ); + setExistingTargets([...toUpdate, ...newTargets]); } } @@ -196,48 +352,47 @@ function SshServerForm({ } ]; + const showDaemonLocation = !isNative && pamMode === "push"; + const showDaemonPort = + !isNative && pamMode === "push" && standardDaemonLocation === "remote"; + return ( - <> - - - - {t("sshServer")} - - - {t("sshServerDescription")} - - - -
-
-

- {t("sshServerMode")} -

- - {t("sshServerModeStandard")} - -
+ + + {t("sshServer")} + + {t("sshServerDescription")} + + + + +
+

+ {t("sshServerMode")} +

+
-
-

- {t("sshAuthenticationMethod")} -

- - value={pamMode} - options={authMethodOptions} - onChange={setPamMode} - cols={2} - /> -
+
+

+ {t("sshAuthenticationMethod")} +

+ + value={pamMode} + options={authMethodOptions} + onChange={setPamMode} + cols={2} + /> +
+ {showDaemonLocation && (

{t("sshAuthDaemonLocation")}

- value={authDaemonMode} + value={standardDaemonLocation} options={daemonLocationOptions} - onChange={setAuthDaemonMode} + onChange={setStandardDaemonLocation} cols={2} />

@@ -253,104 +408,96 @@ function SshServerForm({

+ )} -
- - - setAuthDaemonPort(e.target.value) - } + {showDaemonPort && ( +
+ ( + + + {t("sshDaemonPort")} + + + + + + + )} /> -
-
-
-
+ + )} + - - - - {t("sshServerDestination")} - - - {t("sshServerDestinationDescription")} - - - -
-
-
- - - setBgDestination(e.target.value) - } - /> -
-
- - - setBgDestinationPort(e.target.value) - } - /> -
-
- {sites.length > 0 && ( -
- - -
- )} +
+
+

+ {t("sshServerDestination")} +

+

+ {t("sshServerDestinationDescription")} +

- -
- -
- - + {isNative ? ( + + + + + + { + setSelectedNativeSite(site); + setNativeSiteOpen(false); + }} + /> + + + ) : ( + + )} +
+ +
+ +
+ ); } diff --git a/src/components/BrowserGatewayTargetForm.tsx b/src/components/BrowserGatewayTargetForm.tsx new file mode 100644 index 000000000..cbc5cfeed --- /dev/null +++ b/src/components/BrowserGatewayTargetForm.tsx @@ -0,0 +1,145 @@ +"use client"; + +import { ChevronsUpDown, ExternalLink } from "lucide-react"; +import { useTranslations } from "next-intl"; +import { useState } from "react"; +import { + MultiSitesSelector, + formatMultiSitesSelectorLabel +} from "./multi-site-selector"; +import { SitesSelector, type Selectedsite } from "./site-selector"; +import { Button } from "./ui/button"; +import { Input } from "./ui/input"; +import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover"; + +type SingleSiteProps = { + multiSite?: false; + selectedSite: Selectedsite | null; + onSiteChange: (site: Selectedsite | null) => void; +}; + +type MultiSiteProps = { + multiSite: true; + selectedSites: Selectedsite[]; + onSitesChange: (sites: Selectedsite[]) => void; +}; + +export type BrowserGatewayTargetFormProps = { + orgId: string; + destination: string; + destinationPort: string; + onDestinationChange: (v: string) => void; + onDestinationPortChange: (v: string) => void; + learnMoreHref?: string; +} & (SingleSiteProps | MultiSiteProps); + +export function BrowserGatewayTargetForm(props: BrowserGatewayTargetFormProps) { + const t = useTranslations(); + const [siteOpen, setSiteOpen] = useState(false); + + const siteSelector = + props.multiSite === true ? ( + + + + + + + + + ) : ( + + + + + + { + props.onSiteChange(site); + setSiteOpen(false); + }} + /> + + + ); + + return ( +
+
+
+ + {siteSelector} +
+
+ + + props.onDestinationChange(e.target.value) + } + /> +
+
+ + + props.onDestinationPortChange(e.target.value) + } + /> +
+
+ {props.multiSite && ( +

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

+ )} +
+ ); +}