Compare commits

..

10 Commits

Author SHA1 Message Date
Eduard Gert
818ba5daa4 Allow wildcard dns zone records (#536)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2026-01-20 17:32:14 +01:00
Ali Amer
3a30f76629 Add Frontend Support for Peer Debug Bundle Trigger and History (#485)
* implement debug ui

* update job ui

* Add type cell, show tooltip if peer is offline, add copy to clipboard for upload key, show error reason in tooltip

* update job event description

---------

Co-authored-by: Eduard Gert <kontakt@eduardgert.de>
2026-01-20 17:12:33 +01:00
Misha Bragin
34dc21c89d Add password change (embedded Idp) (#535) 2026-01-20 15:00:14 +01:00
Eduard Gert
2e37703622 Update CONTRIBUTOR_LICENSE_AGREEMENT.md (#534) 2026-01-19 14:55:04 +01:00
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
Eduard Gert
3affa8908f Redirect /setup to /peers if no setup is required (#526)
Some checks failed
build and push / build_n_push (push) Has been cancelled
* Redirect /setup to /peers if not setup is required

* Fix bad state while redirect

* Prevent redirect to /setup if already on /setup

* Fix loading state
2026-01-08 15:01:45 +01:00
82 changed files with 3357 additions and 192 deletions

View File

@@ -1,7 +1,7 @@
## Contributor License Agreement
This Contributor License Agreement (referred to as the "Agreement") is entered into by the individual
submitting this Agreement and NetBird GmbH, c/o Max-Beer-Straße 2-4 Münzstraße 12 10178 Berlin, Germany,
submitting this Agreement and NetBird GmbH, Brunnenstraße 196, 10119 Berlin, Germany,
referred to as "NetBird" (collectively, the "Parties"). The Agreement outlines the terms and conditions
under which NetBird may utilize software contributions provided by the Contributor for inclusion in
its software development projects. By submitting this Agreement, the Contributor confirms their acceptance

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,6 +40,7 @@ import {
MonitorSmartphoneIcon,
NetworkIcon,
PencilIcon,
RadioTowerIcon,
TimerResetIcon,
} from "lucide-react";
import { useRouter, useSearchParams } from "next/navigation";
@@ -61,12 +61,13 @@ 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 { PeerRemoteJobsSection } from "@/modules/peer/PeerRemoteJobsSection";
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 +81,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 +101,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 +137,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 +148,6 @@ const PeerGeneralInformation = () => {
*/
const { hasChanges, updateRef: updateHasChangedRef } = useHasChanges([
selectedGroups,
loginExpiration,
inactivityExpiration,
]);
const updatePeer = async (newName?: string) => {
@@ -170,8 +157,6 @@ const PeerGeneralInformation = () => {
if (permission.peers.update) {
const updateRequest = update({
name: newName ?? name,
loginExpiration,
inactivityExpiration,
});
batchCall = groupCalls ? [...groupCalls, updateRequest] : [updateRequest];
} else {
@@ -184,11 +169,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 +265,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 />
@@ -382,6 +329,13 @@ const PeerOverviewTabs = () => {
Accessible Peers
</TabsTrigger>
)}
{peer?.id && permission.peers.delete && (
<TabsTrigger value={"peer-job"}>
<RadioTowerIcon size={16} />
Remote Jobs
</TabsTrigger>
)}
</TabsList>
{permission.routes.read && (
@@ -395,6 +349,11 @@ const PeerOverviewTabs = () => {
<AccessiblePeersSection peerID={peer.id} />
</TabsContent>
)}
{peer.id && permission.peers.delete && (
<TabsContent value={"peer-job"} className={"pb-8"}>
<PeerRemoteJobsSection peerID={peer.id} />
</TabsContent>
)}
</Tabs>
);
};
@@ -582,9 +541,9 @@ function PeerInformationCard({ peer }: Readonly<{ peer: Peer }>) {
peer.connected
? "just now"
: dayjs(peer.last_seen).format("D MMMM, YYYY [at] h:mm A") +
" (" +
dayjs().to(peer.last_seen) +
")"
" (" +
dayjs().to(peer.last_seen) +
")"
}
/>

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

@@ -1,7 +1,22 @@
"use client";
import InstanceSetupWizard from "@/modules/instance-setup/InstanceSetupWizard";
import { useInstanceSetup } from "@/contexts/InstanceSetupProvider";
import { useRouter } from "next/navigation";
import FullScreenLoading from "@components/ui/FullScreenLoading";
import { useEffect } from "react";
export default function SetupPage() {
return <InstanceSetupWizard />;
const { setupRequired, loading } = useInstanceSetup();
const router = useRouter();
useEffect(() => {
if (!loading && !setupRequired) router.replace("/peers");
}, [loading, setupRequired]);
return loading || !setupRequired ? (
<FullScreenLoading />
) : (
<InstanceSetupWizard />
);
}

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

@@ -0,0 +1,36 @@
import { cn } from "@utils/helpers";
import * as React from "react";
export const TooltipListItem = ({
icon,
label,
value,
className,
labelClassName,
}: {
icon?: React.ReactNode;
label: string;
value: string | React.ReactNode;
className?: string;
labelClassName?: string;
}) => {
return (
<div
className={cn(
"flex justify-between gap-12 border-b border-nb-gray-920 py-2 px-4 last:border-b-0",
className,
)}
>
<div
className={cn(
"flex items-center gap-2 text-nb-gray-100 font-medium",
labelClassName,
)}
>
{icon}
{label}
</div>
<div className={"text-nb-gray-300"}>{value}</div>
</div>
);
};

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

@@ -11,7 +11,7 @@ import {
} from "@components/DropdownMenu";
import TextWithTooltip from "@components/ui/TextWithTooltip";
import { UserAvatar } from "@components/ui/UserAvatar";
import { LogOutIcon, User2 } from "lucide-react";
import { KeyRound, LogOutIcon, User2 } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { useHotkeys } from "react-hotkeys-hook";
@@ -19,9 +19,13 @@ import { useApplicationContext } from "@/contexts/ApplicationProvider";
import { usePermissions } from "@/contexts/PermissionsProvider";
import { useLoggedInUser } from "@/contexts/UsersProvider";
import useOSDetection from "@/hooks/useOperatingSystem";
import { ChangePasswordModalContent } from "@/modules/users/ChangePasswordModal";
import { isNetBirdHosted } from "@utils/netbird";
import { Modal } from "@components/modal/Modal";
export default function UserDropdown() {
const [dropdownOpen, setDropdownOpen] = useState(false);
const [changePasswordModal, setChangePasswordModal] = useState(false);
const { user } = useApplicationContext();
const { loggedInUser, logout } = useLoggedInUser();
const { isRestricted, permission } = usePermissions();
@@ -31,17 +35,28 @@ export default function UserDropdown() {
useHotkeys("shift+mod+l", () => logout(), []);
return (
<DropdownMenu
modal={false}
open={dropdownOpen}
onOpenChange={setDropdownOpen}
>
<>
<Modal
open={changePasswordModal}
onOpenChange={setChangePasswordModal}
key={changePasswordModal ? 1 : 0}
>
<ChangePasswordModalContent
userId={loggedInUser?.id}
onSuccess={() => setChangePasswordModal(false)}
/>
</Modal>
<DropdownMenu
modal={false}
open={dropdownOpen}
onOpenChange={setDropdownOpen}
>
<DropdownMenuTrigger>
<UserAvatar size={"medium"} />
</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}
@@ -72,6 +87,20 @@ export default function UserDropdown() {
/>
)}
{!isNetBirdHosted() && loggedInUser?.idp_id === "local" && (
<DropdownMenuItem
onClick={() => {
setDropdownOpen(false);
setChangePasswordModal(true);
}}
>
<div className={"flex gap-3 items-center"}>
<KeyRound size={14} />
Change Password
</div>
</DropdownMenuItem>
)}
<DropdownMenuItem onClick={logout}>
<div className={"flex gap-3 items-center"}>
<LogOutIcon size={14} />
@@ -81,6 +110,7 @@ export default function UserDropdown() {
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</>
);
}

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

View File

@@ -36,12 +36,12 @@ export default function InstanceSetupProvider({
const pathname = usePathname();
// Routes that don't need setup check
const bypassRoutes = ["/setup", "/install"];
const shouldBypass =
bypassRoutes.includes(pathname) || isOIDCCallback();
const bypassRoutes = ["/install"];
const shouldBypass = bypassRoutes.includes(pathname) || isOIDCCallback();
// Skip setup check for NetBird hosted (cloud) deployments
const isCloud = isNetBirdHosted();
const isSetupPage = pathname === "/setup";
// Check instance status on mount
useEffect(() => {
@@ -70,10 +70,10 @@ export default function InstanceSetupProvider({
// Handle redirect separately to avoid setState during render conflicts
useEffect(() => {
if (setupRequired && !shouldBypass) {
if (setupRequired && !shouldBypass && !isSetupPage) {
router.replace("/setup");
}
}, [setupRequired, shouldBypass, router]);
}, [setupRequired, shouldBypass, router, isSetupPage]);
// Show loading while checking (only for non-cloud, non-bypass routes)
if (loading && !shouldBypass && !isCloud) {
@@ -81,7 +81,7 @@ export default function InstanceSetupProvider({
}
// If setup required and not on setup page, wait for redirect
if (setupRequired && !shouldBypass) {
if (setupRequired && !shouldBypass && !isSetupPage) {
return <FullScreenLoading />;
}

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

23
src/interfaces/Job.ts Normal file
View File

@@ -0,0 +1,23 @@
export interface Job {
id: string;
triggered_by: string;
completed_at: Date | null;
created_at: Date;
failed_reason: string | null;
workload: Workload;
status: "pending" | "succeeded" | "failed";
}
export interface Workload {
type: "bundle";
parameters: BundleJobParameters;
result: string | null;
}
// Parameters for bundle job
export interface BundleJobParameters {
anonymize: boolean;
bundle_for: boolean;
bundle_for_time: number;
log_file_count: number;
}

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

@@ -277,6 +277,14 @@ export default function ActivityDescription({ event }: Props) {
</div>
);
if (event.activity_code == "user.password.change")
return (
<div className={"inline"}>
Password was changed for user <Value>{event.meta.username}</Value>{" "}
<Value>{event.meta.email}</Value>
</div>
);
/**
* Service User
*/
@@ -693,6 +701,16 @@ export default function ActivityDescription({ event }: Props) {
</div>
);
/**
* Jobs
*/
if (event.activity_code == "peer.job.create")
return (<div className={"inline"}>
Remote job <Value>{m.job_type}</Value> created for peer <Value>{m.for_peer_name}</Value>
</div>
)
if (event.activity_code == "account.settings.extra.flow.group.remove")
return (
<div className={"inline"}>

View File

@@ -33,6 +33,7 @@ const ACTION_COLOR_MAPPING: Record<string, ActionStatus> = {
rename: ActionStatus.INFO,
unblock: ActionStatus.INFO,
login: ActionStatus.INFO,
change: ActionStatus.INFO,
};
export function getColorFromCode(code: string): string {

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,361 @@
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 "";
if (domain === "*") return "";
const valid = validator.isValidDomain(domain, {
allowWildcard: true,
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, wildcard or leave empty to use the primary
domain.
</HelpText>
<div className={"flex w-full"}>
<Input
autoFocus={true}
placeholder={"E.g., dev, * or 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,194 @@
import {
AlarmClock,
BugPlay,
FileText,
PlusCircle,
Shield,
} from "lucide-react";
import { useMemo, useState } from "react";
import { useSWRConfig } from "swr";
import Button from "@/components/Button";
import FancyToggleSwitch from "@/components/FancyToggleSwitch";
import HelpText from "@/components/HelpText";
import { Input } from "@/components/Input";
import { Label } from "@/components/Label";
import {
ModalClose,
ModalContent,
ModalFooter,
} from "@/components/modal/Modal";
import ModalHeader from "@/components/modal/ModalHeader";
import { notify } from "@/components/Notification";
import Separator from "@/components/Separator";
import { Workload } from "@/interfaces/Job";
import { useApiCall } from "@/utils/api";
type Props = {
peerID: string;
onSuccess: () => void;
};
export function CreateDebugJobModalContent({ peerID, onSuccess }: Props) {
const jobRequest = useApiCall<Workload>(`/peers/${peerID}/jobs`, true);
const { mutate } = useSWRConfig();
const [bundleForTimeEnabled, setBundleForTimeEnabled] = useState(false);
const [bundleForTime, setBundleForTime] = useState<string>("");
const [logFileCount, setLogFileCount] = useState<string>("10");
const [anonymize, setAnonymize] = useState<boolean>(false);
const isValid = useMemo(() => {
let validBundleFor = true;
let validLogFileCount = true;
const logFileCountNumber = Number(logFileCount);
const bundleForTimeNumber = Number(bundleForTime);
if (bundleForTime) {
validBundleFor = bundleForTimeNumber >= 1 && bundleForTimeNumber <= 5;
}
validLogFileCount = logFileCountNumber >= 1 && logFileCountNumber <= 1000;
return validLogFileCount && validBundleFor;
}, [bundleForTime, logFileCount]);
const createDebugJob = async () => {
notify({
title: "Create Debug Job",
description: "Debug job triggered successfully.",
loadingMessage: "Creating job...",
promise: jobRequest
.post({
workload: {
type: "bundle",
parameters: {
anonymize,
bundle_for: bundleForTimeEnabled,
bundle_for_time: bundleForTimeEnabled
? Number(bundleForTime)
: undefined,
log_file_count: logFileCount ? Number(logFileCount) : 10,
},
},
})
.then((job) => {
mutate(`/peers/${peerID}/jobs`);
onSuccess();
return job;
}),
});
};
return (
<ModalContent maxWidthClass="max-w-xl">
<ModalHeader
icon={<BugPlay size={20} />}
title="Debug Bundle"
description="Generate a debug bundle on this peer with logs and diagnostics. Useful for troubleshooting without CLI access."
color="netbird"
/>
<Separator />
<div className={"px-8 py-6 flex flex-col gap-4"}>
{/* Log File Count */}
<div className="flex justify-between gap-6">
<div className={"max-w-[300px]"}>
<Label>Log File Count</Label>
<HelpText>
Sets the limit for how many individual log files will be included
in the debug bundle.
</HelpText>
</div>
<Input
type="number"
min={1}
placeholder={"10"}
max={50}
value={logFileCount}
onChange={(e) => setLogFileCount(e.target.value)}
maxWidthClass="w-[220px]"
customPrefix={<FileText size={16} className="text-nb-gray-300" />}
customSuffix="File(s)"
/>
</div>
{/* Bundle Duration */}
<div>
<FancyToggleSwitch
value={bundleForTimeEnabled}
onChange={(enabled) => {
setBundleForTimeEnabled(enabled);
if (!enabled) {
setBundleForTime("");
} else {
setBundleForTime("2");
}
}}
label={
<>
<AlarmClock size={15} />
Enable Bundle Duration
</>
}
helpText="When enabled, allows you to specify a time period for log collection before generating the debug bundle."
/>
{bundleForTimeEnabled && (
<div className="flex justify-between gap-6 mt-6 mb-3">
<div className={"max-w-[300px]"}>
<Label>Duration</Label>
<HelpText>
Time period for which logs should be collected before creating
the debug bundle.
</HelpText>
</div>
<Input
type="number"
min={1}
max={60}
value={bundleForTime}
onChange={(e) => setBundleForTime(e.target.value)}
maxWidthClass="w-[220px]"
placeholder={"2"}
customPrefix={
<AlarmClock size={16} className="text-nb-gray-300" />
}
customSuffix="Minute(s)"
/>
</div>
)}
</div>
{/* Anonymize Data */}
<FancyToggleSwitch
value={anonymize}
onChange={setAnonymize}
label={
<>
<Shield size={15} />
Anonymize Log Data
</>
}
helpText="Remove sensitive information (IP addresses, domains etc.) before creating the debug bundle."
/>
</div>
<ModalFooter className="items-center">
<div className="flex gap-3 w-full justify-end">
<ModalClose asChild>
<Button variant="secondary">Cancel</Button>
</ModalClose>
<Button
variant="primary"
disabled={!isValid}
onClick={createDebugJob}
>
<PlusCircle size={16} />
Create Debug Bundle
</Button>
</div>
</ModalFooter>
</ModalContent>
);
}

View File

@@ -0,0 +1,60 @@
import Badge from "@components/Badge";
import CopyToClipboardText from "@components/CopyToClipboardText";
import FullTooltip from "@components/FullTooltip";
import { Input } from "@components/Input";
import * as React from "react";
import { Job } from "@/interfaces/Job";
import EmptyRow from "@/modules/common-table-rows/EmptyRow";
type Props = {
job: Job;
};
export const JobOutputCell = ({ job }: Props) => {
if (job.status === "succeeded" && job.workload.result) {
return (
<div className="flex flex-col gap-1 items-start justify-center pb-1">
{Object.entries(job.workload.result).map(([key, value]) => (
<div key={key} className="text-sm max-w-[200px]">
<span className="font-normal capitalize text-nb-gray-300 text-xs">
{key.replaceAll("_", " ")}
</span>
<br />
<span className="text-nb-gray-200 truncate">
<CopyToClipboardText
message={"Upload key has been copied to your clipboard"}
alwaysShowIcon={true}
>
<span className={"font-mono truncate"}>
{typeof value === "boolean"
? value
? "Yes"
: "No"
: String(value)}
</span>
</CopyToClipboardText>
</span>
</div>
))}
</div>
);
}
if (job.status === "failed" && job.failed_reason) {
return (
<div className={"flex"}>
<FullTooltip
content={
<div className={"max-w-xs text-xs"}>{job.failed_reason}</div>
}
>
<Badge variant={"red"} className={"px-3 max-w-[200px]"}>
<div className={"truncate"}>{job.failed_reason}</div>
</Badge>
</FullTooltip>
</div>
);
}
return <EmptyRow />;
};

View File

@@ -0,0 +1,56 @@
import Badge from "@components/Badge";
import FullTooltip from "@components/FullTooltip";
import { TooltipListItem } from "@components/TooltipListItem";
import { InfoIcon } from "lucide-react";
import React from "react";
import EmptyRow from "@/modules/common-table-rows/EmptyRow";
export const JobParametersCell = ({ parameters }: { parameters: any }) => {
if (!parameters || Object.keys(parameters).length === 0) {
return <EmptyRow />;
}
const entries = Object.entries(parameters);
return (
<FullTooltip
side={"top"}
interactive={true}
delayDuration={250}
skipDelayDuration={100}
contentClassName={"p-0"}
content={
<div
className={"text-xs flex flex-col"}
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
}}
>
{entries.map(([key, value]) => (
<TooltipListItem
label={key.replaceAll("_", " ")}
labelClassName={"capitalize"}
value={
typeof value === "boolean"
? value
? "Yes"
: "No"
: String(value)
}
key={key}
/>
))}
</div>
}
>
<Badge
variant="gray"
className="flex items-center gap-1.5 cursor-default"
>
<InfoIcon size={12} />
{entries.length} Parameters
</Badge>
</FullTooltip>
);
};

View File

@@ -0,0 +1,30 @@
import { cn } from "@utils/helpers";
import React from "react";
import { Job } from "@/interfaces/Job";
type Props = {
job: Job;
};
export default function JobStatusCell({ job }: Readonly<Props>) {
const status = job.status;
return (
<div
className={cn("flex gap-2.5 items-center text-nb-gray-300 text-sm")}
data-cy={"job-status-cell"}
>
<span
className={cn(
"h-2 w-2 rounded-full",
status == "pending" && "bg-yellow-400",
status == "failed" && "bg-red-500",
status == "succeeded" && "bg-green-500",
)}
></span>
{status == "pending" && "Pending"}
{status == "failed" && "Failed"}
{status == "succeeded" && "Completed"}
</div>
);
}

View File

@@ -0,0 +1,22 @@
import { BugIcon } from "lucide-react";
import * as React from "react";
import { Job } from "@/interfaces/Job";
import EmptyRow from "@/modules/common-table-rows/EmptyRow";
type Props = {
job: Job;
};
export const JobTypeCell = ({ job }: Props) => {
if (job.workload.type === "bundle") {
return (
<div
className={"flex items-center gap-2 whitespace-nowrap text-nb-gray-200"}
>
<BugIcon size={14} />
<span>Debug Bundle</span>
</div>
);
}
return <EmptyRow />;
};

View File

@@ -0,0 +1,141 @@
import Card from "@components/Card";
import { DataTable } from "@components/table/DataTable";
import DataTableHeader from "@components/table/DataTableHeader";
import NoResults from "@components/ui/NoResults";
import { ColumnDef, SortingState } from "@tanstack/react-table";
import { ClipboardList } from "lucide-react";
import React, { useState } from "react";
import { useSWRConfig } from "swr";
import DataTableRefreshButton from "@/components/table/DataTableRefreshButton";
import { DataTableRowsPerPage } from "@/components/table/DataTableRowsPerPage";
import { Job } from "@/interfaces/Job";
import EmptyRow from "@/modules/common-table-rows/EmptyRow";
import LastTimeRow from "@/modules/common-table-rows/LastTimeRow";
import { JobOutputCell } from "@/modules/jobs/table/JobOutputCell";
import { JobParametersCell } from "@/modules/jobs/table/JobParametersCell";
import JobStatusCell from "@/modules/jobs/table/JobStatusCell";
import { JobTypeCell } from "@/modules/jobs/table/JobTypeCell";
import { RemoteJobDropdownButton } from "@/modules/peer/RemoteJobDropdownButton";
type Props = {
jobs?: Job[];
peerID: string;
isLoading: boolean;
headingTarget?: HTMLHeadingElement | null;
};
const PeerRemoteJobsColumns: ColumnDef<Job>[] = [
{
accessorKey: "Type",
header: ({ column }) => (
<DataTableHeader column={column}>Type</DataTableHeader>
),
cell: ({ row }) => <JobTypeCell job={row.original} />,
},
{
accessorKey: "CreatedAt",
header: ({ column }) => (
<DataTableHeader column={column}>Created</DataTableHeader>
),
sortingFn: "datetime",
cell: ({ row }) => (
<LastTimeRow date={row.original.created_at} text="Created at" />
),
},
{
accessorKey: "Status",
header: ({ column }) => (
<DataTableHeader column={column}>Status</DataTableHeader>
),
cell: ({ row }) => <JobStatusCell job={row.original} />,
},
{
accessorKey: "CompletedAt",
header: ({ column }) => (
<DataTableHeader column={column}>Completed</DataTableHeader>
),
sortingFn: "datetime",
cell: ({ row }) =>
row.original.completed_at ? (
<LastTimeRow date={row.original.completed_at} text="Completed at" />
) : (
<EmptyRow />
),
},
{
accessorKey: "Parameters",
header: ({ column }) => (
<DataTableHeader column={column}>Parameters</DataTableHeader>
),
cell: ({ row }) => (
<JobParametersCell parameters={row.original.workload.parameters} />
),
},
{
id: "ResultOrReason",
header: ({ column }) => (
<DataTableHeader column={column}>Output</DataTableHeader>
),
cell: ({ row }) => <JobOutputCell job={row.original} />,
},
];
export default function PeerRemoteJobsTable({
jobs,
isLoading,
headingTarget,
peerID,
}: Props) {
const { mutate } = useSWRConfig();
const [sorting, setSorting] = useState<SortingState>([
{ id: "CreatedAt", desc: true },
]);
return (
<DataTable
rightSide={() => (
<div className={"gap-x-4 ml-auto flex"}>
<RemoteJobDropdownButton />
</div>
)}
wrapperComponent={Card}
wrapperProps={{ className: "mt-6 w-full" }}
headingTarget={headingTarget}
useRowId={true}
sorting={sorting}
setSorting={setSorting}
minimal={true}
showSearchAndFilters={true}
inset={false}
tableClassName="mt-0"
text="Jobs"
columns={PeerRemoteJobsColumns}
keepStateInLocalStorage={false}
data={jobs}
searchPlaceholder="Search by type, status, or parameters..."
isLoading={isLoading}
getStartedCard={
<NoResults
className="py-4"
title="This peer has no remote jobs"
description="Create a debug bundle or trigger other remote jobs to see them listed here."
icon={<ClipboardList size={20} className="text-nb-gray-300" />}
/>
}
paginationPaddingClassName="px-0 pt-8"
>
{(table) => (
<>
<DataTableRowsPerPage table={table} disabled={jobs?.length == 0} />
<DataTableRefreshButton
isDisabled={jobs?.length == 0}
onClick={() => {
mutate(`/peers/${peerID}/jobs`).then();
}}
/>
</>
)}
</DataTable>
);
}

View File

@@ -58,6 +58,7 @@ export default function AddRouteDropdownButton() {
icon={<PlusCircle size={14} />}
color={"green"}
margin={""}
size={"small"}
/>
<div className={"flex flex-col text-left"}>
<div className={"text-left text-white"}>New Network Route</div>
@@ -79,6 +80,7 @@ export default function AddRouteDropdownButton() {
}
color={"netbird"}
margin={""}
size={"small"}
/>
<div className={"flex flex-col text-left"}>
<div className={"text-left text-white"}>Existing Network</div>

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

@@ -0,0 +1,64 @@
import { ExternalLinkIcon } from "lucide-react";
import React, { lazy, Suspense } from "react";
import InlineLink from "@/components/InlineLink";
import Paragraph from "@/components/Paragraph";
import SkeletonTable, {
SkeletonTableHeader,
} from "@/components/skeletons/SkeletonTable";
import { usePortalElement } from "@/hooks/usePortalElement";
import { Job } from "@/interfaces/Job";
import useFetchApi from "@/utils/api";
const PeerRemoteJobsTable = lazy(
() => import("@/modules/jobs/table/PeerRemoteJobsTable"),
);
type Props = {
peerID: string;
};
export const PeerRemoteJobsSection = ({ peerID }: Props) => {
const { data: jobs, isLoading } = useFetchApi<Job[]>(`/peers/${peerID}/jobs`);
const { ref: headingRef, portalTarget } =
usePortalElement<HTMLHeadingElement>();
return (
<div className="pb-10 px-8">
<div className="max-w-6xl">
<div className="flex justify-between items-center mb-5">
<div>
<h2 ref={headingRef}>Remote Jobs</h2>
<Paragraph>
Remotely trigger actions such as debug bundles or other tasks on
this peer, without requiring CLI access.
</Paragraph>
<Paragraph>
Learn more about{" "}
<InlineLink href={"https://docs.netbird.io"} target={"_blank"}>
Remote Jobs <ExternalLinkIcon size={12} />
</InlineLink>
in our documentation.
</Paragraph>
</div>
</div>
<Suspense
fallback={
<div>
<SkeletonTableHeader className="!p-0" />
<div className="mt-8 w-full">
<SkeletonTable withHeader={false} />
</div>
</div>
}
>
<PeerRemoteJobsTable
peerID={peerID}
jobs={jobs}
isLoading={isLoading}
headingTarget={portalTarget}
/>
</Suspense>
</div>
</div>
);
};

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

@@ -0,0 +1,87 @@
import Button from "@components/Button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@components/DropdownMenu";
import { Modal } from "@components/modal/Modal";
import SquareIcon from "@components/SquareIcon";
import { BugPlay, ChevronDown } from "lucide-react";
import React, { useState } from "react";
import { usePeer } from "@/contexts/PeerProvider";
import { usePermissions } from "@/contexts/PermissionsProvider";
import { CreateDebugJobModalContent } from "../jobs/CreateDebugJobModal";
export const RemoteJobDropdownButton = () => {
const [modal, setModal] = useState(false);
const { peer } = usePeer();
const { permission } = usePermissions();
const isConnected = peer?.connected;
const disabled = !permission.peers.delete;
return (
<>
<Modal open={modal} onOpenChange={setModal} key={modal ? 1 : 0}>
<CreateDebugJobModalContent
peerID={peer.id!}
onSuccess={() => setModal(false)}
/>
</Modal>
<DropdownMenu modal={false}>
<DropdownMenuTrigger
asChild={true}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
}}
>
<Button variant={"primary"} disabled={disabled}>
Run Remote Job
<ChevronDown size={16} />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-auto" align="end" sideOffset={10}>
{!isConnected && (
<>
<div
className={
"text-xs flex items-center w-full justify-center max-w-xs px-3 py-3 text-nb-gray-200 font-light"
}
>
<div>
Peer{" "}
<span className={"text-white font-medium"}>{peer.name}</span>{" "}
is currently offline. Please connect the peer to run remote
jobs.
</div>
</div>
<DropdownMenuSeparator />
</>
)}
<DropdownMenuItem
onClick={() => setModal(true)}
disabled={disabled || !isConnected}
>
<div className={"flex gap-3 items-center justify-center pr-3"}>
<SquareIcon
icon={<BugPlay size={14} />}
margin={""}
size={"small"}
/>
<div className={"flex flex-col text-left"}>
<div className={"text-left text-white"}>Debug Bundle</div>
<div className={"text-xs"}>
Collect debug information for troubleshooting
</div>
</div>
</div>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</>
);
};

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

@@ -0,0 +1,194 @@
"use client";
import Button from "@components/Button";
import { Input } from "@components/Input";
import {
Modal,
ModalClose,
ModalContent,
ModalFooter,
ModalTrigger,
} from "@components/modal/Modal";
import ModalHeader from "@components/modal/ModalHeader";
import { notify } from "@components/Notification";
import Separator from "@components/Separator";
import { Label } from "@components/Label";
import HelpText from "@components/HelpText";
import { useApiCall } from "@utils/api";
import { KeyRound, LockIcon } from "lucide-react";
import React, { useMemo, useState } from "react";
type Props = {
children: React.ReactNode;
userId?: string;
};
export default function ChangePasswordModal({
children,
userId,
}: Readonly<Props>) {
const [modal, setModal] = useState(false);
return (
<Modal open={modal} onOpenChange={setModal} key={modal ? 1 : 0}>
<ModalTrigger asChild>{children}</ModalTrigger>
<ChangePasswordModalContent
userId={userId}
onSuccess={() => setModal(false)}
/>
</Modal>
);
}
type ModalProps = {
userId?: string;
onSuccess?: () => void;
};
export function ChangePasswordModalContent({
userId,
onSuccess,
}: Readonly<ModalProps>) {
const passwordRequest = useApiCall<void>(`/users/${userId}/password`, true);
const [currentPassword, setCurrentPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [isLoading, setIsLoading] = useState(false);
const currentPasswordError = useMemo(() => {
if (currentPassword.length === 0) return undefined;
return undefined;
}, [currentPassword]);
const newPasswordError = useMemo(() => {
if (newPassword.length === 0) return undefined;
if (newPassword.length < 8) return "Password must be at least 8 characters";
return undefined;
}, [newPassword]);
const confirmPasswordError = useMemo(() => {
if (confirmPassword.length === 0) return undefined;
if (newPassword !== confirmPassword) return "Passwords do not match";
return undefined;
}, [newPassword, confirmPassword]);
const isDisabled = useMemo(() => {
if (currentPassword.length === 0) return true;
if (newPassword.length < 8) return true;
if (confirmPassword.length === 0) return true;
if (newPassword !== confirmPassword) return true;
return false;
}, [currentPassword, newPassword, confirmPassword]);
const changePassword = async () => {
if (!userId || isDisabled) return;
setIsLoading(true);
notify({
title: "Change Password",
description: "Your password has been successfully changed.",
promise: passwordRequest
.put({
old_password: currentPassword,
new_password: newPassword,
})
.then(() => {
onSuccess && onSuccess();
})
.finally(() => {
setIsLoading(false);
}),
loadingMessage: "Changing password...",
});
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && !isDisabled && !isLoading) {
changePassword();
}
};
return (
<ModalContent maxWidthClass={"max-w-lg"}>
<ModalHeader
icon={<KeyRound size={18} />}
title={"Change Password"}
description={"Update your account password."}
color={"netbird"}
/>
<Separator />
<form className={"px-8 py-6 flex flex-col gap-6"} onSubmit={changePassword}>
<div>
<Label>Current Password</Label>
<HelpText>Enter your current password to verify your identity.</HelpText>
<Input
type="password"
placeholder={"Enter current password"}
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
onKeyDown={handleKeyDown}
showPasswordToggle
error={currentPasswordError}
customPrefix={<LockIcon size={16} className={"text-nb-gray-300"} />}
name={"current-password"}
autoComplete={"current-password"}
/>
</div>
<div>
<Label>New Password</Label>
<HelpText>
Enter your new password. Must be at least 8 characters.
</HelpText>
<Input
type="password"
placeholder={"Enter new password"}
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
onKeyDown={handleKeyDown}
showPasswordToggle
error={newPasswordError}
customPrefix={<LockIcon size={16} className={"text-nb-gray-300"} />}
name={"new-password"}
autoComplete={"new-password"}
/>
</div>
<div>
<Label>Confirm New Password</Label>
<HelpText>Re-enter your new password to confirm.</HelpText>
<Input
type="password"
placeholder={"Confirm new password"}
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
onKeyDown={handleKeyDown}
showPasswordToggle
error={confirmPasswordError}
customPrefix={<LockIcon size={16} className={"text-nb-gray-300"} />}
name={"confirm-password"}
autoComplete={"confirm-password"}
/>
</div>
</form>
<ModalFooter className={"items-center"}>
<div className={"flex gap-3 w-full justify-end"}>
<ModalClose asChild={true}>
<Button variant={"secondary"}>Cancel</Button>
</ModalClose>
<Button
variant={"primary"}
disabled={isDisabled || isLoading}
onClick={changePassword}
>
Change Password
</Button>
</div>
</ModalFooter>
</ModalContent>
);
}

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