Compare commits

...

21 Commits

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

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

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

* Add support for separate linux and mac paths
2024-06-12 16:32:10 +02:00
Eduard Gert
5caeab118b UX changes for modals and refactoring (#380) 2024-05-08 14:42:04 +02:00
Eduard Gert
3f943bb7d4 Use next/font/local instead of next/font/google (#376)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2024-04-19 17:12:56 +02:00
Eduard Gert
96b939e6cc Add changes from cloud repo to public one (#377)
* Remove unused files

* Update activity descriptions

* Update SelectDropdown

* Update redirect logic for / page

* Update HelpText.tsx

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

* Update icon

* Add better validation for routes

* Add better validation for DNS

* Add better validation for setup keys

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

* Fix spelling

* Hide masquerade for exit nodes

* Add exit node information to peers list

* Change exit node button, add indicator to peers table

* Add steps to route modal

* Add hook to check if peer has exit nodes

* Hide exit node indicator for regular users

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

* Add fallback for group name

* Add fallback for setup key name

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

* Check if auto_groups actually exists for the setup keys

* Add fallback for group names in setup keys table

* Add fallback for group names in peers table
2024-04-11 16:42:27 +02:00
Eduard Gert
6d4716cdad Remove integrations from public repo and sync changes (#369)
Some checks failed
build and push / build_n_push (push) Has been cancelled
* Change icon size

* Remove integrations

* Add no cache header

* Add analytics event tracking

* Add small announcement improvements

* Remove peer approval setting

* Do not load countries when user has no permission

* Add tab query params to settings

* Decrease navigation font size

* Change order of providers

* Increase padding for modals

* Show page only when user is fully loaded and found

* Remove unused state

* Remove integrations page
2024-04-02 14:06:38 +02:00
amplitudes
859916b1df fix: user deletion notification (#367) 2024-04-02 12:26:45 +02:00
Eduard Gert
80ce7d21b0 Fix issue where the first users cache is not populated (#366) 2024-03-28 11:27:00 +01:00
Eduard Gert
06fdbd8ec4 Hide profile settings and announcements for blocked dashboard view (#365) 2024-03-28 10:25:21 +01:00
Eduard Gert
973cceff79 Add setting to change dashboard view for regular users (#362) 2024-03-27 16:09:58 +01:00
Eduard Gert
f4a2d6fae8 Add Okta SCIM integration (#361)
* Add Okta integration (wip)

* Update okta setup dialog

* Add okta integration images

* Add error handling for 500 status codes

* Add okta integration

* Fix lint warnings

* Update azures last sync time

* Remove 'on' from step, disable copy for HTTP Header

* Update text for custom IDP
2024-03-27 15:55:56 +01:00
Eduard Gert
cb922b46b7 Add 'Offline' filter to peers table (#364) 2024-03-26 20:03:24 +01:00
Eduard Gert
4c56ae704c Show peers for regular users but hide / disable actions (delete, enable ssh etc.) (#360)
* Show peers for regular users but hide / disable actions (delete, enable ssh etc.)

* Do not load countries for regular users
2024-03-21 14:21:26 +01:00
145 changed files with 2984 additions and 4103 deletions

View File

@@ -1,4 +1,3 @@
# simple server configuration to replace nginx's default
server {
listen 80 default_server;
listen [::]:80 default_server;
@@ -7,10 +6,14 @@ server {
location / {
try_files $uri $uri.html $uri/ =404;
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0";
expires off;
}
error_page 404 /404.html;
location = /404.html {
internal;
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0";
expires off;
}
}

View File

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

View File

@@ -53,7 +53,7 @@
"framer-motion": "^10.16.4",
"ip-cidr": "^3.1.0",
"lodash": "^4.17.21",
"lucide-react": "^0.287.0",
"lucide-react": "^0.383.0",
"next": "13.5.5",
"next-themes": "^0.2.1",
"punycode": "^2.3.1",

View File

@@ -5,14 +5,12 @@ import InlineLink from "@components/InlineLink";
import Paragraph from "@components/Paragraph";
import { RestrictedAccess } from "@components/ui/RestrictedAccess";
import useFetchApi from "@utils/api";
import { isLocalDev, isNetBirdHosted } from "@utils/netbird";
import { ExternalLinkIcon } from "lucide-react";
import React from "react";
import ActivityIcon from "@/assets/icons/ActivityIcon";
import { ActivityEvent } from "@/interfaces/ActivityEvent";
import PageContainer from "@/layouts/PageContainer";
import ActivityTable from "@/modules/activity/ActivityTable";
import { EventStreamingCard } from "@/modules/integrations/event-streaming/EventStreamingCard";
export default function Activity() {
const { data: events, isLoading } = useFetchApi<ActivityEvent[]>("/events");
@@ -50,7 +48,6 @@ export default function Activity() {
</Paragraph>
</div>
<RestrictedAccess page={"Activity"}>
{(isLocalDev() || isNetBirdHosted()) && <EventStreamingCard />}
<ActivityTable events={events} isLoading={isLoading} />
</RestrictedAccess>
</PageContainer>

View File

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

View File

@@ -1,39 +0,0 @@
"use client";
import { RestrictedAccess } from "@components/ui/RestrictedAccess";
import { VerticalTabs } from "@components/VerticalTabs";
import { FileText, FingerprintIcon } from "lucide-react";
import { useSearchParams } from "next/navigation";
import React, { useState } from "react";
import PageContainer from "@/layouts/PageContainer";
import EventStreamingTab from "@/modules/integrations/event-streaming/EventStreamingTab";
import IdentityProviderTab from "@/modules/integrations/idp-sync/IdentityProviderTab";
export default function Integrations() {
const searchParams = useSearchParams();
const currentTab = searchParams.get("tab");
const [tab, setTab] = useState(currentTab || "event-streaming");
return (
<PageContainer>
<VerticalTabs value={tab} onChange={setTab}>
<VerticalTabs.List>
<VerticalTabs.Trigger value="event-streaming">
<FileText size={14} />
Event Streaming
</VerticalTabs.Trigger>
<VerticalTabs.Trigger value="identity-provider">
<FingerprintIcon size={14} />
Identity Provider
</VerticalTabs.Trigger>
</VerticalTabs.List>
<RestrictedAccess page={"Integrations"}>
<div className={"border-l border-nb-gray-930 w-full"}>
<EventStreamingTab />
<IdentityProviderTab />
</div>
</RestrictedAccess>
</VerticalTabs>
</PageContainer>
);
}

View File

@@ -23,6 +23,7 @@ import Separator from "@components/Separator";
import FullScreenLoading from "@components/ui/FullScreenLoading";
import LoginExpiredBadge from "@components/ui/LoginExpiredBadge";
import TextWithTooltip from "@components/ui/TextWithTooltip";
import useRedirect from "@hooks/useRedirect";
import { IconCloudLock, IconInfoCircle } from "@tabler/icons-react";
import useFetchApi from "@utils/api";
import dayjs from "dayjs";
@@ -32,6 +33,7 @@ import {
FlagIcon,
Globe,
History,
LockIcon,
MapPin,
MonitorSmartphoneIcon,
NetworkIcon,
@@ -50,11 +52,14 @@ import PeerIcon from "@/assets/icons/PeerIcon";
import { useCountries } from "@/contexts/CountryProvider";
import PeerProvider, { usePeer } from "@/contexts/PeerProvider";
import RoutesProvider from "@/contexts/RoutesProvider";
import { useLoggedInUser } from "@/contexts/UsersProvider";
import { useHasChanges } from "@/hooks/useHasChanges";
import { getOperatingSystem } from "@/hooks/useOperatingSystem";
import { OperatingSystem } from "@/interfaces/OperatingSystem";
import type { Peer } from "@/interfaces/Peer";
import PageContainer from "@/layouts/PageContainer";
import { AddExitNodeButton } from "@/modules/exit-node/AddExitNodeButton";
import { useHasExitNodes } from "@/modules/exit-node/useHasExitNodes";
import useGroupHelper from "@/modules/groups/useGroupHelper";
import AddRouteDropdownButton from "@/modules/peer/AddRouteDropdownButton";
import PeerRoutesTable from "@/modules/peer/PeerRoutesTable";
@@ -62,8 +67,11 @@ import PeerRoutesTable from "@/modules/peer/PeerRoutesTable";
export default function PeerPage() {
const queryParameter = useSearchParams();
const peerId = queryParameter.get("id");
const { data: peer } = useFetchApi<Peer>("/peers/" + peerId);
return peer ? (
const { data: peer, isLoading } = useFetchApi<Peer>("/peers/" + peerId, true);
useRedirect("/peers", false, !peerId);
return peer && !isLoading ? (
<PeerProvider peer={peer}>
<PeerOverview />
</PeerProvider>
@@ -124,6 +132,9 @@ function PeerOverview() {
});
};
const { isUser } = useLoggedInUser();
const hasExitNodes = useHasExitNodes(peer);
return (
<PageContainer>
<RoutesProvider>
@@ -148,29 +159,31 @@ function PeerOverview() {
/>
<TextWithTooltip text={name} maxChars={30} />
<Modal
open={showEditNameModal}
onOpenChange={setShowEditNameModal}
>
<ModalTrigger>
<div
className={
"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 cursor-pointer"
}
>
<PencilIcon size={16} />
</div>
</ModalTrigger>
<EditNameModal
onSuccess={(newName) => {
setName(newName);
setShowEditNameModal(false);
}}
peer={peer}
initialName={name}
key={showEditNameModal ? 1 : 0}
/>
</Modal>
{!isUser && (
<Modal
open={showEditNameModal}
onOpenChange={setShowEditNameModal}
>
<ModalTrigger>
<div
className={
"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 cursor-pointer"
}
>
<PencilIcon size={16} />
</div>
</ModalTrigger>
<EditNameModal
onSuccess={(newName) => {
setName(newName);
setShowEditNameModal(false);
}}
peer={peer}
initialName={name}
key={showEditNameModal ? 1 : 0}
/>
</Modal>
)}
</h1>
<LoginExpiredBadge loginExpired={peer.login_expired} />
</div>
@@ -192,7 +205,7 @@ function PeerOverview() {
variant={"primary"}
className={"w-full"}
onClick={() => updatePeer()}
disabled={!hasChanges}
disabled={!hasChanges || isUser}
>
Save Changes
</Button>
@@ -210,18 +223,32 @@ function PeerOverview() {
"flex gap-2 items-center !text-nb-gray-300 text-xs"
}
>
<IconInfoCircle size={14} />
<span>
Login expiration is disabled for all peers added with an
setup-key.
</span>
{!peer.user_id ? (
<>
<>
<IconInfoCircle size={14} />
<span>
Login expiration is disabled for all peers added
with an setup-key.
</span>
</>
</>
) : (
<>
<LockIcon size={14} />
<span>
{`You don't have the required permissions to update this
setting.`}
</span>
</>
)}
</div>
}
className={"w-full block"}
disabled={!!peer.user_id}
disabled={!!peer.user_id && !isUser}
>
<FancyToggleSwitch
disabled={!peer.user_id}
disabled={!peer.user_id || isUser}
value={loginExpiration}
onChange={setLoginExpiration}
label={
@@ -235,33 +262,74 @@ function PeerOverview() {
}
/>
</FullTooltip>
<FancyToggleSwitch
value={ssh}
onChange={(set) =>
!set
? setSsh(false)
: openSSHDialog().then((confirm) => setSsh(confirm))
<FullTooltip
content={
<div
className={
"flex gap-2 items-center !text-nb-gray-300 text-xs"
}
>
<LockIcon size={14} />
<span>
{`You don't have the required permissions to update this
setting.`}
</span>
</div>
}
label={
<>
<TerminalSquare size={16} />
SSH Access
</>
}
helpText={
"Enable the SSH server on this peer to access the machine via an secure shell."
}
/>
interactive={false}
className={"w-full block"}
disabled={!isUser}
>
<FancyToggleSwitch
value={ssh}
disabled={isUser}
onChange={(set) =>
!set
? setSsh(false)
: openSSHDialog().then((confirm) => setSsh(confirm))
}
label={
<>
<TerminalSquare size={16} />
SSH Access
</>
}
helpText={
"Enable the SSH server on this peer to access the machine via an secure shell."
}
/>
</FullTooltip>
<div>
<Label>Assigned Groups</Label>
<HelpText>
Use groups to control what this peer can access.
</HelpText>
<PeerGroupSelector
onChange={setSelectedGroups}
values={selectedGroups}
peer={peer}
/>
<FullTooltip
content={
<div
className={
"flex gap-2 items-center !text-nb-gray-300 text-xs"
}
>
<LockIcon size={14} />
<span>
{`You don't have the required permissions to update this
setting.`}
</span>
</div>
}
interactive={false}
className={"w-full block"}
disabled={!isUser}
>
<PeerGroupSelector
disabled={isUser}
onChange={setSelectedGroups}
values={selectedGroups}
peer={peer}
/>
</FullTooltip>
</div>
</div>
</div>
@@ -269,7 +337,7 @@ function PeerOverview() {
<Separator />
{isLinux ? (
{isLinux && !isUser ? (
<div className={"px-8 py-6"}>
<div className={"max-w-6xl"}>
<div className={"flex justify-between items-center"}>
@@ -281,7 +349,8 @@ function PeerOverview() {
</Paragraph>
</div>
<div className={"inline-flex gap-4 justify-end"}>
<div>
<div className={"gap-4 flex"}>
<AddExitNodeButton peer={peer} firstTime={!hasExitNodes} />
<AddRouteDropdownButton />
</div>
</div>

View File

@@ -17,11 +17,15 @@ import { SetupModalContent } from "@/modules/setup-netbird-modal/SetupModal";
const PeersTable = lazy(() => import("@/modules/peers/PeersTable"));
export default function Peers() {
const { isUser } = useLoggedInUser();
const { permission } = useLoggedInUser();
return (
<PageContainer>
{isUser ? <PeersDefaultView /> : <PeersView />}
{permission?.dashboard_view === "blocked" ? (
<PeersBlockedView />
) : (
<PeersView />
)}
</PageContainer>
);
}
@@ -78,11 +82,11 @@ function PeersView() {
);
}
function PeersDefaultView() {
function PeersBlockedView() {
return (
<div className={"flex items-center justify-center flex-col"}>
<div className={"p-default py-6 max-w-3xl text-center"}>
<h1>Add new peer to your network</h1>
<h1>Add new device to your network</h1>
<Paragraph className={"inline"}>
To get started, install NetBird and log in using your email account.
After that you should be connected. If you have further questions

View File

@@ -2,20 +2,35 @@
import { RestrictedAccess } from "@components/ui/RestrictedAccess";
import { VerticalTabs } from "@components/VerticalTabs";
import { AlertOctagonIcon, FolderGit2Icon, ShieldIcon } from "lucide-react";
import React, { useState } from "react";
import {
AlertOctagonIcon,
FolderGit2Icon,
LockIcon,
ShieldIcon,
} from "lucide-react";
import { useSearchParams } from "next/navigation";
import React, { useEffect, useState } from "react";
import { useLoggedInUser } from "@/contexts/UsersProvider";
import PageContainer from "@/layouts/PageContainer";
import { useAccount } from "@/modules/account/useAccount";
import AuthenticationTab from "@/modules/settings/AuthenticationTab";
import DangerZoneTab from "@/modules/settings/DangerZoneTab";
import GroupsTab from "@/modules/settings/GroupsTab";
import PermissionsTab from "@/modules/settings/PermissionsTab";
export default function NetBirdSettings() {
const [tab, setTab] = useState("authentication");
const queryParams = useSearchParams();
const queryTab = queryParams.get("tab");
const [tab, setTab] = useState(queryTab || "authentication");
const { isOwner } = useLoggedInUser();
const account = useAccount();
useEffect(() => {
if (queryTab) {
setTab(queryTab);
}
}, [queryTab]);
return (
<PageContainer>
<VerticalTabs value={tab} onChange={setTab}>
@@ -28,6 +43,10 @@ export default function NetBirdSettings() {
<FolderGit2Icon size={14} />
Groups
</VerticalTabs.Trigger>
<VerticalTabs.Trigger value="permissions">
<LockIcon size={14} />
Permissions
</VerticalTabs.Trigger>
<VerticalTabs.Trigger value="danger-zone" disabled={!isOwner}>
<AlertOctagonIcon size={14} />
Danger zone
@@ -36,6 +55,7 @@ export default function NetBirdSettings() {
<RestrictedAccess page={"Settings"}>
<div className={"border-l border-nb-gray-930 w-full"}>
{account && <AuthenticationTab account={account} />}
{account && <PermissionsTab account={account} />}
{account && <GroupsTab account={account} />}
{account && <DangerZoneTab account={account} />}
</div>

View File

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

View File

@@ -10,6 +10,7 @@ import Paragraph from "@components/Paragraph";
import { PeerGroupSelector } from "@components/PeerGroupSelector";
import Separator from "@components/Separator";
import FullScreenLoading from "@components/ui/FullScreenLoading";
import useRedirect from "@hooks/useRedirect";
import { IconCirclePlus, IconSettings2 } from "@tabler/icons-react";
import useFetchApi, { useApiCall } from "@utils/api";
import { generateColorFromString } from "@utils/helpers";
@@ -42,6 +43,8 @@ export default function UserPage() {
return users?.find((u) => u.id === userId);
}, [users, userId]);
useRedirect("/team/users", false, !userId);
return !isLoading && user ? (
<UserOverview user={user} />
) : (

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

Before

Width:  |  Height:  |  Size: 493 B

View File

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

Before

Width:  |  Height:  |  Size: 888 B

View File

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

Before

Width:  |  Height:  |  Size: 166 B

View File

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

Before

Width:  |  Height:  |  Size: 741 B

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

Binary file not shown.

View File

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

View File

@@ -5,7 +5,7 @@ export type IconProps = {
};
export const defaultIconProps: IconProps = {
size: 16,
size: 15,
className:
"dark:fill-nb-gray-400 fill-gray-500 peer-data-[active=true]/icon:dark:fill-white peer-data-[active=true]/icon:fill-gray-900 shrink-0",
autoHeight: false,

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,5 @@
import { CommandItem } from "@components/Command";
import FullTooltip from "@components/FullTooltip";
import { Popover, PopoverContent, PopoverTrigger } from "@components/Popover";
import { IconArrowBack } from "@tabler/icons-react";
import useFetchApi from "@utils/api";
@@ -62,8 +63,13 @@ export function NetworkRouteSelector({
const isSearching = search.length > 0;
const found =
dropdownOptions.filter((item) => {
const hasDomains = item?.domains ? item.domains.length > 0 : false;
const domains =
hasDomains && item?.domains ? item?.domains.join(" ") : "";
return (
item.network_id.includes(search) || item.network.includes(search)
item.network_id.includes(search) ||
item.network?.includes(search) ||
domains.includes(search)
);
}).length > 0;
return isSearching && !found;
@@ -117,6 +123,7 @@ export function NetworkRouteSelector({
>
{value.network}
</div>
<DomainList domains={value?.domains} />
</div>
) : (
<span>Select an existing network...</span>
@@ -208,7 +215,11 @@ export function NetworkRouteSelector({
return (
<CommandItem
key={option.network + option.network_id}
value={option.network + option.network_id}
value={
option.network +
option.network_id +
option?.domains?.join(", ")
}
onSelect={() => {
togglePeer(option);
setOpen(false);
@@ -226,6 +237,7 @@ export function NetworkRouteSelector({
>
{option.network}
</div>
<DomainList domains={option?.domains} />
</CommandItem>
);
})}
@@ -238,3 +250,19 @@ export function NetworkRouteSelector({
</Popover>
);
}
function DomainList({ domains }: { domains?: string[] }) {
const firstDomain = domains ? domains[0] : "";
return (
domains &&
domains.length > 0 && (
<FullTooltip
content={<div className={"text-xs max-w-sm"}>{domains.join(", ")}</div>}
>
<div className={"text-xs text-nb-gray-300"}>
{firstDomain} {domains.length > 1 && "+" + (domains.length - 1)}
</div>
</FullTooltip>
)
);
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -60,7 +60,7 @@ export default function SidebarItem({
<li className={"px-4 cursor-pointer"}>
<button
className={classNames(
"rounded-lg text-base w-full ",
"rounded-lg text-[.95rem] w-full ",
"font-normal ",
className,
isChild ? "pl-7 pr-2 py-2 mt-1 mb-0.5" : "py-2 px-3",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,70 @@
import Badge from "@components/Badge";
import FullTooltip from "@components/FullTooltip";
import { GlobeIcon } from "lucide-react";
import * as React from "react";
type Props = {
domains: string[];
};
export const DomainListBadge = ({ domains }: Props) => {
const firstDomain = domains.length > 0 ? domains[0] : undefined;
return (
<DomainsTooltip domains={domains}>
<div className={"inline-flex items-center gap-2"}>
{firstDomain && (
<Badge variant={"gray"}>
<GlobeIcon size={10} />
{firstDomain}
</Badge>
)}
{domains && domains.length > 1 && (
<Badge variant={"gray"}>+ {domains.length - 1}</Badge>
)}
</div>
</DomainsTooltip>
);
};
export const DomainsTooltip = ({
domains,
children,
className,
}: {
domains: string[];
children: React.ReactNode;
className?: string;
}) => {
return (
<FullTooltip
interactive={false}
className={className}
content={
<div className={"flex flex-col gap-2 items-start"}>
{domains.map((domain) => {
return (
domain && (
<div
key={domain}
className={"flex gap-2 items-center justify-between w-full"}
>
<div
className={
"flex gap-2 items-center text-nb-gray-300 text-xs"
}
>
<GlobeIcon size={11} />
{domain}
</div>
</div>
)
);
})}
</div>
}
disabled={domains.length <= 1}
>
{children}
</FullTooltip>
);
};

View File

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

View File

@@ -0,0 +1,88 @@
import Button from "@components/Button";
import { Input } from "@components/Input";
import { validator } from "@utils/helpers";
import { uniqueId } from "lodash";
import { GlobeIcon, MinusCircleIcon } from "lucide-react";
import * as React from "react";
import { useEffect, useMemo, useState } from "react";
import { Domain } from "@/interfaces/Domain";
type Props = {
value: Domain;
onChange: (d: Domain) => void;
onRemove: () => void;
onError?: (error: boolean) => void;
error?: string;
};
enum ActionType {
ADD = "ADD",
REMOVE = "REMOVE",
UPDATE = "UPDATE",
}
export const domainReducer = (state: Domain[], action: any): Domain[] => {
switch (action.type) {
case ActionType.ADD:
return [...state, { name: "", id: uniqueId("domain") }];
case ActionType.REMOVE:
return state.filter((_, i) => i !== action.index);
case ActionType.UPDATE:
return state.map((n, i) => (i === action.index ? action.d : n));
default:
return state;
}
};
export default function InputDomain({
value,
onChange,
onRemove,
onError,
}: Readonly<Props>) {
const [name, setName] = useState(value?.name || "");
const handleNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setName(e.target.value);
onChange({ ...value, name: e.target.value });
};
const domainError = useMemo(() => {
if (name == "") {
return "";
}
const valid = validator.isValidDomain(name);
if (!valid) {
return "Please enter a valid domain, e.g. example.com or intra.example.com";
}
}, [name]);
useEffect(() => {
const hasError = domainError !== "" && domainError !== undefined;
onError?.(hasError);
return () => onError?.(false);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [domainError]);
return (
<div className={"flex gap-2 w-full"}>
<div className={"w-full"}>
<Input
customPrefix={<GlobeIcon size={15} />}
placeholder={"e.g., example.com"}
maxWidthClass={"w-full"}
value={name}
error={domainError}
onChange={handleNameChange}
/>
</div>
<Button
className={"h-[42px]"}
variant={"default-outline"}
onClick={onRemove}
>
<MinusCircleIcon size={15} />
</Button>
</div>
);
}

View File

@@ -5,7 +5,9 @@ import * as React from "react";
type Props = {
data: {
label: string;
value: string;
value: string | React.ReactNode;
noCopy?: boolean;
tooltip?: boolean;
}[];
className?: string;
};
@@ -16,10 +18,11 @@ export const MinimalList = ({ data, className }: Props) => {
{data.map((item, index) => {
return (
<Card.ListItem
copy
copy={!item.noCopy}
label={item.label}
value={item.value}
key={index}
tooltip={item.tooltip !== false}
/>
);
})}

View File

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

View File

@@ -36,6 +36,7 @@ export default function UserDropdown() {
useHotkeys("shift+mod+l", () => logout(), []);
const [dropdownOpen, setDropdownOpen] = useState(false);
const { permission } = useLoggedInUser();
return (
<DropdownMenu
@@ -67,19 +68,23 @@ export default function UserDropdown() {
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => {
setDropdownOpen(false);
if (loggedInUser) {
router.push(`/team/user?id=${loggedInUser.id}`);
}
}}
>
<div className={"flex gap-3 items-center"}>
<User2 size={14} />
Profile Settings
</div>
</DropdownMenuItem>
{permission.dashboard_view !== "blocked" && (
<DropdownMenuItem
onClick={() => {
setDropdownOpen(false);
if (loggedInUser) {
router.push(`/team/user?id=${loggedInUser.id}`);
}
}}
>
<div className={"flex gap-3 items-center"}>
<User2 size={14} />
Profile Settings
</div>
</DropdownMenuItem>
)}
<DropdownMenuItem onClick={logoutSession}>
<div className={"flex gap-3 items-center"}>
<LogOutIcon size={14} />

View File

@@ -19,6 +19,7 @@ const AnalyticsContext = React.createContext(
{} as {
initialized: boolean;
trackPageView: () => void;
trackEvent: (category: string, action: string, label: string) => void;
},
);
const config = loadConfig();
@@ -51,8 +52,20 @@ export default function AnalyticsProvider({ children }: Props) {
ReactGA.send({ hitType: "pageview", page: path, title: document.title });
};
const trackEvent = (category: string, action: string, label: string) => {
if (isProduction() && ReactGA.isInitialized) {
ReactGA.event({
category: category,
action: action,
label: label,
});
}
};
return (
<AnalyticsContext.Provider value={{ initialized, trackPageView }}>
<AnalyticsContext.Provider
value={{ initialized, trackPageView, trackEvent }}
>
{children}
</AnalyticsContext.Provider>
);

View File

@@ -2,6 +2,7 @@ import { AnnouncementVariant } from "@components/ui/AnnouncementBanner";
import { useLocalStorage } from "@hooks/useLocalStorage";
import md5 from "crypto-js/md5";
import React, { useEffect, useState } from "react";
import { useLoggedInUser } from "@/contexts/UsersProvider";
const initialAnnouncements: Announcement[] = [];
@@ -12,6 +13,7 @@ export interface Announcement extends AnnouncementVariant {
linkText?: string;
isExternal?: boolean;
closeable: boolean;
isCloudOnly: boolean;
}
interface AnnouncementInfo extends Announcement {
@@ -28,6 +30,9 @@ const AnnouncementContext = React.createContext(
bannerHeight: number;
announcements?: AnnouncementInfo[];
closeAnnouncement: (hash: string) => void;
setAnnouncements: React.Dispatch<
React.SetStateAction<AnnouncementInfo[] | undefined>
>;
},
);
@@ -39,8 +44,11 @@ export default function AnnouncementProvider({ children }: Props) {
string[]
>("netbird-closed-announcements", []);
const [announcements, setAnnouncements] = useState<AnnouncementInfo[]>();
const { permission } = useLoggedInUser();
useEffect(() => {
if (announcements && announcements.length > 0) return;
if (permission?.dashboard_view === "blocked") return;
const initial = initialAnnouncements.map((announcement) => {
const hash = md5(announcement.text).toString();
const isOpen = !closedAnnouncements.some((h) => h === hash);
@@ -48,12 +56,12 @@ export default function AnnouncementProvider({ children }: Props) {
...announcement,
hash,
isOpen,
};
} as AnnouncementInfo;
});
if (initial.length > 0) {
setAnnouncements(initial);
}
}, [closedAnnouncements]);
}, [closedAnnouncements, announcements]);
const closeAnnouncement = (hash: string) => {
setClosedAnnouncements([...closedAnnouncements, hash]);
@@ -78,7 +86,12 @@ export default function AnnouncementProvider({ children }: Props) {
return (
<AnnouncementContext.Provider
value={{ bannerHeight: height, announcements, closeAnnouncement }}
value={{
bannerHeight: height,
announcements,
closeAnnouncement,
setAnnouncements,
}}
>
{children}
</AnnouncementContext.Provider>

View File

@@ -3,7 +3,14 @@ import FullScreenLoading from "@components/ui/FullScreenLoading";
import { useApiCall } from "@utils/api";
import { useIsMd } from "@utils/responsive";
import { getLatestNetbirdRelease } from "@utils/version";
import React, { useContext, useEffect, useMemo, useRef, useState } from "react";
import React, {
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { useLocalStorage } from "@/hooks/useLocalStorage";
import { User } from "@/interfaces/User";
import type { NetbirdRelease } from "@/interfaces/Version";
@@ -32,13 +39,27 @@ export default function ApplicationProvider({ children }: Props) {
const userRequest = useApiCall<User[]>("/users", true);
const [show, setShow] = useState(false);
const requestCalled = useRef(false);
const maxTries = 3;
const populateCache = useCallback(
async (tries = 0) => {
if (tries >= maxTries) {
setShow(true);
return Promise.reject();
}
try {
await userRequest.get().then(() => setShow(true));
return Promise.resolve();
} catch (e) {
setTimeout(() => populateCache(tries + 1), 500);
}
},
[userRequest, setShow],
);
useEffect(() => {
if (!requestCalled.current) {
userRequest
.get()
.then(() => setShow(true))
.catch(() => setShow(true));
populateCache().then();
requestCalled.current = true;
}
// eslint-disable-next-line react-hooks/exhaustive-deps

View File

@@ -17,10 +17,16 @@ const CountryContext = React.createContext(
);
export default function CountryProvider({ children }: Props) {
const { isUser } = useLoggedInUser();
const { permission } = useLoggedInUser();
return isUser ? (
children
const getRegionByPeer = (peer: Peer) => "Unknown";
return permission?.dashboard_view != "full" ? (
<CountryContext.Provider
value={{ countries: [], isLoading: false, getRegionByPeer }}
>
{children}
</CountryContext.Provider>
) : (
<CountryProviderContent>{children}</CountryProviderContent>
);
@@ -29,7 +35,7 @@ export default function CountryProvider({ children }: Props) {
function CountryProviderContent({ children }: Props) {
const { data: countries, isLoading } = useFetchApi<Country[]>(
"/locations/countries",
false,
true,
false,
);

View File

@@ -81,16 +81,20 @@ export default function DialogProvider({ children }: Props) {
/>
{dialogOptions.children && (
<div className={"px-8 pt-4"}>{dialogOptions.children}</div>
<div className={"px-8 pt-0"}>{dialogOptions.children}</div>
)}
<ModalFooter className={"items-center gap-2"} separator={false}>
<ModalFooter
className={"items-center gap-2 pt-5"}
separator={false}
>
<ModalClose asChild={true}>
<Button
variant={"secondary"}
className={"w-full"}
size={"sm"}
tabIndex={-1}
data-cy={"confirmation.cancel"}
onClick={() => fn.current && fn.current(false)}
>
{dialogOptions.cancelText || "Cancel"}
@@ -106,6 +110,7 @@ export default function DialogProvider({ children }: Props) {
}
className={"w-full"}
size={"sm"}
data-cy={"confirmation.confirm"}
onClick={() => fn.current && fn.current(true)}
>
{dialogOptions.confirmText || "Confirm"}

View File

@@ -20,10 +20,10 @@ const GroupContext = React.createContext(
export default function GroupsProvider({ children }: Props) {
const path = usePathname();
const { isUser } = useLoggedInUser();
const { permission } = useLoggedInUser();
return isUser && path == "/peers" ? (
children
return path === "/peers" && permission.dashboard_view == "blocked" ? (
<>{children}</>
) : (
<GroupsProviderContent>{children}</GroupsProviderContent>
);

View File

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

View File

@@ -25,7 +25,7 @@ const RoutesContext = React.createContext(
);
export default function RoutesProvider({ children }: Props) {
const routeRequest = useApiCall<Route>("/routes");
const routeRequest = useApiCall<Route>("/routes", true);
const { mutate } = useSWRConfig();
const updateRoute = async (
@@ -34,6 +34,8 @@ export default function RoutesProvider({ children }: Props) {
onSuccess?: (route: Route) => void,
message?: string,
) => {
const hasDomains = route.domains ? route.domains.length > 0 : false;
notify({
title: "Network " + route.network_id + "-" + route.network,
description: message
@@ -48,7 +50,9 @@ export default function RoutesProvider({ children }: Props) {
peer: toUpdate.peer ?? (route.peer || undefined),
peer_groups:
toUpdate.peer_groups ?? (route.peer_groups || undefined),
network: route.network,
network: !hasDomains ? route.network : undefined,
domains: hasDomains ? route.domains : undefined,
keep_route: route.keep_route,
metric: toUpdate.metric ?? route.metric ?? 9999,
masquerade: toUpdate.masquerade ?? route.masquerade ?? true,
groups: toUpdate.groups ?? route.groups ?? [],
@@ -80,7 +84,9 @@ export default function RoutesProvider({ children }: Props) {
enabled: route.enabled,
peer: route.peer || undefined,
peer_groups: route.peer_groups || undefined,
network: route.network,
network: route?.network || undefined,
domains: route?.domains || undefined,
keep_route: route?.keep_route || false,
metric: route.metric || 9999,
masquerade: route.masquerade,
groups: route.groups || [],

View File

@@ -1,6 +1,7 @@
import FullScreenLoading from "@components/ui/FullScreenLoading";
import useFetchApi from "@utils/api";
import React, { useMemo } from "react";
import { Permission } from "@/interfaces/Permission";
import { User } from "@/interfaces/User";
type Props = {
@@ -26,7 +27,7 @@ export default function UsersProvider({ children }: Props) {
return users?.find((user) => user.is_current);
}, [users]);
return !isLoading ? (
return !isLoading && loggedInUser ? (
<UsersContext.Provider value={{ users, refresh, loggedInUser }}>
{children}
</UsersContext.Provider>
@@ -43,5 +44,19 @@ export const useLoggedInUser = () => {
const isAdmin = loggedInUser ? loggedInUser?.role === "admin" : false;
const isUser = !isOwner && !isAdmin;
const isOwnerOrAdmin = isOwner || isAdmin;
return { loggedInUser, isOwner, isAdmin, isUser, isOwnerOrAdmin } as const;
const permission = useMemo(() => {
return {
dashboard_view: loggedInUser?.permissions.dashboard_view || "blocked",
} as Permission;
}, [loggedInUser]);
return {
loggedInUser,
isOwner,
isAdmin,
isUser,
isOwnerOrAdmin,
permission,
} as const;
};

View File

@@ -19,6 +19,8 @@ export const getOperatingSystem = (os: string) => {
if (os.toLowerCase().includes("android"))
return OperatingSystem.ANDROID as const;
if (os.toLowerCase().includes("ios")) return OperatingSystem.IOS as const;
if (os.toLowerCase().includes("ipad")) return OperatingSystem.IOS as const;
if (os.toLowerCase().includes("iphone")) return OperatingSystem.IOS as const;
if (os.toLowerCase().includes("windows"))
return OperatingSystem.WINDOWS as const;
return OperatingSystem.LINUX as const;

View File

@@ -1,8 +1,9 @@
import loadConfig from "@utils/config";
import { usePathname, useRouter } from "next/navigation";
import { useEffect } from "react";
import { useEffect, useRef } from "react";
const config = loadConfig();
export const useRedirect = (
url: string,
replace: boolean = false,
@@ -10,24 +11,43 @@ export const useRedirect = (
) => {
const router = useRouter();
const currentPath = usePathname();
const callBackUrls = [config.redirectURI, config.silentRedirectURI];
const callBackUrls = useRef([config.redirectURI, config.silentRedirectURI]);
const isRedirecting = useRef(false);
const intervalRef = useRef<NodeJS.Timeout | null>(null);
useEffect(() => {
if (!enable) return;
if (callBackUrls.includes(url)) return; // Don't redirect to the callback urls to avoid infinite loop
if (url === currentPath) return; // Don't redirect to the current page
// If redirect is disabled or the url is already in the callback urls or the url is the current path then do not redirect
if (!enable || callBackUrls.current.includes(url) || url === currentPath)
return;
const redirect = replace ? router.replace : router.push; // Replace the current history or add a new one
const performRedirect = () => {
if (!isRedirecting.current) {
isRedirecting.current = true;
router.refresh();
if (replace) {
router.replace(url);
} else {
router.push(url);
}
isRedirecting.current = false;
}
};
router.refresh();
redirect(url);
performRedirect();
// Timer in case the user has his browser tab open but not focused
const interval = setInterval(() => {
router.refresh();
redirect(url);
}, 1000);
// Try to redirect after 1.25 seconds if for whatever reason the redirect did not happen (network change, browser tab open but not focused etc.)
intervalRef.current = setInterval(() => {
if (!isRedirecting.current) {
performRedirect();
}
}, 1250);
return () => clearInterval(interval);
}, [replace, router, url, enable]);
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
}
};
}, [replace, router, url, enable, currentPath]);
};
export default useRedirect;

View File

@@ -10,5 +10,6 @@ export interface Account {
jwt_groups_enabled: boolean;
jwt_groups_claim_name: string;
jwt_allow_groups: string[];
regular_users_view_blocked: boolean;
};
}

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

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

View File

@@ -17,6 +17,14 @@ export interface AzureADIntegration {
user_group_prefixes: string[];
}
export interface OktaIntegration {
id: string;
enabled: boolean;
group_prefixes: string[];
user_group_prefixes: string[];
auth_token: string;
}
export interface IdentityProviderLog {
id: number;
level: string;

View File

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

View File

@@ -0,0 +1,3 @@
export interface Permission {
dashboard_view: "limited" | "full" | "blocked";
}

View File

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

View File

@@ -3,26 +3,34 @@ export interface Route {
description: string;
enabled: boolean;
peer?: string;
network: string;
network?: string;
domains?: string[];
network_id: string;
network_type?: string;
metric?: number;
masquerade: boolean;
groups: string[];
keep_route?: boolean;
// Frontend only
peer_groups?: string[];
routesGroups?: string[];
groupedRoutes?: GroupedRoute[];
group_names?: string[];
domain_search?: string;
}
export interface GroupedRoute {
id: string;
enabled: boolean;
network: string;
network?: string;
domains?: string[];
keep_route?: boolean;
network_id: string;
high_availability_count: number;
is_using_route_groups: boolean;
routes?: Route[];
group_names?: string[];
description?: string;
description_search?: string;
domain_search?: string;
}

View File

@@ -1,3 +1,5 @@
import { Permission } from "@/interfaces/Permission";
export interface User {
id: string;
email?: string;
@@ -9,6 +11,7 @@ export interface User {
is_service_user?: boolean;
is_blocked?: boolean;
last_login?: Date;
permissions: Permission;
}
export enum Role {

View File

@@ -6,18 +6,20 @@ import { cn } from "@utils/helpers";
import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime";
import { Viewport } from "next/dist/lib/metadata/types/extra-types";
import { Inter } from "next/font/google";
import localFont from "next/font/local";
import React from "react";
import { Toaster } from "react-hot-toast";
import OIDCProvider from "@/auth/OIDCProvider";
import AnalyticsProvider from "@/contexts/AnalyticsProvider";
import AnnouncementProvider from "@/contexts/AnnouncementProvider";
import DialogProvider from "@/contexts/DialogProvider";
import ErrorBoundaryProvider from "@/contexts/ErrorBoundary";
import { GlobalThemeProvider } from "@/contexts/GlobalThemeProvider";
import { NavigationEvents } from "@/contexts/NavigationEvents";
const inter = Inter({ subsets: ["latin"] });
const inter = localFont({
src: "../assets/fonts/Inter.ttf",
display: "swap",
});
// Extend dayjs with relativeTime plugin
dayjs.extend(relativeTime);
@@ -36,11 +38,9 @@ export default function AppLayout({ children }: { children: React.ReactNode }) {
<GlobalThemeProvider>
<ErrorBoundaryProvider>
<OIDCProvider>
<AnnouncementProvider>
<TooltipProvider delayDuration={0}>
{children}
</TooltipProvider>
</AnnouncementProvider>
<TooltipProvider delayDuration={0}>
{children}
</TooltipProvider>
</OIDCProvider>
</ErrorBoundaryProvider>
</GlobalThemeProvider>

View File

@@ -9,7 +9,9 @@ import { useIsSm, useIsXs } from "@utils/responsive";
import { AnimatePresence, motion } from "framer-motion";
import { XIcon } from "lucide-react";
import React from "react";
import { useAnnouncement } from "@/contexts/AnnouncementProvider";
import AnnouncementProvider, {
useAnnouncement,
} from "@/contexts/AnnouncementProvider";
import ApplicationProvider, {
useApplicationContext,
} from "@/contexts/ApplicationProvider";
@@ -27,11 +29,13 @@ export default function DashboardLayout({
return (
<ApplicationProvider>
<UsersProvider>
<GroupsProvider>
<CountryProvider>
<DashboardPageContent>{children}</DashboardPageContent>
</CountryProvider>
</GroupsProvider>
<AnnouncementProvider>
<GroupsProvider>
<CountryProvider>
<DashboardPageContent>{children}</DashboardPageContent>
</CountryProvider>
</GroupsProvider>
</AnnouncementProvider>
</UsersProvider>
</ApplicationProvider>
);
@@ -42,7 +46,7 @@ function DashboardPageContent({ children }: { children: React.ReactNode }) {
const { mobileNavOpen, toggleMobileNav } = useApplicationContext();
const isSm = useIsSm();
const isXs = useIsXs();
const { isUser } = useLoggedInUser();
const { permission } = useLoggedInUser();
const navOpenPageWidth = isSm ? "50%" : isXs ? "65%" : "80%";
const { bannerHeight } = useAnnouncement();
@@ -154,7 +158,9 @@ function DashboardPageContent({ children }: { children: React.ReactNode }) {
height: `calc(100vh - ${headerHeight + bannerHeight}px)`,
}}
>
{!isUser && <Navigation hideOnMobile />}
{permission.dashboard_view !== "blocked" && (
<Navigation hideOnMobile />
)}
{children}
</div>
</motion.div>

View File

@@ -40,7 +40,7 @@ export default function NavbarWithDropdown() {
const { toggleMobileNav } = useApplicationContext();
const { bannerHeight } = useAnnouncement();
const { isUser } = useLoggedInUser();
const { permission } = useLoggedInUser();
return (
<>
@@ -62,7 +62,8 @@ export default function NavbarWithDropdown() {
<Button
className={cn(
"!px-3 md:hidden",
isUser && "opacity-0 pointer-events-none",
permission.dashboard_view == "blocked" &&
"opacity-0 pointer-events-none",
)}
variant={"default-outline"}
onClick={toggleMobileNav}

View File

@@ -2,21 +2,21 @@
import { ScrollArea } from "@components/ScrollArea";
import { cn } from "@utils/helpers";
import { isLocalDev, isNetBirdHosted } from "@utils/netbird";
import { CustomFlowbiteTheme, Sidebar } from "flowbite-react";
import { SidebarItemGroupProps } from "flowbite-react/lib/esm/components/Sidebar/SidebarItemGroup";
import AccessControlIcon from "@/assets/icons/AccessControlIcon";
import ActivityIcon from "@/assets/icons/ActivityIcon";
import DNSIcon from "@/assets/icons/DNSIcon";
import DocsIcon from "@/assets/icons/DocsIcon";
import IntegrationIcon from "@/assets/icons/IntegrationIcon";
import NetworkRoutesIcon from "@/assets/icons/NetworkRoutesIcon";
import PeerIcon from "@/assets/icons/PeerIcon";
import SettingsIcon from "@/assets/icons/SettingsIcon";
import SetupKeysIcon from "@/assets/icons/SetupKeysIcon";
import TeamIcon from "@/assets/icons/TeamIcon";
import SidebarItem from "@/components/SidebarItem";
import { useAnnouncement } from "@/contexts/AnnouncementProvider";
import { useLoggedInUser } from "@/contexts/UsersProvider";
import { headerHeight } from "@/layouts/Header";
const customTheme: CustomFlowbiteTheme["sidebar"] = {
root: {
@@ -34,6 +34,7 @@ export default function Navigation({
hideOnMobile = false,
}: Props) {
const { isUser } = useLoggedInUser();
const { bannerHeight } = useAnnouncement();
return (
<Sidebar
@@ -42,123 +43,133 @@ export default function Navigation({
hideOnMobile ? "hidden md:block" : "",
fullWidth
? "w-auto max-w-[22rem]"
: "w-[15rem] min-w-[15rem] overflow-y-auto",
: "w-[15rem] max-w-[15rem] min-w-[15rem] overflow-y-auto",
)}
theme={customTheme}
style={{
height: fullWidth ? "calc(100vh - 75px)" : "100%",
height: fullWidth
? `calc(100vh - ${headerHeight + bannerHeight}px)`
: "100%",
}}
>
<Sidebar.Items className={cn(fullWidth ? "w-10/12" : "fixed")}>
<Sidebar.Items className={cn(fullWidth ? "w-10/12" : "fixed h-full")}>
<ScrollArea
style={{
height: !fullWidth ? "calc(100vh - 75px)" : "100%",
height: !fullWidth
? `calc(100vh - ${headerHeight + bannerHeight}px)`
: "100%",
}}
className={"pt-4"}
>
<SidebarItemGroup>
<SidebarItem icon={<PeerIcon />} label="Peers" href={"/peers"} />
{!isUser && (
<>
<div
className={
"flex flex-col justify-between pt-4 w-[15rem] max-w-[15rem] min-w-[15rem]"
}
style={{
height: !fullWidth
? `calc(100vh - ${headerHeight + bannerHeight}px)`
: "100%",
}}
>
<div>
<SidebarItemGroup>
<SidebarItem
icon={<SetupKeysIcon />}
label="Setup Keys"
href={"/setup-keys"}
icon={<PeerIcon />}
label="Peers"
href={"/peers"}
/>
<SidebarItem
icon={<AccessControlIcon />}
label="Access Control"
collapsible
>
{!isUser && (
<>
<SidebarItem
icon={<SetupKeysIcon />}
label="Setup Keys"
href={"/setup-keys"}
/>
<SidebarItem
icon={<AccessControlIcon />}
label="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"
href={"/network-routes"}
/>
<SidebarItem
icon={<DNSIcon />}
label="DNS"
collapsible
exactPathMatch={true}
>
<SidebarItem
label="Nameservers"
isChild
href={"/dns/nameservers"}
/>
<SidebarItem
label="DNS Settings"
isChild
href={"/dns/settings"}
/>
</SidebarItem>
<SidebarItem icon={<TeamIcon />} label="Team" collapsible>
<SidebarItem label="Users" isChild href={"/team/users"} />
<SidebarItem
label="Service Users"
isChild
href={"/team/service-users"}
/>
</SidebarItem>
<SidebarItem
icon={<ActivityIcon />}
label="Activity"
href={"/activity"}
/>
</>
)}
{isUser && (
<SidebarItem
label="Policies"
href={"/access-control"}
isChild
icon={<DocsIcon />}
href={"https://docs.netbird.io/"}
target={"_blank"}
label="Documentation"
/>
)}
</SidebarItemGroup>
{!isUser && (
<SidebarItemGroup>
<SidebarItem
icon={<SettingsIcon />}
label="Settings"
href={"/settings"}
exactPathMatch={true}
/>
<SidebarItem
label="Posture Checks"
isChild
href={"/posture-checks"}
exactPathMatch={true}
/>
</SidebarItem>
<SidebarItem
icon={<NetworkRoutesIcon />}
label="Network Routes"
href={"/network-routes"}
/>
<SidebarItem
icon={<DNSIcon />}
label="DNS"
collapsible
exactPathMatch={true}
>
<SidebarItem
label="Nameservers"
isChild
href={"/dns/nameservers"}
icon={<DocsIcon />}
href={"https://docs.netbird.io/"}
target={"_blank"}
label="Documentation"
/>
<SidebarItem
label="DNS Settings"
isChild
href={"/dns/settings"}
/>
</SidebarItem>
<SidebarItem icon={<TeamIcon />} label="Team" collapsible>
<SidebarItem label="Users" isChild href={"/team/users"} />
<SidebarItem
label="Service Users"
isChild
href={"/team/service-users"}
/>
</SidebarItem>
<SidebarItem
icon={<ActivityIcon />}
label="Activity"
href={"/activity"}
/>
</>
)}
{isUser && (
<SidebarItem
icon={<DocsIcon />}
href={"https://docs.netbird.io/"}
target={"_blank"}
label="Documentation"
/>
)}
</SidebarItemGroup>
{!isUser && (
<SidebarItemGroup>
<SidebarItem
icon={<SettingsIcon />}
label="Settings"
href={"/settings"}
exactPathMatch={true}
/>
{(isLocalDev() || isNetBirdHosted()) && (
<SidebarItem
icon={<IntegrationIcon />}
label="Integrations"
href={"/integrations"}
exactPathMatch={true}
/>
</SidebarItemGroup>
)}
<SidebarItem
icon={<DocsIcon />}
href={"https://docs.netbird.io/"}
target={"_blank"}
label="Documentation"
/>
</SidebarItemGroup>
)}
</div>
</div>
</ScrollArea>
</Sidebar.Items>
</Sidebar>
@@ -167,7 +178,10 @@ export default function Navigation({
export function SidebarItemGroup(props: SidebarItemGroupProps) {
return (
<Sidebar.ItemGroup className={"dark:border-zinc-700/40"} {...props}>
<Sidebar.ItemGroup
className={"dark:border-zinc-700/40 space-y-1.5"}
{...props}
>
{props.children}
</Sidebar.ItemGroup>
);

View File

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

View File

@@ -3,7 +3,11 @@ import { Label } from "@components/Label";
import { IconInfoCircle } from "@tabler/icons-react";
import { cn } from "@utils/helpers";
import { isLocalDev, isProduction } from "@utils/netbird";
import { isEmpty } from "lodash";
import { GlobeIcon } from "lucide-react";
import React, { useMemo } from "react";
import RoundedFlag from "@/assets/countries/RoundedFlag";
import { useCountries } from "@/contexts/CountryProvider";
import { ActivityEvent } from "@/interfaces/ActivityEvent";
type Props = {
@@ -54,7 +58,8 @@ export default function ActivityDescription({ event }: Props) {
if (event.activity_code == "setupkey.peer.add")
return (
<div className={"inline"}>
Peer <Value>{m.name}</Value> with ip <Value>{m.ip}</Value> was added
Peer <Value>{m.name}</Value> <PeerConnectionInfo meta={m} /> was added
with the NetBird IP <Value>{m.ip}</Value>
</div>
);
@@ -113,29 +118,38 @@ export default function ActivityDescription({ event }: Props) {
* Route
*/
if (event.activity_code == "route.delete")
if (event.activity_code == "route.delete") {
let hasDomains = m?.domains && m?.domains.length > 0;
return (
<div className={"inline"}>
Route <Value>{m.name}</Value> with the <Value>{m.network_range}</Value>{" "}
range was deleted
Route <Value>{m.name}</Value> with the {hasDomains ? "domain(s)" : ""}{" "}
<Value>{hasDomains ? m?.domains : m.network_range}</Value>{" "}
{hasDomains ? "" : "range"} was deleted
</div>
);
}
if (event.activity_code == "route.update")
if (event.activity_code == "route.update") {
let hasDomains = m?.domains && m?.domains.length > 0;
return (
<div className={"inline"}>
Route <Value>{m.name}</Value> with the <Value>{m.network_range}</Value>{" "}
range was updated
Route <Value>{m.name}</Value> with the {hasDomains ? "domain(s)" : ""}{" "}
<Value>{hasDomains ? m?.domains : m.network_range}</Value>{" "}
{hasDomains ? "" : "range"} was updated
</div>
);
}
if (event.activity_code == "route.add")
if (event.activity_code == "route.add") {
let hasDomains = m?.domains && m?.domains.length > 0;
return (
<div className={"inline"}>
Route <Value>{m.name}</Value> with the <Value>{m.network_range}</Value>{" "}
range was created
Route <Value>{m.name}</Value> with the {hasDomains ? "domain(s)" : ""}{" "}
<Value>{hasDomains ? m?.domains : m.network_range}</Value>{" "}
{hasDomains ? "" : "range"} was created
</div>
);
}
/**
* User
@@ -144,21 +158,24 @@ export default function ActivityDescription({ event }: Props) {
if (event.activity_code == "user.peer.delete")
return (
<div className={"inline"}>
Peer <Value>{m.name}</Value> with ip <Value>{m.ip}</Value> was deleted
Peer <Value>{m.name}</Value> <PeerConnectionInfo meta={m} /> with
NetBird IP <Value>{m.ip}</Value> was deleted
</div>
);
if (event.activity_code == "user.peer.add")
return (
<div className={"inline"}>
Peer <Value>{m.name}</Value> with ip <Value>{m.ip}</Value> was added
Peer <Value>{m.name}</Value> <PeerConnectionInfo meta={m} /> was added
with the NetBird IP <Value>{m.ip}</Value>
</div>
);
if (event.activity_code == "user.peer.update")
return (
<div className={"inline"}>
Peer <Value>{m.name}</Value> with ip <Value>{m.ip}</Value> was updated
Peer <Value>{m.name}</Value> <PeerConnectionInfo meta={m} /> with
NetBird IP <Value>{m.ip}</Value> was updated
</div>
);
@@ -252,15 +269,15 @@ export default function ActivityDescription({ event }: Props) {
if (event.activity_code == "peer.group.delete")
return (
<div className={"inline"}>
Group <Value>{m.group}</Value> was removed from the peer with the ip{" "}
<Value>{m.peer_ip}</Value>
Group <Value>{m.group}</Value> was removed from the peer with the
NetBird IP <Value>{m.peer_ip}</Value>
</div>
);
if (event.activity_code == "peer.group.add")
return (
<div className={"inline"}>
Group <Value>{m.group}</Value> was added to the peer with the ip{" "}
Group <Value>{m.group}</Value> was added to the peer with the NetBird IP{" "}
<Value>{m.peer_ip}</Value>
</div>
);
@@ -303,7 +320,7 @@ export default function ActivityDescription({ event }: Props) {
if (event.activity_code == "peer.rename")
return (
<div className={"inline"}>
Peer with the ip <Value>{m.ip}</Value> was renamed to{" "}
Peer with the NetBird IP <Value>{m.ip}</Value> was renamed to{" "}
<Value>{m.name}</Value>
</div>
);
@@ -311,7 +328,7 @@ export default function ActivityDescription({ event }: Props) {
if (event.activity_code == "peer.approve")
return (
<div className={"inline"}>
Peer with the ip <Value>{m.ip}</Value> was approved
Peer with the NetBird IP <Value>{m.ip}</Value> was approved
</div>
);
@@ -477,15 +494,46 @@ export default function ActivityDescription({ event }: Props) {
</div>
);
// TODO add activity texts
// rule.add
// rule.update
// rule.delete
// setupkey.update
// setupkey.overuse
// group.update
// group.delete
// user.peer.login
if (event.activity_code == "transferred.owner.role")
return <div className={"inline"}>Owner role was transferred</div>;
/**
* EDR
*/
if (event.activity_code == "integrated-validator.api.created")
return (
<div className={"inline"}>
<Value>{m?.platform}</Value> integration created
</div>
);
if (event.activity_code == "integrated-validator.api.updated")
return (
<div className={"inline"}>
<Value>{m?.platform}</Value> integration updated
</div>
);
if (event.activity_code == "integrated-validator.api.deleted")
return (
<div className={"inline"}>
<Value>{m?.platform}</Value> integration deleted
</div>
);
if (event.activity_code == "integrated-validator.host-check.approved")
return (
<div className={"inline"}>
Peer approved by <Value>{m?.platform}</Value> integration
</div>
);
if (event.activity_code == "integrated-validator.host-check.denied")
return (
<div className={"inline"}>
Peer rejected by <Value>{m?.platform}</Value> integration
</div>
);
return (
<div className={"flex gap-2.5 items-center"}>
@@ -528,7 +576,7 @@ function Value({
return children ? (
<span
className={cn(
"text-nb-gray-200 inline font-medium bg-nb-gray-900 py-[3px] text-[11px] px-[5px] border border-nb-gray-800 rounded-[4px]",
"text-nb-gray-200 inline-flex gap-1 items-center max-h-[22px] font-medium bg-nb-gray-900 py-[3px] text-[11px] px-[5px] border border-nb-gray-800 rounded-[4px]",
className,
)}
>
@@ -536,3 +584,40 @@ function Value({
</span>
) : null;
}
function PeerConnectionInfo({ meta }: { meta: any }) {
const hasMeta =
!isEmpty(meta?.location_country_code) ||
!isEmpty(meta?.location_connection_ip);
const { countries } = useCountries();
const countryText = useMemo(() => {
if (!countries) return "Unknown";
const country = countries.find(
(c) => c.country_code === meta?.location_country_code,
);
if (!country) return "Unknown";
if (!meta?.location_city_name) return country.country_name;
return `${country.country_name}, ${meta?.location_city_name}`;
}, [countries, meta]);
return hasMeta ? (
<>
{" "}
from{" "}
{meta?.location_connection_ip && (
<Value>{meta?.location_connection_ip}</Value>
)}{" "}
{meta?.location_country_code && (
<Value>
{isEmpty(meta?.location_country_code) ? (
<GlobeIcon size={9} className={"text-nb-gray-300"} />
) : (
<RoundedFlag country={meta?.location_country_code} size={9} />
)}
{countryText}
</Value>
)}
</>
) : null;
}

View File

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

View File

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

View File

@@ -1,3 +1,4 @@
import Badge from "@components/Badge";
import Button from "@components/Button";
import {
Modal,
@@ -10,10 +11,12 @@ import ModalHeader from "@components/modal/ModalHeader";
import { PeerGroupSelector } from "@components/PeerGroupSelector";
import Separator from "@components/Separator";
import MultipleGroups from "@components/ui/MultipleGroups";
import { IconCirclePlus } from "@tabler/icons-react";
import { FolderGit2 } from "lucide-react";
import * as React from "react";
import { useMemo } from "react";
import { useGroups } from "@/contexts/GroupsProvider";
import { useLoggedInUser } from "@/contexts/UsersProvider";
import { Group } from "@/interfaces/Group";
import { Peer } from "@/interfaces/Peer";
import useGroupHelper from "@/modules/groups/useGroupHelper";
@@ -26,6 +29,7 @@ type Props = {
label?: string;
description?: string;
peer?: Peer;
showAddGroupButton?: boolean;
};
export default function GroupsRow({
@@ -36,8 +40,10 @@ export default function GroupsRow({
label = "Assigned Groups",
description = "Use groups to control what this peer can access",
peer,
showAddGroupButton = false,
}: Props) {
const { groups: allGroups } = useGroups();
const { isUser } = useLoggedInUser();
// Get the group by the id
const foundGroups = useMemo(() => {
@@ -54,10 +60,17 @@ export default function GroupsRow({
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setModal && setModal(true);
setModal && !isUser && setModal(true);
}}
>
<MultipleGroups groups={foundGroups} label={label} />
{foundGroups?.length == 0 && showAddGroupButton ? (
<Badge variant={"gray"} useHover={true}>
<IconCirclePlus size={14} />
Add Groups
</Badge>
) : (
<MultipleGroups groups={foundGroups} label={label} />
)}
</ModalTrigger>
<EditGroupsModal
groups={foundGroups}

View File

@@ -17,8 +17,9 @@ import Paragraph from "@components/Paragraph";
import { PeerGroupSelector } from "@components/PeerGroupSelector";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@components/Tabs";
import { Textarea } from "@components/Textarea";
import InputDomain, { domainReducer } from "@components/ui/InputDomain";
import { useApiCall } from "@utils/api";
import { cn, validator } from "@utils/helpers";
import { cn } from "@utils/helpers";
import cidr from "ip-cidr";
import { uniqueId } from "lodash";
import {
@@ -35,7 +36,7 @@ import {
import React, { useEffect, useMemo, useReducer, useState } from "react";
import { useSWRConfig } from "swr";
import DNSIcon from "@/assets/icons/DNSIcon";
import { Domain, Nameserver, NameserverGroup } from "@/interfaces/Nameserver";
import { Nameserver, NameserverGroup } from "@/interfaces/Nameserver";
import useGroupHelper from "@/modules/groups/useGroupHelper";
type Props = {
@@ -97,25 +98,12 @@ enum ActionType {
UPDATE = "UPDATE",
}
export const domainReducer = (state: Domain[], action: any) => {
switch (action.type) {
case ActionType.ADD:
return [...state, { name: "", id: uniqueId("ns") }];
case ActionType.REMOVE:
return state.filter((_, i) => i !== action.index);
case ActionType.UPDATE:
return state.map((n, i) => (i === action.index ? action.d : n));
default:
return state;
}
};
export function NameserverModalContent({
onSuccess,
preset,
cell,
}: ModalProps) {
const nsRequest = useApiCall<NameserverGroup>("/dns/nameservers");
const nsRequest = useApiCall<NameserverGroup>("/dns/nameservers", true);
const { mutate } = useSWRConfig();
const isUpdate = useMemo(() => {
@@ -199,7 +187,7 @@ export function NameserverModalContent({
// Domains
const [domains, setDomains] = useReducer(domainReducer, [], () => {
if (preset?.domains?.length) {
return preset.domains.map((d) => ({ name: d, id: uniqueId("ns") }));
return preset.domains.map((d) => ({ name: d, id: uniqueId("domain") }));
}
return [];
});
@@ -233,25 +221,27 @@ export function NameserverModalContent({
return domains.some((d) => d.name === "");
}, [domains]);
const hasAnyError = useMemo(() => {
return (
const nameLengthError = useMemo(() => {
if (name.length > 40) return "Name should be less than 40 characters";
return "";
}, [name]);
const canContinueToDomains = useMemo(() => {
return !(
hasNSErrors ||
nsError ||
domainError ||
name == "" ||
nameservers.length == 0 ||
hasDomainErrors ||
groups.length == 0
);
}, [
nsError,
domainError,
name,
nameservers,
groups,
hasNSErrors,
hasDomainErrors,
]);
}, [hasNSErrors, nsError, nameservers.length, groups.length]);
const canContinueToGeneral = useMemo(() => {
return !(!canContinueToDomains || domainError || hasDomainErrors);
}, [canContinueToDomains, domainError, hasDomainErrors]);
const canSubmit = useMemo(() => {
return !(!canContinueToGeneral || nameLengthError !== "" || name == "");
}, [canContinueToGeneral, nameLengthError, name]);
return (
<ModalContent maxWidthClass={"max-w-xl"}>
@@ -262,7 +252,7 @@ export function NameserverModalContent({
color={"netbird"}
/>
<Tabs defaultValue={tab} onValueChange={(v) => setTab(v)}>
<Tabs defaultValue={tab} onValueChange={(v) => setTab(v)} value={tab}>
<TabsList justify={"start"} className={"px-8"}>
<TabsTrigger value={"nameserver"}>
<ServerIcon
@@ -273,7 +263,7 @@ export function NameserverModalContent({
/>
Nameserver
</TabsTrigger>
<TabsTrigger value={"domains"}>
<TabsTrigger value={"domains"} disabled={!canContinueToDomains}>
<GlobeIcon
size={16}
className={
@@ -282,7 +272,7 @@ export function NameserverModalContent({
/>
Domains
</TabsTrigger>
<TabsTrigger value={"general"}>
<TabsTrigger value={"general"} disabled={!canContinueToGeneral}>
<Text
size={16}
className={
@@ -368,7 +358,7 @@ export function NameserverModalContent({
<div className={"flex flex-col gap-2 w-full"}>
{domains.map((domain, i) => {
return (
<DomainInput
<InputDomain
key={domain.id}
value={domain}
onChange={(d) =>
@@ -427,6 +417,7 @@ export function NameserverModalContent({
<Input
autoFocus={true}
tabIndex={0}
error={nameLengthError}
placeholder={"e.g., Public DNS"}
value={name}
onChange={(e) => setName(e.target.value)}
@@ -465,20 +456,77 @@ export function NameserverModalContent({
</Paragraph>
</div>
<div className={"flex gap-3 w-full justify-end"}>
<ModalClose asChild={true}>
<Button variant={"secondary"}>Cancel</Button>
</ModalClose>
{!isUpdate ? (
<>
{tab == "nameserver" && (
<ModalClose asChild={true}>
<Button variant={"secondary"}>Cancel</Button>
</ModalClose>
)}
<Button variant={"primary"} disabled={hasAnyError} onClick={submit}>
{isUpdate ? (
<>Save Changes</>
) : (
<>
<PlusCircle size={16} />
Add Nameserver
</>
)}
</Button>
{tab == "domains" && (
<Button
variant={"secondary"}
onClick={() => setTab("nameserver")}
>
Back
</Button>
)}
{tab == "nameserver" && (
<Button
variant={"primary"}
onClick={() => setTab("domains")}
disabled={!canContinueToDomains}
>
Continue
</Button>
)}
{tab == "domains" && (
<Button
variant={"primary"}
onClick={() => setTab("general")}
disabled={!canContinueToGeneral}
>
Continue
</Button>
)}
{tab == "general" && (
<>
<Button
variant={"secondary"}
onClick={() => setTab("domains")}
>
Back
</Button>
<Button
variant={"primary"}
disabled={!canSubmit}
onClick={submit}
>
<PlusCircle size={16} />
Add Nameserver
</Button>
</>
)}
</>
) : (
<>
<ModalClose asChild={true}>
<Button variant={"secondary"}>Cancel</Button>
</ModalClose>
<Button
variant={"primary"}
disabled={!canSubmit}
onClick={submit}
>
Save Changes
</Button>
</>
)}
</div>
</ModalFooter>
</ModalContent>
@@ -516,7 +564,7 @@ function NameserverInput({
const validCIDR = cidr.isValidAddress(ip);
if (!validCIDR) {
onError && onError(true);
return "Please enter a valid CIDR, e.g., 192.168.1.0/24";
return "Please enter a valid IP, e.g., 192.168.1.0";
}
onError && onError(false);
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -532,7 +580,7 @@ function NameserverInput({
<div className={"w-full"}>
<Input
customPrefix={"IP"}
placeholder={"e.g., 172.16.0.0/16"}
placeholder={"e.g., 172.16.0.0"}
maxWidthClass={"w-full"}
value={ip}
className={"font-mono !text-[13px]"}
@@ -559,63 +607,3 @@ function NameserverInput({
</div>
);
}
function DomainInput({
value,
onChange,
onRemove,
onError,
}: {
value: Domain;
onChange: (d: Domain) => void;
onRemove: () => void;
onError?: (error: boolean) => void;
error?: string;
}) {
const [name, setName] = useState(value.name);
const handleNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setName(e.target.value);
onChange({ ...value, name: e.target.value });
};
const domainError = useMemo(() => {
if (name == "") {
return "";
}
const valid = validator.isValidDomain(name);
if (!valid) {
onError && onError(true);
return "Please enter a valid domain, e.g. example.com or intra.example.com";
}
onError && onError(false);
}, [name, onError]);
useEffect(() => {
return () => onError && onError(false);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<div className={"flex gap-2 w-full"}>
<div className={"w-full"}>
<Input
customPrefix={<GlobeIcon size={15} />}
placeholder={"e.g., example.com"}
maxWidthClass={"w-full"}
value={name}
error={domainError}
onChange={handleNameChange}
/>
</div>
<Button
className={"h-[42px]"}
variant={"default-outline"}
onClick={onRemove}
>
<MinusCircleIcon size={15} />
</Button>
</div>
);
}

View File

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

View File

@@ -0,0 +1,59 @@
import { DropdownMenuItem } from "@components/DropdownMenu";
import { Modal } from "@components/modal/Modal";
import { getOperatingSystem } from "@hooks/useOperatingSystem";
import { IconCirclePlus, IconDirectionSign } from "@tabler/icons-react";
import * as React from "react";
import { useState } from "react";
import RoutesProvider from "@/contexts/RoutesProvider";
import { OperatingSystem } from "@/interfaces/OperatingSystem";
import { Peer } from "@/interfaces/Peer";
import { useHasExitNodes } from "@/modules/exit-node/useHasExitNodes";
import { RouteModalContent } from "@/modules/routes/RouteModal";
type Props = {
peer: Peer;
};
export const ExitNodeDropdownButton = ({ peer }: Props) => {
const [modal, setModal] = useState(false);
const isLinux = getOperatingSystem(peer.os) === OperatingSystem.LINUX;
const hasExitNodes = useHasExitNodes(peer);
return isLinux ? (
<>
<DropdownMenuItem onClick={() => setModal(true)}>
<div className={"flex gap-3 items-center w-full"}>
{hasExitNodes ? (
<>
<IconCirclePlus size={14} className={"shrink-0"} />
<div className={"flex justify-between items-center w-full"}>
Add Exit Node
</div>
</>
) : (
<>
<IconDirectionSign
size={14}
className={"shrink-0 text-yellow-400"}
/>
<div className={"flex justify-between items-center w-full"}>
Set Up Exit Node
</div>
</>
)}
</div>
</DropdownMenuItem>
<Modal open={modal} onOpenChange={setModal}>
{modal && (
<RoutesProvider>
<RouteModalContent
onSuccess={() => setModal(false)}
peer={peer}
exitNode={true}
/>
</RoutesProvider>
)}
</Modal>
</>
) : null;
};

View File

@@ -0,0 +1,47 @@
import FullTooltip from "@components/FullTooltip";
import InlineLink from "@components/InlineLink";
import { ExternalLinkIcon } from "lucide-react";
import * as React from "react";
type Props = {
children: React.ReactNode;
hoverButton?: boolean;
};
export const ExitNodeHelpTooltip = ({
children,
hoverButton = false,
}: Props) => {
return (
<div
onClick={(e) => {
e.stopPropagation();
}}
>
<FullTooltip
hoverButton={hoverButton}
content={
<div className={"text-xs max-w-xs"}>
An exit node is a network route that routes all your internet
traffic through one of your peers.
<div className={"mt-2"}>
Learn more about{" "}
<InlineLink
href={
"https://docs.netbird.io/how-to/configuring-default-routes-for-internet-traffic"
}
target={"_blank"}
className={"mr-1"}
>
Exit Nodes
<ExternalLinkIcon size={10} />
</InlineLink>
in our documentation.
</div>
</div>
}
>
{children}
</FullTooltip>
</div>
);
};

View File

@@ -0,0 +1,25 @@
import FullTooltip from "@components/FullTooltip";
import { IconDirectionSign } from "@tabler/icons-react";
import * as React from "react";
import { Peer } from "@/interfaces/Peer";
import { useHasExitNodes } from "@/modules/exit-node/useHasExitNodes";
type Props = {
peer: Peer;
};
export const ExitNodePeerIndicator = ({ peer }: Props) => {
const hasExitNode = useHasExitNodes(peer);
return hasExitNode ? (
<FullTooltip
content={
<div className={"text-xs max-w-xs"}>
This peer is an exit node. Traffic from the configured distribution
groups will be routed through this peer.
</div>
}
>
<IconDirectionSign size={15} className={"text-yellow-400 shrink-0"} />
</FullTooltip>
) : null;
};

View File

@@ -0,0 +1,19 @@
import useFetchApi from "@utils/api";
import { useLoggedInUser } from "@/contexts/UsersProvider";
import { Peer } from "@/interfaces/Peer";
import { Route } from "@/interfaces/Route";
export const useHasExitNodes = (peer?: Peer) => {
const { isOwnerOrAdmin } = useLoggedInUser();
const { data: routes } = useFetchApi<Route[]>(
`/routes`,
false,
true,
isOwnerOrAdmin,
);
return peer
? routes?.some(
(route) => route?.peer === peer.id && route?.network === "0.0.0.0/0",
) || false
: false;
};

View File

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

View File

@@ -1,104 +0,0 @@
import Button from "@components/Button";
import InlineLink from "@components/InlineLink";
import Paragraph from "@components/Paragraph";
import { ToggleSwitch } from "@components/ToggleSwitch";
import { cn } from "@utils/helpers";
import { ExternalLinkIcon, Repeat } from "lucide-react";
import { StaticImport } from "next/dist/shared/lib/get-img-props";
import Image from "next/image";
import * as React from "react";
type Props<T> = {
image: StaticImport | string;
name: string;
description: string;
url: {
title: string;
href: string;
};
data?: T;
switchState: boolean;
onEnabledChange: (enabled: boolean) => void;
children?: React.ReactNode;
onSetup?: () => void;
disabled?: boolean;
hideSwitch?: boolean;
};
export function IntegrationCard<T>({
image,
name,
description,
url,
data,
switchState,
onEnabledChange,
children,
onSetup,
disabled,
hideSwitch = false,
}: Props<T>) {
return (
<div
className={cn(
" border border-nb-gray-900/50 p-5 rounded-lg transition-all max-w-[360px] flex flex-col justify-between gap-4",
switchState ? "bg-nb-gray-930/50" : "bg-nb-gray-930/30",
disabled && "opacity-60 pointer-events-none",
)}
>
<div className={"flex flex-col gap-4"}>
<div className={"flex justify-between"}>
<div className={"flex gap-4"}>
<div
className={
"h-12 w-12 flex items-center justify-center rounded-md bg-nb-gray-900/70 p-2 border border-nb-gray-900/70"
}
>
<Image src={image} alt={name} className={"rounded-[4px]"} />
</div>
<div>
<h3 className={""}>{name}</h3>
<InlineLink
href={url.href}
target={"_blank"}
className={"text-sm font-light"}
variant={"faded"}
>
{url.title}
<ExternalLinkIcon size={12} />
</InlineLink>
</div>
</div>
{!hideSwitch && (
<div className={"flex items-center"}>
<ToggleSwitch
checked={switchState}
onCheckedChange={onEnabledChange}
className={"grow"}
/>
</div>
)}
</div>
<div>
<Paragraph className={"text-sm font-light"}>{description}</Paragraph>
</div>
</div>
{data == undefined ? (
<div>
<Button
variant={"secondary"}
size={"xs"}
className={"w-full items-center"}
onClick={onSetup}
>
<Repeat size={13} />
Connect {name}
</Button>
</div>
) : (
children
)}
</div>
);
}

View File

@@ -1,56 +0,0 @@
import Paragraph from "@components/Paragraph";
import { cn } from "@utils/helpers";
import { ArrowRightLeft } from "lucide-react";
import { StaticImport } from "next/dist/shared/lib/get-img-props";
import Image from "next/image";
import * as React from "react";
import netBirdLogo from "@/assets/netbird.svg";
type Props = {
image: StaticImport | string;
title: string;
description: string;
};
export const IntegrationModalHeader = ({
image,
title,
description,
}: Props) => {
return (
<>
<div className={"flex justify-center items-center gap-4 mt-5"}>
<div
className={
"h-12 w-12 flex items-center justify-center rounded-md bg-nb-gray-900/70 p-2 border border-nb-gray-900/70"
}
>
<Image
src={netBirdLogo}
alt={"NetBird"}
className={"rounded-[4px]"}
/>
</div>
<div>
<ArrowRightLeft size={24} className={"text-netbird"} />
</div>
<div
className={
"h-12 w-12 flex items-center justify-center rounded-md bg-nb-gray-900/70 p-2 border border-nb-gray-900/70"
}
>
<Image src={image} alt={""} className={"rounded-[4px]"} />
</div>
</div>
<div
className={
"mx-auto text-center flex flex-col items-center justify-center mt-6 z-[1]"
}
>
<h2 className={"text-lg my-0 leading-[1.5 text-center]"}>{title}</h2>
<Paragraph className={cn("text-sm text-center max-w-[450px] px-4")}>
{description}
</Paragraph>
</div>
</>
);
};

View File

@@ -1,70 +0,0 @@
import { IconCircleFilled } from "@tabler/icons-react";
import useFetchApi from "@utils/api";
import { cn } from "@utils/helpers";
import { FileText } from "lucide-react";
import Image from "next/image";
import { useRouter } from "next/navigation";
import * as React from "react";
import datadogLogo from "@/assets/integrations/datadog.png";
import { EventStream } from "@/interfaces/EventStream";
export const EventStreamingCard = () => {
const { data: eventStreamIntegrations } = useFetchApi<EventStream[]>(
"/integrations/event-streaming",
);
const dataDogSettings = eventStreamIntegrations?.find(
(integration) => integration.platform === "datadog",
);
const enabled = dataDogSettings ? dataDogSettings.enabled : false;
const router = useRouter();
return (
<div className={"p-default pb-6"}>
<div
onClick={() => router.push("/integrations")}
className={cn(
"border cursor-pointer border-nb-gray-900/50 bg-nb-gray-900/30 hover:bg-nb-gray-900/50 py-3 pl-3 pr-5 rounded-lg transition-all min-w-[310px] max-w-[400px]",
)}
>
<div className={"inline-flex gap-4 w-full"}>
<div
className={
"h-10 w-10 shrink-0 flex items-center justify-center rounded-md bg-nb-gray-900/70 p-2 border border-nb-gray-900/70"
}
>
{dataDogSettings?.enabled && (
<Image
src={datadogLogo}
alt={"Datadog"}
className={"rounded-[4px]"}
/>
)}
{!dataDogSettings && <FileText size={16} />}
</div>
<div className={""}>
<div className={"flex items-center gap-3 justify-between"}>
<div className={"font-medium text-sm flex gap-2 items-center"}>
Event Streaming
</div>
<div
className={cn(
"text-xs flex gap-2 items-center mb-2 font-medium",
enabled ? "text-green-500" : "text-nb-gray-500",
)}
>
<IconCircleFilled size={8} />
{enabled ? "Enabled" : "Disabled"}
</div>
</div>
<p className={"text-xs font-light !text-nb-gray-300 "}>
Stream your activity events to third-party services.
</p>
</div>
</div>
</div>
</div>
);
};

View File

@@ -1,49 +0,0 @@
import Breadcrumbs from "@components/Breadcrumbs";
import InlineLink from "@components/InlineLink";
import Paragraph from "@components/Paragraph";
import * as Tabs from "@radix-ui/react-tabs";
import { ExternalLinkIcon, FileText } from "lucide-react";
import React from "react";
import IntegrationIcon from "@/assets/icons/IntegrationIcon";
import Datadog from "@/modules/integrations/event-streaming/datadog/Datadog";
export default function EventStreamingTab() {
return (
<Tabs.Content value={"event-streaming"}>
<div className={"p-default py-6"}>
<Breadcrumbs>
<Breadcrumbs.Item
href={"/integrations"}
label={"Integrations"}
icon={<IntegrationIcon size={13} />}
/>
<Breadcrumbs.Item
href={"/integrations"}
label={"Event Streaming"}
icon={<FileText size={14} />}
active
/>
</Breadcrumbs>
<h1>Event Streaming</h1>
<Paragraph>
Event Streaming allows you to stream NetBirds activity events to
different third-party services.
</Paragraph>
<Paragraph>
Learn more about{" "}
<InlineLink
href={"https://docs.netbird.io/how-to/activity-event-streaming"}
target={"_blank"}
>
Event Streaming
<ExternalLinkIcon size={12} />
</InlineLink>
in our documentation.
</Paragraph>
<div className={"gap-6 mt-6 flex flex-wrap"}>
<Datadog />
</div>
</div>
</Tabs.Content>
);
}

View File

@@ -1,75 +0,0 @@
import { notify } from "@components/Notification";
import { SkeletonIntegration } from "@components/skeletons/SkeletonIntegration";
import useFetchApi, { useApiCall } from "@utils/api";
import * as React from "react";
import { useState } from "react";
import { useSWRConfig } from "swr";
import integrationImage from "@/assets/integrations/datadog.png";
import { useDialog } from "@/contexts/DialogProvider";
import { EventStream } from "@/interfaces/EventStream";
import DatadogSetup from "@/modules/integrations/event-streaming/datadog/DatadogSetup";
import { IntegrationCard } from "@/modules/integrations/IntegrationCard";
export default function Datadog() {
const { mutate } = useSWRConfig();
const { data: eventStreamIntegrations, isLoading } = useFetchApi<
EventStream[]
>("/integrations/event-streaming");
const dataDogSettings = eventStreamIntegrations?.find(
(integration) => integration.platform === "datadog",
);
const integrationRequest = useApiCall<EventStream>(
"/integrations/event-streaming",
);
const [setupModal, setSetupModal] = useState(false);
const { confirm } = useDialog();
const toggleSwitch = async () => {
if (!dataDogSettings) return setSetupModal(true);
const choice = await confirm({
title: `Disconnect Datadog?`,
description:
"Disconnecting deletes the current configuration. You will need to start the setup process again.",
confirmText: "Disconnect",
cancelText: "Cancel",
type: "warning",
});
if (!choice) return;
notify({
title: "Datadog Integration",
description: `Datadog was successfully disconnected`,
promise: integrationRequest.del({}, "/" + dataDogSettings.id).then(() => {
mutate("/integrations/event-streaming");
}),
loadingMessage: "Disconnecting integration...",
});
};
return isLoading ? (
<>
<SkeletonIntegration />
</>
) : (
<>
<IntegrationCard
name="Datadog"
description="Datadog is a monitoring service for cloud-scale applications."
url={{
title: "datadoghq.com",
href: "https://www.datadoghq.com/",
}}
image={integrationImage}
data={dataDogSettings}
switchState={!dataDogSettings ? false : dataDogSettings.enabled}
onEnabledChange={toggleSwitch}
onSetup={() => setSetupModal(true)}
></IntegrationCard>
<DatadogSetup open={setupModal} onOpenChange={setSetupModal} />
</>
);
}

View File

@@ -1,44 +0,0 @@
import { CountryEURounded } from "@/assets/countries/CountryEURounded";
import { CountryJPRounded } from "@/assets/countries/CountryJPRounded";
import { CountryUSRounded } from "@/assets/countries/CountryUSRounded";
export const DatadogRegions = [
{
name: "Europe (EU)",
site_url: "https://app.datadoghq.eu",
send_logs_url: "https://http-intake.logs.datadoghq.eu/api/v2/logs",
icon: CountryEURounded,
},
{
name: "United States (US1)",
site_url: "https://app.datadoghq.com",
send_logs_url: "https://http-intake.logs.datadoghq.com/api/v2/logs",
icon: CountryUSRounded,
},
{
name: "United States (US3)",
site_url: "https://us3.datadoghq.com",
send_logs_url: "https://http-intake.logs.us3.datadoghq.com/api/v2/logs",
icon: CountryUSRounded,
},
{
name: "United States (US5)",
site_url: "https://us5.datadoghq.com",
send_logs_url: "https://http-intake.logs.us5.datadoghq.com/api/v2/logs",
icon: CountryUSRounded,
},
{
name: "United States (US1-FED)",
site_url: "https://app.ddog-gov.com",
send_logs_url: "https://http-intake.logs.ddog-gov.com/api/v2/logs",
icon: CountryUSRounded,
},
{
name: "Japan (AP1)",
site_url: "https://ap1.datadoghq.com",
send_logs_url: "https://http-intake.logs.ap1.datadoghq.com/api/v2/logs",
icon: CountryJPRounded,
},
] as const;
export const DatadogApiKeysPage = "/organization-settings/api-keys";

View File

@@ -1,277 +0,0 @@
import Button from "@components/Button";
import InlineLink from "@components/InlineLink";
import { Input } from "@components/Input";
import { Modal, ModalContent, ModalFooter } from "@components/modal/Modal";
import { notify } from "@components/Notification";
import {
SelectDropdown,
SelectOption,
} from "@components/select/SelectDropdown";
import Steps from "@components/Steps";
import { GradientFadedBackground } from "@components/ui/GradientFadedBackground";
import { IconArrowLeft, IconArrowRight } from "@tabler/icons-react";
import { useApiCall } from "@utils/api";
import { cn } from "@utils/helpers";
import {
ExternalLinkIcon,
Globe,
GlobeIcon,
KeyRound,
Repeat,
} from "lucide-react";
import Link from "next/link";
import React, { useState } from "react";
import { useSWRConfig } from "swr";
import datadogLogo from "@/assets/integrations/datadog.png";
import { EventStream } from "@/interfaces/EventStream";
import {
DatadogApiKeysPage,
DatadogRegions,
} from "@/modules/integrations/event-streaming/datadog/DatadogRegions";
import { IntegrationModalHeader } from "@/modules/integrations/IntegrationModalHeader";
type Props = {
open: boolean;
onOpenChange: (open: boolean) => void;
onSuccess?: () => void;
};
export default function DatadogSetup({ open, onOpenChange, onSuccess }: Props) {
return (
<>
<Modal open={open} onOpenChange={onOpenChange} key={open ? 1 : 0}>
<SetupContent
onSuccess={() => {
onOpenChange(false);
onSuccess && onSuccess();
}}
/>
</Modal>
</>
);
}
type ModalProps = {
onSuccess: () => void;
};
export function SetupContent({ onSuccess }: ModalProps) {
const { mutate } = useSWRConfig();
const integrationRequest = useApiCall<EventStream>(
"/integrations/event-streaming",
);
const datadogRegions = DatadogRegions.map((region) => {
return {
label: region.name,
value: region.send_logs_url,
icon: region.icon,
} as SelectOption;
});
const [selectedRegion, setSelectedRegion] = useState(datadogRegions[0].value);
const changeRegion = (region: string) => {
setSelectedRegion(region);
setApiUrl(region);
};
const [apiKey, setApiKey] = useState("");
const [apiUrl, setApiUrl] = useState(datadogRegions[0].value);
const [step, setStep] = useState(1);
const apiKeyEntered = apiKey.length > 0 && apiKey != "";
const apiUrlEntered = apiUrl.length > 0 && apiUrl != "";
const apiKeyAndUrlEntered = apiKeyEntered && apiUrlEntered;
const apiPageUrl =
DatadogRegions.find((region) => region.send_logs_url == apiUrl)?.site_url +
DatadogApiKeysPage;
const connect = async () => {
notify({
title: "Datadog Integration",
description: `Datadog was successfully connected to NetBird.`,
promise: integrationRequest
.post({
platform: "datadog",
config: {
api_key: apiKey,
api_url: apiUrl,
},
enabled: true,
})
.then(() => {
mutate("/integrations/event-streaming");
onSuccess();
}),
loadingMessage: "Setting up integration...",
});
};
return (
<ModalContent
maxWidthClass={cn("relative", step === 1 ? "max-w-md" : "max-w-lg")}
showClose={true}
>
<GradientFadedBackground />
<IntegrationModalHeader
image={datadogLogo}
title={"Connect NetBird with Datadog"}
description={
"Start streaming your NetBird activity events to Datadog. Follow the steps below to get started."
}
/>
{step == 1 && (
<div className={"px-8 py-3 flex flex-col mt-4 z-0"}>
<p className={"font-medium flex gap-3 items-center text-base"}>
<GlobeIcon size={16} />
Select your Datadog region
</p>
<p className={"mb-3 mt-2"}>
To identify which region you are on please check out the{" "}
<InlineLink
href={"https://docs.datadoghq.com/getting_started/site/"}
target={"_blank"}
variant={"default"}
className={"inline"}
>
Datadog Documentation.
</InlineLink>
</p>
<SelectDropdown
value={selectedRegion}
onChange={changeRegion}
options={datadogRegions}
/>
<div className={"mt-3 hidden"}>
<Input
type={"text"}
className={"w-full"}
customPrefix={
<div className={"flex items-center gap-2"}>
<Globe size={16} className={"text-nb-gray-300"} />
</div>
}
placeholder={"https://http-intake.logs.datadoghq.eu/api/v2/logs"}
value={apiUrl}
onChange={(e) => setApiUrl(e.target.value)}
/>
</div>
<div className={"mb-3"}></div>
</div>
)}
{step == 2 && (
<div className={"px-8 py-3 flex flex-col gap-0 mt-4 z-0"}>
<p className={"font-medium flex gap-3 items-center text-base"}>
<KeyRound size={16} />
Get your Datadog API Key
</p>
<Steps>
<Steps.Step step={1}>
<p>Navigate to Datadogs API Keys page</p>
<div className={"flex gap-4"}>
<Link href={apiPageUrl} passHref target={"_blank"}>
<Button variant={"primary"} size={"xs"}>
<ExternalLinkIcon size={14} />
API Keys
</Button>
</Link>
</div>
</Steps.Step>
<Steps.Step step={2}>
<p className={"font-normal"}>
Click{" "}
<div
className={
"inline-flex bg-nb-gray-900 py-1.5 px-2.5 rounded-md text-xs items-center mx-0.5"
}
>
+ New Key
</div>{" "}
at the top
</p>
</Steps.Step>
<Steps.Step step={3}>
<p className={"font-normal"}>
Give it a descriptive name like{" "}
<div
className={
"inline-flex bg-nb-gray-900 py-1.5 px-2.5 rounded-md text-xs items-center mx-0.5"
}
>
NetBird Activity Events
</div>
and click{" "}
<div
className={
"inline-flex bg-nb-gray-900 py-1.5 px-2.5 rounded-md text-xs items-center mx-0.5"
}
>
Create Key
</div>
</p>
</Steps.Step>
<Steps.Step step={4} line={false}>
<p className={"font-normal"}>Enter your API-Key</p>
</Steps.Step>
</Steps>
<div className={"mb-4"}>
<Input
type={"text"}
className={"w-full"}
customPrefix={
<div className={"flex items-center gap-2"}>
<KeyRound size={16} className={"text-nb-gray-300"} />
</div>
}
placeholder={"1c17401cf170f7ac33dd9dcdf8040eb2"}
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
/>
</div>
</div>
)}
<ModalFooter className={"items-center gap-4"}>
{step == 1 && (
<Button
variant={"primary"}
className={"w-full"}
disabled={!apiUrlEntered}
onClick={() => setStep(2)}
>
Continue
<IconArrowRight size={16} />
</Button>
)}
{step == 2 && (
<>
<Button
variant={"secondary"}
className={"w-full"}
onClick={() => setStep(1)}
>
<IconArrowLeft size={16} />
Back
</Button>
<Button
variant={"primary"}
className={"w-full"}
disabled={!apiKeyAndUrlEntered}
onClick={connect}
>
<Repeat size={16} />
Connect
</Button>
</>
)}
</ModalFooter>
</ModalContent>
);
}

View File

@@ -1,109 +0,0 @@
import Button from "@components/Button";
import { Input } from "@components/Input";
import { useDebounce } from "@hooks/useDebounce";
import { Folder, MinusCircleIcon, PlusIcon } from "lucide-react";
import * as React from "react";
import { useEffect, useState } from "react";
type GroupPrefixInputProps = {
value: string[];
onChange: (values: string[]) => void;
addText?: string;
icon?: React.ReactNode;
text?: string;
placeholder?: string;
};
export function GroupPrefixInput({
value,
onChange,
addText = "Add group filter",
icon = <Folder size={14} />,
text = "Group starts with...",
placeholder = "e.g., NetBird_",
}: GroupPrefixInputProps) {
const [groupPrefixes, setGroupPrefixes] = useState<string[]>(value);
const prefixes = useDebounce(groupPrefixes, 100);
useEffect(() => {
onChange(prefixes);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [prefixes]);
const onChangeHandler = (
e: React.ChangeEvent<HTMLInputElement>,
index: number,
) => {
const newPrefixes = [...groupPrefixes];
newPrefixes[index] = e.target.value;
setGroupPrefixes(newPrefixes);
};
const onRemoveGroupPrefix = (index: number) => {
setGroupPrefixes((p) => {
const newPrefixes = [...p];
newPrefixes.splice(index, 1);
return newPrefixes;
});
};
const onAddGroupPrefix = () => {
setGroupPrefixes((p) => {
const newPrefixes = [...p];
newPrefixes.push("");
return newPrefixes;
});
};
return (
<div className={"mt-4"}>
{groupPrefixes.length > 0 && (
<div className={"flex gap-3 w-full mb-3"}>
<div className={"flex flex-col gap-2 w-full"}>
{groupPrefixes.map((g, i) => {
return (
<div className={"flex gap-2 w-full"} key={i}>
<div className={"w-full"}>
<Input
customPrefix={
<div className={"flex gap-2 items-center"}>
{icon}
<span>{text}</span>
</div>
}
placeholder={placeholder}
maxWidthClass={"w-full"}
value={g}
className={" !text-[13px]"}
onKeyDown={(event) => {
if (event.code === "Space") event.preventDefault();
}}
onChange={(e) => onChangeHandler(e, i)}
/>
</div>
<Button
className={"h-[42px]"}
variant={"default-outline"}
onClick={() => onRemoveGroupPrefix(i)}
>
<MinusCircleIcon size={15} />
</Button>
</div>
);
})}
</div>
</div>
)}
<Button
variant={"dotted"}
className={"w-full"}
size={"sm"}
onClick={onAddGroupPrefix}
>
<PlusIcon size={14} />
{addText}
</Button>
</div>
);
}

View File

@@ -1,87 +0,0 @@
import Breadcrumbs from "@components/Breadcrumbs";
import InlineLink from "@components/InlineLink";
import { Label } from "@components/Label";
import Paragraph from "@components/Paragraph";
import { SkeletonIntegration } from "@components/skeletons/SkeletonIntegration";
import * as Tabs from "@radix-ui/react-tabs";
import { ExternalLinkIcon, FingerprintIcon } from "lucide-react";
import React from "react";
import IntegrationIcon from "@/assets/icons/IntegrationIcon";
import { useAccount } from "@/modules/account/useAccount";
import { AzureAD } from "@/modules/integrations/idp-sync/azure-ad/AzureAD";
import { GoogleWorkspace } from "@/modules/integrations/idp-sync/google-workspace/GoogleWorkspace";
import { useIntegrations } from "@/modules/integrations/idp-sync/useIntegrations";
export default function IdentityProviderTab() {
const account = useAccount();
useIntegrations();
return (
<Tabs.Content value={"identity-provider"}>
<div className={"p-default py-6"}>
<Breadcrumbs>
<Breadcrumbs.Item
href={"/integrations"}
label={"Integrations"}
icon={<IntegrationIcon size={13} />}
/>
<Breadcrumbs.Item
href={"/settings"}
label={"Identity Provider"}
icon={<FingerprintIcon size={14} />}
active
/>
</Breadcrumbs>
<h1>Identity Provider</h1>
<Paragraph>
Configure your preferred Identity Provider (IdP) to synchronize your
users and groups to NetBird.
</Paragraph>
<Paragraph>
Learn more about{" "}
<InlineLink href={"#"} target={"_blank"}>
Identity Provider
<ExternalLinkIcon size={12} />
</InlineLink>
in our documentation.
</Paragraph>
<div className={"gap-6 mt-6 flex flex-wrap"}>
{!account ? (
<>
<SkeletonIntegration loadingHeight={196} />
<SkeletonIntegration loadingHeight={196} />
</>
) : (
<>
<GoogleWorkspace />
<AzureAD />
</>
)}
</div>
<div className={"flex flex-col gap-6 max-w-md mt-10"}>
<div
className={
"bg-netbird-950 px-6 py-4 rounded-md border border-netbird-500 "
}
>
<Label className={"!text-netbird-100 text-md"}>
Looking to enable a custom Identity Provider like Okta or
Jumpcloud?
</Label>
<p className={"!text-netbird-200 mt-2"}>
Please contact us at{" "}
<InlineLink
href={"mailto:support@netbird.io"}
className={"inline !text-netbird-500 font-medium"}
>
{" "}
support@netbird.io
</InlineLink>{" "}
</p>
</div>
</div>
</div>
</Tabs.Content>
);
}

View File

@@ -1,158 +0,0 @@
import Button from "@components/Button";
import FullTooltip from "@components/FullTooltip";
import { notify } from "@components/Notification";
import { SkeletonIntegration } from "@components/skeletons/SkeletonIntegration";
import useFetchApi, { useApiCall } from "@utils/api";
import dayjs from "dayjs";
import { RefreshCw, Settings } from "lucide-react";
import * as React from "react";
import { useEffect, useState } from "react";
import { useSWRConfig } from "swr";
import integrationImage from "@/assets/integrations/entra-id.png";
import {
AzureADIntegration,
IdentityProviderLog,
} from "@/interfaces/IdentityProvider";
import AzureADConfiguration from "@/modules/integrations/idp-sync/azure-ad/AzureADConfiguration";
import AzureADSetup from "@/modules/integrations/idp-sync/azure-ad/AzureADSetup";
import { useIntegrations } from "@/modules/integrations/idp-sync/useIntegrations";
import { IntegrationCard } from "@/modules/integrations/IntegrationCard";
export const AzureAD = () => {
const { mutate } = useSWRConfig();
const [setupModal, setSetupModal] = useState(false);
const {
azure: integration,
isAnyIntegrationEnabled,
isAzureLoading,
} = useIntegrations();
const azureRequest = useApiCall<AzureADIntegration>(
"/integrations/azure-idp",
);
const [enabled, setEnabled] = useState(
integration ? integration.enabled : false,
);
useEffect(() => {
setEnabled(integration?.enabled ?? false);
}, [integration]);
const toggleSwitch = async (state: boolean) => {
if (!integration) return setSetupModal(true);
notify({
title: "Entra ID (Azure AD) Integration",
description: `Entra ID (Azure AD) was successfully ${
state ? "enabled" : "disabled"
}`,
promise: azureRequest
.put(
{
enabled: state,
},
"/" + integration.id,
)
.then(() => {
mutate("/integrations/azure-idp");
setEnabled(state);
}),
loadingMessage: "Updating integration...",
});
};
return isAzureLoading ? (
<SkeletonIntegration loadingHeight={197} />
) : (
<>
<IntegrationCard
name="Entra ID (Azure AD)"
description="Microsoft Entra ID is a cloud-based identity and access management solution."
url={{
title: "microsoft.com",
href: "https://www.microsoft.com/en-us/security/business/identity-access/microsoft-entra-id",
}}
image={integrationImage}
data={integration}
disabled={enabled ? false : isAnyIntegrationEnabled}
switchState={enabled}
onEnabledChange={toggleSwitch}
onSetup={() => setSetupModal(true)}
>
{integration && <ConfigurationButton config={integration} />}
</IntegrationCard>
<AzureADSetup
open={setupModal}
onOpenChange={setSetupModal}
onSuccess={() => setEnabled(true)}
/>
</>
);
};
type ConfigurationProps = {
config: AzureADIntegration;
};
const ConfigurationButton = ({ config }: ConfigurationProps) => {
const { data: logs } = useFetchApi<IdentityProviderLog[]>(
`/integrations/azure-idp/${config.id}/logs`,
);
const { mutate } = useSWRConfig();
const syncRequest = useApiCall<{ response: boolean }>(
`/integrations/azure-idp/${config.id}/sync`,
);
const [configModal, setConfigModal] = useState(false);
const forceSync = async () => {
notify({
title: "Entra ID (Azure AD) Integration",
description: `Entra ID (Azure AD) was successfully synced`,
loadingMessage: "Syncing integration...",
promise: syncRequest.post({}).then(() => {
mutate(`/integrations/azure-idp/${config.id}/logs`);
}),
});
};
return (
<>
<div className={"flex gap-2"}>
<FullTooltip
content={
<div className={"text-xs"}>
Force synchronization of users and groups
</div>
}
disabled={!config.enabled}
className={"w-full"}
interactive={false}
>
<Button
variant={"secondary"}
size={"xs"}
className={"w-full items-center"}
onClick={forceSync}
disabled={!config.enabled}
>
<RefreshCw size={14} />
Synced {dayjs().to(logs?.[0]?.timestamp)}
</Button>
</FullTooltip>
<Button
variant={"secondary"}
size={"xs"}
className={"items-center"}
onClick={() => {
setConfigModal(true);
}}
>
<Settings size={14} />
</Button>
</div>
<AzureADConfiguration open={configModal} onOpenChange={setConfigModal} />
</>
);
};

View File

@@ -1,372 +0,0 @@
import Button from "@components/Button";
import HelpText from "@components/HelpText";
import { Input } from "@components/Input";
import { Label } from "@components/Label";
import {
Modal,
ModalClose,
ModalContent,
ModalFooter,
} from "@components/modal/Modal";
import { notify } from "@components/Notification";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@components/Tabs";
import { GradientFadedBackground } from "@components/ui/GradientFadedBackground";
import { useHasChanges } from "@hooks/useHasChanges";
import { useApiCall } from "@utils/api";
import { cn } from "@utils/helpers";
import {
AlertOctagon,
Box,
Cog,
Folder,
FolderGit2,
KeyRound,
RefreshCw,
UserCircle,
} from "lucide-react";
import React, { useState } from "react";
import { useSWRConfig } from "swr";
import integrationImage from "@/assets/integrations/entra-id.png";
import { useDialog } from "@/contexts/DialogProvider";
import { AzureADIntegration } from "@/interfaces/IdentityProvider";
import { GroupPrefixInput } from "@/modules/integrations/idp-sync/GroupPrefixInput";
import { useIntegrations } from "@/modules/integrations/idp-sync/useIntegrations";
import { IntegrationModalHeader } from "@/modules/integrations/IntegrationModalHeader";
type Props = {
open: boolean;
onOpenChange: (open: boolean) => void;
onSuccess?: () => void;
};
export default function AzureADConfiguration({
open,
onOpenChange,
onSuccess,
}: Props) {
const { azure } = useIntegrations();
return (
<>
<Modal open={open} onOpenChange={onOpenChange} key={open ? 1 : 0}>
{azure && (
<ConfigurationContent
onSuccess={() => {
onOpenChange(false);
onSuccess && onSuccess();
}}
config={azure}
/>
)}
</Modal>
</>
);
}
type ModalProps = {
onSuccess: () => void;
config: AzureADIntegration;
};
export function ConfigurationContent({ onSuccess, config }: ModalProps) {
const { mutate } = useSWRConfig();
const { confirm } = useDialog();
const [tab, setTab] = useState<string>("settings");
const azureRequest = useApiCall<AzureADIntegration>(
"/integrations/azure-idp",
);
const clientSecretPlaceholder = "******************************";
const [clientSecret, setClientSecret] = useState(clientSecretPlaceholder);
const [clientId, setClientId] = useState(config.clientId);
const [tenantId, setTenantId] = useState(config.tenantId);
const [interval, setInterval] = useState(config.syncInterval.toString());
const [groupPrefixes, setGroupPrefixes] = useState<string[]>(
config.group_prefixes || [],
);
const [userGroupPrefixes, setUserGroupPrefixes] = useState<string[]>(
config.user_group_prefixes || [],
);
const deleteIntegration = async () => {
const choice = await confirm({
title: `Delete integration?`,
description: "Are you sure you want to delete this integration?",
confirmText: "Delete",
cancelText: "Cancel",
type: "danger",
});
if (!choice) return;
notify({
title: "Entra ID (Azure AD) Integration",
description: `Entra ID (Azure AD) was successfully deleted`,
promise: azureRequest.del({}, `/${config.id}`).then(() => {
mutate("/integrations/azure-idp");
onSuccess();
}),
loadingMessage: "Deleting integration...",
});
};
const updateIntegration = async () => {
notify({
title: "Entra ID (Azure AD) Integration",
description: `Entra ID (Azure AD) was successfully updated`,
promise: azureRequest
.put(
{
client_id: clientId,
tenant_id: tenantId,
client_secret:
clientSecretPlaceholder == clientSecret
? undefined
: btoa(clientSecret),
sync_interval: interval ? parseInt(interval) : 300,
group_prefixes: groupPrefixes || [],
user_group_prefixes: userGroupPrefixes || [],
},
`/${config.id}`,
)
.then(() => {
mutate("/integrations/azure-idp");
onSuccess();
}),
loadingMessage: "Updating integration...",
});
};
const { hasChanges } = useHasChanges([
clientId,
tenantId,
clientSecret,
interval,
groupPrefixes,
userGroupPrefixes,
]);
return (
<ModalContent
maxWidthClass={cn("relative max-w-xl")}
showClose={true}
className={""}
autoFocus={false}
>
<GradientFadedBackground />
<IntegrationModalHeader
image={integrationImage}
title={"Entra ID (Azure AD) Configuration"}
description={"Sync your users and groups from Entra ID to NetBird."}
/>
<Tabs
defaultValue={tab}
onValueChange={(v) => setTab(v)}
className={"mt-6"}
>
<TabsList justify={"start"} className={"px-8"}>
<TabsTrigger value={"settings"}>
<Cog
size={16}
className={
"text-nb-gray-500 group-data-[state=active]/trigger:text-netbird transition-all"
}
/>
Settings
</TabsTrigger>
<TabsTrigger value={"group-sync"}>
<FolderGit2
size={16}
className={
"text-nb-gray-500 group-data-[state=active]/trigger:text-netbird transition-all"
}
/>
Group Sync
</TabsTrigger>
<TabsTrigger value={"user-sync"}>
<UserCircle
size={16}
className={
"text-nb-gray-500 group-data-[state=active]/trigger:text-netbird transition-all"
}
/>
User Sync
</TabsTrigger>
<TabsTrigger value={"danger"}>
<AlertOctagon
size={16}
className={
"text-nb-gray-500 group-data-[state=active]/trigger:text-netbird transition-all"
}
/>
Danger Zone
</TabsTrigger>
</TabsList>
<TabsContent value={"settings"} className={"px-8 text-sm"}>
<div className={"flex-col gap-3 flex"}>
<Input
type={"text"}
autoCorrect={"off"}
autoComplete={"off"}
className={"w-full"}
customPrefix={
<div className={"min-w-[165px] flex gap-2 items-center"}>
<Box size={16} />
Application (client) ID
</div>
}
placeholder={"62d3a656-c87d-4f30-a242-5b6347e29e9f"}
value={clientId}
onChange={(e) => setClientId(e.target.value)}
/>
<Input
autoCorrect={"off"}
autoComplete={"off"}
type={"text"}
className={"w-full"}
customPrefix={
<div className={"min-w-[165px] flex gap-2 items-center"}>
<Folder size={16} />
Directory (tenant) ID
</div>
}
placeholder={"5d60468a-65b7-45eb-a61a-53ecfbcd1ea3"}
value={tenantId}
onChange={(e) => setTenantId(e.target.value)}
/>
<Input
autoCorrect={"off"}
type={"text"}
className={"w-full"}
customPrefix={
<div className={"min-w-[165px] flex gap-2 items-center"}>
<KeyRound size={16} />
Client Secret
</div>
}
placeholder={"YdV7Q~JJ62Xl.LvYoBanxZR2sJA2va_3UbqvncY8"}
value={clientSecret}
onChange={(e) => setClientSecret(e.target.value)}
/>
<div className={"flex justify-between mt-4"}>
<div>
<Label>Sync Interval</Label>
<HelpText className={"max-w-[300px]"}>
The interval in seconds when the synchronization should
happen.
</HelpText>
</div>
<Input
maxWidthClass={"max-w-[400px]"}
placeholder={"300"}
min={1}
max={99999}
value={interval}
type={"number"}
onChange={(e) => setInterval(e.target.value)}
customPrefix={
<RefreshCw size={16} className={"text-nb-gray-300"} />
}
customSuffix={"Seconds"}
/>
</div>
</div>
</TabsContent>
<TabsContent value={"group-sync"} className={"px-8"}>
<div>
<Label>
<div className={"flex gap-2 items-center"}>
<FolderGit2 size={16} />
Synchronize Groups
</div>
</Label>
<HelpText className={"max-w-lg mt-2"}>
By default,{" "}
<span className={"text-netbird font-semibold"}>All Groups</span>{" "}
will be synchronized from your IdP to NetBird. <br />
If you want to synchronize only groups that start with a specific
prefix, you can add them below.
</HelpText>
</div>
<GroupPrefixInput value={groupPrefixes} onChange={setGroupPrefixes} />
</TabsContent>
<TabsContent value={"user-sync"} className={"px-8"}>
<div>
<Label>
<div className={"flex gap-2 items-center"}>
<UserCircle size={16} />
Synchronize Users
</div>
</Label>
<HelpText className={"max-w-lg mt-2"}>
By default,{" "}
<span className={"text-netbird font-semibold"}>All Users</span>{" "}
will be synchronized from your IdP to NetBird. <br />
If you want to synchronize only users that belong to a specific
group, you can add them below.
</HelpText>
</div>
<GroupPrefixInput
addText={"Add user group filter"}
text={"User group starts with..."}
value={userGroupPrefixes}
onChange={setUserGroupPrefixes}
/>
</TabsContent>
<TabsContent value={"danger"} className={"px-8"}>
<div>
<Label>
<div className={"flex gap-2 items-center"}>
<AlertOctagon size={16} />
Delete Integration
</div>
</Label>
<HelpText className={"max-w-lg mt-2"}>
Deleting this integration will remove the ability to sync users
and groups from your IdP to NetBird. If you delete the integration
you will need to reconfigure it again to enable the
synchronization.
</HelpText>
</div>
<Button
variant={"danger"}
size={"xs"}
className={"mt-3"}
onClick={deleteIntegration}
>
Delete Integration
</Button>
</TabsContent>
</Tabs>
<div className={"h-6"}></div>
<ModalFooter className={"items-center gap-4"}>
<ModalClose asChild={true}>
<Button variant={"secondary"} className={"w-full"}>
Cancel
</Button>
</ModalClose>
<Button
variant={"primary"}
className={"w-full"}
disabled={!hasChanges}
onClick={updateIntegration}
>
Save
</Button>
</ModalFooter>
</ModalContent>
);
}

View File

@@ -1,498 +0,0 @@
import Button from "@components/Button";
import HelpText from "@components/HelpText";
import InlineLink from "@components/InlineLink";
import { Input } from "@components/Input";
import { Modal, ModalContent, ModalFooter } from "@components/modal/Modal";
import { notify } from "@components/Notification";
import Steps from "@components/Steps";
import { GradientFadedBackground } from "@components/ui/GradientFadedBackground";
import { Lightbox } from "@components/ui/Lightbox";
import { Mark } from "@components/ui/Mark";
import { MinimalList } from "@components/ui/MinimalList";
import { IconArrowLeft, IconArrowRight } from "@tabler/icons-react";
import { useApiCall } from "@utils/api";
import { cn } from "@utils/helpers";
import { isEmpty } from "lodash";
import {
Box,
Clock4,
Folder,
FolderGit2,
KeyRound,
PlusCircle,
Repeat,
Settings2,
Shield,
UserCircle,
} from "lucide-react";
import React, { useState } from "react";
import { useSWRConfig } from "swr";
import integrationImage from "@/assets/integrations/entra-id.png";
import { AzureADIntegration } from "@/interfaces/IdentityProvider";
import azureGrantAdmin from "@/modules/integrations/idp-sync/azure-ad/images/azure-grant-admin-conset.png";
import { IntegrationModalHeader } from "@/modules/integrations/IntegrationModalHeader";
import { GroupPrefixInput } from "../GroupPrefixInput";
type Props = {
open: boolean;
onOpenChange: (open: boolean) => void;
onSuccess?: () => void;
};
export default function AzureADSetup({ open, onOpenChange, onSuccess }: Props) {
return (
<>
<Modal open={open} onOpenChange={onOpenChange} key={open ? 1 : 0}>
<SetupContent
onSuccess={() => {
onOpenChange(false);
onSuccess && onSuccess();
}}
/>
</Modal>
</>
);
}
type ModalProps = {
onSuccess: () => void;
};
export function SetupContent({ onSuccess }: ModalProps) {
const { mutate } = useSWRConfig();
const azureRequest = useApiCall<AzureADIntegration>(
"/integrations/azure-idp",
);
const [step, setStep] = useState(0);
const maxSteps = 6;
const [clientSecret, setClientSecret] = useState("");
const [clientId, setClientId] = useState("");
const [tenantId, setTenantId] = useState("");
const clientSecretEntered = !isEmpty(clientSecret);
const clientIdEntered = !isEmpty(clientId);
const tenantIdEntered = !isEmpty(tenantId);
const allEntered = clientIdEntered && tenantIdEntered && clientSecretEntered;
const isDisabled =
(step == 8 && !clientSecretEntered) || (step == 9 && !allEntered);
const [groupPrefixes, setGroupPrefixes] = useState<string[]>([]);
const [userGroupPrefixes, setUserGroupPrefixes] = useState<string[]>([]);
const connect = async () => {
notify({
title: "Entra ID Integration",
description: `Entra ID was successfully connected to NetBird.`,
promise: azureRequest
.post({
client_secret: btoa(clientSecret), // Encode client secret to base64
client_id: clientId,
tenant_id: tenantId,
group_prefixes: groupPrefixes || [],
user_group_prefixes: userGroupPrefixes || [],
})
.then(() => {
mutate("/integrations/azure-idp");
onSuccess();
}),
loadingMessage: "Setting up integration...",
});
};
return (
<ModalContent
maxWidthClass={cn("relative", step == 0 ? "max-w-md" : "max-w-xl")}
showClose={true}
className={""}
onEscapeKeyDown={(e) => step > 0 && e.preventDefault()}
onInteractOutside={(e) => step > 0 && e.preventDefault()}
onPointerDownOutside={(e) => step > 0 && e.preventDefault()}
>
<GradientFadedBackground />
{step > 0 && (
<div className={"flex gap-2 w-full items-center justify-center mb-4"}>
{Array.from({ length: maxSteps }).map((_, index) => (
<div
key={index}
className={cn(
"w-8 h-1 rounded-full bg-nb-gray-800",
step >= index + 1 && "bg-netbird",
)}
/>
))}
</div>
)}
<IntegrationModalHeader
image={integrationImage}
title={"Connect NetBird with Entra ID (Azure AD)"}
description={
"Start syncing your users and groups from Entra ID to NetBird. Follow the steps below to get started."
}
/>
{step == 0 && (
<div
className={
"px-8 py-3 flex z-0 flex-col gap-0 text-sm mb-3 text-center justify-center items-center"
}
>
<div
className={
"mt-6 text-base font-medium text-nb-gray-100 flex gap-2 items-center justify-center"
}
>
<Shield size={16} />
Required Permissions
</div>
<p className={"mt-2 !text-nb-gray-300 !leading-[1.5]"}>
Ensure that you have an an{" "}
<span className={"text-nb-gray-100 font-semibold"}>
Azure AD user account
</span>{" "}
with the following{" "}
<span className={"text-nb-gray-100 font-semibold"}>
permissions
</span>
.{" "}
{
"If you don't have the required permissions, ask your Azure AD administrator to grant them to you."
}
</p>
<div
className={
"flex items-center flex-col gap-0 mt-2 w-full justify-center max-w-lg"
}
>
<div
className={
"py-2 px-6 flex items-center gap-2 rounded-md w-full justify-center bg-nb-gray-930/0 text-nb-gray-200"
}
>
<PlusCircle size={14} className={"text-sky-500"} />
Create Azure AD applications
</div>
<div
className={
"py-2 px-6 flex items-center gap-2 rounded-md w-full justify-center bg-nb-gray-930/0 text-nb-gray-200"
}
>
<Settings2 size={14} className={"text-sky-500"} />
Manage Azure AD applications
</div>
</div>
</div>
)}
{step == 1 && (
<div className={"px-8 py-3 flex flex-col gap-0 mt-4"}>
<p className={"font-medium flex gap-3 items-center text-base"}>
<Box size={20} />
Create and configure Azure AD application
</p>
<Steps>
<Steps.Step step={1}>
<p>
Navigate to{" "}
<InlineLink
className={"inline"}
target={"_blank"}
href={
"https://portal.azure.com/#view/Microsoft_AAD_IAM/ActiveDirectoryMenuBlade/~/Overview"
}
>
Azure Active Directory
</InlineLink>
</p>
</Steps.Step>
<Steps.Step step={2}>
<p className={"font-normal"}>
Click <Mark>App Registrations</Mark> in the left menu then click
on the <Mark>+ New registration</Mark> button to create a new
application.
</p>
</Steps.Step>
<Steps.Step step={3} line={false}>
<p className={"font-normal"}>
Fill in the form with the following values and click{" "}
<Mark>Register</Mark>
</p>
</Steps.Step>
</Steps>
<MinimalList
data={[
{
label: "Name",
value: "NetBird",
},
{
label: "Account Types",
value:
"Accounts in this organizational directory only (Default Directory only - Single tenant)",
},
{
label: "Redirect Type",
value: "Single-page application (SPA)",
},
{
label: "Redirect URI",
value: "https://app.netbird.io/silent-auth",
},
]}
/>
</div>
)}
{step == 2 && (
<div className={"px-8 py-3 flex flex-col gap-0 mt-4"}>
<p className={"font-medium flex gap-3 items-center text-base"}>
<Shield size={20} />
Add API permissions
</p>
<Steps>
<Steps.Step step={1}>
<p className={"font-normal"}>
Click <Mark>API permissions</Mark> on the left side menu
</p>
</Steps.Step>
<Steps.Step step={2}>
<p className={"font-normal"}>
Click <Mark>Add a permission</Mark> then{" "}
<Mark>Microsoft Graph</Mark> and then on the{" "}
<Mark>Application permissions</Mark> tab.
</p>
</Steps.Step>
<Steps.Step step={3}>
<p className={"font-normal"}>
In <Mark>Select permissions</Mark> select{" "}
<Mark>User.Read.All</Mark> and <Mark>Group.Read.All</Mark> and
click <Mark>Add permissions</Mark>
</p>
</Steps.Step>
<Steps.Step step={4} line={false}>
<p className={"font-normal"}>
Click <Mark>Grant admin conset for Default Directory</Mark> and
click <Mark>Yes</Mark>
</p>
<Lightbox image={azureGrantAdmin} />
</Steps.Step>
</Steps>
</div>
)}
{step == 3 && (
<div className={"px-8 py-3 flex flex-col gap-0 mt-4"}>
<p className={"font-medium flex gap-3 items-center text-base"}>
<KeyRound size={20} />
Generate client secret
</p>
<Steps>
<Steps.Step step={1}>
<p className={"font-normal"}>
Navigate to <Mark>Certificates & secrets</Mark> on left side
menu
</p>
</Steps.Step>
<Steps.Step step={2}>
<p className={"font-normal"}>
Click on <Mark>+ New client secret</Mark>
</p>
</Steps.Step>
<Steps.Step step={3}>
<p className={"font-normal"}>
Add <Mark copy>NetBird</Mark> as the description and click{" "}
<Mark>Add</Mark>
</p>
</Steps.Step>
<Steps.Step step={4} line={false}>
<p className={"font-normal"}>
Copy the <Mark>Value</Mark> and paste it here
</p>
</Steps.Step>
</Steps>
<div className={"mb-4"}>
<Input
type={"text"}
className={"w-full"}
customPrefix={
<div className={"flex items-center gap-2"}>
<KeyRound size={16} className={"text-nb-gray-300"} />
</div>
}
placeholder={"YdV7Q~JJ62Xl.LvYoBanxZR2sJA2va_3UbqvncY8"}
value={clientSecret}
onChange={(e) => setClientSecret(e.target.value)}
/>
</div>
</div>
)}
{step == 4 && (
<div className={"px-8 py-3 flex flex-col gap-0 mt-4"}>
<p className={"font-medium flex gap-3 items-center text-base"}>
<Box size={20} />
Enter Application ID and Directory ID
</p>
<Steps>
<Steps.Step step={1}>
<p className={"font-normal"}>
Navigate to{" "}
<InlineLink
target={"_blank"}
className={"inline"}
href={
"https://portal.azure.com/#view/Microsoft_AAD_IAM/ActiveDirectoryMenuBlade/~/RegisteredApps"
}
>
Owner applications
</InlineLink>
</p>
</Steps.Step>
<Steps.Step step={2} line={false}>
<p className={"font-normal"}>
Select <Mark>NetBird</Mark> application in overview page and
enter your <Mark>Application (client) ID</Mark> and{" "}
<Mark>Directory (tenant) ID</Mark>
</p>
</Steps.Step>
</Steps>
<div className={"mb-4 flex flex-col gap-3"}>
<Input
type={"text"}
className={"w-full"}
customPrefix={
<div className={"min-w-[165px] flex gap-2 items-center"}>
<Box size={16} />
Application (client) ID
</div>
}
placeholder={"62d3a656-c87d-4f30-a242-5b6347e29e9f"}
value={clientId}
onChange={(e) => setClientId(e.target.value)}
/>
<Input
type={"text"}
className={"w-full"}
customPrefix={
<div className={"min-w-[165px] flex gap-2 items-center"}>
<Folder size={16} />
Directory (tenant) ID
</div>
}
placeholder={"5d60468a-65b7-45eb-a61a-53ecfbcd1ea3"}
value={tenantId}
onChange={(e) => setTenantId(e.target.value)}
/>
</div>
</div>
)}
{step == 5 && (
<div className={"px-8 py-3 flex flex-col gap-0 mt-4"}>
<p className={"font-medium flex gap-3 items-center text-base"}>
<FolderGit2 size={20} />
Groups to be synchronized
</p>
<div className={"mb-4 flex flex-col gap-1"}>
<div>
<HelpText className={"max-w-lg mt-2 text-sm"}>
By default,{" "}
<span className={"text-netbird font-semibold"}>All Groups</span>{" "}
will be synchronized from your IdP to NetBird. <br />
If you want to synchronize only groups that start with a
specific prefix, you can add them below.
</HelpText>
</div>
<GroupPrefixInput
value={groupPrefixes}
onChange={setGroupPrefixes}
/>
</div>
</div>
)}
{step == 6 && (
<div className={"px-8 py-3 flex flex-col gap-0 mt-4"}>
<p className={"font-medium flex gap-3 items-center text-base"}>
<UserCircle size={18} />
Users to be synchronized
</p>
<div className={"mb-4 flex flex-col gap-1"}>
<div>
<HelpText className={"max-w-lg mt-2 text-sm"}>
By default,{" "}
<span className={"text-netbird font-semibold"}>All Users</span>{" "}
will be synchronized from your IdP to NetBird. <br />
If you want to synchronize only users that belong to a specific
group, you can add them below.
</HelpText>
</div>
<GroupPrefixInput
addText={"Add user group filter"}
text={"User group starts with..."}
value={userGroupPrefixes}
onChange={setUserGroupPrefixes}
/>
</div>
</div>
)}
<ModalFooter className={"items-center gap-4"}>
{step > 0 && (
<Button
variant={"secondary"}
className={"w-full"}
onClick={() => setStep(step - 1)}
>
<IconArrowLeft size={16} />
Back
</Button>
)}
{step >= 0 && step < maxSteps && (
<Button
variant={"primary"}
className={"w-full"}
disabled={isDisabled}
onClick={() => setStep(step + 1)}
>
{step == 0 ? "Get Started" : "Continue"}
<IconArrowRight size={16} />
</Button>
)}
{step == maxSteps && (
<Button
variant={"primary"}
className={"w-full"}
disabled={isDisabled}
onClick={connect}
>
<Repeat size={16} />
Connect
</Button>
)}
</ModalFooter>
{step == 0 && (
<div
className={
"text-center z-0 mt-2.5 text-xs text-nb-gray-300 flex items-center justify-center gap-2 font-normal"
}
>
<Clock4 size={12} />
<div>
Estimated setup time:
<span className={"font-medium"}> 10-20 Minutes</span>
</div>
</div>
)}
</ModalContent>
);
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 188 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 171 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 182 KiB

Some files were not shown because too many files have changed in this diff Show More