From 61e11d37404958e8cbd7b577da1312f8d18b38e2 Mon Sep 17 00:00:00 2001 From: Eduard Gert Date: Fri, 21 Feb 2025 15:53:40 +0100 Subject: [PATCH] Apply recent cloud changes (#447) * Add resource description, add single resource for acl, add icons for group badges, add inactivity expiration * Add extra dns labels, remove routing restriction --- src/app/(dashboard)/peer/page.tsx | 136 ++++--- src/app/(dashboard)/team/user/page.tsx | 17 +- src/assets/icons/CircleIcon.tsx | 2 +- src/assets/icons/EntraIcon.tsx | 39 ++ src/assets/icons/GoogleIcon.tsx | 31 ++ src/assets/icons/JWTIcon.tsx | 36 ++ src/assets/icons/OktaIcon.tsx | 18 + src/components/Card.tsx | 80 +++- src/components/CopyToClipboardText.tsx | 32 +- src/components/DropdownInfoText.tsx | 8 +- src/components/FancyToggleSwitch.tsx | 84 +++- src/components/FullTooltip.tsx | 12 +- src/components/PeerGroupSelector.tsx | 385 ++++++++++++------ src/components/PeerSelector.tsx | 92 +++-- src/components/Radio.tsx | 71 ++++ src/components/Separator.tsx | 6 +- src/components/ToggleSwitch.tsx | 49 ++- src/components/Tooltip.tsx | 8 +- src/components/VerticalTabs.tsx | 2 +- src/components/VirtualScrollAreaList.tsx | 18 +- src/components/ui/GroupBadge.tsx | 13 +- src/components/ui/GroupBadgeIcon.tsx | 32 ++ src/components/ui/LoginExpiredBadge.tsx | 4 +- src/components/ui/ResourceBadge.tsx | 58 +++ src/components/ui/SmallBadge.tsx | 39 ++ src/components/ui/TextWithTooltip.tsx | 2 + src/contexts/PeerProvider.tsx | 42 +- src/contexts/UsersProvider.tsx | 6 +- src/hooks/useExpirationState.tsx | 33 ++ src/hooks/usePlaceholders.tsx | 1 + src/hooks/useTimeFormatter.tsx | 63 +++ src/interfaces/Account.ts | 2 + src/interfaces/Group.ts | 9 +- src/interfaces/Peer.ts | 2 + src/interfaces/Policy.ts | 7 + src/interfaces/PostureCheck.ts | 44 +- src/interfaces/SetupKey.ts | 1 + .../access-control/AccessControlModal.tsx | 11 +- .../table/AccessControlActiveCell.tsx | 3 + .../table/AccessControlDestinationsCell.tsx | 35 +- .../access-control/useAccessControl.ts | 12 +- src/modules/common-table-rows/LastTimeRow.tsx | 2 +- .../exit-node/ExitNodeDropdownButton.tsx | 7 +- src/modules/groups/GroupSelector.tsx | 8 +- src/modules/groups/useGroupIdentification.ts | 24 ++ src/modules/networks/NetworkModal.tsx | 7 +- .../misc/NetworkInformationSquare.tsx | 19 +- .../networks/misc/NetworkNavigation.tsx | 4 +- .../resources/NetworkResourceModal.tsx | 13 + .../networks/resources/ResourceNameCell.tsx | 3 +- .../networks/resources/ResourcePolicyCell.tsx | 8 +- .../networks/resources/ResourcesTable.tsx | 11 +- .../routing-peers/NetworkRoutingPeerModal.tsx | 48 ++- .../RoutingPeerMasqueradeSwitch.tsx | 60 +++ .../RoutingPeersMasqueradeCell.tsx | 36 +- src/modules/networks/table/NetworksTable.tsx | 2 +- src/modules/peer/PeerExpirationToggle.tsx | 73 ++++ src/modules/peers/PeerActionCell.tsx | 36 +- src/modules/peers/PeerAddressCell.tsx | 4 +- .../peers/PeerAddressTooltipContent.tsx | 75 +++- src/modules/peers/PeerStatusCell.tsx | 19 +- src/modules/routes/RouteModal.tsx | 40 +- src/modules/routes/RouteUpdateModal.tsx | 31 +- src/modules/settings/AuthenticationTab.tsx | 261 +++++++----- src/modules/settings/GroupsActionCell.tsx | 26 +- src/modules/settings/GroupsNameCell.tsx | 38 ++ src/modules/settings/GroupsTable.tsx | 13 +- src/modules/settings/useGroupsUsage.tsx | 4 +- src/modules/setup-keys/SetupKeyActionCell.tsx | 1 + src/modules/setup-keys/SetupKeyGroupsCell.tsx | 1 + src/modules/setup-keys/SetupKeyModal.tsx | 36 +- src/modules/setup-keys/SetupKeyStatusCell.tsx | 57 +++ src/modules/setup-keys/SetupKeysTable.tsx | 19 +- src/modules/setup-netbird-modal/MacOSTab.tsx | 29 +- .../setup-netbird-modal/SetupModal.tsx | 46 ++- .../setup-netbird-modal/WindowsTab.tsx | 17 +- .../users/table-cells/UserRoleCell.tsx | 8 +- src/utils/version.ts | 16 + 78 files changed, 1994 insertions(+), 653 deletions(-) create mode 100644 src/assets/icons/EntraIcon.tsx create mode 100644 src/assets/icons/GoogleIcon.tsx create mode 100644 src/assets/icons/JWTIcon.tsx create mode 100644 src/assets/icons/OktaIcon.tsx create mode 100644 src/components/Radio.tsx create mode 100644 src/components/ui/GroupBadgeIcon.tsx create mode 100644 src/components/ui/ResourceBadge.tsx create mode 100644 src/components/ui/SmallBadge.tsx create mode 100644 src/hooks/useExpirationState.tsx create mode 100644 src/hooks/useTimeFormatter.tsx create mode 100644 src/modules/groups/useGroupIdentification.ts create mode 100644 src/modules/networks/routing-peers/RoutingPeerMasqueradeSwitch.tsx create mode 100644 src/modules/peer/PeerExpirationToggle.tsx create mode 100644 src/modules/settings/GroupsNameCell.tsx create mode 100644 src/modules/setup-keys/SetupKeyStatusCell.tsx diff --git a/src/app/(dashboard)/peer/page.tsx b/src/app/(dashboard)/peer/page.tsx index 490d8dca..bb75055d 100644 --- a/src/app/(dashboard)/peer/page.tsx +++ b/src/app/(dashboard)/peer/page.tsx @@ -23,10 +23,9 @@ 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"; +import { cn } from "@utils/helpers"; import dayjs from "dayjs"; import { isEmpty, trim } from "lodash"; import { @@ -41,6 +40,7 @@ import { NetworkIcon, PencilIcon, TerminalSquare, + TimerResetIcon, } from "lucide-react"; import { useRouter, useSearchParams } from "next/navigation"; import { toASCII } from "punycode"; @@ -56,11 +56,11 @@ import PeerProvider, { usePeer } from "@/contexts/PeerProvider"; import RoutesProvider from "@/contexts/RoutesProvider"; import { useLoggedInUser } from "@/contexts/UsersProvider"; import { useHasChanges } from "@/hooks/useHasChanges"; -import { OperatingSystem } from "@/interfaces/OperatingSystem"; import type { Peer } from "@/interfaces/Peer"; import PageContainer from "@/layouts/PageContainer"; import useGroupHelper from "@/modules/groups/useGroupHelper"; import { AccessiblePeersSection } from "@/modules/peer/AccessiblePeersSection"; +import { PeerExpirationToggle } from "@/modules/peer/PeerExpirationToggle"; import { PeerNetworkRoutesSection } from "@/modules/peer/PeerNetworkRoutesSection"; export default function PeerPage() { @@ -70,9 +70,16 @@ export default function PeerPage() { useRedirect("/peers", false, !peerId); + const peerKey = useMemo(() => { + let id = peer?.id ?? ""; + let ssh = peer?.ssh_enabled ? "1" : "0"; + let expiration = peer?.login_expiration_enabled ? "1" : "0"; + return `${id}-${ssh}-${expiration}`; + }, [peer]); + return peer && !isLoading ? ( - + ) : ( @@ -89,20 +96,15 @@ function PeerOverview() { const [loginExpiration, setLoginExpiration] = useState( peer.login_expiration_enabled, ); + const [inactivityExpiration, setInactivityExpiration] = useState( + peer.inactivity_expiration_enabled, + ); const [selectedGroups, setSelectedGroups, { getAllGroupCalls }] = useGroupHelper({ initial: peerGroups, peer, }); - /** - * Check the operating system of the peer, if it is linux, then show the routes table, otherwise hide it. - */ - const isLinux = useMemo(() => { - const operatingSystem = getOperatingSystem(peer.os); - return operatingSystem == OperatingSystem.LINUX; - }, [peer.os]); - /** * Detect if there are changes in the peer information, if there are changes, then enable the save button. */ @@ -111,10 +113,16 @@ function PeerOverview() { ssh, selectedGroups, loginExpiration, + inactivityExpiration, ]); const updatePeer = async () => { - const updateRequest = update(name, ssh, loginExpiration); + const updateRequest = update({ + name, + ssh, + loginExpiration, + inactivityExpiration, + }); const groupCalls = getAllGroupCalls(); const batchCall = groupCalls ? [...groupCalls, updateRequest] @@ -125,13 +133,19 @@ function PeerOverview() { promise: Promise.all(batchCall).then(() => { mutate("/peers/" + peer.id); mutate("/groups"); - updateHasChangedRef([name, ssh, selectedGroups, loginExpiration]); + updateHasChangedRef([ + name, + ssh, + selectedGroups, + loginExpiration, + inactivityExpiration, + ]); }), loadingMessage: "Saving the peer...", }); }; - const { isUser } = useLoggedInUser(); + const { isUser, isOwnerOrAdmin } = useLoggedInUser(); return ( @@ -213,53 +227,43 @@ function PeerOverview() {
-
- +
+ } + onChange={(state) => { + setLoginExpiration(state); + !state && setInactivityExpiration(false); + }} + /> + {isOwnerOrAdmin && !!peer?.user_id && (
- {!peer.user_id ? ( - <> - <> - - - Login expiration is disabled for all peers added - with an setup-key. - - - - ) : ( - <> - - - {`You don't have the required permissions to update this - setting.`} - - + className={cn( + "border border-nb-gray-900 border-t-0 rounded-b-md bg-nb-gray-940 px-[1.28rem] pt-3 pb-5 flex flex-col gap-4 mx-[0.25rem]", + !loginExpiration + ? "opacity-50 pointer-events-none" + : "bg-nb-gray-930/80", )} + > +
- } - className={"w-full block"} - disabled={!!peer.user_id && !isUser} - > - - - Login Expiration - - } - helpText={ - "Enable to require SSO login peers to re-authenticate when their login expires." - } - /> - + )} +
+
- {isLinux && !isUser ? ( + {!isUser ? ( <> @@ -335,7 +339,7 @@ function PeerOverview() { ); } -function PeerInformationCard({ peer }: { peer: Peer }) { +function PeerInformationCard({ peer }: Readonly<{ peer: Peer }>) { const { isLoading, getRegionByPeer } = useCountries(); const countryText = useMemo(() => { @@ -371,14 +375,20 @@ function PeerInformationCard({ peer }: { peer: Peer }) { Domain Name } + className={ + peer?.extra_dns_labels && peer.extra_dns_labels.length > 0 + ? "items-start" + : "" + } value={peer.dns_label} + extraText={peer?.extra_dns_labels} /> ) { const [name, setName] = useState(initialName); const isDisabled = useMemo(() => { diff --git a/src/app/(dashboard)/team/user/page.tsx b/src/app/(dashboard)/team/user/page.tsx index 44bc6983..b03ea582 100644 --- a/src/app/(dashboard)/team/user/page.tsx +++ b/src/app/(dashboard)/team/user/page.tsx @@ -40,6 +40,7 @@ export default function UserPage() { const { data: users, isLoading } = useFetchApi( `/users?service_user=${isServiceUser}`, ); + const { isOwnerOrAdmin } = useLoggedInUser(); const user = useMemo(() => { return users?.find((u) => u.id === userId); @@ -49,11 +50,15 @@ export default function UserPage() { const userGroups = useGroupIdsToGroups(user?.auto_groups); - return !isLoading && user && userGroups !== undefined ? ( - - ) : ( - - ); + if (!isOwnerOrAdmin && user && !isLoading) { + return ; + } + + if (isOwnerOrAdmin && user && !isLoading && userGroups) { + return ; + } + + return ; } type Props = { @@ -195,7 +200,7 @@ function UserOverview({ user, initialGroups }: Readonly) {
- {!user.is_service_user && ( + {!user.is_service_user && isOwnerOrAdmin && (
diff --git a/src/assets/icons/CircleIcon.tsx b/src/assets/icons/CircleIcon.tsx index 8301cc2c..f4210c50 100644 --- a/src/assets/icons/CircleIcon.tsx +++ b/src/assets/icons/CircleIcon.tsx @@ -12,7 +12,7 @@ export default function CircleIcon({ size = 11, inactiveDot = "gray", className, -}: Props) { +}: Readonly) { return ( ) { + return ( + + + + + + + + + ); +} diff --git a/src/assets/icons/GoogleIcon.tsx b/src/assets/icons/GoogleIcon.tsx new file mode 100644 index 00000000..9f31edfc --- /dev/null +++ b/src/assets/icons/GoogleIcon.tsx @@ -0,0 +1,31 @@ +import { iconProperties, IconProps } from "@/assets/icons/IconProperties"; + +export default function GoogleIcon(props: Readonly) { + return ( + + + + + + + + ); +} diff --git a/src/assets/icons/JWTIcon.tsx b/src/assets/icons/JWTIcon.tsx new file mode 100644 index 00000000..2c1f68c0 --- /dev/null +++ b/src/assets/icons/JWTIcon.tsx @@ -0,0 +1,36 @@ +import { iconProperties, IconProps } from "@/assets/icons/IconProperties"; + +export default function JWTIcon(props: Readonly) { + return ( + + + + + + + + + + ); +} diff --git a/src/assets/icons/OktaIcon.tsx b/src/assets/icons/OktaIcon.tsx new file mode 100644 index 00000000..185b2523 --- /dev/null +++ b/src/assets/icons/OktaIcon.tsx @@ -0,0 +1,18 @@ +import { iconProperties, IconProps } from "@/assets/icons/IconProperties"; + +export default function OktaIcon(props: Readonly) { + return ( + + + + ); +} diff --git a/src/components/Card.tsx b/src/components/Card.tsx index 9decc8b6..f63a8d12 100644 --- a/src/components/Card.tsx +++ b/src/components/Card.tsx @@ -7,6 +7,7 @@ import React from "react"; interface Props extends React.HTMLAttributes { children: React.ReactNode; } + function Card({ children, className, ...props }: Props) { return (
{label}
-
- copy && - copyToClipBoard( - `${copyText ? copyText : label} has been copied to clipboard.`, - ) - } - > - {tooltip ? ( - - ) : ( - value - )} - {copy && } +
+ + {extraText?.map((extraLabel, index) => ( + + ))}
); } +type CardTextItemProps = { + label: React.ReactNode; + value: React.ReactNode; + copy?: boolean; + copyText?: string; + tooltip?: boolean; +}; + +const CardTextItem = ({ + label, + value, + copy = false, + copyText, + tooltip = true, +}: CardTextItemProps) => { + const [, copyToClipBoard] = useCopyToClipboard(value as string); + return ( +
+ copy && + copyToClipBoard( + `${copyText ? copyText : label} has been copied to clipboard.`, + ) + } + > + {tooltip ? ( + + ) : ( + value + )} + {copy && } +
+ ); +}; + Card.List = CardList; Card.ListItem = CardListItem; diff --git a/src/components/CopyToClipboardText.tsx b/src/components/CopyToClipboardText.tsx index 9a4564d7..7173f0ed 100644 --- a/src/components/CopyToClipboardText.tsx +++ b/src/components/CopyToClipboardText.tsx @@ -6,9 +6,18 @@ import useCopyToClipboard from "@/hooks/useCopyToClipboard"; type Props = { children: React.ReactNode; message?: string; + iconAlignment?: "left" | "right"; + className?: string; + alwaysShowIcon?: boolean; }; -export default function CopyToClipboardText({ children, message }: Props) { +export default function CopyToClipboardText({ + children, + message, + iconAlignment = "right", + className, + alwaysShowIcon = false, +}: Props) { const [wrapper, copyToClipboard, copied] = useCopyToClipboard(); return ( @@ -16,6 +25,7 @@ export default function CopyToClipboardText({ children, message }: Props) { className={cn( "flex gap-2 items-center group cursor-pointer transition-all hover:underline underline-offset-4 decoration-dashed decoration-nb-gray-600", !copied && "hover:opacity-90", + className, )} onClick={(e) => { e.stopPropagation(); @@ -28,17 +38,21 @@ export default function CopyToClipboardText({ children, message }: Props) { {copied ? ( ) : ( )}
diff --git a/src/components/DropdownInfoText.tsx b/src/components/DropdownInfoText.tsx index a8d1879e..2ed7122f 100644 --- a/src/components/DropdownInfoText.tsx +++ b/src/components/DropdownInfoText.tsx @@ -1,11 +1,15 @@ +import { cn } from "@utils/helpers"; import * as React from "react"; type Props = { children: React.ReactNode; + className?: string; }; -export const DropdownInfoText = ({ children }: Props) => { +export const DropdownInfoText = ({ children, className }: Props) => { return ( -
{children}
+
+ {children} +
); }; diff --git a/src/components/FancyToggleSwitch.tsx b/src/components/FancyToggleSwitch.tsx index bc04d015..c459e920 100644 --- a/src/components/FancyToggleSwitch.tsx +++ b/src/components/FancyToggleSwitch.tsx @@ -2,16 +2,51 @@ import HelpText from "@components/HelpText"; import { Label } from "@components/Label"; import { ToggleSwitch } from "@components/ToggleSwitch"; import { cn } from "@utils/helpers"; +import { cva, VariantProps } from "class-variance-authority"; import React from "react"; -type Props = { +export const fancyToggleSwitchVariants = cva([], { + variants: { + variant: { + default: ["px-6 py-4 border rounded-md"], + blank: null, + }, + state: { + true: null, + false: null, + }, + }, + compoundVariants: [ + { + variant: "default", + state: true, + className: ["border-nb-gray-800 bg-nb-gray-900/70"], + }, + { + variant: "default", + state: false, + className: [ + "border-nb-gray-910 bg-nb-gray-900/30 hover:bg-nb-gray-900/40", + ], + }, + ], +}); + +export type FancyToggleSwitchVariants = VariantProps< + typeof fancyToggleSwitchVariants +>; + +interface Props extends FancyToggleSwitchVariants { value: boolean; onChange: (value: boolean) => void; helpText?: React.ReactNode; label?: React.ReactNode; children?: React.ReactNode; disabled?: boolean; -}; + dataCy?: string; + className?: string; +} + export default function FancyToggleSwitch({ value, onChange, @@ -19,28 +54,49 @@ export default function FancyToggleSwitch({ label, children, disabled = false, -}: Props) { + dataCy, + className, + variant = "default", +}: Readonly) { + const handleToggle = () => { + if (disabled) return; + onChange(!value); + }; + + const handleKeyDown = (event: React.KeyboardEvent) => { + if (disabled) return; + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + handleToggle(); + } + }; + return (
{ - if (disabled) return; - onChange(!value); - }} + onClick={handleToggle} + onKeyDown={handleKeyDown} + tabIndex={-1} + role={"switch"} + aria-checked={value} className={cn( - "px-5 py-3.5 border rounded-md cursor-pointer transition-all duration-300 relative z-[1]", - 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", + "cursor-pointer transition-all duration-300 relative z-[1]", + "inline-block text-left w-full", disabled && "opacity-50 pointer-events-none", + fancyToggleSwitchVariants({ variant, state: value }), + className, )} > -
+
{helpText}
-
- +
+
{children && value ? children : null}
diff --git a/src/components/FullTooltip.tsx b/src/components/FullTooltip.tsx index 34967a38..1a898e06 100644 --- a/src/components/FullTooltip.tsx +++ b/src/components/FullTooltip.tsx @@ -22,6 +22,8 @@ type Props = { keepOpen?: boolean; customOpen?: boolean; customOnOpenChange?: React.Dispatch>; + delayDuration?: number; + skipDelayDuration?: number; } & TooltipProps; export default function FullTooltip({ children, @@ -37,6 +39,8 @@ export default function FullTooltip({ keepOpen = false, customOpen, customOnOpenChange, + delayDuration = 1, + skipDelayDuration = 300, }: Props) { const [open, setOpen] = useState(!!keepOpen); @@ -46,9 +50,13 @@ export default function FullTooltip({ }; return !disabled ? ( - + diff --git a/src/components/PeerGroupSelector.tsx b/src/components/PeerGroupSelector.tsx index dfaf6a9b..1ec9a403 100644 --- a/src/components/PeerGroupSelector.tsx +++ b/src/components/PeerGroupSelector.tsx @@ -1,12 +1,17 @@ import Badge from "@components/Badge"; import { Checkbox } from "@components/Checkbox"; import { CommandItem } from "@components/Command"; +import { DropdownInfoText } from "@components/DropdownInfoText"; import FullTooltip from "@components/FullTooltip"; +import InlineLink from "@components/InlineLink"; import { Popover, PopoverContent, PopoverTrigger } from "@components/Popover"; +import { Radio, RadioItem } from "@components/Radio"; import { ScrollArea } from "@components/ScrollArea"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@components/Tabs"; import { AccessControlGroupCount } from "@components/ui/AccessControlGroupCount"; import GroupBadge from "@components/ui/GroupBadge"; import GroupBadgeWithEditPeers from "@components/ui/GroupBadgeWithEditPeers"; +import ResourceBadge from "@components/ui/ResourceBadge"; import TextWithTooltip from "@components/ui/TextWithTooltip"; import { VirtualScrollAreaList } from "@components/VirtualScrollAreaList"; import { useSearch } from "@hooks/useSearch"; @@ -21,6 +26,7 @@ import { FolderGit2, GlobeIcon, Layers3, + Layers3Icon, MonitorSmartphoneIcon, NetworkIcon, SearchIcon, @@ -28,11 +34,13 @@ import { } from "lucide-react"; import * as React from "react"; import { Fragment, useEffect, useMemo, useState } from "react"; +import Skeleton from "react-loading-skeleton"; import { useGroups } from "@/contexts/GroupsProvider"; import { useElementSize } from "@/hooks/useElementSize"; import type { Group, GroupPeer, GroupResource } from "@/interfaces/Group"; import { NetworkResource } from "@/interfaces/Network"; import type { Peer } from "@/interfaces/Peer"; +import { PolicyRuleResource } from "@/interfaces/Policy"; interface MultiSelectProps { values: Group[]; @@ -49,6 +57,10 @@ interface MultiSelectProps { disabledGroups?: Group[]; dataCy?: string; showResourceCounter?: boolean; + showResources?: boolean; + resource?: PolicyRuleResource; + onResourceChange?: (resource?: PolicyRuleResource) => void; + placeholder?: string; } export function PeerGroupSelector({ onChange, @@ -65,12 +77,19 @@ export function PeerGroupSelector({ disabledGroups, dataCy = "group-selector-dropdown", showResourceCounter = true, + showResources = false, + resource, + onResourceChange, + placeholder = "Add or select group(s)...", }: Readonly) { const { groups, dropdownOptions, setDropdownOptions, addDropdownOptions } = useGroups(); const searchRef = React.useRef(null); const [inputRef, { width }] = useElementSize(); const [search, setSearch] = useState(""); + const { data: resources, isLoading } = useFetchApi( + "/networks/resources", + ); // Update dropdown options when groups change useEffect(() => { @@ -102,6 +121,7 @@ export function PeerGroupSelector({ // Add group to the groupOptions if it does not exist const selectGroup = (name: string) => { + onResourceChange?.(undefined); const group = groups?.find((group) => group.name == name); const option = dropdownOptions.find((option) => option.name == name); const groupPeers: GroupPeer[] | undefined = @@ -169,6 +189,8 @@ export function PeerGroupSelector({ const [slice, setSlice] = useState(10); + const [tab, setTab] = useState("groups"); + useEffect(() => { if (open) { setTimeout(() => { @@ -191,16 +213,41 @@ export function PeerGroupSelector({ open, ); + // Reset the search input when switching tabs + useEffect(() => { + setSearch(""); + setTimeout(() => { + searchRef.current?.focus(); + }, 0); + }, [tab]); + + const searchPlaceholder = + tab === "groups" + ? 'Search groups or add new group by pressing "Enter"...' + : "Search resource..."; + + const selectResource = (resource?: NetworkResource) => { + onResourceChange?.( + resource + ? ({ + id: resource?.id, + type: resource?.type, + } as PolicyRuleResource) + : undefined, + ); + onChange([]); + }; + return ( { + setOpen(isOpen); if (!isOpen && search.length > 0) { setTimeout(() => { setSearch(""); - }, 100); + }, 200); } - setOpen(isOpen); }} > @@ -220,6 +267,18 @@ export function PeerGroupSelector({ "flex items-center gap-2 border-nb-gray-700 flex-wrap h-full" } > + {resource && showResources && ( + r.id === resource.id)} + onClick={(e) => { + e.preventDefault(); + e.stopPropagation(); + selectResource(); + }} + showX={true} + /> + )} {values.map((group) => { return (
Add or select group(s)... + {values.length == 0 && !resource && ( + {placeholder} )}
@@ -277,7 +336,7 @@ export function PeerGroupSelector({
- - - {searchedGroupNotFound && ( - { - toggleGroupByName(search); - searchRef.current?.focus(); - }} - value={search} - onClick={(e) => e.preventDefault()} + + {showResources && } + + + - - {folderIcon} - {search} - -
- Add this group by pressing{" "} - - {"'Enter'"} - -
-
- )} - - {sortedDropdownOptions.slice(0, slice).map((option) => { - const isSelected = - values.find((group) => group.name == option.name) != - undefined; - const peerCount = - option.peers?.length ?? option?.peers_count ?? 0; - - const isDisabled = disabledGroups - ? disabledGroups?.findIndex((g) => g.id === option.id) !== - -1 - : false; - - return ( - - This group is already part of the routing peer and can - not be used for the access control groups. -
- } - disabled={!isDisabled} - className={"w-full block"} - key={option.name} - > + {searchedGroupNotFound && ( { - if (peer != undefined && option.name == "All") return; // Prevent removing the "All" group - if (isDisabled) return; - toggleGroupByName(option.name); + toggleGroupByName(search); searchRef.current?.focus(); }} - className={cn(isDisabled && "opacity-40")} + value={search} onClick={(e) => e.preventDefault()} > -
- -
- -
- {option?.id && showRoutes && ( - - )} - - {showResourceCounter && ( - - )} - -
- {peerIcon} - {peerCount} Peer(s) - -
+ + {folderIcon} + {search} + +
+ Add this group by pressing{" "} + + {"'Enter'"} +
- - ); - })} - - + )} + + {sortedDropdownOptions.slice(0, slice).map((option) => { + const isSelected = + values.find((group) => group.name == option.name) != + undefined; + const peerCount = + option.peers?.length ?? option?.peers_count ?? 0; + + const isDisabled = disabledGroups + ? disabledGroups?.findIndex( + (g) => g.id === option.id, + ) !== -1 + : false; + + if (hideAllGroup && option?.name === "All") return; + + return ( + + This group is already part of the routing peer and + can not be used for the access control groups. +
+ } + disabled={!isDisabled} + className={"w-full block"} + key={option.name} + > + { + if (peer != undefined && option.name == "All") + return; // Prevent removing the "All" group + if (isDisabled) return; + toggleGroupByName(option.name); + searchRef.current?.focus(); + }} + className={cn(isDisabled && "opacity-40")} + onClick={(e) => e.preventDefault()} + > +
+ +
+ +
+ {option?.id && showRoutes && ( + + )} + + {showResourceCounter && ( + + )} + +
+ {peerIcon} + {peerCount} Peer(s) + +
+
+
+ + ); + })} + + + + {showResources && ( + + + + )} + @@ -439,6 +518,43 @@ export function PeerGroupSelector({ ); } +const TabTriggers = ({ + searchRef, +}: { + searchRef: React.MutableRefObject; +}) => { + return ( + + searchRef.current?.focus()} + > + + Groups + + searchRef.current?.focus()} + > + + Resource + + + ); +}; + const ResourcesCounter = ({ group }: { group: Group }) => { return group?.resources_count && group.resources_count > 0 ? (
{ return item.address.toLowerCase().includes(lowerCaseQuery); }; -const ResourcesList = ({ search }: { search: string }) => { - const { data: resources, isLoading } = useFetchApi( - "/networks/resources", - ); - +const ResourcesList = ({ + search, + resources, + isLoading, + value, + onChange, +}: { + search: string; + resources?: NetworkResource[]; + isLoading: boolean; + value?: PolicyRuleResource; + onChange: (resource: NetworkResource) => void; +}) => { const [filteredItems, _, setSearch] = useSearch( resources || [], resourcesSearchPredicate, @@ -473,16 +597,43 @@ const ResourcesList = ({ search }: { search: string }) => { setSearch(search); }, [search, setSearch]); - return isLoading ? ( - <>Loading... - ) : ( - filteredItems.length > 0 && ( + if (isLoading) { + return ( +
+ + + + +
+ ); + } + + if (search != "" && filteredItems.length == 0) { + return ( + + There are no resources matching your search. Please try a different + search term. + + ); + } + + if (search == "" && filteredItems.length == 0) { + return ( + + There are no resources available yet.
+ Go to Networks to add some + resources. +
+ ); + } + + return ( + null} + onSelect={onChange} + itemClassName={"dark:aria-selected:bg-nb-gray-800/20"} renderItem={(res) => { - const isSelected = false; - return (
@@ -496,22 +647,13 @@ const ResourcesList = ({ search }: { search: string }) => { }} > {res.type === "host" && ( - + )} {res.type === "domain" && ( - + )} {res.type === "subnet" && ( - + )} @@ -524,13 +666,14 @@ const ResourcesList = ({ search }: { search: string }) => { "text-neutral-500 dark:text-nb-gray-300 font-medium flex items-center gap-2" } > - + {res.address} +
); }} /> - ) + ); }; diff --git a/src/components/PeerSelector.tsx b/src/components/PeerSelector.tsx index 8310ee60..caf362a9 100644 --- a/src/components/PeerSelector.tsx +++ b/src/components/PeerSelector.tsx @@ -1,31 +1,26 @@ import { DropdownInfoText } from "@components/DropdownInfoText"; import { DropdownInput } from "@components/DropdownInput"; +import FullTooltip from "@components/FullTooltip"; import { Popover, PopoverContent, PopoverTrigger } from "@components/Popover"; import TextWithTooltip from "@components/ui/TextWithTooltip"; import { VirtualScrollAreaList } from "@components/VirtualScrollAreaList"; +import { getOperatingSystem } from "@hooks/useOperatingSystem"; import { useSearch } from "@hooks/useSearch"; import useFetchApi from "@utils/api"; import { cn } from "@utils/helpers"; +import { isRoutingPeerSupported } from "@utils/version"; import { sortBy, unionBy } from "lodash"; -import { ChevronsUpDown, MapPin } from "lucide-react"; +import { ArrowUpCircleIcon, ChevronsUpDown, MapPin } from "lucide-react"; import * as React from "react"; import { memo, useEffect, useState } from "react"; -import { FcLinux } from "react-icons/fc"; import { useElementSize } from "@/hooks/useElementSize"; -import { getOperatingSystem } from "@/hooks/useOperatingSystem"; import { OperatingSystem } from "@/interfaces/OperatingSystem"; import { Peer } from "@/interfaces/Peer"; +import { OSLogo } from "@/modules/peers/PeerOSCell"; const MapPinIcon = memo(() => ); MapPinIcon.displayName = "MapPinIcon"; -const LinuxIcon = memo(() => ( - - - -)); -LinuxIcon.displayName = "LinuxIcon"; - interface MultiSelectProps { value?: Peer; onChange: React.Dispatch>; @@ -63,11 +58,6 @@ export function PeerSelector({ // Sort let options = sortBy([...peers], "name") as Peer[]; - // Filter out peers that are not linux - options = options.filter((peer) => { - return getOperatingSystem(peer.os) === OperatingSystem.LINUX; - }); - // Filter out excluded peers if (excludedPeers) { options = options.filter((peer) => { @@ -128,8 +118,7 @@ export function PeerSelector({ } >
- - +
- { - "Seems like you don't have any Linux peers to assign as a routing peer." - } + {"No peers available to select."}
)} @@ -185,10 +172,35 @@ export function PeerSelector({ {filteredItems.length > 0 && ( { + const isSupported = isRoutingPeerSupported( + item.version, + item.os, + ); + if (!isSupported) return; + togglePeer(item); + }} renderItem={(option) => { + const os = getOperatingSystem(option.os); + const isSupported = isRoutingPeerSupported( + option.version, + option.os, + ); return ( - <> + + Please update NetBird to at least{" "} + v0.36.6 or later + to use this peer as a routing peer. +
+ } + >
- - +
+ +
+ +
+ +
+ {!isSupported && ( +
+ + +
+ )}
{option.ip}
- + ); }} /> diff --git a/src/components/Radio.tsx b/src/components/Radio.tsx new file mode 100644 index 00000000..982b13d1 --- /dev/null +++ b/src/components/Radio.tsx @@ -0,0 +1,71 @@ +import * as RadioPrimitive from "@radix-ui/react-radio-group"; +import { cn } from "@utils/helpers"; +import { cva, VariantProps } from "class-variance-authority"; +import * as React from "react"; +import { forwardRef } from "react"; + +type RadioVariants = VariantProps; + +const variants = cva([], { + variants: { + variant: { + default: [ + "dark:data-[state=unchecked]:bg-nb-gray-950 dark:border-nb-gray-900 dark:ring-offset-neutral-950 dark:focus-visible:ring-neutral-300", + "dark:data-[state=checked]:bg-netbird", + ], + }, + }, +}); + +const Radio = forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & RadioVariants +>( + ( + { className, children, variant = "default", defaultValue, ...props }, + ref, + ) => ( + + {children} + + ), +); +Radio.displayName = RadioPrimitive.Root.displayName; + +type Props = { + value: string; + className?: string; +} & RadioVariants; + +const RadioItem = ({ value, className, variant = "default" }: Props) => { + return ( + + +
+
+
+ ); +}; +RadioItem.displayName = RadioPrimitive.Item.displayName; + +export { Radio, RadioItem }; diff --git a/src/components/Separator.tsx b/src/components/Separator.tsx index fe2198ad..6daa2c00 100644 --- a/src/components/Separator.tsx +++ b/src/components/Separator.tsx @@ -1,7 +1,3 @@ export default function Separator() { - return ( - - ); + return ; } diff --git a/src/components/ToggleSwitch.tsx b/src/components/ToggleSwitch.tsx index 047a6a56..89ed4e3a 100644 --- a/src/components/ToggleSwitch.tsx +++ b/src/components/ToggleSwitch.tsx @@ -36,29 +36,36 @@ const switchVariants = cva("", { const ToggleSwitch = React.forwardRef< React.ElementRef, - React.ComponentPropsWithoutRef & SwitchVariants ->(({ className, size = "default", variant = "default", ...props }, ref) => ( - { - e.stopPropagation(); - props.onClick?.(e); - }} - ref={ref} - > - & + SwitchVariants & { dataCy?: string } +>( + ( + { className, size = "default", variant = "default", dataCy, ...props }, + ref, + ) => ( + - -)); + {...props} + data-cy={dataCy} + onClick={(e) => { + e.stopPropagation(); + props.onClick?.(e); + }} + ref={ref} + > + + + ), +); ToggleSwitch.displayName = SwitchPrimitives.Root.displayName; export { ToggleSwitch }; diff --git a/src/components/Tooltip.tsx b/src/components/Tooltip.tsx index 69520ba1..a13906ca 100644 --- a/src/components/Tooltip.tsx +++ b/src/components/Tooltip.tsx @@ -10,6 +10,9 @@ 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"; + const TooltipContent = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef @@ -19,10 +22,7 @@ const TooltipContent = React.forwardRef< ref={ref} asChild={true} sideOffset={sideOffset} - className={cn( - "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", - className, - )} + className={cn(tooltipClasses, className)} {...props} >
{props.children}
diff --git a/src/components/VerticalTabs.tsx b/src/components/VerticalTabs.tsx index d8946624..dd29ba6e 100644 --- a/src/components/VerticalTabs.tsx +++ b/src/components/VerticalTabs.tsx @@ -51,7 +51,7 @@ function List({ children }: { children: React.ReactNode }) { = { items: T[]; onSelect: (item: T) => void; renderItem?: (item: T) => React.ReactNode; + itemClassName?: string; }; export function VirtualScrollAreaList({ items, onSelect, renderItem, -}: Props) { + itemClassName, +}: Readonly>) { const virtuosoRef = useRef(null); const [selected, setSelected] = useState(0); @@ -81,8 +83,9 @@ export function VirtualScrollAreaList({ setSelected(index)} id={option.id} - onClick={() => onClick(option as T)} + onClick={() => onClick(option)} ariaSelected={selected === index} + className={itemClassName} > {renderMemoizedItem ? renderMemoizedItem(option) : option.id} @@ -103,10 +106,18 @@ type ItemWrapperProps = { onMouseEnter?: () => void; onClick?: () => void; ariaSelected?: boolean; + className?: string; }; export const VirtualScrollListItemWrapper = memo( - ({ id, children, onClick, onMouseEnter, ariaSelected }: ItemWrapperProps) => { + ({ + id, + children, + onClick, + onMouseEnter, + ariaSelected, + className, + }: ItemWrapperProps) => { return (
) { const isNew = !group?.id; return ( @@ -36,11 +38,10 @@ export default function GroupBadge({ onClick?.(e); }} > - - + {children} - {isNew && showNewBadge && } + {isNew && showNewBadge && } {showX && ( { + const { groups } = useGroups(); + const group = groups?.find((g) => g.id === id); + + const { isAzureGroup, isGoogleGroup, isOktaGroup, isJWTGroup } = + useGroupIdentification({ id, issued: issued ?? group?.issued }); + + if (isGoogleGroup) + return ; + if (isAzureGroup) + return ; + if (isOktaGroup) return ; + if (isJWTGroup) return ; + + return ; +}; diff --git a/src/components/ui/LoginExpiredBadge.tsx b/src/components/ui/LoginExpiredBadge.tsx index d5a6a396..87443801 100644 --- a/src/components/ui/LoginExpiredBadge.tsx +++ b/src/components/ui/LoginExpiredBadge.tsx @@ -9,8 +9,8 @@ export default function LoginExpiredBadge({ loginExpired }: Props) { return loginExpired ? ( - - + + Login required diff --git a/src/components/ui/ResourceBadge.tsx b/src/components/ui/ResourceBadge.tsx new file mode 100644 index 00000000..594c3d70 --- /dev/null +++ b/src/components/ui/ResourceBadge.tsx @@ -0,0 +1,58 @@ +import Badge from "@components/Badge"; +import TruncatedText from "@components/ui/TruncatedText"; +import { cn } from "@utils/helpers"; +import { GlobeIcon, NetworkIcon, WorkflowIcon, XIcon } from "lucide-react"; +import * as React from "react"; +import { NetworkResource } from "@/interfaces/Network"; + +type Props = { + resource?: NetworkResource; + onClick?: (e: React.MouseEvent) => void; + showX?: boolean; + children?: React.ReactNode; + className?: string; +}; +export default function ResourceBadge({ + onClick, + resource, + showX = false, + children, + className, +}: Readonly) { + if (!resource) return; + + return ( + { + e.preventDefault(); + onClick?.(e); + }} + > + {resource.type === "host" && ( + + )} + {resource.type === "domain" && ( + + )} + {resource.type === "subnet" && ( + + )} + + + {children} + {showX && ( + + )} + + ); +} diff --git a/src/components/ui/SmallBadge.tsx b/src/components/ui/SmallBadge.tsx new file mode 100644 index 00000000..cbf22c08 --- /dev/null +++ b/src/components/ui/SmallBadge.tsx @@ -0,0 +1,39 @@ +import { cn } from "@utils/helpers"; +import { cva, type VariantProps } from "class-variance-authority"; +import * as React from "react"; + +const smallBadgeVariants = cva("", { + variants: { + variant: { + green: "bg-green-900 border border-green-500/20 text-green-400", + white: "bg-white/20 border border-white/10 text-white", + sky: "bg-sky-900 border border-sky-500/20 text-white", + }, + }, +}); + +type Props = { + text?: string; + className?: string; + children?: React.ReactNode; +} & VariantProps; + +export const SmallBadge = ({ + text = "NEW", + className, + variant = "green", + children, +}: Props) => { + return ( + + {children} + {text} + + ); +}; diff --git a/src/components/ui/TextWithTooltip.tsx b/src/components/ui/TextWithTooltip.tsx index b66c3605..5b50d632 100644 --- a/src/components/ui/TextWithTooltip.tsx +++ b/src/components/ui/TextWithTooltip.tsx @@ -25,6 +25,8 @@ export default function TextWithTooltip({ disabled={charCount <= maxChars || hideTooltip} interactive={false} className={"truncate w-full min-w-0"} + skipDelayDuration={350} + delayDuration={200} content={
{text} diff --git a/src/contexts/PeerProvider.tsx b/src/contexts/PeerProvider.tsx index 714e71b5..f3cd0fd3 100644 --- a/src/contexts/PeerProvider.tsx +++ b/src/contexts/PeerProvider.tsx @@ -20,12 +20,13 @@ const PeerContext = React.createContext( peer: Peer; user?: User; peerGroups: Group[]; - update: ( - name: string, - ssh: boolean, - loginExpiration: boolean, - approval_required?: boolean, - ) => Promise; + update: (props: { + name?: string; + ssh?: boolean; + loginExpiration?: boolean; + inactivityExpiration?: boolean; + approval_required?: boolean; + }) => Promise; openSSHDialog: () => Promise; deletePeer: () => void; isLoading: boolean; @@ -61,23 +62,30 @@ export default function PeerProvider({ children, peer }: Props) { } }; - const update = async ( - name: string, - ssh: boolean, - loginExpiration: boolean, - approval_required?: boolean, - ) => { + const update = async (props: { + name?: string; + ssh?: boolean; + loginExpiration?: boolean; + inactivityExpiration?: boolean; + approval_required?: boolean; + }) => { return peerRequest.put( { peerId: peer?.id, - name: name != undefined ? name : peer.name, - ssh_enabled: ssh != undefined ? ssh : peer.ssh_enabled, + name: props.name != undefined ? props.name : peer.name, + ssh_enabled: props.ssh != undefined ? props.ssh : peer.ssh_enabled, login_expiration_enabled: - loginExpiration != undefined - ? loginExpiration + props.loginExpiration != undefined + ? props.loginExpiration : peer.login_expiration_enabled, + inactivity_expiration_enabled: + props?.inactivityExpiration == undefined + ? undefined + : props.inactivityExpiration, approval_required: - approval_required == undefined ? undefined : approval_required, + props?.approval_required == undefined + ? undefined + : props.approval_required, }, `/${peer.id}`, ); diff --git a/src/contexts/UsersProvider.tsx b/src/contexts/UsersProvider.tsx index 09cbcefd..67591516 100644 --- a/src/contexts/UsersProvider.tsx +++ b/src/contexts/UsersProvider.tsx @@ -2,7 +2,7 @@ import FullScreenLoading from "@components/ui/FullScreenLoading"; import useFetchApi from "@utils/api"; import React, { useMemo } from "react"; import { Permission } from "@/interfaces/Permission"; -import { User } from "@/interfaces/User"; +import { Role, User } from "@/interfaces/User"; type Props = { children: React.ReactNode; @@ -40,8 +40,8 @@ export const useUsers = () => React.useContext(UsersContext); export const useLoggedInUser = () => { const { loggedInUser } = useUsers(); - const isOwner = loggedInUser ? loggedInUser?.role === "owner" : false; - const isAdmin = loggedInUser ? loggedInUser?.role === "admin" : false; + const isOwner = loggedInUser ? loggedInUser?.role === Role.Owner : false; + const isAdmin = loggedInUser ? loggedInUser?.role === Role.Admin : false; const isUser = !isOwner && !isAdmin; const isOwnerOrAdmin = isOwner || isAdmin; diff --git a/src/hooks/useExpirationState.tsx b/src/hooks/useExpirationState.tsx new file mode 100644 index 00000000..a1f8acac --- /dev/null +++ b/src/hooks/useExpirationState.tsx @@ -0,0 +1,33 @@ +import { TimeRange, useTimeFormatter } from "@hooks/useTimeFormatter"; +import { useState } from "react"; + +type Props = { + enabled: boolean; + expirationInSeconds: number; + timeRange?: TimeRange; +}; +export const useExpirationState = ({ + enabled, + expirationInSeconds, + timeRange = ["hours", "days"], +}: Props) => { + const [isEnabled, setIsEnabled] = useState(enabled); + const [expiresInSeconds] = useState(expirationInSeconds || 86400); + + const { value: seconds, time: unit } = useTimeFormatter( + expiresInSeconds, + timeRange, + ); + + const [expiresIn, setExpiresIn] = useState(seconds); + const [expireInterval, setExpireInterval] = useState(unit); + + return [ + isEnabled, + setIsEnabled, + expiresIn, + setExpiresIn, + expireInterval, + setExpireInterval, + ] as const; +}; diff --git a/src/hooks/usePlaceholders.tsx b/src/hooks/usePlaceholders.tsx index 62ad11d6..f60df052 100644 --- a/src/hooks/usePlaceholders.tsx +++ b/src/hooks/usePlaceholders.tsx @@ -25,6 +25,7 @@ export function useSetupKeyPlaceholders() { expires_in: 0, usage_limit: null, ephemeral: randomBoolean(), + allow_extra_dns_labels: randomBoolean(), } as SetupKey); } diff --git a/src/hooks/useTimeFormatter.tsx b/src/hooks/useTimeFormatter.tsx new file mode 100644 index 00000000..c3596513 --- /dev/null +++ b/src/hooks/useTimeFormatter.tsx @@ -0,0 +1,63 @@ +import { useMemo } from "react"; + +export type TimeUnit = "seconds" | "minutes" | "hours" | "days"; +export type TimeRange = TimeUnit[]; + +const TIME_CONVERSIONS: Record = { + seconds: 1, + minutes: 60, + hours: 3600, + days: 86400, +}; + +interface FormattedTime { + value: string; + time: TimeUnit | string; +} + +export const isValidTimeUnit = (unit: string): unit is TimeUnit => { + return unit in TIME_CONVERSIONS; +}; + +export const convertToSeconds = ( + value: string, + unit: TimeUnit | string, +): number => { + if (!isValidTimeUnit(unit)) { + console.warn(`Invalid time unit: ${unit}`); + } + return Math.round(parseFloat(value) * TIME_CONVERSIONS[unit]); +}; + +export const useTimeFormatter = ( + seconds: number, + range: TimeRange, +): FormattedTime => { + return useMemo(() => { + const smallerUnit = range[0]; + const largestUnit = range[range.length - 1]; + const largestIndex = range.indexOf(largestUnit); + + if (TIME_CONVERSIONS[smallerUnit] >= TIME_CONVERSIONS[largestUnit]) { + console.warn("First unit must be smaller than second unit"); + } + + if (seconds === TIME_CONVERSIONS.days && largestUnit === "days") { + return { value: "24", time: "hours" }; + } + + // Convert seconds to all units in range + const converted = range.map((unit) => { + const value = seconds / TIME_CONVERSIONS[unit]; + return { + value: Number.isInteger(value) ? value.toString() : value.toFixed(2), + time: unit, + }; + }); + + const { value, time } = + converted.reverse().find(({ value }) => parseFloat(value) >= 1) || + converted[largestIndex]; + return { value, time }; + }, [seconds, range]); +}; diff --git a/src/interfaces/Account.ts b/src/interfaces/Account.ts index 73f15458..330ef2b0 100644 --- a/src/interfaces/Account.ts +++ b/src/interfaces/Account.ts @@ -6,6 +6,8 @@ export interface Account { }; peer_login_expiration_enabled: boolean; peer_login_expiration: number; + peer_inactivity_expiration_enabled: boolean; + peer_inactivity_expiration: number; groups_propagation_enabled: boolean; jwt_groups_enabled: boolean; jwt_groups_claim_name: string; diff --git a/src/interfaces/Group.ts b/src/interfaces/Group.ts index 27863486..8237b867 100644 --- a/src/interfaces/Group.ts +++ b/src/interfaces/Group.ts @@ -5,6 +5,7 @@ export interface Group { peers_count?: number; resources?: GroupResource[] | string[]; resources_count?: number; + issued?: GroupIssued; // Frontend only keepClientState?: boolean; @@ -18,4 +19,10 @@ export interface GroupPeer { export interface GroupResource { id: string; type: string; -} \ No newline at end of file +} + +export enum GroupIssued { + API = "api", + INTEGRATION = "integration", + JWT = "jwt", +} diff --git a/src/interfaces/Peer.ts b/src/interfaces/Peer.ts index 6c9e793d..5342c066 100644 --- a/src/interfaces/Peer.ts +++ b/src/interfaces/Peer.ts @@ -16,9 +16,11 @@ export interface Peer { user?: User; ui_version?: string; dns_label: string; + extra_dns_labels?: string[]; last_login: Date; login_expired: boolean; login_expiration_enabled: boolean; + inactivity_expiration_enabled: boolean; approval_required: boolean; city_name: string; country_code: string; diff --git a/src/interfaces/Policy.ts b/src/interfaces/Policy.ts index 91383752..c0136207 100644 --- a/src/interfaces/Policy.ts +++ b/src/interfaces/Policy.ts @@ -22,6 +22,13 @@ export interface PolicyRule { action: string; protocol: Protocol; ports: string[]; + sourceResource?: PolicyRuleResource; + destinationResource?: PolicyRuleResource; +} + +export interface PolicyRuleResource { + id: string; + type: "domain" | "host" | "subnet" | undefined; } export type Protocol = "all" | "tcp" | "udp" | "icmp"; diff --git a/src/interfaces/PostureCheck.ts b/src/interfaces/PostureCheck.ts index 6a441816..e67d7b74 100644 --- a/src/interfaces/PostureCheck.ts +++ b/src/interfaces/PostureCheck.ts @@ -66,47 +66,19 @@ export interface Process { } export const windowsKernelVersions: SelectOption[] = [ - { value: "5.0", label: "Windows 2000" }, - { value: "5.1", label: "Windows XP" }, - { value: "6.0", label: "Windows Vista" }, - { value: "6.1", label: "Windows 7" }, - { value: "6.2", label: "Windows 8" }, - { value: "6.3", label: "Windows 8.1" }, { value: "10.0", label: "Windows 10" }, { value: "10.0.2", label: "Windows 11" }, ]; export const iOSVersions: SelectOption[] = [ - { value: "1.0", label: "iPhone OS 1.x" }, - { value: "2.0", label: "iPhone OS 2.x" }, - { value: "3.0", label: "iPhone OS 3.x" }, - { value: "4.0", label: "iOS 4.x" }, - { value: "5.0", label: "iOS 5.x" }, - { value: "6.0", label: "iOS 6.x" }, - { value: "7.0", label: "iOS 7.x" }, - { value: "8.0", label: "iOS 8.x" }, - { value: "9.0", label: "iOS 9.x" }, - { value: "10.0", label: "iOS 10.x" }, - { value: "11.0", label: "iOS 11.x" }, - { value: "12.0", label: "iOS 12.x" }, - { value: "13.0", label: "iOS 13.x" }, { value: "14.0", label: "iOS 14.x" }, { value: "15.0", label: "iOS 15.x" }, { value: "16.0", label: "iOS 16.x" }, { value: "17.0", label: "iOS 17.x" }, + { value: "18.0", label: "iOS 18.x" }, ]; export const macOSVersions: SelectOption[] = [ - { value: "10.0", label: "Mac OS X Cheetah" }, - { value: "10.1", label: "Mac OS X Puma" }, - { value: "10.2", label: "Mac OS X Jaguar" }, - { value: "10.3", label: "Mac OS X Panther" }, - { value: "10.4", label: "Mac OS X Tiger" }, - { value: "10.5", label: "Mac OS X Leopard" }, - { value: "10.6", label: "Mac OS X Snow Leopard" }, - { value: "10.7", label: "Mac OS X Lion" }, - { value: "10.8", label: "OS X Mountain Lion" }, - { value: "10.9", label: "OS X Mavericks" }, { value: "10.10", label: "OS X Yosemite" }, { value: "10.11", label: "OS X El Capitan" }, { value: "10.12", label: "macOS Sierra" }, @@ -117,21 +89,10 @@ export const macOSVersions: SelectOption[] = [ { value: "12.0", label: "macOS Monterey" }, { value: "13.0", label: "macOS Ventura" }, { value: "14.0", label: "macOS Sonoma" }, + { value: "15.0", label: "macOS Sequoia" }, ]; export const androidVersions: SelectOption[] = [ - { value: "1.5", label: "Android Cupcake" }, - { value: "1.6", label: "Android Donut" }, - { value: "2.0", label: "Android Eclair" }, - { value: "2.2", label: "Android Froyo" }, - { value: "2.3", label: "Android Gingerbread" }, - { value: "3.0", label: "Android Honeycomb" }, - { value: "4.0", label: "Android Ice Cream Sandwich" }, - { value: "4.1", label: "Android Jelly Bean" }, - { value: "4.4", label: "Android KitKat" }, - { value: "5.0", label: "Android Lollipop" }, - { value: "6.0", label: "Android Marshmallow" }, - { value: "7.0", label: "Android Nougat" }, { value: "8.0", label: "Android Oreo" }, { value: "9.0", label: "Android Pie" }, { value: "10", label: "Android 10" }, @@ -140,4 +101,5 @@ export const androidVersions: SelectOption[] = [ { value: "13", label: "Android 13" }, { value: "14", label: "Android 14" }, { value: "15", label: "Android 15" }, + { value: "16", label: "Android 16" }, ]; diff --git a/src/interfaces/SetupKey.ts b/src/interfaces/SetupKey.ts index d1af7350..2ba416c5 100644 --- a/src/interfaces/SetupKey.ts +++ b/src/interfaces/SetupKey.ts @@ -16,4 +16,5 @@ export interface SetupKey { expires_in: number; usage_limit: number | null; ephemeral: boolean; + allow_extra_dns_labels: boolean; } diff --git a/src/modules/access-control/AccessControlModal.tsx b/src/modules/access-control/AccessControlModal.tsx index 246b12cc..b86efc59 100644 --- a/src/modules/access-control/AccessControlModal.tsx +++ b/src/modules/access-control/AccessControlModal.tsx @@ -149,6 +149,8 @@ export function AccessControlModalContent({ submit, isPostureChecksLoading, getPolicyData, + destinationResource, + setDestinationResource, } = useAccessControl({ policy, postureCheckTemplates, @@ -166,9 +168,10 @@ export function AccessControlModalContent({ }); const continuePostureChecksDisabled = useMemo(() => { - if (sourceGroups.length == 0 || destinationGroups.length == 0) return true; if (direction != "bi" && ports.length == 0) return true; - }, [sourceGroups, destinationGroups, direction, ports]); + if (sourceGroups.length > 0 && destinationResource) return false; + if (sourceGroups.length == 0 || destinationGroups.length == 0) return true; + }, [sourceGroups, destinationGroups, direction, ports, destinationResource]); const submitDisabled = useMemo(() => { if (name.length == 0) return true; @@ -304,6 +307,10 @@ export function AccessControlModalContent({ onChange={setDestinationGroups} values={destinationGroups} saveGroupAssignments={useSave} + resource={destinationResource} + onResourceChange={setDestinationResource} + showResources={true} + placeholder={"Select destination(s)..."} />
diff --git a/src/modules/access-control/table/AccessControlActiveCell.tsx b/src/modules/access-control/table/AccessControlActiveCell.tsx index fefd695a..ef2b3698 100644 --- a/src/modules/access-control/table/AccessControlActiveCell.tsx +++ b/src/modules/access-control/table/AccessControlActiveCell.tsx @@ -32,6 +32,9 @@ export default function AccessControlActiveCell({ policy }: Readonly) { return group.id; }) as string[]) : []; + if (rule.destinationResource) { + rule.destinations = null; + } }); updatePolicy( diff --git a/src/modules/access-control/table/AccessControlDestinationsCell.tsx b/src/modules/access-control/table/AccessControlDestinationsCell.tsx index 551cb181..bc6c9181 100644 --- a/src/modules/access-control/table/AccessControlDestinationsCell.tsx +++ b/src/modules/access-control/table/AccessControlDestinationsCell.tsx @@ -1,18 +1,49 @@ import MultipleGroups from "@components/ui/MultipleGroups"; +import ResourceBadge from "@components/ui/ResourceBadge"; +import useFetchApi from "@utils/api"; import React, { useMemo } from "react"; +import Skeleton from "react-loading-skeleton"; import { Group } from "@/interfaces/Group"; -import { Policy } from "@/interfaces/Policy"; +import { NetworkResource } from "@/interfaces/Network"; +import { Policy, PolicyRuleResource } from "@/interfaces/Policy"; type Props = { policy: Policy; }; -export default function AccessControlDestinationsCell({ policy }: Props) { +export default function AccessControlDestinationsCell({ + policy, +}: Readonly) { const firstRule = useMemo(() => { if (policy.rules.length > 0) return policy.rules[0]; return undefined; }, [policy]); + if (firstRule?.destinationResource) { + return ( + + ); + } + return firstRule ? ( ) : null; } + +const AccessControlDestinationResourceCell = ({ + resource, +}: { + resource: PolicyRuleResource; +}) => { + const { data: resources, isLoading } = useFetchApi( + "/networks/resources", + ); + if (isLoading) return ; + + return ( +
+ r.id === resource.id)} /> +
+ ); +}; diff --git a/src/modules/access-control/useAccessControl.ts b/src/modules/access-control/useAccessControl.ts index 0dd641c3..0505e957 100644 --- a/src/modules/access-control/useAccessControl.ts +++ b/src/modules/access-control/useAccessControl.ts @@ -117,6 +117,10 @@ export const useAccessControl = ({ : initialDestinationGroups ?? [], }); + const [destinationResource, setDestinationResource] = useState( + firstRule?.destinationResource, + ); + const { updateOrCreateAndNotify: checkToCreate } = usePostureCheck({}); const createPostureChecksWithoutID = async () => { const checks = postureChecks.filter( @@ -146,7 +150,8 @@ export const useAccessControl = ({ description, name, sources: sources, - destinations: destinations, + destinations: destinationResource ? undefined : destinations, + destinationResource: destinationResource || undefined, action: "accept", protocol, enabled, @@ -214,7 +219,8 @@ export const useAccessControl = ({ protocol, enabled, sources, - destinations, + destinations: destinationResource ? undefined : destinations, + destinationResource: destinationResource || undefined, ports: ports.length > 0 ? ports.map((p) => p.toString()) : undefined, }, ], @@ -268,5 +274,7 @@ export const useAccessControl = ({ getPolicyData, portAndDirectionDisabled, isPostureChecksLoading, + destinationResource, + setDestinationResource, } as const; }; diff --git a/src/modules/common-table-rows/LastTimeRow.tsx b/src/modules/common-table-rows/LastTimeRow.tsx index 358daa11..0373e580 100644 --- a/src/modules/common-table-rows/LastTimeRow.tsx +++ b/src/modules/common-table-rows/LastTimeRow.tsx @@ -26,7 +26,7 @@ export default function LastTimeRow({
<> diff --git a/src/modules/exit-node/ExitNodeDropdownButton.tsx b/src/modules/exit-node/ExitNodeDropdownButton.tsx index 17a451c1..a8e4bfa0 100644 --- a/src/modules/exit-node/ExitNodeDropdownButton.tsx +++ b/src/modules/exit-node/ExitNodeDropdownButton.tsx @@ -1,11 +1,9 @@ import { DropdownMenuItem } from "@components/DropdownMenu"; import { Modal } from "@components/modal/Modal"; -import { getOperatingSystem } from "@hooks/useOperatingSystem"; import { IconCirclePlus, IconDirectionSign } from "@tabler/icons-react"; import * as React from "react"; import { useState } from "react"; import RoutesProvider from "@/contexts/RoutesProvider"; -import { OperatingSystem } from "@/interfaces/OperatingSystem"; import { Peer } from "@/interfaces/Peer"; import { useHasExitNodes } from "@/modules/exit-node/useHasExitNodes"; import { RouteModalContent } from "@/modules/routes/RouteModal"; @@ -16,10 +14,9 @@ type Props = { export const ExitNodeDropdownButton = ({ peer }: Props) => { const [modal, setModal] = useState(false); - const isLinux = getOperatingSystem(peer.os) === OperatingSystem.LINUX; const hasExitNodes = useHasExitNodes(peer); - return isLinux ? ( + return ( <> setModal(true)}>
@@ -55,5 +52,5 @@ export const ExitNodeDropdownButton = ({ peer }: Props) => { )} - ) : null; + ); }; diff --git a/src/modules/groups/GroupSelector.tsx b/src/modules/groups/GroupSelector.tsx index c3b77d80..d46a1909 100644 --- a/src/modules/groups/GroupSelector.tsx +++ b/src/modules/groups/GroupSelector.tsx @@ -3,6 +3,7 @@ import { Checkbox } from "@components/Checkbox"; import { CommandItem } from "@components/Command"; import { Popover, PopoverContent, PopoverTrigger } from "@components/Popover"; import { ScrollArea } from "@components/ScrollArea"; +import { GroupBadgeIcon } from "@components/ui/GroupBadgeIcon"; import TextWithTooltip from "@components/ui/TextWithTooltip"; import { IconArrowBack } from "@tabler/icons-react"; import { cn } from "@utils/helpers"; @@ -171,10 +172,13 @@ export function GroupSelector({ >
- +
{ + const isJWTGroup = issued === GroupIssued.JWT; + const isOktaGroup = !!id?.includes("okta"); + const isGoogleGroup = !!id?.includes("google"); + const isAzureGroup = !!id?.includes("azure"); + + const isRegularGroup = + !isJWTGroup && !isOktaGroup && !isGoogleGroup && !isAzureGroup; + + return { + isOktaGroup, + isGoogleGroup, + isAzureGroup, + isJWTGroup, + isRegularGroup, + }; +}; diff --git a/src/modules/networks/NetworkModal.tsx b/src/modules/networks/NetworkModal.tsx index d99cca90..bbe1df2f 100644 --- a/src/modules/networks/NetworkModal.tsx +++ b/src/modules/networks/NetworkModal.tsx @@ -97,7 +97,7 @@ const Content = ({ network, onCreated, onUpdated }: ContentProps) => { description={ network ? network.name - : "Access resources like LANs and VPC by adding a network." + : "Access internal resources in LANs and VPC by adding a network." } color={"netbird"} /> @@ -131,7 +131,10 @@ const Content = ({ network, onCreated, onUpdated }: ContentProps) => {
Learn more about - + Networks diff --git a/src/modules/networks/misc/NetworkInformationSquare.tsx b/src/modules/networks/misc/NetworkInformationSquare.tsx index 4ac84b81..93e6046d 100644 --- a/src/modules/networks/misc/NetworkInformationSquare.tsx +++ b/src/modules/networks/misc/NetworkInformationSquare.tsx @@ -1,4 +1,4 @@ -import DescriptionWithTooltip from "@components/ui/DescriptionWithTooltip"; +import TruncatedText from "@components/ui/TruncatedText"; import { cn } from "@utils/helpers"; import { ArrowRightIcon } from "lucide-react"; import * as React from "react"; @@ -51,16 +51,19 @@ export const NetworkInformationSquare = ({ >
-

- {name} -

- + diff --git a/src/modules/networks/misc/NetworkNavigation.tsx b/src/modules/networks/misc/NetworkNavigation.tsx index 9d7ec8dd..e625b7be 100644 --- a/src/modules/networks/misc/NetworkNavigation.tsx +++ b/src/modules/networks/misc/NetworkNavigation.tsx @@ -1,5 +1,5 @@ import SidebarItem from "@components/SidebarItem"; -import { NewBadge } from "@components/ui/NewBadge"; +import { SmallBadge } from "@components/ui/SmallBadge"; import * as React from "react"; import NetworkRoutesIcon from "@/assets/icons/NetworkRoutesIcon"; @@ -11,7 +11,7 @@ export const NetworkNavigation = () => { label={
Networks - +
} href={"/networks"} diff --git a/src/modules/networks/resources/NetworkResourceModal.tsx b/src/modules/networks/resources/NetworkResourceModal.tsx index 08c86f9b..06a2c5c5 100644 --- a/src/modules/networks/resources/NetworkResourceModal.tsx +++ b/src/modules/networks/resources/NetworkResourceModal.tsx @@ -17,6 +17,7 @@ import { notify } from "@components/Notification"; import Paragraph from "@components/Paragraph"; import { PeerGroupSelector } from "@components/PeerGroupSelector"; import Separator from "@components/Separator"; +import { Textarea } from "@components/Textarea"; import { useApiCall } from "@utils/api"; import { ExternalLinkIcon, @@ -156,6 +157,18 @@ export function ResourceModalContent({ onChange={(e) => setName(e.target.value)} />
+
+ + + Write a short description to add more context to this resource. + +