From 13b391ac4bd99a11ff15483dad2b5854512978c7 Mon Sep 17 00:00:00 2001 From: Eduard Gert Date: Thu, 12 Feb 2026 14:52:01 +0100 Subject: [PATCH 01/15] Add reverse proxy --- .gitignore | 2 + package-lock.json | 69 +- package.json | 4 +- src/app/(dashboard)/events/proxy/page.tsx | 73 ++ 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 | 72 ++ src/app/(dashboard)/settings/page.tsx | 2 +- src/app/globals.css | 16 + 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 | 8 +- src/components/Button.tsx | 5 +- src/components/Callout.tsx | 2 + src/components/CopyToClipboardText.tsx | 31 +- src/components/DatePickerWithRange.tsx | 3 + .../nodes => components}/DeviceCard.tsx | 65 +- 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/SettingCard.tsx | 92 ++ 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 ++++--- .../table/DataTableHeadingPortal.tsx | 36 +- 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 | 636 ++++++++++++++ src/contexts/ServerPaginationProvider.tsx | 222 +++++ src/hooks/useUrlTab.ts | 27 + src/interfaces/Network.ts | 1 + src/interfaces/Permission.ts | 2 + src/interfaces/ReverseProxy.ts | 116 +++ src/layouts/AppLayout.tsx | 13 +- src/layouts/Navigation.tsx | 78 +- src/modules/activity/ActivityDescription.tsx | 14 +- .../NetworkRoutingPeerCount.tsx | 2 +- .../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 | 16 +- .../misc/NetworkInformationSquare.tsx | 2 +- .../resources/NetworkResourceModal.tsx | 14 +- .../resources/ResourceExposeServiceCell.tsx | 74 ++ .../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/OnboardingDevices.tsx | 109 +-- src/modules/peer/AccessiblePeersSection.tsx | 7 +- src/modules/peer/PeerNetworkRoutesSection.tsx | 27 +- src/modules/peer/PeerRemoteJobsSection.tsx | 7 +- src/modules/peers/PeerNameCell.tsx | 2 +- src/modules/peers/PeerVersionCell.tsx | 7 +- src/modules/peers/PeersTable.tsx | 4 +- .../reverse-proxy/ReverseProxyModal.tsx | 814 ++++++++++++++++++ .../reverse-proxy/auth/AuthPasswordModal.tsx | 118 +++ .../reverse-proxy/auth/AuthPinModal.tsx | 109 +++ .../reverse-proxy/auth/AuthSSOModal.tsx | 97 +++ .../domain/CustomDomainClusterCell.tsx | 36 + .../domain/CustomDomainModal.tsx | 180 ++++ .../domain/CustomDomainSelector.tsx | 123 +++ .../domain/CustomDomainVerificationModal.tsx | 158 ++++ .../domain/CustomDomainsTable.tsx | 352 ++++++++ .../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 | 267 ++++++ .../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 | 51 ++ .../table/ReverseProxyDestinationCell.tsx | 30 + .../table/ReverseProxyNameCell.tsx | 67 ++ .../table/ReverseProxyStatusCell.tsx | 65 ++ .../reverse-proxy/table/ReverseProxyTable.tsx | 349 ++++++++ .../table/ReverseProxyTargetsCell.tsx | 52 ++ .../targets/ReverseProxyTargetActionCell.tsx | 46 + .../targets/ReverseProxyTargetActiveCell.tsx | 32 + .../targets/ReverseProxyTargetContext.tsx | 16 + .../targets/ReverseProxyTargetDevice.tsx | 88 ++ .../targets/ReverseProxyTargetModal.tsx | 596 +++++++++++++ .../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 +- 128 files changed, 7738 insertions(+), 1038 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 rename src/{modules/control-center/nodes => components}/DeviceCard.tsx (56%) 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 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 5cba48e37..86420ac0e 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 797d9f219..f2f14ee17 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 8c4817f91..c0d4d8341 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 000000000..f62818c97 --- /dev/null +++ b/src/app/(dashboard)/events/proxy/page.tsx @@ -0,0 +1,73 @@ +"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"; + +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 10967154c..eeb3d0f15 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 316f566f9..c2ba2eabf 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 bf432c319..e43560dd1 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.routes.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.routes.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 cf718630a..becc30659 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 000000000..705299033 --- /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 000000000..d052c0ce9 --- /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 000000000..1019c22c1 --- /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.push("/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 000000000..b895c6b6c --- /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 000000000..4a89758ef --- /dev/null +++ b/src/app/(dashboard)/reverse-proxy/services/page.tsx @@ -0,0 +1,72 @@ +"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"; + +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. + + + + 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. + +
+ + + + }> + + + + +
+ ); +} diff --git a/src/app/(dashboard)/settings/page.tsx b/src/app/(dashboard)/settings/page.tsx index 632173627..149602f9b 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 d1948563f..3dd2e7981 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,17 @@ 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; +} /* 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 000000000..3948d5f9f --- /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 000000000..b02ad1bc4 --- /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 000000000..a2d817c7f --- /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 000000000..4f989db34 --- /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 6314f0aed..424cc3c39 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 dcc47d4b1..e4bc6ca89 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 cb8358f04..1e5e13bfc 100644 --- a/src/components/Breadcrumbs.tsx +++ b/src/components/Breadcrumbs.tsx @@ -45,7 +45,13 @@ export const Item = ({ )} > {icon && icon} - {href ? router.push(href)}>{label} : label} + {href ? ( + router.push(href)} data-cy={"breadcrumb-item"}> + {label} + + ) : ( + label + )}
); diff --git a/src/components/Button.tsx b/src/components/Button.tsx index a3e0947c7..302b94235 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 56481df46..01832c904 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 7173f0edd..04ef3a4d0 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 bdaaf8941..874063c8d 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 000000000..3a85d4272 --- /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 7ed035a68..142bb7aa5 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 8b9641ba8..cac9dc205 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 99d4edaff..2efb81a5d 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 7fafdbc52..e3b6037b3 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 520a9df9b..df7101b3e 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 000000000..436ca4c40 --- /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 e33628b77..7d176a95f 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/SettingCard.tsx b/src/components/SettingCard.tsx new file mode 100644 index 000000000..1f04b52b1 --- /dev/null +++ b/src/components/SettingCard.tsx @@ -0,0 +1,92 @@ +"use client"; + +import Button from "@components/Button"; +import HelpText from "@components/HelpText"; +import { Label } from "@components/Label"; +import { SmallBadge } from "@components/ui/SmallBadge"; +import { cn } from "@utils/helpers"; +import { PlusCircle, SquarePen } from "lucide-react"; +import React from "react"; + +type SettingCardItemProps = { + label: React.ReactNode; + description: React.ReactNode; + enabled: boolean; + onClick: () => void; +}; + +function SettingCardItem({ + label, + description, + enabled, + onClick, +}: Readonly) { + return ( +
+
+
+ + {enabled && ( + + )} +
+ {description} +
+
e.stopPropagation()}> + {enabled ? ( + + ) : ( + + )} +
+
+ ); +} + +type SettingCardProps = { + children: React.ReactNode; + className?: string; +}; + +function SettingCard({ children, className }: Readonly) { + return ( +
+ {children} +
+ ); +} + +SettingCard.Item = SettingCardItem; + +export default SettingCard; diff --git a/src/components/SidebarItem.tsx b/src/components/SidebarItem.tsx index 48d61a0f6..7887505e8 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.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 ( -
  • +