diff --git a/package.json b/package.json index c767a31..5bb2105 100644 --- a/package.json +++ b/package.json @@ -53,7 +53,7 @@ "framer-motion": "^10.16.4", "ip-cidr": "^3.1.0", "lodash": "^4.17.21", - "lucide-react": "^0.287.0", + "lucide-react": "^0.383.0", "next": "13.5.5", "next-themes": "^0.2.1", "punycode": "^2.3.1", diff --git a/src/app/(dashboard)/peer/page.tsx b/src/app/(dashboard)/peer/page.tsx index bda0bac..f830f7a 100644 --- a/src/app/(dashboard)/peer/page.tsx +++ b/src/app/(dashboard)/peer/page.tsx @@ -23,6 +23,7 @@ import Separator from "@components/Separator"; import FullScreenLoading from "@components/ui/FullScreenLoading"; import LoginExpiredBadge from "@components/ui/LoginExpiredBadge"; import TextWithTooltip from "@components/ui/TextWithTooltip"; +import useRedirect from "@hooks/useRedirect"; import { IconCloudLock, IconInfoCircle } from "@tabler/icons-react"; import useFetchApi from "@utils/api"; import dayjs from "dayjs"; @@ -66,8 +67,11 @@ import PeerRoutesTable from "@/modules/peer/PeerRoutesTable"; export default function PeerPage() { const queryParameter = useSearchParams(); const peerId = queryParameter.get("id"); - const { data: peer } = useFetchApi("/peers/" + peerId); - return peer ? ( + const { data: peer, isLoading } = useFetchApi("/peers/" + peerId, true); + + useRedirect("/peers", false, !peerId); + + return peer && !isLoading ? ( diff --git a/src/app/(dashboard)/team/user/page.tsx b/src/app/(dashboard)/team/user/page.tsx index df56117..62320f1 100644 --- a/src/app/(dashboard)/team/user/page.tsx +++ b/src/app/(dashboard)/team/user/page.tsx @@ -10,6 +10,7 @@ import Paragraph from "@components/Paragraph"; import { PeerGroupSelector } from "@components/PeerGroupSelector"; import Separator from "@components/Separator"; import FullScreenLoading from "@components/ui/FullScreenLoading"; +import useRedirect from "@hooks/useRedirect"; import { IconCirclePlus, IconSettings2 } from "@tabler/icons-react"; import useFetchApi, { useApiCall } from "@utils/api"; import { generateColorFromString } from "@utils/helpers"; @@ -42,6 +43,8 @@ export default function UserPage() { return users?.find((u) => u.id === userId); }, [users, userId]); + useRedirect("/team/users", false, !userId); + return !isLoading && user ? ( ) : ( diff --git a/src/app/not-found.tsx b/src/app/not-found.tsx index 58f7f98..b2afb48 100644 --- a/src/app/not-found.tsx +++ b/src/app/not-found.tsx @@ -35,6 +35,6 @@ export default function NotFound() { } const Redirect = ({ url, queryParams }: Props) => { - useRedirect(url == "/" ? "/peers" : url + (queryParams && `?${queryParams}`)); + useRedirect("/peers" + (queryParams && `?${queryParams}`)); return ; }; diff --git a/src/components/NetworkRouteSelector.tsx b/src/components/NetworkRouteSelector.tsx index c92c3d0..eac7a48 100644 --- a/src/components/NetworkRouteSelector.tsx +++ b/src/components/NetworkRouteSelector.tsx @@ -1,4 +1,5 @@ import { CommandItem } from "@components/Command"; +import FullTooltip from "@components/FullTooltip"; import { Popover, PopoverContent, PopoverTrigger } from "@components/Popover"; import { IconArrowBack } from "@tabler/icons-react"; import useFetchApi from "@utils/api"; @@ -62,8 +63,13 @@ export function NetworkRouteSelector({ const isSearching = search.length > 0; const found = dropdownOptions.filter((item) => { + const hasDomains = item?.domains ? item.domains.length > 0 : false; + const domains = + hasDomains && item?.domains ? item?.domains.join(" ") : ""; return ( - item.network_id.includes(search) || item.network.includes(search) + item.network_id.includes(search) || + item.network?.includes(search) || + domains.includes(search) ); }).length > 0; return isSearching && !found; @@ -117,6 +123,7 @@ export function NetworkRouteSelector({ > {value.network} + ) : ( Select an existing network... @@ -208,7 +215,11 @@ export function NetworkRouteSelector({ return ( { togglePeer(option); setOpen(false); @@ -226,6 +237,7 @@ export function NetworkRouteSelector({ > {option.network} + ); })} @@ -238,3 +250,19 @@ export function NetworkRouteSelector({ ); } + +function DomainList({ domains }: { domains?: string[] }) { + const firstDomain = domains ? domains[0] : ""; + return ( + domains && + domains.length > 0 && ( + {domains.join(", ")}} + > +
+ {firstDomain} {domains.length > 1 && "+" + (domains.length - 1)} +
+
+ ) + ); +} diff --git a/src/components/modal/ModalHeader.tsx b/src/components/modal/ModalHeader.tsx index 251febc..0da6a26 100644 --- a/src/components/modal/ModalHeader.tsx +++ b/src/components/modal/ModalHeader.tsx @@ -10,6 +10,7 @@ interface Props extends IconVariant { className?: string; margin?: string; truncate?: boolean; + children?: React.ReactNode; } export default function ModalHeader({ icon, @@ -19,6 +20,7 @@ export default function ModalHeader({ className = "pb-6 px-8", margin = "mt-0", truncate = false, + children, }: Props) { return (
@@ -26,11 +28,15 @@ export default function ModalHeader({ {icon && }

{title}

- - {description} - + {children ? ( + <>{children} + ) : ( + + {description} + + )}
diff --git a/src/components/ui/DomainListBadge.tsx b/src/components/ui/DomainListBadge.tsx new file mode 100644 index 0000000..a3be9d4 --- /dev/null +++ b/src/components/ui/DomainListBadge.tsx @@ -0,0 +1,70 @@ +import Badge from "@components/Badge"; +import FullTooltip from "@components/FullTooltip"; +import { GlobeIcon } from "lucide-react"; +import * as React from "react"; + +type Props = { + domains: string[]; +}; +export const DomainListBadge = ({ domains }: Props) => { + const firstDomain = domains.length > 0 ? domains[0] : undefined; + + return ( + +
+ {firstDomain && ( + + + {firstDomain} + + )} + {domains && domains.length > 1 && ( + + {domains.length - 1} + )} +
+
+ ); +}; + +export const DomainsTooltip = ({ + domains, + children, + className, +}: { + domains: string[]; + children: React.ReactNode; + className?: string; +}) => { + return ( + + {domains.map((domain) => { + return ( + domain && ( +
+
+ + {domain} +
+
+ ) + ); + })} + + } + disabled={domains.length <= 1} + > + {children} +
+ ); +}; diff --git a/src/components/ui/InputDomain.tsx b/src/components/ui/InputDomain.tsx new file mode 100644 index 0000000..3db878a --- /dev/null +++ b/src/components/ui/InputDomain.tsx @@ -0,0 +1,88 @@ +import Button from "@components/Button"; +import { Input } from "@components/Input"; +import { validator } from "@utils/helpers"; +import { uniqueId } from "lodash"; +import { GlobeIcon, MinusCircleIcon } from "lucide-react"; +import * as React from "react"; +import { useEffect, useMemo, useState } from "react"; +import { Domain } from "@/interfaces/Domain"; + +type Props = { + value: Domain; + onChange: (d: Domain) => void; + onRemove: () => void; + onError?: (error: boolean) => void; + error?: string; +}; +enum ActionType { + ADD = "ADD", + REMOVE = "REMOVE", + UPDATE = "UPDATE", +} + +export const domainReducer = (state: Domain[], action: any): Domain[] => { + switch (action.type) { + case ActionType.ADD: + return [...state, { name: "", id: uniqueId("domain") }]; + case ActionType.REMOVE: + return state.filter((_, i) => i !== action.index); + case ActionType.UPDATE: + return state.map((n, i) => (i === action.index ? action.d : n)); + default: + return state; + } +}; + +export default function InputDomain({ + value, + onChange, + onRemove, + onError, +}: Readonly) { + const [name, setName] = useState(value?.name || ""); + + const handleNameChange = (e: React.ChangeEvent) => { + setName(e.target.value); + onChange({ ...value, name: e.target.value }); + }; + + const domainError = useMemo(() => { + if (name == "") { + return ""; + } + const valid = validator.isValidDomain(name); + if (!valid) { + return "Please enter a valid domain, e.g. example.com or intra.example.com"; + } + }, [name]); + + useEffect(() => { + const hasError = domainError !== "" && domainError !== undefined; + onError?.(hasError); + return () => onError?.(false); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [domainError]); + + return ( +
+
+ } + placeholder={"e.g., example.com"} + maxWidthClass={"w-full"} + value={name} + error={domainError} + onChange={handleNameChange} + /> +
+ + +
+ ); +} diff --git a/src/contexts/RoutesProvider.tsx b/src/contexts/RoutesProvider.tsx index 60a160f..9ae1095 100644 --- a/src/contexts/RoutesProvider.tsx +++ b/src/contexts/RoutesProvider.tsx @@ -34,6 +34,8 @@ export default function RoutesProvider({ children }: Props) { onSuccess?: (route: Route) => void, message?: string, ) => { + const hasDomains = route.domains ? route.domains.length > 0 : false; + notify({ title: "Network " + route.network_id + "-" + route.network, description: message @@ -48,7 +50,9 @@ export default function RoutesProvider({ children }: Props) { peer: toUpdate.peer ?? (route.peer || undefined), peer_groups: toUpdate.peer_groups ?? (route.peer_groups || undefined), - network: route.network, + network: !hasDomains ? route.network : undefined, + domains: hasDomains ? route.domains : undefined, + keep_route: route.keep_route, metric: toUpdate.metric ?? route.metric ?? 9999, masquerade: toUpdate.masquerade ?? route.masquerade ?? true, groups: toUpdate.groups ?? route.groups ?? [], @@ -80,7 +84,9 @@ export default function RoutesProvider({ children }: Props) { enabled: route.enabled, peer: route.peer || undefined, peer_groups: route.peer_groups || undefined, - network: route.network, + network: route?.network || undefined, + domains: route?.domains || undefined, + keep_route: route?.keep_route || false, metric: route.metric || 9999, masquerade: route.masquerade, groups: route.groups || [], diff --git a/src/hooks/useOperatingSystem.ts b/src/hooks/useOperatingSystem.ts index 6eb0dc9..8ca85e7 100644 --- a/src/hooks/useOperatingSystem.ts +++ b/src/hooks/useOperatingSystem.ts @@ -19,6 +19,8 @@ export const getOperatingSystem = (os: string) => { if (os.toLowerCase().includes("android")) return OperatingSystem.ANDROID as const; if (os.toLowerCase().includes("ios")) return OperatingSystem.IOS as const; + if (os.toLowerCase().includes("ipad")) return OperatingSystem.IOS as const; + if (os.toLowerCase().includes("iphone")) return OperatingSystem.IOS as const; if (os.toLowerCase().includes("windows")) return OperatingSystem.WINDOWS as const; return OperatingSystem.LINUX as const; diff --git a/src/hooks/useRedirect.tsx b/src/hooks/useRedirect.tsx index 51dd557..4be09fe 100644 --- a/src/hooks/useRedirect.tsx +++ b/src/hooks/useRedirect.tsx @@ -1,8 +1,9 @@ import loadConfig from "@utils/config"; import { usePathname, useRouter } from "next/navigation"; -import { useEffect } from "react"; +import { useEffect, useRef } from "react"; const config = loadConfig(); + export const useRedirect = ( url: string, replace: boolean = false, @@ -10,24 +11,43 @@ export const useRedirect = ( ) => { const router = useRouter(); const currentPath = usePathname(); - const callBackUrls = [config.redirectURI, config.silentRedirectURI]; + const callBackUrls = useRef([config.redirectURI, config.silentRedirectURI]); + const isRedirecting = useRef(false); + const intervalRef = useRef(null); useEffect(() => { - if (!enable) return; - if (callBackUrls.includes(url)) return; // Don't redirect to the callback urls to avoid infinite loop - if (url === currentPath) return; // Don't redirect to the current page + // If redirect is disabled or the url is already in the callback urls or the url is the current path then do not redirect + if (!enable || callBackUrls.current.includes(url) || url === currentPath) + return; - const redirect = replace ? router.replace : router.push; // Replace the current history or add a new one + const performRedirect = () => { + if (!isRedirecting.current) { + isRedirecting.current = true; + router.refresh(); + if (replace) { + router.replace(url); + } else { + router.push(url); + } + isRedirecting.current = false; + } + }; - router.refresh(); - redirect(url); + performRedirect(); - // Timer in case the user has his browser tab open but not focused - const interval = setInterval(() => { - router.refresh(); - redirect(url); - }, 1000); + // Try to redirect after 1.25 seconds if for whatever reason the redirect did not happen (network change, browser tab open but not focused etc.) + intervalRef.current = setInterval(() => { + if (!isRedirecting.current) { + performRedirect(); + } + }, 1250); - return () => clearInterval(interval); - }, [replace, router, url, enable]); + return () => { + if (intervalRef.current) { + clearInterval(intervalRef.current); + } + }; + }, [replace, router, url, enable, currentPath]); }; + +export default useRedirect; diff --git a/src/interfaces/Domain.ts b/src/interfaces/Domain.ts new file mode 100644 index 0000000..2849ec0 --- /dev/null +++ b/src/interfaces/Domain.ts @@ -0,0 +1,4 @@ +export interface Domain { + id?: string; + name: string; +} diff --git a/src/interfaces/Nameserver.ts b/src/interfaces/Nameserver.ts index 50ad75c..cee49f3 100644 --- a/src/interfaces/Nameserver.ts +++ b/src/interfaces/Nameserver.ts @@ -17,11 +17,6 @@ export interface Nameserver { id?: string; } -export interface Domain { - id?: string; - name: string; -} - export const NameserverPresets: Record = { Default: { name: "", diff --git a/src/interfaces/Route.ts b/src/interfaces/Route.ts index 3da4e6f..7c919fe 100644 --- a/src/interfaces/Route.ts +++ b/src/interfaces/Route.ts @@ -3,26 +3,34 @@ export interface Route { description: string; enabled: boolean; peer?: string; - network: string; + network?: string; + domains?: string[]; network_id: string; network_type?: string; metric?: number; masquerade: boolean; groups: string[]; + keep_route?: boolean; + // Frontend only peer_groups?: string[]; routesGroups?: string[]; groupedRoutes?: GroupedRoute[]; group_names?: string[]; + domain_search?: string; } export interface GroupedRoute { id: string; enabled: boolean; - network: string; + network?: string; + domains?: string[]; + keep_route?: boolean; network_id: string; high_availability_count: number; is_using_route_groups: boolean; routes?: Route[]; group_names?: string[]; description?: string; + description_search?: string; + domain_search?: string; } diff --git a/src/modules/activity/ActivityDescription.tsx b/src/modules/activity/ActivityDescription.tsx index 78b8902..f845dbd 100644 --- a/src/modules/activity/ActivityDescription.tsx +++ b/src/modules/activity/ActivityDescription.tsx @@ -3,7 +3,11 @@ import { Label } from "@components/Label"; import { IconInfoCircle } from "@tabler/icons-react"; import { cn } from "@utils/helpers"; import { isLocalDev, isProduction } from "@utils/netbird"; +import { isEmpty } from "lodash"; +import { GlobeIcon } from "lucide-react"; import React, { useMemo } from "react"; +import RoundedFlag from "@/assets/countries/RoundedFlag"; +import { useCountries } from "@/contexts/CountryProvider"; import { ActivityEvent } from "@/interfaces/ActivityEvent"; type Props = { @@ -54,7 +58,8 @@ export default function ActivityDescription({ event }: Props) { if (event.activity_code == "setupkey.peer.add") return (
- Peer {m.name} with ip {m.ip} was added + Peer {m.name} was added + with the NetBird IP {m.ip}
); @@ -113,29 +118,38 @@ export default function ActivityDescription({ event }: Props) { * Route */ - if (event.activity_code == "route.delete") + if (event.activity_code == "route.delete") { + let hasDomains = m?.domains && m?.domains.length > 0; return (
- Route {m.name} with the {m.network_range}{" "} - range was deleted + Route {m.name} with the {hasDomains ? "domain(s)" : ""}{" "} + {hasDomains ? m?.domains : m.network_range}{" "} + {hasDomains ? "" : "range"} was deleted
); + } - if (event.activity_code == "route.update") + if (event.activity_code == "route.update") { + let hasDomains = m?.domains && m?.domains.length > 0; return (
- Route {m.name} with the {m.network_range}{" "} - range was updated + Route {m.name} with the {hasDomains ? "domain(s)" : ""}{" "} + {hasDomains ? m?.domains : m.network_range}{" "} + {hasDomains ? "" : "range"} was updated
); + } - if (event.activity_code == "route.add") + if (event.activity_code == "route.add") { + let hasDomains = m?.domains && m?.domains.length > 0; return (
- Route {m.name} with the {m.network_range}{" "} - range was created + Route {m.name} with the {hasDomains ? "domain(s)" : ""}{" "} + {hasDomains ? m?.domains : m.network_range}{" "} + {hasDomains ? "" : "range"} was created
); + } /** * User @@ -144,21 +158,24 @@ export default function ActivityDescription({ event }: Props) { if (event.activity_code == "user.peer.delete") return (
- Peer {m.name} with ip {m.ip} was deleted + Peer {m.name} with + NetBird IP {m.ip} was deleted
); if (event.activity_code == "user.peer.add") return (
- Peer {m.name} with ip {m.ip} was added + Peer {m.name} was added + with the NetBird IP {m.ip}
); if (event.activity_code == "user.peer.update") return (
- Peer {m.name} with ip {m.ip} was updated + Peer {m.name} with + NetBird IP {m.ip} was updated
); @@ -252,15 +269,15 @@ export default function ActivityDescription({ event }: Props) { if (event.activity_code == "peer.group.delete") return (
- Group {m.group} was removed from the peer with the ip{" "} - {m.peer_ip} + Group {m.group} was removed from the peer with the + NetBird IP {m.peer_ip}
); if (event.activity_code == "peer.group.add") return (
- Group {m.group} was added to the peer with the ip{" "} + Group {m.group} was added to the peer with the NetBird IP{" "} {m.peer_ip}
); @@ -303,7 +320,7 @@ export default function ActivityDescription({ event }: Props) { if (event.activity_code == "peer.rename") return (
- Peer with the ip {m.ip} was renamed to{" "} + Peer with the NetBird IP {m.ip} was renamed to{" "} {m.name}
); @@ -311,7 +328,7 @@ export default function ActivityDescription({ event }: Props) { if (event.activity_code == "peer.approve") return (
- Peer with the ip {m.ip} was approved + Peer with the NetBird IP {m.ip} was approved
); @@ -559,7 +576,7 @@ function Value({ return children ? ( @@ -567,3 +584,40 @@ function Value({ ) : null; } + +function PeerConnectionInfo({ meta }: { meta: any }) { + const hasMeta = + !isEmpty(meta?.location_country_code) || + !isEmpty(meta?.location_connection_ip); + const { countries } = useCountries(); + + const countryText = useMemo(() => { + if (!countries) return "Unknown"; + const country = countries.find( + (c) => c.country_code === meta?.location_country_code, + ); + if (!country) return "Unknown"; + if (!meta?.location_city_name) return country.country_name; + return `${country.country_name}, ${meta?.location_city_name}`; + }, [countries, meta]); + + return hasMeta ? ( + <> + {" "} + from{" "} + {meta?.location_connection_ip && ( + {meta?.location_connection_ip} + )}{" "} + {meta?.location_country_code && ( + + {isEmpty(meta?.location_country_code) ? ( + + ) : ( + + )} + {countryText} + + )} + + ) : null; +} diff --git a/src/modules/dns-nameservers/NameserverModal.tsx b/src/modules/dns-nameservers/NameserverModal.tsx index a7ec763..c9eddfa 100644 --- a/src/modules/dns-nameservers/NameserverModal.tsx +++ b/src/modules/dns-nameservers/NameserverModal.tsx @@ -17,8 +17,9 @@ import Paragraph from "@components/Paragraph"; import { PeerGroupSelector } from "@components/PeerGroupSelector"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@components/Tabs"; import { Textarea } from "@components/Textarea"; +import InputDomain, { domainReducer } from "@components/ui/InputDomain"; import { useApiCall } from "@utils/api"; -import { cn, validator } from "@utils/helpers"; +import { cn } from "@utils/helpers"; import cidr from "ip-cidr"; import { uniqueId } from "lodash"; import { @@ -35,7 +36,7 @@ import { import React, { useEffect, useMemo, useReducer, useState } from "react"; import { useSWRConfig } from "swr"; import DNSIcon from "@/assets/icons/DNSIcon"; -import { Domain, Nameserver, NameserverGroup } from "@/interfaces/Nameserver"; +import { Nameserver, NameserverGroup } from "@/interfaces/Nameserver"; import useGroupHelper from "@/modules/groups/useGroupHelper"; type Props = { @@ -97,19 +98,6 @@ enum ActionType { UPDATE = "UPDATE", } -export const domainReducer = (state: Domain[], action: any) => { - switch (action.type) { - case ActionType.ADD: - return [...state, { name: "", id: uniqueId("ns") }]; - case ActionType.REMOVE: - return state.filter((_, i) => i !== action.index); - case ActionType.UPDATE: - return state.map((n, i) => (i === action.index ? action.d : n)); - default: - return state; - } -}; - export function NameserverModalContent({ onSuccess, preset, @@ -199,7 +187,7 @@ export function NameserverModalContent({ // Domains const [domains, setDomains] = useReducer(domainReducer, [], () => { if (preset?.domains?.length) { - return preset.domains.map((d) => ({ name: d, id: uniqueId("ns") })); + return preset.domains.map((d) => ({ name: d, id: uniqueId("domain") })); } return []; }); @@ -370,7 +358,7 @@ export function NameserverModalContent({
{domains.map((domain, i) => { return ( - @@ -619,63 +607,3 @@ function NameserverInput({
); } - -function DomainInput({ - value, - onChange, - onRemove, - onError, -}: { - value: Domain; - onChange: (d: Domain) => void; - onRemove: () => void; - onError?: (error: boolean) => void; - error?: string; -}) { - const [name, setName] = useState(value.name); - - const handleNameChange = (e: React.ChangeEvent) => { - setName(e.target.value); - onChange({ ...value, name: e.target.value }); - }; - - const domainError = useMemo(() => { - if (name == "") { - return ""; - } - const valid = validator.isValidDomain(name); - if (!valid) { - onError && onError(true); - return "Please enter a valid domain, e.g. example.com or intra.example.com"; - } - onError && onError(false); - }, [name, onError]); - - useEffect(() => { - return () => onError && onError(false); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - return ( -
-
- } - placeholder={"e.g., example.com"} - maxWidthClass={"w-full"} - value={name} - error={domainError} - onChange={handleNameChange} - /> -
- - -
- ); -} diff --git a/src/modules/exit-node/AddExitNodeButton.tsx b/src/modules/exit-node/AddExitNodeButton.tsx index eac5495..96287da 100644 --- a/src/modules/exit-node/AddExitNodeButton.tsx +++ b/src/modules/exit-node/AddExitNodeButton.tsx @@ -26,7 +26,7 @@ export const AddExitNodeButton = ({ peer, firstTime = false }: Props) => { ) : ( <> - Setup Exit Node + Set Up Exit Node )} diff --git a/src/modules/exit-node/ExitNodeDropdownButton.tsx b/src/modules/exit-node/ExitNodeDropdownButton.tsx index 20ffa8a..17a451c 100644 --- a/src/modules/exit-node/ExitNodeDropdownButton.tsx +++ b/src/modules/exit-node/ExitNodeDropdownButton.tsx @@ -1,11 +1,13 @@ import { DropdownMenuItem } from "@components/DropdownMenu"; import { Modal } from "@components/modal/Modal"; import { getOperatingSystem } from "@hooks/useOperatingSystem"; -import { IconDirectionSign } from "@tabler/icons-react"; +import { IconCirclePlus, IconDirectionSign } from "@tabler/icons-react"; import * as React from "react"; import { useState } from "react"; +import RoutesProvider from "@/contexts/RoutesProvider"; import { OperatingSystem } from "@/interfaces/OperatingSystem"; import { Peer } from "@/interfaces/Peer"; +import { useHasExitNodes } from "@/modules/exit-node/useHasExitNodes"; import { RouteModalContent } from "@/modules/routes/RouteModal"; type Props = { @@ -15,24 +17,41 @@ type Props = { export const ExitNodeDropdownButton = ({ peer }: Props) => { const [modal, setModal] = useState(false); const isLinux = getOperatingSystem(peer.os) === OperatingSystem.LINUX; + const hasExitNodes = useHasExitNodes(peer); return isLinux ? ( <> setModal(true)}>
- -
- Add Exit Node -
+ {hasExitNodes ? ( + <> + +
+ Add Exit Node +
+ + ) : ( + <> + +
+ Set Up Exit Node +
+ + )}
{modal && ( - setModal(false)} - peer={peer} - exitNode={true} - /> + + setModal(false)} + peer={peer} + exitNode={true} + /> + )} diff --git a/src/modules/peer/PeerRoutesTable.tsx b/src/modules/peer/PeerRoutesTable.tsx index 0c3bbe3..cab3ad1 100644 --- a/src/modules/peer/PeerRoutesTable.tsx +++ b/src/modules/peer/PeerRoutesTable.tsx @@ -12,8 +12,8 @@ import { Route } from "@/interfaces/Route"; import PeerRouteActionCell from "@/modules/peer/PeerRouteActionCell"; 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 GroupedRouteNetworkRangeCell from "@/modules/route-group/GroupedRouteNetworkRangeCell"; import RouteDistributionGroupsCell from "@/modules/routes/RouteDistributionGroupsCell"; type Props = { @@ -32,9 +32,14 @@ export const RouteTableColumns: ColumnDef[] = [ { accessorKey: "network", header: ({ column }) => { - return Network Range; + return Network; }, - cell: ({ row }) => , + cell: ({ row }) => ( + + ), }, { id: "groups", diff --git a/src/modules/route-group/GroupedRouteNetworkRangeCell.tsx b/src/modules/route-group/GroupedRouteNetworkRangeCell.tsx index e0b8218..3b3faaa 100644 --- a/src/modules/route-group/GroupedRouteNetworkRangeCell.tsx +++ b/src/modules/route-group/GroupedRouteNetworkRangeCell.tsx @@ -1,15 +1,23 @@ +import { DomainListBadge } from "@components/ui/DomainListBadge"; 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; + network?: string; + domains?: string[]; }; -export default function GroupedRouteNetworkRangeCell({ network }: Props) { +export default function GroupedRouteNetworkRangeCell({ + network, + domains, +}: Props) { const isExitNode = network === "0.0.0.0/0"; + const hasDomains = domains ? domains.length > 0 : false; - return isExitNode ? ( + return hasDomains && domains ? ( + + ) : isExitNode ? (
diff --git a/src/modules/route-group/NetworkRoutesTable.tsx b/src/modules/route-group/NetworkRoutesTable.tsx index f5e2649..582c7e4 100644 --- a/src/modules/route-group/NetworkRoutesTable.tsx +++ b/src/modules/route-group/NetworkRoutesTable.tsx @@ -39,6 +39,14 @@ export const GroupedRouteTableColumns: ColumnDef[] = [ accessorKey: "description", sortingFn: "text", }, + { + accessorKey: "description_search", + sortingFn: "text", + }, + { + accessorKey: "domain_search", + sortingFn: "text", + }, { id: "enabled", accessorKey: "enabled", @@ -50,13 +58,22 @@ export const GroupedRouteTableColumns: ColumnDef[] = [ return row.group_names?.map((name) => name).join(", "); }, }, + { + id: "domains", + accessorFn: (row) => { + return row.domains?.map((name) => name).join(", "); + }, + }, { accessorKey: "network", header: ({ column }) => { - return Network Range; + return Network; }, cell: ({ row }) => ( - + ), }, { @@ -132,7 +149,10 @@ export default function NetworkRoutesTable({ columnVisibility={{ enabled: false, description: false, + description_search: false, group_names: false, + domains: false, + domain_search: false, }} renderExpandedRow={(row) => { const data = cloneDeep(row); diff --git a/src/modules/route-group/useGroupedRoutes.tsx b/src/modules/route-group/useGroupedRoutes.tsx index 3b49a11..b30f3a9 100644 --- a/src/modules/route-group/useGroupedRoutes.tsx +++ b/src/modules/route-group/useGroupedRoutes.tsx @@ -53,15 +53,26 @@ export default function useGroupedRoutes({ routes }: Props) { }); const allGroupNames = [...peerGroupNames, ...distributionGroupNames]; + const hasDomains = routes[0].domains + ? routes[0].domains.length > 0 + : false; + + const childDescriptions = + routes?.map((r) => r?.description).join(", ") || ""; + const domainString = routes?.map((r) => r.domains?.join(", ")).join(", "); results.push({ id, enabled: routes.find((r) => r.enabled) != undefined, - network: routes[0].network, + network: !hasDomains ? routes[0].network : undefined, + domains: hasDomains ? routes[0].domains || undefined : undefined, + domain_search: domainString, + keep_route: routes[0].keep_route || false, network_id: routes[0].network_id, high_availability_count: allPeers, is_using_route_groups: !!groupPeerRoute, description: groupPeerRoute ? groupPeerRoute?.description : undefined, + description_search: childDescriptions, routes: routes, group_names: allGroupNames, }); diff --git a/src/modules/routes/RouteAddRoutingPeerModal.tsx b/src/modules/routes/RouteAddRoutingPeerModal.tsx index b03e7da..be98fc3 100644 --- a/src/modules/routes/RouteAddRoutingPeerModal.tsx +++ b/src/modules/routes/RouteAddRoutingPeerModal.tsx @@ -89,6 +89,13 @@ function Content({ onSuccess, groupedRoute, peer }: ModalProps) { .map((g) => g.id) .filter((id) => id !== undefined) as string[]; + let useRange = false; + if (routeNetwork?.domains) { + useRange = routeNetwork.domains.length <= 0; + } else { + useRange = true; + } + createRoute( { network_id: routeNetwork.network_id, @@ -96,7 +103,9 @@ function Content({ onSuccess, groupedRoute, peer }: ModalProps) { enabled: true, peer: routingPeer?.id || undefined, peer_groups: undefined, - network: routeNetwork.network, + network: useRange ? routeNetwork.network : undefined, + domains: useRange ? undefined : routeNetwork.domains, + keep_route: routeNetwork.keep_route || false, metric: 9999, masquerade: true, groups: groupIds, @@ -139,7 +148,7 @@ function Content({ onSuccess, groupedRoute, peer }: ModalProps) {
- Assign a single peer as a routing peer for the Network CIDR. + Assign a single peer as a routing peer for the network route. (false); + const [routeType, setRouteTyp] = useState("ip-range"); + const [keepRoute, setKeepRoute] = useState(true); + + const isMasqueradeDisabled = useMemo(() => { + if (exitNode) return true; + return routeType === "domains"; + }, [exitNode, routeType]); + + const isDomainOrRangeEntered = useMemo(() => { + if (routeType === "ip-range") return networkRange !== ""; + const isEmptyDomain = domainRoutes.some((d) => d.name === ""); + const isAtLeastOneDomain = domainRoutes.length > 0; + return !isEmptyDomain && isAtLeastOneDomain && !domainError; + }, [domainRoutes, routeType, networkRange, domainError]); + + // Enable Masquerade if domain route type is selected + useEffect(() => { + if (routeType === "domains") setMasquerade(true); + }, [routeType]); + /** * Distribution Groups */ @@ -142,6 +175,11 @@ export function RouteModalContent({ .filter((g) => g !== undefined) as string[]; const useSinglePeer = peerTab === "routing-peer"; + const domainRouteNames = + routeType === "domains" + ? domainRoutes.map((d) => d.name).filter((d) => d !== "") + : undefined; + const useKeepRoute = routeType === "domains" ? keepRoute : undefined; createRoute( { @@ -150,7 +188,9 @@ export function RouteModalContent({ enabled: enabled, peer: useSinglePeer ? routingPeer?.id : undefined, peer_groups: useSinglePeer ? undefined : peerGroups || undefined, - network: networkRange, + network: routeType === "ip-range" ? networkRange : undefined, + domains: domainRouteNames, + keep_route: useKeepRoute, metric: Number(metric) || 9999, masquerade: masquerade, groups: groupIds, @@ -184,7 +224,7 @@ export function RouteModalContent({ (peerTab === "peer-group" && routingPeerGroups.length == 0) || (peerTab === "routing-peer" && !routingPeer) || groups.length == 0 || - networkRange == "" + !isDomainOrRangeEntered ); }, [ cidrError, @@ -192,7 +232,7 @@ export function RouteModalContent({ routingPeerGroups.length, routingPeer, groups, - networkRange, + isDomainOrRangeEntered, ]); const networkIdentifierError = useMemo(() => { @@ -228,7 +268,7 @@ export function RouteModalContent({ title={ exitNode ? isFirstExitNode - ? "Setup Exit Node" + ? "Set Up Exit Node" : "Add Exit Node" : "Create New Route" } @@ -286,18 +326,136 @@ export function RouteModalContent({
- - 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)} - /> + + + Select your route type to add either a network range or a list + of domains. + +
+ + setRouteTyp("ip-range")} + className={"w-full"} + > + + Network Range + + setRouteTyp("domains")} + className={"w-full"} + > + + Domains + + +
+ +
+ + Add a private IPv4 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)} + /> +
+ +
+ + + Add domains that dynamically resolve to one or more IPv4 + addresses + +
+ {domainRoutes.length > 0 && ( +
+
+ {domainRoutes.map((domain, i) => { + return ( + + setDomainRoutes({ + type: "UPDATE", + index: i, + d, + }) + } + onError={setDomainError} + onRemove={() => + setDomainRoutes({ + type: "REMOVE", + index: i, + }) + } + /> + ); + })} +
+
+ )} + +
+
+ + DNS records for load-balanced systems often change. + Keeping resolved addresses ensures ongoing connections + to active resources remain uninterrupted. +
+ } + > + +
+ + Keep Routes + +
+ + } + helpText={ +
+ Retain previously resolved routes after IP address + updates to maintain stable connections. +
+ } + /> + +
+
+ {exitNode && peer ? ( <> ) : ( @@ -317,7 +475,7 @@ export function RouteModalContent({
Assign a single peer as a routing peer for the - {exitNode ? " exit node." : " Network CIDR."} + {exitNode ? " exit node." : " network route."} [] = [ sortingFn: "text", cell: ({ row }) => , }, + { + accessorKey: "description", + sortingFn: "text", + }, + { + accessorKey: "domain_search", + sortingFn: "text", + }, + { + id: "domains", + accessorFn: (row) => { + return row.domains?.map((name) => name).join(", "); + }, + }, { accessorKey: "metric", header: ({ column }) => { @@ -78,10 +91,6 @@ export default function RouteTable({ row }: Props) { }, ]); - const [editModal, setEditModal] = useState(false); - const [currentRow, setCurrentRow] = useState(); - const [currentCellClicked, setCurrentCellClicked] = useState(""); - const data = useMemo(() => { if (!row.routes) return []; // Get the group names for better search results @@ -95,23 +104,17 @@ export default function RouteTable({ row }: Props) { return groups?.find((g) => g.id === id)?.name || ""; }) || []; const allGroupNames = [...distributionGroupNames, ...peerGroupNames]; + const domainString = route?.domains?.join(", ") || ""; return { ...route, group_names: allGroupNames, + domain_search: domainString, } as Route; }); }, [row.routes, groups]); return ( <> - {editModal && currentRow && ( - - )} { - setCurrentRow(row.original); - setEditModal(true); - setCurrentCellClicked(cell); + description: false, + domains: false, + domain_search: false, }} setSorting={setSorting} columns={RouteTableColumns} diff --git a/src/modules/routes/RouteUpdateModal.tsx b/src/modules/routes/RouteUpdateModal.tsx index 18b86b4..8c3b8ac 100644 --- a/src/modules/routes/RouteUpdateModal.tsx +++ b/src/modules/routes/RouteUpdateModal.tsx @@ -18,6 +18,7 @@ import { PeerGroupSelector } from "@components/PeerGroupSelector"; import { PeerSelector } from "@components/PeerSelector"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@components/Tabs"; import { Textarea } from "@components/Textarea"; +import { DomainsTooltip } from "@components/ui/DomainListBadge"; import { cn } from "@utils/helpers"; import { uniqBy } from "lodash"; import { @@ -84,6 +85,23 @@ function RouteUpdateModalContent({ onSuccess, route, cell }: ModalProps) { // General const [description, setDescription] = useState(route.description || ""); + const isExitNode = useMemo(() => { + return route?.network === "0.0.0.0/0"; + }, [route]); + + const isUsingDomains = useMemo(() => { + try { + return route?.domains && route.domains.length > 0; + } catch (e) { + return false; + } + }, [route]); + + const routeType = useMemo(() => { + if (isUsingDomains) return "domains"; + return "ip-range"; + }, [isUsingDomains]); + // Network const [routingPeer, setRoutingPeer] = useState(() => { if (route.peer && peers) { @@ -92,6 +110,11 @@ function RouteUpdateModalContent({ onSuccess, route, cell }: ModalProps) { return undefined; }); + const isMasqueradeDisabled = useMemo(() => { + if (isExitNode) return true; + return routeType === "domains"; + }, [isExitNode, routeType]); + const initialRoutingPeerGroups = useMemo(() => { if (!route) return []; if (route?.peer_groups && allGroups) { @@ -217,14 +240,36 @@ function RouteUpdateModalContent({ onSuccess, route, cell }: ModalProps) { cell && cell == "metric" ? "settings" : "network", ); + const routeInfo = useMemo(() => { + let hasDomains = route?.domains ? route.domains.length > 0 : false; + try { + if (hasDomains && route?.domains) { + return route?.domains.join(", "); + } else { + return route.network; + } + } catch (e) { + return route.network; + } + }, [route]); + return ( } title={"Update " + route.network_id} - description={route.network} + description={routeInfo} color={"netbird"} - /> + truncate={true} + > + {route?.domains && ( + + + {routeInfo} + + + )} + setTab(v)}> @@ -269,7 +314,8 @@ function RouteUpdateModalContent({ onSuccess, route, cell }: ModalProps) {
- Assign a single peer as a routing peer for the Network CIDR. + Assign a single peer as a routing peer for the + {isExitNode ? " exit node." : " network route."} - Assign peer group with Linux machines to be used as routing - peers. + Assign a peer group with Linux machines to be used as + {isExitNode ? " exit nodes." : "routing peers."} - - - Masquerade - - } - helpText={ - "Allow access to your private networks without configuring routes on your local routers or other devices." - } - /> + {!isExitNode && ( + + + Masquerade + + } + helpText={ + "Allow access to your private networks without configuring routes on your local routers or other devices." + } + /> + )}
diff --git a/src/modules/setup-keys/SetupKeyKeyCell.tsx b/src/modules/setup-keys/SetupKeyKeyCell.tsx new file mode 100644 index 0000000..bb9fbd1 --- /dev/null +++ b/src/modules/setup-keys/SetupKeyKeyCell.tsx @@ -0,0 +1,16 @@ +import Badge from "@components/Badge"; +import React from "react"; + +type Props = { + text: string; +}; + +export default function SetupKeyKeyCell({ text }: Props) { + return ( +
+ + {text.substring(0, 5) + "****"} + +
+ ); +} diff --git a/src/modules/setup-keys/SetupKeyTypeCell.tsx b/src/modules/setup-keys/SetupKeyTypeCell.tsx new file mode 100644 index 0000000..63ef685 --- /dev/null +++ b/src/modules/setup-keys/SetupKeyTypeCell.tsx @@ -0,0 +1,24 @@ +import Badge from "@components/Badge"; +import { IconRepeat } from "@tabler/icons-react"; +import { Repeat1 } from "lucide-react"; + +type Props = { + reusable: boolean; +}; +export default function SetupKeyTypeCell({ reusable }: Props) { + return ( +
+ + {reusable ? ( + <> + Reusable + + ) : ( + <> + One-off + + )} + +
+ ); +} diff --git a/src/utils/helpers.ts b/src/utils/helpers.ts index 484ef65..3a18508 100644 --- a/src/utils/helpers.ts +++ b/src/utils/helpers.ts @@ -41,10 +41,20 @@ export const sleep = (ms: number) => { export const validator = { isValidDomain: (domain: string) => { - const regExp = - /^(?!.*\s)[a-zA-Z0-9](?!.*\s$)(?!.*\.$)(?:(?!-)[a-zA-Z0-9-]{1,63}(?= minMaxChars[0] && domain.length <= minMaxChars[1]; + const includesDot = domain.includes("."); + const hasNoWhitespace = !domain.includes(" "); + return ( + unicodeDomain.test(domain) && + includesDot && + hasNoWhitespace && + isValidDomainLength + ); } catch (e) { return false; }