Sync changes with netbird cloud (#407)

* Update axa oidc library and package.json

* Update ACL port state to show correct value

* Filter user groups by unique groups only

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

* Add peer multiselect, optimize dropdown performance for peer selection, remove 'all' group from some dropdowns, various ui / ux optimizations
This commit is contained in:
Eduard Gert
2024-08-13 15:51:22 +02:00
committed by GitHub
parent 650496f670
commit d34ae9beb2
51 changed files with 2245 additions and 757 deletions

528
package-lock.json generated
View File

@@ -8,7 +8,7 @@
"name": "netbird-dashboard",
"version": "2.0.0",
"dependencies": {
"@axa-fr/react-oidc": "^5.14.0",
"@axa-fr/react-oidc": "^7.22.18",
"@radix-ui/react-accordion": "^1.1.2",
"@radix-ui/react-checkbox": "^1.0.4",
"@radix-ui/react-collapsible": "^1.0.3",
@@ -17,8 +17,9 @@
"@radix-ui/react-label": "^2.0.2",
"@radix-ui/react-popover": "^1.0.7",
"@radix-ui/react-radio-group": "^1.1.3",
"@radix-ui/react-scroll-area": "^1.0.5",
"@radix-ui/react-scroll-area": "^1.1.0",
"@radix-ui/react-select": "^2.0.0",
"@radix-ui/react-slider": "^1.1.2",
"@radix-ui/react-slot": "^1.0.2",
"@radix-ui/react-switch": "^1.0.3",
"@radix-ui/react-tabs": "^1.0.4",
@@ -32,6 +33,7 @@
"@types/node": "20.10.6",
"@types/react": "^18",
"@types/react-dom": "^18",
"@types/react-window": "^1.8.8",
"autoprefixer": "^10",
"class-variance-authority": "^0.7.0",
"clsx": "^2.0.0",
@@ -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",
@@ -5396,6 +5734,7 @@
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"dev": true,
"engines": {
"node": ">= 0.6"
}
@@ -5404,6 +5743,7 @@
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"dev": true,
"dependencies": {
"mime-db": "1.52.0"
},
@@ -5712,14 +6052,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/opener": {
"version": "1.5.2",
"resolved": "https://registry.npmjs.org/opener/-/opener-1.5.2.tgz",
"integrity": "sha512-ur5UIdyw5Y7yEj9wLzhqXiy6GZ3Mwx0yGI+5sMn2r0N0v3cKJvUmFH5yPP+WXh9e0xfyzyJX95D8l088DNFj7A==",
"bin": {
"opener": "bin/opener-bin.js"
}
},
"node_modules/optionator": {
"version": "0.9.3",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.3.tgz",
@@ -6346,6 +6678,18 @@
}
}
},
"node_modules/react-virtuoso": {
"version": "4.10.0",
"resolved": "https://registry.npmjs.org/react-virtuoso/-/react-virtuoso-4.10.0.tgz",
"integrity": "sha512-CyxU5TYMH4bw2cybH0bNqN/yIg2q2Vd0kbs92tQc5ResZALAIzIVJY4JL6BHgJFQjwrLhCYrFwKq0p+lvBgA0w==",
"engines": {
"node": ">=10"
},
"peerDependencies": {
"react": ">=16 || >=17 || >= 18",
"react-dom": ">=16 || >=17 || >= 18"
}
},
"node_modules/read-cache": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",

View File

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

View File

@@ -327,6 +327,7 @@ function PeerOverview() {
disabled={isUser}
onChange={setSelectedGroups}
values={selectedGroups}
hideAllGroup={true}
peer={peer}
/>
</FullTooltip>

View File

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

View File

@@ -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={
<>

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -29,6 +29,7 @@ interface MultiSelectProps {
max?: number;
disabled?: boolean;
popoverWidth?: "auto" | number;
hideAllGroup?: boolean;
}
export function PeerGroupSelector({
onChange,
@@ -37,6 +38,7 @@ export function PeerGroupSelector({
max,
disabled = false,
popoverWidth = "auto",
hideAllGroup = false,
}: MultiSelectProps) {
const { groups, dropdownOptions, setDropdownOptions } = useGroups();
const searchRef = React.useRef<HTMLInputElement>(null);
@@ -47,7 +49,13 @@ export function PeerGroupSelector({
useEffect(() => {
if (!groups) return;
const sortedGroups = sortBy([...groups], "name") as Group[];
setDropdownOptions(unionBy(sortedGroups, dropdownOptions, "name"));
let uniqueGroups = unionBy(sortedGroups, dropdownOptions, "name");
uniqueGroups = hideAllGroup
? uniqueGroups.filter((group) => group.name !== "All")
: uniqueGroups;
setDropdownOptions(uniqueGroups);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [groups]);
@@ -66,8 +74,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 &&
groupPeers.push({ id: peer?.id as string, name: peer?.name as string });
}
if (!group && !option) {
setDropdownOptions((previous) => [
@@ -100,17 +111,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);
@@ -203,7 +215,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 +250,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

View File

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

View File

@@ -4,30 +4,65 @@ 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 overflow-hidden will-change-scroll webkit-scroll",
className,
)}
{...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 +84,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
View File

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

View File

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

View File

@@ -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 {
@@ -28,6 +29,7 @@ import {
getSortedRowModel,
PaginationState,
Row,
RowSelectionState,
SortingState,
Table as TanStackTable,
useReactTable,
@@ -105,6 +107,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;
@@ -126,6 +129,11 @@ interface DataTableProps<TData, TValue> {
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;
}
export function DataTable<TData, TValue>(props: DataTableProps<TData, TValue>) {
@@ -139,6 +147,7 @@ export function DataTableContent<TData, TValue>({
children,
searchPlaceholder = "Search...",
columnVisibility = {},
setColumnVisibility,
sorting = [],
setSorting,
text = "rows",
@@ -159,6 +168,11 @@ export function DataTableContent<TData, TValue>({
rightSide,
manualPagination = false,
showHeader = true,
rowSelection,
setRowSelection,
useRowId,
headingTarget,
showResetFilterButton = true,
}: DataTableProps<TData, TValue>) {
const path = usePathname();
const [columnFilters, setColumnFilters] = useLocalStorage<ColumnFiltersState>(
@@ -176,9 +190,6 @@ export function DataTableContent<TData, TValue>({
pageSize: 10,
});
const [tableColumnVisibility, setColumnVisibility] =
React.useState<VisibilityState>(columnVisibility);
const hasInitialData = !!(data && data.length > 0);
const table = useReactTable({
@@ -196,8 +207,9 @@ export function DataTableContent<TData, TValue>({
manualPagination: manualPagination,
state: {
sorting,
rowSelection: rowSelection ?? {},
columnFilters,
columnVisibility: tableColumnVisibility,
columnVisibility: columnVisibility,
globalFilter: globalSearch,
pagination: paginationState,
},
@@ -207,6 +219,8 @@ export function DataTableContent<TData, TValue>({
pageSize: 10,
},
},
getRowId: useRowId ? (row) => row.id : undefined,
onRowSelectionChange: setRowSelection,
onSortingChange: setSorting,
onPaginationChange: setPaginationState,
onColumnFiltersChange: setColumnFilters,
@@ -235,6 +249,7 @@ export function DataTableContent<TData, TValue>({
table.setPageIndex(0);
setColumnFilters([]);
setGlobalSearch("");
setRowSelection?.({});
};
return (
@@ -248,11 +263,14 @@ export function DataTableContent<TData, TValue>({
setGlobalSearch={(val) => {
table.setPageIndex(0);
setGlobalSearch(val);
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)}
@@ -412,6 +430,11 @@ export function DataTableContent<TData, TValue>({
<div className={paginationClassName}>
<DataTablePagination table={table} text={text} />
</div>
<DataTableHeadingPortal
table={table}
headingTarget={headingTarget}
text={text}
/>
</div>
);
}

View File

@@ -0,0 +1,73 @@
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;
text: string;
};
export const DataTableHeadingPortal = function <TData>({
table,
headingTarget,
text = "Items",
}: Props<TData>) {
const hasMounted = useRef(false);
if (!headingTarget) return;
if (!hasMounted.current) {
headingTarget.innerHTML = "";
hasMounted.current = true;
}
const totalItems = table?.getPreFilteredRowModel().rows.length;
const filteredItems = table?.getFilteredRowModel().rows.length;
const hasAnyFiltersActive =
table &&
!(
table?.getState().columnFilters.length <= 0 &&
table?.getState().globalFilter === ""
);
return createPortal(
<Heading
text={text}
hasAnyFilterActive={hasAnyFiltersActive}
totalItems={totalItems}
filteredItems={filteredItems}
/>,
headingTarget,
);
};
type HeadingProps = {
hasAnyFilterActive: boolean | null;
filteredItems?: number;
totalItems?: number;
text: string;
};
const Heading = ({
hasAnyFilterActive,
filteredItems,
totalItems,
text,
}: HeadingProps) => {
if (!totalItems || totalItems == 1) {
return text;
}
if (hasAnyFilterActive) {
return (
<>
<span className={"text-netbird"}>{filteredItems}</span> of {totalItems}{" "}
{text}
</>
);
}
return `${totalItems} ${text}`;
};

View File

@@ -25,7 +25,10 @@ export default function GroupBadge({
useHover={true}
variant={"gray-ghost"}
className={cn("transition-all group whitespace-nowrap", className)}
onClick={onClick}
onClick={(e) => {
e.preventDefault();
onClick?.();
}}
>
<FolderGit2 size={12} className={"shrink-0"} />
<TextWithTooltip text={group?.name || ""} maxChars={20} />

View File

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

View File

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

View File

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

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

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

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

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

View File

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

View File

@@ -272,6 +272,16 @@ export function AccessControlModalContent({
if (continuePostureChecksDisabled) return true;
}, [name, continuePostureChecksDisabled]);
const handleProtocolChange = (p: Protocol) => {
setProtocol(p);
if (p == "icmp") {
setPorts([]);
}
if (p == "all") {
setPorts([]);
}
};
return (
<ModalContent maxWidthClass={"max-w-2xl"}>
<ModalHeader
@@ -322,7 +332,7 @@ 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"}>

View File

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

View File

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

View File

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

View File

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

View 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>
);
};

View File

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

View File

@@ -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,36 @@ 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()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
aria-label="Select row"
/>
</div>
),
enableSorting: false,
enableHiding: false,
},
{
accessorKey: "name",
header: ({ column }) => {
@@ -148,10 +177,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 +210,248 @@ 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={{
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>
</>
}
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} />
<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={() => {
mutate("/groups").then();
mutate("/users").then();
mutate("/peers").then();
}}
/>
</>
)}
</DataTable>
</>
);
}

View File

@@ -39,7 +39,7 @@ export default function PostureCheckModal({
onSuccess,
postureCheck,
}: Props) {
const postureCheckRequest = useApiCall("/posture-checks");
const postureCheckRequest = useApiCall<PostureCheck>("/posture-checks");
const { mutate } = useSWRConfig();
const [name, setName] = useState(postureCheck?.name || "");

View File

@@ -4,10 +4,14 @@ import { DataTable } from "@components/table/DataTable";
import DataTableHeader from "@components/table/DataTableHeader";
import DataTableRefreshButton from "@components/table/DataTableRefreshButton";
import { useLocalStorage } from "@hooks/useLocalStorage";
import type { ColumnDef, SortingState } from "@tanstack/react-table";
import {
ColumnDef,
RowSelectionState,
SortingState,
} from "@tanstack/react-table";
import useFetchApi from "@utils/api";
import { usePathname } from "next/navigation";
import React from "react";
import React, { useState } from "react";
import { useSWRConfig } from "swr";
import { PostureCheck } from "@/interfaces/PostureCheck";
import { PostureCheckChecksCell } from "@/modules/posture-checks/table/cells/PostureCheckChecksCell";
@@ -17,7 +21,7 @@ type Props = {
onAdd: (checks: PostureCheck[]) => void;
};
export default function PostureCheckBrowseTable({ onAdd }: Props) {
export default function PostureCheckBrowseTable({ onAdd }: Readonly<Props>) {
const { data: postureChecks, isLoading } =
useFetchApi<PostureCheck[]>("/posture-checks");
const { mutate } = useSWRConfig();
@@ -34,9 +38,14 @@ export default function PostureCheckBrowseTable({ onAdd }: Props) {
],
);
const [selectedRows, setSelectedRows] = useState<RowSelectionState>({});
return (
<div className={""}>
<DataTable
showResetFilterButton={false}
rowSelection={selectedRows}
setRowSelection={setSelectedRows}
isLoading={isLoading}
text={"Posture Check"}
sorting={sorting}
@@ -73,14 +82,12 @@ export default function PostureCheckBrowseTable({ onAdd }: Props) {
>
{() => {
return (
<>
<DataTableRefreshButton
isDisabled={postureChecks?.length == 0}
onClick={() => {
mutate("/posture-checks");
}}
/>
</>
<DataTableRefreshButton
isDisabled={postureChecks?.length == 0}
onClick={() => {
mutate("/posture-checks");
}}
/>
);
}}
</DataTable>

View File

@@ -5,10 +5,10 @@ import { cn } from "@utils/helpers";
import { HelpCircle, PlusCircle } from "lucide-react";
import { useRouter } from "next/navigation";
import * as React from "react";
import { useMemo, useState } from "react";
import { useMemo } from "react";
import PeerIcon from "@/assets/icons/PeerIcon";
import { GroupedRoute } from "@/interfaces/Route";
import RouteAddRoutingPeerModal from "@/modules/routes/RouteAddRoutingPeerModal";
import { useAddRoutingPeer } from "@/modules/routes/RouteAddRoutingPeerProvider";
type Props = {
groupedRoute: GroupedRoute;
@@ -43,109 +43,99 @@ export default function GroupedRouteHighAvailabilityCell({
[],
);
const [modal, setModal] = useState(false);
const { openAddRoutingPeerModal } = useAddRoutingPeer();
return (
<>
{!groupedRoute.is_using_route_groups && (
<RouteAddRoutingPeerModal
groupedRoute={groupedRoute}
modal={modal}
setModal={setModal}
/>
)}
<FullTooltip
interactive={false}
content={
<div className={"max-w-xs text-xs"}>
{!isActive && !groupedRoute.is_using_route_groups && (
<>
{disabledText}
<div className={"inline-flex mt-2"}>
Go ahead and add more routing peers to enable high
availability for this network route.
</div>
</>
)}
{isActive && !groupedRoute.is_using_route_groups && (
<>
{enabledText}
<div className={"inline-flex mt-2"}>
You can add more peers to increase the availability of this
network route.
</div>
</>
)}
{!isActive && groupedRoute.is_using_route_groups && (
<>
{disabledText}
<div className={"inline-flex mt-2"}>
To configure, you must add more peers to a group in this
route. You can do it in the Peers menu.
</div>
</>
)}
{isActive && groupedRoute.is_using_route_groups && (
<>
{enabledText}
<div className={"inline-flex mt-2"}>
You can add more peers to a group in this route by going to
the peers page.
</div>
</>
)}
</div>
}
>
<div className={"flex gap-3 items-center"}>
<Badge
variant={isActive ? "green" : "gray"}
className={cn(
"inline-flex gap-2 min-w-[110px] font-medium items-center justify-center min-h-[34px]",
!isActive && "opacity-30",
)}
useHover={true}
>
{isActive ? (
<>
<div className={"h-2 w-2 rounded-full bg-green-500"}></div>
{groupedRoute.high_availability_count} Peer(s)
</>
) : (
<>
<div className={"h-2 w-2 rounded-full bg-nb-gray-700"}></div>
Disabled
</>
)}
<HelpCircle size={12} />
</Badge>
{groupedRoute.is_using_route_groups && (
<Button
size={"xs"}
variant={"secondary"}
className={"min-w-[130px]"}
onClick={() => router.push("/peers")}
>
<>
<PeerIcon size={12} />
Go to Peers
</>
</Button>
<FullTooltip
interactive={false}
content={
<div className={"max-w-xs text-xs"}>
{!isActive && !groupedRoute.is_using_route_groups && (
<>
{disabledText}
<div className={"inline-flex mt-2"}>
Go ahead and add more routing peers to enable high availability
for this network route.
</div>
</>
)}
{isActive && !groupedRoute.is_using_route_groups && (
<>
{enabledText}
<div className={"inline-flex mt-2"}>
You can add more peers to increase the availability of this
network route.
</div>
</>
)}
{!isActive && groupedRoute.is_using_route_groups && (
<>
{disabledText}
<div className={"inline-flex mt-2"}>
To configure, you must add more peers to a group in this route.
You can do it in the Peers menu.
</div>
</>
)}
{isActive && groupedRoute.is_using_route_groups && (
<>
{enabledText}
<div className={"inline-flex mt-2"}>
You can add more peers to a group in this route by going to the
peers page.
</div>
</>
)}
{!groupedRoute.is_using_route_groups && (
<Button
size={"xs"}
variant={"secondary"}
className={"min-w-[130px]"}
onClick={() => setModal(true)}
>
<PlusCircle size={12} />
Add Peer
</Button>
)}{" "}
</div>
</FullTooltip>
</>
}
>
<div className={"flex gap-3 items-center"}>
<Badge
variant={isActive ? "green" : "gray"}
className={cn(
"inline-flex gap-2 min-w-[110px] font-medium items-center justify-center min-h-[34px]",
!isActive && "opacity-30",
)}
useHover={true}
>
{isActive ? (
<>
<div className={"h-2 w-2 rounded-full bg-green-500"}></div>
{groupedRoute.high_availability_count} Peer(s)
</>
) : (
<>
<div className={"h-2 w-2 rounded-full bg-nb-gray-700"}></div>
Disabled
</>
)}
<HelpCircle size={12} />
</Badge>
{groupedRoute.is_using_route_groups && (
<Button
size={"xs"}
variant={"secondary"}
className={"min-w-[130px]"}
onClick={() => router.push("/peers")}
>
<>
<PeerIcon size={12} />
Go to Peers
</>
</Button>
)}
{!groupedRoute.is_using_route_groups && (
<Button
size={"xs"}
variant={"secondary"}
className={"min-w-[130px]"}
onClick={() => openAddRoutingPeerModal(groupedRoute)}
>
<PlusCircle size={12} />
Add Peer
</Button>
)}{" "}
</div>
</FullTooltip>
);
}

View File

@@ -23,6 +23,7 @@ import GroupedRouteHighAvailabilityCell from "@/modules/route-group/GroupedRoute
import GroupedRouteNameCell from "@/modules/route-group/GroupedRouteNameCell";
import GroupedRouteNetworkRangeCell from "@/modules/route-group/GroupedRouteNetworkRangeCell";
import GroupedRouteTypeCell from "@/modules/route-group/GroupedRouteTypeCell";
import { RouteAddRoutingPeerProvider } from "@/modules/routes/RouteAddRoutingPeerProvider";
import RouteModal from "@/modules/routes/RouteModal";
import RouteTable from "@/modules/routes/RouteTable";
@@ -138,130 +139,135 @@ export default function NetworkRoutesTable({
);
return (
<DataTable
isLoading={isLoading}
text={"Network Routes"}
sorting={sorting}
setSorting={setSorting}
columns={GroupedRouteTableColumns}
data={groupedRoutes}
searchPlaceholder={"Search by network, range, name or groups..."}
columnVisibility={{
enabled: false,
description: false,
description_search: false,
group_names: false,
domains: false,
domain_search: false,
}}
renderExpandedRow={(row) => {
const data = cloneDeep(row);
return (
<GroupRouteProvider groupedRoute={data}>
<RouteTable row={data} />
</GroupRouteProvider>
);
}}
getStartedCard={
<GetStartedTest
icon={
<SquareIcon
icon={
<NetworkRoutesIcon className={"fill-nb-gray-200"} size={20} />
}
color={"gray"}
size={"large"}
/>
}
title={"Create New Route"}
description={
"It looks like you don't have any routes. Access LANs and VPC by adding a network route."
}
button={
<div className={"gap-x-4 flex items-center justify-center"}>
<AddExitNodeButton />
<RouteModal>
<Button variant={"primary"} className={""}>
<PlusCircle size={16} />
Add Route
</Button>
</RouteModal>
</div>
}
learnMore={
<>
Learn more about
<InlineLink
href={
"https://docs.netbird.io/how-to/routing-traffic-to-private-networks"
<RouteAddRoutingPeerProvider>
<DataTable
isLoading={isLoading}
text={"Network Routes"}
sorting={sorting}
setSorting={setSorting}
columns={GroupedRouteTableColumns}
data={groupedRoutes}
searchPlaceholder={"Search by network, range, name or groups..."}
columnVisibility={{
enabled: false,
description: false,
description_search: false,
group_names: false,
domains: false,
domain_search: false,
}}
renderExpandedRow={(row) => {
const data = cloneDeep(row);
return (
<GroupRouteProvider groupedRoute={data}>
<RouteTable row={data} />
</GroupRouteProvider>
);
}}
getStartedCard={
<GetStartedTest
icon={
<SquareIcon
icon={
<NetworkRoutesIcon className={"fill-nb-gray-200"} size={20} />
}
target={"_blank"}
>
Network Routes
<ExternalLinkIcon size={12} />
</InlineLink>
</>
}
/>
}
rightSide={() => (
<>
{routes && routes?.length > 0 && (
<div className={"gap-x-4 ml-auto flex"}>
<AddExitNodeButton />
<RouteModal>
<Button variant={"primary"} className={""}>
<PlusCircle size={16} />
Add Route
</Button>
</RouteModal>
</div>
)}
</>
)}
>
{(table) => (
<>
<ButtonGroup disabled={routes?.length == 0}>
<ButtonGroup.Button
onClick={() => {
table.setPageIndex(0);
table.getColumn("enabled")?.setFilterValue(true);
}}
disabled={routes?.length == 0}
variant={
table.getColumn("enabled")?.getFilterValue() == true
? "tertiary"
: "secondary"
}
>
Enabled
</ButtonGroup.Button>
<ButtonGroup.Button
onClick={() => {
table.setPageIndex(0);
table.getColumn("enabled")?.setFilterValue(undefined);
}}
disabled={routes?.length == 0}
variant={
table.getColumn("enabled")?.getFilterValue() == undefined
? "tertiary"
: "secondary"
}
>
All
</ButtonGroup.Button>
</ButtonGroup>
<DataTableRowsPerPage table={table} disabled={routes?.length == 0} />
<DataTableRefreshButton
isDisabled={routes?.length == 0}
onClick={() => {
mutate("/setup-keys").then();
mutate("/groups").then();
}}
color={"gray"}
size={"large"}
/>
}
title={"Create New Route"}
description={
"It looks like you don't have any routes. Access LANs and VPC by adding a network route."
}
button={
<div className={"gap-x-4 flex items-center justify-center"}>
<AddExitNodeButton />
<RouteModal>
<Button variant={"primary"} className={""}>
<PlusCircle size={16} />
Add Route
</Button>
</RouteModal>
</div>
}
learnMore={
<>
Learn more about
<InlineLink
href={
"https://docs.netbird.io/how-to/routing-traffic-to-private-networks"
}
target={"_blank"}
>
Network Routes
<ExternalLinkIcon size={12} />
</InlineLink>
</>
}
/>
</>
)}
</DataTable>
}
rightSide={() => (
<>
{routes && routes?.length > 0 && (
<div className={"gap-x-4 ml-auto flex"}>
<AddExitNodeButton />
<RouteModal>
<Button variant={"primary"} className={""}>
<PlusCircle size={16} />
Add Route
</Button>
</RouteModal>
</div>
)}
</>
)}
>
{(table) => (
<>
<ButtonGroup disabled={routes?.length == 0}>
<ButtonGroup.Button
onClick={() => {
table.setPageIndex(0);
table.getColumn("enabled")?.setFilterValue(true);
}}
disabled={routes?.length == 0}
variant={
table.getColumn("enabled")?.getFilterValue() == true
? "tertiary"
: "secondary"
}
>
Enabled
</ButtonGroup.Button>
<ButtonGroup.Button
onClick={() => {
table.setPageIndex(0);
table.getColumn("enabled")?.setFilterValue(undefined);
}}
disabled={routes?.length == 0}
variant={
table.getColumn("enabled")?.getFilterValue() == undefined
? "tertiary"
: "secondary"
}
>
All
</ButtonGroup.Button>
</ButtonGroup>
<DataTableRowsPerPage
table={table}
disabled={routes?.length == 0}
/>
<DataTableRefreshButton
isDisabled={routes?.length == 0}
onClick={() => {
mutate("/setup-keys").then();
mutate("/groups").then();
}}
/>
</>
)}
</DataTable>
</RouteAddRoutingPeerProvider>
);
}

View File

@@ -0,0 +1,46 @@
import * as React from "react";
import { useState } from "react";
import { GroupedRoute } from "@/interfaces/Route";
import RouteAddRoutingPeerModal from "@/modules/routes/RouteAddRoutingPeerModal";
type Props = {
children: React.ReactNode;
};
const GroupedRouteContext = React.createContext(
{} as {
openAddRoutingPeerModal: (groupedRoute: GroupedRoute) => void;
},
);
export const RouteAddRoutingPeerProvider = ({ children }: Props) => {
const [groupedRoute, setGroupedRoute] = useState<GroupedRoute>();
const [modal, setModal] = useState(false);
const openAddRoutingPeerModal = (groupedRoute: GroupedRoute) => {
setGroupedRoute(groupedRoute);
setModal(true);
};
return (
<GroupedRouteContext.Provider value={{ openAddRoutingPeerModal }}>
{children}
{modal && groupedRoute && (
<RouteAddRoutingPeerModal
groupedRoute={groupedRoute}
modal={modal}
setModal={setModal}
/>
)}
</GroupedRouteContext.Provider>
);
};
export const useAddRoutingPeer = () => {
const context = React.useContext(GroupedRouteContext);
if (context === undefined) {
throw new Error(
"useGroupedRoute must be used within a GroupedRouteProvider",
);
}
return context;
};

View File

@@ -579,7 +579,7 @@ export function RouteModalContent({
<div>
<Label>Metric</Label>
<HelpText className={"max-w-[200px]"}>
A lower metric indicates a higher priority route.
A lower metric indicates higher priority routes.
</HelpText>
</div>

View File

@@ -46,6 +46,7 @@ export default function SetupKeyGroupsCell({ setupKey }: Props) {
}
groups={setupKey.auto_groups || []}
onSave={handleSave}
hideAllGroup={true}
showAddGroupButton={true}
modal={modal}
setModal={setModal}

View File

@@ -297,6 +297,7 @@ export function SetupKeyModalContent({ onSuccess }: ModalProps) {
<PeerGroupSelector
onChange={setSelectedGroups}
values={selectedGroups}
hideAllGroup={true}
/>
</div>
</div>

View File

@@ -6,6 +6,7 @@ import Paragraph from "@components/Paragraph";
import SmallParagraph from "@components/SmallParagraph";
import { Tabs, TabsList, TabsTrigger } from "@components/Tabs";
import { ExternalLinkIcon } from "lucide-react";
import { usePathname } from "next/navigation";
import React from "react";
import AndroidIcon from "@/assets/icons/AndroidIcon";
import AppleIcon from "@/assets/icons/AppleIcon";
@@ -53,23 +54,25 @@ export function SetupModalContent({
}) {
const os = useOperatingSystem();
const [isFirstRun] = useLocalStorage<boolean>("netbird-first-run", true);
const pathname = usePathname();
const isInstallPage = pathname === "/install";
return (
<>
{header && (
<div className={"text-center pb-8 pt-4 px-8"}>
<h2 className={"text-3xl max-w-lg mx-auto"}>
{isFirstRun ? (
{isFirstRun && !isInstallPage ? (
<>
Hello {user?.given_name || "there"}! 👋 <br />
{`It's time to add your first device.`}
</>
) : (
<>Add new peer</>
<>Install NetBird</>
)}
</h2>
<Paragraph className={"max-w-xs mx-auto mt-3"}>
To get started, install NetBird and log in using your email account.
To get started, install NetBird and log in with your email account.
</Paragraph>
</div>
)}

View File

@@ -8,6 +8,7 @@ import { DataTableRowsPerPage } from "@components/table/DataTableRowsPerPage";
import GetStartedTest from "@components/ui/GetStartedTest";
import { IconSettings2 } from "@tabler/icons-react";
import { ColumnDef, SortingState } from "@tanstack/react-table";
import useFetchApi from "@utils/api";
import { ExternalLinkIcon, PlusCircle } from "lucide-react";
import { usePathname, useRouter } from "next/navigation";
import React from "react";
@@ -65,6 +66,7 @@ type Props = {
};
export default function ServiceUsersTable({ users, isLoading }: Props) {
useFetchApi("/groups");
const { mutate } = useSWRConfig();
const router = useRouter();
const path = usePathname();
@@ -161,7 +163,8 @@ export default function ServiceUsersTable({ users, isLoading }: Props) {
<DataTableRefreshButton
isDisabled={users?.length == 0}
onClick={() => {
mutate("/users?service_user=true").then();
mutate("/users?service_user=true");
mutate("/groups");
}}
/>
</>

View File

@@ -155,6 +155,7 @@ export function UserInviteModalContent({ onSuccess }: ModalProps) {
<PeerGroupSelector
onChange={setSelectedGroups}
values={selectedGroups}
hideAllGroup={true}
/>
</div>
</div>

View File

@@ -8,9 +8,10 @@ import { ChevronsUpDown, Cog, User2 } from "lucide-react";
import * as React from "react";
import { useState } from "react";
import NetBirdIcon from "@/assets/icons/NetBirdIcon";
import { useDialog } from "@/contexts/DialogProvider";
import { useLoggedInUser } from "@/contexts/UsersProvider";
import { useElementSize } from "@/hooks/useElementSize";
import { Role } from "@/interfaces/User";
import { Role, User } from "@/interfaces/User";
interface MultiSelectProps {
value?: Role;
@@ -18,6 +19,7 @@ interface MultiSelectProps {
disabled?: boolean;
popoverWidth?: "auto" | number;
hideOwner?: boolean;
currentUser?: User;
}
const UserRoles = [
@@ -44,11 +46,39 @@ export function UserRoleSelector({
disabled = false,
popoverWidth = "auto",
hideOwner = false,
currentUser,
}: MultiSelectProps) {
const [inputRef, { width }] = useElementSize<HTMLButtonElement>();
const { isOwner } = useLoggedInUser();
const { confirm } = useDialog();
const toggle = async (item: Role) => {
if (item === Role.Owner) {
let ok = await confirm({
title: "Transfer Ownership?",
type: "warning",
description: (
<div className={"inline-block"}>
This action will transfer the{" "}
<span className={"text-netbird inline font-medium"}>Owner</span>{" "}
role to{" "}
{currentUser ? (
<span className={"text-netbird inline font-medium"}>
{currentUser.name}
</span>
) : (
"this user"
)}{" "}
and leave you with the{" "}
<span className={"text-netbird inline font-medium"}>Admin</span>{" "}
role. This action can only be undone if the new owner transfers the
role back to you.
</div>
),
});
if (!ok) return;
}
const toggle = (item: Role) => {
const isSelected = value == item;
if (isSelected) {
} else {

View File

@@ -7,6 +7,7 @@ import DataTableRefreshButton from "@components/table/DataTableRefreshButton";
import { DataTableRowsPerPage } from "@components/table/DataTableRowsPerPage";
import GetStartedTest from "@components/ui/GetStartedTest";
import { ColumnDef, SortingState } from "@tanstack/react-table";
import useFetchApi from "@utils/api";
import { isLocalDev, isNetBirdHosted } from "@utils/netbird";
import dayjs from "dayjs";
import { ExternalLinkIcon, MailPlus, PlusCircle } from "lucide-react";
@@ -100,6 +101,7 @@ type Props = {
};
export default function UsersTable({ users, isLoading }: Props) {
useFetchApi("/groups");
const { mutate } = useSWRConfig();
const path = usePathname();
@@ -196,7 +198,8 @@ export default function UsersTable({ users, isLoading }: Props) {
<DataTableRefreshButton
isDisabled={users?.length == 0}
onClick={() => {
mutate("/users?service_user=false").then();
mutate("/users?service_user=false");
mutate("/groups");
}}
/>
</>

View File

@@ -1,4 +1,5 @@
import MultipleGroups from "@components/ui/MultipleGroups";
import { uniq } from "lodash";
import React, { useState } from "react";
import { useGroups } from "@/contexts/GroupsProvider";
import { Group } from "@/interfaces/Group";
@@ -12,7 +13,7 @@ export default function UserGroupCell({ user }: Props) {
const { groups } = useGroups();
const [allGroups] = useState(() => {
return user.auto_groups
return uniq(user.auto_groups)
.map((group) => {
return groups?.find((g) => g.id == group);
})

View File

@@ -50,7 +50,9 @@ async function apiRequest<T>(
}
}
export function useNetBirdFetch(ignoreError: boolean = false) {
export function useNetBirdFetch(ignoreError: boolean = false): {
fetch: (input: RequestInfo, init?: RequestInit) => Promise<Response>;
} {
const tokenSource = config.tokenSource || "accessToken";
const { idToken } = useOidcIdToken();
const { accessToken } = useOidcAccessToken();
@@ -69,7 +71,10 @@ export function useNetBirdFetch(ignoreError: boolean = false) {
const nativeFetch = async (input: RequestInfo, init?: RequestInit) => {
const tokenExpired = await isTokenExpired();
if (tokenExpired) {
return handleErrors({ code: 401, message: "token expired" });
return handleErrors({
code: 401,
message: "token expired",
} as ErrorResponse);
}
const headers = {
@@ -85,7 +90,10 @@ export function useNetBirdFetch(ignoreError: boolean = false) {
};
return {
fetch: nativeFetch,
fetch: nativeFetch as (
input: RequestInfo,
init?: RequestInit,
) => Promise<Response>,
};
}
@@ -131,22 +139,22 @@ export function useApiCall<T>(url: string, ignoreError = false) {
post: async (data: any, suffix = "") => {
return apiRequest<T>(fetch, "POST", url + suffix, data)
.then((res) => Promise.resolve(res as T))
.catch((err) => handleErrors(err as ErrorResponse));
.catch((err) => handleErrors(err as ErrorResponse)) as Promise<T>;
},
put: async (data: any, suffix = "") => {
return apiRequest<T>(fetch, "PUT", url + suffix, data)
.then((res) => Promise.resolve(res as T))
.catch((err) => handleErrors(err as ErrorResponse));
.catch((err) => handleErrors(err as ErrorResponse)) as Promise<T>;
},
del: async (data: any = "", suffix = "") => {
return apiRequest<T>(fetch, "DELETE", url + suffix, data)
.then((res) => Promise.resolve(res as T))
.catch((err) => handleErrors(err as ErrorResponse));
.catch((err) => handleErrors(err as ErrorResponse)) as Promise<T>;
},
get: async (suffix = "") => {
return apiRequest<T>(fetch, "GET", url + suffix)
.then((res) => Promise.resolve(res as T))
.catch((err) => handleErrors(err as ErrorResponse));
.catch((err) => handleErrors(err as ErrorResponse)) as Promise<T>;
},
};
}

View File

@@ -1,4 +1,4 @@
import { StringMap } from "@axa-fr/react-oidc/dist/vanilla/oidc";
import { StringMap } from "@axa-fr/react-oidc";
import { validator } from "@utils/helpers";
interface Config {

View File

@@ -42,7 +42,7 @@ export const sleep = (ms: number) => {
export const validator = {
isValidDomain: (domain: string) => {
const unicodeDomain =
/^(?!.*\.\.)(?!.*\.$)(?!.*\s)(?:(?!-)(?!.*--)[a-zA-Z0-9\u00A1-\uFFFF-]{1,63}(?<!-)\.)+(?!-)(?!.*--)[a-zA-Z0-9\u00A1-\uFFFF-]{2,63}$/u;
/^(?!.*\.\.)(?!.*\.$)(?!.*\s)(?:(?!-)(?!.*--)[a-zA-Z0-9\u00A1-\uFFFF-]{1,63}(?<!-)\.)+(?!-)(?!.*--)[a-zA-Z0-9\u00A1-\uFFFF-]{2,63}$/u;
try {
const minMaxChars = [1, 255];
const isValidDomainLength =