Compare commits

..

13 Commits

Author SHA1 Message Date
Eduard Gert
76ef50a886 Add Access Control Groups & various UI / UX improvements (#415)
Some checks failed
build and push / build_n_push (push) Has been cancelled
* Update codespell

* Add access control group, add various ui / ux improvements
2024-10-04 19:54:49 +02:00
Maycon Santos
58cec8fcd1 ignore mappin spelling (#408)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2024-08-13 15:59:38 +02:00
Eduard Gert
d34ae9beb2 Sync changes with netbird cloud (#407)
* Update axa oidc library and package.json

* Update ACL port state to show correct value

* Filter user groups by unique groups only

* Add peer multiselect, optimize dropdown performance for peer selection, remove 'all' group from some dropdowns, various ui / ux optimizations

* Add peer multiselect, optimize dropdown performance for peer selection, remove 'all' group from some dropdowns, various ui / ux optimizations
2024-08-13 15:51:22 +02:00
Eduard Gert
650496f670 Include all settings in put request to prevent overwrite (#405)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2024-07-31 18:48:59 +02:00
Tom Hubrecht
121778c4a6 Fix package-lock.json (#401) 2024-07-12 10:35:31 +02:00
juliaroesschen
d4102c5d04 fix typo in route update modal (#397)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2024-07-04 15:05:57 +02:00
pascal-fischer
e78c35bdbe Fix DNS modal to allow one char domains (#393)
* update regex to allow one char domains in DNS routing modal

* update regex
2024-07-04 10:50:37 +02:00
juliaroesschen
6ebee98695 Fix typo in Network Routes dialogue (#395) 2024-07-04 10:48:49 +02:00
juliaroesschen
f4b28d5f40 Fix typo in routes modal 2024-06-28 11:38:39 +02:00
Eduard Gert
b4b6d9295b Add DNS routes (#390)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2024-06-17 09:32:55 +02:00
Maycon Santos
4898742ee9 Fix http://localhost:3000/ url validation case (#388)
* Fix http://localhost:3000/ url validation case

* adjust min regex occurrences
2024-06-12 18:18:14 +02:00
Eduard Gert
79164e9dd5 Add process posture check (#378)
* Add process posture check

* Add support for separate linux and mac paths
2024-06-12 16:32:10 +02:00
Eduard Gert
5caeab118b UX changes for modals and refactoring (#380) 2024-05-08 14:42:04 +02:00
159 changed files with 7275 additions and 2066 deletions

View File

@@ -12,4 +12,5 @@ jobs:
uses: codespell-project/actions-codespell@v2
with:
only_warn: 1
skip: package-lock.json,*.svg
skip: package-lock.json,*.svg
ignore_words_list: mappin, allTime

View File

@@ -5,6 +5,9 @@ const nextConfig = {
unoptimized: true,
},
reactStrictMode: false,
env: {
APP_ENV: process.env.APP_ENV || "production",
},
};
module.exports = nextConfig;

536
package-lock.json generated
View File

@@ -8,7 +8,7 @@
"name": "netbird-dashboard",
"version": "2.0.0",
"dependencies": {
"@axa-fr/react-oidc": "^5.14.0",
"@axa-fr/react-oidc": "^7.22.18",
"@radix-ui/react-accordion": "^1.1.2",
"@radix-ui/react-checkbox": "^1.0.4",
"@radix-ui/react-collapsible": "^1.0.3",
@@ -17,8 +17,9 @@
"@radix-ui/react-label": "^2.0.2",
"@radix-ui/react-popover": "^1.0.7",
"@radix-ui/react-radio-group": "^1.1.3",
"@radix-ui/react-scroll-area": "^1.0.5",
"@radix-ui/react-scroll-area": "^1.1.0",
"@radix-ui/react-select": "^2.0.0",
"@radix-ui/react-slider": "^1.1.2",
"@radix-ui/react-slot": "^1.0.2",
"@radix-ui/react-switch": "^1.0.3",
"@radix-ui/react-tabs": "^1.0.4",
@@ -32,6 +33,7 @@
"@types/node": "20.10.6",
"@types/react": "^18",
"@types/react-dom": "^18",
"@types/react-window": "^1.8.8",
"autoprefixer": "^10",
"class-variance-authority": "^0.7.0",
"clsx": "^2.0.0",
@@ -48,7 +50,7 @@
"framer-motion": "^10.16.4",
"ip-cidr": "^3.1.0",
"lodash": "^4.17.21",
"lucide-react": "^0.287.0",
"lucide-react": "^0.383.0",
"next": "13.5.5",
"next-themes": "^0.2.1",
"punycode": "^2.3.1",
@@ -62,6 +64,7 @@
"react-jwt": "^1.2.0",
"react-loading-skeleton": "^3.3.1",
"react-responsive": "^9.0.2",
"react-virtuoso": "^4.9.0",
"swr": "^2.2.4",
"tailwind-merge": "^1.14.0",
"tailwindcss-animate": "^1.0.7",
@@ -93,16 +96,31 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@axa-fr/react-oidc": {
"version": "5.14.2",
"resolved": "https://registry.npmjs.org/@axa-fr/react-oidc/-/react-oidc-5.14.2.tgz",
"integrity": "sha512-N+ssJlVtVHnsvlusMxY3zLPKCB+lGzeHIxWXUb0WY3uA7Z+jxx7A2m9W1kHbhYzHuihgA3rWIcdKsvtdkeKXwg==",
"node_modules/@axa-fr/oidc-client": {
"version": "7.22.21",
"resolved": "https://registry.npmjs.org/@axa-fr/oidc-client/-/oidc-client-7.22.21.tgz",
"integrity": "sha512-w6CokGCz9Au0E3bCS5yJCUDlQemGE/TlT8jdN9FltOHI/NUw0Mn/5Rzeh/LOtlo5TIhaOS2nIlCEOY+JEIpj2w==",
"hasInstallScript": true,
"dependencies": {
"@openid/appauth": "1.3.1"
"@axa-fr/oidc-client-service-worker": "7.22.21"
}
},
"node_modules/@axa-fr/oidc-client-service-worker": {
"version": "7.22.21",
"resolved": "https://registry.npmjs.org/@axa-fr/oidc-client-service-worker/-/oidc-client-service-worker-7.22.21.tgz",
"integrity": "sha512-wDZTpRsY36sl4Ah9/ZhzDxybLj46HZjMl7Rn0qLhpK1Sb+GL+d9Agq6xNclkvizDFwuyX6hTaPGQpwcE0WNRQQ=="
},
"node_modules/@axa-fr/react-oidc": {
"version": "7.22.21",
"resolved": "https://registry.npmjs.org/@axa-fr/react-oidc/-/react-oidc-7.22.21.tgz",
"integrity": "sha512-lEdCt/q7kBXJ1AX+tEK/QAkz4p4G2qOSlhdYxPSSBRIf4ZwZEcmlH6F28W/FySk6tj/coi56dGvmcHz+hSZUDQ==",
"hasInstallScript": true,
"dependencies": {
"@axa-fr/oidc-client": "7.22.21",
"@axa-fr/oidc-client-service-worker": "7.22.21"
},
"peerDependencies": {
"react": "x",
"react-dom": "x"
"react": "^17.0.0 || ^18.0.0"
}
},
"node_modules/@babel/runtime": {
@@ -542,32 +560,6 @@
"node": ">= 8"
}
},
"node_modules/@openid/appauth": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@openid/appauth/-/appauth-1.3.1.tgz",
"integrity": "sha512-e54kpi219wES2ijPzeHe1kMnT8VKH8YeTd1GAn9BzVBmutz3tBgcG1y8a4pziNr4vNjFnuD4W446Ua7ELnNDiA==",
"dependencies": {
"@types/base64-js": "^1.3.0",
"@types/jquery": "^3.5.5",
"base64-js": "^1.5.1",
"follow-redirects": "^1.13.3",
"form-data": "^4.0.0",
"opener": "^1.5.2"
}
},
"node_modules/@openid/appauth/node_modules/form-data": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz",
"integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"mime-types": "^2.1.12"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/@popperjs/core": {
"version": "2.11.8",
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
@@ -1202,26 +1194,25 @@
}
},
"node_modules/@radix-ui/react-scroll-area": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.0.5.tgz",
"integrity": "sha512-b6PAgH4GQf9QEn8zbT2XUHpW5z8BzqEc7Kl11TwDrvuTrxlkcjTD5qa/bxgKr+nmuXKu4L/W5UZ4mlP/VG/5Gw==",
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.1.0.tgz",
"integrity": "sha512-9ArIZ9HWhsrfqS765h+GZuLoxaRHD/j0ZWOWilsCvYTpYJp8XwCqNG7Dt9Nu/TItKOdgLGkOPCodQvDc+UMwYg==",
"dependencies": {
"@babel/runtime": "^7.13.10",
"@radix-ui/number": "1.0.1",
"@radix-ui/primitive": "1.0.1",
"@radix-ui/react-compose-refs": "1.0.1",
"@radix-ui/react-context": "1.0.1",
"@radix-ui/react-direction": "1.0.1",
"@radix-ui/react-presence": "1.0.1",
"@radix-ui/react-primitive": "1.0.3",
"@radix-ui/react-use-callback-ref": "1.0.1",
"@radix-ui/react-use-layout-effect": "1.0.1"
"@radix-ui/number": "1.1.0",
"@radix-ui/primitive": "1.1.0",
"@radix-ui/react-compose-refs": "1.1.0",
"@radix-ui/react-context": "1.1.0",
"@radix-ui/react-direction": "1.1.0",
"@radix-ui/react-presence": "1.1.0",
"@radix-ui/react-primitive": "2.0.0",
"@radix-ui/react-use-callback-ref": "1.1.0",
"@radix-ui/react-use-layout-effect": "1.1.0"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0",
"react-dom": "^16.8 || ^17.0 || ^18.0"
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
@@ -1232,6 +1223,148 @@
}
}
},
"node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/number": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.0.tgz",
"integrity": "sha512-V3gRzhVNU1ldS5XhAPTom1fOIo4ccrjjJgmE+LI2h/WaFpHmx0MQApT+KZHnx8abG6Avtfcz4WoEciMnpFT3HQ=="
},
"node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/primitive": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.0.tgz",
"integrity": "sha512-4Z8dn6Upk0qk4P74xBhZ6Hd/w0mPEzOOLxy4xiPXOXqjF7jZS0VAKk7/x/H6FyY2zCkYJqePf1G5KmkmNJ4RBA=="
},
"node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-compose-refs": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.0.tgz",
"integrity": "sha512-b4inOtiaOnYf9KWyO3jAeeCG6FeyfY6ldiEPanbUjWd+xIk5wZeHa8yVwmrJ2vderhu/BQvzCrJI0lHd+wIiqw==",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-context": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.0.tgz",
"integrity": "sha512-OKrckBy+sMEgYM/sMmqmErVn0kZqrHPJze+Ql3DzYsDDp0hl0L62nx/2122/Bvps1qz645jlcu2tD9lrRSdf8A==",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-direction": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.0.tgz",
"integrity": "sha512-BUuBvgThEiAXh2DWu93XsT+a3aWrGqolGlqqw5VU1kG7p/ZH2cuDlM1sRLNnY3QcBS69UIz2mcKhMxDsdewhjg==",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-presence": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.0.tgz",
"integrity": "sha512-Gq6wuRN/asf9H/E/VzdKoUtT8GC9PQc9z40/vEr0VCJ4u5XvvhWIrSsCB6vD2/cH7ugTdSfYq9fLJCcM00acrQ==",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.0",
"@radix-ui/react-use-layout-effect": "1.1.0"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-primitive": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.0.tgz",
"integrity": "sha512-ZSpFm0/uHa8zTvKBDjLFWLo8dkr4MBsiDLz0g3gMUwqgLHz9rTaRRGYDgvZPtBJgYCBKXkS9fzmoySgr8CO6Cw==",
"dependencies": {
"@radix-ui/react-slot": "1.1.0"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-slot": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.0.tgz",
"integrity": "sha512-FUCf5XMfmW4dtYl69pdS4DbxKy8nj4M7SafBgPllysxmdachynNflAdp/gCsnYWNDnge6tI9onzMp5ARYc1KNw==",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.0"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-use-callback-ref": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.0.tgz",
"integrity": "sha512-CasTfvsy+frcFkbXtSJ2Zu9JHpN8TYKxkgJGWbjiZhFivxaeW7rMeZt7QELGVLaYVfFMsKHjb7Ak0nMEe+2Vfw==",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-use-layout-effect": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.0.tgz",
"integrity": "sha512-+FPE0rOdziWSrH9athwI1R0HDVbWlEhd+FR+aSDk4uWGmSJ9Z54sdZVDQPZAinJhJXwfT+qnj969mCsT2gfm5w==",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-select": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.0.0.tgz",
@@ -1275,6 +1408,230 @@
}
}
},
"node_modules/@radix-ui/react-slider": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slider/-/react-slider-1.2.0.tgz",
"integrity": "sha512-dAHCDA4/ySXROEPaRtaMV5WHL8+JB/DbtyTbJjYkY0RXmKMO2Ln8DFZhywG5/mVQ4WqHDBc8smc14yPXPqZHYA==",
"dependencies": {
"@radix-ui/number": "1.1.0",
"@radix-ui/primitive": "1.1.0",
"@radix-ui/react-collection": "1.1.0",
"@radix-ui/react-compose-refs": "1.1.0",
"@radix-ui/react-context": "1.1.0",
"@radix-ui/react-direction": "1.1.0",
"@radix-ui/react-primitive": "2.0.0",
"@radix-ui/react-use-controllable-state": "1.1.0",
"@radix-ui/react-use-layout-effect": "1.1.0",
"@radix-ui/react-use-previous": "1.1.0",
"@radix-ui/react-use-size": "1.1.0"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-slider/node_modules/@radix-ui/number": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.0.tgz",
"integrity": "sha512-V3gRzhVNU1ldS5XhAPTom1fOIo4ccrjjJgmE+LI2h/WaFpHmx0MQApT+KZHnx8abG6Avtfcz4WoEciMnpFT3HQ=="
},
"node_modules/@radix-ui/react-slider/node_modules/@radix-ui/primitive": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.0.tgz",
"integrity": "sha512-4Z8dn6Upk0qk4P74xBhZ6Hd/w0mPEzOOLxy4xiPXOXqjF7jZS0VAKk7/x/H6FyY2zCkYJqePf1G5KmkmNJ4RBA=="
},
"node_modules/@radix-ui/react-slider/node_modules/@radix-ui/react-collection": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.0.tgz",
"integrity": "sha512-GZsZslMJEyo1VKm5L1ZJY8tGDxZNPAoUeQUIbKeJfoi7Q4kmig5AsgLMYYuyYbfjd8fBmFORAIwYAkXMnXZgZw==",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.0",
"@radix-ui/react-context": "1.1.0",
"@radix-ui/react-primitive": "2.0.0",
"@radix-ui/react-slot": "1.1.0"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-slider/node_modules/@radix-ui/react-compose-refs": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.0.tgz",
"integrity": "sha512-b4inOtiaOnYf9KWyO3jAeeCG6FeyfY6ldiEPanbUjWd+xIk5wZeHa8yVwmrJ2vderhu/BQvzCrJI0lHd+wIiqw==",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-slider/node_modules/@radix-ui/react-context": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.0.tgz",
"integrity": "sha512-OKrckBy+sMEgYM/sMmqmErVn0kZqrHPJze+Ql3DzYsDDp0hl0L62nx/2122/Bvps1qz645jlcu2tD9lrRSdf8A==",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-slider/node_modules/@radix-ui/react-direction": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.0.tgz",
"integrity": "sha512-BUuBvgThEiAXh2DWu93XsT+a3aWrGqolGlqqw5VU1kG7p/ZH2cuDlM1sRLNnY3QcBS69UIz2mcKhMxDsdewhjg==",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-slider/node_modules/@radix-ui/react-primitive": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.0.tgz",
"integrity": "sha512-ZSpFm0/uHa8zTvKBDjLFWLo8dkr4MBsiDLz0g3gMUwqgLHz9rTaRRGYDgvZPtBJgYCBKXkS9fzmoySgr8CO6Cw==",
"dependencies": {
"@radix-ui/react-slot": "1.1.0"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-slider/node_modules/@radix-ui/react-slot": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.0.tgz",
"integrity": "sha512-FUCf5XMfmW4dtYl69pdS4DbxKy8nj4M7SafBgPllysxmdachynNflAdp/gCsnYWNDnge6tI9onzMp5ARYc1KNw==",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.0"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-slider/node_modules/@radix-ui/react-use-callback-ref": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.0.tgz",
"integrity": "sha512-CasTfvsy+frcFkbXtSJ2Zu9JHpN8TYKxkgJGWbjiZhFivxaeW7rMeZt7QELGVLaYVfFMsKHjb7Ak0nMEe+2Vfw==",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-slider/node_modules/@radix-ui/react-use-controllable-state": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.1.0.tgz",
"integrity": "sha512-MtfMVJiSr2NjzS0Aa90NPTnvTSg6C/JLCV7ma0W6+OMV78vd8OyRpID+Ng9LxzsPbLeuBnWBA1Nq30AtBIDChw==",
"dependencies": {
"@radix-ui/react-use-callback-ref": "1.1.0"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-slider/node_modules/@radix-ui/react-use-layout-effect": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.0.tgz",
"integrity": "sha512-+FPE0rOdziWSrH9athwI1R0HDVbWlEhd+FR+aSDk4uWGmSJ9Z54sdZVDQPZAinJhJXwfT+qnj969mCsT2gfm5w==",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-slider/node_modules/@radix-ui/react-use-previous": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.0.tgz",
"integrity": "sha512-Z/e78qg2YFnnXcW88A4JmTtm4ADckLno6F7OXotmkQfeuCVaKuYzqAATPhVzl3delXE7CxIV8shofPn3jPc5Og==",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-slider/node_modules/@radix-ui/react-use-size": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.0.tgz",
"integrity": "sha512-XW3/vWuIXHa+2Uwcc2ABSfcCledmXhhQPlGbfcRXbiUQI5Icjcg19BGCZVKKInYbvUCut/ufbbLLPFC5cbb1hw==",
"dependencies": {
"@radix-ui/react-use-layout-effect": "1.1.0"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-slot": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.0.2.tgz",
@@ -1658,24 +2015,11 @@
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@types/base64-js": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@types/base64-js/-/base64-js-1.3.2.tgz",
"integrity": "sha512-Q2Xn2/vQHRGLRXhQ5+BSLwhHkR3JVflxVKywH0Q6fVoAiUE8fFYL2pE5/l2ZiOiBDfA8qUqRnSxln4G/NFz1Sg=="
},
"node_modules/@types/crypto-js": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/@types/crypto-js/-/crypto-js-4.2.2.tgz",
"integrity": "sha512-sDOLlVbHhXpAUAL0YHDUUwDZf3iN4Bwi4W6a0W0b+QcAezUbRtH4FVb+9J4h+XFPW7l/gQ9F8qC7P+Ec4k8QVQ=="
},
"node_modules/@types/jquery": {
"version": "3.5.29",
"resolved": "https://registry.npmjs.org/@types/jquery/-/jquery-3.5.29.tgz",
"integrity": "sha512-oXQQC9X9MOPRrMhPHHOsXqeQDnWeCDT3PelUIg/Oy8FAbzSZtFHRjc7IpbfFVmpLtJ+UOoywpRsuO5Jxjybyeg==",
"dependencies": {
"@types/sizzle": "*"
}
},
"node_modules/@types/json5": {
"version": "0.0.29",
"resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz",
@@ -1717,6 +2061,14 @@
"@types/react": "*"
}
},
"node_modules/@types/react-window": {
"version": "1.8.8",
"resolved": "https://registry.npmjs.org/@types/react-window/-/react-window-1.8.8.tgz",
"integrity": "sha512-8Ls660bHR1AUA2kuRvVG9D/4XpRC6wjAaPT9dil7Ckc76eP9TKWZwwmgfq8Q1LANX3QNDnoU4Zp48A3w+zK69Q==",
"dependencies": {
"@types/react": "*"
}
},
"node_modules/@types/scheduler": {
"version": "0.16.8",
"resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.8.tgz",
@@ -1731,7 +2083,8 @@
"node_modules/@types/sizzle": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/@types/sizzle/-/sizzle-2.3.8.tgz",
"integrity": "sha512-0vWLNK2D5MT9dg0iOo8GlKguPAU02QjmZitPEsXRuJXU/OGIOt9vT9Fc26wtYuavLxtO45v9PGleoL9Z0k1LHg=="
"integrity": "sha512-0vWLNK2D5MT9dg0iOo8GlKguPAU02QjmZitPEsXRuJXU/OGIOt9vT9Fc26wtYuavLxtO45v9PGleoL9Z0k1LHg==",
"dev": true
},
"node_modules/@types/yauzl": {
"version": "2.10.3",
@@ -2187,7 +2540,8 @@
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"dev": true
},
"node_modules/at-least-node": {
"version": "1.0.0",
@@ -2285,6 +2639,7 @@
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
"dev": true,
"funding": [
{
"type": "github",
@@ -2927,6 +3282,7 @@
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"dev": true,
"dependencies": {
"delayed-stream": "~1.0.0"
},
@@ -3170,6 +3526,7 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"dev": true,
"engines": {
"node": ">=0.4.0"
}
@@ -4057,25 +4414,6 @@
"tailwindcss": "^3"
}
},
"node_modules/follow-redirects": {
"version": "1.15.5",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.5.tgz",
"integrity": "sha512-vSFWUON1B+yAw1VN4xMfxgn5fTUiaOzAJCKBwIIgT/+7CuGy9+r+5gITvP62j3RmaD5Ph65UaERdOSRGUzZtgw==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/RubenVerborgh"
}
],
"engines": {
"node": ">=4.0"
},
"peerDependenciesMeta": {
"debug": {
"optional": true
}
}
},
"node_modules/for-each": {
"version": "0.3.3",
"resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz",
@@ -5351,9 +5689,9 @@
}
},
"node_modules/lucide-react": {
"version": "0.287.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.287.0.tgz",
"integrity": "sha512-auxP2bTGiMoELzX+6ItTeNzLmhGd/O+PHBsrXV2YwPXYCxarIFJhiMOSzFT9a1GWeYPSZtnWdLr79IVXr/5JqQ==",
"version": "0.383.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.383.0.tgz",
"integrity": "sha512-13xlG0CQCJtzjSQYwwJ3WRqMHtRj3EXmLlorrARt7y+IHnxUCp3XyFNL1DfaGySWxHObDvnu1u1dV+0VMKHUSg==",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0"
}
@@ -5396,6 +5734,7 @@
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"dev": true,
"engines": {
"node": ">= 0.6"
}
@@ -5404,6 +5743,7 @@
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"dev": true,
"dependencies": {
"mime-db": "1.52.0"
},
@@ -5712,14 +6052,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/opener": {
"version": "1.5.2",
"resolved": "https://registry.npmjs.org/opener/-/opener-1.5.2.tgz",
"integrity": "sha512-ur5UIdyw5Y7yEj9wLzhqXiy6GZ3Mwx0yGI+5sMn2r0N0v3cKJvUmFH5yPP+WXh9e0xfyzyJX95D8l088DNFj7A==",
"bin": {
"opener": "bin/opener-bin.js"
}
},
"node_modules/optionator": {
"version": "0.9.3",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.3.tgz",
@@ -6346,6 +6678,18 @@
}
}
},
"node_modules/react-virtuoso": {
"version": "4.10.0",
"resolved": "https://registry.npmjs.org/react-virtuoso/-/react-virtuoso-4.10.0.tgz",
"integrity": "sha512-CyxU5TYMH4bw2cybH0bNqN/yIg2q2Vd0kbs92tQc5ResZALAIzIVJY4JL6BHgJFQjwrLhCYrFwKq0p+lvBgA0w==",
"engines": {
"node": ">=10"
},
"peerDependencies": {
"react": ">=16 || >=17 || >= 18",
"react-dom": ">=16 || >=17 || >= 18"
}
},
"node_modules/read-cache": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",

View File

@@ -13,7 +13,7 @@
"cypress:open": "cypress open"
},
"dependencies": {
"@axa-fr/react-oidc": "^5.14.0",
"@axa-fr/react-oidc": "^7.22.18",
"@radix-ui/react-accordion": "^1.1.2",
"@radix-ui/react-checkbox": "^1.0.4",
"@radix-ui/react-collapsible": "^1.0.3",
@@ -22,8 +22,9 @@
"@radix-ui/react-label": "^2.0.2",
"@radix-ui/react-popover": "^1.0.7",
"@radix-ui/react-radio-group": "^1.1.3",
"@radix-ui/react-scroll-area": "^1.0.5",
"@radix-ui/react-scroll-area": "^1.1.0",
"@radix-ui/react-select": "^2.0.0",
"@radix-ui/react-slider": "^1.1.2",
"@radix-ui/react-slot": "^1.0.2",
"@radix-ui/react-switch": "^1.0.3",
"@radix-ui/react-tabs": "^1.0.4",
@@ -37,6 +38,7 @@
"@types/node": "20.10.6",
"@types/react": "^18",
"@types/react-dom": "^18",
"@types/react-window": "^1.8.8",
"autoprefixer": "^10",
"class-variance-authority": "^0.7.0",
"clsx": "^2.0.0",
@@ -53,7 +55,7 @@
"framer-motion": "^10.16.4",
"ip-cidr": "^3.1.0",
"lodash": "^4.17.21",
"lucide-react": "^0.287.0",
"lucide-react": "^0.383.0",
"next": "13.5.5",
"next-themes": "^0.2.1",
"punycode": "^2.3.1",
@@ -67,6 +69,7 @@
"react-jwt": "^1.2.0",
"react-loading-skeleton": "^3.3.1",
"react-responsive": "^9.0.2",
"react-virtuoso": "^4.9.0",
"swr": "^2.2.4",
"tailwind-merge": "^1.14.0",
"tailwindcss-animate": "^1.0.7",

View File

@@ -5,6 +5,7 @@ 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 useFetchApi from "@utils/api";
import { ExternalLinkIcon } from "lucide-react";
import React, { lazy, Suspense } from "react";
@@ -20,6 +21,9 @@ const AccessControlTable = lazy(
export default function AccessControlPage() {
const { data: policies, isLoading } = useFetchApi<Policy[]>("/policies");
const { ref: headingRef, portalTarget } =
usePortalElement<HTMLHeadingElement>();
return (
<PageContainer>
<GroupsProvider>
@@ -31,12 +35,7 @@ export default function AccessControlPage() {
icon={<AccessControlIcon size={14} />}
/>
</Breadcrumbs>
<h1>
{policies && policies.length > 1
? `${policies.length} Access Control Policies`
: "Access Control Policies"}
</h1>
<h1 ref={headingRef}>Access Control Policies</h1>
<Paragraph>
Create rules to manage access in your network and define what peers
can connect.
@@ -57,7 +56,11 @@ export default function AccessControlPage() {
<RestrictedAccess page={"Access Control"}>
<PoliciesProvider>
<Suspense fallback={<SkeletonTable />}>
<AccessControlTable isLoading={isLoading} policies={policies} />
<AccessControlTable
isLoading={isLoading}
policies={policies}
headingTarget={portalTarget}
/>
</Suspense>
</PoliciesProvider>
</RestrictedAccess>

View File

@@ -4,6 +4,7 @@ import Breadcrumbs from "@components/Breadcrumbs";
import InlineLink from "@components/InlineLink";
import Paragraph from "@components/Paragraph";
import { RestrictedAccess } from "@components/ui/RestrictedAccess";
import { usePortalElement } from "@hooks/usePortalElement";
import useFetchApi from "@utils/api";
import { ExternalLinkIcon } from "lucide-react";
import React from "react";
@@ -15,6 +16,9 @@ import ActivityTable from "@/modules/activity/ActivityTable";
export default function Activity() {
const { data: events, isLoading } = useFetchApi<ActivityEvent[]>("/events");
const { ref: headingRef, portalTarget } =
usePortalElement<HTMLHeadingElement>();
return (
<PageContainer>
<div className={"p-default py-6"}>
@@ -25,11 +29,7 @@ export default function Activity() {
icon={<ActivityIcon size={13} />}
/>
</Breadcrumbs>
<h1>
{events && events.length > 1
? `${events.length} Activity Events`
: "Activity Events"}
</h1>
<h1 ref={headingRef}>Activity Events</h1>
<Paragraph>
Here you can see all the account and network activity events.
</Paragraph>
@@ -48,7 +48,11 @@ export default function Activity() {
</Paragraph>
</div>
<RestrictedAccess page={"Activity"}>
<ActivityTable events={events} isLoading={isLoading} />
<ActivityTable
events={events}
isLoading={isLoading}
headingTarget={portalTarget}
/>
</RestrictedAccess>
</PageContainer>
);

View File

@@ -5,6 +5,7 @@ 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 useFetchApi from "@utils/api";
import { ExternalLinkIcon, ServerIcon } from "lucide-react";
import React, { lazy, Suspense } from "react";
@@ -20,6 +21,9 @@ export default function NameServers() {
const { data: nameserverGroups, isLoading } =
useFetchApi<NameserverGroup[]>("/dns/nameservers");
const { ref: headingRef, portalTarget } =
usePortalElement<HTMLHeadingElement>();
return (
<PageContainer>
<div className={"p-default py-6"}>
@@ -36,11 +40,7 @@ export default function NameServers() {
icon={<ServerIcon size={13} />}
/>
</Breadcrumbs>
<h1>
{nameserverGroups && nameserverGroups.length > 1
? `${nameserverGroups.length} Nameservers`
: "Nameservers"}
</h1>
<h1 ref={headingRef}>Nameservers</h1>
<Paragraph>
Add nameservers for domain name resolution in your NetBird network.
</Paragraph>
@@ -62,6 +62,7 @@ export default function NameServers() {
<NameserverGroupTable
nameserverGroups={nameserverGroups}
isLoading={isLoading}
headingTarget={portalTarget}
/>
</Suspense>
</RestrictedAccess>

View File

@@ -5,6 +5,7 @@ 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 useFetchApi from "@utils/api";
import { ExternalLinkIcon } from "lucide-react";
import React, { lazy, Suspense } from "react";
@@ -23,6 +24,9 @@ export default function NetworkRoutes() {
const { data: routes, isLoading } = useFetchApi<Route[]>("/routes");
const groupedRoutes = useGroupedRoutes({ routes });
const { ref: headingRef, portalTarget } =
usePortalElement<HTMLHeadingElement>();
return (
<PageContainer>
<RoutesProvider>
@@ -35,11 +39,7 @@ export default function NetworkRoutes() {
icon={<NetworkRoutesIcon size={13} />}
/>
</Breadcrumbs>
<h1>
{groupedRoutes && groupedRoutes.length > 1
? `${groupedRoutes.length} Network Routes`
: "Network Routes"}
</h1>
<h1 ref={headingRef}>Network Routes</h1>
<Paragraph>
Network routes allow you to access other networks like LANs and
VPCs without installing NetBird on every resource.
@@ -65,6 +65,7 @@ export default function NetworkRoutes() {
isLoading={isLoading}
groupedRoutes={groupedRoutes}
routes={routes}
headingTarget={portalTarget}
/>
</Suspense>
</RestrictedAccess>

View File

@@ -23,6 +23,8 @@ import Separator from "@components/Separator";
import FullScreenLoading from "@components/ui/FullScreenLoading";
import LoginExpiredBadge from "@components/ui/LoginExpiredBadge";
import TextWithTooltip from "@components/ui/TextWithTooltip";
import { getOperatingSystem } from "@hooks/useOperatingSystem";
import useRedirect from "@hooks/useRedirect";
import { IconCloudLock, IconInfoCircle } from "@tabler/icons-react";
import useFetchApi from "@utils/api";
import dayjs from "dayjs";
@@ -53,22 +55,22 @@ import PeerProvider, { usePeer } from "@/contexts/PeerProvider";
import RoutesProvider from "@/contexts/RoutesProvider";
import { useLoggedInUser } from "@/contexts/UsersProvider";
import { useHasChanges } from "@/hooks/useHasChanges";
import { getOperatingSystem } from "@/hooks/useOperatingSystem";
import { OperatingSystem } from "@/interfaces/OperatingSystem";
import type { Peer } from "@/interfaces/Peer";
import PageContainer from "@/layouts/PageContainer";
import { AddExitNodeButton } from "@/modules/exit-node/AddExitNodeButton";
import { useHasExitNodes } from "@/modules/exit-node/useHasExitNodes";
import useGroupHelper from "@/modules/groups/useGroupHelper";
import AddRouteDropdownButton from "@/modules/peer/AddRouteDropdownButton";
import PeerRoutesTable from "@/modules/peer/PeerRoutesTable";
import { AccessiblePeersSection } from "@/modules/peer/AccessiblePeersSection";
import { PeerNetworkRoutesSection } from "@/modules/peer/PeerNetworkRoutesSection";
export default function PeerPage() {
const queryParameter = useSearchParams();
const peerId = queryParameter.get("id");
const { data: peer } = useFetchApi<Peer>("/peers/" + peerId);
return peer ? (
<PeerProvider peer={peer}>
const { data: peer, isLoading } = useFetchApi<Peer>("/peers/" + peerId, true);
useRedirect("/peers", false, !peerId);
return peer && !isLoading ? (
<PeerProvider peer={peer} key={peerId}>
<PeerOverview />
</PeerProvider>
) : (
@@ -129,7 +131,6 @@ function PeerOverview() {
};
const { isUser } = useLoggedInUser();
const hasExitNodes = useHasExitNodes(peer);
return (
<PageContainer>
@@ -323,6 +324,7 @@ function PeerOverview() {
disabled={isUser}
onChange={setSelectedGroups}
values={selectedGroups}
hideAllGroup={true}
peer={peer}
/>
</FullTooltip>
@@ -331,30 +333,19 @@ function PeerOverview() {
</div>
</div>
<Separator />
{isLinux && !isUser ? (
<div className={"px-8 py-6"}>
<div className={"max-w-6xl"}>
<div className={"flex justify-between items-center"}>
<div>
<h2>Network Routes</h2>
<Paragraph>
Access other networks without installing NetBird on every
resource.
</Paragraph>
</div>
<div className={"inline-flex gap-4 justify-end"}>
<div className={"gap-4 flex"}>
<AddExitNodeButton peer={peer} firstTime={!hasExitNodes} />
<AddRouteDropdownButton />
</div>
</div>
</div>
<PeerRoutesTable peer={peer} />
</div>
</div>
<>
<Separator />
<PeerNetworkRoutesSection peer={peer} />
</>
) : null}
{peer?.id && (
<>
<Separator />
<AccessiblePeersSection peerID={peer.id} />
</>
)}
</RoutesProvider>
</PageContainer>
);

View File

@@ -4,13 +4,12 @@ import Breadcrumbs from "@components/Breadcrumbs";
import InlineLink from "@components/InlineLink";
import Paragraph from "@components/Paragraph";
import SkeletonTable from "@components/skeletons/SkeletonTable";
import useFetchApi from "@utils/api";
import { usePortalElement } from "@hooks/usePortalElement";
import { ExternalLinkIcon } from "lucide-react";
import React, { lazy, Suspense, useEffect } from "react";
import React, { lazy, Suspense } from "react";
import PeerIcon from "@/assets/icons/PeerIcon";
import { useGroups } from "@/contexts/GroupsProvider";
import PeersProvider, { usePeers } from "@/contexts/PeersProvider";
import { useLoggedInUser, useUsers } from "@/contexts/UsersProvider";
import { Peer } from "@/interfaces/Peer";
import PageContainer from "@/layouts/PageContainer";
import { SetupModalContent } from "@/modules/setup-netbird-modal/SetupModal";
@@ -21,24 +20,22 @@ export default function Peers() {
return (
<PageContainer>
{permission?.dashboard_view === "blocked" ? (
{permission.dashboard_view === "blocked" ? (
<PeersBlockedView />
) : (
<PeersView />
<PeersProvider>
<PeersView />
</PeersProvider>
)}
</PageContainer>
);
}
function PeersView() {
const { data: peers, isLoading } = useFetchApi<Peer[]>("/peers");
const { peers, isLoading } = usePeers();
const { users } = useUsers();
const { refresh } = useGroups();
useEffect(() => {
refresh();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const { ref: headingRef, portalTarget } =
usePortalElement<HTMLHeadingElement>();
const peersWithUser = peers?.map((peer) => {
if (!users) return peer;
@@ -58,7 +55,7 @@ function PeersView() {
icon={<PeerIcon size={13} />}
/>
</Breadcrumbs>
<h1>{peers && peers.length > 1 ? `${peers.length} Peers` : "Peers"}</h1>
<h1 ref={headingRef}>Peers</h1>
<Paragraph>
A list of all machines and devices connected to your private network.
Use this view to manage peers.
@@ -76,7 +73,11 @@ function PeersView() {
</Paragraph>
</div>
<Suspense fallback={<SkeletonTable />}>
<PeersTable isLoading={isLoading} peers={peersWithUser} />
<PeersTable
isLoading={isLoading}
peers={peersWithUser}
headingTarget={portalTarget}
/>
</Suspense>
</>
);

View File

@@ -5,6 +5,7 @@ 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 useFetchApi from "@utils/api";
import { ExternalLinkIcon, ShieldCheck } from "lucide-react";
import React, { lazy, Suspense } from "react";
@@ -21,6 +22,9 @@ export default function PostureChecksPage() {
const { data: postureChecks, isLoading } =
useFetchApi<PostureCheck[]>("/posture-checks");
const { ref: headingRef, portalTarget } =
usePortalElement<HTMLHeadingElement>();
return (
<PageContainer>
<GroupsProvider>
@@ -38,17 +42,16 @@ export default function PostureChecksPage() {
icon={<ShieldCheck size={15} />}
/>
</Breadcrumbs>
<h1>
{postureChecks && postureChecks.length > 1
? `${postureChecks.length} Posture Checks`
: "Posture Checks"}
</h1>
<h1 ref={headingRef}>Posture Checks</h1>
<Paragraph>
Use posture checks to further restrict access in your network.
</Paragraph>
<Paragraph>
Learn more about
<InlineLink href={"https://docs.netbird.io/how-to/manage-posture-checks"} target={"_blank"}>
<InlineLink
href={"https://docs.netbird.io/how-to/manage-posture-checks"}
target={"_blank"}
>
Posture Checks
<ExternalLinkIcon size={12} />
</InlineLink>
@@ -60,6 +63,7 @@ export default function PostureChecksPage() {
<PoliciesProvider>
<Suspense fallback={<SkeletonTable />}>
<PostureCheckTable
headingTarget={portalTarget}
isLoading={isLoading}
postureChecks={postureChecks}
/>

View File

@@ -5,6 +5,7 @@ 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 useFetchApi from "@utils/api";
import { ExternalLinkIcon } from "lucide-react";
import React, { lazy, Suspense, useMemo } from "react";
@@ -38,6 +39,9 @@ export default function SetupKeys() {
});
}, [setupKeys, groups]);
const { ref: headingRef, portalTarget } =
usePortalElement<HTMLHeadingElement>();
return (
<PageContainer>
<div className={"p-default py-6"}>
@@ -48,11 +52,7 @@ export default function SetupKeys() {
icon={<SetupKeysIcon size={13} />}
/>
</Breadcrumbs>
<h1>
{setupKeys && setupKeys.length > 1
? `${setupKeys.length} Setup Keys`
: "Setup Keys"}
</h1>
<h1 ref={headingRef}>Setup Keys</h1>
<Paragraph>
Setup keys are pre-authentication keys that allow to register new
machines in your network.
@@ -74,6 +74,7 @@ export default function SetupKeys() {
<RestrictedAccess page={"Setup Keys"}>
<Suspense fallback={<SkeletonTable />}>
<SetupKeysTable
headingTarget={portalTarget}
setupKeys={setupKeysWithGroups}
isLoading={isLoading}
/>

View File

@@ -5,6 +5,7 @@ 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 { IconSettings2 } from "@tabler/icons-react";
import useFetchApi from "@utils/api";
import { ExternalLinkIcon } from "lucide-react";
@@ -22,6 +23,9 @@ export default function ServiceUsers() {
"/users?service_user=true",
);
const { ref: headingRef, portalTarget } =
usePortalElement<HTMLHeadingElement>();
return (
<PageContainer>
<div className={"p-default py-6"}>
@@ -38,11 +42,7 @@ export default function ServiceUsers() {
icon={<IconSettings2 size={17} />}
/>
</Breadcrumbs>
<h1>
{users && users.length > 1
? `${users.length} Service Users`
: "Service Users"}
</h1>
<h1 ref={headingRef}>Service Users</h1>
<Paragraph>
Use service users to create API tokens and avoid losing automated
access.
@@ -61,7 +61,11 @@ export default function ServiceUsers() {
</div>
<RestrictedAccess page={"Service Users"}>
<Suspense fallback={<SkeletonTable />}>
<ServiceUsersTable users={users} isLoading={isLoading} />
<ServiceUsersTable
users={users}
isLoading={isLoading}
headingTarget={portalTarget}
/>
</Suspense>
</RestrictedAccess>
</PageContainer>

View File

@@ -10,6 +10,7 @@ import Paragraph from "@components/Paragraph";
import { PeerGroupSelector } from "@components/PeerGroupSelector";
import Separator from "@components/Separator";
import FullScreenLoading from "@components/ui/FullScreenLoading";
import useRedirect from "@hooks/useRedirect";
import { IconCirclePlus, IconSettings2 } from "@tabler/icons-react";
import useFetchApi, { useApiCall } from "@utils/api";
import { generateColorFromString } from "@utils/helpers";
@@ -42,6 +43,8 @@ export default function UserPage() {
return users?.find((u) => u.id === userId);
}, [users, userId]);
useRedirect("/team/users", false, !userId);
return !isLoading && user ? (
<UserOverview user={user} />
) : (
@@ -184,7 +187,7 @@ function UserOverview({ user }: Props) {
)}
</div>
<div className={"flex gap-10 w-full mt-8 max-w-6xl"}>
<div className={"flex gap-10 w-full mt-8 max-w-6xl items-start"}>
<UserInformationCard user={user} />
<div className={"flex flex-col gap-8 w-1/2 "}>
{!user.is_service_user && (
@@ -197,6 +200,7 @@ function UserOverview({ user }: Props) {
disabled={isUser}
onChange={setSelectedGroups}
values={selectedGroups}
hideAllGroup={true}
/>
</div>
)}
@@ -211,6 +215,8 @@ function UserOverview({ user }: Props) {
<UserRoleSelector
value={role}
onChange={setRole}
hideOwner={user.is_service_user}
currentUser={user}
disabled={
isLoggedInUser ||
!isOwnerOrAdmin ||
@@ -298,15 +304,18 @@ function UserInformationCard({ user }: { user: User }) {
{!isServiceUser && (
<>
<Card.ListItem
label={
<>
<Ban size={16} />
Block User
</>
}
value={<UserBlockCell user={user} isUserPage={true} />}
/>
{!user.is_current && user.role != Role.Owner && (
<Card.ListItem
label={
<>
<Ban size={16} />
Block User
</>
}
value={<UserBlockCell user={user} isUserPage={true} />}
/>
)}
<Card.ListItem
label={
<>

View File

@@ -5,6 +5,7 @@ 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 useFetchApi from "@utils/api";
import { ExternalLinkIcon, User2 } from "lucide-react";
import React, { lazy, Suspense } from "react";
@@ -19,6 +20,9 @@ export default function TeamUsers() {
"/users?service_user=false",
);
const { ref: headingRef, portalTarget } =
usePortalElement<HTMLHeadingElement>();
return (
<PageContainer>
<div className={"p-default py-6"}>
@@ -35,7 +39,7 @@ export default function TeamUsers() {
icon={<User2 size={16} />}
/>
</Breadcrumbs>
<h1>{users && users.length > 1 ? `${users.length} Users` : "Users"}</h1>
<h1 ref={headingRef}>Users</h1>
<Paragraph>
Manage users and their permissions. Same-domain email users are added
automatically on first sign-in.
@@ -54,7 +58,11 @@ export default function TeamUsers() {
</div>
<RestrictedAccess page={"Users"}>
<Suspense fallback={<SkeletonTable />}>
<UsersTable users={users} isLoading={isLoading} />
<UsersTable
users={users}
isLoading={isLoading}
headingTarget={portalTarget}
/>
</Suspense>
</RestrictedAccess>
</PageContainer>

View File

@@ -68,4 +68,9 @@ p {
.stepper-bg-variant .step-circle {
@apply !border-[#1d2024];
}
.webkit-scroll{
-webkit-overflow-scrolling: touch;
-webkit-transform: translate3d(0, 0, 0);
}

View File

@@ -1,14 +1,40 @@
"use client";
import FullScreenLoading from "@components/ui/FullScreenLoading";
import { useRouter } from "next/navigation";
import { useEffect } from "react";
import { useLocalStorage } from "@hooks/useLocalStorage";
import { useRedirect } from "@hooks/useRedirect";
import { useEffect, useState } from "react";
type Props = {
url: string;
queryParams?: string;
};
export default function NotFound() {
const router = useRouter();
useEffect(() => {
router.push("/peers");
});
const [mounted, setMounted] = useState(false);
const [tempQueryParams, setTempQueryParams] = useLocalStorage(
"netbird-query-params",
"",
);
const [queryParams, setQueryParams] = useState("");
return <FullScreenLoading />;
useEffect(() => {
setQueryParams(tempQueryParams);
setTempQueryParams("");
setMounted(true);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return mounted ? (
<Redirect
url={window?.location?.pathname || "/"}
queryParams={queryParams}
/>
) : (
<FullScreenLoading />
);
}
const Redirect = ({ url, queryParams }: Props) => {
useRedirect("/peers" + (queryParams && `?${queryParams}`));
return <FullScreenLoading />;
};

View File

@@ -16,6 +16,8 @@ export default function CircleIcon({
return (
<span
style={{ width: size + "px", height: size + "px" }}
data-cy="circle-icon"
data-cy-status={active ? "active" : "inactive"}
className={cn(
"rounded-full",
active

View File

@@ -5,9 +5,17 @@ import NetBirdLogo from "@/assets/netbird.svg";
type Props = {
size?: number;
className?: string;
};
function NetBirdIcon({ size = 16 }: Props) {
return <Image src={NetBirdLogo} alt={"Netbird Icon"} width={size} />;
function NetBirdIcon({ size = 16, className }: Props) {
return (
<Image
src={NetBirdLogo}
alt={"Netbird Icon"}
width={size}
className={className}
/>
);
}
export default memo(NetBirdIcon);

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

View File

@@ -1,10 +1,10 @@
"use client";
import { OidcProvider } from "@axa-fr/react-oidc";
import {
AuthorityConfiguration,
OidcConfiguration,
} from "@axa-fr/react-oidc/dist/vanilla/oidc";
OidcProvider,
} from "@axa-fr/react-oidc";
import FullScreenLoading from "@components/ui/FullScreenLoading";
import { useLocalStorage } from "@hooks/useLocalStorage";
import { useRedirect } from "@hooks/useRedirect";
@@ -30,7 +30,7 @@ const auth0AuthorityConfig: AuthorityConfiguration = {
revocation_endpoint: new URL("oauth/revoke", config.authority).href,
end_session_endpoint: new URL("v2/logout", config.authority).href,
userinfo_endpoint: new URL("userinfo", config.authority).href,
//issuer: new URL("", config.authority).href,
issuer: new URL("", config.authority).href,
};
const onEvent = (configurationName: any, eventName: any, data: any) => {

View File

@@ -11,9 +11,17 @@ export const SecureProvider = ({ children }: Props) => {
const currentPath = usePathname();
useEffect(() => {
let timeout: NodeJS.Timeout | undefined = undefined;
if (!isAuthenticated) {
login(currentPath);
timeout = setTimeout(async () => {
if (!isAuthenticated) {
await login(currentPath);
}
}, 1500);
}
return () => {
clearTimeout(timeout);
};
}, [currentPath, isAuthenticated, login]);
return (

View File

@@ -2,7 +2,7 @@ import { cn } from "@utils/helpers";
import { cva, VariantProps } from "class-variance-authority";
import * as React from "react";
type BadgeVariants = VariantProps<typeof variants>;
export type BadgeVariants = VariantProps<typeof variants>;
interface Props extends React.HTMLAttributes<HTMLDivElement>, BadgeVariants {
children: React.ReactNode;
@@ -22,6 +22,9 @@ const variants = cva("", {
purple: ["bg-purple-950/50 border-purple-500 border text-purple-500"],
yellow: ["bg-yellow-950 border-yellow-500 border text-yellow-400"],
gray: ["bg-nb-gray-930/60 border-nb-gray-800/40 text-nb-gray-300 border"],
grayer: [
"bg-nb-gray-900/40 border-nb-gray-800/40 text-nb-gray-300 border",
],
"gray-ghost": [
"bg-nb-gray-900 border-nb-gray-800 text-nb-gray-300 border border-nb-gray-800/50",
],
@@ -37,6 +40,7 @@ const variants = cva("", {
"blue-darker": ["hover:bg-sky-800"],
red: ["hover:bg-red-950/40"],
gray: ["hover:bg-nb-gray-900"],
grayer: ["hover:bg-nb-gray-900"],
"gray-ghost": ["hover:bg-nb-gray-900"],
green: ["hover:bg-green-950/50"],
netbird: ["hover:bg-netbird-950/50"],
@@ -50,7 +54,7 @@ export default function Badge({
variant = "blue",
useHover = false,
...props
}: Props) {
}: Readonly<Props>) {
return (
<div
className={cn(

View File

@@ -49,6 +49,10 @@ export const buttonVariants = cva(
"bg-white hover:text-black focus:ring-zinc-200/50 hover:bg-gray-100 border-gray-200 text-gray-900",
"dark:focus:ring-zinc-800/50 dark:bg-white dark:text-gray-800 dark:border-gray-700/40 dark:hover:bg-neutral-200 disabled:dark:bg-nb-gray-920 disabled:dark:text-nb-gray-300",
],
white: [
"focus:ring-white/50 bg-white text-gray-800 border-white outline-none hover:bg-neutral-200 disabled:dark:bg-nb-gray-920 disabled:dark:text-nb-gray-300",
"disabled:dark:bg-nb-gray-900 disabled:dark:text-nb-gray-300 disabled:dark:border-nb-gray-900",
],
outline: [
"bg-white hover:text-black focus:ring-zinc-200/50 hover:bg-gray-100 border-gray-200 text-gray-900",
"dark:focus:ring-zinc-800/50 dark:bg-transparent dark:text-netbird dark:border-netbird dark:hover:bg-nb-gray-900/30",
@@ -69,6 +73,7 @@ export const buttonVariants = cva(
},
size: {
xs: "text-xs py-2 px-4",
xs2: "text-[0.78rem] py-2 px-4",
sm: "text-sm py-2.5 px-4",
md: "text-md py-2.5 px-4",
lg: "text-lg py-2.5 px-4",

View File

@@ -5,14 +5,16 @@ import React, { forwardRef } from "react";
type Props = {
children: React.ReactNode;
disabled?: boolean;
className?: string;
};
function ButtonGroup({ children, disabled }: Props) {
function ButtonGroup({ children, disabled, className }: Props) {
return (
<div
className={cn(
"rounded-lg border-[1px] dark:border-nb-gray-900 border-neutral-200 overflow-hidden flex items-center justify-center shrink-0 border-separate",
disabled ? "opacity-100 !border-nb-gray-900/20" : "",
className,
)}
>
{children}
@@ -21,7 +23,10 @@ function ButtonGroup({ children, disabled }: Props) {
}
const ButtonGroupButton = forwardRef(
({ ...props }: ButtonProps, ref: React.ForwardedRef<HTMLButtonElement>) => {
(
{ className, ...props }: ButtonProps,
ref: React.ForwardedRef<HTMLButtonElement>,
) => {
return (
<Button
ref={ref}
@@ -31,6 +36,7 @@ const ButtonGroupButton = forwardRef(
className={cn(
"first:border-l-0 last:border-r-0 border-t-0 border-b-0 h-[41px]",
"!py-2.5 !px-4",
className,
)}
/>
);

View File

@@ -2,19 +2,41 @@
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
import { cn } from "@utils/helpers";
import { cva, VariantProps } from "class-variance-authority";
import { Check } from "lucide-react";
import * as React from "react";
type CheckboxVariants = VariantProps<typeof variants>;
const variants = cva([], {
variants: {
variant: {
default: [
"dark:data-[state=unchecked]:bg-nb-gray-950 dark:border-nb-gray-900 dark:ring-offset-neutral-950 dark:focus-visible:ring-neutral-300 ",
"dark:data-[state=checked]:bg-netbird dark:data-[state=checked]:text-neutral-50",
],
tableCell: [
"dark:data-[state=unchecked]:bg-nb-gray-920 dark:border-nb-gray-800 dark:ring-offset-neutral-950 dark:focus-visible:ring-neutral-300 ",
"dark:data-[state=checked]:bg-netbird dark:data-[state=checked]:text-neutral-50",
],
},
},
});
const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
>(({ className, ...props }, ref) => (
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root> &
CheckboxVariants
>(({ className, variant = "default", ...props }, ref) => (
<div className={"h-5 w-5"}>
<CheckboxPrimitive.Root
ref={ref}
className={cn(
"dark:data-[state=unchecked]:bg-nb-gray-950",
"peer h-5 w-5 shrink-0 rounded-[4px] border border-neutral-900 ring-offset-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-neutral-950 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-neutral-900 data-[state=checked]:text-neutral-50 dark:border-nb-gray-900 dark:ring-offset-neutral-950 dark:focus-visible:ring-neutral-300 dark:data-[state=checked]:bg-netbird dark:data-[state=checked]:text-neutral-50",
variants({ variant }),
"border-neutral-900",
"peer h-5 w-5 shrink-0 rounded-[4px] border",
"ring-offset-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-neutral-950 focus-visible:ring-offset-2",
"disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-neutral-900 data-[state=checked]:text-neutral-50 ",
className,
)}
{...props}

View File

@@ -117,7 +117,7 @@ const CommandItem = React.forwardRef<
ref={ref}
className={cn(
"text-xs flex justify-between py-2 px-3 cursor-pointer items-center rounded-md",
"bg-transparent dark:aria-selected:bg-nb-gray-800/50",
"bg-transparent dark:aria-selected:bg-nb-gray-800/20",
className,
)}
{...props}

View File

@@ -6,7 +6,7 @@ import { Calendar } from "@components/ui/Calendar";
import { cn } from "@utils/helpers";
import dayjs from "dayjs";
import { Calendar as CalendarIcon } from "lucide-react";
import React from "react";
import React, { useMemo, useState } from "react";
import { DateRange } from "react-day-picker";
interface Props {
@@ -15,38 +15,145 @@ interface Props {
className?: string;
}
const defaultRanges = {
today: {
from: dayjs().startOf("day").toDate(),
to: dayjs().endOf("day").toDate(),
},
yesterday: {
from: dayjs().subtract(1, "day").startOf("day").toDate(),
to: dayjs().subtract(1, "day").endOf("day").toDate(),
},
last14Days: {
from: dayjs().subtract(14, "day").startOf("day").toDate(),
to: dayjs().endOf("day").toDate(),
},
lastMonth: {
from: dayjs().subtract(1, "month").startOf("day").toDate(),
to: dayjs().endOf("day").toDate(),
},
allTime: {
from: dayjs("1970-01-01").startOf("day").toDate(),
to: dayjs().endOf("day").toDate(),
},
};
const isEqualDateRange = (a: DateRange | undefined, b: DateRange) => {
if (!a) return false;
const aFromDay = dayjs(a.from).format("YYYY-MM-DD");
const aToDay = dayjs(a.to).format("YYYY-MM-DD");
const bFromDay = dayjs(b.from).format("YYYY-MM-DD");
const bToDay = dayjs(b.to).format("YYYY-MM-DD");
return aFromDay === bFromDay && aToDay === bToDay;
};
export function DatePickerWithRange({ className, value, onChange }: Props) {
const isActive = useMemo(() => {
return {
today: isEqualDateRange(value, defaultRanges.today),
yesterday: isEqualDateRange(value, defaultRanges.yesterday),
last14Days: isEqualDateRange(value, defaultRanges.last14Days),
lastMonth: isEqualDateRange(value, defaultRanges.lastMonth),
allTime: isEqualDateRange(value, defaultRanges.allTime),
};
}, [value]);
const displayDateValue = useMemo(() => {
if (!value) return "Select date range";
if (isActive.allTime) return "All Time";
if (isActive.lastMonth) return "Last Month";
if (isActive.last14Days) return "Last 14 Days";
if (isActive.yesterday) return "Yesterday";
if (isActive.today) return "Today";
if (!value.to) return dayjs(value.from).format("MMM DD, YYYY").toString();
return `${dayjs(value.from).format("MMM DD, YYYY")} - ${dayjs(
value.to,
).format("MMM DD, YYYY")}`;
}, [value, isActive]);
const [calendarOpen, setCalendarOpen] = useState(false);
const updateRangeAndClose = (range: DateRange) => {
setCalendarOpen(false);
onChange?.(range);
};
return (
<div className={cn("grid gap-2", className)}>
<Popover>
<Popover open={calendarOpen} onOpenChange={setCalendarOpen}>
<PopoverTrigger asChild>
<Button
id="date"
variant={"secondary"}
className={cn("w-[260px] justify-start text-left font-normal")}
className={cn("max-w-[260px] justify-start text-left font-normal")}
>
<CalendarIcon size={16} />
{value?.from ? (
value.to ? (
<>
{dayjs(value.from).format("MMM DD, YYYY")} -{" "}
{dayjs(value.to).format("MMM DD, YYYY")}
</>
) : (
<>{dayjs(value.from, "LLL dd, y").toString()}</>
)
) : (
<span>Pick your date range</span>
)}
<CalendarIcon size={16} className={"shrink-0"} />
{displayDateValue}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start" sideOffset={10}>
<div
className={
"px-4 py-3 flex flex-wrap gap-2 max-w-[280px] sm:max-w-none border-b border-nb-gray-800 items-center justify-between w-full"
}
>
<div>
<CalendarButton
label={
<>
<CalendarIcon size={14} className={"shrink-0"} />
All Time
</>
}
active={isActive.allTime}
onClick={() => updateRangeAndClose(defaultRanges.allTime)}
/>
</div>
<div className={"flex gap-2 flex-wrap"}>
<CalendarButton
label={"Last Month"}
active={isActive.lastMonth}
onClick={() => updateRangeAndClose(defaultRanges.lastMonth)}
/>
<CalendarButton
label={"Last 14 Days"}
active={isActive.last14Days}
onClick={() => updateRangeAndClose(defaultRanges.last14Days)}
/>
<CalendarButton
label={"Yesterday"}
active={isActive.yesterday}
onClick={() => updateRangeAndClose(defaultRanges.yesterday)}
/>
<CalendarButton
label={"Today"}
active={isActive.today}
onClick={() => updateRangeAndClose(defaultRanges.today)}
/>
</div>
</div>
<Calendar
initialFocus
mode="range"
defaultMonth={value?.from}
selected={value}
onSelect={onChange}
onSelect={(range) => {
let from =
range && range.from
? dayjs(range.from).startOf("day").toDate()
: undefined;
let to =
range && range.to
? dayjs(range.to).endOf("day").toDate()
: undefined;
if (!from && !to) {
onChange?.(undefined);
return;
}
onChange?.({ from, to });
}}
numberOfMonths={2}
/>
</PopoverContent>
@@ -54,3 +161,25 @@ export function DatePickerWithRange({ className, value, onChange }: Props) {
</div>
);
}
type CalendarButtonProps = {
label: string | React.ReactNode;
onClick: () => void;
active?: boolean;
};
function CalendarButton({ label, onClick, active }: CalendarButtonProps) {
return (
<button
className={cn(
"py-1.5 leading-none px-2.5 rounded-md text-center text-xs transition-all flex gap-2",
active
? "bg-nb-gray-800 text-white"
: "bg-transparent text-nb-gray-300 hover:bg-nb-gray-900 hover:text-nb-gray-100",
)}
onClick={onClick}
>
{label}
</button>
);
}

View File

@@ -0,0 +1,15 @@
"use client";
import { useEffect } from "react";
export const DisableDarkReader = () => {
useEffect(() => {
try {
const lock = document.createElement("meta");
lock.name = "darkreader-lock";
document.head.appendChild(lock);
} catch (e) {}
}, []);
return null;
};

View File

@@ -0,0 +1,11 @@
import * as React from "react";
type Props = {
children: React.ReactNode;
};
export const DropdownInfoText = ({ children }: Props) => {
return (
<div className={"text-center pt-2 mb-6 text-nb-gray-400"}>{children}</div>
);
};

View File

@@ -0,0 +1,48 @@
import { IconArrowBack } from "@tabler/icons-react";
import { cn } from "@utils/helpers";
import { SearchIcon } from "lucide-react";
import * as React from "react";
import { forwardRef } from "react";
type Props = {
value: string;
onChange: (value: string) => void;
placeholder?: string;
};
export const DropdownInput = forwardRef<HTMLInputElement, Props>(
({ value, onChange, placeholder = "Search..." }, ref) => {
return (
<div className={"relative w-full"}>
<input
ref={ref}
className={cn(
"min-h-[42px] w-full relative",
"border-b-0 border-t-0 border-r-0 border-l-0 border-neutral-200 dark:border-nb-gray-700 items-center",
"bg-transparent text-sm outline-none focus-visible:outline-none ring-0 focus-visible:ring-0",
"dark:placeholder:text-nb-gray-400 font-light placeholder:text-neutral-500 pl-10",
)}
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
/>
<div className={"absolute left-0 top-0 h-full flex items-center pl-4"}>
<div className={"flex items-center"}>
<SearchIcon size={14} />
</div>
</div>
<div className={"absolute right-0 top-0 h-full flex items-center pr-4"}>
<div
className={
"flex items-center bg-nb-gray-800 py-1 px-1.5 rounded-[4px] border border-nb-gray-500"
}
>
<IconArrowBack size={10} />
</div>
</div>
</div>
);
},
);
DropdownInput.displayName = "DropdownInput";

View File

@@ -31,7 +31,7 @@ export default function FancyToggleSwitch({
value
? "border-nb-gray-800 bg-nb-gray-900/70"
: "border-nb-gray-800 bg-nb-gray-900/30 hover:bg-nb-gray-900/40",
disabled && "opacity-30 pointer-events-none",
disabled && "opacity-50 pointer-events-none",
)}
>
<div className={"flex justify-between gap-10 "}>

View File

@@ -4,6 +4,7 @@ import {
TooltipProvider,
TooltipTrigger,
} from "@components/Tooltip";
import { TooltipProps } from "@radix-ui/react-tooltip";
import { cn } from "@utils/helpers";
import React, { useState } from "react";
@@ -19,7 +20,9 @@ type Props = {
align?: "end" | "center" | "start";
side?: "top" | "bottom" | "left" | "right";
keepOpen?: boolean;
};
customOpen?: boolean;
customOnOpenChange?: React.Dispatch<React.SetStateAction<boolean>>;
} & TooltipProps;
export default function FullTooltip({
children,
content,
@@ -32,6 +35,8 @@ export default function FullTooltip({
align = "center",
side = "top",
keepOpen = false,
customOpen,
customOnOpenChange,
}: Props) {
const [open, setOpen] = useState(!!keepOpen);
@@ -42,7 +47,11 @@ export default function FullTooltip({
return !disabled ? (
<TooltipProvider disableHoverableContent={!interactive}>
<Tooltip delayDuration={1} open={open} onOpenChange={handleOpen}>
<Tooltip
delayDuration={1}
open={customOpen || open}
onOpenChange={customOnOpenChange || handleOpen}
>
{children && (
<TooltipTrigger asChild={true}>
{hoverButton ? (

View File

@@ -13,6 +13,7 @@ export interface InputProps
icon?: React.ReactNode;
error?: string;
errorTooltip?: boolean;
errorTooltipPosition?: "top" | "top-right";
}
const inputVariants = cva("", {
@@ -49,6 +50,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
maxWidthClass = "",
error,
errorTooltip = false,
errorTooltipPosition = "top",
...props
},
ref,
@@ -72,9 +74,10 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
)}
<div
className={
"absolute left-0 top-0 h-full flex items-center text-xs dark:text-nb-gray-300 pl-3 leading-[0]"
}
className={cn(
"absolute left-0 top-0 h-full flex items-center text-xs dark:text-nb-gray-300 pl-3 leading-[0]",
props.disabled && "opacity-30",
)}
>
{icon}
</div>
@@ -97,17 +100,21 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
/>
<div
className={
"absolute right-0 top-0 h-full flex items-center text-xs dark:text-nb-gray-300 pr-4 leading-[0]"
}
className={cn(
"absolute right-0 top-0 h-full flex items-center text-xs dark:text-nb-gray-300 pr-4 leading-[0] select-none",
props.disabled && "opacity-30",
)}
>
{customSuffix}
</div>
{error && errorTooltip && (
<div
className={
"absolute right-0 top-2 h-[0px] w-full flex items-center pr-3 justify-center"
}
className={cn(
errorTooltipPosition == "top" &&
"absolute right-0 top-2 h-[0px] w-full flex items-center pr-3 justify-center",
errorTooltipPosition == "top-right" &&
"absolute -right-6 top-2 h-[0px] w-full flex items-center pr-3 justify-end",
)}
>
<FullTooltip
content={
@@ -120,7 +127,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
</div>
}
interactive={false}
align={"center"}
align={errorTooltipPosition == "top" ? "center" : "end"}
side={"top"}
keepOpen={true}
>

View File

@@ -1,5 +1,7 @@
import { CommandItem } from "@components/Command";
import FullTooltip from "@components/FullTooltip";
import { Popover, PopoverContent, PopoverTrigger } from "@components/Popover";
import TextWithTooltip from "@components/ui/TextWithTooltip";
import { IconArrowBack } from "@tabler/icons-react";
import useFetchApi from "@utils/api";
import { cn } from "@utils/helpers";
@@ -62,8 +64,13 @@ export function NetworkRouteSelector({
const isSearching = search.length > 0;
const found =
dropdownOptions.filter((item) => {
const hasDomains = item?.domains ? item.domains.length > 0 : false;
const domains =
hasDomains && item?.domains ? item?.domains.join(" ") : "";
return (
item.network_id.includes(search) || item.network.includes(search)
item.network_id.includes(search) ||
item.network?.includes(search) ||
domains.includes(search)
);
}).length > 0;
return isSearching && !found;
@@ -102,12 +109,12 @@ export function NetworkRouteSelector({
{value ? (
<div
className={
"flex items-center justify-between text-sm text-white w-full pr-4 pl-1"
"flex items-center justify-between text-sm text-white w-full pr-4 pl-1 gap-2"
}
>
<div className={"flex items-center gap-2.5 text-sm"}>
<NetworkRoutesIcon size={16} />
{value.network_id}
<TextWithTooltip text={value.network_id} maxChars={15} />
</div>
<div
@@ -117,6 +124,7 @@ export function NetworkRouteSelector({
>
{value.network}
</div>
<DomainList domains={value?.domains} />
</div>
) : (
<span>Select an existing network...</span>
@@ -208,15 +216,23 @@ export function NetworkRouteSelector({
return (
<CommandItem
key={option.network + option.network_id}
value={option.network + option.network_id}
value={
option.network +
option.network_id +
option?.domains?.join(", ")
}
onSelect={() => {
togglePeer(option);
setOpen(false);
}}
className={"gap-2"}
>
<div className={"flex items-center gap-2.5 text-sm"}>
<NetworkRoutesIcon size={14} />
{option.network_id}
<TextWithTooltip
text={option.network_id}
maxChars={15}
/>
</div>
<div
@@ -226,6 +242,7 @@ export function NetworkRouteSelector({
>
{option.network}
</div>
<DomainList domains={option?.domains} />
</CommandItem>
);
})}
@@ -238,3 +255,23 @@ export function NetworkRouteSelector({
</Popover>
);
}
function DomainList({ domains }: { domains?: string[] }) {
const firstDomain = domains ? domains[0] : "";
return (
domains &&
domains.length > 0 && (
<FullTooltip
content={<div className={"text-xs max-w-sm"}>{domains.join(", ")}</div>}
>
<div
className={
"text-xs text-nb-gray-300 block min-w-0 truncate max-w-[180px]"
}
>
{firstDomain} {domains.length > 1 && "+" + (domains.length - 1)}
</div>
</FullTooltip>
)
);
}

View File

@@ -16,6 +16,7 @@ export interface NotifyProps<T> {
duration?: number;
icon?: React.ReactNode;
backgroundColor?: string;
preventSuccessToast?: boolean;
}
interface NotificationProps<T> extends NotifyProps<T> {
t: Toast;
@@ -29,12 +30,15 @@ export default function Notification<T>({
promise,
loadingMessage,
duration = 3500,
preventSuccessToast = false,
}: NotificationProps<T>) {
const [error, setError] = useState("");
const [loading, setLoading] = useState(!!promise);
const [toastDuration] = useState(duration);
const [preventSuccess, setPreventSuccess] = useState(false);
const closeToast = () => {
setTimeout(() => {
setLoading(false);
@@ -47,6 +51,7 @@ export default function Notification<T>({
if (promise) {
promise
.then(() => {
if (preventSuccessToast) setPreventSuccess(true);
setLoading(false);
closeToast();
})
@@ -66,7 +71,7 @@ export default function Notification<T>({
return (
<AnimatePresence>
{t.visible && (
{t.visible && !preventSuccess && (
<motion.div
initial={{ opacity: 1, y: -50 }}
animate={{ opacity: 1, y: 0 }}

View File

@@ -1,10 +1,13 @@
import Badge from "@components/Badge";
import { Checkbox } from "@components/Checkbox";
import { CommandItem } from "@components/Command";
import FullTooltip from "@components/FullTooltip";
import { Popover, PopoverContent, PopoverTrigger } from "@components/Popover";
import { ScrollArea } from "@components/ScrollArea";
import { AccessControlGroupCount } from "@components/ui/AccessControlGroupCount";
import GroupBadge from "@components/ui/GroupBadge";
import TextWithTooltip from "@components/ui/TextWithTooltip";
import GroupBadgeWithEditPeers from "@components/ui/GroupBadgeWithEditPeers";
import useSortedDropdownOptions from "@hooks/useSortedDropdownOptions";
import { IconArrowBack } from "@tabler/icons-react";
import { cn } from "@utils/helpers";
import { Command, CommandGroup, CommandInput, CommandList } from "cmdk";
@@ -29,6 +32,13 @@ interface MultiSelectProps {
max?: number;
disabled?: boolean;
popoverWidth?: "auto" | number;
hideAllGroup?: boolean;
showPeerCount?: boolean;
disableInlineRemoveGroup?: boolean;
saveGroupAssignments?: boolean;
showRoutes?: boolean;
disabledGroups?: Group[];
dataCy?: string;
}
export function PeerGroupSelector({
onChange,
@@ -37,8 +47,16 @@ export function PeerGroupSelector({
max,
disabled = false,
popoverWidth = "auto",
}: MultiSelectProps) {
const { groups, dropdownOptions, setDropdownOptions } = useGroups();
hideAllGroup = false,
showPeerCount = false,
disableInlineRemoveGroup = false,
saveGroupAssignments = true,
showRoutes = false,
disabledGroups,
dataCy = "group-selector-dropdown",
}: Readonly<MultiSelectProps>) {
const { groups, dropdownOptions, setDropdownOptions, addDropdownOptions } =
useGroups();
const searchRef = React.useRef<HTMLInputElement>(null);
const [inputRef, { width }] = useElementSize<HTMLButtonElement>();
const [search, setSearch] = useState("");
@@ -46,8 +64,19 @@ export function PeerGroupSelector({
// Update dropdown options when groups change
useEffect(() => {
if (!groups) return;
const sortedGroups = sortBy([...groups], "name") as Group[];
setDropdownOptions(unionBy(sortedGroups, dropdownOptions, "name"));
const sortedGroups = sortBy([...groups], "name");
const clientGroups = dropdownOptions.filter(
(group) => group.keepClientState,
);
let uniqueGroups = unionBy(sortedGroups, dropdownOptions, "name");
uniqueGroups = unionBy(clientGroups, uniqueGroups, "name");
uniqueGroups = hideAllGroup
? uniqueGroups.filter((group) => group.name !== "All")
: uniqueGroups;
setDropdownOptions(uniqueGroups);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [groups]);
@@ -66,14 +95,11 @@ export function PeerGroupSelector({
const option = dropdownOptions.find((option) => option.name == name);
const groupPeers: GroupPeer[] | undefined =
(group?.peers as GroupPeer[]) || [];
groupPeers &&
groupPeers.push({ id: peer?.id as string, name: peer?.name as string });
if (peer) groupPeers?.push({ id: peer?.id as string, name: peer?.name });
if (!group && !option) {
setDropdownOptions((previous) => [
...previous,
{ name: name, peers: groupPeers },
]);
addDropdownOptions([{ name: name, peers: groupPeers }]);
}
if (max == 1 && values.length == 1) {
@@ -100,17 +126,18 @@ export function PeerGroupSelector({
const isSearching = search.length > 0;
const groupDoesNotExist =
dropdownOptions.filter((item) => item.name == trim(search)).length == 0;
return isSearching && groupDoesNotExist;
const isAllGroup = search.toLowerCase() == "all";
return isSearching && groupDoesNotExist && !isAllGroup;
}, [search, dropdownOptions]);
const [open, setOpen] = useState(false);
const folderIcon = useMemo(() => {
return <FolderGit2 size={12} />;
return <FolderGit2 size={12} className={"shrink-0"} />;
}, []);
const peerIcon = useMemo(() => {
return <MonitorSmartphoneIcon size={14} />;
return <MonitorSmartphoneIcon size={14} className={"shrink-0"} />;
}, []);
const [slice, setSlice] = useState(10);
@@ -125,6 +152,18 @@ export function PeerGroupSelector({
}
}, [open, dropdownOptions]);
const onPeerAssignmentChange = (oldGroup: Group, newGroup: Group) => {
const filtered = values.filter((group) => group.name !== oldGroup.name);
const union = unionBy([newGroup], filtered, "name");
onChange(union);
};
const sortedDropdownOptions = useSortedDropdownOptions(
dropdownOptions,
values,
open,
);
return (
<Popover
open={open}
@@ -140,12 +179,13 @@ export function PeerGroupSelector({
<PopoverTrigger asChild>
<button
className={cn(
"min-h-[46px] w-full relative items-center",
"min-h-[46px] w-full relative items-center group",
"border border-neutral-200 dark:border-nb-gray-700 justify-between py-2 px-3",
"rounded-md bg-white text-sm dark:bg-nb-gray-900/40 flex dark:text-neutral-400/70 text-neutral-500 cursor-pointer hover:dark:bg-nb-gray-900/50",
"disabled:pointer-events-none disabled:opacity-30",
"disabled:pointer-events-none disabled:opacity-30 transition-all",
)}
disabled={disabled}
data-cy={dataCy}
ref={inputRef}
>
<div
@@ -153,18 +193,48 @@ export function PeerGroupSelector({
"flex items-center gap-2 border-nb-gray-700 flex-wrap h-full"
}
>
{values.map((group) => (
<GroupBadge
className={"py-[3px]"}
group={group}
key={group.name}
onClick={() => {
if (peer != undefined && group.name == "All") return; // Prevent removing the "All" group
toggleGroupByName(group.name);
}}
showX={peer != undefined ? group.name !== "All" : true}
/>
))}
{values.map((group) => {
return (
<div
key={group.name}
className={cn(
showPeerCount
? "flex gap-x-1 gap-y-2 items-center justify-between w-full"
: "",
)}
>
{showPeerCount ? (
<GroupBadgeWithEditPeers
className={"py-[3px]"}
group={group}
key={group.name}
showNewBadge={true}
onPeerAssignmentChange={onPeerAssignmentChange}
useSave={saveGroupAssignments}
/>
) : (
<GroupBadge
className={"py-[3px]"}
group={group}
key={group.name}
showNewBadge={true}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
if (disableInlineRemoveGroup) return;
if (peer != undefined && group.name == "All") return; // Prevent removing the "All" group
toggleGroupByName(group.name);
}}
showX={
peer != undefined
? group.name !== "All"
: !disableInlineRemoveGroup
}
/>
)}
</div>
);
})}
{values.length == 0 && (
<span className={"pl-1"}>Add or select group(s)...</span>
@@ -172,7 +242,10 @@ export function PeerGroupSelector({
</div>
<div className={"pl-2"}>
<ChevronsUpDown size={18} className={"shrink-0"} />
<ChevronsUpDown
size={18}
className={"shrink-0 group-hover:text-nb-gray-300 transition-all"}
/>
</div>
</button>
</PopoverTrigger>
@@ -198,11 +271,12 @@ export function PeerGroupSelector({
<CommandList className={"w-full"}>
<div className={"relative"}>
<CommandInput
data-cy={"group-search-input"}
className={cn(
"min-h-[42px] w-full relative",
"border-b-0 border-t-0 border-r-0 border-l-0 border-neutral-200 dark:border-nb-gray-700 items-center",
"bg-transparent text-sm outline-none focus-visible:outline-none ring-0 focus-visible:ring-0",
"dark:placeholder:text-neutral-500 font-light placeholder:text-neutral-500 pl-10",
"dark:placeholder:text-nb-gray-400 font-light placeholder:text-neutral-500 pl-10",
)}
ref={searchRef}
value={search}
@@ -237,9 +311,7 @@ export function PeerGroupSelector({
<CommandGroup>
<ScrollArea
className={
"max-h-[195px] overflow-y-auto flex flex-col gap-1 pl-2 py-2 pr-3"
}
className={"max-h-[195px] flex flex-col gap-1 pl-2 py-2 pr-3"}
>
{searchedGroupNotFound && (
<CommandItem
@@ -264,38 +336,64 @@ export function PeerGroupSelector({
</CommandItem>
)}
{dropdownOptions.slice(0, slice).map((option) => {
{sortedDropdownOptions.slice(0, slice).map((option) => {
const isSelected =
values.find((group) => group.name == option.name) !=
undefined;
return (
<CommandItem
key={option.name}
value={option.name + option.id}
onSelect={() => {
if (peer != undefined && option.name == "All") return; // Prevent removing the "All" group
toggleGroupByName(option.name);
searchRef.current?.focus();
}}
onClick={(e) => e.preventDefault()}
>
<div className={"flex items-center gap-2"}>
<Badge variant={"gray-ghost"}>
{folderIcon}
<TextWithTooltip text={option.name} maxChars={30} />
</Badge>
</div>
const peerCount =
option.peers?.length ?? option?.peers_count ?? 0;
<div
className={
"text-neutral-500 dark:text-nb-gray-300 font-medium flex items-center gap-2"
}
const isDisabled = disabledGroups
? disabledGroups?.findIndex((g) => g.id === option.id) !==
-1
: false;
return (
<FullTooltip
content={
<div className={"text-xs max-w-xs"}>
This group is already part of the routing peer and can
not be used for the access control groups.
</div>
}
disabled={!isDisabled}
className={"w-full block"}
key={option.name}
>
<CommandItem
key={option.name}
value={option.name + option.id}
disabled={isDisabled}
onSelect={() => {
if (peer != undefined && option.name == "All") return; // Prevent removing the "All" group
if (isDisabled) return;
toggleGroupByName(option.name);
searchRef.current?.focus();
}}
className={cn(isDisabled && "opacity-40")}
onClick={(e) => e.preventDefault()}
>
{peerIcon}
{option.peers_count || 0} Peer(s)
<Checkbox checked={isSelected} />
</div>
</CommandItem>
<div className={"flex items-center gap-2"}>
<GroupBadge group={option} showNewBadge={true} />
</div>
<div className={"flex items-center gap-5"}>
{option?.id && showRoutes && (
<AccessControlGroupCount group_id={option.id} />
)}
<div
className={
"text-neutral-500 dark:text-nb-gray-300 font-medium flex items-center gap-2"
}
>
{peerIcon}
{peerCount} Peer(s)
<Checkbox checked={isSelected} />
</div>
</div>
</CommandItem>
</FullTooltip>
);
})}
</ScrollArea>

View File

@@ -1,21 +1,31 @@
import { CommandItem } from "@components/Command";
import { DropdownInfoText } from "@components/DropdownInfoText";
import { DropdownInput } from "@components/DropdownInput";
import { Popover, PopoverContent, PopoverTrigger } from "@components/Popover";
import { ScrollArea } from "@components/ScrollArea";
import TextWithTooltip from "@components/ui/TextWithTooltip";
import { IconArrowBack } from "@tabler/icons-react";
import { VirtualScrollAreaList } from "@components/VirtualScrollAreaList";
import { useSearch } from "@hooks/useSearch";
import useFetchApi from "@utils/api";
import { cn } from "@utils/helpers";
import { Command, CommandGroup, CommandInput, CommandList } from "cmdk";
import { sortBy, trim, unionBy } from "lodash";
import { ChevronsUpDown, MapPin, SearchIcon } from "lucide-react";
import { sortBy, unionBy } from "lodash";
import { ChevronsUpDown, MapPin } from "lucide-react";
import * as React from "react";
import { useEffect, useMemo, useState } from "react";
import { memo, useEffect, useState } from "react";
import { FcLinux } from "react-icons/fc";
import { useElementSize } from "@/hooks/useElementSize";
import { getOperatingSystem } from "@/hooks/useOperatingSystem";
import { OperatingSystem } from "@/interfaces/OperatingSystem";
import { Peer } from "@/interfaces/Peer";
const MapPinIcon = memo(() => <MapPin size={12} />);
MapPinIcon.displayName = "MapPinIcon";
const LinuxIcon = memo(() => (
<span className={"grayscale brightness-[100%] contrast-[40%]"}>
<FcLinux className={"text-white text-lg min-w-[20px] brightness-150"} />
</span>
));
LinuxIcon.displayName = "LinuxIcon";
interface MultiSelectProps {
value?: Peer;
onChange: React.Dispatch<React.SetStateAction<Peer | undefined>>;
@@ -23,6 +33,13 @@ interface MultiSelectProps {
disabled?: boolean;
}
const searchPredicate = (item: Peer, query: string) => {
const lowerCaseQuery = query.toLowerCase();
if (item.name.toLowerCase().includes(lowerCaseQuery)) return true;
if (item.hostname.toLowerCase().includes(lowerCaseQuery)) return true;
return item.ip.toLowerCase().startsWith(lowerCaseQuery);
};
export function PeerSelector({
onChange,
value,
@@ -30,13 +47,16 @@ export function PeerSelector({
disabled = false,
}: MultiSelectProps) {
const { data: peers } = useFetchApi<Peer[]>("/peers");
const [dropdownOptions, setDropdownOptions] = useState<Peer[]>([]);
const searchRef = React.useRef<HTMLInputElement>(null);
const [inputRef, { width }] = useElementSize<HTMLButtonElement>();
const [search, setSearch] = useState("");
// Update dropdown options when peers change
const [unfilteredItems, setUnfilteredItems] = useState<Peer[]>([]);
const [filteredItems, search, setSearch] = useSearch(
unfilteredItems,
searchPredicate,
{ filter: true, debounce: 150 },
);
// Update unfiltered items when peers change
useEffect(() => {
if (!peers) return;
@@ -56,7 +76,7 @@ export function PeerSelector({
});
}
setDropdownOptions(unionBy(options, dropdownOptions, "id"));
setUnfilteredItems(unionBy(options, unfilteredItems, "id"));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [peers]);
@@ -68,44 +88,11 @@ export function PeerSelector({
onChange(peer);
setSearch("");
}
setOpen(false);
};
const peerNotFound = useMemo(() => {
const isSearching = search.length > 0;
// Search peer by ip or name
const peerFound =
dropdownOptions.filter((item) => {
return (
item.name.includes(search) ||
item.hostname.includes(search) ||
item.ip.includes(search)
);
}).length > 0;
return isSearching && !peerFound;
}, [search, dropdownOptions]);
const [open, setOpen] = useState(false);
const [slice, setSlice] = useState(10);
useEffect(() => {
if (open) {
setTimeout(() => {
setSlice(dropdownOptions.length);
}, 100);
} else {
setSlice(10);
}
}, [open, dropdownOptions]);
const LinuxIcon = (
<span className={"grayscale brightness-[100%] contrast-[40%]"}>
<FcLinux className={"text-white text-lg min-w-[20px] brightness-150"} />
</span>
);
return (
<Popover
open={open}
@@ -141,7 +128,7 @@ export function PeerSelector({
}
>
<div className={"flex items-center gap-2.5 text-sm"}>
{LinuxIcon}
<LinuxIcon />
<TextWithTooltip text={value.name} maxChars={20} />
</div>
@@ -150,7 +137,7 @@ export function PeerSelector({
"text-neutral-500 dark:text-nb-gray-300 font-medium flex items-center gap-1 font-mono text-[10px]"
}
>
<MapPin size={12} />
<MapPinIcon />
{value.ip}
</div>
</div>
@@ -168,113 +155,67 @@ export function PeerSelector({
style={{
width: width,
}}
forceMount={true}
align="start"
side={"top"}
sideOffset={10}
>
<Command
className={"w-full flex"}
loop
filter={(value, search) => {
const formatValue = trim(value.toLowerCase());
const formatSearch = trim(search.toLowerCase());
if (formatValue.includes(formatSearch)) return 1;
return 0;
}}
>
<CommandList className={"w-full"}>
<div className={"relative"}>
<CommandInput
className={cn(
"min-h-[42px] w-full relative",
"border-b-0 border-t-0 border-r-0 border-l-0 border-neutral-200 dark:border-nb-gray-700 items-center",
"bg-transparent text-sm outline-none focus-visible:outline-none ring-0 focus-visible:ring-0",
"dark:placeholder:text-neutral-500 font-light placeholder:text-neutral-500 pl-10",
)}
ref={searchRef}
value={search}
onValueChange={setSearch}
placeholder={"Search for peers by name or ip..."}
/>
<div
className={
"absolute left-0 top-0 h-full flex items-center pl-4"
}
>
<div className={"flex items-center"}>
<SearchIcon size={14} />
</div>
</div>
<div
className={
"absolute right-0 top-0 h-full flex items-center pr-4"
}
>
<div
className={
"flex items-center bg-nb-gray-800 py-1 px-1.5 rounded-[4px] border border-nb-gray-500"
}
>
<IconArrowBack size={10} />
</div>
</div>
</div>
<div className={"w-full"}>
<DropdownInput
value={search}
onChange={setSearch}
placeholder={"Search for peers by name or ip..."}
/>
<div className={""}>
{dropdownOptions.length == 0 && !peerNotFound && (
<div
className={
"text-center pb-2 text-nb-gray-500 max-w-xs mx-auto"
}
>
{
"Seems like you don't have any linux peers to assign as a routing peer."
}
</div>
)}
{peerNotFound && (
<div className={"text-center pb-2 text-nb-gray-500"}>
There are no peers matching your search.
</div>
)}
<CommandGroup>
<ScrollArea
className={
"max-h-[180px] overflow-y-auto flex flex-col gap-1 pl-2 py-2 pr-3"
}
>
{dropdownOptions.slice(0, slice).map((option) => {
return (
<CommandItem
key={option.name}
value={option.name + option.id}
onSelect={() => {
togglePeer(option);
setOpen(false);
}}
>
<div className={"flex items-center gap-2.5 text-sm"}>
{LinuxIcon}
<TextWithTooltip text={option.name} maxChars={20} />
</div>
{unfilteredItems.length == 0 && (
<DropdownInfoText>
{
"Seems like you don't have any linux peers to assign as a routing peer."
}
</DropdownInfoText>
)}
<div
className={
"text-neutral-500 dark:text-nb-gray-300 font-medium flex items-center gap-1 font-mono text-[10px]"
}
>
<MapPin size={12} />
{option.ip}
</div>
</CommandItem>
);
})}
</ScrollArea>
</CommandGroup>
</div>
</CommandList>
</Command>
{filteredItems.length == 0 && (
<DropdownInfoText>
There are no peers matching your search.
</DropdownInfoText>
)}
{filteredItems.length > 0 && (
<VirtualScrollAreaList
items={filteredItems}
onSelect={togglePeer}
renderItem={(option) => {
return (
<>
<div
className={cn(
"flex items-center gap-2.5 text-sm",
value && value.id == option.id
? "text-white"
: "text-nb-gray-300",
)}
>
<LinuxIcon />
<TextWithTooltip text={option.name} maxChars={20} />
</div>
<div
className={cn(
"font-medium flex items-center gap-1 font-mono text-[10px]",
value && value.id == option.id
? "text-white"
: "text-nb-gray-300",
)}
>
<MapPinIcon />
{option.ip}
</div>
</>
);
}}
/>
)}
</div>
</PopoverContent>
</Popover>
);

View File

@@ -73,6 +73,7 @@ export function PortSelector({
"border border-neutral-200 dark:border-nb-gray-700 justify-between py-2 px-3",
"rounded-md bg-white text-sm dark:bg-nb-gray-900/40 flex dark:text-neutral-400/70 text-neutral-500 cursor-pointer hover:dark:bg-nb-gray-900/50",
)}
data-cy={"port-selector"}
disabled={disabled}
ref={inputRef}
>
@@ -138,6 +139,7 @@ export function PortSelector({
"bg-transparent text-sm outline-none focus-visible:outline-none ring-0 focus-visible:ring-0",
"dark:placeholder:text-neutral-500 font-light placeholder:text-neutral-500 pl-10",
)}
data-cy={"port-input"}
typeof={"number"}
ref={searchRef}
value={search}

View File

@@ -4,30 +4,66 @@ import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
import { cn } from "@utils/helpers";
import * as React from "react";
type AdditionalScrollAreaProps = {
withoutViewport?: boolean;
};
const ScrollArea = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
>(({ className, children, ...props }, ref) => (
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root> &
AdditionalScrollAreaProps
>(({ className, children, withoutViewport = false, ...props }, ref) => (
<ScrollAreaPrimitive.Root
ref={ref}
className={cn("relative overflow-hidden", className)}
className={cn(
"relative will-change-scroll webkit-scroll",
className,
"overflow-hidden",
)}
{...props}
>
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
{children}
</ScrollAreaPrimitive.Viewport>
{withoutViewport ? (
children
) : (
<ScrollAreaViewport disableOverflowY={false}>
{children}
</ScrollAreaViewport>
)}
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
));
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName;
type AdditionalScrollAreaViewportProps = {
disableOverflowY?: boolean;
};
const ScrollAreaViewport = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.Viewport>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Viewport> &
AdditionalScrollAreaViewportProps
>(({ disableOverflowY = true, ...props }, ref) => {
return (
<ScrollAreaPrimitive.Viewport
ref={ref}
className="h-full w-full rounded-[inherit] will-change-scroll webkit-scroll"
{...props}
style={
disableOverflowY ? { overflowY: undefined, ...props.style } : undefined
}
/>
);
});
ScrollAreaViewport.displayName = ScrollAreaPrimitive.Viewport.displayName;
const ScrollBar = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
>(({ className, orientation = "vertical", ...props }, ref) => (
<ScrollAreaPrimitive.ScrollAreaScrollbar
ref={ref}
style={{ boxSizing: "unset", overflow: undefined }}
orientation={orientation}
className={cn(
"flex touch-none select-none transition-colors",
@@ -49,4 +85,15 @@ const ScrollBar = React.forwardRef<
));
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName;
export { ScrollArea, ScrollBar };
const MemoizedScrollArea = React.memo(ScrollArea);
const MemoizedScrollAreaViewport = React.memo(ScrollAreaViewport);
const MemoizedScrollBar = React.memo(ScrollBar);
export {
MemoizedScrollArea,
MemoizedScrollAreaViewport,
MemoizedScrollBar,
ScrollArea,
ScrollAreaViewport,
ScrollBar,
};

View File

@@ -21,12 +21,19 @@ function SegmentedTabs({ value, onChange, children }: Props) {
);
}
function List({ children }: { children: React.ReactNode }) {
function List({
children,
className = "",
}: {
children: React.ReactNode;
className?: string;
}) {
return (
<TabsList
className={
"bg-nb-gray-930/70 p-1.5 rounded-t-lg flex justify-center gap-1 border border-b-0 border-nb-gray-900"
}
className={cn(
"bg-nb-gray-930/70 p-1.5 rounded-t-lg flex justify-center gap-1 border border-b-0 border-nb-gray-900",
className,
)}
>
{children}
</TabsList>

27
src/components/Slider.tsx Normal file
View File

@@ -0,0 +1,27 @@
"use client";
import * as SliderPrimitive from "@radix-ui/react-slider";
import { cn } from "@utils/helpers";
import React from "react";
const Slider = React.forwardRef<
React.ElementRef<typeof SliderPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
>(({ className, ...props }, ref) => (
<SliderPrimitive.Root
ref={ref}
className={cn(
"relative flex w-full touch-none select-none items-center",
className,
)}
{...props}
>
<SliderPrimitive.Track className="relative h-2 w-full grow overflow-hidden rounded-full bg-neutral-100 dark:bg-neutral-800">
<SliderPrimitive.Range className="absolute h-full bg-neutral-900 dark:bg-neutral-50" />
</SliderPrimitive.Track>
<SliderPrimitive.Thumb className="block h-5 w-5 rounded-full border-2 border-neutral-900 bg-white ring-offset-white transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-neutral-950 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 dark:border-neutral-50 dark:bg-neutral-950 dark:ring-offset-neutral-950 dark:focus-visible:ring-neutral-300" />
</SliderPrimitive.Root>
));
Slider.displayName = SliderPrimitive.Root.displayName;
export { Slider };

View File

@@ -57,15 +57,15 @@ const TabsList = React.forwardRef<
)}
{...props}
>
<ScrollArea>
<div className={"relative z-[1] flex flex-nowrap"}>{props.children}</div>
<ScrollBar orientation="horizontal" />
</ScrollArea>
<span
className={
"absolute left-0 dark:bg-nb-gray-900 bg-nb-gray-100 w-full h-[1px] bottom-0 z-0"
}
/>
<ScrollArea>
<div className={"relative z-[1] flex flex-nowrap"}>{props.children}</div>
<ScrollBar orientation="horizontal" />
</ScrollArea>
</TabsPrimitive.List>
));
TabsList.displayName = TabsPrimitive.List.displayName;

View File

@@ -1,11 +1,16 @@
import Paragraph from "@components/Paragraph";
import { cn } from "@utils/helpers";
import { cva } from "class-variance-authority";
import { cva, VariantProps } from "class-variance-authority";
import * as React from "react";
type TextareaVariants = VariantProps<typeof inputVariants>;
export interface InputProps
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {
extends React.TextareaHTMLAttributes<HTMLTextAreaElement>,
TextareaVariants {
error?: string;
customElement?: React.ReactNode;
resize?: boolean;
}
const inputVariants = cva("", {
@@ -15,6 +20,10 @@ const inputVariants = cva("", {
"dark:bg-nb-gray-900 dark:placeholder:text-neutral-400/70 placeholder:text-neutral-500 border-neutral-200 dark:border-nb-gray-700",
"ring-offset-neutral-200/20 dark:ring-offset-neutral-950/50 dark:focus-visible:ring-neutral-500/20 focus-visible:ring-neutral-300/10",
],
darker: [
"dark:bg-nb-gray-900/40 dark:placeholder:text-neutral-400/70 placeholder:text-neutral-500 border-neutral-200 dark:border-nb-gray-900",
"ring-offset-neutral-200/20 dark:ring-offset-neutral-950/50 dark:focus-visible:ring-neutral-500/20 focus-visible:ring-neutral-300/10",
],
error: [
"dark:bg-red-950/30 dark:placeholder:text-red-400/70 placeholder:text-red-500 border-red-500 dark:border-red-500 text-red-500",
"ring-offset-red-500/10 dark:ring-offset-red-500/10 dark:focus-visible:ring-red-500/10 focus-visible:ring-red-500/10",
@@ -24,7 +33,10 @@ const inputVariants = cva("", {
});
const Textarea = React.forwardRef<HTMLTextAreaElement, InputProps>(
({ className, error, ...props }, ref) => {
(
{ className, variant = "default", resize, customElement, error, ...props },
ref,
) => {
return (
<>
<div className={cn("flex relative")}>
@@ -32,14 +44,20 @@ const Textarea = React.forwardRef<HTMLTextAreaElement, InputProps>(
ref={ref}
{...props}
className={cn(
inputVariants({ variant: error ? "error" : "default" }),
"flex w-full rounded-md bg-white px-3 py-2 text-sm file:bg-transparent file:text-sm file:font-medium focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50 ",
inputVariants({ variant: error ? "error" : variant }),
"flex w-full min-h-[42px] rounded-md bg-white px-3 pb-3 pt-2.5 text-sm file:bg-transparent file:text-sm file:font-medium focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50 ",
"file:border-0",
"focus-visible:ring-2 focus-visible:ring-offset-2",
"border",
"overflow-hidden",
className,
resize ? "resize" : "resize-none",
)}
style={{
height: variant === "darker" ? "42px" : "auto",
}}
/>
{customElement && customElement}
</div>
{error && (
<Paragraph className={"text-xs !text-red-500 mt-2"}>

View File

@@ -0,0 +1,132 @@
import {
MemoizedScrollArea,
MemoizedScrollAreaViewport,
} from "@components/ScrollArea";
import { cn } from "@utils/helpers";
import * as React from "react";
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Virtuoso, VirtuosoHandle } from "react-virtuoso";
type Props<T extends { id?: string }> = {
items: T[];
onSelect: (item: T) => void;
renderItem?: (item: T) => React.ReactNode;
};
export function VirtualScrollAreaList<T extends { id?: string }>({
items,
onSelect,
renderItem,
}: Props<T>) {
const virtuosoRef = useRef<VirtuosoHandle>(null);
const [selected, setSelected] = useState(0);
useEffect(() => {
setSelected(0);
}, [items]);
const scrollToItem = useCallback((index: number) => {
virtuosoRef.current?.scrollIntoView({
index,
behavior: "auto",
align: "center",
});
}, []);
const navigation = useCallback(
(e: KeyboardEvent) => {
if (items.length === 0) return;
const length = items.length - 1;
if (e.code === "ArrowUp" || (e.key === "Tab" && e.shiftKey)) {
e.preventDefault();
const newSelected = selected === 0 ? length : selected - 1;
setSelected(newSelected);
scrollToItem(newSelected);
} else if (e.key === "ArrowDown" || e.key === "Tab") {
e.preventDefault();
const newSelected = selected === length ? 0 : selected + 1;
setSelected(newSelected);
scrollToItem(newSelected);
}
if (e.key === "Enter") {
e.preventDefault();
onSelect?.(items[selected]);
}
},
[items, scrollToItem, selected],
);
useEffect(() => {
window.addEventListener("keydown", navigation);
return () => {
window.removeEventListener("keydown", navigation);
};
}, [navigation]);
const renderMemoizedItem = useMemo(() => renderItem, [renderItem]);
return (
<MemoizedScrollArea
withoutViewport={true}
className={"max-h-[195px] flex flex-col gap-1 py-2"}
>
<Virtuoso
ref={virtuosoRef}
overscan={50}
data={items}
computeItemKey={(index) => items[index].id as string}
context={{ selected, setSelected, onClick: onSelect }}
itemContent={(index, option, { selected, setSelected, onClick }) => {
return (
<VirtualScrollListItemWrapper
onMouseEnter={() => setSelected(index)}
id={option.id}
onClick={() => onClick(option as T)}
ariaSelected={selected === index}
>
{renderMemoizedItem ? renderMemoizedItem(option) : option.id}
</VirtualScrollListItemWrapper>
);
}}
style={{ height: 195 }}
components={{
Scroller: MemoizedScrollAreaViewport,
}}
/>
</MemoizedScrollArea>
);
}
type ItemWrapperProps = {
children: React.ReactNode;
id?: string;
onMouseEnter?: () => void;
onClick?: () => void;
ariaSelected?: boolean;
};
export const VirtualScrollListItemWrapper = memo(
({ id, children, onClick, onMouseEnter, ariaSelected }: ItemWrapperProps) => {
return (
<div
key={id ?? undefined}
className={"pr-3 pl-2 webkit-scroll"}
onMouseEnter={onMouseEnter}
onClick={onClick}
>
<div
className={cn(
"text-xs flex justify-between py-2 px-3 cursor-pointer items-center rounded-md",
"bg-transparent dark:aria-selected:bg-nb-gray-800/50",
)}
aria-selected={ariaSelected}
role={"listitem"}
tabIndex={0}
>
{children}
</div>
</div>
);
},
);
VirtualScrollListItemWrapper.displayName = "VirtualScrollListItemWrapper";

View File

@@ -31,8 +31,9 @@ const ModalOverlay = React.forwardRef<
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed top-0 left-0 bottom-0 right-0 grid z-50 bg-black/30 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 dark:bg-neutral-950/70",
"fixed top-0 left-0 bottom-0 right-0 grid z-50 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 ",
"mx-auto place-items-start overflow-y-auto md:py-16",
"bg-black/30 dark:bg-black/50 backdrop-blur-sm",
className,
)}
{...props}
@@ -65,7 +66,7 @@ const ModalContent = React.forwardRef<
<DialogPrimitive.Content
ref={ref}
className={cn(
"mx-auto relative top-0 z-50 grid w-full border border-neutral-200 bg-white py-6 dark:shadow-lg shadow-sm duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1 data-[state=open]:slide-in-from-left-1 sm:rounded-lg md:w-full dark:border-nb-gray-900 dark:bg-nb-gray",
"mx-auto relative top-0 z-[52] grid w-full border border-neutral-200 bg-white py-6 dark:shadow-lg shadow-sm duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1 data-[state=open]:slide-in-from-left-1 sm:rounded-lg md:w-full dark:border-nb-gray-900 dark:bg-nb-gray",
className,
maxWidthClass,
)}
@@ -75,7 +76,10 @@ const ModalContent = React.forwardRef<
<>
{children}
{showClose && (
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-white transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-neutral-950 focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-neutral-100 data-[state=open]:text-neutral-500 dark:ring-offset-neutral-950 dark:focus:ring-neutral-300 dark:data-[state=open]:bg-neutral-800 dark:data-[state=open]:text-neutral-400">
<DialogPrimitive.Close
data-cy={"modal-close"}
className="absolute right-4 z-10 top-4 rounded-sm opacity-70 ring-offset-white transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-neutral-950 focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-neutral-100 data-[state=open]:text-neutral-500 dark:ring-offset-neutral-950 dark:focus:ring-neutral-300 dark:data-[state=open]:bg-neutral-800 dark:data-[state=open]:text-neutral-400"
>
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>

View File

@@ -10,6 +10,7 @@ interface Props extends IconVariant {
className?: string;
margin?: string;
truncate?: boolean;
children?: React.ReactNode;
}
export default function ModalHeader({
icon,
@@ -19,6 +20,7 @@ export default function ModalHeader({
className = "pb-6 px-8",
margin = "mt-0",
truncate = false,
children,
}: Props) {
return (
<div className={cn(className, "min-w-0")}>
@@ -26,11 +28,15 @@ export default function ModalHeader({
{icon && <SquareIcon color={color} icon={icon} />}
<div className={"min-w-0"}>
<h2 className={"text-lg my-0 leading-[1.5]"}>{title}</h2>
<Paragraph
className={cn("text-sm", margin, truncate && "!block truncate")}
>
{description}
</Paragraph>
{children ? (
<>{children}</>
) : (
<Paragraph
className={cn("text-sm", margin, truncate && "!block truncate")}
>
{description}
</Paragraph>
)}
</div>
</div>
</div>

View File

@@ -1,4 +1,5 @@
import { cn } from "@utils/helpers";
import * as React from "react";
import Skeleton from "react-loading-skeleton";
type Props = {
@@ -8,24 +9,10 @@ type Props = {
export default function SkeletonTable({ withHeader = true }: Props) {
return (
<div className={"w-full"}>
{withHeader && (
<div
className={
"flex gap-x-4 gap-y-6 p-default flex-wrap w-full justify-between"
}
>
<div className={"flex gap-x-4 gap-y-6"}>
<Skeleton height={42} width={400} className={"rounded-md"} />
<Skeleton height={42} width={140} className={"rounded-md"} />
<Skeleton height={42} width={190} className={"rounded-md"} />
<Skeleton height={42} width={50} className={"rounded-md"} />
</div>
<Skeleton height={42} width={120} className={"rounded-md"} />
</div>
)}
{withHeader && <SkeletonTableHeader />}
<Skeleton
height={48}
containerClassName={"flex-1 "}
containerClassName={"flex"}
className={cn(withHeader && "mt-8")}
/>
<div>
@@ -60,3 +47,28 @@ export function TableSkeletonRow({ odd = false }: RowProps) {
</div>
);
}
type SkeletonTableHeaderProps = {
className?: string;
};
export const SkeletonTableHeader = ({
className,
}: SkeletonTableHeaderProps) => {
return (
<div
className={cn(
"flex gap-x-4 gap-y-6 p-default flex-wrap w-full justify-between",
className,
)}
>
<div className={"flex gap-x-4 gap-y-6"}>
<Skeleton height={42} width={400} className={"rounded-md"} />
<Skeleton height={42} width={140} className={"rounded-md"} />
<Skeleton height={42} width={190} className={"rounded-md"} />
<Skeleton height={42} width={50} className={"rounded-md"} />
</div>
<Skeleton height={42} width={120} className={"rounded-md"} />
</div>
);
};

View File

@@ -1,6 +1,7 @@
"use client";
import SkeletonTable from "@components/skeletons/SkeletonTable";
import DataTableGlobalSearch from "@components/table/DataTableGlobalSearch";
import { DataTableHeadingPortal } from "@components/table/DataTableHeadingPortal";
import { DataTablePagination } from "@components/table/DataTablePagination";
import DataTableResetFilterButton from "@components/table/DataTableResetFilterButton";
import {
@@ -10,6 +11,7 @@ import {
TableHead,
TableHeader,
TableRow,
TableWrapper,
} from "@components/table/Table";
import NoResults from "@components/ui/NoResults";
import {
@@ -28,6 +30,8 @@ import {
getSortedRowModel,
PaginationState,
Row,
RowSelectionState,
SortingFn,
SortingState,
Table as TanStackTable,
useReactTable,
@@ -52,6 +56,9 @@ declare module "@tanstack/table-core" {
interface FilterMeta {
itemRank: RankingInfo;
}
interface SortingFns {
checkbox: SortingFn<unknown>;
}
}
const fuzzyFilter: FilterFn<any> = (row, columnId, value, addMeta) => {
@@ -98,6 +105,20 @@ const arrIncludesSomeExact: FilterFn<any> = (
return value.some((val) => val === rowValue);
};
const checkboxSort: SortingFn<any> = (rowA, rowB, columnId) => {
const valueA =
columnId === "select" ? rowA.getIsSelected() : rowA.getValue(columnId);
const valueB =
columnId === "select" ? rowB.getIsSelected() : rowB.getValue(columnId);
if (valueA && !valueB) {
return -1;
}
if (!valueA && valueB) {
return 1;
}
return 0;
};
interface DataTableProps<TData, TValue> {
columns: ColumnDef<TData, TValue>[];
data: TData[] | undefined;
@@ -105,6 +126,7 @@ interface DataTableProps<TData, TValue> {
aboveTable?: (table: TanStackTable<TData>) => React.ReactNode;
searchPlaceholder?: string;
columnVisibility?: VisibilityState;
setColumnVisibility?: React.Dispatch<React.SetStateAction<VisibilityState>>;
sorting?: SortingState;
setSorting?: React.Dispatch<React.SetStateAction<SortingState>>;
text?: string;
@@ -122,10 +144,25 @@ interface DataTableProps<TData, TValue> {
wrapperClassName?: string;
tableClassName?: string;
searchClassName?: string;
showSearch?: boolean;
showSearchAndFilters?: boolean;
rightSide?: (table: TanStackTable<TData>) => React.ReactNode;
manualPagination?: boolean;
showHeader?: boolean;
rowSelection?: RowSelectionState;
setRowSelection?: React.Dispatch<React.SetStateAction<RowSelectionState>>;
useRowId?: boolean;
headingTarget?: HTMLHeadingElement | null;
showResetFilterButton?: boolean;
onFilterReset?: () => void;
wrapperComponent?: React.ElementType;
wrapperProps?: any;
keepStateInLocalStorage?: boolean;
paginationPaddingClassName?: string;
tableCellClassName?: string;
initialSelectionState?: RowSelectionState;
initialPageSize?: number;
uniqueKey?: string;
resetRowSelectionOnSearch?: boolean;
}
export function DataTable<TData, TValue>(props: DataTableProps<TData, TValue>) {
@@ -139,6 +176,7 @@ export function DataTableContent<TData, TValue>({
children,
searchPlaceholder = "Search...",
columnVisibility = {},
setColumnVisibility,
sorting = [],
setSorting,
text = "rows",
@@ -159,25 +197,46 @@ export function DataTableContent<TData, TValue>({
rightSide,
manualPagination = false,
showHeader = true,
rowSelection,
setRowSelection,
useRowId,
headingTarget,
showResetFilterButton = true,
onFilterReset,
showSearchAndFilters = true,
wrapperProps,
wrapperComponent,
keepStateInLocalStorage = true,
paginationPaddingClassName,
tableCellClassName,
initialPageSize = 10,
uniqueKey,
resetRowSelectionOnSearch = true,
}: DataTableProps<TData, TValue>) {
const path = usePathname();
const [columnFilters, setColumnFilters] = useLocalStorage<ColumnFiltersState>(
"netbird-table-columns" + path,
`netbird-table-columns${uniqueKey ? "/" + (uniqueKey as string) : path}`,
[],
keepStateInLocalStorage,
);
const [globalSearch, setGlobalSearch] = useLocalStorage(
"netbird-table-search" + path,
`netbird-table-search${uniqueKey ? "/" + (uniqueKey as string) : path}`,
"",
keepStateInLocalStorage,
);
const [paginationState, setPaginationState] =
useLocalStorage<PaginationState>("netbird-table-pagination" + path, {
pageIndex: 0,
pageSize: 10,
});
const [tableColumnVisibility, setColumnVisibility] =
React.useState<VisibilityState>(columnVisibility);
useLocalStorage<PaginationState>(
`netbird-table-pagination${
uniqueKey ? "/" + (uniqueKey as string) : path
}`,
{
pageIndex: 0,
pageSize: 10,
},
keepStateInLocalStorage,
);
const hasInitialData = !!(data && data.length > 0);
@@ -196,17 +255,23 @@ export function DataTableContent<TData, TValue>({
manualPagination: manualPagination,
state: {
sorting,
rowSelection: rowSelection ?? {},
columnFilters,
columnVisibility: tableColumnVisibility,
columnVisibility: columnVisibility,
globalFilter: globalSearch,
pagination: paginationState,
},
initialState: {
pagination: {
pageIndex: 0,
pageSize: 10,
pageSize: initialPageSize || 10,
},
},
sortingFns: {
checkbox: checkboxSort,
},
getRowId: useRowId ? (row) => row.id : undefined,
onRowSelectionChange: setRowSelection,
onSortingChange: setSorting,
onPaginationChange: setPaginationState,
onColumnFiltersChange: setColumnFilters,
@@ -235,12 +300,19 @@ export function DataTableContent<TData, TValue>({
table.setPageIndex(0);
setColumnFilters([]);
setGlobalSearch("");
setRowSelection?.({});
onFilterReset?.();
};
return (
<div className={cn("relative table-fixed-scroll", className)}>
{!minimal && (
<div className={"flex gap-x-4 gap-y-6 p-default flex-wrap"}>
{showSearchAndFilters && (
<div
className={cn(
"flex gap-x-4 gap-y-6 flex-wrap",
!minimal && "p-default",
)}
>
<DataTableGlobalSearch
className={searchClassName}
disabled={!hasInitialData}
@@ -248,170 +320,193 @@ export function DataTableContent<TData, TValue>({
setGlobalSearch={(val) => {
table.setPageIndex(0);
setGlobalSearch(val);
resetRowSelectionOnSearch && setRowSelection?.({});
}}
placeholder={searchPlaceholder}
/>
{children && children(table)}
<DataTableResetFilterButton onClick={resetFilters} table={table} />
{showResetFilterButton && (
<DataTableResetFilterButton onClick={resetFilters} table={table} />
)}
<div className={"flex gap-4 flex-wrap grow"}>
<div className={"flex gap-4 flex-wrap"}></div>
{rightSide && rightSide(table)}
</div>
</div>
)}
{aboveTable && aboveTable(table)}
{!hasInitialData && !isLoading && getStartedCard}
{!hasInitialData && !isLoading && (
<TableWrapper
wrapperComponent={wrapperComponent}
wrapperProps={wrapperProps}
>
{getStartedCard}
</TableWrapper>
)}
{hasInitialData && !isLoading && (
<TableComponent
className={cn("relative mt-8", tableClassName)}
minimal={minimal}
<TableWrapper
wrapperComponent={wrapperComponent}
wrapperProps={wrapperProps}
>
{showHeader && as == "table" && (
<TableHeaderComponent minimal={minimal}>
{table.getHeaderGroups().map((headerGroup) => (
<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>
)}
<Accordion
asChild={true}
type={"multiple"}
value={accordion}
onValueChange={setAccordion}
<TableComponent
className={cn("relative mt-8", tableClassName)}
minimal={minimal}
>
<TableBodyComponent
className={cn(
"relative",
data == undefined && "blur-sm",
wrapperClassName,
)}
>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<AccordionItem
value={row.original.id}
asChild={true}
key={row.original.id}
>
<>
<TableRowComponent
minimal={minimal}
data-row-id={row.original.id}
className={cn(
(onRowClick || renderExpandedRow) &&
"cursor-pointer relative group/accordion",
rowClassName,
)}
data-state={row.getIsSelected() && "selected"}
data-accordion={
accordion?.includes(row.original.id)
? "opened"
: "closed"
}
onClick={(e) => {
if (renderExpandedRow) {
e.preventDefault();
e.stopPropagation();
setAccordion((prev) => {
if (prev?.includes(row.original.id)) {
return prev.filter(
(item) => item !== row.original.id,
);
} else {
return [...(prev ?? []), row.original.id];
}
});
}
}}
>
<>
{row.getVisibleCells().map((cell) => (
<TableCellComponent
key={cell.id}
className={"relative"}
minimal={minimal}
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>
{showHeader && as == "table" && (
<TableHeaderComponent minimal={minimal}>
{table.getHeaderGroups().map((headerGroup) => (
<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>
)}
{renderExpandedRow && (
<AccordionContent asChild={true}>
<TableRowComponent
data-row-id={row.id + "-expanded-row"}
key={row.id + "-expanded-row"}
minimal={minimal}
className={cn(
onRowClick && "cursor-pointer relative",
rowClassName,
)}
data-state={row.getIsSelected() && "selected"}
>
<TableDataUnstyledComponent
className={"w-full"}
colSpan={row.getVisibleCells().length}
<Accordion
asChild={true}
type={"multiple"}
value={accordion}
onValueChange={setAccordion}
>
<TableBodyComponent
className={cn(
"relative",
data == undefined && "blur-sm",
wrapperClassName,
)}
>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<AccordionItem
value={row.original.id}
asChild={true}
key={row.original.id}
>
<>
<TableRowComponent
minimal={minimal}
data-row-id={row.original.id}
className={cn(
(onRowClick || renderExpandedRow) &&
"cursor-pointer relative group/accordion",
rowClassName,
)}
data-state={row.getIsSelected() && "selected"}
data-accordion={
accordion?.includes(row.original.id)
? "opened"
: "closed"
}
onClick={(e) => {
if (renderExpandedRow) {
e.preventDefault();
e.stopPropagation();
setAccordion((prev) => {
if (prev?.includes(row.original.id)) {
return prev.filter(
(item) => item !== row.original.id,
);
} else {
return [...(prev ?? []), row.original.id];
}
});
}
}}
>
<>
{row.getVisibleCells().map((cell) => (
<TableCellComponent
key={cell.id}
className={cn("relative", tableCellClassName)}
minimal={minimal}
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>
{renderExpandedRow && (
<AccordionContent asChild={true}>
<TableRowComponent
data-row-id={row.id + "-expanded-row"}
key={row.id + "-expanded-row"}
minimal={minimal}
className={cn(
onRowClick && "cursor-pointer relative",
rowClassName,
)}
data-state={row.getIsSelected() && "selected"}
>
{renderExpandedRow(row.original)}
</TableDataUnstyledComponent>
</TableRowComponent>
</AccordionContent>
)}
</>
</AccordionItem>
))
) : (
<TableRowUnstyledComponent>
<TableCellComponent
colSpan={columns.length}
className="!py-4 !px-0 text-center"
>
<NoResults />
</TableCellComponent>
</TableRowUnstyledComponent>
)}
</TableBodyComponent>
</Accordion>
</TableComponent>
<TableDataUnstyledComponent
className={"w-full"}
colSpan={row.getVisibleCells().length}
>
{renderExpandedRow(row.original)}
</TableDataUnstyledComponent>
</TableRowComponent>
</AccordionContent>
)}
</>
</AccordionItem>
))
) : (
<TableRowUnstyledComponent>
<TableCellComponent
colSpan={columns.length}
className="!py-0 !px-0 text-center"
>
<NoResults className={"py-4"} />
</TableCellComponent>
</TableRowUnstyledComponent>
)}
</TableBodyComponent>
</Accordion>
</TableComponent>
</TableWrapper>
)}
<div className={paginationClassName}>
<DataTablePagination table={table} text={text} />
<DataTablePagination
table={table}
text={text}
paginationPadding={paginationPaddingClassName}
/>
</div>
<DataTableHeadingPortal table={table} headingTarget={headingTarget} />
</div>
);
}

View File

@@ -0,0 +1,64 @@
import { Table } from "@tanstack/react-table";
import * as React from "react";
import { useRef } from "react";
import { createPortal } from "react-dom";
type Props<TData> = {
table: Table<TData> | null;
headingTarget?: HTMLHeadingElement | null;
};
export const DataTableHeadingPortal = function <TData>({
table,
headingTarget,
}: Props<TData>) {
const hasMounted = useRef(false);
if (!headingTarget) return;
if (!hasMounted.current) hasMounted.current = true;
const totalItems = table?.getPreFilteredRowModel().rows.length;
const filteredItems = table?.getFilteredRowModel().rows.length;
if (!totalItems || totalItems == 1) return;
const hasAnyFiltersActive =
table &&
!(
table?.getState().columnFilters.length <= 0 &&
table?.getState().globalFilter === ""
);
const portalContainer = document.createElement("span");
headingTarget.prepend(portalContainer);
return createPortal(
<Heading
hasAnyFilterActive={hasAnyFiltersActive}
totalItems={totalItems}
filteredItems={filteredItems}
/>,
portalContainer,
);
};
type HeadingProps = {
hasAnyFilterActive: boolean | null;
filteredItems?: number;
totalItems?: number;
};
const Heading = ({
hasAnyFilterActive,
filteredItems,
totalItems,
}: HeadingProps) => {
if (hasAnyFilterActive) {
return (
<>
<span className={"text-netbird"}>{filteredItems}</span> of {totalItems}{" "}
</>
);
}
return `${totalItems} `;
};

View File

@@ -1,5 +1,6 @@
import ButtonGroup from "@components/ButtonGroup";
import { Table } from "@tanstack/react-table";
import { cn } from "@utils/helpers";
import {
ChevronLeft,
ChevronRight,
@@ -10,11 +11,13 @@ import {
interface DataTablePaginationProps<TData> {
table: Table<TData>;
text?: string;
paginationPadding?: string;
}
export function DataTablePagination<TData>({
table,
text = "rows",
paginationPadding = "px-8 py-8",
}: DataTablePaginationProps<TData>) {
const allRows = table.getFilteredRowModel().rows.length;
const rowsPerPage = table.getState().pagination.pageSize;
@@ -25,8 +28,8 @@ export function DataTablePagination<TData>({
const pageCount = table.getPageCount();
return pageCount > 1 ? (
<div className="flex items-center justify-between px-8 py-8">
<div className=" text-nb-gray-400">
<div className={cn("flex items-center justify-between", paginationPadding)}>
<div className="text-nb-gray-400">
Showing{" "}
<span className={"font-medium text-white"}>
{showingFrom} to {showingTo}

View File

@@ -28,9 +28,10 @@ export function DataTableRowsPerPage<TData>({
role="combobox"
aria-expanded={open}
disabled={disabled}
data-cy={"rows-per-page"}
className="w-[200px] justify-between"
>
<RowsIcon size={15} className={"text-nb-gray-300"} />
<RowsIcon size={15} className={"text-nb-gray-300 shrink-0"} />
<div>
<span className={"text-white"}>
{table.getState().pagination.pageSize}
@@ -47,6 +48,7 @@ export function DataTableRowsPerPage<TData>({
<CommandItem
key={val}
value={val.toString()}
data-cy={`rows-per-page-value`}
onSelect={(currentValue) => {
table.setPageSize(Number(currentValue));
setOpen(false);

View File

@@ -1,6 +1,25 @@
import { cn } from "@utils/helpers";
import * as React from "react";
type TableWrapperProps = {
wrapperComponent?: React.ElementType;
wrapperProps?: any;
children: React.ReactNode;
};
const TableWrapper = ({
wrapperComponent,
children,
wrapperProps,
}: TableWrapperProps) => {
if (!wrapperComponent) return <>{children}</>;
return React.createElement(
wrapperComponent,
wrapperProps ? wrapperProps : {},
children,
);
};
type TableProps = {
minimal?: boolean;
};
@@ -164,4 +183,5 @@ export {
TableHead,
TableHeader,
TableRow,
TableWrapper,
};

View File

@@ -0,0 +1,67 @@
import FullTooltip from "@components/FullTooltip";
import useFetchApi from "@utils/api";
import { uniqBy } from "lodash";
import { RouteIcon } from "lucide-react";
import * as React from "react";
import { useMemo } from "react";
import Skeleton from "react-loading-skeleton";
import { Route } from "@/interfaces/Route";
type Props = {
group_id: string;
};
export const AccessControlGroupCount = ({ group_id }: Props) => {
const { data, isLoading } = useFetchApi<Route[]>("/routes");
const routes = useMemo(() => {
const routes = data?.filter((route) => {
const groups = route?.access_control_groups;
if (!groups) return false;
return groups.includes(group_id);
});
return uniqBy(routes, "network_id");
}, [data, group_id]);
if (isLoading) return <Skeleton width={100} height={16} />;
return routes && routes.length > 0 ? (
<FullTooltip
content={
<div className={"text-xs max-w-lg w-full gap-2"}>
{routes.map((route) => {
const domains = route?.domains;
return (
<div
key={route.id}
className={
"w-full gap-10 flex text-nb-gray-300/80 justify-between"
}
>
<span className={"flex items-center gap-2 text-nb-gray-200"}>
<RouteIcon size={12} /> {route.network_id}
</span>
{domains ? (
<span className={""}>{domains.join(", ")}</span>
) : (
<span className={"font-mono text-[10px]"}>
{route.network}
</span>
)}
</div>
);
})}
</div>
}
>
<div
className={
"text-nb-gray-300 font-medium flex items-center gap-2 hover:text-nb-gray-100 transition-all"
}
>
<RouteIcon size={14} className={"shrink-0"} />
{routes.length} Route(s)
</div>
</FullTooltip>
) : null;
};

View File

@@ -0,0 +1,70 @@
import Badge from "@components/Badge";
import FullTooltip from "@components/FullTooltip";
import { GlobeIcon } from "lucide-react";
import * as React from "react";
type Props = {
domains: string[];
};
export const DomainListBadge = ({ domains }: Props) => {
const firstDomain = domains.length > 0 ? domains[0] : undefined;
return (
<DomainsTooltip domains={domains}>
<div className={"inline-flex items-center gap-2"}>
{firstDomain && (
<Badge variant={"gray"}>
<GlobeIcon size={10} />
{firstDomain}
</Badge>
)}
{domains && domains.length > 1 && (
<Badge variant={"gray"}>+ {domains.length - 1}</Badge>
)}
</div>
</DomainsTooltip>
);
};
export const DomainsTooltip = ({
domains,
children,
className,
}: {
domains: string[];
children: React.ReactNode;
className?: string;
}) => {
return (
<FullTooltip
interactive={false}
className={className}
content={
<div className={"flex flex-col gap-2 items-start"}>
{domains.map((domain) => {
return (
domain && (
<div
key={domain}
className={"flex gap-2 items-center justify-between w-full"}
>
<div
className={
"flex gap-2 items-center text-nb-gray-300 text-xs"
}
>
<GlobeIcon size={11} />
{domain}
</div>
</div>
)
);
})}
</div>
}
disabled={domains.length <= 1}
>
{children}
</FullTooltip>
);
};

View File

@@ -7,10 +7,11 @@ import { Group } from "@/interfaces/Group";
type Props = {
group: Group;
onClick?: () => void;
onClick?: (e: React.MouseEvent<HTMLDivElement>) => void;
showX?: boolean;
children?: React.ReactNode;
className?: string;
showNewBadge?: boolean;
};
export default function GroupBadge({
onClick,
@@ -18,22 +19,41 @@ export default function GroupBadge({
showX = false,
children,
className,
showNewBadge = false,
}: Props) {
const isNew = !group?.id;
return (
<Badge
key={group.id}
key={group.id || group.name}
useHover={true}
variant={"gray-ghost"}
className={cn("transition-all group whitespace-nowrap", className)}
onClick={onClick}
onClick={(e) => {
e.preventDefault();
onClick?.(e);
}}
>
<FolderGit2 size={12} className={"shrink-0"} />
<TextWithTooltip text={group?.name || ""} maxChars={20} />
{children}
{isNew && showNewBadge && (
<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"
}
>
NEW
</span>
)}
{showX && (
<XIcon
size={12}
className={"cursor-pointer group-hover:text-white shrink-0"}
className={
"cursor-pointer group-hover:text-nb-gray-100 transition-all shrink-0"
}
/>
)}
</Badge>

View File

@@ -0,0 +1,124 @@
import Badge from "@components/Badge";
import TextWithTooltip from "@components/ui/TextWithTooltip";
import { cn } from "@utils/helpers";
import { EyeIcon, FolderGit2, 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 = {
group: Group;
className?: string;
showNewBadge?: boolean;
showPeerCount?: boolean;
useSave?: boolean;
onPeerAssignmentChange?: (oldGroup: Group, newGroup: Group) => void;
};
export default function GroupBadgeWithEditPeers({
group,
className,
showNewBadge = false,
useSave = true,
onPeerAssignmentChange,
}: Readonly<Props>) {
const isNew = !group?.id;
const [editGroupPeersModal, setEditGroupPeersModal] = useState(false);
const { dropdownOptions, addDropdownOptions, updateGroupDropdown } =
useGroups();
const currentGroup = useMemo(() => {
return dropdownOptions?.find((g) => g.name === group?.name);
}, [group, dropdownOptions]);
const peerCount =
currentGroup?.peers?.length ?? currentGroup?.peers_count ?? 0;
const updateGroupOptions = (g: Group) => {
updateGroupDropdown(group.name, g);
onPeerAssignmentChange?.(group, g);
};
const isAllGroup = currentGroup?.name === "All";
return (
<>
{currentGroup && editGroupPeersModal && (
<AssignPeerToGroupModal
useSave={useSave}
group={currentGroup}
onUpdate={(g) => updateGroupOptions(g)}
open={editGroupPeersModal}
setOpen={setEditGroupPeersModal}
/>
)}
<Badge
key={group.id ?? group.name}
useHover={true}
variant={"gray-ghost"}
className={cn(
"transition-all group group/badge whitespace-nowrap overflow-hidden",
className,
)}
onClick={(e) => {
if (!currentGroup) return;
e.stopPropagation();
setEditGroupPeersModal(true);
}}
>
<div
className={
"flex flex-col items-start justify-start pt-[0px] pb-[2px]"
}
>
<div
className={
"text-nb-gray-200 flex gap-1.5 items-center z-10 relative"
}
>
<FolderGit2 size={12} className={"shrink-0"} />
<TextWithTooltip text={group?.name || ""} maxChars={20} />
{isNew && showNewBadge && (
<span
className={
"text-[7px] relative -top-[0px] leading-[0] bg-green-900 border border-green-500/20 py-1.5 px-1 rounded-[3px] text-green-400"
}
>
NEW
</span>
)}
</div>
<span
className={
"text-[0.7rem] relative leading-none mt-[2px] text-nb-gray-300 mb-[1px] font-normal flex gap-1.5 items-center group-hover/badge:text-netbird transition-all"
}
>
<span>
<span
className={
"font-medium text-nb-gray-200 group-hover/badge:text-netbird transition-all"
}
>
{peerCount}
</span>{" "}
Peers{" "}
</span>
{isAllGroup ? (
<EyeIcon size={11} className={"shrink-0"} />
) : (
<SquarePen
size={11}
className={
"shrink-0 transition-all relative z-10 group-hover/badge:text-netbird text-netbird-400/80"
}
/>
)}
</span>
</div>
</Badge>
</>
);
}

View File

@@ -0,0 +1,89 @@
import Button from "@components/Button";
import { Input } from "@components/Input";
import { validator } from "@utils/helpers";
import { uniqueId } from "lodash";
import { GlobeIcon, MinusCircleIcon } from "lucide-react";
import * as React from "react";
import { useEffect, useMemo, useState } from "react";
import { Domain } from "@/interfaces/Domain";
type Props = {
value: Domain;
onChange: (d: Domain) => void;
onRemove: () => void;
onError?: (error: boolean) => void;
error?: string;
};
enum ActionType {
ADD = "ADD",
REMOVE = "REMOVE",
UPDATE = "UPDATE",
}
export const domainReducer = (state: Domain[], action: any): Domain[] => {
switch (action.type) {
case ActionType.ADD:
return [...state, { name: "", id: uniqueId("domain") }];
case ActionType.REMOVE:
return state.filter((_, i) => i !== action.index);
case ActionType.UPDATE:
return state.map((n, i) => (i === action.index ? action.d : n));
default:
return state;
}
};
export default function InputDomain({
value,
onChange,
onRemove,
onError,
}: Readonly<Props>) {
const [name, setName] = useState(value?.name || "");
const handleNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setName(e.target.value);
onChange({ ...value, name: e.target.value });
};
const domainError = useMemo(() => {
if (name == "") {
return "";
}
const valid = validator.isValidDomain(name);
if (!valid) {
return "Please enter a valid domain, e.g. example.com or intra.example.com";
}
}, [name]);
useEffect(() => {
const hasError = domainError !== "" && domainError !== undefined;
onError?.(hasError);
return () => onError?.(false);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [domainError]);
return (
<div className={"flex gap-2 w-full"}>
<div className={"w-full"}>
<Input
customPrefix={<GlobeIcon size={15} />}
placeholder={"e.g., example.com"}
maxWidthClass={"w-full"}
data-cy={"domain-input"}
value={name}
error={domainError}
onChange={handleNameChange}
/>
</div>
<Button
className={"h-[42px]"}
variant={"default-outline"}
onClick={onRemove}
>
<MinusCircleIcon size={15} />
</Button>
</div>
);
}

View File

@@ -1,4 +1,5 @@
import Paragraph from "@components/Paragraph";
import { cn } from "@utils/helpers";
import { FilterX } from "lucide-react";
import React from "react";
import Skeleton from "react-loading-skeleton";
@@ -8,23 +9,25 @@ type Props = {
title?: string;
description?: string;
children?: React.ReactNode;
className?: string;
};
export default function NoResults({
icon,
title = "Could not find any results",
description = "We couldn't find any results. Please try a different search term or change your filters.",
children,
className,
}: Props) {
return (
<div className={"relative overflow-hidden"}>
<div className={cn("relative overflow-hidden", className)}>
<div
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"
"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"
}
></div>
<div
className={
"absolute w-full h-full left-0 top-0 z-10 px-5 overflow-hidden"
"absolute w-full h-full left-0 top-0 z-10 px-5 overflow-hidden py-4"
}
>
<div className={"flex flex-col gap-2"}>
@@ -33,7 +36,7 @@ export default function NoResults({
<Skeleton className={"w-full"} height={70} duration={4} />
</div>
</div>
<div className={"max-w-md mx-auto relative z-20 py-6"}>
<div className={cn("max-w-md mx-auto relative z-20 py-6")}>
<div
className={
"mx-auto w-14 h-14 bg-nb-gray-930 flex items-center justify-center mb-3 rounded-md"

View File

@@ -0,0 +1,59 @@
import Card from "@components/Card";
import Paragraph from "@components/Paragraph";
import { FilterX } from "lucide-react";
import React from "react";
import Skeleton from "react-loading-skeleton";
type Props = {
icon?: React.ReactNode;
title?: string;
description?: string;
children?: React.ReactNode;
};
export default function NoResultsCard({
icon,
title = "Could not find any results",
description = "We couldn't find any results. Please try a different search term or change your filters.",
children,
}: Readonly<Props>) {
return (
<div className={"px-8 mt-8"}>
<Card className={"w-full relative overflow-hidden"}>
<div
className={
"absolute z-20 bg-gradient-to-b dark:to-nb-gray-950 dark:from-nb-gray-950/40 w-full h-full"
}
></div>
<div
className={
"absolute w-full h-full left-0 top-0 z-10 px-5 py-3 overflow-hidden"
}
>
<div className={"flex flex-col gap-2"}>
<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 className={"max-w-md mx-auto relative z-20 py-8"}>
<div
className={
"mx-auto w-10 h-10 bg-nb-gray-930 flex items-center justify-center mb-3 rounded-md border border-nb-gray-800"
}
>
{icon || <FilterX size={24} />}
</div>
<div className={"text-center"}>
<h1 className={"text-2xl font-medium max-w-lg mx-auto"}>{title}</h1>
<Paragraph className={"justify-center my-2 !text-nb-gray-400"}>
{description}
</Paragraph>
{children}
</div>
</div>
</Card>
</div>
);
}

View File

@@ -1,15 +1,84 @@
import Badge from "@components/Badge";
import { MonitorSmartphoneIcon } from "lucide-react";
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;
} & React.HTMLAttributes<HTMLDivElement>;
export default function PeerBadge({ children }: Props) {
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 (
<Badge variant={"gray"} className={"px-3 gap-2 whitespace-nowrap"}>
<MonitorSmartphoneIcon size={12} />
{children}
</Badge>
<>
{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

@@ -1,12 +1,13 @@
import Badge from "@components/Badge";
import {cn} from "@utils/helpers";
import React, {useEffect} from "react";
import { cn } from "@utils/helpers";
import React, { useEffect } from "react";
import LongArrowLeftIcon from "@/assets/icons/LongArrowLeftIcon";
type Props = {
disabled?: boolean;
value: Direction;
onChange: (value: Direction) => void;
className?: string;
};
export type Direction = "bi" | "in" | "out";
@@ -15,6 +16,7 @@ export default function PolicyDirection({
disabled = false,
value,
onChange,
className,
}: Props) {
const toggleIn = () => {
if (value == "in") {
@@ -40,6 +42,14 @@ export default function PolicyDirection({
}
};
const toggleDirection = () => {
if (value == "bi") {
onChange("in");
} else {
onChange("bi");
}
};
useEffect(() => {
if (disabled) onChange("bi");
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -48,15 +58,17 @@ export default function PolicyDirection({
return (
<div
className={cn(
"flex flex-col gap-2 mt-[23px] cursor-pointer",
"flex flex-col gap-2 mt-[23px] cursor-pointer select-none",
disabled && "opacity-50 pointer-events-none",
"hover:opacity-80 transition-all",
className,
)}
onClick={toggleDirection}
data-cy={"policy-direction"}
>
<Badge
variant={value == "bi" ? "green" : value == "in" ? "blueDark" : "gray"}
className={"px-4 py-1"}
onClick={toggleIn}
useHover={true}
>
<LongArrowLeftIcon
size={40}
@@ -72,10 +84,8 @@ export default function PolicyDirection({
/>
</Badge>
<Badge
useHover={true}
variant={value == "bi" ? "green" : value == "out" ? "blueDark" : "gray"}
className={"px-4 py-1"}
onClick={toggleOut}
>
<LongArrowLeftIcon
size={40}

View File

@@ -94,6 +94,7 @@ export default function DialogProvider({ children }: Props) {
className={"w-full"}
size={"sm"}
tabIndex={-1}
data-cy={"confirmation.cancel"}
onClick={() => fn.current && fn.current(false)}
>
{dialogOptions.cancelText || "Cancel"}
@@ -109,6 +110,7 @@ export default function DialogProvider({ children }: Props) {
}
className={"w-full"}
size={"sm"}
data-cy={"confirmation.confirm"}
onClick={() => fn.current && fn.current(true)}
>
{dialogOptions.confirmText || "Confirm"}

View File

@@ -1,8 +1,10 @@
import useFetchApi from "@utils/api";
import useFetchApi, { useApiCall } from "@utils/api";
import { merge, sortBy, unionBy } from "lodash";
import { usePathname } from "next/navigation";
import React, { useState } from "react";
import React, { useEffect, useState } from "react";
import { useLoggedInUser } from "@/contexts/UsersProvider";
import { Group } from "@/interfaces/Group";
import { Peer } from "@/interfaces/Peer";
type Props = {
children: React.ReactNode;
@@ -14,7 +16,11 @@ const GroupContext = React.createContext(
refresh: () => void;
dropdownOptions: Group[];
setDropdownOptions: React.Dispatch<React.SetStateAction<Group[]>>;
addDropdownOptions: (options: Group[]) => void;
isLoading: boolean;
createOrUpdate: (group: Group) => Promise<Group>;
reset: () => void;
updateGroupDropdown: (oldGroupName: string, newGroup: Group) => void;
},
);
@@ -31,12 +37,82 @@ export default function GroupsProvider({ children }: Props) {
export function GroupsProviderContent({ children }: Props) {
const { data: groups, mutate, isLoading } = useFetchApi<Group[]>("/groups");
const groupRequest = useApiCall<Group>("/groups", true);
const [dropdownOptions, setDropdownOptions] = useState<Group[]>([]);
const refresh = () => {
if (groups && !isLoading) mutate().then();
};
const reset = () => {
mutate();
setDropdownOptions([]);
addDropdownOptions(groups || []);
};
const addDropdownOptions = (options: Group[]) => {
setDropdownOptions((prev) => {
let union = unionBy(options, prev, "name");
return sortBy(
union.map((item) =>
merge({}, prev.find((p) => p.name === item.name) || {}, item),
),
"name",
);
});
};
const updateGroupDropdown = (oldGroupName: string, newGroup: Group) => {
setDropdownOptions((prev) => {
let updated = prev.map((g) => {
if (g.name === oldGroupName) {
return newGroup;
}
return g;
});
return sortBy(updated, "name");
});
};
// Update dropdown options when groups change
useEffect(() => {
if (!groups) return;
const sortedGroups = sortBy([...groups], "name");
const dropdownGroups = dropdownOptions.filter((g) => g.keepClientState);
const union = unionBy(dropdownGroups, sortedGroups, "name");
addDropdownOptions(union);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [groups]);
const createOrUpdate = async (group: Group) => {
let peers = group?.peers?.map((p) => {
let isString = typeof p === "string";
if (isString) return p;
let peer = p as Peer;
return peer.id;
}) as string[];
if (group.name === "All") return Promise.resolve(group);
const groupID =
group?.id ?? groups?.find((g) => g.name === group.name)?.id ?? undefined;
if (groupID) {
return groupRequest.put(
{
name: group.name,
peers: peers,
},
`/${group.id}`,
);
} else {
return groupRequest.post({
name: group.name,
peers: peers,
});
}
};
return (
<GroupContext.Provider
value={{
@@ -44,7 +120,11 @@ export function GroupsProviderContent({ children }: Props) {
refresh,
dropdownOptions,
setDropdownOptions,
addDropdownOptions,
isLoading,
createOrUpdate,
reset,
updateGroupDropdown,
}}
>
{children}

View File

@@ -77,9 +77,7 @@ export default function PeerProvider({ children, peer }: Props) {
? loginExpiration
: peer.login_expiration_enabled,
approval_required:
approval_required != undefined
? approval_required
: peer.approval_required,
approval_required == undefined ? undefined : approval_required,
},
`/${peer.id}`,
);

View File

@@ -1,5 +1,5 @@
import useFetchApi from "@utils/api";
import React from "react";
import React, { useMemo } from "react";
import type { Peer } from "@/interfaces/Peer";
type Props = {
@@ -9,15 +9,21 @@ type Props = {
const PeerContext = React.createContext(
{} as {
peers: Peer[] | undefined;
isLoading: boolean;
},
);
export default function PeersProvider({ children }: Props) {
const { data: peers } = useFetchApi<Peer[]>("/peers");
export default function PeersProvider({ children }: Readonly<Props>) {
const { data: peers, isLoading } = useFetchApi<Peer[]>("/peers");
return (
<PeerContext.Provider value={{ peers }}>{children}</PeerContext.Provider>
);
const data = useMemo(() => {
return {
peers,
isLoading,
};
}, [peers, isLoading]);
return <PeerContext.Provider value={data}>{children}</PeerContext.Provider>;
}
export const usePeers = () => React.useContext(PeerContext);

View File

@@ -15,12 +15,15 @@ const PoliciesContext = React.createContext(
onSuccess?: (p: Policy) => void,
message?: string,
) => void;
createPolicy: (policy: Policy) => Promise<Policy>;
},
);
export default function PoliciesProvider({ children }: Props) {
const request = useApiCall<Policy>("/policies");
const createPolicy = async (policy: Policy) => request.post(policy);
const updatePolicy = async (
policy: Policy,
toUpdate: Partial<Policy>,
@@ -29,9 +32,8 @@ export default function PoliciesProvider({ children }: Props) {
) => {
notify({
title: "Access Control Policy " + policy.name,
description: message
? message
: "The access control policy was successfully updated",
description:
message || "The access control policy was successfully updated",
promise: request
.put(
{
@@ -55,7 +57,7 @@ export default function PoliciesProvider({ children }: Props) {
};
return (
<PoliciesContext.Provider value={{ updatePolicy }}>
<PoliciesContext.Provider value={{ updatePolicy, createPolicy }}>
{children}
</PoliciesContext.Provider>
);

View File

@@ -34,6 +34,8 @@ export default function RoutesProvider({ children }: Props) {
onSuccess?: (route: Route) => void,
message?: string,
) => {
const hasDomains = route.domains ? route.domains.length > 0 : false;
notify({
title: "Network " + route.network_id + "-" + route.network,
description: message
@@ -48,10 +50,13 @@ export default function RoutesProvider({ children }: Props) {
peer: toUpdate.peer ?? (route.peer || undefined),
peer_groups:
toUpdate.peer_groups ?? (route.peer_groups || undefined),
network: route.network,
network: !hasDomains ? route.network : undefined,
domains: hasDomains ? route.domains : undefined,
keep_route: route.keep_route,
metric: toUpdate.metric ?? route.metric ?? 9999,
masquerade: toUpdate.masquerade ?? route.masquerade ?? true,
groups: toUpdate.groups ?? route.groups ?? [],
access_control_groups: toUpdate.access_control_groups ?? undefined,
},
`/${route.id}`,
)
@@ -80,10 +85,13 @@ export default function RoutesProvider({ children }: Props) {
enabled: route.enabled,
peer: route.peer || undefined,
peer_groups: route.peer_groups || undefined,
network: route.network,
network: route?.network || undefined,
domains: route?.domains || undefined,
keep_route: route?.keep_route || false,
metric: route.metric || 9999,
masquerade: route.masquerade,
groups: route.groups || [],
access_control_groups: route?.access_control_groups || undefined,
})
.then((route) => {
mutate("/routes");

View File

@@ -0,0 +1,21 @@
import { useEffect } from "react";
// Updates the height of a <textarea> when the value changes.
const useAutosizeTextArea = (
textAreaRef: HTMLTextAreaElement | null,
value: string,
) => {
useEffect(() => {
if (textAreaRef) {
// We need to reset the height momentarily to get the correct scrollHeight for the textarea
textAreaRef.style.height = "42px";
const scrollHeight = textAreaRef.scrollHeight;
// We then set the height directly, outside the render loop
// Trying to set this with state or a ref will product an incorrect value.
textAreaRef.style.height = scrollHeight + "px";
}
}, [textAreaRef, value]);
};
export default useAutosizeTextArea;

View File

@@ -19,7 +19,10 @@ type SetValue<T> = Dispatch<SetStateAction<T>>;
export function useLocalStorage<T>(
key: string,
initialValue: T,
enabled: boolean = true,
): [T, SetValue<T>] {
const [tempValue, setTempValue] = useState(initialValue);
// Get from local storage then
// parse stored json or return initialValue
const readValue = useCallback((): T => {
@@ -39,11 +42,18 @@ export function useLocalStorage<T>(
// State to store our value
// Pass initial state function to useState so logic is only executed once
const [storedValue, setStoredValue] = useState<T>(readValue);
const [storedValue, setStoredValue] = useState<T>(
enabled ? readValue : initialValue,
);
// Return a wrapped version of useState's setter function that ...
// ... persists the new value to localStorage.
const setValue: SetValue<T> = useEventCallback((value) => {
if (!enabled) {
setStoredValue(value);
return;
}
// Prevent build error "window is undefined" but keeps working
if (typeof window === "undefined") {
console.warn(
@@ -69,12 +79,14 @@ export function useLocalStorage<T>(
});
useEffect(() => {
if (!enabled) return;
setStoredValue(readValue());
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const handleStorageChange = useCallback(
(event: StorageEvent | CustomEvent) => {
if (!enabled) return;
if ((event as StorageEvent)?.key && (event as StorageEvent).key !== key) {
return;
}
@@ -90,6 +102,9 @@ export function useLocalStorage<T>(
// See: useLocalStorage()
useEventListener("local-storage", handleStorageChange);
if (!enabled) {
return [tempValue, setTempValue];
}
return [storedValue, setValue];
}

View File

@@ -2,6 +2,10 @@
import { OperatingSystem } from "@/interfaces/OperatingSystem";
/**
* Get the operating system of the user based on the user agent of the browser
* This is used for the setup modal to show the correct installation guide
*/
export default function useOperatingSystem() {
const isBrowser = typeof window !== "undefined";
const userAgent = isBrowser ? navigator.userAgent.toLowerCase() : "";
@@ -9,16 +13,26 @@ export default function useOperatingSystem() {
? /(iP*)/g.test(navigator.userAgent) && navigator.maxTouchPoints > 2
: false;
if (iOS) return OperatingSystem.IOS;
// For FreeBSD, we return Linux as we currently don't have an official installation guide for FreeBSD
if (userAgent.toLowerCase().includes("freebsd")) return OperatingSystem.LINUX;
return getOperatingSystem(userAgent);
}
/**
* Get the operating system based on a string (user agent, api response, etc.)
* Falls back to Linux if the operating system is not recognized
*/
export const getOperatingSystem = (os: string) => {
if (os.toLowerCase().includes("freebsd"))
return OperatingSystem.FREEBSD as const;
if (os.toLowerCase().includes("darwin"))
return OperatingSystem.APPLE as const;
if (os.toLowerCase().includes("mac")) return OperatingSystem.APPLE as const;
if (os.toLowerCase().includes("android"))
return OperatingSystem.ANDROID as const;
if (os.toLowerCase().includes("ios")) return OperatingSystem.IOS as const;
if (os.toLowerCase().includes("ipad")) return OperatingSystem.IOS as const;
if (os.toLowerCase().includes("iphone")) return OperatingSystem.IOS as const;
if (os.toLowerCase().includes("windows"))
return OperatingSystem.WINDOWS as const;
return OperatingSystem.LINUX as const;

View File

@@ -0,0 +1,12 @@
import { useLayoutEffect, useRef, useState } from "react";
export function usePortalElement<Element>() {
const ref = useRef<Element>(null);
const [portalTarget, setPortalTarget] = useState<Element | null>(null);
useLayoutEffect(() => {
setPortalTarget(ref.current);
}, []);
return { ref, portalTarget, setPortalTarget };
}

13
src/hooks/usePrevious.ts Normal file
View File

@@ -0,0 +1,13 @@
import { useEffect, useRef } from "react";
const usePrevious = <T>(value: T): T | undefined => {
const ref = useRef<T>();
useEffect(() => {
ref.current = value;
}, [value]);
return ref.current;
};
export default usePrevious;

View File

@@ -1,8 +1,9 @@
import loadConfig from "@utils/config";
import { usePathname, useRouter } from "next/navigation";
import { useEffect } from "react";
import { useEffect, useRef } from "react";
const config = loadConfig();
export const useRedirect = (
url: string,
replace: boolean = false,
@@ -10,24 +11,43 @@ export const useRedirect = (
) => {
const router = useRouter();
const currentPath = usePathname();
const callBackUrls = [config.redirectURI, config.silentRedirectURI];
const callBackUrls = useRef([config.redirectURI, config.silentRedirectURI]);
const isRedirecting = useRef(false);
const intervalRef = useRef<NodeJS.Timeout | null>(null);
useEffect(() => {
if (!enable) return;
if (callBackUrls.includes(url)) return; // Don't redirect to the callback urls to avoid infinite loop
if (url === currentPath) return; // Don't redirect to the current page
// If redirect is disabled or the url is already in the callback urls or the url is the current path then do not redirect
if (!enable || callBackUrls.current.includes(url) || url === currentPath)
return;
const redirect = replace ? router.replace : router.push; // Replace the current history or add a new one
const performRedirect = () => {
if (!isRedirecting.current) {
isRedirecting.current = true;
router.refresh();
if (replace) {
router.replace(url);
} else {
router.push(url);
}
isRedirecting.current = false;
}
};
router.refresh();
redirect(url);
performRedirect();
// Timer in case the user has his browser tab open but not focused
const interval = setInterval(() => {
router.refresh();
redirect(url);
}, 1000);
// Try to redirect after 1.25 seconds if for whatever reason the redirect did not happen (network change, browser tab open but not focused etc.)
intervalRef.current = setInterval(() => {
if (!isRedirecting.current) {
performRedirect();
}
}, 1250);
return () => clearInterval(interval);
}, [replace, router, url, enable]);
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
}
};
}, [replace, router, url, enable, currentPath]);
};
export default useRedirect;

91
src/hooks/useSearch.ts Normal file
View File

@@ -0,0 +1,91 @@
import { debounce as lodashDebounce, isEqual } from "lodash";
import { ChangeEvent, useCallback, useEffect, useRef, useState } from "react";
import usePrevious from "./usePrevious";
export type Predicate<T> = (item: T, query: string) => boolean;
export interface Options {
initialQuery?: string;
filter?: boolean;
debounce?: number;
}
function filterCollection<T>(
collection: T[],
predicate: Predicate<T>,
query: string,
filter: boolean,
): T[] {
if (query) {
return collection.filter((item) => predicate(item, query));
} else {
return filter ? collection : [];
}
}
export function useSearch<T>(
collection: T[],
predicate: Predicate<T>,
{ debounce, filter = false, initialQuery = "" }: Options = {},
): [
T[],
string,
(event: ChangeEvent<HTMLInputElement> | string) => void,
(querty: string) => void,
] {
const isMounted = useRef<boolean>(false);
const [query, setQuery] = useState<string>(initialQuery);
const prevCollection = usePrevious(collection);
const prevPredicate = usePrevious(predicate);
const prevQuery = usePrevious(query);
const prevFilter = usePrevious(filter);
const [filteredCollection, setFilteredCollection] = useState<T[]>(() =>
filterCollection<T>(collection, predicate, query, filter),
);
const handleChange = useCallback(
(event: ChangeEvent<HTMLInputElement> | string) => {
setQuery(typeof event === "string" ? event : event.target.value);
},
[setQuery],
);
const debouncedFilterCollection = useCallback(
lodashDebounce(
(
collection: T[],
predicate: Predicate<T>,
query: string,
filter: boolean,
) => {
if (isMounted.current) {
setFilteredCollection(
filterCollection(collection, predicate, query, filter),
);
}
},
debounce,
),
[debounce],
);
useEffect(() => {
if (
!isEqual(collection, prevCollection) ||
!isEqual(predicate, prevPredicate) ||
!isEqual(query, prevQuery) ||
!isEqual(filter, prevFilter)
)
debouncedFilterCollection(collection, predicate, query, filter);
}, [collection, predicate, query, filter]);
useEffect(() => {
isMounted.current = true;
return () => {
isMounted.current = false;
};
}, []);
return [filteredCollection, query, handleChange, setQuery];
}

View File

@@ -0,0 +1,36 @@
import { useEffect, useMemo, useRef } from "react";
import { Group } from "@/interfaces/Group";
const useSortedDropdownOptions = (
dropdownOptions: Group[],
values: Group[],
isPopupOpen: boolean,
): Group[] => {
const sortOrderRef = useRef<Map<string, number>>(new Map());
const prevValuesRef = useRef<Group[]>([]);
// Update sort order when values change and popup is closed
useEffect(() => {
if (
!isPopupOpen &&
JSON.stringify(values) !== JSON.stringify(prevValuesRef.current)
) {
sortOrderRef.current = new Map(
values.map((group, index) => [group.name, index]),
);
prevValuesRef.current = values;
}
}, [values, isPopupOpen]);
// Sort the dropdown options based on the current sort order
return useMemo(() => {
const sortOrder = sortOrderRef.current;
return [...dropdownOptions].sort((a, b) => {
const indexA = sortOrder.get(a.name) ?? Infinity;
const indexB = sortOrder.get(b.name) ?? Infinity;
return indexA - indexB;
});
}, [dropdownOptions, sortOrderRef.current]);
};
export default useSortedDropdownOptions;

4
src/interfaces/Domain.ts Normal file
View File

@@ -0,0 +1,4 @@
export interface Domain {
id?: string;
name: string;
}

View File

@@ -3,6 +3,8 @@ export interface Group {
name: string;
peers?: GroupPeer[] | string[];
peers_count?: number;
// Frontend only
keepClientState?: boolean;
}
export interface GroupPeer {

View File

@@ -17,11 +17,6 @@ export interface Nameserver {
id?: string;
}
export interface Domain {
id?: string;
name: string;
}
export const NameserverPresets: Record<string, NameserverGroup> = {
Default: {
name: "",

View File

@@ -6,4 +6,5 @@ export enum OperatingSystem {
DOCKER,
IOS,
UNKNOWN,
FREEBSD,
}

View File

@@ -1,13 +1,14 @@
import { Group } from "@/interfaces/Group";
import { PostureCheck } from "@/interfaces/PostureCheck";
export interface Policy {
id?: string;
name: string;
description: string;
enabled: boolean;
query: string;
query?: string;
rules: PolicyRule[];
source_posture_checks: string[];
source_posture_checks: string[] | PostureCheck[];
}
export interface PolicyRule {

View File

@@ -10,6 +10,7 @@ export interface PostureCheck {
os_version_check?: OperatingSystemVersionCheck;
geo_location_check?: GeoLocationCheck;
peer_network_range_check?: PeerNetworkRangeCheck;
process_check?: ProcessCheck;
};
policies?: Policy[];
active?: boolean;
@@ -53,6 +54,17 @@ export interface PeerNetworkRangeCheck {
action: "allow" | "deny";
}
export interface ProcessCheck {
processes: Process[];
}
export interface Process {
id: string;
linux_path?: string;
mac_path?: string;
windows_path?: string;
}
export const windowsKernelVersions: SelectOption[] = [
{ value: "5.0", label: "Windows 2000" },
{ value: "5.1", label: "Windows XP" },

View File

@@ -3,26 +3,36 @@ export interface Route {
description: string;
enabled: boolean;
peer?: string;
network: string;
network?: string;
domains?: string[];
network_id: string;
network_type?: string;
metric?: number;
masquerade: boolean;
groups: string[];
keep_route?: boolean;
access_control_groups?: string[];
// Frontend only
peer_groups?: string[];
routesGroups?: string[];
groupedRoutes?: GroupedRoute[];
group_names?: string[];
domain_search?: string;
}
export interface GroupedRoute {
id: string;
enabled: boolean;
network: string;
network?: string;
domains?: string[];
keep_route?: boolean;
access_control_groups?: string[];
network_id: string;
high_availability_count: number;
is_using_route_groups: boolean;
routes?: Route[];
group_names?: string[];
description?: string;
description_search?: string;
domain_search?: string;
}

View File

@@ -1,6 +1,7 @@
"use client";
import "../app/globals.css";
import { DisableDarkReader } from "@components/DisableDarkReader";
import { TooltipProvider } from "@components/Tooltip";
import { cn } from "@utils/helpers";
import dayjs from "dayjs";
@@ -52,6 +53,7 @@ export default function AppLayout({ children }: { children: React.ReactNode }) {
}}
/>
<NavigationEvents />
<DisableDarkReader />
</AnalyticsProvider>
</body>
</html>

View File

@@ -14,7 +14,6 @@ import {
ModalTrigger,
} from "@components/modal/Modal";
import ModalHeader from "@components/modal/ModalHeader";
import { notify } from "@components/Notification";
import Paragraph from "@components/Paragraph";
import { PeerGroupSelector } from "@components/PeerGroupSelector";
import { PortSelector } from "@components/PortSelector";
@@ -27,10 +26,8 @@ import {
} from "@components/Select";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@components/Tabs";
import { Textarea } from "@components/Textarea";
import PolicyDirection, { Direction } from "@components/ui/PolicyDirection";
import useFetchApi, { useApiCall } from "@utils/api";
import PolicyDirection from "@components/ui/PolicyDirection";
import { cn } from "@utils/helpers";
import { uniqBy } from "lodash";
import {
ArrowRightLeft,
ExternalLinkIcon,
@@ -42,14 +39,11 @@ import {
Shield,
Text,
} from "lucide-react";
import React, { useEffect, useMemo, useRef, useState } from "react";
import { useSWRConfig } from "swr";
import React, { useMemo, useState } from "react";
import AccessControlIcon from "@/assets/icons/AccessControlIcon";
import { usePolicies } from "@/contexts/PoliciesProvider";
import { Group } from "@/interfaces/Group";
import { Policy, Protocol } from "@/interfaces/Policy";
import { PostureCheck } from "@/interfaces/PostureCheck";
import useGroupHelper from "@/modules/groups/useGroupHelper";
import { useAccessControl } from "@/modules/access-control/useAccessControl";
import { PostureCheckTab } from "@/modules/posture-checks/ui/PostureCheckTab";
import { PostureCheckTabTrigger } from "@/modules/posture-checks/ui/PostureCheckTabTrigger";
@@ -62,20 +56,20 @@ type UpdateModalProps = {
open: boolean;
onOpenChange?: (open: boolean) => void;
cell?: string;
postureCheckTemplates?: PostureCheck[];
onSuccess?: (policy: Policy) => void;
useSave?: boolean;
allowEditPeers?: boolean;
};
export default function AccessControlModal({ children }: Props) {
export default function AccessControlModal({ children }: Readonly<Props>) {
const [modal, setModal] = useState(false);
return (
<>
<Modal open={modal} onOpenChange={setModal} key={modal ? 1 : 0}>
{children && <ModalTrigger asChild>{children}</ModalTrigger>}
{modal && (
<AccessControlModalContent onSuccess={() => setModal(false)} />
)}
</Modal>
</>
<Modal open={modal} onOpenChange={setModal} key={modal ? 1 : 0}>
{children && <ModalTrigger asChild>{children}</ModalTrigger>}
{modal && <AccessControlModalContent onSuccess={() => setModal(false)} />}
</Modal>
);
}
@@ -84,19 +78,27 @@ export function AccessControlUpdateModal({
open,
onOpenChange,
cell,
}: UpdateModalProps) {
postureCheckTemplates,
onSuccess,
useSave = true,
allowEditPeers,
}: Readonly<UpdateModalProps>) {
return (
<>
<Modal open={open} onOpenChange={onOpenChange} key={open ? 1 : 0}>
{open && (
<AccessControlModalContent
onSuccess={() => onOpenChange && onOpenChange(false)}
policy={policy}
cell={cell}
/>
)}
</Modal>
</>
<Modal open={open} onOpenChange={onOpenChange} key={open ? 1 : 0}>
{open && (
<AccessControlModalContent
onSuccess={(p) => {
onOpenChange && onOpenChange(false);
onSuccess && onSuccess(p);
}}
policy={policy}
cell={cell}
postureCheckTemplates={postureCheckTemplates}
useSave={useSave}
allowEditPeers={allowEditPeers}
/>
)}
</Modal>
);
}
@@ -104,18 +106,43 @@ type ModalProps = {
onSuccess?: (p: Policy) => void;
policy?: Policy;
cell?: string;
postureCheckTemplates?: PostureCheck[];
useSave?: boolean;
allowEditPeers?: boolean;
};
export function AccessControlModalContent({
onSuccess,
policy,
cell,
}: ModalProps) {
const { data: allPostureChecks, isLoading: isPostureChecksLoading } =
useFetchApi<PostureCheck[]>("/posture-checks");
const { updatePolicy } = usePolicies();
const firstRule = policy?.rules ? policy.rules[0] : undefined;
postureCheckTemplates,
useSave = true,
allowEditPeers = false,
}: Readonly<ModalProps>) {
const {
portAndDirectionDisabled,
destinationGroups,
direction,
ports,
sourceGroups,
setSourceGroups,
setDestinationGroups,
setPorts,
setDirection,
setProtocol,
enabled,
setEnabled,
setName,
setDescription,
setPostureChecks,
name,
protocol,
description,
postureChecks,
submit,
isPostureChecksLoading,
getPolicyData,
} = useAccessControl({ policy, postureCheckTemplates, onSuccess });
const [tab, setTab] = useState(() => {
if (!cell) return "policy";
@@ -124,152 +151,36 @@ export function AccessControlModalContent({
return "policy";
});
const [enabled, setEnabled] = useState<boolean>(policy?.enabled ?? true);
const [ports, setPorts] = useState<number[]>(() => {
if (!firstRule) return [];
if (firstRule.ports == undefined) return [];
if (firstRule.ports.length > 0) {
return firstRule.ports.map((p) => Number(p));
const continuePostureChecksDisabled = useMemo(() => {
if (sourceGroups.length == 0 || destinationGroups.length == 0) return true;
if (direction != "bi" && ports.length == 0) return true;
}, [sourceGroups, destinationGroups, direction, ports]);
const submitDisabled = useMemo(() => {
if (name.length == 0) return true;
if (continuePostureChecksDisabled) return true;
}, [name, continuePostureChecksDisabled]);
const handleProtocolChange = (p: Protocol) => {
setProtocol(p);
if (p == "icmp") {
setPorts([]);
}
return [];
});
const [protocol, setProtocol] = useState<Protocol>(
firstRule ? firstRule.protocol : "all",
);
const [direction, setDirection] = useState<Direction>(() => {
if (firstRule && firstRule?.bidirectional) return "bi";
if (firstRule && firstRule?.bidirectional == false) return "in";
return "bi";
});
const [name, setName] = useState(policy?.name || "");
const [description, setDescription] = useState(policy?.description || "");
const { mutate } = useSWRConfig();
const policyRequest = useApiCall<Policy>("/policies");
const [
sourceGroups,
setSourceGroups,
{ getGroupsToUpdate: getSourceGroupsToUpdate },
] = useGroupHelper({
initial: firstRule ? (firstRule.sources as Group[]) : [],
});
const [
destinationGroups,
setDestinationGroups,
{ getGroupsToUpdate: getDestinationGroupsToUpdate },
] = useGroupHelper({
initial: firstRule ? (firstRule.destinations as Group[]) : [],
});
const submit = async () => {
const g1 = getSourceGroupsToUpdate();
const g2 = getDestinationGroupsToUpdate();
const createOrUpdateGroups = uniqBy([...g1, ...g2], "name").map(
(g) => g.promise,
);
const groups = await Promise.all(
createOrUpdateGroups.map((call) => call()),
);
let sources = sourceGroups
.map((g) => {
const find = groups.find((group) => group.name === g.name);
return find?.id;
})
.filter((g) => g !== undefined) as string[];
let destinations = destinationGroups
.map((g) => {
const find = groups.find((group) => group.name === g.name);
return find?.id;
})
.filter((g) => g !== undefined) as string[];
if (direction == "out") {
const tmp = sources;
sources = destinations;
destinations = tmp;
if (p == "all") {
setPorts([]);
}
const policyObj = {
name,
description,
enabled,
source_posture_checks: postureChecks
? postureChecks.map((c) => c.id)
: undefined,
rules: [
{
bidirectional: direction == "bi",
description,
name,
action: "accept",
protocol,
enabled,
sources,
destinations,
ports: ports.length > 0 ? ports.map((p) => p.toString()) : undefined,
},
],
} as Policy;
if (policy) {
updatePolicy(
policy,
policyObj,
() => {
mutate("/policies");
onSuccess && onSuccess(policy);
},
"The policy was successfully saved",
);
} else {
notify({
title: "Create Access Control Policy",
description: "Policy was created successfully.",
loadingMessage: "Creating your policy...",
promise: policyRequest.post(policyObj).then((policy) => {
mutate("/policies");
onSuccess && onSuccess(policy);
}),
});
if (p == "tcp" || p == "udp") {
setDirection("in");
}
};
const portAndDirectionDisabled = protocol == "icmp" || protocol == "all";
const buttonDisabled = useMemo(() => {
if (sourceGroups.length == 0 || destinationGroups.length == 0) return true;
if (name.length == 0) return true;
if (direction != "bi" && ports.length == 0) return true;
}, [sourceGroups, destinationGroups, direction, ports, name]);
const [postureChecks, setPostureChecks] = useState<PostureCheck[]>([]);
const postureChecksLoaded = useRef(false);
const initialPostureChecks = useMemo(() => {
return (
allPostureChecks?.filter((check) => {
if (policy?.source_posture_checks) {
return policy.source_posture_checks.includes(check.id);
}
return false;
}) || []
);
}, [policy, allPostureChecks]);
useEffect(() => {
if (postureChecksLoaded.current) return;
if (initialPostureChecks.length > 0) {
postureChecksLoaded.current = true;
setPostureChecks(initialPostureChecks);
}
}, [initialPostureChecks]);
const close = () => {
const data = getPolicyData();
onSuccess && onSuccess(data);
};
return (
<ModalContent maxWidthClass={"max-w-2xl"}>
<ModalContent maxWidthClass={"max-w-3xl"}>
<ModalHeader
icon={<AccessControlIcon className={"fill-netbird"} />}
title={
@@ -283,14 +194,17 @@ export function AccessControlModalContent({
color={"netbird"}
/>
<Tabs defaultValue={tab} onValueChange={(v) => setTab(v)}>
<Tabs defaultValue={tab} onValueChange={(v) => setTab(v)} value={tab}>
<TabsList justify={"start"} className={"px-8"}>
<TabsTrigger value={"policy"}>
<ArrowRightLeft size={16} />
Policy
</TabsTrigger>
<PostureCheckTabTrigger />
<TabsTrigger value={"general"}>
<PostureCheckTabTrigger disabled={continuePostureChecksDisabled} />
<TabsTrigger
value={"general"}
disabled={continuePostureChecksDisabled}
>
<Text
size={16}
className={
@@ -303,7 +217,10 @@ export function AccessControlModalContent({
<TabsContent value={"policy"} className={"pb-8"}>
<div className={"px-8 flex-col flex gap-6"}>
<div className={"flex justify-between items-center"}>
<div
className={"flex justify-between items-center"}
data-cy={"protocol-wrapper"}
>
<div>
<Label>Protocol</Label>
<HelpText className={"max-w-sm"}>
@@ -315,15 +232,18 @@ export function AccessControlModalContent({
</div>
<Select
value={protocol}
onValueChange={(v) => setProtocol(v as Protocol)}
onValueChange={(v) => handleProtocolChange(v as Protocol)}
>
<SelectTrigger className="w-[140px]">
<div className={"flex items-center gap-3"}>
<div
className={"flex items-center gap-3"}
data-cy={"protocol-select-button"}
>
<Share2 size={15} className={"text-nb-gray-300"} />
<SelectValue placeholder="Select protocol..." />
</div>
</SelectTrigger>
<SelectContent>
<SelectContent data-cy={"protocol-selection"}>
<SelectItem value="all">ALL</SelectItem>
<SelectItem value="tcp">TCP</SelectItem>
<SelectItem value="udp">UDP</SelectItem>
@@ -339,9 +259,14 @@ export function AccessControlModalContent({
Source
</Label>
<PeerGroupSelector
dataCy={"source-group-selector"}
showPeerCount={allowEditPeers}
disableInlineRemoveGroup={false}
popoverWidth={500}
showRoutes={false}
onChange={setSourceGroups}
values={sourceGroups}
saveGroupAssignments={useSave}
/>
</div>
<PolicyDirection
@@ -356,9 +281,14 @@ export function AccessControlModalContent({
Destination
</Label>
<PeerGroupSelector
dataCy={"destination-group-selector"}
showRoutes={true}
showPeerCount={allowEditPeers}
disableInlineRemoveGroup={false}
popoverWidth={500}
onChange={setDestinationGroups}
values={destinationGroups}
saveGroupAssignments={useSave}
/>
</div>
</div>
@@ -418,6 +348,7 @@ export function AccessControlModalContent({
autoFocus={true}
tabIndex={0}
value={name}
data-cy={"policy-name"}
onChange={(e) => setName(e.target.value)}
placeholder={"e.g., Devs to Servers"}
/>
@@ -429,6 +360,7 @@ export function AccessControlModalContent({
</HelpText>
<Textarea
value={description}
data-cy={"policy-description"}
onChange={(e) => setDescription(e.target.value)}
placeholder={
"e.g., Devs are allowed to access servers and servers are allowed to access Devs."
@@ -456,24 +388,81 @@ export function AccessControlModalContent({
</Paragraph>
</div>
<div className={"flex gap-3 w-full justify-end"}>
<ModalClose asChild={true}>
<Button variant={"secondary"}>Cancel</Button>
</ModalClose>
{!policy ? (
<>
{tab == "policy" && (
<ModalClose asChild={true}>
<Button variant={"secondary"}>Cancel</Button>
</ModalClose>
)}
<Button
variant={"primary"}
disabled={buttonDisabled}
onClick={submit}
>
{policy ? (
<>Save Changes</>
) : (
<>
<PlusCircle size={16} />
Add Policy
</>
)}
</Button>
{tab == "posture_checks" && (
<Button variant={"secondary"} onClick={() => setTab("policy")}>
Back
</Button>
)}
{tab == "policy" && (
<Button
variant={"primary"}
onClick={() => setTab("posture_checks")}
disabled={continuePostureChecksDisabled}
>
Continue
</Button>
)}
{tab == "posture_checks" && (
<Button
variant={"primary"}
onClick={() => setTab("general")}
disabled={continuePostureChecksDisabled}
>
Continue
</Button>
)}
{tab == "general" && (
<>
<Button
variant={"secondary"}
onClick={() => setTab("posture_checks")}
>
Back
</Button>
<Button
variant={"primary"}
disabled={submitDisabled}
onClick={submit}
data-cy={"submit-policy"}
>
<PlusCircle size={16} />
Add Policy
</Button>
</>
)}
</>
) : (
<>
<ModalClose asChild={true}>
<Button variant={"secondary"}>Cancel</Button>
</ModalClose>
<Button
variant={"primary"}
disabled={submitDisabled}
onClick={() => {
if (useSave) {
submit();
} else {
close();
}
}}
>
Save Changes
</Button>
</>
)}
</div>
</ModalFooter>
</ModalContent>

View File

@@ -1,4 +1,5 @@
import { ToggleSwitch } from "@components/ToggleSwitch";
import { cloneDeep } from "@utils/helpers";
import React, { useMemo } from "react";
import { mutate } from "swr";
import { usePolicies } from "@/contexts/PoliciesProvider";
@@ -8,7 +9,7 @@ import { Policy } from "@/interfaces/Policy";
type Props = {
policy: Policy;
};
export default function AccessControlActiveCell({ policy }: Props) {
export default function AccessControlActiveCell({ policy }: Readonly<Props>) {
const { updatePolicy } = usePolicies();
const isChecked = useMemo(() => {
@@ -16,7 +17,7 @@ export default function AccessControlActiveCell({ policy }: Props) {
}, [policy]);
const update = async (enabled: boolean) => {
const rules = [...policy.rules];
const rules = cloneDeep(policy.rules);
rules.forEach((rule) => {
rule.enabled = enabled;
rule.sources = rule.sources
@@ -26,8 +27,8 @@ export default function AccessControlActiveCell({ policy }: Props) {
}) as string[])
: [];
rule.destinations = rule.destinations
? (rule.destinations.map((source) => {
const group = source as Group;
? (rule.destinations.map((destination) => {
const group = destination as Group;
return group.id;
}) as string[])
: [];

View File

@@ -6,12 +6,14 @@ import ActiveInactiveRow from "@/modules/common-table-rows/ActiveInactiveRow";
type Props = {
policy: Policy;
};
export default function AccessControlNameCell({ policy }: Props) {
export default function AccessControlNameCell({ policy }: Readonly<Props>) {
return (
<ActiveInactiveRow
active={policy.enabled}
inactiveDot={"gray"}
text={policy.name}
dataCy={policy.name}
>
<DescriptionWithTooltip className={"mt-1"} text={policy.description} />
</ActiveInactiveRow>

View File

@@ -5,7 +5,7 @@ import {
TooltipProvider,
TooltipTrigger,
} from "@components/Tooltip";
import React, { useMemo, useState } from "react";
import React, { useMemo } from "react";
import { Policy } from "@/interfaces/Policy";
type Props = {
@@ -19,15 +19,15 @@ export default function AccessControlPortsCell({ policy }: Props) {
const hasPorts = firstRule?.ports && firstRule?.ports.length > 0;
const [firstTwoPorts] = useState(() => {
const firstTwoPorts = useMemo(() => {
if (!hasPorts) return [];
return firstRule?.ports.slice(0, 2) ?? [];
});
}, [hasPorts, firstRule]);
const [otherPorts] = useState(() => {
const otherPorts = useMemo(() => {
if (!hasPorts) return [];
return firstRule?.ports.slice(2) ?? [];
});
}, [hasPorts, firstRule]);
return (
<div className={"flex-1"}>

View File

@@ -31,6 +31,7 @@ import AccessControlSourcesCell from "@/modules/access-control/table/AccessContr
type Props = {
policies?: Policy[];
isLoading: boolean;
headingTarget?: HTMLHeadingElement | null;
};
export const AccessControlTableColumns: ColumnDef<Policy>[] = [
@@ -161,7 +162,11 @@ export const AccessControlTableColumns: ColumnDef<Policy>[] = [
},
];
export default function AccessControlTable({ policies, isLoading }: Props) {
export default function AccessControlTable({
policies,
isLoading,
headingTarget,
}: Props) {
const { mutate } = useSWRConfig();
const path = usePathname();
@@ -191,8 +196,9 @@ export default function AccessControlTable({ policies, isLoading }: Props) {
/>
)}
<DataTable
headingTarget={headingTarget}
isLoading={isLoading}
text={"Access Control"}
text={"Access Control Policies"}
sorting={sorting}
setSorting={setSorting}
columns={AccessControlTableColumns}
@@ -222,12 +228,14 @@ export default function AccessControlTable({ policies, isLoading }: Props) {
"It looks like you don't have any policies yet. Policies can allow connections by specific protocol and ports."
}
button={
<AccessControlModal>
<Button variant={"primary"} className={""}>
<PlusCircle size={16} />
Add Policy
</Button>
</AccessControlModal>
<div className={"flex gap-4 items-center justify-center"}>
<AccessControlModal>
<Button variant={"primary"} className={""}>
<PlusCircle size={16} />
Add Policy
</Button>
</AccessControlModal>
</div>
}
learnMore={
<>
@@ -246,12 +254,14 @@ export default function AccessControlTable({ policies, isLoading }: Props) {
rightSide={() => (
<>
{policies && policies?.length > 0 && (
<AccessControlModal>
<Button variant={"primary"} className={"ml-auto"}>
<PlusCircle size={16} />
Add Policy
</Button>
</AccessControlModal>
<div className={"flex ml-auto gap-4"}>
<AccessControlModal>
<Button variant={"primary"} className={"ml-auto"}>
<PlusCircle size={16} />
Add Policy
</Button>
</AccessControlModal>
</div>
)}
</>
)}

View File

@@ -0,0 +1,262 @@
import { notify } from "@components/Notification";
import { Direction } from "@components/ui/PolicyDirection";
import useFetchApi, { useApiCall } from "@utils/api";
import { merge, uniqBy } from "lodash";
import { useEffect, useMemo, useRef, useState } from "react";
import { useSWRConfig } from "swr";
import { usePolicies } from "@/contexts/PoliciesProvider";
import { Group } from "@/interfaces/Group";
import { Policy, Protocol } from "@/interfaces/Policy";
import { PostureCheck } from "@/interfaces/PostureCheck";
import useGroupHelper from "@/modules/groups/useGroupHelper";
import { usePostureCheck } from "@/modules/posture-checks/usePostureCheck";
type Props = {
policy?: Policy;
postureCheckTemplates?: PostureCheck[];
onSuccess?: (policy: Policy) => void;
};
// TODO add reducer
export const useAccessControl = ({
policy,
postureCheckTemplates,
onSuccess,
}: Props = {}) => {
const { data: allPostureChecks, isLoading: isPostureChecksLoading } =
useFetchApi<PostureCheck[]>("/posture-checks");
const [postureChecks, setPostureChecks] = useState<PostureCheck[]>([]);
const postureChecksLoaded = useRef(false);
const initialPostureChecks = useMemo(() => {
const foundChecks =
allPostureChecks?.filter((check) => {
if (policy?.source_posture_checks) {
if (
policy.source_posture_checks.every((id) => typeof id === "string")
) {
let checks = policy.source_posture_checks as string[];
return checks.includes(check.id);
} else {
return policy.source_posture_checks.some((c) => {
let policyCheck = c as PostureCheck;
return policyCheck.id === check.id;
});
}
}
return false;
}) || [];
const templates = postureCheckTemplates || [];
return merge(foundChecks, templates);
}, [policy, allPostureChecks, postureCheckTemplates]);
useEffect(() => {
if (postureChecksLoaded.current) return;
if (initialPostureChecks.length > 0) {
postureChecksLoaded.current = true;
setPostureChecks(initialPostureChecks);
}
}, [initialPostureChecks]);
const { updatePolicy } = usePolicies();
const firstRule = policy?.rules ? policy.rules[0] : undefined;
const [enabled, setEnabled] = useState<boolean>(policy?.enabled ?? true);
const [ports, setPorts] = useState<number[]>(() => {
if (!firstRule) return [];
if (firstRule.ports == undefined) return [];
if (firstRule.ports.length > 0) {
return firstRule.ports.map((p) => Number(p));
}
return [];
});
const [protocol, setProtocol] = useState<Protocol>(
firstRule ? firstRule.protocol : "all",
);
const [direction, setDirection] = useState<Direction>(() => {
if (firstRule && firstRule?.bidirectional) return "bi";
if (firstRule && firstRule?.bidirectional == false) return "in";
return "bi";
});
const [name, setName] = useState(policy?.name || "");
const [description, setDescription] = useState(policy?.description || "");
const { mutate } = useSWRConfig();
const policyRequest = useApiCall<Policy>("/policies");
const [
sourceGroups,
setSourceGroups,
{ getGroupsToUpdate: getSourceGroupsToUpdate },
] = useGroupHelper({
initial: firstRule ? (firstRule.sources as Group[]) : [],
});
const [
destinationGroups,
setDestinationGroups,
{ getGroupsToUpdate: getDestinationGroupsToUpdate },
] = useGroupHelper({
initial: firstRule ? (firstRule.destinations as Group[]) : [],
});
const { updateOrCreateAndNotify: checkToCreate } = usePostureCheck({});
const createPostureChecksWithoutID = async () => {
const checks = postureChecks.filter(
(check) => check?.id === undefined || check?.id === "",
);
const createChecks = checks.map((check) => checkToCreate(check));
return Promise.all(createChecks);
};
const getPolicyData = () => {
let sources = sourceGroups;
let destinations = destinationGroups;
if (direction == "out") {
const tmp = sourceGroups;
sources = destinations;
destinations = tmp;
}
return {
name,
description,
enabled,
source_posture_checks: postureChecks,
rules: [
{
bidirectional: direction == "bi",
description,
name,
sources: sources,
destinations: destinations,
action: "accept",
protocol,
enabled,
ports: ports.length > 0 ? ports.map((p) => p.toString()) : undefined,
},
],
} as Policy;
};
const submit = async () => {
const g1 = getSourceGroupsToUpdate();
const g2 = getDestinationGroupsToUpdate();
const createOrUpdateGroups = uniqBy([...g1, ...g2], "name").map(
(g) => g.promise,
);
const groups = await Promise.all(
createOrUpdateGroups.map((call) => call()),
);
// Create posture checks if they don't have an ID
let hasError = false;
let allChecks = postureChecks;
await createPostureChecksWithoutID()
.then((checks) => {
allChecks = [...allChecks, ...(checks as PostureCheck[])];
})
.catch((e) => {
hasError = true;
console.error(e);
});
if (hasError) return;
let sources = sourceGroups
.map((g) => {
const find = groups.find((group) => group.name === g.name);
return find?.id;
})
.filter((g) => g !== undefined) as string[];
let destinations = destinationGroups
.map((g) => {
const find = groups.find((group) => group.name === g.name);
return find?.id;
})
.filter((g) => g !== undefined) as string[];
if (direction == "out") {
const tmp = sources;
sources = destinations;
destinations = tmp;
}
const policyObj = {
name,
description,
enabled,
source_posture_checks: postureChecks
? postureChecks.map((c) => c.id)
: undefined,
rules: [
{
bidirectional: direction == "bi",
description,
name,
action: "accept",
protocol,
enabled,
sources,
destinations,
ports: ports.length > 0 ? ports.map((p) => p.toString()) : undefined,
},
],
} as Policy;
if (policy && policy?.id !== undefined) {
updatePolicy(
policy,
policyObj,
() => {
mutate("/policies");
onSuccess && onSuccess(policy);
},
"The policy was successfully saved",
);
} else {
notify({
title: "Create Access Control Policy",
description: "Policy was created successfully.",
loadingMessage: "Creating your policy...",
promise: policyRequest.post(policyObj).then((policy) => {
mutate("/policies");
onSuccess && onSuccess(policy);
}),
});
}
};
const portAndDirectionDisabled = protocol == "icmp" || protocol == "all";
return {
protocol,
setProtocol,
direction,
setDirection,
name,
setName,
description,
setDescription,
enabled,
setEnabled,
ports,
setPorts,
sourceGroups,
setSourceGroups,
destinationGroups,
setDestinationGroups,
postureChecks,
setPostureChecks,
submit,
getPolicyData,
portAndDirectionDisabled,
isPostureChecksLoading,
} as const;
};

View File

@@ -91,6 +91,7 @@ export default function AccessTokensTable({ user }: Props) {
text={"Access Tokens"}
tableClassName={"mt-0"}
minimal={true}
showSearchAndFilters={false}
inset={false}
sorting={sorting}
setSorting={setSorting}
@@ -98,8 +99,9 @@ export default function AccessTokensTable({ user }: Props) {
data={tokens}
/>
) : (
<div className={"py-3 bg-nb-gray-950 overflow-hidden"}>
<div className={"bg-nb-gray-950 overflow-hidden"}>
<NoResults
className={"py-3"}
title={"No access tokens"}
description={
"You don't have any access tokens yet. You can add a token to access the NetBird API."

View File

@@ -3,7 +3,11 @@ import { Label } from "@components/Label";
import { IconInfoCircle } from "@tabler/icons-react";
import { cn } from "@utils/helpers";
import { isLocalDev, isProduction } from "@utils/netbird";
import { isEmpty } from "lodash";
import { GlobeIcon } from "lucide-react";
import React, { useMemo } from "react";
import RoundedFlag from "@/assets/countries/RoundedFlag";
import { useCountries } from "@/contexts/CountryProvider";
import { ActivityEvent } from "@/interfaces/ActivityEvent";
type Props = {
@@ -54,7 +58,8 @@ export default function ActivityDescription({ event }: Props) {
if (event.activity_code == "setupkey.peer.add")
return (
<div className={"inline"}>
Peer <Value>{m.name}</Value> with ip <Value>{m.ip}</Value> was added
Peer <Value>{m.name}</Value> <PeerConnectionInfo meta={m} /> was added
with the NetBird IP <Value>{m.ip}</Value>
</div>
);
@@ -113,29 +118,38 @@ export default function ActivityDescription({ event }: Props) {
* Route
*/
if (event.activity_code == "route.delete")
if (event.activity_code == "route.delete") {
let hasDomains = m?.domains && m?.domains.length > 0;
return (
<div className={"inline"}>
Route <Value>{m.name}</Value> with the <Value>{m.network_range}</Value>{" "}
range was deleted
Route <Value>{m.name}</Value> with the {hasDomains ? "domain(s)" : ""}{" "}
<Value>{hasDomains ? m?.domains : m.network_range}</Value>{" "}
{hasDomains ? "" : "range"} was deleted
</div>
);
}
if (event.activity_code == "route.update")
if (event.activity_code == "route.update") {
let hasDomains = m?.domains && m?.domains.length > 0;
return (
<div className={"inline"}>
Route <Value>{m.name}</Value> with the <Value>{m.network_range}</Value>{" "}
range was updated
Route <Value>{m.name}</Value> with the {hasDomains ? "domain(s)" : ""}{" "}
<Value>{hasDomains ? m?.domains : m.network_range}</Value>{" "}
{hasDomains ? "" : "range"} was updated
</div>
);
}
if (event.activity_code == "route.add")
if (event.activity_code == "route.add") {
let hasDomains = m?.domains && m?.domains.length > 0;
return (
<div className={"inline"}>
Route <Value>{m.name}</Value> with the <Value>{m.network_range}</Value>{" "}
range was created
Route <Value>{m.name}</Value> with the {hasDomains ? "domain(s)" : ""}{" "}
<Value>{hasDomains ? m?.domains : m.network_range}</Value>{" "}
{hasDomains ? "" : "range"} was created
</div>
);
}
/**
* User
@@ -144,21 +158,24 @@ export default function ActivityDescription({ event }: Props) {
if (event.activity_code == "user.peer.delete")
return (
<div className={"inline"}>
Peer <Value>{m.name}</Value> with ip <Value>{m.ip}</Value> was deleted
Peer <Value>{m.name}</Value> <PeerConnectionInfo meta={m} /> with
NetBird IP <Value>{m.ip}</Value> was deleted
</div>
);
if (event.activity_code == "user.peer.add")
return (
<div className={"inline"}>
Peer <Value>{m.name}</Value> with ip <Value>{m.ip}</Value> was added
Peer <Value>{m.name}</Value> <PeerConnectionInfo meta={m} /> was added
with the NetBird IP <Value>{m.ip}</Value>
</div>
);
if (event.activity_code == "user.peer.update")
return (
<div className={"inline"}>
Peer <Value>{m.name}</Value> with ip <Value>{m.ip}</Value> was updated
Peer <Value>{m.name}</Value> <PeerConnectionInfo meta={m} /> with
NetBird IP <Value>{m.ip}</Value> was updated
</div>
);
@@ -252,15 +269,15 @@ export default function ActivityDescription({ event }: Props) {
if (event.activity_code == "peer.group.delete")
return (
<div className={"inline"}>
Group <Value>{m.group}</Value> was removed from the peer with the ip{" "}
<Value>{m.peer_ip}</Value>
Group <Value>{m.group}</Value> was removed from the peer with the
NetBird IP <Value>{m.peer_ip}</Value>
</div>
);
if (event.activity_code == "peer.group.add")
return (
<div className={"inline"}>
Group <Value>{m.group}</Value> was added to the peer with the ip{" "}
Group <Value>{m.group}</Value> was added to the peer with the NetBird IP{" "}
<Value>{m.peer_ip}</Value>
</div>
);
@@ -303,7 +320,7 @@ export default function ActivityDescription({ event }: Props) {
if (event.activity_code == "peer.rename")
return (
<div className={"inline"}>
Peer with the ip <Value>{m.ip}</Value> was renamed to{" "}
Peer with the NetBird IP <Value>{m.ip}</Value> was renamed to{" "}
<Value>{m.name}</Value>
</div>
);
@@ -311,7 +328,7 @@ export default function ActivityDescription({ event }: Props) {
if (event.activity_code == "peer.approve")
return (
<div className={"inline"}>
Peer with the ip <Value>{m.ip}</Value> was approved
Peer with the NetBird IP <Value>{m.ip}</Value> was approved
</div>
);
@@ -559,7 +576,7 @@ function Value({
return children ? (
<span
className={cn(
"text-nb-gray-200 inline font-medium bg-nb-gray-900 py-[3px] text-[11px] px-[5px] border border-nb-gray-800 rounded-[4px]",
"text-nb-gray-200 inline-flex gap-1 items-center max-h-[22px] font-medium bg-nb-gray-900 py-[3px] text-[11px] px-[5px] border border-nb-gray-800 rounded-[4px]",
className,
)}
>
@@ -567,3 +584,40 @@ function Value({
</span>
) : null;
}
function PeerConnectionInfo({ meta }: { meta: any }) {
const hasMeta =
!isEmpty(meta?.location_country_code) ||
!isEmpty(meta?.location_connection_ip);
const { countries } = useCountries();
const countryText = useMemo(() => {
if (!countries) return "Unknown";
const country = countries.find(
(c) => c.country_code === meta?.location_country_code,
);
if (!country) return "Unknown";
if (!meta?.location_city_name) return country.country_name;
return `${country.country_name}, ${meta?.location_city_name}`;
}, [countries, meta]);
return hasMeta ? (
<>
{" "}
from{" "}
{meta?.location_connection_ip && (
<Value>{meta?.location_connection_ip}</Value>
)}{" "}
{meta?.location_country_code && (
<Value>
{isEmpty(meta?.location_country_code) ? (
<GlobeIcon size={9} className={"text-nb-gray-300"} />
) : (
<RoundedFlag country={meta?.location_country_code} size={9} />
)}
{countryText}
</Value>
)}
</>
) : null;
}

View File

@@ -149,7 +149,7 @@ export function ActivityEventCodeSelector({
<ScrollArea
className={
"max-h-[380px] overflow-y-auto flex flex-col gap-1 pl-2 py-2 pr-3"
"max-h-[380px] overflow-y-hidden flex flex-col gap-1 pl-2 py-2 pr-3"
}
>
{Object.keys(groupedEventNames).map((group) => {

View File

@@ -24,6 +24,7 @@ import { ActivityUserSelector } from "@/modules/activity/ActivityUserSelector";
type Props = {
events?: ActivityEvent[];
isLoading: boolean;
headingTarget?: HTMLHeadingElement | null;
};
const ActivityFeedColumnsTable: ColumnDef<ActivityEvent>[] = [
@@ -52,7 +53,14 @@ const ActivityFeedColumnsTable: ColumnDef<ActivityEvent>[] = [
},
];
export default function ActivityTable({ events, isLoading }: Props) {
const defaultFromDate = dayjs().subtract(14, "day").toDate();
const defaultToDate = dayjs().toDate();
export default function ActivityTable({
events,
isLoading,
headingTarget,
}: Props) {
const { mutate } = useSWRConfig();
const path = usePathname();
@@ -68,8 +76,8 @@ export default function ActivityTable({ events, isLoading }: Props) {
const [initialDateRange, setInitialDateRange] = useLocalStorage<
DateRange | undefined
>("netbird-table-range" + path, {
from: dayjs().subtract(14, "day").toDate(),
to: dayjs().toDate(),
from: defaultFromDate,
to: defaultToDate,
});
// Range for DatePicker
@@ -80,6 +88,7 @@ export default function ActivityTable({ events, isLoading }: Props) {
return (
<DataTable
headingTarget={headingTarget}
wrapperClassName={"gap-0 flex flex-col"}
tableClassName={"px-8 mt-10"}
paginationClassName={"max-w-[800px]"}
@@ -125,6 +134,11 @@ export default function ActivityTable({ events, isLoading }: Props) {
}
/>
}
onFilterReset={() => {
const date = { from: defaultFromDate, to: defaultToDate };
setInitialDateRange(date);
setDateRange(date);
}}
>
{(table) => {
return (

View File

@@ -175,7 +175,7 @@ export function ActivityUserSelector({
<ScrollArea
className={
"max-h-[380px] overflow-y-auto flex flex-col gap-1 pl-2 py-2 pr-3"
"max-h-[380px] overflow-y-hidden flex flex-col gap-1 pl-2 py-2 pr-3"
}
>
<CommandGroup>

View File

@@ -11,7 +11,9 @@ type Props = {
text?: string | React.ReactNode;
className?: string;
additionalInfo?: React.ReactNode;
dataCy?: string;
};
export default function ActiveInactiveRow({
active,
children,
@@ -20,13 +22,15 @@ export default function ActiveInactiveRow({
inactiveDot = "gray",
className,
additionalInfo,
}: Props) {
dataCy,
}: Readonly<Props>) {
return (
<div
className={cn(
"gap-3 dark:text-neutral-300 text-neutral-500 min-w-0",
className,
)}
data-cy={dataCy}
>
{leftSection}
<div className={"flex flex-col gap-1"}>

View File

@@ -1,3 +1,4 @@
import Badge from "@components/Badge";
import Button from "@components/Button";
import {
Modal,
@@ -10,6 +11,7 @@ import ModalHeader from "@components/modal/ModalHeader";
import { PeerGroupSelector } from "@components/PeerGroupSelector";
import Separator from "@components/Separator";
import MultipleGroups from "@components/ui/MultipleGroups";
import { IconCirclePlus } from "@tabler/icons-react";
import { FolderGit2 } from "lucide-react";
import * as React from "react";
import { useMemo } from "react";
@@ -27,6 +29,8 @@ type Props = {
label?: string;
description?: string;
peer?: Peer;
showAddGroupButton?: boolean;
hideAllGroup?: boolean;
};
export default function GroupsRow({
@@ -37,6 +41,8 @@ export default function GroupsRow({
label = "Assigned Groups",
description = "Use groups to control what this peer can access",
peer,
showAddGroupButton = false,
hideAllGroup = false,
}: Props) {
const { groups: allGroups } = useGroups();
const { isUser } = useLoggedInUser();
@@ -59,7 +65,14 @@ export default function GroupsRow({
setModal && !isUser && setModal(true);
}}
>
<MultipleGroups groups={foundGroups} label={label} />
{foundGroups?.length == 0 && showAddGroupButton ? (
<Badge variant={"gray"} useHover={true}>
<IconCirclePlus size={14} />
Add Groups
</Badge>
) : (
<MultipleGroups groups={foundGroups} label={label} />
)}
</ModalTrigger>
<EditGroupsModal
groups={foundGroups}
@@ -67,6 +80,7 @@ export default function GroupsRow({
label={label}
description={description}
peer={peer}
hideAllGroup={hideAllGroup}
/>
</Modal>
);
@@ -78,6 +92,7 @@ type EditGroupsModalProps = {
label?: string;
description?: string;
peer?: Peer;
hideAllGroup?: boolean;
};
export function EditGroupsModal({
@@ -86,6 +101,7 @@ export function EditGroupsModal({
label,
description,
peer,
hideAllGroup = false,
}: EditGroupsModalProps) {
const [selectedGroups, setSelectedGroups, { getAllGroupCalls }] =
useGroupHelper({
@@ -114,6 +130,7 @@ export function EditGroupsModal({
onChange={setSelectedGroups}
values={selectedGroups}
peer={peer}
hideAllGroup={hideAllGroup}
/>
</div>
</div>

View File

@@ -17,8 +17,9 @@ import Paragraph from "@components/Paragraph";
import { PeerGroupSelector } from "@components/PeerGroupSelector";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@components/Tabs";
import { Textarea } from "@components/Textarea";
import InputDomain, { domainReducer } from "@components/ui/InputDomain";
import { useApiCall } from "@utils/api";
import { cn, validator } from "@utils/helpers";
import { cn } from "@utils/helpers";
import cidr from "ip-cidr";
import { uniqueId } from "lodash";
import {
@@ -35,7 +36,7 @@ import {
import React, { useEffect, useMemo, useReducer, useState } from "react";
import { useSWRConfig } from "swr";
import DNSIcon from "@/assets/icons/DNSIcon";
import { Domain, Nameserver, NameserverGroup } from "@/interfaces/Nameserver";
import { Nameserver, NameserverGroup } from "@/interfaces/Nameserver";
import useGroupHelper from "@/modules/groups/useGroupHelper";
type Props = {
@@ -97,19 +98,6 @@ enum ActionType {
UPDATE = "UPDATE",
}
export const domainReducer = (state: Domain[], action: any) => {
switch (action.type) {
case ActionType.ADD:
return [...state, { name: "", id: uniqueId("ns") }];
case ActionType.REMOVE:
return state.filter((_, i) => i !== action.index);
case ActionType.UPDATE:
return state.map((n, i) => (i === action.index ? action.d : n));
default:
return state;
}
};
export function NameserverModalContent({
onSuccess,
preset,
@@ -199,7 +187,7 @@ export function NameserverModalContent({
// Domains
const [domains, setDomains] = useReducer(domainReducer, [], () => {
if (preset?.domains?.length) {
return preset.domains.map((d) => ({ name: d, id: uniqueId("ns") }));
return preset.domains.map((d) => ({ name: d, id: uniqueId("domain") }));
}
return [];
});
@@ -238,27 +226,22 @@ export function NameserverModalContent({
return "";
}, [name]);
const hasAnyError = useMemo(() => {
return (
const canContinueToDomains = useMemo(() => {
return !(
hasNSErrors ||
nsError ||
domainError ||
nameservers.length == 0 ||
hasDomainErrors ||
groups.length == 0 ||
nameLengthError !== "" ||
name == ""
groups.length == 0
);
}, [
nsError,
domainError,
nameservers,
groups,
hasNSErrors,
hasDomainErrors,
nameLengthError,
name,
]);
}, [hasNSErrors, nsError, nameservers.length, groups.length]);
const canContinueToGeneral = useMemo(() => {
return !(!canContinueToDomains || domainError || hasDomainErrors);
}, [canContinueToDomains, domainError, hasDomainErrors]);
const canSubmit = useMemo(() => {
return !(!canContinueToGeneral || nameLengthError !== "" || name == "");
}, [canContinueToGeneral, nameLengthError, name]);
return (
<ModalContent maxWidthClass={"max-w-xl"}>
@@ -269,7 +252,7 @@ export function NameserverModalContent({
color={"netbird"}
/>
<Tabs defaultValue={tab} onValueChange={(v) => setTab(v)}>
<Tabs defaultValue={tab} onValueChange={(v) => setTab(v)} value={tab}>
<TabsList justify={"start"} className={"px-8"}>
<TabsTrigger value={"nameserver"}>
<ServerIcon
@@ -280,7 +263,7 @@ export function NameserverModalContent({
/>
Nameserver
</TabsTrigger>
<TabsTrigger value={"domains"}>
<TabsTrigger value={"domains"} disabled={!canContinueToDomains}>
<GlobeIcon
size={16}
className={
@@ -289,7 +272,7 @@ export function NameserverModalContent({
/>
Domains
</TabsTrigger>
<TabsTrigger value={"general"}>
<TabsTrigger value={"general"} disabled={!canContinueToGeneral}>
<Text
size={16}
className={
@@ -375,7 +358,7 @@ export function NameserverModalContent({
<div className={"flex flex-col gap-2 w-full"}>
{domains.map((domain, i) => {
return (
<DomainInput
<InputDomain
key={domain.id}
value={domain}
onChange={(d) =>
@@ -473,20 +456,77 @@ export function NameserverModalContent({
</Paragraph>
</div>
<div className={"flex gap-3 w-full justify-end"}>
<ModalClose asChild={true}>
<Button variant={"secondary"}>Cancel</Button>
</ModalClose>
{!isUpdate ? (
<>
{tab == "nameserver" && (
<ModalClose asChild={true}>
<Button variant={"secondary"}>Cancel</Button>
</ModalClose>
)}
<Button variant={"primary"} disabled={hasAnyError} onClick={submit}>
{isUpdate ? (
<>Save Changes</>
) : (
<>
<PlusCircle size={16} />
Add Nameserver
</>
)}
</Button>
{tab == "domains" && (
<Button
variant={"secondary"}
onClick={() => setTab("nameserver")}
>
Back
</Button>
)}
{tab == "nameserver" && (
<Button
variant={"primary"}
onClick={() => setTab("domains")}
disabled={!canContinueToDomains}
>
Continue
</Button>
)}
{tab == "domains" && (
<Button
variant={"primary"}
onClick={() => setTab("general")}
disabled={!canContinueToGeneral}
>
Continue
</Button>
)}
{tab == "general" && (
<>
<Button
variant={"secondary"}
onClick={() => setTab("domains")}
>
Back
</Button>
<Button
variant={"primary"}
disabled={!canSubmit}
onClick={submit}
>
<PlusCircle size={16} />
Add Nameserver
</Button>
</>
)}
</>
) : (
<>
<ModalClose asChild={true}>
<Button variant={"secondary"}>Cancel</Button>
</ModalClose>
<Button
variant={"primary"}
disabled={!canSubmit}
onClick={submit}
>
Save Changes
</Button>
</>
)}
</div>
</ModalFooter>
</ModalContent>
@@ -567,63 +607,3 @@ function NameserverInput({
</div>
);
}
function DomainInput({
value,
onChange,
onRemove,
onError,
}: {
value: Domain;
onChange: (d: Domain) => void;
onRemove: () => void;
onError?: (error: boolean) => void;
error?: string;
}) {
const [name, setName] = useState(value.name);
const handleNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setName(e.target.value);
onChange({ ...value, name: e.target.value });
};
const domainError = useMemo(() => {
if (name == "") {
return "";
}
const valid = validator.isValidDomain(name);
if (!valid) {
onError && onError(true);
return "Please enter a valid domain, e.g. example.com or intra.example.com";
}
onError && onError(false);
}, [name, onError]);
useEffect(() => {
return () => onError && onError(false);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<div className={"flex gap-2 w-full"}>
<div className={"w-full"}>
<Input
customPrefix={<GlobeIcon size={15} />}
placeholder={"e.g., example.com"}
maxWidthClass={"w-full"}
value={name}
error={domainError}
onChange={handleNameChange}
/>
</div>
<Button
className={"h-[42px]"}
variant={"default-outline"}
onClick={onRemove}
>
<MinusCircleIcon size={15} />
</Button>
</div>
);
}

View File

@@ -85,14 +85,17 @@ export const NameserverGroupTableColumns: ColumnDef<NameserverGroup>[] = [
cell: ({ cell }) => <NameserverActionCell ns={cell.row.original} />,
},
];
type Props = {
nameserverGroups?: NameserverGroup[];
isLoading?: boolean;
headingTarget?: HTMLHeadingElement | null;
};
export default function NameserverGroupTable({
nameserverGroups,
isLoading,
headingTarget,
}: Props) {
const { mutate } = useSWRConfig();
const path = usePathname();
@@ -123,6 +126,7 @@ export default function NameserverGroupTable({
/>
)}
<DataTable
headingTarget={headingTarget}
isLoading={isLoading}
text={"Network Routes"}
sorting={sorting}

View File

@@ -26,7 +26,7 @@ export const AddExitNodeButton = ({ peer, firstTime = false }: Props) => {
) : (
<>
<IconDirectionSign size={16} className={"text-yellow-400"} />
Setup Exit Node
Set Up Exit Node
</>
)}
</Button>

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