Compare commits

...

17 Commits

Author SHA1 Message Date
Misha Bragin
d2febbf27b Fix version comparison (#544)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2026-01-27 14:13:27 +01:00
Misha Bragin
615b4487ad Point to the right upgrade doc (#543)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2026-01-27 12:23:56 +01:00
Misha Bragin
a7c7800916 Add invite notification count badge (#542) 2026-01-27 10:44:39 +01:00
Eduard Gert
3d51e0893e Update announcement (#538)
* Update announcement

* Fix repeated fetches
2026-01-27 09:33:43 +01:00
Misha Bragin
d7d44b5817 Adjust Invites API (#541)
* Add API adjustments

* Invite_link renamed to invite_token
2026-01-26 19:25:56 +01:00
Misha Bragin
f67f39b68b Local user invites (#539) 2026-01-25 21:40:49 +01:00
dependabot[bot]
d2bc7a1f57 Bump lodash from 4.17.21 to 4.17.23 (#537)
Bumps [lodash](https://github.com/lodash/lodash) from 4.17.21 to 4.17.23.
- [Release notes](https://github.com/lodash/lodash/releases)
- [Commits](https://github.com/lodash/lodash/compare/4.17.21...4.17.23)

---
updated-dependencies:
- dependency-name: lodash
  dependency-version: 4.17.23
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-23 13:28:28 +01:00
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
95 changed files with 4840 additions and 291 deletions

View File

@@ -54,8 +54,19 @@ jobs:
fileName: "ironrdp_web_bg.wasm"
out-file-path: 'public/ironrdp-pkg'
- name: Get version from tag
id: version
run: |
if [[ "${{ github.ref }}" == refs/tags/* ]]; then
echo "version=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
else
echo "version=development" >> $GITHUB_OUTPUT
fi
- name: Build
run: npm run build
env:
NEXT_PUBLIC_DASHBOARD_VERSION: ${{ steps.version.outputs.version }}
-
name: Set up QEMU
uses: docker/setup-qemu-action@v2

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

12
announcements.json Normal file
View File

@@ -0,0 +1,12 @@
[
{
"tag": "New",
"text": "Custom DNS Zones for Private Network Resolution",
"link": "https://netbird.io/knowledge-hub/custom-dns-zones",
"linkText": "Read Release Article",
"variant": "important",
"isExternal": true,
"closeable": true,
"isCloudOnly": false
}
]

View File

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

View File

@@ -7,6 +7,8 @@ const nextConfig = {
reactStrictMode: false,
env: {
APP_ENV: process.env.APP_ENV || "production",
NEXT_PUBLIC_DASHBOARD_VERSION:
process.env.NEXT_PUBLIC_DASHBOARD_VERSION || "development",
},
};

39
package-lock.json generated
View File

@@ -56,9 +56,10 @@
"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",
"lodash": "^4.17.23",
"lucide-react": "^0.539.0",
"next": "^14.2.35",
"next-themes": "^0.2.1",
@@ -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",
@@ -7242,9 +7253,10 @@
}
},
"node_modules/lodash": {
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="
"version": "4.17.23",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
"license": "MIT"
},
"node_modules/lodash.merge": {
"version": "4.6.2",
@@ -8561,7 +8573,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,9 +61,10 @@
"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",
"lodash": "^4.17.23",
"lucide-react": "^0.539.0",
"next": "^14.2.35",
"next-themes": "^0.2.1",

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

@@ -0,0 +1,8 @@
import { globalMetaTitle } from "@utils/meta";
import type { Metadata } from "next";
import BlankLayout from "@/layouts/BlankLayout";
export const metadata: Metadata = {
title: `Accept Invite - ${globalMetaTitle}`,
};
export default BlankLayout;

321
src/app/invite/page.tsx Normal file
View File

@@ -0,0 +1,321 @@
"use client";
import Button from "@components/Button";
import { Input } from "@components/Input";
import Paragraph from "@components/Paragraph";
import FullScreenLoading from "@components/ui/FullScreenLoading";
import { acceptInvite, fetchInviteInfo } from "@utils/unauthenticatedApi";
import {
AlertCircle,
CheckCircle2,
Clock,
KeyRound,
Mail,
User2,
} from "lucide-react";
import dayjs from "dayjs";
import { useRouter, useSearchParams } from "next/navigation";
import { Suspense, useEffect, useMemo, useState } from "react";
import NetBirdIcon from "@/assets/icons/NetBirdIcon";
import { UserInviteInfo } from "@/interfaces/User";
export default function InviteAcceptPage() {
return (
<Suspense fallback={<FullScreenLoading />}>
<InviteAcceptContent />
</Suspense>
);
}
function InviteAcceptContent() {
const searchParams = useSearchParams();
const router = useRouter();
const token = searchParams?.get("token");
const [loading, setLoading] = useState(true);
const [inviteInfo, setInviteInfo] = useState<UserInviteInfo | null>(null);
const [error, setError] = useState<string | null>(null);
const [isRateLimited, setIsRateLimited] = useState(false);
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [submitting, setSubmitting] = useState(false);
const [success, setSuccess] = useState(false);
useEffect(() => {
if (!token) {
setError("No invite token provided");
setLoading(false);
return;
}
fetchInviteInfo(token)
.then((info) => {
setInviteInfo(info);
setLoading(false);
})
.catch((err) => {
if (err.code === 429) {
setError("Too many attempts. Please wait a moment and try again.");
setIsRateLimited(true);
} else {
setError(err.message || "Invalid or expired invite link");
setIsRateLimited(false);
}
setLoading(false);
});
}, [token]);
const passwordsMatch = password === confirmPassword;
const hasMinLength = password.length >= 8;
const hasUppercase = /[A-Z]/.test(password);
const hasLowercase = /[a-z]/.test(password);
const hasNumber = /[0-9]/.test(password);
const hasSpecialChar = /[!@#$%^&*(),.?":{}|<>]/.test(password);
const passwordValid = hasMinLength && hasUppercase && hasLowercase && hasNumber && hasSpecialChar;
const canSubmit = passwordValid && passwordsMatch && !submitting;
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!canSubmit || !token) return;
setSubmitting(true);
setError(null);
try {
await acceptInvite(token, password);
setSuccess(true);
} catch (err: any) {
setError(err.message || "Failed to accept invite");
} finally {
setSubmitting(false);
}
};
const isExpired = useMemo(() => {
if (!inviteInfo) return false;
return new Date(inviteInfo.expires_at) < new Date();
}, [inviteInfo]);
if (loading) {
return <FullScreenLoading />;
}
if (error && !inviteInfo) {
if (isRateLimited) {
return (
<div className="min-h-screen flex items-center justify-center bg-nb-gray-950 p-4">
<div className="max-w-md w-full text-center">
<div className="mb-6 flex justify-center">
<div className="w-16 h-16 bg-yellow-500/10 rounded-full flex items-center justify-center">
<Clock className="w-8 h-8 text-yellow-500" />
</div>
</div>
<h1 className="text-2xl font-semibold text-white mb-2">
Too Many Requests
</h1>
<Paragraph className="text-nb-gray-400 text-base">
You&apos;ve made too many requests. Please wait a moment and try
again.
</Paragraph>
<Button
variant="secondary"
className="mt-6"
onClick={() => window.location.reload()}
>
Try Again
</Button>
</div>
</div>
);
}
return (
<div className="min-h-screen flex items-center justify-center bg-nb-gray-950 p-4">
<div className="max-w-md w-full text-center">
<div className="mb-6 flex justify-center">
<div className="w-16 h-16 bg-red-500/10 rounded-full flex items-center justify-center">
<AlertCircle className="w-8 h-8 text-red-500" />
</div>
</div>
<h1 className="text-2xl font-semibold text-white mb-2">
Invalid Invite
</h1>
<Paragraph className="text-nb-gray-400 text-base">
This invite link is invalid or has expired. Please contact your
administrator to receive a new invitation.
</Paragraph>
<Button
variant="secondary"
className="mt-6"
onClick={() => router.push("/")}
>
Go to Login
</Button>
</div>
</div>
);
}
if (success) {
return (
<div className="min-h-screen flex items-center justify-center bg-nb-gray-950 p-4">
<div className="max-w-md w-full text-center">
<div className="mb-6 flex justify-center">
<div className="w-16 h-16 bg-green-500/10 rounded-full flex items-center justify-center">
<CheckCircle2 className="w-8 h-8 text-green-500" />
</div>
</div>
<h1 className="text-2xl font-semibold text-white mb-2">
Account Created!
</h1>
<Paragraph className="text-nb-gray-400">
Your account has been created successfully. You can now log in with
your email and password.
</Paragraph>
<Button
variant="primary"
className="mt-6"
onClick={() => router.push("/")}
>
Go to Login
</Button>
</div>
</div>
);
}
if (isExpired || !inviteInfo?.valid) {
return (
<div className="min-h-screen flex items-center justify-center bg-nb-gray-950 p-4">
<div className="max-w-md w-full text-center">
<div className="mb-6 flex justify-center">
<div className="w-16 h-16 bg-yellow-500/10 rounded-full flex items-center justify-center">
<AlertCircle className="w-8 h-8 text-yellow-500" />
</div>
</div>
<h1 className="text-2xl font-semibold text-white mb-2">
Invite Expired
</h1>
<Paragraph className="text-nb-gray-400">
This invite link has expired. Please contact your administrator to
receive a new invitation.
</Paragraph>
<Button
variant="secondary"
className="mt-6"
onClick={() => router.push("/")}
>
Go to Login
</Button>
</div>
</div>
);
}
return (
<div className="min-h-screen flex items-center justify-center bg-nb-gray-950 p-4">
<div className="max-w-md w-full">
<div className="mb-8 flex justify-center">
<NetBirdIcon size={48} />
</div>
<div className="text-center mb-8">
<h1 className="text-2xl font-semibold text-white mb-2">
Welcome to NetBird
</h1>
<p className="dark:text-nb-gray-400 text-nb-gray-500 text-base">
You&apos;ve been invited by <span className="dark:text-white text-nb-gray-900 font-medium">{inviteInfo.invited_by}</span> to join the network. Set your password to complete your account setup.
</p>
</div>
<div className="bg-nb-gray-930 border border-nb-gray-900 rounded-lg p-6 mb-6">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 bg-nb-gray-900 rounded-full flex items-center justify-center">
<User2 className="w-5 h-5 text-nb-gray-400" />
</div>
<div>
<div className="text-white font-medium">{inviteInfo.name}</div>
<div className="text-nb-gray-400 text-sm flex items-center gap-1">
<Mail className="w-3 h-3" />
{inviteInfo.email}
</div>
</div>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<Input
type="password"
placeholder="Password"
value={password}
onChange={(e) => setPassword(e.target.value)}
customPrefix={
<KeyRound size={16} className="text-nb-gray-400" />
}
/>
{password && (
<div className="mt-2 space-y-1">
<PasswordRule met={hasMinLength} text="At least 8 characters" />
<PasswordRule met={hasUppercase} text="One uppercase letter" />
<PasswordRule met={hasLowercase} text="One lowercase letter" />
<PasswordRule met={hasNumber} text="One number" />
<PasswordRule met={hasSpecialChar} text="One special character (!@#$%^&*)" />
</div>
)}
</div>
<div>
<Input
type="password"
placeholder="Confirm Password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
customPrefix={
<KeyRound size={16} className="text-nb-gray-400" />
}
/>
{confirmPassword && !passwordsMatch && (
<p className="text-xs text-red-500 mt-1">
Passwords do not match
</p>
)}
</div>
{error && (
<div className="bg-red-500/10 border border-red-500/20 rounded-md p-3">
<p className="text-sm text-red-500">{error}</p>
</div>
)}
<Button
type="submit"
variant="primary"
className="w-full"
disabled={!canSubmit}
>
{submitting ? "Creating Account..." : "Create Account"}
</Button>
</form>
</div>
<p className="text-center text-xs text-nb-gray-500">
Invite expires on {dayjs(inviteInfo.expires_at).format("D MMMM, YYYY [at] h:mm A")}
</p>
</div>
</div>
);
}
function PasswordRule({ met, text }: { met: boolean; text: string }) {
return (
<div className="flex items-center gap-2 text-xs">
{met ? (
<CheckCircle2 className="w-3 h-3 text-green-500" />
) : (
<AlertCircle className="w-3 h-3 text-nb-gray-500" />
)}
<span className={met ? "text-green-500" : "text-nb-gray-500"}>{text}</span>
</div>
);
}

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

@@ -104,7 +104,9 @@ export default function OIDCProvider({ children }: Props) {
// We bypass authentication for pages that do not require auth.
// E.g., when we just want to show installation steps for public.
// Or the instance setup wizard for first-time setup.
if (path === "/install" || path === "/setup") return children;
// Or the invite acceptance page for new users.
if (path === "/install" || path === "/setup" || path?.startsWith("/invite"))
return children;
return mounted && providerConfig ? (
<OidcProvider

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

@@ -0,0 +1,148 @@
"use client";
import FullTooltip from "@components/FullTooltip";
import { cn } from "@utils/helpers";
import { ArrowUpCircle } from "lucide-react";
import * as React from "react";
import Skeleton from "react-loading-skeleton";
import useFetchApi from "@utils/api";
import { isNetBirdHosted } from "@utils/netbird";
import { useApplicationContext } from "@/contexts/ApplicationProvider";
import { VersionInfo as VersionInfoType } from "@/interfaces/Instance";
function formatVersion(version: string): string {
if (!version) return "";
// Add "v" prefix if version starts with a number
if (/^\d/.test(version)) return `v${version}`;
return version;
}
function compareVersions(current: string, latest: string): boolean {
// Returns true if latest is newer than current
if (!current || !latest) return false;
if (current === "development") return false;
// Strip "v" prefix if present
const normalizedCurrent = current.replace(/^v/, "");
const normalizedLatest = latest.replace(/^v/, "");
const currentParts = normalizedCurrent
.split(".")
.map((p) => parseInt(p, 10) || 0);
const latestParts = normalizedLatest
.split(".")
.map((p) => parseInt(p, 10) || 0);
for (let i = 0; i < Math.max(currentParts.length, latestParts.length); i++) {
const c = currentParts[i] || 0;
const l = latestParts[i] || 0;
if (l > c) return true;
if (l < c) return false;
}
return false;
}
export const NavigationVersionInfo = () => {
const { isNavigationCollapsed, mobileNavOpen } = useApplicationContext();
// Only show for self-hosted, not cloud
if (isNetBirdHosted()) return null;
return (
<div
className={cn(
"px-4 py-4 animate-fade-in",
isNavigationCollapsed &&
!mobileNavOpen &&
"hidden md:group-hover/navigation:block",
)}
>
<NavigationVersionInfoContent />
</div>
);
};
const NavigationVersionInfoContent = () => {
const { data: versionInfo, isLoading } = useFetchApi<VersionInfoType>(
"/instance/version",
true, // ignore errors
false, // don't revalidate on focus
);
const dashboardVersion =
process.env.NEXT_PUBLIC_DASHBOARD_VERSION || "development";
if (isLoading)
return <Skeleton height={80} className={"rounded-lg opacity-60"} />;
if (!versionInfo) return null;
// Compare versions to detect updates (returns false for "development" versions)
const managementUpdateAvailable = compareVersions(
versionInfo.management_current_version,
versionInfo.management_available_version,
);
const dashboardUpdateAvailable = compareVersions(
dashboardVersion,
versionInfo.dashboard_available_version,
);
const hasUpdate = managementUpdateAvailable || dashboardUpdateAvailable;
return (
<div
className={cn(
"w-full rounded-md text-xs flex flex-col gap-2 whitespace-normal border text-left",
"bg-nb-gray-900/20 py-3 px-3 border-nb-gray-800/30",
)}
>
<div className="flex flex-col gap-1 text-nb-gray-400">
<FullTooltip
content={
<span className="text-xs">
Latest: {formatVersion(versionInfo.management_available_version)}
</span>
}
side="top"
className="w-full"
>
<div className="flex items-center justify-between w-full cursor-default">
<span>Management</span>
<span className="text-nb-gray-300 font-medium">
{formatVersion(versionInfo.management_current_version)}
</span>
</div>
</FullTooltip>
<FullTooltip
content={
<span className="text-xs">
Latest: {formatVersion(versionInfo.dashboard_available_version)}
</span>
}
side="top"
className="w-full"
>
<div className="flex items-center justify-between w-full cursor-default">
<span>Dashboard</span>
<span className="text-nb-gray-300 font-medium">
{formatVersion(dashboardVersion)}
</span>
</div>
</FullTooltip>
</div>
{hasUpdate && (
<a
href="https://docs.netbird.io/selfhosted/maintenance/upgrade"
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center gap-1.5 text-white font-medium bg-netbird hover:bg-netbird-500 transition-colors rounded-md py-1.5 px-2 mt-1"
>
<ArrowUpCircle size={12} />
<span>Update available</span>
</a>
)}
</div>
);
};
export default NavigationVersionInfo;

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

@@ -1,21 +1,26 @@
import { AnnouncementVariant } from "@components/ui/AnnouncementBanner";
import { useLocalStorage } from "@hooks/useLocalStorage";
import md5 from "crypto-js/md5";
import React, { useEffect, useState } from "react";
import React, {
createContext,
useContext,
useEffect,
useRef,
useState,
} from "react";
import { usePermissions } from "@/contexts/PermissionsProvider";
import { isNetBirdHosted } from "@utils/netbird";
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",
linkText: "Read Release Article",
variant: "important", // "default" or "important"
isExternal: true,
closeable: true,
isCloudOnly: false,
},
];
const ANNOUNCEMENTS_URL =
"https://raw.githubusercontent.com/netbirdio/dashboard/main/announcements.json";
const STORAGE_KEY = "netbird-announcements";
const CACHE_DURATION_MS = 30 * 60 * 1000;
const BANNER_HEIGHT = 40;
interface AnnouncementStore {
timestamp: number;
announcements: Announcement[];
closedAnnouncements: string[];
}
export interface Announcement extends AnnouncementVariant {
tag: string;
@@ -36,7 +41,7 @@ type Props = {
children: React.ReactNode;
};
const AnnouncementContext = React.createContext(
const AnnouncementContext = createContext(
{} as {
bannerHeight: number;
announcements?: AnnouncementInfo[];
@@ -47,59 +52,99 @@ const AnnouncementContext = React.createContext(
},
);
const bannerHeight = 40;
const getAnnouncements = async (): Promise<AnnouncementInfo[]> => {
try {
let stored: AnnouncementStore | null = null;
try {
const data = localStorage.getItem(STORAGE_KEY);
stored = data ? JSON.parse(data) : null;
} catch {}
const now = Date.now();
let raw: Announcement[];
if (stored && now - stored.timestamp < CACHE_DURATION_MS) {
raw = stored.announcements;
} else {
const response = await fetch(ANNOUNCEMENTS_URL);
if (!response.ok) return [];
raw = await response.json();
}
const isCloud = isNetBirdHosted();
const filtered = raw.filter((a) => !a.isCloudOnly || isCloud);
const hashes = new Set(filtered.map((a) => md5(a.text).toString()));
const closed = (stored?.closedAnnouncements ?? []).filter((h) =>
hashes.has(h),
);
try {
localStorage.setItem(
STORAGE_KEY,
JSON.stringify({
timestamp: now,
announcements: raw,
closedAnnouncements: closed,
}),
);
} catch {}
return filtered.map((a) => {
const hash = md5(a.text).toString();
return { ...a, hash, isOpen: !closed.includes(hash) };
});
} catch {
return [];
}
};
const saveAnnouncements = (closedAnnouncements: string[]) => {
try {
const data = localStorage.getItem(STORAGE_KEY);
const stored: AnnouncementStore | null = data ? JSON.parse(data) : null;
if (stored) {
localStorage.setItem(
STORAGE_KEY,
JSON.stringify({ ...stored, closedAnnouncements }),
);
}
} catch {}
};
export default function AnnouncementProvider({ children }: Readonly<Props>) {
const [height, setHeight] = useState(0);
const [closedAnnouncements, setClosedAnnouncements] = useLocalStorage<
string[]
>("netbird-closed-announcements", []);
const [announcements, setAnnouncements] = useState<AnnouncementInfo[]>();
const { isRestricted } = usePermissions();
const fetchingRef = useRef(false);
useEffect(() => {
if (announcements && announcements.length > 0) return;
if (isRestricted) return;
const initial = initialAnnouncements.map((announcement) => {
const hash = md5(announcement.text).toString();
const isOpen = !closedAnnouncements.some((h) => h === hash);
return {
...announcement,
hash,
isOpen,
} as AnnouncementInfo;
});
if (initial.length > 0) {
setAnnouncements(initial);
}
}, [closedAnnouncements, announcements]);
if (announcements !== undefined || isRestricted || fetchingRef.current)
return;
fetchingRef.current = true;
getAnnouncements()
.then((a) => setAnnouncements(a))
.finally(() => (fetchingRef.current = false));
}, [announcements, isRestricted]);
const closeAnnouncement = (hash: string) => {
setClosedAnnouncements([...closedAnnouncements, hash]);
setAnnouncements(() => {
return announcements?.map((a) => {
if (a.hash === hash) {
return { ...a, isOpen: false };
}
return a;
});
});
if (!announcements) return;
const updated = announcements.map((a) =>
a.hash === hash ? { ...a, isOpen: false } : a,
);
const closedAnnouncements = updated
.filter((a) => !a.isOpen)
.map((a) => a.hash);
saveAnnouncements(closedAnnouncements);
setAnnouncements(updated);
};
useEffect(() => {
const isAnnouncementOpen = announcements?.some((a) => a.isOpen);
if (isAnnouncementOpen) {
setHeight(bannerHeight);
} else {
setHeight(0);
}
}, [announcements]);
const bannerHeight = announcements?.some((a) => a.isOpen) ? BANNER_HEIGHT : 0;
return (
<AnnouncementContext.Provider
value={{
bannerHeight: height,
bannerHeight,
announcements,
closeAnnouncement,
setAnnouncements,
@@ -110,6 +155,4 @@ export default function AnnouncementProvider({ children }: Readonly<Props>) {
);
}
export const useAnnouncement = () => {
return React.useContext(AnnouncementContext);
};
export const useAnnouncement = () => useContext(AnnouncementContext);

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

View File

@@ -17,3 +17,9 @@ export interface ApiError {
code: number;
message: string;
}
export interface VersionInfo {
management_current_version: string;
management_available_version: string;
dashboard_available_version: string;
}

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

@@ -17,6 +17,51 @@ export interface User {
idp_id?: string;
}
export interface UserInviteCreateRequest {
email: string;
name: string;
role: string;
auto_groups: string[];
expires_in?: number;
}
export interface UserInvite {
id: string;
email: string;
name: string;
role: string;
auto_groups: string[];
expires_at: string;
created_at: string;
expired: boolean;
invite_token?: string;
}
export interface UserInviteInfo {
email: string;
name: string;
expires_at: string;
valid: boolean;
invited_by: string;
}
export interface UserInviteAcceptRequest {
password: string;
}
export interface UserInviteAcceptResponse {
success: boolean;
}
export interface UserInviteRegenerateRequest {
expires_in?: number;
}
export interface UserInviteRegenerateResponse {
invite_token: string;
invite_expires_at: string;
}
export enum Role {
User = "user",
Admin = "admin",

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

@@ -14,6 +14,7 @@ import SettingsIcon from "@/assets/icons/SettingsIcon";
import SetupKeysIcon from "@/assets/icons/SetupKeysIcon";
import TeamIcon from "@/assets/icons/TeamIcon";
import SidebarItem from "@/components/SidebarItem";
import { NavigationVersionInfo } from "@/components/VersionInfo";
import { useAnnouncement } from "@/contexts/AnnouncementProvider";
import { useApplicationContext } from "@/contexts/ApplicationProvider";
import { usePermissions } from "@/contexts/PermissionsProvider";
@@ -143,6 +144,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
@@ -195,6 +202,7 @@ export default function Navigation({
/>
</SidebarItemGroup>
</div>
<NavigationVersionInfo />
</div>
</ScrollArea>
</div>

View File

@@ -277,6 +277,50 @@ 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>
);
/**
* User Invite Link
*/
if (event.activity_code == "user.invite.link.create")
return (
<div className={"inline"}>
Invite link was created for <Value>{event.meta.username}</Value>{" "}
<Value>{event.meta.email}</Value>
</div>
);
if (event.activity_code == "user.invite.link.accept")
return (
<div className={"inline"}>
Invite link was accepted by <Value>{event.meta.username}</Value>{" "}
<Value>{event.meta.email}</Value>
</div>
);
if (event.activity_code == "user.invite.link.regenerate")
return (
<div className={"inline"}>
Invite link was regenerated for <Value>{event.meta.username}</Value>{" "}
<Value>{event.meta.email}</Value>
</div>
);
if (event.activity_code == "user.invite.link.delete")
return (
<div className={"inline"}>
Invite link was deleted for <Value>{event.meta.username}</Value>{" "}
<Value>{event.meta.email}</Value>
</div>
);
/**
* Service User
*/
@@ -693,6 +737,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

@@ -12,10 +12,11 @@ import {
import { notify } from "@components/Notification";
import Paragraph from "@components/Paragraph";
import { PeerGroupSelector } from "@components/PeerGroupSelector";
import { IconMailForward } from "@tabler/icons-react";
import { SegmentedTabs } from "@components/SegmentedTabs";
import { IconMailForward, IconLink, IconUserPlus } from "@tabler/icons-react";
import { useApiCall } from "@utils/api";
import { cn, validator } from "@utils/helpers";
import { CopyIcon, MailIcon, User2 } from "lucide-react";
import { AlarmClock, CopyIcon, MailIcon, User2 } from "lucide-react";
import Image from "next/image";
import React, { useMemo, useState } from "react";
import { useSWRConfig } from "swr";
@@ -25,28 +26,50 @@ import Avatar2 from "@/assets/avatars/030.jpg";
import Avatar3 from "@/assets/avatars/063.jpg";
import Avatar4 from "@/assets/avatars/086.jpg";
import { Group } from "@/interfaces/Group";
import { Role, User } from "@/interfaces/User";
import { Role, User, UserInvite } from "@/interfaces/User";
import useGroupHelper from "@/modules/groups/useGroupHelper";
import { UserRoleSelector } from "@/modules/users/UserRoleSelector";
import {isNetBirdHosted} from "@utils/netbird";
import { isNetBirdHosted } from "@utils/netbird";
type UserCreationMode = "create" | "invite";
type Props = {
children: React.ReactNode;
groups?: Group[];
};
const copyMessage = "Password was copied to your clipboard!";
const passwordCopyMessage = "Password was copied to your clipboard!";
const inviteLinkCopyMessage = "Invite link was copied to your clipboard!";
type SuccessData =
| { type: "password"; user: User }
| { type: "invite"; invite: UserInvite };
export default function UserInviteModal({ children, groups }: Readonly<Props>) {
const [open, setOpen] = useState(false);
const [successModal, setSuccessModal] = useState(false);
const [createdUser, setCreatedUser] = useState<User>();
const [successData, setSuccessData] = useState<SuccessData | null>(null);
const { mutate } = useSWRConfig();
const [, copyToClipboard] = useCopyToClipboard(createdUser?.password);
const handleOnSuccess = (user: User) => {
const isPasswordSuccess = successData?.type === "password";
const isInviteSuccess = successData?.type === "invite";
const getInviteFullUrl = () => {
if (!isInviteSuccess) return "";
const origin = typeof window !== "undefined" ? window.location.origin : "";
return `${origin}/invite?token=${successData.invite.invite_token}`;
};
const getCopyValue = () => {
if (successData?.type === "password") return successData.user.password;
if (successData?.type === "invite") return getInviteFullUrl();
return undefined;
};
const [, copyToClipboard] = useCopyToClipboard(getCopyValue());
const handleUserCreated = (user: User) => {
if (user.password) {
setCreatedUser(user);
setSuccessData({ type: "password", user });
setSuccessModal(true);
} else {
setOpen(false);
@@ -56,9 +79,22 @@ export default function UserInviteModal({ children, groups }: Readonly<Props>) {
}, 1000);
};
const handleInviteCreated = (invite: UserInvite) => {
setSuccessData({ type: "invite", invite });
setSuccessModal(true);
setTimeout(() => {
mutate("/users?service_user=false");
mutate("/users/invites");
}, 1000);
};
const handleCopyAndClose = () => {
copyToClipboard(copyMessage).then(() => {
setCreatedUser(undefined);
const message =
successData?.type === "password"
? passwordCopyMessage
: inviteLinkCopyMessage;
copyToClipboard(message).then(() => {
setSuccessData(null);
setSuccessModal(false);
setOpen(false);
});
@@ -68,14 +104,18 @@ export default function UserInviteModal({ children, groups }: Readonly<Props>) {
<>
<Modal open={open} onOpenChange={setOpen} key={open ? 1 : 0}>
<ModalTrigger asChild={true}>{children}</ModalTrigger>
<UserInviteModalContent onSuccess={handleOnSuccess} groups={groups} />
<UserInviteModalContent
onUserCreated={handleUserCreated}
onInviteCreated={handleInviteCreated}
groups={groups}
/>
</Modal>
<Modal
open={successModal}
onOpenChange={(open) => {
if (!open) {
setCreatedUser(undefined);
setSuccessData(null);
}
setSuccessModal(open);
setOpen(open);
@@ -85,7 +125,7 @@ export default function UserInviteModal({ children, groups }: Readonly<Props>) {
onEscapeKeyDown={(e) => e.preventDefault()}
onInteractOutside={(e) => e.preventDefault()}
onPointerDownOutside={(e) => e.preventDefault()}
maxWidthClass={"max-w-md"}
maxWidthClass={isInviteSuccess ? "max-w-xl" : "max-w-md"}
className={"mt-20"}
showClose={false}
>
@@ -93,20 +133,41 @@ export default function UserInviteModal({ children, groups }: Readonly<Props>) {
<div className={"flex flex-col items-center justify-center gap-3"}>
<div>
<h2 className={"text-2xl text-center mb-2"}>
User created successfully!
{isPasswordSuccess && "User created successfully!"}
{isInviteSuccess && "Invite link created!"}
</h2>
<Paragraph className={"mt-0 text-sm text-center"}>
This password will not be shown again, so be sure to copy it
and store in a secure location.
{isPasswordSuccess &&
"This password will not be shown again, so be sure to copy it and store in a secure location."}
{isInviteSuccess &&
"Share this link with the user. They will be able to set their own password."}
</Paragraph>
</div>
</div>
</div>
<div className={"px-8 pb-6"}>
<Code message={copyMessage}>
<Code.Line>{createdUser?.password || ""}</Code.Line>
<Code
message={
isPasswordSuccess ? passwordCopyMessage : inviteLinkCopyMessage
}
codeToCopy={getCopyValue()}
>
{isPasswordSuccess && (
<Code.Line>{successData.user.password}</Code.Line>
)}
{isInviteSuccess && (
<span className="break-all whitespace-normal block">
{getInviteFullUrl()}
</span>
)}
</Code>
{isInviteSuccess && (
<Paragraph className={"mt-3 text-xs text-nb-gray-400 text-center"}>
Expires on{" "}
{new Date(successData.invite.expires_at).toLocaleString()}
</Paragraph>
)}
</div>
<ModalFooter className={"items-center"}>
<Button
@@ -125,31 +186,38 @@ export default function UserInviteModal({ children, groups }: Readonly<Props>) {
}
type ModalProps = {
onSuccess: (user: User) => void;
onUserCreated: (user: User) => void;
onInviteCreated: (invite: UserInvite) => void;
groups?: Group[];
};
export function UserInviteModalContent({
onSuccess,
onUserCreated,
onInviteCreated,
groups = [],
}: Readonly<ModalProps>) {
const userRequest = useApiCall<User>("/users");
const inviteRequest = useApiCall<UserInvite>("/users/invites");
const { mutate } = useSWRConfig();
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [role, setRole] = useState("user");
const [expiresIn, setExpiresIn] = useState("3");
const [selectedGroups, setSelectedGroups, { save: saveGroups }] =
useGroupHelper({
initial: groups,
});
const sendInvite = async () => {
const isCloud = isNetBirdHosted();
const [mode, setMode] = useState<UserCreationMode>("invite");
const createUser = async () => {
const groups = await saveGroups();
const groupIds = groups.map((group) => group.id) as string[];
notify({
title: "User Invitation",
description: `${name} was invited to join your network.`,
title: "Create User",
description: `Creating user account for ${name}...`,
promise: userRequest
.post({
name,
@@ -160,11 +228,46 @@ export function UserInviteModalContent({
})
.then((user) => {
mutate("/users?service_user=false");
onSuccess && onSuccess(user);
onUserCreated && onUserCreated(user);
}),
loadingMessage: "Sending invite...",
loadingMessage: "Creating user...",
});
};
const createInvite = async () => {
const groups = await saveGroups();
const groupIds = groups.map((group) => group.id) as string[];
notify({
title: "Create Invite",
description: `Creating invite link for ${name}...`,
promise: inviteRequest
.post({
name,
email,
role,
auto_groups: groupIds,
expires_in: parseInt(expiresIn || "3") * 24 * 60 * 60, // Days to seconds
})
.then((invite) => {
mutate("/users?service_user=false");
onInviteCreated && onInviteCreated(invite);
}),
loadingMessage: "Creating invite...",
});
};
const handleSubmit = async () => {
if (isCloud) {
await createUser();
} else {
if (mode === "create") {
await createUser();
} else {
await createInvite();
}
}
};
const isValidEmail = useMemo(() => {
return email.length > 0 && validator.isValidEmail(email);
}, [email]);
@@ -173,6 +276,33 @@ export function UserInviteModalContent({
return name.length === 0 || !isValidEmail;
}, [name, isValidEmail]);
const getTitle = () => {
if (isCloud) return "Invite User";
return mode === "create" ? "Create User" : "Invite User";
};
const getDescription = () => {
if (isCloud) return "Invite a user to your network and set their permissions.";
if (mode === "create") {
return "Create a NetBird user account with email and password.";
}
return "Generate an invite link that the user can use to set their own password.";
};
const getButtonText = () => {
if (isCloud) return "Send Invitation";
return mode === "create" ? "Create User" : "Create Invite Link";
};
const getButtonIcon = () => {
if (isCloud) return <IconMailForward size={16} />;
return mode === "create" ? (
<IconUserPlus size={16} />
) : (
<IconLink size={16} />
);
};
return (
<ModalContent maxWidthClass={"max-w-lg relative"} showClose={true}>
<div
@@ -193,15 +323,31 @@ export function UserInviteModalContent({
"mx-auto text-center flex flex-col items-center justify-center mt-6"
}
>
<h2 className={"text-lg my-0 leading-[1.5 text-center]"}>
{isNetBirdHosted() ? "Invite User" : "Create User"}
</h2>
<h2 className={"text-lg my-0 leading-[1.5 text-center]"}>{getTitle()}</h2>
<Paragraph className={cn("text-sm text-center max-w-xs")}>
{isNetBirdHosted() ? "Invite a user to your network and set their permissions." : "Create a NetBird user account with email and password."}
{getDescription()}
</Paragraph>
</div>
<div className={"px-8 py-3 flex flex-col gap-6 mt-4"}>
<div className={"px-8 py-3 flex flex-col gap-6 mt-4 relative z-10"}>
{!isCloud && (
<SegmentedTabs
value={mode}
onChange={(value) => setMode(value as UserCreationMode)}
>
<SegmentedTabs.List className="rounded-lg border">
<SegmentedTabs.Trigger value="invite">
<IconLink size={16} />
Invite User
</SegmentedTabs.Trigger>
<SegmentedTabs.Trigger value="create">
<IconUserPlus size={16} />
Create User
</SegmentedTabs.Trigger>
</SegmentedTabs.List>
</SegmentedTabs>
)}
<div className={"flex flex-col gap-4"}>
<Input
customPrefix={
@@ -230,6 +376,26 @@ export function UserInviteModalContent({
onChange={setRole}
hideOwner={true}
/>
{!isCloud && mode === "invite" && (
<div className={"flex justify-between mt-3"}>
<div>
<Label>Expires in</Label>
<HelpText>Days until the invite expires.</HelpText>
</div>
<Input
maxWidthClass={"max-w-[200px]"}
placeholder={"3"}
min={1}
value={expiresIn}
type={"number"}
onChange={(e) => setExpiresIn(e.target.value)}
customPrefix={
<AlarmClock size={16} className={"text-nb-gray-300"} />
}
customSuffix={"Day(s)"}
/>
</div>
)}
</div>
<div className={"mb-4"}>
@@ -252,10 +418,10 @@ export function UserInviteModalContent({
variant={"primary"}
className={"w-full"}
disabled={isDisabled}
onClick={sendInvite}
onClick={handleSubmit}
>
{isNetBirdHosted() ? "Send Invitation" : "Create User"}
<IconMailForward size={16} />
{getButtonText()}
{getButtonIcon()}
</Button>
</ModalFooter>
</ModalContent>

View File

@@ -0,0 +1,528 @@
import Button from "@components/Button";
import Code from "@components/Code";
import InlineLink from "@components/InlineLink";
import { Modal, ModalContent, ModalFooter } from "@components/modal/Modal";
import Paragraph from "@components/Paragraph";
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 MultipleGroups from "@components/ui/MultipleGroups";
import Skeleton from "react-loading-skeleton";
import { ColumnDef, SortingState } from "@tanstack/react-table";
import useFetchApi, { useApiCall } from "@utils/api";
import { notify } from "@components/Notification";
import { RefreshCw } from "lucide-react";
import { isNetBirdHosted } from "@utils/netbird";
import dayjs from "dayjs";
import {
Cog,
CopyIcon,
CreditCardIcon,
ExternalLinkIcon,
EyeIcon,
Link2,
MailPlus,
NetworkIcon,
Trash2,
User2,
} from "lucide-react";
import NetBirdIcon from "@/assets/icons/NetBirdIcon";
import Badge from "@components/Badge";
import { usePathname } from "next/navigation";
import React, { useMemo, useState } from "react";
import { useSWRConfig } from "swr";
import { useDialog } from "@/contexts/DialogProvider";
import { useGroups } from "@/contexts/GroupsProvider";
import { usePermissions } from "@/contexts/PermissionsProvider";
import useCopyToClipboard from "@/hooks/useCopyToClipboard";
import { useLocalStorage } from "@/hooks/useLocalStorage";
import { cn, generateColorFromString } from "@utils/helpers";
import { Group } from "@/interfaces/Group";
import { Role, UserInvite, UserInviteRegenerateResponse } from "@/interfaces/User";
import UserInviteModal from "@/modules/users/UserInviteModal";
import { useAccount } from "@/modules/account/useAccount";
// Name cell for invites - same styling as UserNameCell but for invites
function InviteNameCell({ invite }: { invite: UserInvite }) {
return (
<div
className={cn("flex gap-4 px-2 py-1 items-center")}
data-cy={"invite-name-cell"}
>
<div
className={
"w-10 h-10 rounded-full relative flex items-center justify-center text-white uppercase text-md font-medium bg-nb-gray-900"
}
style={{
color: generateColorFromString(invite.name || invite.email),
}}
>
{invite?.name?.charAt(0) || invite?.email?.charAt(0)}
</div>
<div className={"flex flex-col justify-center"}>
<span className={cn("text-base font-medium flex items-center gap-3")}>
{invite.name}
</span>
<span className={cn("text-sm text-nb-gray-400")}>{invite.email}</span>
</div>
</div>
);
}
// Role cell for invites - same styling as UserRoleCell but for invites
function InviteRoleCell({ invite }: { invite: UserInvite }) {
const role = invite.role as Role;
return (
<div className={cn("flex gap-3 items-center text-nb-gray-200")}>
<Badge variant={role === "owner" ? "netbird" : "gray"}>
{role === Role.User && (
<>
<User2 size={14} />
User
</>
)}
{role === Role.Admin && (
<>
<Cog size={14} />
Admin
</>
)}
{role === Role.Owner && (
<>
<NetBirdIcon size={14} />
Owner
</>
)}
{role === Role.BillingAdmin && (
<>
<CreditCardIcon size={14} />
Billing Admin
</>
)}
{role === Role.Auditor && (
<>
<EyeIcon size={14} />
Auditor
</>
)}
{role === Role.NetworkAdmin && (
<>
<NetworkIcon size={14} />
Network Admin
</>
)}
</Badge>
</div>
);
}
// Regenerate cell for invites - button to regenerate invite link with modal
function InviteRegenerateCell({ invite }: { invite: UserInvite }) {
const { mutate } = useSWRConfig();
const { permission } = usePermissions();
const [modalOpen, setModalOpen] = useState(false);
const [regeneratedData, setRegeneratedData] = useState<UserInviteRegenerateResponse | null>(null);
const regenerateRequest = useApiCall<UserInviteRegenerateResponse>(
`/users/invites/${invite.id}/regenerate`,
);
const getInviteFullUrl = () => {
if (!regeneratedData) return "";
const origin = typeof window !== "undefined" ? window.location.origin : "";
return `${origin}/invite?token=${regeneratedData.invite_token}`;
};
const [, copyToClipboard] = useCopyToClipboard(getInviteFullUrl());
const handleRegenerate = async () => {
notify({
title: "Regenerate Invite",
description: `Regenerating invite link for ${invite.name}...`,
promise: regenerateRequest.post({}).then((response) => {
setRegeneratedData(response);
setModalOpen(true);
mutate("/users/invites");
}),
loadingMessage: "Regenerating...",
});
};
const handleCopyAndClose = () => {
copyToClipboard("Invite link was copied to your clipboard!").then(() => {
setRegeneratedData(null);
setModalOpen(false);
});
};
return (
<>
<div className={"flex"}>
<Button
variant="secondary"
size="xs"
onClick={handleRegenerate}
disabled={!permission.users.update}
>
<RefreshCw size={14} />
Regenerate
</Button>
</div>
<Modal
open={modalOpen}
onOpenChange={(open) => {
if (!open) {
setRegeneratedData(null);
}
setModalOpen(open);
}}
>
<ModalContent
maxWidthClass={"max-w-xl"}
className={"mt-20"}
showClose={true}
>
<div className={"pb-6 px-8"}>
<div className={"flex flex-col items-center justify-center gap-3"}>
<div>
<h2 className={"text-2xl text-center mb-2"}>
Invite link regenerated!
</h2>
<Paragraph className={"mt-0 text-sm text-center"}>
Share this link with the user. They will be able to set their own password.
</Paragraph>
</div>
</div>
</div>
<div className={"px-8 pb-6"}>
<Code
message={"Invite link was copied to your clipboard!"}
codeToCopy={getInviteFullUrl()}
>
<span className="break-all whitespace-normal block">
{getInviteFullUrl()}
</span>
</Code>
{regeneratedData && (
<Paragraph className={"mt-3 text-xs text-nb-gray-400 text-center"}>
Expires on{" "}
{new Date(regeneratedData.invite_expires_at).toLocaleString()}
</Paragraph>
)}
</div>
<ModalFooter className={"items-center"}>
<Button
variant={"primary"}
className={"w-full"}
onClick={handleCopyAndClose}
>
<CopyIcon size={14} />
Copy & Close
</Button>
</ModalFooter>
</ModalContent>
</Modal>
</>
);
}
// Groups cell for invites - read-only display of auto_groups
function InviteGroupCell({ invite }: { invite: UserInvite }) {
const { groups, isLoading } = useGroups();
const foundGroups = useMemo(() => {
if (isLoading || !groups) return [];
return (invite.auto_groups || [])
.map((groupId) => groups.find((g) => g?.id === groupId))
.filter((g): g is Group => g !== undefined);
}, [invite.auto_groups, groups, isLoading]);
if (isLoading) {
return (
<div className={"flex gap-2"}>
<Skeleton height={34} width={90} />
<Skeleton height={34} width={45} />
</div>
);
}
return (
<MultipleGroups
groups={foundGroups}
label={"Auto-assigned Groups"}
/>
);
}
// Status cell for invites - shows Valid/Expired based on expired field
function InviteStatusCell({ invite }: { invite: UserInvite }) {
const isExpired = invite.expired;
const text = isExpired ? "Expired" : "Valid";
const color = isExpired ? "bg-red-500" : "bg-green-500";
return (
<div
className={cn("flex gap-2.5 items-center text-nb-gray-300 text-sm")}
data-cy={"invite-status-cell"}
>
<span className={cn("h-2 w-2 rounded-full", color)}></span>
{text}
</div>
);
}
// Action cell for invites - delete invite
function InviteActionCell({ invite }: { invite: UserInvite }) {
const { confirm } = useDialog();
const { permission } = usePermissions();
const inviteRequest = useApiCall<UserInvite>("/users/invites");
const { mutate } = useSWRConfig();
const deleteInvite = async () => {
const name = invite.name || invite.email || "Invite";
notify({
title: `'${name}' deleted`,
description: "Invite was successfully deleted.",
promise: inviteRequest.del("", `/${invite.id}`).then(() => {
mutate("/users/invites");
}),
loadingMessage: "Deleting the invite...",
});
};
const openConfirm = async () => {
const name = invite.name || invite.email || "Invite";
const choice = await confirm({
title: `Delete invite for '${name}'?`,
description:
"Deleting this invite will revoke the invite link. The user will no longer be able to join using this invite.",
confirmText: "Delete",
cancelText: "Cancel",
maxWidthClass: "max-w-md",
type: "danger",
});
if (!choice) return;
deleteInvite().then();
};
return (
<div className={"flex justify-end pr-4 items-center gap-2"}>
<Button
variant={"danger-outline"}
size={"sm"}
onClick={openConfirm}
data-cy={"delete-invite"}
disabled={!permission.users.delete}
>
<Trash2 size={16} />
Delete
</Button>
</div>
);
}
export const InvitesTableColumns: ColumnDef<UserInvite>[] = [
{
accessorKey: "name",
header: ({ column }) => {
return <DataTableHeader column={column}>Name</DataTableHeader>;
},
accessorFn: (row) => row.name + " " + row.email,
sortingFn: "text",
cell: ({ row }) => <InviteNameCell invite={row.original} />,
},
{
accessorKey: "role",
header: ({ column }) => {
return <DataTableHeader column={column}>Role</DataTableHeader>;
},
sortingFn: "text",
cell: ({ row }) => <InviteRoleCell invite={row.original} />,
},
{
accessorKey: "expired",
header: ({ column }) => {
return <DataTableHeader column={column}>Status</DataTableHeader>;
},
sortingFn: "basic",
cell: ({ row }) => <InviteStatusCell invite={row.original} />,
},
{
accessorKey: "auto_groups",
header: ({ column }) => {
return <DataTableHeader column={column}>Groups</DataTableHeader>;
},
sortingFn: "text",
cell: ({ row }) => <InviteGroupCell invite={row.original} />,
},
{
id: "regenerate",
header: ({ column }) => {
return <DataTableHeader column={column}>Regenerate</DataTableHeader>;
},
cell: ({ row }) => <InviteRegenerateCell invite={row.original} />,
},
{
accessorKey: "expires_at",
header: ({ column }) => {
return <DataTableHeader column={column}>Expires</DataTableHeader>;
},
sortingFn: "datetime",
cell: ({ row }) => (
<span className="text-nb-gray-400">
{dayjs(row.original.expires_at).format("D MMM, YYYY")}
</span>
),
},
{
accessorKey: "id",
header: "",
sortingFn: "text",
cell: ({ row }) => <InviteActionCell invite={row.original} />,
},
];
type Props = {
headingTarget?: HTMLHeadingElement | null;
onShowUsers?: () => void;
};
export default function UserInvitesTable({
headingTarget,
onShowUsers,
}: Readonly<Props>) {
useFetchApi("/groups");
const { data: invites, isLoading } = useFetchApi<UserInvite[]>("/users/invites");
const { mutate } = useSWRConfig();
const path = usePathname();
// Default sorting state of the table
const [sorting, setSorting] = useLocalStorage<SortingState>(
"netbird-table-sort-invites" + path,
[
{
id: "is_current",
desc: true,
},
{
id: "name",
desc: true,
},
],
);
return (
<DataTable
headingTarget={headingTarget}
isLoading={isLoading}
text={"Invites"}
sorting={sorting}
setSorting={setSorting}
columns={InvitesTableColumns}
data={invites}
searchPlaceholder={"Search by name or email..."}
getStartedCard={
<GetStartedTest
icon={
<SquareIcon
icon={<Link2 className={"fill-nb-gray-200"} size={20} />}
color={"gray"}
size={"large"}
/>
}
title={"No Pending Invites"}
description={
"There are no pending invites. Create an invite to add users to your network."
}
button={
<div className={"flex flex-col items-center justify-center"}>
<InviteUserButton show={true} />
</div>
}
learnMore={
<>
Learn more about
<InlineLink
href={
"https://docs.netbird.io/how-to/add-users-to-your-network"
}
target={"_blank"}
>
Users
<ExternalLinkIcon size={12} />
</InlineLink>
</>
}
/>
}
rightSide={() => (
<InviteUserButton
show={invites && invites?.length > 0}
className={"ml-auto"}
/>
)}
>
{(table) => {
return (
<>
<DataTableRowsPerPage table={table} disabled={invites?.length == 0} />
<DataTableRefreshButton
isDisabled={invites?.length == 0}
onClick={() => {
mutate("/users/invites");
}}
/>
<Button variant={"secondary"} onClick={onShowUsers}>
<User2 size={14} />
Show Users
</Button>
</>
);
}}
</DataTable>
);
}
type InviteUserButtonProps = {
show?: boolean;
className?: string;
groups?: Group[];
};
export const InviteUserButton = ({
show = false,
className,
groups,
}: InviteUserButtonProps) => {
const { permission } = usePermissions();
const account = useAccount();
if (!show) return null;
// On cloud: always show "Invite User"
// On self-hosted: only show when embedded_idp_enabled is true
const isCloud = isNetBirdHosted();
const embeddedIdpEnabled = account?.settings.embedded_idp_enabled;
if (!isCloud && !embeddedIdpEnabled) return null;
return (
<UserInviteModal groups={groups}>
<Button
variant={"primary"}
className={className}
disabled={!permission.users.create}
>
<MailPlus size={16} />
{isCloud ? "Invite User" : "Add User"}
</Button>
</UserInviteModal>
);
};

View File

@@ -7,6 +7,7 @@ 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 { NotificationCountBadge } from "@components/ui/NotificationCountBadge";
import {
ColumnDef,
Row,
@@ -17,15 +18,15 @@ import {
import useFetchApi from "@utils/api";
import { isNetBirdHosted } from "@utils/netbird";
import dayjs from "dayjs";
import { ExternalLinkIcon, MailPlus } from "lucide-react";
import { ExternalLinkIcon, Link2, MailPlus } from "lucide-react";
import { usePathname, useRouter } from "next/navigation";
import React from "react";
import React, { useState } from "react";
import { useSWRConfig } from "swr";
import TeamIcon from "@/assets/icons/TeamIcon";
import { usePermissions } from "@/contexts/PermissionsProvider";
import { useLocalStorage } from "@/hooks/useLocalStorage";
import { Group } from "@/interfaces/Group";
import { User } from "@/interfaces/User";
import { User, UserInvite } from "@/interfaces/User";
import LastTimeRow from "@/modules/common-table-rows/LastTimeRow";
import { PendingApprovalFilter } from "@/modules/users/PendingApprovalFilter";
import UserActionCell from "@/modules/users/table-cells/UserActionCell";
@@ -35,6 +36,7 @@ import UserNameCell from "@/modules/users/table-cells/UserNameCell";
import UserRoleCell from "@/modules/users/table-cells/UserRoleCell";
import UserStatusCell from "@/modules/users/table-cells/UserStatusCell";
import UserInviteModal from "@/modules/users/UserInviteModal";
import UserInvitesTable from "@/modules/users/UserInvitesTable";
import { useAccount } from "@/modules/account/useAccount";
export const UsersTableColumns: ColumnDef<User>[] = [
@@ -142,6 +144,21 @@ export default function UsersTable({
useFetchApi("/groups");
const { mutate } = useSWRConfig();
const path = usePathname();
const account = useAccount();
const isCloud = isNetBirdHosted();
const embeddedIdpEnabled = account?.settings.embedded_idp_enabled;
const showInvitesToggle = !isCloud && embeddedIdpEnabled;
const { data: invites } = useFetchApi<UserInvite[]>(
"/users/invites",
false,
true,
showInvitesToggle,
);
const validInvitesCount = invites?.filter((i) => !i.expired).length ?? 0;
const [showInvites, setShowInvites] = useState(false);
// Default sorting state of the table
const [sorting, setSorting] = useLocalStorage<SortingState>(
@@ -162,6 +179,15 @@ export default function UsersTable({
const router = useRouter();
const { permission } = usePermissions();
if (showInvites) {
return (
<UserInvitesTable
headingTarget={headingTarget}
onShowUsers={() => setShowInvites(false)}
/>
);
}
return (
<DataTable
headingTarget={headingTarget}
@@ -256,6 +282,16 @@ export default function UsersTable({
mutate("/groups");
}}
/>
{showInvitesToggle && (
<Button
variant={"secondary"}
onClick={() => setShowInvites(true)}
>
<Link2 size={14} />
Show Invites
<NotificationCountBadge count={validInvitesCount} />
</Button>
)}
</>
);
}}
@@ -294,8 +330,9 @@ export const InviteUserButton = ({
disabled={!permission.users.create}
>
<MailPlus size={16} />
{isCloud ? "Invite User" : "Create User"}
{isCloud ? "Invite User" : "Add User"}
</Button>
</UserInviteModal>
);
};

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

@@ -5,6 +5,7 @@ import {
SetupRequest,
SetupResponse,
} from "@/interfaces/Instance";
import { UserInviteInfo, UserInviteAcceptResponse } from "@/interfaces/User";
const config = loadConfig();
@@ -52,3 +53,18 @@ export async function fetchInstanceStatus(): Promise<InstanceStatus> {
export async function submitSetup(data: SetupRequest): Promise<SetupResponse> {
return unauthenticatedRequest<SetupResponse>("POST", "/setup", data);
}
export async function fetchInviteInfo(token: string): Promise<UserInviteInfo> {
return unauthenticatedRequest<UserInviteInfo>("GET", `/users/invites/${token}`);
}
export async function acceptInvite(
token: string,
password: string,
): Promise<UserInviteAcceptResponse> {
return unauthenticatedRequest<UserInviteAcceptResponse>(
"POST",
`/users/invites/${token}/accept`,
{ password },
);
}

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