Compare commits

..
141 changed files with 4381 additions and 5841 deletions

No files matched your search

+4 -1
View File
@@ -5,6 +5,9 @@ const nextConfig = {
unoptimized: true,
},
reactStrictMode: false,
experimental: {
externalDir: true,
},
env: {
APP_ENV: process.env.APP_ENV || "production",
NEXT_PUBLIC_DASHBOARD_VERSION:
@@ -12,4 +15,4 @@ const nextConfig = {
},
};
module.exports = nextConfig;
module.exports = nextConfig;
+2377 -70
View File
File diff suppressed because it is too large. Load diff
+6 -5
View File
@@ -18,6 +18,7 @@
"dependencies": {
"@axa-fr/react-oidc": "^7.26.3",
"@dagrejs/dagre": "^1.1.5",
"@netbirdio/explain": "^0.1.5",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-collapsible": "^1.1.12",
@@ -49,7 +50,7 @@
"@xterm/xterm": "^5.5.0",
"@xyflow/react": "^12.8.4",
"autoprefixer": "^10",
"chart.js": "^4.4.8",
"commander": "^14.0.3","chart.js": "^4.4.8",
"chroma-js": "^3.1.2",
"class-variance-authority": "^0.7.0",
"classnames": "^2.5.1",
@@ -68,7 +69,7 @@
"js-cookie": "^3.0.5",
"lodash": "^4.17.23",
"lucide-react": "^0.566.0",
"next": "16.1.7",
"next": "^16.1.7",
"next-themes": "^0.2.1",
"punycode": "^2.3.1",
"react": "^19.2.4",
@@ -80,8 +81,11 @@
"react-icons": "^5.5.0",
"react-jwt": "^1.2.0",
"react-loading-skeleton": "^3.3.1",
"react-markdown": "^10.1.0",
"react-responsive": "^9.0.2",
"react-virtuoso": "^4.9.0",
"remark-breaks": "^4.0.0",
"remark-gfm": "^4.0.1",
"sonner": "^2.0.7",
"swr": "^2.2.4",
"tailwind-merge": "^1.14.0",
@@ -89,9 +93,6 @@
"timescape": "^0.7.1",
"typescript": "^5"
},
"overrides": {
"minimatch": ">=10.2.1"
},
"devDependencies": {
"@faker-js/faker": "^9.5.1",
"@types/chroma-js": "^3.1.1",
+1 -4
View File
@@ -9,7 +9,6 @@ import { ExternalLinkIcon } from "lucide-react";
import ReverseProxyIcon from "@/assets/icons/ReverseProxyIcon";
import React, { useMemo } from "react";
import ActivityIcon from "@/assets/icons/ActivityIcon";
import PeersProvider from "@/contexts/PeersProvider";
import { usePermissions } from "@/contexts/PermissionsProvider";
import ServerPaginationProvider from "@/contexts/ServerPaginationProvider";
import PageContainer from "@/layouts/PageContainer";
@@ -73,9 +72,7 @@ export default function ProxyEventsPage() {
defaultPageSize={10}
defaultFilters={defaultFilters}
>
<PeersProvider>
<ReverseProxyEventsTable headingTarget={portalTarget} />
</PeersProvider>
<ReverseProxyEventsTable headingTarget={portalTarget} />
</ServerPaginationProvider>
</RestrictedAccess>
</PageContainer>
+9 -1
View File
@@ -2,6 +2,7 @@
import Breadcrumbs from "@components/Breadcrumbs";
import Button from "@components/Button";
import ExplainButton from "@components/ExplainButton";
import Card from "@components/Card";
import {
DropdownMenu,
@@ -99,6 +100,7 @@ function NetworkOverview({ network }: Readonly<{ network: Network }>) {
<PageContainer>
<NetworkAccessControlProvider>
<NetworkProvider network={network}>
<div>
<div className={"p-default py-6"}>
<Breadcrumbs>
<Breadcrumbs.Item
@@ -131,7 +133,10 @@ function NetworkOverview({ network }: Readonly<{ network: Network }>) {
description={network.description}
/>
</div>
<NetworkActions />
<div className="flex items-center gap-2">
<ExplainButton />
<NetworkActions />
</div>
</div>
</div>
@@ -192,6 +197,7 @@ function NetworkOverview({ network }: Readonly<{ network: Network }>) {
/>
</TabsContent>
</Tabs>
</div>
</NetworkProvider>
</NetworkAccessControlProvider>
</PageContainer>
@@ -282,6 +288,7 @@ function NetworkInformationCard({ network }: Readonly<{ network: Network }>) {
<Card.List>
<Card.ListItem
tooltip={false}
data-nb-explain="High Availability"
label={
<>
<ServerIcon size={16} />
@@ -327,6 +334,7 @@ function NetworkInformationCard({ network }: Readonly<{ network: Network }>) {
/>
<Card.ListItem
tooltip={false}
data-nb-explain="Active Policies"
label={
policyCount > 0 ? (
<>
+119 -103
View File
@@ -2,6 +2,7 @@
import Breadcrumbs from "@components/Breadcrumbs";
import Button from "@components/Button";
import { Callout } from "@components/Callout";
import Card from "@components/Card";
import HelpText from "@components/HelpText";
import { Input } from "@components/Input";
@@ -71,7 +72,6 @@ import ReverseProxiesProvider, {
useReverseProxies,
} from "@/contexts/ReverseProxiesProvider";
import { ReverseProxyFlatTargetsTabContent } from "@/modules/reverse-proxy/targets/flat/ReverseProxyFlatTargetsTabContent";
import { PeerEditIPModal } from "@/modules/peer/PeerEditIPModal";
import { PeerSSHToggle } from "@/modules/peer/PeerSSHToggle";
import { RDPButton } from "@/modules/remote-access/rdp/RDPButton";
import { SSHButton } from "@/modules/remote-access/ssh/SSHButton";
@@ -469,55 +469,31 @@ function PeerInformationCard({ peer }: Readonly<{ peer: Peer }>) {
const { update } = usePeer();
const { mutate } = useSWRConfig();
const [showEditIPModal, setShowEditIPModal] = useState(false);
const [showEditIPv6Modal, setShowEditIPv6Modal] = useState(false);
const { permission } = usePermissions();
const countryText = useMemo(() => {
return getRegionByPeer(peer);
}, [getRegionByPeer, peer]);
const handleSaveIP = (newIP: string) => {
notify({
title: peer.name,
description: "NetBird Peer IP was successfully updated",
promise: update({ ip: newIP }).then(() => {
mutate("/peers/" + peer.id);
setShowEditIPModal(false);
}),
loadingMessage: "Updating peer IP...",
});
};
const handleSaveIPv6 = (newIPv6: string) => {
notify({
title: peer.name,
description: "NetBird Peer IPv6 was successfully updated",
promise: update({ ipv6: newIPv6 }).then(() => {
mutate("/peers/" + peer.id);
setShowEditIPv6Modal(false);
}),
loadingMessage: "Updating peer IPv6...",
});
};
return (
<>
<PeerEditIPModal
version="v4"
currentIP={peer.ip}
open={showEditIPModal}
onOpenChange={setShowEditIPModal}
onSave={handleSaveIP}
key={showEditIPModal ? "v4-open" : "v4-closed"}
/>
<PeerEditIPModal
version="v6"
currentIP={peer.ipv6 || ""}
open={showEditIPv6Modal}
onOpenChange={setShowEditIPv6Modal}
onSave={handleSaveIPv6}
key={showEditIPv6Modal ? "v6-open" : "v6-closed"}
/>
<Modal open={showEditIPModal} onOpenChange={setShowEditIPModal}>
<EditIPModal
onSuccess={(newIP) => {
notify({
title: peer.name,
description: "Peer IP was successfully updated",
promise: update({ ip: newIP }).then(() => {
mutate("/peers/" + peer.id);
setShowEditIPModal(false);
}),
loadingMessage: "Updating peer IP...",
});
}}
peer={peer}
key={showEditIPModal ? 1 : 0}
/>
</Modal>
<Card className={"w-full xl:w-1/2"}>
<Card.List>
<Card.ListItem
@@ -526,48 +502,35 @@ function PeerInformationCard({ peer }: Readonly<{ peer: Peer }>) {
copyText={"NetBird IP Address"}
label={
<>
<MapPin size={16} className={"shrink-0"} />
<MapPin size={16} />
NetBird IP Address
</>
}
valueToCopy={peer.ip}
value={
<EditableValue
value={peer.ip}
canEdit={permission.peers.update}
onEdit={() => setShowEditIPModal(true)}
/>
<div className="flex items-center gap-2 justify-between w-full">
<span>{peer.ip}</span>
{permission.peers.update && (
<button
className="flex w-7 h-7 items-center justify-center gap-2 text-nb-gray-400 hover:text-neutral-100 transition-all hover:bg-nb-gray-800/60 rounded-md cursor-pointer"
onClick={(e) => {
e.stopPropagation();
setShowEditIPModal(true);
}}
>
<PencilIcon size={14} />
</button>
)}
</div>
}
/>
{peer.ipv6 && (
<Card.ListItem
copy
tooltip={false}
copyText={"NetBird IPv6 Address"}
label={
<>
<MapPin size={16} className={"shrink-0"} />
NetBird IPv6 Address
</>
}
valueToCopy={peer.ipv6}
value={
<EditableValue
value={peer.ipv6}
canEdit={permission.peers.update}
onEdit={() => setShowEditIPv6Modal(true)}
/>
}
/>
)}
<Card.ListItem
copy
copyText={"Public IP Address"}
label={
<>
<NetworkIcon size={16} className={"shrink-0"} />
<NetworkIcon size={16} />
Public IP Address
</>
}
@@ -579,7 +542,7 @@ function PeerInformationCard({ peer }: Readonly<{ peer: Peer }>) {
copyText={"DNS label"}
label={
<>
<Globe size={16} className={"shrink-0"} />
<Globe size={16} />
Domain Name
</>
}
@@ -597,7 +560,7 @@ function PeerInformationCard({ peer }: Readonly<{ peer: Peer }>) {
copyText={"Hostname"}
label={
<>
<MonitorSmartphoneIcon size={16} className={"shrink-0"} />
<MonitorSmartphoneIcon size={16} />
Hostname
</>
}
@@ -607,7 +570,7 @@ function PeerInformationCard({ peer }: Readonly<{ peer: Peer }>) {
<Card.ListItem
label={
<>
<FlagIcon size={16} className={"shrink-0"} />
<FlagIcon size={16} />
Region
</>
}
@@ -637,7 +600,7 @@ function PeerInformationCard({ peer }: Readonly<{ peer: Peer }>) {
<Card.ListItem
label={
<>
<Cpu size={16} className={"shrink-0"} />
<Cpu size={16} />
Operating System
</>
}
@@ -648,7 +611,7 @@ function PeerInformationCard({ peer }: Readonly<{ peer: Peer }>) {
<Card.ListItem
label={
<>
<Barcode size={16} className={"shrink-0"} />
<Barcode size={16} />
Serial Number
</>
}
@@ -660,7 +623,7 @@ function PeerInformationCard({ peer }: Readonly<{ peer: Peer }>) {
<Card.ListItem
label={
<>
<CalendarDays size={16} className={"shrink-0"} />
<CalendarDays size={16} />
Registered on
</>
}
@@ -676,7 +639,7 @@ function PeerInformationCard({ peer }: Readonly<{ peer: Peer }>) {
<Card.ListItem
label={
<>
<History size={16} className={"shrink-0"} />
<History size={16} />
Last seen
</>
}
@@ -693,7 +656,7 @@ function PeerInformationCard({ peer }: Readonly<{ peer: Peer }>) {
<Card.ListItem
label={
<>
<NetBirdIcon size={16} className={"shrink-0"} />
<NetBirdIcon size={16} />
Agent Version
</>
}
@@ -704,7 +667,7 @@ function PeerInformationCard({ peer }: Readonly<{ peer: Peer }>) {
<Card.ListItem
label={
<>
<NetBirdIcon size={16} className={"shrink-0"} />
<NetBirdIcon size={16} />
UI Version
</>
}
@@ -802,29 +765,82 @@ function EditNameModal({ onSuccess, peer, initialName }: Readonly<ModalProps>) {
);
}
function EditableValue({
value,
canEdit,
onEdit,
}: {
value: string;
canEdit: boolean;
onEdit: () => void;
}) {
interface EditIPModalProps {
onSuccess: (ip: string) => void;
peer: Peer;
}
function EditIPModal({ onSuccess, peer }: Readonly<EditIPModalProps>) {
const [ip, setIP] = useState(peer.ip);
const [error, setError] = useState("");
const validateIP = (ipAddress: string) => {
const ipRegex =
/^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/;
return ipRegex.test(ipAddress);
};
const isDisabled = useMemo(() => {
if (ip === peer.ip) return true;
const trimmedIP = trim(ip);
return trimmedIP.length === 0 || !validateIP(ip);
}, [ip, peer.ip]);
React.useEffect(() => {
switch (true) {
case ip === peer.ip:
setError("");
break;
case !validateIP(ip):
setError("Please enter a valid IP, e.g., 100.64.0.15");
break;
default:
setError("");
break;
}
}, [ip, peer.ip]);
return (
<div className="flex items-center gap-2 justify-between w-full">
<span>{value}</span>
{canEdit && (
<button
className="flex w-7 h-7 items-center justify-center gap-2 text-nb-gray-400 hover:text-neutral-100 transition-all hover:bg-nb-gray-800/60 rounded-md cursor-pointer"
onClick={(e) => {
e.stopPropagation();
onEdit();
}}
>
<PencilIcon size={14} />
</button>
)}
</div>
<ModalContent maxWidthClass={"max-w-md"}>
<form>
<ModalHeader
title={"Edit Peer IP Address"}
description={"Update the NetBird IP address for this peer."}
color={"blue"}
/>
<div className={"p-default flex flex-col gap-4"}>
<div>
<Input
placeholder={"e.g., 100.64.0.15"}
value={ip}
onChange={(e) => setIP(e.target.value)}
error={error}
/>
</div>
<Callout>Changes take effect when the peer reconnects.</Callout>
</div>
<ModalFooter className={"items-center"} separator={false}>
<div className={"flex gap-3 w-full justify-end"}>
<ModalClose asChild={true}>
<Button variant={"secondary"} className={"w-full"}>
Cancel
</Button>
</ModalClose>
<Button
variant={"primary"}
className={"w-full"}
onClick={() => onSuccess(ip)}
disabled={isDisabled}
>
Save
</Button>
</div>
</ModalFooter>
</form>
</ModalContent>
);
}
+114 -3
View File
@@ -1,5 +1,116 @@
import { redirect } from "next/navigation";
"use client";
export default function PeersIndex() {
redirect("/peers/users");
import Breadcrumbs from "@components/Breadcrumbs";
import InlineLink from "@components/InlineLink";
import Paragraph from "@components/Paragraph";
import SkeletonTable from "@components/skeletons/SkeletonTable";
import { usePortalElement } from "@hooks/usePortalElement";
import { ExternalLinkIcon } from "lucide-react";
import React, { lazy, Suspense } from "react";
import PeerIcon from "@/assets/icons/PeerIcon";
import PeersProvider, { usePeers } from "@/contexts/PeersProvider";
import { usePermissions } from "@/contexts/PermissionsProvider";
import { useUsers } from "@/contexts/UsersProvider";
import PageContainer from "@/layouts/PageContainer";
import { SetupModalContent } from "@/modules/setup-netbird-modal/SetupModal";
const PeersTable = lazy(() => import("@/modules/peers/PeersTable"));
export default function Peers() {
const { isRestricted } = usePermissions();
return (
<PageContainer>
{isRestricted ? (
<PeersBlockedView />
) : (
<PeersProvider>
<PeersView />
</PeersProvider>
)}
</PageContainer>
);
}
function PeersView() {
const { peers, isLoading } = usePeers();
const { users } = useUsers();
const { ref: headingRef, portalTarget } =
usePortalElement<HTMLHeadingElement>();
const peersWithUser = peers?.map((peer) => {
if (!users) return peer;
return {
...peer,
user: users?.find((user) => user.id === peer.user_id),
};
});
return (
<>
<div className={"p-default py-6"}>
<Breadcrumbs>
<Breadcrumbs.Item
href={"/peers"}
label={"Peers"}
icon={<PeerIcon size={13} />}
/>
</Breadcrumbs>
<h1 ref={headingRef}>Peers</h1>
<Paragraph>
A list of all machines and devices connected to your private network.
Use this view to manage peers.
</Paragraph>
<Paragraph>
Learn more about{" "}
<InlineLink
href={"https://docs.netbird.io/how-to/add-machines-to-your-network"}
target={"_blank"}
>
Peers
<ExternalLinkIcon size={12} />
</InlineLink>
in our documentation.
</Paragraph>
</div>
<Suspense fallback={<SkeletonTable />}>
<PeersTable
isLoading={isLoading}
peers={peersWithUser}
headingTarget={portalTarget}
/>
</Suspense>
</>
);
}
function PeersBlockedView() {
return (
<div className={"flex items-center justify-center flex-col"}>
<div className={"p-default py-6 max-w-3xl text-center"}>
<h1>Add new device to your network</h1>
<Paragraph className={"inline"}>
To get started, install NetBird and log in using your email account.
After that you should be connected. If you have further questions
check out our{" "}
<InlineLink
href={"https://docs.netbird.io/how-to/getting-started#installation"}
target={"_blank"}
>
Installation Guide
<ExternalLinkIcon size={12} />
</InlineLink>
</Paragraph>
</div>
<div className={"px-3 pt-1 pb-8 max-w-3xl w-full"}>
<div
className={
"rounded-md border border-nb-gray-900/70 grid w-full bg-nb-gray-930/40 stepper-bg-variant"
}
>
<SetupModalContent header={false} footer={false} />
</div>
</div>
</div>
);
}
@@ -1,8 +0,0 @@
import { globalMetaTitle } from "@utils/meta";
import type { Metadata } from "next";
import BlankLayout from "@/layouts/BlankLayout";
export const metadata: Metadata = {
title: `Servers - ${globalMetaTitle}`,
};
export default BlankLayout;
-115
View File
@@ -1,115 +0,0 @@
"use client";
import Breadcrumbs from "@components/Breadcrumbs";
import InlineLink from "@components/InlineLink";
import Paragraph from "@components/Paragraph";
import SkeletonTable from "@components/skeletons/SkeletonTable";
import { usePortalElement } from "@hooks/usePortalElement";
import { ExternalLinkIcon } from "lucide-react";
import React, { lazy, Suspense, useMemo } from "react";
import PeerIcon from "@/assets/icons/PeerIcon";
import PeersProvider, { usePeers } from "@/contexts/PeersProvider";
import { usePermissions } from "@/contexts/PermissionsProvider";
import { useUsers } from "@/contexts/UsersProvider";
import PageContainer from "@/layouts/PageContainer";
import { SetupModalContent } from "@/modules/setup-netbird-modal/SetupModal";
const PeersTable = lazy(() => import("@/modules/peers/PeersTable"));
export default function ServersPage() {
const { isRestricted } = usePermissions();
return (
<PageContainer>
{isRestricted ? (
<ServersBlockedView />
) : (
<PeersProvider>
<ServersView />
</PeersProvider>
)}
</PageContainer>
);
}
function ServersView() {
const { peers, isLoading: isPeersLoading } = usePeers();
const { users, isLoading: isUsersLoading } = useUsers();
const { ref: headingRef, portalTarget } =
usePortalElement<HTMLHeadingElement>();
// The kind filter classifies peers by whether their owner is a real
// user vs a service/no-user, so we must wait until both peers and
// users have loaded before joining them — otherwise peers temporarily
// render with peer.user === undefined and get misclassified.
const isLoading = isPeersLoading || isUsersLoading;
const peersWithUser = useMemo(() => {
if (!peers || !users) return undefined;
return peers.map((peer) => ({
...peer,
user: users.find((u) => u.id === peer.user_id),
}));
}, [peers, users]);
return (
<>
<div className={"p-default py-6"}>
<Breadcrumbs>
<Breadcrumbs.Item
label={"Peers"}
icon={<PeerIcon size={13} />}
/>
<Breadcrumbs.Item
href={"/peers/servers"}
label={"Servers"}
active
/>
</Breadcrumbs>
<h1 ref={headingRef}>Servers</h1>
<Paragraph>
Servers, VMs, autonomous agents and other unattended machines with no
user behind them, typically enrolled with a{" "}
<InlineLink href={"/settings?tab=setup-keys"}>setup key.</InlineLink>
</Paragraph>
</div>
<Suspense fallback={<SkeletonTable />}>
<PeersTable
isLoading={isLoading}
peers={peersWithUser}
headingTarget={portalTarget}
kind={"servers"}
/>
</Suspense>
</>
);
}
function ServersBlockedView() {
return (
<div className={"flex items-center justify-center flex-col"}>
<div className={"p-default py-6 max-w-3xl text-center"}>
<h1>Add new server to your network</h1>
<Paragraph className={"inline"}>
To get started, install NetBird on the server and enroll it using a
setup key. If you have further questions check out our{" "}
<InlineLink
href={"https://docs.netbird.io/how-to/getting-started#installation"}
target={"_blank"}
>
Installation Guide
<ExternalLinkIcon size={12} />
</InlineLink>
</Paragraph>
</div>
<div className={"px-3 pt-1 pb-8 max-w-3xl w-full"}>
<div
className={
"rounded-md border border-nb-gray-900/70 grid w-full bg-nb-gray-930/40 stepper-bg-variant"
}
>
<SetupModalContent header={false} footer={false} isUserDevice={false} />
</div>
</div>
</div>
);
}
@@ -1,8 +0,0 @@
import { globalMetaTitle } from "@utils/meta";
import type { Metadata } from "next";
import BlankLayout from "@/layouts/BlankLayout";
export const metadata: Metadata = {
title: `User Devices - ${globalMetaTitle}`,
};
export default BlankLayout;
-122
View File
@@ -1,122 +0,0 @@
"use client";
import Breadcrumbs from "@components/Breadcrumbs";
import InlineLink from "@components/InlineLink";
import Paragraph from "@components/Paragraph";
import SkeletonTable from "@components/skeletons/SkeletonTable";
import { usePortalElement } from "@hooks/usePortalElement";
import { ExternalLinkIcon } from "lucide-react";
import React, { lazy, Suspense, useMemo } from "react";
import PeerIcon from "@/assets/icons/PeerIcon";
import PeersProvider, { usePeers } from "@/contexts/PeersProvider";
import { usePermissions } from "@/contexts/PermissionsProvider";
import { useUsers } from "@/contexts/UsersProvider";
import PageContainer from "@/layouts/PageContainer";
import { SetupModalContent } from "@/modules/setup-netbird-modal/SetupModal";
const PeersTable = lazy(() => import("@/modules/peers/PeersTable"));
export default function UserDevicesPage() {
const { isRestricted } = usePermissions();
return (
<PageContainer>
{isRestricted ? (
<UserDevicesBlockedView />
) : (
<PeersProvider>
<UserDevicesView />
</PeersProvider>
)}
</PageContainer>
);
}
function UserDevicesView() {
const { peers, isLoading: isPeersLoading } = usePeers();
const { users, isLoading: isUsersLoading } = useUsers();
const { ref: headingRef, portalTarget } =
usePortalElement<HTMLHeadingElement>();
// The kind filter classifies peers by whether their owner is a real
// user vs a service/no-user, so we must wait until both peers and
// users have loaded before joining them — otherwise peers temporarily
// render with peer.user === undefined and get misclassified.
const isLoading = isPeersLoading || isUsersLoading;
const peersWithUser = useMemo(() => {
if (!peers || !users) return undefined;
return peers.map((peer) => ({
...peer,
user: users.find((u) => u.id === peer.user_id),
}));
}, [peers, users]);
return (
<>
<div className={"p-default py-6"}>
<Breadcrumbs>
<Breadcrumbs.Item
label={"Peers"}
icon={<PeerIcon size={13} />}
/>
<Breadcrumbs.Item
href={"/peers/users"}
label={"User Devices"}
active
/>
</Breadcrumbs>
<h1 ref={headingRef}>User Devices</h1>
<Paragraph>
Laptops, phones and other personal devices with a user behind them,
typically added when the user signs in with SSO.{" "}
<InlineLink
href={"https://docs.netbird.io/how-to/add-machines-to-your-network"}
target={"_blank"}
>
Learn more
<ExternalLinkIcon size={12} />
</InlineLink>
</Paragraph>
</div>
<Suspense fallback={<SkeletonTable />}>
<PeersTable
isLoading={isLoading}
peers={peersWithUser}
headingTarget={portalTarget}
kind={"users"}
/>
</Suspense>
</>
);
}
function UserDevicesBlockedView() {
return (
<div className={"flex items-center justify-center flex-col"}>
<div className={"p-default py-6 max-w-3xl text-center"}>
<h1>Add new device to your network</h1>
<Paragraph className={"inline"}>
To get started, install NetBird and log in using your email account.
After that you should be connected. If you have further questions
check out our{" "}
<InlineLink
href={"https://docs.netbird.io/how-to/getting-started#installation"}
target={"_blank"}
>
Installation Guide
<ExternalLinkIcon size={12} />
</InlineLink>
</Paragraph>
</div>
<div className={"px-3 pt-1 pb-8 max-w-3xl w-full"}>
<div
className={
"rounded-md border border-nb-gray-900/70 grid w-full bg-nb-gray-930/40 stepper-bg-variant"
}
>
<SetupModalContent header={false} footer={false} isUserDevice />
</div>
</div>
</div>
);
}
@@ -1,8 +0,0 @@
import { globalMetaTitle } from "@utils/meta";
import type { Metadata } from "next";
import BlankLayout from "@/layouts/BlankLayout";
export const metadata: Metadata = {
title: `Clusters - Reverse Proxy - ${globalMetaTitle}`,
};
export default BlankLayout;
@@ -1,66 +0,0 @@
"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 { ExternalLinkIcon } from "lucide-react";
import React, { lazy, Suspense } from "react";
import ReverseProxyIcon from "@/assets/icons/ReverseProxyIcon";
import { usePermissions } from "@/contexts/PermissionsProvider";
import { REVERSE_PROXY_CLUSTERS_DOCS_LINK } from "@/interfaces/ReverseProxy";
import PageContainer from "@/layouts/PageContainer";
const ClustersTable = lazy(
() => import("@/modules/reverse-proxy/clusters/ClustersTable"),
);
export default function ReverseProxyClustersPage() {
const { permission } = usePermissions();
const { ref: headingRef, portalTarget } =
usePortalElement<HTMLHeadingElement>();
return (
<PageContainer>
<div className={"p-default py-6"}>
<Breadcrumbs>
<Breadcrumbs.Item
href={"/reverse-proxy/services"}
label={"Reverse Proxy"}
icon={<ReverseProxyIcon size={16} />}
/>
<Breadcrumbs.Item
href={"/reverse-proxy/clusters"}
label={"Clusters"}
active={true}
/>
</Breadcrumbs>
<h1 ref={headingRef}>Clusters</h1>
<Paragraph>
Proxy clusters that route inbound traffic to your services. Shared
clusters are deployed at the server level; account clusters are
self-hosted on your own infrastructure.
</Paragraph>
<Paragraph>
Learn more about
<InlineLink href={REVERSE_PROXY_CLUSTERS_DOCS_LINK} target={"_blank"}>
Clusters
<ExternalLinkIcon size={12} />
</InlineLink>
in our documentation.
</Paragraph>
</div>
<RestrictedAccess
page={"Clusters"}
hasAccess={permission?.services?.read}
>
<Suspense fallback={<SkeletonTable />}>
<ClustersTable headingTarget={portalTarget} />
</Suspense>
</RestrictedAccess>
</PageContainer>
);
}
-9
View File
@@ -13,7 +13,6 @@ import {
} from "lucide-react";
import { useSearchParams } from "next/navigation";
import React, { useEffect, useMemo, useState } from "react";
import SetupKeysIcon from "@/assets/icons/SetupKeysIcon";
import { usePermissions } from "@/contexts/PermissionsProvider";
import { useLoggedInUser } from "@/contexts/UsersProvider";
import PageContainer from "@/layouts/PageContainer";
@@ -24,7 +23,6 @@ import DangerZoneTab from "@/modules/settings/DangerZoneTab";
import IdentityProvidersTab from "@/modules/settings/IdentityProvidersTab";
import NetworkSettingsTab from "@/modules/settings/NetworkSettingsTab";
import PermissionsTab from "@/modules/settings/PermissionsTab";
import SetupKeysTab from "@/modules/settings/SetupKeysTab";
import GroupsSettings from "@/modules/settings/GroupsSettings";
export default function NetBirdSettings() {
@@ -57,12 +55,6 @@ export default function NetBirdSettings() {
<ShieldIcon size={14} />
Authentication
</VerticalTabs.Trigger>
{permission.setup_keys.read && (
<VerticalTabs.Trigger value="setup-keys">
<SetupKeysIcon size={14} />
Setup Keys
</VerticalTabs.Trigger>
)}
{account?.settings?.embedded_idp_enabled &&
permission?.identity_providers?.read && (
<VerticalTabs.Trigger value="identity-providers">
@@ -97,7 +89,6 @@ export default function NetBirdSettings() {
>
<div className={"border-l border-nb-gray-930 w-full"}>
{account && <AuthenticationTab account={account} />}
{permission.setup_keys.read && <SetupKeysTab />}
{account?.settings?.embedded_idp_enabled &&
permission.identity_providers.read && <IdentityProvidersTab />}
{account && <PermissionsTab account={account} />}
+89 -4
View File
@@ -1,5 +1,90 @@
import { redirect } from "next/navigation";
"use client";
export default function SetupKeysIndex() {
redirect("/settings?tab=setup-keys");
}
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, useMemo } from "react";
import SetupKeysIcon from "@/assets/icons/SetupKeysIcon";
import { useGroups } from "@/contexts/GroupsProvider";
import { usePermissions } from "@/contexts/PermissionsProvider";
import { Group } from "@/interfaces/Group";
import { SetupKey } from "@/interfaces/SetupKey";
import PageContainer from "@/layouts/PageContainer";
const SetupKeysTable = lazy(
() => import("@/modules/setup-keys/SetupKeysTable"),
);
export default function SetupKeys() {
const { data: setupKeys, isLoading } = useFetchApi<SetupKey[]>("/setup-keys");
const { permission } = usePermissions();
const { groups } = useGroups();
const setupKeysWithGroups = useMemo(() => {
if (!setupKeys) return [];
return setupKeys?.map((setupKey) => {
if (!setupKey.auto_groups) return setupKey;
if (!groups) return setupKey;
return {
...setupKey,
groups: setupKey.auto_groups
?.map((group) => {
return groups.find((g) => g.id === group) || undefined;
})
.filter((group) => group !== undefined) as Group[],
};
});
}, [setupKeys, groups]);
const { ref: headingRef, portalTarget } =
usePortalElement<HTMLHeadingElement>();
return (
<PageContainer>
<div className={"p-default py-6"}>
<Breadcrumbs>
<Breadcrumbs.Item
href={"/setup-keys"}
label={"Setup Keys"}
icon={<SetupKeysIcon size={13} />}
/>
</Breadcrumbs>
<h1 ref={headingRef}>Setup Keys</h1>
<Paragraph>
Setup keys are pre-authentication keys that allow to register new
machines in your network.
</Paragraph>
<Paragraph>
Learn more about
<InlineLink
href={
"https://docs.netbird.io/how-to/register-machines-using-setup-keys"
}
target={"_blank"}
>
Setup Keys
<ExternalLinkIcon size={12} />
</InlineLink>
in our documentation.
</Paragraph>
</div>
<RestrictedAccess
page={"Setup Keys"}
hasAccess={permission.setup_keys.read}
>
<Suspense fallback={<SkeletonTable />}>
<SetupKeysTable
headingTarget={portalTarget}
setupKeys={setupKeysWithGroups}
isLoading={isLoading}
/>
</Suspense>
</RestrictedAccess>
</PageContainer>
);
}
+6 -13
View File
@@ -18,7 +18,7 @@ import {
} from "@utils/version";
export default function SSHPage() {
const { peerId, username, port, ipVersion } = useSSHQueryParams();
const { peerId, username, port } = useSSHQueryParams();
const {
data: peer,
@@ -48,7 +48,6 @@ export default function SSHPage() {
peer={peer}
username={username}
port={port}
ipVersion={ipVersion}
/>
) : (
<LoadingMessage message={"Starting ssh session..."} />
@@ -61,10 +60,9 @@ type Props = {
username: string;
port: string;
peer: Peer;
ipVersion: string | null;
};
function SSHTerminal({ username, port, peer, ipVersion }: Props) {
function SSHTerminal({ username, port, peer }: Props) {
const client = useNetBirdClient();
const connected = useRef(false);
const sshConnectedOnce = useRef(false);
@@ -83,12 +81,9 @@ function SSHTerminal({ username, port, peer, ipVersion }: Props) {
const isClientDisconnected = client.status === NetBirdStatus.DISCONNECTED;
const isClientConnecting = client.status === NetBirdStatus.CONNECTING;
// Use the FQDN when an IP version is specified so the dialer resolves to the correct address family.
const sshHost = ipVersion ? peer.dns_label || peer.ip : peer.ip;
useEffect(() => {
document.title = `${username}@${sshHost} - ${peer.hostname}`;
}, [username, peer, client, sshHost]);
document.title = `${username}@${peer.ip} - ${peer.hostname}`;
}, [username, peer, client]);
const handleReconnect = async () => {
if (!peer?.id) return;
@@ -102,10 +97,9 @@ function SSHTerminal({ username, port, peer, ipVersion }: Props) {
const rules = [`${protocol}/${aclPort}`];
await client?.connectTemporary(peer.id, rules);
await ssh({
hostname: sshHost,
hostname: peer.ip,
port: Number(port),
username,
ipVersion: ipVersion || undefined,
});
} catch (error) {
console.error("Reconnection failed:", error);
@@ -129,10 +123,9 @@ function SSHTerminal({ username, port, peer, ipVersion }: Props) {
const rules = [`${protocol}/${aclPort}`];
await client?.connectTemporary(peer.id, rules);
const res = await ssh({
hostname: sshHost,
hostname: peer.ip,
port: Number(port),
username,
ipVersion: ipVersion || undefined,
});
if (res === SSHStatus.CONNECTED) {
sshConnectedOnce.current = true;
-238
View File
@@ -1,238 +0,0 @@
/**
* Tests for globals.css CSS variable definitions.
*
* The PR introduced a two-ramp CSS-variable system for nb-gray:
* - :root defines the INVERTED ramp (light mode — high numbers = light,
* low numbers = dark text)
* - .dark defines the ORIGINAL ramp (dark mode — original values)
*
* These tests parse the raw CSS file as a string and verify:
* 1. :root defines every nb-gray custom property.
* 2. .dark defines every nb-gray custom property.
* 3. The light and dark ramps are genuinely different (inverted) for key stops.
* 4. All values are space-separated RGB triples (not hex, not rgb()).
* 5. h1, h2, p now use theme-agnostic text classes (not dark:-prefixed).
*
* Run with: npx tsx src/app/globals-css.test.ts
*/
import { readFileSync } from "fs";
import { fileURLToPath } from "url";
import { dirname, join } from "path";
const __filename = fileURLToPath(import.meta.url);
const __dir = dirname(__filename);
const css = readFileSync(join(__dir, "globals.css"), "utf-8");
// ─── helpers ───────────────────────────────────────────────────────────────
/**
* Extract the value of a CSS custom property from a given block of CSS text.
* Returns the trimmed raw value string, or null if not found.
*/
function extractVar(cssText: string, varName: string): string | null {
// matches "--varName: VALUE;"
const regex = new RegExp(`${varName}\\s*:\\s*([^;]+);`);
const match = cssText.match(regex);
return match ? match[1].trim() : null;
}
/**
* Extract the text content between the first occurrence of `selector {` and
* its matching closing `}`. NaĂŻve but sufficient for the flat blocks in
* globals.css.
*/
function extractBlock(cssText: string, selector: string): string {
const start = cssText.indexOf(selector);
if (start === -1) return "";
const brace = cssText.indexOf("{", start);
if (brace === -1) return "";
let depth = 1;
let i = brace + 1;
while (i < cssText.length && depth > 0) {
if (cssText[i] === "{") depth++;
else if (cssText[i] === "}") depth--;
i++;
}
return cssText.slice(brace + 1, i - 1);
}
/**
* Check that a string looks like a space-separated RGB triple, e.g. "255 255 255".
*/
function isRgbTriple(value: string): boolean {
return /^\d{1,3}\s+\d{1,3}\s+\d{1,3}$/.test(value.trim());
}
// ─── setup ─────────────────────────────────────────────────────────────────
const rootBlock = extractBlock(css, ":root");
const darkBlock = extractBlock(css, ".dark");
const STOPS = [
"DEFAULT",
"50",
"100",
"200",
"250",
"300",
"350",
"400",
"500",
"600",
"700",
"800",
"850",
"900",
"910",
"920",
"925",
"930",
"935",
"940",
"950",
"960",
] as const;
type Check = { desc: string; ok: boolean };
const checks: Check[] = [];
// ─── 1. :root block exists ─────────────────────────────────────────────────
checks.push({
desc: ":root block is present in globals.css",
ok: rootBlock.length > 0,
});
// ─── 2. .dark block exists ─────────────────────────────────────────────────
checks.push({
desc: ".dark block is present in globals.css",
ok: darkBlock.length > 0,
});
// ─── 3. :root defines all nb-gray stops as RGB triples ─────────────────────
for (const stop of STOPS) {
const varName =
stop === "DEFAULT" ? "--nb-gray-DEFAULT" : `--nb-gray-${stop}`;
const value = extractVar(rootBlock, varName);
checks.push({
desc: `[light/:root] ${varName} is defined as an RGB triple`,
ok: value !== null && isRgbTriple(value),
});
}
// ─── 4. .dark defines all nb-gray stops as RGB triples ─────────────────────
for (const stop of STOPS) {
const varName =
stop === "DEFAULT" ? "--nb-gray-DEFAULT" : `--nb-gray-${stop}`;
const value = extractVar(darkBlock, varName);
checks.push({
desc: `[dark/.dark] ${varName} is defined as an RGB triple`,
ok: value !== null && isRgbTriple(value),
});
}
// ─── 5. Ramps are inverted: light-mode high stops ≠ dark-mode high stops ───
// e.g. in light mode --nb-gray-900 should be near-white (high channels),
// in dark mode --nb-gray-900 should be near-dark (low channels).
{
const lightDefault = extractVar(rootBlock, "--nb-gray-DEFAULT");
const darkDefault = extractVar(darkBlock, "--nb-gray-DEFAULT");
checks.push({
desc: "DEFAULT stop differs between light and dark mode",
ok: lightDefault !== null && darkDefault !== null && lightDefault !== darkDefault,
});
// Light mode DEFAULT should be near-white (all channels high)
if (lightDefault) {
const channels = lightDefault.split(/\s+/).map(Number);
checks.push({
desc: "light mode DEFAULT is near-white (all channels >= 240)",
ok: channels.every((c) => c >= 240),
});
}
// Dark mode DEFAULT should be near-black (all channels low)
if (darkDefault) {
const channels = darkDefault.split(/\s+/).map(Number);
checks.push({
desc: "dark mode DEFAULT is near-black (all channels <= 50)",
ok: channels.every((c) => c <= 50),
});
}
}
// ─── 6. Light mode 50-stop is dark text (low channels) ─────────────────────
{
const light50 = extractVar(rootBlock, "--nb-gray-50");
if (light50) {
const channels = light50.split(/\s+/).map(Number);
checks.push({
desc: "light mode nb-gray-50 is near-black text (all channels <= 50)",
ok: channels.every((c) => c <= 50),
});
}
}
// ─── 7. Dark mode 50-stop is bright text (high channels) ───────────────────
{
const dark50 = extractVar(darkBlock, "--nb-gray-50");
if (dark50) {
const channels = dark50.split(/\s+/).map(Number);
checks.push({
desc: "dark mode nb-gray-50 is near-white text (all channels >= 220)",
ok: channels.every((c) => c >= 220),
});
}
}
// ─── 8. No hex values in the CSS variable declarations ─────────────────────
{
const hexInRoot = /#[0-9a-fA-F]{3,8}/.test(rootBlock);
const hexInDark = /#[0-9a-fA-F]{3,8}/.test(darkBlock);
checks.push({
desc: ":root block contains no hard-coded hex colour values",
ok: !hexInRoot,
});
checks.push({
desc: ".dark block contains no hard-coded hex colour values",
ok: !hexInDark,
});
}
// ─── 9. h1, h2, p use theme-agnostic nb-gray classes ─────────────────────
{
checks.push({
desc: "h1 uses text-nb-gray-100 (not dark:text-nb-gray-100 or text-gray-700)",
ok:
css.includes("text-nb-gray-100") &&
!css.includes("dark:text-nb-gray-100") &&
!css.match(/h1[^}]*text-gray-700/),
});
checks.push({
desc: "p uses text-nb-gray-200 (not dark:text-zinc-50 or dark:text-gray-700)",
ok:
css.includes("text-nb-gray-200") &&
!css.includes("dark:text-zinc-50") &&
!css.includes("text-gray-700"),
});
}
// ─── 10. timescape input focus uses text-nb-gray-50 ────────────────────────
{
checks.push({
desc: ".timescape input:focus uses text-nb-gray-50 (not text-white)",
ok: css.includes("text-nb-gray-50") && !css.match(/timescape[^}]*text-white/),
});
}
// ─── run ───────────────────────────────────────────────────────────────────
console.log("=== globals.css CSS-variable definitions ===");
let failures = 0;
for (const { desc, ok } of checks) {
if (!ok) failures++;
console.log(`${ok ? "âś“" : "âś—"} ${desc}`);
}
console.log(`\n${failures} test(s) failed`);
process.exit(failures > 0 ? 1 : 0);
+195
View File
@@ -200,3 +200,198 @@ p {
.react-flow__node-groupNode .selected{
@apply border-netbird;
}
/* @netbirdio/explain theme overrides — match dashboard palette */
html:root {
--nb-explain-bg: #1c1e21;
--nb-explain-bg-subtle: #25282d;
--nb-explain-bg-hover: #2e3238;
--nb-explain-border: #363b40;
--nb-explain-text: #e4e7e9;
--nb-explain-text-muted: #a3adb5;
--nb-explain-text-dim: #7c8994;
--nb-explain-accent: #f68330;
--nb-explain-accent-hover: #f46d1b;
--nb-explain-accent-glow: rgba(246, 131, 48, 0.15);
--nb-explain-user-bg: #f68330;
--nb-explain-user-text: #ffffff;
--nb-explain-user-glow: rgba(246, 131, 48, 0.25);
--nb-explain-radius: 8px;
--nb-explain-radius-sm: 6px;
--nb-explain-radius-xs: 4px;
--nb-explain-font: inherit;
--nb-explain-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.6), 0 0 0 1px #363b40;
--nb-explain-banner-bg: rgba(246, 131, 48, 0.92);
--nb-explain-banner-text: #ffffff;
--nb-explain-error-text: #F98080;
}
/* Override explain highlight outline to match netbird orange */
[data-nb-explain-highlight] {
outline: 1px solid rgba(246, 131, 48, 0.7) !important;
}
/* Markdown body — Dify-style rendering for AI chat messages */
.nb-markdown-body {
font-size: 0.875rem;
line-height: 1.6;
color: var(--nb-explain-text, #e4e7e9);
word-wrap: break-word;
overflow-wrap: break-word;
}
.nb-markdown-body > *:first-child {
margin-top: 0;
}
.nb-markdown-body > *:last-child {
margin-bottom: 0;
}
.nb-markdown-body p {
margin: 0.4em 0;
font-weight: 400;
letter-spacing: normal;
color: inherit;
font-size: inherit;
}
.nb-markdown-body strong {
font-weight: 600;
color: rgba(255, 255, 255, 0.95);
}
.nb-markdown-body em {
font-style: italic;
}
/* Headings */
.nb-markdown-body h1 {
font-size: 1.25em;
font-weight: 600;
margin: 0.6em 0 0.3em;
color: rgba(255, 255, 255, 0.95);
}
.nb-markdown-body h2 {
font-size: 1.15em;
font-weight: 600;
margin: 0.5em 0 0.3em;
color: rgba(255, 255, 255, 0.95);
}
.nb-markdown-body h3 {
font-size: 1.05em;
font-weight: 600;
margin: 0.4em 0 0.2em;
color: rgba(255, 255, 255, 0.95);
}
/* Lists */
.nb-markdown-body ul {
margin: 0.4em 0;
padding-left: 1.4em;
list-style-type: disc;
}
.nb-markdown-body ol {
margin: 0.4em 0;
padding-left: 1.4em;
list-style-type: decimal;
}
.nb-markdown-body li {
margin: 0.15em 0;
}
.nb-markdown-body li > p {
margin: 0.1em 0;
}
/* Task lists (GFM) */
.nb-markdown-body li input[type="checkbox"] {
margin-right: 0.4em;
vertical-align: middle;
}
/* Inline code */
.nb-markdown-body code {
background: rgba(0, 0, 0, 0.25);
border-radius: 4px;
padding: 0.15em 0.4em;
font-size: 0.88em;
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
}
/* Code blocks */
.nb-markdown-body pre {
background: rgba(0, 0, 0, 0.3);
border-radius: 6px;
padding: 0.7em 0.9em;
margin: 0.5em 0;
overflow-x: auto;
font-size: 0.85em;
}
.nb-markdown-body pre code {
background: none;
border-radius: 0;
padding: 0;
font-size: inherit;
}
/* Blockquotes */
.nb-markdown-body blockquote {
margin: 0.5em 0;
padding: 0.3em 0.8em;
border-left: 3px solid var(--nb-explain-accent, #f68330);
color: var(--nb-explain-text-muted, #a3adb5);
background: rgba(0, 0, 0, 0.15);
border-radius: 0 4px 4px 0;
}
.nb-markdown-body blockquote p {
margin: 0.2em 0;
}
/* Links */
.nb-markdown-body a {
color: var(--nb-explain-accent, #f68330);
text-decoration: underline;
text-underline-offset: 2px;
}
.nb-markdown-body a:hover {
opacity: 0.85;
}
/* Horizontal rules */
.nb-markdown-body hr {
border: none;
border-top: 1px solid var(--nb-explain-border, #363b40);
margin: 0.8em 0;
}
/* Tables (GFM) */
.nb-markdown-body table {
border-collapse: collapse;
margin: 0.5em 0;
width: 100%;
font-size: 0.88em;
}
.nb-markdown-body th,
.nb-markdown-body td {
border: 1px solid var(--nb-explain-border, #363b40);
padding: 0.35em 0.6em;
text-align: left;
}
.nb-markdown-body th {
background: rgba(0, 0, 0, 0.2);
font-weight: 600;
}
.nb-markdown-body tr:nth-child(even) {
background: rgba(0, 0, 0, 0.08);
}
@@ -23,7 +23,6 @@ export const idpIcon = (
zitadel: <ZitadelIcon size={size} />,
authentik: <AuthentikIcon size={size} />,
keycloak: <KeycloakIcon size={size} />,
adfs: <MicrosoftIcon size={size} />,
oidc: <KeyRound size={size} className="text-nb-gray-400" />,
};
Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

Loaded 20 of 141 files, more files were not shown because too many files have changed in this diff. Show more