Compare commits

...

5 Commits

Author SHA1 Message Date
Eduard Gert
8aec338c43 Fix dns doc link (#533)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2026-01-19 10:01:55 +01:00
Viktor Liu
f4f0c240fd Bump wasm to v0.63.0 (#531) 2026-01-19 09:49:26 +01:00
Viktor Liu
04e22a3c7e Enable SSH for Windows and Android peers (#532)
* Enable SSH for Windows and Android peers, hide update badge for temporary peers

* Fix RDP to use tcp protocol instead of netbird-ssh
2026-01-19 09:49:08 +01:00
Eduard Gert
54ef076303 Fix config vars (#529)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2026-01-16 19:59:42 +01:00
Eduard Gert
92676b6c38 Add DNS zones (#528)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2026-01-16 17:33:16 +01:00
65 changed files with 2351 additions and 176 deletions

View File

@@ -15,4 +15,4 @@
"googleAnalyticsID": "$NETBIRD_GOOGLE_ANALYTICS_ID",
"googleTagManagerID": "$NETBIRD_GOOGLE_TAG_MANAGER_ID",
"wasmPath": "$NETBIRD_WASM_PATH"
}
}

30
package-lock.json generated
View File

@@ -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",

View File

@@ -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",

View File

@@ -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={<ServerIcon size={13} />}
icon={<DNSIcon size={13} />}
/>
</Breadcrumbs>
<h1 ref={headingRef}>Nameservers</h1>

View File

@@ -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;

View File

@@ -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<DNSZone[]>("/dns/zones");
const { ref: headingRef, portalTarget } =
usePortalElement<HTMLHeadingElement>();
return (
<PageContainer>
<div className={"p-default py-6"}>
<Breadcrumbs>
<Breadcrumbs.Item label={"DNS"} icon={<DNSIcon size={13} />} />
<Breadcrumbs.Item
href={"/dns/zones"}
label={"Zones"}
active
icon={<DNSZoneIcon size={16} />}
/>
</Breadcrumbs>
<h1 ref={headingRef}>Zones</h1>
<Paragraph>
Manage DNS zones to control domain name resolution for your network.
</Paragraph>
<Paragraph>
Learn more about
<InlineLink href={DNS_ZONE_DOCS_LINK} target={"_blank"}>
DNS Zones
<ExternalLinkIcon size={12} />
</InlineLink>
in our documentation.
</Paragraph>
</div>
<RestrictedAccess page={"DNS Zones"} hasAccess={permission?.dns?.read}>
<Suspense fallback={<SkeletonTable />}>
<DNSZonesProvider>
<DNSZonesTable
isLoading={isLoading}
headingTarget={portalTarget}
data={zones}
/>
</DNSZonesProvider>
</Suspense>
</RestrictedAccess>
</PageContainer>
);
}

View File

@@ -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)}
</TabsTrigger>
<TabsTrigger
value={"zones"}
className={groupDetails === null ? "animate-pulse" : ""}
>
<DNSZoneIcon
size={16}
className={
"fill-nb-gray-500 group-data-[state=active]/trigger:fill-netbird transition-all"
}
/>
{singularize("Zones", zonesCount)}
</TabsTrigger>
{group.name !== "All" && (
<TabsTrigger
value={"setup-keys"}
@@ -304,6 +322,13 @@ const GroupOverviewTabs = ({ group }: { group: Group }) => {
/>
</TabsContent>
<TabsContent value={"zones"} className={"pb-8"}>
<GroupDNSZonesSection
zones={groupDetails?.zones}
isLoading={isLoading}
/>
</TabsContent>
<TabsContent value={"setup-keys"} className={"pb-8"}>
<GroupSetupKeysSection
setupKeys={groupDetails?.setupKeys}

View File

@@ -26,7 +26,6 @@ import { RestrictedAccess } from "@components/ui/RestrictedAccess";
import TextWithTooltip from "@components/ui/TextWithTooltip";
import useRedirect from "@hooks/useRedirect";
import useFetchApi from "@utils/api";
import { cn } from "@utils/helpers";
import dayjs from "dayjs";
import { isEmpty, trim } from "lodash";
import {
@@ -41,7 +40,6 @@ import {
MonitorSmartphoneIcon,
NetworkIcon,
PencilIcon,
TimerResetIcon,
} from "lucide-react";
import { useRouter, useSearchParams } from "next/navigation";
import { toASCII } from "punycode";
@@ -61,12 +59,12 @@ import type { Peer } from "@/interfaces/Peer";
import PageContainer from "@/layouts/PageContainer";
import useGroupHelper from "@/modules/groups/useGroupHelper";
import { AccessiblePeersSection } from "@/modules/peer/AccessiblePeersSection";
import { PeerExpirationToggle } from "@/modules/peer/PeerExpirationToggle";
import { PeerNetworkRoutesSection } from "@/modules/peer/PeerNetworkRoutesSection";
import { PeerSSHToggle } from "@/modules/peer/PeerSSHToggle";
import { RDPButton } from "@/modules/remote-access/rdp/RDPButton";
import { SSHButton } from "@/modules/remote-access/ssh/SSHButton";
import Link from "next/link";
import { PeerExpirationSettings } from "@/modules/peer/PeerExpirationSettings";
export default function PeerPage() {
const queryParameter = useSearchParams();
@@ -80,12 +78,6 @@ export default function PeerPage() {
useRedirect("/peers", false, !peerId || isRestricted);
const peerKey = useMemo(() => {
let id = peer?.id ?? "";
let expiration = peer?.login_expiration_enabled ? "1" : "0";
return `${id}-${expiration}`;
}, [peer]);
if (isRestricted) {
return (
<PageContainer>
@@ -106,7 +98,7 @@ export default function PeerPage() {
return peer && !isLoading ? (
<PeerProvider peer={peer} key={peerId} isPeerDetailPage={true}>
<PeerOverview key={peerKey} />
<PeerOverview key={peer?.id} />
</PeerProvider>
) : (
<FullScreenLoading />
@@ -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 = () => {
<PeerInformationCard peer={peer} />
<div className={"flex flex-col gap-6 lg:w-1/2 transition-all"}>
<div>
<PeerExpirationToggle
peer={peer}
value={loginExpiration}
icon={<TimerResetIcon size={16} />}
onChange={(state) => {
setLoginExpiration(state);
!state && setInactivityExpiration(false);
}}
/>
{permission.peers.update && !!peer?.user_id && (
<div
className={cn(
"border border-nb-gray-900 border-t-0 rounded-b-md bg-nb-gray-940 px-[1.28rem] pt-3 pb-5 flex flex-col gap-4 mx-[0.25rem]",
!loginExpiration
? "opacity-50 pointer-events-none"
: "bg-nb-gray-930/80",
)}
>
<PeerExpirationToggle
peer={peer}
variant={"blank"}
value={inactivityExpiration}
onChange={setInactivityExpiration}
title={"Require login after disconnect"}
description={
"Enable to require authentication after users disconnect from management for 10 minutes."
}
className={
!loginExpiration ? "opacity-40 pointer-events-none" : ""
}
/>
</div>
)}
</div>
<PeerExpirationSettings />
<PeerSSHToggle />

View File

@@ -20,7 +20,6 @@ import {
useNetBirdClient,
} from "@/modules/remote-access/useNetBirdClient";
import { cn } from "@utils/helpers";
import { isNetbirdSSHProtocolSupported } from "@utils/version";
export default function RDPPage() {
const { peerId } = useRDPQueryParams();
@@ -85,11 +84,8 @@ function RDPSession({ peer }: Props) {
try {
setCredentials(rdpCredentials);
setIsNetBirdConnecting(true);
const protocol = isNetbirdSSHProtocolSupported(peer.version)
? "netbird-ssh"
: "tcp";
await client.connectTemporary(peer.id, [
`${protocol}/${rdpCredentials.port}`,
`tcp/${rdpCredentials.port}`,
]);
setIsNetBirdConnecting(false);
} catch (error) {

View File

@@ -0,0 +1,19 @@
import { iconProperties, IconProps } from "@/assets/icons/IconProperties";
export default function DNSZoneIcon(props: IconProps) {
return (
<svg
width="24"
height="24"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
{...iconProperties(props)}
>
<path
fillRule="evenodd"
d="M5 5a2 2 0 0 0-2 2v3a1 1 0 0 0 1 1h16a1 1 0 0 0 1-1V7a2 2 0 0 0-2-2H5Zm9 2a1 1 0 1 0 0 2h.01a1 1 0 1 0 0-2H14Zm3 0a1 1 0 1 0 0 2h.01a1 1 0 1 0 0-2H17ZM3 17v-3a1 1 0 0 1 1-1h16a1 1 0 0 1 1 1v3a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Zm11-2a1 1 0 1 0 0 2h.01a1 1 0 1 0 0-2H14Zm3 0a1 1 0 1 0 0 2h.01a1 1 0 1 0 0-2H17Z"
clipRule="evenodd"
/>
</svg>
);
}

View File

@@ -0,0 +1,30 @@
import { iconProperties, IconProps } from "@/assets/icons/IconProperties";
export default function SlackIcon(props: Readonly<IconProps>) {
return (
<svg
width="127"
height="127"
viewBox="0 0 127 127"
xmlns="http://www.w3.org/2000/svg"
{...iconProperties(props)}
>
<path
d="M27.2 80c0 7.3-5.9 13.2-13.2 13.2C6.7 93.2.8 87.3.8 80c0-7.3 5.9-13.2 13.2-13.2h13.2V80zm6.6 0c0-7.3 5.9-13.2 13.2-13.2 7.3 0 13.2 5.9 13.2 13.2v33c0 7.3-5.9 13.2-13.2 13.2-7.3 0-13.2-5.9-13.2-13.2V80z"
fill="#E01E5A"
/>
<path
d="M47 27c-7.3 0-13.2-5.9-13.2-13.2C33.8 6.5 39.7.6 47 .6c7.3 0 13.2 5.9 13.2 13.2V27H47zm0 6.7c7.3 0 13.2 5.9 13.2 13.2 0 7.3-5.9 13.2-13.2 13.2H13.9C6.6 60.1.7 54.2.7 46.9c0-7.3 5.9-13.2 13.2-13.2H47z"
fill="#36C5F0"
/>
<path
d="M99.9 46.9c0-7.3 5.9-13.2 13.2-13.2 7.3 0 13.2 5.9 13.2 13.2 0 7.3-5.9 13.2-13.2 13.2H99.9V46.9zm-6.6 0c0 7.3-5.9 13.2-13.2 13.2-7.3 0-13.2-5.9-13.2-13.2V13.8C66.9 6.5 72.8.6 80.1.6c7.3 0 13.2 5.9 13.2 13.2v33.1z"
fill="#2EB67D"
/>
<path
d="M80.1 99.8c7.3 0 13.2 5.9 13.2 13.2 0 7.3-5.9 13.2-13.2 13.2-7.3 0-13.2-5.9-13.2-13.2V99.8h13.2zm0-6.6c-7.3 0-13.2-5.9-13.2-13.2 0-7.3 5.9-13.2 13.2-13.2h33.1c7.3 0 13.2 5.9 13.2 13.2 0 7.3-5.9 13.2-13.2 13.2H80.1z"
fill="#ECB22E"
/>
</svg>
);
}

View File

@@ -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

View File

@@ -93,25 +93,53 @@ const DropdownMenuItem = React.forwardRef<
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean;
variant?: "default" | "danger";
href?: string;
target?: string;
rel?: string;
}
>(({ className, inset, variant = "default", onClick, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex select-none items-center rounded-md pr-2 pl-3 py-1.5 text-sm outline-none",
"transition-colors focus:bg-gray-100 focus:text-gray-900 data-[disabled]:pointer-events-none data-[disabled]:opacity-50 cursor-pointer ",
inset && "pl-8",
menuItemVariants({ variant }),
>(
(
{
className,
)}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onClick && onClick(e);
}}
{...props}
/>
));
inset,
variant = "default",
onClick,
href,
target,
rel,
...props
},
ref,
) => {
return (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex select-none items-center rounded-md pr-2 pl-3 py-1.5 text-sm outline-none",
"transition-colors focus:bg-gray-100 focus:text-gray-900 data-[disabled]:pointer-events-none data-[disabled]:opacity-50 cursor-pointer ",
inset && "pl-8",
menuItemVariants({ variant }),
className,
)}
onClick={(e) => {
if (href) return;
e.preventDefault();
e.stopPropagation();
onClick && onClick(e);
}}
{...props}
>
{href ? (
<a href={href} target={target} rel={rel}>
{props.children}
</a>
) : (
props.children
)}
</DropdownMenuPrimitive.Item>
);
},
);
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
const DropdownMenuCheckboxItem = React.forwardRef<

View File

@@ -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;
}

View File

@@ -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,
)}

View File

@@ -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 (
<DropdownMenu
modal={false}
open={dropdownOpen}
onOpenChange={setDropdownOpen}
>
<DropdownMenuTrigger asChild={true}>
<Button
size={"xs"}
variant={"default-outline"}
className={cn(
"!rounded-full h-[38px] w-[38px] !p-0",
dropdownOpen && "text-white",
)}
>
<CircleQuestionMark size={18} />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-56" align="end" forceMount>
<DropdownMenuLabel className="font-normal">
<div className="flex flex-col space-y-1 px-1">
<div className="text-sm font-normal leading-none text-nb-gray-200 py-1">
Help and Support
</div>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem
href="https://docs.netbird.io/"
target="_blank"
rel="noopener noreferrer"
asChild
>
<div className={"flex gap-3 items-center"}>
<BookText size={14} />
Documentation
</div>
<DropdownMenuShortcut>
<ArrowUpRightIcon size={16} />
</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuItem
href="https://docs.netbird.io/help/troubleshooting-client"
target="_blank"
rel="noopener noreferrer"
asChild
>
<div className={"flex gap-3 items-center"}>
<TriangleAlert size={14} />
Troubleshooting
</div>
<DropdownMenuShortcut>
<ArrowUpRightIcon size={16} />
</DropdownMenuShortcut>
</DropdownMenuItem>
{isNetBirdHosted() && (
<DropdownMenuItem href="mailto:support@netbird.io?subject=Support Request">
<div className={"flex gap-3 items-center"}>
<MailIcon size={14} />
support@netbird.io
</div>
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem
href="https://forum.netbird.io/"
target="_blank"
rel="noopener noreferrer"
asChild
>
<div className={"flex gap-3 items-center"}>
<MessagesSquareIcon size={14} />
NetBird Forum
</div>
<DropdownMenuShortcut>
<ArrowUpRightIcon size={16} />
</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuItem
href="https://docs.netbird.io/slack-url"
target="_blank"
rel="noopener noreferrer"
asChild
>
<div className={"flex gap-3 items-center"}>
<SlackIcon size={14} />
NetBird Slack
</div>
<DropdownMenuShortcut>
<ArrowUpRightIcon size={16} />
</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
href={"https://forms.gle/TeLw2zrXEdw6RcQ36"}
target={"_blank"}
rel="noopener noreferrer"
asChild
>
<div className={"flex gap-3 items-center"}>
<MessageSquareShare size={14} />
Feedback
</div>
<DropdownMenuShortcut>
<ArrowUpRightIcon size={16} />
</DropdownMenuShortcut>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@@ -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({
<Skeleton className={"w-full"} height={70} duration={4} />
</div>
</div>
<div className={cn("max-w-md mx-auto relative z-20 py-6")}>
<div
className={cn("max-w-md mx-auto relative z-20 py-6", contentClassName)}
>
<div
className={
"mx-auto w-14 h-14 bg-nb-gray-930 flex items-center justify-center mb-3 rounded-md"

View File

@@ -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;

View File

@@ -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 ? (
<Avatar
alt=""
img={user?.picture}
rounded
return pictureLoaded && user?.picture ? (
<Image
src={user?.picture}
alt={""}
onError={() => setPictureLoaded(false)}
size={getAvatarSize()}
className={"shrink-0"}
width={getAvatarSize()}
height={getAvatarSize()}
className={"rounded-full"}
/>
) : (
<div
className={cn(
"rounded-full flex items-center justify-center bg-nb-gray-900 text-netbird uppercase",
size == "small" && "w-8 h-8",
size == "medium" && "w-[2.3rem] h-[2.3rem]",
size == "medium" && "w-[2.2rem] h-[2.2rem]",
size == "default" && "w-10 h-10",
size == "large" && "w-12 h-12",
)}

View File

@@ -41,7 +41,7 @@ export default function UserDropdown() {
</DropdownMenuTrigger>
<DropdownMenuContent className="w-56" align="end" forceMount>
<DropdownMenuLabel className="font-normal">
<div className="flex flex-col space-y-1">
<div className="flex flex-col space-y-0.5 px-1">
<div className="text-sm font-medium leading-none dark:text-gray-300">
<TextWithTooltip
text={user?.name}

View File

@@ -7,8 +7,8 @@ import { usePermissions } from "@/contexts/PermissionsProvider";
const initialAnnouncements: Announcement[] = [
{
tag: "New",
text: "NetBird v0.61 Released - Granular SSH Access Control and Automatic Updates",
link: "https://netbird.io/knowledge-hub/granular-ssh-access-automatic-updates",
text: "NetBird v0.62 Released - Local Users and Simplified IdP Integration",
link: "https://netbird.io/knowledge-hub/local-users-simplified-idp",
linkText: "Read Release Article",
variant: "important", // "default" or "important"
isExternal: true,

View File

@@ -66,6 +66,8 @@ export default function DialogProvider({ children }: Props) {
<ModalContent
maxWidthClass={dialogOptions.maxWidthClass || "max-w-[400px]"}
showClose={false}
onInteractOutside={(e) => e.preventDefault()}
onPointerDownOutside={(e) => e.preventDefault()}
>
<ModalHeader
center={dialogOptions.type == "center"}

25
src/interfaces/DNS.ts Normal file
View File

@@ -0,0 +1,25 @@
export interface DNSZone {
id?: string;
name: string;
domain: string;
enabled: boolean;
enable_search_domain: boolean;
distribution_groups: string[];
records?: DNSRecord[];
groups_search?: string;
}
export interface DNSRecord {
id?: string;
name: string;
type: "A" | "AAAA" | "CNAME";
content: string;
ttl: number;
}
export type DNSRecordType = "A" | "AAAA" | "CNAME";
export const DNS_ZONE_DOCS_LINK =
"https://docs.netbird.io/manage/dns/custom-zones";
export const DNS_RECORDS_DOCS_LINK =
"https://docs.netbird.io/manage/dns/custom-zones#adding-records-to-a-zone";

View File

@@ -11,8 +11,9 @@ import React from "react";
import { useAnnouncement } from "@/contexts/AnnouncementProvider";
import { useApplicationContext } from "@/contexts/ApplicationProvider";
import { usePermissions } from "@/contexts/PermissionsProvider";
import HelpAndSupportButton from "@components/ui/HelpAndSupportButton";
export const headerHeight = 75;
export const headerHeight = 65;
export default function NavbarWithDropdown() {
const router = useRouter();
@@ -31,7 +32,7 @@ export default function NavbarWithDropdown() {
<AnnouncementBanner />
<div
className={cn(
"bg-white px-2 py-4 dark:border-gray-700 dark:bg-nb-gray backdrop-blur-lg sm:px-6",
"bg-white px-2 py-3 dark:border-gray-700 dark:bg-nb-gray backdrop-blur-lg sm:px-6",
"border-b dark:border-zinc-700/40 px-3 md:px-4 w-full",
"flex justify-between items-center transition-all",
)}
@@ -62,7 +63,8 @@ export default function NavbarWithDropdown() {
<ToggleCollapsableNavigationButton />
</div>
<div className="flex md:order-2 gap-4 items-center">
<div className="flex md:order-2 gap-5 items-center">
<HelpAndSupportButton />
<UserDropdown />
</div>
</div>

View File

@@ -143,6 +143,12 @@ export default function Navigation({
href={"/dns/nameservers"}
visible={permission.nameservers.read}
/>
<SidebarItem
label="Zones"
isChild
href={"/dns/zones"}
visible={permission?.dns?.read}
/>
<SidebarItem
label="DNS Settings"
isChild

View File

@@ -9,7 +9,7 @@ import GoogleLogo from "@/assets/nameservers/google.svg";
import Quad9Logo from "@/assets/nameservers/quad9.svg";
import { Group } from "@/interfaces/Group";
import { NameserverGroup, NameserverPresets } from "@/interfaces/Nameserver";
import NameserverModal from "@/modules/dns-nameservers/NameserverModal";
import NameserverModal from "@/modules/dns/nameservers/NameserverModal";
type Props = {
children: React.ReactNode;

View File

@@ -19,14 +19,14 @@ import { usePermissions } from "@/contexts/PermissionsProvider";
import { useLocalStorage } from "@/hooks/useLocalStorage";
import { Group } from "@/interfaces/Group";
import { NameserverGroup } from "@/interfaces/Nameserver";
import NameserverModal from "@/modules/dns-nameservers/NameserverModal";
import NameserverTemplateModal from "@/modules/dns-nameservers/NameserverTemplateModal";
import NameserverActionCell from "@/modules/dns-nameservers/table/NameserverActionCell";
import NameserverActiveCell from "@/modules/dns-nameservers/table/NameserverActiveCell";
import NameserverDistributionGroupsCell from "@/modules/dns-nameservers/table/NameserverDistributionGroupsCell";
import NameserverMatchDomainsCell from "@/modules/dns-nameservers/table/NameserverMatchDomainsCell";
import NameserverNameCell from "@/modules/dns-nameservers/table/NameserverNameCell";
import NameserverNameserversCell from "@/modules/dns-nameservers/table/NameserverNameserversCell";
import NameserverModal from "@/modules/dns/nameservers/NameserverModal";
import NameserverTemplateModal from "@/modules/dns/nameservers/NameserverTemplateModal";
import NameserverActionCell from "@/modules/dns/nameservers/table/NameserverActionCell";
import NameserverActiveCell from "@/modules/dns/nameservers/table/NameserverActiveCell";
import NameserverDistributionGroupsCell from "@/modules/dns/nameservers/table/NameserverDistributionGroupsCell";
import NameserverMatchDomainsCell from "@/modules/dns/nameservers/table/NameserverMatchDomainsCell";
import NameserverNameCell from "@/modules/dns/nameservers/table/NameserverNameCell";
import NameserverNameserversCell from "@/modules/dns/nameservers/table/NameserverNameserversCell";
export const NameserverGroupTableColumns: ColumnDef<NameserverGroup>[] = [
{

View File

@@ -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<Props>) {
return (
<Modal open={open} onOpenChange={onOpenChange} key={open ? 1 : 0}>
{children && <ModalTrigger asChild>{children}</ModalTrigger>}
{open && (
<DNSRecordModalContent
onSuccess={() => onOpenChange(false)}
onSuccessAdded={() => {
setTimeout(() => {
const row = document.querySelector<HTMLElement>(
`[data-row-id="${zone.id}"]`,
);
if (row?.getAttribute("data-accordion") === "closed") {
row?.click();
}
row?.scrollIntoView({ behavior: "smooth" });
}, 200);
onOpenChange(false);
}}
zone={zone}
record={record}
/>
)}
</Modal>
);
}
type ModalProps = {
onSuccess?: () => void;
onSuccessAdded?: () => void;
zone: DNSZone;
record?: DNSRecord;
};
export function DNSRecordModalContent({
onSuccess,
onSuccessAdded,
zone,
record,
}: Readonly<ModalProps>) {
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<DNSRecordType>(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 (
<ModalContent maxWidthClass={"max-w-xl"}>
<ModalHeader
title={record ? "Update DNS Record" : "Add DNS Record"}
description={
record
? `Update record of '${zone.domain}' zone`
: `Add new record to the '${zone.domain}' zone`
}
icon={<GlobeIcon size={16} />}
/>
<Separator />
<div className={"px-8 py-6 flex flex-col gap-6"}>
<div className={"flex items-center justify-between gap-10"}>
<div>
<Label>Record Type</Label>
<HelpText className={"max-w-sm"}>
Select the type of record you want to add
</HelpText>
</div>
<div className={"min-w-[130px]"}>
<Select
value={type}
onValueChange={(v) => {
setType(v as DNSRecordType);
setRecordValue("");
}}
>
<SelectTrigger
className="w-full pl-4"
data-cy={"dns-record-type-select"}
>
<SelectValue placeholder="Select type..." />
</SelectTrigger>
<SelectContent>
<SelectItem value="A">A</SelectItem>
<SelectItem value="AAAA">AAAA</SelectItem>
<SelectItem value="CNAME">CNAME</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className={"w-full mb-3"}>
<Label>Hostname</Label>
<HelpText>
Enter a subdomain or leave empty to use the primary domain.
</HelpText>
<div className={"flex w-full"}>
<Input
autoFocus={true}
placeholder={"Subdomain (leave empty for primary domain)"}
errorTooltip={true}
errorTooltipPosition={"bottom"}
error={domainError}
value={domain}
className={"rounded-r-none"}
maxWidthClass={"w-full"}
onChange={(e) => setDomain(e.target.value)}
/>
<div
className={
"bg-nb-gray-900 rounded-r-md border text-nb-gray-300 border-l-0 text-sm border-nb-gray-700 flex items-center justify-center whitespace-nowrap px-4 opacity-80"
}
>
.{zone.domain}
</div>
</div>
</div>
<div className={"flex gap-4 items-start mb-3"}>
{type === "A" && (
<div className={"flex-1"}>
<Label>IPv4 Address</Label>
<Input
className={"mt-1.5 font-mono text-[0.82rem]"}
placeholder={"192.168.1.1"}
errorTooltip={false}
errorTooltipPosition={"top"}
error={ipv4Error}
value={recordValue}
maxWidthClass={"w-full"}
onChange={(e) => setRecordValue(e.target.value)}
/>
</div>
)}
{type === "AAAA" && (
<div className={"flex-1"}>
<Label>IPv6 Address</Label>
<Input
className={"mt-1.5 font-mono text-[0.82rem]"}
placeholder={"2001:0db8:85a3::8a2e:0370:7334"}
errorTooltip={false}
errorTooltipPosition={"top"}
error={ipv6Error}
value={recordValue}
maxWidthClass={"w-full"}
onChange={(e) => setRecordValue(e.target.value)}
/>
</div>
)}
{type === "CNAME" && (
<div className={"flex-1"}>
<Label>Target Domain</Label>
<Input
className={"mt-1.5"}
placeholder={"e.g., example.com or intra.example.com"}
errorTooltip={false}
errorTooltipPosition={"top"}
error={cnameError}
value={recordValue}
maxWidthClass={"w-full"}
onChange={(e) => setRecordValue(e.target.value)}
/>
</div>
)}
<div className={"min-w-[200px]"}>
<Label>TTL (Time to Live)</Label>
<div className={"mt-2.5"}>
<Select value={ttl} onValueChange={(v) => setTtl(v)}>
<SelectTrigger
className="w-full"
data-cy={"dns-record-ttl-select"}
>
<div className={"flex items-center gap-2"}>
<ClockIcon size={14} className={"text-nb-gray-300"} />
<SelectValue placeholder="Select TTL..." />
</div>
</SelectTrigger>
<SelectContent>
<SelectItem value="60">{getTTLLabel(60)}</SelectItem>
<SelectItem value="120">{getTTLLabel(120)}</SelectItem>
<SelectItem value="300">{getTTLLabel(300)}</SelectItem>
<SelectItem value="600">{getTTLLabel(600)}</SelectItem>
<SelectItem value="900">{getTTLLabel(900)}</SelectItem>
<SelectItem value="1800">{getTTLLabel(1800)}</SelectItem>
<SelectItem value="3600">{getTTLLabel(3600)}</SelectItem>
<SelectItem value="7200">{getTTLLabel(7200)}</SelectItem>
<SelectItem value="43200">{getTTLLabel(43200)}</SelectItem>
<SelectItem value="86400">{getTTLLabel(86400)}</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</div>
</div>
<ModalFooter className={"items-center"}>
<div className={"w-full"}>
<Paragraph className={"text-sm mt-auto"}>
Learn more about
<InlineLink href={DNS_RECORDS_DOCS_LINK} target={"_blank"}>
DNS Records
<ExternalLinkIcon size={12} />
</InlineLink>
</Paragraph>
</div>
<div className={"flex gap-3 w-full justify-end"}>
<>
<ModalClose asChild={true}>
<Button variant={"secondary"}>Cancel</Button>
</ModalClose>
<Button
variant={"primary"}
onClick={handleAddRecord}
disabled={!canUpdateOrCreate}
>
{record ? "Save Changes" : "Add Record"}
</Button>
</>
</div>
</ModalFooter>
</ModalContent>
);
}
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`;
};

View File

@@ -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<Props>) {
return (
<Modal open={open} onOpenChange={onOpenChange} key={open ? 1 : 0}>
{children && <ModalTrigger asChild>{children}</ModalTrigger>}
{open && (
<DNSZoneModalContent
onSuccess={(z) => {
onOpenChange(false);
onSuccess?.(z);
}}
onSuccessAdded={(z) => {
onOpenChange(false);
onSuccessAdded?.(z);
}}
zone={zone}
initialDistributionGroups={initialDistributionGroups}
/>
)}
</Modal>
);
}
type ModalProps = {
onSuccess?: (zone: DNSZone) => void;
onSuccessAdded?: (zone: DNSZone) => void;
initialDistributionGroups?: Group[];
zone?: DNSZone;
};
export function DNSZoneModalContent({
onSuccess,
onSuccessAdded,
zone,
initialDistributionGroups,
}: Readonly<ModalProps>) {
const { createZone, updateZone } = useDNSZones();
const [domain, setDomain] = useState(zone?.domain ?? "");
const [enabled, setEnabled] = useState<boolean>(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 (
<ModalContent maxWidthClass={"max-w-2xl"}>
<ModalHeader
icon={<DNSZoneIcon size={20} className={"fill-netbird"} />}
title={zone ? "Update DNS Zone" : "Add DNS Zone"}
description={
"Use a zone to control domain name resolution for your network."
}
color={"netbird"}
/>
<Separator />
<div className={"px-8 pt-6 pb-7 flex-col flex gap-6"}>
<div>
<Label>Domain</Label>
<HelpText>
Enter a domain for this zone (e.g., company.internal,
intra.example.com)
</HelpText>
<Input
disabled={!!zone}
readOnly={!!zone}
placeholder={"e.g., company.internal"}
errorTooltip={false}
errorTooltipPosition={"top"}
error={domainError}
value={domain}
onChange={(e) => setDomain(e.target.value)}
/>
</div>
<div className={"mb-2"}>
<Label>Distribution Groups</Label>
<HelpText>
Advertise this zone and its records to peers that belong to the
following groups
</HelpText>
<PeerGroupSelector
onChange={setGroups}
values={groups}
showResources={false}
showResourceCounter={false}
/>
</div>
<FancyToggleSwitch
value={searchDomainsEnabled}
onChange={setSearchDomainsEnabled}
label={
<>
<ScanSearch size={15} />
Enable Search Domains
</>
}
helpText={
"E.g., 'server.company.internal' will be accessible with 'server'"
}
/>
<FancyToggleSwitch
value={enabled}
onChange={setEnabled}
label={
<>
<Power size={15} />
Enable DNS Zone
</>
}
helpText={"Use this switch to enable or disable the dns zone."}
/>
</div>
<ModalFooter className={"items-center"}>
<div className={"w-full"}>
<Paragraph className={"text-sm mt-auto"}>
Learn more about
<InlineLink href={DNS_ZONE_DOCS_LINK} target={"_blank"}>
DNS Zones
<ExternalLinkIcon size={12} />
</InlineLink>
</Paragraph>
</div>
<div className={"flex gap-3 w-full justify-end"}>
<ModalClose asChild={true}>
<Button variant={"secondary"}>Cancel</Button>
</ModalClose>
<Button
variant={"primary"}
onClick={handleOnSubmit}
disabled={!canUpdateOrCreate}
>
{zone ? "Save Changes" : "Add Zone"}
</Button>
</div>
</ModalFooter>
</ModalContent>
);
}

View File

@@ -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<DNSZone>;
updateZone: (zone: DNSZone) => Promise<DNSZone>;
deleteZone: (zone: DNSZone) => Promise<DNSZone>;
openZoneModal: (
zone?: DNSZone,
initialDistributionGroups?: Group[],
) => void;
openRecordModal: (zone: DNSZone, record?: DNSRecord) => void;
addRecord: (zone: DNSZone, record: DNSRecord) => Promise<DNSRecord>;
updateRecord: (zone: DNSZone, record: DNSRecord) => Promise<DNSRecord>;
deleteRecord: (zone: DNSZone, record: DNSRecord) => Promise<DNSRecord>;
askForRecord: (zone: DNSZone) => void;
},
);
export const DNSZonesProvider = ({ children }: Props) => {
const { mutate } = useSWRConfig();
const zoneRequest = useApiCall<DNSZone>("/dns/zones", true);
const recordRequest = useApiCall<DNSRecord>("/dns/zones", true);
const [dnsModal, setDnsModal] = useState(false);
const [recordModal, setRecordModal] = useState(false);
const [currentZone, setCurrentZone] = useState<DNSZone>();
const [currentRecord, setCurrentRecord] = useState<DNSRecord>();
const [initialDistributionGroups, setInitialDistributionGroups] =
useState<Group[]>();
const { confirm } = useDialog();
const createZone = async (zone: DNSZone): Promise<DNSZone> => {
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<DNSZone> => {
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<DNSZone> => {
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<DNSRecord> => {
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<DNSRecord> => {
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<DNSRecord> => {
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 (
<DNSZonesContext.Provider
value={{
createZone,
updateZone,
deleteZone,
openZoneModal,
openRecordModal,
addRecord,
updateRecord,
deleteRecord,
askForRecord,
}}
>
{children}
<DNSZoneModal
open={dnsModal}
onOpenChange={(open) => {
setDnsModal(open);
if (!open) {
setCurrentZone(undefined);
setInitialDistributionGroups(undefined);
}
}}
onSuccessAdded={(z) => askForRecord(z)}
zone={currentZone}
initialDistributionGroups={initialDistributionGroups}
/>
{currentZone && (
<DNSRecordModal
open={recordModal}
onOpenChange={(open) => {
setRecordModal(open);
if (!open) {
setCurrentZone(undefined);
setCurrentRecord(undefined);
}
}}
zone={currentZone}
record={currentRecord}
/>
)}
</DNSZonesContext.Provider>
);
};
export const useDNSZones = () => React.useContext(DNSZonesContext);

View File

@@ -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 (
<div className={"flex justify-end pr-4"}>
<Button
variant={"default-outline"}
size={"sm"}
onClick={() => openRecordModal(zone, record)}
disabled={!permission?.dns?.update}
>
<PenSquare size={16} />
Edit
</Button>
<Button
variant={"danger-outline"}
size={"sm"}
onClick={() => deleteRecord(zone, record)}
disabled={!permission?.dns?.delete}
>
<Trash2 size={16} />
Delete
</Button>
</div>
);
};

View File

@@ -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 (
<div className="flex flex-col gap-0 dark:text-neutral-300 text-neutral-500 font-light truncate font-mono">
<CopyToClipboardText>
<span className={"font-normal truncate text-[0.82rem]"}>
{record.content}
</span>
</CopyToClipboardText>
</div>
);
};

View File

@@ -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 (
<div className="flex flex-col gap-0 dark:text-neutral-300 text-neutral-500 font-light truncate">
<CopyToClipboardText>
<span className={"font-normal truncate"}>{record.name}</span>
</CopyToClipboardText>
</div>
);
};

View File

@@ -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 (
<div
className={
"flex items-center whitespace-nowrap gap-2 dark:text-neutral-300 text-neutral-500 hover:text-neutral-100 transition-all py-2 px-3 rounded-md"
}
>
<ClockIcon size={14} />
{getTTLLabel(record.ttl)}
</div>
);
};

View File

@@ -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 (
<div className={"flex"}>
<Badge
variant={"gray"}
className={"uppercase tracking-wider font-medium"}
>
{record.type}
</Badge>
</div>
);
};

View File

@@ -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<DNSRecord>[] = [
{
accessorKey: "type",
header: ({ column }) => {
return <DataTableHeader column={column}>Type</DataTableHeader>;
},
cell: ({ row }) => <DNSRecordTypeCell record={row.original} />,
},
{
accessorKey: "name",
header: ({ column }) => {
return <DataTableHeader column={column}>Hostname</DataTableHeader>;
},
cell: ({ row }) => <DNSRecordNameCell record={row.original} />,
},
{
accessorKey: "content",
header: ({ column }) => {
return <DataTableHeader column={column}>Content</DataTableHeader>;
},
cell: ({ row }) => <DNSRecordContentCell record={row.original} />,
},
{
accessorKey: "ttl",
header: ({ column }) => {
return <DataTableHeader column={column}>TTL</DataTableHeader>;
},
cell: ({ row }) => <DNSRecordTimeToLiveCell record={row.original} />,
},
{
accessorKey: "id",
header: "",
cell: ({ row }) => <DNSRecordActionCell record={row.original} />,
},
];
const ZoneContext = createContext({} as DNSZone);
export default function DNSRecordsTable({ zone }: Props) {
const [sorting, setSorting] = useState<SortingState>([]);
return (
<ZoneContext.Provider value={zone}>
<DataTable
uniqueKey={zone.id}
keepStateInLocalStorage={false}
tableClassName={"mt-0"}
minimal={true}
showSearchAndFilters={false}
rowClassName={"last:pb-10"}
className={"bg-nb-gray-960 py-2"}
inset={true}
text={"DNS Records"}
manualPagination={true}
sorting={sorting}
columnVisibility={{}}
setSorting={setSorting}
columns={DNSRecordsTableColumns}
data={zone.records}
/>
</ZoneContext.Provider>
);
}
export const useDNSZone = () => useContext(ZoneContext);

View File

@@ -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 (
<div className={"flex justify-end pr-4"}>
<DropdownMenu modal={false}>
<DropdownMenuTrigger
asChild={true}
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
}}
>
<Button variant={"secondary"} className={"!px-3"}>
<MoreVertical size={16} className={"shrink-0"} />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-auto" align="end">
<DropdownMenuItem onClick={() => openZoneModal(zone)}>
<div className={"flex gap-3 items-center"}>
<SquarePenIcon size={14} className={"shrink-0"} />
Edit
</div>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => deleteZone(zone)}
variant={"danger"}
disabled={!permission?.dns?.delete}
>
<div className={"flex gap-3 items-center"}>
<Trash2 size={14} className={"shrink-0"} />
Delete
</div>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
};

View File

@@ -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 (
<div className={"flex min-w-[0px]"}>
<ToggleSwitch
disabled={!permission?.dns?.update}
checked={zone.enabled}
size={"small"}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
updateZone({
...zone,
enabled: !zone.enabled,
});
}}
/>
</div>
);
};

View File

@@ -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<Group>[]) => {
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 <EmptyRow />;
return (
<GroupsRow
label={"Distribution Groups"}
description={
"Advertise this zone to peers that belong to the following groups"
}
groups={groupIDs || []}
hideAllGroup={false}
disabled={!permission?.dns?.update}
onSave={handleSave}
modal={modal}
setModal={setModal}
/>
);
};

View File

@@ -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 (
<div className={"flex gap-6 items-center min-w-[270px] max-w-[270px]"}>
<ChevronRightIcon
size={20}
className={cn(
"group-data-[accordion=opened]/accordion:hidden text-nb-gray-400 shrink-0",
!hasRecords && "cursor-default opacity-0",
)}
/>
<ChevronDown
size={20}
className={cn(
"group-data-[accordion=closed]/accordion:hidden text-nb-gray-400 shrink-0",
!hasRecords && "cursor-default opacity-0",
)}
/>
<ActiveInactiveRow
active={zone.enabled}
inactiveDot={"gray"}
text={zone.domain}
dataCy={zone.id}
/>
</div>
);
};

View File

@@ -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 (
<div className={"flex gap-3"}>
{recordsCount > 0 && (
<Badge
variant={"gray"}
useHover={true}
className={"cursor-pointer"}
onClick={() => void 0}
>
<GlobeIcon size={12} />
<div>
<span className={"font-medium text-xs"}>{recordsCount}</span>
</div>
</Badge>
)}
<Button
size={"xs"}
variant={"secondary"}
className={"min-w-[130px]"}
onClick={() => openRecordModal(zone)}
disabled={!permission?.dns?.create}
>
<PlusCircle size={12} />
Add Record
</Button>
</div>
);
};

View File

@@ -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 (
<div className={"flex min-w-[0px]"}>
<ToggleSwitch
disabled={!permission?.dns?.update}
checked={zone?.enable_search_domain}
size={"small"}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
updateZone({
...zone,
enable_search_domain: !zone.enable_search_domain,
});
}}
/>
</div>
);
};

View File

@@ -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<DNSZone>[] = [
{
accessorKey: "domain",
header: ({ column }) => (
<DataTableHeader column={column}>Zone</DataTableHeader>
),
sortingFn: "text",
cell: ({ row }) => <DNSZonesNameCell zone={row.original} />,
},
{
accessorKey: "enabled",
header: ({ column }) => (
<DataTableHeader column={column}>Active</DataTableHeader>
),
cell: ({ row }) => <DNSZonesActiveCell zone={row.original} />,
},
{
accessorKey: "records",
header: ({ column }) => (
<DataTableHeader column={column}>Records</DataTableHeader>
),
sortingFn: "text",
cell: ({ row }) => <DNSZonesRecordsCell zone={row.original} />,
},
{
accessorKey: "distribution_groups",
header: ({ column }) => (
<DataTableHeader column={column}>Distribution Groups</DataTableHeader>
),
cell: ({ row }) => <DNSZonesGroupCell zone={row.original} />,
},
{
accessorKey: "enable_search_domain",
header: ({ column }) => (
<DataTableHeader column={column}>Search Domain</DataTableHeader>
),
cell: ({ row }) => <DNSZonesSearchDomainCell zone={row.original} />,
},
{
accessorKey: "id",
header: () => "",
cell: ({ row }) => <DNSZonesActionCell zone={row.original} />,
},
{
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<SortingState>(
"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 (
<DataTable
headingTarget={headingTarget}
isLoading={isLoading}
text={"DNS Zones"}
sorting={sorting}
setSorting={setSorting}
columns={DNSZonesColumns}
data={zonesWithGroups}
useRowId={true}
wrapperComponent={isGroupPage ? Card : undefined}
wrapperProps={isGroupPage ? { className: "mt-6 w-full" } : undefined}
paginationPaddingClassName={isGroupPage ? "px-0 pt-8" : undefined}
tableClassName={isGroupPage ? "mt-0 mb-2" : undefined}
inset={false}
minimal={isGroupPage}
keepStateInLocalStorage={!isGroupPage}
searchPlaceholder={"Search by domain, ip, content or group..."}
columnVisibility={{ searchString: false }}
renderExpandedRow={(zone) => {
const hasRecords = (zone?.records?.length ?? 0) > 0;
if (!hasRecords) return;
return (
<>
<DNSRecordsTable zone={zone} />
<div className={"h-2 w-full bg-nb-gray-960"}></div>
</>
);
}}
getStartedCard={
isGroupPage ? (
<NoResults
icon={<DNSZoneIcon className={"fill-nb-gray-200"} size={24} />}
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."
}
>
<div className={"gap-x-4 flex items-center justify-center mt-4"}>
<AddZoneButton distributionGroups={distributionGroups} />
</div>
</NoResults>
) : (
<GetStartedTest
icon={
<SquareIcon
icon={<DNSZoneIcon className={"fill-nb-gray-200"} size={24} />}
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={
<div className={"gap-x-4 flex items-center justify-center"}>
<AddZoneButton distributionGroups={distributionGroups} />
</div>
}
learnMore={
<>
Learn more about
<InlineLink href={DNS_ZONE_DOCS_LINK} target={"_blank"}>
DNS Zones
<ExternalLinkIcon size={12} />
</InlineLink>
</>
}
/>
)
}
rightSide={() => (
<>
{data && data?.length > 0 && (
<div className={"gap-x-4 ml-auto flex"}>
<AddZoneButton distributionGroups={distributionGroups} />
</div>
)}
</>
)}
>
{(table) => (
<>
<ButtonGroup disabled={data?.length == 0}>
<ButtonGroup.Button
onClick={() => {
table.setPageIndex(0);
table.getColumn("enabled")?.setFilterValue(undefined);
}}
disabled={data?.length == 0}
variant={
table.getColumn("enabled")?.getFilterValue() === undefined
? "tertiary"
: "secondary"
}
>
All
</ButtonGroup.Button>
<ButtonGroup.Button
onClick={() => {
table.setPageIndex(0);
table.getColumn("enabled")?.setFilterValue(true);
}}
disabled={data?.length == 0}
variant={
table.getColumn("enabled")?.getFilterValue() === true
? "tertiary"
: "secondary"
}
>
Active
</ButtonGroup.Button>
<ButtonGroup.Button
onClick={() => {
table.setPageIndex(0);
table.getColumn("enabled")?.setFilterValue(false);
}}
disabled={data?.length == 0}
variant={
table.getColumn("enabled")?.getFilterValue() === false
? "tertiary"
: "secondary"
}
>
Inactive
</ButtonGroup.Button>
</ButtonGroup>
<DataTableRowsPerPage table={table} disabled={data?.length == 0} />
<DataTableRefreshButton
isDisabled={data?.length == 0}
onClick={() => {
mutate("/dns/zones").then();
mutate("/groups").then();
}}
/>
</>
)}
</DataTable>
);
}
type AddZoneButtonProps = {
distributionGroups?: Group[];
};
const AddZoneButton = ({ distributionGroups }: AddZoneButtonProps) => {
const { permission } = usePermissions();
const { openZoneModal } = useDNSZones();
return (
<Button
variant={"primary"}
className={""}
disabled={!permission?.dns?.create}
onClick={() => openZoneModal(undefined, distributionGroups)}
>
<PlusCircle size={16} />
Add Zone
</Button>
);
};

View File

@@ -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 (
<GroupDetailsTableContainer>
<DNSZonesProvider>
<DNSZonesTable
isGroupPage={true}
isLoading={isLoading}
data={zones}
distributionGroups={[group]}
/>
</DNSZonesProvider>
</GroupDetailsTableContainer>
);
};

View File

@@ -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 = {

View File

@@ -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<Policy[]>(`/policies`);
const { data: nameservers, isLoading: isNameserversLoading } =
useFetchApi<NameserverGroup[]>(`/dns/nameservers`);
const { data: zones, isLoading: isZonesLoading } =
useFetchApi<DNSZone[]>(`/dns/zones`);
const { data: routes, isLoading: isRoutesLoading } =
useFetchApi<Route[]>(`/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,

View File

@@ -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<GroupUsage>[] = [
{
@@ -178,6 +179,28 @@ export const GroupsTableColumns: ColumnDef<GroupUsage>[] = [
/>
),
},
{
accessorKey: "zones_count",
header: ({ column }) => {
return (
<DataTableHeader
column={column}
tooltip={<div className={"text-xs normal-case"}>Zones</div>}
>
<DNSZoneIcon size={16} />
</DataTableHeader>
);
},
cell: ({ row }) => (
<GroupsCountCell
icon={<DNSZoneIcon size={14} />}
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<GroupUsage>[] = [
row.routes_count > 0 ||
row.setup_keys_count > 0 ||
row.users_count > 0 ||
row.resources_count > 0
row.resources_count > 0 ||
row.zones_count
);
},
},

View File

@@ -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<Policy[]>(`/policies`); // Policies
const { data: nameservers, isLoading: isNameserversLoading } =
useFetchApi<NameserverGroup[]>(`/dns/nameservers`); // DNS
const { data: zones, isLoading: isZonesLoading } =
useFetchApi<DNSZone[]>(`/dns/zones`); // DNS Zones
const { data: routes, isLoading: isRoutesLoading } =
useFetchApi<Route[]>(`/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,

View File

@@ -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 (
<div>
<PeerExpirationToggle
peer={peer}
value={peerLoginExpiration}
icon={<TimerResetIcon size={16} />}
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 && (
<div
className={cn(
"border border-nb-gray-900 border-t-0 rounded-b-md bg-nb-gray-940 px-[1.28rem] pt-3 pb-5 flex flex-col gap-4 mx-[0.25rem]",
!peerLoginExpiration
? "opacity-50 pointer-events-none"
: "bg-nb-gray-930/80",
isAccountInactivityExpirationDisabled &&
"opacity-50 bg-nb-gray-940",
)}
>
<PeerExpirationToggle
peer={peer}
variant={"blank"}
type={"inactivity-expiration"}
value={peerInactivityExpiration}
onChange={async (state) => {
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" : ""
}
/>
</div>
)}
</div>
);
};

View File

@@ -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 (
<FullTooltip
content={
const noPermissionOrNoUser = !peer.user_id || !permission?.peers.update;
const isAccountLoginExpirationDisabled =
account && account?.settings?.peer_login_expiration_enabled === false;
const isAccountInactivityExpirationDisabled =
account && account?.settings?.peer_inactivity_expiration_enabled === false;
const isGlobalSettingDisabled =
type === "login-expiration"
? isAccountLoginExpirationDisabled
: isAccountInactivityExpirationDisabled;
const tooltipContent = useMemo(() => {
if (noPermissionOrNoUser) {
return (
<div className={"flex gap-2 items-center !text-nb-gray-300 text-xs"}>
{!peer.user_id ? (
<>
@@ -50,14 +68,37 @@ export const PeerExpirationToggle = ({
</>
)}
</div>
}
);
}
if (isGlobalSettingDisabled) {
const text =
type === "login-expiration"
? "'Peer Session Expiration'"
: "'Require login after disconnect'";
return (
<div className={"flex flex-col gap-2 text-xs max-w-xs"}>
<div>
Global setting {text} is currently disabled. Enable the global
setting to be able to toggle it individually per peer.{" "}
<InlineLink href={"/settings"}>
Go to Settings <ArrowUpRightIcon size={12} />
</InlineLink>
</div>
</div>
);
}
}, [noPermissionOrNoUser, peer, type, isGlobalSettingDisabled]);
return (
<FullTooltip
content={tooltipContent}
className={"w-full block"}
disabled={!!peer.user_id && permission.peers.update}
disabled={tooltipContent === undefined}
>
<FancyToggleSwitch
className={className}
disabled={!peer.user_id || !permission.peers.update}
value={value}
disabled={isGlobalSettingDisabled || noPermissionOrNoUser}
value={isGlobalSettingDisabled ? false : value}
onChange={onChange}
variant={variant}
label={

View File

@@ -37,7 +37,12 @@ import { isNetbirdSSHProtocolSupported } from "@utils/version";
export const PeerSSHToggle = () => {
const { permission } = usePermissions();
const { peer, toggleSSH, setSSHInstructionsModal } = usePeer();
const { data: policies, isLoading } = useFetchApi<Policy[]>("/policies");
const { data: policies } = useFetchApi<Policy[]>(
"/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 = () => {
<div className={"flex gap-3"}>
{isSSHClientEnabled ? (
<Button variant={"secondary"} onClick={() => setSshPolicyModal(true)}>
<Button
variant={"secondary"}
onClick={() => setSshPolicyModal(true)}
disabled={!permission?.policies.create}
>
<CirclePlusIcon size={14} />
Create SSH Policy
</Button>
@@ -301,29 +310,31 @@ export const PeerSSHToggle = () => {
)}
</div>
<PoliciesProvider>
<Modal
open={policyModal}
onOpenChange={(state) => {
setPolicyModal(state);
setCurrentPolicy(undefined);
}}
>
<AccessControlModalContent
key={policyModal ? "1" : "0"}
policy={currentPolicy}
onSuccess={async (p) => {
setPolicyModal(false);
{permission?.policies.create && (
<PoliciesProvider>
<Modal
open={policyModal}
onOpenChange={(state) => {
setPolicyModal(state);
setCurrentPolicy(undefined);
}}
>
<AccessControlModalContent
key={policyModal ? "1" : "0"}
policy={currentPolicy}
onSuccess={async (p) => {
setPolicyModal(false);
setCurrentPolicy(undefined);
}}
/>
</Modal>
<PeerSSHPolicyModal
open={sshPolicyModal}
onOpenChange={setSshPolicyModal}
peer={peer}
/>
</Modal>
<PeerSSHPolicyModal
open={sshPolicyModal}
onOpenChange={setSshPolicyModal}
peer={peer}
/>
</PoliciesProvider>
</PoliciesProvider>
)}
</div>
);
};

View File

@@ -20,11 +20,13 @@ type Props = {
version: string;
os: string;
serial?: string;
ephemeral?: boolean;
};
export default function PeerVersionCell({ version, os, serial }: Props) {
export default function PeerVersionCell({ version, os, serial, ephemeral }: Props) {
const { latestVersion, latestUrl } = useApplicationContext();
const updateAvailable = useMemo(() => {
if (ephemeral) return false;
const operatingSystem = getOperatingSystem(os);
if (
operatingSystem === OperatingSystem.IOS ||
@@ -33,7 +35,7 @@ export default function PeerVersionCell({ version, os, serial }: Props) {
return false;
if (!latestVersion) return false;
return !compareVersions(version, latestVersion);
}, [os, version, latestVersion]);
}, [os, version, latestVersion, ephemeral]);
const updateIcon = useMemo(() => {
return <ArrowUpCircleIcon size={15} className={"text-netbird"} />;

View File

@@ -170,6 +170,7 @@ const PeersTableColumns: ColumnDef<Peer>[] = [
version={row.original.version}
os={row.original.os}
serial={row.original.serial_number}
ephemeral={row.original.ephemeral}
/>
),
},

View File

@@ -26,9 +26,7 @@ export const SSHButton = ({ peer, isDropdown = false }: Props) => {
const hasPermission = permission.peers.update;
const os = getOperatingSystem(peer?.os);
const isWindows = os === OperatingSystem.WINDOWS;
const isMobile = os === OperatingSystem.ANDROID || os === OperatingSystem.IOS;
const isSSHSupported = !isWindows && !isMobile;
const isSSHSupported = os !== OperatingSystem.IOS;
return (
isSSHSupported && (

View File

@@ -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}

View File

@@ -84,9 +84,7 @@ export default function AuthenticationTab({ account }: Readonly<Props>) {
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<Props>) {
const saveChanges = async () => {
const expiration = convertToSeconds(expiresIn, expireInterval);
const peerInactivityExpiration = convertToSeconds(
peerInactivityExpiresIn,
peerInactivityExpireInterval,
);
notify({
title: "Save Authentication Settings",

View File

@@ -68,7 +68,7 @@ const loadConfig = (): Config => {
googleAnalyticsID: configJson?.googleAnalyticsID || undefined,
googleTagManagerID: configJson?.googleTagManagerID || undefined,
wasmPath:
configJson?.wasmPath || "https://pkgs.netbird.io/wasm/client/v0.60.2",
configJson?.wasmPath || "https://pkgs.netbird.io/wasm/client/v0.63.0",
} as Config;
};

View File

@@ -29,8 +29,9 @@ const config: Config = {
"925": "#1e2123",
"930": "#25282c",
"935": "#1f2124",
"940": "#1c1d21",
"940": "#1c1e21",
"950": "#181a1d",
"960": "#15171a",
},
netbird: {
DEFAULT: "#f68330",