From 9be1fa7b303a346c4b9948fd41d67a996fe93e53 Mon Sep 17 00:00:00 2001 From: Viktor Liu Date: Sun, 22 Mar 2026 06:59:25 +0100 Subject: [PATCH 01/13] Add IPv6 overlay settings, peer display, and per-group control - IPv6 network range input with /48-/112 CIDR validation - Per-group IPv6 enabled selector (replaces simple toggle) - Peer IPv6 display: table cell, tooltip, detail page, selectors, search - Gray out IPv6 for peers whose client does not support it yet - DeviceCard and routing peers show IPv6 when available --- src/app/(dashboard)/peer/page.tsx | 128 ++++++++++++++++++ src/components/DeviceCard.tsx | 9 +- src/components/PeerGroupSelector.tsx | 4 +- src/components/PeerSelector.tsx | 5 +- src/contexts/PeerProvider.tsx | 2 + src/interfaces/Account.ts | 2 + src/interfaces/Peer.ts | 1 + .../NetworkRoutingPeersTabContent.tsx | 2 +- src/modules/peers/PeerAddressCell.tsx | 1 + .../peers/PeerAddressTooltipContent.tsx | 15 ++ src/modules/settings/NetworkSettingsTab.tsx | 88 +++++++++++- src/utils/version.ts | 1 + 12 files changed, 250 insertions(+), 8 deletions(-) diff --git a/src/app/(dashboard)/peer/page.tsx b/src/app/(dashboard)/peer/page.tsx index a5cd5504..636f4c9f 100644 --- a/src/app/(dashboard)/peer/page.tsx +++ b/src/app/(dashboard)/peer/page.tsx @@ -3,6 +3,7 @@ import Breadcrumbs from "@components/Breadcrumbs"; import Button from "@components/Button"; import { Callout } from "@components/Callout"; +import cidr from "ip-cidr"; import Card from "@components/Card"; import HelpText from "@components/HelpText"; import { Input } from "@components/Input"; @@ -469,6 +470,7 @@ function PeerInformationCard({ peer }: Readonly<{ peer: Peer }>) { const { update } = usePeer(); const { mutate } = useSWRConfig(); const [showEditIPModal, setShowEditIPModal] = useState(false); + const [showEditIPv6Modal, setShowEditIPv6Modal] = useState(false); const { permission } = usePermissions(); const countryText = useMemo(() => { @@ -494,6 +496,23 @@ function PeerInformationCard({ peer }: Readonly<{ peer: Peer }>) { key={showEditIPModal ? 1 : 0} /> + + { + notify({ + title: peer.name, + description: "Peer IPv6 was successfully updated", + promise: update({ ipv6: newIPv6 }).then(() => { + mutate("/peers/" + peer.id); + setShowEditIPv6Modal(false); + }), + loadingMessage: "Updating peer IPv6...", + }); + }} + peer={peer} + key={showEditIPv6Modal ? 1 : 0} + /> + ) { } /> + {peer.ipv6 && ( + + + NetBird IPv6 Address + + } + valueToCopy={peer.ipv6} + value={ +
+ {peer.ipv6} + {permission.peers.update && ( + + )} +
+ } + /> + )} + ) { ); } + +interface EditIPv6ModalProps { + onSuccess: (ipv6: string) => void; + peer: Peer; +} + +function isValidIPv6(address: string): boolean { + return cidr.isValidAddress(address) && address.includes(":"); +} + +function EditIPv6Modal({ onSuccess, peer }: Readonly) { + const [ipv6, setIPv6] = useState(peer.ipv6 || ""); + const [error, setError] = useState(""); + + const isDisabled = useMemo(() => { + if (ipv6 === peer.ipv6) return true; + const trimmed = trim(ipv6); + return trimmed.length === 0 || !isValidIPv6(trimmed); + }, [ipv6, peer.ipv6]); + + React.useEffect(() => { + switch (true) { + case ipv6 === peer.ipv6: + setError(""); + break; + case !isValidIPv6(trim(ipv6)): + setError("Please enter a valid IPv6 address, e.g., fd00:1234::1"); + break; + default: + setError(""); + break; + } + }, [ipv6, peer.ipv6]); + + return ( + +
+ + +
+
+ setIPv6(e.target.value)} + error={error} + /> +
+ + Changes take effect when the peer reconnects. +
+ + +
+ + + + + +
+
+ +
+ ); +} diff --git a/src/components/DeviceCard.tsx b/src/components/DeviceCard.tsx index 9f0b74c6..a84de586 100644 --- a/src/components/DeviceCard.tsx +++ b/src/components/DeviceCard.tsx @@ -28,8 +28,13 @@ export const DeviceCard = ({ const descriptionText = useMemo(() => { return description !== undefined ? description - : address || device?.ip || resource?.address; - }, [description, address, device]); + : address || + (device?.ip + ? device.ipv6 + ? `${device.ip}, ${device.ipv6}` + : device.ip + : resource?.address); + }, [description, address, device, resource]); return (
{ const lowerCaseQuery = query.toLowerCase(); if (item.name.toLowerCase().includes(lowerCaseQuery)) return true; - return item.ip.toLowerCase().includes(lowerCaseQuery); + if (item.ip.toLowerCase().includes(lowerCaseQuery)) return true; + return item.ipv6?.toLowerCase().includes(lowerCaseQuery) ?? false; }; const PeersList = ({ @@ -1059,6 +1060,7 @@ const PeersList = ({ } > {res.ip} + {res.ipv6 && `, ${res.ipv6}`}
diff --git a/src/components/PeerSelector.tsx b/src/components/PeerSelector.tsx index df7101b3..f3c9c8ff 100644 --- a/src/components/PeerSelector.tsx +++ b/src/components/PeerSelector.tsx @@ -30,7 +30,8 @@ const searchPredicate = (item: Peer, query: string) => { const lowerCaseQuery = query.toLowerCase(); if (item.name.toLowerCase().includes(lowerCaseQuery)) return true; if (item.hostname.toLowerCase().includes(lowerCaseQuery)) return true; - return item.ip.toLowerCase().startsWith(lowerCaseQuery); + if (item.ip.toLowerCase().startsWith(lowerCaseQuery)) return true; + return !!item.ipv6?.toLowerCase().startsWith(lowerCaseQuery); }; export function PeerSelector({ @@ -126,6 +127,7 @@ export function PeerSelector({ > {value.ip} + {value.ipv6 && `, ${value.ipv6}`} ) : ( @@ -240,6 +242,7 @@ export function PeerSelector({ > {option.ip} + {option.ipv6 && `, ${option.ipv6}`} ); diff --git a/src/contexts/PeerProvider.tsx b/src/contexts/PeerProvider.tsx index 7521d22c..d0a45f0b 100644 --- a/src/contexts/PeerProvider.tsx +++ b/src/contexts/PeerProvider.tsx @@ -80,6 +80,7 @@ export default function PeerProvider({ inactivityExpiration?: boolean; approval_required?: boolean; ip?: string; + ipv6?: string; }) => { return peerRequest.put( { @@ -99,6 +100,7 @@ export default function PeerProvider({ ? undefined : props.approval_required, ip: props.ip != undefined ? props.ip : undefined, + ipv6: props.ipv6 != undefined ? props.ipv6 : undefined, }, `/${peer.id}`, ); diff --git a/src/interfaces/Account.ts b/src/interfaces/Account.ts index c87c55e5..5e4b56c9 100644 --- a/src/interfaces/Account.ts +++ b/src/interfaces/Account.ts @@ -28,6 +28,8 @@ export interface Account { auto_update_version: string; auto_update_always: boolean; local_auth_disabled?: boolean; + ipv6_enabled_groups?: string[]; + network_range_v6?: string; }; onboarding?: AccountOnboarding; } diff --git a/src/interfaces/Peer.ts b/src/interfaces/Peer.ts index bacad514..537d106e 100644 --- a/src/interfaces/Peer.ts +++ b/src/interfaces/Peer.ts @@ -5,6 +5,7 @@ export interface Peer { id?: string; name: string; ip: string; + ipv6?: string; connected: boolean; created_at?: Date; last_seen: Date; diff --git a/src/modules/networks/routing-peers/NetworkRoutingPeersTabContent.tsx b/src/modules/networks/routing-peers/NetworkRoutingPeersTabContent.tsx index ab5865b5..a5e316e2 100644 --- a/src/modules/networks/routing-peers/NetworkRoutingPeersTabContent.tsx +++ b/src/modules/networks/routing-peers/NetworkRoutingPeersTabContent.tsx @@ -34,7 +34,7 @@ export const NetworkRoutingPeersTabContent = ({ return { ...router, - search: `${peer?.name ?? ""} ${peer?.ip ?? ""} ${user?.name ?? ""} ${user?.id ?? ""} ${group?.name ?? ""}`, + search: `${peer?.name ?? ""} ${peer?.ip ?? ""} ${peer?.ipv6 ?? ""} ${user?.name ?? ""} ${user?.id ?? ""} ${group?.name ?? ""}`, }; }); }, [users, peers, routers, groups]); diff --git a/src/modules/peers/PeerAddressCell.tsx b/src/modules/peers/PeerAddressCell.tsx index 2de803ce..742e8f94 100644 --- a/src/modules/peers/PeerAddressCell.tsx +++ b/src/modules/peers/PeerAddressCell.tsx @@ -54,6 +54,7 @@ export default function PeerAddressCell({ peer }: Props) { className={"dark:text-nb-gray-400 font-mono font-thin text-xs"} > {peer.ip} + {peer.ipv6 && `, ${peer.ipv6}`} diff --git a/src/modules/peers/PeerAddressTooltipContent.tsx b/src/modules/peers/PeerAddressTooltipContent.tsx index edc2761b..0d199283 100644 --- a/src/modules/peers/PeerAddressTooltipContent.tsx +++ b/src/modules/peers/PeerAddressTooltipContent.tsx @@ -38,6 +38,21 @@ export const PeerAddressTooltipContent = ({ peer }: Props) => { } /> + {peer.ipv6 && ( + } + label={"NetBird IPv6"} + value={ + + {peer.ipv6} + + } + /> + )} } label={"Public IP"} diff --git a/src/modules/settings/NetworkSettingsTab.tsx b/src/modules/settings/NetworkSettingsTab.tsx index 17e4d4c6..d280e335 100644 --- a/src/modules/settings/NetworkSettingsTab.tsx +++ b/src/modules/settings/NetworkSettingsTab.tsx @@ -6,6 +6,7 @@ import InlineLink from "@components/InlineLink"; import { Input } from "@components/Input"; import { Label } from "@components/Label"; import { notify } from "@components/Notification"; +import { PeerGroupSelector } from "@components/PeerGroupSelector"; import { useHasChanges } from "@hooks/useHasChanges"; import * as Tabs from "@radix-ui/react-tabs"; import { useApiCall } from "@utils/api"; @@ -18,6 +19,7 @@ import { useSWRConfig } from "swr"; import SettingsIcon from "@/assets/icons/SettingsIcon"; import { usePermissions } from "@/contexts/PermissionsProvider"; import { Account } from "@/interfaces/Account"; +import useGroupHelper from "@/modules/groups/useGroupHelper"; type Props = { account: Account; @@ -38,6 +40,16 @@ export default function NetworkSettingsTab({ account }: Readonly) { const [networkRange, setNetworkRange] = useState( account.settings.network_range || "", ); + const [networkRangeV6, setNetworkRangeV6] = useState( + account.settings.network_range_v6 || "", + ); + const [ipv6EnabledGroups, setIpv6EnabledGroups] = useGroupHelper({ + initial: account.settings?.ipv6_enabled_groups, + }); + const ipv6GroupNames = useMemo( + () => ipv6EnabledGroups.map((g) => g.name).sort(), + [ipv6EnabledGroups], + ); const toggleNetworkDNSSetting = async (toggle: boolean) => { notify({ @@ -64,19 +76,32 @@ export default function NetworkSettingsTab({ account }: Readonly) { const { hasChanges, updateRef } = useHasChanges([ customDNSDomain, networkRange, + networkRangeV6, + ipv6GroupNames, ]); const saveChanges = async () => { const updatedSettings = { ...account.settings, + ipv6_enabled_groups: ipv6EnabledGroups.map((g) => g.id).filter((id): id is string => !!id), }; if (customDNSDomain !== "" || account.settings.dns_domain) { updatedSettings.dns_domain = customDNSDomain; } - if (networkRange !== "") { + // Only send network ranges when the user actually changed them, to avoid + // triggering a reallocation when the server hasn't stored an explicit override. + if (networkRange !== (account.settings.network_range || "")) { updatedSettings.network_range = networkRange; + } else { + delete updatedSettings.network_range; + } + + if (networkRangeV6 !== (account.settings.network_range_v6 || "")) { + updatedSettings.network_range_v6 = networkRangeV6; + } else { + delete updatedSettings.network_range_v6; } notify({ @@ -89,7 +114,7 @@ export default function NetworkSettingsTab({ account }: Readonly) { }) .then(() => { mutate("/accounts"); - updateRef([customDNSDomain, networkRange]); + updateRef([customDNSDomain, networkRange, networkRangeV6, ipv6GroupNames]); }), loadingMessage: "Updating network settings...", }); @@ -124,6 +149,17 @@ export default function NetworkSettingsTab({ account }: Readonly) { } }, [networkRange, account.settings.network_range]); + const networkRangeV6Error = useMemo(() => { + if (networkRangeV6 == "") return ""; + if (!networkRangeV6.includes(":") || !cidr.isValidCIDR(networkRangeV6)) { + return "Please enter a valid IPv6 CIDR range, e.g. fd00:1234::/64"; + } + const prefixLen = parseInt(networkRangeV6.split("/")[1], 10); + if (prefixLen < 48 || prefixLen > 112) { + return "Prefix length must be between /48 and /112"; + } + }, [networkRangeV6]); + return (
@@ -150,7 +186,8 @@ export default function NetworkSettingsTab({ account }: Readonly) { !hasChanges || !permission.settings.update || !!domainError || - !!networkRangeError + !!networkRangeError || + !!networkRangeV6Error } onClick={saveChanges} > @@ -216,6 +253,51 @@ export default function NetworkSettingsTab({ account }: Readonly) {
+
+
+
+ + + Specify a custom IPv6 range for your network in CIDR format. + All peer IPv6 addresses will be re-allocated when changed. + +
+
+ setNetworkRangeV6(e.target.value)} + /> +
+
+
+ +
+ + + Peers in the selected groups will receive IPv6 overlay + addresses (dual-stack). Remove all groups to disable IPv6. + Changes apply on save and will restart affected clients. + + +
+ +
+ { if (version == "development") return true; return compareVersions(version, "0.61.0"); }; + From 492bbbd24736f29db3dd30cbb8c20c50c76e4141 Mon Sep 17 00:00:00 2001 From: Viktor Liu Date: Thu, 26 Mar 2026 10:41:18 +0100 Subject: [PATCH 02/13] Fix PeerContext update type and use group IDs for dirty-checking --- src/contexts/PeerProvider.tsx | 1 + src/modules/settings/NetworkSettingsTab.tsx | 10 +++++++--- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/contexts/PeerProvider.tsx b/src/contexts/PeerProvider.tsx index d0a45f0b..13b86d05 100644 --- a/src/contexts/PeerProvider.tsx +++ b/src/contexts/PeerProvider.tsx @@ -30,6 +30,7 @@ const PeerContext = React.createContext( inactivityExpiration?: boolean; approval_required?: boolean; ip?: string; + ipv6?: string; }) => Promise; toggleSSH: (newState: boolean) => Promise; setSSHInstructionsModal: (open: boolean) => void; diff --git a/src/modules/settings/NetworkSettingsTab.tsx b/src/modules/settings/NetworkSettingsTab.tsx index d280e335..d9adc780 100644 --- a/src/modules/settings/NetworkSettingsTab.tsx +++ b/src/modules/settings/NetworkSettingsTab.tsx @@ -46,8 +46,12 @@ export default function NetworkSettingsTab({ account }: Readonly) { const [ipv6EnabledGroups, setIpv6EnabledGroups] = useGroupHelper({ initial: account.settings?.ipv6_enabled_groups, }); - const ipv6GroupNames = useMemo( - () => ipv6EnabledGroups.map((g) => g.name).sort(), + const ipv6GroupIds = useMemo( + () => + ipv6EnabledGroups + .map((g) => g.id) + .filter((id): id is string => !!id) + .sort(), [ipv6EnabledGroups], ); @@ -77,7 +81,7 @@ export default function NetworkSettingsTab({ account }: Readonly) { customDNSDomain, networkRange, networkRangeV6, - ipv6GroupNames, + ipv6GroupIds, ]); const saveChanges = async () => { From d156e539a7b52d5033044a71516f557acb75c524 Mon Sep 17 00:00:00 2001 From: Viktor Liu Date: Thu, 26 Mar 2026 10:49:57 +0100 Subject: [PATCH 03/13] Fix remaining ipv6GroupNames reference --- src/modules/settings/NetworkSettingsTab.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/modules/settings/NetworkSettingsTab.tsx b/src/modules/settings/NetworkSettingsTab.tsx index d9adc780..756d9e96 100644 --- a/src/modules/settings/NetworkSettingsTab.tsx +++ b/src/modules/settings/NetworkSettingsTab.tsx @@ -118,7 +118,7 @@ export default function NetworkSettingsTab({ account }: Readonly) { }) .then(() => { mutate("/accounts"); - updateRef([customDNSDomain, networkRange, networkRangeV6, ipv6GroupNames]); + updateRef([customDNSDomain, networkRange, networkRangeV6, ipv6GroupIds]); }), loadingMessage: "Updating network settings...", }); From c8d22d58b03161aac2a78c5134ce66965998fcbc Mon Sep 17 00:00:00 2001 From: Viktor Liu Date: Fri, 27 Mar 2026 12:23:28 +0100 Subject: [PATCH 04/13] Add ip_version query param support to browser SSH --- src/app/(remote-access)/peer/ssh/page.tsx | 19 +++++++++++++------ src/modules/remote-access/ssh/useSSH.ts | 2 ++ .../remote-access/ssh/useSSHQueryParams.ts | 10 +++++++++- src/modules/remote-access/useNetBirdClient.ts | 2 ++ 4 files changed, 26 insertions(+), 7 deletions(-) diff --git a/src/app/(remote-access)/peer/ssh/page.tsx b/src/app/(remote-access)/peer/ssh/page.tsx index e24035b2..ef8c7eb3 100644 --- a/src/app/(remote-access)/peer/ssh/page.tsx +++ b/src/app/(remote-access)/peer/ssh/page.tsx @@ -18,7 +18,7 @@ import { } from "@utils/version"; export default function SSHPage() { - const { peerId, username, port } = useSSHQueryParams(); + const { peerId, username, port, ipVersion } = useSSHQueryParams(); const { data: peer, @@ -48,6 +48,7 @@ export default function SSHPage() { peer={peer} username={username} port={port} + ipVersion={ipVersion} /> ) : ( @@ -60,9 +61,10 @@ type Props = { username: string; port: string; peer: Peer; + ipVersion: string | null; }; -function SSHTerminal({ username, port, peer }: Props) { +function SSHTerminal({ username, port, peer, ipVersion }: Props) { const client = useNetBirdClient(); const connected = useRef(false); const sshConnectedOnce = useRef(false); @@ -81,9 +83,12 @@ function SSHTerminal({ username, port, peer }: Props) { const isClientDisconnected = client.status === NetBirdStatus.DISCONNECTED; const isClientConnecting = client.status === NetBirdStatus.CONNECTING; + // Use the FQDN when an IP version is specified so the dialer resolves to the correct address family. + const sshHost = ipVersion ? peer.dns_label || peer.ip : peer.ip; + useEffect(() => { - document.title = `${username}@${peer.ip} - ${peer.hostname}`; - }, [username, peer, client]); + document.title = `${username}@${sshHost} - ${peer.hostname}`; + }, [username, peer, client, sshHost]); const handleReconnect = async () => { if (!peer?.id) return; @@ -97,9 +102,10 @@ function SSHTerminal({ username, port, peer }: Props) { const rules = [`${protocol}/${aclPort}`]; await client?.connectTemporary(peer.id, rules); await ssh({ - hostname: peer.ip, + hostname: sshHost, port: Number(port), username, + ipVersion: ipVersion || undefined, }); } catch (error) { console.error("Reconnection failed:", error); @@ -123,9 +129,10 @@ function SSHTerminal({ username, port, peer }: Props) { const rules = [`${protocol}/${aclPort}`]; await client?.connectTemporary(peer.id, rules); const res = await ssh({ - hostname: peer.ip, + hostname: sshHost, port: Number(port), username, + ipVersion: ipVersion || undefined, }); if (res === SSHStatus.CONNECTED) { sshConnectedOnce.current = true; diff --git a/src/modules/remote-access/ssh/useSSH.ts b/src/modules/remote-access/ssh/useSSH.ts index 121d68ad..95f57c7e 100644 --- a/src/modules/remote-access/ssh/useSSH.ts +++ b/src/modules/remote-access/ssh/useSSH.ts @@ -5,6 +5,7 @@ interface SSHConfig { hostname: string; port: number; username: string; + ipVersion?: string; } interface SSHConnection { @@ -71,6 +72,7 @@ export const useSSH = (client: any) => { config.port, config.username, requiresJwt ? accessToken : undefined, + config.ipVersion, ); ssh.onclose = () => { diff --git a/src/modules/remote-access/ssh/useSSHQueryParams.ts b/src/modules/remote-access/ssh/useSSHQueryParams.ts index 938f95e8..ea61db31 100644 --- a/src/modules/remote-access/ssh/useSSHQueryParams.ts +++ b/src/modules/remote-access/ssh/useSSHQueryParams.ts @@ -6,6 +6,7 @@ interface SSHQueryParams { peerId: string | null; username: string | null; port: string | null; + ipVersion: string | null; } export function useSSHQueryParams() { @@ -15,6 +16,7 @@ export function useSSHQueryParams() { peerId: null, username: null, port: null, + ipVersion: null, }); const [, setLocalQueryParams] = useLocalStorage("netbird-query-params", ""); @@ -22,10 +24,11 @@ export function useSSHQueryParams() { const peerId = searchParams.get("id"); const username = searchParams.get("user"); const port = searchParams.get("port"); + const ipVersion = searchParams.get("ip_version"); // If all params are present in URL, use them if (peerId && username && port) { - setParams({ peerId, username, port }); + setParams({ peerId, username, port, ipVersion }); return; } @@ -47,18 +50,23 @@ export function useSSHQueryParams() { const storedPeerId = urlParams.get("id"); const storedUsername = urlParams.get("user"); const storedPort = urlParams.get("port"); + const storedIpVersion = urlParams.get("ip_version"); if (storedPeerId && storedUsername && storedPort) { const newSearchParams = new URLSearchParams(); newSearchParams.set("id", storedPeerId); newSearchParams.set("user", storedUsername); newSearchParams.set("port", storedPort); + if (storedIpVersion) { + newSearchParams.set("ip_version", storedIpVersion); + } router.replace(`/peer/ssh?${newSearchParams.toString()}`); setParams({ peerId: storedPeerId, username: storedUsername, port: storedPort, + ipVersion: storedIpVersion, }); // Clear stored params after restoring diff --git a/src/modules/remote-access/useNetBirdClient.ts b/src/modules/remote-access/useNetBirdClient.ts index 6838fd88..28bd8946 100644 --- a/src/modules/remote-access/useNetBirdClient.ts +++ b/src/modules/remote-access/useNetBirdClient.ts @@ -224,6 +224,7 @@ export const useNetBirdClient = () => { port: number, username: string, jwtToken?: string, + ipVersion?: string, ): Promise => { if (!netBirdClient.current?.createSSHConnection) { throw new Error("Go client not ready"); @@ -233,6 +234,7 @@ export const useNetBirdClient = () => { port, username, jwtToken, + ipVersion, ); }, [], From 9fd483c117c15ec9be788f1e7f71f1764777c9c0 Mon Sep 17 00:00:00 2001 From: Eduard Gert Date: Thu, 9 Apr 2026 10:34:37 +0200 Subject: [PATCH 05/13] Fix save new groups and add settings loading skeleton --- src/modules/settings/NetworkSettingsTab.tsx | 49 ++++++++++++++------- 1 file changed, 34 insertions(+), 15 deletions(-) diff --git a/src/modules/settings/NetworkSettingsTab.tsx b/src/modules/settings/NetworkSettingsTab.tsx index 756d9e96..88533d05 100644 --- a/src/modules/settings/NetworkSettingsTab.tsx +++ b/src/modules/settings/NetworkSettingsTab.tsx @@ -20,12 +20,24 @@ import SettingsIcon from "@/assets/icons/SettingsIcon"; import { usePermissions } from "@/contexts/PermissionsProvider"; import { Account } from "@/interfaces/Account"; import useGroupHelper from "@/modules/groups/useGroupHelper"; +import { useGroups } from "@/contexts/GroupsProvider"; +import { SkeletonSettings } from "@components/skeletons/SkeletonSettings"; type Props = { account: Account; }; export default function NetworkSettingsTab({ account }: Readonly) { + const { isLoading: isGroupsLoading } = useGroups(); + + return isGroupsLoading ? ( + + ) : ( + + ); +} + +function NetworkSettingsTabContent({ account }: Readonly) { const { permission } = usePermissions(); const { mutate } = useSWRConfig(); @@ -43,15 +55,12 @@ export default function NetworkSettingsTab({ account }: Readonly) { const [networkRangeV6, setNetworkRangeV6] = useState( account.settings.network_range_v6 || "", ); - const [ipv6EnabledGroups, setIpv6EnabledGroups] = useGroupHelper({ - initial: account.settings?.ipv6_enabled_groups, - }); - const ipv6GroupIds = useMemo( - () => - ipv6EnabledGroups - .map((g) => g.id) - .filter((id): id is string => !!id) - .sort(), + const [ipv6EnabledGroups, setIpv6EnabledGroups, { save: saveGroups }] = + useGroupHelper({ + initial: account.settings?.ipv6_enabled_groups, + }); + const ipv6GroupNames = useMemo( + () => ipv6EnabledGroups.map((g) => g.name).sort(), [ipv6EnabledGroups], ); @@ -81,13 +90,18 @@ export default function NetworkSettingsTab({ account }: Readonly) { customDNSDomain, networkRange, networkRangeV6, - ipv6GroupIds, + ipv6GroupNames, ]); const saveChanges = async () => { + const groups = await saveGroups(); + const ipv6EnabledGroupIds = groups + .map((group) => group.id) + .filter(Boolean) as string[]; + const updatedSettings = { ...account.settings, - ipv6_enabled_groups: ipv6EnabledGroups.map((g) => g.id).filter((id): id is string => !!id), + ipv6_enabled_groups: ipv6EnabledGroupIds, }; if (customDNSDomain !== "" || account.settings.dns_domain) { @@ -118,7 +132,12 @@ export default function NetworkSettingsTab({ account }: Readonly) { }) .then(() => { mutate("/accounts"); - updateRef([customDNSDomain, networkRange, networkRangeV6, ipv6GroupIds]); + updateRef([ + customDNSDomain, + networkRange, + networkRangeV6, + ipv6GroupNames, + ]); }), loadingMessage: "Updating network settings...", }); @@ -287,9 +306,9 @@ export default function NetworkSettingsTab({ account }: Readonly) {
- Peers in the selected groups will receive IPv6 overlay - addresses (dual-stack). Remove all groups to disable IPv6. - Changes apply on save and will restart affected clients. + Peers in the selected groups will receive IPv6 overlay addresses + (dual-stack). Remove all groups to disable IPv6. Changes apply on + save and will restart affected clients. Date: Thu, 9 Apr 2026 10:59:16 +0200 Subject: [PATCH 06/13] Extract edit ip modal into separate component --- src/app/(dashboard)/peer/page.tsx | 292 +++++++-------------------- src/modules/peer/PeerEditIPModal.tsx | 118 +++++++++++ 2 files changed, 192 insertions(+), 218 deletions(-) create mode 100644 src/modules/peer/PeerEditIPModal.tsx diff --git a/src/app/(dashboard)/peer/page.tsx b/src/app/(dashboard)/peer/page.tsx index 636f4c9f..1a3f95e7 100644 --- a/src/app/(dashboard)/peer/page.tsx +++ b/src/app/(dashboard)/peer/page.tsx @@ -2,8 +2,6 @@ import Breadcrumbs from "@components/Breadcrumbs"; import Button from "@components/Button"; -import { Callout } from "@components/Callout"; -import cidr from "ip-cidr"; import Card from "@components/Card"; import HelpText from "@components/HelpText"; import { Input } from "@components/Input"; @@ -73,6 +71,7 @@ import ReverseProxiesProvider, { useReverseProxies, } from "@/contexts/ReverseProxiesProvider"; import { ReverseProxyFlatTargetsTabContent } from "@/modules/reverse-proxy/targets/flat/ReverseProxyFlatTargetsTabContent"; +import { PeerEditIPModal } from "@/modules/peer/PeerEditIPModal"; import { PeerSSHToggle } from "@/modules/peer/PeerSSHToggle"; import { RDPButton } from "@/modules/remote-access/rdp/RDPButton"; import { SSHButton } from "@/modules/remote-access/ssh/SSHButton"; @@ -477,42 +476,48 @@ function PeerInformationCard({ peer }: Readonly<{ peer: Peer }>) { return getRegionByPeer(peer); }, [getRegionByPeer, peer]); + const handleSaveIP = (newIP: string) => { + notify({ + title: peer.name, + description: "NetBird Peer IP was successfully updated", + promise: update({ ip: newIP }).then(() => { + mutate("/peers/" + peer.id); + setShowEditIPModal(false); + }), + loadingMessage: "Updating peer IP...", + }); + }; + + const handleSaveIPv6 = (newIPv6: string) => { + notify({ + title: peer.name, + description: "NetBird Peer IPv6 was successfully updated", + promise: update({ ipv6: newIPv6 }).then(() => { + mutate("/peers/" + peer.id); + setShowEditIPv6Modal(false); + }), + loadingMessage: "Updating peer IPv6...", + }); + }; + return ( <> - - { - 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} - /> - - - { - notify({ - title: peer.name, - description: "Peer IPv6 was successfully updated", - promise: update({ ipv6: newIPv6 }).then(() => { - mutate("/peers/" + peer.id); - setShowEditIPv6Modal(false); - }), - loadingMessage: "Updating peer IPv6...", - }); - }} - peer={peer} - key={showEditIPv6Modal ? 1 : 0} - /> - + + ) { } valueToCopy={peer.ip} value={ -
- {peer.ip} - {permission.peers.update && ( - - )} -
+ setShowEditIPModal(true)} + /> } /> @@ -557,20 +553,11 @@ function PeerInformationCard({ peer }: Readonly<{ peer: Peer }>) { } valueToCopy={peer.ipv6} value={ -
- {peer.ipv6} - {permission.peers.update && ( - - )} -
+ setShowEditIPv6Modal(true)} + /> } /> )} @@ -815,160 +802,29 @@ 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]); - +function EditableValue({ + value, + canEdit, + onEdit, +}: { + value: string; + canEdit: boolean; + onEdit: () => void; +}) { return ( - -
- - -
-
- setIP(e.target.value)} - error={error} - /> -
- - Changes take effect when the peer reconnects. -
- - -
- - - - - -
-
- -
- ); -} - -interface EditIPv6ModalProps { - onSuccess: (ipv6: string) => void; - peer: Peer; -} - -function isValidIPv6(address: string): boolean { - return cidr.isValidAddress(address) && address.includes(":"); -} - -function EditIPv6Modal({ onSuccess, peer }: Readonly) { - const [ipv6, setIPv6] = useState(peer.ipv6 || ""); - const [error, setError] = useState(""); - - const isDisabled = useMemo(() => { - if (ipv6 === peer.ipv6) return true; - const trimmed = trim(ipv6); - return trimmed.length === 0 || !isValidIPv6(trimmed); - }, [ipv6, peer.ipv6]); - - React.useEffect(() => { - switch (true) { - case ipv6 === peer.ipv6: - setError(""); - break; - case !isValidIPv6(trim(ipv6)): - setError("Please enter a valid IPv6 address, e.g., fd00:1234::1"); - break; - default: - setError(""); - break; - } - }, [ipv6, peer.ipv6]); - - return ( - -
- - -
-
- setIPv6(e.target.value)} - error={error} - /> -
- - Changes take effect when the peer reconnects. -
- - -
- - - - - -
-
- -
+
+ {value} + {canEdit && ( + + )} +
); } diff --git a/src/modules/peer/PeerEditIPModal.tsx b/src/modules/peer/PeerEditIPModal.tsx new file mode 100644 index 00000000..6495bb5b --- /dev/null +++ b/src/modules/peer/PeerEditIPModal.tsx @@ -0,0 +1,118 @@ +import Button from "@components/Button"; +import { Callout } from "@components/Callout"; +import { Input } from "@components/Input"; +import { + Modal, + ModalClose, + ModalContent, + ModalFooter, +} from "@components/modal/Modal"; +import ModalHeader from "@components/modal/ModalHeader"; +import cidr from "ip-cidr"; +import { trim } from "lodash"; +import React, { useMemo, useState } from "react"; + +type IPVersion = "v4" | "v6"; + +interface PeerEditIPModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onSave: (ip: string) => void; + currentIP: string; + version: IPVersion; +} + +const config: Record< + IPVersion, + { + title: string; + description: string; + placeholder: string; + errorMessage: string; + validate: (ip: string) => boolean; + } +> = { + v4: { + title: "Edit Peer IP Address", + description: "Update the NetBird IP address for this peer.", + placeholder: "e.g., 100.64.0.15", + errorMessage: "Please enter a valid IP, e.g., 100.64.0.15", + validate: (ip: string) => + /^(?:(?: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]?)$/.test( + ip, + ), + }, + v6: { + title: "Edit Peer IPv6 Address", + description: "Update the NetBird IPv6 address for this peer.", + placeholder: "e.g., fd00:1234::1", + errorMessage: "Please enter a valid IPv6 address, e.g., fd00:1234::1", + validate: (ip: string) => cidr.isValidAddress(ip) && ip.includes(":"), + }, +}; + +export function PeerEditIPModal({ + open, + onOpenChange, + onSave, + currentIP, + version, +}: Readonly) { + const { title, description, placeholder, errorMessage, validate } = + config[version]; + const [ip, setIP] = useState(currentIP); + + const isDisabled = useMemo(() => { + if (ip === currentIP) return true; + const trimmed = trim(ip); + return trimmed.length === 0 || !validate(trimmed); + }, [ip, currentIP, validate]); + + const error = useMemo(() => { + if (ip === currentIP) return ""; + if (!validate(trim(ip))) return errorMessage; + return ""; + }, [ip, currentIP, validate, errorMessage]); + + return ( + + +
+ + +
+
+ setIP(e.target.value)} + error={error} + /> +
+ + Changes take effect when the peer reconnects. +
+ + +
+ + + + + +
+
+ +
+
+ ); +} From c3eac73722c19fc88a69c201dbc7ed13872b949d Mon Sep 17 00:00:00 2001 From: Eduard Gert Date: Thu, 9 Apr 2026 11:32:24 +0200 Subject: [PATCH 07/13] Prevent icons from shrinking --- src/app/(dashboard)/peer/page.tsx | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/src/app/(dashboard)/peer/page.tsx b/src/app/(dashboard)/peer/page.tsx index 1a3f95e7..14094cff 100644 --- a/src/app/(dashboard)/peer/page.tsx +++ b/src/app/(dashboard)/peer/page.tsx @@ -526,7 +526,7 @@ function PeerInformationCard({ peer }: Readonly<{ peer: Peer }>) { copyText={"NetBird IP Address"} label={ <> - + NetBird IP Address } @@ -547,7 +547,7 @@ function PeerInformationCard({ peer }: Readonly<{ peer: Peer }>) { copyText={"NetBird IPv6 Address"} label={ <> - + NetBird IPv6 Address } @@ -567,7 +567,7 @@ function PeerInformationCard({ peer }: Readonly<{ peer: Peer }>) { copyText={"Public IP Address"} label={ <> - + Public IP Address } @@ -579,7 +579,7 @@ function PeerInformationCard({ peer }: Readonly<{ peer: Peer }>) { copyText={"DNS label"} label={ <> - + Domain Name } @@ -597,7 +597,7 @@ function PeerInformationCard({ peer }: Readonly<{ peer: Peer }>) { copyText={"Hostname"} label={ <> - + Hostname } @@ -607,7 +607,7 @@ function PeerInformationCard({ peer }: Readonly<{ peer: Peer }>) { - + Region } @@ -637,7 +637,7 @@ function PeerInformationCard({ peer }: Readonly<{ peer: Peer }>) { - + Operating System } @@ -648,7 +648,7 @@ function PeerInformationCard({ peer }: Readonly<{ peer: Peer }>) { - + Serial Number } @@ -660,7 +660,7 @@ function PeerInformationCard({ peer }: Readonly<{ peer: Peer }>) { - + Registered on } @@ -676,7 +676,7 @@ function PeerInformationCard({ peer }: Readonly<{ peer: Peer }>) { - + Last seen } @@ -693,7 +693,7 @@ function PeerInformationCard({ peer }: Readonly<{ peer: Peer }>) { - + Agent Version } @@ -704,7 +704,7 @@ function PeerInformationCard({ peer }: Readonly<{ peer: Peer }>) { - + UI Version } From d3ac691e355009e5b4499cc8d2174d4aeb40713b Mon Sep 17 00:00:00 2001 From: Eduard Gert Date: Thu, 9 Apr 2026 11:32:46 +0200 Subject: [PATCH 08/13] Update ipv6 icon --- src/modules/peers/PeerAddressTooltipContent.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/modules/peers/PeerAddressTooltipContent.tsx b/src/modules/peers/PeerAddressTooltipContent.tsx index 0d199283..0228ea0b 100644 --- a/src/modules/peers/PeerAddressTooltipContent.tsx +++ b/src/modules/peers/PeerAddressTooltipContent.tsx @@ -40,7 +40,7 @@ export const PeerAddressTooltipContent = ({ peer }: Props) => { /> {peer.ipv6 && ( } + icon={} label={"NetBird IPv6"} value={ Date: Thu, 9 Apr 2026 11:33:44 +0200 Subject: [PATCH 09/13] Remove ipv6 from address as we have it in the tooltip already --- src/modules/peers/PeerAddressCell.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/modules/peers/PeerAddressCell.tsx b/src/modules/peers/PeerAddressCell.tsx index 742e8f94..7240fc9d 100644 --- a/src/modules/peers/PeerAddressCell.tsx +++ b/src/modules/peers/PeerAddressCell.tsx @@ -11,6 +11,7 @@ import { PeerAddressTooltipContent } from "@/modules/peers/PeerAddressTooltipCon type Props = { peer: Peer; }; + export default function PeerAddressCell({ peer }: Props) { return ( {peer.ip} - {peer.ipv6 && `, ${peer.ipv6}`}
From 4cd8a8b291c67763931a22803b73f888138d4c43 Mon Sep 17 00:00:00 2001 From: Eduard Gert Date: Thu, 9 Apr 2026 11:36:25 +0200 Subject: [PATCH 10/13] Allow search by ipv6 --- src/modules/peers/PeersTable.tsx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/modules/peers/PeersTable.tsx b/src/modules/peers/PeersTable.tsx index 3fda0740..3b5d16c6 100644 --- a/src/modules/peers/PeersTable.tsx +++ b/src/modules/peers/PeersTable.tsx @@ -204,6 +204,10 @@ const PeersTableColumns: ColumnDef[] = [ ), }, + { + id: "ipv6", + accessorFn: (row) => row.ipv6, + }, ]; type Props = { @@ -321,6 +325,7 @@ export default function PeersTable({ connect: permission.peers.update, groups: permission.groups.read, os: false, + ipv6: false, }} isLoading={isLoading} getStartedCard={} From e0a8fccd5922adc6f71af0c31d4ad2149808cc30 Mon Sep 17 00:00:00 2001 From: Eduard Gert Date: Thu, 9 Apr 2026 11:40:27 +0200 Subject: [PATCH 11/13] Decrease card list py --- src/components/Card.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/Card.tsx b/src/components/Card.tsx index e2611761..52f7f8c3 100644 --- a/src/components/Card.tsx +++ b/src/components/Card.tsx @@ -50,7 +50,7 @@ function CardListItem({ return (
  • From 1c17726b5e37675b4f01c473a31812643132f138 Mon Sep 17 00:00:00 2001 From: Eduard Gert Date: Thu, 9 Apr 2026 11:43:17 +0200 Subject: [PATCH 12/13] Decrease font size of card list items --- src/components/Card.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/Card.tsx b/src/components/Card.tsx index 52f7f8c3..935047d5 100644 --- a/src/components/Card.tsx +++ b/src/components/Card.tsx @@ -54,7 +54,7 @@ function CardListItem({ className, )} > -
    {label}
    +
    {label}
    From 9f10aab8c12abe718e4e17fa759ce0affa7cc6fd Mon Sep 17 00:00:00 2001 From: Eduard Gert Date: Thu, 9 Apr 2026 15:10:27 +0200 Subject: [PATCH 13/13] Show only ipv4 in device card, selectors etc. for now --- src/components/DeviceCard.tsx | 9 ++------- src/components/PeerGroupSelector.tsx | 6 +----- src/components/PeerSelector.tsx | 4 ---- 3 files changed, 3 insertions(+), 16 deletions(-) diff --git a/src/components/DeviceCard.tsx b/src/components/DeviceCard.tsx index a84de586..9f0b74c6 100644 --- a/src/components/DeviceCard.tsx +++ b/src/components/DeviceCard.tsx @@ -28,13 +28,8 @@ export const DeviceCard = ({ const descriptionText = useMemo(() => { return description !== undefined ? description - : address || - (device?.ip - ? device.ipv6 - ? `${device.ip}, ${device.ipv6}` - : device.ip - : resource?.address); - }, [description, address, device, resource]); + : address || device?.ip || resource?.address; + }, [description, address, device]); return (
    { if (tab === "groups") return placeholderForSearch; if (tab === "resources") return "Search resource..."; - if (tab === "peers") return "Search peer..."; + if (tab === "peers") return "Search peer by name or ip..."; return "Search..."; }, [tab, placeholderForSearch]); @@ -537,9 +537,6 @@ export function PeerGroupSelector({ 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, @@ -1060,7 +1057,6 @@ const PeersList = ({ } > {res.ip} - {res.ipv6 && `, ${res.ipv6}`}
    diff --git a/src/components/PeerSelector.tsx b/src/components/PeerSelector.tsx index f3c9c8ff..e259de7c 100644 --- a/src/components/PeerSelector.tsx +++ b/src/components/PeerSelector.tsx @@ -125,9 +125,7 @@ export function PeerSelector({ "text-neutral-500 dark:text-nb-gray-300 font-medium flex items-center gap-1 font-mono text-[10px]" } > - {value.ip} - {value.ipv6 && `, ${value.ipv6}`}
  • ) : ( @@ -240,9 +238,7 @@ export function PeerSelector({ !isSupported && "opacity-50", )} > - {option.ip} - {option.ipv6 && `, ${option.ipv6}`} );