diff --git a/src/app/(dashboard)/access-control/page.tsx b/src/app/(dashboard)/access-control/page.tsx index 29c0c1be..777e4bdf 100644 --- a/src/app/(dashboard)/access-control/page.tsx +++ b/src/app/(dashboard)/access-control/page.tsx @@ -5,6 +5,7 @@ import InlineLink from "@components/InlineLink"; import Paragraph from "@components/Paragraph"; import SkeletonTable from "@components/skeletons/SkeletonTable"; import { RestrictedAccess } from "@components/ui/RestrictedAccess"; +import { usePortalElement } from "@hooks/usePortalElement"; import useFetchApi from "@utils/api"; import { ExternalLinkIcon } from "lucide-react"; import React, { lazy, Suspense } from "react"; @@ -20,6 +21,9 @@ const AccessControlTable = lazy( export default function AccessControlPage() { const { data: policies, isLoading } = useFetchApi("/policies"); + const { ref: headingRef, portalTarget } = + usePortalElement(); + return ( @@ -31,12 +35,7 @@ export default function AccessControlPage() { icon={} /> - -

- {policies && policies.length > 1 - ? `${policies.length} Access Control Policies` - : "Access Control Policies"} -

+

Access Control Policies

Create rules to manage access in your network and define what peers can connect. @@ -57,7 +56,11 @@ export default function AccessControlPage() { }> - + diff --git a/src/app/(dashboard)/activity/page.tsx b/src/app/(dashboard)/activity/page.tsx index f1b399a8..a5af61b1 100644 --- a/src/app/(dashboard)/activity/page.tsx +++ b/src/app/(dashboard)/activity/page.tsx @@ -4,6 +4,7 @@ import Breadcrumbs from "@components/Breadcrumbs"; import InlineLink from "@components/InlineLink"; import Paragraph from "@components/Paragraph"; import { RestrictedAccess } from "@components/ui/RestrictedAccess"; +import { usePortalElement } from "@hooks/usePortalElement"; import useFetchApi from "@utils/api"; import { ExternalLinkIcon } from "lucide-react"; import React from "react"; @@ -15,6 +16,9 @@ import ActivityTable from "@/modules/activity/ActivityTable"; export default function Activity() { const { data: events, isLoading } = useFetchApi("/events"); + const { ref: headingRef, portalTarget } = + usePortalElement(); + return (
@@ -25,11 +29,7 @@ export default function Activity() { icon={} /> -

- {events && events.length > 1 - ? `${events.length} Activity Events` - : "Activity Events"} -

+

Activity Events

Here you can see all the account and network activity events. @@ -48,7 +48,11 @@ export default function Activity() {
- +
); diff --git a/src/app/(dashboard)/dns/nameservers/page.tsx b/src/app/(dashboard)/dns/nameservers/page.tsx index 07e31ff2..63f20dd5 100644 --- a/src/app/(dashboard)/dns/nameservers/page.tsx +++ b/src/app/(dashboard)/dns/nameservers/page.tsx @@ -5,6 +5,7 @@ import InlineLink from "@components/InlineLink"; import Paragraph from "@components/Paragraph"; import SkeletonTable from "@components/skeletons/SkeletonTable"; import { RestrictedAccess } from "@components/ui/RestrictedAccess"; +import { usePortalElement } from "@hooks/usePortalElement"; import useFetchApi from "@utils/api"; import { ExternalLinkIcon, ServerIcon } from "lucide-react"; import React, { lazy, Suspense } from "react"; @@ -20,6 +21,9 @@ export default function NameServers() { const { data: nameserverGroups, isLoading } = useFetchApi("/dns/nameservers"); + const { ref: headingRef, portalTarget } = + usePortalElement(); + return (
@@ -36,11 +40,7 @@ export default function NameServers() { icon={} /> -

- {nameserverGroups && nameserverGroups.length > 1 - ? `${nameserverGroups.length} Nameservers` - : "Nameservers"} -

+

Nameservers

Add nameservers for domain name resolution in your NetBird network. @@ -62,6 +62,7 @@ export default function NameServers() { diff --git a/src/app/(dashboard)/network-routes/page.tsx b/src/app/(dashboard)/network-routes/page.tsx index 210bffe3..e5b00981 100644 --- a/src/app/(dashboard)/network-routes/page.tsx +++ b/src/app/(dashboard)/network-routes/page.tsx @@ -5,6 +5,7 @@ import InlineLink from "@components/InlineLink"; import Paragraph from "@components/Paragraph"; import SkeletonTable from "@components/skeletons/SkeletonTable"; import { RestrictedAccess } from "@components/ui/RestrictedAccess"; +import { usePortalElement } from "@hooks/usePortalElement"; import useFetchApi from "@utils/api"; import { ExternalLinkIcon } from "lucide-react"; import React, { lazy, Suspense } from "react"; @@ -23,6 +24,9 @@ export default function NetworkRoutes() { const { data: routes, isLoading } = useFetchApi("/routes"); const groupedRoutes = useGroupedRoutes({ routes }); + const { ref: headingRef, portalTarget } = + usePortalElement(); + return ( @@ -35,11 +39,7 @@ export default function NetworkRoutes() { icon={} /> -

- {groupedRoutes && groupedRoutes.length > 1 - ? `${groupedRoutes.length} Network Routes` - : "Network Routes"} -

+

Network Routes

Network routes allow you to access other networks like LANs and VPCs without installing NetBird on every resource. @@ -65,6 +65,7 @@ export default function NetworkRoutes() { isLoading={isLoading} groupedRoutes={groupedRoutes} routes={routes} + headingTarget={portalTarget} /> diff --git a/src/app/(dashboard)/peer/page.tsx b/src/app/(dashboard)/peer/page.tsx index 17b65090..3738cf42 100644 --- a/src/app/(dashboard)/peer/page.tsx +++ b/src/app/(dashboard)/peer/page.tsx @@ -23,6 +23,7 @@ import Separator from "@components/Separator"; import FullScreenLoading from "@components/ui/FullScreenLoading"; import LoginExpiredBadge from "@components/ui/LoginExpiredBadge"; import TextWithTooltip from "@components/ui/TextWithTooltip"; +import { getOperatingSystem } from "@hooks/useOperatingSystem"; import useRedirect from "@hooks/useRedirect"; import { IconCloudLock, IconInfoCircle } from "@tabler/icons-react"; import useFetchApi from "@utils/api"; @@ -54,15 +55,12 @@ import PeerProvider, { usePeer } from "@/contexts/PeerProvider"; import RoutesProvider from "@/contexts/RoutesProvider"; import { useLoggedInUser } from "@/contexts/UsersProvider"; import { useHasChanges } from "@/hooks/useHasChanges"; -import { getOperatingSystem } from "@/hooks/useOperatingSystem"; import { OperatingSystem } from "@/interfaces/OperatingSystem"; import type { Peer } from "@/interfaces/Peer"; import PageContainer from "@/layouts/PageContainer"; -import { AddExitNodeButton } from "@/modules/exit-node/AddExitNodeButton"; -import { useHasExitNodes } from "@/modules/exit-node/useHasExitNodes"; import useGroupHelper from "@/modules/groups/useGroupHelper"; -import AddRouteDropdownButton from "@/modules/peer/AddRouteDropdownButton"; -import PeerRoutesTable from "@/modules/peer/PeerRoutesTable"; +import { AccessiblePeersSection } from "@/modules/peer/AccessiblePeersSection"; +import { PeerNetworkRoutesSection } from "@/modules/peer/PeerNetworkRoutesSection"; export default function PeerPage() { const queryParameter = useSearchParams(); @@ -72,7 +70,7 @@ export default function PeerPage() { useRedirect("/peers", false, !peerId); return peer && !isLoading ? ( - + ) : ( @@ -133,7 +131,6 @@ function PeerOverview() { }; const { isUser } = useLoggedInUser(); - const hasExitNodes = useHasExitNodes(peer); return ( @@ -336,30 +333,19 @@ function PeerOverview() {
- - {isLinux && !isUser ? ( -
-
-
-
-

Network Routes

- - Access other networks without installing NetBird on every - resource. - -
-
-
- - -
-
-
- -
-
+ <> + + + ) : null} + + {peer?.id && ( + <> + + + + )}
); diff --git a/src/app/(dashboard)/posture-checks/page.tsx b/src/app/(dashboard)/posture-checks/page.tsx index 630159c8..7edafb36 100644 --- a/src/app/(dashboard)/posture-checks/page.tsx +++ b/src/app/(dashboard)/posture-checks/page.tsx @@ -5,6 +5,7 @@ import InlineLink from "@components/InlineLink"; import Paragraph from "@components/Paragraph"; import SkeletonTable from "@components/skeletons/SkeletonTable"; import { RestrictedAccess } from "@components/ui/RestrictedAccess"; +import { usePortalElement } from "@hooks/usePortalElement"; import useFetchApi from "@utils/api"; import { ExternalLinkIcon, ShieldCheck } from "lucide-react"; import React, { lazy, Suspense } from "react"; @@ -21,6 +22,9 @@ export default function PostureChecksPage() { const { data: postureChecks, isLoading } = useFetchApi("/posture-checks"); + const { ref: headingRef, portalTarget } = + usePortalElement(); + return ( @@ -38,17 +42,16 @@ export default function PostureChecksPage() { icon={} /> -

- {postureChecks && postureChecks.length > 1 - ? `${postureChecks.length} Posture Checks` - : "Posture Checks"} -

+

Posture Checks

Use posture checks to further restrict access in your network. Learn more about - + Posture Checks @@ -60,6 +63,7 @@ export default function PostureChecksPage() { }> diff --git a/src/app/(dashboard)/setup-keys/page.tsx b/src/app/(dashboard)/setup-keys/page.tsx index 42adf3df..08147118 100644 --- a/src/app/(dashboard)/setup-keys/page.tsx +++ b/src/app/(dashboard)/setup-keys/page.tsx @@ -5,6 +5,7 @@ import InlineLink from "@components/InlineLink"; import Paragraph from "@components/Paragraph"; import SkeletonTable from "@components/skeletons/SkeletonTable"; import { RestrictedAccess } from "@components/ui/RestrictedAccess"; +import { usePortalElement } from "@hooks/usePortalElement"; import useFetchApi from "@utils/api"; import { ExternalLinkIcon } from "lucide-react"; import React, { lazy, Suspense, useMemo } from "react"; @@ -38,6 +39,9 @@ export default function SetupKeys() { }); }, [setupKeys, groups]); + const { ref: headingRef, portalTarget } = + usePortalElement(); + return (
@@ -48,11 +52,7 @@ export default function SetupKeys() { icon={} /> -

- {setupKeys && setupKeys.length > 1 - ? `${setupKeys.length} Setup Keys` - : "Setup Keys"} -

+

Setup Keys

Setup keys are pre-authentication keys that allow to register new machines in your network. @@ -74,6 +74,7 @@ export default function SetupKeys() { }> diff --git a/src/app/(dashboard)/team/service-users/page.tsx b/src/app/(dashboard)/team/service-users/page.tsx index f280b826..20cf10db 100644 --- a/src/app/(dashboard)/team/service-users/page.tsx +++ b/src/app/(dashboard)/team/service-users/page.tsx @@ -5,6 +5,7 @@ import InlineLink from "@components/InlineLink"; import Paragraph from "@components/Paragraph"; import SkeletonTable from "@components/skeletons/SkeletonTable"; import { RestrictedAccess } from "@components/ui/RestrictedAccess"; +import { usePortalElement } from "@hooks/usePortalElement"; import { IconSettings2 } from "@tabler/icons-react"; import useFetchApi from "@utils/api"; import { ExternalLinkIcon } from "lucide-react"; @@ -22,6 +23,9 @@ export default function ServiceUsers() { "/users?service_user=true", ); + const { ref: headingRef, portalTarget } = + usePortalElement(); + return (
@@ -38,11 +42,7 @@ export default function ServiceUsers() { icon={} /> -

- {users && users.length > 1 - ? `${users.length} Service Users` - : "Service Users"} -

+

Service Users

Use service users to create API tokens and avoid losing automated access. @@ -61,7 +61,11 @@ export default function ServiceUsers() {
}> - +
diff --git a/src/app/(dashboard)/team/users/page.tsx b/src/app/(dashboard)/team/users/page.tsx index 3dd4aa33..9744224a 100644 --- a/src/app/(dashboard)/team/users/page.tsx +++ b/src/app/(dashboard)/team/users/page.tsx @@ -5,6 +5,7 @@ import InlineLink from "@components/InlineLink"; import Paragraph from "@components/Paragraph"; import SkeletonTable from "@components/skeletons/SkeletonTable"; import { RestrictedAccess } from "@components/ui/RestrictedAccess"; +import { usePortalElement } from "@hooks/usePortalElement"; import useFetchApi from "@utils/api"; import { ExternalLinkIcon, User2 } from "lucide-react"; import React, { lazy, Suspense } from "react"; @@ -19,6 +20,9 @@ export default function TeamUsers() { "/users?service_user=false", ); + const { ref: headingRef, portalTarget } = + usePortalElement(); + return (
@@ -35,7 +39,7 @@ export default function TeamUsers() { icon={} /> -

{users && users.length > 1 ? `${users.length} Users` : "Users"}

+

Users

Manage users and their permissions. Same-domain email users are added automatically on first sign-in. @@ -54,7 +58,11 @@ export default function TeamUsers() {
}> - +
diff --git a/src/assets/icons/NetBirdIcon.tsx b/src/assets/icons/NetBirdIcon.tsx index 310ffdbf..13fbfccc 100644 --- a/src/assets/icons/NetBirdIcon.tsx +++ b/src/assets/icons/NetBirdIcon.tsx @@ -5,9 +5,17 @@ import NetBirdLogo from "@/assets/netbird.svg"; type Props = { size?: number; + className?: string; }; -function NetBirdIcon({ size = 16 }: Props) { - return {"Netbird; +function NetBirdIcon({ size = 16, className }: Props) { + return ( + {"Netbird + ); } export default memo(NetBirdIcon); diff --git a/src/components/Badge.tsx b/src/components/Badge.tsx index 4fd009be..dde62ed5 100644 --- a/src/components/Badge.tsx +++ b/src/components/Badge.tsx @@ -2,7 +2,7 @@ import { cn } from "@utils/helpers"; import { cva, VariantProps } from "class-variance-authority"; import * as React from "react"; -type BadgeVariants = VariantProps; +export type BadgeVariants = VariantProps; interface Props extends React.HTMLAttributes, BadgeVariants { children: React.ReactNode; @@ -22,6 +22,9 @@ const variants = cva("", { purple: ["bg-purple-950/50 border-purple-500 border text-purple-500"], yellow: ["bg-yellow-950 border-yellow-500 border text-yellow-400"], gray: ["bg-nb-gray-930/60 border-nb-gray-800/40 text-nb-gray-300 border"], + grayer: [ + "bg-nb-gray-900/40 border-nb-gray-800/40 text-nb-gray-300 border", + ], "gray-ghost": [ "bg-nb-gray-900 border-nb-gray-800 text-nb-gray-300 border border-nb-gray-800/50", ], @@ -37,6 +40,7 @@ const variants = cva("", { "blue-darker": ["hover:bg-sky-800"], 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"], green: ["hover:bg-green-950/50"], netbird: ["hover:bg-netbird-950/50"], @@ -50,7 +54,7 @@ export default function Badge({ variant = "blue", useHover = false, ...props -}: Props) { +}: Readonly) { return (
; + +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 dark:data-[state=checked]:text-neutral-50", + ], + tableCell: [ + "dark:data-[state=unchecked]:bg-nb-gray-920 dark:border-nb-gray-800 dark:ring-offset-neutral-950 dark:focus-visible:ring-neutral-300 ", + "dark:data-[state=checked]:bg-netbird dark:data-[state=checked]:text-neutral-50", + ], + }, + }, +}); + const Checkbox = React.forwardRef< React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( + React.ComponentPropsWithoutRef & + CheckboxVariants +>(({ className, variant = "default", ...props }, ref) => (
{ + if (!a) return false; + const aFromDay = dayjs(a.from).format("YYYY-MM-DD"); + const aToDay = dayjs(a.to).format("YYYY-MM-DD"); + const bFromDay = dayjs(b.from).format("YYYY-MM-DD"); + const bToDay = dayjs(b.to).format("YYYY-MM-DD"); + return aFromDay === bFromDay && aToDay === bToDay; +}; + export function DatePickerWithRange({ className, value, onChange }: Props) { + const isActive = useMemo(() => { + return { + today: isEqualDateRange(value, defaultRanges.today), + yesterday: isEqualDateRange(value, defaultRanges.yesterday), + last14Days: isEqualDateRange(value, defaultRanges.last14Days), + lastMonth: isEqualDateRange(value, defaultRanges.lastMonth), + allTime: isEqualDateRange(value, defaultRanges.allTime), + }; + }, [value]); + + const displayDateValue = useMemo(() => { + if (!value) return "Select date range"; + + if (isActive.allTime) return "All Time"; + if (isActive.lastMonth) return "Last Month"; + if (isActive.last14Days) return "Last 14 Days"; + if (isActive.yesterday) return "Yesterday"; + if (isActive.today) return "Today"; + + if (!value.to) return dayjs(value.from).format("MMM DD, YYYY").toString(); + return `${dayjs(value.from).format("MMM DD, YYYY")} - ${dayjs( + value.to, + ).format("MMM DD, YYYY")}`; + }, [value, isActive]); + + const [calendarOpen, setCalendarOpen] = useState(false); + + const updateRangeAndClose = (range: DateRange) => { + setCalendarOpen(false); + onChange?.(range); + }; + return (
- + +
+
+ + + All Time + + } + active={isActive.allTime} + onClick={() => updateRangeAndClose(defaultRanges.allTime)} + /> +
+
+ updateRangeAndClose(defaultRanges.lastMonth)} + /> + updateRangeAndClose(defaultRanges.last14Days)} + /> + updateRangeAndClose(defaultRanges.yesterday)} + /> + updateRangeAndClose(defaultRanges.today)} + /> +
+
{ + let from = + range && range.from + ? dayjs(range.from).startOf("day").toDate() + : undefined; + let to = + range && range.to + ? dayjs(range.to).endOf("day").toDate() + : undefined; + if (!from && !to) { + onChange?.(undefined); + return; + } + onChange?.({ from, to }); + }} numberOfMonths={2} />
@@ -54,3 +161,25 @@ export function DatePickerWithRange({ className, value, onChange }: Props) {
); } + +type CalendarButtonProps = { + label: string | React.ReactNode; + onClick: () => void; + active?: boolean; +}; + +function CalendarButton({ label, onClick, active }: CalendarButtonProps) { + return ( + + ); +} diff --git a/src/components/DisableDarkReader.tsx b/src/components/DisableDarkReader.tsx new file mode 100644 index 00000000..6b51df62 --- /dev/null +++ b/src/components/DisableDarkReader.tsx @@ -0,0 +1,15 @@ +"use client"; + +import { useEffect } from "react"; + +export const DisableDarkReader = () => { + useEffect(() => { + try { + const lock = document.createElement("meta"); + lock.name = "darkreader-lock"; + document.head.appendChild(lock); + } catch (e) {} + }, []); + + return null; +}; diff --git a/src/components/FancyToggleSwitch.tsx b/src/components/FancyToggleSwitch.tsx index 1fb6f511..bc04d015 100644 --- a/src/components/FancyToggleSwitch.tsx +++ b/src/components/FancyToggleSwitch.tsx @@ -31,7 +31,7 @@ export default function FancyToggleSwitch({ value ? "border-nb-gray-800 bg-nb-gray-900/70" : "border-nb-gray-800 bg-nb-gray-900/30 hover:bg-nb-gray-900/40", - disabled && "opacity-30 pointer-events-none", + disabled && "opacity-50 pointer-events-none", )} >
diff --git a/src/components/FullTooltip.tsx b/src/components/FullTooltip.tsx index e21463ad..34967a38 100644 --- a/src/components/FullTooltip.tsx +++ b/src/components/FullTooltip.tsx @@ -4,6 +4,7 @@ import { TooltipProvider, TooltipTrigger, } from "@components/Tooltip"; +import { TooltipProps } from "@radix-ui/react-tooltip"; import { cn } from "@utils/helpers"; import React, { useState } from "react"; @@ -19,7 +20,9 @@ type Props = { align?: "end" | "center" | "start"; side?: "top" | "bottom" | "left" | "right"; keepOpen?: boolean; -}; + customOpen?: boolean; + customOnOpenChange?: React.Dispatch>; +} & TooltipProps; export default function FullTooltip({ children, content, @@ -32,6 +35,8 @@ export default function FullTooltip({ align = "center", side = "top", keepOpen = false, + customOpen, + customOnOpenChange, }: Props) { const [open, setOpen] = useState(!!keepOpen); @@ -42,7 +47,11 @@ export default function FullTooltip({ return !disabled ? ( - + {children && ( {hoverButton ? ( diff --git a/src/components/Input.tsx b/src/components/Input.tsx index e2e5230f..d865b599 100644 --- a/src/components/Input.tsx +++ b/src/components/Input.tsx @@ -74,9 +74,10 @@ const Input = React.forwardRef( )}
{icon}
@@ -99,9 +100,10 @@ const Input = React.forwardRef( />
{customSuffix}
diff --git a/src/components/Notification.tsx b/src/components/Notification.tsx index 073df755..a062cac6 100644 --- a/src/components/Notification.tsx +++ b/src/components/Notification.tsx @@ -16,6 +16,7 @@ export interface NotifyProps { duration?: number; icon?: React.ReactNode; backgroundColor?: string; + preventSuccessToast?: boolean; } interface NotificationProps extends NotifyProps { t: Toast; @@ -29,12 +30,15 @@ export default function Notification({ promise, loadingMessage, duration = 3500, + preventSuccessToast = false, }: NotificationProps) { const [error, setError] = useState(""); const [loading, setLoading] = useState(!!promise); const [toastDuration] = useState(duration); + const [preventSuccess, setPreventSuccess] = useState(false); + const closeToast = () => { setTimeout(() => { setLoading(false); @@ -47,6 +51,7 @@ export default function Notification({ if (promise) { promise .then(() => { + if (preventSuccessToast) setPreventSuccess(true); setLoading(false); closeToast(); }) @@ -66,7 +71,7 @@ export default function Notification({ return ( - {t.visible && ( + {t.visible && !preventSuccess && ( ) { + const { groups, dropdownOptions, setDropdownOptions, addDropdownOptions } = + useGroups(); const searchRef = React.useRef(null); const [inputRef, { width }] = useElementSize(); const [search, setSearch] = useState(""); @@ -48,9 +64,14 @@ export function PeerGroupSelector({ // Update dropdown options when groups change useEffect(() => { if (!groups) return; - const sortedGroups = sortBy([...groups], "name") as Group[]; + const sortedGroups = sortBy([...groups], "name"); + const clientGroups = dropdownOptions.filter( + (group) => group.keepClientState, + ); let uniqueGroups = unionBy(sortedGroups, dropdownOptions, "name"); + uniqueGroups = unionBy(clientGroups, uniqueGroups, "name"); + uniqueGroups = hideAllGroup ? uniqueGroups.filter((group) => group.name !== "All") : uniqueGroups; @@ -75,16 +96,10 @@ export function PeerGroupSelector({ const groupPeers: GroupPeer[] | undefined = (group?.peers as GroupPeer[]) || []; - if (peer) { - groupPeers && - groupPeers.push({ id: peer?.id as string, name: peer?.name as string }); - } + if (peer) groupPeers?.push({ id: peer?.id as string, name: peer?.name }); if (!group && !option) { - setDropdownOptions((previous) => [ - ...previous, - { name: name, peers: groupPeers }, - ]); + addDropdownOptions([{ name: name, peers: groupPeers }]); } if (max == 1 && values.length == 1) { @@ -137,6 +152,18 @@ export function PeerGroupSelector({ } }, [open, dropdownOptions]); + const onPeerAssignmentChange = (oldGroup: Group, newGroup: Group) => { + const filtered = values.filter((group) => group.name !== oldGroup.name); + const union = unionBy([newGroup], filtered, "name"); + onChange(union); + }; + + const sortedDropdownOptions = useSortedDropdownOptions( + dropdownOptions, + values, + open, + ); + return ( @@ -275,38 +336,64 @@ export function PeerGroupSelector({ )} - {dropdownOptions.slice(0, slice).map((option) => { + {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} - value={option.name + option.id} - onSelect={() => { - if (peer != undefined && option.name == "All") return; // Prevent removing the "All" group - toggleGroupByName(option.name); - searchRef.current?.focus(); - }} - onClick={(e) => e.preventDefault()} > -
- - {folderIcon} - - -
- -
{ + 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()} > - {peerIcon} - {option.peers_count || 0} Peer(s) - -
- +
+ +
+ +
+ {option?.id && showRoutes && ( + + )} + +
+ {peerIcon} + {peerCount} Peer(s) + +
+
+ + ); })} diff --git a/src/components/PortSelector.tsx b/src/components/PortSelector.tsx index 6174ad89..0a0e306c 100644 --- a/src/components/PortSelector.tsx +++ b/src/components/PortSelector.tsx @@ -73,6 +73,7 @@ export function PortSelector({ "border border-neutral-200 dark:border-nb-gray-700 justify-between py-2 px-3", "rounded-md bg-white text-sm dark:bg-nb-gray-900/40 flex dark:text-neutral-400/70 text-neutral-500 cursor-pointer hover:dark:bg-nb-gray-900/50", )} + data-cy={"port-selector"} disabled={disabled} ref={inputRef} > @@ -138,6 +139,7 @@ export function PortSelector({ "bg-transparent text-sm outline-none focus-visible:outline-none ring-0 focus-visible:ring-0", "dark:placeholder:text-neutral-500 font-light placeholder:text-neutral-500 pl-10", )} + data-cy={"port-input"} typeof={"number"} ref={searchRef} value={search} diff --git a/src/components/ScrollArea.tsx b/src/components/ScrollArea.tsx index a1bc67a8..1f5551aa 100644 --- a/src/components/ScrollArea.tsx +++ b/src/components/ScrollArea.tsx @@ -16,8 +16,9 @@ const ScrollArea = React.forwardRef< diff --git a/src/components/Tabs.tsx b/src/components/Tabs.tsx index 62590524..04b3b5b0 100644 --- a/src/components/Tabs.tsx +++ b/src/components/Tabs.tsx @@ -57,15 +57,15 @@ const TabsList = React.forwardRef< )} {...props} > - -
{props.children}
- -
+ +
{props.children}
+ +
)); TabsList.displayName = TabsPrimitive.List.displayName; diff --git a/src/components/Textarea.tsx b/src/components/Textarea.tsx index e989b099..34ecf774 100644 --- a/src/components/Textarea.tsx +++ b/src/components/Textarea.tsx @@ -1,11 +1,16 @@ import Paragraph from "@components/Paragraph"; import { cn } from "@utils/helpers"; -import { cva } from "class-variance-authority"; +import { cva, VariantProps } from "class-variance-authority"; import * as React from "react"; +type TextareaVariants = VariantProps; + export interface InputProps - extends React.TextareaHTMLAttributes { + extends React.TextareaHTMLAttributes, + TextareaVariants { error?: string; + customElement?: React.ReactNode; + resize?: boolean; } const inputVariants = cva("", { @@ -15,6 +20,10 @@ const inputVariants = cva("", { "dark:bg-nb-gray-900 dark:placeholder:text-neutral-400/70 placeholder:text-neutral-500 border-neutral-200 dark:border-nb-gray-700", "ring-offset-neutral-200/20 dark:ring-offset-neutral-950/50 dark:focus-visible:ring-neutral-500/20 focus-visible:ring-neutral-300/10", ], + darker: [ + "dark:bg-nb-gray-900/40 dark:placeholder:text-neutral-400/70 placeholder:text-neutral-500 border-neutral-200 dark:border-nb-gray-900", + "ring-offset-neutral-200/20 dark:ring-offset-neutral-950/50 dark:focus-visible:ring-neutral-500/20 focus-visible:ring-neutral-300/10", + ], error: [ "dark:bg-red-950/30 dark:placeholder:text-red-400/70 placeholder:text-red-500 border-red-500 dark:border-red-500 text-red-500", "ring-offset-red-500/10 dark:ring-offset-red-500/10 dark:focus-visible:ring-red-500/10 focus-visible:ring-red-500/10", @@ -24,7 +33,10 @@ const inputVariants = cva("", { }); const Textarea = React.forwardRef( - ({ className, error, ...props }, ref) => { + ( + { className, variant = "default", resize, customElement, error, ...props }, + ref, + ) => { return ( <>
@@ -32,14 +44,20 @@ const Textarea = React.forwardRef( ref={ref} {...props} className={cn( - inputVariants({ variant: error ? "error" : "default" }), - "flex w-full rounded-md bg-white px-3 py-2 text-sm file:bg-transparent file:text-sm file:font-medium focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50 ", + inputVariants({ variant: error ? "error" : variant }), + "flex w-full min-h-[42px] rounded-md bg-white px-3 pb-3 pt-2.5 text-sm file:bg-transparent file:text-sm file:font-medium focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50 ", "file:border-0", "focus-visible:ring-2 focus-visible:ring-offset-2", "border", + "overflow-hidden", className, + resize ? "resize" : "resize-none", )} + style={{ + height: variant === "darker" ? "42px" : "auto", + }} /> + {customElement && customElement}
{error && ( diff --git a/src/components/modal/Modal.tsx b/src/components/modal/Modal.tsx index d93c7f36..8f2abbba 100644 --- a/src/components/modal/Modal.tsx +++ b/src/components/modal/Modal.tsx @@ -31,8 +31,9 @@ const ModalOverlay = React.forwardRef< Close diff --git a/src/components/skeletons/SkeletonTable.tsx b/src/components/skeletons/SkeletonTable.tsx index 8c312320..10795142 100644 --- a/src/components/skeletons/SkeletonTable.tsx +++ b/src/components/skeletons/SkeletonTable.tsx @@ -1,4 +1,5 @@ import { cn } from "@utils/helpers"; +import * as React from "react"; import Skeleton from "react-loading-skeleton"; type Props = { @@ -8,24 +9,10 @@ type Props = { export default function SkeletonTable({ withHeader = true }: Props) { return (
- {withHeader && ( -
-
- - - - -
- -
- )} + {withHeader && }
@@ -60,3 +47,28 @@ export function TableSkeletonRow({ odd = false }: RowProps) {
); } + +type SkeletonTableHeaderProps = { + className?: string; +}; + +export const SkeletonTableHeader = ({ + className, +}: SkeletonTableHeaderProps) => { + return ( +
+
+ + + + +
+ +
+ ); +}; diff --git a/src/components/table/DataTable.tsx b/src/components/table/DataTable.tsx index 54f026d8..56935b3d 100644 --- a/src/components/table/DataTable.tsx +++ b/src/components/table/DataTable.tsx @@ -11,6 +11,7 @@ import { TableHead, TableHeader, TableRow, + TableWrapper, } from "@components/table/Table"; import NoResults from "@components/ui/NoResults"; import { @@ -30,6 +31,7 @@ import { PaginationState, Row, RowSelectionState, + SortingFn, SortingState, Table as TanStackTable, useReactTable, @@ -54,6 +56,9 @@ declare module "@tanstack/table-core" { interface FilterMeta { itemRank: RankingInfo; } + interface SortingFns { + checkbox: SortingFn; + } } const fuzzyFilter: FilterFn = (row, columnId, value, addMeta) => { @@ -100,6 +105,20 @@ const arrIncludesSomeExact: FilterFn = ( return value.some((val) => val === rowValue); }; +const checkboxSort: SortingFn = (rowA, rowB, columnId) => { + const valueA = + columnId === "select" ? rowA.getIsSelected() : rowA.getValue(columnId); + const valueB = + columnId === "select" ? rowB.getIsSelected() : rowB.getValue(columnId); + if (valueA && !valueB) { + return -1; + } + if (!valueA && valueB) { + return 1; + } + return 0; +}; + interface DataTableProps { columns: ColumnDef[]; data: TData[] | undefined; @@ -125,7 +144,7 @@ interface DataTableProps { wrapperClassName?: string; tableClassName?: string; searchClassName?: string; - showSearch?: boolean; + showSearchAndFilters?: boolean; rightSide?: (table: TanStackTable) => React.ReactNode; manualPagination?: boolean; showHeader?: boolean; @@ -134,6 +153,16 @@ interface DataTableProps { useRowId?: boolean; headingTarget?: HTMLHeadingElement | null; showResetFilterButton?: boolean; + onFilterReset?: () => void; + wrapperComponent?: React.ElementType; + wrapperProps?: any; + keepStateInLocalStorage?: boolean; + paginationPaddingClassName?: string; + tableCellClassName?: string; + initialSelectionState?: RowSelectionState; + initialPageSize?: number; + uniqueKey?: string; + resetRowSelectionOnSearch?: boolean; } export function DataTable(props: DataTableProps) { @@ -173,22 +202,41 @@ export function DataTableContent({ useRowId, headingTarget, showResetFilterButton = true, + onFilterReset, + showSearchAndFilters = true, + wrapperProps, + wrapperComponent, + keepStateInLocalStorage = true, + paginationPaddingClassName, + tableCellClassName, + initialPageSize = 10, + uniqueKey, + resetRowSelectionOnSearch = true, }: DataTableProps) { const path = usePathname(); + const [columnFilters, setColumnFilters] = useLocalStorage( - "netbird-table-columns" + path, + `netbird-table-columns${uniqueKey ? "/" + (uniqueKey as string) : path}`, [], + keepStateInLocalStorage, ); const [globalSearch, setGlobalSearch] = useLocalStorage( - "netbird-table-search" + path, + `netbird-table-search${uniqueKey ? "/" + (uniqueKey as string) : path}`, "", + keepStateInLocalStorage, ); const [paginationState, setPaginationState] = - useLocalStorage("netbird-table-pagination" + path, { - pageIndex: 0, - pageSize: 10, - }); + useLocalStorage( + `netbird-table-pagination${ + uniqueKey ? "/" + (uniqueKey as string) : path + }`, + { + pageIndex: 0, + pageSize: 10, + }, + keepStateInLocalStorage, + ); const hasInitialData = !!(data && data.length > 0); @@ -216,9 +264,12 @@ export function DataTableContent({ initialState: { pagination: { pageIndex: 0, - pageSize: 10, + pageSize: initialPageSize || 10, }, }, + sortingFns: { + checkbox: checkboxSort, + }, getRowId: useRowId ? (row) => row.id : undefined, onRowSelectionChange: setRowSelection, onSortingChange: setSorting, @@ -250,12 +301,18 @@ export function DataTableContent({ setColumnFilters([]); setGlobalSearch(""); setRowSelection?.({}); + onFilterReset?.(); }; return (
- {!minimal && ( -
+ {showSearchAndFilters && ( +
({ setGlobalSearch={(val) => { table.setPageIndex(0); setGlobalSearch(val); - setRowSelection?.({}); + resetRowSelectionOnSearch && setRowSelection?.({}); }} placeholder={searchPlaceholder} /> @@ -277,164 +334,179 @@ export function DataTableContent({
)} + {aboveTable && aboveTable(table)} - {!hasInitialData && !isLoading && getStartedCard} - {hasInitialData && !isLoading && ( - - {showHeader && as == "table" && ( - - {table.getHeaderGroups().map((headerGroup) => ( - - {headerGroup.headers.map((header) => { - return ( - - {header.isPlaceholder - ? null - : flexRender( - header.column.columnDef.header, - header.getContext(), - )} - - ); - })} - - ))} - - )} + {getStartedCard} + + )} - + - + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + return ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext(), + )} + + ); + })} + + ))} + + )} + + - {table.getRowModel().rows?.length ? ( - table.getRowModel().rows.map((row) => ( - - <> - { - if (renderExpandedRow) { - e.preventDefault(); - e.stopPropagation(); - setAccordion((prev) => { - if (prev?.includes(row.original.id)) { - return prev.filter( - (item) => item !== row.original.id, - ); - } else { - return [...(prev ?? []), row.original.id]; - } - }); + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + + <> + - <> - {row.getVisibleCells().map((cell) => ( - { - onRowClick && onRowClick(row, cell.column.id); - }} - > -
{ + if (renderExpandedRow) { + e.preventDefault(); + e.stopPropagation(); + setAccordion((prev) => { + if (prev?.includes(row.original.id)) { + return prev.filter( + (item) => item !== row.original.id, + ); + } else { + return [...(prev ?? []), row.original.id]; } - >
-
- {flexRender( - cell.column.columnDef.cell, - cell.getContext(), - )} -
-
- ))} - -
+ }); + } + }} + > + <> + {row.getVisibleCells().map((cell) => ( + { + onRowClick && onRowClick(row, cell.column.id); + }} + > +
+
+ {flexRender( + cell.column.columnDef.cell, + cell.getContext(), + )} +
+
+ ))} + +
- {renderExpandedRow && ( - - - + - {renderExpandedRow(row.original)} - - - - )} - -
- )) - ) : ( - - - - - - )} -
-
-
+ + {renderExpandedRow(row.original)} + + + + )} + + + )) + ) : ( + + + + + + )} + + + + )}
- +
- + +
); } diff --git a/src/components/table/DataTableHeadingPortal.tsx b/src/components/table/DataTableHeadingPortal.tsx index d503fbb2..d5bb365b 100644 --- a/src/components/table/DataTableHeadingPortal.tsx +++ b/src/components/table/DataTableHeadingPortal.tsx @@ -6,24 +6,20 @@ import { createPortal } from "react-dom"; type Props = { table: Table | null; headingTarget?: HTMLHeadingElement | null; - text: string; }; + export const DataTableHeadingPortal = function ({ table, headingTarget, - text = "Items", }: Props) { const hasMounted = useRef(false); if (!headingTarget) return; - - if (!hasMounted.current) { - headingTarget.innerHTML = ""; - hasMounted.current = true; - } + if (!hasMounted.current) hasMounted.current = true; const totalItems = table?.getPreFilteredRowModel().rows.length; const filteredItems = table?.getFilteredRowModel().rows.length; + if (!totalItems || totalItems == 1) return; const hasAnyFiltersActive = table && @@ -32,14 +28,16 @@ export const DataTableHeadingPortal = function ({ table?.getState().globalFilter === "" ); + const portalContainer = document.createElement("span"); + headingTarget.prepend(portalContainer); + return createPortal( , - headingTarget, + portalContainer, ); }; @@ -47,27 +45,20 @@ type HeadingProps = { hasAnyFilterActive: boolean | null; filteredItems?: number; totalItems?: number; - text: string; }; const Heading = ({ hasAnyFilterActive, filteredItems, totalItems, - text, }: HeadingProps) => { - if (!totalItems || totalItems == 1) { - return text; - } - if (hasAnyFilterActive) { return ( <> {filteredItems} of {totalItems}{" "} - {text} ); } - return `${totalItems} ${text}`; + return `${totalItems} `; }; diff --git a/src/components/table/DataTablePagination.tsx b/src/components/table/DataTablePagination.tsx index f5a62e84..03d57753 100644 --- a/src/components/table/DataTablePagination.tsx +++ b/src/components/table/DataTablePagination.tsx @@ -1,5 +1,6 @@ import ButtonGroup from "@components/ButtonGroup"; import { Table } from "@tanstack/react-table"; +import { cn } from "@utils/helpers"; import { ChevronLeft, ChevronRight, @@ -10,11 +11,13 @@ import { interface DataTablePaginationProps { table: Table; text?: string; + paginationPadding?: string; } export function DataTablePagination({ table, text = "rows", + paginationPadding = "px-8 py-8", }: DataTablePaginationProps) { const allRows = table.getFilteredRowModel().rows.length; const rowsPerPage = table.getState().pagination.pageSize; @@ -25,8 +28,8 @@ export function DataTablePagination({ const pageCount = table.getPageCount(); return pageCount > 1 ? ( -
-
+
+
Showing{" "} {showingFrom} to {showingTo} diff --git a/src/components/table/Table.tsx b/src/components/table/Table.tsx index de18148d..0cbf5be1 100644 --- a/src/components/table/Table.tsx +++ b/src/components/table/Table.tsx @@ -1,6 +1,25 @@ import { cn } from "@utils/helpers"; import * as React from "react"; +type TableWrapperProps = { + wrapperComponent?: React.ElementType; + wrapperProps?: any; + children: React.ReactNode; +}; + +const TableWrapper = ({ + wrapperComponent, + children, + wrapperProps, +}: TableWrapperProps) => { + if (!wrapperComponent) return <>{children}; + return React.createElement( + wrapperComponent, + wrapperProps ? wrapperProps : {}, + children, + ); +}; + type TableProps = { minimal?: boolean; }; @@ -164,4 +183,5 @@ export { TableHead, TableHeader, TableRow, + TableWrapper, }; diff --git a/src/components/ui/AccessControlGroupCount.tsx b/src/components/ui/AccessControlGroupCount.tsx new file mode 100644 index 00000000..7d01c009 --- /dev/null +++ b/src/components/ui/AccessControlGroupCount.tsx @@ -0,0 +1,67 @@ +import FullTooltip from "@components/FullTooltip"; +import useFetchApi from "@utils/api"; +import { uniqBy } from "lodash"; +import { RouteIcon } from "lucide-react"; +import * as React from "react"; +import { useMemo } from "react"; +import Skeleton from "react-loading-skeleton"; +import { Route } from "@/interfaces/Route"; + +type Props = { + group_id: string; +}; +export const AccessControlGroupCount = ({ group_id }: Props) => { + const { data, isLoading } = useFetchApi("/routes"); + + const routes = useMemo(() => { + const routes = data?.filter((route) => { + const groups = route?.access_control_groups; + if (!groups) return false; + return groups.includes(group_id); + }); + return uniqBy(routes, "network_id"); + }, [data, group_id]); + + if (isLoading) return ; + + return routes && routes.length > 0 ? ( + + {routes.map((route) => { + const domains = route?.domains; + + return ( +
+ + {route.network_id} + + {domains ? ( + {domains.join(", ")} + ) : ( + + {route.network} + + )} +
+ ); + })} +
+ } + > +
+ + {routes.length} Route(s) +
+ + ) : null; +}; diff --git a/src/components/ui/GroupBadge.tsx b/src/components/ui/GroupBadge.tsx index 141f2a9a..b3e7a38f 100644 --- a/src/components/ui/GroupBadge.tsx +++ b/src/components/ui/GroupBadge.tsx @@ -7,10 +7,11 @@ import { Group } from "@/interfaces/Group"; type Props = { group: Group; - onClick?: () => void; + onClick?: (e: React.MouseEvent) => void; showX?: boolean; children?: React.ReactNode; className?: string; + showNewBadge?: boolean; }; export default function GroupBadge({ onClick, @@ -18,25 +19,41 @@ export default function GroupBadge({ showX = false, children, className, + showNewBadge = false, }: Props) { + const isNew = !group?.id; + return ( { e.preventDefault(); - onClick?.(); + onClick?.(e); }} > + {children} + {isNew && showNewBadge && ( + + NEW + + )} + {showX && ( )} diff --git a/src/components/ui/GroupBadgeWithEditPeers.tsx b/src/components/ui/GroupBadgeWithEditPeers.tsx new file mode 100644 index 00000000..0c050f49 --- /dev/null +++ b/src/components/ui/GroupBadgeWithEditPeers.tsx @@ -0,0 +1,124 @@ +import Badge from "@components/Badge"; +import TextWithTooltip from "@components/ui/TextWithTooltip"; +import { cn } from "@utils/helpers"; +import { EyeIcon, FolderGit2, SquarePen } from "lucide-react"; +import * as React from "react"; +import { useMemo, useState } from "react"; +import { useGroups } from "@/contexts/GroupsProvider"; +import { Group } from "@/interfaces/Group"; +import { AssignPeerToGroupModal } from "@/modules/groups/AssignPeerToGroupModal"; + +type Props = { + group: Group; + className?: string; + showNewBadge?: boolean; + showPeerCount?: boolean; + useSave?: boolean; + onPeerAssignmentChange?: (oldGroup: Group, newGroup: Group) => void; +}; + +export default function GroupBadgeWithEditPeers({ + group, + className, + showNewBadge = false, + useSave = true, + onPeerAssignmentChange, +}: Readonly) { + const isNew = !group?.id; + const [editGroupPeersModal, setEditGroupPeersModal] = useState(false); + const { dropdownOptions, addDropdownOptions, updateGroupDropdown } = + useGroups(); + + const currentGroup = useMemo(() => { + return dropdownOptions?.find((g) => g.name === group?.name); + }, [group, dropdownOptions]); + + const peerCount = + currentGroup?.peers?.length ?? currentGroup?.peers_count ?? 0; + + const updateGroupOptions = (g: Group) => { + updateGroupDropdown(group.name, g); + onPeerAssignmentChange?.(group, g); + }; + + const isAllGroup = currentGroup?.name === "All"; + + return ( + <> + {currentGroup && editGroupPeersModal && ( + updateGroupOptions(g)} + open={editGroupPeersModal} + setOpen={setEditGroupPeersModal} + /> + )} + + { + if (!currentGroup) return; + e.stopPropagation(); + setEditGroupPeersModal(true); + }} + > +
+
+ + + {isNew && showNewBadge && ( + + NEW + + )} +
+ + + + {peerCount} + {" "} + Peers{" "} + + {isAllGroup ? ( + + ) : ( + + )} + +
+
+ + ); +} diff --git a/src/components/ui/InputDomain.tsx b/src/components/ui/InputDomain.tsx index 3db878a6..36549def 100644 --- a/src/components/ui/InputDomain.tsx +++ b/src/components/ui/InputDomain.tsx @@ -70,6 +70,7 @@ export default function InputDomain({ customPrefix={} placeholder={"e.g., example.com"} maxWidthClass={"w-full"} + data-cy={"domain-input"} value={name} error={domainError} onChange={handleNameChange} diff --git a/src/components/ui/NoResults.tsx b/src/components/ui/NoResults.tsx index ff9de0dd..ab6d1349 100644 --- a/src/components/ui/NoResults.tsx +++ b/src/components/ui/NoResults.tsx @@ -1,4 +1,5 @@ import Paragraph from "@components/Paragraph"; +import { cn } from "@utils/helpers"; import { FilterX } from "lucide-react"; import React from "react"; import Skeleton from "react-loading-skeleton"; @@ -8,23 +9,25 @@ type Props = { title?: string; description?: string; children?: React.ReactNode; + className?: string; }; export default function NoResults({ icon, title = "Could not find any results", description = "We couldn't find any results. Please try a different search term or change your filters.", children, + className, }: Props) { return ( -
+
@@ -33,7 +36,7 @@ export default function NoResults({
-
+
) { + return ( +
+ +
+
+
+ + + + + +
+
+
+
+ {icon || } +
+
+

{title}

+ + {description} + + {children} +
+
+
+
+ ); +} diff --git a/src/components/ui/PeerBadge.tsx b/src/components/ui/PeerBadge.tsx index 8ebc2508..7807627c 100644 --- a/src/components/ui/PeerBadge.tsx +++ b/src/components/ui/PeerBadge.tsx @@ -1,15 +1,84 @@ -import Badge from "@components/Badge"; -import { MonitorSmartphoneIcon } from "lucide-react"; +import Badge, { BadgeVariants } from "@components/Badge"; +import { cn } from "@utils/helpers"; +import { EyeIcon, MonitorSmartphoneIcon, SquarePen } from "lucide-react"; import * as React from "react"; +import { useMemo, useState } from "react"; +import { useGroups } from "@/contexts/GroupsProvider"; +import { Group } from "@/interfaces/Group"; +import { AssignPeerToGroupModal } from "@/modules/groups/AssignPeerToGroupModal"; type Props = { children?: React.ReactNode; -} & React.HTMLAttributes; -export default function PeerBadge({ children }: Props) { + group?: Group; + useSave?: boolean; + onAssignmentChange?: (group: Group) => void; +} & React.HTMLAttributes & + BadgeVariants; +export default function PeerBadge({ + children, + group, + variant = "gray", + className, + useSave = true, + onAssignmentChange, +}: Props) { + const [editGroupPeersModal, setEditGroupPeersModal] = useState(false); + + const { dropdownOptions, addDropdownOptions } = 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 updateGroupOptions = (g: Group) => { + addDropdownOptions([g]); + onAssignmentChange && onAssignmentChange(g); + }; + return ( - - - {children} - + <> + {currentGroup && editGroupPeersModal && ( + updateGroupOptions(g)} + open={editGroupPeersModal} + setOpen={setEditGroupPeersModal} + /> + )} + + { + if (!currentGroup) return; + e.stopPropagation(); + setEditGroupPeersModal(true); + }} + useHover={!!currentGroup} + > + {!currentGroup && } + {currentGroup ? <>{peerCount} Peer(s) : children} + + {currentGroup && ( + <> + {currentGroup.name == "All" ? ( + + ) : ( + + )} + + )} + + ); } diff --git a/src/components/ui/PolicyDirection.tsx b/src/components/ui/PolicyDirection.tsx index a1efd80a..f19c1722 100644 --- a/src/components/ui/PolicyDirection.tsx +++ b/src/components/ui/PolicyDirection.tsx @@ -1,12 +1,13 @@ import Badge from "@components/Badge"; -import {cn} from "@utils/helpers"; -import React, {useEffect} from "react"; +import { cn } from "@utils/helpers"; +import React, { useEffect } from "react"; import LongArrowLeftIcon from "@/assets/icons/LongArrowLeftIcon"; type Props = { disabled?: boolean; value: Direction; onChange: (value: Direction) => void; + className?: string; }; export type Direction = "bi" | "in" | "out"; @@ -15,6 +16,7 @@ export default function PolicyDirection({ disabled = false, value, onChange, + className, }: Props) { const toggleIn = () => { if (value == "in") { @@ -40,6 +42,14 @@ export default function PolicyDirection({ } }; + const toggleDirection = () => { + if (value == "bi") { + onChange("in"); + } else { + onChange("bi"); + } + }; + useEffect(() => { if (disabled) onChange("bi"); // eslint-disable-next-line react-hooks/exhaustive-deps @@ -48,15 +58,17 @@ export default function PolicyDirection({ return (
void; dropdownOptions: Group[]; setDropdownOptions: React.Dispatch>; + addDropdownOptions: (options: Group[]) => void; isLoading: boolean; + createOrUpdate: (group: Group) => Promise; + reset: () => void; + updateGroupDropdown: (oldGroupName: string, newGroup: Group) => void; }, ); @@ -31,12 +37,82 @@ export default function GroupsProvider({ children }: Props) { export function GroupsProviderContent({ children }: Props) { const { data: groups, mutate, isLoading } = useFetchApi("/groups"); + const groupRequest = useApiCall("/groups", true); const [dropdownOptions, setDropdownOptions] = useState([]); const refresh = () => { if (groups && !isLoading) mutate().then(); }; + const reset = () => { + mutate(); + setDropdownOptions([]); + addDropdownOptions(groups || []); + }; + + const addDropdownOptions = (options: Group[]) => { + setDropdownOptions((prev) => { + let union = unionBy(options, prev, "name"); + return sortBy( + union.map((item) => + merge({}, prev.find((p) => p.name === item.name) || {}, item), + ), + "name", + ); + }); + }; + + const updateGroupDropdown = (oldGroupName: string, newGroup: Group) => { + setDropdownOptions((prev) => { + let updated = prev.map((g) => { + if (g.name === oldGroupName) { + return newGroup; + } + return g; + }); + return sortBy(updated, "name"); + }); + }; + + // Update dropdown options when groups change + useEffect(() => { + if (!groups) return; + const sortedGroups = sortBy([...groups], "name"); + const dropdownGroups = dropdownOptions.filter((g) => g.keepClientState); + const union = unionBy(dropdownGroups, sortedGroups, "name"); + addDropdownOptions(union); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [groups]); + + const createOrUpdate = async (group: Group) => { + let peers = group?.peers?.map((p) => { + let isString = typeof p === "string"; + if (isString) return p; + let peer = p as Peer; + return peer.id; + }) as string[]; + + if (group.name === "All") return Promise.resolve(group); + + const groupID = + group?.id ?? groups?.find((g) => g.name === group.name)?.id ?? undefined; + + if (groupID) { + return groupRequest.put( + { + name: group.name, + peers: peers, + }, + `/${group.id}`, + ); + } else { + return groupRequest.post({ + name: group.name, + peers: peers, + }); + } + }; + return ( {children} diff --git a/src/contexts/PoliciesProvider.tsx b/src/contexts/PoliciesProvider.tsx index a9b4b846..aed16c52 100644 --- a/src/contexts/PoliciesProvider.tsx +++ b/src/contexts/PoliciesProvider.tsx @@ -15,12 +15,15 @@ const PoliciesContext = React.createContext( onSuccess?: (p: Policy) => void, message?: string, ) => void; + createPolicy: (policy: Policy) => Promise; }, ); export default function PoliciesProvider({ children }: Props) { const request = useApiCall("/policies"); + const createPolicy = async (policy: Policy) => request.post(policy); + const updatePolicy = async ( policy: Policy, toUpdate: Partial, @@ -29,9 +32,8 @@ export default function PoliciesProvider({ children }: Props) { ) => { notify({ title: "Access Control Policy " + policy.name, - description: message - ? message - : "The access control policy was successfully updated", + description: + message || "The access control policy was successfully updated", promise: request .put( { @@ -55,7 +57,7 @@ export default function PoliciesProvider({ children }: Props) { }; return ( - + {children} ); diff --git a/src/contexts/RoutesProvider.tsx b/src/contexts/RoutesProvider.tsx index 9ae1095e..219bf1f3 100644 --- a/src/contexts/RoutesProvider.tsx +++ b/src/contexts/RoutesProvider.tsx @@ -56,6 +56,7 @@ export default function RoutesProvider({ children }: Props) { metric: toUpdate.metric ?? route.metric ?? 9999, masquerade: toUpdate.masquerade ?? route.masquerade ?? true, groups: toUpdate.groups ?? route.groups ?? [], + access_control_groups: toUpdate.access_control_groups ?? undefined, }, `/${route.id}`, ) @@ -90,6 +91,7 @@ export default function RoutesProvider({ children }: Props) { metric: route.metric || 9999, masquerade: route.masquerade, groups: route.groups || [], + access_control_groups: route?.access_control_groups || undefined, }) .then((route) => { mutate("/routes"); diff --git a/src/hooks/useAutosizeTextArea.ts b/src/hooks/useAutosizeTextArea.ts new file mode 100644 index 00000000..b6a6ab40 --- /dev/null +++ b/src/hooks/useAutosizeTextArea.ts @@ -0,0 +1,21 @@ +import { useEffect } from "react"; + +// Updates the height of a