"use client"; import { useEffect, useRef, useState } from "react"; import { Button } from "@app/components/ui/button"; import { Input } from "@app/components/ui/input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@app/components/ui/select"; import { Switch } from "@app/components/ui/switch"; import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@app/components/ui/form"; import { Credenza, CredenzaBody, CredenzaClose, CredenzaContent, CredenzaDescription, CredenzaFooter, CredenzaHeader, CredenzaTitle } from "@app/components/Credenza"; import { toast } from "@app/hooks/useToast"; import { useTranslations } from "next-intl"; import { createApiClient, formatAxiosError } from "@app/lib/api"; import { getUserDisplayName } from "@app/lib/getUserDisplayName"; import { useEnvContext } from "@app/hooks/useEnvContext"; import { Tag, TagInput } from "@app/components/tags/tag-input"; import { UserType } from "@server/types/UserTypes"; import { useQueries, useQuery, useQueryClient } from "@tanstack/react-query"; import { orgQueries, resourceQueries } from "@app/lib/queries"; import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@app/components/ui/command"; import { Popover, PopoverContent, PopoverTrigger } from "@app/components/ui/popover"; import { cn } from "@app/lib/cn"; import { ListSitesResponse } from "@server/routers/site"; import { Check, ChevronsUpDown, ChevronDown } from "lucide-react"; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@app/components/ui/collapsible"; import { HorizontalTabs, TabItem } from "@app/components/HorizontalTabs"; import { Separator } from "@app/components/ui/separator"; // import { InfoPopup } from "@app/components/ui/info-popup"; // Helper to validate port range string format 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)) { return false; } if (port < 1 || port > 65535) { return false; } } } return true; }; // Port range string schema for client-side validation // Note: This schema is defined outside the component, so we'll use a function to get the message 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) }); // Helper to determine the port mode from a port range string type PortMode = "all" | "blocked" | "custom"; const getPortModeFromString = (val: string | undefined | null): PortMode => { if (val === "*") return "all"; if (!val || val.trim() === "") return "blocked"; return "custom"; }; // Helper to get the port string for API from mode and custom value const getPortStringFromMode = ( mode: PortMode, customValue: string ): string | undefined => { if (mode === "all") return "*"; if (mode === "blocked") return ""; return customValue; }; type Site = ListSitesResponse["sites"][0]; type InternalResourceData = { id: number; name: string; orgId: string; siteName: string; // mode: "host" | "cidr" | "port"; mode: "host" | "cidr"; // protocol: string | null; // proxyPort: number | null; siteId: number; destination: string; // destinationPort?: number | null; alias?: string | null; tcpPortRangeString?: string | null; udpPortRangeString?: string | null; disableIcmp?: boolean; }; type EditInternalResourceDialogProps = { open: boolean; setOpen: (val: boolean) => void; resource: InternalResourceData; orgId: string; sites: Site[]; onSuccess?: () => void; }; export default function EditInternalResourceDialog({ open, setOpen, resource, orgId, sites, onSuccess }: EditInternalResourceDialogProps) { const t = useTranslations(); const api = createApiClient(useEnvContext()); const queryClient = useQueryClient(); const [isSubmitting, setIsSubmitting] = useState(false); const formSchema = z.object({ name: z .string() .min(1, t("editInternalResourceDialogNameRequired")) .max(255, t("editInternalResourceDialogNameMaxLength")), siteId: z.number().int().positive(), mode: z.enum(["host", "cidr", "port"]), // protocol: z.enum(["tcp", "udp"]).nullish(), // proxyPort: z.int().positive().min(1, t("editInternalResourceDialogProxyPortMin")).max(65535, t("editInternalResourceDialogProxyPortMax")).nullish(), destination: z.string().min(1), // destinationPort: z.int().positive().min(1, t("editInternalResourceDialogDestinationPortMin")).max(65535, t("editInternalResourceDialogDestinationPortMax")).nullish(), alias: z.string().nullish(), tcpPortRangeString: createPortRangeStringSchema(t), udpPortRangeString: createPortRangeStringSchema(t), disableIcmp: z.boolean().optional(), roles: z .array( z.object({ id: z.string(), text: z.string() }) ) .optional(), users: z .array( z.object({ id: z.string(), text: z.string() }) ) .optional(), clients: z .array( z.object({ id: z.string(), text: z.string() }) ) .optional() }); // .refine( // (data) => { // if (data.mode === "port") { // return data.protocol !== undefined && data.protocol !== null; // } // return true; // }, // { // message: t("editInternalResourceDialogProtocol") + " is required for port mode", // path: ["protocol"] // } // ) // .refine( // (data) => { // if (data.mode === "port") { // return data.proxyPort !== undefined && data.proxyPort !== null; // } // return true; // }, // { // message: t("editInternalResourceDialogSitePort") + " is required for port mode", // path: ["proxyPort"] // } // ) // .refine( // (data) => { // if (data.mode === "port") { // return data.destinationPort !== undefined && data.destinationPort !== null; // } // return true; // }, // { // message: t("targetPort") + " is required for port mode", // path: ["destinationPort"] // } // ); type FormData = z.infer; const queries = useQueries({ queries: [ orgQueries.roles({ orgId }), orgQueries.users({ orgId }), orgQueries.clients({ orgId, filters: { filter: "machine" } }), resourceQueries.siteResourceUsers({ siteResourceId: resource.id }), resourceQueries.siteResourceRoles({ siteResourceId: resource.id }), resourceQueries.siteResourceClients({ siteResourceId: resource.id }) ], combine: (results) => { const [ rolesQuery, usersQuery, clientsQuery, resourceUsersQuery, resourceRolesQuery, resourceClientsQuery ] = results; const allRoles = (rolesQuery.data ?? []) .map((role) => ({ id: role.roleId.toString(), text: role.name })) .filter((role) => role.text !== "Admin"); const allUsers = (usersQuery.data ?? []).map((user) => ({ id: user.id.toString(), text: `${getUserDisplayName({ email: user.email, username: user.username })}${user.type !== UserType.Internal ? ` (${user.idpName})` : ""}` })); const machineClients = (clientsQuery.data ?? []) .filter((client) => !client.userId) .map((client) => ({ id: client.clientId.toString(), text: client.name })); const existingClients = (resourceClientsQuery.data ?? []).map( (c: { clientId: number; name: string }) => ({ id: c.clientId.toString(), text: c.name }) ); const formRoles = (resourceRolesQuery.data ?? []) .map((i) => ({ id: i.roleId.toString(), text: i.name })) .filter((role) => role.text !== "Admin"); const formUsers = (resourceUsersQuery.data ?? []).map((i) => ({ id: i.userId.toString(), text: `${getUserDisplayName({ email: i.email, username: i.username })}${i.type !== UserType.Internal ? ` (${i.idpName})` : ""}` })); return { allRoles, allUsers, machineClients, existingClients, formRoles, formUsers, hasMachineClients: machineClients.length > 0 || existingClients.length > 0, isLoading: results.some((query) => query.isLoading) }; } }); const { allRoles, allUsers, machineClients, existingClients, formRoles, formUsers, hasMachineClients, isLoading: loadingRolesUsers } = queries; const [activeRolesTagIndex, setActiveRolesTagIndex] = useState< number | null >(null); const [activeUsersTagIndex, setActiveUsersTagIndex] = useState< number | null >(null); const [activeClientsTagIndex, setActiveClientsTagIndex] = useState< number | null >(null); // Collapsible state for ports and restrictions const [isPortsExpanded, setIsPortsExpanded] = useState(false); // Port restriction UI state const [tcpPortMode, setTcpPortMode] = useState( getPortModeFromString(resource.tcpPortRangeString) ); const [udpPortMode, setUdpPortMode] = useState( getPortModeFromString(resource.udpPortRangeString) ); const [tcpCustomPorts, setTcpCustomPorts] = useState( resource.tcpPortRangeString && resource.tcpPortRangeString !== "*" ? resource.tcpPortRangeString : "" ); const [udpCustomPorts, setUdpCustomPorts] = useState( resource.udpPortRangeString && resource.udpPortRangeString !== "*" ? resource.udpPortRangeString : "" ); const availableSites = sites.filter( (site) => site.type === "newt" && site.subnet ); const form = useForm({ resolver: zodResolver(formSchema), defaultValues: { name: resource.name, siteId: resource.siteId, mode: resource.mode || "host", // protocol: (resource.protocol as "tcp" | "udp" | null | undefined) ?? undefined, // proxyPort: resource.proxyPort ?? undefined, destination: resource.destination || "", // destinationPort: resource.destinationPort ?? undefined, alias: resource.alias ?? null, tcpPortRangeString: resource.tcpPortRangeString ?? "*", udpPortRangeString: resource.udpPortRangeString ?? "*", disableIcmp: resource.disableIcmp ?? false, roles: [], users: [], clients: [] } }); const mode = form.watch("mode"); // Update form values when port mode or custom ports change 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]); // Helper function to check if destination contains letters (hostname vs IP) const isHostname = (destination: string): boolean => { return /[a-zA-Z]/.test(destination); }; // Helper function to clean resource name for FQDN format const cleanForFQDN = (name: string): string => { return name .toLowerCase() .replace(/[^a-z0-9.-]/g, "-") // Replace invalid chars with hyphens .replace(/[-]+/g, "-") // Replace multiple hyphens with single hyphen .replace(/^-|-$/g, "") // Remove leading/trailing hyphens .replace(/^\.|\.$/g, ""); // Remove leading/trailing dots }; const handleSubmit = async (data: FormData) => { setIsSubmitting(true); try { // Validate: if mode is "host" and destination is a hostname (contains letters), // an alias is required if (data.mode === "host" && isHostname(data.destination)) { const currentAlias = data.alias?.trim() || ""; if (!currentAlias) { // Prefill alias based on destination let aliasValue = data.destination; if (data.destination.toLowerCase() === "localhost") { // Use resource name cleaned for FQDN with .internal suffix const cleanedName = cleanForFQDN(data.name); aliasValue = `${cleanedName}.internal`; } // Update the form with the prefilled alias form.setValue("alias", aliasValue); data.alias = aliasValue; } } // Update the site resource await api.post(`/site-resource/${resource.id}`, { name: data.name, siteId: data.siteId, mode: data.mode, // protocol: data.mode === "port" ? data.protocol : null, // proxyPort: data.mode === "port" ? data.proxyPort : null, // destinationPort: data.mode === "port" ? data.destinationPort : null, destination: data.destination, alias: data.alias && typeof data.alias === "string" && data.alias.trim() ? data.alias : null, tcpPortRangeString: data.tcpPortRangeString, udpPortRangeString: data.udpPortRangeString, disableIcmp: data.disableIcmp ?? false, roleIds: (data.roles || []).map((r) => parseInt(r.id)), userIds: (data.users || []).map((u) => u.id), clientIds: (data.clients || []).map((c) => parseInt(c.id)) }); // Update roles, users, and clients // await Promise.all([ // api.post(`/site-resource/${resource.id}/roles`, { // roleIds: (data.roles || []).map((r) => parseInt(r.id)) // }), // api.post(`/site-resource/${resource.id}/users`, { // userIds: (data.users || []).map((u) => u.id) // }), // api.post(`/site-resource/${resource.id}/clients`, { // clientIds: (data.clients || []).map((c) => parseInt(c.id)) // }) // ]); await queryClient.invalidateQueries( resourceQueries.siteResourceRoles({ siteResourceId: resource.id }) ); await queryClient.invalidateQueries( resourceQueries.siteResourceUsers({ siteResourceId: resource.id }) ); await queryClient.invalidateQueries( resourceQueries.siteResourceClients({ siteResourceId: resource.id }) ); toast({ title: t("editInternalResourceDialogSuccess"), description: t( "editInternalResourceDialogInternalResourceUpdatedSuccessfully" ), variant: "default" }); setOpen(false); onSuccess?.(); } catch (error) { console.error("Error updating internal resource:", error); toast({ title: t("editInternalResourceDialogError"), description: formatAxiosError( error, t( "editInternalResourceDialogFailedToUpdateInternalResource" ) ), variant: "destructive" }); } finally { setIsSubmitting(false); } }; const hasInitialized = useRef(false); const previousResourceId = useRef(null); useEffect(() => { if (open) { const resourceChanged = previousResourceId.current !== resource.id; if (resourceChanged) { form.reset({ name: resource.name, siteId: resource.siteId, mode: resource.mode || "host", destination: resource.destination || "", alias: resource.alias ?? null, tcpPortRangeString: resource.tcpPortRangeString ?? "*", udpPortRangeString: resource.udpPortRangeString ?? "*", disableIcmp: resource.disableIcmp ?? false, roles: [], users: [], clients: [] }); // Reset port mode state setTcpPortMode( getPortModeFromString(resource.tcpPortRangeString) ); setUdpPortMode( getPortModeFromString(resource.udpPortRangeString) ); setTcpCustomPorts( resource.tcpPortRangeString && resource.tcpPortRangeString !== "*" ? resource.tcpPortRangeString : "" ); setUdpCustomPorts( resource.udpPortRangeString && resource.udpPortRangeString !== "*" ? resource.udpPortRangeString : "" ); // Reset visibility states setIsPortsExpanded(false); previousResourceId.current = resource.id; } hasInitialized.current = false; } }, [ open, resource.id, resource.name, resource.mode, resource.destination, resource.alias, form ]); useEffect(() => { if (open && !loadingRolesUsers && !hasInitialized.current) { hasInitialized.current = true; form.setValue("roles", formRoles); form.setValue("users", formUsers); form.setValue("clients", existingClients); } }, [open, loadingRolesUsers, formRoles, formUsers, existingClients, form]); return ( { if (!open) { // reset only on close form.reset({ name: resource.name, siteId: resource.siteId, mode: resource.mode || "host", // protocol: (resource.protocol as "tcp" | "udp" | null | undefined) ?? undefined, // proxyPort: resource.proxyPort ?? undefined, destination: resource.destination || "", // destinationPort: resource.destinationPort ?? undefined, alias: resource.alias ?? null, tcpPortRangeString: resource.tcpPortRangeString ?? "*", udpPortRangeString: resource.udpPortRangeString ?? "*", disableIcmp: resource.disableIcmp ?? false, roles: [], users: [], clients: [] }); // Reset port mode state setTcpPortMode( getPortModeFromString(resource.tcpPortRangeString) ); setUdpPortMode( getPortModeFromString(resource.udpPortRangeString) ); setTcpCustomPorts( resource.tcpPortRangeString && resource.tcpPortRangeString !== "*" ? resource.tcpPortRangeString : "" ); setUdpCustomPorts( resource.udpPortRangeString && resource.udpPortRangeString !== "*" ? resource.udpPortRangeString : "" ); // Reset visibility states setIsPortsExpanded(false); // Reset previous resource ID to ensure clean state on next open previousResourceId.current = null; } setOpen(open); }} > {t("editInternalResourceDialogEditClientResource")} {t( "editInternalResourceDialogUpdateResourceProperties", { resourceName: resource.name } )}
{/* Name and Site - Side by Side */}
( {t( "editInternalResourceDialogName" )} )} /> ( {t("site")} {t( "noSitesFound" )} {availableSites.map( (site) => ( { field.onChange( site.siteId ); }} > { site.name } ) )} )} />
{/* Tabs for Network Settings and Access Control */} {/* Network Settings Tab */}
{t( "editInternalResourceDialogDestinationDescription" )}
{/* Mode - Smaller select */}
( {t( "editInternalResourceDialogMode" )} )} />
{/* Destination - Larger input */}
( {t( "editInternalResourceDialogDestination" )} )} />
{/* Alias - Equally sized input (if allowed) */} {mode !== "cidr" && (
( {t( "editInternalResourceDialogAlias" )} )} />
)}
{/* Ports and Restrictions */}
{/* TCP Ports */}
{t( "editInternalResourceDialogPortRestrictionsDescription" )}
{t( "editInternalResourceDialogTcp" )}
(
{/**/} {tcpPortMode === "custom" ? ( setTcpCustomPorts( e .target .value ) } /> ) : ( )}
)} />
{/* UDP Ports */}
{t( "editInternalResourceDialogUdp" )}
(
{/**/} {udpPortMode === "custom" ? ( setUdpCustomPorts( e .target .value ) } /> ) : ( )}
)} />
{/* ICMP Toggle */}
{t( "editInternalResourceDialogIcmp" )}
(
field.onChange( !checked ) } /> {field.value ? t( "blocked" ) : t( "allowed" )}
)} />
{/* Access Control Tab */}
{t( "editInternalResourceDialogAccessControlDescription" )}
{loadingRolesUsers ? (
{t("loading")}
) : (
{/* Roles */} ( {t("roles")} { form.setValue( "roles", newRoles as [ Tag, ...Tag[] ] ); }} enableAutocomplete={ true } autocompleteOptions={ allRoles } allowDuplicates={ false } restrictTagsToAutocompleteOptions={ true } sortTags={true} /> )} /> {/* Users */} ( {t("users")} { form.setValue( "users", newUsers as [ Tag, ...Tag[] ] ); }} enableAutocomplete={ true } autocompleteOptions={ allUsers } allowDuplicates={ false } restrictTagsToAutocompleteOptions={ true } sortTags={true} /> )} /> {/* Clients (Machines) */} {hasMachineClients && ( ( {t( "machineClients" )} { form.setValue( "clients", newClients as [ Tag, ...Tag[] ] ); }} enableAutocomplete={ true } autocompleteOptions={ machineClients } allowDuplicates={ false } restrictTagsToAutocompleteOptions={ true } sortTags={ true } /> )} /> )}
)}
); }