From 6cf80b012ebbf2221ea79b4830313301aa387ccb Mon Sep 17 00:00:00 2001 From: mlsmaycon Date: Sun, 15 Feb 2026 15:45:12 +0100 Subject: [PATCH 1/8] add draft --- src/app/(dashboard)/settings/page.tsx | 7 ++ src/interfaces/Account.ts | 2 + src/modules/settings/PeerExposeTab.tsx | 156 +++++++++++++++++++++++++ 3 files changed, 165 insertions(+) create mode 100644 src/modules/settings/PeerExposeTab.tsx diff --git a/src/app/(dashboard)/settings/page.tsx b/src/app/(dashboard)/settings/page.tsx index 149602f9..c7b1bef5 100644 --- a/src/app/(dashboard)/settings/page.tsx +++ b/src/app/(dashboard)/settings/page.tsx @@ -6,6 +6,7 @@ import { AlertOctagonIcon, FingerprintIcon, FolderGit2Icon, + GlobeIcon, LockIcon, MonitorSmartphoneIcon, NetworkIcon, @@ -24,6 +25,7 @@ import IdentityProvidersTab from "@/modules/settings/IdentityProvidersTab"; import NetworkSettingsTab from "@/modules/settings/NetworkSettingsTab"; import PermissionsTab from "@/modules/settings/PermissionsTab"; import GroupsSettings from "@/modules/settings/GroupsSettings"; +import PeerExposeTab from "@/modules/settings/PeerExposeTab"; export default function NetBirdSettings() { const queryParams = useSearchParams(); @@ -78,6 +80,10 @@ export default function NetBirdSettings() { Clients + + + Peer Expose + )} @@ -95,6 +101,7 @@ export default function NetBirdSettings() { {account && } {account && } {account && } + {account && } {account && } diff --git a/src/interfaces/Account.ts b/src/interfaces/Account.ts index 174904e9..6379e4c2 100644 --- a/src/interfaces/Account.ts +++ b/src/interfaces/Account.ts @@ -8,6 +8,8 @@ export interface Account { extra: { peer_approval_enabled: boolean; user_approval_required: boolean; + peer_expose_enabled?: boolean; + peer_expose_groups?: string[]; }; peer_login_expiration_enabled: boolean; peer_login_expiration: number; diff --git a/src/modules/settings/PeerExposeTab.tsx b/src/modules/settings/PeerExposeTab.tsx new file mode 100644 index 00000000..2597b605 --- /dev/null +++ b/src/modules/settings/PeerExposeTab.tsx @@ -0,0 +1,156 @@ +import Breadcrumbs from "@components/Breadcrumbs"; +import Button from "@components/Button"; +import FancyToggleSwitch from "@components/FancyToggleSwitch"; +import HelpText from "@components/HelpText"; +import { Label } from "@components/Label"; +import { notify } from "@components/Notification"; +import { PeerGroupSelector } from "@components/PeerGroupSelector"; +import * as Tabs from "@radix-ui/react-tabs"; +import { useApiCall } from "@utils/api"; +import { AnimatePresence, motion } from "framer-motion"; +import { GlobeIcon } from "lucide-react"; +import React, { useMemo, useState } from "react"; +import { useSWRConfig } from "swr"; +import SettingsIcon from "@/assets/icons/SettingsIcon"; +import { usePermissions } from "@/contexts/PermissionsProvider"; +import { useHasChanges } from "@/hooks/useHasChanges"; +import { Account } from "@/interfaces/Account"; +import { Group } from "@/interfaces/Group"; +import { useGroupIdsToGroups } from "@/modules/groups/useGroupIdsToGroups"; + +type Props = { + account: Account; +}; + +export default function PeerExposeTab({ account }: Readonly) { + const { permission } = usePermissions(); + const { mutate } = useSWRConfig(); + const saveRequest = useApiCall("/accounts/" + account.id); + + const [peerExposeEnabled, setPeerExposeEnabled] = useState( + account?.settings?.extra?.peer_expose_enabled ?? false, + ); + + const initialGroups = useGroupIdsToGroups( + account?.settings?.extra?.peer_expose_groups, + ); + const [peerExposeGroups, setPeerExposeGroups] = useState([]); + + const groupIds = useMemo( + () => peerExposeGroups.map((g) => g.id).filter(Boolean) as string[], + [peerExposeGroups], + ); + + const { hasChanges, updateRef } = useHasChanges([ + peerExposeEnabled, + groupIds, + ]); + + React.useEffect(() => { + if (initialGroups) { + setPeerExposeGroups(initialGroups); + } + }, [initialGroups]); + + const saveChanges = async () => { + notify({ + title: "Peer Expose Settings", + description: "Peer expose settings were updated successfully.", + promise: saveRequest + .put({ + id: account.id, + settings: { + ...account.settings, + extra: { + ...account.settings?.extra, + peer_expose_enabled: peerExposeEnabled, + peer_expose_groups: groupIds, + }, + }, + }) + .then(() => { + mutate("/accounts"); + updateRef([peerExposeEnabled, groupIds]); + }), + loadingMessage: "Updating peer expose settings...", + }); + }; + + return ( + +
+ + } + /> + } + active + /> + +
+

Peer Expose

+ +
+ +
+ + + Enable peer expose + + } + helpText={ + "Allow peers to expose local services through the NetBird reverse proxy using the CLI." + } + disabled={!permission.settings.update} + /> +
+ + + {peerExposeEnabled && ( +
+ +
+
+ + + Restrict which peer groups are allowed to expose services. + Leave empty to allow all peers. + + +
+
+
+
+ )} +
+
+
+ ); +} From d29e5e24317bbd4b3a1f8f521cb0630377a0b7c0 Mon Sep 17 00:00:00 2001 From: mlsmaycon Date: Sat, 21 Feb 2026 14:12:38 +0100 Subject: [PATCH 2/8] add reverse proxy activities --- custom-zones.patch | 2402 ++++++++++++++++++ src/modules/activity/ActivityDescription.tsx | 26 + 2 files changed, 2428 insertions(+) create mode 100644 custom-zones.patch diff --git a/custom-zones.patch b/custom-zones.patch new file mode 100644 index 00000000..07f92ada --- /dev/null +++ b/custom-zones.patch @@ -0,0 +1,2402 @@ +diff --git a/package.json b/package.json +index a834b47b..b1306eed 100644 +--- a/package.json ++++ b/package.json +@@ -68,6 +68,7 @@ + "flowbite": "^1.8.1", + "flowbite-react": "^0.6.4", + "framer-motion": "^10.16.4", ++ "ip-address": "^10.1.0", + "ip-cidr": "^3.1.0", + "js-cookie": "^3.0.5", + "lodash": "^4.17.21", +diff --git a/src/app/(dashboard)/dns/nameservers/page.tsx b/src/app/(dashboard)/dns/nameservers/page.tsx +index 7e66f2b6..b287ea09 100644 +--- a/src/app/(dashboard)/dns/nameservers/page.tsx ++++ b/src/app/(dashboard)/dns/nameservers/page.tsx +@@ -7,7 +7,7 @@ import SkeletonTable from "@components/skeletons/SkeletonTable"; + import { RestrictedAccess } from "@components/ui/RestrictedAccess"; + import { usePortalElement } from "@hooks/usePortalElement"; + import useFetchApi from "@utils/api"; +-import { ExternalLinkIcon, ServerIcon } from "lucide-react"; ++import { ExternalLinkIcon } from "lucide-react"; + import React, { lazy, Suspense } from "react"; + import DNSIcon from "@/assets/icons/DNSIcon"; + import { usePermissions } from "@/contexts/PermissionsProvider"; +@@ -15,7 +15,7 @@ import { NameserverGroup } from "@/interfaces/Nameserver"; + import PageContainer from "@/layouts/PageContainer"; + + const NameserverGroupTable = lazy( +- () => import("@/modules/dns-nameservers/table/NameserverGroupTable"), ++ () => import("@/modules/dns/nameservers/table/NameserverGroupTable"), + ); + + export default function NameServers() { +@@ -40,7 +40,7 @@ export default function NameServers() { + href={"/dns/nameservers"} + label={"Nameservers"} + active +- icon={} ++ icon={} + /> + +

Nameservers

+diff --git a/src/app/(dashboard)/dns/zones/layout.tsx b/src/app/(dashboard)/dns/zones/layout.tsx +new file mode 100644 +index 00000000..640fa1fc +--- /dev/null ++++ b/src/app/(dashboard)/dns/zones/layout.tsx +@@ -0,0 +1,8 @@ ++import { globalMetaTitle } from "@utils/meta"; ++import type { Metadata } from "next"; ++import BlankLayout from "@/layouts/BlankLayout"; ++ ++export const metadata: Metadata = { ++ title: `Zones - DNS - ${globalMetaTitle}`, ++}; ++export default BlankLayout; +diff --git a/src/app/(dashboard)/dns/zones/page.tsx b/src/app/(dashboard)/dns/zones/page.tsx +new file mode 100644 +index 00000000..0abf50f1 +--- /dev/null ++++ b/src/app/(dashboard)/dns/zones/page.tsx +@@ -0,0 +1,70 @@ ++"use client"; ++ ++import Breadcrumbs from "@components/Breadcrumbs"; ++import InlineLink from "@components/InlineLink"; ++import Paragraph from "@components/Paragraph"; ++import SkeletonTable from "@components/skeletons/SkeletonTable"; ++import { RestrictedAccess } from "@components/ui/RestrictedAccess"; ++import { usePortalElement } from "@hooks/usePortalElement"; ++import useFetchApi from "@utils/api"; ++import { ExternalLinkIcon } from "lucide-react"; ++import React, { lazy, Suspense } from "react"; ++import DNSIcon from "@/assets/icons/DNSIcon"; ++import { usePermissions } from "@/contexts/PermissionsProvider"; ++import { DNS_ZONE_DOCS_LINK, DNSZone } from "@/interfaces/DNS"; ++import PageContainer from "@/layouts/PageContainer"; ++import { DNSZonesProvider } from "@/modules/dns/zones/DNSZonesProvider"; ++import DNSZoneIcon from "@/assets/icons/DNSZoneIcon"; ++ ++const DNSZonesTable = lazy( ++ () => import("@/modules/dns/zones/table/DNSZonesTable"), ++); ++ ++export default function DNSZonePage() { ++ const { permission } = usePermissions(); ++ ++ const { data: zones, isLoading } = useFetchApi("/dns/zones"); ++ ++ const { ref: headingRef, portalTarget } = ++ usePortalElement(); ++ ++ return ( ++ ++
++ ++ } /> ++ } ++ /> ++ ++

Zones

++ ++ Manage DNS zones to control domain name resolution for your network. ++ ++ ++ Learn more about ++ ++ DNS Zones ++ ++ ++ in our documentation. ++ ++
++ ++ ++ }> ++ ++ ++ ++ ++ ++
++ ); ++} +diff --git a/src/app/(dashboard)/group/page.tsx b/src/app/(dashboard)/group/page.tsx +index df9d0d1e..38118a48 100644 +--- a/src/app/(dashboard)/group/page.tsx ++++ b/src/app/(dashboard)/group/page.tsx +@@ -15,6 +15,7 @@ import { useSearchParams } from "next/navigation"; + import React, { useState } from "react"; + import AccessControlIcon from "@/assets/icons/AccessControlIcon"; + import DNSIcon from "@/assets/icons/DNSIcon"; ++import DNSZoneIcon from "@/assets/icons/DNSZoneIcon"; + import NetworkRoutesIcon from "@/assets/icons/NetworkRoutesIcon"; + import PeerIcon from "@/assets/icons/PeerIcon"; + import SetupKeysIcon from "@/assets/icons/SetupKeysIcon"; +@@ -24,6 +25,7 @@ import { usePermissions } from "@/contexts/PermissionsProvider"; + import RoutesProvider from "@/contexts/RoutesProvider"; + import { Group, GROUP_TOOLTIP_TEXT } from "@/interfaces/Group"; + import PageContainer from "@/layouts/PageContainer"; ++import { GroupDNSZonesSection } from "@/modules/groups/details/GroupDNSZonesSection"; + import { GroupNameserversSection } from "@/modules/groups/details/GroupNameserversSection"; + import { GroupNetworkRoutesSection } from "@/modules/groups/details/GroupNetworkRoutesSection"; + import { GroupPeersSection } from "@/modules/groups/details/GroupPeersSection"; +@@ -134,7 +136,9 @@ const validAllGroupTabs = [ + "resources", + "network-routes", + "nameservers", ++ "zones", + ]; ++ + const validOtherGroupTabs = ["users", "peers", "setup-keys"]; + + const GroupOverviewTabs = ({ group }: { group: Group }) => { +@@ -162,6 +166,7 @@ const GroupOverviewTabs = ({ group }: { group: Group }) => { + const resourcesCount = groupDetails?.resources_count || 0; + const routesCount = groupDetails?.routes?.length || 0; + const nameserversCount = groupDetails?.nameservers?.length || 0; ++ const zonesCount = groupDetails?.zones?.length || 0; + const setupKeysCount = groupDetails?.setupKeys?.length || 0; + + return ( +@@ -249,6 +254,19 @@ const GroupOverviewTabs = ({ group }: { group: Group }) => { + {singularize("Nameservers", nameserversCount)} + + ++ ++ ++ {singularize("Zones", zonesCount)} ++ ++ + {group.name !== "All" && ( + { + /> + + ++ ++ ++ ++ + + ++ ++ ++ ); ++} +diff --git a/src/components/table/Table.tsx b/src/components/table/Table.tsx +index fc119926..ac0b0cb3 100644 +--- a/src/components/table/Table.tsx ++++ b/src/components/table/Table.tsx +@@ -104,7 +104,7 @@ const TableRow = React.forwardRef< + " transition-colors group/table-row data-[state=selected]:bg-neutral-100 dark:data-[state=selected]:bg-nb-gray-930/70", + "dark:data-[state=selected]:border-nb-gray-900", + minimal +- ? "dark:hover:bg-nb-gray-900/10" ++ ? "dark:hover:bg-nb-gray-910/[15%]" + : "border-b dark:border-zinc-700/40 dark:hover:bg-nb-gray-940 hover:bg-neutral-100/50", + className, + )} +diff --git a/src/components/ui/PeerCountBadge.tsx b/src/components/ui/PeerCountBadge.tsx +index 73b6d256..f8fa4ce5 100644 +--- a/src/components/ui/PeerCountBadge.tsx ++++ b/src/components/ui/PeerCountBadge.tsx +@@ -19,11 +19,12 @@ export default function PeerCountBadge({ + className, + }: Props) { + const router = useRouter(); +- const { dropdownOptions } = useGroups(); ++ const { dropdownOptions, groups } = useGroups(); + + const currentGroup = useMemo(() => { +- return dropdownOptions?.find((g) => g.name === group?.name); +- }, [group, dropdownOptions]); ++ const options = dropdownOptions?.find((g) => g.name === group?.name); ++ return options ?? groups?.find((g) => g.name === group?.name); ++ }, [group, dropdownOptions, groups]); + + const peerCount = useMemo(() => { + let peerCount = currentGroup?.peers_count ?? 0; +diff --git a/src/contexts/DialogProvider.tsx b/src/contexts/DialogProvider.tsx +index 0cf10d7d..096b28f7 100644 +--- a/src/contexts/DialogProvider.tsx ++++ b/src/contexts/DialogProvider.tsx +@@ -66,6 +66,8 @@ export default function DialogProvider({ children }: Props) { + e.preventDefault()} ++ onPointerDownOutside={(e) => e.preventDefault()} + > + ++ + [] = [ + { +diff --git a/src/modules/dns-nameservers/table/NameserverMatchDomainsCell.tsx b/src/modules/dns/nameservers/table/NameserverMatchDomainsCell.tsx +similarity index 100% +rename from src/modules/dns-nameservers/table/NameserverMatchDomainsCell.tsx +rename to src/modules/dns/nameservers/table/NameserverMatchDomainsCell.tsx +diff --git a/src/modules/dns-nameservers/table/NameserverNameCell.tsx b/src/modules/dns/nameservers/table/NameserverNameCell.tsx +similarity index 100% +rename from src/modules/dns-nameservers/table/NameserverNameCell.tsx +rename to src/modules/dns/nameservers/table/NameserverNameCell.tsx +diff --git a/src/modules/dns-nameservers/table/NameserverNameserversCell.tsx b/src/modules/dns/nameservers/table/NameserverNameserversCell.tsx +similarity index 100% +rename from src/modules/dns-nameservers/table/NameserverNameserversCell.tsx +rename to src/modules/dns/nameservers/table/NameserverNameserversCell.tsx +diff --git a/src/modules/dns/zones/DNSRecordModal.tsx b/src/modules/dns/zones/DNSRecordModal.tsx +new file mode 100644 +index 00000000..7e59b2b1 +--- /dev/null ++++ b/src/modules/dns/zones/DNSRecordModal.tsx +@@ -0,0 +1,359 @@ ++import Button from "@components/Button"; ++import HelpText from "@components/HelpText"; ++import InlineLink from "@components/InlineLink"; ++import { Input } from "@components/Input"; ++import { Label } from "@components/Label"; ++import { ++ Modal, ++ ModalClose, ++ ModalContent, ++ ModalFooter, ++ ModalTrigger, ++} from "@components/modal/Modal"; ++import ModalHeader from "@components/modal/ModalHeader"; ++import Paragraph from "@components/Paragraph"; ++import { ++ Select, ++ SelectContent, ++ SelectItem, ++ SelectTrigger, ++ SelectValue, ++} from "@components/Select"; ++import Separator from "@components/Separator"; ++import { validator } from "@utils/helpers"; ++import { Address4, Address6 } from "ip-address"; ++import { ClockIcon, ExternalLinkIcon, GlobeIcon } from "lucide-react"; ++import React, { useMemo, useState } from "react"; ++import { ++ DNS_RECORDS_DOCS_LINK, ++ DNSRecord, ++ DNSRecordType, ++ DNSZone, ++} from "@/interfaces/DNS"; ++import { useDNSZones } from "@/modules/dns/zones/DNSZonesProvider"; ++ ++type Props = { ++ children?: React.ReactNode; ++ open: boolean; ++ onOpenChange: (open: boolean) => void; ++ zone: DNSZone; ++ record?: DNSRecord; ++}; ++ ++export default function DNSRecordModal({ ++ children, ++ open, ++ onOpenChange, ++ zone, ++ record, ++}: Readonly) { ++ return ( ++ ++ {children && {children}} ++ {open && ( ++ onOpenChange(false)} ++ onSuccessAdded={() => { ++ setTimeout(() => { ++ const row = document.querySelector( ++ `[data-row-id="${zone.id}"]`, ++ ); ++ if (row?.getAttribute("data-accordion") === "closed") { ++ row?.click(); ++ } ++ row?.scrollIntoView({ behavior: "smooth" }); ++ }, 200); ++ onOpenChange(false); ++ }} ++ zone={zone} ++ record={record} ++ /> ++ )} ++ ++ ); ++} ++ ++type ModalProps = { ++ onSuccess?: () => void; ++ onSuccessAdded?: () => void; ++ zone: DNSZone; ++ record?: DNSRecord; ++}; ++ ++export function DNSRecordModalContent({ ++ onSuccess, ++ onSuccessAdded, ++ zone, ++ record, ++}: Readonly) { ++ const { addRecord, updateRecord } = useDNSZones(); ++ ++ const getInitialDomain = () => { ++ if (!record) return ""; ++ if (record.name === zone.domain) return ""; ++ return record.name.replace(`.${zone.domain}`, ""); ++ }; ++ ++ const [domain, setDomain] = useState(record?.name ? getInitialDomain() : ""); ++ const [ttl, setTtl] = useState(record ? record.ttl.toString() : "300"); ++ const [type, setType] = useState(record?.type ?? "A"); ++ const [recordValue, setRecordValue] = useState(record?.content ?? ""); ++ ++ const domainError = useMemo(() => { ++ if (domain == "") return ""; ++ const valid = validator.isValidDomain(domain, { ++ allowWildcard: false, ++ allowOnlyTld: true, ++ }); ++ if (!valid) { ++ return "Please enter a valid domain, e.g. example.com or intra.example.com"; ++ } ++ }, [domain]); ++ ++ const ipv4Error = useMemo(() => { ++ if (recordValue === "" || type !== "A") return ""; ++ const valid = Address4.isValid(recordValue); ++ if (!valid) { ++ return "Please enter a valid IPv4 address, e.g. 192.168.1.1"; ++ } ++ }, [recordValue, type]); ++ ++ const ipv6Error = useMemo(() => { ++ if (recordValue === "" || type !== "AAAA") return ""; ++ const valid = Address6.isValid(recordValue); ++ if (!valid) { ++ return "Please enter a valid IPv6 address, e.g. 2001:0db8:85a3::8a2e:0370:7334"; ++ } ++ }, [recordValue, type]); ++ ++ const cnameError = useMemo(() => { ++ if (recordValue === "" || type !== "CNAME") return ""; ++ const valid = validator.isValidDomain(recordValue, { ++ allowWildcard: false, ++ allowOnlyTld: false, ++ }); ++ if (!valid) { ++ return "Please enter a valid domain, e.g. example.com or server.example.com"; ++ } ++ }, [recordValue, type]); ++ ++ const handleAddRecord = async () => { ++ const name = domain !== "" ? `${domain}.${zone.domain}` : zone.domain; ++ ++ if (record) { ++ updateRecord(zone, { ++ id: record.id, ++ name, ++ type, ++ content: recordValue, ++ ttl: parseInt(ttl), ++ }).then(onSuccess); ++ } else { ++ addRecord(zone, { ++ name, ++ type, ++ content: recordValue, ++ ttl: parseInt(ttl), ++ }).then(onSuccessAdded); ++ } ++ }; ++ ++ const canUpdateOrCreate = ++ !cnameError && ++ !ipv6Error && ++ !ipv4Error && ++ !domainError && ++ recordValue !== ""; ++ ++ return ( ++ ++ } ++ /> ++ ++
++
++
++ ++ ++ Select the type of record you want to add ++ ++
++
++ ++
++
++
++ ++ ++ Enter a subdomain or leave empty to use the primary domain. ++ ++
++ setDomain(e.target.value)} ++ /> ++
++ .{zone.domain} ++
++
++
++ ++
++ {type === "A" && ( ++
++ ++ setRecordValue(e.target.value)} ++ /> ++
++ )} ++ ++ {type === "AAAA" && ( ++
++ ++ setRecordValue(e.target.value)} ++ /> ++
++ )} ++ ++ {type === "CNAME" && ( ++
++ ++ setRecordValue(e.target.value)} ++ /> ++
++ )} ++ ++
++ ++
++ ++
++
++
++
++ ++ ++
++ ++ Learn more about ++ ++ DNS Records ++ ++ ++ ++
++ ++
++ <> ++ ++ ++ ++ ++ ++
++
++
++ ); ++} ++ ++export const getTTLLabel = (seconds: number): string => { ++ if (seconds < 60) return `${seconds} Sec.`; ++ if (seconds < 3600) { ++ const minutes = seconds / 60; ++ return minutes === 1 ? "1 Min." : `${minutes} Min.`; ++ } ++ if (seconds < 86400) { ++ const hours = seconds / 3600; ++ return hours === 1 ? "1 Hour" : `${hours} Hours`; ++ } ++ const days = seconds / 86400; ++ return days === 1 ? "1 Day" : `${days} Days`; ++}; +diff --git a/src/modules/dns/zones/DNSZoneModal.tsx b/src/modules/dns/zones/DNSZoneModal.tsx +new file mode 100644 +index 00000000..4767a6d6 +--- /dev/null ++++ b/src/modules/dns/zones/DNSZoneModal.tsx +@@ -0,0 +1,225 @@ ++import Button from "@components/Button"; ++import FancyToggleSwitch from "@components/FancyToggleSwitch"; ++import HelpText from "@components/HelpText"; ++import InlineLink from "@components/InlineLink"; ++import { Input } from "@components/Input"; ++import { Label } from "@components/Label"; ++import { ++ Modal, ++ ModalClose, ++ ModalContent, ++ ModalFooter, ++ ModalTrigger, ++} from "@components/modal/Modal"; ++import ModalHeader from "@components/modal/ModalHeader"; ++import Paragraph from "@components/Paragraph"; ++import { PeerGroupSelector } from "@components/PeerGroupSelector"; ++import Separator from "@components/Separator"; ++import { validator } from "@utils/helpers"; ++import { ExternalLinkIcon, Power, ScanSearch } from "lucide-react"; ++import React, { useMemo, useState } from "react"; ++import { DNS_ZONE_DOCS_LINK, DNSZone } from "@/interfaces/DNS"; ++import { useDNSZones } from "@/modules/dns/zones/DNSZonesProvider"; ++import useGroupHelper from "@/modules/groups/useGroupHelper"; ++import { Group } from "@/interfaces/Group"; ++import DNSZoneIcon from "@/assets/icons/DNSZoneIcon"; ++ ++type Props = { ++ children?: React.ReactNode; ++ open: boolean; ++ onOpenChange: (open: boolean) => void; ++ onSuccess?: (zone: DNSZone) => void; ++ onSuccessAdded?: (zone: DNSZone) => void; ++ initialDistributionGroups?: Group[]; ++ zone?: DNSZone; ++}; ++ ++export default function DNSZoneModal({ ++ children, ++ open, ++ onOpenChange, ++ onSuccess, ++ onSuccessAdded, ++ initialDistributionGroups, ++ zone, ++}: Readonly) { ++ return ( ++ ++ {children && {children}} ++ {open && ( ++ { ++ onOpenChange(false); ++ onSuccess?.(z); ++ }} ++ onSuccessAdded={(z) => { ++ onOpenChange(false); ++ onSuccessAdded?.(z); ++ }} ++ zone={zone} ++ initialDistributionGroups={initialDistributionGroups} ++ /> ++ )} ++ ++ ); ++} ++ ++type ModalProps = { ++ onSuccess?: (zone: DNSZone) => void; ++ onSuccessAdded?: (zone: DNSZone) => void; ++ initialDistributionGroups?: Group[]; ++ zone?: DNSZone; ++}; ++ ++export function DNSZoneModalContent({ ++ onSuccess, ++ onSuccessAdded, ++ zone, ++ initialDistributionGroups, ++}: Readonly) { ++ const { createZone, updateZone } = useDNSZones(); ++ const [domain, setDomain] = useState(zone?.domain ?? ""); ++ const [enabled, setEnabled] = useState(zone?.enabled ?? true); ++ const [searchDomainsEnabled, setSearchDomainsEnabled] = useState( ++ zone?.enable_search_domain ?? false, ++ ); ++ const [groups, setGroups, { save: saveGroups }] = useGroupHelper({ ++ initial: initialDistributionGroups ?? zone?.distribution_groups ?? [], ++ }); ++ ++ const domainError = useMemo(() => { ++ if (domain == "") return ""; ++ const valid = validator.isValidDomain(domain, { ++ allowWildcard: false, ++ allowOnlyTld: false, ++ }); ++ if (!valid) { ++ return "Please enter a valid domain, e.g. company.internal or intra.example.com"; ++ } ++ }, [domain]); ++ ++ const handleOnSubmit = async () => { ++ return saveGroups().then((distributionGroups) => { ++ const groupIds = distributionGroups.map((group) => group.id as string); ++ ++ if (zone) { ++ updateZone({ ++ id: zone.id, ++ domain, ++ name: domain, ++ distribution_groups: groupIds, ++ enabled, ++ enable_search_domain: searchDomainsEnabled, ++ } as DNSZone).then(onSuccess); ++ } else { ++ createZone({ ++ domain, ++ name: domain, ++ distribution_groups: groupIds, ++ enabled, ++ enable_search_domain: searchDomainsEnabled, ++ } as DNSZone).then(onSuccessAdded); ++ } ++ }); ++ }; ++ ++ const canUpdateOrCreate = !domainError && groups?.length > 0 && domain !== ""; ++ ++ return ( ++ ++ } ++ title={zone ? "Update DNS Zone" : "Add DNS Zone"} ++ description={ ++ "Use a zone to control domain name resolution for your network." ++ } ++ color={"netbird"} ++ /> ++ ++ ++ ++
++
++ ++ ++ Enter a domain for this zone (e.g., company.internal, ++ intra.example.com) ++ ++ setDomain(e.target.value)} ++ /> ++
++
++ ++ ++ Advertise this zone and its records to peers that belong to the ++ following groups ++ ++ ++
++ ++ ++ ++ Enable Search Domains ++ ++ } ++ helpText={ ++ "E.g., 'server.company.internal' will be accessible with 'server'" ++ } ++ /> ++ ++ ++ ++ Enable DNS Zone ++ ++ } ++ helpText={"Use this switch to enable or disable the dns zone."} ++ /> ++
++ ++ ++
++ ++ Learn more about ++ ++ DNS Zones ++ ++ ++ ++
++
++ ++ ++ ++ ++
++
++
++ ); ++} +diff --git a/src/modules/dns/zones/DNSZonesProvider.tsx b/src/modules/dns/zones/DNSZonesProvider.tsx +new file mode 100644 +index 00000000..c3b3e4d3 +--- /dev/null ++++ b/src/modules/dns/zones/DNSZonesProvider.tsx +@@ -0,0 +1,264 @@ ++import { notify } from "@components/Notification"; ++import { useApiCall } from "@utils/api"; ++import * as React from "react"; ++import { useState } from "react"; ++import { useSWRConfig } from "swr"; ++import { useDialog } from "@/contexts/DialogProvider"; ++import { DNSRecord, DNSZone } from "@/interfaces/DNS"; ++import { Group } from "@/interfaces/Group"; ++import DNSRecordModal from "@/modules/dns/zones/DNSRecordModal"; ++import DNSZoneModal from "@/modules/dns/zones/DNSZoneModal"; ++ ++type Props = { ++ children?: React.ReactNode; ++}; ++ ++const DNSZonesContext = React.createContext( ++ {} as { ++ createZone: (zone: DNSZone) => Promise; ++ updateZone: (zone: DNSZone) => Promise; ++ deleteZone: (zone: DNSZone) => Promise; ++ openZoneModal: ( ++ zone?: DNSZone, ++ initialDistributionGroups?: Group[], ++ ) => void; ++ openRecordModal: (zone: DNSZone, record?: DNSRecord) => void; ++ addRecord: (zone: DNSZone, record: DNSRecord) => Promise; ++ updateRecord: (zone: DNSZone, record: DNSRecord) => Promise; ++ deleteRecord: (zone: DNSZone, record: DNSRecord) => Promise; ++ askForRecord: (zone: DNSZone) => void; ++ }, ++); ++ ++export const DNSZonesProvider = ({ children }: Props) => { ++ const { mutate } = useSWRConfig(); ++ const zoneRequest = useApiCall("/dns/zones", true); ++ const recordRequest = useApiCall("/dns/zones", true); ++ const [dnsModal, setDnsModal] = useState(false); ++ const [recordModal, setRecordModal] = useState(false); ++ const [currentZone, setCurrentZone] = useState(); ++ const [currentRecord, setCurrentRecord] = useState(); ++ const [initialDistributionGroups, setInitialDistributionGroups] = ++ useState(); ++ const { confirm } = useDialog(); ++ ++ const createZone = async (zone: DNSZone): Promise => { ++ const promise = zoneRequest.post(zone).then((zone) => { ++ mutate("/dns/zones"); ++ return Promise.resolve(zone); ++ }); ++ ++ notify({ ++ title: `DNS Zone '${zone.domain}'`, ++ description: `DNS Zone was added successfully.`, ++ promise: promise, ++ loadingMessage: "Adding DNS Zone...", ++ }); ++ ++ return promise; ++ }; ++ ++ const updateZone = async (zone: DNSZone): Promise => { ++ if (!zone?.id) return Promise.reject("Can not update DNS Zone without ID"); ++ const promise = zoneRequest.put(zone, `/${zone.id}`).then((zone) => { ++ mutate("/dns/zones"); ++ return Promise.resolve(zone); ++ }); ++ ++ notify({ ++ title: `DNS Zone '${zone.domain}'`, ++ description: `DNS Zone was updated successfully.`, ++ promise: promise, ++ loadingMessage: "Updating DNS Zone...", ++ }); ++ ++ return promise; ++ }; ++ ++ const deleteZone = async (zone: DNSZone): Promise => { ++ if (!zone?.id) return Promise.reject("Can not delete DNS Zone without ID"); ++ ++ const choice = await confirm({ ++ title: `Delete zone '${zone.domain}'?`, ++ description: ++ "Are you sure you want to delete this zone? This action cannot be undone.", ++ confirmText: "Delete", ++ cancelText: "Cancel", ++ type: "danger", ++ maxWidthClass: "max-w-md", ++ }); ++ if (!choice) return Promise.resolve(zone); ++ ++ const promise = zoneRequest.del({}, `/${zone.id}`).then((zone) => { ++ mutate("/dns/zones"); ++ return Promise.resolve(zone); ++ }); ++ ++ notify({ ++ title: `DNS Zone '${zone.domain}'`, ++ description: `DNS Zone was deleted successfully.`, ++ promise: promise, ++ loadingMessage: "Deleting DNS Zone...", ++ }); ++ ++ return promise; ++ }; ++ ++ const addRecord = async ( ++ zone: DNSZone, ++ record: DNSRecord, ++ ): Promise => { ++ if (!zone?.id) ++ return Promise.reject("Can not add DNS Record without DNS Zone"); ++ const promise = recordRequest ++ .post(record, `/${zone.id}/records`) ++ .then((record) => { ++ mutate("/dns/zones"); ++ return Promise.resolve(record); ++ }); ++ ++ notify({ ++ title: `${record.type} Record '${record.name}'`, ++ description: `DNS Record was added successfully.`, ++ promise: promise, ++ loadingMessage: "Adding DNS Record...", ++ }); ++ ++ return promise; ++ }; ++ ++ const updateRecord = async ( ++ zone: DNSZone, ++ record: DNSRecord, ++ ): Promise => { ++ if (!zone?.id) ++ return Promise.reject("Can not update DNS Record without DNS Zone"); ++ if (!record?.id) ++ return Promise.reject("Can not update DNS Record without ID"); ++ const promise = recordRequest ++ .put(record, `/${zone.id}/records/${record.id}`) ++ .then((record) => { ++ mutate("/dns/zones"); ++ return Promise.resolve(record); ++ }); ++ ++ notify({ ++ title: `${record.type} Record '${record.name}'`, ++ description: `DNS Record was updated successfully.`, ++ promise: promise, ++ loadingMessage: "Updating DNS Record...", ++ }); ++ ++ return promise; ++ }; ++ ++ const deleteRecord = async ( ++ zone: DNSZone, ++ record: DNSRecord, ++ ): Promise => { ++ if (!zone?.id) ++ return Promise.reject("Can not delete DNS Record without DNS Zone"); ++ if (!record?.id) ++ return Promise.reject("Can not delete DNS Record without ID"); ++ ++ const choice = await confirm({ ++ title: `Delete record '${record.name}'?`, ++ description: ++ "Are you sure you want to delete this record? This action cannot be undone.", ++ confirmText: "Delete", ++ cancelText: "Cancel", ++ type: "danger", ++ maxWidthClass: "max-w-md", ++ }); ++ if (!choice) return Promise.resolve(record); ++ ++ const promise = recordRequest ++ .del({}, `/${zone.id}/records/${record.id}`) ++ .then((record) => { ++ mutate("/dns/zones"); ++ return Promise.resolve(record); ++ }); ++ ++ notify({ ++ title: `${record.type} Record '${record.name}'`, ++ description: `DNS Record was deleted successfully.`, ++ promise: promise, ++ loadingMessage: "Deleting DNS Record...", ++ }); ++ ++ return promise; ++ }; ++ ++ const openZoneModal = (zone?: DNSZone, distributionGroups?: Group[]) => { ++ if (zone) setCurrentZone(zone); ++ if (distributionGroups) setInitialDistributionGroups(distributionGroups); ++ setDnsModal(true); ++ }; ++ ++ const openRecordModal = (zone: DNSZone, record?: DNSRecord) => { ++ setCurrentZone(zone); ++ if (record) setCurrentRecord(record); ++ setRecordModal(true); ++ }; ++ ++ const askForRecord = async (zone: DNSZone) => { ++ const choice = await confirm({ ++ title: `Add new record to '${zone.name}'?`, ++ description: ++ "Add either an A, AAAA or a CNAME record to control domain name resolution for your network.", ++ confirmText: "Add Record", ++ cancelText: "Later", ++ type: "default", ++ maxWidthClass: "max-w-md", ++ }); ++ if (!choice) return; ++ openRecordModal(zone); ++ }; ++ ++ return ( ++ ++ {children} ++ { ++ setDnsModal(open); ++ if (!open) { ++ setCurrentZone(undefined); ++ setInitialDistributionGroups(undefined); ++ } ++ }} ++ onSuccessAdded={(z) => askForRecord(z)} ++ zone={currentZone} ++ initialDistributionGroups={initialDistributionGroups} ++ /> ++ {currentZone && ( ++ { ++ setRecordModal(open); ++ if (!open) { ++ setCurrentZone(undefined); ++ setCurrentRecord(undefined); ++ } ++ }} ++ zone={currentZone} ++ record={currentRecord} ++ /> ++ )} ++ ++ ); ++}; ++ ++export const useDNSZones = () => React.useContext(DNSZonesContext); +diff --git a/src/modules/dns/zones/records/DNSRecordActionCell.tsx b/src/modules/dns/zones/records/DNSRecordActionCell.tsx +new file mode 100644 +index 00000000..399206ee +--- /dev/null ++++ b/src/modules/dns/zones/records/DNSRecordActionCell.tsx +@@ -0,0 +1,40 @@ ++import Button from "@components/Button"; ++import { PenSquare, Trash2 } from "lucide-react"; ++import * as React from "react"; ++import { usePermissions } from "@/contexts/PermissionsProvider"; ++import { DNSRecord } from "@/interfaces/DNS"; ++import { useDNSZones } from "@/modules/dns/zones/DNSZonesProvider"; ++import { useDNSZone } from "@/modules/dns/zones/records/DNSRecordsTable"; ++ ++type Props = { ++ record: DNSRecord; ++}; ++ ++export const DNSRecordActionCell = ({ record }: Props) => { ++ const { permission } = usePermissions(); ++ const { deleteRecord, openRecordModal } = useDNSZones(); ++ const zone = useDNSZone(); ++ ++ return ( ++
++ ++ ++
++ ); ++}; +diff --git a/src/modules/dns/zones/records/DNSRecordContentCell.tsx b/src/modules/dns/zones/records/DNSRecordContentCell.tsx +new file mode 100644 +index 00000000..7e04974d +--- /dev/null ++++ b/src/modules/dns/zones/records/DNSRecordContentCell.tsx +@@ -0,0 +1,19 @@ ++import CopyToClipboardText from "@components/CopyToClipboardText"; ++import * as React from "react"; ++import { DNSRecord } from "@/interfaces/DNS"; ++ ++type Props = { ++ record: DNSRecord; ++}; ++ ++export const DNSRecordContentCell = ({ record }: Props) => { ++ return ( ++
++ ++ ++ {record.content} ++ ++ ++
++ ); ++}; +diff --git a/src/modules/dns/zones/records/DNSRecordNameCell.tsx b/src/modules/dns/zones/records/DNSRecordNameCell.tsx +new file mode 100644 +index 00000000..3eed5c78 +--- /dev/null ++++ b/src/modules/dns/zones/records/DNSRecordNameCell.tsx +@@ -0,0 +1,17 @@ ++import CopyToClipboardText from "@components/CopyToClipboardText"; ++import * as React from "react"; ++import { DNSRecord } from "@/interfaces/DNS"; ++ ++type Props = { ++ record: DNSRecord; ++}; ++ ++export const DNSRecordNameCell = ({ record }: Props) => { ++ return ( ++
++ ++ {record.name} ++ ++
++ ); ++}; +diff --git a/src/modules/dns/zones/records/DNSRecordTimeToLiveCell.tsx b/src/modules/dns/zones/records/DNSRecordTimeToLiveCell.tsx +new file mode 100644 +index 00000000..0dccd9bf +--- /dev/null ++++ b/src/modules/dns/zones/records/DNSRecordTimeToLiveCell.tsx +@@ -0,0 +1,21 @@ ++import { ClockIcon } from "lucide-react"; ++import * as React from "react"; ++import { DNSRecord } from "@/interfaces/DNS"; ++import { getTTLLabel } from "@/modules/dns/zones/DNSRecordModal"; ++ ++type Props = { ++ record: DNSRecord; ++}; ++ ++export const DNSRecordTimeToLiveCell = ({ record }: Props) => { ++ return ( ++
++ ++ {getTTLLabel(record.ttl)} ++
++ ); ++}; +diff --git a/src/modules/dns/zones/records/DNSRecordTypeCell.tsx b/src/modules/dns/zones/records/DNSRecordTypeCell.tsx +new file mode 100644 +index 00000000..5b8faaf9 +--- /dev/null ++++ b/src/modules/dns/zones/records/DNSRecordTypeCell.tsx +@@ -0,0 +1,20 @@ ++import Badge from "@components/Badge"; ++import * as React from "react"; ++import { DNSRecord } from "@/interfaces/DNS"; ++ ++type Props = { ++ record: DNSRecord; ++}; ++ ++export const DNSRecordTypeCell = ({ record }: Props) => { ++ return ( ++
++ ++ {record.type} ++ ++
++ ); ++}; +diff --git a/src/modules/dns/zones/records/DNSRecordsTable.tsx b/src/modules/dns/zones/records/DNSRecordsTable.tsx +new file mode 100644 +index 00000000..ff3fa302 +--- /dev/null ++++ b/src/modules/dns/zones/records/DNSRecordsTable.tsx +@@ -0,0 +1,80 @@ ++import { DataTable } from "@components/table/DataTable"; ++import DataTableHeader from "@components/table/DataTableHeader"; ++import { ColumnDef, SortingState } from "@tanstack/react-table"; ++import React, { createContext, useContext, useState } from "react"; ++import { DNSRecord, DNSZone } from "@/interfaces/DNS"; ++import { DNSRecordActionCell } from "@/modules/dns/zones/records/DNSRecordActionCell"; ++import { DNSRecordContentCell } from "@/modules/dns/zones/records/DNSRecordContentCell"; ++import { DNSRecordNameCell } from "@/modules/dns/zones/records/DNSRecordNameCell"; ++import { DNSRecordTimeToLiveCell } from "@/modules/dns/zones/records/DNSRecordTimeToLiveCell"; ++import { DNSRecordTypeCell } from "@/modules/dns/zones/records/DNSRecordTypeCell"; ++ ++type Props = { ++ zone: DNSZone; ++}; ++ ++export const DNSRecordsTableColumns: ColumnDef[] = [ ++ { ++ accessorKey: "type", ++ header: ({ column }) => { ++ return Type; ++ }, ++ cell: ({ row }) => , ++ }, ++ { ++ accessorKey: "name", ++ header: ({ column }) => { ++ return Hostname; ++ }, ++ cell: ({ row }) => , ++ }, ++ { ++ accessorKey: "content", ++ header: ({ column }) => { ++ return Content; ++ }, ++ cell: ({ row }) => , ++ }, ++ { ++ accessorKey: "ttl", ++ header: ({ column }) => { ++ return TTL; ++ }, ++ cell: ({ row }) => , ++ }, ++ { ++ accessorKey: "id", ++ header: "", ++ cell: ({ row }) => , ++ }, ++]; ++ ++const ZoneContext = createContext({} as DNSZone); ++ ++export default function DNSRecordsTable({ zone }: Props) { ++ const [sorting, setSorting] = useState([]); ++ ++ return ( ++ ++ ++ ++ ); ++} ++ ++export const useDNSZone = () => useContext(ZoneContext); +diff --git a/src/modules/dns/zones/table/DNSZonesActionCell.tsx b/src/modules/dns/zones/table/DNSZonesActionCell.tsx +new file mode 100644 +index 00000000..aa1a490d +--- /dev/null ++++ b/src/modules/dns/zones/table/DNSZonesActionCell.tsx +@@ -0,0 +1,58 @@ ++import Button from "@components/Button"; ++import { ++ DropdownMenu, ++ DropdownMenuContent, ++ DropdownMenuItem, ++ DropdownMenuTrigger, ++} from "@components/DropdownMenu"; ++import { MoreVertical, SquarePenIcon, Trash2 } from "lucide-react"; ++import * as React from "react"; ++import { usePermissions } from "@/contexts/PermissionsProvider"; ++import { DNSZone } from "@/interfaces/DNS"; ++import { useDNSZones } from "@/modules/dns/zones/DNSZonesProvider"; ++ ++type Props = { ++ zone: DNSZone; ++}; ++ ++export const DNSZonesActionCell = ({ zone }: Props) => { ++ const { permission } = usePermissions(); ++ const { openZoneModal, deleteZone } = useDNSZones(); ++ ++ return ( ++
++ ++ { ++ e.stopPropagation(); ++ e.preventDefault(); ++ }} ++ > ++ ++ ++ ++ openZoneModal(zone)}> ++
++ ++ Edit ++
++
++ ++ deleteZone(zone)} ++ variant={"danger"} ++ disabled={!permission?.dns?.delete} ++ > ++
++ ++ Delete ++
++
++
++
++
++ ); ++}; +diff --git a/src/modules/dns/zones/table/DNSZonesActiveCell.tsx b/src/modules/dns/zones/table/DNSZonesActiveCell.tsx +new file mode 100644 +index 00000000..35daa15e +--- /dev/null ++++ b/src/modules/dns/zones/table/DNSZonesActiveCell.tsx +@@ -0,0 +1,32 @@ ++import { ToggleSwitch } from "@components/ToggleSwitch"; ++import * as React from "react"; ++import { usePermissions } from "@/contexts/PermissionsProvider"; ++import { DNSZone } from "@/interfaces/DNS"; ++import { useDNSZones } from "@/modules/dns/zones/DNSZonesProvider"; ++ ++type Props = { ++ zone: DNSZone; ++}; ++ ++export const DNSZonesActiveCell = ({ zone }: Props) => { ++ const { permission } = usePermissions(); ++ const { updateZone } = useDNSZones(); ++ ++ return ( ++
++ { ++ e.preventDefault(); ++ e.stopPropagation(); ++ updateZone({ ++ ...zone, ++ enabled: !zone.enabled, ++ }); ++ }} ++ /> ++
++ ); ++}; +diff --git a/src/modules/dns/zones/table/DNSZonesGroupCell.tsx b/src/modules/dns/zones/table/DNSZonesGroupCell.tsx +new file mode 100644 +index 00000000..3b3fa487 +--- /dev/null ++++ b/src/modules/dns/zones/table/DNSZonesGroupCell.tsx +@@ -0,0 +1,60 @@ ++import * as React from "react"; ++import { useMemo, useState } from "react"; ++import { useGroups } from "@/contexts/GroupsProvider"; ++import { usePermissions } from "@/contexts/PermissionsProvider"; ++import { DNSZone } from "@/interfaces/DNS"; ++import { Group } from "@/interfaces/Group"; ++import EmptyRow from "@/modules/common-table-rows/EmptyRow"; ++import GroupsRow from "@/modules/common-table-rows/GroupsRow"; ++import { useDNSZones } from "@/modules/dns/zones/DNSZonesProvider"; ++ ++type Props = { ++ zone: DNSZone; ++}; ++ ++export const DNSZonesGroupCell = ({ zone }: Props) => { ++ const { groups } = useGroups(); ++ const { updateZone } = useDNSZones(); ++ const [modal, setModal] = useState(false); ++ const { permission } = usePermissions(); ++ ++ const allGroups = zone?.distribution_groups ++ .map((group) => { ++ return groups?.find((g) => g.id == group); ++ }) ++ .filter((g) => g != undefined) as Group[]; ++ ++ const groupIDs = useMemo(() => { ++ return allGroups ++ ?.map((group) => group.id) ++ .filter((id) => id !== undefined) as string[]; ++ }, [allGroups]); ++ ++ const handleSave = async (promises: Promise[]) => { ++ const groups = await Promise.all(promises); ++ const groupIds = groups?.map((g) => g.id as string); ++ await updateZone({ ++ ...zone, ++ distribution_groups: groupIds, ++ }).then(() => { ++ setModal(false); ++ }); ++ }; ++ ++ if (!zone?.distribution_groups) return ; ++ ++ return ( ++ ++ ); ++}; +diff --git a/src/modules/dns/zones/table/DNSZonesNameCell.tsx b/src/modules/dns/zones/table/DNSZonesNameCell.tsx +new file mode 100644 +index 00000000..d534c03d +--- /dev/null ++++ b/src/modules/dns/zones/table/DNSZonesNameCell.tsx +@@ -0,0 +1,38 @@ ++import { cn } from "@utils/helpers"; ++import { ChevronDown, ChevronRightIcon } from "lucide-react"; ++import * as React from "react"; ++import { DNSZone } from "@/interfaces/DNS"; ++import ActiveInactiveRow from "@/modules/common-table-rows/ActiveInactiveRow"; ++ ++type Props = { ++ zone: DNSZone; ++}; ++ ++export const DNSZonesNameCell = ({ zone }: Props) => { ++ const hasRecords = (zone?.records?.length ?? 0) > 0; ++ ++ return ( ++
++ ++ ++ ++
++ ); ++}; +diff --git a/src/modules/dns/zones/table/DNSZonesRecordsCell.tsx b/src/modules/dns/zones/table/DNSZonesRecordsCell.tsx +new file mode 100644 +index 00000000..2607f428 +--- /dev/null ++++ b/src/modules/dns/zones/table/DNSZonesRecordsCell.tsx +@@ -0,0 +1,47 @@ ++import Badge from "@components/Badge"; ++import Button from "@components/Button"; ++import { GlobeIcon, PlusCircle } from "lucide-react"; ++import * as React from "react"; ++import { usePermissions } from "@/contexts/PermissionsProvider"; ++import { DNSZone } from "@/interfaces/DNS"; ++import { useDNSZones } from "@/modules/dns/zones/DNSZonesProvider"; ++ ++type Props = { ++ zone: DNSZone; ++}; ++ ++export const DNSZonesRecordsCell = ({ zone }: Props) => { ++ const { permission } = usePermissions(); ++ const { openRecordModal } = useDNSZones(); ++ ++ const recordsCount = zone?.records?.length ?? 0; ++ ++ return ( ++
++ {recordsCount > 0 && ( ++ void 0} ++ > ++ ++
++ {recordsCount} ++
++
++ )} ++ ++ ++
++ ); ++}; +diff --git a/src/modules/dns/zones/table/DNSZonesSearchDomainCell.tsx b/src/modules/dns/zones/table/DNSZonesSearchDomainCell.tsx +new file mode 100644 +index 00000000..2fd613d4 +--- /dev/null ++++ b/src/modules/dns/zones/table/DNSZonesSearchDomainCell.tsx +@@ -0,0 +1,32 @@ ++import { ToggleSwitch } from "@components/ToggleSwitch"; ++import * as React from "react"; ++import { usePermissions } from "@/contexts/PermissionsProvider"; ++import { DNSZone } from "@/interfaces/DNS"; ++import { useDNSZones } from "@/modules/dns/zones/DNSZonesProvider"; ++ ++type Props = { ++ zone: DNSZone; ++}; ++ ++export const DNSZonesSearchDomainCell = ({ zone }: Props) => { ++ const { permission } = usePermissions(); ++ const { updateZone } = useDNSZones(); ++ ++ return ( ++
++ { ++ e.preventDefault(); ++ e.stopPropagation(); ++ updateZone({ ++ ...zone, ++ enable_search_domain: !zone.enable_search_domain, ++ }); ++ }} ++ /> ++
++ ); ++}; +diff --git a/src/modules/dns/zones/table/DNSZonesTable.tsx b/src/modules/dns/zones/table/DNSZonesTable.tsx +new file mode 100644 +index 00000000..ff7e946f +--- /dev/null ++++ b/src/modules/dns/zones/table/DNSZonesTable.tsx +@@ -0,0 +1,303 @@ ++import Button from "@components/Button"; ++import ButtonGroup from "@components/ButtonGroup"; ++import Card from "@components/Card"; ++import InlineLink from "@components/InlineLink"; ++import SquareIcon from "@components/SquareIcon"; ++import { DataTable } from "@components/table/DataTable"; ++import DataTableHeader from "@components/table/DataTableHeader"; ++import DataTableRefreshButton from "@components/table/DataTableRefreshButton"; ++import { DataTableRowsPerPage } from "@components/table/DataTableRowsPerPage"; ++import GetStartedTest from "@components/ui/GetStartedTest"; ++import NoResults from "@components/ui/NoResults"; ++import { ColumnDef, SortingState } from "@tanstack/react-table"; ++import { ExternalLinkIcon, PlusCircle } from "lucide-react"; ++import { usePathname } from "next/navigation"; ++import React, { useMemo } from "react"; ++import { useSWRConfig } from "swr"; ++import { usePermissions } from "@/contexts/PermissionsProvider"; ++import { useLocalStorage } from "@/hooks/useLocalStorage"; ++import { DNS_ZONE_DOCS_LINK, DNSZone } from "@/interfaces/DNS"; ++import { useDNSZones } from "@/modules/dns/zones/DNSZonesProvider"; ++import DNSRecordsTable from "@/modules/dns/zones/records/DNSRecordsTable"; ++import { DNSZonesActionCell } from "@/modules/dns/zones/table/DNSZonesActionCell"; ++import { DNSZonesActiveCell } from "@/modules/dns/zones/table/DNSZonesActiveCell"; ++import { DNSZonesGroupCell } from "@/modules/dns/zones/table/DNSZonesGroupCell"; ++import { DNSZonesNameCell } from "@/modules/dns/zones/table/DNSZonesNameCell"; ++import { DNSZonesRecordsCell } from "@/modules/dns/zones/table/DNSZonesRecordsCell"; ++import { DNSZonesSearchDomainCell } from "@/modules/dns/zones/table/DNSZonesSearchDomainCell"; ++import { Group } from "@/interfaces/Group"; ++import DNSZoneIcon from "@/assets/icons/DNSZoneIcon"; ++import { useGroups } from "@/contexts/GroupsProvider"; ++ ++export const DNSZonesColumns: ColumnDef[] = [ ++ { ++ accessorKey: "domain", ++ header: ({ column }) => ( ++ Zone ++ ), ++ sortingFn: "text", ++ cell: ({ row }) => , ++ }, ++ { ++ accessorKey: "enabled", ++ header: ({ column }) => ( ++ Active ++ ), ++ cell: ({ row }) => , ++ }, ++ { ++ accessorKey: "records", ++ header: ({ column }) => ( ++ Records ++ ), ++ sortingFn: "text", ++ cell: ({ row }) => , ++ }, ++ { ++ accessorKey: "distribution_groups", ++ header: ({ column }) => ( ++ Distribution Groups ++ ), ++ cell: ({ row }) => , ++ }, ++ { ++ accessorKey: "enable_search_domain", ++ header: ({ column }) => ( ++ Search Domain ++ ), ++ cell: ({ row }) => , ++ }, ++ { ++ accessorKey: "id", ++ header: () => "", ++ cell: ({ row }) => , ++ }, ++ { ++ id: "searchString", ++ accessorFn: (row) => { ++ return [ ++ row?.groups_search, ++ row?.name, ++ row?.domain, ++ row?.records?.map((r) => r.name).join(""), ++ row?.records?.map((r) => r.content).join(""), ++ row?.records?.map((r) => r.type).join(""), ++ ]?.join(""); ++ }, ++ }, ++]; ++ ++type Props = { ++ isLoading: boolean; ++ data?: DNSZone[]; ++ headingTarget?: HTMLHeadingElement | null; ++ isGroupPage?: boolean; ++ distributionGroups?: Group[]; ++}; ++ ++export default function DNSZonesTable({ ++ data, ++ isLoading, ++ headingTarget, ++ isGroupPage = false, ++ distributionGroups, ++}: Props) { ++ const { mutate } = useSWRConfig(); ++ const path = usePathname(); ++ const { groups } = useGroups(); ++ ++ // Default sorting state of the table ++ const [sorting, setSorting] = useLocalStorage( ++ "netbird-table-sort" + path, ++ [ ++ { ++ id: "domain", ++ desc: true, ++ }, ++ { ++ id: "id", ++ desc: true, ++ }, ++ ], ++ !isGroupPage, ++ ); ++ ++ const zonesWithGroups = useMemo(() => { ++ return ( ++ data?.map((zone) => { ++ return { ++ ...zone, ++ groups_search: groups ++ ?.map((g) => ++ zone?.distribution_groups?.includes(g?.id ?? "") ? g.name : "", ++ ) ++ .join(""), ++ } as DNSZone; ++ }) ?? [] ++ ); ++ }, [data, groups]); ++ ++ return ( ++ { ++ const hasRecords = (zone?.records?.length ?? 0) > 0; ++ if (!hasRecords) return; ++ return ( ++ <> ++ ++
++ ++ ); ++ }} ++ getStartedCard={ ++ isGroupPage ? ( ++ } ++ className={"py-4"} ++ contentClassName={"max-w-lg"} ++ title={"This group is not used within any zones yet"} ++ description={ ++ "Assign this group as a distribution group in your zones to see them listed here." ++ } ++ > ++
++ ++
++
++ ) : ( ++ } ++ color={"gray"} ++ size={"large"} ++ /> ++ } ++ title={"Create New Zone"} ++ description={ ++ "It looks like you don't have any zones. Control domain name resolution for your network by adding a zone." ++ } ++ button={ ++
++ ++
++ } ++ learnMore={ ++ <> ++ Learn more about ++ ++ DNS Zones ++ ++ ++ ++ } ++ /> ++ ) ++ } ++ rightSide={() => ( ++ <> ++ {data && data?.length > 0 && ( ++
++ ++
++ )} ++ ++ )} ++ > ++ {(table) => ( ++ <> ++ ++ { ++ table.setPageIndex(0); ++ table.getColumn("enabled")?.setFilterValue(undefined); ++ }} ++ disabled={data?.length == 0} ++ variant={ ++ table.getColumn("enabled")?.getFilterValue() === undefined ++ ? "tertiary" ++ : "secondary" ++ } ++ > ++ All ++ ++ { ++ table.setPageIndex(0); ++ table.getColumn("enabled")?.setFilterValue(true); ++ }} ++ disabled={data?.length == 0} ++ variant={ ++ table.getColumn("enabled")?.getFilterValue() === true ++ ? "tertiary" ++ : "secondary" ++ } ++ > ++ Active ++ ++ { ++ table.setPageIndex(0); ++ table.getColumn("enabled")?.setFilterValue(false); ++ }} ++ disabled={data?.length == 0} ++ variant={ ++ table.getColumn("enabled")?.getFilterValue() === false ++ ? "tertiary" ++ : "secondary" ++ } ++ > ++ Inactive ++ ++ ++ ++ { ++ mutate("/dns/zones").then(); ++ mutate("/groups").then(); ++ }} ++ /> ++ ++ )} ++
++ ); ++} ++ ++type AddZoneButtonProps = { ++ distributionGroups?: Group[]; ++}; ++ ++const AddZoneButton = ({ distributionGroups }: AddZoneButtonProps) => { ++ const { permission } = usePermissions(); ++ const { openZoneModal } = useDNSZones(); ++ ++ return ( ++ ++ ); ++}; +diff --git a/src/modules/groups/details/GroupDNSZonesSection.tsx b/src/modules/groups/details/GroupDNSZonesSection.tsx +new file mode 100644 +index 00000000..fecac435 +--- /dev/null ++++ b/src/modules/groups/details/GroupDNSZonesSection.tsx +@@ -0,0 +1,29 @@ ++import React from "react"; ++import { useGroupContext } from "@/contexts/GroupProvider"; ++import { DNSZone } from "@/interfaces/DNS"; ++import { DNSZonesProvider } from "@/modules/dns/zones/DNSZonesProvider"; ++import DNSZonesTable from "@/modules/dns/zones/table/DNSZonesTable"; ++import { GroupDetailsTableContainer } from "@/modules/groups/details/GroupDetailsTableContainer"; ++ ++export const GroupDNSZonesSection = ({ ++ zones, ++ isLoading = true, ++}: { ++ zones?: DNSZone[]; ++ isLoading?: boolean; ++}) => { ++ const { group } = useGroupContext(); ++ ++ return ( ++ ++ ++ ++ ++ ++ ); ++}; +diff --git a/src/modules/groups/details/GroupNameserversSection.tsx b/src/modules/groups/details/GroupNameserversSection.tsx +index f4bd7014..15a1fa85 100644 +--- a/src/modules/groups/details/GroupNameserversSection.tsx ++++ b/src/modules/groups/details/GroupNameserversSection.tsx +@@ -4,7 +4,7 @@ import { NameserverGroup } from "@/interfaces/Nameserver"; + import { GroupDetailsTableContainer } from "@/modules/groups/details/GroupDetailsTableContainer"; + + const NameserverGroupTable = lazy( +- () => import("@/modules/dns-nameservers/table/NameserverGroupTable"), ++ () => import("@/modules/dns/nameservers/table/NameserverGroupTable"), + ); + + type Props = { +diff --git a/src/modules/groups/details/useGroupDetails.ts b/src/modules/groups/details/useGroupDetails.ts +index d16ea66d..85e07777 100644 +--- a/src/modules/groups/details/useGroupDetails.ts ++++ b/src/modules/groups/details/useGroupDetails.ts +@@ -1,4 +1,5 @@ + import { useMemo } from "react"; ++import { DNSZone } from "@/interfaces/DNS"; + import { Group, GroupPeer, GroupResource } from "@/interfaces/Group"; + import { NameserverGroup } from "@/interfaces/Nameserver"; + import { +@@ -16,6 +17,7 @@ import useFetchApi from "@/utils/api"; + export interface GroupDetails extends Group { + policies: Policy[]; + nameservers: NameserverGroup[]; ++ zones?: DNSZone[]; + routes: Route[]; + setupKeys: SetupKey[]; + users: User[]; +@@ -31,6 +33,8 @@ export default function useGroupDetails(groupId: string) { + useFetchApi(`/policies`); + const { data: nameservers, isLoading: isNameserversLoading } = + useFetchApi(`/dns/nameservers`); ++ const { data: zones, isLoading: isZonesLoading } = ++ useFetchApi(`/dns/zones`); + const { data: routes, isLoading: isRoutesLoading } = + useFetchApi(`/routes`); + const { data: setupKeys, isLoading: isSetupKeysLoading } = +@@ -65,6 +69,12 @@ export default function useGroupDetails(groupId: string) { + return nameservers?.filter((ns) => ns.groups?.includes(groupId)) || []; + }, [nameservers, groupId]); + ++ const linkedZones = useMemo(() => { ++ return ( ++ zones?.filter((ns) => ns.distribution_groups?.includes(groupId)) || [] ++ ); ++ }, [zones, groupId]); ++ + const linkedRoutes = useMemo(() => { + return ( + routes?.filter((route) => { +@@ -117,6 +127,7 @@ export default function useGroupDetails(groupId: string) { + isGroupsLoading || + isPoliciesLoading || + isNameserversLoading || ++ isZonesLoading || + isRoutesLoading || + isSetupKeysLoading || + isUsersLoading || +@@ -130,6 +141,7 @@ export default function useGroupDetails(groupId: string) { + ...group, + policies: linkedPolicies, + nameservers: linkedNameservers, ++ zones: linkedZones, + routes: linkedRoutes, + setupKeys: linkedSetupKeys, + users: linkedUsers, +@@ -141,6 +153,7 @@ export default function useGroupDetails(groupId: string) { + group, + linkedPolicies, + linkedNameservers, ++ linkedZones, + linkedRoutes, + linkedSetupKeys, + linkedUsers, +diff --git a/src/modules/groups/table/GroupsTable.tsx b/src/modules/groups/table/GroupsTable.tsx +index e0894bfe..67addd2a 100644 +--- a/src/modules/groups/table/GroupsTable.tsx ++++ b/src/modules/groups/table/GroupsTable.tsx +@@ -3,6 +3,7 @@ import { DataTable } from "@components/table/DataTable"; + import DataTableHeader from "@components/table/DataTableHeader"; + import { DataTableRowsPerPage } from "@components/table/DataTableRowsPerPage"; + import { ColumnDef, SortingState } from "@tanstack/react-table"; ++import { removeAllSpaces } from "@utils/helpers"; + import { Layers3Icon } from "lucide-react"; + import { usePathname } from "next/navigation"; + import React from "react"; +@@ -19,7 +20,7 @@ import GroupsActionCell from "@/modules/groups/table/GroupsActionCell"; + import GroupsCountCell from "@/modules/groups/table/GroupsCountCell"; + import GroupsNameCell from "@/modules/groups/table/GroupsNameCell"; + import useGroupsUsage, { GroupUsage } from "@/modules/groups/useGroupsUsage"; +-import { removeAllSpaces } from "@utils/helpers"; ++import DNSZoneIcon from "@/assets/icons/DNSZoneIcon"; + + export const GroupsTableColumns: ColumnDef[] = [ + { +@@ -178,6 +179,28 @@ export const GroupsTableColumns: ColumnDef[] = [ + /> + ), + }, ++ { ++ accessorKey: "zones_count", ++ header: ({ column }) => { ++ return ( ++ Zones} ++ > ++ ++ ++ ); ++ }, ++ cell: ({ row }) => ( ++ } ++ groupName={row.original.name} ++ href={`/group?id=${row.original.id}&tab=zones`} ++ text={"Zone(s)"} ++ count={row.original.zones_count} ++ /> ++ ), ++ }, + { + accessorKey: "setup_keys_count", + header: ({ column }) => { +@@ -216,7 +239,8 @@ export const GroupsTableColumns: ColumnDef[] = [ + row.routes_count > 0 || + row.setup_keys_count > 0 || + row.users_count > 0 || +- row.resources_count > 0 ++ row.resources_count > 0 || ++ row.zones_count + ); + }, + }, +diff --git a/src/modules/groups/useGroupsUsage.tsx b/src/modules/groups/useGroupsUsage.tsx +index 22fef68d..1fb5e5bc 100644 +--- a/src/modules/groups/useGroupsUsage.tsx ++++ b/src/modules/groups/useGroupsUsage.tsx +@@ -1,5 +1,6 @@ + import useFetchApi from "@utils/api"; + import { useMemo } from "react"; ++import { DNSZone } from "@/interfaces/DNS"; + import { Group } from "@/interfaces/Group"; + import { NameserverGroup } from "@/interfaces/Nameserver"; + import { Policy } from "@/interfaces/Policy"; +@@ -11,6 +12,7 @@ export interface GroupUsage extends Group { + peers_count: number; + policies_count: number; + nameservers_count: number; ++ zones_count: number; + routes_count: number; + setup_keys_count: number; + users_count: number; +@@ -24,6 +26,8 @@ export default function useGroupsUsage() { + useFetchApi(`/policies`); // Policies + const { data: nameservers, isLoading: isNameserversLoading } = + useFetchApi(`/dns/nameservers`); // DNS ++ const { data: zones, isLoading: isZonesLoading } = ++ useFetchApi(`/dns/zones`); // DNS Zones + const { data: routes, isLoading: isRoutesLoading } = + useFetchApi(`/routes`); // Routes + const { data: setupKeys, isLoading: isSetupKeysLoading } = +@@ -57,6 +61,14 @@ export default function useGroupsUsage() { + .filter((u) => u !== undefined); + }, [nameservers, isNameserversLoading]); + ++ const zonesGroups = useMemo(() => { ++ if (isZonesLoading) return; ++ if (!zones) return []; ++ return zones ++ ?.map((zone) => zone.distribution_groups) ++ .filter((u) => u !== undefined); ++ }, [zones, isZonesLoading]); ++ + const setupKeysGroups = useMemo(() => { + if (isSetupKeysLoading) return; + if (!setupKeys) return []; +@@ -78,6 +90,7 @@ export default function useGroupsUsage() { + isGroupsLoading || + isPoliciesLoading || + isNameserversLoading || ++ isZonesLoading || + isRoutesLoading || + isSetupKeysLoading || + isUsersLoading +@@ -86,6 +99,7 @@ export default function useGroupsUsage() { + isGroupsLoading, + isPoliciesLoading, + isNameserversLoading, ++ isZonesLoading, + isRoutesLoading, + isSetupKeysLoading, + isUsersLoading, +@@ -104,6 +118,10 @@ export default function useGroupsUsage() { + return nameserver.includes(group.id as string); + }).length; + ++ const zonesCount = zonesGroups?.filter((zone) => { ++ return zone.includes(group.id as string); ++ }).length; ++ + const routeCount = ( + routes?.filter((route) => { + const groupId = group.id as string; +@@ -133,6 +151,7 @@ export default function useGroupsUsage() { + resources_count: group.resources_count, + policies_count: policyCount, + nameservers_count: nameserverCount, ++ zones_count: zonesCount, + routes_count: routeCount, + setup_keys_count: setupKeyCount, + users_count: userCount, +@@ -143,6 +162,7 @@ export default function useGroupsUsage() { + groups, + policiesGroups, + nameserversGroups, ++ zonesGroups, + routes, + isRoutesLoading, + setupKeysGroups, +diff --git a/src/modules/routes/RouteTable.tsx b/src/modules/routes/RouteTable.tsx +index 64e941c8..1d628b40 100644 +--- a/src/modules/routes/RouteTable.tsx ++++ b/src/modules/routes/RouteTable.tsx +@@ -114,7 +114,7 @@ export default function RouteTable({ row }: Props) { + desc: true, + }, + ]); +- ++ + const hasAtLeastOneExitNode = useMemo(() => { + return row.routes?.some((route) => route.network === "0.0.0.0/0"); + }, [row.routes]); +@@ -147,7 +147,7 @@ export default function RouteTable({ row }: Props) { + tableClassName={"mt-0"} + minimal={true} + showSearchAndFilters={false} +- className={"bg-neutral-900/50 py-2"} ++ className={"bg-nb-gray-960 py-2"} + inset={true} + text={"Network Routes"} + manualPagination={true} +diff --git a/tailwind.config.ts b/tailwind.config.ts +index a4321202..58e21f41 100644 +--- a/tailwind.config.ts ++++ b/tailwind.config.ts +@@ -29,8 +29,9 @@ const config: Config = { + "925": "#1e2123", + "930": "#25282c", + "935": "#1f2124", +- "940": "#1c1d21", ++ "940": "#1c1e21", + "950": "#181a1d", ++ "960": "#15171a", + }, + netbird: { + DEFAULT: "#f68330", diff --git a/src/modules/activity/ActivityDescription.tsx b/src/modules/activity/ActivityDescription.tsx index 5d7fcfc8..3a58eb65 100644 --- a/src/modules/activity/ActivityDescription.tsx +++ b/src/modules/activity/ActivityDescription.tsx @@ -664,6 +664,32 @@ export default function ActivityDescription({ event }: Props) { ); + /** + * Reverse Proxy + */ + + if (event.activity_code == "service.peer.expose") + return ( +
+ Peer {m.peer_name} exposed the service {m.domain} with auth{" "} + {m.auth? "Enabled":"Disabled"} +
+ ); + + if (event.activity_code == "service.peer.unexpose") + return ( +
+ Peer {m.peer_name} unexpose the service {m.domain} +
+ ); + + if (event.activity_code == "service.peer.expose.expire") + return ( +
+ Exposed the service {m.domain} from peer {m.peer_name} was removed due to renew expiration +
+ ); + /** * Networks */ From 04aea818aa3f663206089fa66695feb345ed7b4c Mon Sep 17 00:00:00 2001 From: mlsmaycon Date: Sat, 21 Feb 2026 15:47:08 +0100 Subject: [PATCH 3/8] move peer expose settings into client settings tab and fix activity descriptions Co-Authored-By: Claude Opus 4.6 --- src/app/(dashboard)/settings/page.tsx | 7 - src/modules/activity/ActivityDescription.tsx | 33 ++-- src/modules/settings/ClientSettingsTab.tsx | 96 +++++++++++- src/modules/settings/PeerExposeTab.tsx | 156 ------------------- 4 files changed, 113 insertions(+), 179 deletions(-) delete mode 100644 src/modules/settings/PeerExposeTab.tsx diff --git a/src/app/(dashboard)/settings/page.tsx b/src/app/(dashboard)/settings/page.tsx index c7b1bef5..149602f9 100644 --- a/src/app/(dashboard)/settings/page.tsx +++ b/src/app/(dashboard)/settings/page.tsx @@ -6,7 +6,6 @@ import { AlertOctagonIcon, FingerprintIcon, FolderGit2Icon, - GlobeIcon, LockIcon, MonitorSmartphoneIcon, NetworkIcon, @@ -25,7 +24,6 @@ import IdentityProvidersTab from "@/modules/settings/IdentityProvidersTab"; import NetworkSettingsTab from "@/modules/settings/NetworkSettingsTab"; import PermissionsTab from "@/modules/settings/PermissionsTab"; import GroupsSettings from "@/modules/settings/GroupsSettings"; -import PeerExposeTab from "@/modules/settings/PeerExposeTab"; export default function NetBirdSettings() { const queryParams = useSearchParams(); @@ -80,10 +78,6 @@ export default function NetBirdSettings() { Clients - - - Peer Expose - )} @@ -101,7 +95,6 @@ export default function NetBirdSettings() { {account && } {account && } {account && } - {account && } {account && } diff --git a/src/modules/activity/ActivityDescription.tsx b/src/modules/activity/ActivityDescription.tsx index 3a58eb65..b71849b9 100644 --- a/src/modules/activity/ActivityDescription.tsx +++ b/src/modules/activity/ActivityDescription.tsx @@ -666,29 +666,32 @@ export default function ActivityDescription({ event }: Props) { /** * Reverse Proxy - */ + */ if (event.activity_code == "service.peer.expose") return ( -
- Peer {m.peer_name} exposed the service {m.domain} with auth{" "} - {m.auth? "Enabled":"Disabled"} -
- ); +
+ Peer {m.peer_name} exposed service{" "} + {m.domain} with auth{" "} + {m.auth ? "Enabled" : "Disabled"} +
+ ); if (event.activity_code == "service.peer.unexpose") return ( -
- Peer {m.peer_name} unexpose the service {m.domain} -
- ); +
+ Peer {m.peer_name} unexposed service{" "} + {m.domain} +
+ ); - if (event.activity_code == "service.peer.expose.expire") + if (event.activity_code == "service.peer.expose.expire") return ( -
- Exposed the service {m.domain} from peer {m.peer_name} was removed due to renew expiration -
- ); +
+ Service {m.domain} exposed by peer{" "} + {m.peer_name} was removed due to renewal expiration +
+ ); /** * Networks diff --git a/src/modules/settings/ClientSettingsTab.tsx b/src/modules/settings/ClientSettingsTab.tsx index e9f39a33..c31af1e5 100644 --- a/src/modules/settings/ClientSettingsTab.tsx +++ b/src/modules/settings/ClientSettingsTab.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 { SelectDropdown, SelectOption, @@ -14,10 +15,12 @@ import { useHasChanges } from "@hooks/useHasChanges"; import * as Tabs from "@radix-ui/react-tabs"; import { useApiCall } from "@utils/api"; import { validator } from "@utils/helpers"; +import { AnimatePresence, motion } from "framer-motion"; import { ClockFadingIcon, ExternalLinkIcon, FlaskConicalIcon, + GlobeIcon, MonitorSmartphoneIcon, RefreshCcw, } from "lucide-react"; @@ -26,6 +29,8 @@ import { useSWRConfig } from "swr"; import SettingsIcon from "@/assets/icons/SettingsIcon"; import { usePermissions } from "@/contexts/PermissionsProvider"; import { Account } from "@/interfaces/Account"; +import { Group } from "@/interfaces/Group"; +import { useGroupIdsToGroups } from "@/modules/groups/useGroupIdsToGroups"; import { SmallBadge } from "@components/ui/SmallBadge"; type Props = { @@ -69,9 +74,31 @@ export default function ClientSettingsTab({ account }: Readonly) { isCustomVersion ? autoUpdateSetting : "", ); + const [peerExposeEnabled, setPeerExposeEnabled] = useState( + account?.settings?.extra?.peer_expose_enabled ?? false, + ); + + const initialGroups = useGroupIdsToGroups( + account?.settings?.extra?.peer_expose_groups, + ); + const [peerExposeGroups, setPeerExposeGroups] = useState([]); + + const peerExposeGroupIds = useMemo( + () => peerExposeGroups.map((g) => g.id).filter(Boolean) as string[], + [peerExposeGroups], + ); + + React.useEffect(() => { + if (initialGroups) { + setPeerExposeGroups(initialGroups); + } + }, [initialGroups]); + const { hasChanges, updateRef } = useHasChanges([ autoUpdateMethod, autoUpdateCustomVersion, + peerExposeEnabled, + peerExposeGroupIds, ]); const handleUpdateMethodChange = (value: string) => { @@ -118,11 +145,21 @@ export default function ClientSettingsTab({ account }: Readonly) { settings: { ...account.settings, auto_update_version: autoUpdateCustomVersion || autoUpdateMethod, + extra: { + ...account.settings?.extra, + peer_expose_enabled: peerExposeEnabled, + peer_expose_groups: peerExposeGroupIds, + }, }, }) .then(() => { mutate("/accounts"); - updateRef([autoUpdateMethod, autoUpdateCustomVersion]); + updateRef([ + autoUpdateMethod, + autoUpdateCustomVersion, + peerExposeEnabled, + peerExposeGroupIds, + ]); }), loadingMessage: "Updating client settings...", }); @@ -260,6 +297,63 @@ export default function ClientSettingsTab({ account }: Readonly) { } disabled={!permission.settings.update} /> + +
+ + + Allow peers to expose local services through the NetBird reverse + proxy using the CLI. + +
+ + + + Enable Peer Expose + + } + helpText={ + "When enabled, peers can expose local HTTP services accessible via a public URL." + } + disabled={!permission.settings.update} + /> + + + {peerExposeEnabled && ( +
+ +
+
+ + + Restrict which peer groups are allowed to expose + services. Leave empty to allow all peers. + + +
+
+
+
+ )} +
diff --git a/src/modules/settings/PeerExposeTab.tsx b/src/modules/settings/PeerExposeTab.tsx deleted file mode 100644 index 2597b605..00000000 --- a/src/modules/settings/PeerExposeTab.tsx +++ /dev/null @@ -1,156 +0,0 @@ -import Breadcrumbs from "@components/Breadcrumbs"; -import Button from "@components/Button"; -import FancyToggleSwitch from "@components/FancyToggleSwitch"; -import HelpText from "@components/HelpText"; -import { Label } from "@components/Label"; -import { notify } from "@components/Notification"; -import { PeerGroupSelector } from "@components/PeerGroupSelector"; -import * as Tabs from "@radix-ui/react-tabs"; -import { useApiCall } from "@utils/api"; -import { AnimatePresence, motion } from "framer-motion"; -import { GlobeIcon } from "lucide-react"; -import React, { useMemo, useState } from "react"; -import { useSWRConfig } from "swr"; -import SettingsIcon from "@/assets/icons/SettingsIcon"; -import { usePermissions } from "@/contexts/PermissionsProvider"; -import { useHasChanges } from "@/hooks/useHasChanges"; -import { Account } from "@/interfaces/Account"; -import { Group } from "@/interfaces/Group"; -import { useGroupIdsToGroups } from "@/modules/groups/useGroupIdsToGroups"; - -type Props = { - account: Account; -}; - -export default function PeerExposeTab({ account }: Readonly) { - const { permission } = usePermissions(); - const { mutate } = useSWRConfig(); - const saveRequest = useApiCall("/accounts/" + account.id); - - const [peerExposeEnabled, setPeerExposeEnabled] = useState( - account?.settings?.extra?.peer_expose_enabled ?? false, - ); - - const initialGroups = useGroupIdsToGroups( - account?.settings?.extra?.peer_expose_groups, - ); - const [peerExposeGroups, setPeerExposeGroups] = useState([]); - - const groupIds = useMemo( - () => peerExposeGroups.map((g) => g.id).filter(Boolean) as string[], - [peerExposeGroups], - ); - - const { hasChanges, updateRef } = useHasChanges([ - peerExposeEnabled, - groupIds, - ]); - - React.useEffect(() => { - if (initialGroups) { - setPeerExposeGroups(initialGroups); - } - }, [initialGroups]); - - const saveChanges = async () => { - notify({ - title: "Peer Expose Settings", - description: "Peer expose settings were updated successfully.", - promise: saveRequest - .put({ - id: account.id, - settings: { - ...account.settings, - extra: { - ...account.settings?.extra, - peer_expose_enabled: peerExposeEnabled, - peer_expose_groups: groupIds, - }, - }, - }) - .then(() => { - mutate("/accounts"); - updateRef([peerExposeEnabled, groupIds]); - }), - loadingMessage: "Updating peer expose settings...", - }); - }; - - return ( - -
- - } - /> - } - active - /> - -
-

Peer Expose

- -
- -
- - - Enable peer expose - - } - helpText={ - "Allow peers to expose local services through the NetBird reverse proxy using the CLI." - } - disabled={!permission.settings.update} - /> -
- - - {peerExposeEnabled && ( -
- -
-
- - - Restrict which peer groups are allowed to expose services. - Leave empty to allow all peers. - - -
-
-
-
- )} -
-
-
- ); -} From 36ec0eeb3337b80715108d76143290351c0d0ad3 Mon Sep 17 00:00:00 2001 From: mlsmaycon Date: Sat, 21 Feb 2026 16:33:01 +0100 Subject: [PATCH 4/8] prevent false positive group report --- src/modules/settings/ClientSettingsTab.tsx | 25 ++++++++++++++++------ 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/src/modules/settings/ClientSettingsTab.tsx b/src/modules/settings/ClientSettingsTab.tsx index c31af1e5..1b977e85 100644 --- a/src/modules/settings/ClientSettingsTab.tsx +++ b/src/modules/settings/ClientSettingsTab.tsx @@ -81,19 +81,15 @@ export default function ClientSettingsTab({ account }: Readonly) { const initialGroups = useGroupIdsToGroups( account?.settings?.extra?.peer_expose_groups, ); - const [peerExposeGroups, setPeerExposeGroups] = useState([]); + const [peerExposeGroups, setPeerExposeGroups] = useState( + initialGroups ?? [], + ); const peerExposeGroupIds = useMemo( () => peerExposeGroups.map((g) => g.id).filter(Boolean) as string[], [peerExposeGroups], ); - React.useEffect(() => { - if (initialGroups) { - setPeerExposeGroups(initialGroups); - } - }, [initialGroups]); - const { hasChanges, updateRef } = useHasChanges([ autoUpdateMethod, autoUpdateCustomVersion, @@ -101,6 +97,21 @@ export default function ClientSettingsTab({ account }: Readonly) { peerExposeGroupIds, ]); + React.useEffect(() => { + if (initialGroups) { + setPeerExposeGroups(initialGroups); + const groupIds = initialGroups + .map((g) => g.id) + .filter(Boolean) as string[]; + updateRef([ + autoUpdateMethod, + autoUpdateCustomVersion, + peerExposeEnabled, + groupIds, + ]); + } + }, [initialGroups]); + const handleUpdateMethodChange = (value: string) => { setAutoUpdateMethod(value); if (value === "disabled" || value === "latest") { From 97748c06c6bee4980e08c93621ee4574481cf1d7 Mon Sep 17 00:00:00 2001 From: mlsmaycon Date: Sat, 21 Feb 2026 16:37:38 +0100 Subject: [PATCH 5/8] add docs link --- src/modules/settings/ClientSettingsTab.tsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/modules/settings/ClientSettingsTab.tsx b/src/modules/settings/ClientSettingsTab.tsx index 1b977e85..c0659c70 100644 --- a/src/modules/settings/ClientSettingsTab.tsx +++ b/src/modules/settings/ClientSettingsTab.tsx @@ -316,7 +316,14 @@ export default function ClientSettingsTab({ account }: Readonly) { Allow peers to expose local services through the NetBird reverse - proxy using the CLI. + proxy using the CLI.{" "} + + Learn more + + From 599d3c991efc04c73d357e0a6eadca05d3246599 Mon Sep 17 00:00:00 2001 From: mlsmaycon Date: Sat, 21 Feb 2026 19:44:14 +0100 Subject: [PATCH 6/8] allow save when groups are added to the setting --- src/modules/settings/ClientSettingsTab.tsx | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/modules/settings/ClientSettingsTab.tsx b/src/modules/settings/ClientSettingsTab.tsx index c0659c70..18869473 100644 --- a/src/modules/settings/ClientSettingsTab.tsx +++ b/src/modules/settings/ClientSettingsTab.tsx @@ -137,13 +137,16 @@ export default function ClientSettingsTab({ account }: Readonly) { return ( !hasChanges || !permission.settings.update || - (autoUpdateMethod === "custom" && !canSaveCustomVersion) + (autoUpdateMethod === "custom" && !canSaveCustomVersion) || + (peerExposeEnabled && peerExposeGroupIds.length === 0) ); }, [ hasChanges, permission.settings.update, autoUpdateMethod, canSaveCustomVersion, + peerExposeEnabled, + peerExposeGroupIds, ]); const saveChanges = async () => { @@ -358,8 +361,8 @@ export default function ClientSettingsTab({ account }: Readonly) {
- Restrict which peer groups are allowed to expose - services. Leave empty to allow all peers. + Select which peer groups are allowed to expose + services. At least one group is required. Date: Mon, 23 Feb 2026 12:29:45 +0100 Subject: [PATCH 7/8] Add loading skeleton to client settings, update icon, use grouphelper to allow creating new groups, remove .patch --- custom-zones.patch | 2402 ----------------- src/components/skeletons/SkeletonSettings.tsx | 20 + src/modules/settings/ClientSettingsTab.tsx | 227 +- 3 files changed, 128 insertions(+), 2521 deletions(-) delete mode 100644 custom-zones.patch create mode 100644 src/components/skeletons/SkeletonSettings.tsx diff --git a/custom-zones.patch b/custom-zones.patch deleted file mode 100644 index 07f92ada..00000000 --- a/custom-zones.patch +++ /dev/null @@ -1,2402 +0,0 @@ -diff --git a/package.json b/package.json -index a834b47b..b1306eed 100644 ---- a/package.json -+++ b/package.json -@@ -68,6 +68,7 @@ - "flowbite": "^1.8.1", - "flowbite-react": "^0.6.4", - "framer-motion": "^10.16.4", -+ "ip-address": "^10.1.0", - "ip-cidr": "^3.1.0", - "js-cookie": "^3.0.5", - "lodash": "^4.17.21", -diff --git a/src/app/(dashboard)/dns/nameservers/page.tsx b/src/app/(dashboard)/dns/nameservers/page.tsx -index 7e66f2b6..b287ea09 100644 ---- a/src/app/(dashboard)/dns/nameservers/page.tsx -+++ b/src/app/(dashboard)/dns/nameservers/page.tsx -@@ -7,7 +7,7 @@ import SkeletonTable from "@components/skeletons/SkeletonTable"; - import { RestrictedAccess } from "@components/ui/RestrictedAccess"; - import { usePortalElement } from "@hooks/usePortalElement"; - import useFetchApi from "@utils/api"; --import { ExternalLinkIcon, ServerIcon } from "lucide-react"; -+import { ExternalLinkIcon } from "lucide-react"; - import React, { lazy, Suspense } from "react"; - import DNSIcon from "@/assets/icons/DNSIcon"; - import { usePermissions } from "@/contexts/PermissionsProvider"; -@@ -15,7 +15,7 @@ import { NameserverGroup } from "@/interfaces/Nameserver"; - import PageContainer from "@/layouts/PageContainer"; - - const NameserverGroupTable = lazy( -- () => import("@/modules/dns-nameservers/table/NameserverGroupTable"), -+ () => import("@/modules/dns/nameservers/table/NameserverGroupTable"), - ); - - export default function NameServers() { -@@ -40,7 +40,7 @@ export default function NameServers() { - href={"/dns/nameservers"} - label={"Nameservers"} - active -- icon={} -+ icon={} - /> - -

Nameservers

-diff --git a/src/app/(dashboard)/dns/zones/layout.tsx b/src/app/(dashboard)/dns/zones/layout.tsx -new file mode 100644 -index 00000000..640fa1fc ---- /dev/null -+++ b/src/app/(dashboard)/dns/zones/layout.tsx -@@ -0,0 +1,8 @@ -+import { globalMetaTitle } from "@utils/meta"; -+import type { Metadata } from "next"; -+import BlankLayout from "@/layouts/BlankLayout"; -+ -+export const metadata: Metadata = { -+ title: `Zones - DNS - ${globalMetaTitle}`, -+}; -+export default BlankLayout; -diff --git a/src/app/(dashboard)/dns/zones/page.tsx b/src/app/(dashboard)/dns/zones/page.tsx -new file mode 100644 -index 00000000..0abf50f1 ---- /dev/null -+++ b/src/app/(dashboard)/dns/zones/page.tsx -@@ -0,0 +1,70 @@ -+"use client"; -+ -+import Breadcrumbs from "@components/Breadcrumbs"; -+import InlineLink from "@components/InlineLink"; -+import Paragraph from "@components/Paragraph"; -+import SkeletonTable from "@components/skeletons/SkeletonTable"; -+import { RestrictedAccess } from "@components/ui/RestrictedAccess"; -+import { usePortalElement } from "@hooks/usePortalElement"; -+import useFetchApi from "@utils/api"; -+import { ExternalLinkIcon } from "lucide-react"; -+import React, { lazy, Suspense } from "react"; -+import DNSIcon from "@/assets/icons/DNSIcon"; -+import { usePermissions } from "@/contexts/PermissionsProvider"; -+import { DNS_ZONE_DOCS_LINK, DNSZone } from "@/interfaces/DNS"; -+import PageContainer from "@/layouts/PageContainer"; -+import { DNSZonesProvider } from "@/modules/dns/zones/DNSZonesProvider"; -+import DNSZoneIcon from "@/assets/icons/DNSZoneIcon"; -+ -+const DNSZonesTable = lazy( -+ () => import("@/modules/dns/zones/table/DNSZonesTable"), -+); -+ -+export default function DNSZonePage() { -+ const { permission } = usePermissions(); -+ -+ const { data: zones, isLoading } = useFetchApi("/dns/zones"); -+ -+ const { ref: headingRef, portalTarget } = -+ usePortalElement(); -+ -+ return ( -+ -+
-+ -+ } /> -+ } -+ /> -+ -+

Zones

-+ -+ Manage DNS zones to control domain name resolution for your network. -+ -+ -+ Learn more about -+ -+ DNS Zones -+ -+ -+ in our documentation. -+ -+
-+ -+ -+ }> -+ -+ -+ -+ -+ -+
-+ ); -+} -diff --git a/src/app/(dashboard)/group/page.tsx b/src/app/(dashboard)/group/page.tsx -index df9d0d1e..38118a48 100644 ---- a/src/app/(dashboard)/group/page.tsx -+++ b/src/app/(dashboard)/group/page.tsx -@@ -15,6 +15,7 @@ import { useSearchParams } from "next/navigation"; - import React, { useState } from "react"; - import AccessControlIcon from "@/assets/icons/AccessControlIcon"; - import DNSIcon from "@/assets/icons/DNSIcon"; -+import DNSZoneIcon from "@/assets/icons/DNSZoneIcon"; - import NetworkRoutesIcon from "@/assets/icons/NetworkRoutesIcon"; - import PeerIcon from "@/assets/icons/PeerIcon"; - import SetupKeysIcon from "@/assets/icons/SetupKeysIcon"; -@@ -24,6 +25,7 @@ import { usePermissions } from "@/contexts/PermissionsProvider"; - import RoutesProvider from "@/contexts/RoutesProvider"; - import { Group, GROUP_TOOLTIP_TEXT } from "@/interfaces/Group"; - import PageContainer from "@/layouts/PageContainer"; -+import { GroupDNSZonesSection } from "@/modules/groups/details/GroupDNSZonesSection"; - import { GroupNameserversSection } from "@/modules/groups/details/GroupNameserversSection"; - import { GroupNetworkRoutesSection } from "@/modules/groups/details/GroupNetworkRoutesSection"; - import { GroupPeersSection } from "@/modules/groups/details/GroupPeersSection"; -@@ -134,7 +136,9 @@ const validAllGroupTabs = [ - "resources", - "network-routes", - "nameservers", -+ "zones", - ]; -+ - const validOtherGroupTabs = ["users", "peers", "setup-keys"]; - - const GroupOverviewTabs = ({ group }: { group: Group }) => { -@@ -162,6 +166,7 @@ const GroupOverviewTabs = ({ group }: { group: Group }) => { - const resourcesCount = groupDetails?.resources_count || 0; - const routesCount = groupDetails?.routes?.length || 0; - const nameserversCount = groupDetails?.nameservers?.length || 0; -+ const zonesCount = groupDetails?.zones?.length || 0; - const setupKeysCount = groupDetails?.setupKeys?.length || 0; - - return ( -@@ -249,6 +254,19 @@ const GroupOverviewTabs = ({ group }: { group: Group }) => { - {singularize("Nameservers", nameserversCount)} - - -+ -+ -+ {singularize("Zones", zonesCount)} -+ -+ - {group.name !== "All" && ( - { - /> - - -+ -+ -+ -+ - - -+ -+ -+ ); -+} -diff --git a/src/components/table/Table.tsx b/src/components/table/Table.tsx -index fc119926..ac0b0cb3 100644 ---- a/src/components/table/Table.tsx -+++ b/src/components/table/Table.tsx -@@ -104,7 +104,7 @@ const TableRow = React.forwardRef< - " transition-colors group/table-row data-[state=selected]:bg-neutral-100 dark:data-[state=selected]:bg-nb-gray-930/70", - "dark:data-[state=selected]:border-nb-gray-900", - minimal -- ? "dark:hover:bg-nb-gray-900/10" -+ ? "dark:hover:bg-nb-gray-910/[15%]" - : "border-b dark:border-zinc-700/40 dark:hover:bg-nb-gray-940 hover:bg-neutral-100/50", - className, - )} -diff --git a/src/components/ui/PeerCountBadge.tsx b/src/components/ui/PeerCountBadge.tsx -index 73b6d256..f8fa4ce5 100644 ---- a/src/components/ui/PeerCountBadge.tsx -+++ b/src/components/ui/PeerCountBadge.tsx -@@ -19,11 +19,12 @@ export default function PeerCountBadge({ - className, - }: Props) { - const router = useRouter(); -- const { dropdownOptions } = useGroups(); -+ const { dropdownOptions, groups } = useGroups(); - - const currentGroup = useMemo(() => { -- return dropdownOptions?.find((g) => g.name === group?.name); -- }, [group, dropdownOptions]); -+ const options = dropdownOptions?.find((g) => g.name === group?.name); -+ return options ?? groups?.find((g) => g.name === group?.name); -+ }, [group, dropdownOptions, groups]); - - const peerCount = useMemo(() => { - let peerCount = currentGroup?.peers_count ?? 0; -diff --git a/src/contexts/DialogProvider.tsx b/src/contexts/DialogProvider.tsx -index 0cf10d7d..096b28f7 100644 ---- a/src/contexts/DialogProvider.tsx -+++ b/src/contexts/DialogProvider.tsx -@@ -66,6 +66,8 @@ export default function DialogProvider({ children }: Props) { - e.preventDefault()} -+ onPointerDownOutside={(e) => e.preventDefault()} - > - -+ - [] = [ - { -diff --git a/src/modules/dns-nameservers/table/NameserverMatchDomainsCell.tsx b/src/modules/dns/nameservers/table/NameserverMatchDomainsCell.tsx -similarity index 100% -rename from src/modules/dns-nameservers/table/NameserverMatchDomainsCell.tsx -rename to src/modules/dns/nameservers/table/NameserverMatchDomainsCell.tsx -diff --git a/src/modules/dns-nameservers/table/NameserverNameCell.tsx b/src/modules/dns/nameservers/table/NameserverNameCell.tsx -similarity index 100% -rename from src/modules/dns-nameservers/table/NameserverNameCell.tsx -rename to src/modules/dns/nameservers/table/NameserverNameCell.tsx -diff --git a/src/modules/dns-nameservers/table/NameserverNameserversCell.tsx b/src/modules/dns/nameservers/table/NameserverNameserversCell.tsx -similarity index 100% -rename from src/modules/dns-nameservers/table/NameserverNameserversCell.tsx -rename to src/modules/dns/nameservers/table/NameserverNameserversCell.tsx -diff --git a/src/modules/dns/zones/DNSRecordModal.tsx b/src/modules/dns/zones/DNSRecordModal.tsx -new file mode 100644 -index 00000000..7e59b2b1 ---- /dev/null -+++ b/src/modules/dns/zones/DNSRecordModal.tsx -@@ -0,0 +1,359 @@ -+import Button from "@components/Button"; -+import HelpText from "@components/HelpText"; -+import InlineLink from "@components/InlineLink"; -+import { Input } from "@components/Input"; -+import { Label } from "@components/Label"; -+import { -+ Modal, -+ ModalClose, -+ ModalContent, -+ ModalFooter, -+ ModalTrigger, -+} from "@components/modal/Modal"; -+import ModalHeader from "@components/modal/ModalHeader"; -+import Paragraph from "@components/Paragraph"; -+import { -+ Select, -+ SelectContent, -+ SelectItem, -+ SelectTrigger, -+ SelectValue, -+} from "@components/Select"; -+import Separator from "@components/Separator"; -+import { validator } from "@utils/helpers"; -+import { Address4, Address6 } from "ip-address"; -+import { ClockIcon, ExternalLinkIcon, GlobeIcon } from "lucide-react"; -+import React, { useMemo, useState } from "react"; -+import { -+ DNS_RECORDS_DOCS_LINK, -+ DNSRecord, -+ DNSRecordType, -+ DNSZone, -+} from "@/interfaces/DNS"; -+import { useDNSZones } from "@/modules/dns/zones/DNSZonesProvider"; -+ -+type Props = { -+ children?: React.ReactNode; -+ open: boolean; -+ onOpenChange: (open: boolean) => void; -+ zone: DNSZone; -+ record?: DNSRecord; -+}; -+ -+export default function DNSRecordModal({ -+ children, -+ open, -+ onOpenChange, -+ zone, -+ record, -+}: Readonly) { -+ return ( -+ -+ {children && {children}} -+ {open && ( -+ onOpenChange(false)} -+ onSuccessAdded={() => { -+ setTimeout(() => { -+ const row = document.querySelector( -+ `[data-row-id="${zone.id}"]`, -+ ); -+ if (row?.getAttribute("data-accordion") === "closed") { -+ row?.click(); -+ } -+ row?.scrollIntoView({ behavior: "smooth" }); -+ }, 200); -+ onOpenChange(false); -+ }} -+ zone={zone} -+ record={record} -+ /> -+ )} -+ -+ ); -+} -+ -+type ModalProps = { -+ onSuccess?: () => void; -+ onSuccessAdded?: () => void; -+ zone: DNSZone; -+ record?: DNSRecord; -+}; -+ -+export function DNSRecordModalContent({ -+ onSuccess, -+ onSuccessAdded, -+ zone, -+ record, -+}: Readonly) { -+ const { addRecord, updateRecord } = useDNSZones(); -+ -+ const getInitialDomain = () => { -+ if (!record) return ""; -+ if (record.name === zone.domain) return ""; -+ return record.name.replace(`.${zone.domain}`, ""); -+ }; -+ -+ const [domain, setDomain] = useState(record?.name ? getInitialDomain() : ""); -+ const [ttl, setTtl] = useState(record ? record.ttl.toString() : "300"); -+ const [type, setType] = useState(record?.type ?? "A"); -+ const [recordValue, setRecordValue] = useState(record?.content ?? ""); -+ -+ const domainError = useMemo(() => { -+ if (domain == "") return ""; -+ const valid = validator.isValidDomain(domain, { -+ allowWildcard: false, -+ allowOnlyTld: true, -+ }); -+ if (!valid) { -+ return "Please enter a valid domain, e.g. example.com or intra.example.com"; -+ } -+ }, [domain]); -+ -+ const ipv4Error = useMemo(() => { -+ if (recordValue === "" || type !== "A") return ""; -+ const valid = Address4.isValid(recordValue); -+ if (!valid) { -+ return "Please enter a valid IPv4 address, e.g. 192.168.1.1"; -+ } -+ }, [recordValue, type]); -+ -+ const ipv6Error = useMemo(() => { -+ if (recordValue === "" || type !== "AAAA") return ""; -+ const valid = Address6.isValid(recordValue); -+ if (!valid) { -+ return "Please enter a valid IPv6 address, e.g. 2001:0db8:85a3::8a2e:0370:7334"; -+ } -+ }, [recordValue, type]); -+ -+ const cnameError = useMemo(() => { -+ if (recordValue === "" || type !== "CNAME") return ""; -+ const valid = validator.isValidDomain(recordValue, { -+ allowWildcard: false, -+ allowOnlyTld: false, -+ }); -+ if (!valid) { -+ return "Please enter a valid domain, e.g. example.com or server.example.com"; -+ } -+ }, [recordValue, type]); -+ -+ const handleAddRecord = async () => { -+ const name = domain !== "" ? `${domain}.${zone.domain}` : zone.domain; -+ -+ if (record) { -+ updateRecord(zone, { -+ id: record.id, -+ name, -+ type, -+ content: recordValue, -+ ttl: parseInt(ttl), -+ }).then(onSuccess); -+ } else { -+ addRecord(zone, { -+ name, -+ type, -+ content: recordValue, -+ ttl: parseInt(ttl), -+ }).then(onSuccessAdded); -+ } -+ }; -+ -+ const canUpdateOrCreate = -+ !cnameError && -+ !ipv6Error && -+ !ipv4Error && -+ !domainError && -+ recordValue !== ""; -+ -+ return ( -+ -+ } -+ /> -+ -+
-+
-+
-+ -+ -+ Select the type of record you want to add -+ -+
-+
-+ -+
-+
-+
-+ -+ -+ Enter a subdomain or leave empty to use the primary domain. -+ -+
-+ setDomain(e.target.value)} -+ /> -+
-+ .{zone.domain} -+
-+
-+
-+ -+
-+ {type === "A" && ( -+
-+ -+ setRecordValue(e.target.value)} -+ /> -+
-+ )} -+ -+ {type === "AAAA" && ( -+
-+ -+ setRecordValue(e.target.value)} -+ /> -+
-+ )} -+ -+ {type === "CNAME" && ( -+
-+ -+ setRecordValue(e.target.value)} -+ /> -+
-+ )} -+ -+
-+ -+
-+ -+
-+
-+
-+
-+ -+ -+
-+ -+ Learn more about -+ -+ DNS Records -+ -+ -+ -+
-+ -+
-+ <> -+ -+ -+ -+ -+ -+
-+
-+
-+ ); -+} -+ -+export const getTTLLabel = (seconds: number): string => { -+ if (seconds < 60) return `${seconds} Sec.`; -+ if (seconds < 3600) { -+ const minutes = seconds / 60; -+ return minutes === 1 ? "1 Min." : `${minutes} Min.`; -+ } -+ if (seconds < 86400) { -+ const hours = seconds / 3600; -+ return hours === 1 ? "1 Hour" : `${hours} Hours`; -+ } -+ const days = seconds / 86400; -+ return days === 1 ? "1 Day" : `${days} Days`; -+}; -diff --git a/src/modules/dns/zones/DNSZoneModal.tsx b/src/modules/dns/zones/DNSZoneModal.tsx -new file mode 100644 -index 00000000..4767a6d6 ---- /dev/null -+++ b/src/modules/dns/zones/DNSZoneModal.tsx -@@ -0,0 +1,225 @@ -+import Button from "@components/Button"; -+import FancyToggleSwitch from "@components/FancyToggleSwitch"; -+import HelpText from "@components/HelpText"; -+import InlineLink from "@components/InlineLink"; -+import { Input } from "@components/Input"; -+import { Label } from "@components/Label"; -+import { -+ Modal, -+ ModalClose, -+ ModalContent, -+ ModalFooter, -+ ModalTrigger, -+} from "@components/modal/Modal"; -+import ModalHeader from "@components/modal/ModalHeader"; -+import Paragraph from "@components/Paragraph"; -+import { PeerGroupSelector } from "@components/PeerGroupSelector"; -+import Separator from "@components/Separator"; -+import { validator } from "@utils/helpers"; -+import { ExternalLinkIcon, Power, ScanSearch } from "lucide-react"; -+import React, { useMemo, useState } from "react"; -+import { DNS_ZONE_DOCS_LINK, DNSZone } from "@/interfaces/DNS"; -+import { useDNSZones } from "@/modules/dns/zones/DNSZonesProvider"; -+import useGroupHelper from "@/modules/groups/useGroupHelper"; -+import { Group } from "@/interfaces/Group"; -+import DNSZoneIcon from "@/assets/icons/DNSZoneIcon"; -+ -+type Props = { -+ children?: React.ReactNode; -+ open: boolean; -+ onOpenChange: (open: boolean) => void; -+ onSuccess?: (zone: DNSZone) => void; -+ onSuccessAdded?: (zone: DNSZone) => void; -+ initialDistributionGroups?: Group[]; -+ zone?: DNSZone; -+}; -+ -+export default function DNSZoneModal({ -+ children, -+ open, -+ onOpenChange, -+ onSuccess, -+ onSuccessAdded, -+ initialDistributionGroups, -+ zone, -+}: Readonly) { -+ return ( -+ -+ {children && {children}} -+ {open && ( -+ { -+ onOpenChange(false); -+ onSuccess?.(z); -+ }} -+ onSuccessAdded={(z) => { -+ onOpenChange(false); -+ onSuccessAdded?.(z); -+ }} -+ zone={zone} -+ initialDistributionGroups={initialDistributionGroups} -+ /> -+ )} -+ -+ ); -+} -+ -+type ModalProps = { -+ onSuccess?: (zone: DNSZone) => void; -+ onSuccessAdded?: (zone: DNSZone) => void; -+ initialDistributionGroups?: Group[]; -+ zone?: DNSZone; -+}; -+ -+export function DNSZoneModalContent({ -+ onSuccess, -+ onSuccessAdded, -+ zone, -+ initialDistributionGroups, -+}: Readonly) { -+ const { createZone, updateZone } = useDNSZones(); -+ const [domain, setDomain] = useState(zone?.domain ?? ""); -+ const [enabled, setEnabled] = useState(zone?.enabled ?? true); -+ const [searchDomainsEnabled, setSearchDomainsEnabled] = useState( -+ zone?.enable_search_domain ?? false, -+ ); -+ const [groups, setGroups, { save: saveGroups }] = useGroupHelper({ -+ initial: initialDistributionGroups ?? zone?.distribution_groups ?? [], -+ }); -+ -+ const domainError = useMemo(() => { -+ if (domain == "") return ""; -+ const valid = validator.isValidDomain(domain, { -+ allowWildcard: false, -+ allowOnlyTld: false, -+ }); -+ if (!valid) { -+ return "Please enter a valid domain, e.g. company.internal or intra.example.com"; -+ } -+ }, [domain]); -+ -+ const handleOnSubmit = async () => { -+ return saveGroups().then((distributionGroups) => { -+ const groupIds = distributionGroups.map((group) => group.id as string); -+ -+ if (zone) { -+ updateZone({ -+ id: zone.id, -+ domain, -+ name: domain, -+ distribution_groups: groupIds, -+ enabled, -+ enable_search_domain: searchDomainsEnabled, -+ } as DNSZone).then(onSuccess); -+ } else { -+ createZone({ -+ domain, -+ name: domain, -+ distribution_groups: groupIds, -+ enabled, -+ enable_search_domain: searchDomainsEnabled, -+ } as DNSZone).then(onSuccessAdded); -+ } -+ }); -+ }; -+ -+ const canUpdateOrCreate = !domainError && groups?.length > 0 && domain !== ""; -+ -+ return ( -+ -+ } -+ title={zone ? "Update DNS Zone" : "Add DNS Zone"} -+ description={ -+ "Use a zone to control domain name resolution for your network." -+ } -+ color={"netbird"} -+ /> -+ -+ -+ -+
-+
-+ -+ -+ Enter a domain for this zone (e.g., company.internal, -+ intra.example.com) -+ -+ setDomain(e.target.value)} -+ /> -+
-+
-+ -+ -+ Advertise this zone and its records to peers that belong to the -+ following groups -+ -+ -+
-+ -+ -+ -+ Enable Search Domains -+ -+ } -+ helpText={ -+ "E.g., 'server.company.internal' will be accessible with 'server'" -+ } -+ /> -+ -+ -+ -+ Enable DNS Zone -+ -+ } -+ helpText={"Use this switch to enable or disable the dns zone."} -+ /> -+
-+ -+ -+
-+ -+ Learn more about -+ -+ DNS Zones -+ -+ -+ -+
-+
-+ -+ -+ -+ -+
-+
-+
-+ ); -+} -diff --git a/src/modules/dns/zones/DNSZonesProvider.tsx b/src/modules/dns/zones/DNSZonesProvider.tsx -new file mode 100644 -index 00000000..c3b3e4d3 ---- /dev/null -+++ b/src/modules/dns/zones/DNSZonesProvider.tsx -@@ -0,0 +1,264 @@ -+import { notify } from "@components/Notification"; -+import { useApiCall } from "@utils/api"; -+import * as React from "react"; -+import { useState } from "react"; -+import { useSWRConfig } from "swr"; -+import { useDialog } from "@/contexts/DialogProvider"; -+import { DNSRecord, DNSZone } from "@/interfaces/DNS"; -+import { Group } from "@/interfaces/Group"; -+import DNSRecordModal from "@/modules/dns/zones/DNSRecordModal"; -+import DNSZoneModal from "@/modules/dns/zones/DNSZoneModal"; -+ -+type Props = { -+ children?: React.ReactNode; -+}; -+ -+const DNSZonesContext = React.createContext( -+ {} as { -+ createZone: (zone: DNSZone) => Promise; -+ updateZone: (zone: DNSZone) => Promise; -+ deleteZone: (zone: DNSZone) => Promise; -+ openZoneModal: ( -+ zone?: DNSZone, -+ initialDistributionGroups?: Group[], -+ ) => void; -+ openRecordModal: (zone: DNSZone, record?: DNSRecord) => void; -+ addRecord: (zone: DNSZone, record: DNSRecord) => Promise; -+ updateRecord: (zone: DNSZone, record: DNSRecord) => Promise; -+ deleteRecord: (zone: DNSZone, record: DNSRecord) => Promise; -+ askForRecord: (zone: DNSZone) => void; -+ }, -+); -+ -+export const DNSZonesProvider = ({ children }: Props) => { -+ const { mutate } = useSWRConfig(); -+ const zoneRequest = useApiCall("/dns/zones", true); -+ const recordRequest = useApiCall("/dns/zones", true); -+ const [dnsModal, setDnsModal] = useState(false); -+ const [recordModal, setRecordModal] = useState(false); -+ const [currentZone, setCurrentZone] = useState(); -+ const [currentRecord, setCurrentRecord] = useState(); -+ const [initialDistributionGroups, setInitialDistributionGroups] = -+ useState(); -+ const { confirm } = useDialog(); -+ -+ const createZone = async (zone: DNSZone): Promise => { -+ const promise = zoneRequest.post(zone).then((zone) => { -+ mutate("/dns/zones"); -+ return Promise.resolve(zone); -+ }); -+ -+ notify({ -+ title: `DNS Zone '${zone.domain}'`, -+ description: `DNS Zone was added successfully.`, -+ promise: promise, -+ loadingMessage: "Adding DNS Zone...", -+ }); -+ -+ return promise; -+ }; -+ -+ const updateZone = async (zone: DNSZone): Promise => { -+ if (!zone?.id) return Promise.reject("Can not update DNS Zone without ID"); -+ const promise = zoneRequest.put(zone, `/${zone.id}`).then((zone) => { -+ mutate("/dns/zones"); -+ return Promise.resolve(zone); -+ }); -+ -+ notify({ -+ title: `DNS Zone '${zone.domain}'`, -+ description: `DNS Zone was updated successfully.`, -+ promise: promise, -+ loadingMessage: "Updating DNS Zone...", -+ }); -+ -+ return promise; -+ }; -+ -+ const deleteZone = async (zone: DNSZone): Promise => { -+ if (!zone?.id) return Promise.reject("Can not delete DNS Zone without ID"); -+ -+ const choice = await confirm({ -+ title: `Delete zone '${zone.domain}'?`, -+ description: -+ "Are you sure you want to delete this zone? This action cannot be undone.", -+ confirmText: "Delete", -+ cancelText: "Cancel", -+ type: "danger", -+ maxWidthClass: "max-w-md", -+ }); -+ if (!choice) return Promise.resolve(zone); -+ -+ const promise = zoneRequest.del({}, `/${zone.id}`).then((zone) => { -+ mutate("/dns/zones"); -+ return Promise.resolve(zone); -+ }); -+ -+ notify({ -+ title: `DNS Zone '${zone.domain}'`, -+ description: `DNS Zone was deleted successfully.`, -+ promise: promise, -+ loadingMessage: "Deleting DNS Zone...", -+ }); -+ -+ return promise; -+ }; -+ -+ const addRecord = async ( -+ zone: DNSZone, -+ record: DNSRecord, -+ ): Promise => { -+ if (!zone?.id) -+ return Promise.reject("Can not add DNS Record without DNS Zone"); -+ const promise = recordRequest -+ .post(record, `/${zone.id}/records`) -+ .then((record) => { -+ mutate("/dns/zones"); -+ return Promise.resolve(record); -+ }); -+ -+ notify({ -+ title: `${record.type} Record '${record.name}'`, -+ description: `DNS Record was added successfully.`, -+ promise: promise, -+ loadingMessage: "Adding DNS Record...", -+ }); -+ -+ return promise; -+ }; -+ -+ const updateRecord = async ( -+ zone: DNSZone, -+ record: DNSRecord, -+ ): Promise => { -+ if (!zone?.id) -+ return Promise.reject("Can not update DNS Record without DNS Zone"); -+ if (!record?.id) -+ return Promise.reject("Can not update DNS Record without ID"); -+ const promise = recordRequest -+ .put(record, `/${zone.id}/records/${record.id}`) -+ .then((record) => { -+ mutate("/dns/zones"); -+ return Promise.resolve(record); -+ }); -+ -+ notify({ -+ title: `${record.type} Record '${record.name}'`, -+ description: `DNS Record was updated successfully.`, -+ promise: promise, -+ loadingMessage: "Updating DNS Record...", -+ }); -+ -+ return promise; -+ }; -+ -+ const deleteRecord = async ( -+ zone: DNSZone, -+ record: DNSRecord, -+ ): Promise => { -+ if (!zone?.id) -+ return Promise.reject("Can not delete DNS Record without DNS Zone"); -+ if (!record?.id) -+ return Promise.reject("Can not delete DNS Record without ID"); -+ -+ const choice = await confirm({ -+ title: `Delete record '${record.name}'?`, -+ description: -+ "Are you sure you want to delete this record? This action cannot be undone.", -+ confirmText: "Delete", -+ cancelText: "Cancel", -+ type: "danger", -+ maxWidthClass: "max-w-md", -+ }); -+ if (!choice) return Promise.resolve(record); -+ -+ const promise = recordRequest -+ .del({}, `/${zone.id}/records/${record.id}`) -+ .then((record) => { -+ mutate("/dns/zones"); -+ return Promise.resolve(record); -+ }); -+ -+ notify({ -+ title: `${record.type} Record '${record.name}'`, -+ description: `DNS Record was deleted successfully.`, -+ promise: promise, -+ loadingMessage: "Deleting DNS Record...", -+ }); -+ -+ return promise; -+ }; -+ -+ const openZoneModal = (zone?: DNSZone, distributionGroups?: Group[]) => { -+ if (zone) setCurrentZone(zone); -+ if (distributionGroups) setInitialDistributionGroups(distributionGroups); -+ setDnsModal(true); -+ }; -+ -+ const openRecordModal = (zone: DNSZone, record?: DNSRecord) => { -+ setCurrentZone(zone); -+ if (record) setCurrentRecord(record); -+ setRecordModal(true); -+ }; -+ -+ const askForRecord = async (zone: DNSZone) => { -+ const choice = await confirm({ -+ title: `Add new record to '${zone.name}'?`, -+ description: -+ "Add either an A, AAAA or a CNAME record to control domain name resolution for your network.", -+ confirmText: "Add Record", -+ cancelText: "Later", -+ type: "default", -+ maxWidthClass: "max-w-md", -+ }); -+ if (!choice) return; -+ openRecordModal(zone); -+ }; -+ -+ return ( -+ -+ {children} -+ { -+ setDnsModal(open); -+ if (!open) { -+ setCurrentZone(undefined); -+ setInitialDistributionGroups(undefined); -+ } -+ }} -+ onSuccessAdded={(z) => askForRecord(z)} -+ zone={currentZone} -+ initialDistributionGroups={initialDistributionGroups} -+ /> -+ {currentZone && ( -+ { -+ setRecordModal(open); -+ if (!open) { -+ setCurrentZone(undefined); -+ setCurrentRecord(undefined); -+ } -+ }} -+ zone={currentZone} -+ record={currentRecord} -+ /> -+ )} -+ -+ ); -+}; -+ -+export const useDNSZones = () => React.useContext(DNSZonesContext); -diff --git a/src/modules/dns/zones/records/DNSRecordActionCell.tsx b/src/modules/dns/zones/records/DNSRecordActionCell.tsx -new file mode 100644 -index 00000000..399206ee ---- /dev/null -+++ b/src/modules/dns/zones/records/DNSRecordActionCell.tsx -@@ -0,0 +1,40 @@ -+import Button from "@components/Button"; -+import { PenSquare, Trash2 } from "lucide-react"; -+import * as React from "react"; -+import { usePermissions } from "@/contexts/PermissionsProvider"; -+import { DNSRecord } from "@/interfaces/DNS"; -+import { useDNSZones } from "@/modules/dns/zones/DNSZonesProvider"; -+import { useDNSZone } from "@/modules/dns/zones/records/DNSRecordsTable"; -+ -+type Props = { -+ record: DNSRecord; -+}; -+ -+export const DNSRecordActionCell = ({ record }: Props) => { -+ const { permission } = usePermissions(); -+ const { deleteRecord, openRecordModal } = useDNSZones(); -+ const zone = useDNSZone(); -+ -+ return ( -+
-+ -+ -+
-+ ); -+}; -diff --git a/src/modules/dns/zones/records/DNSRecordContentCell.tsx b/src/modules/dns/zones/records/DNSRecordContentCell.tsx -new file mode 100644 -index 00000000..7e04974d ---- /dev/null -+++ b/src/modules/dns/zones/records/DNSRecordContentCell.tsx -@@ -0,0 +1,19 @@ -+import CopyToClipboardText from "@components/CopyToClipboardText"; -+import * as React from "react"; -+import { DNSRecord } from "@/interfaces/DNS"; -+ -+type Props = { -+ record: DNSRecord; -+}; -+ -+export const DNSRecordContentCell = ({ record }: Props) => { -+ return ( -+
-+ -+ -+ {record.content} -+ -+ -+
-+ ); -+}; -diff --git a/src/modules/dns/zones/records/DNSRecordNameCell.tsx b/src/modules/dns/zones/records/DNSRecordNameCell.tsx -new file mode 100644 -index 00000000..3eed5c78 ---- /dev/null -+++ b/src/modules/dns/zones/records/DNSRecordNameCell.tsx -@@ -0,0 +1,17 @@ -+import CopyToClipboardText from "@components/CopyToClipboardText"; -+import * as React from "react"; -+import { DNSRecord } from "@/interfaces/DNS"; -+ -+type Props = { -+ record: DNSRecord; -+}; -+ -+export const DNSRecordNameCell = ({ record }: Props) => { -+ return ( -+
-+ -+ {record.name} -+ -+
-+ ); -+}; -diff --git a/src/modules/dns/zones/records/DNSRecordTimeToLiveCell.tsx b/src/modules/dns/zones/records/DNSRecordTimeToLiveCell.tsx -new file mode 100644 -index 00000000..0dccd9bf ---- /dev/null -+++ b/src/modules/dns/zones/records/DNSRecordTimeToLiveCell.tsx -@@ -0,0 +1,21 @@ -+import { ClockIcon } from "lucide-react"; -+import * as React from "react"; -+import { DNSRecord } from "@/interfaces/DNS"; -+import { getTTLLabel } from "@/modules/dns/zones/DNSRecordModal"; -+ -+type Props = { -+ record: DNSRecord; -+}; -+ -+export const DNSRecordTimeToLiveCell = ({ record }: Props) => { -+ return ( -+
-+ -+ {getTTLLabel(record.ttl)} -+
-+ ); -+}; -diff --git a/src/modules/dns/zones/records/DNSRecordTypeCell.tsx b/src/modules/dns/zones/records/DNSRecordTypeCell.tsx -new file mode 100644 -index 00000000..5b8faaf9 ---- /dev/null -+++ b/src/modules/dns/zones/records/DNSRecordTypeCell.tsx -@@ -0,0 +1,20 @@ -+import Badge from "@components/Badge"; -+import * as React from "react"; -+import { DNSRecord } from "@/interfaces/DNS"; -+ -+type Props = { -+ record: DNSRecord; -+}; -+ -+export const DNSRecordTypeCell = ({ record }: Props) => { -+ return ( -+
-+ -+ {record.type} -+ -+
-+ ); -+}; -diff --git a/src/modules/dns/zones/records/DNSRecordsTable.tsx b/src/modules/dns/zones/records/DNSRecordsTable.tsx -new file mode 100644 -index 00000000..ff3fa302 ---- /dev/null -+++ b/src/modules/dns/zones/records/DNSRecordsTable.tsx -@@ -0,0 +1,80 @@ -+import { DataTable } from "@components/table/DataTable"; -+import DataTableHeader from "@components/table/DataTableHeader"; -+import { ColumnDef, SortingState } from "@tanstack/react-table"; -+import React, { createContext, useContext, useState } from "react"; -+import { DNSRecord, DNSZone } from "@/interfaces/DNS"; -+import { DNSRecordActionCell } from "@/modules/dns/zones/records/DNSRecordActionCell"; -+import { DNSRecordContentCell } from "@/modules/dns/zones/records/DNSRecordContentCell"; -+import { DNSRecordNameCell } from "@/modules/dns/zones/records/DNSRecordNameCell"; -+import { DNSRecordTimeToLiveCell } from "@/modules/dns/zones/records/DNSRecordTimeToLiveCell"; -+import { DNSRecordTypeCell } from "@/modules/dns/zones/records/DNSRecordTypeCell"; -+ -+type Props = { -+ zone: DNSZone; -+}; -+ -+export const DNSRecordsTableColumns: ColumnDef[] = [ -+ { -+ accessorKey: "type", -+ header: ({ column }) => { -+ return Type; -+ }, -+ cell: ({ row }) => , -+ }, -+ { -+ accessorKey: "name", -+ header: ({ column }) => { -+ return Hostname; -+ }, -+ cell: ({ row }) => , -+ }, -+ { -+ accessorKey: "content", -+ header: ({ column }) => { -+ return Content; -+ }, -+ cell: ({ row }) => , -+ }, -+ { -+ accessorKey: "ttl", -+ header: ({ column }) => { -+ return TTL; -+ }, -+ cell: ({ row }) => , -+ }, -+ { -+ accessorKey: "id", -+ header: "", -+ cell: ({ row }) => , -+ }, -+]; -+ -+const ZoneContext = createContext({} as DNSZone); -+ -+export default function DNSRecordsTable({ zone }: Props) { -+ const [sorting, setSorting] = useState([]); -+ -+ return ( -+ -+ -+ -+ ); -+} -+ -+export const useDNSZone = () => useContext(ZoneContext); -diff --git a/src/modules/dns/zones/table/DNSZonesActionCell.tsx b/src/modules/dns/zones/table/DNSZonesActionCell.tsx -new file mode 100644 -index 00000000..aa1a490d ---- /dev/null -+++ b/src/modules/dns/zones/table/DNSZonesActionCell.tsx -@@ -0,0 +1,58 @@ -+import Button from "@components/Button"; -+import { -+ DropdownMenu, -+ DropdownMenuContent, -+ DropdownMenuItem, -+ DropdownMenuTrigger, -+} from "@components/DropdownMenu"; -+import { MoreVertical, SquarePenIcon, Trash2 } from "lucide-react"; -+import * as React from "react"; -+import { usePermissions } from "@/contexts/PermissionsProvider"; -+import { DNSZone } from "@/interfaces/DNS"; -+import { useDNSZones } from "@/modules/dns/zones/DNSZonesProvider"; -+ -+type Props = { -+ zone: DNSZone; -+}; -+ -+export const DNSZonesActionCell = ({ zone }: Props) => { -+ const { permission } = usePermissions(); -+ const { openZoneModal, deleteZone } = useDNSZones(); -+ -+ return ( -+
-+ -+ { -+ e.stopPropagation(); -+ e.preventDefault(); -+ }} -+ > -+ -+ -+ -+ openZoneModal(zone)}> -+
-+ -+ Edit -+
-+
-+ -+ deleteZone(zone)} -+ variant={"danger"} -+ disabled={!permission?.dns?.delete} -+ > -+
-+ -+ Delete -+
-+
-+
-+
-+
-+ ); -+}; -diff --git a/src/modules/dns/zones/table/DNSZonesActiveCell.tsx b/src/modules/dns/zones/table/DNSZonesActiveCell.tsx -new file mode 100644 -index 00000000..35daa15e ---- /dev/null -+++ b/src/modules/dns/zones/table/DNSZonesActiveCell.tsx -@@ -0,0 +1,32 @@ -+import { ToggleSwitch } from "@components/ToggleSwitch"; -+import * as React from "react"; -+import { usePermissions } from "@/contexts/PermissionsProvider"; -+import { DNSZone } from "@/interfaces/DNS"; -+import { useDNSZones } from "@/modules/dns/zones/DNSZonesProvider"; -+ -+type Props = { -+ zone: DNSZone; -+}; -+ -+export const DNSZonesActiveCell = ({ zone }: Props) => { -+ const { permission } = usePermissions(); -+ const { updateZone } = useDNSZones(); -+ -+ return ( -+
-+ { -+ e.preventDefault(); -+ e.stopPropagation(); -+ updateZone({ -+ ...zone, -+ enabled: !zone.enabled, -+ }); -+ }} -+ /> -+
-+ ); -+}; -diff --git a/src/modules/dns/zones/table/DNSZonesGroupCell.tsx b/src/modules/dns/zones/table/DNSZonesGroupCell.tsx -new file mode 100644 -index 00000000..3b3fa487 ---- /dev/null -+++ b/src/modules/dns/zones/table/DNSZonesGroupCell.tsx -@@ -0,0 +1,60 @@ -+import * as React from "react"; -+import { useMemo, useState } from "react"; -+import { useGroups } from "@/contexts/GroupsProvider"; -+import { usePermissions } from "@/contexts/PermissionsProvider"; -+import { DNSZone } from "@/interfaces/DNS"; -+import { Group } from "@/interfaces/Group"; -+import EmptyRow from "@/modules/common-table-rows/EmptyRow"; -+import GroupsRow from "@/modules/common-table-rows/GroupsRow"; -+import { useDNSZones } from "@/modules/dns/zones/DNSZonesProvider"; -+ -+type Props = { -+ zone: DNSZone; -+}; -+ -+export const DNSZonesGroupCell = ({ zone }: Props) => { -+ const { groups } = useGroups(); -+ const { updateZone } = useDNSZones(); -+ const [modal, setModal] = useState(false); -+ const { permission } = usePermissions(); -+ -+ const allGroups = zone?.distribution_groups -+ .map((group) => { -+ return groups?.find((g) => g.id == group); -+ }) -+ .filter((g) => g != undefined) as Group[]; -+ -+ const groupIDs = useMemo(() => { -+ return allGroups -+ ?.map((group) => group.id) -+ .filter((id) => id !== undefined) as string[]; -+ }, [allGroups]); -+ -+ const handleSave = async (promises: Promise[]) => { -+ const groups = await Promise.all(promises); -+ const groupIds = groups?.map((g) => g.id as string); -+ await updateZone({ -+ ...zone, -+ distribution_groups: groupIds, -+ }).then(() => { -+ setModal(false); -+ }); -+ }; -+ -+ if (!zone?.distribution_groups) return ; -+ -+ return ( -+ -+ ); -+}; -diff --git a/src/modules/dns/zones/table/DNSZonesNameCell.tsx b/src/modules/dns/zones/table/DNSZonesNameCell.tsx -new file mode 100644 -index 00000000..d534c03d ---- /dev/null -+++ b/src/modules/dns/zones/table/DNSZonesNameCell.tsx -@@ -0,0 +1,38 @@ -+import { cn } from "@utils/helpers"; -+import { ChevronDown, ChevronRightIcon } from "lucide-react"; -+import * as React from "react"; -+import { DNSZone } from "@/interfaces/DNS"; -+import ActiveInactiveRow from "@/modules/common-table-rows/ActiveInactiveRow"; -+ -+type Props = { -+ zone: DNSZone; -+}; -+ -+export const DNSZonesNameCell = ({ zone }: Props) => { -+ const hasRecords = (zone?.records?.length ?? 0) > 0; -+ -+ return ( -+
-+ -+ -+ -+
-+ ); -+}; -diff --git a/src/modules/dns/zones/table/DNSZonesRecordsCell.tsx b/src/modules/dns/zones/table/DNSZonesRecordsCell.tsx -new file mode 100644 -index 00000000..2607f428 ---- /dev/null -+++ b/src/modules/dns/zones/table/DNSZonesRecordsCell.tsx -@@ -0,0 +1,47 @@ -+import Badge from "@components/Badge"; -+import Button from "@components/Button"; -+import { GlobeIcon, PlusCircle } from "lucide-react"; -+import * as React from "react"; -+import { usePermissions } from "@/contexts/PermissionsProvider"; -+import { DNSZone } from "@/interfaces/DNS"; -+import { useDNSZones } from "@/modules/dns/zones/DNSZonesProvider"; -+ -+type Props = { -+ zone: DNSZone; -+}; -+ -+export const DNSZonesRecordsCell = ({ zone }: Props) => { -+ const { permission } = usePermissions(); -+ const { openRecordModal } = useDNSZones(); -+ -+ const recordsCount = zone?.records?.length ?? 0; -+ -+ return ( -+
-+ {recordsCount > 0 && ( -+ void 0} -+ > -+ -+
-+ {recordsCount} -+
-+
-+ )} -+ -+ -+
-+ ); -+}; -diff --git a/src/modules/dns/zones/table/DNSZonesSearchDomainCell.tsx b/src/modules/dns/zones/table/DNSZonesSearchDomainCell.tsx -new file mode 100644 -index 00000000..2fd613d4 ---- /dev/null -+++ b/src/modules/dns/zones/table/DNSZonesSearchDomainCell.tsx -@@ -0,0 +1,32 @@ -+import { ToggleSwitch } from "@components/ToggleSwitch"; -+import * as React from "react"; -+import { usePermissions } from "@/contexts/PermissionsProvider"; -+import { DNSZone } from "@/interfaces/DNS"; -+import { useDNSZones } from "@/modules/dns/zones/DNSZonesProvider"; -+ -+type Props = { -+ zone: DNSZone; -+}; -+ -+export const DNSZonesSearchDomainCell = ({ zone }: Props) => { -+ const { permission } = usePermissions(); -+ const { updateZone } = useDNSZones(); -+ -+ return ( -+
-+ { -+ e.preventDefault(); -+ e.stopPropagation(); -+ updateZone({ -+ ...zone, -+ enable_search_domain: !zone.enable_search_domain, -+ }); -+ }} -+ /> -+
-+ ); -+}; -diff --git a/src/modules/dns/zones/table/DNSZonesTable.tsx b/src/modules/dns/zones/table/DNSZonesTable.tsx -new file mode 100644 -index 00000000..ff7e946f ---- /dev/null -+++ b/src/modules/dns/zones/table/DNSZonesTable.tsx -@@ -0,0 +1,303 @@ -+import Button from "@components/Button"; -+import ButtonGroup from "@components/ButtonGroup"; -+import Card from "@components/Card"; -+import InlineLink from "@components/InlineLink"; -+import SquareIcon from "@components/SquareIcon"; -+import { DataTable } from "@components/table/DataTable"; -+import DataTableHeader from "@components/table/DataTableHeader"; -+import DataTableRefreshButton from "@components/table/DataTableRefreshButton"; -+import { DataTableRowsPerPage } from "@components/table/DataTableRowsPerPage"; -+import GetStartedTest from "@components/ui/GetStartedTest"; -+import NoResults from "@components/ui/NoResults"; -+import { ColumnDef, SortingState } from "@tanstack/react-table"; -+import { ExternalLinkIcon, PlusCircle } from "lucide-react"; -+import { usePathname } from "next/navigation"; -+import React, { useMemo } from "react"; -+import { useSWRConfig } from "swr"; -+import { usePermissions } from "@/contexts/PermissionsProvider"; -+import { useLocalStorage } from "@/hooks/useLocalStorage"; -+import { DNS_ZONE_DOCS_LINK, DNSZone } from "@/interfaces/DNS"; -+import { useDNSZones } from "@/modules/dns/zones/DNSZonesProvider"; -+import DNSRecordsTable from "@/modules/dns/zones/records/DNSRecordsTable"; -+import { DNSZonesActionCell } from "@/modules/dns/zones/table/DNSZonesActionCell"; -+import { DNSZonesActiveCell } from "@/modules/dns/zones/table/DNSZonesActiveCell"; -+import { DNSZonesGroupCell } from "@/modules/dns/zones/table/DNSZonesGroupCell"; -+import { DNSZonesNameCell } from "@/modules/dns/zones/table/DNSZonesNameCell"; -+import { DNSZonesRecordsCell } from "@/modules/dns/zones/table/DNSZonesRecordsCell"; -+import { DNSZonesSearchDomainCell } from "@/modules/dns/zones/table/DNSZonesSearchDomainCell"; -+import { Group } from "@/interfaces/Group"; -+import DNSZoneIcon from "@/assets/icons/DNSZoneIcon"; -+import { useGroups } from "@/contexts/GroupsProvider"; -+ -+export const DNSZonesColumns: ColumnDef[] = [ -+ { -+ accessorKey: "domain", -+ header: ({ column }) => ( -+ Zone -+ ), -+ sortingFn: "text", -+ cell: ({ row }) => , -+ }, -+ { -+ accessorKey: "enabled", -+ header: ({ column }) => ( -+ Active -+ ), -+ cell: ({ row }) => , -+ }, -+ { -+ accessorKey: "records", -+ header: ({ column }) => ( -+ Records -+ ), -+ sortingFn: "text", -+ cell: ({ row }) => , -+ }, -+ { -+ accessorKey: "distribution_groups", -+ header: ({ column }) => ( -+ Distribution Groups -+ ), -+ cell: ({ row }) => , -+ }, -+ { -+ accessorKey: "enable_search_domain", -+ header: ({ column }) => ( -+ Search Domain -+ ), -+ cell: ({ row }) => , -+ }, -+ { -+ accessorKey: "id", -+ header: () => "", -+ cell: ({ row }) => , -+ }, -+ { -+ id: "searchString", -+ accessorFn: (row) => { -+ return [ -+ row?.groups_search, -+ row?.name, -+ row?.domain, -+ row?.records?.map((r) => r.name).join(""), -+ row?.records?.map((r) => r.content).join(""), -+ row?.records?.map((r) => r.type).join(""), -+ ]?.join(""); -+ }, -+ }, -+]; -+ -+type Props = { -+ isLoading: boolean; -+ data?: DNSZone[]; -+ headingTarget?: HTMLHeadingElement | null; -+ isGroupPage?: boolean; -+ distributionGroups?: Group[]; -+}; -+ -+export default function DNSZonesTable({ -+ data, -+ isLoading, -+ headingTarget, -+ isGroupPage = false, -+ distributionGroups, -+}: Props) { -+ const { mutate } = useSWRConfig(); -+ const path = usePathname(); -+ const { groups } = useGroups(); -+ -+ // Default sorting state of the table -+ const [sorting, setSorting] = useLocalStorage( -+ "netbird-table-sort" + path, -+ [ -+ { -+ id: "domain", -+ desc: true, -+ }, -+ { -+ id: "id", -+ desc: true, -+ }, -+ ], -+ !isGroupPage, -+ ); -+ -+ const zonesWithGroups = useMemo(() => { -+ return ( -+ data?.map((zone) => { -+ return { -+ ...zone, -+ groups_search: groups -+ ?.map((g) => -+ zone?.distribution_groups?.includes(g?.id ?? "") ? g.name : "", -+ ) -+ .join(""), -+ } as DNSZone; -+ }) ?? [] -+ ); -+ }, [data, groups]); -+ -+ return ( -+ { -+ const hasRecords = (zone?.records?.length ?? 0) > 0; -+ if (!hasRecords) return; -+ return ( -+ <> -+ -+
-+ -+ ); -+ }} -+ getStartedCard={ -+ isGroupPage ? ( -+ } -+ className={"py-4"} -+ contentClassName={"max-w-lg"} -+ title={"This group is not used within any zones yet"} -+ description={ -+ "Assign this group as a distribution group in your zones to see them listed here." -+ } -+ > -+
-+ -+
-+
-+ ) : ( -+ } -+ color={"gray"} -+ size={"large"} -+ /> -+ } -+ title={"Create New Zone"} -+ description={ -+ "It looks like you don't have any zones. Control domain name resolution for your network by adding a zone." -+ } -+ button={ -+
-+ -+
-+ } -+ learnMore={ -+ <> -+ Learn more about -+ -+ DNS Zones -+ -+ -+ -+ } -+ /> -+ ) -+ } -+ rightSide={() => ( -+ <> -+ {data && data?.length > 0 && ( -+
-+ -+
-+ )} -+ -+ )} -+ > -+ {(table) => ( -+ <> -+ -+ { -+ table.setPageIndex(0); -+ table.getColumn("enabled")?.setFilterValue(undefined); -+ }} -+ disabled={data?.length == 0} -+ variant={ -+ table.getColumn("enabled")?.getFilterValue() === undefined -+ ? "tertiary" -+ : "secondary" -+ } -+ > -+ All -+ -+ { -+ table.setPageIndex(0); -+ table.getColumn("enabled")?.setFilterValue(true); -+ }} -+ disabled={data?.length == 0} -+ variant={ -+ table.getColumn("enabled")?.getFilterValue() === true -+ ? "tertiary" -+ : "secondary" -+ } -+ > -+ Active -+ -+ { -+ table.setPageIndex(0); -+ table.getColumn("enabled")?.setFilterValue(false); -+ }} -+ disabled={data?.length == 0} -+ variant={ -+ table.getColumn("enabled")?.getFilterValue() === false -+ ? "tertiary" -+ : "secondary" -+ } -+ > -+ Inactive -+ -+ -+ -+ { -+ mutate("/dns/zones").then(); -+ mutate("/groups").then(); -+ }} -+ /> -+ -+ )} -+
-+ ); -+} -+ -+type AddZoneButtonProps = { -+ distributionGroups?: Group[]; -+}; -+ -+const AddZoneButton = ({ distributionGroups }: AddZoneButtonProps) => { -+ const { permission } = usePermissions(); -+ const { openZoneModal } = useDNSZones(); -+ -+ return ( -+ -+ ); -+}; -diff --git a/src/modules/groups/details/GroupDNSZonesSection.tsx b/src/modules/groups/details/GroupDNSZonesSection.tsx -new file mode 100644 -index 00000000..fecac435 ---- /dev/null -+++ b/src/modules/groups/details/GroupDNSZonesSection.tsx -@@ -0,0 +1,29 @@ -+import React from "react"; -+import { useGroupContext } from "@/contexts/GroupProvider"; -+import { DNSZone } from "@/interfaces/DNS"; -+import { DNSZonesProvider } from "@/modules/dns/zones/DNSZonesProvider"; -+import DNSZonesTable from "@/modules/dns/zones/table/DNSZonesTable"; -+import { GroupDetailsTableContainer } from "@/modules/groups/details/GroupDetailsTableContainer"; -+ -+export const GroupDNSZonesSection = ({ -+ zones, -+ isLoading = true, -+}: { -+ zones?: DNSZone[]; -+ isLoading?: boolean; -+}) => { -+ const { group } = useGroupContext(); -+ -+ return ( -+ -+ -+ -+ -+ -+ ); -+}; -diff --git a/src/modules/groups/details/GroupNameserversSection.tsx b/src/modules/groups/details/GroupNameserversSection.tsx -index f4bd7014..15a1fa85 100644 ---- a/src/modules/groups/details/GroupNameserversSection.tsx -+++ b/src/modules/groups/details/GroupNameserversSection.tsx -@@ -4,7 +4,7 @@ import { NameserverGroup } from "@/interfaces/Nameserver"; - import { GroupDetailsTableContainer } from "@/modules/groups/details/GroupDetailsTableContainer"; - - const NameserverGroupTable = lazy( -- () => import("@/modules/dns-nameservers/table/NameserverGroupTable"), -+ () => import("@/modules/dns/nameservers/table/NameserverGroupTable"), - ); - - type Props = { -diff --git a/src/modules/groups/details/useGroupDetails.ts b/src/modules/groups/details/useGroupDetails.ts -index d16ea66d..85e07777 100644 ---- a/src/modules/groups/details/useGroupDetails.ts -+++ b/src/modules/groups/details/useGroupDetails.ts -@@ -1,4 +1,5 @@ - import { useMemo } from "react"; -+import { DNSZone } from "@/interfaces/DNS"; - import { Group, GroupPeer, GroupResource } from "@/interfaces/Group"; - import { NameserverGroup } from "@/interfaces/Nameserver"; - import { -@@ -16,6 +17,7 @@ import useFetchApi from "@/utils/api"; - export interface GroupDetails extends Group { - policies: Policy[]; - nameservers: NameserverGroup[]; -+ zones?: DNSZone[]; - routes: Route[]; - setupKeys: SetupKey[]; - users: User[]; -@@ -31,6 +33,8 @@ export default function useGroupDetails(groupId: string) { - useFetchApi(`/policies`); - const { data: nameservers, isLoading: isNameserversLoading } = - useFetchApi(`/dns/nameservers`); -+ const { data: zones, isLoading: isZonesLoading } = -+ useFetchApi(`/dns/zones`); - const { data: routes, isLoading: isRoutesLoading } = - useFetchApi(`/routes`); - const { data: setupKeys, isLoading: isSetupKeysLoading } = -@@ -65,6 +69,12 @@ export default function useGroupDetails(groupId: string) { - return nameservers?.filter((ns) => ns.groups?.includes(groupId)) || []; - }, [nameservers, groupId]); - -+ const linkedZones = useMemo(() => { -+ return ( -+ zones?.filter((ns) => ns.distribution_groups?.includes(groupId)) || [] -+ ); -+ }, [zones, groupId]); -+ - const linkedRoutes = useMemo(() => { - return ( - routes?.filter((route) => { -@@ -117,6 +127,7 @@ export default function useGroupDetails(groupId: string) { - isGroupsLoading || - isPoliciesLoading || - isNameserversLoading || -+ isZonesLoading || - isRoutesLoading || - isSetupKeysLoading || - isUsersLoading || -@@ -130,6 +141,7 @@ export default function useGroupDetails(groupId: string) { - ...group, - policies: linkedPolicies, - nameservers: linkedNameservers, -+ zones: linkedZones, - routes: linkedRoutes, - setupKeys: linkedSetupKeys, - users: linkedUsers, -@@ -141,6 +153,7 @@ export default function useGroupDetails(groupId: string) { - group, - linkedPolicies, - linkedNameservers, -+ linkedZones, - linkedRoutes, - linkedSetupKeys, - linkedUsers, -diff --git a/src/modules/groups/table/GroupsTable.tsx b/src/modules/groups/table/GroupsTable.tsx -index e0894bfe..67addd2a 100644 ---- a/src/modules/groups/table/GroupsTable.tsx -+++ b/src/modules/groups/table/GroupsTable.tsx -@@ -3,6 +3,7 @@ import { DataTable } from "@components/table/DataTable"; - import DataTableHeader from "@components/table/DataTableHeader"; - import { DataTableRowsPerPage } from "@components/table/DataTableRowsPerPage"; - import { ColumnDef, SortingState } from "@tanstack/react-table"; -+import { removeAllSpaces } from "@utils/helpers"; - import { Layers3Icon } from "lucide-react"; - import { usePathname } from "next/navigation"; - import React from "react"; -@@ -19,7 +20,7 @@ import GroupsActionCell from "@/modules/groups/table/GroupsActionCell"; - import GroupsCountCell from "@/modules/groups/table/GroupsCountCell"; - import GroupsNameCell from "@/modules/groups/table/GroupsNameCell"; - import useGroupsUsage, { GroupUsage } from "@/modules/groups/useGroupsUsage"; --import { removeAllSpaces } from "@utils/helpers"; -+import DNSZoneIcon from "@/assets/icons/DNSZoneIcon"; - - export const GroupsTableColumns: ColumnDef[] = [ - { -@@ -178,6 +179,28 @@ export const GroupsTableColumns: ColumnDef[] = [ - /> - ), - }, -+ { -+ accessorKey: "zones_count", -+ header: ({ column }) => { -+ return ( -+ Zones
} -+ > -+ -+ -+ ); -+ }, -+ cell: ({ row }) => ( -+ } -+ groupName={row.original.name} -+ href={`/group?id=${row.original.id}&tab=zones`} -+ text={"Zone(s)"} -+ count={row.original.zones_count} -+ /> -+ ), -+ }, - { - accessorKey: "setup_keys_count", - header: ({ column }) => { -@@ -216,7 +239,8 @@ export const GroupsTableColumns: ColumnDef[] = [ - row.routes_count > 0 || - row.setup_keys_count > 0 || - row.users_count > 0 || -- row.resources_count > 0 -+ row.resources_count > 0 || -+ row.zones_count - ); - }, - }, -diff --git a/src/modules/groups/useGroupsUsage.tsx b/src/modules/groups/useGroupsUsage.tsx -index 22fef68d..1fb5e5bc 100644 ---- a/src/modules/groups/useGroupsUsage.tsx -+++ b/src/modules/groups/useGroupsUsage.tsx -@@ -1,5 +1,6 @@ - import useFetchApi from "@utils/api"; - import { useMemo } from "react"; -+import { DNSZone } from "@/interfaces/DNS"; - import { Group } from "@/interfaces/Group"; - import { NameserverGroup } from "@/interfaces/Nameserver"; - import { Policy } from "@/interfaces/Policy"; -@@ -11,6 +12,7 @@ export interface GroupUsage extends Group { - peers_count: number; - policies_count: number; - nameservers_count: number; -+ zones_count: number; - routes_count: number; - setup_keys_count: number; - users_count: number; -@@ -24,6 +26,8 @@ export default function useGroupsUsage() { - useFetchApi(`/policies`); // Policies - const { data: nameservers, isLoading: isNameserversLoading } = - useFetchApi(`/dns/nameservers`); // DNS -+ const { data: zones, isLoading: isZonesLoading } = -+ useFetchApi(`/dns/zones`); // DNS Zones - const { data: routes, isLoading: isRoutesLoading } = - useFetchApi(`/routes`); // Routes - const { data: setupKeys, isLoading: isSetupKeysLoading } = -@@ -57,6 +61,14 @@ export default function useGroupsUsage() { - .filter((u) => u !== undefined); - }, [nameservers, isNameserversLoading]); - -+ const zonesGroups = useMemo(() => { -+ if (isZonesLoading) return; -+ if (!zones) return []; -+ return zones -+ ?.map((zone) => zone.distribution_groups) -+ .filter((u) => u !== undefined); -+ }, [zones, isZonesLoading]); -+ - const setupKeysGroups = useMemo(() => { - if (isSetupKeysLoading) return; - if (!setupKeys) return []; -@@ -78,6 +90,7 @@ export default function useGroupsUsage() { - isGroupsLoading || - isPoliciesLoading || - isNameserversLoading || -+ isZonesLoading || - isRoutesLoading || - isSetupKeysLoading || - isUsersLoading -@@ -86,6 +99,7 @@ export default function useGroupsUsage() { - isGroupsLoading, - isPoliciesLoading, - isNameserversLoading, -+ isZonesLoading, - isRoutesLoading, - isSetupKeysLoading, - isUsersLoading, -@@ -104,6 +118,10 @@ export default function useGroupsUsage() { - return nameserver.includes(group.id as string); - }).length; - -+ const zonesCount = zonesGroups?.filter((zone) => { -+ return zone.includes(group.id as string); -+ }).length; -+ - const routeCount = ( - routes?.filter((route) => { - const groupId = group.id as string; -@@ -133,6 +151,7 @@ export default function useGroupsUsage() { - resources_count: group.resources_count, - policies_count: policyCount, - nameservers_count: nameserverCount, -+ zones_count: zonesCount, - routes_count: routeCount, - setup_keys_count: setupKeyCount, - users_count: userCount, -@@ -143,6 +162,7 @@ export default function useGroupsUsage() { - groups, - policiesGroups, - nameserversGroups, -+ zonesGroups, - routes, - isRoutesLoading, - setupKeysGroups, -diff --git a/src/modules/routes/RouteTable.tsx b/src/modules/routes/RouteTable.tsx -index 64e941c8..1d628b40 100644 ---- a/src/modules/routes/RouteTable.tsx -+++ b/src/modules/routes/RouteTable.tsx -@@ -114,7 +114,7 @@ export default function RouteTable({ row }: Props) { - desc: true, - }, - ]); -- -+ - const hasAtLeastOneExitNode = useMemo(() => { - return row.routes?.some((route) => route.network === "0.0.0.0/0"); - }, [row.routes]); -@@ -147,7 +147,7 @@ export default function RouteTable({ row }: Props) { - tableClassName={"mt-0"} - minimal={true} - showSearchAndFilters={false} -- className={"bg-neutral-900/50 py-2"} -+ className={"bg-nb-gray-960 py-2"} - inset={true} - text={"Network Routes"} - manualPagination={true} -diff --git a/tailwind.config.ts b/tailwind.config.ts -index a4321202..58e21f41 100644 ---- a/tailwind.config.ts -+++ b/tailwind.config.ts -@@ -29,8 +29,9 @@ const config: Config = { - "925": "#1e2123", - "930": "#25282c", - "935": "#1f2124", -- "940": "#1c1d21", -+ "940": "#1c1e21", - "950": "#181a1d", -+ "960": "#15171a", - }, - netbird: { - DEFAULT: "#f68330", diff --git a/src/components/skeletons/SkeletonSettings.tsx b/src/components/skeletons/SkeletonSettings.tsx new file mode 100644 index 00000000..8b531519 --- /dev/null +++ b/src/components/skeletons/SkeletonSettings.tsx @@ -0,0 +1,20 @@ +import * as React from "react"; +import Skeleton from "react-loading-skeleton"; + +export const SkeletonSettings = () => { + return ( +
+ + +
+ + +
+
+ + +
+ +
+ ); +}; diff --git a/src/modules/settings/ClientSettingsTab.tsx b/src/modules/settings/ClientSettingsTab.tsx index 18869473..53913006 100644 --- a/src/modules/settings/ClientSettingsTab.tsx +++ b/src/modules/settings/ClientSettingsTab.tsx @@ -14,13 +14,11 @@ import { import { useHasChanges } from "@hooks/useHasChanges"; import * as Tabs from "@radix-ui/react-tabs"; import { useApiCall } from "@utils/api"; -import { validator } from "@utils/helpers"; -import { AnimatePresence, motion } from "framer-motion"; +import { cn, validator } from "@utils/helpers"; import { ClockFadingIcon, ExternalLinkIcon, FlaskConicalIcon, - GlobeIcon, MonitorSmartphoneIcon, RefreshCcw, } from "lucide-react"; @@ -29,9 +27,11 @@ import { useSWRConfig } from "swr"; import SettingsIcon from "@/assets/icons/SettingsIcon"; import { usePermissions } from "@/contexts/PermissionsProvider"; import { Account } from "@/interfaces/Account"; -import { Group } from "@/interfaces/Group"; -import { useGroupIdsToGroups } from "@/modules/groups/useGroupIdsToGroups"; import { SmallBadge } from "@components/ui/SmallBadge"; +import ReverseProxyIcon from "@/assets/icons/ReverseProxyIcon"; +import useGroupHelper from "@/modules/groups/useGroupHelper"; +import { useGroups } from "@/contexts/GroupsProvider"; +import { SkeletonSettings } from "@components/skeletons/SkeletonSettings"; type Props = { account: Account; @@ -53,6 +53,16 @@ const latestOrCustomVersion = [ ] as SelectOption[]; export default function ClientSettingsTab({ account }: Readonly) { + const { isLoading: isGroupsLoading } = useGroups(); + + return isGroupsLoading ? ( + + ) : ( + + ); +} + +function ClientSettingsTabContent({ account }: Readonly) { const { permission } = usePermissions(); const { mutate } = useSWRConfig(); @@ -77,16 +87,12 @@ export default function ClientSettingsTab({ account }: Readonly) { const [peerExposeEnabled, setPeerExposeEnabled] = useState( account?.settings?.extra?.peer_expose_enabled ?? false, ); - - const initialGroups = useGroupIdsToGroups( - account?.settings?.extra?.peer_expose_groups, - ); - const [peerExposeGroups, setPeerExposeGroups] = useState( - initialGroups ?? [], - ); - - const peerExposeGroupIds = useMemo( - () => peerExposeGroups.map((g) => g.id).filter(Boolean) as string[], + const [peerExposeGroups, setPeerExposeGroups, { save: saveGroups }] = + useGroupHelper({ + initial: account.settings?.extra?.peer_expose_groups, + }); + const peerExposeGroupNames = useMemo( + () => peerExposeGroups.map((g) => g.name).sort(), [peerExposeGroups], ); @@ -94,24 +100,9 @@ export default function ClientSettingsTab({ account }: Readonly) { autoUpdateMethod, autoUpdateCustomVersion, peerExposeEnabled, - peerExposeGroupIds, + peerExposeGroupNames, ]); - React.useEffect(() => { - if (initialGroups) { - setPeerExposeGroups(initialGroups); - const groupIds = initialGroups - .map((g) => g.id) - .filter(Boolean) as string[]; - updateRef([ - autoUpdateMethod, - autoUpdateCustomVersion, - peerExposeEnabled, - groupIds, - ]); - } - }, [initialGroups]); - const handleUpdateMethodChange = (value: string) => { setAutoUpdateMethod(value); if (value === "disabled" || value === "latest") { @@ -138,7 +129,7 @@ export default function ClientSettingsTab({ account }: Readonly) { !hasChanges || !permission.settings.update || (autoUpdateMethod === "custom" && !canSaveCustomVersion) || - (peerExposeEnabled && peerExposeGroupIds.length === 0) + (peerExposeEnabled && peerExposeGroups.length === 0) ); }, [ hasChanges, @@ -146,10 +137,15 @@ export default function ClientSettingsTab({ account }: Readonly) { autoUpdateMethod, canSaveCustomVersion, peerExposeEnabled, - peerExposeGroupIds, + peerExposeGroups, ]); const saveChanges = async () => { + const groups = await saveGroups(); + const peerExposeGroupIds = groups + .map((group) => group.id) + .filter(Boolean) as string[]; + notify({ title: "Client Settings", description: `Client settings successfully updated.`, @@ -172,7 +168,7 @@ export default function ClientSettingsTab({ account }: Readonly) { autoUpdateMethod, autoUpdateCustomVersion, peerExposeEnabled, - peerExposeGroupIds, + peerExposeGroupNames, ]); }), loadingMessage: "Updating client settings...", @@ -203,7 +199,7 @@ export default function ClientSettingsTab({ account }: Readonly) { return ( -
+
) {
-
+
-
+
+
+ + + Allow peers to expose local services through the NetBird reverse + proxy using the CLI.
This requires at least NetBird{" "} + v0.66.0.{" "} + + Learn more + + +
+
+ + + +
+
+ + + Select which peer groups are allowed to expose services. At + least one group is required. + + +
+
+
+ +
- - - Enable Lazy Connections - - } - helpText={ - <> - Allow to establish connections between peers only when required. - This requires NetBird client v0.45 or higher. Changes will only - take effect after restarting the clients. - - } - disabled={!permission.settings.update} - /> - -
- - - Allow peers to expose local services through the NetBird reverse - proxy using the CLI.{" "} - - Learn more - - - -
- - - - Enable Peer Expose - - } - helpText={ - "When enabled, peers can expose local HTTP services accessible via a public URL." - } - disabled={!permission.settings.update} - /> - - - {peerExposeEnabled && ( -
- -
-
- - - Select which peer groups are allowed to expose - services. At least one group is required. - - -
-
-
-
- )} -
From 97be13f7b045c328ae85df4bcbd245c2aa54448b Mon Sep 17 00:00:00 2001 From: mlsmaycon Date: Mon, 23 Feb 2026 19:46:39 +0100 Subject: [PATCH 8/8] mv expose settings from extra settings --- src/interfaces/Account.ts | 4 ++-- src/modules/settings/ClientSettingsTab.tsx | 11 ++++------- 2 files changed, 6 insertions(+), 9 deletions(-) diff --git a/src/interfaces/Account.ts b/src/interfaces/Account.ts index 6379e4c2..70fe17f0 100644 --- a/src/interfaces/Account.ts +++ b/src/interfaces/Account.ts @@ -8,10 +8,10 @@ export interface Account { extra: { peer_approval_enabled: boolean; user_approval_required: boolean; - peer_expose_enabled?: boolean; - peer_expose_groups?: string[]; }; peer_login_expiration_enabled: boolean; + peer_expose_enabled?: boolean; + peer_expose_groups?: string[]; peer_login_expiration: number; peer_inactivity_expiration_enabled: boolean; peer_inactivity_expiration: number; diff --git a/src/modules/settings/ClientSettingsTab.tsx b/src/modules/settings/ClientSettingsTab.tsx index 53913006..f3851580 100644 --- a/src/modules/settings/ClientSettingsTab.tsx +++ b/src/modules/settings/ClientSettingsTab.tsx @@ -85,11 +85,11 @@ function ClientSettingsTabContent({ account }: Readonly) { ); const [peerExposeEnabled, setPeerExposeEnabled] = useState( - account?.settings?.extra?.peer_expose_enabled ?? false, + account?.settings?.peer_expose_enabled ?? false, ); const [peerExposeGroups, setPeerExposeGroups, { save: saveGroups }] = useGroupHelper({ - initial: account.settings?.extra?.peer_expose_groups, + initial: account.settings?.peer_expose_groups, }); const peerExposeGroupNames = useMemo( () => peerExposeGroups.map((g) => g.name).sort(), @@ -155,11 +155,8 @@ function ClientSettingsTabContent({ account }: Readonly) { settings: { ...account.settings, auto_update_version: autoUpdateCustomVersion || autoUpdateMethod, - extra: { - ...account.settings?.extra, - peer_expose_enabled: peerExposeEnabled, - peer_expose_groups: peerExposeGroupIds, - }, + peer_expose_enabled: peerExposeEnabled, + peer_expose_groups: peerExposeGroupIds, }, }) .then(() => {