diff --git a/src/app/(dashboard)/peer/page.tsx b/src/app/(dashboard)/peer/page.tsx index d80a31fb..d2bfa5e4 100644 --- a/src/app/(dashboard)/peer/page.tsx +++ b/src/app/(dashboard)/peer/page.tsx @@ -2,6 +2,7 @@ import Breadcrumbs from "@components/Breadcrumbs"; import Button from "@components/Button"; +import { Callout } from "@components/Callout"; import Card from "@components/Card"; import FancyToggleSwitch from "@components/FancyToggleSwitch"; import FullTooltip from "@components/FullTooltip"; @@ -219,7 +220,7 @@ const PeerGeneralInformation = () => {
@@ -324,7 +325,7 @@ const PeerGeneralInformation = () => { } interactive={false} className={"w-full block"} - disabled={!permission.peers.update} + disabled={permission.peers.update} > { function PeerInformationCard({ peer }: Readonly<{ peer: Peer }>) { const { isLoading, getRegionByPeer } = useCountries(); + const { update } = usePeer(); + const { mutate } = useSWRConfig(); + const [showEditIPModal, setShowEditIPModal] = useState(false); + const { permission } = usePermissions(); const countryText = useMemo(() => { return getRegionByPeer(peer); }, [getRegionByPeer, peer]); return ( - - - - - NetBird IP-Address - - } - value={peer.ip} - /> - - - - Public IP-Address - - } - value={peer.connection_ip} + <> + + { + notify({ + title: peer.name, + description: "Peer IP was successfully updated", + promise: update({ ip: newIP }).then(() => { + mutate("/peers/" + peer.id); + setShowEditIPModal(false); + }), + loadingMessage: "Updating peer IP...", + }); + }} + peer={peer} + key={showEditIPModal ? 1 : 0} /> + + + + + + NetBird IP Address + + } + valueToCopy={peer.ip} + value={ +
+ {peer.ip} + {permission.peers.update && ( + + )} +
+ } + /> - - - Domain Name - - } - className={ - peer?.extra_dns_labels && peer.extra_dns_labels.length > 0 - ? "items-start" - : "" - } - value={peer.dns_label} - extraText={peer?.extra_dns_labels} - /> + + + Public IP Address + + } + value={peer.connection_ip} + /> - - - Hostname - - } - value={peer.hostname} - /> + + + Domain Name + + } + className={ + peer?.extra_dns_labels && peer.extra_dns_labels.length > 0 + ? "items-start" + : "" + } + value={peer.dns_label} + extraText={peer?.extra_dns_labels} + /> - - - Region - - } - tooltip={false} - value={ - isEmpty(peer.country_code) ? ( - "Unknown" - ) : ( + - {isLoading ? ( - - ) : ( -
-
- -
- {countryText} -
- )} + + Hostname - ) - } - /> + } + value={peer.hostname} + /> - - - Operating System - - } - value={peer.os} - /> + + + Region + + } + tooltip={false} + value={ + isEmpty(peer.country_code) ? ( + "Unknown" + ) : ( + <> + {isLoading ? ( + + ) : ( +
+
+ +
+ {countryText} +
+ )} + + ) + } + /> - {peer.serial_number && peer.serial_number !== "" && ( - - Serial Number + + Operating System } - value={peer.serial_number} + value={peer.os} /> - )} - - - Last seen - - } - value={ - peer.connected - ? "just now" - : dayjs(peer.last_seen).format("D MMMM, YYYY [at] h:mm A") + - " (" + - dayjs().to(peer.last_seen) + - ")" - } - /> + {peer.serial_number && peer.serial_number !== "" && ( + + + Serial Number + + } + value={peer.serial_number} + /> + )} - - - Agent Version - - } - value={peer.version} - /> + + + Last seen + + } + value={ + peer.connected + ? "just now" + : dayjs(peer.last_seen).format("D MMMM, YYYY [at] h:mm A") + + " (" + + dayjs().to(peer.last_seen) + + ")" + } + /> - {peer.ui_version && ( - UI Version + Agent Version } - value={peer.ui_version?.replace("netbird-desktop-ui/", "")} + value={peer.version} /> - )} -
-
+ + {peer.ui_version && ( + + + UI Version + + } + value={peer.ui_version?.replace("netbird-desktop-ui/", "")} + /> + )} +
+
+ ); } @@ -654,3 +697,83 @@ function EditNameModal({ onSuccess, peer, initialName }: Readonly) { ); } + +interface EditIPModalProps { + onSuccess: (ip: string) => void; + peer: Peer; +} + +function EditIPModal({ onSuccess, peer }: Readonly) { + const [ip, setIP] = useState(peer.ip); + const [error, setError] = useState(""); + + const validateIP = (ipAddress: string) => { + const ipRegex = + /^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/; + return ipRegex.test(ipAddress); + }; + + const isDisabled = useMemo(() => { + if (ip === peer.ip) return true; + const trimmedIP = trim(ip); + return trimmedIP.length === 0 || !validateIP(ip); + }, [ip, peer.ip]); + + React.useEffect(() => { + switch (true) { + case ip === peer.ip: + setError(""); + break; + case !validateIP(ip): + setError("Please enter a valid IP, e.g., 100.64.0.15"); + break; + default: + setError(""); + break; + } + }, [ip, peer.ip]); + + return ( + +
+ + +
+
+ setIP(e.target.value)} + error={error} + /> +
+ + Changes take effect when the peer reconnects. +
+ + +
+ + + + + +
+
+ +
+ ); +} diff --git a/src/components/Card.tsx b/src/components/Card.tsx index f63a8d12..e2611761 100644 --- a/src/components/Card.tsx +++ b/src/components/Card.tsx @@ -29,6 +29,7 @@ function CardList({ children }: Props) { type CardListItemProps = { label: React.ReactNode; value: React.ReactNode; + valueToCopy?: string; className?: string; copy?: boolean; copyText?: string; @@ -39,6 +40,7 @@ type CardListItemProps = { function CardListItem({ label, value, + valueToCopy, className, copy = false, copyText, @@ -57,6 +59,7 @@ function CardListItem({ { - const [, copyToClipBoard] = useCopyToClipboard(value as string); + const [, copyToClipBoard] = useCopyToClipboard(valueToCopy ?? `${value}`); return (
Promise; openSSHDialog: () => Promise; deletePeer: () => void; @@ -36,7 +37,7 @@ const PeerContext = React.createContext( export default function PeerProvider({ children, peer }: Props) { const user = usePeerUser(peer); const { peerGroups, isLoading } = usePeerGroups(peer); - const peerRequest = useApiCall("/peers"); + const peerRequest = useApiCall("/peers", true); const { confirm } = useDialog(); const { mutate } = useSWRConfig(); @@ -68,6 +69,7 @@ export default function PeerProvider({ children, peer }: Props) { loginExpiration?: boolean; inactivityExpiration?: boolean; approval_required?: boolean; + ip?: string; }) => { return peerRequest.put( { @@ -86,6 +88,7 @@ export default function PeerProvider({ children, peer }: Props) { props?.approval_required == undefined ? undefined : props.approval_required, + ip: props.ip != undefined ? props.ip : undefined, }, `/${peer.id}`, ); diff --git a/src/interfaces/Account.ts b/src/interfaces/Account.ts index ed4d5e29..db87702b 100644 --- a/src/interfaces/Account.ts +++ b/src/interfaces/Account.ts @@ -19,6 +19,7 @@ export interface Account { regular_users_view_blocked: boolean; routing_peer_dns_resolution_enabled: boolean; dns_domain: string; + network_range?: string; lazy_connection_enabled: boolean; }; } diff --git a/src/modules/activity/ActivityDescription.tsx b/src/modules/activity/ActivityDescription.tsx index 38165c12..9b15bb97 100644 --- a/src/modules/activity/ActivityDescription.tsx +++ b/src/modules/activity/ActivityDescription.tsx @@ -63,11 +63,12 @@ export default function ActivityDescription({ event }: Props) {
); - if (event.activity_code == "setupkey.peer.add") + if (event.activity_code == "peer.setupkey.add") return (
Peer {m.name} was added - with the NetBird IP {m.ip} + with the NetBird IP {m.ip} using the setup key{" "} + {m.setup_key_name}
); @@ -340,6 +341,14 @@ export default function ActivityDescription({ event }: Props) {
); + if (event.activity_code == "peer.ip.update") + return ( +
+ Peer {m.name} IP address was updated from{" "} + {m.old_ip} to {m.ip} +
+ ); + /** * Group */ @@ -378,6 +387,15 @@ export default function ActivityDescription({ event }: Props) { if (event.activity_code == "account.setting.peer.login.expiration.disable") return
Global login expiration was disabled
; + if (event.activity_code == "account.network.range.update") + return ( +
+ Account network range was updated from{" "} + {m.old_network_range} to{" "} + {m.new_network_range} +
+ ); + /** * Nameserver */ diff --git a/src/modules/activity/UsersDropdownSelector.tsx b/src/modules/activity/UsersDropdownSelector.tsx index e86954ec..48e57a52 100644 --- a/src/modules/activity/UsersDropdownSelector.tsx +++ b/src/modules/activity/UsersDropdownSelector.tsx @@ -1,16 +1,16 @@ import Button from "@components/Button"; -import { CommandItem } from "@components/Command"; +import { DropdownInfoText } from "@components/DropdownInfoText"; +import { DropdownInput } from "@components/DropdownInput"; import { Popover, PopoverContent, PopoverTrigger } from "@components/Popover"; -import { ScrollArea } from "@components/ScrollArea"; import { SmallBadge } from "@components/ui/SmallBadge"; import TextWithTooltip from "@components/ui/TextWithTooltip"; -import { cn, generateColorFromString } from "@utils/helpers"; -import { Command, CommandGroup, CommandInput, CommandList } from "cmdk"; -import { sortBy, trim, uniqBy } from "lodash"; -import { ChevronsUpDown, Cog, SearchIcon, UserCircle2 } from "lucide-react"; +import { VirtualScrollAreaList } from "@components/VirtualScrollAreaList"; +import { useSearch } from "@hooks/useSearch"; +import { generateColorFromString } from "@utils/helpers"; +import { sortBy, uniqBy } from "lodash"; +import { ChevronsUpDown, Cog, UserCircle2 } from "lucide-react"; import * as React from "react"; import { useMemo, useState } from "react"; -import { useDebounce } from "@/hooks/useDebounce"; import { useElementSize } from "@/hooks/useElementSize"; import { SmallUserAvatar } from "@/modules/users/SmallUserAvatar"; @@ -29,17 +29,35 @@ export type UserSelectOption = { external?: boolean; }; +const searchPredicate = (item: UserSelectOption, query: string) => { + const lowerCaseQuery = query.toLowerCase(); + if ( + item.email === "NetBird" && + "NetBird System".toLowerCase().includes(lowerCaseQuery) + ) + return true; + if (item.name.toLowerCase().includes(lowerCaseQuery)) return true; + if (item.email?.toLowerCase().includes(lowerCaseQuery)) return true; + return item.id.toLowerCase().startsWith(lowerCaseQuery); +}; + export function UsersDropdownSelector({ onChange, value, disabled = false, popoverWidth = 250, options, -}: Props) { - const searchRef = React.useRef(null); +}: Readonly) { + const [filteredItems, search, setSearch] = useSearch( + options.concat({ + id: "all-users", + name: "All Users", + email: "Include all users", + }), + searchPredicate, + { filter: true, debounce: 150 }, + ); const [inputRef, { width }] = useElementSize(); - const [searchInput, setSearchInput] = useState(""); - const search = useDebounce(searchInput, 500); const toggle = (item: string | undefined) => { const isSelected = value == item; @@ -47,7 +65,7 @@ export function UsersDropdownSelector({ onChange && onChange(undefined); } else { onChange && onChange(item); - setSearchInput(""); + setSearch(""); } setOpen(false); }; @@ -55,11 +73,17 @@ export function UsersDropdownSelector({ const [open, setOpen] = useState(false); const sortedOptions = useMemo(() => { - return sortBy( - uniqBy(options, (o) => o.email), + const sorted = sortBy( + uniqBy(filteredItems, (o) => o.email), ["external", "name"], ); - }, [options]); + const allUsersIndex = sorted.findIndex((o) => o.id === "all-users"); + if (allUsersIndex > -1) { + const allUsers = sorted.splice(allUsersIndex, 1)[0]; + sorted.unshift(allUsers); + } + return sorted; + }, [filteredItems]); const selectedUser = useMemo(() => { return options.find((user) => user.email == value); @@ -71,7 +95,7 @@ export function UsersDropdownSelector({ onOpenChange={(isOpen) => { if (!isOpen) { setTimeout(() => { - setSearchInput(""); + setSearch(""); }, 100); } setOpen(isOpen); @@ -137,145 +161,105 @@ export function UsersDropdownSelector({ side={"bottom"} sideOffset={10} > - { - const formatValue = trim(value.toLowerCase()); - const formatSearch = trim(search.toLowerCase()); - if (formatValue.includes(formatSearch)) return 1; - return 0; - }} - > - -
- -
-
- -
-
+
+ + + {options.length == 0 && !search && ( +
+ + {"No users available to select."} + +
+ )} + + {filteredItems.length == 0 && search != "" && ( +
+ + There are no users matching your search. +
+ )} - - -
- { - toggle(undefined); - }} - onClick={(e) => e.preventDefault()} - > -
+ {sortedOptions.length > 0 && ( + { + if (item.id === "all-users") { + toggle(undefined); + return; + } + toggle(item.email); + }} + renderItem={(user) => { + const isSystemUser = user.email === "NetBird"; + + return ( +
+ {user.id === "all-users" ? (
+ ) : ( + + )} -
- All Users - - Include all users - -
-
- - - {sortedOptions.map((user) => { - const isSystemUser = user.email === "NetBird"; - const searchValue = isSystemUser - ? "NetBird System" - : user.name + " " + user.id + " " + user.email; - - return ( - { - toggle(user.email); - }} - onClick={(e) => e.preventDefault()} +
+ -
- - -
- - - - - - -
- {user.external && ( - - - - )} -
- - ); - })} -
- - - - + + + + + +
+ {user.external && ( + + + + )} +
+ ); + }} + /> + )} +
); diff --git a/src/modules/settings/NetworkSettingsTab.tsx b/src/modules/settings/NetworkSettingsTab.tsx index 24029c00..17e4d4c6 100644 --- a/src/modules/settings/NetworkSettingsTab.tsx +++ b/src/modules/settings/NetworkSettingsTab.tsx @@ -11,6 +11,7 @@ import * as Tabs from "@radix-ui/react-tabs"; import { useApiCall } from "@utils/api"; import { validator } from "@utils/helpers"; import { isNetBirdHosted } from "@utils/netbird"; +import cidr from "ip-cidr"; import { ExternalLinkIcon, GlobeIcon, NetworkIcon } from "lucide-react"; import React, { useMemo, useState } from "react"; import { useSWRConfig } from "swr"; @@ -34,6 +35,9 @@ export default function NetworkSettingsTab({ account }: Readonly) { const [customDNSDomain, setCustomDNSDomain] = useState( account.settings.dns_domain || "", ); + const [networkRange, setNetworkRange] = useState( + account.settings.network_range || "", + ); const toggleNetworkDNSSetting = async (toggle: boolean) => { notify({ @@ -57,25 +61,37 @@ export default function NetworkSettingsTab({ account }: Readonly) { }); }; - const { hasChanges, updateRef } = useHasChanges([customDNSDomain]); + const { hasChanges, updateRef } = useHasChanges([ + customDNSDomain, + networkRange, + ]); const saveChanges = async () => { + const updatedSettings = { + ...account.settings, + }; + + if (customDNSDomain !== "" || account.settings.dns_domain) { + updatedSettings.dns_domain = customDNSDomain; + } + + if (networkRange !== "") { + updatedSettings.network_range = networkRange; + } + notify({ - title: "Custom DNS Domain", - description: `Custom DNS Domain successfully updated.`, + title: "Network Settings", + description: `Network settings successfully updated.`, promise: saveRequest .put({ id: account.id, - settings: { - ...account.settings, - dns_domain: customDNSDomain || "", - }, + settings: updatedSettings, }) .then(() => { mutate("/accounts"); - updateRef([customDNSDomain]); + updateRef([customDNSDomain, networkRange]); }), - loadingMessage: "Updating Custom DNS domain...", + loadingMessage: "Updating network settings...", }); }; @@ -90,6 +106,24 @@ export default function NetworkSettingsTab({ account }: Readonly) { } }, [customDNSDomain]); + const networkRangeError = useMemo(() => { + if (networkRange == "") { + if (account.settings.network_range) { + return "Network range cannot be empty"; + } + return ""; + } + + try { + const validCIDR = cidr.isValidCIDR(networkRange); + if (!validCIDR) { + return "Please enter a valid IPv4 CIDR range, e.g. 100.64.0.0/16 or 192.168.1.0/24"; + } + } catch (error) { + return "Please enter a valid IPv4 CIDR range, e.g. 100.64.0.0/16 or 192.168.1.0/24"; + } + }, [networkRange, account.settings.network_range]); + return (
@@ -112,7 +146,12 @@ export default function NetworkSettingsTab({ account }: Readonly) {
@@ -149,6 +189,33 @@ export default function NetworkSettingsTab({ account }: Readonly) {
+
+
+
+ + + Specify a custom IPv4 range for your network in CIDR format. + All peer IPs will be re-allocated when changed. + +
+
+ setNetworkRange(e.target.value)} + /> +
+
+
+