mirror of
https://github.com/netbirdio/dashboard.git
synced 2026-09-23 00:30:43 -07:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
da4e7d4fbf | ||
|
|
358b477ded | ||
|
|
f535fe2667 | ||
|
|
52f7020a0f | ||
|
|
a604643f9a | ||
|
|
42cd088c5d |
No files matched your search
@@ -9,6 +9,7 @@ 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";
|
||||
@@ -72,7 +73,9 @@ export default function ProxyEventsPage() {
|
||||
defaultPageSize={10}
|
||||
defaultFilters={defaultFilters}
|
||||
>
|
||||
<ReverseProxyEventsTable headingTarget={portalTarget} />
|
||||
<PeersProvider>
|
||||
<ReverseProxyEventsTable headingTarget={portalTarget} />
|
||||
</PeersProvider>
|
||||
</ServerPaginationProvider>
|
||||
</RestrictedAccess>
|
||||
</PageContainer>
|
||||
|
||||
@@ -1,116 +1,5 @@
|
||||
"use client";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
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>
|
||||
);
|
||||
export default function PeersIndex() {
|
||||
redirect("/peers/users");
|
||||
}
|
||||
+1
-1
@@ -3,6 +3,6 @@ import type { Metadata } from "next";
|
||||
import BlankLayout from "@/layouts/BlankLayout";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: `Self-Hosted Proxies - Reverse Proxy - ${globalMetaTitle}`,
|
||||
title: `Servers - ${globalMetaTitle}`,
|
||||
};
|
||||
export default BlankLayout;
|
||||
@@ -0,0 +1,115 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
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;
|
||||
@@ -0,0 +1,122 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
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;
|
||||
+11
-13
@@ -13,11 +13,8 @@ import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import { REVERSE_PROXY_CLUSTERS_DOCS_LINK } from "@/interfaces/ReverseProxy";
|
||||
import PageContainer from "@/layouts/PageContainer";
|
||||
|
||||
const SelfHostedProxiesTable = lazy(
|
||||
() =>
|
||||
import(
|
||||
"@/modules/reverse-proxy/self-hosted-proxies/SelfHostedProxiesTable"
|
||||
),
|
||||
const ClustersTable = lazy(
|
||||
() => import("@/modules/reverse-proxy/clusters/ClustersTable"),
|
||||
);
|
||||
|
||||
export default function ReverseProxyClustersPage() {
|
||||
@@ -36,31 +33,32 @@ export default function ReverseProxyClustersPage() {
|
||||
icon={<ReverseProxyIcon size={16} />}
|
||||
/>
|
||||
<Breadcrumbs.Item
|
||||
href={"/reverse-proxy/self-hosted-proxies"}
|
||||
label={"Self-Hosted Proxies"}
|
||||
href={"/reverse-proxy/clusters"}
|
||||
label={"Clusters"}
|
||||
active={true}
|
||||
/>
|
||||
</Breadcrumbs>
|
||||
<h1 ref={headingRef}>Self-Hosted Proxies</h1>
|
||||
<h1 ref={headingRef}>Clusters</h1>
|
||||
<Paragraph>
|
||||
Setup self-hosted proxies on your own infrastructure for full control
|
||||
over traffic and geographic location.
|
||||
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"}>
|
||||
Self-Hosted Proxies
|
||||
Clusters
|
||||
<ExternalLinkIcon size={12} />
|
||||
</InlineLink>
|
||||
in our documentation.
|
||||
</Paragraph>
|
||||
</div>
|
||||
<RestrictedAccess
|
||||
page={"Self-Hosted Proxies"}
|
||||
page={"Clusters"}
|
||||
hasAccess={permission?.services?.read}
|
||||
>
|
||||
<Suspense fallback={<SkeletonTable />}>
|
||||
<SelfHostedProxiesTable headingTarget={portalTarget} />
|
||||
<ClustersTable headingTarget={portalTarget} />
|
||||
</Suspense>
|
||||
</RestrictedAccess>
|
||||
</PageContainer>
|
||||
@@ -13,6 +13,7 @@ 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";
|
||||
@@ -23,6 +24,7 @@ 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() {
|
||||
@@ -55,6 +57,12 @@ 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">
|
||||
@@ -89,6 +97,7 @@ 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} />}
|
||||
|
||||
@@ -1,90 +1,5 @@
|
||||
"use client";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
export default function SetupKeysIndex() {
|
||||
redirect("/settings?tab=setup-keys");
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
/**
|
||||
* 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);
|
||||
@@ -0,0 +1,237 @@
|
||||
/**
|
||||
* Tests for buttonVariants — the cva variant helper exported from Button.tsx.
|
||||
*
|
||||
* These tests cover the light-mode additions introduced in the light-mode draft
|
||||
* PR. The key changes were:
|
||||
* - danger-outline: added explicit light-mode styles (bg-transparent, text-red-600, etc.)
|
||||
* - danger-text: made fully theme-agnostic (dropped the dark:-prefixed classes)
|
||||
* - default-outline: made fully theme-agnostic (dropped the dark:-prefixed classes)
|
||||
* - danger: added explicit light-mode enabled/disabled styles
|
||||
*
|
||||
* Run with: npx tsx src/components/Button.test.ts
|
||||
*/
|
||||
|
||||
import { buttonVariants } from "./Button.js";
|
||||
|
||||
type Case = { desc: string; classes: string[]; notClasses?: string[] };
|
||||
|
||||
function run(name: string, cases: Case[], fn: () => string): number {
|
||||
console.log(`\n=== ${name} ===`);
|
||||
let failures = 0;
|
||||
const result = fn();
|
||||
for (const { desc, classes, notClasses = [] } of cases) {
|
||||
for (const cls of classes) {
|
||||
const ok = result.includes(cls);
|
||||
if (!ok) failures++;
|
||||
console.log(
|
||||
`${ok ? "✓" : "✗"} [${desc}] contains "${cls}"` +
|
||||
(ok ? "" : ` (actual: "${result}")`),
|
||||
);
|
||||
}
|
||||
for (const cls of notClasses) {
|
||||
const ok = !result.includes(cls);
|
||||
if (!ok) failures++;
|
||||
console.log(
|
||||
`${ok ? "✓" : "✗"} [${desc}] does NOT contain "${cls}"` +
|
||||
(ok ? "" : ` (actual: "${result}")`),
|
||||
);
|
||||
}
|
||||
}
|
||||
return failures;
|
||||
}
|
||||
|
||||
let failures = 0;
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// danger-outline variant
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
failures += run(
|
||||
"buttonVariants — danger-outline",
|
||||
[
|
||||
{
|
||||
desc: "light-mode base",
|
||||
classes: [
|
||||
"bg-transparent",
|
||||
"text-red-600",
|
||||
"border-red-300",
|
||||
"hover:bg-red-50",
|
||||
"hover:border-red-400",
|
||||
"focus:ring-red-500/30",
|
||||
],
|
||||
},
|
||||
{
|
||||
desc: "dark-mode overrides preserved",
|
||||
classes: [
|
||||
"dark:bg-transparent",
|
||||
"dark:text-red-500",
|
||||
"dark:border-transparent",
|
||||
],
|
||||
},
|
||||
],
|
||||
() => buttonVariants({ variant: "danger-outline" }),
|
||||
);
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// danger-text variant
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
failures += run(
|
||||
"buttonVariants — danger-text",
|
||||
[
|
||||
{
|
||||
desc: "theme-agnostic text",
|
||||
classes: ["text-red-500", "hover:text-red-600"],
|
||||
},
|
||||
{
|
||||
desc: "no padding / shadow",
|
||||
classes: ["border-transparent", "!px-0", "!shadow-none", "!py-0"],
|
||||
},
|
||||
{
|
||||
desc: "focus ring",
|
||||
classes: ["focus:ring-red-500/30"],
|
||||
},
|
||||
{
|
||||
desc: "ring offset is theme-agnostic (no dark: prefix)",
|
||||
classes: ["ring-offset-nb-gray-950/50"],
|
||||
notClasses: ["dark:ring-offset-neutral-950/50"],
|
||||
},
|
||||
],
|
||||
() => buttonVariants({ variant: "danger-text" }),
|
||||
);
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// default-outline variant
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
failures += run(
|
||||
"buttonVariants — default-outline",
|
||||
[
|
||||
{
|
||||
desc: "ring offset and focus ring are theme-agnostic",
|
||||
classes: ["ring-offset-nb-gray-950/50", "focus:ring-nb-gray-500/20"],
|
||||
},
|
||||
{
|
||||
desc: "light/dark-agnostic base styles",
|
||||
classes: [
|
||||
"bg-transparent",
|
||||
"text-nb-gray-400",
|
||||
"border-transparent",
|
||||
"hover:text-nb-gray-50",
|
||||
"hover:bg-nb-gray-900/30",
|
||||
"hover:border-nb-gray-800/50",
|
||||
],
|
||||
},
|
||||
{
|
||||
desc: "data-state=open styles are theme-agnostic",
|
||||
classes: [
|
||||
"data-[state=open]:text-nb-gray-50",
|
||||
"data-[state=open]:bg-nb-gray-900/30",
|
||||
"data-[state=open]:border-nb-gray-800/50",
|
||||
],
|
||||
},
|
||||
{
|
||||
desc: "no leftover dark:-only prefix variants for core styles",
|
||||
notClasses: [
|
||||
"dark:ring-offset-nb-gray-950/50",
|
||||
"dark:focus:ring-nb-gray-500/20",
|
||||
"dark:bg-transparent",
|
||||
"dark:text-nb-gray-400",
|
||||
"data-[state=open]:dark:text-nb-gray-50",
|
||||
],
|
||||
},
|
||||
],
|
||||
() => buttonVariants({ variant: "default-outline" }),
|
||||
);
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// danger variant
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
failures += run(
|
||||
"buttonVariants — danger",
|
||||
[
|
||||
{
|
||||
desc: "light-mode enabled state",
|
||||
classes: [
|
||||
"enabled:bg-red-600",
|
||||
"enabled:text-white",
|
||||
"enabled:hover:bg-red-700",
|
||||
"enabled:focus:bg-red-700",
|
||||
"enabled:focus:ring-red-400/50",
|
||||
],
|
||||
},
|
||||
{
|
||||
desc: "light-mode disabled state",
|
||||
classes: [
|
||||
"disabled:bg-red-300",
|
||||
"disabled:text-white",
|
||||
"border-red-600",
|
||||
],
|
||||
},
|
||||
{
|
||||
desc: "dark-mode overrides preserved",
|
||||
classes: [
|
||||
"dark:bg-red-600",
|
||||
"dark:text-red-100",
|
||||
"hover:dark:bg-red-700",
|
||||
],
|
||||
},
|
||||
],
|
||||
() => buttonVariants({ variant: "danger" }),
|
||||
);
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// Unchanged variants — regression check: ensure they still work
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
failures += run(
|
||||
"buttonVariants — primary (regression)",
|
||||
[
|
||||
{
|
||||
desc: "primary still has netbird colour",
|
||||
classes: ["enabled:dark:bg-netbird", "enabled:bg-netbird"],
|
||||
},
|
||||
],
|
||||
() => buttonVariants({ variant: "primary" }),
|
||||
);
|
||||
|
||||
failures += run(
|
||||
"buttonVariants — default (regression)",
|
||||
[
|
||||
{
|
||||
desc: "default variant still renders",
|
||||
classes: ["bg-white", "text-gray-900"],
|
||||
},
|
||||
],
|
||||
() => buttonVariants({ variant: "default" }),
|
||||
);
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// Size and rounding defaults
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
failures += run(
|
||||
"buttonVariants — default size and rounding",
|
||||
[
|
||||
{
|
||||
desc: "default size is md",
|
||||
classes: ["py-2.5", "px-4"],
|
||||
},
|
||||
{
|
||||
desc: "default rounded is true",
|
||||
classes: ["rounded-md"],
|
||||
},
|
||||
],
|
||||
() => buttonVariants({}),
|
||||
);
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// Edge: danger-outline with extra className override doesn't lose base classes
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
{
|
||||
const cls = buttonVariants({ variant: "danger-outline" });
|
||||
const ok =
|
||||
cls.includes("text-red-600") && cls.includes("border-red-300");
|
||||
if (!ok) failures++;
|
||||
console.log(
|
||||
`\n=== danger-outline boundary ===\n${ok ? "✓" : "✗"} light-mode base classes present when called without extra options`,
|
||||
);
|
||||
}
|
||||
|
||||
console.log(`\n${failures} test(s) failed`);
|
||||
process.exit(failures > 0 ? 1 : 0);
|
||||
@@ -9,6 +9,11 @@ type Props = {
|
||||
iconAlignment?: "left" | "right";
|
||||
className?: string;
|
||||
alwaysShowIcon?: boolean;
|
||||
// Overrides the rendered innerText as the value written to the
|
||||
// clipboard. Use when the displayed text is an abbreviation of the
|
||||
// canonical value (e.g. the short DNS label) but the user should
|
||||
// still get the full string when they click.
|
||||
textToCopy?: string;
|
||||
};
|
||||
|
||||
export default function CopyToClipboardText({
|
||||
@@ -17,8 +22,9 @@ export default function CopyToClipboardText({
|
||||
iconAlignment = "right",
|
||||
className,
|
||||
alwaysShowIcon = false,
|
||||
textToCopy,
|
||||
}: Props) {
|
||||
const [wrapper, copyToClipboard, copied] = useCopyToClipboard();
|
||||
const [wrapper, copyToClipboard, copied] = useCopyToClipboard(textToCopy);
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* Tests for NetBirdLogo component source structure.
|
||||
*
|
||||
* The PR introduced dual-image theme switching so the logo automatically
|
||||
* shows the light-text SVG in light mode and the dark-text SVG in dark mode:
|
||||
*
|
||||
* - A <span> wrapping both <Image>s (one per theme) replaces the single
|
||||
* previous <Image>.
|
||||
* - The light-mode image has className="block dark:hidden".
|
||||
* - The dark-mode image has className="hidden dark:block".
|
||||
* - The mobile mark (logomark only) now always has className="md:hidden ml-4"
|
||||
* rather than a conditional cn() call.
|
||||
* - The netbird-full-light.svg asset is imported.
|
||||
*
|
||||
* Because this is a Next.js component that relies on next/image and SVG
|
||||
* asset imports, we verify the source text directly rather than rendering it
|
||||
* in Node.
|
||||
*
|
||||
* Run with: npx tsx src/components/NetBirdLogo.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 source = readFileSync(join(__dir, "NetBirdLogo.tsx"), "utf-8");
|
||||
|
||||
type Check = { desc: string; ok: boolean };
|
||||
const checks: Check[] = [];
|
||||
|
||||
// 1. Light-mode SVG asset is imported
|
||||
checks.push({
|
||||
desc: "imports netbird-full-light.svg",
|
||||
ok: source.includes("netbird-full-light.svg"),
|
||||
});
|
||||
|
||||
// 2. The import alias is used in the JSX
|
||||
checks.push({
|
||||
desc: "NetBirdLogoFullLight is used as an Image src",
|
||||
ok: source.includes("NetBirdLogoFullLight") && source.includes("src={NetBirdLogoFullLight}"),
|
||||
});
|
||||
|
||||
// 3. Dark-mode logo is still imported and used
|
||||
checks.push({
|
||||
desc: "imports netbird-full.svg (dark logo)",
|
||||
ok: source.includes("netbird-full.svg"),
|
||||
});
|
||||
checks.push({
|
||||
desc: "NetBirdLogoFull is used as an Image src",
|
||||
ok: source.includes("src={NetBirdLogoFull}"),
|
||||
});
|
||||
|
||||
// 4. Light-mode image has the correct visibility toggle class
|
||||
checks.push({
|
||||
desc: "light-logo image has className=\"block dark:hidden\"",
|
||||
ok: source.includes("block dark:hidden"),
|
||||
});
|
||||
|
||||
// 5. Dark-mode image has the correct visibility toggle class
|
||||
checks.push({
|
||||
desc: "dark-logo image has className=\"hidden dark:block\"",
|
||||
ok: source.includes("hidden dark:block"),
|
||||
});
|
||||
|
||||
// 6. Both images are wrapped in a <span> (the new structure)
|
||||
checks.push({
|
||||
desc: "a wrapping <span> element is present",
|
||||
ok: source.includes("<span"),
|
||||
});
|
||||
|
||||
// 7. The wrapping span carries the conditional hidden class for mobile
|
||||
checks.push({
|
||||
desc: "wrapping span uses cn() for responsive hiding",
|
||||
ok: source.includes('cn("relative"') || source.includes("cn(\"relative\""),
|
||||
});
|
||||
|
||||
// 8. Mobile logomark uses simple className (no unnecessary conditional)
|
||||
checks.push({
|
||||
desc: "mobile mark className does not use unnecessary mobile-conditional cn",
|
||||
ok:
|
||||
source.includes('"md:hidden ml-4"') ||
|
||||
source.includes("'md:hidden ml-4'"),
|
||||
});
|
||||
|
||||
// 9. Mobile mark still imports NetBirdLogoMark
|
||||
checks.push({
|
||||
desc: "imports netbird.svg (logomark)",
|
||||
ok: source.includes("netbird.svg"),
|
||||
});
|
||||
|
||||
// 10. sizes object is still present for both size values
|
||||
checks.push({
|
||||
desc: "sizes.default is defined",
|
||||
ok: source.includes("default:"),
|
||||
});
|
||||
checks.push({
|
||||
desc: "sizes.large is defined",
|
||||
ok: source.includes("large:"),
|
||||
});
|
||||
|
||||
// 11. Props still have defaults (size and mobile)
|
||||
checks.push({
|
||||
desc: "size prop defaults to 'default'",
|
||||
ok: source.includes("size = \"default\""),
|
||||
});
|
||||
checks.push({
|
||||
desc: "mobile prop defaults to true",
|
||||
ok: source.includes("mobile = true"),
|
||||
});
|
||||
|
||||
// 12. Alt text is preserved on both images
|
||||
const altMatches = source.match(/alt=\{"NetBird Logo"\}/g);
|
||||
checks.push({
|
||||
desc: "at least two images have alt='NetBird Logo'",
|
||||
ok: altMatches !== null && altMatches.length >= 2,
|
||||
});
|
||||
|
||||
// ─── run ───────────────────────────────────────────────────────────────────
|
||||
|
||||
console.log("=== NetBirdLogo source checks ===");
|
||||
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);
|
||||
@@ -30,8 +30,10 @@ import {
|
||||
MonitorSmartphoneIcon,
|
||||
NetworkIcon,
|
||||
SearchIcon,
|
||||
ServerIcon,
|
||||
ShieldCheck,
|
||||
WorkflowIcon,
|
||||
XIcon,
|
||||
} from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { Fragment, useEffect, useMemo, useState } from "react";
|
||||
@@ -47,7 +49,15 @@ import { HorizontalUsersStack } from "@/modules/users/HorizontalUsersStack";
|
||||
import { PeerOperatingSystemIcon } from "@/modules/peers/PeerOperatingSystemIcon";
|
||||
import TruncatedText from "@components/ui/TruncatedText";
|
||||
|
||||
type PeerGroupSelectorTab = "peers" | "groups" | "resources";
|
||||
type PeerGroupSelectorTab = "peers" | "groups" | "resources" | "clusters";
|
||||
|
||||
export type ClusterOption = {
|
||||
/** Cluster apex domain (e.g. "eu.proxy.netbird.io"); also the value
|
||||
* that downstream code stores in target_id / proxy_cluster. */
|
||||
domain: string;
|
||||
/** Human-friendly label; falls back to domain. */
|
||||
label?: string;
|
||||
};
|
||||
|
||||
const groupsSearchPredicate = (item: Group, query: string) => {
|
||||
const lowerCaseQuery = query.toLowerCase();
|
||||
@@ -74,8 +84,16 @@ interface MultiSelectProps {
|
||||
showPeers?: boolean;
|
||||
showPeerCounter?: boolean;
|
||||
hideGroupsTab?: boolean;
|
||||
tabOrder?: ("groups" | "peers" | "resources")[];
|
||||
tabOrder?: PeerGroupSelectorTab[];
|
||||
closeOnSelect?: boolean;
|
||||
/** Show a Clusters tab. Off by default; flip on with clusters list. */
|
||||
showClusters?: boolean;
|
||||
/** Clusters offered in the Clusters tab. When empty the tab is hidden. */
|
||||
clusters?: ClusterOption[];
|
||||
/** Currently-selected cluster (domain string), if any. */
|
||||
selectedCluster?: string;
|
||||
/** Called when the user picks (or clears) a cluster. */
|
||||
onClusterChange?: (cluster?: string) => void;
|
||||
resource?: PolicyRuleResource;
|
||||
onResourceChange?: (resource?: PolicyRuleResource) => void;
|
||||
placeholder?: React.ReactNode | string;
|
||||
@@ -120,6 +138,10 @@ export function PeerGroupSelector({
|
||||
resourceIds,
|
||||
additionalResources,
|
||||
policies,
|
||||
showClusters = false,
|
||||
clusters,
|
||||
selectedCluster,
|
||||
onClusterChange,
|
||||
}: Readonly<MultiSelectProps>) {
|
||||
const { data: fetchedResources, isLoading: isResourcesLoading } = useFetchApi<
|
||||
NetworkResource[]
|
||||
@@ -291,9 +313,29 @@ export function PeerGroupSelector({
|
||||
if (tab === "groups") return placeholderForSearch;
|
||||
if (tab === "resources") return "Search resource...";
|
||||
if (tab === "peers") return "Search peer by name or ip...";
|
||||
if (tab === "clusters") return "Search cluster...";
|
||||
return "Search...";
|
||||
}, [tab, placeholderForSearch]);
|
||||
|
||||
const filteredClusters = useMemo(() => {
|
||||
if (!clusters || clusters.length === 0) return [];
|
||||
if (!search) return clusters;
|
||||
const q = search.toLowerCase();
|
||||
return clusters.filter(
|
||||
(c) =>
|
||||
c.domain.toLowerCase().includes(q) ||
|
||||
c.label?.toLowerCase().includes(q),
|
||||
);
|
||||
}, [clusters, search]);
|
||||
|
||||
const selectCluster = (cluster?: ClusterOption) => {
|
||||
onClusterChange?.(cluster?.domain);
|
||||
onChange([]);
|
||||
if (closeOnSelect) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const selectResource = (resource?: NetworkResource) => {
|
||||
onResourceChange?.(
|
||||
resource
|
||||
@@ -375,6 +417,36 @@ export function PeerGroupSelector({
|
||||
showX={true}
|
||||
/>
|
||||
)}
|
||||
{selectedCluster && (
|
||||
<Badge
|
||||
useHover={true}
|
||||
data-cy={"cluster-badge"}
|
||||
variant={"gray-ghost"}
|
||||
className={
|
||||
"py-[3px] transition-all group whitespace-nowrap"
|
||||
}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onClusterChange?.(undefined);
|
||||
}}
|
||||
>
|
||||
<ServerIcon size={12} className={"shrink-0"} />
|
||||
<TruncatedText
|
||||
text={
|
||||
(clusters ?? []).find((c) => c.domain === selectedCluster)
|
||||
?.label ?? selectedCluster
|
||||
}
|
||||
maxChars={20}
|
||||
/>
|
||||
<XIcon
|
||||
size={12}
|
||||
className={
|
||||
"cursor-pointer group-hover:text-nb-gray-100 transition-all shrink-0"
|
||||
}
|
||||
/>
|
||||
</Badge>
|
||||
)}
|
||||
{values.map((group) => {
|
||||
return (
|
||||
<div
|
||||
@@ -418,7 +490,7 @@ export function PeerGroupSelector({
|
||||
);
|
||||
})}
|
||||
|
||||
{values.length == 0 && !resource && (
|
||||
{values.length == 0 && !resource && !selectedCluster && (
|
||||
<span className={cn(typeof placeholder === "string" && "pl-1")}>
|
||||
{placeholder}
|
||||
</span>
|
||||
@@ -497,6 +569,7 @@ export function PeerGroupSelector({
|
||||
searchRef={searchRef}
|
||||
showPeers={showPeers}
|
||||
showResources={showResources}
|
||||
showClusters={showClusters}
|
||||
hideGroupsTab={hideGroupsTab}
|
||||
tabOrder={tabOrder}
|
||||
/>
|
||||
@@ -646,6 +719,15 @@ export function PeerGroupSelector({
|
||||
/>
|
||||
</TabsContent>
|
||||
)}
|
||||
{showClusters && (
|
||||
<TabsContent value={"clusters"} className={"p-0 my-0"}>
|
||||
<ClustersList
|
||||
clusters={filteredClusters}
|
||||
value={selectedCluster}
|
||||
onChange={selectCluster}
|
||||
/>
|
||||
</TabsContent>
|
||||
)}
|
||||
</Tabs>
|
||||
</CommandList>
|
||||
</Command>
|
||||
@@ -658,17 +740,22 @@ const TabTriggers = ({
|
||||
searchRef,
|
||||
showResources = false,
|
||||
showPeers = false,
|
||||
showClusters = false,
|
||||
hideGroupsTab = false,
|
||||
tabOrder,
|
||||
}: {
|
||||
searchRef: React.MutableRefObject<HTMLInputElement | null>;
|
||||
showResources?: boolean;
|
||||
showPeers?: boolean;
|
||||
showClusters?: boolean;
|
||||
hideGroupsTab?: boolean;
|
||||
tabOrder?: ("groups" | "peers" | "resources")[];
|
||||
tabOrder?: PeerGroupSelectorTab[];
|
||||
}) => {
|
||||
const tabCount =
|
||||
(!hideGroupsTab ? 1 : 0) + (showResources ? 1 : 0) + (showPeers ? 1 : 0);
|
||||
(!hideGroupsTab ? 1 : 0) +
|
||||
(showResources ? 1 : 0) +
|
||||
(showPeers ? 1 : 0) +
|
||||
(showClusters ? 1 : 0);
|
||||
if (tabCount <= 1) return null;
|
||||
|
||||
const groupsTab = !hideGroupsTab && (
|
||||
@@ -722,10 +809,28 @@ const TabTriggers = ({
|
||||
</TabsTrigger>
|
||||
);
|
||||
|
||||
const tabMap = {
|
||||
const clustersTab = showClusters && (
|
||||
<TabsTrigger
|
||||
key="clusters"
|
||||
value={"clusters"}
|
||||
className={"text-[.8rem] font-normal"}
|
||||
onClick={() => searchRef.current?.focus()}
|
||||
>
|
||||
<ServerIcon
|
||||
className={
|
||||
"text-nb-gray-500 group-data-[state=active]/trigger:text-netbird transition-all"
|
||||
}
|
||||
size={14}
|
||||
/>
|
||||
Proxy Clusters
|
||||
</TabsTrigger>
|
||||
);
|
||||
|
||||
const tabMap: Record<PeerGroupSelectorTab, React.ReactNode> = {
|
||||
groups: groupsTab,
|
||||
peers: peersTab,
|
||||
resources: resourcesTab,
|
||||
clusters: clustersTab,
|
||||
};
|
||||
|
||||
if (tabOrder) {
|
||||
@@ -741,6 +846,7 @@ const TabTriggers = ({
|
||||
{groupsTab}
|
||||
{resourcesTab}
|
||||
{peersTab}
|
||||
{clustersTab}
|
||||
</TabsList>
|
||||
);
|
||||
};
|
||||
@@ -962,6 +1068,69 @@ const ResourcesList = ({
|
||||
);
|
||||
};
|
||||
|
||||
const ClustersList = ({
|
||||
clusters,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
clusters: ClusterOption[];
|
||||
value?: string;
|
||||
onChange: (cluster?: ClusterOption) => void;
|
||||
}) => {
|
||||
if (clusters.length === 0) {
|
||||
return (
|
||||
<DropdownInfoText className={"mt-5 max-w-sm mx-auto"}>
|
||||
No proxy clusters available. Go to{" "}
|
||||
<InlineLink href={"/reverse-proxy/custom-domains"}>
|
||||
Custom Domains
|
||||
</InlineLink>{" "}
|
||||
to configure one that supports private services.
|
||||
</DropdownInfoText>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Radio defaultValue={value} name={"cluster"} value={value}>
|
||||
<ScrollArea
|
||||
className={"max-h-[195px] flex flex-col gap-1 py-2 px-2"}
|
||||
>
|
||||
{clusters.map((c) => (
|
||||
<CommandItem
|
||||
key={c.domain}
|
||||
value={c.domain}
|
||||
onSelect={() => onChange(c)}
|
||||
onClick={(e) => e.preventDefault()}
|
||||
>
|
||||
<div className={"flex items-center gap-2"}>
|
||||
<Badge
|
||||
useHover={false}
|
||||
variant={"gray-ghost"}
|
||||
className={cn(
|
||||
"transition-all group whitespace-nowrap h-7 px-2",
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
}}
|
||||
>
|
||||
<ServerIcon size={12} className={"shrink-0"} />
|
||||
<TextWithTooltip text={c.label ?? c.domain} maxChars={32} />
|
||||
</Badge>
|
||||
</div>
|
||||
<div
|
||||
className={
|
||||
"text-neutral-500 dark:text-nb-gray-300 font-medium flex items-center gap-2"
|
||||
}
|
||||
>
|
||||
{c.label && c.label !== c.domain ? c.domain : null}
|
||||
<RadioItem value={c.domain} />
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</ScrollArea>
|
||||
</Radio>
|
||||
);
|
||||
};
|
||||
|
||||
const peersSearchPredicate = (item: Peer, query: string) => {
|
||||
const lowerCaseQuery = query.toLowerCase();
|
||||
if (item.name.toLowerCase().includes(lowerCaseQuery)) return true;
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* Tests for Separator component class string.
|
||||
*
|
||||
* The PR changed the background class from "bg-zinc-700/40" to
|
||||
* "bg-nb-gray-800/40" so the separator follows the new theme-aware
|
||||
* nb-gray colour system instead of the hard-coded zinc colour.
|
||||
*
|
||||
* Because Separator is a trivial presentational component (no logic), we
|
||||
* verify the component source text directly rather than rendering it.
|
||||
*
|
||||
* Run with: npx tsx src/components/Separator.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 source = readFileSync(join(__dir, "Separator.tsx"), "utf-8");
|
||||
|
||||
type Check = { desc: string; pattern: RegExp | string; shouldMatch: boolean };
|
||||
|
||||
const checks: Check[] = [
|
||||
{
|
||||
desc: "uses nb-gray-800/40 background",
|
||||
pattern: "bg-nb-gray-800/40",
|
||||
shouldMatch: true,
|
||||
},
|
||||
{
|
||||
desc: "does NOT use the old zinc-700/40 background",
|
||||
pattern: "bg-zinc-700/40",
|
||||
shouldMatch: false,
|
||||
},
|
||||
{
|
||||
desc: "still renders a span element",
|
||||
pattern: "<span",
|
||||
shouldMatch: true,
|
||||
},
|
||||
{
|
||||
desc: "still includes height and width classes",
|
||||
pattern: "h-[1px]",
|
||||
shouldMatch: true,
|
||||
},
|
||||
{
|
||||
desc: "w-full present",
|
||||
pattern: "w-full",
|
||||
shouldMatch: true,
|
||||
},
|
||||
{
|
||||
desc: "block display class present",
|
||||
pattern: /\bblock\b/,
|
||||
shouldMatch: true,
|
||||
},
|
||||
];
|
||||
|
||||
let failures = 0;
|
||||
console.log("=== Separator source checks ===");
|
||||
|
||||
for (const { desc, pattern, shouldMatch } of checks) {
|
||||
const matched =
|
||||
pattern instanceof RegExp ? pattern.test(source) : source.includes(pattern as string);
|
||||
const ok = matched === shouldMatch;
|
||||
if (!ok) failures++;
|
||||
console.log(
|
||||
`${ok ? "✓" : "✗"} ${desc}` +
|
||||
(ok ? "" : ` [expected shouldMatch=${shouldMatch}]`),
|
||||
);
|
||||
}
|
||||
|
||||
// Boundary: the class attribute should contain exactly the expected string
|
||||
const classMatch = source.match(/className=\{["']([^"']+)["']\}/);
|
||||
if (classMatch) {
|
||||
const classValue = classMatch[1];
|
||||
const ok = classValue.includes("bg-nb-gray-800/40") && !classValue.includes("bg-zinc-700/40");
|
||||
if (!ok) failures++;
|
||||
console.log(
|
||||
`${ok ? "✓" : "✗"} className attribute has bg-nb-gray-800/40 and not bg-zinc-700/40` +
|
||||
(ok ? "" : ` (className: "${classValue}")`),
|
||||
);
|
||||
}
|
||||
|
||||
console.log(`\n${failures} test(s) failed`);
|
||||
process.exit(failures > 0 ? 1 : 0);
|
||||
@@ -13,6 +13,7 @@ type SettingCardItemProps = {
|
||||
description: React.ReactNode;
|
||||
enabled: boolean;
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
function SettingCardItem({
|
||||
@@ -20,21 +21,31 @@ function SettingCardItem({
|
||||
description,
|
||||
enabled,
|
||||
onClick,
|
||||
disabled = false,
|
||||
}: Readonly<SettingCardItemProps>) {
|
||||
const handleClick = () => {
|
||||
if (disabled) return;
|
||||
onClick();
|
||||
};
|
||||
return (
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={onClick}
|
||||
tabIndex={disabled ? -1 : 0}
|
||||
aria-disabled={disabled || undefined}
|
||||
onClick={handleClick}
|
||||
onKeyDown={(e) => {
|
||||
if (disabled) return;
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
onClick();
|
||||
}
|
||||
}}
|
||||
className={
|
||||
"flex justify-between gap-10 px-6 border-t border-nb-gray-920 first:border-t-0 py-5 hover:bg-nb-gray-935 cursor-pointer transition-colors"
|
||||
}
|
||||
className={cn(
|
||||
"flex justify-between gap-10 px-6 border-t border-nb-gray-920 first:border-t-0 py-5 transition-colors",
|
||||
disabled
|
||||
? "opacity-50 cursor-not-allowed"
|
||||
: "hover:bg-nb-gray-935 cursor-pointer",
|
||||
)}
|
||||
>
|
||||
<div className={"max-w-sm"}>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -56,7 +67,8 @@ function SettingCardItem({
|
||||
variant={"secondaryLighter"}
|
||||
size={"xs"}
|
||||
className={"pl-3 pr-3"}
|
||||
onClick={onClick}
|
||||
onClick={handleClick}
|
||||
disabled={disabled}
|
||||
>
|
||||
<SquarePen size={12} />
|
||||
Edit
|
||||
@@ -66,7 +78,8 @@ function SettingCardItem({
|
||||
variant={"secondaryLighter"}
|
||||
size={"xs"}
|
||||
className={"pl-3 pr-3"}
|
||||
onClick={onClick}
|
||||
onClick={handleClick}
|
||||
disabled={disabled}
|
||||
>
|
||||
<PlusCircle size={12} />
|
||||
Add
|
||||
|
||||
@@ -0,0 +1,331 @@
|
||||
/**
|
||||
* Source-level regression tests for the light-mode class-name changes
|
||||
* introduced in this PR.
|
||||
*
|
||||
* These components had "dark:" prefixes removed, "text-white" replaced with
|
||||
* "text-nb-gray-100", or similar theme-agnostic rewrites. Because the
|
||||
* components import React and Next.js modules (preventing plain-Node
|
||||
* execution), we verify the source files directly to ensure the correct
|
||||
* class strings are present.
|
||||
*
|
||||
* Files covered:
|
||||
* - HelpText.tsx (dark: prefix removed from text colour)
|
||||
* - Input.tsx (hover:text-white → hover:text-nb-gray-100, dark: removed from prefix/suffix)
|
||||
* - PinCodeInput.tsx (dark: prefix removed from bg and border)
|
||||
* - Code.tsx (dark: prefix removed from text colour)
|
||||
* - FullTooltip.tsx (hover:text-neutral-100 → hover:text-nb-gray-100)
|
||||
* - Notification.tsx (text-white → text-nb-gray-100)
|
||||
* - PeerSelector.tsx (text-white → text-nb-gray-100)
|
||||
* - NetworkRouteSelector.tsx (text-white → text-nb-gray-100)
|
||||
* - PortSelector.tsx (text-white → text-nb-gray-100)
|
||||
* - DatePickerWithRange.tsx (text-white → text-nb-gray-100 in CalendarButton)
|
||||
* - DataTableFilter.tsx (text-white → text-nb-gray-100)
|
||||
* - Modal.tsx (border-zinc-700/40 → border-neutral-200)
|
||||
*
|
||||
* Run with: npx tsx src/components/light-mode-class-changes.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);
|
||||
|
||||
function readSource(file: string): string {
|
||||
return readFileSync(join(__dir, file), "utf-8");
|
||||
}
|
||||
|
||||
type FileCheck = {
|
||||
file: string;
|
||||
checks: Array<{ desc: string; pattern: string | RegExp; shouldContain: boolean }>;
|
||||
};
|
||||
|
||||
const fileChecks: FileCheck[] = [
|
||||
// ── HelpText.tsx ───────────────────────────────────────────────────────────
|
||||
{
|
||||
file: "HelpText.tsx",
|
||||
checks: [
|
||||
{
|
||||
desc: "uses theme-agnostic text-nb-gray-300",
|
||||
pattern: "text-nb-gray-300",
|
||||
shouldContain: true,
|
||||
},
|
||||
{
|
||||
desc: "does NOT have dark:text-nb-gray-300",
|
||||
pattern: "dark:text-nb-gray-300",
|
||||
shouldContain: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ── Input.tsx ──────────────────────────────────────────────────────────────
|
||||
{
|
||||
file: "Input.tsx",
|
||||
checks: [
|
||||
{
|
||||
desc: "password toggle uses hover:text-nb-gray-100",
|
||||
pattern: "hover:text-nb-gray-100",
|
||||
shouldContain: true,
|
||||
},
|
||||
{
|
||||
desc: "password toggle does NOT use hover:text-white",
|
||||
pattern: "hover:text-white",
|
||||
shouldContain: false,
|
||||
},
|
||||
{
|
||||
desc: "prefix div uses theme-agnostic text-nb-gray-300",
|
||||
pattern: /absolute left-0[^"]*text-xs text-nb-gray-300/,
|
||||
shouldContain: true,
|
||||
},
|
||||
{
|
||||
desc: "prefix div does NOT use dark:text-nb-gray-300",
|
||||
pattern: "dark:text-nb-gray-300",
|
||||
shouldContain: false,
|
||||
},
|
||||
{
|
||||
desc: "suffix div uses theme-agnostic text-nb-gray-300",
|
||||
pattern: /absolute right-0[^"]*text-xs text-nb-gray-300/,
|
||||
shouldContain: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ── PinCodeInput.tsx ───────────────────────────────────────────────────────
|
||||
{
|
||||
file: "PinCodeInput.tsx",
|
||||
checks: [
|
||||
{
|
||||
desc: "input cell uses bg-nb-gray-900 without dark: prefix",
|
||||
pattern: /"bg-nb-gray-900 border border-nb-gray-700"/,
|
||||
shouldContain: true,
|
||||
},
|
||||
{
|
||||
desc: "does NOT use dark:bg-nb-gray-900 (old format)",
|
||||
pattern: "dark:bg-nb-gray-900 border dark:border-nb-gray-700",
|
||||
shouldContain: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ── Code.tsx ───────────────────────────────────────────────────────────────
|
||||
{
|
||||
file: "Code.tsx",
|
||||
checks: [
|
||||
{
|
||||
desc: "light (non-dark prop) text uses theme-agnostic text-nb-gray-200",
|
||||
pattern: '"text-nb-gray-200 hover:text-nb-gray-200"',
|
||||
shouldContain: true,
|
||||
},
|
||||
{
|
||||
desc: "does NOT use dark:text-nb-gray-200 for the non-dark prop path",
|
||||
pattern: "dark:text-nb-gray-200 hover:dark:text-nb-gray-200",
|
||||
shouldContain: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ── FullTooltip.tsx ────────────────────────────────────────────────────────
|
||||
{
|
||||
file: "FullTooltip.tsx",
|
||||
checks: [
|
||||
{
|
||||
desc: "action wrapper uses hover:text-nb-gray-100",
|
||||
pattern: "hover:text-nb-gray-100",
|
||||
shouldContain: true,
|
||||
},
|
||||
{
|
||||
desc: "does NOT use hover:text-neutral-100",
|
||||
pattern: "hover:text-neutral-100",
|
||||
shouldContain: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ── Notification.tsx ───────────────────────────────────────────────────────
|
||||
{
|
||||
file: "Notification.tsx",
|
||||
checks: [
|
||||
{
|
||||
desc: "icon wrapper uses text-nb-gray-100",
|
||||
pattern: "text-nb-gray-100",
|
||||
shouldContain: true,
|
||||
},
|
||||
{
|
||||
desc: "does NOT use bare text-white for icon",
|
||||
pattern: /shadow-sm text-white flex/,
|
||||
shouldContain: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ── PeerSelector.tsx ───────────────────────────────────────────────────────
|
||||
{
|
||||
file: "PeerSelector.tsx",
|
||||
checks: [
|
||||
{
|
||||
desc: "selected-value div uses text-nb-gray-100",
|
||||
pattern: "text-nb-gray-100",
|
||||
shouldContain: true,
|
||||
},
|
||||
{
|
||||
desc: "selected-value wrapper string uses text-nb-gray-100",
|
||||
pattern: '"flex items-center justify-between text-sm text-nb-gray-100',
|
||||
shouldContain: true,
|
||||
},
|
||||
{
|
||||
desc: "does NOT use bare text-white in selected value wrapper",
|
||||
pattern: '"flex items-center justify-between text-sm text-white',
|
||||
shouldContain: false,
|
||||
},
|
||||
{
|
||||
desc: "does NOT use text-white anywhere for option/value selection",
|
||||
pattern: '"text-white"',
|
||||
shouldContain: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ── NetworkRouteSelector.tsx ───────────────────────────────────────────────
|
||||
{
|
||||
file: "NetworkRouteSelector.tsx",
|
||||
checks: [
|
||||
{
|
||||
desc: "selected value text uses text-nb-gray-100",
|
||||
pattern: '"flex items-center justify-between text-sm text-nb-gray-100',
|
||||
shouldContain: true,
|
||||
},
|
||||
{
|
||||
desc: "does NOT use text-white in selected value wrapper",
|
||||
pattern: '"flex items-center justify-between text-sm text-white',
|
||||
shouldContain: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ── PortSelector.tsx ───────────────────────────────────────────────────────
|
||||
{
|
||||
file: "PortSelector.tsx",
|
||||
checks: [
|
||||
{
|
||||
desc: "version callout uses text-nb-gray-100",
|
||||
pattern: '"text-nb-gray-100 font-normal"',
|
||||
shouldContain: true,
|
||||
},
|
||||
{
|
||||
desc: "does NOT use text-white in version callout",
|
||||
pattern: '"text-white font-normal"',
|
||||
shouldContain: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ── Tooltip.tsx ────────────────────────────────────────────────────────────
|
||||
{
|
||||
file: "Tooltip.tsx",
|
||||
checks: [
|
||||
{
|
||||
desc: "uses text-nb-gray-50 (not text-neutral-50)",
|
||||
pattern: "text-nb-gray-50",
|
||||
shouldContain: true,
|
||||
},
|
||||
{
|
||||
desc: "does NOT use text-neutral-50",
|
||||
pattern: "text-neutral-50",
|
||||
shouldContain: false,
|
||||
},
|
||||
{
|
||||
desc: "default variant does not include border-neutral-200",
|
||||
pattern: "border-neutral-200",
|
||||
shouldContain: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// ─── run ───────────────────────────────────────────────────────────────────
|
||||
|
||||
let failures = 0;
|
||||
for (const { file, checks } of fileChecks) {
|
||||
console.log(`\n=== ${file} ===`);
|
||||
let source: string;
|
||||
try {
|
||||
source = readSource(file);
|
||||
} catch {
|
||||
console.log(`✗ COULD NOT READ ${file}`);
|
||||
failures++;
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const { desc, pattern, shouldContain } of checks) {
|
||||
const matches =
|
||||
pattern instanceof RegExp ? pattern.test(source) : source.includes(pattern as string);
|
||||
const ok = matches === shouldContain;
|
||||
if (!ok) failures++;
|
||||
console.log(
|
||||
`${ok ? "✓" : "✗"} ${desc}` +
|
||||
(ok ? "" : ` [expected shouldContain=${shouldContain}]`),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Modal.tsx is in a subdirectory ────────────────────────────────────────
|
||||
{
|
||||
console.log("\n=== modal/Modal.tsx ===");
|
||||
const modalSource = readFileSync(join(__dir, "modal", "Modal.tsx"), "utf-8");
|
||||
const modalChecks = [
|
||||
{
|
||||
desc: "SidebarModalContent uses border-neutral-200 in light mode",
|
||||
pattern: "border-neutral-200",
|
||||
shouldContain: true,
|
||||
},
|
||||
{
|
||||
desc: "does NOT use border-zinc-700/40 as the primary border",
|
||||
// The old value was "border border-zinc-700/40"; it's now "border border-neutral-200"
|
||||
pattern: "border border-zinc-700/40",
|
||||
shouldContain: false,
|
||||
},
|
||||
];
|
||||
for (const { desc, pattern, shouldContain } of modalChecks) {
|
||||
const matches = modalSource.includes(pattern);
|
||||
const ok = matches === shouldContain;
|
||||
if (!ok) failures++;
|
||||
console.log(
|
||||
`${ok ? "✓" : "✗"} ${desc}` +
|
||||
(ok ? "" : ` [expected shouldContain=${shouldContain}]`),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── table/DataTableFilter.tsx ─────────────────────────────────────────────
|
||||
{
|
||||
console.log("\n=== table/DataTableFilter.tsx ===");
|
||||
const dtfSource = readFileSync(
|
||||
join(__dir, "table", "DataTableFilter.tsx"),
|
||||
"utf-8",
|
||||
);
|
||||
const dtfChecks = [
|
||||
{
|
||||
desc: "filter count span uses text-nb-gray-100",
|
||||
pattern: '"text-nb-gray-100"',
|
||||
shouldContain: true,
|
||||
},
|
||||
{
|
||||
desc: "does NOT use text-white for filter count span",
|
||||
pattern: '"text-white"',
|
||||
shouldContain: false,
|
||||
},
|
||||
];
|
||||
for (const { desc, pattern, shouldContain } of dtfChecks) {
|
||||
const matches = dtfSource.includes(pattern);
|
||||
const ok = matches === shouldContain;
|
||||
if (!ok) failures++;
|
||||
console.log(
|
||||
`${ok ? "✓" : "✗"} ${desc}` +
|
||||
(ok ? "" : ` [expected shouldContain=${shouldContain}]`),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\n${failures} test(s) failed`);
|
||||
process.exit(failures > 0 ? 1 : 0);
|
||||
@@ -0,0 +1,288 @@
|
||||
/**
|
||||
* Tests for cva variant helpers in Tooltip, InlineLink, and Label components.
|
||||
*
|
||||
* These tests replicate each component's cva call so they can run in plain
|
||||
* Node/tsx without needing the full React/Next.js environment (and without
|
||||
* the path-alias shims for @utils/helpers, etc.).
|
||||
*
|
||||
* The PR switched several components from "dark:text-neutral-50" /
|
||||
* "border-neutral-200" to theme-agnostic "text-nb-gray-50" / "border-nb-gray-*"
|
||||
* classes. The tests here verify those exact class strings are present (or
|
||||
* absent) in the generated output.
|
||||
*
|
||||
* Run with: npx tsx src/components/light-mode-variants.test.ts
|
||||
*/
|
||||
|
||||
import { cva } from "class-variance-authority";
|
||||
|
||||
// ─── replicated variant helpers ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Mirrors tooltipVariants from src/components/Tooltip.tsx (post-PR).
|
||||
* Key change: "text-neutral-50" → "text-nb-gray-50",
|
||||
* removed "border-neutral-200" from both variants.
|
||||
*/
|
||||
const tooltipVariants = cva(
|
||||
[
|
||||
"z-[9999] overflow-hidden rounded-md border text-sm shadow-md",
|
||||
"animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
],
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: ["bg-nb-gray-940", "text-nb-gray-50", "border-nb-gray-930"],
|
||||
lighter: ["bg-nb-gray-920", "text-nb-gray-50", "border-nb-gray-900"],
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Mirrors linkVariants from src/components/InlineLink.tsx (post-PR).
|
||||
* Key change: "hover:text-white" → "hover:text-nb-gray-100" for both
|
||||
* "white" and "dashed" variants.
|
||||
*/
|
||||
const linkVariants = cva(
|
||||
"underline-offset-4 items-center transition-all duration-200 inline-flex texts-inherit gap-1",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "text-netbird hover:underline font-normal",
|
||||
faded: "text-nb-gray-400 hover:text-nb-gray-300 hover:underline",
|
||||
white: "text-nb-gray-100 hover:text-nb-gray-100 hover:underline",
|
||||
dashed:
|
||||
"text-nb-gray-100/90 underline font-normal decoration-dashed hover:text-nb-gray-100",
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Mirrors labelVariants from src/components/Label.tsx (post-PR).
|
||||
* Key change: "dark:text-nb-gray-200" → "text-nb-gray-200".
|
||||
*/
|
||||
const labelVariants = cva(
|
||||
"text-sm font-medium tracking-wider leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 mb-1.5 inline-block text-nb-gray-200 flex items-center gap-2",
|
||||
);
|
||||
|
||||
// ─── runner ────────────────────────────────────────────────────────────────
|
||||
|
||||
type Check = { desc: string; classes: string[]; notClasses?: string[] };
|
||||
|
||||
function checkVariant(name: string, result: string, checks: Check[]): number {
|
||||
console.log(`\n=== ${name} ===`);
|
||||
let failures = 0;
|
||||
for (const { desc, classes, notClasses = [] } of checks) {
|
||||
for (const cls of classes) {
|
||||
const ok = result.includes(cls);
|
||||
if (!ok) failures++;
|
||||
console.log(
|
||||
`${ok ? "✓" : "✗"} [${desc}] contains "${cls}"` +
|
||||
(ok ? "" : ` (actual: "${result}")`),
|
||||
);
|
||||
}
|
||||
for (const cls of notClasses) {
|
||||
const ok = !result.includes(cls);
|
||||
if (!ok) failures++;
|
||||
console.log(
|
||||
`${ok ? "✓" : "✗"} [${desc}] does NOT contain "${cls}"` +
|
||||
(ok ? "" : ` (actual: "${result}")`),
|
||||
);
|
||||
}
|
||||
}
|
||||
return failures;
|
||||
}
|
||||
|
||||
let failures = 0;
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// Tooltip variants
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
failures += checkVariant(
|
||||
"tooltipVariants — default",
|
||||
tooltipVariants({ variant: "default" }),
|
||||
[
|
||||
{
|
||||
desc: "uses nb-gray text",
|
||||
classes: ["text-nb-gray-50"],
|
||||
notClasses: ["text-neutral-50"],
|
||||
},
|
||||
{
|
||||
desc: "uses nb-gray border (not neutral-200)",
|
||||
classes: ["border-nb-gray-930"],
|
||||
notClasses: ["border-neutral-200"],
|
||||
},
|
||||
{
|
||||
desc: "background unchanged",
|
||||
classes: ["bg-nb-gray-940"],
|
||||
},
|
||||
{
|
||||
desc: "base animation classes present",
|
||||
classes: ["animate-in", "fade-in-0"],
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
failures += checkVariant(
|
||||
"tooltipVariants — lighter",
|
||||
tooltipVariants({ variant: "lighter" }),
|
||||
[
|
||||
{
|
||||
desc: "uses nb-gray text",
|
||||
classes: ["text-nb-gray-50"],
|
||||
notClasses: ["text-neutral-50"],
|
||||
},
|
||||
{
|
||||
desc: "uses nb-gray border (not neutral-200)",
|
||||
classes: ["border-nb-gray-900"],
|
||||
notClasses: ["border-neutral-200"],
|
||||
},
|
||||
{
|
||||
desc: "lighter background",
|
||||
classes: ["bg-nb-gray-920"],
|
||||
},
|
||||
{
|
||||
desc: "darker background not mixed in",
|
||||
notClasses: ["bg-nb-gray-940"],
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// InlineLink variants
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
failures += checkVariant(
|
||||
"linkVariants — white",
|
||||
linkVariants({ variant: "white" }),
|
||||
[
|
||||
{
|
||||
desc: "text colour uses nb-gray-100",
|
||||
classes: ["text-nb-gray-100"],
|
||||
notClasses: ["text-white"],
|
||||
},
|
||||
{
|
||||
desc: "hover stays on nb-gray-100 (not white)",
|
||||
classes: ["hover:text-nb-gray-100"],
|
||||
notClasses: ["hover:text-white"],
|
||||
},
|
||||
{
|
||||
desc: "underline on hover",
|
||||
classes: ["hover:underline"],
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
failures += checkVariant(
|
||||
"linkVariants — dashed",
|
||||
linkVariants({ variant: "dashed" }),
|
||||
[
|
||||
{
|
||||
desc: "text colour uses nb-gray-100/90",
|
||||
classes: ["text-nb-gray-100/90"],
|
||||
},
|
||||
{
|
||||
desc: "hover uses nb-gray-100 (not white)",
|
||||
classes: ["hover:text-nb-gray-100"],
|
||||
notClasses: ["hover:text-white"],
|
||||
},
|
||||
{
|
||||
desc: "decoration style",
|
||||
classes: ["decoration-dashed", "underline"],
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
failures += checkVariant(
|
||||
"linkVariants — default (regression)",
|
||||
linkVariants({ variant: "default" }),
|
||||
[
|
||||
{
|
||||
desc: "default variant unchanged",
|
||||
classes: ["text-netbird", "hover:underline"],
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
failures += checkVariant(
|
||||
"linkVariants — faded (regression)",
|
||||
linkVariants({ variant: "faded" }),
|
||||
[
|
||||
{
|
||||
desc: "faded variant unchanged",
|
||||
classes: ["text-nb-gray-400", "hover:text-nb-gray-300"],
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// Label variants
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
failures += checkVariant(
|
||||
"labelVariants — base",
|
||||
labelVariants(),
|
||||
[
|
||||
{
|
||||
desc: "text colour is theme-agnostic nb-gray-200",
|
||||
classes: ["text-nb-gray-200"],
|
||||
notClasses: ["dark:text-nb-gray-200"],
|
||||
},
|
||||
{
|
||||
desc: "typography classes present",
|
||||
classes: [
|
||||
"text-sm",
|
||||
"font-medium",
|
||||
"tracking-wider",
|
||||
"leading-none",
|
||||
"mb-1.5",
|
||||
"inline-block",
|
||||
],
|
||||
},
|
||||
{
|
||||
desc: "disabled peer styles present",
|
||||
classes: [
|
||||
"peer-disabled:cursor-not-allowed",
|
||||
"peer-disabled:opacity-70",
|
||||
],
|
||||
},
|
||||
{
|
||||
desc: "flex layout classes present",
|
||||
classes: ["flex", "items-center", "gap-2"],
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// Boundary / negative cases
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// tooltip: ensure neither variant leaks the other's background
|
||||
{
|
||||
const def = tooltipVariants({ variant: "default" });
|
||||
const lighter = tooltipVariants({ variant: "lighter" });
|
||||
const ok1 = !def.includes("bg-nb-gray-920");
|
||||
const ok2 = !lighter.includes("bg-nb-gray-940");
|
||||
if (!ok1) failures++;
|
||||
if (!ok2) failures++;
|
||||
console.log(
|
||||
`\n=== tooltipVariants — no cross-contamination ===\n` +
|
||||
`${ok1 ? "✓" : "✗"} default does not contain bg-nb-gray-920\n` +
|
||||
`${ok2 ? "✓" : "✗"} lighter does not contain bg-nb-gray-940`,
|
||||
);
|
||||
}
|
||||
|
||||
// linkVariants: calling with no variant falls back to base classes only
|
||||
{
|
||||
const base = linkVariants({});
|
||||
const hasBase = base.includes("underline-offset-4");
|
||||
if (!hasBase) failures++;
|
||||
console.log(
|
||||
`\n=== linkVariants — no variant arg ===\n` +
|
||||
`${hasBase ? "✓" : "✗"} base classes present when no variant is specified`,
|
||||
);
|
||||
}
|
||||
|
||||
console.log(`\n${failures} test(s) failed`);
|
||||
process.exit(failures > 0 ? 1 : 0);
|
||||
@@ -8,7 +8,11 @@ import { useLocalStorage } from "@/hooks/useLocalStorage";
|
||||
import { Peer } from "@/interfaces/Peer";
|
||||
import SetupModal from "@/modules/setup-netbird-modal/SetupModal";
|
||||
|
||||
function AddPeerButton() {
|
||||
type Props = {
|
||||
isUserDevice?: boolean;
|
||||
};
|
||||
|
||||
function AddPeerButton({ isUserDevice }: Readonly<Props>) {
|
||||
const { data: peers } = useFetchApi<Peer[]>("/peers");
|
||||
const { oidcUser: user } = useOidcUser();
|
||||
|
||||
@@ -44,7 +48,7 @@ function AddPeerButton() {
|
||||
Add Peer
|
||||
</Button>
|
||||
</ModalTrigger>
|
||||
<SetupModal user={user} />
|
||||
<SetupModal user={user} isUserDevice={isUserDevice} />
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
|
||||
Loaded 20 of 57 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user