Add reverse proxy (#552)
Some checks failed
build and push / build_n_push (push) Has been cancelled

* **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.
This commit is contained in:
Eduard Gert
2026-02-13 18:59:16 +01:00
committed by GitHub
parent 84c239ce30
commit b71d0fde89
134 changed files with 7912 additions and 1164 deletions

2
.gitignore vendored
View File

@@ -37,6 +37,8 @@ next-env.d.ts
# config # config
.local-config.json .local-config.json
.test-config.json
cypress.env.json
.configs/.local-config.zitadel.json .configs/.local-config.zitadel.json
.configs/.staging-config.json .configs/.staging-config.json
.configs/.temp-config.json .configs/.temp-config.json

69
package-lock.json generated
View File

@@ -59,7 +59,7 @@
"ip-cidr": "^3.1.0", "ip-cidr": "^3.1.0",
"js-cookie": "^3.0.5", "js-cookie": "^3.0.5",
"lodash": "^4.17.23", "lodash": "^4.17.23",
"lucide-react": "^0.539.0", "lucide-react": "^0.562.0",
"next": "^16.1.6", "next": "^16.1.6",
"next-themes": "^0.2.1", "next-themes": "^0.2.1",
"punycode": "^2.3.1", "punycode": "^2.3.1",
@@ -67,7 +67,6 @@
"react-day-picker": "^9.13.0", "react-day-picker": "^9.13.0",
"react-dom": "^19.2.4", "react-dom": "^19.2.4",
"react-ga4": "^2.1.0", "react-ga4": "^2.1.0",
"react-hot-toast": "^2.4.1",
"react-hotjar": "^6.3.1", "react-hotjar": "^6.3.1",
"react-hotkeys-hook": "^4.4.1", "react-hotkeys-hook": "^4.4.1",
"react-icons": "^5.5.0", "react-icons": "^5.5.0",
@@ -75,6 +74,7 @@
"react-loading-skeleton": "^3.3.1", "react-loading-skeleton": "^3.3.1",
"react-responsive": "^9.0.2", "react-responsive": "^9.0.2",
"react-virtuoso": "^4.9.0", "react-virtuoso": "^4.9.0",
"sonner": "^2.0.7",
"swr": "^2.2.4", "swr": "^2.2.4",
"tailwind-merge": "^1.14.0", "tailwind-merge": "^1.14.0",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
@@ -90,6 +90,9 @@
"postcss": "^8", "postcss": "^8",
"prettier": "3.0.3", "prettier": "3.0.3",
"tailwindcss": "^3.4.17" "tailwindcss": "^3.4.17"
},
"engines": {
"node": ">=20.9.0"
} }
}, },
"node_modules/@alloc/quick-lru": { "node_modules/@alloc/quick-lru": {
@@ -165,6 +168,7 @@
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/code-frame": "^7.29.0", "@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0", "@babel/generator": "^7.29.0",
@@ -3027,6 +3031,7 @@
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.10.tgz", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.10.tgz",
"integrity": "sha512-WPigyYuGhgZ/cTPRXB2EwUw+XvsRA3GqHlsP4qteqrnnjDrApbS7MxcGr/hke5iUoeB7E/gQtrs9I37zAJ0Vjw==", "integrity": "sha512-WPigyYuGhgZ/cTPRXB2EwUw+XvsRA3GqHlsP4qteqrnnjDrApbS7MxcGr/hke5iUoeB7E/gQtrs9I37zAJ0Vjw==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"csstype": "^3.2.2" "csstype": "^3.2.2"
} }
@@ -3036,6 +3041,7 @@
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"peerDependencies": { "peerDependencies": {
"@types/react": "^19.2.0" "@types/react": "^19.2.0"
} }
@@ -3094,6 +3100,7 @@
"integrity": "sha512-BtE0k6cjwjLZoZixN0t5AKP0kSzlGu7FctRXYuPAm//aaiZhmfq1JwdYpYr1brzEspYyFeF+8XF5j2VK6oalrA==", "integrity": "sha512-BtE0k6cjwjLZoZixN0t5AKP0kSzlGu7FctRXYuPAm//aaiZhmfq1JwdYpYr1brzEspYyFeF+8XF5j2VK6oalrA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@typescript-eslint/scope-manager": "8.54.0", "@typescript-eslint/scope-manager": "8.54.0",
"@typescript-eslint/types": "8.54.0", "@typescript-eslint/types": "8.54.0",
@@ -3600,7 +3607,8 @@
"version": "5.5.0", "version": "5.5.0",
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz", "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz",
"integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==", "integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==",
"license": "MIT" "license": "MIT",
"peer": true
}, },
"node_modules/@xyflow/react": { "node_modules/@xyflow/react": {
"version": "12.10.0", "version": "12.10.0",
@@ -3639,6 +3647,7 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"acorn": "bin/acorn" "acorn": "bin/acorn"
}, },
@@ -4056,6 +4065,7 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"baseline-browser-mapping": "^2.9.0", "baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759", "caniuse-lite": "^1.0.30001759",
@@ -4684,6 +4694,7 @@
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
"license": "ISC", "license": "ISC",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
} }
@@ -5212,6 +5223,7 @@
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.2.tgz", "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.2.tgz",
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1", "@eslint-community/regexpp": "^4.12.1",
@@ -5409,6 +5421,7 @@
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@rtsao/scc": "^1.1.0", "@rtsao/scc": "^1.1.0",
"array-includes": "^3.1.9", "array-includes": "^3.1.9",
@@ -6010,15 +6023,6 @@
"url": "https://github.com/sponsors/ljharb" "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": { "node_modules/gopd": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", "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", "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz",
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"jiti": "bin/jiti.js" "jiti": "bin/jiti.js"
} }
@@ -6918,9 +6923,9 @@
} }
}, },
"node_modules/lucide-react": { "node_modules/lucide-react": {
"version": "0.539.0", "version": "0.562.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.539.0.tgz", "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.562.0.tgz",
"integrity": "sha512-VVISr+VF2krO91FeuCrm1rSOLACQUYVy7NQkzrOty52Y8TlTPcXcMdQFj9bYzBgXbWCiywlwSZ3Z8u6a+6bMlg==", "integrity": "sha512-82hOAu7y0dbVuFfmO4bYF1XEwYk/mEbM5E+b1jgci/udUBEE/R7LF5Ip0CCEmXe8AybRM8L+04eP+LGZeDvkiw==",
"license": "ISC", "license": "ISC",
"peerDependencies": { "peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
@@ -7465,6 +7470,7 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"nanoid": "^3.3.11", "nanoid": "^3.3.11",
"picocolors": "^1.1.1", "picocolors": "^1.1.1",
@@ -7672,6 +7678,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
} }
@@ -7712,6 +7719,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
"integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"scheduler": "^0.27.0" "scheduler": "^0.27.0"
}, },
@@ -7725,23 +7733,6 @@
"integrity": "sha512-ZKS7PGNFqqMd3PJ6+C2Jtz/o1iU9ggiy8Y8nUeksgVuvNISbmrQtJiZNvC/TjDsqD0QlU5Wkgs7i+w9+OjHhhQ==", "integrity": "sha512-ZKS7PGNFqqMd3PJ6+C2Jtz/o1iU9ggiy8Y8nUeksgVuvNISbmrQtJiZNvC/TjDsqD0QlU5Wkgs7i+w9+OjHhhQ==",
"license": "MIT" "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": { "node_modules/react-hotjar": {
"version": "6.3.1", "version": "6.3.1",
"resolved": "https://registry.npmjs.org/react-hotjar/-/react-hotjar-6.3.1.tgz", "resolved": "https://registry.npmjs.org/react-hotjar/-/react-hotjar-6.3.1.tgz",
@@ -8336,6 +8327,16 @@
"url": "https://github.com/sponsors/ljharb" "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": { "node_modules/source-map-js": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "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", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz",
"integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@alloc/quick-lru": "^5.2.0", "@alloc/quick-lru": "^5.2.0",
"arg": "^5.0.2", "arg": "^5.0.2",
@@ -8779,6 +8781,7 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },
@@ -8944,6 +8947,7 @@
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
"tsserver": "bin/tsserver" "tsserver": "bin/tsserver"
@@ -9271,6 +9275,7 @@
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"funding": { "funding": {
"url": "https://github.com/sponsors/colinhacks" "url": "https://github.com/sponsors/colinhacks"
} }

View File

@@ -67,7 +67,7 @@
"ip-cidr": "^3.1.0", "ip-cidr": "^3.1.0",
"js-cookie": "^3.0.5", "js-cookie": "^3.0.5",
"lodash": "^4.17.23", "lodash": "^4.17.23",
"lucide-react": "^0.539.0", "lucide-react": "^0.562.0",
"next": "^16.1.6", "next": "^16.1.6",
"next-themes": "^0.2.1", "next-themes": "^0.2.1",
"punycode": "^2.3.1", "punycode": "^2.3.1",
@@ -75,7 +75,6 @@
"react-day-picker": "^9.13.0", "react-day-picker": "^9.13.0",
"react-dom": "^19.2.4", "react-dom": "^19.2.4",
"react-ga4": "^2.1.0", "react-ga4": "^2.1.0",
"react-hot-toast": "^2.4.1",
"react-hotjar": "^6.3.1", "react-hotjar": "^6.3.1",
"react-hotkeys-hook": "^4.4.1", "react-hotkeys-hook": "^4.4.1",
"react-icons": "^5.5.0", "react-icons": "^5.5.0",
@@ -83,6 +82,7 @@
"react-loading-skeleton": "^3.3.1", "react-loading-skeleton": "^3.3.1",
"react-responsive": "^9.0.2", "react-responsive": "^9.0.2",
"react-virtuoso": "^4.9.0", "react-virtuoso": "^4.9.0",
"sonner": "^2.0.7",
"swr": "^2.2.4", "swr": "^2.2.4",
"tailwind-merge": "^1.14.0", "tailwind-merge": "^1.14.0",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",

View File

@@ -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<HTMLHeadingElement>();
const defaultFilters = useMemo(
() => ({
start_date: dayjs().subtract(7, "day").startOf("day").toISOString(),
end_date: dayjs().endOf("day").toISOString(),
}),
[],
);
return (
<PageContainer>
<div className="p-default py-6">
<Breadcrumbs>
<Breadcrumbs.Item
label="Activity"
disabled
icon={<ActivityIcon size={13} />}
/>
<Breadcrumbs.Item
href="/events/proxy"
label="Proxy Events"
icon={<ReverseProxyIcon size={15} />}
/>
</Breadcrumbs>
<h1 ref={headingRef}>Proxy Events</h1>
<Paragraph>
View access logs for your reverse proxy services, including allowed
and denied requests.
</Paragraph>
<Paragraph>
Learn more about{" "}
<InlineLink href={REVERSE_PROXY_EVENTS_DOCS_LINK} target="_blank">
Proxy Events <ExternalLinkIcon size={12} />
</InlineLink>{" "}
in our documentation.
</Paragraph>
</div>
<RestrictedAccess
page="Proxy Events"
hasAccess={permission?.services?.read}
>
<ServerPaginationProvider
url="/events/proxy"
defaultPageSize={10}
defaultFilters={defaultFilters}
>
<ReverseProxyEventsTable headingTarget={portalTarget} />
</ServerPaginationProvider>
</RestrictedAccess>
</PageContainer>
);
}

View File

@@ -61,7 +61,7 @@ export default function NetworkRoutes() {
in our documentation. in our documentation.
</Paragraph> </Paragraph>
<Callout className={"max-w-xl mt-3"} variant={"warning"}> <Callout className={"max-w-xl mt-5"} variant={"warning"}>
<span> <span>
We recommend using the new Networks concept to easier visualise We recommend using the new Networks concept to easier visualise
and manage access to your resources.{" "} and manage access to your resources.{" "}

View File

@@ -12,14 +12,14 @@ import {
} from "@components/DropdownMenu"; } from "@components/DropdownMenu";
import FullTooltip from "@components/FullTooltip"; import FullTooltip from "@components/FullTooltip";
import InlineLink from "@components/InlineLink"; import InlineLink from "@components/InlineLink";
import Separator from "@components/Separator";
import FullScreenLoading from "@components/ui/FullScreenLoading"; import FullScreenLoading from "@components/ui/FullScreenLoading";
import useRedirect from "@hooks/useRedirect"; import useRedirect from "@hooks/useRedirect";
import useFetchApi from "@utils/api"; import useFetchApi from "@utils/api";
import { cn } from "@utils/helpers"; import { cn, singularize } from "@utils/helpers";
import { import {
ArrowUpRightIcon, ArrowUpRightIcon,
HelpCircle, HelpCircle,
Layers3Icon,
MoreVertical, MoreVertical,
PencilLineIcon, PencilLineIcon,
ServerIcon, ServerIcon,
@@ -28,19 +28,27 @@ import {
Trash2, Trash2,
} from "lucide-react"; } from "lucide-react";
import { useRouter, useSearchParams } from "next/navigation"; import { useRouter, useSearchParams } from "next/navigation";
import React, { useMemo, useState } from "react"; import React, { useMemo } from "react";
import { useSWRConfig } from "swr"; import useUrlTab from "@/hooks/useUrlTab";
import NetworkRoutesIcon from "@/assets/icons/NetworkRoutesIcon"; import NetworkRoutesIcon from "@/assets/icons/NetworkRoutesIcon";
import { usePermissions } from "@/contexts/PermissionsProvider"; import { usePermissions } from "@/contexts/PermissionsProvider";
import { Network } from "@/interfaces/Network"; import { Network, NetworkResource, NetworkRouter } from "@/interfaces/Network";
import PageContainer from "@/layouts/PageContainer"; import PageContainer from "@/layouts/PageContainer";
import { NetworkInformationSquare } from "@/modules/networks/misc/NetworkInformationSquare"; import { NetworkInformationSquare } from "@/modules/networks/misc/NetworkInformationSquare";
import { import {
NetworkProvider, NetworkProvider,
useNetworksContext, useNetworksContext,
} from "@/modules/networks/NetworkProvider"; } from "@/modules/networks/NetworkProvider";
import { ResourcesSection } from "@/modules/networks/resources/ResourcesSection"; import { ResourcesTabContent } from "@/modules/networks/resources/ResourcesTabContent";
import { NetworkRoutingPeersSection } from "@/modules/networks/routing-peers/NetworkRoutingPeersSection"; 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() { export default function NetworkDetailPage() {
const queryParameter = useSearchParams(); const queryParameter = useSearchParams();
@@ -53,7 +61,9 @@ export default function NetworkDetailPage() {
useRedirect("/networks", false, !networkId); useRedirect("/networks", false, !networkId);
return network && !isLoading ? ( return network && !isLoading ? (
<NetworkOverview network={network} /> <ReverseProxiesProvider initialNetwork={network}>
<NetworkOverview network={network} />
</ReverseProxiesProvider>
) : ( ) : (
<FullScreenLoading /> <FullScreenLoading />
); );
@@ -62,8 +72,23 @@ export default function NetworkDetailPage() {
function NetworkOverview({ network }: Readonly<{ network: Network }>) { function NetworkOverview({ network }: Readonly<{ network: Network }>) {
const { permission } = usePermissions(); const { permission } = usePermissions();
const [networkModal, setNetworkModal] = useState(false); const { data: resources, isLoading: isResourcesLoading } = useFetchApi<
const { mutate } = useSWRConfig(); 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 = !!( const isActive = !!(
network?.routing_peers_count && network.routing_peers_count > 0 network?.routing_peers_count && network.routing_peers_count > 0
@@ -72,7 +97,7 @@ function NetworkOverview({ network }: Readonly<{ network: Network }>) {
return ( return (
<PageContainer> <PageContainer>
<NetworkProvider network={network}> <NetworkProvider network={network}>
<div className={"p-default py-6 mb-4"}> <div className={"p-default py-6"}>
<Breadcrumbs> <Breadcrumbs>
<Breadcrumbs.Item <Breadcrumbs.Item
href={"/networks"} href={"/networks"}
@@ -115,11 +140,58 @@ function NetworkOverview({ network }: Readonly<{ network: Network }>) {
</div> </div>
</div> </div>
<Separator /> <Tabs
<ResourcesSection network={network} /> defaultValue={tab}
<div className={"h-3"} /> onValueChange={setTab}
<Separator /> value={tab}
<NetworkRoutingPeersSection network={network} /> className={"pb-0 mb-0"}
>
<TabsList justify={"start"} className={"px-8"}>
<TabsTrigger value={"resources"}>
<Layers3Icon size={14} />
{singularize("Resources", network?.resources?.length)}
</TabsTrigger>
<TabsTrigger value={"routing-peers"}>
<PeerIcon
size={12}
className={
"fill-nb-gray-500 group-data-[state=active]/trigger:fill-netbird transition-all"
}
/>
{singularize("Routing Peers", network?.routing_peers_count)}
</TabsTrigger>
<TabsTrigger value={"services"}>
<ReverseProxyIcon
size={16}
className={
"fill-nb-gray-500 group-data-[state=active]/trigger:fill-netbird transition-all"
}
/>
{singularize("Services", services.length)}
</TabsTrigger>
</TabsList>
<TabsContent value={"resources"} className={"pb-8"}>
<ResourcesTabContent
data={resources}
isLoading={isResourcesLoading}
/>
</TabsContent>
<TabsContent value={"routing-peers"} className={"pb-8"}>
<NetworkRoutingPeersTabContent
routers={routers}
isLoading={isRoutersLoading}
/>
</TabsContent>
<TabsContent value={"services"} className={"pb-8"}>
<ReverseProxyFlatTargetsTabContent
targets={services}
isLoading={isServicesLoading}
/>
</TabsContent>
</Tabs>
</NetworkProvider> </NetworkProvider>
</PageContainer> </PageContainer>
); );

View File

@@ -26,6 +26,7 @@ import { RestrictedAccess } from "@components/ui/RestrictedAccess";
import TextWithTooltip from "@components/ui/TextWithTooltip"; import TextWithTooltip from "@components/ui/TextWithTooltip";
import useRedirect from "@hooks/useRedirect"; import useRedirect from "@hooks/useRedirect";
import useFetchApi from "@utils/api"; import useFetchApi from "@utils/api";
import { singularize } from "@utils/helpers";
import dayjs from "dayjs"; import dayjs from "dayjs";
import { isEmpty, trim } from "lodash"; import { isEmpty, trim } from "lodash";
import { import {
@@ -36,13 +37,14 @@ import {
FlagIcon, FlagIcon,
Globe, Globe,
History, History,
ListIcon,
MapPin, MapPin,
MonitorSmartphoneIcon, MonitorSmartphoneIcon,
NetworkIcon, NetworkIcon,
PencilIcon, PencilIcon,
RadioTowerIcon, RadioTowerIcon,
TimerResetIcon,
} from "lucide-react"; } from "lucide-react";
import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation"; import { useRouter, useSearchParams } from "next/navigation";
import { toASCII } from "punycode"; import { toASCII } from "punycode";
import React, { useMemo, useState } from "react"; import React, { useMemo, useState } from "react";
@@ -52,21 +54,27 @@ import RoundedFlag from "@/assets/countries/RoundedFlag";
import CircleIcon from "@/assets/icons/CircleIcon"; import CircleIcon from "@/assets/icons/CircleIcon";
import NetBirdIcon from "@/assets/icons/NetBirdIcon"; import NetBirdIcon from "@/assets/icons/NetBirdIcon";
import PeerIcon from "@/assets/icons/PeerIcon"; import PeerIcon from "@/assets/icons/PeerIcon";
import ReverseProxyIcon from "@/assets/icons/ReverseProxyIcon";
import { useCountries } from "@/contexts/CountryProvider"; import { useCountries } from "@/contexts/CountryProvider";
import PeerProvider, { usePeer } from "@/contexts/PeerProvider"; import PeerProvider, { usePeer } from "@/contexts/PeerProvider";
import { usePermissions } from "@/contexts/PermissionsProvider"; import { usePermissions } from "@/contexts/PermissionsProvider";
import RoutesProvider from "@/contexts/RoutesProvider"; import RoutesProvider from "@/contexts/RoutesProvider";
import { useHasChanges } from "@/hooks/useHasChanges"; import { useHasChanges } from "@/hooks/useHasChanges";
import type { Group } from "@/interfaces/Group";
import type { Peer } from "@/interfaces/Peer"; import type { Peer } from "@/interfaces/Peer";
import PageContainer from "@/layouts/PageContainer"; import PageContainer from "@/layouts/PageContainer";
import useGroupHelper from "@/modules/groups/useGroupHelper"; import useGroupHelper from "@/modules/groups/useGroupHelper";
import { AccessiblePeersSection } from "@/modules/peer/AccessiblePeersSection"; import { AccessiblePeersSection } from "@/modules/peer/AccessiblePeersSection";
import { PeerNetworkRoutesSection } from "@/modules/peer/PeerNetworkRoutesSection"; import { PeerNetworkRoutesSection } from "@/modules/peer/PeerNetworkRoutesSection";
import { PeerRemoteJobsSection } from "@/modules/peer/PeerRemoteJobsSection"; 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 { PeerSSHToggle } from "@/modules/peer/PeerSSHToggle";
import { RDPButton } from "@/modules/remote-access/rdp/RDPButton"; import { RDPButton } from "@/modules/remote-access/rdp/RDPButton";
import { SSHButton } from "@/modules/remote-access/ssh/SSHButton"; import { SSHButton } from "@/modules/remote-access/ssh/SSHButton";
import Link from "next/link";
import { PeerExpirationSettings } from "@/modules/peer/PeerExpirationSettings"; import { PeerExpirationSettings } from "@/modules/peer/PeerExpirationSettings";
export default function PeerPage() { export default function PeerPage() {
@@ -99,10 +107,12 @@ export default function PeerPage() {
/> />
); );
return peer && !isLoading ? ( return peer && peer.id && !isLoading ? (
<PeerProvider peer={peer} key={peerId} isPeerDetailPage={true}> <ReverseProxiesProvider initialPeer={peer}>
<PeerOverview key={peer?.id} /> <PeerProvider peer={peer} key={peerId} isPeerDetailPage={true}>
</PeerProvider> <PeerOverview key={peer?.id} />
</PeerProvider>
</ReverseProxiesProvider>
) : ( ) : (
<FullScreenLoading /> <FullScreenLoading />
); );
@@ -114,38 +124,60 @@ function PeerOverview() {
return ( return (
<PageContainer> <PageContainer>
<RoutesProvider> <RoutesProvider>
<div className={"p-default py-6 pb-0"}> <PeerSettingsProvider>
<Breadcrumbs> <div className={"p-default py-6 pb-0"}>
<Breadcrumbs.Item <Breadcrumbs>
href={"/peers"} <Breadcrumbs.Item
label={"Peers"} href={"/peers"}
icon={<PeerIcon size={13} />} label={"Peers"}
/> icon={<PeerIcon size={13} />}
<Breadcrumbs.Item label={peer.ip} active /> />
</Breadcrumbs> <Breadcrumbs.Item label={peer.ip} active />
<PeerGeneralInformation /> </Breadcrumbs>
</div> <PeerHeader />
<PeerOverviewTabs /> </div>
<PeerOverviewTabs />
</PeerSettingsProvider>
</RoutesProvider> </RoutesProvider>
</PageContainer> </PageContainer>
); );
} }
const PeerGeneralInformation = () => { type PeerSettingsContextType = {
const router = useRouter(); selectedGroups: Group[];
setSelectedGroups: React.Dispatch<React.SetStateAction<Group[]>>;
hasChanges: boolean;
updatePeer: (newName?: string) => Promise<void>;
name: string;
setName: (name: string) => void;
tab: string;
setTab: (tab: string) => void;
};
const PeerSettingsContext = React.createContext<PeerSettingsContextType | null>(
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 { mutate } = useSWRConfig();
const { peer, user, peerGroups, update } = usePeer(); const { peer, peerGroups, update } = usePeer();
const { permission } = usePermissions();
const [name, setName] = useState(peer.name); const [name, setName] = useState(peer.name);
const [showEditNameModal, setShowEditNameModal] = useState(false); const [tab, setTab] = useState("overview");
const [selectedGroups, setSelectedGroups, { getAllGroupCalls }] = const [selectedGroups, setSelectedGroups, { getAllGroupCalls }] =
useGroupHelper({ useGroupHelper({
initial: peerGroups?.filter((g) => g?.name !== "All"), initial: peerGroups?.filter((g) => g?.name !== "All"),
peer, peer,
}); });
/**
* Detect if there are changes in the peer information, if there are changes, then enable the save button.
*/
const { hasChanges, updateRef: updateHasChangedRef } = useHasChanges([ const { hasChanges, updateRef: updateHasChangedRef } = useHasChanges([
selectedGroups, selectedGroups,
]); ]);
@@ -175,7 +207,31 @@ const PeerGeneralInformation = () => {
}); });
}; };
return (
<PeerSettingsContext.Provider
value={{
selectedGroups,
setSelectedGroups,
hasChanges,
updatePeer,
name,
setName,
tab,
setTab,
}}
>
{children}
</PeerSettingsContext.Provider>
);
};
const PeerHeader = () => {
const router = useRouter();
const { peer, user } = usePeer();
const { permission } = usePermissions(); const { permission } = usePermissions();
const { name, setName, hasChanges, updatePeer, tab } = usePeerSettings();
const [showEditNameModal, setShowEditNameModal] = useState(false);
const isOverviewTab = tab === "overview";
return ( return (
<> <>
@@ -236,49 +292,145 @@ const PeerGeneralInformation = () => {
</div> </div>
)} )}
</div> </div>
<div className={"flex gap-4"}> {isOverviewTab && (
<Button <div className={"flex gap-4"}>
variant={"default"} <Button
className={"w-full"} variant={"default"}
onClick={() => router.push("/peers")} className={"w-full"}
> onClick={() => router.push("/peers")}
Cancel >
</Button> Cancel
<Button </Button>
variant={"primary"} <Button
className={"w-full"} variant={"primary"}
onClick={() => updatePeer()} className={"w-full"}
disabled={ onClick={() => updatePeer()}
!hasChanges || !permission.peers.read || !permission.groups.update disabled={
} !hasChanges ||
> !permission.peers.update ||
Save Changes !permission.groups.update
</Button> }
</div> >
Save Changes
</Button>
</div>
)}
</div> </div>
</>
);
};
const PeerOverviewTabs = () => {
const { peer } = usePeer();
const { permission } = usePermissions();
const { reverseProxies, isLoading: isServicesLoading } = useReverseProxies();
const { tab, setTab } = usePeerSettings();
const flatTargets = useMemo(
() => flattenReverseProxies({ reverseProxies, peer }),
[reverseProxies, peer],
);
return (
<Tabs
defaultValue={tab}
onValueChange={setTab}
value={tab}
className={"pt-4 pb-0 mb-0"}
>
<TabsList justify={"start"} className={"px-8"}>
<TabsTrigger value={"overview"}>
<ListIcon size={16} />
Overview
</TabsTrigger>
{permission.routes.read && (
<TabsTrigger value={"network-routes"}>
<NetworkIcon size={16} />
Network Routes
</TabsTrigger>
)}
{peer?.id && permission.peers.read && (
<TabsTrigger value={"accessible-peers"}>
<MonitorSmartphoneIcon size={16} />
Accessible Peers
</TabsTrigger>
)}
{peer?.id && permission.services?.read && (
<TabsTrigger value={"reverse-proxies"}>
<ReverseProxyIcon
size={16}
className="fill-nb-gray-400 group-data-[state=active]/trigger:fill-netbird"
/>
{singularize("Services", flatTargets.length)}
</TabsTrigger>
)}
{peer?.id && permission.peers.delete && (
<TabsTrigger value={"peer-job"}>
<RadioTowerIcon size={16} />
Remote Jobs
</TabsTrigger>
)}
</TabsList>
<TabsContent value={"overview"} className={"pb-8"}>
<PeerOverviewTabContent />
</TabsContent>
{permission.routes.read && (
<TabsContent value={"network-routes"} className={"pb-8"}>
<PeerNetworkRoutesSection peer={peer} />
</TabsContent>
)}
{peer?.id && permission.peers.read && (
<TabsContent value={"accessible-peers"} className={"pb-8"}>
<AccessiblePeersSection peerID={peer.id} />
</TabsContent>
)}
{peer?.id && permission.services?.read && (
<TabsContent value={"reverse-proxies"} className={"pb-8"}>
<ReverseProxyFlatTargetsTabContent
targets={flatTargets}
isLoading={isServicesLoading}
hideResourceColumn
emptyTableTitle={"This peer has no services"}
emptyTableDescription={
"Add your services to this peer and securely expose them through NetBird's reverse proxy"
}
/>
</TabsContent>
)}
{peer.id && permission.peers.delete && (
<TabsContent value={"peer-job"} className={"pb-8"}>
<PeerRemoteJobsSection peerID={peer.id} />
</TabsContent>
)}
</Tabs>
);
};
const PeerOverviewTabContent = () => {
const { peer } = usePeer();
const { permission } = usePermissions();
const { selectedGroups, setSelectedGroups } = usePeerSettings();
return (
<div className={"px-8"}>
<div <div
className={ className={
"flex-wrap xl:flex-nowrap flex gap-10 w-full mt-5 max-w-6xl items-start" "flex-wrap xl:flex-nowrap flex gap-10 w-full items-start pt-2 max-w-6xl"
} }
> >
<PeerInformationCard peer={peer} /> <PeerInformationCard peer={peer} />
<div className={"flex flex-col gap-6 lg:w-1/2 transition-all"}> <div className={"flex flex-col gap-8 lg:w-1/2 transition-all"}>
<PeerExpirationSettings /> <PeerExpirationSettings />
<PeerSSHToggle />
{/* Remote Access Buttons */}
<div>
<Label>Remote Access</Label>
<HelpText>Connect directly to this peer via SSH or RDP.</HelpText>
<div className="flex gap-3">
<SSHButton peer={peer} />
<RDPButton peer={peer} />
</div>
</div>
{permission.groups.read && ( {permission.groups.read && (
<div> <div>
<Label>Assigned Groups</Label> <Label>Assigned Groups</Label>
@@ -294,67 +446,21 @@ const PeerGeneralInformation = () => {
/> />
</div> </div>
)} )}
<PeerSSHToggle />
{/* Remote Access Buttons */}
<div>
<Label>Remote Access</Label>
<HelpText>Connect directly to this peer via SSH or RDP.</HelpText>
<div className="flex gap-3">
<SSHButton peer={peer} />
<RDPButton peer={peer} />
</div>
</div>
</div> </div>
</div> </div>
</> </div>
);
};
const PeerOverviewTabs = () => {
const { peer } = usePeer();
const { permission } = usePermissions();
const [tab, setTab] = useState(
permission.routes.read ? "network-routes" : "accessible-peers",
);
return (
<Tabs
defaultValue={tab}
onValueChange={(v) => setTab(v)}
value={tab}
className={"pt-10 pb-0 mb-0"}
>
<TabsList justify={"start"} className={"px-8"}>
{permission.routes.read && (
<TabsTrigger value={"network-routes"}>
<NetworkIcon size={16} />
Network Routes
</TabsTrigger>
)}
{peer?.id && permission.peers.read && (
<TabsTrigger value={"accessible-peers"}>
<MonitorSmartphoneIcon size={16} />
Accessible Peers
</TabsTrigger>
)}
{peer?.id && permission.peers.delete && (
<TabsTrigger value={"peer-job"}>
<RadioTowerIcon size={16} />
Remote Jobs
</TabsTrigger>
)}
</TabsList>
{permission.routes.read && (
<TabsContent value={"network-routes"} className={"pb-8"}>
<PeerNetworkRoutesSection peer={peer} />
</TabsContent>
)}
{peer?.id && permission.peers.read && (
<TabsContent value={"accessible-peers"} className={"pb-8"}>
<AccessiblePeersSection peerID={peer.id} />
</TabsContent>
)}
{peer.id && permission.peers.delete && (
<TabsContent value={"peer-job"} className={"pb-8"}>
<PeerRemoteJobsSection peerID={peer.id} />
</TabsContent>
)}
</Tabs>
); );
}; };
@@ -541,9 +647,9 @@ function PeerInformationCard({ peer }: Readonly<{ peer: Peer }>) {
peer.connected peer.connected
? "just now" ? "just now"
: dayjs(peer.last_seen).format("D MMMM, YYYY [at] h:mm A") + : dayjs(peer.last_seen).format("D MMMM, YYYY [at] h:mm A") +
" (" + " (" +
dayjs().to(peer.last_seen) + dayjs().to(peer.last_seen) +
")" ")"
} }
/> />

View File

@@ -105,7 +105,7 @@ function PeersBlockedView() {
<div className={"px-3 pt-1 pb-8 max-w-3xl w-full"}> <div className={"px-3 pt-1 pb-8 max-w-3xl w-full"}>
<div <div
className={ className={
"rounded-md border border-nb-gray-900/70 grid w-full bg-nb-gray-930/40" "rounded-md border border-nb-gray-900/70 grid w-full bg-nb-gray-930/40 stepper-bg-variant"
} }
> >
<SetupModalContent header={false} footer={false} /> <SetupModalContent header={false} footer={false} />

View File

@@ -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;

View File

@@ -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<HTMLHeadingElement>();
return (
<PageContainer>
<div className={"p-default py-6"}>
<Breadcrumbs>
<Breadcrumbs.Item
href={"/reverse-proxy/services"}
label={"Reverse Proxy"}
icon={<ReverseProxyIcon size={16} />}
/>
<Breadcrumbs.Item
href={"/reverse-proxy/custom-domains"}
label={"Custom Domains"}
active={true}
/>
</Breadcrumbs>
<h1 ref={headingRef}>Domains</h1>
<Paragraph>
Add and manage custom domains for your reverse proxy services.
</Paragraph>
<Paragraph>
Learn more about
<InlineLink
href={REVERSE_PROXY_CUSTOM_DOMAINS_DOCS_LINK}
target={"_blank"}
>
Custom Domains
<ExternalLinkIcon size={12} />
</InlineLink>
in our documentation.
</Paragraph>
</div>
<RestrictedAccess
page={"Custom Domains"}
hasAccess={permission?.services?.read}
>
<ReverseProxiesProvider>
<Suspense fallback={<SkeletonTable />}>
<CustomDomainsTable headingTarget={portalTarget} />
</Suspense>
</ReverseProxiesProvider>
</RestrictedAccess>
</PageContainer>
);
}

View File

@@ -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 <FullScreenLoading height={"auto"} />;
}

View File

@@ -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;

View File

@@ -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<HTMLHeadingElement>();
return (
<PageContainer>
<div className={"p-default py-6"}>
<Breadcrumbs>
<Breadcrumbs.Item
href={"/reverse-proxy/services"}
label={"Reverse Proxy"}
icon={<ReverseProxyIcon size={16} />}
/>
<Breadcrumbs.Item
href={"/reverse-proxy/services"}
label={"Services"}
active={true}
/>
</Breadcrumbs>
<h1 ref={headingRef}>Services</h1>
<Paragraph>
Expose services securely through NetBird&apos;s reverse proxy.
</Paragraph>
<Paragraph>
Learn more about
<InlineLink href={REVERSE_PROXY_DOCS_LINK} target={"_blank"}>
Services
<ExternalLinkIcon size={12} />
</InlineLink>
in our documentation.
</Paragraph>
{isNetBirdHosted() ? (
<Callout className={"max-w-xl mt-5"} variant={"info"}>
NetBird&apos;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.
</Callout>
) : (
<Callout className={"max-w-xl mt-5"} variant={"info"}>
NetBird&apos;s Reverse Proxy is currently in beta. <br /> Features
and functionality are subject to change upon release.
</Callout>
)}
</div>
<RestrictedAccess
page={"Services"}
hasAccess={permission?.services?.read}
>
<ReverseProxiesProvider>
<Suspense fallback={<SkeletonTable />}>
<ReverseProxyTable headingTarget={portalTarget} />
</Suspense>
</ReverseProxiesProvider>
</RestrictedAccess>
</PageContainer>
);
}

View File

@@ -56,7 +56,7 @@ export default function NetBirdSettings() {
Authentication Authentication
</VerticalTabs.Trigger> </VerticalTabs.Trigger>
{account?.settings?.embedded_idp_enabled && {account?.settings?.embedded_idp_enabled &&
permission.identity_providers.read && ( permission?.identity_providers?.read && (
<VerticalTabs.Trigger value="identity-providers"> <VerticalTabs.Trigger value="identity-providers">
<FingerprintIcon size={14} /> <FingerprintIcon size={14} />
Identity Providers Identity Providers

View File

@@ -2,6 +2,11 @@
@tailwind components; @tailwind components;
@tailwind utilities; @tailwind utilities;
:root {
--toasts-before: 0;
--lift: 1;
}
html{ html{
@apply bg-nb-gray; @apply bg-nb-gray;
} }
@@ -171,6 +176,25 @@ p {
@apply m-0 p-0 box-border; @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 */ /* Control Center */
.react-flow__node-groupNode .selected{ .react-flow__node-groupNode .selected{

View File

@@ -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 (
<div
className={cn(
"flex items-center justify-center grayscale brightness-[100%] contrast-[40%]",
"w-4 h-4 shrink-0",
osType === OperatingSystem.WINDOWS && "p-[2.5px]",
osType === OperatingSystem.APPLE && "p-[2.7px]",
osType === OperatingSystem.FREEBSD && "p-[1.5px]",
)}
>
<OSLogo os={os} />
</div>
);
};

View File

@@ -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 && <PeerOSIcon os={peer.os} />}
{resource?.type && <ResourceIcon type={resource.type} />}
</>
);
};

View File

@@ -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 <GlobeIcon size={size} />;
case "subnet":
return <NetworkIcon size={size} />;
case "host":
return <WorkflowIcon size={size} />;
default:
return <WorkflowIcon size={size} />;
}
};

View File

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

View File

@@ -6,9 +6,8 @@ import {
OidcProvider, OidcProvider,
} from "@axa-fr/react-oidc"; } from "@axa-fr/react-oidc";
import FullScreenLoading from "@components/ui/FullScreenLoading"; import FullScreenLoading from "@components/ui/FullScreenLoading";
import { useLocalStorage } from "@hooks/useLocalStorage";
import loadConfig, { buildExtras } from "@utils/config"; 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 React, { useEffect, useState } from "react";
import { OIDCError } from "@/auth/OIDCError"; import { OIDCError } from "@/auth/OIDCError";
import { SecureProvider } from "@/auth/SecureProvider"; import { SecureProvider } from "@/auth/SecureProvider";
@@ -43,33 +42,6 @@ export default function OIDCProvider({ children }: Props) {
const [mounted, setMounted] = useState(false); const [mounted, setMounted] = useState(false);
const router = useRouter(); const router = useRouter();
const path = usePathname(); 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 = () => { const withCustomHistory = () => {
return { return {

View File

@@ -3,6 +3,23 @@ import { usePathname } from "next/navigation";
import * as React from "react"; import * as React from "react";
import { useEffect } 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 = { type Props = {
children: React.ReactNode; children: React.ReactNode;
}; };
@@ -10,6 +27,22 @@ export const SecureProvider = ({ children }: Props) => {
const { isAuthenticated, login } = useOidc(); const { isAuthenticated, login } = useOidc();
const currentPath = usePathname(); 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(() => { useEffect(() => {
let timeout: NodeJS.Timeout | undefined = undefined; let timeout: NodeJS.Timeout | undefined = undefined;
if (!isAuthenticated) { if (!isAuthenticated) {

View File

@@ -1,6 +1,6 @@
import { cn } from "@utils/helpers"; import { cn } from "@utils/helpers";
import { ChevronRightIcon } from "lucide-react"; import { ChevronRightIcon } from "lucide-react";
import { useRouter } from "next/navigation"; import Link from "next/link";
import React from "react"; import React from "react";
type Props = { type Props = {
@@ -25,8 +25,6 @@ export const Item = ({
active, active,
disabled = false, disabled = false,
}: ItemProps) => { }: ItemProps) => {
const router = useRouter();
return ( return (
<div <div
className={cn( className={cn(
@@ -45,7 +43,13 @@ export const Item = ({
)} )}
> >
{icon && icon} {icon && icon}
{href ? <span onClick={() => router.push(href)}>{label}</span> : label} {href ? (
<Link href={href} data-cy={"breadcrumb-item"}>
{label}
</Link>
) : (
label
)}
</div> </div>
</div> </div>
); );

View File

@@ -54,7 +54,7 @@ export const buttonVariants = cva(
dotted: [ dotted: [
"bg-white hover:text-black focus:ring-zinc-200/50 hover:bg-gray-100 border-gray-200 text-gray-900 border-dashed", "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: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: [ tertiary: [
"bg-white hover:text-black focus:ring-zinc-200/50 hover:bg-gray-100 border-gray-200 text-gray-900", "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", "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": [ "default-outline": [
"dark:ring-offset-nb-gray-950/50 dark:focus:ring-nb-gray-500/20", "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", "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",

View File

@@ -19,6 +19,8 @@ export const calloutVariants = cva(
default: "bg-nb-gray-900/60 border-nb-gray-800/80 text-nb-gray-300", 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", warning: "bg-netbird-500/10 border-netbird-400/20 text-netbird-150",
info: "bg-sky-400/10 border-sky-400/20 text-sky-100", 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",
}, },
}, },
}, },

View File

@@ -22,11 +22,7 @@ export default function CopyToClipboardText({
return ( return (
<div <div
className={cn( className={cn("flex gap-2 items-center group cursor-pointer", className)}
"flex gap-2 items-center group cursor-pointer transition-all hover:underline underline-offset-4 decoration-dashed decoration-nb-gray-600",
!copied && "hover:opacity-90",
className,
)}
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
e.preventDefault(); e.preventDefault();
@@ -34,27 +30,34 @@ export default function CopyToClipboardText({
}} }}
ref={wrapper} ref={wrapper}
> >
{children} <span className="relative truncate">
{children}
<span className="absolute bottom-0 left-0 right-0 border-b border-dashed border-transparent group-hover:border-nb-gray-500 pointer-events-none" />
</span>
{copied ? ( <span
className={cn(
"shrink-0",
iconAlignment === "left" ? "order-first" : "order-last",
)}
>
<CheckIcon <CheckIcon
className={cn( className={cn(
"text-nb-gray-100 group-hover:opacity-100 shrink-0", "text-nb-gray-100 group-hover:opacity-100",
iconAlignment === "left" ? "order-first" : "order-last", !copied && "hidden",
!alwaysShowIcon && "opacity-0", !alwaysShowIcon && !copied && "opacity-0",
)} )}
size={11} size={11}
/> />
) : (
<CopyIcon <CopyIcon
className={cn( className={cn(
"text-nb-gray-100 group-hover:opacity-100 shrink-0", "text-nb-gray-100 group-hover:opacity-100",
iconAlignment === "left" ? "order-first" : "order-last", copied && "hidden",
!alwaysShowIcon && "opacity-0", !alwaysShowIcon && "opacity-0",
)} )}
size={11} size={11}
/> />
)} </span>
</div> </div>
); );
} }

View File

@@ -15,6 +15,7 @@ interface Props {
value?: DateRange; value?: DateRange;
onChange?: (range: DateRange | undefined) => void; onChange?: (range: DateRange | undefined) => void;
className?: string; className?: string;
disabled?: boolean;
} }
const defaultRanges = { const defaultRanges = {
@@ -61,6 +62,7 @@ export function DatePickerWithRange({
className, className,
value, value,
onChange, onChange,
disabled = false,
}: Readonly<Props>) { }: Readonly<Props>) {
const isActive = useMemo(() => { const isActive = useMemo(() => {
return { return {
@@ -120,6 +122,7 @@ export function DatePickerWithRange({
<Button <Button
id="date" id="date"
variant={"secondary"} variant={"secondary"}
disabled={disabled}
className={cn("max-w-[260px] justify-start text-left font-normal")} className={cn("max-w-[260px] justify-start text-left font-normal")}
> >
<CalendarIcon size={16} className={"shrink-0"} /> <CalendarIcon size={16} className={"shrink-0"} />

View File

@@ -0,0 +1,93 @@
import TruncatedText from "@components/ui/TruncatedText";
import { cn } from "@utils/helpers";
import * as React from "react";
import { useMemo } from "react";
import RoundedFlag from "@/assets/countries/RoundedFlag";
import { PeerOSIcon } from "@/assets/icons/PeerOSIcon";
import { ResourceIcon } from "@/assets/icons/ResourceIcon";
import { NetworkResource } from "@/interfaces/Network";
import type { Peer } from "@/interfaces/Peer";
type DeviceCardProps = {
device?: Peer;
resource?: NetworkResource;
className?: string;
address?: string;
description?: string;
};
export const DeviceCard = ({
device,
resource,
className,
address,
description,
}: DeviceCardProps) => {
if (!device && !resource) return null;
const descriptionText = useMemo(() => {
return description !== undefined
? description
: address || device?.ip || resource?.address;
}, [description, address, device]);
return (
<div
className={cn(
"flex shrink-0 items-center gap-2.5 text-nb-gray-200 text-left py-1 pl-3 pr-4 rounded-md group/machine my-0 w-[230px]",
!descriptionText && "py-2",
className,
)}
>
<div
className={cn(
"flex items-center justify-center rounded-md h-9 w-9 shrink-0 bg-nb-gray-900 transition-all",
"group-hover:bg-nb-gray-800 relative",
)}
>
{device ? (
<PeerOSIcon os={device.os} />
) : resource?.type ? (
<ResourceIcon type={resource.type} />
) : null}
{device?.country_code && (
<div className={"absolute -bottom-[4px] -right-[4px]"}>
<div
className={cn(
"flex items-center justify-center rounded-full border-[3px] shrink-0",
"border-nb-gray-940",
)}
>
<RoundedFlag country={device?.country_code} size={10} />
</div>
</div>
)}
</div>
<div
className={
"flex flex-col gap-0 justify-center top-[0.15rem] leading-tight relative"
}
>
<span
className={
"font-normal text-[0.85rem] text-nb-gray-100 flex items-center gap-2"
}
>
<TruncatedText
text={device?.name || resource?.name || "Unknown"}
maxWidth={"150px"}
hideTooltip={true}
/>
</span>
<span
className={
"text-sm font-normal text-nb-gray-400 relative whitespace-nowrap"
}
>
<TruncatedText text={descriptionText} maxWidth={"160px"} />
</span>
</div>
</div>
);
};

View File

@@ -0,0 +1,45 @@
import { cn } from "@utils/helpers";
import { ExternalLinkIcon } from "lucide-react";
import React from "react";
type Props = {
href: string;
children: React.ReactNode;
iconAlignment?: "left" | "right";
className?: string;
alwaysShowIcon?: boolean;
};
export default function ExternalLinkText({
href,
children,
iconAlignment = "right",
className,
alwaysShowIcon = false,
}: Props) {
return (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className={cn(
"flex gap-2 items-center group/link cursor-pointer hover:opacity-90",
className,
)}
onClick={(e) => e.stopPropagation()}
>
<span className="relative">
{children}
<span className="absolute bottom-0 left-0 right-0 border-b border-dashed border-transparent group-hover/link:border-nb-gray-500 pointer-events-none" />
</span>
<ExternalLinkIcon
className={cn(
"text-nb-gray-100 group-hover/link:opacity-100 shrink-0",
iconAlignment === "left" ? "order-first" : "order-last",
!alwaysShowIcon && "opacity-0",
)}
size={12}
/>
</a>
);
}

View File

@@ -99,7 +99,11 @@ export default function FancyToggleSwitch({
/> />
</div> </div>
</div> </div>
<div>{children && value ? children : null}</div> {children && value ? (
<div className="mt-4" onClick={(e) => e.stopPropagation()}>
{children}
</div>
) : null}
</div> </div>
); );
} }

View File

@@ -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 (
<>
<FullTooltip
interactive={interactive}
side={"top"}
align={"start"}
alignOffset={0}
className={
"inline underline decoration-dashed underline-offset-[3px] decoration-nb-gray-300 cursor-help transition-all hover:decoration-white"
}
content={content}
>
{children}
</FullTooltip>
</>
);
};

View File

@@ -127,6 +127,8 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
suffix && "!pr-16", suffix && "!pr-16",
icon && "!pl-10", icon && "!pl-10",
"border", "border",
props.readOnly &&
"!bg-nb-gray-920 text-nb-gray-400 !border-nb-gray-800",
className, className,
)} )}
/> />

View File

@@ -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", "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< type LabelProps = React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
React.ElementRef<typeof LabelPrimitive.Root>, VariantProps<typeof labelVariants> & {
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> & as?: "label" | "div";
VariantProps<typeof labelVariants> };
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root const Label = React.forwardRef<HTMLElement, LabelProps>(
ref={ref} ({ className, as = "label", children, ...props }, ref) => {
className={cn(labelVariants(), className, "select-none")} const classes = cn(labelVariants(), className, "select-none");
{...props}
/> if (as === "div") {
)); return (
<div ref={ref as React.Ref<HTMLDivElement>} className={classes}>
{children}
</div>
);
}
return (
<LabelPrimitive.Root
ref={ref as React.Ref<HTMLLabelElement>}
className={classes}
{...props}
>
{children}
</LabelPrimitive.Root>
);
},
);
Label.displayName = LabelPrimitive.Root.displayName; Label.displayName = LabelPrimitive.Root.displayName;
export { Label }; export { Label };

View File

@@ -2,11 +2,11 @@ import { IconCircleX } from "@tabler/icons-react";
import type { ErrorResponse } from "@utils/api"; import type { ErrorResponse } from "@utils/api";
import { cn } from "@utils/helpers"; import { cn } from "@utils/helpers";
import classNames from "classnames"; import classNames from "classnames";
import { AnimatePresence, motion } from "framer-motion"; import { motion } from "framer-motion";
import { CheckIcon, Loader2, XIcon } from "lucide-react"; import { CheckIcon, Loader2, XIcon } from "lucide-react";
import * as React from "react"; import * as React from "react";
import { useEffect, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import toast, { type Toast } from "react-hot-toast"; import { toast } from "sonner";
export interface NotifyProps<T> { export interface NotifyProps<T> {
title: string; title: string;
@@ -22,14 +22,15 @@ export interface NotifyProps<T> {
} }
interface NotificationProps<T> extends NotifyProps<T> { interface NotificationProps<T> extends NotifyProps<T> {
t: Toast; toastId: string | number;
} }
export default function Notification<T>({ export default function Notification<T>({
title, title,
description, description,
icon, icon,
backgroundColor, backgroundColor,
t, toastId,
promise, promise,
loadingTitle, loadingTitle,
loadingMessage, loadingMessage,
@@ -39,17 +40,65 @@ export default function Notification<T>({
}: NotificationProps<T>) { }: NotificationProps<T>) {
const [error, setError] = useState(""); const [error, setError] = useState("");
const [loading, setLoading] = useState(!!promise); const [loading, setLoading] = useState(!!promise);
const [readyToDismiss, setReadyToDismiss] = useState(!promise);
const [toastDuration] = useState(duration); const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const remainingRef = useRef(duration);
const startTimeRef = useRef<number | null>(null);
const [preventSuccess, setPreventSuccess] = useState(false); 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 closeToast = () => { const pauseTimer = useCallback(() => {
setTimeout(() => { if (!timerRef.current || !startTimeRef.current) return;
setLoading(false); clearTimeout(timerRef.current);
toast.dismiss(t.id); timerRef.current = null;
}, toastDuration); remainingRef.current = Math.max(
}; 0,
remainingRef.current - (Date.now() - startTimeRef.current),
);
}, []);
const notificationRef = useRef<HTMLDivElement>(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(() => { useEffect(() => {
// Run the promise // Run the promise
@@ -57,8 +106,11 @@ export default function Notification<T>({
promise promise
.then(() => { .then(() => {
setLoading(false); setLoading(false);
closeToast(); if (preventSuccessToast) {
if (preventSuccessToast) setPreventSuccess(true); toast.dismiss(toastId);
} else {
setReadyToDismiss(true);
}
}) })
.catch((e) => { .catch((e) => {
const err = e as ErrorResponse; const err = e as ErrorResponse;
@@ -78,78 +130,76 @@ export default function Notification<T>({
} }
setLoading(false); setLoading(false);
closeToast(); setReadyToDismiss(true);
}); });
} else {
closeToast();
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
return ( return (
<AnimatePresence> <motion.div
{t.visible && !preventSuccess && ( ref={notificationRef}
<motion.div initial={{ y: -20 }}
initial={{ opacity: 1, y: -50 }} animate={{ y: 0 }}
animate={{ opacity: 1, y: 0 }} transition={{ type: "spring", stiffness: 400, damping: 20 }}
exit={{ opacity: 0, y: -50 }} data-toast-notification
className={cn( className="w-[28rem] pb-2"
"max-w-md w-full justify-between bg-white dark:bg-nb-gray-940 shadow-lg rounded-md px-4 py-2.5 pointer-events-auto flex border dark:border-nb-gray-900", >
)} <div
> className={cn(
<div className={"flex items-center gap-4"}> "w-full justify-between bg-white dark:bg-nb-gray-940 shadow-lg rounded-md px-4 py-2.5 pointer-events-auto flex border dark:border-nb-gray-900",
<div )}
className={classNames( >
"h-8 w-8 shadow-sm text-white flex items-center justify-center rounded-md shrink-0", <div className={"flex items-center gap-4"}>
loading <div
? "bg-nb-gray-900" className={classNames(
: error "h-8 w-8 shadow-sm text-white flex items-center justify-center rounded-md shrink-0",
? "bg-red-500" loading
: backgroundColor || "bg-green-500", ? "bg-nb-gray-900"
)} : error
> ? "bg-red-500"
{loading ? ( : backgroundColor || "bg-green-500",
<Loader2 size={14} className={"animate-spin"} /> )}
) : error ? (
<IconCircleX size={24} />
) : (
icon || <CheckIcon size={14} />
)}
</div>
<div className={"flex flex-col text-sm"}>
<p>
<span className={"font-semibold"}>
{loading ? loadingTitle || title : title}
</span>
</p>
<p
className={"text-xs dark:text-nb-gray-300 text-gray-600 mt-0.5"}
>
{loading ? loadingMessage : error ? error : description}
</p>
</div>
</div>
<button
className="flex dark:border-nb-gray-900 items-center cursor-pointer group"
onClick={() => toast.dismiss(t.id)}
> >
<div {loading ? (
className={ <Loader2 size={14} className={"animate-spin"} />
"p-2 hover:bg-nb-gray-900 rounded-md opacity-50 group-hover:opacity-100" ) : error ? (
} <IconCircleX size={24} />
> ) : (
<XIcon size={16} /> icon || <CheckIcon size={14} />
</div> )}
</button> </div>
</motion.div> <div className={"flex flex-col text-sm"}>
)} <p>
</AnimatePresence> <span className={"font-semibold"}>
{loading ? loadingTitle || title : title}
</span>
</p>
<p className={"text-xs dark:text-nb-gray-300 text-gray-600 mt-0.5"}>
{loading ? loadingMessage : error ? error : description}
</p>
</div>
</div>
<button
className="flex dark:border-nb-gray-900 items-center cursor-pointer group"
onClick={() => toast.dismiss(toastId)}
>
<div
className={
"p-2 hover:bg-nb-gray-900 rounded-md opacity-50 group-hover:opacity-100"
}
>
<XIcon size={16} />
</div>
</button>
</div>
</motion.div>
); );
} }
export function notify<T>(props: NotifyProps<T>) { export function notify<T>(props: NotifyProps<T>) {
return toast.custom((t) => <Notification {...props} t={t} />, { return toast.custom((id) => <Notification {...props} toastId={id} />, {
duration: Infinity, duration: Infinity,
}); });
} }

View File

@@ -44,6 +44,9 @@ import { PolicyRuleResource } from "@/interfaces/Policy";
import { User } from "@/interfaces/User"; import { User } from "@/interfaces/User";
import { HorizontalUsersStack } from "@/modules/users/HorizontalUsersStack"; import { HorizontalUsersStack } from "@/modules/users/HorizontalUsersStack";
import { PeerOperatingSystemIcon } from "@/modules/peers/PeerOperatingSystemIcon"; import { PeerOperatingSystemIcon } from "@/modules/peers/PeerOperatingSystemIcon";
import TruncatedText from "@components/ui/TruncatedText";
type PeerGroupSelectorTab = "peers" | "groups" | "resources";
const groupsSearchPredicate = (item: Group, query: string) => { const groupsSearchPredicate = (item: Group, query: string) => {
const lowerCaseQuery = query.toLowerCase(); const lowerCaseQuery = query.toLowerCase();
@@ -68,6 +71,9 @@ interface MultiSelectProps {
showResourceCounter?: boolean; showResourceCounter?: boolean;
showResources?: boolean; showResources?: boolean;
showPeers?: boolean; showPeers?: boolean;
hideGroupsTab?: boolean;
tabOrder?: ("groups" | "peers" | "resources")[];
closeOnSelect?: boolean;
resource?: PolicyRuleResource; resource?: PolicyRuleResource;
onResourceChange?: (resource?: PolicyRuleResource) => void; onResourceChange?: (resource?: PolicyRuleResource) => void;
placeholder?: string; placeholder?: string;
@@ -76,6 +82,7 @@ interface MultiSelectProps {
side?: "top" | "bottom"; side?: "top" | "bottom";
users?: User[]; users?: User[];
placeholderForSearch?: string; placeholderForSearch?: string;
resourceIds?: string[];
} }
export function PeerGroupSelector({ export function PeerGroupSelector({
onChange, onChange,
@@ -94,6 +101,9 @@ export function PeerGroupSelector({
showResourceCounter = true, showResourceCounter = true,
showResources = false, showResources = false,
showPeers = false, showPeers = false,
hideGroupsTab = false,
tabOrder,
closeOnSelect = false,
resource, resource,
onResourceChange, onResourceChange,
placeholder = "Add or select group(s)...", placeholder = "Add or select group(s)...",
@@ -102,6 +112,7 @@ export function PeerGroupSelector({
side = "bottom", side = "bottom",
users, users,
placeholderForSearch = 'Search groups or add new group by pressing "Enter"...', placeholderForSearch = 'Search groups or add new group by pressing "Enter"...',
resourceIds,
}: Readonly<MultiSelectProps>) { }: Readonly<MultiSelectProps>) {
const { data: resources, isLoading: isResourcesLoading } = useFetchApi< const { data: resources, isLoading: isResourcesLoading } = useFetchApi<
NetworkResource[] NetworkResource[]
@@ -229,7 +240,13 @@ export function PeerGroupSelector({
const [slice, setSlice] = useState(10); 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<PeerGroupSelectorTab>(getDefaultTab);
useEffect(() => { useEffect(() => {
if (open) { if (open) {
@@ -272,6 +289,9 @@ export function PeerGroupSelector({
: undefined, : undefined,
); );
onChange([]); onChange([]);
if (closeOnSelect) {
setOpen(false);
}
}; };
const selectPeer = (peer?: Peer) => { const selectPeer = (peer?: Peer) => {
@@ -281,6 +301,9 @@ export function PeerGroupSelector({
type: "peer", type: "peer",
}); });
onChange([]); onChange([]);
if (closeOnSelect) {
setOpen(false);
}
}; };
return ( return (
@@ -438,11 +461,20 @@ export function PeerGroupSelector({
</div> </div>
</div> </div>
<Tabs defaultValue={"groups"} value={tab} onValueChange={setTab}> <Tabs
defaultValue={
tabOrder?.[0] ??
(hideGroupsTab ? (showPeers ? "peers" : "resources") : "groups")
}
value={tab}
onValueChange={(v) => setTab(v as PeerGroupSelectorTab)}
>
<TabTriggers <TabTriggers
searchRef={searchRef} searchRef={searchRef}
showPeers={showPeers} showPeers={showPeers}
showResources={showResources} showResources={showResources}
hideGroupsTab={hideGroupsTab}
tabOrder={tabOrder}
/> />
<TabsContent value={"groups"} className={"p-0 my-0"}> <TabsContent value={"groups"} className={"p-0 my-0"}>
<CommandGroup> <CommandGroup>
@@ -562,7 +594,11 @@ export function PeerGroupSelector({
<TabsContent value={"resources"} className={"p-0 my-0"}> <TabsContent value={"resources"} className={"p-0 my-0"}>
<ResourcesList <ResourcesList
search={search} search={search}
resources={resources} resources={
resourceIds
? resources?.filter((r) => resourceIds.includes(r.id))
: resources
}
isLoading={isResourcesLoading} isLoading={isResourcesLoading}
value={resource} value={resource}
onChange={selectResource} onChange={selectResource}
@@ -592,60 +628,89 @@ const TabTriggers = ({
searchRef, searchRef,
showResources = false, showResources = false,
showPeers = false, showPeers = false,
hideGroupsTab = false,
tabOrder,
}: { }: {
searchRef: React.MutableRefObject<HTMLInputElement | null>; searchRef: React.MutableRefObject<HTMLInputElement | null>;
showResources?: boolean; showResources?: boolean;
showPeers?: 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 && (
<TabsTrigger
key="groups"
value={"groups"}
className={"text-[.8rem] font-normal"}
onClick={() => searchRef.current?.focus()}
>
<FolderGit2
className={
"text-nb-gray-500 group-data-[state=active]/trigger:text-netbird transition-all"
}
size={14}
/>
Groups
</TabsTrigger>
);
const resourcesTab = showResources && (
<TabsTrigger
key="resources"
value={"resources"}
className={"text-[.8rem] font-normal"}
onClick={() => searchRef.current?.focus()}
>
<Layers3Icon
className={
"text-nb-gray-500 group-data-[state=active]/trigger:text-netbird transition-all"
}
size={14}
/>
Resources
</TabsTrigger>
);
const peersTab = showPeers && (
<TabsTrigger
key="peers"
value={"peers"}
className={"text-[.8rem] font-normal"}
onClick={() => searchRef.current?.focus()}
>
<MonitorSmartphoneIcon
className={
"text-nb-gray-500 group-data-[state=active]/trigger:text-netbird transition-all"
}
size={14}
/>
Peers
</TabsTrigger>
);
const tabMap = {
groups: groupsTab,
peers: peersTab,
resources: resourcesTab,
};
if (tabOrder) {
return (
<TabsList justify={"start"} className={"px-3"}>
{tabOrder.map((tab) => tabMap[tab])}
</TabsList>
);
}
return ( return (
<TabsList justify={"start"} className={"px-3"}> <TabsList justify={"start"} className={"px-3"}>
<TabsTrigger {groupsTab}
value={"groups"} {resourcesTab}
className={"text-[.8rem] font-normal"} {peersTab}
onClick={() => searchRef.current?.focus()}
>
<FolderGit2
className={
"text-nb-gray-500 group-data-[state=active]/trigger:text-netbird transition-all"
}
size={14}
/>
Groups
</TabsTrigger>
{showResources && (
<TabsTrigger
value={"resources"}
className={"text-[.8rem] font-normal"}
onClick={() => searchRef.current?.focus()}
>
<Layers3Icon
className={
"text-nb-gray-500 group-data-[state=active]/trigger:text-netbird transition-all"
}
size={14}
/>
Resources
</TabsTrigger>
)}
{showPeers && (
<TabsTrigger
value={"peers"}
className={"text-[.8rem] font-normal"}
onClick={() => searchRef.current?.focus()}
>
<MonitorSmartphoneIcon
className={
"text-nb-gray-500 group-data-[state=active]/trigger:text-netbird transition-all"
}
size={14}
/>
Peers
</TabsTrigger>
)}
</TabsList> </TabsList>
); );
}; };
@@ -787,6 +852,7 @@ const ResourcesList = ({
<VirtualScrollAreaList <VirtualScrollAreaList
items={filteredItems} items={filteredItems}
onSelect={onChange} onSelect={onChange}
estimatedItemHeight={42}
itemClassName={"dark:aria-selected:bg-nb-gray-800/20"} itemClassName={"dark:aria-selected:bg-nb-gray-800/20"}
renderItem={(res) => { renderItem={(res) => {
return ( return (
@@ -896,6 +962,7 @@ const PeersList = ({
<VirtualScrollAreaList <VirtualScrollAreaList
items={filteredItems} items={filteredItems}
onSelect={onChange} onSelect={onChange}
estimatedItemHeight={42}
itemClassName={"dark:aria-selected:bg-nb-gray-800/20"} itemClassName={"dark:aria-selected:bg-nb-gray-800/20"}
renderItem={(res) => { renderItem={(res) => {
if (!res?.id) return; if (!res?.id) return;
@@ -904,7 +971,7 @@ const PeersList = ({
<Fragment key={res.id}> <Fragment key={res.id}>
<div className={"flex items-center gap-2"}> <div className={"flex items-center gap-2"}>
<Badge <Badge
useHover={true} useHover={false}
data-cy={"group-badge"} data-cy={"group-badge"}
variant={"gray-ghost"} variant={"gray-ghost"}
className={cn( className={cn(
@@ -915,7 +982,7 @@ const PeersList = ({
}} }}
> >
<PeerOperatingSystemIcon os={res.os} /> <PeerOperatingSystemIcon os={res.os} />
<TextWithTooltip text={res?.name || ""} maxChars={20} /> <TruncatedText text={res?.name || ""} maxWidth={"270px"} />
</Badge> </Badge>
</div> </div>

View File

@@ -13,7 +13,6 @@ import { ArrowUpCircleIcon, ChevronsUpDown, MapPin } from "lucide-react";
import * as React from "react"; import * as React from "react";
import { memo, useEffect, useState } from "react"; import { memo, useEffect, useState } from "react";
import { useElementSize } from "@/hooks/useElementSize"; import { useElementSize } from "@/hooks/useElementSize";
import { OperatingSystem } from "@/interfaces/OperatingSystem";
import { Peer } from "@/interfaces/Peer"; import { Peer } from "@/interfaces/Peer";
import { PeerOperatingSystemIcon } from "@/modules/peers/PeerOperatingSystemIcon"; import { PeerOperatingSystemIcon } from "@/modules/peers/PeerOperatingSystemIcon";

View File

@@ -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<PinCodeInputRef, Props>(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<HTMLInputElement>) => {
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<HTMLInputElement>) => {
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<HTMLInputElement>) => {
e.target.select();
};
return (
<div className={cn("flex gap-2", className)}>
{digits.map((digit, index) => (
<input
key={index}
ref={(el) => {
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",
)}
/>
))}
</div>
);
});
export default PinCodeInput;

View File

@@ -188,7 +188,6 @@ export function PortSelector({
"dark:placeholder:text-nb-gray-400 font-light placeholder:text-neutral-500 pl-10", "dark:placeholder:text-nb-gray-400 font-light placeholder:text-neutral-500 pl-10",
)} )}
data-cy={"port-input"} data-cy={"port-input"}
typeof={"number"}
ref={searchRef} ref={searchRef}
value={search} value={search}
onValueChange={setSearch} onValueChange={setSearch}

View File

@@ -1,7 +1,6 @@
import * as RadixRadioGroup from "@radix-ui/react-radio-group"; import * as RadixRadioGroup from "@radix-ui/react-radio-group";
import { cn } from "@utils/helpers"; import { cn } from "@utils/helpers";
import * as React from "react"; import * as React from "react";
import { useState } from "react";
type Props = { type Props = {
value: string; value: string;
@@ -10,10 +9,8 @@ type Props = {
}; };
export const RadioGroup = ({ value, onChange, children }: Props) => { export const RadioGroup = ({ value, onChange, children }: Props) => {
const [defaultValue] = useState(value);
return ( return (
<RadixRadioGroup.Root <RadixRadioGroup.Root
defaultValue={defaultValue}
value={value} value={value}
onValueChange={onChange} onValueChange={onChange}
className={ className={

View File

@@ -0,0 +1,103 @@
"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<SettingCardItemProps>) {
return (
<div
role="button"
tabIndex={0}
onClick={onClick}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onClick();
}
}}
className={
"flex justify-between gap-10 px-6 border-t border-nb-gray-920 first:border-t-0 py-5 hover:bg-nb-gray-935 cursor-pointer transition-colors"
}
>
<div className={"max-w-sm"}>
<div className="flex items-center gap-2">
<Label>{label}</Label>
{enabled && (
<SmallBadge
text="Enabled"
variant="green"
size="md"
className={"-top-[0.25rem]"}
/>
)}
</div>
<HelpText margin={false}>{description}</HelpText>
</div>
<div onClick={(e) => e.stopPropagation()}>
{enabled ? (
<Button
variant={"secondaryLighter"}
size={"xs"}
className={"pl-3 pr-3"}
onClick={onClick}
>
<SquarePen size={12} />
Edit
</Button>
) : (
<Button
variant={"secondaryLighter"}
size={"xs"}
className={"pl-3 pr-3"}
onClick={onClick}
>
<PlusCircle size={12} />
Add
</Button>
)}
</div>
</div>
);
}
type SettingCardProps = {
children: React.ReactNode;
className?: string;
};
function SettingCard({ children, className }: Readonly<SettingCardProps>) {
return (
<div
className={cn(
"border-nb-gray-920 bg-nb-gray-800/10 border rounded-md",
className,
)}
>
{children}
</div>
);
}
const SettingCardWithItem = SettingCard as React.FC<Readonly<SettingCardProps>> & {
Item: typeof SettingCardItem;
};
SettingCardWithItem.Item = SettingCardItem;
export default SettingCardWithItem;

View File

@@ -5,7 +5,7 @@ import { cn } from "@utils/helpers";
import classNames from "classnames"; import classNames from "classnames";
import { ChevronDownIcon, ChevronUpIcon, DotIcon } from "lucide-react"; import { ChevronDownIcon, ChevronUpIcon, DotIcon } from "lucide-react";
import { usePathname, useRouter } from "next/navigation"; import { usePathname, useRouter } from "next/navigation";
import React, { useMemo } from "react"; import React, { useEffect, useMemo } from "react";
import { useApplicationContext } from "@/contexts/ApplicationProvider"; import { useApplicationContext } from "@/contexts/ApplicationProvider";
export type SidebarItemProps = { export type SidebarItemProps = {
@@ -36,8 +36,22 @@ export default function SidebarItem({
labelClassName, labelClassName,
visible, visible,
}: Readonly<SidebarItemProps>) { }: Readonly<SidebarItemProps>) {
const [open, setOpen] = React.useState(false);
const path = usePathname(); 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 router = useRouter();
const { mobileNavOpen, toggleMobileNav, isNavigationCollapsed } = const { mobileNavOpen, toggleMobileNav, isNavigationCollapsed } =
useApplicationContext(); useApplicationContext();
@@ -48,6 +62,7 @@ export default function SidebarItem({
? path == href ? path == href
: path.includes(href) : path.includes(href)
: false; : false;
if (collapsible && href) return;
if (collapsible && mobileNavOpen) return; if (collapsible && mobileNavOpen) return;
if (collapsible && open) return; if (collapsible && open) return;
if (preventRedirect) return; if (preventRedirect) return;
@@ -66,7 +81,7 @@ export default function SidebarItem({
return ( return (
<Collapsible.Root open={open} onOpenChange={setOpen}> <Collapsible.Root open={open} onOpenChange={setOpen}>
<Collapsible.Trigger asChild> <Collapsible.Trigger asChild>
<li className={"px-4 cursor-pointer list-none"}> <li className={"px-3 cursor-pointer list-none"}>
<button <button
className={classNames( className={classNames(
"rounded-lg text-[.87rem] w-full relative font-normal", "rounded-lg text-[.87rem] w-full relative font-normal",
@@ -101,7 +116,7 @@ export default function SidebarItem({
<span <span
className={cn( className={cn(
"px-4 whitespace-nowrap flex-1 w-full text-left", "px-3 whitespace-nowrap flex-1 w-full text-left",
labelClassName, labelClassName,
isNavigationCollapsed && isNavigationCollapsed &&
!mobileNavOpen && !mobileNavOpen &&

View File

@@ -1,7 +1,4 @@
import { import { MemoizedScrollArea, ScrollAreaViewport } from "@components/ScrollArea";
MemoizedScrollArea,
ScrollAreaViewport,
} from "@components/ScrollArea";
import { cn } from "@utils/helpers"; import { cn } from "@utils/helpers";
import * as React from "react"; import * as React from "react";
import { import {

View File

@@ -59,6 +59,7 @@ const ModalContent = React.forwardRef<
children, children,
showClose = true, showClose = true,
maxWidthClass = "max-w-3xl", maxWidthClass = "max-w-3xl",
onPointerDownOutside,
...props ...props
}, },
ref, ref,
@@ -72,6 +73,19 @@ const ModalContent = React.forwardRef<
className, className,
maxWidthClass, maxWidthClass,
)} )}
onPointerDownOutside={(e) => {
// Prevent closing modal when clicking on toast notifications
try {
const target = e.target as HTMLElement;
if (target?.closest("[data-toast-notification]")) {
e.preventDefault();
return;
}
} catch {
// Ignore errors
}
onPointerDownOutside?.(e);
}}
{...props} {...props}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >

View File

@@ -14,6 +14,7 @@ import * as React from "react";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import Skeleton from "react-loading-skeleton"; import Skeleton from "react-loading-skeleton";
import { useElementSize } from "@/hooks/useElementSize"; import { useElementSize } from "@/hooks/useElementSize";
import { DropdownInfoText } from "@components/DropdownInfoText";
export interface SelectOption { export interface SelectOption {
label: string | React.ReactNode; label: string | React.ReactNode;
@@ -25,13 +26,16 @@ export interface SelectOption {
}>; }>;
renderItem?: () => React.ReactNode; renderItem?: () => React.ReactNode;
searchValue?: string; searchValue?: string;
className?: string;
disabled?: boolean;
} }
interface SelectDropdownProps { interface SelectDropdownProps {
value: string; value: string;
onChange: (value: string) => void; onChange: (value: string) => void;
disabled?: boolean; disabled?: boolean;
popoverWidth?: "auto" | number; popoverWidth?: "auto" | "content" | number;
popoverMinWidth?: number;
options: SelectOption[]; options: SelectOption[];
showSearch?: boolean; showSearch?: boolean;
showValues?: boolean; showValues?: boolean;
@@ -51,6 +55,7 @@ export function SelectDropdown({
value, value,
disabled = false, disabled = false,
popoverWidth = "auto", popoverWidth = "auto",
popoverMinWidth,
options, options,
showSearch = false, showSearch = false,
showValues = false, showValues = false,
@@ -169,9 +174,18 @@ export function SelectDropdown({
)} )}
</PopoverTrigger> </PopoverTrigger>
<PopoverContent <PopoverContent
className="w-full p-0 shadow-sm shadow-nb-gray-950 focus:outline-none" className={cn(
"p-0 shadow-sm shadow-nb-gray-950 focus:outline-none",
popoverWidth !== "content" && "w-full",
)}
style={{ style={{
width: popoverWidth === "auto" ? width : popoverWidth, width:
popoverWidth === "content"
? "auto"
: popoverWidth === "auto"
? width
: popoverWidth,
minWidth: popoverMinWidth,
}} }}
align="start" align="start"
side={"bottom"} side={"bottom"}
@@ -194,9 +208,10 @@ export function SelectDropdown({
)} )}
{filteredItems.length == 0 && ( {filteredItems.length == 0 && (
<div className={"text-center pb-2 px-3 text-nb-gray-400 text-xs"}> <DropdownInfoText className={"max-w-sm mx-auto px-4"}>
There are no results matching your search. There are no results matching your search. Please try a
</div> different search term.
</DropdownInfoText>
)} )}
<ScrollArea <ScrollArea
@@ -209,7 +224,7 @@ export function SelectDropdown({
}} }}
> >
<CommandGroup> <CommandGroup>
<div className={"grid grid-cols-1 gap-1 pb-2"}> <div className={"grid grid-cols-1 gap-1 pb-2 w-full"}>
{filteredItems.map((option) => ( {filteredItems.map((option) => (
<SelectDropdownItem <SelectDropdownItem
option={option} option={option}
@@ -253,22 +268,29 @@ const SelectDropdownItem = ({
}, [isVisible]); }, [isVisible]);
return ( return (
<div ref={elementRef} className={"transition-all"}> <div ref={elementRef} className={"transition-all w-full"}>
{visible ? ( {visible ? (
<CommandItem <CommandItem
value={option?.searchValue ?? value} value={option?.searchValue ?? value}
ref={elementRef} ref={elementRef}
className={"py-1 px-2"} className={"py-1 px-2 w-full"}
onSelect={() => toggle(option.value)} onSelect={() => !option?.disabled && toggle(option.value)}
onClick={(e) => e.preventDefault()} onClick={(e) => e.preventDefault()}
disabled={option?.disabled}
> >
<div className={"flex items-center gap-2.5 p-1"}> <div
className={cn(
"flex items-center gap-2.5 p-1 w-full",
option?.className,
option?.disabled && "cursor-not-allowed",
)}
>
{option.icon && <option.icon size={14} width={14} />} {option.icon && <option.icon size={14} width={14} />}
{option?.renderItem && option.renderItem()} {option?.renderItem && option.renderItem()}
{!option?.renderItem && ( {!option?.renderItem && (
<div <div
className={cn( className={cn(
"flex flex-col text-sm font-medium", "flex flex-col text-sm font-medium w-full",
size === "xs" && "text-xs", size === "xs" && "text-xs",
)} )}
> >

View File

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

View File

@@ -133,9 +133,10 @@ interface DataTableProps<TData, TValue> {
className?: string; className?: string;
inset?: boolean; inset?: boolean;
isLoading?: boolean; isLoading?: boolean;
isFetching?: boolean;
as?: "div" | "table"; as?: "div" | "table";
paginationClassName?: string; paginationClassName?: string;
rowClassName?: string; rowClassName?: string | ((row: Row<TData>) => string);
wrapperClassName?: string; wrapperClassName?: string;
tableClassName?: string; tableClassName?: string;
searchClassName?: string; searchClassName?: string;
@@ -150,6 +151,8 @@ interface DataTableProps<TData, TValue> {
useRowId?: boolean; useRowId?: boolean;
headingTarget?: HTMLHeadingElement | null; headingTarget?: HTMLHeadingElement | null;
showResetFilterButton?: boolean; showResetFilterButton?: boolean;
serverSidePagination?: boolean;
hasServerSideFilters?: boolean;
onFilterReset?: () => void; onFilterReset?: () => void;
wrapperComponent?: React.ElementType; wrapperComponent?: React.ElementType;
wrapperProps?: any; wrapperProps?: any;
@@ -195,6 +198,7 @@ export function DataTable<TData, TValue>({
tableClassName, tableClassName,
inset, inset,
isLoading = false, isLoading = false,
isFetching = false,
paginationClassName, paginationClassName,
rowClassName, rowClassName,
wrapperClassName, wrapperClassName,
@@ -211,6 +215,8 @@ export function DataTable<TData, TValue>({
useRowId, useRowId,
headingTarget, headingTarget,
showResetFilterButton = true, showResetFilterButton = true,
serverSidePagination = false,
hasServerSideFilters,
onFilterReset, onFilterReset,
showSearchAndFilters = true, showSearchAndFilters = true,
wrapperProps, wrapperProps,
@@ -236,6 +242,19 @@ export function DataTable<TData, TValue>({
const path = usePathname(); const path = usePathname();
const isInitialRender = useRef(true); const isInitialRender = useRef(true);
const [showOverlay, setShowOverlay] = useState(false);
const overlayTimer = useRef<ReturnType<typeof setTimeout>>(undefined);
useEffect(() => {
if (!serverSidePagination) return;
if (isFetching && !isLoading) {
overlayTimer.current = setTimeout(() => setShowOverlay(true), 500);
} else {
clearTimeout(overlayTimer.current);
setShowOverlay(false);
}
return () => clearTimeout(overlayTimer.current);
}, [serverSidePagination, isFetching, isLoading]);
const [localColumnFilters, setLocalColumnFilters] = const [localColumnFilters, setLocalColumnFilters] =
useLocalStorage<ColumnFiltersState>( useLocalStorage<ColumnFiltersState>(
`netbird-table-columns${uniqueKey ? "/" + (uniqueKey as string) : path}`, `netbird-table-columns${uniqueKey ? "/" + (uniqueKey as string) : path}`,
@@ -411,12 +430,7 @@ export function DataTable<TData, TValue>({
return ( return (
<div className={cn("relative table-fixed-scroll", className)}> <div className={cn("relative table-fixed-scroll", className)}>
{showSearchAndFilters && ( {showSearchAndFilters && (
<div <div className={cn("flex gap-x-4 gap-y-6", !minimal && "p-default")}>
className={cn(
"flex gap-x-4 gap-y-6 flex-wrap",
!minimal && "p-default",
)}
>
<DataTableGlobalSearch <DataTableGlobalSearch
className={searchClassName} className={searchClassName}
disabled={false} // Never disable the search input disabled={false} // Never disable the search input
@@ -439,10 +453,14 @@ export function DataTable<TData, TValue>({
/> />
{children?.(table)} {children?.(table)}
{showResetFilterButton && ( {showResetFilterButton && (
<DataTableResetFilterButton onClick={resetFilters} table={table} /> <DataTableResetFilterButton
onClick={resetFilters}
table={table}
hasServerSideFilters={hasServerSideFilters}
/>
)} )}
<div className={"flex gap-4 flex-wrap grow"}> <div className={"flex gap-4 grow"}>
<div className={"flex gap-4 flex-wrap"}></div> <div className={"flex gap-4"}></div>
{rightSide?.(table)} {rightSide?.(table)}
</div> </div>
</div> </div>
@@ -450,135 +468,141 @@ export function DataTable<TData, TValue>({
{aboveTable?.(table)} {aboveTable?.(table)}
<TableWrapper <div className="relative">
wrapperComponent={wrapperComponent} {showOverlay && (
wrapperProps={wrapperProps} <div className="absolute inset-0 bg-nb-gray-950/60 z-10 rounded-md animate-pulse" />
> )}
{isLoading ? ( <TableWrapper
<TableContentSkeleton /> wrapperComponent={wrapperComponent}
) : !hasInitialData ? ( wrapperProps={wrapperProps}
getStartedCard >
) : ( {isLoading ? (
<TableComponent <TableContentSkeleton />
className={cn("relative mt-6", tableClassName)} ) : !hasInitialData && !hasServerSideFilters ? (
minimal={minimal} getStartedCard
> ) : (
{showHeader && as == "table" && ( <TableComponent
<TableHeaderComponent minimal={minimal}> className={cn("relative mt-6", tableClassName)}
{table.getHeaderGroups().map((headerGroup) => ( minimal={minimal}
<TableRowComponent key={headerGroup.id} minimal={minimal}>
{headerGroup.headers.map((header) => {
return (
<TableHead
key={header.id}
minimal={minimal}
inset={inset}
>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</TableHead>
);
})}
</TableRowComponent>
))}
</TableHeaderComponent>
)}
<TableBodyComponent
className={cn(
"relative",
data == undefined && "blur-sm",
wrapperClassName,
)}
> >
{table.getRowModel().rows?.length ? ( {showHeader && as == "table" && (
table.getRowModel().rows.map((row) => { <TableHeaderComponent minimal={minimal}>
const expandedRow = renderExpandedRow?.(row.original); {table.getHeaderGroups().map((headerGroup) => (
const rowId = row.original.id ?? row.id; <TableRowComponent key={headerGroup.id} minimal={minimal}>
const isExpanded = accordion?.includes(rowId); {headerGroup.headers.map((header) => {
const rowContent = ( return (
<React.Fragment key={row.id}> <TableHead
<TableRowComponent key={header.id}
minimal={minimal}
data-row-id={rowId}
className={cn(
(onRowClick || renderExpandedRow) &&
"relative group/accordion",
(onRowClick || expandedRow) && "cursor-pointer",
rowClassName,
)}
data-state={row.getIsSelected() && "selected"}
data-accordion={isExpanded ? "opened" : "closed"}
onClick={(e) => {
if (expandedRow) {
e.preventDefault();
e.stopPropagation();
setAccordion((prev) => {
if (prev?.includes(rowId)) {
return prev.filter(
(item) => item !== rowId,
);
} else {
return [...(prev ?? []), rowId];
}
});
}
}}
>
{row.getVisibleCells().map((cell) => (
<TableCellComponent
key={cell.id}
className={cn("relative", tableCellClassName)}
minimal={minimal} minimal={minimal}
inset={inset} inset={inset}
onClick={() => {
onRowClick && onRowClick(row, cell.column.id);
}}
> >
<div {header.isPlaceholder
className={ ? null
"absolute left-0 top-0 w-full h-full z-0" : flexRender(
} header.column.columnDef.header,
></div> header.getContext(),
<div className={"relative z-[1]"}> )}
{flexRender( </TableHead>
cell.column.columnDef.cell, );
cell.getContext(), })}
)} </TableRowComponent>
</div> ))}
</TableCellComponent> </TableHeaderComponent>
))} )}
</TableRowComponent>
{expandedRow && isExpanded && ( <TableBodyComponent
className={cn(
"relative",
data == undefined && "blur-sm",
wrapperClassName,
)}
>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => {
const expandedRow = renderExpandedRow?.(row.original);
const rowId = row.original.id ?? row.id;
const isExpanded = accordion?.includes(rowId);
const rowContent = (
<React.Fragment key={row.id}>
<TableRowComponent <TableRowComponent
data-row-id={row.id + "-expanded-row"}
minimal={minimal} minimal={minimal}
data-row-id={rowId}
className={cn( className={cn(
onRowClick && "cursor-pointer relative", (onRowClick || renderExpandedRow) &&
rowClassName, "relative group/accordion",
(onRowClick || expandedRow) && "cursor-pointer",
typeof rowClassName === "function"
? rowClassName(row)
: rowClassName,
)} )}
data-state={row.getIsSelected() && "selected"} data-state={row.getIsSelected() && "selected"}
data-accordion={isExpanded ? "opened" : "closed"}
onClick={(e) => {
if (expandedRow) {
e.preventDefault();
e.stopPropagation();
setAccordion((prev) => {
if (prev?.includes(rowId)) {
return prev.filter((item) => item !== rowId);
} else {
return [...(prev ?? []), rowId];
}
});
}
}}
> >
<TableDataUnstyledComponent {row.getVisibleCells().map((cell) => (
className={"w-full"} <TableCellComponent
colSpan={row.getVisibleCells().length} key={cell.id}
> className={cn("relative", tableCellClassName)}
{expandedRow} minimal={minimal}
</TableDataUnstyledComponent> inset={inset}
onClick={() => {
onRowClick && onRowClick(row, cell.column.id);
}}
>
<div
className={
"absolute left-0 top-0 w-full h-full z-0"
}
></div>
<div className={"relative z-[1]"}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</div>
</TableCellComponent>
))}
</TableRowComponent> </TableRowComponent>
)}
</React.Fragment>
);
return renderRow {expandedRow && isExpanded && (
? renderRow(row.original, rowContent) <TableRowComponent
: rowContent; data-row-id={row.id + "-expanded-row"}
}) minimal={minimal}
className={cn(
onRowClick && "cursor-pointer relative",
typeof rowClassName === "function"
? rowClassName(row)
: rowClassName,
)}
data-state={row.getIsSelected() && "selected"}
>
<TableDataUnstyledComponent
className={"w-full"}
colSpan={row.getVisibleCells().length}
>
{expandedRow}
</TableDataUnstyledComponent>
</TableRowComponent>
)}
</React.Fragment>
);
return renderRow
? renderRow(row.original, rowContent)
: rowContent;
})
) : ( ) : (
<TableRowUnstyledComponent> <TableRowUnstyledComponent>
<TableCellComponent <TableCellComponent
@@ -589,10 +613,11 @@ export function DataTable<TData, TValue>({
</TableCellComponent> </TableCellComponent>
</TableRowUnstyledComponent> </TableRowUnstyledComponent>
)} )}
</TableBodyComponent> </TableBodyComponent>
</TableComponent> </TableComponent>
)} )}
</TableWrapper> </TableWrapper>
</div>
<div className={paginationClassName}> <div className={paginationClassName}>
<DataTablePagination <DataTablePagination
@@ -603,7 +628,13 @@ export function DataTable<TData, TValue>({
/> />
</div> </div>
<DataTableHeadingPortal table={table} headingTarget={headingTarget} /> <DataTableHeadingPortal
table={table}
headingTarget={headingTarget}
totalRecords={totalRecords}
manualPagination={manualPagination}
hasActiveFilters={hasServerSideFilters}
/>
</div> </div>
); );
} }

View File

@@ -12,6 +12,7 @@ type Props = {
tooltip?: string | React.ReactNode; tooltip?: string | React.ReactNode;
center?: boolean; center?: boolean;
className?: string; className?: string;
sorting?: boolean;
}; };
export default function DataTableHeader({ export default function DataTableHeader({
children, children,
@@ -19,23 +20,31 @@ export default function DataTableHeader({
tooltip, tooltip,
center, center,
className, className,
sorting = true,
}: Props) { }: Props) {
return ( return (
<FullTooltip content={tooltip} disabled={!tooltip}> <FullTooltip content={tooltip} disabled={!tooltip}>
<div <div
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")} onClick={
sorting
? () => column.toggleSorting(column.getIsSorted() === "asc")
: undefined
}
className={cn( className={cn(
"flex items-center whitespace-nowrap cursor-pointer gap-2 dark:text-gray-400 dark:hover:text-gray-300 transition-all select-none hover:text-nb-gray text-xs tracking-wide", "flex items-center whitespace-nowrap gap-2 dark:text-gray-400 transition-all select-none text-xs tracking-wide",
sorting &&
"cursor-pointer dark:hover:text-gray-300 hover:text-nb-gray",
center && "justify-center w-full", center && "justify-center w-full",
className, className,
)} )}
> >
{children} {children}
{column.getIsSorted() === "desc" ? ( {sorting &&
<IconSortAscending size={16} /> (column.getIsSorted() === "desc" ? (
) : ( <IconSortAscending size={16} />
<IconSortDescending size={16} /> ) : (
)} <IconSortDescending size={16} />
))}
</div> </div>
</FullTooltip> </FullTooltip>
); );

View File

@@ -6,27 +6,57 @@ import { createPortal } from "react-dom";
type Props<TData> = { type Props<TData> = {
table: Table<TData> | null; table: Table<TData> | null;
headingTarget?: HTMLHeadingElement | null; headingTarget?: HTMLHeadingElement | null;
totalRecords?: number;
manualPagination?: boolean;
hasActiveFilters?: boolean;
}; };
export const DataTableHeadingPortal = function <TData>({ export const DataTableHeadingPortal = function <TData>({
table, table,
headingTarget, headingTarget,
totalRecords,
manualPagination,
hasActiveFilters,
}: Props<TData>) { }: Props<TData>) {
const hasMounted = useRef(false); const hasMounted = useRef(false);
const initialTotalRecords = useRef<number | undefined>(undefined);
if (
manualPagination &&
totalRecords !== undefined &&
initialTotalRecords.current === undefined
) {
initialTotalRecords.current = totalRecords;
}
if (!headingTarget) return; if (!headingTarget) return;
if (!hasMounted.current) hasMounted.current = true; if (!hasMounted.current) hasMounted.current = true;
const totalItems = table?.getPreFilteredRowModel().rows.length; const filteredItems = manualPagination
const filteredItems = table?.getFilteredRowModel().rows.length; ? totalRecords
: table?.getFilteredRowModel().rows.length;
const getTotalRecords = () => {
if (Number(initialTotalRecords.current) < Number(filteredItems)) {
initialTotalRecords.current = filteredItems;
return filteredItems;
}
return initialTotalRecords.current;
};
const totalItems = manualPagination
? getTotalRecords()
: table?.getPreFilteredRowModel().rows.length;
if (!totalItems || totalItems == 1) return; if (!totalItems || totalItems == 1) return;
const hasAnyFiltersActive = const hasAnyFiltersActive = manualPagination
table && ? hasActiveFilters ?? totalRecords !== initialTotalRecords.current
!( : table &&
table?.getState().columnFilters.length <= 0 && !(
table?.getState().globalFilter === "" table?.getState().columnFilters.length <= 0 &&
); table?.getState().globalFilter === ""
);
const portalContainer = document.createElement("span"); const portalContainer = document.createElement("span");
headingTarget.prepend(portalContainer); headingTarget.prepend(portalContainer);

View File

@@ -1,21 +0,0 @@
import { Sparkles } from "lucide-react";
import * as React from "react";
export const AIButton = () => {
return (
<div
className={
"animated-gradient-bg gap-2 flex items-center justify-center text-sm font-medium p-[2px] rounded-md group"
}
>
<div
className={
"flex items-center justify-center w-full h-full gap-2 bg-nb-gray-930/70 px-3 py-2.5 rounded-md"
}
>
<Sparkles size={16} />
AI Rule Wizard
</div>
</div>
);
};

View File

@@ -64,7 +64,7 @@ export default function GetStartedTest({
{description} {description}
</Paragraph> </Paragraph>
</div> </div>
<div>{button}</div> {button && <div>{button}</div>}
</div> </div>
</div> </div>
<Paragraph className={"text-sm justify-center pb-5 px-8"}> <Paragraph className={"text-sm justify-center pb-5 px-8"}>

View File

@@ -1,16 +0,0 @@
import * as React from "react";
type Props = {
text?: string;
};
export const NewBadge = ({ text = "NEW" }: Props) => {
return (
<span
className={
"text-[7px] relative top-[.25px] leading-[0] bg-green-900 border border-green-500/20 py-1.5 px-1 rounded-[3px] text-green-400"
}
>
{text}
</span>
);
};

View File

@@ -5,6 +5,7 @@ import { FilterX } from "lucide-react";
import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { usePathname, useRouter, useSearchParams } from "next/navigation";
import React, { useCallback } from "react"; import React, { useCallback } from "react";
import Skeleton from "react-loading-skeleton"; import Skeleton from "react-loading-skeleton";
import SquareIcon from "@components/SquareIcon";
type Props = { type Props = {
icon?: React.ReactNode; icon?: React.ReactNode;
@@ -54,7 +55,7 @@ export default function NoResults({
<div className={cn("relative overflow-hidden", className)}> <div className={cn("relative overflow-hidden", className)}>
<div <div
className={ className={
"absolute z-20 bg-gradient-to-b dark:to-nb-gray-950 dark:from-nb-gray-950/70 w-full h-full overflow-hidden top-0" "absolute z-20 bg-gradient-to-b dark:to-nb-gray-950 dark:from-nb-gray-950/50 w-full h-full overflow-hidden top-0"
} }
></div> ></div>
<div <div
@@ -66,18 +67,21 @@ export default function NoResults({
<Skeleton className={"w-full"} height={70} duration={4} /> <Skeleton className={"w-full"} height={70} duration={4} />
<Skeleton className={"w-full"} height={70} duration={4} /> <Skeleton className={"w-full"} height={70} duration={4} />
<Skeleton className={"w-full"} height={70} duration={4} /> <Skeleton className={"w-full"} height={70} duration={4} />
<Skeleton className={"w-full"} height={70} duration={4} />
<Skeleton className={"w-full"} height={70} duration={4} />
</div> </div>
</div> </div>
<div <div
className={cn("max-w-md mx-auto relative z-20 py-6", contentClassName)} className={cn("max-w-md mx-auto relative z-20 py-6", contentClassName)}
> >
<div <div className={"flex items-center justify-center mb-6"}>
className={ <SquareIcon
"mx-auto w-14 h-14 bg-nb-gray-930 flex items-center justify-center mb-3 rounded-md" icon={icon ? icon : <FilterX size={24} />}
} color={"gray"}
> size={"large"}
{icon ? icon : <FilterX size={24} />} />
</div> </div>
<div className={"text-center"}> <div className={"text-center"}>
<h1 className={"text-2xl font-medium max-w-lg mx-auto"}>{title}</h1> <h1 className={"text-2xl font-medium max-w-lg mx-auto"}>{title}</h1>
<Paragraph className={"justify-center my-2 !text-nb-gray-400"}> <Paragraph className={"justify-center my-2 !text-nb-gray-400"}>

View File

@@ -1,84 +0,0 @@
import Badge, { BadgeVariants } from "@components/Badge";
import { cn } from "@utils/helpers";
import { EyeIcon, MonitorSmartphoneIcon, SquarePen } from "lucide-react";
import * as React from "react";
import { useMemo, useState } from "react";
import { useGroups } from "@/contexts/GroupsProvider";
import { Group } from "@/interfaces/Group";
import { AssignPeerToGroupModal } from "@/modules/groups/AssignPeerToGroupModal";
type Props = {
children?: React.ReactNode;
group?: Group;
useSave?: boolean;
onAssignmentChange?: (group: Group) => void;
} & React.HTMLAttributes<HTMLDivElement> &
BadgeVariants;
export default function PeerBadge({
children,
group,
variant = "gray",
className,
useSave = true,
onAssignmentChange,
}: Props) {
const [editGroupPeersModal, setEditGroupPeersModal] = useState(false);
const { dropdownOptions, addDropdownOptions } = useGroups();
const currentGroup = useMemo(() => {
return dropdownOptions?.find((g) => g.name === group?.name);
}, [group, dropdownOptions]);
const peerCount = useMemo(() => {
let peerCount = currentGroup?.peers_count ?? 0;
let countedPeers = currentGroup?.peers?.length ?? 0;
if (peerCount !== countedPeers) {
peerCount = countedPeers;
}
return peerCount;
}, [currentGroup]);
const updateGroupOptions = (g: Group) => {
addDropdownOptions([g]);
onAssignmentChange && onAssignmentChange(g);
};
return (
<>
{currentGroup && editGroupPeersModal && (
<AssignPeerToGroupModal
useSave={useSave}
group={currentGroup}
onUpdate={(g) => updateGroupOptions(g)}
open={editGroupPeersModal}
setOpen={setEditGroupPeersModal}
/>
)}
<Badge
variant={variant}
className={cn(className, "px-3 gap-2 whitespace-nowrap")}
onClick={(e) => {
if (!currentGroup) return;
e.stopPropagation();
setEditGroupPeersModal(true);
}}
useHover={!!currentGroup}
>
{!currentGroup && <MonitorSmartphoneIcon size={12} />}
{currentGroup ? <>{peerCount} Peer(s)</> : children}
{currentGroup && (
<>
{currentGroup.name == "All" ? (
<EyeIcon size={12} />
) : (
<SquarePen size={12} />
)}
</>
)}
</Badge>
</>
);
}

View File

@@ -10,6 +10,12 @@ const smallBadgeVariants = cva("", {
white: "bg-white/20 border border-white/10 text-white", white: "bg-white/20 border border-white/10 text-white",
sky: "bg-sky-900 border border-sky-500/20 text-white", sky: "bg-sky-900 border border-sky-500/20 text-white",
netbird: "bg-netbird-900 border border-netbird-400 text-netbird-300", netbird: "bg-netbird-900 border border-netbird-400 text-netbird-300",
yellow: "bg-yellow-900 border border-yellow-500/20 text-yellow-400",
},
size: {
default:
"text-[0.4rem] relative -top-[.25px] leading-[0] py-[0.39rem] px-1 rounded-[3px]",
md: "text-[0.55rem] relative -top-[.25px] leading-[0] py-[0.45rem] px-1 rounded-[3px]",
}, },
}, },
}); });
@@ -27,15 +33,10 @@ export const SmallBadge = ({
textClassName, textClassName,
variant = "green", variant = "green",
children, children,
size = "default",
}: Props) => { }: Props) => {
return ( return (
<span <span className={cn(smallBadgeVariants({ variant, size }), className)}>
className={cn(
smallBadgeVariants({ variant }),
"text-[7px] relative -top-[.25px] leading-[0] py-[0.39rem] px-1 rounded-[3px]",
className,
)}
>
{children} {children}
<span className={cn("relative top-[0.4px]", textClassName)}>{text}</span> <span className={cn("relative top-[0.4px]", textClassName)}>{text}</span>
</span> </span>

View File

@@ -4,22 +4,38 @@ import React, { useMemo, useState } from "react";
type Props = { type Props = {
text?: string; text?: string;
children?: React.ReactNode;
tooltipContent?: React.ReactNode;
className?: string; className?: string;
maxChars?: number; maxChars?: number;
maxWidth?: string; // Optional CSS width value maxWidth?: string; // Optional CSS width value
hideTooltip?: boolean; hideTooltip?: boolean;
align?: "start" | "center" | "end";
alignOffset?: number;
side?: "top" | "right" | "bottom" | "left";
sideOffset?: number;
}; };
export default function TruncatedText({ export default function TruncatedText({
text, text,
children,
tooltipContent,
className, className,
maxChars = 40, maxChars = 40,
maxWidth, maxWidth,
hideTooltip = false, hideTooltip = false,
align,
alignOffset = 20,
side,
sideOffset = 4,
}: Readonly<Props>) { }: Readonly<Props>) {
const [isOverflowing, setIsOverflowing] = useState(false); const [isOverflowing, setIsOverflowing] = useState(false);
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const contentRef = React.useRef<HTMLDivElement>(null); const contentRef = React.useRef<HTMLDivElement>(null);
const measureRef = React.useRef<HTMLSpanElement>(null);
const hasCustomChildren = !!children;
const content = children ?? text;
const charCount = useMemo(() => { const charCount = useMemo(() => {
if (!text) return 0; if (!text) return 0;
@@ -27,12 +43,17 @@ export default function TruncatedText({
}, [text]); }, [text]);
// Check for overflow on mount and when text/maxWidth changes // Check for overflow on mount and when text/maxWidth changes
// When custom children are provided, use a hidden measurement element
// to detect overflow independently of children's own truncation
React.useEffect(() => { React.useEffect(() => {
const element = contentRef.current; const container = contentRef.current;
if (element) { const measure = measureRef.current;
setIsOverflowing(element.scrollWidth > element.clientWidth); if (hasCustomChildren && container && measure) {
setIsOverflowing(measure.scrollWidth > container.clientWidth);
} else if (container) {
setIsOverflowing(container.scrollWidth > container.clientWidth);
} }
}, [text, maxWidth]); }, [text, children, maxWidth, hasCustomChildren]);
// If maxWidth is provided, use overflow detection // If maxWidth is provided, use overflow detection
// Otherwise, fall back to character count logic // Otherwise, fall back to character count logic
@@ -44,11 +65,28 @@ export default function TruncatedText({
? { maxWidth } ? { maxWidth }
: { maxWidth: `${maxChars - 2}ch` }; : { maxWidth: `${maxChars - 2}ch` };
const measureElement = hasCustomChildren && text && (
<span
ref={measureRef}
className="absolute invisible whitespace-nowrap pointer-events-none h-0 overflow-hidden"
aria-hidden="true"
>
{text}
</span>
);
if (isDisabled) { if (isDisabled) {
return ( return (
<div className="w-full min-w-0 inline-block" style={containerStyle}> <div
className={cn(
"w-full min-w-0 inline-block",
hasCustomChildren && "relative",
)}
style={containerStyle}
>
{measureElement}
<div ref={contentRef} className={cn(className, "truncate")}> <div ref={contentRef} className={cn(className, "truncate")}>
{text} {content}
</div> </div>
</div> </div>
); );
@@ -57,25 +95,36 @@ export default function TruncatedText({
return ( return (
<Tooltip delayDuration={650} open={open} onOpenChange={setOpen}> <Tooltip delayDuration={650} open={open} onOpenChange={setOpen}>
<TooltipTrigger asChild={true}> <TooltipTrigger asChild={true}>
<div className="w-full min-w-0 inline-block" style={containerStyle}> <div
className={cn(
"w-full min-w-0 inline-block",
hasCustomChildren && "relative",
)}
style={containerStyle}
>
{measureElement}
<div ref={contentRef} className={cn(className, "truncate")}> <div ref={contentRef} className={cn(className, "truncate")}>
{text} {content}
</div> </div>
</div> </div>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent <TooltipContent
alignOffset={20} align={align}
sideOffset={4} alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
}} }}
className={cn(className, "px-3 py-1.5")} className={cn(className, "px-3 py-1.5")}
> >
<div className="text-neutral-300 flex flex-col gap-1"> {tooltipContent ?? (
<div className="max-w-xs break-all whitespace-normal text-xs"> <div className="text-neutral-300 flex flex-col gap-1">
{text} <div className="max-w-xs break-all whitespace-normal text-xs">
{text}
</div>
</div> </div>
</div> )}
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
); );

View File

@@ -0,0 +1,634 @@
"use client";
import { notify } from "@components/Notification";
import useFetchApi, { useApiCall } from "@utils/api";
import React, {
createContext,
useCallback,
useContext,
useMemo,
useState,
} from "react";
import { useSWRConfig } from "swr";
import { useDialog } from "@/contexts/DialogProvider";
import { Network, NetworkResource } from "@/interfaces/Network";
import { Peer } from "@/interfaces/Peer";
import {
ReverseProxy,
ReverseProxyDomain,
ReverseProxyFlatTarget,
ReverseProxyTarget,
ReverseProxyTargetProtocol,
ReverseProxyTargetType,
} from "@/interfaces/ReverseProxy";
import ReverseProxyModal from "@/modules/reverse-proxy/ReverseProxyModal";
import ReverseProxyTargetModal from "@/modules/reverse-proxy/targets/ReverseProxyTargetModal";
type ReverseProxiesContextValue = {
reverseProxies: ReverseProxy[] | undefined;
isLoading: boolean;
openModal: (options?: OpenModalOptions) => void;
openTargetModal: (options: OpenTargetModalOptions) => void;
handleCreateOrUpdateProxy: (options: HandleCreateOrUpdateOptions) => void;
resolveDestination: (target: ReverseProxyTarget) => string;
handleToggle: (proxy: ReverseProxy) => Promise<void>;
handleDelete: (proxy: ReverseProxy) => Promise<void>;
handleDeleteTarget: (
proxy: ReverseProxy,
target: ReverseProxyTarget,
) => Promise<void>;
handleToggleTarget: (
proxy: ReverseProxy,
target: ReverseProxyTarget,
) => Promise<void>;
domains: ReverseProxyDomain[] | undefined;
isLoadingDomains: boolean;
validateDomain: (domainId: string) => Promise<void>;
deleteDomain: (domain: ReverseProxyDomain) => Promise<void>;
createDomain: (
domain: string,
targetCluster: string,
) => Promise<ReverseProxyDomain>;
};
type OpenModalOptions = {
proxy?: ReverseProxy;
initialTab?: string;
initialPeer?: Peer;
initialNetwork?: Network;
initialResource?: NetworkResource;
onSuccess?: () => void;
};
type OpenTargetModalOptions = {
proxy: ReverseProxy;
target?: ReverseProxyTarget;
};
type HandleCreateOrUpdateOptions = {
data: Partial<ReverseProxy>;
proxyId?: string;
onSuccess?: () => void;
};
type Props = {
children: React.ReactNode;
initialPeer?: Peer;
initialNetwork?: Network;
};
const ReverseProxiesContext = createContext<ReverseProxiesContextValue | null>(
null,
);
export default function ReverseProxiesProvider({
children,
initialPeer,
initialNetwork,
}: Readonly<Props>) {
const { mutate } = useSWRConfig();
const { confirm } = useDialog();
// Reverse Proxies
const { data: rawReverseProxies, isLoading } = useFetchApi<ReverseProxy[]>(
"/reverse-proxies/services",
);
const request = useApiCall<ReverseProxy>("/reverse-proxies/services");
// Peers & Resources for resolving target destinations
const { data: peers } = useFetchApi<Peer[]>("/peers");
const { data: resources } = useFetchApi<NetworkResource[]>(
"/networks/resources",
);
const resolveDestination = useCallback(
(target: ReverseProxyTarget) => {
if (target.host === "unknown") return target.host;
const host = resolveTargetHost(target, peers, resources);
return formatTargetDestination(target, host);
},
[peers, resources],
);
const reverseProxies = useMemo(() => {
return rawReverseProxies?.map((proxy) => ({
...proxy,
targets: proxy.targets.map((target) => ({
...target,
destination: resolveDestination(target),
})),
}));
}, [rawReverseProxies, resolveDestination]);
// Domains
const { data: domains, isLoading: isLoadingDomains } = useFetchApi<
ReverseProxyDomain[]
>("/reverse-proxies/domains");
const domainRequest = useApiCall<ReverseProxyDomain>(
"/reverse-proxies/domains",
true,
);
const [modalOpen, setModalOpen] = useState(false);
const [currentProxy, setCurrentProxy] = useState<ReverseProxy | undefined>();
const [initialTab, setInitialTab] = useState<string | undefined>();
const [modalInitialPeer, setModalInitialPeer] = useState<Peer | undefined>();
const [modalInitialNetwork, setModalInitialNetwork] = useState<
Network | undefined
>();
const [targetModalOpen, setTargetModalOpen] = useState(false);
const [targetModalProxy, setTargetModalProxy] = useState<
ReverseProxy | undefined
>();
const [editingTarget, setEditingTarget] = useState<
ReverseProxyTarget | undefined
>();
const [modalInitialResource, setModalInitialResource] = useState<
NetworkResource | undefined
>();
const onSuccessRef = React.useRef<(() => void) | undefined>(undefined);
const openModal = useCallback(
(options?: OpenModalOptions) => {
setCurrentProxy(options?.proxy);
setInitialTab(options?.initialTab);
setModalInitialPeer(options?.initialPeer ?? initialPeer);
setModalInitialNetwork(options?.initialNetwork ?? initialNetwork);
setModalInitialResource(options?.initialResource);
onSuccessRef.current = options?.onSuccess;
setModalOpen(true);
},
[initialPeer, initialNetwork],
);
const closeModal = useCallback(() => {
setModalOpen(false);
setCurrentProxy(undefined);
setInitialTab(undefined);
setModalInitialPeer(undefined);
setModalInitialNetwork(undefined);
setModalInitialResource(undefined);
onSuccessRef.current = undefined;
}, []);
const openTargetModal = useCallback((options: OpenTargetModalOptions) => {
setTargetModalProxy(options.proxy);
setEditingTarget(options.target);
setTargetModalOpen(true);
}, []);
const closeTargetModal = useCallback(() => {
setTargetModalOpen(false);
setTargetModalProxy(undefined);
setEditingTarget(undefined);
}, []);
const handleSaveTarget = useCallback(
async (target: ReverseProxyTarget) => {
if (!targetModalProxy) return;
let updatedTargets: ReverseProxyTarget[];
const isEditing = !!editingTarget;
const proxyId = targetModalProxy.id;
if (isEditing) {
// Update existing target - match by index against the original target
const targetIndex = targetModalProxy.targets.indexOf(editingTarget);
updatedTargets = targetModalProxy.targets.map((t, i) => {
return i === targetIndex ? target : t;
});
} else {
// Add new target
updatedTargets = [...(targetModalProxy.targets || []), target];
}
notify({
title: targetModalProxy.domain,
description: isEditing
? "Target updated successfully"
: "Target added successfully",
promise: request
.put(
{ ...targetModalProxy, targets: sanitizeTargets(updatedTargets) },
`/${targetModalProxy.id}`,
)
.then(() => {
mutate("/reverse-proxies/services");
// After adding a new target, scroll to the row and open the accordion
if (!isEditing) {
setTimeout(() => {
const row = document.querySelector<HTMLElement>(
`[data-row-id="${proxyId}"]`,
);
if (row?.getAttribute("data-accordion") === "closed") {
row?.click();
}
row?.scrollIntoView({ behavior: "smooth" });
}, 200);
}
}),
loadingMessage: isEditing ? "Updating target..." : "Adding target...",
});
closeTargetModal();
},
[targetModalProxy, editingTarget, request, mutate, closeTargetModal],
);
const handleCreateOrUpdateProxy = useCallback(
({ data, proxyId, onSuccess }: HandleCreateOrUpdateOptions) => {
const sanitizedData = {
...data,
targets: data.targets ? sanitizeTargets(data.targets) : undefined,
};
const isCreating = !proxyId;
const promise = isCreating
? request.post(sanitizedData)
: request.put(sanitizedData, `/${proxyId}`);
notify({
title: data.domain || "",
description: isCreating
? "Service was successfully created"
: "Service was successfully updated",
promise: promise.then((result) => {
mutate("/reverse-proxies/services");
onSuccess?.();
if (isCreating && result?.id) {
setTimeout(() => {
const row = document.querySelector<HTMLElement>(
`[data-row-id="${result.id}"]`,
);
if (row?.getAttribute("data-accordion") === "closed") {
row?.click();
}
row?.scrollIntoView({ behavior: "smooth" });
}, 200);
}
}),
loadingMessage: isCreating
? "Creating service..."
: "Updating service...",
});
},
[request, mutate],
);
const handleToggle = useCallback(
async (proxy: ReverseProxy) => {
const newEnabled = !proxy.enabled;
notify({
title: proxy.domain,
description: `Reverse proxy ${newEnabled ? "enabled" : "disabled"}`,
promise: request
.put(
{
...proxy,
enabled: newEnabled,
targets: sanitizeTargets(proxy.targets),
},
`/${proxy.id}`,
)
.then(() => {
mutate("/reverse-proxies/services");
}),
loadingMessage: `${
newEnabled ? "Enabling" : "Disabling"
} reverse proxy...`,
});
},
[mutate, request],
);
const handleToggleTarget = useCallback(
async (proxy: ReverseProxy, target: ReverseProxyTarget) => {
const newEnabled = !target.enabled;
const targetIndex = proxy.targets.indexOf(target);
const updatedTargets = proxy.targets.map((t, i) => {
return i === targetIndex ? { ...t, enabled: newEnabled } : t;
});
notify({
title: proxy.domain,
description: `Target ${newEnabled ? "enabled" : "disabled"}`,
promise: request
.put(
{ ...proxy, targets: sanitizeTargets(updatedTargets) },
`/${proxy.id}`,
)
.then(() => {
mutate("/reverse-proxies/services");
}),
loadingMessage: `${newEnabled ? "Enabling" : "Disabling"} target...`,
});
},
[mutate, request],
);
const handleDelete = useCallback(
async (proxy: ReverseProxy) => {
const choice = await confirm({
title: `Delete '${proxy.domain}'?`,
description:
"Are you sure you want to delete this reverse proxy? This action cannot be undone.",
confirmText: "Delete",
cancelText: "Cancel",
type: "danger",
});
if (!choice) return;
notify({
title: proxy.domain,
description: "Reverse proxy was successfully deleted",
promise: request.del({}, `/${proxy.id}`).then(() => {
mutate("/reverse-proxies/services");
}),
loadingMessage: "Deleting reverse proxy...",
});
},
[confirm, request, mutate],
);
const handleDeleteTarget = useCallback(
async (proxy: ReverseProxy, target: ReverseProxyTarget) => {
const isOnlyTarget = proxy.targets.length <= 1;
const choice = await confirm({
title: isOnlyTarget ? `Delete '${proxy.domain}'?` : `Delete target?`,
description: isOnlyTarget
? "This is the only target for this service. Deleting it will remove the entire service. This action cannot be undone."
: "Are you sure you want to delete this target? This action cannot be undone.",
confirmText: "Delete",
cancelText: "Cancel",
type: "danger",
});
if (!choice) return;
if (isOnlyTarget) {
notify({
title: proxy.domain,
description: "Service was successfully deleted",
promise: request.del({}, `/${proxy.id}`).then(() => {
mutate("/reverse-proxies/services");
}),
loadingMessage: "Deleting service...",
});
} else {
const targetIndex = proxy.targets.indexOf(target);
const updatedTargets = proxy.targets.filter(
(_, i) => i !== targetIndex,
);
notify({
title: proxy.domain,
description: "Target was successfully deleted",
promise: request
.put(
{ ...proxy, targets: sanitizeTargets(updatedTargets) },
`/${proxy.id}`,
)
.then(() => {
mutate("/reverse-proxies/services");
}),
loadingMessage: "Deleting target...",
});
}
},
[confirm, request, mutate],
);
const createDomain = useCallback(
async (
domain: string,
targetCluster: string,
): Promise<ReverseProxyDomain> => {
const promise = domainRequest
.post({
domain,
target_cluster: targetCluster,
})
.then((d) => {
mutate("/reverse-proxies/domains");
return d;
});
notify({
title: "Add Custom Domain",
description: "Domain successfully added",
promise,
loadingMessage: "Adding domain...",
});
return promise;
},
[domainRequest, mutate],
);
const validateDomain = useCallback(
async (domainId: string) => {
// Delay refetch to allow the server to propagate the validation result
const DOMAIN_VALIDATION_REFETCH_DELAY_MS = 2000;
notify({
title: "Domain Validation",
description: "Domain validation started",
promise: domainRequest.get(`/${domainId}/validate`).then(() => {
setTimeout(() => {
mutate("/reverse-proxies/domains");
}, DOMAIN_VALIDATION_REFETCH_DELAY_MS);
}),
loadingMessage: "Validating domain...",
});
},
[domainRequest, mutate],
);
const deleteDomain = useCallback(
async (domain: ReverseProxyDomain) => {
const choice = await confirm({
title: `Delete '${domain.domain}'?`,
description:
"Are you sure you want to delete this domain? This action cannot be undone.",
confirmText: "Delete",
cancelText: "Cancel",
type: "danger",
});
if (!choice) return;
notify({
title: domain.domain,
description: "Domain was successfully deleted",
promise: domainRequest.del({}, `/${domain.id}`).then(() => {
mutate("/reverse-proxies/domains");
}),
loadingMessage: "Deleting domain...",
});
},
[confirm, domainRequest, mutate],
);
return (
<ReverseProxiesContext.Provider
value={{
reverseProxies,
isLoading,
openModal,
openTargetModal,
handleCreateOrUpdateProxy,
handleToggle,
handleToggleTarget,
handleDelete,
handleDeleteTarget,
resolveDestination,
domains,
isLoadingDomains,
createDomain,
validateDomain,
deleteDomain,
}}
>
{children}
{modalOpen && (
<ReverseProxyModal
open={modalOpen}
onOpenChange={(open) => {
if (!open) closeModal();
}}
reverseProxy={currentProxy}
domains={domains}
initialTab={initialTab}
initialPeer={modalInitialPeer}
initialNetwork={modalInitialNetwork}
initialResource={modalInitialResource}
initialSubdomain={modalInitialResource?.name}
onSuccess={onSuccessRef.current}
/>
)}
{targetModalOpen && targetModalProxy && (
<ReverseProxyTargetModal
key={targetModalOpen ? 1 : 0}
open={targetModalOpen}
onOpenChange={(open) => {
if (!open) closeTargetModal();
}}
onSave={handleSaveTarget}
currentTarget={editingTarget}
reverseProxy={targetModalProxy}
initialPeer={initialPeer}
initialNetwork={initialNetwork}
/>
)}
</ReverseProxiesContext.Provider>
);
}
export const useReverseProxies = () => {
const context = useContext(ReverseProxiesContext);
if (!context) {
throw new Error(
"useReverseProxies must be used within a ReverseProxiesProvider",
);
}
return context;
};
type FlattenReverseProxiesParams = {
reverseProxies: ReverseProxy[] | undefined;
peer?: Peer;
network?: Network;
};
export function flattenReverseProxies({
reverseProxies,
peer,
network,
}: FlattenReverseProxiesParams): ReverseProxyFlatTarget[] {
if (!reverseProxies) return [];
const flattened: ReverseProxyFlatTarget[] = [];
reverseProxies.forEach((proxy) => {
proxy.targets.forEach((target) => {
// Filter by peer if provided
if (peer) {
if (
target.target_type !== ReverseProxyTargetType.PEER ||
target.target_id !== peer.id
) {
return;
}
}
// Filter by network if provided (check if target resource belongs to network)
if (network && !peer) {
if (isResourceTargetType(target.target_type)) {
const isResourceInNetwork = network.resources?.includes(
target.target_id || "",
);
if (!isResourceInNetwork) return;
} else {
// For peer targets in network context, skip them
return;
}
}
flattened.push({
...target,
proxy,
});
});
});
return flattened;
}
export function sanitizeTargets(
targets: ReverseProxyTarget[],
): ReverseProxyTarget[] {
return targets.map((t) => {
const { destination: _, ...target } = t;
if (t.target_type === ReverseProxyTargetType.SUBNET)
return target as ReverseProxyTarget;
const { host: __, ...rest } = target;
return rest as ReverseProxyTarget;
});
}
export function isResourceTargetType(type: ReverseProxyTargetType): boolean {
return (
type === ReverseProxyTargetType.HOST ||
type === ReverseProxyTargetType.DOMAIN ||
type === ReverseProxyTargetType.SUBNET
);
}
function formatTargetDestination(
target: ReverseProxyTarget,
resolvedHost?: string,
): string {
const host = target.host || resolvedHost || "localhost";
const isDefault =
(target.protocol === "http" && target.port === 80) ||
(target.protocol === "https" && target.port === 443) ||
target.port === 0;
return isDefault
? `${target.protocol}://${host}`
: `${target.protocol}://${host}:${target.port}`;
}
export function defaultPortForProtocol(
protocol: ReverseProxyTargetProtocol,
): number {
return protocol === ReverseProxyTargetProtocol.HTTPS ? 443 : 80;
}
function resolveTargetHost(
target: ReverseProxyTarget,
peers?: Peer[],
resources?: NetworkResource[],
): string {
if (target.host) return "";
if (target.target_type === ReverseProxyTargetType.PEER) {
return peers?.find((p) => p.id === target.target_id)?.ip ?? "";
}
if (isResourceTargetType(target.target_type)) {
const address = resources?.find((r) => r.id === target.target_id)?.address;
if (!address) return "";
return address.includes("/") ? address.split("/")[0] : address;
}
return "";
}

View File

@@ -0,0 +1,222 @@
"use client";
import useFetchApi from "@utils/api";
import React, {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { useSWRConfig } from "swr";
import { Pagination } from "@/interfaces/Pagination";
type ServerPaginationContextValue<T = unknown> = {
data?: T;
isLoading: boolean;
isFetching: boolean;
mutate: () => Promise<unknown>;
pagination: { pageIndex: number; pageSize: number };
pageCount: number;
totalRecords?: number;
onPaginationChange: (pagination: {
pageIndex: number;
pageSize: number;
}) => void;
globalFilter: string;
onGlobalFilterChange: (value: string) => void;
setFilter: (key: string, value: string | undefined) => void;
getFilter: (key: string) => string | undefined;
hasActiveFilters: boolean;
resetFilters: () => void;
onFilterReset: () => void;
hasServerSideFilters: boolean;
serverSidePagination: true;
manualPagination: true;
manualFiltering: true;
keepStateInLocalStorage: false;
};
const ServerPaginationContext =
createContext<ServerPaginationContextValue | null>(null);
type ProviderProps = {
url: string;
defaultPageSize?: number;
defaultFilters?: Record<string, string>;
children: React.ReactNode;
};
export default function ServerPaginationProvider({
url,
defaultPageSize = 50,
defaultFilters,
children,
}: Readonly<ProviderProps>) {
const { mutate: swrMutate } = useSWRConfig();
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(defaultPageSize);
const [search, setSearch] = useState("");
const [filters, setFilters] = useState<Record<string, string | undefined>>(
defaultFilters ?? {},
);
const buildUrl = useCallback(
(
p: number,
ps: number,
s: string,
f: Record<string, string | undefined>,
) => {
const params = new URLSearchParams();
params.set("page", String(p));
params.set("page_size", String(ps));
if (s) params.set("search", s.toLowerCase());
Object.entries(f).forEach(([key, value]) => {
if (value !== undefined) params.set(key, value);
});
return `${url}?${params.toString()}`;
},
[url],
);
const apiUrl = buildUrl(page, pageSize, search, filters);
const {
data: response,
isLoading,
isValidating,
} = useFetchApi<Pagination<unknown>>(apiUrl);
const hasLoadedOnce = useRef(false);
const previousResponse = useRef<Pagination<unknown> | undefined>(undefined);
if (response?.data) {
hasLoadedOnce.current = true;
previousResponse.current = response;
}
const activeResponse = response ?? previousResponse.current;
const totalPages = activeResponse?.total_pages ?? 0;
const hasNextPage = page < totalPages;
// Prefetch next page
useFetchApi<Pagination<unknown>>(
hasNextPage ? buildUrl(page + 1, pageSize, search, filters) : apiUrl,
false,
false,
hasNextPage,
);
const onPaginationChange = useCallback(
(pagination: { pageIndex: number; pageSize: number }) => {
if (pagination.pageSize !== pageSize) {
setPage(1);
setPageSize(pagination.pageSize);
} else {
setPage(pagination.pageIndex + 1);
}
},
[pageSize],
);
const onGlobalFilterChange = useCallback((value: string) => {
setSearch(value);
setPage(1);
}, []);
const filterTimeout = useRef<ReturnType<typeof setTimeout>>(undefined);
const pendingFilters = useRef<Record<string, string | undefined>>({});
useEffect(() => () => clearTimeout(filterTimeout.current), []);
const setFilter = useCallback((key: string, value: string | undefined) => {
pendingFilters.current[key] = value;
clearTimeout(filterTimeout.current);
filterTimeout.current = setTimeout(() => {
const pending = pendingFilters.current;
pendingFilters.current = {};
setFilters((prev) => ({ ...prev, ...pending }));
setPage(1);
}, 100);
}, []);
const getFilter = useCallback((key: string) => filters[key], [filters]);
const hasActiveFilters =
search !== "" ||
Object.entries(filters).some(
([key, value]) => value !== (defaultFilters ?? {})[key],
);
const resetFilters = useCallback(() => {
setSearch("");
setFilters(defaultFilters ?? {});
setPage(1);
}, [defaultFilters]);
const mutate = useCallback(() => {
return swrMutate(apiUrl);
}, [swrMutate, apiUrl]);
const value = useMemo<ServerPaginationContextValue>(
() => ({
data: activeResponse?.data,
isLoading: isLoading && !hasLoadedOnce.current,
isFetching: isValidating,
mutate,
setFilter,
getFilter,
hasActiveFilters,
resetFilters,
pagination: { pageIndex: page - 1, pageSize },
pageCount: totalPages,
totalRecords: activeResponse?.total_records,
onPaginationChange,
manualPagination: true,
serverSidePagination: true,
manualFiltering: true,
keepStateInLocalStorage: false,
globalFilter: search,
onGlobalFilterChange,
onFilterReset: resetFilters,
hasServerSideFilters: hasActiveFilters,
}),
[
activeResponse?.data,
activeResponse?.total_records,
isLoading,
isValidating,
mutate,
setFilter,
getFilter,
hasActiveFilters,
resetFilters,
page,
pageSize,
totalPages,
onPaginationChange,
search,
onGlobalFilterChange,
],
);
return (
<ServerPaginationContext.Provider value={value}>
{children}
</ServerPaginationContext.Provider>
);
}
export function useServerPagination<T>() {
const context = useContext(ServerPaginationContext);
if (!context) {
throw new Error(
"useServerPagination must be used within a ServerPaginationProvider",
);
}
return context as ServerPaginationContextValue<T>;
}

27
src/hooks/useUrlTab.ts Normal file
View File

@@ -0,0 +1,27 @@
import { useRouter, useSearchParams } from "next/navigation";
import { useCallback, useMemo } from "react";
export default function useUrlTab(
validTabs: string[],
defaultTab: string,
): [string, (value: string) => void] {
const searchParams = useSearchParams();
const router = useRouter();
const tab = useMemo(() => {
const tabParam = searchParams.get("tab");
if (tabParam && validTabs.includes(tabParam)) return tabParam;
return defaultTab;
}, [searchParams, validTabs, defaultTab]);
const setTab = useCallback(
(value: string) => {
const params = new URLSearchParams(searchParams.toString());
params.set("tab", value);
router.replace(`?${params.toString()}`, { scroll: false });
},
[searchParams, router],
);
return [tab, setTab];
}

View File

@@ -17,6 +17,7 @@ export interface NetworkRouter {
metric: number; metric: number;
masquerade: boolean; masquerade: boolean;
enabled: boolean; enabled: boolean;
search?: string;
} }
export interface NetworkResource { export interface NetworkResource {

View File

@@ -33,6 +33,8 @@ export interface Permissions {
proxy: Permission; proxy: Permission;
proxy_configuration: Permission; proxy_configuration: Permission;
services: Permission;
}; };
} }

View File

@@ -0,0 +1,131 @@
export interface ReverseProxy {
id?: string;
name: string;
domain: string;
proxy_cluster?: string;
targets: ReverseProxyTarget[];
enabled: boolean;
pass_host_header?: boolean;
rewrite_redirects?: boolean;
auth?: ReverseProxyAuth;
meta?: ReverseProxyMeta;
}
export interface ReverseProxyMeta {
created_at: string;
status: ReverseProxyStatus;
certificate_issued_at?: string;
}
export enum ReverseProxyStatus {
PENDING = "pending",
ACTIVE = "active",
TUNNEL_NOT_CREATED = "tunnel_not_created",
CERTIFICATE_PENDING = "certificate_pending",
CERTIFICATE_FAILED = "certificate_failed",
ERROR = "error",
}
export interface ReverseProxyTarget {
target_id?: string;
target_type: ReverseProxyTargetType;
path?: string;
protocol: ReverseProxyTargetProtocol;
host?: string;
port: number;
enabled: boolean;
access_local?: boolean;
// Frontend
destination?: string;
}
export interface ReverseProxyAuth {
password_auth?: {
enabled: boolean;
password: string;
};
pin_auth?: {
enabled: boolean;
pin: string;
};
bearer_auth?: {
enabled: boolean;
distribution_groups: string[];
};
link_auth?: {
enabled: boolean;
};
}
export interface ReverseProxyDomain {
id: string;
domain: string;
validated: boolean;
type: ReverseProxyDomainType;
target_cluster?: string;
}
export enum ReverseProxyDomainType {
FREE = "free",
CUSTOM = "custom",
}
export enum ReverseProxyTargetType {
PEER = "peer",
HOST = "host",
DOMAIN = "domain",
SUBNET = "subnet",
}
export enum ReverseProxyTargetProtocol {
HTTP = "http",
HTTPS = "https",
}
export interface ReverseProxyEvent {
id: string;
service_id: string;
timestamp: string;
method: string;
host: string;
path: string;
duration_ms: number;
status_code: number;
source_ip: string;
reason?: string;
user_id?: string;
auth_method_used?: string;
country_code?: string;
city_name?: string;
}
export interface ReverseProxyFlatTarget extends ReverseProxyTarget {
proxy: ReverseProxy;
}
export const REVERSE_PROXY_DOCS_LINK =
"https://docs.netbird.io/manage/reverse-proxy";
export const REVERSE_PROXY_SERVICES_DOCS_LINK =
"https://docs.netbird.io/manage/reverse-proxy#services";
export const REVERSE_PROXY_TARGETS_DOCS_LINK =
"https://docs.netbird.io/manage/reverse-proxy#targets";
export const REVERSE_PROXY_AUTHENTICATION_DOCS_LINK =
"https://docs.netbird.io/manage/reverse-proxy/authentication";
export const REVERSE_PROXY_SETTINGS_DOCS_LINK =
"https://docs.netbird.io/manage/reverse-proxy#step-4-configure-advanced-settings";
export const REVERSE_PROXY_CLUSTERS_DOCS_LINK =
"https://docs.netbird.io/manage/reverse-proxy#self-hosted-proxy-setup";
export const REVERSE_PROXY_CUSTOM_DOMAINS_DOCS_LINK =
"https://docs.netbird.io/manage/reverse-proxy/custom-domains";
export const REVERSE_PROXY_DOMAIN_VERIFICATION_LINK =
"https://docs.netbird.io/manage/reverse-proxy/custom-domains#validating-a-custom-domain";
export const REVERSE_PROXY_EVENTS_DOCS_LINK =
"https://docs.netbird.io/manage/reverse-proxy/access-logs";

View File

@@ -9,7 +9,7 @@ import relativeTime from "dayjs/plugin/relativeTime";
import { Viewport } from "next"; import { Viewport } from "next";
import localFont from "next/font/local"; import localFont from "next/font/local";
import React, { Suspense } from "react"; import React, { Suspense } from "react";
import { Toaster } from "react-hot-toast"; import { Toaster } from "sonner";
import OIDCProvider from "@/auth/OIDCProvider"; import OIDCProvider from "@/auth/OIDCProvider";
import FullScreenLoading from "@/components/ui/FullScreenLoading"; import FullScreenLoading from "@/components/ui/FullScreenLoading";
import AnalyticsProvider, { import AnalyticsProvider, {
@@ -59,10 +59,13 @@ export default function AppLayout({
</GlobalThemeProvider> </GlobalThemeProvider>
</DialogProvider> </DialogProvider>
<Toaster <Toaster
position={"top-center"} position="top-center"
toastOptions={{ duration={3000}
duration: 3000, toastOptions={{ unstyled: true }}
}} style={{ "--width": "28rem" } as React.CSSProperties}
gap={0}
visibleToasts={5}
offset="12px"
/> />
<NavigationEvents /> <NavigationEvents />
<DisableDarkReader /> <DisableDarkReader />

View File

@@ -1,11 +1,8 @@
"use client"; "use client";
import { ScrollArea } from "@components/ScrollArea"; import { ScrollArea } from "@components/ScrollArea";
import { SmallBadge } from "@components/ui/SmallBadge";
import { cn } from "@utils/helpers"; import { cn } from "@utils/helpers";
import * as React from "react";
import AccessControlIcon from "@/assets/icons/AccessControlIcon"; import AccessControlIcon from "@/assets/icons/AccessControlIcon";
import ActivityIcon from "@/assets/icons/ActivityIcon";
import ControlCenterIcon from "@/assets/icons/ControlCenterIcon"; import ControlCenterIcon from "@/assets/icons/ControlCenterIcon";
import DNSIcon from "@/assets/icons/DNSIcon"; import DNSIcon from "@/assets/icons/DNSIcon";
import DocsIcon from "@/assets/icons/DocsIcon"; import DocsIcon from "@/assets/icons/DocsIcon";
@@ -20,6 +17,10 @@ import { useApplicationContext } from "@/contexts/ApplicationProvider";
import { usePermissions } from "@/contexts/PermissionsProvider"; import { usePermissions } from "@/contexts/PermissionsProvider";
import { headerHeight } from "@/layouts/Header"; import { headerHeight } from "@/layouts/Header";
import { NetworkNavigation } from "@/modules/networks/misc/NetworkNavigation"; import { NetworkNavigation } from "@/modules/networks/misc/NetworkNavigation";
import { SmallBadge } from "@components/ui/SmallBadge";
import * as React from "react";
import ReverseProxyIcon from "@/assets/icons/ReverseProxyIcon";
import ActivityIcon from "@/assets/icons/ActivityIcon";
type Props = { type Props = {
fullWidth?: boolean; fullWidth?: boolean;
@@ -131,6 +132,41 @@ export default function Navigation({
<NetworkNavigation /> <NetworkNavigation />
<SidebarItem
icon={<ReverseProxyIcon size={16} />}
labelClassName={"pr-0"}
label={
<div className={"flex items-center gap-2"}>
Reverse Proxy
<SmallBadge
text={"Beta"}
variant={"sky"}
className={"text-[8px] leading-none py-[3px] px-[5px]"}
textClassName={"top-0"}
/>
</div>
}
href={"/reverse-proxy"}
collapsible
exactPathMatch={false}
visible={permission?.services?.read}
>
<SidebarItem
label="Services"
isChild
href={"/reverse-proxy/services"}
exactPathMatch={true}
visible={permission?.services?.read}
/>
<SidebarItem
label="Custom Domains"
isChild
href={"/reverse-proxy/custom-domains"}
exactPathMatch={true}
visible={permission?.services?.read}
/>
</SidebarItem>
<SidebarItem <SidebarItem
icon={<DNSIcon />} icon={<DNSIcon />}
label="DNS" label="DNS"
@@ -176,13 +212,7 @@ export default function Navigation({
visible={permission.users.read} visible={permission.users.read}
/> />
</SidebarItem> </SidebarItem>
<SidebarItem <ActivityNavigationItem />
icon={<ActivityIcon />}
label="Activity"
href={"/events/audit"}
exactPathMatch={true}
visible={permission.events.read}
/>
</SidebarItemGroup> </SidebarItemGroup>
<SidebarItemGroup> <SidebarItemGroup>
@@ -225,3 +255,31 @@ export function SidebarItemGroup({ children }: SidebarItemGroupProps) {
</div> </div>
); );
} }
const ActivityNavigationItem = () => {
const { permission } = usePermissions();
return (
<SidebarItem
icon={<ActivityIcon />}
label="Activity"
collapsible
visible={permission.events.read}
>
<SidebarItem
label="Audit Events"
href={"/events/audit"}
isChild
exactPathMatch={true}
visible={permission.events.read}
/>
<SidebarItem
label="Proxy Events"
isChild
href={"/events/proxy"}
exactPathMatch={true}
visible={permission.events.read}
/>
</SidebarItem>
);
};

View File

@@ -742,10 +742,16 @@ export default function ActivityDescription({ event }: Props) {
*/ */
if (event.activity_code == "peer.job.create") if (event.activity_code == "peer.job.create")
return (<div className={"inline"}> return (
Remote job <Value>{m.job_type}</Value> created for peer <Value>{m.for_peer_name}</Value> <div className={"inline"}>
</div> Remote job <Value>{m.job_type}</Value> created for peer{" "}
) <Value>{m.for_peer_name}</Value>
</div>
);
/**
* Flow Settings
*/
if (event.activity_code == "account.settings.extra.flow.group.remove") if (event.activity_code == "account.settings.extra.flow.group.remove")
return ( return (

View File

@@ -23,7 +23,7 @@ export const NetworkRoutingPeerCount = ({ network }: Props) => {
}, [network, routerCount]); }, [network, routerCount]);
const openNetworkPage = () => { const openNetworkPage = () => {
router.push(`/network?id=${network.id}#routing-peers`); router.push(`/network?id=${network.id}&tab=routing-peers`);
}; };
return ( return (

View File

@@ -1,111 +0,0 @@
import TruncatedText from "@components/ui/TruncatedText";
import { getOperatingSystem } from "@hooks/useOperatingSystem";
import { cn } from "@utils/helpers";
import { GlobeIcon, NetworkIcon, WorkflowIcon } from "lucide-react";
import * as React from "react";
import RoundedFlag from "@/assets/countries/RoundedFlag";
import { NetworkResource } from "@/interfaces/Network";
import { OperatingSystem } from "@/interfaces/OperatingSystem";
import type { Peer } from "@/interfaces/Peer";
import { OSLogo } from "@/modules/peers/PeerOSCell";
type DeviceCardProps = {
device?: Peer;
resource?: NetworkResource;
className?: string;
};
export const DeviceCard = ({
device,
resource,
className,
}: DeviceCardProps) => {
if (!device && !resource) return;
return (
<div
className={cn(
"flex shrink-0 items-center gap-2.5 text-nb-gray-300 text-left py-1 pl-3 pr-4 rounded-md group/machine my-0 w-[200px]",
className,
)}
>
<div
className={cn(
"flex items-center justify-center rounded-md h-9 w-9 shrink-0 bg-nb-gray-850 transition-all",
"group-hover:bg-nb-gray-800 relative",
)}
>
{device && <PeerOSIcon os={device.os} />}
{resource?.type && <ResourceIcon type={resource.type} />}
{device?.country_code && (
<div className={"absolute -bottom-[4px] -right-[4px]"}>
<div
className={cn(
"flex items-center justify-center rounded-full border-[3px] shrink-0",
"border-nb-gray-940",
)}
>
<RoundedFlag country={device?.country_code} size={10} />
</div>
</div>
)}
</div>
<div className={"flex flex-col gap-0 justify-center mt-2 leading-tight"}>
<span
className={
"mb-1.5 font-normal text-[0.85rem] text-nb-gray-100 flex items-center gap-2"
}
>
<TruncatedText
text={device?.name || resource?.name || "Unknown"}
maxWidth={"150px"}
hideTooltip={true}
/>
</span>
<span
className={
"text-sm font-normal text-nb-gray-400 -top-[0.3rem] relative"
}
>
{device?.ip || resource?.address}
</span>
</div>
</div>
);
};
const PeerOSIcon = ({ os }: { os: string }) => {
const osType = getOperatingSystem(os);
return (
<div
className={cn(
"flex items-center justify-center grayscale brightness-[100%] contrast-[40%]",
"w-4 h-4 shrink-0",
osType === OperatingSystem.WINDOWS && "p-[2.5px]",
osType === OperatingSystem.APPLE && "p-[2.7px]",
osType === OperatingSystem.FREEBSD && "p-[1.5px]",
)}
>
<OSLogo os={os} />
</div>
);
};
const ResourceIcon = ({
type,
size = 15,
}: {
type: "domain" | "host" | "subnet";
size?: number;
}) => {
switch (type) {
case "domain":
return <GlobeIcon size={size} />;
case "subnet":
return <NetworkIcon size={size} />;
case "host":
return <WorkflowIcon size={size} />;
default:
return <WorkflowIcon size={size} />;
}
};

View File

@@ -5,7 +5,7 @@ import { NetworkIcon } from "lucide-react";
import * as React from "react"; import * as React from "react";
import CircleIcon from "@/assets/icons/CircleIcon"; import CircleIcon from "@/assets/icons/CircleIcon";
import { Network, NetworkResource } from "@/interfaces/Network"; import { Network, NetworkResource } from "@/interfaces/Network";
import { DeviceCard } from "@/modules/control-center/nodes/DeviceCard"; import { DeviceCard } from "@components/DeviceCard";
type NetworkNodeType = { type NetworkNodeType = {
network: Network; network: Network;

View File

@@ -2,7 +2,7 @@ import { cn } from "@utils/helpers";
import { Handle, type Node, Position } from "@xyflow/react"; import { Handle, type Node, Position } from "@xyflow/react";
import * as React from "react"; import * as React from "react";
import type { Peer } from "@/interfaces/Peer"; import type { Peer } from "@/interfaces/Peer";
import { DeviceCard } from "@/modules/control-center/nodes/DeviceCard"; import { DeviceCard } from "@components/DeviceCard";
import { useAnySourceGroupEnabled } from "@/modules/control-center/utils/helpers"; import { useAnySourceGroupEnabled } from "@/modules/control-center/utils/helpers";
type PeerNodeProps = Node< type PeerNodeProps = Node<

View File

@@ -3,7 +3,7 @@ import { Handle, type Node, Position } from "@xyflow/react";
import * as React from "react"; import * as React from "react";
import { NetworkResource } from "@/interfaces/Network"; import { NetworkResource } from "@/interfaces/Network";
import { Peer } from "@/interfaces/Peer"; import { Peer } from "@/interfaces/Peer";
import { DeviceCard } from "@/modules/control-center/nodes/DeviceCard"; import { DeviceCard } from "@components/DeviceCard";
import { useAnySourceGroupEnabled } from "@/modules/control-center/utils/helpers"; import { useAnySourceGroupEnabled } from "@/modules/control-center/utils/helpers";
type ResourceNode = Node< type ResourceNode = Node<

View File

@@ -10,7 +10,7 @@ import { ChevronsUpDown } from "lucide-react";
import * as React from "react"; import * as React from "react";
import { OperatingSystem } from "@/interfaces/OperatingSystem"; import { OperatingSystem } from "@/interfaces/OperatingSystem";
import type { Peer } from "@/interfaces/Peer"; import type { Peer } from "@/interfaces/Peer";
import { DeviceCard } from "@/modules/control-center/nodes/DeviceCard"; import { DeviceCard } from "@components/DeviceCard";
import { OSLogo } from "@/modules/peers/PeerOSCell"; import { OSLogo } from "@/modules/peers/PeerOSCell";
type PeerNodeProps = Node< type PeerNodeProps = Node<

View File

@@ -12,7 +12,7 @@ import NetworkModal from "@/modules/networks/NetworkModal";
import NetworkResourceModal from "@/modules/networks/resources/NetworkResourceModal"; import NetworkResourceModal from "@/modules/networks/resources/NetworkResourceModal";
import { ResourceGroupModal } from "@/modules/networks/resources/ResourceGroupModal"; import { ResourceGroupModal } from "@/modules/networks/resources/ResourceGroupModal";
import NetworkRoutingPeerModal from "@/modules/networks/routing-peers/NetworkRoutingPeerModal"; import NetworkRoutingPeerModal from "@/modules/networks/routing-peers/NetworkRoutingPeerModal";
import { Policy } from "@/interfaces/Policy"; import { Policy, PolicyRuleResource } from "@/interfaces/Policy";
import PoliciesProvider from "@/contexts/PoliciesProvider"; import PoliciesProvider from "@/contexts/PoliciesProvider";
type Props = { type Props = {
@@ -59,6 +59,7 @@ export const NetworkProvider = ({
name?: string; name?: string;
description?: string; description?: string;
destinationGroups?: Group[] | string[]; destinationGroups?: Group[] | string[];
destinationResource?: PolicyRuleResource;
}>(); }>();
const [currentPolicy, setCurrentPolicy] = useState<Policy>(); const [currentPolicy, setCurrentPolicy] = useState<Policy>();
@@ -103,8 +104,17 @@ export const NetworkProvider = ({
}; };
const openPolicyModal = (network?: Network, resource?: NetworkResource) => { const openPolicyModal = (network?: Network, resource?: NetworkResource) => {
const hasResourceGroups = (resource?.groups?.length || 0) > 0;
setPolicyDefaultSettings({ setPolicyDefaultSettings({
destinationGroups: resource?.groups, destinationGroups: hasResourceGroups ? resource?.groups : undefined,
destinationResource: hasResourceGroups
? undefined
: resource
? ({
id: resource.id,
type: resource.type,
} as PolicyRuleResource)
: undefined,
name: name:
network && !resource network && !resource
? `${network?.name} Policy` ? `${network?.name} Policy`
@@ -178,6 +188,7 @@ export const NetworkProvider = ({
() => { () => {
onResourceDelete?.(); onResourceDelete?.();
mutate(`/networks/${network.id}/resources`); mutate(`/networks/${network.id}/resources`);
mutate(`/networks/${network.id}`);
mutate("/groups"); mutate("/groups");
}, },
), ),
@@ -289,6 +300,9 @@ export const NetworkProvider = ({
<AccessControlModalContent <AccessControlModalContent
key={policyModal ? "1" : "0"} key={policyModal ? "1" : "0"}
initialDestinationGroups={policyDefaultSettings?.destinationGroups} initialDestinationGroups={policyDefaultSettings?.destinationGroups}
initialDestinationResource={
policyDefaultSettings?.destinationResource
}
initialName={policyDefaultSettings?.name} initialName={policyDefaultSettings?.name}
initialDescription={policyDefaultSettings?.description} initialDescription={policyDefaultSettings?.description}
policy={currentPolicy} policy={currentPolicy}

View File

@@ -22,7 +22,7 @@ export const NetworkInformationSquare = ({
className={cn( className={cn(
"flex w-full items-center max-w-[450px] gap-4 dark:text-neutral-300 text-neutral-500 transition-all group/network rounded-md", "flex w-full items-center max-w-[450px] gap-4 dark:text-neutral-300 text-neutral-500 transition-all group/network rounded-md",
onClick onClick
? "hover:text-neutral-100 hover:bg-nb-gray-910 cursor-pointer py-2 pl-3 pr-14 relative" ? "hover:text-neutral-100 hover:bg-nb-gray-900/60 cursor-pointer py-2 pl-3 pr-14 relative"
: "cursor-default", : "cursor-default",
)} )}
onClick={onClick} onClick={onClick}

View File

@@ -17,7 +17,6 @@ import { notify } from "@components/Notification";
import Paragraph from "@components/Paragraph"; import Paragraph from "@components/Paragraph";
import { PeerGroupSelector } from "@components/PeerGroupSelector"; import { PeerGroupSelector } from "@components/PeerGroupSelector";
import Separator from "@components/Separator"; import Separator from "@components/Separator";
import { Textarea } from "@components/Textarea";
import { useApiCall } from "@utils/api"; import { useApiCall } from "@utils/api";
import { import {
ExternalLinkIcon, ExternalLinkIcon,
@@ -100,7 +99,7 @@ export function ResourceModalContent({
name, name,
description, description,
address, address,
groups: savedGroups.map((g) => g.id), groups: savedGroups ? savedGroups.map((g) => g.id) : undefined,
enabled, enabled,
}).then((r) => { }).then((r) => {
onCreated?.(r); onCreated?.(r);
@@ -118,7 +117,7 @@ export function ResourceModalContent({
name, name,
description, description,
address, address,
groups: savedGroups.map((g) => g.id), groups: savedGroups ? savedGroups.map((g) => g.id) : undefined,
enabled, enabled,
}).then((r) => { }).then((r) => {
onUpdated?.(r); onUpdated?.(r);
@@ -128,7 +127,7 @@ export function ResourceModalContent({
// TODO: Address validation is missing for proper handling of submit button // TODO: Address validation is missing for proper handling of submit button
const canCreate = useMemo(() => { const canCreate = useMemo(() => {
return name.length > 0 && address.length > 0 && groups.length > 0; return name.length > 0 && address.length > 0;
}, [name, address, groups]); }, [name, address, groups]);
return ( return (
@@ -162,10 +161,9 @@ export function ResourceModalContent({
<HelpText> <HelpText>
Write a short description to add more context to this resource. Write a short description to add more context to this resource.
</HelpText> </HelpText>
<Textarea <Input
placeholder={"e.g., Production, Development"} placeholder={"e.g., Production, Development"}
value={description} value={description}
rows={1}
onChange={(e) => setDescription(e.target.value)} onChange={(e) => setDescription(e.target.value)}
/> />
</div> </div>
@@ -173,14 +171,14 @@ export function ResourceModalContent({
<ResourceSingleAddressInput value={address} onChange={setAddress} /> <ResourceSingleAddressInput value={address} onChange={setAddress} />
<div> <div>
<Label>Destination Groups</Label> <Label>Destination Groups (optional)</Label>
<HelpText> <HelpText>
Add this resource to groups and use them as destinations when Add this resource to groups and use them as destinations when
creating policies creating policies
</HelpText> </HelpText>
<PeerGroupSelector onChange={setGroups} values={groups} /> <PeerGroupSelector onChange={setGroups} values={groups} />
</div> </div>
<div className={"mt-3"}> <div className={"mt-2 mb-2"}>
<FancyToggleSwitch <FancyToggleSwitch
value={enabled} value={enabled}
onChange={setEnabled} onChange={setEnabled}

View File

@@ -0,0 +1,76 @@
import Button from "@components/Button";
import { useRouter } from "next/navigation";
import { useMemo } from "react";
import { usePermissions } from "@/contexts/PermissionsProvider";
import { useReverseProxies } from "@/contexts/ReverseProxiesProvider";
import { NetworkResource } from "@/interfaces/Network";
import { isResourceTargetType } from "@/contexts/ReverseProxiesProvider";
import { useNetworksContext } from "@/modules/networks/NetworkProvider";
import ReverseProxyIcon from "@/assets/icons/ReverseProxyIcon";
import Badge from "@components/Badge";
import { CirclePlusIcon } from "lucide-react";
type Props = {
resource: NetworkResource;
};
export const ResourceExposeServiceCell = ({ resource }: Props) => {
const { permission } = usePermissions();
const { openModal, reverseProxies } = useReverseProxies();
const { network } = useNetworksContext();
const router = useRouter();
const servicesCount = useMemo(() => {
if (!reverseProxies) return 0;
return reverseProxies.filter((proxy) =>
proxy.targets.some(
(target) =>
isResourceTargetType(target.target_type) &&
target.target_id === resource.id,
),
).length;
}, [reverseProxies, resource.id]);
return (
<div className={"flex items-center gap-3"}>
{servicesCount > 0 && (
<Badge
variant={"gray"}
useHover={false}
className={"select-none hover:bg-nb-gray-910 cursor-pointer"}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
if (!network?.id) return;
router.push(`/network?id=${network.id}&tab=services&target=${resource.id}`);
}}
>
<ReverseProxyIcon size={14} className={"fill-green-500"} />
<div>
<span className={"font-medium text-xs"}>{servicesCount}</span>
</div>
</Badge>
)}
<Button
variant={"secondary"}
size={"xs"}
className={"!px-3"}
onClick={(e) => {
e.stopPropagation();
openModal({
initialResource: resource,
initialNetwork: network,
onSuccess: () => {
if (!network?.id) return;
router.push(`/network?id=${network.id}&tab=services`);
},
});
}}
disabled={!permission.services?.create}
>
<CirclePlusIcon size={12} />
Expose Service
</Button>
</div>
);
};

View File

@@ -12,7 +12,6 @@ import Separator from "@components/Separator";
import { useApiCall } from "@utils/api"; import { useApiCall } from "@utils/api";
import { FolderGit2 } from "lucide-react"; import { FolderGit2 } from "lucide-react";
import * as React from "react"; import * as React from "react";
import { useMemo } from "react";
import { Network, NetworkResource } from "@/interfaces/Network"; import { Network, NetworkResource } from "@/interfaces/Network";
import useGroupHelper from "@/modules/groups/useGroupHelper"; import useGroupHelper from "@/modules/groups/useGroupHelper";
@@ -78,10 +77,6 @@ const ResourceGroupModalContent = ({
}); });
}; };
const canSave = useMemo(() => {
return groups.length > 0;
}, [groups]);
return ( return (
<ModalContent maxWidthClass={"max-w-xl"}> <ModalContent maxWidthClass={"max-w-xl"}>
<ModalHeader <ModalHeader
@@ -107,11 +102,7 @@ const ResourceGroupModalContent = ({
<Button variant={"secondary"}>Cancel</Button> <Button variant={"secondary"}>Cancel</Button>
</ModalClose> </ModalClose>
<Button <Button variant={"primary"} onClick={updateResource}>
variant={"primary"}
onClick={updateResource}
disabled={!canSave}
>
Save Groups Save Groups
</Button> </Button>
</div> </div>

View File

@@ -139,7 +139,6 @@ export const ResourcePolicyCell = ({ resource }: Props) => {
<Button <Button
size={"xs"} size={"xs"}
variant={"secondary"} variant={"secondary"}
className={"min-w-[100px]"}
disabled={!permission.networks.update} disabled={!permission.networks.update}
onClick={() => openPolicyModal(network, resource)} onClick={() => openPolicyModal(network, resource)}
> >

View File

@@ -1,52 +0,0 @@
import Paragraph from "@components/Paragraph";
import SkeletonTable, {
SkeletonTableHeader,
} from "@components/skeletons/SkeletonTable";
import { usePortalElement } from "@hooks/usePortalElement";
import useFetchApi from "@utils/api";
import * as React from "react";
import { Suspense } from "react";
import { Network, NetworkResource } from "@/interfaces/Network";
import ResourcesTable from "@/modules/networks/resources/ResourcesTable";
type ResourcesSectionProps = {
network: Network;
};
export const ResourcesSection = ({ network }: ResourcesSectionProps) => {
const { data: resources, isLoading } = useFetchApi<NetworkResource[]>(
`/networks/${network.id}/resources`,
);
const { ref: headingRef, portalTarget } =
usePortalElement<HTMLHeadingElement>();
return (
<div className={"py-7 px-8"}>
<div className={"max-w-6xl"}>
<div className={"flex justify-between items-center mb-6"}>
<div>
<h2 ref={headingRef}>Resources</h2>
<Paragraph>Add and manage resources for this network.</Paragraph>
</div>
</div>
<Suspense
fallback={
<div>
<SkeletonTableHeader className={"!p-0"} />
<div className={"mt-8 w-full"}>
<SkeletonTable withHeader={false} />
</div>
</div>
}
>
<ResourcesTable
isLoading={isLoading}
headingTarget={portalTarget}
resources={resources}
/>
</Suspense>
</div>
</div>
);
};

View File

@@ -0,0 +1,55 @@
import SkeletonTable, {
SkeletonTableHeader,
} from "@components/skeletons/SkeletonTable";
import * as React from "react";
import { Suspense } from "react";
import { NetworkResource } from "@/interfaces/Network";
import ResourcesTable from "@/modules/networks/resources/ResourcesTable";
import Paragraph from "@components/Paragraph";
import InlineLink from "@components/InlineLink";
import { ExternalLinkIcon } from "lucide-react";
type ResourcesSectionProps = {
data?: NetworkResource[];
isLoading: boolean;
};
export const ResourcesTabContent = ({
data,
isLoading,
}: ResourcesSectionProps) => {
return (
<div className={"px-8"}>
<div className={"flex justify-between items-center mb-5"}>
<div>
<Paragraph>
Add resources to this network to control what peers can access.
</Paragraph>
<Paragraph>
Learn more about{" "}
<InlineLink
href={"https://docs.netbird.io/how-to/networks#resources"}
target={"_blank"}
>
Resources
<ExternalLinkIcon size={12} />
</InlineLink>
in our documentation.
</Paragraph>
</div>
</div>
<Suspense
fallback={
<div>
<SkeletonTableHeader className={"!p-0"} />
<div className={"mt-8 w-full"}>
<SkeletonTable withHeader={false} />
</div>
</div>
}
>
<ResourcesTable isLoading={isLoading} resources={data} />
</Suspense>
</div>
);
};

View File

@@ -16,6 +16,7 @@ import { Group } from "@/interfaces/Group";
import { NetworkResource } from "@/interfaces/Network"; import { NetworkResource } from "@/interfaces/Network";
import { useNetworksContext } from "@/modules/networks/NetworkProvider"; import { useNetworksContext } from "@/modules/networks/NetworkProvider";
import { ResourceActionCell } from "@/modules/networks/resources/ResourceActionCell"; import { ResourceActionCell } from "@/modules/networks/resources/ResourceActionCell";
import { ResourceExposeServiceCell } from "@/modules/networks/resources/ResourceExposeServiceCell";
import ResourceAddressCell from "@/modules/networks/resources/ResourceAddressCell"; import ResourceAddressCell from "@/modules/networks/resources/ResourceAddressCell";
import { ResourceEnabledCell } from "@/modules/networks/resources/ResourceEnabledCell"; import { ResourceEnabledCell } from "@/modules/networks/resources/ResourceEnabledCell";
import { ResourceGroupCell } from "@/modules/networks/resources/ResourceGroupCell"; import { ResourceGroupCell } from "@/modules/networks/resources/ResourceGroupCell";
@@ -72,7 +73,7 @@ const NetworkResourceColumns: ColumnDef<NetworkResource>[] = [
{ {
id: "groups", id: "groups",
accessorFn: (resource) => { accessorFn: (resource) => {
let groups = resource?.groups as Group[]; let groups = (resource?.groups ?? []) as Group[];
return groups.map((group) => group.name).join(", "); return groups.map((group) => group.name).join(", ");
}, },
header: ({ column }) => { header: ({ column }) => {
@@ -92,6 +93,14 @@ const NetworkResourceColumns: ColumnDef<NetworkResource>[] = [
return <ResourcePolicyCell resource={row.original} />; return <ResourcePolicyCell resource={row.original} />;
}, },
}, },
{
id: "expose_service",
accessorKey: "id",
header: "",
cell: ({ row }) => {
return <ResourceExposeServiceCell resource={row.original} />;
},
},
{ {
id: "actions", id: "actions",
accessorKey: "id", accessorKey: "id",
@@ -116,6 +125,13 @@ export default function ResourcesTable({
const { openResourceModal, network } = useNetworksContext(); const { openResourceModal, network } = useNetworksContext();
const router = useRouter(); const router = useRouter();
const removeResourceParam = React.useCallback(() => {
if (!resourceId) return;
const newParams = new URLSearchParams(params.toString());
newParams.delete("resource");
router.replace(`?${newParams.toString()}`, { scroll: false });
}, [resourceId, params, router]);
return ( return (
<DataTable <DataTable
wrapperComponent={Card} wrapperComponent={Card}
@@ -134,6 +150,7 @@ export default function ResourcesTable({
resourceId ? [{ id: "id", value: resourceId }] : undefined resourceId ? [{ id: "id", value: resourceId }] : undefined
} }
initialSearch={resourceId} initialSearch={resourceId}
onFilterReset={removeResourceParam}
data={resources} data={resources}
searchPlaceholder={"Search by name, address or group..."} searchPlaceholder={"Search by name, address or group..."}
isLoading={isLoading} isLoading={isLoading}
@@ -150,7 +167,7 @@ export default function ResourcesTable({
? "Assign this group to your resources inside your networks to see them listed here." ? "Assign this group to your resources inside your networks to see them listed here."
: "Add resources to this network to control what peers can access. Resources can be anything from a single IP, a subnet, or a domain." : "Add resources to this network to control what peers can access. Resources can be anything from a single IP, a subnet, or a domain."
} }
icon={<Layers3Icon size={20} />} icon={<Layers3Icon size={20} className={"text-nb-gray-400"} />}
> >
{isGroupPage && permission?.networks?.create && ( {isGroupPage && permission?.networks?.create && (
<> <>

View File

@@ -1,73 +0,0 @@
import Button from "@components/Button";
import Paragraph from "@components/Paragraph";
import SkeletonTable, {
SkeletonTableHeader,
} from "@components/skeletons/SkeletonTable";
import { usePortalElement } from "@hooks/usePortalElement";
import { IconCirclePlus } from "@tabler/icons-react";
import useFetchApi from "@utils/api";
import * as React from "react";
import { Suspense } from "react";
import { usePermissions } from "@/contexts/PermissionsProvider";
import { Network, NetworkRouter } from "@/interfaces/Network";
import { useNetworksContext } from "@/modules/networks/NetworkProvider";
import NetworkRoutingPeersTable from "@/modules/networks/routing-peers/NetworkRoutingPeersTable";
export const NetworkRoutingPeersSection = ({
network,
}: {
network: Network;
}) => {
const { permission } = usePermissions();
const { data: routers, isLoading } = useFetchApi<NetworkRouter[]>(
`/networks/${network.id}/routers`,
);
const { ref: headingRef, portalTarget } =
usePortalElement<HTMLHeadingElement>();
const { openAddRoutingPeerModal } = useNetworksContext();
return (
<div className={"py-7 px-8"} id={"routing-peers"}>
<div className={"max-w-6xl"}>
<div className={"flex justify-between items-center"}>
<div>
<h2 ref={headingRef}>Routing Peers</h2>
<Paragraph>
Add and manage routing peers for this network.
</Paragraph>
</div>
<div className={"inline-flex gap-4 justify-end"}>
<div>
<Button
variant={"primary"}
onClick={() => openAddRoutingPeerModal(network)}
disabled={!permission.networks.update}
>
<IconCirclePlus size={16} />
Add Routing Peer
</Button>
</div>
</div>
</div>
<Suspense
fallback={
<div>
<SkeletonTableHeader className={"!p-0"} />
<div className={"mt-8 w-full"}>
<SkeletonTable withHeader={false} />
</div>
</div>
}
>
<NetworkRoutingPeersTable
isLoading={isLoading}
routers={routers}
headingTarget={portalTarget}
/>
</Suspense>
</div>
</div>
);
};

View File

@@ -0,0 +1,77 @@
import SkeletonTable, {
SkeletonTableHeader,
} from "@components/skeletons/SkeletonTable";
import * as React from "react";
import { Suspense, useMemo } from "react";
import { NetworkRouter } from "@/interfaces/Network";
import NetworkRoutingPeersTable from "@/modules/networks/routing-peers/NetworkRoutingPeersTable";
import useFetchApi from "@utils/api";
import { useGroups } from "@/contexts/GroupsProvider";
import { Peer } from "@/interfaces/Peer";
import { useUsers } from "@/contexts/UsersProvider";
import Paragraph from "@components/Paragraph";
import InlineLink from "@components/InlineLink";
import { ExternalLinkIcon } from "lucide-react";
export const NetworkRoutingPeersTabContent = ({
routers,
isLoading,
}: {
routers?: NetworkRouter[];
isLoading: boolean;
}) => {
const { groups } = useGroups();
const { users } = useUsers();
const { data: peers } = useFetchApi<Peer[]>(`/peers`);
const data = useMemo(() => {
return routers?.map((router) => {
const peer = peers?.find((peer) => peer.id === router.peer);
const user = peer ? users?.find((user) => user.id === peer.user_id) : undefined;
const group = groups?.find(
(group) => group.id === router?.peer_groups?.[0],
);
return {
...router,
search: `${peer?.name ?? ""} ${peer?.ip ?? ""} ${user?.name ?? ""} ${user?.id ?? ""} ${group?.name ?? ""}`,
};
});
}, [users, peers, routers, groups]);
return (
<div className={"px-8"} id={"routing-peers"}>
<div className={"flex justify-between items-center mb-5"}>
<div>
<Paragraph>
Add routing peers to this network to access resources inside this
network.
</Paragraph>
<Paragraph>
Learn more about
<InlineLink
href={"https://docs.netbird.io/manage/networks#routing-peers"}
target={"_blank"}
>
Routing Peers
<ExternalLinkIcon size={12} />
</InlineLink>
in our documentation.
</Paragraph>
</div>
</div>
<Suspense
fallback={
<div>
<SkeletonTableHeader className={"!p-0"} />
<div className={"mt-8 w-full"}>
<SkeletonTable withHeader={false} />
</div>
</div>
}
>
<NetworkRoutingPeersTable isLoading={isLoading} routers={data} />
</Suspense>
</div>
);
};

View File

@@ -68,6 +68,12 @@ const NetworkRouterColumns: ColumnDef<NetworkRouter>[] = [
return <RoutingPeersActionCell router={row.original} />; return <RoutingPeersActionCell router={row.original} />;
}, },
}, },
{
id: "search",
accessorKey: "search",
header: "",
filterFn: "fuzzy",
},
]; ];
export default function NetworkRoutingPeersTable({ export default function NetworkRoutingPeersTable({
@@ -93,14 +99,14 @@ export default function NetworkRoutingPeersTable({
sorting={sorting} sorting={sorting}
setSorting={setSorting} setSorting={setSorting}
minimal={true} minimal={true}
showSearchAndFilters={false} showSearchAndFilters={true}
inset={false} inset={false}
tableClassName={"mt-0"} tableClassName={"mt-0"}
text={"Routing Peers"} text={"Routing Peers"}
columns={NetworkRouterColumns} columns={NetworkRouterColumns}
keepStateInLocalStorage={false} keepStateInLocalStorage={false}
data={routers} data={routers}
searchPlaceholder={"Search by name..."} searchPlaceholder={"Search by peer name, group name..."}
isLoading={isLoading} isLoading={isLoading}
getStartedCard={ getStartedCard={
<NoResults <NoResults
@@ -109,10 +115,10 @@ export default function NetworkRoutingPeersTable({
description={ description={
"Add routing peers to this network to access resources inside this network." "Add routing peers to this network to access resources inside this network."
} }
icon={<PeerIcon size={20} className={"fill-nb-gray-300"} />} icon={<PeerIcon size={18} className={"fill-nb-gray-400"} />}
/> />
} }
columnVisibility={{}} columnVisibility={{ search: false }}
paginationPaddingClassName={"px-0 pt-8"} paginationPaddingClassName={"px-0 pt-8"}
rightSide={() => ( rightSide={() => (
<Button <Button

View File

@@ -77,7 +77,7 @@ export default function NetworkRoutingPeerCell({ network }: Props) {
"inline-flex gap-2 min-w-[110px] font-medium items-center justify-center min-h-[34px] cursor-pointer", "inline-flex gap-2 min-w-[110px] font-medium items-center justify-center min-h-[34px] cursor-pointer",
)} )}
onClick={() => onClick={() =>
router.push(`/network?id=${network.id}#routing-peers`) router.push(`/network?id=${network.id}&tab=routing-peers`)
} }
useHover={true} useHover={true}
> >

View File

@@ -3,7 +3,8 @@ import { Modal, ModalPortal } from "@components/modal/Modal";
import { NetBirdLogo } from "@components/NetBirdLogo"; import { NetBirdLogo } from "@components/NetBirdLogo";
import { notify } from "@components/Notification"; import { notify } from "@components/Notification";
import { GradientFadedBackground } from "@components/ui/GradientFadedBackground"; import { GradientFadedBackground } from "@components/ui/GradientFadedBackground";
import { DialogContent } from "@radix-ui/react-dialog"; import { DialogContent, DialogTitle } from "@radix-ui/react-dialog";
import { VisuallyHidden } from "@radix-ui/react-visually-hidden";
import useFetchApi, { useApiCall } from "@utils/api"; import useFetchApi, { useApiCall } from "@utils/api";
import { cn } from "@utils/helpers"; import { cn } from "@utils/helpers";
import { isNetBirdHosted } from "@utils/netbird"; import { isNetBirdHosted } from "@utils/netbird";
@@ -304,6 +305,9 @@ export const Onboarding = ({
} }
> >
<div> <div>
<VisuallyHidden asChild>
<DialogTitle>Onboarding</DialogTitle>
</VisuallyHidden>
<div <div
className={cn( className={cn(
"sm:px-4 py-10 max-w-6xl mx-auto flex flex-col items-center", "sm:px-4 py-10 max-w-6xl mx-auto flex flex-col items-center",

View File

@@ -1,20 +1,10 @@
import TruncatedText from "@components/ui/TruncatedText"; import { DeviceCard } from "@components/DeviceCard";
import { getOperatingSystem } from "@hooks/useOperatingSystem";
import { cn } from "@utils/helpers"; import { cn } from "@utils/helpers";
import { import { ShieldCheckIcon, ShieldXIcon } from "lucide-react";
GlobeIcon,
NetworkIcon,
ShieldCheckIcon,
ShieldXIcon,
WorkflowIcon,
} from "lucide-react";
import * as React from "react"; import * as React from "react";
import RoundedFlag from "@/assets/countries/RoundedFlag";
import { NetworkResource } from "@/interfaces/Network"; import { NetworkResource } from "@/interfaces/Network";
import { OperatingSystem } from "@/interfaces/OperatingSystem";
import type { Peer } from "@/interfaces/Peer"; import type { Peer } from "@/interfaces/Peer";
import { Intent } from "@/modules/onboarding/Onboarding"; import { Intent } from "@/modules/onboarding/Onboarding";
import { OSLogo } from "@/modules/peers/PeerOSCell";
type Props = { type Props = {
intent?: Intent; intent?: Intent;
@@ -169,101 +159,6 @@ export const WaitingForDevice = ({
); );
}; };
type DeviceCardProps = {
device?: Peer;
resource?: NetworkResource;
};
export const DeviceCard = ({ device, resource }: DeviceCardProps) => {
if (!device && !resource) return;
return (
<div
className={cn(
"flex shrink-0 items-center gap-2.5 text-nb-gray-300 text-left py-1 pl-3 pr-4 rounded-md group/machine my-0 w-[200px]",
)}
>
<div
className={cn(
"flex items-center justify-center rounded-md h-9 w-9 shrink-0 bg-nb-gray-900 transition-all",
"group-hover:bg-nb-gray-800 relative",
)}
>
{device && <PeerOSIcon os={device.os} />}
{resource?.type && <ResourceIcon type={resource.type} />}
{device?.country_code && (
<div className={"absolute -bottom-[4px] -right-[4px]"}>
<div
className={cn(
"flex items-center justify-center rounded-full border-[3px] shrink-0",
"border-nb-gray-940",
)}
>
<RoundedFlag country={device?.country_code} size={10} />
</div>
</div>
)}
</div>
<div className={"flex flex-col gap-0 justify-center mt-2 leading-tight"}>
<span
className={
"mb-1.5 font-normal text-[0.85rem] text-nb-gray-100 flex items-center gap-2"
}
>
<TruncatedText
text={device?.name || resource?.name || "Unknown"}
maxWidth={"150px"}
hideTooltip={true}
/>
</span>
<span
className={
"text-sm font-normal text-nb-gray-400 -top-[0.3rem] relative"
}
>
{device?.ip || resource?.address}
</span>
</div>
</div>
);
};
const PeerOSIcon = ({ os }: { os: string }) => {
const osType = getOperatingSystem(os);
return (
<div
className={cn(
"flex items-center justify-center grayscale brightness-[100%] contrast-[40%]",
"w-4 h-4 shrink-0",
osType === OperatingSystem.WINDOWS && "p-[2.5px]",
osType === OperatingSystem.APPLE && "p-[2.7px]",
osType === OperatingSystem.FREEBSD && "p-[1.5px]",
)}
>
<OSLogo os={os} />
</div>
);
};
const ResourceIcon = ({
type,
size = 15,
}: {
type: "domain" | "host" | "subnet";
size?: number;
}) => {
switch (type) {
case "domain":
return <GlobeIcon size={size} />;
case "subnet":
return <NetworkIcon size={size} />;
case "host":
return <WorkflowIcon size={size} />;
default:
return <WorkflowIcon size={size} />;
}
};
const Line = ({ const Line = ({
className, className,
height = "100%", height = "100%",

View File

@@ -2,7 +2,6 @@ import Paragraph from "@components/Paragraph";
import SkeletonTable, { import SkeletonTable, {
SkeletonTableHeader, SkeletonTableHeader,
} from "@components/skeletons/SkeletonTable"; } from "@components/skeletons/SkeletonTable";
import { usePortalElement } from "@hooks/usePortalElement";
import useFetchApi from "@utils/api"; import useFetchApi from "@utils/api";
import * as React from "react"; import * as React from "react";
import { lazy, Suspense } from "react"; import { lazy, Suspense } from "react";
@@ -21,8 +20,6 @@ export const AccessiblePeersSection = ({ peerID }: Props) => {
`/peers/${peerID}/accessible-peers`, `/peers/${peerID}/accessible-peers`,
); );
const { users } = useUsers(); const { users } = useUsers();
const { ref: headingRef, portalTarget } =
usePortalElement<HTMLHeadingElement>();
const peersWithUser = peers?.map((peer) => { const peersWithUser = peers?.map((peer) => {
if (!users) return peer; if (!users) return peer;
@@ -34,10 +31,9 @@ export const AccessiblePeersSection = ({ peerID }: Props) => {
return ( return (
<div className={"pb-10 px-8"}> <div className={"pb-10 px-8"}>
<div className={"max-w-6xl"}> <div className={""}>
<div className={"flex justify-between items-center mb-5"}> <div className={"flex justify-between items-center mb-5"}>
<div> <div>
<h2 ref={headingRef}>Accessible Peers</h2>
<Paragraph> <Paragraph>
This peer can connect to the following peers within the NetBird This peer can connect to the following peers within the NetBird
network. network.
@@ -59,7 +55,6 @@ export const AccessiblePeersSection = ({ peerID }: Props) => {
peerID={peerID} peerID={peerID}
isLoading={isLoading} isLoading={isLoading}
peers={peersWithUser} peers={peersWithUser}
headingTarget={portalTarget}
/> />
</Suspense> </Suspense>
</div> </div>

View File

@@ -1,6 +1,5 @@
import Paragraph from "@components/Paragraph"; import Paragraph from "@components/Paragraph";
import SkeletonTable from "@components/skeletons/SkeletonTable"; import SkeletonTable from "@components/skeletons/SkeletonTable";
import { usePortalElement } from "@hooks/usePortalElement";
import * as React from "react"; import * as React from "react";
import { lazy, Suspense } from "react"; import { lazy, Suspense } from "react";
import type { Peer } from "@/interfaces/Peer"; import type { Peer } from "@/interfaces/Peer";
@@ -8,6 +7,8 @@ import { AddExitNodeButton } from "@/modules/exit-node/AddExitNodeButton";
import { useHasExitNodes } from "@/modules/exit-node/useHasExitNodes"; import { useHasExitNodes } from "@/modules/exit-node/useHasExitNodes";
import AddRouteDropdownButton from "@/modules/peer/AddRouteDropdownButton"; import AddRouteDropdownButton from "@/modules/peer/AddRouteDropdownButton";
import usePeerRoutes from "@/modules/peer/usePeerRoutes"; import usePeerRoutes from "@/modules/peer/usePeerRoutes";
import InlineLink from "@components/InlineLink";
import { ExternalLinkIcon } from "lucide-react";
const PeerRoutesTable = lazy(() => import("@/modules/peer/PeerRoutesTable")); const PeerRoutesTable = lazy(() => import("@/modules/peer/PeerRoutesTable"));
@@ -18,23 +19,36 @@ type Props = {
export const PeerNetworkRoutesSection = ({ peer }: Props) => { export const PeerNetworkRoutesSection = ({ peer }: Props) => {
const { peerRoutes, isLoading } = usePeerRoutes({ peer }); const { peerRoutes, isLoading } = usePeerRoutes({ peer });
const exitNodeInfo = useHasExitNodes(peer); const exitNodeInfo = useHasExitNodes(peer);
const { ref: headingRef, portalTarget } =
usePortalElement<HTMLHeadingElement>();
return ( return (
<div className={"pb-10 px-8"}> <div className={"pb-10 px-8"}>
<div className={"max-w-6xl"}> <div className={""}>
<div className={"flex justify-between items-center mb-5"}> <div className={"flex justify-between items-center mb-5"}>
<div> <div>
<h2 ref={headingRef}>Network Routes</h2>
<Paragraph> <Paragraph>
Access other networks without installing NetBird on every Access other networks without installing NetBird on every
resource. resource.
</Paragraph> </Paragraph>
<Paragraph>
Learn more about
<InlineLink
href={
"https://docs.netbird.io/how-to/routing-traffic-to-private-networks"
}
target={"_blank"}
>
Network Routes
<ExternalLinkIcon size={12} />
</InlineLink>
in our documentation.
</Paragraph>
</div> </div>
<div className={"inline-flex gap-4 justify-end"}> <div className={"inline-flex gap-4 justify-end"}>
<div className={"gap-4 flex"}> <div className={"gap-4 flex"}>
<AddExitNodeButton peer={peer} firstTime={!exitNodeInfo.hasExitNode} /> <AddExitNodeButton
peer={peer}
firstTime={!exitNodeInfo.hasExitNode}
/>
<AddRouteDropdownButton /> <AddRouteDropdownButton />
</div> </div>
</div> </div>
@@ -53,7 +67,6 @@ export const PeerNetworkRoutesSection = ({ peer }: Props) => {
peer={peer} peer={peer}
isLoading={isLoading} isLoading={isLoading}
peerRoutes={peerRoutes} peerRoutes={peerRoutes}
headingTarget={portalTarget}
/> />
</Suspense> </Suspense>
</div> </div>

View File

@@ -5,7 +5,6 @@ import Paragraph from "@/components/Paragraph";
import SkeletonTable, { import SkeletonTable, {
SkeletonTableHeader, SkeletonTableHeader,
} from "@/components/skeletons/SkeletonTable"; } from "@/components/skeletons/SkeletonTable";
import { usePortalElement } from "@/hooks/usePortalElement";
import { Job } from "@/interfaces/Job"; import { Job } from "@/interfaces/Job";
import useFetchApi from "@/utils/api"; import useFetchApi from "@/utils/api";
@@ -18,15 +17,12 @@ type Props = {
export const PeerRemoteJobsSection = ({ peerID }: Props) => { export const PeerRemoteJobsSection = ({ peerID }: Props) => {
const { data: jobs, isLoading } = useFetchApi<Job[]>(`/peers/${peerID}/jobs`); const { data: jobs, isLoading } = useFetchApi<Job[]>(`/peers/${peerID}/jobs`);
const { ref: headingRef, portalTarget } =
usePortalElement<HTMLHeadingElement>();
return ( return (
<div className="pb-10 px-8"> <div className="pb-10 px-8">
<div className="max-w-6xl"> <div className="">
<div className="flex justify-between items-center mb-5"> <div className="flex justify-between items-center mb-5">
<div> <div>
<h2 ref={headingRef}>Remote Jobs</h2>
<Paragraph> <Paragraph>
Remotely trigger actions such as debug bundles or other tasks on Remotely trigger actions such as debug bundles or other tasks on
this peer, without requiring CLI access. this peer, without requiring CLI access.
@@ -55,7 +51,6 @@ export const PeerRemoteJobsSection = ({ peerID }: Props) => {
peerID={peerID} peerID={peerID}
jobs={jobs} jobs={jobs}
isLoading={isLoading} isLoading={isLoading}
headingTarget={portalTarget}
/> />
</Suspense> </Suspense>
</div> </div>

View File

@@ -17,7 +17,6 @@ import RouteDistributionGroupsCell from "@/modules/routes/RouteDistributionGroup
type Props = { type Props = {
peerRoutes?: Route[]; peerRoutes?: Route[];
isLoading: boolean; isLoading: boolean;
headingTarget?: HTMLHeadingElement | null;
peer: Peer; peer: Peer;
}; };
@@ -72,7 +71,6 @@ export default function PeerRoutesTable({
peerRoutes, peerRoutes,
isLoading, isLoading,
peer, peer,
headingTarget,
}: Props) { }: Props) {
// Default sorting state of the table // Default sorting state of the table
const [sorting, setSorting] = useState<SortingState>([ const [sorting, setSorting] = useState<SortingState>([
@@ -89,7 +87,6 @@ export default function PeerRoutesTable({
wrapperProps={{ wrapperProps={{
className: cn("w-full"), className: cn("w-full"),
}} }}
headingTarget={headingTarget}
text={"Network Routes"} text={"Network Routes"}
tableClassName={"mt-0"} tableClassName={"mt-0"}
getStartedCard={ getStartedCard={

View File

@@ -32,7 +32,7 @@ export default function PeerNameCell({ peer, linkToPeer = true }: Props) {
className={cn( className={cn(
"flex items-center max-w-[280px] gap-2 dark:text-neutral-300 text-neutral-500 transition-all py-2 px-3 rounded-md ", "flex items-center max-w-[280px] gap-2 dark:text-neutral-300 text-neutral-500 transition-all py-2 px-3 rounded-md ",
linkToPeer && linkToPeer &&
"hover:text-neutral-100 hover:bg-nb-gray-800/60 cursor-pointer", "hover:text-neutral-100 hover:bg-nb-gray-900/60 cursor-pointer",
)} )}
data-testid="peer-name-cell" data-testid="peer-name-cell"
aria-label={`View details of peer ${peer.name}`} aria-label={`View details of peer ${peer.name}`}

View File

@@ -22,7 +22,12 @@ type Props = {
serial?: string; serial?: string;
ephemeral?: boolean; ephemeral?: boolean;
}; };
export default function PeerVersionCell({ version, os, serial, ephemeral }: Props) { export default function PeerVersionCell({
version,
os,
serial,
ephemeral,
}: Props) {
const { latestVersion, latestUrl } = useApplicationContext(); const { latestVersion, latestUrl } = useApplicationContext();
const updateAvailable = useMemo(() => { const updateAvailable = useMemo(() => {

View File

@@ -36,6 +36,7 @@ import PeerNameCell from "@/modules/peers/PeerNameCell";
import { PeerOSCell } from "@/modules/peers/PeerOSCell"; import { PeerOSCell } from "@/modules/peers/PeerOSCell";
import PeerStatusCell from "@/modules/peers/PeerStatusCell"; import PeerStatusCell from "@/modules/peers/PeerStatusCell";
import PeerVersionCell from "@/modules/peers/PeerVersionCell"; import PeerVersionCell from "@/modules/peers/PeerVersionCell";
import { removeAllSpaces } from "@utils/helpers";
const PeersTableColumns: ColumnDef<Peer>[] = [ const PeersTableColumns: ColumnDef<Peer>[] = [
{ {
@@ -144,7 +145,8 @@ const PeersTableColumns: ColumnDef<Peer>[] = [
cell: ({ row }) => <PeerLastSeenCell peer={row.original} />, cell: ({ row }) => <PeerLastSeenCell peer={row.original} />,
}, },
{ {
accessorKey: "os", id: "os",
accessorFn: (peer) => removeAllSpaces(peer?.os),
header: ({ column }) => { header: ({ column }) => {
return <DataTableHeader column={column}>OS</DataTableHeader>; return <DataTableHeader column={column}>OS</DataTableHeader>;
}, },

View File

@@ -118,7 +118,11 @@ export const usePostureCheck = ({ postureCheck, onSuccess }: Props = {}) => {
checkToUpdateOrCreate?: PostureCheck, checkToUpdateOrCreate?: PostureCheck,
) => { ) => {
const call = () => updateOrCreate(checkToUpdateOrCreate || state); const call = () => updateOrCreate(checkToUpdateOrCreate || state);
let response = undefined; const promise = call().then((check) => {
mutate("/posture-checks");
onSuccess && onSuccess(check);
return check;
});
notify({ notify({
title: `Posture Check ${state.name}`, title: `Posture Check ${state.name}`,
description: `Posture Check was ${ description: `Posture Check was ${
@@ -127,17 +131,9 @@ export const usePostureCheck = ({ postureCheck, onSuccess }: Props = {}) => {
loadingMessage: `${ loadingMessage: `${
postureCheck ? "Updating" : "Creating" postureCheck ? "Updating" : "Creating"
} your posture check...`, } your posture check...`,
promise: call().then((check) => { promise,
mutate("/posture-checks");
onSuccess && onSuccess(check);
response = check;
}),
}); });
if (response === undefined) { return promise;
return Promise.reject("Failed to create or update posture check");
} else {
return Promise.resolve(response);
}
}; };
return { state, dispatch, updateOrCreateAndNotify, updateOrCreate }; return { state, dispatch, updateOrCreateAndNotify, updateOrCreate };

View File

@@ -0,0 +1,838 @@
"use client";
import Button from "@components/Button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@components/DropdownMenu";
import FancyToggleSwitch from "@components/FancyToggleSwitch";
import HelpText from "@components/HelpText";
import InlineLink, { InlineButtonLink } from "@components/InlineLink";
import { Input } from "@components/Input";
import { Label } from "@components/Label";
import SettingCard from "@components/SettingCard";
import {
Modal,
ModalClose,
ModalContent,
ModalFooter,
} from "@components/modal/Modal";
import Paragraph from "@components/Paragraph";
import ModalHeader from "@components/modal/ModalHeader";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@components/Tabs";
import { ToggleSwitch } from "@components/ToggleSwitch";
import {
AlertTriangle,
ArrowRight,
ArrowUpRight,
Binary,
Edit,
ExternalLinkIcon,
GlobeIcon,
LockKeyhole,
MinusCircleIcon,
MoreVertical,
PlusCircle,
PlusIcon,
RectangleEllipsis,
Server,
Settings,
Text,
Users,
} from "lucide-react";
import { Callout } from "@components/Callout";
import { useRouter } from "next/navigation";
import React, { useMemo, useState } from "react";
import ReverseProxyIcon from "@/assets/icons/ReverseProxyIcon";
import { useDialog } from "@/contexts/DialogProvider";
import { usePermissions } from "@/contexts/PermissionsProvider";
import { Network, NetworkResource } from "@/interfaces/Network";
import { Peer } from "@/interfaces/Peer";
import {
REVERSE_PROXY_AUTHENTICATION_DOCS_LINK,
REVERSE_PROXY_SERVICES_DOCS_LINK,
REVERSE_PROXY_SETTINGS_DOCS_LINK,
ReverseProxy,
ReverseProxyAuth,
ReverseProxyDomain,
ReverseProxyDomainType,
ReverseProxyTarget,
} from "@/interfaces/ReverseProxy";
import { CustomDomainSelector } from "./domain/CustomDomainSelector";
import { cn } from "@utils/helpers";
import AuthPasswordModal from "@/modules/reverse-proxy/auth/AuthPasswordModal";
import AuthPinModal from "@/modules/reverse-proxy/auth/AuthPinModal";
import AuthSSOModal from "@/modules/reverse-proxy/auth/AuthSSOModal";
import ReverseProxyTargetModal from "@/modules/reverse-proxy/targets/ReverseProxyTargetModal";
import useGroupHelper from "@/modules/groups/useGroupHelper";
import { useReverseProxies } from "@/contexts/ReverseProxiesProvider";
type Props = {
open: boolean;
onOpenChange: (open: boolean) => void;
reverseProxy?: ReverseProxy;
domains?: ReverseProxyDomain[];
/** Pre-set the subdomain (e.g. from resource name) */
initialSubdomain?: string;
/** Pre-set a resource target - hides target selection in modal */
initialResource?: NetworkResource;
initialPeer?: Peer;
initialNetwork?: Network;
initialTab?: string;
onSuccess?: () => void;
};
// Helper to parse domain into subdomain and base domain
function parseDomain(fullDomain: string): {
subdomain: string;
baseDomain: string;
isCustom: boolean;
} {
const knownDomains = ["netbird.cloud", "netbird.io", "netbird.app"];
for (const known of knownDomains) {
if (fullDomain.endsWith(`.${known}`)) {
return {
subdomain: fullDomain.slice(0, -(known.length + 1)),
baseDomain: known,
isCustom: false,
};
}
}
// Custom domain - find the first dot to split
const firstDot = fullDomain.indexOf(".");
if (firstDot > 0) {
return {
subdomain: fullDomain.slice(0, firstDot),
baseDomain: fullDomain.slice(firstDot + 1),
isCustom: true,
};
}
return {
subdomain: fullDomain,
baseDomain: "netbird.cloud",
isCustom: false,
};
}
export default function ReverseProxyModal({
open,
onOpenChange,
reverseProxy,
domains,
initialSubdomain,
initialResource,
initialPeer,
initialNetwork,
initialTab,
onSuccess,
}: Readonly<Props>) {
const router = useRouter();
const { permission } = usePermissions();
const { confirm } = useDialog();
const { reverseProxies, handleCreateOrUpdateProxy } = useReverseProxies();
// Check if the proxy's cluster exists in available free domains
const isClusterConnected = useMemo(() => {
if (!reverseProxy?.proxy_cluster) return false;
return domains?.some(
(d) =>
d.type === ReverseProxyDomainType.FREE &&
d.domain === reverseProxy.proxy_cluster,
);
}, [reverseProxy?.proxy_cluster, domains]);
const [tab, setTab] = useState(() => {
if (initialTab && initialTab !== "") return initialTab;
return "targets";
});
// Parse existing domain if editing
const parsed = reverseProxy?.domain ? parseDomain(reverseProxy.domain) : null;
// Form state
const [subdomain, setSubdomain] = useState(
parsed?.subdomain ||
initialSubdomain
?.toLowerCase()
.replace(/\s+/g, "-")
.replace(/[^a-z0-9-]/g, "") ||
"",
);
const [baseDomain, setBaseDomain] = useState(() => {
if (parsed?.baseDomain) return parsed.baseDomain;
const validatedDomains = domains?.filter((d) => d.validated) || [];
const customDomain = validatedDomains.find(
(d) => d.type === ReverseProxyDomainType.CUSTOM,
);
const freeDomain = validatedDomains.find(
(d) => d.type === ReverseProxyDomainType.FREE,
);
return customDomain?.domain || freeDomain?.domain || "";
});
const [targets, setTargets] = useState<ReverseProxyTarget[]>(
reverseProxy?.targets || [],
);
const [passHostHeader, setPassHostHeader] = useState(
reverseProxy?.pass_host_header ?? false,
);
const [rewriteRedirects, setRewriteRedirects] = useState(
reverseProxy?.rewrite_redirects ?? false,
);
// Compute full domain
const fullDomain = useMemo(() => {
if (!baseDomain) return subdomain;
return `${subdomain}.${baseDomain}`;
}, [subdomain, baseDomain]);
const domainAlreadyExists = useMemo(() => {
if (!reverseProxies || !fullDomain) return false;
return reverseProxies.some(
(p) => p.domain === fullDomain && p.id !== reverseProxy?.id,
);
}, [reverseProxies, fullDomain, reverseProxy?.id]);
// Authentication options - initialized from existing reverseProxy.auth
const [passwordEnabled, setPasswordEnabled] = useState(
reverseProxy?.auth?.password_auth?.enabled ?? false,
);
const [password, setPassword] = useState(
reverseProxy?.auth?.password_auth?.password ?? "",
);
const [pinEnabled, setPinEnabled] = useState(
reverseProxy?.auth?.pin_auth?.enabled ?? false,
);
const [pin, setPin] = useState(reverseProxy?.auth?.pin_auth?.pin ?? "");
const [bearerEnabled, setBearerEnabled] = useState(
reverseProxy?.auth?.bearer_auth?.enabled ?? false,
);
const [bearerGroups, setBearerGroups, { save: saveGroups }] = useGroupHelper({
initial: reverseProxy?.auth?.bearer_auth?.distribution_groups ?? [],
});
const [linkAuthEnabled, setLinkAuthEnabled] = useState(
reverseProxy?.auth?.link_auth?.enabled ?? false,
);
// Auth modal states
const [passwordModalOpen, setPasswordModalOpen] = useState(false);
const [ssoModalOpen, setSsoModalOpen] = useState(false);
const [pinModalOpen, setPinModalOpen] = useState(false);
// Target being added/edited
const [targetModalOpen, setTargetModalOpen] = useState(false);
const [editingTargetIndex, setEditingTargetIndex] = useState<number | null>(
null,
);
const isSubdomainValid = useMemo(() => {
return (
subdomain.length > 0 && baseDomain.length > 0 && !domainAlreadyExists
);
}, [subdomain, baseDomain, domainAlreadyExists]);
const canContinueToSettings = useMemo(() => {
return isSubdomainValid && targets.length > 0;
}, [isSubdomainValid, targets]);
const submitDisabled = useMemo(() => {
return !canContinueToSettings;
}, [canContinueToSettings]);
const saveTarget = (targetData: ReverseProxyTarget) => {
if (editingTargetIndex !== null) {
// Update existing target
setTargets(
targets.map((t, i) =>
i === editingTargetIndex ? { ...t, ...targetData } : t,
),
);
} else {
// Add new target
setTargets([...targets, targetData]);
}
setTargetModalOpen(false);
setEditingTargetIndex(null);
};
const editTarget = (index: number) => {
setEditingTargetIndex(index);
setTargetModalOpen(true);
};
const removeTarget = (index: number) => {
setTargets(targets.filter((_, i) => i !== index));
};
const toggleTargetEnabled = (index: number) => {
setTargets(
targets.map((t, i) => (i === index ? { ...t, enabled: !t.enabled } : t)),
);
};
const hasNoAuth =
!passwordEnabled && !pinEnabled && !bearerEnabled && !linkAuthEnabled;
const handleSubmit = async () => {
// Show warning if no authentication is configured
if (hasNoAuth) {
const confirmed = await confirm({
title: "No Authentication Configured",
description:
"This service will be publicly accessible to everyone on the internet without any restrictions. Are you sure you want to continue?",
type: "warning",
confirmText: reverseProxy ? "Save Changes" : "Add Service",
cancelText: "Cancel",
maxWidthClass: "max-w-lg",
});
if (!confirmed) return;
}
const savedGroups = await saveGroups();
const auth: ReverseProxyAuth = {
password_auth: {
enabled: passwordEnabled,
password: password,
},
pin_auth: {
enabled: pinEnabled,
pin: pin,
},
bearer_auth: {
enabled: bearerEnabled,
distribution_groups: savedGroups.map((g) => g.id as string),
},
link_auth: {
enabled: linkAuthEnabled,
},
};
handleCreateOrUpdateProxy({
data: {
name: fullDomain,
domain: fullDomain,
targets,
enabled: reverseProxy?.enabled ?? true,
pass_host_header: passHostHeader,
rewrite_redirects: rewriteRedirects,
auth,
},
proxyId: reverseProxy?.id,
onSuccess: () => {
onOpenChange(false);
onSuccess?.();
},
});
};
return (
<Modal open={open} onOpenChange={onOpenChange} key={open ? 1 : 0}>
<ModalContent
maxWidthClass={tab === "service" ? "max-w-xl" : "max-w-2xl"}
>
<ModalHeader
icon={<ReverseProxyIcon className={"fill-netbird"} size={18} />}
title={reverseProxy ? "Edit Service" : "Add Service"}
description={
"Expose services securely through NetBird's reverse proxy."
}
color={"netbird"}
/>
<Tabs value={tab} onValueChange={setTab}>
<TabsList justify={"start"} className={"px-8"}>
<TabsTrigger value={"targets"}>
<Text size={14} />
Details
</TabsTrigger>
<TabsTrigger value={"auth"} disabled={!canContinueToSettings}>
<LockKeyhole size={16} />
Authentication
</TabsTrigger>
<TabsTrigger value={"settings"} disabled={!canContinueToSettings}>
<Settings size={14} />
Settings
</TabsTrigger>
</TabsList>
<TabsContent value={"targets"} className={"pb-8"}>
<div className={"px-8 flex-col flex gap-6"}>
<div>
<Label>
<GlobeIcon size={14} />
Domain
</Label>
<HelpText>
Enter a subdomain and select a domain for your service.
</HelpText>
<div className="flex items-start mt-2">
<div className="flex-1 min-w-0">
<Input
autoFocus
value={subdomain}
onChange={(e) => {
setSubdomain(
e.target.value
.toLowerCase()
.replace(/[^a-z0-9-]/g, ""),
);
}}
error={
domainAlreadyExists
? "This domain is already used by another service."
: undefined
}
placeholder={"myapp"}
className="!rounded-r-none !border-r-0"
/>
</div>
<div className="flex-1 min-w-0">
<CustomDomainSelector
value={baseDomain}
onChange={setBaseDomain}
className="!rounded-l-none"
/>
</div>
</div>
</div>
{reverseProxy?.proxy_cluster && (
<Callout variant={"error"}>
Cluster {reverseProxy.proxy_cluster} is offline. Make sure the
proxy server is running and connected to the right management
address.
</Callout>
)}
<div>
<Label>
<Server size={14} />
Targets
</Label>
<HelpText>
Add one or more devices running your service or resources to
make it publicly accessible.
</HelpText>
{targets.length > 0 && (
<div
className={
"mt-3 mb-3 overflow-hidden border border-nb-gray-900 bg-nb-gray-920/30 py-1 px-1 rounded-md "
}
>
<table className="w-full">
<tbody>
{targets.map((target, index) => (
<tr
key={index}
onClick={() => editTarget(index)}
className="rounded-md hover:bg-nb-gray-900/30 cursor-pointer transition-all"
>
<td className="py-2.5 pl-5 pr-2 align-middle">
<span className="text-[11px] leading-none font-mono px-2.5 py-2 rounded bg-nb-gray-900 text-nb-gray-300 inline-flex items-center">
{target.path
? target.path.startsWith("/")
? target.path
: `/${target.path}`
: "/"}
</span>
</td>
<td className="py-2.5 px-4 align-middle">
<ArrowRight
size={12}
className="text-nb-gray-400"
/>
</td>
<td className="py-2.5 pr-2 align-middle">
<TargetDestination target={target} />
</td>
<td className="py-2.5 pl-2 pr-4">
<div
className="flex items-center gap-2 justify-end"
onClick={(e) => e.stopPropagation()}
>
<ToggleSwitch
size="small"
checked={target.enabled !== false}
onCheckedChange={() =>
toggleTargetEnabled(index)
}
/>
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button
variant="default-outline"
className="!px-3"
>
<MoreVertical
size={16}
className="shrink-0"
/>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-auto min-w-[200px]"
align="end"
>
<DropdownMenuItem
onClick={() => editTarget(index)}
>
<div className="flex gap-3 items-center">
<Edit size={14} className="shrink-0" />
Edit Target
</div>
</DropdownMenuItem>
<DropdownMenuItem
variant={"danger"}
onClick={() => removeTarget(index)}
>
<div className="flex gap-3 items-center">
<MinusCircleIcon
size={14}
className="shrink-0"
/>
Remove Target
</div>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<Button
variant="dotted"
className={cn("w-full mt-1", targets?.length > 0 && "mt-1")}
size="sm"
onClick={() => setTargetModalOpen(true)}
disabled={
!!(initialNetwork && !initialNetwork.resources?.length)
}
>
<PlusIcon size={14} />
Add Target
</Button>
{initialNetwork && !initialNetwork.resources?.length && (
<Callout
variant="warning"
className="mt-3"
icon={
<AlertTriangle
size={14}
className="shrink-0 relative top-[3px]"
/>
}
>
There are currently no resources in your network{" "}
<span className={"text-netbird-100 font-medium"}>
{initialNetwork?.name}
</span>
. Add resources to your network before exposing it as a
service.{" "}
<InlineButtonLink
variant={"default"}
onClick={() => {
onOpenChange(false);
router.push(
`/network?id=${initialNetwork.id}&tab=resources`,
);
}}
>
Go to Resources
<ArrowUpRight size={14} />
</InlineButtonLink>
</Callout>
)}
</div>
</div>
</TabsContent>
<TabsContent value={"auth"} className={"pb-8"}>
<div className={"px-8 flex-col flex gap-4"}>
<SettingCard>
<SettingCard.Item
label={
<>
<Users size={15} />
SSO (Single Sign-On)
</>
}
description="Require users to authenticate via SSO to access this service."
enabled={bearerEnabled}
onClick={() => setSsoModalOpen(true)}
/>
<SettingCard.Item
label={
<>
<RectangleEllipsis size={15} />
Password
</>
}
description="Require a password to access this service."
enabled={passwordEnabled}
onClick={() => setPasswordModalOpen(true)}
/>
<SettingCard.Item
label={
<>
<Binary size={15} />
PIN Code
</>
}
description="Require a numeric PIN code to access this service."
enabled={pinEnabled}
onClick={() => setPinModalOpen(true)}
/>
</SettingCard>
</div>
</TabsContent>
<TabsContent value={"settings"} className={"pb-8"}>
<div className={"px-8 flex-col flex gap-4"}>
<FancyToggleSwitch
value={passHostHeader}
onChange={setPassHostHeader}
label={
<>
<GlobeIcon size={15} />
Pass Host Header
</>
}
helpText="Forward the original Host header to the backend instead of rewriting it to the target address."
/>
<FancyToggleSwitch
value={rewriteRedirects}
onChange={setRewriteRedirects}
label={
<>
<ArrowRight size={15} />
Rewrite Redirects
</>
}
helpText="Rewrite Location headers in backend responses to use the public domain instead of the internal backend address."
/>
</div>
</TabsContent>
</Tabs>
<ModalFooter className={"items-center"}>
<div className={"w-full"}>
{tab === "targets" && (
<Paragraph className={"text-sm mt-auto"}>
Learn more about
<InlineLink
href={REVERSE_PROXY_SERVICES_DOCS_LINK}
target={"_blank"}
>
Services
<ExternalLinkIcon size={12} />
</InlineLink>
</Paragraph>
)}
{tab === "auth" && (
<Paragraph className={"text-sm mt-auto"}>
Learn more about
<InlineLink
href={REVERSE_PROXY_AUTHENTICATION_DOCS_LINK}
target={"_blank"}
>
Authentication
<ExternalLinkIcon size={12} />
</InlineLink>
</Paragraph>
)}
{tab === "settings" && (
<Paragraph className={"text-sm mt-auto"}>
Learn more about
<InlineLink
href={REVERSE_PROXY_SETTINGS_DOCS_LINK}
target={"_blank"}
>
Settings
<ExternalLinkIcon size={12} />
</InlineLink>
</Paragraph>
)}
</div>
<div className={"flex gap-3 w-full justify-end"}>
{!reverseProxy ? (
<>
{tab === "targets" && (
<>
<ModalClose asChild>
<Button variant={"secondary"}>Cancel</Button>
</ModalClose>
<Button
variant={"primary"}
onClick={() => setTab("auth")}
disabled={!canContinueToSettings}
>
Continue
</Button>
</>
)}
{tab === "auth" && (
<>
<Button
variant={"secondary"}
onClick={() => setTab("targets")}
>
Back
</Button>
<Button
variant={"primary"}
onClick={() => setTab("settings")}
>
Continue
</Button>
</>
)}
{tab === "settings" && (
<>
<Button
variant={"secondary"}
onClick={() => setTab("auth")}
>
Back
</Button>
<Button
variant={"primary"}
disabled={submitDisabled || !permission?.services?.create}
onClick={handleSubmit}
>
<PlusCircle size={16} />
Add Service
</Button>
</>
)}
</>
) : (
<>
<ModalClose asChild>
<Button variant={"secondary"}>Cancel</Button>
</ModalClose>
<Button
variant={"primary"}
disabled={submitDisabled || !permission?.services?.update}
onClick={handleSubmit}
>
Save Changes
</Button>
</>
)}
</div>
</ModalFooter>
</ModalContent>
<ReverseProxyTargetModal
key={targetModalOpen ? 1 : 0}
open={targetModalOpen}
onOpenChange={(open) => {
setTargetModalOpen(open);
if (!open) setEditingTargetIndex(null);
}}
onSave={saveTarget}
currentTarget={
editingTargetIndex !== null ? targets[editingTargetIndex] : null
}
reverseProxy={{
id: reverseProxy?.id || "",
name: fullDomain,
domain: fullDomain,
targets: targets,
enabled: reverseProxy?.enabled ?? true,
}}
initialResource={initialResource}
initialPeer={initialPeer}
initialNetwork={initialNetwork}
/>
<AuthPasswordModal
open={passwordModalOpen}
key={passwordModalOpen ? "pass1" : "pass0"}
onOpenChange={setPasswordModalOpen}
currentPassword={password}
isEnabled={passwordEnabled}
onSave={(newPassword) => {
setTimeout(() => {
setPassword(newPassword);
setPasswordEnabled(true);
}, 200);
}}
onRemove={() => {
setTimeout(() => {
setPassword("");
setPasswordEnabled(false);
}, 200);
}}
/>
<AuthSSOModal
open={ssoModalOpen}
onOpenChange={setSsoModalOpen}
key={ssoModalOpen ? "sso1" : "sso0"}
currentGroups={bearerGroups}
onSave={(groups) => {
setTimeout(() => {
setBearerGroups(groups);
setBearerEnabled(true);
}, 200);
}}
onRemove={() => {
setTimeout(() => {
setBearerGroups([]);
setBearerEnabled(false);
}, 200);
}}
/>
<AuthPinModal
open={pinModalOpen}
onOpenChange={setPinModalOpen}
key={pinModalOpen ? "p1" : "p0"}
currentPin={pin}
isEnabled={pinEnabled}
onSave={(newPin) => {
setTimeout(() => {
setPin(newPin);
setPinEnabled(true);
}, 200);
}}
onRemove={() => {
setTimeout(() => {
setPin("");
setPinEnabled(false);
}, 200);
}}
/>
</Modal>
);
}
function TargetDestination({ target }: { target: ReverseProxyTarget }) {
const { resolveDestination } = useReverseProxies();
return (
<span className="text-[0.76rem] text-nb-gray-200 whitespace-nowrap font-mono">
{resolveDestination(target)}
</span>
);
}

View File

@@ -0,0 +1,118 @@
import Button from "@components/Button";
import { Input } from "@components/Input";
import { Modal, ModalClose, ModalContent } from "@components/modal/Modal";
import ModalHeader from "@components/modal/ModalHeader";
import React, { useState } from "react";
import { GradientFadedBackground } from "@components/ui/GradientFadedBackground";
type Props = {
open: boolean;
onOpenChange: (open: boolean) => void;
currentPassword: string;
isEnabled: boolean;
onSave: (password: string) => void;
onRemove: () => void;
};
export default function AuthPasswordModal({
open,
onOpenChange,
currentPassword,
isEnabled,
onSave,
onRemove,
}: Readonly<Props>) {
const [password, setPassword] = useState(currentPassword);
const [isMasked, setIsMasked] = useState(isEnabled && currentPassword === "");
const isEditing = isEnabled;
const handleSave = () => {
if (password.trim()) {
onOpenChange(false);
onSave(password);
}
};
const handleRemove = () => {
onOpenChange(false);
setPassword("");
onRemove();
};
return (
<Modal open={open} onOpenChange={onOpenChange}>
<ModalContent maxWidthClass="max-w-md">
<ModalHeader
title="Password"
description="Require a password to access this service."
/>
<GradientFadedBackground />
<div className="px-8">
<Input
type="password"
value={isMasked ? "**********" : password}
name={"service-password"}
showPasswordToggle={!isMasked}
onChange={(e) => {
if (isMasked) {
setIsMasked(false);
setPassword(e.target.value.replace(/\*/g, ""));
} else {
setPassword(e.target.value);
}
}}
placeholder="Enter password..."
autoComplete="off"
data-1p-ignore
data-lpignore="true"
data-form-type="other"
onKeyDown={(e) => {
if (e.key === "Enter" && password.trim()) {
handleSave();
}
}}
/>
<div className="flex gap-3 w-full justify-between mt-6">
{isEditing ? (
<>
<Button variant="danger-text" onClick={handleRemove}>
Remove
</Button>
<div className="flex gap-3">
<ModalClose asChild>
<Button variant="secondary">Cancel</Button>
</ModalClose>
<Button
variant="primary"
onClick={handleSave}
disabled={!password.trim()}
>
Save
</Button>
</div>
</>
) : (
<>
<div />
<div className="flex gap-3">
<ModalClose asChild>
<Button variant="secondary">Cancel</Button>
</ModalClose>
<Button
variant="primary"
onClick={handleSave}
disabled={!password.trim()}
>
Add Password
</Button>
</div>
</>
)}
</div>
</div>
</ModalContent>
</Modal>
);
}

View File

@@ -0,0 +1,109 @@
import Button from "@components/Button";
import { Modal, ModalClose, ModalContent } from "@components/modal/Modal";
import ModalHeader from "@components/modal/ModalHeader";
import PinCodeInput from "@components/PinCodeInput";
import { GradientFadedBackground } from "@components/ui/GradientFadedBackground";
import React, { useState } from "react";
type Props = {
open: boolean;
onOpenChange: (open: boolean) => void;
currentPin: string;
isEnabled: boolean;
onSave: (pin: string) => void;
onRemove: () => void;
};
export default function AuthPinModal({
open,
onOpenChange,
currentPin,
isEnabled,
onSave,
onRemove,
}: Readonly<Props>) {
const [pin, setPin] = useState(currentPin);
const [isMasked, setIsMasked] = useState(isEnabled && currentPin === "");
const isEditing = isEnabled;
const handleSave = () => {
if (pin.length === 6) {
onOpenChange(false);
onSave(pin);
}
};
const handleRemove = () => {
onOpenChange(false);
setPin("");
onRemove();
};
return (
<Modal open={open} onOpenChange={onOpenChange}>
<ModalContent maxWidthClass="max-w-md">
<ModalHeader
title="PIN Code"
description="Require a numeric PIN code to access this service."
/>
<GradientFadedBackground />
<div className="px-8">
<div className="flex justify-center">
{isMasked ? (
<PinCodeInput
value={"******"}
onChange={(value) => {
const digit = value.replace(/\D/g, "");
setPin(digit);
setIsMasked(false);
}}
type={"password"}
/>
) : (
<PinCodeInput value={pin} onChange={setPin} />
)}
</div>
<div className="flex gap-3 w-full justify-between mt-6">
{isEditing ? (
<>
<Button variant="danger-text" onClick={handleRemove}>
Remove
</Button>
<div className="flex gap-3">
<ModalClose asChild>
<Button variant="secondary">Cancel</Button>
</ModalClose>
<Button
variant="primary"
onClick={handleSave}
disabled={pin.length !== 6}
>
Save
</Button>
</div>
</>
) : (
<>
<div />
<div className="flex gap-3">
<ModalClose asChild>
<Button variant="secondary">Cancel</Button>
</ModalClose>
<Button
variant="primary"
onClick={handleSave}
disabled={pin.length !== 6}
>
Add PIN
</Button>
</div>
</>
)}
</div>
</div>
</ModalContent>
</Modal>
);
}

View File

@@ -0,0 +1,97 @@
import Button from "@components/Button";
import { Modal, ModalClose, ModalContent } from "@components/modal/Modal";
import ModalHeader from "@components/modal/ModalHeader";
import { PeerGroupSelector } from "@components/PeerGroupSelector";
import { GradientFadedBackground } from "@components/ui/GradientFadedBackground";
import React, { useState } from "react";
import { Group } from "@/interfaces/Group";
type Props = {
open: boolean;
onOpenChange: (open: boolean) => void;
currentGroups: Group[];
onSave: (groups: Group[]) => void;
onRemove: () => void;
};
export default function AuthSSOModal({
open,
onOpenChange,
currentGroups,
onSave,
onRemove,
}: Readonly<Props>) {
const [groups, setGroups] = useState<Group[]>(currentGroups);
const isEditing = currentGroups.length > 0;
const handleSave = () => {
if (groups.length > 0) {
onOpenChange(false);
onSave(groups);
}
};
const handleRemove = () => {
onOpenChange(false);
setGroups([]);
onRemove();
};
return (
<Modal open={open} onOpenChange={onOpenChange}>
<ModalContent maxWidthClass="max-w-xl">
<ModalHeader
title="SSO (Single Sign-On)"
description="Require users to authenticate via SSO to access this service."
/>
<GradientFadedBackground />
<div className="px-8">
<PeerGroupSelector
values={groups}
onChange={setGroups}
placeholder="Select distribution groups..."
/>
<div className="flex gap-3 w-full justify-between mt-6">
{isEditing ? (
<>
<Button variant="danger-text" onClick={handleRemove}>
Remove
</Button>
<div className="flex gap-3">
<ModalClose asChild>
<Button variant="secondary">Cancel</Button>
</ModalClose>
<Button
variant="primary"
onClick={handleSave}
disabled={groups.length === 0}
>
Save
</Button>
</div>
</>
) : (
<>
<div />
<div className="flex gap-3">
<ModalClose asChild>
<Button variant="secondary">Cancel</Button>
</ModalClose>
<Button
variant="primary"
onClick={handleSave}
disabled={groups.length === 0}
>
Add Groups
</Button>
</div>
</>
)}
</div>
</div>
</ModalContent>
</Modal>
);
}

View File

@@ -0,0 +1,34 @@
"use client";
import Badge from "@components/Badge";
import { Globe, Server } from "lucide-react";
import React from "react";
import { ReverseProxyDomain } from "@/interfaces/ReverseProxy";
type Props = {
domain: ReverseProxyDomain;
};
export default function CustomDomainClusterCell({ domain }: Readonly<Props>) {
const hasCluster = !!domain.target_cluster;
if (!hasCluster) {
return (
<div className="flex items-center gap-2">
<Badge variant="gray" className="font-normal">
<Globe size={12} />
All
</Badge>
</div>
);
}
return (
<div className="flex items-center gap-2">
<Badge variant={"gray"} className={"font-normal"}>
<Server size={11} className={"text-nb-gray-400"} />
{domain.target_cluster}
</Badge>
</div>
);
}

View File

@@ -0,0 +1,178 @@
import Button from "@components/Button";
import { Callout } from "@components/Callout";
import InlineLink from "@components/InlineLink";
import { Input } from "@components/Input";
import { Label } from "@components/Label";
import {
Modal,
ModalClose,
ModalContent,
ModalFooter,
} from "@components/modal/Modal";
import ModalHeader from "@components/modal/ModalHeader";
import Paragraph from "@components/Paragraph";
import { validator } from "@utils/helpers";
import { ExternalLinkIcon, GlobeIcon, ServerIcon } from "lucide-react";
import * as React from "react";
import { useMemo, useState } from "react";
import { useReverseProxies } from "@/contexts/ReverseProxiesProvider";
import {
REVERSE_PROXY_CLUSTERS_DOCS_LINK,
REVERSE_PROXY_CUSTOM_DOMAINS_DOCS_LINK,
ReverseProxyDomainType,
} from "@/interfaces/ReverseProxy";
import HelpText from "@components/HelpText";
import Separator from "@components/Separator";
import {
SelectDropdown,
SelectOption,
} from "@components/select/SelectDropdown";
type Props = {
open: boolean;
onOpenChange: (open: boolean) => void;
onDomainSubmit: (domain: string, targetCluster: string) => void;
};
export const CustomDomainModal = ({
open,
onOpenChange,
onDomainSubmit,
}: Props) => {
const { domains } = useReverseProxies();
const [domain, setDomain] = useState("");
const [selectedCluster, setSelectedCluster] = useState("");
// Get available proxy clusters (free domains)
const availableClusters = useMemo(() => {
return domains?.filter((d) => d.type === ReverseProxyDomainType.FREE) || [];
}, [domains]);
// Auto-select first cluster if only one available
React.useEffect(() => {
if (availableClusters.length === 1 && !selectedCluster) {
setSelectedCluster(availableClusters[0].domain);
}
}, [availableClusters, selectedCluster]);
const error = useMemo(() => {
if (!domain) return "";
const isValid = validator.isValidDomain(domain, {
allowWildcard: false,
allowOnlyTld: false,
preventLeadingAndTrailingDots: true,
});
if (!isValid) {
return "Please enter a valid TLD domain, e.g., company.com";
}
return "";
}, [domain]);
const isValidDomain = !error && domain.length > 0;
const canSubmit = isValidDomain && selectedCluster;
const addDomain = () => {
if (canSubmit && domain && selectedCluster) {
onDomainSubmit(domain, selectedCluster);
}
};
const availableClusterOptions = availableClusters.map((cluster) => {
return {
label: cluster.domain,
value: cluster.domain,
icon: ServerIcon,
} as SelectOption;
});
return (
<Modal open={open} onOpenChange={onOpenChange}>
<ModalContent maxWidthClass={"relative max-w-lg"} showClose={true}>
<ModalHeader
icon={<GlobeIcon size={20} />}
title={"Add Custom Domain"}
description={"You will need to verify the domain with DNS records"}
color={"netbird"}
/>
<Separator />
<div className={"px-8 flex flex-col gap-6 pt-6 pb-8"}>
{availableClusters.length === 0 ? (
<Callout variant="warning">
No proxy clusters are currently connected. Please ensure at least
one proxy is running before adding a domain. <br /> Learn more
about{" "}
<InlineLink
href={REVERSE_PROXY_CLUSTERS_DOCS_LINK}
target={"_blank"}
>
Proxy Clusters
<ExternalLinkIcon size={12} />
</InlineLink>
</Callout>
) : (
<>
<div>
<Label>Domain</Label>
<Input
autoFocus
value={domain}
onChange={(e) => setDomain(e.target.value.toLowerCase())}
onKeyDown={(e) => {
if (e.key === "Enter" && canSubmit) {
addDomain();
}
}}
placeholder="e.g., company.com"
error={error || undefined}
/>
</div>
<div>
<Label>Target Proxy Cluster</Label>
<HelpText>
Select the cluster your CNAME record should point to
</HelpText>
<SelectDropdown
showSearch={false}
value={selectedCluster}
onChange={setSelectedCluster}
options={availableClusterOptions}
placeholder={"Select a proxy cluster..."}
/>
</div>
</>
)}
</div>
<ModalFooter className={"items-center"}>
<div className={"w-full"}>
<Paragraph className={"text-sm mt-auto"}>
Learn more about
<InlineLink
href={REVERSE_PROXY_CUSTOM_DOMAINS_DOCS_LINK}
target={"_blank"}
>
Custom Domains
<ExternalLinkIcon size={12} />
</InlineLink>
</Paragraph>
</div>
<div className={"flex gap-3 w-full justify-end"}>
<ModalClose asChild={true}>
<Button variant={"secondary"}>Cancel</Button>
</ModalClose>
<Button
variant={"primary"}
onClick={addDomain}
disabled={!canSubmit}
>
Add Domain
</Button>
</div>
</ModalFooter>
</ModalContent>
</Modal>
);
};

View File

@@ -0,0 +1,109 @@
import {
SelectDropdown,
SelectOption,
} from "@components/select/SelectDropdown";
import { SmallBadge } from "@components/ui/SmallBadge";
import { ArrowUpRight } from "lucide-react";
import { useRouter } from "next/navigation";
import * as React from "react";
import { useMemo } from "react";
import { useReverseProxies } from "@/contexts/ReverseProxiesProvider";
import { ReverseProxyDomainType } from "@/interfaces/ReverseProxy";
import { isNetBirdHosted } from "@utils/netbird";
interface DomainSelectorProps {
value: string;
onChange: (value: string) => void;
disabled?: boolean;
className?: string;
}
export function CustomDomainSelector({
value,
onChange,
disabled = false,
className,
}: DomainSelectorProps) {
const router = useRouter();
const { domains } = useReverseProxies();
const options: SelectOption[] = useMemo(() => {
const opts: SelectOption[] = [];
// Add free domains (connected proxy clusters, e.g., .eu.proxy.netbird.io)
domains
?.filter((d) => d.type === ReverseProxyDomainType.FREE)
.forEach((domain) => {
opts.push({
value: domain.domain,
label: `.${domain.domain}`,
renderItem: () => (
<div className="flex items-center gap-2 w-full text-sm justify-between">
<div className="flex items-center gap-2">
<span>.{domain.domain}</span>
</div>
{isNetBirdHosted() ? (
<SmallBadge text="Free" variant="green" size="md" />
) : (
<SmallBadge text="Cluster" variant="green" size="md" />
)}
</div>
),
});
});
// Add validated custom domains
domains
?.filter((d) => d.validated && d.type === ReverseProxyDomainType.CUSTOM)
.forEach((domain) => {
opts.push({
value: domain.domain,
label: `.${domain.domain}`,
renderItem: () => (
<div className="flex items-center gap-2 w-full text-sm justify-between">
<span>.{domain.domain}</span>
<SmallBadge text="Custom" variant="sky" size="md" />
</div>
),
});
});
// Add "Add Custom Domain" option
opts.push({
value: "add_custom",
label: "Add Custom Domain",
renderItem: () => (
<div className="flex items-center justify-between gap-2 text-netbird text-sm w-full">
<div className={"flex items-center gap-2"}>
<span>Add Custom Domain</span>
</div>
<ArrowUpRight size={16} />
</div>
),
});
return opts;
}, [domains]);
const handleChange = (selectedValue: string) => {
if (selectedValue === "add_custom") {
router.push("/reverse-proxy/custom-domains");
return;
}
onChange(selectedValue);
};
return (
<SelectDropdown
value={value}
onChange={handleChange}
options={options}
popoverWidth={335}
showSearch={true}
searchPlaceholder="Search domains..."
disabled={disabled}
placeholder="Select domain..."
className={className}
/>
);
}

View File

@@ -0,0 +1,162 @@
import Button from "@components/Button";
import Card from "@components/Card";
import {
Modal,
ModalClose,
ModalContent,
ModalFooter,
} from "@components/modal/Modal";
import ModalHeader from "@components/modal/ModalHeader";
import Steps from "@components/Steps";
import { GradientFadedBackground } from "@components/ui/GradientFadedBackground";
import { Mark } from "@components/ui/Mark";
import { ExternalLinkIcon, GlobeIcon } from "lucide-react";
import * as React from "react";
import { Callout } from "@components/Callout";
import { useReverseProxies } from "@/contexts/ReverseProxiesProvider";
import {
REVERSE_PROXY_CLUSTERS_DOCS_LINK,
REVERSE_PROXY_DOMAIN_VERIFICATION_LINK,
ReverseProxyDomain,
ReverseProxyDomainType,
} from "@/interfaces/ReverseProxy";
import Paragraph from "@components/Paragraph";
import InlineLink from "@components/InlineLink";
type Props = {
open: boolean;
onOpenChange: (open: boolean) => void;
domain: ReverseProxyDomain;
onStartVerification: (domain: ReverseProxyDomain) => void;
targetCluster?: string;
};
export const CustomDomainVerificationModal = ({
open,
onOpenChange,
domain,
onStartVerification,
targetCluster,
}: Props) => {
const { domains } = useReverseProxies();
const handleStartVerification = () => {
onStartVerification(domain);
onOpenChange(false);
};
// Get free domains (proxy clusters) as CNAME targets
const freeDomains =
domains?.filter((d) => d.type === ReverseProxyDomainType.FREE) || [];
// Use provided target cluster, or fall back to first available
const cnameTarget = targetCluster || freeDomains[0]?.domain || "";
return (
<Modal open={open} onOpenChange={onOpenChange}>
<ModalContent maxWidthClass={"relative max-w-[600px]"} showClose={true}>
<GradientFadedBackground />
<ModalHeader
icon={<GlobeIcon size={20} />}
title={"Verify Domain"}
description={domain.domain}
color={"netbird"}
/>
<div className={"px-8 flex flex-col gap-0 pb-6"}>
<Steps className={"pt-0 stepper-bg-variant"}>
<Steps.Step step={1}>
<p className={"font-normal"}>
Sign in to your domain name provider (e.g. cloudflare.com or
godaddy.com)
</p>
</Steps.Step>
<Steps.Step step={2} line={false}>
<p className={"font-normal"}>
Add the <Mark>CNAME record</Mark> below to your DNS
configuration for <Mark>{domain.domain}</Mark>
</p>
</Steps.Step>
</Steps>
<div className={"flex flex-col gap-6"}>
{!cnameTarget ? (
<Callout variant={"warning"}>
No proxy clusters are currently connected. Please ensure at
least one proxy is running to configure DNS verification. <br />
Learn more about{" "}
<InlineLink
href={REVERSE_PROXY_CLUSTERS_DOCS_LINK}
target={"_blank"}
>
Proxy Clusters
<ExternalLinkIcon size={12} />
</InlineLink>
</Callout>
) : (
<>
<Card className={"w-full"}>
<Card.List>
<Card.ListItem
copy
copyText={`*.${domain.domain}`}
label={"CNAME Record"}
value={`*.${domain.domain}`}
/>
<Card.ListItem
copy
copyText={cnameTarget}
label={"CNAME Content"}
value={cnameTarget}
/>
</Card.List>
</Card>
{!targetCluster && freeDomains.length > 1 && (
<Callout variant={"info"}>
<span className="font-medium">
Available proxy clusters:
</span>{" "}
{freeDomains.map((d) => d.domain).join(", ")}. Choose the
cluster closest to your users for best performance.
</Callout>
)}
<Callout variant={"warning"}>
DNS changes may take some time to propagate. If NetBird does
not find the record immediately, please wait up to 24 hours
and try again.
</Callout>
</>
)}
</div>
</div>
<ModalFooter className={"items-center"}>
<div className={"w-full"}>
<Paragraph className={"text-sm mt-auto"}>
Learn more about
<InlineLink
href={REVERSE_PROXY_DOMAIN_VERIFICATION_LINK}
target={"_blank"}
>
Domain Verification
<ExternalLinkIcon size={12} />
</InlineLink>
</Paragraph>
</div>
<div className={"flex gap-3 w-full justify-end"}>
<ModalClose asChild={true}>
<Button variant={"secondary"}>Verify Later</Button>
</ModalClose>
<Button
variant={"primary"}
onClick={handleStartVerification}
disabled={!cnameTarget}
>
Start Verification
</Button>
</div>
</ModalFooter>
</ModalContent>
</Modal>
);
};

Some files were not shown because too many files have changed in this diff Show More