diff --git a/config.json b/config.json
index b040858b..67198511 100644
--- a/config.json
+++ b/config.json
@@ -1,18 +1,10 @@
{
- "auth0Auth": "$USE_AUTH0",
- "authAuthority": "$AUTH_AUTHORITY",
- "authClientId": "$AUTH_CLIENT_ID",
- "authClientSecret": "$AUTH_CLIENT_SECRET",
- "authScopesSupported": "$AUTH_SUPPORTED_SCOPES",
- "authAudience": "$AUTH_AUDIENCE",
- "apiOrigin": "$NETBIRD_MGMT_API_ENDPOINT",
- "grpcApiOrigin": "$NETBIRD_MGMT_GRPC_API_ENDPOINT",
- "redirectURI": "$AUTH_REDIRECT_URI",
- "silentRedirectURI": "$AUTH_SILENT_REDIRECT_URI",
- "tokenSource": "$NETBIRD_TOKEN_SOURCE",
- "dragQueryParams": "$NETBIRD_DRAG_QUERY_PARAMS",
- "hotjarTrackID": "$NETBIRD_HOTJAR_TRACK_ID",
- "googleAnalyticsID": "$NETBIRD_GOOGLE_ANALYTICS_ID",
- "googleTagManagerID": "$NETBIRD_GOOGLE_TAG_MANAGER_ID",
- "wasmPath": "$NETBIRD_WASM_PATH"
-}
\ No newline at end of file
+ "auth0Auth": "true",
+ "authAuthority": "https://netbird-localdev.eu.auth0.com",
+ "authClientId": "kBRMAOqIZ7hvpVCaypQLCJvTzkYYIXVt",
+ "authScopesSupported": "openid profile email api offline_access email_verified",
+ "authAudience": "http://localhost:3000/",
+ "apiOrigin": "http://localhost",
+ "grpcApiOrigin": "http://localhost:80",
+ "latestVersion": "v0.6.3"
+}
diff --git a/package-lock.json b/package-lock.json
index 3cb2162e..91dfef30 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -56,6 +56,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",
@@ -6598,15 +6599,12 @@
}
},
"node_modules/ip-address": {
- "version": "7.1.0",
- "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-7.1.0.tgz",
- "integrity": "sha512-V9pWC/VJf2lsXqP7IWJ+pe3P1/HCYGBMZrrnT62niLGjAfCbeiwXMUxaeHvnVlz19O27pvXP4azs+Pj/A0x+SQ==",
- "dependencies": {
- "jsbn": "1.1.0",
- "sprintf-js": "1.1.2"
- },
+ "version": "10.1.0",
+ "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz",
+ "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==",
+ "license": "MIT",
"engines": {
- "node": ">= 10"
+ "node": ">= 12"
}
},
"node_modules/ip-cidr": {
@@ -6621,6 +6619,19 @@
"node": ">=10.0.0"
}
},
+ "node_modules/ip-cidr/node_modules/ip-address": {
+ "version": "7.1.0",
+ "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-7.1.0.tgz",
+ "integrity": "sha512-V9pWC/VJf2lsXqP7IWJ+pe3P1/HCYGBMZrrnT62niLGjAfCbeiwXMUxaeHvnVlz19O27pvXP4azs+Pj/A0x+SQ==",
+ "license": "MIT",
+ "dependencies": {
+ "jsbn": "1.1.0",
+ "sprintf-js": "1.1.2"
+ },
+ "engines": {
+ "node": ">= 10"
+ }
+ },
"node_modules/is-array-buffer": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz",
@@ -8561,7 +8572,8 @@
"node_modules/sprintf-js": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.2.tgz",
- "integrity": "sha512-VE0SOVEHCk7Qc8ulkWw3ntAzXuqf7S2lvwQaDLRnUeIEaKNQJzV6BwmLKhOqT61aGhfUMrXeaBk+oDGCzvhcug=="
+ "integrity": "sha512-VE0SOVEHCk7Qc8ulkWw3ntAzXuqf7S2lvwQaDLRnUeIEaKNQJzV6BwmLKhOqT61aGhfUMrXeaBk+oDGCzvhcug==",
+ "license": "BSD-3-Clause"
},
"node_modules/stable-hash": {
"version": "0.0.5",
diff --git a/package.json b/package.json
index 7fd4f6dd..3d9b3c34 100644
--- a/package.json
+++ b/package.json
@@ -61,6 +61,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" && (
{
/>
+
+
+
+
{
- let id = peer?.id ?? "";
- let expiration = peer?.login_expiration_enabled ? "1" : "0";
- return `${id}-${expiration}`;
- }, [peer]);
-
if (isRestricted) {
return (
@@ -106,7 +98,7 @@ export default function PeerPage() {
return peer && !isLoading ? (
-
+
) : (
@@ -142,12 +134,6 @@ const PeerGeneralInformation = () => {
const { peer, user, peerGroups, update } = usePeer();
const [name, setName] = useState(peer.name);
const [showEditNameModal, setShowEditNameModal] = useState(false);
- const [loginExpiration, setLoginExpiration] = useState(
- peer.login_expiration_enabled,
- );
- const [inactivityExpiration, setInactivityExpiration] = useState(
- peer.inactivity_expiration_enabled,
- );
const [selectedGroups, setSelectedGroups, { getAllGroupCalls }] =
useGroupHelper({
initial: peerGroups?.filter((g) => g?.name !== "All"),
@@ -159,8 +145,6 @@ const PeerGeneralInformation = () => {
*/
const { hasChanges, updateRef: updateHasChangedRef } = useHasChanges([
selectedGroups,
- loginExpiration,
- inactivityExpiration,
]);
const updatePeer = async (newName?: string) => {
@@ -170,8 +154,6 @@ const PeerGeneralInformation = () => {
if (permission.peers.update) {
const updateRequest = update({
name: newName ?? name,
- loginExpiration,
- inactivityExpiration,
});
batchCall = groupCalls ? [...groupCalls, updateRequest] : [updateRequest];
} else {
@@ -184,11 +166,7 @@ const PeerGeneralInformation = () => {
promise: Promise.all(batchCall).then(() => {
mutate("/peers/" + peer.id);
mutate("/groups");
- updateHasChangedRef([
- selectedGroups,
- loginExpiration,
- inactivityExpiration,
- ]);
+ updateHasChangedRef([selectedGroups]);
}),
loadingMessage: "Saving the peer...",
});
@@ -284,41 +262,7 @@ const PeerGeneralInformation = () => {
-
-
}
- onChange={(state) => {
- setLoginExpiration(state);
- !state && setInactivityExpiration(false);
- }}
- />
- {permission.peers.update && !!peer?.user_id && (
-
- )}
-
+
diff --git a/src/assets/icons/DNSZoneIcon.tsx b/src/assets/icons/DNSZoneIcon.tsx
new file mode 100644
index 00000000..b08b37b1
--- /dev/null
+++ b/src/assets/icons/DNSZoneIcon.tsx
@@ -0,0 +1,19 @@
+import { iconProperties, IconProps } from "@/assets/icons/IconProperties";
+
+export default function DNSZoneIcon(props: IconProps) {
+ return (
+
+
+
+ );
+}
diff --git a/src/assets/icons/SlackIcon.tsx b/src/assets/icons/SlackIcon.tsx
new file mode 100644
index 00000000..92abe6fe
--- /dev/null
+++ b/src/assets/icons/SlackIcon.tsx
@@ -0,0 +1,30 @@
+import { iconProperties, IconProps } from "@/assets/icons/IconProperties";
+
+export default function SlackIcon(props: Readonly
) {
+ return (
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/Button.tsx b/src/components/Button.tsx
index 3c1fde42..a3e0947c 100644
--- a/src/components/Button.tsx
+++ b/src/components/Button.tsx
@@ -76,6 +76,7 @@ export const buttonVariants = cva(
"default-outline": [
"dark:ring-offset-nb-gray-950/50 dark:focus:ring-nb-gray-500/20",
"dark:bg-transparent dark:text-nb-gray-400 dark:border-transparent dark:hover:text-white dark:hover:bg-nb-gray-900/30 dark:hover:border-nb-gray-800/50",
+ "data-[state=open]:dark:text-white data-[state=open]:dark:bg-nb-gray-900/30 data-[state=open]:dark:border-nb-gray-800/50",
],
danger: [
"", // TODO - add danger button styles for light mode
diff --git a/src/components/DropdownMenu.tsx b/src/components/DropdownMenu.tsx
index 529e6a1d..5fa0a820 100644
--- a/src/components/DropdownMenu.tsx
+++ b/src/components/DropdownMenu.tsx
@@ -93,25 +93,53 @@ const DropdownMenuItem = React.forwardRef<
React.ComponentPropsWithoutRef & {
inset?: boolean;
variant?: "default" | "danger";
+ href?: string;
+ target?: string;
+ rel?: string;
}
->(({ className, inset, variant = "default", onClick, ...props }, ref) => (
- (
+ (
+ {
className,
- )}
- onClick={(e) => {
- e.preventDefault();
- e.stopPropagation();
- onClick && onClick(e);
- }}
- {...props}
- />
-));
+ inset,
+ variant = "default",
+ onClick,
+ href,
+ target,
+ rel,
+ ...props
+ },
+ ref,
+ ) => {
+ return (
+ {
+ if (href) return;
+ e.preventDefault();
+ e.stopPropagation();
+ onClick && onClick(e);
+ }}
+ {...props}
+ >
+ {href ? (
+
+ {props.children}
+
+ ) : (
+ props.children
+ )}
+
+ );
+ },
+);
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
const DropdownMenuCheckboxItem = React.forwardRef<
diff --git a/src/components/Input.tsx b/src/components/Input.tsx
index 5309044a..7ed035a6 100644
--- a/src/components/Input.tsx
+++ b/src/components/Input.tsx
@@ -17,7 +17,7 @@ export interface InputProps
icon?: React.ReactNode;
error?: string;
errorTooltip?: boolean;
- errorTooltipPosition?: "top" | "top-right";
+ errorTooltipPosition?: "top" | "top-right" | "bottom";
prefixClassName?: string;
showPasswordToggle?: boolean;
}
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/HelpAndSupportButton.tsx b/src/components/ui/HelpAndSupportButton.tsx
new file mode 100644
index 00000000..f6426ee2
--- /dev/null
+++ b/src/components/ui/HelpAndSupportButton.tsx
@@ -0,0 +1,145 @@
+"use client";
+
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuLabel,
+ DropdownMenuSeparator,
+ DropdownMenuShortcut,
+ DropdownMenuTrigger,
+} from "@components/DropdownMenu";
+import {
+ ArrowUpRightIcon,
+ BookText,
+ CircleQuestionMark,
+ MailIcon,
+ MessageSquareShare,
+ MessagesSquareIcon,
+ TriangleAlert,
+} from "lucide-react";
+import { useState } from "react";
+import Button from "@components/Button";
+import { cn } from "@utils/helpers";
+import SlackIcon from "@/assets/icons/SlackIcon";
+import { isNetBirdHosted } from "@utils/netbird";
+
+export default function HelpAndSupportButton() {
+ const [dropdownOpen, setDropdownOpen] = useState(false);
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Documentation
+
+
+
+
+
+
+
+
+ Troubleshooting
+
+
+
+
+
+
+ {isNetBirdHosted() && (
+
+
+
+ support@netbird.io
+
+
+ )}
+
+
+
+
+
+
+ NetBird Forum
+
+
+
+
+
+
+
+
+ NetBird Slack
+
+
+
+
+
+
+
+
+
+
+
+ Feedback
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/ui/NoResults.tsx b/src/components/ui/NoResults.tsx
index 39466506..aa0a540e 100644
--- a/src/components/ui/NoResults.tsx
+++ b/src/components/ui/NoResults.tsx
@@ -14,7 +14,9 @@ type Props = {
className?: string;
hasFiltersApplied?: boolean;
onResetFilters?: () => void;
+ contentClassName?: string;
};
+
export default function NoResults({
icon,
title = "Could not find any results",
@@ -23,6 +25,7 @@ export default function NoResults({
className,
hasFiltersApplied = false,
onResetFilters,
+ contentClassName,
}: Props) {
const router = useRouter();
const pathname = usePathname();
@@ -65,7 +68,9 @@ export default function NoResults({
-
+
{
- 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/components/ui/UserAvatar.tsx b/src/components/ui/UserAvatar.tsx
index 58d61039..c2bb999f 100644
--- a/src/components/ui/UserAvatar.tsx
+++ b/src/components/ui/UserAvatar.tsx
@@ -1,7 +1,7 @@
import { cn, generateColorFromUser } from "@utils/helpers";
-import { Avatar } from "flowbite-react";
import * as React from "react";
import { useState } from "react";
+import Image from "next/image";
import { useApplicationContext } from "@/contexts/ApplicationProvider";
type Props = {
@@ -13,26 +13,27 @@ export const UserAvatar = ({ size = "default" }: Props) => {
const [pictureLoaded, setPictureLoaded] = useState(true);
const getAvatarSize = () => {
- if (size === "small") return "sm";
- if (size === "large") return "lg";
- return "md";
+ if (size === "small") return 32;
+ if (size === "default") return 40;
+ if (size === "large") return 48;
+ return 35.2;
};
- return pictureLoaded ? (
-
setPictureLoaded(false)}
- size={getAvatarSize()}
- className={"shrink-0"}
+ width={getAvatarSize()}
+ height={getAvatarSize()}
+ className={"rounded-full"}
/>
) : (
-
+
e.preventDefault()}
+ onPointerDownOutside={(e) => e.preventDefault()}
>
-
diff --git a/src/layouts/Navigation.tsx b/src/layouts/Navigation.tsx
index dabf6c90..38e1da74 100644
--- a/src/layouts/Navigation.tsx
+++ b/src/layouts/Navigation.tsx
@@ -143,6 +143,12 @@ export default function Navigation({
href={"/dns/nameservers"}
visible={permission.nameservers.read}
/>
+
[] = [
{
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 (
+
+ }
+ />
+
+
+
+
+ Record Type
+
+ Select the type of record you want to add
+
+
+
+ {
+ setType(v as DNSRecordType);
+ setRecordValue("");
+ }}
+ >
+
+
+
+
+ A
+ AAAA
+ CNAME
+
+
+
+
+
+
Hostname
+
+ Enter a subdomain or leave empty to use the primary domain.
+
+
+
setDomain(e.target.value)}
+ />
+
+ .{zone.domain}
+
+
+
+
+
+ {type === "A" && (
+
+ IPv4 Address
+ setRecordValue(e.target.value)}
+ />
+
+ )}
+
+ {type === "AAAA" && (
+
+ IPv6 Address
+ setRecordValue(e.target.value)}
+ />
+
+ )}
+
+ {type === "CNAME" && (
+
+ Target Domain
+ setRecordValue(e.target.value)}
+ />
+
+ )}
+
+
+
TTL (Time to Live)
+
+
setTtl(v)}>
+
+
+
+
+
+
+
+ {getTTLLabel(60)}
+ {getTTLLabel(120)}
+ {getTTLLabel(300)}
+ {getTTLLabel(600)}
+ {getTTLLabel(900)}
+ {getTTLLabel(1800)}
+ {getTTLLabel(3600)}
+ {getTTLLabel(7200)}
+ {getTTLLabel(43200)}
+ {getTTLLabel(86400)}
+
+
+
+
+
+
+
+
+
+
+ Learn more about
+
+ DNS Records
+
+
+
+
+
+
+ <>
+
+ Cancel
+
+
+ {record ? "Save Changes" : "Add Record"}
+
+ >
+
+
+
+ );
+}
+
+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"}
+ />
+
+
+
+
+
+ Domain
+
+ Enter a domain for this zone (e.g., company.internal,
+ intra.example.com)
+
+ setDomain(e.target.value)}
+ />
+
+
+
Distribution Groups
+
+ 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
+
+
+
+
+
+
+ Cancel
+
+
+ {zone ? "Save Changes" : "Add Zone"}
+
+
+
+
+ );
+}
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 (
+
+
openRecordModal(zone, record)}
+ disabled={!permission?.dns?.update}
+ >
+
+ Edit
+
+
deleteRecord(zone, record)}
+ disabled={!permission?.dns?.delete}
+ >
+
+ Delete
+
+
+ );
+};
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}
+
+
+ )}
+
+
openRecordModal(zone)}
+ disabled={!permission?.dns?.create}
+ >
+
+ Add Record
+
+
+ );
+};
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 (
+ openZoneModal(undefined, distributionGroups)}
+ >
+
+ Add Zone
+
+ );
+};
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 ad66e008..4f0bd9c9 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 ||
@@ -131,6 +142,7 @@ export default function useGroupDetails(groupId: string) {
...group,
policies: linkedPolicies,
nameservers: linkedNameservers,
+ zones: linkedZones,
routes: linkedRoutes,
setupKeys: linkedSetupKeys,
users: linkedUsers,
@@ -142,6 +154,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/peer/PeerExpirationSettings.tsx b/src/modules/peer/PeerExpirationSettings.tsx
new file mode 100644
index 00000000..878a0def
--- /dev/null
+++ b/src/modules/peer/PeerExpirationSettings.tsx
@@ -0,0 +1,105 @@
+import * as React from "react";
+import { useState } from "react";
+import { PeerExpirationToggle } from "@/modules/peer/PeerExpirationToggle";
+import { usePeer } from "@/contexts/PeerProvider";
+import { TimerResetIcon } from "lucide-react";
+import { usePermissions } from "@/contexts/PermissionsProvider";
+import { notify } from "@components/Notification";
+import { useSWRConfig } from "swr";
+import { cn } from "@utils/helpers";
+import { useAccount } from "@/modules/account/useAccount";
+
+export const PeerExpirationSettings = () => {
+ const { peer, update } = usePeer();
+ const { permission } = usePermissions();
+ const { mutate } = useSWRConfig();
+ const account = useAccount();
+
+ const [peerLoginExpiration, setPeerLoginExpiration] = useState(
+ peer.login_expiration_enabled,
+ );
+ const [peerInactivityExpiration, setPeerInactivityExpiration] = useState(
+ peer.inactivity_expiration_enabled,
+ );
+
+ const updateExpiration = async ({
+ loginExpiration,
+ inactivityExpiration,
+ }: {
+ loginExpiration?: boolean;
+ inactivityExpiration?: boolean;
+ }) => {
+ if (!permission?.peers.update) return;
+
+ const promise = update({
+ loginExpiration,
+ inactivityExpiration,
+ }).then(() => {
+ mutate("/peers/" + peer.id);
+ });
+
+ notify({
+ title: peer.name,
+ description: "Expiration was successfully updated",
+ promise,
+ loadingMessage: "Updating setting...",
+ });
+
+ return promise;
+ };
+
+ const isAccountInactivityExpirationDisabled =
+ account && account?.settings?.peer_inactivity_expiration_enabled === false;
+
+ return (
+
+
}
+ type={"login-expiration"}
+ onChange={async (state) => {
+ setPeerLoginExpiration(state);
+ !state && setPeerInactivityExpiration(false);
+
+ await updateExpiration({
+ loginExpiration: state,
+ inactivityExpiration: !state ? false : undefined,
+ });
+ }}
+ />
+ {permission?.peers.update && !!peer?.user_id && (
+
+
{
+ setPeerInactivityExpiration(state);
+ await updateExpiration({
+ inactivityExpiration: state,
+ });
+ }}
+ title={"Require login after disconnect"}
+ description={
+ "Enable to require authentication after users disconnect from management for 10 minutes."
+ }
+ className={
+ !peerLoginExpiration ? "opacity-40 pointer-events-none" : ""
+ }
+ />
+
+ )}
+
+ );
+};
diff --git a/src/modules/peer/PeerExpirationToggle.tsx b/src/modules/peer/PeerExpirationToggle.tsx
index 1ce9ba1a..2a209bd4 100644
--- a/src/modules/peer/PeerExpirationToggle.tsx
+++ b/src/modules/peer/PeerExpirationToggle.tsx
@@ -3,10 +3,13 @@ import FancyToggleSwitch, {
} from "@components/FancyToggleSwitch";
import FullTooltip from "@components/FullTooltip";
import { IconInfoCircle } from "@tabler/icons-react";
-import { LockIcon } from "lucide-react";
+import { ArrowUpRightIcon, LockIcon } from "lucide-react";
import * as React from "react";
+import { useMemo } from "react";
import { usePermissions } from "@/contexts/PermissionsProvider";
import { Peer } from "@/interfaces/Peer";
+import InlineLink from "@components/InlineLink";
+import { useAccount } from "@/modules/account/useAccount";
type Props = {
peer: Peer;
@@ -16,6 +19,7 @@ type Props = {
description?: string;
icon?: React.ReactNode;
className?: string;
+ type?: "login-expiration" | "inactivity-expiration";
} & FancyToggleSwitchVariants;
export const PeerExpirationToggle = ({
@@ -27,12 +31,26 @@ export const PeerExpirationToggle = ({
icon,
className,
variant = "default",
+ type = "login-expiration",
}: Props) => {
const { permission } = usePermissions();
+ const account = useAccount();
- return (
- {
+ if (noPermissionOrNoUser) {
+ return (
{!peer.user_id ? (
<>
@@ -50,14 +68,37 @@ export const PeerExpirationToggle = ({
>
)}
- }
+ );
+ }
+ if (isGlobalSettingDisabled) {
+ const text =
+ type === "login-expiration"
+ ? "'Peer Session Expiration'"
+ : "'Require login after disconnect'";
+ return (
+
+
+ Global setting {text} is currently disabled. Enable the global
+ setting to be able to toggle it individually per peer.{" "}
+
+ Go to Settings
+
+
+
+ );
+ }
+ }, [noPermissionOrNoUser, peer, type, isGlobalSettingDisabled]);
+
+ return (
+
{
const { permission } = usePermissions();
const { peer, toggleSSH, setSSHInstructionsModal } = usePeer();
- const { data: policies, isLoading } = useFetchApi("/policies");
+ const { data: policies } = useFetchApi(
+ "/policies",
+ true,
+ true,
+ permission?.policies.read,
+ );
const [tooltipOpen, setTooltipOpen] = useState(false);
const [policyModal, setPolicyModal] = useState(false);
const [sshPolicyModal, setSshPolicyModal] = useState(false);
@@ -201,7 +206,11 @@ export const PeerSSHToggle = () => {
{isSSHClientEnabled ? (
- setSshPolicyModal(true)}>
+ setSshPolicyModal(true)}
+ disabled={!permission?.policies.create}
+ >
Create SSH Policy
@@ -301,29 +310,31 @@ export const PeerSSHToggle = () => {
)}
-
- {
- setPolicyModal(state);
- setCurrentPolicy(undefined);
- }}
- >
- {
- setPolicyModal(false);
+ {permission?.policies.create && (
+
+ {
+ setPolicyModal(state);
setCurrentPolicy(undefined);
}}
+ >
+ {
+ setPolicyModal(false);
+ setCurrentPolicy(undefined);
+ }}
+ />
+
+
-
-
-
+
+ )}
);
};
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/src/modules/settings/AuthenticationTab.tsx b/src/modules/settings/AuthenticationTab.tsx
index 3b25c42d..40a37c15 100644
--- a/src/modules/settings/AuthenticationTab.tsx
+++ b/src/modules/settings/AuthenticationTab.tsx
@@ -84,9 +84,7 @@ export default function AuthenticationTab({ account }: Readonly) {
peerInactivityExpirationEnabled,
setPeerInactivityExpirationEnabled,
peerInactivityExpiresIn,
- setPeerInactivityExpiresIn,
peerInactivityExpireInterval,
- setPeerInactivityExpireInterval,
] = useExpirationState({
enabled: account.settings.peer_inactivity_expiration_enabled,
expirationInSeconds: account.settings.peer_inactivity_expiration || 600,
@@ -111,10 +109,6 @@ export default function AuthenticationTab({ account }: Readonly) {
const saveChanges = async () => {
const expiration = convertToSeconds(expiresIn, expireInterval);
- const peerInactivityExpiration = convertToSeconds(
- peerInactivityExpiresIn,
- peerInactivityExpireInterval,
- );
notify({
title: "Save Authentication Settings",
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",