Compare commits

...

10 Commits

Author SHA1 Message Date
Viktor Liu
aff2365ef7 Add layer 4 protocol support to reverse proxy (#579)
Some checks failed
build and push / build_n_push (push) Has been cancelled
* Add layer 4 proto support

* Fix initialResource fallback and UDP session_idle_timeout

* Fix tlsResourceId init for resource-driven create flows, UDP timeout label

* Address PR review: ServiceMode enum, resource init fix, modal title, a11y

* Add L4 protocol values to ReverseProxyTargetProtocol, remove unsafe double cast

* Add aria-labels to L4 port/host inputs

* Unify domain input for all service modes including L4

* Support L4 proxy events

* Fix custom port reset on edit and show port in L4 service link

* Remove redundant listen port from L4 target cell

* Show link only for HTTP/TLS services, copy-on-click for TCP/UDP

* Move mode badge before domain and use fixed width for alignment

* Fix HTTP services to open as link instead of copy

* Hide old proxy clusters from L4 domain selector

* Move service type inside modal

* Update auth cell

* Add target selector component

* Extract into separate components

* hide services types for not supported clusters

* Remove advanced settings tab in http targetmodal and use accordion instead

* Update advanced settings

* Update target device row

* Update text

* Add type cell

* Fix flat target name cell

* Update modal title

* Fix edit target in flat table

* Remove unused proxycluster interface

* Move proxy type icon into type component

* sync cloud

* use emptyrow

* fix l4 type

* fix duplicate error notification

* Set the correct target type

* Fix subnet host editable

* Fix subnet host editable

* hide selector when initial resource or peer

* Rename dropdown

* Update text

* update status cell

* merge cloud

* Update tooltips

* Address coderabbit comments

* Fix skeleton device card

* Update listen port tooltip

* Adjust padding

* update package-lock.json

* bump next to 16.1.7

---------

Co-authored-by: Eduard Gert <kontakt@eduardgert.de>
2026-03-18 17:43:00 +01:00
Zoltan Papp
bad057d415 [dashboard] feat: add auto_update_always toggle to client settings (#580)
* [dashboard] feat: add auto_update_always toggle to client settings

Add "Always Update" toggle to the Clients settings tab that controls
whether updates are installed automatically in the background or require
user interaction from the UI. Includes a warning icon and caution callout
when enabled to highlight the risk of disrupting active connections.

* [dashboard] fix: improve auto-update UI clarity and toggle label

Clarify that automatic updates require user interaction by updating the
description. Rename "Always Update" to "Force Automatic Updates" for
clarity. Move warning callout below the toggle switch instead of inside it.

* Update src/modules/settings/ClientSettingsTab.tsx

Co-authored-by: Eduard Gert <kontakt@eduardgert.de>

---------

Co-authored-by: Eduard Gert <kontakt@eduardgert.de>
2026-03-16 15:35:44 +01:00
Misha Bragin
4d846e2c94 Improve text for optional resource setiings (#584) 2026-03-12 20:48:09 +01:00
Eduard Gert
15fb6e0b05 Refactor resource modal (#582)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2026-03-12 16:30:51 +01:00
Eduard Gert
55c5525626 Fix resource group policy when adding single resource as destination (#581)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2026-03-11 19:23:59 +01:00
Eduard Gert
c0c1f4688e Add proxy events sort (#560)
Some checks failed
build and push / build_n_push (push) Has been cancelled
* Add proxy events sort

* Fix coderabbit comment

* Disable local sort when server pagination is used
2026-03-10 10:10:53 +01:00
Eduard Gert
b5a8f751ba Create policies inside resources (#568)
* Add acl tooltips

* Adjust resource modal and add tooltips

* Prevent nextjs navigation trigger on tab change

* Update wording

* add acl into resource

* Refactor resource policies

* Add prop to hide group edit and disable redirect

* Add skeleton loader to network page

* Create policy for new resources

* Show existing policies if groups are matching

* Add confirm dialog after creating resource without policy

* Add dialog if user edits policy that is used in multiple resources

* Add callout when selecting resource groups containing policies

* Add dialog if deleting policies containing resources

* Fix stale policies and new group creation in resource modal

* Remove whitespace

* Fix sort

* Cleanup

* Address coderabbit comments

* Fix policy alignment

* Fix initial resource

* disable selector if user did not select  resource groups

* Consider current resource when editing / deleting policy

* Remove unused mutate

* Fix dot position

* Remove ask for policy

* Fix policy index

* Fix multiple resource confirm dialog on policy cell
2026-03-10 10:10:38 +01:00
Eduard Gert
10a8e7b745 Fix stale certificate issued state (#575)
* Fix stale certificate issued state

* fix coderabbit
2026-03-09 10:08:35 +01:00
Viktor Liu
60e8394010 Add per-target options to reverse proxy (#576) 2026-03-06 18:55:28 +01:00
Eduard Gert
9420214059 Bump minimatch and ajv dependencies (#572) 2026-03-02 11:32:52 +01:00
75 changed files with 4498 additions and 1580 deletions

108
package-lock.json generated
View File

@@ -60,7 +60,7 @@
"js-cookie": "^3.0.5",
"lodash": "^4.17.23",
"lucide-react": "^0.562.0",
"next": "^16.1.6",
"next": "^16.1.7",
"next-themes": "^0.2.1",
"punycode": "^2.3.1",
"react": "^19.2.4",
@@ -1213,9 +1213,9 @@
}
},
"node_modules/@next/env": {
"version": "16.1.6",
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.1.6.tgz",
"integrity": "sha512-N1ySLuZjnAtN3kFnwhAwPvZah8RJxKasD7x1f8shFqhncnWZn4JMfg37diLNuoHsLAlrDfM3g4mawVdtAG8XLQ==",
"version": "16.1.7",
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.1.7.tgz",
"integrity": "sha512-rJJbIdJB/RQr2F1nylZr/PJzamvNNhfr3brdKP6s/GW850jbtR70QlSfFselvIBbcPUOlQwBakexjFzqLzF6pg==",
"license": "MIT"
},
"node_modules/@next/eslint-plugin-next": {
@@ -1229,9 +1229,9 @@
}
},
"node_modules/@next/swc-darwin-arm64": {
"version": "16.1.6",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.1.6.tgz",
"integrity": "sha512-wTzYulosJr/6nFnqGW7FrG3jfUUlEf8UjGA0/pyypJl42ExdVgC6xJgcXQ+V8QFn6niSG2Pb8+MIG1mZr2vczw==",
"version": "16.1.7",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.1.7.tgz",
"integrity": "sha512-b2wWIE8sABdyafc4IM8r5Y/dS6kD80JRtOGrUiKTsACFQfWWgUQ2NwoUX1yjFMXVsAwcQeNpnucF2ZrujsBBPg==",
"cpu": [
"arm64"
],
@@ -1245,9 +1245,9 @@
}
},
"node_modules/@next/swc-darwin-x64": {
"version": "16.1.6",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.1.6.tgz",
"integrity": "sha512-BLFPYPDO+MNJsiDWbeVzqvYd4NyuRrEYVB5k2N3JfWncuHAy2IVwMAOlVQDFjj+krkWzhY2apvmekMkfQR0CUQ==",
"version": "16.1.7",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.1.7.tgz",
"integrity": "sha512-zcnVaaZulS1WL0Ss38R5Q6D2gz7MtBu8GZLPfK+73D/hp4GFMrC2sudLky1QibfV7h6RJBJs/gOFvYP0X7UVlQ==",
"cpu": [
"x64"
],
@@ -1261,9 +1261,9 @@
}
},
"node_modules/@next/swc-linux-arm64-gnu": {
"version": "16.1.6",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.1.6.tgz",
"integrity": "sha512-OJYkCd5pj/QloBvoEcJ2XiMnlJkRv9idWA/j0ugSuA34gMT6f5b7vOiCQHVRpvStoZUknhl6/UxOXL4OwtdaBw==",
"version": "16.1.7",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.1.7.tgz",
"integrity": "sha512-2ant89Lux/Q3VyC8vNVg7uBaFVP9SwoK2jJOOR0L8TQnX8CAYnh4uctAScy2Hwj2dgjVHqHLORQZJ2wH6VxhSQ==",
"cpu": [
"arm64"
],
@@ -1277,9 +1277,9 @@
}
},
"node_modules/@next/swc-linux-arm64-musl": {
"version": "16.1.6",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.1.6.tgz",
"integrity": "sha512-S4J2v+8tT3NIO9u2q+S0G5KdvNDjXfAv06OhfOzNDaBn5rw84DGXWndOEB7d5/x852A20sW1M56vhC/tRVbccQ==",
"version": "16.1.7",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.1.7.tgz",
"integrity": "sha512-uufcze7LYv0FQg9GnNeZ3/whYfo+1Q3HnQpm16o6Uyi0OVzLlk2ZWoY7j07KADZFY8qwDbsmFnMQP3p3+Ftprw==",
"cpu": [
"arm64"
],
@@ -1293,9 +1293,9 @@
}
},
"node_modules/@next/swc-linux-x64-gnu": {
"version": "16.1.6",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.1.6.tgz",
"integrity": "sha512-2eEBDkFlMMNQnkTyPBhQOAyn2qMxyG2eE7GPH2WIDGEpEILcBPI/jdSv4t6xupSP+ot/jkfrCShLAa7+ZUPcJQ==",
"version": "16.1.7",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.1.7.tgz",
"integrity": "sha512-KWVf2gxYvHtvuT+c4MBOGxuse5TD7DsMFYSxVxRBnOzok/xryNeQSjXgxSv9QpIVlaGzEn/pIuI6Koosx8CGWA==",
"cpu": [
"x64"
],
@@ -1309,9 +1309,9 @@
}
},
"node_modules/@next/swc-linux-x64-musl": {
"version": "16.1.6",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.1.6.tgz",
"integrity": "sha512-oicJwRlyOoZXVlxmIMaTq7f8pN9QNbdes0q2FXfRsPhfCi8n8JmOZJm5oo1pwDaFbnnD421rVU409M3evFbIqg==",
"version": "16.1.7",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.1.7.tgz",
"integrity": "sha512-HguhaGwsGr1YAGs68uRKc4aGWxLET+NevJskOcCAwXbwj0fYX0RgZW2gsOCzr9S11CSQPIkxmoSbuVaBp4Z3dA==",
"cpu": [
"x64"
],
@@ -1325,9 +1325,9 @@
}
},
"node_modules/@next/swc-win32-arm64-msvc": {
"version": "16.1.6",
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.1.6.tgz",
"integrity": "sha512-gQmm8izDTPgs+DCWH22kcDmuUp7NyiJgEl18bcr8irXA5N2m2O+JQIr6f3ct42GOs9c0h8QF3L5SzIxcYAAXXw==",
"version": "16.1.7",
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.1.7.tgz",
"integrity": "sha512-S0n3KrDJokKTeFyM/vGGGR8+pCmXYrjNTk2ZozOL1C/JFdfUIL9O1ATaJOl5r2POe56iRChbsszrjMAdWSv7kQ==",
"cpu": [
"arm64"
],
@@ -1341,9 +1341,9 @@
}
},
"node_modules/@next/swc-win32-x64-msvc": {
"version": "16.1.6",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.1.6.tgz",
"integrity": "sha512-NRfO39AIrzBnixKbjuo2YiYhB6o9d8v/ymU9m/Xk8cyVk+k7XylniXkHwjs4s70wedVffc6bQNbufk5v0xEm0A==",
"version": "16.1.7",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.1.7.tgz",
"integrity": "sha512-mwgtg8CNZGYm06LeEd+bNnOUfwOyNem/rOiP14Lsz+AnUY92Zq/LXwtebtUiaeVkhbroRCQ0c8GlR4UT1U+0yg==",
"cpu": [
"x64"
],
@@ -3255,13 +3255,13 @@
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": {
"version": "9.0.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
"integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
"version": "9.0.9",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
"integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^2.0.1"
"brace-expansion": "^2.0.2"
},
"engines": {
"node": ">=16 || 14 >=14.17"
@@ -3665,9 +3665,9 @@
}
},
"node_modules/ajv": {
"version": "6.12.6",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
"version": "6.14.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz",
"integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==",
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.1",
@@ -5768,9 +5768,9 @@
}
},
"node_modules/flatted": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz",
"integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==",
"version": "3.4.2",
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz",
"integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==",
"license": "ISC"
},
"node_modules/for-each": {
@@ -6973,9 +6973,9 @@
}
},
"node_modules/minimatch": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
"license": "ISC",
"dependencies": {
"brace-expansion": "^1.1.7"
@@ -7067,14 +7067,14 @@
"license": "MIT"
},
"node_modules/next": {
"version": "16.1.6",
"resolved": "https://registry.npmjs.org/next/-/next-16.1.6.tgz",
"integrity": "sha512-hkyRkcu5x/41KoqnROkfTm2pZVbKxvbZRuNvKXLRXxs3VfyO0WhY50TQS40EuKO9SW3rBj/sF3WbVwDACeMZyw==",
"version": "16.1.7",
"resolved": "https://registry.npmjs.org/next/-/next-16.1.7.tgz",
"integrity": "sha512-WM0L7WrSvKwoLegLYr6V+mz+RIofqQgVAfHhMp9a88ms0cFX8iX9ew+snpWlSBwpkURJOUdvCEt3uLl3NNzvWg==",
"license": "MIT",
"dependencies": {
"@next/env": "16.1.6",
"@next/env": "16.1.7",
"@swc/helpers": "0.5.15",
"baseline-browser-mapping": "^2.8.3",
"baseline-browser-mapping": "^2.9.19",
"caniuse-lite": "^1.0.30001579",
"postcss": "8.4.31",
"styled-jsx": "5.1.6"
@@ -7086,14 +7086,14 @@
"node": ">=20.9.0"
},
"optionalDependencies": {
"@next/swc-darwin-arm64": "16.1.6",
"@next/swc-darwin-x64": "16.1.6",
"@next/swc-linux-arm64-gnu": "16.1.6",
"@next/swc-linux-arm64-musl": "16.1.6",
"@next/swc-linux-x64-gnu": "16.1.6",
"@next/swc-linux-x64-musl": "16.1.6",
"@next/swc-win32-arm64-msvc": "16.1.6",
"@next/swc-win32-x64-msvc": "16.1.6",
"@next/swc-darwin-arm64": "16.1.7",
"@next/swc-darwin-x64": "16.1.7",
"@next/swc-linux-arm64-gnu": "16.1.7",
"@next/swc-linux-arm64-musl": "16.1.7",
"@next/swc-linux-x64-gnu": "16.1.7",
"@next/swc-linux-x64-musl": "16.1.7",
"@next/swc-win32-arm64-msvc": "16.1.7",
"@next/swc-win32-x64-msvc": "16.1.7",
"sharp": "^0.34.4"
},
"peerDependencies": {

View File

@@ -68,7 +68,7 @@
"js-cookie": "^3.0.5",
"lodash": "^4.17.23",
"lucide-react": "^0.562.0",
"next": "^16.1.6",
"next": "^16.1.7",
"next-themes": "^0.2.1",
"punycode": "^2.3.1",
"react": "^19.2.4",

View File

@@ -25,6 +25,8 @@ export default function ProxyEventsPage() {
() => ({
start_date: dayjs().subtract(7, "day").startOf("day").toISOString(),
end_date: dayjs().endOf("day").toISOString(),
sort_by: "timestamp",
sort_order: "desc",
}),
[],
);

View File

@@ -12,7 +12,6 @@ import {
} from "@components/DropdownMenu";
import FullTooltip from "@components/FullTooltip";
import InlineLink from "@components/InlineLink";
import FullScreenLoading from "@components/ui/FullScreenLoading";
import useRedirect from "@hooks/useRedirect";
import useFetchApi from "@utils/api";
import { cn, singularize } from "@utils/helpers";
@@ -35,6 +34,7 @@ import { usePermissions } from "@/contexts/PermissionsProvider";
import { Network, NetworkResource, NetworkRouter } from "@/interfaces/Network";
import PageContainer from "@/layouts/PageContainer";
import { NetworkInformationSquare } from "@/modules/networks/misc/NetworkInformationSquare";
import { NetworkAccessControlProvider } from "@/modules/networks/NetworkAccessControlProvider";
import {
NetworkProvider,
useNetworksContext,
@@ -49,6 +49,7 @@ import ReverseProxiesProvider, {
flattenReverseProxies,
useReverseProxies,
} from "@/contexts/ReverseProxiesProvider";
import { SkeletonNetwork } from "@components/skeletons/SkeletonNetwork";
export default function NetworkDetailPage() {
const queryParameter = useSearchParams();
@@ -65,7 +66,7 @@ export default function NetworkDetailPage() {
<NetworkOverview network={network} />
</ReverseProxiesProvider>
) : (
<FullScreenLoading />
<SkeletonNetwork />
);
}
@@ -96,103 +97,103 @@ function NetworkOverview({ network }: Readonly<{ network: Network }>) {
return (
<PageContainer>
<NetworkProvider network={network}>
<div className={"p-default py-6"}>
<Breadcrumbs>
<Breadcrumbs.Item
href={"/networks"}
label={"Networks"}
disabled={!permission.networks.read}
icon={<NetworkRoutesIcon size={13} />}
/>
<Breadcrumbs.Item
href={"/network"}
label={network.name}
active={true}
/>
</Breadcrumbs>
<NetworkAccessControlProvider>
<NetworkProvider network={network}>
<div className={"p-default py-6"}>
<Breadcrumbs>
<Breadcrumbs.Item
href={"/networks"}
label={"Networks"}
disabled={!permission.networks.read}
icon={<NetworkRoutesIcon size={13} />}
/>
<Breadcrumbs.Item
href={"/network"}
label={network.name}
active={true}
/>
</Breadcrumbs>
<div className={"flex justify-between max-w-6xl"}>
<div
className={"w-full lg:w-1/2 flex justify-between items-center"}
>
<div className={"flex justify-between max-w-6xl"}>
<div
className={cn(
"flex items-center w-full",
!network.description && "gap-2",
)}
className={"w-full lg:w-1/2 flex justify-between items-center"}
>
<NetworkInformationSquare
name={network.name}
active={isActive}
size={"lg"}
description={network.description}
/>
</div>
<NetworkProvider network={network}>
<div
className={cn(
"flex items-center w-full",
!network.description && "gap-2",
)}
>
<NetworkInformationSquare
name={network.name}
active={isActive}
size={"lg"}
description={network.description}
/>
</div>
<NetworkActions />
</NetworkProvider>
</div>
</div>
<div className={"flex gap-10 w-full mt-8 max-w-6xl items-start"}>
<NetworkInformationCard network={network} />
</div>
</div>
<div className={"flex gap-10 w-full mt-8 max-w-6xl items-start"}>
<NetworkInformationCard network={network} />
</div>
</div>
<Tabs
defaultValue={tab}
onValueChange={setTab}
value={tab}
className={"pb-0 mb-0"}
>
<TabsList justify={"start"} className={"px-8"}>
<TabsTrigger value={"resources"}>
<Layers3Icon size={14} />
{singularize("Resources", network?.resources?.length)}
</TabsTrigger>
<TabsTrigger value={"routing-peers"}>
<PeerIcon
size={12}
className={
"fill-nb-gray-500 group-data-[state=active]/trigger:fill-netbird transition-all"
}
/>
{singularize("Routing Peers", network?.routing_peers_count)}
</TabsTrigger>
<TabsTrigger value={"services"}>
<ReverseProxyIcon
size={16}
className={
"fill-nb-gray-500 group-data-[state=active]/trigger:fill-netbird transition-all"
}
/>
{singularize("Services", services.length)}
</TabsTrigger>
</TabsList>
<Tabs
defaultValue={tab}
onValueChange={setTab}
value={tab}
className={"pb-0 mb-0"}
>
<TabsList justify={"start"} className={"px-8"}>
<TabsTrigger value={"resources"}>
<Layers3Icon size={14} />
{singularize("Resources", network?.resources?.length)}
</TabsTrigger>
<TabsTrigger value={"routing-peers"}>
<PeerIcon
size={12}
className={
"fill-nb-gray-500 group-data-[state=active]/trigger:fill-netbird transition-all"
}
<TabsContent value={"resources"} className={"pb-8"}>
<ResourcesTabContent
data={resources}
isLoading={isResourcesLoading}
/>
{singularize("Routing Peers", network?.routing_peers_count)}
</TabsTrigger>
<TabsTrigger value={"services"}>
<ReverseProxyIcon
size={16}
className={
"fill-nb-gray-500 group-data-[state=active]/trigger:fill-netbird transition-all"
}
</TabsContent>
<TabsContent value={"routing-peers"} className={"pb-8"}>
<NetworkRoutingPeersTabContent
routers={routers}
isLoading={isRoutersLoading}
/>
{singularize("Services", services.length)}
</TabsTrigger>
</TabsList>
</TabsContent>
<TabsContent value={"resources"} className={"pb-8"}>
<ResourcesTabContent
data={resources}
isLoading={isResourcesLoading}
/>
</TabsContent>
<TabsContent value={"routing-peers"} className={"pb-8"}>
<NetworkRoutingPeersTabContent
routers={routers}
isLoading={isRoutersLoading}
/>
</TabsContent>
<TabsContent value={"services"} className={"pb-8"}>
<ReverseProxyFlatTargetsTabContent
targets={services}
isLoading={isServicesLoading}
/>
</TabsContent>
</Tabs>
</NetworkProvider>
<TabsContent value={"services"} className={"pb-8"}>
<ReverseProxyFlatTargetsTabContent
targets={services}
isLoading={isServicesLoading}
/>
</TabsContent>
</Tabs>
</NetworkProvider>
</NetworkAccessControlProvider>
</PageContainer>
);
}

View File

@@ -8,8 +8,12 @@ export default function ReverseProxyIcon(props: IconProps) {
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
{...iconProperties(props)}
fill={"currentColor"}
>
<path d="M11.4488 2.1499C11.7903 1.95003 12.2097 1.95003 12.5513 2.1499L16.5018 4.46123L12 7.03523L7.49823 4.46123L11.4488 2.1499ZM6.44447 6.46472L6.44444 10.2784L2.93531 12.3315L7.53662 14.8399L10.8889 12.8787V9.00593L6.44447 6.46472ZM2 14.3992V18.7395C2 19.1477 2.21366 19.5247 2.55984 19.7272L6.44446 22V16.8223L2 14.3992ZM8.66668 22L12 20.0497L15.3333 22V16.7994L12 14.8492L8.66668 16.7993V22ZM17.5556 22L21.4401 19.7272C21.7863 19.5247 22 19.1477 22 18.7395V14.3992L17.5556 16.8223V22ZM21.0647 12.3315L17.5556 10.2784V6.46474L13.1111 9.00593V12.8787L16.4634 14.8399L21.0647 12.3315Z" />
<path
fill={"currentColor"}
d="M11.4488 2.1499C11.7903 1.95003 12.2097 1.95003 12.5513 2.1499L16.5018 4.46123L12 7.03523L7.49823 4.46123L11.4488 2.1499ZM6.44447 6.46472L6.44444 10.2784L2.93531 12.3315L7.53662 14.8399L10.8889 12.8787V9.00593L6.44447 6.46472ZM2 14.3992V18.7395C2 19.1477 2.21366 19.5247 2.55984 19.7272L6.44446 22V16.8223L2 14.3992ZM8.66668 22L12 20.0497L15.3333 22V16.7994L12 14.8492L8.66668 16.7993V22ZM17.5556 22L21.4401 19.7272C21.7863 19.5247 22 19.1477 22 18.7395V14.3992L17.5556 16.8223V22ZM21.0647 12.3315L17.5556 10.2784V6.46474L13.1111 9.00593V12.8787L16.4634 14.8399L21.0647 12.3315Z"
/>
</svg>
);
}

View File

@@ -2,6 +2,7 @@
import * as AccordionPrimitive from "@radix-ui/react-accordion";
import { cn } from "@utils/helpers";
import { motion } from "framer-motion";
import { ChevronDown } from "lucide-react";
import * as React from "react";
@@ -23,7 +24,7 @@ const AccordionTrigger = React.forwardRef<
<AccordionPrimitive.Trigger
ref={ref}
className={cn(
"flex flex-1 items-center gap-4 font-medium transition-all [&[data-state=open]>svg.chevron]:rotate-180 hover:opacity-80 my-2",
"flex flex-1 items-center gap-4 font-medium [&[data-state=open]>svg.chevron]:rotate-180 hover:opacity-80 my-2",
className,
)}
{...props}
@@ -36,20 +37,41 @@ const AccordionTrigger = React.forwardRef<
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName;
const AccordionContent = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Content>,
HTMLDivElement,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Content
ref={ref}
className={cn(
"overflow-hidden text-sm transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down",
className,
)}
{...props}
>
<div className=" pt-0">{children}</div>
</AccordionPrimitive.Content>
));
>(({ className, children }, ref) => {
const wrapperRef = React.useRef<HTMLDivElement>(null);
const [isOpen, setIsOpen] = React.useState(false);
React.useEffect(() => {
const el = wrapperRef.current?.closest("[data-state]");
if (!el) return;
const update = () => setIsOpen(el.getAttribute("data-state") === "open");
update();
const observer = new MutationObserver(update);
observer.observe(el, { attributes: true, attributeFilter: ["data-state"] });
return () => observer.disconnect();
}, []);
return (
<div ref={wrapperRef}>
<motion.div
ref={ref}
initial={false}
animate={{
height: isOpen ? "auto" : 0,
opacity: isOpen ? 1 : 0,
}}
transition={{ duration: 0.15, ease: "easeOut" }}
className={cn("overflow-hidden text-sm", className)}
>
<div className="pt-0">{children}</div>
</motion.div>
</div>
);
});
AccordionContent.displayName = AccordionPrimitive.Content.displayName;
export { Accordion, AccordionContent, AccordionItem, AccordionTrigger };

View File

@@ -80,13 +80,15 @@ export const DeviceCard = ({
hideTooltip={true}
/>
</span>
<span
className={
"text-sm font-normal text-nb-gray-400 relative whitespace-nowrap"
}
>
<TruncatedText text={descriptionText} maxWidth={"160px"} />
</span>
{descriptionText && (
<span
className={
"text-sm font-normal text-nb-gray-400 relative whitespace-nowrap"
}
>
<TruncatedText text={descriptionText} maxWidth={"160px"} />
</span>
)}
</div>
</div>
);

View File

@@ -1,29 +1,68 @@
import * as React from "react";
import FullTooltip from "@components/FullTooltip";
import { HelpCircle } from "lucide-react";
import { cn } from "@utils/helpers";
import { TooltipVariants } from "@components/Tooltip";
type Props = {
content: React.ReactNode;
children: React.ReactNode;
children?: React.ReactNode;
interactive?: boolean;
};
className?: string;
triggerClassName?: string;
align?: "start" | "center" | "end";
side?: "top" | "right" | "bottom" | "left";
alignOffset?: number;
sideOffset?: number;
iconSize?: number;
delayDuration?: number;
} & TooltipVariants;
export const HelpTooltip = ({
content,
children,
interactive = true,
interactive = false,
className,
variant = "default",
triggerClassName,
align = "start",
side = "top",
alignOffset = 0,
sideOffset,
iconSize = 12,
delayDuration = 300,
}: Props) => {
return (
<>
<FullTooltip
interactive={interactive}
side={"top"}
align={"start"}
alignOffset={0}
side={side}
align={align}
alignOffset={alignOffset}
sideOffset={sideOffset}
delayDuration={delayDuration}
variant={variant}
className={
"inline underline decoration-dashed underline-offset-[3px] decoration-nb-gray-300 cursor-help transition-all hover:decoration-white"
}
content={content}
content={
<div className={cn("max-w-xs text-xs", className)}>{content}</div>
}
>
{children}
{children ? (
children
) : (
<span
className={cn(
"p-2 -m-2 inline-flex items-center justify-center relative top-[1px] group/help",
triggerClassName,
)}
>
<HelpCircle
size={iconSize}
className={"text-nb-gray-300 group-hover/help:text-nb-gray-100"}
/>
</span>
)}
</FullTooltip>
</>
);

View File

@@ -18,6 +18,7 @@ export interface NotifyProps<T> {
icon?: React.ReactNode;
backgroundColor?: string;
preventSuccessToast?: boolean;
showOnlyError?: boolean;
errorMessages?: ErrorResponse[];
}
@@ -36,6 +37,7 @@ export default function Notification<T>({
loadingMessage,
duration = 3500,
preventSuccessToast = false,
showOnlyError = false,
errorMessages,
}: NotificationProps<T>) {
const [error, setError] = useState("");
@@ -49,10 +51,13 @@ export default function Notification<T>({
const startTimer = useCallback(() => {
if (timerRef.current) return;
startTimeRef.current = Date.now();
timerRef.current = setTimeout(() => {
timerRef.current = null;
toast.dismiss(toastId);
}, Math.max(0, remainingRef.current));
timerRef.current = setTimeout(
() => {
timerRef.current = null;
toast.dismiss(toastId);
},
Math.max(0, remainingRef.current),
);
}, [toastId]);
const pauseTimer = useCallback(() => {
@@ -88,7 +93,10 @@ export default function Notification<T>({
}
});
observer.observe(toastEl, { attributes: true, attributeFilter: ["data-expanded"] });
observer.observe(toastEl, {
attributes: true,
attributeFilter: ["data-expanded"],
});
// Start immediately if not expanded
const expanded = toastEl.getAttribute("data-expanded") === "true";
@@ -106,7 +114,7 @@ export default function Notification<T>({
promise
.then(() => {
setLoading(false);
if (preventSuccessToast) {
if (showOnlyError || preventSuccessToast) {
toast.dismiss(toastId);
} else {
setReadyToDismiss(true);
@@ -136,6 +144,9 @@ export default function Notification<T>({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const hideUntilError = showOnlyError && loading && !error;
if (hideUntilError) return null;
return (
<motion.div
ref={notificationRef}

View File

@@ -30,6 +30,7 @@ import {
MonitorSmartphoneIcon,
NetworkIcon,
SearchIcon,
ShieldCheck,
WorkflowIcon,
} from "lucide-react";
import * as React from "react";
@@ -40,7 +41,7 @@ import { useElementSize } from "@/hooks/useElementSize";
import type { Group, GroupPeer, GroupResource } from "@/interfaces/Group";
import { NetworkResource } from "@/interfaces/Network";
import type { Peer } from "@/interfaces/Peer";
import { PolicyRuleResource } from "@/interfaces/Policy";
import { Policy, PolicyRuleResource } from "@/interfaces/Policy";
import { User } from "@/interfaces/User";
import { HorizontalUsersStack } from "@/modules/users/HorizontalUsersStack";
import { PeerOperatingSystemIcon } from "@/modules/peers/PeerOperatingSystemIcon";
@@ -71,6 +72,7 @@ interface MultiSelectProps {
showResourceCounter?: boolean;
showResources?: boolean;
showPeers?: boolean;
showPeerCounter?: boolean;
hideGroupsTab?: boolean;
tabOrder?: ("groups" | "peers" | "resources")[];
closeOnSelect?: boolean;
@@ -83,6 +85,8 @@ interface MultiSelectProps {
users?: User[];
placeholderForSearch?: string;
resourceIds?: string[];
additionalResources?: NetworkResource[];
policies?: Policy[];
}
export function PeerGroupSelector({
onChange,
@@ -101,6 +105,7 @@ export function PeerGroupSelector({
showResourceCounter = true,
showResources = false,
showPeers = false,
showPeerCounter = true,
hideGroupsTab = false,
tabOrder,
closeOnSelect = false,
@@ -113,11 +118,21 @@ export function PeerGroupSelector({
users,
placeholderForSearch = 'Search groups or add new group by pressing "Enter"...',
resourceIds,
additionalResources,
policies,
}: Readonly<MultiSelectProps>) {
const { data: resources, isLoading: isResourcesLoading } = useFetchApi<
const { data: fetchedResources, isLoading: isResourcesLoading } = useFetchApi<
NetworkResource[]
>("/networks/resources");
const resources = useMemo(() => {
if (!additionalResources?.length) return fetchedResources;
const additional = additionalResources.filter(
(ar) => !fetchedResources?.some((r) => r.id === ar.id),
);
return [...(fetchedResources || []), ...additional];
}, [fetchedResources, additionalResources]);
const { data: peers, isLoading: isPeersLoading } =
useFetchApi<Peer[]>("/peers");
@@ -329,7 +344,7 @@ export function PeerGroupSelector({
"min-h-[46px] w-full relative items-center group",
"border border-neutral-200 dark:border-nb-gray-700 justify-between py-2 px-3",
"rounded-md bg-white text-sm dark:bg-nb-gray-900/40 flex dark:text-neutral-400/70 text-neutral-500 cursor-pointer hover:dark:bg-nb-gray-900/50",
"disabled:pointer-events-none disabled:opacity-30 transition-all",
"disabled:pointer-events-none disabled:opacity-60 transition-all",
)}
disabled={disabled}
data-cy={dataCy}
@@ -343,7 +358,14 @@ export function PeerGroupSelector({
{resource && (
<ResourceBadge
className={"py-[3px]"}
resource={resources?.find((r) => r.id === resource.id)}
resource={
resources?.find((r) => r.id === resource.id) ??
({
id: resource.id,
name: resource.id,
type: resource.type,
} as NetworkResource)
}
peer={peers?.find((p) => p.id === resource.id)}
onClick={(e) => {
e.preventDefault();
@@ -569,12 +591,21 @@ export function PeerGroupSelector({
<ResourcesCounter group={option} />
)}
{policies && (
<PolicyCounter
group={option}
policies={policies}
/>
)}
<div className={"flex gap-4 items-center"}>
{!users ? (
<PeerCounter
group={option}
showResourceCounter={showResourceCounter}
/>
showPeerCounter && (
<PeerCounter
group={option}
showResourceCounter={showResourceCounter}
/>
)
) : (
<UsersCounter
group={option}
@@ -790,6 +821,39 @@ const ResourcesCounter = ({ group }: { group: Group }) => {
) : null;
};
const PolicyCounter = ({
group,
policies,
}: {
group: Group;
policies: Policy[];
}) => {
const count = useMemo(() => {
if (!group.id) return 0;
return policies.filter((policy) => {
const destinations = policy.rules?.[0]?.destinations as
| (Group | string)[]
| undefined;
return destinations?.some((d) =>
typeof d === "string" ? d === group.id : d.id === group.id,
);
}).length;
}, [group.id, policies]);
if (count === 0) return null;
return (
<div
className={
"text-nb-gray-300 font-medium flex items-center gap-2 transition-all"
}
>
<ShieldCheck size={14} className={"shrink-0"} />
{count} {count === 1 ? "Policy" : "Policies"}
</div>
);
};
const resourcesSearchPredicate = (item: NetworkResource, query: string) => {
const lowerCaseQuery = query.toLowerCase();
if (item.name.toLowerCase().includes(lowerCaseQuery)) return true;

View File

@@ -8,6 +8,7 @@ type Props = {
description: ReactNode;
icon?: ReactNode;
className?: string;
disabled?: boolean;
};
export const RadioCard = ({
@@ -16,15 +17,18 @@ export const RadioCard = ({
description,
className,
icon,
disabled,
}: Props) => {
return (
<RadioGroup.Item
value={value}
disabled={disabled}
className={cn(
"peer relative block cursor-pointer rounded-lg border border-nb-gray-900 bg-nb-gray-930/60 px-5 py-3 transition-all focus:outline-none",
"data-[state=checked]:border-nb-gray-400 data-[state=checked]:bg-nb-gray-920",
"outline-none focus:ring-0 focus:bg-nb-gray-930 focus:border-nb-gray-920",
"hover:bg-nb-gray-930",
"disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-nb-gray-930/60",
className,
)}
>

View File

@@ -75,23 +75,59 @@ SelectLabel.displayName = SelectPrimitive.Label.displayName;
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item> & {
extra?: React.ReactNode;
icon?: React.ReactNode;
description?: React.ReactNode;
}
>(({ className, children, extra, icon, description, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full select-none items-center rounded-md py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-gray-100 focus:text-neutral-900 data-[disabled]:pointer-events-none data-[disabled]:opacity-50 dark:focus:bg-nb-gray-900 dark:focus:text-neutral-50 dark:text-gray-400 cursor-pointer",
"relative flex w-full select-none items-center rounded-md py-1.5 text-sm outline-none focus:bg-gray-100 focus:text-neutral-900 data-[disabled]:pointer-events-none data-[disabled]:opacity-50 dark:focus:bg-nb-gray-900 dark:focus:text-neutral-50 dark:text-gray-400 cursor-pointer",
icon ? "pl-2 pr-8" : "pl-8 pr-2",
description && "py-2",
className,
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
{icon ? (
<>
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<div className="flex items-center gap-3">
<span className="flex-shrink-0">{icon}</span>
<div className="flex flex-col">
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
{description && (
<span className="text-xs text-nb-gray-300 font-normal">
{description}
</span>
)}
</div>
</div>
</>
) : (
<>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<div className="flex flex-col">
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
{description && (
<span className="text-xs text-nb-gray-300 font-normal">
{description}
</span>
)}
</div>
</>
)}
{extra}
</SelectPrimitive.Item>
));
SelectItem.displayName = SelectPrimitive.Item.displayName;

View File

@@ -38,6 +38,7 @@ const ModalOverlay = React.forwardRef<
"bg-black/30 dark:bg-black/40 backdrop-blur-sm",
className,
)}
style={{ scrollbarGutter: "stable both-edges" }}
{...props}
/>
));

View File

@@ -1,15 +1,20 @@
import * as React from "react";
import Skeleton from "react-loading-skeleton";
import { cn } from "@utils/helpers";
export const SkeletonDeviceCard = () => {
type Props = {
className?: string;
};
export const SkeletonDeviceCard = ({ className = "min-h-[59px]" }: Props) => {
return (
<div className={"min-h-[59px] relative -left-2"}>
<div className={"py-2 pr-4 pl-2 flex gap-3"}>
<Skeleton height={36} width={36} />
<div className={"flex flex-col pr-[1.15rem]"}>
<Skeleton height={16} width={70} />
<Skeleton height={16} width={140} />
</div>
<div
className={cn("py-2 pr-4 pl-2 flex gap-3 relative -left-2", className)}
>
<Skeleton height={36} width={36} />
<div className={"flex flex-col pr-[1.15rem]"}>
<Skeleton height={16} width={70} />
<Skeleton height={16} width={140} />
</div>
</div>
);

View File

@@ -0,0 +1,42 @@
import * as React from "react";
import { useEffect, useState } from "react";
import Skeleton from "react-loading-skeleton";
import SkeletonTable from "@components/skeletons/SkeletonTable";
export const SkeletonNetwork = ({ delay = 400 }: { delay?: number }) => {
const [show, setShow] = useState(delay === 0);
useEffect(() => {
if (delay === 0) return;
const timer = setTimeout(() => setShow(true), delay);
return () => clearTimeout(timer);
}, [delay]);
if (!show) return null;
return (
<div className={"p-default py-6 w-full"}>
<Skeleton height={24} width={240} className={"mb-4"} />
<div className={"mb-8 flex items-center gap-4"}>
<Skeleton height={48} width={48} />
<Skeleton height={20} width={200} />
</div>
<div className={"mb-4"}>
<Skeleton height={106} className={"mb-2 w-full max-w-[574px]"} />
</div>
<div className={"flex items-center gap-4 mb-8"}>
<Skeleton height={24} width={130} />
<Skeleton height={24} width={130} />
<Skeleton height={24} width={130} />
</div>
<div>
<Skeleton height={16} width={530} className={"w-full max-w-[530px]"} />
<Skeleton height={16} width={430} className={"w-full max-w-[430px]"} />
</div>
<div className={"w-full"}>
<SkeletonTable withHeader={false} />
</div>
</div>
);
};

View File

@@ -296,6 +296,7 @@ export function DataTable<TData, TValue>({
autoResetAll: false,
autoResetExpanded: false,
manualPagination: manualPagination,
manualSorting: serverSidePagination,
manualFiltering: manualFiltering || manualColumnFiltering,
pageCount: pageCount,
state: {

View File

@@ -5,6 +5,7 @@ import { IconSortAscending, IconSortDescending } from "@tabler/icons-react";
import type { Column } from "@tanstack/table-core";
import { cn } from "@utils/helpers";
import React from "react";
import { useOptionalServerPagination } from "@/contexts/ServerPaginationProvider";
type Props = {
column: Column<any>;
@@ -13,6 +14,7 @@ type Props = {
center?: boolean;
className?: string;
sorting?: boolean;
name?: string;
};
export default function DataTableHeader({
children,
@@ -21,15 +23,22 @@ export default function DataTableHeader({
center,
className,
sorting = true,
name,
}: Props) {
const serverPagination = useOptionalServerPagination();
const handleSort = () => {
const direction = column.getIsSorted() === "asc" ? "desc" : "asc";
column.toggleSorting(direction === "desc");
if (name && serverPagination?.setSort) {
serverPagination.setSort(name, direction);
}
};
return (
<FullTooltip content={tooltip} disabled={!tooltip}>
<div
onClick={
sorting
? () => column.toggleSorting(column.getIsSorted() === "asc")
: undefined
}
onClick={sorting ? handleSort : undefined}
className={cn(
"flex items-center whitespace-nowrap gap-2 dark:text-gray-400 transition-all select-none text-xs tracking-wide",
sorting &&

View File

@@ -26,6 +26,7 @@ type Props = {
showResources?: boolean;
redirectGroupTab?: string;
showUsers?: boolean;
disableRedirect?: boolean;
};
export default function MultipleGroups({
@@ -37,6 +38,7 @@ export default function MultipleGroups({
showResources = false,
showUsers = false,
redirectGroupTab,
disableRedirect = false,
}: Readonly<Props>) {
const { permission } = usePermissions();
@@ -64,6 +66,7 @@ export default function MultipleGroups({
{firstGroup && (
<GroupBadge
group={firstGroup}
showNewBadge={true}
className={
permission.groups.update ? "group-hover:bg-nb-gray-800" : ""
}
@@ -101,7 +104,7 @@ export default function MultipleGroups({
return (
group && (
<div
key={group.id}
key={group?.id || group?.name}
className={
"flex gap-2 items-center justify-between w-full"
}
@@ -110,16 +113,23 @@ export default function MultipleGroups({
group={group}
className={"py-0"}
textClassName={"py-1.5"}
redirectToGroupPage={true}
showNewBadge={true}
redirectToGroupPage={!disableRedirect}
redirectGroupTab={redirectGroupTab}
></GroupBadge>
<ArrowRightIcon size={14} />
{showResources ? (
<ResourceCountBadge group={group} />
<ResourceCountBadge
group={group}
disableRedirect={disableRedirect}
/>
) : showUsers ? (
<UserCountStack group={group} />
) : (
<PeerCountBadge group={group} />
<PeerCountBadge
group={group}
disableRedirect={disableRedirect}
/>
)}
</div>
)

View File

@@ -10,6 +10,7 @@ import ResourceCountBadge from "@components/ui/ResourceCountBadge";
type Props = {
group?: Group;
disableRedirect?: boolean;
} & React.HTMLAttributes<HTMLDivElement> &
BadgeVariants;
@@ -17,6 +18,7 @@ export default function PeerCountBadge({
group,
variant = "gray",
className,
disableRedirect = false,
}: Props) {
const router = useRouter();
const { dropdownOptions, groups } = useGroups();
@@ -35,7 +37,8 @@ export default function PeerCountBadge({
return peerCount;
}, [currentGroup]);
const canRedirect = !!group?.id && group?.name !== "All";
const canRedirect =
!!group?.id && group?.name !== "All" && !disableRedirect;
const onClick = (e: React.MouseEvent<HTMLDivElement>) => {
e.stopPropagation();
@@ -46,7 +49,7 @@ export default function PeerCountBadge({
const showResources = resourcesCount > 0 && peerCount === 0;
return showResources ? (
<ResourceCountBadge group={group} />
<ResourceCountBadge group={group} disableRedirect={disableRedirect} />
) : (
<Badge
variant={variant}

View File

@@ -7,15 +7,20 @@ import { Group } from "@/interfaces/Group";
type Props = {
group?: Group;
disableRedirect?: boolean;
} & React.HTMLAttributes<HTMLDivElement> &
BadgeVariants;
export default function ResourceCountBadge({ group }: Props) {
export default function ResourceCountBadge({
group,
disableRedirect = false,
}: Props) {
const router = useRouter();
const hasId = !!group?.id;
const onClick = (e: React.MouseEvent<HTMLDivElement>) => {
e.stopPropagation();
if (disableRedirect) return;
if (hasId) router.push(`/group?id=${group?.id}&tab=resources`);
};

View File

@@ -27,6 +27,8 @@ type DialogOptions = {
type?: "default" | "warning" | "danger" | "center";
children?: React.ReactNode;
maxWidthClass?: string;
hideIcon?: boolean;
center?: boolean;
};
export default function DialogProvider({ children }: Props) {
@@ -70,14 +72,14 @@ export default function DialogProvider({ children }: Props) {
onPointerDownOutside={(e) => e.preventDefault()}
>
<ModalHeader
center={dialogOptions.type == "center"}
center={dialogOptions.center ?? dialogOptions.type == "center"}
title={dialogOptions.title || "Confirmation"}
margin={"mt-1"}
description={
dialogOptions.description ||
"Are you sure you want to continue? This action cannot be undone."
}
icon={dialogTypes[dialogOptions.type || "default"]}
icon={dialogOptions.hideIcon ? "" : dialogTypes[dialogOptions.type || "default"]}
color={
dialogOptions.type == "default"
? "blue"

View File

@@ -1,7 +1,12 @@
import { Modal } from "@components/modal/Modal";
import { notify } from "@components/Notification";
import { useApiCall } from "@utils/api";
import { cloneDeep } from "@utils/helpers";
import React, { useState } from "react";
import { useSWRConfig } from "swr";
import { useGroups } from "@/contexts/GroupsProvider";
import { Group } from "@/interfaces/Group";
import { NetworkResource } from "@/interfaces/Network";
import { Policy } from "@/interfaces/Policy";
import { AccessControlModalContent } from "@/modules/access-control/AccessControlModal";
@@ -18,18 +23,115 @@ const PoliciesContext = React.createContext(
message?: string,
) => void;
createPolicy: (policy: Policy) => Promise<Policy>;
createPoliciesForResource: (
policies: Policy[],
resource: NetworkResource,
knownGroups?: Group[],
) => Promise<void>;
openEditPolicyModal: (policy: Policy, tab?: string) => void;
deletePolicy: (policy: Policy, onSuccess?: () => void) => Promise<void>;
serializeRules: (
rules: Policy["rules"],
enabled?: boolean,
) => Policy["rules"];
},
);
export default function PoliciesProvider({ children }: Props) {
const { mutate } = useSWRConfig();
const request = useApiCall<Policy>("/policies");
const { createOrUpdate: createOrUpdateGroup, groups } = useGroups();
const [policyModal, setPolicyModal] = useState(false);
const [currentPolicy, setCurrentPolicy] = useState<Policy>();
const [initialPolicyTab, setInitialPolicyTab] = useState("");
const createPolicy = async (policy: Policy) => request.post(policy);
const createPolicyForResource = async (
policy: Policy,
resource: NetworkResource,
knownGroups?: Group[],
) => {
const rule = policy.rules[0];
const allGroups = [...(knownGroups || []), ...(groups || [])];
const resolveGroup = async (g: Group | string): Promise<string> => {
if (typeof g === "string") return g;
if (g.id) return g.id;
const existing = allGroups.find((eg) => eg.name === g.name);
if (existing?.id) return existing.id;
const created = await createOrUpdateGroup(g);
return created.id!;
};
const sources = await Promise.all(
(rule.sources ?? []).map(resolveGroup),
).then((ids) => ids.filter(Boolean) as string[]);
const destinations = rule.destinationResource
? undefined
: await Promise.all((rule.destinations ?? []).map(resolveGroup)).then(
(ids) => ids.filter(Boolean) as string[],
);
const destinationResource = rule.destinationResource
? { id: resource.id, type: resource.type }
: undefined;
return createPolicy({
...policy,
source_posture_checks: (policy.source_posture_checks ?? []).map((c) =>
typeof c === "string" ? c : c.id,
),
rules: [
{
...rule,
sources,
destinations,
destinationResource,
},
],
} as Policy);
};
const createPoliciesForResource = async (
newPolicies: Policy[],
resource: NetworkResource,
knownGroups?: Group[],
) => {
const policiesToCreate = newPolicies.filter((p) => !p.id);
if (policiesToCreate.length === 0) return;
await Promise.all(
policiesToCreate.map((p) =>
createPolicyForResource(p, resource, knownGroups),
),
);
await mutate("/policies");
};
const serializeRules = (rules: Policy["rules"], enabled?: boolean) => {
rules = cloneDeep(rules);
rules.forEach((rule) => {
if (enabled !== undefined) rule.enabled = enabled;
rule.sources = rule.sources
? (rule.sources.map((s) => {
const group = s as Group;
return group.id ?? s;
}) as string[])
: [];
rule.destinations = rule.destinations
? (rule.destinations.map((d) => {
const group = d as Group;
return group.id ?? d;
}) as string[])
: [];
if (rule.destinationResource) rule.destinations = null;
if (rule.sourceResource) rule.sources = null;
});
return rules;
};
const updatePolicy = async (
policy: Policy,
toUpdate: Partial<Policy>,
@@ -62,6 +164,20 @@ export default function PoliciesProvider({ children }: Props) {
});
};
const deletePolicy = async (policy: Policy, onSuccess?: () => void) => {
const promise = request.del("", `/${policy.id}`).then(() => {
mutate("/policies");
onSuccess?.();
});
notify({
title: "Access Control Policy " + policy.name,
description: "The policy was successfully deleted.",
promise,
loadingMessage: "Deleting policy...",
});
return promise;
};
const openEditPolicyModal = (policy: Policy, tab?: string) => {
setCurrentPolicy(policy);
tab && setInitialPolicyTab(tab);
@@ -70,7 +186,14 @@ export default function PoliciesProvider({ children }: Props) {
return (
<PoliciesContext.Provider
value={{ updatePolicy, createPolicy, openEditPolicyModal }}
value={{
updatePolicy,
createPolicy,
createPoliciesForResource,
openEditPolicyModal,
deletePolicy,
serializeRules,
}}
>
{children}
<Modal

View File

@@ -26,6 +26,8 @@ import ReverseProxyTargetModal from "@/modules/reverse-proxy/targets/ReverseProx
type ReverseProxiesContextValue = {
reverseProxies: ReverseProxy[] | undefined;
resources: NetworkResource[] | undefined;
peers: Peer[] | undefined;
isLoading: boolean;
openModal: (options?: OpenModalOptions) => void;
openTargetModal: (options: OpenTargetModalOptions) => void;
@@ -93,7 +95,7 @@ export default function ReverseProxiesProvider({
const { data: rawReverseProxies, isLoading } = useFetchApi<ReverseProxy[]>(
"/reverse-proxies/services",
);
const request = useApiCall<ReverseProxy>("/reverse-proxies/services");
const request = useApiCall<ReverseProxy>("/reverse-proxies/services", true);
// Peers & Resources for resolving target destinations
const { data: peers } = useFetchApi<Peer[]>("/peers");
@@ -465,6 +467,8 @@ export default function ReverseProxiesProvider({
<ReverseProxiesContext.Provider
value={{
reverseProxies,
resources,
peers,
isLoading,
openModal,
openTargetModal,

View File

@@ -31,6 +31,7 @@ type ServerPaginationContextValue<T = unknown> = {
onGlobalFilterChange: (value: string) => void;
setFilter: (key: string, value: string | undefined) => void;
getFilter: (key: string) => string | undefined;
setSort: (name: string, direction: "asc" | "desc") => void;
hasActiveFilters: boolean;
resetFilters: () => void;
onFilterReset: () => void;
@@ -146,6 +147,15 @@ export default function ServerPaginationProvider({
const getFilter = useCallback((key: string) => filters[key], [filters]);
const setSort = useCallback((name: string, direction: "asc" | "desc") => {
setFilters((prev) => ({
...prev,
sort_by: name,
sort_order: direction,
}));
setPage(1);
}, []);
const hasActiveFilters =
search !== "" ||
Object.entries(filters).some(
@@ -170,6 +180,7 @@ export default function ServerPaginationProvider({
mutate,
setFilter,
getFilter,
setSort,
hasActiveFilters,
resetFilters,
pagination: { pageIndex: page - 1, pageSize },
@@ -193,6 +204,7 @@ export default function ServerPaginationProvider({
mutate,
setFilter,
getFilter,
setSort,
hasActiveFilters,
resetFilters,
page,
@@ -220,3 +232,8 @@ export function useServerPagination<T>() {
}
return context as ServerPaginationContextValue<T>;
}
export function useOptionalServerPagination<T>() {
const context = useContext(ServerPaginationContext);
return context as ServerPaginationContextValue<T> | null;
}

View File

@@ -1,26 +1,37 @@
import { useRouter, useSearchParams } from "next/navigation";
import { useCallback, useMemo } from "react";
import { useSearchParams } from "next/navigation";
import { useCallback, useEffect, useState } from "react";
export default function useUrlTab(
validTabs: string[],
defaultTab: string,
): [string, (value: string) => void] {
const searchParams = useSearchParams();
const router = useRouter();
const tab = useMemo(() => {
const tabParam = searchParams.get("tab");
if (tabParam && validTabs.includes(tabParam)) return tabParam;
return defaultTab;
}, [searchParams, validTabs, defaultTab]);
const getTab = useCallback(
(params: URLSearchParams) => {
const tabParam = params.get("tab");
if (tabParam && validTabs.includes(tabParam)) return tabParam;
return defaultTab;
},
[validTabs, defaultTab],
);
const [tab, setTabState] = useState(() => getTab(searchParams));
useEffect(() => {
const newTab = getTab(searchParams);
setTabState(newTab);
}, [searchParams, getTab]);
const setTab = useCallback(
(value: string) => {
const params = new URLSearchParams(searchParams.toString());
params.set("tab", value);
router.replace(`?${params.toString()}`, { scroll: false });
const nextTab = validTabs.includes(value) ? value : defaultTab;
setTabState(nextTab);
const params = new URLSearchParams(window.location.search);
params.set("tab", nextTab);
window.history.replaceState(null, "", `?${params.toString()}`);
},
[searchParams, router],
[validTabs, defaultTab],
);
return [tab, setTab];

View File

@@ -26,6 +26,7 @@ export interface Account {
lazy_connection_enabled: boolean;
embedded_idp_enabled?: boolean;
auto_update_version: string;
auto_update_always: boolean;
local_auth_disabled?: boolean;
};
onboarding?: AccountOnboarding;

View File

@@ -1,7 +1,17 @@
export enum ServiceMode {
HTTP = "http",
TCP = "tcp",
UDP = "udp",
TLS = "tls",
}
export interface ReverseProxy {
id?: string;
name: string;
domain: string;
mode?: ServiceMode;
listen_port?: number;
port_auto_assigned?: boolean;
proxy_cluster?: string;
targets: ReverseProxyTarget[];
enabled: boolean;
@@ -26,6 +36,17 @@ export enum ReverseProxyStatus {
ERROR = "error",
}
export type ServiceTargetOptionsPathRewrite = "preserve";
export interface ServiceTargetOptions {
skip_tls_verify?: boolean;
request_timeout?: string;
session_idle_timeout?: string;
path_rewrite?: ServiceTargetOptionsPathRewrite;
custom_headers?: Record<string, string>;
proxy_protocol?: boolean;
}
export interface ReverseProxyTarget {
target_id?: string;
target_type: ReverseProxyTargetType;
@@ -35,6 +56,7 @@ export interface ReverseProxyTarget {
port: number;
enabled: boolean;
access_local?: boolean;
options?: ServiceTargetOptions;
// Frontend
destination?: string;
}
@@ -63,6 +85,7 @@ export interface ReverseProxyDomain {
validated: boolean;
type: ReverseProxyDomainType;
target_cluster?: string;
supports_custom_ports?: boolean;
}
export enum ReverseProxyDomainType {
@@ -80,6 +103,15 @@ export enum ReverseProxyTargetType {
export enum ReverseProxyTargetProtocol {
HTTP = "http",
HTTPS = "https",
TCP = "tcp",
UDP = "udp",
}
export enum EventProtocol {
HTTP = "http",
TCP = "tcp",
UDP = "udp",
TLS = "tls",
}
export interface ReverseProxyEvent {
@@ -97,12 +129,31 @@ export interface ReverseProxyEvent {
auth_method_used?: string;
country_code?: string;
city_name?: string;
bytes_upload: number;
bytes_download: number;
protocol?: EventProtocol;
}
export function isL4Event(event: ReverseProxyEvent): boolean {
return (
event.protocol === EventProtocol.TCP ||
event.protocol === EventProtocol.UDP ||
event.protocol === EventProtocol.TLS
);
}
export interface ReverseProxyFlatTarget extends ReverseProxyTarget {
proxy: ReverseProxy;
}
export function isL4Mode(mode?: ServiceMode): boolean {
return (
mode === ServiceMode.TCP ||
mode === ServiceMode.UDP ||
mode === ServiceMode.TLS
);
}
export const REVERSE_PROXY_DOCS_LINK =
"https://docs.netbird.io/manage/reverse-proxy";
@@ -129,3 +180,6 @@ export const REVERSE_PROXY_DOMAIN_VERIFICATION_LINK =
export const REVERSE_PROXY_EVENTS_DOCS_LINK =
"https://docs.netbird.io/manage/reverse-proxy/access-logs";
export const REVERSE_PROXY_TROUBLESHOOTING_DOCS_LINK =
"https://docs.netbird.io/manage/reverse-proxy#troubleshooting";

View File

@@ -46,6 +46,7 @@ import React, { useMemo, useState } from "react";
import AccessControlIcon from "@/assets/icons/AccessControlIcon";
import { usePermissions } from "@/contexts/PermissionsProvider";
import { Group } from "@/interfaces/Group";
import { NetworkResource } from "@/interfaces/Network";
import { Policy, PolicyRuleResource, Protocol } from "@/interfaces/Policy";
import { PostureCheck } from "@/interfaces/PostureCheck";
import { useAccessControl } from "@/modules/access-control/useAccessControl";
@@ -54,6 +55,7 @@ import { PostureCheckTabTrigger } from "@/modules/posture-checks/ui/PostureCheck
import { SSHAccessType } from "@/modules/access-control/ssh/SSHAccessType";
import { SSHAuthorizedGroups } from "@/modules/access-control/ssh/SSHAuthorizedGroups";
import { useUsers } from "@/contexts/UsersProvider";
import { HelpTooltip } from "@components/HelpTooltip";
type Props = {
children?: React.ReactNode;
@@ -124,6 +126,8 @@ type ModalProps = {
initialPorts?: number[];
initialDestinationResource?: PolicyRuleResource;
initialTab?: string;
disableDestinationSelector?: boolean;
additionalResources?: NetworkResource[];
};
export function AccessControlModalContent({
@@ -140,6 +144,8 @@ export function AccessControlModalContent({
initialPorts,
initialDestinationResource,
initialTab,
disableDestinationSelector = false,
additionalResources,
}: Readonly<ModalProps>) {
const { permission } = usePermissions();
const { users } = useUsers();
@@ -293,7 +299,25 @@ export function AccessControlModalContent({
<SelectItem value="tcp">TCP</SelectItem>
<SelectItem value="udp">UDP</SelectItem>
<SelectItem value="icmp">ICMP</SelectItem>
<SelectItem value="netbird-ssh">NetBird SSH</SelectItem>
<SelectItem
value="netbird-ssh"
extra={
<HelpTooltip
triggerClassName={"ml-[0.01rem]"}
align={"center"}
side={"right"}
content={
<>
Select NetBird SSH for SSH-specific policies with
fine-grained access control, or use TCP with port 22
for basic network-level SSH access
</>
}
/>
}
>
NetBird SSH
</SelectItem>
</SelectContent>
</Select>
</div>
@@ -303,6 +327,15 @@ export function AccessControlModalContent({
<Label className={"mb-2"}>
<FolderDown size={15} />
Source
<HelpTooltip
content={
<>
Typically a group of user devices (e.g., Developers,
Marketing) or individual devices in peer-to-peer
connections that will access the destination.
</>
}
/>
</Label>
<PeerGroupSelector
dataCy={"source-group-selector"}
@@ -337,6 +370,15 @@ export function AccessControlModalContent({
<Label className={"mb-2"}>
<FolderInput size={15} />
Destination
<HelpTooltip
content={
<>
Typically a group of peers or resources (e.g., Servers,
Databases, Internal Services) that will be accessed by
the source. Can also be an individual peer or resource.
</>
}
/>
</Label>
<PeerGroupSelector
dataCy={"destination-group-selector"}
@@ -353,8 +395,11 @@ export function AccessControlModalContent({
resource={destinationResource}
onResourceChange={setDestinationResource}
saveGroupAssignments={useSave}
additionalResources={additionalResources}
disabled={
!permission.policies.update || !permission.policies.create
disableDestinationSelector ||
!permission.policies.update ||
!permission.policies.create
}
/>
</div>
@@ -575,7 +620,13 @@ export function AccessControlModalContent({
<Button
variant={"primary"}
disabled={submitDisabled || !permission.policies.create}
onClick={submit}
onClick={() => {
if (useSave) {
submit();
} else {
close();
}
}}
data-cy={"submit-policy"}
>
<PlusCircle size={16} />

View File

@@ -1,33 +1,19 @@
import Button from "@components/Button";
import { notify } from "@components/Notification";
import { useApiCall } from "@utils/api";
import { Trash2 } from "lucide-react";
import * as React from "react";
import { useSWRConfig } from "swr";
import { useDialog } from "@/contexts/DialogProvider";
import { usePermissions } from "@/contexts/PermissionsProvider";
import { Policy } from "@/interfaces/Policy";
import { Route } from "@/interfaces/Route";
import { usePolicies } from "@/contexts/PoliciesProvider";
type Props = {
policy: Policy;
};
export default function AccessControlActionCell({ policy }: Readonly<Props>) {
const { confirm } = useDialog();
const policyRequest = useApiCall<Route>("/policies");
const { mutate } = useSWRConfig();
const { permission } = usePermissions();
const deleteRule = async () => {
notify({
title: "Access Control Policy " + policy.name,
description: "The policy was successfully removed.",
promise: policyRequest.del("", `/${policy.id}`).then(() => {
mutate("/policies");
}),
loadingMessage: "Deleting the policy...",
});
};
const { deletePolicy } = usePolicies();
const openConfirm = async () => {
const choice = await confirm({
@@ -39,7 +25,7 @@ export default function AccessControlActionCell({ policy }: Readonly<Props>) {
type: "danger",
});
if (!choice) return;
deleteRule().then();
await deletePolicy(policy);
};
return (

View File

@@ -1,17 +1,15 @@
import { ToggleSwitch } from "@components/ToggleSwitch";
import { cloneDeep } from "@utils/helpers";
import React, { useMemo } from "react";
import { mutate } from "swr";
import { usePermissions } from "@/contexts/PermissionsProvider";
import { usePolicies } from "@/contexts/PoliciesProvider";
import { Group } from "@/interfaces/Group";
import { Policy } from "@/interfaces/Policy";
type Props = {
policy: Policy;
};
export default function AccessControlActiveCell({ policy }: Readonly<Props>) {
const { updatePolicy } = usePolicies();
const { updatePolicy, serializeRules } = usePolicies();
const { permission } = usePermissions();
const isChecked = useMemo(() => {
@@ -19,32 +17,9 @@ export default function AccessControlActiveCell({ policy }: Readonly<Props>) {
}, [policy]);
const update = async (enabled: boolean) => {
const rules = cloneDeep(policy.rules);
rules.forEach((rule) => {
rule.enabled = enabled;
rule.sources = rule.sources
? (rule.sources.map((source) => {
const group = source as Group;
return group.id;
}) as string[])
: [];
rule.destinations = rule.destinations
? (rule.destinations.map((destination) => {
const group = destination as Group;
return group.id;
}) as string[])
: [];
if (rule.destinationResource) {
rule.destinations = null;
}
if (rule.sourceResource) {
rule.sources = null;
}
});
updatePolicy(
policy,
{ enabled, rules },
{ enabled, rules: serializeRules(policy.rules, enabled) },
() => {
mutate("/policies");
},

View File

@@ -11,9 +11,13 @@ import { parsePortsToStrings } from "@/modules/access-control/useAccessControl";
type Props = {
policy: Policy;
visiblePorts?: number;
};
export default function AccessControlPortsCell({ policy }: Readonly<Props>) {
export default function AccessControlPortsCell({
policy,
visiblePorts = 2,
}: Readonly<Props>) {
const rule = useMemo(() => {
if (policy.rules.length > 0) return policy.rules[0];
return undefined;
@@ -25,13 +29,13 @@ export default function AccessControlPortsCell({ policy }: Readonly<Props>) {
const allPorts = useMemo(() => parsePortsToStrings(rule), [rule]);
const firstTwoPorts = useMemo(() => {
return allPorts?.slice(0, 2) ?? [];
}, [allPorts]);
const visiblePortsList = useMemo(() => {
return allPorts?.slice(0, visiblePorts) ?? [];
}, [allPorts, visiblePorts]);
const otherPorts = useMemo(() => {
return allPorts?.slice(2) ?? [];
}, [allPorts]);
return allPorts?.slice(visiblePorts) ?? [];
}, [allPorts, visiblePorts]);
return (
<div className={"flex-1"}>
@@ -48,7 +52,7 @@ export default function AccessControlPortsCell({ policy }: Readonly<Props>) {
</Badge>
)}
{firstTwoPorts?.map((port) => {
{visiblePortsList?.map((port) => {
return (
<Badge
key={port}
@@ -75,12 +79,8 @@ export default function AccessControlPortsCell({ policy }: Readonly<Props>) {
</div>
</TooltipTrigger>
{otherPorts && otherPorts.length > 0 && (
<TooltipContent>
<div
className={
"flex gap-2 items-start mt-3 mb-2 flex-wrap max-w-sm"
}
>
<TooltipContent className={"p-3"}>
<div className={"flex gap-2 items-start flex-wrap max-w-sm"}>
{otherPorts.map((port) => {
return (
<Badge key={port} variant={"gray"}>

View File

@@ -11,9 +11,15 @@ import EmptyRow from "@/modules/common-table-rows/EmptyRow";
type Props = {
policy: Policy;
hideEdit?: boolean;
disableRedirect?: boolean;
};
export default function AccessControlSourcesCell({ policy }: Props) {
export default function AccessControlSourcesCell({
policy,
hideEdit = false,
disableRedirect = false,
}: Props) {
const { permission } = usePermissions();
const canUpdate = permission?.policies?.update;
@@ -27,12 +33,18 @@ export default function AccessControlSourcesCell({ policy }: Props) {
}
return firstRule ? (
<div className={cn("flex items-center gap-1", canUpdate && "group")}>
<div
className={cn(
"flex items-center gap-1",
canUpdate && !hideEdit && "group",
)}
>
<MultipleGroups
groups={firstRule.sources as Group[]}
showUsers={firstRule.protocol === "netbird-ssh"}
disableRedirect={disableRedirect}
/>
{canUpdate && <TransparentEditIconButton />}
{canUpdate && !hideEdit && <TransparentEditIconButton />}
</div>
) : (
<EmptyRow />

View File

@@ -10,6 +10,7 @@ import { ArrowUpRightIcon, Layers3Icon } from "lucide-react";
import { useRouter } from "next/navigation";
import React, { useState } from "react";
import { useSWRConfig } from "swr";
import { NetworkAccessControlProvider } from "@/modules/networks/NetworkAccessControlProvider";
import { usePermissions } from "@/contexts/PermissionsProvider";
import { Group } from "@/interfaces/Group";
import { NetworkResourceWithNetwork } from "@/interfaces/Network";
@@ -115,67 +116,70 @@ export const GroupResourcesSection = ({
const { mutate } = useSWRConfig();
return (
<GroupDetailsTableContainer>
<DataTable
wrapperComponent={Card}
wrapperProps={{ className: "mt-6 pb-2 w-full" }}
sorting={sorting}
setSorting={setSorting}
minimal={true}
isLoading={isLoading}
showSearchAndFilters={true}
renderRow={(row, children) => (
<NetworkProvider
network={row.network}
onResourceUpdate={() => mutate("/networks/resources")}
onResourceDelete={() => mutate("/networks/resources")}
>
{children}
</NetworkProvider>
)}
inset={false}
tableClassName={"mt-0"}
text={"Resources"}
columns={GroupResourcesColumns}
keepStateInLocalStorage={false}
data={resources}
searchPlaceholder={"Search by name, address or group..."}
getStartedCard={
<NoResults
className={"py-4"}
title={"This group has no assigned resources"}
description={
"Assign this group to your resources inside your networks to see them listed here."
}
icon={<Layers3Icon size={20} />}
>
{permission?.networks?.create && (
<>
<Button
variant={"primary"}
className={"mt-4"}
onClick={() => router.push("/networks")}
>
Go to Networks
<ArrowUpRightIcon size={16} />
</Button>
</>
)}
</NoResults>
}
columnVisibility={{
description: false,
id: false,
}}
paginationPaddingClassName={"px-0 pt-8"}
>
{(table) => (
<DataTableRowsPerPage
table={table}
disabled={!resources || resources?.length == 0}
/>
)}
</DataTable>
</GroupDetailsTableContainer>
<NetworkAccessControlProvider>
<GroupDetailsTableContainer>
<DataTable
wrapperComponent={Card}
wrapperProps={{ className: "mt-6 pb-2 w-full" }}
sorting={sorting}
setSorting={setSorting}
minimal={true}
isLoading={isLoading}
showSearchAndFilters={true}
renderRow={(row, children) => (
<NetworkProvider
key={row.network.id + row.name}
network={row.network}
onResourceUpdate={() => mutate("/networks/resources")}
onResourceDelete={() => mutate("/networks/resources")}
>
{children}
</NetworkProvider>
)}
inset={false}
tableClassName={"mt-0"}
text={"Resources"}
columns={GroupResourcesColumns}
keepStateInLocalStorage={false}
data={resources}
searchPlaceholder={"Search by name, address or group..."}
getStartedCard={
<NoResults
className={"py-4"}
title={"This group has no assigned resources"}
description={
"Assign this group to your resources inside your networks to see them listed here."
}
icon={<Layers3Icon size={20} />}
>
{permission?.networks?.create && (
<>
<Button
variant={"primary"}
className={"mt-4"}
onClick={() => router.push("/networks")}
>
Go to Networks
<ArrowUpRightIcon size={16} />
</Button>
</>
)}
</NoResults>
}
columnVisibility={{
description: false,
id: false,
}}
paginationPaddingClassName={"px-0 pt-8"}
>
{(table) => (
<DataTableRowsPerPage
table={table}
disabled={!resources || resources?.length == 0}
/>
)}
</DataTable>
</GroupDetailsTableContainer>
</NetworkAccessControlProvider>
);
};

View File

@@ -0,0 +1,151 @@
import useFetchApi from "@utils/api";
import { orderBy } from "lodash";
import * as React from "react";
import { useCallback, useContext } from "react";
import { Group } from "@/interfaces/Group";
import { NetworkResource } from "@/interfaces/Network";
import { Policy } from "@/interfaces/Policy";
type NetworkAccessControlContextValue = {
policies?: Policy[];
policiesLoading: boolean;
resources?: NetworkResource[];
assignedPolicies: (
resource?: NetworkResource,
groups?: Group[],
) => {
policies: Policy[];
enabledPolicies: Policy[];
isLoading: boolean;
policyCount: number;
};
resourceExists: (name: string, excludeId?: string) => boolean;
getPolicyDestinationResources: (policy: Policy) => NetworkResource[];
};
const NetworkAccessControlContext =
React.createContext<NetworkAccessControlContextValue | null>(null);
type Props = {
children: React.ReactNode;
};
const toGroupId = (g: Group | string): string | undefined =>
typeof g === "string" ? g : g?.id;
export const NetworkAccessControlProvider = ({ children }: Props) => {
const { data: policies, isLoading: policiesLoading } =
useFetchApi<Policy[]>("/policies");
const { data: resources } = useFetchApi<NetworkResource[]>(
"/networks/resources",
);
const resourceExists = useCallback(
(name: string, excludeId?: string) => {
if (!name) return false;
return !!resources?.find(
(r) =>
r.name.toLowerCase() === name.toLowerCase() && r.id !== excludeId,
);
},
[resources],
);
const assignedPolicies = useCallback(
(resource?: NetworkResource, groups?: Group[]) => {
const resourceGroups = (groups || resource?.groups) as
| (Group | string)[]
| undefined;
if (!resource && !resourceGroups?.length) {
return {
policies: [],
enabledPolicies: [],
isLoading: policiesLoading,
policyCount: 0,
};
}
const resourceGroupIds = new Set(
resourceGroups?.map(toGroupId).filter(Boolean),
);
const resourcePolicies = orderBy(
policies?.filter((policy) => {
const rule = policy.rules?.[0];
if (!rule) return false;
if (resource && rule.destinationResource?.id === resource.id)
return true;
const destinations = (rule.destinations ?? []) as (Group | string)[];
return destinations.some((d) => {
const destId = toGroupId(d);
return !!destId && resourceGroupIds.has(destId);
});
}),
"enabled",
"desc",
);
const enabledPolicies = resourcePolicies?.filter(
(policy) => policy?.enabled,
);
return {
policies: resourcePolicies,
enabledPolicies,
isLoading: policiesLoading,
policyCount: resourcePolicies?.length || 0,
};
},
[policies, policiesLoading],
);
const getPolicyDestinationResources = useCallback(
(policy: Policy): NetworkResource[] => {
const rule = policy?.rules?.[0];
const destinationGroups = rule?.destinations as
| (Group | string)[]
| undefined;
const destinationGroupIds = new Set(
destinationGroups?.map(toGroupId).filter(Boolean),
);
const directDestinationId = rule?.destinationResource?.id;
return (
resources?.filter((resource) => {
if (directDestinationId && resource.id === directDestinationId)
return true;
const resourceGroups = resource.groups as
| (Group | string)[]
| undefined;
return resourceGroups?.some((g) => {
const groupId = toGroupId(g);
return !!groupId && destinationGroupIds.has(groupId);
});
}) ?? []
);
},
[resources],
);
return (
<NetworkAccessControlContext.Provider
value={{
policies,
policiesLoading,
resources,
assignedPolicies,
resourceExists,
getPolicyDestinationResources,
}}
>
{children}
</NetworkAccessControlContext.Provider>
);
};
export const useNetworkAccessControl =
(): NetworkAccessControlContextValue => {
const context = useContext(NetworkAccessControlContext);
if (!context) {
throw new Error(
"useNetworkAccessControl must be used within a NetworkAccessControlProvider",
);
}
return context;
};

View File

@@ -5,6 +5,7 @@ import * as React from "react";
import { useState } from "react";
import { useSWRConfig } from "swr";
import { useDialog } from "@/contexts/DialogProvider";
import { useNetworkAccessControl } from "@/modules/networks/NetworkAccessControlProvider";
import { Group } from "@/interfaces/Group";
import { Network, NetworkResource, NetworkRouter } from "@/interfaces/Network";
import { AccessControlModalContent } from "@/modules/access-control/AccessControlModal";
@@ -14,6 +15,9 @@ import { ResourceGroupModal } from "@/modules/networks/resources/ResourceGroupMo
import NetworkRoutingPeerModal from "@/modules/networks/routing-peers/NetworkRoutingPeerModal";
import { Policy, PolicyRuleResource } from "@/interfaces/Policy";
import PoliciesProvider from "@/contexts/PoliciesProvider";
import { ResourceIcon } from "@/assets/icons/ResourceIcon";
import CopyToClipboardText from "@components/CopyToClipboardText";
import { cn } from "@utils/helpers";
type Props = {
children: React.ReactNode;
@@ -27,7 +31,11 @@ const NetworksContext = React.createContext(
openAddRoutingPeerModal: (network: Network, router?: NetworkRouter) => void;
openEditNetworkModal: (network: Network) => void;
openCreateNetworkModal: () => void;
openResourceModal: (network: Network, resource?: NetworkResource) => void;
openResourceModal: (
network: Network,
resource?: NetworkResource,
initialTab?: string,
) => void;
openResourceGroupModal: (
network: Network,
resource?: NetworkResource,
@@ -38,6 +46,24 @@ const NetworksContext = React.createContext(
deleteResource: (network: Network, resource: NetworkResource) => void;
deleteRouter: (network: Network, router: NetworkRouter) => void;
network?: Network;
assignedPolicies: (
resource?: NetworkResource,
groups?: Group[],
) => {
policies: Policy[];
enabledPolicies: Policy[];
isLoading: boolean;
policyCount: number;
};
resourceExists: (name: string, excludeId?: string) => boolean;
resources?: NetworkResource[];
getPolicyDestinationResources: (policy: Policy) => NetworkResource[];
confirmMultiResourceAction: (
policy: Policy,
action: "edit" | "delete",
additionalResource?: NetworkResource,
) => Promise<boolean>;
policies?: Policy[];
},
);
@@ -50,6 +76,13 @@ export const NetworkProvider = ({
const { mutate } = useSWRConfig();
const { confirm } = useDialog();
const deleteCall = useApiCall("/networks").del;
const {
policies,
resources,
assignedPolicies,
resourceExists,
getPolicyDestinationResources,
} = useNetworkAccessControl();
const [currentNetwork, setCurrentNetwork] = useState<Network>();
const [currentResource, setCurrentResource] = useState<NetworkResource>();
@@ -88,9 +121,18 @@ export const NetworkProvider = ({
setNetworkModal(true);
};
const openResourceModal = (network: Network, resource?: NetworkResource) => {
const [resourceModalInitialTab, setResourceModalInitialTab] = useState<
string | undefined
>();
const openResourceModal = (
network: Network,
resource?: NetworkResource,
initialTab?: string,
) => {
setCurrentNetwork(network);
resource && setCurrentResource(resource);
setResourceModalInitialTab(initialTab);
setResourceModal(true);
};
@@ -110,11 +152,11 @@ export const NetworkProvider = ({
destinationResource: hasResourceGroups
? undefined
: resource
? ({
id: resource.id,
type: resource.type,
} as PolicyRuleResource)
: undefined,
? ({
id: resource.id,
type: resource.type,
} as PolicyRuleResource)
: undefined,
name:
network && !resource
? `${network?.name} Policy`
@@ -138,6 +180,46 @@ export const NetworkProvider = ({
setPolicyModal(true);
};
const confirmMultiResourceAction = async (
policy: Policy,
action: "edit" | "delete",
additionalResource?: NetworkResource,
) => {
const fetchedResources = getPolicyDestinationResources(policy);
const affectedResources =
additionalResource &&
!fetchedResources.some((r) => r.id === additionalResource.id)
? [...fetchedResources, additionalResource]
: fetchedResources;
const isMulti = affectedResources.length > 1;
if (!isMulti && action === "edit") return true;
return confirm({
title: isMulti ? (
<>This policy is used by multiple resources</>
) : (
<>
{action === "edit" ? "Edit" : "Delete"} policy &apos;{policy.name}
&apos;?
</>
),
description: isMulti
? `This policy uses one or many resource group(s) as destinations. ${
action === "edit" ? "Updating" : "Deleting"
} this policy will also affect following resources:`
: action === "delete"
? "Are you sure you want to delete this policy? This action cannot be undone."
: undefined,
children: isMulti ? (
<AffectedResourceList resources={affectedResources} />
) : undefined,
confirmText: action === "edit" ? "Edit Policy" : "Delete Policy",
cancelText: "Cancel",
hideIcon: isMulti,
type: action === "edit" ? "warning" : "danger",
maxWidthClass: isMulti ? "max-w-lg" : undefined,
});
};
const deleteNetwork = async (network: Network) => {
const choice = await confirm({
title: `Delete network '${network.name}'?`,
@@ -244,19 +326,6 @@ export const NetworkProvider = ({
openResourceModal(network);
};
const askForAccessControlPolicy = async (res: NetworkResource) => {
const choice = await confirm({
title: `Add policy for '${res.name}'?`,
description:
"Without a policy, the resource will not be accessible by any peers. Create a policy to control access to this resource.",
confirmText: "Create Policy",
cancelText: "Later",
type: "default",
});
if (!choice) return;
openPolicyModal(currentNetwork, res);
};
return (
<NetworksContext.Provider
value={{
@@ -271,24 +340,30 @@ export const NetworkProvider = ({
deleteResource,
deleteRouter,
network,
assignedPolicies,
resourceExists,
resources,
getPolicyDestinationResources,
confirmMultiResourceAction,
policies,
}}
>
{children}
<NetworkModal
open={networkModal}
setOpen={setNetworkModal}
network={currentNetwork}
onCreated={async (network) => {
mutate("/networks");
await askForResource(network);
}}
onUpdated={(n) => {
mutate("/networks");
mutate(`/networks/${n.id}`);
}}
/>
<PoliciesProvider>
{children}
<NetworkModal
open={networkModal}
setOpen={setNetworkModal}
network={currentNetwork}
onCreated={async (network) => {
mutate("/networks");
await askForResource(network);
}}
onUpdated={(n) => {
mutate("/networks");
mutate(`/networks/${n.id}`);
}}
/>
<Modal
open={policyModal}
onOpenChange={(state) => {
@@ -321,93 +396,99 @@ export const NetworkProvider = ({
}}
/>
</Modal>
{currentNetwork && (
<>
<NetworkRoutingPeerModal
network={currentNetwork}
router={currentRouter}
open={routingPeerModal}
onCreated={async () => {
setRoutingPeerModal(false);
setCurrentRouter(undefined);
mutate(`/networks`);
mutate("/groups");
if (network) {
mutate(`/networks/${currentNetwork.id}/routers`);
mutate(`/networks/${network.id}`);
}
}}
onUpdated={async () => {
setRoutingPeerModal(false);
setCurrentRouter(undefined);
mutate(`/networks`);
mutate("/groups");
if (network) {
mutate(`/networks/${network.id}`);
mutate(`/networks/${currentNetwork.id}/routers`);
}
}}
setOpen={(state) => {
setCurrentRouter(undefined);
setRoutingPeerModal(state);
}}
/>
<ResourceGroupModal
network={currentNetwork}
resource={currentResource}
open={resourceGroupModal}
onOpenChange={(state) => {
setCurrentResource(undefined);
setResourceGroupModal(state);
}}
onUpdated={() => {
setResourceGroupModal(false);
setCurrentResource(undefined);
mutate("/groups");
mutate("/networks/resources");
if (network) {
onResourceUpdate?.();
mutate(`/networks/${network.id}/resources`);
mutate(`/networks/${network.id}`);
}
}}
/>
<NetworkResourceModal
network={currentNetwork}
resource={currentResource}
initialTab={resourceModalInitialTab}
onCreated={async (r) => {
setResourceModal(false);
setCurrentResource(undefined);
mutate("/networks");
mutate("/groups");
mutate("/networks/resources");
if (network) {
mutate(`/networks/${network.id}/resources`);
mutate(`/networks/${network.id}`);
} else {
currentNetwork?.routing_peers_count === 0 &&
(await askForRoutingPeer(currentNetwork));
}
}}
onUpdated={() => {
setResourceModal(false);
setCurrentResource(undefined);
mutate("/networks");
mutate("/groups");
mutate("/networks/resources");
if (network) {
onResourceUpdate?.();
mutate(`/networks/${network.id}/resources`);
mutate(`/networks/${network.id}`);
}
}}
open={resourceModal}
setOpen={(state) => {
setCurrentResource(undefined);
setResourceModalInitialTab(undefined);
setResourceModal(state);
}}
/>
</>
)}
</PoliciesProvider>
{currentNetwork && (
<>
<NetworkRoutingPeerModal
network={currentNetwork}
router={currentRouter}
open={routingPeerModal}
onCreated={async () => {
setRoutingPeerModal(false);
setCurrentRouter(undefined);
mutate(`/networks`);
mutate("/groups");
if (network) {
mutate(`/networks/${currentNetwork.id}/routers`);
mutate(`/networks/${network.id}`);
}
}}
onUpdated={async () => {
setRoutingPeerModal(false);
setCurrentRouter(undefined);
mutate(`/networks`);
mutate("/groups");
if (network) {
mutate(`/networks/${network.id}`);
mutate(`/networks/${currentNetwork.id}/routers`);
}
}}
setOpen={(state) => {
setCurrentRouter(undefined);
setRoutingPeerModal(state);
}}
/>
<ResourceGroupModal
network={currentNetwork}
resource={currentResource}
open={resourceGroupModal}
onOpenChange={(state) => {
setCurrentResource(undefined);
setResourceGroupModal(state);
}}
onUpdated={() => {
setResourceGroupModal(false);
setCurrentResource(undefined);
mutate("/groups");
if (network) {
onResourceUpdate?.();
mutate(`/networks/${network.id}/resources`);
mutate(`/networks/${network.id}`);
}
}}
/>
<NetworkResourceModal
network={currentNetwork}
resource={currentResource}
onCreated={async (r) => {
setResourceModal(false);
setCurrentResource(undefined);
mutate("/networks");
mutate("/groups");
if (network) {
mutate(`/networks/${network.id}/resources`);
mutate(`/networks/${network.id}`);
} else {
await askForAccessControlPolicy(r);
}
}}
onUpdated={() => {
setResourceModal(false);
setCurrentResource(undefined);
mutate("/networks");
mutate("/groups");
if (network) {
onResourceUpdate?.();
mutate(`/networks/${network.id}/resources`);
mutate(`/networks/${network.id}`);
}
}}
open={resourceModal}
setOpen={(state) => {
setCurrentResource(undefined);
setResourceModal(state);
}}
/>
</>
)}
</NetworksContext.Provider>
);
};
@@ -419,3 +500,37 @@ export const useNetworksContext = () => {
}
return context;
};
function AffectedResourceList({ resources }: { resources: NetworkResource[] }) {
const maxVisible = 6;
const visible = resources.slice(0, maxVisible);
const remaining = resources.length - maxVisible;
return (
<div
className={cn(
"rounded-md bg-nb-gray-930 border border-nb-gray-900 text-xs mt-4",
)}
>
{visible.map((r, i) => (
<div
key={r.id}
className={cn(
"flex items-center gap-2.5 px-3 py-2.5",
i > 0 && "border-t border-nb-gray-900",
)}
>
<ResourceIcon type={r.type || "host"} size={12} />
<span className="font-medium text-nb-gray-200">{r.name}</span>
<CopyToClipboardText className={"text-nb-gray-300"}>
{r.address}
</CopyToClipboardText>
</div>
))}
{remaining > 0 && (
<div className="border-t border-nb-gray-900 px-3 py-2 text-nb-gray-200">
+ {remaining} more
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,308 @@
import Button from "@components/Button";
import HelpText from "@components/HelpText";
import { Label } from "@components/Label";
import { Modal } from "@components/modal/Modal";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@components/DropdownMenu";
import { Edit2, MoreVertical, PlusIcon, Trash2 } from "lucide-react";
import React, { useMemo, useState } from "react";
import { NetworkResource } from "@/interfaces/Network";
import { Policy, PolicyRuleResource } from "@/interfaces/Policy";
import { cn } from "@utils/helpers";
import { usePolicies } from "@/contexts/PoliciesProvider";
import AccessControlSourcesCell from "@/modules/access-control/table/AccessControlSourcesCell";
import { useNetworksContext } from "@/modules/networks/NetworkProvider";
import { AccessControlModalContent } from "@/modules/access-control/AccessControlModal";
import CircleIcon from "@/assets/icons/CircleIcon";
import AccessControlProtocolCell from "@/modules/access-control/table/AccessControlProtocolCell";
import AccessControlPortsCell from "@/modules/access-control/table/AccessControlPortsCell";
import TruncatedText from "@components/ui/TruncatedText";
type Props = {
existingPolicies: Policy[];
newPolicies: Policy[];
onNewPoliciesChange: (policies: Policy[]) => void;
address: string;
resourceName?: string;
resourceId?: string;
hasResourceGroups?: boolean;
};
function getResourceType(address: string): "domain" | "host" | "subnet" {
const hasChars = !!address.match(/[a-z*]/i);
const isCIDR = !!address.match(/\//);
return hasChars ? "domain" : isCIDR ? "subnet" : "host";
}
export default function NetworkResourceAccessControl({
existingPolicies,
newPolicies,
onNewPoliciesChange,
address,
resourceName,
resourceId,
hasResourceGroups = false,
}: Readonly<Props>) {
const { network, confirmMultiResourceAction } = useNetworksContext();
const { openEditPolicyModal, deletePolicy } = usePolicies();
const [policyModalOpen, setPolicyModalOpen] = useState(false);
const [editingPolicyIndex, setEditingPolicyIndex] = useState<number | null>(
null,
);
const allPolicies = useMemo(
() => [...existingPolicies, ...newPolicies],
[existingPolicies, newPolicies],
);
const destinationResource: PolicyRuleResource = useMemo(() => {
return {
id: resourceId || resourceName || address,
type: getResourceType(address),
};
}, [address, resourceName, resourceId]);
const currentResource = useMemo<NetworkResource>(() => {
return {
id: resourceId || resourceName || address,
name: resourceName || address,
address,
type: getResourceType(address),
enabled: true,
};
}, [resourceId, resourceName, address]);
const openAddPolicy = () => {
setEditingPolicyIndex(null);
setPolicyModalOpen(true);
};
const openEditPolicy = async (policy: Policy) => {
if (policy.id) {
const confirm = await confirmMultiResourceAction(
policy,
"edit",
currentResource,
);
if (!confirm) return;
openEditPolicyModal(policy);
} else {
const idx = newPolicies.indexOf(policy);
if (idx === -1) return;
setEditingPolicyIndex(idx);
setPolicyModalOpen(true);
}
};
const savePolicy = (policy: Policy) => {
if (editingPolicyIndex !== null) {
onNewPoliciesChange(
newPolicies.map((p, i) => (i === editingPolicyIndex ? policy : p)),
);
} else {
onNewPoliciesChange([...newPolicies, policy]);
}
};
const handleDeletePolicy = async (policy: Policy) => {
const confirm = await confirmMultiResourceAction(
policy,
"delete",
currentResource,
);
if (!confirm) return;
if (policy.id) {
await deletePolicy(policy);
} else {
onNewPoliciesChange(newPolicies.filter((p) => p !== policy));
}
};
return (
<div className={"px-8 flex-col flex gap-6"}>
<div>
<Label>Access Control Policies</Label>
<HelpText>
Define which source groups are allowed to access this resource. You
can also restrict access to specific protocols and ports. Without
policies access to this resource will not be possible.
</HelpText>
{allPolicies.length > 0 && (
<div
className={
"mt-3 mb-3 overflow-hidden border border-nb-gray-900 bg-nb-gray-920/30 py-1 px-1 rounded-md"
}
>
<table className="w-full">
<thead>
<tr>
<th className="py-2 px-4 text-left text-[11px] uppercase tracking-wider text-nb-gray-400 font-medium">
Name
</th>
<th className="py-2 pl-5 pr-2 text-left text-[11px] uppercase tracking-wider text-nb-gray-400 font-medium">
Source Groups
</th>
<th className="py-2 px-4 text-left text-[11px] uppercase tracking-wider text-nb-gray-400 font-medium">
Protocol & Ports
</th>
<th className="py-2 pr-4 pl-2" />
</tr>
</thead>
<tbody>
{allPolicies.map((policy, index) => {
return (
<tr
key={policy.id || `new-${index}`}
onClick={() => openEditPolicy(policy)}
className="rounded-md hover:bg-nb-gray-900/30 cursor-pointer transition-all"
>
<td className="py-2.5 px-4 align-middle">
<div
className={
"text-[13px] mt-1 flex items-center gap-2 leading-none font-medium text-nb-gray-300 group-hover:text-nb-gray-200 whitespace-nowrap"
}
>
<div className={"self-start flex"}>
<CircleIcon
size={8}
active={policy.enabled}
className={cn("shrink-0 relative top-[5px]")}
/>
</div>
<div
className={
"flex flex-col items-start justify-start"
}
>
<TruncatedText
text={policy.name}
maxWidth={"130px"}
className={"leading-normal"}
/>
{policy.description && (
<div className={"text-nb-gray-400 text-xs"}>
<TruncatedText
text={policy.description}
maxWidth={"130px"}
/>
</div>
)}
</div>
</div>
</td>
<td className="py-2.5 pl-5 pr-2 align-middle">
<AccessControlSourcesCell
policy={policy}
hideEdit
disableRedirect
/>
</td>
<td className="py-2.5 pl-3 pr-2 align-middle">
<div className={"flex items-center gap-2"}>
<AccessControlProtocolCell policy={policy} />
<AccessControlPortsCell
policy={policy}
visiblePorts={1}
/>
</div>
</td>
<td className="py-2.5 pl-2 pr-3">
<div
className="flex items-center gap-6 justify-end"
onClick={(e) => e.stopPropagation()}
>
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button
variant="default-outline"
className="!px-3"
>
<MoreVertical size={16} className="shrink-0" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-auto min-w-[200px]"
align="end"
>
<DropdownMenuItem
onClick={() => openEditPolicy(policy)}
>
<div className="flex gap-3 items-center">
<Edit2 size={14} className="shrink-0" />
Edit Policy
</div>
</DropdownMenuItem>
<DropdownMenuItem
variant={"danger"}
onClick={() => handleDeletePolicy(policy)}
>
<div className="flex gap-3 items-center">
<Trash2 size={14} className="shrink-0" />
Delete Policy
</div>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
<Button
variant="dotted"
className={"w-full mt-1"}
size="sm"
onClick={openAddPolicy}
>
<PlusIcon size={14} />
Add Policy
</Button>
</div>
<Modal
open={policyModalOpen}
onOpenChange={(open) => {
setPolicyModalOpen(open);
if (!open) setEditingPolicyIndex(null);
}}
key={policyModalOpen ? 1 : 0}
>
<AccessControlModalContent
useSave={false}
policy={
editingPolicyIndex !== null
? newPolicies[editingPolicyIndex]
: undefined
}
initialDestinationResource={
editingPolicyIndex === null ? destinationResource : undefined
}
disableDestinationSelector={!hasResourceGroups}
additionalResources={[currentResource]}
initialName={`${resourceName || address} Access`}
initialDescription={
network?.description
? `${network.name}, ${network.description}`
: network?.name || ""
}
onSuccess={(policy) => {
savePolicy(policy);
setPolicyModalOpen(false);
setEditingPolicyIndex(null);
}}
/>
</Modal>
</div>
);
}

View File

@@ -1,9 +1,9 @@
"use client";
import Button from "@components/Button";
import FancyToggleSwitch from "@components/FancyToggleSwitch";
import { Callout } from "@components/Callout";
import HelpText from "@components/HelpText";
import InlineLink from "@components/InlineLink";
import InlineLink, { InlineButtonLink } from "@components/InlineLink";
import { Input } from "@components/Input";
import { Label } from "@components/Label";
import {
@@ -15,18 +15,31 @@ import {
import ModalHeader from "@components/modal/ModalHeader";
import { notify } from "@components/Notification";
import Paragraph from "@components/Paragraph";
import { HelpTooltip } from "@components/HelpTooltip";
import { PeerGroupSelector } from "@components/PeerGroupSelector";
import Separator from "@components/Separator";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@components/Tabs";
import { useApiCall } from "@utils/api";
import { useDialog } from "@/contexts/DialogProvider";
import { usePolicies } from "@/contexts/PoliciesProvider";
import { useNetworksContext } from "@/modules/networks/NetworkProvider";
import {
ExternalLinkIcon,
PlusCircle,
Power,
ShieldCheck,
WorkflowIcon,
} from "lucide-react";
import React, { useMemo, useState } from "react";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@components/Accordion";
import React, { useMemo, useRef, useState } from "react";
import { Group } from "@/interfaces/Group";
import { Network, NetworkResource } from "@/interfaces/Network";
import { Policy } from "@/interfaces/Policy";
import useGroupHelper from "@/modules/groups/useGroupHelper";
import NetworkResourceAccessControl from "@/modules/networks/resources/NetworkResourceAccessControl";
import { ResourceSingleAddressInput } from "@/modules/networks/resources/ResourceSingleAddressInput";
type Props = {
@@ -36,6 +49,7 @@ type Props = {
resource?: NetworkResource;
onCreated?: (r: NetworkResource) => void;
onUpdated?: (r: NetworkResource) => void;
initialTab?: string;
};
export default function NetworkResourceModal({
@@ -45,6 +59,7 @@ export default function NetworkResourceModal({
resource,
onUpdated,
onCreated,
initialTab,
}: Props) {
return (
<Modal open={open} onOpenChange={setOpen}>
@@ -54,6 +69,7 @@ export default function NetworkResourceModal({
resource={resource}
onCreated={onCreated}
onUpdated={onUpdated}
initialTab={initialTab}
/>
</Modal>
);
@@ -64,6 +80,7 @@ type ModalProps = {
onUpdated?: (r: NetworkResource) => void;
network: Network;
resource?: NetworkResource;
initialTab?: string;
};
export function ResourceModalContent({
@@ -71,6 +88,7 @@ export function ResourceModalContent({
onUpdated,
network,
resource,
initialTab,
}: ModalProps) {
const create = useApiCall<NetworkResource>(
`/networks/${network.id}/resources`,
@@ -88,50 +106,121 @@ export function ResourceModalContent({
const [enabled, setEnabled] = useState<boolean>(
resource ? resource.enabled : true,
);
const nameRef = useRef<HTMLInputElement>(null);
const [tab, setTab] = useState(initialTab || "resource");
const [addressError, setAddressError] = useState("");
const { confirm } = useDialog();
// Access control policies
const [policies, setPolicies] = useState<Policy[]>([]);
const { createPoliciesForResource } = usePolicies();
const {
assignedPolicies,
resourceExists,
policies: allPolicies,
} = useNetworksContext();
const { policies: existingPolicies } = useMemo(
() => assignedPolicies(resource, groups),
[assignedPolicies, resource, groups],
);
const allResourcePolicies = useMemo(() => {
return [...(existingPolicies || []), ...policies];
}, [existingPolicies, policies]);
const groupPolicyCount = useMemo(() => {
if (!groups.length || !allPolicies) return 0;
const groupIds = new Set(groups.map((g) => g.id));
return allPolicies.filter((policy) => {
const rule = policy.rules?.[0];
if (!rule || rule.destinationResource) return false;
const destinations = rule.destinations as (Group | string)[] | undefined;
return destinations?.some((d) => {
const id = typeof d === "string" ? d : d.id;
return !!id && groupIds.has(id);
});
}).length;
}, [groups, allPolicies]);
const isAddressValid = address.length > 0 && addressError === "";
const nameError = useMemo(() => {
if (name === "") return "";
if (resourceExists(name, resource?.id))
return "A resource with this name already exists. Please use another name.";
return "";
}, [name, resourceExists, resource?.id]);
const confirmMissingPolicies = async () => {
if (allResourcePolicies.length > 0) return true;
return confirm({
title: "No Access Control Policies Configured",
description:
"Without access control policies, this resource will not be accessible by any peers. You can also create policies later. Are you sure you want to continue?",
type: "warning",
confirmText: resource ? "Save Changes" : "Add Resource",
cancelText: "Cancel",
maxWidthClass: "max-w-lg",
});
};
const createResource = async () => {
if (!(await confirmMissingPolicies())) return;
const savedGroups = await saveGroups();
const promise = create({
name,
description,
address,
groups: savedGroups ? savedGroups.map((g) => g.id) : undefined,
enabled,
}).then(async (r) => {
await createPoliciesForResource(policies, r, savedGroups);
onCreated?.(r);
});
notify({
title: "Resource Created",
description: `The resource "${name}" has been created successfully.`,
loadingMessage: "Creating resource...",
promise: create({
name,
description,
address,
groups: savedGroups ? savedGroups.map((g) => g.id) : undefined,
enabled,
}).then((r) => {
onCreated?.(r);
}),
promise,
});
return promise;
};
const updateResource = async () => {
if (!(await confirmMissingPolicies())) return;
const savedGroups = await saveGroups();
const promise = update({
name,
description,
address,
groups: savedGroups ? savedGroups.map((g) => g.id) : undefined,
enabled,
}).then(async (r) => {
await createPoliciesForResource(policies, r, savedGroups);
onUpdated?.(r);
});
notify({
title: "Resource Updated",
description: `The resource "${name}" has been updated successfully.`,
description: `Resource "${name}" has been updated successfully.`,
loadingMessage: "Updating resource...",
promise: update({
name,
description,
address,
groups: savedGroups ? savedGroups.map((g) => g.id) : undefined,
enabled,
}).then((r) => {
onUpdated?.(r);
}),
promise,
});
};
// TODO: Address validation is missing for proper handling of submit button
const canCreate = useMemo(() => {
return name.length > 0 && address.length > 0;
}, [name, address, groups]);
return name.length > 0 && isAddressValid && nameError === "";
}, [name, isAddressValid, nameError]);
return (
<ModalContent maxWidthClass={"max-w-xl"}>
<ModalContent
maxWidthClass={
tab === "access-control" ? "max-w-[790px]" : "max-w-[680px]"
}
>
<ModalHeader
icon={<WorkflowIcon size={20} />}
title={resource ? "Edit Resource" : "Add Resource"}
@@ -143,55 +232,164 @@ export function ResourceModalContent({
color={"yellow"}
/>
<Separator />
<Tabs defaultValue={tab} onValueChange={(v) => setTab(v)} value={tab}>
<TabsList justify={"start"} className={"px-8"}>
<TabsTrigger value={"resource"}>
<WorkflowIcon size={16} />
Resource
</TabsTrigger>
<TabsTrigger
value={"access-control"}
disabled={!resource && !canCreate}
>
<ShieldCheck size={16} />
Access Control
</TabsTrigger>
</TabsList>
<div className={"px-8 flex-col flex gap-6 py-6"}>
<div>
<Label>Name</Label>
<HelpText>Provide a name for your resource</HelpText>
<Input
tabIndex={0}
placeholder={"e.g., Postgres Database"}
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
<div>
<Label>Description (optional)</Label>
<HelpText>
Write a short description to add more context to this resource.
</HelpText>
<Input
placeholder={"e.g., Production, Development"}
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
</div>
<TabsContent value={"resource"} className={"pb-4"}>
<div className={"px-8 flex-col flex gap-6"}>
<div>
<Label>Name</Label>
<HelpText>
Set an easily identifiable name for your resource
</HelpText>
<Input
ref={nameRef}
autoFocus={true}
tabIndex={0}
placeholder={"e.g., Postgres Database"}
value={name}
error={nameError}
onChange={(e) => setName(e.target.value)}
/>
</div>
<ResourceSingleAddressInput
value={address}
onChange={setAddress}
onError={setAddressError}
description={
<>
Enter a single{" "}
<HelpTooltip
content={
"A single host address, e.g., 10.0.0.1 or 192.168.1.5. Use this to give access to a specific machine or service."
}
>
IP Address
</HelpTooltip>
,{" "}
<HelpTooltip
content={
"To give access to an entire subnet, use a CIDR block. For example, 10.0.0.0/24 or 192.168.1.0/24."
}
>
CIDR Block
</HelpTooltip>{" "}
or{" "}
<HelpTooltip
content={
"A DNS domain name, e.g., service.internal, example.com or *.example.com to match all subdomains."
}
>
Domain Name
</HelpTooltip>
</>
}
/>
<ResourceSingleAddressInput value={address} onChange={setAddress} />
<Accordion
type={"multiple"}
className={"flex flex-col gap-2 -mt-2"}
>
<AccordionItem value={"resource-groups"}>
<AccordionTrigger
className={
"text-[0.8rem] tracking-wider text-nb-gray-200 py-4 my-0 leading-none gap-2 flex items-center"
}
>
<span className={"relative top-[1px]"}>
Optional Settings
</span>
</AccordionTrigger>
<AccordionContent className={""}>
<div className={"flex flex-col gap-6 pb-4 pt-2"}>
<div>
<Label>Description</Label>
<HelpText>
Write a short description to add more context to this
resource.
</HelpText>
<Input
placeholder={"e.g., Production, Development"}
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
</div>
<div>
<Label>Resource Groups</Label>
<HelpText className={"mt-1"}>
Add this resource to a group (e.g., Databases, Web
Servers) and reference the group <br /> in access
policies to simplify management.
</HelpText>
<PeerGroupSelector
side={"top"}
onChange={setGroups}
values={groups}
showPeerCounter={false}
placeholder={"Add or select resource group(s)..."}
policies={allPolicies}
/>
{groupPolicyCount > 0 && (
<Callout variant={"info"} className={"mt-3"}>
Your selected resource groups are used in{" "}
<span className="text-white font-medium">
{groupPolicyCount} Access Control{" "}
{groupPolicyCount === 1 ? "Policy" : "Policies"}
</span>
. This resource will inherit access from{" "}
{groupPolicyCount === 1
? "this policy"
: "these policies"}
.
{isAddressValid || resource ? (
<>
{" "}
Please review them in the{" "}
<InlineButtonLink
onClick={() => setTab("access-control")}
variant={"dashed"}
>
Access Control
</InlineButtonLink>{" "}
tab.
</>
) : (
" Please review them in the Access Control tab."
)}
</Callout>
)}
</div>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
</TabsContent>
<div>
<Label>Destination Groups (optional)</Label>
<HelpText>
Add this resource to groups and use them as destinations when
creating policies
</HelpText>
<PeerGroupSelector onChange={setGroups} values={groups} />
</div>
<div className={"mt-2 mb-2"}>
<FancyToggleSwitch
value={enabled}
onChange={setEnabled}
label={
<>
<Power size={15} />
Enable Resource
</>
}
helpText={"Use this switch to enable or disable the resource."}
<TabsContent value={"access-control"} className={"pb-8"}>
<NetworkResourceAccessControl
existingPolicies={existingPolicies || []}
newPolicies={policies}
onNewPoliciesChange={setPolicies}
address={address}
resourceName={name}
resourceId={resource?.id}
hasResourceGroups={groups.length > 0}
/>
</div>
</div>
</TabsContent>
</Tabs>
<ModalFooter className={"items-center"}>
<div className={"w-full"}>
@@ -207,25 +405,58 @@ export function ResourceModalContent({
</Paragraph>
</div>
<div className={"flex gap-3 w-full justify-end"}>
<ModalClose asChild={true}>
<Button variant={"secondary"}>Cancel</Button>
</ModalClose>
{!resource ? (
<>
{tab === "resource" && (
<>
<ModalClose asChild={true}>
<Button variant={"secondary"}>Cancel</Button>
</ModalClose>
<Button
variant={"primary"}
onClick={() => setTab("access-control")}
disabled={!canCreate}
>
Continue
</Button>
</>
)}
<Button
variant={"primary"}
data-cy={"submit-route"}
onClick={resource ? updateResource : createResource}
disabled={!canCreate}
>
{resource ? (
<>Save Changes</>
) : (
<>
<PlusCircle size={16} />
Add Resource
</>
)}
</Button>
{tab === "access-control" && (
<>
<Button
variant={"secondary"}
onClick={() => setTab("resource")}
>
Back
</Button>
<Button
variant={"primary"}
data-cy={"submit-route"}
onClick={createResource}
disabled={!canCreate}
>
<PlusCircle size={16} />
Add Resource
</Button>
</>
)}
</>
) : (
<>
<ModalClose asChild={true}>
<Button variant={"secondary"}>Cancel</Button>
</ModalClose>
<Button
variant={"primary"}
data-cy={"submit-route"}
onClick={updateResource}
disabled={!canCreate}
>
Save Changes
</Button>
</>
)}
</div>
</ModalFooter>
</ModalContent>

View File

@@ -1,6 +1,8 @@
import Badge from "@components/Badge";
import MultipleGroups, {
TransparentEditIconButton,
} from "@components/ui/MultipleGroups";
import { IconCirclePlus } from "@tabler/icons-react";
import * as React from "react";
import { usePermissions } from "@/contexts/PermissionsProvider";
import { Group } from "@/interfaces/Group";
@@ -15,6 +17,9 @@ export const ResourceGroupCell = ({ resource }: Props) => {
const { network, openResourceGroupModal } = useNetworksContext();
const groups = resource?.groups as Group[] | undefined;
const hasGroups = groups && groups.length > 0;
return (
<button
className={"flex cursor-pointer items-center justify-center gap-1 group"}
@@ -23,12 +28,25 @@ export const ResourceGroupCell = ({ resource }: Props) => {
openResourceGroupModal(network, resource);
}}
>
<MultipleGroups
groups={resource?.groups as Group[]}
showResources={true}
redirectGroupTab={"resources"}
/>
{permission.networks.update && <TransparentEditIconButton />}
{hasGroups ? (
<>
<MultipleGroups
groups={groups}
showResources={true}
redirectGroupTab={"resources"}
/>
{permission.networks.update && <TransparentEditIconButton />}
</>
) : (
<Badge
variant={"gray"}
useHover={true}
disabled={!permission.networks.update}
>
<IconCirclePlus size={14} />
Add Groups
</Badge>
)}
</button>
);
};

View File

@@ -8,12 +8,13 @@ import {
import ModalHeader from "@components/modal/ModalHeader";
import { notify } from "@components/Notification";
import { PeerGroupSelector } from "@components/PeerGroupSelector";
import Separator from "@components/Separator";
import { useApiCall } from "@utils/api";
import { FolderGit2 } from "lucide-react";
import * as React from "react";
import { Network, NetworkResource } from "@/interfaces/Network";
import useGroupHelper from "@/modules/groups/useGroupHelper";
import { useNetworksContext } from "@/modules/networks/NetworkProvider";
import { FolderGit2 } from "lucide-react";
import Separator from "@components/Separator";
type ResourceGroupModalProps = {
resource?: NetworkResource;
@@ -58,6 +59,7 @@ const ResourceGroupModalContent = ({
`/networks/${network?.id}/resources/${resource?.id}`,
).put;
const { policies } = useNetworksContext();
const [groups, setGroups, { save: saveGroups }] = useGroupHelper({
initial: resource?.groups || [],
});
@@ -78,21 +80,26 @@ const ResourceGroupModalContent = ({
};
return (
<ModalContent maxWidthClass={"max-w-xl"}>
<ModalContent maxWidthClass={"max-w-2xl"}>
<ModalHeader
icon={<FolderGit2 size={18} />}
title={"Assigned Groups"}
title={"Resource Groups"}
description={
"Add this resource to groups and use them as destinations when creating policies"
"Add this resource to a group (e.g., Databases, Web Servers) and reference the group in access policies to simplify management."
}
color={"blue"}
icon={<FolderGit2 size={18} />}
/>
<Separator />
<div className={"px-8 py-6 flex flex-col gap-8"}>
<div className={"px-8 py-6 pt-6 flex flex-col gap-8"}>
<div>
<PeerGroupSelector onChange={setGroups} values={groups} />
<PeerGroupSelector
onChange={setGroups}
values={groups}
showPeerCounter={false}
placeholder={"Add or select resource group(s)..."}
policies={policies}
/>
</div>
</div>

View File

@@ -46,7 +46,7 @@ export default function ResourceNameCell({ resource }: Readonly<Props>) {
/>
<DescriptionWithTooltip
maxChars={25}
className={cn("font-normal mt-0.5 ")}
className={cn("font-normal")}
text={resource.description}
/>
</div>

View File

@@ -1,52 +1,37 @@
import Badge from "@components/Badge";
import Button from "@components/Button";
import FullTooltip from "@components/FullTooltip";
import useFetchApi from "@utils/api";
import { orderBy } from "lodash";
import { PlusCircle, ShieldIcon, SquarePenIcon } from "lucide-react";
import { Settings, ShieldIcon, ShieldOff, SquarePenIcon } from "lucide-react";
import * as React from "react";
import { useMemo, useState } from "react";
import { useState } from "react";
import Skeleton from "react-loading-skeleton";
import CircleIcon from "@/assets/icons/CircleIcon";
import { usePermissions } from "@/contexts/PermissionsProvider";
import { Group } from "@/interfaces/Group";
import { NetworkResource } from "@/interfaces/Network";
import { Policy } from "@/interfaces/Policy";
import { useNetworksContext } from "@/modules/networks/NetworkProvider";
import { cn } from "@utils/helpers";
type Props = {
resource?: NetworkResource;
};
export const ResourcePolicyCell = ({ resource }: Props) => {
const { permission } = usePermissions();
const { openPolicyModal, network, openEditPolicyModal } =
useNetworksContext();
const { data: policies, isLoading } = useFetchApi<Policy[]>("/policies");
const {
openResourceModal,
network,
openEditPolicyModal,
assignedPolicies,
confirmMultiResourceAction,
} = useNetworksContext();
const {
policies: resourcePolicies,
enabledPolicies,
isLoading,
policyCount,
} = assignedPolicies(resource);
const [tooltipOpen, setTooltipOpen] = useState(false);
const assignedPolicies = useMemo(() => {
const resourceGroups = resource?.groups as Group[];
return orderBy(
policies?.filter((policy) => {
const destinationResource = policy.rules
?.map((rule) => rule?.destinationResource?.id === resource?.id)
.some((id) => id);
if (destinationResource) return true;
const destinationPolicyGroups = policy.rules
?.map((rule) => rule?.destinations)
.flat() as Group[];
const policyGroups = [...destinationPolicyGroups];
return resourceGroups?.some((resourceGroup) =>
policyGroups.some(
(policyGroup) => policyGroup?.id === resourceGroup.id,
),
);
}),
"enabled",
"desc",
);
}, [policies, resource]);
if (isLoading) {
return (
<div className={"flex gap-3"}>
@@ -55,13 +40,16 @@ export const ResourcePolicyCell = ({ resource }: Props) => {
);
}
const enabledPolicies = assignedPolicies?.filter((policy) => policy?.enabled);
const policyCount = assignedPolicies?.length || 0;
return (
network && (
<div className={"flex gap-3"}>
{policyCount === 0 && (
<Badge variant={"gray"}>
<ShieldOff size={12} className="text-red-500" />
<span className={"font-medium text-xs"}>None</span>
</Badge>
)}
{policyCount > 0 && (
<FullTooltip
contentClassName={"p-0"}
@@ -72,17 +60,23 @@ export const ResourcePolicyCell = ({ resource }: Props) => {
className={"border-nb-gray-800"}
content={
<div className={"text-xs flex flex-col p-1"}>
{assignedPolicies?.map((policy: Policy) => {
{resourcePolicies?.map((policy: Policy) => {
const rule = policy?.rules?.[0];
if (!rule) return;
if (!rule) return null;
return (
<button
key={policy.id}
className={
"m-0 pl-3 py-2.5 leading-none flex justify-between group hover:bg-nb-gray-900 rounded-md"
}
onClick={() => {
onClick={async () => {
setTooltipOpen(false);
const confirm = await confirmMultiResourceAction(
policy,
"edit",
resource,
);
if (!confirm) return;
openEditPolicyModal(policy);
}}
>
@@ -118,18 +112,29 @@ export const ResourcePolicyCell = ({ resource }: Props) => {
>
<Badge
variant={"gray"}
useHover={false}
className={"select-none hover:bg-nb-gray-910"}
useHover={true}
className={"select-none hover:bg-nb-gray-910 cursor-pointer"}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
if (!tooltipOpen) setTooltipOpen(true);
if (!permission.networks.update) return;
if (tooltipOpen) setTooltipOpen(false);
openResourceModal(network, resource, "access-control");
}}
>
<ShieldIcon size={14} className={"text-green-500"} />
<ShieldIcon
size={14}
className={cn(
enabledPolicies?.length > 0
? "text-green-500"
: "text-nb-gray-400",
)}
/>
<div>
<span className={"font-medium text-xs"}>
{enabledPolicies?.length}
{enabledPolicies?.length > 0
? enabledPolicies?.length
: `${policyCount} Disabled`}
</span>
</div>
</Badge>
@@ -139,11 +144,12 @@ export const ResourcePolicyCell = ({ resource }: Props) => {
<Button
size={"xs"}
variant={"secondary"}
className={"!px-3"}
disabled={!permission.networks.update}
onClick={() => openPolicyModal(network, resource)}
onClick={() => openResourceModal(network, resource, "access-control")}
>
<PlusCircle size={12} />
Add Policy
<Settings size={12} />
Configure
</Button>
</div>
)

View File

@@ -13,8 +13,9 @@ type Props = {
label?: string;
className?: string;
onError?: (error: string) => void;
description?: string;
description?: React.ReactNode;
placeholder?: string;
autoFocus?: boolean;
};
export const ResourceSingleAddressInput = ({
value,
@@ -24,6 +25,7 @@ export const ResourceSingleAddressInput = ({
onError,
description = "Enter a single IP address, CIDR block or domain name",
placeholder = "Address (IP, CIDR or Domain)",
autoFocus,
}: Props) => {
const hasChars = useMemo(() => {
return !!value.match(/[a-z*]/i);
@@ -71,6 +73,7 @@ export const ResourceSingleAddressInput = ({
<Label>{label}</Label>
<HelpText>{description}</HelpText>
<Input
autoFocus={autoFocus}
customPrefix={PrefixIcon}
error={error}
placeholder={placeholder}

View File

@@ -77,7 +77,7 @@ const NetworkResourceColumns: ColumnDef<NetworkResource>[] = [
return groups.map((group) => group.name).join(", ");
},
header: ({ column }) => {
return <DataTableHeader column={column}>Groups</DataTableHeader>;
return <DataTableHeader column={column}>Resource Groups</DataTableHeader>;
},
cell: ({ row }) => {
return <ResourceGroupCell resource={row.original} />;
@@ -121,7 +121,12 @@ export default function ResourcesTable({
const params = useSearchParams();
const resourceId = params.get("resource") ?? undefined;
const [sorting, setSorting] = useState<SortingState>([]);
const [sorting, setSorting] = useState<SortingState>([
{
id: "name",
desc: false,
},
]);
const { openResourceModal, network } = useNetworksContext();
const router = useRouter();

View File

@@ -16,6 +16,7 @@ import NetworkRoutesIcon from "@/assets/icons/NetworkRoutesIcon";
import { usePermissions } from "@/contexts/PermissionsProvider";
import { useLocalStorage } from "@/hooks/useLocalStorage";
import { Network } from "@/interfaces/Network";
import { NetworkAccessControlProvider } from "@/modules/networks/NetworkAccessControlProvider";
import {
NetworkProvider,
useNetworksContext,
@@ -99,82 +100,84 @@ export default function NetworksTable({
return (
<>
<GlobalSearchModal open={searchModal} setOpen={setSearchModal} />
<NetworkProvider>
<DataTable
headingTarget={headingTarget}
isLoading={isLoading}
text={"Networks"}
sorting={sorting}
setSorting={setSorting}
columns={NetworkTableColumns}
data={data}
searchPlaceholder={"Search by network name or description..."}
columnVisibility={{
description: false,
}}
onSearchClick={() => setSearchModal(true)}
getStartedCard={
<GetStartedTest
icon={
<SquareIcon
icon={
<NetworkRoutesIcon
className={"fill-nb-gray-200"}
size={20}
/>
}
color={"gray"}
size={"large"}
/>
}
title={"Create New Network"}
description={
"It looks like you don't have any networks. Access internal resources in your LANs and VPC by adding a network."
}
button={
<div className={"gap-x-4 flex items-center justify-center"}>
<NetworkAccessControlProvider>
<NetworkProvider>
<DataTable
headingTarget={headingTarget}
isLoading={isLoading}
text={"Networks"}
sorting={sorting}
setSorting={setSorting}
columns={NetworkTableColumns}
data={data}
searchPlaceholder={"Search by network name or description..."}
columnVisibility={{
description: false,
}}
onSearchClick={() => setSearchModal(true)}
getStartedCard={
<GetStartedTest
icon={
<SquareIcon
icon={
<NetworkRoutesIcon
className={"fill-nb-gray-200"}
size={20}
/>
}
color={"gray"}
size={"large"}
/>
}
title={"Create New Network"}
description={
"It looks like you don't have any networks. Access internal resources in your LANs and VPC by adding a network."
}
button={
<div className={"gap-x-4 flex items-center justify-center"}>
<AddNetworkButton />
</div>
}
learnMore={
<>
Learn more about
<InlineLink
href={"https://docs.netbird.io/how-to/networks"}
target={"_blank"}
>
Networks
<ExternalLinkIcon size={12} />
</InlineLink>
</>
}
/>
}
rightSide={() =>
data &&
data.length > 0 && (
<div className={cn("gap-x-4 ml-auto flex")}>
<AddNetworkButton />
</div>
}
learnMore={
<>
Learn more about
<InlineLink
href={"https://docs.netbird.io/how-to/networks"}
target={"_blank"}
>
Networks
<ExternalLinkIcon size={12} />
</InlineLink>
</>
}
/>
}
rightSide={() =>
data &&
data.length > 0 && (
<div className={cn("gap-x-4 ml-auto flex")}>
<AddNetworkButton />
</div>
)
}
>
{(table) => (
<>
<DataTableRowsPerPage
table={table}
disabled={data?.length == 0}
/>
<DataTableRefreshButton
isDisabled={data?.length == 0}
onClick={() => {
mutate("/networks").then();
}}
/>
</>
)}
</DataTable>
</NetworkProvider>
)
}
>
{(table) => (
<>
<DataTableRowsPerPage
table={table}
disabled={data?.length == 0}
/>
<DataTableRefreshButton
isDisabled={data?.length == 0}
onClick={() => {
mutate("/networks").then();
}}
/>
</>
)}
</DataTable>
</NetworkProvider>
</NetworkAccessControlProvider>
</>
);
}

View File

@@ -0,0 +1,182 @@
import Button from "@components/Button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@components/DropdownMenu";
import HelpText from "@components/HelpText";
import { InlineButtonLink } from "@components/InlineLink";
import { Label } from "@components/Label";
import { ToggleSwitch } from "@components/ToggleSwitch";
import {
AlertTriangle,
ArrowRight,
ArrowUpRight,
Edit,
MinusCircleIcon,
MoreVertical,
PlusIcon,
} from "lucide-react";
import { Callout } from "@components/Callout";
import React from "react";
import { Network } from "@/interfaces/Network";
import { ReverseProxyTarget } from "@/interfaces/ReverseProxy";
import { useReverseProxies } from "@/contexts/ReverseProxiesProvider";
import { cn } from "@utils/helpers";
type Props = {
targets: ReverseProxyTarget[];
onEditTarget: (index: number) => void;
onRemoveTarget: (index: number) => void;
onToggleTargetEnabled: (index: number) => void;
onAddTarget: () => void;
initialNetwork?: Network;
onNavigateToResources?: () => void;
};
export default function ReverseProxyHTTPTargets({
targets,
onEditTarget,
onRemoveTarget,
onToggleTargetEnabled,
onAddTarget,
initialNetwork,
onNavigateToResources,
}: Readonly<Props>) {
return (
<div>
<Label>HTTP/S Targets</Label>
<HelpText>
Add one or more devices running your service or resources to make it
publicly accessible.
</HelpText>
{targets.length > 0 && (
<div
className={
"mt-3 mb-3 overflow-hidden border border-nb-gray-900 bg-nb-gray-920/30 py-1 px-1 rounded-md "
}
>
<table className="w-full">
<tbody>
{targets.map((target, index) => (
<tr
key={index}
onClick={() => onEditTarget(index)}
className="rounded-md hover:bg-nb-gray-900/30 cursor-pointer transition-all"
>
<td className="py-2.5 pl-5 pr-2 align-middle">
<span className="text-[11px] leading-none font-mono px-2.5 py-2 rounded bg-nb-gray-900 text-nb-gray-300 inline-flex items-center">
{target.path
? target.path.startsWith("/")
? target.path
: `/${target.path}`
: "/"}
</span>
</td>
<td className="py-2.5 px-4 align-middle">
<ArrowRight size={12} className="text-nb-gray-400" />
</td>
<td className="py-2.5 pr-2 align-middle">
<TargetDestination target={target} />
</td>
<td className="py-2.5 pl-2 pr-4">
<div
className="flex items-center gap-2 justify-end"
onClick={(e) => e.stopPropagation()}
>
<ToggleSwitch
size="small"
checked={target.enabled}
onCheckedChange={() => onToggleTargetEnabled(index)}
/>
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button
variant="default-outline"
className="!px-3"
>
<MoreVertical size={16} className="shrink-0" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-auto min-w-[200px]"
align="end"
>
<DropdownMenuItem
onClick={() => onEditTarget(index)}
>
<div className="flex gap-3 items-center">
<Edit size={14} className="shrink-0" />
Edit Target
</div>
</DropdownMenuItem>
<DropdownMenuItem
variant={"danger"}
onClick={() => onRemoveTarget(index)}
>
<div className="flex gap-3 items-center">
<MinusCircleIcon
size={14}
className="shrink-0"
/>
Remove Target
</div>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<Button
variant="dotted"
className={cn("w-full mt-1", targets?.length > 0 && "mt-1")}
size="sm"
onClick={onAddTarget}
disabled={!!(initialNetwork && !initialNetwork.resources?.length)}
>
<PlusIcon size={14} />
Add Target
</Button>
{initialNetwork && !initialNetwork.resources?.length && (
<Callout
variant="warning"
className="mt-3"
icon={
<AlertTriangle
size={14}
className="shrink-0 relative top-[3px]"
/>
}
>
There are currently no resources in your network{" "}
<span className={"text-netbird-100 font-medium"}>
{initialNetwork?.name}
</span>
. Add resources to your network before exposing it as a service.{" "}
<InlineButtonLink variant={"default"} onClick={onNavigateToResources}>
Go to Resources
<ArrowUpRight size={14} />
</InlineButtonLink>
</Callout>
)}
</div>
);
}
function TargetDestination({ target }: { target: ReverseProxyTarget }) {
const { resolveDestination } = useReverseProxies();
return (
<span className="text-[0.76rem] text-nb-gray-200 whitespace-nowrap font-mono">
{resolveDestination(target)}
</span>
);
}

View File

@@ -0,0 +1,133 @@
import { Input } from "@components/Input";
import { Label } from "@components/Label";
import { ArrowRight } from "lucide-react";
import React, { useRef } from "react";
import { Network, NetworkResource } from "@/interfaces/Network";
import { Peer } from "@/interfaces/Peer";
import ReverseProxyAddressInput, {
CidrHelpText,
} from "@/modules/reverse-proxy/targets/ReverseProxyAddressInput";
import ReverseProxyTargetSelector, {
type Target,
} from "@/modules/reverse-proxy/targets/ReverseProxyTargetSelector";
import { HelpTooltip } from "@components/HelpTooltip";
type Props = {
l4Target: Target | undefined;
setL4Target: React.Dispatch<React.SetStateAction<Target | undefined>>;
isListenPortSupported: boolean;
listenPort: number;
setListenPort: (port: number) => void;
port: number;
setPort: (port: number) => void;
initialResource?: NetworkResource;
initialPeer?: Peer;
initialNetwork?: Network;
};
export default function ReverseProxyLayer4Content({
l4Target,
setL4Target,
isListenPortSupported,
listenPort,
setListenPort,
port,
setPort,
initialResource,
initialPeer,
initialNetwork,
}: Readonly<Props>) {
const listenPortRef = useRef<HTMLInputElement>(null);
const portRef = useRef<HTMLInputElement>(null);
return (
<div className={"-mt-1 flex flex-col gap-8"}>
{!initialResource && !initialPeer && (
<ReverseProxyTargetSelector
value={l4Target}
initialNetwork={initialNetwork}
onChange={(selection) => {
setL4Target(selection);
if (selection) {
setTimeout(() => {
if (isListenPortSupported) {
listenPortRef.current?.focus();
} else {
portRef.current?.focus();
}
}, 0);
}
}}
/>
)}
<div className={"flex gap-4 items-center"}>
<div className={"w-full max-w-[180px]"}>
<Label>
Listen Port
<HelpTooltip
className={"max-w-sm"}
content={
isListenPortSupported
? "Enter the public listen port this service will be reachable on."
: "The listen port will be automatically assigned after the service is created."
}
/>
</Label>
<div className={"mt-2"}>
<Input
ref={listenPortRef}
type="number"
min={1}
max={65535}
placeholder={!isListenPortSupported ? "Auto" : "443"}
value={!isListenPortSupported ? "" : listenPort || ""}
onChange={(e) => setListenPort(parseInt(e.target.value) || 0)}
disabled={!isListenPortSupported || !l4Target}
aria-label="Public listen port"
/>
</div>
</div>
<ArrowRight size={16} className="text-nb-gray-400 shrink-0 mt-6" />
<div className={"w-full flex"}>
<div className={"w-full"}>
<Label>
Host / IP
<CidrHelpText target={l4Target} />
</Label>
<div className="flex w-full mt-2 relative">
<ReverseProxyAddressInput
value={l4Target}
onChange={setL4Target}
/>
</div>
</div>
<div>
<Label>
Port
<HelpTooltip
content={
"Enter the port where your service (e.g., webserver, app, API) is currently listening."
}
/>
</Label>
<div className={"mt-2 min-w-[120px]"}>
<Input
ref={portRef}
type="number"
min={1}
max={65535}
placeholder="443"
value={port || ""}
onChange={(e) => setPort(parseInt(e.target.value) || 0)}
disabled={!l4Target}
aria-label="Destination port"
className={"rounded-l-none"}
/>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,15 +1,9 @@
"use client";
import Button from "@components/Button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@components/DropdownMenu";
import FancyToggleSwitch from "@components/FancyToggleSwitch";
import HelpText from "@components/HelpText";
import InlineLink, { InlineButtonLink } from "@components/InlineLink";
import InlineLink from "@components/InlineLink";
import { Input } from "@components/Input";
import { Label } from "@components/Label";
import SettingCard from "@components/SettingCard";
@@ -22,27 +16,19 @@ import {
import Paragraph from "@components/Paragraph";
import ModalHeader from "@components/modal/ModalHeader";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@components/Tabs";
import { ToggleSwitch } from "@components/ToggleSwitch";
import {
AlertTriangle,
ArrowRight,
ArrowUpRight,
Binary,
Edit,
ClockFadingIcon,
ExternalLinkIcon,
GlobeIcon,
LockKeyhole,
MinusCircleIcon,
MoreVertical,
MapPinned,
PlusCircle,
PlusIcon,
RectangleEllipsis,
Server,
Settings,
Text,
Users,
} from "lucide-react";
import { Callout } from "@components/Callout";
import { useRouter } from "next/navigation";
import React, { useMemo, useState } from "react";
import ReverseProxyIcon from "@/assets/icons/ReverseProxyIcon";
@@ -51,23 +37,38 @@ import { usePermissions } from "@/contexts/PermissionsProvider";
import { Network, NetworkResource } from "@/interfaces/Network";
import { Peer } from "@/interfaces/Peer";
import {
isL4Mode as isL4ServiceMode,
REVERSE_PROXY_AUTHENTICATION_DOCS_LINK,
REVERSE_PROXY_SERVICES_DOCS_LINK,
REVERSE_PROXY_SETTINGS_DOCS_LINK,
ReverseProxy,
ReverseProxyAuth,
ReverseProxyDomain,
ReverseProxyDomainType,
ReverseProxyTarget,
ReverseProxyTargetProtocol,
ReverseProxyTargetType,
ServiceMode,
} from "@/interfaces/ReverseProxy";
import { CustomDomainSelector } from "./domain/CustomDomainSelector";
import { cn } from "@utils/helpers";
import { useReverseProxies } from "@/contexts/ReverseProxiesProvider";
import ReverseProxyDomainInput from "./domain/ReverseProxyDomainInput";
import { useReverseProxyDomain } from "./domain/useReverseProxyDomain";
import AuthPasswordModal from "@/modules/reverse-proxy/auth/AuthPasswordModal";
import AuthPinModal from "@/modules/reverse-proxy/auth/AuthPinModal";
import AuthSSOModal from "@/modules/reverse-proxy/auth/AuthSSOModal";
import ReverseProxyHTTPTargets from "@/modules/reverse-proxy/ReverseProxyHTTPTargets";
import ReverseProxyLayer4Content from "@/modules/reverse-proxy/ReverseProxyLayer4Content";
import ReverseProxyTargetModal from "@/modules/reverse-proxy/targets/ReverseProxyTargetModal";
import { type Target } from "@/modules/reverse-proxy/targets/ReverseProxyTargetSelector";
import { useReverseProxyAddress } from "@/modules/reverse-proxy/targets/ReverseProxyAddressInput";
import {
validateTimeout,
validateSessionIdleTimeout,
} from "@/modules/reverse-proxy/targets/useReverseProxyTargetOptions";
import useGroupHelper from "@/modules/groups/useGroupHelper";
import { useReverseProxies } from "@/contexts/ReverseProxiesProvider";
import {
ReverseProxyServiceModeSelector,
SERVICE_MODES,
} from "@/modules/reverse-proxy/ReverseProxyServiceModeSelector";
type Props = {
open: boolean;
@@ -84,41 +85,6 @@ type Props = {
onSuccess?: () => void;
};
// Helper to parse domain into subdomain and base domain
function parseDomain(fullDomain: string): {
subdomain: string;
baseDomain: string;
isCustom: boolean;
} {
const knownDomains = ["netbird.cloud", "netbird.io", "netbird.app"];
for (const known of knownDomains) {
if (fullDomain.endsWith(`.${known}`)) {
return {
subdomain: fullDomain.slice(0, -(known.length + 1)),
baseDomain: known,
isCustom: false,
};
}
}
// Custom domain - find the first dot to split
const firstDot = fullDomain.indexOf(".");
if (firstDot > 0) {
return {
subdomain: fullDomain.slice(0, firstDot),
baseDomain: fullDomain.slice(firstDot + 1),
isCustom: true,
};
}
return {
subdomain: fullDomain,
baseDomain: "netbird.cloud",
isCustom: false,
};
}
export default function ReverseProxyModal({
open,
onOpenChange,
@@ -134,51 +100,112 @@ export default function ReverseProxyModal({
const router = useRouter();
const { permission } = usePermissions();
const { confirm } = useDialog();
const { reverseProxies, handleCreateOrUpdateProxy } = useReverseProxies();
const { handleCreateOrUpdateProxy } = useReverseProxies();
// Check if the proxy's cluster exists in available free domains
const isClusterConnected = useMemo(() => {
if (!reverseProxy?.proxy_cluster) return false;
return domains?.some(
(d) =>
d.type === ReverseProxyDomainType.FREE &&
d.domain === reverseProxy.proxy_cluster,
);
}, [reverseProxy?.proxy_cluster, domains]);
const {
subdomain,
setSubdomain,
baseDomain,
setBaseDomain,
fullDomain,
domainAlreadyExists,
isClusterConnected,
} = useReverseProxyDomain({ reverseProxy, domains, initialSubdomain });
const [tab, setTab] = useState(() => {
if (initialTab && initialTab !== "") return initialTab;
return "targets";
});
// Parse existing domain if editing
const parsed = reverseProxy?.domain ? parseDomain(reverseProxy.domain) : null;
const [serviceMode, setServiceMode] = useState<ServiceMode>(
reverseProxy?.mode ?? ServiceMode.HTTP,
);
// Form state
const [subdomain, setSubdomain] = useState(
parsed?.subdomain ||
initialSubdomain
?.toLowerCase()
.replace(/\s+/g, "-")
.replace(/[^a-z0-9-]/g, "") ||
const isL4Mode = isL4ServiceMode(serviceMode);
// L4 target selection state (TLS/TCP/UDP) - target is in targets[0]
const [l4Target, setL4Target] = useState<Target | undefined>(() => {
const existing = isL4ServiceMode(reverseProxy?.mode)
? reverseProxy?.targets?.[0]
: undefined;
if (existing) {
const isPeer = existing.target_type === ReverseProxyTargetType.PEER;
return {
type: existing.target_type,
peerId: isPeer ? existing.target_id : undefined,
resourceId: isPeer ? undefined : existing.target_id,
host: existing.host || "",
};
}
if (initialResource) {
const addr = initialResource.address;
return {
type:
(initialResource.type as ReverseProxyTargetType) ??
ReverseProxyTargetType.HOST,
resourceId: initialResource.id,
host: addr.includes("/") ? addr.split("/")[0] : addr,
};
}
if (initialPeer) {
return {
type: ReverseProxyTargetType.PEER,
peerId: initialPeer.id,
host: initialPeer.ip,
};
}
return undefined;
});
const [port, setPort] = useState<number>(
reverseProxy?.targets?.[0]?.port || 0,
);
const [listenPort, setListenPort] = useState<number>(
reverseProxy?.listen_port || 0,
);
// CIDR detection for L4 subnet resources
const { isCidrRange: l4IsCidrRange, isValidCidrHost: l4IsValidCidrHost } =
useReverseProxyAddress(l4Target);
// Proxy protocol: for L4 modes maps to target proxy_protocol
const [proxyProtocol, setProxyProtocol] = useState(
reverseProxy?.targets?.[0]?.options?.proxy_protocol ?? false,
);
const [timeoutOption, setTimeoutOption] = useState(
reverseProxy?.targets?.[0]?.options?.request_timeout ??
reverseProxy?.targets?.[0]?.options?.session_idle_timeout ??
"",
);
const [baseDomain, setBaseDomain] = useState(() => {
if (parsed?.baseDomain) return parsed.baseDomain;
const validatedDomains = domains?.filter((d) => d.validated) || [];
const customDomain = validatedDomains.find(
(d) => d.type === ReverseProxyDomainType.CUSTOM,
);
const freeDomain = validatedDomains.find(
(d) => d.type === ReverseProxyDomainType.FREE,
);
return customDomain?.domain || freeDomain?.domain || "";
});
const timeoutError = useMemo(() => {
if (!timeoutOption) return undefined;
return serviceMode === ServiceMode.UDP
? validateSessionIdleTimeout(timeoutOption)
: validateTimeout(timeoutOption);
}, [timeoutOption, serviceMode]);
const [targets, setTargets] = useState<ReverseProxyTarget[]>(
reverseProxy?.targets || [],
);
const selectedDomain = useMemo(
() =>
domains?.find(
(d) => d.domain === baseDomain || d.target_cluster === baseDomain,
),
[domains, baseDomain],
);
// Whether a custom listen port is supported (TLS always, TCP/UDP only when cluster supports it)
const isListenPortSupported = useMemo(() => {
if (serviceMode !== ServiceMode.TCP && serviceMode !== ServiceMode.UDP)
return true;
return selectedDomain?.supports_custom_ports ?? false;
}, [selectedDomain, serviceMode]);
const [passHostHeader, setPassHostHeader] = useState(
reverseProxy?.pass_host_header ?? false,
);
@@ -186,19 +213,6 @@ export default function ReverseProxyModal({
reverseProxy?.rewrite_redirects ?? false,
);
// Compute full domain
const fullDomain = useMemo(() => {
if (!baseDomain) return subdomain;
return `${subdomain}.${baseDomain}`;
}, [subdomain, baseDomain]);
const domainAlreadyExists = useMemo(() => {
if (!reverseProxies || !fullDomain) return false;
return reverseProxies.some(
(p) => p.domain === fullDomain && p.id !== reverseProxy?.id,
);
}, [reverseProxies, fullDomain, reverseProxy?.id]);
// Authentication options - initialized from existing reverseProxy.auth
const [passwordEnabled, setPasswordEnabled] = useState(
reverseProxy?.auth?.password_auth?.enabled ?? false,
@@ -233,19 +247,32 @@ export default function ReverseProxyModal({
null,
);
const isSubdomainValid = useMemo(() => {
return (
subdomain.length > 0 && baseDomain.length > 0 && !domainAlreadyExists
);
}, [subdomain, baseDomain, domainAlreadyExists]);
const canContinueToSettings = useMemo(() => {
return isSubdomainValid && targets.length > 0;
}, [isSubdomainValid, targets]);
const submitDisabled = useMemo(() => {
return !canContinueToSettings;
}, [canContinueToSettings]);
const isSubdomainValid =
subdomain.length > 0 && baseDomain.length > 0 && !domainAlreadyExists;
const isValidPort = (port: number) => port >= 1 && port <= 65535;
const hasHttpEndpoint = !isL4Mode && targets.length > 0;
const hasL4Endpoint =
isL4Mode &&
!!l4Target &&
l4IsValidCidrHost &&
isValidPort(port) &&
(!isListenPortSupported || isValidPort(listenPort));
const hasAnyEndpoint = hasHttpEndpoint || hasL4Endpoint;
return isSubdomainValid && hasAnyEndpoint;
}, [
subdomain,
baseDomain,
domainAlreadyExists,
serviceMode,
targets.length,
isL4Mode,
l4Target,
l4IsValidCidrHost,
port,
isListenPortSupported,
listenPort,
]);
const saveTarget = (targetData: ReverseProxyTarget) => {
if (editingTargetIndex !== null) {
@@ -282,8 +309,8 @@ export default function ReverseProxyModal({
!passwordEnabled && !pinEnabled && !bearerEnabled && !linkAuthEnabled;
const handleSubmit = async () => {
// Show warning if no authentication is configured
if (hasNoAuth) {
// Show warning if no authentication is configured (HTTP only; TLS is pass-through)
if (!isL4Mode && hasNoAuth) {
const confirmed = await confirm({
title: "No Authentication Configured",
description:
@@ -316,15 +343,46 @@ export default function ReverseProxyModal({
},
};
const l4TargetPayload: ReverseProxyTarget | undefined = l4Target
? {
target_id: l4Target?.peerId || l4Target?.resourceId || "",
target_type: l4Target?.type,
port: port,
protocol:
serviceMode === ServiceMode.TLS
? ReverseProxyTargetProtocol.TCP
: serviceMode === ServiceMode.UDP
? ReverseProxyTargetProtocol.UDP
: ReverseProxyTargetProtocol.TCP,
host: l4IsCidrRange ? l4Target?.host : undefined,
enabled: true,
options: (() => {
const opts: Record<string, unknown> = {};
if (serviceMode !== ServiceMode.UDP && proxyProtocol)
opts.proxy_protocol = true;
if (timeoutOption) {
opts[
serviceMode === ServiceMode.UDP
? "session_idle_timeout"
: "request_timeout"
] = timeoutOption;
}
return Object.keys(opts).length ? opts : undefined;
})(),
}
: undefined;
handleCreateOrUpdateProxy({
data: {
name: fullDomain,
domain: fullDomain,
targets,
mode: isL4Mode ? (serviceMode as ServiceMode) : undefined,
listen_port: isL4Mode && isListenPortSupported ? listenPort : undefined,
targets: isL4Mode && l4TargetPayload ? [l4TargetPayload] : targets,
enabled: reverseProxy?.enabled ?? true,
pass_host_header: passHostHeader,
rewrite_redirects: rewriteRedirects,
auth,
pass_host_header: isL4Mode ? undefined : passHostHeader,
rewrite_redirects: isL4Mode ? undefined : rewriteRedirects,
auth: isL4Mode ? undefined : auth,
},
proxyId: reverseProxy?.id,
onSuccess: () => {
@@ -334,6 +392,20 @@ export default function ReverseProxyModal({
});
};
const modalTitle = useMemo(() => {
const prefix = reverseProxy ? "Edit" : "Add";
const label = serviceMode ? SERVICE_MODES[serviceMode].label : "Service";
return `${prefix} ${label}`;
}, [reverseProxy, serviceMode]);
const modalDescription = useMemo(
() =>
isL4Mode
? "Forward traffic directly to your backend service."
: "Expose services securely through NetBird's reverse proxy.",
[isL4Mode],
);
return (
<Modal open={open} onOpenChange={onOpenChange} key={open ? 1 : 0}>
<ModalContent
@@ -341,224 +413,81 @@ export default function ReverseProxyModal({
>
<ModalHeader
icon={<ReverseProxyIcon className={"fill-netbird"} size={18} />}
title={reverseProxy ? "Edit Service" : "Add Service"}
description={
"Expose services securely through NetBird's reverse proxy."
}
title={modalTitle}
description={modalDescription}
color={"netbird"}
/>
<Tabs value={tab} onValueChange={setTab}>
<TabsList justify={"start"} className={"px-8"}>
<TabsTrigger value={"targets"}>
<Text size={14} />
Details
</TabsTrigger>
<TabsTrigger value={"auth"} disabled={!canContinueToSettings}>
<LockKeyhole size={16} />
Authentication
<ReverseProxyIcon size={14} />
Service
</TabsTrigger>
{!isL4Mode && (
<TabsTrigger value={"auth"} disabled={!canContinueToSettings}>
<LockKeyhole size={16} />
Authentication
</TabsTrigger>
)}
<TabsTrigger value={"settings"} disabled={!canContinueToSettings}>
<Settings size={14} />
Settings
Advanced Settings
</TabsTrigger>
</TabsList>
<TabsContent value={"targets"} className={"pb-8"}>
<div className={"px-8 flex-col flex gap-6"}>
<div>
<Label>
<GlobeIcon size={14} />
Domain
</Label>
<HelpText>
Enter a subdomain and select a domain for your service.
</HelpText>
<div className="flex items-start mt-2">
<div className="flex-1 min-w-0">
<Input
autoFocus
value={subdomain}
onChange={(e) => {
setSubdomain(
e.target.value
.toLowerCase()
.replace(/[^a-z0-9-]/g, ""),
);
}}
error={
domainAlreadyExists
? "This domain is already used by another service."
: undefined
}
placeholder={"myapp"}
className="!rounded-r-none !border-r-0"
/>
</div>
<div className="flex-1 min-w-0">
<CustomDomainSelector
value={baseDomain}
onChange={setBaseDomain}
className="!rounded-l-none"
/>
</div>
</div>
</div>
<ReverseProxyDomainInput
subdomain={subdomain}
onSubdomainChange={setSubdomain}
baseDomain={baseDomain}
onBaseDomainChange={setBaseDomain}
domainAlreadyExists={domainAlreadyExists}
clusterOffline={
reverseProxy?.proxy_cluster && !isClusterConnected
? { clusterName: reverseProxy.proxy_cluster }
: undefined
}
/>
{reverseProxy?.proxy_cluster && !isClusterConnected && (
<Callout variant={"error"}>
Cluster {reverseProxy.proxy_cluster} is offline. Make sure the
proxy server is running and connected to the right management
address.
</Callout>
{!reverseProxy && (
<ReverseProxyServiceModeSelector
onChange={setServiceMode}
value={serviceMode}
domain={selectedDomain}
/>
)}
<div>
<Label>
<Server size={14} />
Targets
</Label>
<HelpText>
Add one or more devices running your service or resources to
make it publicly accessible.
</HelpText>
{targets.length > 0 && (
<div
className={
"mt-3 mb-3 overflow-hidden border border-nb-gray-900 bg-nb-gray-920/30 py-1 px-1 rounded-md "
}
>
<table className="w-full">
<tbody>
{targets.map((target, index) => (
<tr
key={index}
onClick={() => editTarget(index)}
className="rounded-md hover:bg-nb-gray-900/30 cursor-pointer transition-all"
>
<td className="py-2.5 pl-5 pr-2 align-middle">
<span className="text-[11px] leading-none font-mono px-2.5 py-2 rounded bg-nb-gray-900 text-nb-gray-300 inline-flex items-center">
{target.path
? target.path.startsWith("/")
? target.path
: `/${target.path}`
: "/"}
</span>
</td>
<td className="py-2.5 px-4 align-middle">
<ArrowRight
size={12}
className="text-nb-gray-400"
/>
</td>
<td className="py-2.5 pr-2 align-middle">
<TargetDestination target={target} />
</td>
<td className="py-2.5 pl-2 pr-4">
<div
className="flex items-center gap-2 justify-end"
onClick={(e) => e.stopPropagation()}
>
<ToggleSwitch
size="small"
checked={target.enabled !== false}
onCheckedChange={() =>
toggleTargetEnabled(index)
}
/>
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button
variant="default-outline"
className="!px-3"
>
<MoreVertical
size={16}
className="shrink-0"
/>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-auto min-w-[200px]"
align="end"
>
<DropdownMenuItem
onClick={() => editTarget(index)}
>
<div className="flex gap-3 items-center">
<Edit size={14} className="shrink-0" />
Edit Target
</div>
</DropdownMenuItem>
<DropdownMenuItem
variant={"danger"}
onClick={() => removeTarget(index)}
>
<div className="flex gap-3 items-center">
<MinusCircleIcon
size={14}
className="shrink-0"
/>
Remove Target
</div>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<Button
variant="dotted"
className={cn("w-full mt-1", targets?.length > 0 && "mt-1")}
size="sm"
onClick={() => setTargetModalOpen(true)}
disabled={
!!(initialNetwork && !initialNetwork.resources?.length)
}
>
<PlusIcon size={14} />
Add Target
</Button>
{initialNetwork && !initialNetwork.resources?.length && (
<Callout
variant="warning"
className="mt-3"
icon={
<AlertTriangle
size={14}
className="shrink-0 relative top-[3px]"
/>
}
>
There are currently no resources in your network{" "}
<span className={"text-netbird-100 font-medium"}>
{initialNetwork?.name}
</span>
. Add resources to your network before exposing it as a
service.{" "}
<InlineButtonLink
variant={"default"}
onClick={() => {
onOpenChange(false);
router.push(
`/network?id=${initialNetwork.id}&tab=resources`,
);
}}
>
Go to Resources
<ArrowUpRight size={14} />
</InlineButtonLink>
</Callout>
)}
</div>
{isL4Mode ? (
<ReverseProxyLayer4Content
l4Target={l4Target}
setL4Target={setL4Target}
isListenPortSupported={isListenPortSupported}
listenPort={listenPort}
setListenPort={setListenPort}
port={port}
setPort={setPort}
initialResource={initialResource}
initialPeer={initialPeer}
initialNetwork={initialNetwork}
/>
) : (
<ReverseProxyHTTPTargets
targets={targets}
onEditTarget={editTarget}
onRemoveTarget={removeTarget}
onToggleTargetEnabled={toggleTargetEnabled}
onAddTarget={() => setTargetModalOpen(true)}
initialNetwork={initialNetwork}
onNavigateToResources={() => {
onOpenChange(false);
router.push(
`/network?id=${initialNetwork?.id}&tab=resources`,
);
}}
/>
)}
</div>
</TabsContent>
@@ -603,73 +532,116 @@ export default function ReverseProxyModal({
</TabsContent>
<TabsContent value={"settings"} className={"pb-8"}>
<div className={"px-8 flex-col flex gap-4"}>
<FancyToggleSwitch
value={passHostHeader}
onChange={setPassHostHeader}
label={
<>
<GlobeIcon size={15} />
Pass Host Header
</>
}
helpText="Forward the original Host header to the backend instead of rewriting it to the target address."
/>
<FancyToggleSwitch
value={rewriteRedirects}
onChange={setRewriteRedirects}
label={
<>
<ArrowRight size={15} />
Rewrite Redirects
</>
}
helpText="Rewrite Location headers in backend responses to use the public domain instead of the internal backend address."
/>
<div className={"px-8 flex-col flex gap-6"}>
{(serviceMode === ServiceMode.TCP ||
serviceMode === ServiceMode.TLS) && (
<FancyToggleSwitch
value={proxyProtocol}
onChange={setProxyProtocol}
label={
<>
<MapPinned size={15} />
Preserve Client Source IP
</>
}
helpText="Preserve client source IP addresses when forwarding traffic to the backend using PROXY Protocol v2."
/>
)}
{isL4Mode && (
<>
<div className={"flex items-center justify-between"}>
<div>
<Label>
{serviceMode === ServiceMode.UDP
? "Session Idle Timeout"
: "Connection Timeout"}
</Label>
<HelpText className={"mb-0"}>
{serviceMode === ServiceMode.UDP ? (
<>
Close the UDP session after this period of
inactivity.
<br /> Leave this field empty for no timeout.
</>
) : (
<>
Timeout for establishing backend connections. <br />{" "}
Leave this field empty for no timeout.
</>
)}
</HelpText>
</div>
<Input
customPrefix={<ClockFadingIcon size={16} />}
placeholder="e.g. 10s, 30s, 1m"
value={timeoutOption}
onChange={(e) => setTimeoutOption(e.target.value)}
maxWidthClass="w-[180px]"
errorTooltip={true}
error={timeoutError}
/>
</div>
</>
)}
{!isL4Mode && (
<div className={"flex flex-col gap-4"}>
<FancyToggleSwitch
value={passHostHeader}
onChange={setPassHostHeader}
label={
<>
<GlobeIcon size={15} />
Pass Host Header
</>
}
helpText="Forward the original Host header to the backend instead of rewriting it to the target address."
/>
<FancyToggleSwitch
value={rewriteRedirects}
onChange={setRewriteRedirects}
label={
<>
<ArrowRight size={15} />
Rewrite Redirects
</>
}
helpText="Rewrite Location headers in backend responses to use the public domain instead of the internal backend address."
/>
</div>
)}
</div>
</TabsContent>
</Tabs>
<ModalFooter className={"items-center"}>
<div className={"w-full"}>
{tab === "targets" && (
<Paragraph className={"text-sm mt-auto"}>
Learn more about
<InlineLink
href={REVERSE_PROXY_SERVICES_DOCS_LINK}
target={"_blank"}
>
Services
<ExternalLinkIcon size={12} />
</InlineLink>
</Paragraph>
)}
{tab === "auth" && (
<Paragraph className={"text-sm mt-auto"}>
Learn more about
<InlineLink
href={REVERSE_PROXY_AUTHENTICATION_DOCS_LINK}
target={"_blank"}
>
Authentication
<ExternalLinkIcon size={12} />
</InlineLink>
</Paragraph>
)}
{tab === "settings" && (
<Paragraph className={"text-sm mt-auto"}>
Learn more about
<InlineLink
href={REVERSE_PROXY_SETTINGS_DOCS_LINK}
target={"_blank"}
>
Settings
<ExternalLinkIcon size={12} />
</InlineLink>
</Paragraph>
)}
{(() => {
const docsLink = {
targets: {
href: REVERSE_PROXY_SERVICES_DOCS_LINK,
label: "Services",
},
auth: {
href: REVERSE_PROXY_AUTHENTICATION_DOCS_LINK,
label: "Authentication",
},
settings: {
href: REVERSE_PROXY_SETTINGS_DOCS_LINK,
label: "Settings",
},
}[tab];
return docsLink ? (
<Paragraph className={"text-sm mt-auto"}>
Learn more about
<InlineLink href={docsLink.href} target={"_blank"}>
{docsLink.label}
<ExternalLinkIcon size={12} />
</InlineLink>
</Paragraph>
) : null;
})()}
</div>
<div className={"flex gap-3 w-full justify-end"}>
{!reverseProxy ? (
@@ -681,7 +653,7 @@ export default function ReverseProxyModal({
</ModalClose>
<Button
variant={"primary"}
onClick={() => setTab("auth")}
onClick={() => setTab(isL4Mode ? "settings" : "auth")}
disabled={!canContinueToSettings}
>
Continue
@@ -710,13 +682,17 @@ export default function ReverseProxyModal({
<>
<Button
variant={"secondary"}
onClick={() => setTab("auth")}
onClick={() => setTab(isL4Mode ? "targets" : "auth")}
>
Back
</Button>
<Button
variant={"primary"}
disabled={submitDisabled || !permission?.services?.create}
disabled={
!canContinueToSettings ||
!permission?.services?.create ||
!!timeoutError
}
onClick={handleSubmit}
>
<PlusCircle size={16} />
@@ -732,7 +708,11 @@ export default function ReverseProxyModal({
</ModalClose>
<Button
variant={"primary"}
disabled={submitDisabled || !permission?.services?.update}
disabled={
!canContinueToSettings ||
!permission?.services?.update ||
!!timeoutError
}
onClick={handleSubmit}
>
Save Changes
@@ -760,6 +740,7 @@ export default function ReverseProxyModal({
domain: fullDomain,
targets: targets,
enabled: reverseProxy?.enabled ?? true,
mode: serviceMode,
}}
initialResource={initialResource}
initialPeer={initialPeer}
@@ -828,12 +809,3 @@ export default function ReverseProxyModal({
</Modal>
);
}
function TargetDestination({ target }: { target: ReverseProxyTarget }) {
const { resolveDestination } = useReverseProxies();
return (
<span className="text-[0.76rem] text-nb-gray-200 whitespace-nowrap font-mono">
{resolveDestination(target)}
</span>
);
}

View File

@@ -0,0 +1,125 @@
import React, { ReactNode, useEffect } from "react";
import {
isL4Mode as isL4ServiceMode,
type ReverseProxyDomain,
ServiceMode,
} from "@/interfaces/ReverseProxy";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@components/Select";
import { ArrowRightFromLine, Globe, LockKeyhole } from "lucide-react";
import { HelpTooltip } from "@components/HelpTooltip";
import { Label } from "@components/Label";
import HelpText from "@components/HelpText";
type Props = {
value?: ServiceMode;
onChange: (value: ServiceMode) => void;
disabled?: boolean;
domain?: ReverseProxyDomain;
};
type ServiceModeConfig = {
label: string;
description: string;
icon: ReactNode;
};
export const SERVICE_MODES: Record<ServiceMode, ServiceModeConfig> = {
[ServiceMode.HTTP]: {
label: "HTTP/S Service",
description:
"Reverse proxy with path routing and built-in authentication (SSO, PIN, password). Typically used for web applications and APIs.",
icon: <Globe size={14} />,
},
[ServiceMode.TLS]: {
label: "TLS Passthrough",
description:
"Passes encrypted TLS traffic straight through to the backend. Typically used for services that manage their own TLS certificates.",
icon: <LockKeyhole size={14} />,
},
[ServiceMode.TCP]: {
label: "TCP Service",
description:
"Forwards raw TCP traffic to your backend on a dedicated port. Typically used for databases, custom protocols, or any TCP-based service.",
icon: <ArrowRightFromLine size={14} />,
},
[ServiceMode.UDP]: {
label: "UDP Service",
description:
"Forwards raw UDP traffic to your backend on a dedicated port. Typically used for real-time services like voice, video, or streaming.",
icon: <ArrowRightFromLine size={14} />,
},
};
export const ReverseProxyServiceModeSelector = ({
value,
onChange,
disabled,
domain,
}: Props) => {
const selected = value ?? ServiceMode.HTTP;
const selectedMode = SERVICE_MODES[selected];
const isL4Supported = domain?.supports_custom_ports === true;
// Reset to HTTP if the current L4 mode becomes unsupported (e.g. domain changed)
useEffect(() => {
if (!isL4Supported && isL4ServiceMode(selected)) {
onChange(ServiceMode.HTTP);
}
}, [isL4Supported, selected, onChange]);
return (
<div className="flex justify-between items-center gap-10 mt-2">
<div>
<Label>Service Type</Label>
<HelpText>
Select a type to define how the proxy handles and forwards traffic to
your backend services.
</HelpText>
</div>
<Select
value={selected}
onValueChange={(v) => onChange(v as ServiceMode)}
disabled={disabled}
>
<SelectTrigger className="max-w-[240px] min-w-[200px]">
<div
className={"flex items-center gap-2 whitespace-nowrap"}
data-cy={"service-mode-select-button"}
>
{selectedMode.icon}
<SelectValue placeholder="Select type..." />
</div>
</SelectTrigger>
<SelectContent data-cy={"service-mode-selection"}>
{Object.entries(SERVICE_MODES)
.filter(
([mode]) =>
isL4Supported || !isL4ServiceMode(mode as ServiceMode),
)
.map(([mode, config]) => (
<SelectItem
key={mode}
value={mode}
extra={
<HelpTooltip
triggerClassName={"ml-[0.01rem]"}
align={"center"}
side={"right"}
content={<>{config.description}</>}
/>
}
>
<span className="whitespace-nowrap">{config.label}</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
};

View File

@@ -23,6 +23,7 @@ import {
} from "@/interfaces/ReverseProxy";
import HelpText from "@components/HelpText";
import Separator from "@components/Separator";
import { isNetBirdHosted } from "@/utils/netbird";
import {
SelectDropdown,
SelectOption,
@@ -99,18 +100,35 @@ export const CustomDomainModal = ({
<div className={"px-8 flex flex-col gap-6 pt-6 pb-8"}>
{availableClusters.length === 0 ? (
<Callout variant="warning">
No proxy clusters are currently connected. Please ensure at least
one proxy is running before adding a domain. <br /> Learn more
about{" "}
<InlineLink
href={REVERSE_PROXY_CLUSTERS_DOCS_LINK}
target={"_blank"}
>
Proxy Clusters
<ExternalLinkIcon size={12} />
</InlineLink>
</Callout>
isNetBirdHosted() ? (
<Callout variant={"warning"}>
No proxy clusters are currently connected. Please try again in a
few minutes. If the issue persists, check{" "}
<InlineLink
href={"https://status.netbird.io/"}
target={"_blank"}
>
NetBird Status
</InlineLink>{" "}
or reach out to{" "}
<InlineLink href={"mailto:support@netbird.io"}>
support@netbird.io
</InlineLink>
</Callout>
) : (
<Callout variant="warning">
No proxy clusters are currently connected. Please ensure at
least one proxy is running before adding a domain. <br /> Learn
more about{" "}
<InlineLink
href={REVERSE_PROXY_CLUSTERS_DOCS_LINK}
target={"_blank"}
>
Proxy Clusters
<ExternalLinkIcon size={12} />
</InlineLink>
</Callout>
)
) : (
<>
<div>

View File

@@ -22,6 +22,7 @@ import {
} from "@/interfaces/ReverseProxy";
import Paragraph from "@components/Paragraph";
import InlineLink from "@components/InlineLink";
import { isNetBirdHosted } from "@/utils/netbird";
type Props = {
open: boolean;
@@ -79,18 +80,36 @@ export const CustomDomainVerificationModal = ({
</Steps>
<div className={"flex flex-col gap-6"}>
{!cnameTarget ? (
<Callout variant={"warning"}>
No proxy clusters are currently connected. Please ensure at
least one proxy is running to configure DNS verification. <br />
Learn more about{" "}
<InlineLink
href={REVERSE_PROXY_CLUSTERS_DOCS_LINK}
target={"_blank"}
>
Proxy Clusters
<ExternalLinkIcon size={12} />
</InlineLink>
</Callout>
isNetBirdHosted() ? (
<Callout variant={"warning"}>
No proxy clusters are currently connected. Please try again in
a few minutes. If the issue persists, check{" "}
<InlineLink
href={"https://status.netbird.io/"}
target={"_blank"}
>
NetBird Status
</InlineLink>{" "}
or reach out to{" "}
<InlineLink href={"mailto:support@netbird.io"}>
support@netbird.io
</InlineLink>
</Callout>
) : (
<Callout variant={"warning"}>
No proxy clusters are currently connected. Please ensure at
least one proxy is running to configure DNS verification.{" "}
<br />
Learn more about{" "}
<InlineLink
href={REVERSE_PROXY_CLUSTERS_DOCS_LINK}
target={"_blank"}
>
Proxy Clusters
<ExternalLinkIcon size={12} />
</InlineLink>
</Callout>
)
) : (
<>
<Card className={"w-full"}>

View File

@@ -0,0 +1,84 @@
import HelpText from "@components/HelpText";
import { Input } from "@components/Input";
import { Label } from "@components/Label";
import { Callout } from "@components/Callout";
import React from "react";
import { CustomDomainSelector } from "./CustomDomainSelector";
import { isNetBirdHosted } from "@utils/netbird";
import InlineLink from "@components/InlineLink";
type Props = {
subdomain: string;
onSubdomainChange: (value: string) => void;
baseDomain: string;
onBaseDomainChange: (value: string) => void;
domainAlreadyExists: boolean;
clusterOffline?: {
clusterName: string;
};
};
export default function ReverseProxyDomainInput({
subdomain,
onSubdomainChange,
baseDomain,
onBaseDomainChange,
domainAlreadyExists,
clusterOffline,
}: Readonly<Props>) {
return (
<div>
<Label>Domain</Label>
<HelpText>
Enter a subdomain and select a domain for your service.
</HelpText>
<div className="flex items-start mt-2">
<div className="flex-1 min-w-0">
<Input
autoFocus
value={subdomain}
onChange={(e) => {
onSubdomainChange(
e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, ""),
);
}}
error={
domainAlreadyExists
? "This domain is already used by another service."
: undefined
}
placeholder={"myapp"}
className="!rounded-r-none !border-r-0"
/>
</div>
<div className="flex-1 min-w-0">
<CustomDomainSelector
value={baseDomain}
onChange={onBaseDomainChange}
className="!rounded-l-none"
/>
</div>
</div>
{clusterOffline &&
(isNetBirdHosted() ? (
<Callout variant={"warning"} className={"mt-3"}>
Cluster {clusterOffline.clusterName} is offline. Please try again in
a few minutes. If the issue persists, check{" "}
<InlineLink href={"https://status.netbird.io/"} target={"_blank"}>
NetBird Status
</InlineLink>{" "}
or reach out to{" "}
<InlineLink href={"mailto:support@netbird.io"}>
support@netbird.io
</InlineLink>
</Callout>
) : (
<Callout variant={"error"} className={"mt-3"}>
Cluster {clusterOffline.clusterName} is offline. Make sure the proxy
server is running and connected to the right management address.
</Callout>
))}
</div>
);
}

View File

@@ -0,0 +1,133 @@
import { useMemo, useState } from "react";
import {
ReverseProxy,
ReverseProxyDomain,
ReverseProxyDomainType,
} from "@/interfaces/ReverseProxy";
import { useReverseProxies } from "@/contexts/ReverseProxiesProvider";
// Helper to parse domain into subdomain and base domain.
// When availableDomains is provided, matches against them first (longest match wins)
// to avoid e.g. "netbird.io" matching when the actual domain is "eu.proxy.netbird.io".
function parseDomain(
fullDomain: string,
availableDomains?: ReverseProxyDomain[],
): {
subdomain: string;
baseDomain: string;
isCustom: boolean;
} {
// Try matching against actual available domains first (sorted longest-first for specificity)
if (availableDomains?.length) {
const sorted = [...availableDomains]
.filter((d) => d.domain)
.sort((a, b) => b.domain.length - a.domain.length);
for (const d of sorted) {
if (fullDomain.endsWith(`.${d.domain}`)) {
return {
subdomain: fullDomain.slice(0, -(d.domain.length + 1)),
baseDomain: d.domain,
isCustom: d.type === ReverseProxyDomainType.CUSTOM,
};
}
}
}
// Fallback to hardcoded known domains
const knownDomains = ["netbird.cloud", "netbird.io", "netbird.app"];
for (const known of knownDomains) {
if (fullDomain.endsWith(`.${known}`)) {
return {
subdomain: fullDomain.slice(0, -(known.length + 1)),
baseDomain: known,
isCustom: false,
};
}
}
// Custom domain - find the first dot to split
const firstDot = fullDomain.indexOf(".");
if (firstDot > 0) {
return {
subdomain: fullDomain.slice(0, firstDot),
baseDomain: fullDomain.slice(firstDot + 1),
isCustom: true,
};
}
return {
subdomain: fullDomain,
baseDomain: "netbird.cloud",
isCustom: false,
};
}
type UseReverseProxyDomainOptions = {
reverseProxy?: ReverseProxy;
domains?: ReverseProxyDomain[];
initialSubdomain?: string;
};
export function useReverseProxyDomain({
reverseProxy,
domains,
initialSubdomain,
}: UseReverseProxyDomainOptions) {
const { reverseProxies } = useReverseProxies();
const parsed = reverseProxy?.domain
? parseDomain(reverseProxy.domain, domains)
: null;
const [subdomain, setSubdomain] = useState(() => {
return (
parsed?.subdomain ||
initialSubdomain
?.toLowerCase()
.replace(/\s+/g, "-")
.replace(/[^a-z0-9-]/g, "") ||
""
);
});
const [baseDomain, setBaseDomain] = useState(() => {
if (parsed?.baseDomain) return parsed.baseDomain;
const validatedDomains = domains?.filter((d) => d.validated) || [];
const customDomain = validatedDomains.find(
(d) => d.type === ReverseProxyDomainType.CUSTOM,
);
const freeDomain = validatedDomains.find(
(d) => d.type === ReverseProxyDomainType.FREE,
);
return customDomain?.domain || freeDomain?.domain || "";
});
const fullDomain = baseDomain ? `${subdomain}.${baseDomain}` : subdomain;
const domainAlreadyExists = useMemo(() => {
if (!reverseProxies || !fullDomain) return false;
return reverseProxies.some(
(p) => p.domain === fullDomain && p.id !== reverseProxy?.id,
);
}, [reverseProxies, fullDomain, reverseProxy?.id]);
const isClusterConnected = useMemo(() => {
if (!reverseProxy?.proxy_cluster) return false;
return domains?.some(
(d) =>
d.type === ReverseProxyDomainType.FREE &&
d.domain === reverseProxy.proxy_cluster,
);
}, [reverseProxy?.proxy_cluster, domains]);
return {
subdomain,
setSubdomain,
baseDomain,
setBaseDomain,
fullDomain,
domainAlreadyExists,
isClusterConnected,
};
}

View File

@@ -0,0 +1,32 @@
import { cn, formatBytes } from "@utils/helpers";
import { ArrowDownIcon, ArrowUpIcon } from "lucide-react";
import * as React from "react";
import { ReverseProxyEvent } from "@/interfaces/ReverseProxy";
import EmptyRow from "@/modules/common-table-rows/EmptyRow";
type Props = {
event: ReverseProxyEvent;
};
export const ReverseProxyEventsBytesCell = ({ event }: Props) => {
if (
(event.bytes_download === undefined || event.bytes_download === 0) &&
(event.bytes_upload === undefined || event.bytes_upload === 0)
)
return <EmptyRow />;
return (
<div className={"flex flex-col text-xs gap-1 text-nb-gray-300 font-medium"}>
<div className={"flex gap-2 items-center whitespace-nowrap"}>
<ArrowDownIcon size={15} className={cn("text-sky-400")} />
<span className="sr-only">Download:</span>
{formatBytes(event.bytes_download ?? 0)}
</div>
<div className={"flex gap-2 items-center whitespace-nowrap"}>
<ArrowUpIcon size={15} className={cn("text-netbird")} />
<span className="sr-only">Upload:</span>
{formatBytes(event.bytes_upload ?? 0)}
</div>
</div>
);
};

View File

@@ -1,5 +1,6 @@
import * as React from "react";
import { ReverseProxyEvent } from "@/interfaces/ReverseProxy";
import { formatDuration } from "@utils/helpers";
type Props = {
event: ReverseProxyEvent;
@@ -8,7 +9,7 @@ type Props = {
export const ReverseProxyEventsDurationCell = ({ event }: Props) => {
return (
<span className="text-nb-gray-300 text-[0.82rem] px-3 py-2 font-mono">
{event.duration_ms}ms
{formatDuration(event.duration_ms)}
</span>
);
};

View File

@@ -1,13 +1,26 @@
import CopyToClipboardText from "@components/CopyToClipboardText";
import TruncatedText from "@components/ui/TruncatedText";
import * as React from "react";
import { ReverseProxyEvent } from "@/interfaces/ReverseProxy";
import {
isL4Event,
ReverseProxy,
ReverseProxyEvent,
} from "@/interfaces/ReverseProxy";
type Props = {
event: ReverseProxyEvent;
service?: ReverseProxy;
};
export const ReverseProxyEventsMethodCell = ({ event }: Props) => {
if (isL4Event(event)) {
return (
<span className="font-mono text-[0.82rem] font-medium py-2 text-nb-gray-200 uppercase">
{event.protocol}
</span>
);
}
return (
<span className="font-mono text-[0.82rem] font-medium py-2 text-nb-gray-300">
{event.method}
@@ -15,8 +28,13 @@ export const ReverseProxyEventsMethodCell = ({ event }: Props) => {
);
};
export const ReverseProxyEventsUrlCell = ({ event }: Props) => {
const fullUrl = `${event.host}${event.path}`;
export const ReverseProxyEventsUrlCell = ({ event, service }: Props) => {
const isL4 = isL4Event(event);
const listenPort = service?.listen_port;
const hostWithPort =
isL4 && listenPort ? `${event.host}:${listenPort}` : event.host || "-";
const fullUrl = isL4 ? hostWithPort : `${event.host}${event.path}`;
return (
<TruncatedText
@@ -34,7 +52,12 @@ export const ReverseProxyEventsUrlCell = ({ event }: Props) => {
<CopyToClipboardText message={"URL has been copied to your clipboard"}>
<span className="font-mono text-[0.82rem] whitespace-nowrap">
<span className="text-nb-gray-200">{event.host}</span>
<span className="text-nb-gray-300">{event.path}</span>
{isL4 && listenPort && (
<span className="text-nb-gray-300">:{listenPort}</span>
)}
{!isL4 && (
<span className="text-nb-gray-300">{event.path}</span>
)}
</span>
</CopyToClipboardText>
</TruncatedText>

View File

@@ -1,12 +1,14 @@
import * as React from "react";
import { ReverseProxyEvent } from "@/interfaces/ReverseProxy";
import { isL4Event, ReverseProxyEvent } from "@/interfaces/ReverseProxy";
import Badge from "@components/Badge";
import EmptyRow from "@/modules/common-table-rows/EmptyRow";
type Props = {
event: ReverseProxyEvent;
};
export const ReverseProxyEventsStatusCell = ({ event }: Props) => {
if (isL4Event(event)) return <EmptyRow />;
const isSuccess = event.status_code >= 200 && event.status_code < 400;
return (

View File

@@ -11,17 +11,17 @@ import GetStartedTest from "@components/ui/GetStartedTest";
import type { ColumnDef, SortingState } from "@tanstack/react-table";
import { ExternalLinkIcon } from "lucide-react";
import ReverseProxyIcon from "@/assets/icons/ReverseProxyIcon";
import { usePathname } from "next/navigation";
import dayjs from "dayjs";
import React, { useCallback, useMemo } from "react";
import React, { useCallback, useMemo, useState } from "react";
import { DateRange } from "react-day-picker";
import { DatePickerWithRange } from "@components/DatePickerWithRange";
import { useLocalStorage } from "@/hooks/useLocalStorage";
import { useServerPagination } from "@/contexts/ServerPaginationProvider";
import {
REVERSE_PROXY_EVENTS_DOCS_LINK,
ReverseProxy,
ReverseProxyEvent,
} from "@/interfaces/ReverseProxy";
import useFetchApi from "@/utils/api";
import { ReverseProxyEventsStatusCell } from "@/modules/reverse-proxy/events/ReverseProxyEventsStatusCell";
import { ReverseProxyEventsUserCell } from "@/modules/reverse-proxy/events/ReverseProxyEventsUserCell";
import { ReverseProxyEventsLocationIpCell } from "@/modules/reverse-proxy/events/ReverseProxyEventsLocationIpCell";
@@ -33,12 +33,17 @@ import { ReverseProxyEventsTimeCell } from "@/modules/reverse-proxy/events/Rever
import { ReverseProxyEventsAuthMethodCell } from "@/modules/reverse-proxy/events/ReverseProxyEventsAuthMethodCell";
import { ReverseProxyEventsReasonCell } from "@/modules/reverse-proxy/events/ReverseProxyEventsReasonCell";
import { ReverseProxyEventsDurationCell } from "@/modules/reverse-proxy/events/ReverseProxyEventsDurationCell";
import { ReverseProxyEventsBytesCell } from "@/modules/reverse-proxy/events/ReverseProxyEventsBytesCell";
export const ReverseProxyEventsTableColumns: ColumnDef<ReverseProxyEvent>[] = [
export const makeEventsColumns = (
servicesMap: Map<string, ReverseProxy>,
): ColumnDef<ReverseProxyEvent>[] => [
{
id: "timestamp",
header: ({ column }) => (
<DataTableHeader column={column} sorting={false}>Time</DataTableHeader>
<DataTableHeader column={column} name="timestamp">
Time
</DataTableHeader>
),
cell: ({ row }) => (
<ReverseProxyEventsTimeCell timestamp={row.original.timestamp} />
@@ -52,7 +57,9 @@ export const ReverseProxyEventsTableColumns: ColumnDef<ReverseProxyEvent>[] = [
accessorFn: (row) =>
`${row.source_ip} ${row.city_name || ""} ${row.country_code || ""}`,
header: ({ column }) => (
<DataTableHeader column={column} sorting={false}>Location / IP</DataTableHeader>
<DataTableHeader column={column} name="source_ip">
Location / IP
</DataTableHeader>
),
cell: ({ row }) => (
<ReverseProxyEventsLocationIpCell event={row.original} />
@@ -62,24 +69,35 @@ export const ReverseProxyEventsTableColumns: ColumnDef<ReverseProxyEvent>[] = [
id: "method",
accessorKey: "method",
header: ({ column }) => (
<DataTableHeader column={column} sorting={false}>Method</DataTableHeader>
<DataTableHeader column={column} name="method">
Method
</DataTableHeader>
),
cell: ({ row }) => <ReverseProxyEventsMethodCell event={row.original} />,
filterFn: "arrIncludesSomeExact",
},
{
id: "url",
accessorFn: (row) => `${row.host} ${row.path}`,
accessorFn: (row) => `${row.host} ${row.path || ""}`,
header: ({ column }) => (
<DataTableHeader column={column} sorting={false}>URL</DataTableHeader>
<DataTableHeader column={column} name="url" sorting={false}>
Host / URL
</DataTableHeader>
),
cell: ({ row }) => (
<ReverseProxyEventsUrlCell
event={row.original}
service={servicesMap.get(row.original.service_id)}
/>
),
cell: ({ row }) => <ReverseProxyEventsUrlCell event={row.original} />,
},
{
id: "status",
accessorKey: "status_code",
header: ({ column }) => (
<DataTableHeader column={column} sorting={false}>Status</DataTableHeader>
<DataTableHeader column={column} name="status_code">
Status
</DataTableHeader>
),
cell: ({ row }) => <ReverseProxyEventsStatusCell event={row.original} />,
size: 80,
@@ -94,15 +112,29 @@ export const ReverseProxyEventsTableColumns: ColumnDef<ReverseProxyEvent>[] = [
id: "duration",
accessorKey: "duration_ms",
header: ({ column }) => (
<DataTableHeader column={column} sorting={false}>Duration</DataTableHeader>
<DataTableHeader column={column} name="duration">
Duration
</DataTableHeader>
),
cell: ({ row }) => <ReverseProxyEventsDurationCell event={row.original} />,
},
{
id: "bytes",
accessorFn: (row) => (row.bytes_download ?? 0) + (row.bytes_upload ?? 0),
header: ({ column }) => (
<DataTableHeader column={column} sorting={false}>
Bytes
</DataTableHeader>
),
cell: ({ row }) => <ReverseProxyEventsBytesCell event={row.original} />,
},
{
id: "auth_method",
accessorKey: "auth_method_used",
header: ({ column }) => (
<DataTableHeader column={column} sorting={false}>Auth Method</DataTableHeader>
<DataTableHeader column={column} name="auth_method">
Auth Method
</DataTableHeader>
),
cell: ({ row }) => (
<ReverseProxyEventsAuthMethodCell event={row.original} />
@@ -112,7 +144,9 @@ export const ReverseProxyEventsTableColumns: ColumnDef<ReverseProxyEvent>[] = [
id: "reason",
accessorKey: "reason",
header: ({ column }) => (
<DataTableHeader column={column} sorting={false}>Reason</DataTableHeader>
<DataTableHeader column={column} name="reason">
Reason
</DataTableHeader>
),
cell: ({ row }) => <ReverseProxyEventsReasonCell event={row.original} />,
},
@@ -120,7 +154,9 @@ export const ReverseProxyEventsTableColumns: ColumnDef<ReverseProxyEvent>[] = [
id: "user",
accessorFn: (row) => row.user_id || "",
header: ({ column }) => (
<DataTableHeader column={column} sorting={false}>User</DataTableHeader>
<DataTableHeader column={column} name="user_id">
User
</DataTableHeader>
),
cell: ({ row }) => <ReverseProxyEventsUserCell event={row.original} />,
},
@@ -136,8 +172,6 @@ type Props = {
export default function ReverseProxyEventsTable({
headingTarget,
}: Readonly<Props>) {
const path = usePathname();
const {
data: events,
isLoading,
@@ -148,6 +182,20 @@ export default function ReverseProxyEventsTable({
...paginationProps
} = useServerPagination<ReverseProxyEvent[]>();
const { data: services } = useFetchApi<ReverseProxy[]>(
"/reverse-proxies/services",
);
const servicesMap = useMemo(() => {
const map = new Map<string, ReverseProxy>();
for (const svc of services ?? []) {
if (svc.id) map.set(svc.id, svc);
}
return map;
}, [services]);
const columns = useMemo(() => makeEventsColumns(servicesMap), [servicesMap]);
const activeStatus = getFilter("status");
const dateRange = useMemo<DateRange | undefined>(() => {
@@ -174,15 +222,12 @@ export default function ReverseProxyEventsTable({
[setFilter],
);
const [sorting, setSorting] = useLocalStorage<SortingState>(
"netbird-table-sort" + path,
[
{
id: "timestamp",
desc: true,
},
],
);
const [sorting, setSorting] = useState<SortingState>([
{
id: "timestamp",
desc: true,
},
]);
return (
<DataTable
@@ -195,7 +240,7 @@ export default function ReverseProxyEventsTable({
text={"Proxy Events"}
sorting={sorting}
setSorting={setSorting}
columns={ReverseProxyEventsTableColumns}
columns={columns}
columnVisibility={{ is_success: false, id: false }}
searchPlaceholder={"Search by IP, host, path, user..."}
getStartedCard={

View File

@@ -11,6 +11,7 @@ import { UserCountStack } from "@components/ui/MultipleGroups";
import {
ArrowRightIcon,
Binary,
HelpCircle,
LucideIcon,
RectangleEllipsis,
Settings,
@@ -23,7 +24,8 @@ import { useGroups } from "@/contexts/GroupsProvider";
import { usePermissions } from "@/contexts/PermissionsProvider";
import { useReverseProxies } from "@/contexts/ReverseProxiesProvider";
import { Group } from "@/interfaces/Group";
import { ReverseProxy } from "@/interfaces/ReverseProxy";
import { isL4Mode, ReverseProxy } from "@/interfaces/ReverseProxy";
import FullTooltip from "@components/FullTooltip";
const AUTH_METHODS: {
key: "password_auth" | "pin_auth" | "bearer_auth";
@@ -56,6 +58,28 @@ export default function ReverseProxyAuthCell({
const { permission } = usePermissions();
const { openModal } = useReverseProxies();
const { groups } = useGroups();
// L4 services don't support auth
if (isL4Mode(reverseProxy.mode)) {
return (
<div className={"flex"}>
<FullTooltip
content={
<div className={"flex text-xs max-w-[340px]"}>
Auth methods are not supported for TCP/UDP and TLS passthrough
services as they operate at the network layer.
</div>
}
>
<Badge variant={"gray"}>
N/A
<HelpCircle size={12} />
</Badge>
</FullTooltip>
</div>
);
}
const auth = reverseProxy.auth;
const enabled = AUTH_METHODS.filter((m) => auth?.[m.key]?.enabled);
@@ -71,20 +95,17 @@ export default function ReverseProxyAuthCell({
const SingleIcon = enabled.length === 1 ? enabled[0].Icon : null;
const badgeContent =
SingleIcon ? (
<>
<SingleIcon size={12} className="text-green-500" />
<span className={"font-medium text-xs"}>{enabled[0].label}</span>
</>
) : enabled.length > 1 ? (
<>
<ShieldCheck size={12} className="text-green-400" />
<span className={"font-medium text-xs"}>
{enabled.length} Enabled
</span>
</>
) : null;
const badgeContent = SingleIcon ? (
<>
<SingleIcon size={12} className="text-green-500" />
<span className={"font-medium text-xs"}>{enabled[0].label}</span>
</>
) : enabled.length > 1 ? (
<>
<ShieldCheck size={12} className="text-green-400" />
<span className={"font-medium text-xs"}>{enabled.length} Enabled</span>
</>
) : null;
return (
<div
@@ -137,9 +158,7 @@ export default function ReverseProxyAuthCell({
{ssoGroups.map((group) => (
<div
key={group.id}
className={
"flex gap-2 items-center justify-between"
}
className={"flex gap-2 items-center justify-between"}
>
<GroupBadge group={group} />
<ArrowRightIcon size={14} />

View File

@@ -10,6 +10,8 @@ import {
ReverseProxyDomainType,
} from "@/interfaces/ReverseProxy";
import FullTooltip from "@components/FullTooltip";
import { isNetBirdHosted } from "@/utils/netbird";
import InlineLink from "@components/InlineLink";
type Props = {
reverseProxy: ReverseProxy;
@@ -52,10 +54,24 @@ export default function ReverseProxyClusterCell({
return (
<FullTooltip
content={
<div className={"flex flex-col gap-1 text-xs max-w-xs"}>
Cluster {reverseProxy.proxy_cluster} is offline. Make sure the proxy
server is running and connected to the right management address.
</div>
isNetBirdHosted() ? (
<div className={"text-xs max-w-xs"}>
Cluster {reverseProxy.proxy_cluster} is offline. Please try again in
a few minutes. If the issue persists, check{" "}
<InlineLink href={"https://status.netbird.io/"} target={"_blank"}>
NetBird Status
</InlineLink>{" "}
or reach out to{" "}
<InlineLink href={"mailto:support@netbird.io"}>
support@netbird.io
</InlineLink>
</div>
) : (
<div className={"flex flex-col gap-1 text-xs max-w-xs"}>
Cluster {reverseProxy.proxy_cluster} is offline. Make sure the proxy
server is running and connected to the right management address.
</div>
)
}
align={"center"}
alignOffset={0}

View File

@@ -1,8 +1,9 @@
import { cn } from "@utils/helpers";
import { ChevronDown, ChevronRightIcon, LockIcon } from "lucide-react";
import * as React from "react";
import { ReverseProxy } from "@/interfaces/ReverseProxy";
import { isL4Mode, ReverseProxy, ServiceMode } from "@/interfaces/ReverseProxy";
import ExternalLinkText from "@components/ExternalLinkText";
import CopyToClipboardText from "@components/CopyToClipboardText";
type Props = {
reverseProxy?: ReverseProxy;
@@ -18,8 +19,14 @@ export default function ReverseProxyNameCell({
showChevron = true,
}: Readonly<Props>) {
const displayDomain = domain ?? reverseProxy?.domain ?? "";
const isL4 = reverseProxy?.mode && isL4Mode(reverseProxy.mode);
const portSuffix =
isL4 && reverseProxy?.listen_port ? `:${reverseProxy.listen_port}` : "";
const isLinkable = !isL4 || reverseProxy?.mode === ServiceMode.TLS;
const isEnabled = enabled ?? reverseProxy?.enabled ?? false;
const hasTargets = (reverseProxy?.targets?.length ?? 0) > 0;
const hasExpandableTargets =
(reverseProxy?.targets?.length ?? 0) > 0 && !isL4Mode(reverseProxy?.mode);
return (
<div
@@ -36,14 +43,14 @@ export default function ReverseProxyNameCell({
size={20}
className={cn(
"group-data-[accordion=opened]/accordion:hidden text-nb-gray-400 shrink-0",
!hasTargets && "cursor-default opacity-0",
!hasExpandableTargets && "cursor-default opacity-0",
)}
/>
<ChevronDown
size={20}
className={cn(
"group-data-[accordion=closed]/accordion:hidden text-nb-gray-400 shrink-0",
!hasTargets && "cursor-default opacity-0",
!hasExpandableTargets && "cursor-default opacity-0",
)}
/>
</>
@@ -57,13 +64,21 @@ export default function ReverseProxyNameCell({
)}
/>
<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">{displayDomain}</span>
)}
<div className="flex items-center gap-2">
{displayDomain && isLinkable ? (
<ExternalLinkText href={`https://${displayDomain}${portSuffix}`}>
<span className="font-medium truncate">
{displayDomain}
{portSuffix}
</span>
</ExternalLinkText>
) : (
<CopyToClipboardText>
{displayDomain}
{portSuffix}
</CopyToClipboardText>
)}
</div>
</div>
</div>
</div>

View File

@@ -1,17 +1,21 @@
import {
REVERSE_PROXY_TROUBLESHOOTING_DOCS_LINK,
ReverseProxy,
ReverseProxyMeta,
ReverseProxyStatus,
} from "@/interfaces/ReverseProxy";
import useFetchApi from "@utils/api";
import Badge from "@components/Badge";
import { Loader2 } from "lucide-react";
import { useMemo } from "react";
import FullTooltip from "@components/FullTooltip";
import InlineLink from "@components/InlineLink";
import { CircleAlert, Loader2 } from "lucide-react";
import { useRef } from "react";
type Props = {
serviceId: string;
meta?: ReverseProxyMeta;
enabled?: boolean;
isL4?: boolean;
};
const POLL_INTERVAL_MS = 3500;
@@ -20,39 +24,106 @@ export default function ReverseProxyStatusCell({
serviceId,
meta,
enabled,
isL4,
}: Readonly<Props>) {
const status = meta?.status;
const certificateIssued = !!meta?.certificate_issued_at;
const dataRef = useRef<ReverseProxy | undefined>(undefined);
const isSettingUp =
enabled &&
status !== undefined &&
status !== ReverseProxyStatus.ACTIVE &&
!certificateIssued;
const isActive =
meta?.status === ReverseProxyStatus.ACTIVE ||
dataRef.current?.meta?.status === ReverseProxyStatus.ACTIVE;
const hasError =
meta?.status === ReverseProxyStatus.ERROR ||
dataRef.current?.meta?.status === ReverseProxyStatus.ERROR;
const isTunnelNotCreated =
meta?.status === ReverseProxyStatus.TUNNEL_NOT_CREATED ||
dataRef.current?.meta?.status === ReverseProxyStatus.TUNNEL_NOT_CREATED;
const certificateIssued =
!!meta?.certificate_issued_at ||
!!dataRef.current?.meta?.certificate_issued_at;
const shouldPoll = !!enabled && !(isActive && (isL4 || certificateIssued));
const { data } = useFetchApi<ReverseProxy>(
`/reverse-proxies/services/${serviceId}`,
true,
false,
isSettingUp,
shouldPoll,
{ refreshInterval: POLL_INTERVAL_MS },
);
const currentStatus = data?.meta?.status ?? status;
dataRef.current = data;
const currentCertificateIssued = useMemo(() => {
if (data && data?.meta) return !!data?.meta?.certificate_issued_at;
return certificateIssued;
}, [data]);
if (!enabled) return null;
if (
!enabled ||
(currentStatus === ReverseProxyStatus.ACTIVE && currentCertificateIssued)
) {
// L4 services don't need certificates
if (isL4) {
if (isActive) return null;
if (hasError) {
return (
<FullTooltip
content={
<div className={"text-xs max-w-xs"}>
Something went wrong while setting up this service. See our{" "}
<InlineLink
href={REVERSE_PROXY_TROUBLESHOOTING_DOCS_LINK}
target={"_blank"}
>
Troubleshooting Docs
</InlineLink>{" "}
for more details.
</div>
}
align={"center"}
alignOffset={0}
>
<div className={"flex"}>
<Badge variant={"red"}>
<CircleAlert size={11} />
Error
</Badge>
</div>
</FullTooltip>
);
}
if (isTunnelNotCreated) {
return (
<FullTooltip
content={
<div className={"text-xs max-w-xs"}>
The tunnel to the target peer could not be established. See our{" "}
<InlineLink
href={REVERSE_PROXY_TROUBLESHOOTING_DOCS_LINK}
target={"_blank"}
>
Troubleshooting Docs
</InlineLink>{" "}
for more details.
</div>
}
align={"center"}
alignOffset={0}
>
<div className={"flex"}>
<Badge variant={"red"}>
<CircleAlert size={11} />
Tunnel not created
</Badge>
</div>
</FullTooltip>
);
}
return <SettingUpService />;
}
// HTTP services: hide once active with certificate issued
if (isActive && certificateIssued) {
return null;
}
if (!currentCertificateIssued) {
if (!certificateIssued) {
return (
<div className={"flex"}>
<Badge variant={"yellow"}>
@@ -63,6 +134,10 @@ export default function ReverseProxyStatusCell({
);
}
return <SettingUpService />;
}
const SettingUpService = () => {
return (
<div className={"flex"}>
<Badge variant={"yellow"}>
@@ -71,4 +146,4 @@ export default function ReverseProxyStatusCell({
</Badge>
</div>
);
}
};

View File

@@ -17,6 +17,7 @@ import { usePermissions } from "@/contexts/PermissionsProvider";
import { useReverseProxies } from "@/contexts/ReverseProxiesProvider";
import { useLocalStorage } from "@/hooks/useLocalStorage";
import {
isL4Mode,
REVERSE_PROXY_DOCS_LINK,
ReverseProxy,
} from "@/interfaces/ReverseProxy";
@@ -28,6 +29,7 @@ import ReverseProxyNameCell from "@/modules/reverse-proxy/table/ReverseProxyName
import ReverseProxyTargetsCell from "@/modules/reverse-proxy/table/ReverseProxyTargetsCell";
import ReverseProxyTargetsTable from "@/modules/reverse-proxy/targets/ReverseProxyTargetsTable";
import ReverseProxyStatusCell from "@/modules/reverse-proxy/table/ReverseProxyStatusCell";
import { ReverseProxyTypeCell } from "@/modules/reverse-proxy/table/ReverseProxyTypeCell";
const ReverseProxyColumns: ColumnDef<ReverseProxy>[] = [
{
@@ -38,6 +40,14 @@ const ReverseProxyColumns: ColumnDef<ReverseProxy>[] = [
sortingFn: "text",
cell: ({ row }) => <ReverseProxyNameCell reverseProxy={row.original} />,
},
{
accessorKey: "mode",
header: ({ column }) => {
return <DataTableHeader column={column}>Type</DataTableHeader>;
},
sortingFn: "text",
cell: ({ row }) => <ReverseProxyTypeCell reverseProxy={row.original} />,
},
{
id: "status",
accessorFn: (proxy) => proxy?.meta?.certificate_issued_at,
@@ -48,6 +58,7 @@ const ReverseProxyColumns: ColumnDef<ReverseProxy>[] = [
serviceId={row.original.id}
meta={row.original.meta}
enabled={row.original.enabled}
isL4={isL4Mode(row.original.mode)}
/>
) : null,
},
@@ -68,7 +79,7 @@ const ReverseProxyColumns: ColumnDef<ReverseProxy>[] = [
{
accessorKey: "targets",
header: ({ column }) => {
return <DataTableHeader column={column}>Targets</DataTableHeader>;
return <DataTableHeader column={column}>Target(s)</DataTableHeader>;
},
cell: ({ row }) => <ReverseProxyTargetsCell reverseProxy={row.original} />,
},
@@ -131,7 +142,9 @@ export default function ReverseProxyTable({ headingTarget }: Readonly<Props>) {
useRowId={true}
searchPlaceholder={"Search by URL, domain, or target..."}
columnVisibility={{ searchString: false }}
tableCellClassName={"h-[80px]"}
renderExpandedRow={(reverseProxy) => {
if (isL4Mode(reverseProxy.mode)) return;
const hasTargets = (reverseProxy?.targets?.length ?? 0) > 0;
if (!hasTargets) return;
return (
@@ -159,7 +172,6 @@ export default function ReverseProxyTable({ headingTarget }: Readonly<Props>) {
button={
<Button
variant={"primary"}
className={""}
onClick={() => openModal()}
disabled={!permission?.services?.create}
>

View File

@@ -4,7 +4,8 @@ import { PlusCircle, Server } from "lucide-react";
import * as React from "react";
import { usePermissions } from "@/contexts/PermissionsProvider";
import { useReverseProxies } from "@/contexts/ReverseProxiesProvider";
import { ReverseProxy } from "@/interfaces/ReverseProxy";
import { isL4Mode, ReverseProxy } from "@/interfaces/ReverseProxy";
import { ReverseProxyTargetDevice } from "@/modules/reverse-proxy/targets/ReverseProxyTargetDevice";
type Props = {
reverseProxy: ReverseProxy;
@@ -16,6 +17,22 @@ export default function ReverseProxyTargetsCell({
const { permission } = usePermissions();
const { openTargetModal } = useReverseProxies();
if (isL4Mode(reverseProxy.mode)) {
const target = reverseProxy?.targets?.[0];
const address = target.host
? `${target.host}:${target.port}`
: `:${target.port}`;
return (
<ReverseProxyTargetDevice
target={target}
address={address}
wrapperClassName={"h-[48px]"}
skeletonClassName={"h-[48px]"}
/>
);
}
const targetsCount = reverseProxy?.targets?.length ?? 0;
return (

View File

@@ -0,0 +1,71 @@
import * as React from "react";
import { useMemo } from "react";
import { ReverseProxy, ServiceMode } from "@/interfaces/ReverseProxy";
import { trim } from "lodash";
import { SERVICE_MODES } from "@/modules/reverse-proxy/ReverseProxyServiceModeSelector";
import Badge from "@components/Badge";
import { cn } from "@utils/helpers";
import { ArrowRightFromLineIcon, GlobeIcon, LockKeyhole } from "lucide-react";
type Props = {
reverseProxy?: ReverseProxy;
};
export const ReverseProxyTypeCell = ({ reverseProxy }: Props) => {
const serviceModeLabel = useMemo(() => {
if (!reverseProxy?.mode) return "HTTP/S";
const mode = SERVICE_MODES[reverseProxy.mode];
if (!mode) return "HTTP/S";
return trim(mode.label.replace("Service", ""));
}, [reverseProxy]);
return (
<div className={"flex"}>
<Badge variant={"gray"} className={"font-normal"}>
<ReverseProxyServiceIcon
reverseProxy={reverseProxy}
className={"text-nb-gray-200"}
size={11}
/>
{serviceModeLabel}
</Badge>
</div>
);
};
type ReverseProxyServiceIconProps = {
reverseProxy?: ReverseProxy;
className?: string;
size?: number;
};
export const ReverseProxyServiceIcon = ({
reverseProxy,
className,
size = 14,
}: ReverseProxyServiceIconProps) => {
const mode = reverseProxy?.mode;
switch (mode) {
case ServiceMode.HTTP:
return <GlobeIcon size={size} className={cn("shrink-0", className)} />;
case ServiceMode.TLS:
return <LockKeyhole size={size} className={cn("shrink-0", className)} />;
case ServiceMode.TCP:
return (
<ArrowRightFromLineIcon
size={size}
className={cn("shrink-0", className)}
/>
);
case ServiceMode.UDP:
return (
<ArrowRightFromLineIcon
size={size}
className={cn("shrink-0", className)}
/>
);
default:
return <GlobeIcon size={size} className={cn("shrink-0", className)} />;
}
};

View File

@@ -0,0 +1,100 @@
import { Input } from "@components/Input";
import React, { useMemo } from "react";
import cidr from "ip-cidr";
import type { Target } from "@/modules/reverse-proxy/targets/ReverseProxyTargetSelector";
import { ReverseProxyTargetType } from "@/interfaces/ReverseProxy";
import { useReverseProxies } from "@/contexts/ReverseProxiesProvider";
import { cn } from "@utils/helpers";
import { HelpTooltip } from "@components/HelpTooltip";
export function useReverseProxyAddress(target: Target | undefined) {
const { resources } = useReverseProxies();
const resource = useMemo(
() => resources?.find((r) => r.id === target?.resourceId),
[resources, target?.resourceId],
);
const resourceAddress = resource?.address || "";
const isCidrRange = useMemo(() => {
if (target?.type === ReverseProxyTargetType.SUBNET) return true;
if (!resourceAddress) return false;
if (!cidr.isValidCIDR(resourceAddress)) return false;
const parts = resourceAddress.split("/");
const mask = parts.length === 2 ? parseInt(parts[1], 10) : 32;
return mask < 32;
}, [target?.type, resourceAddress]);
const cidrInfo = useMemo(() => {
if (!resourceAddress) return null;
if (!cidr.isValidCIDR(resourceAddress)) return null;
try {
return new cidr(resourceAddress);
} catch {
return null;
}
}, [resourceAddress]);
const isHostEditable = isCidrRange;
const isHostInCidrRange = useMemo(() => {
if (!cidrInfo || !target?.host) return false;
if (!cidr.isValidAddress(target.host)) return false;
return cidrInfo.contains(target.host);
}, [cidrInfo, target?.host]);
const isValidCidrHost =
!isCidrRange ||
(!!target?.host && !!cidrInfo && isHostInCidrRange);
return {
resourceAddress,
cidrInfo,
isCidrRange,
isHostEditable,
isHostInCidrRange,
isValidCidrHost,
};
}
export function CidrHelpText({ target }: { target: Target | undefined }) {
const { cidrInfo, resourceAddress } = useReverseProxyAddress(target);
if (!cidrInfo) return null;
return (
<HelpTooltip content={`Enter an IP address within ${resourceAddress}`} />
);
}
type Props = {
value: Target | undefined;
onChange: React.Dispatch<React.SetStateAction<Target | undefined>>;
className?: string;
autoFocus?: boolean;
};
export default function ReverseProxyAddressInput({
value: target,
onChange,
className,
autoFocus,
}: Readonly<Props>) {
const { isHostEditable } = useReverseProxyAddress(target);
return (
<Input
value={target?.host ?? ""}
onChange={(e) => {
const host = isHostEditable
? e.target.value.replace(/[^0-9.]/g, "")
: e.target.value;
onChange((prev) => prev && { ...prev, host });
}}
maxWidthClass={"w-full"}
customSuffix={":"}
placeholder="e.g., 192.168.0.10"
disabled={!target}
readOnly={target && !isHostEditable ? true : undefined}
className={cn("rounded-r-none border-r-0", className)}
autoFocus={autoFocus}
/>
);
}

View File

@@ -0,0 +1,185 @@
import Button from "@components/Button";
import HelpText from "@components/HelpText";
import { Input } from "@components/Input";
import { Label } from "@components/Label";
import { MinusCircleIcon, PlusIcon } from "lucide-react";
import { useCallback, useState } from "react";
const HEADER_NAME_RE = /^[A-Za-z0-9!#$%&'*+\-.^_`|~]+$/;
const BLOCKED_HEADERS = new Set([
"host",
"connection",
"transfer-encoding",
"keep-alive",
"proxy-authenticate",
"proxy-authorization",
"te",
"trailer",
"upgrade",
]);
type HeaderEntry = { id: number; name: string; value: string };
function recordToHeaderEntries(
record: Record<string, string> | undefined,
nextId: () => number,
): HeaderEntry[] {
if (!record) return [];
return Object.entries(record).map(([name, value]) => ({
id: nextId(),
name,
value,
}));
}
export function headerEntriesToRecord(
entries: HeaderEntry[],
): Record<string, string> | undefined {
if (entries.length === 0) return undefined;
const record: Record<string, string> = {};
for (const entry of entries) {
if (entry.name) record[entry.name] = entry.value;
}
return Object.keys(record).length > 0 ? record : undefined;
}
function validateHeaderName(
name: string,
allNames: string[],
): string | undefined {
if (!name) return undefined;
if (!HEADER_NAME_RE.test(name))
return "Invalid characters in header name. Please use another one.";
if (BLOCKED_HEADERS.has(name.toLowerCase()))
return `"${name}" is a reserved header. Please use another one.`;
const dupeCount = allNames.filter(
(n) => n.toLowerCase() === name.toLowerCase(),
).length;
if (dupeCount > 1) return "Duplicate header name. Please use another one.";
return undefined;
}
function validateHeaderValue(value: string): string | undefined {
if (value.includes("\r") || value.includes("\n"))
return "Value must not contain line breaks";
return undefined;
}
export function useCustomHeaders(initialHeaders?: Record<string, string>) {
const [nextId] = useState(() => {
let id = 0;
return () => ++id;
});
const [headerEntries, setHeaderEntries] = useState<HeaderEntry[]>(() =>
recordToHeaderEntries(initialHeaders, nextId),
);
const addHeader = useCallback(() => {
setHeaderEntries((prev) => [
...prev,
{ id: nextId(), name: "", value: "" },
]);
}, [nextId]);
const removeHeader = useCallback((id: number) => {
setHeaderEntries((prev) => prev.filter((h) => h.id !== id));
}, []);
const updateHeaderEntry = useCallback(
(id: number, field: "name" | "value", fieldValue: string) => {
setHeaderEntries((prev) =>
prev.map((h) => (h.id === id ? { ...h, [field]: fieldValue } : h)),
);
},
[],
);
const allHeaderNames = headerEntries.map((h) => h.name);
const headerErrors = headerEntries.map((entry) => ({
name: validateHeaderName(entry.name, allHeaderNames),
value: validateHeaderValue(entry.value),
}));
const hasHeaderErrors = headerErrors.some((e) => e.name || e.value);
return {
headerEntries,
setHeaderEntries,
addHeader,
removeHeader,
updateHeaderEntry,
headerErrors,
hasHeaderErrors,
};
}
export type CustomHeadersProps = Pick<
ReturnType<typeof useCustomHeaders>,
| "headerEntries"
| "addHeader"
| "removeHeader"
| "updateHeaderEntry"
| "headerErrors"
>;
export default function ReverseProxyTargetCustomHeaders({
headerEntries,
addHeader,
removeHeader,
updateHeaderEntry,
headerErrors,
}: CustomHeadersProps) {
return (
<div>
<Label>Custom Headers</Label>
<HelpText>
Add additional headers to include when forwarding requests.
<br />
Hop-by-hop headers like Host or Connection are not allowed.
</HelpText>
{headerEntries.length > 0 && (
<div className="flex flex-col gap-2 mb-3">
{headerEntries.map((entry, index) => (
<div key={entry.id} className="flex items-center gap-2">
<Input
placeholder="Header, e.g., Authorization"
aria-label={`Header name for entry ${entry.id}`}
value={entry.name}
onChange={(e) =>
updateHeaderEntry(entry.id, "name", e.target.value)
}
maxWidthClass="flex-1"
error={headerErrors[index]?.name}
errorTooltip
/>
<Input
placeholder="Value, e.g., Bearer eyJhbGci..."
aria-label={`Header value for entry ${entry.id}`}
value={entry.value}
onChange={(e) =>
updateHeaderEntry(entry.id, "value", e.target.value)
}
maxWidthClass="flex-1"
error={headerErrors[index]?.value}
errorTooltip
/>
<Button
variant="default-outline"
className="!px-2 shrink-0"
onClick={() => removeHeader(entry.id)}
aria-label="Remove header"
>
<MinusCircleIcon size={14} />
</Button>
</div>
))}
</div>
)}
<Button variant="dotted" className="w-full" size="sm" onClick={addHeader}>
<PlusIcon size={14} />
Add Header
</Button>
</div>
);
}

View File

@@ -17,11 +17,19 @@ import EmptyRow from "@/modules/common-table-rows/EmptyRow";
type Props = {
target: ReverseProxyTarget;
showDescription?: boolean;
wrapperClassName?: string;
skeletonClassName?: string;
deviceClassName?: string;
address?: string;
};
export const ReverseProxyTargetDevice = ({
target,
showDescription,
wrapperClassName = "h-[59px]",
skeletonClassName = "min-h-[59px]",
deviceClassName = "",
address,
}: Props) => {
const router = useRouter();
const { data: peers, isLoading: isPeersLoading } =
@@ -55,17 +63,19 @@ export const ReverseProxyTargetDevice = ({
};
if (isPeersLoading || isResourceLoading || isNetworksLoading)
return <SkeletonDeviceCard />;
return <SkeletonDeviceCard className={skeletonClassName} />;
if (!peer && !resource)
return (
<div className={"min-h-[59px] flex items-center relative left-1"}>
<div
className={cn("flex items-center relative left-1", wrapperClassName)}
>
<EmptyRow />
</div>
);
return (
<div className={"min-h-[59px] flex items-center relative -left-2"}>
<div className={cn("flex items-center relative -left-2", wrapperClassName)}>
<div
className={cn(
"cursor-pointer rounded-md hover:bg-nb-gray-900/40 flex items-center justify-between group pr-4",
@@ -73,8 +83,13 @@ export const ReverseProxyTargetDevice = ({
onClick={handleClick}
>
<DeviceCard
address={address}
device={peer}
className={cn(!target.enabled && "opacity-40", "pl-2")}
className={cn(
!target.enabled && "opacity-40",
"pl-2",
deviceClassName,
)}
resource={resource}
description={showDescription ? resource?.description : undefined}
/>

View File

@@ -7,25 +7,22 @@ import {
AccordionTrigger,
} from "@components/Accordion";
import Button from "@components/Button";
import { Checkbox } from "@components/Checkbox";
import FancyToggleSwitch from "@components/FancyToggleSwitch";
import HelpText from "@components/HelpText";
import { Input } from "@components/Input";
import { Label } from "@components/Label";
import { Modal, ModalContent, ModalFooter } from "@components/modal/Modal";
import ModalHeader from "@components/modal/ModalHeader";
import { PeerGroupSelector } from "@components/PeerGroupSelector";
import { SelectDropdown } from "@components/select/SelectDropdown";
import useFetchApi from "@utils/api";
import {
AlertTriangle,
ClockFadingIcon,
ExternalLinkIcon,
HelpCircle,
PlusCircle,
Server,
Settings,
ShieldXIcon,
} from "lucide-react";
import { Callout } from "@components/Callout";
import cidr from "ip-cidr";
import React, { useMemo, useRef, useState } from "react";
import { Network, NetworkResource } from "@/interfaces/Network";
import { Peer } from "@/interfaces/Peer";
@@ -35,18 +32,27 @@ import {
ReverseProxyTarget,
ReverseProxyTargetProtocol,
ReverseProxyTargetType,
ServiceMode,
ServiceTargetOptionsPathRewrite,
} from "@/interfaces/ReverseProxy";
import {
defaultPortForProtocol,
isResourceTargetType,
} from "@/contexts/ReverseProxiesProvider";
import Separator from "@components/Separator";
import { cn } from "@utils/helpers";
import { HelpTooltip } from "@components/HelpTooltip";
import InlineLink, { InlineButtonLink } from "@components/InlineLink";
import SetupModal from "@/modules/setup-netbird-modal/SetupModal";
import FullTooltip from "@components/FullTooltip";
import InlineLink from "@components/InlineLink";
import Paragraph from "@components/Paragraph";
import ReverseProxyTargetCustomHeaders from "@/modules/reverse-proxy/targets/ReverseProxyTargetCustomHeaders";
import ReverseProxyTargetSelector, {
Target,
} from "@/modules/reverse-proxy/targets/ReverseProxyTargetSelector";
import { useReverseProxyTargetOptions } from "@/modules/reverse-proxy/targets/useReverseProxyTargetOptions";
import ReverseProxyAddressInput, {
CidrHelpText,
useReverseProxyAddress,
} from "@/modules/reverse-proxy/targets/ReverseProxyAddressInput";
import Separator from "@components/Separator";
/** Get initial host value based on target, resource, or peer */
function getInitialHost(
@@ -87,88 +93,44 @@ export default function ReverseProxyTargetModal({
}: Readonly<Props>) {
const existingTargets = reverseProxy.targets || [];
const domain = reverseProxy.domain;
// Fetch resources and peers for target selection
const { data: resources } = useFetchApi<NetworkResource[]>(
"/networks/resources",
);
const { data: peers } = useFetchApi<Peer[]>("/peers");
const [targetType, setTargetType] = useState<ReverseProxyTargetType>(
currentTarget?.target_type ??
(initialResource
? (initialResource.type as ReverseProxyTargetType) ??
ReverseProxyTargetType.HOST
: ReverseProxyTargetType.PEER),
);
const [targetPeerId, setTargetPeerId] = useState<string | undefined>(
currentTarget?.target_type === ReverseProxyTargetType.PEER
? currentTarget?.target_id
: initialPeer?.id,
);
const [targetResourceId, setTargetResourceId] = useState<string | undefined>(
currentTarget && isResourceTargetType(currentTarget.target_type)
? currentTarget?.target_id
: initialResource?.id,
const [target, setTarget] = useState<Target | undefined>(
currentTarget || initialResource || initialPeer
? {
type:
currentTarget?.target_type ??
(initialResource
? (initialResource.type as ReverseProxyTargetType) ??
ReverseProxyTargetType.HOST
: ReverseProxyTargetType.PEER),
peerId:
currentTarget?.target_type === ReverseProxyTargetType.PEER
? currentTarget?.target_id
: initialPeer?.id,
resourceId:
currentTarget && isResourceTargetType(currentTarget.target_type)
? currentTarget?.target_id
: initialResource?.id,
host: getInitialHost(currentTarget, initialResource, initialPeer),
}
: undefined,
);
const [targetProtocol, setTargetProtocol] =
useState<ReverseProxyTargetProtocol>(
currentTarget?.protocol ?? ReverseProxyTargetProtocol.HTTP,
);
const [targetHost, setTargetHost] = useState(
getInitialHost(currentTarget, initialResource, initialPeer),
);
const [targetPort, setTargetPort] = useState<number>(
currentTarget?.port ?? 0,
);
const [targetPath, setTargetPath] = useState(currentTarget?.path ?? "");
const [accessLocal, setAccessLocal] = useState(
currentTarget?.access_local ?? false,
);
const [accessLocal] = useState(currentTarget?.access_local ?? false);
const [options, setOption, { getTargetOptions, headers, errors }] =
useReverseProxyTargetOptions(currentTarget?.options);
const portInputRef = useRef<HTMLInputElement>(null);
const [installModal, setInstallModal] = useState(false);
// Get the current resource's address (from initialResource or selected resource)
const currentResourceAddress = useMemo(() => {
if (initialResource) return initialResource.address;
if (targetResourceId) {
const resource = resources?.find((r) => r.id === targetResourceId);
return resource?.address || "";
}
return "";
}, [initialResource, targetResourceId, resources]);
// Parse the CIDR using ip-cidr library
const cidrInfo = useMemo(() => {
if (!currentResourceAddress) return null;
if (!cidr.isValidCIDR(currentResourceAddress)) return null;
try {
return new cidr(currentResourceAddress);
} catch {
return null;
}
}, [currentResourceAddress]);
// Get the CIDR mask (e.g., 24 for /24)
const cidrMask = useMemo(() => {
if (!cidrInfo) return null;
const parts = currentResourceAddress.split("/");
return parts.length === 2 ? parseInt(parts[1], 10) : 32;
}, [cidrInfo, currentResourceAddress]);
// Check if address is a CIDR range (has more than one address)
const isCidrRange = useMemo(() => {
return cidrMask !== null && cidrMask < 32;
}, [cidrMask]);
// Host should be editable if it's a CIDR range with multiple addresses
const isHostEditable = isCidrRange;
// Validate if current host is within CIDR range
const isHostInCidrRange = useMemo(() => {
if (!cidrInfo || !targetHost) return false;
if (!cidr.isValidAddress(targetHost)) return false;
return cidrInfo.contains(targetHost);
}, [cidrInfo, targetHost]);
const { isCidrRange, isHostEditable, isValidCidrHost } =
useReverseProxyAddress(target);
// Normalize path for comparison (ensure it starts with / and handle empty as /)
const normalizePath = (path: string | undefined) => {
@@ -196,7 +158,6 @@ export default function ReverseProxyTargetModal({
const isValidPort =
targetPort === 0 || (targetPort >= 1 && targetPort <= 65535);
const isValidCidrHost = !isCidrRange || (targetHost && isHostInCidrRange);
const canAddTarget = useMemo(() => {
// Don't allow if path is duplicate or port is invalid
@@ -209,11 +170,12 @@ export default function ReverseProxyTargetModal({
if (initialPeer) {
return true;
}
if (targetType === ReverseProxyTargetType.PEER) {
return !!targetPeerId;
if (!target) return false;
if (target.type === ReverseProxyTargetType.PEER) {
return !!target.peerId;
}
if (isResourceTargetType(targetType)) {
return !!targetResourceId && isValidCidrHost;
if (isResourceTargetType(target.type)) {
return !!target.resourceId && isValidCidrHost;
}
return false;
}, [
@@ -221,43 +183,44 @@ export default function ReverseProxyTargetModal({
isValidPort,
initialResource,
initialPeer,
targetType,
targetPeerId,
targetResourceId,
target,
isValidCidrHost,
]);
const hasTarget =
initialResource || initialPeer || targetPeerId || targetResourceId;
const hasTarget = !!(initialResource || initialPeer || target);
const handleSave = () => {
const resolvedType = initialPeer ? ReverseProxyTargetType.PEER : targetType;
const isResource = isResourceTargetType(resolvedType) || !!initialResource;
if (!target) return;
const resolvedType = initialPeer
? ReverseProxyTargetType.PEER
: target.type;
const resolvedIsResource =
isResourceTargetType(resolvedType) || !!initialResource;
const targetData: ReverseProxyTarget = {
target_type: resolvedType,
target_id:
resolvedType === ReverseProxyTargetType.PEER
? targetPeerId
: targetResourceId,
? target.peerId
: target.resourceId,
protocol: targetProtocol,
host:
resolvedType === ReverseProxyTargetType.SUBNET ? targetHost : undefined,
resolvedType === ReverseProxyTargetType.SUBNET
? target.host
: undefined,
port: targetPort,
path: targetPath || undefined,
enabled: currentTarget?.enabled ?? true,
access_local: isResource ? accessLocal : undefined,
access_local: resolvedIsResource ? accessLocal : undefined,
options: getTargetOptions(),
};
onSave(targetData);
onOpenChange(false);
};
const showAdvancedSettings = false;
// const showAdvancedSettings = !!hasTarget && (isResourceTargetType(targetType) || !!initialResource);
return (
<>
<Modal open={open} onOpenChange={onOpenChange}>
<ModalContent maxWidthClass="max-w-xl">
<ModalContent maxWidthClass="max-w-2xl">
<ModalHeader
icon={<Server className="text-netbird" size={16} />}
title={currentTarget ? "Edit Target" : "Add Target"}
@@ -267,141 +230,18 @@ export default function ReverseProxyTargetModal({
<Separator />
<div
className={cn(
"px-8 py-6 pt-4 flex flex-col gap-6",
!showAdvancedSettings && "mb-3",
)}
>
<div className="px-8 pt-5 pb-4 flex flex-col gap-6">
{!initialResource && !initialPeer && (
<div>
<Label className={"gap-0 inline"}>
{initialNetwork ? (
"Select Resource"
) : (
<>
Select{" "}
<HelpTooltip
content={
<div className="max-w-sm text-xs">
A{" "}
<span className={"text-white font-medium"}>
peer
</span>{" "}
is a machine (e.g., laptop, server, container)
running NetBird. Select a peer if your service runs
directly on it.
<span className={"mt-1 block"}>
If you don&apos;t have a peer yet, you can{" "}
<InlineButtonLink
onClick={() => setInstallModal(true)}
>
Install NetBird
</InlineButtonLink>
.
</span>
</div>
}
>
Peer
</HelpTooltip>{" "}
or{" "}
<HelpTooltip
content={
<div className="max-w-sm text-xs">
A{" "}
<span className={"text-white font-medium"}>
resource
</span>{" "}
is a destination (IP, subnet, or domain) that
can&apos;t run NetBird directly. Resources are part
of a network and are reached through a routing peer
that forwards traffic to them.
<span className={"mt-1 block"}>
If you don&apos;t have resources yet, go to{" "}
<InlineLink href={"/networks"}>
Networks
</InlineLink>{" "}
to create some.
</span>
</div>
}
>
Resource
</HelpTooltip>
</>
)}
</Label>
<HelpText>
{initialNetwork
? "Select the resource from your network you want to expose."
: "Select the peer where your service is running or select a resource to expose it."}
</HelpText>
<PeerGroupSelector
values={[]}
onChange={() => {}}
placeholder={
initialNetwork
? "Select a resource..."
: "Select a peer or resource..."
<ReverseProxyTargetSelector
value={target}
initialNetwork={initialNetwork}
onChange={(selection) => {
setTarget(selection);
if (selection) {
setTimeout(() => portInputRef.current?.focus(), 0);
}
showPeers={!initialNetwork}
showResources={true}
showRoutes={false}
hideAllGroup={true}
hideGroupsTab={true}
resourceIds={
initialNetwork ? initialNetwork.resources ?? [] : undefined
}
tabOrder={
initialNetwork ? ["resources"] : ["peers", "resources"]
}
closeOnSelect={true}
max={1}
resource={
isResourceTargetType(targetType) && targetResourceId
? { id: targetResourceId, type: "host" }
: targetType === ReverseProxyTargetType.PEER &&
targetPeerId
? { id: targetPeerId, type: "peer" }
: undefined
}
onResourceChange={(res) => {
if (res) {
if (res.type === "peer") {
setTargetType(ReverseProxyTargetType.PEER);
setTargetPeerId(res.id);
setTargetResourceId(undefined);
const peer = peers?.find((p) => p.id === res.id);
setTargetHost(peer?.ip || "localhost");
} else {
const selectedResource = resources?.find(
(r) => r.id === res.id,
);
setTargetType(
(selectedResource?.type as ReverseProxyTargetType) ??
ReverseProxyTargetType.HOST,
);
setTargetResourceId(res.id);
setTargetPeerId(undefined);
const address = selectedResource?.address || "";
// If CIDR range, pre-fill with base IP
if (address.includes("/")) {
setTargetHost(address.split("/")[0]);
} else {
setTargetHost(address);
}
}
setTimeout(() => portInputRef.current?.focus(), 0);
} else {
setTargetPeerId(undefined);
setTargetResourceId(undefined);
setTargetHost("");
}
}}
/>
</div>
}}
/>
)}
<div>
@@ -430,6 +270,9 @@ export default function ReverseProxyTargetModal({
value = "/" + value;
}
setTargetPath(value);
if (!value || value === "/") {
setOption("path_rewrite", undefined);
}
}}
/>
</div>
@@ -444,128 +287,231 @@ export default function ReverseProxyTargetModal({
/>
}
>
Please use a different location. This location is already used
by another target and cannot be added.
This location is already used by another target and cannot be
added. <br /> Please use a different location.
</Callout>
)}
</div>
<div className="flex gap-3 mt-1">
<div className="flex-1">
<Label>Protocol & Host / IP</Label>
{cidrInfo && (
<HelpText className="!mt-1">
Enter an IP address within {currentResourceAddress}
</HelpText>
)}
<div className="flex items-center mt-2">
<div className="w-[120px]">
<SelectDropdown
value={targetProtocol}
onChange={(v) =>
setTargetProtocol(v as ReverseProxyTargetProtocol)
}
options={[
{
value: ReverseProxyTargetProtocol.HTTP,
label: "http://",
},
{
value: ReverseProxyTargetProtocol.HTTPS,
label: "https://",
},
]}
className="!rounded-r-none !border-r-0"
disabled={!hasTarget}
/>
</div>
<div className="flex-1">
<Input
value={targetHost}
onChange={(e) => {
// Only allow valid IP characters for CIDR ranges
const value = isHostEditable
? e.target.value.replace(/[^0-9.]/g, "")
: e.target.value;
setTargetHost(value);
}}
placeholder="e.g., 192.168.0.10"
className="!rounded-l-none"
disabled={!hasTarget}
readOnly={hasTarget && !isHostEditable ? true : undefined}
autoFocus={!!initialResource && isHostEditable}
/>
</div>
</div>
</div>
<div className="w-[150px]">
<Label>
Port
<FullTooltip
content={
<div className={"text-xs max-w-xs"}>
Enter the port where your service (e.g., webserver, app,
API) is currently listening. If left empty, defaults to
port 80 for HTTP or 443 for HTTPS.
{targetPath &&
targetPath !== "/" &&
hasTarget &&
!isPathDuplicate && (
<FancyToggleSwitch
value={options.path_rewrite === "preserve"}
onChange={(v) =>
setOption(
"path_rewrite",
v
? ("preserve" as ServiceTargetOptionsPathRewrite)
: undefined,
)
}
className={"mt-3.5"}
label={
<>
Preserve Full Path
<HelpTooltip
content={
<div className="text-xs max-w-xs flex flex-col gap-2">
<div>
When disabled, a request to e.g.,{" "}
<span className="font-mono text-white">
{targetPath}/users
</span>{" "}
is forwarded as{" "}
<span className="font-mono text-white">
/users
</span>
.
</div>
<div>
When enabled, a request to e.g.,{" "}
<span className="font-mono text-white">
{targetPath}/users
</span>{" "}
is forwarded as{" "}
<span className="font-mono text-white">
{targetPath}/users
</span>
.
</div>
</div>
}
/>
</>
}
helpText={
<div>
Keep the original full request path when forwarding.{" "}
<br />
When disabled the matched prefix path is stripped.
</div>
}
>
<HelpCircle
size={12}
className="cursor-help hover:text-nb-gray-100 transition-colors"
/>
</FullTooltip>
</Label>
{cidrInfo && <HelpText className="!mt-1">&nbsp;</HelpText>}
<div className="mt-2">
<Input
ref={portInputRef}
type="number"
value={targetPort === 0 ? "" : targetPort}
onChange={(e) =>
setTargetPort(parseInt(e.target.value) || 0)
}
placeholder={String(defaultPortForProtocol(targetProtocol))}
min={0}
max={65535}
disabled={!hasTarget}
autoFocus={!!initialResource && !isHostEditable}
/>
</div>
</div>
)}
</div>
{showAdvancedSettings && (
<Accordion type="single" collapsible>
<AccordionItem value="advanced">
<AccordionTrigger className="text-sm text-nb-gray-300">
<div className="flex items-center gap-2">
<Settings size={14} />
Advanced Settings
<div>
<div className="flex mt-1">
<div className="flex-1">
<Label>
Protocol & Host / IP
<CidrHelpText target={target} />
</Label>
<div className="flex items-center mt-2">
<div className="w-[120px]">
<SelectDropdown
value={targetProtocol}
onChange={(v) => {
const proto = v as ReverseProxyTargetProtocol;
setTargetProtocol(proto);
if (proto !== ReverseProxyTargetProtocol.HTTPS) {
setOption("skip_tls_verify", undefined);
}
}}
options={[
{
value: ReverseProxyTargetProtocol.HTTP,
label: "http://",
},
{
value: ReverseProxyTargetProtocol.HTTPS,
label: "https://",
},
]}
className="!rounded-r-none !border-r-0"
disabled={!hasTarget}
/>
</div>
</AccordionTrigger>
<AccordionContent>
<div className="flex flex-col gap-4 mt-4">
<label className="flex items-start gap-3 cursor-pointer">
<Checkbox
checked={accessLocal}
onCheckedChange={(v) => setAccessLocal(v === true)}
/>
<div className="flex-1">
<ReverseProxyAddressInput
value={target}
onChange={setTarget}
autoFocus={!!initialResource && isHostEditable}
className="!rounded-l-none"
/>
</div>
</div>
</div>
<div className="w-[150px]">
<Label>
Port
<HelpTooltip
content={
"Enter the port where your service (e.g., webserver, app, API) is currently listening. If left empty, defaults to port 80 for HTTP or 443 for HTTPS."
}
/>
</Label>
<div className="mt-2">
<Input
ref={portInputRef}
type="number"
className={"rounded-l-none"}
value={targetPort === 0 ? "" : targetPort}
onChange={(e) =>
setTargetPort(parseInt(e.target.value) || 0)
}
placeholder={String(
defaultPortForProtocol(targetProtocol),
)}
min={0}
max={65535}
disabled={!hasTarget}
autoFocus={!!initialResource && !isHostEditable}
/>
</div>
</div>
</div>
{targetProtocol === ReverseProxyTargetProtocol.HTTPS &&
hasTarget && (
<FancyToggleSwitch
className={"mt-3.5"}
value={options.skip_tls_verify ?? false}
onChange={(v) =>
setOption("skip_tls_verify", v || undefined)
}
label={
<>
<ShieldXIcon size={15} />
Skip TLS Verification
</>
}
helpText="Skip certificate verification when connecting to this target. Useful if your service already uses a self-signed certificate."
/>
)}
</div>
<Accordion
type={"multiple"}
className={"flex flex-col gap-2 -mt-2"}
>
<AccordionItem value={"optional-settings"}>
<AccordionTrigger
className={
"text-[0.8rem] tracking-wider text-nb-gray-200 py-4 my-0 leading-none gap-2 flex items-center"
}
>
<span className={"relative top-[1px]"}>
Optional Settings
</span>
</AccordionTrigger>
<AccordionContent>
<div className={"flex flex-col gap-8 pb-6 pt-2"}>
<div className={"flex items-center justify-between"}>
<div>
<Label>Request Timeout</Label>
<HelpText className={"mb-0"}>
Max time to wait for a response as duration string
(max 5m). <br /> Leave this field empty for no
timeout.
</HelpText>
</div>
<Input
customPrefix={<ClockFadingIcon size={16} />}
placeholder="e.g. 10s, 30s, 1m"
value={options.request_timeout ?? ""}
onChange={(e) =>
setOption(
"request_timeout",
e.target.value || undefined,
)
}
maxWidthClass="w-[180px]"
errorTooltip={true}
error={errors.timeout}
/>
</div>
{reverseProxy.mode === ServiceMode.UDP && (
<div className={"flex items-center justify-between"}>
<div>
<Label className="!mb-0" as={"div"}>
This is the routing peer
</Label>
<HelpText className="!mt-1">
Enable if the service runs directly on the routing
peer rather than behind it.
<Label>Session Idle Timeout</Label>
<HelpText className={"mb-0"}>
How long a UDP session stays alive without traffic
(max 10m). <br /> Defaults to 30s when empty.
</HelpText>
</div>
</label>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
)}
<Input
customPrefix={<ClockFadingIcon size={16} />}
placeholder="e.g. 30s, 2m, 5m"
value={options.session_idle_timeout ?? ""}
onChange={(e) =>
setOption(
"session_idle_timeout",
e.target.value || undefined,
)
}
maxWidthClass="w-[180px]"
errorTooltip={true}
error={errors.sessionIdleTimeout}
/>
</div>
)}
<ReverseProxyTargetCustomHeaders {...headers} />
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
<ModalFooter className={"items-center"}>
@@ -588,7 +534,7 @@ export default function ReverseProxyTargetModal({
<Button
variant="primary"
onClick={handleSave}
disabled={!canAddTarget}
disabled={!canAddTarget || errors.options}
>
{currentTarget ? (
"Save Changes"
@@ -603,10 +549,6 @@ export default function ReverseProxyTargetModal({
</ModalFooter>
</ModalContent>
</Modal>
<Modal open={installModal} onOpenChange={setInstallModal}>
<SetupModal />
</Modal>
</>
);
}

View File

@@ -0,0 +1,151 @@
"use client";
import HelpText from "@components/HelpText";
import { Label } from "@components/Label";
import { Modal } from "@components/modal/Modal";
import { PeerGroupSelector } from "@components/PeerGroupSelector";
import React, { useState } from "react";
import { Network } from "@/interfaces/Network";
import { ReverseProxyTargetType } from "@/interfaces/ReverseProxy";
import {
isResourceTargetType,
useReverseProxies,
} from "@/contexts/ReverseProxiesProvider";
import { HelpTooltip } from "@components/HelpTooltip";
import InlineLink, { InlineButtonLink } from "@components/InlineLink";
import SetupModal from "@/modules/setup-netbird-modal/SetupModal";
export type Target = {
type: ReverseProxyTargetType;
peerId?: string;
resourceId?: string;
host: string;
};
type Props = {
value?: Target;
initialNetwork?: Network;
onChange: (value: Target | undefined) => void;
};
export default function ReverseProxyTargetSelector({
value,
initialNetwork,
onChange,
}: Readonly<Props>) {
const { resources, peers } = useReverseProxies();
const [installModal, setInstallModal] = useState(false);
return (
<div>
<Label className={"gap-0 inline"}>
{initialNetwork ? (
"Select Resource"
) : (
<>
Select{" "}
<HelpTooltip
className={"max-w-sm"}
content={
<>
A <span className={"text-white font-medium"}>peer</span> is a
machine (e.g., laptop, server, container) running NetBird.
Select a peer if your service runs directly on it.
<span className={"mt-1 block"}>
If you don&apos;t have a peer yet, you can{" "}
<InlineButtonLink onClick={() => setInstallModal(true)}>
Install NetBird
</InlineButtonLink>
.
</span>
</>
}
interactive={true}
>
Peer
</HelpTooltip>{" "}
or{" "}
<HelpTooltip
className={"max-w-sm"}
content={
<>
A <span className={"text-white font-medium"}>resource</span>{" "}
is a destination (IP, subnet, or domain) that can&apos;t run
NetBird directly. Resources are part of a network and are
reached through a routing peer that forwards traffic to them.
<span className={"mt-1 block"}>
If you don&apos;t have resources yet, go to{" "}
<InlineLink href={"/networks"}>Networks</InlineLink> to
create some.
</span>
</>
}
interactive={true}
>
Resource
</HelpTooltip>
</>
)}
</Label>
<HelpText>
{initialNetwork
? "Select the resource from your network you want to expose."
: "Select the peer or resource where your service is running."}
</HelpText>
<PeerGroupSelector
values={[]}
onChange={() => {}}
placeholder={
initialNetwork
? "Select a resource..."
: "Select a peer or resource..."
}
showPeers={!initialNetwork}
showResources={true}
showRoutes={false}
hideAllGroup={true}
hideGroupsTab={true}
resourceIds={
initialNetwork ? initialNetwork.resources ?? [] : undefined
}
tabOrder={initialNetwork ? ["resources"] : ["peers", "resources"]}
closeOnSelect={true}
max={1}
resource={
value?.type && isResourceTargetType(value.type) && value.resourceId
? { id: value.resourceId, type: value.type }
: value?.type === ReverseProxyTargetType.PEER && value.peerId
? { id: value.peerId, type: "peer" }
: undefined
}
onResourceChange={(res) => {
if (res) {
if (res.type === "peer") {
const peer = peers?.find((p) => p.id === res.id);
onChange({
type: ReverseProxyTargetType.PEER,
peerId: res.id,
host: peer?.ip || "localhost",
});
} else {
const selectedResource = resources?.find((r) => r.id === res.id);
const address = selectedResource?.address || "";
onChange({
type:
(selectedResource?.type as ReverseProxyTargetType) ??
ReverseProxyTargetType.HOST,
resourceId: res.id,
host: address.includes("/") ? address.split("/")[0] : address,
});
}
} else {
onChange(undefined);
}
}}
/>
<Modal open={installModal} onOpenChange={setInstallModal}>
<SetupModal />
</Modal>
</div>
);
}

View File

@@ -74,6 +74,7 @@ export default function ReverseProxyTargetsTable({ reverseProxy }: Props) {
className={"bg-nb-gray-960 py-2"}
inset={true}
text={"Targets"}
initialPageSize={reverseProxy?.targets?.length}
manualPagination={true}
sorting={sorting}
columnVisibility={{}}

View File

@@ -9,7 +9,7 @@ import { MoreVertical, Settings, SquarePenIcon, Trash2 } from "lucide-react";
import * as React from "react";
import { usePermissions } from "@/contexts/PermissionsProvider";
import { useReverseProxies } from "@/contexts/ReverseProxiesProvider";
import { ReverseProxyFlatTarget } from "@/interfaces/ReverseProxy";
import { isL4Mode, ReverseProxyFlatTarget } from "@/interfaces/ReverseProxy";
type Props = {
target: ReverseProxyFlatTarget;
@@ -40,7 +40,11 @@ export default function ReverseProxyFlatTargetActionCell({
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
openTargetModal({ proxy: target.proxy, target: target });
if (isL4Mode(target.proxy.mode)) {
openModal({ proxy: target.proxy });
} else {
openTargetModal({ proxy: target.proxy, target: target });
}
}}
disabled={!permission?.services?.update}
>
@@ -57,9 +61,9 @@ export default function ReverseProxyFlatTargetActionCell({
}}
disabled={!permission?.services?.update}
>
<div className={"flex gap-3 items-center"}>
<div className={"flex gap-3 items-center pr-6"}>
<Settings size={14} className={"shrink-0"} />
Settings
Advanced Settings
</div>
</DropdownMenuItem>

View File

@@ -44,14 +44,15 @@ const FlatTargetsTableColumns: ColumnDef<ReverseProxyFlatTarget>[] = [
: `/${target.path}`
: "";
const fullUrl = `${target.proxy.domain}${path}`;
const disabled = target.enabled === false;
const isEnabled = target.proxy.enabled && target.enabled !== false;
const disabled = !target.enabled;
const isEnabled = target.proxy.enabled && target.enabled;
return (
<div className={disabled ? "opacity-40" : ""}>
<ReverseProxyNameCell
domain={fullUrl}
enabled={isEnabled}
reverseProxy={row.original.proxy}
showChevron={false}
/>
</div>
@@ -62,7 +63,7 @@ const FlatTargetsTableColumns: ColumnDef<ReverseProxyFlatTarget>[] = [
accessorKey: "arrow",
header: "",
cell: ({ row }) => (
<ReverseProxyArrowCell disabled={row.original.enabled === false} />
<ReverseProxyArrowCell disabled={!row.original.enabled} />
),
},
{
@@ -120,7 +121,13 @@ const FlatTargetsTableColumns: ColumnDef<ReverseProxyFlatTarget>[] = [
{
id: "searchString",
accessorFn: (row) => {
return [row.proxy.domain, row.destination, row.host, row.port, row.path].join("");
return [
row.proxy.domain,
row.destination,
row.host,
row.port,
row.path,
].join("");
},
},
];

View File

@@ -0,0 +1,116 @@
import { useCallback, useState } from "react";
import { ServiceTargetOptions } from "@/interfaces/ReverseProxy";
import {
headerEntriesToRecord,
useCustomHeaders,
} from "@/modules/reverse-proxy/targets/ReverseProxyTargetCustomHeaders";
// Go time.ParseDuration format: one or more {number}{unit} pairs
const DURATION_RE = /^(\d+(\.\d+)?(ns|us|µs|ms|s|m|h))+$/;
const MAX_TIMEOUT_MS = 5 * 60 * 1000; // 5m
const MAX_SESSION_IDLE_TIMEOUT_MS = 10 * 60 * 1000; // 10m
function parseDurationMs(duration: string): number {
const units: Record<string, number> = {
ns: 1e-6,
us: 1e-3,
µs: 1e-3,
ms: 1,
s: 1000,
m: 60_000,
h: 3_600_000,
};
let total = 0;
for (const [, val, , unit] of duration.matchAll(
/(\d+(\.\d+)?)(ns|us|µs|ms|s|m|h)/g,
)) {
total += parseFloat(val) * units[unit];
}
return total;
}
export function validateTimeout(timeout: string): string | undefined {
if (!timeout) return undefined;
if (!DURATION_RE.test(timeout))
return 'Invalid duration, use e.g., "10s", "30s", "1m"';
if (parseDurationMs(timeout) > MAX_TIMEOUT_MS)
return "Timeout cannot exceed the maximum of 5m.";
return undefined;
}
export function validateSessionIdleTimeout(timeout: string): string | undefined {
if (!timeout) return undefined;
if (!DURATION_RE.test(timeout))
return 'Invalid duration, use e.g., "30s", "2m", "5m"';
if (parseDurationMs(timeout) > MAX_SESSION_IDLE_TIMEOUT_MS)
return "Session idle timeout cannot exceed the maximum of 10m.";
return undefined;
}
export function useReverseProxyTargetOptions(
initialOptions?: ServiceTargetOptions,
) {
const [targetOptions, setTargetOptions] = useState<ServiceTargetOptions>(
() => {
const { custom_headers: _, ...rest } = initialOptions ?? {};
return rest;
},
);
const {
headerEntries,
addHeader,
removeHeader,
updateHeaderEntry,
headerErrors,
hasHeaderErrors,
} = useCustomHeaders(initialOptions?.custom_headers);
const updateOption = useCallback(
<K extends keyof ServiceTargetOptions>(
key: K,
value: ServiceTargetOptions[K],
) => {
setTargetOptions((prev) => ({ ...prev, [key]: value }));
},
[],
);
const timeoutError = validateTimeout(targetOptions.request_timeout ?? "");
const sessionIdleTimeoutError = validateSessionIdleTimeout(
targetOptions.session_idle_timeout ?? "",
);
const hasOptionsErrors =
!!timeoutError || !!sessionIdleTimeoutError || hasHeaderErrors;
const getTargetOptions = useCallback((): ServiceTargetOptions | undefined => {
const customHeaders = headerEntriesToRecord(headerEntries);
const merged: ServiceTargetOptions = {
...targetOptions,
custom_headers: customHeaders,
};
const hasOptions = Object.values(merged).some((v) => v !== undefined);
return hasOptions ? merged : undefined;
}, [targetOptions, headerEntries]);
return [
targetOptions,
updateOption,
{
getTargetOptions,
headers: {
headerEntries,
addHeader,
removeHeader,
updateHeaderEntry,
headerErrors,
hasHeaderErrors,
},
errors: {
timeout: timeoutError,
sessionIdleTimeout: sessionIdleTimeoutError,
options: hasOptionsErrors,
},
},
] as const;
}

View File

@@ -11,6 +11,7 @@ import {
SelectDropdown,
SelectOption,
} from "@components/select/SelectDropdown";
import { Callout } from "@components/Callout";
import { useHasChanges } from "@hooks/useHasChanges";
import * as Tabs from "@radix-ui/react-tabs";
import { useApiCall } from "@utils/api";
@@ -20,6 +21,7 @@ import {
ExternalLinkIcon,
FlaskConicalIcon,
MonitorSmartphoneIcon,
AlertTriangle,
RefreshCcw,
} from "lucide-react";
import React, { useMemo, useState } from "react";
@@ -84,6 +86,10 @@ function ClientSettingsTabContent({ account }: Readonly<Props>) {
isCustomVersion ? autoUpdateSetting : "",
);
const [autoUpdateAlways, setAutoUpdateAlways] = useState(
account.settings?.auto_update_always ?? false,
);
const [peerExposeEnabled, setPeerExposeEnabled] = useState<boolean>(
account?.settings?.peer_expose_enabled ?? false,
);
@@ -99,6 +105,7 @@ function ClientSettingsTabContent({ account }: Readonly<Props>) {
const { hasChanges, updateRef } = useHasChanges([
autoUpdateMethod,
autoUpdateCustomVersion,
autoUpdateAlways,
peerExposeEnabled,
peerExposeGroupNames,
]);
@@ -155,6 +162,7 @@ function ClientSettingsTabContent({ account }: Readonly<Props>) {
settings: {
...account.settings,
auto_update_version: autoUpdateCustomVersion || autoUpdateMethod,
auto_update_always: autoUpdateAlways,
peer_expose_enabled: peerExposeEnabled,
peer_expose_groups: peerExposeGroupIds,
},
@@ -164,6 +172,7 @@ function ClientSettingsTabContent({ account }: Readonly<Props>) {
updateRef([
autoUpdateMethod,
autoUpdateCustomVersion,
autoUpdateAlways,
peerExposeEnabled,
peerExposeGroupNames,
]);
@@ -235,9 +244,9 @@ function ClientSettingsTabContent({ account }: Readonly<Props>) {
/>
</Label>
<HelpText>
Select how NetBird clients handle automatic updates by choosing
the latest version, a custom version, or disabling updates
altogether. Automatic Updates require at least NetBird{" "}
Configure how NetBird clients receive update notifications.
When enabled, users will be prompted to install the selected
version. This requires at least NetBird{" "}
<span className={"text-white font-medium"}>v0.61.0</span>.{" "}
<InlineLink
href={"https://docs.netbird.io/manage/peers/auto-update"}
@@ -265,6 +274,39 @@ function ClientSettingsTabContent({ account }: Readonly<Props>) {
}}
/>
</div>
<FancyToggleSwitch
className={"mt-4"}
value={autoUpdateAlways}
onChange={setAutoUpdateAlways}
label={
<>
<AlertTriangle size={15} className={"text-yellow-400"} />
Force Automatic Updates
</>
}
helpText={
"When enabled, updates are installed automatically in the background without user interaction."
}
disabled={
!permission.settings.update || autoUpdateMethod === "disabled"
}
/>
{autoUpdateAlways && autoUpdateMethod !== "disabled" && (
<Callout
className={"mt-3"}
variant={"warning"}
icon={
<AlertTriangle
size={14}
className={"shrink-0 relative top-[3px]"}
/>
}
>
Enabling automatic updates will restart the NetBird client
during updates, which can temporarily disrupt active
connections. Use with caution in production environments.
</Callout>
)}
</div>
<div>

View File

@@ -257,3 +257,16 @@ export const singularize = (
}
return count + " " + word;
};
/**
* Converts milliseconds to human-readable duration (ms, s, m)
* @param ms Duration in milliseconds
* @returns Formatted string with appropriate unit
*/
export const formatDuration = (ms: number): string => {
if (!Number.isFinite(ms) || ms < 0) return "0ms";
if (ms < 1000) return `${ms}ms`;
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
if (ms < 3600000) return `${(ms / 60000).toFixed(1)}m`;
return `${(ms / 3600000).toFixed(1)}h`;
};