Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
818ba5daa4 | ||
|
|
3a30f76629 | ||
|
|
34dc21c89d | ||
|
|
2e37703622 | ||
|
|
8aec338c43 | ||
|
|
f4f0c240fd | ||
|
|
04e22a3c7e | ||
|
|
54ef076303 | ||
|
|
92676b6c38 | ||
|
|
3affa8908f |
@@ -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
|
||||
|
||||
@@ -15,4 +15,4 @@
|
||||
"googleAnalyticsID": "$NETBIRD_GOOGLE_ANALYTICS_ID",
|
||||
"googleTagManagerID": "$NETBIRD_GOOGLE_TAG_MANAGER_ID",
|
||||
"wasmPath": "$NETBIRD_WASM_PATH"
|
||||
}
|
||||
}
|
||||
|
||||
30
package-lock.json
generated
30
package-lock.json
generated
@@ -56,6 +56,7 @@
|
||||
"flowbite": "^1.8.1",
|
||||
"flowbite-react": "^0.6.4",
|
||||
"framer-motion": "^10.16.4",
|
||||
"ip-address": "^10.1.0",
|
||||
"ip-cidr": "^3.1.0",
|
||||
"js-cookie": "^3.0.5",
|
||||
"lodash": "^4.17.21",
|
||||
@@ -6598,15 +6599,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/ip-address": {
|
||||
"version": "7.1.0",
|
||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-7.1.0.tgz",
|
||||
"integrity": "sha512-V9pWC/VJf2lsXqP7IWJ+pe3P1/HCYGBMZrrnT62niLGjAfCbeiwXMUxaeHvnVlz19O27pvXP4azs+Pj/A0x+SQ==",
|
||||
"dependencies": {
|
||||
"jsbn": "1.1.0",
|
||||
"sprintf-js": "1.1.2"
|
||||
},
|
||||
"version": "10.1.0",
|
||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz",
|
||||
"integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
"node": ">= 12"
|
||||
}
|
||||
},
|
||||
"node_modules/ip-cidr": {
|
||||
@@ -6621,6 +6619,19 @@
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ip-cidr/node_modules/ip-address": {
|
||||
"version": "7.1.0",
|
||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-7.1.0.tgz",
|
||||
"integrity": "sha512-V9pWC/VJf2lsXqP7IWJ+pe3P1/HCYGBMZrrnT62niLGjAfCbeiwXMUxaeHvnVlz19O27pvXP4azs+Pj/A0x+SQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"jsbn": "1.1.0",
|
||||
"sprintf-js": "1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/is-array-buffer": {
|
||||
"version": "3.0.5",
|
||||
"resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz",
|
||||
@@ -8561,7 +8572,8 @@
|
||||
"node_modules/sprintf-js": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.2.tgz",
|
||||
"integrity": "sha512-VE0SOVEHCk7Qc8ulkWw3ntAzXuqf7S2lvwQaDLRnUeIEaKNQJzV6BwmLKhOqT61aGhfUMrXeaBk+oDGCzvhcug=="
|
||||
"integrity": "sha512-VE0SOVEHCk7Qc8ulkWw3ntAzXuqf7S2lvwQaDLRnUeIEaKNQJzV6BwmLKhOqT61aGhfUMrXeaBk+oDGCzvhcug==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/stable-hash": {
|
||||
"version": "0.0.5",
|
||||
|
||||
@@ -61,6 +61,7 @@
|
||||
"flowbite": "^1.8.1",
|
||||
"flowbite-react": "^0.6.4",
|
||||
"framer-motion": "^10.16.4",
|
||||
"ip-address": "^10.1.0",
|
||||
"ip-cidr": "^3.1.0",
|
||||
"js-cookie": "^3.0.5",
|
||||
"lodash": "^4.17.21",
|
||||
|
||||
@@ -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>
|
||||
|
||||
8
src/app/(dashboard)/dns/zones/layout.tsx
Normal file
8
src/app/(dashboard)/dns/zones/layout.tsx
Normal 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;
|
||||
70
src/app/(dashboard)/dns/zones/page.tsx
Normal file
70
src/app/(dashboard)/dns/zones/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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) +
|
||||
")"
|
||||
}
|
||||
/>
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 />
|
||||
);
|
||||
}
|
||||
|
||||
19
src/assets/icons/DNSZoneIcon.tsx
Normal file
19
src/assets/icons/DNSZoneIcon.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
30
src/assets/icons/SlackIcon.tsx
Normal file
30
src/assets/icons/SlackIcon.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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<
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
36
src/components/TooltipListItem.tsx
Normal file
36
src/components/TooltipListItem.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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,
|
||||
)}
|
||||
|
||||
145
src/components/ui/HelpAndSupportButton.tsx
Normal file
145
src/components/ui/HelpAndSupportButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -7,8 +7,8 @@ import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
const initialAnnouncements: Announcement[] = [
|
||||
{
|
||||
tag: "New",
|
||||
text: "NetBird v0.61 Released - Granular SSH Access Control and Automatic Updates",
|
||||
link: "https://netbird.io/knowledge-hub/granular-ssh-access-automatic-updates",
|
||||
text: "NetBird v0.62 Released - Local Users and Simplified IdP Integration",
|
||||
link: "https://netbird.io/knowledge-hub/local-users-simplified-idp",
|
||||
linkText: "Read Release Article",
|
||||
variant: "important", // "default" or "important"
|
||||
isExternal: true,
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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
25
src/interfaces/DNS.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
export interface DNSZone {
|
||||
id?: string;
|
||||
name: string;
|
||||
domain: string;
|
||||
enabled: boolean;
|
||||
enable_search_domain: boolean;
|
||||
distribution_groups: string[];
|
||||
records?: DNSRecord[];
|
||||
groups_search?: string;
|
||||
}
|
||||
|
||||
export interface DNSRecord {
|
||||
id?: string;
|
||||
name: string;
|
||||
type: "A" | "AAAA" | "CNAME";
|
||||
content: string;
|
||||
ttl: number;
|
||||
}
|
||||
|
||||
export type DNSRecordType = "A" | "AAAA" | "CNAME";
|
||||
|
||||
export const DNS_ZONE_DOCS_LINK =
|
||||
"https://docs.netbird.io/manage/dns/custom-zones";
|
||||
export const DNS_RECORDS_DOCS_LINK =
|
||||
"https://docs.netbird.io/manage/dns/custom-zones#adding-records-to-a-zone";
|
||||
23
src/interfaces/Job.ts
Normal file
23
src/interfaces/Job.ts
Normal 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;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -143,6 +143,12 @@ export default function Navigation({
|
||||
href={"/dns/nameservers"}
|
||||
visible={permission.nameservers.read}
|
||||
/>
|
||||
<SidebarItem
|
||||
label="Zones"
|
||||
isChild
|
||||
href={"/dns/zones"}
|
||||
visible={permission?.dns?.read}
|
||||
/>
|
||||
<SidebarItem
|
||||
label="DNS Settings"
|
||||
isChild
|
||||
|
||||
@@ -277,6 +277,14 @@ export default function ActivityDescription({ event }: Props) {
|
||||
</div>
|
||||
);
|
||||
|
||||
if (event.activity_code == "user.password.change")
|
||||
return (
|
||||
<div className={"inline"}>
|
||||
Password was changed for user <Value>{event.meta.username}</Value>{" "}
|
||||
<Value>{event.meta.email}</Value>
|
||||
</div>
|
||||
);
|
||||
|
||||
/**
|
||||
* Service User
|
||||
*/
|
||||
@@ -693,6 +701,16 @@ export default function ActivityDescription({ event }: Props) {
|
||||
</div>
|
||||
);
|
||||
|
||||
/**
|
||||
* Jobs
|
||||
*/
|
||||
|
||||
if (event.activity_code == "peer.job.create")
|
||||
return (<div className={"inline"}>
|
||||
Remote job <Value>{m.job_type}</Value> created for peer <Value>{m.for_peer_name}</Value>
|
||||
</div>
|
||||
)
|
||||
|
||||
if (event.activity_code == "account.settings.extra.flow.group.remove")
|
||||
return (
|
||||
<div className={"inline"}>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
@@ -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>[] = [
|
||||
{
|
||||
361
src/modules/dns/zones/DNSRecordModal.tsx
Normal file
361
src/modules/dns/zones/DNSRecordModal.tsx
Normal 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`;
|
||||
};
|
||||
225
src/modules/dns/zones/DNSZoneModal.tsx
Normal file
225
src/modules/dns/zones/DNSZoneModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
264
src/modules/dns/zones/DNSZonesProvider.tsx
Normal file
264
src/modules/dns/zones/DNSZonesProvider.tsx
Normal 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);
|
||||
40
src/modules/dns/zones/records/DNSRecordActionCell.tsx
Normal file
40
src/modules/dns/zones/records/DNSRecordActionCell.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
19
src/modules/dns/zones/records/DNSRecordContentCell.tsx
Normal file
19
src/modules/dns/zones/records/DNSRecordContentCell.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
17
src/modules/dns/zones/records/DNSRecordNameCell.tsx
Normal file
17
src/modules/dns/zones/records/DNSRecordNameCell.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
21
src/modules/dns/zones/records/DNSRecordTimeToLiveCell.tsx
Normal file
21
src/modules/dns/zones/records/DNSRecordTimeToLiveCell.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
20
src/modules/dns/zones/records/DNSRecordTypeCell.tsx
Normal file
20
src/modules/dns/zones/records/DNSRecordTypeCell.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
80
src/modules/dns/zones/records/DNSRecordsTable.tsx
Normal file
80
src/modules/dns/zones/records/DNSRecordsTable.tsx
Normal 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);
|
||||
58
src/modules/dns/zones/table/DNSZonesActionCell.tsx
Normal file
58
src/modules/dns/zones/table/DNSZonesActionCell.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
32
src/modules/dns/zones/table/DNSZonesActiveCell.tsx
Normal file
32
src/modules/dns/zones/table/DNSZonesActiveCell.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
60
src/modules/dns/zones/table/DNSZonesGroupCell.tsx
Normal file
60
src/modules/dns/zones/table/DNSZonesGroupCell.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
38
src/modules/dns/zones/table/DNSZonesNameCell.tsx
Normal file
38
src/modules/dns/zones/table/DNSZonesNameCell.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
47
src/modules/dns/zones/table/DNSZonesRecordsCell.tsx
Normal file
47
src/modules/dns/zones/table/DNSZonesRecordsCell.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
32
src/modules/dns/zones/table/DNSZonesSearchDomainCell.tsx
Normal file
32
src/modules/dns/zones/table/DNSZonesSearchDomainCell.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
303
src/modules/dns/zones/table/DNSZonesTable.tsx
Normal file
303
src/modules/dns/zones/table/DNSZonesTable.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
29
src/modules/groups/details/GroupDNSZonesSection.tsx
Normal file
29
src/modules/groups/details/GroupDNSZonesSection.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
);
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
194
src/modules/jobs/CreateDebugJobModal.tsx
Normal file
194
src/modules/jobs/CreateDebugJobModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
60
src/modules/jobs/table/JobOutputCell.tsx
Normal file
60
src/modules/jobs/table/JobOutputCell.tsx
Normal 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 />;
|
||||
};
|
||||
56
src/modules/jobs/table/JobParametersCell.tsx
Normal file
56
src/modules/jobs/table/JobParametersCell.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
30
src/modules/jobs/table/JobStatusCell.tsx
Normal file
30
src/modules/jobs/table/JobStatusCell.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
22
src/modules/jobs/table/JobTypeCell.tsx
Normal file
22
src/modules/jobs/table/JobTypeCell.tsx
Normal 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 />;
|
||||
};
|
||||
141
src/modules/jobs/table/PeerRemoteJobsTable.tsx
Normal file
141
src/modules/jobs/table/PeerRemoteJobsTable.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
105
src/modules/peer/PeerExpirationSettings.tsx
Normal file
105
src/modules/peer/PeerExpirationSettings.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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={
|
||||
|
||||
64
src/modules/peer/PeerRemoteJobsSection.tsx
Normal file
64
src/modules/peer/PeerRemoteJobsSection.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
87
src/modules/peer/RemoteJobDropdownButton.tsx
Normal file
87
src/modules/peer/RemoteJobDropdownButton.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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"} />;
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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",
|
||||
|
||||
194
src/modules/users/ChangePasswordModal.tsx
Normal file
194
src/modules/users/ChangePasswordModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -29,8 +29,9 @@ const config: Config = {
|
||||
"925": "#1e2123",
|
||||
"930": "#25282c",
|
||||
"935": "#1f2124",
|
||||
"940": "#1c1d21",
|
||||
"940": "#1c1e21",
|
||||
"950": "#181a1d",
|
||||
"960": "#15171a",
|
||||
},
|
||||
netbird: {
|
||||
DEFAULT: "#f68330",
|
||||
|
||||
Reference in New Issue
Block a user