Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
133 changes: 120 additions & 13 deletions src/app/(dashboard)/peer/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,13 @@ import ModalHeader from "@components/modal/ModalHeader";
import { notify } from "@components/Notification";
import Paragraph from "@components/Paragraph";
import { PeerGroupSelector } from "@components/PeerGroupSelector";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@components/Select";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@components/Tabs";
import FullScreenLoading from "@components/ui/FullScreenLoading";
import LoginExpiredBadge from "@components/ui/LoginExpiredBadge";
Expand Down Expand Up @@ -61,7 +68,6 @@ import RoutesProvider from "@/contexts/RoutesProvider";
import { useHasChanges } from "@/hooks/useHasChanges";
import type { Group } from "@/interfaces/Group";
import type { Peer } from "@/interfaces/Peer";
import type { User } from "@/interfaces/User";
import PageContainer from "@/layouts/PageContainer";
import useGroupHelper from "@/modules/groups/useGroupHelper";
import { AccessiblePeersSection } from "@/modules/peer/AccessiblePeersSection";
Expand All @@ -74,6 +80,14 @@ import ReverseProxiesProvider, {
import { ReverseProxyFlatTargetsTabContent } from "@/modules/reverse-proxy/targets/flat/ReverseProxyFlatTargetsTabContent";
import { PeerEditIPModal } from "@/modules/peer/PeerEditIPModal";
import { PeerSSHToggle } from "@/modules/peer/PeerSSHToggle";
import {
inferPeerKind,
normalizePeerKind,
PEER_KIND_LABELS,
supportsPeerKind,
type PeerKind,
type ResolvedPeerKind,
} from "@/modules/peers/peerKind";
import { RDPButton } from "@/modules/remote-access/rdp/RDPButton";
import { SSHButton } from "@/modules/remote-access/ssh/SSHButton";
import { PeerExpirationSettings } from "@/modules/peer/PeerExpirationSettings";
Expand Down Expand Up @@ -119,17 +133,8 @@ export default function PeerPage() {
);
}

// Route the user back to the list view that matches the peer's kind
// (a real user owner → /peers/users, otherwise /peers/servers). Used
// for the breadcrumb and the Cancel back-button so they don't bounce
// through the legacy /peers redirect.
function peerListPath(user: User | undefined): string {
const hasRealUser = !!user && !user.is_service_user;
return hasRealUser ? "/peers/users" : "/peers/servers";
}

function PeerOverview() {
const { peer, user } = usePeer();
const { peer } = usePeer();

return (
<PageContainer>
Expand All @@ -138,7 +143,7 @@ function PeerOverview() {
<div className={"p-default py-6 pb-0"}>
<Breadcrumbs>
<Breadcrumbs.Item
href={peerListPath(user)}
href={"/peers"}
label={"Peers"}
icon={<PeerIcon size={13} />}
/>
Expand Down Expand Up @@ -307,7 +312,7 @@ const PeerHeader = () => {
<Button
variant={"default"}
className={"w-full"}
onClick={() => router.push(peerListPath(user))}
onClick={() => router.push("/peers")}
>
Cancel
</Button>
Expand Down Expand Up @@ -481,6 +486,9 @@ function PeerInformationCard({ peer }: Readonly<{ peer: Peer }>) {
const [showEditIPModal, setShowEditIPModal] = useState(false);
const [showEditIPv6Modal, setShowEditIPv6Modal] = useState(false);
const { permission } = usePermissions();
const hasPeerKind = supportsPeerKind(peer);
const selectedPeerKind = normalizePeerKind(peer.kind);
const inferredPeerKind = inferPeerKind(peer);

const countryText = useMemo(() => {
return getRegionByPeer(peer);
Expand Down Expand Up @@ -510,6 +518,22 @@ function PeerInformationCard({ peer }: Readonly<{ peer: Peer }>) {
});
};

const handleSavePeerKind = (kind: PeerKind) => {
if (!hasPeerKind || !permission.peers.update || kind === selectedPeerKind) {
return;
}

notify({
title: peer.name,
description: "Peer type was successfully updated",
promise: update({ kind }).then(() => {
mutate("/peers/" + peer.id);
mutate("/peers");
}),
loadingMessage: "Updating peer type...",
});
};

return (
<>
<PeerEditIPModal
Expand Down Expand Up @@ -614,6 +638,32 @@ function PeerInformationCard({ peer }: Readonly<{ peer: Peer }>) {
value={peer.hostname}
/>

{hasPeerKind && (
<Card.ListItem
tooltip={false}
label={
<>
<MonitorSmartphoneIcon size={16} className={"shrink-0"} />
Peer Type
</>
}
value={
permission.peers.update ? (
<PeerKindSelect
value={selectedPeerKind}
inferredKind={inferredPeerKind}
onChange={handleSavePeerKind}
/>
) : (
<PeerKindValue
value={selectedPeerKind}
inferredKind={inferredPeerKind}
/>
)
}
/>
)}
Comment on lines +641 to +665

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Show inferred peer type on older backends instead of hiding the entire row.

This block hides “Peer Type” when supportsPeerKind(peer) is false. That drops fallback visibility for older backends; the editor should be hidden there, but inferred type should still be shown read-only.

Proposed fix
-          {hasPeerKind && (
-            <Card.ListItem
-              tooltip={false}
-              label={
-                <>
-                  <MonitorSmartphoneIcon size={16} className={"shrink-0"} />
-                  Peer Type
-                </>
-              }
-              value={
-                permission.peers.update ? (
-                  <PeerKindSelect
-                    value={selectedPeerKind}
-                    inferredKind={inferredPeerKind}
-                    onChange={handleSavePeerKind}
-                  />
-                ) : (
-                  <PeerKindValue
-                    value={selectedPeerKind}
-                    inferredKind={inferredPeerKind}
-                  />
-                )
-              }
-            />
-          )}
+          <Card.ListItem
+            tooltip={false}
+            label={
+              <>
+                <MonitorSmartphoneIcon size={16} className={"shrink-0"} />
+                Peer Type
+              </>
+            }
+            value={
+              hasPeerKind && permission.peers.update ? (
+                <PeerKindSelect
+                  value={selectedPeerKind}
+                  inferredKind={inferredPeerKind}
+                  onChange={handleSavePeerKind}
+                />
+              ) : (
+                <PeerKindValue
+                  value={selectedPeerKind}
+                  inferredKind={inferredPeerKind}
+                />
+              )
+            }
+          />
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
{hasPeerKind && (
<Card.ListItem
tooltip={false}
label={
<>
<MonitorSmartphoneIcon size={16} className={"shrink-0"} />
Peer Type
</>
}
value={
permission.peers.update ? (
<PeerKindSelect
value={selectedPeerKind}
inferredKind={inferredPeerKind}
onChange={handleSavePeerKind}
/>
) : (
<PeerKindValue
value={selectedPeerKind}
inferredKind={inferredPeerKind}
/>
)
}
/>
)}
<Card.ListItem
tooltip={false}
label={
<>
<MonitorSmartphoneIcon size={16} className={"shrink-0"} />
Peer Type
</>
}
value={
hasPeerKind && permission.peers.update ? (
<PeerKindSelect
value={selectedPeerKind}
inferredKind={inferredPeerKind}
onChange={handleSavePeerKind}
/>
) : (
<PeerKindValue
value={selectedPeerKind}
inferredKind={inferredPeerKind}
/>
)
}
/>
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/app/`(dashboard)/peer/page.tsx around lines 641 - 665, The Card.ListItem
for "Peer Type" is currently hidden entirely when hasPeerKind is false, but it
should instead remain visible to show the inferred peer type in read-only mode
for older backends. Remove the conditional wrapper {hasPeerKind && (...)} around
the Card.ListItem so the row is always rendered. The conditional logic inside
the value prop (which shows PeerKindSelect when permission.peers.update is true
and PeerKindValue otherwise) will properly handle showing the read-only inferred
type for older backends that don't support peer kind updates.


<Card.ListItem
label={
<>
Expand Down Expand Up @@ -727,6 +777,63 @@ function PeerInformationCard({ peer }: Readonly<{ peer: Peer }>) {
);
}

function PeerKindValue({
value,
inferredKind,
}: Readonly<{
value: PeerKind;
inferredKind: ResolvedPeerKind;
}>) {
return (
<>
{PEER_KIND_LABELS[value]}
{value === "auto" && (
<span className={"text-nb-gray-300"}>
{" "}
({PEER_KIND_LABELS[inferredKind]})
</span>
)}
</>
);
}

function PeerKindSelect({
value,
inferredKind,
onChange,
}: Readonly<{
value: PeerKind;
inferredKind: ResolvedPeerKind;
onChange: (kind: PeerKind) => void;
}>) {
return (
<Select value={value} onValueChange={(kind) => onChange(kind as PeerKind)}>
<SelectTrigger className={"h-9 min-w-[190px] text-left"}>
<div className={"flex items-center gap-1.5 whitespace-nowrap"}>
<SelectValue />
{value === "auto" && (
<span className={"text-nb-gray-300"}>
({PEER_KIND_LABELS[inferredKind]})
</span>
)}
</div>
</SelectTrigger>
<SelectContent>
<SelectItem
value={"auto"}
description={`Uses the current ${PEER_KIND_LABELS[
inferredKind
].toLowerCase()} classification.`}
>
{PEER_KIND_LABELS.auto}
</SelectItem>
<SelectItem value={"device"}>{PEER_KIND_LABELS.device}</SelectItem>
<SelectItem value={"server"}>{PEER_KIND_LABELS.server}</SelectItem>
</SelectContent>
</Select>
);
}

interface ModalProps {
onSuccess: (name: string) => void;
peer: Peer;
Expand Down
151 changes: 148 additions & 3 deletions src/app/(dashboard)/peers/page.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,150 @@
import { redirect } from "next/navigation";
"use client";

export default function PeersIndex() {
redirect("/peers/users");
import Breadcrumbs from "@components/Breadcrumbs";
import FullTooltip from "@components/FullTooltip";
import InlineLink from "@components/InlineLink";
import Paragraph from "@components/Paragraph";
import SkeletonTable from "@components/skeletons/SkeletonTable";
import { usePortalElement } from "@hooks/usePortalElement";
import { ExternalLinkIcon, InfoIcon } 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 PeersPage() {
const { isRestricted } = usePermissions();

return (
<PageContainer>
{isRestricted ? (
<PeersBlockedView />
) : (
<PeersProvider>
<PeersView />
</PeersProvider>
)}
</PageContainer>
);
}

function PeersView() {
const { peers, isLoading: isPeersLoading } = usePeers();
const { users, isLoading: isUsersLoading } = useUsers();
const { ref: headingRef, portalTarget } =
usePortalElement<HTMLHeadingElement>();

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
href={"/peers"}
label={"Peers"}
icon={<PeerIcon size={13} />}
active
/>
</Breadcrumbs>
<h1 ref={headingRef}>
Peers
<FullTooltip
side={"right"}
align={"center"}
className={"ml-2 align-middle"}
content={
<div className={"max-w-md space-y-3 text-xs leading-relaxed"}>
<div>
<div className={"font-medium text-nb-gray-100"}>Servers</div>
<div>
Servers, VMs, autonomous agents and other unattended
machines with no user behind them, typically enrolled with a
setup key.
</div>
</div>
<div>
<div className={"font-medium text-nb-gray-100"}>Devices</div>
<div>
Laptops, phones and other personal devices with a user
behind them, typically added when the user signs in with
SSO.
</div>
</div>
</div>
}
>
<span
className={
"inline-flex h-5 w-5 cursor-help items-center justify-center rounded-full text-nb-gray-500 transition-colors hover:text-nb-gray-200"
}
>
<InfoIcon size={14} />
</span>
</FullTooltip>
</h1>
<Paragraph>
A list of all machines and devices connected to your private network.{" "}
<InlineLink
href={"https://docs.netbird.io/how-to/add-machines-to-your-network"}
target={"_blank"}
>
Comment on lines +100 to +103

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Locate InlineLink implementation
fd -i 'InlineLink' src

# Inspect component behavior around target/rel propagation
rg -n -C4 'target|rel|noopener|noreferrer' src --iglob '*InlineLink*'

Repository: netbirdio/dashboard

Length of output: 1154


🏁 Script executed:

cat -n src/components/InlineLink.tsx

Repository: netbirdio/dashboard

Length of output: 2035


🏁 Script executed:

sed -n '95,140p' src/app/\(dashboard\)/peers/page.tsx

Repository: netbirdio/dashboard

Length of output: 1527


Add rel="noopener noreferrer" to protect against reverse-tabnabbing.

The InlineLink component does not automatically set the rel attribute when target="_blank" is used. Both instances at lines 100–103 and 130–133 in peers/page.tsx need explicit rel="noopener noreferrer" to prevent reverse-tabnabbing attacks.

Either:

  1. Update the InlineLink component to automatically include rel="noopener noreferrer" when target="_blank" is present, or
  2. Add rel="noopener noreferrer" directly to each InlineLink that uses target="_blank".
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/app/`(dashboard)/peers/page.tsx around lines 100 - 103, The InlineLink
component instances that use target="_blank" are missing the rel="noopener
noreferrer" attribute, which exposes the application to reverse-tabnabbing
attacks. Add the rel="noopener noreferrer" attribute to each InlineLink
component where target="_blank" is specified (there are multiple instances in
the peers/page.tsx file). This prevents the opened page from accessing the
window.opener property and potentially redirecting the original page.

Learn more
<ExternalLinkIcon size={12} />
</InlineLink>
</Paragraph>
</div>
<Suspense fallback={<SkeletonTable />}>
<PeersTable
isLoading={isLoading}
peers={peersWithUser}
headingTarget={portalTarget}
showKindFilters
/>
</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>
);
}
3 changes: 3 additions & 0 deletions src/components/table/DataTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,7 @@ interface DataTableProps<TData, TValue> {
setRowSelection?: React.Dispatch<React.SetStateAction<RowSelectionState>>;
useRowId?: boolean;
headingTarget?: HTMLHeadingElement | null;
headingCountLabel?: string;
showResetFilterButton?: boolean;
serverSidePagination?: boolean;
hasServerSideFilters?: boolean;
Expand Down Expand Up @@ -224,6 +225,7 @@ export function DataTable<TData, TValue>({
setRowSelection,
useRowId,
headingTarget,
headingCountLabel,
showResetFilterButton = true,
serverSidePagination = false,
hasServerSideFilters,
Expand Down Expand Up @@ -643,6 +645,7 @@ export function DataTable<TData, TValue>({
<DataTableHeadingPortal
table={table}
headingTarget={headingTarget}
countLabel={headingCountLabel}
totalRecords={totalRecords}
manualPagination={manualPagination}
hasActiveFilters={hasServerSideFilters}
Expand Down
Loading