From b20496edeb06a95cfc02f4bcd3cdc450ee7383bd Mon Sep 17 00:00:00 2001 From: Eduard Gert Date: Fri, 24 Jan 2025 14:02:03 +0100 Subject: [PATCH] Add option to select single resource in group selector --- src/components/DropdownInfoText.tsx | 8 +- src/components/PeerGroupSelector.tsx | 376 ++++++++++++------ src/components/Radio.tsx | 71 ++++ src/components/VirtualScrollAreaList.tsx | 18 +- src/components/ui/ResourceBadge.tsx | 58 +++ src/interfaces/Policy.ts | 7 + .../access-control/AccessControlModal.tsx | 11 +- .../access-control/useAccessControl.ts | 12 +- 8 files changed, 433 insertions(+), 128 deletions(-) create mode 100644 src/components/Radio.tsx create mode 100644 src/components/ui/ResourceBadge.tsx diff --git a/src/components/DropdownInfoText.tsx b/src/components/DropdownInfoText.tsx index a8d1879e..2ed7122f 100644 --- a/src/components/DropdownInfoText.tsx +++ b/src/components/DropdownInfoText.tsx @@ -1,11 +1,15 @@ +import { cn } from "@utils/helpers"; import * as React from "react"; type Props = { children: React.ReactNode; + className?: string; }; -export const DropdownInfoText = ({ children }: Props) => { +export const DropdownInfoText = ({ children, className }: Props) => { return ( -
{children}
+
+ {children} +
); }; diff --git a/src/components/PeerGroupSelector.tsx b/src/components/PeerGroupSelector.tsx index dfaf6a9b..efc32716 100644 --- a/src/components/PeerGroupSelector.tsx +++ b/src/components/PeerGroupSelector.tsx @@ -1,12 +1,16 @@ import Badge from "@components/Badge"; import { Checkbox } from "@components/Checkbox"; import { CommandItem } from "@components/Command"; +import { DropdownInfoText } from "@components/DropdownInfoText"; import FullTooltip from "@components/FullTooltip"; import { Popover, PopoverContent, PopoverTrigger } from "@components/Popover"; +import { Radio, RadioItem } from "@components/Radio"; import { ScrollArea } from "@components/ScrollArea"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@components/Tabs"; import { AccessControlGroupCount } from "@components/ui/AccessControlGroupCount"; import GroupBadge from "@components/ui/GroupBadge"; import GroupBadgeWithEditPeers from "@components/ui/GroupBadgeWithEditPeers"; +import ResourceBadge from "@components/ui/ResourceBadge"; import TextWithTooltip from "@components/ui/TextWithTooltip"; import { VirtualScrollAreaList } from "@components/VirtualScrollAreaList"; import { useSearch } from "@hooks/useSearch"; @@ -21,6 +25,7 @@ import { FolderGit2, GlobeIcon, Layers3, + Layers3Icon, MonitorSmartphoneIcon, NetworkIcon, SearchIcon, @@ -28,11 +33,13 @@ import { } from "lucide-react"; import * as React from "react"; import { Fragment, useEffect, useMemo, useState } from "react"; +import Skeleton from "react-loading-skeleton"; import { useGroups } from "@/contexts/GroupsProvider"; import { useElementSize } from "@/hooks/useElementSize"; import type { Group, GroupPeer, GroupResource } from "@/interfaces/Group"; import { NetworkResource } from "@/interfaces/Network"; import type { Peer } from "@/interfaces/Peer"; +import { PolicyRuleResource } from "@/interfaces/Policy"; interface MultiSelectProps { values: Group[]; @@ -49,6 +56,10 @@ interface MultiSelectProps { disabledGroups?: Group[]; dataCy?: string; showResourceCounter?: boolean; + showResources?: boolean; + resource?: PolicyRuleResource; + onResourceChange?: (resource?: PolicyRuleResource) => void; + placeholder?: string; } export function PeerGroupSelector({ onChange, @@ -65,12 +76,19 @@ export function PeerGroupSelector({ disabledGroups, dataCy = "group-selector-dropdown", showResourceCounter = true, + showResources = false, + resource, + onResourceChange, + placeholder = "Add or select group(s)...", }: Readonly) { const { groups, dropdownOptions, setDropdownOptions, addDropdownOptions } = useGroups(); const searchRef = React.useRef(null); const [inputRef, { width }] = useElementSize(); const [search, setSearch] = useState(""); + const { data: resources, isLoading } = useFetchApi( + "/networks/resources", + ); // Update dropdown options when groups change useEffect(() => { @@ -102,6 +120,7 @@ export function PeerGroupSelector({ // Add group to the groupOptions if it does not exist const selectGroup = (name: string) => { + onResourceChange?.(undefined); const group = groups?.find((group) => group.name == name); const option = dropdownOptions.find((option) => option.name == name); const groupPeers: GroupPeer[] | undefined = @@ -169,6 +188,8 @@ export function PeerGroupSelector({ const [slice, setSlice] = useState(10); + const [tab, setTab] = useState("groups"); + useEffect(() => { if (open) { setTimeout(() => { @@ -191,6 +212,31 @@ export function PeerGroupSelector({ open, ); + // Reset the search input when switching tabs + useEffect(() => { + setSearch(""); + setTimeout(() => { + searchRef.current?.focus(); + }, 0); + }, [tab]); + + const searchPlaceholder = + tab === "groups" + ? 'Search groups or add new group by pressing "Enter"...' + : "Search resource..."; + + const selectResource = (resource?: NetworkResource) => { + onResourceChange?.( + resource + ? ({ + id: resource?.id, + type: resource?.type, + } as PolicyRuleResource) + : undefined, + ); + onChange([]); + }; + return ( + {resource && showResources && ( + r.id === resource.id)} + onClick={(e) => { + e.preventDefault(); + e.stopPropagation(); + selectResource(); + }} + showX={true} + /> + )} {values.map((group) => { return (
Add or select group(s)... + {values.length == 0 && !resource && ( + {placeholder} )}
@@ -277,7 +335,7 @@ export function PeerGroupSelector({
- - - {searchedGroupNotFound && ( - { - toggleGroupByName(search); - searchRef.current?.focus(); - }} - value={search} - onClick={(e) => e.preventDefault()} + + {showResources && } + + + - - {folderIcon} - {search} - -
- Add this group by pressing{" "} - - {"'Enter'"} - -
-
- )} - - {sortedDropdownOptions.slice(0, slice).map((option) => { - const isSelected = - values.find((group) => group.name == option.name) != - undefined; - const peerCount = - option.peers?.length ?? option?.peers_count ?? 0; - - const isDisabled = disabledGroups - ? disabledGroups?.findIndex((g) => g.id === option.id) !== - -1 - : false; - - return ( - - This group is already part of the routing peer and can - not be used for the access control groups. - - } - disabled={!isDisabled} - className={"w-full block"} - key={option.name} - > + {searchedGroupNotFound && ( { - if (peer != undefined && option.name == "All") return; // Prevent removing the "All" group - if (isDisabled) return; - toggleGroupByName(option.name); + toggleGroupByName(search); searchRef.current?.focus(); }} - className={cn(isDisabled && "opacity-40")} + value={search} onClick={(e) => e.preventDefault()} > -
- -
- -
- {option?.id && showRoutes && ( - - )} - - {showResourceCounter && ( - - )} - -
- {peerIcon} - {peerCount} Peer(s) - -
+ + {folderIcon} + {search} + +
+ Add this group by pressing{" "} + + {"'Enter'"} +
- - ); - })} - - + )} + + {sortedDropdownOptions.slice(0, slice).map((option) => { + const isSelected = + values.find((group) => group.name == option.name) != + undefined; + const peerCount = + option.peers?.length ?? option?.peers_count ?? 0; + + const isDisabled = disabledGroups + ? disabledGroups?.findIndex( + (g) => g.id === option.id, + ) !== -1 + : false; + + return ( + + This group is already part of the routing peer and + can not be used for the access control groups. +
+ } + disabled={!isDisabled} + className={"w-full block"} + key={option.name} + > + { + if (peer != undefined && option.name == "All") + return; // Prevent removing the "All" group + if (isDisabled) return; + toggleGroupByName(option.name); + searchRef.current?.focus(); + }} + className={cn(isDisabled && "opacity-40")} + onClick={(e) => e.preventDefault()} + > +
+ +
+ +
+ {option?.id && showRoutes && ( + + )} + + {showResourceCounter && ( + + )} + +
+ {peerIcon} + {peerCount} Peer(s) + +
+
+
+
+ ); + })} +
+
+ + {showResources && ( + + + + )} +
@@ -439,6 +515,43 @@ export function PeerGroupSelector({ ); } +const TabTriggers = ({ + searchRef, +}: { + searchRef: React.MutableRefObject; +}) => { + return ( + + searchRef.current?.focus()} + > + + Groups + + searchRef.current?.focus()} + > + + Resource + + + ); +}; + const ResourcesCounter = ({ group }: { group: Group }) => { return group?.resources_count && group.resources_count > 0 ? (
{ return item.address.toLowerCase().includes(lowerCaseQuery); }; -const ResourcesList = ({ search }: { search: string }) => { - const { data: resources, isLoading } = useFetchApi( - "/networks/resources", - ); - +const ResourcesList = ({ + search, + resources, + isLoading, + value, + onChange, +}: { + search: string; + resources?: NetworkResource[]; + isLoading: boolean; + value?: PolicyRuleResource; + onChange: (resource: NetworkResource) => void; +}) => { const [filteredItems, _, setSearch] = useSearch( resources || [], resourcesSearchPredicate, @@ -473,16 +594,41 @@ const ResourcesList = ({ search }: { search: string }) => { setSearch(search); }, [search, setSearch]); - return isLoading ? ( - <>Loading... - ) : ( - filteredItems.length > 0 && ( + if (isLoading) { + return ( +
+ + + + +
+ ); + } + + if (search != "" && filteredItems.length == 0) { + return ( + + There are no resources matching your search. Please try a different + search term. + + ); + } + + if (resources && resources.length == 0) { + return ( + + There are no resources available. + + ); + } + + return ( + null} + onSelect={onChange} + itemClassName={"dark:aria-selected:bg-nb-gray-800/20"} renderItem={(res) => { - const isSelected = false; - return (
@@ -496,22 +642,13 @@ const ResourcesList = ({ search }: { search: string }) => { }} > {res.type === "host" && ( - + )} {res.type === "domain" && ( - + )} {res.type === "subnet" && ( - + )} @@ -524,13 +661,14 @@ const ResourcesList = ({ search }: { search: string }) => { "text-neutral-500 dark:text-nb-gray-300 font-medium flex items-center gap-2" } > - + {res.address} +
); }} /> - ) + ); }; diff --git a/src/components/Radio.tsx b/src/components/Radio.tsx new file mode 100644 index 00000000..982b13d1 --- /dev/null +++ b/src/components/Radio.tsx @@ -0,0 +1,71 @@ +import * as RadioPrimitive from "@radix-ui/react-radio-group"; +import { cn } from "@utils/helpers"; +import { cva, VariantProps } from "class-variance-authority"; +import * as React from "react"; +import { forwardRef } from "react"; + +type RadioVariants = VariantProps; + +const variants = cva([], { + variants: { + variant: { + default: [ + "dark:data-[state=unchecked]:bg-nb-gray-950 dark:border-nb-gray-900 dark:ring-offset-neutral-950 dark:focus-visible:ring-neutral-300", + "dark:data-[state=checked]:bg-netbird", + ], + }, + }, +}); + +const Radio = forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & RadioVariants +>( + ( + { className, children, variant = "default", defaultValue, ...props }, + ref, + ) => ( + + {children} + + ), +); +Radio.displayName = RadioPrimitive.Root.displayName; + +type Props = { + value: string; + className?: string; +} & RadioVariants; + +const RadioItem = ({ value, className, variant = "default" }: Props) => { + return ( + + +
+
+
+ ); +}; +RadioItem.displayName = RadioPrimitive.Item.displayName; + +export { Radio, RadioItem }; diff --git a/src/components/VirtualScrollAreaList.tsx b/src/components/VirtualScrollAreaList.tsx index 47f36bf8..c46fa80b 100644 --- a/src/components/VirtualScrollAreaList.tsx +++ b/src/components/VirtualScrollAreaList.tsx @@ -11,13 +11,15 @@ type Props = { items: T[]; onSelect: (item: T) => void; renderItem?: (item: T) => React.ReactNode; + itemClassName?: string; }; export function VirtualScrollAreaList({ items, onSelect, renderItem, -}: Props) { + itemClassName, +}: Readonly>) { const virtuosoRef = useRef(null); const [selected, setSelected] = useState(0); @@ -81,8 +83,9 @@ export function VirtualScrollAreaList({ setSelected(index)} id={option.id} - onClick={() => onClick(option as T)} + onClick={() => onClick(option)} ariaSelected={selected === index} + className={itemClassName} > {renderMemoizedItem ? renderMemoizedItem(option) : option.id} @@ -103,10 +106,18 @@ type ItemWrapperProps = { onMouseEnter?: () => void; onClick?: () => void; ariaSelected?: boolean; + className?: string; }; export const VirtualScrollListItemWrapper = memo( - ({ id, children, onClick, onMouseEnter, ariaSelected }: ItemWrapperProps) => { + ({ + id, + children, + onClick, + onMouseEnter, + ariaSelected, + className, + }: ItemWrapperProps) => { return (
) => void; + showX?: boolean; + children?: React.ReactNode; + className?: string; +}; +export default function ResourceBadge({ + onClick, + resource, + showX = false, + children, + className, +}: Readonly) { + if (!resource) return; + + return ( + { + e.preventDefault(); + onClick?.(e); + }} + > + {resource.type === "host" && ( + + )} + {resource.type === "domain" && ( + + )} + {resource.type === "subnet" && ( + + )} + + + {children} + {showX && ( + + )} + + ); +} diff --git a/src/interfaces/Policy.ts b/src/interfaces/Policy.ts index 91383752..c0136207 100644 --- a/src/interfaces/Policy.ts +++ b/src/interfaces/Policy.ts @@ -22,6 +22,13 @@ export interface PolicyRule { action: string; protocol: Protocol; ports: string[]; + sourceResource?: PolicyRuleResource; + destinationResource?: PolicyRuleResource; +} + +export interface PolicyRuleResource { + id: string; + type: "domain" | "host" | "subnet" | undefined; } export type Protocol = "all" | "tcp" | "udp" | "icmp"; diff --git a/src/modules/access-control/AccessControlModal.tsx b/src/modules/access-control/AccessControlModal.tsx index 246b12cc..b86efc59 100644 --- a/src/modules/access-control/AccessControlModal.tsx +++ b/src/modules/access-control/AccessControlModal.tsx @@ -149,6 +149,8 @@ export function AccessControlModalContent({ submit, isPostureChecksLoading, getPolicyData, + destinationResource, + setDestinationResource, } = useAccessControl({ policy, postureCheckTemplates, @@ -166,9 +168,10 @@ export function AccessControlModalContent({ }); const continuePostureChecksDisabled = useMemo(() => { - if (sourceGroups.length == 0 || destinationGroups.length == 0) return true; if (direction != "bi" && ports.length == 0) return true; - }, [sourceGroups, destinationGroups, direction, ports]); + if (sourceGroups.length > 0 && destinationResource) return false; + if (sourceGroups.length == 0 || destinationGroups.length == 0) return true; + }, [sourceGroups, destinationGroups, direction, ports, destinationResource]); const submitDisabled = useMemo(() => { if (name.length == 0) return true; @@ -304,6 +307,10 @@ export function AccessControlModalContent({ onChange={setDestinationGroups} values={destinationGroups} saveGroupAssignments={useSave} + resource={destinationResource} + onResourceChange={setDestinationResource} + showResources={true} + placeholder={"Select destination(s)..."} />
diff --git a/src/modules/access-control/useAccessControl.ts b/src/modules/access-control/useAccessControl.ts index 0dd641c3..0505e957 100644 --- a/src/modules/access-control/useAccessControl.ts +++ b/src/modules/access-control/useAccessControl.ts @@ -117,6 +117,10 @@ export const useAccessControl = ({ : initialDestinationGroups ?? [], }); + const [destinationResource, setDestinationResource] = useState( + firstRule?.destinationResource, + ); + const { updateOrCreateAndNotify: checkToCreate } = usePostureCheck({}); const createPostureChecksWithoutID = async () => { const checks = postureChecks.filter( @@ -146,7 +150,8 @@ export const useAccessControl = ({ description, name, sources: sources, - destinations: destinations, + destinations: destinationResource ? undefined : destinations, + destinationResource: destinationResource || undefined, action: "accept", protocol, enabled, @@ -214,7 +219,8 @@ export const useAccessControl = ({ protocol, enabled, sources, - destinations, + destinations: destinationResource ? undefined : destinations, + destinationResource: destinationResource || undefined, ports: ports.length > 0 ? ports.map((p) => p.toString()) : undefined, }, ], @@ -268,5 +274,7 @@ export const useAccessControl = ({ getPolicyData, portAndDirectionDisabled, isPostureChecksLoading, + destinationResource, + setDestinationResource, } as const; };