Compare commits

..

13 Commits

Author SHA1 Message Date
juliaroesschen
d4102c5d04 fix typo in route update modal (#397)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2024-07-04 15:05:57 +02:00
pascal-fischer
e78c35bdbe Fix DNS modal to allow one char domains (#393)
* update regex to allow one char domains in DNS routing modal

* update regex
2024-07-04 10:50:37 +02:00
juliaroesschen
6ebee98695 Fix typo in Network Routes dialogue (#395) 2024-07-04 10:48:49 +02:00
juliaroesschen
f4b28d5f40 Fix typo in routes modal 2024-06-28 11:38:39 +02:00
Eduard Gert
b4b6d9295b Add DNS routes (#390)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2024-06-17 09:32:55 +02:00
Maycon Santos
4898742ee9 Fix http://localhost:3000/ url validation case (#388)
* Fix http://localhost:3000/ url validation case

* adjust min regex occurrences
2024-06-12 18:18:14 +02:00
Eduard Gert
79164e9dd5 Add process posture check (#378)
* Add process posture check

* Add support for separate linux and mac paths
2024-06-12 16:32:10 +02:00
Eduard Gert
5caeab118b UX changes for modals and refactoring (#380) 2024-05-08 14:42:04 +02:00
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
90 changed files with 2407 additions and 721 deletions

View File

@@ -5,6 +5,9 @@ const nextConfig = {
unoptimized: true,
},
reactStrictMode: false,
env: {
APP_ENV: process.env.APP_ENV || "production",
},
};
module.exports = nextConfig;

View File

@@ -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",

View File

@@ -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";
@@ -57,6 +58,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";
@@ -64,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<Peer>("/peers/" + peerId);
return peer ? (
const { data: peer, isLoading } = useFetchApi<Peer>("/peers/" + peerId, true);
useRedirect("/peers", false, !peerId);
return peer && !isLoading ? (
<PeerProvider peer={peer}>
<PeerOverview />
</PeerProvider>
@@ -127,6 +133,7 @@ function PeerOverview() {
};
const { isUser } = useLoggedInUser();
const hasExitNodes = useHasExitNodes(peer);
return (
<PageContainer>
@@ -342,7 +349,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

@@ -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 ? (
<UserOverview user={user} />
) : (

View File

@@ -1,14 +1,40 @@
"use client";
import FullScreenLoading from "@components/ui/FullScreenLoading";
import { useRouter } from "next/navigation";
import { useEffect } from "react";
import { useLocalStorage } from "@hooks/useLocalStorage";
import { useRedirect } from "@hooks/useRedirect";
import { useEffect, useState } from "react";
type Props = {
url: string;
queryParams?: string;
};
export default function NotFound() {
const router = useRouter();
useEffect(() => {
router.push("/peers");
});
const [mounted, setMounted] = useState(false);
const [tempQueryParams, setTempQueryParams] = useLocalStorage(
"netbird-query-params",
"",
);
const [queryParams, setQueryParams] = useState("");
return <FullScreenLoading />;
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("/peers" + (queryParams && `?${queryParams}`));
return <FullScreenLoading />;
};

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

@@ -16,6 +16,8 @@ export default function CircleIcon({
return (
<span
style={{ width: size + "px", height: size + "px" }}
data-cy="circle-icon"
data-cy-status={active ? "active" : "inactive"}
className={cn(
"rounded-full",
active

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

@@ -5,14 +5,16 @@ import React, { forwardRef } from "react";
type Props = {
children: React.ReactNode;
disabled?: boolean;
className?: string;
};
function ButtonGroup({ children, disabled }: Props) {
function ButtonGroup({ children, disabled, className }: Props) {
return (
<div
className={cn(
"rounded-lg border-[1px] dark:border-nb-gray-900 border-neutral-200 overflow-hidden flex items-center justify-center shrink-0 border-separate",
disabled ? "opacity-100 !border-nb-gray-900/20" : "",
className,
)}
>
{children}
@@ -21,7 +23,10 @@ function ButtonGroup({ children, disabled }: Props) {
}
const ButtonGroupButton = forwardRef(
({ ...props }: ButtonProps, ref: React.ForwardedRef<HTMLButtonElement>) => {
(
{ className, ...props }: ButtonProps,
ref: React.ForwardedRef<HTMLButtonElement>,
) => {
return (
<Button
ref={ref}
@@ -31,6 +36,7 @@ const ButtonGroupButton = forwardRef(
className={cn(
"first:border-l-0 last:border-r-0 border-t-0 border-b-0 h-[41px]",
"!py-2.5 !px-4",
className,
)}
/>
);

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

@@ -13,6 +13,7 @@ export interface InputProps
icon?: React.ReactNode;
error?: string;
errorTooltip?: boolean;
errorTooltipPosition?: "top" | "top-right";
}
const inputVariants = cva("", {
@@ -49,6 +50,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
maxWidthClass = "",
error,
errorTooltip = false,
errorTooltipPosition = "top",
...props
},
ref,
@@ -105,9 +107,12 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
</div>
{error && errorTooltip && (
<div
className={
"absolute right-0 top-2 h-[0px] w-full flex items-center pr-3 justify-center"
}
className={cn(
errorTooltipPosition == "top" &&
"absolute right-0 top-2 h-[0px] w-full flex items-center pr-3 justify-center",
errorTooltipPosition == "top-right" &&
"absolute -right-6 top-2 h-[0px] w-full flex items-center pr-3 justify-end",
)}
>
<FullTooltip
content={
@@ -120,7 +125,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
</div>
}
interactive={false}
align={"center"}
align={errorTooltipPosition == "top" ? "center" : "end"}
side={"top"}
keepOpen={true}
>

View File

@@ -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}
</div>
<DomainList domains={value?.domains} />
</div>
) : (
<span>Select an existing network...</span>
@@ -208,7 +215,11 @@ export function NetworkRouteSelector({
return (
<CommandItem
key={option.network + option.network_id}
value={option.network + option.network_id}
value={
option.network +
option.network_id +
option?.domains?.join(", ")
}
onSelect={() => {
togglePeer(option);
setOpen(false);
@@ -226,6 +237,7 @@ export function NetworkRouteSelector({
>
{option.network}
</div>
<DomainList domains={option?.domains} />
</CommandItem>
);
})}
@@ -238,3 +250,19 @@ export function NetworkRouteSelector({
</Popover>
);
}
function DomainList({ domains }: { domains?: string[] }) {
const firstDomain = domains ? domains[0] : "";
return (
domains &&
domains.length > 0 && (
<FullTooltip
content={<div className={"text-xs max-w-sm"}>{domains.join(", ")}</div>}
>
<div className={"text-xs text-nb-gray-300"}>
{firstDomain} {domains.length > 1 && "+" + (domains.length - 1)}
</div>
</FullTooltip>
)
);
}

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

@@ -198,6 +198,7 @@ export function PeerGroupSelector({
<CommandList className={"w-full"}>
<div className={"relative"}>
<CommandInput
data-cy={"group-search-input"}
className={cn(
"min-h-[42px] w-full relative",
"border-b-0 border-t-0 border-r-0 border-l-0 border-neutral-200 dark:border-nb-gray-700 items-center",

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

@@ -21,12 +21,19 @@ function SegmentedTabs({ value, onChange, children }: Props) {
);
}
function List({ children }: { children: React.ReactNode }) {
function List({
children,
className = "",
}: {
children: React.ReactNode;
className?: string;
}) {
return (
<TabsList
className={
"bg-nb-gray-930/70 p-1.5 rounded-t-lg flex justify-center gap-1 border border-b-0 border-nb-gray-900"
}
className={cn(
"bg-nb-gray-930/70 p-1.5 rounded-t-lg flex justify-center gap-1 border border-b-0 border-nb-gray-900",
className,
)}
>
{children}
</TabsList>

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

@@ -75,7 +75,10 @@ const ModalContent = React.forwardRef<
<>
{children}
{showClose && (
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-white transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-neutral-950 focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-neutral-100 data-[state=open]:text-neutral-500 dark:ring-offset-neutral-950 dark:focus:ring-neutral-300 dark:data-[state=open]:bg-neutral-800 dark:data-[state=open]:text-neutral-400">
<DialogPrimitive.Close
data-cy={"modal-close"}
className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-white transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-neutral-950 focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-neutral-100 data-[state=open]:text-neutral-500 dark:ring-offset-neutral-950 dark:focus:ring-neutral-300 dark:data-[state=open]:bg-neutral-800 dark:data-[state=open]:text-neutral-400"
>
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>

View File

@@ -9,6 +9,8 @@ interface Props extends IconVariant {
description: string | React.ReactNode;
className?: string;
margin?: string;
truncate?: boolean;
children?: React.ReactNode;
}
export default function ModalHeader({
icon,
@@ -17,14 +19,24 @@ export default function ModalHeader({
color = "netbird",
className = "pb-6 px-8",
margin = "mt-0",
truncate = false,
children,
}: 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>
{children ? (
<>{children}</>
) : (
<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

@@ -28,9 +28,10 @@ export function DataTableRowsPerPage<TData>({
role="combobox"
aria-expanded={open}
disabled={disabled}
data-cy={"rows-per-page"}
className="w-[200px] justify-between"
>
<RowsIcon size={15} className={"text-nb-gray-300"} />
<RowsIcon size={15} className={"text-nb-gray-300 shrink-0"} />
<div>
<span className={"text-white"}>
{table.getState().pagination.pageSize}
@@ -47,6 +48,7 @@ export function DataTableRowsPerPage<TData>({
<CommandItem
key={val}
value={val.toString()}
data-cy={`rows-per-page-value`}
onSelect={(currentValue) => {
table.setPageSize(Number(currentValue));
setOpen(false);

View File

@@ -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 (
<DomainsTooltip domains={domains}>
<div className={"inline-flex items-center gap-2"}>
{firstDomain && (
<Badge variant={"gray"}>
<GlobeIcon size={10} />
{firstDomain}
</Badge>
)}
{domains && domains.length > 1 && (
<Badge variant={"gray"}>+ {domains.length - 1}</Badge>
)}
</div>
</DomainsTooltip>
);
};
export const DomainsTooltip = ({
domains,
children,
className,
}: {
domains: string[];
children: React.ReactNode;
className?: string;
}) => {
return (
<FullTooltip
interactive={false}
className={className}
content={
<div className={"flex flex-col gap-2 items-start"}>
{domains.map((domain) => {
return (
domain && (
<div
key={domain}
className={"flex gap-2 items-center justify-between w-full"}
>
<div
className={
"flex gap-2 items-center text-nb-gray-300 text-xs"
}
>
<GlobeIcon size={11} />
{domain}
</div>
</div>
)
);
})}
</div>
}
disabled={domains.length <= 1}
>
{children}
</FullTooltip>
);
};

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

@@ -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<Props>) {
const [name, setName] = useState(value?.name || "");
const handleNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
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 (
<div className={"flex gap-2 w-full"}>
<div className={"w-full"}>
<Input
customPrefix={<GlobeIcon size={15} />}
placeholder={"e.g., example.com"}
maxWidthClass={"w-full"}
value={name}
error={domainError}
onChange={handleNameChange}
/>
</div>
<Button
className={"h-[42px]"}
variant={"default-outline"}
onClick={onRemove}
>
<MinusCircleIcon size={15} />
</Button>
</div>
);
}

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

@@ -94,6 +94,7 @@ export default function DialogProvider({ children }: Props) {
className={"w-full"}
size={"sm"}
tabIndex={-1}
data-cy={"confirmation.cancel"}
onClick={() => fn.current && fn.current(false)}
>
{dialogOptions.cancelText || "Cancel"}
@@ -109,6 +110,7 @@ export default function DialogProvider({ children }: Props) {
}
className={"w-full"}
size={"sm"}
data-cy={"confirmation.confirm"}
onClick={() => fn.current && fn.current(true)}
>
{dialogOptions.confirmText || "Confirm"}

View File

@@ -77,9 +77,7 @@ export default function PeerProvider({ children, peer }: Props) {
? loginExpiration
: peer.login_expiration_enabled,
approval_required:
approval_required != undefined
? approval_required
: peer.approval_required,
approval_required == undefined ? undefined : approval_required,
},
`/${peer.id}`,
);

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 (
@@ -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 || [],

View File

@@ -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;

View File

@@ -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<NodeJS.Timeout | null>(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;

4
src/interfaces/Domain.ts Normal file
View File

@@ -0,0 +1,4 @@
export interface Domain {
id?: string;
name: string;
}

View File

@@ -17,11 +17,6 @@ export interface Nameserver {
id?: string;
}
export interface Domain {
id?: string;
name: string;
}
export const NameserverPresets: Record<string, NameserverGroup> = {
Default: {
name: "",

View File

@@ -10,6 +10,7 @@ export interface PostureCheck {
os_version_check?: OperatingSystemVersionCheck;
geo_location_check?: GeoLocationCheck;
peer_network_range_check?: PeerNetworkRangeCheck;
process_check?: ProcessCheck;
};
policies?: Policy[];
active?: boolean;
@@ -53,6 +54,17 @@ export interface PeerNetworkRangeCheck {
action: "allow" | "deny";
}
export interface ProcessCheck {
processes: Process[];
}
export interface Process {
id: string;
linux_path?: string;
mac_path?: string;
windows_path?: string;
}
export const windowsKernelVersions: SelectOption[] = [
{ value: "5.0", label: "Windows 2000" },
{ value: "5.1", label: "Windows XP" },

View File

@@ -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;
}

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

@@ -239,12 +239,6 @@ export function AccessControlModalContent({
const portAndDirectionDisabled = protocol == "icmp" || protocol == "all";
const buttonDisabled = useMemo(() => {
if (sourceGroups.length == 0 || destinationGroups.length == 0) return true;
if (name.length == 0) return true;
if (direction != "bi" && ports.length == 0) return true;
}, [sourceGroups, destinationGroups, direction, ports, name]);
const [postureChecks, setPostureChecks] = useState<PostureCheck[]>([]);
const postureChecksLoaded = useRef(false);
@@ -268,6 +262,16 @@ export function AccessControlModalContent({
}
}, [initialPostureChecks]);
const continuePostureChecksDisabled = useMemo(() => {
if (sourceGroups.length == 0 || destinationGroups.length == 0) return true;
if (direction != "bi" && ports.length == 0) return true;
}, [sourceGroups, destinationGroups, direction, ports]);
const submitDisabled = useMemo(() => {
if (name.length == 0) return true;
if (continuePostureChecksDisabled) return true;
}, [name, continuePostureChecksDisabled]);
return (
<ModalContent maxWidthClass={"max-w-2xl"}>
<ModalHeader
@@ -283,14 +287,17 @@ export function AccessControlModalContent({
color={"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={"policy"}>
<ArrowRightLeft size={16} />
Policy
</TabsTrigger>
<PostureCheckTabTrigger />
<TabsTrigger value={"general"}>
<PostureCheckTabTrigger disabled={continuePostureChecksDisabled} />
<TabsTrigger
value={"general"}
disabled={continuePostureChecksDisabled}
>
<Text
size={16}
className={
@@ -456,24 +463,74 @@ export function AccessControlModalContent({
</Paragraph>
</div>
<div className={"flex gap-3 w-full justify-end"}>
<ModalClose asChild={true}>
<Button variant={"secondary"}>Cancel</Button>
</ModalClose>
{!policy ? (
<>
{tab == "policy" && (
<ModalClose asChild={true}>
<Button variant={"secondary"}>Cancel</Button>
</ModalClose>
)}
<Button
variant={"primary"}
disabled={buttonDisabled}
onClick={submit}
>
{policy ? (
<>Save Changes</>
) : (
<>
<PlusCircle size={16} />
Add Policy
</>
)}
</Button>
{tab == "posture_checks" && (
<Button variant={"secondary"} onClick={() => setTab("policy")}>
Back
</Button>
)}
{tab == "policy" && (
<Button
variant={"primary"}
onClick={() => setTab("posture_checks")}
disabled={continuePostureChecksDisabled}
>
Continue
</Button>
)}
{tab == "posture_checks" && (
<Button
variant={"primary"}
onClick={() => setTab("general")}
disabled={continuePostureChecksDisabled}
>
Continue
</Button>
)}
{tab == "general" && (
<>
<Button
variant={"secondary"}
onClick={() => setTab("posture_checks")}
>
Back
</Button>
<Button
variant={"primary"}
disabled={submitDisabled}
onClick={submit}
>
<PlusCircle size={16} />
Add Policy
</Button>
</>
)}
</>
) : (
<>
<ModalClose asChild={true}>
<Button variant={"secondary"}>Cancel</Button>
</ModalClose>
<Button
variant={"primary"}
disabled={submitDisabled}
onClick={submit}
>
Save Changes
</Button>
</>
)}
</div>
</ModalFooter>
</ModalContent>

View File

@@ -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 (
<div className={"inline"}>
Peer <Value>{m.name}</Value> with ip <Value>{m.ip}</Value> was added
Peer <Value>{m.name}</Value> <PeerConnectionInfo meta={m} /> was added
with the NetBird IP <Value>{m.ip}</Value>
</div>
);
@@ -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 (
<div className={"inline"}>
Route <Value>{m.name}</Value> with the <Value>{m.network_range}</Value>{" "}
range was deleted
Route <Value>{m.name}</Value> with the {hasDomains ? "domain(s)" : ""}{" "}
<Value>{hasDomains ? m?.domains : m.network_range}</Value>{" "}
{hasDomains ? "" : "range"} was deleted
</div>
);
}
if (event.activity_code == "route.update")
if (event.activity_code == "route.update") {
let hasDomains = m?.domains && m?.domains.length > 0;
return (
<div className={"inline"}>
Route <Value>{m.name}</Value> with the <Value>{m.network_range}</Value>{" "}
range was updated
Route <Value>{m.name}</Value> with the {hasDomains ? "domain(s)" : ""}{" "}
<Value>{hasDomains ? m?.domains : m.network_range}</Value>{" "}
{hasDomains ? "" : "range"} was updated
</div>
);
}
if (event.activity_code == "route.add")
if (event.activity_code == "route.add") {
let hasDomains = m?.domains && m?.domains.length > 0;
return (
<div className={"inline"}>
Route <Value>{m.name}</Value> with the <Value>{m.network_range}</Value>{" "}
range was created
Route <Value>{m.name}</Value> with the {hasDomains ? "domain(s)" : ""}{" "}
<Value>{hasDomains ? m?.domains : m.network_range}</Value>{" "}
{hasDomains ? "" : "range"} was created
</div>
);
}
/**
* User
@@ -144,21 +158,24 @@ export default function ActivityDescription({ event }: Props) {
if (event.activity_code == "user.peer.delete")
return (
<div className={"inline"}>
Peer <Value>{m.name}</Value> with ip <Value>{m.ip}</Value> was deleted
Peer <Value>{m.name}</Value> <PeerConnectionInfo meta={m} /> with
NetBird IP <Value>{m.ip}</Value> was deleted
</div>
);
if (event.activity_code == "user.peer.add")
return (
<div className={"inline"}>
Peer <Value>{m.name}</Value> with ip <Value>{m.ip}</Value> was added
Peer <Value>{m.name}</Value> <PeerConnectionInfo meta={m} /> was added
with the NetBird IP <Value>{m.ip}</Value>
</div>
);
if (event.activity_code == "user.peer.update")
return (
<div className={"inline"}>
Peer <Value>{m.name}</Value> with ip <Value>{m.ip}</Value> was updated
Peer <Value>{m.name}</Value> <PeerConnectionInfo meta={m} /> with
NetBird IP <Value>{m.ip}</Value> was updated
</div>
);
@@ -252,15 +269,15 @@ export default function ActivityDescription({ event }: Props) {
if (event.activity_code == "peer.group.delete")
return (
<div className={"inline"}>
Group <Value>{m.group}</Value> was removed from the peer with the ip{" "}
<Value>{m.peer_ip}</Value>
Group <Value>{m.group}</Value> was removed from the peer with the
NetBird IP <Value>{m.peer_ip}</Value>
</div>
);
if (event.activity_code == "peer.group.add")
return (
<div className={"inline"}>
Group <Value>{m.group}</Value> was added to the peer with the ip{" "}
Group <Value>{m.group}</Value> was added to the peer with the NetBird IP{" "}
<Value>{m.peer_ip}</Value>
</div>
);
@@ -303,7 +320,7 @@ export default function ActivityDescription({ event }: Props) {
if (event.activity_code == "peer.rename")
return (
<div className={"inline"}>
Peer with the ip <Value>{m.ip}</Value> was renamed to{" "}
Peer with the NetBird IP <Value>{m.ip}</Value> was renamed to{" "}
<Value>{m.name}</Value>
</div>
);
@@ -311,7 +328,7 @@ export default function ActivityDescription({ event }: Props) {
if (event.activity_code == "peer.approve")
return (
<div className={"inline"}>
Peer with the ip <Value>{m.ip}</Value> was approved
Peer with the NetBird IP <Value>{m.ip}</Value> was approved
</div>
);
@@ -477,15 +494,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"}>
@@ -528,7 +576,7 @@ function Value({
return children ? (
<span
className={cn(
"text-nb-gray-200 inline font-medium bg-nb-gray-900 py-[3px] text-[11px] px-[5px] border border-nb-gray-800 rounded-[4px]",
"text-nb-gray-200 inline-flex gap-1 items-center max-h-[22px] font-medium bg-nb-gray-900 py-[3px] text-[11px] px-[5px] border border-nb-gray-800 rounded-[4px]",
className,
)}
>
@@ -536,3 +584,40 @@ function Value({
</span>
) : 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 && (
<Value>{meta?.location_connection_ip}</Value>
)}{" "}
{meta?.location_country_code && (
<Value>
{isEmpty(meta?.location_country_code) ? (
<GlobeIcon size={9} className={"text-nb-gray-300"} />
) : (
<RoundedFlag country={meta?.location_country_code} size={9} />
)}
{countryText}
</Value>
)}
</>
) : null;
}

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

@@ -1,3 +1,4 @@
import Badge from "@components/Badge";
import Button from "@components/Button";
import {
Modal,
@@ -10,6 +11,7 @@ import ModalHeader from "@components/modal/ModalHeader";
import { PeerGroupSelector } from "@components/PeerGroupSelector";
import Separator from "@components/Separator";
import MultipleGroups from "@components/ui/MultipleGroups";
import { IconCirclePlus } from "@tabler/icons-react";
import { FolderGit2 } from "lucide-react";
import * as React from "react";
import { useMemo } from "react";
@@ -27,6 +29,7 @@ type Props = {
label?: string;
description?: string;
peer?: Peer;
showAddGroupButton?: boolean;
};
export default function GroupsRow({
@@ -37,6 +40,7 @@ export default function GroupsRow({
label = "Assigned Groups",
description = "Use groups to control what this peer can access",
peer,
showAddGroupButton = false,
}: Props) {
const { groups: allGroups } = useGroups();
const { isUser } = useLoggedInUser();
@@ -59,7 +63,14 @@ export default function GroupsRow({
setModal && !isUser && setModal(true);
}}
>
<MultipleGroups groups={foundGroups} label={label} />
{foundGroups?.length == 0 && showAddGroupButton ? (
<Badge variant={"gray"} useHover={true}>
<IconCirclePlus size={14} />
Add Groups
</Badge>
) : (
<MultipleGroups groups={foundGroups} label={label} />
)}
</ModalTrigger>
<EditGroupsModal
groups={foundGroups}

View File

@@ -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,25 +98,12 @@ 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,
cell,
}: ModalProps) {
const nsRequest = useApiCall<NameserverGroup>("/dns/nameservers");
const nsRequest = useApiCall<NameserverGroup>("/dns/nameservers", true);
const { mutate } = useSWRConfig();
const isUpdate = useMemo(() => {
@@ -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 [];
});
@@ -233,25 +221,27 @@ export function NameserverModalContent({
return domains.some((d) => d.name === "");
}, [domains]);
const hasAnyError = useMemo(() => {
return (
const nameLengthError = useMemo(() => {
if (name.length > 40) return "Name should be less than 40 characters";
return "";
}, [name]);
const canContinueToDomains = useMemo(() => {
return !(
hasNSErrors ||
nsError ||
domainError ||
name == "" ||
nameservers.length == 0 ||
hasDomainErrors ||
groups.length == 0
);
}, [
nsError,
domainError,
name,
nameservers,
groups,
hasNSErrors,
hasDomainErrors,
]);
}, [hasNSErrors, nsError, nameservers.length, groups.length]);
const canContinueToGeneral = useMemo(() => {
return !(!canContinueToDomains || domainError || hasDomainErrors);
}, [canContinueToDomains, domainError, hasDomainErrors]);
const canSubmit = useMemo(() => {
return !(!canContinueToGeneral || nameLengthError !== "" || name == "");
}, [canContinueToGeneral, nameLengthError, name]);
return (
<ModalContent maxWidthClass={"max-w-xl"}>
@@ -262,7 +252,7 @@ export function NameserverModalContent({
color={"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={"nameserver"}>
<ServerIcon
@@ -273,7 +263,7 @@ export function NameserverModalContent({
/>
Nameserver
</TabsTrigger>
<TabsTrigger value={"domains"}>
<TabsTrigger value={"domains"} disabled={!canContinueToDomains}>
<GlobeIcon
size={16}
className={
@@ -282,7 +272,7 @@ export function NameserverModalContent({
/>
Domains
</TabsTrigger>
<TabsTrigger value={"general"}>
<TabsTrigger value={"general"} disabled={!canContinueToGeneral}>
<Text
size={16}
className={
@@ -368,7 +358,7 @@ export function NameserverModalContent({
<div className={"flex flex-col gap-2 w-full"}>
{domains.map((domain, i) => {
return (
<DomainInput
<InputDomain
key={domain.id}
value={domain}
onChange={(d) =>
@@ -427,6 +417,7 @@ export function NameserverModalContent({
<Input
autoFocus={true}
tabIndex={0}
error={nameLengthError}
placeholder={"e.g., Public DNS"}
value={name}
onChange={(e) => setName(e.target.value)}
@@ -465,20 +456,77 @@ export function NameserverModalContent({
</Paragraph>
</div>
<div className={"flex gap-3 w-full justify-end"}>
<ModalClose asChild={true}>
<Button variant={"secondary"}>Cancel</Button>
</ModalClose>
{!isUpdate ? (
<>
{tab == "nameserver" && (
<ModalClose asChild={true}>
<Button variant={"secondary"}>Cancel</Button>
</ModalClose>
)}
<Button variant={"primary"} disabled={hasAnyError} onClick={submit}>
{isUpdate ? (
<>Save Changes</>
) : (
<>
<PlusCircle size={16} />
Add Nameserver
</>
)}
</Button>
{tab == "domains" && (
<Button
variant={"secondary"}
onClick={() => setTab("nameserver")}
>
Back
</Button>
)}
{tab == "nameserver" && (
<Button
variant={"primary"}
onClick={() => setTab("domains")}
disabled={!canContinueToDomains}
>
Continue
</Button>
)}
{tab == "domains" && (
<Button
variant={"primary"}
onClick={() => setTab("general")}
disabled={!canContinueToGeneral}
>
Continue
</Button>
)}
{tab == "general" && (
<>
<Button
variant={"secondary"}
onClick={() => setTab("domains")}
>
Back
</Button>
<Button
variant={"primary"}
disabled={!canSubmit}
onClick={submit}
>
<PlusCircle size={16} />
Add Nameserver
</Button>
</>
)}
</>
) : (
<>
<ModalClose asChild={true}>
<Button variant={"secondary"}>Cancel</Button>
</ModalClose>
<Button
variant={"primary"}
disabled={!canSubmit}
onClick={submit}
>
Save Changes
</Button>
</>
)}
</div>
</ModalFooter>
</ModalContent>
@@ -516,7 +564,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 +580,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]"}
@@ -559,63 +607,3 @@ function NameserverInput({
</div>
);
}
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<HTMLInputElement>) => {
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 (
<div className={"flex gap-2 w-full"}>
<div className={"w-full"}>
<Input
customPrefix={<GlobeIcon size={15} />}
placeholder={"e.g., example.com"}
maxWidthClass={"w-full"}
value={name}
error={domainError}
onChange={handleNameChange}
/>
</div>
<Button
className={"h-[42px]"}
variant={"default-outline"}
onClick={onRemove}
>
<MinusCircleIcon size={15} />
</Button>
</div>
);
}

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"} />
Set Up 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,59 @@
import { DropdownMenuItem } from "@components/DropdownMenu";
import { Modal } from "@components/modal/Modal";
import { getOperatingSystem } from "@hooks/useOperatingSystem";
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 = {
peer: Peer;
};
export const ExitNodeDropdownButton = ({ peer }: Props) => {
const [modal, setModal] = useState(false);
const isLinux = getOperatingSystem(peer.os) === OperatingSystem.LINUX;
const hasExitNodes = useHasExitNodes(peer);
return isLinux ? (
<>
<DropdownMenuItem onClick={() => setModal(true)}>
<div className={"flex gap-3 items-center w-full"}>
{hasExitNodes ? (
<>
<IconCirclePlus size={14} className={"shrink-0"} />
<div className={"flex justify-between items-center w-full"}>
Add Exit Node
</div>
</>
) : (
<>
<IconDirectionSign
size={14}
className={"shrink-0 text-yellow-400"}
/>
<div className={"flex justify-between items-center w-full"}>
Set Up Exit Node
</div>
</>
)}
</div>
</DropdownMenuItem>
<Modal open={modal} onOpenChange={setModal}>
{modal && (
<RoutesProvider>
<RouteModalContent
onSuccess={() => setModal(false)}
peer={peer}
exitNode={true}
/>
</RoutesProvider>
)}
</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,19 @@
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 === "0.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

@@ -12,8 +12,9 @@ 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 = {
peer: Peer;
@@ -31,9 +32,24 @@ export const RouteTableColumns: ColumnDef<Route>[] = [
{
accessorKey: "network",
header: ({ column }) => {
return <DataTableHeader column={column}>Network Range</DataTableHeader>;
return <DataTableHeader column={column}>Network</DataTableHeader>;
},
cell: ({ row }) => <PeerRouteNetworkCell route={row.original} />,
cell: ({ row }) => (
<GroupedRouteNetworkRangeCell
domains={row.original?.domains}
network={row.original?.network}
/>
),
},
{
id: "groups",
accessorFn: (r) => r.groups?.length,
header: ({ column }) => {
return (
<DataTableHeader column={column}>Distribution Groups</DataTableHeader>
);
},
cell: ({ row }) => <RouteDistributionGroupsCell route={row.original} />,
},
{
id: "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

@@ -0,0 +1,310 @@
import Button from "@components/Button";
import HelpText from "@components/HelpText";
import InlineLink from "@components/InlineLink";
import { Input } from "@components/Input";
import { Label } from "@components/Label";
import { ModalClose, ModalFooter } from "@components/modal/Modal";
import Paragraph from "@components/Paragraph";
import { cn, validator } from "@utils/helpers";
import { isEmpty, uniqueId } from "lodash";
import {
ExternalLinkIcon,
MinusCircleIcon,
PlusCircle,
ServerCogIcon,
TerminalIcon,
} from "lucide-react";
import * as React from "react";
import { useMemo, useState } from "react";
import AppleIcon from "@/assets/icons/AppleIcon";
import WindowsIcon from "@/assets/icons/WindowsIcon";
import { Process, ProcessCheck } from "@/interfaces/PostureCheck";
import { PostureCheckCard } from "@/modules/posture-checks/ui/PostureCheckCard";
type Props = {
value?: ProcessCheck;
onChange: (value: ProcessCheck | undefined) => void;
};
export const PostureCheckProcess = ({ value, onChange }: Props) => {
const [open, setOpen] = useState(false);
return (
<PostureCheckCard
open={open}
setOpen={setOpen}
key={open ? 1 : 0}
active={value?.processes && value?.processes?.length > 0}
title={"Process"}
description={
"Restrict access in your network based on running processes of a peer."
}
icon={<ServerCogIcon size={18} />}
iconClass={"bg-gradient-to-tr from-nb-gray-500 to-nb-gray-300"}
modalWidthClass={"max-w-xl"}
onReset={() => onChange(undefined)}
>
<CheckContent
value={value}
onChange={(v) => {
onChange(v);
setOpen(false);
}}
/>
</PostureCheckCard>
);
};
const CheckContent = ({ value, onChange }: Props) => {
const [processes, setProcesses] = useState<Process[]>(
value?.processes
? value.processes.map((p) => {
return {
id: uniqueId("process"),
linux_path: p?.linux_path || "",
mac_path: p?.mac_path || "",
windows_path: p?.windows_path || "",
};
})
: [
{
id: uniqueId("process"),
linux_path: "",
mac_path: "",
windows_path: "",
},
],
);
const handleProcessChange = (
id: string,
linux_path: string,
mac_path: string,
windows_path: string,
) => {
const newProcesses = processes.map((p) =>
p.id === id ? { ...p, linux_path, mac_path, windows_path } : p,
);
setProcesses(newProcesses);
};
const removeProcess = (id: string) => {
const newProcesses = processes.filter((p) => p.id !== id);
setProcesses(newProcesses);
};
const addProcess = () => {
setProcesses([
...processes,
{
id: uniqueId("process"),
linux_path: "",
mac_path: "",
windows_path: "",
},
]);
};
const pathErrors = useMemo(() => {
if (processes && processes.length > 0) {
return processes.map((p) => {
return {
id: p.id,
errorMacPath: p?.mac_path
? validator.isValidUnixFilePath(p?.mac_path || "")
? ""
: "Please enter a valid macOS file path"
: "",
errorLinuxPath: p?.linux_path
? validator.isValidUnixFilePath(p?.linux_path || "")
? ""
: "Please enter a valid Unix file path"
: "",
errorWindowsPath: p?.windows_path
? validator.isValidWindowsFilePath(p?.windows_path || "")
? ""
: "Please enter a valid Windows file path"
: "",
};
});
} else {
return [];
}
}, [processes]);
const hasErrorsOrIsEmpty = useMemo(() => {
if (processes.length === 0) return true;
const hasOnlyEmptyPaths = processes.some(
(p) => p.linux_path === "" && p.mac_path === "" && p.windows_path === "",
);
const hasPathErrors = pathErrors.some(
(e) =>
e.errorLinuxPath !== "" ||
e.errorMacPath !== "" ||
e.errorWindowsPath !== "",
);
return hasOnlyEmptyPaths || hasPathErrors;
}, [processes, pathErrors]);
return (
<>
<div className={"flex flex-col px-8 gap-2 pb-6"}>
<div className={"flex justify-between items-start gap-10 mt-2"}>
<div>
<Label>Processes</Label>
<HelpText className={""}>
Add the path of an executable file of the process. You can define
a path for Linux, macOS and Windows. Peers will only be allowed to
connect if the process is running on their system.
</HelpText>
</div>
</div>
{processes.length > 0 && (
<div className={"mb-2 flex flex-col gap-4 w-full "}>
{processes.map((p) => {
return (
<div key={p.id} className={"flex gap-2 items-center"}>
<div className={"w-full flex flex-col gap-1.5"}>
<Input
customPrefix={<TerminalIcon size={16} />}
placeholder={"/usr/local/bin/netbird"}
value={p.linux_path}
error={
pathErrors.find((e) => e.id === p.id)?.errorLinuxPath
}
errorTooltip={true}
errorTooltipPosition={"top-right"}
className={"w-full"}
onChange={(e) =>
handleProcessChange(
p.id,
e.target.value,
p?.mac_path || "",
p?.windows_path || "",
)
}
/>
<Input
customPrefix={
<AppleIcon
size={16}
className={cn(
pathErrors.find((e) => e.id === p.id)
?.errorMacPath && "fill-red-500",
)}
/>
}
placeholder={
"/Applications/NetBird.app/Contents/MacOS/netbird"
}
value={p.mac_path}
error={
pathErrors.find((e) => e.id === p.id)?.errorMacPath
}
errorTooltip={true}
errorTooltipPosition={"top-right"}
className={"w-full"}
onChange={(e) =>
handleProcessChange(
p.id,
p?.linux_path || "",
e.target.value,
p?.windows_path || "",
)
}
/>
<Input
customPrefix={
<WindowsIcon
size={16}
className={cn(
pathErrors.find((e) => e.id === p.id)
?.errorWindowsPath && "fill-red-500",
)}
/>
}
placeholder={`C:\\ProgramData\\NetBird\\netbird.exe`}
value={p.windows_path}
errorTooltip={true}
errorTooltipPosition={"top-right"}
error={
pathErrors.find((e) => e.id === p.id)?.errorWindowsPath
}
className={"w-full"}
onChange={(e) =>
handleProcessChange(
p.id,
p?.linux_path || "",
p?.mac_path || "",
e.target.value,
)
}
/>
</div>
<Button
className={"h-[42px]"}
variant={"default-outline"}
onClick={() => removeProcess(p.id)}
>
<MinusCircleIcon size={15} />
</Button>
</div>
);
})}
</div>
)}
<Button
variant={"dotted"}
size={"sm"}
onClick={addProcess}
className={"mt-1"}
>
<PlusCircle size={16} />
Add Process
</Button>
</div>
<ModalFooter className={"items-center"}>
<div className={"w-full"}>
<Paragraph className={"text-sm mt-auto"}>
Learn more about
<InlineLink
href={
"https://docs.netbird.io/how-to/manage-posture-checks#process-check"
}
target={"_blank"}
>
Process Check
<ExternalLinkIcon size={12} />
</InlineLink>
</Paragraph>
</div>
<div className={"flex gap-3 w-full justify-end"}>
<ModalClose asChild={true}>
<Button variant={"secondary"}>Cancel</Button>
</ModalClose>
<Button
variant={"primary"}
disabled={hasErrorsOrIsEmpty}
onClick={() => {
if (isEmpty(processes)) {
onChange(undefined);
} else {
onChange({
processes: processes.filter(
(p) =>
p.linux_path !== "" ||
p.mac_path !== "" ||
p.windows_path !== "",
),
});
}
}}
>
Save
</Button>
</div>
</ModalFooter>
</>
);
};

View File

@@ -0,0 +1,112 @@
import Badge from "@components/Badge";
import FullTooltip from "@components/FullTooltip";
import { ScrollArea } from "@components/ScrollArea";
import { tryGetProcessNameFromPath } from "@utils/helpers";
import { TerminalIcon } from "lucide-react";
import * as React from "react";
import AppleIcon from "@/assets/icons/AppleIcon";
import WindowsIcon from "@/assets/icons/WindowsIcon";
import { ProcessCheck } from "@/interfaces/PostureCheck";
type Props = {
check?: ProcessCheck;
children?: React.ReactNode;
};
export const ProcessTooltip = ({ check, children }: Props) => {
return check ? (
<FullTooltip
className={"w-full min-w-0"}
interactive={true}
contentClassName={"p-0"}
content={
<div
className={
"text-neutral-300 text-sm max-w-xs flex flex-col gap-1 min-w-0"
}
>
<div className={"px-4 pt-3"}>
<span>
<span className={"text-green-500 font-semibold"}>Allow only</span>{" "}
peers which are running the following processes
</span>
</div>
<ScrollArea
className={
"max-h-[275px] overflow-y-auto flex flex-col px-4 min-w-0"
}
>
<div className={"flex flex-col gap-3 mt-1 text-xs mb-3.5 min-w-0"}>
{check.processes.map((p, index) => {
return (
<div className={"flex-col flex gap-1 min-w-0"} key={index}>
{p?.linux_path && (
<Badge
variant={"gray"}
useHover={false}
className={"justify-start font-medium text-xs min-w-0"}
>
<span className={"mr-1.5"}>
<TerminalIcon size={12} />
</span>
<span
className={"truncate inline-block "}
title={p?.linux_path}
>
{tryGetProcessNameFromPath(p?.linux_path) ||
"Unknown path"}
</span>
</Badge>
)}
{p?.mac_path && (
<Badge
variant={"gray"}
useHover={false}
className={"justify-start font-medium text-xs min-w-0"}
>
<span className={"mr-1.5"}>
<AppleIcon size={12} />
</span>
<span
className={"truncate inline-block "}
title={p?.mac_path}
>
{tryGetProcessNameFromPath(p?.mac_path) ||
"Unknown path"}
</span>
</Badge>
)}
{p?.windows_path && (
<Badge
variant={"gray"}
useHover={false}
className={"justify-start font-medium text-xs min-w-0"}
>
<span className={"mr-1.5"}>
<WindowsIcon size={12} />
</span>
<span
className={"truncate inline-block"}
title={p?.windows_path}
>
{tryGetProcessNameFromPath(p?.windows_path) ||
"Unknown path"}
</span>
</Badge>
)}
</div>
);
})}
</div>
</ScrollArea>
</div>
}
>
{children}
</FullTooltip>
) : (
children
);
};

View File

@@ -24,6 +24,7 @@ import { PostureCheckGeoLocation } from "@/modules/posture-checks/checks/Posture
import { PostureCheckNetBirdVersion } from "@/modules/posture-checks/checks/PostureCheckNetBirdVersion";
import { PostureCheckOperatingSystem } from "@/modules/posture-checks/checks/PostureCheckOperatingSystem";
import { PostureCheckPeerNetworkRange } from "@/modules/posture-checks/checks/PostureCheckPeerNetworkRange";
import { PostureCheckProcess } from "@/modules/posture-checks/checks/PostureCheckProcess";
type Props = {
open: boolean;
@@ -58,6 +59,9 @@ export default function PostureCheckModal({
const [peerNetworkRangeCheck, setPeerNetworkRangeCheck] = useState(
postureCheck?.checks.peer_network_range_check || undefined,
);
const [processCheck, setProcessCheck] = useState(
postureCheck?.checks.process_check || undefined,
);
const validateOSCheck = (osCheck?: OperatingSystemVersionCheck) => {
if (!osCheck) return;
@@ -98,6 +102,7 @@ export default function PostureCheckModal({
geo_location_check: validateLocationCheck(geoLocationCheck),
os_version_check: validateOSCheck(osVersionCheck),
peer_network_range_check: peerNetworkRangeCheck,
process_check: processCheck,
},
};
@@ -133,7 +138,8 @@ export default function PostureCheckModal({
!!nbVersionCheck ||
!!geoLocationCheck ||
!!osVersionCheck ||
!!peerNetworkRangeCheck;
!!peerNetworkRangeCheck ||
!!processCheck;
const canCreate = !isEmpty(name) && isAtLeastOneCheckEnabled;
const [tab, setTab] = useState("checks");
@@ -163,7 +169,10 @@ export default function PostureCheckModal({
Checks
</TabsTrigger>
<TabsTrigger value={"general"}>
<TabsTrigger
value={"general"}
disabled={!isAtLeastOneCheckEnabled}
>
<Text
size={16}
className={
@@ -184,13 +193,17 @@ export default function PostureCheckModal({
value={geoLocationCheck}
onChange={setGeoLocationCheckCheck}
/>
<PostureCheckPeerNetworkRange
value={peerNetworkRangeCheck}
onChange={setPeerNetworkRangeCheck}
/>
<PostureCheckOperatingSystem
value={osVersionCheck}
onChange={setOsVersionCheck}
/>
<PostureCheckPeerNetworkRange
value={peerNetworkRangeCheck}
onChange={setPeerNetworkRangeCheck}
<PostureCheckProcess
value={processCheck}
onChange={setProcessCheck}
/>
</>
</TabsContent>
@@ -243,12 +256,23 @@ export default function PostureCheckModal({
</div>
<div className={"flex gap-3 w-full justify-end"}>
<>
<Button
variant={"secondary"}
onClick={() => onOpenChange(false)}
>
Cancel
</Button>
{tab == "checks" && (
<Button
variant={"secondary"}
onClick={() => onOpenChange(false)}
>
Cancel
</Button>
)}
{tab == "general" && (
<Button
variant={"secondary"}
onClick={() => setTab("checks")}
>
Back
</Button>
)}
{!postureCheck && tab == "checks" && (
<Button

View File

@@ -1,5 +1,5 @@
import { cn } from "@utils/helpers";
import { Disc3Icon, FlagIcon, NetworkIcon } from "lucide-react";
import { Disc3Icon, FlagIcon, NetworkIcon, ServerCogIcon } from "lucide-react";
import * as React from "react";
import NetBirdIcon from "@/assets/icons/NetBirdIcon";
import { PostureCheck } from "@/interfaces/PostureCheck";
@@ -7,6 +7,7 @@ import { GeoLocationTooltip } from "@/modules/posture-checks/checks/tooltips/Geo
import { NetBirdVersionTooltip } from "@/modules/posture-checks/checks/tooltips/NetBirdVersionTooltip";
import { OperatingSystemTooltip } from "@/modules/posture-checks/checks/tooltips/OperatingSystemTooltip";
import { PeerNetworkRangeTooltip } from "@/modules/posture-checks/checks/tooltips/PeerNetworkRangeTooltip";
import { ProcessTooltip } from "@/modules/posture-checks/checks/tooltips/ProcessTooltip";
type Props = {
check: PostureCheck;
@@ -71,6 +72,18 @@ export const PostureCheckChecksCell = ({ check }: Props) => {
</div>
</PeerNetworkRangeTooltip>
)}
{check.checks.process_check && (
<ProcessTooltip check={check.checks.process_check}>
<div
className={cn(
"bg-gradient-to-tr from-nb-gray-500 to-nb-gray-300 h-8 w-8 rounded-full flex items-center justify-center relative z-[8] hover:scale-[1.1] transition-all",
)}
>
<ServerCogIcon size={14} />
</div>
</ProcessTooltip>
)}
</div>
</div>
</div>

View File

@@ -49,7 +49,10 @@ export const PostureCheckPolicyUsageCell = ({ check }: Props) => {
interactive={false}
>
<Badge
onClick={() => router.push("/access-control")}
onClick={(e) => {
e.stopPropagation();
router.push("/access-control");
}}
variant={"gray"}
useHover={!!(check.policies && check.policies?.length > 0)}
className={cn(

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

@@ -2,9 +2,13 @@ import { TabsTrigger } from "@components/Tabs";
import { ShieldCheck } from "lucide-react";
import * as React from "react";
export const PostureCheckTabTrigger = () => {
type Props = {
disabled?: boolean;
};
export const PostureCheckTabTrigger = ({ disabled = false }: Props) => {
return (
<TabsTrigger value={"posture_checks"}>
<TabsTrigger value={"posture_checks"} disabled={disabled}>
<ShieldCheck size={16} />
Posture Checks
</TabsTrigger>

View File

@@ -1,8 +1,36 @@
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) {
return (
export default function GroupedRouteNetworkRangeCell({
network,
domains,
}: Props) {
const isExitNode = network === "0.0.0.0/0";
const hasDomains = domains ? domains.length > 0 : false;
return hasDomains && domains ? (
<DomainListBadge domains={domains} />
) : 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";
@@ -38,6 +39,14 @@ export const GroupedRouteTableColumns: ColumnDef<GroupedRoute>[] = [
accessorKey: "description",
sortingFn: "text",
},
{
accessorKey: "description_search",
sortingFn: "text",
},
{
accessorKey: "domain_search",
sortingFn: "text",
},
{
id: "enabled",
accessorKey: "enabled",
@@ -49,13 +58,22 @@ export const GroupedRouteTableColumns: ColumnDef<GroupedRoute>[] = [
return row.group_names?.map((name) => name).join(", ");
},
},
{
id: "domains",
accessorFn: (row) => {
return row.domains?.map((name) => name).join(", ");
},
},
{
accessorKey: "network",
header: ({ column }) => {
return <DataTableHeader column={column}>Network Range</DataTableHeader>;
return <DataTableHeader column={column}>Network</DataTableHeader>;
},
cell: ({ row }) => (
<GroupedRouteNetworkRangeCell network={row.original.network} />
<GroupedRouteNetworkRangeCell
network={row.original.network}
domains={row.original?.domains}
/>
),
},
{
@@ -131,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);
@@ -157,12 +178,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 +207,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

@@ -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,
});

View File

@@ -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) {
<div>
<Label>Routing Peer</Label>
<HelpText>
Assign a single peer as a routing peer for the Network CIDR.
Assign a single peer as a routing peer for the network route.
</HelpText>
<PeerSelector
onChange={setRoutingPeer}

View File

@@ -1,7 +1,9 @@
"use client";
import Button from "@components/Button";
import ButtonGroup from "@components/ButtonGroup";
import FancyToggleSwitch from "@components/FancyToggleSwitch";
import FullTooltip from "@components/FullTooltip";
import HelpText from "@components/HelpText";
import InlineLink from "@components/InlineLink";
import { Input } from "@components/Input";
@@ -20,23 +22,29 @@ import { PeerSelector } from "@components/PeerSelector";
import { SegmentedTabs } from "@components/SegmentedTabs";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@components/Tabs";
import { Textarea } from "@components/Textarea";
import InputDomain, { domainReducer } from "@components/ui/InputDomain";
import { IconDirectionSign } from "@tabler/icons-react";
import { cn } from "@utils/helpers";
import cidr from "ip-cidr";
import { uniqBy } from "lodash";
import {
ArrowDownWideNarrow,
CircleHelp,
ExternalLinkIcon,
FolderGit2,
GlobeIcon,
GlobeLockIcon,
MonitorSmartphoneIcon,
NetworkIcon,
PlusCircle,
PlusIcon,
Power,
RouteIcon,
Settings2,
Text,
VenetianMask,
} from "lucide-react";
import React, { useMemo, useRef, useState } from "react";
import React, { useEffect, useMemo, useReducer, useRef, useState } from "react";
import NetworkRoutesIcon from "@/assets/icons/NetworkRoutesIcon";
import { useRoutes } from "@/contexts/RoutesProvider";
import { Peer } from "@/interfaces/Peer";
@@ -63,19 +71,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 +109,49 @@ export function RouteModalContent({ onSuccess, peer }: ModalProps) {
initial: [],
});
/**
* DNS Routes
* IP Range or Domain Tab = ip-range or domains
*/
const [domainRoutes, setDomainRoutes] = useReducer(domainReducer, []);
const [domainError, setDomainError] = useState<boolean>(false);
const [routeType, setRouteTyp] = useState<string>("ip-range");
const [keepRoute, setKeepRoute] = useState<boolean>(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
*/
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();
@@ -130,6 +175,11 @@ export function RouteModalContent({ onSuccess, peer }: ModalProps) {
.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(
{
@@ -138,7 +188,9 @@ export function RouteModalContent({ onSuccess, peer }: ModalProps) {
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,
@@ -147,36 +199,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 ||
!isDomainOrRangeEntered
);
}, [
networkIdentifier,
cidrError,
peerTab,
routingPeerGroups.length,
routingPeer,
groups,
isDomainOrRangeEntered,
]);
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
? "Set Up 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 +299,7 @@ export function RouteModalContent({ onSuccess, peer }: ModalProps) {
</TabsTrigger>
<TabsTrigger
value={"general"}
disabled={!isNetworkEntered}
onClick={() => nameRef.current?.focus()}
>
<Text
@@ -202,7 +310,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 +323,196 @@ 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>Route Type</Label>
<HelpText>
Select your route type to add either a network range or a list
of domains.
</HelpText>
<div className={"flex justify-between items-center w-full"}>
<ButtonGroup className={"w-full"}>
<ButtonGroup.Button
variant={routeType == "ip-range" ? "tertiary" : "secondary"}
onClick={() => setRouteTyp("ip-range")}
className={"w-full"}
>
<NetworkIcon size={16} />
Network Range
</ButtonGroup.Button>
<ButtonGroup.Button
variant={routeType == "domains" ? "tertiary" : "secondary"}
onClick={() => setRouteTyp("domains")}
className={"w-full"}
>
<GlobeIcon size={16} />
Domains
</ButtonGroup.Button>
</ButtonGroup>
</div>
<div
className={cn(
"mt-5 mb-3",
routeType !== "ip-range" && "hidden",
)}
>
<Label>Network Range</Label>
<HelpText>Add a private IPv4 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>
<div
className={cn("mt-5 mb-3", routeType !== "domains" && "hidden")}
>
<Label>Domains</Label>
<HelpText>
Add domains that dynamically resolve to one or more IPv4
addresses
</HelpText>
<div>
{domainRoutes.length > 0 && (
<div className={"flex gap-3 w-full mb-3"}>
<div className={"flex flex-col gap-2 w-full"}>
{domainRoutes.map((domain, i) => {
return (
<InputDomain
key={domain.id}
value={domain}
onChange={(d) =>
setDomainRoutes({
type: "UPDATE",
index: i,
d,
})
}
onError={setDomainError}
onRemove={() =>
setDomainRoutes({
type: "REMOVE",
index: i,
})
}
/>
);
})}
</div>
</div>
)}
<Button
variant={"dotted"}
className={"w-full"}
size={"sm"}
onClick={() => setDomainRoutes({ type: "ADD" })}
>
<PlusIcon size={14} />
Add Domain
</Button>
</div>
<div className={cn("mt-6 w-full")}>
<FullTooltip
side={"top"}
content={
<div className={"text-xs max-w-xs"}>
DNS records for load-balanced systems often change.
Keeping resolved addresses ensures ongoing connections
to active resources remain uninterrupted.
</div>
}
>
<FancyToggleSwitch
value={keepRoute}
onChange={setKeepRoute}
label={
<>
<div className={"flex gap-2"}>
<GlobeLockIcon size={14} />
Keep Routes
<CircleHelp
size={12}
className={"top-[1px] relative text-nb-gray-300"}
/>
</div>
</>
}
helpText={
<div>
Retain previously resolved routes after IP address
updates to maintain stable connections.
</div>
}
/>
</FullTooltip>
</div>
</div>
</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 route."}
</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 +521,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 +546,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,24 +559,27 @@ 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>
<Label>Metric</Label>
<HelpText className={"max-w-[200px]"}>
Lower metrics indicating higher priority routes.
A lower metric indicates a higher priority route.
</HelpText>
</div>
@@ -346,6 +588,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 +610,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

@@ -9,7 +9,6 @@ import RouteActiveCell from "@/modules/routes/RouteActiveCell";
import RouteDistributionGroupsCell from "@/modules/routes/RouteDistributionGroupsCell";
import RouteMetricCell from "@/modules/routes/RouteMetricCell";
import RoutePeerCell from "@/modules/routes/RoutePeerCell";
import RouteUpdateModal from "@/modules/routes/RouteUpdateModal";
type Props = {
row: GroupedRoute;
@@ -23,6 +22,20 @@ export const RouteTableColumns: ColumnDef<Route>[] = [
sortingFn: "text",
cell: ({ row }) => <RoutePeerCell route={row.original} />,
},
{
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<Route>();
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 && (
<RouteUpdateModal
route={currentRow}
open={editModal}
onOpenChange={setEditModal}
cell={currentCellClicked}
/>
)}
<DataTable
tableClassName={"mt-0"}
minimal={true}
@@ -122,11 +125,9 @@ export default function RouteTable({ row }: Props) {
sorting={sorting}
columnVisibility={{
group_names: false,
}}
onRowClick={(row, cell) => {
setCurrentRow(row.original);
setEditModal(true);
setCurrentCellClicked(cell);
description: false,
domains: false,
domain_search: false,
}}
setSorting={setSorting}
columns={RouteTableColumns}

View File

@@ -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<Peer | undefined>(() => {
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) {
@@ -197,27 +220,56 @@ 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",
);
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 (
<ModalContent maxWidthClass={"max-w-xl"}>
<ModalHeader
icon={<NetworkRoutesIcon className={"fill-netbird"} />}
title={"Update " + route.network_id}
description={route.network}
description={routeInfo}
color={"netbird"}
/>
truncate={true}
>
{route?.domains && (
<DomainsTooltip domains={route.domains} className={"block"}>
<Paragraph className={cn("text-sm", "!block truncate")}>
{routeInfo}
</Paragraph>
</DomainsTooltip>
)}
</ModalHeader>
<Tabs defaultValue={tab} onValueChange={(v) => setTab(v)}>
<TabsList justify={"start"} className={"px-8"}>
@@ -262,7 +314,8 @@ function RouteUpdateModalContent({ onSuccess, route, cell }: ModalProps) {
<div>
<Label>Routing Peer</Label>
<HelpText>
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."}
</HelpText>
<PeerSelector
onChange={setRoutingPeer}
@@ -274,8 +327,8 @@ function RouteUpdateModalContent({ onSuccess, route, cell }: ModalProps) {
<div>
<Label>Peer Group</Label>
<HelpText>
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."}
</HelpText>
<PeerGroupSelector
max={1}
@@ -326,24 +379,26 @@ function RouteUpdateModalContent({ onSuccess, route, cell }: 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."
}
/>
{!isExitNode && (
<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>
<Label>Metric</Label>
<HelpText className={"max-w-[200px]"}>
Lower metrics indicating higher priority routes.
A lower metric indicates a higher priority route.
</HelpText>
</div>
@@ -352,6 +407,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

@@ -0,0 +1,37 @@
import Badge from "@components/Badge";
import FullTooltip from "@components/FullTooltip";
import { cn } from "@utils/helpers";
import { HelpCircle } from "lucide-react";
import * as React from "react";
import EmptyRow from "@/modules/common-table-rows/EmptyRow";
type Props = {
ephemeral: boolean;
};
export default function SetupKeyEphemeralCell({ ephemeral }: Props) {
return ephemeral ? (
<FullTooltip
interactive={false}
content={
<div className={"max-w-xs text-xs"}>
Peers that are offline for over 10 minutes will be removed
automatically.
</div>
}
disabled={!ephemeral}
>
<Badge variant={"gray"}>
<span
className={cn(
"h-2 w-2 rounded-full mr-0.5",
ephemeral ? "bg-yellow-500" : "bg-nb-gray-400",
)}
></span>
Ephemeral
<HelpCircle size={12} />
</Badge>
</FullTooltip>
) : (
<EmptyRow />
);
}

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,
})
@@ -46,6 +46,7 @@ export default function SetupKeyGroupsCell({ setupKey }: Props) {
}
groups={setupKey.auto_groups || []}
onSave={handleSave}
showAddGroupButton={true}
modal={modal}
setModal={setModal}
/>

View File

@@ -87,7 +87,11 @@ export default function SetupKeyModal({ children, open, setOpen }: Props) {
</div>
</div>
<div className={"px-8 pb-6"}>
<div
className={"px-8 pb-6"}
data-cy={"setup-key-copy-input"}
data-cy-setup-key-value={setupKey?.key || ""}
>
<Code message={copyMessage}>
<Code.Line>
{setupKey?.key || "Setup key could not be created..."}
@@ -101,6 +105,7 @@ export default function SetupKeyModal({ children, open, setOpen }: Props) {
variant={"secondary"}
className={"w-full"}
tabIndex={-1}
data-cy={"setup-key-close"}
>
Close
</Button>
@@ -108,6 +113,7 @@ export default function SetupKeyModal({ children, open, setOpen }: Props) {
<Button
variant={"primary"}
className={"w-full"}
data-cy={"setup-key-copy"}
onClick={() => copy(copyMessage)}
>
<CopyIcon size={14} />
@@ -126,7 +132,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 +149,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;
@@ -194,6 +208,7 @@ export function SetupKeyModalContent({ onSuccess }: ModalProps) {
<Input
placeholder={"e.g., AWS Servers"}
value={name}
data-cy={"setup-key-name"}
onChange={(e) => setName(e.target.value)}
/>
</div>
@@ -225,6 +240,7 @@ export function SetupKeyModalContent({ onSuccess }: ModalProps) {
disabled={!reusable}
value={usageLimit}
type={"number"}
data-cy={"setup-key-usage-limit"}
onChange={(e) => setUsageLimit(e.target.value)}
placeholder={usageLimitPlaceholder}
customPrefix={
@@ -245,7 +261,10 @@ export function SetupKeyModalContent({ onSuccess }: ModalProps) {
min={1}
max={365}
value={expiresIn}
error={expiresInError}
errorTooltip={true}
type={"number"}
data-cy={"setup-key-expire-in-days"}
onChange={(e) => setExpiresIn(e.target.value)}
customPrefix={
<AlarmClock size={16} className={"text-nb-gray-300"} />
@@ -302,7 +321,12 @@ export function SetupKeyModalContent({ onSuccess }: ModalProps) {
<Button variant={"secondary"}>Cancel</Button>
</ModalClose>
<Button variant={"primary"} onClick={submit} disabled={isDisabled}>
<Button
variant={"primary"}
onClick={submit}
disabled={isDisabled}
data-cy={"create-setup-key"}
>
<PlusCircle size={16} />
Create Setup Key
</Button>

View File

@@ -3,7 +3,20 @@ import ActiveInactiveRow from "@/modules/common-table-rows/ActiveInactiveRow";
type Props = {
name: string;
valid: boolean;
secret?: string;
};
export default function SetupKeyNameCell({ valid, name }: Props) {
return <ActiveInactiveRow active={valid} inactiveDot={"red"} text={name} />;
export default function SetupKeyNameCell({ name, valid, secret }: Props) {
return (
<ActiveInactiveRow
active={valid || false}
inactiveDot={"red"}
text={name || ""}
>
{secret && (
<span className={"font-mono text-xs text-nb-gray-400 mt-1"}>
{secret.substring(0, 5) + "****"}
</span>
)}
</ActiveInactiveRow>
);
}

View File

@@ -1,4 +1,5 @@
import { MonitorSmartphoneIcon } from "lucide-react";
import { IconRepeat } from "@tabler/icons-react";
import { Repeat1 } from "lucide-react";
type Props = {
current: number;
@@ -7,14 +8,16 @@ type Props = {
};
export default function SetupKeyUsageCell({ current, limit, reusable }: Props) {
return reusable ? (
<div className={"flex gap-1 flex-col"}>
<div className={"flex items-center gap-2"}>
<MonitorSmartphoneIcon size={14} />
{current} of {limit} Peers
</div>
<div></div>
<div className={"flex items-center text-[13px] text-nb-gray-300 gap-2"}>
<IconRepeat size={14} className={"text-green-400"} />
<span>
<span className={"font-medium text-nb-gray-200"}> {current} </span> of{" "}
{limit == 0 ? <>Unlimited</> : limit} Peers
</span>
</div>
) : (
<div className={"text-nb-gray-800"}>-</div>
<div className={"flex items-center text-[13px] text-nb-gray-300 gap-2"}>
<Repeat1 size={14} /> One-off
</div>
);
}

View File

@@ -3,10 +3,11 @@ import ButtonGroup from "@components/ButtonGroup";
import InlineLink from "@components/InlineLink";
import SquareIcon from "@components/SquareIcon";
import { DataTable } from "@components/table/DataTable";
import DataTableHeader from "@components/table/DataTableHeader";
import DataTableRefreshButton from "@components/table/DataTableRefreshButton";
import { DataTableRowsPerPage } from "@components/table/DataTableRowsPerPage";
import GetStartedTest from "@components/ui/GetStartedTest";
import { SortingState } from "@tanstack/react-table";
import { ColumnDef, SortingState } from "@tanstack/react-table";
import { ExternalLinkIcon, PlusCircle } from "lucide-react";
import { usePathname } from "next/navigation";
import React, { useState } from "react";
@@ -14,8 +15,96 @@ import { useSWRConfig } from "swr";
import SetupKeysIcon from "@/assets/icons/SetupKeysIcon";
import { useLocalStorage } from "@/hooks/useLocalStorage";
import { SetupKey } from "@/interfaces/SetupKey";
import ExpirationDateRow from "@/modules/common-table-rows/ExpirationDateRow";
import LastTimeRow from "@/modules/common-table-rows/LastTimeRow";
import SetupKeyActionCell from "@/modules/setup-keys/SetupKeyActionCell";
import SetupKeyEphemeralCell from "@/modules/setup-keys/SetupKeyEphemeralCell";
import SetupKeyGroupsCell from "@/modules/setup-keys/SetupKeyGroupsCell";
import SetupKeyModal from "@/modules/setup-keys/SetupKeyModal";
import { SetupKeysTableColumns } from "@/modules/setup-keys/SetupKeysTableColumns";
import SetupKeyNameCell from "@/modules/setup-keys/SetupKeyNameCell";
import SetupKeyUsageCell from "@/modules/setup-keys/SetupKeyUsageCell";
export const SetupKeysTableColumns: ColumnDef<SetupKey>[] = [
{
accessorKey: "name",
header: ({ column }) => {
return <DataTableHeader column={column}>Name & Key</DataTableHeader>;
},
sortingFn: "text",
cell: ({ row }) => (
<SetupKeyNameCell
name={row.original.name}
valid={row.original.valid}
secret={row.original.key}
/>
),
},
{
id: "valid",
accessorKey: "valid",
sortingFn: "basic",
},
{
accessorKey: "usage_limit",
header: ({ column }) => {
return <DataTableHeader column={column}>Usage</DataTableHeader>;
},
cell: ({ row }) => (
<SetupKeyUsageCell
current={row.original.used_times}
limit={row.original.usage_limit || 0}
reusable={row.original.type == "reusable"}
/>
),
},
{
accessorKey: "last_used",
header: ({ column }) => {
return <DataTableHeader column={column}>Last used</DataTableHeader>;
},
sortingFn: "datetime",
cell: ({ row }) => (
<LastTimeRow date={row.original.last_used} text={"Last used on"} />
),
},
{
id: "group_strings",
accessorKey: "group_strings",
accessorFn: (s) => s.groups?.map((g) => g?.name || "").join(", "),
},
{
accessorFn: (item) => item.auto_groups?.length,
id: "groups",
header: ({ column }) => {
return <DataTableHeader column={column}>Groups</DataTableHeader>;
},
cell: ({ row }) => <SetupKeyGroupsCell setupKey={row.original} />,
},
{
accessorKey: "ephemeral",
header: ({ column }) => {
return <DataTableHeader column={column}>Ephemeral</DataTableHeader>;
},
cell: ({ row }) => (
<SetupKeyEphemeralCell ephemeral={row.original.ephemeral} />
),
},
{
accessorKey: "expires",
header: ({ column }) => {
return <DataTableHeader column={column}>Expires</DataTableHeader>;
},
cell: ({ row }) => <ExpirationDateRow date={row.original.expires} />,
},
{
accessorKey: "id",
header: "",
cell: ({ row }) => {
return <SetupKeyActionCell setupKey={row.original} />;
},
},
];
type Props = {
setupKeys?: SetupKey[];
@@ -33,10 +122,6 @@ export default function SetupKeysTable({ setupKeys, isLoading }: Props) {
id: "valid",
desc: true,
},
{
id: "type",
desc: true,
},
{
id: "last_used",
desc: true,
@@ -52,7 +137,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

@@ -1,104 +0,0 @@
"use client";
import DataTableHeader from "@components/table/DataTableHeader";
import { ColumnDef } from "@tanstack/react-table";
import * as React from "react";
import { SetupKey } from "@/interfaces/SetupKey";
import ExpirationDateRow from "@/modules/common-table-rows/ExpirationDateRow";
import LastTimeRow from "@/modules/common-table-rows/LastTimeRow";
import SetupKeyActionCell from "@/modules/setup-keys/SetupKeyActionCell";
import SetupKeyGroupsCell from "@/modules/setup-keys/SetupKeyGroupsCell";
import SetupKeyKeyCell from "@/modules/setup-keys/SetupKeyKeyCell";
import SetupKeyNameCell from "@/modules/setup-keys/SetupKeyNameCell";
import SetupKeyTypeCell from "@/modules/setup-keys/SetupKeyTypeCell";
export const SetupKeysTableColumns: ColumnDef<SetupKey>[] = [
/* {
id: "select",
header: ({ table }) => (
<Checkbox
checked={table.getIsAllPageRowsSelected()}
onCheckedChange={(value) => table.toggleAllRowsSelected(!!value)}
aria-label="Select all"
/>
),
cell: ({ row }) => (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
aria-label="Select row"
/>
),
enableSorting: false,
enableHiding: false,
},*/
{
accessorKey: "name",
header: ({ column }) => {
return <DataTableHeader column={column}>Name</DataTableHeader>;
},
sortingFn: "text",
cell: ({ row }) => (
<SetupKeyNameCell valid={row.original.valid} name={row.original.name} />
),
},
{
id: "valid",
accessorKey: "valid",
sortingFn: "basic",
},
{
accessorKey: "type",
header: ({ column }) => {
return <DataTableHeader column={column}>Reusable</DataTableHeader>;
},
cell: ({ row }) => (
<SetupKeyTypeCell reusable={row.original.type === "reusable"} />
),
},
{
accessorKey: "key",
header: ({ column }) => {
return <DataTableHeader column={column}>Key</DataTableHeader>;
},
cell: ({ row }) => <SetupKeyKeyCell text={row.original.key} />,
},
{
id: "group_strings",
accessorKey: "group_strings",
accessorFn: (s) => s.groups?.map((g) => g.name).join(", "),
},
{
accessorKey: "last_used",
header: ({ column }) => {
return <DataTableHeader column={column}>Last used</DataTableHeader>;
},
sortingFn: "datetime",
cell: ({ row }) => (
<LastTimeRow date={row.original.last_used} text={"Last used on"} />
),
},
{
accessorFn: (item) => item.auto_groups?.length,
id: "groups",
header: ({ column }) => {
return <DataTableHeader column={column}>Groups</DataTableHeader>;
},
cell: ({ row }) => <SetupKeyGroupsCell setupKey={row.original} />,
},
{
accessorKey: "expires",
header: ({ column }) => {
return <DataTableHeader column={column}>Expires</DataTableHeader>;
},
cell: ({ row }) => <ExpirationDateRow date={row.original.expires} />,
},
{
accessorKey: "id",
header: "",
cell: ({ row }) => {
return <SetupKeyActionCell setupKey={row.original} />;
},
},
];

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);

View File

@@ -27,9 +27,11 @@ const loadConfig = (): Config => {
let silentRedirectURI = "/#silent-callback";
let tokenSource = "accessToken";
if (process.env.NODE_ENV !== "production") {
if (process.env.APP_ENV === "test") {
configJson = require("@/config/test");
} else if (process.env.NODE_ENV === "development") {
configJson = require("@/config/local");
} else {
} else if (process.env.NODE_ENV === "production") {
configJson = require("@/config/production");
}

View File

@@ -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}(?<!-)\.){1,126}(?!-)[a-zA-Z0-9-]{1,63}(?<!-)$/;
const unicodeDomain =
/^(?!.*\.\.)(?!.*\.$)(?!.*\s)(?:(?!-)(?!.*--)[a-zA-Z0-9\u00A1-\uFFFF-]{1,63}(?<!-)\.)+(?!-)(?!.*--)[a-zA-Z0-9\u00A1-\uFFFF-]{2,63}$/u;
try {
return domain.match(regExp);
const minMaxChars = [1, 255];
const isValidDomainLength =
domain.length >= 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;
}
@@ -60,7 +70,7 @@ export const validator = {
isValidUrl: (urlString: string) => {
const urlPattern = new RegExp(
"^(https?:\\/\\/)?" + // validate protocol
"((([a-z\\d]([a-z\\d-]*[a-z\\d])*)\\.)+[a-z]{2,}|" + // validate domain name
"((([a-z\\d]([a-z\\d-]*[a-z\\d])*)\\.)+[a-z]{2,}|localhost|" + // validate domain name
"((\\d{1,3}\\.){3}\\d{1,3}))" + // validate OR ip (v4) address
"(\\:\\d+)?(\\/[-a-z\\d%_.~+]*)*" + // validate port and path
"(\\?[;&a-z\\d%_.~+=-]*)?" + // validate query string
@@ -74,8 +84,37 @@ export const validator = {
/^(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$/;
return semverRegex.test(version);
},
isValidUnixFilePath: (path: string) => {
const endsWithSlash = path.endsWith("/");
const unixPathRegex = /^\/(?:[^/]+\/)*[^/]+$/;
const isValid = unixPathRegex.test(path);
return isValid && !endsWithSlash;
},
isValidWindowsFilePath: (path: string) => {
const endsWithBackSlash = path.endsWith("\\");
const windowsPathRegex =
/^[a-zA-Z]:\\(?:[^\\/:*?"<>|\r\n]+\\)*[^\\/:*?"<>|\r\n]*$/;
const isValid = windowsPathRegex.test(path);
return isValid && !endsWithBackSlash;
},
};
export function isInt(n: number) {
return n % 1 === 0;
}
export function tryGetProcessNameFromPath(path: string) {
try {
const canSplitByForwardSlash = path.includes("/");
const canSplitByBackSlash = path.includes("\\");
const byForwardSlash = canSplitByForwardSlash
? path.split("/").pop()
: undefined;
const byBackSlash = canSplitByBackSlash
? path.split("\\").pop()
: undefined;
return byForwardSlash || byBackSlash || path;
} catch (e) {
return path;
}
}

View File

@@ -34,6 +34,9 @@
"@/config/local": [
"./.local-config.json"
],
"@/config/test": [
"./.test-config.json"
],
"@components/*": [
"./src/components/*"
],