diff --git a/src/app/(dashboard)/peer/page.tsx b/src/app/(dashboard)/peer/page.tsx index d2474be..bda0bac 100644 --- a/src/app/(dashboard)/peer/page.tsx +++ b/src/app/(dashboard)/peer/page.tsx @@ -57,6 +57,8 @@ import { getOperatingSystem } from "@/hooks/useOperatingSystem"; import { OperatingSystem } from "@/interfaces/OperatingSystem"; import type { Peer } from "@/interfaces/Peer"; import PageContainer from "@/layouts/PageContainer"; +import { AddExitNodeButton } from "@/modules/exit-node/AddExitNodeButton"; +import { useHasExitNodes } from "@/modules/exit-node/useHasExitNodes"; import useGroupHelper from "@/modules/groups/useGroupHelper"; import AddRouteDropdownButton from "@/modules/peer/AddRouteDropdownButton"; import PeerRoutesTable from "@/modules/peer/PeerRoutesTable"; @@ -127,6 +129,7 @@ function PeerOverview() { }; const { isUser } = useLoggedInUser(); + const hasExitNodes = useHasExitNodes(peer); return ( @@ -342,7 +345,8 @@ function PeerOverview() {
-
+
+
diff --git a/src/components/PeerSelector.tsx b/src/components/PeerSelector.tsx index 8a5f5dc..8f3888f 100644 --- a/src/components/PeerSelector.tsx +++ b/src/components/PeerSelector.tsx @@ -121,7 +121,7 @@ export function PeerSelector({ + + + {modal && ( + setModal(false)} + peer={peer} + isFirstExitNode={firstTime} + exitNode={true} + /> + )} + + + ); +}; diff --git a/src/modules/exit-node/ExitNodeDropdownButton.tsx b/src/modules/exit-node/ExitNodeDropdownButton.tsx new file mode 100644 index 0000000..20ffa8a --- /dev/null +++ b/src/modules/exit-node/ExitNodeDropdownButton.tsx @@ -0,0 +1,40 @@ +import { DropdownMenuItem } from "@components/DropdownMenu"; +import { Modal } from "@components/modal/Modal"; +import { getOperatingSystem } from "@hooks/useOperatingSystem"; +import { IconDirectionSign } from "@tabler/icons-react"; +import * as React from "react"; +import { useState } from "react"; +import { OperatingSystem } from "@/interfaces/OperatingSystem"; +import { Peer } from "@/interfaces/Peer"; +import { RouteModalContent } from "@/modules/routes/RouteModal"; + +type Props = { + peer: Peer; +}; + +export const ExitNodeDropdownButton = ({ peer }: Props) => { + const [modal, setModal] = useState(false); + const isLinux = getOperatingSystem(peer.os) === OperatingSystem.LINUX; + + return isLinux ? ( + <> + setModal(true)}> +
+ +
+ Add Exit Node +
+
+
+ + {modal && ( + setModal(false)} + peer={peer} + exitNode={true} + /> + )} + + + ) : null; +}; diff --git a/src/modules/exit-node/ExitNodeHelpTooltip.tsx b/src/modules/exit-node/ExitNodeHelpTooltip.tsx new file mode 100644 index 0000000..96a3d13 --- /dev/null +++ b/src/modules/exit-node/ExitNodeHelpTooltip.tsx @@ -0,0 +1,41 @@ +import FullTooltip from "@components/FullTooltip"; +import InlineLink from "@components/InlineLink"; +import { ExternalLinkIcon } from "lucide-react"; +import * as React from "react"; + +type Props = { + children: React.ReactNode; + hoverButton?: boolean; +}; +export const ExitNodeHelpTooltip = ({ + children, + hoverButton = false, +}: Props) => { + return ( + + An exit node is a network route that routes all your internet traffic + through one of your peers. +
+ Learn more about{" "} + + Exit Nodes + + + in our documentation. +
+
+ } + > + {children} + + ); +}; diff --git a/src/modules/exit-node/ExitNodePeerIndicator.tsx b/src/modules/exit-node/ExitNodePeerIndicator.tsx new file mode 100644 index 0000000..213c121 --- /dev/null +++ b/src/modules/exit-node/ExitNodePeerIndicator.tsx @@ -0,0 +1,25 @@ +import FullTooltip from "@components/FullTooltip"; +import { IconDirectionSign } from "@tabler/icons-react"; +import * as React from "react"; +import { Peer } from "@/interfaces/Peer"; +import { useHasExitNodes } from "@/modules/exit-node/useHasExitNodes"; + +type Props = { + peer: Peer; +}; +export const ExitNodePeerIndicator = ({ peer }: Props) => { + const hasExitNode = useHasExitNodes(peer); + + return hasExitNode ? ( + + This peer has an exit node. Traffic from the configured distribution + groups will be routed through this peer. + + } + > + + + ) : null; +}; diff --git a/src/modules/exit-node/useHasExitNodes.tsx b/src/modules/exit-node/useHasExitNodes.tsx new file mode 100644 index 0000000..288fab2 --- /dev/null +++ b/src/modules/exit-node/useHasExitNodes.tsx @@ -0,0 +1,20 @@ +import useFetchApi from "@utils/api"; +import { useLoggedInUser } from "@/contexts/UsersProvider"; +import { Peer } from "@/interfaces/Peer"; +import { Route } from "@/interfaces/Route"; + +export const useHasExitNodes = (peer?: Peer) => { + const { isOwnerOrAdmin } = useLoggedInUser(); + const { data: routes } = useFetchApi( + `/routes`, + false, + true, + isOwnerOrAdmin, + ); + return peer + ? routes?.some( + (route) => + route?.peer === peer.id && route?.network.includes("0.0.0.0"), + ) || false + : false; +}; diff --git a/src/modules/peer/PeerRouteNetworkCell.tsx b/src/modules/peer/PeerRouteNetworkCell.tsx index 4733bd7..23dd92b 100644 --- a/src/modules/peer/PeerRouteNetworkCell.tsx +++ b/src/modules/peer/PeerRouteNetworkCell.tsx @@ -1,10 +1,29 @@ +import { IconDirectionSign } from "@tabler/icons-react"; +import { InfoIcon } from "lucide-react"; +import * as React from "react"; import { Route } from "@/interfaces/Route"; +import { ExitNodeHelpTooltip } from "@/modules/exit-node/ExitNodeHelpTooltip"; type Props = { route: Route; }; export default function PeerRouteNetworkCell({ route }: Props) { - return ( + const isExitNode = route?.network === "0.0.0.0/0"; + + return isExitNode ? ( + +
+ + Exit Node{" "} + +
+
+ ) : (
{route.network}
diff --git a/src/modules/peer/PeerRoutesTable.tsx b/src/modules/peer/PeerRoutesTable.tsx index 0f3426d..0c3bbe3 100644 --- a/src/modules/peer/PeerRoutesTable.tsx +++ b/src/modules/peer/PeerRoutesTable.tsx @@ -14,6 +14,7 @@ import PeerRouteActiveCell from "@/modules/peer/PeerRouteActiveCell"; import PeerRouteNameCell from "@/modules/peer/PeerRouteNameCell"; import PeerRouteNetworkCell from "@/modules/peer/PeerRouteNetworkCell"; import usePeerRoutes from "@/modules/peer/usePeerRoutes"; +import RouteDistributionGroupsCell from "@/modules/routes/RouteDistributionGroupsCell"; type Props = { peer: Peer; @@ -35,6 +36,16 @@ export const RouteTableColumns: ColumnDef[] = [ }, cell: ({ row }) => , }, + { + id: "groups", + accessorFn: (r) => r.groups?.length, + header: ({ column }) => { + return ( + Distribution Groups + ); + }, + cell: ({ row }) => , + }, { id: "enabled", accessorKey: "enabled", diff --git a/src/modules/peers/PeerActionCell.tsx b/src/modules/peers/PeerActionCell.tsx index 6dd426a..3310b1b 100644 --- a/src/modules/peers/PeerActionCell.tsx +++ b/src/modules/peers/PeerActionCell.tsx @@ -19,6 +19,7 @@ import { useRouter } from "next/navigation"; import React from "react"; import { useSWRConfig } from "swr"; import { usePeer } from "@/contexts/PeerProvider"; +import { ExitNodeDropdownButton } from "@/modules/exit-node/ExitNodeDropdownButton"; export default function PeerActionCell() { const { peer, deletePeer, update, openSSHDialog } = usePeer(); @@ -125,6 +126,9 @@ export default function PeerActionCell() { + + + diff --git a/src/modules/peers/PeerNameCell.tsx b/src/modules/peers/PeerNameCell.tsx index 10988a3..b9fe70b 100644 --- a/src/modules/peers/PeerNameCell.tsx +++ b/src/modules/peers/PeerNameCell.tsx @@ -1,9 +1,10 @@ import { useRouter } from "next/navigation"; import * as React from "react"; import { useMemo } from "react"; -import { useUsers } from "@/contexts/UsersProvider"; +import { useLoggedInUser, useUsers } from "@/contexts/UsersProvider"; import { Peer } from "@/interfaces/Peer"; import ActiveInactiveRow from "@/modules/common-table-rows/ActiveInactiveRow"; +import { ExitNodePeerIndicator } from "@/modules/exit-node/ExitNodePeerIndicator"; type Props = { peer: Peer; @@ -11,22 +12,33 @@ type Props = { export default function PeerNameCell({ peer }: Props) { const { users } = useUsers(); const router = useRouter(); + const { isOwnerOrAdmin } = useLoggedInUser(); const userOfPeer = useMemo(() => { return users?.find((user) => user.id === peer.user_id); }, [users, peer.user_id]); return ( -
router.push("/peer?id=" + peer.id)} - > - -
{userOfPeer?.email}
-
+
+
router.push("/peer?id=" + peer.id)} + > + + } + > +
+ {userOfPeer?.email} +
+
+
); } diff --git a/src/modules/route-group/GroupedRouteNetworkRangeCell.tsx b/src/modules/route-group/GroupedRouteNetworkRangeCell.tsx index 27459ae..e0b8218 100644 --- a/src/modules/route-group/GroupedRouteNetworkRangeCell.tsx +++ b/src/modules/route-group/GroupedRouteNetworkRangeCell.tsx @@ -1,8 +1,28 @@ +import { IconDirectionSign } from "@tabler/icons-react"; +import { InfoIcon } from "lucide-react"; +import * as React from "react"; +import { ExitNodeHelpTooltip } from "@/modules/exit-node/ExitNodeHelpTooltip"; + type Props = { network: string; }; export default function GroupedRouteNetworkRangeCell({ network }: Props) { - return ( + const isExitNode = network === "0.0.0.0/0"; + + return isExitNode ? ( + +
+ + Exit Node{" "} + +
+
+ ) : (
{network}
diff --git a/src/modules/route-group/NetworkRoutesTable.tsx b/src/modules/route-group/NetworkRoutesTable.tsx index 29e5cac..f5e2649 100644 --- a/src/modules/route-group/NetworkRoutesTable.tsx +++ b/src/modules/route-group/NetworkRoutesTable.tsx @@ -17,6 +17,7 @@ import NetworkRoutesIcon from "@/assets/icons/NetworkRoutesIcon"; import GroupRouteProvider from "@/contexts/GroupRouteProvider"; import { useLocalStorage } from "@/hooks/useLocalStorage"; import { GroupedRoute, Route } from "@/interfaces/Route"; +import { AddExitNodeButton } from "@/modules/exit-node/AddExitNodeButton"; import GroupedRouteActionCell from "@/modules/route-group/GroupedRouteActionCell"; import GroupedRouteHighAvailabilityCell from "@/modules/route-group/GroupedRouteHighAvailabilityCell"; import GroupedRouteNameCell from "@/modules/route-group/GroupedRouteNameCell"; @@ -157,12 +158,15 @@ export default function NetworkRoutesTable({ "It looks like you don't have any routes. Access LANs and VPC by adding a network route." } button={ - - - +
+ + + + +
} learnMore={ <> @@ -183,12 +187,15 @@ export default function NetworkRoutesTable({ rightSide={() => ( <> {routes && routes?.length > 0 && ( - - - +
+ + + + +
)} )} diff --git a/src/modules/routes/RouteModal.tsx b/src/modules/routes/RouteModal.tsx index e121293..f5eca54 100644 --- a/src/modules/routes/RouteModal.tsx +++ b/src/modules/routes/RouteModal.tsx @@ -20,6 +20,7 @@ import { PeerSelector } from "@components/PeerSelector"; import { SegmentedTabs } from "@components/SegmentedTabs"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@components/Tabs"; import { Textarea } from "@components/Textarea"; +import { IconDirectionSign } from "@tabler/icons-react"; import { cn } from "@utils/helpers"; import cidr from "ip-cidr"; import { uniqBy } from "lodash"; @@ -63,19 +64,36 @@ export default function RouteModal({ children }: Props) { type ModalProps = { onSuccess?: (route: Route) => void; peer?: Peer; + exitNode?: boolean; + isFirstExitNode?: boolean; }; -export function RouteModalContent({ onSuccess, peer }: ModalProps) { +export function RouteModalContent({ + onSuccess, + peer, + exitNode, + isFirstExitNode = false, +}: ModalProps) { const { createRoute } = useRoutes(); + const [tab, setTab] = useState("network"); - // General - const [networkIdentifier, setNetworkIdentifier] = useState(""); + /** + * Network Identifier, Description & Network Range + */ + const [networkIdentifier, setNetworkIdentifier] = useState( + exitNode + ? peer + ? `Exit Node (${ + peer.name.length > 25 + ? peer.name.substring(0, 25) + "..." + : peer.name + })` + : "Exit Node" + : "", + ); const [description, setDescription] = useState(""); - - // Network - const [networkRange, setNetworkRange] = useState(""); + const [networkRange, setNetworkRange] = useState(exitNode ? "0.0.0.0/0" : ""); const [routingPeer, setRoutingPeer] = useState(peer); - const [ routingPeerGroups, setRoutingPeerGroups, @@ -84,29 +102,23 @@ export function RouteModalContent({ onSuccess, peer }: ModalProps) { initial: [], }); + /** + * Distribution Groups + */ const [groups, setGroups, { getGroupsToUpdate }] = useGroupHelper({ initial: [], }); - // Additional Settings + /** + * Additional Settings + */ const [enabled, setEnabled] = useState(true); const [metric, setMetric] = useState("9999"); const [masquerade, setMasquerade] = useState(true); - // Validate CIDR - const cidrError = useMemo(() => { - if (networkRange == "") return ""; - const validCIDR = cidr.isValidAddress(networkRange); - if (!validCIDR) return "Please enter a valid CIDR, e.g., 192.168.1.0/24"; - }, [networkRange]); - - // Refs to manage focus on tab change - const networkRangeRef = useRef(null); - const nameRef = useRef(null); - const [peerTab, setPeerTab] = useState("routing-peer"); - - // Create route - // TODO Refactor to avoid duplicate code + /** + * Create Route + */ const createRouteHandler = async () => { const g1 = getAllRoutingGroupsToUpdate(); const g2 = getGroupsToUpdate(); @@ -147,36 +159,71 @@ export function RouteModalContent({ onSuccess, peer }: ModalProps) { ); }; - // Is button disabled - const isDisabled = useMemo(() => { - return ( - networkIdentifier == "" || + /** + * Refs to manage input focus on tab change + */ + const networkRangeRef = useRef(null); + const nameRef = useRef(null); + const [peerTab, setPeerTab] = useState("routing-peer"); + + /** + * Validate CIDR Range + */ + const cidrError = useMemo(() => { + if (networkRange == "") return ""; + const validCIDR = cidr.isValidAddress(networkRange); + if (!validCIDR) return "Please enter a valid CIDR, e.g., 192.168.1.0/24"; + }, [networkRange]); + + /** + * Allow to create route only when all fields are filled + */ + const isNetworkEntered = useMemo(() => { + return !( (cidrError && cidrError.length > 1) || (peerTab === "peer-group" && routingPeerGroups.length == 0) || (peerTab === "routing-peer" && !routingPeer) || groups.length == 0 ); - }, [ - networkIdentifier, - cidrError, - peerTab, - routingPeerGroups.length, - routingPeer, - groups, - ]); + }, [cidrError, peerTab, routingPeerGroups.length, routingPeer, groups]); - const [tab, setTab] = useState("network"); + const isNameEntered = useMemo(() => { + return !(networkIdentifier == ""); + }, [networkIdentifier]); + + const canCreateOrSave = useMemo(() => { + return isNetworkEntered && isNameEntered; + }, [isNetworkEntered, isNameEntered]); return ( } - title={"Create New Route"} - description={"Access LANs and VPC by adding a network route."} - color={"netbird"} + icon={ + exitNode ? ( + + ) : ( + + ) + } + title={ + exitNode + ? isFirstExitNode + ? "Setup Exit Node" + : "Add Exit Node" + : "Create New Route" + } + truncate={!!peer} + description={ + exitNode + ? peer + ? `Route all traffic through the peer '${peer.name}'` + : "Route all internet traffic through a peer" + : "Access LANs and VPC by adding a network route." + } + color={exitNode ? "yellow" : "netbird"} /> - setTab(v)}> + setTab(v)} value={tab}> nameRef.current?.focus()} > Name & Description - + + +
+
+ + Add a private IP address range + } + placeholder={"e.g., 172.16.0.0/16"} + value={networkRange} + className={"font-mono !text-[13px]"} + error={cidrError} + onChange={(e) => setNetworkRange(e.target.value)} + /> +
+ {exitNode && peer ? ( + <> + ) : ( + + + + + Routing Peer + + + + + Peer Group + + + +
+ + Assign a single peer as a routing peer for the + {exitNode ? " Exit Node" : " Network CIDR"} + + +
+
+ +
+ + Assign peer group with Linux machines to be used as + routing peers. + + +
+
+
+ )} + +
+ + + {exitNode + ? peer + ? `Route all internet traffic through this peer for the following groups` + : `Route all internet traffic through the peer(s) for the following groups` + : "Advertise this route to peers that belong to the following groups"} + + +
+
+
@@ -244,69 +364,6 @@ export function RouteModalContent({ onSuccess, peer }: ModalProps) {
- -
-
- - Add a private IP address range - } - placeholder={"e.g., 172.16.0.0/16"} - value={networkRange} - className={"font-mono !text-[13px]"} - error={cidrError} - onChange={(e) => setNetworkRange(e.target.value)} - /> -
- - - - - Routing Peer - - - - - Peer Group - - - -
- - Assign a single peer as a routing peer for the Network CIDR. - - -
-
- -
- - Assign peer group with Linux machines to be used as routing - peers. - - -
-
-
-
- - - Advertise this route to peers that belong to the following - groups - - -
-
-
- - - Masquerade - - } - helpText={ - "Allow access to your private networks without configuring routes on your local routers or other devices." - } - /> + {!exitNode && ( + + + Masquerade + + } + helpText={ + "Allow access to your private networks without configuring routes on your local routers or other devices." + } + /> + )} +
@@ -366,28 +426,64 @@ export function RouteModalContent({ onSuccess, peer }: ModalProps) { Learn more about - Network Routes + {exitNode ? "Exit Nodes" : "Network Routes"}
- - - + {tab == "network" && ( + + + + )} - + {tab == "general" && ( + + )} + + {tab == "settings" && ( + + )} + + {tab == "network" && ( + + )} + {tab == "general" && ( + + )} + {tab == "settings" && ( + + )}
diff --git a/src/utils/api.tsx b/src/utils/api.tsx index c2495a2..2bd6b13 100644 --- a/src/utils/api.tsx +++ b/src/utils/api.tsx @@ -93,6 +93,7 @@ export default function useFetchApi( url: string, ignoreError = false, revalidate = true, + allowFetch = true, ) { const { fetch } = useNetBirdFetch(ignoreError); const handleErrors = useApiErrorHandling(ignoreError); @@ -100,6 +101,7 @@ export default function useFetchApi( const { data, error, isLoading, isValidating, mutate } = useSWR( url, async (url) => { + if (!allowFetch) return; return apiRequest(fetch, "GET", url).catch((err) => handleErrors(err as ErrorResponse), );