Compare commits

...

18 Commits

Author SHA1 Message Date
Eduard Gert
bf81aeb02d Add fine-grained ssh policy (#522)
Some checks failed
build and push / build_n_push (push) Has been cancelled
* Add fine-grained ssh policy

* Update version text

* Fix coderabbit comment
2025-12-30 09:27:17 +01:00
Eduard Gert
b058e66e32 Add auto update setting (#519)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2025-12-29 12:38:50 +01:00
Eduard Gert
8d6b617cbd Update NextJS to 14.2.35 (#518)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2025-12-22 11:02:29 +01:00
Eduard Gert
47db655e9f Update eslint and tailwind (#515)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2025-11-27 17:38:18 +01:00
dependabot[bot]
0661cbf9f4 Bump js-yaml from 4.1.0 to 4.1.1 (#509)
Bumps [js-yaml](https://github.com/nodeca/js-yaml) from 4.1.0 to 4.1.1.
- [Changelog](https://github.com/nodeca/js-yaml/blob/master/CHANGELOG.md)
- [Commits](https://github.com/nodeca/js-yaml/compare/4.1.0...4.1.1)

---
updated-dependencies:
- dependency-name: js-yaml
  dependency-version: 4.1.1
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-11-27 15:25:50 +01:00
Eduard Gert
240a96fa8b Add onboarding for new accounts (#514) 2025-11-27 14:49:58 +01:00
Eduard Gert
43bc069a49 Increase ssh detection timeout (#512)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2025-11-21 10:32:50 +01:00
Eduard Gert
936de0f4f3 Add ssh policy info for peers (#511)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2025-11-20 14:29:14 +01:00
Eduard Gert
d81b75a946 Bump browser ssh versions for ssh rewrite (#510)
Some checks failed
build and push / build_n_push (push) Has been cancelled
* Bump browser ssh versions for ssh rewrite

* Remove cypress temporary
2025-11-18 17:07:58 +01:00
Eduard Gert
a632eeeef0 Remove dns0eu (#508) 2025-11-10 14:21:58 +01:00
Eduard Gert
e2219aeea0 Add group update activity event (#504)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2025-11-10 10:50:04 +01:00
Eduard Gert
63f4c69eb4 Fix native ssh detection (#505) 2025-11-07 09:33:58 +01:00
Eduard Gert
b1af256296 Add wasm client version (#503) 2025-11-06 10:59:41 +01:00
Eduard Gert
4027894a2e Feature/groups page (#498)
* move our group membership from the settings menu, into the Team menu

* add action to the table and new group page

* update group page and return group settings to settings menu

* new update

* fix bug

* group action: add peer to group

* group action: add user to group

* Update wording, redirect to group page after creation

* Add better table loading skeleton

* Adjust group name cell

* Update wording

* Update sort order

* Refactor

* Merge main

* Fix button height

* Fix resources table

* Adjust table loading skeleton

* Adjust table loading skeleton

* Add loading to tab triggers

* Update meta

* Update group location

* Fix rename

* Refactor group details

* Fix linked peers

* Fix group usage

* Fix incrementing peer count

* Prevent renaming to already existing group

* Fix group name click

* Update group nav

* Make group table cells clickable

* Fix breadcrumbs

* Update wording

* Add confirmation before removing users from group

* Add permissions

* Add initial group for network routes

* Add acl and routing peer groups

---------

Co-authored-by: aliamerj <aliamer19ali@gmail.com>
2025-11-05 12:08:49 +01:00
Yanis64
af90792595 Add multi-group support for JWT allow groups with tag system (#500)
* fix: add multi-group support for JWT allow groups with tag system

* Update src/modules/settings/GroupsTab.tsx to use the Badge component

Co-authored-by: Eduard Gert <kontakt@eduardgert.de>

* chore(GroupsTab): import Badge components

---------

Co-authored-by: Eduard Gert <kontakt@eduardgert.de>
2025-11-03 16:09:15 +01:00
Eduard Gert
9a401733b3 Fix toggle for p2p policies (#501)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2025-10-31 13:21:23 +01:00
Eduard Gert
07b6895380 Sync SSH & RDP changes (#495)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2025-10-16 14:44:26 +02:00
Eduard Gert
9e2e38764e Add control center (#494)
Some checks failed
build and push / build_n_push (push) Has been cancelled
* Add control center

* Update rdp doc link
2025-10-09 11:26:21 +02:00
196 changed files with 16888 additions and 4354 deletions

View File

@@ -2,7 +2,6 @@ name: build and push
on:
push:
branches:
- "feature/**"
- main
tags:
- "**"

View File

@@ -26,6 +26,7 @@ The dashboard makes it possible to:
- NextJS
- ReactJS
- Tailwind CSS
- [React Flow](https://reactflow.dev/) for the Control Center
- Auth0
- Nginx
- Docker

View File

@@ -13,5 +13,6 @@
"dragQueryParams": "$NETBIRD_DRAG_QUERY_PARAMS",
"hotjarTrackID": "$NETBIRD_HOTJAR_TRACK_ID",
"googleAnalyticsID": "$NETBIRD_GOOGLE_ANALYTICS_ID",
"googleTagManagerID": "$NETBIRD_GOOGLE_TAG_MANAGER_ID"
"googleTagManagerID": "$NETBIRD_GOOGLE_TAG_MANAGER_ID",
"wasmPath": "$NETBIRD_WASM_PATH"
}

View File

@@ -7,6 +7,10 @@ server {
root /usr/share/nginx/html;
default_type application/wasm;
}
location = /ironrdp-pkg/ironrdp_web_bg.wasm {
root /usr/share/nginx/html;
default_type application/wasm;
}
location / {
try_files $uri $uri.html $uri/ =404;

View File

@@ -61,11 +61,12 @@ export NETBIRD_GOOGLE_ANALYTICS_ID=${NETBIRD_GOOGLE_ANALYTICS_ID}
export NETBIRD_GOOGLE_TAG_MANAGER_ID=${NETBIRD_GOOGLE_TAG_MANAGER_ID}
export NETBIRD_TOKEN_SOURCE=${NETBIRD_TOKEN_SOURCE:-accessToken}
export NETBIRD_DRAG_QUERY_PARAMS=${NETBIRD_DRAG_QUERY_PARAMS:-false}
export NETBIRD_WASM_PATH=${NETBIRD_WASM_PATH}
echo "NetBird latest version: ${NETBIRD_LATEST_VERSION}"
# replace ENVs in the config
ENV_STR="\$\$USE_AUTH0 \$\$AUTH_AUDIENCE \$\$AUTH_AUTHORITY \$\$AUTH_CLIENT_ID \$\$AUTH_CLIENT_SECRET \$\$AUTH_SUPPORTED_SCOPES \$\$NETBIRD_MGMT_API_ENDPOINT \$\$NETBIRD_MGMT_GRPC_API_ENDPOINT \$\$NETBIRD_HOTJAR_TRACK_ID \$\$NETBIRD_GOOGLE_ANALYTICS_ID \$\$NETBIRD_GOOGLE_TAG_MANAGER_ID \$\$AUTH_REDIRECT_URI \$\$AUTH_SILENT_REDIRECT_URI \$\$NETBIRD_TOKEN_SOURCE \$\$NETBIRD_DRAG_QUERY_PARAMS"
ENV_STR="\$\$USE_AUTH0 \$\$AUTH_AUDIENCE \$\$AUTH_AUTHORITY \$\$AUTH_CLIENT_ID \$\$AUTH_CLIENT_SECRET \$\$AUTH_SUPPORTED_SCOPES \$\$NETBIRD_MGMT_API_ENDPOINT \$\$NETBIRD_MGMT_GRPC_API_ENDPOINT \$\$NETBIRD_HOTJAR_TRACK_ID \$\$NETBIRD_GOOGLE_ANALYTICS_ID \$\$NETBIRD_GOOGLE_TAG_MANAGER_ID \$\$AUTH_REDIRECT_URI \$\$AUTH_SILENT_REDIRECT_URI \$\$NETBIRD_TOKEN_SOURCE \$\$NETBIRD_DRAG_QUERY_PARAMS \$\$NETBIRD_WASM_PATH"
OIDC_TRUSTED_DOMAINS="/usr/share/nginx/html/OidcTrustedDomains.js"
envsubst "$ENV_STR" < "$OIDC_TRUSTED_DOMAINS".tmpl > "$OIDC_TRUSTED_DOMAINS"

6092
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -14,6 +14,7 @@
},
"dependencies": {
"@axa-fr/react-oidc": "^7.22.18",
"@dagrejs/dagre": "^1.1.5",
"@radix-ui/react-accordion": "^1.1.2",
"@radix-ui/react-checkbox": "^1.0.4",
"@radix-ui/react-collapsible": "^1.0.3",
@@ -35,6 +36,7 @@
"@tanstack/match-sorter-utils": "^8.8.4",
"@tanstack/react-table": "^8.10.7",
"@types/crypto-js": "^4.2.2",
"@types/d3": "^7.4.3",
"@types/lodash": "^4.14.200",
"@types/node": "20.10.6",
"@types/react": "^18",
@@ -42,6 +44,7 @@
"@types/react-window": "^1.8.8",
"@xterm/addon-fit": "^0.10.0",
"@xterm/xterm": "^5.5.0",
"@xyflow/react": "^12.8.4",
"autoprefixer": "^10",
"chart.js": "^4.4.8",
"chroma-js": "^3.1.2",
@@ -49,9 +52,10 @@
"clsx": "^2.0.0",
"cmdk": "^0.2.0",
"crypto-js": "^4.2.0",
"d3": "^7.9.0",
"date-fns": "^2.30.0",
"dayjs": "^1.11.10",
"eslint": "^8",
"elkjs": "^0.10.0",
"eslint-config-prettier": "^9.0.0",
"eslint-plugin-simple-import-sort": "^10.0.0",
"flowbite": "^1.8.1",
@@ -60,8 +64,8 @@
"ip-cidr": "^3.1.0",
"js-cookie": "^3.0.5",
"lodash": "^4.17.21",
"lucide-react": "^0.481.0",
"next": "^14.2.28",
"lucide-react": "^0.539.0",
"next": "^14.2.35",
"next-themes": "^0.2.1",
"punycode": "^2.3.1",
"react": "^18.3.1",
@@ -85,10 +89,10 @@
"@faker-js/faker": "^9.5.1",
"@types/chroma-js": "^3.1.1",
"@types/js-cookie": "^3.0.6",
"eslint-config-next": "^14.2.28",
"cypress": "^13.13.0",
"eslint": "^9.39.1",
"eslint-config-next": "^16.0.5",
"postcss": "^8",
"prettier": "3.0.3",
"tailwindcss": "^3"
"tailwindcss": "^3.4.17"
}
}

View File

@@ -33,7 +33,7 @@ export default function AccessControlPage() {
<div className={"p-default py-6"}>
<Breadcrumbs>
<Breadcrumbs.Item
href={"/policies"}
href={"/access-control"}
label={"Access Control"}
icon={<AccessControlIcon size={14} />}
/>

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -32,7 +32,7 @@ export default function NameServers() {
<div className={"p-default py-6"}>
<Breadcrumbs>
<Breadcrumbs.Item
href={"/dns"}
href={"/dns/nameservers"}
label={"DNS"}
icon={<DNSIcon size={13} />}
/>

View File

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

View File

@@ -0,0 +1,315 @@
"use client";
import Breadcrumbs from "@components/Breadcrumbs";
import FullTooltip from "@components/FullTooltip";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@components/Tabs";
import FullScreenLoading from "@components/ui/FullScreenLoading";
import { GroupBadgeIcon } from "@components/ui/GroupBadgeIcon";
import { PageNotFound } from "@components/ui/PageNotFound";
import { RestrictedAccess } from "@components/ui/RestrictedAccess";
import useRedirect from "@hooks/useRedirect";
import useFetchApi from "@utils/api";
import { cn, singularize } from "@utils/helpers";
import { FolderGit2Icon, Layers3Icon, PencilIcon } from "lucide-react";
import { useSearchParams } from "next/navigation";
import React, { useState } from "react";
import AccessControlIcon from "@/assets/icons/AccessControlIcon";
import DNSIcon from "@/assets/icons/DNSIcon";
import NetworkRoutesIcon from "@/assets/icons/NetworkRoutesIcon";
import PeerIcon from "@/assets/icons/PeerIcon";
import SetupKeysIcon from "@/assets/icons/SetupKeysIcon";
import TeamIcon from "@/assets/icons/TeamIcon";
import { GroupProvider, useGroupContext } from "@/contexts/GroupProvider";
import { usePermissions } from "@/contexts/PermissionsProvider";
import RoutesProvider from "@/contexts/RoutesProvider";
import { Group, GROUP_TOOLTIP_TEXT } from "@/interfaces/Group";
import PageContainer from "@/layouts/PageContainer";
import { GroupNameserversSection } from "@/modules/groups/details/GroupNameserversSection";
import { GroupNetworkRoutesSection } from "@/modules/groups/details/GroupNetworkRoutesSection";
import { GroupPeersSection } from "@/modules/groups/details/GroupPeersSection";
import { GroupPoliciesSection } from "@/modules/groups/details/GroupPoliciesSection";
import { GroupResourcesSection } from "@/modules/groups/details/GroupResourcesSection";
import { GroupSetupKeysSection } from "@/modules/groups/details/GroupSetupKeysSection";
import { GroupUsersSection } from "@/modules/groups/details/GroupUsersSection";
import useGroupDetails from "@/modules/groups/details/useGroupDetails";
export default function GroupPage() {
const queryParameter = useSearchParams();
const { isRestricted } = usePermissions();
const groupId = queryParameter.get("id");
const {
data: group,
isLoading,
error,
} = useFetchApi<Group>(`/groups/${groupId}`, true);
useRedirect("/groups", false, !groupId || isRestricted);
if (isRestricted) {
return (
<PageContainer>
<RestrictedAccess page={"Group Information"} />
</PageContainer>
);
}
if (error)
return (
<PageNotFound
title={error?.message}
description={
"The group you are attempting to access cannot be found. It may have been deleted, or you may not have permission to view it. Please verify the URL or return to the dashboard."
}
/>
);
return group && !isLoading ? (
<PageContainer>
<RoutesProvider>
<GroupProvider group={group} isDetailPage={true}>
<div className={"p-default py-6 pb-0 w-full mb-[6px]"}>
<Breadcrumbs>
<Breadcrumbs.Item
href={"/groups"}
label={"Groups"}
icon={<FolderGit2Icon size={14} />}
/>
<Breadcrumbs.Item label={group.name} active />
</Breadcrumbs>
<GroupDetailsName />
</div>
<GroupOverviewTabs group={group} />
</GroupProvider>
</RoutesProvider>
</PageContainer>
) : (
<FullScreenLoading />
);
}
const GroupDetailsName = () => {
const { group, isJWTGroup, isAllowedToRename, openGroupRenameModal } =
useGroupContext();
const { permission } = usePermissions();
return (
<div className={"w-full"}>
<h1 className={"flex items-center gap-3 w-full whitespace-nowrap"}>
<GroupBadgeIcon id={group?.id} issued={group?.issued} size={20} />
{group.name}
{group.name !== "All" && permission?.groups?.update && (
<div>
<FullTooltip
content={
<div className={"text-xs max-w-xs"}>
{isJWTGroup
? GROUP_TOOLTIP_TEXT.RENAME.JWT
: GROUP_TOOLTIP_TEXT.RENAME.INTEGRATION}
</div>
}
interactive={false}
disabled={isAllowedToRename}
className={"w-full block"}
>
<div
className={cn(
"flex h-8 w-8 items-center justify-center gap-2 dark:text-neutral-300 text-neutral-500 hover:text-neutral-100 transition-all hover:bg-nb-gray-800/60 rounded-md cursor-pointer",
!isAllowedToRename &&
"opacity-40 cursor-not-allowed pointer-events-none",
)}
onClick={openGroupRenameModal}
>
<PencilIcon size={16} />
</div>
</FullTooltip>
</div>
)}
</h1>
</div>
);
};
const validAllGroupTabs = [
"policies",
"resources",
"network-routes",
"nameservers",
];
const validOtherGroupTabs = ["users", "peers", "setup-keys"];
const GroupOverviewTabs = ({ group }: { group: Group }) => {
const searchParams = useSearchParams();
const getInitialTab = () => {
const isAllGroup = group.name === "All";
const tabParam = searchParams.get("tab");
const validTabs = isAllGroup
? validAllGroupTabs
: [...validAllGroupTabs, ...validOtherGroupTabs];
if (tabParam === null) return isAllGroup ? "policies" : "users";
if (isAllGroup) {
return validTabs.includes(tabParam) ? tabParam : "policies";
}
return validTabs.includes(tabParam) ? tabParam : "users";
};
const [tab, setTab] = useState(getInitialTab());
const { groupDetails, isLoading } = useGroupDetails(group?.id || "");
const peersCount = groupDetails?.peers_count || 0;
const usersCount = groupDetails?.users?.length || 0;
const policiesCount = groupDetails?.policies?.length || 0;
const resourcesCount = groupDetails?.resources_count || 0;
const routesCount = groupDetails?.routes?.length || 0;
const nameserversCount = groupDetails?.nameservers?.length || 0;
const setupKeysCount = groupDetails?.setupKeys?.length || 0;
return (
<Tabs
defaultValue={tab}
onValueChange={(v) => setTab(v)}
value={tab}
className={"pt-2 pb-0 mb-0"}
>
<TabsList justify={"start"} className={"px-8"}>
{group.name !== "All" && (
<TabsTrigger
value={"users"}
className={groupDetails === null ? "animate-pulse" : ""}
>
<TeamIcon
size={12}
className={
"fill-nb-gray-500 group-data-[state=active]/trigger:fill-netbird transition-all"
}
/>
{singularize("Users", usersCount)}
</TabsTrigger>
)}
{group.name !== "All" && (
<TabsTrigger
value={"peers"}
className={groupDetails === null ? "animate-pulse" : ""}
>
<PeerIcon
size={12}
className={
"fill-nb-gray-500 group-data-[state=active]/trigger:fill-netbird transition-all"
}
/>
{singularize("Peers", peersCount)}
</TabsTrigger>
)}
<TabsTrigger
value={"policies"}
className={groupDetails === null ? "animate-pulse" : ""}
>
<AccessControlIcon
size={12}
className={
"fill-nb-gray-500 group-data-[state=active]/trigger:fill-netbird transition-all"
}
/>
{singularize("Policies", policiesCount)}
</TabsTrigger>
<TabsTrigger
value={"resources"}
className={groupDetails === null ? "animate-pulse" : ""}
>
<Layers3Icon size={14} />
{singularize("Resources", resourcesCount)}
</TabsTrigger>
<TabsTrigger
value={"network-routes"}
className={groupDetails === null ? "animate-pulse" : ""}
>
<NetworkRoutesIcon
size={12}
className={
"fill-nb-gray-500 group-data-[state=active]/trigger:fill-netbird transition-all"
}
/>
{singularize("Network Routes", routesCount)}
</TabsTrigger>
<TabsTrigger
value={"nameservers"}
className={groupDetails === null ? "animate-pulse" : ""}
>
<DNSIcon
size={12}
className={
"fill-nb-gray-500 group-data-[state=active]/trigger:fill-netbird transition-all"
}
/>
{singularize("Nameservers", nameserversCount)}
</TabsTrigger>
{group.name !== "All" && (
<TabsTrigger
value={"setup-keys"}
className={groupDetails === null ? "animate-pulse" : ""}
>
<SetupKeysIcon
size={12}
className={
"fill-nb-gray-500 group-data-[state=active]/trigger:fill-netbird transition-all"
}
/>
{singularize("Setup Keys", setupKeysCount)}
</TabsTrigger>
)}
</TabsList>
<TabsContent value={"users"} className={"pb-8"}>
<GroupUsersSection users={groupDetails?.users} isLoading={isLoading} />
</TabsContent>
<TabsContent value={"peers"} className={"pb-8"}>
<GroupPeersSection
peers={groupDetails?.peersOfGroup}
isLoading={isLoading}
/>
</TabsContent>
<TabsContent value={"policies"} className={"pb-8"}>
<GroupPoliciesSection
policies={groupDetails?.policies}
isLoading={isLoading}
/>
</TabsContent>
<TabsContent value={"resources"} className={"pb-8"}>
<GroupResourcesSection
resources={groupDetails?.networkResources}
isLoading={isLoading}
/>
</TabsContent>
<TabsContent value={"network-routes"} className={"pb-8"}>
<GroupNetworkRoutesSection
routes={groupDetails?.routes}
isLoading={isLoading}
/>
</TabsContent>
<TabsContent value={"nameservers"} className={"pb-8"}>
<GroupNameserversSection
nameserverGroups={groupDetails?.nameservers}
isLoading={isLoading}
/>
</TabsContent>
<TabsContent value={"setup-keys"} className={"pb-8"}>
<GroupSetupKeysSection
setupKeys={groupDetails?.setupKeys}
isLoading={isLoading}
/>
</TabsContent>
</Tabs>
);
};

View File

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

View File

@@ -0,0 +1,56 @@
"use client";
import Paragraph from "@components/Paragraph";
import SkeletonTable from "@components/skeletons/SkeletonTable";
import { RestrictedAccess } from "@components/ui/RestrictedAccess";
import { usePortalElement } from "@hooks/usePortalElement";
import { ExternalLinkIcon, FolderGit2Icon } from "lucide-react";
import React, { lazy, Suspense } from "react";
import Breadcrumbs from "@/components/Breadcrumbs";
import InlineLink from "@/components/InlineLink";
import { usePermissions } from "@/contexts/PermissionsProvider";
import PageContainer from "@/layouts/PageContainer";
const GroupsTable = lazy(() => import("@/modules/groups/table/GroupsTable"));
export default function GroupsPage() {
const { permission } = usePermissions();
const { ref: headingRef, portalTarget } =
usePortalElement<HTMLHeadingElement>();
return (
<PageContainer>
<div className={"p-default py-6"}>
<Breadcrumbs>
<Breadcrumbs.Item
href={"/groups"}
label={"Groups"}
icon={<FolderGit2Icon size={14} />}
active
/>
</Breadcrumbs>
<h1 ref={headingRef}>Groups</h1>
<Paragraph>
Here is the overview of the groups of your organization. You can
delete the unused ones.
</Paragraph>
<Paragraph>
Learn more about{" "}
<InlineLink
href={"https://docs.netbird.io/how-to/manage-network-access"}
target={"_blank"}
>
Groups
<ExternalLinkIcon size={12} />
</InlineLink>
in our documentation.
</Paragraph>
</div>
<RestrictedAccess hasAccess={permission.groups.read} page={"Groups"}>
<Suspense fallback={<SkeletonTable />}>
<GroupsTable headingTarget={portalTarget} />
</Suspense>
</RestrictedAccess>
</PageContainer>
);
}

View File

@@ -7,7 +7,7 @@ import SkeletonTable from "@components/skeletons/SkeletonTable";
import { RestrictedAccess } from "@components/ui/RestrictedAccess";
import { usePortalElement } from "@hooks/usePortalElement";
import useFetchApi from "@utils/api";
import { ExternalLinkIcon } from "lucide-react";
import { ArrowUpRightIcon, ExternalLinkIcon } from "lucide-react";
import React, { lazy, Suspense } from "react";
import NetworkRoutesIcon from "@/assets/icons/NetworkRoutesIcon";
import PeersProvider from "@/contexts/PeersProvider";
@@ -16,6 +16,7 @@ import RoutesProvider from "@/contexts/RoutesProvider";
import { Route } from "@/interfaces/Route";
import PageContainer from "@/layouts/PageContainer";
import useGroupedRoutes from "@/modules/route-group/useGroupedRoutes";
import { Callout } from "@components/Callout";
const NetworkRoutesTable = lazy(
() => import("@/modules/route-group/NetworkRoutesTable"),
@@ -59,6 +60,17 @@ export default function NetworkRoutes() {
</InlineLink>
in our documentation.
</Paragraph>
<Callout className={"max-w-xl mt-3"} variant={"warning"}>
<span>
We recommend using the new Networks concept to easier visualise
and manage access to your resources.{" "}
<InlineLink href={"/networks"}>
Go to Networks
<ArrowUpRightIcon size={14} />
</InlineLink>
</span>
</Callout>
</div>
<RestrictedAccess hasAccess={permission.routes.read}>

View File

@@ -1,7 +1,15 @@
"use client";
import Breadcrumbs from "@components/Breadcrumbs";
import Button from "@components/Button";
import Card from "@components/Card";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@components/DropdownMenu";
import FullTooltip from "@components/FullTooltip";
import InlineLink from "@components/InlineLink";
import Separator from "@components/Separator";
@@ -12,12 +20,14 @@ import { cn } from "@utils/helpers";
import {
ArrowUpRightIcon,
HelpCircle,
MoreVertical,
PencilLineIcon,
ServerIcon,
ShieldCheckIcon,
ShieldXIcon,
Trash2,
} from "lucide-react";
import { useSearchParams } from "next/navigation";
import { useRouter, useSearchParams } from "next/navigation";
import React, { useMemo, useState } from "react";
import { useSWRConfig } from "swr";
import NetworkRoutesIcon from "@/assets/icons/NetworkRoutesIcon";
@@ -25,8 +35,10 @@ import { usePermissions } from "@/contexts/PermissionsProvider";
import { Network } from "@/interfaces/Network";
import PageContainer from "@/layouts/PageContainer";
import { NetworkInformationSquare } from "@/modules/networks/misc/NetworkInformationSquare";
import NetworkModal from "@/modules/networks/NetworkModal";
import { NetworkProvider } from "@/modules/networks/NetworkProvider";
import {
NetworkProvider,
useNetworksContext,
} from "@/modules/networks/NetworkProvider";
import { ResourcesSection } from "@/modules/networks/resources/ResourcesSection";
import { NetworkRoutingPeersSection } from "@/modules/networks/routing-peers/NetworkRoutingPeersSection";
@@ -77,35 +89,24 @@ function NetworkOverview({ network }: Readonly<{ network: Network }>) {
<div className={"flex justify-between max-w-6xl"}>
<div
className={cn(
"flex items-center",
!network.description && "gap-2",
)}
className={"w-full lg:w-1/2 flex justify-between items-center"}
>
<NetworkInformationSquare
name={network.name}
active={isActive}
size={"lg"}
description={network.description}
/>
{permission.networks.update && (
<button
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"
}
onClick={() => setNetworkModal(true)}
>
<PencilLineIcon size={18} />
</button>
)}
<NetworkModal
open={networkModal}
setOpen={setNetworkModal}
onUpdated={() => {
mutate(`/networks/${network.id}`);
}}
network={network}
/>
<div
className={cn(
"flex items-center w-full",
!network.description && "gap-2",
)}
>
<NetworkInformationSquare
name={network.name}
active={isActive}
size={"lg"}
description={network.description}
/>
</div>
<NetworkProvider network={network}>
<NetworkActions />
</NetworkProvider>
</div>
</div>
@@ -124,6 +125,56 @@ function NetworkOverview({ network }: Readonly<{ network: Network }>) {
);
}
function NetworkActions() {
const { permission } = usePermissions();
const { deleteNetwork, openEditNetworkModal, network } = useNetworksContext();
const router = useRouter();
if (!network) return;
return (
<DropdownMenu modal={false}>
<DropdownMenuTrigger
asChild={true}
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
}}
>
<Button variant={"secondary"} className={"!px-3"}>
<MoreVertical size={16} className={"shrink-0"} />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-auto" align="end">
<DropdownMenuItem
onClick={() => openEditNetworkModal(network)}
disabled={!permission.networks.update}
>
<div className={"flex gap-3 items-center"}>
<PencilLineIcon size={14} className={"shrink-0"} />
Rename
</div>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() =>
deleteNetwork(network).then(() => router.push("/networks"))
}
variant={"danger"}
disabled={!permission.networks.delete}
>
<div className={"flex gap-3 items-center"}>
<Trash2 size={14} className={"shrink-0"} />
Delete
</div>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
function NetworkInformationCard({ network }: Readonly<{ network: Network }>) {
const isHighlyAvailable = !!(
network?.routing_peers_count && network?.routing_peers_count >= 2
@@ -154,7 +205,7 @@ function NetworkInformationCard({ network }: Readonly<{ network: Network }>) {
const policyCount = network.policies?.length ?? 0;
return (
<Card>
<Card className={"w-full lg:w-1/2"}>
<Card.List>
<Card.ListItem
tooltip={false}

View File

@@ -4,8 +4,6 @@ import Breadcrumbs from "@components/Breadcrumbs";
import Button from "@components/Button";
import { Callout } from "@components/Callout";
import Card from "@components/Card";
import FancyToggleSwitch from "@components/FancyToggleSwitch";
import FullTooltip from "@components/FullTooltip";
import HelpText from "@components/HelpText";
import { Input } from "@components/Input";
import { Label } from "@components/Label";
@@ -32,18 +30,17 @@ import { cn } from "@utils/helpers";
import dayjs from "dayjs";
import { isEmpty, trim } from "lodash";
import {
ArrowRightIcon,
Barcode,
CalendarDays,
Cpu,
FlagIcon,
Globe,
History,
LockIcon,
MapPin,
MonitorSmartphoneIcon,
NetworkIcon,
PencilIcon,
TerminalSquare,
TimerResetIcon,
} from "lucide-react";
import { useRouter, useSearchParams } from "next/navigation";
@@ -66,8 +63,10 @@ import useGroupHelper from "@/modules/groups/useGroupHelper";
import { AccessiblePeersSection } from "@/modules/peer/AccessiblePeersSection";
import { PeerExpirationToggle } from "@/modules/peer/PeerExpirationToggle";
import { PeerNetworkRoutesSection } from "@/modules/peer/PeerNetworkRoutesSection";
import { PeerSSHToggle } from "@/modules/peer/PeerSSHToggle";
import { RDPButton } from "@/modules/remote-access/rdp/RDPButton";
import { SSHButton } from "@/modules/remote-access/ssh/SSHButton";
import Link from "next/link";
export default function PeerPage() {
const queryParameter = useSearchParams();
@@ -83,9 +82,8 @@ export default function PeerPage() {
const peerKey = useMemo(() => {
let id = peer?.id ?? "";
let ssh = peer?.ssh_enabled ? "1" : "0";
let expiration = peer?.login_expiration_enabled ? "1" : "0";
return `${id}-${ssh}-${expiration}`;
return `${id}-${expiration}`;
}, [peer]);
if (isRestricted) {
@@ -107,7 +105,7 @@ export default function PeerPage() {
);
return peer && !isLoading ? (
<PeerProvider peer={peer} key={peerId}>
<PeerProvider peer={peer} key={peerId} isPeerDetailPage={true}>
<PeerOverview key={peerKey} />
</PeerProvider>
) : (
@@ -141,8 +139,7 @@ function PeerOverview() {
const PeerGeneralInformation = () => {
const router = useRouter();
const { mutate } = useSWRConfig();
const { peer, user, peerGroups, openSSHDialog, update } = usePeer();
const [ssh, setSsh] = useState(peer.ssh_enabled);
const { peer, user, peerGroups, update } = usePeer();
const [name, setName] = useState(peer.name);
const [showEditNameModal, setShowEditNameModal] = useState(false);
const [loginExpiration, setLoginExpiration] = useState(
@@ -153,7 +150,7 @@ const PeerGeneralInformation = () => {
);
const [selectedGroups, setSelectedGroups, { getAllGroupCalls }] =
useGroupHelper({
initial: peerGroups,
initial: peerGroups?.filter((g) => g?.name !== "All"),
peer,
});
@@ -161,7 +158,6 @@ const PeerGeneralInformation = () => {
* Detect if there are changes in the peer information, if there are changes, then enable the save button.
*/
const { hasChanges, updateRef: updateHasChangedRef } = useHasChanges([
ssh,
selectedGroups,
loginExpiration,
inactivityExpiration,
@@ -174,7 +170,6 @@ const PeerGeneralInformation = () => {
if (permission.peers.update) {
const updateRequest = update({
name: newName ?? name,
ssh,
loginExpiration,
inactivityExpiration,
});
@@ -190,7 +185,6 @@ const PeerGeneralInformation = () => {
mutate("/peers/" + peer.id);
mutate("/groups");
updateHasChangedRef([
ssh,
selectedGroups,
loginExpiration,
inactivityExpiration,
@@ -245,9 +239,21 @@ const PeerGeneralInformation = () => {
</h1>
<LoginExpiredBadge loginExpired={peer.login_expired} />
</div>
<div className={"flex items-center gap-8"}>
<Paragraph className={"flex items-center"}>{user?.email}</Paragraph>
</div>
{(user?.id || user?.email) && (
<div className={"flex items-center gap-8"}>
<Paragraph className={"flex items-center"}>
<Link
href={`/team/user?id=${user?.id}`}
className={
"hover:text-nb-gray-200 transition-all flex items-center gap-1"
}
>
{user?.email || user?.id}
<ArrowRightIcon size={14} />
</Link>
</Paragraph>
</div>
)}
</div>
<div className={"flex gap-4"}>
<Button
@@ -314,41 +320,7 @@ const PeerGeneralInformation = () => {
)}
</div>
<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={permission.peers.update}
>
<FancyToggleSwitch
value={ssh}
disabled={!permission.peers.update}
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>
<PeerSSHToggle />
{/* Remote Access Buttons */}
<div>

View File

@@ -19,9 +19,9 @@ import { useAccount } from "@/modules/account/useAccount";
import AuthenticationTab from "@/modules/settings/AuthenticationTab";
import ClientSettingsTab from "@/modules/settings/ClientSettingsTab";
import DangerZoneTab from "@/modules/settings/DangerZoneTab";
import GroupsTab from "@/modules/settings/GroupsTab";
import NetworkSettingsTab from "@/modules/settings/NetworkSettingsTab";
import PermissionsTab from "@/modules/settings/PermissionsTab";
import GroupsSettings from "@/modules/settings/GroupsSettings";
export default function NetBirdSettings() {
const queryParams = useSearchParams();
@@ -81,7 +81,7 @@ export default function NetBirdSettings() {
<div className={"border-l border-nb-gray-930 w-full"}>
{account && <AuthenticationTab account={account} />}
{account && <PermissionsTab account={account} />}
{account && <GroupsTab account={account} />}
{account && <GroupsSettings account={account} />}
{account && <NetworkSettingsTab account={account} />}
{account && <ClientSettingsTab account={account} />}
{account && <DangerZoneTab account={account} />}

View File

@@ -9,6 +9,7 @@ import { notify } from "@components/Notification";
import Paragraph from "@components/Paragraph";
import { PeerGroupSelector } from "@components/PeerGroupSelector";
import Separator from "@components/Separator";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@components/Tabs";
import FullScreenLoading from "@components/ui/FullScreenLoading";
import { RestrictedAccess } from "@components/ui/RestrictedAccess";
import useRedirect from "@hooks/useRedirect";
@@ -16,7 +17,15 @@ import { IconCirclePlus, IconSettings2 } from "@tabler/icons-react";
import useFetchApi, { useApiCall } from "@utils/api";
import { generateColorFromString } from "@utils/helpers";
import dayjs from "dayjs";
import { Ban, GalleryHorizontalEnd, History, Mail, User2 } from "lucide-react";
import {
Ban,
GalleryHorizontalEnd,
History,
KeyRoundIcon,
Mail,
MonitorSmartphoneIcon,
User2,
} from "lucide-react";
import { useRouter, useSearchParams } from "next/navigation";
import React, { useMemo, useState } from "react";
import { useSWRConfig } from "swr";
@@ -33,6 +42,7 @@ import useGroupHelper from "@/modules/groups/useGroupHelper";
import { useGroupIdsToGroups } from "@/modules/groups/useGroupIdsToGroups";
import UserBlockCell from "@/modules/users/table-cells/UserBlockCell";
import UserStatusCell from "@/modules/users/table-cells/UserStatusCell";
import { UserPeersSection } from "@/modules/users/UserPeersSection";
import { UserRoleSelector } from "@/modules/users/UserRoleSelector";
export default function UserPage() {
@@ -80,6 +90,7 @@ type Props = {
function UserOverview({ user, initialGroups }: Readonly<Props>) {
const router = useRouter();
const userRequest = useApiCall<User>("/users");
const isServiceUser = !!user?.is_service_user;
const { mutate } = useSWRConfig();
const { loggedInUser, isOwnerOrAdmin, isUser } = useLoggedInUser();
const isLoggedInUser = loggedInUser ? loggedInUser?.id === user.id : false;
@@ -91,7 +102,6 @@ function UserOverview({ user, initialGroups }: Readonly<Props>) {
});
const [role, setRole] = useState(user.role || Role.User);
const { hasChanges, updateRef: updateChangesRef } = useHasChanges([
role,
selectedGroups,
@@ -114,13 +124,24 @@ function UserOverview({ user, initialGroups }: Readonly<Props>) {
`/${user.id}`,
)
.then(() => {
mutate(`/users?service_user=${user.is_service_user}`);
mutate(`/users?service_user=${isServiceUser}`);
updateChangesRef([role, selectedGroups]);
}),
loadingMessage: "Saving changes...",
});
};
const isProfilePage = !!user?.is_current && !isServiceUser;
const canViewTokens = permission?.pats?.read;
const canViewPeers = permission?.peers?.read;
const showAccessTokens = (user?.is_current || isServiceUser) && canViewTokens;
const showPeers = !isServiceUser && canViewPeers;
const showTabs = isProfilePage && showPeers && showAccessTokens;
const showSeparator = !showTabs;
const [tab, setTab] = useState(isServiceUser ? "access-tokens" : "peers");
return (
<PageContainer>
<div className={"p-default py-6 mb-4"}>
@@ -132,7 +153,7 @@ function UserOverview({ user, initialGroups }: Readonly<Props>) {
icon={<TeamIcon size={13} />}
/>
{user.is_service_user ? (
{isServiceUser ? (
<Breadcrumbs.Item
href={"/team/service-users"}
label={"Service Users"}
@@ -158,7 +179,7 @@ function UserOverview({ user, initialGroups }: Readonly<Props>) {
"w-10 h-10 rounded-full relative flex items-center justify-center text-white uppercase text-md font-medium bg-nb-gray-900"
}
style={
user.is_service_user
isServiceUser
? {
color: "white",
}
@@ -171,13 +192,13 @@ function UserOverview({ user, initialGroups }: Readonly<Props>) {
}
}
>
{user.is_service_user ? (
{isServiceUser ? (
<IconSettings2 size={16} />
) : (
user?.name?.charAt(0) || user?.id?.charAt(0)
)}
</div>
<h1 className={"flex items-center gap-3"}>
<h1 className={"flex items-center gap-3"} title={user?.id}>
{user.name || user.id}
</h1>
</div>
@@ -188,7 +209,7 @@ function UserOverview({ user, initialGroups }: Readonly<Props>) {
variant={"default"}
className={"w-full"}
onClick={() => {
user.is_service_user
isServiceUser
? router.push("/team/service-users")
: router.push("/team/users");
}}
@@ -212,7 +233,7 @@ function UserOverview({ user, initialGroups }: Readonly<Props>) {
<div className={"flex gap-10 w-full mt-8 max-w-6xl items-start"}>
<UserInformationCard user={user} />
<div className={"flex flex-col gap-8 w-1/2 "}>
{!user.is_service_user && isOwnerOrAdmin && (
{!isServiceUser && isOwnerOrAdmin && (
<div>
<Label>Auto-assigned groups</Label>
<HelpText>
@@ -238,7 +259,7 @@ function UserOverview({ user, initialGroups }: Readonly<Props>) {
<UserRoleSelector
value={role}
onChange={setRole}
hideOwner={user.is_service_user}
hideOwner={isServiceUser}
currentUser={user}
disabled={isLoggedInUser || !permission.users.update}
/>
@@ -248,38 +269,65 @@ function UserOverview({ user, initialGroups }: Readonly<Props>) {
</div>
</div>
{(user.is_current || user.is_service_user) && permission.pats.read && (
<>
<Separator />
<div className={"px-8 py-6"}>
<div className={"max-w-6xl"}>
<div className={"flex justify-between items-center"}>
<div>
<h2>Access Tokens</h2>
<Paragraph>
Access tokens give access to NetBird API.
</Paragraph>
</div>
<div className={"inline-flex gap-4 justify-end"}>
{showSeparator && <Separator />}
<Tabs
defaultValue={tab}
onValueChange={setTab}
value={tab}
className={"pb-0 mb-0"}
>
<TabsList justify={"start"} className={"px-8"} hidden={!showTabs}>
{showPeers && (
<TabsTrigger value={"peers"}>
<MonitorSmartphoneIcon size={16} />
Peers
</TabsTrigger>
)}
{showAccessTokens && (
<TabsTrigger value={"access-tokens"}>
<KeyRoundIcon size={16} />
Access Tokens
</TabsTrigger>
)}
</TabsList>
{showPeers && (
<TabsContent value={"peers"} className={"pb-8"}>
<UserPeersSection user={user} />
</TabsContent>
)}
{showAccessTokens && (
<TabsContent value={"access-tokens"} className={"pb-8"}>
<div className={"px-8"}>
<div className={"max-w-6xl"}>
<div className={"flex justify-between items-center"}>
<div>
<CreateAccessTokenModal user={user}>
<Button
variant={"primary"}
data-cy={"access-token-open-modal"}
disabled={!permission.pats.create}
>
<IconCirclePlus size={16} />
Create Access Token
</Button>
</CreateAccessTokenModal>
<h2>Access Tokens</h2>
<Paragraph>
Access tokens give access to NetBird API.
</Paragraph>
</div>
<div className={"inline-flex gap-4 justify-end"}>
<div>
<CreateAccessTokenModal user={user}>
<Button
variant={"primary"}
data-cy={"access-token-open-modal"}
disabled={!permission.pats.create}
>
<IconCirclePlus size={16} />
Create Access Token
</Button>
</CreateAccessTokenModal>
</div>
</div>
</div>
<AccessTokensTable user={user} />
</div>
<AccessTokensTable user={user} />
</div>
</div>
</>
)}
</TabsContent>
)}
</Tabs>
</PageContainer>
);
}

View File

@@ -20,6 +20,7 @@ import {
useNetBirdClient,
} from "@/modules/remote-access/useNetBirdClient";
import { cn } from "@utils/helpers";
import { isNetbirdSSHProtocolSupported } from "@utils/version";
export default function RDPPage() {
const { peerId } = useRDPQueryParams();
@@ -31,7 +32,7 @@ export default function RDPPage() {
} = useFetchApi<Peer>(`/peers/${peerId}`, true, false, !!peerId);
return (
<div className={"w-screen h-screen overflow-hidden"}>
<div className={"w-screen h-screen overflow-hidden fixed inset-0"}>
{peerId && peer && !isLoading ? (
<RDPSession key={peer.id} peer={peer} />
) : (
@@ -55,7 +56,7 @@ function RDPSession({ peer }: Props) {
useEffect(() => {
document.title = `${peer.name} - ${peer.ip} - RDP`;
}, []);
}, [peer.ip, peer.name, connected, rdp]);
const sendErrorNotification = (title: string, message: string) => {
notify({
@@ -84,7 +85,12 @@ function RDPSession({ peer }: Props) {
try {
setCredentials(rdpCredentials);
setIsNetBirdConnecting(true);
await client.connectTemporary(peer.id, [`tcp/${rdpCredentials.port}`]);
const protocol = isNetbirdSSHProtocolSupported(peer.version)
? "netbird-ssh"
: "tcp";
await client.connectTemporary(peer.id, [
`${protocol}/${rdpCredentials.port}`,
]);
setIsNetBirdConnecting(false);
} catch (error) {
sendErrorNotification(
@@ -104,6 +110,7 @@ function RDPSession({ peer }: Props) {
port: credentials.port,
username: credentials.username,
password: credentials.password,
domain: credentials.domain,
width: window.innerWidth,
height: window.innerHeight,
});

View File

@@ -12,6 +12,10 @@ import {
NetBirdStatus,
useNetBirdClient,
} from "@/modules/remote-access/useNetBirdClient";
import {
isNativeSSHSupported,
isNetbirdSSHProtocolSupported,
} from "@utils/version";
export default function SSHPage() {
const { peerId, username, port } = useSSHQueryParams();
@@ -86,7 +90,11 @@ function SSHTerminal({ username, port, peer }: Props) {
if (isSSHConnected || isSSHConnecting) return;
connected.current = false;
try {
const rules = [`tcp/${port}`];
const aclPort = isNativeSSHSupported(peer.version) ? "22022" : port;
const protocol = isNetbirdSSHProtocolSupported(peer.version)
? "netbird-ssh"
: "tcp";
const rules = [`${protocol}/${aclPort}`];
await client?.connectTemporary(peer.id, rules);
await ssh({
hostname: peer.ip,
@@ -106,8 +114,13 @@ function SSHTerminal({ username, port, peer }: Props) {
if (!peer.id) return;
if (connected.current) return;
connected.current = true;
try {
const rules = [`tcp/${port}`];
const aclPort = isNativeSSHSupported(peer.version) ? "22022" : port;
const protocol = isNetbirdSSHProtocolSupported(peer.version)
? "netbird-ssh"
: "tcp";
const rules = [`${protocol}/${aclPort}`];
await client?.connectTemporary(peer.id, rules);
const res = await ssh({
hostname: peer.ip,
@@ -118,7 +131,7 @@ function SSHTerminal({ username, port, peer }: Props) {
sshConnectedOnce.current = true;
}
} catch (error) {
console.error("Connection failed:", error);
console.error("Connection error:", error);
}
};

View File

@@ -167,4 +167,10 @@ p {
.xterm-viewport {
@apply m-0 p-0 box-border;
}
}
/* Control Center */
.react-flow__node-groupNode .selected{
@apply border-netbird;
}

View File

@@ -0,0 +1,22 @@
import { iconProperties, IconProps } from "@/assets/icons/IconProperties";
export default function ControlCenterIcon(props: IconProps) {
return (
<svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
fill="currentColor"
viewBox="0 0 24 24"
{...iconProperties(props)}
>
<path d="M5 3a2 2 0 0 0-2 2v2a2 2 0 0 0 2 2h2a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2H5Zm0 12a2 2 0 0 0-2 2v2a2 2 0 0 0 2 2h2a2 2 0 0 0 2-2v-2a2 2 0 0 0-2-2H5Zm12 0a2 2 0 0 0-2 2v2a2 2 0 0 0 2 2h2a2 2 0 0 0 2-2v-2a2 2 0 0 0-2-2h-2Zm0-12a2 2 0 0 0-2 2v2a2 2 0 0 0 2 2h2a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2h-2Z" />
<path
fillRule="evenodd"
d="M10 6.5a1 1 0 0 1 1-1h2a1 1 0 1 1 0 2h-2a1 1 0 0 1-1-1ZM10 18a1 1 0 0 1 1-1h2a1 1 0 1 1 0 2h-2a1 1 0 0 1-1-1Zm-4-4a1 1 0 0 1-1-1v-2a1 1 0 1 1 2 0v2a1 1 0 0 1-1 1Zm12 0a1 1 0 0 1-1-1v-2a1 1 0 1 1 2 0v2a1 1 0 0 1-1 1Z"
clipRule="evenodd"
/>
</svg>
);
}

View File

@@ -0,0 +1,19 @@
import { iconProperties, IconProps } from "@/assets/icons/IconProperties";
export default function JumpcloudIcon(props: Readonly<IconProps>) {
return (
<svg
width="167"
height="82"
viewBox="0 0 167 82"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...iconProperties(props)}
>
<path
d="M166.911 58.3592C166.911 64.3815 164.519 70.1571 160.26 74.4155C156.002 78.6739 150.226 81.0662 144.204 81.0662H137.961C137.31 73.4972 129.5 67.0612 118.46 64.0722C121.244 61.3253 123.148 57.8124 123.931 53.9803C124.713 50.1482 124.338 46.17 122.854 42.5515C121.369 38.933 118.842 35.8378 115.594 33.6594C112.345 31.481 108.522 30.3178 104.611 30.3178C100.7 30.3178 96.8772 31.481 93.6289 33.6594C90.3805 35.8378 87.8534 38.933 86.3689 42.5515C84.8843 46.17 84.5094 50.1482 85.2918 53.9803C86.0743 57.8124 87.9786 61.3253 90.7628 64.0722C85.5111 65.3278 80.6301 67.8055 76.5167 71.3037C73.9207 69.8152 71.1411 68.6726 68.2487 67.9049C70.6422 65.5587 72.2829 62.5529 72.9614 59.2707C73.6399 55.9884 73.3255 52.5784 72.0584 49.4755C70.7913 46.3726 68.6288 43.7174 65.8467 41.8484C63.0646 39.9793 59.7888 38.9812 56.4372 38.9812C53.0855 38.9812 49.8098 39.9793 47.0277 41.8484C44.2455 43.7174 42.0831 46.3726 40.816 49.4755C39.5488 52.5784 39.2345 55.9884 39.913 59.2707C40.5915 62.5529 42.2321 65.5587 44.6257 67.9049C35.9237 70.3154 29.5841 75.1364 28.2342 80.9698H21.991C16.0936 80.7777 10.502 78.2999 6.39821 74.0603C2.2944 69.8206 0 64.1513 0 58.2508C0 52.3503 2.2944 46.681 6.39821 42.4413C10.502 38.2016 16.0936 35.7238 21.991 35.5317C24.8814 35.5419 27.7438 36.0981 30.4278 37.1709C32.2478 33.2162 35.1686 29.8695 38.8407 27.5312C42.5128 25.1928 46.7807 23.9618 51.1341 23.9854C51.6885 23.9854 52.2429 23.9854 52.7732 23.9854C53.9093 18.1059 56.8018 12.7093 61.0689 8.50798C65.336 4.30669 70.7769 1.49837 76.6733 0.453829C82.5698 -0.590709 88.6443 0.177651 94.095 2.65746C99.546 5.13728 104.116 9.21191 107.203 14.3434C110.733 13.2708 114.463 13.023 118.104 13.6193C121.746 14.2155 125.202 15.6397 128.206 17.7822C131.21 19.9247 133.682 22.7283 135.432 25.977C137.182 29.2257 138.162 32.8326 138.298 36.52C141.665 35.6031 145.198 35.4762 148.622 36.1492C152.046 36.8222 155.269 38.277 158.038 40.4001C160.808 42.5233 163.049 45.2574 164.588 48.3892C166.127 51.5211 166.922 54.9661 166.911 58.4557V58.3592Z"
fill="#4CC2BF"
/>
</svg>
);
}

View File

@@ -0,0 +1,27 @@
import { iconProperties, IconProps } from "@/assets/icons/IconProperties";
export default function OIDCIcon(props: Readonly<IconProps>) {
return (
<svg
width="173"
height="174"
viewBox="0 0 173 174"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...iconProperties(props)}
>
<path
d="M76.3945 173.48L103.325 154.065L102.072 0L76.3945 20.041V173.48Z"
fill="#FF8E00"
/>
<path
d="M76.7077 173.48C-24.0221 157.466 -26.8926 69.7689 76.0814 50.7288L76.3945 68.8909C3.35034 81.0694 12.6045 146.598 76.3945 156.257L76.7077 173.48Z"
fill="white"
/>
<path
d="M103.011 68.2646C115.468 68.3493 126.32 74.0515 137.144 79.8508L121.174 91.7502H172.216L172.529 56.9916L156.558 68.8909C140.397 60.7278 125.542 50.9315 103.011 50.7288V68.2646Z"
fill="white"
/>
</svg>
);
}

View File

@@ -1,12 +0,0 @@
<svg width="573" height="148" viewBox="0 0 573 148" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_4_4)">
<path d="M0.739014 125.602V33.09H28.239C34.491 33.09 39.919 34.274 44.524 36.638C49.128 39.004 52.698 42.341 55.233 46.65C57.767 50.958 59.034 56.071 59.034 61.984V96.58C59.034 102.41 57.767 107.5 55.233 111.85C52.698 116.203 49.128 119.581 44.523 121.99C39.918 124.397 34.491 125.601 28.239 125.601L0.739014 125.602ZM16.58 111.028H28.24C32.801 111.028 36.433 109.741 39.138 107.163C41.841 104.587 43.193 101.06 43.193 96.581V61.984C43.193 57.592 41.841 54.107 39.138 51.529C36.433 48.952 32.801 47.663 28.239 47.663H16.58V111.028ZM76.396 125.602V33.09H95.786L121.512 107.86C121.216 104.673 120.942 101.483 120.688 98.292C120.386 94.5366 120.154 90.776 119.991 87.012C119.821 83.169 119.738 79.812 119.738 76.938V33.09H134.185V125.602H114.795L89.323 50.832C89.491 53.283 89.703 56.239 89.956 59.702C90.2137 63.2478 90.4251 66.7967 90.59 70.348C90.758 73.982 90.844 77.318 90.844 80.36V125.602H76.396ZM181.582 126.869C175.245 126.869 169.752 125.812 165.107 123.701C160.46 121.591 156.889 118.569 154.398 114.64C151.905 110.711 150.616 106.086 150.533 100.763H166.374C166.374 104.565 167.747 107.543 170.494 109.698C173.238 111.852 176.976 112.929 181.709 112.929C186.271 112.929 189.839 111.874 192.417 109.761C194.993 107.65 196.282 104.735 196.282 101.017C196.282 97.892 195.374 95.167 193.558 92.842C191.74 90.52 189.142 88.936 185.764 88.09L175.119 85.175C167.852 83.318 162.256 79.979 158.327 75.164C154.398 70.348 152.434 64.518 152.434 57.675C152.434 52.438 153.616 47.875 155.982 43.988C158.347 40.103 161.705 37.103 166.058 34.99C170.408 32.88 175.54 31.822 181.455 31.822C190.409 31.822 197.506 34.125 202.745 38.729C207.983 43.335 210.645 49.523 210.73 57.295H194.888C194.888 53.663 193.704 50.812 191.34 48.741C188.974 46.671 185.637 45.636 181.328 45.636C177.188 45.636 173.978 46.608 171.697 48.551C169.416 50.495 168.275 53.24 168.275 56.788C168.275 60 169.141 62.724 170.873 64.962C172.603 67.202 175.119 68.786 178.413 69.714L189.439 72.756C196.789 74.616 202.407 77.932 206.294 82.704C210.179 87.478 212.124 93.371 212.124 100.383C212.124 105.623 210.856 110.248 208.322 114.26C205.787 118.273 202.239 121.378 197.677 123.574C193.114 125.77 187.748 126.869 181.582 126.869ZM257.366 126.869C251.366 126.869 246.17 125.729 241.778 123.448C237.384 121.167 233.985 117.957 231.577 113.816C229.169 109.678 227.965 104.818 227.965 99.242V59.45C227.965 53.874 229.169 49.017 231.577 44.876C233.984 40.738 237.384 37.526 241.778 35.245C246.17 32.964 251.366 31.823 257.366 31.823C263.449 31.823 268.665 32.963 273.017 35.245C277.367 37.526 280.747 40.738 283.156 44.876C285.563 49.016 286.767 53.874 286.767 59.45V99.243C286.767 104.819 285.563 109.679 283.156 113.817C280.748 117.957 277.346 121.167 272.954 123.449C268.56 125.729 263.364 126.869 257.366 126.869ZM257.366 113.183C261.758 113.183 265.265 111.915 267.885 109.381C270.502 106.846 271.813 103.468 271.813 99.242V59.45C271.813 55.227 270.503 51.846 267.885 49.312C265.265 46.777 261.758 45.51 257.366 45.51C252.972 45.51 249.466 46.777 246.848 49.312C244.228 51.846 242.918 55.227 242.918 59.45V99.243C242.918 103.469 244.228 106.847 246.848 109.382C249.465 111.916 252.972 113.183 257.366 113.183ZM257.366 87.583C254.915 87.583 252.909 86.781 251.346 85.175C249.782 83.571 249.002 81.5 249.002 78.965C249.002 76.431 249.762 74.403 251.283 72.883C252.803 71.362 254.831 70.601 257.366 70.601C259.901 70.601 261.928 71.361 263.449 72.883C264.969 74.403 265.73 76.431 265.73 78.966C265.73 81.5 264.97 83.571 263.45 85.176C261.928 86.781 259.9 87.583 257.366 87.583Z" fill="black"/>
<path d="M332.69 126.999C329.057 126.999 326.164 125.941 324.01 123.831C321.855 121.72 320.778 118.847 320.778 115.213C320.778 111.581 321.855 108.686 324.01 106.532C326.164 104.378 329.057 103.3 332.69 103.3C336.322 103.3 339.217 104.378 341.372 106.532C343.526 108.686 344.603 111.582 344.603 115.213C344.603 118.847 343.526 121.72 341.372 123.831C339.217 125.941 336.322 126.999 332.691 126.999H332.69ZM381.862 125.732V33.219H437.369V47.032H397.449V71.112H432.934V84.798H397.45V111.918H437.37V125.732H381.862ZM484.766 126.999C475.725 126.999 468.65 124.528 463.539 119.585C458.426 114.643 455.872 107.906 455.872 99.372V33.219H471.84V99.245C471.84 103.639 472.937 107.061 475.135 109.51C477.331 111.962 480.54 113.185 484.766 113.185C488.905 113.185 492.095 111.962 494.334 109.51C496.572 107.06 497.693 103.639 497.693 99.245V33.219H513.66V99.372C513.66 107.906 511.126 114.642 506.057 119.585C500.987 124.528 493.891 126.999 484.767 126.999H484.766Z" fill="#686868"/>
<path d="M549 0H573V148H549V0Z" fill="#FFCC03"/>
</g>
<defs>
<clipPath id="clip0_4_4">
<rect width="573" height="148" fill="white"/>
</clipPath>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 4.7 KiB

View File

@@ -1,12 +0,0 @@
<svg width="573" height="148" viewBox="0 0 573 148" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_4_4)">
<path d="M0.739014 125.602V33.09H28.239C34.491 33.09 39.919 34.274 44.524 36.638C49.128 39.004 52.698 42.341 55.233 46.65C57.767 50.958 59.034 56.071 59.034 61.984V96.58C59.034 102.41 57.767 107.5 55.233 111.85C52.698 116.203 49.128 119.581 44.523 121.99C39.918 124.397 34.491 125.601 28.239 125.601L0.739014 125.602ZM16.58 111.028H28.24C32.801 111.028 36.433 109.741 39.138 107.163C41.841 104.587 43.193 101.06 43.193 96.581V61.984C43.193 57.592 41.841 54.107 39.138 51.529C36.433 48.952 32.801 47.663 28.239 47.663H16.58V111.028ZM76.396 125.602V33.09H95.786L121.512 107.86C121.216 104.673 120.942 101.483 120.688 98.292C120.386 94.5366 120.154 90.776 119.991 87.012C119.821 83.169 119.738 79.812 119.738 76.938V33.09H134.185V125.602H114.795L89.323 50.832C89.491 53.283 89.703 56.239 89.956 59.702C90.2137 63.2478 90.4251 66.7967 90.59 70.348C90.758 73.982 90.844 77.318 90.844 80.36V125.602H76.396ZM181.582 126.869C175.245 126.869 169.752 125.812 165.107 123.701C160.46 121.591 156.889 118.569 154.398 114.64C151.905 110.711 150.616 106.086 150.533 100.763H166.374C166.374 104.565 167.747 107.543 170.494 109.698C173.238 111.852 176.976 112.929 181.709 112.929C186.271 112.929 189.839 111.874 192.417 109.761C194.993 107.65 196.282 104.735 196.282 101.017C196.282 97.892 195.374 95.167 193.558 92.842C191.74 90.52 189.142 88.936 185.764 88.09L175.119 85.175C167.852 83.318 162.256 79.979 158.327 75.164C154.398 70.348 152.434 64.518 152.434 57.675C152.434 52.438 153.616 47.875 155.982 43.988C158.347 40.103 161.705 37.103 166.058 34.99C170.408 32.88 175.54 31.822 181.455 31.822C190.409 31.822 197.506 34.125 202.745 38.729C207.983 43.335 210.645 49.523 210.73 57.295H194.888C194.888 53.663 193.704 50.812 191.34 48.741C188.974 46.671 185.637 45.636 181.328 45.636C177.188 45.636 173.978 46.608 171.697 48.551C169.416 50.495 168.275 53.24 168.275 56.788C168.275 60 169.141 62.724 170.873 64.962C172.603 67.202 175.119 68.786 178.413 69.714L189.439 72.756C196.789 74.616 202.407 77.932 206.294 82.704C210.179 87.478 212.124 93.371 212.124 100.383C212.124 105.623 210.856 110.248 208.322 114.26C205.787 118.273 202.239 121.378 197.677 123.574C193.114 125.77 187.748 126.869 181.582 126.869ZM257.366 126.869C251.366 126.869 246.17 125.729 241.778 123.448C237.384 121.167 233.985 117.957 231.577 113.816C229.169 109.678 227.965 104.818 227.965 99.242V59.45C227.965 53.874 229.169 49.017 231.577 44.876C233.984 40.738 237.384 37.526 241.778 35.245C246.17 32.964 251.366 31.823 257.366 31.823C263.449 31.823 268.665 32.963 273.017 35.245C277.367 37.526 280.747 40.738 283.156 44.876C285.563 49.016 286.767 53.874 286.767 59.45V99.243C286.767 104.819 285.563 109.679 283.156 113.817C280.748 117.957 277.346 121.167 272.954 123.449C268.56 125.729 263.364 126.869 257.366 126.869ZM257.366 113.183C261.758 113.183 265.265 111.915 267.885 109.381C270.502 106.846 271.813 103.468 271.813 99.242V59.45C271.813 55.227 270.503 51.846 267.885 49.312C265.265 46.777 261.758 45.51 257.366 45.51C252.972 45.51 249.466 46.777 246.848 49.312C244.228 51.846 242.918 55.227 242.918 59.45V99.243C242.918 103.469 244.228 106.847 246.848 109.382C249.465 111.916 252.972 113.183 257.366 113.183ZM257.366 87.583C254.915 87.583 252.909 86.781 251.346 85.175C249.782 83.571 249.002 81.5 249.002 78.965C249.002 76.431 249.762 74.403 251.283 72.883C252.803 71.362 254.831 70.601 257.366 70.601C259.901 70.601 261.928 71.361 263.449 72.883C264.969 74.403 265.73 76.431 265.73 78.966C265.73 81.5 264.97 83.571 263.45 85.176C261.928 86.781 259.9 87.583 257.366 87.583Z" fill="black"/>
<path d="M332.69 126.999C329.057 126.999 326.164 125.941 324.01 123.831C321.855 121.72 320.778 118.847 320.778 115.213C320.778 111.581 321.855 108.686 324.01 106.532C326.164 104.378 329.057 103.3 332.69 103.3C336.322 103.3 339.217 104.378 341.372 106.532C343.526 108.686 344.603 111.582 344.603 115.213C344.603 118.847 343.526 121.72 341.372 123.831C339.217 125.941 336.322 126.999 332.691 126.999H332.69ZM381.862 125.732V33.219H437.369V47.032H397.449V71.112H432.934V84.798H397.45V111.918H437.37V125.732H381.862ZM484.766 126.999C475.725 126.999 468.65 124.528 463.539 119.585C458.426 114.643 455.872 107.906 455.872 99.372V33.219H471.84V99.245C471.84 103.639 472.937 107.061 475.135 109.51C477.331 111.962 480.54 113.185 484.766 113.185C488.905 113.185 492.095 111.962 494.334 109.51C496.572 107.06 497.693 103.639 497.693 99.245V33.219H513.66V99.372C513.66 107.906 511.126 114.642 506.057 119.585C500.987 124.528 493.891 126.999 484.767 126.999H484.766Z" fill="#359CEF"/>
<path d="M549 0H573V148H549V0Z" fill="#FFCC03"/>
</g>
<defs>
<clipPath id="clip0_4_4">
<rect width="573" height="148" fill="white"/>
</clipPath>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 261 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 283 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 290 KiB

View File

@@ -32,6 +32,10 @@ const variants = cva("", {
green: ["bg-green-950 border-green-500 border text-green-400"],
netbird: ["bg-netbird-950 border-netbird-500 border text-netbird-500"],
},
size: {
default: "text-[0.75rem] py-1.5 px-3",
xs: "text-[0.6rem] py-[0.3rem] px-2",
},
hover: {
none: [],
blue: ["hover:bg-sky-200"],
@@ -42,7 +46,7 @@ const variants = cva("", {
red: ["hover:bg-red-950/40"],
gray: ["hover:bg-nb-gray-900"],
grayer: ["hover:bg-nb-gray-900"],
"gray-ghost": ["hover:bg-nb-gray-900"],
"gray-ghost": ["hover:bg-nb-gray-800 cursor-pointer"],
green: ["hover:bg-green-950/50"],
netbird: ["hover:bg-netbird-950/50"],
},
@@ -53,6 +57,7 @@ export default function Badge({
children,
className,
variant = "blue",
size = "default",
useHover = false,
disabled = false,
...props
@@ -60,8 +65,8 @@ export default function Badge({
return (
<div
className={cn(
"relative z-10 cursor-inherit whitespace-nowrap rounded-md text-[12px] py-1.5 px-3 font-normal flex gap-1.5 items-center justify-center transition-all",
variants({ variant, hover: useHover ? variant : "none" }),
"relative z-10 cursor-inherit whitespace-nowrap rounded-md font-normal flex gap-1.5 items-center justify-center transition-all",
variants({ variant, hover: useHover ? variant : "none", size }),
disabled && "cursor-not-allowed opacity-50 select-none",
className,
)}

View File

@@ -34,7 +34,7 @@ const ButtonGroupButton = forwardRef(
border={2}
rounded={false}
className={cn(
"first:border-l-0 last:border-r-0 border-t-0 border-b-0 h-[41px]",
"first:border-l-0 last:border-r-0 border-t-0 border-b-0 h-[40px]",
"!py-2.5 !px-4",
className,
)}

View File

@@ -81,7 +81,7 @@ const menuItemVariants = cva("", {
variants: {
variant: {
default:
"dark:focus:bg-nb-gray-900 dark:focus:text-gray-50 dark:text-gray-400 dark:data-[state=open]:bg-nb-gray-900 dark:data-[state=open]:text-gray-50",
"dark:focus:bg-nb-gray-900 dark:focus:text-gray-50 dark:text-nb-gray-300 dark:data-[state=open]:bg-nb-gray-900 dark:data-[state=open]:text-gray-50",
danger:
"dark:focus:bg-red-900/20 dark:focus:text-red-500 dark:text-red-500",
},

View File

@@ -0,0 +1,43 @@
"use client";
import * as HoverCardPrimitive from "@radix-ui/react-hover-card";
import { cn } from "@utils/helpers";
import * as React from "react";
import { TooltipVariants, tooltipVariants } from "./Tooltip";
const HoverCard = HoverCardPrimitive.Root;
const HoverCardTrigger = HoverCardPrimitive.Trigger;
const HoverCardContent = React.forwardRef<
React.ElementRef<typeof HoverCardPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof HoverCardPrimitive.Content> &
TooltipVariants
>(
(
{
className = "px-4 py-2.5",
sideOffset = 7,
side = "top",
variant = "default",
...props
},
ref,
) => (
<HoverCardPrimitive.Portal>
<HoverCardPrimitive.Content
ref={ref}
asChild={true}
side={side}
sideOffset={sideOffset}
className={cn(tooltipVariants({ variant }), className)}
{...props}
>
<div>{props.children}</div>
</HoverCardPrimitive.Content>
</HoverCardPrimitive.Portal>
),
);
HoverCardContent.displayName = HoverCardPrimitive.Content.displayName;
export { HoverCard, HoverCardContent, HoverCardTrigger };

View File

@@ -6,7 +6,7 @@ import { cva, type VariantProps } from "class-variance-authority";
import * as React from "react";
const labelVariants = cva(
"text-sm font-medium tracking-wider leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 mb-1 inline-block dark:text-nb-gray-200 flex items-center gap-2",
"text-sm font-medium tracking-wider leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 mb-1.5 inline-block dark:text-nb-gray-200 flex items-center gap-2",
);
const Label = React.forwardRef<

View File

@@ -42,8 +42,8 @@ import { NetworkResource } from "@/interfaces/Network";
import type { Peer } from "@/interfaces/Peer";
import { PolicyRuleResource } from "@/interfaces/Policy";
import { User } from "@/interfaces/User";
import { PeerOperatingSystemIcon } from "@/modules/peers/PeerOperatingSystemIcon";
import { HorizontalUsersStack } from "@/modules/users/HorizontalUsersStack";
import { PeerOperatingSystemIcon } from "@/modules/peers/PeerOperatingSystemIcon";
const groupsSearchPredicate = (item: Group, query: string) => {
const lowerCaseQuery = query.toLowerCase();
@@ -171,7 +171,15 @@ export function PeerGroupSelector({
const groupResources: GroupResource[] | undefined =
(group?.resources as GroupResource[]) || [];
if (peer) groupPeers?.push({ id: peer?.id as string, name: peer?.name });
if (peer) {
const peerInGroup = groupPeers?.find((p) => p?.id === peer?.id);
if (!peerInGroup) {
groupPeers?.push({
id: peer?.id as string,
name: peer?.name,
});
}
}
if (!group && !option) {
addDropdownOptions([
@@ -309,7 +317,7 @@ export function PeerGroupSelector({
"flex items-center gap-2 border-nb-gray-700 flex-wrap h-full"
}
>
{resource && showResources && (
{resource && (
<ResourceBadge
className={"py-[3px]"}
resource={resources?.find((r) => r.id === resource.id)}
@@ -518,7 +526,7 @@ export function PeerGroupSelector({
/>
</div>
<div className={"flex items-center gap-5"}>
<div className={"flex items-center gap-4"}>
{option?.id && showRoutes && (
<AccessControlGroupCount group_id={option.id} />
)}
@@ -527,19 +535,12 @@ export function PeerGroupSelector({
<ResourcesCounter group={option} />
)}
<div className={"flex gap-3 items-center"}>
<div className={"flex gap-4 items-center"}>
{!users ? (
<div
className={
"text-neutral-500 dark:text-nb-gray-300 font-medium flex items-center gap-2"
}
>
<MonitorSmartphoneIcon
size={14}
className={"shrink-0"}
/>
{peerCount} Peer(s)
</div>
<PeerCounter
group={option}
showResourceCounter={showResourceCounter}
/>
) : (
<UsersCounter
group={option}
@@ -547,7 +548,6 @@ export function PeerGroupSelector({
selected={isSelected}
/>
)}
<Checkbox checked={isSelected} />
</div>
</div>
@@ -663,7 +663,14 @@ const UsersCounter = ({
users?.filter((user) => user.auto_groups.includes(group.id as string)) ||
[];
if (usersOfGroup.length === 0) return null;
if (usersOfGroup.length === 0)
return (
<span
className={"group-hover/user-stack:text-nb-gray-200 text-nb-gray-300"}
>
0 User(s)
</span>
);
return (
<HorizontalUsersStack
@@ -678,6 +685,31 @@ const UsersCounter = ({
);
};
const PeerCounter = ({
group,
showResourceCounter,
}: {
group: Group;
showResourceCounter?: boolean;
}) => {
const peerCount = group.peers?.length ?? group?.peers_count ?? 0;
const resourcesCount = group?.resources_count ?? 0;
const hidePeerCounter =
showResourceCounter && peerCount === 0 && resourcesCount > 0;
return (
<div
className={cn(
"text-neutral-500 dark:text-nb-gray-300 font-medium flex items-center gap-2",
hidePeerCounter && "hidden",
)}
>
<MonitorSmartphoneIcon size={14} className={"shrink-0"} />
{peerCount} Peer(s)
</div>
);
};
const ResourcesCounter = ({ group }: { group: Group }) => {
return group?.resources_count && group.resources_count > 0 ? (
<div

View File

@@ -139,7 +139,11 @@ export function PortSelector({
<Badge
key={x}
variant={"gray"}
onClick={() => toggle(x)}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
toggle(x);
}}
className={"uppercase tracking-wider font-medium py-1"}
>
{x}

View File

@@ -39,38 +39,43 @@ const Tabs = React.forwardRef<
Tabs.displayName = TabsPrimitive.Root.displayName;
type TabListProps = {
hidden?: boolean;
justify?: "start" | "end" | "center" | "between";
};
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List> & TabListProps
>(({ className, justify = "center", ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
"flex flex-nowrap text-neutral-500 dark:text-nb-gray-400 w-full relative",
className,
justify == "center" && "justify-center justify-items-end",
justify == "start" && "justify-start",
justify == "end" && "justify-end",
justify == "between" && "justify-between",
)}
{...props}
>
<span
className={
"absolute left-0 dark:bg-nb-gray-900 bg-nb-gray-100 w-full h-[1px] bottom-0 z-0"
}
/>
<ScrollArea>
<div className={"relative z-[1] flex flex-nowrap w-full "}>
{props.children}
</div>
<ScrollBar orientation="horizontal" />
</ScrollArea>
</TabsPrimitive.List>
));
>(({ className, justify = "center", hidden = false, ...props }, ref) => {
return (
!hidden && (
<TabsPrimitive.List
ref={ref}
className={cn(
"flex flex-nowrap text-neutral-500 dark:text-nb-gray-400 w-full relative",
className,
justify == "center" && "justify-center justify-items-end",
justify == "start" && "justify-start",
justify == "end" && "justify-end",
justify == "between" && "justify-between",
)}
{...props}
>
<span
className={
"absolute left-0 dark:bg-nb-gray-900 bg-nb-gray-100 w-full h-[1px] bottom-0 z-0"
}
/>
<ScrollArea>
<div className={"relative z-[1] flex flex-nowrap w-full "}>
{props.children}
</div>
<ScrollBar orientation="horizontal" />
</ScrollArea>
</TabsPrimitive.List>
)
);
});
TabsList.displayName = TabsPrimitive.List.displayName;
const TabsTrigger = React.forwardRef<

View File

@@ -22,14 +22,14 @@ export const tooltipVariants = cva(
variants: {
variant: {
default: [
"bg-white dark:bg-nb-gray-940",
"text-neutral-950 dark:text-neutral-50",
"border-neutral-200 dark:border-nb-gray-930",
"bg-nb-gray-940",
"text-neutral-50",
"border-neutral-200 border-nb-gray-930",
],
lighter: [
"bg-white dark:bg-nb-gray-920",
"text-neutral-950 dark:text-neutral-50",
"border-neutral-200 dark:border-nb-gray-900",
"bg-nb-gray-920",
"text-neutral-50",
"border-neutral-200 border-nb-gray-900",
],
},
},

View File

@@ -10,7 +10,7 @@ export default function SkeletonTable({ withHeader = true }: Readonly<Props>) {
return (
<div className={"w-full"}>
{withHeader && <SkeletonTableHeader />}
<div className={"mt-6"}>
<div className={"mt-6 relative -top-1"}>
<TableSkeletonRow />
<TableSkeletonRow odd />
<TableSkeletonRow />
@@ -68,7 +68,7 @@ export const SkeletonTableHeader = ({
return (
<div
className={cn(
"flex gap-x-4 gap-y-6 p-default flex-wrap w-full justify-between",
"flex gap-x-4 gap-y-6 p-default flex-wrap w-full justify-between relative -top-1",
className,
)}
>

View File

@@ -133,6 +133,7 @@ interface DataTableProps<TData, TValue> {
getStartedCard?: React.ReactNode;
placeholders?: TData[];
renderExpandedRow?: (row: TData) => React.ReactNode;
renderRow?: (row: TData, children: React.ReactNode) => React.ReactNode;
minimal?: boolean;
className?: string;
inset?: boolean;
@@ -193,6 +194,7 @@ export function DataTable<TData, TValue>({
onRowClick,
getStartedCard,
renderExpandedRow,
renderRow,
minimal,
className,
tableClassName,
@@ -507,7 +509,7 @@ export function DataTable<TData, TValue>({
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => {
const expandedRow = renderExpandedRow?.(row.original);
return (
const rowContent = (
<AccordionItem
value={row.original.id}
asChild={true}
@@ -597,6 +599,8 @@ export function DataTable<TData, TValue>({
</>
</AccordionItem>
);
return renderRow ? renderRow(row.original, rowContent) : rowContent;
})
) : (
<TableRowUnstyledComponent>

View File

@@ -0,0 +1,89 @@
import Button from "@components/Button";
import FullTooltip from "@components/FullTooltip";
import { IconX } from "@tabler/icons-react";
import { cn } from "@utils/helpers";
import { AnimatePresence, motion } from "framer-motion";
import { MonitorSmartphoneIcon } from "lucide-react";
import * as React from "react";
type Props<T> = {
selectedItems?: T[];
label?: string;
onCanceled?: () => void;
rightSide?: React.ReactNode;
};
export function DataTableMultiSelectPopup<T>({
onCanceled,
label = "Peer(s) selected",
selectedItems,
rightSide,
}: Props<T>) {
const count = selectedItems?.length || 0;
return (
<AnimatePresence>
{count > 0 && (
<div
className={"fixed -bottom-16 z-50 w-full left-0 pointer-events-none"}
>
<motion.div
exit={{
y: 100,
}}
>
<AnimatePresence>
<motion.div
animate={{ y: 0 }}
initial={{ y: 100 }}
exit={{ y: 100 }}
transition={{
type: "spring",
stiffness: 270,
damping: 25,
duration: 0.35,
}}
className={cn(
"max-w-xl mx-auto border relative z-[50] bg-nb-gray-800 border-nb-gray-900 shadow-2xl border-b-0 overflow-hidden pointer-events-auto",
"rounded-t-lg",
)}
>
<AnimatePresence mode={"popLayout"}>
<div
className={
"flex gap-2 items-center text-sm px-6 pt-3.5 pb-20 bg-nb-gray-920/90 text-nb-gray-200 justify-between"
}
>
<div className={"flex gap-2 items-center"}>
<MonitorSmartphoneIcon size={16} className={""} />
<span>
<span className={"font-medium text-white"}>
{count}
</span>{" "}
{label}
</span>
</div>
<div className={"flex gap-2 items-center"}>
{rightSide}
<FullTooltip
content={<span className={"text-xs"}>Cancel</span>}
>
<Button
onClick={onCanceled}
variant={"default-outline"}
size={"xs"}
className={"!h-9 !w-9"}
>
<IconX size={16} className={"shrink-0"} />
</Button>
</FullTooltip>
</div>
</div>
</AnimatePresence>
</motion.div>
</AnimatePresence>
</motion.div>
</div>
)}
</AnimatePresence>
);
}

View File

@@ -35,7 +35,7 @@ export default function DataTableRefreshButton({ onClick, isDisabled }: Props) {
}}
>
<Button
className={"h-[44px]"}
className={"h-[42px]"}
variant={"secondary"}
disabled={isDisabled == true ? true : disabled}
>

View File

@@ -36,7 +36,7 @@ export default function DataTableResetFilterButton<TData>({
}}
>
<Button
className={"h-[44px]"}
className={"h-[42px]"}
variant={"secondary"}
onClick={onClick}
>

View File

@@ -0,0 +1,115 @@
import Button from "@components/Button";
import HelpText from "@components/HelpText";
import InlineLink from "@components/InlineLink";
import { Input } from "@components/Input";
import { Label } from "@components/Label";
import {
Modal,
ModalClose,
ModalContent,
ModalFooter,
ModalTrigger,
} from "@components/modal/Modal";
import { ExternalLinkIcon, FolderGit2Icon, PlusCircle } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { useSWRConfig } from "swr";
import { usePermissions } from "@/contexts/PermissionsProvider";
import { Group } from "@/interfaces/Group";
import { useApiCall } from "@/utils/api";
import ModalHeader from "../modal/ModalHeader";
import { notify } from "../Notification";
import Paragraph from "../Paragraph";
import Separator from "../Separator";
export const AddGroupButton = () => {
const create = useApiCall<Group>("/groups", true).post;
const { mutate } = useSWRConfig();
const [name, setName] = useState<string>("");
const [open, setOpen] = useState(false);
const router = useRouter();
const { permission } = usePermissions();
const createGroup = () => {
notify({
title: "Create Group",
description: `Group '${name}' successfully created`,
loadingMessage: "Creating group...",
promise: create({ name }).then((g) => {
setOpen(false);
setName("");
mutate("/groups");
router.push(`/group?id=${g?.id}`);
}),
});
};
return (
permission?.groups?.create && (
<Modal open={open} onOpenChange={setOpen}>
<ModalTrigger asChild>
<Button
variant={"primary"}
size={"sm"}
className={"ml-auto h-[42px]"}
>
<PlusCircle size={16} />
Create Group
</Button>
</ModalTrigger>
<ModalContent maxWidthClass={"max-w-xl"}>
<ModalHeader
icon={<FolderGit2Icon size={18} />}
title="Create Group"
description="Create a group to manage and organize access in your network"
color="netbird"
/>
<Separator />
<div className={"px-8 flex-col flex gap-6 py-6"}>
<div>
<Label>Name</Label>
<HelpText>
Set an easily identifiable name for your group
</HelpText>
<Input
tabIndex={0}
placeholder={"e.g., Developers"}
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
</div>
<ModalFooter className={"items-center"}>
<div className={"w-full"}>
<Paragraph className={"text-sm mt-auto"}>
Learn more about
<InlineLink
href={"https://docs.netbird.io/how-to/manage-network-access"}
target={"_blank"}
>
Groups
<ExternalLinkIcon size={12} />
</InlineLink>
</Paragraph>
</div>
<div className={"flex gap-3 w-full justify-end"}>
<ModalClose asChild={true}>
<Button variant={"secondary"}>Cancel</Button>
</ModalClose>
<Button
variant={"primary"}
data-cy={"submit-route"}
disabled={!name}
onClick={createGroup}
>
<PlusCircle size={16} />
Create Group
</Button>
</div>
</ModalFooter>
</ModalContent>
</Modal>
)
);
};

View File

@@ -6,6 +6,7 @@ import { cn } from "@utils/helpers";
import { XIcon } from "lucide-react";
import * as React from "react";
import { Group } from "@/interfaces/Group";
import { useRouter } from "next/navigation";
type Props = {
group: Group;
@@ -17,6 +18,9 @@ type Props = {
maxChars?: number;
maxWidth?: string;
hideTooltip?: boolean;
textClassName?: string;
redirectGroupTab?: string;
redirectToGroupPage?: boolean;
};
export default function GroupBadge({
@@ -29,19 +33,33 @@ export default function GroupBadge({
maxChars = 20,
maxWidth,
hideTooltip = false,
textClassName,
redirectGroupTab,
redirectToGroupPage = false,
}: Readonly<Props>) {
const isNew = !group?.id;
const router = useRouter();
const handleGroupPageRedirect = () => {
if (!group?.id) return;
let redirectUrl = `/group?id=${group.id}`;
if (redirectGroupTab) {
redirectUrl += `&tab=${encodeURIComponent(redirectGroupTab)}`;
}
router.push(redirectUrl);
};
return (
<Badge
key={group.id ?? group.name}
useHover={true}
useHover={!!onClick || redirectToGroupPage}
data-cy={"group-badge"}
variant={"gray-ghost"}
className={cn("transition-all group whitespace-nowrap", className)}
onClick={(e) => {
e.preventDefault();
onClick?.(e);
if (redirectToGroupPage) handleGroupPageRedirect();
}}
>
<GroupBadgeIcon id={group?.id} issued={group?.issued} />
@@ -49,6 +67,7 @@ export default function GroupBadge({
text={group?.name || ""}
maxChars={maxChars}
maxWidth={maxWidth}
className={textClassName}
hideTooltip={hideTooltip}
/>
{children}

View File

@@ -2,7 +2,9 @@ import { FolderGit2 } from "lucide-react";
import * as React from "react";
import EntraIcon from "@/assets/icons/EntraIcon";
import GoogleIcon from "@/assets/icons/GoogleIcon";
import JumpcloudIcon from "@/assets/icons/JumpcloudIcon";
import JWTIcon from "@/assets/icons/JWTIcon";
import OIDCIcon from "@/assets/icons/OIDCIcon";
import OktaIcon from "@/assets/icons/OktaIcon";
import { useGroups } from "@/contexts/GroupsProvider";
import { GroupIssued } from "@/interfaces/Group";
@@ -20,8 +22,14 @@ export const GroupBadgeIcon = ({
const { groups } = useGroups();
const group = groups?.find((g) => g.id === id);
const { isAzureGroup, isGoogleGroup, isOktaGroup, isJWTGroup } =
useGroupIdentification({ id, issued: issued ?? group?.issued });
const {
isAzureGroup,
isGoogleGroup,
isOktaGroup,
isJWTGroup,
isJumpcloudGroup,
isOIDCGroup,
} = useGroupIdentification({ id, issued: issued ?? group?.issued });
if (isGoogleGroup)
return <GoogleIcon size={size - 1} className={"shrink-0 mr-0.5"} />;
@@ -29,6 +37,10 @@ export const GroupBadgeIcon = ({
return <EntraIcon size={size + 1} className={"shrink-0 mr-0.5"} />;
if (isOktaGroup)
return <OktaIcon size={size - 1} className={"shrink-0 mr-0.5"} />;
if (isJumpcloudGroup)
return <JumpcloudIcon size={size + 2} className={"shrink-0 mr-0.5"} />;
if (isOIDCGroup)
return <OIDCIcon size={size} className={"shrink-0 mr-0.5"} />;
if (isJWTGroup) return <JWTIcon size={size} className={"shrink-0"} />;
return <FolderGit2 size={size} className={"shrink-0"} />;

View File

@@ -0,0 +1,21 @@
import Button from "@components/Button";
import { Modal, ModalTrigger } from "@components/modal/Modal";
import { DownloadIcon } from "lucide-react";
import React, { useState } from "react";
import SetupModal from "@/modules/setup-netbird-modal/SetupModal";
export function InstallNetBirdButton() {
const [installModal, setInstallModal] = useState(false);
return (
<Modal open={installModal} onOpenChange={setInstallModal}>
<ModalTrigger asChild>
<Button variant={"secondary"} size={"sm"}>
<DownloadIcon size={16} />
Install NetBird
</Button>
</ModalTrigger>
<SetupModal />
</Modal>
);
}

View File

@@ -1,19 +1,21 @@
import Badge from "@components/Badge";
import { ScrollArea } from "@components/ScrollArea";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@components/Tooltip";
HoverCard,
HoverCardContent,
HoverCardTrigger,
} from "@components/HoverCard";
import { ScrollArea } from "@components/ScrollArea";
import GroupBadge from "@components/ui/GroupBadge";
import PeerBadge from "@components/ui/PeerBadge";
import PeerCountBadge from "@components/ui/PeerCountBadge";
import ResourceCountBadge from "@components/ui/ResourceCountBadge";
import { cn } from "@utils/helpers";
import { ArrowRightIcon, PencilLineIcon } from "lucide-react";
import * as React from "react";
import { usePermissions } from "@/contexts/PermissionsProvider";
import { useUsers } from "@/contexts/UsersProvider";
import { Group } from "@/interfaces/Group";
import EmptyRow from "@/modules/common-table-rows/EmptyRow";
import { HorizontalUsersStack } from "@/modules/users/HorizontalUsersStack";
type Props = {
groups: Group[];
@@ -21,6 +23,9 @@ type Props = {
description?: string;
onClick?: () => void;
className?: string;
showResources?: boolean;
redirectGroupTab?: string;
showUsers?: boolean;
};
export default function MultipleGroups({
@@ -29,6 +34,9 @@ export default function MultipleGroups({
description = "Use groups to control what this peer can access",
onClick,
className,
showResources = false,
showUsers = false,
redirectGroupTab,
}: Readonly<Props>) {
const { permission } = usePermissions();
@@ -45,13 +53,9 @@ export default function MultipleGroups({
const otherGroups = orderedGroups.length > 0 ? orderedGroups.slice(1) : [];
return (
<TooltipProvider
disableHoverableContent={false}
delayDuration={200}
skipDelayDuration={200}
>
<Tooltip>
<TooltipTrigger asChild={true}>
<div className={"flex"}>
<HoverCard openDelay={200} closeDelay={100}>
<HoverCardTrigger>
<div
className={cn("inline-flex items-center gap-2 z-0", className)}
data-cy={"multiple-groups"}
@@ -78,9 +82,9 @@ export default function MultipleGroups({
</Badge>
)}
</div>
</TooltipTrigger>
</HoverCardTrigger>
{orderedGroups && orderedGroups.length > 0 && (
<TooltipContent
<HoverCardContent
className={"p-0"}
onClick={(e) => e.stopPropagation()}
>
@@ -102,19 +106,31 @@ export default function MultipleGroups({
"flex gap-2 items-center justify-between w-full"
}
>
<GroupBadge group={group}></GroupBadge>
<GroupBadge
group={group}
className={"py-0"}
textClassName={"py-1.5"}
redirectToGroupPage={true}
redirectGroupTab={redirectGroupTab}
></GroupBadge>
<ArrowRightIcon size={14} />
<PeerBadge> {group.peers_count} Peer(s)</PeerBadge>
{showResources ? (
<ResourceCountBadge group={group} />
) : showUsers ? (
<UserCountStack group={group} />
) : (
<PeerCountBadge group={group} />
)}
</div>
)
);
})}
</div>
</ScrollArea>
</TooltipContent>
</HoverCardContent>
)}
</Tooltip>
</TooltipProvider>
</HoverCard>
</div>
);
}
@@ -129,3 +145,17 @@ export const TransparentEditIconButton = () => {
</div>
);
};
export const UserCountStack = ({ group }: { group: Group }) => {
const { users } = useUsers();
const usersOfGroup =
users?.filter((user) => user.auto_groups.includes(group.id as string)) ||
[];
return (
<HorizontalUsersStack
users={usersOfGroup}
side={"right"}
isAllGroup={group?.name === "All"}
/>
);
};

View File

@@ -1,5 +1,6 @@
import Card from "@components/Card";
import Paragraph from "@components/Paragraph";
import { cn } from "@utils/helpers";
import { FilterX } from "lucide-react";
import React from "react";
import Skeleton from "react-loading-skeleton";
@@ -9,15 +10,18 @@ type Props = {
title?: string;
description?: string;
children?: React.ReactNode;
className?: string;
};
export default function NoResultsCard({
icon,
title = "Could not find any results",
description = "We couldn't find any results. Please try a different search term or change your filters.",
children,
className,
}: Readonly<Props>) {
return (
<div className={"px-8 mt-8"}>
<div className={cn("px-8 mt-8", className)}>
<Card className={"w-full relative overflow-hidden"}>
<div
className={

View File

@@ -0,0 +1,64 @@
import Badge, { BadgeVariants } from "@components/Badge";
import { cn, singularize } from "@utils/helpers";
import { MonitorSmartphoneIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import * as React from "react";
import { useMemo } from "react";
import { useGroups } from "@/contexts/GroupsProvider";
import { Group } from "@/interfaces/Group";
import ResourceCountBadge from "@components/ui/ResourceCountBadge";
type Props = {
group?: Group;
} & React.HTMLAttributes<HTMLDivElement> &
BadgeVariants;
export default function PeerCountBadge({
group,
variant = "gray",
className,
}: Props) {
const router = useRouter();
const { dropdownOptions } = useGroups();
const currentGroup = useMemo(() => {
return dropdownOptions?.find((g) => g.name === group?.name);
}, [group, dropdownOptions]);
const peerCount = useMemo(() => {
let peerCount = currentGroup?.peers_count ?? 0;
let countedPeers = currentGroup?.peers?.length ?? 0;
if (peerCount !== countedPeers) {
peerCount = countedPeers;
}
return peerCount;
}, [currentGroup]);
const canRedirect = !!group?.id && group?.name !== "All";
const onClick = (e: React.MouseEvent<HTMLDivElement>) => {
e.stopPropagation();
if (canRedirect) router.push(`/group?id=${group?.id}&tab=peers`);
};
const resourcesCount = group?.resources_count ?? 0;
const showResources = resourcesCount > 0 && peerCount === 0;
return showResources ? (
<ResourceCountBadge group={group} />
) : (
<Badge
variant={variant}
className={cn(
className,
"px-3 gap-2 whitespace-nowrap",
canRedirect && "cursor-pointer",
)}
onClick={onClick}
useHover={canRedirect}
>
<MonitorSmartphoneIcon size={12} />
{singularize("Peers", peerCount, true)}
</Badge>
);
}

View File

@@ -2,7 +2,7 @@ import Badge from "@components/Badge";
import { cn } from "@utils/helpers";
import React, { useEffect, useMemo } from "react";
import LongArrowLeftIcon from "@/assets/icons/LongArrowLeftIcon";
import { PolicyRuleResource } from "@/interfaces/Policy";
import { PolicyRuleResource, Protocol } from "@/interfaces/Policy";
type Props = {
disabled?: boolean;
@@ -10,6 +10,7 @@ type Props = {
onChange: (value: Direction) => void;
className?: string;
destinationResource?: PolicyRuleResource;
protocol?: Protocol;
};
export type Direction = "bi" | "in" | "out";
@@ -20,8 +21,10 @@ export default function PolicyDirection({
onChange,
className,
destinationResource,
protocol,
}: Readonly<Props>) {
const toggleDirection = () => {
if (protocol === "netbird-ssh") return;
if (value == "bi") {
onChange("in");
} else {
@@ -30,41 +33,49 @@ export default function PolicyDirection({
};
useEffect(() => {
if (protocol === "netbird-ssh") {
onChange("in");
return;
}
if (disabled) onChange("bi");
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [disabled]);
}, [disabled, protocol]);
const isNetworkResource =
!!destinationResource && destinationResource?.type !== "peer";
const topBadgeClass = useMemo(() => {
if (destinationResource) return "blueDark";
if (isNetworkResource) return "blueDark";
if (value === "bi") return "green";
if (value === "in") return "blueDark";
return "gray";
}, [value, destinationResource]);
}, [value, isNetworkResource]);
const topArrowClass = useMemo(() => {
if (destinationResource) return "fill-sky-500";
if (isNetworkResource) return "fill-sky-500";
if (value === "bi") return "fill-green-500";
if (value === "in") return "fill-sky-500";
return "fill-gray-500";
}, [value, destinationResource]);
}, [value, isNetworkResource]);
const bottomBadgeClass = useMemo(() => {
if (destinationResource) return "gray";
if (isNetworkResource) return "gray";
if (value === "bi") return "green";
return "gray";
}, [value, destinationResource]);
}, [value, isNetworkResource]);
const bottomArrowClass = useMemo(() => {
if (destinationResource) return "fill-gray-500";
if (isNetworkResource) return "fill-gray-500";
if (value === "bi") return "fill-green-500";
return "fill-gray-500";
}, [value, destinationResource]);
}, [value, isNetworkResource]);
return (
<button
className={cn(
"flex flex-col gap-2 mt-[23px] cursor-pointer select-none",
disabled && "opacity-50 pointer-events-none",
(disabled || protocol === "netbird-ssh") &&
"opacity-50 pointer-events-none",
"hover:opacity-80 transition-all",
className,
)}

View File

@@ -0,0 +1,33 @@
import Badge, { BadgeVariants } from "@components/Badge";
import { cn, singularize } from "@utils/helpers";
import { LayersIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import * as React from "react";
import { Group } from "@/interfaces/Group";
type Props = {
group?: Group;
} & React.HTMLAttributes<HTMLDivElement> &
BadgeVariants;
export default function ResourceCountBadge({ group }: Props) {
const router = useRouter();
const hasId = !!group?.id;
const onClick = (e: React.MouseEvent<HTMLDivElement>) => {
e.stopPropagation();
if (hasId) router.push(`/group?id=${group?.id}&tab=resources`);
};
return (
<Badge
className={cn("px-3 gap-2 whitespace-nowrap", hasId && "cursor-pointer")}
variant={"gray"}
onClick={onClick}
useHover={hasId}
>
<LayersIcon size={12} />
{singularize("Resources", group?.resources_count, true)}
</Badge>
);
}

View File

@@ -1,4 +1,4 @@
import * as HoverCard from "@radix-ui/react-hover-card";
import { Tooltip, TooltipContent, TooltipTrigger } from "@components/Tooltip";
import { cn } from "@utils/helpers";
import React, { useMemo, useState } from "react";
@@ -55,38 +55,28 @@ export default function TruncatedText({
}
return (
<HoverCard.Root
openDelay={650}
closeDelay={100}
open={open}
onOpenChange={setOpen}
>
<HoverCard.Trigger asChild={true}>
<Tooltip delayDuration={650} open={open} onOpenChange={setOpen}>
<TooltipTrigger asChild={true}>
<div className="w-full min-w-0 inline-block" style={containerStyle}>
<div ref={contentRef} className={cn(className, "truncate")}>
{text}
</div>
</div>
</HoverCard.Trigger>
<HoverCard.Portal>
<HoverCard.Content
onMouseLeave={() => setOpen(false)}
onMouseEnter={() => setOpen(false)}
alignOffset={20}
sideOffset={4}
className={cn(
"z-[9999] overflow-hidden rounded-md border border-neutral-200 bg-white text-sm text-neutral-950 shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 dark:border-nb-gray-930 dark:bg-nb-gray-940 dark:text-neutral-50",
className,
"px-3 py-1.5",
)}
>
<div className="text-neutral-300 flex flex-col gap-1">
<div className="max-w-xs break-all whitespace-normal text-xs">
{text}
</div>
</TooltipTrigger>
<TooltipContent
alignOffset={20}
sideOffset={4}
onClick={(e) => {
e.stopPropagation();
}}
className={cn(className, "px-3 py-1.5")}
>
<div className="text-neutral-300 flex flex-col gap-1">
<div className="max-w-xs break-all whitespace-normal text-xs">
{text}
</div>
</HoverCard.Content>
</HoverCard.Portal>
</HoverCard.Root>
</div>
</TooltipContent>
</Tooltip>
);
}

View File

@@ -17,6 +17,12 @@ declare global {
}
}
export type HubspotFormField = {
objectTypeId?: string;
name: string;
value: string;
};
const AnalyticsContext = React.createContext(
{} as {
initialized: boolean;

View File

@@ -4,7 +4,18 @@ import md5 from "crypto-js/md5";
import React, { useEffect, useState } from "react";
import { usePermissions } from "@/contexts/PermissionsProvider";
const initialAnnouncements: Announcement[] = [];
const initialAnnouncements: Announcement[] = [
{
tag: "New",
text: "NetBird v0.60.0 - Identity-aware, private SSH over your NetBird network.",
link: "https://docs.netbird.io/how-to/ssh",
linkText: "Documentation",
variant: "default", // "default" or "important"
isExternal: true,
closeable: true,
isCloudOnly: false,
},
];
export interface Announcement extends AnnouncementVariant {
tag: string;

View File

@@ -0,0 +1,335 @@
import { notify } from "@components/Notification";
import { useApiCall } from "@utils/api";
import * as React from "react";
import { useState } from "react";
import { useSWRConfig } from "swr";
import { useDialog } from "@/contexts/DialogProvider";
import { useGroups } from "@/contexts/GroupsProvider";
import { usePermissions } from "@/contexts/PermissionsProvider";
import { Group } from "@/interfaces/Group";
import { Peer } from "@/interfaces/Peer";
import { User } from "@/interfaces/User";
import { EditGroupNameModal } from "@/modules/groups/EditGroupNameModal";
import { useGroupIdentification } from "@/modules/groups/useGroupIdentification";
type Props = {
group: Group;
children?: React.ReactNode;
isDetailPage?: boolean;
};
const GroupContext = React.createContext(
{} as {
group: Group;
deleteGroup: () => Promise<void>;
renameGroup: (name: string) => Promise<void>;
isRegularGroup: boolean;
isIntegrationGroup: boolean;
isJWTGroup: boolean;
isAllowedToDelete: boolean;
isAllowedToRename: boolean;
openGroupRenameModal?: () => void;
addPeersToGroup: (peers: Peer[]) => Promise<void>;
removePeersFromGroup: (peer: Peer[]) => Promise<void>;
addUsersToGroup: (users: User[]) => Promise<void>;
removeUsersFromGroup: (users: User[]) => Promise<void>;
},
);
export const GroupProvider = ({
group,
children,
isDetailPage = true,
}: Props) => {
const { permission } = usePermissions();
const [groupNameModal, setGroupNameModal] = useState(false);
const { mutate } = useSWRConfig();
const { deleteGroupDropdownOption, updateGroupDropdown } = useGroups();
const groupRequest = useApiCall<Group>("/groups/" + group.id);
const userRequest = useApiCall<User>("/users");
const { confirm } = useDialog();
const { isRegularGroup, isIntegrationGroup, isJWTGroup } =
useGroupIdentification({
id: group?.id,
issued: group?.issued,
});
const isAllowedToRename = isRegularGroup && permission?.groups?.update;
const isAllowedToDelete = !isIntegrationGroup && permission?.groups?.delete;
const handleDelete = async () => {
if (!isAllowedToDelete) return Promise.reject("Not allowed to delete");
const promise = groupRequest.del().then(() => {
deleteGroupDropdownOption(group.name);
if (isDetailPage) mutate(`/groups/${group.id}`);
mutate("/groups");
});
notify({
title: "Delete Group " + group.name,
description: "Group successfully deleted",
promise,
loadingMessage: "Deleting group...",
});
return promise;
};
const deleteGroup = async () => {
const choice = await confirm({
title: `Delete '${group.name}'?`,
description:
"Are you sure you want to delete this group? This action cannot be undone.",
confirmText: "Delete",
cancelText: "Cancel",
type: "danger",
});
if (!choice) return;
handleDelete().then();
};
const renameGroup = (name: string) => {
if (!isAllowedToRename) return Promise.reject("Not allowed to rename");
const currentPeerIds =
group.peers?.map((p) => (typeof p === "string" ? p : p.id)) || [];
const promise = groupRequest
.put({ ...group, peers: currentPeerIds, name })
.then(() => {
updateGroupDropdown(group.name, { ...group, name });
if (isDetailPage) mutate(`/groups/${group.id}`);
mutate("/groups");
});
notify({
title: `Rename Group ${group.name}`,
description: "Group successfully renamed to " + name,
promise,
loadingMessage: "Renaming group...",
});
return promise;
};
const removePeersFromGroup = async (peers: Peer[]) => {
if (!permission?.groups?.update) return Promise.reject();
const peer = peers.length === 1 ? peers[0] : undefined;
const choice = await confirm({
title: peer
? `Remove peer '${peer.name}' from '${group.name}'?`
: `Remove peers from '${group.name}'?`,
description: peer
? `Are you sure you want to remove this peer from the group? You can add it back later if needed.`
: `Are you sure you want to remove these peers from the group? You can add them back later if needed.`,
confirmText: "Remove",
cancelText: "Cancel",
type: "warning",
maxWidthClass: "max-w-lg",
});
if (!choice) return Promise.resolve();
const currentPeerIds =
group.peers?.map((p) => (typeof p === "string" ? p : p.id)) || [];
const newPeerIds = currentPeerIds.filter((pid) => {
return !peers.find((peer) => peer.id === pid);
});
const promise = groupRequest
.put({ ...group, peers: newPeerIds })
.then(() => {
if (isDetailPage) mutate(`/groups/${group.id}`);
mutate("/groups");
});
notify({
title: `Remove Peer from Group`,
description: peer
? `Peer '${peer.name}' successfully removed from group '${group.name}'`
: `Peers successfully removed from group '${group.name}'`,
promise,
loadingMessage: peer
? "Removing peer from group..."
: `Removing peers from group...`,
});
return promise;
};
const addPeersToGroup = async (peers: Peer[]) => {
if (!permission?.groups?.update) return Promise.reject();
const currentPeerIds =
group.peers?.map((p) => (typeof p === "string" ? p : p.id)) || [];
const newPeerIds = [...currentPeerIds, ...peers.map((peer) => peer.id)];
const uniquePeerIds = Array.from(new Set(newPeerIds));
const promise = groupRequest
.put({ ...group, peers: uniquePeerIds })
.then(() => {
if (isDetailPage) mutate(`/groups/${group.id}`);
mutate("/groups");
});
notify({
title: "Adding peers to group",
description: `Peers were successfully added to ${group.name}.`,
promise,
loadingMessage: "Adding peers to group...",
});
return promise;
};
const removeUserFromGroup = async (
user: User,
returnOnlyPromise?: boolean,
) => {
if (!permission?.groups?.update) return Promise.reject();
if (!permission?.users?.update) return Promise.reject();
const currentGroupIds = user.auto_groups?.map((g) => g) || [];
const newGroupIds = currentGroupIds.filter((gid) => gid !== group.id);
const promise = userRequest
.put({ ...user, auto_groups: newGroupIds }, `/${user.id}`)
.then(() => {
if (returnOnlyPromise) return;
if (isDetailPage) mutate(`/groups/${group.id}`);
mutate("/groups");
mutate("/users?service_user=false");
});
if (!returnOnlyPromise) {
notify({
title: `Remove User from Group ${group.name}`,
description: `User '${user.name}' was successfully removed from group '${group.name}'.`,
promise,
loadingMessage: "Removing user from group...",
});
}
return promise;
};
const removeUsersFromGroup = async (users: User[]) => {
if (!permission?.groups?.update) return Promise.reject();
if (!permission?.users?.update) return Promise.reject();
let promises = users.map((user) => removeUserFromGroup(user, true));
const user = users.length === 1 ? users[0] : undefined;
const choice = await confirm({
title: user
? `Remove user '${user?.name ?? user?.id}' from '${group.name}'?`
: `Remove users from '${group.name}'?`,
description: user
? `Are you sure you want to remove this user from the group? You can add it back later if needed.`
: `Are you sure you want to remove these users from the group? You can add them back later if needed.`,
confirmText: "Remove",
cancelText: "Cancel",
type: "warning",
maxWidthClass: "max-w-lg",
});
if (!choice) return Promise.resolve();
const promise = Promise.all(promises).then(() => {
if (isDetailPage) mutate(`/groups/${group.id}`);
mutate("/groups");
mutate("/users?service_user=false");
});
notify({
title: `Remove Users from Group ${group.name}`,
description: `Users were successfully removed from group '${group.name}'.`,
promise,
loadingMessage: "Removing users from group...",
});
return promise;
};
const addUserToGroup = async (user: User, returnOnlyPromise?: boolean) => {
if (!permission?.groups?.update) return Promise.reject();
if (!permission?.users?.update) return Promise.reject();
const currentGroupIds = user.auto_groups?.map((g) => g) || [];
const newGroupIds = Array.from(new Set([...currentGroupIds, group.id]));
const promise = userRequest
.put({ ...user, auto_groups: newGroupIds }, `/${user.id}`)
.then(() => {
if (returnOnlyPromise) return;
if (isDetailPage) mutate(`/groups/${group.id}`);
mutate("/groups");
mutate("/users?service_user=false");
});
if (!returnOnlyPromise) {
notify({
title: `Add User to Group ${group.name}`,
description: `User '${user.name}' was successfully added to group '${group.name}'.`,
promise,
loadingMessage: "Adding user to group...",
});
}
return promise;
};
const addUsersToGroup = async (users: User[]) => {
let promises = users.map((user) => addUserToGroup(user, true));
const promise = Promise.all(promises).then(() => {
if (isDetailPage) mutate(`/groups/${group.id}`);
mutate("/groups");
mutate("/users?service_user=false");
});
notify({
title: `Add Users to Group ${group.name}`,
description: `Users were successfully added to group '${group.name}'.`,
promise,
loadingMessage: "Adding users to group...",
});
return promise;
};
const openGroupRenameModal = () => {
if (!isAllowedToRename) return;
setGroupNameModal(true);
};
return (
<GroupContext.Provider
value={{
group,
deleteGroup,
renameGroup,
isRegularGroup,
isIntegrationGroup,
isJWTGroup,
isAllowedToDelete,
isAllowedToRename,
openGroupRenameModal,
addPeersToGroup,
removePeersFromGroup,
addUsersToGroup,
removeUsersFromGroup,
}}
>
<EditGroupNameModal
initialName={group.name}
open={groupNameModal}
onOpenChange={setGroupNameModal}
onSuccess={(newName) =>
renameGroup(newName).then(() => {
setGroupNameModal(false);
})
}
/>
{children}
</GroupContext.Provider>
);
};
export const useGroupContext = () => {
const context = React.useContext(GroupContext);
if (!context) {
throw new Error("useGroup must be used within a GroupProvider");
}
return context;
};

View File

@@ -20,6 +20,7 @@ const GroupContext = React.createContext(
createOrUpdate: (group: Group) => Promise<Group>;
reset: () => void;
updateGroupDropdown: (oldGroupName: string, newGroup: Group) => void;
deleteGroupDropdownOption: (name: string) => void;
},
);
@@ -132,6 +133,13 @@ export function GroupsProviderContent({
}
};
const deleteGroupDropdownOption = (name: string) => {
setDropdownOptions((prev) => {
let updated = prev.filter((g) => g.name !== name);
return sortBy(updated, "name");
});
};
return (
<GroupContext.Provider
value={{
@@ -144,6 +152,7 @@ export function GroupsProviderContent({
createOrUpdate,
reset,
updateGroupDropdown,
deleteGroupDropdownOption,
}}
>
{children}

View File

@@ -1,18 +1,21 @@
import { notify } from "@components/Notification";
import SkeletonPeerDetail from "@components/skeletons/SkeletonPeerDetail";
import { useApiCall } from "@utils/api";
import React, { useMemo } from "react";
import React, { useMemo, useState } from "react";
import { useSWRConfig } from "swr";
import { useDialog } from "@/contexts/DialogProvider";
import { useGroups } from "@/contexts/GroupsProvider";
import { usePermissions } from "@/contexts/PermissionsProvider";
import { useUsers } from "@/contexts/UsersProvider";
import { Group, GroupPeer } from "@/interfaces/Group";
import { Peer } from "@/interfaces/Peer";
import { User } from "@/interfaces/User";
import { PeerSSHInstructions } from "@/modules/peer/PeerSSHInstructions";
type Props = {
children: React.ReactNode;
peer: Peer;
isPeerDetailPage?: boolean;
};
const PeerContext = React.createContext(
@@ -28,18 +31,25 @@ const PeerContext = React.createContext(
approval_required?: boolean;
ip?: string;
}) => Promise<Peer>;
openSSHDialog: () => Promise<boolean>;
toggleSSH: (newState: boolean) => Promise<void>;
setSSHInstructionsModal: (open: boolean) => void;
deletePeer: () => void;
isLoading: boolean;
},
);
export default function PeerProvider({ children, peer }: Props) {
export default function PeerProvider({
children,
peer,
isPeerDetailPage = false,
}: Props) {
const user = usePeerUser(peer);
const { peerGroups, isLoading } = usePeerGroups(peer);
const peerRequest = useApiCall<Peer>("/peers", true);
const { confirm } = useDialog();
const { mutate } = useSWRConfig();
const { permission } = usePermissions();
const [sshInstructionsModal, setSSHInstructionsModal] = useState(false);
const deletePeer = async () => {
const choice = await confirm({
@@ -94,25 +104,20 @@ export default function PeerProvider({ children, peer }: Props) {
);
};
const openSSHDialog = async (): Promise<boolean> => {
return await confirm({
title: `Enable SSH Server for ${peer.name}?`,
description: (
<div className={"flex flex-col gap-2"}>
<div>
Enabling this option allows remote SSH access to this machine from
other connected network participants.
</div>
<div>
Make sure SSH is allowed in the NetBird Client under{" "}
<span className={"text-white"}>Settings &rarr; Allow SSH</span>
</div>
</div>
),
confirmText: "Enable",
cancelText: "Cancel",
type: "warning",
maxWidthClass: "max-w-lg",
const toggleSSH = async (enable: boolean) => {
if (!permission.peers.update) return;
notify({
title: peer.name,
description: enable
? "SSH Access successfully enabled"
: "SSH Access successfully disabled",
promise: update({ ssh: enable }).then(() => {
isPeerDetailPage ? mutate(`/peers/${peer.id}`) : mutate("/peers");
setSSHInstructionsModal(false);
}),
loadingMessage: enable
? "Enabling SSH Access..."
: "Disabling SSH Access...",
});
};
@@ -123,16 +128,29 @@ export default function PeerProvider({ children, peer }: Props) {
peerGroups,
user,
update,
openSSHDialog,
toggleSSH,
setSSHInstructionsModal,
deletePeer,
isLoading,
}}
>
{sshInstructionsModal && (
<PeerSSHInstructions
open={sshInstructionsModal}
onOpenChange={setSSHInstructionsModal}
peer={peer}
onSuccess={() => {
mutate(`/peers/${peer.id}`);
setSSHInstructionsModal(false);
}}
/>
)}
{children}
</PeerContext.Provider>
) : (
) : isPeerDetailPage ? (
<SkeletonPeerDetail />
);
) : null;
}
/**

View File

@@ -1,7 +1,9 @@
import { Modal } from "@components/modal/Modal";
import { notify } from "@components/Notification";
import { useApiCall } from "@utils/api";
import React from "react";
import React, { useState } from "react";
import { Policy } from "@/interfaces/Policy";
import { AccessControlModalContent } from "@/modules/access-control/AccessControlModal";
type Props = {
children: React.ReactNode;
@@ -16,11 +18,15 @@ const PoliciesContext = React.createContext(
message?: string,
) => void;
createPolicy: (policy: Policy) => Promise<Policy>;
openEditPolicyModal: (policy: Policy, tab?: string) => void;
},
);
export default function PoliciesProvider({ children }: Props) {
const request = useApiCall<Policy>("/policies");
const [policyModal, setPolicyModal] = useState(false);
const [currentPolicy, setCurrentPolicy] = useState<Policy>();
const [initialPolicyTab, setInitialPolicyTab] = useState("");
const createPolicy = async (policy: Policy) => request.post(policy);
@@ -56,9 +62,34 @@ export default function PoliciesProvider({ children }: Props) {
});
};
const openEditPolicyModal = (policy: Policy, tab?: string) => {
setCurrentPolicy(policy);
tab && setInitialPolicyTab(tab);
setPolicyModal(true);
};
return (
<PoliciesContext.Provider value={{ updatePolicy, createPolicy }}>
<PoliciesContext.Provider
value={{ updatePolicy, createPolicy, openEditPolicyModal }}
>
{children}
<Modal
open={policyModal}
onOpenChange={(state) => {
setPolicyModal(state);
setCurrentPolicy(undefined);
}}
>
<AccessControlModalContent
key={policyModal ? "1" : "0"}
policy={currentPolicy}
initialTab={initialPolicyTab}
onSuccess={async (p) => {
setPolicyModal(false);
setCurrentPolicy(undefined);
}}
/>
</Modal>
</PoliciesContext.Provider>
);
}

View File

@@ -22,5 +22,12 @@ export interface Account {
dns_domain: string;
network_range?: string;
lazy_connection_enabled: boolean;
auto_update_version: string;
};
onboarding?: AccountOnboarding;
}
export interface AccountOnboarding {
onboarding_flow_pending: boolean;
signup_form_pending: boolean;
}

View File

@@ -26,3 +26,14 @@ export enum GroupIssued {
INTEGRATION = "integration",
JWT = "jwt",
}
export const GROUP_TOOLTIP_TEXT = {
RENAME: {
JWT: "This group is issued by JWT and cannot be renamed.",
INTEGRATION: "This group is issued by an IdP and cannot be renamed.",
},
DELETE: {
INTEGRATION: "This group is issued by an IdP and cannot be deleted.",
},
IN_USE: "Remove dependencies to this group to delete it.",
};

View File

@@ -104,50 +104,4 @@ export const NameserverPresets: Record<string, NameserverGroup> = {
enabled: true,
search_domains_enabled: false,
},
DNS0: {
name: "DNS0.EU",
description: "DNS0.EU DNS Servers",
primary: true,
domains: [],
nameservers: [
{
ip: "193.110.81.0",
ns_type: "udp",
port: 53,
id: "1",
},
{
ip: "185.253.5.0",
ns_type: "udp",
port: 53,
id: "2",
},
],
groups: [],
enabled: true,
search_domains_enabled: false,
},
DNS0Zero: {
name: "DNS0.EU Zero",
description: "DNS0.EU Zero DNS Servers",
primary: true,
domains: [],
nameservers: [
{
ip: "193.110.81.9",
ns_type: "udp",
port: 53,
id: "1",
},
{
ip: "185.253.5.9",
ns_type: "udp",
port: 53,
id: "2",
},
],
groups: [],
enabled: true,
search_domains_enabled: false,
},
};

View File

@@ -28,3 +28,7 @@ export interface NetworkResource {
type?: "domain" | "host" | "subnet";
enabled: boolean;
}
export interface NetworkResourceWithNetwork extends NetworkResource {
network: Network;
}

View File

@@ -24,9 +24,24 @@ export interface Peer {
login_expiration_enabled: boolean;
inactivity_expiration_enabled: boolean;
approval_required: boolean;
disapproval_reason?: string;
city_name: string;
country_code: string;
connection_ip: string;
serial_number: string;
ephemeral: boolean;
local_flags?: PeerLocalFlags;
}
export interface PeerLocalFlags {
block_inbound: boolean;
block_lan_access: boolean;
disable_client_routes: boolean;
disable_dns: boolean;
disable_firewall: boolean;
disable_server_routes: boolean;
lazy_connection_enabled: boolean;
rosenpass_enabled: boolean;
rosenpass_permissive: boolean;
server_ssh_allowed: boolean;
}

View File

@@ -25,8 +25,11 @@ export interface PolicyRule {
port_ranges?: PortRange[];
sourceResource?: PolicyRuleResource;
destinationResource?: PolicyRuleResource;
authorized_groups?: AuthorizedGroups;
}
export type AuthorizedGroups = Record<string, string[]>; // group_id, local machine usernames
export interface PortRange {
start: number;
end: number;
@@ -37,4 +40,4 @@ export interface PolicyRuleResource {
type?: "domain" | "host" | "subnet" | "peer";
}
export type Protocol = "all" | "tcp" | "udp" | "icmp";
export type Protocol = "all" | "tcp" | "udp" | "icmp" | "netbird-ssh";

View File

@@ -5,6 +5,7 @@ import { useOidcUser } from "@axa-fr/react-oidc";
import Button from "@components/Button";
import { UserAvatar } from "@components/ui/UserAvatar";
import { cn } from "@utils/helpers";
import { isNetBirdHosted } from "@utils/netbird";
import { useIsSm, useIsXs } from "@utils/responsive";
import { AnimatePresence, motion } from "framer-motion";
import { XIcon } from "lucide-react";
@@ -20,6 +21,7 @@ import GroupsProvider from "@/contexts/GroupsProvider";
import { usePermissions } from "@/contexts/PermissionsProvider";
import UsersProvider from "@/contexts/UsersProvider";
import Navigation from "@/layouts/Navigation";
import { OnboardingProvider } from "@/modules/onboarding/OnboardingProvider";
import Header, { headerHeight } from "./Header";
export default function DashboardLayout({
@@ -33,6 +35,7 @@ export default function DashboardLayout({
<AnnouncementProvider>
<GroupsProvider>
<CountryProvider>
{!isNetBirdHosted() && <OnboardingProvider />}
<DashboardPageContent>{children}</DashboardPageContent>
</CountryProvider>
</GroupsProvider>

View File

@@ -1,9 +1,12 @@
"use client";
import { ScrollArea } from "@components/ScrollArea";
import { SmallBadge } from "@components/ui/SmallBadge";
import { cn } from "@utils/helpers";
import * as React from "react";
import AccessControlIcon from "@/assets/icons/AccessControlIcon";
import ActivityIcon from "@/assets/icons/ActivityIcon";
import ControlCenterIcon from "@/assets/icons/ControlCenterIcon";
import DNSIcon from "@/assets/icons/DNSIcon";
import DocsIcon from "@/assets/icons/DocsIcon";
import PeerIcon from "@/assets/icons/PeerIcon";
@@ -67,6 +70,23 @@ export default function Navigation({
>
<div>
<SidebarItemGroup>
<SidebarItem
icon={<ControlCenterIcon size={16} />}
label={
<div className={"flex items-center gap-2"}>
Control Center
<SmallBadge
text={"Beta"}
variant={"sky"}
className={"text-[8px] leading-none py-[3px] px-[5px]"}
textClassName={"top-0"}
/>
</div>
}
href={"/control-center"}
visible={permission.policies.read}
/>
<SidebarItem
icon={<PeerIcon />}
label="Peers"
@@ -93,6 +113,12 @@ export default function Navigation({
exactPathMatch={true}
visible={permission.policies.read}
/>
<SidebarItem
label="Groups"
isChild
href={"/groups"}
visible={permission.policies.read}
/>
<SidebarItem
label="Posture Checks"
isChild

View File

@@ -39,17 +39,21 @@ import {
Power,
Share2,
Shield,
SquareTerminalIcon,
Text,
} from "lucide-react";
import React, { useMemo, useState } from "react";
import AccessControlIcon from "@/assets/icons/AccessControlIcon";
import { usePermissions } from "@/contexts/PermissionsProvider";
import { Group } from "@/interfaces/Group";
import { Policy, Protocol } from "@/interfaces/Policy";
import { Policy, PolicyRuleResource, Protocol } from "@/interfaces/Policy";
import { PostureCheck } from "@/interfaces/PostureCheck";
import { useAccessControl } from "@/modules/access-control/useAccessControl";
import { PostureCheckTab } from "@/modules/posture-checks/ui/PostureCheckTab";
import { PostureCheckTabTrigger } from "@/modules/posture-checks/ui/PostureCheckTabTrigger";
import { SSHAccessType } from "@/modules/access-control/ssh/SSHAccessType";
import { SSHAuthorizedGroups } from "@/modules/access-control/ssh/SSHAuthorizedGroups";
import { useUsers } from "@/contexts/UsersProvider";
type Props = {
children?: React.ReactNode;
@@ -116,6 +120,10 @@ type ModalProps = {
postureCheckTemplates?: PostureCheck[];
useSave?: boolean;
allowEditPeers?: boolean;
initialProtocol?: Protocol;
initialPorts?: number[];
initialDestinationResource?: PolicyRuleResource;
initialTab?: string;
};
export function AccessControlModalContent({
@@ -128,8 +136,13 @@ export function AccessControlModalContent({
initialDestinationGroups,
initialName,
initialDescription,
initialProtocol,
initialPorts,
initialDestinationResource,
initialTab,
}: Readonly<ModalProps>) {
const { permission } = usePermissions();
const { users } = useUsers();
const {
portDisabled,
@@ -163,6 +176,10 @@ export function AccessControlModalContent({
portRanges,
setPortRanges,
hasPortSupport,
sshAccessType,
setSshAccessType,
sshAuthorizedGroups,
setSshAuthorizedGroups,
} = useAccessControl({
policy,
postureCheckTemplates,
@@ -170,9 +187,13 @@ export function AccessControlModalContent({
initialDestinationGroups,
initialName,
initialDescription,
initialPorts,
initialProtocol,
initialDestinationResource,
});
const [tab, setTab] = useState(() => {
if (initialTab && initialTab !== "") return initialTab;
if (!cell) return "policy";
if (cell == "posture_checks") return "posture_checks";
return "policy";
@@ -239,10 +260,10 @@ export function AccessControlModalContent({
<TabsContent value={"policy"} className={"pb-8"}>
<div className={"px-8 flex-col flex gap-6"}>
<div
className={"flex justify-between items-center"}
className={"flex justify-between items-center gap-10"}
data-cy={"protocol-wrapper"}
>
<div>
<div className={"w-full"}>
<Label>Protocol</Label>
<HelpText className={"max-w-sm"}>
Allow only specified network protocols. To change traffic
@@ -258,7 +279,7 @@ export function AccessControlModalContent({
!permission.policies.update || !permission.policies.create
}
>
<SelectTrigger className="w-[140px]">
<SelectTrigger className="w-[280px]">
<div
className={"flex items-center gap-3"}
data-cy={"protocol-select-button"}
@@ -272,6 +293,7 @@ export function AccessControlModalContent({
<SelectItem value="tcp">TCP</SelectItem>
<SelectItem value="udp">UDP</SelectItem>
<SelectItem value="icmp">ICMP</SelectItem>
<SelectItem value="netbird-ssh">NetBird SSH</SelectItem>
</SelectContent>
</Select>
</div>
@@ -286,14 +308,15 @@ export function AccessControlModalContent({
dataCy={"source-group-selector"}
popoverWidth={500}
placeholder={"Select source(s)..."}
showRoutes={true}
showRoutes={protocol !== "netbird-ssh"}
showResources={false}
showPeers={true}
showResourceCounter={true}
showPeers={protocol !== "netbird-ssh"}
showResourceCounter={false}
showPeerCount={allowEditPeers}
disableInlineRemoveGroup={false}
values={sourceGroups}
onChange={setSourceGroups}
users={protocol === "netbird-ssh" ? users : undefined}
resource={sourceResource}
onResourceChange={setSourceResource}
saveGroupAssignments={useSave}
@@ -306,6 +329,7 @@ export function AccessControlModalContent({
value={direction}
onChange={setDirection}
disabled={destinationOnlyResources}
protocol={protocol}
destinationResource={destinationResource}
/>
@@ -319,7 +343,7 @@ export function AccessControlModalContent({
popoverWidth={500}
placeholder={"Select destination(s)..."}
showRoutes={true}
showResources={true}
showResources={protocol !== "netbird-ssh"}
showPeers={true}
showResourceCounter={true}
showPeerCount={allowEditPeers}
@@ -354,33 +378,79 @@ export function AccessControlModalContent({
</Callout>
)}
<div
className={cn(
"mb-2",
portDisabled && "opacity-30 pointer-events-none",
)}
>
{protocol === "netbird-ssh" ? (
<div>
<Label className={"flex items-center gap-2"}>
<Shield size={14} />
Ports
</Label>
<HelpText>
Allow network traffic and access only to specified ports.
Select ports or port ranges between 1 and 65535.
</HelpText>
</div>
<div className={""}>
<PortSelector
showAll={true}
ports={ports}
onPortsChange={setPorts}
portRanges={portRanges}
onPortRangesChange={setPortRanges}
disabled={portDisabled}
{destinationHasResources && (
<Callout
variant={"warning"}
icon={
<AlertCircleIcon
size={14}
className={"shrink-0 relative top-[3px] text-netbird"}
/>
}
className="mb-6"
>
SSH access only works on peers, not on routed resources.
Please ensure your destination groups contain peers for SSH
connectivity.
</Callout>
)}
<div
className={"flex justify-between items-center gap-10 mt-2"}
>
<div className={"w-full"}>
<Label className={"flex items-center gap-2"}>
<SquareTerminalIcon size={15} />
SSH Access
</Label>
<HelpText>
Select {`'Full Access'`} to allow SSH as any local user,
or {`'Limited Access'`} to specify which local users each
group is allowed to use.
</HelpText>
</div>
<SSHAccessType
value={sshAccessType}
onChange={setSshAccessType}
/>
</div>
<SSHAuthorizedGroups
sourceGroups={sourceGroups}
authorizedGroups={sshAuthorizedGroups}
setAuthorizedGroups={setSshAuthorizedGroups}
accessType={sshAccessType}
/>
</div>
</div>
) : (
<div
className={cn(
"mb-2 mt-2",
portDisabled && "opacity-30 pointer-events-none",
)}
>
<div>
<Label className={"flex items-center gap-2"}>
<Shield size={14} />
Ports
</Label>
<HelpText>
Allow network traffic and access only to specified ports.
Select ports or port ranges between 1 and 65535.
</HelpText>
</div>
<div className={""}>
<PortSelector
showAll={true}
ports={ports}
onPortsChange={setPorts}
portRanges={portRanges}
onPortRangesChange={setPortRanges}
disabled={portDisabled}
/>
</div>
</div>
)}
<FancyToggleSwitch
value={enabled}

View File

@@ -0,0 +1,50 @@
import * as React from "react";
import { Dispatch, SetStateAction } from "react";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@components/Select";
import { usePermissions } from "@/contexts/PermissionsProvider";
import { ShieldHalfIcon, ShieldUserIcon } from "lucide-react";
type Props = {
value: "full" | "limited";
onChange: Dispatch<SetStateAction<"full" | "limited">>;
};
export const SSHAccessType = ({ value, onChange }: Props) => {
const { permission } = usePermissions();
return (
<Select
value={value}
onValueChange={(v) => onChange(v as "full" | "limited")}
disabled={!permission?.policies?.update || !permission?.policies?.create}
>
<SelectTrigger className="w-[280px]">
<div
className={"flex items-center gap-3"}
data-cy={"protocol-select-button"}
>
{value === "full" ? (
<ShieldUserIcon size={15} className={"text-nb-gray-300 shrink-0"} />
) : (
<ShieldHalfIcon size={15} className={"text-nb-gray-300 shrink-0"} />
)}
<SelectValue placeholder="Select ssh access type..." />
</div>
</SelectTrigger>
<SelectContent data-cy={"ssh-access-selection"}>
<SelectItem value="full" className={"whitespace-nowrap"}>
Full Access
</SelectItem>
<SelectItem value="limited" className={"whitespace-nowrap"}>
Limited Access
</SelectItem>
</SelectContent>
</Select>
);
};

View File

@@ -0,0 +1,139 @@
import { InfoIcon } from "lucide-react";
import React, { useCallback, useEffect, useMemo } from "react";
import { Group } from "@/interfaces/Group";
import { AuthorizedGroups } from "@/interfaces/Policy";
import GroupBadge from "@components/ui/GroupBadge";
import { HorizontalUsersStack } from "@/modules/users/HorizontalUsersStack";
import { useUsers } from "@/contexts/UsersProvider";
import { cn } from "@utils/helpers";
import { Callout } from "@components/Callout";
import { SSHUsernameSelector } from "@/modules/access-control/ssh/SSHUsernameSelector";
type Props = {
sourceGroups?: Group[];
accessType?: "full" | "limited";
authorizedGroups?: AuthorizedGroups;
setAuthorizedGroups?: (authorizedGroups: AuthorizedGroups) => void;
};
export function SSHAuthorizedGroups({
sourceGroups,
authorizedGroups,
setAuthorizedGroups,
accessType,
}: Props) {
const isEmpty =
!authorizedGroups || Object.keys(authorizedGroups).length === 0;
useEffect(() => {
if (sourceGroups) {
let groupsMap: AuthorizedGroups = {};
sourceGroups.forEach((sourceGroup) => {
if (!sourceGroup?.name) return;
const groupId = sourceGroup?.id;
if (groupId) {
groupsMap[sourceGroup.name] = authorizedGroups?.[groupId] || [];
} else {
groupsMap[sourceGroup.name] = [];
}
});
setAuthorizedGroups?.(groupsMap);
}
}, [sourceGroups]);
const handleUserNamesChange = useCallback(
(groupName: string, values: string[]) => {
setAuthorizedGroups?.({
...authorizedGroups,
[groupName]: values || [],
});
},
[authorizedGroups, setAuthorizedGroups],
);
if (accessType === "full") return;
if ((accessType === "limited" && isEmpty) || !authorizedGroups) {
return (
<Callout
variant={"info"}
icon={<InfoIcon size={14} className={"shrink-0 relative top-[3px]"} />}
className="mt-3 py-[.75rem]"
>
You have not added any source groups yet, please add source groups in
order to specify which user group has access to which system users on
the destination machines.
</Callout>
);
}
return (
<div
className={cn(
"rounded-md overflow-hidden mt-3 py-2",
"border border-nb-gray-900 bg-nb-gray-920/30",
)}
>
{Object.entries(authorizedGroups).map(([groupName, usernames]) => (
<AuthorizedUserRow
key={groupName}
groupName={groupName}
usernames={usernames}
sourceGroups={sourceGroups}
handleUserNamesChange={(values) =>
handleUserNamesChange(groupName, values)
}
/>
))}
</div>
);
}
type RowProps = {
sourceGroups?: Group[];
groupName: string;
usernames: string[];
handleUserNamesChange: (usernames: string[]) => void;
};
function AuthorizedUserRow({
sourceGroups,
usernames,
groupName,
handleUserNamesChange,
}: RowProps) {
const { users } = useUsers();
const group = useMemo(
() => sourceGroups?.find((g) => g.name === groupName),
[sourceGroups, groupName],
);
const usersOfGroup = useMemo(
() =>
users?.filter((user) => user.auto_groups.includes(group?.id || "")) || [],
[users, group],
);
return (
group && (
<div className="flex gap-6 w-full items-center py-2 px-4">
<div className={"flex items-center gap-2 col-span-3"}>
<GroupBadge group={group} showNewBadge={true} />
<HorizontalUsersStack users={usersOfGroup} />
</div>
<div
className={
"flex items-center gap-4 min-w-[340px] max-w-[340px] ml-auto"
}
>
<SSHUsernameSelector
onChange={handleUserNamesChange}
values={usernames}
/>
</div>
</div>
)
);
}

View File

@@ -0,0 +1,261 @@
import Badge from "@components/Badge";
import { Callout } from "@components/Callout";
import { Checkbox } from "@components/Checkbox";
import { CommandItem } from "@components/Command";
import { DropdownInfoText } from "@components/DropdownInfoText";
import { Popover, PopoverContent, PopoverTrigger } from "@components/Popover";
import { IconArrowBack } from "@tabler/icons-react";
import { cn } from "@utils/helpers";
import { Command, CommandGroup, CommandInput, CommandList } from "cmdk";
import { trim } from "lodash";
import {
ChevronsUpDown,
CircleUserIcon,
SearchIcon,
XIcon,
} from "lucide-react";
import * as React from "react";
import { useMemo, useState } from "react";
import { useElementSize } from "@/hooks/useElementSize";
import { PostureCheck } from "@/interfaces/PostureCheck";
interface MultiSelectProps {
values?: string[];
onChange: (value: string[]) => void;
disabled?: boolean;
popoverWidth?: "auto" | number;
}
export function SSHUsernameSelector({
values,
onChange,
disabled = false,
popoverWidth = "auto",
}: Readonly<MultiSelectProps>) {
const searchRef = React.useRef<HTMLInputElement>(null);
const [open, setOpen] = useState(false);
const [inputRef, { width }] = useElementSize<HTMLButtonElement>();
const [search, setSearch] = useState("");
const toggle = (value: string) => {
if (disabled) return;
const previous = values || [];
if (previous.includes(value)) {
onChange(previous.filter((item) => item !== value));
} else {
onChange([...previous, value]);
}
setSearch("");
};
const notFound = useMemo(() => {
const isSearching = search.length > 0;
const trimmed = trim(search);
return trimmed && !values?.includes(trimmed) && isSearching;
}, [search, values]);
return (
<>
<Popover
open={open}
onOpenChange={(isOpen) => {
if (!isOpen) {
setTimeout(() => {
setSearch("");
}, 100);
}
setOpen(isOpen);
}}
>
<PopoverTrigger asChild>
<button
className={cn(
"min-h-[42px] w-full relative items-center",
"border border-neutral-200 dark:border-nb-gray-700 justify-between py-1.5 px-2.5",
"rounded-md bg-white text-sm dark:bg-nb-gray-900/40 flex dark:text-neutral-400/70 text-neutral-500 cursor-pointer hover:dark:bg-nb-gray-900/50",
)}
data-cy={"ssh-username-selector"}
disabled={disabled}
ref={inputRef}
>
<div
className={
"flex items-center gap-2 border-nb-gray-700 flex-wrap h-full"
}
>
{values?.length === 0 && (
<Badge variant={"gray"} className={"font-normal py-1"}>
<CircleUserIcon size={12} className={"shrink-0"} />
All Local Users
</Badge>
)}
{values?.map((user) => (
<Badge
key={user}
variant={"gray"}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
toggle(user);
}}
className={"font-normal py-1"}
>
{user}
<XIcon
size={12}
className={"cursor-pointer group-hover:text-black"}
/>
</Badge>
))}
</div>
<ChevronsUpDown size={18} className={"shrink-0"} />
</button>
</PopoverTrigger>
<PopoverContent
className="w-full p-0 shadow-sm shadow-nb-gray-950"
style={{
width: popoverWidth === "auto" ? width : popoverWidth,
}}
align="start"
side={"top"}
sideOffset={10}
>
<Command
className={"w-full flex"}
loop
filter={(value, search) => {
const formatValue = trim(value.toLowerCase());
const formatSearch = trim(search.toLowerCase());
if (formatValue.includes(formatSearch)) return 1;
return 0;
}}
>
<CommandList className={"w-full"}>
<div className={"relative"}>
<CommandInput
className={cn(
"min-h-[42px] w-full relative",
"border-b-0 border-t-0 border-r-0 border-l-0 border-neutral-200 dark:border-nb-gray-700 items-center",
"bg-transparent text-sm outline-none focus-visible:outline-none ring-0 focus-visible:ring-0",
"dark:placeholder:text-nb-gray-400 font-light placeholder:text-neutral-500 pl-10",
)}
data-cy={"ssh-username-input"}
ref={searchRef}
value={search}
onValueChange={setSearch}
placeholder={"E.g., root, ec2-user, ubuntu"}
/>
<div
className={
"absolute left-0 top-0 h-full flex items-center pl-4"
}
>
<div className={"flex items-center"}>
<SearchIcon size={14} />
</div>
</div>
<div
className={
"absolute right-0 top-0 h-full flex items-center pr-4"
}
>
<div
className={
"flex items-center bg-nb-gray-800 py-1 px-1.5 rounded-[4px] border border-nb-gray-500"
}
>
<IconArrowBack size={10} />
</div>
</div>
</div>
<div
className={cn(
"flex flex-col gap-2",
values?.length != 0 && "p-2",
values?.length != 0 && search && "p-2",
)}
>
{notFound && (
<CommandGroup>
<div
className={cn(
"max-h-[180px] overflow-y-auto flex flex-col gap-1",
)}
>
<CommandItem
key={search}
onSelect={() => {
toggle(search);
searchRef.current?.focus();
}}
value={search}
onClick={(e) => e.preventDefault()}
>
<Badge variant={"gray"} className={"font-normal py-1"}>
{search}
</Badge>
<div
className={"text-neutral-500 dark:text-nb-gray-300"}
>
Add username by pressing{" "}
<span className={"font-bold text-netbird"}>
{"'Enter'"}
</span>
</div>
</CommandItem>
</div>
</CommandGroup>
)}
<CommandGroup>
<div
className={cn(
"max-h-[180px] overflow-y-auto flex flex-col gap-1",
)}
>
{values?.map((user) => {
const isSelected = values?.includes(user);
return (
<CommandItem
key={user}
value={user.toString()}
onSelect={() => {
toggle(user);
searchRef.current?.focus();
}}
onClick={(e) => e.preventDefault()}
>
<div className={"flex items-center gap-2"}>
<Badge
variant={"gray"}
className={"font-normal py-1"}
>
{user}
</Badge>
</div>
<div
className={
"text-neutral-500 dark:text-nb-gray-300 font-medium flex items-center gap-2"
}
>
<Checkbox checked={isSelected} />
</div>
</CommandItem>
);
})}
</div>
</CommandGroup>
</div>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</>
);
}

View File

@@ -37,6 +37,9 @@ export default function AccessControlActiveCell({ policy }: Readonly<Props>) {
if (rule.destinationResource) {
rule.destinations = null;
}
if (rule.sourceResource) {
rule.sources = null;
}
});
updatePolicy(

View File

@@ -1,5 +1,9 @@
import MultipleGroups from "@components/ui/MultipleGroups";
import MultipleGroups, {
TransparentEditIconButton,
} from "@components/ui/MultipleGroups";
import { cn } from "@utils/helpers";
import React, { useMemo } from "react";
import { usePermissions } from "@/contexts/PermissionsProvider";
import { Group } from "@/interfaces/Group";
import { Policy } from "@/interfaces/Policy";
import { AccessControlResourceCell } from "@/modules/access-control/table/AccessControlResourceCell";
@@ -8,9 +12,13 @@ import EmptyRow from "@/modules/common-table-rows/EmptyRow";
type Props = {
policy: Policy;
};
export default function AccessControlDestinationsCell({
policy,
}: Readonly<Props>) {
const { permission } = usePermissions();
const canUpdate = permission?.policies?.update;
const firstRule = useMemo(() => {
if (policy.rules.length > 0) return policy.rules[0];
return undefined;
@@ -23,7 +31,10 @@ export default function AccessControlDestinationsCell({
}
return firstRule ? (
<MultipleGroups groups={firstRule.destinations as Group[]} />
<div className={cn("flex items-center gap-1", canUpdate && "group")}>
<MultipleGroups groups={firstRule.destinations as Group[]} />
{canUpdate && <TransparentEditIconButton />}
</div>
) : (
<EmptyRow />
);

View File

@@ -16,7 +16,9 @@ export default function AccessControlDirectionCell({
}, [policy]);
const bidirectional = firstRule ? firstRule.bidirectional : false;
const isSingleResource = !!firstRule?.destinationResource;
const isSingleResource =
!!firstRule?.destinationResource &&
firstRule?.destinationResource?.type !== "peer";
return (
<div className={"flex h-full"}>

View File

@@ -5,9 +5,9 @@ import {
TooltipProvider,
TooltipTrigger,
} from "@components/Tooltip";
import { orderBy } from "lodash";
import React, { useMemo } from "react";
import { Policy } from "@/interfaces/Policy";
import { parsePortsToStrings } from "@/modules/access-control/useAccessControl";
type Props = {
policy: Policy;
@@ -23,19 +23,7 @@ export default function AccessControlPortsCell({ policy }: Readonly<Props>) {
const hasPortRanges = rule?.port_ranges && rule?.port_ranges?.length > 0;
const hasAnyPorts = hasPorts || hasPortRanges;
const allPorts = useMemo(() => {
const ports = rule?.ports ?? [];
const portRanges =
rule?.port_ranges?.map((r) => {
if (r.start === r.end) return `${r.start}`;
return `${r.start}-${r.end}`;
}) ?? [];
return orderBy(
[...portRanges, ...ports],
[(p) => Number(p.split("-")[0])],
["asc"],
);
}, [rule]);
const allPorts = useMemo(() => parsePortsToStrings(rule), [rule]);
const firstTwoPorts = useMemo(() => {
return allPorts?.slice(0, 2) ?? [];

View File

@@ -1,5 +1,9 @@
import MultipleGroups from "@components/ui/MultipleGroups";
import MultipleGroups, {
TransparentEditIconButton,
} from "@components/ui/MultipleGroups";
import { cn } from "@utils/helpers";
import React, { useMemo } from "react";
import { usePermissions } from "@/contexts/PermissionsProvider";
import { Group } from "@/interfaces/Group";
import { Policy } from "@/interfaces/Policy";
import { AccessControlResourceCell } from "@/modules/access-control/table/AccessControlResourceCell";
@@ -8,7 +12,11 @@ import EmptyRow from "@/modules/common-table-rows/EmptyRow";
type Props = {
policy: Policy;
};
export default function AccessControlSourcesCell({ policy }: Props) {
const { permission } = usePermissions();
const canUpdate = permission?.policies?.update;
const firstRule = useMemo(() => {
if (policy.rules.length > 0) return policy.rules[0];
return undefined;
@@ -19,7 +27,13 @@ export default function AccessControlSourcesCell({ policy }: Props) {
}
return firstRule ? (
<MultipleGroups groups={firstRule.sources as Group[]} />
<div className={cn("flex items-center gap-1", canUpdate && "group")}>
<MultipleGroups
groups={firstRule.sources as Group[]}
showUsers={firstRule.protocol === "netbird-ssh"}
/>
{canUpdate && <TransparentEditIconButton />}
</div>
) : (
<EmptyRow />
);

View File

@@ -1,5 +1,6 @@
import Button from "@components/Button";
import ButtonGroup from "@components/ButtonGroup";
import Card from "@components/Card";
import FullTooltip from "@components/FullTooltip";
import InlineLink from "@components/InlineLink";
import SquareIcon from "@components/SquareIcon";
@@ -15,6 +16,7 @@ import { usePathname, useSearchParams } from "next/navigation";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { useSWRConfig } from "swr";
import AccessControlIcon from "@/assets/icons/AccessControlIcon";
import NoResults from "@/components/ui/NoResults";
import { usePermissions } from "@/contexts/PermissionsProvider";
import { useLocalStorage } from "@/hooks/useLocalStorage";
import type { Policy } from "@/interfaces/Policy";
@@ -35,6 +37,7 @@ type Props = {
policies?: Policy[];
isLoading: boolean;
headingTarget?: HTMLHeadingElement | null;
isGroupPage?: boolean;
};
export const AccessControlTableColumns: ColumnDef<Policy>[] = [
@@ -179,12 +182,13 @@ export default function AccessControlTable({
policies,
isLoading,
headingTarget,
isGroupPage,
}: Readonly<Props>) {
const { mutate } = useSWRConfig();
const path = usePathname();
const { permission } = usePermissions();
const params = useSearchParams();
const idParam = params.get("id") ?? undefined;
const idParam = !isGroupPage ? params.get("id") : undefined;
// Default sorting state of the table
const [sorting, setSorting] = useLocalStorage<SortingState>(
@@ -195,6 +199,7 @@ export default function AccessControlTable({
desc: true,
},
],
!isGroupPage,
);
const [editModal, setEditModal] = useState(false);
@@ -249,7 +254,13 @@ export default function AccessControlTable({
<DataTable
headingTarget={headingTarget}
isLoading={isLoading}
keepStateInLocalStorage={!idParam}
wrapperComponent={isGroupPage ? Card : undefined}
wrapperProps={isGroupPage ? { className: "mt-6 w-full" } : undefined}
paginationPaddingClassName={isGroupPage ? "px-0 pt-8" : undefined}
tableClassName={isGroupPage ? "mt-0 mb-2" : undefined}
inset={!isGroupPage}
minimal={isGroupPage}
keepStateInLocalStorage={!isGroupPage || !idParam}
initialSearch={idParam ? "" : undefined}
initialFilters={
idParam
@@ -278,25 +289,22 @@ export default function AccessControlTable({
}}
searchPlaceholder={"Search by name and description..."}
getStartedCard={
<GetStartedTest
icon={
<SquareIcon
icon={
<AccessControlIcon className={"fill-nb-gray-200"} size={20} />
}
color={"gray"}
size={"large"}
/>
}
title={"Create New Policy"}
description={
"It looks like you don't have any policies yet. Policies can allow connections by specific protocol and ports."
}
button={
isGroupPage ? (
<NoResults
className={"py-4"}
title={"This group is not used within any policies yet"}
description={
"Assign this group as either a source or destination inside a policy to see them listed here."
}
icon={
<AccessControlIcon size={20} className={"fill-nb-gray-300"} />
}
>
<div className={"flex gap-4 items-center justify-center"}>
<AccessControlModal>
<Button
variant={"primary"}
className={"mt-4"}
disabled={!permission.policies.create}
>
<PlusCircle size={16} />
@@ -304,25 +312,59 @@ export default function AccessControlTable({
</Button>
</AccessControlModal>
</div>
}
learnMore={
<>
Learn more about
<InlineLink
href={"https://docs.netbird.io/how-to/manage-network-access"}
target={"_blank"}
>
Access Controls
<ExternalLinkIcon size={12} />
</InlineLink>
</>
}
/>
</NoResults>
) : (
<GetStartedTest
icon={
<SquareIcon
icon={
<AccessControlIcon
className={"fill-nb-gray-200"}
size={20}
/>
}
color={"gray"}
size={"large"}
/>
}
title={"Create New Policy"}
description={
"It looks like you don't have any policies yet. Policies can allow connections by specific protocol and ports."
}
button={
<div className={"flex gap-4 items-center justify-center"}>
<AccessControlModal>
<Button
variant={"primary"}
disabled={!permission.policies.create}
>
<PlusCircle size={16} />
Add Policy
</Button>
</AccessControlModal>
</div>
}
learnMore={
<>
Learn more about
<InlineLink
href={
"https://docs.netbird.io/how-to/manage-network-access"
}
target={"_blank"}
>
Access Controls
<ExternalLinkIcon size={12} />
</InlineLink>
</>
}
/>
)
}
rightSide={() => (
<>
{policies && policies?.length > 0 && (
<div className={"flex ml-auto gap-4"}>
<div className={"flex items-center ml-auto"}>
<AccessControlModal>
<Button
variant={"primary"}

View File

@@ -1,12 +1,19 @@
import { notify } from "@components/Notification";
import { Direction } from "@components/ui/PolicyDirection";
import useFetchApi, { useApiCall } from "@utils/api";
import { merge, uniqBy } from "lodash";
import { merge, orderBy, uniqBy } from "lodash";
import { useEffect, useMemo, useRef, useState } from "react";
import { useSWRConfig } from "swr";
import { usePolicies } from "@/contexts/PoliciesProvider";
import { Group } from "@/interfaces/Group";
import { Policy, PortRange, Protocol } from "@/interfaces/Policy";
import {
AuthorizedGroups,
Policy,
PolicyRule,
PolicyRuleResource,
PortRange,
Protocol,
} from "@/interfaces/Policy";
import { PostureCheck } from "@/interfaces/PostureCheck";
import useGroupHelper from "@/modules/groups/useGroupHelper";
import { usePostureCheck } from "@/modules/posture-checks/usePostureCheck";
@@ -18,6 +25,9 @@ type Props = {
initialDestinationGroups?: Group[] | string[];
initialName?: string;
initialDescription?: string;
initialProtocol?: Protocol;
initialPorts?: number[];
initialDestinationResource?: PolicyRuleResource;
};
// TODO add reducer
@@ -29,6 +39,9 @@ export const useAccessControl = ({
initialName,
initialDescription,
onSuccess,
initialProtocol,
initialPorts,
initialDestinationResource,
}: Props = {}) => {
const { data: allPostureChecks, isLoading: isPostureChecksLoading } =
useFetchApi<PostureCheck[]>("/posture-checks");
@@ -75,6 +88,7 @@ export const useAccessControl = ({
const [enabled, setEnabled] = useState<boolean>(policy?.enabled ?? true);
const [ports, setPorts] = useState<number[]>(() => {
if (initialPorts) return initialPorts;
if (!firstRule) return [];
if (firstRule.ports == undefined) return [];
if (firstRule.ports.length > 0) {
@@ -93,7 +107,7 @@ export const useAccessControl = ({
});
const [protocol, setProtocol] = useState<Protocol>(
firstRule ? firstRule.protocol : "all",
firstRule ? firstRule.protocol : initialProtocol ?? "all",
);
const [direction, setDirection] = useState<Direction>(() => {
if (!firstRule) return "bi";
@@ -131,9 +145,24 @@ export const useAccessControl = ({
);
const [destinationResource, setDestinationResource] = useState(
firstRule?.destinationResource,
firstRule?.destinationResource ?? initialDestinationResource,
);
const [sshAccessType, setSshAccessType] = useState<"full" | "limited">(() => {
if (protocol === "netbird-ssh") {
return firstRule?.authorized_groups !== undefined &&
Object.keys(firstRule?.authorized_groups).length > 0
? "limited"
: "full";
} else {
return "full";
}
});
const [sshAuthorizedGroups, setSshAuthorizedGroups] = useState<
AuthorizedGroups | undefined
>(firstRule?.authorized_groups);
const { updateOrCreateAndNotify: checkToCreate } = usePostureCheck({});
const createPostureChecksWithoutID = async () => {
const checks = postureChecks.filter(
@@ -176,6 +205,7 @@ export const useAccessControl = ({
enabled,
ports: newPorts,
port_ranges: newPortRanges,
authorized_groups: sshAuthorizedGroups,
},
],
} as Policy;
@@ -226,10 +256,34 @@ export const useAccessControl = ({
destinations = tmp;
}
const [newPorts, newPortRanges] = parseAccessControlPorts(
ports,
portRanges,
);
let [newPorts, newPortRanges] = parseAccessControlPorts(ports, portRanges);
let authorizedGroups: AuthorizedGroups = {};
if (protocol === "netbird-ssh") {
// Set port 22 for SSH protocol
newPorts = ["22"];
newPortRanges = [];
const isEmpty =
!sshAuthorizedGroups ||
Object.keys(sshAuthorizedGroups).length === 0 ||
sshAccessType === "full";
if (!isEmpty) {
Object.entries(sshAuthorizedGroups).reduce(
(acc, [groupName, usernames]) => {
const group = groups?.find((group) => group.name === groupName);
if (group?.id) {
authorizedGroups[group.id] = usernames;
}
return acc;
},
{} as AuthorizedGroups,
);
} else {
authorizedGroups = {};
}
}
const policyObj = {
name,
@@ -252,6 +306,8 @@ export const useAccessControl = ({
destinationResource: destinationResource || undefined,
ports: newPorts,
port_ranges: newPortRanges,
authorized_groups:
protocol === "netbird-ssh" ? authorizedGroups : undefined,
},
],
} as Policy;
@@ -282,7 +338,10 @@ export const useAccessControl = ({
const hasPortSupport = (p: Protocol) => p === "tcp" || p === "udp";
const portDisabled = !hasPortSupport(protocol);
const isDestinationPeer = destinationResource?.type === "peer";
const destinationHasResources = useMemo(() => {
if (isDestinationPeer) return false;
if (destinationResource) return true;
return destinationGroups.some((group) => {
@@ -294,9 +353,10 @@ export const useAccessControl = ({
}
return false;
});
}, [destinationGroups, destinationResource]);
}, [destinationGroups, destinationResource, isDestinationPeer]);
const destinationOnlyResources = useMemo(() => {
if (isDestinationPeer) return false;
if (destinationResource) return true;
return (
@@ -318,13 +378,13 @@ export const useAccessControl = ({
return hasResources && !hasPeers;
})
);
}, [destinationGroups, destinationResource]);
}, [destinationGroups, destinationResource, isDestinationPeer]);
useEffect(() => {
if (destinationOnlyResources && direction !== "in") {
if (destinationOnlyResources && direction !== "in" && !isDestinationPeer) {
setDirection("in");
}
}, [destinationOnlyResources, direction, setDirection]);
}, [destinationOnlyResources, direction, setDirection, isDestinationPeer]);
return {
protocol,
@@ -358,6 +418,10 @@ export const useAccessControl = ({
destinationHasResources,
destinationOnlyResources,
hasPortSupport,
sshAccessType,
setSshAccessType,
sshAuthorizedGroups,
setSshAuthorizedGroups,
} as const;
};
@@ -376,3 +440,18 @@ const parseAccessControlPorts = (ports: number[], portRanges: PortRange[]) => {
const allRanges = [...portRanges, ...portRangesFromPorts];
return [undefined, allRanges];
};
export const parsePortsToStrings = (rule?: PolicyRule): string[] => {
if (!rule) return [];
const ports = rule?.ports ?? [];
const portRanges =
rule?.port_ranges?.map((r) => {
if (r.start === r.end) return `${r.start}`;
return `${r.start}-${r.end}`;
}) ?? [];
return orderBy(
[...portRanges, ...ports],
[(p) => Number(p.split("-")[0])],
["asc"],
);
};

View File

@@ -203,6 +203,14 @@ export default function ActivityDescription({ event }: Props) {
</div>
);
if (event.activity_code == "user.create")
return (
<div className={"inline"}>
<Value>{event.meta.username}</Value> <Value>{event.meta.email}</Value>{" "}
was created by <Value>{event?.initiator_name || "NetBird"}</Value>
</div>
);
if (event.activity_code == "user.group.add")
return (
<div className={"inline"}>
@@ -391,6 +399,14 @@ export default function ActivityDescription({ event }: Props) {
</div>
);
if (event.activity_code == "group.update")
return (
<div className={"inline"}>
Group <Value>{event.meta.old_name}</Value> was renamed to{" "}
<Value>{event.meta.new_name}</Value>
</div>
);
/**
* Account
*/

View File

@@ -0,0 +1,48 @@
import { SegmentedTabs } from "@components/SegmentedTabs";
import { FolderGit2, MonitorSmartphoneIcon, NetworkIcon } from "lucide-react";
import * as React from "react";
export enum FlowView {
NETWORKS = "networks",
GROUPS = "groups",
PEERS = "peers",
}
type Props = {
value?: FlowView;
onChange?: (value: FlowView) => void;
};
export const FlowSelector = ({ value, onChange }: Props) => {
return (
<SegmentedTabs value={value} onChange={(v) => onChange?.(v as FlowView)}>
<SegmentedTabs.List
className={
"border-b rounded-b-lg text-sm font-medium bg-nb-gray-930 p-1"
}
>
<SegmentedTabs.Trigger
value={FlowView.PEERS}
className={"text-xs px-3 py-1"}
>
<MonitorSmartphoneIcon size={12} />
Peers
</SegmentedTabs.Trigger>
<SegmentedTabs.Trigger
value={FlowView.GROUPS}
className={"text-xs px-3 py-1"}
>
<FolderGit2 size={12} />
Groups
</SegmentedTabs.Trigger>
<SegmentedTabs.Trigger
value={FlowView.NETWORKS}
className={"text-xs px-3 py-[0.45rem]"}
>
<NetworkIcon size={12} />
Networks
</SegmentedTabs.Trigger>
</SegmentedTabs.List>
</SegmentedTabs>
);
};

View File

@@ -0,0 +1,48 @@
import Button from "@components/Button";
import useFetchApi from "@utils/api";
import { cn } from "@utils/helpers";
import * as React from "react";
import { useMemo } from "react";
import CircleIcon from "@/assets/icons/CircleIcon";
import { Network, NetworkRouter } from "@/interfaces/Network";
import { Peer } from "@/interfaces/Peer";
type Props = {
network: Network;
};
export const NetworkRoutingPeerCount = ({ network }: Props) => {
const { data: routers, isLoading: isRoutersLoading } =
useFetchApi<NetworkRouter[]>("/networks/routers");
const { data: peers, isLoading: isPeersLoading } =
useFetchApi<Peer[]>("/peers");
const routingPeerStatusColor = useMemo(() => {
if (!network) return "bg-nb-gray-500";
const routerCount = network.routers?.length || 0;
if (routerCount === 0) return "bg-nb-gray-500";
if (routerCount === 1) return "bg-yellow-400";
if (routerCount > 1) return "bg-green-400";
return "bg-nb-gray-500";
}, [network]);
const networkRouters = useMemo(() => {
if (!network || !peers) return [];
const routerIds = network?.routers?.map((r) => r) || [];
return routers?.filter((r) => routerIds.includes(r.id)) || [];
}, [network, peers, routers]);
return (
<Button
variant={"secondary"}
size={"xs"}
className={"!bg-nb-gray-930 !text-nb-gray-300 cursor-default"}
>
<CircleIcon
size={8}
className={cn("shrink-0 block", routingPeerStatusColor)}
/>
{network.routers?.length || 0} Routing Peer(s)
</Button>
);
};

View File

@@ -0,0 +1,125 @@
import { Edge, useInternalNode } from "@xyflow/react";
import React from "react";
import { getEdgeParams } from "@/modules/control-center/utils/edge-helper";
type AnimatedLineProps = Edge<
{
label?: string;
color?: string;
},
"animated-line"
>;
function AnimatedLine({ id, source, target, data }: AnimatedLineProps) {
const sourceNode = useInternalNode(source);
const targetNode = useInternalNode(target);
if (!sourceNode || !targetNode) return null;
const { sx, sy, tx, ty } = getEdgeParams(sourceNode, targetNode);
const labelX = (sx + tx) / 2;
const labelY = (sy + ty) / 2;
let angle = Math.atan2(ty - sy, tx - sx) * (180 / Math.PI);
if (angle < -90 || angle > 90) {
angle += 180;
}
const label = data?.label || "";
const hasLabel = label?.length > 0;
const fontSize = 12;
const paddingX = hasLabel ? 2 : 0;
const paddingY = hasLabel ? 2 : 0;
const gapWidth = hasLabel ? 4 : 0;
const labelTextWidth = label.length * 7;
const labelWidth = gapWidth + labelTextWidth + paddingX * 2;
const labelHeight = fontSize + paddingY * 2;
const dx = tx - sx;
const dy = ty - sy;
const length = Math.sqrt(dx * dx + dy * dy);
const gap = labelWidth / 2;
const nx = dx / length;
const ny = dy / length;
const preLabelX = labelX - nx * gap;
const preLabelY = labelY - ny * gap;
const postLabelX = labelX + nx * gap;
const postLabelY = labelY + ny * gap;
const color = data?.color || "#0e9f6e";
return (
<>
<line
x1={sx}
y1={sy}
x2={preLabelX}
y2={preLabelY}
stroke={color}
strokeWidth={2}
strokeDasharray="5, 5"
>
<animate
attributeName="stroke-dashoffset"
from="20"
to="0"
dur="0.5s"
repeatCount="indefinite"
/>
</line>
<line
x1={postLabelX}
y1={postLabelY}
x2={tx}
y2={ty}
stroke={color}
strokeWidth={2}
strokeDasharray="5, 5"
>
<animate
attributeName="stroke-dashoffset"
from="20"
to="0"
dur="0.5s"
repeatCount="indefinite"
/>
</line>
{label && hasLabel && (
<foreignObject
x={labelX - labelWidth / 2}
y={labelY - labelHeight / 2}
width={labelWidth}
height={labelHeight}
style={{ overflow: "visible" }}
>
<div
style={{
width: labelWidth,
height: labelHeight,
fontSize,
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: `${paddingY}px ${paddingX}px`,
transform: `rotate(${angle}deg)`,
transformOrigin: "center center",
boxSizing: "border-box",
background: "none",
}}
className={
"flex items-center justify-center gap-1 select-none pointer-events-none z-10 text-green-50"
}
>
<div className={"whitespace-nowrap"}>{label}</div>
</div>
</foreignObject>
)}
</>
);
}
export default AnimatedLine;

View File

@@ -0,0 +1,70 @@
import { BaseEdge, type EdgeProps, getSmoothStepPath } from "@xyflow/react";
import React from "react";
export function BidirectionalEdges({
id,
sourceX,
sourceY,
targetX,
targetY,
sourcePosition,
targetPosition,
}: EdgeProps) {
const [forwardPath] = getSmoothStepPath({
sourceX: sourceX - 5,
sourceY: sourceY - 5,
sourcePosition,
targetX: targetX + 15,
targetY: targetY - 5,
targetPosition,
});
const [backwardPath] = getSmoothStepPath({
sourceX: targetX + 5,
sourceY: targetY + 5,
sourcePosition: targetPosition,
targetX: sourceX - 15,
targetY: sourceY + 5,
targetPosition: sourcePosition,
});
return (
<>
<BaseEdge
id={`${id}-forward`}
path={forwardPath}
style={{
strokeWidth: 2,
stroke: "#0e9f6e",
strokeDasharray: "5, 5",
}}
>
<animate
attributeName="stroke-dashoffset"
from="20"
to="0"
dur="0.5s"
repeatCount="indefinite"
/>
</BaseEdge>
<BaseEdge
id={`${id}-backward`}
path={backwardPath}
style={{
strokeWidth: 2,
stroke: "#0e9f6e",
strokeDasharray: "5, 5",
}}
>
<animate
attributeName="stroke-dashoffset"
from="20"
to="0"
dur="0.5s"
repeatCount="indefinite"
/>
</BaseEdge>
</>
);
}

View File

@@ -0,0 +1,92 @@
import {
BaseEdge,
type EdgeProps,
getSimpleBezierPath,
getSmoothStepPath,
getStraightPath,
} from "@xyflow/react";
import React from "react";
type Props = {
data: {
enabled: boolean;
type: "smoothstep" | "straight" | "bezier";
};
} & EdgeProps;
export function DirectionIn({
id,
sourceX,
sourceY,
targetX,
targetY,
sourcePosition,
targetPosition,
data,
}: Props) {
const { enabled, type = "straight" } = data;
const getPath = () => {
switch (type) {
case "straight":
return getStraightPath({
sourceX,
sourceY,
targetX,
targetY,
});
case "bezier":
return getSimpleBezierPath({
sourceX,
sourceY,
sourcePosition,
targetX,
targetY,
targetPosition,
});
case "smoothstep":
return getSmoothStepPath({
sourceX,
sourceY,
sourcePosition,
targetX,
targetY,
targetPosition,
});
default:
return getSmoothStepPath({
sourceX,
sourceY,
sourcePosition,
targetX,
targetY,
targetPosition,
});
}
};
const [edgePath] = getPath();
return (
<BaseEdge
id={id}
path={edgePath}
style={{
opacity: enabled ? 1 : 0.6,
strokeWidth: 2,
stroke: enabled ? "#0e9f6e" : "#787878",
strokeDasharray: "5, 5",
}}
>
{enabled && (
<animate
attributeName="stroke-dashoffset"
from="20"
to="0"
dur="0.5s"
repeatCount="indefinite"
/>
)}
</BaseEdge>
);
}

View File

@@ -0,0 +1,53 @@
import {
BaseEdge,
EdgeProps,
getBezierPath,
useInternalNode,
} from "@xyflow/react";
import React from "react";
import { getEdgeParams } from "@/modules/control-center/utils/edge-helper";
function FloatingEdge({ id, source, target, markerEnd, style }: EdgeProps) {
const sourceNode = useInternalNode(source);
const targetNode = useInternalNode(target);
if (!sourceNode || !targetNode) {
return null;
}
const { sx, sy, tx, ty, sourcePos, targetPos } = getEdgeParams(
sourceNode,
targetNode,
);
const [edgePath] = getBezierPath({
sourceX: sx,
sourceY: sy,
sourcePosition: sourcePos,
targetPosition: targetPos,
targetX: tx,
targetY: ty,
});
return (
<BaseEdge
id={id}
path={edgePath}
style={{
strokeWidth: 2,
stroke: "#0e9f6e",
strokeDasharray: "5, 5",
}}
>
<animate
attributeName="stroke-dashoffset"
from="20"
to="0"
dur="0.5s"
repeatCount="indefinite"
/>
</BaseEdge>
);
}
export default FloatingEdge;

View File

@@ -0,0 +1,45 @@
import { BaseEdge, type EdgeProps, getSimpleBezierPath } from "@xyflow/react";
import React from "react";
import { useSourceGroupEnabled } from "@/modules/control-center/utils/helpers";
type Props = {
data: {
enabled: boolean;
};
} & EdgeProps;
export function SimpleConnection({
id,
sourceX,
sourceY,
targetX,
targetY,
sourcePosition,
targetPosition,
data,
source,
}: Props) {
const [edgePath] = getSimpleBezierPath({
sourceX,
sourceY,
sourcePosition,
targetX,
targetY,
targetPosition,
});
const enabled = useSourceGroupEnabled(source);
return (
<BaseEdge
id={id}
path={edgePath}
style={{
strokeWidth: 1.5,
stroke: "#595959",
strokeDasharray: "0, 0",
opacity: enabled ? 1 : 0.6,
}}
></BaseEdge>
);
}

View File

@@ -0,0 +1,111 @@
import TruncatedText from "@components/ui/TruncatedText";
import { getOperatingSystem } from "@hooks/useOperatingSystem";
import { cn } from "@utils/helpers";
import { GlobeIcon, NetworkIcon, WorkflowIcon } from "lucide-react";
import * as React from "react";
import RoundedFlag from "@/assets/countries/RoundedFlag";
import { NetworkResource } from "@/interfaces/Network";
import { OperatingSystem } from "@/interfaces/OperatingSystem";
import type { Peer } from "@/interfaces/Peer";
import { OSLogo } from "@/modules/peers/PeerOSCell";
type DeviceCardProps = {
device?: Peer;
resource?: NetworkResource;
className?: string;
};
export const DeviceCard = ({
device,
resource,
className,
}: DeviceCardProps) => {
if (!device && !resource) return;
return (
<div
className={cn(
"flex shrink-0 items-center gap-2.5 text-nb-gray-300 text-left py-1 pl-3 pr-4 rounded-md group/machine my-0 w-[200px]",
className,
)}
>
<div
className={cn(
"flex items-center justify-center rounded-md h-9 w-9 shrink-0 bg-nb-gray-850 transition-all",
"group-hover:bg-nb-gray-800 relative",
)}
>
{device && <PeerOSIcon os={device.os} />}
{resource?.type && <ResourceIcon type={resource.type} />}
{device?.country_code && (
<div className={"absolute -bottom-[4px] -right-[4px]"}>
<div
className={cn(
"flex items-center justify-center rounded-full border-[3px] shrink-0",
"border-nb-gray-940",
)}
>
<RoundedFlag country={device?.country_code} size={10} />
</div>
</div>
)}
</div>
<div className={"flex flex-col gap-0 justify-center mt-2 leading-tight"}>
<span
className={
"mb-1.5 font-normal text-[0.85rem] text-nb-gray-100 flex items-center gap-2"
}
>
<TruncatedText
text={device?.name || resource?.name || "Unknown"}
maxWidth={"150px"}
hideTooltip={true}
/>
</span>
<span
className={
"text-sm font-normal text-nb-gray-400 -top-[0.3rem] relative"
}
>
{device?.ip || resource?.address}
</span>
</div>
</div>
);
};
const PeerOSIcon = ({ os }: { os: string }) => {
const osType = getOperatingSystem(os);
return (
<div
className={cn(
"flex items-center justify-center grayscale brightness-[100%] contrast-[40%]",
"w-4 h-4 shrink-0",
osType === OperatingSystem.WINDOWS && "p-[2.5px]",
osType === OperatingSystem.APPLE && "p-[2.7px]",
osType === OperatingSystem.FREEBSD && "p-[1.5px]",
)}
>
<OSLogo os={os} />
</div>
);
};
const ResourceIcon = ({
type,
size = 15,
}: {
type: "domain" | "host" | "subnet";
size?: number;
}) => {
switch (type) {
case "domain":
return <GlobeIcon size={size} />;
case "subnet":
return <NetworkIcon size={size} />;
case "host":
return <WorkflowIcon size={size} />;
default:
return <WorkflowIcon size={size} />;
}
};

View File

@@ -0,0 +1,80 @@
import { GroupBadgeIcon } from "@components/ui/GroupBadgeIcon";
import { cn } from "@utils/helpers";
import { Handle, type Node, Position } from "@xyflow/react";
import * as React from "react";
import { useMemo } from "react";
import { Group } from "@/interfaces/Group";
type GroupNodeProps = Node<
{
group: Group;
enabled: boolean;
hoverable?: boolean;
onClick?: (g: Group) => void;
},
"groupNode"
>;
export const GroupNode = ({ data, id }: GroupNodeProps) => {
const { enabled = true, group, hoverable = true, onClick } = data;
const countLabel = useMemo(() => {
const peerCount = group?.peers_count || 0;
const resourceCount = group?.resources_count || 0;
if (resourceCount === 0) {
return `${peerCount} Peer(s)`;
}
if (peerCount === 0) {
return `${resourceCount} Resource(s)`;
}
return `${peerCount} Peer(s), ${resourceCount} Resource(s)`;
}, [group?.peers_count, group?.resources_count]);
return (
<div
className={cn(
"cc-group-node bg-nb-gray-940 border border-nb-gray-800 rounded-lg overflow-hidden transition-all",
!enabled && "opacity-60",
hoverable && "hover:bg-nb-gray-930 cursor-pointer",
)}
onClick={() => onClick?.(group)}
>
<div
className={
"flex w-full items-center justify-between text-nb-gray-300 gap-2 text-sm pl-3 pr-5 py-3 font-normal"
}
>
<div className={"flex items-center gap-3 font-normal text-sm"}>
<div
className={
"h-9 w-9 bg-nb-gray-850 rounded-md flex items-center justify-center shrink-0"
}
>
<GroupBadgeIcon id={group?.id} issued={group?.issued} size={14} />
</div>
<div>
<div className={" text-nb-gray-200 font-normal whitespace-nowrap"}>
{group.name}
</div>
<div className={"text-nb-gray-400 whitespace-nowrap text-xs"}>
{countLabel}
</div>
</div>
</div>
</div>
<Handle
type="source"
position={Position.Right}
id={"sr"}
className={"opacity-0"}
/>
<Handle
type="target"
position={Position.Left}
id={"tl"}
className={"opacity-0"}
/>
</div>
);
};

View File

@@ -0,0 +1,102 @@
import useFetchApi from "@utils/api";
import { cn } from "@utils/helpers";
import { Handle, type Node, Position } from "@xyflow/react";
import { NetworkIcon } from "lucide-react";
import * as React from "react";
import CircleIcon from "@/assets/icons/CircleIcon";
import { DeviceCard } from "@/modules/control-center/nodes/DeviceCard";
import { Network, NetworkResource } from "@/interfaces/Network";
type NetworkNodeType = {
network: Network;
};
type NetworkNodeProps = Node<NetworkNodeType, "networkNode">;
export const NetworkNode = ({ data }: NetworkNodeProps) => {
const { data: networkResources, isLoading: isLoadingResources } = useFetchApi<
NetworkResource[]
>("/networks/resources");
const n = data.network as Network;
const resourceIds = n?.resources || [];
const routingPeers = n?.routers || [];
const resources =
networkResources?.filter((r) => resourceIds.includes(r?.id || "")) || [];
return (
<div
className={cn(
"bg-nb-gray-940 border border-nb-gray-800 rounded-2xl overflow-hidden group hover:bg-nb-gray-935 transition-all cursor-pointer",
)}
>
<div
className={cn(
"flex w-full items-center justify-between text-nb-gray-300 gap-2 text-sm pl-6 pr-6 py-3.5 font-normal bg-nb-gray-935 border-b border-nb-gray-800 group-hover:bg-nb-gray-930 transition-all",
resources?.length === 0 && "border-b-0",
)}
>
<div className={"flex items-center gap-3 font-normal text-sm"}>
<div>
<div
className={
" text-nb-gray-100 font-medium whitespace-nowrap flex items-center gap-2"
}
>
<NetworkIcon size={12} />
{n?.name}
</div>
<div className={"text-nb-gray-400 whitespace-nowrap mt-0.5"}>
{resources?.length || 0} Resources
</div>
</div>
</div>
<div className={"flex items-center gap-2 text-xs"}>
<CircleIcon
size={8}
className={cn(
"shrink-0 block",
routingPeers?.length === 0 && "bg-nb-gray-500",
routingPeers?.length === 1 && "bg-yellow-400",
routingPeers?.length > 1 && "bg-green-400",
)}
/>
{routingPeers?.length || 0} Routing Peer(s)
</div>
</div>
{resources && resources.length > 0 && (
<div className={"p-2 flex flex-col gap-4 relative"}>
<div className={"grid grid-cols-2 relative z-0"}>
{resources?.slice(0, 6).map((r) => {
return <DeviceCard resource={r} key={r.id} />;
})}
</div>
<div
className={cn(
"absolute w-full h-full bg-gradient-to-b from-transparent via-nb-gray-940/20 to-nb-gray-940 z-10 left-0 top-0 pointer-events-none",
resources?.length > 6 ? "opacity-100" : "opacity-0",
)}
></div>
</div>
)}
<Handle
type="source"
position={Position.Right}
id={"sr"}
style={{
opacity: 0,
}}
/>
<Handle
type="target"
position={Position.Left}
id={"tl"}
style={{
opacity: 0,
}}
/>
</div>
);
};

View File

@@ -0,0 +1,44 @@
import { cn } from "@utils/helpers";
import { Handle, type Node, Position } from "@xyflow/react";
import * as React from "react";
import type { Peer } from "@/interfaces/Peer";
import { DeviceCard } from "@/modules/control-center/nodes/DeviceCard";
import { useAnySourceGroupEnabled } from "@/modules/control-center/utils/helpers";
type PeerNodeProps = Node<
{
peer: Peer;
enabled?: boolean;
},
"peerNode"
>;
export const PeerNode = ({ data, id }: PeerNodeProps) => {
const { peer, enabled } = data;
const isEnabled = useAnySourceGroupEnabled(id);
return (
<div
className={
"border-0 border-nb-gray-800 rounded-lg overflow-hidden transition-all"
}
>
<DeviceCard
device={peer}
className={cn("p-0", !isEnabled && "opacity-60")}
/>
<Handle
type="source"
position={Position.Right}
id={"sr"}
className={"opacity-0"}
/>
<Handle
type="target"
position={Position.Left}
id={"tl"}
className={"opacity-0"}
/>
</div>
);
};

View File

@@ -0,0 +1,66 @@
import { GroupBadgeIcon } from "@components/ui/GroupBadgeIcon";
import { cn } from "@utils/helpers";
import { Handle, type Node, Position } from "@xyflow/react";
import * as React from "react";
import { getPolicyProtocolAndPortText } from "@/modules/control-center/utils/helpers";
import { Policy } from "@/interfaces/Policy";
type PolicyNode = Node<
{
policy: Policy;
},
"policyNode"
>;
export const PolicyNode = ({ data }: PolicyNode) => {
const rule = data.policy.rules?.[0];
const label = getPolicyProtocolAndPortText(data.policy);
const isActive = rule?.enabled;
return (
<div
className={cn(
"relative bg-nb-gray-940 hover:bg-nb-gray-930 cursor-pointer border border-nb-gray-800 rounded-full flex justify-between overflow-hidden",
!isActive && "opacity-60",
)}
>
<div className={"flex items-center justify-center"}>
<div
className={cn(
"h-2 w-2 rounded-full ml-3 mr-2",
isActive ? "bg-green-400" : "bg-nb-gray-400",
)}
></div>
</div>
<div className={"pt-2.5 pb-[0.6rem] pr-3 flex gap-4 leading-none"}>
<div
className={
" text-nb-gray-200 font-normal whitespace-nowrap text-[0.8rem] flex items-center justify-center w-full"
}
>
<div className={"truncate max-w-[200px]"}>{rule?.name}</div>
</div>
</div>
<div
className={
"border-l border-nb-gray-800 flex items-center text-nb-gray-300 text-[0.65rem] pl-2 pr-3 font-mono"
}
>
<div>{label === "" ? "All" : label}</div>
</div>
<Handle
type="source"
position={Position.Right}
id={"sr"}
className={"opacity-0"}
/>
<Handle
type="target"
position={Position.Left}
id={"tl"}
className={"opacity-0"}
/>
</div>
);
};

View File

@@ -0,0 +1,41 @@
import { cn } from "@utils/helpers";
import { Handle, type Node, Position } from "@xyflow/react";
import * as React from "react";
import { NetworkResource } from "@/interfaces/Network";
import { DeviceCard } from "@/modules/control-center/nodes/DeviceCard";
import { useAnySourceGroupEnabled } from "@/modules/control-center/utils/helpers";
type ResourceNode = Node<
{
resource: NetworkResource;
enabled?: boolean;
},
"resourceNode"
>;
export const ResourceNode = ({ data, id }: ResourceNode) => {
const { enabled, resource } = data;
const isEnabled = useAnySourceGroupEnabled(id);
return (
<div
className={
"cursor-pointer border-0 border-nb-gray-800 rounded-lg overflow-hidden transition-all"
}
>
<DeviceCard
resource={resource}
className={cn("p-0", !isEnabled && "opacity-60")}
/>
<Handle
type="target"
position={Position.Left}
id={"tl"}
style={{
opacity: 0,
}}
/>
</div>
);
};

View File

@@ -0,0 +1,135 @@
import {
SelectDropdown,
SelectOption,
} from "@components/select/SelectDropdown";
import { GroupBadgeIcon } from "@components/ui/GroupBadgeIcon";
import useFetchApi from "@utils/api";
import { Handle, type Node, Position } from "@xyflow/react";
import { sortBy } from "lodash";
import { ChevronsUpDown } from "lucide-react";
import * as React from "react";
import { useMemo } from "react";
import { Group } from "@/interfaces/Group";
type NodeProps = Node<
{
currentGroup: string;
onChange: (id: string) => void;
},
"selectGroupNode"
>;
export const SelectGroupNode = ({ data, id }: NodeProps) => {
const { data: groups, isLoading: isGroupsLoading } =
useFetchApi<Group[]>("/groups");
const groupOptions: SelectOption[] = sortBy(
groups?.map(
(g) =>
({
value: g.id,
label: g.name,
icon: () => (
<GroupBadgeIcon id={g?.id} issued={g?.issued} size={14} />
),
}) as SelectOption,
) || [],
"label",
"asc",
);
const group = groups?.find((g) => g.id === data.currentGroup);
const countLabel = useMemo(() => {
const peerCount = group?.peers_count || 0;
const resourceCount = group?.resources_count || 0;
if (resourceCount === 0) {
return `${peerCount} Peer(s)`;
}
if (peerCount === 0) {
return `${resourceCount} Resource(s)`;
}
return `${peerCount} Peer(s), ${resourceCount} Resource(s)`;
}, [group]);
return (
<div
className={
"bg-nb-gray-930 border hover:bg-nb-gray-910 cursor-pointer border-nb-gray-800 rounded-lg overflow-hidden transition-all"
}
>
<SelectDropdown
variant={"secondary"}
value={data.currentGroup}
onChange={data.onChange}
options={groupOptions}
showSearch={true}
searchPlaceholder={"Search groups..."}
popoverWidth={280}
className={"!bg-nb-gray-920 !hover:bg-nb-gray-925 !text-nb-gray-300"}
size={"xs"}
maxHeight={300}
>
<div className={"flex items-center justify-between gap-8 pr-3"}>
{group && (
<div
className={
"flex w-full items-center justify-between text-nb-gray-300 gap-2 text-sm pl-3 pr-5 py-3 font-normal"
}
>
<div className={"flex items-center gap-3 font-normal text-sm"}>
<div
className={
"h-9 w-9 bg-nb-gray-850 rounded-md flex items-center justify-center shrink-0"
}
>
<GroupBadgeIcon
id={group?.id}
issued={group?.issued}
size={14}
/>
</div>
<div>
<div
className={
" text-nb-gray-200 font-normal whitespace-nowrap text-left"
}
>
{group.name}
</div>
<div
className={
"text-nb-gray-400 whitespace-nowrap text-xs text-left"
}
>
{countLabel}
</div>
</div>
</div>
</div>
)}
<ChevronsUpDown size={18} className={"shrink-0"} />
</div>
</SelectDropdown>
<Handle
type="source"
position={Position.Right}
id={"sr"}
style={{
height: 20,
width: "1px",
border: "none",
backgroundColor: "#3f444b",
borderRadius: "0px 4px 4px 0px",
right: -2,
}}
/>
<Handle
type="target"
position={Position.Left}
id={"tl"}
className={"opacity-0"}
/>
</div>
);
};

View File

@@ -0,0 +1,102 @@
import {
SelectDropdown,
SelectOption,
} from "@components/select/SelectDropdown";
import useFetchApi from "@utils/api";
import { cn } from "@utils/helpers";
import { Handle, type Node, Position } from "@xyflow/react";
import { sortBy } from "lodash";
import { ChevronsUpDown } from "lucide-react";
import * as React from "react";
import { OperatingSystem } from "@/interfaces/OperatingSystem";
import type { Peer } from "@/interfaces/Peer";
import { DeviceCard } from "@/modules/control-center/nodes/DeviceCard";
import { OSLogo } from "@/modules/peers/PeerOSCell";
type PeerNodeProps = Node<
{
currentPeer: string;
onPeerChange: (peerId: string) => void;
},
"selectPeerNode"
>;
export const SelectPeerNode = ({ data, id }: PeerNodeProps) => {
const { data: peers, isLoading: isPeersLoading } =
useFetchApi<Peer[]>("/peers");
const peerSelectOptions: SelectOption[] = sortBy(
peers?.map(
(p) =>
({
value: p.id,
label: p.name,
icon: () => {
const os = p.os as unknown as OperatingSystem;
return (
<div
className={cn(
"flex items-center justify-center grayscale brightness-[100%] contrast-[40%]",
"w-4 h-4 shrink-0",
os === OperatingSystem.WINDOWS && "p-[2.5px]",
os === OperatingSystem.APPLE && "p-[2.7px]",
os === OperatingSystem.FREEBSD && "p-[1.5px]",
)}
>
<OSLogo os={p.os} />
</div>
);
},
}) as SelectOption,
) || [],
"label",
"asc",
);
const peer = peers?.find((p) => p.id === data.currentPeer);
return (
<div
className={
"bg-nb-gray-930 border hover:bg-nb-gray-910 cursor-pointer border-nb-gray-800 rounded-lg overflow-hidden transition-all"
}
>
<SelectDropdown
variant={"secondary"}
value={data.currentPeer}
onChange={data.onPeerChange}
options={peerSelectOptions}
showSearch={true}
searchPlaceholder={"Search peers..."}
popoverWidth={280}
className={"!bg-nb-gray-920 !hover:bg-nb-gray-925 !text-nb-gray-300"}
size={"xs"}
maxHeight={300}
>
<div className={"flex items-center justify-between gap-8 pr-3"}>
{peer && <DeviceCard device={peer} />}
<ChevronsUpDown size={18} className={"shrink-0"} />
</div>
</SelectDropdown>
<Handle
type="source"
position={Position.Right}
id={"sr"}
style={{
height: 20,
width: "1px",
border: "none",
backgroundColor: "#3f444b",
borderRadius: "0px 4px 4px 0px",
right: -2,
}}
/>
<Handle
type="target"
position={Position.Left}
id={"tl"}
className={"opacity-0"}
/>
</div>
);
};

View File

@@ -0,0 +1,90 @@
import { InternalNode, Node, Position } from "@xyflow/react";
type IntersectionPoint = {
x: number;
y: number;
};
function getNodeIntersection(
intersectionNode: InternalNode<Node>,
targetNode: InternalNode<Node>,
) {
const { width: intersectionNodeWidth, height: intersectionNodeHeight } =
intersectionNode.measured;
const intersectionNodePosition = intersectionNode.internals.positionAbsolute;
const targetPosition = targetNode.internals.positionAbsolute;
const measuredTargetWidth = targetNode.measured.width || 0;
const measuredTargetHeight = targetNode.measured.height || 0;
const w = (intersectionNodeWidth || 0) / 2;
const h = (intersectionNodeHeight || 0) / 2;
const x2 = intersectionNodePosition.x + w;
const y2 = intersectionNodePosition.y + h;
const x1 = targetPosition.x + measuredTargetWidth / 2;
const y1 = targetPosition.y + measuredTargetHeight / 2;
const xx1 = (x1 - x2) / (2 * w) - (y1 - y2) / (2 * h);
const yy1 = (x1 - x2) / (2 * w) + (y1 - y2) / (2 * h);
const a = 1 / (Math.abs(xx1) + Math.abs(yy1));
const xx3 = a * xx1;
const yy3 = a * yy1;
const x = w * (xx3 + yy3) + x2;
const y = h * (-xx3 + yy3) + y2;
return { x, y };
}
function getEdgePosition(
node: InternalNode<Node>,
intersectionPoint: IntersectionPoint,
) {
const n = { ...node.internals.positionAbsolute, ...node };
const nx = Math.round(n.x);
const ny = Math.round(n.y);
const px = Math.round(intersectionPoint.x);
const py = Math.round(intersectionPoint.y);
const measuredWidth = n.measured.width || 0;
const measuredHeight = n.measured.height || 0;
if (px <= nx + 1) {
return Position.Left;
}
if (px >= nx + measuredWidth - 1) {
return Position.Right;
}
if (py <= ny + 1) {
return Position.Top;
}
if (py >= n.y + measuredHeight - 1) {
return Position.Bottom;
}
return Position.Top;
}
export function getEdgeParams(
source: InternalNode<Node>,
target: InternalNode<Node>,
) {
const sourceIntersectionPoint: IntersectionPoint = getNodeIntersection(
source,
target,
);
const targetIntersectionPoint: IntersectionPoint = getNodeIntersection(
target,
source,
);
const sourcePos = getEdgePosition(source, sourceIntersectionPoint);
const targetPos = getEdgePosition(target, targetIntersectionPoint);
return {
sx: sourceIntersectionPoint.x,
sy: sourceIntersectionPoint.y,
tx: targetIntersectionPoint.x,
ty: targetIntersectionPoint.y,
sourcePos,
targetPos,
};
}

View File

@@ -0,0 +1,13 @@
import AnimatedLine from "@/modules/control-center/edges/AnimatedLine";
import { BidirectionalEdges } from "@/modules/control-center/edges/BidirectionalEdges";
import { DirectionIn } from "@/modules/control-center/edges/DirectionIn";
import FloatingEdge from "@/modules/control-center/edges/FloatingEdge";
import { SimpleConnection } from "@/modules/control-center/edges/SimpleConnection";
export const EDGE_TYPES = {
in: DirectionIn,
bi: BidirectionalEdges,
floating: FloatingEdge,
"floating-straight": AnimatedLine,
simple: SimpleConnection,
};

View File

@@ -0,0 +1,145 @@
import { useReactFlow } from "@xyflow/react";
import { orderBy } from "lodash";
import { Group } from "@/interfaces/Group";
import { Network } from "@/interfaces/Network";
import { Peer } from "@/interfaces/Peer";
import { Policy } from "@/interfaces/Policy";
export const getDestinationGroupsFromPolicy = (policy: Policy) => {
const rule = policy.rules?.[0];
if (!rule) return [];
const destinations = rule.destinations as Group[];
if (!destinations) return [];
return destinations;
};
export const getSourceGroupsFromPolicy = (policy: Policy) => {
const rule = policy.rules?.[0];
if (!rule) return [];
const sources = rule.sources as Group[];
if (!sources) return [];
return sources;
};
export const getNetworksFromPolicy = (networks: Network[], policy: Policy) => {
const policyId = policy.id;
if (!policyId) return [];
return networks.filter((network) => {
return network.policies?.some((p) => p === policyId);
});
};
export const getPeersFromGroup = (group: Group, peers: Peer[]) => {
return peers.filter((peer) => {
const groupIds = peer.groups?.map((g) => g.id) || [];
return groupIds.includes(group.id);
});
};
export const getPolicyProtocolAndPortText = (
policy: Policy,
maxPorts?: number,
) => {
const rule = policy.rules?.[0];
if (!rule) return "";
let p = rule.protocol;
if (p === "all") {
return "";
} else if (p === "icmp") {
return "ICMP";
} else {
const ports = getPolicyPortsText(policy);
if (!ports || ports.length === 0) {
return p.toUpperCase();
}
if (ports.length > (maxPorts ?? 3)) {
const firstFour = ports.slice(0, 4);
return `${p.toUpperCase()}:${firstFour.join(",")}, ...`;
}
return `${p.toUpperCase()}:${ports.join(",")}`;
}
};
export const getPolicyPortsText = (policy: Policy) => {
const rule = policy.rules?.[0];
if (!rule) return undefined;
const ports = rule.ports || [];
const portRanges = rule.port_ranges || [];
if (ports.length === 0 && portRanges.length === 0) {
return undefined;
}
const portStrings = ports.map((port) => String(port));
const rangeStrings = portRanges.map((range) => {
if (range.start === range.end) return String(range.start);
return `${range.start}-${range.end}`;
});
return orderBy(
[...portStrings, ...rangeStrings],
[(x) => Number(x.split("-")[0])],
["asc"],
);
};
export const getResourcePolicyByGroups = (
groups: Group[],
policies: Policy[],
): Policy[] => {
const groupIds = groups.map((group) => group.id);
return policies.filter((policy) => {
const rule = policy.rules?.[0];
if (!rule) return false;
const destinations = rule.destinations as Group[];
return destinations?.some((d) => groupIds.includes(d.id));
});
};
export function useSourceGroupEnabled(sourceId: string) {
const { getNode } = useReactFlow();
const node = getNode(sourceId);
return node?.data?.enabled ?? false;
}
export function useAnySourceGroupEnabled(sourceId: string) {
const { getNodes, getEdges } = useReactFlow();
const nodes = getNodes();
const edges = getEdges();
const incomingEdges = edges.filter((e) => e.target === sourceId);
const sourceNodes = incomingEdges
.map((edge) => nodes.find((n) => n.id === edge.source))
.filter(Boolean);
const sourceEnabledStates = sourceNodes.map((n) => n?.data?.enabled);
return sourceEnabledStates.some(Boolean);
}
export function getFirstGroup(groups?: Group[], policies?: Policy[]) {
const sortedGroups = orderBy(groups, "peers_count", "desc");
const groupsWithoutAll = sortedGroups?.filter((g) => g.name !== "All");
const groupsWithPolicies = orderBy(
groupsWithoutAll?.filter((g) => {
return policies?.some((p) => {
const sources = getSourceGroupsFromPolicy(p);
return sources?.some((source) => source.id === g.id);
});
}),
"peers_count",
"desc",
);
if (groupsWithPolicies && groupsWithPolicies?.length > 0) {
return groupsWithPolicies[0];
}
if (groupsWithoutAll && groupsWithoutAll?.length > 0) {
return groupsWithoutAll[0];
}
return sortedGroups?.[0];
}

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