diff --git a/src/app/(dashboard)/peers/page.tsx b/src/app/(dashboard)/peers/page.tsx index becc3065..cf718630 100644 --- a/src/app/(dashboard)/peers/page.tsx +++ b/src/app/(dashboard)/peers/page.tsx @@ -105,7 +105,7 @@ function PeersBlockedView() {
diff --git a/src/app/globals.css b/src/app/globals.css index 93ba9f49..f0410bed 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -67,7 +67,7 @@ p { } .stepper-bg-variant .step-circle { - @apply !border-[#1d2024]; + @apply !border-nb-gray-940; } .webkit-scroll{ @@ -117,4 +117,43 @@ p { @keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } -} \ No newline at end of file +} + + +.animate-slow-ping { + animation: ping 1.6s cubic-bezier(0, 0, 0.2, 1) infinite +} + +@keyframes ping { + 75%, 100% { + transform: scale(2); + opacity: 0; + } +} + +.animate-slow-pulse { + animation: pulse 2.5s cubic-bezier(0.4, 0, 0.6, 1) infinite +} + + +@keyframes pulse { + 60% { + opacity: 0.5; + } +} + +@keyframes bg-scroll { + 0% { + background-position: 0% 100%; + } + 100% { + background-position: 0% 0%; + } +} + +.animate-bg-scroll { + animation: bg-scroll 4s linear infinite; +} +.animate-bg-scroll-faster { + animation: bg-scroll 1.8s linear infinite; +} diff --git a/src/assets/nameservers/dns0-zero.svg b/src/assets/nameservers/dns0-zero.svg new file mode 100644 index 00000000..d8b0f470 --- /dev/null +++ b/src/assets/nameservers/dns0-zero.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/src/assets/nameservers/dns0.svg b/src/assets/nameservers/dns0.svg new file mode 100644 index 00000000..cccac1bf --- /dev/null +++ b/src/assets/nameservers/dns0.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/src/components/Badge.tsx b/src/components/Badge.tsx index 00e88867..917d8702 100644 --- a/src/components/Badge.tsx +++ b/src/components/Badge.tsx @@ -61,9 +61,9 @@ export default function Badge({
diff --git a/src/components/Button.tsx b/src/components/Button.tsx index e8f9483a..9e4b9127 100644 --- a/src/components/Button.tsx +++ b/src/components/Button.tsx @@ -4,7 +4,7 @@ import { cva, VariantProps } from "class-variance-authority"; import classNames from "classnames"; import React, { forwardRef } from "react"; -type ButtonVariants = VariantProps; +export type ButtonVariants = VariantProps; export interface ButtonProps extends React.ButtonHTMLAttributes, @@ -28,7 +28,7 @@ export const buttonVariants = cva( "dark:focus:ring-zinc-800/50 dark:bg-nb-gray dark:text-gray-400 dark:border-gray-700/30 dark:hover:text-white dark:hover:bg-zinc-800/50", ], primary: [ - "dark:focus:ring-netbird-600/50 dark:ring-offset-neutral-950/50 enabled:dark:bg-netbird disabled:dark:bg-nb-gray-920 dark:text-gray-100 enabled:dark:hover:text-white enabled:dark:hover:bg-netbird-500/80", + "dark:focus:ring-netbird-600/50 dark:ring-offset-neutral-950/50 enabled:dark:bg-netbird disabled:dark:bg-nb-gray-910 dark:text-gray-100 enabled:dark:hover:text-white enabled:dark:hover:bg-netbird-500/80", "enabled:bg-netbird enabled:text-white enabled:focus:ring-netbird-400/50 enabled:hover:bg-netbird-500", ], secondary: [ @@ -49,7 +49,7 @@ export const buttonVariants = cva( dropdown: [ "bg-white hover:text-black focus:ring-zinc-200/50 hover:bg-gray-100 border-neutral-200 text-gray-900", "dark:ring-offset-neutral-950/50 dark:focus:ring-neutral-500/20 ", - "dark:bg-nb-gray-900/40 dark:text-gray-400 dark:border-nb-gray-800 dark:hover:bg-nb-gray-900/50", + "dark:bg-nb-gray-900/40 dark:text-gray-400 dark:border-nb-gray-900 dark:hover:bg-nb-gray-900/50", ], dotted: [ "bg-white hover:text-black focus:ring-zinc-200/50 hover:bg-gray-100 border-gray-200 text-gray-900 border-dashed", diff --git a/src/components/DatePickerWithRange.tsx b/src/components/DatePickerWithRange.tsx index dbd4e9e5..bdaaf894 100644 --- a/src/components/DatePickerWithRange.tsx +++ b/src/components/DatePickerWithRange.tsx @@ -34,6 +34,10 @@ const defaultRanges = { from: dayjs().subtract(2, "day").startOf("day").toDate(), to: dayjs().endOf("day").toDate(), }, + last7Days: { + from: dayjs().subtract(7, "day").startOf("day").toDate(), + to: dayjs().endOf("day").toDate(), + }, lastMonth: { from: dayjs().subtract(1, "month").startOf("day").toDate(), to: dayjs().endOf("day").toDate(), @@ -64,6 +68,7 @@ export function DatePickerWithRange({ yesterday: isEqualDateRange(value, defaultRanges.yesterday), last14Days: isEqualDateRange(value, defaultRanges.last14Days), last2Days: isEqualDateRange(value, defaultRanges.last2Days), + last7Days: isEqualDateRange(value, defaultRanges.last7Days), lastMonth: isEqualDateRange(value, defaultRanges.lastMonth), allTime: isEqualDateRange(value, defaultRanges.allTime), }; @@ -76,6 +81,7 @@ export function DatePickerWithRange({ if (isActive.lastMonth) return "Last Month"; if (isActive.last14Days) return "Last 14 Days"; if (isActive.last2Days) return "Last 2 Days"; + if (isActive.last7Days) return "Last 7 Days"; if (isActive.yesterday) return "Yesterday"; if (isActive.today) return "Today"; @@ -88,12 +94,11 @@ export function DatePickerWithRange({ const [calendarOpen, setCalendarOpen] = useState(false); const updateRangeAndClose = (range: DateRange) => { - setCalendarOpen(false); onChange?.(range); }; const debouncedOnChange = useMemo(() => { - return onChange ? debounce(onChange, 300) : undefined; + return onChange ? debounce(onChange, 500) : undefined; }, [onChange]); const handleOnSelect = (range?: DateRange) => { diff --git a/src/components/FullTooltip.tsx b/src/components/FullTooltip.tsx index 1a898e06..1f60a52f 100644 --- a/src/components/FullTooltip.tsx +++ b/src/components/FullTooltip.tsx @@ -3,6 +3,7 @@ import { TooltipContent, TooltipProvider, TooltipTrigger, + TooltipVariants, } from "@components/Tooltip"; import { TooltipProps } from "@radix-ui/react-tooltip"; import { cn } from "@utils/helpers"; @@ -24,7 +25,9 @@ type Props = { customOnOpenChange?: React.Dispatch>; delayDuration?: number; skipDelayDuration?: number; -} & TooltipProps; +} & TooltipProps & + TooltipVariants; + export default function FullTooltip({ children, content, @@ -41,6 +44,7 @@ export default function FullTooltip({ customOnOpenChange, delayDuration = 1, skipDelayDuration = 300, + variant = "default", }: Props) { const [open, setOpen] = useState(!!keepOpen); @@ -66,7 +70,7 @@ export default function FullTooltip({
@@ -82,6 +86,7 @@ export default function FullTooltip({ alignOffset={20} forceMount={true} className={contentClassName} + variant={variant} align={align} side={side} > diff --git a/src/components/Kbd.tsx b/src/components/Kbd.tsx index e45213c1..9a263ed9 100644 --- a/src/components/Kbd.tsx +++ b/src/components/Kbd.tsx @@ -12,6 +12,7 @@ const variants = cva("", { variants: { variant: { default: ["bg-nb-gray-800 border-nb-gray-700 text-nb-gray-300 "], + darker: ["bg-nb-gray-930 border-nb-gray-900 text-nb-gray-250 "], netbird: ["bg-netbird-100 text-netbird border-netbird "], }, size: { @@ -30,7 +31,7 @@ export default function Kbd({ size = "default", disabled = false, className, -}: Props) { +}: Readonly) { return (
{ + return ( + <> + {"NetBird + {mobile && ( + {"NetBird + )} + + ); +}; diff --git a/src/components/PeerSelector.tsx b/src/components/PeerSelector.tsx index caf362a9..e571b7bf 100644 --- a/src/components/PeerSelector.tsx +++ b/src/components/PeerSelector.tsx @@ -172,6 +172,7 @@ export function PeerSelector({ {filteredItems.length > 0 && ( { const isSupported = isRoutingPeerSupported( item.version, diff --git a/src/components/RadioCard.tsx b/src/components/RadioCard.tsx new file mode 100644 index 00000000..725c21e9 --- /dev/null +++ b/src/components/RadioCard.tsx @@ -0,0 +1,67 @@ +import * as RadioGroup from "@radix-ui/react-radio-group"; +import { ReactNode } from "react"; // or replace with clsx or similar +import { cn } from "@/utils/helpers"; + +type Props = { + value: string; + title: ReactNode; + description: ReactNode; + icon?: ReactNode; + className?: string; +}; + +export const RadioCard = ({ + value, + title, + description, + className, + icon, +}: Props) => { + return ( + +
+ {icon} + {title} +
+
+ {description} +
+
+ ); +}; + +type RadioCardGroupProps = { + value: string; + onValueChange: (val: string) => void; + children: React.ReactNode; + className?: string; + "aria-label"?: string; +}; + +export const RadioCardGroup = ({ + value, + onValueChange, + children, + className, + "aria-label": ariaLabel = "Options", +}: RadioCardGroupProps) => { + return ( + + {children} + + ); +}; diff --git a/src/components/Steps.tsx b/src/components/Steps.tsx index 32d2fab2..5d18afef 100644 --- a/src/components/Steps.tsx +++ b/src/components/Steps.tsx @@ -24,6 +24,8 @@ type StepProps = { line?: boolean; center?: boolean; horizontal?: boolean; + disabled?: boolean; + className?: string; }; const Step = ({ @@ -32,6 +34,8 @@ const Step = ({ line = true, center = false, horizontal, + disabled = false, + className, }: StepProps) => { return (
{line && ( @@ -57,6 +63,7 @@ const Step = ({ "h-[34px] w-[34px] shrink-0 rounded-full flex items-center justify-center font-medium text-xs relative z-0 border-4 transition-all", "dark:bg-nb-gray-900 dark:text-nb-gray-400 dark:border-nb-gray dark:group-hover:bg-nb-gray-800", "bg-nb-gray-100 text-nb-gray-400 border-white group-hover:bg-nb-gray-200 step-circle", + "[.stepper-bg-variant]:border-nb-gray-940", )} > {step} diff --git a/src/components/Tooltip.tsx b/src/components/Tooltip.tsx index a13906ca..6f1a589a 100644 --- a/src/components/Tooltip.tsx +++ b/src/components/Tooltip.tsx @@ -2,6 +2,7 @@ import * as TooltipPrimitive from "@radix-ui/react-tooltip"; import { cn } from "@utils/helpers"; +import { cva, VariantProps } from "class-variance-authority"; import * as React from "react"; const TooltipProvider = TooltipPrimitive.Provider; @@ -10,25 +11,58 @@ const Tooltip = TooltipPrimitive.Root; const TooltipTrigger = TooltipPrimitive.Trigger; -export const tooltipClasses = - "z-[9999] overflow-hidden rounded-md border border-neutral-200 bg-white text-sm text-neutral-950 shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 dark:border-nb-gray-930 dark:bg-nb-gray-940 dark:text-neutral-50"; +export type TooltipVariants = VariantProps; + +export const tooltipVariants = cva( + [ + "z-[9999] overflow-hidden rounded-md border text-sm shadow-md", + "animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", + ], + { + 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", + ], + lighter: [ + "bg-white dark:bg-nb-gray-920", + "text-neutral-950 dark:text-neutral-50", + "border-neutral-200 dark:border-nb-gray-900", + ], + }, + }, + }, +); const TooltipContent = React.forwardRef< React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className = "px-4 py-2.5", sideOffset = 7, ...props }, ref) => ( - - -
{props.children}
-
-
-)); + React.ComponentPropsWithoutRef & + TooltipVariants +>( + ( + { + className = "px-4 py-2.5", + sideOffset = 7, + variant = "default", + ...props + }, + ref, + ) => ( + + +
{props.children}
+
+
+ ), +); TooltipContent.displayName = TooltipPrimitive.Content.displayName; export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger }; diff --git a/src/components/VirtualScrollAreaList.tsx b/src/components/VirtualScrollAreaList.tsx index 64fc7226..cd4a985d 100644 --- a/src/components/VirtualScrollAreaList.tsx +++ b/src/components/VirtualScrollAreaList.tsx @@ -11,12 +11,16 @@ type Props = { items: T[]; onSelect: (item: T) => void; renderItem?: (item: T, selected?: boolean) => React.ReactNode; + renderHeading?: (item: T) => React.ReactNode; renderBeforeItem?: (item: T) => React.ReactNode; itemClassName?: string; itemWrapperClassName?: string; scrollAreaClassName?: string; maxHeight?: number; estimatedItemHeight?: number; + estimatedHeadingHeight?: number; + heightAdjustment?: number; + groupKey?: (item: T) => string | undefined; }; export function VirtualScrollAreaList({ @@ -24,13 +28,20 @@ export function VirtualScrollAreaList({ onSelect, renderItem, renderBeforeItem, + renderHeading, itemClassName, itemWrapperClassName, scrollAreaClassName, maxHeight, estimatedItemHeight = 36, + estimatedHeadingHeight = 16, + heightAdjustment = 8, + groupKey, }: Readonly>) { const virtuosoRef = useRef(null); + const [lastInputMethod, setLastInputMethod] = useState<"mouse" | "keyboard">( + "mouse", + ); const [selected, setSelected] = useState(0); useEffect(() => { @@ -47,6 +58,7 @@ export function VirtualScrollAreaList({ const navigation = useCallback( (e: KeyboardEvent) => { + setLastInputMethod("keyboard"); if (items.length === 0) return; const length = items.length - 1; if (e.code === "ArrowUp" || (e.key === "Tab" && e.shiftKey)) { @@ -69,20 +81,54 @@ export function VirtualScrollAreaList({ ); useEffect(() => { + const handleMouse = () => setLastInputMethod("mouse"); + window.addEventListener("keydown", navigation); + window.addEventListener("mousemove", handleMouse); return () => { window.removeEventListener("keydown", navigation); + window.removeEventListener("mousemove", handleMouse); }; }, [navigation]); + const headingCount = useMemo(() => { + if (!groupKey) return 0; + + let count = 0; + let prev: string | undefined; + + for (const item of items) { + const key = groupKey(item); + if (key !== prev) { + count++; + prev = key; + } + } + + return count; + }, [items, groupKey]); + const renderMemoizedItem = useMemo(() => renderItem, [renderItem]); const scrollAreaHeight = { maxHeight: maxHeight ?? 195 }; const virtuosoHeight = { - height: Math.min(items.length * estimatedItemHeight + 8, maxHeight ?? 195), + height: Math.min( + items.length * estimatedItemHeight + + headingCount * estimatedHeadingHeight + + +(8 + heightAdjustment), + maxHeight ?? 195, + ), }; + const fixedItemHeight = useMemo(() => { + if (!groupKey) return estimatedItemHeight; + if (items.length === 0) return 0; + const h = virtuosoHeight.height / items.length; + if (isNaN(h)) return estimatedItemHeight; + return h; + }, [estimatedItemHeight, groupKey, items.length, virtuosoHeight.height]); + return ( ({ ref={virtuosoRef} overscan={50} data={items} + defaultItemHeight={fixedItemHeight} totalCount={items.length} - fixedItemHeight={estimatedItemHeight} computeItemKey={(index) => items[index].id as string} context={{ selected, setSelected, onClick: onSelect }} itemContent={(index, option, { selected, setSelected, onClick }) => { + const group = groupKey?.(option); + const prevGroup = + index > 0 ? groupKey?.(items[index - 1]) : undefined; + const showHeading = group && group !== prevGroup; + return (
+ {showHeading && renderHeading?.(option)} {renderBeforeItem?.(option)} setSelected(index)} + onMouseEnter={() => { + if (lastInputMethod === "mouse") { + setSelected(index); + } + }} id={option.id} onClick={() => onClick(option)} ariaSelected={selected === index} @@ -151,17 +207,13 @@ export const VirtualScrollListItemWrapper = memo( return (
) { const [inputRef, { width }] = useElementSize(); const toggle = (selectedValue: string) => { const isSelected = value == selectedValue; - if (isSelected) { - } else { - onChange && onChange(selectedValue); - } + if (!isSelected) onChange?.(selectedValue); setTimeout(() => { setSearch(""); }, 100); @@ -66,18 +65,6 @@ export function SelectDropdown({ const [open, setOpen] = useState(false); - const [slice, setSlice] = useState(10); - - useEffect(() => { - if (open) { - setTimeout(() => { - setSlice(options.length); - }, 100); - } else { - setSlice(10); - } - }, [open, options]); - const selected = options.find((o) => o.value === value); const searchRef = React.useRef(null); @@ -96,7 +83,6 @@ export function SelectDropdown({ { - setSlice(10); if (!isOpen) { setTimeout(() => { setSearch(""); @@ -107,7 +93,7 @@ export function SelectDropdown({ > - + + - { - if (hovered) event.preventDefault(); - }} - > - - Reset Filters & Search - - - - ) : null; + { + if (hovered) event.preventDefault(); + }} + > + + Reset Filters & Search + + + + ) + ); } diff --git a/src/components/ui/GradientFadedBackground.tsx b/src/components/ui/GradientFadedBackground.tsx index 283615e0..deb02b5e 100644 --- a/src/components/ui/GradientFadedBackground.tsx +++ b/src/components/ui/GradientFadedBackground.tsx @@ -1,11 +1,17 @@ +import { cn } from "@utils/helpers"; import * as React from "react"; -export const GradientFadedBackground = () => { +type Props = { + className?: string; +}; + +export const GradientFadedBackground = ({ className }: Props) => { return (
void; error?: string; disabled?: boolean; + showRemoveButton?: boolean; preventLeadingAndTrailingDots?: boolean; allowWildcard?: boolean; }; @@ -44,6 +45,7 @@ export default function InputDomain({ disabled, preventLeadingAndTrailingDots, allowWildcard = true, + showRemoveButton = true, }: Readonly) { const [name, setName] = useState(value?.name || ""); @@ -88,14 +90,16 @@ export default function InputDomain({ />
- + {showRemoveButton && ( + + )}
); } diff --git a/src/components/ui/MultipleGroups.tsx b/src/components/ui/MultipleGroups.tsx index 97dfe462..5e340ca0 100644 --- a/src/components/ui/MultipleGroups.tsx +++ b/src/components/ui/MultipleGroups.tsx @@ -9,9 +9,9 @@ import { import GroupBadge from "@components/ui/GroupBadge"; import PeerBadge from "@components/ui/PeerBadge"; import { cn } from "@utils/helpers"; -import { orderBy } from "lodash"; -import { ArrowRightIcon } from "lucide-react"; +import { ArrowRightIcon, PencilLineIcon } from "lucide-react"; import * as React from "react"; +import { usePermissions } from "@/contexts/PermissionsProvider"; import { Group } from "@/interfaces/Group"; import EmptyRow from "@/modules/common-table-rows/EmptyRow"; @@ -30,8 +30,17 @@ export default function MultipleGroups({ onClick, className, }: Readonly) { - if (!groups) return ; - const orderedGroups = orderBy(groups, ["peers_count", "name"], ["desc"]); + const { permission } = usePermissions(); + + if (!groups || groups?.length === 0) return ; + const orderedGroups = groups.sort((a, b) => { + if (a.name === "All") return 1; + if (b.name === "All") return -1; + const aPeerCount = a.peers_count ?? 0; + const bPeerCount = b.peers_count ?? 0; + if (aPeerCount !== bPeerCount) return bPeerCount - aPeerCount; + return a.name.localeCompare(b.name); + }); const firstGroup = orderedGroups.length > 0 ? orderedGroups[0] : undefined; const otherGroups = orderedGroups.length > 0 ? orderedGroups.slice(1) : []; @@ -48,12 +57,22 @@ export default function MultipleGroups({ data-cy={"multiple-groups"} onClick={onClick} > - {firstGroup && } + {firstGroup && ( + + )} {otherGroups && otherGroups.length > 0 && ( + {otherGroups.length} @@ -98,3 +117,15 @@ export default function MultipleGroups({ ); } + +export const TransparentEditIconButton = () => { + return ( +
+ +
+ ); +}; diff --git a/src/components/ui/NoResults.tsx b/src/components/ui/NoResults.tsx index ab6d1349..39466506 100644 --- a/src/components/ui/NoResults.tsx +++ b/src/components/ui/NoResults.tsx @@ -1,7 +1,9 @@ +import Button from "@components/Button"; import Paragraph from "@components/Paragraph"; import { cn } from "@utils/helpers"; import { FilterX } from "lucide-react"; -import React from "react"; +import { usePathname, useRouter, useSearchParams } from "next/navigation"; +import React, { useCallback } from "react"; import Skeleton from "react-loading-skeleton"; type Props = { @@ -10,6 +12,8 @@ type Props = { description?: string; children?: React.ReactNode; className?: string; + hasFiltersApplied?: boolean; + onResetFilters?: () => void; }; export default function NoResults({ icon, @@ -17,7 +21,32 @@ export default function NoResults({ description = "We couldn't find any results. Please try a different search term or change your filters.", children, className, + hasFiltersApplied = false, + onResetFilters, }: Props) { + const router = useRouter(); + const pathname = usePathname(); + const searchParams = useSearchParams(); + + const handleResetClick = useCallback(() => { + if (onResetFilters) { + onResetFilters(); + + const params = new URLSearchParams(); + + const page_size = searchParams.get("page_size"); + + params.set("page", "1"); + + if (page_size) { + params.set("page_size", page_size); + } + + const newUrl = `${pathname}?${params.toString()}`; + router.push(newUrl); + } + }, [onResetFilters, router, pathname, searchParams]); + return (
{description} + {hasFiltersApplied && onResetFilters && ( + + )} {children}
diff --git a/src/components/ui/PolicyDirection.tsx b/src/components/ui/PolicyDirection.tsx index f19c1722..145184a2 100644 --- a/src/components/ui/PolicyDirection.tsx +++ b/src/components/ui/PolicyDirection.tsx @@ -1,13 +1,15 @@ import Badge from "@components/Badge"; import { cn } from "@utils/helpers"; -import React, { useEffect } from "react"; +import React, { useEffect, useMemo } from "react"; import LongArrowLeftIcon from "@/assets/icons/LongArrowLeftIcon"; +import { PolicyRuleResource } from "@/interfaces/Policy"; type Props = { disabled?: boolean; value: Direction; onChange: (value: Direction) => void; className?: string; + destinationResource?: PolicyRuleResource; }; export type Direction = "bi" | "in" | "out"; @@ -17,31 +19,8 @@ export default function PolicyDirection({ value, onChange, className, -}: Props) { - const toggleIn = () => { - if (value == "in") { - onChange("out"); - return; - } - if (value == "bi") { - onChange("out"); - } else { - onChange("bi"); - } - }; - - const toggleOut = () => { - if (value == "out") { - onChange("in"); - return; - } - if (value == "bi") { - onChange("in"); - } else { - onChange("bi"); - } - }; - + destinationResource, +}: Readonly) { const toggleDirection = () => { if (value == "bi") { onChange("in"); @@ -55,8 +34,34 @@ export default function PolicyDirection({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [disabled]); + const topBadgeClass = useMemo(() => { + if (destinationResource) return "blueDark"; + if (value === "bi") return "green"; + if (value === "in") return "blueDark"; + return "gray"; + }, [value, destinationResource]); + + const topArrowClass = useMemo(() => { + if (destinationResource) return "fill-sky-500"; + if (value === "bi") return "fill-green-500"; + if (value === "in") return "fill-sky-500"; + return "fill-gray-500"; + }, [value, destinationResource]); + + const bottomBadgeClass = useMemo(() => { + if (destinationResource) return "gray"; + if (value === "bi") return "green"; + return "gray"; + }, [value, destinationResource]); + + const bottomArrowClass = useMemo(() => { + if (destinationResource) return "fill-gray-500"; + if (value === "bi") return "fill-green-500"; + return "fill-gray-500"; + }, [value, destinationResource]); + return ( -
- + - + -
+ ); } diff --git a/src/hooks/useSearch.ts b/src/hooks/useSearch.ts index 1676f242..1647dae1 100644 --- a/src/hooks/useSearch.ts +++ b/src/hooks/useSearch.ts @@ -32,7 +32,9 @@ export function useSearch( string, (event: ChangeEvent | string) => void, (querty: string) => void, + boolean, ] { + const [isSearching, setIsSearching] = useState(false); const isMounted = useRef(false); const [query, setQuery] = useState(initialQuery); const prevCollection = usePrevious(collection); @@ -62,6 +64,7 @@ export function useSearch( setFilteredCollection( filterCollection(collection, predicate, query, filter), ); + setIsSearching(false); } }, debounce, @@ -75,8 +78,10 @@ export function useSearch( !isEqual(predicate, prevPredicate) || !isEqual(query, prevQuery) || !isEqual(filter, prevFilter) - ) + ) { + if (!isEqual(query, prevQuery)) setIsSearching(true); debouncedFilterCollection(collection, predicate, query, filter); + } }, [collection, predicate, query, filter]); useEffect(() => { @@ -87,5 +92,5 @@ export function useSearch( }; }, []); - return [filteredCollection, query, handleChange, setQuery]; + return [filteredCollection, query, handleChange, setQuery, isSearching]; } diff --git a/src/interfaces/Nameserver.ts b/src/interfaces/Nameserver.ts index cee49f3c..4b4c09c9 100644 --- a/src/interfaces/Nameserver.ts +++ b/src/interfaces/Nameserver.ts @@ -104,4 +104,50 @@ export const NameserverPresets: Record = { enabled: true, search_domains_enabled: false, }, + DNS0: { + name: "DNS0.EU", + description: "DNS0.EU DNS Servers", + primary: true, + domains: [], + nameservers: [ + { + ip: "193.110.81.0", + ns_type: "udp", + port: 53, + id: "1", + }, + { + ip: "185.253.5.0", + ns_type: "udp", + port: 53, + id: "2", + }, + ], + groups: [], + enabled: true, + search_domains_enabled: false, + }, + DNS0Zero: { + name: "DNS0.EU Zero", + description: "DNS0.EU Zero DNS Servers", + primary: true, + domains: [], + nameservers: [ + { + ip: "193.110.81.9", + ns_type: "udp", + port: 53, + id: "1", + }, + { + ip: "185.253.5.9", + ns_type: "udp", + port: 53, + id: "2", + }, + ], + groups: [], + enabled: true, + search_domains_enabled: false, + }, }; diff --git a/src/layouts/Header.tsx b/src/layouts/Header.tsx index d28e5cde..9861b0d1 100644 --- a/src/layouts/Header.tsx +++ b/src/layouts/Header.tsx @@ -1,15 +1,13 @@ "use client"; import Button from "@components/Button"; +import { NetBirdLogo } from "@components/NetBirdLogo"; import { AnnouncementBanner } from "@components/ui/AnnouncementBanner"; import UserDropdown from "@components/ui/UserDropdown"; import { cn } from "@utils/helpers"; import { MenuIcon, PanelLeftCloseIcon, PanelLeftOpenIcon } from "lucide-react"; -import Image from "next/image"; import { useRouter } from "next/navigation"; -import React, { useMemo } from "react"; -import NetBirdLogo from "@/assets/netbird.svg"; -import NetBirdLogoFull from "@/assets/netbird-full.svg"; +import React from "react"; import { useAnnouncement } from "@/contexts/AnnouncementProvider"; import { useApplicationContext } from "@/contexts/ApplicationProvider"; import { usePermissions } from "@/contexts/PermissionsProvider"; @@ -18,25 +16,6 @@ export const headerHeight = 75; export default function NavbarWithDropdown() { const router = useRouter(); - const Logo = useMemo(() => { - return ( - <> - {"NetBird - {"NetBird - - ); - }, []); - const { toggleMobileNav } = useApplicationContext(); const { bannerHeight } = useAnnouncement(); const { isRestricted } = usePermissions(); @@ -78,7 +57,7 @@ export default function NavbarWithDropdown() { "cursor-pointer hover:opacity-70 transition-all mr-auto" } > - {Logo} +
diff --git a/src/modules/access-control/AccessControlModal.tsx b/src/modules/access-control/AccessControlModal.tsx index 62fe001e..dbe226d3 100644 --- a/src/modules/access-control/AccessControlModal.tsx +++ b/src/modules/access-control/AccessControlModal.tsx @@ -1,6 +1,7 @@ "use client"; import Button from "@components/Button"; +import { Callout } from "@components/Callout"; import FancyToggleSwitch from "@components/FancyToggleSwitch"; import HelpText from "@components/HelpText"; import InlineLink from "@components/InlineLink"; @@ -29,6 +30,7 @@ import { Textarea } from "@components/Textarea"; import PolicyDirection from "@components/ui/PolicyDirection"; import { cn } from "@utils/helpers"; import { + AlertCircleIcon, ArrowRightLeft, ExternalLinkIcon, FolderDown, @@ -130,11 +132,13 @@ export function AccessControlModalContent({ const { permission } = usePermissions(); const { - portAndDirectionDisabled, + portDisabled, destinationGroups, direction, ports, sourceGroups, + destinationHasResources, + destinationOnlyResources, setSourceGroups, setDestinationGroups, setPorts, @@ -156,6 +160,7 @@ export function AccessControlModalContent({ setDestinationResource, portRanges, setPortRanges, + hasPortSupport, } = useAccessControl({ policy, postureCheckTemplates, @@ -183,17 +188,10 @@ export function AccessControlModalContent({ const handleProtocolChange = (p: Protocol) => { setProtocol(p); - if (p == "icmp") { + if (!hasPortSupport(p)) { setPorts([]); setPortRanges([]); } - if (p == "all") { - setPorts([]); - setPortRanges([]); - } - if (p == "tcp" || p == "udp") { - setDirection("in"); - } }; const close = () => { @@ -301,7 +299,8 @@ export function AccessControlModalContent({
@@ -329,10 +328,28 @@ export function AccessControlModalContent({
+ {destinationHasResources && + !destinationOnlyResources && + direction === "bi" && ( + + } + className="mb-4" + > + Some destination groups contain resources. Resources only + support incoming traffic and cannot initiate connections. + + )} +
@@ -352,7 +369,7 @@ export function AccessControlModalContent({ onPortsChange={setPorts} portRanges={portRanges} onPortRangesChange={setPortRanges} - disabled={portAndDirectionDisabled} + disabled={portDisabled} />
diff --git a/src/modules/access-control/table/AccessControlDirectionCell.tsx b/src/modules/access-control/table/AccessControlDirectionCell.tsx index 541bda27..90759500 100644 --- a/src/modules/access-control/table/AccessControlDirectionCell.tsx +++ b/src/modules/access-control/table/AccessControlDirectionCell.tsx @@ -7,17 +7,20 @@ import { Policy } from "@/interfaces/Policy"; type Props = { policy: Policy; }; -export default function AccessControlDirectionCell({ policy }: Props) { +export default function AccessControlDirectionCell({ + policy, +}: Readonly) { const firstRule = useMemo(() => { if (policy.rules.length > 0) return policy.rules[0]; return undefined; }, [policy]); const bidirectional = firstRule ? firstRule.bidirectional : false; + const isSingleResource = !!firstRule?.destinationResource; return (
- {bidirectional ? ( + {bidirectional && !isSingleResource ? ( [] = [ ), }, { + id: "id", + accessorKey: "id", + filterFn: "exactMatch", + }, + { + id: "actions", accessorKey: "id", header: "", cell: ({ cell }) => , @@ -176,6 +182,8 @@ export default function AccessControlTable({ const { mutate } = useSWRConfig(); const path = usePathname(); const { permission } = usePermissions(); + const params = useSearchParams(); + const idParam = params.get("id") ?? undefined; // Default sorting state of the table const [sorting, setSorting] = useLocalStorage( @@ -205,12 +213,25 @@ export default function AccessControlTable({ { diff --git a/src/modules/access-control/useAccessControl.ts b/src/modules/access-control/useAccessControl.ts index b2325c2d..39d794c1 100644 --- a/src/modules/access-control/useAccessControl.ts +++ b/src/modules/access-control/useAccessControl.ts @@ -96,9 +96,9 @@ export const useAccessControl = ({ firstRule ? firstRule.protocol : "all", ); const [direction, setDirection] = useState(() => { - if (firstRule && firstRule?.bidirectional) return "bi"; - if (firstRule && firstRule?.bidirectional == false) return "in"; - return "bi"; + if (!firstRule) return "bi"; + if (firstRule.bidirectional) return "bi"; + return "in"; }); const [name, setName] = useState(policy?.name || initialName || ""); const [description, setDescription] = useState( @@ -273,7 +273,52 @@ export const useAccessControl = ({ } }; - const portAndDirectionDisabled = protocol == "icmp" || protocol == "all"; + const hasPortSupport = (p: Protocol) => p === "tcp" || p === "udp"; + const portDisabled = !hasPortSupport(protocol); + + const destinationHasResources = useMemo(() => { + if (destinationResource) return true; + + return destinationGroups.some((group) => { + if (group.resources_count !== undefined) { + return group.resources_count > 0; + } + if (group.resources && Array.isArray(group.resources)) { + return group.resources.length > 0; + } + return false; + }); + }, [destinationGroups, destinationResource]); + + const destinationOnlyResources = useMemo(() => { + if (destinationResource) return true; + + return ( + destinationGroups.length > 0 && + destinationGroups.every((group) => { + const hasPeers = + group.peers_count !== undefined + ? group.peers_count > 0 + : group.peers && + Array.isArray(group.peers) && + group.peers.length > 0; + const hasResources = + group.resources_count !== undefined + ? group.resources_count > 0 + : group.resources && + Array.isArray(group.resources) && + group.resources.length > 0; + + return hasResources && !hasPeers; + }) + ); + }, [destinationGroups, destinationResource]); + + useEffect(() => { + if (destinationOnlyResources && direction !== "in") { + setDirection("in"); + } + }, [destinationOnlyResources, direction, setDirection]); return { protocol, @@ -298,10 +343,13 @@ export const useAccessControl = ({ setPostureChecks, submit, getPolicyData, - portAndDirectionDisabled, + portDisabled, isPostureChecksLoading, destinationResource, setDestinationResource, + destinationHasResources, + destinationOnlyResources, + hasPortSupport, } as const; }; diff --git a/src/modules/activity/ActivityTable.tsx b/src/modules/activity/ActivityTable.tsx index 058c135a..d052bb22 100644 --- a/src/modules/activity/ActivityTable.tsx +++ b/src/modules/activity/ActivityTable.tsx @@ -121,13 +121,13 @@ export default function ActivityTable({ return ( (null); const [inputRef, { width }] = useElementSize(); - const [search, setSearch] = useState(""); + const [searchInput, setSearchInput] = useState(""); + const search = useDebounce(searchInput, 500); const toggle = (item: string | undefined) => { const isSelected = value == item; @@ -45,7 +47,7 @@ export function UsersDropdownSelector({ onChange && onChange(undefined); } else { onChange && onChange(item); - setSearch(""); + setSearchInput(""); } setOpen(false); }; @@ -69,7 +71,7 @@ export function UsersDropdownSelector({ onOpenChange={(isOpen) => { if (!isOpen) { setTimeout(() => { - setSearch(""); + setSearchInput(""); }, 100); } setOpen(isOpen); @@ -155,8 +157,8 @@ export function UsersDropdownSelector({ "dark:placeholder:text-nb-gray-400 font-light placeholder:text-neutral-500 pl-10", )} ref={searchRef} - value={search} - onValueChange={setSearch} + value={searchInput} + onValueChange={setSearchInput} placeholder={"Search user..."} />
{ toggle(undefined); - searchRef.current?.focus(); }} onClick={(e) => e.preventDefault()} > @@ -217,7 +218,6 @@ export function UsersDropdownSelector({ className={"py-1 px-2"} onSelect={() => { toggle(user.email); - searchRef.current?.focus(); }} onClick={(e) => e.preventDefault()} > diff --git a/src/modules/common-table-rows/GroupsRow.tsx b/src/modules/common-table-rows/GroupsRow.tsx index edd6d2d6..76aa2f2c 100644 --- a/src/modules/common-table-rows/GroupsRow.tsx +++ b/src/modules/common-table-rows/GroupsRow.tsx @@ -10,8 +10,11 @@ import { import ModalHeader from "@components/modal/ModalHeader"; import { PeerGroupSelector } from "@components/PeerGroupSelector"; import Separator from "@components/Separator"; -import MultipleGroups from "@components/ui/MultipleGroups"; +import MultipleGroups, { + TransparentEditIconButton, +} from "@components/ui/MultipleGroups"; import { IconCirclePlus } from "@tabler/icons-react"; +import { cn } from "@utils/helpers"; import { FolderGit2 } from "lucide-react"; import * as React from "react"; import { useMemo } from "react"; @@ -64,7 +67,7 @@ export default function GroupsRow({ onClick={(e) => { e.preventDefault(); e.stopPropagation(); - setModal && permission.groups.update && setModal(true); + setModal && permission.groups.update && !disabled && setModal(true); }} > {foundGroups?.length == 0 && showAddGroupButton ? ( @@ -73,7 +76,15 @@ export default function GroupsRow({ Add Groups ) : ( - +
+ + {!disabled && } +
)} ) { const [open, setOpen] = useState(false); const [presetModal, setPresetModal] = useState(false); const [preset, setPreset] = useState(NameserverPresets.Default); @@ -49,11 +52,11 @@ type ModalProps = { export function NameserverTemplateModalContent({ onePresetSelection, -}: ModalProps) { +}: Readonly) { return ( - +
-
+
onePresetSelection(NameserverPresets.Google)} src={GoogleLogo} @@ -61,6 +64,7 @@ export function NameserverTemplateModalContent({ description={ "A free, global DNS resolution service by Google that implements a number of security, performance, and compliance improvements." } + href={"https://developers.google.com/speed/public-dns"} /> onePresetSelection(NameserverPresets.Cloudflare)} @@ -69,6 +73,26 @@ export function NameserverTemplateModalContent({ description={ "Enterprise-grade DNS service that offers the fastest response time, unparalleled redundancy, and advanced security with built-in DDoS mitigation and DNSSEC." } + href={"https://www.cloudflare.com/learning/dns/what-is-1.1.1.1/"} + /> + + onePresetSelection(NameserverPresets.DNS0)} + src={DNS0Logo} + title={"DNS0.EU DNS"} + description={ + "A free, sovereign and GDPR-compliant DNS resolver with a strong focus on security to protect the citizens and organizations of the European Union." + } + href={"https://www.dns0.eu/"} + /> + onePresetSelection(NameserverPresets.DNS0Zero)} + src={DNS0ZeroLogo} + title={"DNS0.EU Zero DNS"} + description={ + "Increase the catch rate for malicious domains by combining human-vetted threat intelligence with advanced heuristics that automatically identify high-risk patterns." + } + href={"https://www.dns0.eu/zero"} /> onePresetSelection(NameserverPresets.Quad9)} @@ -77,6 +101,7 @@ export function NameserverTemplateModalContent({ description={ "The Quad9 DNS service is operated by the Swiss-based Quad9 Foundation, whose mission is to provide a safer and more robust Internet for everyone." } + href={"https://quad9.net/"} /> onePresetSelection(NameserverPresets.Default)} @@ -98,15 +123,19 @@ function NameserverTemplate({ title, description, onClick, -}: { + href, + hrefTitle, +}: Readonly<{ src?: StaticImageData; icon?: React.ReactNode; title: string; description?: string; onClick?: () => void; -}) { + href?: string; + hrefTitle?: string; +}>) { return ( -
{src && {title}} {icon && icon}
-
-

{title}

+
+
+

{title}

+
{description && (

{description}

)} + {href && ( +
+ { + e.stopPropagation(); + }} + > + {hrefTitle || "Learn more"} + + +
+ )}
-
+ ); } diff --git a/src/modules/networks/misc/NetworkInformationSquare.tsx b/src/modules/networks/misc/NetworkInformationSquare.tsx index 93e6046d..4fd75c07 100644 --- a/src/modules/networks/misc/NetworkInformationSquare.tsx +++ b/src/modules/networks/misc/NetworkInformationSquare.tsx @@ -1,4 +1,4 @@ -import TruncatedText from "@components/ui/TruncatedText"; +import DescriptionWithTooltip from "@components/ui/DescriptionWithTooltip"; import { cn } from "@utils/helpers"; import { ArrowRightIcon } from "lucide-react"; import * as React from "react"; @@ -45,25 +45,22 @@ export const NetworkInformationSquare = ({
- - + {name} +

+ diff --git a/src/modules/networks/resources/ResourceGroupCell.tsx b/src/modules/networks/resources/ResourceGroupCell.tsx index 91f3c643..224896e1 100644 --- a/src/modules/networks/resources/ResourceGroupCell.tsx +++ b/src/modules/networks/resources/ResourceGroupCell.tsx @@ -1,4 +1,6 @@ -import MultipleGroups from "@components/ui/MultipleGroups"; +import MultipleGroups, { + TransparentEditIconButton, +} from "@components/ui/MultipleGroups"; import * as React from "react"; import { usePermissions } from "@/contexts/PermissionsProvider"; import { Group } from "@/interfaces/Group"; @@ -15,13 +17,14 @@ export const ResourceGroupCell = ({ resource }: Props) => { return ( ); }; diff --git a/src/modules/networks/resources/ResourceSingleAddressInput.tsx b/src/modules/networks/resources/ResourceSingleAddressInput.tsx index a0a78a77..3e29fdf1 100644 --- a/src/modules/networks/resources/ResourceSingleAddressInput.tsx +++ b/src/modules/networks/resources/ResourceSingleAddressInput.tsx @@ -5,13 +5,26 @@ import { validator } from "@utils/helpers"; import cidr from "ip-cidr"; import { GlobeIcon, NetworkIcon, WorkflowIcon } from "lucide-react"; import * as React from "react"; -import { useMemo } from "react"; +import { useEffect, useMemo } from "react"; type Props = { value: string; onChange: (value: string) => void; + label?: string; + className?: string; + onError?: (error: string) => void; + description?: string; + placeholder?: string; }; -export const ResourceSingleAddressInput = ({ value, onChange }: Props) => { +export const ResourceSingleAddressInput = ({ + value, + onChange, + label = "Address", + className = "", + onError, + description = "Enter a single IP address, CIDR block or domain name", + placeholder = "Address (IP, CIDR or Domain)", +}: Props) => { const hasChars = useMemo(() => { return !!value.match(/[a-z*]/i); }, [value]); @@ -31,35 +44,39 @@ export const ResourceSingleAddressInput = ({ value, onChange }: Props) => { // Case 1: If it has characters (potential domain) but is not a CIDR block if (hasChars && !isCIDRBlock) { - if (!validator.isValidDomain(value)) { - return "Please enter a valid domain, e.g. intra.example.com or *.example.com"; + if ( + !validator.isValidDomain(value) || + !value.includes(".") || + value.endsWith(".") + ) { + return "Please enter a valid domain, e.g. service.internal, example.com or *.example.com"; } return ""; // Valid domain } // Case 2: If it's not a valid domain, check if it's a valid CIDR if (!cidr.isValidAddress(value)) { - return "Please enter a valid IP or CIDR, e.g., 192.168.1.0/24"; + return "Please enter a valid IP or CIDR, e.g., 10.0.0.21, 192.168.1.0/24"; } return ""; // Valid CIDR }, [value, hasChars, isCIDRBlock]); + useEffect(() => { + onError?.(error); + }, [error]); + return ( - <> -
- - - Enter a single IP address, CIDR block or domain name - - onChange(e.target.value)} - /> -
- +
+ + {description} + onChange(e.target.value)} + /> +
); }; diff --git a/src/modules/networks/resources/ResourcesTable.tsx b/src/modules/networks/resources/ResourcesTable.tsx index ecc4598e..93415376 100644 --- a/src/modules/networks/resources/ResourcesTable.tsx +++ b/src/modules/networks/resources/ResourcesTable.tsx @@ -8,6 +8,7 @@ import { IconCirclePlus } from "@tabler/icons-react"; import { ColumnDef, SortingState } from "@tanstack/react-table"; import { removeAllSpaces } from "@utils/helpers"; import { Layers3Icon } from "lucide-react"; +import { useSearchParams } from "next/navigation"; import * as React from "react"; import { useState } from "react"; import { usePermissions } from "@/contexts/PermissionsProvider"; @@ -30,6 +31,11 @@ type Props = { const NetworkResourceColumns: ColumnDef[] = [ { id: "id", + accessorKey: "id", + filterFn: "exactMatch", + }, + { + id: "name", accessorKey: "name", header: ({ column }) => { return Resource; @@ -101,6 +107,8 @@ export default function ResourcesTable({ headingTarget, }: Readonly) { const { permission } = usePermissions(); + const params = useSearchParams(); + const resourceId = params.get("resource") ?? undefined; const [sorting, setSorting] = useState([]); const { openResourceModal, network } = useNetworksContext(); @@ -119,6 +127,10 @@ export default function ResourcesTable({ text={"Resources"} columns={NetworkResourceColumns} keepStateInLocalStorage={false} + initialFilters={ + resourceId ? [{ id: "id", value: resourceId }] : undefined + } + initialSearch={resourceId} data={resources} searchPlaceholder={"Search by name, address or group..."} isLoading={isLoading} @@ -134,6 +146,7 @@ export default function ResourcesTable({ } columnVisibility={{ description: false, + id: false, }} paginationPaddingClassName={"px-0 pt-8"} rightSide={() => ( diff --git a/src/modules/networks/table/NetworksTable.tsx b/src/modules/networks/table/NetworksTable.tsx index 2c523dce..12e3fd0b 100644 --- a/src/modules/networks/table/NetworksTable.tsx +++ b/src/modules/networks/table/NetworksTable.tsx @@ -10,7 +10,7 @@ import { ColumnDef, SortingState } from "@tanstack/react-table"; import { cn } from "@utils/helpers"; import { ExternalLinkIcon, PlusCircle } from "lucide-react"; import { usePathname } from "next/navigation"; -import React from "react"; +import React, { useState } from "react"; import { useSWRConfig } from "swr"; import NetworkRoutesIcon from "@/assets/icons/NetworkRoutesIcon"; import { usePermissions } from "@/contexts/PermissionsProvider"; @@ -25,6 +25,7 @@ import NetworkNameCell from "@/modules/networks/table/NetworkNameCell"; import { NetworkPolicyCell } from "@/modules/networks/table/NetworkPolicyCell"; import { NetworkResourceCell } from "@/modules/networks/table/NetworkResourceCell"; import NetworkRoutingPeerCell from "@/modules/networks/table/NetworkRoutingPeerCell"; +import { GlobalSearchModal } from "@/modules/search/GlobalSearchModal"; export const NetworkTableColumns: ColumnDef[] = [ { @@ -79,9 +80,10 @@ export default function NetworksTable({ isLoading, data, headingTarget, -}: Props) { +}: Readonly) { const { mutate } = useSWRConfig(); const path = usePathname(); + const [searchModal, setSearchModal] = useState(false); // Default sorting state of the table const [sorting, setSorting] = useLocalStorage( @@ -95,75 +97,85 @@ export default function NetworksTable({ ); return ( - - - } - color={"gray"} - size={"large"} - /> - } - title={"Create New Network"} - description={ - "It looks like you don't have any networks. Access internal resources in your LANs and VPC by adding a network." - } - button={ -
+ <> + + + setSearchModal(true)} + getStartedCard={ + + } + color={"gray"} + size={"large"} + /> + } + title={"Create New Network"} + description={ + "It looks like you don't have any networks. Access internal resources in your LANs and VPC by adding a network." + } + button={ +
+ +
+ } + learnMore={ + <> + Learn more about + + Networks + + + + } + /> + } + rightSide={() => + data && + data.length > 0 && ( +
- } - learnMore={ - <> - Learn more about - - Networks - - - - } - /> - } - rightSide={() => - data && - data.length > 0 && ( -
- -
- ) - } - > - {(table) => ( - <> - - { - mutate("/networks").then(); - }} - /> - - )} -
-
+ ) + } + > + {(table) => ( + <> + + { + mutate("/networks").then(); + }} + /> + + )} + + + ); } diff --git a/src/modules/posture-checks/checks/PostureCheckOperatingSystem.tsx b/src/modules/posture-checks/checks/PostureCheckOperatingSystem.tsx index 92bad5fe..558dc466 100644 --- a/src/modules/posture-checks/checks/PostureCheckOperatingSystem.tsx +++ b/src/modules/posture-checks/checks/PostureCheckOperatingSystem.tsx @@ -366,7 +366,7 @@ export const OperatingSystemTab = ({ useEffect(() => { onError(versionError); - }, [versionError]); + }, [versionError, onError]); return (
diff --git a/src/modules/search/GlobalSearchModal.tsx b/src/modules/search/GlobalSearchModal.tsx new file mode 100644 index 00000000..1723cd70 --- /dev/null +++ b/src/modules/search/GlobalSearchModal.tsx @@ -0,0 +1,391 @@ +import { DropdownInput } from "@components/DropdownInput"; +import Kbd from "@components/Kbd"; +import { Modal, ModalContent } from "@components/modal/Modal"; +import { VirtualScrollAreaList } from "@components/VirtualScrollAreaList"; +import { useSearch } from "@hooks/useSearch"; +import useFetchApi from "@utils/api"; +import { removeAllSpaces } from "@utils/helpers"; +import { + ArrowDownIcon, + ArrowUpIcon, + CornerDownLeft, + GlobeIcon, + LayersIcon, + NetworkIcon, + TextSearchIcon, + WorkflowIcon, +} from "lucide-react"; +import { useRouter } from "next/navigation"; +import * as React from "react"; +import { useMemo } from "react"; +import Skeleton from "react-loading-skeleton"; +import { Network, NetworkResource } from "@/interfaces/Network"; + +type Props = { + open: boolean; + setOpen: (open: boolean) => void; +}; + +enum SearchType { + Network = "network", + NetworkResource = "network-resource", +} + +type SearchResult = { + type: U; + id: string; + data: T; + onAction?: (item: T) => void; +}; + +type NetworkSearchResult = SearchResult; +type ResourceSearchResult = SearchResult< + NetworkResource, + SearchType.NetworkResource +>; +type AnySearchResult = NetworkSearchResult | ResourceSearchResult; + +const searchPredicate = (item: AnySearchResult, query: string) => { + if (!query) return false; + const lower = removeAllSpaces(query.toLowerCase()); + const { name, description, id } = item.data; + const find = (s: string | undefined) => + removeAllSpaces(s?.toLowerCase()).includes(lower); + + if (item.type === SearchType.Network) { + if (find(name)) return true; + if (find(description)) return true; + if (find(id)) return true; + } + + if (item.type === SearchType.NetworkResource) { + if (find(name)) return true; + if (find(description)) return true; + if (find(item.data?.address)) return true; + if (find(id)) return true; + } + + return false; +}; + +export const GlobalSearchModal = ({ open, setOpen }: Props) => { + return open && ; +}; + +const GlobalSearchModalContent = ({ open, setOpen }: Props) => { + const router = useRouter(); + + const { data: networks, isLoading: isNetworksLoading } = useFetchApi< + Network[] + >("/networks", true, false, open, { + key: "global-search-networks", + }); + const { data: resources, isLoading: isResourcesLoading } = useFetchApi< + NetworkResource[] + >("/networks/resources", true, false, open, { + key: "global-search-resources", + }); + + const findNetworkByResourceId = (resourceId: string) => { + return networks?.find( + (network) => network.resources?.some((res) => res === resourceId), + ); + }; + + const items: AnySearchResult[] = useMemo(() => { + if (isNetworksLoading || isResourcesLoading) return []; + const networkResults: NetworkSearchResult[] = (networks ?? []).map( + (network) => ({ + type: SearchType.Network, + id: network.id, + data: network, + onAction: () => router.push(`/network?id=${network.id}`), + }), + ); + + const resourceResults: ResourceSearchResult[] = (resources ?? []).map( + (resource) => ({ + type: SearchType.NetworkResource, + id: resource.id, + data: resource, + onAction: () => { + const network = findNetworkByResourceId(resource.id); + if (network) + router.push(`/network?id=${network.id}&resource=${resource.id}`); + }, + }), + ); + + return [...networkResults, ...resourceResults]; + }, [isNetworksLoading, isResourcesLoading, networks, resources]); + + const [filteredItems, search, setSearch, setQuery, isSearching] = useSearch( + items, + searchPredicate, + { + filter: false, + debounce: 350, + }, + ); + + const isLoading = isNetworksLoading || isResourcesLoading || isSearching; + + const networksCount = useMemo(() => { + return filteredItems.filter((i) => i.type === SearchType.Network).length; + }, [filteredItems]); + + const resourcesCount = useMemo(() => { + return filteredItems.filter((i) => i.type === SearchType.NetworkResource) + .length; + }, [filteredItems]); + + return ( +
+ { + if (!isOpen) setSearch(""); + setOpen(isOpen); + }} + > + + + + {search === "" && } + + {isLoading && search !== "" && } + + {!isSearching && search !== "" && filteredItems.length === 0 && ( + + )} + + {!isSearching && search != "" && filteredItems.length !== 0 && ( + i.type} + estimatedItemHeight={48} + estimatedHeadingHeight={32} + heightAdjustment={5} + onSelect={(item) => { + const { onAction, data, type } = item; + if (type === SearchType.Network) onAction?.(data); + if (type === SearchType.NetworkResource) onAction?.(data); + }} + renderHeading={(item) => { + return ( +
+ {item.type === SearchType.Network && + `Networks (${networksCount})`} + {item.type === SearchType.NetworkResource && + `Resources (${resourcesCount})`} +
+ ); + }} + renderItem={(item) => { + const network = findNetworkByResourceId(item.id); + + return ( +
+
+
+ {item.type === SearchType.Network && ( +
+ {item.data.name.substring(0, 2)} +
+ )} + {item.type === SearchType.NetworkResource && ( + + )} +
+
+
+ {item.data.name} {network && ` - ${network.name}`} +
+
+ {item.data.description} +
+
+
+
+ {item.type === SearchType.Network && ( +
+
+ + {item.data?.resources?.length} Resource(s) +
+
+ )} + {item.type === SearchType.NetworkResource && ( +
+
+ {item.data?.address} +
+
+ )} +
+ +
+
+
+ ); + }} + /> + )} + +
+
+
+ ); +}; + +const ResourceIcon = ({ type }: { type: NetworkResource["type"] }) => { + const size = 14; + switch (type) { + case "host": + return ; + case "domain": + return ; + case "subnet": + return ; + default: + return ; + } +}; + +const BlankState = () => { + return ( +
+
+
+
+ +
+
+ +
+
+ +
+
+ +
+ Search for Networks and Resources +
+
+ Quickly find networks and associated resources.
+ Start typing to search by name, description or address. +
+
+
+ ); +}; + +const NotFoundState = () => { + return ( +
+
+
+
+ +
+
+ +
+ Could not find any results +
+
+ {`We couldn't find any results. Please try a different search term.`} +
+
+
+ ); +}; + +const LoadingState = () => { + return ( +
+ + + +
+ ); +}; + +const KeyboardShortcutsFooter = () => { + return ( +
+
+ + + + + + +
Navigate
+
+
+ + + +
Open
+
+
+ + esc + +
Close
+
+
+ ); +}; diff --git a/src/modules/settings/DangerZoneTab.tsx b/src/modules/settings/DangerZoneTab.tsx index b182b0e7..e9924275 100644 --- a/src/modules/settings/DangerZoneTab.tsx +++ b/src/modules/settings/DangerZoneTab.tsx @@ -1,4 +1,3 @@ -import { useOidc } from "@axa-fr/react-oidc"; import Breadcrumbs from "@components/Breadcrumbs"; import Button from "@components/Button"; import Card from "@components/Card"; @@ -29,6 +28,13 @@ export default function DangerZoneTab({ account }: Props) { .del() .catch((error) => reject(error)) .then(() => { + // Clear browser storage after account deletion + if (typeof window !== "undefined") { + localStorage.clear(); + sessionStorage.clear(); + // Optionally, clear cookies if needed + // document.cookie = ... (set cookies to expire) + } logout().then(); resolve(); }); diff --git a/src/modules/setup-netbird-modal/DockerTab.tsx b/src/modules/setup-netbird-modal/DockerTab.tsx index e6cd059d..166f98f2 100644 --- a/src/modules/setup-netbird-modal/DockerTab.tsx +++ b/src/modules/setup-netbird-modal/DockerTab.tsx @@ -14,11 +14,13 @@ import { RoutingPeerSetupKeyInfo } from "@/modules/setup-netbird-modal/SetupModa type Props = { setupKey?: string; showSetupKeyInfo?: boolean; + hostname?: string; }; export default function DockerTab({ setupKey, showSetupKeyInfo = false, + hostname, }: Readonly) { return ( @@ -59,7 +61,16 @@ export default function DockerTab({ {" "} \ - -v netbird-client:/etc/netbird \ + + {hostname && ( + + {" "} + -e NB_HOSTNAME= + {`'${hostname}'`} \ + + )} + + -v netbird-client:/var/lib/netbird \ {GRPC_API_ORIGIN && ( {" "} @@ -73,9 +84,7 @@ export default function DockerTab({

Read our documentation

diff --git a/src/modules/setup-netbird-modal/LinuxTab.tsx b/src/modules/setup-netbird-modal/LinuxTab.tsx index 581e82d5..297bc3dc 100644 --- a/src/modules/setup-netbird-modal/LinuxTab.tsx +++ b/src/modules/setup-netbird-modal/LinuxTab.tsx @@ -14,6 +14,7 @@ import { TerminalSquareIcon } from "lucide-react"; import React from "react"; import { OperatingSystem } from "@/interfaces/OperatingSystem"; import { + HostnameParameter, RoutingPeerSetupKeyInfo, SetupKeyParameter, } from "@/modules/setup-netbird-modal/SetupModal"; @@ -21,11 +22,13 @@ import { type Props = { setupKey?: string; showSetupKeyInfo?: boolean; + hostname?: string; }; export default function LinuxTab({ setupKey, showSetupKeyInfo = false, + hostname, }: Readonly) { return ( @@ -47,6 +50,7 @@ export default function LinuxTab({ {getNetBirdUpCommand()} +
@@ -104,6 +108,7 @@ export default function LinuxTab({ {getNetBirdUpCommand()} + diff --git a/src/modules/setup-netbird-modal/MacOSTab.tsx b/src/modules/setup-netbird-modal/MacOSTab.tsx index c000a2c9..34e19567 100644 --- a/src/modules/setup-netbird-modal/MacOSTab.tsx +++ b/src/modules/setup-netbird-modal/MacOSTab.tsx @@ -24,6 +24,7 @@ import Link from "next/link"; import React from "react"; import { OperatingSystem } from "@/interfaces/OperatingSystem"; import { + HostnameParameter, RoutingPeerSetupKeyInfo, SetupKeyParameter, } from "@/modules/setup-netbird-modal/SetupModal"; @@ -31,10 +32,12 @@ import { type Props = { setupKey?: string; showSetupKeyInfo?: boolean; + hostname?: string; }; export default function MacOSTab({ setupKey, showSetupKeyInfo, + hostname, }: Readonly) { return ( @@ -120,6 +123,7 @@ export default function MacOSTab({ {getNetBirdUpCommand()} + @@ -162,6 +166,7 @@ export default function MacOSTab({ {getNetBirdUpCommand()} + @@ -222,6 +227,7 @@ export default function MacOSTab({ {getNetBirdUpCommand()} + diff --git a/src/modules/setup-netbird-modal/SetupModal.tsx b/src/modules/setup-netbird-modal/SetupModal.tsx index ead19bb9..694ef983 100644 --- a/src/modules/setup-netbird-modal/SetupModal.tsx +++ b/src/modules/setup-netbird-modal/SetupModal.tsx @@ -8,7 +8,7 @@ import { Tabs, TabsList, TabsTrigger } from "@components/Tabs"; import { cn } from "@utils/helpers"; import { ExternalLinkIcon } from "lucide-react"; import { usePathname } from "next/navigation"; -import React from "react"; +import React, { useMemo } from "react"; import AndroidIcon from "@/assets/icons/AndroidIcon"; import AppleIcon from "@/assets/icons/AppleIcon"; import DockerIcon from "@/assets/icons/DockerIcon"; @@ -34,6 +34,7 @@ type Props = { user?: OidcUserInfo; setupKey?: string; showOnlyRoutingPeerOS?: boolean; + className?: string; }; export default function SetupModal({ @@ -41,9 +42,10 @@ export default function SetupModal({ user, setupKey, showOnlyRoutingPeerOS = false, + className, }: Readonly) { return ( - + ) { const os = useOperatingSystem(); const [isFirstRun] = useLocalStorage("netbird-first-run", true); const pathname = usePathname(); const isInstallPage = pathname === "/install"; + const titleMessage = useMemo(() => { + if (title) return title; + + if (isFirstRun && !isInstallPage) { + let name = user?.given_name || "there"; + return ( + <> + Hello {name}! 👋
It's time to add your first device. + + ); + } + + return setupKey ? "Install NetBird with Setup Key" : "Install NetBird"; + }, [isFirstRun, isInstallPage, setupKey, title, user?.given_name]); + return ( <> {header && ( @@ -85,14 +108,7 @@ export function SetupModalContent({ setupKey ? "text-2xl" : "text-3xl", )} > - {isFirstRun && !isInstallPage ? ( - <> - Hello {user?.given_name || "there"}! 👋
- {`It's time to add your first device.`} - - ) : ( - <>Install NetBird{setupKey && " with Setup Key"} - )} + {titleMessage} )} - - - Docker - + {!hideDocker && ( + + + Docker + + )} {!setupKey && ( @@ -183,10 +204,13 @@ export function SetupModalContent({ )} - + {!hideDocker && ( + + )} {footer && ( @@ -227,6 +251,22 @@ export const SetupKeyParameter = ({ setupKey }: SetupKeyParameterProps) => { ); }; +export const HostnameParameter = ({ hostname }: { hostname?: string }) => { + return ( + hostname && ( + <> + {" "} + --hostname{" "} + + {"'"} + {hostname} + {"'"} + + + ) + ); +}; + export const RoutingPeerSetupKeyInfo = () => { return (
) { return ( @@ -67,6 +70,7 @@ export default function WindowsTab({ {getNetBirdUpCommand()} + diff --git a/src/modules/users/table-cells/UserGroupCell.tsx b/src/modules/users/table-cells/UserGroupCell.tsx index 81abbd94..6e676486 100644 --- a/src/modules/users/table-cells/UserGroupCell.tsx +++ b/src/modules/users/table-cells/UserGroupCell.tsx @@ -1,17 +1,24 @@ -import MultipleGroups from "@components/ui/MultipleGroups"; +import { notify } from "@components/Notification"; +import { useApiCall } from "@utils/api"; import { uniq } from "lodash"; -import React, { useMemo } from "react"; +import React, { useMemo, useState } from "react"; import Skeleton from "react-loading-skeleton"; +import { useSWRConfig } from "swr"; import { useGroups } from "@/contexts/GroupsProvider"; +import { usePermissions } from "@/contexts/PermissionsProvider"; import { Group } from "@/interfaces/Group"; import { User } from "@/interfaces/User"; -import EmptyRow from "@/modules/common-table-rows/EmptyRow"; +import GroupsRow from "@/modules/common-table-rows/GroupsRow"; type Props = { user: User; }; export default function UserGroupCell({ user }: Readonly) { const { groups, isLoading } = useGroups(); + const [modal, setModal] = useState(false); + const { mutate } = useSWRConfig(); + const { permission } = usePermissions(); + const userRequest = useApiCall("/users"); const allGroups = useMemo(() => { if (isLoading) return []; @@ -20,6 +27,10 @@ export default function UserGroupCell({ user }: Readonly) { .filter((g): g is Group => g !== undefined); }, [user.auto_groups, groups, isLoading]); + const userGroupIds = useMemo(() => { + return (allGroups.map((group) => group.id) as string[]) || []; + }, [allGroups]); + if (isLoading) return (
@@ -28,9 +39,45 @@ export default function UserGroupCell({ user }: Readonly) {
); - return allGroups.length == 0 ? ( - - ) : ( - + const handleSave = async (promises: Promise[]) => { + if (!user) return; + + const groups = await Promise.all(promises); + const groupIds = + groups?.map((group) => group?.id).filter((id) => id !== undefined) || []; + + notify({ + title: user?.name || user?.email || "User", + description: "Groups of the user were successfully saved", + promise: userRequest + .put( + { + ...user, + auto_groups: groupIds, + }, + `/${user.id}`, + ) + .then(() => { + setModal(false); + mutate(`/users?service_user=false`); + mutate(`/integrations/msp/switcher`); + mutate("/groups"); + }), + loadingMessage: "Updating groups...", + }); + }; + + return ( + ); } diff --git a/src/utils/helpers.ts b/src/utils/helpers.ts index 0a0ac6aa..79fa8946 100644 --- a/src/utils/helpers.ts +++ b/src/utils/helpers.ts @@ -20,7 +20,8 @@ export function randomBoolean() { return Math.random() >= 0.5; } -export function removeAllSpaces(str: string) { +export function removeAllSpaces(str?: string) { + if (!str) return ""; return str.replace(/\s/g, ""); } diff --git a/src/utils/netbird.ts b/src/utils/netbird.ts index 72164e76..773d80fe 100644 --- a/src/utils/netbird.ts +++ b/src/utils/netbird.ts @@ -8,16 +8,13 @@ export const getNetBirdUpCommand = () => { if (GRPC_API_ORIGIN) { cmd += " --management-url " + GRPC_API_ORIGIN; } - if (!isNetBirdHosted()) { - let admin_url = window.location.protocol + "//" + window.location.hostname; - if (window.location.port != "") { - admin_url += ":" + window.location.port; - } - cmd += " --admin-url " + admin_url; - } return cmd; }; +export const getInstallUrl = () => { + return window.location.origin + "/install"; +}; + export const isNetBirdHosted = () => { return ( window.location.hostname.endsWith(".netbird.io") || diff --git a/tailwind.config.ts b/tailwind.config.ts index 9a5a6782..dc439e5a 100644 --- a/tailwind.config.ts +++ b/tailwind.config.ts @@ -15,7 +15,7 @@ const config: Config = { "100": "#e4e7e9", "200": "#cbd2d6", "250": "#b7c0c6", - "300": "#a7b1b9", + "300": "#aab4bd", "350": "#8f9ca8", "400": "#7c8994", "500": "#616e79", @@ -28,7 +28,7 @@ const config: Config = { "920": "#25282d", "925": "#1e2123", "930": "#25282c", - "940": "#1b1f22", + "940": "#1c1d21", "950": "#181a1d", }, netbird: {