+
- {canUpdate && }
+ {canUpdate && !hideEdit && }
) : (
diff --git a/src/modules/groups/details/GroupResourcesSection.tsx b/src/modules/groups/details/GroupResourcesSection.tsx
index 2c37265a..74b1456f 100644
--- a/src/modules/groups/details/GroupResourcesSection.tsx
+++ b/src/modules/groups/details/GroupResourcesSection.tsx
@@ -10,6 +10,7 @@ import { ArrowUpRightIcon, Layers3Icon } from "lucide-react";
import { useRouter } from "next/navigation";
import React, { useState } from "react";
import { useSWRConfig } from "swr";
+import { NetworkAccessControlProvider } from "@/modules/networks/NetworkAccessControlProvider";
import { usePermissions } from "@/contexts/PermissionsProvider";
import { Group } from "@/interfaces/Group";
import { NetworkResourceWithNetwork } from "@/interfaces/Network";
@@ -115,67 +116,70 @@ export const GroupResourcesSection = ({
const { mutate } = useSWRConfig();
return (
-
- (
- mutate("/networks/resources")}
- onResourceDelete={() => mutate("/networks/resources")}
- >
- {children}
-
- )}
- inset={false}
- tableClassName={"mt-0"}
- text={"Resources"}
- columns={GroupResourcesColumns}
- keepStateInLocalStorage={false}
- data={resources}
- searchPlaceholder={"Search by name, address or group..."}
- getStartedCard={
- }
- >
- {permission?.networks?.create && (
- <>
- router.push("/networks")}
- >
- Go to Networks
-
-
- >
- )}
-
- }
- columnVisibility={{
- description: false,
- id: false,
- }}
- paginationPaddingClassName={"px-0 pt-8"}
- >
- {(table) => (
-
- )}
-
-
+
+
+ (
+ mutate("/networks/resources")}
+ onResourceDelete={() => mutate("/networks/resources")}
+ >
+ {children}
+
+ )}
+ inset={false}
+ tableClassName={"mt-0"}
+ text={"Resources"}
+ columns={GroupResourcesColumns}
+ keepStateInLocalStorage={false}
+ data={resources}
+ searchPlaceholder={"Search by name, address or group..."}
+ getStartedCard={
+ }
+ >
+ {permission?.networks?.create && (
+ <>
+ router.push("/networks")}
+ >
+ Go to Networks
+
+
+ >
+ )}
+
+ }
+ columnVisibility={{
+ description: false,
+ id: false,
+ }}
+ paginationPaddingClassName={"px-0 pt-8"}
+ >
+ {(table) => (
+
+ )}
+
+
+
);
};
diff --git a/src/modules/networks/NetworkAccessControlProvider.tsx b/src/modules/networks/NetworkAccessControlProvider.tsx
new file mode 100644
index 00000000..a2cf11f8
--- /dev/null
+++ b/src/modules/networks/NetworkAccessControlProvider.tsx
@@ -0,0 +1,151 @@
+import useFetchApi from "@utils/api";
+import { orderBy } from "lodash";
+import * as React from "react";
+import { useCallback, useContext } from "react";
+import { Group } from "@/interfaces/Group";
+import { NetworkResource } from "@/interfaces/Network";
+import { Policy } from "@/interfaces/Policy";
+
+type NetworkAccessControlContextValue = {
+ policies?: Policy[];
+ policiesLoading: boolean;
+ resources?: NetworkResource[];
+ assignedPolicies: (
+ resource?: NetworkResource,
+ groups?: Group[],
+ ) => {
+ policies: Policy[];
+ enabledPolicies: Policy[];
+ isLoading: boolean;
+ policyCount: number;
+ };
+ resourceExists: (name: string, excludeId?: string) => boolean;
+ getPolicyDestinationResources: (policy: Policy) => NetworkResource[];
+};
+
+const NetworkAccessControlContext =
+ React.createContext
(null);
+
+type Props = {
+ children: React.ReactNode;
+};
+
+const toGroupId = (g: Group | string): string | undefined =>
+ typeof g === "string" ? g : g?.id;
+
+export const NetworkAccessControlProvider = ({ children }: Props) => {
+ const { data: policies, isLoading: policiesLoading } =
+ useFetchApi("/policies");
+ const { data: resources } = useFetchApi(
+ "/networks/resources",
+ );
+
+ const resourceExists = useCallback(
+ (name: string, excludeId?: string) => {
+ if (!name) return false;
+ return !!resources?.find(
+ (r) =>
+ r.name.toLowerCase() === name.toLowerCase() && r.id !== excludeId,
+ );
+ },
+ [resources],
+ );
+
+ const assignedPolicies = useCallback(
+ (resource?: NetworkResource, groups?: Group[]) => {
+ const resourceGroups = (groups || resource?.groups) as
+ | (Group | string)[]
+ | undefined;
+ if (!resource && !resourceGroups?.length) {
+ return {
+ policies: [],
+ enabledPolicies: [],
+ isLoading: policiesLoading,
+ policyCount: 0,
+ };
+ }
+ const resourceGroupIds = new Set(
+ resourceGroups?.map(toGroupId).filter(Boolean),
+ );
+ const resourcePolicies = orderBy(
+ policies?.filter((policy) => {
+ const rule = policy.rules?.[0];
+ if (!rule) return false;
+ if (resource && rule.destinationResource?.id === resource.id)
+ return true;
+ const destinations = (rule.destinations ?? []) as (Group | string)[];
+ return destinations.some((d) => {
+ const destId = toGroupId(d);
+ return !!destId && resourceGroupIds.has(destId);
+ });
+ }),
+ "enabled",
+ "desc",
+ );
+ const enabledPolicies = resourcePolicies?.filter(
+ (policy) => policy?.enabled,
+ );
+ return {
+ policies: resourcePolicies,
+ enabledPolicies,
+ isLoading: policiesLoading,
+ policyCount: resourcePolicies?.length || 0,
+ };
+ },
+ [policies, policiesLoading],
+ );
+
+ const getPolicyDestinationResources = useCallback(
+ (policy: Policy): NetworkResource[] => {
+ const rule = policy?.rules?.[0];
+ const destinationGroups = rule?.destinations as
+ | (Group | string)[]
+ | undefined;
+ const destinationGroupIds = new Set(
+ destinationGroups?.map(toGroupId).filter(Boolean),
+ );
+ const directDestinationId = rule?.destinationResource?.id;
+
+ return (
+ resources?.filter((resource) => {
+ if (directDestinationId && resource.id === directDestinationId)
+ return true;
+ const resourceGroups = resource.groups as
+ | (Group | string)[]
+ | undefined;
+ return resourceGroups?.some((g) => {
+ const groupId = toGroupId(g);
+ return !!groupId && destinationGroupIds.has(groupId);
+ });
+ }) ?? []
+ );
+ },
+ [resources],
+ );
+
+ return (
+
+ {children}
+
+ );
+};
+
+export const useNetworkAccessControl =
+ (): NetworkAccessControlContextValue => {
+ const context = useContext(NetworkAccessControlContext);
+ if (!context) {
+ throw new Error(
+ "useNetworkAccessControl must be used within a NetworkAccessControlProvider",
+ );
+ }
+ return context;
+ };
diff --git a/src/modules/networks/NetworkProvider.tsx b/src/modules/networks/NetworkProvider.tsx
index a7d82555..7250bc9c 100644
--- a/src/modules/networks/NetworkProvider.tsx
+++ b/src/modules/networks/NetworkProvider.tsx
@@ -5,6 +5,7 @@ import * as React from "react";
import { useState } from "react";
import { useSWRConfig } from "swr";
import { useDialog } from "@/contexts/DialogProvider";
+import { useNetworkAccessControl } from "@/modules/networks/NetworkAccessControlProvider";
import { Group } from "@/interfaces/Group";
import { Network, NetworkResource, NetworkRouter } from "@/interfaces/Network";
import { AccessControlModalContent } from "@/modules/access-control/AccessControlModal";
@@ -14,6 +15,9 @@ import { ResourceGroupModal } from "@/modules/networks/resources/ResourceGroupMo
import NetworkRoutingPeerModal from "@/modules/networks/routing-peers/NetworkRoutingPeerModal";
import { Policy, PolicyRuleResource } from "@/interfaces/Policy";
import PoliciesProvider from "@/contexts/PoliciesProvider";
+import { ResourceIcon } from "@/assets/icons/ResourceIcon";
+import CopyToClipboardText from "@components/CopyToClipboardText";
+import { cn } from "@utils/helpers";
type Props = {
children: React.ReactNode;
@@ -27,7 +31,11 @@ const NetworksContext = React.createContext(
openAddRoutingPeerModal: (network: Network, router?: NetworkRouter) => void;
openEditNetworkModal: (network: Network) => void;
openCreateNetworkModal: () => void;
- openResourceModal: (network: Network, resource?: NetworkResource) => void;
+ openResourceModal: (
+ network: Network,
+ resource?: NetworkResource,
+ initialTab?: string,
+ ) => void;
openResourceGroupModal: (
network: Network,
resource?: NetworkResource,
@@ -38,6 +46,24 @@ const NetworksContext = React.createContext(
deleteResource: (network: Network, resource: NetworkResource) => void;
deleteRouter: (network: Network, router: NetworkRouter) => void;
network?: Network;
+ assignedPolicies: (
+ resource?: NetworkResource,
+ groups?: Group[],
+ ) => {
+ policies: Policy[];
+ enabledPolicies: Policy[];
+ isLoading: boolean;
+ policyCount: number;
+ };
+ resourceExists: (name: string, excludeId?: string) => boolean;
+ resources?: NetworkResource[];
+ getPolicyDestinationResources: (policy: Policy) => NetworkResource[];
+ confirmMultiResourceAction: (
+ policy: Policy,
+ action: "edit" | "delete",
+ additionalResource?: NetworkResource,
+ ) => Promise;
+ policies?: Policy[];
},
);
@@ -50,6 +76,13 @@ export const NetworkProvider = ({
const { mutate } = useSWRConfig();
const { confirm } = useDialog();
const deleteCall = useApiCall("/networks").del;
+ const {
+ policies,
+ resources,
+ assignedPolicies,
+ resourceExists,
+ getPolicyDestinationResources,
+ } = useNetworkAccessControl();
const [currentNetwork, setCurrentNetwork] = useState();
const [currentResource, setCurrentResource] = useState();
@@ -88,9 +121,18 @@ export const NetworkProvider = ({
setNetworkModal(true);
};
- const openResourceModal = (network: Network, resource?: NetworkResource) => {
+ const [resourceModalInitialTab, setResourceModalInitialTab] = useState<
+ string | undefined
+ >();
+
+ const openResourceModal = (
+ network: Network,
+ resource?: NetworkResource,
+ initialTab?: string,
+ ) => {
setCurrentNetwork(network);
resource && setCurrentResource(resource);
+ setResourceModalInitialTab(initialTab);
setResourceModal(true);
};
@@ -110,11 +152,11 @@ export const NetworkProvider = ({
destinationResource: hasResourceGroups
? undefined
: resource
- ? ({
- id: resource.id,
- type: resource.type,
- } as PolicyRuleResource)
- : undefined,
+ ? ({
+ id: resource.id,
+ type: resource.type,
+ } as PolicyRuleResource)
+ : undefined,
name:
network && !resource
? `${network?.name} Policy`
@@ -138,6 +180,45 @@ export const NetworkProvider = ({
setPolicyModal(true);
};
+ const confirmMultiResourceAction = async (
+ policy: Policy,
+ action: "edit" | "delete",
+ additionalResource?: NetworkResource,
+ ) => {
+ const fetchedResources = getPolicyDestinationResources(policy);
+ const affectedResources =
+ additionalResource &&
+ !fetchedResources.some((r) => r.id === additionalResource.id)
+ ? [...fetchedResources, additionalResource]
+ : fetchedResources;
+ const isMulti = affectedResources.length > 1;
+ return confirm({
+ title: isMulti ? (
+ <>This policy is used by multiple resources>
+ ) : (
+ <>
+ {action === "edit" ? "Edit" : "Delete"} policy '{policy.name}
+ '?
+ >
+ ),
+ description: isMulti
+ ? `This policy uses one or many resource group(s) as destinations. ${
+ action === "edit" ? "Updating" : "Deleting"
+ } this policy will also affect following resources:`
+ : action === "delete"
+ ? "Are you sure you want to delete this policy? This action cannot be undone."
+ : undefined,
+ children: isMulti ? (
+
+ ) : undefined,
+ confirmText: action === "edit" ? "Edit Policy" : "Delete Policy",
+ cancelText: "Cancel",
+ hideIcon: isMulti,
+ type: action === "edit" ? "warning" : "danger",
+ maxWidthClass: isMulti ? "max-w-lg" : undefined,
+ });
+ };
+
const deleteNetwork = async (network: Network) => {
const choice = await confirm({
title: `Delete network '${network.name}'?`,
@@ -244,19 +325,6 @@ export const NetworkProvider = ({
openResourceModal(network);
};
- const askForAccessControlPolicy = async (res: NetworkResource) => {
- const choice = await confirm({
- title: `Add policy for '${res.name}'?`,
- description:
- "Without a policy, the resource will not be accessible by any peers. Create a policy to control access to this resource.",
- confirmText: "Create Policy",
- cancelText: "Later",
- type: "default",
- });
- if (!choice) return;
- openPolicyModal(currentNetwork, res);
- };
-
return (
- {children}
-
- {
- mutate("/networks");
- await askForResource(network);
- }}
- onUpdated={(n) => {
- mutate("/networks");
- mutate(`/networks/${n.id}`);
- }}
- />
+ {children}
+
+ {
+ mutate("/networks");
+ await askForResource(network);
+ }}
+ onUpdated={(n) => {
+ mutate("/networks");
+ mutate(`/networks/${n.id}`);
+ }}
+ />
{
@@ -321,93 +395,99 @@ export const NetworkProvider = ({
}}
/>
+ {currentNetwork && (
+ <>
+ {
+ setRoutingPeerModal(false);
+ setCurrentRouter(undefined);
+ mutate(`/networks`);
+ mutate("/groups");
+ if (network) {
+ mutate(`/networks/${currentNetwork.id}/routers`);
+ mutate(`/networks/${network.id}`);
+ }
+ }}
+ onUpdated={async () => {
+ setRoutingPeerModal(false);
+ setCurrentRouter(undefined);
+ mutate(`/networks`);
+ mutate("/groups");
+ if (network) {
+ mutate(`/networks/${network.id}`);
+ mutate(`/networks/${currentNetwork.id}/routers`);
+ }
+ }}
+ setOpen={(state) => {
+ setCurrentRouter(undefined);
+ setRoutingPeerModal(state);
+ }}
+ />
+
+ {
+ setCurrentResource(undefined);
+ setResourceGroupModal(state);
+ }}
+ onUpdated={() => {
+ setResourceGroupModal(false);
+ setCurrentResource(undefined);
+ mutate("/groups");
+ mutate("/networks/resources");
+ if (network) {
+ onResourceUpdate?.();
+ mutate(`/networks/${network.id}/resources`);
+ mutate(`/networks/${network.id}`);
+ }
+ }}
+ />
+
+ {
+ setResourceModal(false);
+ setCurrentResource(undefined);
+ mutate("/networks");
+ mutate("/groups");
+ mutate("/networks/resources");
+ if (network) {
+ mutate(`/networks/${network.id}/resources`);
+ mutate(`/networks/${network.id}`);
+ } else {
+ currentNetwork?.routing_peers_count === 0 &&
+ (await askForRoutingPeer(currentNetwork));
+ }
+ }}
+ onUpdated={() => {
+ setResourceModal(false);
+ setCurrentResource(undefined);
+ mutate("/networks");
+ mutate("/groups");
+ mutate("/networks/resources");
+ if (network) {
+ onResourceUpdate?.();
+ mutate(`/networks/${network.id}/resources`);
+ mutate(`/networks/${network.id}`);
+ }
+ }}
+ open={resourceModal}
+ setOpen={(state) => {
+ setCurrentResource(undefined);
+ setResourceModalInitialTab(undefined);
+ setResourceModal(state);
+ }}
+ />
+ >
+ )}
- {currentNetwork && (
- <>
- {
- setRoutingPeerModal(false);
- setCurrentRouter(undefined);
- mutate(`/networks`);
- mutate("/groups");
- if (network) {
- mutate(`/networks/${currentNetwork.id}/routers`);
- mutate(`/networks/${network.id}`);
- }
- }}
- onUpdated={async () => {
- setRoutingPeerModal(false);
- setCurrentRouter(undefined);
- mutate(`/networks`);
- mutate("/groups");
- if (network) {
- mutate(`/networks/${network.id}`);
- mutate(`/networks/${currentNetwork.id}/routers`);
- }
- }}
- setOpen={(state) => {
- setCurrentRouter(undefined);
- setRoutingPeerModal(state);
- }}
- />
-
- {
- setCurrentResource(undefined);
- setResourceGroupModal(state);
- }}
- onUpdated={() => {
- setResourceGroupModal(false);
- setCurrentResource(undefined);
- mutate("/groups");
- if (network) {
- onResourceUpdate?.();
- mutate(`/networks/${network.id}/resources`);
- mutate(`/networks/${network.id}`);
- }
- }}
- />
-
- {
- setResourceModal(false);
- setCurrentResource(undefined);
- mutate("/networks");
- mutate("/groups");
- if (network) {
- mutate(`/networks/${network.id}/resources`);
- mutate(`/networks/${network.id}`);
- } else {
- await askForAccessControlPolicy(r);
- }
- }}
- onUpdated={() => {
- setResourceModal(false);
- setCurrentResource(undefined);
- mutate("/networks");
- mutate("/groups");
- if (network) {
- onResourceUpdate?.();
- mutate(`/networks/${network.id}/resources`);
- mutate(`/networks/${network.id}`);
- }
- }}
- open={resourceModal}
- setOpen={(state) => {
- setCurrentResource(undefined);
- setResourceModal(state);
- }}
- />
- >
- )}
);
};
@@ -419,3 +499,37 @@ export const useNetworksContext = () => {
}
return context;
};
+
+function AffectedResourceList({ resources }: { resources: NetworkResource[] }) {
+ const maxVisible = 6;
+ const visible = resources.slice(0, maxVisible);
+ const remaining = resources.length - maxVisible;
+ return (
+
+ {visible.map((r, i) => (
+
0 && "border-t border-nb-gray-900",
+ )}
+ >
+
+ {r.name}
+
+ {r.address}
+
+
+ ))}
+ {remaining > 0 && (
+
+ + {remaining} more
+
+ )}
+
+ );
+}
diff --git a/src/modules/networks/resources/NetworkResourceAccessControl.tsx b/src/modules/networks/resources/NetworkResourceAccessControl.tsx
new file mode 100644
index 00000000..8d0f0d97
--- /dev/null
+++ b/src/modules/networks/resources/NetworkResourceAccessControl.tsx
@@ -0,0 +1,307 @@
+import Button from "@components/Button";
+import HelpText from "@components/HelpText";
+import { Label } from "@components/Label";
+import { Modal } from "@components/modal/Modal";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "@components/DropdownMenu";
+import { Edit2, MoreVertical, PlusIcon, Trash2 } from "lucide-react";
+import React, { useMemo, useState } from "react";
+import { NetworkResource } from "@/interfaces/Network";
+import { Policy, PolicyRuleResource } from "@/interfaces/Policy";
+import { cn } from "@utils/helpers";
+import { usePolicies } from "@/contexts/PoliciesProvider";
+import AccessControlSourcesCell from "@/modules/access-control/table/AccessControlSourcesCell";
+import { useNetworksContext } from "@/modules/networks/NetworkProvider";
+import { AccessControlModalContent } from "@/modules/access-control/AccessControlModal";
+import CircleIcon from "@/assets/icons/CircleIcon";
+import AccessControlProtocolCell from "@/modules/access-control/table/AccessControlProtocolCell";
+import AccessControlPortsCell from "@/modules/access-control/table/AccessControlPortsCell";
+import TruncatedText from "@components/ui/TruncatedText";
+
+type Props = {
+ existingPolicies: Policy[];
+ newPolicies: Policy[];
+ onNewPoliciesChange: (policies: Policy[]) => void;
+ address: string;
+ resourceName?: string;
+ resourceId?: string;
+ hasResourceGroups?: boolean;
+};
+
+function getResourceType(address: string): "domain" | "host" | "subnet" {
+ const hasChars = !!address.match(/[a-z*]/i);
+ const isCIDR = !!address.match(/\//);
+ return hasChars ? "domain" : isCIDR ? "subnet" : "host";
+}
+
+export default function NetworkResourceAccessControl({
+ existingPolicies,
+ newPolicies,
+ onNewPoliciesChange,
+ address,
+ resourceName,
+ resourceId,
+ hasResourceGroups = false,
+}: Readonly) {
+ const { network, confirmMultiResourceAction } = useNetworksContext();
+ const { openEditPolicyModal, deletePolicy } = usePolicies();
+ const [policyModalOpen, setPolicyModalOpen] = useState(false);
+ const [editingPolicyIndex, setEditingPolicyIndex] = useState(
+ null,
+ );
+
+ const allPolicies = useMemo(
+ () => [...existingPolicies, ...newPolicies],
+ [existingPolicies, newPolicies],
+ );
+
+ const destinationResource: PolicyRuleResource = useMemo(() => {
+ return {
+ id: resourceId || resourceName || address,
+ type: getResourceType(address),
+ };
+ }, [address, resourceName, resourceId]);
+
+ const currentResource = useMemo(() => {
+ return {
+ id: resourceId || "",
+ name: resourceName || address,
+ address,
+ type: getResourceType(address),
+ enabled: true,
+ };
+ }, [resourceId, resourceName, address]);
+
+ const openAddPolicy = () => {
+ setEditingPolicyIndex(null);
+ setPolicyModalOpen(true);
+ };
+
+ const openEditPolicy = async (policy: Policy) => {
+ if (policy.id) {
+ const confirm = await confirmMultiResourceAction(
+ policy,
+ "edit",
+ currentResource,
+ );
+ if (!confirm) return;
+ openEditPolicyModal(policy);
+ } else {
+ const idx = newPolicies.indexOf(policy);
+ if (idx === -1) return;
+ setEditingPolicyIndex(idx);
+ setPolicyModalOpen(true);
+ }
+ };
+
+ const savePolicy = (policy: Policy) => {
+ if (editingPolicyIndex !== null) {
+ onNewPoliciesChange(
+ newPolicies.map((p, i) => (i === editingPolicyIndex ? policy : p)),
+ );
+ } else {
+ onNewPoliciesChange([...newPolicies, policy]);
+ }
+ };
+
+ const handleDeletePolicy = async (policy: Policy) => {
+ const confirm = await confirmMultiResourceAction(
+ policy,
+ "delete",
+ currentResource,
+ );
+ if (!confirm) return;
+ if (policy.id) {
+ await deletePolicy(policy);
+ } else {
+ onNewPoliciesChange(newPolicies.filter((p) => p !== policy));
+ }
+ };
+
+ return (
+
+
+
Access Control Policies
+
+ Define which source groups are allowed to access this resource. You
+ can also restrict access to specific protocols and ports. Without
+ policies access to this resource will not be possible.
+
+
+ {allPolicies.length > 0 && (
+
+
+
+
+
+ Name
+
+
+ Source Groups
+
+
+ Protocol & Ports
+
+
+
+
+
+ {allPolicies.map((policy, index) => {
+ return (
+ openEditPolicy(policy)}
+ className="rounded-md hover:bg-nb-gray-900/30 cursor-pointer transition-all"
+ >
+
+
+
+
+
+
+
+ {policy.description && (
+
+
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+ e.stopPropagation()}
+ >
+
+
+
+
+
+
+
+ openEditPolicy(policy)}
+ >
+
+
+ Edit Policy
+
+
+ handleDeletePolicy(policy)}
+ >
+
+
+ Delete Policy
+
+
+
+
+
+
+
+ );
+ })}
+
+
+
+ )}
+
+
+
+ Add Policy
+
+
+
+
{
+ setPolicyModalOpen(open);
+ if (!open) setEditingPolicyIndex(null);
+ }}
+ key={policyModalOpen ? 1 : 0}
+ >
+ {
+ savePolicy(policy);
+ setPolicyModalOpen(false);
+ setEditingPolicyIndex(null);
+ }}
+ />
+
+
+ );
+}
diff --git a/src/modules/networks/resources/NetworkResourceModal.tsx b/src/modules/networks/resources/NetworkResourceModal.tsx
index 6f7a6efc..a238e18b 100644
--- a/src/modules/networks/resources/NetworkResourceModal.tsx
+++ b/src/modules/networks/resources/NetworkResourceModal.tsx
@@ -1,9 +1,10 @@
"use client";
import Button from "@components/Button";
+import { Callout } from "@components/Callout";
import FancyToggleSwitch from "@components/FancyToggleSwitch";
import HelpText from "@components/HelpText";
-import InlineLink from "@components/InlineLink";
+import InlineLink, { InlineButtonLink } from "@components/InlineLink";
import { Input } from "@components/Input";
import { Label } from "@components/Label";
import {
@@ -15,18 +16,27 @@ import {
import ModalHeader from "@components/modal/ModalHeader";
import { notify } from "@components/Notification";
import Paragraph from "@components/Paragraph";
+import { HelpTooltip } from "@components/HelpTooltip";
import { PeerGroupSelector } from "@components/PeerGroupSelector";
-import Separator from "@components/Separator";
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@components/Tabs";
import { useApiCall } from "@utils/api";
+import { useDialog } from "@/contexts/DialogProvider";
+import { usePolicies } from "@/contexts/PoliciesProvider";
+import { useNetworksContext } from "@/modules/networks/NetworkProvider";
import {
ExternalLinkIcon,
PlusCircle,
Power,
+ ShieldCheck,
+ Text,
WorkflowIcon,
} from "lucide-react";
-import React, { useMemo, useState } from "react";
+import React, { useMemo, useRef, useState } from "react";
+import { Group } from "@/interfaces/Group";
import { Network, NetworkResource } from "@/interfaces/Network";
+import { Policy } from "@/interfaces/Policy";
import useGroupHelper from "@/modules/groups/useGroupHelper";
+import NetworkResourceAccessControl from "@/modules/networks/resources/NetworkResourceAccessControl";
import { ResourceSingleAddressInput } from "@/modules/networks/resources/ResourceSingleAddressInput";
type Props = {
@@ -36,6 +46,7 @@ type Props = {
resource?: NetworkResource;
onCreated?: (r: NetworkResource) => void;
onUpdated?: (r: NetworkResource) => void;
+ initialTab?: string;
};
export default function NetworkResourceModal({
@@ -45,6 +56,7 @@ export default function NetworkResourceModal({
resource,
onUpdated,
onCreated,
+ initialTab,
}: Props) {
return (
@@ -54,6 +66,7 @@ export default function NetworkResourceModal({
resource={resource}
onCreated={onCreated}
onUpdated={onUpdated}
+ initialTab={initialTab}
/>
);
@@ -64,6 +77,7 @@ type ModalProps = {
onUpdated?: (r: NetworkResource) => void;
network: Network;
resource?: NetworkResource;
+ initialTab?: string;
};
export function ResourceModalContent({
@@ -71,6 +85,7 @@ export function ResourceModalContent({
onUpdated,
network,
resource,
+ initialTab,
}: ModalProps) {
const create = useApiCall(
`/networks/${network.id}/resources`,
@@ -88,50 +103,123 @@ export function ResourceModalContent({
const [enabled, setEnabled] = useState(
resource ? resource.enabled : true,
);
+ const nameRef = useRef(null);
+ const [tab, setTab] = useState(initialTab || "resource");
+ const [addressError, setAddressError] = useState("");
+
+ const { confirm } = useDialog();
+
+ // Access control policies
+ const [policies, setPolicies] = useState([]);
+ const { createPoliciesForResource } = usePolicies();
+ const {
+ assignedPolicies,
+ resourceExists,
+ policies: allPolicies,
+ } = useNetworksContext();
+
+ const { policies: existingPolicies } = useMemo(
+ () => assignedPolicies(resource, groups),
+ [assignedPolicies, resource, groups],
+ );
+
+ const allResourcePolicies = useMemo(() => {
+ return [...(existingPolicies || []), ...policies];
+ }, [existingPolicies, policies]);
+
+ const groupPolicyCount = useMemo(() => {
+ if (!groups.length || !allPolicies) return 0;
+ const groupIds = new Set(groups.map((g) => g.id));
+ return allPolicies.filter((policy) => {
+ const rule = policy.rules?.[0];
+ if (!rule || rule.destinationResource) return false;
+ const destinations = rule.destinations as
+ | (Group | string)[]
+ | undefined;
+ return destinations?.some((d) => {
+ const id = typeof d === "string" ? d : d.id;
+ return !!id && groupIds.has(id);
+ });
+ }).length;
+ }, [groups, allPolicies]);
+
+ const isAddressValid = address.length > 0 && addressError === "";
+
+ const nameError = useMemo(() => {
+ if (name === "") return "";
+ if (resourceExists(name, resource?.id))
+ return "A resource with this name already exists. Please use another name.";
+ return "";
+ }, [name, resourceExists, resource?.id]);
+
+ const confirmMissingPolicies = async () => {
+ if (allResourcePolicies.length > 0) return true;
+ return confirm({
+ title: "No Access Control Policies Configured",
+ description:
+ "Without access control policies, this resource will not be accessible by any peers. You can also create policies later. Are you sure you want to continue?",
+ type: "warning",
+ confirmText: resource ? "Save Changes" : "Add Resource",
+ cancelText: "Cancel",
+ maxWidthClass: "max-w-lg",
+ });
+ };
const createResource = async () => {
+ if (!(await confirmMissingPolicies())) return;
const savedGroups = await saveGroups();
+ const promise = create({
+ name,
+ description,
+ address,
+ groups: savedGroups ? savedGroups.map((g) => g.id) : undefined,
+ enabled,
+ }).then(async (r) => {
+ await createPoliciesForResource(policies, r);
+ onCreated?.(r);
+ });
+
notify({
title: "Resource Created",
description: `The resource "${name}" has been created successfully.`,
loadingMessage: "Creating resource...",
- promise: create({
- name,
- description,
- address,
- groups: savedGroups ? savedGroups.map((g) => g.id) : undefined,
- enabled,
- }).then((r) => {
- onCreated?.(r);
- }),
+ promise,
});
+
+ return promise;
};
const updateResource = async () => {
+ if (!(await confirmMissingPolicies())) return;
const savedGroups = await saveGroups();
+ const promise = update({
+ name,
+ description,
+ address,
+ groups: savedGroups ? savedGroups.map((g) => g.id) : undefined,
+ enabled,
+ }).then(async (r) => {
+ await createPoliciesForResource(policies, r);
+ onUpdated?.(r);
+ });
notify({
title: "Resource Updated",
- description: `The resource "${name}" has been updated successfully.`,
+ description: `Resource "${name}" has been updated successfully.`,
loadingMessage: "Updating resource...",
- promise: update({
- name,
- description,
- address,
- groups: savedGroups ? savedGroups.map((g) => g.id) : undefined,
- enabled,
- }).then((r) => {
- onUpdated?.(r);
- }),
+ promise,
});
};
- // TODO: Address validation is missing for proper handling of submit button
const canCreate = useMemo(() => {
- return name.length > 0 && address.length > 0;
- }, [name, address, groups]);
+ return name.length > 0 && isAddressValid && nameError === "";
+ }, [name, isAddressValid, nameError]);
return (
-
+
}
title={resource ? "Edit Resource" : "Add Resource"}
@@ -143,55 +231,168 @@ export function ResourceModalContent({
color={"yellow"}
/>
-
-
-
-
- Name
- Provide a name for your resource
- setName(e.target.value)}
- />
-
-
- Description (optional)
-
- Write a short description to add more context to this resource.
-
- setDescription(e.target.value)}
- />
-
+
setTab(v)} value={tab}>
+
+
+
+ Resource
+
+
+
+ Access Control
+
+
+
+ Name & Description
+
+
-
+
+
+
+ Enter a single{" "}
+
+ IP Address
+
+ ,{" "}
+
+ CIDR Block
+ {" "}
+ or{" "}
+
+ Domain Name
+
+ >
+ }
+ />
-
-
Destination Groups (optional)
-
- Add this resource to groups and use them as destinations when
- creating policies
-
-
-
-
-
-
- Enable Resource
- >
- }
- helpText={"Use this switch to enable or disable the resource."}
+
+
Resource Groups (optional)
+
+ Organize this resource into a group (e.g., Databases, Web
+ Servers) and reference the group in access policies to keep
+ rules reusable and easy to maintain.
+
+
+ {groupPolicyCount > 0 && (
+
+ Your selected resource groups are used in{" "}
+
+ {groupPolicyCount} Access Control{" "}
+ {groupPolicyCount === 1 ? "Policy" : "Policies"}
+
+ . This resource will inherit access from{" "}
+ {groupPolicyCount === 1 ? "this policy" : "these policies"}.
+ {isAddressValid || resource ? (
+ <>
+ {" "}
+ Please review them in the{" "}
+ setTab("access-control")}
+ variant={"dashed"}
+ >
+ Access Control
+ {" "}
+ tab.
+ >
+ ) : (
+ " Please review them in the Access Control tab."
+ )}
+
+ )}
+
+
+
+ Enable Resource
+ >
+ }
+ helpText={"Use this switch to enable or disable the resource."}
+ />
+
+
+
+
+ 0}
/>
-
-
+
+
+
+
+
+
@@ -207,25 +408,78 @@ export function ResourceModalContent({
-
- Cancel
-
-
-
- {resource ? (
- <>Save Changes>
- ) : (
- <>
-
- Add Resource
- >
- )}
-
+ {!resource ? (
+ <>
+ {tab === "resource" && (
+ <>
+
+ Cancel
+
+
setTab("access-control")}
+ disabled={!isAddressValid}
+ >
+ Continue
+
+ >
+ )}
+
+ {tab === "access-control" && (
+ <>
+
setTab("resource")}
+ >
+ Back
+
+
{
+ setTab("general");
+ setTimeout(() => nameRef.current?.focus(), 0);
+ }}
+ >
+ Continue
+
+ >
+ )}
+
+ {tab === "general" && (
+ <>
+
setTab("access-control")}
+ >
+ Back
+
+
+
+ Add Resource
+
+ >
+ )}
+ >
+ ) : (
+ <>
+
+ Cancel
+
+
+ Save Changes
+
+ >
+ )}
diff --git a/src/modules/networks/resources/ResourceGroupCell.tsx b/src/modules/networks/resources/ResourceGroupCell.tsx
index 9b6108c2..b6d6bb94 100644
--- a/src/modules/networks/resources/ResourceGroupCell.tsx
+++ b/src/modules/networks/resources/ResourceGroupCell.tsx
@@ -1,6 +1,8 @@
+import Badge from "@components/Badge";
import MultipleGroups, {
TransparentEditIconButton,
} from "@components/ui/MultipleGroups";
+import { IconCirclePlus } from "@tabler/icons-react";
import * as React from "react";
import { usePermissions } from "@/contexts/PermissionsProvider";
import { Group } from "@/interfaces/Group";
@@ -15,6 +17,9 @@ export const ResourceGroupCell = ({ resource }: Props) => {
const { network, openResourceGroupModal } = useNetworksContext();
+ const groups = resource?.groups as Group[] | undefined;
+ const hasGroups = groups && groups.length > 0;
+
return (
{
openResourceGroupModal(network, resource);
}}
>
-
- {permission.networks.update && }
+ {hasGroups ? (
+ <>
+
+ {permission.networks.update && }
+ >
+ ) : (
+
+
+ Add Groups
+
+ )}
);
};
diff --git a/src/modules/networks/resources/ResourceGroupModal.tsx b/src/modules/networks/resources/ResourceGroupModal.tsx
index 3ef80571..62e666d5 100644
--- a/src/modules/networks/resources/ResourceGroupModal.tsx
+++ b/src/modules/networks/resources/ResourceGroupModal.tsx
@@ -8,9 +8,7 @@ import {
import ModalHeader from "@components/modal/ModalHeader";
import { notify } from "@components/Notification";
import { PeerGroupSelector } from "@components/PeerGroupSelector";
-import Separator from "@components/Separator";
import { useApiCall } from "@utils/api";
-import { FolderGit2 } from "lucide-react";
import * as React from "react";
import { Network, NetworkResource } from "@/interfaces/Network";
import useGroupHelper from "@/modules/groups/useGroupHelper";
@@ -78,21 +76,22 @@ const ResourceGroupModalContent = ({
};
return (
-
+
}
- title={"Assigned Groups"}
+ title={"Resource Groups"}
description={
- "Add this resource to groups and use them as destinations when creating policies"
+ "Organize this resource into a group (e.g., Databases, Web Servers) and reference the group in access policies to keep rules reusable and easy to maintain."
}
- color={"blue"}
/>
-
-
-
+
diff --git a/src/modules/networks/resources/ResourceNameCell.tsx b/src/modules/networks/resources/ResourceNameCell.tsx
index ec61b51b..01490fcb 100644
--- a/src/modules/networks/resources/ResourceNameCell.tsx
+++ b/src/modules/networks/resources/ResourceNameCell.tsx
@@ -46,7 +46,7 @@ export default function ResourceNameCell({ resource }: Readonly
) {
/>
diff --git a/src/modules/networks/resources/ResourcePolicyCell.tsx b/src/modules/networks/resources/ResourcePolicyCell.tsx
index 4c32adb1..b0e5e2d4 100644
--- a/src/modules/networks/resources/ResourcePolicyCell.tsx
+++ b/src/modules/networks/resources/ResourcePolicyCell.tsx
@@ -1,52 +1,37 @@
import Badge from "@components/Badge";
import Button from "@components/Button";
import FullTooltip from "@components/FullTooltip";
-import useFetchApi from "@utils/api";
-import { orderBy } from "lodash";
-import { PlusCircle, ShieldIcon, SquarePenIcon } from "lucide-react";
+import { Settings, ShieldIcon, ShieldOff, SquarePenIcon } from "lucide-react";
import * as React from "react";
-import { useMemo, useState } from "react";
+import { useState } from "react";
import Skeleton from "react-loading-skeleton";
import CircleIcon from "@/assets/icons/CircleIcon";
import { usePermissions } from "@/contexts/PermissionsProvider";
-import { Group } from "@/interfaces/Group";
import { NetworkResource } from "@/interfaces/Network";
import { Policy } from "@/interfaces/Policy";
import { useNetworksContext } from "@/modules/networks/NetworkProvider";
+import { cn } from "@utils/helpers";
type Props = {
resource?: NetworkResource;
};
export const ResourcePolicyCell = ({ resource }: Props) => {
const { permission } = usePermissions();
- const { openPolicyModal, network, openEditPolicyModal } =
- useNetworksContext();
- const { data: policies, isLoading } = useFetchApi("/policies");
+ const {
+ openResourceModal,
+ network,
+ openEditPolicyModal,
+ assignedPolicies,
+ confirmMultiResourceAction,
+ } = useNetworksContext();
+ const {
+ policies: resourcePolicies,
+ enabledPolicies,
+ isLoading,
+ policyCount,
+ } = assignedPolicies(resource);
const [tooltipOpen, setTooltipOpen] = useState(false);
- const assignedPolicies = useMemo(() => {
- const resourceGroups = resource?.groups as Group[];
- return orderBy(
- policies?.filter((policy) => {
- const destinationResource = policy.rules
- ?.map((rule) => rule?.destinationResource?.id === resource?.id)
- .some((id) => id);
- if (destinationResource) return true;
- const destinationPolicyGroups = policy.rules
- ?.map((rule) => rule?.destinations)
- .flat() as Group[];
- const policyGroups = [...destinationPolicyGroups];
- return resourceGroups?.some((resourceGroup) =>
- policyGroups.some(
- (policyGroup) => policyGroup?.id === resourceGroup.id,
- ),
- );
- }),
- "enabled",
- "desc",
- );
- }, [policies, resource]);
-
if (isLoading) {
return (
@@ -55,13 +40,16 @@ export const ResourcePolicyCell = ({ resource }: Props) => {
);
}
- const enabledPolicies = assignedPolicies?.filter((policy) => policy?.enabled);
-
- const policyCount = assignedPolicies?.length || 0;
-
return (
network && (
+ {policyCount === 0 && (
+
+
+ None
+
+ )}
+
{policyCount > 0 && (
{
className={"border-nb-gray-800"}
content={
- {assignedPolicies?.map((policy: Policy) => {
+ {resourcePolicies?.map((policy: Policy) => {
const rule = policy?.rules?.[0];
- if (!rule) return;
+ if (!rule) return null;
return (
{
+ onClick={async () => {
setTooltipOpen(false);
+ const confirm = await confirmMultiResourceAction(
+ policy,
+ "edit",
+ resource,
+ );
+ if (!confirm) return;
openEditPolicyModal(policy);
}}
>
@@ -118,18 +112,29 @@ export const ResourcePolicyCell = ({ resource }: Props) => {
>
{
e.preventDefault();
e.stopPropagation();
- if (!tooltipOpen) setTooltipOpen(true);
+ if (!permission.networks.update) return;
+ if (tooltipOpen) setTooltipOpen(false);
+ openResourceModal(network, resource, "access-control");
}}
>
-
+ 0
+ ? "text-green-500"
+ : "text-nb-gray-400",
+ )}
+ />
- {enabledPolicies?.length}
+ {enabledPolicies?.length > 0
+ ? enabledPolicies?.length
+ : `${policyCount} Disabled`}
@@ -139,11 +144,12 @@ export const ResourcePolicyCell = ({ resource }: Props) => {
openPolicyModal(network, resource)}
+ onClick={() => openResourceModal(network, resource, "access-control")}
>
-
- Add Policy
+
+ Configure
)
diff --git a/src/modules/networks/resources/ResourceSingleAddressInput.tsx b/src/modules/networks/resources/ResourceSingleAddressInput.tsx
index 3e29fdf1..536f0d88 100644
--- a/src/modules/networks/resources/ResourceSingleAddressInput.tsx
+++ b/src/modules/networks/resources/ResourceSingleAddressInput.tsx
@@ -13,8 +13,9 @@ type Props = {
label?: string;
className?: string;
onError?: (error: string) => void;
- description?: string;
+ description?: React.ReactNode;
placeholder?: string;
+ autoFocus?: boolean;
};
export const ResourceSingleAddressInput = ({
value,
@@ -24,6 +25,7 @@ export const ResourceSingleAddressInput = ({
onError,
description = "Enter a single IP address, CIDR block or domain name",
placeholder = "Address (IP, CIDR or Domain)",
+ autoFocus,
}: Props) => {
const hasChars = useMemo(() => {
return !!value.match(/[a-z*]/i);
@@ -71,6 +73,7 @@ export const ResourceSingleAddressInput = ({
{label}
{description}
[] = [
return groups.map((group) => group.name).join(", ");
},
header: ({ column }) => {
- return Groups ;
+ return Resource Groups ;
},
cell: ({ row }) => {
return ;
@@ -121,7 +121,12 @@ export default function ResourcesTable({
const params = useSearchParams();
const resourceId = params.get("resource") ?? undefined;
- const [sorting, setSorting] = useState([]);
+ const [sorting, setSorting] = useState([
+ {
+ id: "name",
+ desc: false,
+ },
+ ]);
const { openResourceModal, network } = useNetworksContext();
const router = useRouter();
diff --git a/src/modules/networks/table/NetworksTable.tsx b/src/modules/networks/table/NetworksTable.tsx
index 12e3fd0b..dc09b498 100644
--- a/src/modules/networks/table/NetworksTable.tsx
+++ b/src/modules/networks/table/NetworksTable.tsx
@@ -16,6 +16,7 @@ import NetworkRoutesIcon from "@/assets/icons/NetworkRoutesIcon";
import { usePermissions } from "@/contexts/PermissionsProvider";
import { useLocalStorage } from "@/hooks/useLocalStorage";
import { Network } from "@/interfaces/Network";
+import { NetworkAccessControlProvider } from "@/modules/networks/NetworkAccessControlProvider";
import {
NetworkProvider,
useNetworksContext,
@@ -99,82 +100,84 @@ export default function NetworksTable({
return (
<>
-
- setSearchModal(true)}
- getStartedCard={
-
- }
- color={"gray"}
- size={"large"}
- />
- }
- title={"Create New Network"}
- description={
- "It looks like you don't have any networks. Access internal resources in your LANs and VPC by adding a network."
- }
- button={
-
+
+
+ setSearchModal(true)}
+ getStartedCard={
+
+ }
+ color={"gray"}
+ size={"large"}
+ />
+ }
+ title={"Create New Network"}
+ description={
+ "It looks like you don't have any networks. Access internal resources in your LANs and VPC by adding a network."
+ }
+ button={
+
+ }
+ learnMore={
+ <>
+ Learn more about
+
+ Networks
+
+
+ >
+ }
+ />
+ }
+ rightSide={() =>
+ data &&
+ data.length > 0 && (
+
- }
- learnMore={
- <>
- Learn more about
-
- Networks
-
-
- >
- }
- />
- }
- rightSide={() =>
- data &&
- data.length > 0 && (
-
- )
- }
- >
- {(table) => (
- <>
-
- {
- mutate("/networks").then();
- }}
- />
- >
- )}
-
-
+ )
+ }
+ >
+ {(table) => (
+ <>
+
+ {
+ mutate("/networks").then();
+ }}
+ />
+ >
+ )}
+
+
+
>
);
}
diff --git a/src/modules/reverse-proxy/ReverseProxyModal.tsx b/src/modules/reverse-proxy/ReverseProxyModal.tsx
index 693a3abc..b7ef4b27 100644
--- a/src/modules/reverse-proxy/ReverseProxyModal.tsx
+++ b/src/modules/reverse-proxy/ReverseProxyModal.tsx
@@ -360,7 +360,7 @@ export default function ReverseProxyModal({
- Settings
+ Advanced Settings
diff --git a/src/modules/reverse-proxy/events/ReverseProxyEventsTable.tsx b/src/modules/reverse-proxy/events/ReverseProxyEventsTable.tsx
index 61fa0904..36e0d4b2 100644
--- a/src/modules/reverse-proxy/events/ReverseProxyEventsTable.tsx
+++ b/src/modules/reverse-proxy/events/ReverseProxyEventsTable.tsx
@@ -11,12 +11,10 @@ import GetStartedTest from "@components/ui/GetStartedTest";
import type { ColumnDef, SortingState } from "@tanstack/react-table";
import { ExternalLinkIcon } from "lucide-react";
import ReverseProxyIcon from "@/assets/icons/ReverseProxyIcon";
-import { usePathname } from "next/navigation";
import dayjs from "dayjs";
-import React, { useCallback, useMemo } from "react";
+import React, { useCallback, useMemo, useState } from "react";
import { DateRange } from "react-day-picker";
import { DatePickerWithRange } from "@components/DatePickerWithRange";
-import { useLocalStorage } from "@/hooks/useLocalStorage";
import { useServerPagination } from "@/contexts/ServerPaginationProvider";
import {
REVERSE_PROXY_EVENTS_DOCS_LINK,
@@ -38,7 +36,9 @@ export const ReverseProxyEventsTableColumns: ColumnDef
[] = [
{
id: "timestamp",
header: ({ column }) => (
- Time
+
+ Time
+
),
cell: ({ row }) => (
@@ -52,7 +52,9 @@ export const ReverseProxyEventsTableColumns: ColumnDef[] = [
accessorFn: (row) =>
`${row.source_ip} ${row.city_name || ""} ${row.country_code || ""}`,
header: ({ column }) => (
- Location / IP
+
+ Location / IP
+
),
cell: ({ row }) => (
@@ -62,7 +64,9 @@ export const ReverseProxyEventsTableColumns: ColumnDef[] = [
id: "method",
accessorKey: "method",
header: ({ column }) => (
- Method
+
+ Method
+
),
cell: ({ row }) => ,
filterFn: "arrIncludesSomeExact",
@@ -71,7 +75,9 @@ export const ReverseProxyEventsTableColumns: ColumnDef[] = [
id: "url",
accessorFn: (row) => `${row.host} ${row.path}`,
header: ({ column }) => (
- URL
+
+ URL
+
),
cell: ({ row }) => ,
},
@@ -79,7 +85,9 @@ export const ReverseProxyEventsTableColumns: ColumnDef[] = [
id: "status",
accessorKey: "status_code",
header: ({ column }) => (
- Status
+
+ Status
+
),
cell: ({ row }) => ,
size: 80,
@@ -94,7 +102,9 @@ export const ReverseProxyEventsTableColumns: ColumnDef[] = [
id: "duration",
accessorKey: "duration_ms",
header: ({ column }) => (
- Duration
+
+ Duration
+
),
cell: ({ row }) => ,
},
@@ -102,7 +112,9 @@ export const ReverseProxyEventsTableColumns: ColumnDef[] = [
id: "auth_method",
accessorKey: "auth_method_used",
header: ({ column }) => (
- Auth Method
+
+ Auth Method
+
),
cell: ({ row }) => (
@@ -112,7 +124,9 @@ export const ReverseProxyEventsTableColumns: ColumnDef[] = [
id: "reason",
accessorKey: "reason",
header: ({ column }) => (
- Reason
+
+ Reason
+
),
cell: ({ row }) => ,
},
@@ -120,7 +134,9 @@ export const ReverseProxyEventsTableColumns: ColumnDef[] = [
id: "user",
accessorFn: (row) => row.user_id || "",
header: ({ column }) => (
- User
+
+ User
+
),
cell: ({ row }) => ,
},
@@ -136,8 +152,6 @@ type Props = {
export default function ReverseProxyEventsTable({
headingTarget,
}: Readonly) {
- const path = usePathname();
-
const {
data: events,
isLoading,
@@ -174,15 +188,12 @@ export default function ReverseProxyEventsTable({
[setFilter],
);
- const [sorting, setSorting] = useLocalStorage(
- "netbird-table-sort" + path,
- [
- {
- id: "timestamp",
- desc: true,
- },
- ],
- );
+ const [sorting, setSorting] = useState([
+ {
+ id: "timestamp",
+ desc: true,
+ },
+ ]);
return (
) {
- const status = meta?.status;
- const certificateIssued = !!meta?.certificate_issued_at;
+ const dataRef = useRef(undefined);
- const isSettingUp =
- enabled &&
- status !== undefined &&
- status !== ReverseProxyStatus.ACTIVE &&
- !certificateIssued;
+ const isActive =
+ meta?.status === ReverseProxyStatus.ACTIVE ||
+ dataRef.current?.meta?.status === ReverseProxyStatus.ACTIVE;
+
+ const certificateIssued =
+ !!meta?.certificate_issued_at ||
+ !!dataRef.current?.meta?.certificate_issued_at;
+
+ const shouldPoll = !!enabled && !(isActive && certificateIssued);
const { data } = useFetchApi(
`/reverse-proxies/services/${serviceId}`,
true,
false,
- isSettingUp,
+ shouldPoll,
{ refreshInterval: POLL_INTERVAL_MS },
);
- const currentStatus = data?.meta?.status ?? status;
-
- const currentCertificateIssued = useMemo(() => {
- if (data && data?.meta) return !!data?.meta?.certificate_issued_at;
- return certificateIssued;
- }, [data]);
+ dataRef.current = data;
- if (
- !enabled ||
- (currentStatus === ReverseProxyStatus.ACTIVE && currentCertificateIssued)
- ) {
+ if (!enabled || (isActive && certificateIssued)) {
return null;
}
- if (!currentCertificateIssued) {
+ if (!certificateIssued) {
return (
diff --git a/src/modules/reverse-proxy/targets/ReverseProxyTargetCustomHeaders.tsx b/src/modules/reverse-proxy/targets/ReverseProxyTargetCustomHeaders.tsx
new file mode 100644
index 00000000..b697b31b
--- /dev/null
+++ b/src/modules/reverse-proxy/targets/ReverseProxyTargetCustomHeaders.tsx
@@ -0,0 +1,185 @@
+import Button from "@components/Button";
+import HelpText from "@components/HelpText";
+import { Input } from "@components/Input";
+import { Label } from "@components/Label";
+import { MinusCircleIcon, PlusIcon } from "lucide-react";
+import { useCallback, useState } from "react";
+
+const HEADER_NAME_RE = /^[A-Za-z0-9!#$%&'*+\-.^_`|~]+$/;
+const BLOCKED_HEADERS = new Set([
+ "host",
+ "connection",
+ "transfer-encoding",
+ "keep-alive",
+ "proxy-authenticate",
+ "proxy-authorization",
+ "te",
+ "trailer",
+ "upgrade",
+]);
+
+type HeaderEntry = { id: number; name: string; value: string };
+
+function recordToHeaderEntries(
+ record: Record | undefined,
+ nextId: () => number,
+): HeaderEntry[] {
+ if (!record) return [];
+ return Object.entries(record).map(([name, value]) => ({
+ id: nextId(),
+ name,
+ value,
+ }));
+}
+
+export function headerEntriesToRecord(
+ entries: HeaderEntry[],
+): Record | undefined {
+ if (entries.length === 0) return undefined;
+ const record: Record = {};
+ for (const entry of entries) {
+ if (entry.name) record[entry.name] = entry.value;
+ }
+ return Object.keys(record).length > 0 ? record : undefined;
+}
+
+function validateHeaderName(
+ name: string,
+ allNames: string[],
+): string | undefined {
+ if (!name) return undefined;
+ if (!HEADER_NAME_RE.test(name))
+ return "Invalid characters in header name. Please use another one.";
+ if (BLOCKED_HEADERS.has(name.toLowerCase()))
+ return `"${name}" is a reserved header. Please use another one.`;
+ const dupeCount = allNames.filter(
+ (n) => n.toLowerCase() === name.toLowerCase(),
+ ).length;
+ if (dupeCount > 1) return "Duplicate header name. Please use another one.";
+ return undefined;
+}
+
+function validateHeaderValue(value: string): string | undefined {
+ if (value.includes("\r") || value.includes("\n"))
+ return "Value must not contain line breaks";
+ return undefined;
+}
+
+export function useCustomHeaders(initialHeaders?: Record) {
+ const [nextId] = useState(() => {
+ let id = 0;
+ return () => ++id;
+ });
+
+ const [headerEntries, setHeaderEntries] = useState(() =>
+ recordToHeaderEntries(initialHeaders, nextId),
+ );
+
+ const addHeader = useCallback(() => {
+ setHeaderEntries((prev) => [
+ ...prev,
+ { id: nextId(), name: "", value: "" },
+ ]);
+ }, [nextId]);
+
+ const removeHeader = useCallback((id: number) => {
+ setHeaderEntries((prev) => prev.filter((h) => h.id !== id));
+ }, []);
+
+ const updateHeaderEntry = useCallback(
+ (id: number, field: "name" | "value", fieldValue: string) => {
+ setHeaderEntries((prev) =>
+ prev.map((h) => (h.id === id ? { ...h, [field]: fieldValue } : h)),
+ );
+ },
+ [],
+ );
+
+ const allHeaderNames = headerEntries.map((h) => h.name);
+ const headerErrors = headerEntries.map((entry) => ({
+ name: validateHeaderName(entry.name, allHeaderNames),
+ value: validateHeaderValue(entry.value),
+ }));
+
+ const hasHeaderErrors = headerErrors.some((e) => e.name || e.value);
+
+ return {
+ headerEntries,
+ setHeaderEntries,
+ addHeader,
+ removeHeader,
+ updateHeaderEntry,
+ headerErrors,
+ hasHeaderErrors,
+ };
+}
+
+export type CustomHeadersProps = Pick<
+ ReturnType,
+ | "headerEntries"
+ | "addHeader"
+ | "removeHeader"
+ | "updateHeaderEntry"
+ | "headerErrors"
+>;
+
+export default function ReverseProxyTargetCustomHeaders({
+ headerEntries,
+ addHeader,
+ removeHeader,
+ updateHeaderEntry,
+ headerErrors,
+}: CustomHeadersProps) {
+ return (
+
+
Custom Headers
+
+ Add additional headers to include when forwarding requests.
+
+ Hop-by-hop headers like Host or Connection are not allowed.
+
+ {headerEntries.length > 0 && (
+
+ )}
+
+
+ Add Header
+
+
+ );
+}
diff --git a/src/modules/reverse-proxy/targets/ReverseProxyTargetModal.tsx b/src/modules/reverse-proxy/targets/ReverseProxyTargetModal.tsx
index e2ec3600..5df5a216 100644
--- a/src/modules/reverse-proxy/targets/ReverseProxyTargetModal.tsx
+++ b/src/modules/reverse-proxy/targets/ReverseProxyTargetModal.tsx
@@ -1,13 +1,7 @@
"use client";
-import {
- Accordion,
- AccordionContent,
- AccordionItem,
- AccordionTrigger,
-} from "@components/Accordion";
import Button from "@components/Button";
-import { Checkbox } from "@components/Checkbox";
+import FancyToggleSwitch from "@components/FancyToggleSwitch";
import HelpText from "@components/HelpText";
import { Input } from "@components/Input";
import { Label } from "@components/Label";
@@ -15,14 +9,17 @@ import { Modal, ModalContent, ModalFooter } from "@components/modal/Modal";
import ModalHeader from "@components/modal/ModalHeader";
import { PeerGroupSelector } from "@components/PeerGroupSelector";
import { SelectDropdown } from "@components/select/SelectDropdown";
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@components/Tabs";
import useFetchApi from "@utils/api";
import {
AlertTriangle,
+ ClockFadingIcon,
ExternalLinkIcon,
- HelpCircle,
PlusCircle,
Server,
Settings,
+ ShieldXIcon,
+ Text,
} from "lucide-react";
import { Callout } from "@components/Callout";
import cidr from "ip-cidr";
@@ -35,18 +32,19 @@ import {
ReverseProxyTarget,
ReverseProxyTargetProtocol,
ReverseProxyTargetType,
+ ServiceTargetOptionsPathRewrite,
} from "@/interfaces/ReverseProxy";
import {
defaultPortForProtocol,
isResourceTargetType,
} from "@/contexts/ReverseProxiesProvider";
-import Separator from "@components/Separator";
import { cn } from "@utils/helpers";
import { HelpTooltip } from "@components/HelpTooltip";
import InlineLink, { InlineButtonLink } from "@components/InlineLink";
import SetupModal from "@/modules/setup-netbird-modal/SetupModal";
-import FullTooltip from "@components/FullTooltip";
import Paragraph from "@components/Paragraph";
+import ReverseProxyTargetCustomHeaders from "@/modules/reverse-proxy/targets/ReverseProxyTargetCustomHeaders";
+import { useReverseProxyTargetOptions } from "@/modules/reverse-proxy/targets/useReverseProxyTargetOptions";
/** Get initial host value based on target, resource, or peer */
function getInitialHost(
@@ -93,6 +91,8 @@ export default function ReverseProxyTargetModal({
);
const { data: peers } = useFetchApi("/peers");
+ const [tab, setTab] = useState("details");
+
const [targetType, setTargetType] = useState(
currentTarget?.target_type ??
(initialResource
@@ -121,9 +121,9 @@ export default function ReverseProxyTargetModal({
currentTarget?.port ?? 0,
);
const [targetPath, setTargetPath] = useState(currentTarget?.path ?? "");
- const [accessLocal, setAccessLocal] = useState(
- currentTarget?.access_local ?? false,
- );
+ const [accessLocal] = useState(currentTarget?.access_local ?? false);
+ const [options, setOption, { getTargetOptions, headers, errors }] =
+ useReverseProxyTargetOptions(currentTarget?.options);
const portInputRef = useRef(null);
const [installModal, setInstallModal] = useState(false);
@@ -232,7 +232,8 @@ export default function ReverseProxyTargetModal({
const handleSave = () => {
const resolvedType = initialPeer ? ReverseProxyTargetType.PEER : targetType;
- const isResource = isResourceTargetType(resolvedType) || !!initialResource;
+ const resolvedIsResource =
+ isResourceTargetType(resolvedType) || !!initialResource;
const targetData: ReverseProxyTarget = {
target_type: resolvedType,
target_id:
@@ -245,19 +246,17 @@ export default function ReverseProxyTargetModal({
port: targetPort,
path: targetPath || undefined,
enabled: currentTarget?.enabled ?? true,
- access_local: isResource ? accessLocal : undefined,
+ access_local: resolvedIsResource ? accessLocal : undefined,
+ options: getTargetOptions(),
};
onSave(targetData);
onOpenChange(false);
};
- const showAdvancedSettings = false;
- // const showAdvancedSettings = !!hasTarget && (isResourceTargetType(targetType) || !!initialResource);
-
return (
<>
-
+
}
title={currentTarget ? "Edit Target" : "Add Target"}
@@ -265,308 +264,397 @@ export default function ReverseProxyTargetModal({
color="netbird"
/>
-
+
+
+
+
+ Details
+
+
+
+ Advanced Settings
+
+
-
- {!initialResource && !initialPeer && (
-
-
- {initialNetwork ? (
- "Select Resource"
- ) : (
- <>
- Select{" "}
-
- A{" "}
-
- peer
- {" "}
- is a machine (e.g., laptop, server, container)
- running NetBird. Select a peer if your service runs
- directly on it.
-
- If you don't have a peer yet, you can{" "}
- setInstallModal(true)}
- >
- Install NetBird
-
- .
-
-
- }
- >
- Peer
- {" "}
- or{" "}
-
- A{" "}
-
- resource
- {" "}
- is a destination (IP, subnet, or domain) that
- can't run NetBird directly. Resources are part
- of a network and are reached through a routing peer
- that forwards traffic to them.
-
- If you don't have resources yet, go to{" "}
-
- Networks
- {" "}
- to create some.
-
-
- }
- >
- Resource
-
- >
- )}
-
+
+
+ {!initialResource && !initialPeer && (
+
+
+ {initialNetwork ? (
+ "Select Resource"
+ ) : (
+ <>
+ Select{" "}
+
+ A{" "}
+
+ peer
+ {" "}
+ is a machine (e.g., laptop, server, container)
+ running NetBird. Select a peer if your service
+ runs directly on it.
+
+ If you don't have a peer yet, you can{" "}
+ setInstallModal(true)}
+ >
+ Install NetBird
+
+ .
+
+ >
+ }
+ interactive={true}
+ >
+ Peer
+ {" "}
+ or{" "}
+
+ A{" "}
+
+ resource
+ {" "}
+ is a destination (IP, subnet, or domain) that
+ can't run NetBird directly. Resources are
+ part of a network and are reached through a
+ routing peer that forwards traffic to them.
+
+ If you don't have resources yet, go to{" "}
+
+ Networks
+ {" "}
+ to create some.
+
+ >
+ }
+ interactive={true}
+ >
+ Resource
+
+ >
+ )}
+
-
- {initialNetwork
- ? "Select the resource from your network you want to expose."
- : "Select the peer where your service is running or select a resource to expose it."}
-
-
{}}
- placeholder={
- initialNetwork
- ? "Select a resource..."
- : "Select a peer or resource..."
- }
- showPeers={!initialNetwork}
- showResources={true}
- showRoutes={false}
- hideAllGroup={true}
- hideGroupsTab={true}
- resourceIds={
- initialNetwork ? initialNetwork.resources ?? [] : undefined
- }
- tabOrder={
- initialNetwork ? ["resources"] : ["peers", "resources"]
- }
- closeOnSelect={true}
- max={1}
- resource={
- isResourceTargetType(targetType) && targetResourceId
- ? { id: targetResourceId, type: "host" }
- : targetType === ReverseProxyTargetType.PEER &&
- targetPeerId
- ? { id: targetPeerId, type: "peer" }
- : undefined
- }
- onResourceChange={(res) => {
- if (res) {
- if (res.type === "peer") {
- setTargetType(ReverseProxyTargetType.PEER);
- setTargetPeerId(res.id);
- setTargetResourceId(undefined);
- const peer = peers?.find((p) => p.id === res.id);
- setTargetHost(peer?.ip || "localhost");
- } else {
- const selectedResource = resources?.find(
- (r) => r.id === res.id,
- );
- setTargetType(
- (selectedResource?.type as ReverseProxyTargetType) ??
- ReverseProxyTargetType.HOST,
- );
- setTargetResourceId(res.id);
- setTargetPeerId(undefined);
- const address = selectedResource?.address || "";
- // If CIDR range, pre-fill with base IP
- if (address.includes("/")) {
- setTargetHost(address.split("/")[0]);
+
+ {initialNetwork
+ ? "Select the resource from your network you want to expose."
+ : "Select the peer where your service is running or select a resource to expose it."}
+
+ {}}
+ placeholder={
+ initialNetwork
+ ? "Select a resource..."
+ : "Select a peer or resource..."
+ }
+ showPeers={!initialNetwork}
+ showResources={true}
+ showRoutes={false}
+ hideAllGroup={true}
+ hideGroupsTab={true}
+ resourceIds={
+ initialNetwork
+ ? initialNetwork.resources ?? []
+ : undefined
+ }
+ tabOrder={
+ initialNetwork ? ["resources"] : ["peers", "resources"]
+ }
+ closeOnSelect={true}
+ max={1}
+ resource={
+ isResourceTargetType(targetType) && targetResourceId
+ ? { id: targetResourceId, type: "host" }
+ : targetType === ReverseProxyTargetType.PEER &&
+ targetPeerId
+ ? { id: targetPeerId, type: "peer" }
+ : undefined
+ }
+ onResourceChange={(res) => {
+ if (res) {
+ if (res.type === "peer") {
+ setTargetType(ReverseProxyTargetType.PEER);
+ setTargetPeerId(res.id);
+ setTargetResourceId(undefined);
+ const peer = peers?.find((p) => p.id === res.id);
+ setTargetHost(peer?.ip || "localhost");
+ } else {
+ const selectedResource = resources?.find(
+ (r) => r.id === res.id,
+ );
+ setTargetType(
+ (selectedResource?.type as ReverseProxyTargetType) ??
+ ReverseProxyTargetType.HOST,
+ );
+ setTargetResourceId(res.id);
+ setTargetPeerId(undefined);
+ const address = selectedResource?.address || "";
+ // If CIDR range, pre-fill with base IP
+ if (address.includes("/")) {
+ setTargetHost(address.split("/")[0]);
+ } else {
+ setTargetHost(address);
+ }
+ }
+ setTimeout(() => portInputRef.current?.focus(), 0);
} else {
- setTargetHost(address);
+ setTargetPeerId(undefined);
+ setTargetResourceId(undefined);
+ setTargetHost("");
}
- }
- setTimeout(() => portInputRef.current?.focus(), 0);
- } else {
- setTargetPeerId(undefined);
- setTargetResourceId(undefined);
- setTargetHost("");
- }
- }}
- />
-
- )}
-
-
-
Location (Optional)
-
- Specify an optional path from where requests are routed to your
- service.
-
-
-
- {domain || "domain.example.com"}
-
-
{
- let value = e.target.value;
- if (value && !value.startsWith("/")) {
- value = "/" + value;
- }
- setTargetPath(value);
- }}
- />
-
- {isPathDuplicate && hasTarget && (
-
- }
- >
- Please use a different location. This location is already used
- by another target and cannot be added.
-
- )}
-
+
+ )}
-
-
-
Protocol & Host / IP
- {cidrInfo && (
-
- Enter an IP address within {currentResourceAddress}
+
+
Location (Optional)
+
+ Specify an optional path from where requests are routed to
+ your service.
- )}
-
-
-
- setTargetProtocol(v as ReverseProxyTargetProtocol)
- }
- options={[
- {
- value: ReverseProxyTargetProtocol.HTTP,
- label: "http://",
- },
- {
- value: ReverseProxyTargetProtocol.HTTPS,
- label: "https://",
- },
- ]}
- className="!rounded-r-none !border-r-0"
- disabled={!hasTarget}
- />
-
-
+
+
+ {domain || "domain.example.com"}
+
{
- // Only allow valid IP characters for CIDR ranges
- const value = isHostEditable
- ? e.target.value.replace(/[^0-9.]/g, "")
- : e.target.value;
- setTargetHost(value);
+ let value = e.target.value;
+ if (value && !value.startsWith("/")) {
+ value = "/" + value;
+ }
+ setTargetPath(value);
+ if (!value || value === "/") {
+ setOption("path_rewrite", undefined);
+ }
}}
- placeholder="e.g., 192.168.0.10"
- className="!rounded-l-none"
- disabled={!hasTarget}
- readOnly={hasTarget && !isHostEditable ? true : undefined}
- autoFocus={!!initialResource && isHostEditable}
/>
+ {isPathDuplicate && hasTarget && (
+
+ }
+ >
+ This location is already used by another target and cannot
+ be added.
Please use a different location.
+
+ )}
+ {targetPath &&
+ targetPath !== "/" &&
+ hasTarget &&
+ !isPathDuplicate && (
+
+ setOption(
+ "path_rewrite",
+ v
+ ? ("preserve" as ServiceTargetOptionsPathRewrite)
+ : undefined,
+ )
+ }
+ className={"mt-3.5"}
+ label={
+ <>
+ Preserve Full Path
+
+
+ When disabled, a request to e.g.,{" "}
+
+ {targetPath}/users
+ {" "}
+ is forwarded as{" "}
+
+ /users
+
+ .
+
+
+ When enabled, a request to e.g.,{" "}
+
+ {targetPath}/users
+ {" "}
+ is forwarded as{" "}
+
+ {targetPath}/users
+
+ .
+
+
+ }
+ />
+ >
+ }
+ helpText={
+
+ Keep the original full request path when forwarding.{" "}
+
+ When disabled the matched prefix path is stripped.
+
+ }
+ />
+ )}
+
+
+
+
+
+
Protocol & Host / IP
+ {cidrInfo && (
+
+ Enter an IP address within {currentResourceAddress}
+
+ )}
+
+
+ {
+ const proto = v as ReverseProxyTargetProtocol;
+ setTargetProtocol(proto);
+ if (proto !== ReverseProxyTargetProtocol.HTTPS) {
+ setOption("skip_tls_verify", undefined);
+ }
+ }}
+ options={[
+ {
+ value: ReverseProxyTargetProtocol.HTTP,
+ label: "http://",
+ },
+ {
+ value: ReverseProxyTargetProtocol.HTTPS,
+ label: "https://",
+ },
+ ]}
+ className="!rounded-r-none !border-r-0"
+ disabled={!hasTarget}
+ />
+
+
+ {
+ // Only allow valid IP characters for CIDR ranges
+ const value = isHostEditable
+ ? e.target.value.replace(/[^0-9.]/g, "")
+ : e.target.value;
+ setTargetHost(value);
+ }}
+ placeholder="e.g., 192.168.0.10"
+ className="!rounded-l-none"
+ disabled={!hasTarget}
+ readOnly={
+ hasTarget && !isHostEditable ? true : undefined
+ }
+ autoFocus={!!initialResource && isHostEditable}
+ />
+
+
+
+
+
+ Port
+
+
+ {cidrInfo && (
+
+ )}
+
+
+ setTargetPort(parseInt(e.target.value) || 0)
+ }
+ placeholder={String(
+ defaultPortForProtocol(targetProtocol),
+ )}
+ min={0}
+ max={65535}
+ disabled={!hasTarget}
+ autoFocus={!!initialResource && !isHostEditable}
+ />
+
+
+
+ {targetProtocol === ReverseProxyTargetProtocol.HTTPS &&
+ hasTarget && (
+
+ setOption("skip_tls_verify", v || undefined)
+ }
+ label={
+ <>
+
+ Skip TLS Verification
+ >
+ }
+ helpText="Skip certificate verification when connecting to this target. Useful if your service already uses a self-signed certificate."
+ />
+ )}
-
-
- Port
-
- Enter the port where your service (e.g., webserver, app,
- API) is currently listening. If left empty, defaults to
- port 80 for HTTP or 443 for HTTPS.
-
- }
- >
-
-
-
- {cidrInfo && }
-
+
+
+
+
+
+
+ Request Timeout
+
+ Max time to wait for a response as duration string (max
+ 5m). Leave this field empty for no timeout.
+
+
}
+ placeholder="e.g. 10s, 30s, 1m"
+ value={options.request_timeout ?? ""}
onChange={(e) =>
- setTargetPort(parseInt(e.target.value) || 0)
+ setOption("request_timeout", e.target.value || undefined)
}
- placeholder={String(defaultPortForProtocol(targetProtocol))}
- min={0}
- max={65535}
- disabled={!hasTarget}
- autoFocus={!!initialResource && !isHostEditable}
+ maxWidthClass="w-[180px]"
+ errorTooltip={true}
+ error={errors.timeout}
/>
-
-
- {showAdvancedSettings && (
-
-
-
-
-
- Advanced Settings
-
-
-
-
-
- setAccessLocal(v === true)}
- />
-
-
- This is the routing peer
-
-
- Enable if the service runs directly on the routing
- peer rather than behind it.
-
-
-
-
-
-
-
- )}
-
+
+
+
+
@@ -582,23 +670,61 @@ export default function ReverseProxyTargetModal({
-
onOpenChange(false)}>
- Cancel
-
-
- {currentTarget ? (
- "Save Changes"
- ) : (
- <>
-
- Add Target
- >
- )}
-
+ {currentTarget ? (
+ <>
+
onOpenChange(false)}
+ >
+ Cancel
+
+
+ Save Changes
+
+ >
+ ) : (
+ <>
+ {tab === "details" && (
+ <>
+
onOpenChange(false)}
+ >
+ Cancel
+
+
setTab("options")}
+ disabled={!canAddTarget}
+ >
+ Continue
+
+ >
+ )}
+ {tab === "options" && (
+ <>
+
setTab("details")}
+ >
+ Back
+
+
+
+ Add Target
+
+ >
+ )}
+ >
+ )}
diff --git a/src/modules/reverse-proxy/targets/useReverseProxyTargetOptions.ts b/src/modules/reverse-proxy/targets/useReverseProxyTargetOptions.ts
new file mode 100644
index 00000000..79f79712
--- /dev/null
+++ b/src/modules/reverse-proxy/targets/useReverseProxyTargetOptions.ts
@@ -0,0 +1,101 @@
+import { useCallback, useState } from "react";
+import { ServiceTargetOptions } from "@/interfaces/ReverseProxy";
+import {
+ headerEntriesToRecord,
+ useCustomHeaders,
+} from "@/modules/reverse-proxy/targets/ReverseProxyTargetCustomHeaders";
+
+// Go time.ParseDuration format: one or more {number}{unit} pairs
+const DURATION_RE = /^(\d+(\.\d+)?(ns|us|µs|ms|s|m|h))+$/;
+const MAX_TIMEOUT_MS = 5 * 60 * 1000; // 5m
+
+function parseDurationMs(duration: string): number {
+ const units: Record = {
+ ns: 1e-6,
+ us: 1e-3,
+ µs: 1e-3,
+ ms: 1,
+ s: 1000,
+ m: 60_000,
+ h: 3_600_000,
+ };
+ let total = 0;
+ for (const [, val, , unit] of duration.matchAll(
+ /(\d+(\.\d+)?)(ns|us|µs|ms|s|m|h)/g,
+ )) {
+ total += parseFloat(val) * units[unit];
+ }
+ return total;
+}
+
+function validateTimeout(timeout: string): string | undefined {
+ if (!timeout) return undefined;
+ if (!DURATION_RE.test(timeout))
+ return 'Invalid duration, use e.g., "10s", "30s", "1m"';
+ if (parseDurationMs(timeout) > MAX_TIMEOUT_MS)
+ return "Timeout cannot exceed the maximum of 5m.";
+ return undefined;
+}
+
+export function useReverseProxyTargetOptions(
+ initialOptions?: ServiceTargetOptions,
+) {
+ const [targetOptions, setTargetOptions] = useState(
+ () => {
+ const { custom_headers: _, ...rest } = initialOptions ?? {};
+ return rest;
+ },
+ );
+
+ const {
+ headerEntries,
+ addHeader,
+ removeHeader,
+ updateHeaderEntry,
+ headerErrors,
+ hasHeaderErrors,
+ } = useCustomHeaders(initialOptions?.custom_headers);
+
+ const updateOption = useCallback(
+ (
+ key: K,
+ value: ServiceTargetOptions[K],
+ ) => {
+ setTargetOptions((prev) => ({ ...prev, [key]: value }));
+ },
+ [],
+ );
+
+ const timeoutError = validateTimeout(targetOptions.request_timeout ?? "");
+ const hasOptionsErrors = !!timeoutError || hasHeaderErrors;
+
+ const getTargetOptions = useCallback((): ServiceTargetOptions | undefined => {
+ const customHeaders = headerEntriesToRecord(headerEntries);
+ const merged: ServiceTargetOptions = {
+ ...targetOptions,
+ custom_headers: customHeaders,
+ };
+ const hasOptions = Object.values(merged).some((v) => v !== undefined);
+ return hasOptions ? merged : undefined;
+ }, [targetOptions, headerEntries]);
+
+ return [
+ targetOptions,
+ updateOption,
+ {
+ getTargetOptions,
+ headers: {
+ headerEntries,
+ addHeader,
+ removeHeader,
+ updateHeaderEntry,
+ headerErrors,
+ hasHeaderErrors,
+ },
+ errors: {
+ timeout: timeoutError,
+ options: hasOptionsErrors,
+ },
+ },
+ ] as const;
+}