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 +148,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..f33bd191 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; @@ -100,9 +99,8 @@ export const PeerSSHInstructions = ({

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

diff --git a/src/modules/peer/PeerSSHPolicyInfo.tsx b/src/modules/peer/PeerSSHPolicyInfo.tsx index 38588edc..761866f4 100644 --- a/src/modules/peer/PeerSSHPolicyInfo.tsx +++ b/src/modules/peer/PeerSSHPolicyInfo.tsx @@ -20,8 +20,8 @@ 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.{" "} + 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/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.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.{" "} + 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.61.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..f9899d68 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.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.{" "} + 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..3b38a45d 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.61.0+. + * @param version + */ +export const isNetbirdSSHProtocolSupported = (version: string) => { + if (version == "development") return true; + return compareVersions(version, "0.61.0"); +};