Compare commits

..
Author SHA1 Message Date
coderabbitai[bot] da4e7d4fbf CodeRabbit Generated Unit Tests: Generate unit tests for PR changes 2026-06-02 21:46:15 +00:00
Misha Bragin 358b477ded Move setup keys to Settings (#653) 2026-05-27 17:17:04 +02:00
Maycon Santosandcoderabbitai[bot] f535fe2667 Feature/private service expose (#646)
* feat: private service expose

Dashboard surface for the netbird private-service feature: the
reverse-proxy modal gains a Private toggle and the target modal gains
a Direct-upstream option with custom upstream host, both feeding the
backend's Service.private + target.direct_upstream / target.host
fields. The Proxy Events page wraps its table in PeersProvider so
peer-name fallback resolution works for tunnel-peer callers.

Reverse-proxy modal changes (ReverseProxyModal.tsx):
- Private toggle that pivots the modal between standalone and cluster
  target types and auto-injects the cluster target.

Reverse-proxy target modal changes:
- Direct upstream toggle (target dials via the host stack instead of
  the embedded NetBird client).
- Custom upstream host input revealed when Direct upstream is on.
- New ReverseProxyClusterTargetSelector + ReverseProxyAddressInput.
- useReverseProxyTargetOptions updated for the new option shape.

Proxy Events table:
- Reuses across the reverse-proxy events surface; UserCell falls back
  to the peer name when no user is attached to the call.

* feat(reverse-proxy): move NetBird-only access to auth tab + access groups

Restructures the Private (NetBird-only) flow so the auth model is
clearer:

- Removes the 'Private (NetBird-only)' toggle from the Service main
  page. Service mode is the only primary choice now.
- Adds a 'NetBird-only access' toggle on the Authentication tab, gated
  on serviceMode=HTTP and selectedDomain.supports_private===true. When
  the cluster doesn't support it, the toggle is disabled with an
  inline note explaining why.
- Adds an Access Groups picker (PeerGroupSelector) inline on the auth
  tab when NetBird-only is on. Replaces the previous bearer-auth
  distribution_groups overload — these groups go on the new
  service.access_groups field on the wire.
- When NetBird-only is enabled, all other auth modes (SSO, password,
  PIN, headers, link) are hidden — the inbound peer's WireGuard
  identity is the only auth path.
- Adds a 'Direct upstream' toggle to the Advanced settings tab,
  gated on isPrivate + cluster supports private. The toggle is
  service-level in the UI; on save it patches the (single) cluster
  target's options.direct_upstream.
- togglePrivate now also clears bearer/password/pin/header/link
  state when entering private mode (strict mutual exclusivity).

* fix(reverse-proxy): tighten NetBird-only flow per review

Five fixes from the first cut:

1. Auth-tab design mismatch. The NetBird-only block was an inline
   FancyToggleSwitch + inline PeerGroupSelector. It now follows the
   same SettingCard.Item pattern as SSO/Password/PIN/Headers: a
   clickable row showing enabled state that opens a dedicated modal
   (AuthNetBirdOnlyModal) where the access groups are picked.

2. Trimmed wordy NetBird-only description down to one line.

3. Trimmed wordy Direct upstream help text.

4. Removed the per-target Direct upstream toggle from the target
   modal. Direct upstream now lives only at the service level (under
   Advanced settings) for private services. Cluster targets still
   imply direct_upstream via the existing sanitizeTargets path, so
   the wire stays correct.

5. togglePrivate no longer drops the user's existing targets when
   entering private mode. The previous behavior silently dropped any
   non-cluster targets the operator had configured, leaving the
   targets list empty and the Save button disabled. Now: targets
   stay put, canSaveService gates on 'all targets are cluster type'
   so the Save button accurately reflects what the backend will
   accept, and an inline warning explains what to fix when the
   constraint isn't met.

The AuthNetBirdOnlyModal requires at least one access group before
Enable becomes clickable, mirroring the SSO modal's pattern.

* fix(reverse-proxy): allow any target type on private services

The cluster-target restriction was a holdover from the previous
auth-by-bearer-groups model where only cluster targets exposed a
proxy peer that could host the ACL. With the new access_groups path
the ACL is server-side and works regardless of how the proxy reaches
the upstream.

- Drop the inline 'Private services only support cluster targets'
  warning.
- canSaveService no longer requires all targets to be cluster type.
- Service-level Direct upstream now applies to every target (cluster,
  peer, host, domain) when private, so the operator can mix target
  types and still control the dial path globally.
- Tightened NetBird-only description to '...connected peers in the
  selected NetBird groups.' (per review).
- Tightened Direct upstream help to '...reachable without a Wireguard
  connection.' (per review).

A future iteration may add a dedicated cluster-only mode with its own
guided flow; for now the operator picks whatever target types suit
their topology.

* fix(reverse-proxy): count NetBird-only access as protection

The 'No Protection Configured' popup fired even on private services
because isUnprotected only checked the password/PIN/bearer/header/link
auth modes. NetBird-only access is also a form of protection (tunnel
identity + access groups), so include isPrivate in the gate.

* fix(reverse-proxy): show NetBird-only auth and resolve access group names

Two display bugs for private services:

- ReverseProxyModal seeded accessGroups with {id}-only stubs, which
  made useGroupHelper skip its ID→Group resolution and render empty
  pills in the NetBird-only modal when editing an existing service.
  Pass the raw string[] so useGroupHelper resolves full Group objects
  with names against the GroupsProvider.

- ReverseProxyAuthCell only inspected password/pin/bearer/headers
  auth flags, so a NetBird-only service displayed "No Auth" in the
  services table. Add a NetBird-only entry that counts toward the
  auth badge, renders as "NetBird Only" with the CircleUser icon
  when it's the single auth, and lists the access groups (name +
  user count) in the hover.

* feat(reverse-proxy): unify proxy-cluster target into the peer/resource selector

Operators picking a target for a service now see Proxy Clusters as a
third tab alongside Peers and Resources, gated on at least one cluster
advertising supports_private. When the service already has a proxy
cluster the tab lists only that cluster; when it doesn't, picking a
cluster commits it as the service's domain. The dedicated private-mode
cluster picker is removed in favour of this unified flow.

Selecting a cluster target now also auto-flips the service to
NetBird-only, since cluster targets are only reachable over the
WireGuard overlay and SSO/password/PIN advertise an auth path no
public client could exercise. The Access Control tab carries a note
explaining that an allow rule for the account's NetBird network range
is applied automatically alongside any operator-configured rules.

PeerGroupSelector gains opt-in showClusters/clusters/selectedCluster/
onClusterChange props plus a Proxy Clusters tab in its TabsList; the
tabOrder union widens to include "clusters". All call sites that don't
pass showClusters render unchanged.

* fix(reverse-proxy): polish private-service copy and lock Direct Upstream for cluster targets

Title-case NetBird-Only Access and Direct Upstream so the labels read
consistently across the auth tab, the dedicated modal, and the
services-table auth cell.

Cluster targets are reached via the embedded proxy's host network
stack and have no WireGuard endpoint to fall back to, so toggling
Direct Upstream off would silently break them. When any target is a
cluster the toggle is forced on, the FancyToggleSwitch is disabled,
and the helper text explains why. The save path writes the locked-on
value to every target's options.direct_upstream so the persisted
state matches what the UI shows.

* fix(reverse-proxy): support dynamic placeholder and restrict input to IPv4 or IPv6 when required

* fix(reverse-proxy): rename "Agent" to "Peer" in events user cell tooltip

* feat(reverse-proxy): gate NetBird-Only + Direct Upstream on cluster capability

When the selected cluster doesn't have at least one connected embedded
proxy (`netbird proxy`), the NetBird-Only Access and Direct Upstream
controls are visually disabled with a tooltip explaining why, instead of
silently no-op (NetBird-Only) or hidden (Direct Upstream).

- ReverseProxyCluster interface gains the optional `private` flag that
  the management API now exposes on /api/reverse-proxies/clusters.
- ClustersFeaturesCell renders a new "Private" badge for clusters whose
  `private === true`, matching the other capability badges.
- SettingCard.Item gains a `disabled` prop: opacity-50 + cursor-not-allowed,
  click/keyboard guarded, Add/Edit button greyed out. No visual change
  for existing usages (default false).
- ReverseProxyModal:
  - NetBird-Only Access card is now wrapped in a FullTooltip that
    activates only when the cluster lacks support; the SettingCard.Item
    is rendered with `disabled` in that case. Description text stops
    switching based on cluster state — the tooltip explains the gate.
  - Direct Upstream toggle is no longer hidden when supports_private is
    false; it stays visible and disabled (alongside the existing
    hasClusterTarget locked-on case), wrapped in a tooltip explaining
    the cluster requirement. Existing private services whose cluster
    lost the capability now show the control disabled rather than
    vanishing.

* disable button when no group were selected

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* Feature/private service expose update (#651)

* feat(reverse-proxy): refine UI state and copy for NetBird-Only and Direct Upstream controls

- Default `Direct Upstream` to `false` for peer/resource targets; enhance tooltip descriptions.
- Improve conditional rendering of `NetBird-Only Access` setting with appropriate tooltips for unsupported clusters.
- Add cluster badge interaction in `PeerGroupSelector` and dynamic placeholder handling.
- Simplify `ReverseProxyTargetSelector` messaging for proxy forwarding options.
- Enhance `ClustersFeaturesCell` with richer description for the "Private" badge.

* refactor(reverse-proxy): improve tooltip and modal text for clarity

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-05-26 21:37:11 +02:00
Misha Bragin 52f7020a0f Fix broken SSH and RDP (#650) 2026-05-26 20:14:36 +02:00
Misha Bragin a604643f9a Feature/simplified layout (#649)
Splits the Peers sidebar entry into User Devices (/peers/users) and Servers (/peers/servers), with /peers redirecting to User Devices and a shared kind filter splitting peers
by whether the owner is a real user vs a service/no-user. The Servers page description and an inline link replace what was the old "Setup Keys" sidebar item under Peers.
2026-05-25 13:09:30 +02:00
Maycon SantosandEduard Gert 42cd088c5d rebuild self-hosted page as Clusters with type features (#641)
* feat(reverse-proxy): rebuild self-hosted page as Clusters with type + features

The Self-Hosted Proxies page was account-only by design but the
underlying API already returned every cluster the account could see.
Lifting that filter and renaming the page surfaces shared clusters
too — operators can see what NetBird-deployed clusters are reachable
alongside their own self-hosted ones, with online status and feature
support visible per row.

ReverseProxyCluster matches the new backend shape: `type`
(account/shared), `online`, and the three capability flags. The
`isSelfHostedCluster` provider hook now compares against `type ===
account` instead of a deprecated boolean.

Page folder renamed self-hosted-proxies → clusters (history-preserving
git mv). Table columns: Cluster (with an EphemeralPeerIndicator-style
icon next to the name marking account vs shared and a colored dot for
online status), Connected Proxies (plain numeric), Features (one
tooltip-backed badge per supported capability), Actions (Delete only
on account-owned rows; shared clusters render an empty action cell).

Empty state shows when the list is fully empty with a doc link in the
page header. Sidebar entry restored under Reverse Proxy.

* Update record in modal, update doc link, update modal title

* update reverse proxy documentation links to latest anchors

* update cluster modal description to "proxy cluster" instead of "self-hosted cluster"

---------

Co-authored-by: Eduard Gert <kontakt@eduardgert.de>
2026-05-20 11:48:38 +02:00
57 changed files with 3620 additions and 530 deletions

No files matched your search

+4 -1
View File
@@ -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>
+3 -114
View File
@@ -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");
}
@@ -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;
+115
View File
@@ -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;
+122
View File
@@ -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;
@@ -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>
+9
View File
@@ -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} />}
+4 -89
View File
@@ -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");
}
+238
View File
@@ -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);
+237
View File
@@ -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);
+7 -1
View File
@@ -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
+130
View File
@@ -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);
+175 -6
View File
@@ -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;
+84
View File
@@ -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);
+20 -7
View File
@@ -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);
+288
View File
@@ -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);
+6 -2
View File
@@ -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