Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c7775ade8c | ||
|
|
cd3e75b640 | ||
|
|
f8281c8057 | ||
|
|
c1fcadaefe | ||
|
|
a0c4520f4b | ||
|
|
76ef50a886 | ||
|
|
58cec8fcd1 | ||
|
|
d34ae9beb2 | ||
|
|
650496f670 | ||
|
|
121778c4a6 | ||
|
|
d4102c5d04 | ||
|
|
e78c35bdbe | ||
|
|
6ebee98695 | ||
|
|
f4b28d5f40 |
3
.github/workflows/codespell.yml
vendored
3
.github/workflows/codespell.yml
vendored
@@ -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
|
||||
|
||||
536
package-lock.json
generated
536
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -23,6 +23,7 @@ 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";
|
||||
@@ -54,15 +55,12 @@ 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();
|
||||
@@ -72,7 +70,7 @@ export default function PeerPage() {
|
||||
useRedirect("/peers", false, !peerId);
|
||||
|
||||
return peer && !isLoading ? (
|
||||
<PeerProvider peer={peer}>
|
||||
<PeerProvider peer={peer} key={peerId}>
|
||||
<PeerOverview />
|
||||
</PeerProvider>
|
||||
) : (
|
||||
@@ -133,7 +131,6 @@ function PeerOverview() {
|
||||
};
|
||||
|
||||
const { isUser } = useLoggedInUser();
|
||||
const hasExitNodes = useHasExitNodes(peer);
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
@@ -300,65 +297,38 @@ function PeerOverview() {
|
||||
/>
|
||||
</FullTooltip>
|
||||
|
||||
<div>
|
||||
<Label>Assigned Groups</Label>
|
||||
<HelpText>
|
||||
Use groups to control what this peer can access.
|
||||
</HelpText>
|
||||
<FullTooltip
|
||||
content={
|
||||
<div
|
||||
className={
|
||||
"flex gap-2 items-center !text-nb-gray-300 text-xs"
|
||||
}
|
||||
>
|
||||
<LockIcon size={14} />
|
||||
<span>
|
||||
{`You don't have the required permissions to update this
|
||||
setting.`}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
interactive={false}
|
||||
className={"w-full block"}
|
||||
disabled={!isUser}
|
||||
>
|
||||
{!isUser && (
|
||||
<div>
|
||||
<Label>Assigned Groups</Label>
|
||||
<HelpText>
|
||||
Use groups to control what this peer can access.
|
||||
</HelpText>
|
||||
<PeerGroupSelector
|
||||
disabled={isUser}
|
||||
onChange={setSelectedGroups}
|
||||
values={selectedGroups}
|
||||
hideAllGroup={true}
|
||||
peer={peer}
|
||||
/>
|
||||
</FullTooltip>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -187,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 && (
|
||||
@@ -200,6 +200,7 @@ function UserOverview({ user }: Props) {
|
||||
disabled={isUser}
|
||||
onChange={setSelectedGroups}
|
||||
values={selectedGroups}
|
||||
hideAllGroup={true}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -214,6 +215,8 @@ function UserOverview({ user }: Props) {
|
||||
<UserRoleSelector
|
||||
value={role}
|
||||
onChange={setRole}
|
||||
hideOwner={user.is_service_user}
|
||||
currentUser={user}
|
||||
disabled={
|
||||
isLoggedInUser ||
|
||||
!isOwnerOrAdmin ||
|
||||
@@ -301,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={
|
||||
<>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
BIN
src/assets/os-icons/FreeBSD.png
Normal file
BIN
src/assets/os-icons/FreeBSD.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.1 KiB |
@@ -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) => {
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
15
src/components/DisableDarkReader.tsx
Normal file
15
src/components/DisableDarkReader.tsx
Normal 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;
|
||||
};
|
||||
11
src/components/DropdownInfoText.tsx
Normal file
11
src/components/DropdownInfoText.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
48
src/components/DropdownInput.tsx
Normal file
48
src/components/DropdownInput.tsx
Normal 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";
|
||||
@@ -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 "}>
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -74,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>
|
||||
@@ -99,9 +100,10 @@ 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>
|
||||
|
||||
@@ -1,6 +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";
|
||||
@@ -108,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
|
||||
@@ -224,10 +225,14 @@ export function NetworkRouteSelector({
|
||||
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
|
||||
@@ -259,7 +264,11 @@ function DomainList({ domains }: { domains?: string[] }) {
|
||||
<FullTooltip
|
||||
content={<div className={"text-xs max-w-sm"}>{domains.join(", ")}</div>}
|
||||
>
|
||||
<div className={"text-xs text-nb-gray-300"}>
|
||||
<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>
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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>
|
||||
@@ -203,7 +276,7 @@ export function PeerGroupSelector({
|
||||
"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}
|
||||
@@ -238,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
|
||||
@@ -265,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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
27
src/components/Slider.tsx
Normal file
27
src/components/Slider.tsx
Normal 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 };
|
||||
@@ -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;
|
||||
|
||||
@@ -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"}>
|
||||
|
||||
132
src/components/VirtualScrollAreaList.tsx
Normal file
132
src/components/VirtualScrollAreaList.tsx
Normal 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";
|
||||
@@ -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,
|
||||
)}
|
||||
@@ -77,7 +78,7 @@ const ModalContent = React.forwardRef<
|
||||
{showClose && (
|
||||
<DialogPrimitive.Close
|
||||
data-cy={"modal-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"
|
||||
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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
64
src/components/table/DataTableHeadingPortal.tsx
Normal file
64
src/components/table/DataTableHeadingPortal.tsx
Normal 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} `;
|
||||
};
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
67
src/components/ui/AccessControlGroupCount.tsx
Normal file
67
src/components/ui/AccessControlGroupCount.tsx
Normal 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;
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
124
src/components/ui/GroupBadgeWithEditPeers.tsx
Normal file
124
src/components/ui/GroupBadgeWithEditPeers.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -70,6 +70,7 @@ export default function InputDomain({
|
||||
customPrefix={<GlobeIcon size={15} />}
|
||||
placeholder={"e.g., example.com"}
|
||||
maxWidthClass={"w-full"}
|
||||
data-cy={"domain-input"}
|
||||
value={name}
|
||||
error={domainError}
|
||||
onChange={handleNameChange}
|
||||
|
||||
@@ -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"
|
||||
|
||||
59
src/components/ui/NoResultsCard.tsx
Normal file
59
src/components/ui/NoResultsCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import useFetchApi from "@utils/api";
|
||||
import { usePathname } from "next/navigation";
|
||||
import React, { useState } from "react";
|
||||
import useFetchApi, { useApiCall } from "@utils/api";
|
||||
import { merge, sortBy, unionBy } from "lodash";
|
||||
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,29 +15,114 @@ 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;
|
||||
},
|
||||
);
|
||||
|
||||
export default function GroupsProvider({ children }: Props) {
|
||||
const path = usePathname();
|
||||
const { permission } = useLoggedInUser();
|
||||
const { permission, isUser } = useLoggedInUser();
|
||||
|
||||
return path === "/peers" && permission.dashboard_view == "blocked" ? (
|
||||
return permission.dashboard_view == "blocked" ? (
|
||||
<>{children}</>
|
||||
) : (
|
||||
<GroupsProviderContent>{children}</GroupsProviderContent>
|
||||
<GroupsProviderContent isUser={isUser}>{children}</GroupsProviderContent>
|
||||
);
|
||||
}
|
||||
|
||||
export function GroupsProviderContent({ children }: Props) {
|
||||
const { data: groups, mutate, isLoading } = useFetchApi<Group[]>("/groups");
|
||||
type ProviderContentProps = {
|
||||
children: React.ReactNode;
|
||||
isUser: boolean;
|
||||
};
|
||||
|
||||
export function GroupsProviderContent({
|
||||
children,
|
||||
isUser,
|
||||
}: Readonly<ProviderContentProps>) {
|
||||
const {
|
||||
data: groups,
|
||||
mutate,
|
||||
isLoading,
|
||||
} = useFetchApi<Group[]>("/groups", false, true, !isUser);
|
||||
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 +130,11 @@ export function GroupsProviderContent({ children }: Props) {
|
||||
refresh,
|
||||
dropdownOptions,
|
||||
setDropdownOptions,
|
||||
addDropdownOptions,
|
||||
isLoading,
|
||||
createOrUpdate,
|
||||
reset,
|
||||
updateGroupDropdown,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -24,7 +24,7 @@ const RoutesContext = React.createContext(
|
||||
},
|
||||
);
|
||||
|
||||
export default function RoutesProvider({ children }: Props) {
|
||||
export default function RoutesProvider({ children }: Readonly<Props>) {
|
||||
const routeRequest = useApiCall<Route>("/routes", true);
|
||||
const { mutate } = useSWRConfig();
|
||||
|
||||
@@ -38,9 +38,7 @@ export default function RoutesProvider({ children }: Props) {
|
||||
|
||||
notify({
|
||||
title: "Network " + route.network_id + "-" + route.network,
|
||||
description: message
|
||||
? message
|
||||
: "The network route was successfully updated",
|
||||
description: message ?? "The network route was successfully updated",
|
||||
promise: routeRequest
|
||||
.put(
|
||||
{
|
||||
@@ -56,6 +54,10 @@ export default function RoutesProvider({ children }: Props) {
|
||||
metric: toUpdate.metric ?? route.metric ?? 9999,
|
||||
masquerade: toUpdate.masquerade ?? route.masquerade ?? true,
|
||||
groups: toUpdate.groups ?? route.groups ?? [],
|
||||
access_control_groups:
|
||||
toUpdate.access_control_groups ??
|
||||
route.access_control_groups ??
|
||||
undefined,
|
||||
},
|
||||
`/${route.id}`,
|
||||
)
|
||||
@@ -74,9 +76,7 @@ export default function RoutesProvider({ children }: Props) {
|
||||
) => {
|
||||
notify({
|
||||
title: "Network " + route.network_id + "-" + route.network,
|
||||
description: message
|
||||
? message
|
||||
: "The network route was successfully created",
|
||||
description: message ?? "The network route was successfully created",
|
||||
promise: routeRequest
|
||||
.post({
|
||||
network_id: route.network_id,
|
||||
@@ -90,6 +90,7 @@ export default function RoutesProvider({ children }: Props) {
|
||||
metric: route.metric || 9999,
|
||||
masquerade: route.masquerade,
|
||||
groups: route.groups || [],
|
||||
access_control_groups: route?.access_control_groups || undefined,
|
||||
})
|
||||
.then((route) => {
|
||||
mutate("/routes");
|
||||
|
||||
21
src/hooks/useAutosizeTextArea.ts
Normal file
21
src/hooks/useAutosizeTextArea.ts
Normal 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;
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
|
||||
@@ -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,10 +13,18 @@ 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;
|
||||
|
||||
12
src/hooks/usePortalElement.tsx
Normal file
12
src/hooks/usePortalElement.tsx
Normal 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
13
src/hooks/usePrevious.ts
Normal 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;
|
||||
91
src/hooks/useSearch.ts
Normal file
91
src/hooks/useSearch.ts
Normal 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];
|
||||
}
|
||||
36
src/hooks/useSortedDropdownOptions.ts
Normal file
36
src/hooks/useSortedDropdownOptions.ts
Normal 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;
|
||||
@@ -3,6 +3,8 @@ export interface Group {
|
||||
name: string;
|
||||
peers?: GroupPeer[] | string[];
|
||||
peers_count?: number;
|
||||
// Frontend only
|
||||
keepClientState?: boolean;
|
||||
}
|
||||
|
||||
export interface GroupPeer {
|
||||
|
||||
@@ -6,4 +6,5 @@ export enum OperatingSystem {
|
||||
DOCKER,
|
||||
IOS,
|
||||
UNKNOWN,
|
||||
FREEBSD,
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -11,6 +11,7 @@ export interface Route {
|
||||
masquerade: boolean;
|
||||
groups: string[];
|
||||
keep_route?: boolean;
|
||||
access_control_groups?: string[];
|
||||
// Frontend only
|
||||
peer_groups?: string[];
|
||||
routesGroups?: string[];
|
||||
@@ -25,6 +26,7 @@ export interface GroupedRoute {
|
||||
network?: string;
|
||||
domains?: string[];
|
||||
keep_route?: boolean;
|
||||
access_control_groups?: string[];
|
||||
network_id: string;
|
||||
high_availability_count: number;
|
||||
is_using_route_groups: boolean;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,144 +151,6 @@ 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));
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
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);
|
||||
}),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const portAndDirectionDisabled = protocol == "icmp" || protocol == "all";
|
||||
|
||||
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 continuePostureChecksDisabled = useMemo(() => {
|
||||
if (sourceGroups.length == 0 || destinationGroups.length == 0) return true;
|
||||
if (direction != "bi" && ports.length == 0) return true;
|
||||
@@ -272,8 +161,26 @@ export function AccessControlModalContent({
|
||||
if (continuePostureChecksDisabled) return true;
|
||||
}, [name, continuePostureChecksDisabled]);
|
||||
|
||||
const handleProtocolChange = (p: Protocol) => {
|
||||
setProtocol(p);
|
||||
if (p == "icmp") {
|
||||
setPorts([]);
|
||||
}
|
||||
if (p == "all") {
|
||||
setPorts([]);
|
||||
}
|
||||
if (p == "tcp" || p == "udp") {
|
||||
setDirection("in");
|
||||
}
|
||||
};
|
||||
|
||||
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={
|
||||
@@ -310,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"}>
|
||||
@@ -322,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>
|
||||
@@ -346,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
|
||||
@@ -363,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>
|
||||
@@ -425,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"}
|
||||
/>
|
||||
@@ -436,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."
|
||||
@@ -510,6 +435,7 @@ export function AccessControlModalContent({
|
||||
variant={"primary"}
|
||||
disabled={submitDisabled}
|
||||
onClick={submit}
|
||||
data-cy={"submit-policy"}
|
||||
>
|
||||
<PlusCircle size={16} />
|
||||
Add Policy
|
||||
@@ -525,7 +451,13 @@ export function AccessControlModalContent({
|
||||
<Button
|
||||
variant={"primary"}
|
||||
disabled={submitDisabled}
|
||||
onClick={submit}
|
||||
onClick={() => {
|
||||
if (useSave) {
|
||||
submit();
|
||||
} else {
|
||||
close();
|
||||
}
|
||||
}}
|
||||
>
|
||||
Save Changes
|
||||
</Button>
|
||||
|
||||
@@ -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[])
|
||||
: [];
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"}>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
262
src/modules/access-control/useAccessControl.ts
Normal file
262
src/modules/access-control/useAccessControl.ts
Normal 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;
|
||||
};
|
||||
@@ -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."
|
||||
|
||||
@@ -47,6 +47,14 @@ export default function ActivityDescription({ event }: Props) {
|
||||
</div>
|
||||
);
|
||||
|
||||
if (event.activity_code == "setupkey.delete")
|
||||
return (
|
||||
<div className={"inline"}>
|
||||
Setup-Key <Value> {m.name}</Value> with key <Value>{m.key}</Value> was
|
||||
deleted
|
||||
</div>
|
||||
);
|
||||
|
||||
if (event.activity_code == "setupkey.add")
|
||||
return (
|
||||
<div className={"inline"}>
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"}>
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
export default function EmptyRow() {
|
||||
return <div className={"text-nb-gray-600"}>-</div>;
|
||||
import { cn } from "@utils/helpers";
|
||||
|
||||
type Props = {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export default function EmptyRow({ className }: Readonly<Props>) {
|
||||
return <div className={cn("text-nb-gray-600", className)}>-</div>;
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ type Props = {
|
||||
description?: string;
|
||||
peer?: Peer;
|
||||
showAddGroupButton?: boolean;
|
||||
hideAllGroup?: boolean;
|
||||
};
|
||||
|
||||
export default function GroupsRow({
|
||||
@@ -41,6 +42,7 @@ export default function GroupsRow({
|
||||
description = "Use groups to control what this peer can access",
|
||||
peer,
|
||||
showAddGroupButton = false,
|
||||
hideAllGroup = false,
|
||||
}: Props) {
|
||||
const { groups: allGroups } = useGroups();
|
||||
const { isUser } = useLoggedInUser();
|
||||
@@ -78,6 +80,7 @@ export default function GroupsRow({
|
||||
label={label}
|
||||
description={description}
|
||||
peer={peer}
|
||||
hideAllGroup={hideAllGroup}
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
@@ -89,6 +92,7 @@ type EditGroupsModalProps = {
|
||||
label?: string;
|
||||
description?: string;
|
||||
peer?: Peer;
|
||||
hideAllGroup?: boolean;
|
||||
};
|
||||
|
||||
export function EditGroupsModal({
|
||||
@@ -97,6 +101,7 @@ export function EditGroupsModal({
|
||||
label,
|
||||
description,
|
||||
peer,
|
||||
hideAllGroup = false,
|
||||
}: EditGroupsModalProps) {
|
||||
const [selectedGroups, setSelectedGroups, { getAllGroupCalls }] =
|
||||
useGroupHelper({
|
||||
@@ -125,6 +130,7 @@ export function EditGroupsModal({
|
||||
onChange={setSelectedGroups}
|
||||
values={selectedGroups}
|
||||
peer={peer}
|
||||
hideAllGroup={hideAllGroup}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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}
|
||||
|
||||
372
src/modules/groups/AssignPeerToGroupModal.tsx
Normal file
372
src/modules/groups/AssignPeerToGroupModal.tsx
Normal file
@@ -0,0 +1,372 @@
|
||||
import Button from "@components/Button";
|
||||
import { Checkbox } from "@components/Checkbox";
|
||||
import { Modal, ModalContent } from "@components/modal/Modal";
|
||||
import ModalHeader from "@components/modal/ModalHeader";
|
||||
import { notify } from "@components/Notification";
|
||||
import SkeletonTable from "@components/skeletons/SkeletonTable";
|
||||
import DataTableHeader from "@components/table/DataTableHeader";
|
||||
import NoResultsCard from "@components/ui/NoResultsCard";
|
||||
import { ColumnDef, RowSelectionState } from "@tanstack/react-table";
|
||||
import useFetchApi, { useApiCall } from "@utils/api";
|
||||
import { cn } from "@utils/helpers";
|
||||
import { FolderGit2, PencilLineIcon } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useSWRConfig } from "swr";
|
||||
import PeerIcon from "@/assets/icons/PeerIcon";
|
||||
import { DataTable } from "@/components/table/DataTable";
|
||||
import { Group, GroupPeer } from "@/interfaces/Group";
|
||||
import { Peer } from "@/interfaces/Peer";
|
||||
import { EditGroupNameModal } from "@/modules/groups/EditGroupNameModal";
|
||||
import PeerAddressCell from "@/modules/peers/PeerAddressCell";
|
||||
import PeerNameCell from "@/modules/peers/PeerNameCell";
|
||||
import { PeerOSCell } from "@/modules/peers/PeerOSCell";
|
||||
|
||||
type Props = {
|
||||
group: Group;
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
onUpdate?: (g: Group) => void;
|
||||
useSave?: boolean;
|
||||
};
|
||||
|
||||
export const AssignPeerToGroupModal = ({
|
||||
group,
|
||||
open = false,
|
||||
setOpen,
|
||||
onUpdate,
|
||||
useSave = true,
|
||||
}: Props) => {
|
||||
return (
|
||||
<Modal open={open} onOpenChange={setOpen} key={open ? "1" : "0"}>
|
||||
{open && (
|
||||
<AssignGroupToPeerModalContent
|
||||
group={group}
|
||||
onSuccess={(g) => {
|
||||
setOpen(false);
|
||||
onUpdate && onUpdate(g);
|
||||
}}
|
||||
useSave={useSave}
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
type ContentProps = {
|
||||
group: Group;
|
||||
onSuccess?: (g: Group) => void;
|
||||
useSave?: boolean;
|
||||
};
|
||||
|
||||
export const AssignGroupToPeerModalContent = ({
|
||||
group,
|
||||
onSuccess,
|
||||
useSave,
|
||||
}: ContentProps) => {
|
||||
const { data: peers, isLoading } = useFetchApi<Peer[]>("/peers");
|
||||
const { mutate } = useSWRConfig();
|
||||
const groupRequest = useApiCall<Group>("/groups");
|
||||
const [initialPeersSet, setInitialPeersSet] = useState(false);
|
||||
const [selectedRows, setSelectedRows] = useState<RowSelectionState>({});
|
||||
const isAllGroup = group.name === "All";
|
||||
const [sorting, setSorting] = useState([
|
||||
{
|
||||
id: "select",
|
||||
desc: false,
|
||||
},
|
||||
{
|
||||
id: "name",
|
||||
desc: false,
|
||||
},
|
||||
]);
|
||||
|
||||
const [groupNameModal, setGroupNameModal] = useState(false);
|
||||
const [groupName, setGroupName] = useState(group.name);
|
||||
|
||||
const onGroupNameUpdate = (name: string) => {
|
||||
setGroupNameModal(false);
|
||||
setGroupName(name);
|
||||
};
|
||||
|
||||
// Get initial selected peers
|
||||
const getInitialSelectedPeers = useCallback(() => {
|
||||
if (!group) return undefined;
|
||||
if (!peers) return undefined;
|
||||
let initialSelectedPeers = group?.peers
|
||||
?.map((p) => {
|
||||
if (typeof p === "string") return p;
|
||||
return p.id;
|
||||
})
|
||||
.filter((p) => p !== undefined) as string[];
|
||||
if (!initialSelectedPeers) return {};
|
||||
|
||||
// Return Record<string, boolean> for initialSelectedPeers
|
||||
return initialSelectedPeers.reduce(
|
||||
(acc, peerId) => {
|
||||
acc[peerId] = true;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, boolean>,
|
||||
);
|
||||
}, [group, peers]);
|
||||
|
||||
const handleOnSave = async (selectedPeers: Peer[]) => {
|
||||
if (!useSave) {
|
||||
onSuccess &&
|
||||
onSuccess({
|
||||
...group,
|
||||
name: groupName,
|
||||
peers: selectedPeers.map((peer) => {
|
||||
return {
|
||||
id: peer.id,
|
||||
name: peer.name,
|
||||
} as GroupPeer;
|
||||
}),
|
||||
peers_count: selectedPeers.length,
|
||||
keepClientState: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const hasGroupID = !!group?.id;
|
||||
let request;
|
||||
|
||||
if (hasGroupID) {
|
||||
request = () =>
|
||||
groupRequest.put(
|
||||
{
|
||||
name: group.name,
|
||||
peers: selectedPeers.map((peer) => peer.id),
|
||||
},
|
||||
"/" + group?.id,
|
||||
);
|
||||
} else {
|
||||
request = () =>
|
||||
groupRequest.post({
|
||||
name: group.name,
|
||||
peers: selectedPeers.map((peer) => peer.id),
|
||||
});
|
||||
}
|
||||
notify({
|
||||
title: "Saving changes",
|
||||
description: `${group?.name || "Group"} was successfully saved.`,
|
||||
promise: request()
|
||||
.then((g: Group) => {
|
||||
mutate("/groups");
|
||||
onSuccess && onSuccess(g);
|
||||
})
|
||||
.catch(() => {}),
|
||||
loadingMessage: "Updating group...",
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (initialPeersSet) return;
|
||||
const initialSelectedPeers = getInitialSelectedPeers();
|
||||
if (initialSelectedPeers === undefined) return;
|
||||
setSelectedRows(initialSelectedPeers);
|
||||
setInitialPeersSet(true);
|
||||
}, [getInitialSelectedPeers, initialPeersSet]);
|
||||
|
||||
return (
|
||||
<ModalContent
|
||||
maxWidthClass={"max-w-4xl"}
|
||||
className={cn(peers && peers.length > 0 ? "pb-0" : "pb-8")}
|
||||
showClose={true}
|
||||
>
|
||||
{groupNameModal && (
|
||||
<EditGroupNameModal
|
||||
initialName={groupName}
|
||||
open={groupNameModal}
|
||||
onOpenChange={setGroupNameModal}
|
||||
onSuccess={onGroupNameUpdate}
|
||||
/>
|
||||
)}
|
||||
<div className={"flex items-start justify-between pr-8"}>
|
||||
<ModalHeader
|
||||
title={
|
||||
<div className={"flex items-center gap-2 mb-1 text-nb-gray-100"}>
|
||||
<FolderGit2 size={16} className={"shrink-0"} />
|
||||
<div className={"flex gap-2 items-center"}>
|
||||
{groupName}
|
||||
{groupName !== "All" && (
|
||||
<button
|
||||
className={
|
||||
"flex items-center gap-2 dark:text-neutral-300 text-neutral-500 hover:text-neutral-100 transition-all hover:bg-nb-gray-800/60 py-2 px-3 rounded-md cursor-pointer"
|
||||
}
|
||||
onClick={() => setGroupNameModal(true)}
|
||||
>
|
||||
<PencilLineIcon size={16} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
description={
|
||||
isAllGroup
|
||||
? "View assigned peers for this group"
|
||||
: "Manage assigned peers for this group"
|
||||
}
|
||||
color={"blue"}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{initialPeersSet ? (
|
||||
<DataTable
|
||||
useRowId={true}
|
||||
rowSelection={selectedRows}
|
||||
setRowSelection={setSelectedRows}
|
||||
onRowClick={(row) => row.toggleSelected()}
|
||||
text={"Peers"}
|
||||
resetRowSelectionOnSearch={false}
|
||||
uniqueKey={group?.id ?? group?.name}
|
||||
sorting={sorting}
|
||||
keepStateInLocalStorage={false}
|
||||
setSorting={setSorting}
|
||||
columns={PeersTableColumns}
|
||||
data={initialPeersSet ? peers : undefined}
|
||||
isLoading={isLoading && !initialPeersSet}
|
||||
tableCellClassName={"!py-1 scale-[95%]"}
|
||||
searchPlaceholder={"Search by name, IP or owner..."}
|
||||
minimal={false}
|
||||
columnVisibility={{
|
||||
connected: false,
|
||||
select: !isAllGroup,
|
||||
approval_required: false,
|
||||
group_name_strings: false,
|
||||
group_names: false,
|
||||
ip: false,
|
||||
user_name: false,
|
||||
user_email: false,
|
||||
}}
|
||||
getStartedCard={
|
||||
<NoResultsCard
|
||||
title={"Seems like you don't have any peers"}
|
||||
description={
|
||||
"In order to view or assign peers to a group, you need to have at least one peer."
|
||||
}
|
||||
icon={<PeerIcon className={"fill-nb-gray-200"} size={14} />}
|
||||
/>
|
||||
}
|
||||
rightSide={(table) => (
|
||||
<div className={"ml-auto flex items-center gap-5"}>
|
||||
<div className={"text-sm"}>
|
||||
{Object.keys(selectedRows).length > 0 && (
|
||||
<div className={"text-nb-gray-200"}>
|
||||
<span className={"text-netbird font-medium"}>
|
||||
{Object.keys(selectedRows).length}
|
||||
</span>{" "}
|
||||
Peer(s) selected
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{!isAllGroup && (
|
||||
<Button
|
||||
variant={"primary"}
|
||||
className={"ml-auto"}
|
||||
disabled={peers?.length === 0}
|
||||
onClick={() => {
|
||||
const selectedPeers = table
|
||||
.getSelectedRowModel()
|
||||
.rows.map((row) => row.original);
|
||||
handleOnSave(selectedPeers).then();
|
||||
}}
|
||||
>
|
||||
Confirm Changes
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<SkeletonTable withHeader={false} />
|
||||
)}
|
||||
</ModalContent>
|
||||
);
|
||||
};
|
||||
|
||||
const PeersTableColumns: ColumnDef<Peer>[] = [
|
||||
{
|
||||
id: "select",
|
||||
header: ({ table, column }) => (
|
||||
<div className={"min-w-[20px] max-w-[20px]"}>
|
||||
<Checkbox
|
||||
checked={table.getIsAllPageRowsSelected()}
|
||||
onCheckedChange={(value) => table.toggleAllRowsSelected(!!value)}
|
||||
aria-label="Select all"
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
accessorFn: (peer) => peer.id,
|
||||
sortingFn: "checkbox",
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<div className={"min-w-[20px] max-w-[20px]"}>
|
||||
<Checkbox
|
||||
variant={"tableCell"}
|
||||
checked={row.getIsSelected()}
|
||||
onCheckedChange={(value) => row.toggleSelected(!!value)}
|
||||
aria-label="Select row"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "name",
|
||||
header: ({ column }) => {
|
||||
return <DataTableHeader column={column}>Name</DataTableHeader>;
|
||||
},
|
||||
sortingFn: "text",
|
||||
cell: ({ row }) => <PeerNameCell peer={row.original} linkToPeer={false} />,
|
||||
},
|
||||
{
|
||||
id: "approval_required",
|
||||
accessorKey: "approval_required",
|
||||
sortingFn: "basic",
|
||||
accessorFn: (peer) => peer.approval_required,
|
||||
},
|
||||
{
|
||||
id: "connected",
|
||||
accessorKey: "connected",
|
||||
accessorFn: (peer) => peer.connected,
|
||||
},
|
||||
{
|
||||
accessorKey: "ip",
|
||||
sortingFn: "text",
|
||||
},
|
||||
{
|
||||
id: "user_name",
|
||||
accessorFn: (peer) => (peer.user ? peer.user?.name : "Unknown"),
|
||||
},
|
||||
{
|
||||
id: "user_email",
|
||||
accessorFn: (peer) => (peer.user ? peer.user?.email : "Unknown"),
|
||||
},
|
||||
{
|
||||
accessorKey: "dns_label",
|
||||
header: ({ column }) => {
|
||||
return <DataTableHeader column={column}>Address</DataTableHeader>;
|
||||
},
|
||||
cell: ({ row }) => <PeerAddressCell peer={row.original} />,
|
||||
},
|
||||
{
|
||||
accessorKey: "group_name_strings",
|
||||
accessorFn: (peer) => peer.groups?.map((g) => g?.name || "").join(", "),
|
||||
sortingFn: "text",
|
||||
},
|
||||
{
|
||||
accessorKey: "group_names",
|
||||
accessorFn: (peer) => peer.groups?.map((g) => g?.name || ""),
|
||||
sortingFn: "text",
|
||||
filterFn: "arrIncludesSome",
|
||||
},
|
||||
{
|
||||
accessorKey: "os",
|
||||
header: ({ column }) => {
|
||||
return <DataTableHeader column={column}>OS</DataTableHeader>;
|
||||
},
|
||||
cell: ({ row }) => <PeerOSCell os={row.original.os} />,
|
||||
},
|
||||
];
|
||||
78
src/modules/groups/EditGroupNameModal.tsx
Normal file
78
src/modules/groups/EditGroupNameModal.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import Button from "@components/Button";
|
||||
import { Input } from "@components/Input";
|
||||
import {
|
||||
Modal,
|
||||
ModalClose,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
} from "@components/modal/Modal";
|
||||
import ModalHeader from "@components/modal/ModalHeader";
|
||||
import { IconCornerDownLeft } from "@tabler/icons-react";
|
||||
import { trim } from "lodash";
|
||||
import * as React from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
type Props = {
|
||||
initialName: string;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSuccess: (name: string) => void;
|
||||
};
|
||||
export const EditGroupNameModal = ({
|
||||
initialName,
|
||||
onOpenChange,
|
||||
open,
|
||||
onSuccess,
|
||||
}: Props) => {
|
||||
const [name, setName] = useState(initialName);
|
||||
const isDisabled = useMemo(() => {
|
||||
if (name === initialName) return true;
|
||||
const trimmedName = trim(name);
|
||||
return trimmedName.length === 0;
|
||||
}, [name, initialName]);
|
||||
|
||||
return (
|
||||
<Modal open={open} onOpenChange={onOpenChange}>
|
||||
<ModalContent maxWidthClass={"max-w-md"}>
|
||||
<form>
|
||||
<ModalHeader
|
||||
title={"Edit Group Name"}
|
||||
description={"Set an easily identifiable name for your group."}
|
||||
color={"blue"}
|
||||
/>
|
||||
|
||||
<div className={"p-default flex flex-col gap-4"}>
|
||||
<div>
|
||||
<Input
|
||||
placeholder={"e.g., AWS Servers"}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ModalFooter className={"items-center"} separator={false}>
|
||||
<div className={"flex gap-3 w-full justify-end"}>
|
||||
<ModalClose asChild={true}>
|
||||
<Button variant={"secondary"} className={"w-full"}>
|
||||
Cancel
|
||||
</Button>
|
||||
</ModalClose>
|
||||
|
||||
<Button
|
||||
variant={"primary"}
|
||||
className={"w-full"}
|
||||
onClick={() => onSuccess(name)}
|
||||
disabled={isDisabled}
|
||||
type={"submit"}
|
||||
>
|
||||
Confirm
|
||||
<IconCornerDownLeft size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
</ModalFooter>
|
||||
</form>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -174,7 +174,7 @@ export function GroupSelector({
|
||||
"flex items-center gap-2 whitespace-nowrap text-sm"
|
||||
}
|
||||
>
|
||||
<FolderGit2 size={15} />
|
||||
<FolderGit2 size={13} className={"shrink-0"} />
|
||||
<TextWithTooltip text={value} maxChars={15} />
|
||||
</div>
|
||||
<div
|
||||
|
||||
68
src/modules/peer/AccessiblePeersSection.tsx
Normal file
68
src/modules/peer/AccessiblePeersSection.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import Paragraph from "@components/Paragraph";
|
||||
import SkeletonTable, {
|
||||
SkeletonTableHeader,
|
||||
} from "@components/skeletons/SkeletonTable";
|
||||
import { usePortalElement } from "@hooks/usePortalElement";
|
||||
import useFetchApi from "@utils/api";
|
||||
import * as React from "react";
|
||||
import { lazy, Suspense } from "react";
|
||||
import { useUsers } from "@/contexts/UsersProvider";
|
||||
import type { Peer } from "@/interfaces/Peer";
|
||||
|
||||
const AccessiblePeersTable = lazy(
|
||||
() => import("@/modules/peer/AccessiblePeersTable"),
|
||||
);
|
||||
|
||||
type Props = {
|
||||
peerID: string;
|
||||
};
|
||||
export const AccessiblePeersSection = ({ peerID }: Props) => {
|
||||
const { data: peers, isLoading } = useFetchApi<Peer[]>(
|
||||
`/peers/${peerID}/accessible-peers`,
|
||||
);
|
||||
const { users } = useUsers();
|
||||
const { ref: headingRef, portalTarget } =
|
||||
usePortalElement<HTMLHeadingElement>();
|
||||
|
||||
const peersWithUser = peers?.map((peer) => {
|
||||
if (!users) return peer;
|
||||
return {
|
||||
...peer,
|
||||
user: users?.find((user) => user.id === peer.user_id),
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={"py-7 px-8"}>
|
||||
<div className={"max-w-6xl"}>
|
||||
<div className={"flex justify-between items-center mb-5"}>
|
||||
<div>
|
||||
<h2 ref={headingRef}>Accessible Peers</h2>
|
||||
<Paragraph>
|
||||
This peer can connect to the following peers within the NetBird
|
||||
network.
|
||||
</Paragraph>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Suspense
|
||||
fallback={
|
||||
<div>
|
||||
<SkeletonTableHeader className={"!p-0"} />
|
||||
<div className={"mt-8 w-full"}>
|
||||
<SkeletonTable withHeader={false} />
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<AccessiblePeersTable
|
||||
peerID={peerID}
|
||||
isLoading={isLoading}
|
||||
peers={peersWithUser}
|
||||
headingTarget={portalTarget}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
210
src/modules/peer/AccessiblePeersTable.tsx
Normal file
210
src/modules/peer/AccessiblePeersTable.tsx
Normal file
@@ -0,0 +1,210 @@
|
||||
import ButtonGroup from "@components/ButtonGroup";
|
||||
import Card from "@components/Card";
|
||||
import { DataTable } from "@components/table/DataTable";
|
||||
import DataTableHeader from "@components/table/DataTableHeader";
|
||||
import DataTableRefreshButton from "@components/table/DataTableRefreshButton";
|
||||
import { DataTableRowsPerPage } from "@components/table/DataTableRowsPerPage";
|
||||
import NoResults from "@components/ui/NoResults";
|
||||
import { ColumnDef, SortingState } from "@tanstack/react-table";
|
||||
import * as React from "react";
|
||||
import { useState } from "react";
|
||||
import { useSWRConfig } from "swr";
|
||||
import PeerIcon from "@/assets/icons/PeerIcon";
|
||||
import { Peer } from "@/interfaces/Peer";
|
||||
import PeerAddressCell from "@/modules/peers/PeerAddressCell";
|
||||
import PeerLastSeenCell from "@/modules/peers/PeerLastSeenCell";
|
||||
import PeerNameCell from "@/modules/peers/PeerNameCell";
|
||||
import { PeerOSCell } from "@/modules/peers/PeerOSCell";
|
||||
|
||||
type Props = {
|
||||
peers?: Peer[];
|
||||
peerID: string;
|
||||
isLoading: boolean;
|
||||
headingTarget?: HTMLHeadingElement | null;
|
||||
};
|
||||
|
||||
const AccessiblePeersColumns: ColumnDef<Peer>[] = [
|
||||
{
|
||||
accessorKey: "name",
|
||||
header: ({ column }) => {
|
||||
return <DataTableHeader column={column}>Name</DataTableHeader>;
|
||||
},
|
||||
sortingFn: "text",
|
||||
cell: ({ row }) => <PeerNameCell peer={row.original} />,
|
||||
},
|
||||
{
|
||||
id: "connected",
|
||||
accessorKey: "connected",
|
||||
accessorFn: (peer) => peer.connected,
|
||||
},
|
||||
{
|
||||
accessorKey: "ip",
|
||||
sortingFn: "text",
|
||||
},
|
||||
{
|
||||
id: "user_name",
|
||||
accessorFn: (peer) => (peer.user ? peer.user?.name : "Unknown"),
|
||||
},
|
||||
{
|
||||
id: "user_email",
|
||||
accessorFn: (peer) => (peer.user ? peer.user?.email : "Unknown"),
|
||||
},
|
||||
{
|
||||
accessorKey: "dns_label",
|
||||
header: ({ column }) => {
|
||||
return <DataTableHeader column={column}>Address</DataTableHeader>;
|
||||
},
|
||||
cell: ({ row }) => <PeerAddressCell peer={row.original} />,
|
||||
},
|
||||
{
|
||||
accessorKey: "last_seen",
|
||||
header: ({ column }) => {
|
||||
return <DataTableHeader column={column}>Last seen</DataTableHeader>;
|
||||
},
|
||||
sortingFn: "datetime",
|
||||
cell: ({ row }) => <PeerLastSeenCell peer={row.original} />,
|
||||
},
|
||||
{
|
||||
accessorKey: "os",
|
||||
header: ({ column }) => {
|
||||
return <DataTableHeader column={column}>OS</DataTableHeader>;
|
||||
},
|
||||
cell: ({ row }) => <PeerOSCell os={row.original.os} />,
|
||||
},
|
||||
];
|
||||
|
||||
export default function AccessiblePeersTable({
|
||||
peers,
|
||||
isLoading,
|
||||
headingTarget,
|
||||
peerID,
|
||||
}: Props) {
|
||||
const { mutate } = useSWRConfig();
|
||||
// Default sorting state of the table
|
||||
const [sorting, setSorting] = useState<SortingState>([
|
||||
{
|
||||
id: "connected",
|
||||
desc: true,
|
||||
},
|
||||
{
|
||||
id: "last_seen",
|
||||
desc: true,
|
||||
},
|
||||
{
|
||||
id: "name",
|
||||
desc: false,
|
||||
},
|
||||
]);
|
||||
|
||||
return (
|
||||
<DataTable
|
||||
wrapperComponent={Card}
|
||||
wrapperProps={{ className: "mt-6 w-full" }}
|
||||
headingTarget={headingTarget}
|
||||
useRowId={true}
|
||||
sorting={sorting}
|
||||
setSorting={setSorting}
|
||||
minimal={true}
|
||||
showSearchAndFilters={true}
|
||||
inset={false}
|
||||
tableClassName={"mt-0"}
|
||||
text={"Peers"}
|
||||
columns={AccessiblePeersColumns}
|
||||
keepStateInLocalStorage={false}
|
||||
data={peers}
|
||||
searchPlaceholder={"Search by name, IP, owner or group..."}
|
||||
isLoading={isLoading}
|
||||
getStartedCard={
|
||||
<NoResults
|
||||
className={"py-4"}
|
||||
title={"This peer has no accessible peers"}
|
||||
description={
|
||||
"Add more peers to your network or check your access control policies."
|
||||
}
|
||||
icon={<PeerIcon size={20} className={"fill-nb-gray-300"} />}
|
||||
/>
|
||||
}
|
||||
columnVisibility={{
|
||||
connected: false,
|
||||
ip: false,
|
||||
user_name: false,
|
||||
user_email: false,
|
||||
}}
|
||||
paginationPaddingClassName={"px-0 pt-8"}
|
||||
>
|
||||
{(table) => (
|
||||
<>
|
||||
<ButtonGroup disabled={peers?.length == 0}>
|
||||
<ButtonGroup.Button
|
||||
disabled={peers?.length == 0}
|
||||
onClick={() => {
|
||||
table.setPageIndex(0);
|
||||
table.setColumnFilters([
|
||||
{
|
||||
id: "connected",
|
||||
value: undefined,
|
||||
},
|
||||
]);
|
||||
}}
|
||||
variant={
|
||||
table.getColumn("connected")?.getFilterValue() == undefined
|
||||
? "tertiary"
|
||||
: "secondary"
|
||||
}
|
||||
>
|
||||
All
|
||||
</ButtonGroup.Button>
|
||||
<ButtonGroup.Button
|
||||
onClick={() => {
|
||||
table.setPageIndex(0);
|
||||
table.setColumnFilters([
|
||||
{
|
||||
id: "connected",
|
||||
value: true,
|
||||
},
|
||||
]);
|
||||
}}
|
||||
disabled={peers?.length == 0}
|
||||
variant={
|
||||
table.getColumn("connected")?.getFilterValue() == true
|
||||
? "tertiary"
|
||||
: "secondary"
|
||||
}
|
||||
>
|
||||
Online
|
||||
</ButtonGroup.Button>
|
||||
<ButtonGroup.Button
|
||||
onClick={() => {
|
||||
table.setPageIndex(0);
|
||||
table.setColumnFilters([
|
||||
{
|
||||
id: "connected",
|
||||
value: false,
|
||||
},
|
||||
]);
|
||||
}}
|
||||
disabled={peers?.length == 0}
|
||||
variant={
|
||||
table.getColumn("connected")?.getFilterValue() == false
|
||||
? "tertiary"
|
||||
: "secondary"
|
||||
}
|
||||
>
|
||||
Offline
|
||||
</ButtonGroup.Button>
|
||||
</ButtonGroup>
|
||||
|
||||
<DataTableRowsPerPage table={table} disabled={peers?.length == 0} />
|
||||
|
||||
<DataTableRefreshButton
|
||||
isDisabled={peers?.length == 0}
|
||||
onClick={() => {
|
||||
mutate("/users").then();
|
||||
mutate(`/peers/${peerID}/accessible-peers`).then();
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</DataTable>
|
||||
);
|
||||
}
|
||||
64
src/modules/peer/PeerNetworkRoutesSection.tsx
Normal file
64
src/modules/peer/PeerNetworkRoutesSection.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import Paragraph from "@components/Paragraph";
|
||||
import SkeletonTable from "@components/skeletons/SkeletonTable";
|
||||
import { usePortalElement } from "@hooks/usePortalElement";
|
||||
import * as React from "react";
|
||||
import { lazy, Suspense } from "react";
|
||||
import { useLoggedInUser } from "@/contexts/UsersProvider";
|
||||
import type { Peer } from "@/interfaces/Peer";
|
||||
import { AddExitNodeButton } from "@/modules/exit-node/AddExitNodeButton";
|
||||
import { useHasExitNodes } from "@/modules/exit-node/useHasExitNodes";
|
||||
import AddRouteDropdownButton from "@/modules/peer/AddRouteDropdownButton";
|
||||
import usePeerRoutes from "@/modules/peer/usePeerRoutes";
|
||||
|
||||
const PeerRoutesTable = lazy(() => import("@/modules/peer/PeerRoutesTable"));
|
||||
|
||||
type Props = {
|
||||
peer: Peer;
|
||||
};
|
||||
|
||||
export const PeerNetworkRoutesSection = ({ peer }: Props) => {
|
||||
const { peerRoutes, isLoading } = usePeerRoutes({ peer });
|
||||
const hasExitNodes = useHasExitNodes(peer);
|
||||
const { isUser } = useLoggedInUser();
|
||||
const { ref: headingRef, portalTarget } =
|
||||
usePortalElement<HTMLHeadingElement>();
|
||||
|
||||
return (
|
||||
<div className={"pt-7 pb-10 px-8"}>
|
||||
<div className={"max-w-6xl"}>
|
||||
<div className={"flex justify-between items-center mb-5"}>
|
||||
<div>
|
||||
<h2 ref={headingRef}>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>
|
||||
|
||||
<Suspense
|
||||
fallback={
|
||||
<div>
|
||||
<div className={"mt-0 w-full"}>
|
||||
<SkeletonTable withHeader={false} />
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<PeerRoutesTable
|
||||
peer={peer}
|
||||
isLoading={isLoading}
|
||||
peerRoutes={peerRoutes}
|
||||
headingTarget={portalTarget}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -3,20 +3,21 @@ import { DataTable } from "@components/table/DataTable";
|
||||
import DataTableHeader from "@components/table/DataTableHeader";
|
||||
import NoResults from "@components/ui/NoResults";
|
||||
import { ColumnDef, SortingState } from "@tanstack/react-table";
|
||||
import { usePathname } from "next/navigation";
|
||||
import React from "react";
|
||||
import { cn } from "@utils/helpers";
|
||||
import React, { useState } from "react";
|
||||
import NetworkRoutesIcon from "@/assets/icons/NetworkRoutesIcon";
|
||||
import { useLocalStorage } from "@/hooks/useLocalStorage";
|
||||
import { Peer } from "@/interfaces/Peer";
|
||||
import { Route } from "@/interfaces/Route";
|
||||
import PeerRouteActionCell from "@/modules/peer/PeerRouteActionCell";
|
||||
import PeerRouteActiveCell from "@/modules/peer/PeerRouteActiveCell";
|
||||
import PeerRouteNameCell from "@/modules/peer/PeerRouteNameCell";
|
||||
import usePeerRoutes from "@/modules/peer/usePeerRoutes";
|
||||
import GroupedRouteNetworkRangeCell from "@/modules/route-group/GroupedRouteNetworkRangeCell";
|
||||
import RouteDistributionGroupsCell from "@/modules/routes/RouteDistributionGroupsCell";
|
||||
|
||||
type Props = {
|
||||
peerRoutes?: Route[];
|
||||
isLoading: boolean;
|
||||
headingTarget?: HTMLHeadingElement | null;
|
||||
peer: Peer;
|
||||
};
|
||||
|
||||
@@ -67,50 +68,50 @@ export const RouteTableColumns: ColumnDef<Route>[] = [
|
||||
},
|
||||
];
|
||||
|
||||
export default function PeerRoutesTable({ peer }: Props) {
|
||||
const path = usePathname();
|
||||
|
||||
export default function PeerRoutesTable({
|
||||
peerRoutes,
|
||||
isLoading,
|
||||
peer,
|
||||
}: Props) {
|
||||
// Default sorting state of the table
|
||||
const [sorting, setSorting] = useLocalStorage<SortingState>(
|
||||
"netbird-table-sort" + path,
|
||||
[
|
||||
{
|
||||
id: "network_id",
|
||||
desc: true,
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
const peerRoutes = usePeerRoutes({ peer });
|
||||
const [sorting, setSorting] = useState<SortingState>([
|
||||
{
|
||||
id: "network_id",
|
||||
desc: true,
|
||||
},
|
||||
]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card className={"mt-5 w-full"}>
|
||||
{peerRoutes && peerRoutes.length > 0 ? (
|
||||
<DataTable
|
||||
text={"Network Routes"}
|
||||
tableClassName={"mt-0"}
|
||||
minimal={true}
|
||||
inset={false}
|
||||
sorting={sorting}
|
||||
setSorting={setSorting}
|
||||
columns={RouteTableColumns}
|
||||
data={peerRoutes}
|
||||
<DataTable
|
||||
wrapperComponent={Card}
|
||||
wrapperProps={{
|
||||
className: cn("w-full"),
|
||||
}}
|
||||
text={"Network Routes"}
|
||||
tableClassName={"mt-0"}
|
||||
getStartedCard={
|
||||
<NoResults
|
||||
className={"py-4"}
|
||||
title={"This peer has no network routes"}
|
||||
description={
|
||||
"You don't have any assigned network routes yet. You can add this peer to an existing network or create a new network route."
|
||||
}
|
||||
icon={
|
||||
<NetworkRoutesIcon size={20} className={"fill-nb-gray-300"} />
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className={"py-8"}>
|
||||
<NoResults
|
||||
title={"This peer has no network routes"}
|
||||
description={
|
||||
"You don't have any assigned network routes yet. You can add this peer to an existing network or create a new network route."
|
||||
}
|
||||
icon={
|
||||
<NetworkRoutesIcon size={20} className={"fill-nb-gray-300"} />
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
}
|
||||
minimal={true}
|
||||
showSearchAndFilters={false}
|
||||
inset={false}
|
||||
isLoading={isLoading}
|
||||
sorting={sorting}
|
||||
setSorting={setSorting}
|
||||
columns={RouteTableColumns}
|
||||
data={peerRoutes}
|
||||
paginationPaddingClassName={"px-0 pt-8"}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,11 +7,12 @@ import { Route } from "@/interfaces/Route";
|
||||
type Props = {
|
||||
peer: Peer;
|
||||
};
|
||||
|
||||
export default function usePeerRoutes({ peer }: Props) {
|
||||
const { data: routes } = useFetchApi<Route[]>("/routes");
|
||||
const { data: routes, isLoading } = useFetchApi<Route[]>("/routes");
|
||||
const { peerGroups } = usePeerGroups(peer);
|
||||
|
||||
return useMemo(() => {
|
||||
const peerRoutes = useMemo(() => {
|
||||
if (!routes) return undefined;
|
||||
return routes.filter((route) => {
|
||||
const foundPeer = route.peer === peer.id;
|
||||
@@ -24,4 +25,6 @@ export default function usePeerRoutes({ peer }: Props) {
|
||||
: false;
|
||||
});
|
||||
}, [routes, peer.id, peerGroups]);
|
||||
|
||||
return { peerRoutes, isLoading };
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ export default function PeerGroupCell() {
|
||||
label={"Assigned Groups"}
|
||||
description={"Use groups to control what this peer can access"}
|
||||
groups={groupIDs || []}
|
||||
hideAllGroup={true}
|
||||
onSave={handleSave}
|
||||
modal={modal}
|
||||
peer={peer}
|
||||
|
||||
457
src/modules/peers/PeerMultiSelect.tsx
Normal file
457
src/modules/peers/PeerMultiSelect.tsx
Normal file
@@ -0,0 +1,457 @@
|
||||
import Button from "@components/Button";
|
||||
import FancyToggleSwitch from "@components/FancyToggleSwitch";
|
||||
import FullTooltip from "@components/FullTooltip";
|
||||
import HelpText from "@components/HelpText";
|
||||
import { Label } from "@components/Label";
|
||||
import { notify } from "@components/Notification";
|
||||
import { PeerGroupSelector } from "@components/PeerGroupSelector";
|
||||
import { IconX } from "@tabler/icons-react";
|
||||
import { RowSelectionState } from "@tanstack/react-table";
|
||||
import { useApiCall } from "@utils/api";
|
||||
import { cn } from "@utils/helpers";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { uniq, uniqBy } from "lodash";
|
||||
import {
|
||||
CheckCircle,
|
||||
CirclePlus,
|
||||
FolderGit2,
|
||||
Loader2,
|
||||
MonitorSmartphoneIcon,
|
||||
RedoDot,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useSWRConfig } from "swr";
|
||||
import { useDialog } from "@/contexts/DialogProvider";
|
||||
import { usePeers } from "@/contexts/PeersProvider";
|
||||
import { Group, GroupPeer } from "@/interfaces/Group";
|
||||
import { Peer } from "@/interfaces/Peer";
|
||||
import useGroupHelper from "@/modules/groups/useGroupHelper";
|
||||
|
||||
type Props = {
|
||||
selectedPeers?: RowSelectionState;
|
||||
onCanceled?: () => void;
|
||||
};
|
||||
export const PeerMultiSelect = ({ selectedPeers = {}, onCanceled }: Props) => {
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{Object.keys(selectedPeers).length > 0 && (
|
||||
<PeerGroupMassAssignmentContent
|
||||
selectedPeers={selectedPeers}
|
||||
onCanceled={onCanceled}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
};
|
||||
|
||||
const PeerGroupMassAssignmentContent = ({
|
||||
selectedPeers = {},
|
||||
onCanceled,
|
||||
}: Props) => {
|
||||
const { mutate } = useSWRConfig();
|
||||
const { confirm } = useDialog();
|
||||
|
||||
const { peers } = usePeers();
|
||||
|
||||
const groupCall = useApiCall<Group>("/groups");
|
||||
const getAllGroups = useApiCall<Group[]>("/groups").get;
|
||||
const peerCall = useApiCall<Peer>("/peers", true);
|
||||
|
||||
const [showGroupAssignment, setShowGroupAssignment] = useState(false);
|
||||
const groupAssignmentRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
const [selectedGroups, setSelectedGroups, { getAllGroupCalls }] =
|
||||
useGroupHelper({
|
||||
initial: [],
|
||||
});
|
||||
const [replaceAllGroups, setReplaceAllGroups] = useState(false);
|
||||
|
||||
const peerCount = useMemo(
|
||||
() => Object.keys(selectedPeers).length,
|
||||
[selectedPeers],
|
||||
);
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isSuccess, setIsSuccess] = useState(false);
|
||||
const isLoadingOrSuccess = isLoading || isSuccess;
|
||||
|
||||
useEffect(() => {
|
||||
const timeout = setTimeout(() => {
|
||||
isSuccess && setIsSuccess(false);
|
||||
}, 1000);
|
||||
return () => clearTimeout(timeout);
|
||||
}, [isSuccess]);
|
||||
|
||||
const addGroupsToPeers = async () => {
|
||||
if (replaceAllGroups) {
|
||||
const choice = await confirm({
|
||||
title: `Overwrite existing groups?`,
|
||||
description: `Are you sure you want to overwrite the existing groups of your ${peerCount} selected peer(s)? This action cannot be undone.`,
|
||||
confirmText: "Overwrite",
|
||||
cancelText: "Cancel",
|
||||
type: "warning",
|
||||
});
|
||||
if (!choice) return;
|
||||
}
|
||||
setIsSuccess(false);
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const allGroups = await getAllGroups();
|
||||
const selectedGroupCalls = getAllGroupCalls();
|
||||
const selectedPeerIDs = Object.keys(selectedPeers);
|
||||
let currentSelectedGroups = await Promise.all(selectedGroupCalls);
|
||||
currentSelectedGroups = currentSelectedGroups
|
||||
.map((g) => {
|
||||
let findGroup = allGroups?.find((group) => group.id === g.id);
|
||||
if (findGroup) return findGroup;
|
||||
return g;
|
||||
})
|
||||
.filter((g) => g !== undefined);
|
||||
let selectedPeerGroups: Group[] = [];
|
||||
|
||||
if (replaceAllGroups) {
|
||||
// Get all the groups of the selected peers
|
||||
selectedPeerGroups = uniqBy(
|
||||
Object.keys(selectedPeers)
|
||||
.map((id) => {
|
||||
return peers?.find((p) => p.id === id)?.groups ?? [];
|
||||
})
|
||||
.flat()
|
||||
.filter((g) => g !== undefined),
|
||||
"id",
|
||||
);
|
||||
|
||||
// Find the groups
|
||||
selectedPeerGroups =
|
||||
allGroups?.filter((group) =>
|
||||
selectedPeerGroups.find((g) => g.id === group.id),
|
||||
) ?? [];
|
||||
|
||||
// Remove the peers from the groups
|
||||
selectedPeerGroups = selectedPeerGroups.map((group) => {
|
||||
let previousPeers = group?.peers as GroupPeer[];
|
||||
let previousPeerIDs = previousPeers?.map((p) => p.id);
|
||||
previousPeerIDs = previousPeerIDs
|
||||
.filter((id) => !selectedPeerIDs.includes(id))
|
||||
.filter((id) => id !== "" && id !== null && id !== undefined);
|
||||
|
||||
return {
|
||||
...group,
|
||||
peers: previousPeerIDs,
|
||||
};
|
||||
}) as Group[];
|
||||
}
|
||||
|
||||
// Add selected peers to the selected groups
|
||||
currentSelectedGroups = currentSelectedGroups
|
||||
.map((group) => {
|
||||
let previousPeers = (group?.peers as GroupPeer[]) ?? [];
|
||||
let previousPeerIDs = previousPeers.map((p) => p.id);
|
||||
|
||||
let peers = uniq(
|
||||
[...previousPeerIDs, ...selectedPeerIDs].filter(
|
||||
(p) => p !== "" && p !== null && p !== undefined,
|
||||
),
|
||||
);
|
||||
return {
|
||||
...group,
|
||||
peers,
|
||||
};
|
||||
})
|
||||
.filter((g) => g !== undefined) as Group[];
|
||||
|
||||
// Merge the groups from the peers and the selected groups and remove duplicates
|
||||
currentSelectedGroups = uniqBy(
|
||||
[...currentSelectedGroups, ...selectedPeerGroups],
|
||||
"id",
|
||||
);
|
||||
|
||||
// Remove 'All' group if it exists
|
||||
currentSelectedGroups = currentSelectedGroups.filter(
|
||||
(group) => group.name !== "All",
|
||||
);
|
||||
|
||||
// Create the update calls for each group
|
||||
let updateGroupCalls = () =>
|
||||
Promise.all(
|
||||
currentSelectedGroups.map((group) => {
|
||||
return groupCall.put(
|
||||
{
|
||||
name: group.name,
|
||||
peers: group.peers,
|
||||
},
|
||||
"/" + group.id,
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
notify({
|
||||
title: "Assign Groups to Peers",
|
||||
description: "Groups were successfully assigned to the peers",
|
||||
promise: updateGroupCalls()
|
||||
.then(() => {
|
||||
if (currentSelectedGroups.length > 0) {
|
||||
mutate("/groups");
|
||||
mutate("/peers");
|
||||
setIsSuccess(true);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false);
|
||||
}),
|
||||
loadingMessage: "Updating the groups of the selected peers...",
|
||||
});
|
||||
} catch (e) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteAllPeers = async () => {
|
||||
const choice = await confirm({
|
||||
title: `Delete '${peerCount}' ${peerCount > 1 ? "peers" : "peer"}?`,
|
||||
description: `Are you sure you want to delete these peers? This action cannot be undone.`,
|
||||
confirmText: "Delete All",
|
||||
cancelText: "Cancel",
|
||||
type: "danger",
|
||||
});
|
||||
if (!choice) return;
|
||||
|
||||
let batchDeleteCalls = () =>
|
||||
Object.keys(selectedPeers).map((id) => {
|
||||
return peerCall.del({}, `/${id}`);
|
||||
});
|
||||
|
||||
notify({
|
||||
title: "Delete Peers",
|
||||
description: "Peers were successfully deleted",
|
||||
promise: Promise.all(batchDeleteCalls()).then(() => {
|
||||
mutate("/peers");
|
||||
onCanceled?.();
|
||||
}),
|
||||
loadingMessage: "Deleting the selected peers...",
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={"fixed -bottom-16 z-50 w-full left-0 pointer-events-none"}>
|
||||
<motion.div
|
||||
exit={{
|
||||
y: showGroupAssignment
|
||||
? (groupAssignmentRef?.current?.clientHeight ?? 0) + 55 || 370
|
||||
: 100,
|
||||
}}
|
||||
>
|
||||
<AnimatePresence>
|
||||
{showGroupAssignment && (
|
||||
<motion.div
|
||||
ref={groupAssignmentRef}
|
||||
animate={{ y: 0 }}
|
||||
initial={{ y: 100 }}
|
||||
exit={{
|
||||
y: (groupAssignmentRef?.current?.clientHeight ?? 0) + 55 || 370,
|
||||
}}
|
||||
transition={{
|
||||
type: "spring",
|
||||
stiffness: 276,
|
||||
damping: 25,
|
||||
duration: 0.35,
|
||||
}}
|
||||
className={
|
||||
"max-w-xl mx-auto rounded-t-lg -bottom-14 relative z-[49] flex gap-4 flex-col px-6 pt-6 pb-20 bg-nb-gray-920 border border-nb-gray-900 shadow-2xl border-b-0 overflow-hidden pointer-events-auto"
|
||||
}
|
||||
>
|
||||
<AnimatePresence>
|
||||
{isLoadingOrSuccess && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{
|
||||
type: "spring",
|
||||
stiffness: 260,
|
||||
damping: 20,
|
||||
duration: 0.25,
|
||||
}}
|
||||
className={
|
||||
"absolute w-full h-full flex items-center justify-center bg-nb-gray-920/70 z-50 top-0 left-0"
|
||||
}
|
||||
>
|
||||
<motion.span
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.8 }}
|
||||
transition={{
|
||||
type: "spring",
|
||||
stiffness: 260,
|
||||
damping: 20,
|
||||
duration: 0.25,
|
||||
}}
|
||||
className={
|
||||
"flex items-center justify-center gap-2 mb-14 font-normal text-nb-gray-100 text-sm"
|
||||
}
|
||||
>
|
||||
{isLoading && (
|
||||
<>
|
||||
<Loader2 size={14} className={"animate-spin"} />
|
||||
<span>Assigning groups...</span>
|
||||
</>
|
||||
)}
|
||||
{!isLoading && isSuccess && (
|
||||
<>
|
||||
<CheckCircle size={14} className={"text-green-400"} />
|
||||
<span>Groups successfully assigned</span>
|
||||
</>
|
||||
)}
|
||||
</motion.span>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<div>
|
||||
<Label>Assign Groups</Label>
|
||||
<HelpText>
|
||||
Assign the following groups to the selected peers. Previously
|
||||
assigned groups will be kept unless you choose to overwrite
|
||||
them.
|
||||
</HelpText>
|
||||
<PeerGroupSelector
|
||||
onChange={setSelectedGroups}
|
||||
values={selectedGroups}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FancyToggleSwitch
|
||||
value={replaceAllGroups}
|
||||
onChange={setReplaceAllGroups}
|
||||
label={
|
||||
<div className={"flex gap-2"}>
|
||||
<RedoDot size={14} />
|
||||
Overwrite Existing Groups
|
||||
</div>
|
||||
}
|
||||
helpText={
|
||||
<div>
|
||||
Overwrite the existing groups of the peers with the selected
|
||||
ones. Previously assigned groups will be removed.
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<AnimatePresence>
|
||||
<motion.div
|
||||
animate={{ y: 0 }}
|
||||
initial={{ y: 100 }}
|
||||
exit={{ y: showGroupAssignment ? 370 : 100 }}
|
||||
transition={{
|
||||
type: "spring",
|
||||
stiffness: 270,
|
||||
damping: 25,
|
||||
duration: 0.35,
|
||||
}}
|
||||
className={cn(
|
||||
"max-w-xl mx-auto border relative z-[50] bg-nb-gray-800 border-nb-gray-900 shadow-2xl border-b-0 overflow-hidden pointer-events-auto",
|
||||
!showGroupAssignment && "rounded-t-lg",
|
||||
)}
|
||||
>
|
||||
<AnimatePresence mode={"popLayout"}>
|
||||
<div
|
||||
className={
|
||||
"flex gap-2 items-center text-sm px-6 pt-3.5 pb-20 bg-nb-gray-920/90 text-nb-gray-200 justify-between"
|
||||
}
|
||||
>
|
||||
<div className={"flex gap-2 items-center"}>
|
||||
<MonitorSmartphoneIcon size={16} className={""} />
|
||||
<span>
|
||||
<span className={"font-medium text-white"}>
|
||||
{peerCount}
|
||||
</span>{" "}
|
||||
Peer(s) selected
|
||||
</span>
|
||||
</div>
|
||||
<div className={"flex gap-2 items-center"}>
|
||||
{!showGroupAssignment ? (
|
||||
<>
|
||||
<FullTooltip
|
||||
content={
|
||||
<span className={"text-xs"}>Assign Groups</span>
|
||||
}
|
||||
>
|
||||
<Button
|
||||
onClick={() =>
|
||||
setShowGroupAssignment(!showGroupAssignment)
|
||||
}
|
||||
variant={"default-outline"}
|
||||
size={"xs"}
|
||||
className={"!h-9 !w-9"}
|
||||
>
|
||||
<FolderGit2 size={16} className={"shrink-0"} />
|
||||
</Button>
|
||||
</FullTooltip>
|
||||
<FullTooltip
|
||||
content={<span className={"text-xs"}>Delete All</span>}
|
||||
>
|
||||
<Button
|
||||
variant={"danger-outline"}
|
||||
size={"xs"}
|
||||
className={"!h-9 !w-9"}
|
||||
onClick={deleteAllPeers}
|
||||
>
|
||||
<Trash2 size={16} className={"shrink-0"} />
|
||||
</Button>
|
||||
</FullTooltip>
|
||||
<FullTooltip
|
||||
content={<span className={"text-xs"}>Cancel</span>}
|
||||
>
|
||||
<Button
|
||||
onClick={onCanceled}
|
||||
variant={"default-outline"}
|
||||
size={"xs"}
|
||||
className={"!h-9 !w-9"}
|
||||
>
|
||||
<IconX size={16} className={"shrink-0"} />
|
||||
</Button>
|
||||
</FullTooltip>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
size={"xs"}
|
||||
variant={"secondary"}
|
||||
className={"!h-9 !px-3.5"}
|
||||
onClick={onCanceled}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size={"xs"}
|
||||
variant={"primary"}
|
||||
className={"!h-9 !px-3.5"}
|
||||
disabled={
|
||||
selectedGroups.length == 0 ||
|
||||
Object.keys(selectedPeers).length == 0 ||
|
||||
isLoadingOrSuccess
|
||||
}
|
||||
onClick={addGroupsToPeers}
|
||||
>
|
||||
{replaceAllGroups ? (
|
||||
<RedoDot size={14} />
|
||||
) : (
|
||||
<CirclePlus size={14} />
|
||||
)}
|
||||
{replaceAllGroups ? "Overwrite" : "Add"} Groups
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,3 +1,4 @@
|
||||
import { cn } from "@utils/helpers";
|
||||
import { useRouter } from "next/navigation";
|
||||
import * as React from "react";
|
||||
import { useMemo } from "react";
|
||||
@@ -8,8 +9,9 @@ import { ExitNodePeerIndicator } from "@/modules/exit-node/ExitNodePeerIndicator
|
||||
|
||||
type Props = {
|
||||
peer: Peer;
|
||||
linkToPeer?: boolean;
|
||||
};
|
||||
export default function PeerNameCell({ peer }: Props) {
|
||||
export default function PeerNameCell({ peer, linkToPeer = true }: Props) {
|
||||
const { users } = useUsers();
|
||||
const router = useRouter();
|
||||
const { isOwnerOrAdmin } = useLoggedInUser();
|
||||
@@ -21,11 +23,14 @@ export default function PeerNameCell({ peer }: Props) {
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
className={
|
||||
"flex items-center max-w-[300px] gap-2 dark:text-neutral-300 text-neutral-500 hover:text-neutral-100 transition-all hover:bg-nb-gray-800/60 py-2 px-3 rounded-md cursor-pointer"
|
||||
}
|
||||
className={cn(
|
||||
"flex items-center max-w-[300px] gap-2 dark:text-neutral-300 text-neutral-500 transition-all py-2 px-3 rounded-md ",
|
||||
linkToPeer &&
|
||||
"hover:text-neutral-100 hover:bg-nb-gray-800/60 cursor-pointer",
|
||||
)}
|
||||
data-testid="peer-name-cell"
|
||||
onClick={() => router.push("/peer?id=" + peer.id)}
|
||||
aria-label={`View details of peer ${peer.name}`}
|
||||
onClick={() => linkToPeer && router.push("/peer?id=" + peer.id)}
|
||||
>
|
||||
<ActiveInactiveRow
|
||||
active={peer.connected}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { FaWindows } from "react-icons/fa6";
|
||||
import { FcAndroidOs, FcLinux } from "react-icons/fc";
|
||||
import IOSIcon from "@/assets/icons/IOSIcon";
|
||||
import AppleLogo from "@/assets/os-icons/apple.svg";
|
||||
import FreeBSDLogo from "@/assets/os-icons/FreeBSD.png";
|
||||
import { getOperatingSystem } from "@/hooks/useOperatingSystem";
|
||||
import { OperatingSystem } from "@/interfaces/OperatingSystem";
|
||||
|
||||
@@ -49,6 +50,8 @@ export function OSLogo({ os }: { os: string }) {
|
||||
return <FaWindows className={"text-white text-lg"} />;
|
||||
if (icon === OperatingSystem.APPLE)
|
||||
return <Image src={AppleLogo} alt={""} width={14} />;
|
||||
if (icon === OperatingSystem.FREEBSD)
|
||||
return <Image src={FreeBSDLogo} alt={""} width={18} />;
|
||||
if (icon === OperatingSystem.IOS)
|
||||
return <IOSIcon className={"fill-white"} size={20} />;
|
||||
if (icon === OperatingSystem.ANDROID)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import Button from "@components/Button";
|
||||
import ButtonGroup from "@components/ButtonGroup";
|
||||
import { Checkbox } from "@components/Checkbox";
|
||||
import InlineLink from "@components/InlineLink";
|
||||
import SquareIcon from "@components/SquareIcon";
|
||||
import { DataTable } from "@components/table/DataTable";
|
||||
@@ -9,11 +10,15 @@ import { DataTableRowsPerPage } from "@components/table/DataTableRowsPerPage";
|
||||
import AddPeerButton from "@components/ui/AddPeerButton";
|
||||
import GetStartedTest from "@components/ui/GetStartedTest";
|
||||
import { NotificationCountBadge } from "@components/ui/NotificationCountBadge";
|
||||
import { ColumnDef, SortingState } from "@tanstack/react-table";
|
||||
import {
|
||||
ColumnDef,
|
||||
RowSelectionState,
|
||||
SortingState,
|
||||
} from "@tanstack/react-table";
|
||||
import { uniqBy } from "lodash";
|
||||
import { ExternalLinkIcon } from "lucide-react";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import React from "react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import React, { useState } from "react";
|
||||
import { useSWRConfig } from "swr";
|
||||
import PeerIcon from "@/assets/icons/PeerIcon";
|
||||
import PeerProvider from "@/contexts/PeerProvider";
|
||||
@@ -26,12 +31,37 @@ import PeerActionCell from "@/modules/peers/PeerActionCell";
|
||||
import PeerAddressCell from "@/modules/peers/PeerAddressCell";
|
||||
import PeerGroupCell from "@/modules/peers/PeerGroupCell";
|
||||
import PeerLastSeenCell from "@/modules/peers/PeerLastSeenCell";
|
||||
import { PeerMultiSelect } from "@/modules/peers/PeerMultiSelect";
|
||||
import PeerNameCell from "@/modules/peers/PeerNameCell";
|
||||
import { PeerOSCell } from "@/modules/peers/PeerOSCell";
|
||||
import PeerStatusCell from "@/modules/peers/PeerStatusCell";
|
||||
import PeerVersionCell from "@/modules/peers/PeerVersionCell";
|
||||
|
||||
const PeersTableColumns: ColumnDef<Peer>[] = [
|
||||
{
|
||||
id: "select",
|
||||
header: ({ table }) => (
|
||||
<div className={"min-w-[20px] max-w-[20px]"}>
|
||||
<Checkbox
|
||||
checked={table.getIsAllPageRowsSelected()}
|
||||
onCheckedChange={(value) => table.toggleAllRowsSelected(!!value)}
|
||||
aria-label="Select all"
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<div className={"min-w-[20px] max-w-[20px]"}>
|
||||
<Checkbox
|
||||
checked={row.getIsSelected()}
|
||||
variant={"tableCell"}
|
||||
onCheckedChange={(value) => row.toggleSelected(!!value)}
|
||||
aria-label="Select row"
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
},
|
||||
{
|
||||
accessorKey: "name",
|
||||
header: ({ column }) => {
|
||||
@@ -148,10 +178,11 @@ const PeersTableColumns: ColumnDef<Peer>[] = [
|
||||
type Props = {
|
||||
peers?: Peer[];
|
||||
isLoading: boolean;
|
||||
headingTarget?: HTMLHeadingElement | null;
|
||||
};
|
||||
export default function PeersTable({ peers, isLoading }: Props) {
|
||||
|
||||
export default function PeersTable({ peers, isLoading, headingTarget }: Props) {
|
||||
const { mutate } = useSWRConfig();
|
||||
const router = useRouter();
|
||||
const path = usePathname();
|
||||
|
||||
// Default sorting state of the table
|
||||
@@ -180,193 +211,252 @@ export default function PeersTable({ peers, isLoading }: Props) {
|
||||
|
||||
const { isUser } = useLoggedInUser();
|
||||
|
||||
const [selectedRows, setSelectedRows] = useState<RowSelectionState>({});
|
||||
|
||||
const resetSelectedRows = () => {
|
||||
if (Object.keys(selectedRows).length > 0) {
|
||||
setSelectedRows({});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<DataTable
|
||||
onRowClick={(row) => router.push("/peer?id=" + row.original.id)}
|
||||
text={"Peers"}
|
||||
sorting={sorting}
|
||||
setSorting={setSorting}
|
||||
columns={PeersTableColumns}
|
||||
data={peers}
|
||||
searchPlaceholder={"Search by name, IP, owner or group..."}
|
||||
columnVisibility={{
|
||||
connected: false,
|
||||
approval_required: false,
|
||||
group_name_strings: false,
|
||||
group_names: false,
|
||||
ip: false,
|
||||
user_name: false,
|
||||
user_email: false,
|
||||
actions: !isUser,
|
||||
}}
|
||||
isLoading={isLoading}
|
||||
getStartedCard={
|
||||
<GetStartedTest
|
||||
icon={
|
||||
<SquareIcon
|
||||
icon={<PeerIcon className={"fill-nb-gray-200"} size={20} />}
|
||||
color={"gray"}
|
||||
size={"large"}
|
||||
/>
|
||||
}
|
||||
title={"Get Started with NetBird"}
|
||||
description={
|
||||
"It looks like you don't have any connected machines.\n" +
|
||||
"Get started by adding one to your network."
|
||||
}
|
||||
button={<AddPeerButton />}
|
||||
learnMore={
|
||||
<>
|
||||
Learn more in our{" "}
|
||||
<InlineLink
|
||||
href={"https://docs.netbird.io/how-to/getting-started"}
|
||||
target={"_blank"}
|
||||
>
|
||||
Getting Started Guide
|
||||
<ExternalLinkIcon size={12} />
|
||||
</InlineLink>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
}
|
||||
rightSide={() => <>{peers && peers.length > 0 && <AddPeerButton />}</>}
|
||||
>
|
||||
{(table) => (
|
||||
<>
|
||||
<ButtonGroup disabled={peers?.length == 0}>
|
||||
<ButtonGroup.Button
|
||||
disabled={peers?.length == 0}
|
||||
onClick={() => {
|
||||
table.setPageIndex(0);
|
||||
table.setColumnFilters([
|
||||
{
|
||||
id: "connected",
|
||||
value: undefined,
|
||||
},
|
||||
{
|
||||
id: "approval_required",
|
||||
value: undefined,
|
||||
},
|
||||
]);
|
||||
}}
|
||||
variant={
|
||||
table.getColumn("connected")?.getFilterValue() == undefined
|
||||
? "tertiary"
|
||||
: "secondary"
|
||||
}
|
||||
>
|
||||
All
|
||||
</ButtonGroup.Button>
|
||||
<ButtonGroup.Button
|
||||
onClick={() => {
|
||||
table.setPageIndex(0);
|
||||
table.setColumnFilters([
|
||||
{
|
||||
id: "connected",
|
||||
value: true,
|
||||
},
|
||||
{
|
||||
id: "approval_required",
|
||||
value: undefined,
|
||||
},
|
||||
]);
|
||||
}}
|
||||
disabled={peers?.length == 0}
|
||||
variant={
|
||||
table.getColumn("connected")?.getFilterValue() == true
|
||||
? "tertiary"
|
||||
: "secondary"
|
||||
}
|
||||
>
|
||||
Online
|
||||
</ButtonGroup.Button>
|
||||
<ButtonGroup.Button
|
||||
onClick={() => {
|
||||
table.setPageIndex(0);
|
||||
table.setColumnFilters([
|
||||
{
|
||||
id: "connected",
|
||||
value: false,
|
||||
},
|
||||
{
|
||||
id: "approval_required",
|
||||
value: undefined,
|
||||
},
|
||||
]);
|
||||
}}
|
||||
disabled={peers?.length == 0}
|
||||
variant={
|
||||
table.getColumn("connected")?.getFilterValue() == false
|
||||
? "tertiary"
|
||||
: "secondary"
|
||||
}
|
||||
>
|
||||
Offline
|
||||
</ButtonGroup.Button>
|
||||
</ButtonGroup>
|
||||
|
||||
{pendingApprovalCount > 0 && (
|
||||
<Button
|
||||
disabled={peers?.length == 0}
|
||||
onClick={() => {
|
||||
table.setPageIndex(0);
|
||||
let current =
|
||||
table.getColumn("approval_required")?.getFilterValue() ===
|
||||
undefined
|
||||
? true
|
||||
: undefined;
|
||||
|
||||
table.setColumnFilters([
|
||||
{
|
||||
id: "connected",
|
||||
value: undefined,
|
||||
},
|
||||
{
|
||||
id: "approval_required",
|
||||
value: current,
|
||||
},
|
||||
]);
|
||||
}}
|
||||
variant={
|
||||
table.getColumn("approval_required")?.getFilterValue() === true
|
||||
? "tertiary"
|
||||
: "secondary"
|
||||
}
|
||||
>
|
||||
Pending Approvals
|
||||
<NotificationCountBadge count={pendingApprovalCount} />
|
||||
</Button>
|
||||
)}
|
||||
<DataTableRowsPerPage table={table} disabled={peers?.length == 0} />
|
||||
|
||||
<GroupSelector
|
||||
disabled={peers?.length == 0}
|
||||
values={
|
||||
(table.getColumn("group_names")?.getFilterValue() as string[]) ||
|
||||
[]
|
||||
<>
|
||||
<PeerMultiSelect
|
||||
selectedPeers={selectedRows}
|
||||
onCanceled={() => setSelectedRows({})}
|
||||
/>
|
||||
<DataTable
|
||||
headingTarget={headingTarget}
|
||||
rowSelection={selectedRows}
|
||||
setRowSelection={setSelectedRows}
|
||||
useRowId={true}
|
||||
text={"Peers"}
|
||||
sorting={sorting}
|
||||
setSorting={setSorting}
|
||||
columns={PeersTableColumns}
|
||||
data={peers}
|
||||
searchPlaceholder={"Search by name, IP, owner or group..."}
|
||||
columnVisibility={{
|
||||
select: !isUser,
|
||||
connected: false,
|
||||
approval_required: false,
|
||||
group_name_strings: false,
|
||||
group_names: false,
|
||||
ip: false,
|
||||
user_name: false,
|
||||
user_email: false,
|
||||
actions: !isUser,
|
||||
groups: !isUser,
|
||||
}}
|
||||
isLoading={isLoading}
|
||||
getStartedCard={
|
||||
<GetStartedTest
|
||||
icon={
|
||||
<SquareIcon
|
||||
icon={<PeerIcon className={"fill-nb-gray-200"} size={20} />}
|
||||
color={"gray"}
|
||||
size={"large"}
|
||||
/>
|
||||
}
|
||||
title={"Get Started with NetBird"}
|
||||
description={
|
||||
"It looks like you don't have any connected machines.\n" +
|
||||
"Get started by adding one to your network."
|
||||
}
|
||||
button={<AddPeerButton />}
|
||||
learnMore={
|
||||
<>
|
||||
Learn more in our{" "}
|
||||
<InlineLink
|
||||
href={"https://docs.netbird.io/how-to/getting-started"}
|
||||
target={"_blank"}
|
||||
>
|
||||
Getting Started Guide
|
||||
<ExternalLinkIcon size={12} />
|
||||
</InlineLink>
|
||||
</>
|
||||
}
|
||||
onChange={(groups) => {
|
||||
table.setPageIndex(0);
|
||||
if (groups.length == 0) {
|
||||
table.getColumn("group_names")?.setFilterValue(undefined);
|
||||
return;
|
||||
} else {
|
||||
table.getColumn("group_names")?.setFilterValue(groups);
|
||||
}
|
||||
}}
|
||||
groups={tableGroups}
|
||||
/>
|
||||
}
|
||||
rightSide={() => <>{peers && peers.length > 0 && <AddPeerButton />}</>}
|
||||
>
|
||||
{(table) => (
|
||||
<>
|
||||
<ButtonGroup disabled={peers?.length == 0}>
|
||||
<ButtonGroup.Button
|
||||
disabled={peers?.length == 0}
|
||||
onClick={() => {
|
||||
table.setPageIndex(0);
|
||||
let groupFilters = table
|
||||
.getColumn("group_names")
|
||||
?.getFilterValue();
|
||||
table.setColumnFilters([
|
||||
{
|
||||
id: "connected",
|
||||
value: undefined,
|
||||
},
|
||||
{
|
||||
id: "approval_required",
|
||||
value: undefined,
|
||||
},
|
||||
{
|
||||
id: "group_names",
|
||||
value: groupFilters ?? [],
|
||||
},
|
||||
{
|
||||
id: "group_names",
|
||||
value: groupFilters ?? [],
|
||||
},
|
||||
]);
|
||||
resetSelectedRows();
|
||||
}}
|
||||
variant={
|
||||
table.getColumn("connected")?.getFilterValue() == undefined
|
||||
? "tertiary"
|
||||
: "secondary"
|
||||
}
|
||||
>
|
||||
All
|
||||
</ButtonGroup.Button>
|
||||
<ButtonGroup.Button
|
||||
onClick={() => {
|
||||
table.setPageIndex(0);
|
||||
let groupFilters = table
|
||||
.getColumn("group_names")
|
||||
?.getFilterValue();
|
||||
table.setColumnFilters([
|
||||
{
|
||||
id: "connected",
|
||||
value: true,
|
||||
},
|
||||
{
|
||||
id: "approval_required",
|
||||
value: undefined,
|
||||
},
|
||||
{
|
||||
id: "group_names",
|
||||
value: groupFilters ?? [],
|
||||
},
|
||||
{
|
||||
id: "group_names",
|
||||
value: groupFilters ?? [],
|
||||
},
|
||||
]);
|
||||
resetSelectedRows();
|
||||
}}
|
||||
disabled={peers?.length == 0}
|
||||
variant={
|
||||
table.getColumn("connected")?.getFilterValue() == true
|
||||
? "tertiary"
|
||||
: "secondary"
|
||||
}
|
||||
>
|
||||
Online
|
||||
</ButtonGroup.Button>
|
||||
<ButtonGroup.Button
|
||||
onClick={() => {
|
||||
table.setPageIndex(0);
|
||||
let groupFilters = table
|
||||
.getColumn("group_names")
|
||||
?.getFilterValue();
|
||||
table.setColumnFilters([
|
||||
{
|
||||
id: "connected",
|
||||
value: false,
|
||||
},
|
||||
{
|
||||
id: "approval_required",
|
||||
value: undefined,
|
||||
},
|
||||
{
|
||||
id: "group_names",
|
||||
value: groupFilters ?? [],
|
||||
},
|
||||
]);
|
||||
resetSelectedRows();
|
||||
}}
|
||||
disabled={peers?.length == 0}
|
||||
variant={
|
||||
table.getColumn("connected")?.getFilterValue() == false
|
||||
? "tertiary"
|
||||
: "secondary"
|
||||
}
|
||||
>
|
||||
Offline
|
||||
</ButtonGroup.Button>
|
||||
</ButtonGroup>
|
||||
|
||||
<DataTableRefreshButton
|
||||
isDisabled={peers?.length == 0}
|
||||
onClick={() => {
|
||||
mutate("/groups").then();
|
||||
mutate("/users").then();
|
||||
mutate("/peers").then();
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</DataTable>
|
||||
{pendingApprovalCount > 0 && (
|
||||
<Button
|
||||
disabled={peers?.length == 0}
|
||||
onClick={() => {
|
||||
table.setPageIndex(0);
|
||||
let current =
|
||||
table.getColumn("approval_required")?.getFilterValue() ===
|
||||
undefined
|
||||
? true
|
||||
: undefined;
|
||||
|
||||
table.setColumnFilters([
|
||||
{
|
||||
id: "connected",
|
||||
value: undefined,
|
||||
},
|
||||
{
|
||||
id: "approval_required",
|
||||
value: current,
|
||||
},
|
||||
]);
|
||||
|
||||
resetSelectedRows();
|
||||
}}
|
||||
variant={
|
||||
table.getColumn("approval_required")?.getFilterValue() ===
|
||||
true
|
||||
? "tertiary"
|
||||
: "secondary"
|
||||
}
|
||||
>
|
||||
Pending Approvals
|
||||
<NotificationCountBadge count={pendingApprovalCount} />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<DataTableRowsPerPage table={table} disabled={peers?.length == 0} />
|
||||
|
||||
{!isUser && (
|
||||
<GroupSelector
|
||||
disabled={peers?.length == 0}
|
||||
values={
|
||||
(table
|
||||
.getColumn("group_names")
|
||||
?.getFilterValue() as string[]) || []
|
||||
}
|
||||
onChange={(groups) => {
|
||||
table.setPageIndex(0);
|
||||
if (groups.length == 0) {
|
||||
table.getColumn("group_names")?.setFilterValue(undefined);
|
||||
return;
|
||||
} else {
|
||||
table.getColumn("group_names")?.setFilterValue(groups);
|
||||
}
|
||||
resetSelectedRows();
|
||||
}}
|
||||
groups={tableGroups}
|
||||
/>
|
||||
)}
|
||||
|
||||
<DataTableRefreshButton
|
||||
isDisabled={peers?.length == 0}
|
||||
onClick={() => {
|
||||
if (!isUser) mutate("/groups").then();
|
||||
mutate("/users").then();
|
||||
mutate("/peers").then();
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</DataTable>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
34
src/modules/posture-checks/helper/PostureCheckHelper.ts
Normal file
34
src/modules/posture-checks/helper/PostureCheckHelper.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import {
|
||||
GeoLocation,
|
||||
GeoLocationCheck,
|
||||
OperatingSystemVersionCheck,
|
||||
} from "@/interfaces/PostureCheck";
|
||||
|
||||
export const validateOSCheck = (osCheck?: OperatingSystemVersionCheck) => {
|
||||
if (!osCheck) return;
|
||||
const os = osCheck;
|
||||
if (os.darwin && os.darwin.min_version == "") os.darwin.min_version = "0";
|
||||
if (os.android && os.android.min_version == "") os.android.min_version = "0";
|
||||
if (os.windows && os.windows.min_kernel_version == "")
|
||||
os.windows.min_kernel_version = "0";
|
||||
if (os.linux && os.linux.min_kernel_version == "")
|
||||
os.linux.min_kernel_version = "0";
|
||||
if (os.ios && os.ios.min_version == "") os.ios.min_version = "0";
|
||||
return os;
|
||||
};
|
||||
|
||||
export const validateLocationCheck = (locationCheck?: GeoLocationCheck) => {
|
||||
if (!locationCheck) return;
|
||||
if (!locationCheck.locations) return;
|
||||
return {
|
||||
action: locationCheck.action,
|
||||
locations: locationCheck.locations.map((location) => {
|
||||
if (location.city_name == "")
|
||||
return { country_code: location.country_code } as GeoLocation;
|
||||
return {
|
||||
country_code: location.country_code,
|
||||
city_name: location.city_name,
|
||||
} as GeoLocation;
|
||||
}),
|
||||
} as GeoLocationCheck;
|
||||
};
|
||||
@@ -5,32 +5,27 @@ import { Input } from "@components/Input";
|
||||
import { Label } from "@components/Label";
|
||||
import { Modal, ModalContent, ModalFooter } from "@components/modal/Modal";
|
||||
import ModalHeader from "@components/modal/ModalHeader";
|
||||
import { notify } from "@components/Notification";
|
||||
import Paragraph from "@components/Paragraph";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@components/Tabs";
|
||||
import { Textarea } from "@components/Textarea";
|
||||
import { useApiCall } from "@utils/api";
|
||||
import { cn } from "@utils/helpers";
|
||||
import { isEmpty } from "lodash";
|
||||
import { ExternalLinkIcon, LayoutList, ShieldCheck, Text } from "lucide-react";
|
||||
import React, { useState } from "react";
|
||||
import { useSWRConfig } from "swr";
|
||||
import {
|
||||
GeoLocationCheck,
|
||||
OperatingSystemVersionCheck,
|
||||
PostureCheck,
|
||||
} from "@/interfaces/PostureCheck";
|
||||
import { PostureCheck } from "@/interfaces/PostureCheck";
|
||||
import { PostureCheckGeoLocation } from "@/modules/posture-checks/checks/PostureCheckGeoLocation";
|
||||
import { PostureCheckNetBirdVersion } from "@/modules/posture-checks/checks/PostureCheckNetBirdVersion";
|
||||
import { PostureCheckOperatingSystem } from "@/modules/posture-checks/checks/PostureCheckOperatingSystem";
|
||||
import { PostureCheckPeerNetworkRange } from "@/modules/posture-checks/checks/PostureCheckPeerNetworkRange";
|
||||
import { PostureCheckProcess } from "@/modules/posture-checks/checks/PostureCheckProcess";
|
||||
import { usePostureCheck } from "@/modules/posture-checks/usePostureCheck";
|
||||
|
||||
type Props = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSuccess?: (check: PostureCheck) => void;
|
||||
postureCheck?: PostureCheck;
|
||||
useSave?: boolean;
|
||||
};
|
||||
|
||||
export default function PostureCheckModal({
|
||||
@@ -38,109 +33,28 @@ export default function PostureCheckModal({
|
||||
onOpenChange,
|
||||
onSuccess,
|
||||
postureCheck,
|
||||
useSave = true,
|
||||
}: Props) {
|
||||
const postureCheckRequest = useApiCall("/posture-checks");
|
||||
const { mutate } = useSWRConfig();
|
||||
const {
|
||||
state: check,
|
||||
dispatch: setCheck,
|
||||
updateOrCreateAndNotify: updateOrCreate,
|
||||
} = usePostureCheck({
|
||||
postureCheck,
|
||||
onSuccess,
|
||||
});
|
||||
|
||||
const [name, setName] = useState(postureCheck?.name || "");
|
||||
const [description, setDescription] = useState(
|
||||
postureCheck?.description || "",
|
||||
);
|
||||
|
||||
const [nbVersionCheck, setNbVersionCheck] = useState(
|
||||
postureCheck?.checks.nb_version_check || undefined,
|
||||
);
|
||||
const [geoLocationCheck, setGeoLocationCheckCheck] = useState(
|
||||
postureCheck?.checks.geo_location_check || undefined,
|
||||
);
|
||||
const [osVersionCheck, setOsVersionCheck] = useState(
|
||||
postureCheck?.checks.os_version_check || undefined,
|
||||
);
|
||||
const [peerNetworkRangeCheck, setPeerNetworkRangeCheck] = useState(
|
||||
postureCheck?.checks.peer_network_range_check || undefined,
|
||||
);
|
||||
const [processCheck, setProcessCheck] = useState(
|
||||
postureCheck?.checks.process_check || undefined,
|
||||
);
|
||||
|
||||
const validateOSCheck = (osCheck?: OperatingSystemVersionCheck) => {
|
||||
if (!osCheck) return;
|
||||
const os = osCheck;
|
||||
if (os.darwin && os.darwin.min_version == "") os.darwin.min_version = "0";
|
||||
if (os.android && os.android.min_version == "")
|
||||
os.android.min_version = "0";
|
||||
if (os.windows && os.windows.min_kernel_version == "")
|
||||
os.windows.min_kernel_version = "0";
|
||||
if (os.linux && os.linux.min_kernel_version == "")
|
||||
os.linux.min_kernel_version = "0";
|
||||
if (os.ios && os.ios.min_version == "") os.ios.min_version = "0";
|
||||
return os;
|
||||
};
|
||||
|
||||
const validateLocationCheck = (locationCheck?: GeoLocationCheck) => {
|
||||
if (!locationCheck) return;
|
||||
if (!locationCheck.locations) return;
|
||||
return {
|
||||
action: locationCheck.action,
|
||||
locations: locationCheck.locations.map((location) => {
|
||||
if (location.city_name == "")
|
||||
return { country_code: location.country_code };
|
||||
return {
|
||||
country_code: location.country_code,
|
||||
city_name: location.city_name,
|
||||
};
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
const updateOrCreatePostureCheck = () => {
|
||||
const newData = {
|
||||
name,
|
||||
description,
|
||||
checks: {
|
||||
nb_version_check: nbVersionCheck,
|
||||
geo_location_check: validateLocationCheck(geoLocationCheck),
|
||||
os_version_check: validateOSCheck(osVersionCheck),
|
||||
peer_network_range_check: peerNetworkRangeCheck,
|
||||
process_check: processCheck,
|
||||
},
|
||||
};
|
||||
|
||||
const updateOrCreate = !postureCheck
|
||||
? () =>
|
||||
postureCheckRequest.post(newData).then((check: PostureCheck) => {
|
||||
mutate("/posture-checks");
|
||||
onSuccess?.(check);
|
||||
onOpenChange(false);
|
||||
})
|
||||
: () =>
|
||||
postureCheckRequest
|
||||
.put({ ...newData, id: postureCheck.id }, `/${postureCheck.id}`)
|
||||
.then((check: PostureCheck) => {
|
||||
mutate("/posture-checks").then();
|
||||
onSuccess?.(check);
|
||||
onOpenChange(false);
|
||||
});
|
||||
|
||||
notify({
|
||||
title: `Posture Check ${newData.name}`,
|
||||
description: `Posture Check was ${
|
||||
postureCheck ? "updated" : "created"
|
||||
} successfully.`,
|
||||
loadingMessage: `${
|
||||
postureCheck ? "Updating" : "Creating"
|
||||
} your posture check...`,
|
||||
promise: updateOrCreate(),
|
||||
});
|
||||
const close = () => {
|
||||
onSuccess && onSuccess(check);
|
||||
};
|
||||
|
||||
const isAtLeastOneCheckEnabled =
|
||||
!!nbVersionCheck ||
|
||||
!!geoLocationCheck ||
|
||||
!!osVersionCheck ||
|
||||
!!peerNetworkRangeCheck ||
|
||||
!!processCheck;
|
||||
const canCreate = !isEmpty(name) && isAtLeastOneCheckEnabled;
|
||||
!!check?.checks?.nb_version_check ||
|
||||
!!check?.checks?.geo_location_check ||
|
||||
!!check?.checks?.os_version_check ||
|
||||
!!check?.checks?.peer_network_range_check ||
|
||||
!!check?.checks.process_check;
|
||||
const canCreate = !isEmpty(check?.name) && isAtLeastOneCheckEnabled;
|
||||
|
||||
const [tab, setTab] = useState("checks");
|
||||
|
||||
@@ -186,24 +100,49 @@ export default function PostureCheckModal({
|
||||
<TabsContent value={"checks"} className={"pb-6 px-8"}>
|
||||
<>
|
||||
<PostureCheckNetBirdVersion
|
||||
value={nbVersionCheck}
|
||||
onChange={setNbVersionCheck}
|
||||
value={check?.checks?.nb_version_check}
|
||||
onChange={(v) =>
|
||||
setCheck({
|
||||
type: "version",
|
||||
payload: v,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<PostureCheckGeoLocation
|
||||
value={geoLocationCheck}
|
||||
onChange={setGeoLocationCheckCheck}
|
||||
value={check?.checks?.geo_location_check}
|
||||
onChange={(v) =>
|
||||
setCheck({
|
||||
type: "location",
|
||||
payload: v,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<PostureCheckPeerNetworkRange
|
||||
value={peerNetworkRangeCheck}
|
||||
onChange={setPeerNetworkRangeCheck}
|
||||
value={check?.checks?.peer_network_range_check}
|
||||
onChange={(v) =>
|
||||
setCheck({
|
||||
type: "network_range",
|
||||
payload: v,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<PostureCheckOperatingSystem
|
||||
value={osVersionCheck}
|
||||
onChange={setOsVersionCheck}
|
||||
value={check?.checks?.os_version_check}
|
||||
onChange={(v) =>
|
||||
setCheck({
|
||||
type: "os",
|
||||
payload: v,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<PostureCheckProcess
|
||||
value={processCheck}
|
||||
onChange={setProcessCheck}
|
||||
value={check?.checks?.process_check}
|
||||
onChange={(v) =>
|
||||
setCheck({
|
||||
type: "process_check",
|
||||
payload: v,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</>
|
||||
</TabsContent>
|
||||
@@ -217,8 +156,13 @@ export default function PostureCheckModal({
|
||||
<Input
|
||||
autoFocus={true}
|
||||
tabIndex={0}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
value={check?.name}
|
||||
onChange={(e) =>
|
||||
setCheck({
|
||||
type: "name",
|
||||
payload: e.target.value,
|
||||
})
|
||||
}
|
||||
placeholder={"e.g., NetBird Version > 0.25.0"}
|
||||
/>
|
||||
</div>
|
||||
@@ -229,8 +173,13 @@ export default function PostureCheckModal({
|
||||
policy.
|
||||
</HelpText>
|
||||
<Textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
value={check?.description}
|
||||
onChange={(e) =>
|
||||
setCheck({
|
||||
type: "description",
|
||||
payload: e.target.value,
|
||||
})
|
||||
}
|
||||
placeholder={
|
||||
"e.g., Check if the NetBird version is bigger than 0.25.0"
|
||||
}
|
||||
@@ -288,7 +237,13 @@ export default function PostureCheckModal({
|
||||
<Button
|
||||
variant={"primary"}
|
||||
disabled={!canCreate}
|
||||
onClick={updateOrCreatePostureCheck}
|
||||
onClick={() => {
|
||||
if (useSave) {
|
||||
updateOrCreate();
|
||||
} else {
|
||||
close();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{postureCheck ? "Save Changes" : "Create Posture Check"}
|
||||
</Button>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user