diff --git a/src/contexts/RoutesProvider.tsx b/src/contexts/RoutesProvider.tsx index 34810750..b1233bd7 100644 --- a/src/contexts/RoutesProvider.tsx +++ b/src/contexts/RoutesProvider.tsx @@ -61,6 +61,7 @@ export default function RoutesProvider({ children }: Readonly) { : toUpdate.access_control_groups ?? route.access_control_groups ?? undefined, + skip_auto_apply: toUpdate.skip_auto_apply ?? route.skip_auto_apply ?? true, }, `/${route.id}`, ) @@ -94,6 +95,7 @@ export default function RoutesProvider({ children }: Readonly) { masquerade: route.masquerade, groups: route.groups || [], access_control_groups: route?.access_control_groups || undefined, + skip_auto_apply: route.skip_auto_apply ?? true, }) .then((route) => { mutate("/routes"); diff --git a/src/interfaces/Route.ts b/src/interfaces/Route.ts index 1e13c0cc..e459dbff 100644 --- a/src/interfaces/Route.ts +++ b/src/interfaces/Route.ts @@ -12,6 +12,7 @@ export interface Route { groups: string[]; keep_route?: boolean; access_control_groups?: string[]; + skip_auto_apply?: boolean; // Frontend only peer_groups?: string[]; routesGroups?: string[]; diff --git a/src/modules/exit-node/ExitNodeDropdownButton.tsx b/src/modules/exit-node/ExitNodeDropdownButton.tsx index e259fa3f..905f7c7a 100644 --- a/src/modules/exit-node/ExitNodeDropdownButton.tsx +++ b/src/modules/exit-node/ExitNodeDropdownButton.tsx @@ -15,7 +15,7 @@ type Props = { export const ExitNodeDropdownButton = ({ peer }: Props) => { const [modal, setModal] = useState(false); - const hasExitNodes = useHasExitNodes(peer); + const exitNodeInfo = useHasExitNodes(peer); const { permission } = usePermissions(); return ( @@ -25,7 +25,7 @@ export const ExitNodeDropdownButton = ({ peer }: Props) => { disabled={!permission.routes.create} >
- {hasExitNodes ? ( + {exitNodeInfo.hasExitNode ? ( <>
diff --git a/src/modules/exit-node/ExitNodePeerIndicator.tsx b/src/modules/exit-node/ExitNodePeerIndicator.tsx index 931195d7..05612e58 100644 --- a/src/modules/exit-node/ExitNodePeerIndicator.tsx +++ b/src/modules/exit-node/ExitNodePeerIndicator.tsx @@ -8,18 +8,22 @@ type Props = { peer: Peer; }; export const ExitNodePeerIndicator = ({ peer }: Props) => { - const hasExitNode = useHasExitNodes(peer); + const exitNodeInfo = useHasExitNodes(peer); - return hasExitNode ? ( - - This peer is an exit node. Traffic from the configured distribution - groups will be routed through this peer. -
- } - > - + if (!exitNodeInfo.hasExitNode) { + return null; + } + + const tooltipContent = exitNodeInfo.skipAutoApply === false + ? "This peer is an auto-applied exit node. Traffic from the configured distribution groups will be routed through this peer." + : "This peer is an exit node. Traffic from the configured distribution groups will be routed through this peer."; + + return ( + {tooltipContent}
}> + - ) : null; + ); }; diff --git a/src/modules/exit-node/useHasExitNodes.tsx b/src/modules/exit-node/useHasExitNodes.tsx index e99b967d..5b6ed712 100644 --- a/src/modules/exit-node/useHasExitNodes.tsx +++ b/src/modules/exit-node/useHasExitNodes.tsx @@ -3,7 +3,12 @@ import { useLoggedInUser } from "@/contexts/UsersProvider"; import { Peer } from "@/interfaces/Peer"; import { Route } from "@/interfaces/Route"; -export const useHasExitNodes = (peer?: Peer) => { +export interface ExitNodeInfo { + hasExitNode: boolean; + skipAutoApply?: boolean; +} + +export const useHasExitNodes = (peer?: Peer): ExitNodeInfo => { const { isOwnerOrAdmin } = useLoggedInUser(); const { data: routes } = useFetchApi( `/routes`, @@ -11,9 +16,17 @@ export const useHasExitNodes = (peer?: Peer) => { true, isOwnerOrAdmin, ); - return peer - ? routes?.some( - (route) => route?.peer === peer.id && route?.network === "0.0.0.0/0", - ) || false - : false; + + if (!peer || !routes) { + return { hasExitNode: false }; + } + + const exitNodeRoute = routes.find( + (route) => route?.peer === peer.id && route?.network === "0.0.0.0/0", + ); + + return { + hasExitNode: !!exitNodeRoute, + skipAutoApply: exitNodeRoute?.skip_auto_apply, + }; }; diff --git a/src/modules/peer/PeerNetworkRoutesSection.tsx b/src/modules/peer/PeerNetworkRoutesSection.tsx index 7da2d8a7..99329f65 100644 --- a/src/modules/peer/PeerNetworkRoutesSection.tsx +++ b/src/modules/peer/PeerNetworkRoutesSection.tsx @@ -17,7 +17,7 @@ type Props = { export const PeerNetworkRoutesSection = ({ peer }: Props) => { const { peerRoutes, isLoading } = usePeerRoutes({ peer }); - const hasExitNodes = useHasExitNodes(peer); + const exitNodeInfo = useHasExitNodes(peer); const { ref: headingRef, portalTarget } = usePortalElement(); @@ -34,7 +34,7 @@ export const PeerNetworkRoutesSection = ({ peer }: Props) => {
- +
diff --git a/src/modules/routes/RouteAutoApplyCell.tsx b/src/modules/routes/RouteAutoApplyCell.tsx new file mode 100644 index 00000000..bfa3490d --- /dev/null +++ b/src/modules/routes/RouteAutoApplyCell.tsx @@ -0,0 +1,53 @@ +import { ToggleSwitch } from "@components/ToggleSwitch"; +import React, { useMemo } from "react"; +import { useSWRConfig } from "swr"; +import { usePermissions } from "@/contexts/PermissionsProvider"; +import { useRoutes } from "@/contexts/RoutesProvider"; +import { Route } from "@/interfaces/Route"; + +type Props = { + route: Route; +}; + +export default function RouteAutoApplyCell({ route }: Readonly) { + const { permission } = usePermissions(); + const { updateRoute } = useRoutes(); + const { mutate } = useSWRConfig(); + + const isExitNode = useMemo(() => route.network === "0.0.0.0/0", [route]); + + const isChecked = useMemo(() => { + // Checked means Auto Apply is ON, which maps to skip_auto_apply === false + return route.skip_auto_apply === false; + }, [route]); + + const update = async (checked: boolean) => { + // When toggled ON (checked === true), we want skip_auto_apply = false + const nextSkipAutoApply = !checked; + updateRoute( + route, + { skip_auto_apply: nextSkipAutoApply }, + () => { + mutate("/routes"); + }, + checked + ? "Auto Apply was enabled for the route" + : "Auto Apply was disabled for the route", + ); + }; + + if (!isExitNode) return null; + + return ( +
+ update(!isChecked)} + disabled={!permission.routes.update} + /> +
+ ); +} + + diff --git a/src/modules/routes/RouteModal.tsx b/src/modules/routes/RouteModal.tsx index 2763fa99..1a167624 100644 --- a/src/modules/routes/RouteModal.tsx +++ b/src/modules/routes/RouteModal.tsx @@ -227,6 +227,7 @@ export function RouteModalContent({ const [enabled, setEnabled] = useState(true); const [metric, setMetric] = useState("9999"); const [masquerade, setMasquerade] = useState(true); + const [isForced, setIsForced] = useState(true); const isNonLinuxRoutingPeer = useMemo(() => { if (!routingPeer) return false; @@ -304,6 +305,7 @@ export function RouteModalContent({ masquerade: useSinglePeer && isNonLinuxRoutingPeer ? true : masquerade, groups: groupIds, access_control_groups: accessControlGroupIds || undefined, + skip_auto_apply: !isForced, }, onSuccess, ); @@ -718,6 +720,20 @@ export function RouteModalContent({ helpText={"Use this switch to enable or disable the route."} /> + {exitNode && ( + + + Auto Apply Route + + } + helpText={"Automatically apply this exit node to your distribution groups. This requires NetBird client v0.55.0 or higher."} + /> + )} + {!exitNode && ( [] = [ }, cell: ({ row }) => , }, + { + id: "skipAutoApply", + accessorKey: "skip_auto_apply", + header: ({ column }) => { + return Auto Apply; + }, + cell: ({ row }) => , + sortingFn: "basic", + }, { id: "group_names", accessorFn: (row) => { @@ -104,6 +114,10 @@ export default function RouteTable({ row }: Props) { desc: true, }, ]); + + const hasAtLeastOneExitNode = useMemo(() => { + return row.routes?.some((route) => route.network === "0.0.0.0/0"); + }, [row.routes]); const data = useMemo(() => { if (!row.routes) return []; @@ -144,6 +158,7 @@ export default function RouteTable({ row }: Props) { domains: false, domain_search: false, network: false, + skipAutoApply: !!hasAtLeastOneExitNode, }} setSorting={setSorting} columns={RouteTableColumns} diff --git a/src/modules/routes/RouteUpdateModal.tsx b/src/modules/routes/RouteUpdateModal.tsx index 5b18d425..4324e31a 100644 --- a/src/modules/routes/RouteUpdateModal.tsx +++ b/src/modules/routes/RouteUpdateModal.tsx @@ -20,6 +20,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@components/Tabs"; import { Textarea } from "@components/Textarea"; import { DomainsTooltip } from "@components/ui/DomainListBadge"; import { getOperatingSystem } from "@hooks/useOperatingSystem"; +import { IconDirectionSign } from "@tabler/icons-react"; import { cn } from "@utils/helpers"; import { uniqBy } from "lodash"; import { @@ -193,6 +194,7 @@ function RouteUpdateModalContent({ onSuccess, route, cell }: ModalProps) { const [masquerade, setMasquerade] = useState( route?.masquerade ?? true, ); + const [isForced, setIsForced] = useState(route?.skip_auto_apply === false); // Refs to manage focus on tab change const networkRangeRef = useRef(null); @@ -257,6 +259,7 @@ function RouteUpdateModalContent({ onSuccess, route, cell }: ModalProps) { masquerade: useSinglePeer && isNonLinuxRoutingPeer ? true : masquerade, groups: groupIds, access_control_groups: accessControlGroupIds || undefined, + skip_auto_apply: !isForced, }, (r) => { onSuccess && onSuccess(r); @@ -456,6 +459,21 @@ function RouteUpdateModalContent({ onSuccess, route, cell }: ModalProps) { } helpText={"Use this switch to enable or disable the route."} /> + + {isExitNode && ( + + + Auto Apply Route + + } + helpText={"Automatically apply this exit node to your distribution groups. This requires NetBird client v0.55.0 or higher."} + /> + )} + {!isExitNode && (