Skip to content

Add reverse proxy#552

Merged
mlsmaycon merged 15 commits intomainfrom
feature/reverse-proxy
Feb 13, 2026
Merged

Add reverse proxy#552
mlsmaycon merged 15 commits intomainfrom
feature/reverse-proxy

Conversation

@heisbrot
Copy link
Copy Markdown
Contributor

@heisbrot heisbrot commented Feb 12, 2026

Summary by CodeRabbit

  • New Features

    • Full Reverse Proxy UI: Services, Targets, Clusters, Custom Domains (with verification) and a Proxy Events page.
    • In-app modals for service auth (SSO, password, PIN) and a new PIN input component.
  • Improvements

    • Network & Peer pages: tabbed views (Resources, Routing Peers, Services) and improved tables, search and filters.
    • Toast stacking/visibility and global toast styling refined.
  • Chores

    • Notification/Toaster integration updated across the app.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Feb 12, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds a large Reverse Proxy feature (types, provider, pages, tables, modals, events), ServerPagination and useUrlTab hooks, Sonner toast migration, many new UI components/icons, Peer/Network page reorganizations with providers and tabs, assorted component API changes, styling tweaks, and removals/cleanups.

Changes

Cohort / File(s) Summary
Reverse Proxy Core & Types
src/interfaces/ReverseProxy.ts, src/contexts/ReverseProxiesProvider.tsx, src/modules/reverse-proxy/...
New Reverse Proxy domain types, provider, hooks, utilities, and extensive feature UI (tables, targets, domains, auth, events, modals, target context). Hard functional addition with many exported types and components.
Reverse Proxy Pages & Routing
src/app/(dashboard)/reverse-proxy/page.tsx, src/app/(dashboard)/reverse-proxy/services/..., src/app/(dashboard)/reverse-proxy/custom-domains/...
New pages, layouts, and a redirect for reverse-proxy services and custom-domains; permission gated and portal heading wiring.
Reverse Proxy Tables & Targets
src/modules/reverse-proxy/table/..., src/modules/reverse-proxy/targets/..., src/modules/reverse-proxy/targets/flat/...
Many new table components, cell renderers, targets table/modals, flat-targets table, action cells, and related UI.
Server Pagination & URL Tabs
src/contexts/ServerPaginationProvider.tsx, src/hooks/useUrlTab.ts
Added ServerPaginationProvider for server-backed pagination/filters and useUrlTab hook to sync tab state to URL.
Toasts / Notifications Migration
package.json, src/app/globals.css, src/layouts/AppLayout.tsx, src/components/Notification.tsx
Replaced react-hot-toast with sonner, added global CSS for toast behavior, and refactored Notification to id-based timer/MutationObserver logic.
Peer & Network Restructuring
src/app/(dashboard)/peer/page.tsx, src/app/(dashboard)/network/page.tsx, src/modules/networks/...
Wrapped pages with ReverseProxiesProvider/PeerSettingsProvider, replaced static sections with tabbed UI and tab-content components, added flattenReverseProxies usage.
New Components & Icons
src/components/*, src/assets/icons/*
Added PinCodeInput, ExternalLinkText, HelpTooltip, SettingCard, PeerOSIcon/ResourceIcon/ReverseProxyIcon/PeerOrResourceIcon, skeletons, and other UI primitives.
Component API Changes
src/components/table/DataTable.tsx, src/components/Label.tsx, src/components/PeerGroupSelector.tsx, src/components/DatePickerWithRange.tsx, src/components/PinCodeInput.tsx, ...
Public API updates: DataTable gains isFetching/serverSidePagination/hasServerSideFilters and flexible rowClassName; Label supports as prop; PeerGroupSelector gains tab controls; DatePickerWithRange adds disabled; PinCodeInput exposes ref API, etc.
Removals & Cleanups
src/components/ui/AIButton.tsx, src/components/ui/NewBadge.tsx, src/components/ui/PeerBadge.tsx, src/modules/networks/resources/ResourcesSection.tsx, src/modules/networks/routing-peers/NetworkRoutingPeersSection.tsx, src/modules/setup-keys/SetupKeyEphemeralCell.tsx
Removed several small UI components and old section files; replaced with new tabbed/content implementations.
Auth & Query Param Handling
src/auth/OIDCProvider.tsx, src/auth/SecureProvider.tsx, src/interfaces/Permission.ts
Removed localStorage caching from OIDCProvider; SecureProvider now stores/clears selected query params when unauthenticated; added services permission.
Styling, Config & Misc
.gitignore, tailwind.config.ts, src/app/globals.css, package.json, src/app/(dashboard)/settings/page.tsx, src/app/(dashboard)/network-routes/page.tsx
Ignored test env files, tweaked nb-gray palette, added Sonner toast CSS variables, updated dependencies (lucide-react, removed react-hot-toast, added sonner), minor UI spacing and optional chaining fix.

Sequence Diagram(s)

sequenceDiagram
    participant User
    participant UI as ReverseProxyModal
    participant Provider as ReverseProxiesProvider
    participant API as NetBird API
    participant TargetModal as ReverseProxyTargetModal
    participant AuthModal as Auth Modal

    User->>UI: open modal / edit proxy
    UI->>Provider: request domains/proxies (useReverseProxies)
    Provider->>API: GET /reverse-proxies, /domains
    API-->>Provider: return data
    Provider-->>UI: provide domains/proxies

    User->>TargetModal: add/edit target
    TargetModal->>API: validate/resolve peers/resources
    API-->>TargetModal: validation/result

    User->>AuthModal: configure auth
    AuthModal-->>UI: auth config

    User->>UI: submit proxy
    UI->>Provider: handleCreateOrUpdateProxy(payload)
    Provider->>API: POST/PUT /reverse-proxies
    API-->>Provider: success
    Provider->>Provider: mutate() refresh cache
    Provider-->>UI: onSuccess -> close modal
    UI->>User: show notification (sonner)
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Possibly related PRs

Suggested reviewers

  • mlsmaycon
  • braginini
  • bcmmbaga

Poem

"I hopped through code with nimble paws,
Services, targets, tabs and laws.
Sonner toasts now softly sing,
Reverse proxies take to wing.
A little rabbit cheers: hooray—deploy today!" 🐇✨

🚥 Pre-merge checks | ✅ 2 | ❌ 2
❌ Failed checks (2 warnings)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 3.92% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Merge Conflict Detection ⚠️ Warning ❌ Merge conflicts detected (66 files):

⚔️ .gitignore (content)
⚔️ package-lock.json (content)
⚔️ package.json (content)
⚔️ src/app/(dashboard)/network-routes/page.tsx (content)
⚔️ src/app/(dashboard)/network/page.tsx (content)
⚔️ src/app/(dashboard)/peer/page.tsx (content)
⚔️ src/app/(dashboard)/peers/page.tsx (content)
⚔️ src/app/(dashboard)/settings/page.tsx (content)
⚔️ src/app/globals.css (content)
⚔️ src/auth/OIDCProvider.tsx (content)
⚔️ src/auth/SecureProvider.tsx (content)
⚔️ src/components/Breadcrumbs.tsx (content)
⚔️ src/components/Button.tsx (content)
⚔️ src/components/Callout.tsx (content)
⚔️ src/components/CopyToClipboardText.tsx (content)
⚔️ src/components/DatePickerWithRange.tsx (content)
⚔️ src/components/FancyToggleSwitch.tsx (content)
⚔️ src/components/Input.tsx (content)
⚔️ src/components/Label.tsx (content)
⚔️ src/components/Notification.tsx (content)
⚔️ src/components/PeerGroupSelector.tsx (content)
⚔️ src/components/PeerSelector.tsx (content)
⚔️ src/components/PortSelector.tsx (content)
⚔️ src/components/RadioGroup.tsx (content)
⚔️ src/components/SidebarItem.tsx (content)
⚔️ src/components/VirtualScrollAreaList.tsx (content)
⚔️ src/components/modal/Modal.tsx (content)
⚔️ src/components/select/SelectDropdown.tsx (content)
⚔️ src/components/table/DataTable.tsx (content)
⚔️ src/components/table/DataTableHeadingPortal.tsx (content)
⚔️ src/components/ui/GetStartedTest.tsx (content)
⚔️ src/components/ui/NoResults.tsx (content)
⚔️ src/components/ui/SmallBadge.tsx (content)
⚔️ src/components/ui/TruncatedText.tsx (content)
⚔️ src/interfaces/Network.ts (content)
⚔️ src/interfaces/Permission.ts (content)
⚔️ src/layouts/AppLayout.tsx (content)
⚔️ src/layouts/Navigation.tsx (content)
⚔️ src/modules/activity/ActivityDescription.tsx (content)
⚔️ src/modules/control-center/NetworkRoutingPeerCount.tsx (content)
⚔️ src/modules/control-center/nodes/NetworkNode.tsx (content)
⚔️ src/modules/control-center/nodes/PeerNode.tsx (content)
⚔️ src/modules/control-center/nodes/ResourceNode.tsx (content)
⚔️ src/modules/control-center/nodes/SelectPeerNode.tsx (content)
⚔️ src/modules/networks/NetworkProvider.tsx (content)
⚔️ src/modules/networks/misc/NetworkInformationSquare.tsx (content)
⚔️ src/modules/networks/resources/NetworkResourceModal.tsx (content)
⚔️ src/modules/networks/resources/ResourceGroupModal.tsx (content)
⚔️ src/modules/networks/resources/ResourcePolicyCell.tsx (content)
⚔️ src/modules/networks/resources/ResourcesTable.tsx (content)
⚔️ src/modules/networks/routing-peers/NetworkRoutingPeersTable.tsx (content)
⚔️ src/modules/networks/table/NetworkRoutingPeerCell.tsx (content)
⚔️ src/modules/onboarding/Onboarding.tsx (content)
⚔️ src/modules/onboarding/OnboardingDevices.tsx (content)
⚔️ src/modules/peer/AccessiblePeersSection.tsx (content)
⚔️ src/modules/peer/PeerNetworkRoutesSection.tsx (content)
⚔️ src/modules/peer/PeerRemoteJobsSection.tsx (content)
⚔️ src/modules/peer/PeerRoutesTable.tsx (content)
⚔️ src/modules/peers/PeerNameCell.tsx (content)
⚔️ src/modules/peers/PeerVersionCell.tsx (content)
⚔️ src/modules/peers/PeersTable.tsx (content)
⚔️ src/modules/posture-checks/usePostureCheck.ts (content)
⚔️ src/modules/settings/ClientSettingsTab.tsx (content)
⚔️ src/modules/users/table-cells/UserStatusCell.tsx (content)
⚔️ src/utils/api.tsx (content)
⚔️ tailwind.config.ts (content)

These conflicts must be resolved before merging into main.
Resolve conflicts locally and push changes to this branch.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'Add reverse proxy' accurately summarizes the main change: comprehensive implementation of reverse proxy feature with services, custom domains, events, and related infrastructure.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feature/reverse-proxy

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@heisbrot heisbrot marked this pull request as ready for review February 12, 2026 13:53
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 6

Note

Due to the large number of review comments, Critical severity comments were prioritized as inline comments.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
src/app/(dashboard)/settings/page.tsx (1)

58-64: ⚠️ Potential issue | 🔴 Critical

Inconsistent optional chaining — Line 93 will throw if identity_providers is undefined.

Line 59 was updated to use permission?.identity_providers?.read, but Line 93 still uses permission.identity_providers.read without optional chaining. If identity_providers is undefined, the tab trigger is safely hidden but the content render on Line 93 will throw a TypeError.

🐛 Proposed fix
             {account?.settings?.embedded_idp_enabled &&
-              permission.identity_providers.read && <IdentityProvidersTab />}
+              permission?.identity_providers?.read && <IdentityProvidersTab />}

Also applies to: 92-93

src/modules/networks/resources/NetworkResourceModal.tsx (1)

92-107: ⚠️ Potential issue | 🟠 Major

Silent group loss when saveGroups() fails.

If saveGroups() returns a falsy value due to an error (e.g., network failure while persisting a newly created group), the resource is still created with groups: undefined, silently discarding the user's group selection. The user sees a success toast but their groups are gone.

Consider distinguishing between "user selected no groups" and "saving groups failed" — e.g., return early or show an error when saveGroups() fails unexpectedly. The same applies to updateResource on Line 120.

src/components/DeviceCard.tsx (1)

40-41: ⚠️ Potential issue | 🔴 Critical

Both icons render simultaneously in the same 36×36px container when device and resource.type are both present.

ResourceNode passes both resource and device (peer) props to DeviceCard. Since lines 40–41 are independent conditionals and the icon container is only h-9 w-9 (36×36 pixels), both PeerOSIcon and ResourceIcon will render and visually overlap when both props are truthy. If they should be mutually exclusive, refactor to use a conditional ternary. Otherwise, add a comment clarifying that this is intentional and document the expected UI behavior.

Proposed fix (if mutually exclusive)
-        {device && <PeerOSIcon os={device.os} />}
-        {resource?.type && <ResourceIcon type={resource.type} />}
+        {device ? <PeerOSIcon os={device.os} /> : resource?.type ? <ResourceIcon type={resource.type} /> : null}
🤖 Fix all issues with AI agents
In `@src/app/globals.css`:
- Around line 186-189: The --scale formula in the
[data-sonner-toast][data-expanded="false"][data-front="false"] rule is inverted
and produces negative values (causing flipped toasts); update the --scale
calculation used in that selector (the --scale custom property) from
calc(var(--toasts-before) * 0.03 - 1) to calc(1 - var(--toasts-before) * 0.03)
(preserve the !important) so stacked toasts shrink progressively with positive
scale values.

In `@src/modules/networks/routing-peers/NetworkRoutingPeersTabContent.tsx`:
- Around line 29-30: The user lookup incorrectly uses router.peer (a peer ID)
instead of the resolved peer's user ID; update the users search to use the found
peer's user_id (i.e., after const peer = peers?.find(...), change
users?.find((user) => user.id === router.peer) to users?.find((user) => user.id
=== peer?.user_id)), and ensure you handle the case where peer may be undefined
so the user variable becomes undefined when no peer is found.

In `@src/modules/reverse-proxy/events/ReverseProxyEventsLocationIpCell.tsx`:
- Around line 77-83: The CopyToClipboardText component in
ReverseProxyEventsLocationIpCell is sending the literal notification string as
the clipboard payload because its message prop is used as the value written to
clipboard; change the prop so the actual IP is copied by passing event.source_ip
(or the equivalent value shown in the span) as the clipboard payload while still
showing the user-friendly notification text via a separate prop or internal UI;
locate the CopyToClipboardText usage in ReverseProxyEventsLocationIpCell (and
mirror the fix applied in ReverseProxyEventsUrlCell) to ensure the component
writes event.source_ip to the clipboard and retains the notification text for
display.
- Around line 48-54: The tooltip is copying the wrong value for region; update
the CopyToClipboardText usage so it explicitly receives the region string as the
payload to copy instead of reusing another value—e.g. ensure the children remain
{region} and add the explicit copy prop your component expects (for example
textToCopy={region} or value={region}), and guard null/undefined with region ??
""; keep the message as "Region has been copied to your clipboard" on the
CopyToClipboardText component.

In `@src/modules/reverse-proxy/events/ReverseProxyEventsRequestCell.tsx`:
- Around line 34-39: The CopyToClipboardText component is currently receiving
the human-readable notification string in its message prop, so that exact text
gets written to the clipboard; change the message prop to the actual URL built
from the event data (e.g., construct the URL using event.host and event.path —
e.g., `${event.host}${event.path}` or include protocol like
`https://${event.host}${event.path}` as appropriate) so copyToClipboard(message)
writes the URL, while keeping the visible notification text (like "URL has been
copied to your clipboard") shown to the user via the component's UI/props.

In `@src/modules/reverse-proxy/table/ReverseProxyDestinationCell.tsx`:
- Around line 23-27: The CopyToClipboardText component is missing its required
message prop so clicking copies undefined; update the JSX where
CopyToClipboardText is rendered (the instance wrapping the span in
ReverseProxyDestinationCell) to pass message={target.destination} (or
String(target.destination) to be safe) so the component's
copyToClipboard(message) receives the correct destination string.
🟠 Major comments (21)
src/modules/networks/NetworkProvider.tsx-106-115 (1)

106-115: ⚠️ Potential issue | 🟠 Major

Guard on resource being defined before building destinationResource.

openPolicyModal can be called without a resource parameter. When resource is undefined, the code sets { id: resource?.id, type: resource?.type } which becomes { id: undefined, type: undefined }. This violates the PolicyRuleResource contract where id is required (string, not string | undefined), and the as cast bypasses TypeScript's type checking. This can cause bugs downstream when the undefined id is sent to API calls.

Proposed fix
 const openPolicyModal = (network?: Network, resource?: NetworkResource) => {
   const hasResourceGroups = (resource?.groups?.length || 0) > 0;
   setPolicyDefaultSettings({
     destinationGroups: hasResourceGroups ? resource?.groups : undefined,
-    destinationResource: hasResourceGroups
+    destinationResource: hasResourceGroups
       ? undefined
-      : ({
-          id: resource?.id,
-          type: resource?.type,
-        } as PolicyRuleResource),
+      : resource
+        ? ({
+            id: resource.id,
+            type: resource.type,
+          } as PolicyRuleResource)
+        : undefined,
     name:
src/components/SettingCard.tsx-90-92 (1)

90-92: ⚠️ Potential issue | 🟠 Major

Add explicit type declaration for SettingCard.Item to satisfy strict TypeScript.

With strict: true in tsconfig.json, TypeScript won't automatically infer the Item property on the SettingCard function. The direct assignment on line 90 works at runtime but violates type safety. Consumers using SettingCard.Item in ReverseProxyModal.tsx will see TS errors like "Property 'Item' does not exist on type…".

Add an interface extension or type assertion:

interface SettingCard extends React.FC<Readonly<SettingCardProps>> {
  Item: typeof SettingCardItem;
}

Then apply it to the function or use a type assertion as SettingCard after the assignment.

src/components/SidebarItem.tsx-42-45 (1)

42-45: ⚠️ Potential issue | 🟠 Major

Fix false-positive route matching in collapsible item detection.

The path.startsWith(href) check in hasActiveChild causes unintended matches with similarly-named sibling routes. For example, with routes like /networks and /network-routes, navigating to /network-routes would incorrectly expand the /networks collapsible item. The component has an exactPathMatch prop (line 20) used elsewhere but not for collapsible items. Update the logic to check path boundaries:

const hasActiveChild = useMemo(() => {
  if (!collapsible || !href) return false;
  return path === href || path.startsWith(href + '/');
}, [collapsible, href, path]);
src/modules/reverse-proxy/auth/AuthPinModal.tsx-25-26 (1)

25-26: ⚠️ Potential issue | 🟠 Major

Stale state when modal reopens without remounting.

useState(currentPin) and useState(isEnabled && currentPin === "") only initialize on the first mount. If the parent keeps AuthPinModal mounted and toggles open, subsequent opens will show the previous session's pin and isMasked values instead of reinitializing from the new currentPin/isEnabled props.

🐛 Proposed fix — sync state when modal opens
+import React, { useState, useEffect } from "react";
 
 export default function AuthPinModal({
   open,
   onOpenChange,
   currentPin,
   isEnabled,
   onSave,
   onRemove,
 }: Readonly<Props>) {
   const [pin, setPin] = useState(currentPin);
   const [isMasked, setIsMasked] = useState(isEnabled && currentPin === "");
-  const isEditing = isEnabled;
+
+  useEffect(() => {
+    if (open) {
+      setPin(currentPin);
+      setIsMasked(isEnabled && currentPin === "");
+    }
+  }, [open, currentPin, isEnabled]);
+
+  const isEditing = isEnabled;
src/modules/reverse-proxy/auth/AuthSSOModal.tsx-24-25 (1)

24-25: ⚠️ Potential issue | 🟠 Major

Stale local state when currentGroups prop changes.

useState(currentGroups) only uses the prop as the initial value. If the modal is kept mounted and currentGroups changes between opens (e.g., groups are added/removed externally), the local groups state will be stale. Sync it on open:

🐛 Proposed fix
+import React, { useEffect, useState } from "react";
-import React, { useState } from "react";
 ...
   const [groups, setGroups] = useState<Group[]>(currentGroups);
+
+  useEffect(() => {
+    if (open) {
+      setGroups(currentGroups);
+    }
+  }, [open, currentGroups]);

Alternatively, ensure the parent remounts this component (via a key prop) each time the modal opens.

src/contexts/ReverseProxiesProvider.tsx-583-591 (1)

583-591: ⚠️ Potential issue | 🟠 Major

sanitizeTargets removes host only for non-SUBNET targets but doesn't strip frontend-only destination field.

The destination field on ReverseProxyTarget is annotated as // Frontend in the interface. Sending it to the API could cause unexpected behavior if the backend doesn't expect it.

(See the related comment on handleToggle above for the proposed fix.)

src/contexts/ReverseProxiesProvider.tsx-303-330 (1)

303-330: ⚠️ Potential issue | 🟠 Major

Inconsistent target identity matching across operations.

handleSaveTarget (Lines 194-198) matches targets by four fields: path, host, port, and protocol. However, handleToggleTarget (Lines 308-311) and handleDeleteTarget (Lines 382-385) match by only three fields: path, host, port — missing protocol.

If two targets share the same path/host/port but differ in protocol (e.g., HTTP vs HTTPS), toggle/delete would affect both.

Proposed fix for handleToggleTarget
         const isSameTarget =
           t.path === target.path &&
           t.host === target.host &&
-          t.port === target.port;
+          t.port === target.port &&
+          t.protocol === target.protocol;
Proposed fix for handleDeleteTarget
           const isSameTarget =
             t.path === target.path &&
             t.host === target.host &&
-            t.port === target.port;
+            t.port === target.port &&
+            t.protocol === target.protocol;

Also applies to: 356-405, 191-200

src/contexts/ReverseProxiesProvider.tsx-277-301 (1)

277-301: ⚠️ Potential issue | 🟠 Major

handleToggle sends the entire proxy object in the PUT request.

request.put({ ...proxy, enabled: newEnabled, targets: sanitizeTargets(proxy.targets) }) spreads all proxy fields (including frontend-only fields like destination on targets) into the API request. The sanitizeTargets strips host from non-SUBNET targets, but doesn't strip the destination field which is marked as // Frontend in the interface.

Proposed fix in sanitizeTargets
 export function sanitizeTargets(
   targets: ReverseProxyTarget[],
 ): ReverseProxyTarget[] {
   return targets.map((t) => {
-    if (t.target_type === ReversProxyTargetType.SUBNET) return t;
-    const { host: _, ...rest } = t;
+    const { destination: _, ...withoutDestination } = t;
+    if (t.target_type === ReversProxyTargetType.SUBNET) return withoutDestination as ReverseProxyTarget;
+    const { host: _h, ...rest } = withoutDestination;
     return rest as ReverseProxyTarget;
   });
 }
src/modules/reverse-proxy/ReverseProxyModal.tsx-218-220 (1)

218-220: ⚠️ Potential issue | 🟠 Major

linkAuthEnabled has no UI control in the Authentication tab despite being used in auth logic.

linkAuthEnabled is initialized from reverseProxy?.auth?.link_auth?.enabled, included in the hasNoAuth check (line 279), and sent in the auth payload (lines 311-313). However, the Authentication tab (lines 569-607) only provides UI controls for three auth methods (SSO, Password, PIN Code)—no toggle exists for link auth.

This creates an inconsistency:

  • Existing proxies with link auth enabled will preserve that state but users cannot disable it.
  • New proxies can never enable link auth through the UI.

Since link_auth is defined in the ReverseProxyAuth interface and already tracked in auth counts (see ReverseProxyAuthCell), either add a SettingCard.Item for link auth or add a comment explaining why it's intentionally omitted.

src/interfaces/ReverseProxy.ts-14-18 (1)

14-18: ⚠️ Potential issue | 🟠 Major

created_at and certificate_issued_at typed as Date but JSON deserialization yields string.

Unless a custom deserializer is in place, useFetchApi / fetch will parse these as strings from JSON. Typing them as Date will mislead consumers into calling .getTime() etc., which will fail at runtime.

🐛 Proposed fix
 export interface ReverseProxyMeta {
-  created_at: Date;
+  created_at: string;
   status: ReverseProxyStatus;
-  certificate_issued_at?: Date;
+  certificate_issued_at?: string;
 }
src/interfaces/ReverseProxy.ts-73-78 (1)

73-78: ⚠️ Potential issue | 🟠 Major

Fix typo in enum name: ReversProxyTargetTypeReverseProxyTargetType.

Missing the 'e' in "Reverse". This enum is already used across 5 files (30+ references), so correcting it now is critical to avoid greater disruption later. Requires coordinated updates in:

  • src/interfaces/ReverseProxy.ts (definition)
  • src/contexts/ReverseProxiesProvider.tsx
  • src/modules/reverse-proxy/table/ReverseProxyTable.tsx
  • src/modules/reverse-proxy/targets/ReverseProxyTargetDevice.tsx
  • src/modules/reverse-proxy/targets/ReverseProxyTargetModal.tsx
🐛 Proposed fix
-export enum ReversProxyTargetType {
+export enum ReverseProxyTargetType {
   PEER = "peer",
   HOST = "host",
   DOMAIN = "domain",
   SUBNET = "subnet",
 }
src/modules/reverse-proxy/table/ReverseProxyTargetsCell.tsx-44-45 (1)

44-45: ⚠️ Potential issue | 🟠 Major

Update permission check to use services.create instead of routes.create for consistency with target edit/delete operations.

The "Add Target" button in ReverseProxyTargetsCell uses permission?.routes?.create, but editing and deleting targets both use permission?.services?.update and permission?.services?.delete. Additionally, the equivalent functionality in ReverseProxyFlatTargetsTable correctly uses permission?.services?.create. The services module is the dedicated permission scope for reverse proxy operations, while routes is shared with network routes and exit nodes. Align this button to use permission?.services?.create to maintain consistent permission checks across all target operations.

src/interfaces/ReverseProxy.ts-106-116 (1)

106-116: ⚠️ Potential issue | 🟠 Major

Fix incorrect documentation URL path and map each constant to distinct subsections.

The URL path /how-to/reverse-proxy appears to be incorrect—the actual NetBird docs use /selfhosted/reverse-proxy. Additionally, the documentation page has subsections (Quick Setup, Configuration Templates, Troubleshooting, etc.) that these constants should reference distinctly rather than all pointing to the same URL:

  • REVERSE_PROXY_DOCS_LINK → General reverse-proxy page
  • REVERSE_PROXY_CLUSTERS_DOCS_LINK → Relevant subsection for clusters
  • REVERSE_PROXY_CUSTOM_DOMAINS_DOCS_LINK → Relevant subsection for custom domains
  • REVERSE_PROXY_DOMAIN_VERIFICATION_LINK → Relevant subsection for domain verification
src/modules/reverse-proxy/targets/ReverseProxyTargetActiveCell.tsx-11-31 (1)

11-31: ⚠️ Potential issue | 🟠 Major

Missing permission gate — inconsistent with ReverseProxyActiveCell.

ReverseProxyActiveCell (the proxy-level toggle) disables the switch when !permission?.services?.update, but this target-level toggle has no such guard. A user without update permission can still toggle individual targets.

Proposed fix
 export default function ReverseProxyTargetActiveCell({
   target,
 }: Readonly<Props>) {
   const reverseProxy = useReverseProxyTarget();
   const { handleToggleTarget } = useReverseProxies();
+  const { permission } = usePermissions();
 
   return (
     <div
       onClick={(e) => {
         e.stopPropagation();
       }}
     >
       <ToggleSwitch
+        disabled={!permission?.services?.update}
         checked={target.enabled !== false}
         size={"small"}
         onCheckedChange={() => {
           handleToggleTarget(reverseProxy, target);
         }}
       />
     </div>
   );
 }

You'll also need to add the import:

import { usePermissions } from "@/contexts/PermissionsProvider";
src/modules/reverse-proxy/targets/ReverseProxyTargetModal.tsx-229-248 (1)

229-248: ⚠️ Potential issue | 🟠 Major

Bug: editing a disabled target silently re-enables it.

enabled is hardcoded to true in the save payload (Line 243). When a user edits a disabled target (e.g., changing its port), saving will flip it back to enabled without any indication.

Proposed fix
     const targetData: ReverseProxyTarget = {
       target_type: resolvedType,
       target_id:
         resolvedType === ReversProxyTargetType.PEER
           ? targetPeerId
           : targetResourceId,
       protocol: targetProtocol,
       host:
         resolvedType === ReversProxyTargetType.SUBNET ? targetHost : undefined,
       port: targetPort,
       path: targetPath || undefined,
-      enabled: true,
+      enabled: currentTarget?.enabled ?? true,
       access_local: isResource ? accessLocal : undefined,
     };
src/app/(dashboard)/reverse-proxy/page.tsx-10-12 (1)

10-12: ⚠️ Potential issue | 🟠 Major

Use router.replace instead of router.push to avoid a redirect loop on back navigation.

router.push adds /reverse-proxy to the browser history stack. When users press the back button, they'll land on /reverse-proxy again, which immediately redirects forward — creating an effectively broken back button.

Proposed fix
   useEffect(() => {
-    router.push("/reverse-proxy/services");
+    router.replace("/reverse-proxy/services");
   }, [router]);
src/modules/networks/resources/ResourceExposeServiceCell.tsx-67-67 (1)

67-67: ⚠️ Potential issue | 🟠 Major

Wrong permission check — should be services?.create, not routes?.create.

This button creates a reverse proxy service, but the disabled check uses permission.routes?.create. Other reverse proxy UI in this PR (e.g., ReverseProxyFlatTargetsTable line 196) correctly checks permission?.services?.create. This mismatch could grant or deny access based on the wrong capability.

🐛 Proposed fix
-        disabled={!permission.routes?.create}
+        disabled={!permission?.services?.create}
src/app/(dashboard)/peer/page.tsx-304-314 (1)

304-314: ⚠️ Potential issue | 🟠 Major

Save button checks peers.read instead of peers.update.

Line 310 disables the Save button when !permission.peers.read, but the intent is to prevent saving when the user lacks update permission. The updatePeer function (Line 189) already branches on permission.peers.update, so the button guard should match:

🐛 Proposed fix
              disabled={
                !hasChanges ||
-               !permission.peers.read ||
+               !permission.peers.update ||
                !permission.groups.update
              }
src/modules/reverse-proxy/table/ReverseProxyTable.tsx-214-214 (1)

214-214: ⚠️ Potential issue | 🟠 Major

Fallback to example data in production is likely unintended.

When reverseProxies is undefined (e.g., still loading or no data), the table renders hardcoded example entries instead of showing the empty-state getStartedCard. The getStartedCard will never display because data always has entries due to the fallback.

Consider using reverseProxies directly and letting the empty state handle the no-data case:

🐛 Proposed fix
-  const data = reverseProxies ?? exampleReverseProxies;
+  const data = reverseProxies;

If the example data is intended only for development, gate it behind a process.env.NODE_ENV === 'development' check, or remove it entirely.

src/modules/reverse-proxy/table/ReverseProxyTable.tsx-104-192 (1)

104-192: ⚠️ Potential issue | 🟠 Major

Hardcoded credentials in example data shipped to the client.

This example dataset contains plaintext secrets (password: "secret", pin: "1234") and will be bundled into the client-side JavaScript. Even as demo data, this sets a poor precedent and could confuse users or leak into production UI.

🛡️ Suggested fix: remove sensitive fields from example data
     auth: {
-      password_auth: { enabled: true, password: "secret" },
+      password_auth: { enabled: true },
     },
     auth: {
-      bearer_auth: { enabled: true, distribution_groups: ["developers"] },
-      pin_auth: { enabled: true, pin: "1234" },
+      bearer_auth: { enabled: true, distribution_groups: ["developers"] },
+      pin_auth: { enabled: true },
     },
src/app/(dashboard)/peer/page.tsx-361-369 (1)

361-369: ⚠️ Potential issue | 🟠 Major

Use permission.services.read instead of permission.routes.read for the reverse-proxies tab.

The reverse-proxies tab is inconsistently gated on permission.routes.read while all other reverse-proxy UIs consistently use permission.services.* (ReverseProxyTable.tsx line 259, ReverseProxyActionCell.tsx lines 44 & 58, ReverseProxyActiveCell.tsx line 20, etc.). The permission.services.read permission is already defined and used throughout the codebase (Navigation.tsx, reverse-proxy/services/page.tsx, reverse-proxy/custom-domains/page.tsx, events/proxy/page.tsx). Align this tab with the established permission pattern.

🟡 Minor comments (18)
src/components/Input.tsx-130-131 (1)

130-131: ⚠️ Potential issue | 🟡 Minor

Missing dark: prefix on readOnly styles — breaks light mode.

Every other usage of nb-gray-* tokens in this file is scoped behind the dark: variant (e.g., dark:bg-nb-gray-900, dark:border-nb-gray-700). These classes will apply dark-themed colors unconditionally, including in light mode.

Proposed fix
-              props.readOnly &&
-                "!bg-nb-gray-920 text-nb-gray-400 !border-nb-gray-800",
+              props.readOnly &&
+                "dark:!bg-nb-gray-920 dark:text-nb-gray-400 dark:!border-nb-gray-800",
src/modules/peer/PeerNetworkRoutesSection.tsx-48-51 (1)

48-51: ⚠️ Potential issue | 🟡 Minor

Complete the headingTarget prop removal in PeerRoutesTable.

The prop was removed from the call site (lines 66-70 in PeerNetworkRoutesSection.tsx) but not from the component definition. PeerRoutesTable still defines, destructures, and passes headingTarget to DataTable. Remove it from the Props type, function signature, and the DataTable component call in PeerRoutesTable.tsx to fully complete the refactoring.

src/components/SettingCard.tsx-25-29 (1)

25-29: ⚠️ Potential issue | 🟡 Minor

Clickable div lacks keyboard accessibility.

The container div acts as a button (has cursor-pointer and onClick) but lacks role="button", tabIndex={0}, and an onKeyDown handler for Enter/Space. Screen-reader and keyboard-only users cannot activate this item.

Proposed fix
     <div
       onClick={onClick}
+      role="button"
+      tabIndex={0}
+      onKeyDown={(e) => {
+        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"
       }
src/components/Breadcrumbs.tsx-48-54 (1)

48-54: ⚠️ Potential issue | 🟡 Minor

Accessibility: clickable <span> lacks keyboard navigation and link semantics.

Using a <span onClick> for navigation is not accessible — it's not focusable, doesn't respond to Enter/Space, and screen readers won't announce it as a link. Consider using Next.js <Link> or at minimum an <a> tag, which also gives users right-click context menus and status bar URL previews.

♻️ Suggested fix using Next.js Link
+import Link from "next/link";
         {href ? (
-          <span onClick={() => router.push(href)} data-cy={"breadcrumb-item"}>
+          <Link href={href} data-cy={"breadcrumb-item"}>
             {label}
-          </span>
+          </Link>
         ) : (
           label
         )}
src/modules/reverse-proxy/table/ReverseProxyNameCell.tsx-60-62 (1)

60-62: ⚠️ Potential issue | 🟡 Minor

Empty domain produces an invalid link.

When displayDomain falls through to the default "" (Line 20), the rendered href becomes "https://" — a broken URL that users can click. Consider either hiding the link when the domain is empty or rendering plain text instead.

Proposed fix
-        <div className="flex flex-col gap-0 dark:text-neutral-300 text-neutral-500 truncate">
-          <ExternalLinkText href={`https://${displayDomain}`}>
-            <span className="font-medium truncate">{displayDomain}</span>
-          </ExternalLinkText>
-        </div>
+        <div className="flex flex-col gap-0 dark:text-neutral-300 text-neutral-500 truncate">
+          {displayDomain ? (
+            <ExternalLinkText href={`https://${displayDomain}`}>
+              <span className="font-medium truncate">{displayDomain}</span>
+            </ExternalLinkText>
+          ) : (
+            <span className="font-medium truncate text-nb-gray-400">—</span>
+          )}
+        </div>
src/modules/networks/routing-peers/NetworkRoutingPeersTabContent.tsx-35-39 (1)

35-39: ⚠️ Potential issue | 🟡 Minor

Search string includes literal "undefined" for missing values.

When peer, user, or group is undefined, template literal interpolation produces the string "undefined", which becomes searchable text. Use nullish coalescing to substitute empty strings:

🐛 Proposed fix
       return {
         ...router,
-        search: `${peer?.name} ${peer?.ip} ${user?.name} ${user?.id} ${group?.name}`,
+        search: `${peer?.name ?? ""} ${peer?.ip ?? ""} ${user?.name ?? ""} ${user?.id ?? ""} ${group?.name ?? ""}`,
       };
src/components/Label.tsx-21-27 (1)

21-27: ⚠️ Potential issue | 🟡 Minor

Props are not spread onto the div element.

When as="div", only className and children are forwarded. Any additional props (e.g., id, data-*, aria-*, event handlers) passed to <Label as="div" onClick={...}> are silently dropped, unlike the label branch which spreads ...props. If this is intentional (to avoid passing label-specific props to a div), consider at least forwarding HTML-safe props:

♻️ Proposed fix
     if (as === "div") {
       return (
-        <div ref={ref as React.Ref<HTMLDivElement>} className={classes}>
+        <div ref={ref as React.Ref<HTMLDivElement>} className={classes} {...props}>
           {children}
         </div>
       );
     }

If label-specific props (like htmlFor) shouldn't land on a div, destructure them out before spreading.

src/contexts/ReverseProxiesProvider.tsx-407-424 (1)

407-424: ⚠️ Potential issue | 🟡 Minor

createDomain silently swallows errors.

The catch block returns undefined without logging or notifying the user. While useApiCall may handle error display internally, callers of createDomain that check for undefined won't know the difference between a network error and an intentional absence.

src/modules/reverse-proxy/domain/CustomDomainModal.tsx-117-131 (1)

117-131: ⚠️ Potential issue | 🟡 Minor

Empty <HelpText></HelpText> renders an unnecessary DOM element.

Line 119 renders an empty HelpText component with no content. Either add descriptive text (e.g., "Enter the domain you want to use for your service") or remove it.

Proposed fix (option: remove)
              <div>
                <Label>Domain</Label>
-               <HelpText></HelpText>
                <Input
src/modules/reverse-proxy/auth/AuthPasswordModal.tsx-77-113 (1)

77-113: ⚠️ Potential issue | 🟡 Minor

Missing bottom padding on modal content.

The container div at Line 52 has className="px-8" but no bottom padding. The button row at Line 77 uses mt-6 but there's no pb-6 or similar to add spacing below the buttons before the modal edge.

Proposed fix
-        <div className="px-8">
+        <div className="px-8 pb-6">
src/modules/reverse-proxy/domain/CustomDomainVerificationModal.tsx-148-153 (1)

148-153: ⚠️ Potential issue | 🟡 Minor

"Start Verification" button is enabled even when no proxy cluster is available.

When cnameTarget is empty (no proxy clusters connected), the modal shows a warning callout but the "Start Verification" button remains enabled. Clicking it would invoke onStartVerification(domain) in a state where DNS verification cannot succeed since there's no CNAME target to point to.

Proposed fix
-            <Button variant={"primary"} onClick={handleStartVerification}>
+            <Button variant={"primary"} onClick={handleStartVerification} disabled={!cnameTarget}>
               Start Verification
             </Button>
src/modules/reverse-proxy/table/ReverseProxyTargetsCell.tsx-23-35 (1)

23-35: ⚠️ Potential issue | 🟡 Minor

No-op onClick on a visually interactive badge is misleading.

The badge has useHover={true} and cursor-pointer, signaling to users that it's clickable, but onClick={() => void 0} does nothing. Either implement the click action (e.g., open the targets list/modal) or remove the interactive styling.

♻️ If the badge shouldn't be clickable yet
         <Badge
           variant={"gray"}
-          useHover={true}
-          className={"cursor-pointer"}
-          onClick={() => void 0}
+          useHover={false}
         >
src/interfaces/ReverseProxy.ts-20-27 (1)

20-27: ⚠️ Potential issue | 🟡 Minor

Typo in enum member: CERTIFICATED_FAILED should be CERTIFICATE_FAILED.

The string value "certificate_failed" is correct for the API, but the member name CERTIFICATED_FAILED is grammatically wrong and will be confusing in code.

🐛 Proposed fix
-  CERTIFICATED_FAILED = "certificate_failed",
+  CERTIFICATE_FAILED = "certificate_failed",
src/modules/reverse-proxy/table/ReverseProxyDestinationCell.tsx-14-14 (1)

14-14: ⚠️ Potential issue | 🟡 Minor

destination is optional — consider guarding against undefined in addition to "unknown".

ReverseProxyTarget.destination is typed as string | undefined. When it's undefined (but not "unknown"), the component will render an empty CopyToClipboardText. Consider treating undefined the same as "unknown".

♻️ Suggested guard
-  if (target.destination === "unknown") return <EmptyRow />;
+  if (!target.destination || target.destination === "unknown") return <EmptyRow />;
src/modules/reverse-proxy/targets/ReverseProxyTargetModal.tsx-176-191 (1)

176-191: ⚠️ Potential issue | 🟡 Minor

Path duplicate detection uses path+host+port identity — fragile when editing.

The duplicate check skips the current target by matching on the combination of path, host, and port. If the user changes any of these fields during edit, the old target won't be excluded, causing a false-positive duplicate warning. Consider using target_id for identity when available:

Proposed fix
     return existingTargets.some((t) => {
-      // Skip the target being edited - match by path, host, and port
-      if (
-        currentTarget &&
-        normalizePath(t.path) === normalizePath(currentTarget.path) &&
-        t.host === currentTarget.host &&
-        t.port === currentTarget.port
-      ) {
+      if (
+        currentTarget &&
+        currentTarget.target_id &&
+        t.target_id === currentTarget.target_id
+      ) {
         return false;
       }
       return normalizePath(t.path) === normalizedCurrentPath;
     });
src/modules/networks/resources/ResourcesTabContent.tsx-28-38 (1)

28-38: ⚠️ Potential issue | 🟡 Minor

Missing whitespace between "about" and the link text.

JSX collapses the newline between "Learn more about" and <InlineLink>, resulting in "Learn more aboutResources" being rendered without a space.

Proposed fix
          <Paragraph>
-            Learn more about
-            <InlineLink
+            Learn more about{" "}
+            <InlineLink
               href={"https://docs.netbird.io/how-to/networks#resources"}
               target={"_blank"}
             >
               Resources
               <ExternalLinkIcon size={12} />
             </InlineLink>
+            {" "}
             in our documentation.
           </Paragraph>
src/modules/networks/resources/ResourceExposeServiceCell.tsx-44-44 (1)

44-44: ⚠️ Potential issue | 🟡 Minor

Navigation URL will contain literal "undefined" if network is nullish.

network?.id evaluates to undefined when network is absent, producing /network?id=undefined&tab=services&target=.... Consider guarding against this or asserting that network is always available in this context.

🛡️ Proposed guard
          onClick={(e) => {
            e.preventDefault();
            e.stopPropagation();
+           if (!network?.id) return;
            router.push(`/network?id=${network?.id}&tab=services&target=${resource.id}`);
          }}

Apply the same guard to the onSuccess callback at line 63.

src/modules/reverse-proxy/events/ReverseProxyEventsTable.tsx-227-262 (1)

227-262: ⚠️ Potential issue | 🟡 Minor

Disabled controls when events list is empty can trap users.

When all filter buttons (All / Success / Failed) and the refresh button are disabled based on !events?.length, two problems arise:

  1. If a user selects "Failed" and no failed events exist, events becomes [], all buttons including "All" are disabled, and the user can't revert the filter without using the reset-filter button.
  2. If no events exist yet (first use), the refresh button is disabled, so the user can't poll for new events.

Consider keeping at least the "All" button and the refresh button always enabled:

🐛 Proposed fix for refresh button
           <DataTableRefreshButton
-            isDisabled={!events?.length}
+            isDisabled={false}
             onClick={() => mutate()}
           />

Comment thread src/app/globals.css
Comment thread src/modules/networks/routing-peers/NetworkRoutingPeersTabContent.tsx Outdated
Comment thread src/modules/reverse-proxy/events/ReverseProxyEventsRequestCell.tsx
Comment thread src/modules/reverse-proxy/table/ReverseProxyDestinationCell.tsx
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 10

🤖 Fix all issues with AI agents
In `@src/app/`(dashboard)/peer/page.tsx:
- Around line 361-369: The Reverse Proxies tab trigger (TabsTrigger value
"reverse-proxies") is shown when peer?.id && permission.services?.read, but the
corresponding TabsContent is gated by permission.routes.read causing a mismatch;
update the TabsContent (the block rendering content for value "reverse-proxies")
to use the same permission check as the trigger (permission.services?.read) and
include peer?.id if the trigger requires it so both trigger and content are
shown/hidden together (look for TabsTrigger, TabsContent,
permission.services?.read and permission.routes.read and make the condition
consistent).

In `@src/contexts/ReverseProxiesProvider.tsx`:
- Around line 569-576: The current isSameTarget function compares only path,
host, port, and protocol and can misidentify different backends; update
isSameTarget(ReverseProxyTarget) to also compare target_id when present (e.g.,
if both a.target_id and b.target_id are defined, require them to match) while
preserving the existing field comparisons (path, host, port, protocol) so
edits/toggles/deletes match the exact target_id-backed entry.

In `@src/interfaces/ReverseProxy.ts`:
- Around line 106-116: The four exported constants REVERSE_PROXY_DOCS_LINK,
REVERSE_PROXY_CLUSTERS_DOCS_LINK, REVERSE_PROXY_CUSTOM_DOMAINS_DOCS_LINK, and
REVERSE_PROXY_DOMAIN_VERIFICATION_LINK all point to the same generic URL; update
each constant to its specific documentation URL or anchor/path (e.g., add the
correct section anchors like /reverse-proxy#clusters,
/reverse-proxy#custom-domains, /reverse-proxy#domain-verification or the real
distinct doc paths) so each constant references the precise docs section it
represents.

In `@src/modules/reverse-proxy/domain/CustomDomainModal.tsx`:
- Around line 42-56: The modal keeps stale values because domain and
selectedCluster aren't cleared when the modal is reopened; add a React.useEffect
that watches the modal's open prop and resets state by calling setDomain("") and
setSelectedCluster("") (and optionally re-run the existing auto-select logic)
whenever open changes (e.g., when open becomes true or on close) so the form is
cleared; reference the domain, setDomain, selectedCluster, setSelectedCluster
state variables and the availableClusters useMemo when adding this effect.

In `@src/modules/reverse-proxy/table/ReverseProxyTable.tsx`:
- Around line 247-252: The refresh button in DataTableRefreshButton is calling
mutate("/reverse-proxies") but the data source used by ReverseProxiesProvider is
"/reverse-proxies/services", so the SWR cache key is wrong and the refetch won't
run; update the onClick handler in ReverseProxyTable (the mutate call next to
DataTableRefreshButton) to call mutate("/reverse-proxies/services") so it
targets the same key used by ReverseProxiesProvider and triggers a proper
refetch.

In `@src/modules/reverse-proxy/table/ReverseProxyTargetsCell.tsx`:
- Around line 24-34: The Badge in ReverseProxyTargetsCell is styled as
interactive (useHover, cursor-pointer, onClick={() => void 0}) but the handler
is a no-op; either wire it to the real action or remove the interactive
affordances: if this should open the targets details, replace the no-op with the
real handler (e.g., call the component/row toggle such as props.onToggleTargets
or the row.toggleOpen function) and also add accessible keyboard support/role;
otherwise remove useHover, the cursor-pointer className and the onClick prop so
the Badge is non-interactive.

In `@src/modules/reverse-proxy/targets/ReverseProxyTargetModal.tsx`:
- Around line 475-480: The onChange handler in ReverseProxyTargetModal currently
strips input when isHostEditable by using a regex that only allows 0-9 and dots,
which prevents entering IPv6 CIDRs; update the input sanitization in that
onChange (where setTargetHost is called) to allow IPv6 characters by permitting
0-9, a-f, A-F and colon (e.g., include a-fA-F: in the allowed character set) or
replace the naive strip-with-regex approach with a validation step that accepts
both IPv4 and IPv6 CIDR syntaxes before calling setTargetHost; ensure you update
the branch that checks isHostEditable so IPv6 hex digits and colons are
preserved.
- Around line 177-185: The duplicate-path check in the useMemo for
isPathDuplicate uses reference equality (t === currentTarget) which is brittle;
change that to compare a stable identifier instead (for example use t.target_id
=== currentTarget.target_id or, if target_id may be missing, compare a combined
key like `${t.target_id||''}:${normalizePath(t.path)}` against the same for
currentTarget). Update the predicate inside existingTargets.some to skip the
target being edited by comparing these stable ids (and guard for undefined
currentTarget or missing target_id) while keeping normalizePath(targetPath) as
the normalizedCurrentPath.
- Around line 93-99: The current useState initializer unsafely casts
initialResource.type to ReverseProxyTargetType; replace that cast with a small
mapping/validation routine (e.g., a function mapNetworkTypeToReverseProxyType)
that accepts NetworkResource.type and returns a valid ReverseProxyTargetType or
a safe default (ReverseProxyTargetType.HOST or PEER) if the value is undefined
or unknown. Locate the initialization around targetType/setTargetType and the
use of initialResource, implement the mapping function to explicitly handle
"domain"|"host"|"subnet"|undefined -> corresponding ReverseProxyTargetType
values, and use that function in place of the (initialResource.type as
ReverseProxyTargetType) cast so runtime mismatches are handled
deterministically.
- Around line 352-358: The resource prop passed into PeerGroupSelector in
ReverseProxyTargetModal currently hardcodes type: "host" for
isResourceTargetType branches; change it to use the actual targetType value so
when targetType is DOMAIN or SUBNET the resource becomes { id: targetResourceId,
type: targetType } instead of type: "host" (keep the existing branch for
ReverseProxyTargetType.PEER that uses { id: targetPeerId, type: "peer" }
unchanged); update the resource construction around
isResourceTargetType(targetType) && targetResourceId to reference targetType for
the type field so PeerGroupSelector receives the correct resource type.
🧹 Nitpick comments (11)
src/modules/posture-checks/usePostureCheck.ts (1)

117-137: Clean refactor — sharing a single promise with both notify and the caller is correct.

Two optional nits:

  1. async on line 117 is unnecessary since no await is used; the function already returns a Promise. Removing it avoids wrapping the returned promise in an extra Promise layer.
  2. Line 123: onSuccess?.(check) is slightly more idiomatic than onSuccess && onSuccess(check).
Suggested diff
-  const updateOrCreateAndNotify = async (
+  const updateOrCreateAndNotify = (
     checkToUpdateOrCreate?: PostureCheck,
   ) => {
     const call = () => updateOrCreate(checkToUpdateOrCreate || state);
     const promise = call().then((check) => {
       mutate("/posture-checks");
-      onSuccess && onSuccess(check);
+      onSuccess?.(check);
       return check;
     });
src/modules/networks/NetworkProvider.tsx (1)

110-117: Remove unnecessary type assertion as PolicyRuleResource (lines 113–116).

NetworkResource.type is a subset of PolicyRuleResource.type ("domain" | "host" | "subnet" vs. "domain" | "host" | "subnet" | "peer"). The cast is safe but unnecessary; TypeScript allows the subset to be assigned to the superset without an explicit assertion. Removing the cast improves code clarity.

Suggested change
      destinationResource: hasResourceGroups
        ? undefined
        : resource
          ? ({
              id: resource.id,
              type: resource.type,
            })
          : undefined,
src/components/SidebarItem.tsx (1)

50-54: Missing open in useEffect dependency array will trigger react-hooks/exhaustive-deps lint warning.

The omission of open is clearly intentional (you only want to react to route changes, not re-run when the user manually toggles). Suppress the lint rule with a comment to document the intent:

Suggested fix
   useEffect(() => {
     if (hasActiveChild && !open) {
       setOpen(true);
     }
+    // eslint-disable-next-line react-hooks/exhaustive-deps
   }, [hasActiveChild]);
src/modules/reverse-proxy/domain/CustomDomainModal.tsx (1)

52-52: Minor: inconsistent hook import style.

useMemo and useState are imported directly, but useEffect is called as React.useEffect. Pick one style for consistency.

Suggested fix
-import { useMemo, useState } from "react";
+import { useEffect, useMemo, useState } from "react";

Then replace React.useEffect with useEffect.

src/contexts/ReverseProxiesProvider.tsx (2)

97-101: Redundant data fetching — peers and resources are already loaded by ReverseProxiesProvider but ReverseProxyTargetDevice fetches them again independently.

The provider fetches /peers and /networks/resources (Lines 98-101) for destination resolution. However, ReverseProxyTargetDevice.tsx (Lines 27-32) also calls useFetchApi for the same endpoints plus /networks. Since SWR deduplicates by cache key, this isn't a correctness bug, but it means the provider holds peer/resource data that could be exposed through the context instead of having each ReverseProxyTargetDevice instance independently fetch. Consider exposing peers and resources (and potentially networks) from the context to avoid coupling child components to the same API calls.


214-224: Direct DOM manipulation (querySelector + click()) is fragile.

Using document.querySelector with data-row-id to find and programmatically click accordion rows is brittle — it breaks if the table doesn't render in time (the 200ms timeout is a guess), or if the data attribute naming changes. This is a minor concern since it's a non-critical UX enhancement (auto-expand + scroll after create), but worth noting for future maintainability.

Also applies to: 253-262

src/modules/reverse-proxy/table/ReverseProxyTable.tsx (1)

199-245: Use strict equality (===) instead of loose equality (==) for length checks.

Multiple comparisons use == 0 (Lines 199, 205, 219, 233, 245). While functionally equivalent here since length is always a number, strict equality is idiomatic TypeScript/JavaScript.

src/modules/reverse-proxy/targets/ReverseProxyTargetDevice.tsx (1)

27-47: Entity lookups are not memoized — find() runs on every render.

peer, resource, and network are computed via Array.find() on every render. Since this component is rendered per-row in a table, consider wrapping these in useMemo to avoid redundant linear scans when unrelated state changes trigger re-renders.

♻️ Suggested improvement
-  const peer = isPeer
-    ? peers?.find((p) => p.id === target.target_id)
-    : undefined;
-  const resource = isResource
-    ? resources?.find((r) => r.id === target.target_id)
-    : undefined;
-
-  const network =
-    isResource && resource
-      ? networks?.find((n) => n.resources?.includes(resource.id))
-      : undefined;
+  const peer = React.useMemo(
+    () => (isPeer ? peers?.find((p) => p.id === target.target_id) : undefined),
+    [isPeer, peers, target.target_id],
+  );
+  const resource = React.useMemo(
+    () => (isResource ? resources?.find((r) => r.id === target.target_id) : undefined),
+    [isResource, resources, target.target_id],
+  );
+  const network = React.useMemo(
+    () =>
+      isResource && resource
+        ? networks?.find((n) => n.resources?.includes(resource.id))
+        : undefined,
+    [isResource, resource, networks],
+  );
src/modules/networks/resources/ResourceExposeServiceCell.tsx (1)

4-7: Consolidate imports from the same module.

useReverseProxies (line 5) and isResourceTargetType (line 7) are both imported from @/contexts/ReverseProxiesProvider in separate statements.

Suggested fix
-import { useReverseProxies } from "@/contexts/ReverseProxiesProvider";
-import { NetworkResource } from "@/interfaces/Network";
-import { isResourceTargetType } from "@/contexts/ReverseProxiesProvider";
+import {
+  useReverseProxies,
+  isResourceTargetType,
+} from "@/contexts/ReverseProxiesProvider";
+import { NetworkResource } from "@/interfaces/Network";
src/modules/reverse-proxy/targets/ReverseProxyTargetModal.tsx (2)

513-517: Port input allows negative values through keyboard entry.

parseInt(e.target.value) || 0 will parse negative numbers (e.g., -5-5), and while isValidPort will correctly reject them for the save button, the input will display the negative value. Consider clamping:

Suggested fix
                    onChange={(e) =>
-                     setTargetPort(parseInt(e.target.value) || 0)
+                     setTargetPort(Math.max(0, parseInt(e.target.value) || 0))
                    }

244-245: Dead code: showAdvancedSettings is always false.

The advanced settings section (lines 528–558) is unreachable. If this is intentional scaffolding for a future iteration, a brief comment explaining the plan would help. Otherwise, consider removing the dead code and the accordion section.

Comment thread src/app/(dashboard)/peer/page.tsx
Comment thread src/contexts/ReverseProxiesProvider.tsx Outdated
Comment thread src/interfaces/ReverseProxy.ts Outdated
Comment thread src/modules/reverse-proxy/domain/CustomDomainModal.tsx
Comment thread src/modules/reverse-proxy/table/ReverseProxyTable.tsx
Comment thread src/modules/reverse-proxy/table/ReverseProxyTargetsCell.tsx
Comment thread src/modules/reverse-proxy/targets/ReverseProxyTargetModal.tsx
Comment thread src/modules/reverse-proxy/targets/ReverseProxyTargetModal.tsx
Comment thread src/modules/reverse-proxy/targets/ReverseProxyTargetModal.tsx
Comment thread src/modules/reverse-proxy/targets/ReverseProxyTargetModal.tsx
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@src/app/globals.css`:
- Around line 191-196: The stylelint error flags the missing empty line before
the opacity declaration inside the toast CSS rule for the selector
[data-sonner-toast][data-removed='true'][data-front='false'][data-swipe-out='false'][data-expanded='false'];
fix it by inserting a single blank line immediately before the "opacity: 0
!important;" declaration (between the --y translateY line and the opacity line)
so the declaration-empty-line-before rule is satisfied, then run the linter to
confirm the warning is gone.

Comment thread src/app/globals.css
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 5

🤖 Fix all issues with AI agents
In `@src/contexts/ReverseProxiesProvider.tsx`:
- Around line 393-410: The createDomain function currently swallows all errors
by returning undefined; change the catch to capture the thrown error (e.g.,
catch (error)), log the error (console.error or the app logger) with a clear
message referencing createDomain/domainRequest.post, and then re-throw the error
(or return a structured error object) so callers can distinguish
network/validation/409 conflicts; ensure mutate("/reverse-proxies/domains")
remains only on successful post.

In `@src/modules/reverse-proxy/ReverseProxyModal.tsx`:
- Around line 221-223: The component initializes linkAuthEnabled from
reverseProxy?.auth?.link_auth?.enabled but provides no UI or save handling to
change it; update the component so the modal offers a control (e.g., a
Switch/Checkbox) bound to linkAuthEnabled and call setLinkAuthEnabled on change,
and ensure the form save/submit logic that persists auth settings includes this
value (update the same handler that writes other auth fields) so toggling
actually updates reverseProxy.auth.link_auth.enabled; alternatively, if link
auth is not supported yet, remove linkAuthEnabled from the hasNoAuth calculation
(reference hasNoAuth and linkAuthEnabled) so the "no auth" warning logic is
correct.
- Around line 310-312: The current assignment to bearer_auth.distribution_groups
uses savedGroups.map((g) => g.id as string) which unsafely casts possibly
undefined ids; change this to first filter out groups with missing ids (e.g.,
savedGroups.filter(g => g.id != null)) and then map to their ids so
distribution_groups receives only real strings; update the code referencing
bearer_auth or distribution_groups accordingly to produce a string[] without
using the unsafe "as string" cast.

In `@src/modules/reverse-proxy/table/ReverseProxyTable.tsx`:
- Around line 88-98: The search string builder (id: "searchString" in the
accessorFn) concatenates target ports with .join("") which merges numbers (e.g.,
80 and 443 -> "80443") and can produce false matches; change the join for ports
(and similarly for destination and host if needed) to use a separator such as a
space or comma (e.g., .map(t => t.port).join(" ")) so each field remains
distinct when the overall search string is created from row?.domain,
row?.proxy_cluster, and row?.targets?.map(...).

In `@src/modules/reverse-proxy/targets/ReverseProxyTargetModal.tsx`:
- Around line 159-161: The isCidrRange logic currently assumes IPv4 by checking
cidrMask < 32; update it to choose the max prefix based on address family (IPv4
=> 32, IPv6 => 128) and compare cidrMask against that max so single-host
prefixes (/32 for IPv4, /128 for IPv6) are handled correctly. Detect the family
using the existing CIDR/address parsing available in this component (e.g., from
the same source that sets cidrMask or via the ip-cidr helper), compute maxMask =
isIPv6 ? 128 : 32, then set isCidrRange = cidrMask !== null && cidrMask <
maxMask and also adjust any default prefix logic (the default /32) to use the
family-aware max when choosing defaults.
🧹 Nitpick comments (12)
src/interfaces/ReverseProxy.ts (1)

42-58: Sensitive credentials stored as plain strings in the auth interface.

password and pin are typed as plain string. While this is a frontend interface and the actual security depends on the API, ensure these values are never logged, persisted to localStorage, or included in error reports. Verify the backend stores them securely (hashed/encrypted).

src/contexts/ReverseProxiesProvider.tsx (3)

546-557: target.target_id || "" can silently pass the includes check on unexpected data.

If target_id is undefined, this falls back to checking network.resources?.includes(""), which will never match — so it's safe but misleading. Consider an early return instead for clarity.

♻️ Suggested improvement
       if (network && !peer) {
         if (isResourceTargetType(target.target_type)) {
+          if (!target.target_id) return;
           const isResourceInNetwork = network.resources?.includes(
-            target.target_id || "",
+            target.target_id,
           );
           if (!isResourceInNetwork) return;

213-224: DOM manipulation (querySelector, click, scrollIntoView) for scroll-to-row UX.

Direct DOM manipulation with querySelector and simulated click() is fragile — it depends on specific data-row-id attributes and accordion data-accordion attribute values being present. If the table rendering changes, this will silently break. Consider using a React ref-based approach or a callback that the DataTable exposes for programmatic row expansion and scrolling.

This pattern repeats in handleCreateOrUpdateProxy (Lines 253-261) as well.


130-146: Consider consolidating modal state into a reducer or object.

Eight separate useState calls manage the modal lifecycle. A single useReducer or a state object (e.g., modalState: { open, proxy, tab, peer, network, resource, onSuccess }) would make open/close transitions atomic and reduce the risk of partial state updates.

src/modules/reverse-proxy/table/ReverseProxyTable.tsx (1)

199-246: Loose equality (==) used for length checks.

Multiple comparisons use reverseProxies?.length == 0 (e.g., Lines 199, 205, 219, 233, 245, 248). While functionally fine here since the LHS is always number | undefined, prefer strict equality (=== 0) for consistency and to avoid any confusion.

src/app/(dashboard)/peer/page.tsx (3)

146-226: PeerSettingsProvider is defined inline — consider extracting to its own file.

The PeerSettingsContext, usePeerSettings, and PeerSettingsProvider are defined inline within page.tsx (which is already ~850 lines). According to the AI summary, a separate src/contexts/PeerSettingsProvider.tsx file exists but the context is actually defined here. Consider moving it to keep the page file focused on layout/composition.


110-118: Verify that ReverseProxiesProvider wrapping the entire peer page doesn't cause unnecessary API calls.

ReverseProxiesProvider fetches /reverse-proxies/services, /peers, and /networks/resources (three API calls). These fire for every peer page visit regardless of whether the user ever navigates to the "Reverse Proxies" tab. If this tab is infrequently used, consider lazy-loading the provider or deferring the fetch until the tab is active.


329-332: flatTargets is recomputed on every reverseProxies or peer change, but peer object identity may change on every render.

The useMemo depends on [reverseProxies, peer]. If usePeer() returns a new peer object reference on each render (e.g., from SWR's immutable returns), flatTargets will be recomputed every time. Consider depending on peer.id instead, or ensure peer has stable identity.

src/modules/reverse-proxy/ReverseProxyModal.tsx (3)

88-120: parseDomain uses hardcoded known domains — fragile if new domains are introduced.

The knownDomains array ["netbird.cloud", "netbird.io", "netbird.app"] is hardcoded. If the backend introduces new managed domains, this function will incorrectly classify them as custom. Consider deriving the known domains from the domains prop (filtered by ReverseProxyDomainType.FREE) instead of hardcoding.


782-793: Auth modal callbacks all use setTimeout(..., 200) — fragile timing dependency.

Every auth modal onSave/onRemove callback wraps state updates in a 200ms setTimeout, presumably to avoid conflicts with modal close animations. This is a fragile pattern — if animation durations change, or if the component unmounts before the timeout fires, state updates will be lost or trigger React warnings. Consider using the modal's onOpenChange callback or an onExitComplete event to coordinate timing instead.

Also applies to: 802-812, 821-832


246-248: Unnecessary useMemo for submitDisabled.

submitDisabled is just !canContinueToSettings — wrapping it in useMemo adds overhead without benefit. A plain derived value suffices.

♻️ Suggested simplification
-  const submitDisabled = useMemo(() => {
-    return !canContinueToSettings;
-  }, [canContinueToSettings]);
+  const submitDisabled = !canContinueToSettings;
src/modules/reverse-proxy/targets/ReverseProxyTargetModal.tsx (1)

174-177: normalizePath is a plain function defined inside the component — recreated every render.

Since it's a pure utility with no dependencies on component state, move it outside the component to avoid recreation on every render and make it reusable.

♻️ Suggested refactor
+// Normalize path for comparison (ensure it starts with / and handle empty as /)
+const normalizePath = (path: string | undefined) => {
+  if (!path || path === "") return "/";
+  return path.startsWith("/") ? path : `/${path}`;
+};
+
 export default function ReverseProxyTargetModal({
   ...
-  // Normalize path for comparison (ensure it starts with / and handle empty as /)
-  const normalizePath = (path: string | undefined) => {
-    if (!path || path === "") return "/";
-    return path.startsWith("/") ? path : `/${path}`;
-  };

Comment thread src/contexts/ReverseProxiesProvider.tsx
Comment thread src/modules/reverse-proxy/ReverseProxyModal.tsx
Comment thread src/modules/reverse-proxy/ReverseProxyModal.tsx
Comment thread src/modules/reverse-proxy/table/ReverseProxyTable.tsx
Comment thread src/modules/reverse-proxy/targets/ReverseProxyTargetModal.tsx
@mlsmaycon mlsmaycon merged commit b71d0fd into main Feb 13, 2026
4 checks passed
@mlsmaycon mlsmaycon deleted the feature/reverse-proxy branch February 13, 2026 17:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants