From 5faa00d3e6f9479e4e7dd0cde6b15765d94f8c47 Mon Sep 17 00:00:00 2001 From: Eduard Gert Date: Mon, 29 Dec 2025 13:42:54 +0100 Subject: [PATCH 1/3] Add fine-grained ssh policy --- src/app/(dashboard)/group/page.tsx | 34 +- src/app/(dashboard)/network-routes/page.tsx | 2 +- src/app/(dashboard)/peer/page.tsx | 22 +- src/app/(dashboard)/team/user/page.tsx | 122 +++++--- src/app/(remote-access)/peer/rdp/page.tsx | 10 +- src/app/(remote-access)/peer/ssh/page.tsx | 15 +- src/assets/icons/JumpcloudIcon.tsx | 19 ++ src/assets/icons/OIDCIcon.tsx | 27 ++ src/components/Badge.tsx | 11 +- src/components/DropdownMenu.tsx | 2 +- src/components/HoverCard.tsx | 43 +++ src/components/Label.tsx | 2 +- src/components/PeerGroupSelector.tsx | 56 +++- src/components/PortSelector.tsx | 6 +- src/components/Tabs.tsx | 57 ++-- src/components/Tooltip.tsx | 12 +- src/components/ui/GroupBadge.tsx | 21 +- src/components/ui/GroupBadgeIcon.tsx | 16 +- src/components/ui/MultipleGroups.tsx | 72 +++-- src/components/ui/PeerCountBadge.tsx | 64 ++++ src/components/ui/PolicyDirection.tsx | 14 +- src/components/ui/ResourceCountBadge.tsx | 33 ++ src/components/ui/TruncatedText.tsx | 46 ++- src/contexts/PeerProvider.tsx | 5 +- src/contexts/PoliciesProvider.tsx | 35 ++- src/interfaces/Peer.ts | 14 + src/interfaces/Policy.ts | 5 +- .../access-control/AccessControlModal.tsx | 121 ++++++-- .../access-control/ssh/SSHAccessType.tsx | 50 +++ .../ssh/SSHAuthorizedGroups.tsx | 139 +++++++++ .../ssh/SSHUsernameSelector.tsx | 261 ++++++++++++++++ .../table/AccessControlDestinationsCell.tsx | 15 +- .../table/AccessControlPortsCell.tsx | 16 +- .../table/AccessControlSourcesCell.tsx | 18 +- .../access-control/useAccessControl.ts | 73 ++++- src/modules/activity/ActivityDescription.tsx | 8 + .../NameserverDistributionGroupsCell.tsx | 16 +- .../table/NameserverGroupTable.tsx | 2 +- .../details/GroupNameserversSection.tsx | 12 +- .../details/GroupNetworkRoutesSection.tsx | 67 +--- .../groups/details/GroupPeersSection.tsx | 9 +- .../groups/details/GroupPoliciesSection.tsx | 8 +- .../groups/details/GroupResourcesSection.tsx | 11 +- .../groups/details/GroupSetupKeysSection.tsx | 12 +- .../groups/details/GroupUsersSection.tsx | 9 +- src/modules/groups/details/useGroupDetails.ts | 7 +- src/modules/groups/table/GroupsTable.tsx | 2 +- src/modules/groups/useGroupIdentification.ts | 12 +- src/modules/networks/NetworkProvider.tsx | 63 ++-- .../networks/resources/ResourceGroupCell.tsx | 6 +- .../networks/resources/ResourcePolicyCell.tsx | 123 +++++--- .../routing-peers/NetworkRoutingPeerName.tsx | 10 +- .../networks/table/NetworkActionCell.tsx | 2 +- src/modules/peer/PeerSSHInstructions.tsx | 10 +- src/modules/peer/PeerSSHPolicyInfo.tsx | 2 +- src/modules/peer/PeerSSHPolicyModal.tsx | 3 +- src/modules/peer/PeerSSHToggle.tsx | 290 +++++++++++++++++- src/modules/peers/PeerActionCell.tsx | 71 ++++- src/modules/peers/PeerConnectButton.tsx | 2 +- src/modules/peers/PeerGroupCell.tsx | 10 +- .../cells/PostureCheckPolicyUsageCell.tsx | 138 +++++---- .../remote-access/rdp/RDPCredentialsModal.tsx | 22 +- src/modules/remote-access/ssh/SSHButton.tsx | 11 +- src/modules/remote-access/ssh/SSHTooltip.tsx | 3 +- src/modules/remote-access/useNetBirdClient.ts | 7 +- .../route-group/NetworkRoutesTable.tsx | 2 +- src/modules/routes/RoutePeerCell.tsx | 10 +- src/modules/setup-keys/SetupKeysTable.tsx | 2 +- src/modules/users/HorizontalUsersStack.tsx | 12 +- src/modules/users/UserPeersSection.tsx | 73 +++++ src/utils/helpers.ts | 8 +- src/utils/version.ts | 10 + 72 files changed, 2031 insertions(+), 492 deletions(-) create mode 100644 src/assets/icons/JumpcloudIcon.tsx create mode 100644 src/assets/icons/OIDCIcon.tsx create mode 100644 src/components/HoverCard.tsx create mode 100644 src/components/ui/PeerCountBadge.tsx create mode 100644 src/components/ui/ResourceCountBadge.tsx create mode 100644 src/modules/access-control/ssh/SSHAccessType.tsx create mode 100644 src/modules/access-control/ssh/SSHAuthorizedGroups.tsx create mode 100644 src/modules/access-control/ssh/SSHUsernameSelector.tsx create mode 100644 src/modules/users/UserPeersSection.tsx diff --git a/src/app/(dashboard)/group/page.tsx b/src/app/(dashboard)/group/page.tsx index 1834cf60..df9d0d1e 100644 --- a/src/app/(dashboard)/group/page.tsx +++ b/src/app/(dashboard)/group/page.tsx @@ -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 }) => { - + - + - + - + - + - + - + ); diff --git a/src/app/(dashboard)/network-routes/page.tsx b/src/app/(dashboard)/network-routes/page.tsx index deee52d7..10967154 100644 --- a/src/app/(dashboard)/network-routes/page.tsx +++ b/src/app/(dashboard)/network-routes/page.tsx @@ -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"), diff --git a/src/app/(dashboard)/peer/page.tsx b/src/app/(dashboard)/peer/page.tsx index 62d888ca..e0788e5a 100644 --- a/src/app/(dashboard)/peer/page.tsx +++ b/src/app/(dashboard)/peer/page.tsx @@ -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 = () => { -
- {user?.email} -
+ {(user?.id || user?.email) && ( +
+ + + {user?.email || user?.id} + + + +
+ )}
@@ -188,7 +209,7 @@ function UserOverview({ user, initialGroups }: Readonly) { 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) {
- {!user.is_service_user && isOwnerOrAdmin && ( + {!isServiceUser && isOwnerOrAdmin && (
@@ -238,7 +259,7 @@ function UserOverview({ user, initialGroups }: Readonly) { @@ -248,38 +269,65 @@ function UserOverview({ user, initialGroups }: Readonly) {
- {(user.is_current || user.is_service_user) && permission.pats.read && ( - <> - -
-
-
-
-

Access Tokens

- - Access tokens give access to NetBird API. - -
-
+ {showSeparator && } + + + + {showPeers && ( + + + + )} + {showAccessTokens && ( + +
+
+
- - - +

Access Tokens

+ + Access tokens give access to NetBird API. + +
+
+
+ + + +
+
-
-
- - )} + + )} + ); } diff --git a/src/app/(remote-access)/peer/rdp/page.tsx b/src/app/(remote-access)/peer/rdp/page.tsx index 784bc122..35082eb0 100644 --- a/src/app/(remote-access)/peer/rdp/page.tsx +++ b/src/app/(remote-access)/peer/rdp/page.tsx @@ -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( diff --git a/src/app/(remote-access)/peer/ssh/page.tsx b/src/app/(remote-access)/peer/ssh/page.tsx index 5f9fca90..e24035b2 100644 --- a/src/app/(remote-access)/peer/ssh/page.tsx +++ b/src/app/(remote-access)/peer/ssh/page.tsx @@ -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, diff --git a/src/assets/icons/JumpcloudIcon.tsx b/src/assets/icons/JumpcloudIcon.tsx new file mode 100644 index 00000000..945b2fec --- /dev/null +++ b/src/assets/icons/JumpcloudIcon.tsx @@ -0,0 +1,19 @@ +import { iconProperties, IconProps } from "@/assets/icons/IconProperties"; + +export default function JumpcloudIcon(props: Readonly) { + return ( + + + + ); +} diff --git a/src/assets/icons/OIDCIcon.tsx b/src/assets/icons/OIDCIcon.tsx new file mode 100644 index 00000000..ca73b789 --- /dev/null +++ b/src/assets/icons/OIDCIcon.tsx @@ -0,0 +1,27 @@ +import { iconProperties, IconProps } from "@/assets/icons/IconProperties"; + +export default function OIDCIcon(props: Readonly) { + return ( + + + + + + ); +} diff --git a/src/components/Badge.tsx b/src/components/Badge.tsx index 917d8702..d7ee3c3b 100644 --- a/src/components/Badge.tsx +++ b/src/components/Badge.tsx @@ -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 (
, + React.ComponentPropsWithoutRef & + TooltipVariants +>( + ( + { + className = "px-4 py-2.5", + sideOffset = 7, + side = "top", + variant = "default", + ...props + }, + ref, + ) => ( + + +
{props.children}
+
+
+ ), +); +HoverCardContent.displayName = HoverCardPrimitive.Content.displayName; + +export { HoverCard, HoverCardContent, HoverCardTrigger }; diff --git a/src/components/Label.tsx b/src/components/Label.tsx index 425c5522..8b9641ba 100644 --- a/src/components/Label.tsx +++ b/src/components/Label.tsx @@ -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< diff --git a/src/components/PeerGroupSelector.tsx b/src/components/PeerGroupSelector.tsx index d063aed0..7fafdbc5 100644 --- a/src/components/PeerGroupSelector.tsx +++ b/src/components/PeerGroupSelector.tsx @@ -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({ />
-
+
{option?.id && showRoutes && ( )} @@ -535,19 +535,12 @@ export function PeerGroupSelector({ )} -
+
{!users ? ( -
- - {peerCount} Peer(s) -
+ ) : ( )} -
@@ -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 ( + + 0 User(s) + + ); return ( { + const peerCount = group.peers?.length ?? group?.peers_count ?? 0; + const resourcesCount = group?.resources_count ?? 0; + const hidePeerCounter = + showResourceCounter && peerCount === 0 && resourcesCount > 0; + + return ( +
+ + {peerCount} Peer(s) +
+ ); +}; + const ResourcesCounter = ({ group }: { group: Group }) => { return group?.resources_count && group.resources_count > 0 ? (
toggle(x)} + onClick={(e) => { + e.preventDefault(); + e.stopPropagation(); + toggle(x); + }} className={"uppercase tracking-wider font-medium py-1"} > {x} diff --git a/src/components/Tabs.tsx b/src/components/Tabs.tsx index 9f2c7121..db7703f0 100644 --- a/src/components/Tabs.tsx +++ b/src/components/Tabs.tsx @@ -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, React.ComponentPropsWithoutRef & TabListProps ->(({ className, justify = "center", ...props }, ref) => ( - - - -
- {props.children} -
- -
-
-)); +>(({ className, justify = "center", hidden = false, ...props }, ref) => { + return ( + !hidden && ( + + + +
+ {props.children} +
+ +
+
+ ) + ); +}); TabsList.displayName = TabsPrimitive.List.displayName; const TabsTrigger = React.forwardRef< diff --git a/src/components/Tooltip.tsx b/src/components/Tooltip.tsx index 6f1a589a..48288b58 100644 --- a/src/components/Tooltip.tsx +++ b/src/components/Tooltip.tsx @@ -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", ], }, }, diff --git a/src/components/ui/GroupBadge.tsx b/src/components/ui/GroupBadge.tsx index a9b6bc86..39016135 100644 --- a/src/components/ui/GroupBadge.tsx +++ b/src/components/ui/GroupBadge.tsx @@ -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) { 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 ( { e.preventDefault(); onClick?.(e); + if (redirectToGroupPage) handleGroupPageRedirect(); }} > @@ -49,6 +67,7 @@ export default function GroupBadge({ text={group?.name || ""} maxChars={maxChars} maxWidth={maxWidth} + className={textClassName} hideTooltip={hideTooltip} /> {children} diff --git a/src/components/ui/GroupBadgeIcon.tsx b/src/components/ui/GroupBadgeIcon.tsx index 0e4065bf..9c7f5aea 100644 --- a/src/components/ui/GroupBadgeIcon.tsx +++ b/src/components/ui/GroupBadgeIcon.tsx @@ -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 ; @@ -29,6 +37,10 @@ export const GroupBadgeIcon = ({ return ; if (isOktaGroup) return ; + if (isJumpcloudGroup) + return ; + if (isOIDCGroup) + return ; if (isJWTGroup) return ; return ; diff --git a/src/components/ui/MultipleGroups.tsx b/src/components/ui/MultipleGroups.tsx index 5e340ca0..bd190daf 100644 --- a/src/components/ui/MultipleGroups.tsx +++ b/src/components/ui/MultipleGroups.tsx @@ -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) { const { permission } = usePermissions(); @@ -45,13 +53,9 @@ export default function MultipleGroups({ const otherGroups = orderedGroups.length > 0 ? orderedGroups.slice(1) : []; return ( - - - +
+ +
)}
- +
{orderedGroups && orderedGroups.length > 0 && ( - e.stopPropagation()} > @@ -102,19 +106,31 @@ export default function MultipleGroups({ "flex gap-2 items-center justify-between w-full" } > - + - {group.peers_count} Peer(s) + {showResources ? ( + + ) : showUsers ? ( + + ) : ( + + )}
) ); })}
- + )} - - + +
); } @@ -129,3 +145,17 @@ export const TransparentEditIconButton = () => {
); }; + +export const UserCountStack = ({ group }: { group: Group }) => { + const { users } = useUsers(); + const usersOfGroup = + users?.filter((user) => user.auto_groups.includes(group.id as string)) || + []; + return ( + + ); +}; diff --git a/src/components/ui/PeerCountBadge.tsx b/src/components/ui/PeerCountBadge.tsx new file mode 100644 index 00000000..73b6d256 --- /dev/null +++ b/src/components/ui/PeerCountBadge.tsx @@ -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 & + 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) => { + 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 ? ( + + ) : ( + + + {singularize("Peers", peerCount, true)} + + ); +} diff --git a/src/components/ui/PolicyDirection.tsx b/src/components/ui/PolicyDirection.tsx index 415c685c..f2ba3695 100644 --- a/src/components/ui/PolicyDirection.tsx +++ b/src/components/ui/PolicyDirection.tsx @@ -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) { 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({ + + + { + const formatValue = trim(value.toLowerCase()); + const formatSearch = trim(search.toLowerCase()); + if (formatValue.includes(formatSearch)) return 1; + return 0; + }} + > + +
+ +
+
+ +
+
+
+
+ +
+
+
+ +
+ {notFound && ( + +
+ { + toggle(search); + searchRef.current?.focus(); + }} + value={search} + onClick={(e) => e.preventDefault()} + > + + {search} + +
+ Add username by pressing{" "} + + {"'Enter'"} + +
+
+
+
+ )} + + +
+ {values?.map((user) => { + const isSelected = values?.includes(user); + return ( + { + toggle(user); + searchRef.current?.focus(); + }} + onClick={(e) => e.preventDefault()} + > +
+ + {user} + +
+ +
+ +
+
+ ); + })} +
+
+
+
+
+
+ + + ); +} diff --git a/src/modules/access-control/table/AccessControlDestinationsCell.tsx b/src/modules/access-control/table/AccessControlDestinationsCell.tsx index 7d07a102..40451fd9 100644 --- a/src/modules/access-control/table/AccessControlDestinationsCell.tsx +++ b/src/modules/access-control/table/AccessControlDestinationsCell.tsx @@ -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) { + 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 ? ( - +
+ + {canUpdate && } +
) : ( ); diff --git a/src/modules/access-control/table/AccessControlPortsCell.tsx b/src/modules/access-control/table/AccessControlPortsCell.tsx index acfe0d19..5f427dcc 100644 --- a/src/modules/access-control/table/AccessControlPortsCell.tsx +++ b/src/modules/access-control/table/AccessControlPortsCell.tsx @@ -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) { 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) ?? []; diff --git a/src/modules/access-control/table/AccessControlSourcesCell.tsx b/src/modules/access-control/table/AccessControlSourcesCell.tsx index 69c49ec6..4193b643 100644 --- a/src/modules/access-control/table/AccessControlSourcesCell.tsx +++ b/src/modules/access-control/table/AccessControlSourcesCell.tsx @@ -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 ? ( - +
+ + {canUpdate && } +
) : ( ); diff --git a/src/modules/access-control/useAccessControl.ts b/src/modules/access-control/useAccessControl.ts index 54ff8977..d7a5f904 100644 --- a/src/modules/access-control/useAccessControl.ts +++ b/src/modules/access-control/useAccessControl.ts @@ -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"], + ); +}; diff --git a/src/modules/activity/ActivityDescription.tsx b/src/modules/activity/ActivityDescription.tsx index eb8c21de..d795a941 100644 --- a/src/modules/activity/ActivityDescription.tsx +++ b/src/modules/activity/ActivityDescription.tsx @@ -203,6 +203,14 @@ export default function ActivityDescription({ event }: Props) {
); + if (event.activity_code == "user.create") + return ( +
+ {event.meta.username} {event.meta.email}{" "} + was created by {event?.initiator_name || "NetBird"} +
+ ); + if (event.activity_code == "user.group.add") return (
diff --git a/src/modules/dns-nameservers/table/NameserverDistributionGroupsCell.tsx b/src/modules/dns-nameservers/table/NameserverDistributionGroupsCell.tsx index 95c588b2..baa84d82 100644 --- a/src/modules/dns-nameservers/table/NameserverDistributionGroupsCell.tsx +++ b/src/modules/dns-nameservers/table/NameserverDistributionGroupsCell.tsx @@ -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 ; + return ( +
+ + {canUpdate && } +
+ ); } diff --git a/src/modules/dns-nameservers/table/NameserverGroupTable.tsx b/src/modules/dns-nameservers/table/NameserverGroupTable.tsx index fffc9bc0..b668e8dc 100644 --- a/src/modules/dns-nameservers/table/NameserverGroupTable.tsx +++ b/src/modules/dns-nameservers/table/NameserverGroupTable.tsx @@ -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} diff --git a/src/modules/groups/details/GroupNameserversSection.tsx b/src/modules/groups/details/GroupNameserversSection.tsx index 5d59b0f8..5e2a369d 100644 --- a/src/modules/groups/details/GroupNameserversSection.tsx +++ b/src/modules/groups/details/GroupNameserversSection.tsx @@ -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 ( [] = [ - { - accessorKey: "network_id", - header: ({ column }) => { - return Name; - }, - sortingFn: "text", - cell: ({ row }) => , - }, - { - accessorKey: "description", - sortingFn: "text", - }, - { - accessorKey: "domain_search", - sortingFn: "text", - }, - { - accessorKey: "network", - header: ({ column }) => { - return Network; - }, - cell: ({ row }) => ( - - ), - }, - { - accessorKey: "metric", - header: ({ column }) => { - return Metric; - }, - cell: ({ row }) => , - sortingFn: "alphanumeric", - }, - { - id: "enabled", - accessorKey: "enabled", - sortingFn: "basic", - header: ({ column }) => ( - Active - ), - cell: ({ row }) => , - }, -]; +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[] }) => { [] = [ }, ]; -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({}); const [open, setOpen] = useState(false); @@ -112,7 +117,7 @@ export const GroupPeersSection = ({ peers }: { peers?: Peer[] }) => { return ( 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 ( diff --git a/src/modules/groups/details/GroupResourcesSection.tsx b/src/modules/groups/details/GroupResourcesSection.tsx index c428da1c..2c37265a 100644 --- a/src/modules/groups/details/GroupResourcesSection.tsx +++ b/src/modules/groups/details/GroupResourcesSection.tsx @@ -100,11 +100,15 @@ const GroupResourcesColumns: ColumnDef[] = [ }, ]; +type Props = { + resources?: NetworkResourceWithNetwork[]; + isLoading?: boolean; +}; + export const GroupResourcesSection = ({ resources, -}: { - resources?: NetworkResourceWithNetwork[]; -}) => { + isLoading = true, +}: Props) => { const [sorting, setSorting] = useState([]); 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) => ( import("@/modules/setup-keys/SetupKeysTable"), ); +type Props = { + setupKeys?: SetupKey[]; + isLoading?: boolean; +}; + export const GroupSetupKeysSection = ({ setupKeys, -}: { - setupKeys?: SetupKey[]; -}) => { + isLoading = true, +}: Props) => { const { group } = useGroupContext(); return ( [] = [ }, ]; -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({}); const [open, setOpen] = useState(false); @@ -122,7 +127,7 @@ export const GroupUsersSection = ({ users }: { users?: User[] }) => { return ( { + const groupDetails = useMemo(() => { if (isLoading || !group) return null; return { @@ -147,4 +147,9 @@ export default function useGroupDetails(groupId: string) { linkedPeers, linkedNetworkResources, ]); + + return { + groupDetails, + isLoading, + }; } diff --git a/src/modules/groups/table/GroupsTable.tsx b/src/modules/groups/table/GroupsTable.tsx index 42521ac3..e0894bfe 100644 --- a/src/modules/groups/table/GroupsTable.tsx +++ b/src/modules/groups/table/GroupsTable.tsx @@ -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[] = [ { diff --git a/src/modules/groups/useGroupIdentification.ts b/src/modules/groups/useGroupIdentification.ts index af04e42b..9bbd4ff7 100644 --- a/src/modules/groups/useGroupIdentification.ts +++ b/src/modules/groups/useGroupIdentification.ts @@ -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, }; }; diff --git a/src/modules/networks/NetworkProvider.tsx b/src/modules/networks/NetworkProvider.tsx index 5fe49de5..05b6213a 100644 --- a/src/modules/networks/NetworkProvider.tsx +++ b/src/modules/networks/NetworkProvider.tsx @@ -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; 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(); 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}`); }} /> - { - setPolicyModal(state); - setPolicyDefaultSettings(undefined); - }} - > - { - setPolicyModal(false); + + { + 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); }} - /> - + > + { + 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)); + } + }} + /> + + {currentNetwork && ( <> { openResourceGroupModal(network, resource); }} > - + {permission.networks.update && } ); diff --git a/src/modules/networks/resources/ResourcePolicyCell.tsx b/src/modules/networks/resources/ResourcePolicyCell.tsx index 1482b4ab..4144abd7 100644 --- a/src/modules/networks/resources/ResourcePolicyCell.tsx +++ b/src/modules/networks/resources/ResourcePolicyCell.tsx @@ -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("/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) => {
{policyCount > 0 && ( - - Assigned Policies - -
- {assignedPolicies?.map((policy: Policy, index: number) => { - return ( - + {assignedPolicies?.map((policy: Policy) => { + const rule = policy?.rules?.[0]; + if (!rule) return; + return ( +
+ +
+ +
+ + ); + })}
} interactive={true} + align={"start"} + alignOffset={0} + sideOffset={14} > - + { + e.preventDefault(); + e.stopPropagation(); + if (!tooltipOpen) setTooltipOpen(true); + }} + >
- {" "} - {assignedPolicies?.length} + {enabledPolicies?.length}
diff --git a/src/modules/networks/routing-peers/NetworkRoutingPeerName.tsx b/src/modules/networks/routing-peers/NetworkRoutingPeerName.tsx index 092f5064..f22ca161 100644 --- a/src/modules/networks/routing-peers/NetworkRoutingPeerName.tsx +++ b/src/modules/networks/routing-peers/NetworkRoutingPeerName.tsx @@ -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 (
- + - {routingPeerGroup.peers_count} Peer(s) +
); } diff --git a/src/modules/networks/table/NetworkActionCell.tsx b/src/modules/networks/table/NetworkActionCell.tsx index 5bac5d3d..f786acf9 100644 --- a/src/modules/networks/table/NetworkActionCell.tsx +++ b/src/modules/networks/table/NetworkActionCell.tsx @@ -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) { const { permission } = usePermissions(); const { deleteNetwork, openEditNetworkModal } = useNetworksContext(); const router = useRouter(); diff --git a/src/modules/peer/PeerSSHInstructions.tsx b/src/modules/peer/PeerSSHInstructions.tsx index afb9bfba..b9ddc90c 100644 --- a/src/modules/peer/PeerSSHInstructions.tsx +++ b/src/modules/peer/PeerSSHInstructions.tsx @@ -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; @@ -101,8 +100,7 @@ export const PeerSSHInstructions = ({

Starting from NetBird v0.60.0, SSH requires an explicit access - control policy that allows TCP traffic on port{" "} - 22 + control policy to allow SSH connections to this machine.

diff --git a/src/modules/peer/PeerSSHPolicyInfo.tsx b/src/modules/peer/PeerSSHPolicyInfo.tsx index 38588edc..6e0387a9 100644 --- a/src/modules/peer/PeerSSHPolicyInfo.tsx +++ b/src/modules/peer/PeerSSHPolicyInfo.tsx @@ -21,7 +21,7 @@ export const PeerSSHPolicyInfo = ({ peer, className }: Props) => { Starting from NetBird v0.60.0, SSH requires an explicit access - control policy that allows TCP traffic on port 22.{" "} + control policy to allow SSH connections to this machine.{" "} setPolicyModal(true)}> Create SSH Policy diff --git a/src/modules/peer/PeerSSHPolicyModal.tsx b/src/modules/peer/PeerSSHPolicyModal.tsx index b11648c2..91422e35 100644 --- a/src/modules/peer/PeerSSHPolicyModal.tsx +++ b/src/modules/peer/PeerSSHPolicyModal.tsx @@ -15,8 +15,7 @@ export const PeerSSHPolicyModal = ({ open, onOpenChange, peer }: Props) => { { const { permission } = usePermissions(); const { peer, toggleSSH, setSSHInstructionsModal } = usePeer(); + const { data: policies, isLoading } = useFetchApi("/policies"); + const [tooltipOpen, setTooltipOpen] = useState(false); + const [policyModal, setPolicyModal] = useState(false); + const [sshPolicyModal, setSshPolicyModal] = useState(false); + const [currentPolicy, setCurrentPolicy] = useState(); + 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: ( +
+ Starting from NetBird v0.60.0, once SSH access is disabled, you cannot + re-enable it again from the dashboard. You'll need to create an + explicit access control policy and update your NetBird client to + restore SSH functionality.{" "} + e.stopPropagation()} + > + Learn more + + +
+ ), + confirmText: "Disable", + cancelText: "Cancel", + type: "warning", + maxWidthClass: "max-w-xl", + }); + if (!choice) return; + toggleSSH(false); + }; + + return isSSHDashboardEnabled ? ( <> - {`You don't have the required permissions to update this - setting.`} + {`You don't have the required permissions to update this setting.`}
} @@ -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 = () => { + ) : ( +
+
+ +
+ + + Set up SSH and create an explicit access control policy defining which + users can access specific local usernames of this machine via SSH. + + + {!isNetbirdSSHProtocolSupported(peer.version) && + enabledPolicies?.length > 0 && + isSSHClientEnabled && ( + + } + 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. + + )} + + {!isSSHClientEnabled && enabledPolicies?.length > 0 && ( + + } + className="my-3" + > + You have an SSH access policy configured, but the SSH server + isn't enabled on this client. Enable the SSH server to allow SSH + connections. + + )} + + {isSSHClientEnabled && enabledPolicies?.length === 0 && ( + + } + className="my-3" + > + Your SSH server is enabled, but starting from NetBird v0.60.0, SSH + requires an explicit access control policy. Please create an SSH + access control policy in order to allow SSH connections. + + )} + +
+ {isSSHClientEnabled ? ( + + ) : ( + + )} + + {enabledPolicies?.length > 0 && ( + + {assignedPolicies?.map((policy: Policy) => { + const rule = policy?.rules?.[0]; + if (!rule) return; + return ( + + ); + })} +
+ } + interactive={true} + align={"start"} + alignOffset={0} + sideOffset={8} + > + { + e.preventDefault(); + e.stopPropagation(); + if (!tooltipOpen) setTooltipOpen(true); + }} + > + 0 + ? "text-green-500" + : "text-nb-gray-300", + )} + /> +
+ + {singularize( + "Active Policies", + enabledPolicies?.length, + true, + )} + +
+
+ + )} +
+ + + { + setPolicyModal(state); + setCurrentPolicy(undefined); + }} + > + { + setPolicyModal(false); + setCurrentPolicy(undefined); + }} + /> + + + +
); }; diff --git a/src/modules/peers/PeerActionCell.tsx b/src/modules/peers/PeerActionCell.tsx index aae26d7d..1271e90b 100644 --- a/src/modules/peers/PeerActionCell.tsx +++ b/src/modules/peers/PeerActionCell.tsx @@ -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: ( +
+ Starting from NetBird v0.60.0, once SSH access is disabled, you cannot + re-enable it again from the dashboard. You'll need to create an + explicit access control policy and update your NetBird client to + restore SSH functionality.{" "} + e.stopPropagation()} + > + Learn more + + +
+ ), + confirmText: "Disable", + cancelText: "Cancel", + type: "warning", + maxWidthClass: "max-w-xl", + }); + if (!choice) return; + toggleSSH(false); + }; + return (
@@ -101,21 +140,23 @@ export default function PeerActionCell() { - - peer.ssh_enabled - ? toggleSSH(false) - : setSSHInstructionsModal(true) - } - disabled={!permission.peers.update} - > -
- -
- {peer.ssh_enabled ? "Disable" : "Enable"} SSH Access + {showSSHButton && ( + + peer.ssh_enabled + ? disableDashboardSSH() + : setSSHInstructionsModal(true) + } + disabled={!permission.peers.update} + > +
+ +
+ {peer.ssh_enabled ? "Disable" : "Enable"} SSH Access +
-
- + + )} diff --git a/src/modules/peers/PeerConnectButton.tsx b/src/modules/peers/PeerConnectButton.tsx index 05b60c80..5717ea33 100644 --- a/src/modules/peers/PeerConnectButton.tsx +++ b/src/modules/peers/PeerConnectButton.tsx @@ -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(); diff --git a/src/modules/peers/PeerGroupCell.tsx b/src/modules/peers/PeerGroupCell.tsx index 2e7a2f86..5c78f7c1 100644 --- a/src/modules/peers/PeerGroupCell.tsx +++ b/src/modules/peers/PeerGroupCell.tsx @@ -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} diff --git a/src/modules/posture-checks/table/cells/PostureCheckPolicyUsageCell.tsx b/src/modules/posture-checks/table/cells/PostureCheckPolicyUsageCell.tsx index f4849310..d43a6bb7 100644 --- a/src/modules/posture-checks/table/cells/PostureCheckPolicyUsageCell.tsx +++ b/src/modules/posture-checks/table/cells/PostureCheckPolicyUsageCell.tsx @@ -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 (
- 0)} - content={ -
- - Assigned - {check.policies && check.policies?.length > 1 - ? " Policies" - : " Policy"} - -
- {check.policies && - check.policies?.length > 0 && - check.policies?.map((policy: Policy, index: number) => { - return ( - 0 && ( + + {policies?.map((policy: Policy) => { + const rule = policy?.rules?.[0]; + if (!rule) return; + return ( + + ); + })}
-
- } - interactive={false} - > - { - 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} > - - - - {check.policies && check.policies?.length > 0 - ? check.policies && check.policies?.length - : ""} - {" "} - {check.policies && check.policies?.length == 0 - ? "No Policies" - : check.policies && check.policies?.length > 1 - ? "Policies" - : "Policy"} - - -
+ { + e.preventDefault(); + e.stopPropagation(); + if (!tooltipOpen) setTooltipOpen(true); + }} + > + +
+ {policyCount} +
+
+ + )} @@ -93,8 +113,8 @@ export const PostureCheckPolicyUsageCell = ({ check }: Props) => { onClick={() => router.push("/access-control")} > <> - Go to Policies + diff --git a/src/modules/remote-access/rdp/RDPCredentialsModal.tsx b/src/modules/remote-access/rdp/RDPCredentialsModal.tsx index c201e658..ce20f2ec 100644 --- a/src/modules/remote-access/rdp/RDPCredentialsModal.tsx +++ b/src/modules/remote-access/rdp/RDPCredentialsModal.tsx @@ -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; diff --git a/src/modules/remote-access/ssh/SSHButton.tsx b/src/modules/remote-access/ssh/SSHButton.tsx index d2c198dd..b4196702 100644 --- a/src/modules/remote-access/ssh/SSHButton.tsx +++ b/src/modules/remote-access/ssh/SSHButton.tsx @@ -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) => {
diff --git a/src/modules/remote-access/ssh/SSHTooltip.tsx b/src/modules/remote-access/ssh/SSHTooltip.tsx index 93a18b94..108cc98d 100644 --- a/src/modules/remote-access/ssh/SSHTooltip.tsx +++ b/src/modules/remote-access/ssh/SSHTooltip.tsx @@ -78,7 +78,8 @@ const SSHDisabledText = ({
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.
{ { 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`, ); diff --git a/src/modules/route-group/NetworkRoutesTable.tsx b/src/modules/route-group/NetworkRoutesTable.tsx index 6088a758..d6f8ea39 100644 --- a/src/modules/route-group/NetworkRoutesTable.tsx +++ b/src/modules/route-group/NetworkRoutesTable.tsx @@ -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..."} diff --git a/src/modules/routes/RoutePeerCell.tsx b/src/modules/routes/RoutePeerCell.tsx index 82c9596f..3d473dc8 100644 --- a/src/modules/routes/RoutePeerCell.tsx +++ b/src/modules/routes/RoutePeerCell.tsx @@ -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 && ( <> - + - {group.peers_count} Peer(s) + )}
diff --git a/src/modules/setup-keys/SetupKeysTable.tsx b/src/modules/setup-keys/SetupKeysTable.tsx index 179e2f7c..ca615684 100644 --- a/src/modules/setup-keys/SetupKeysTable.tsx +++ b/src/modules/setup-keys/SetupKeysTable.tsx @@ -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"} diff --git a/src/modules/users/HorizontalUsersStack.tsx b/src/modules/users/HorizontalUsersStack.tsx index 90f5aaa2..f1dc5153 100644 --- a/src/modules/users/HorizontalUsersStack.tsx +++ b/src/modules/users/HorizontalUsersStack.tsx @@ -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 ( @@ -97,7 +105,7 @@ export const HorizontalUsersStack = ({ users.length > 0 && "group-hover/user-stack:text-nb-gray-200 ", )} > - {users?.length || 0} User(s) + {userCountText}
diff --git a/src/modules/users/UserPeersSection.tsx b/src/modules/users/UserPeersSection.tsx new file mode 100644 index 00000000..9252259b --- /dev/null +++ b/src/modules/users/UserPeersSection.tsx @@ -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(); + + const { data: peers, isLoading: isPeersLoading } = + useFetchApi("/peers"); + + const userPeers = useMemo(() => { + return ( + peers?.filter((peer) => { + return peer?.user_id === user.id; + }) || [] + ); + }, [user, peers]); + + return ( +
+
+
+
+

Peers

+ View all peers registered by this user. +
+
+ + + +
+ +
+
+ } + > + } + /> + } + /> + +
+
+ ); +}; diff --git a/src/utils/helpers.ts b/src/utils/helpers.ts index 58992b92..b212fa90 100644 --- a/src/utils/helpers.ts +++ b/src/utils/helpers.ts @@ -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) { diff --git a/src/utils/version.ts b/src/utils/version.ts index 16616e57..80ec7362 100644 --- a/src/utils/version.ts +++ b/src/utils/version.ts @@ -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.60.8+. + * @param version + */ +export const isNetbirdSSHProtocolSupported = (version: string) => { + if (version == "development") return true; + return compareVersions(version, "0.60.8"); +}; From 590405c8a77b0fedba949a4b2e7016ec38741288 Mon Sep 17 00:00:00 2001 From: Eduard Gert Date: Mon, 29 Dec 2025 13:45:26 +0100 Subject: [PATCH 2/3] Update version text --- src/modules/peer/PeerSSHInstructions.tsx | 2 +- src/modules/peer/PeerSSHPolicyInfo.tsx | 2 +- src/modules/peer/PeerSSHToggle.tsx | 4 ++-- src/modules/peers/PeerActionCell.tsx | 2 +- src/utils/version.ts | 4 ++-- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/modules/peer/PeerSSHInstructions.tsx b/src/modules/peer/PeerSSHInstructions.tsx index b9ddc90c..f33bd191 100644 --- a/src/modules/peer/PeerSSHInstructions.tsx +++ b/src/modules/peer/PeerSSHInstructions.tsx @@ -99,7 +99,7 @@ export const PeerSSHInstructions = ({

- Starting from NetBird v0.60.0, SSH requires an explicit access + Starting from NetBird v0.61.0, SSH requires an explicit access control policy to allow SSH connections to this machine.

diff --git a/src/modules/peer/PeerSSHPolicyInfo.tsx b/src/modules/peer/PeerSSHPolicyInfo.tsx index 6e0387a9..761866f4 100644 --- a/src/modules/peer/PeerSSHPolicyInfo.tsx +++ b/src/modules/peer/PeerSSHPolicyInfo.tsx @@ -20,7 +20,7 @@ export const PeerSSHPolicyInfo = ({ peer, className }: Props) => { <> - Starting from NetBird v0.60.0, SSH requires an explicit access + Starting from NetBird v0.61.0, SSH requires an explicit access control policy to allow SSH connections to this machine.{" "} setPolicyModal(true)}> Create SSH Policy diff --git a/src/modules/peer/PeerSSHToggle.tsx b/src/modules/peer/PeerSSHToggle.tsx index c6c4d708..2087ace7 100644 --- a/src/modules/peer/PeerSSHToggle.tsx +++ b/src/modules/peer/PeerSSHToggle.tsx @@ -78,7 +78,7 @@ export const PeerSSHToggle = () => { title: `Disable SSH Access?`, description: (
- Starting from NetBird v0.60.0, once SSH access is disabled, you cannot + Starting from NetBird v0.61.0, once SSH access is disabled, you cannot re-enable it again from the dashboard. You'll need to create an explicit access control policy and update your NetBird client to restore SSH functionality.{" "} @@ -193,7 +193,7 @@ export const PeerSSHToggle = () => { } className="my-3" > - Your SSH server is enabled, but starting from NetBird v0.60.0, SSH + 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. diff --git a/src/modules/peers/PeerActionCell.tsx b/src/modules/peers/PeerActionCell.tsx index 1271e90b..f9899d68 100644 --- a/src/modules/peers/PeerActionCell.tsx +++ b/src/modules/peers/PeerActionCell.tsx @@ -65,7 +65,7 @@ export default function PeerActionCell() { title: `Disable SSH Access?`, description: (
- Starting from NetBird v0.60.0, once SSH access is disabled, you cannot + Starting from NetBird v0.61.0, once SSH access is disabled, you cannot re-enable it again from the dashboard. You'll need to create an explicit access control policy and update your NetBird client to restore SSH functionality.{" "} diff --git a/src/utils/version.ts b/src/utils/version.ts index 80ec7362..3b38a45d 100644 --- a/src/utils/version.ts +++ b/src/utils/version.ts @@ -87,10 +87,10 @@ export const isNativeSSHSupported = (version: string) => { /** * Check if NetBird SSH protocol is supported. - * Supported starting from NetBird v0.60.8+. + * Supported starting from NetBird v0.61.0+. * @param version */ export const isNetbirdSSHProtocolSupported = (version: string) => { if (version == "development") return true; - return compareVersions(version, "0.60.8"); + return compareVersions(version, "0.61.0"); }; From ce1f8b46f0ce2d1d3968b3f7c0931dafd066a6f5 Mon Sep 17 00:00:00 2001 From: Eduard Gert Date: Mon, 29 Dec 2025 13:59:01 +0100 Subject: [PATCH 3/3] Fix coderabbit comment --- src/modules/groups/details/useGroupDetails.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/modules/groups/details/useGroupDetails.ts b/src/modules/groups/details/useGroupDetails.ts index d16ea66d..ad66e008 100644 --- a/src/modules/groups/details/useGroupDetails.ts +++ b/src/modules/groups/details/useGroupDetails.ts @@ -121,7 +121,8 @@ export default function useGroupDetails(groupId: string) { isSetupKeysLoading || isUsersLoading || isPeerLoading || - isLoadingResources; + isLoadingResources || + isNetworksLoading; const groupDetails = useMemo(() => { if (isLoading || !group) return null;