Compare commits

...

1 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
72 changed files with 2035 additions and 495 deletions

View File

@@ -154,7 +154,7 @@ const GroupOverviewTabs = ({ group }: { group: Group }) => {
};
const [tab, setTab] = useState(getInitialTab());
const groupDetails = useGroupDetails(group?.id || "");
const { groupDetails, isLoading } = useGroupDetails(group?.id || "");
const peersCount = groupDetails?.peers_count || 0;
const usersCount = groupDetails?.users?.length || 0;
@@ -266,31 +266,49 @@ const GroupOverviewTabs = ({ group }: { group: Group }) => {
</TabsList>
<TabsContent value={"users"} className={"pb-8"}>
<GroupUsersSection users={groupDetails?.users} />
<GroupUsersSection users={groupDetails?.users} isLoading={isLoading} />
</TabsContent>
<TabsContent value={"peers"} className={"pb-8"}>
<GroupPeersSection peers={groupDetails?.peersOfGroup} />
<GroupPeersSection
peers={groupDetails?.peersOfGroup}
isLoading={isLoading}
/>
</TabsContent>
<TabsContent value={"policies"} className={"pb-8"}>
<GroupPoliciesSection policies={groupDetails?.policies} />
<GroupPoliciesSection
policies={groupDetails?.policies}
isLoading={isLoading}
/>
</TabsContent>
<TabsContent value={"resources"} className={"pb-8"}>
<GroupResourcesSection resources={groupDetails?.networkResources} />
<GroupResourcesSection
resources={groupDetails?.networkResources}
isLoading={isLoading}
/>
</TabsContent>
<TabsContent value={"network-routes"} className={"pb-8"}>
<GroupNetworkRoutesSection routes={groupDetails?.routes} />
<GroupNetworkRoutesSection
routes={groupDetails?.routes}
isLoading={isLoading}
/>
</TabsContent>
<TabsContent value={"nameservers"} className={"pb-8"}>
<GroupNameserversSection nameserverGroups={groupDetails?.nameservers} />
<GroupNameserversSection
nameserverGroups={groupDetails?.nameservers}
isLoading={isLoading}
/>
</TabsContent>
<TabsContent value={"setup-keys"} className={"pb-8"}>
<GroupSetupKeysSection setupKeys={groupDetails?.setupKeys} />
<GroupSetupKeysSection
setupKeys={groupDetails?.setupKeys}
isLoading={isLoading}
/>
</TabsContent>
</Tabs>
);

View File

@@ -1,7 +1,6 @@
"use client";
import Breadcrumbs from "@components/Breadcrumbs";
import { Callout } from "@components/Callout";
import InlineLink from "@components/InlineLink";
import Paragraph from "@components/Paragraph";
import SkeletonTable from "@components/skeletons/SkeletonTable";
@@ -17,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"),

View File

@@ -30,6 +30,7 @@ import { cn } from "@utils/helpers";
import dayjs from "dayjs";
import { isEmpty, trim } from "lodash";
import {
ArrowRightIcon,
Barcode,
CalendarDays,
Cpu,
@@ -65,6 +66,7 @@ import { PeerNetworkRoutesSection } from "@/modules/peer/PeerNetworkRoutesSectio
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();
@@ -148,7 +150,7 @@ const PeerGeneralInformation = () => {
);
const [selectedGroups, setSelectedGroups, { getAllGroupCalls }] =
useGroupHelper({
initial: peerGroups,
initial: peerGroups?.filter((g) => g?.name !== "All"),
peer,
});
@@ -237,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

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

@@ -4,7 +4,6 @@ import { notify } from "@components/Notification";
import FullScreenLoading from "@components/ui/FullScreenLoading";
import { IconCircleX } from "@tabler/icons-react";
import useFetchApi from "@utils/api";
import { cn } from "@utils/helpers";
import { Loader2Icon } from "lucide-react";
import React, { useCallback, useEffect, useRef, useState } from "react";
import type { Peer } from "@/interfaces/Peer";
@@ -20,6 +19,8 @@ import {
NetBirdStatus,
useNetBirdClient,
} from "@/modules/remote-access/useNetBirdClient";
import { cn } from "@utils/helpers";
import { isNetbirdSSHProtocolSupported } from "@utils/version";
export default function RDPPage() {
const { peerId } = useRDPQueryParams();
@@ -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(

View File

@@ -2,7 +2,6 @@
import { PageNotFound } from "@components/ui/PageNotFound";
import useFetchApi, { ErrorResponse } from "@utils/api";
import { isNativeSSHSupported } from "@utils/version";
import { CircleXIcon, InfoIcon, Loader2Icon } from "lucide-react";
import React, { useEffect, useRef } from "react";
import type { Peer } from "@/interfaces/Peer";
@@ -13,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();
@@ -88,7 +91,10 @@ function SSHTerminal({ username, port, peer }: Props) {
connected.current = false;
try {
const aclPort = isNativeSSHSupported(peer.version) ? "22022" : port;
const rules = [`tcp/${aclPort}`];
const protocol = isNetbirdSSHProtocolSupported(peer.version)
? "netbird-ssh"
: "tcp";
const rules = [`${protocol}/${aclPort}`];
await client?.connectTemporary(peer.id, rules);
await ssh({
hostname: peer.ip,
@@ -111,7 +117,10 @@ function SSHTerminal({ username, port, peer }: Props) {
try {
const aclPort = isNativeSSHSupported(peer.version) ? "22022" : port;
const rules = [`tcp/${aclPort}`];
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,

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

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

@@ -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();
@@ -526,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} />
)}
@@ -535,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}
@@ -555,7 +548,6 @@ export function PeerGroupSelector({
selected={isSelected}
/>
)}
<Checkbox checked={isSelected} />
</div>
</div>
@@ -671,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
@@ -686,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

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

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

@@ -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,9 +33,13 @@ 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";
@@ -67,7 +74,8 @@ export default function PolicyDirection({
<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

@@ -139,7 +139,10 @@ export default function PeerProvider({
open={sshInstructionsModal}
onOpenChange={setSSHInstructionsModal}
peer={peer}
onSuccess={() => toggleSSH(true)}
onSuccess={() => {
mutate(`/peers/${peer.id}`);
setSSHInstructionsModal(false);
}}
/>
)}

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

@@ -30,4 +30,18 @@ export interface Peer {
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

@@ -39,6 +39,7 @@ import {
Power,
Share2,
Shield,
SquareTerminalIcon,
Text,
} from "lucide-react";
import React, { useMemo, useState } from "react";
@@ -50,6 +51,9 @@ 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;
@@ -119,6 +123,7 @@ type ModalProps = {
initialProtocol?: Protocol;
initialPorts?: number[];
initialDestinationResource?: PolicyRuleResource;
initialTab?: string;
};
export function AccessControlModalContent({
@@ -134,8 +139,10 @@ export function AccessControlModalContent({
initialProtocol,
initialPorts,
initialDestinationResource,
initialTab,
}: Readonly<ModalProps>) {
const { permission } = usePermissions();
const { users } = useUsers();
const {
portDisabled,
@@ -169,6 +176,10 @@ export function AccessControlModalContent({
portRanges,
setPortRanges,
hasPortSupport,
sshAccessType,
setSshAccessType,
sshAuthorizedGroups,
setSshAuthorizedGroups,
} = useAccessControl({
policy,
postureCheckTemplates,
@@ -182,6 +193,7 @@ export function AccessControlModalContent({
});
const [tab, setTab] = useState(() => {
if (initialTab && initialTab !== "") return initialTab;
if (!cell) return "policy";
if (cell == "posture_checks") return "posture_checks";
return "policy";
@@ -248,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
@@ -267,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"}
@@ -281,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>
@@ -295,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}
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}
@@ -315,6 +329,7 @@ export function AccessControlModalContent({
value={direction}
onChange={setDirection}
disabled={destinationOnlyResources}
protocol={protocol}
destinationResource={destinationResource}
/>
@@ -328,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}
@@ -363,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

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

@@ -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,13 +1,15 @@
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 {
AuthorizedGroups,
Policy,
PolicyRule,
PolicyRuleResource,
PortRange,
Protocol,
@@ -146,6 +148,21 @@ export const useAccessControl = ({
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(
@@ -188,6 +205,7 @@ export const useAccessControl = ({
enabled,
ports: newPorts,
port_ranges: newPortRanges,
authorized_groups: sshAuthorizedGroups,
},
],
} as Policy;
@@ -238,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,
@@ -264,6 +306,8 @@ export const useAccessControl = ({
destinationResource: destinationResource || undefined,
ports: newPorts,
port_ranges: newPortRanges,
authorized_groups:
protocol === "netbird-ssh" ? authorizedGroups : undefined,
},
],
} as Policy;
@@ -374,6 +418,10 @@ export const useAccessControl = ({
destinationHasResources,
destinationOnlyResources,
hasPortSupport,
sshAccessType,
setSshAccessType,
sshAuthorizedGroups,
setSshAuthorizedGroups,
} as const;
};
@@ -392,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"}>

View File

@@ -1,14 +1,21 @@
import MultipleGroups from "@components/ui/MultipleGroups";
import MultipleGroups, {
TransparentEditIconButton,
} from "@components/ui/MultipleGroups";
import { cn } from "@utils/helpers";
import * as React from "react";
import { useGroups } from "@/contexts/GroupsProvider";
import { usePermissions } from "@/contexts/PermissionsProvider";
import { Group } from "@/interfaces/Group";
import { NameserverGroup } from "@/interfaces/Nameserver";
type Props = {
ns: NameserverGroup;
};
export default function NameserverDistributionGroupsCell({ ns }: Props) {
const { groups } = useGroups();
const { permission } = usePermissions();
const canUpdate = permission?.nameservers?.update;
const allGroups = ns.groups
.map((group) => {
@@ -16,5 +23,10 @@ export default function NameserverDistributionGroupsCell({ ns }: Props) {
})
.filter((g) => g != undefined) as Group[];
return <MultipleGroups groups={allGroups} />;
return (
<div className={cn("flex items-center gap-1", canUpdate && "group")}>
<MultipleGroups groups={allGroups} />
{canUpdate && <TransparentEditIconButton />}
</div>
);
}

View File

@@ -145,7 +145,7 @@ export default function NameserverGroupTable({
wrapperProps={isGroupPage ? { className: "mt-6 w-full" } : undefined}
paginationPaddingClassName={isGroupPage ? "px-0 pt-8" : undefined}
tableClassName={isGroupPage ? "mt-0" : undefined}
inset={!isGroupPage}
inset={false}
minimal={isGroupPage}
showSearchAndFilters={isGroupPage}
keepStateInLocalStorage={!isGroupPage}

View File

@@ -8,17 +8,21 @@ const NameserverGroupTable = lazy(
() => import("@/modules/dns-nameservers/table/NameserverGroupTable"),
);
type Props = {
nameserverGroups?: NameserverGroup[];
isLoading?: boolean;
};
export const GroupNameserversSection = ({
nameserverGroups,
}: {
nameserverGroups?: NameserverGroup[];
}) => {
isLoading = true,
}: Props) => {
const { group } = useGroupContext();
return (
<GroupDetailsTableContainer>
<NameserverGroupTable
isLoading={false}
isLoading={isLoading}
nameserverGroups={nameserverGroups}
isGroupPage={true}
distributionGroups={[group]}

View File

@@ -1,68 +1,19 @@
import { DataTable } from "@components/table/DataTable";
import DataTableHeader from "@components/table/DataTableHeader";
import { DataTableRowsPerPage } from "@components/table/DataTableRowsPerPage";
import { usePortalElement } from "@hooks/usePortalElement";
import { ColumnDef } from "@tanstack/react-table";
import React from "react";
import { useGroupContext } from "@/contexts/GroupProvider";
import { Route } from "@/interfaces/Route";
import { GroupDetailsTableContainer } from "@/modules/groups/details/GroupDetailsTableContainer";
import PeerRouteNameCell from "@/modules/peer/PeerRouteNameCell";
import GroupedRouteNetworkRangeCell from "@/modules/route-group/GroupedRouteNetworkRangeCell";
import NetworkRoutesTable from "@/modules/route-group/NetworkRoutesTable";
import useGroupedRoutes from "@/modules/route-group/useGroupedRoutes";
import RouteActiveCell from "@/modules/routes/RouteActiveCell";
import RouteMetricCell from "@/modules/routes/RouteMetricCell";
export const GroupNetworkRoutesTableColumns: ColumnDef<Route>[] = [
{
accessorKey: "network_id",
header: ({ column }) => {
return <DataTableHeader column={column}>Name</DataTableHeader>;
},
sortingFn: "text",
cell: ({ row }) => <PeerRouteNameCell route={row.original} />,
},
{
accessorKey: "description",
sortingFn: "text",
},
{
accessorKey: "domain_search",
sortingFn: "text",
},
{
accessorKey: "network",
header: ({ column }) => {
return <DataTableHeader column={column}>Network</DataTableHeader>;
},
cell: ({ row }) => (
<GroupedRouteNetworkRangeCell
domains={row.original?.domains}
network={row.original?.network}
/>
),
},
{
accessorKey: "metric",
header: ({ column }) => {
return <DataTableHeader column={column}>Metric</DataTableHeader>;
},
cell: ({ row }) => <RouteMetricCell metric={row.original.metric} />,
sortingFn: "alphanumeric",
},
{
id: "enabled",
accessorKey: "enabled",
sortingFn: "basic",
header: ({ column }) => (
<DataTableHeader column={column}>Active</DataTableHeader>
),
cell: ({ row }) => <RouteActiveCell route={row.original} />,
},
];
type Props = {
routes?: Route[];
isLoading?: boolean;
};
export const GroupNetworkRoutesSection = ({ routes }: { routes?: Route[] }) => {
export const GroupNetworkRoutesSection = ({
routes,
isLoading = true,
}: Props) => {
const groupedRoutes = useGroupedRoutes({ routes });
const { group } = useGroupContext();
@@ -70,7 +21,7 @@ export const GroupNetworkRoutesSection = ({ routes }: { routes?: Route[] }) => {
<GroupDetailsTableContainer>
<NetworkRoutesTable
isGroupPage={true}
isLoading={false}
isLoading={isLoading}
groupedRoutes={groupedRoutes}
routes={routes}
distributionGroups={[group]}

View File

@@ -103,7 +103,12 @@ const GroupPeersTableColumns: ColumnDef<Peer>[] = [
},
];
export const GroupPeersSection = ({ peers }: { peers?: Peer[] }) => {
type Props = {
peers?: Peer[];
isLoading?: boolean;
};
export const GroupPeersSection = ({ peers, isLoading = true }: Props) => {
const { group, addPeersToGroup, removePeersFromGroup } = useGroupContext();
const [selectedRows, setSelectedRows] = useState<RowSelectionState>({});
const [open, setOpen] = useState(false);
@@ -112,7 +117,7 @@ export const GroupPeersSection = ({ peers }: { peers?: Peer[] }) => {
return (
<GroupDetailsTableContainer>
<GroupPeersTable
isLoading={false}
isLoading={isLoading}
peers={peers}
columns={GroupPeersTableColumns}
selectedRows={selectedRows}

View File

@@ -6,13 +6,17 @@ import { GroupDetailsTableContainer } from "@/modules/groups/details/GroupDetail
const AccessControlTable = lazy(
() => import("@/modules/access-control/table/AccessControlTable"),
);
type Props = {
policies?: Policy[];
isLoading?: boolean;
};
export const GroupPoliciesSection = ({ policies }: { policies?: Policy[] }) => {
export const GroupPoliciesSection = ({ policies, isLoading = true }: Props) => {
return (
<GroupDetailsTableContainer>
<PoliciesProvider>
<AccessControlTable
isLoading={false}
isLoading={isLoading}
policies={policies}
isGroupPage={true}
/>

View File

@@ -100,11 +100,15 @@ const GroupResourcesColumns: ColumnDef<NetworkResourceWithNetwork>[] = [
},
];
type Props = {
resources?: NetworkResourceWithNetwork[];
isLoading?: boolean;
};
export const GroupResourcesSection = ({
resources,
}: {
resources?: NetworkResourceWithNetwork[];
}) => {
isLoading = true,
}: Props) => {
const [sorting, setSorting] = useState<SortingState>([]);
const { permission } = usePermissions();
const router = useRouter();
@@ -118,6 +122,7 @@ export const GroupResourcesSection = ({
sorting={sorting}
setSorting={setSorting}
minimal={true}
isLoading={isLoading}
showSearchAndFilters={true}
renderRow={(row, children) => (
<NetworkProvider

View File

@@ -7,17 +7,21 @@ const SetupKeysTable = lazy(
() => import("@/modules/setup-keys/SetupKeysTable"),
);
type Props = {
setupKeys?: SetupKey[];
isLoading?: boolean;
};
export const GroupSetupKeysSection = ({
setupKeys,
}: {
setupKeys?: SetupKey[];
}) => {
isLoading = true,
}: Props) => {
const { group } = useGroupContext();
return (
<GroupDetailsTableContainer>
<SetupKeysTable
isLoading={false}
isLoading={isLoading}
setupKeys={setupKeys}
isGroupPage={true}
groups={[group]}

View File

@@ -113,7 +113,12 @@ export const GroupUsersTableColumns: ColumnDef<User>[] = [
},
];
export const GroupUsersSection = ({ users }: { users?: User[] }) => {
type Props = {
users?: User[];
isLoading?: boolean;
};
export const GroupUsersSection = ({ users, isLoading = true }: Props) => {
const { group, addUsersToGroup, removeUsersFromGroup } = useGroupContext();
const [selectedRows, setSelectedRows] = useState<RowSelectionState>({});
const [open, setOpen] = useState(false);
@@ -122,7 +127,7 @@ export const GroupUsersSection = ({ users }: { users?: User[] }) => {
return (
<GroupDetailsTableContainer>
<UsersTable
isLoading={false}
isLoading={isLoading}
columns={GroupUsersTableColumns}
selectedRows={selectedRows}
setSelectedRows={setSelectedRows}

View File

@@ -121,9 +121,10 @@ export default function useGroupDetails(groupId: string) {
isSetupKeysLoading ||
isUsersLoading ||
isPeerLoading ||
isLoadingResources;
isLoadingResources ||
isNetworksLoading;
return useMemo(() => {
const groupDetails = useMemo(() => {
if (isLoading || !group) return null;
return {
@@ -147,4 +148,9 @@ export default function useGroupDetails(groupId: string) {
linkedPeers,
linkedNetworkResources,
]);
return {
groupDetails,
isLoading,
};
}

View File

@@ -3,7 +3,6 @@ import { DataTable } from "@components/table/DataTable";
import DataTableHeader from "@components/table/DataTableHeader";
import { DataTableRowsPerPage } from "@components/table/DataTableRowsPerPage";
import { ColumnDef, SortingState } from "@tanstack/react-table";
import { removeAllSpaces } from "@utils/helpers";
import { Layers3Icon } from "lucide-react";
import { usePathname } from "next/navigation";
import React from "react";
@@ -20,6 +19,7 @@ import GroupsActionCell from "@/modules/groups/table/GroupsActionCell";
import GroupsCountCell from "@/modules/groups/table/GroupsCountCell";
import GroupsNameCell from "@/modules/groups/table/GroupsNameCell";
import useGroupsUsage, { GroupUsage } from "@/modules/groups/useGroupsUsage";
import { removeAllSpaces } from "@utils/helpers";
export const GroupsTableColumns: ColumnDef<GroupUsage>[] = [
{

View File

@@ -6,15 +6,15 @@ type Props = {
};
export const useGroupIdentification = ({ id, issued }: Props) => {
const isJWTGroup = issued === GroupIssued.JWT;
const isOktaGroup = !!id?.includes("okta");
const isGoogleGroup = !!id?.includes("google");
const isAzureGroup = !!id?.includes("azure");
const isJumpcloudGroup = !!id?.includes("jumpcloud");
const isOIDCGroup = !!id?.includes("oidc");
const isRegularGroup =
!isJWTGroup && !isOktaGroup && !isGoogleGroup && !isAzureGroup;
const isIntegrationGroup = isOktaGroup || isGoogleGroup || isAzureGroup;
const isJWTGroup = issued === GroupIssued.JWT;
const isIntegrationGroup = issued === GroupIssued.INTEGRATION;
const isRegularGroup = issued === GroupIssued.API || isJWTGroup;
return {
isOktaGroup,
@@ -22,6 +22,8 @@ export const useGroupIdentification = ({ id, issued }: Props) => {
isAzureGroup,
isJWTGroup,
isRegularGroup,
isJumpcloudGroup,
isOIDCGroup,
isIntegrationGroup,
};
};

View File

@@ -12,6 +12,8 @@ import NetworkModal from "@/modules/networks/NetworkModal";
import NetworkResourceModal from "@/modules/networks/resources/NetworkResourceModal";
import { ResourceGroupModal } from "@/modules/networks/resources/ResourceGroupModal";
import NetworkRoutingPeerModal from "@/modules/networks/routing-peers/NetworkRoutingPeerModal";
import { Policy } from "@/interfaces/Policy";
import PoliciesProvider from "@/contexts/PoliciesProvider";
type Props = {
children: React.ReactNode;
@@ -31,6 +33,7 @@ const NetworksContext = React.createContext(
resource?: NetworkResource,
) => void;
openPolicyModal: (network?: Network, resource?: NetworkResource) => void;
openEditPolicyModal: (policy: Policy) => void;
deleteNetwork: (network: Network) => Promise<void>;
deleteResource: (network: Network, resource: NetworkResource) => void;
deleteRouter: (network: Network, router: NetworkRouter) => void;
@@ -57,6 +60,7 @@ export const NetworkProvider = ({
description?: string;
destinationGroups?: Group[] | string[];
}>();
const [currentPolicy, setCurrentPolicy] = useState<Policy>();
const [routingPeerModal, setRoutingPeerModal] = useState(false);
const [networkModal, setNetworkModal] = useState(false);
@@ -119,6 +123,11 @@ export const NetworkProvider = ({
setPolicyModal(true);
};
const openEditPolicyModal = (policy: Policy) => {
setCurrentPolicy(policy);
setPolicyModal(true);
};
const deleteNetwork = async (network: Network) => {
const choice = await confirm({
title: `Delete network '${network.name}'?`,
@@ -246,6 +255,7 @@ export const NetworkProvider = ({
openResourceModal,
openResourceGroupModal,
openPolicyModal,
openEditPolicyModal,
deleteNetwork,
deleteResource,
deleteRouter,
@@ -267,32 +277,37 @@ export const NetworkProvider = ({
mutate(`/networks/${n.id}`);
}}
/>
<Modal
open={policyModal}
onOpenChange={(state) => {
setPolicyModal(state);
setPolicyDefaultSettings(undefined);
}}
>
<AccessControlModalContent
key={policyModal ? "1" : "0"}
initialDestinationGroups={policyDefaultSettings?.destinationGroups}
initialName={policyDefaultSettings?.name}
initialDescription={policyDefaultSettings?.description}
onSuccess={async (p) => {
setPolicyModal(false);
<PoliciesProvider>
<Modal
open={policyModal}
onOpenChange={(state) => {
setPolicyModal(state);
setPolicyDefaultSettings(undefined);
mutate("/networks");
if (network) {
onResourceUpdate?.();
mutate(`/networks/${network.id}/resources`);
mutate(`/networks/${network.id}`);
} else {
currentNetwork && (await askForRoutingPeer(currentNetwork));
}
setCurrentPolicy(undefined);
}}
/>
</Modal>
>
<AccessControlModalContent
key={policyModal ? "1" : "0"}
initialDestinationGroups={policyDefaultSettings?.destinationGroups}
initialName={policyDefaultSettings?.name}
initialDescription={policyDefaultSettings?.description}
policy={currentPolicy}
onSuccess={async (p) => {
setPolicyModal(false);
setPolicyDefaultSettings(undefined);
setCurrentPolicy(undefined);
mutate("/networks");
if (network) {
onResourceUpdate?.();
mutate(`/networks/${network.id}/resources`);
mutate(`/networks/${network.id}`);
} else {
currentNetwork && (await askForRoutingPeer(currentNetwork));
}
}}
/>
</Modal>
</PoliciesProvider>
{currentNetwork && (
<>
<NetworkRoutingPeerModal

View File

@@ -23,7 +23,11 @@ export const ResourceGroupCell = ({ resource }: Props) => {
openResourceGroupModal(network, resource);
}}
>
<MultipleGroups groups={resource?.groups as Group[]} />
<MultipleGroups
groups={resource?.groups as Group[]}
showResources={true}
redirectGroupTab={"resources"}
/>
{permission.networks.update && <TransparentEditIconButton />}
</button>
);

View File

@@ -2,10 +2,12 @@ import Badge from "@components/Badge";
import Button from "@components/Button";
import FullTooltip from "@components/FullTooltip";
import useFetchApi from "@utils/api";
import { PlusCircle, ShieldIcon } from "lucide-react";
import { orderBy } from "lodash";
import { PlusCircle, ShieldIcon, SquarePenIcon } from "lucide-react";
import * as React from "react";
import { useMemo } from "react";
import { useMemo, useState } from "react";
import Skeleton from "react-loading-skeleton";
import CircleIcon from "@/assets/icons/CircleIcon";
import { usePermissions } from "@/contexts/PermissionsProvider";
import { Group } from "@/interfaces/Group";
import { NetworkResource } from "@/interfaces/Network";
@@ -17,27 +19,32 @@ type Props = {
};
export const ResourcePolicyCell = ({ resource }: Props) => {
const { permission } = usePermissions();
const { openPolicyModal, network } = useNetworksContext();
const { openPolicyModal, network, openEditPolicyModal } =
useNetworksContext();
const { data: policies, isLoading } = useFetchApi<Policy[]>("/policies");
const [tooltipOpen, setTooltipOpen] = useState(false);
const assignedPolicies = useMemo(() => {
const resourceGroups = resource?.groups as Group[];
return policies?.filter((policy) => {
if (!policy.enabled) return false;
const destinationResource = policy.rules
?.map((rule) => rule?.destinationResource?.id === resource?.id)
.some((id) => id);
if (destinationResource) return true;
const destinationPolicyGroups = policy.rules
?.map((rule) => rule?.destinations)
.flat() as Group[];
const policyGroups = [...destinationPolicyGroups];
return resourceGroups?.some((resourceGroup) =>
policyGroups.some(
(policyGroup) => policyGroup?.id === resourceGroup.id,
),
);
});
return orderBy(
policies?.filter((policy) => {
const destinationResource = policy.rules
?.map((rule) => rule?.destinationResource?.id === resource?.id)
.some((id) => id);
if (destinationResource) return true;
const destinationPolicyGroups = policy.rules
?.map((rule) => rule?.destinations)
.flat() as Group[];
const policyGroups = [...destinationPolicyGroups];
return resourceGroups?.some((resourceGroup) =>
policyGroups.some(
(policyGroup) => policyGroup?.id === resourceGroup.id,
),
);
}),
"enabled",
"desc",
);
}, [policies, resource]);
if (isLoading) {
@@ -48,6 +55,8 @@ export const ResourcePolicyCell = ({ resource }: Props) => {
);
}
const enabledPolicies = assignedPolicies?.filter((policy) => policy?.enabled);
const policyCount = assignedPolicies?.length || 0;
return (
@@ -55,36 +64,72 @@ export const ResourcePolicyCell = ({ resource }: Props) => {
<div className={"flex gap-3"}>
{policyCount > 0 && (
<FullTooltip
contentClassName={"p-0"}
delayDuration={200}
skipDelayDuration={200}
customOpen={tooltipOpen}
customOnOpenChange={setTooltipOpen}
className={"border-nb-gray-800"}
content={
<div className={"text-xs max-w-lg"}>
<span className={"font-medium text-nb-gray-100 text-sm"}>
Assigned Policies
</span>
<div className={"flex gap-2 pt-2 pb-2 flex-wrap"}>
{assignedPolicies?.map((policy: Policy, index: number) => {
return (
<Badge
variant={"gray-ghost"}
useHover={false}
key={index}
className={"justify-start font-medium"}
<div className={"text-xs flex flex-col p-1"}>
{assignedPolicies?.map((policy: Policy) => {
const rule = policy?.rules?.[0];
if (!rule) return;
return (
<button
key={policy.id}
className={
"m-0 pl-3 py-2.5 leading-none flex justify-between group hover:bg-nb-gray-900 rounded-md"
}
onClick={() => {
setTooltipOpen(false);
openEditPolicyModal(policy);
}}
>
<div
className={
" flex items-center gap-2 leading-none font-medium text-nb-gray-300 group-hover:text-nb-gray-200 whitespace-nowrap"
}
>
<ShieldIcon size={14} className={"text-green-500"} />
<CircleIcon
size={8}
active={policy.enabled}
className={"shrink-0"}
/>
{policy.name}
</Badge>
);
})}
</div>
</div>
<div
className={
"text-nb-gray-300 px-2 ml-4 uppercase font-mono opacity-0 group-hover:opacity-100"
}
>
<SquarePenIcon size={12} />
</div>
</button>
);
})}
</div>
}
interactive={true}
align={"start"}
alignOffset={0}
sideOffset={14}
>
<Badge variant={"gray"} useHover={true}>
<Badge
variant={"gray"}
useHover={false}
className={"select-none hover:bg-nb-gray-910"}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
if (!tooltipOpen) setTooltipOpen(true);
}}
>
<ShieldIcon size={14} className={"text-green-500"} />
<div>
<span className={"font-medium text-xs"}>
{" "}
{assignedPolicies?.length}
{enabledPolicies?.length}
</span>
</div>
</Badge>

View File

@@ -1,5 +1,5 @@
import GroupBadge from "@components/ui/GroupBadge";
import PeerBadge from "@components/ui/PeerBadge";
import PeerCountBadge from "@components/ui/PeerCountBadge";
import useFetchApi from "@utils/api";
import { ArrowRightIcon } from "lucide-react";
import * as React from "react";
@@ -45,9 +45,13 @@ export const NetworkRoutingPeerName = ({ router }: Props) => {
if (routingPeerGroup) {
return (
<div className={"flex items-center gap-2 max-w-[295px] min-w-[295px]"}>
<GroupBadge group={routingPeerGroup} />
<GroupBadge
group={routingPeerGroup}
redirectToGroupPage={true}
redirectGroupTab={"peers"}
/>
<ArrowRightIcon size={14} className={"shrink-0"} />
<PeerBadge> {routingPeerGroup.peers_count} Peer(s)</PeerBadge>
<PeerCountBadge group={routingPeerGroup} />
</div>
);
}

View File

@@ -16,7 +16,7 @@ import { useNetworksContext } from "@/modules/networks/NetworkProvider";
type Props = {
network: Network;
};
export default function NetworkActionCell({ network }: Props) {
export default function NetworkActionCell({ network }: Readonly<Props>) {
const { permission } = usePermissions();
const { deleteNetwork, openEditNetworkModal } = useNetworksContext();
const router = useRouter();

View File

@@ -9,7 +9,6 @@ import {
} from "@components/modal/Modal";
import ModalHeader from "@components/modal/ModalHeader";
import Paragraph from "@components/Paragraph";
import { SegmentedTabs } from "@components/SegmentedTabs";
import Separator from "@components/Separator";
import Steps from "@components/Steps";
import { Lightbox } from "@components/ui/Lightbox";
@@ -18,11 +17,11 @@ import { cn } from "@utils/helpers";
import { ExternalLinkIcon, PlusCircle, TerminalSquare } from "lucide-react";
import * as React from "react";
import { useState } from "react";
import NetBirdIcon from "@/assets/icons/NetBirdIcon";
import sshImage from "@/assets/ssh/ssh-client.png";
import { SegmentedTabs } from "@components/SegmentedTabs";
import NetBirdIcon from "@/assets/icons/NetBirdIcon";
import { Peer } from "@/interfaces/Peer";
import { PeerSSHPolicyModal } from "@/modules/peer/PeerSSHPolicyModal";
import { Terminal } from "@/modules/remote-access/ssh/Terminal";
type Props = {
open?: boolean;
@@ -100,9 +99,8 @@ export const PeerSSHInstructions = ({
<Steps.Step step={2}>
<p className={"font-normal"}>
Starting from NetBird v0.60.0, SSH requires an explicit access
control policy that allows <Mark>TCP</Mark> traffic on port{" "}
<Mark>22</Mark>
Starting from NetBird v0.61.0, SSH requires an explicit access
control policy to allow SSH connections to this machine.
</p>
<div className={"mt-2"}>
<Button
@@ -142,7 +140,7 @@ export const PeerSSHInstructions = ({
</ModalClose>
<Button variant={"primary"} onClick={onSuccess}>
Confirm & Enable
Finish Setup
</Button>
</div>
</ModalFooter>

View File

@@ -20,8 +20,8 @@ export const PeerSSHPolicyInfo = ({ peer, className }: Props) => {
<>
<Callout className={cn("max-w-xl", className)} variant={"warning"}>
<span>
Starting from NetBird v0.60.0, SSH requires an explicit access
control policy that allows TCP traffic on port 22.{" "}
Starting from NetBird v0.61.0, SSH requires an explicit access
control policy to allow SSH connections to this machine.{" "}
<InlineButtonLink onClick={() => setPolicyModal(true)}>
Create SSH Policy
</InlineButtonLink>

View File

@@ -15,8 +15,7 @@ export const PeerSSHPolicyModal = ({ open, onOpenChange, peer }: Props) => {
<Modal open={open} onOpenChange={onOpenChange}>
<AccessControlModalContent
key={open ? "1" : "0"}
initialPorts={[22]}
initialProtocol={"tcp"}
initialProtocol={"netbird-ssh"}
initialName={"SSH Access"}
initialDestinationResource={
peer

View File

@@ -1,24 +1,114 @@
import FancyToggleSwitch from "@components/FancyToggleSwitch";
import FullTooltip from "@components/FullTooltip";
import { LockIcon, TerminalSquare } from "lucide-react";
import {
AlertCircleIcon,
ArrowUpRightIcon,
CirclePlusIcon,
ExternalLinkIcon,
LockIcon,
ShieldIcon,
SquarePenIcon,
TerminalSquare,
} from "lucide-react";
import * as React from "react";
import { useMemo, useState } from "react";
import { usePeer } from "@/contexts/PeerProvider";
import { usePermissions } from "@/contexts/PermissionsProvider";
import { PeerSSHPolicyInfo } from "@/modules/peer/PeerSSHPolicyInfo";
import { Label } from "@components/Label";
import HelpText from "@components/HelpText";
import Button from "@components/Button";
import useFetchApi from "@utils/api";
import { Policy } from "@/interfaces/Policy";
import { Group } from "@/interfaces/Group";
import { orderBy } from "lodash";
import CircleIcon from "@/assets/icons/CircleIcon";
import Badge from "@components/Badge";
import { cn, singularize } from "@utils/helpers";
import { Modal } from "@components/modal/Modal";
import { AccessControlModalContent } from "@/modules/access-control/AccessControlModal";
import PoliciesProvider from "@/contexts/PoliciesProvider";
import { PeerSSHPolicyModal } from "@/modules/peer/PeerSSHPolicyModal";
import { Callout } from "@components/Callout";
import { useDialog } from "@/contexts/DialogProvider";
import InlineLink from "@components/InlineLink";
import { isNetbirdSSHProtocolSupported } from "@utils/version";
export const PeerSSHToggle = () => {
const { permission } = usePermissions();
const { peer, toggleSSH, setSSHInstructionsModal } = usePeer();
const { data: policies, isLoading } = useFetchApi<Policy[]>("/policies");
const [tooltipOpen, setTooltipOpen] = useState(false);
const [policyModal, setPolicyModal] = useState(false);
const [sshPolicyModal, setSshPolicyModal] = useState(false);
const [currentPolicy, setCurrentPolicy] = useState<Policy>();
const { confirm } = useDialog();
return (
const isSSHDashboardEnabled = peer?.ssh_enabled;
const isSSHClientEnabled = peer?.local_flags?.server_ssh_allowed;
const assignedPolicies = useMemo(() => {
const peerGroups = peer?.groups as Group[];
return orderBy(
policies?.filter((policy) => {
const rule = policy?.rules?.[0];
const isSSHProtocol = rule?.protocol === "netbird-ssh";
if (!isSSHProtocol) return false;
const destinationResource = policy.rules
?.map((rule) => rule?.destinationResource?.id === peer?.id)
.some((id) => id);
if (destinationResource) return true;
const destinationPolicyGroups = policy.rules
?.map((rule) => rule?.destinations)
.flat() as Group[];
const policyGroups = [...destinationPolicyGroups];
return peerGroups?.some((peerGroup) =>
policyGroups.some((policyGroup) => policyGroup?.id === peerGroup.id),
);
}),
"enabled",
"desc",
);
}, [policies, peer]);
const enabledPolicies = assignedPolicies?.filter((policy) => policy?.enabled);
const disableDashboardSSH = async () => {
const choice = await confirm({
title: `Disable SSH Access?`,
description: (
<div>
Starting from NetBird v0.61.0, once SSH access is disabled, you cannot
re-enable it again from the dashboard. You&apos;ll need to create an
explicit access control policy and update your NetBird client to
restore SSH functionality.{" "}
<InlineLink
href={"https://docs.netbird.io/manage/peers/ssh"}
target={"_blank"}
onClick={(e) => e.stopPropagation()}
>
Learn more
<ExternalLinkIcon size={12} />
</InlineLink>
</div>
),
confirmText: "Disable",
cancelText: "Cancel",
type: "warning",
maxWidthClass: "max-w-xl",
});
if (!choice) return;
toggleSSH(false);
};
return isSSHDashboardEnabled ? (
<>
<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.`}
{`You don't have the required permissions to update this setting.`}
</span>
</div>
}
@@ -30,7 +120,7 @@ export const PeerSSHToggle = () => {
value={peer.ssh_enabled}
disabled={!permission.peers.update}
onChange={(enable) =>
enable ? setSSHInstructionsModal(true) : toggleSSH(false)
enable ? setSSHInstructionsModal(true) : disableDashboardSSH()
}
label={
<>
@@ -45,5 +135,195 @@ export const PeerSSHToggle = () => {
</FullTooltip>
<PeerSSHPolicyInfo peer={peer} />
</>
) : (
<div>
<div className={"flex gap-2 items-center w-full"}>
<Label>SSH Access</Label>
</div>
<HelpText>
Set up SSH and create an explicit access control policy defining which
users can access specific local usernames of this machine via SSH.
</HelpText>
{!isNetbirdSSHProtocolSupported(peer.version) &&
enabledPolicies?.length > 0 &&
isSSHClientEnabled && (
<Callout
variant={"warning"}
icon={
<AlertCircleIcon
size={14}
className={"shrink-0 relative top-[3px] text-netbird"}
/>
}
className="my-3"
>
You have SSH access configured but your client runs on an older
NetBird version. Please update your NetBird client to v.0.61.0+ in
order to allow SSH connections.
</Callout>
)}
{!isSSHClientEnabled && enabledPolicies?.length > 0 && (
<Callout
variant={"warning"}
icon={
<AlertCircleIcon
size={14}
className={"shrink-0 relative top-[3px] text-netbird"}
/>
}
className="my-3"
>
You have an SSH access policy configured, but the SSH server
isn&apos;t enabled on this client. Enable the SSH server to allow SSH
connections.
</Callout>
)}
{isSSHClientEnabled && enabledPolicies?.length === 0 && (
<Callout
variant={"warning"}
icon={
<AlertCircleIcon
size={14}
className={"shrink-0 relative top-[3px] text-netbird"}
/>
}
className="my-3"
>
Your SSH server is enabled, but starting from NetBird v0.61.0, SSH
requires an explicit access control policy. Please create an SSH
access control policy in order to allow SSH connections.
</Callout>
)}
<div className={"flex gap-3"}>
{isSSHClientEnabled ? (
<Button variant={"secondary"} onClick={() => setSshPolicyModal(true)}>
<CirclePlusIcon size={14} />
Create SSH Policy
</Button>
) : (
<Button
variant={"secondary"}
onClick={() => setSSHInstructionsModal(true)}
>
Enable SSH Access <ArrowUpRightIcon size={14} />
</Button>
)}
{enabledPolicies?.length > 0 && (
<FullTooltip
contentClassName={"p-0"}
delayDuration={200}
skipDelayDuration={200}
customOpen={tooltipOpen}
customOnOpenChange={setTooltipOpen}
className={"border-nb-gray-800"}
content={
<div className={"text-xs flex flex-col p-1"}>
{assignedPolicies?.map((policy: Policy) => {
const rule = policy?.rules?.[0];
if (!rule) return;
return (
<button
key={policy.id}
className={
"m-0 pl-3 py-2.5 leading-none flex justify-between group hover:bg-nb-gray-900 rounded-md"
}
onClick={() => {
setTooltipOpen(false);
setCurrentPolicy(policy);
setPolicyModal(true);
}}
>
<div
className={
" flex items-center gap-2 leading-none font-medium text-nb-gray-300 group-hover:text-nb-gray-200 whitespace-nowrap"
}
>
<CircleIcon
size={8}
active={policy.enabled}
className={"shrink-0"}
/>
{policy.name}
</div>
<div
className={
"text-nb-gray-300 px-2 ml-4 uppercase font-mono opacity-0 group-hover:opacity-100"
}
>
<SquarePenIcon size={12} />
</div>
</button>
);
})}
</div>
}
interactive={true}
align={"start"}
alignOffset={0}
sideOffset={8}
>
<Badge
variant={"gray"}
useHover={false}
className={"select-none hover:bg-nb-gray-910 px-4"}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
if (!tooltipOpen) setTooltipOpen(true);
}}
>
<ShieldIcon
size={14}
className={cn(
enabledPolicies?.length > 0
? "text-green-500"
: "text-nb-gray-300",
)}
/>
<div>
<span className={"font-medium text-xs"}>
{singularize(
"Active Policies",
enabledPolicies?.length,
true,
)}
</span>
</div>
</Badge>
</FullTooltip>
)}
</div>
<PoliciesProvider>
<Modal
open={policyModal}
onOpenChange={(state) => {
setPolicyModal(state);
setCurrentPolicy(undefined);
}}
>
<AccessControlModalContent
key={policyModal ? "1" : "0"}
policy={currentPolicy}
onSuccess={async (p) => {
setPolicyModal(false);
setCurrentPolicy(undefined);
}}
/>
</Modal>
<PeerSSHPolicyModal
open={sshPolicyModal}
onOpenChange={setSshPolicyModal}
peer={peer}
/>
</PoliciesProvider>
</div>
);
};

View File

@@ -10,6 +10,7 @@ import FullTooltip from "@components/FullTooltip";
import { notify } from "@components/Notification";
import { IconInfoCircle } from "@tabler/icons-react";
import {
ExternalLinkIcon,
MonitorIcon,
MoreVertical,
TerminalSquare,
@@ -17,11 +18,13 @@ import {
Trash2,
} from "lucide-react";
import { useRouter } from "next/navigation";
import React from "react";
import React, { useMemo } from "react";
import { useSWRConfig } from "swr";
import { usePeer } from "@/contexts/PeerProvider";
import { usePermissions } from "@/contexts/PermissionsProvider";
import { ExitNodeDropdownButton } from "@/modules/exit-node/ExitNodeDropdownButton";
import InlineLink from "@components/InlineLink";
import { useDialog } from "@/contexts/DialogProvider";
export default function PeerActionCell() {
const { peer, deletePeer, update, toggleSSH, setSSHInstructionsModal } =
@@ -29,6 +32,14 @@ export default function PeerActionCell() {
const router = useRouter();
const { mutate } = useSWRConfig();
const { permission } = usePermissions();
const { confirm } = useDialog();
const showSSHButton = useMemo(() => {
const isClientSSHEnabled = peer?.local_flags?.server_ssh_allowed;
const isDashboardSSHEnabled = peer?.ssh_enabled;
if (isDashboardSSHEnabled) return true;
return !isClientSSHEnabled;
}, [peer]);
const toggleLoginExpiration = async () => {
const text = peer.login_expiration_enabled ? "disabled" : "enabled";
@@ -49,6 +60,34 @@ export default function PeerActionCell() {
});
};
const disableDashboardSSH = async () => {
const choice = await confirm({
title: `Disable SSH Access?`,
description: (
<div>
Starting from NetBird v0.61.0, once SSH access is disabled, you cannot
re-enable it again from the dashboard. You&apos;ll need to create an
explicit access control policy and update your NetBird client to
restore SSH functionality.{" "}
<InlineLink
href={"https://docs.netbird.io/manage/peers/ssh"}
target={"_blank"}
onClick={(e) => e.stopPropagation()}
>
Learn more
<ExternalLinkIcon size={12} />
</InlineLink>
</div>
),
confirmText: "Disable",
cancelText: "Cancel",
type: "warning",
maxWidthClass: "max-w-xl",
});
if (!choice) return;
toggleSSH(false);
};
return (
<div className={"flex justify-end pr-4 gap-3"}>
<DropdownMenu modal={false}>
@@ -101,21 +140,23 @@ export default function PeerActionCell() {
</DropdownMenuItem>
</FullTooltip>
<DropdownMenuItem
onClick={() =>
peer.ssh_enabled
? toggleSSH(false)
: setSSHInstructionsModal(true)
}
disabled={!permission.peers.update}
>
<div className={"flex gap-3 items-center w-full"}>
<TerminalSquare size={14} className={"shrink-0"} />
<div className={"flex justify-between items-center w-full"}>
{peer.ssh_enabled ? "Disable" : "Enable"} SSH Access
{showSSHButton && (
<DropdownMenuItem
onClick={() =>
peer.ssh_enabled
? disableDashboardSSH()
: setSSHInstructionsModal(true)
}
disabled={!permission.peers.update}
>
<div className={"flex gap-3 items-center w-full"}>
<TerminalSquare size={14} className={"shrink-0"} />
<div className={"flex justify-between items-center w-full"}>
{peer.ssh_enabled ? "Disable" : "Enable"} SSH Access
</div>
</div>
</div>
</DropdownMenuItem>
</DropdownMenuItem>
)}
<ExitNodeDropdownButton peer={peer} />

View File

@@ -6,12 +6,12 @@ import {
import FullTooltip from "@components/FullTooltip";
import { getOperatingSystem } from "@hooks/useOperatingSystem";
import { IconChevronDown } from "@tabler/icons-react";
import { cn } from "@utils/helpers";
import * as React from "react";
import { usePeer } from "@/contexts/PeerProvider";
import { OperatingSystem } from "@/interfaces/OperatingSystem";
import { RDPButton } from "@/modules/remote-access/rdp/RDPButton";
import { SSHButton } from "@/modules/remote-access/ssh/SSHButton";
import { cn } from "@utils/helpers";
export const PeerConnectButton = () => {
const { peer } = usePeer();

View File

@@ -27,8 +27,13 @@ export default function PeerGroupCell() {
const groupIDs = useMemo(() => {
return peerGroups
?.map((group) => group.id)
.filter((id) => id !== undefined) as string[];
?.map((group) => {
if (group?.name === "All") return;
return group.id;
})
.filter((id) => {
return id !== undefined;
}) as string[];
}, [peerGroups]);
return (
@@ -37,6 +42,7 @@ export default function PeerGroupCell() {
description={"Use groups to control what this peer can access"}
groups={groupIDs || []}
hideAllGroup={true}
showAddGroupButton={true}
disabled={!permission.groups.update}
onSave={handleSave}
modal={modal}

View File

@@ -2,81 +2,101 @@ import Badge from "@components/Badge";
import Button from "@components/Button";
import FullTooltip from "@components/FullTooltip";
import { cn } from "@utils/helpers";
import { ArrowUpRightSquareIcon } from "lucide-react";
import { ArrowUpRightIcon, ShieldIcon, SquarePenIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import * as React from "react";
import AccessControlIcon from "@/assets/icons/AccessControlIcon";
import { useState } from "react";
import CircleIcon from "@/assets/icons/CircleIcon";
import { usePolicies } from "@/contexts/PoliciesProvider";
import { Policy } from "@/interfaces/Policy";
import { PostureCheck } from "@/interfaces/PostureCheck";
type Props = {
check: PostureCheck;
};
export const PostureCheckPolicyUsageCell = ({ check }: Props) => {
const router = useRouter();
const [tooltipOpen, setTooltipOpen] = useState(false);
const policyCount = check?.policies?.length || 0;
const policies = check?.policies;
const { openEditPolicyModal } = usePolicies();
return (
<div className={cn("flex gap-4")}>
<FullTooltip
disabled={!(check.policies && check.policies?.length > 0)}
content={
<div className={"text-xs max-w-lg"}>
<span className={"font-medium text-nb-gray-100 text-sm"}>
Assigned
{check.policies && check.policies?.length > 1
? " Policies"
: " Policy"}
</span>
<div className={"flex gap-2 pt-3 pb-2 flex-wrap"}>
{check.policies &&
check.policies?.length > 0 &&
check.policies?.map((policy: Policy, index: number) => {
return (
<Badge
variant={"gray-ghost"}
useHover={false}
key={index}
className={"justify-start font-medium"}
{policyCount > 0 && (
<FullTooltip
contentClassName={"p-0"}
delayDuration={200}
skipDelayDuration={200}
customOpen={tooltipOpen}
customOnOpenChange={setTooltipOpen}
className={"border-nb-gray-800"}
content={
<div className={"text-xs flex flex-col p-1"}>
{policies?.map((policy: Policy) => {
const rule = policy?.rules?.[0];
if (!rule) return;
return (
<button
key={policy.id}
className={
"m-0 pl-3 py-2.5 leading-none flex justify-between group hover:bg-nb-gray-900 rounded-md"
}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setTooltipOpen(false);
openEditPolicyModal(policy, "posture_checks");
}}
>
<div
className={
" flex items-center gap-2 leading-none font-medium text-nb-gray-300 group-hover:text-nb-gray-200 whitespace-nowrap"
}
>
<AccessControlIcon size={12} />
<CircleIcon
size={8}
active={policy.enabled}
className={"shrink-0"}
/>
{policy.name}
</Badge>
);
})}
</div>
<div
className={
"text-nb-gray-300 px-2 ml-4 uppercase font-mono opacity-0 group-hover:opacity-100"
}
>
<SquarePenIcon size={12} />
</div>
</button>
);
})}
</div>
</div>
}
interactive={false}
>
<Badge
onClick={(e) => {
e.stopPropagation();
router.push("/access-control");
}}
variant={"gray"}
useHover={!!(check.policies && check.policies?.length > 0)}
className={cn(
"min-w-[110px] font-medium cursor-pointer",
check.policies &&
check.policies.length == 0 &&
"opacity-30 pointer-events-none",
)}
}
interactive={true}
align={"start"}
alignOffset={0}
sideOffset={14}
>
<AccessControlIcon size={12} />
<span>
<span className={"font-bold"}>
{check.policies && check.policies?.length > 0
? check.policies && check.policies?.length
: ""}
</span>{" "}
{check.policies && check.policies?.length == 0
? "No Policies"
: check.policies && check.policies?.length > 1
? "Policies"
: "Policy"}
</span>
</Badge>
</FullTooltip>
<Badge
variant={"gray"}
useHover={false}
className={"select-none hover:bg-nb-gray-910"}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
if (!tooltipOpen) setTooltipOpen(true);
}}
>
<ShieldIcon size={14} className={"text-green-500"} />
<div>
<span className={"font-medium text-xs"}>{policyCount}</span>
</div>
</Badge>
</FullTooltip>
)}
<FullTooltip
content={
<div className={"text-xs max-w-[260px]"}>
@@ -93,8 +113,8 @@ export const PostureCheckPolicyUsageCell = ({ check }: Props) => {
onClick={() => router.push("/access-control")}
>
<>
<ArrowUpRightSquareIcon size={12} />
Go to Policies
<ArrowUpRightIcon size={12} />
</>
</Button>
</FullTooltip>

View File

@@ -1,13 +1,8 @@
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 * as React from "react";
import { useCallback, useMemo, useState } from "react";
import { Modal, ModalContent, ModalFooter } from "@components/modal/Modal";
import ModalHeader from "@components/modal/ModalHeader";
import Paragraph from "@components/Paragraph";
import Separator from "@components/Separator";
import { IconLoader2 } from "@tabler/icons-react";
import { Peer } from "@/interfaces/Peer";
import {
ChevronsLeftRightEllipsis,
ExternalLinkIcon,
@@ -15,13 +10,18 @@ import {
MonitorIcon,
User2,
} from "lucide-react";
import * as React from "react";
import { useCallback, useMemo, useState } from "react";
import { Peer } from "@/interfaces/Peer";
import Separator from "@components/Separator";
import Paragraph from "@components/Paragraph";
import InlineLink from "@components/InlineLink";
import Button from "@components/Button";
import { Label } from "@components/Label";
import HelpText from "@components/HelpText";
import { Input } from "@components/Input";
import {
RDP_DOCS_LINK,
RDPCredentials,
} from "@/modules/remote-access/rdp/useRemoteDesktop";
import { IconLoader2 } from "@tabler/icons-react";
type Props = {
open: boolean;

View File

@@ -1,14 +1,14 @@
import Button from "@components/Button";
import { DropdownMenuItem } from "@components/DropdownMenu";
import { getOperatingSystem } from "@hooks/useOperatingSystem";
import { CircleHelpIcon, TerminalIcon } from "lucide-react";
import * as React from "react";
import { useState } from "react";
import { usePermissions } from "@/contexts/PermissionsProvider";
import { OperatingSystem } from "@/interfaces/OperatingSystem";
import { Peer } from "@/interfaces/Peer";
import { SSHCredentialsModal } from "@/modules/remote-access/ssh/SSHCredentialsModal";
import { SSHTooltip } from "@/modules/remote-access/ssh/SSHTooltip";
import { getOperatingSystem } from "@hooks/useOperatingSystem";
import { OperatingSystem } from "@/interfaces/OperatingSystem";
type Props = {
peer: Peer;
@@ -19,8 +19,9 @@ export const SSHButton = ({ peer, isDropdown = false }: Props) => {
const [modal, setModal] = useState(false);
const { permission } = usePermissions();
const disabled =
!peer.connected || !peer.ssh_enabled || !permission.peers.update;
const isSSHEnabled =
peer?.local_flags?.server_ssh_allowed || peer?.ssh_enabled;
const disabled = !peer.connected || !permission.peers.update || !isSSHEnabled;
const hasPermission = permission.peers.update;
@@ -42,7 +43,7 @@ export const SSHButton = ({ peer, isDropdown = false }: Props) => {
<div>
<SSHTooltip
isOnline={peer.connected}
isSSHEnabled={peer.ssh_enabled}
isSSHEnabled={isSSHEnabled}
hasPermission={hasPermission}
side={isDropdown ? "left" : "top"}
>

View File

@@ -78,7 +78,8 @@ const SSHDisabledText = ({
<div className={"max-w-xs text-xs flex flex-col gap-2"}>
<div>
SSH Access is currently disabled for this peer. Please enable SSH access
for this peer and make sure SSH is allowed in the NetBird Client.
for this peer and make sure to add an explicit access control policy
allowing SSH access.
</div>
<div>
<InlineLink

View File

@@ -287,7 +287,12 @@ export const useNetBirdClient = () => {
{
name,
wg_pub_key: keyPairs.publicKey,
rules: rules ?? ["tcp/22022", "tcp/3389", "tcp/44338"],
rules: rules ?? [
"tcp/22022",
"tcp/3389",
"tcp/44338",
"netbird-ssh/22",
],
},
`/${peerId}/temporary-access`,
);

View File

@@ -175,7 +175,7 @@ export default function NetworkRoutesTable({
wrapperProps={isGroupPage ? { className: "mt-6 w-full" } : undefined}
paginationPaddingClassName={isGroupPage ? "px-0 pt-8" : undefined}
tableClassName={isGroupPage ? "mt-0 mb-2" : undefined}
inset={!isGroupPage}
inset={false}
minimal={isGroupPage}
keepStateInLocalStorage={!isGroupPage}
searchPlaceholder={"Search by network, range, name or groups..."}

View File

@@ -1,6 +1,6 @@
import DescriptionWithTooltip from "@components/ui/DescriptionWithTooltip";
import GroupBadge from "@components/ui/GroupBadge";
import PeerBadge from "@components/ui/PeerBadge";
import PeerCountBadge from "@components/ui/PeerCountBadge";
import { ArrowRightIcon } from "lucide-react";
import * as React from "react";
import { useMemo } from "react";
@@ -44,9 +44,13 @@ export default function RoutePeerCell({ route }: Props) {
{group && (
<>
<GroupBadge group={group} />
<GroupBadge
group={group}
redirectToGroupPage={true}
redirectGroupTab={"peers"}
/>
<ArrowRightIcon size={14} className={"shrink-0"} />
<PeerBadge> {group.peers_count} Peer(s)</PeerBadge>
<PeerCountBadge group={group} />
</>
)}
</div>

View File

@@ -168,7 +168,7 @@ export default function SetupKeysTable({
wrapperProps={isGroupPage ? { className: "mt-6 w-full" } : undefined}
paginationPaddingClassName={isGroupPage ? "px-0 pt-8" : undefined}
tableClassName={isGroupPage ? "mt-0 mb-2" : undefined}
inset={!isGroupPage}
inset={false}
minimal={isGroupPage}
keepStateInLocalStorage={!isGroupPage}
text={"Setup Keys"}

View File

@@ -4,6 +4,7 @@ import TextWithTooltip from "@components/ui/TextWithTooltip";
import { cn, generateColorFromString } from "@utils/helpers";
import { orderBy } from "lodash";
import * as React from "react";
import { useMemo } from "react";
import { User } from "@/interfaces/User";
import { SmallUserAvatar } from "@/modules/users/SmallUserAvatar";
@@ -12,6 +13,7 @@ type Props = {
max?: number;
avatarClassName?: string;
side?: "left" | "right" | "top" | "bottom";
isAllGroup?: boolean;
};
export const HorizontalUsersStack = ({
@@ -19,9 +21,15 @@ export const HorizontalUsersStack = ({
max = 3,
avatarClassName,
side = "top",
isAllGroup = false,
}: Props) => {
let usersToDisplay = orderBy(users?.slice(0, max) || [], ["name"]);
const userCountText = useMemo(() => {
if (isAllGroup) return "All Users";
return `${users?.length || 0} User(s)`;
}, [users, isAllGroup]);
return (
<FullTooltip
side={side}
@@ -84,7 +92,7 @@ export const HorizontalUsersStack = ({
}}
>
<UserAvatarCircle
name={user.name}
name={user?.name || user?.id}
className={avatarClassName}
hoverEffect={true}
/>
@@ -97,7 +105,7 @@ export const HorizontalUsersStack = ({
users.length > 0 && "group-hover/user-stack:text-nb-gray-200 ",
)}
>
{users?.length || 0} User(s)
{userCountText}
</div>
</div>
</FullTooltip>

View File

@@ -0,0 +1,73 @@
import * as React from "react";
import { Suspense, useMemo } from "react";
import { usePortalElement } from "@hooks/usePortalElement";
import SkeletonTable, {
SkeletonTableHeader,
} from "@components/skeletons/SkeletonTable";
import { User } from "@/interfaces/User";
import useFetchApi from "@utils/api";
import { Peer } from "@/interfaces/Peer";
import MinimalPeersTable from "@/modules/peer/MinimalPeersTable";
import NoResults from "@components/ui/NoResults";
import PeerIcon from "@/assets/icons/PeerIcon";
import Paragraph from "@components/Paragraph";
type Props = {
user: User;
};
export const UserPeersSection = ({ user }: Props) => {
const { ref: headingRef, portalTarget } =
usePortalElement<HTMLHeadingElement>();
const { data: peers, isLoading: isPeersLoading } =
useFetchApi<Peer[]>("/peers");
const userPeers = useMemo(() => {
return (
peers?.filter((peer) => {
return peer?.user_id === user.id;
}) || []
);
}, [user, peers]);
return (
<div className={"pb-10 px-8"}>
<div className={"max-w-6xl"}>
<div className={"flex justify-between items-center mb-5"}>
<div>
<h2 ref={headingRef}>Peers</h2>
<Paragraph>View all peers registered by this user.</Paragraph>
</div>
</div>
<Suspense
fallback={
<div>
<SkeletonTableHeader className={"!p-0"} />
<div className={"mt-8 w-full"}>
<SkeletonTable withHeader={false} />
</div>
</div>
}
>
<MinimalPeersTable
isLoading={isPeersLoading}
peers={userPeers}
headingTarget={portalTarget}
getStartedCard={
<NoResults
className={"py-4"}
title={"This user has no registered peers"}
description={
"Install NetBird and sign in as this user to register peers."
}
icon={<PeerIcon size={20} className={"fill-nb-gray-300"} />}
/>
}
/>
</Suspense>
</div>
</div>
);
};

View File

@@ -244,8 +244,12 @@ export const getBrowserInfo = () => {
}
};
export const singularize = (word: string, count?: number) => {
if (!count) return word;
export const singularize = (
word: string,
count?: number,
showZero?: boolean,
) => {
if (!count) return showZero ? `0 ${word}` : word;
if (word.endsWith("ies") && count === 1) {
return count + " " + word.slice(0, -3) + "y";
} else if (word.endsWith("s") && count === 1) {

View File

@@ -84,3 +84,13 @@ export const isNativeSSHSupported = (version: string) => {
if (version == "development") return true;
return compareVersions(version, "0.60.0");
};
/**
* Check if NetBird SSH protocol is supported.
* Supported starting from NetBird v0.61.0+.
* @param version
*/
export const isNetbirdSSHProtocolSupported = (version: string) => {
if (version == "development") return true;
return compareVersions(version, "0.61.0");
};