"use client"; import { HorizontalTabs } from "@app/components/HorizontalTabs"; import { OptionSelect, type OptionSelectOption } from "@app/components/OptionSelect"; import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert"; import { StrategySelect } from "@app/components/StrategySelect"; import { Tag, TagInput } from "@app/components/tags/tag-input"; import { Button } from "@app/components/ui/button"; import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@app/components/ui/form"; import { Input } from "@app/components/ui/input"; import { Popover, PopoverContent, PopoverTrigger } from "@app/components/ui/popover"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@app/components/ui/select"; import { Switch } from "@app/components/ui/switch"; import { useEnvContext } from "@app/hooks/useEnvContext"; import { usePaidStatus } from "@app/hooks/usePaidStatus"; import { cn } from "@app/lib/cn"; import { getUserDisplayName } from "@app/lib/getUserDisplayName"; import { orgQueries, resourceQueries } from "@app/lib/queries"; import { zodResolver } from "@hookform/resolvers/zod"; import { tierMatrix } from "@server/lib/billing/tierMatrix"; import { UserType } from "@server/types/UserTypes"; import { useQuery } from "@tanstack/react-query"; import { ChevronsUpDown, ExternalLink } from "lucide-react"; import { useTranslations } from "next-intl"; import { useEffect, useRef, useState } from "react"; import { useForm } from "react-hook-form"; import { z } from "zod"; import { MultiSitesSelector, formatMultiSitesSelectorLabel } from "./multi-site-selector"; import type { Selectedsite } from "./site-selector"; import { CaretSortIcon } from "@radix-ui/react-icons"; import { MachinesSelector } from "./machines-selector"; import DomainPicker from "@app/components/DomainPicker"; import { SwitchInput } from "@app/components/SwitchInput"; import CertificateStatus from "@app/components/CertificateStatus"; import { build } from "@server/build"; // --- Helpers (shared) --- const isValidPortRangeString = (val: string | undefined | null): boolean => { if (!val || val.trim() === "" || val.trim() === "*") return true; const parts = val.split(",").map((p) => p.trim()); for (const part of parts) { if (part === "") return false; if (part.includes("-")) { const [start, end] = part.split("-").map((p) => p.trim()); if (!start || !end) return false; const startPort = parseInt(start, 10); const endPort = parseInt(end, 10); if (isNaN(startPort) || isNaN(endPort)) return false; if ( startPort < 1 || startPort > 65535 || endPort < 1 || endPort > 65535 ) return false; if (startPort > endPort) return false; } else { const port = parseInt(part, 10); if (isNaN(port) || port < 1 || port > 65535) return false; } } return true; }; const getPortRangeValidationMessage = (t: (key: string) => string) => t("editInternalResourceDialogPortRangeValidationError"); const createPortRangeStringSchema = (t: (key: string) => string) => z .string() .optional() .nullable() .refine((val) => isValidPortRangeString(val), { message: getPortRangeValidationMessage(t) }); export type PortMode = "all" | "blocked" | "custom"; export const getPortModeFromString = ( val: string | undefined | null ): PortMode => { if (val === "*") return "all"; if (!val || val.trim() === "") return "blocked"; return "custom"; }; export const getPortStringFromMode = ( mode: PortMode, customValue: string ): string | undefined => { if (mode === "all") return "*"; if (mode === "blocked") return ""; return customValue; }; export const isHostname = (destination: string): boolean => /[a-zA-Z]/.test(destination); export const cleanForFQDN = (name: string): string => name .toLowerCase() .replace(/[^a-z0-9.-]/g, "-") .replace(/[-]+/g, "-") .replace(/^-|-$/g, "") .replace(/^\.|\.$/g, ""); // --- Types --- export type InternalResourceMode = "host" | "cidr" | "http"; export type InternalResourceData = { id: number; name: string; orgId: string; siteNames: string[]; mode: InternalResourceMode; siteIds: number[]; niceId: string; destination: string; alias?: string | null; tcpPortRangeString?: string | null; udpPortRangeString?: string | null; disableIcmp?: boolean; authDaemonMode?: "site" | "remote" | null; authDaemonPort?: number | null; httpHttpsPort?: number | null; scheme?: "http" | "https" | null; ssl?: boolean; subdomain?: string | null; domainId?: string | null; fullDomain?: string | null; }; const tagSchema = z.object({ id: z.string(), text: z.string() }); function buildSelectedSitesForResource( resource: InternalResourceData ): Selectedsite[] { return resource.siteIds.map((siteId, idx) => ({ name: resource.siteNames[idx] ?? "", siteId, type: "newt" as const })); } export type InternalResourceFormValues = { name: string; siteIds: number[]; mode: InternalResourceMode; destination: string; alias?: string | null; niceId?: string; tcpPortRangeString?: string | null; udpPortRangeString?: string | null; disableIcmp?: boolean; authDaemonMode?: "site" | "remote" | null; authDaemonPort?: number | null; httpHttpsPort?: number | null; scheme?: "http" | "https"; ssl?: boolean; httpConfigSubdomain?: string | null; httpConfigDomainId?: string | null; httpConfigFullDomain?: string | null; roles?: z.infer[]; users?: z.infer[]; clients?: z.infer[]; }; type InternalResourceFormProps = { variant: "create" | "edit"; resource?: InternalResourceData; open?: boolean; orgId: string; siteResourceId?: number; formId: string; onSubmit: (values: InternalResourceFormValues) => void | Promise; onSubmitDisabledChange?: (disabled: boolean) => void; }; export function InternalResourceForm({ variant, resource, open, orgId, siteResourceId, formId, onSubmit, onSubmitDisabledChange }: InternalResourceFormProps) { const t = useTranslations(); const { env } = useEnvContext(); const { isPaidUser } = usePaidStatus(); const disableEnterpriseFeatures = env.flags.disableEnterpriseFeatures; const sshSectionDisabled = !isPaidUser(tierMatrix.sshPam); const httpSectionDisabled = !isPaidUser(tierMatrix.httpPrivateResources); const nameRequiredKey = variant === "create" ? "createInternalResourceDialogNameRequired" : "editInternalResourceDialogNameRequired"; const nameMaxKey = variant === "create" ? "createInternalResourceDialogNameMaxLength" : "editInternalResourceDialogNameMaxLength"; const siteRequiredKey = variant === "create" ? "createInternalResourceDialogPleaseSelectSite" : undefined; const nameLabelKey = variant === "create" ? "createInternalResourceDialogName" : "editInternalResourceDialogName"; const modeLabelKey = variant === "create" ? "createInternalResourceDialogMode" : "editInternalResourceDialogMode"; const modeHostKey = variant === "create" ? "createInternalResourceDialogModeHost" : "editInternalResourceDialogModeHost"; const modeCidrKey = variant === "create" ? "createInternalResourceDialogModeCidr" : "editInternalResourceDialogModeCidr"; const modeHttpKey = variant === "create" ? "createInternalResourceDialogModeHttp" : "editInternalResourceDialogModeHttp"; const schemeLabelKey = variant === "create" ? "createInternalResourceDialogScheme" : "editInternalResourceDialogScheme"; const enableSslLabelKey = variant === "create" ? "createInternalResourceDialogEnableSsl" : "editInternalResourceDialogEnableSsl"; const enableSslDescriptionKey = variant === "create" ? "createInternalResourceDialogEnableSslDescription" : "editInternalResourceDialogEnableSslDescription"; const destinationLabelKey = variant === "create" ? "createInternalResourceDialogDestination" : "editInternalResourceDialogDestination"; const destinationRequiredKey = variant === "create" ? "createInternalResourceDialogDestinationRequired" : undefined; const aliasLabelKey = variant === "create" ? "createInternalResourceDialogAlias" : "editInternalResourceDialogAlias"; const httpHttpsPortLabelKey = variant === "create" ? "createInternalResourceDialogModePort" : "editInternalResourceDialogModePort"; const httpConfigurationTitleKey = variant === "create" ? "createInternalResourceDialogHttpConfiguration" : "editInternalResourceDialogHttpConfiguration"; const httpConfigurationDescriptionKey = variant === "create" ? "createInternalResourceDialogHttpConfigurationDescription" : "editInternalResourceDialogHttpConfigurationDescription"; const siteIdsSchema = siteRequiredKey ? z.array(z.number().int().positive()).min(1, t(siteRequiredKey)) : z.array(z.number().int().positive()).min(1); const formSchema = z .object({ name: z.string().min(1, t(nameRequiredKey)).max(255, t(nameMaxKey)), siteIds: siteIdsSchema, mode: z.enum(["host", "cidr", "http"]), destination: z .string() .min( 1, destinationRequiredKey ? { message: t(destinationRequiredKey) } : undefined ), alias: z.string().nullish(), httpHttpsPort: z .number() .int() .min(1) .max(65535) .optional() .nullable(), scheme: z.enum(["http", "https"]).optional(), ssl: z.boolean().optional(), httpConfigSubdomain: z.string().nullish(), httpConfigDomainId: z.string().nullish(), httpConfigFullDomain: z.string().nullish(), niceId: z .string() .min(1) .max(255) .regex(/^[a-zA-Z0-9-]+$/) .optional(), tcpPortRangeString: createPortRangeStringSchema(t), udpPortRangeString: createPortRangeStringSchema(t), disableIcmp: z.boolean().optional(), authDaemonMode: z.enum(["site", "remote"]).optional().nullable(), authDaemonPort: z.number().int().positive().optional().nullable(), roles: z.array(tagSchema).optional(), users: z.array(tagSchema).optional(), clients: z .array( z.object({ clientId: z.number(), name: z.string() }) ) .optional() }) .superRefine((data, ctx) => { if (data.mode !== "http") return; if (!data.scheme) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: t("internalResourceDownstreamSchemeRequired"), path: ["scheme"] }); } if ( data.httpHttpsPort == null || !Number.isFinite(data.httpHttpsPort) || data.httpHttpsPort < 1 ) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: t("internalResourceHttpPortRequired"), path: ["httpHttpsPort"] }); } }); type FormData = z.infer; const rolesQuery = useQuery(orgQueries.roles({ orgId })); const usersQuery = useQuery(orgQueries.users({ orgId })); const clientsQuery = useQuery(orgQueries.machineClients({ orgId })); const resourceRolesQuery = useQuery({ ...resourceQueries.siteResourceRoles({ siteResourceId: siteResourceId ?? 0 }), enabled: siteResourceId != null }); const resourceUsersQuery = useQuery({ ...resourceQueries.siteResourceUsers({ siteResourceId: siteResourceId ?? 0 }), enabled: siteResourceId != null }); const resourceClientsQuery = useQuery({ ...resourceQueries.siteResourceClients({ siteResourceId: siteResourceId ?? 0 }), enabled: siteResourceId != null }); const allRoles = (rolesQuery.data ?? []) .map((r) => ({ id: r.roleId.toString(), text: r.name })) .filter((r) => r.text !== "Admin"); const allUsers = (usersQuery.data ?? []).map((u) => ({ id: u.id.toString(), text: `${getUserDisplayName({ email: u.email, username: u.username })}${u.type !== UserType.Internal ? ` (${u.idpName})` : ""}` })); const allClients = (clientsQuery.data ?? []) .filter((c) => !c.userId) .map((c) => ({ id: c.clientId.toString(), text: c.name })); let formRoles: FormData["roles"] = []; let formUsers: FormData["users"] = []; let existingClients: FormData["clients"] = []; if (siteResourceId != null) { const rolesData = resourceRolesQuery.data; const usersData = resourceUsersQuery.data; const clientsData = resourceClientsQuery.data; if (rolesData) { formRoles = (rolesData as { roleId: number; name: string }[]) .map((i) => ({ id: i.roleId.toString(), text: i.name })) .filter((r) => r.text !== "Admin"); } if (usersData) { formUsers = ( usersData as { userId: string; email?: string; username?: string; type?: string; idpName?: string; }[] ).map((i) => ({ id: i.userId.toString(), text: `${getUserDisplayName({ email: i.email, username: i.username })}${i.type !== UserType.Internal ? ` (${i.idpName})` : ""}` })); } if (clientsData) { existingClients = [ ...(clientsData as { clientId: number; name: string }[]) ]; } } const loadingRolesUsers = rolesQuery.isLoading || usersQuery.isLoading || clientsQuery.isLoading || (siteResourceId != null && (resourceRolesQuery.isLoading || resourceUsersQuery.isLoading || resourceClientsQuery.isLoading)); const hasMachineClients = allClients.length > 0; const [activeRolesTagIndex, setActiveRolesTagIndex] = useState< number | null >(null); const [activeUsersTagIndex, setActiveUsersTagIndex] = useState< number | null >(null); const [activeClientsTagIndex, setActiveClientsTagIndex] = useState< number | null >(null); const [tcpPortMode, setTcpPortMode] = useState(() => variant === "edit" && resource ? getPortModeFromString(resource.tcpPortRangeString) : "all" ); const [udpPortMode, setUdpPortMode] = useState(() => variant === "edit" && resource ? getPortModeFromString(resource.udpPortRangeString) : "all" ); const [tcpCustomPorts, setTcpCustomPorts] = useState(() => variant === "edit" && resource && resource.tcpPortRangeString && resource.tcpPortRangeString !== "*" ? resource.tcpPortRangeString : "" ); const [udpCustomPorts, setUdpCustomPorts] = useState(() => variant === "edit" && resource && resource.udpPortRangeString && resource.udpPortRangeString !== "*" ? resource.udpPortRangeString : "" ); const defaultValues: FormData = variant === "edit" && resource ? { name: resource.name, siteIds: resource.siteIds, mode: resource.mode ?? "host", destination: resource.destination ?? "", alias: resource.alias ?? null, tcpPortRangeString: resource.tcpPortRangeString ?? "*", udpPortRangeString: resource.udpPortRangeString ?? "*", disableIcmp: resource.disableIcmp ?? false, authDaemonMode: resource.authDaemonMode ?? "site", authDaemonPort: resource.authDaemonPort ?? null, httpHttpsPort: resource.httpHttpsPort ?? null, scheme: resource.scheme ?? "http", ssl: resource.ssl ?? false, httpConfigSubdomain: resource.subdomain ?? null, httpConfigDomainId: resource.domainId ?? null, httpConfigFullDomain: resource.fullDomain ?? null, niceId: resource.niceId, roles: [], users: [], clients: [] } : { name: "", siteIds: [], mode: "host", destination: "", alias: null, httpHttpsPort: null, scheme: "http", ssl: true, httpConfigSubdomain: null, httpConfigDomainId: null, httpConfigFullDomain: null, tcpPortRangeString: "*", udpPortRangeString: "*", disableIcmp: false, authDaemonMode: "site", authDaemonPort: null, roles: [], users: [], clients: [] }; const [selectedSites, setSelectedSites] = useState(() => variant === "edit" && resource ? buildSelectedSitesForResource(resource) : [] ); const form = useForm({ resolver: zodResolver(formSchema), defaultValues }); const mode = form.watch("mode"); const httpConfigSubdomain = form.watch("httpConfigSubdomain"); const httpConfigDomainId = form.watch("httpConfigDomainId"); const httpConfigFullDomain = form.watch("httpConfigFullDomain"); const isHttpMode = mode === "http"; const authDaemonMode = form.watch("authDaemonMode") ?? "site"; const hasInitialized = useRef(false); const previousResourceId = useRef(null); useEffect(() => { const tcpValue = getPortStringFromMode(tcpPortMode, tcpCustomPorts); form.setValue("tcpPortRangeString", tcpValue); }, [tcpPortMode, tcpCustomPorts, form]); useEffect(() => { const udpValue = getPortStringFromMode(udpPortMode, udpCustomPorts); form.setValue("udpPortRangeString", udpValue); }, [udpPortMode, udpCustomPorts, form]); // Reset when create dialog opens useEffect(() => { if (variant === "create" && open) { form.reset({ name: "", siteIds: [], mode: "host", destination: "", alias: null, httpHttpsPort: null, scheme: "http", ssl: true, httpConfigSubdomain: null, httpConfigDomainId: null, httpConfigFullDomain: null, tcpPortRangeString: "*", udpPortRangeString: "*", disableIcmp: false, authDaemonMode: "site", authDaemonPort: null, roles: [], users: [], clients: [] }); setSelectedSites([]); setTcpPortMode("all"); setUdpPortMode("all"); setTcpCustomPorts(""); setUdpCustomPorts(""); } }, [variant, open, form]); // Reset when edit dialog opens / resource changes useEffect(() => { if (variant === "edit" && resource) { const resourceChanged = previousResourceId.current !== resource.id; if (resourceChanged) { form.reset({ name: resource.name, siteIds: resource.siteIds, mode: resource.mode ?? "host", destination: resource.destination ?? "", alias: resource.alias ?? null, httpHttpsPort: resource.httpHttpsPort ?? null, scheme: resource.scheme ?? "http", ssl: resource.ssl ?? false, httpConfigSubdomain: resource.subdomain ?? null, httpConfigDomainId: resource.domainId ?? null, httpConfigFullDomain: resource.fullDomain ?? null, tcpPortRangeString: resource.tcpPortRangeString ?? "*", udpPortRangeString: resource.udpPortRangeString ?? "*", disableIcmp: resource.disableIcmp ?? false, authDaemonMode: resource.authDaemonMode ?? "site", authDaemonPort: resource.authDaemonPort ?? null, roles: [], users: [], clients: [] }); setSelectedSites(buildSelectedSitesForResource(resource)); setTcpPortMode( getPortModeFromString(resource.tcpPortRangeString) ); setUdpPortMode( getPortModeFromString(resource.udpPortRangeString) ); setTcpCustomPorts( resource.tcpPortRangeString && resource.tcpPortRangeString !== "*" ? resource.tcpPortRangeString : "" ); setUdpCustomPorts( resource.udpPortRangeString && resource.udpPortRangeString !== "*" ? resource.udpPortRangeString : "" ); previousResourceId.current = resource.id; } } }, [variant, resource, form]); // When edit dialog closes, clear previousResourceId so next open (for any resource) resets from fresh data useEffect(() => { if (variant === "edit" && open === false) { previousResourceId.current = null; } }, [variant, open]); // Populate roles/users/clients when edit data is loaded useEffect(() => { if ( variant === "edit" && siteResourceId != null && !loadingRolesUsers && !hasInitialized.current ) { hasInitialized.current = true; form.setValue("roles", formRoles); form.setValue("users", formUsers); form.setValue("clients", existingClients); } }, [ variant, siteResourceId, loadingRolesUsers, formRoles, formUsers, existingClients, form ]); useEffect(() => { onSubmitDisabledChange?.(isHttpMode && httpSectionDisabled); }, [isHttpMode, httpSectionDisabled, onSubmitDisabledChange]); return (
{ const siteIds = values.siteIds; onSubmit({ ...values, siteIds, clients: (values.clients ?? []).map((c) => ({ id: c.clientId.toString(), text: c.name })) }); })} className="space-y-6" id={formId} >
( {t(nameLabelKey)} )} /> {variant === "edit" && ( ( {t("identifier")} )} /> )}
{t( "editInternalResourceDialogDestinationDescription" )}
( {t("sites")} { setSelectedSites( sites ); field.onChange( sites.map( ( s ) => s.siteId ) ); }} /> )} />
{ const modeOptions: OptionSelectOption[] = [ { value: "host", label: t( modeHostKey ) }, { value: "cidr", label: t( modeCidrKey ) }, { value: "http", label: t( modeHttpKey ) } ]; return ( {t(modeLabelKey)} options={ modeOptions } value={field.value} onChange={ field.onChange } cols={3} /> ); }} />
{selectedSites.length > 1 && (

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

)}
{mode === "http" && (
( {t(schemeLabelKey)} )} />
)}
( {t(destinationLabelKey)} )} />
{mode === "host" && (
( {t(aliasLabelKey)} )} />
)} {mode === "http" && (
( {t( httpHttpsPortLabelKey )} { const raw = e.target .value; if ( raw === "" ) { field.onChange( null ); return; } const n = Number(raw); field.onChange( Number.isFinite( n ) ? n : null ); }} /> )} />
)}
{isHttpMode && ( )} {isHttpMode ? (
{t(httpConfigurationDescriptionKey)}
{ if (res === null) { form.setValue( "httpConfigSubdomain", null ); form.setValue( "httpConfigDomainId", null ); form.setValue( "httpConfigFullDomain", null ); return; } form.setValue( "httpConfigSubdomain", res.subdomain ?? null ); form.setValue( "httpConfigDomainId", res.domainId ); form.setValue( "httpConfigFullDomain", res.fullDomain ); }} />
( )} /> {variant === "edit" && resource?.domainId && httpConfigFullDomain && httpConfigDomainId === resource.domainId && httpConfigFullDomain === resource.fullDomain && build != "oss" && form.watch("ssl") && (
{t("certificateStatus")}:
)}
) : (
{t( "editInternalResourceDialogPortRestrictionsDescription" )}
{t("editInternalResourceDialogTcp")}
(
{tcpPortMode === "custom" ? ( setTcpCustomPorts( e .target .value ) } /> ) : ( )}
)} />
{t("editInternalResourceDialogUdp")}
(
{udpPortMode === "custom" ? ( setUdpCustomPorts( e .target .value ) } /> ) : ( )}
)} />
{t( "editInternalResourceDialogIcmp" )}
(
field.onChange( !checked ) } /> {field.value ? t("blocked") : t("allowed")}
)} />
)}
{t( "editInternalResourceDialogAccessControlDescription" )}
{loadingRolesUsers ? (
{t("loading")}
) : (
( {t("roles")} form.setValue( "roles", newRoles as [ Tag, ...Tag[] ] ) } enableAutocomplete autocompleteOptions={ allRoles } allowDuplicates={false} restrictTagsToAutocompleteOptions={ true } sortTags={true} /> )} /> ( {t("users")} form.setValue( "users", newUsers as [ Tag, ...Tag[] ] ) } enableAutocomplete={true} autocompleteOptions={ allUsers } allowDuplicates={false} restrictTagsToAutocompleteOptions={ true } sortTags={true} /> )} /> {hasMachineClients && ( ( {t("machineClients")} { form.setValue( "clients", machines ); }} /> )} /> )}
)}
{/* SSH Access tab (host mode only) */} {!disableEnterpriseFeatures && mode === "host" && (
{t.rich( "internalResourceAuthDaemonDescription", { docsLink: (chunks) => ( {chunks} ) } )}
( {t( "internalResourceAuthDaemonStrategyLabel" )} value={ field.value ?? undefined } options={[ { id: "site", title: t( "internalResourceAuthDaemonSite" ), description: t( "internalResourceAuthDaemonSiteDescription" ), disabled: sshSectionDisabled }, { id: "remote", title: t( "internalResourceAuthDaemonRemote" ), description: t( "internalResourceAuthDaemonRemoteDescription" ), disabled: sshSectionDisabled } ]} onChange={(v) => { if (sshSectionDisabled) return; field.onChange(v); if (v === "site") { form.setValue( "authDaemonPort", null ); } }} cols={2} /> )} /> {authDaemonMode === "remote" && ( ( {t( "internalResourceAuthDaemonPort" )} { if ( sshSectionDisabled ) return; const v = e.target.value; if (v === "") { field.onChange( null ); return; } const num = parseInt(v, 10); field.onChange( Number.isNaN( num ) ? null : num ); }} /> )} /> )}
)}
); }