"use client"; import { Tag, TagInput } from "@app/components/tags/tag-input"; import { Button } from "@app/components/ui/button"; import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@app/components/ui/command"; 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 { getUserDisplayName } from "@app/lib/getUserDisplayName"; import { cn } from "@app/lib/cn"; import { orgQueries, resourceQueries } from "@app/lib/queries"; import { useQueries, useQuery } from "@tanstack/react-query"; import { ListSitesResponse } from "@server/routers/site"; import { UserType } from "@server/types/UserTypes"; import { Check, 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 { zodResolver } from "@hookform/resolvers/zod"; import { useEnvContext } from "@app/hooks/useEnvContext"; import { usePaidStatus } from "@app/hooks/usePaidStatus"; import { tierMatrix } from "@server/lib/billing/tierMatrix"; import { HorizontalTabs } from "@app/components/HorizontalTabs"; import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert"; import { StrategySelect } from "@app/components/StrategySelect"; // --- 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 --- type Site = ListSitesResponse["sites"][0]; export type InternalResourceData = { id: number; name: string; orgId: string; siteName: string; mode: "host" | "cidr"; siteId: number; destination: string; alias?: string | null; tcpPortRangeString?: string | null; udpPortRangeString?: string | null; disableIcmp?: boolean; authDaemonMode?: "site" | "remote" | null; authDaemonPort?: number | null; }; const tagSchema = z.object({ id: z.string(), text: z.string() }); export type InternalResourceFormValues = { name: string; siteId: number; mode: "host" | "cidr"; destination: string; alias?: string | null; tcpPortRangeString?: string | null; udpPortRangeString?: string | null; disableIcmp?: boolean; authDaemonMode?: "site" | "remote" | null; authDaemonPort?: number | null; roles?: z.infer[]; users?: z.infer[]; clients?: z.infer[]; }; type InternalResourceFormProps = { variant: "create" | "edit"; resource?: InternalResourceData; open?: boolean; sites: Site[]; orgId: string; siteResourceId?: number; formId: string; onSubmit: (values: InternalResourceFormValues) => void | Promise; }; export function InternalResourceForm({ variant, resource, open, sites, orgId, siteResourceId, formId, onSubmit }: InternalResourceFormProps) { const t = useTranslations(); const { env } = useEnvContext(); const { isPaidUser } = usePaidStatus(); const disableEnterpriseFeatures = env.flags.disableEnterpriseFeatures; const sshSectionDisabled = !isPaidUser(tierMatrix.sshPam); 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 destinationLabelKey = variant === "create" ? "createInternalResourceDialogDestination" : "editInternalResourceDialogDestination"; const destinationRequiredKey = variant === "create" ? "createInternalResourceDialogDestinationRequired" : undefined; const aliasLabelKey = variant === "create" ? "createInternalResourceDialogAlias" : "editInternalResourceDialogAlias"; const formSchema = z.object({ name: z.string().min(1, t(nameRequiredKey)).max(255, t(nameMaxKey)), siteId: z .number() .int() .positive(siteRequiredKey ? t(siteRequiredKey) : undefined), mode: z.enum(["host", "cidr"]), destination: z .string() .min( 1, destinationRequiredKey ? { message: t(destinationRequiredKey) } : undefined ), alias: z.string().nullish(), 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(tagSchema).optional() }); type FormData = z.infer; const availableSites = sites.filter((s) => s.type === "newt"); const rolesQuery = useQuery(orgQueries.roles({ orgId })); const usersQuery = useQuery(orgQueries.users({ orgId })); const clientsQuery = useQuery(orgQueries.clients({ 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 }[] ).map((c) => ({ id: c.clientId.toString(), text: c.name })); } } 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, siteId: resource.siteId, 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, roles: [], users: [], clients: [] } : { name: "", siteId: availableSites[0]?.siteId ?? 0, mode: "host", destination: "", alias: null, tcpPortRangeString: "*", udpPortRangeString: "*", disableIcmp: false, authDaemonMode: "site", authDaemonPort: null, roles: [], users: [], clients: [] }; const form = useForm({ resolver: zodResolver(formSchema), defaultValues }); const mode = form.watch("mode"); 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: "", siteId: availableSites[0]?.siteId ?? 0, mode: "host", destination: "", alias: null, tcpPortRangeString: "*", udpPortRangeString: "*", disableIcmp: false, authDaemonMode: "site", authDaemonPort: null, roles: [], users: [], clients: [] }); setTcpPortMode("all"); setUdpPortMode("all"); setTcpCustomPorts(""); setUdpCustomPorts(""); } }, [variant, open]); // 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, siteId: resource.siteId, 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, roles: [], users: [], clients: [] }); 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 ]); return (
onSubmit(values as InternalResourceFormValues) )} className="space-y-6" id={formId} >
( {t(nameLabelKey)} )} /> ( {t("site")} {t("noSitesFound")} {availableSites.map( (site) => ( field.onChange( site.siteId ) } > {site.name} ) )} )} />
{t( "editInternalResourceDialogDestinationDescription" )}
( {t(modeLabelKey)} )} />
( {t(destinationLabelKey)} )} />
{mode !== "cidr" && (
( {t(aliasLabelKey)} )} />
)}
{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={true} 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", newClients as [ Tag, ...Tag[] ] ) } enableAutocomplete={ true } autocompleteOptions={ allClients } allowDuplicates={false} restrictTagsToAutocompleteOptions={ true } sortTags={true} /> )} /> )}
)}
{/* SSH Access tab */} {!disableEnterpriseFeatures && (
{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 ); }} /> )} /> )}
)}
); }