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/(private)/policies/resource/[niceId]/page.tsx b/src/app/[orgId]/settings/(private)/policies/resources/public/[niceId]/page.tsx similarity index 87% rename from src/app/[orgId]/settings/(private)/policies/resource/[niceId]/page.tsx rename to src/app/[orgId]/settings/(private)/policies/resources/public/[niceId]/page.tsx index 5519506b9..1113016b8 100644 --- a/src/app/[orgId]/settings/(private)/policies/resource/[niceId]/page.tsx +++ b/src/app/[orgId]/settings/(private)/policies/resources/public/[niceId]/page.tsx @@ -28,11 +28,11 @@ export default async function EditPolicyPage(props: EditPolicyPageProps) { ); policyResponse = res.data.data; } catch { - redirect(`/${params.orgId}/settings/policies/resource`); + redirect(`/${params.orgId}/settings/policies/resources/public`); } if (!policyResponse) { - redirect(`/${params.orgId}/settings/policies/resource`); + redirect(`/${params.orgId}/settings/policies/resources/public`); } return ( @@ -46,7 +46,9 @@ export default async function EditPolicyPage(props: EditPolicyPageProps) { /> diff --git a/src/app/[orgId]/settings/(private)/policies/resource/create/page.tsx b/src/app/[orgId]/settings/(private)/policies/resources/public/create/page.tsx similarity index 88% rename from src/app/[orgId]/settings/(private)/policies/resource/create/page.tsx rename to src/app/[orgId]/settings/(private)/policies/resources/public/create/page.tsx index edf67fbef..4afa1110d 100644 --- a/src/app/[orgId]/settings/(private)/policies/resource/create/page.tsx +++ b/src/app/[orgId]/settings/(private)/policies/resources/public/create/page.tsx @@ -23,7 +23,9 @@ export default async function CreateResourcePolicyPage( /> diff --git a/src/app/[orgId]/settings/(private)/policies/resource/page.tsx b/src/app/[orgId]/settings/(private)/policies/resources/public/page.tsx similarity index 100% rename from src/app/[orgId]/settings/(private)/policies/resource/page.tsx rename to src/app/[orgId]/settings/(private)/policies/resources/public/page.tsx diff --git a/src/app/[orgId]/settings/resources/public/ProxyResourceTargetsForm.tsx b/src/app/[orgId]/settings/resources/public/ProxyResourceTargetsForm.tsx index 881d46b7b..4e5302df2 100644 --- a/src/app/[orgId]/settings/resources/public/ProxyResourceTargetsForm.tsx +++ b/src/app/[orgId]/settings/resources/public/ProxyResourceTargetsForm.tsx @@ -10,7 +10,10 @@ import { PathRewriteDisplay, PathRewriteModal } from "@app/components/PathMatchRenameModal"; -import { ResourceTargetAddressItem } from "@app/components/resource-target-address-item"; +import { + ResourceTargetAddressItem, + ResourceTargetSiteItem +} from "@app/components/resource-target-address-item"; import { SettingsSection, SettingsSectionBody, @@ -65,6 +68,7 @@ import { useMemo, useState } from "react"; +import { maxSize } from "zod"; export type LocalTarget = Omit< ArrayElement & { @@ -228,7 +232,7 @@ export function ProxyResourceTargetsForm({ const priorityColumn: ColumnDef = { id: "priority", header: () => ( -
+
{t("priority")} @@ -244,7 +248,6 @@ export function ProxyResourceTargetsForm({ ), cell: ({ row }) => { return ( -
-
); }, size: 120, @@ -396,13 +398,12 @@ export function ProxyResourceTargetsForm({ maxSize: 200 }; - const addressColumn: ColumnDef = { - accessorKey: "address", - header: () => {t("address")}, + const siteColumn: ColumnDef = { + accessorKey: "site", + header: () => {t("site")}, cell: ({ row }) => { return ( - ); }, - size: 400, - minSize: 350, - maxSize: 500 + size: 220, + minSize: 180, + maxSize: 280 + }; + + const addressColumn: ColumnDef = { + accessorKey: "address", + header: () => {t("address")}, + cell: ({ row }) => { + return ( + + ); + }, + size: 350, + minSize: 300, + maxSize: 450 }; const rewritePathColumn: ColumnDef = { @@ -526,6 +544,7 @@ export function ProxyResourceTargetsForm({ if (isAdvancedMode) { const cols = [ + siteColumn, addressColumn, healthCheckColumn, enabledColumn, @@ -534,12 +553,13 @@ export function ProxyResourceTargetsForm({ if (isHttp) { cols.unshift(matchPathColumn); - cols.splice(3, 0, rewritePathColumn, priorityColumn); + cols.splice(4, 0, rewritePathColumn, priorityColumn); } return cols; } else { return [ + siteColumn, addressColumn, healthCheckColumn, enabledColumn, @@ -779,6 +799,10 @@ export function ProxyResourceTargetsForm({ header.column .id === "actions"; + const isSiteColumn = + header.column + .id === + "site"; return ( {header.isPlaceholder @@ -819,6 +845,10 @@ export function ProxyResourceTargetsForm({ cell.column .id === "actions"; + const isSiteColumn = + cell.column + .id === + "site"; return ( {flexRender( diff --git a/src/app/[orgId]/settings/resources/public/[niceId]/authentication/page.tsx b/src/app/[orgId]/settings/resources/public/[niceId]/authentication/page.tsx index ba55ce833..5c02e8c8f 100644 --- a/src/app/[orgId]/settings/resources/public/[niceId]/authentication/page.tsx +++ b/src/app/[orgId]/settings/resources/public/[niceId]/authentication/page.tsx @@ -330,7 +330,7 @@ export default function ResourceAuthenticationPage() { asChild > {t("editSharedPolicy")} 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 d2ed89601..756561bf5 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,16 +36,43 @@ type ExistingTarget = { siteId: number; }; -export default function SshSettingsPage(props: { +type TargetRow = { + targetId: number; + resourceId: number; + siteId: number; + siteName?: string; + mode: string | null; + ip: string; + port: number; +}; + +type ResourceTargetsResponse = { + targets: TargetRow[]; +}; + +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: targetsResponse, isLoading: isLoadingTargets } = useQuery({ + queryKey: ["resourceTargets", resource.resourceId, params.orgId, "rdp"], + queryFn: async () => { + const res = await api.get(`/resource/${resource.resourceId}/targets`); + return res.data.data as ResourceTargetsResponse; + } + }); + + if (isLoadingTargets) { + return null; + } + return ( ); @@ -63,138 +92,103 @@ export default function SshSettingsPage(props: { function RdpServerForm({ orgId, resource, - updateResource, - disabled + disabled, + targetsResponse }: { orgId: string; resource: GetResourceResponse; updateResource: ResourceContextType["updateResource"]; disabled: boolean; + targetsResponse: ResourceTargetsResponse; }) { const t = useTranslations(); const api = createApiClient(useEnvContext()); const router = useRouter(); + const targets = targetsResponse.targets.filter((t) => t.mode === "rdp"); + 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: ["resourceTargets", resource.resourceId, orgId, "rdp"], - queryFn: async () => { - const res = await api.get(`/resource/${resource.resourceId}/targets`); - return res.data.data as { - targets: Array<{ - targetId: number; - resourceId: number; - siteId: number; - siteName?: string; - mode: string | null; - ip: string; - port: 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?.ip ?? "", + destinationPort: firstTarget ? String(firstTarget.port) : "3389" } }); - useEffect(() => { - if (!bgTargetsResponse?.targets?.length) return; - const targets = bgTargetsResponse.targets.filter( - (t) => t.mode === "rdp" - ); - if (!targets.length) return; - const first = targets[0]; - - setBgDestination(first.ip); - setBgDestinationPort(String(first.port)); - setExistingTargets( - targets.map((t) => ({ - targetId: t.targetId, - siteId: t.siteId + const [existingTargets, setExistingTargets] = useState( + () => + targets.map((target) => ({ + targetId: target.targetId, + 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(`/target/${t.targetId}`) - ) - ); + const toDelete = existingTargets.filter( + (t) => !selectedSiteIds.has(t.siteId) + ); + await Promise.all(toDelete.map((t) => api.delete(`/target/${t.targetId}`))); - const toUpdate = existingTargets.filter((t) => - selectedSiteIds.has(t.siteId) - ); - await Promise.all( - toUpdate.map((t) => - api.post( - `/target/${t.targetId}`, - { - mode: "rdp", - ip: bgDestination, - port: Number(bgDestinationPort), - siteId: t.siteId, - hcEnabled: false - } - ) - ) - ); + const toUpdate = existingTargets.filter((t) => + selectedSiteIds.has(t.siteId) + ); + await Promise.all( + toUpdate.map((t) => + api.post(`/target/${t.targetId}`, { + mode: "rdp", + ip: destination, + port: Number(destinationPort), + siteId: t.siteId, + hcEnabled: false + }) + ) + ); - const toCreate = selectedSites.filter( - (s) => !existingSiteIds.has(s.siteId) - ); - const created = await Promise.all( - toCreate.map((s) => - api.put( - `/resource/${resource.resourceId}/target`, - { - siteId: s.siteId, - mode: "rdp", - ip: bgDestination, - port: Number(bgDestinationPort), - hcEnabled: false - } - ) - ) - ); + const toCreate = selectedSites.filter( + (s) => !existingSiteIds.has(s.siteId) + ); + const created = await Promise.all( + toCreate.map((s) => + api.put(`/resource/${resource.resourceId}/target`, { + siteId: s.siteId, + mode: "rdp", + ip: destination, + port: Number(destinationPort), + authToken: null, + hcEnabled: false + }) + ) + ); - const newTargets: ExistingTarget[] = created.map((res, i) => ({ - targetId: res.data.data.targetId, - siteId: toCreate[i].siteId - })); - setExistingTargets([...toUpdate, ...newTargets]); - } + const newTargets: ExistingTarget[] = created.map((res, i) => ({ + targetId: res.data.data.targetId, + siteId: toCreate[i].siteId + })); + setExistingTargets([...toUpdate, ...newTargets]); toast({ title: t("settingsUpdated"), @@ -226,31 +220,31 @@ function RdpServerForm({ 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 7a85be60c..0187a6c62 100644 --- a/src/app/[orgId]/settings/resources/public/[niceId]/ssh/page.tsx +++ b/src/app/[orgId]/settings/resources/public/[niceId]/ssh/page.tsx @@ -15,10 +15,7 @@ import { import { StrategySelect, StrategyOption } from "@app/components/StrategySelect"; import { BrowserGatewayTargetForm } from "@app/components/BrowserGatewayTargetForm"; import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert"; -import { - SitesSelector, - type Selectedsite -} from "@app/components/site-selector"; +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"; @@ -41,15 +38,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"; @@ -59,16 +57,20 @@ type ExistingTarget = { authToken?: string | null; }; -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 TargetRow = { + targetId: number; + resourceId: number; + siteId: number; + siteName?: string; + mode: string | null; + ip: string; + port: number; + authToken?: string | null; +}; + +type ResourceTargetsResponse = { + targets: TargetRow[]; +}; export default function SshSettingsPage(props: { params: Promise<{ orgId: string }>; @@ -76,10 +78,23 @@ 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: targetsResponse, isLoading: isLoadingTargets } = useQuery({ + queryKey: ["resourceTargets", resource.resourceId, params.orgId, "ssh"], + queryFn: async () => { + const res = await api.get(`/resource/${resource.resourceId}/targets`); + return res.data.data as ResourceTargetsResponse; + } + }); + + if (isLoadingTargets) { + return null; + } + return ( ); @@ -99,146 +115,145 @@ function SshServerForm({ orgId, resource, updateResource, - disabled + disabled, + targetsResponse }: { orgId: string; resource: GetResourceResponse; updateResource: ResourceContextType["updateResource"]; disabled: boolean; + targetsResponse: ResourceTargetsResponse; }) { const t = useTranslations(); const api = createApiClient(useEnvContext()); const router = useRouter(); const isNativeInitially = resource.authDaemonMode === "native"; + const targets = targetsResponse.targets.filter((t) => t.mode === "ssh"); + 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?.ip ?? ""), + destinationPort: isNativeInitially + ? "22" + : firstTarget + ? String(firstTarget.port) + : "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) => ({ + targetId: target.targetId, + siteId: target.siteId + })) ); - // Native mode: single site - const [selectedNativeSite, setSelectedNativeSite] = - useState(null); const [nativeExistingTarget, setNativeExistingTarget] = - useState(null); - const [nativeSiteOpen, setNativeSiteOpen] = useState(false); - - const { data: bgTargetsResponse } = useQuery({ - queryKey: ["resourceTargets", resource.resourceId, orgId, "ssh"], - queryFn: async () => { - const res = await api.get(`/resource/${resource.resourceId}/targets`); - return res.data.data as { - targets: Array<{ - targetId: number; - resourceId: number; - siteId: number; - siteName?: string; - mode: string | null; - ip: string; - port: number; - authToken?: string | null; - }>; - }; - } - }); - - useEffect(() => { - if (!bgTargetsResponse?.targets?.length) return; - const targets = bgTargetsResponse.targets.filter( - (t) => t.mode === "ssh" + useState(() => + isNativeInitially && firstTarget + ? { + targetId: firstTarget.targetId, + siteId: firstTarget.siteId, + authToken: firstTarget.authToken + } + : null ); - if (!targets.length) return; - const first = targets[0]; - if (isNativeInitially) { - setSelectedNativeSite({ - siteId: first.siteId, - name: first.siteName ?? String(first.siteId), - type: "newt" as const - }); - setNativeExistingTarget({ - targetId: first.targetId, - siteId: first.siteId, - authToken: first.authToken - }); - } else { - setBgDestination(first.ip); - setBgDestinationPort(String(first.port)); - setExistingTargets( - targets.map((t) => ({ - targetId: t.targetId, - siteId: t.siteId, - authToken: t.authToken - })) - ); - setSelectedSites( - targets.map((t) => ({ - siteId: t.siteId, - name: t.siteName ?? String(t.siteId), - type: "newt" as const - })) - ); - } - }, [bgTargetsResponse]); - + const [nativeSiteOpen, setNativeSiteOpen] = useState(false); 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( `/target/${nativeExistingTarget.targetId}`, @@ -270,23 +285,34 @@ function SshServerForm({ } } } 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 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(`/target/${t.targetId}`) + const toDelete = existingTargets.filter( + (t) => !selectedSiteIds.has(t.siteId) + ); + await Promise.all( + toDelete.map((t) => + api.delete( + `/org/${orgId}/browser-gateway-target/${t.browserGatewayTargetId}` +>>>>>>> 8ee520dbb58f6bd4009581c79322f77b17ff6757 ) - ); + ) + ); +<<<<<<< HEAD const toUpdate = existingTargets.filter((t) => selectedSiteIds.has(t.siteId) ); @@ -297,60 +323,52 @@ function SshServerForm({ { mode: "ssh", ip: bgDestination, - port: Number(bgDestinationPort), - siteId: t.siteId, - authToken: t.authToken, - hcEnabled: false + api.delete(`/target/${t.targetId}`) } ) - ) - ); + ); +<<<<<<< HEAD const toCreate = selectedSites.filter( (s) => !existingSiteIds.has(s.siteId) ); - const created = await Promise.all( + `/target/${t.targetId}`, toCreate.map((s) => - api.put( - `/resource/${resource.resourceId}/target`, - { - siteId: s.siteId, - mode: "ssh", - ip: bgDestination, + mode: "ssh", + ip: values.destination, + port: Number(values.destinationPort), + siteId: t.siteId, + authToken: t.authToken, + hcEnabled: false port: Number(bgDestinationPort), hcEnabled: false - } - ) - ) - ); + ) + ); + ) + ); + +<<<<<<< HEAD const newTargets: ExistingTarget[] = created.map( (res, i) => ({ - targetId: res.data.data.targetId, + `/resource/${resource.resourceId}/target`, siteId: toCreate[i].siteId, authToken: res.data.data.authToken - }) - ); - setExistingTargets([...toUpdate, ...newTargets]); - } - } - - toast({ - title: t("settingsUpdated"), - description: t("settingsUpdatedDescription") + mode: "ssh", + ip: values.destination, + port: Number(values.destinationPort), + hcEnabled: false + const newTargets: ExistingTarget[] = created.map((res, i) => ({ + browserGatewayTargetId: + ) + ); }); - router.refresh(); - } catch (err) { - console.error(err); - toast({ - variant: "destructive", - title: t("settingsErrorUpdate"), - description: formatAxiosError( - err, - t("settingsErrorUpdateDescription") - ) - }); - } + const newTargets: ExistingTarget[] = created.map((res, i) => ({ + targetId: res.data.data.targetId, + siteId: toCreate[i].siteId, + authToken: res.data.data.authToken + })); + setExistingTargets([...toUpdate, ...newTargets]); } const authMethodOptions: StrategyOption<"passthrough" | "push">[] = [ @@ -382,6 +400,9 @@ function SshServerForm({ const showDaemonLocation = !isNative && pamMode === "push"; const showDaemonPort = !isNative && pamMode === "push" && standardDaemonLocation === "remote"; + const useMultiSiteTargetForm = + !isNative && + (standardDaemonLocation !== "site" || pamMode === "passthrough"); return ( @@ -395,160 +416,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 ecac78b6f..7b2c38399 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,16 +36,43 @@ type ExistingTarget = { siteId: number; }; -export default function SshSettingsPage(props: { +type TargetRow = { + targetId: number; + resourceId: number; + siteId: number; + siteName?: string; + mode: string | null; + ip: string; + port: number; +}; + +type ResourceTargetsResponse = { + targets: TargetRow[]; +}; + +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: targetsResponse, isLoading: isLoadingTargets } = useQuery({ + queryKey: ["resourceTargets", resource.resourceId, params.orgId, "vnc"], + queryFn: async () => { + const res = await api.get(`/resource/${resource.resourceId}/targets`); + return res.data.data as ResourceTargetsResponse; + } + }); + + if (isLoadingTargets) { + return null; + } + return ( ); @@ -61,138 +92,103 @@ export default function SshSettingsPage(props: { function VncServerForm({ orgId, resource, - updateResource, - disabled + disabled, + targetsResponse }: { orgId: string; resource: GetResourceResponse; updateResource: ResourceContextType["updateResource"]; disabled: boolean; + targetsResponse: ResourceTargetsResponse; }) { const t = useTranslations(); const api = createApiClient(useEnvContext()); const router = useRouter(); + const targets = targetsResponse.targets.filter((t) => t.mode === "vnc"); + 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: ["resourceTargets", resource.resourceId, orgId, "vnc"], - queryFn: async () => { - const res = await api.get(`/resource/${resource.resourceId}/targets`); - return res.data.data as { - targets: Array<{ - targetId: number; - resourceId: number; - siteId: number; - siteName?: string; - mode: string | null; - ip: string; - port: 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?.ip ?? "", + destinationPort: firstTarget ? String(firstTarget.port) : "5900" } }); - useEffect(() => { - if (!bgTargetsResponse?.targets?.length) return; - const targets = bgTargetsResponse.targets.filter( - (t) => t.mode === "vnc" - ); - if (!targets.length) return; - const first = targets[0]; - - setBgDestination(first.ip); - setBgDestinationPort(String(first.port)); - setExistingTargets( - targets.map((t) => ({ - targetId: t.targetId, - siteId: t.siteId + const [existingTargets, setExistingTargets] = useState( + () => + targets.map((target) => ({ + targetId: target.targetId, + 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(`/target/${t.targetId}`) - ) - ); + const toDelete = existingTargets.filter( + (t) => !selectedSiteIds.has(t.siteId) + ); + await Promise.all(toDelete.map((t) => api.delete(`/target/${t.targetId}`))); - const toUpdate = existingTargets.filter((t) => - selectedSiteIds.has(t.siteId) - ); - await Promise.all( - toUpdate.map((t) => - api.post( - `/target/${t.targetId}`, - { - mode: "vnc", - ip: bgDestination, - port: Number(bgDestinationPort), - siteId: t.siteId, - hcEnabled: false - } - ) - ) - ); + const toUpdate = existingTargets.filter((t) => + selectedSiteIds.has(t.siteId) + ); + await Promise.all( + toUpdate.map((t) => + api.post(`/target/${t.targetId}`, { + mode: "vnc", + ip: destination, + port: Number(destinationPort), + siteId: t.siteId, + hcEnabled: false + }) + ) + ); - const toCreate = selectedSites.filter( - (s) => !existingSiteIds.has(s.siteId) - ); - const created = await Promise.all( - toCreate.map((s) => - api.put( - `/resource/${resource.resourceId}/target`, - { - siteId: s.siteId, - mode: "vnc", - ip: bgDestination, - port: Number(bgDestinationPort), - hcEnabled: false - } - ) - ) - ); + const toCreate = selectedSites.filter( + (s) => !existingSiteIds.has(s.siteId) + ); + const created = await Promise.all( + toCreate.map((s) => + api.put(`/resource/${resource.resourceId}/target`, { + siteId: s.siteId, + mode: "vnc", + ip: destination, + port: Number(destinationPort), + authToken: null, + hcEnabled: false + }) + ) + ); - const newTargets: ExistingTarget[] = created.map((res, i) => ({ - targetId: res.data.data.targetId, - siteId: toCreate[i].siteId - })); - setExistingTargets([...toUpdate, ...newTargets]); - } + const newTargets: ExistingTarget[] = created.map((res, i) => ({ + targetId: res.data.data.targetId, + siteId: toCreate[i].siteId + })); + setExistingTargets([...toUpdate, ...newTargets]); toast({ title: t("settingsUpdated"), @@ -224,31 +220,31 @@ function VncServerForm({ 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 f3715cba0..322330d63 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); @@ -509,20 +602,23 @@ 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( `/resource/${id}/target`, { siteId: site.siteId, mode: "ssh", - ip: bgDestination, - port: Number(bgDestinationPort), + ip: bgValues.destination, + port: Number(bgValues.destinationPort), hcEnabled: false } ); @@ -533,18 +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( `/resource/${id}/target`, { siteId: site.siteId, mode: resourceType, - ip: bgDestination, - port: Number(bgDestinationPort), + ip: bgValues.destination, + port: Number(bgValues.destinationPort), authToken: null, hcEnabled: false } - ); + ); } router.push( @@ -764,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) */} @@ -887,9 +1008,7 @@ export default function Page() { {/* Mode */}
- - {t("sshServerMode")} - +

{t("sshServerMode")}

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

{t("sshAuthenticationMethod")}

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

{t("sshAuthDaemonLocation")}

@@ -1056,55 +1167,39 @@ export default function Page() { "site" || pamMode === "passthrough" ? ( - +
+ + ) : ( - +
+ + )}
@@ -1142,26 +1237,18 @@ export default function Page() { > - +
+ +
@@ -1197,26 +1284,18 @@ export default function Page() { > - +
+ +
@@ -1257,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/app/navigation.tsx b/src/app/navigation.tsx index bf954a5cb..87cd3259f 100644 --- a/src/app/navigation.tsx +++ b/src/app/navigation.tsx @@ -146,7 +146,7 @@ export const orgNavSections = ( items: [ { title: "sidebarResourcePolicies", - href: "/{orgId}/settings/policies/resource", + href: "/{orgId}/settings/policies/resources/public", icon: ( ) 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/ResourcePoliciesTable.tsx b/src/components/ResourcePoliciesTable.tsx index 48235e79f..4a0a382e5 100644 --- a/src/components/ResourcePoliciesTable.tsx +++ b/src/components/ResourcePoliciesTable.tsx @@ -200,7 +200,7 @@ export function ResourcePoliciesTable({ {t("viewSettings")} @@ -219,7 +219,7 @@ export function ResourcePoliciesTable({