Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
650496f670 | ||
|
|
121778c4a6 | ||
|
|
d4102c5d04 | ||
|
|
e78c35bdbe | ||
|
|
6ebee98695 | ||
|
|
f4b28d5f40 | ||
|
|
b4b6d9295b | ||
|
|
4898742ee9 | ||
|
|
79164e9dd5 | ||
|
|
5caeab118b | ||
|
|
3f943bb7d4 | ||
|
|
96b939e6cc | ||
|
|
5e13548b81 | ||
|
|
2272a1d2a4 | ||
|
|
fc3da50346 | ||
|
|
6d4716cdad | ||
|
|
859916b1df | ||
|
|
80ce7d21b0 | ||
|
|
06fdbd8ec4 | ||
|
|
973cceff79 | ||
|
|
f4a2d6fae8 | ||
|
|
cb922b46b7 | ||
|
|
4c56ae704c |
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,9 @@ const nextConfig = {
|
||||
unoptimized: true,
|
||||
},
|
||||
reactStrictMode: false,
|
||||
env: {
|
||||
APP_ENV: process.env.APP_ENV || "production",
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = nextConfig;
|
||||
|
||||
8
package-lock.json
generated
@@ -48,7 +48,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",
|
||||
@@ -5351,9 +5351,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lucide-react": {
|
||||
"version": "0.287.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.287.0.tgz",
|
||||
"integrity": "sha512-auxP2bTGiMoELzX+6ItTeNzLmhGd/O+PHBsrXV2YwPXYCxarIFJhiMOSzFT9a1GWeYPSZtnWdLr79IVXr/5JqQ==",
|
||||
"version": "0.383.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.383.0.tgz",
|
||||
"integrity": "sha512-13xlG0CQCJtzjSQYwwJ3WRqMHtRj3EXmLlorrARt7y+IHnxUCp3XyFNL1DfaGySWxHObDvnu1u1dV+0VMKHUSg==",
|
||||
"peerDependencies": {
|
||||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0"
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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} />
|
||||
) : (
|
||||
|
||||
@@ -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 />;
|
||||
};
|
||||
|
||||
@@ -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 />;
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
BIN
src/assets/integrations/okta.png
Normal file
|
After Width: | Height: | Size: 9.4 KiB |
@@ -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>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
)}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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} />
|
||||
)}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
70
src/components/ui/DomainListBadge.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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
|
||||
|
||||
88
src/components/ui/InputDomain.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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}`,
|
||||
);
|
||||
|
||||
@@ -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 || [],
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -0,0 +1,4 @@
|
||||
export interface Domain {
|
||||
id?: string;
|
||||
name: string;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -17,11 +17,6 @@ export interface Nameserver {
|
||||
id?: string;
|
||||
}
|
||||
|
||||
export interface Domain {
|
||||
id?: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export const NameserverPresets: Record<string, NameserverGroup> = {
|
||||
Default: {
|
||||
name: "",
|
||||
|
||||
3
src/interfaces/Permission.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export interface Permission {
|
||||
dashboard_view: "limited" | "full" | "blocked";
|
||||
}
|
||||
@@ -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" },
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)} />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
46
src/modules/exit-node/AddExitNodeButton.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
59
src/modules/exit-node/ExitNodeDropdownButton.tsx
Normal 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;
|
||||
};
|
||||
47
src/modules/exit-node/ExitNodeHelpTooltip.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
25
src/modules/exit-node/ExitNodePeerIndicator.tsx
Normal 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;
|
||||
};
|
||||
19
src/modules/exit-node/useHasExitNodes.tsx
Normal 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;
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
Before Width: | Height: | Size: 176 KiB |
|
Before Width: | Height: | Size: 188 KiB |
|
Before Width: | Height: | Size: 171 KiB |
|
Before Width: | Height: | Size: 118 KiB |