Add posture checks to further restrict network access (#338)

This commit is contained in:
Eduard Gert
2024-02-22 13:58:41 +01:00
committed by GitHub
parent 8ffdb442f1
commit a5fc05ca3a
609 changed files with 25238 additions and 273 deletions

View File

@@ -28,9 +28,10 @@ export default function AccessControlPage() {
<Breadcrumbs.Item
href={"/policies"}
label={"Access Control"}
icon={<AccessControlIcon size={13} />}
icon={<AccessControlIcon size={14} />}
/>
</Breadcrumbs>
<h1>
{policies && policies.length > 1
? `${policies.length} Access Control Policies`

View File

@@ -339,10 +339,12 @@ function PeerInformationCard({ peer }: { peer: Peer }) {
</>
}
value={
dayjs(peer.last_seen).format("D MMMM, YYYY [at] h:mm A") +
" (" +
dayjs().to(peer.last_seen) +
")"
peer.connected
? "just now"
: dayjs(peer.last_seen).format("D MMMM, YYYY [at] h:mm A") +
" (" +
dayjs().to(peer.last_seen) +
")"
}
/>
<Card.ListItem

View File

@@ -0,0 +1,8 @@
import { globalMetaTitle } from "@utils/meta";
import type { Metadata } from "next";
import BlankLayout from "@/layouts/BlankLayout";
export const metadata: Metadata = {
title: `Posture Checks - ${globalMetaTitle}`,
};
export default BlankLayout;

View File

@@ -0,0 +1,72 @@
"use client";
import Breadcrumbs from "@components/Breadcrumbs";
import InlineLink from "@components/InlineLink";
import Paragraph from "@components/Paragraph";
import SkeletonTable from "@components/skeletons/SkeletonTable";
import { RestrictedAccess } from "@components/ui/RestrictedAccess";
import useFetchApi from "@utils/api";
import { ExternalLinkIcon, ShieldCheck } from "lucide-react";
import React, { lazy, Suspense } from "react";
import AccessControlIcon from "@/assets/icons/AccessControlIcon";
import GroupsProvider from "@/contexts/GroupsProvider";
import PoliciesProvider from "@/contexts/PoliciesProvider";
import { PostureCheck } from "@/interfaces/PostureCheck";
import PageContainer from "@/layouts/PageContainer";
const PostureCheckTable = lazy(
() => import("@/modules/posture-checks/table/PostureCheckTable"),
);
export default function PostureChecksPage() {
const { data: postureChecks, isLoading } =
useFetchApi<PostureCheck[]>("/posture-checks");
return (
<PageContainer>
<GroupsProvider>
<div className={"p-default py-6"}>
<Breadcrumbs>
<Breadcrumbs.Item
href={"/access-control"}
label={"Access Control"}
icon={<AccessControlIcon size={14} />}
/>
<Breadcrumbs.Item
href={"/posture-checks"}
label={"Posture Checks"}
active
icon={<ShieldCheck size={15} />}
/>
</Breadcrumbs>
<h1>
{postureChecks && postureChecks.length > 1
? `${postureChecks.length} Posture Checks`
: "Posture Checks"}
</h1>
<Paragraph>
Use posture checks to further restrict access in your network.
</Paragraph>
<Paragraph>
Learn more about
<InlineLink href={"#"} target={"_blank"}>
Posture Checks
<ExternalLinkIcon size={12} />
</InlineLink>
in our documentation.
</Paragraph>
</div>
<RestrictedAccess page={"Posture Checks"}>
<PoliciesProvider>
<Suspense fallback={<SkeletonTable />}>
<PostureCheckTable
isLoading={isLoading}
postureChecks={postureChecks}
/>
</Suspense>
</PoliciesProvider>
</RestrictedAccess>
</GroupsProvider>
</PageContainer>
);
}

View File

@@ -0,0 +1,20 @@
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

@@ -0,0 +1,30 @@
import Image from "next/image";
import * as React from "react";
import { memo } from "react";
type Props = {
country: string;
size?: number;
};
const RoundedFlag = ({ country, size = 20 }: Props) => {
return (
<div
className={
"shrink-0 overflow-hidden rounded-full relative shadow-xl flex items-center justify-center"
}
style={{
width: size == 14 ? 20 : size,
height: size == 14 ? 20 : size,
}}
>
<Image
src={`/assets/flags/4x3/${country.toLowerCase()}.svg`}
alt={country}
fill={true}
className={"object-cover object-center"}
/>
</div>
);
};
export default memo(RoundedFlag);

View File

@@ -0,0 +1,9 @@
<?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>

After

Width:  |  Height:  |  Size: 493 B

View File

@@ -0,0 +1,65 @@
import * as React from "react";
import { iconProperties, IconProps } from "@/assets/icons/IconProperties";
export const LinuxIcon = (props: IconProps) => {
return (
<svg
fill="#000000"
height="800px"
width="800px"
version="1.1"
id="Layer_1"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 304.998 304.998"
{...iconProperties(props)}
>
<g id="XMLID_91_">
<path
id="XMLID_92_"
d="M274.659,244.888c-8.944-3.663-12.77-8.524-12.4-15.777c0.381-8.466-4.422-14.667-6.703-17.117
c1.378-5.264,5.405-23.474,0.004-39.291c-5.804-16.93-23.524-42.787-41.808-68.204c-7.485-10.438-7.839-21.784-8.248-34.922
c-0.392-12.531-0.834-26.735-7.822-42.525C190.084,9.859,174.838,0,155.851,0c-11.295,0-22.889,3.53-31.811,9.684
c-18.27,12.609-15.855,40.1-14.257,58.291c0.219,2.491,0.425,4.844,0.545,6.853c1.064,17.816,0.096,27.206-1.17,30.06
c-0.819,1.865-4.851,7.173-9.118,12.793c-4.413,5.812-9.416,12.4-13.517,18.539c-4.893,7.387-8.843,18.678-12.663,29.597
c-2.795,7.99-5.435,15.537-8.005,20.047c-4.871,8.676-3.659,16.766-2.647,20.505c-1.844,1.281-4.508,3.803-6.757,8.557
c-2.718,5.8-8.233,8.917-19.701,11.122c-5.27,1.078-8.904,3.294-10.804,6.586c-2.765,4.791-1.259,10.811,0.115,14.925
c2.03,6.048,0.765,9.876-1.535,16.826c-0.53,1.604-1.131,3.42-1.74,5.423c-0.959,3.161-0.613,6.035,1.026,8.542
c4.331,6.621,16.969,8.956,29.979,10.492c7.768,0.922,16.27,4.029,24.493,7.035c8.057,2.944,16.388,5.989,23.961,6.913
c1.151,0.145,2.291,0.218,3.39,0.218c11.434,0,16.6-7.587,18.238-10.704c4.107-0.838,18.272-3.522,32.871-3.882
c14.576-0.416,28.679,2.462,32.674,3.357c1.256,2.404,4.567,7.895,9.845,10.724c2.901,1.586,6.938,2.495,11.073,2.495
c0.001,0,0,0,0.001,0c4.416,0,12.817-1.044,19.466-8.039c6.632-7.028,23.202-16,35.302-22.551c2.7-1.462,5.226-2.83,7.441-4.065
c6.797-3.768,10.506-9.152,10.175-14.771C282.445,250.905,279.356,246.811,274.659,244.888z M124.189,243.535
c-0.846-5.96-8.513-11.871-17.392-18.715c-7.26-5.597-15.489-11.94-17.756-17.312c-4.685-11.082-0.992-30.568,5.447-40.602
c3.182-5.024,5.781-12.643,8.295-20.011c2.714-7.956,5.521-16.182,8.66-19.783c4.971-5.622,9.565-16.561,10.379-25.182
c4.655,4.444,11.876,10.083,18.547,10.083c1.027,0,2.024-0.134,2.977-0.403c4.564-1.318,11.277-5.197,17.769-8.947
c5.597-3.234,12.499-7.222,15.096-7.585c4.453,6.394,30.328,63.655,32.972,82.044c2.092,14.55-0.118,26.578-1.229,31.289
c-0.894-0.122-1.96-0.221-3.08-0.221c-7.207,0-9.115,3.934-9.612,6.283c-1.278,6.103-1.413,25.618-1.427,30.003
c-2.606,3.311-15.785,18.903-34.706,21.706c-7.707,1.12-14.904,1.688-21.39,1.688c-5.544,0-9.082-0.428-10.551-0.651l-9.508-10.879
C121.429,254.489,125.177,250.583,124.189,243.535z M136.254,64.149c-0.297,0.128-0.589,0.265-0.876,0.411
c-0.029-0.644-0.096-1.297-0.199-1.952c-1.038-5.975-5-10.312-9.419-10.312c-0.327,0-0.656,0.025-1.017,0.08
c-2.629,0.438-4.691,2.413-5.821,5.213c0.991-6.144,4.472-10.693,8.602-10.693c4.85,0,8.947,6.536,8.947,14.272
C136.471,62.143,136.4,63.113,136.254,64.149z M173.94,68.756c0.444-1.414,0.684-2.944,0.684-4.532
c0-7.014-4.45-12.509-10.131-12.509c-5.552,0-10.069,5.611-10.069,12.509c0,0.47,0.023,0.941,0.067,1.411
c-0.294-0.113-0.581-0.223-0.861-0.329c-0.639-1.935-0.962-3.954-0.962-6.015c0-8.387,5.36-15.211,11.95-15.211
c6.589,0,11.95,6.824,11.95,15.211C176.568,62.78,175.605,66.11,173.94,68.756z M169.081,85.08
c-0.095,0.424-0.297,0.612-2.531,1.774c-1.128,0.587-2.532,1.318-4.289,2.388l-1.174,0.711c-4.718,2.86-15.765,9.559-18.764,9.952
c-2.037,0.274-3.297-0.516-6.13-2.441c-0.639-0.435-1.319-0.897-2.044-1.362c-5.107-3.351-8.392-7.042-8.763-8.485
c1.665-1.287,5.792-4.508,7.905-6.415c4.289-3.988,8.605-6.668,10.741-6.668c0.113,0,0.215,0.008,0.321,0.028
c2.51,0.443,8.701,2.914,13.223,4.718c2.09,0.834,3.895,1.554,5.165,2.01C166.742,82.664,168.828,84.422,169.081,85.08z
M205.028,271.45c2.257-10.181,4.857-24.031,4.436-32.196c-0.097-1.855-0.261-3.874-0.42-5.826
c-0.297-3.65-0.738-9.075-0.283-10.684c0.09-0.042,0.19-0.078,0.301-0.109c0.019,4.668,1.033,13.979,8.479,17.226
c2.219,0.968,4.755,1.458,7.537,1.458c7.459,0,15.735-3.659,19.125-7.049c1.996-1.996,3.675-4.438,4.851-6.372
c0.257,0.753,0.415,1.737,0.332,3.005c-0.443,6.885,2.903,16.019,9.271,19.385l0.927,0.487c2.268,1.19,8.292,4.353,8.389,5.853
c-0.001,0.001-0.051,0.177-0.387,0.489c-1.509,1.379-6.82,4.091-11.956,6.714c-9.111,4.652-19.438,9.925-24.076,14.803
c-6.53,6.872-13.916,11.488-18.376,11.488c-0.537,0-1.026-0.068-1.461-0.206C206.873,288.406,202.886,281.417,205.028,271.45z
M39.917,245.477c-0.494-2.312-0.884-4.137-0.465-5.905c0.304-1.31,6.771-2.714,9.533-3.313c3.883-0.843,7.899-1.714,10.525-3.308
c3.551-2.151,5.474-6.118,7.17-9.618c1.228-2.531,2.496-5.148,4.005-6.007c0.085-0.05,0.215-0.108,0.463-0.108
c2.827,0,8.759,5.943,12.177,11.262c0.867,1.341,2.473,4.028,4.331,7.139c5.557,9.298,13.166,22.033,17.14,26.301
c3.581,3.837,9.378,11.214,7.952,17.541c-1.044,4.909-6.602,8.901-7.913,9.784c-0.476,0.108-1.065,0.163-1.758,0.163
c-7.606,0-22.662-6.328-30.751-9.728l-1.197-0.503c-4.517-1.894-11.891-3.087-19.022-4.241c-5.674-0.919-13.444-2.176-14.732-3.312
c-1.044-1.171,0.167-4.978,1.235-8.337c0.769-2.414,1.563-4.91,1.998-7.523C41.225,251.596,40.499,248.203,39.917,245.477z"
/>
</g>
</svg>
);
};

View File

@@ -34,7 +34,7 @@ const auth0AuthorityConfig: AuthorityConfiguration = {
const onEvent = (configurationName: any, eventName: any, data: any) => {
if (process.env.NODE_ENV !== "production") {
console.info(`oidc:${configurationName}:${eventName}`, data);
//console.info(`oidc:${configurationName}:${eventName}`, data);
}
};

View File

@@ -0,0 +1,90 @@
import { Checkbox } from "@components/Checkbox";
import { Input } from "@components/Input";
import { Popover, PopoverContent } from "@components/Popover";
import { useElementSize } from "@hooks/useElementSize";
import { Anchor } from "@radix-ui/react-popover";
import * as React from "react";
import { useEffect, useRef, useState } from "react";
import { FaWindows } from "react-icons/fa6";
type Props = {};
export const AutoCompleteInput = ({}: Props) => {
const [open, setOpen] = useState<boolean>(false);
const inputRef = useRef<HTMLInputElement>(null);
const [elementWidth, { width }] = useElementSize<HTMLDivElement>();
useEffect(() => {
const input = inputRef.current;
const onFocus = () => {
setOpen(true);
};
if (input) {
inputRef.current.addEventListener("focus", onFocus);
}
return () => {
if (input) {
inputRef.current.removeEventListener("focus", onFocus);
}
};
}, []);
return (
<div className={"z-10 relative"}>
<Popover modal={false} open={open} onOpenChange={setOpen}>
<Anchor ref={elementWidth}>
<Input
placeholder={"11"}
ref={inputRef}
maxWidthClass={"max-w-[200px]"}
customPrefix={
<div className={"flex items-center gap-2"}>
<Checkbox></Checkbox>
<div
className={"flex gap-2 items-center text-sm text-nb-gray-200"}
>
<FaWindows className={"text-sky-600 text-lg"} />
Windows
</div>
</div>
}
/>
</Anchor>
<PopoverContent
hideWhenDetached={false}
className="w-full p-0 shadow-sm shadow-nb-gray-950"
style={{
width: width,
}}
forceMount={true}
align="start"
side={"bottom"}
sideOffset={10}
onOpenAutoFocus={(event) => event.preventDefault()}
onCloseAutoFocus={(event) => event.preventDefault()}
onInteractOutside={(event) => {
event.preventDefault();
if (event.target !== inputRef.current) {
setOpen(false);
}
}}
onPointerDownOutside={(event) => {
event.preventDefault();
if (event.target !== inputRef.current) {
setOpen(false);
}
}}
onFocusOutside={(event) => {
event.preventDefault();
if (event.target !== inputRef.current) {
setOpen(false);
}
}}
></PopoverContent>
</Popover>
</div>
);
};

View File

@@ -9,21 +9,23 @@ const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
>(({ className, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={cn(
"dark:data-[state=unchecked]:bg-nb-gray-950",
"peer h-5 w-5 shrink-0 rounded-[4px] border border-neutral-900 ring-offset-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-neutral-950 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-neutral-900 data-[state=checked]:text-neutral-50 dark:border-nb-gray-900 dark:ring-offset-neutral-950 dark:focus-visible:ring-neutral-300 dark:data-[state=checked]:bg-netbird dark:data-[state=checked]:text-neutral-50",
className,
)}
{...props}
>
<CheckboxPrimitive.Indicator
className={cn("flex items-center justify-center text-current")}
<div className={"h-5 w-5"}>
<CheckboxPrimitive.Root
ref={ref}
className={cn(
"dark:data-[state=unchecked]:bg-nb-gray-950",
"peer h-5 w-5 shrink-0 rounded-[4px] border border-neutral-900 ring-offset-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-neutral-950 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-neutral-900 data-[state=checked]:text-neutral-50 dark:border-nb-gray-900 dark:ring-offset-neutral-950 dark:focus-visible:ring-neutral-300 dark:data-[state=checked]:bg-netbird dark:data-[state=checked]:text-neutral-50",
className,
)}
{...props}
>
<Check className="h-4 w-4" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
<CheckboxPrimitive.Indicator
className={"flex items-center justify-center"}
>
<Check size={14} />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
</div>
));
Checkbox.displayName = CheckboxPrimitive.Root.displayName;

View File

@@ -5,16 +5,20 @@ import {
TooltipTrigger,
} from "@components/Tooltip";
import { cn } from "@utils/helpers";
import React from "react";
import React, { useState } from "react";
type Props = {
children: React.ReactNode;
children?: React.ReactNode;
content: React.ReactNode;
hoverButton?: boolean;
isAction?: boolean;
interactive?: boolean;
disabled?: boolean;
className?: string;
contentClassName?: string;
align?: "end" | "center" | "start";
side?: "top" | "bottom" | "left" | "right";
keepOpen?: boolean;
};
export default function FullTooltip({
children,
@@ -24,26 +28,45 @@ export default function FullTooltip({
interactive = true,
disabled,
className,
contentClassName,
align = "center",
side = "top",
keepOpen = false,
}: Props) {
const [open, setOpen] = useState(!!keepOpen);
const handleOpen = (isOpen: boolean) => {
if (keepOpen) return;
setOpen(isOpen);
};
return !disabled ? (
<TooltipProvider disableHoverableContent={!interactive}>
<Tooltip delayDuration={1}>
<TooltipTrigger asChild={true}>
{hoverButton ? (
<div
className={cn(
isAction ? "cursor-pointer" : "cursor-default",
"inline-flex items-center 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",
)}
>
{children}
</div>
) : (
<div className={cn("inline-flex", className)}>{children}</div>
)}
</TooltipTrigger>
<Tooltip delayDuration={1} open={open} onOpenChange={handleOpen}>
{children && (
<TooltipTrigger asChild={true}>
{hoverButton ? (
<div
className={cn(
isAction ? "cursor-pointer" : "cursor-default",
"inline-flex items-center 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",
)}
>
{children}
</div>
) : (
<div className={cn("inline-flex", className)}>{children}</div>
)}
</TooltipTrigger>
)}
{!disabled && (
<TooltipContent alignOffset={20}>
<TooltipContent
alignOffset={20}
forceMount={true}
className={contentClassName}
align={align}
side={side}
>
<div className={"text-neutral-300 flex flex-col gap-1"}>
{content}
</div>

View File

@@ -1,6 +1,8 @@
import FullTooltip from "@components/FullTooltip";
import Paragraph from "@components/Paragraph";
import { cn } from "@utils/helpers";
import { cva } from "class-variance-authority";
import { AlertCircle } from "lucide-react";
import * as React from "react";
export interface InputProps
@@ -10,6 +12,7 @@ export interface InputProps
maxWidthClass?: string;
icon?: React.ReactNode;
error?: string;
errorTooltip?: boolean;
}
const inputVariants = cva("", {
@@ -45,6 +48,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
icon,
maxWidthClass = "",
error,
errorTooltip = false,
...props
},
ref,
@@ -60,7 +64,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
}),
"flex h-[42px] w-auto rounded-l-md bg-white px-3 py-2 text-sm ",
"border items-center whitespace-nowrap",
props.disabled && "opacity-50",
props.disabled && "opacity-20",
)}
>
{customPrefix}
@@ -99,8 +103,33 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
>
{customSuffix}
</div>
{error && errorTooltip && (
<div
className={
"absolute right-0 top-2 h-[0px] w-full flex items-center pr-3 justify-center"
}
>
<FullTooltip
content={
<div className={"text-xs text-red-500 inline-flex"}>
<AlertCircle
size={13}
className={"top-[1px] relative mr-2"}
/>
{error}
</div>
}
interactive={false}
align={"center"}
side={"top"}
keepOpen={true}
>
&nbsp;
</FullTooltip>
</div>
)}
</div>
{error && (
{error && !errorTooltip && (
<Paragraph className={"text-xs !text-red-500 mt-2"}>
{error}
</Paragraph>

View File

@@ -0,0 +1,62 @@
import * as RadixRadioGroup from "@radix-ui/react-radio-group";
import { cn } from "@utils/helpers";
import * as React from "react";
import { useState } from "react";
type Props = {
value: string;
onChange: (value: string) => void;
children: React.ReactNode;
};
export const RadioGroup = ({ value, onChange, children }: Props) => {
const [defaultValue] = useState(value);
return (
<RadixRadioGroup.Root
defaultValue={defaultValue}
value={value}
onValueChange={onChange}
className={
"flex bg-nb-gray-900 rounded-md border border-nb-gray-700 text-sm items-center justify-center p-1"
}
>
{children}
</RadixRadioGroup.Root>
);
};
export const RadioGroupItems = ({
children,
}: {
children: React.ReactNode;
}) => {
return <div className={"flex w-full bg-nb-gray-900"}>{children}</div>;
};
export const RadioGroupItem = ({
value,
children,
variant = "default",
}: {
value: string;
children?: React.ReactNode;
variant?: "default" | "red" | "green";
}) => {
return (
<RadixRadioGroup.Item value={value} asChild={true}>
<div
key={value}
className={cn(
variant === "default" &&
"text-nb-gray-500 hover:text-nb-gray-400 data-[state=checked]:bg-nb-gray-600 data-[state=checked]:text-nb-gray-100",
variant === "red" &&
"text-nb-gray-500 hover:text-nb-gray-400 data-[state=checked]:bg-red-800 data-[state=checked]:text-red-200",
variant === "green" &&
"text-nb-gray-500 hover:text-nb-gray-400 data-[state=checked]:bg-green-800 data-[state=checked]:text-green-200",
"cursor-pointer relative transition-all w-full py-1.5 px-5 rounded-md h-full flex items-center text-sm gap-1 text-center justify-center",
)}
>
{children ? children : <div className={"h-3"}></div>}
</div>
</RadixRadioGroup.Item>
);
};

View File

@@ -13,6 +13,8 @@ const iconVariant = cva(
red: "bg-red-950 border-red-500 text-red-500",
gray: "bg-nb-gray-930 border-nb-gray-800 text-gray-500",
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",
},
size: {
small: "w-8 h-8",

View File

@@ -17,18 +17,18 @@ const Tabs = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Root>
>(({ className, onValueChange, ...props }, ref) => {
const [value, setValue] = useState(
const [tabValue, setTabValue] = useState(
props.defaultValue ? props.defaultValue : "",
);
return (
<TabContext.Provider value={value}>
<TabContext.Provider value={props.value ? props.value : tabValue}>
<TabsPrimitive.Root
ref={ref}
value={value}
onValueChange={(value) => {
setValue(value);
onValueChange && onValueChange(value);
value={props.value ? props.value : tabValue}
onValueChange={(v) => {
setTabValue(v);
onValueChange && onValueChange(v);
}}
className={cn("relative min-w-0", className)}
{...props}

View File

@@ -18,6 +18,10 @@ const switchVariants = cva("", {
"dark:data-[state=checked]:bg-netbird dark:data-[state=unchecked]:bg-nb-gray-700",
"data-[state=checked]:bg-neutral-900 data-[state=unchecked]:bg-neutral-200",
],
"red-green": [
"dark:data-[state=checked]:bg-red-600 dark:data-[state=unchecked]:bg-nb-gray-700",
"data-[state=checked]:bg-red-500 data-[state=unchecked]:bg-red-200",
],
red: [
"dark:data-[state=checked]:bg-red-600 dark:data-[state=unchecked]:bg-nb-gray-700",
"data-[state=checked]:bg-red-500 data-[state=unchecked]:bg-red-200",

View File

@@ -13,7 +13,7 @@ const TooltipTrigger = TooltipPrimitive.Trigger;
const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className = "px-5 py-3", sideOffset = 7, ...props }, ref) => (
>(({ className = "px-4 py-2.5", sideOffset = 7, ...props }, ref) => (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
ref={ref}

View File

@@ -2,17 +2,26 @@ import Button from "@components/Button";
import { CommandItem } from "@components/Command";
import { Popover, PopoverContent, PopoverTrigger } from "@components/Popover";
import { ScrollArea } from "@components/ScrollArea";
import { SelectDropdownSearchInput } from "@components/select/SelectDropdownSearchInput";
import { useDebounce } from "@hooks/useDebounce";
import useIsVisible from "@hooks/useIsVisible";
import { cn } from "@utils/helpers";
import { Command, CommandGroup, CommandList } from "cmdk";
import { trim } from "lodash";
import { isEmpty } from "lodash";
import { ChevronsUpDown } from "lucide-react";
import * as React from "react";
import { useState } from "react";
import { useEffect, useRef, useState } from "react";
import Skeleton from "react-loading-skeleton";
import { useElementSize } from "@/hooks/useElementSize";
export interface SelectOption {
label: string | React.ReactNode;
value: string;
icon?: React.ComponentType<{ size: number; width: number }>;
icon?: React.ComponentType<{
size?: number;
width?: number;
country?: string;
}>;
}
interface SelectDropdownProps {
@@ -21,6 +30,10 @@ interface SelectDropdownProps {
disabled?: boolean;
popoverWidth?: "auto" | number;
options: SelectOption[];
showSearch?: boolean;
placeholder?: string;
searchPlaceholder?: string;
isLoading?: boolean;
}
export function SelectDropdown({
@@ -29,6 +42,10 @@ export function SelectDropdown({
disabled = false,
popoverWidth = "auto",
options,
showSearch = false,
placeholder = "Select...",
searchPlaceholder = "Search...",
isLoading = false,
}: SelectDropdownProps) {
const [inputRef, { width }] = useElementSize<HTMLButtonElement>();
@@ -38,29 +55,67 @@ export function SelectDropdown({
} else {
onChange && onChange(selectedValue);
}
setTimeout(() => {
setSearch("");
}, 100);
setOpen(false);
};
const [open, setOpen] = useState(false);
const [slice, setSlice] = useState(10);
useEffect(() => {
if (open) {
setTimeout(() => {
setSlice(options.length);
}, 100);
} else {
setSlice(10);
}
}, [open, options]);
const selected = options.find((o) => o.value === value);
const searchRef = React.useRef<HTMLInputElement>(null);
const [search, setSearch] = useState("");
const debouncedSearch = useDebounce(search, 200);
const filteredItems = React.useMemo(() => {
if (isEmpty(debouncedSearch)) return options;
return options.filter((item) => {
const value = `${item.label}${item.value}` || "";
return value.toLowerCase().includes(debouncedSearch.toLowerCase());
});
}, [options, debouncedSearch]);
return (
<Popover
open={open}
onOpenChange={(isOpen) => {
setSlice(10);
if (!isOpen) {
setTimeout(() => {
setSearch("");
}, 100);
}
setOpen(isOpen);
}}
>
<PopoverTrigger asChild={true}>
<PopoverTrigger asChild={true} disabled={disabled || isLoading}>
<Button
variant={"input"}
disabled={disabled}
disabled={disabled || isLoading}
ref={inputRef}
className={"w-full"}
>
<div className={"w-full flex justify-between items-center gap-2"}>
{selected && (
{isLoading ? (
<div className={"flex gap-2"}>
<Skeleton width={20} />
<Skeleton width={100} />
</div>
) : selected ? (
<React.Fragment>
<div className={"flex items-center gap-2.5"}>
{selected?.icon && <selected.icon size={14} width={14} />}
@@ -71,6 +126,14 @@ export function SelectDropdown({
</div>
</div>
</React.Fragment>
) : (
<React.Fragment>
<div className={"flex items-center gap-2.5"}>
<div className={"flex flex-col text-sm font-medium"}>
<span className={"text-nb-gray-200"}>{placeholder}</span>
</div>
</div>
</React.Fragment>
)}
<div className={"pl-2"}>
@@ -91,41 +154,40 @@ export function SelectDropdown({
<Command
className={"w-full flex"}
loop
filter={(value, search) => {
const formatValue = trim(value.toLowerCase());
const formatSearch = trim(search.toLowerCase());
if (formatValue.includes(formatSearch)) return 1;
return 0;
}}
filter={() => 0}
shouldFilter={false}
>
<CommandList className={"w-full"}>
{showSearch && (
<SelectDropdownSearchInput
search={search}
setSearch={setSearch}
ref={searchRef}
placeholder={searchPlaceholder}
/>
)}
{filteredItems.length == 0 && (
<div className={"text-center pb-2 px-3 text-nb-gray-400 text-xs"}>
There are no results matching your search.
</div>
)}
<ScrollArea
className={
"max-h-[380px] overflow-y-auto flex flex-col gap-1 pl-2 py-2 pr-3"
}
className={cn(
"max-h-[380px] overflow-y-auto flex flex-col gap-1 pl-2 pb-2 pr-3",
!showSearch && "pt-2",
)}
>
<CommandGroup>
<div className={"grid grid-cols-1 gap-1"}>
{options.map((option) => {
return (
<CommandItem
key={option.value}
value={option.value}
className={"py-1 px-2"}
onSelect={() => toggle(option.value)}
onClick={(e) => e.preventDefault()}
>
<div className={"flex items-center gap-2.5 p-1"}>
{option.icon && <option.icon size={14} width={14} />}
<div className={"flex flex-col text-sm font-medium"}>
<span className={"text-nb-gray-200"}>
{option.label}
</span>
</div>
</div>
</CommandItem>
);
})}
{filteredItems.map((option) => (
<SelectDropdownItem
option={option}
toggle={toggle}
key={option.value}
/>
))}
</div>
</CommandGroup>
</ScrollArea>
@@ -135,3 +197,46 @@ export function SelectDropdown({
</Popover>
);
}
const SelectDropdownItem = ({
option,
toggle,
}: {
option: SelectOption;
toggle: (value: string) => void;
}) => {
const value = option.value || "" + option.label || "";
const elementRef = useRef<HTMLDivElement>(null);
const isVisible = useIsVisible(elementRef);
const [visible, setVisible] = useState(isVisible);
useEffect(() => {
if (isVisible && !visible) {
setVisible(true);
}
}, [isVisible]);
return (
<div ref={elementRef} className={"transition-all"}>
{visible ? (
<CommandItem
value={value}
ref={elementRef}
className={"py-1 px-2"}
onSelect={() => toggle(option.value)}
onClick={(e) => e.preventDefault()}
>
<div className={"flex items-center gap-2.5 p-1"}>
{option.icon && <option.icon size={14} width={14} />}
<div className={"flex flex-col text-sm font-medium"}>
<span className={"text-nb-gray-200"}>{option.label}</span>
</div>
</div>
</CommandItem>
) : (
<div className={"h-[35px] py-1 px-2"}></div>
)}
</div>
);
};

View File

@@ -0,0 +1,55 @@
import { IconArrowBack } from "@tabler/icons-react";
import { cn } from "@utils/helpers";
import { SearchIcon } from "lucide-react";
import * as React from "react";
import { Dispatch, forwardRef } from "react";
type Props = {
search: string;
setSearch: Dispatch<React.SetStateAction<string>>;
placeholder?: string;
};
export const SelectDropdownSearchInput = forwardRef<HTMLInputElement, Props>(
(
{
search,
setSearch,
placeholder = "Search for peers by name or ip...",
}: Props,
ref,
) => {
return (
<div className={"relative"}>
<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",
"bg-transparent text-sm outline-none focus-visible:outline-none ring-0 focus-visible:ring-0",
"dark:placeholder:text-nb-gray-400 font-light placeholder:text-neutral-500 pl-10",
)}
ref={ref}
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={placeholder}
/>
<div className={"absolute left-0 top-0 h-full flex items-center pl-4"}>
<div className={"flex items-center"}>
<SearchIcon size={14} />
</div>
</div>
<div className={"absolute right-0 top-0 h-full flex items-center pr-4"}>
<div
className={
"flex items-center bg-nb-gray-800 py-1 px-1.5 rounded-[4px] border border-nb-gray-500"
}
>
<IconArrowBack size={10} />
</div>
</div>
</div>
);
},
);
SelectDropdownSearchInput.displayName = "SelectDropdownSearchInput";

View File

@@ -120,6 +120,7 @@ interface DataTableProps<TData, TValue> {
showSearch?: boolean;
rightSide?: (table: TanStackTable<TData>) => React.ReactNode;
manualPagination?: boolean;
showHeader?: boolean;
}
export function DataTable<TData, TValue>(props: DataTableProps<TData, TValue>) {
@@ -152,6 +153,7 @@ export function DataTableContent<TData, TValue>({
searchClassName,
rightSide,
manualPagination = false,
showHeader = true,
}: DataTableProps<TData, TValue>) {
const path = usePathname();
const [columnFilters, setColumnFilters] = useLocalStorage<ColumnFiltersState>(
@@ -250,7 +252,7 @@ export function DataTableContent<TData, TValue>({
className={cn("relative mt-8", tableClassName)}
minimal={minimal}
>
{as == "table" && (
{showHeader && as == "table" && (
<TableHeaderComponent minimal={minimal}>
{table.getHeaderGroups().map((headerGroup) => (
<TableRowComponent key={headerGroup.id} minimal={minimal}>
@@ -361,6 +363,7 @@ export function DataTableContent<TData, TValue>({
minimal={minimal}
className={cn(
onRowClick && "cursor-pointer relative",
rowClassName,
)}
data-state={row.getIsSelected() && "selected"}
>

View File

@@ -9,12 +9,13 @@ const Table = React.forwardRef<
React.HTMLAttributes<HTMLTableElement> & TableProps
>(({ className, minimal, ...props }, ref) => {
return (
<div className={cn("relative overflow-x-auto w-full", className)}>
<div className={cn("relative overflow-x-auto w-full")}>
<table
ref={ref}
className={cn(
"caption-bottom text-sm min-w-full max-w-full w-full",
minimal ? "" : "border dark:border-zinc-700/40 border-l-0 border-r-0",
className,
)}
{...props}
/>

View File

@@ -0,0 +1,56 @@
import {
SelectDropdown,
SelectOption,
} from "@components/select/SelectDropdown";
import useFetchApi from "@utils/api";
import { MapPin } from "lucide-react";
import { createElement, useMemo } from "react";
import { City } from "@/interfaces/City";
type Props = {
value: string;
onChange: (value: string) => void;
country: string;
};
export const CitySelector = ({ value, onChange, country = "de" }: Props) => {
const { data: cities, isLoading } = useFetchApi<City[]>(
`/locations/countries/${country}/cities`,
);
const cityList = useMemo(() => {
const pinIcon = (props: {
size?: number;
width?: number;
country?: string;
}) =>
createElement(MapPin, {
...props,
});
if (!cities) return [];
const all = cities?.map((city) => {
return {
label: city.city_name,
value: city.city_name,
icon: pinIcon,
} as SelectOption;
}) as SelectOption[];
all.unshift({ label: "All Locations", value: "", icon: pinIcon });
return all;
}, [cities]);
return (
<div className={"block w-full"}>
<SelectDropdown
isLoading={isLoading}
showSearch={true}
placeholder={"Select city (optional)..."}
searchPlaceholder={"Search city..."}
value={value}
onChange={onChange}
options={cityList || []}
/>
</div>
);
};

View File

@@ -0,0 +1,52 @@
import {
SelectDropdown,
SelectOption,
} from "@components/select/SelectDropdown";
import useFetchApi from "@utils/api";
import { createElement, useMemo } from "react";
import RoundedFlag from "@/assets/countries/RoundedFlag";
import { Country } from "@/interfaces/Country";
type Props = {
value: string;
onChange: (value: string) => void;
};
export const CountrySelector = ({ value, onChange }: Props) => {
const { data: countries, isLoading } = useFetchApi<Country[]>(
"/locations/countries",
);
const countryList = useMemo(() => {
return countries?.map((country) => {
const flag = (props: {
size?: number;
width?: number;
country?: string;
}) =>
createElement(RoundedFlag, {
country: country.country_code,
size: 20,
...props,
});
return {
label: country.country_name + " (" + country.country_code + ")",
value: country.country_code,
icon: flag,
} as SelectOption;
}) as SelectOption[];
}, [countries]);
return (
<div className={"block w-full"}>
<SelectDropdown
isLoading={isLoading}
showSearch={true}
placeholder={"Select country..."}
searchPlaceholder={"Search country..."}
value={value}
onChange={onChange}
options={countryList || []}
/>
</div>
);
};

View File

@@ -5,13 +5,18 @@ import React from "react";
type Props = {
text?: string;
className?: string;
maxChars?: number;
};
export default function DescriptionWithTooltip({ text, className }: Props) {
export default function DescriptionWithTooltip({
text,
className,
maxChars = 30,
}: Props) {
return (
<TextWithTooltip
text={text}
maxChars={30}
maxChars={maxChars}
className={cn("text-sm text-nb-gray-400 whitespace-nowrap", className)}
/>
);

View File

@@ -0,0 +1,154 @@
import { cn } from "@utils/helpers";
import { AnimatePresence, motion } from "framer-motion";
import { ChevronLeft, ChevronRight } from "lucide-react";
import * as React from "react";
import { useState } from "react";
type Props = {
defaultValue?: string;
children: React.ReactNode;
className?: string;
};
const SlidingTabContext = React.createContext(
{} as {
current: string | undefined;
onChange: (value: string) => void;
back: () => void;
},
);
export const useSlidingTabContext = () => {
const context = React.useContext(SlidingTabContext);
if (!context) {
throw new Error("TabContext is not found");
}
return context;
};
export const SlidingTabs = (props: Props) => {
const [current, setCurrent] = useState<string | undefined>();
return (
<SlidingTabContext.Provider
value={{
current: current,
onChange: setCurrent,
back: () => setCurrent(undefined),
}}
>
<div className={cn("overflow-hidden relative", props.className)}>
<AnimatePresence initial={false} mode={"popLayout"}>
<motion.div
key={current}
className={"z-10 relative"}
initial={{
x: current != undefined ? 50 : -100,
opacity: 0,
}}
animate={{
x: 0,
opacity: 1,
}}
exit={{
x: current != undefined ? 100 : 0,
opacity: 0,
}}
transition={{
x: { type: "spring", stiffness: 300, damping: 30, duration: 0.3 },
opacity: { duration: 0.15 },
}}
>
{props.children}
</motion.div>
</AnimatePresence>
</div>
</SlidingTabContext.Provider>
);
};
export const SlidingTabsList = (props: Props) => {
const { onChange, current } = useSlidingTabContext();
return !current ? <div>{props.children}</div> : null;
};
export const SlidingTabsTrigger = ({
value,
children,
title,
description,
icon,
iconClass = "bg-gradient-to-tr from-netbird-200 to-netbird-100",
}: {
value: string;
children?: React.ReactNode;
title?: string;
description?: string;
iconClass?: string;
icon?: React.ReactNode;
}) => {
const { onChange, current } = useSlidingTabContext();
return (
<div onClick={() => onChange(value)}>
<div
className={
"hover:bg-nb-gray-920/80 border border-transparent hover:border-nb-gray-900 rounded-md flex flex-col items-center transition-all cursor-pointer"
}
>
<div className={"flex gap-4 items-center w-full px-4 py-3"}>
<div
className={cn(
"h-9 w-9 shrink-0 shadow-xl rounded-md flex items-center justify-center select-none",
iconClass,
)}
>
{icon}
</div>
<div className={"pr-10"}>
<div className={"text-sm font-medium flex gap-2 items-center"}>
{title}
</div>
<div className={"text-xs mt-0.5 text-nb-gray-300"}>
{description}
</div>
</div>
<div className={"ml-auto flex gap-2 items-center "}>
<ChevronRight size={22} />
</div>
</div>
</div>
</div>
);
};
export const SlidingTabsPanel = ({
value,
children,
}: {
value: string;
children: React.ReactNode;
}) => {
const { current } = useSlidingTabContext();
return current == value ? <>{children}</> : null;
};
export const SlidingTabsBackTrigger = ({
children,
}: {
children: React.ReactNode;
}) => {
const { back } = useSlidingTabContext();
return (
<div
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
back();
}}
className={"flex gap-2 items-center select-none cursor-pointer"}
>
<ChevronLeft size={18} />
Back
</div>
);
};

View File

@@ -40,6 +40,10 @@ export default function PoliciesProvider({ children }: Props) {
enabled: toUpdate.enabled ?? policy.enabled,
query: toUpdate.query ?? policy.query ?? "",
rules: toUpdate.rules ?? policy.rules ?? [],
source_posture_checks:
toUpdate.source_posture_checks ??
policy.source_posture_checks ??
[],
},
`/${policy.id}`,
)

25
src/hooks/useIsVisible.ts Normal file
View File

@@ -0,0 +1,25 @@
import { RefObject, useEffect, useRef, useState } from "react";
export default function useIsVisible(ref: RefObject<HTMLElement>) {
const observerRef = useRef<IntersectionObserver | null>(null);
const [isOnScreen, setIsOnScreen] = useState(false);
useEffect(() => {
observerRef.current = new IntersectionObserver(([entry]) =>
setIsOnScreen(entry.isIntersecting),
);
}, []);
useEffect(() => {
if (ref.current && observerRef.current) {
observerRef.current.observe(ref.current);
}
return () => {
if (observerRef.current) {
observerRef.current.disconnect();
}
};
}, [ref]);
return isOnScreen;
}

View File

@@ -7,20 +7,3 @@ export interface AccessToken {
last_used: Date;
plain_token?: string;
}
export interface SpecificPAT {
name: string;
user_id: string;
id: string;
}
export interface PersonalAccessTokenGenerated {
plain_token: string;
personal_access_token: AccessToken;
}
export interface PersonalAccessTokenCreate {
user_id: string;
name: string;
expires_in: number;
}

View File

@@ -9,108 +9,3 @@ export interface ActivityEvent {
target_id: string;
meta: { [key: string]: string };
}
export const ActivityGroupNames = {
user: "User",
account: "Account",
setupkey: "Setup-Key",
rule: "Rule",
policy: "Policy",
peer: "Peer",
group: "Group",
route: "Route",
dns: "DNS",
nameserver: "Nameserver",
service: "Service",
integration: "Integration",
dashboard: "Dashboard",
};
export const ActivityEventCodes = {
User: {
"user.peer.add": "Peer added",
"user.join": "User joined",
"user.invite": "User invited",
"user.peer.delete": "Peer deleted",
"user.group.add": "Group added to user",
"user.group.delete": "Group removed from user",
"user.role.update": "User role updated",
"personal.access.token.create": "Personal access token created",
"personal.access.token.delete": "Personal access token deleted",
"user.block": "User blocked",
"user.unblock": "User unblocked",
"user.delete": "User deleted",
"user.peer.login": "User logged in peer",
},
Account: {
"account.create": "Account created",
"account.setting.peer.login.expiration.update":
"Account peer login expiration duration updated",
"account.setting.peer.login.expiration.enable":
"Account peer login expiration enabled",
"account.setting.peer.login.expiration.disable":
"Account peer login expiration disabled",
},
"Setup-Key": {
"setupkey.peer.add": "Peer added with setup key",
"setupkey.add": "Setup key created",
"setupkey.update": "Setup key updated",
"setupkey.revoke": "Setup key revoked",
"setupkey.overuse": "Setup key overused",
"setupkey.group.add": "Group added to setup key",
"setupkey.group.delete": "Group removed from user setup key",
},
Rule: {
"rule.add": "Rule added",
"rule.update": "Rule updated",
"rule.delete": "Rule deleted",
},
Policy: {
"policy.add": "Policy added",
"policy.update": "Policy updated",
"policy.delete": "Policy deleted",
},
Peer: {
"peer.group.add": "Group added to peer",
"peer.group.delete": "Group removed from peer",
"peer.ssh.enable": "Peer SSH server enabled",
"peer.ssh.disable": "Peer SSH server disabled",
"peer.rename": "Peer renamed",
"peer.login.expiration.enable": "Peer login expiration enabled",
"peer.login.expiration.disable": "Peer login expiration disabled",
"peer.login.expire": "Peer login expired",
},
Group: {
"group.add": "Group created",
"group.update": "Group updated",
"group.delete": "Group deleted",
},
Route: {
"route.add": "Route created",
"route.delete": "Route deleted",
"route.update": "Route updated",
},
DNS: {
"dns.setting.disabled.management.group.add":
"Group added to disabled management DNS setting",
"dns.setting.disabled.management.group.delete":
"Group removed from disabled management DNS setting",
},
Nameserver: {
"nameserver.group.add": "Nameserver group created",
"nameserver.group.delete": "Nameserver group deleted",
"nameserver.group.update": "Nameserver group updated",
},
Service: {
"service.user.create": "Service user created",
"service.user.delete": "Service user deleted",
},
Integration: {
"integration.create": "Integration created",
"integration.update": "Integration updated",
"integration.delete": "Integration deleted",
},
Dashboard: {
"dashboard.login": "Dashboard login",
},
};

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

@@ -0,0 +1,4 @@
export interface City {
city_name: string;
geoname_id: number;
}

View File

@@ -0,0 +1,4 @@
export interface Country {
country_code: string;
country_name: string;
}

View File

@@ -21,37 +21,3 @@ export interface Peer {
login_expiration_enabled: boolean;
approval_required: boolean;
}
export interface FormPeer extends Peer {
groupsNames: string[];
userEmail?: string;
}
export interface PeerToSave extends Peer {
groupsToSave: string[];
}
export interface PeerGroupsToSave {
ID: string;
groupsToRemove: string[];
groupsToAdd: string[];
groupsNoId: string[];
}
export interface PeerNameToIP {
[key: string]: string;
}
export interface PeerIPToName {
[key: string]: string;
}
export interface PeerIPToID {
[key: string]: string;
}
export interface PeerDataTable extends Peer {
key: string;
groups: Group[];
groupsCount: number;
}

View File

@@ -7,6 +7,7 @@ export interface Policy {
enabled: boolean;
query: string;
rules: PolicyRule[];
source_posture_checks: string[];
}
export interface PolicyRule {
@@ -23,9 +24,3 @@ export interface PolicyRule {
}
export type Protocol = "all" | "tcp" | "udp" | "icmp";
export interface PolicyToSave extends Policy {
sourcesNoId?: string[];
destinationsNoId?: string[];
groupsToSave?: string[];
}

View File

@@ -0,0 +1,125 @@
import { SelectOption } from "@components/select/SelectDropdown";
import { Policy } from "@/interfaces/Policy";
export interface PostureCheck {
id: string;
name: string;
description?: string;
checks: {
nb_version_check?: NetBirdVersionCheck;
os_version_check?: OperatingSystemVersionCheck;
geo_location_check?: GeoLocationCheck;
};
policies?: Policy[];
active?: boolean;
}
export interface NetBirdVersionCheck {
min_version: string;
}
export interface OperatingSystemVersionCheck {
android?: {
min_version: string;
};
darwin?: {
min_version: string;
};
ios?: {
min_version: string;
};
linux?: {
min_kernel_version: string;
};
windows?: {
min_kernel_version: string;
};
}
export interface GeoLocationCheck {
locations: GeoLocation[];
action: "allow" | "deny";
}
export interface GeoLocation {
id: string;
country_code: string;
city_name: string;
}
export const windowsKernelVersions: SelectOption[] = [
{ value: "5.0", label: "Windows 2000" },
{ value: "5.1", label: "Windows XP" },
{ value: "6.0", label: "Windows Vista" },
{ value: "6.1", label: "Windows 7" },
{ value: "6.2", label: "Windows 8" },
{ value: "6.3", label: "Windows 8.1" },
{ value: "10.0", label: "Windows 10" },
{ value: "10.0.2", label: "Windows 11" },
];
export const iOSVersions: SelectOption[] = [
{ value: "1.0", label: "iPhone OS 1.x" },
{ value: "2.0", label: "iPhone OS 2.x" },
{ value: "3.0", label: "iPhone OS 3.x" },
{ value: "4.0", label: "iOS 4.x" },
{ value: "5.0", label: "iOS 5.x" },
{ value: "6.0", label: "iOS 6.x" },
{ value: "7.0", label: "iOS 7.x" },
{ value: "8.0", label: "iOS 8.x" },
{ value: "9.0", label: "iOS 9.x" },
{ value: "10.0", label: "iOS 10.x" },
{ value: "11.0", label: "iOS 11.x" },
{ value: "12.0", label: "iOS 12.x" },
{ value: "13.0", label: "iOS 13.x" },
{ value: "14.0", label: "iOS 14.x" },
{ value: "15.0", label: "iOS 15.x" },
{ value: "16.0", label: "iOS 16.x" },
{ value: "17.0", label: "iOS 17.x" },
];
export const macOSVersions: SelectOption[] = [
{ value: "10.0", label: "Mac OS X Cheetah" },
{ value: "10.1", label: "Mac OS X Puma" },
{ value: "10.2", label: "Mac OS X Jaguar" },
{ value: "10.3", label: "Mac OS X Panther" },
{ value: "10.4", label: "Mac OS X Tiger" },
{ value: "10.5", label: "Mac OS X Leopard" },
{ value: "10.6", label: "Mac OS X Snow Leopard" },
{ value: "10.7", label: "Mac OS X Lion" },
{ value: "10.8", label: "OS X Mountain Lion" },
{ value: "10.9", label: "OS X Mavericks" },
{ value: "10.10", label: "OS X Yosemite" },
{ value: "10.11", label: "OS X El Capitan" },
{ value: "10.12", label: "macOS Sierra" },
{ value: "10.13", label: "macOS High Sierra" },
{ value: "10.14", label: "macOS Mojave" },
{ value: "10.15", label: "macOS Catalina" },
{ value: "11.0", label: "macOS Big Sur" },
{ value: "12.0", label: "macOS Monterey" },
{ value: "13.0", label: "macOS Ventura" },
{ value: "14.0", label: "macOS Sonoma" },
];
export const androidVersions: SelectOption[] = [
{ value: "1.5", label: "Android Cupcake" },
{ value: "1.6", label: "Android Donut" },
{ value: "2.0", label: "Android Eclair" },
{ value: "2.2", label: "Android Froyo" },
{ value: "2.3", label: "Android Gingerbread" },
{ value: "3.0", label: "Android Honeycomb" },
{ value: "4.0", label: "Android Ice Cream Sandwich" },
{ value: "4.1", label: "Android Jelly Bean" },
{ value: "4.4", label: "Android KitKat" },
{ value: "5.0", label: "Android Lollipop" },
{ value: "6.0", label: "Android Marshmallow" },
{ value: "7.0", label: "Android Nougat" },
{ value: "8.0", label: "Android Oreo" },
{ value: "9.0", label: "Android Pie" },
{ value: "10", label: "Android 10" },
{ value: "11", label: "Android 11" },
{ value: "12", label: "Android 12" },
{ value: "13", label: "Android 13" },
{ value: "14", label: "Android 14" },
{ value: "15", label: "Android 15" },
];

View File

@@ -11,15 +11,6 @@ export interface User {
last_login?: Date;
}
export interface FormUser extends User {
autoGroupsNames: string[];
is_active?: boolean;
}
export interface UserToSave extends User {
groupsToCreate: string[];
}
export enum Role {
User = "user",
Admin = "admin",

View File

@@ -1,9 +1,3 @@
export interface Version {
major: number;
minor: number;
patch: number;
}
export interface NetbirdRelease {
latest_version: string;
last_checked: Date;

View File

@@ -42,7 +42,7 @@ export default function Navigation({
hideOnMobile ? "hidden md:block" : "",
fullWidth
? "w-auto max-w-[22rem]"
: "w-[14rem] min-w-[14rem] overflow-y-auto",
: "w-[15rem] min-w-[15rem] overflow-y-auto",
)}
theme={customTheme}
style={{
@@ -69,8 +69,22 @@ export default function Navigation({
<SidebarItem
icon={<AccessControlIcon />}
label="Access Control"
href={"/access-control"}
/>
collapsible
>
<SidebarItem
label="Policies"
href={"/access-control"}
isChild
exactPathMatch={true}
/>
<SidebarItem
label="Posture Checks"
isChild
href={"/posture-checks"}
exactPathMatch={true}
/>
</SidebarItem>
<SidebarItem
icon={<NetworkRoutesIcon />}
label="Network Routes"

View File

@@ -28,7 +28,7 @@ import {
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@components/Tabs";
import { Textarea } from "@components/Textarea";
import PolicyDirection, { Direction } from "@components/ui/PolicyDirection";
import { useApiCall } from "@utils/api";
import useFetchApi, { useApiCall } from "@utils/api";
import { cn } from "@utils/helpers";
import { uniqBy } from "lodash";
import {
@@ -42,13 +42,16 @@ import {
Shield,
Text,
} from "lucide-react";
import React, { useMemo, useState } from "react";
import React, { useEffect, useMemo, useRef, useState } from "react";
import { useSWRConfig } from "swr";
import AccessControlIcon from "@/assets/icons/AccessControlIcon";
import { usePolicies } from "@/contexts/PoliciesProvider";
import { Group } from "@/interfaces/Group";
import { Policy, Protocol } from "@/interfaces/Policy";
import { PostureCheck } from "@/interfaces/PostureCheck";
import useGroupHelper from "@/modules/groups/useGroupHelper";
import { PostureCheckTab } from "@/modules/posture-checks/ui/PostureCheckTab";
import { PostureCheckTabTrigger } from "@/modules/posture-checks/ui/PostureCheckTabTrigger";
type Props = {
children?: React.ReactNode;
@@ -108,11 +111,15 @@ export function AccessControlModalContent({
policy,
cell,
}: ModalProps) {
const { data: allPostureChecks, isLoading: isPostureChecksLoading } =
useFetchApi<PostureCheck[]>("/posture-checks");
const { updatePolicy } = usePolicies();
const firstRule = policy?.rules ? policy.rules[0] : undefined;
const [tab, setTab] = useState(() => {
if (!cell) return "policy";
if (cell == "posture_checks") return "posture_checks";
if (cell == "name") return "general";
return "policy";
});
@@ -189,6 +196,9 @@ export function AccessControlModalContent({
name,
description,
enabled,
source_posture_checks: postureChecks
? postureChecks.map((c) => c.id)
: undefined,
rules: [
{
bidirectional: direction == "bi",
@@ -235,6 +245,29 @@ export function AccessControlModalContent({
if (direction != "bi" && ports.length == 0) return true;
}, [sourceGroups, destinationGroups, direction, ports, name]);
const [postureChecks, setPostureChecks] = useState<PostureCheck[]>([]);
const postureChecksLoaded = useRef(false);
const initialPostureChecks = useMemo(() => {
return (
allPostureChecks?.filter((check) => {
if (policy?.source_posture_checks) {
return policy.source_posture_checks.includes(check.id);
}
return false;
}) || []
);
}, [policy, allPostureChecks]);
useEffect(() => {
if (postureChecksLoaded.current) return;
if (initialPostureChecks.length > 0) {
postureChecksLoaded.current = true;
setPostureChecks(initialPostureChecks);
}
}, [initialPostureChecks]);
return (
<ModalContent maxWidthClass={"max-w-2xl"}>
<ModalHeader
@@ -256,6 +289,7 @@ export function AccessControlModalContent({
<ArrowRightLeft size={16} />
Policy
</TabsTrigger>
<PostureCheckTabTrigger />
<TabsTrigger value={"general"}>
<Text
size={16}
@@ -368,6 +402,11 @@ export function AccessControlModalContent({
/>
</div>
</TabsContent>
<PostureCheckTab
isLoading={isPostureChecksLoading}
postureChecks={postureChecks}
setPostureChecks={setPostureChecks}
/>
<TabsContent value={"general"} className={"px-8 pb-6"}>
<div className={"flex flex-col gap-6"}>
<div>

View File

@@ -0,0 +1,27 @@
import Badge from "@components/Badge";
import { IconCirclePlus } from "@tabler/icons-react";
import { ShieldCheck } from "lucide-react";
import React from "react";
import { Policy } from "@/interfaces/Policy";
type Props = {
policy: Policy;
};
export default function AccessControlPostureCheckCell({ policy }: Props) {
return policy.source_posture_checks &&
policy.source_posture_checks.length > 0 ? (
<div className={"flex"}>
<Badge variant={"gray"} useHover={true}>
<ShieldCheck size={14} className={"text-green-500"} />
{policy.source_posture_checks.length} Posture Check(s)
</Badge>
</div>
) : (
<div className={"flex"}>
<Badge variant={"gray"} useHover={true}>
<IconCirclePlus size={14} />
Add Posture Check
</Badge>
</div>
);
}

View File

@@ -24,6 +24,7 @@ import AccessControlDestinationsCell from "@/modules/access-control/table/Access
import AccessControlDirectionCell from "@/modules/access-control/table/AccessControlDirectionCell";
import AccessControlNameCell from "@/modules/access-control/table/AccessControlNameCell";
import AccessControlPortsCell from "@/modules/access-control/table/AccessControlPortsCell";
import AccessControlPostureCheckCell from "@/modules/access-control/table/AccessControlPostureCheckCell";
import AccessControlProtocolCell from "@/modules/access-control/table/AccessControlProtocolCell";
import AccessControlSourcesCell from "@/modules/access-control/table/AccessControlSourcesCell";
@@ -142,6 +143,17 @@ export const AccessControlTableColumns: ColumnDef<Policy>[] = [
},
cell: ({ cell }) => <AccessControlPortsCell policy={cell.row.original} />,
},
{
id: "posture_checks",
accessorFn: (row) => row.source_posture_checks?.length || 0,
sortingFn: "basic",
header: ({ column }) => {
return <DataTableHeader column={column}>Posture Checks</DataTableHeader>;
},
cell: ({ cell }) => (
<AccessControlPostureCheckCell policy={cell.row.original} />
),
},
{
accessorKey: "id",
header: "",

View File

@@ -5,9 +5,10 @@ import {
TooltipTrigger,
} from "@components/Tooltip";
import Image from "next/image";
import { useMemo } from "react";
import React, { useMemo } from "react";
import { FaWindows } from "react-icons/fa6";
import { FcAndroidOs, FcLinux } from "react-icons/fc";
import IOSIcon from "@/assets/icons/IOSIcon";
import AppleLogo from "@/assets/os-icons/apple.svg";
import { getOperatingSystem } from "@/hooks/useOperatingSystem";
import { OperatingSystem } from "@/interfaces/OperatingSystem";
@@ -39,7 +40,7 @@ export function PeerOSCell({ os }: { os: string }) {
);
}
function OSLogo({ os }: { os: string }) {
export function OSLogo({ os }: { os: string }) {
const icon = useMemo(() => {
return getOperatingSystem(os.toLowerCase());
}, [os]);
@@ -48,6 +49,8 @@ function OSLogo({ os }: { os: string }) {
return <FaWindows className={"text-white text-lg"} />;
if (icon === OperatingSystem.APPLE)
return <Image src={AppleLogo} alt={""} width={14} />;
if (icon === OperatingSystem.IOS)
return <IOSIcon className={"fill-white"} size={20} />;
if (icon === OperatingSystem.ANDROID)
return <FcAndroidOs className={"text-white text-2xl brightness-200"} />;

View File

@@ -0,0 +1,203 @@
import Button from "@components/Button";
import HelpText from "@components/HelpText";
import InlineLink from "@components/InlineLink";
import { Label } from "@components/Label";
import { ModalClose, ModalFooter } from "@components/modal/Modal";
import Paragraph from "@components/Paragraph";
import { RadioGroup, RadioGroupItem } from "@components/RadioGroup";
import { CitySelector } from "@components/ui/CitySelector";
import { CountrySelector } from "@components/ui/CountrySelector";
import { isEmpty, uniqueId } from "lodash";
import {
ExternalLinkIcon,
FlagIcon,
MinusCircleIcon,
PlusCircle,
ShieldCheck,
ShieldXIcon,
} from "lucide-react";
import * as React from "react";
import { useState } from "react";
import { GeoLocation, GeoLocationCheck } from "@/interfaces/PostureCheck";
import { PostureCheckCard } from "@/modules/posture-checks/ui/PostureCheckCard";
type Props = {
value?: GeoLocationCheck;
onChange: (value: GeoLocationCheck | undefined) => void;
};
export const PostureCheckGeoLocation = ({ value, onChange }: Props) => {
const [open, setOpen] = useState(false);
return (
<PostureCheckCard
open={open}
setOpen={setOpen}
icon={<FlagIcon size={16} />}
title={"Country & Region"}
description={
"Restrict access in your network based on country or region."
}
iconClass={"bg-gradient-to-tr from-indigo-500 to-indigo-400"}
modalWidthClass={"max-w-2xl"}
active={value ? value?.locations?.length > 0 : false}
onReset={() => onChange(undefined)}
license={
<div className={"text-xs max-w-xs"}>
This check includes GeoLite2 data created by MaxMind, available from{" "}
<InlineLink href={"https://www.maxmind.com"} target={"_blank"}>
https://www.maxmind.com
</InlineLink>
</div>
}
>
<CheckContent
value={value}
onChange={(v) => {
onChange(v);
setOpen(false);
}}
/>
</PostureCheckCard>
);
};
const CheckContent = ({ value, onChange }: Props) => {
const [allowDenyLocation, setAllowDenyLocation] = useState<string>(
value?.action ? value.action : "allow",
);
const [locations, setLocations] = useState<GeoLocation[]>(
value?.locations.map((l) => {
return {
id: uniqueId("location"),
country_code: l.country_code,
city_name: l.city_name || "",
};
}) || [],
);
const updateLocation = (id: string, location: GeoLocation) => {
const find = locations.find((l) => l.id === id);
if (find) {
Object.assign(find, location);
setLocations([...locations]);
}
};
const removeLocation = (id: string) => {
setLocations(locations.filter((l) => l.id !== id));
};
const addLocation = () => {
setLocations([
...locations,
{ id: uniqueId("location"), country_code: "AF", city_name: "" },
]);
};
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>Allow or Block Location</Label>
<HelpText className={""}>
Choose whether you want to allow or block access from specific
countries or regions
</HelpText>
</div>
<RadioGroup value={allowDenyLocation} onChange={setAllowDenyLocation}>
<RadioGroupItem value={"allow"} variant={"green"}>
<ShieldCheck size={16} />
Allow
</RadioGroupItem>
<RadioGroupItem value={"deny"} variant={"red"}>
<ShieldXIcon size={16} />
Block
</RadioGroupItem>
</RadioGroup>
</div>
{locations.length > 0 && (
<div className={"mb-2 flex flex-col gap-2 w-full "}>
{locations.map((location) => {
return (
<div key={location.id} className={"flex gap-2"}>
<CountrySelector
value={location.country_code}
onChange={(value) => {
updateLocation(location.id, {
...location,
country_code: value,
});
}}
/>
{location.country_code && (
<CitySelector
value={location.city_name || ""}
onChange={(value) => {
updateLocation(location.id, {
...location,
city_name: value,
});
}}
country={location.country_code}
/>
)}
<Button
className={"h-[42px]"}
variant={"default-outline"}
onClick={() => removeLocation(location.id)}
>
<MinusCircleIcon size={15} />
</Button>
</div>
);
})}
</div>
)}
<Button
variant={"dotted"}
size={"sm"}
disabled={allowDenyLocation == "all"}
onClick={addLocation}
>
<PlusCircle size={16} />
Add Location
</Button>
</div>
<ModalFooter className={"items-center"}>
<div className={"w-full"}>
<Paragraph className={"text-sm mt-auto"}>
Learn more about
<InlineLink href={"#"} target={"_blank"}>
Country & Region 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"}
onClick={() => {
if (isEmpty(locations)) {
onChange(undefined);
} else {
onChange({
action: allowDenyLocation as "allow" | "deny",
locations: locations,
});
}
}}
>
Save
</Button>
</div>
</ModalFooter>
</>
);
};

View File

@@ -0,0 +1,116 @@
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 { validator } from "@utils/helpers";
import { isEmpty } from "lodash";
import { ExternalLinkIcon } from "lucide-react";
import * as React from "react";
import { useMemo, useState } from "react";
import NetBirdIcon from "@/assets/icons/NetBirdIcon";
import { NetBirdVersionCheck } from "@/interfaces/PostureCheck";
import { PostureCheckCard } from "@/modules/posture-checks/ui/PostureCheckCard";
type Props = {
value?: NetBirdVersionCheck;
onChange: (value: NetBirdVersionCheck | undefined) => void;
};
export const PostureCheckNetBirdVersion = ({ value, onChange }: Props) => {
const [open, setOpen] = useState(false);
return (
<PostureCheckCard
open={open}
setOpen={setOpen}
key={open ? 1 : 0}
active={value?.min_version !== undefined}
title={"NetBird Client Version"}
description={
"Restrict access to peers with a specific NetBird client version."
}
icon={<NetBirdIcon size={18} />}
modalWidthClass={"max-w-lg"}
onReset={() => onChange(undefined)}
>
<CheckContent
value={value}
onChange={(v) => {
onChange(v);
setOpen(false);
}}
/>
</PostureCheckCard>
);
};
const CheckContent = ({ value, onChange }: Props) => {
const [version, setVersion] = useState(value?.min_version || "");
const versionError = useMemo(() => {
if (version == "") return "";
const validSemver = validator.isValidVersion(version);
if (!validSemver)
return "Please enter a valid version, e.g., 0.2, 0.2.0, 0.2.0-alpha.1";
}, [version]);
const canSave = useMemo(() => {
return !versionError && version !== value?.min_version && !isEmpty(version);
}, [version, versionError, value]);
return (
<>
<div className={"flex flex-col px-8 gap-3 pb-6"}>
<div>
<Label>Minimum required version</Label>
<HelpText>
Only peers with the minimum specified NetBird client version will
have access to the network.
</HelpText>
<div>
<Input
className={"max-w-[200px]"}
value={version}
onChange={(e) => setVersion(e.target.value)}
placeholder={"e.g., 0.25.0"}
error={versionError}
customPrefix={"Version"}
/>
</div>
</div>
</div>
<ModalFooter className={"items-center"}>
<div className={"w-full"}>
<Paragraph className={"text-sm mt-auto"}>
Learn more about
<InlineLink href={"#"} target={"_blank"}>
Client Version 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={!canSave}
onClick={() => {
if (isEmpty(version)) {
onChange(undefined);
} else {
onChange({ min_version: version });
}
}}
>
Save
</Button>
</div>
</ModalFooter>
</>
);
};

View File

@@ -0,0 +1,416 @@
import Button from "@components/Button";
import FancyToggleSwitch from "@components/FancyToggleSwitch";
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 { RadioGroup, RadioGroupItem } from "@components/RadioGroup";
import {
SelectDropdown,
SelectOption,
} from "@components/select/SelectDropdown";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@components/Tabs";
import { IconMathEqualGreater } from "@tabler/icons-react";
import { validator } from "@utils/helpers";
import { isEmpty } from "lodash";
import {
Disc3Icon,
ExternalLinkIcon,
FileCog,
GalleryHorizontalEnd,
ShieldCheck,
ShieldXIcon,
} from "lucide-react";
import * as React from "react";
import { useEffect, useMemo, useState } from "react";
import AndroidIcon from "@/assets/icons/AndroidIcon";
import AppleIcon from "@/assets/icons/AppleIcon";
import IOSIcon from "@/assets/icons/IOSIcon";
import { LinuxIcon } from "@/assets/icons/LinuxIcon";
import WindowsIcon from "@/assets/icons/WindowsIcon";
import { OperatingSystem } from "@/interfaces/OperatingSystem";
import {
androidVersions,
iOSVersions,
macOSVersions,
OperatingSystemVersionCheck,
windowsKernelVersions,
} from "@/interfaces/PostureCheck";
import { PostureCheckCard } from "@/modules/posture-checks/ui/PostureCheckCard";
type Props = {
value?: OperatingSystemVersionCheck;
onChange: (value: OperatingSystemVersionCheck | undefined) => void;
};
export const PostureCheckOperatingSystem = ({ value, onChange }: Props) => {
const [open, setOpen] = useState(false);
return (
<PostureCheckCard
open={open}
setOpen={setOpen}
key={open ? 1 : 0}
icon={<Disc3Icon size={16} />}
title={"Operating System"}
modalWidthClass={"max-w-xl"}
description={
"Restrict access in your network based on the operating system."
}
iconClass={"bg-gradient-to-tr from-nb-gray-500 to-nb-gray-300"}
active={value !== undefined}
onReset={() => onChange(undefined)}
>
<CheckContent
value={value}
onChange={(v) => {
onChange(v);
setOpen(false);
}}
/>
</PostureCheckCard>
);
};
const CheckContent = ({ value, onChange }: Props) => {
const [tab] = useState(String(OperatingSystem.LINUX));
const firstTimeCheck = value === undefined;
const [windowsVersion, setWindowsVersion] = useState<string>(
firstTimeCheck
? ""
: value && value.windows
? value.windows.min_kernel_version
: "-",
);
const [macOSVersion, setMacOSVersion] = useState<string>(
firstTimeCheck
? ""
: value && value.darwin
? value.darwin?.min_version
: "-",
);
const [androidVersion, setAndroidVersion] = useState<string>(
firstTimeCheck
? ""
: value && value.android
? value.android?.min_version
: "-",
);
const [iOSVersion, setIOSVersion] = useState<string>(
firstTimeCheck ? "" : value && value.ios ? value.ios?.min_version : "-",
);
const [linuxVersion, setLinuxVersion] = useState<string>(
firstTimeCheck
? ""
: value && value.linux
? value.linux?.min_kernel_version
: "-",
);
const [linuxError, setLinuxError] = useState("");
const [windowsError, setWindowsError] = useState("");
const [macOSError, setMacOSError] = useState("");
const [iOSError, setIOSError] = useState("");
const [androidError, setAndroidError] = useState("");
const versionError =
linuxError || windowsError || macOSError || iOSError || androidError;
return (
<>
<Tabs defaultValue={tab}>
<TabsList justify={"start"} className={"px-8"}>
<TabsTrigger value={String(OperatingSystem.LINUX)}>
<LinuxIcon
className={
"fill-nb-gray-500 group-data-[state=active]/trigger:fill-netbird transition-all"
}
/>
Linux
</TabsTrigger>
<TabsTrigger value={String(OperatingSystem.WINDOWS)}>
<WindowsIcon
className={
"fill-nb-gray-500 group-data-[state=active]/trigger:fill-netbird transition-all"
}
/>
Windows
</TabsTrigger>
<TabsTrigger value={String(OperatingSystem.APPLE)}>
<AppleIcon
className={
"fill-nb-gray-500 group-data-[state=active]/trigger:fill-netbird transition-all"
}
/>
macOS
</TabsTrigger>
<TabsTrigger value={String(OperatingSystem.IOS)}>
<IOSIcon
className={
"fill-nb-gray-500 group-data-[state=active]/trigger:fill-netbird transition-all"
}
/>
iOS
</TabsTrigger>
<TabsTrigger value={String(OperatingSystem.ANDROID)}>
<AndroidIcon
className={
"fill-nb-gray-500 group-data-[state=active]/trigger:fill-netbird transition-all"
}
/>
Android
</TabsTrigger>
</TabsList>
<TabsContent value={String(OperatingSystem.LINUX)} className={"px-8"}>
<OperatingSystemTab
value={linuxVersion}
onChange={setLinuxVersion}
os={OperatingSystem.LINUX}
onError={setLinuxError}
/>
</TabsContent>
<TabsContent value={String(OperatingSystem.WINDOWS)} className={"px-8"}>
<OperatingSystemTab
versionList={windowsKernelVersions}
value={windowsVersion}
onChange={setWindowsVersion}
os={OperatingSystem.WINDOWS}
onError={setWindowsError}
/>
</TabsContent>
<TabsContent value={String(OperatingSystem.APPLE)} className={"px-8"}>
<OperatingSystemTab
versionList={macOSVersions}
value={macOSVersion}
onChange={setMacOSVersion}
os={OperatingSystem.APPLE}
onError={setMacOSError}
/>
</TabsContent>
<TabsContent value={String(OperatingSystem.IOS)} className={"px-8"}>
<OperatingSystemTab
versionList={iOSVersions}
value={iOSVersion}
onChange={setIOSVersion}
os={OperatingSystem.IOS}
onError={setIOSError}
/>
</TabsContent>
<TabsContent value={String(OperatingSystem.ANDROID)} className={"px-8"}>
<OperatingSystemTab
versionList={androidVersions}
value={androidVersion}
onChange={setAndroidVersion}
os={OperatingSystem.ANDROID}
onError={setAndroidError}
/>
</TabsContent>
</Tabs>
<div className={"h-6"}></div>
<ModalFooter className={"items-center"}>
<div className={"w-full"}>
<Paragraph className={"text-sm mt-auto"}>
Learn more about
<InlineLink href={"#"} target={"_blank"}>
Operating System 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
disabled={!!versionError}
variant={"primary"}
onClick={() => {
const osCheck = {} as OperatingSystemVersionCheck;
if (windowsVersion !== "-") {
osCheck.windows = { min_kernel_version: windowsVersion };
}
if (macOSVersion !== "-") {
osCheck.darwin = { min_version: macOSVersion };
}
if (androidVersion !== "-") {
osCheck.android = { min_version: androidVersion };
}
if (iOSVersion !== "-") {
osCheck.ios = { min_version: iOSVersion };
}
if (linuxVersion !== "-") {
osCheck.linux = { min_kernel_version: linuxVersion };
}
if (isEmpty(osCheck)) {
onChange(undefined);
} else {
onChange(osCheck);
}
}}
>
Save
</Button>
</div>
</ModalFooter>
</>
);
};
type OperatingSystemTabProps = {
value: string;
onChange: (value: string) => void;
versionList?: SelectOption[];
os: OperatingSystem;
onError: (error: string) => void;
};
const allOrMinOptions = [
{
label: "All versions",
value: "all",
icon: GalleryHorizontalEnd,
},
{
label: "Equal or greater than",
value: "min",
icon: IconMathEqualGreater,
},
] as SelectOption[];
export const OperatingSystemTab = ({
value,
onChange,
versionList,
os,
onError,
}: OperatingSystemTabProps) => {
const [allow, setAllow] = useState(value == "-" ? "block" : "allow");
const [allOrMin, setAllOrMin] = useState(
value == "" || value == "-" || value == "0" ? "all" : "min",
);
const [useCustomVersion, setUseCustomVersion] = useState(() => {
if (!versionList) return false;
if (!value) return false;
if (value === "-") return false;
if (value === "0") return false;
const find = versionList.map((v) => v.value).includes(value);
return !find;
});
const changeAllow = (value: string) => {
setAllow(value);
if (value === "block") {
setAllOrMin("all");
onChange("-");
setAllOrMin("all");
setUseCustomVersion(false);
} else {
onChange("");
setAllOrMin("all");
setUseCustomVersion(false);
}
};
const changeAllOrMin = (option: string) => {
setAllOrMin(option);
if (option === "all") {
onChange("");
} else if (option === "min" && value == "" && versionList) {
const getLast = versionList[versionList.length - 1];
onChange(getLast.value);
}
};
const prefix =
os === OperatingSystem.LINUX || os === OperatingSystem.WINDOWS
? "Kernel Version"
: "Version";
const versionError = useMemo(() => {
const msg = "Please enter a valid version, e.g., 0.2, 0.2.0, 0.2.0-alpha.1";
if (value == "") return "";
if (value == "-") return "";
const validSemver = validator.isValidVersion(value);
if (!validSemver) return msg;
return "";
}, [value]);
useEffect(() => {
onError(versionError);
}, [versionError]);
return (
<div className={""}>
<div className={"flex justify-between items-start gap-10 "}>
<div>
<Label>Allow or Block</Label>
<HelpText>
Choose whether you want to allow or block the operating system.
</HelpText>
</div>
<RadioGroup value={allow} onChange={changeAllow}>
<RadioGroupItem value={"allow"} variant={"green"}>
<ShieldCheck size={14} />
Allow
</RadioGroupItem>
<RadioGroupItem value={"block"} variant={"red"}>
<ShieldXIcon size={14} />
Block
</RadioGroupItem>
</RadioGroup>
</div>
<div className={"gap-4 items-center grid grid-cols-2 mt-3"}>
<SelectDropdown
value={allOrMin}
onChange={changeAllOrMin}
options={allOrMinOptions}
disabled={allow === "block"}
/>
{versionList && !useCustomVersion ? (
<SelectDropdown
value={value || "0"}
showSearch={true}
placeholder={"Select version..."}
onChange={onChange}
options={versionList}
disabled={allOrMin === "all" || allow === "block"}
/>
) : (
<Input
value={value}
customPrefix={prefix}
placeholder={"e.g., 6.0.0"}
error={versionError}
errorTooltip={true}
disabled={allOrMin === "all" || allow === "block"}
onChange={(v) => {
onChange(v.target.value);
}}
/>
)}
</div>
{os !== OperatingSystem.LINUX && (
<div className={"mt-4"}>
<FancyToggleSwitch
disabled={allow === "block" || allOrMin === "all"}
value={useCustomVersion}
onChange={setUseCustomVersion}
label={
<>
<FileCog size={14} />
Use custom version number
</>
}
helpText={"Use a custom version number if you need more control."}
/>
</div>
)}
</div>
);
};

View File

@@ -0,0 +1,84 @@
import FullTooltip from "@components/FullTooltip";
import { ScrollArea } from "@components/ScrollArea";
import useFetchApi from "@utils/api";
import * as React from "react";
import RoundedFlag from "@/assets/countries/RoundedFlag";
import { Country } from "@/interfaces/Country";
import { GeoLocationCheck } from "@/interfaces/PostureCheck";
type Props = {
children: React.ReactNode;
check?: GeoLocationCheck;
};
export const GeoLocationTooltip = ({ children, check }: Props) => {
const { data: countries } = useFetchApi<Country[]>(`/locations/countries`);
return check ? (
<FullTooltip
className={"w-full"}
interactive={true}
contentClassName={"p-0"}
content={
<div
className={
"text-neutral-300 flex flex-col items-start text-sm gap-1 justify-start min-w-[200px]"
}
>
<div className={"px-4 pt-3"}>
{check.action == "allow" ? (
<span>
<span className={"text-green-500 font-semibold"}>
Allow only
</span>{" "}
the following <br />
countries & regions
</span>
) : (
<span>
<span className={"text-red-500 font-semibold"}>Block</span> the
following <br />
countries & regions
</span>
)}
</div>
<ScrollArea
className={"max-h-[285px] overflow-y-auto flex flex-col px-4"}
>
<div className={"flex flex-col gap-1.5 mt-2 text-xs mb-3 w-full"}>
{check.locations.map((location, index) => {
const country = countries?.find(
(c) => c.country_code === location.country_code,
);
return (
<div
className={" rounded-full flex items-center gap-2 pr-4"}
key={index}
>
<div
className={"border-2 border-nb-gray-900/50 rounded-full"}
>
<RoundedFlag country={location.country_code} size={23} />
</div>
<div className={"flex items-center"}>
<span className={"font-semibold"}>
{country && country.country_name}
</span>
{location.city_name && `, ${location.city_name}`}
</div>
</div>
);
})}
</div>
</ScrollArea>
</div>
}
>
{children}
</FullTooltip>
) : (
check
);
};

View File

@@ -0,0 +1,32 @@
import FullTooltip from "@components/FullTooltip";
import { IconMathEqualGreater } from "@tabler/icons-react";
import * as React from "react";
type Props = {
version?: string;
children?: React.ReactNode;
};
export const NetBirdVersionTooltip = ({ version, children }: Props) => {
return version ? (
<FullTooltip
className={"w-full"}
interactive={false}
content={
<div className={"text-neutral-300 flex items-center text-sm gap-1"}>
<span className={""}>Min. Client Version</span>
<span
className={"text-netbird font-semibold flex items-center gap-1"}
>
<IconMathEqualGreater size={14} />
{version}
</span>
</div>
}
>
{children}
</FullTooltip>
) : (
children
);
};

View File

@@ -0,0 +1,70 @@
import FullTooltip from "@components/FullTooltip";
import * as React from "react";
import AndroidIcon from "@/assets/icons/AndroidIcon";
import AppleIcon from "@/assets/icons/AppleIcon";
import IOSIcon from "@/assets/icons/IOSIcon";
import { LinuxIcon } from "@/assets/icons/LinuxIcon";
import WindowsIcon from "@/assets/icons/WindowsIcon";
import {
androidVersions,
iOSVersions,
macOSVersions,
OperatingSystemVersionCheck,
windowsKernelVersions,
} from "@/interfaces/PostureCheck";
import { PostureCheckOperatingSystemInfo } from "@/modules/posture-checks/ui/PostureCheckOperatingSystemInfo";
type Props = {
check?: OperatingSystemVersionCheck;
children: React.ReactNode;
};
export const OperatingSystemTooltip = ({ check, children }: Props) => {
return check ? (
<FullTooltip
interactive={false}
contentClassName={"p-2.5"}
className={"w-full"}
content={
<div>
<div className={"flex flex-col gap-1"}>
<PostureCheckOperatingSystemInfo
icon={LinuxIcon}
os={"Linux"}
version={check.linux?.min_kernel_version}
versionText={"Kernel Version"}
/>
<PostureCheckOperatingSystemInfo
icon={WindowsIcon}
os={"Windows"}
version={check.windows?.min_kernel_version}
versionText={"Kernel Version"}
versionList={windowsKernelVersions}
/>
<PostureCheckOperatingSystemInfo
icon={AppleIcon}
os={"macOS"}
version={check.darwin?.min_version}
versionList={macOSVersions}
/>
<PostureCheckOperatingSystemInfo
icon={IOSIcon}
os={"iOS"}
version={check.ios?.min_version}
versionList={iOSVersions}
/>
<PostureCheckOperatingSystemInfo
icon={AndroidIcon}
os={"Android"}
version={check.android?.min_version}
versionList={androidVersions}
/>
</div>
</div>
}
>
{children}
</FullTooltip>
) : (
children
);
};

View File

@@ -0,0 +1,33 @@
import { Modal, ModalContent } from "@components/modal/Modal";
import { cn } from "@utils/helpers";
import * as React from "react";
import { PostureCheck } from "@/interfaces/PostureCheck";
import PostureCheckBrowseTable from "@/modules/posture-checks/table/PostureCheckBrowseTable";
type Props = {
onSuccess: (checks: PostureCheck[]) => void;
open: boolean;
onOpenChange: (open: boolean) => void;
};
export const PostureCheckBrowseModal = ({
onSuccess,
open,
onOpenChange,
}: Props) => {
return (
<Modal open={open} onOpenChange={onOpenChange} key={open ? 1 : 0}>
<ModalContent
maxWidthClass={cn("relative", "max-w-2xl")}
className={"pb-0"}
showClose={false}
>
<PostureCheckBrowseTable
onAdd={(checks) => {
onSuccess(checks);
onOpenChange(false);
}}
/>
</ModalContent>
</Modal>
);
};

View File

@@ -0,0 +1,269 @@
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 { Modal, ModalContent, ModalFooter } from "@components/modal/Modal";
import ModalHeader from "@components/modal/ModalHeader";
import { notify } from "@components/Notification";
import Paragraph from "@components/Paragraph";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@components/Tabs";
import { Textarea } from "@components/Textarea";
import { useApiCall } from "@utils/api";
import { cn } from "@utils/helpers";
import { isEmpty } from "lodash";
import { ExternalLinkIcon, LayoutList, ShieldCheck, Text } from "lucide-react";
import React, { useState } from "react";
import { useSWRConfig } from "swr";
import {
GeoLocationCheck,
OperatingSystemVersionCheck,
PostureCheck,
} from "@/interfaces/PostureCheck";
import { PostureCheckGeoLocation } from "@/modules/posture-checks/checks/PostureCheckGeoLocation";
import { PostureCheckNetBirdVersion } from "@/modules/posture-checks/checks/PostureCheckNetBirdVersion";
import { PostureCheckOperatingSystem } from "@/modules/posture-checks/checks/PostureCheckOperatingSystem";
type Props = {
open: boolean;
onOpenChange: (open: boolean) => void;
onSuccess?: (check: PostureCheck) => void;
postureCheck?: PostureCheck;
};
export default function PostureCheckModal({
open,
onOpenChange,
onSuccess,
postureCheck,
}: Props) {
const postureCheckRequest = useApiCall("/posture-checks");
const { mutate } = useSWRConfig();
const [name, setName] = useState(postureCheck?.name || "");
const [description, setDescription] = useState(
postureCheck?.description || "",
);
const [nbVersionCheck, setNbVersionCheck] = useState(
postureCheck?.checks.nb_version_check || undefined,
);
const [geoLocationCheck, setGeoLocationCheckCheck] = useState(
postureCheck?.checks.geo_location_check || undefined,
);
const [osVersionCheck, setOsVersionCheck] = useState(
postureCheck?.checks.os_version_check || undefined,
);
const validateOSCheck = (osCheck?: OperatingSystemVersionCheck) => {
if (!osCheck) return;
const os = osCheck;
if (os.darwin && os.darwin.min_version == "") os.darwin.min_version = "0";
if (os.android && os.android.min_version == "")
os.android.min_version = "0";
if (os.windows && os.windows.min_kernel_version == "")
os.windows.min_kernel_version = "0";
if (os.linux && os.linux.min_kernel_version == "")
os.linux.min_kernel_version = "0";
if (os.ios && os.ios.min_version == "") os.ios.min_version = "0";
return os;
};
const validateLocationCheck = (locationCheck?: GeoLocationCheck) => {
if (!locationCheck) return;
if (!locationCheck.locations) return;
return {
action: locationCheck.action,
locations: locationCheck.locations.map((location) => {
if (location.city_name == "")
return { country_code: location.country_code };
return {
country_code: location.country_code,
city_name: location.city_name,
};
}),
};
};
const updateOrCreatePostureCheck = () => {
const newData = {
name,
description,
checks: {
nb_version_check: nbVersionCheck,
geo_location_check: validateLocationCheck(geoLocationCheck),
os_version_check: validateOSCheck(osVersionCheck),
},
};
const updateOrCreate = !postureCheck
? () =>
postureCheckRequest.post(newData).then((check: PostureCheck) => {
mutate("/posture-checks");
onSuccess?.(check);
onOpenChange(false);
})
: () =>
postureCheckRequest
.put({ ...newData, id: postureCheck.id }, `/${postureCheck.id}`)
.then((check: PostureCheck) => {
mutate("/posture-checks").then();
onSuccess?.(check);
onOpenChange(false);
});
notify({
title: `Posture Check ${newData.name}`,
description: `Posture Check was ${
postureCheck ? "updated" : "created"
} successfully.`,
loadingMessage: `${
postureCheck ? "Updating" : "Creating"
} your posture check...`,
promise: updateOrCreate(),
});
};
const isAtLeastOneCheckEnabled =
!!nbVersionCheck || !!geoLocationCheck || !!osVersionCheck;
const canCreate = !isEmpty(name) && isAtLeastOneCheckEnabled;
const [tab, setTab] = useState("checks");
return (
<>
<Modal open={open} onOpenChange={onOpenChange} key={open ? 1 : 0}>
<ModalContent
maxWidthClass={cn("relative", "max-w-2xl")}
showClose={true}
>
<ModalHeader
icon={<ShieldCheck size={19} />}
title={
postureCheck ? "Update Posture Check" : "Create Posture Check"
}
description={
"Use posture checks to further restrict access in your network."
}
color={"netbird"}
/>
<Tabs onValueChange={(v) => setTab(v)} defaultValue={tab} value={tab}>
<TabsList justify={"start"} className={"px-8"}>
<TabsTrigger value={"checks"}>
<LayoutList size={16} />
Checks
</TabsTrigger>
<TabsTrigger value={"general"}>
<Text
size={16}
className={
"text-nb-gray-500 group-data-[state=active]/trigger:text-netbird transition-all"
}
/>
Name & Description
</TabsTrigger>
</TabsList>
<TabsContent value={"checks"} className={"pb-6 px-8"}>
<>
<PostureCheckNetBirdVersion
value={nbVersionCheck}
onChange={setNbVersionCheck}
/>
<PostureCheckGeoLocation
value={geoLocationCheck}
onChange={setGeoLocationCheckCheck}
/>
<PostureCheckOperatingSystem
value={osVersionCheck}
onChange={setOsVersionCheck}
/>
</>
</TabsContent>
<TabsContent value={"general"} className={"pb-8 px-8"}>
<div className={"flex flex-col gap-6"}>
<div>
<Label>Name of the Posture Check</Label>
<HelpText>
Set an easily identifiable name for your posture check.
</HelpText>
<Input
autoFocus={true}
tabIndex={0}
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={"e.g., NetBird Version > 0.25.0"}
/>
</div>
<div>
<Label>Description (optional)</Label>
<HelpText>
Write a short description to add more context to this
policy.
</HelpText>
<Textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder={
"e.g., Check if the NetBird version is bigger than 0.25.0"
}
rows={3}
/>
</div>
</div>
</TabsContent>
</Tabs>
<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/routing-traffic-to-private-networks"
}
target={"_blank"}
>
Posture Checks
<ExternalLinkIcon size={12} />
</InlineLink>
</Paragraph>
</div>
<div className={"flex gap-3 w-full justify-end"}>
<>
<Button
variant={"secondary"}
onClick={() => onOpenChange(false)}
>
Cancel
</Button>
{!postureCheck && tab == "checks" && (
<Button
variant={"primary"}
onClick={() => setTab("general")}
disabled={!isAtLeastOneCheckEnabled}
>
Continue
</Button>
)}
{((!postureCheck && tab == "general") || postureCheck) && (
<Button
variant={"primary"}
disabled={!canCreate}
onClick={updateOrCreatePostureCheck}
>
{postureCheck ? "Save Changes" : "Create Posture Check"}
</Button>
)}
</>
</div>
</ModalFooter>
</ModalContent>
</Modal>
</>
);
}

View File

@@ -0,0 +1,131 @@
import Button from "@components/Button";
import { Checkbox } from "@components/Checkbox";
import { DataTable } from "@components/table/DataTable";
import DataTableHeader from "@components/table/DataTableHeader";
import DataTableRefreshButton from "@components/table/DataTableRefreshButton";
import { useLocalStorage } from "@hooks/useLocalStorage";
import type { ColumnDef, SortingState } from "@tanstack/react-table";
import useFetchApi from "@utils/api";
import { usePathname } from "next/navigation";
import React from "react";
import { useSWRConfig } from "swr";
import { PostureCheck } from "@/interfaces/PostureCheck";
import { PostureCheckChecksCell } from "@/modules/posture-checks/table/cells/PostureCheckChecksCell";
import { PostureCheckNameCell } from "@/modules/posture-checks/table/cells/PostureCheckNameCell";
type Props = {
onAdd: (checks: PostureCheck[]) => void;
};
export default function PostureCheckBrowseTable({ onAdd }: Props) {
const { data: postureChecks, isLoading } =
useFetchApi<PostureCheck[]>("/posture-checks");
const { mutate } = useSWRConfig();
const path = usePathname();
// Default sorting state of the table
const [sorting, setSorting] = useLocalStorage<SortingState>(
"netbird-table-sort" + path,
[
{
id: "name",
desc: true,
},
],
);
return (
<div className={""}>
<DataTable
isLoading={isLoading}
text={"Posture Check"}
sorting={sorting}
wrapperClassName={""}
setSorting={setSorting}
columns={PostureChecksColumns}
showHeader={true}
columnVisibility={{
description: false,
}}
tableClassName={"mt-6 !border-0"}
rowClassName={"!border-b-0 px-10"}
data={postureChecks}
searchPlaceholder={"Search by name and description..."}
onRowClick={(row) => row.toggleSelected()}
rightSide={(table) => (
<>
{postureChecks && postureChecks?.length > 0 && (
<Button
variant={"primary"}
className={"ml-auto"}
onClick={() =>
onAdd(
table.getSelectedRowModel().rows.map((row) => row.original),
)
}
disabled={table.getSelectedRowModel().rows.length <= 0}
>
Add Posture Checks ({table.getSelectedRowModel().rows.length})
</Button>
)}
</>
)}
>
{() => {
return (
<>
<DataTableRefreshButton
isDisabled={postureChecks?.length == 0}
onClick={() => {
mutate("/posture-checks");
}}
/>
</>
);
}}
</DataTable>
</div>
);
}
export const PostureChecksColumns: ColumnDef<PostureCheck>[] = [
{
id: "select",
header: ({ table }) => (
<div className={"min-w-[20px] max-w-[20px]"}>
<Checkbox
checked={table.getIsAllPageRowsSelected()}
onCheckedChange={(value) => table.toggleAllRowsSelected(!!value)}
aria-label="Select all"
/>
</div>
),
cell: ({ row }) => (
<div className={"min-w-[20px] max-w-[20px]"}>
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
aria-label="Select row"
/>
</div>
),
enableSorting: false,
enableHiding: false,
},
{
accessorKey: "name",
header: ({ column }) => {
return <DataTableHeader column={column}>Name</DataTableHeader>;
},
cell: ({ row }) => (
<PostureCheckNameCell small={true} check={row.original} />
),
},
{
accessorKey: "id",
header: ({ column }) => {
return <DataTableHeader column={column}>Checks</DataTableHeader>;
},
cell: ({ row }) => <PostureCheckChecksCell check={row.original} />,
},
];

View File

@@ -0,0 +1,121 @@
import Button from "@components/Button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@components/DropdownMenu";
import HelpText from "@components/HelpText";
import { Label } from "@components/Label";
import {
Edit,
FolderSearch,
MinusCircleIcon,
MoreVertical,
PlusCircle,
} from "lucide-react";
import React from "react";
import { PostureCheck } from "@/interfaces/PostureCheck";
import { PostureCheckChecksCell } from "@/modules/posture-checks/table/cells/PostureCheckChecksCell";
import { PostureCheckNameCell } from "@/modules/posture-checks/table/cells/PostureCheckNameCell";
import { PostureCheckNoChecksInfo } from "@/modules/posture-checks/ui/PostureCheckNoChecksInfo";
type Props = {
data: PostureCheck[];
onAddClick: () => void;
onBrowseClick: () => void;
onRemoveClick: (check: PostureCheck) => void;
onEditClick: (check: PostureCheck) => void;
};
export default function PostureCheckMinimalTable({
data,
onAddClick,
onBrowseClick,
onRemoveClick,
onEditClick,
}: Props) {
return data && data.length > 0 ? (
<div className={""}>
<div className={"flex justify-between gap-10 mb-5 items-end"}>
<div>
<Label>
{data.length}{" "}
{data.length == 1 ? "Posture Check" : "Posture Checks"}
</Label>
<HelpText className={"mb-0"}>
Use posture checks to further restrict access in your network.
</HelpText>
</div>
<div className={"flex items-center justify-center gap-4"}>
<Button variant={"secondary"} size={"xs"} onClick={onBrowseClick}>
<FolderSearch size={14} />
Browse Checks
</Button>
<Button variant={"primary"} size={"xs"} onClick={onAddClick}>
<PlusCircle size={14} />
New Posture Check
</Button>
</div>
</div>
<div
className={
"rounded-md overflow-hidden border border-nb-gray-900 bg-nb-gray-920/30 py-1 px-1"
}
>
{data.map((check) => {
return (
<div
key={check.id}
className={
"flex justify-between py-2 items-center hover:bg-nb-gray-900/30 rounded-md cursor-pointer px-4 transition-all"
}
onClick={() => onEditClick(check)}
>
<PostureCheckNameCell small={true} check={check} />
<div className={"flex gap-4 items-center"}>
<PostureCheckChecksCell check={check} />
<DropdownMenu modal={false}>
<DropdownMenuTrigger
asChild={true}
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
}}
>
<Button variant={"default-outline"} className={"!px-3"}>
<MoreVertical size={16} className={"shrink-0"} />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-auto min-w-[200px]"
align="end"
>
<DropdownMenuItem onClick={() => onEditClick(check)}>
<div className={"flex gap-3 items-center"}>
<Edit size={14} className={"shrink-0"} />
Edit Posture Check
</div>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onRemoveClick(check)}>
<div className={"flex gap-3 items-center"}>
<MinusCircleIcon size={14} className={"shrink-0"} />
Remove Posture Check
</div>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
);
})}
</div>
</div>
) : (
<PostureCheckNoChecksInfo
onAddClick={onAddClick}
onBrowseClick={onBrowseClick}
/>
);
}

View File

@@ -0,0 +1,235 @@
import Button from "@components/Button";
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 { useLocalStorage } from "@hooks/useLocalStorage";
import { IconCirclePlus } from "@tabler/icons-react";
import type { ColumnDef, SortingState } from "@tanstack/react-table";
import useFetchApi from "@utils/api";
import { ExternalLinkIcon, ShieldCheck } from "lucide-react";
import { usePathname } from "next/navigation";
import React, { useMemo, useState } from "react";
import { useSWRConfig } from "swr";
import { Policy } from "@/interfaces/Policy";
import { PostureCheck } from "@/interfaces/PostureCheck";
import PostureCheckModal from "@/modules/posture-checks/modal/PostureCheckModal";
import { PostureCheckActionCell } from "@/modules/posture-checks/table/cells/PostureCheckActionCell";
import { PostureCheckChecksCell } from "@/modules/posture-checks/table/cells/PostureCheckChecksCell";
import { PostureCheckNameCell } from "@/modules/posture-checks/table/cells/PostureCheckNameCell";
import { PostureCheckPolicyUsageCell } from "@/modules/posture-checks/table/cells/PostureCheckPolicyUsageCell";
type Props = {
isLoading: boolean;
postureChecks: PostureCheck[] | undefined;
};
const Columns: ColumnDef<PostureCheck>[] = [
{
accessorKey: "name",
header: ({ column }) => {
return <DataTableHeader column={column}>Name</DataTableHeader>;
},
cell: ({ row }) => <PostureCheckNameCell check={row.original} />,
},
{
id: "active",
accessorKey: "active",
sortingFn: "basic",
},
{
id: "checks",
accessorFn: (row) => Object.keys(row.checks).length,
header: ({ column }) => {
return <DataTableHeader column={column}>Checks</DataTableHeader>;
},
cell: ({ row }) => <PostureCheckChecksCell check={row.original} />,
},
{
id: "access_control_usage",
header: ({ column }) => {
return <DataTableHeader column={column}>Used by</DataTableHeader>;
},
cell: ({ row }) => <PostureCheckPolicyUsageCell check={row.original} />,
},
{
accessorKey: "id",
header: "",
cell: ({ row }) => <PostureCheckActionCell check={row.original} />,
},
];
export default function PostureCheckTable({ postureChecks, isLoading }: Props) {
const { data: policies } = useFetchApi<Policy[]>("/policies");
const { mutate } = useSWRConfig();
const path = usePathname();
const data = useMemo(() => {
if (!postureChecks) return [];
return postureChecks?.map((check) => {
const checkId = check.id;
if (!policies) return check;
const usage = policies?.filter((policy) => {
if (!policy.source_posture_checks) return false;
return policy.source_posture_checks.includes(checkId);
});
const isOnePolicyEnabled = usage.some((policy) => policy.enabled);
return {
...check,
policies: usage || [],
active: isOnePolicyEnabled,
};
});
}, [postureChecks, policies]);
// Default sorting state of the table
const [sorting, setSorting] = useLocalStorage<SortingState>(
"netbird-table-sort" + path,
[
{
id: "active",
desc: true,
},
],
);
const [postureCheckModal, setPostureCheckModal] = useState(false);
const [currentRow, setCurrentRow] = useState<PostureCheck>();
const [, setCurrentCellClicked] = useState("");
return (
<div className={""}>
{postureCheckModal && (
<PostureCheckModal
open={postureCheckModal}
key={currentRow ? 1 : 0}
onOpenChange={setPostureCheckModal}
postureCheck={currentRow}
/>
)}
<DataTable
isLoading={isLoading}
text={"Posture Check"}
sorting={sorting}
wrapperClassName={""}
setSorting={setSorting}
columns={Columns}
showHeader={true}
columnVisibility={{
active: false,
}}
onRowClick={(row, cell) => {
setCurrentRow(row.original);
setPostureCheckModal(true);
setCurrentCellClicked(cell);
}}
data={data}
searchPlaceholder={"Search by name and description..."}
rightSide={() => (
<>
{data && data?.length > 0 && (
<Button
variant={"primary"}
className={"ml-auto"}
onClick={() => {
setCurrentRow(undefined);
setPostureCheckModal(true);
}}
>
<IconCirclePlus size={16} />
Add Posture Check
</Button>
)}
</>
)}
getStartedCard={
<GetStartedTest
icon={
<SquareIcon
icon={<ShieldCheck size={23} />}
color={"gray"}
size={"large"}
/>
}
title={"Create Posture Check"}
description={
"Add posture checks to further restrict access in your network. E.g., only clients with a specific NetBird client version, operating system or location are allowed to connect."
}
button={
<Button
variant={"primary"}
className={"ml-auto"}
onClick={() => setPostureCheckModal(true)}
>
<IconCirclePlus size={16} />
Create Posture Check
</Button>
}
learnMore={
<>
Learn more about
<InlineLink href={"#"} target={"_blank"}>
Posture Checks
<ExternalLinkIcon size={12} />
</InlineLink>
</>
}
/>
}
>
{(table) => {
return (
<>
<ButtonGroup disabled={data?.length == 0}>
<ButtonGroup.Button
onClick={() => {
table.setPageIndex(0);
table.getColumn("active")?.setFilterValue(true);
}}
disabled={data?.length == 0}
variant={
table.getColumn("active")?.getFilterValue() == true
? "tertiary"
: "secondary"
}
>
Active
</ButtonGroup.Button>
<ButtonGroup.Button
onClick={() => {
table.setPageIndex(0);
table.getColumn("active")?.setFilterValue("");
}}
disabled={data?.length == 0}
variant={
table.getColumn("active")?.getFilterValue() != true
? "tertiary"
: "secondary"
}
>
All
</ButtonGroup.Button>
</ButtonGroup>
<DataTableRowsPerPage
table={table}
disabled={data?.length == 0}
/>
<DataTableRefreshButton
isDisabled={data?.length == 0}
onClick={() => {
mutate("/posture-checks");
}}
/>
</>
);
}}
</DataTable>
</div>
);
}

View File

@@ -0,0 +1,67 @@
import Button from "@components/Button";
import FullTooltip from "@components/FullTooltip";
import { notify } from "@components/Notification";
import { useApiCall } from "@utils/api";
import { Trash2 } from "lucide-react";
import * as React from "react";
import { useSWRConfig } from "swr";
import { useDialog } from "@/contexts/DialogProvider";
import { PostureCheck } from "@/interfaces/PostureCheck";
type Props = {
check: PostureCheck;
};
export const PostureCheckActionCell = ({ check }: Props) => {
const deleteRequest = useApiCall("/posture-checks");
const { confirm } = useDialog();
const { mutate } = useSWRConfig();
const handleDelete = async () => {
const choice = await confirm({
title: `Delete '${check.name}'?`,
description:
"Are you sure you want to delete this posture check? This action cannot be undone.",
confirmText: "Delete",
cancelText: "Cancel",
type: "danger",
});
if (choice) {
notify({
title: check.name,
description: "Posture check was successfully deleted",
promise: deleteRequest.del({}, `/${check.id}`).then(() => {
mutate("/posture-checks").then();
}),
loadingMessage: "Deleting posture check...",
});
}
};
const hasPolicies = check.policies ? check.policies?.length > 0 : false;
return (
<div className={"flex justify-end pr-4"}>
<FullTooltip
disabled={!hasPolicies}
content={
<div className={"text-xs max-w-xs"}>
This posture check is assigned to a policy and cannot be deleted.
Please remove the posture check from all policies before deleting
it.
</div>
}
interactive={false}
>
<Button
variant={"danger-outline"}
size={"sm"}
onClick={handleDelete}
disabled={hasPolicies}
>
<Trash2 size={16} />
Delete
</Button>
</FullTooltip>
</div>
);
};

View File

@@ -0,0 +1,63 @@
import { cn } from "@utils/helpers";
import { Disc3Icon, FlagIcon } from "lucide-react";
import * as React from "react";
import NetBirdIcon from "@/assets/icons/NetBirdIcon";
import { PostureCheck } from "@/interfaces/PostureCheck";
import { GeoLocationTooltip } from "@/modules/posture-checks/checks/tooltips/GeoLocationTooltip";
import { NetBirdVersionTooltip } from "@/modules/posture-checks/checks/tooltips/NetBirdVersionTooltip";
import { OperatingSystemTooltip } from "@/modules/posture-checks/checks/tooltips/OperatingSystemTooltip";
type Props = {
check: PostureCheck;
};
export const PostureCheckChecksCell = ({ check }: Props) => {
return (
<div className={"flex"}>
<div
className={
"flex items-center gap-3 bg-nb-gray-900/80 hover:bg-nb-gray-800 border border-nb-gray-800/50 py-1 rounded-full px-1 transition-all"
}
>
<div className={"flex -space-x-2 "}>
{check.checks.nb_version_check && (
<NetBirdVersionTooltip
version={check.checks.nb_version_check.min_version}
>
<div
className={cn(
"bg-gradient-to-tr from-netbird-200 to-netbird-100 h-8 w-8 rounded-full flex items-center justify-center relative z-[10] hover:scale-[1.1] transition-all",
)}
>
<NetBirdIcon size={14} />
</div>
</NetBirdVersionTooltip>
)}
{check.checks.geo_location_check && (
<GeoLocationTooltip check={check.checks.geo_location_check}>
<div
className={cn(
"bg-gradient-to-tr from-indigo-500 to-indigo-400 h-8 w-8 rounded-full flex items-center justify-center relative z-[9] hover:scale-[1.1] transition-all",
)}
>
<FlagIcon size={14} />
</div>
</GeoLocationTooltip>
)}
{check.checks.os_version_check && (
<OperatingSystemTooltip check={check.checks.os_version_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",
)}
>
<Disc3Icon size={14} />
</div>
</OperatingSystemTooltip>
)}
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,34 @@
import * as React from "react";
import RoundedFlag from "@/assets/countries/RoundedFlag";
import { PostureCheck } from "@/interfaces/PostureCheck";
import EmptyRow from "@/modules/common-table-rows/EmptyRow";
type Props = {
check: PostureCheck;
};
export const PostureCheckLocationCell = ({ check }: Props) => {
const countries = check.checks.geo_location_check?.locations.map(
(location) => location.country_code,
);
return countries ? (
<div className={"flex gap-2 items-center"}>
<span className={"font-medium text-nb-gray-200"}></span>
{countries.map((country, index) => {
return (
<div
key={index}
className={"relative"}
style={{
left: -index * 15,
}}
>
<RoundedFlag country={country} size={23} />
</div>
);
})}
</div>
) : (
<EmptyRow />
);
};

View File

@@ -0,0 +1,35 @@
import DescriptionWithTooltip from "@components/ui/DescriptionWithTooltip";
import * as React from "react";
import { PostureCheck } from "@/interfaces/PostureCheck";
import ActiveInactiveRow from "@/modules/common-table-rows/ActiveInactiveRow";
type Props = {
check: PostureCheck;
small?: boolean;
};
export const PostureCheckNameCell = ({ check, small }: Props) => {
return !small ? (
<ActiveInactiveRow
active={check.active || false}
inactiveDot={"gray"}
text={check.name}
>
<DescriptionWithTooltip
className={"mt-1"}
text={check.description}
maxChars={30}
/>
</ActiveInactiveRow>
) : (
<div className={"flex items-center gap-4 min-w-[350px]"}>
<div className={"flex flex-col gap-0.5 min-w-0 max-w-[300px]"}>
<div className={"text-sm text-nb-gray-100 truncate"}>{check.name}</div>
<DescriptionWithTooltip
className={"text-xs"}
text={check.description}
maxChars={30}
/>
</div>
</div>
);
};

View File

@@ -0,0 +1,100 @@
import Badge from "@components/Badge";
import Button from "@components/Button";
import FullTooltip from "@components/FullTooltip";
import { cn } from "@utils/helpers";
import { ArrowUpRightSquareIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import * as React from "react";
import AccessControlIcon from "@/assets/icons/AccessControlIcon";
import { Policy } from "@/interfaces/Policy";
import { PostureCheck } from "@/interfaces/PostureCheck";
type Props = {
check: PostureCheck;
};
export const PostureCheckPolicyUsageCell = ({ check }: Props) => {
const router = useRouter();
return (
<div className={cn("flex gap-4")}>
<FullTooltip
disabled={!(check.policies && check.policies?.length > 0)}
content={
<div className={"text-xs max-w-lg"}>
<span className={"font-medium text-nb-gray-100 text-sm"}>
Assigned
{check.policies && check.policies?.length > 1
? " Policies"
: " Policy"}
</span>
<div className={"flex gap-2 pt-3 pb-2 flex-wrap"}>
{check.policies &&
check.policies?.length > 0 &&
check.policies?.map((policy: Policy, index: number) => {
return (
<Badge
variant={"gray-ghost"}
useHover={false}
key={index}
className={"justify-start font-medium"}
>
<AccessControlIcon size={12} />
{policy.name}
</Badge>
);
})}
</div>
</div>
}
interactive={false}
>
<Badge
onClick={() => router.push("/access-control")}
variant={"gray"}
useHover={!!(check.policies && check.policies?.length > 0)}
className={cn(
"min-w-[110px] font-medium cursor-pointer",
check.policies &&
check.policies.length == 0 &&
"opacity-30 pointer-events-none",
)}
>
<AccessControlIcon size={12} />
<span>
<span className={"font-bold"}>
{check.policies && check.policies?.length > 0
? check.policies && check.policies?.length
: ""}
</span>{" "}
{check.policies && check.policies?.length == 0
? "No Policies"
: check.policies && check.policies?.length > 1
? "Policies"
: "Policy"}
</span>
</Badge>
</FullTooltip>
<FullTooltip
content={
<div className={"text-xs max-w-[260px]"}>
To assign this posture check to your policies, visit the Policies
page.
</div>
}
interactive={false}
>
<Button
size={"xs"}
variant={"secondary"}
className={"min-w-[130px]"}
onClick={() => router.push("/access-control")}
>
<>
<ArrowUpRightSquareIcon size={12} />
Go to Policies
</>
</Button>
</FullTooltip>
</div>
);
};

View File

@@ -0,0 +1,154 @@
import FullTooltip from "@components/FullTooltip";
import { Modal, ModalContent } from "@components/modal/Modal";
import { IconCircleFilled } from "@tabler/icons-react";
import { cn } from "@utils/helpers";
import { ScaleIcon } from "lucide-react";
import * as React from "react";
import { useDialog } from "@/contexts/DialogProvider";
export const PostureCheckCard = ({
children,
title,
description,
icon,
iconClass = "bg-gradient-to-tr from-netbird-200 to-netbird-100",
modalWidthClass = "max-w-xl",
onClose,
open,
setOpen,
active,
onReset,
license,
}: {
children?: React.ReactNode;
title?: string;
description?: string;
iconClass?: string;
icon?: React.ReactNode;
modalWidthClass?: string;
onClose?: () => void;
open: boolean;
setOpen: (open: boolean) => void;
onReset?: () => void;
active?: boolean;
license?: React.ReactNode;
}) => {
const { confirm } = useDialog();
const handleReset = async () => {
const reset = await confirm({
title: `Disable this check?`,
description:
"Are you sure you want to disable this check? All settings of this check will be lost.",
confirmText: "Disable",
cancelText: "Cancel",
type: "danger",
});
if (reset) onReset?.();
};
const licenseToolTip = (
<FullTooltip content={license}>
<ScaleIcon
size={14}
className={
"text-nb-gray-400 hover:text-nb-gray-200 transition-all cursor-pointer -top-[1px] relative"
}
/>
</FullTooltip>
);
return (
<div className={"w-full"}>
<div
onClick={() => setOpen(true)}
className={
"hover:bg-nb-gray-920/80 border border-transparent hover:border-nb-gray-900 rounded-md flex flex-col items-center transition-all cursor-pointer w-full"
}
>
<div className={"flex gap-4 items-center w-full px-4 py-3"}>
<div
className={cn(
"h-9 w-9 shrink-0 shadow-xl rounded-md flex items-center justify-center select-none",
iconClass,
)}
>
{icon}
</div>
<div className={" w-full"}>
<div
className={
"text-sm font-medium flex gap-2 items-center justify-between"
}
>
<span className={"flex items-center gap-2"}>
{title}
{license && licenseToolTip}
</span>
</div>
<div className={"text-xs mt-0.5 text-nb-gray-300"}>
{description}
</div>
</div>
<div>
<span
className={cn(
"text-[10px] rounded-full px-1 py-1 flex items-center gap-1 w-[50px] justify-center uppercase font-medium",
active
? "text-green-400 bg-green-900 hover:bg-green-800 transition-all hover:text-green-200"
: "text-nb-gray-400 bg-nb-gray-900",
)}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
if (active) handleReset().then();
else setOpen(true);
}}
>
<IconCircleFilled size={7} className={"mt-[0.1px]"} />
{active ? "On" : "Off"}
</span>
</div>
</div>
</div>
<Modal
open={open}
onOpenChange={(open) => {
setOpen(open);
if (onClose && !open) {
onClose();
}
}}
key={open ? 1 : 0}
>
<ModalContent
maxWidthClass={cn("relative", modalWidthClass)}
showClose={true}
>
<div className={"flex gap-4 items-center px-8 pb-5"}>
<div
className={cn(
"h-9 w-9 shrink-0 shadow-xl rounded-md flex items-center justify-center select-none",
iconClass,
)}
>
{icon}
</div>
<div className={"pr-10"}>
<div className={"text-sm font-medium flex gap-2 items-center"}>
{title}
{license && licenseToolTip}
</div>
<div className={"text-xs mt-0.5 text-nb-gray-300"}>
{description}
</div>
</div>
</div>
{children}
</ModalContent>
</Modal>
</div>
);
};

View File

@@ -0,0 +1,60 @@
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 NetBirdIcon from "@/assets/icons/NetBirdIcon";
import AppleLogo from "@/assets/os-icons/apple.svg";
type Props = {
children: React.ReactNode;
className?: string;
};
export const PostureCheckIcons = () => {
return (
<div className={"flex items-center justify-center -space-x-2"}>
<Circle className={"top-2"}>
<Image src={AppleLogo} alt={""} width={14} />
</Circle>
<Circle className={"top-1"}>
<div
className={
"h-6 w-6 overflow-hidden rounded-full flex items-center justify-center"
}
>
<CountryDERounded />
</div>
</Circle>
<Circle className={"z-[3]"}>
<NetBirdIcon size={18} />
</Circle>
<Circle className={"top-1 z-[2]"}>
<div
className={
"h-6 w-6 overflow-hidden rounded-full flex items-center justify-center"
}
>
<CountryUSRounded />
</div>
</Circle>
<Circle className={"z-[1] top-2 "}>
<FaWindows className={"text-white text-md"} />
</Circle>
</div>
);
};
const Circle = ({ children, className }: Props) => {
return (
<div
className={cn(
"h-10 w-10 rounded-full bg-nb-gray-900 flex items-center justify-center relative border-2 border-nb-gray",
className,
)}
>
{children}
</div>
);
};

View File

@@ -0,0 +1,53 @@
import Button from "@components/Button";
import Paragraph from "@components/Paragraph";
import { IconCirclePlus } from "@tabler/icons-react";
import useFetchApi from "@utils/api";
import { cn } from "@utils/helpers";
import { FolderSearch } from "lucide-react";
import * as React from "react";
import { PostureCheck } from "@/interfaces/PostureCheck";
export function PostureCheckNoChecksInfo({
onAddClick,
onBrowseClick,
}: {
onAddClick: () => void;
onBrowseClick: () => void;
}) {
const { data: postureChecks } =
useFetchApi<PostureCheck[]>("/posture-checks");
return (
<div>
<div
className={
"mx-auto text-center flex flex-col items-center justify-center"
}
>
<h2 className={"text-lg my-0 leading-[1.5 text-center]"}>
{"You haven't added any posture checks yet"}
</h2>
<Paragraph className={cn("text-sm text-center max-w-md mt-1")}>
Add various posture checks to further restrict access in your network.
E.g., only clients with a specific NetBird client version, operating
system or location are allowed to connect.
</Paragraph>
</div>
<div className={"flex items-center justify-center gap-4 mt-5"}>
<Button
variant={"secondary"}
size={"xs"}
disabled={postureChecks?.length == 0}
onClick={onBrowseClick}
>
<FolderSearch size={14} />
Browse Checks
</Button>
<Button variant={"primary"} size={"xs"} onClick={onAddClick}>
<IconCirclePlus size={14} />
New Posture Check
</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,68 @@
import { SelectOption } from "@components/select/SelectDropdown";
import { IconMathEqualGreater } from "@tabler/icons-react";
import { cn } from "@utils/helpers";
import * as React from "react";
type Props = {
version?: string;
versionText?: string;
versionList?: SelectOption[];
icon: React.FunctionComponent<{ size: number }>;
os: string;
};
export const PostureCheckOperatingSystemInfo = ({
version,
icon,
versionText = "Version",
versionList,
os,
}: Props) => {
const operatingSystemName = versionList?.find(
(item) => item.value === version,
)?.label;
return (
<div
className={cn(
"flex items-center gap-1.5 pl-1 pr-4 py-1 text-xs justify-start",
)}
>
<div
className={cn(
"w-5 h-5 flex items-center justify-center brightness-[150%]",
)}
>
{icon({ size: 14 })}
</div>
<div className={"flex items-center"}>
<span
className={cn(
version ? "text-green-500" : "text-red-500",
"mr-1 font-semibold",
)}
>
{version ? "Allow" : "Block"}{" "}
</span>
{version ? (
version == "0" ? (
os
) : (
<div className={"flex items-center gap-1"}>
{" "}
{os} {operatingSystemName ? "Version" : versionText}
<span
className={"text-netbird flex items-center gap-1 font-semibold"}
>
<IconMathEqualGreater size={14} />
{operatingSystemName || version}
</span>
</div>
)
) : (
os
)}
</div>
</div>
);
};

View File

@@ -0,0 +1,95 @@
import { TabsContent } from "@components/Tabs";
import * as React from "react";
import { useState } from "react";
import Skeleton from "react-loading-skeleton";
import { PostureCheck } from "@/interfaces/PostureCheck";
import { PostureCheckBrowseModal } from "@/modules/posture-checks/modal/PostureCheckBrowseModal";
import PostureCheckModal from "@/modules/posture-checks/modal/PostureCheckModal";
import PostureCheckMinimalTable from "@/modules/posture-checks/table/PostureCheckMinimalTable";
type Props = {
postureChecks: PostureCheck[];
setPostureChecks: React.Dispatch<React.SetStateAction<PostureCheck[]>>;
isLoading: boolean;
};
export const PostureCheckTab = ({
postureChecks,
setPostureChecks,
isLoading,
}: Props) => {
const addPostureChecks = (checks: PostureCheck[]) => {
setPostureChecks((prev) => {
const previous = prev.map((check) => {
const find = checks.find((c) => c.id === check.id);
if (find) return find;
return check;
});
const allChecks = [...previous, ...checks];
return allChecks.filter(
(check, index, self) =>
self.findIndex((c) => c.id === check.id) === index,
);
});
};
const removePostureCheck = (check: PostureCheck) => {
setPostureChecks((prev) => {
return prev.filter((c) => c.id !== check.id);
});
};
const [checkModal, setCheckModal] = useState(false);
const [browseModal, setBrowseModal] = useState(false);
const [currentEditCheck, setCurrentEditCheck] = useState<PostureCheck>();
return isLoading ? (
<TabsContent
value={"posture_checks"}
className={"px-8 pb-8 mt-3 gap-2 flex flex-col"}
>
<Skeleton width={"100%"} height={41} />
<Skeleton width={"100%"} height={42} />
<Skeleton width={"100%"} height={42} />
<Skeleton width={"100%"} height={41} />
</TabsContent>
) : (
<TabsContent value={"posture_checks"} className={"px-8 pb-8 mt-3"}>
{checkModal && (
<PostureCheckModal
open={checkModal}
onOpenChange={setCheckModal}
onSuccess={(check) => addPostureChecks([check])}
postureCheck={currentEditCheck}
/>
)}
{browseModal && (
<PostureCheckBrowseModal
open={browseModal}
onOpenChange={setBrowseModal}
onSuccess={(check) => addPostureChecks(check)}
/>
)}
<div className={"flex flex-col gap-3"}>
<PostureCheckMinimalTable
data={postureChecks}
onEditClick={(check) => {
setCurrentEditCheck(check);
setCheckModal(true);
}}
onAddClick={() => {
setCurrentEditCheck(undefined);
setCheckModal(true);
}}
onBrowseClick={() => {
setCurrentEditCheck(undefined);
setBrowseModal(true);
}}
onRemoveClick={removePostureCheck}
/>
</div>
</TabsContent>
);
};

View File

@@ -0,0 +1,12 @@
import { TabsTrigger } from "@components/Tabs";
import { ShieldCheck } from "lucide-react";
import * as React from "react";
export const PostureCheckTabTrigger = () => {
return (
<TabsTrigger value={"posture_checks"}>
<ShieldCheck size={16} />
Posture Checks
</TabsTrigger>
);
};

View File

@@ -69,4 +69,9 @@ export const validator = {
); // validate fragment locator
return urlPattern.test(urlString);
},
isValidVersion: (version: string) => {
const semverRegex =
/^(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$/;
return semverRegex.test(version);
},
};