diff --git a/messages/en-US.json b/messages/en-US.json index f8f04f55a..ea4d1fc89 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -2032,13 +2032,13 @@ "healthCheckUnknown": "Unknown", "healthCheck": "Health Check", "configureHealthCheck": "Configure Health Check", - "configureHealthCheckDescription": "Set up health monitoring for {target}", + "configureHealthCheckDescription": "Set up monitoring for your resource to ensure it is always available", "enableHealthChecks": "Enable Health Checks", "healthCheckDisabledStateDescription": "When disabled, the site will not perform health checks and the state will be considered unknown.", "enableHealthChecksDescription": "Monitor the health of this target. You can monitor a different endpoint than the target if required.", "healthScheme": "Method", "healthSelectScheme": "Select Method", - "healthCheckPortInvalid": "Health check port must be between 1 and 65535", + "healthCheckPortInvalid": "Port must be between 1 and 65535", "healthCheckPath": "Path", "healthHostname": "IP / Host", "healthPort": "Port", @@ -2080,6 +2080,11 @@ "sshServerDestination": "Server Destination", "sshServerDestinationDescription": "Configure the destination of the SSH server", "destination": "Destination", + "destinationRequired": "Destination is required.", + "domainRequired": "Domain is required.", + "proxyPortRequired": "Port is required.", + "invalidPathConfiguration": "Invalid path configuration.", + "invalidRewritePathConfiguration": "Invalid rewrite path configuration.", "bgTargetMultiSiteDisclaimer": "Selecting multiple sites enables resilient routing and failover for high availability.", "roleAllowSsh": "Allow SSH", "roleAllowSshAllow": "Allow", diff --git a/src/app/[orgId]/settings/resources/public/[niceId]/rdp/page.tsx b/src/app/[orgId]/settings/resources/public/[niceId]/rdp/page.tsx index ee564156a..8ef4f6a8b 100644 --- a/src/app/[orgId]/settings/resources/public/[niceId]/rdp/page.tsx +++ b/src/app/[orgId]/settings/resources/public/[niceId]/rdp/page.tsx @@ -11,22 +11,23 @@ import { } from "@app/components/Settings"; import { BrowserGatewayTargetForm } from "@app/components/BrowserGatewayTargetForm"; import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert"; -import { type Selectedsite } from "@app/components/site-selector"; import { Button } from "@app/components/ui/button"; +import { Form } from "@app/components/ui/form"; import { toast } from "@app/hooks/useToast"; import { useResourceContext } from "@app/hooks/useResourceContext"; import { useEnvContext } from "@app/hooks/useEnvContext"; import { usePaidStatus } from "@app/hooks/usePaidStatus"; +import { createBrowserGatewayTargetFormSchema } from "@app/lib/browserGatewayTargetFormSchema"; +import type { BrowserGatewayTargetFormValues } from "@app/lib/browserGatewayTargetFormSchema"; import { tierMatrix, TierFeature } from "@server/lib/billing/tierMatrix"; import { createApiClient } from "@app/lib/api"; import { formatAxiosError } from "@app/lib/api/formatAxiosError"; +import { zodResolver } from "@hookform/resolvers/zod"; import { useQuery } from "@tanstack/react-query"; import { useTranslations } from "next-intl"; import { useRouter } from "next/navigation"; -import { use, useActionState, useEffect, useState } from "react"; +import { use, useActionState, useMemo, 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"; @@ -35,177 +36,172 @@ type ExistingTarget = { 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" } - ) -}); +type BgTarget = { + browserGatewayTargetId: number; + resourceId: number; + siteId: number; + siteName?: string; + type: string; + destination: string; + destinationPort: number; +}; -export default function SshSettingsPage(props: { +type BgTargetsResponse = { + targets: BgTarget[]; +}; + +export default function RdpSettingsPage(props: { params: Promise<{ orgId: string }>; }) { const params = use(props.params); const { resource, updateResource } = useResourceContext(); const { isPaidUser } = usePaidStatus(); + const api = createApiClient(useEnvContext()); const disabled = !isPaidUser( tierMatrix[TierFeature.AdvancedPublicResources] ); + const { data: bgTargetsResponse, isLoading: isLoadingTargets } = useQuery({ + queryKey: ["browserGatewayTargets", resource.resourceId, params.orgId], + queryFn: async () => { + const res = await api.get( + `/org/${params.orgId}/resource/${resource.resourceId}/browser-gateway-targets` + ); + return res.data.data as BgTargetsResponse; + } + }); + + if (isLoadingTargets) { + return null; + } + return ( - ); } -function SshServerForm({ +function RdpServerForm({ orgId, resource, - updateResource, - disabled + disabled, + bgTargetsResponse }: { orgId: string; resource: GetResourceResponse; updateResource: ResourceContextType["updateResource"]; disabled: boolean; + bgTargetsResponse: BgTargetsResponse; }) { const t = useTranslations(); const api = createApiClient(useEnvContext()); const router = useRouter(); + const targets = bgTargetsResponse.targets; + const firstTarget = targets[0]; - // Standard mode: multi-site - const [selectedSites, setSelectedSites] = useState([]); - const [bgDestination, setBgDestination] = useState(""); - const [bgDestinationPort, setBgDestinationPort] = useState("22"); - const [existingTargets, setExistingTargets] = useState( - [] + const formSchema = useMemo( + () => createBrowserGatewayTargetFormSchema(t), + [t] ); - // Native mode: single site - const [selectedNativeSite, setSelectedNativeSite] = - useState(null); - const [nativeExistingTarget, setNativeExistingTarget] = - useState(null); - - const { data: bgTargetsResponse } = useQuery({ - queryKey: ["browserGatewayTargets", resource.resourceId, orgId], - queryFn: async () => { - const res = await api.get( - `/org/${orgId}/resource/${resource.resourceId}/browser-gateway-targets` - ); - return res.data.data as { - targets: Array<{ - browserGatewayTargetId: number; - resourceId: number; - siteId: number; - siteName?: string; - type: string; - destination: string; - destinationPort: number; - }>; - }; + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { + selectedSites: targets.map((target) => ({ + siteId: target.siteId, + name: target.siteName ?? String(target.siteId), + type: "newt" as const + })), + destination: firstTarget?.destination ?? "", + destinationPort: firstTarget + ? String(firstTarget.destinationPort) + : "3389" } }); - useEffect(() => { - if (!bgTargetsResponse?.targets?.length) return; - const targets = bgTargetsResponse.targets; - const first = targets[0]; - - setBgDestination(first.destination); - setBgDestinationPort(String(first.destinationPort)); - setExistingTargets( - targets.map((t) => ({ - browserGatewayTargetId: t.browserGatewayTargetId, - siteId: t.siteId + const [existingTargets, setExistingTargets] = useState( + () => + targets.map((target) => ({ + browserGatewayTargetId: target.browserGatewayTargetId, + siteId: target.siteId })) - ); - setSelectedSites( - targets.map((t) => ({ - siteId: t.siteId, - name: t.siteName ?? String(t.siteId), - type: "newt" as const - })) - ); - }, [bgTargetsResponse]); + ); const [, formAction, isSubmitting] = useActionState(save, null); async function save() { + const isValid = await form.trigger(); + if (!isValid) return; + + const { selectedSites, destination, destinationPort } = + form.getValues(); + try { - if (bgDestination && bgDestinationPort) { - const selectedSiteIds = new Set( - selectedSites.map((s) => s.siteId) - ); - const existingSiteIds = new Set( - existingTargets.map((t) => t.siteId) - ); + const selectedSiteIds = new Set(selectedSites.map((s) => s.siteId)); + const existingSiteIds = new Set( + existingTargets.map((t) => t.siteId) + ); - 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 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: "rdp", - destination: bgDestination, - destinationPort: Number(bgDestinationPort), - siteId: t.siteId - } - ) + 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: "rdp", + destination, + destinationPort: Number(destinationPort), + 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: "rdp", - destination: bgDestination, - destinationPort: Number(bgDestinationPort) - } - ) + 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: "rdp", + destination, + destinationPort: Number(destinationPort) + } ) - ); + ) + ); - const newTargets: ExistingTarget[] = created.map((res, i) => ({ - browserGatewayTargetId: - res.data.data.browserGatewayTargetId, - siteId: toCreate[i].siteId - })); - setExistingTargets([...toUpdate, ...newTargets]); - } + const newTargets: ExistingTarget[] = created.map((res, i) => ({ + browserGatewayTargetId: res.data.data.browserGatewayTargetId, + siteId: toCreate[i].siteId + })); + setExistingTargets([...toUpdate, ...newTargets]); toast({ title: t("settingsUpdated"), @@ -237,31 +233,31 @@ function SshServerForm({ disabled={disabled} className={disabled ? "opacity-50 pointer-events-none" : ""} > - - - - - -
- -
+
+ + + + + + + +
+ ); 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 c769d28e0..1e283afa8 100644 --- a/src/app/[orgId]/settings/resources/public/[niceId]/ssh/page.tsx +++ b/src/app/[orgId]/settings/resources/public/[niceId]/ssh/page.tsx @@ -16,8 +16,7 @@ import { StrategySelect, StrategyOption } from "@app/components/StrategySelect"; import { BrowserGatewayTargetForm } from "@app/components/BrowserGatewayTargetForm"; import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert"; import { - SitesSelector, - type Selectedsite + SitesSelector } from "@app/components/site-selector"; import { usePaidStatus } from "@app/hooks/usePaidStatus"; import { tierMatrix, TierFeature } from "@server/lib/billing/tierMatrix"; @@ -41,15 +40,16 @@ import { Badge } from "@app/components/ui/badge"; import { toast } from "@app/hooks/useToast"; import { useResourceContext } from "@app/hooks/useResourceContext"; import { useEnvContext } from "@app/hooks/useEnvContext"; +import { createSshSettingsFormSchema } from "@app/lib/browserGatewayTargetFormSchema"; +import type { SshSettingsFormValues } from "@app/lib/browserGatewayTargetFormSchema"; import { createApiClient } from "@app/lib/api"; import { formatAxiosError } from "@app/lib/api/formatAxiosError"; +import { zodResolver } from "@hookform/resolvers/zod"; import { useQuery } from "@tanstack/react-query"; import { useTranslations } from "next-intl"; import { useRouter } from "next/navigation"; -import { use, useActionState, useEffect, useState } from "react"; +import { use, useActionState, useMemo, 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"; @@ -58,16 +58,19 @@ type ExistingTarget = { 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" } - ) -}); +type BgTarget = { + browserGatewayTargetId: number; + resourceId: number; + siteId: number; + siteName?: string; + type: string; + destination: string; + destinationPort: number; +}; + +type BgTargetsResponse = { + targets: BgTarget[]; +}; export default function SshSettingsPage(props: { params: Promise<{ orgId: string }>; @@ -75,10 +78,25 @@ export default function SshSettingsPage(props: { const params = use(props.params); const { resource, updateResource } = useResourceContext(); const { isPaidUser } = usePaidStatus(); + const api = createApiClient(useEnvContext()); const disabled = !isPaidUser( tierMatrix[TierFeature.AdvancedPublicResources] ); + const { data: bgTargetsResponse, isLoading: isLoadingTargets } = useQuery({ + queryKey: ["browserGatewayTargets", resource.resourceId, params.orgId], + queryFn: async () => { + const res = await api.get( + `/org/${params.orgId}/resource/${resource.resourceId}/browser-gateway-targets` + ); + return res.data.data as BgTargetsResponse; + } + }); + + if (isLoadingTargets) { + return null; + } + return ( ); @@ -98,142 +117,146 @@ function SshServerForm({ orgId, resource, updateResource, - disabled + disabled, + bgTargetsResponse }: { orgId: string; resource: GetResourceResponse; updateResource: ResourceContextType["updateResource"]; disabled: boolean; + bgTargetsResponse: BgTargetsResponse; }) { const t = useTranslations(); const api = createApiClient(useEnvContext()); const router = useRouter(); const isNativeInitially = resource.authDaemonMode === "native"; + const targets = bgTargetsResponse.targets; + const firstTarget = targets[0]; + const initialPamMode = + (resource.pamMode as "passthrough" | "push") || "passthrough"; + const initialStandardDaemonLocation = isNativeInitially + ? "site" + : ((resource.authDaemonMode as "site" | "remote") || "site"); + const useSingleSiteOnLoad = + !isNativeInitially && + initialPamMode === "push" && + initialStandardDaemonLocation === "site"; - const [sshServerMode, setSshServerMode] = useState<"standard" | "native">( + const [sshServerMode] = useState<"standard" | "native">( isNativeInitially ? "native" : "standard" ); const isNative = sshServerMode === "native"; - const [pamMode, setPamMode] = useState<"passthrough" | "push">( - (resource.pamMode as "passthrough" | "push") || "passthrough" + const formSchema = useMemo( + () => createSshSettingsFormSchema(t, { isNative }), + [t, isNative] ); - const [standardDaemonLocation, setStandardDaemonLocation] = useState< - "site" | "remote" - >( - isNativeInitially - ? "site" - : (resource.authDaemonMode as "site" | "remote") || "site" - ); - - const form = useForm({ - resolver: zodResolver(sshFormSchema), + const form = useForm({ + resolver: zodResolver(formSchema), defaultValues: { - authDaemonPort: (resource as any).authDaemonPort - ? String((resource as any).authDaemonPort) - : "22123" + pamMode: initialPamMode, + standardDaemonLocation: initialStandardDaemonLocation, + authDaemonPort: (resource as { authDaemonPort?: number }) + .authDaemonPort + ? String((resource as { authDaemonPort?: number }).authDaemonPort) + : "22123", + selectedSites: + isNativeInitially || useSingleSiteOnLoad + ? [] + : targets.map((target) => ({ + siteId: target.siteId, + name: target.siteName ?? String(target.siteId), + type: "newt" as const + })), + selectedSite: + useSingleSiteOnLoad && firstTarget + ? { + siteId: firstTarget.siteId, + name: + firstTarget.siteName ?? + String(firstTarget.siteId), + type: "newt" as const + } + : null, + selectedNativeSite: + isNativeInitially && firstTarget + ? { + siteId: firstTarget.siteId, + name: + firstTarget.siteName ?? + String(firstTarget.siteId), + type: "newt" as const + } + : null, + destination: isNativeInitially + ? "" + : (firstTarget?.destination ?? ""), + destinationPort: isNativeInitially + ? "22" + : firstTarget + ? String(firstTarget.destinationPort) + : "22" } }); - // Standard mode: multi-site - const [selectedSites, setSelectedSites] = useState([]); - const [selectedSite, setSelectedSite] = useState(null); - const [bgDestination, setBgDestination] = useState(""); - const [bgDestinationPort, setBgDestinationPort] = useState("22"); const [existingTargets, setExistingTargets] = useState( - [] + () => + isNativeInitially + ? [] + : targets.map((target) => ({ + browserGatewayTargetId: target.browserGatewayTargetId, + siteId: target.siteId + })) ); - // Native mode: single site - const [selectedNativeSite, setSelectedNativeSite] = - useState(null); const [nativeExistingTarget, setNativeExistingTarget] = - useState(null); + useState(() => + isNativeInitially && firstTarget + ? { + browserGatewayTargetId: + firstTarget.browserGatewayTargetId, + siteId: firstTarget.siteId + } + : null + ); const [nativeSiteOpen, setNativeSiteOpen] = useState(false); - const { data: bgTargetsResponse } = useQuery({ - queryKey: ["browserGatewayTargets", resource.resourceId, orgId], - queryFn: async () => { - const res = await api.get( - `/org/${orgId}/resource/${resource.resourceId}/browser-gateway-targets` - ); - return res.data.data as { - targets: Array<{ - browserGatewayTargetId: number; - resourceId: number; - siteId: number; - siteName?: string; - type: string; - destination: string; - destinationPort: number; - }>; - }; - } - }); - - useEffect(() => { - if (!bgTargetsResponse?.targets?.length) return; - 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 - })) - ); - } - }, [bgTargetsResponse]); - const [, formAction, isSubmitting] = useActionState(save, null); + const pamMode = form.watch("pamMode"); + const standardDaemonLocation = form.watch("standardDaemonLocation"); + const selectedNativeSite = form.watch("selectedNativeSite"); + async function save() { const isValid = await form.trigger(); if (!isValid) return; - const effectiveMode = isNative ? "native" : standardDaemonLocation; - const portVal = form.getValues().authDaemonPort; + const values = form.getValues(); + const effectiveMode = isNative ? "native" : values.standardDaemonLocation; const effectivePort = - !isNative && standardDaemonLocation === "remote" && portVal - ? Number(portVal) + !isNative && + values.standardDaemonLocation === "remote" && + values.authDaemonPort + ? Number(values.authDaemonPort) : null; try { await api.post(`/resource/${resource.resourceId}`, { - pamMode, + pamMode: values.pamMode, authDaemonMode: effectiveMode, authDaemonPort: effectivePort }); updateResource({ ...resource, - pamMode, + pamMode: values.pamMode, authDaemonMode: effectiveMode }); if (isNative) { - if (selectedNativeSite) { + if (values.selectedNativeSite) { if (nativeExistingTarget) { await api.post( `/org/${orgId}/browser-gateway-target/${nativeExistingTarget.browserGatewayTargetId}`, @@ -241,14 +264,14 @@ function SshServerForm({ type: "ssh", destination: "localhost", destinationPort: 22, - siteId: selectedNativeSite.siteId + siteId: values.selectedNativeSite.siteId } ); } else { const res = await api.put( `/org/${orgId}/resource/${resource.resourceId}/browser-gateway-target`, { - siteId: selectedNativeSite.siteId, + siteId: values.selectedNativeSite.siteId, type: "ssh", destination: "localhost", destinationPort: 22 @@ -257,73 +280,82 @@ function SshServerForm({ setNativeExistingTarget({ browserGatewayTargetId: res.data.data.browserGatewayTargetId, - siteId: selectedNativeSite.siteId + siteId: values.selectedNativeSite.siteId }); } } } else { - if (bgDestination && bgDestinationPort) { - const selectedSiteIds = new Set( - selectedSites.map((s) => s.siteId) - ); - const existingSiteIds = new Set( - existingTargets.map((t) => t.siteId) - ); + const useMultiSite = + values.standardDaemonLocation !== "site" || + values.pamMode === "passthrough"; + const activeSites = useMultiSite + ? values.selectedSites + : values.selectedSite + ? [values.selectedSite] + : []; - 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 selectedSiteIds = new Set( + activeSites.map((s) => s.siteId) + ); + const existingSiteIds = new Set( + existingTargets.map((t) => t.siteId) + ); + + 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 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: values.destination, + destinationPort: Number( + values.destinationPort + ), + 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 toCreate = activeSites.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: values.destination, + destinationPort: Number( + values.destinationPort + ) + } ) - ); + ) + ); - const newTargets: ExistingTarget[] = created.map( - (res, i) => ({ - browserGatewayTargetId: - res.data.data.browserGatewayTargetId, - siteId: toCreate[i].siteId - }) - ); - setExistingTargets([...toUpdate, ...newTargets]); - } + const newTargets: ExistingTarget[] = created.map((res, i) => ({ + browserGatewayTargetId: + res.data.data.browserGatewayTargetId, + siteId: toCreate[i].siteId + })); + setExistingTargets([...toUpdate, ...newTargets]); } toast({ @@ -373,6 +405,9 @@ function SshServerForm({ const showDaemonLocation = !isNative && pamMode === "push"; const showDaemonPort = !isNative && pamMode === "push" && standardDaemonLocation === "remote"; + const useMultiSiteTargetForm = + !isNative && + (standardDaemonLocation !== "site" || pamMode === "passthrough"); return ( @@ -386,160 +421,189 @@ function SshServerForm({ disabled={disabled} className={disabled ? "opacity-50 pointer-events-none" : ""} > - - -
- - {t("sshServerMode")} - - - {sshServerMode == "standard" - ? t("sshServerModeStandard") - : t("sshServerModePangolin")} - -
+
+ + +
+

{t("sshServerMode")}

+ + {sshServerMode == "standard" + ? t("sshServerModeStandard") + : t("sshServerModePangolin")} + +
-
- - {t("sshAuthenticationMethod")} - - - value={pamMode} - options={authMethodOptions} - onChange={setPamMode} - cols={2} - /> -
+
+

{t("sshAuthenticationMethod")}

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

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

-
- )} - - {showDaemonPort && ( - - ( - - - {t("sshDaemonPort")} - - - - - - - )} - /> - - )} - -
- - - {t("sshServerDestination")} - - - {t("sshServerDestinationDescription")} - - - {isNative ? ( - - - - - - { - setSelectedNativeSite(site); - setNativeSiteOpen(false); - }} + {showDaemonLocation && ( +
+

{t("sshAuthDaemonLocation")}

+ + value={standardDaemonLocation} + options={daemonLocationOptions} + onChange={(value) => + form.setValue( + "standardDaemonLocation", + value, + { shouldValidate: true } + ) + } + cols={2} /> - - - ) : standardDaemonLocation !== "site" || - pamMode === "passthrough" ? ( - - ) : ( - - )} -
- - -
- -
+

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

+
+ )} + + {showDaemonPort && ( +
+ ( + + + {t("sshDaemonPort")} + + + + + + + )} + /> +
+ )} + +
+ + + {t("sshServerDestination")} + + + {t("sshServerDestinationDescription")} + + + {isNative ? ( + ( + + + + + + + + + { + form.setValue( + "selectedNativeSite", + site, + { + shouldValidate: + true + } + ); + setNativeSiteOpen( + false + ); + }} + /> + + + + + )} + /> + ) : useMultiSiteTargetForm ? ( + + ) : ( + + )} +
+
+
+
+ +
+
); diff --git a/src/app/[orgId]/settings/resources/public/[niceId]/vnc/page.tsx b/src/app/[orgId]/settings/resources/public/[niceId]/vnc/page.tsx index 51efd0311..55606af07 100644 --- a/src/app/[orgId]/settings/resources/public/[niceId]/vnc/page.tsx +++ b/src/app/[orgId]/settings/resources/public/[niceId]/vnc/page.tsx @@ -11,20 +11,23 @@ import { } from "@app/components/Settings"; import { BrowserGatewayTargetForm } from "@app/components/BrowserGatewayTargetForm"; import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert"; -import { type Selectedsite } from "@app/components/site-selector"; import { Button } from "@app/components/ui/button"; +import { Form } from "@app/components/ui/form"; import { toast } from "@app/hooks/useToast"; import { useResourceContext } from "@app/hooks/useResourceContext"; import { useEnvContext } from "@app/hooks/useEnvContext"; import { usePaidStatus } from "@app/hooks/usePaidStatus"; +import { createBrowserGatewayTargetFormSchema } from "@app/lib/browserGatewayTargetFormSchema"; +import type { BrowserGatewayTargetFormValues } from "@app/lib/browserGatewayTargetFormSchema"; import { tierMatrix, TierFeature } from "@server/lib/billing/tierMatrix"; import { createApiClient } from "@app/lib/api"; import { formatAxiosError } from "@app/lib/api/formatAxiosError"; +import { zodResolver } from "@hookform/resolvers/zod"; import { useQuery } from "@tanstack/react-query"; import { useTranslations } from "next-intl"; import { useRouter } from "next/navigation"; -import { use, useActionState, useEffect, useState } from "react"; -import { z } from "zod"; +import { use, useActionState, useMemo, useState } from "react"; +import { useForm } from "react-hook-form"; import { GetResourceResponse } from "@server/routers/resource"; import type { ResourceContextType } from "@app/contexts/resourceContext"; @@ -33,177 +36,172 @@ type ExistingTarget = { 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" } - ) -}); +type BgTarget = { + browserGatewayTargetId: number; + resourceId: number; + siteId: number; + siteName?: string; + type: string; + destination: string; + destinationPort: number; +}; -export default function SshSettingsPage(props: { +type BgTargetsResponse = { + targets: BgTarget[]; +}; + +export default function VncSettingsPage(props: { params: Promise<{ orgId: string }>; }) { const params = use(props.params); const { resource, updateResource } = useResourceContext(); const { isPaidUser } = usePaidStatus(); + const api = createApiClient(useEnvContext()); const disabled = !isPaidUser( tierMatrix[TierFeature.AdvancedPublicResources] ); + const { data: bgTargetsResponse, isLoading: isLoadingTargets } = useQuery({ + queryKey: ["browserGatewayTargets", resource.resourceId, params.orgId], + queryFn: async () => { + const res = await api.get( + `/org/${params.orgId}/resource/${resource.resourceId}/browser-gateway-targets` + ); + return res.data.data as BgTargetsResponse; + } + }); + + if (isLoadingTargets) { + return null; + } + return ( - ); } -function SshServerForm({ +function VncServerForm({ orgId, resource, - updateResource, - disabled + disabled, + bgTargetsResponse }: { orgId: string; resource: GetResourceResponse; updateResource: ResourceContextType["updateResource"]; disabled: boolean; + bgTargetsResponse: BgTargetsResponse; }) { const t = useTranslations(); const api = createApiClient(useEnvContext()); const router = useRouter(); + const targets = bgTargetsResponse.targets; + const firstTarget = targets[0]; - // Standard mode: multi-site - const [selectedSites, setSelectedSites] = useState([]); - const [bgDestination, setBgDestination] = useState(""); - const [bgDestinationPort, setBgDestinationPort] = useState("22"); - const [existingTargets, setExistingTargets] = useState( - [] + const formSchema = useMemo( + () => createBrowserGatewayTargetFormSchema(t), + [t] ); - // Native mode: single site - const [selectedNativeSite, setSelectedNativeSite] = - useState(null); - const [nativeExistingTarget, setNativeExistingTarget] = - useState(null); - - const { data: bgTargetsResponse } = useQuery({ - queryKey: ["browserGatewayTargets", resource.resourceId, orgId], - queryFn: async () => { - const res = await api.get( - `/org/${orgId}/resource/${resource.resourceId}/browser-gateway-targets` - ); - return res.data.data as { - targets: Array<{ - browserGatewayTargetId: number; - resourceId: number; - siteId: number; - siteName?: string; - type: string; - destination: string; - destinationPort: number; - }>; - }; + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { + selectedSites: targets.map((target) => ({ + siteId: target.siteId, + name: target.siteName ?? String(target.siteId), + type: "newt" as const + })), + destination: firstTarget?.destination ?? "", + destinationPort: firstTarget + ? String(firstTarget.destinationPort) + : "5900" } }); - useEffect(() => { - if (!bgTargetsResponse?.targets?.length) return; - const targets = bgTargetsResponse.targets; - const first = targets[0]; - - setBgDestination(first.destination); - setBgDestinationPort(String(first.destinationPort)); - setExistingTargets( - targets.map((t) => ({ - browserGatewayTargetId: t.browserGatewayTargetId, - siteId: t.siteId + const [existingTargets, setExistingTargets] = useState( + () => + targets.map((target) => ({ + browserGatewayTargetId: target.browserGatewayTargetId, + siteId: target.siteId })) - ); - setSelectedSites( - targets.map((t) => ({ - siteId: t.siteId, - name: t.siteName ?? String(t.siteId), - type: "newt" as const - })) - ); - }, [bgTargetsResponse]); + ); const [, formAction, isSubmitting] = useActionState(save, null); async function save() { + const isValid = await form.trigger(); + if (!isValid) return; + + const { selectedSites, destination, destinationPort } = + form.getValues(); + try { - if (bgDestination && bgDestinationPort) { - const selectedSiteIds = new Set( - selectedSites.map((s) => s.siteId) - ); - const existingSiteIds = new Set( - existingTargets.map((t) => t.siteId) - ); + const selectedSiteIds = new Set(selectedSites.map((s) => s.siteId)); + const existingSiteIds = new Set( + existingTargets.map((t) => t.siteId) + ); - 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 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: "vnc", - destination: bgDestination, - destinationPort: Number(bgDestinationPort), - siteId: t.siteId - } - ) + 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: "vnc", + destination, + destinationPort: Number(destinationPort), + 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: "vnc", - destination: bgDestination, - destinationPort: Number(bgDestinationPort) - } - ) + 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: "vnc", + destination, + destinationPort: Number(destinationPort) + } ) - ); + ) + ); - const newTargets: ExistingTarget[] = created.map((res, i) => ({ - browserGatewayTargetId: - res.data.data.browserGatewayTargetId, - siteId: toCreate[i].siteId - })); - setExistingTargets([...toUpdate, ...newTargets]); - } + const newTargets: ExistingTarget[] = created.map((res, i) => ({ + browserGatewayTargetId: res.data.data.browserGatewayTargetId, + siteId: toCreate[i].siteId + })); + setExistingTargets([...toUpdate, ...newTargets]); toast({ title: t("settingsUpdated"), @@ -235,31 +233,31 @@ function SshServerForm({ disabled={disabled} className={disabled ? "opacity-50 pointer-events-none" : ""} > - - - - - -
- -
+
+ + + + + + + +
+ ); diff --git a/src/app/[orgId]/settings/resources/public/create/page.tsx b/src/app/[orgId]/settings/resources/public/create/page.tsx index 407196769..b67489c6b 100644 --- a/src/app/[orgId]/settings/resources/public/create/page.tsx +++ b/src/app/[orgId]/settings/resources/public/create/page.tsx @@ -50,6 +50,12 @@ import { toast } from "@app/hooks/useToast"; import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert"; import { tierMatrix, TierFeature } from "@server/lib/billing/tierMatrix"; import { createApiClient, formatAxiosError } from "@app/lib/api"; +import { + createBrowserGatewayTargetFormSchema, + createSshSettingsFormSchema, + selectedSiteSchema, + type SshSettingsFormValues +} from "@app/lib/browserGatewayTargetFormSchema"; import { DockerManager, DockerState } from "@app/lib/docker"; import { orgQueries } from "@app/lib/queries"; import { finalizeSubdomainSanitize } from "@app/lib/subdomain-utils"; @@ -79,100 +85,134 @@ import { useTransition, useEffect } from "react"; -import { useForm } from "react-hook-form"; +import { useForm, type Resolver } from "react-hook-form"; import { z } from "zod"; -const baseResourceFormSchema = z.object({ - name: z.string().min(1).max(255), - http: z.boolean() -}); +type TranslateFn = (key: string) => string; -const httpResourceFormSchema = z.object({ - domainId: z.string().nonempty(), - subdomain: z.string().optional() -}); +function createBaseResourceFormSchema(t: TranslateFn) { + return z.object({ + name: z + .string() + .min(1, { message: t("nameRequired") }) + .max(255, { + message: t("createInternalResourceDialogNameMaxLength") + }), + http: z.boolean() + }); +} -const tcpUdpResourceFormSchema = z.object({ - protocol: z.string(), - proxyPort: z.int().min(1).max(65535) -}); +function createHttpResourceFormSchema(t: TranslateFn) { + return z.object({ + domainId: z.string().min(1, { message: t("domainRequired") }), + subdomain: z.string().optional() + }); +} -const sshDaemonPortSchema = 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" } - ) -}); +function createTcpUdpResourceFormSchema(t: TranslateFn) { + return z.object({ + protocol: z.string(), + proxyPort: z + .number({ error: t("proxyPortRequired") }) + .int({ error: t("healthCheckPortInvalid") }) + .min(1, { message: t("healthCheckPortInvalid") }) + .max(65535, { message: t("healthCheckPortInvalid") }) + }); +} -const addTargetSchema = z - .object({ - ip: z.string().refine(isTargetValid), - method: z.string().nullable(), - port: z.coerce.number().int().positive(), - siteId: z.int().positive(), - path: z.string().optional().nullable(), - pathMatchType: z - .enum(["exact", "prefix", "regex"]) - .optional() - .nullable(), - rewritePath: z.string().optional().nullable(), - rewritePathType: z - .enum(["exact", "prefix", "regex", "stripPrefix"]) - .optional() - .nullable(), - priority: z.int().min(1).max(1000).optional() - }) - .refine( - (data) => { - if (data.path && !data.pathMatchType) { - return false; - } - if (data.pathMatchType && !data.path) { - return false; - } - if (data.path && data.pathMatchType) { - switch (data.pathMatchType) { - case "exact": - case "prefix": - return data.path.startsWith("/"); - case "regex": - try { - new RegExp(data.path); - return true; - } catch { - return false; - } - } - } - return true; - }, - { - error: "Invalid path configuration" - } - ) - .refine( - (data) => { - if (data.rewritePath && !data.rewritePathType) { - return false; - } - if (data.rewritePathType && !data.rewritePath) { - if (data.rewritePathType !== "stripPrefix") { +function createSshDaemonPortSchema(t: TranslateFn) { + return z.object({ + authDaemonPort: z.string().refine( + (val) => { + if (!val) return true; + const n = Number(val); + return Number.isInteger(n) && n >= 1 && n <= 65535; + }, + { message: t("healthCheckPortInvalid") } + ) + }); +} + +function createAddTargetSchema(t: TranslateFn) { + return z + .object({ + ip: z.string().refine(isTargetValid, { + message: t("targetErrorInvalidIpDescription") + }), + method: z.string().nullable(), + port: z.coerce + .number({ error: t("targetErrorInvalidPortDescription") }) + .int({ error: t("targetErrorInvalidPortDescription") }) + .positive({ error: t("targetErrorInvalidPortDescription") }), + siteId: z + .int({ error: t("siteRequired") }) + .positive({ error: t("siteRequired") }), + path: z.string().optional().nullable(), + pathMatchType: z + .enum(["exact", "prefix", "regex"]) + .optional() + .nullable(), + rewritePath: z.string().optional().nullable(), + rewritePathType: z + .enum(["exact", "prefix", "regex", "stripPrefix"]) + .optional() + .nullable(), + priority: z + .int() + .min(1, { message: t("healthCheckPortInvalid") }) + .max(1000, { message: t("healthCheckPortInvalid") }) + .optional() + }) + .refine( + (data) => { + if (data.path && !data.pathMatchType) { return false; } + if (data.pathMatchType && !data.path) { + return false; + } + if (data.path && data.pathMatchType) { + switch (data.pathMatchType) { + case "exact": + case "prefix": + return data.path.startsWith("/"); + case "regex": + try { + new RegExp(data.path); + return true; + } catch { + return false; + } + } + } + return true; + }, + { + message: t("invalidPathConfiguration") } - return true; - }, - { - error: "Invalid rewrite path configuration" - } - ); + ) + .refine( + (data) => { + if (data.rewritePath && !data.rewritePathType) { + return false; + } + if (data.rewritePathType && !data.rewritePath) { + if (data.rewritePathType !== "stripPrefix") { + return false; + } + } + return true; + }, + { + message: t("invalidRewritePathConfiguration") + } + ); +} type NewResourceType = "http" | "ssh" | "rdp" | "vnc" | "tcp" | "udp"; +type CreateBgTargetFormValues = SshSettingsFormValues; + export default function Page() { const { env } = useEnvContext(); const api = createApiClient({ env }); @@ -223,29 +263,6 @@ export default function Page() { useState(null); const [nativeSiteOpen, setNativeSiteOpen] = useState(false); - // Browser-gateway targets state (SSH standard, RDP, VNC) - const [bgSelectedSites, setBgSelectedSites] = useState([]); - const [bgSelectedSite, setBgSelectedSite] = useState( - null - ); - const [bgDestination, setBgDestination] = useState(""); - const [bgDestinationPort, setBgDestinationPort] = useState("22"); - - // Reset BG state when resource type changes - useEffect(() => { - if (resourceType === "rdp") { - setBgDestinationPort("3389"); - } else if (resourceType === "vnc") { - setBgDestinationPort("5900"); - } else if (resourceType === "ssh") { - setBgDestinationPort("22"); - } - setBgDestination(""); - setBgSelectedSites([]); - setBgSelectedSite(null); - setNativeSelectedSite(null); - }, [resourceType]); - useEffect(() => { if (build !== "saas") return; @@ -278,6 +295,39 @@ export default function Page() { pamMode === "push" && standardDaemonLocation === "remote"; + const bgTargetFormSchema = useMemo(() => { + if (resourceType === "ssh" && !isNative) { + return createSshSettingsFormSchema(t, { isNative: false }); + } + if (resourceType === "rdp" || resourceType === "vnc") { + return createBrowserGatewayTargetFormSchema(t); + } + return z.object({ + selectedSites: z.array(selectedSiteSchema), + selectedSite: selectedSiteSchema.nullable(), + destination: z.string(), + destinationPort: z.string(), + pamMode: z.enum(["passthrough", "push"]), + standardDaemonLocation: z.enum(["site", "remote"]) + }); + }, [resourceType, isNative, t]); + + const bgTargetForm = useForm({ + resolver: zodResolver( + bgTargetFormSchema + ) as unknown as Resolver, + defaultValues: { + selectedSites: [], + selectedSite: null, + selectedNativeSite: null, + destination: "", + destinationPort: "22", + pamMode: "passthrough", + standardDaemonLocation: "site", + authDaemonPort: "22123" + } + }); + // Whether raw (TCP/UDP) resources are available const rawResourcesAllowed = env.flags.allowRawResources && @@ -302,6 +352,24 @@ export default function Page() { } }, [availableTypes, resourceType]); + const baseResourceFormSchema = useMemo( + () => createBaseResourceFormSchema(t), + [t] + ); + const httpResourceFormSchema = useMemo( + () => createHttpResourceFormSchema(t), + [t] + ); + const tcpUdpResourceFormSchema = useMemo( + () => createTcpUdpResourceFormSchema(t), + [t] + ); + const sshDaemonPortSchema = useMemo( + () => createSshDaemonPortSchema(t), + [t] + ); + const addTargetSchema = useMemo(() => createAddTargetSchema(t), [t]); + const baseForm = useForm({ resolver: zodResolver(baseResourceFormSchema), defaultValues: { @@ -330,6 +398,31 @@ export default function Page() { } }); + useEffect(() => { + const defaultPort = + resourceType === "rdp" + ? "3389" + : resourceType === "vnc" + ? "5900" + : "22"; + bgTargetForm.reset({ + selectedSites: [], + selectedSite: null, + selectedNativeSite: null, + destination: "", + destinationPort: defaultPort, + pamMode, + standardDaemonLocation, + authDaemonPort: sshDaemonPortForm.getValues().authDaemonPort + }); + setNativeSelectedSite(null); + }, [resourceType]); + + useEffect(() => { + bgTargetForm.setValue("pamMode", pamMode); + bgTargetForm.setValue("standardDaemonLocation", standardDaemonLocation); + }, [pamMode, standardDaemonLocation]); + // Sync form http field with resourceType useEffect(() => { baseForm.setValue("http", isHttpResource); @@ -508,20 +601,25 @@ export default function Page() { ); } } else { - const sitesToCreate = - standardDaemonLocation !== "site" - ? bgSelectedSites - : bgSelectedSite - ? [bgSelectedSite] - : []; + const bgValues = bgTargetForm.getValues(); + const useMultiSite = + standardDaemonLocation !== "site" || + pamMode === "passthrough"; + const sitesToCreate = useMultiSite + ? bgValues.selectedSites + : bgValues.selectedSite + ? [bgValues.selectedSite] + : []; for (const site of sitesToCreate) { await api.put( `/org/${orgId}/resource/${id}/browser-gateway-target`, { siteId: site.siteId, type: "ssh", - destination: bgDestination, - destinationPort: Number(bgDestinationPort) + destination: bgValues.destination, + destinationPort: Number( + bgValues.destinationPort + ) } ); } @@ -531,16 +629,19 @@ export default function Page() { `/${orgId}/settings/resources/public/${newNiceId}` ); } else if (resourceType === "rdp" || resourceType === "vnc") { - for (const site of bgSelectedSites) { + const bgValues = bgTargetForm.getValues(); + for (const site of bgValues.selectedSites) { await api.put( `/org/${orgId}/resource/${id}/browser-gateway-target`, { siteId: site.siteId, type: resourceType, - destination: bgDestination, - destinationPort: Number(bgDestinationPort) + destination: bgValues.destination, + destinationPort: Number( + bgValues.destinationPort + ) } - ); + ); } router.push( @@ -760,32 +861,56 @@ export default function Page() { {/* Domain/Subdomain (HTTP-based types) */} {isHttpResource && ( -
- = - 1 - } - onDomainChange={(res) => { - if (!res) return; - httpForm.setValue( - "subdomain", - res.subdomain - ); - httpForm.setValue( - "domainId", - res.domainId - ); - }} - /> -

- {t( - "resourceDomainDescription" +

+ ( + + = + 1 + } + onDomainChange={( + res + ) => { + if (!res) + return; + httpForm.setValue( + "subdomain", + res.subdomain, + { + shouldValidate: + true + } + ); + httpForm.setValue( + "domainId", + res.domainId, + { + shouldValidate: + true + } + ); + }} + /> + + + {t( + "resourceDomainDescription" + )} + + )} -

-
+ /> + )} {/* Proxy Port (TCP/UDP types) */} @@ -883,9 +1008,7 @@ export default function Page() { {/* Mode */}
- - {t("sshServerMode")} - +

{t("sshServerMode")}

@@ -897,11 +1020,7 @@ export default function Page() {
- - {t( - "sshAuthenticationMethod" - )} - +

{t("sshAuthenticationMethod")}

@@ -917,11 +1036,7 @@ export default function Page() { {/* Daemon Location (standard + push) */} {showDaemonLocation && (
- - {t( - "sshAuthDaemonLocation" - )} - +

{t("sshAuthDaemonLocation")}

@@ -1052,55 +1167,39 @@ export default function Page() { "site" || pamMode === "passthrough" ? ( - +
+ + ) : ( - +
+ + )}
@@ -1138,26 +1237,18 @@ export default function Page() { > - +
+ +
@@ -1193,26 +1284,18 @@ export default function Page() { > - +
+ +
@@ -1253,15 +1336,31 @@ export default function Page() { const tcpValid = !isHttpResource ? await tcpUdpForm.trigger() : true; - const sshPortValid = showDaemonPort - ? await sshDaemonPortForm.trigger() - : true; + + if ( + resourceType === "ssh" && + !isNative + ) { + bgTargetForm.setValue( + "authDaemonPort", + sshDaemonPortForm.getValues() + .authDaemonPort + ); + } + + const bgValid = + resourceType === "rdp" || + resourceType === "vnc" || + (resourceType === "ssh" && + !isNative) + ? await bgTargetForm.trigger() + : true; if ( baseValid && domainValid && tcpValid && - sshPortValid + bgValid ) { onSubmit(); } diff --git a/src/components/BrowserGatewayTargetForm.tsx b/src/components/BrowserGatewayTargetForm.tsx index df74e17b9..768ac2738 100644 --- a/src/components/BrowserGatewayTargetForm.tsx +++ b/src/components/BrowserGatewayTargetForm.tsx @@ -1,128 +1,173 @@ "use client"; +import { cn } from "@app/lib/cn"; import { ChevronsUpDown, ExternalLink } from "lucide-react"; import { useTranslations } from "next-intl"; import { useState } from "react"; +import type { Control, FieldValues, Path } from "react-hook-form"; +import { useWatch } from "react-hook-form"; import { MultiSitesSelector, formatMultiSitesSelectorLabel } from "./multi-site-selector"; import { SitesSelector, type Selectedsite } from "./site-selector"; import { Button } from "./ui/button"; +import { + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage +} from "./ui/form"; 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 = { +type BaseProps = { + control: Control; orgId: string; - destination: string; - defaultPort: number; - destinationPort: string; - onDestinationChange: (v: string) => void; - onDestinationPortChange: (v: string) => void; + destinationField: Path; + destinationPortField: Path; learnMoreHref?: string; -} & (SingleSiteProps | MultiSiteProps); + defaultPort: number; +}; -export function BrowserGatewayTargetForm(props: BrowserGatewayTargetFormProps) { +type MultiSiteFormProps = BaseProps & { + multiSite: true; + sitesField: Path; +}; + +type SingleSiteFormProps = BaseProps & { + multiSite?: false; + siteField: Path; +}; + +export type BrowserGatewayTargetFormProps = + | MultiSiteFormProps + | SingleSiteFormProps; + +export function BrowserGatewayTargetForm( + props: BrowserGatewayTargetFormProps +) { const t = useTranslations(); const [siteOpen, setSiteOpen] = useState(false); - const siteSelector = - props.multiSite === true ? ( - - - - - - - - - ) : ( - - - - - - { - props.onSiteChange(site); - setSiteOpen(false); - }} - /> - - - ); + const sitesFieldName = + props.multiSite === true ? props.sitesField : props.siteField; + + const watchedSites = useWatch({ + control: props.control, + name: sitesFieldName + }); + + const showMultiSiteDisclaimer = + props.multiSite === true && + ((watchedSites as Selectedsite[] | undefined)?.length ?? 0) > 1; return (
-
-
- - {siteSelector} -
-
- - - props.onDestinationChange(e.target.value) - } - /> -
-
- - - props.onDestinationPortChange(e.target.value) - } - /> -
+
+ ( + + {t("sites")} + + + + + + + + {props.multiSite === true ? ( + + ) : ( + { + field.onChange(site); + setSiteOpen(false); + }} + /> + )} + + + + + )} + /> + ( + + {t("destination")} + + + + + + )} + /> + ( + + {t("port")} + + + + + + )} + />
- {props.multiSite === true && props.selectedSites.length > 1 && ( + {showMultiSiteDisclaimer && (

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

{t("sshServerMode")} - +

value={sshServerMode} options={[ @@ -1870,9 +1870,9 @@ export function PrivateResourceForm({
- +

{t("sshAuthenticationMethod")} - +

- +

{t("sshAuthDaemonLocation")} - +

- {resource.mode!.toUpperCase()} + {resource.ssl ? "HTTPS" : "HTTP"} diff --git a/src/components/Settings.tsx b/src/components/Settings.tsx index a1df9d3c8..09fc8b0af 100644 --- a/src/components/Settings.tsx +++ b/src/components/Settings.tsx @@ -70,7 +70,7 @@ export function SettingsSubsectionHeader({ children: React.ReactNode; className?: string; }) { - return
{children}
; + return
{children}
; } export function SettingsSubsectionTitle({ @@ -80,9 +80,7 @@ export function SettingsSubsectionTitle({ children: React.ReactNode; className?: string; }) { - return ( -

{children}

- ); + return

{children}

; } export function SettingsSubsectionDescription({ diff --git a/src/components/labels-selector.tsx b/src/components/labels-selector.tsx index 8a2bae44c..788d9fe7d 100644 --- a/src/components/labels-selector.tsx +++ b/src/components/labels-selector.tsx @@ -157,7 +157,7 @@ export function LabelsSelector({ /> { const input = e.target.value.trim(); const hasProtocol = /^(https?|h2c):\/\//.test(input); diff --git a/src/lib/browserGatewayTargetFormSchema.ts b/src/lib/browserGatewayTargetFormSchema.ts new file mode 100644 index 000000000..c6d7b3afd --- /dev/null +++ b/src/lib/browserGatewayTargetFormSchema.ts @@ -0,0 +1,140 @@ +import { z } from "zod"; + +type TranslateFn = (key: string) => string; + +export const selectedSiteSchema = z.object({ + siteId: z.number().int().positive(), + name: z.string(), + type: z.string() +}); + +export type SelectedSiteFormValue = z.infer; + +export function createPortStringSchema(t: TranslateFn) { + return z.string().refine( + (val) => { + if (!val) return false; + const n = Number(val); + return Number.isInteger(n) && n >= 1 && n <= 65535; + }, + { message: t("healthCheckPortInvalid") } + ); +} + +function createOptionalAuthDaemonPortSchema(t: TranslateFn) { + return z.string().refine( + (val) => { + if (!val) return true; + const n = Number(val); + return Number.isInteger(n) && n >= 1 && n <= 65535; + }, + { message: t("healthCheckPortInvalid") } + ); +} + +export function createBrowserGatewayTargetFormSchema(t: TranslateFn) { + return z.object({ + selectedSites: z.array(selectedSiteSchema).min(1, { + message: t("siteRequired") + }), + destination: z.string().min(1, { + message: t("destinationRequired") + }), + destinationPort: createPortStringSchema(t) + }); +} + +export type BrowserGatewayTargetFormValues = z.infer< + ReturnType +>; + +export function createSshSettingsFormSchema( + t: TranslateFn, + options: { isNative: boolean } +) { + const { isNative } = options; + const portSchema = createPortStringSchema(t); + const optionalAuthDaemonPortSchema = createOptionalAuthDaemonPortSchema(t); + + return z + .object({ + pamMode: z.enum(["passthrough", "push"]), + standardDaemonLocation: z.enum(["site", "remote"]), + authDaemonPort: z.string(), + selectedSites: z.array(selectedSiteSchema), + selectedSite: selectedSiteSchema.nullable(), + selectedNativeSite: selectedSiteSchema.nullable(), + destination: z.string(), + destinationPort: z.string() + }) + .superRefine((data, ctx) => { + if (isNative) { + if (!data.selectedNativeSite) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["selectedNativeSite"], + message: t("siteRequired") + }); + } + return; + } + + const useMultiSite = + data.standardDaemonLocation !== "site" || + data.pamMode === "passthrough"; + + if (useMultiSite) { + if (data.selectedSites.length === 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["selectedSites"], + message: t("siteRequired") + }); + } + } else if (!data.selectedSite) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["selectedSite"], + message: t("siteRequired") + }); + } + + if (!data.destination.trim()) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["destination"], + message: t("destinationRequired") + }); + } + + const portResult = portSchema.safeParse(data.destinationPort); + if (!portResult.success) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["destinationPort"], + message: t("healthCheckPortInvalid") + }); + } + + const showDaemonPort = + data.pamMode === "push" && + data.standardDaemonLocation === "remote"; + + if (showDaemonPort) { + const authPortResult = optionalAuthDaemonPortSchema.safeParse( + data.authDaemonPort + ); + if (!data.authDaemonPort.trim() || !authPortResult.success) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["authDaemonPort"], + message: t("healthCheckPortInvalid") + }); + } + } + }); +} + +export type SshSettingsFormValues = z.infer< + ReturnType +>;