From c6341e000f569268ea7da9cbe0137ba31fb574c2 Mon Sep 17 00:00:00 2001 From: raghvendra <35917821+fork-boy@users.noreply.github.com> Date: Mon, 9 Feb 2026 09:11:09 +0530 Subject: [PATCH 1/4] docs: fix broken Auth0 quickstart link in README (#548) * docs: fix broken Auth0 quickstart link * docs: spell error fixes in readme * docs: fix typo in NETBIRD_MGMT_API_ENDPOINT placeholder in readme --- README.md | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index a134fae0..6c5ddb5d 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ See [NetBird repo](https://github.com/netbirdio/netbird) The purpose of this project is simple - make it easy to manage VPN built with [NetBird](https://github.com/netbirdio/netbird). The dashboard makes it possible to: + - track the status of your peers - remove peers - manage Setup Keys (to authenticate new peers) @@ -17,10 +18,10 @@ The dashboard makes it possible to: - define access controls ## Some Screenshots + peers add-peer - ## Technologies Used - NextJS @@ -33,8 +34,9 @@ The dashboard makes it possible to: - Let's Encrypt ## How to run + Disclaimer. We believe that proper user management system is not a trivial task and requires quite some effort to make it right. Therefore we decided to -use Auth0 service that covers all our needs (user management, social login, JTW for the management API). +use Auth0 service that covers all our needs (user management, social login, JWT for the management API). Auth0 so far is the only 3rd party dependency that can't be really self-hosted. 1. Install [Docker](https://docs.docker.com/get-docker/) @@ -43,9 +45,9 @@ Auth0 so far is the only 3rd party dependency that can't be really self-hosted. `AUTH0_DOMAIN` `AUTH0_CLIENT_ID` `AUTH0_AUDIENCE` - To obtain these, please use [Auth0 React SDK Guide](https://auth0.com/docs/quickstart/spa/react/01-login#configure-auth0) up until "Configure Allowed Web Origins" + To obtain these, please use [Auth0 React SDK Guide](https://auth0.com/docs/quickstart/spa/react) up until "Configure Allowed Web Origins" -4. NetBird UI Dashboard uses NetBirds Management Service HTTP API, so setting `NETBIRD_MGMT_API_ENDPOINT` is required. Most likely it will be `http://localhost:33071` if you are hosting Management API on the same server. +4. NetBird UI Dashboard uses NetBird's Management Service HTTP API, so setting `NETBIRD_MGMT_API_ENDPOINT` is required. Most likely it will be `http://localhost:33071` if you are hosting Management API on the same server. 5. Run docker container without SSL (Let's Encrypt): ```shell @@ -54,9 +56,10 @@ Auth0 so far is the only 3rd party dependency that can't be really self-hosted. -e AUTH0_DOMAIN= \ -e AUTH0_CLIENT_ID= \ -e AUTH0_AUDIENCE= \ - -e NETBIRD_MGMT_API_ENDPOINT= \ + -e NETBIRD_MGMT_API_ENDPOINT= \ netbirdio/dashboard:main ``` + 6. Run docker container with SSL (Let's Encrypt): ```shell @@ -68,7 +71,7 @@ Auth0 so far is the only 3rd party dependency that can't be really self-hosted. -e AUTH0_DOMAIN= \ -e AUTH0_CLIENT_ID= \ -e AUTH0_AUDIENCE= \ - -e NETBIRD_MGMT_API_ENDPOINT= \ + -e NETBIRD_MGMT_API_ENDPOINT= \ netbirdio/dashboard:main ``` @@ -84,11 +87,11 @@ Open [http://localhost:3000](http://localhost:3000) with your browser to see the You can start editing by modifying the code inside `src/..` The page auto-updates as you edit the file. -## How to migrate from old dashboard (v1) +## How to migrate from old dashboard (v1) The new dashboard comes with a new docker image `netbirdio/dashboard:main`. To migrate from the old dashboard (v1) `wiretrustee/dashboard:main` to the new one, please follow the steps below. 1. Stop the dashboard container `docker compose down dashboard` 2. Replace the docker image name in your `docker-compose.yml` with `netbirdio/dashboard:main` -3. Recreate the dashboard container `docker compose up -d --force-recreate dashboard` \ No newline at end of file +3. Recreate the dashboard container `docker compose up -d --force-recreate dashboard` From ba66201c642bb6a150379928d0f47dcd1c51a332 Mon Sep 17 00:00:00 2001 From: Aaron Dewes Date: Thu, 12 Feb 2026 11:21:08 +0100 Subject: [PATCH 2/4] Remove architecture info tooltip for MacOS (#550) * Remove architecture info tooltip for MacOS Previously, this tooltip helped users determine which binary to download. Since #501, there is only one universal binary download link, so keeping the tooltip explaining how to determine the CPU architecture is unnecessary. * fix: Remove unused imports --- src/modules/setup-netbird-modal/MacOSTab.tsx | 29 -------------------- 1 file changed, 29 deletions(-) diff --git a/src/modules/setup-netbird-modal/MacOSTab.tsx b/src/modules/setup-netbird-modal/MacOSTab.tsx index 27bc9f1e..f6584c05 100644 --- a/src/modules/setup-netbird-modal/MacOSTab.tsx +++ b/src/modules/setup-netbird-modal/MacOSTab.tsx @@ -6,17 +6,14 @@ import { } from "@components/Accordion"; import Button from "@components/Button"; import Code from "@components/Code"; -import InlineLink from "@components/InlineLink"; import Separator from "@components/Separator"; import Steps from "@components/Steps"; import TabsContentPadding, { TabsContent } from "@components/Tabs"; -import { Tooltip, TooltipContent, TooltipTrigger } from "@components/Tooltip"; import { getNetBirdUpCommand, GRPC_API_ORIGIN } from "@utils/netbird"; import { BeerIcon, DownloadIcon, ExternalLinkIcon, - HelpCircle, PackageOpenIcon, TerminalSquareIcon, } from "lucide-react"; @@ -50,32 +47,6 @@ export default function MacOSTab({
Download and run macOS Installer - - - - - -

- {`If you don't know what chip your Mac has, you can find out - by clicking on the Apple logo in the top left corner of your - screen and selecting 'About This Mac'.`} -

-
- - Learn more - - -
-
-
Date: Thu, 12 Feb 2026 15:16:34 +0100 Subject: [PATCH 3/4] Indicate that local user auth is disabled (#551) --- .../users/table-cells/UserStatusCell.tsx | 90 ++++++++++++------- 1 file changed, 59 insertions(+), 31 deletions(-) diff --git a/src/modules/users/table-cells/UserStatusCell.tsx b/src/modules/users/table-cells/UserStatusCell.tsx index a6a3824d..5d844357 100644 --- a/src/modules/users/table-cells/UserStatusCell.tsx +++ b/src/modules/users/table-cells/UserStatusCell.tsx @@ -4,16 +4,24 @@ import { cn } from "@utils/helpers"; import { ExternalLinkIcon, HelpCircle } from "lucide-react"; import React from "react"; import { User } from "@/interfaces/User"; +import { useAccount } from "@/modules/account/useAccount"; type Props = { user: User; }; export default function UserStatusCell({ user }: Readonly) { + const account = useAccount(); const status = user.status; const isPendingApproval = user.pending_approval; + const isLocalAuthDisabled = + account?.settings?.local_auth_disabled === true && + user.idp_id === "local"; const getStatusDisplay = () => { + if (isLocalAuthDisabled) { + return { text: "Disabled", color: "bg-gray-400" }; + } if (isPendingApproval) { return { text: "Pending Approval", color: "bg-netbird" }; } @@ -29,43 +37,63 @@ export default function UserStatusCell({ user }: Readonly) { return { text: status || "Unknown", color: "bg-gray-400" }; }; + const tooltipContent = isLocalAuthDisabled ? ( +
+
+ Local authentication is disabled. This user can no longer log in. + Use your IdP for authentication. +
+
+ + Learn more + +
+
+ ) : ( +
+
+ This user needs to be approved by an administrator before it can + join your organization. +
+ +
+ If you want to disable approval for new users, go to{" "} + + Settings + {" "} + and disable{" "} + + {"'User Approval Required'"} + + . +
+
+ Learn more about{" "} + + User Approval + +
+
+ ); + + const showTooltip = isLocalAuthDisabled || isPendingApproval; const { text, color } = getStatusDisplay(); return (
e.stopPropagation()}> -
- This user needs to be approved by an administrator before it can - join your organization. -
- -
- If you want to disable approval for new users, go to{" "} - - Settings - {" "} - and disable{" "} - - {"'User Approval Required'"} - - . -
-
- Learn more about{" "} - - User Approval - -
-
- } + content={tooltipContent} interactive={true} side="right" - disabled={!isPendingApproval} + disabled={!showTooltip} >
) { > {text} - {isPendingApproval && ( + {showTooltip && ( )}
From b71d0fde891f2dbf1b2be4a37f99dec251d16715 Mon Sep 17 00:00:00 2001 From: Eduard Gert Date: Fri, 13 Feb 2026 18:59:16 +0100 Subject: [PATCH 4/4] Add reverse proxy (#552) * **New Features** * Full Reverse Proxy UI: Services, Targets, Clusters, Custom Domains (with verification) and a Proxy Events page. * In-app modals for service auth (SSO, password, PIN) and a new PIN input component. * **Improvements** * Network & Peer pages: tabbed views (Resources, Routing Peers, Services) and improved tables, search and filters. * Toast stacking/visibility and global toast styling refined. --- .gitignore | 2 + package-lock.json | 69 +- package.json | 4 +- src/app/(dashboard)/events/proxy/page.tsx | 78 ++ src/app/(dashboard)/network-routes/page.tsx | 2 +- src/app/(dashboard)/network/page.tsx | 104 ++- src/app/(dashboard)/peer/page.tsx | 286 ++++-- src/app/(dashboard)/peers/page.tsx | 2 +- .../reverse-proxy/custom-domains/layout.tsx | 8 + .../reverse-proxy/custom-domains/page.tsx | 70 ++ src/app/(dashboard)/reverse-proxy/page.tsx | 15 + .../reverse-proxy/services/layout.tsx | 8 + .../reverse-proxy/services/page.tsx | 83 ++ src/app/(dashboard)/settings/page.tsx | 2 +- src/app/globals.css | 24 + src/assets/icons/PeerOSIcon.tsx | 26 + src/assets/icons/PeerOrResourceIcon.tsx | 19 + src/assets/icons/ResourceIcon.tsx | 20 + src/assets/icons/ReverseProxyIcon.tsx | 15 + src/auth/OIDCProvider.tsx | 30 +- src/auth/SecureProvider.tsx | 33 + src/components/Breadcrumbs.tsx | 12 +- src/components/Button.tsx | 5 +- src/components/Callout.tsx | 2 + src/components/CopyToClipboardText.tsx | 31 +- src/components/DatePickerWithRange.tsx | 3 + src/components/DeviceCard.tsx | 93 ++ src/components/ExternalLinkText.tsx | 45 + src/components/FancyToggleSwitch.tsx | 6 +- src/components/HelpTooltip.tsx | 30 + src/components/Input.tsx | 2 + src/components/Label.tsx | 39 +- src/components/Notification.tsx | 198 +++-- src/components/PeerGroupSelector.tsx | 169 ++-- src/components/PeerSelector.tsx | 1 - src/components/PinCodeInput.tsx | 123 +++ src/components/PortSelector.tsx | 1 - src/components/RadioGroup.tsx | 3 - src/components/SettingCard.tsx | 103 +++ src/components/SidebarItem.tsx | 23 +- src/components/VirtualScrollAreaList.tsx | 5 +- src/components/modal/Modal.tsx | 14 + src/components/select/SelectDropdown.tsx | 46 +- .../skeletons/SkeletonDeviceCard.tsx | 16 + src/components/table/DataTable.tsx | 293 +++--- src/components/table/DataTableHeader.tsx | 23 +- .../table/DataTableHeadingPortal.tsx | 46 +- src/components/ui/AIButton.tsx | 21 - src/components/ui/GetStartedTest.tsx | 2 +- src/components/ui/NewBadge.tsx | 16 - src/components/ui/NoResults.tsx | 18 +- src/components/ui/PeerBadge.tsx | 84 -- src/components/ui/SmallBadge.tsx | 15 +- src/components/ui/TruncatedText.tsx | 77 +- src/contexts/ReverseProxiesProvider.tsx | 634 +++++++++++++ src/contexts/ServerPaginationProvider.tsx | 222 +++++ src/hooks/useUrlTab.ts | 27 + src/interfaces/Network.ts | 1 + src/interfaces/Permission.ts | 2 + src/interfaces/ReverseProxy.ts | 131 +++ src/layouts/AppLayout.tsx | 13 +- src/layouts/Navigation.tsx | 78 +- src/modules/activity/ActivityDescription.tsx | 14 +- .../NetworkRoutingPeerCount.tsx | 2 +- .../control-center/nodes/DeviceCard.tsx | 111 --- .../control-center/nodes/NetworkNode.tsx | 2 +- src/modules/control-center/nodes/PeerNode.tsx | 2 +- .../control-center/nodes/ResourceNode.tsx | 2 +- .../control-center/nodes/SelectPeerNode.tsx | 2 +- src/modules/networks/NetworkProvider.tsx | 18 +- .../misc/NetworkInformationSquare.tsx | 2 +- .../resources/NetworkResourceModal.tsx | 14 +- .../resources/ResourceExposeServiceCell.tsx | 76 ++ .../networks/resources/ResourceGroupModal.tsx | 11 +- .../networks/resources/ResourcePolicyCell.tsx | 1 - .../networks/resources/ResourcesSection.tsx | 52 -- .../resources/ResourcesTabContent.tsx | 55 ++ .../networks/resources/ResourcesTable.tsx | 21 +- .../NetworkRoutingPeersSection.tsx | 73 -- .../NetworkRoutingPeersTabContent.tsx | 77 ++ .../NetworkRoutingPeersTable.tsx | 14 +- .../networks/table/NetworkRoutingPeerCell.tsx | 2 +- src/modules/onboarding/Onboarding.tsx | 6 +- src/modules/onboarding/OnboardingDevices.tsx | 109 +-- src/modules/peer/AccessiblePeersSection.tsx | 7 +- src/modules/peer/PeerNetworkRoutesSection.tsx | 27 +- src/modules/peer/PeerRemoteJobsSection.tsx | 7 +- src/modules/peer/PeerRoutesTable.tsx | 3 - src/modules/peers/PeerNameCell.tsx | 2 +- src/modules/peers/PeerVersionCell.tsx | 7 +- src/modules/peers/PeersTable.tsx | 4 +- src/modules/posture-checks/usePostureCheck.ts | 18 +- .../reverse-proxy/ReverseProxyModal.tsx | 838 ++++++++++++++++++ .../reverse-proxy/auth/AuthPasswordModal.tsx | 118 +++ .../reverse-proxy/auth/AuthPinModal.tsx | 109 +++ .../reverse-proxy/auth/AuthSSOModal.tsx | 97 ++ .../domain/CustomDomainClusterCell.tsx | 34 + .../domain/CustomDomainModal.tsx | 178 ++++ .../domain/CustomDomainSelector.tsx | 109 +++ .../domain/CustomDomainVerificationModal.tsx | 162 ++++ .../domain/CustomDomainsTable.tsx | 351 ++++++++ .../ReverseProxyEventsAuthMethodCell.tsx | 61 ++ .../events/ReverseProxyEventsDurationCell.tsx | 14 + .../ReverseProxyEventsLocationIpCell.tsx | 87 ++ .../events/ReverseProxyEventsReasonCell.tsx | 14 + .../events/ReverseProxyEventsRequestCell.tsx | 42 + .../events/ReverseProxyEventsStatusCell.tsx | 17 + .../events/ReverseProxyEventsTable.tsx | 273 ++++++ .../events/ReverseProxyEventsTimeCell.tsx | 35 + .../events/ReverseProxyEventsUserCell.tsx | 59 ++ .../table/ReverseProxyActionCell.tsx | 69 ++ .../table/ReverseProxyActiveCell.tsx | 31 + .../table/ReverseProxyArrowCell.tsx | 16 + .../table/ReverseProxyAuthCell.tsx | 64 ++ .../table/ReverseProxyClusterCell.tsx | 71 ++ .../table/ReverseProxyDestinationCell.tsx | 31 + .../table/ReverseProxyNameCell.tsx | 71 ++ .../table/ReverseProxyStatusCell.tsx | 74 ++ .../reverse-proxy/table/ReverseProxyTable.tsx | 257 ++++++ .../table/ReverseProxyTargetsCell.tsx | 52 ++ .../targets/ReverseProxyTargetActionCell.tsx | 46 + .../targets/ReverseProxyTargetActiveCell.tsx | 35 + .../targets/ReverseProxyTargetContext.tsx | 16 + .../targets/ReverseProxyTargetDevice.tsx | 88 ++ .../targets/ReverseProxyTargetModal.tsx | 612 +++++++++++++ .../targets/ReverseProxyTargetPath.tsx | 46 + .../targets/ReverseProxyTargetsTable.tsx | 86 ++ .../flat/ReverseProxyFlatTargetActionCell.tsx | 83 ++ .../ReverseProxyFlatTargetsTabContent.tsx | 67 ++ .../flat/ReverseProxyFlatTargetsTable.tsx | 214 +++++ src/modules/settings/ClientSettingsTab.tsx | 24 +- .../setup-keys/SetupKeyEphemeralCell.tsx | 37 - src/utils/api.tsx | 2 + tailwind.config.ts | 6 +- 134 files changed, 7879 insertions(+), 1131 deletions(-) create mode 100644 src/app/(dashboard)/events/proxy/page.tsx create mode 100644 src/app/(dashboard)/reverse-proxy/custom-domains/layout.tsx create mode 100644 src/app/(dashboard)/reverse-proxy/custom-domains/page.tsx create mode 100644 src/app/(dashboard)/reverse-proxy/page.tsx create mode 100644 src/app/(dashboard)/reverse-proxy/services/layout.tsx create mode 100644 src/app/(dashboard)/reverse-proxy/services/page.tsx create mode 100644 src/assets/icons/PeerOSIcon.tsx create mode 100644 src/assets/icons/PeerOrResourceIcon.tsx create mode 100644 src/assets/icons/ResourceIcon.tsx create mode 100644 src/assets/icons/ReverseProxyIcon.tsx create mode 100644 src/components/DeviceCard.tsx create mode 100644 src/components/ExternalLinkText.tsx create mode 100644 src/components/HelpTooltip.tsx create mode 100644 src/components/PinCodeInput.tsx create mode 100644 src/components/SettingCard.tsx create mode 100644 src/components/skeletons/SkeletonDeviceCard.tsx delete mode 100644 src/components/ui/AIButton.tsx delete mode 100644 src/components/ui/NewBadge.tsx delete mode 100644 src/components/ui/PeerBadge.tsx create mode 100644 src/contexts/ReverseProxiesProvider.tsx create mode 100644 src/contexts/ServerPaginationProvider.tsx create mode 100644 src/hooks/useUrlTab.ts create mode 100644 src/interfaces/ReverseProxy.ts delete mode 100644 src/modules/control-center/nodes/DeviceCard.tsx create mode 100644 src/modules/networks/resources/ResourceExposeServiceCell.tsx delete mode 100644 src/modules/networks/resources/ResourcesSection.tsx create mode 100644 src/modules/networks/resources/ResourcesTabContent.tsx delete mode 100644 src/modules/networks/routing-peers/NetworkRoutingPeersSection.tsx create mode 100644 src/modules/networks/routing-peers/NetworkRoutingPeersTabContent.tsx create mode 100644 src/modules/reverse-proxy/ReverseProxyModal.tsx create mode 100644 src/modules/reverse-proxy/auth/AuthPasswordModal.tsx create mode 100644 src/modules/reverse-proxy/auth/AuthPinModal.tsx create mode 100644 src/modules/reverse-proxy/auth/AuthSSOModal.tsx create mode 100644 src/modules/reverse-proxy/domain/CustomDomainClusterCell.tsx create mode 100644 src/modules/reverse-proxy/domain/CustomDomainModal.tsx create mode 100644 src/modules/reverse-proxy/domain/CustomDomainSelector.tsx create mode 100644 src/modules/reverse-proxy/domain/CustomDomainVerificationModal.tsx create mode 100644 src/modules/reverse-proxy/domain/CustomDomainsTable.tsx create mode 100644 src/modules/reverse-proxy/events/ReverseProxyEventsAuthMethodCell.tsx create mode 100644 src/modules/reverse-proxy/events/ReverseProxyEventsDurationCell.tsx create mode 100644 src/modules/reverse-proxy/events/ReverseProxyEventsLocationIpCell.tsx create mode 100644 src/modules/reverse-proxy/events/ReverseProxyEventsReasonCell.tsx create mode 100644 src/modules/reverse-proxy/events/ReverseProxyEventsRequestCell.tsx create mode 100644 src/modules/reverse-proxy/events/ReverseProxyEventsStatusCell.tsx create mode 100644 src/modules/reverse-proxy/events/ReverseProxyEventsTable.tsx create mode 100644 src/modules/reverse-proxy/events/ReverseProxyEventsTimeCell.tsx create mode 100644 src/modules/reverse-proxy/events/ReverseProxyEventsUserCell.tsx create mode 100644 src/modules/reverse-proxy/table/ReverseProxyActionCell.tsx create mode 100644 src/modules/reverse-proxy/table/ReverseProxyActiveCell.tsx create mode 100644 src/modules/reverse-proxy/table/ReverseProxyArrowCell.tsx create mode 100644 src/modules/reverse-proxy/table/ReverseProxyAuthCell.tsx create mode 100644 src/modules/reverse-proxy/table/ReverseProxyClusterCell.tsx create mode 100644 src/modules/reverse-proxy/table/ReverseProxyDestinationCell.tsx create mode 100644 src/modules/reverse-proxy/table/ReverseProxyNameCell.tsx create mode 100644 src/modules/reverse-proxy/table/ReverseProxyStatusCell.tsx create mode 100644 src/modules/reverse-proxy/table/ReverseProxyTable.tsx create mode 100644 src/modules/reverse-proxy/table/ReverseProxyTargetsCell.tsx create mode 100644 src/modules/reverse-proxy/targets/ReverseProxyTargetActionCell.tsx create mode 100644 src/modules/reverse-proxy/targets/ReverseProxyTargetActiveCell.tsx create mode 100644 src/modules/reverse-proxy/targets/ReverseProxyTargetContext.tsx create mode 100644 src/modules/reverse-proxy/targets/ReverseProxyTargetDevice.tsx create mode 100644 src/modules/reverse-proxy/targets/ReverseProxyTargetModal.tsx create mode 100644 src/modules/reverse-proxy/targets/ReverseProxyTargetPath.tsx create mode 100644 src/modules/reverse-proxy/targets/ReverseProxyTargetsTable.tsx create mode 100644 src/modules/reverse-proxy/targets/flat/ReverseProxyFlatTargetActionCell.tsx create mode 100644 src/modules/reverse-proxy/targets/flat/ReverseProxyFlatTargetsTabContent.tsx create mode 100644 src/modules/reverse-proxy/targets/flat/ReverseProxyFlatTargetsTable.tsx delete mode 100644 src/modules/setup-keys/SetupKeyEphemeralCell.tsx diff --git a/.gitignore b/.gitignore index 5cba48e3..86420ac0 100644 --- a/.gitignore +++ b/.gitignore @@ -37,6 +37,8 @@ next-env.d.ts # config .local-config.json +.test-config.json +cypress.env.json .configs/.local-config.zitadel.json .configs/.staging-config.json .configs/.temp-config.json diff --git a/package-lock.json b/package-lock.json index 797d9f21..f2f14ee1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -59,7 +59,7 @@ "ip-cidr": "^3.1.0", "js-cookie": "^3.0.5", "lodash": "^4.17.23", - "lucide-react": "^0.539.0", + "lucide-react": "^0.562.0", "next": "^16.1.6", "next-themes": "^0.2.1", "punycode": "^2.3.1", @@ -67,7 +67,6 @@ "react-day-picker": "^9.13.0", "react-dom": "^19.2.4", "react-ga4": "^2.1.0", - "react-hot-toast": "^2.4.1", "react-hotjar": "^6.3.1", "react-hotkeys-hook": "^4.4.1", "react-icons": "^5.5.0", @@ -75,6 +74,7 @@ "react-loading-skeleton": "^3.3.1", "react-responsive": "^9.0.2", "react-virtuoso": "^4.9.0", + "sonner": "^2.0.7", "swr": "^2.2.4", "tailwind-merge": "^1.14.0", "tailwindcss-animate": "^1.0.7", @@ -90,6 +90,9 @@ "postcss": "^8", "prettier": "3.0.3", "tailwindcss": "^3.4.17" + }, + "engines": { + "node": ">=20.9.0" } }, "node_modules/@alloc/quick-lru": { @@ -165,6 +168,7 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -3027,6 +3031,7 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.10.tgz", "integrity": "sha512-WPigyYuGhgZ/cTPRXB2EwUw+XvsRA3GqHlsP4qteqrnnjDrApbS7MxcGr/hke5iUoeB7E/gQtrs9I37zAJ0Vjw==", "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -3036,6 +3041,7 @@ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -3094,6 +3100,7 @@ "integrity": "sha512-BtE0k6cjwjLZoZixN0t5AKP0kSzlGu7FctRXYuPAm//aaiZhmfq1JwdYpYr1brzEspYyFeF+8XF5j2VK6oalrA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.54.0", "@typescript-eslint/types": "8.54.0", @@ -3600,7 +3607,8 @@ "version": "5.5.0", "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz", "integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@xyflow/react": { "version": "12.10.0", @@ -3639,6 +3647,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4056,6 +4065,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -4684,6 +4694,7 @@ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "license": "ISC", + "peer": true, "engines": { "node": ">=12" } @@ -5212,6 +5223,7 @@ "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.2.tgz", "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -5409,6 +5421,7 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -6010,15 +6023,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/goober": { - "version": "2.1.18", - "resolved": "https://registry.npmjs.org/goober/-/goober-2.1.18.tgz", - "integrity": "sha512-2vFqsaDVIT9Gz7N6kAL++pLpp41l3PfDuusHcjnGLfR6+huZkl6ziX+zgVC3ZxpqWhzH6pyDdGrCeDhMIvwaxw==", - "license": "MIT", - "peerDependencies": { - "csstype": "^3.0.10" - } - }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -6711,6 +6715,7 @@ "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "license": "MIT", + "peer": true, "bin": { "jiti": "bin/jiti.js" } @@ -6918,9 +6923,9 @@ } }, "node_modules/lucide-react": { - "version": "0.539.0", - "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.539.0.tgz", - "integrity": "sha512-VVISr+VF2krO91FeuCrm1rSOLACQUYVy7NQkzrOty52Y8TlTPcXcMdQFj9bYzBgXbWCiywlwSZ3Z8u6a+6bMlg==", + "version": "0.562.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.562.0.tgz", + "integrity": "sha512-82hOAu7y0dbVuFfmO4bYF1XEwYk/mEbM5E+b1jgci/udUBEE/R7LF5Ip0CCEmXe8AybRM8L+04eP+LGZeDvkiw==", "license": "ISC", "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" @@ -7465,6 +7470,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -7672,6 +7678,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -7712,6 +7719,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -7725,23 +7733,6 @@ "integrity": "sha512-ZKS7PGNFqqMd3PJ6+C2Jtz/o1iU9ggiy8Y8nUeksgVuvNISbmrQtJiZNvC/TjDsqD0QlU5Wkgs7i+w9+OjHhhQ==", "license": "MIT" }, - "node_modules/react-hot-toast": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/react-hot-toast/-/react-hot-toast-2.6.0.tgz", - "integrity": "sha512-bH+2EBMZ4sdyou/DPrfgIouFpcRLCJ+HoCA32UoAYHn6T3Ur5yfcDCeSr5mwldl6pFOsiocmrXMuoCJ1vV8bWg==", - "license": "MIT", - "dependencies": { - "csstype": "^3.1.3", - "goober": "^2.1.16" - }, - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "react": ">=16", - "react-dom": ">=16" - } - }, "node_modules/react-hotjar": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/react-hotjar/-/react-hotjar-6.3.1.tgz", @@ -8336,6 +8327,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/sonner": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz", + "integrity": "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==", + "license": "MIT", + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", + "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -8613,6 +8614,7 @@ "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", "license": "MIT", + "peer": true, "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", @@ -8779,6 +8781,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -8944,6 +8947,7 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -9271,6 +9275,7 @@ "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "dev": true, "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index 8c4817f9..c0d4d834 100644 --- a/package.json +++ b/package.json @@ -67,7 +67,7 @@ "ip-cidr": "^3.1.0", "js-cookie": "^3.0.5", "lodash": "^4.17.23", - "lucide-react": "^0.539.0", + "lucide-react": "^0.562.0", "next": "^16.1.6", "next-themes": "^0.2.1", "punycode": "^2.3.1", @@ -75,7 +75,6 @@ "react-day-picker": "^9.13.0", "react-dom": "^19.2.4", "react-ga4": "^2.1.0", - "react-hot-toast": "^2.4.1", "react-hotjar": "^6.3.1", "react-hotkeys-hook": "^4.4.1", "react-icons": "^5.5.0", @@ -83,6 +82,7 @@ "react-loading-skeleton": "^3.3.1", "react-responsive": "^9.0.2", "react-virtuoso": "^4.9.0", + "sonner": "^2.0.7", "swr": "^2.2.4", "tailwind-merge": "^1.14.0", "tailwindcss-animate": "^1.0.7", diff --git a/src/app/(dashboard)/events/proxy/page.tsx b/src/app/(dashboard)/events/proxy/page.tsx new file mode 100644 index 00000000..7316489f --- /dev/null +++ b/src/app/(dashboard)/events/proxy/page.tsx @@ -0,0 +1,78 @@ +"use client"; + +import Breadcrumbs from "@components/Breadcrumbs"; +import InlineLink from "@components/InlineLink"; +import Paragraph from "@components/Paragraph"; +import { RestrictedAccess } from "@components/ui/RestrictedAccess"; +import dayjs from "dayjs"; +import { ExternalLinkIcon } from "lucide-react"; +import ReverseProxyIcon from "@/assets/icons/ReverseProxyIcon"; +import React, { useMemo } from "react"; +import ActivityIcon from "@/assets/icons/ActivityIcon"; +import { usePermissions } from "@/contexts/PermissionsProvider"; +import ServerPaginationProvider from "@/contexts/ServerPaginationProvider"; +import PageContainer from "@/layouts/PageContainer"; +import ReverseProxyEventsTable from "@/modules/reverse-proxy/events/ReverseProxyEventsTable"; +import { usePortalElement } from "@hooks/usePortalElement"; +import { REVERSE_PROXY_EVENTS_DOCS_LINK } from "@/interfaces/ReverseProxy"; + +export default function ProxyEventsPage() { + const { permission } = usePermissions(); + const { ref: headingRef, portalTarget } = + usePortalElement(); + + const defaultFilters = useMemo( + () => ({ + start_date: dayjs().subtract(7, "day").startOf("day").toISOString(), + end_date: dayjs().endOf("day").toISOString(), + }), + [], + ); + + return ( + +
+ + } + /> + } + /> + + +

Proxy Events

+ + + View access logs for your reverse proxy services, including allowed + and denied requests. + + + + Learn more about{" "} + + Proxy Events + {" "} + in our documentation. + +
+ + + + + + +
+ ); +} diff --git a/src/app/(dashboard)/network-routes/page.tsx b/src/app/(dashboard)/network-routes/page.tsx index 10967154..eeb3d0f1 100644 --- a/src/app/(dashboard)/network-routes/page.tsx +++ b/src/app/(dashboard)/network-routes/page.tsx @@ -61,7 +61,7 @@ export default function NetworkRoutes() { in our documentation. - + We recommend using the new Networks concept to easier visualise and manage access to your resources.{" "} diff --git a/src/app/(dashboard)/network/page.tsx b/src/app/(dashboard)/network/page.tsx index 316f566f..c2ba2eab 100644 --- a/src/app/(dashboard)/network/page.tsx +++ b/src/app/(dashboard)/network/page.tsx @@ -12,14 +12,14 @@ import { } from "@components/DropdownMenu"; import FullTooltip from "@components/FullTooltip"; import InlineLink from "@components/InlineLink"; -import Separator from "@components/Separator"; import FullScreenLoading from "@components/ui/FullScreenLoading"; import useRedirect from "@hooks/useRedirect"; import useFetchApi from "@utils/api"; -import { cn } from "@utils/helpers"; +import { cn, singularize } from "@utils/helpers"; import { ArrowUpRightIcon, HelpCircle, + Layers3Icon, MoreVertical, PencilLineIcon, ServerIcon, @@ -28,19 +28,27 @@ import { Trash2, } from "lucide-react"; import { useRouter, useSearchParams } from "next/navigation"; -import React, { useMemo, useState } from "react"; -import { useSWRConfig } from "swr"; +import React, { useMemo } from "react"; +import useUrlTab from "@/hooks/useUrlTab"; import NetworkRoutesIcon from "@/assets/icons/NetworkRoutesIcon"; import { usePermissions } from "@/contexts/PermissionsProvider"; -import { Network } from "@/interfaces/Network"; +import { Network, NetworkResource, NetworkRouter } from "@/interfaces/Network"; import PageContainer from "@/layouts/PageContainer"; import { NetworkInformationSquare } from "@/modules/networks/misc/NetworkInformationSquare"; import { NetworkProvider, useNetworksContext, } from "@/modules/networks/NetworkProvider"; -import { ResourcesSection } from "@/modules/networks/resources/ResourcesSection"; -import { NetworkRoutingPeersSection } from "@/modules/networks/routing-peers/NetworkRoutingPeersSection"; +import { ResourcesTabContent } from "@/modules/networks/resources/ResourcesTabContent"; +import { NetworkRoutingPeersTabContent } from "@/modules/networks/routing-peers/NetworkRoutingPeersTabContent"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@components/Tabs"; +import PeerIcon from "@/assets/icons/PeerIcon"; +import ReverseProxyIcon from "@/assets/icons/ReverseProxyIcon"; +import { ReverseProxyFlatTargetsTabContent } from "@/modules/reverse-proxy/targets/flat/ReverseProxyFlatTargetsTabContent"; +import ReverseProxiesProvider, { + flattenReverseProxies, + useReverseProxies, +} from "@/contexts/ReverseProxiesProvider"; export default function NetworkDetailPage() { const queryParameter = useSearchParams(); @@ -53,7 +61,9 @@ export default function NetworkDetailPage() { useRedirect("/networks", false, !networkId); return network && !isLoading ? ( - + + + ) : ( ); @@ -62,8 +72,23 @@ export default function NetworkDetailPage() { function NetworkOverview({ network }: Readonly<{ network: Network }>) { const { permission } = usePermissions(); - const [networkModal, setNetworkModal] = useState(false); - const { mutate } = useSWRConfig(); + const { data: resources, isLoading: isResourcesLoading } = useFetchApi< + NetworkResource[] + >(`/networks/${network.id}/resources`); + const { data: routers, isLoading: isRoutersLoading } = useFetchApi< + NetworkRouter[] + >(`/networks/${network.id}/routers`); + + const { reverseProxies, isLoading: isServicesLoading } = useReverseProxies(); + const services = useMemo( + () => flattenReverseProxies({ reverseProxies, network }), + [reverseProxies, network], + ); + + const [tab, setTab] = useUrlTab( + ["resources", "routing-peers", "services"], + "resources", + ); const isActive = !!( network?.routing_peers_count && network.routing_peers_count > 0 @@ -72,7 +97,7 @@ function NetworkOverview({ network }: Readonly<{ network: Network }>) { return ( -
+
) {
- - -
- - + + + + + {singularize("Resources", network?.resources?.length)} + + + + {singularize("Routing Peers", network?.routing_peers_count)} + + + + {singularize("Services", services.length)} + + + + + + + + + + + + + + + ); diff --git a/src/app/(dashboard)/peer/page.tsx b/src/app/(dashboard)/peer/page.tsx index bf432c31..a5cd5504 100644 --- a/src/app/(dashboard)/peer/page.tsx +++ b/src/app/(dashboard)/peer/page.tsx @@ -26,6 +26,7 @@ import { RestrictedAccess } from "@components/ui/RestrictedAccess"; import TextWithTooltip from "@components/ui/TextWithTooltip"; import useRedirect from "@hooks/useRedirect"; import useFetchApi from "@utils/api"; +import { singularize } from "@utils/helpers"; import dayjs from "dayjs"; import { isEmpty, trim } from "lodash"; import { @@ -36,13 +37,14 @@ import { FlagIcon, Globe, History, + ListIcon, MapPin, MonitorSmartphoneIcon, NetworkIcon, PencilIcon, RadioTowerIcon, - TimerResetIcon, } from "lucide-react"; +import Link from "next/link"; import { useRouter, useSearchParams } from "next/navigation"; import { toASCII } from "punycode"; import React, { useMemo, useState } from "react"; @@ -52,21 +54,27 @@ import RoundedFlag from "@/assets/countries/RoundedFlag"; import CircleIcon from "@/assets/icons/CircleIcon"; import NetBirdIcon from "@/assets/icons/NetBirdIcon"; import PeerIcon from "@/assets/icons/PeerIcon"; +import ReverseProxyIcon from "@/assets/icons/ReverseProxyIcon"; import { useCountries } from "@/contexts/CountryProvider"; import PeerProvider, { usePeer } from "@/contexts/PeerProvider"; import { usePermissions } from "@/contexts/PermissionsProvider"; import RoutesProvider from "@/contexts/RoutesProvider"; import { useHasChanges } from "@/hooks/useHasChanges"; +import type { Group } from "@/interfaces/Group"; import type { Peer } from "@/interfaces/Peer"; import PageContainer from "@/layouts/PageContainer"; import useGroupHelper from "@/modules/groups/useGroupHelper"; import { AccessiblePeersSection } from "@/modules/peer/AccessiblePeersSection"; import { PeerNetworkRoutesSection } from "@/modules/peer/PeerNetworkRoutesSection"; import { PeerRemoteJobsSection } from "@/modules/peer/PeerRemoteJobsSection"; +import ReverseProxiesProvider, { + flattenReverseProxies, + useReverseProxies, +} from "@/contexts/ReverseProxiesProvider"; +import { ReverseProxyFlatTargetsTabContent } from "@/modules/reverse-proxy/targets/flat/ReverseProxyFlatTargetsTabContent"; import { PeerSSHToggle } from "@/modules/peer/PeerSSHToggle"; import { RDPButton } from "@/modules/remote-access/rdp/RDPButton"; import { SSHButton } from "@/modules/remote-access/ssh/SSHButton"; -import Link from "next/link"; import { PeerExpirationSettings } from "@/modules/peer/PeerExpirationSettings"; export default function PeerPage() { @@ -99,10 +107,12 @@ export default function PeerPage() { /> ); - return peer && !isLoading ? ( - - - + return peer && peer.id && !isLoading ? ( + + + + + ) : ( ); @@ -114,38 +124,60 @@ function PeerOverview() { return ( -
- - } - /> - - - -
- + +
+ + } + /> + + + +
+ +
); } -const PeerGeneralInformation = () => { - const router = useRouter(); +type PeerSettingsContextType = { + selectedGroups: Group[]; + setSelectedGroups: React.Dispatch>; + hasChanges: boolean; + updatePeer: (newName?: string) => Promise; + name: string; + setName: (name: string) => void; + tab: string; + setTab: (tab: string) => void; +}; + +const PeerSettingsContext = React.createContext( + null, +); + +const usePeerSettings = () => { + const context = React.useContext(PeerSettingsContext); + if (!context) { + throw new Error("usePeerSettings must be used within PeerSettingsProvider"); + } + return context; +}; + +const PeerSettingsProvider = ({ children }: { children: React.ReactNode }) => { const { mutate } = useSWRConfig(); - const { peer, user, peerGroups, update } = usePeer(); + const { peer, peerGroups, update } = usePeer(); + const { permission } = usePermissions(); const [name, setName] = useState(peer.name); - const [showEditNameModal, setShowEditNameModal] = useState(false); + const [tab, setTab] = useState("overview"); const [selectedGroups, setSelectedGroups, { getAllGroupCalls }] = useGroupHelper({ initial: peerGroups?.filter((g) => g?.name !== "All"), peer, }); - /** - * Detect if there are changes in the peer information, if there are changes, then enable the save button. - */ const { hasChanges, updateRef: updateHasChangedRef } = useHasChanges([ selectedGroups, ]); @@ -175,7 +207,31 @@ const PeerGeneralInformation = () => { }); }; + return ( + + {children} + + ); +}; + +const PeerHeader = () => { + const router = useRouter(); + const { peer, user } = usePeer(); const { permission } = usePermissions(); + const { name, setName, hasChanges, updatePeer, tab } = usePeerSettings(); + const [showEditNameModal, setShowEditNameModal] = useState(false); + const isOverviewTab = tab === "overview"; return ( <> @@ -236,65 +292,29 @@ const PeerGeneralInformation = () => {
)}
-
- - -
- - -
- - -
- - - - - {/* Remote Access Buttons */} -
- - Connect directly to this peer via SSH or RDP. -
- - -
+ {isOverviewTab && ( +
+ +
- - {permission.groups.read && ( -
- - - Use groups to control what this peer can access. - - -
- )} -
+ )}
); @@ -303,19 +323,27 @@ const PeerGeneralInformation = () => { const PeerOverviewTabs = () => { const { peer } = usePeer(); const { permission } = usePermissions(); + const { reverseProxies, isLoading: isServicesLoading } = useReverseProxies(); + const { tab, setTab } = usePeerSettings(); - const [tab, setTab] = useState( - permission.routes.read ? "network-routes" : "accessible-peers", + const flatTargets = useMemo( + () => flattenReverseProxies({ reverseProxies, peer }), + [reverseProxies, peer], ); return ( setTab(v)} + onValueChange={setTab} value={tab} - className={"pt-10 pb-0 mb-0"} + className={"pt-4 pb-0 mb-0"} > + + + Overview + + {permission.routes.read && ( @@ -330,6 +358,16 @@ const PeerOverviewTabs = () => { )} + {peer?.id && permission.services?.read && ( + + + {singularize("Services", flatTargets.length)} + + )} + {peer?.id && permission.peers.delete && ( @@ -338,6 +376,10 @@ const PeerOverviewTabs = () => { )} + + + + {permission.routes.read && ( @@ -349,6 +391,21 @@ const PeerOverviewTabs = () => { )} + + {peer?.id && permission.services?.read && ( + + + + )} + {peer.id && permission.peers.delete && ( @@ -358,6 +415,55 @@ const PeerOverviewTabs = () => { ); }; +const PeerOverviewTabContent = () => { + const { peer } = usePeer(); + const { permission } = usePermissions(); + const { selectedGroups, setSelectedGroups } = usePeerSettings(); + + return ( +
+
+ + +
+ + {permission.groups.read && ( +
+ + + Use groups to control what this peer can access. + + +
+ )} + + + + {/* Remote Access Buttons */} +
+ + Connect directly to this peer via SSH or RDP. +
+ + +
+
+
+
+
+ ); +}; + function PeerInformationCard({ peer }: Readonly<{ peer: Peer }>) { const { isLoading, getRegionByPeer } = useCountries(); const { update } = usePeer(); @@ -541,9 +647,9 @@ function PeerInformationCard({ peer }: Readonly<{ peer: Peer }>) { peer.connected ? "just now" : dayjs(peer.last_seen).format("D MMMM, YYYY [at] h:mm A") + - " (" + - dayjs().to(peer.last_seen) + - ")" + " (" + + dayjs().to(peer.last_seen) + + ")" } /> diff --git a/src/app/(dashboard)/peers/page.tsx b/src/app/(dashboard)/peers/page.tsx index cf718630..becc3065 100644 --- a/src/app/(dashboard)/peers/page.tsx +++ b/src/app/(dashboard)/peers/page.tsx @@ -105,7 +105,7 @@ function PeersBlockedView() {
diff --git a/src/app/(dashboard)/reverse-proxy/custom-domains/layout.tsx b/src/app/(dashboard)/reverse-proxy/custom-domains/layout.tsx new file mode 100644 index 00000000..70529903 --- /dev/null +++ b/src/app/(dashboard)/reverse-proxy/custom-domains/layout.tsx @@ -0,0 +1,8 @@ +import { globalMetaTitle } from "@utils/meta"; +import type { Metadata } from "next"; +import BlankLayout from "@/layouts/BlankLayout"; + +export const metadata: Metadata = { + title: `Custom Domains - Reverse Proxy - ${globalMetaTitle}`, +}; +export default BlankLayout; diff --git a/src/app/(dashboard)/reverse-proxy/custom-domains/page.tsx b/src/app/(dashboard)/reverse-proxy/custom-domains/page.tsx new file mode 100644 index 00000000..d052c0ce --- /dev/null +++ b/src/app/(dashboard)/reverse-proxy/custom-domains/page.tsx @@ -0,0 +1,70 @@ +"use client"; + +import Breadcrumbs from "@components/Breadcrumbs"; +import InlineLink from "@components/InlineLink"; +import Paragraph from "@components/Paragraph"; +import SkeletonTable from "@components/skeletons/SkeletonTable"; +import { RestrictedAccess } from "@components/ui/RestrictedAccess"; +import { usePortalElement } from "@hooks/usePortalElement"; +import { ExternalLinkIcon } from "lucide-react"; +import React, { lazy, Suspense } from "react"; +import ReverseProxyIcon from "@/assets/icons/ReverseProxyIcon"; +import { usePermissions } from "@/contexts/PermissionsProvider"; +import ReverseProxiesProvider from "@/contexts/ReverseProxiesProvider"; +import { REVERSE_PROXY_CUSTOM_DOMAINS_DOCS_LINK } from "@/interfaces/ReverseProxy"; +import PageContainer from "@/layouts/PageContainer"; + +const CustomDomainsTable = lazy( + () => import("@/modules/reverse-proxy/domain/CustomDomainsTable"), +); + +export default function ReverseProxyCustomDomainsPage() { + const { permission } = usePermissions(); + + const { ref: headingRef, portalTarget } = + usePortalElement(); + + return ( + +
+ + } + /> + + +

Domains

+ + Add and manage custom domains for your reverse proxy services. + + + Learn more about + + Custom Domains + + + in our documentation. + +
+ + + }> + + + + +
+ ); +} diff --git a/src/app/(dashboard)/reverse-proxy/page.tsx b/src/app/(dashboard)/reverse-proxy/page.tsx new file mode 100644 index 00000000..8f5c4973 --- /dev/null +++ b/src/app/(dashboard)/reverse-proxy/page.tsx @@ -0,0 +1,15 @@ +"use client"; + +import FullScreenLoading from "@components/ui/FullScreenLoading"; +import { useRouter } from "next/navigation"; +import { useEffect } from "react"; + +export default function ReverseProxyRedirectPage() { + const router = useRouter(); + + useEffect(() => { + router.replace("/reverse-proxy/services"); + }, [router]); + + return ; +} diff --git a/src/app/(dashboard)/reverse-proxy/services/layout.tsx b/src/app/(dashboard)/reverse-proxy/services/layout.tsx new file mode 100644 index 00000000..b895c6b6 --- /dev/null +++ b/src/app/(dashboard)/reverse-proxy/services/layout.tsx @@ -0,0 +1,8 @@ +import { globalMetaTitle } from "@utils/meta"; +import type { Metadata } from "next"; +import BlankLayout from "@/layouts/BlankLayout"; + +export const metadata: Metadata = { + title: `Services - Reverse Proxy - ${globalMetaTitle}`, +}; +export default BlankLayout; diff --git a/src/app/(dashboard)/reverse-proxy/services/page.tsx b/src/app/(dashboard)/reverse-proxy/services/page.tsx new file mode 100644 index 00000000..24238b49 --- /dev/null +++ b/src/app/(dashboard)/reverse-proxy/services/page.tsx @@ -0,0 +1,83 @@ +"use client"; + +import Breadcrumbs from "@components/Breadcrumbs"; +import InlineLink from "@components/InlineLink"; +import Paragraph from "@components/Paragraph"; +import SkeletonTable from "@components/skeletons/SkeletonTable"; +import { RestrictedAccess } from "@components/ui/RestrictedAccess"; +import { usePortalElement } from "@hooks/usePortalElement"; +import { ExternalLinkIcon } from "lucide-react"; +import React, { lazy, Suspense } from "react"; +import ReverseProxyIcon from "@/assets/icons/ReverseProxyIcon"; +import { usePermissions } from "@/contexts/PermissionsProvider"; +import ReverseProxiesProvider from "@/contexts/ReverseProxiesProvider"; +import { REVERSE_PROXY_DOCS_LINK } from "@/interfaces/ReverseProxy"; +import PageContainer from "@/layouts/PageContainer"; +import { Callout } from "@components/Callout"; +import { isNetBirdHosted } from "@utils/netbird"; + +const ReverseProxyTable = lazy( + () => import("@/modules/reverse-proxy/table/ReverseProxyTable"), +); + +export default function ReverseProxyServicesPage() { + const { permission } = usePermissions(); + + const { ref: headingRef, portalTarget } = + usePortalElement(); + + return ( + +
+ + } + /> + + +

Services

+ + Expose services securely through NetBird's reverse proxy. + + + Learn more about + + Services + + + in our documentation. + + + {isNetBirdHosted() ? ( + + NetBird's Reverse Proxy is currently in beta and available at + no cost during this period. Features, functionality, and pricing are + subject to change upon release. + + ) : ( + + NetBird's Reverse Proxy is currently in beta.
Features + and functionality are subject to change upon release. +
+ )} +
+ + + + }> + + + + +
+ ); +} diff --git a/src/app/(dashboard)/settings/page.tsx b/src/app/(dashboard)/settings/page.tsx index 63217362..149602f9 100644 --- a/src/app/(dashboard)/settings/page.tsx +++ b/src/app/(dashboard)/settings/page.tsx @@ -56,7 +56,7 @@ export default function NetBirdSettings() { Authentication {account?.settings?.embedded_idp_enabled && - permission.identity_providers.read && ( + permission?.identity_providers?.read && ( Identity Providers diff --git a/src/app/globals.css b/src/app/globals.css index d1948563..9a53f195 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -2,6 +2,11 @@ @tailwind components; @tailwind utilities; +:root { + --toasts-before: 0; + --lift: 1; +} + html{ @apply bg-nb-gray; } @@ -171,6 +176,25 @@ p { @apply m-0 p-0 box-border; } +/* Disable sonner's opacity fade-in for custom toasts, but respect visibility */ +[data-sonner-toast][data-visible="true"] { + opacity: 1 !important; +} + + +/* Adjust sonner stacking: less shrink and less lift per toast */ +[data-sonner-toast][data-expanded="false"][data-front="false"] { + --scale: calc(var(--toasts-before) * 0.03 - 1) !important; + --lift-amount: calc(var(--lift) * 10px) !important; +} + +/* Override stacked toast removal to move up instead of down */ +[data-sonner-toast][data-removed='true'][data-front='false'][data-swipe-out='false'][data-expanded='false'] { + --y: translateY(calc(var(--lift) * -20%)) !important; + + opacity: 0 !important; + transition: transform 400ms ease, opacity 300ms ease !important; +} /* Control Center */ .react-flow__node-groupNode .selected{ diff --git a/src/assets/icons/PeerOSIcon.tsx b/src/assets/icons/PeerOSIcon.tsx new file mode 100644 index 00000000..3948d5f9 --- /dev/null +++ b/src/assets/icons/PeerOSIcon.tsx @@ -0,0 +1,26 @@ +import { getOperatingSystem } from "@hooks/useOperatingSystem"; +import { cn } from "@utils/helpers"; +import * as React from "react"; +import { OperatingSystem } from "@/interfaces/OperatingSystem"; +import { OSLogo } from "@/modules/peers/PeerOSCell"; + +type Props = { + os: string; +}; + +export const PeerOSIcon = ({ os }: Props) => { + const osType = getOperatingSystem(os); + return ( +
+ +
+ ); +}; diff --git a/src/assets/icons/PeerOrResourceIcon.tsx b/src/assets/icons/PeerOrResourceIcon.tsx new file mode 100644 index 00000000..b02ad1bc --- /dev/null +++ b/src/assets/icons/PeerOrResourceIcon.tsx @@ -0,0 +1,19 @@ +import * as React from "react"; +import { NetworkResource } from "@/interfaces/Network"; +import { Peer } from "@/interfaces/Peer"; +import { PeerOSIcon } from "./PeerOSIcon"; +import { ResourceIcon } from "./ResourceIcon"; + +type Props = { + peer?: Peer; + resource?: NetworkResource; +}; + +export const PeerOrResourceIcon = ({ peer, resource }: Props) => { + return ( + <> + {peer && } + {resource?.type && } + + ); +}; diff --git a/src/assets/icons/ResourceIcon.tsx b/src/assets/icons/ResourceIcon.tsx new file mode 100644 index 00000000..a2d817c7 --- /dev/null +++ b/src/assets/icons/ResourceIcon.tsx @@ -0,0 +1,20 @@ +import { GlobeIcon, NetworkIcon, WorkflowIcon } from "lucide-react"; +import * as React from "react"; + +type Props = { + type: "domain" | "host" | "subnet"; + size?: number; +}; + +export const ResourceIcon = ({ type, size = 15 }: Props) => { + switch (type) { + case "domain": + return ; + case "subnet": + return ; + case "host": + return ; + default: + return ; + } +}; diff --git a/src/assets/icons/ReverseProxyIcon.tsx b/src/assets/icons/ReverseProxyIcon.tsx new file mode 100644 index 00000000..4f989db3 --- /dev/null +++ b/src/assets/icons/ReverseProxyIcon.tsx @@ -0,0 +1,15 @@ +import { iconProperties, IconProps } from "@/assets/icons/IconProperties"; + +export default function ReverseProxyIcon(props: IconProps) { + return ( + + + + ); +} diff --git a/src/auth/OIDCProvider.tsx b/src/auth/OIDCProvider.tsx index 6314f0ae..424cc3c3 100644 --- a/src/auth/OIDCProvider.tsx +++ b/src/auth/OIDCProvider.tsx @@ -6,9 +6,8 @@ import { OidcProvider, } from "@axa-fr/react-oidc"; import FullScreenLoading from "@components/ui/FullScreenLoading"; -import { useLocalStorage } from "@hooks/useLocalStorage"; import loadConfig, { buildExtras } from "@utils/config"; -import { usePathname, useRouter, useSearchParams } from "next/navigation"; +import { usePathname, useRouter } from "next/navigation"; import React, { useEffect, useState } from "react"; import { OIDCError } from "@/auth/OIDCError"; import { SecureProvider } from "@/auth/SecureProvider"; @@ -43,33 +42,6 @@ export default function OIDCProvider({ children }: Props) { const [mounted, setMounted] = useState(false); const router = useRouter(); const path = usePathname(); - const params = useSearchParams()?.toString(); - const [, setQueryParams] = useLocalStorage("netbird-query-params", params); - - useEffect(() => { - const validParams = [ - "tab", - "search", - "id", - "invite", - "utm_source", - "utm_medium", - "utm_content", - "utm_campaign", - "hs_id", - "page", - "page_size", - "user", - "port", - ]; - - try { - const urlParams = new URLSearchParams(params); - if (validParams.some((param) => urlParams.has(param))) { - setQueryParams(params); - } - } catch (e) {} - }, []); const withCustomHistory = () => { return { diff --git a/src/auth/SecureProvider.tsx b/src/auth/SecureProvider.tsx index dcc47d4b..e4bc6ca8 100644 --- a/src/auth/SecureProvider.tsx +++ b/src/auth/SecureProvider.tsx @@ -3,6 +3,23 @@ import { usePathname } from "next/navigation"; import * as React from "react"; import { useEffect } from "react"; +const QUERY_PARAMS_KEY = "netbird-query-params"; +const VALID_PARAMS = [ + "tab", + "search", + "id", + "invite", + "utm_source", + "utm_medium", + "utm_content", + "utm_campaign", + "hs_id", + "page", + "page_size", + "user", + "port", +]; + type Props = { children: React.ReactNode; }; @@ -10,6 +27,22 @@ export const SecureProvider = ({ children }: Props) => { const { isAuthenticated, login } = useOidc(); const currentPath = usePathname(); + useEffect(() => { + if (isAuthenticated) { + localStorage.removeItem(QUERY_PARAMS_KEY); + } else { + try { + const params = window.location.search.substring(1); + if (params) { + const urlParams = new URLSearchParams(params); + if (VALID_PARAMS.some((param) => urlParams.has(param))) { + localStorage.setItem(QUERY_PARAMS_KEY, JSON.stringify(params)); + } + } + } catch (e) {} + } + }, [isAuthenticated]); + useEffect(() => { let timeout: NodeJS.Timeout | undefined = undefined; if (!isAuthenticated) { diff --git a/src/components/Breadcrumbs.tsx b/src/components/Breadcrumbs.tsx index cb8358f0..2ebb663c 100644 --- a/src/components/Breadcrumbs.tsx +++ b/src/components/Breadcrumbs.tsx @@ -1,6 +1,6 @@ import { cn } from "@utils/helpers"; import { ChevronRightIcon } from "lucide-react"; -import { useRouter } from "next/navigation"; +import Link from "next/link"; import React from "react"; type Props = { @@ -25,8 +25,6 @@ export const Item = ({ active, disabled = false, }: ItemProps) => { - const router = useRouter(); - return (
{icon && icon} - {href ? router.push(href)}>{label} : label} + {href ? ( + + {label} + + ) : ( + label + )}
); diff --git a/src/components/Button.tsx b/src/components/Button.tsx index a3e0947c..302b9423 100644 --- a/src/components/Button.tsx +++ b/src/components/Button.tsx @@ -54,7 +54,7 @@ export const buttonVariants = cva( dotted: [ "bg-white hover:text-black focus:ring-zinc-200/50 hover:bg-gray-100 border-gray-200 text-gray-900 border-dashed", "dark:ring-offset-neutral-950/50 dark:focus:ring-neutral-500/20 ", - "dark:bg-nb-gray-900/30 dark:text-gray-400 dark:border-gray-500/40 dark:hover:text-white dark:hover:bg-zinc-800/50", + "dark:bg-nb-gray-900/30 dark:text-gray-400 dark:border-gray-500/40 dark:hover:text-white dark:hover:bg-nb-gray-900/50", ], tertiary: [ "bg-white hover:text-black focus:ring-zinc-200/50 hover:bg-gray-100 border-gray-200 text-gray-900", @@ -73,6 +73,9 @@ export const buttonVariants = cva( "enabled:dark:focus:ring-red-800/20 enabled:dark:focus:bg-red-950/40 enabled:hover:dark:bg-red-950/50 enabled:dark:hover:border-red-800/50 dark:bg-transparent dark:text-red-500", "", ], + "danger-text": [ + "dark:bg-transparent dark:text-red-500 dark:hover:text-red-600 dark:border-transparent !px-0 !shadow-none !py-0 focus:ring-red-500/30 dark:ring-offset-neutral-950/50", + ], "default-outline": [ "dark:ring-offset-nb-gray-950/50 dark:focus:ring-nb-gray-500/20", "dark:bg-transparent dark:text-nb-gray-400 dark:border-transparent dark:hover:text-white dark:hover:bg-nb-gray-900/30 dark:hover:border-nb-gray-800/50", diff --git a/src/components/Callout.tsx b/src/components/Callout.tsx index 56481df4..01832c90 100644 --- a/src/components/Callout.tsx +++ b/src/components/Callout.tsx @@ -19,6 +19,8 @@ export const calloutVariants = cva( default: "bg-nb-gray-900/60 border-nb-gray-800/80 text-nb-gray-300", warning: "bg-netbird-500/10 border-netbird-400/20 text-netbird-150", info: "bg-sky-400/10 border-sky-400/20 text-sky-100", + success: "bg-green-400/15 border-green-400/20 text-green-100", + error: "bg-red-500/10 border-red-400/20 text-red-100", }, }, }, diff --git a/src/components/CopyToClipboardText.tsx b/src/components/CopyToClipboardText.tsx index 7173f0ed..04ef3a4d 100644 --- a/src/components/CopyToClipboardText.tsx +++ b/src/components/CopyToClipboardText.tsx @@ -22,11 +22,7 @@ export default function CopyToClipboardText({ return (
{ e.stopPropagation(); e.preventDefault(); @@ -34,27 +30,34 @@ export default function CopyToClipboardText({ }} ref={wrapper} > - {children} + + {children} + + - {copied ? ( + - ) : ( - )} +
); } diff --git a/src/components/DatePickerWithRange.tsx b/src/components/DatePickerWithRange.tsx index bdaaf894..874063c8 100644 --- a/src/components/DatePickerWithRange.tsx +++ b/src/components/DatePickerWithRange.tsx @@ -15,6 +15,7 @@ interface Props { value?: DateRange; onChange?: (range: DateRange | undefined) => void; className?: string; + disabled?: boolean; } const defaultRanges = { @@ -61,6 +62,7 @@ export function DatePickerWithRange({ className, value, onChange, + disabled = false, }: Readonly) { const isActive = useMemo(() => { return { @@ -120,6 +122,7 @@ export function DatePickerWithRange({
-
{children && value ? children : null}
+ {children && value ? ( +
e.stopPropagation()}> + {children} +
+ ) : null} ); } diff --git a/src/components/HelpTooltip.tsx b/src/components/HelpTooltip.tsx new file mode 100644 index 00000000..3a85d427 --- /dev/null +++ b/src/components/HelpTooltip.tsx @@ -0,0 +1,30 @@ +import * as React from "react"; +import FullTooltip from "@components/FullTooltip"; + +type Props = { + content: React.ReactNode; + children: React.ReactNode; + interactive?: boolean; +}; +export const HelpTooltip = ({ + content, + children, + interactive = true, +}: Props) => { + return ( + <> + + {children} + + + ); +}; diff --git a/src/components/Input.tsx b/src/components/Input.tsx index 7ed035a6..142bb7aa 100644 --- a/src/components/Input.tsx +++ b/src/components/Input.tsx @@ -127,6 +127,8 @@ const Input = React.forwardRef( suffix && "!pr-16", icon && "!pl-10", "border", + props.readOnly && + "!bg-nb-gray-920 text-nb-gray-400 !border-nb-gray-800", className, )} /> diff --git a/src/components/Label.tsx b/src/components/Label.tsx index 8b9641ba..cac9dc20 100644 --- a/src/components/Label.tsx +++ b/src/components/Label.tsx @@ -9,17 +9,34 @@ const labelVariants = cva( "text-sm font-medium tracking-wider leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 mb-1.5 inline-block dark:text-nb-gray-200 flex items-center gap-2", ); -const Label = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef & - VariantProps ->(({ className, ...props }, ref) => ( - -)); +type LabelProps = React.ComponentPropsWithoutRef & + VariantProps & { + as?: "label" | "div"; + }; + +const Label = React.forwardRef( + ({ className, as = "label", children, ...props }, ref) => { + const classes = cn(labelVariants(), className, "select-none"); + + if (as === "div") { + return ( +
} className={classes}> + {children} +
+ ); + } + + return ( + } + className={classes} + {...props} + > + {children} + + ); + }, +); Label.displayName = LabelPrimitive.Root.displayName; export { Label }; diff --git a/src/components/Notification.tsx b/src/components/Notification.tsx index 99d4edaf..2efb81a5 100644 --- a/src/components/Notification.tsx +++ b/src/components/Notification.tsx @@ -2,11 +2,11 @@ import { IconCircleX } from "@tabler/icons-react"; import type { ErrorResponse } from "@utils/api"; import { cn } from "@utils/helpers"; import classNames from "classnames"; -import { AnimatePresence, motion } from "framer-motion"; +import { motion } from "framer-motion"; import { CheckIcon, Loader2, XIcon } from "lucide-react"; import * as React from "react"; -import { useEffect, useState } from "react"; -import toast, { type Toast } from "react-hot-toast"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { toast } from "sonner"; export interface NotifyProps { title: string; @@ -22,14 +22,15 @@ export interface NotifyProps { } interface NotificationProps extends NotifyProps { - t: Toast; + toastId: string | number; } + export default function Notification({ title, description, icon, backgroundColor, - t, + toastId, promise, loadingTitle, loadingMessage, @@ -39,17 +40,65 @@ export default function Notification({ }: NotificationProps) { const [error, setError] = useState(""); const [loading, setLoading] = useState(!!promise); + const [readyToDismiss, setReadyToDismiss] = useState(!promise); + + const timerRef = useRef | null>(null); + const remainingRef = useRef(duration); + const startTimeRef = useRef(null); - const [toastDuration] = useState(duration); + 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)); + }, [toastId]); - const [preventSuccess, setPreventSuccess] = useState(false); + const pauseTimer = useCallback(() => { + if (!timerRef.current || !startTimeRef.current) return; + clearTimeout(timerRef.current); + timerRef.current = null; + remainingRef.current = Math.max( + 0, + remainingRef.current - (Date.now() - startTimeRef.current), + ); + }, []); - const closeToast = () => { - setTimeout(() => { - setLoading(false); - toast.dismiss(t.id); - }, toastDuration); - }; + const notificationRef = useRef(null); + + // Watch for sonner's expanded state to pause/resume timer + useEffect(() => { + if (!readyToDismiss) return; + + const toastEl = notificationRef.current?.closest( + "[data-sonner-toast]", + ) as HTMLElement | null; + if (!toastEl) { + startTimer(); + return; + } + + const observer = new MutationObserver(() => { + const expanded = toastEl.getAttribute("data-expanded") === "true"; + if (expanded) { + pauseTimer(); + } else { + startTimer(); + } + }); + + observer.observe(toastEl, { attributes: true, attributeFilter: ["data-expanded"] }); + + // Start immediately if not expanded + const expanded = toastEl.getAttribute("data-expanded") === "true"; + if (!expanded) startTimer(); + + return () => { + observer.disconnect(); + if (timerRef.current) clearTimeout(timerRef.current); + }; + }, [readyToDismiss, toastId, startTimer, pauseTimer]); useEffect(() => { // Run the promise @@ -57,8 +106,11 @@ export default function Notification({ promise .then(() => { setLoading(false); - closeToast(); - if (preventSuccessToast) setPreventSuccess(true); + if (preventSuccessToast) { + toast.dismiss(toastId); + } else { + setReadyToDismiss(true); + } }) .catch((e) => { const err = e as ErrorResponse; @@ -78,78 +130,76 @@ export default function Notification({ } setLoading(false); - closeToast(); + setReadyToDismiss(true); }); - } else { - closeToast(); } // eslint-disable-next-line react-hooks/exhaustive-deps }, []); return ( - - {t.visible && !preventSuccess && ( - -
-
- {loading ? ( - - ) : error ? ( - - ) : ( - icon || - )} -
-
-

- - {loading ? loadingTitle || title : title} - -

-

- {loading ? loadingMessage : error ? error : description} -

-
+ +
+
+
+ {loading ? ( + + ) : error ? ( + + ) : ( + icon || + )}
+
+

+ + {loading ? loadingTitle || title : title} + +

+

+ {loading ? loadingMessage : error ? error : description} +

+
+
- - - )} - + +
+ +
+
); } export function notify(props: NotifyProps) { - return toast.custom((t) => , { + return toast.custom((id) => , { duration: Infinity, }); } diff --git a/src/components/PeerGroupSelector.tsx b/src/components/PeerGroupSelector.tsx index 7fafdbc5..e3b6037b 100644 --- a/src/components/PeerGroupSelector.tsx +++ b/src/components/PeerGroupSelector.tsx @@ -44,6 +44,9 @@ import { PolicyRuleResource } from "@/interfaces/Policy"; import { User } from "@/interfaces/User"; import { HorizontalUsersStack } from "@/modules/users/HorizontalUsersStack"; import { PeerOperatingSystemIcon } from "@/modules/peers/PeerOperatingSystemIcon"; +import TruncatedText from "@components/ui/TruncatedText"; + +type PeerGroupSelectorTab = "peers" | "groups" | "resources"; const groupsSearchPredicate = (item: Group, query: string) => { const lowerCaseQuery = query.toLowerCase(); @@ -68,6 +71,9 @@ interface MultiSelectProps { showResourceCounter?: boolean; showResources?: boolean; showPeers?: boolean; + hideGroupsTab?: boolean; + tabOrder?: ("groups" | "peers" | "resources")[]; + closeOnSelect?: boolean; resource?: PolicyRuleResource; onResourceChange?: (resource?: PolicyRuleResource) => void; placeholder?: string; @@ -76,6 +82,7 @@ interface MultiSelectProps { side?: "top" | "bottom"; users?: User[]; placeholderForSearch?: string; + resourceIds?: string[]; } export function PeerGroupSelector({ onChange, @@ -94,6 +101,9 @@ export function PeerGroupSelector({ showResourceCounter = true, showResources = false, showPeers = false, + hideGroupsTab = false, + tabOrder, + closeOnSelect = false, resource, onResourceChange, placeholder = "Add or select group(s)...", @@ -102,6 +112,7 @@ export function PeerGroupSelector({ side = "bottom", users, placeholderForSearch = 'Search groups or add new group by pressing "Enter"...', + resourceIds, }: Readonly) { const { data: resources, isLoading: isResourcesLoading } = useFetchApi< NetworkResource[] @@ -229,7 +240,13 @@ export function PeerGroupSelector({ const [slice, setSlice] = useState(10); - const [tab, setTab] = useState("groups"); + const getDefaultTab = (): PeerGroupSelectorTab => { + if (tabOrder?.[0]) return tabOrder[0]; + if (hideGroupsTab) return showPeers ? "peers" : "resources"; + return "groups"; + }; + + const [tab, setTab] = useState(getDefaultTab); useEffect(() => { if (open) { @@ -272,6 +289,9 @@ export function PeerGroupSelector({ : undefined, ); onChange([]); + if (closeOnSelect) { + setOpen(false); + } }; const selectPeer = (peer?: Peer) => { @@ -281,6 +301,9 @@ export function PeerGroupSelector({ type: "peer", }); onChange([]); + if (closeOnSelect) { + setOpen(false); + } }; return ( @@ -438,11 +461,20 @@ export function PeerGroupSelector({ - + setTab(v as PeerGroupSelectorTab)} + > @@ -562,7 +594,11 @@ export function PeerGroupSelector({ resourceIds.includes(r.id)) + : resources + } isLoading={isResourcesLoading} value={resource} onChange={selectResource} @@ -592,60 +628,89 @@ const TabTriggers = ({ searchRef, showResources = false, showPeers = false, + hideGroupsTab = false, + tabOrder, }: { searchRef: React.MutableRefObject; showResources?: boolean; showPeers?: boolean; + hideGroupsTab?: boolean; + tabOrder?: ("groups" | "peers" | "resources")[]; }) => { - if (!showResources && !showPeers) return null; + const tabCount = + (!hideGroupsTab ? 1 : 0) + (showResources ? 1 : 0) + (showPeers ? 1 : 0); + if (tabCount <= 1) return null; + + const groupsTab = !hideGroupsTab && ( + searchRef.current?.focus()} + > + + Groups + + ); + + const resourcesTab = showResources && ( + searchRef.current?.focus()} + > + + Resources + + ); + + const peersTab = showPeers && ( + searchRef.current?.focus()} + > + + Peers + + ); + + const tabMap = { + groups: groupsTab, + peers: peersTab, + resources: resourcesTab, + }; + + if (tabOrder) { + return ( + + {tabOrder.map((tab) => tabMap[tab])} + + ); + } return ( - searchRef.current?.focus()} - > - - Groups - - - {showResources && ( - searchRef.current?.focus()} - > - - Resources - - )} - - {showPeers && ( - searchRef.current?.focus()} - > - - Peers - - )} + {groupsTab} + {resourcesTab} + {peersTab} ); }; @@ -787,6 +852,7 @@ const ResourcesList = ({ { return ( @@ -896,6 +962,7 @@ const PeersList = ({ { if (!res?.id) return; @@ -904,7 +971,7 @@ const PeersList = ({
- +
diff --git a/src/components/PeerSelector.tsx b/src/components/PeerSelector.tsx index 520a9df9..df7101b3 100644 --- a/src/components/PeerSelector.tsx +++ b/src/components/PeerSelector.tsx @@ -13,7 +13,6 @@ import { ArrowUpCircleIcon, ChevronsUpDown, MapPin } from "lucide-react"; import * as React from "react"; import { memo, useEffect, useState } from "react"; import { useElementSize } from "@/hooks/useElementSize"; -import { OperatingSystem } from "@/interfaces/OperatingSystem"; import { Peer } from "@/interfaces/Peer"; import { PeerOperatingSystemIcon } from "@/modules/peers/PeerOperatingSystemIcon"; diff --git a/src/components/PinCodeInput.tsx b/src/components/PinCodeInput.tsx new file mode 100644 index 00000000..436ca4c4 --- /dev/null +++ b/src/components/PinCodeInput.tsx @@ -0,0 +1,123 @@ +"use client"; + +import { cn } from "@utils/helpers"; +import React, { + ClipboardEvent, + forwardRef, + KeyboardEvent, + useImperativeHandle, + useRef, +} from "react"; + +export interface PinCodeInputRef { + focus: () => void; +} + +interface Props { + value: string; + onChange: (value: string) => void; + length?: number; + disabled?: boolean; + className?: string; + type?: "text" | "password"; +} + +const PinCodeInput = forwardRef(function PinCodeInput( + { value, onChange, length = 6, disabled = false, className, type = "text" }, + ref, +) { + const inputRefs = useRef<(HTMLInputElement | null)[]>([]); + + useImperativeHandle(ref, () => ({ + focus: () => { + inputRefs.current[0]?.focus(); + }, + })); + + const digits = value + .split("") + .concat(Array(length).fill("")) + .slice(0, length); + + const handleChange = (index: number, digit: string) => { + if (!/^\d*$/.test(digit)) return; + + const newDigits = [...digits]; + newDigits[index] = digit.slice(-1); + const newValue = newDigits.join("").replace(/\s/g, ""); + onChange(newValue); + + if (digit && index < length - 1) { + inputRefs.current[index + 1]?.focus(); + } + }; + + const handleKeyDown = (index: number, e: KeyboardEvent) => { + if (e.key === "Backspace" && !digits[index] && index > 0) { + inputRefs.current[index - 1]?.focus(); + } + if (e.key === "ArrowLeft" && index > 0) { + inputRefs.current[index - 1]?.focus(); + } + if (e.key === "ArrowRight" && index < length - 1) { + inputRefs.current[index + 1]?.focus(); + } + if (/^\d$/.test(e.key) && digits[index]) { + e.preventDefault(); + const newDigits = [...digits]; + newDigits[index] = e.key; + onChange(newDigits.join("").replace(/\s/g, "")); + if (index < length - 1) { + inputRefs.current[index + 1]?.focus(); + } + } + }; + + const handlePaste = (e: ClipboardEvent) => { + e.preventDefault(); + const pastedData = e.clipboardData + .getData("text") + .replace(/\D/g, "") + .slice(0, length); + onChange(pastedData); + + const nextIndex = Math.min(pastedData.length, length - 1); + inputRefs.current[nextIndex]?.focus(); + }; + + const handleFocus = (e: React.FocusEvent) => { + e.target.select(); + }; + + return ( +
+ {digits.map((digit, index) => ( + { + inputRefs.current[index] = el; + }} + type={type} + inputMode="numeric" + maxLength={1} + value={digit} + onChange={(e) => handleChange(index, e.target.value)} + onKeyDown={(e) => handleKeyDown(index, e)} + onPaste={handlePaste} + onFocus={handleFocus} + disabled={disabled} + className={cn( + "w-[42px] h-[42px] text-center text-sm rounded-md", + "dark:bg-nb-gray-900 border dark:border-nb-gray-700", + "dark:placeholder:text-neutral-400/70", + "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2", + "ring-offset-neutral-200/20 dark:ring-offset-neutral-950/50 dark:focus-visible:ring-neutral-500/20", + "disabled:cursor-not-allowed disabled:opacity-40", + )} + /> + ))} +
+ ); +}); + +export default PinCodeInput; diff --git a/src/components/PortSelector.tsx b/src/components/PortSelector.tsx index e33628b7..7d176a95 100644 --- a/src/components/PortSelector.tsx +++ b/src/components/PortSelector.tsx @@ -188,7 +188,6 @@ export function PortSelector({ "dark:placeholder:text-nb-gray-400 font-light placeholder:text-neutral-500 pl-10", )} data-cy={"port-input"} - typeof={"number"} ref={searchRef} value={search} onValueChange={setSearch} diff --git a/src/components/RadioGroup.tsx b/src/components/RadioGroup.tsx index e0064460..083045f0 100644 --- a/src/components/RadioGroup.tsx +++ b/src/components/RadioGroup.tsx @@ -1,7 +1,6 @@ import * as RadixRadioGroup from "@radix-ui/react-radio-group"; import { cn } from "@utils/helpers"; import * as React from "react"; -import { useState } from "react"; type Props = { value: string; @@ -10,10 +9,8 @@ type Props = { }; export const RadioGroup = ({ value, onChange, children }: Props) => { - const [defaultValue] = useState(value); return ( void; +}; + +function SettingCardItem({ + label, + description, + enabled, + onClick, +}: Readonly) { + return ( +
{ + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + onClick(); + } + }} + className={ + "flex justify-between gap-10 px-6 border-t border-nb-gray-920 first:border-t-0 py-5 hover:bg-nb-gray-935 cursor-pointer transition-colors" + } + > +
+
+ + {enabled && ( + + )} +
+ {description} +
+
e.stopPropagation()}> + {enabled ? ( + + ) : ( + + )} +
+
+ ); +} + +type SettingCardProps = { + children: React.ReactNode; + className?: string; +}; + +function SettingCard({ children, className }: Readonly) { + return ( +
+ {children} +
+ ); +} + +const SettingCardWithItem = SettingCard as React.FC> & { + Item: typeof SettingCardItem; +}; +SettingCardWithItem.Item = SettingCardItem; + +export default SettingCardWithItem; diff --git a/src/components/SidebarItem.tsx b/src/components/SidebarItem.tsx index 48d61a0f..7656436f 100644 --- a/src/components/SidebarItem.tsx +++ b/src/components/SidebarItem.tsx @@ -5,7 +5,7 @@ import { cn } from "@utils/helpers"; import classNames from "classnames"; import { ChevronDownIcon, ChevronUpIcon, DotIcon } from "lucide-react"; import { usePathname, useRouter } from "next/navigation"; -import React, { useMemo } from "react"; +import React, { useEffect, useMemo } from "react"; import { useApplicationContext } from "@/contexts/ApplicationProvider"; export type SidebarItemProps = { @@ -36,8 +36,22 @@ export default function SidebarItem({ labelClassName, visible, }: Readonly) { - const [open, setOpen] = React.useState(false); const path = usePathname(); + + // Check if any child route is active (for collapsible items) + const hasActiveChild = useMemo(() => { + if (!collapsible || !href) return false; + return path === href || path.startsWith(href + "/"); + }, [collapsible, href, path]); + + const [open, setOpen] = React.useState(hasActiveChild); + + // Open the collapsible if a child route becomes active + useEffect(() => { + if (hasActiveChild && !open) { + setOpen(true); + } + }, [hasActiveChild]); const router = useRouter(); const { mobileNavOpen, toggleMobileNav, isNavigationCollapsed } = useApplicationContext(); @@ -48,6 +62,7 @@ export default function SidebarItem({ ? path == href : path.includes(href) : false; + if (collapsible && href) return; if (collapsible && mobileNavOpen) return; if (collapsible && open) return; if (preventRedirect) return; @@ -66,7 +81,7 @@ export default function SidebarItem({ return ( -
  • +