Compare commits

..

5 Commits

Author SHA1 Message Date
Eduard Gert
3f943bb7d4 Use next/font/local instead of next/font/google (#376)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2024-04-19 17:12:56 +02:00
Eduard Gert
96b939e6cc Add changes from cloud repo to public one (#377)
* Remove unused files

* Update activity descriptions

* Update SelectDropdown

* Update redirect logic for / page

* Update HelpText.tsx

* Update wording for exit nodes
2024-04-19 17:12:37 +02:00
Eduard Gert
5e13548b81 Add better input validation for setup-keys, nameserver and routes (#373)
* Return the correct promise for errors

* Update icon

* Add better validation for routes

* Add better validation for DNS

* Add better validation for setup keys

* Merge exit nodes to input validation
2024-04-17 15:27:21 +02:00
Eduard Gert
2272a1d2a4 Add Exit Nodes (#374)
* Add exit node feature

* Fix spelling

* Hide masquerade for exit nodes

* Add exit node information to peers list

* Change exit node button, add indicator to peers table

* Add steps to route modal

* Add hook to check if peer has exit nodes

* Hide exit node indicator for regular users

* Add documentation links
2024-04-17 13:11:38 +02:00
Eduard Gert
fc3da50346 Add fallbacks for setup key name & setup key group names (#370)
* Add try catch block for global search

* Add fallback for group name

* Add fallback for setup key name

* Do not load setup key modal if it's not open

* Check if auto_groups actually exists for the setup keys

* Add fallback for group names in setup keys table

* Add fallback for group names in peers table
2024-04-11 16:42:27 +02:00
51 changed files with 776 additions and 327 deletions

View File

@@ -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 (
<PageContainer>
@@ -342,7 +345,8 @@ function PeerOverview() {
</Paragraph>
</div>
<div className={"inline-flex gap-4 justify-end"}>
<div>
<div className={"gap-4 flex"}>
<AddExitNodeButton peer={peer} firstTime={!hasExitNodes} />
<AddRouteDropdownButton />
</div>
</div>

View File

@@ -7,7 +7,7 @@ import SkeletonTable from "@components/skeletons/SkeletonTable";
import { RestrictedAccess } from "@components/ui/RestrictedAccess";
import useFetchApi from "@utils/api";
import { ExternalLinkIcon } from "lucide-react";
import React, { lazy, Suspense } from "react";
import React, { lazy, Suspense, useMemo } from "react";
import SetupKeysIcon from "@/assets/icons/SetupKeysIcon";
import { useGroups } from "@/contexts/GroupsProvider";
import { Group } from "@/interfaces/Group";
@@ -22,16 +22,21 @@ export default function SetupKeys() {
const { data: setupKeys, isLoading } = useFetchApi<SetupKey[]>("/setup-keys");
const { groups } = useGroups();
const setupKeysWithGroups = setupKeys?.map((setupKey) => {
if (!setupKey.auto_groups) return setupKey;
if (!groups) return setupKey;
return {
...setupKey,
groups: setupKey.auto_groups.map((group) => {
return groups.find((g) => g.id === group) || undefined;
}) as Group[] | undefined,
};
});
const setupKeysWithGroups = useMemo(() => {
if (!setupKeys) return [];
return setupKeys?.map((setupKey) => {
if (!setupKey.auto_groups) return setupKey;
if (!groups) return setupKey;
return {
...setupKey,
groups: setupKey.auto_groups
?.map((group) => {
return groups.find((g) => g.id === group) || undefined;
})
.filter((group) => group !== undefined) as Group[],
};
});
}, [setupKeys, groups]);
return (
<PageContainer>

View File

@@ -1,9 +1,41 @@
"use client";
import FullScreenLoading from "@components/ui/FullScreenLoading";
import { useLocalStorage } from "@hooks/useLocalStorage";
import { useRedirect } from "@hooks/useRedirect";
import { useEffect, useState } from "react";
type Props = {
url: string;
queryParams?: string;
};
export default function Home() {
useRedirect("/peers");
return <FullScreenLoading />;
const [mounted, setMounted] = useState(false);
const [tempQueryParams, setTempQueryParams] = useLocalStorage(
"netbird-query-params",
"",
);
const [queryParams, setQueryParams] = useState("");
useEffect(() => {
setQueryParams(tempQueryParams);
setTempQueryParams("");
setMounted(true);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return mounted ? (
<Redirect
url={window?.location?.pathname || "/"}
queryParams={queryParams}
/>
) : (
<FullScreenLoading />
);
}
const Redirect = ({ url, queryParams }: Props) => {
useRedirect(url == "/" ? "/peers" : url + (queryParams && `?${queryParams}`));
return <FullScreenLoading />;
};

View File

@@ -1,20 +0,0 @@
import Image from "next/image";
import * as React from "react";
import deIcon from "@/assets/countries/de.svg";
export const CountryDERounded = () => {
return (
<div
className={
"w-5 h-5 overflow-hidden rounded-full relative shadow-2xl border border-nb-gray-600 flex items-center justify-center"
}
>
<Image
src={deIcon}
alt={"de"}
fill={true}
className={"object-cover object-center"}
/>
</div>
);
};

View File

@@ -1,20 +0,0 @@
import Image from "next/image";
import * as React from "react";
import euIcon from "@/assets/countries/eu.svg";
export const CountryEURounded = () => {
return (
<div
className={
"w-5 h-5 overflow-hidden rounded-full relative shadow-2xl border border-nb-gray-600 flex items-center justify-center"
}
>
<Image
src={euIcon}
alt={"eu"}
fill={true}
className={"object-cover object-center shrink-0"}
/>
</div>
);
};

View File

@@ -1,20 +0,0 @@
import Image from "next/image";
import * as React from "react";
import jpIcon from "@/assets/countries/jp.svg";
export const CountryJPRounded = () => {
return (
<div
className={
"w-5 h-5 overflow-hidden rounded-full relative shadow-2xl border border-nb-gray-600 flex items-center justify-center"
}
>
<Image
src={jpIcon}
alt={"eu"}
fill={true}
className={"object-cover object-center"}
/>
</div>
);
};

View File

@@ -1,20 +0,0 @@
import Image from "next/image";
import * as React from "react";
import usIcon from "@/assets/countries/us.svg";
export const CountryUSRounded = () => {
return (
<div
className={
"w-5 h-5 overflow-hidden rounded-full relative shadow-2xl border border-nb-gray-600 flex items-center justify-center"
}
>
<Image
src={usIcon}
alt={"us"}
fill={true}
className={"object-cover object-center"}
/>
</div>
);
};

View File

@@ -1,9 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN"
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg xmlns="http://www.w3.org/2000/svg" width="1000" height="600" viewBox="0 0 5 3">
<desc>Flag of Germany</desc>
<rect id="black_stripe" width="5" height="3" y="0" x="0" fill="#000"/>
<rect id="red_stripe" width="5" height="2" y="1" x="0" fill="#D00"/>
<rect id="gold_stripe" width="5" height="1" y="2" x="0" fill="#FFCE00"/>
</svg>

Before

Width:  |  Height:  |  Size: 493 B

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 810 540"><defs><g id="d"><g id="b"><path id="a" d="M0 0v1h.5z" transform="rotate(18 3.157 -.5)"/><use xlink:href="#a" transform="scale(-1 1)"/></g><g id="c"><use xlink:href="#b" transform="rotate(72)"/><use xlink:href="#b" transform="rotate(144)"/></g><use xlink:href="#c" transform="scale(-1 1)"/></g></defs><path fill="#039" d="M0 0h810v540H0z"/><g fill="#fc0" transform="matrix(30 0 0 30 405 270)"><use xlink:href="#d" y="-6"/><use xlink:href="#d" y="6"/><g id="e"><use xlink:href="#d" x="-6"/><use xlink:href="#d" transform="rotate(-144 -2.344 -2.11)"/><use xlink:href="#d" transform="rotate(144 -2.11 -2.344)"/><use xlink:href="#d" transform="rotate(72 -4.663 -2.076)"/><use xlink:href="#d" transform="rotate(72 -5.076 .534)"/></g><use xlink:href="#e" transform="scale(-1 1)"/></g></svg>

Before

Width:  |  Height:  |  Size: 888 B

View File

@@ -1,4 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 900 600">
<rect fill="#fff" height="600" width="900"/>
<circle fill="#bc002d" cx="450" cy="300" r="180"/>
</svg>

Before

Width:  |  Height:  |  Size: 166 B

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 7410 3900"><path fill="#b22234" d="M0 0h7410v3900H0z"/><path d="M0 450h7410m0 600H0m0 600h7410m0 600H0m0 600h7410m0 600H0" stroke="#fff" stroke-width="300"/><path fill="#3c3b6e" d="M0 0h2964v2100H0z"/><g fill="#fff"><g id="d"><g id="c"><g id="e"><g id="b"><path id="a" d="M247 90l70.534 217.082-184.66-134.164h228.253L176.466 307.082z"/><use xlink:href="#a" y="420"/><use xlink:href="#a" y="840"/><use xlink:href="#a" y="1260"/></g><use xlink:href="#a" y="1680"/></g><use xlink:href="#b" x="247" y="210"/></g><use xlink:href="#c" x="494"/></g><use xlink:href="#d" x="988"/><use xlink:href="#c" x="1976"/><use xlink:href="#e" x="2470"/></g></svg>

Before

Width:  |  Height:  |  Size: 741 B

BIN
src/assets/fonts/Inter.ttf Normal file

Binary file not shown.

View File

@@ -2,7 +2,7 @@ import { useOidc, useOidcUser } from "@axa-fr/react-oidc";
import Button from "@components/Button";
import Paragraph from "@components/Paragraph";
import loadConfig from "@utils/config";
import { ArrowRightIcon, LogOut } from "lucide-react";
import { ArrowRightIcon } from "lucide-react";
import { useSearchParams } from "next/navigation";
import * as React from "react";
import { useEffect, useState } from "react";
@@ -55,7 +55,7 @@ export const OIDCError = () => {
variant={"primary"}
size={"sm"}
className={"mt-5"}
onClick={() => login("/", { client_id: config.clientId })}
onClick={() => logout("/", { client_id: config.clientId })}
>
Continue
<ArrowRightIcon size={16} />
@@ -83,7 +83,6 @@ export const OIDCError = () => {
onClick={() => logout("/", { client_id: config.clientId })}
>
Logout
<LogOut size={16} />
</Button>
</>
)}

View File

@@ -6,6 +6,7 @@ import {
OidcConfiguration,
} from "@axa-fr/react-oidc/dist/vanilla/oidc";
import FullScreenLoading from "@components/ui/FullScreenLoading";
import { useLocalStorage } from "@hooks/useLocalStorage";
import { useRedirect } from "@hooks/useRedirect";
import loadConfig, { buildExtras } from "@utils/config";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
@@ -43,6 +44,19 @@ export default function OIDCProvider({ children }: Props) {
const [mounted, setMounted] = useState(false);
const router = useRouter();
const path = usePathname();
const params = useSearchParams()?.toString();
const [, setQueryParams] = useLocalStorage("netbird-query-params", params);
useEffect(() => {
if (
params?.includes("tab") ||
params?.includes("search") ||
params?.includes("id")
) {
setQueryParams(params);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const withCustomHistory = () => {
return {

View File

@@ -12,14 +12,14 @@ export default function HelpText({
className,
}: Props) {
return (
<p
<span
className={cn(
"text-[.8rem] dark:text-nb-gray-300",
"text-[.8rem] dark:text-nb-gray-300 block font-light tracking-wide",
margin && "mb-2",
className,
)}
>
{children}
</p>
</span>
);
}

View File

@@ -1,3 +1,4 @@
import { IconCircleX } from "@tabler/icons-react";
import type { ErrorResponse } from "@utils/api";
import { cn } from "@utils/helpers";
import classNames from "classnames";
@@ -88,7 +89,7 @@ export default function Notification<T>({
{loading ? (
<Loader2 size={14} className={"animate-spin"} />
) : error ? (
<XIcon size={14} />
<IconCircleX size={24} />
) : (
icon || <CheckIcon size={14} />
)}

View File

@@ -121,7 +121,7 @@ export function PeerSelector({
<PopoverTrigger asChild>
<button
className={cn(
"min-h-[42px] w-full relative items-center group",
"min-h-[46px] w-full relative items-center group",
"border border-neutral-200 dark:border-nb-gray-700 justify-between py-2 px-3",
"rounded-md bg-white text-sm dark:bg-nb-gray-900/40 flex dark:text-neutral-400/70 text-neutral-500 cursor-pointer enabled:hover:dark:bg-nb-gray-900/50",
"disabled:opacity-40 disabled:cursor-default",

View File

@@ -15,6 +15,7 @@ const iconVariant = cva(
green: "bg-green-950 border-green-500 text-green-500",
purple: "bg-purple-950 border-purple-500 text-purple-500",
indigo: "bg-indigo-950 border-indigo-500 text-indigo-500",
yellow: "bg-yellow-950 border-yellow-400 text-yellow-400",
},
size: {
small: "w-8 h-8",

View File

@@ -9,6 +9,7 @@ interface Props extends IconVariant {
description: string | React.ReactNode;
className?: string;
margin?: string;
truncate?: boolean;
}
export default function ModalHeader({
icon,
@@ -17,14 +18,19 @@ export default function ModalHeader({
color = "netbird",
className = "pb-6 px-8",
margin = "mt-0",
truncate = false,
}: Props) {
return (
<div className={className}>
<div className={"flex items-start gap-5 pr-10"}>
<div className={cn(className, "min-w-0")}>
<div className={"flex items-start gap-5 pr-10 min-w-0"}>
{icon && <SquareIcon color={color} icon={icon} />}
<div>
<div className={"min-w-0"}>
<h2 className={"text-lg my-0 leading-[1.5]"}>{title}</h2>
<Paragraph className={cn("text-sm", margin)}>{description}</Paragraph>
<Paragraph
className={cn("text-sm", margin, truncate && "!block truncate")}
>
{description}
</Paragraph>
</div>
</div>
</div>

View File

@@ -1,5 +1,6 @@
import Button from "@components/Button";
import { CommandItem } from "@components/Command";
import Paragraph from "@components/Paragraph";
import { Popover, PopoverContent, PopoverTrigger } from "@components/Popover";
import { ScrollArea } from "@components/ScrollArea";
import { SelectDropdownSearchInput } from "@components/select/SelectDropdownSearchInput";
@@ -31,6 +32,7 @@ interface SelectDropdownProps {
popoverWidth?: "auto" | number;
options: SelectOption[];
showSearch?: boolean;
showValues?: boolean;
placeholder?: string;
searchPlaceholder?: string;
isLoading?: boolean;
@@ -43,6 +45,7 @@ export function SelectDropdown({
popoverWidth = "auto",
options,
showSearch = false,
showValues = false,
placeholder = "Select...",
searchPlaceholder = "Search...",
isLoading = false,
@@ -186,6 +189,7 @@ export function SelectDropdown({
option={option}
toggle={toggle}
key={option.value}
showValue={showValues}
/>
))}
</div>
@@ -201,9 +205,11 @@ export function SelectDropdown({
const SelectDropdownItem = ({
option,
toggle,
showValue = false,
}: {
option: SelectOption;
toggle: (value: string) => void;
showValue?: boolean;
}) => {
const value = option.value || "" + option.label || "";
const elementRef = useRef<HTMLDivElement>(null);
@@ -233,6 +239,13 @@ const SelectDropdownItem = ({
<span className={"text-nb-gray-200"}>{option.label}</span>
</div>
</div>
{showValue && (
<div className={"flex items-center gap-2.5 p-1"}>
<Paragraph className={cn("text-sm text-right")}>
{option.value}
</Paragraph>
</div>
)}
</CommandItem>
) : (
<div className={"h-[35px] py-1 px-2"}></div>

View File

@@ -55,11 +55,15 @@ declare module "@tanstack/table-core" {
}
const fuzzyFilter: FilterFn<any> = (row, columnId, value, addMeta) => {
const val = row.getValue(columnId);
if (!val) return false;
if (typeof val !== "string") return false;
const lowerCaseValue = removeAllSpaces(trim(value.toLowerCase()));
return val.toLowerCase().includes(lowerCaseValue);
try {
const val = row.getValue(columnId);
if (!val) return false;
if (typeof val !== "string") return false;
const lowerCaseValue = removeAllSpaces(trim(value.toLowerCase()));
return val.toLowerCase().includes(lowerCaseValue);
} catch (e) {
return false;
}
};
const exactMatch: FilterFn<any> = (row, columnId, value) => {

View File

@@ -21,14 +21,14 @@ export default function GroupBadge({
}: Props) {
return (
<Badge
key={group.name}
key={group.id}
useHover={true}
variant={"gray-ghost"}
className={cn("transition-all group whitespace-nowrap", className)}
onClick={onClick}
>
<FolderGit2 size={12} className={"shrink-0"} />
<TextWithTooltip text={group.name} maxChars={20} />
<TextWithTooltip text={group?.name || ""} maxChars={20} />
{children}
{showX && (
<XIcon

View File

@@ -24,14 +24,21 @@ export default function TextWithTooltip({
<FullTooltip
disabled={charCount <= maxChars || hideTooltip}
interactive={false}
className={"truncate w-full"}
className={"truncate w-full min-w-0"}
content={
<div className={"max-w-xs break-all whitespace-normal"}>{text}</div>
<div className={"max-w-xs break-all whitespace-normal text-xs"}>
{text}
</div>
}
>
<span className={cn(className, "truncate")}>
{charCount > maxChars ? text && `${text.slice(0, maxChars)}...` : text}
</span>
<div
className={"w-full min-w-0 inline-block"}
style={{
maxWidth: `${maxChars - 2}ch`,
}}
>
<div className={cn(className, "truncate")}>{text}</div>
</div>
</FullTooltip>
);
}

View File

@@ -25,7 +25,7 @@ const RoutesContext = React.createContext(
);
export default function RoutesProvider({ children }: Props) {
const routeRequest = useApiCall<Route>("/routes");
const routeRequest = useApiCall<Route>("/routes", true);
const { mutate } = useSWRConfig();
const updateRoute = async (

View File

@@ -6,7 +6,7 @@ import { cn } from "@utils/helpers";
import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime";
import { Viewport } from "next/dist/lib/metadata/types/extra-types";
import { Inter } from "next/font/google";
import localFont from "next/font/local";
import React from "react";
import { Toaster } from "react-hot-toast";
import OIDCProvider from "@/auth/OIDCProvider";
@@ -16,7 +16,10 @@ import ErrorBoundaryProvider from "@/contexts/ErrorBoundary";
import { GlobalThemeProvider } from "@/contexts/GlobalThemeProvider";
import { NavigationEvents } from "@/contexts/NavigationEvents";
const inter = Inter({ subsets: ["latin"] });
const inter = localFont({
src: "../assets/fonts/Inter.ttf",
display: "swap",
});
// Extend dayjs with relativeTime plugin
dayjs.extend(relativeTime);

View File

@@ -477,15 +477,46 @@ export default function ActivityDescription({ event }: Props) {
</div>
);
// TODO add activity texts
// rule.add
// rule.update
// rule.delete
// setupkey.update
// setupkey.overuse
// group.update
// group.delete
// user.peer.login
if (event.activity_code == "transferred.owner.role")
return <div className={"inline"}>Owner role was transferred</div>;
/**
* EDR
*/
if (event.activity_code == "integrated-validator.api.created")
return (
<div className={"inline"}>
<Value>{m?.platform}</Value> integration created
</div>
);
if (event.activity_code == "integrated-validator.api.updated")
return (
<div className={"inline"}>
<Value>{m?.platform}</Value> integration updated
</div>
);
if (event.activity_code == "integrated-validator.api.deleted")
return (
<div className={"inline"}>
<Value>{m?.platform}</Value> integration deleted
</div>
);
if (event.activity_code == "integrated-validator.host-check.approved")
return (
<div className={"inline"}>
Peer approved by <Value>{m?.platform}</Value> integration
</div>
);
if (event.activity_code == "integrated-validator.host-check.denied")
return (
<div className={"inline"}>
Peer rejected by <Value>{m?.platform}</Value> integration
</div>
);
return (
<div className={"flex gap-2.5 items-center"}>

View File

@@ -3,6 +3,7 @@ import {
ArrowLeftRight,
Blocks,
Cog,
CreditCardIcon,
FolderGit2,
Globe,
HelpCircleIcon,
@@ -10,6 +11,7 @@ import {
LogIn,
MonitorSmartphoneIcon,
NetworkIcon,
RefreshCcw,
Server,
Shield,
ShieldCheck,
@@ -71,10 +73,22 @@ export default function ActivityTypeIcon({
return <User size={size} className={cn(DEFAULT_CLASSES, className)} />;
} else if (code.startsWith("service")) {
return <Cog size={size} className={cn(DEFAULT_CLASSES, className)} />;
} else if (code.startsWith("billing")) {
return (
<CreditCardIcon size={size} className={cn(DEFAULT_CLASSES, className)} />
);
} else if (code.startsWith("integrated")) {
return (
<ShieldCheck size={size} className={cn(DEFAULT_CLASSES, className)} />
);
} else if (code.startsWith("posture")) {
return (
<ShieldCheck size={size} className={cn(DEFAULT_CLASSES, className)} />
);
} else if (code.startsWith("transferred")) {
return (
<RefreshCcw size={size} className={cn(DEFAULT_CLASSES, className)} />
);
} else {
return (
<HelpCircleIcon size={size} className={cn(DEFAULT_CLASSES, className)} />

View File

@@ -10,6 +10,7 @@ type Props = {
leftSection?: React.ReactNode;
text?: string | React.ReactNode;
className?: string;
additionalInfo?: React.ReactNode;
};
export default function ActiveInactiveRow({
active,
@@ -18,11 +19,12 @@ export default function ActiveInactiveRow({
leftSection,
inactiveDot = "gray",
className,
additionalInfo,
}: Props) {
return (
<div
className={cn(
"flex gap-3 dark:text-neutral-300 text-neutral-500 min-w-[250px] max-w-[250px]",
"gap-3 dark:text-neutral-300 text-neutral-500 min-w-0",
className,
)}
>
@@ -34,9 +36,12 @@ export default function ActiveInactiveRow({
inactiveDot={inactiveDot}
className={"mt-1 shrink-0"}
/>
<div className={"flex flex-col"}>
<div className={" font-medium"}>
<div className={"flex flex-col min-w-0"}>
<div
className={"font-medium flex gap-2 items-center justify-center"}
>
<TextWithTooltip text={text as string} maxChars={25} />
{additionalInfo}
</div>
{children}
</div>

View File

@@ -115,7 +115,7 @@ export function NameserverModalContent({
preset,
cell,
}: ModalProps) {
const nsRequest = useApiCall<NameserverGroup>("/dns/nameservers");
const nsRequest = useApiCall<NameserverGroup>("/dns/nameservers", true);
const { mutate } = useSWRConfig();
const isUpdate = useMemo(() => {
@@ -233,24 +233,31 @@ export function NameserverModalContent({
return domains.some((d) => d.name === "");
}, [domains]);
const nameLengthError = useMemo(() => {
if (name.length > 40) return "Name should be less than 40 characters";
return "";
}, [name]);
const hasAnyError = useMemo(() => {
return (
hasNSErrors ||
nsError ||
domainError ||
name == "" ||
nameservers.length == 0 ||
hasDomainErrors ||
groups.length == 0
groups.length == 0 ||
nameLengthError !== "" ||
name == ""
);
}, [
nsError,
domainError,
name,
nameservers,
groups,
hasNSErrors,
hasDomainErrors,
nameLengthError,
name,
]);
return (
@@ -427,6 +434,7 @@ export function NameserverModalContent({
<Input
autoFocus={true}
tabIndex={0}
error={nameLengthError}
placeholder={"e.g., Public DNS"}
value={name}
onChange={(e) => setName(e.target.value)}
@@ -516,7 +524,7 @@ function NameserverInput({
const validCIDR = cidr.isValidAddress(ip);
if (!validCIDR) {
onError && onError(true);
return "Please enter a valid CIDR, e.g., 192.168.1.0/24";
return "Please enter a valid IP, e.g., 192.168.1.0";
}
onError && onError(false);
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -532,7 +540,7 @@ function NameserverInput({
<div className={"w-full"}>
<Input
customPrefix={"IP"}
placeholder={"e.g., 172.16.0.0/16"}
placeholder={"e.g., 172.16.0.0"}
maxWidthClass={"w-full"}
value={ip}
className={"font-mono !text-[13px]"}

View File

@@ -0,0 +1,46 @@
import Button from "@components/Button";
import { Modal } from "@components/modal/Modal";
import { IconCirclePlus, IconDirectionSign } from "@tabler/icons-react";
import * as React from "react";
import { useState } from "react";
import { Peer } from "@/interfaces/Peer";
import { ExitNodeHelpTooltip } from "@/modules/exit-node/ExitNodeHelpTooltip";
import { RouteModalContent } from "@/modules/routes/RouteModal";
type Props = {
peer?: Peer;
firstTime?: boolean;
};
export const AddExitNodeButton = ({ peer, firstTime = false }: Props) => {
const [modal, setModal] = useState(false);
return (
<>
<ExitNodeHelpTooltip>
<Button variant={"secondary"} onClick={() => setModal(true)}>
{!firstTime ? (
<>
<IconCirclePlus size={16} />
Add Exit Node
</>
) : (
<>
<IconDirectionSign size={16} className={"text-yellow-400"} />
Setup Exit Node
</>
)}
</Button>
</ExitNodeHelpTooltip>
<Modal open={modal} onOpenChange={setModal}>
{modal && (
<RouteModalContent
onSuccess={() => setModal(false)}
peer={peer}
isFirstExitNode={firstTime}
exitNode={true}
/>
)}
</Modal>
</>
);
};

View File

@@ -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 ? (
<>
<DropdownMenuItem onClick={() => setModal(true)}>
<div className={"flex gap-3 items-center w-full"}>
<IconDirectionSign size={14} className={"shrink-0"} />
<div className={"flex justify-between items-center w-full"}>
Add Exit Node
</div>
</div>
</DropdownMenuItem>
<Modal open={modal} onOpenChange={setModal}>
{modal && (
<RouteModalContent
onSuccess={() => setModal(false)}
peer={peer}
exitNode={true}
/>
)}
</Modal>
</>
) : null;
};

View File

@@ -0,0 +1,47 @@
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 (
<div
onClick={(e) => {
e.stopPropagation();
}}
>
<FullTooltip
hoverButton={hoverButton}
content={
<div className={"text-xs max-w-xs"}>
An exit node is a network route that routes all your internet
traffic through one of your peers.
<div className={"mt-2"}>
Learn more about{" "}
<InlineLink
href={
"https://docs.netbird.io/how-to/configuring-default-routes-for-internet-traffic"
}
target={"_blank"}
className={"mr-1"}
>
Exit Nodes
<ExternalLinkIcon size={10} />
</InlineLink>
in our documentation.
</div>
</div>
}
>
{children}
</FullTooltip>
</div>
);
};

View File

@@ -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 ? (
<FullTooltip
content={
<div className={"text-xs max-w-xs"}>
This peer is an exit node. Traffic from the configured distribution
groups will be routed through this peer.
</div>
}
>
<IconDirectionSign size={15} className={"text-yellow-400 shrink-0"} />
</FullTooltip>
) : null;
};

View File

@@ -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<Route[]>(
`/routes`,
false,
true,
isOwnerOrAdmin,
);
return peer
? routes?.some(
(route) =>
route?.peer === peer.id && route?.network.includes("0.0.0.0"),
) || false
: false;
};

View File

@@ -142,7 +142,8 @@ export function GroupSelector({
<div className={""}>
<div className={"grid grid-cols-1 gap-1"}>
{orderBy(groups, "name")?.map((item) => {
const value = item.name;
const value = item?.name || "";
if (value === "") return null;
const isSelected =
values.find((c) => c == value) != undefined;

View File

@@ -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 ? (
<ExitNodeHelpTooltip>
<div className={"flex gap-2 items-center dark:text-nb-gray-300 group"}>
<IconDirectionSign size={16} className={"text-yellow-400"} />
Exit Node{" "}
<InfoIcon
size={14}
className={
"text-nb-gray-500 group-hover:text-nb-gray-400 transition-all"
}
/>
</div>
</ExitNodeHelpTooltip>
) : (
<div className={"font-mono dark:text-nb-gray-300 flex max-w-[10px]"}>
{route.network}
</div>

View File

@@ -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<Route>[] = [
},
cell: ({ row }) => <PeerRouteNetworkCell route={row.original} />,
},
{
id: "groups",
accessorFn: (r) => r.groups?.length,
header: ({ column }) => {
return (
<DataTableHeader column={column}>Distribution Groups</DataTableHeader>
);
},
cell: ({ row }) => <RouteDistributionGroupsCell route={row.original} />,
},
{
id: "enabled",
accessorKey: "enabled",

View File

@@ -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() {
</div>
</div>
</DropdownMenuItem>
<ExitNodeDropdownButton peer={peer} />
<DropdownMenuSeparator />
<DropdownMenuItem onClick={deletePeer} variant={"danger"}>

View File

@@ -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 (
<div
className={
"flex items-center min-w-[250px] max-w-[250px] gap-2 dark:text-neutral-300 text-neutral-500 hover:text-neutral-100 transition-all hover:bg-nb-gray-800/60 py-2 px-3 rounded-md cursor-pointer"
}
data-testid="peer-name-cell"
onClick={() => router.push("/peer?id=" + peer.id)}
>
<ActiveInactiveRow active={peer.connected} text={peer.name}>
<div className={"text-nb-gray-400 font-light"}>{userOfPeer?.email}</div>
</ActiveInactiveRow>
<div>
<div
className={
"flex items-center max-w-[300px] gap-2 dark:text-neutral-300 text-neutral-500 hover:text-neutral-100 transition-all hover:bg-nb-gray-800/60 py-2 px-3 rounded-md cursor-pointer"
}
data-testid="peer-name-cell"
onClick={() => router.push("/peer?id=" + peer.id)}
>
<ActiveInactiveRow
active={peer.connected}
text={peer.name}
additionalInfo={
isOwnerOrAdmin && <ExitNodePeerIndicator peer={peer} />
}
>
<div className={"text-nb-gray-400 font-light truncate"}>
{userOfPeer?.email}
</div>
</ActiveInactiveRow>
</div>
</div>
);
}

View File

@@ -72,12 +72,12 @@ const PeersTableColumns: ColumnDef<Peer>[] = [
},
{
accessorKey: "group_name_strings",
accessorFn: (peer) => peer.groups?.map((g) => g.name).join(", "),
accessorFn: (peer) => peer.groups?.map((g) => g?.name || "").join(", "),
sortingFn: "text",
},
{
accessorKey: "group_names",
accessorFn: (peer) => peer.groups?.map((g) => g.name),
accessorFn: (peer) => peer.groups?.map((g) => g?.name || ""),
sortingFn: "text",
filterFn: "arrIncludesSome",
},

View File

@@ -2,8 +2,7 @@ import { cn } from "@utils/helpers";
import Image from "next/image";
import * as React from "react";
import { FaWindows } from "react-icons/fa6";
import { CountryDERounded } from "@/assets/countries/CountryDERounded";
import { CountryUSRounded } from "@/assets/countries/CountryUSRounded";
import RoundedFlag from "@/assets/countries/RoundedFlag";
import NetBirdIcon from "@/assets/icons/NetBirdIcon";
import AppleLogo from "@/assets/os-icons/apple.svg";
@@ -24,7 +23,7 @@ export const PostureCheckIcons = () => {
"h-6 w-6 overflow-hidden rounded-full flex items-center justify-center"
}
>
<CountryDERounded />
<RoundedFlag country="de" />
</div>
</Circle>
<Circle className={"z-[3]"}>
@@ -36,7 +35,7 @@ export const PostureCheckIcons = () => {
"h-6 w-6 overflow-hidden rounded-full flex items-center justify-center"
}
>
<CountryUSRounded />
<RoundedFlag country="us" />
</div>
</Circle>
<Circle className={"z-[1] top-2 "}>

View File

@@ -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 ? (
<ExitNodeHelpTooltip>
<div className={"flex gap-2 items-center dark:text-nb-gray-300 group"}>
<IconDirectionSign size={16} className={"text-yellow-400"} />
Exit Node{" "}
<InfoIcon
size={14}
className={
"text-nb-gray-500 group-hover:text-nb-gray-400 transition-all"
}
/>
</div>
</ExitNodeHelpTooltip>
) : (
<div className={"font-mono dark:text-nb-gray-300 flex max-w-[10px]"}>
{network}
</div>

View File

@@ -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={
<RouteModal>
<Button variant={"primary"} className={""}>
<PlusCircle size={16} />
Add Route
</Button>
</RouteModal>
<div className={"gap-x-4 flex items-center justify-center"}>
<AddExitNodeButton />
<RouteModal>
<Button variant={"primary"} className={""}>
<PlusCircle size={16} />
Add Route
</Button>
</RouteModal>
</div>
}
learnMore={
<>
@@ -183,12 +187,15 @@ export default function NetworkRoutesTable({
rightSide={() => (
<>
{routes && routes?.length > 0 && (
<RouteModal>
<Button variant={"primary"} className={"ml-auto"}>
<PlusCircle size={16} />
Add Route
</Button>
</RouteModal>
<div className={"gap-x-4 ml-auto flex"}>
<AddExitNodeButton />
<RouteModal>
<Button variant={"primary"} className={""}>
<PlusCircle size={16} />
Add Route
</Button>
</RouteModal>
</div>
)}
</>
)}

View File

@@ -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 | undefined>(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<boolean>(true);
const [metric, setMetric] = useState("9999");
const [masquerade, setMasquerade] = useState<boolean>(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<HTMLInputElement>(null);
const nameRef = useRef<HTMLInputElement>(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,91 @@ 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<HTMLInputElement>(null);
const nameRef = useRef<HTMLInputElement>(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
groups.length == 0 ||
networkRange == ""
);
}, [
networkIdentifier,
cidrError,
peerTab,
routingPeerGroups.length,
routingPeer,
groups,
networkRange,
]);
const [tab, setTab] = useState("network");
const networkIdentifierError = useMemo(() => {
return (networkIdentifier?.length || 0) > 40
? "Network Identifier must be less than 40 characters"
: "";
}, [networkIdentifier]);
const metricError = useMemo(() => {
return parseInt(metric) < 1 || parseInt(metric) > 9999
? "Metric must be between 1 and 9999"
: "";
}, [metric]);
const isNameEntered = useMemo(() => {
return networkIdentifier != "" && networkIdentifierError == "";
}, [networkIdentifier, networkIdentifierError]);
const canCreateOrSave = useMemo(() => {
return isNetworkEntered && isNameEntered && metricError == "";
}, [isNetworkEntered, isNameEntered, metricError]);
return (
<ModalContent maxWidthClass={"max-w-xl"}>
<ModalHeader
icon={<NetworkRoutesIcon className={"fill-netbird"} />}
title={"Create New Route"}
description={"Access LANs and VPC by adding a network route."}
color={"netbird"}
icon={
exitNode ? (
<IconDirectionSign size={20} />
) : (
<NetworkRoutesIcon className={"fill-netbird"} />
)
}
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"}
/>
<Tabs defaultValue={tab} onValueChange={(v) => setTab(v)}>
<Tabs defaultValue={tab} onValueChange={(v) => setTab(v)} value={tab}>
<TabsList justify={"start"} className={"px-8"}>
<TabsTrigger
value={"network"}
@@ -192,6 +259,7 @@ export function RouteModalContent({ onSuccess, peer }: ModalProps) {
</TabsTrigger>
<TabsTrigger
value={"general"}
disabled={!isNetworkEntered}
onClick={() => nameRef.current?.focus()}
>
<Text
@@ -202,7 +270,10 @@ export function RouteModalContent({ onSuccess, peer }: ModalProps) {
/>
Name & Description
</TabsTrigger>
<TabsTrigger value={"settings"}>
<TabsTrigger
value={"settings"}
disabled={!isNetworkEntered || !isNameEntered}
>
<Settings2
size={16}
className={
@@ -212,6 +283,78 @@ export function RouteModalContent({ onSuccess, peer }: ModalProps) {
Additional Settings
</TabsTrigger>
</TabsList>
<TabsContent value={"network"} className={"pb-8"}>
<div className={"px-8 flex-col flex gap-6"}>
<div className={cn(exitNode && "hidden")}>
<Label>Network Range</Label>
<HelpText>Add a private IP address range</HelpText>
<Input
ref={networkRangeRef}
customPrefix={<NetworkIcon size={16} />}
placeholder={"e.g., 172.16.0.0/16"}
value={networkRange}
className={"font-mono !text-[13px]"}
error={cidrError}
onChange={(e) => setNetworkRange(e.target.value)}
/>
</div>
{exitNode && peer ? (
<></>
) : (
<SegmentedTabs value={peerTab} onChange={setPeerTab}>
<SegmentedTabs.List>
<SegmentedTabs.Trigger value={"routing-peer"}>
<MonitorSmartphoneIcon size={16} />
Routing Peer
</SegmentedTabs.Trigger>
<SegmentedTabs.Trigger value={"peer-group"} disabled={!!peer}>
<FolderGit2 size={16} />
Peer Group
</SegmentedTabs.Trigger>
</SegmentedTabs.List>
<SegmentedTabs.Content value={"routing-peer"}>
<div>
<HelpText>
Assign a single peer as a routing peer for the
{exitNode ? " exit node." : " Network CIDR."}
</HelpText>
<PeerSelector
onChange={setRoutingPeer}
value={routingPeer}
disabled={!!peer}
/>
</div>
</SegmentedTabs.Content>
<SegmentedTabs.Content value={"peer-group"}>
<div>
<HelpText>
Assign a peer group with Linux machines to be used as
{exitNode ? " exit nodes." : "routing peers."}
</HelpText>
<PeerGroupSelector
max={1}
onChange={setRoutingPeerGroups}
values={routingPeerGroups}
/>
</div>
</SegmentedTabs.Content>
</SegmentedTabs>
)}
<div>
<Label>Distribution Groups</Label>
<HelpText>
{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"}
</HelpText>
<PeerGroupSelector onChange={setGroups} values={groups} />
</div>
</div>
</TabsContent>
<TabsContent value={"general"} className={"px-8 pb-6"}>
<div className={"flex flex-col gap-6"}>
<div>
@@ -220,6 +363,7 @@ export function RouteModalContent({ onSuccess, peer }: ModalProps) {
Add a unique network identifier that is assigned to each device.
</HelpText>
<Input
error={networkIdentifierError}
autoFocus={true}
tabIndex={0}
ref={nameRef}
@@ -244,69 +388,6 @@ export function RouteModalContent({ onSuccess, peer }: ModalProps) {
</div>
</div>
</TabsContent>
<TabsContent value={"network"} className={"pb-8"}>
<div className={"px-8 flex-col flex gap-6"}>
<div>
<Label>Network Range</Label>
<HelpText>Add a private IP address range</HelpText>
<Input
ref={networkRangeRef}
customPrefix={<NetworkIcon size={16} />}
placeholder={"e.g., 172.16.0.0/16"}
value={networkRange}
className={"font-mono !text-[13px]"}
error={cidrError}
onChange={(e) => setNetworkRange(e.target.value)}
/>
</div>
<SegmentedTabs value={peerTab} onChange={setPeerTab}>
<SegmentedTabs.List>
<SegmentedTabs.Trigger value={"routing-peer"}>
<MonitorSmartphoneIcon size={16} />
Routing Peer
</SegmentedTabs.Trigger>
<SegmentedTabs.Trigger value={"peer-group"} disabled={!!peer}>
<FolderGit2 size={16} />
Peer Group
</SegmentedTabs.Trigger>
</SegmentedTabs.List>
<SegmentedTabs.Content value={"routing-peer"}>
<div>
<HelpText>
Assign a single peer as a routing peer for the Network CIDR.
</HelpText>
<PeerSelector
onChange={setRoutingPeer}
value={routingPeer}
disabled={!!peer}
/>
</div>
</SegmentedTabs.Content>
<SegmentedTabs.Content value={"peer-group"}>
<div>
<HelpText>
Assign peer group with Linux machines to be used as routing
peers.
</HelpText>
<PeerGroupSelector
max={1}
onChange={setRoutingPeerGroups}
values={routingPeerGroups}
/>
</div>
</SegmentedTabs.Content>
</SegmentedTabs>
<div>
<Label>Distribution Groups</Label>
<HelpText>
Advertise this route to peers that belong to the following
groups
</HelpText>
<PeerGroupSelector onChange={setGroups} values={groups} />
</div>
</div>
</TabsContent>
<TabsContent value={"settings"} className={"pb-4"}>
<div className={"px-8 flex flex-col gap-6"}>
<FancyToggleSwitch
@@ -320,19 +401,22 @@ export function RouteModalContent({ onSuccess, peer }: ModalProps) {
}
helpText={"Use this switch to enable or disable the route."}
/>
<FancyToggleSwitch
value={masquerade}
onChange={setMasquerade}
label={
<>
<VenetianMask size={15} />
Masquerade
</>
}
helpText={
"Allow access to your private networks without configuring routes on your local routers or other devices."
}
/>
{!exitNode && (
<FancyToggleSwitch
value={masquerade}
onChange={setMasquerade}
label={
<>
<VenetianMask size={15} />
Masquerade
</>
}
helpText={
"Allow access to your private networks without configuring routes on your local routers or other devices."
}
/>
)}
<div className={cn("flex justify-between")}>
<div>
<Label>Metrics</Label>
@@ -346,6 +430,8 @@ export function RouteModalContent({ onSuccess, peer }: ModalProps) {
max={9999}
maxWidthClass={"max-w-[200px]"}
value={metric}
error={metricError}
errorTooltip={true}
type={"number"}
onChange={(e) => setMetric(e.target.value)}
customPrefix={
@@ -366,28 +452,64 @@ export function RouteModalContent({ onSuccess, peer }: ModalProps) {
Learn more about
<InlineLink
href={
"https://docs.netbird.io/how-to/routing-traffic-to-private-networks"
exitNode
? "https://docs.netbird.io/how-to/configuring-default-routes-for-internet-traffic"
: "https://docs.netbird.io/how-to/routing-traffic-to-private-networks"
}
target={"_blank"}
>
Network Routes
{exitNode ? "Exit Nodes" : "Network Routes"}
<ExternalLinkIcon size={12} />
</InlineLink>
</Paragraph>
</div>
<div className={"flex gap-3 w-full justify-end"}>
<ModalClose asChild={true}>
<Button variant={"secondary"}>Cancel</Button>
</ModalClose>
{tab == "network" && (
<ModalClose asChild={true}>
<Button variant={"secondary"}>Cancel</Button>
</ModalClose>
)}
<Button
variant={"primary"}
disabled={isDisabled}
onClick={createRouteHandler}
>
<PlusCircle size={16} />
Add Route
</Button>
{tab == "general" && (
<Button variant={"secondary"} onClick={() => setTab("network")}>
Back
</Button>
)}
{tab == "settings" && (
<Button variant={"secondary"} onClick={() => setTab("general")}>
Back
</Button>
)}
{tab == "network" && (
<Button
variant={"primary"}
onClick={() => setTab("general")}
disabled={!isNetworkEntered}
>
Continue
</Button>
)}
{tab == "general" && (
<Button
variant={"primary"}
onClick={() => setTab("settings")}
disabled={!isNameEntered || !isNetworkEntered}
>
Continue
</Button>
)}
{tab == "settings" && (
<Button
variant={"primary"}
disabled={!canCreateOrSave}
onClick={createRouteHandler}
>
<PlusCircle size={16} />
{exitNode ? "Add Exit Node" : "Add Route"}
</Button>
)}
</div>
</ModalFooter>
</ModalContent>

View File

@@ -197,14 +197,21 @@ function RouteUpdateModalContent({ onSuccess, route, cell }: ModalProps) {
.filter((p) => p != undefined) as string[];
}, [groupedRoute]);
const metricError = useMemo(() => {
return parseInt(metric.toString()) < 1 || parseInt(metric.toString()) > 9999
? "Metric must be between 1 and 9999"
: "";
}, [metric]);
// Is button disabled
const isDisabled = useMemo(() => {
return (
(peerTab === "peer-group" && routingPeerGroups.length == 0) ||
(peerTab === "routing-peer" && !routingPeer) ||
groups.length == 0
groups.length == 0 ||
metricError !== ""
);
}, [peerTab, routingPeerGroups.length, routingPeer, groups]);
}, [peerTab, routingPeerGroups.length, routingPeer, groups, metricError]);
const [tab, setTab] = useState(
cell && cell == "metric" ? "settings" : "network",
@@ -352,6 +359,8 @@ function RouteUpdateModalContent({ onSuccess, route, cell }: ModalProps) {
max={9999}
maxWidthClass={"max-w-[200px]"}
value={metric}
error={metricError}
errorTooltip={true}
type={"number"}
onChange={(e) => setMetric(e.target.value)}
customPrefix={

View File

@@ -17,11 +17,11 @@ export default function SetupKeyActionCell({ setupKey }: Props) {
const handleRevoke = async () => {
notify({
title: "Setup Key: " + setupKey.name,
title: setupKey?.name || "Setup Key",
description: "Setup key was successfully revoked",
promise: deleteRequest
.put({
name: setupKey.name,
name: setupKey?.name || "Setup Key",
type: setupKey.type,
expires_in: setupKey.expires_in,
revoked: true,
@@ -39,7 +39,7 @@ export default function SetupKeyActionCell({ setupKey }: Props) {
const handleConfirm = async () => {
const choice = await confirm({
title: `Revoke '${setupKey.name}'?`,
title: `Revoke '${setupKey?.name || "Setup Key"}'?`,
description:
"Are you sure you want to revoke the setup key? This action cannot be undone.",
confirmText: "Revoke",

View File

@@ -17,15 +17,15 @@ export default function SetupKeyGroupsCell({ setupKey }: Props) {
const groups = await Promise.all(promises);
notify({
title: setupKey.name,
title: setupKey?.name || "Setup Key",
description: "Groups of the setup key were successfully saved",
promise: request
.put({
name: setupKey.name,
name: setupKey?.name || "Setup Key",
type: setupKey.type,
expires_in: setupKey.expires_in,
revoked: setupKey.revoked,
auto_groups: groups.map((group) => group.id),
auto_groups: groups?.map((group) => group.id) || [],
usage_limit: setupKey.usage_limit,
ephemeral: setupKey.ephemeral,
})

View File

@@ -126,7 +126,7 @@ type ModalProps = {
};
export function SetupKeyModalContent({ onSuccess }: ModalProps) {
const setupKeyRequest = useApiCall<SetupKey>("/setup-keys");
const setupKeyRequest = useApiCall<SetupKey>("/setup-keys", true);
const { mutate } = useSWRConfig();
const [name, setName] = useState("");
@@ -143,10 +143,18 @@ export function SetupKeyModalContent({ onSuccess }: ModalProps) {
return reusable ? "Unlimited" : "1";
}, [reusable]);
const expiresInError = useMemo(() => {
const expires = parseInt(expiresIn);
if (expires < 1 || expires > 365) {
return "Days should be between 1 and 365";
}
return "";
}, [expiresIn]);
const isDisabled = useMemo(() => {
const trimmedName = trim(name);
return trimmedName.length === 0;
}, [name]);
return trimmedName.length === 0 || expiresInError.length > 0;
}, [name, expiresInError]);
const submit = () => {
if (!selectedGroups) return;
@@ -245,6 +253,8 @@ export function SetupKeyModalContent({ onSuccess }: ModalProps) {
min={1}
max={365}
value={expiresIn}
error={expiresInError}
errorTooltip={true}
type={"number"}
onChange={(e) => setExpiresIn(e.target.value)}
customPrefix={

View File

@@ -52,7 +52,7 @@ export default function SetupKeysTable({ setupKeys, isLoading }: Props) {
return (
<>
<SetupKeyModal open={open} setOpen={setOpen} />
{open && <SetupKeyModal open={open} setOpen={setOpen} />}
<DataTable
isLoading={isLoading}
text={"Setup Keys"}

View File

@@ -39,7 +39,10 @@ export const SetupKeysTableColumns: ColumnDef<SetupKey>[] = [
},
sortingFn: "text",
cell: ({ row }) => (
<SetupKeyNameCell valid={row.original.valid} name={row.original.name} />
<SetupKeyNameCell
valid={row.original.valid}
name={row.original?.name || ""}
/>
),
},
{
@@ -66,7 +69,7 @@ export const SetupKeysTableColumns: ColumnDef<SetupKey>[] = [
{
id: "group_strings",
accessorKey: "group_strings",
accessorFn: (s) => s.groups?.map((g) => g.name).join(", "),
accessorFn: (s) => s.groups?.map((g) => g?.name || "").join(", "),
},
{
accessorKey: "last_used",

View File

@@ -93,6 +93,7 @@ export default function useFetchApi<T>(
url: string,
ignoreError = false,
revalidate = true,
allowFetch = true,
) {
const { fetch } = useNetBirdFetch(ignoreError);
const handleErrors = useApiErrorHandling(ignoreError);
@@ -100,6 +101,7 @@ export default function useFetchApi<T>(
const { data, error, isLoading, isValidating, mutate } = useSWR(
url,
async (url) => {
if (!allowFetch) return;
return apiRequest<T>(fetch, "GET", url).catch((err) =>
handleErrors(err as ErrorResponse),
);
@@ -167,13 +169,13 @@ export function useApiErrorHandling(ignoreError = false) {
return login(currentPath);
}
if (err.code == 401 && err.message == "token invalid") {
return setError(err);
setError(err);
}
if (err.code == 500 && err.message == "internal server error") {
return setError(err);
setError(err);
}
if (err.code > 400 && err.code <= 500) {
return setError(err);
setError(err);
}
return Promise.reject(err);