Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
58cec8fcd1 | ||
|
|
d34ae9beb2 | ||
|
|
650496f670 | ||
|
|
121778c4a6 | ||
|
|
d4102c5d04 | ||
|
|
e78c35bdbe | ||
|
|
6ebee98695 | ||
|
|
f4b28d5f40 | ||
|
|
b4b6d9295b | ||
|
|
4898742ee9 | ||
|
|
79164e9dd5 | ||
|
|
5caeab118b |
3
.github/workflows/codespell.yml
vendored
3
.github/workflows/codespell.yml
vendored
@@ -12,4 +12,5 @@ jobs:
|
||||
uses: codespell-project/actions-codespell@v2
|
||||
with:
|
||||
only_warn: 1
|
||||
skip: package-lock.json,*.svg
|
||||
skip: package-lock.json,*.svg
|
||||
ignore_words_list: mappin
|
||||
|
||||
@@ -5,6 +5,9 @@ const nextConfig = {
|
||||
unoptimized: true,
|
||||
},
|
||||
reactStrictMode: false,
|
||||
env: {
|
||||
APP_ENV: process.env.APP_ENV || "production",
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = nextConfig;
|
||||
|
||||
536
package-lock.json
generated
536
package-lock.json
generated
@@ -8,7 +8,7 @@
|
||||
"name": "netbird-dashboard",
|
||||
"version": "2.0.0",
|
||||
"dependencies": {
|
||||
"@axa-fr/react-oidc": "^5.14.0",
|
||||
"@axa-fr/react-oidc": "^7.22.18",
|
||||
"@radix-ui/react-accordion": "^1.1.2",
|
||||
"@radix-ui/react-checkbox": "^1.0.4",
|
||||
"@radix-ui/react-collapsible": "^1.0.3",
|
||||
@@ -17,8 +17,9 @@
|
||||
"@radix-ui/react-label": "^2.0.2",
|
||||
"@radix-ui/react-popover": "^1.0.7",
|
||||
"@radix-ui/react-radio-group": "^1.1.3",
|
||||
"@radix-ui/react-scroll-area": "^1.0.5",
|
||||
"@radix-ui/react-scroll-area": "^1.1.0",
|
||||
"@radix-ui/react-select": "^2.0.0",
|
||||
"@radix-ui/react-slider": "^1.1.2",
|
||||
"@radix-ui/react-slot": "^1.0.2",
|
||||
"@radix-ui/react-switch": "^1.0.3",
|
||||
"@radix-ui/react-tabs": "^1.0.4",
|
||||
@@ -32,6 +33,7 @@
|
||||
"@types/node": "20.10.6",
|
||||
"@types/react": "^18",
|
||||
"@types/react-dom": "^18",
|
||||
"@types/react-window": "^1.8.8",
|
||||
"autoprefixer": "^10",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.0.0",
|
||||
@@ -48,7 +50,7 @@
|
||||
"framer-motion": "^10.16.4",
|
||||
"ip-cidr": "^3.1.0",
|
||||
"lodash": "^4.17.21",
|
||||
"lucide-react": "^0.287.0",
|
||||
"lucide-react": "^0.383.0",
|
||||
"next": "13.5.5",
|
||||
"next-themes": "^0.2.1",
|
||||
"punycode": "^2.3.1",
|
||||
@@ -62,6 +64,7 @@
|
||||
"react-jwt": "^1.2.0",
|
||||
"react-loading-skeleton": "^3.3.1",
|
||||
"react-responsive": "^9.0.2",
|
||||
"react-virtuoso": "^4.9.0",
|
||||
"swr": "^2.2.4",
|
||||
"tailwind-merge": "^1.14.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
@@ -93,16 +96,31 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/@axa-fr/react-oidc": {
|
||||
"version": "5.14.2",
|
||||
"resolved": "https://registry.npmjs.org/@axa-fr/react-oidc/-/react-oidc-5.14.2.tgz",
|
||||
"integrity": "sha512-N+ssJlVtVHnsvlusMxY3zLPKCB+lGzeHIxWXUb0WY3uA7Z+jxx7A2m9W1kHbhYzHuihgA3rWIcdKsvtdkeKXwg==",
|
||||
"node_modules/@axa-fr/oidc-client": {
|
||||
"version": "7.22.21",
|
||||
"resolved": "https://registry.npmjs.org/@axa-fr/oidc-client/-/oidc-client-7.22.21.tgz",
|
||||
"integrity": "sha512-w6CokGCz9Au0E3bCS5yJCUDlQemGE/TlT8jdN9FltOHI/NUw0Mn/5Rzeh/LOtlo5TIhaOS2nIlCEOY+JEIpj2w==",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@openid/appauth": "1.3.1"
|
||||
"@axa-fr/oidc-client-service-worker": "7.22.21"
|
||||
}
|
||||
},
|
||||
"node_modules/@axa-fr/oidc-client-service-worker": {
|
||||
"version": "7.22.21",
|
||||
"resolved": "https://registry.npmjs.org/@axa-fr/oidc-client-service-worker/-/oidc-client-service-worker-7.22.21.tgz",
|
||||
"integrity": "sha512-wDZTpRsY36sl4Ah9/ZhzDxybLj46HZjMl7Rn0qLhpK1Sb+GL+d9Agq6xNclkvizDFwuyX6hTaPGQpwcE0WNRQQ=="
|
||||
},
|
||||
"node_modules/@axa-fr/react-oidc": {
|
||||
"version": "7.22.21",
|
||||
"resolved": "https://registry.npmjs.org/@axa-fr/react-oidc/-/react-oidc-7.22.21.tgz",
|
||||
"integrity": "sha512-lEdCt/q7kBXJ1AX+tEK/QAkz4p4G2qOSlhdYxPSSBRIf4ZwZEcmlH6F28W/FySk6tj/coi56dGvmcHz+hSZUDQ==",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@axa-fr/oidc-client": "7.22.21",
|
||||
"@axa-fr/oidc-client-service-worker": "7.22.21"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "x",
|
||||
"react-dom": "x"
|
||||
"react": "^17.0.0 || ^18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/runtime": {
|
||||
@@ -542,32 +560,6 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/@openid/appauth": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@openid/appauth/-/appauth-1.3.1.tgz",
|
||||
"integrity": "sha512-e54kpi219wES2ijPzeHe1kMnT8VKH8YeTd1GAn9BzVBmutz3tBgcG1y8a4pziNr4vNjFnuD4W446Ua7ELnNDiA==",
|
||||
"dependencies": {
|
||||
"@types/base64-js": "^1.3.0",
|
||||
"@types/jquery": "^3.5.5",
|
||||
"base64-js": "^1.5.1",
|
||||
"follow-redirects": "^1.13.3",
|
||||
"form-data": "^4.0.0",
|
||||
"opener": "^1.5.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@openid/appauth/node_modules/form-data": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz",
|
||||
"integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==",
|
||||
"dependencies": {
|
||||
"asynckit": "^0.4.0",
|
||||
"combined-stream": "^1.0.8",
|
||||
"mime-types": "^2.1.12"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/@popperjs/core": {
|
||||
"version": "2.11.8",
|
||||
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
|
||||
@@ -1202,26 +1194,25 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-scroll-area": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.0.5.tgz",
|
||||
"integrity": "sha512-b6PAgH4GQf9QEn8zbT2XUHpW5z8BzqEc7Kl11TwDrvuTrxlkcjTD5qa/bxgKr+nmuXKu4L/W5UZ4mlP/VG/5Gw==",
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.1.0.tgz",
|
||||
"integrity": "sha512-9ArIZ9HWhsrfqS765h+GZuLoxaRHD/j0ZWOWilsCvYTpYJp8XwCqNG7Dt9Nu/TItKOdgLGkOPCodQvDc+UMwYg==",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.13.10",
|
||||
"@radix-ui/number": "1.0.1",
|
||||
"@radix-ui/primitive": "1.0.1",
|
||||
"@radix-ui/react-compose-refs": "1.0.1",
|
||||
"@radix-ui/react-context": "1.0.1",
|
||||
"@radix-ui/react-direction": "1.0.1",
|
||||
"@radix-ui/react-presence": "1.0.1",
|
||||
"@radix-ui/react-primitive": "1.0.3",
|
||||
"@radix-ui/react-use-callback-ref": "1.0.1",
|
||||
"@radix-ui/react-use-layout-effect": "1.0.1"
|
||||
"@radix-ui/number": "1.1.0",
|
||||
"@radix-ui/primitive": "1.1.0",
|
||||
"@radix-ui/react-compose-refs": "1.1.0",
|
||||
"@radix-ui/react-context": "1.1.0",
|
||||
"@radix-ui/react-direction": "1.1.0",
|
||||
"@radix-ui/react-presence": "1.1.0",
|
||||
"@radix-ui/react-primitive": "2.0.0",
|
||||
"@radix-ui/react-use-callback-ref": "1.1.0",
|
||||
"@radix-ui/react-use-layout-effect": "1.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0"
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
@@ -1232,6 +1223,148 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/number": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.0.tgz",
|
||||
"integrity": "sha512-V3gRzhVNU1ldS5XhAPTom1fOIo4ccrjjJgmE+LI2h/WaFpHmx0MQApT+KZHnx8abG6Avtfcz4WoEciMnpFT3HQ=="
|
||||
},
|
||||
"node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/primitive": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.0.tgz",
|
||||
"integrity": "sha512-4Z8dn6Upk0qk4P74xBhZ6Hd/w0mPEzOOLxy4xiPXOXqjF7jZS0VAKk7/x/H6FyY2zCkYJqePf1G5KmkmNJ4RBA=="
|
||||
},
|
||||
"node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-compose-refs": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.0.tgz",
|
||||
"integrity": "sha512-b4inOtiaOnYf9KWyO3jAeeCG6FeyfY6ldiEPanbUjWd+xIk5wZeHa8yVwmrJ2vderhu/BQvzCrJI0lHd+wIiqw==",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-context": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.0.tgz",
|
||||
"integrity": "sha512-OKrckBy+sMEgYM/sMmqmErVn0kZqrHPJze+Ql3DzYsDDp0hl0L62nx/2122/Bvps1qz645jlcu2tD9lrRSdf8A==",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-direction": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.0.tgz",
|
||||
"integrity": "sha512-BUuBvgThEiAXh2DWu93XsT+a3aWrGqolGlqqw5VU1kG7p/ZH2cuDlM1sRLNnY3QcBS69UIz2mcKhMxDsdewhjg==",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-presence": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.0.tgz",
|
||||
"integrity": "sha512-Gq6wuRN/asf9H/E/VzdKoUtT8GC9PQc9z40/vEr0VCJ4u5XvvhWIrSsCB6vD2/cH7ugTdSfYq9fLJCcM00acrQ==",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.0",
|
||||
"@radix-ui/react-use-layout-effect": "1.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-primitive": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.0.tgz",
|
||||
"integrity": "sha512-ZSpFm0/uHa8zTvKBDjLFWLo8dkr4MBsiDLz0g3gMUwqgLHz9rTaRRGYDgvZPtBJgYCBKXkS9fzmoySgr8CO6Cw==",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-slot": "1.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.0.tgz",
|
||||
"integrity": "sha512-FUCf5XMfmW4dtYl69pdS4DbxKy8nj4M7SafBgPllysxmdachynNflAdp/gCsnYWNDnge6tI9onzMp5ARYc1KNw==",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-use-callback-ref": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.0.tgz",
|
||||
"integrity": "sha512-CasTfvsy+frcFkbXtSJ2Zu9JHpN8TYKxkgJGWbjiZhFivxaeW7rMeZt7QELGVLaYVfFMsKHjb7Ak0nMEe+2Vfw==",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-use-layout-effect": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.0.tgz",
|
||||
"integrity": "sha512-+FPE0rOdziWSrH9athwI1R0HDVbWlEhd+FR+aSDk4uWGmSJ9Z54sdZVDQPZAinJhJXwfT+qnj969mCsT2gfm5w==",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-select": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.0.0.tgz",
|
||||
@@ -1275,6 +1408,230 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-slider": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slider/-/react-slider-1.2.0.tgz",
|
||||
"integrity": "sha512-dAHCDA4/ySXROEPaRtaMV5WHL8+JB/DbtyTbJjYkY0RXmKMO2Ln8DFZhywG5/mVQ4WqHDBc8smc14yPXPqZHYA==",
|
||||
"dependencies": {
|
||||
"@radix-ui/number": "1.1.0",
|
||||
"@radix-ui/primitive": "1.1.0",
|
||||
"@radix-ui/react-collection": "1.1.0",
|
||||
"@radix-ui/react-compose-refs": "1.1.0",
|
||||
"@radix-ui/react-context": "1.1.0",
|
||||
"@radix-ui/react-direction": "1.1.0",
|
||||
"@radix-ui/react-primitive": "2.0.0",
|
||||
"@radix-ui/react-use-controllable-state": "1.1.0",
|
||||
"@radix-ui/react-use-layout-effect": "1.1.0",
|
||||
"@radix-ui/react-use-previous": "1.1.0",
|
||||
"@radix-ui/react-use-size": "1.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-slider/node_modules/@radix-ui/number": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.0.tgz",
|
||||
"integrity": "sha512-V3gRzhVNU1ldS5XhAPTom1fOIo4ccrjjJgmE+LI2h/WaFpHmx0MQApT+KZHnx8abG6Avtfcz4WoEciMnpFT3HQ=="
|
||||
},
|
||||
"node_modules/@radix-ui/react-slider/node_modules/@radix-ui/primitive": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.0.tgz",
|
||||
"integrity": "sha512-4Z8dn6Upk0qk4P74xBhZ6Hd/w0mPEzOOLxy4xiPXOXqjF7jZS0VAKk7/x/H6FyY2zCkYJqePf1G5KmkmNJ4RBA=="
|
||||
},
|
||||
"node_modules/@radix-ui/react-slider/node_modules/@radix-ui/react-collection": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.0.tgz",
|
||||
"integrity": "sha512-GZsZslMJEyo1VKm5L1ZJY8tGDxZNPAoUeQUIbKeJfoi7Q4kmig5AsgLMYYuyYbfjd8fBmFORAIwYAkXMnXZgZw==",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.0",
|
||||
"@radix-ui/react-context": "1.1.0",
|
||||
"@radix-ui/react-primitive": "2.0.0",
|
||||
"@radix-ui/react-slot": "1.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-slider/node_modules/@radix-ui/react-compose-refs": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.0.tgz",
|
||||
"integrity": "sha512-b4inOtiaOnYf9KWyO3jAeeCG6FeyfY6ldiEPanbUjWd+xIk5wZeHa8yVwmrJ2vderhu/BQvzCrJI0lHd+wIiqw==",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-slider/node_modules/@radix-ui/react-context": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.0.tgz",
|
||||
"integrity": "sha512-OKrckBy+sMEgYM/sMmqmErVn0kZqrHPJze+Ql3DzYsDDp0hl0L62nx/2122/Bvps1qz645jlcu2tD9lrRSdf8A==",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-slider/node_modules/@radix-ui/react-direction": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.0.tgz",
|
||||
"integrity": "sha512-BUuBvgThEiAXh2DWu93XsT+a3aWrGqolGlqqw5VU1kG7p/ZH2cuDlM1sRLNnY3QcBS69UIz2mcKhMxDsdewhjg==",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-slider/node_modules/@radix-ui/react-primitive": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.0.tgz",
|
||||
"integrity": "sha512-ZSpFm0/uHa8zTvKBDjLFWLo8dkr4MBsiDLz0g3gMUwqgLHz9rTaRRGYDgvZPtBJgYCBKXkS9fzmoySgr8CO6Cw==",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-slot": "1.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-slider/node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.0.tgz",
|
||||
"integrity": "sha512-FUCf5XMfmW4dtYl69pdS4DbxKy8nj4M7SafBgPllysxmdachynNflAdp/gCsnYWNDnge6tI9onzMp5ARYc1KNw==",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-slider/node_modules/@radix-ui/react-use-callback-ref": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.0.tgz",
|
||||
"integrity": "sha512-CasTfvsy+frcFkbXtSJ2Zu9JHpN8TYKxkgJGWbjiZhFivxaeW7rMeZt7QELGVLaYVfFMsKHjb7Ak0nMEe+2Vfw==",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-slider/node_modules/@radix-ui/react-use-controllable-state": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.1.0.tgz",
|
||||
"integrity": "sha512-MtfMVJiSr2NjzS0Aa90NPTnvTSg6C/JLCV7ma0W6+OMV78vd8OyRpID+Ng9LxzsPbLeuBnWBA1Nq30AtBIDChw==",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-use-callback-ref": "1.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-slider/node_modules/@radix-ui/react-use-layout-effect": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.0.tgz",
|
||||
"integrity": "sha512-+FPE0rOdziWSrH9athwI1R0HDVbWlEhd+FR+aSDk4uWGmSJ9Z54sdZVDQPZAinJhJXwfT+qnj969mCsT2gfm5w==",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-slider/node_modules/@radix-ui/react-use-previous": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.0.tgz",
|
||||
"integrity": "sha512-Z/e78qg2YFnnXcW88A4JmTtm4ADckLno6F7OXotmkQfeuCVaKuYzqAATPhVzl3delXE7CxIV8shofPn3jPc5Og==",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-slider/node_modules/@radix-ui/react-use-size": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.0.tgz",
|
||||
"integrity": "sha512-XW3/vWuIXHa+2Uwcc2ABSfcCledmXhhQPlGbfcRXbiUQI5Icjcg19BGCZVKKInYbvUCut/ufbbLLPFC5cbb1hw==",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-use-layout-effect": "1.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.0.2.tgz",
|
||||
@@ -1658,24 +2015,11 @@
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/base64-js": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/base64-js/-/base64-js-1.3.2.tgz",
|
||||
"integrity": "sha512-Q2Xn2/vQHRGLRXhQ5+BSLwhHkR3JVflxVKywH0Q6fVoAiUE8fFYL2pE5/l2ZiOiBDfA8qUqRnSxln4G/NFz1Sg=="
|
||||
},
|
||||
"node_modules/@types/crypto-js": {
|
||||
"version": "4.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/crypto-js/-/crypto-js-4.2.2.tgz",
|
||||
"integrity": "sha512-sDOLlVbHhXpAUAL0YHDUUwDZf3iN4Bwi4W6a0W0b+QcAezUbRtH4FVb+9J4h+XFPW7l/gQ9F8qC7P+Ec4k8QVQ=="
|
||||
},
|
||||
"node_modules/@types/jquery": {
|
||||
"version": "3.5.29",
|
||||
"resolved": "https://registry.npmjs.org/@types/jquery/-/jquery-3.5.29.tgz",
|
||||
"integrity": "sha512-oXQQC9X9MOPRrMhPHHOsXqeQDnWeCDT3PelUIg/Oy8FAbzSZtFHRjc7IpbfFVmpLtJ+UOoywpRsuO5Jxjybyeg==",
|
||||
"dependencies": {
|
||||
"@types/sizzle": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/json5": {
|
||||
"version": "0.0.29",
|
||||
"resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz",
|
||||
@@ -1717,6 +2061,14 @@
|
||||
"@types/react": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react-window": {
|
||||
"version": "1.8.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-window/-/react-window-1.8.8.tgz",
|
||||
"integrity": "sha512-8Ls660bHR1AUA2kuRvVG9D/4XpRC6wjAaPT9dil7Ckc76eP9TKWZwwmgfq8Q1LANX3QNDnoU4Zp48A3w+zK69Q==",
|
||||
"dependencies": {
|
||||
"@types/react": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/scheduler": {
|
||||
"version": "0.16.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.8.tgz",
|
||||
@@ -1731,7 +2083,8 @@
|
||||
"node_modules/@types/sizzle": {
|
||||
"version": "2.3.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/sizzle/-/sizzle-2.3.8.tgz",
|
||||
"integrity": "sha512-0vWLNK2D5MT9dg0iOo8GlKguPAU02QjmZitPEsXRuJXU/OGIOt9vT9Fc26wtYuavLxtO45v9PGleoL9Z0k1LHg=="
|
||||
"integrity": "sha512-0vWLNK2D5MT9dg0iOo8GlKguPAU02QjmZitPEsXRuJXU/OGIOt9vT9Fc26wtYuavLxtO45v9PGleoL9Z0k1LHg==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@types/yauzl": {
|
||||
"version": "2.10.3",
|
||||
@@ -2187,7 +2540,8 @@
|
||||
"node_modules/asynckit": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
||||
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="
|
||||
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/at-least-node": {
|
||||
"version": "1.0.0",
|
||||
@@ -2285,6 +2639,7 @@
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
||||
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -2927,6 +3282,7 @@
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
||||
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"delayed-stream": "~1.0.0"
|
||||
},
|
||||
@@ -3170,6 +3526,7 @@
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
||||
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
@@ -4057,25 +4414,6 @@
|
||||
"tailwindcss": "^3"
|
||||
}
|
||||
},
|
||||
"node_modules/follow-redirects": {
|
||||
"version": "1.15.5",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.5.tgz",
|
||||
"integrity": "sha512-vSFWUON1B+yAw1VN4xMfxgn5fTUiaOzAJCKBwIIgT/+7CuGy9+r+5gITvP62j3RmaD5Ph65UaERdOSRGUzZtgw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://github.com/sponsors/RubenVerborgh"
|
||||
}
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=4.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"debug": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/for-each": {
|
||||
"version": "0.3.3",
|
||||
"resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz",
|
||||
@@ -5351,9 +5689,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lucide-react": {
|
||||
"version": "0.287.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.287.0.tgz",
|
||||
"integrity": "sha512-auxP2bTGiMoELzX+6ItTeNzLmhGd/O+PHBsrXV2YwPXYCxarIFJhiMOSzFT9a1GWeYPSZtnWdLr79IVXr/5JqQ==",
|
||||
"version": "0.383.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.383.0.tgz",
|
||||
"integrity": "sha512-13xlG0CQCJtzjSQYwwJ3WRqMHtRj3EXmLlorrARt7y+IHnxUCp3XyFNL1DfaGySWxHObDvnu1u1dV+0VMKHUSg==",
|
||||
"peerDependencies": {
|
||||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0"
|
||||
}
|
||||
@@ -5396,6 +5734,7 @@
|
||||
"version": "1.52.0",
|
||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
||||
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
@@ -5404,6 +5743,7 @@
|
||||
"version": "2.1.35",
|
||||
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
|
||||
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"mime-db": "1.52.0"
|
||||
},
|
||||
@@ -5712,14 +6052,6 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/opener": {
|
||||
"version": "1.5.2",
|
||||
"resolved": "https://registry.npmjs.org/opener/-/opener-1.5.2.tgz",
|
||||
"integrity": "sha512-ur5UIdyw5Y7yEj9wLzhqXiy6GZ3Mwx0yGI+5sMn2r0N0v3cKJvUmFH5yPP+WXh9e0xfyzyJX95D8l088DNFj7A==",
|
||||
"bin": {
|
||||
"opener": "bin/opener-bin.js"
|
||||
}
|
||||
},
|
||||
"node_modules/optionator": {
|
||||
"version": "0.9.3",
|
||||
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.3.tgz",
|
||||
@@ -6346,6 +6678,18 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-virtuoso": {
|
||||
"version": "4.10.0",
|
||||
"resolved": "https://registry.npmjs.org/react-virtuoso/-/react-virtuoso-4.10.0.tgz",
|
||||
"integrity": "sha512-CyxU5TYMH4bw2cybH0bNqN/yIg2q2Vd0kbs92tQc5ResZALAIzIVJY4JL6BHgJFQjwrLhCYrFwKq0p+lvBgA0w==",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16 || >=17 || >= 18",
|
||||
"react-dom": ">=16 || >=17 || >= 18"
|
||||
}
|
||||
},
|
||||
"node_modules/read-cache": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"cypress:open": "cypress open"
|
||||
},
|
||||
"dependencies": {
|
||||
"@axa-fr/react-oidc": "^5.14.0",
|
||||
"@axa-fr/react-oidc": "^7.22.18",
|
||||
"@radix-ui/react-accordion": "^1.1.2",
|
||||
"@radix-ui/react-checkbox": "^1.0.4",
|
||||
"@radix-ui/react-collapsible": "^1.0.3",
|
||||
@@ -22,8 +22,9 @@
|
||||
"@radix-ui/react-label": "^2.0.2",
|
||||
"@radix-ui/react-popover": "^1.0.7",
|
||||
"@radix-ui/react-radio-group": "^1.1.3",
|
||||
"@radix-ui/react-scroll-area": "^1.0.5",
|
||||
"@radix-ui/react-scroll-area": "^1.1.0",
|
||||
"@radix-ui/react-select": "^2.0.0",
|
||||
"@radix-ui/react-slider": "^1.1.2",
|
||||
"@radix-ui/react-slot": "^1.0.2",
|
||||
"@radix-ui/react-switch": "^1.0.3",
|
||||
"@radix-ui/react-tabs": "^1.0.4",
|
||||
@@ -37,6 +38,7 @@
|
||||
"@types/node": "20.10.6",
|
||||
"@types/react": "^18",
|
||||
"@types/react-dom": "^18",
|
||||
"@types/react-window": "^1.8.8",
|
||||
"autoprefixer": "^10",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.0.0",
|
||||
@@ -53,7 +55,7 @@
|
||||
"framer-motion": "^10.16.4",
|
||||
"ip-cidr": "^3.1.0",
|
||||
"lodash": "^4.17.21",
|
||||
"lucide-react": "^0.287.0",
|
||||
"lucide-react": "^0.383.0",
|
||||
"next": "13.5.5",
|
||||
"next-themes": "^0.2.1",
|
||||
"punycode": "^2.3.1",
|
||||
@@ -67,6 +69,7 @@
|
||||
"react-jwt": "^1.2.0",
|
||||
"react-loading-skeleton": "^3.3.1",
|
||||
"react-responsive": "^9.0.2",
|
||||
"react-virtuoso": "^4.9.0",
|
||||
"swr": "^2.2.4",
|
||||
"tailwind-merge": "^1.14.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
|
||||
@@ -23,6 +23,7 @@ import Separator from "@components/Separator";
|
||||
import FullScreenLoading from "@components/ui/FullScreenLoading";
|
||||
import LoginExpiredBadge from "@components/ui/LoginExpiredBadge";
|
||||
import TextWithTooltip from "@components/ui/TextWithTooltip";
|
||||
import useRedirect from "@hooks/useRedirect";
|
||||
import { IconCloudLock, IconInfoCircle } from "@tabler/icons-react";
|
||||
import useFetchApi from "@utils/api";
|
||||
import dayjs from "dayjs";
|
||||
@@ -66,8 +67,11 @@ import PeerRoutesTable from "@/modules/peer/PeerRoutesTable";
|
||||
export default function PeerPage() {
|
||||
const queryParameter = useSearchParams();
|
||||
const peerId = queryParameter.get("id");
|
||||
const { data: peer } = useFetchApi<Peer>("/peers/" + peerId);
|
||||
return peer ? (
|
||||
const { data: peer, isLoading } = useFetchApi<Peer>("/peers/" + peerId, true);
|
||||
|
||||
useRedirect("/peers", false, !peerId);
|
||||
|
||||
return peer && !isLoading ? (
|
||||
<PeerProvider peer={peer}>
|
||||
<PeerOverview />
|
||||
</PeerProvider>
|
||||
@@ -323,6 +327,7 @@ function PeerOverview() {
|
||||
disabled={isUser}
|
||||
onChange={setSelectedGroups}
|
||||
values={selectedGroups}
|
||||
hideAllGroup={true}
|
||||
peer={peer}
|
||||
/>
|
||||
</FullTooltip>
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -10,6 +10,7 @@ import Paragraph from "@components/Paragraph";
|
||||
import { PeerGroupSelector } from "@components/PeerGroupSelector";
|
||||
import Separator from "@components/Separator";
|
||||
import FullScreenLoading from "@components/ui/FullScreenLoading";
|
||||
import useRedirect from "@hooks/useRedirect";
|
||||
import { IconCirclePlus, IconSettings2 } from "@tabler/icons-react";
|
||||
import useFetchApi, { useApiCall } from "@utils/api";
|
||||
import { generateColorFromString } from "@utils/helpers";
|
||||
@@ -42,6 +43,8 @@ export default function UserPage() {
|
||||
return users?.find((u) => u.id === userId);
|
||||
}, [users, userId]);
|
||||
|
||||
useRedirect("/team/users", false, !userId);
|
||||
|
||||
return !isLoading && user ? (
|
||||
<UserOverview user={user} />
|
||||
) : (
|
||||
@@ -184,7 +187,7 @@ function UserOverview({ user }: Props) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={"flex gap-10 w-full mt-8 max-w-6xl"}>
|
||||
<div className={"flex gap-10 w-full mt-8 max-w-6xl items-start"}>
|
||||
<UserInformationCard user={user} />
|
||||
<div className={"flex flex-col gap-8 w-1/2 "}>
|
||||
{!user.is_service_user && (
|
||||
@@ -197,6 +200,7 @@ function UserOverview({ user }: Props) {
|
||||
disabled={isUser}
|
||||
onChange={setSelectedGroups}
|
||||
values={selectedGroups}
|
||||
hideAllGroup={true}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -211,6 +215,8 @@ function UserOverview({ user }: Props) {
|
||||
<UserRoleSelector
|
||||
value={role}
|
||||
onChange={setRole}
|
||||
hideOwner={user.is_service_user}
|
||||
currentUser={user}
|
||||
disabled={
|
||||
isLoggedInUser ||
|
||||
!isOwnerOrAdmin ||
|
||||
@@ -298,15 +304,18 @@ function UserInformationCard({ user }: { user: User }) {
|
||||
|
||||
{!isServiceUser && (
|
||||
<>
|
||||
<Card.ListItem
|
||||
label={
|
||||
<>
|
||||
<Ban size={16} />
|
||||
Block User
|
||||
</>
|
||||
}
|
||||
value={<UserBlockCell user={user} isUserPage={true} />}
|
||||
/>
|
||||
{!user.is_current && user.role != Role.Owner && (
|
||||
<Card.ListItem
|
||||
label={
|
||||
<>
|
||||
<Ban size={16} />
|
||||
Block User
|
||||
</>
|
||||
}
|
||||
value={<UserBlockCell user={user} isUserPage={true} />}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Card.ListItem
|
||||
label={
|
||||
<>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -1,14 +1,40 @@
|
||||
"use client";
|
||||
|
||||
import FullScreenLoading from "@components/ui/FullScreenLoading";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
import { useLocalStorage } from "@hooks/useLocalStorage";
|
||||
import { useRedirect } from "@hooks/useRedirect";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
type Props = {
|
||||
url: string;
|
||||
queryParams?: string;
|
||||
};
|
||||
export default function NotFound() {
|
||||
const router = useRouter();
|
||||
useEffect(() => {
|
||||
router.push("/peers");
|
||||
});
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [tempQueryParams, setTempQueryParams] = useLocalStorage(
|
||||
"netbird-query-params",
|
||||
"",
|
||||
);
|
||||
const [queryParams, setQueryParams] = useState("");
|
||||
|
||||
return <FullScreenLoading />;
|
||||
useEffect(() => {
|
||||
setQueryParams(tempQueryParams);
|
||||
setTempQueryParams("");
|
||||
setMounted(true);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
return mounted ? (
|
||||
<Redirect
|
||||
url={window?.location?.pathname || "/"}
|
||||
queryParams={queryParams}
|
||||
/>
|
||||
) : (
|
||||
<FullScreenLoading />
|
||||
);
|
||||
}
|
||||
|
||||
const Redirect = ({ url, queryParams }: Props) => {
|
||||
useRedirect("/peers" + (queryParams && `?${queryParams}`));
|
||||
return <FullScreenLoading />;
|
||||
};
|
||||
|
||||
@@ -16,6 +16,8 @@ export default function CircleIcon({
|
||||
return (
|
||||
<span
|
||||
style={{ width: size + "px", height: size + "px" }}
|
||||
data-cy="circle-icon"
|
||||
data-cy-status={active ? "active" : "inactive"}
|
||||
className={cn(
|
||||
"rounded-full",
|
||||
active
|
||||
|
||||
BIN
src/assets/os-icons/FreeBSD.png
Normal file
BIN
src/assets/os-icons/FreeBSD.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.1 KiB |
@@ -1,10 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { OidcProvider } from "@axa-fr/react-oidc";
|
||||
import {
|
||||
AuthorityConfiguration,
|
||||
OidcConfiguration,
|
||||
} from "@axa-fr/react-oidc/dist/vanilla/oidc";
|
||||
OidcProvider,
|
||||
} from "@axa-fr/react-oidc";
|
||||
import FullScreenLoading from "@components/ui/FullScreenLoading";
|
||||
import { useLocalStorage } from "@hooks/useLocalStorage";
|
||||
import { useRedirect } from "@hooks/useRedirect";
|
||||
@@ -30,7 +30,7 @@ const auth0AuthorityConfig: AuthorityConfiguration = {
|
||||
revocation_endpoint: new URL("oauth/revoke", config.authority).href,
|
||||
end_session_endpoint: new URL("v2/logout", config.authority).href,
|
||||
userinfo_endpoint: new URL("userinfo", config.authority).href,
|
||||
//issuer: new URL("", config.authority).href,
|
||||
issuer: new URL("", config.authority).href,
|
||||
};
|
||||
|
||||
const onEvent = (configurationName: any, eventName: any, data: any) => {
|
||||
|
||||
@@ -11,9 +11,17 @@ export const SecureProvider = ({ children }: Props) => {
|
||||
const currentPath = usePathname();
|
||||
|
||||
useEffect(() => {
|
||||
let timeout: NodeJS.Timeout | undefined = undefined;
|
||||
if (!isAuthenticated) {
|
||||
login(currentPath);
|
||||
timeout = setTimeout(async () => {
|
||||
if (!isAuthenticated) {
|
||||
await login(currentPath);
|
||||
}
|
||||
}, 1500);
|
||||
}
|
||||
return () => {
|
||||
clearTimeout(timeout);
|
||||
};
|
||||
}, [currentPath, isAuthenticated, login]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -5,14 +5,16 @@ import React, { forwardRef } from "react";
|
||||
type Props = {
|
||||
children: React.ReactNode;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
function ButtonGroup({ children, disabled }: Props) {
|
||||
function ButtonGroup({ children, disabled, className }: Props) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-lg border-[1px] dark:border-nb-gray-900 border-neutral-200 overflow-hidden flex items-center justify-center shrink-0 border-separate",
|
||||
disabled ? "opacity-100 !border-nb-gray-900/20" : "",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
@@ -21,7 +23,10 @@ function ButtonGroup({ children, disabled }: Props) {
|
||||
}
|
||||
|
||||
const ButtonGroupButton = forwardRef(
|
||||
({ ...props }: ButtonProps, ref: React.ForwardedRef<HTMLButtonElement>) => {
|
||||
(
|
||||
{ className, ...props }: ButtonProps,
|
||||
ref: React.ForwardedRef<HTMLButtonElement>,
|
||||
) => {
|
||||
return (
|
||||
<Button
|
||||
ref={ref}
|
||||
@@ -31,6 +36,7 @@ const ButtonGroupButton = forwardRef(
|
||||
className={cn(
|
||||
"first:border-l-0 last:border-r-0 border-t-0 border-b-0 h-[41px]",
|
||||
"!py-2.5 !px-4",
|
||||
className,
|
||||
)}
|
||||
/>
|
||||
);
|
||||
|
||||
11
src/components/DropdownInfoText.tsx
Normal file
11
src/components/DropdownInfoText.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import * as React from "react";
|
||||
|
||||
type Props = {
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
export const DropdownInfoText = ({ children }: Props) => {
|
||||
return (
|
||||
<div className={"text-center pt-2 mb-6 text-nb-gray-400"}>{children}</div>
|
||||
);
|
||||
};
|
||||
48
src/components/DropdownInput.tsx
Normal file
48
src/components/DropdownInput.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import { IconArrowBack } from "@tabler/icons-react";
|
||||
import { cn } from "@utils/helpers";
|
||||
import { SearchIcon } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { forwardRef } from "react";
|
||||
|
||||
type Props = {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
};
|
||||
|
||||
export const DropdownInput = forwardRef<HTMLInputElement, Props>(
|
||||
({ value, onChange, placeholder = "Search..." }, ref) => {
|
||||
return (
|
||||
<div className={"relative w-full"}>
|
||||
<input
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"min-h-[42px] w-full relative",
|
||||
"border-b-0 border-t-0 border-r-0 border-l-0 border-neutral-200 dark:border-nb-gray-700 items-center",
|
||||
"bg-transparent text-sm outline-none focus-visible:outline-none ring-0 focus-visible:ring-0",
|
||||
"dark:placeholder:text-nb-gray-400 font-light placeholder:text-neutral-500 pl-10",
|
||||
)}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
<div className={"absolute left-0 top-0 h-full flex items-center pl-4"}>
|
||||
<div className={"flex items-center"}>
|
||||
<SearchIcon size={14} />
|
||||
</div>
|
||||
</div>
|
||||
<div className={"absolute right-0 top-0 h-full flex items-center pr-4"}>
|
||||
<div
|
||||
className={
|
||||
"flex items-center bg-nb-gray-800 py-1 px-1.5 rounded-[4px] border border-nb-gray-500"
|
||||
}
|
||||
>
|
||||
<IconArrowBack size={10} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
DropdownInput.displayName = "DropdownInput";
|
||||
@@ -13,6 +13,7 @@ export interface InputProps
|
||||
icon?: React.ReactNode;
|
||||
error?: string;
|
||||
errorTooltip?: boolean;
|
||||
errorTooltipPosition?: "top" | "top-right";
|
||||
}
|
||||
|
||||
const inputVariants = cva("", {
|
||||
@@ -49,6 +50,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
maxWidthClass = "",
|
||||
error,
|
||||
errorTooltip = false,
|
||||
errorTooltipPosition = "top",
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
@@ -105,9 +107,12 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
</div>
|
||||
{error && errorTooltip && (
|
||||
<div
|
||||
className={
|
||||
"absolute right-0 top-2 h-[0px] w-full flex items-center pr-3 justify-center"
|
||||
}
|
||||
className={cn(
|
||||
errorTooltipPosition == "top" &&
|
||||
"absolute right-0 top-2 h-[0px] w-full flex items-center pr-3 justify-center",
|
||||
errorTooltipPosition == "top-right" &&
|
||||
"absolute -right-6 top-2 h-[0px] w-full flex items-center pr-3 justify-end",
|
||||
)}
|
||||
>
|
||||
<FullTooltip
|
||||
content={
|
||||
@@ -120,7 +125,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
</div>
|
||||
}
|
||||
interactive={false}
|
||||
align={"center"}
|
||||
align={errorTooltipPosition == "top" ? "center" : "end"}
|
||||
side={"top"}
|
||||
keepOpen={true}
|
||||
>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { CommandItem } from "@components/Command";
|
||||
import FullTooltip from "@components/FullTooltip";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@components/Popover";
|
||||
import TextWithTooltip from "@components/ui/TextWithTooltip";
|
||||
import { IconArrowBack } from "@tabler/icons-react";
|
||||
import useFetchApi from "@utils/api";
|
||||
import { cn } from "@utils/helpers";
|
||||
@@ -62,8 +64,13 @@ export function NetworkRouteSelector({
|
||||
const isSearching = search.length > 0;
|
||||
const found =
|
||||
dropdownOptions.filter((item) => {
|
||||
const hasDomains = item?.domains ? item.domains.length > 0 : false;
|
||||
const domains =
|
||||
hasDomains && item?.domains ? item?.domains.join(" ") : "";
|
||||
return (
|
||||
item.network_id.includes(search) || item.network.includes(search)
|
||||
item.network_id.includes(search) ||
|
||||
item.network?.includes(search) ||
|
||||
domains.includes(search)
|
||||
);
|
||||
}).length > 0;
|
||||
return isSearching && !found;
|
||||
@@ -102,12 +109,12 @@ export function NetworkRouteSelector({
|
||||
{value ? (
|
||||
<div
|
||||
className={
|
||||
"flex items-center justify-between text-sm text-white w-full pr-4 pl-1"
|
||||
"flex items-center justify-between text-sm text-white w-full pr-4 pl-1 gap-2"
|
||||
}
|
||||
>
|
||||
<div className={"flex items-center gap-2.5 text-sm"}>
|
||||
<NetworkRoutesIcon size={16} />
|
||||
{value.network_id}
|
||||
<TextWithTooltip text={value.network_id} maxChars={15} />
|
||||
</div>
|
||||
|
||||
<div
|
||||
@@ -117,6 +124,7 @@ export function NetworkRouteSelector({
|
||||
>
|
||||
{value.network}
|
||||
</div>
|
||||
<DomainList domains={value?.domains} />
|
||||
</div>
|
||||
) : (
|
||||
<span>Select an existing network...</span>
|
||||
@@ -208,15 +216,23 @@ export function NetworkRouteSelector({
|
||||
return (
|
||||
<CommandItem
|
||||
key={option.network + option.network_id}
|
||||
value={option.network + option.network_id}
|
||||
value={
|
||||
option.network +
|
||||
option.network_id +
|
||||
option?.domains?.join(", ")
|
||||
}
|
||||
onSelect={() => {
|
||||
togglePeer(option);
|
||||
setOpen(false);
|
||||
}}
|
||||
className={"gap-2"}
|
||||
>
|
||||
<div className={"flex items-center gap-2.5 text-sm"}>
|
||||
<NetworkRoutesIcon size={14} />
|
||||
{option.network_id}
|
||||
<TextWithTooltip
|
||||
text={option.network_id}
|
||||
maxChars={15}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
@@ -226,6 +242,7 @@ export function NetworkRouteSelector({
|
||||
>
|
||||
{option.network}
|
||||
</div>
|
||||
<DomainList domains={option?.domains} />
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
@@ -238,3 +255,23 @@ export function NetworkRouteSelector({
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
function DomainList({ domains }: { domains?: string[] }) {
|
||||
const firstDomain = domains ? domains[0] : "";
|
||||
return (
|
||||
domains &&
|
||||
domains.length > 0 && (
|
||||
<FullTooltip
|
||||
content={<div className={"text-xs max-w-sm"}>{domains.join(", ")}</div>}
|
||||
>
|
||||
<div
|
||||
className={
|
||||
"text-xs text-nb-gray-300 block min-w-0 truncate max-w-[180px]"
|
||||
}
|
||||
>
|
||||
{firstDomain} {domains.length > 1 && "+" + (domains.length - 1)}
|
||||
</div>
|
||||
</FullTooltip>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
@@ -198,11 +210,12 @@ export function PeerGroupSelector({
|
||||
<CommandList className={"w-full"}>
|
||||
<div className={"relative"}>
|
||||
<CommandInput
|
||||
data-cy={"group-search-input"}
|
||||
className={cn(
|
||||
"min-h-[42px] w-full relative",
|
||||
"border-b-0 border-t-0 border-r-0 border-l-0 border-neutral-200 dark:border-nb-gray-700 items-center",
|
||||
"bg-transparent text-sm outline-none focus-visible:outline-none ring-0 focus-visible:ring-0",
|
||||
"dark:placeholder:text-neutral-500 font-light placeholder:text-neutral-500 pl-10",
|
||||
"dark:placeholder:text-nb-gray-400 font-light placeholder:text-neutral-500 pl-10",
|
||||
)}
|
||||
ref={searchRef}
|
||||
value={search}
|
||||
@@ -237,9 +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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -21,12 +21,19 @@ function SegmentedTabs({ value, onChange, children }: Props) {
|
||||
);
|
||||
}
|
||||
|
||||
function List({ children }: { children: React.ReactNode }) {
|
||||
function List({
|
||||
children,
|
||||
className = "",
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<TabsList
|
||||
className={
|
||||
"bg-nb-gray-930/70 p-1.5 rounded-t-lg flex justify-center gap-1 border border-b-0 border-nb-gray-900"
|
||||
}
|
||||
className={cn(
|
||||
"bg-nb-gray-930/70 p-1.5 rounded-t-lg flex justify-center gap-1 border border-b-0 border-nb-gray-900",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</TabsList>
|
||||
|
||||
27
src/components/Slider.tsx
Normal file
27
src/components/Slider.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
"use client";
|
||||
|
||||
import * as SliderPrimitive from "@radix-ui/react-slider";
|
||||
import { cn } from "@utils/helpers";
|
||||
import React from "react";
|
||||
|
||||
const Slider = React.forwardRef<
|
||||
React.ElementRef<typeof SliderPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SliderPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex w-full touch-none select-none items-center",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SliderPrimitive.Track className="relative h-2 w-full grow overflow-hidden rounded-full bg-neutral-100 dark:bg-neutral-800">
|
||||
<SliderPrimitive.Range className="absolute h-full bg-neutral-900 dark:bg-neutral-50" />
|
||||
</SliderPrimitive.Track>
|
||||
<SliderPrimitive.Thumb className="block h-5 w-5 rounded-full border-2 border-neutral-900 bg-white ring-offset-white transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-neutral-950 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 dark:border-neutral-50 dark:bg-neutral-950 dark:ring-offset-neutral-950 dark:focus-visible:ring-neutral-300" />
|
||||
</SliderPrimitive.Root>
|
||||
));
|
||||
Slider.displayName = SliderPrimitive.Root.displayName;
|
||||
|
||||
export { Slider };
|
||||
132
src/components/VirtualScrollAreaList.tsx
Normal file
132
src/components/VirtualScrollAreaList.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
import {
|
||||
MemoizedScrollArea,
|
||||
MemoizedScrollAreaViewport,
|
||||
} from "@components/ScrollArea";
|
||||
import { cn } from "@utils/helpers";
|
||||
import * as React from "react";
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Virtuoso, VirtuosoHandle } from "react-virtuoso";
|
||||
|
||||
type Props<T extends { id?: string }> = {
|
||||
items: T[];
|
||||
onSelect: (item: T) => void;
|
||||
renderItem?: (item: T) => React.ReactNode;
|
||||
};
|
||||
|
||||
export function VirtualScrollAreaList<T extends { id?: string }>({
|
||||
items,
|
||||
onSelect,
|
||||
renderItem,
|
||||
}: Props<T>) {
|
||||
const virtuosoRef = useRef<VirtuosoHandle>(null);
|
||||
const [selected, setSelected] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
setSelected(0);
|
||||
}, [items]);
|
||||
|
||||
const scrollToItem = useCallback((index: number) => {
|
||||
virtuosoRef.current?.scrollIntoView({
|
||||
index,
|
||||
behavior: "auto",
|
||||
align: "center",
|
||||
});
|
||||
}, []);
|
||||
|
||||
const navigation = useCallback(
|
||||
(e: KeyboardEvent) => {
|
||||
if (items.length === 0) return;
|
||||
const length = items.length - 1;
|
||||
if (e.code === "ArrowUp" || (e.key === "Tab" && e.shiftKey)) {
|
||||
e.preventDefault();
|
||||
const newSelected = selected === 0 ? length : selected - 1;
|
||||
setSelected(newSelected);
|
||||
scrollToItem(newSelected);
|
||||
} else if (e.key === "ArrowDown" || e.key === "Tab") {
|
||||
e.preventDefault();
|
||||
const newSelected = selected === length ? 0 : selected + 1;
|
||||
setSelected(newSelected);
|
||||
scrollToItem(newSelected);
|
||||
}
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
onSelect?.(items[selected]);
|
||||
}
|
||||
},
|
||||
[items, scrollToItem, selected],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener("keydown", navigation);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", navigation);
|
||||
};
|
||||
}, [navigation]);
|
||||
|
||||
const renderMemoizedItem = useMemo(() => renderItem, [renderItem]);
|
||||
|
||||
return (
|
||||
<MemoizedScrollArea
|
||||
withoutViewport={true}
|
||||
className={"max-h-[195px] flex flex-col gap-1 py-2"}
|
||||
>
|
||||
<Virtuoso
|
||||
ref={virtuosoRef}
|
||||
overscan={50}
|
||||
data={items}
|
||||
computeItemKey={(index) => items[index].id as string}
|
||||
context={{ selected, setSelected, onClick: onSelect }}
|
||||
itemContent={(index, option, { selected, setSelected, onClick }) => {
|
||||
return (
|
||||
<VirtualScrollListItemWrapper
|
||||
onMouseEnter={() => setSelected(index)}
|
||||
id={option.id}
|
||||
onClick={() => onClick(option as T)}
|
||||
ariaSelected={selected === index}
|
||||
>
|
||||
{renderMemoizedItem ? renderMemoizedItem(option) : option.id}
|
||||
</VirtualScrollListItemWrapper>
|
||||
);
|
||||
}}
|
||||
style={{ height: 195 }}
|
||||
components={{
|
||||
Scroller: MemoizedScrollAreaViewport,
|
||||
}}
|
||||
/>
|
||||
</MemoizedScrollArea>
|
||||
);
|
||||
}
|
||||
|
||||
type ItemWrapperProps = {
|
||||
children: React.ReactNode;
|
||||
id?: string;
|
||||
onMouseEnter?: () => void;
|
||||
onClick?: () => void;
|
||||
ariaSelected?: boolean;
|
||||
};
|
||||
|
||||
export const VirtualScrollListItemWrapper = memo(
|
||||
({ id, children, onClick, onMouseEnter, ariaSelected }: ItemWrapperProps) => {
|
||||
return (
|
||||
<div
|
||||
key={id ?? undefined}
|
||||
className={"pr-3 pl-2 webkit-scroll"}
|
||||
onMouseEnter={onMouseEnter}
|
||||
onClick={onClick}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"text-xs flex justify-between py-2 px-3 cursor-pointer items-center rounded-md",
|
||||
"bg-transparent dark:aria-selected:bg-nb-gray-800/50",
|
||||
)}
|
||||
aria-selected={ariaSelected}
|
||||
role={"listitem"}
|
||||
tabIndex={0}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
VirtualScrollListItemWrapper.displayName = "VirtualScrollListItemWrapper";
|
||||
@@ -75,7 +75,10 @@ const ModalContent = React.forwardRef<
|
||||
<>
|
||||
{children}
|
||||
{showClose && (
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-white transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-neutral-950 focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-neutral-100 data-[state=open]:text-neutral-500 dark:ring-offset-neutral-950 dark:focus:ring-neutral-300 dark:data-[state=open]:bg-neutral-800 dark:data-[state=open]:text-neutral-400">
|
||||
<DialogPrimitive.Close
|
||||
data-cy={"modal-close"}
|
||||
className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-white transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-neutral-950 focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-neutral-100 data-[state=open]:text-neutral-500 dark:ring-offset-neutral-950 dark:focus:ring-neutral-300 dark:data-[state=open]:bg-neutral-800 dark:data-[state=open]:text-neutral-400"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
|
||||
@@ -10,6 +10,7 @@ interface Props extends IconVariant {
|
||||
className?: string;
|
||||
margin?: string;
|
||||
truncate?: boolean;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
export default function ModalHeader({
|
||||
icon,
|
||||
@@ -19,6 +20,7 @@ export default function ModalHeader({
|
||||
className = "pb-6 px-8",
|
||||
margin = "mt-0",
|
||||
truncate = false,
|
||||
children,
|
||||
}: Props) {
|
||||
return (
|
||||
<div className={cn(className, "min-w-0")}>
|
||||
@@ -26,11 +28,15 @@ export default function ModalHeader({
|
||||
{icon && <SquareIcon color={color} icon={icon} />}
|
||||
<div className={"min-w-0"}>
|
||||
<h2 className={"text-lg my-0 leading-[1.5]"}>{title}</h2>
|
||||
<Paragraph
|
||||
className={cn("text-sm", margin, truncate && "!block truncate")}
|
||||
>
|
||||
{description}
|
||||
</Paragraph>
|
||||
{children ? (
|
||||
<>{children}</>
|
||||
) : (
|
||||
<Paragraph
|
||||
className={cn("text-sm", margin, truncate && "!block truncate")}
|
||||
>
|
||||
{description}
|
||||
</Paragraph>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
73
src/components/table/DataTableHeadingPortal.tsx
Normal file
73
src/components/table/DataTableHeadingPortal.tsx
Normal 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}`;
|
||||
};
|
||||
@@ -28,9 +28,10 @@ export function DataTableRowsPerPage<TData>({
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
disabled={disabled}
|
||||
data-cy={"rows-per-page"}
|
||||
className="w-[200px] justify-between"
|
||||
>
|
||||
<RowsIcon size={15} className={"text-nb-gray-300"} />
|
||||
<RowsIcon size={15} className={"text-nb-gray-300 shrink-0"} />
|
||||
<div>
|
||||
<span className={"text-white"}>
|
||||
{table.getState().pagination.pageSize}
|
||||
@@ -47,6 +48,7 @@ export function DataTableRowsPerPage<TData>({
|
||||
<CommandItem
|
||||
key={val}
|
||||
value={val.toString()}
|
||||
data-cy={`rows-per-page-value`}
|
||||
onSelect={(currentValue) => {
|
||||
table.setPageSize(Number(currentValue));
|
||||
setOpen(false);
|
||||
|
||||
70
src/components/ui/DomainListBadge.tsx
Normal file
70
src/components/ui/DomainListBadge.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import Badge from "@components/Badge";
|
||||
import FullTooltip from "@components/FullTooltip";
|
||||
import { GlobeIcon } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
type Props = {
|
||||
domains: string[];
|
||||
};
|
||||
export const DomainListBadge = ({ domains }: Props) => {
|
||||
const firstDomain = domains.length > 0 ? domains[0] : undefined;
|
||||
|
||||
return (
|
||||
<DomainsTooltip domains={domains}>
|
||||
<div className={"inline-flex items-center gap-2"}>
|
||||
{firstDomain && (
|
||||
<Badge variant={"gray"}>
|
||||
<GlobeIcon size={10} />
|
||||
{firstDomain}
|
||||
</Badge>
|
||||
)}
|
||||
{domains && domains.length > 1 && (
|
||||
<Badge variant={"gray"}>+ {domains.length - 1}</Badge>
|
||||
)}
|
||||
</div>
|
||||
</DomainsTooltip>
|
||||
);
|
||||
};
|
||||
|
||||
export const DomainsTooltip = ({
|
||||
domains,
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
domains: string[];
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) => {
|
||||
return (
|
||||
<FullTooltip
|
||||
interactive={false}
|
||||
className={className}
|
||||
content={
|
||||
<div className={"flex flex-col gap-2 items-start"}>
|
||||
{domains.map((domain) => {
|
||||
return (
|
||||
domain && (
|
||||
<div
|
||||
key={domain}
|
||||
className={"flex gap-2 items-center justify-between w-full"}
|
||||
>
|
||||
<div
|
||||
className={
|
||||
"flex gap-2 items-center text-nb-gray-300 text-xs"
|
||||
}
|
||||
>
|
||||
<GlobeIcon size={11} />
|
||||
{domain}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
}
|
||||
disabled={domains.length <= 1}
|
||||
>
|
||||
{children}
|
||||
</FullTooltip>
|
||||
);
|
||||
};
|
||||
@@ -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} />
|
||||
|
||||
88
src/components/ui/InputDomain.tsx
Normal file
88
src/components/ui/InputDomain.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import Button from "@components/Button";
|
||||
import { Input } from "@components/Input";
|
||||
import { validator } from "@utils/helpers";
|
||||
import { uniqueId } from "lodash";
|
||||
import { GlobeIcon, MinusCircleIcon } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Domain } from "@/interfaces/Domain";
|
||||
|
||||
type Props = {
|
||||
value: Domain;
|
||||
onChange: (d: Domain) => void;
|
||||
onRemove: () => void;
|
||||
onError?: (error: boolean) => void;
|
||||
error?: string;
|
||||
};
|
||||
enum ActionType {
|
||||
ADD = "ADD",
|
||||
REMOVE = "REMOVE",
|
||||
UPDATE = "UPDATE",
|
||||
}
|
||||
|
||||
export const domainReducer = (state: Domain[], action: any): Domain[] => {
|
||||
switch (action.type) {
|
||||
case ActionType.ADD:
|
||||
return [...state, { name: "", id: uniqueId("domain") }];
|
||||
case ActionType.REMOVE:
|
||||
return state.filter((_, i) => i !== action.index);
|
||||
case ActionType.UPDATE:
|
||||
return state.map((n, i) => (i === action.index ? action.d : n));
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
};
|
||||
|
||||
export default function InputDomain({
|
||||
value,
|
||||
onChange,
|
||||
onRemove,
|
||||
onError,
|
||||
}: Readonly<Props>) {
|
||||
const [name, setName] = useState(value?.name || "");
|
||||
|
||||
const handleNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setName(e.target.value);
|
||||
onChange({ ...value, name: e.target.value });
|
||||
};
|
||||
|
||||
const domainError = useMemo(() => {
|
||||
if (name == "") {
|
||||
return "";
|
||||
}
|
||||
const valid = validator.isValidDomain(name);
|
||||
if (!valid) {
|
||||
return "Please enter a valid domain, e.g. example.com or intra.example.com";
|
||||
}
|
||||
}, [name]);
|
||||
|
||||
useEffect(() => {
|
||||
const hasError = domainError !== "" && domainError !== undefined;
|
||||
onError?.(hasError);
|
||||
return () => onError?.(false);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [domainError]);
|
||||
|
||||
return (
|
||||
<div className={"flex gap-2 w-full"}>
|
||||
<div className={"w-full"}>
|
||||
<Input
|
||||
customPrefix={<GlobeIcon size={15} />}
|
||||
placeholder={"e.g., example.com"}
|
||||
maxWidthClass={"w-full"}
|
||||
value={name}
|
||||
error={domainError}
|
||||
onChange={handleNameChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
className={"h-[42px]"}
|
||||
variant={"default-outline"}
|
||||
onClick={onRemove}
|
||||
>
|
||||
<MinusCircleIcon size={15} />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -94,6 +94,7 @@ export default function DialogProvider({ children }: Props) {
|
||||
className={"w-full"}
|
||||
size={"sm"}
|
||||
tabIndex={-1}
|
||||
data-cy={"confirmation.cancel"}
|
||||
onClick={() => fn.current && fn.current(false)}
|
||||
>
|
||||
{dialogOptions.cancelText || "Cancel"}
|
||||
@@ -109,6 +110,7 @@ export default function DialogProvider({ children }: Props) {
|
||||
}
|
||||
className={"w-full"}
|
||||
size={"sm"}
|
||||
data-cy={"confirmation.confirm"}
|
||||
onClick={() => fn.current && fn.current(true)}
|
||||
>
|
||||
{dialogOptions.confirmText || "Confirm"}
|
||||
|
||||
@@ -77,9 +77,7 @@ export default function PeerProvider({ children, peer }: Props) {
|
||||
? loginExpiration
|
||||
: peer.login_expiration_enabled,
|
||||
approval_required:
|
||||
approval_required != undefined
|
||||
? approval_required
|
||||
: peer.approval_required,
|
||||
approval_required == undefined ? undefined : approval_required,
|
||||
},
|
||||
`/${peer.id}`,
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -34,6 +34,8 @@ export default function RoutesProvider({ children }: Props) {
|
||||
onSuccess?: (route: Route) => void,
|
||||
message?: string,
|
||||
) => {
|
||||
const hasDomains = route.domains ? route.domains.length > 0 : false;
|
||||
|
||||
notify({
|
||||
title: "Network " + route.network_id + "-" + route.network,
|
||||
description: message
|
||||
@@ -48,7 +50,9 @@ export default function RoutesProvider({ children }: Props) {
|
||||
peer: toUpdate.peer ?? (route.peer || undefined),
|
||||
peer_groups:
|
||||
toUpdate.peer_groups ?? (route.peer_groups || undefined),
|
||||
network: route.network,
|
||||
network: !hasDomains ? route.network : undefined,
|
||||
domains: hasDomains ? route.domains : undefined,
|
||||
keep_route: route.keep_route,
|
||||
metric: toUpdate.metric ?? route.metric ?? 9999,
|
||||
masquerade: toUpdate.masquerade ?? route.masquerade ?? true,
|
||||
groups: toUpdate.groups ?? route.groups ?? [],
|
||||
@@ -80,7 +84,9 @@ export default function RoutesProvider({ children }: Props) {
|
||||
enabled: route.enabled,
|
||||
peer: route.peer || undefined,
|
||||
peer_groups: route.peer_groups || undefined,
|
||||
network: route.network,
|
||||
network: route?.network || undefined,
|
||||
domains: route?.domains || undefined,
|
||||
keep_route: route?.keep_route || false,
|
||||
metric: route.metric || 9999,
|
||||
masquerade: route.masquerade,
|
||||
groups: route.groups || [],
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
|
||||
import { OperatingSystem } from "@/interfaces/OperatingSystem";
|
||||
|
||||
/**
|
||||
* Get the operating system of the user based on the user agent of the browser
|
||||
* This is used for the setup modal to show the correct installation guide
|
||||
*/
|
||||
export default function useOperatingSystem() {
|
||||
const isBrowser = typeof window !== "undefined";
|
||||
const userAgent = isBrowser ? navigator.userAgent.toLowerCase() : "";
|
||||
@@ -9,16 +13,26 @@ export default function useOperatingSystem() {
|
||||
? /(iP*)/g.test(navigator.userAgent) && navigator.maxTouchPoints > 2
|
||||
: false;
|
||||
if (iOS) return OperatingSystem.IOS;
|
||||
// For FreeBSD, we return Linux as we currently don't have an official installation guide for FreeBSD
|
||||
if (userAgent.toLowerCase().includes("freebsd")) return OperatingSystem.LINUX;
|
||||
return getOperatingSystem(userAgent);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the operating system based on a string (user agent, api response, etc.)
|
||||
* Falls back to Linux if the operating system is not recognized
|
||||
*/
|
||||
export const getOperatingSystem = (os: string) => {
|
||||
if (os.toLowerCase().includes("freebsd"))
|
||||
return OperatingSystem.FREEBSD as const;
|
||||
if (os.toLowerCase().includes("darwin"))
|
||||
return OperatingSystem.APPLE as const;
|
||||
if (os.toLowerCase().includes("mac")) return OperatingSystem.APPLE as const;
|
||||
if (os.toLowerCase().includes("android"))
|
||||
return OperatingSystem.ANDROID as const;
|
||||
if (os.toLowerCase().includes("ios")) return OperatingSystem.IOS as const;
|
||||
if (os.toLowerCase().includes("ipad")) return OperatingSystem.IOS as const;
|
||||
if (os.toLowerCase().includes("iphone")) return OperatingSystem.IOS as const;
|
||||
if (os.toLowerCase().includes("windows"))
|
||||
return OperatingSystem.WINDOWS as const;
|
||||
return OperatingSystem.LINUX as const;
|
||||
|
||||
12
src/hooks/usePortalElement.tsx
Normal file
12
src/hooks/usePortalElement.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import { useLayoutEffect, useRef, useState } from "react";
|
||||
|
||||
export function usePortalElement<Element>() {
|
||||
const ref = useRef<Element>(null);
|
||||
const [portalTarget, setPortalTarget] = useState<Element | null>(null);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
setPortalTarget(ref.current);
|
||||
}, []);
|
||||
|
||||
return { ref, portalTarget, setPortalTarget };
|
||||
}
|
||||
13
src/hooks/usePrevious.ts
Normal file
13
src/hooks/usePrevious.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
const usePrevious = <T>(value: T): T | undefined => {
|
||||
const ref = useRef<T>();
|
||||
|
||||
useEffect(() => {
|
||||
ref.current = value;
|
||||
}, [value]);
|
||||
|
||||
return ref.current;
|
||||
};
|
||||
|
||||
export default usePrevious;
|
||||
@@ -1,8 +1,9 @@
|
||||
import loadConfig from "@utils/config";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
const config = loadConfig();
|
||||
|
||||
export const useRedirect = (
|
||||
url: string,
|
||||
replace: boolean = false,
|
||||
@@ -10,24 +11,43 @@ export const useRedirect = (
|
||||
) => {
|
||||
const router = useRouter();
|
||||
const currentPath = usePathname();
|
||||
const callBackUrls = [config.redirectURI, config.silentRedirectURI];
|
||||
const callBackUrls = useRef([config.redirectURI, config.silentRedirectURI]);
|
||||
const isRedirecting = useRef(false);
|
||||
const intervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enable) return;
|
||||
if (callBackUrls.includes(url)) return; // Don't redirect to the callback urls to avoid infinite loop
|
||||
if (url === currentPath) return; // Don't redirect to the current page
|
||||
// If redirect is disabled or the url is already in the callback urls or the url is the current path then do not redirect
|
||||
if (!enable || callBackUrls.current.includes(url) || url === currentPath)
|
||||
return;
|
||||
|
||||
const redirect = replace ? router.replace : router.push; // Replace the current history or add a new one
|
||||
const performRedirect = () => {
|
||||
if (!isRedirecting.current) {
|
||||
isRedirecting.current = true;
|
||||
router.refresh();
|
||||
if (replace) {
|
||||
router.replace(url);
|
||||
} else {
|
||||
router.push(url);
|
||||
}
|
||||
isRedirecting.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
router.refresh();
|
||||
redirect(url);
|
||||
performRedirect();
|
||||
|
||||
// Timer in case the user has his browser tab open but not focused
|
||||
const interval = setInterval(() => {
|
||||
router.refresh();
|
||||
redirect(url);
|
||||
}, 1000);
|
||||
// Try to redirect after 1.25 seconds if for whatever reason the redirect did not happen (network change, browser tab open but not focused etc.)
|
||||
intervalRef.current = setInterval(() => {
|
||||
if (!isRedirecting.current) {
|
||||
performRedirect();
|
||||
}
|
||||
}, 1250);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [replace, router, url, enable]);
|
||||
return () => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
}
|
||||
};
|
||||
}, [replace, router, url, enable, currentPath]);
|
||||
};
|
||||
|
||||
export default useRedirect;
|
||||
|
||||
91
src/hooks/useSearch.ts
Normal file
91
src/hooks/useSearch.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import { debounce as lodashDebounce, isEqual } from "lodash";
|
||||
import { ChangeEvent, useCallback, useEffect, useRef, useState } from "react";
|
||||
import usePrevious from "./usePrevious";
|
||||
|
||||
export type Predicate<T> = (item: T, query: string) => boolean;
|
||||
|
||||
export interface Options {
|
||||
initialQuery?: string;
|
||||
filter?: boolean;
|
||||
debounce?: number;
|
||||
}
|
||||
|
||||
function filterCollection<T>(
|
||||
collection: T[],
|
||||
predicate: Predicate<T>,
|
||||
query: string,
|
||||
filter: boolean,
|
||||
): T[] {
|
||||
if (query) {
|
||||
return collection.filter((item) => predicate(item, query));
|
||||
} else {
|
||||
return filter ? collection : [];
|
||||
}
|
||||
}
|
||||
|
||||
export function useSearch<T>(
|
||||
collection: T[],
|
||||
predicate: Predicate<T>,
|
||||
{ debounce, filter = false, initialQuery = "" }: Options = {},
|
||||
): [
|
||||
T[],
|
||||
string,
|
||||
(event: ChangeEvent<HTMLInputElement> | string) => void,
|
||||
(querty: string) => void,
|
||||
] {
|
||||
const isMounted = useRef<boolean>(false);
|
||||
const [query, setQuery] = useState<string>(initialQuery);
|
||||
const prevCollection = usePrevious(collection);
|
||||
const prevPredicate = usePrevious(predicate);
|
||||
const prevQuery = usePrevious(query);
|
||||
const prevFilter = usePrevious(filter);
|
||||
const [filteredCollection, setFilteredCollection] = useState<T[]>(() =>
|
||||
filterCollection<T>(collection, predicate, query, filter),
|
||||
);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(event: ChangeEvent<HTMLInputElement> | string) => {
|
||||
setQuery(typeof event === "string" ? event : event.target.value);
|
||||
},
|
||||
[setQuery],
|
||||
);
|
||||
|
||||
const debouncedFilterCollection = useCallback(
|
||||
lodashDebounce(
|
||||
(
|
||||
collection: T[],
|
||||
predicate: Predicate<T>,
|
||||
query: string,
|
||||
filter: boolean,
|
||||
) => {
|
||||
if (isMounted.current) {
|
||||
setFilteredCollection(
|
||||
filterCollection(collection, predicate, query, filter),
|
||||
);
|
||||
}
|
||||
},
|
||||
debounce,
|
||||
),
|
||||
[debounce],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!isEqual(collection, prevCollection) ||
|
||||
!isEqual(predicate, prevPredicate) ||
|
||||
!isEqual(query, prevQuery) ||
|
||||
!isEqual(filter, prevFilter)
|
||||
)
|
||||
debouncedFilterCollection(collection, predicate, query, filter);
|
||||
}, [collection, predicate, query, filter]);
|
||||
|
||||
useEffect(() => {
|
||||
isMounted.current = true;
|
||||
|
||||
return () => {
|
||||
isMounted.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return [filteredCollection, query, handleChange, setQuery];
|
||||
}
|
||||
4
src/interfaces/Domain.ts
Normal file
4
src/interfaces/Domain.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export interface Domain {
|
||||
id?: string;
|
||||
name: string;
|
||||
}
|
||||
@@ -17,11 +17,6 @@ export interface Nameserver {
|
||||
id?: string;
|
||||
}
|
||||
|
||||
export interface Domain {
|
||||
id?: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export const NameserverPresets: Record<string, NameserverGroup> = {
|
||||
Default: {
|
||||
name: "",
|
||||
|
||||
@@ -6,4 +6,5 @@ export enum OperatingSystem {
|
||||
DOCKER,
|
||||
IOS,
|
||||
UNKNOWN,
|
||||
FREEBSD,
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ export interface PostureCheck {
|
||||
os_version_check?: OperatingSystemVersionCheck;
|
||||
geo_location_check?: GeoLocationCheck;
|
||||
peer_network_range_check?: PeerNetworkRangeCheck;
|
||||
process_check?: ProcessCheck;
|
||||
};
|
||||
policies?: Policy[];
|
||||
active?: boolean;
|
||||
@@ -53,6 +54,17 @@ export interface PeerNetworkRangeCheck {
|
||||
action: "allow" | "deny";
|
||||
}
|
||||
|
||||
export interface ProcessCheck {
|
||||
processes: Process[];
|
||||
}
|
||||
|
||||
export interface Process {
|
||||
id: string;
|
||||
linux_path?: string;
|
||||
mac_path?: string;
|
||||
windows_path?: string;
|
||||
}
|
||||
|
||||
export const windowsKernelVersions: SelectOption[] = [
|
||||
{ value: "5.0", label: "Windows 2000" },
|
||||
{ value: "5.1", label: "Windows XP" },
|
||||
|
||||
@@ -3,26 +3,34 @@ export interface Route {
|
||||
description: string;
|
||||
enabled: boolean;
|
||||
peer?: string;
|
||||
network: string;
|
||||
network?: string;
|
||||
domains?: string[];
|
||||
network_id: string;
|
||||
network_type?: string;
|
||||
metric?: number;
|
||||
masquerade: boolean;
|
||||
groups: string[];
|
||||
keep_route?: boolean;
|
||||
// Frontend only
|
||||
peer_groups?: string[];
|
||||
routesGroups?: string[];
|
||||
groupedRoutes?: GroupedRoute[];
|
||||
group_names?: string[];
|
||||
domain_search?: string;
|
||||
}
|
||||
|
||||
export interface GroupedRoute {
|
||||
id: string;
|
||||
enabled: boolean;
|
||||
network: string;
|
||||
network?: string;
|
||||
domains?: string[];
|
||||
keep_route?: boolean;
|
||||
network_id: string;
|
||||
high_availability_count: number;
|
||||
is_using_route_groups: boolean;
|
||||
routes?: Route[];
|
||||
group_names?: string[];
|
||||
description?: string;
|
||||
description_search?: string;
|
||||
domain_search?: string;
|
||||
}
|
||||
|
||||
@@ -239,12 +239,6 @@ export function AccessControlModalContent({
|
||||
|
||||
const portAndDirectionDisabled = protocol == "icmp" || protocol == "all";
|
||||
|
||||
const buttonDisabled = useMemo(() => {
|
||||
if (sourceGroups.length == 0 || destinationGroups.length == 0) return true;
|
||||
if (name.length == 0) return true;
|
||||
if (direction != "bi" && ports.length == 0) return true;
|
||||
}, [sourceGroups, destinationGroups, direction, ports, name]);
|
||||
|
||||
const [postureChecks, setPostureChecks] = useState<PostureCheck[]>([]);
|
||||
const postureChecksLoaded = useRef(false);
|
||||
|
||||
@@ -268,6 +262,26 @@ export function AccessControlModalContent({
|
||||
}
|
||||
}, [initialPostureChecks]);
|
||||
|
||||
const continuePostureChecksDisabled = useMemo(() => {
|
||||
if (sourceGroups.length == 0 || destinationGroups.length == 0) return true;
|
||||
if (direction != "bi" && ports.length == 0) return true;
|
||||
}, [sourceGroups, destinationGroups, direction, ports]);
|
||||
|
||||
const submitDisabled = useMemo(() => {
|
||||
if (name.length == 0) return true;
|
||||
if (continuePostureChecksDisabled) return true;
|
||||
}, [name, continuePostureChecksDisabled]);
|
||||
|
||||
const handleProtocolChange = (p: Protocol) => {
|
||||
setProtocol(p);
|
||||
if (p == "icmp") {
|
||||
setPorts([]);
|
||||
}
|
||||
if (p == "all") {
|
||||
setPorts([]);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ModalContent maxWidthClass={"max-w-2xl"}>
|
||||
<ModalHeader
|
||||
@@ -283,14 +297,17 @@ export function AccessControlModalContent({
|
||||
color={"netbird"}
|
||||
/>
|
||||
|
||||
<Tabs defaultValue={tab} onValueChange={(v) => setTab(v)}>
|
||||
<Tabs defaultValue={tab} onValueChange={(v) => setTab(v)} value={tab}>
|
||||
<TabsList justify={"start"} className={"px-8"}>
|
||||
<TabsTrigger value={"policy"}>
|
||||
<ArrowRightLeft size={16} />
|
||||
Policy
|
||||
</TabsTrigger>
|
||||
<PostureCheckTabTrigger />
|
||||
<TabsTrigger value={"general"}>
|
||||
<PostureCheckTabTrigger disabled={continuePostureChecksDisabled} />
|
||||
<TabsTrigger
|
||||
value={"general"}
|
||||
disabled={continuePostureChecksDisabled}
|
||||
>
|
||||
<Text
|
||||
size={16}
|
||||
className={
|
||||
@@ -315,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"}>
|
||||
@@ -456,24 +473,74 @@ export function AccessControlModalContent({
|
||||
</Paragraph>
|
||||
</div>
|
||||
<div className={"flex gap-3 w-full justify-end"}>
|
||||
<ModalClose asChild={true}>
|
||||
<Button variant={"secondary"}>Cancel</Button>
|
||||
</ModalClose>
|
||||
{!policy ? (
|
||||
<>
|
||||
{tab == "policy" && (
|
||||
<ModalClose asChild={true}>
|
||||
<Button variant={"secondary"}>Cancel</Button>
|
||||
</ModalClose>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant={"primary"}
|
||||
disabled={buttonDisabled}
|
||||
onClick={submit}
|
||||
>
|
||||
{policy ? (
|
||||
<>Save Changes</>
|
||||
) : (
|
||||
<>
|
||||
<PlusCircle size={16} />
|
||||
Add Policy
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
{tab == "posture_checks" && (
|
||||
<Button variant={"secondary"} onClick={() => setTab("policy")}>
|
||||
Back
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{tab == "policy" && (
|
||||
<Button
|
||||
variant={"primary"}
|
||||
onClick={() => setTab("posture_checks")}
|
||||
disabled={continuePostureChecksDisabled}
|
||||
>
|
||||
Continue
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{tab == "posture_checks" && (
|
||||
<Button
|
||||
variant={"primary"}
|
||||
onClick={() => setTab("general")}
|
||||
disabled={continuePostureChecksDisabled}
|
||||
>
|
||||
Continue
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{tab == "general" && (
|
||||
<>
|
||||
<Button
|
||||
variant={"secondary"}
|
||||
onClick={() => setTab("posture_checks")}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant={"primary"}
|
||||
disabled={submitDisabled}
|
||||
onClick={submit}
|
||||
>
|
||||
<PlusCircle size={16} />
|
||||
Add Policy
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ModalClose asChild={true}>
|
||||
<Button variant={"secondary"}>Cancel</Button>
|
||||
</ModalClose>
|
||||
<Button
|
||||
variant={"primary"}
|
||||
disabled={submitDisabled}
|
||||
onClick={submit}
|
||||
>
|
||||
Save Changes
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
|
||||
@@ -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"}>
|
||||
|
||||
@@ -3,7 +3,11 @@ import { Label } from "@components/Label";
|
||||
import { IconInfoCircle } from "@tabler/icons-react";
|
||||
import { cn } from "@utils/helpers";
|
||||
import { isLocalDev, isProduction } from "@utils/netbird";
|
||||
import { isEmpty } from "lodash";
|
||||
import { GlobeIcon } from "lucide-react";
|
||||
import React, { useMemo } from "react";
|
||||
import RoundedFlag from "@/assets/countries/RoundedFlag";
|
||||
import { useCountries } from "@/contexts/CountryProvider";
|
||||
import { ActivityEvent } from "@/interfaces/ActivityEvent";
|
||||
|
||||
type Props = {
|
||||
@@ -54,7 +58,8 @@ export default function ActivityDescription({ event }: Props) {
|
||||
if (event.activity_code == "setupkey.peer.add")
|
||||
return (
|
||||
<div className={"inline"}>
|
||||
Peer <Value>{m.name}</Value> with ip <Value>{m.ip}</Value> was added
|
||||
Peer <Value>{m.name}</Value> <PeerConnectionInfo meta={m} /> was added
|
||||
with the NetBird IP <Value>{m.ip}</Value>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -113,29 +118,38 @@ export default function ActivityDescription({ event }: Props) {
|
||||
* Route
|
||||
*/
|
||||
|
||||
if (event.activity_code == "route.delete")
|
||||
if (event.activity_code == "route.delete") {
|
||||
let hasDomains = m?.domains && m?.domains.length > 0;
|
||||
return (
|
||||
<div className={"inline"}>
|
||||
Route <Value>{m.name}</Value> with the <Value>{m.network_range}</Value>{" "}
|
||||
range was deleted
|
||||
Route <Value>{m.name}</Value> with the {hasDomains ? "domain(s)" : ""}{" "}
|
||||
<Value>{hasDomains ? m?.domains : m.network_range}</Value>{" "}
|
||||
{hasDomains ? "" : "range"} was deleted
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (event.activity_code == "route.update")
|
||||
if (event.activity_code == "route.update") {
|
||||
let hasDomains = m?.domains && m?.domains.length > 0;
|
||||
return (
|
||||
<div className={"inline"}>
|
||||
Route <Value>{m.name}</Value> with the <Value>{m.network_range}</Value>{" "}
|
||||
range was updated
|
||||
Route <Value>{m.name}</Value> with the {hasDomains ? "domain(s)" : ""}{" "}
|
||||
<Value>{hasDomains ? m?.domains : m.network_range}</Value>{" "}
|
||||
{hasDomains ? "" : "range"} was updated
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (event.activity_code == "route.add")
|
||||
if (event.activity_code == "route.add") {
|
||||
let hasDomains = m?.domains && m?.domains.length > 0;
|
||||
return (
|
||||
<div className={"inline"}>
|
||||
Route <Value>{m.name}</Value> with the <Value>{m.network_range}</Value>{" "}
|
||||
range was created
|
||||
Route <Value>{m.name}</Value> with the {hasDomains ? "domain(s)" : ""}{" "}
|
||||
<Value>{hasDomains ? m?.domains : m.network_range}</Value>{" "}
|
||||
{hasDomains ? "" : "range"} was created
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* User
|
||||
@@ -144,21 +158,24 @@ export default function ActivityDescription({ event }: Props) {
|
||||
if (event.activity_code == "user.peer.delete")
|
||||
return (
|
||||
<div className={"inline"}>
|
||||
Peer <Value>{m.name}</Value> with ip <Value>{m.ip}</Value> was deleted
|
||||
Peer <Value>{m.name}</Value> <PeerConnectionInfo meta={m} /> with
|
||||
NetBird IP <Value>{m.ip}</Value> was deleted
|
||||
</div>
|
||||
);
|
||||
|
||||
if (event.activity_code == "user.peer.add")
|
||||
return (
|
||||
<div className={"inline"}>
|
||||
Peer <Value>{m.name}</Value> with ip <Value>{m.ip}</Value> was added
|
||||
Peer <Value>{m.name}</Value> <PeerConnectionInfo meta={m} /> was added
|
||||
with the NetBird IP <Value>{m.ip}</Value>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (event.activity_code == "user.peer.update")
|
||||
return (
|
||||
<div className={"inline"}>
|
||||
Peer <Value>{m.name}</Value> with ip <Value>{m.ip}</Value> was updated
|
||||
Peer <Value>{m.name}</Value> <PeerConnectionInfo meta={m} /> with
|
||||
NetBird IP <Value>{m.ip}</Value> was updated
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -252,15 +269,15 @@ export default function ActivityDescription({ event }: Props) {
|
||||
if (event.activity_code == "peer.group.delete")
|
||||
return (
|
||||
<div className={"inline"}>
|
||||
Group <Value>{m.group}</Value> was removed from the peer with the ip{" "}
|
||||
<Value>{m.peer_ip}</Value>
|
||||
Group <Value>{m.group}</Value> was removed from the peer with the
|
||||
NetBird IP <Value>{m.peer_ip}</Value>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (event.activity_code == "peer.group.add")
|
||||
return (
|
||||
<div className={"inline"}>
|
||||
Group <Value>{m.group}</Value> was added to the peer with the ip{" "}
|
||||
Group <Value>{m.group}</Value> was added to the peer with the NetBird IP{" "}
|
||||
<Value>{m.peer_ip}</Value>
|
||||
</div>
|
||||
);
|
||||
@@ -303,7 +320,7 @@ export default function ActivityDescription({ event }: Props) {
|
||||
if (event.activity_code == "peer.rename")
|
||||
return (
|
||||
<div className={"inline"}>
|
||||
Peer with the ip <Value>{m.ip}</Value> was renamed to{" "}
|
||||
Peer with the NetBird IP <Value>{m.ip}</Value> was renamed to{" "}
|
||||
<Value>{m.name}</Value>
|
||||
</div>
|
||||
);
|
||||
@@ -311,7 +328,7 @@ export default function ActivityDescription({ event }: Props) {
|
||||
if (event.activity_code == "peer.approve")
|
||||
return (
|
||||
<div className={"inline"}>
|
||||
Peer with the ip <Value>{m.ip}</Value> was approved
|
||||
Peer with the NetBird IP <Value>{m.ip}</Value> was approved
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -559,7 +576,7 @@ function Value({
|
||||
return children ? (
|
||||
<span
|
||||
className={cn(
|
||||
"text-nb-gray-200 inline font-medium bg-nb-gray-900 py-[3px] text-[11px] px-[5px] border border-nb-gray-800 rounded-[4px]",
|
||||
"text-nb-gray-200 inline-flex gap-1 items-center max-h-[22px] font-medium bg-nb-gray-900 py-[3px] text-[11px] px-[5px] border border-nb-gray-800 rounded-[4px]",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
@@ -567,3 +584,40 @@ function Value({
|
||||
</span>
|
||||
) : null;
|
||||
}
|
||||
|
||||
function PeerConnectionInfo({ meta }: { meta: any }) {
|
||||
const hasMeta =
|
||||
!isEmpty(meta?.location_country_code) ||
|
||||
!isEmpty(meta?.location_connection_ip);
|
||||
const { countries } = useCountries();
|
||||
|
||||
const countryText = useMemo(() => {
|
||||
if (!countries) return "Unknown";
|
||||
const country = countries.find(
|
||||
(c) => c.country_code === meta?.location_country_code,
|
||||
);
|
||||
if (!country) return "Unknown";
|
||||
if (!meta?.location_city_name) return country.country_name;
|
||||
return `${country.country_name}, ${meta?.location_city_name}`;
|
||||
}, [countries, meta]);
|
||||
|
||||
return hasMeta ? (
|
||||
<>
|
||||
{" "}
|
||||
from{" "}
|
||||
{meta?.location_connection_ip && (
|
||||
<Value>{meta?.location_connection_ip}</Value>
|
||||
)}{" "}
|
||||
{meta?.location_country_code && (
|
||||
<Value>
|
||||
{isEmpty(meta?.location_country_code) ? (
|
||||
<GlobeIcon size={9} className={"text-nb-gray-300"} />
|
||||
) : (
|
||||
<RoundedFlag country={meta?.location_country_code} size={9} />
|
||||
)}
|
||||
{countryText}
|
||||
</Value>
|
||||
)}
|
||||
</>
|
||||
) : null;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import Badge from "@components/Badge";
|
||||
import Button from "@components/Button";
|
||||
import {
|
||||
Modal,
|
||||
@@ -10,6 +11,7 @@ import ModalHeader from "@components/modal/ModalHeader";
|
||||
import { PeerGroupSelector } from "@components/PeerGroupSelector";
|
||||
import Separator from "@components/Separator";
|
||||
import MultipleGroups from "@components/ui/MultipleGroups";
|
||||
import { IconCirclePlus } from "@tabler/icons-react";
|
||||
import { FolderGit2 } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { useMemo } from "react";
|
||||
@@ -27,6 +29,8 @@ type Props = {
|
||||
label?: string;
|
||||
description?: string;
|
||||
peer?: Peer;
|
||||
showAddGroupButton?: boolean;
|
||||
hideAllGroup?: boolean;
|
||||
};
|
||||
|
||||
export default function GroupsRow({
|
||||
@@ -37,6 +41,8 @@ export default function GroupsRow({
|
||||
label = "Assigned Groups",
|
||||
description = "Use groups to control what this peer can access",
|
||||
peer,
|
||||
showAddGroupButton = false,
|
||||
hideAllGroup = false,
|
||||
}: Props) {
|
||||
const { groups: allGroups } = useGroups();
|
||||
const { isUser } = useLoggedInUser();
|
||||
@@ -59,7 +65,14 @@ export default function GroupsRow({
|
||||
setModal && !isUser && setModal(true);
|
||||
}}
|
||||
>
|
||||
<MultipleGroups groups={foundGroups} label={label} />
|
||||
{foundGroups?.length == 0 && showAddGroupButton ? (
|
||||
<Badge variant={"gray"} useHover={true}>
|
||||
<IconCirclePlus size={14} />
|
||||
Add Groups
|
||||
</Badge>
|
||||
) : (
|
||||
<MultipleGroups groups={foundGroups} label={label} />
|
||||
)}
|
||||
</ModalTrigger>
|
||||
<EditGroupsModal
|
||||
groups={foundGroups}
|
||||
@@ -67,6 +80,7 @@ export default function GroupsRow({
|
||||
label={label}
|
||||
description={description}
|
||||
peer={peer}
|
||||
hideAllGroup={hideAllGroup}
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
@@ -78,6 +92,7 @@ type EditGroupsModalProps = {
|
||||
label?: string;
|
||||
description?: string;
|
||||
peer?: Peer;
|
||||
hideAllGroup?: boolean;
|
||||
};
|
||||
|
||||
export function EditGroupsModal({
|
||||
@@ -86,6 +101,7 @@ export function EditGroupsModal({
|
||||
label,
|
||||
description,
|
||||
peer,
|
||||
hideAllGroup = false,
|
||||
}: EditGroupsModalProps) {
|
||||
const [selectedGroups, setSelectedGroups, { getAllGroupCalls }] =
|
||||
useGroupHelper({
|
||||
@@ -114,6 +130,7 @@ export function EditGroupsModal({
|
||||
onChange={setSelectedGroups}
|
||||
values={selectedGroups}
|
||||
peer={peer}
|
||||
hideAllGroup={hideAllGroup}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -17,8 +17,9 @@ import Paragraph from "@components/Paragraph";
|
||||
import { PeerGroupSelector } from "@components/PeerGroupSelector";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@components/Tabs";
|
||||
import { Textarea } from "@components/Textarea";
|
||||
import InputDomain, { domainReducer } from "@components/ui/InputDomain";
|
||||
import { useApiCall } from "@utils/api";
|
||||
import { cn, validator } from "@utils/helpers";
|
||||
import { cn } from "@utils/helpers";
|
||||
import cidr from "ip-cidr";
|
||||
import { uniqueId } from "lodash";
|
||||
import {
|
||||
@@ -35,7 +36,7 @@ import {
|
||||
import React, { useEffect, useMemo, useReducer, useState } from "react";
|
||||
import { useSWRConfig } from "swr";
|
||||
import DNSIcon from "@/assets/icons/DNSIcon";
|
||||
import { Domain, Nameserver, NameserverGroup } from "@/interfaces/Nameserver";
|
||||
import { Nameserver, NameserverGroup } from "@/interfaces/Nameserver";
|
||||
import useGroupHelper from "@/modules/groups/useGroupHelper";
|
||||
|
||||
type Props = {
|
||||
@@ -97,19 +98,6 @@ enum ActionType {
|
||||
UPDATE = "UPDATE",
|
||||
}
|
||||
|
||||
export const domainReducer = (state: Domain[], action: any) => {
|
||||
switch (action.type) {
|
||||
case ActionType.ADD:
|
||||
return [...state, { name: "", id: uniqueId("ns") }];
|
||||
case ActionType.REMOVE:
|
||||
return state.filter((_, i) => i !== action.index);
|
||||
case ActionType.UPDATE:
|
||||
return state.map((n, i) => (i === action.index ? action.d : n));
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
};
|
||||
|
||||
export function NameserverModalContent({
|
||||
onSuccess,
|
||||
preset,
|
||||
@@ -199,7 +187,7 @@ export function NameserverModalContent({
|
||||
// Domains
|
||||
const [domains, setDomains] = useReducer(domainReducer, [], () => {
|
||||
if (preset?.domains?.length) {
|
||||
return preset.domains.map((d) => ({ name: d, id: uniqueId("ns") }));
|
||||
return preset.domains.map((d) => ({ name: d, id: uniqueId("domain") }));
|
||||
}
|
||||
return [];
|
||||
});
|
||||
@@ -238,27 +226,22 @@ export function NameserverModalContent({
|
||||
return "";
|
||||
}, [name]);
|
||||
|
||||
const hasAnyError = useMemo(() => {
|
||||
return (
|
||||
const canContinueToDomains = useMemo(() => {
|
||||
return !(
|
||||
hasNSErrors ||
|
||||
nsError ||
|
||||
domainError ||
|
||||
nameservers.length == 0 ||
|
||||
hasDomainErrors ||
|
||||
groups.length == 0 ||
|
||||
nameLengthError !== "" ||
|
||||
name == ""
|
||||
groups.length == 0
|
||||
);
|
||||
}, [
|
||||
nsError,
|
||||
domainError,
|
||||
nameservers,
|
||||
groups,
|
||||
hasNSErrors,
|
||||
hasDomainErrors,
|
||||
nameLengthError,
|
||||
name,
|
||||
]);
|
||||
}, [hasNSErrors, nsError, nameservers.length, groups.length]);
|
||||
|
||||
const canContinueToGeneral = useMemo(() => {
|
||||
return !(!canContinueToDomains || domainError || hasDomainErrors);
|
||||
}, [canContinueToDomains, domainError, hasDomainErrors]);
|
||||
|
||||
const canSubmit = useMemo(() => {
|
||||
return !(!canContinueToGeneral || nameLengthError !== "" || name == "");
|
||||
}, [canContinueToGeneral, nameLengthError, name]);
|
||||
|
||||
return (
|
||||
<ModalContent maxWidthClass={"max-w-xl"}>
|
||||
@@ -269,7 +252,7 @@ export function NameserverModalContent({
|
||||
color={"netbird"}
|
||||
/>
|
||||
|
||||
<Tabs defaultValue={tab} onValueChange={(v) => setTab(v)}>
|
||||
<Tabs defaultValue={tab} onValueChange={(v) => setTab(v)} value={tab}>
|
||||
<TabsList justify={"start"} className={"px-8"}>
|
||||
<TabsTrigger value={"nameserver"}>
|
||||
<ServerIcon
|
||||
@@ -280,7 +263,7 @@ export function NameserverModalContent({
|
||||
/>
|
||||
Nameserver
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value={"domains"}>
|
||||
<TabsTrigger value={"domains"} disabled={!canContinueToDomains}>
|
||||
<GlobeIcon
|
||||
size={16}
|
||||
className={
|
||||
@@ -289,7 +272,7 @@ export function NameserverModalContent({
|
||||
/>
|
||||
Domains
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value={"general"}>
|
||||
<TabsTrigger value={"general"} disabled={!canContinueToGeneral}>
|
||||
<Text
|
||||
size={16}
|
||||
className={
|
||||
@@ -375,7 +358,7 @@ export function NameserverModalContent({
|
||||
<div className={"flex flex-col gap-2 w-full"}>
|
||||
{domains.map((domain, i) => {
|
||||
return (
|
||||
<DomainInput
|
||||
<InputDomain
|
||||
key={domain.id}
|
||||
value={domain}
|
||||
onChange={(d) =>
|
||||
@@ -473,20 +456,77 @@ export function NameserverModalContent({
|
||||
</Paragraph>
|
||||
</div>
|
||||
<div className={"flex gap-3 w-full justify-end"}>
|
||||
<ModalClose asChild={true}>
|
||||
<Button variant={"secondary"}>Cancel</Button>
|
||||
</ModalClose>
|
||||
{!isUpdate ? (
|
||||
<>
|
||||
{tab == "nameserver" && (
|
||||
<ModalClose asChild={true}>
|
||||
<Button variant={"secondary"}>Cancel</Button>
|
||||
</ModalClose>
|
||||
)}
|
||||
|
||||
<Button variant={"primary"} disabled={hasAnyError} onClick={submit}>
|
||||
{isUpdate ? (
|
||||
<>Save Changes</>
|
||||
) : (
|
||||
<>
|
||||
<PlusCircle size={16} />
|
||||
Add Nameserver
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
{tab == "domains" && (
|
||||
<Button
|
||||
variant={"secondary"}
|
||||
onClick={() => setTab("nameserver")}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{tab == "nameserver" && (
|
||||
<Button
|
||||
variant={"primary"}
|
||||
onClick={() => setTab("domains")}
|
||||
disabled={!canContinueToDomains}
|
||||
>
|
||||
Continue
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{tab == "domains" && (
|
||||
<Button
|
||||
variant={"primary"}
|
||||
onClick={() => setTab("general")}
|
||||
disabled={!canContinueToGeneral}
|
||||
>
|
||||
Continue
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{tab == "general" && (
|
||||
<>
|
||||
<Button
|
||||
variant={"secondary"}
|
||||
onClick={() => setTab("domains")}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant={"primary"}
|
||||
disabled={!canSubmit}
|
||||
onClick={submit}
|
||||
>
|
||||
<PlusCircle size={16} />
|
||||
Add Nameserver
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ModalClose asChild={true}>
|
||||
<Button variant={"secondary"}>Cancel</Button>
|
||||
</ModalClose>
|
||||
<Button
|
||||
variant={"primary"}
|
||||
disabled={!canSubmit}
|
||||
onClick={submit}
|
||||
>
|
||||
Save Changes
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
@@ -567,63 +607,3 @@ function NameserverInput({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DomainInput({
|
||||
value,
|
||||
onChange,
|
||||
onRemove,
|
||||
onError,
|
||||
}: {
|
||||
value: Domain;
|
||||
onChange: (d: Domain) => void;
|
||||
onRemove: () => void;
|
||||
onError?: (error: boolean) => void;
|
||||
error?: string;
|
||||
}) {
|
||||
const [name, setName] = useState(value.name);
|
||||
|
||||
const handleNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setName(e.target.value);
|
||||
onChange({ ...value, name: e.target.value });
|
||||
};
|
||||
|
||||
const domainError = useMemo(() => {
|
||||
if (name == "") {
|
||||
return "";
|
||||
}
|
||||
const valid = validator.isValidDomain(name);
|
||||
if (!valid) {
|
||||
onError && onError(true);
|
||||
return "Please enter a valid domain, e.g. example.com or intra.example.com";
|
||||
}
|
||||
onError && onError(false);
|
||||
}, [name, onError]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => onError && onError(false);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={"flex gap-2 w-full"}>
|
||||
<div className={"w-full"}>
|
||||
<Input
|
||||
customPrefix={<GlobeIcon size={15} />}
|
||||
placeholder={"e.g., example.com"}
|
||||
maxWidthClass={"w-full"}
|
||||
value={name}
|
||||
error={domainError}
|
||||
onChange={handleNameChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
className={"h-[42px]"}
|
||||
variant={"default-outline"}
|
||||
onClick={onRemove}
|
||||
>
|
||||
<MinusCircleIcon size={15} />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ export const AddExitNodeButton = ({ peer, firstTime = false }: Props) => {
|
||||
) : (
|
||||
<>
|
||||
<IconDirectionSign size={16} className={"text-yellow-400"} />
|
||||
Setup Exit Node
|
||||
Set Up Exit Node
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { DropdownMenuItem } from "@components/DropdownMenu";
|
||||
import { Modal } from "@components/modal/Modal";
|
||||
import { getOperatingSystem } from "@hooks/useOperatingSystem";
|
||||
import { IconDirectionSign } from "@tabler/icons-react";
|
||||
import { IconCirclePlus, IconDirectionSign } from "@tabler/icons-react";
|
||||
import * as React from "react";
|
||||
import { useState } from "react";
|
||||
import RoutesProvider from "@/contexts/RoutesProvider";
|
||||
import { OperatingSystem } from "@/interfaces/OperatingSystem";
|
||||
import { Peer } from "@/interfaces/Peer";
|
||||
import { useHasExitNodes } from "@/modules/exit-node/useHasExitNodes";
|
||||
import { RouteModalContent } from "@/modules/routes/RouteModal";
|
||||
|
||||
type Props = {
|
||||
@@ -15,24 +17,41 @@ type Props = {
|
||||
export const ExitNodeDropdownButton = ({ peer }: Props) => {
|
||||
const [modal, setModal] = useState(false);
|
||||
const isLinux = getOperatingSystem(peer.os) === OperatingSystem.LINUX;
|
||||
const hasExitNodes = useHasExitNodes(peer);
|
||||
|
||||
return isLinux ? (
|
||||
<>
|
||||
<DropdownMenuItem onClick={() => setModal(true)}>
|
||||
<div className={"flex gap-3 items-center w-full"}>
|
||||
<IconDirectionSign size={14} className={"shrink-0"} />
|
||||
<div className={"flex justify-between items-center w-full"}>
|
||||
Add Exit Node
|
||||
</div>
|
||||
{hasExitNodes ? (
|
||||
<>
|
||||
<IconCirclePlus size={14} className={"shrink-0"} />
|
||||
<div className={"flex justify-between items-center w-full"}>
|
||||
Add Exit Node
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<IconDirectionSign
|
||||
size={14}
|
||||
className={"shrink-0 text-yellow-400"}
|
||||
/>
|
||||
<div className={"flex justify-between items-center w-full"}>
|
||||
Set Up Exit Node
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
<Modal open={modal} onOpenChange={setModal}>
|
||||
{modal && (
|
||||
<RouteModalContent
|
||||
onSuccess={() => setModal(false)}
|
||||
peer={peer}
|
||||
exitNode={true}
|
||||
/>
|
||||
<RoutesProvider>
|
||||
<RouteModalContent
|
||||
onSuccess={() => setModal(false)}
|
||||
peer={peer}
|
||||
exitNode={true}
|
||||
/>
|
||||
</RoutesProvider>
|
||||
)}
|
||||
</Modal>
|
||||
</>
|
||||
|
||||
@@ -13,8 +13,7 @@ export const useHasExitNodes = (peer?: Peer) => {
|
||||
);
|
||||
return peer
|
||||
? routes?.some(
|
||||
(route) =>
|
||||
route?.peer === peer.id && route?.network.includes("0.0.0.0"),
|
||||
(route) => route?.peer === peer.id && route?.network === "0.0.0.0/0",
|
||||
) || false
|
||||
: false;
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -12,8 +12,8 @@ import { Route } from "@/interfaces/Route";
|
||||
import PeerRouteActionCell from "@/modules/peer/PeerRouteActionCell";
|
||||
import PeerRouteActiveCell from "@/modules/peer/PeerRouteActiveCell";
|
||||
import PeerRouteNameCell from "@/modules/peer/PeerRouteNameCell";
|
||||
import PeerRouteNetworkCell from "@/modules/peer/PeerRouteNetworkCell";
|
||||
import usePeerRoutes from "@/modules/peer/usePeerRoutes";
|
||||
import GroupedRouteNetworkRangeCell from "@/modules/route-group/GroupedRouteNetworkRangeCell";
|
||||
import RouteDistributionGroupsCell from "@/modules/routes/RouteDistributionGroupsCell";
|
||||
|
||||
type Props = {
|
||||
@@ -32,9 +32,14 @@ export const RouteTableColumns: ColumnDef<Route>[] = [
|
||||
{
|
||||
accessorKey: "network",
|
||||
header: ({ column }) => {
|
||||
return <DataTableHeader column={column}>Network Range</DataTableHeader>;
|
||||
return <DataTableHeader column={column}>Network</DataTableHeader>;
|
||||
},
|
||||
cell: ({ row }) => <PeerRouteNetworkCell route={row.original} />,
|
||||
cell: ({ row }) => (
|
||||
<GroupedRouteNetworkRangeCell
|
||||
domains={row.original?.domains}
|
||||
network={row.original?.network}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "groups",
|
||||
|
||||
@@ -34,6 +34,7 @@ export default function PeerGroupCell() {
|
||||
label={"Assigned Groups"}
|
||||
description={"Use groups to control what this peer can access"}
|
||||
groups={groupIDs || []}
|
||||
hideAllGroup={true}
|
||||
onSave={handleSave}
|
||||
modal={modal}
|
||||
peer={peer}
|
||||
|
||||
457
src/modules/peers/PeerMultiSelect.tsx
Normal file
457
src/modules/peers/PeerMultiSelect.tsx
Normal file
@@ -0,0 +1,457 @@
|
||||
import Button from "@components/Button";
|
||||
import FancyToggleSwitch from "@components/FancyToggleSwitch";
|
||||
import FullTooltip from "@components/FullTooltip";
|
||||
import HelpText from "@components/HelpText";
|
||||
import { Label } from "@components/Label";
|
||||
import { notify } from "@components/Notification";
|
||||
import { PeerGroupSelector } from "@components/PeerGroupSelector";
|
||||
import { IconX } from "@tabler/icons-react";
|
||||
import { RowSelectionState } from "@tanstack/react-table";
|
||||
import { useApiCall } from "@utils/api";
|
||||
import { cn } from "@utils/helpers";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { uniq, uniqBy } from "lodash";
|
||||
import {
|
||||
CheckCircle,
|
||||
CirclePlus,
|
||||
FolderGit2,
|
||||
Loader2,
|
||||
MonitorSmartphoneIcon,
|
||||
RedoDot,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useSWRConfig } from "swr";
|
||||
import { useDialog } from "@/contexts/DialogProvider";
|
||||
import { usePeers } from "@/contexts/PeersProvider";
|
||||
import { Group, GroupPeer } from "@/interfaces/Group";
|
||||
import { Peer } from "@/interfaces/Peer";
|
||||
import useGroupHelper from "@/modules/groups/useGroupHelper";
|
||||
|
||||
type Props = {
|
||||
selectedPeers?: RowSelectionState;
|
||||
onCanceled?: () => void;
|
||||
};
|
||||
export const PeerMultiSelect = ({ selectedPeers = {}, onCanceled }: Props) => {
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{Object.keys(selectedPeers).length > 0 && (
|
||||
<PeerGroupMassAssignmentContent
|
||||
selectedPeers={selectedPeers}
|
||||
onCanceled={onCanceled}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
};
|
||||
|
||||
const PeerGroupMassAssignmentContent = ({
|
||||
selectedPeers = {},
|
||||
onCanceled,
|
||||
}: Props) => {
|
||||
const { mutate } = useSWRConfig();
|
||||
const { confirm } = useDialog();
|
||||
|
||||
const { peers } = usePeers();
|
||||
|
||||
const groupCall = useApiCall<Group>("/groups");
|
||||
const getAllGroups = useApiCall<Group[]>("/groups").get;
|
||||
const peerCall = useApiCall<Peer>("/peers", true);
|
||||
|
||||
const [showGroupAssignment, setShowGroupAssignment] = useState(false);
|
||||
const groupAssignmentRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
const [selectedGroups, setSelectedGroups, { getAllGroupCalls }] =
|
||||
useGroupHelper({
|
||||
initial: [],
|
||||
});
|
||||
const [replaceAllGroups, setReplaceAllGroups] = useState(false);
|
||||
|
||||
const peerCount = useMemo(
|
||||
() => Object.keys(selectedPeers).length,
|
||||
[selectedPeers],
|
||||
);
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isSuccess, setIsSuccess] = useState(false);
|
||||
const isLoadingOrSuccess = isLoading || isSuccess;
|
||||
|
||||
useEffect(() => {
|
||||
const timeout = setTimeout(() => {
|
||||
isSuccess && setIsSuccess(false);
|
||||
}, 1000);
|
||||
return () => clearTimeout(timeout);
|
||||
}, [isSuccess]);
|
||||
|
||||
const addGroupsToPeers = async () => {
|
||||
if (replaceAllGroups) {
|
||||
const choice = await confirm({
|
||||
title: `Overwrite existing groups?`,
|
||||
description: `Are you sure you want to overwrite the existing groups of your ${peerCount} selected peer(s)? This action cannot be undone.`,
|
||||
confirmText: "Overwrite",
|
||||
cancelText: "Cancel",
|
||||
type: "warning",
|
||||
});
|
||||
if (!choice) return;
|
||||
}
|
||||
setIsSuccess(false);
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const allGroups = await getAllGroups();
|
||||
const selectedGroupCalls = getAllGroupCalls();
|
||||
const selectedPeerIDs = Object.keys(selectedPeers);
|
||||
let currentSelectedGroups = await Promise.all(selectedGroupCalls);
|
||||
currentSelectedGroups = currentSelectedGroups
|
||||
.map((g) => {
|
||||
let findGroup = allGroups?.find((group) => group.id === g.id);
|
||||
if (findGroup) return findGroup;
|
||||
return g;
|
||||
})
|
||||
.filter((g) => g !== undefined);
|
||||
let selectedPeerGroups: Group[] = [];
|
||||
|
||||
if (replaceAllGroups) {
|
||||
// Get all the groups of the selected peers
|
||||
selectedPeerGroups = uniqBy(
|
||||
Object.keys(selectedPeers)
|
||||
.map((id) => {
|
||||
return peers?.find((p) => p.id === id)?.groups ?? [];
|
||||
})
|
||||
.flat()
|
||||
.filter((g) => g !== undefined),
|
||||
"id",
|
||||
);
|
||||
|
||||
// Find the groups
|
||||
selectedPeerGroups =
|
||||
allGroups?.filter((group) =>
|
||||
selectedPeerGroups.find((g) => g.id === group.id),
|
||||
) ?? [];
|
||||
|
||||
// Remove the peers from the groups
|
||||
selectedPeerGroups = selectedPeerGroups.map((group) => {
|
||||
let previousPeers = group?.peers as GroupPeer[];
|
||||
let previousPeerIDs = previousPeers?.map((p) => p.id);
|
||||
previousPeerIDs = previousPeerIDs
|
||||
.filter((id) => !selectedPeerIDs.includes(id))
|
||||
.filter((id) => id !== "" && id !== null && id !== undefined);
|
||||
|
||||
return {
|
||||
...group,
|
||||
peers: previousPeerIDs,
|
||||
};
|
||||
}) as Group[];
|
||||
}
|
||||
|
||||
// Add selected peers to the selected groups
|
||||
currentSelectedGroups = currentSelectedGroups
|
||||
.map((group) => {
|
||||
let previousPeers = (group?.peers as GroupPeer[]) ?? [];
|
||||
let previousPeerIDs = previousPeers.map((p) => p.id);
|
||||
|
||||
let peers = uniq(
|
||||
[...previousPeerIDs, ...selectedPeerIDs].filter(
|
||||
(p) => p !== "" && p !== null && p !== undefined,
|
||||
),
|
||||
);
|
||||
return {
|
||||
...group,
|
||||
peers,
|
||||
};
|
||||
})
|
||||
.filter((g) => g !== undefined) as Group[];
|
||||
|
||||
// Merge the groups from the peers and the selected groups and remove duplicates
|
||||
currentSelectedGroups = uniqBy(
|
||||
[...currentSelectedGroups, ...selectedPeerGroups],
|
||||
"id",
|
||||
);
|
||||
|
||||
// Remove 'All' group if it exists
|
||||
currentSelectedGroups = currentSelectedGroups.filter(
|
||||
(group) => group.name !== "All",
|
||||
);
|
||||
|
||||
// Create the update calls for each group
|
||||
let updateGroupCalls = () =>
|
||||
Promise.all(
|
||||
currentSelectedGroups.map((group) => {
|
||||
return groupCall.put(
|
||||
{
|
||||
name: group.name,
|
||||
peers: group.peers,
|
||||
},
|
||||
"/" + group.id,
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
notify({
|
||||
title: "Assign Groups to Peers",
|
||||
description: "Groups were successfully assigned to the peers",
|
||||
promise: updateGroupCalls()
|
||||
.then(() => {
|
||||
if (currentSelectedGroups.length > 0) {
|
||||
mutate("/groups");
|
||||
mutate("/peers");
|
||||
setIsSuccess(true);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false);
|
||||
}),
|
||||
loadingMessage: "Updating the groups of the selected peers...",
|
||||
});
|
||||
} catch (e) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteAllPeers = async () => {
|
||||
const choice = await confirm({
|
||||
title: `Delete '${peerCount}' ${peerCount > 1 ? "peers" : "peer"}?`,
|
||||
description: `Are you sure you want to delete these peers? This action cannot be undone.`,
|
||||
confirmText: "Delete All",
|
||||
cancelText: "Cancel",
|
||||
type: "danger",
|
||||
});
|
||||
if (!choice) return;
|
||||
|
||||
let batchDeleteCalls = () =>
|
||||
Object.keys(selectedPeers).map((id) => {
|
||||
return peerCall.del({}, `/${id}`);
|
||||
});
|
||||
|
||||
notify({
|
||||
title: "Delete Peers",
|
||||
description: "Peers were successfully deleted",
|
||||
promise: Promise.all(batchDeleteCalls()).then(() => {
|
||||
mutate("/peers");
|
||||
onCanceled?.();
|
||||
}),
|
||||
loadingMessage: "Deleting the selected peers...",
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={"fixed -bottom-16 z-50 w-full left-0 pointer-events-none"}>
|
||||
<motion.div
|
||||
exit={{
|
||||
y: showGroupAssignment
|
||||
? (groupAssignmentRef?.current?.clientHeight ?? 0) + 55 || 370
|
||||
: 100,
|
||||
}}
|
||||
>
|
||||
<AnimatePresence>
|
||||
{showGroupAssignment && (
|
||||
<motion.div
|
||||
ref={groupAssignmentRef}
|
||||
animate={{ y: 0 }}
|
||||
initial={{ y: 100 }}
|
||||
exit={{
|
||||
y: (groupAssignmentRef?.current?.clientHeight ?? 0) + 55 || 370,
|
||||
}}
|
||||
transition={{
|
||||
type: "spring",
|
||||
stiffness: 276,
|
||||
damping: 25,
|
||||
duration: 0.35,
|
||||
}}
|
||||
className={
|
||||
"max-w-xl mx-auto rounded-t-lg -bottom-14 relative z-[49] flex gap-4 flex-col px-6 pt-6 pb-20 bg-nb-gray-920 border border-nb-gray-900 shadow-2xl border-b-0 overflow-hidden pointer-events-auto"
|
||||
}
|
||||
>
|
||||
<AnimatePresence>
|
||||
{isLoadingOrSuccess && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{
|
||||
type: "spring",
|
||||
stiffness: 260,
|
||||
damping: 20,
|
||||
duration: 0.25,
|
||||
}}
|
||||
className={
|
||||
"absolute w-full h-full flex items-center justify-center bg-nb-gray-920/70 z-50 top-0 left-0"
|
||||
}
|
||||
>
|
||||
<motion.span
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.8 }}
|
||||
transition={{
|
||||
type: "spring",
|
||||
stiffness: 260,
|
||||
damping: 20,
|
||||
duration: 0.25,
|
||||
}}
|
||||
className={
|
||||
"flex items-center justify-center gap-2 mb-14 font-normal text-nb-gray-100 text-sm"
|
||||
}
|
||||
>
|
||||
{isLoading && (
|
||||
<>
|
||||
<Loader2 size={14} className={"animate-spin"} />
|
||||
<span>Assigning groups...</span>
|
||||
</>
|
||||
)}
|
||||
{!isLoading && isSuccess && (
|
||||
<>
|
||||
<CheckCircle size={14} className={"text-green-400"} />
|
||||
<span>Groups successfully assigned</span>
|
||||
</>
|
||||
)}
|
||||
</motion.span>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<div>
|
||||
<Label>Assign Groups</Label>
|
||||
<HelpText>
|
||||
Assign the following groups to the selected peers. Previously
|
||||
assigned groups will be kept unless you choose to overwrite
|
||||
them.
|
||||
</HelpText>
|
||||
<PeerGroupSelector
|
||||
onChange={setSelectedGroups}
|
||||
values={selectedGroups}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FancyToggleSwitch
|
||||
value={replaceAllGroups}
|
||||
onChange={setReplaceAllGroups}
|
||||
label={
|
||||
<div className={"flex gap-2"}>
|
||||
<RedoDot size={14} />
|
||||
Overwrite Existing Groups
|
||||
</div>
|
||||
}
|
||||
helpText={
|
||||
<div>
|
||||
Overwrite the existing groups of the peers with the selected
|
||||
ones. Previously assigned groups will be removed.
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<AnimatePresence>
|
||||
<motion.div
|
||||
animate={{ y: 0 }}
|
||||
initial={{ y: 100 }}
|
||||
exit={{ y: showGroupAssignment ? 370 : 100 }}
|
||||
transition={{
|
||||
type: "spring",
|
||||
stiffness: 270,
|
||||
damping: 25,
|
||||
duration: 0.35,
|
||||
}}
|
||||
className={cn(
|
||||
"max-w-xl mx-auto border relative z-[50] bg-nb-gray-800 border-nb-gray-900 shadow-2xl border-b-0 overflow-hidden pointer-events-auto",
|
||||
!showGroupAssignment && "rounded-t-lg",
|
||||
)}
|
||||
>
|
||||
<AnimatePresence mode={"popLayout"}>
|
||||
<div
|
||||
className={
|
||||
"flex gap-2 items-center text-sm px-6 pt-3.5 pb-20 bg-nb-gray-920/90 text-nb-gray-200 justify-between"
|
||||
}
|
||||
>
|
||||
<div className={"flex gap-2 items-center"}>
|
||||
<MonitorSmartphoneIcon size={16} className={""} />
|
||||
<span>
|
||||
<span className={"font-medium text-white"}>
|
||||
{peerCount}
|
||||
</span>{" "}
|
||||
Peer(s) selected
|
||||
</span>
|
||||
</div>
|
||||
<div className={"flex gap-2 items-center"}>
|
||||
{!showGroupAssignment ? (
|
||||
<>
|
||||
<FullTooltip
|
||||
content={
|
||||
<span className={"text-xs"}>Assign Groups</span>
|
||||
}
|
||||
>
|
||||
<Button
|
||||
onClick={() =>
|
||||
setShowGroupAssignment(!showGroupAssignment)
|
||||
}
|
||||
variant={"default-outline"}
|
||||
size={"xs"}
|
||||
className={"!h-9 !w-9"}
|
||||
>
|
||||
<FolderGit2 size={16} className={"shrink-0"} />
|
||||
</Button>
|
||||
</FullTooltip>
|
||||
<FullTooltip
|
||||
content={<span className={"text-xs"}>Delete All</span>}
|
||||
>
|
||||
<Button
|
||||
variant={"danger-outline"}
|
||||
size={"xs"}
|
||||
className={"!h-9 !w-9"}
|
||||
onClick={deleteAllPeers}
|
||||
>
|
||||
<Trash2 size={16} className={"shrink-0"} />
|
||||
</Button>
|
||||
</FullTooltip>
|
||||
<FullTooltip
|
||||
content={<span className={"text-xs"}>Cancel</span>}
|
||||
>
|
||||
<Button
|
||||
onClick={onCanceled}
|
||||
variant={"default-outline"}
|
||||
size={"xs"}
|
||||
className={"!h-9 !w-9"}
|
||||
>
|
||||
<IconX size={16} className={"shrink-0"} />
|
||||
</Button>
|
||||
</FullTooltip>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
size={"xs"}
|
||||
variant={"secondary"}
|
||||
className={"!h-9 !px-3.5"}
|
||||
onClick={onCanceled}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size={"xs"}
|
||||
variant={"primary"}
|
||||
className={"!h-9 !px-3.5"}
|
||||
disabled={
|
||||
selectedGroups.length == 0 ||
|
||||
Object.keys(selectedPeers).length == 0 ||
|
||||
isLoadingOrSuccess
|
||||
}
|
||||
onClick={addGroupsToPeers}
|
||||
>
|
||||
{replaceAllGroups ? (
|
||||
<RedoDot size={14} />
|
||||
) : (
|
||||
<CirclePlus size={14} />
|
||||
)}
|
||||
{replaceAllGroups ? "Overwrite" : "Add"} Groups
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -10,6 +10,7 @@ import { FaWindows } from "react-icons/fa6";
|
||||
import { FcAndroidOs, FcLinux } from "react-icons/fc";
|
||||
import IOSIcon from "@/assets/icons/IOSIcon";
|
||||
import AppleLogo from "@/assets/os-icons/apple.svg";
|
||||
import FreeBSDLogo from "@/assets/os-icons/FreeBSD.png";
|
||||
import { getOperatingSystem } from "@/hooks/useOperatingSystem";
|
||||
import { OperatingSystem } from "@/interfaces/OperatingSystem";
|
||||
|
||||
@@ -49,6 +50,8 @@ export function OSLogo({ os }: { os: string }) {
|
||||
return <FaWindows className={"text-white text-lg"} />;
|
||||
if (icon === OperatingSystem.APPLE)
|
||||
return <Image src={AppleLogo} alt={""} width={14} />;
|
||||
if (icon === OperatingSystem.FREEBSD)
|
||||
return <Image src={FreeBSDLogo} alt={""} width={18} />;
|
||||
if (icon === OperatingSystem.IOS)
|
||||
return <IOSIcon className={"fill-white"} size={20} />;
|
||||
if (icon === OperatingSystem.ANDROID)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import Button from "@components/Button";
|
||||
import ButtonGroup from "@components/ButtonGroup";
|
||||
import { Checkbox } from "@components/Checkbox";
|
||||
import InlineLink from "@components/InlineLink";
|
||||
import SquareIcon from "@components/SquareIcon";
|
||||
import { DataTable } from "@components/table/DataTable";
|
||||
@@ -9,11 +10,15 @@ import { DataTableRowsPerPage } from "@components/table/DataTableRowsPerPage";
|
||||
import AddPeerButton from "@components/ui/AddPeerButton";
|
||||
import GetStartedTest from "@components/ui/GetStartedTest";
|
||||
import { NotificationCountBadge } from "@components/ui/NotificationCountBadge";
|
||||
import { ColumnDef, SortingState } from "@tanstack/react-table";
|
||||
import {
|
||||
ColumnDef,
|
||||
RowSelectionState,
|
||||
SortingState,
|
||||
} from "@tanstack/react-table";
|
||||
import { uniqBy } from "lodash";
|
||||
import { ExternalLinkIcon } from "lucide-react";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import React from "react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import React, { useState } from "react";
|
||||
import { useSWRConfig } from "swr";
|
||||
import PeerIcon from "@/assets/icons/PeerIcon";
|
||||
import PeerProvider from "@/contexts/PeerProvider";
|
||||
@@ -26,12 +31,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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
310
src/modules/posture-checks/checks/PostureCheckProcess.tsx
Normal file
310
src/modules/posture-checks/checks/PostureCheckProcess.tsx
Normal file
@@ -0,0 +1,310 @@
|
||||
import Button from "@components/Button";
|
||||
import HelpText from "@components/HelpText";
|
||||
import InlineLink from "@components/InlineLink";
|
||||
import { Input } from "@components/Input";
|
||||
import { Label } from "@components/Label";
|
||||
import { ModalClose, ModalFooter } from "@components/modal/Modal";
|
||||
import Paragraph from "@components/Paragraph";
|
||||
import { cn, validator } from "@utils/helpers";
|
||||
import { isEmpty, uniqueId } from "lodash";
|
||||
import {
|
||||
ExternalLinkIcon,
|
||||
MinusCircleIcon,
|
||||
PlusCircle,
|
||||
ServerCogIcon,
|
||||
TerminalIcon,
|
||||
} from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import AppleIcon from "@/assets/icons/AppleIcon";
|
||||
import WindowsIcon from "@/assets/icons/WindowsIcon";
|
||||
import { Process, ProcessCheck } from "@/interfaces/PostureCheck";
|
||||
import { PostureCheckCard } from "@/modules/posture-checks/ui/PostureCheckCard";
|
||||
|
||||
type Props = {
|
||||
value?: ProcessCheck;
|
||||
onChange: (value: ProcessCheck | undefined) => void;
|
||||
};
|
||||
|
||||
export const PostureCheckProcess = ({ value, onChange }: Props) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<PostureCheckCard
|
||||
open={open}
|
||||
setOpen={setOpen}
|
||||
key={open ? 1 : 0}
|
||||
active={value?.processes && value?.processes?.length > 0}
|
||||
title={"Process"}
|
||||
description={
|
||||
"Restrict access in your network based on running processes of a peer."
|
||||
}
|
||||
icon={<ServerCogIcon size={18} />}
|
||||
iconClass={"bg-gradient-to-tr from-nb-gray-500 to-nb-gray-300"}
|
||||
modalWidthClass={"max-w-xl"}
|
||||
onReset={() => onChange(undefined)}
|
||||
>
|
||||
<CheckContent
|
||||
value={value}
|
||||
onChange={(v) => {
|
||||
onChange(v);
|
||||
setOpen(false);
|
||||
}}
|
||||
/>
|
||||
</PostureCheckCard>
|
||||
);
|
||||
};
|
||||
|
||||
const CheckContent = ({ value, onChange }: Props) => {
|
||||
const [processes, setProcesses] = useState<Process[]>(
|
||||
value?.processes
|
||||
? value.processes.map((p) => {
|
||||
return {
|
||||
id: uniqueId("process"),
|
||||
linux_path: p?.linux_path || "",
|
||||
mac_path: p?.mac_path || "",
|
||||
windows_path: p?.windows_path || "",
|
||||
};
|
||||
})
|
||||
: [
|
||||
{
|
||||
id: uniqueId("process"),
|
||||
linux_path: "",
|
||||
mac_path: "",
|
||||
windows_path: "",
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
const handleProcessChange = (
|
||||
id: string,
|
||||
linux_path: string,
|
||||
mac_path: string,
|
||||
windows_path: string,
|
||||
) => {
|
||||
const newProcesses = processes.map((p) =>
|
||||
p.id === id ? { ...p, linux_path, mac_path, windows_path } : p,
|
||||
);
|
||||
setProcesses(newProcesses);
|
||||
};
|
||||
|
||||
const removeProcess = (id: string) => {
|
||||
const newProcesses = processes.filter((p) => p.id !== id);
|
||||
setProcesses(newProcesses);
|
||||
};
|
||||
|
||||
const addProcess = () => {
|
||||
setProcesses([
|
||||
...processes,
|
||||
{
|
||||
id: uniqueId("process"),
|
||||
linux_path: "",
|
||||
mac_path: "",
|
||||
windows_path: "",
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
const pathErrors = useMemo(() => {
|
||||
if (processes && processes.length > 0) {
|
||||
return processes.map((p) => {
|
||||
return {
|
||||
id: p.id,
|
||||
errorMacPath: p?.mac_path
|
||||
? validator.isValidUnixFilePath(p?.mac_path || "")
|
||||
? ""
|
||||
: "Please enter a valid macOS file path"
|
||||
: "",
|
||||
errorLinuxPath: p?.linux_path
|
||||
? validator.isValidUnixFilePath(p?.linux_path || "")
|
||||
? ""
|
||||
: "Please enter a valid Unix file path"
|
||||
: "",
|
||||
errorWindowsPath: p?.windows_path
|
||||
? validator.isValidWindowsFilePath(p?.windows_path || "")
|
||||
? ""
|
||||
: "Please enter a valid Windows file path"
|
||||
: "",
|
||||
};
|
||||
});
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
}, [processes]);
|
||||
|
||||
const hasErrorsOrIsEmpty = useMemo(() => {
|
||||
if (processes.length === 0) return true;
|
||||
const hasOnlyEmptyPaths = processes.some(
|
||||
(p) => p.linux_path === "" && p.mac_path === "" && p.windows_path === "",
|
||||
);
|
||||
const hasPathErrors = pathErrors.some(
|
||||
(e) =>
|
||||
e.errorLinuxPath !== "" ||
|
||||
e.errorMacPath !== "" ||
|
||||
e.errorWindowsPath !== "",
|
||||
);
|
||||
return hasOnlyEmptyPaths || hasPathErrors;
|
||||
}, [processes, pathErrors]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={"flex flex-col px-8 gap-2 pb-6"}>
|
||||
<div className={"flex justify-between items-start gap-10 mt-2"}>
|
||||
<div>
|
||||
<Label>Processes</Label>
|
||||
<HelpText className={""}>
|
||||
Add the path of an executable file of the process. You can define
|
||||
a path for Linux, macOS and Windows. Peers will only be allowed to
|
||||
connect if the process is running on their system.
|
||||
</HelpText>
|
||||
</div>
|
||||
</div>
|
||||
{processes.length > 0 && (
|
||||
<div className={"mb-2 flex flex-col gap-4 w-full "}>
|
||||
{processes.map((p) => {
|
||||
return (
|
||||
<div key={p.id} className={"flex gap-2 items-center"}>
|
||||
<div className={"w-full flex flex-col gap-1.5"}>
|
||||
<Input
|
||||
customPrefix={<TerminalIcon size={16} />}
|
||||
placeholder={"/usr/local/bin/netbird"}
|
||||
value={p.linux_path}
|
||||
error={
|
||||
pathErrors.find((e) => e.id === p.id)?.errorLinuxPath
|
||||
}
|
||||
errorTooltip={true}
|
||||
errorTooltipPosition={"top-right"}
|
||||
className={"w-full"}
|
||||
onChange={(e) =>
|
||||
handleProcessChange(
|
||||
p.id,
|
||||
e.target.value,
|
||||
p?.mac_path || "",
|
||||
p?.windows_path || "",
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Input
|
||||
customPrefix={
|
||||
<AppleIcon
|
||||
size={16}
|
||||
className={cn(
|
||||
pathErrors.find((e) => e.id === p.id)
|
||||
?.errorMacPath && "fill-red-500",
|
||||
)}
|
||||
/>
|
||||
}
|
||||
placeholder={
|
||||
"/Applications/NetBird.app/Contents/MacOS/netbird"
|
||||
}
|
||||
value={p.mac_path}
|
||||
error={
|
||||
pathErrors.find((e) => e.id === p.id)?.errorMacPath
|
||||
}
|
||||
errorTooltip={true}
|
||||
errorTooltipPosition={"top-right"}
|
||||
className={"w-full"}
|
||||
onChange={(e) =>
|
||||
handleProcessChange(
|
||||
p.id,
|
||||
p?.linux_path || "",
|
||||
e.target.value,
|
||||
p?.windows_path || "",
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Input
|
||||
customPrefix={
|
||||
<WindowsIcon
|
||||
size={16}
|
||||
className={cn(
|
||||
pathErrors.find((e) => e.id === p.id)
|
||||
?.errorWindowsPath && "fill-red-500",
|
||||
)}
|
||||
/>
|
||||
}
|
||||
placeholder={`C:\\ProgramData\\NetBird\\netbird.exe`}
|
||||
value={p.windows_path}
|
||||
errorTooltip={true}
|
||||
errorTooltipPosition={"top-right"}
|
||||
error={
|
||||
pathErrors.find((e) => e.id === p.id)?.errorWindowsPath
|
||||
}
|
||||
className={"w-full"}
|
||||
onChange={(e) =>
|
||||
handleProcessChange(
|
||||
p.id,
|
||||
p?.linux_path || "",
|
||||
p?.mac_path || "",
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
className={"h-[42px]"}
|
||||
variant={"default-outline"}
|
||||
onClick={() => removeProcess(p.id)}
|
||||
>
|
||||
<MinusCircleIcon size={15} />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
variant={"dotted"}
|
||||
size={"sm"}
|
||||
onClick={addProcess}
|
||||
className={"mt-1"}
|
||||
>
|
||||
<PlusCircle size={16} />
|
||||
Add Process
|
||||
</Button>
|
||||
</div>
|
||||
<ModalFooter className={"items-center"}>
|
||||
<div className={"w-full"}>
|
||||
<Paragraph className={"text-sm mt-auto"}>
|
||||
Learn more about
|
||||
<InlineLink
|
||||
href={
|
||||
"https://docs.netbird.io/how-to/manage-posture-checks#process-check"
|
||||
}
|
||||
target={"_blank"}
|
||||
>
|
||||
Process Check
|
||||
<ExternalLinkIcon size={12} />
|
||||
</InlineLink>
|
||||
</Paragraph>
|
||||
</div>
|
||||
<div className={"flex gap-3 w-full justify-end"}>
|
||||
<ModalClose asChild={true}>
|
||||
<Button variant={"secondary"}>Cancel</Button>
|
||||
</ModalClose>
|
||||
<Button
|
||||
variant={"primary"}
|
||||
disabled={hasErrorsOrIsEmpty}
|
||||
onClick={() => {
|
||||
if (isEmpty(processes)) {
|
||||
onChange(undefined);
|
||||
} else {
|
||||
onChange({
|
||||
processes: processes.filter(
|
||||
(p) =>
|
||||
p.linux_path !== "" ||
|
||||
p.mac_path !== "" ||
|
||||
p.windows_path !== "",
|
||||
),
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</ModalFooter>
|
||||
</>
|
||||
);
|
||||
};
|
||||
112
src/modules/posture-checks/checks/tooltips/ProcessTooltip.tsx
Normal file
112
src/modules/posture-checks/checks/tooltips/ProcessTooltip.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
import Badge from "@components/Badge";
|
||||
import FullTooltip from "@components/FullTooltip";
|
||||
import { ScrollArea } from "@components/ScrollArea";
|
||||
import { tryGetProcessNameFromPath } from "@utils/helpers";
|
||||
import { TerminalIcon } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import AppleIcon from "@/assets/icons/AppleIcon";
|
||||
import WindowsIcon from "@/assets/icons/WindowsIcon";
|
||||
import { ProcessCheck } from "@/interfaces/PostureCheck";
|
||||
|
||||
type Props = {
|
||||
check?: ProcessCheck;
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
export const ProcessTooltip = ({ check, children }: Props) => {
|
||||
return check ? (
|
||||
<FullTooltip
|
||||
className={"w-full min-w-0"}
|
||||
interactive={true}
|
||||
contentClassName={"p-0"}
|
||||
content={
|
||||
<div
|
||||
className={
|
||||
"text-neutral-300 text-sm max-w-xs flex flex-col gap-1 min-w-0"
|
||||
}
|
||||
>
|
||||
<div className={"px-4 pt-3"}>
|
||||
<span>
|
||||
<span className={"text-green-500 font-semibold"}>Allow only</span>{" "}
|
||||
peers which are running the following processes
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<ScrollArea
|
||||
className={
|
||||
"max-h-[275px] overflow-y-auto flex flex-col px-4 min-w-0"
|
||||
}
|
||||
>
|
||||
<div className={"flex flex-col gap-3 mt-1 text-xs mb-3.5 min-w-0"}>
|
||||
{check.processes.map((p, index) => {
|
||||
return (
|
||||
<div className={"flex-col flex gap-1 min-w-0"} key={index}>
|
||||
{p?.linux_path && (
|
||||
<Badge
|
||||
variant={"gray"}
|
||||
useHover={false}
|
||||
className={"justify-start font-medium text-xs min-w-0"}
|
||||
>
|
||||
<span className={"mr-1.5"}>
|
||||
<TerminalIcon size={12} />
|
||||
</span>
|
||||
<span
|
||||
className={"truncate inline-block "}
|
||||
title={p?.linux_path}
|
||||
>
|
||||
{tryGetProcessNameFromPath(p?.linux_path) ||
|
||||
"Unknown path"}
|
||||
</span>
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{p?.mac_path && (
|
||||
<Badge
|
||||
variant={"gray"}
|
||||
useHover={false}
|
||||
className={"justify-start font-medium text-xs min-w-0"}
|
||||
>
|
||||
<span className={"mr-1.5"}>
|
||||
<AppleIcon size={12} />
|
||||
</span>
|
||||
<span
|
||||
className={"truncate inline-block "}
|
||||
title={p?.mac_path}
|
||||
>
|
||||
{tryGetProcessNameFromPath(p?.mac_path) ||
|
||||
"Unknown path"}
|
||||
</span>
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{p?.windows_path && (
|
||||
<Badge
|
||||
variant={"gray"}
|
||||
useHover={false}
|
||||
className={"justify-start font-medium text-xs min-w-0"}
|
||||
>
|
||||
<span className={"mr-1.5"}>
|
||||
<WindowsIcon size={12} />
|
||||
</span>
|
||||
<span
|
||||
className={"truncate inline-block"}
|
||||
title={p?.windows_path}
|
||||
>
|
||||
{tryGetProcessNameFromPath(p?.windows_path) ||
|
||||
"Unknown path"}
|
||||
</span>
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</FullTooltip>
|
||||
) : (
|
||||
children
|
||||
);
|
||||
};
|
||||
@@ -24,6 +24,7 @@ import { PostureCheckGeoLocation } from "@/modules/posture-checks/checks/Posture
|
||||
import { PostureCheckNetBirdVersion } from "@/modules/posture-checks/checks/PostureCheckNetBirdVersion";
|
||||
import { PostureCheckOperatingSystem } from "@/modules/posture-checks/checks/PostureCheckOperatingSystem";
|
||||
import { PostureCheckPeerNetworkRange } from "@/modules/posture-checks/checks/PostureCheckPeerNetworkRange";
|
||||
import { PostureCheckProcess } from "@/modules/posture-checks/checks/PostureCheckProcess";
|
||||
|
||||
type Props = {
|
||||
open: boolean;
|
||||
@@ -38,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 || "");
|
||||
@@ -58,6 +59,9 @@ export default function PostureCheckModal({
|
||||
const [peerNetworkRangeCheck, setPeerNetworkRangeCheck] = useState(
|
||||
postureCheck?.checks.peer_network_range_check || undefined,
|
||||
);
|
||||
const [processCheck, setProcessCheck] = useState(
|
||||
postureCheck?.checks.process_check || undefined,
|
||||
);
|
||||
|
||||
const validateOSCheck = (osCheck?: OperatingSystemVersionCheck) => {
|
||||
if (!osCheck) return;
|
||||
@@ -98,6 +102,7 @@ export default function PostureCheckModal({
|
||||
geo_location_check: validateLocationCheck(geoLocationCheck),
|
||||
os_version_check: validateOSCheck(osVersionCheck),
|
||||
peer_network_range_check: peerNetworkRangeCheck,
|
||||
process_check: processCheck,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -133,7 +138,8 @@ export default function PostureCheckModal({
|
||||
!!nbVersionCheck ||
|
||||
!!geoLocationCheck ||
|
||||
!!osVersionCheck ||
|
||||
!!peerNetworkRangeCheck;
|
||||
!!peerNetworkRangeCheck ||
|
||||
!!processCheck;
|
||||
const canCreate = !isEmpty(name) && isAtLeastOneCheckEnabled;
|
||||
|
||||
const [tab, setTab] = useState("checks");
|
||||
@@ -163,7 +169,10 @@ export default function PostureCheckModal({
|
||||
Checks
|
||||
</TabsTrigger>
|
||||
|
||||
<TabsTrigger value={"general"}>
|
||||
<TabsTrigger
|
||||
value={"general"}
|
||||
disabled={!isAtLeastOneCheckEnabled}
|
||||
>
|
||||
<Text
|
||||
size={16}
|
||||
className={
|
||||
@@ -184,13 +193,17 @@ export default function PostureCheckModal({
|
||||
value={geoLocationCheck}
|
||||
onChange={setGeoLocationCheckCheck}
|
||||
/>
|
||||
<PostureCheckPeerNetworkRange
|
||||
value={peerNetworkRangeCheck}
|
||||
onChange={setPeerNetworkRangeCheck}
|
||||
/>
|
||||
<PostureCheckOperatingSystem
|
||||
value={osVersionCheck}
|
||||
onChange={setOsVersionCheck}
|
||||
/>
|
||||
<PostureCheckPeerNetworkRange
|
||||
value={peerNetworkRangeCheck}
|
||||
onChange={setPeerNetworkRangeCheck}
|
||||
<PostureCheckProcess
|
||||
value={processCheck}
|
||||
onChange={setProcessCheck}
|
||||
/>
|
||||
</>
|
||||
</TabsContent>
|
||||
@@ -243,12 +256,23 @@ export default function PostureCheckModal({
|
||||
</div>
|
||||
<div className={"flex gap-3 w-full justify-end"}>
|
||||
<>
|
||||
<Button
|
||||
variant={"secondary"}
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
{tab == "checks" && (
|
||||
<Button
|
||||
variant={"secondary"}
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{tab == "general" && (
|
||||
<Button
|
||||
variant={"secondary"}
|
||||
onClick={() => setTab("checks")}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{!postureCheck && tab == "checks" && (
|
||||
<Button
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { cn } from "@utils/helpers";
|
||||
import { Disc3Icon, FlagIcon, NetworkIcon } from "lucide-react";
|
||||
import { Disc3Icon, FlagIcon, NetworkIcon, ServerCogIcon } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import NetBirdIcon from "@/assets/icons/NetBirdIcon";
|
||||
import { PostureCheck } from "@/interfaces/PostureCheck";
|
||||
@@ -7,6 +7,7 @@ import { GeoLocationTooltip } from "@/modules/posture-checks/checks/tooltips/Geo
|
||||
import { NetBirdVersionTooltip } from "@/modules/posture-checks/checks/tooltips/NetBirdVersionTooltip";
|
||||
import { OperatingSystemTooltip } from "@/modules/posture-checks/checks/tooltips/OperatingSystemTooltip";
|
||||
import { PeerNetworkRangeTooltip } from "@/modules/posture-checks/checks/tooltips/PeerNetworkRangeTooltip";
|
||||
import { ProcessTooltip } from "@/modules/posture-checks/checks/tooltips/ProcessTooltip";
|
||||
|
||||
type Props = {
|
||||
check: PostureCheck;
|
||||
@@ -71,6 +72,18 @@ export const PostureCheckChecksCell = ({ check }: Props) => {
|
||||
</div>
|
||||
</PeerNetworkRangeTooltip>
|
||||
)}
|
||||
|
||||
{check.checks.process_check && (
|
||||
<ProcessTooltip check={check.checks.process_check}>
|
||||
<div
|
||||
className={cn(
|
||||
"bg-gradient-to-tr from-nb-gray-500 to-nb-gray-300 h-8 w-8 rounded-full flex items-center justify-center relative z-[8] hover:scale-[1.1] transition-all",
|
||||
)}
|
||||
>
|
||||
<ServerCogIcon size={14} />
|
||||
</div>
|
||||
</ProcessTooltip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -49,7 +49,10 @@ export const PostureCheckPolicyUsageCell = ({ check }: Props) => {
|
||||
interactive={false}
|
||||
>
|
||||
<Badge
|
||||
onClick={() => router.push("/access-control")}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
router.push("/access-control");
|
||||
}}
|
||||
variant={"gray"}
|
||||
useHover={!!(check.policies && check.policies?.length > 0)}
|
||||
className={cn(
|
||||
|
||||
@@ -2,9 +2,13 @@ import { TabsTrigger } from "@components/Tabs";
|
||||
import { ShieldCheck } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
export const PostureCheckTabTrigger = () => {
|
||||
type Props = {
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export const PostureCheckTabTrigger = ({ disabled = false }: Props) => {
|
||||
return (
|
||||
<TabsTrigger value={"posture_checks"}>
|
||||
<TabsTrigger value={"posture_checks"} disabled={disabled}>
|
||||
<ShieldCheck size={16} />
|
||||
Posture Checks
|
||||
</TabsTrigger>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,15 +1,23 @@
|
||||
import { DomainListBadge } from "@components/ui/DomainListBadge";
|
||||
import { IconDirectionSign } from "@tabler/icons-react";
|
||||
import { InfoIcon } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { ExitNodeHelpTooltip } from "@/modules/exit-node/ExitNodeHelpTooltip";
|
||||
|
||||
type Props = {
|
||||
network: string;
|
||||
network?: string;
|
||||
domains?: string[];
|
||||
};
|
||||
export default function GroupedRouteNetworkRangeCell({ network }: Props) {
|
||||
export default function GroupedRouteNetworkRangeCell({
|
||||
network,
|
||||
domains,
|
||||
}: Props) {
|
||||
const isExitNode = network === "0.0.0.0/0";
|
||||
const hasDomains = domains ? domains.length > 0 : false;
|
||||
|
||||
return isExitNode ? (
|
||||
return hasDomains && domains ? (
|
||||
<DomainListBadge domains={domains} />
|
||||
) : isExitNode ? (
|
||||
<ExitNodeHelpTooltip>
|
||||
<div className={"flex gap-2 items-center dark:text-nb-gray-300 group"}>
|
||||
<IconDirectionSign size={16} className={"text-yellow-400"} />
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -39,6 +40,14 @@ export const GroupedRouteTableColumns: ColumnDef<GroupedRoute>[] = [
|
||||
accessorKey: "description",
|
||||
sortingFn: "text",
|
||||
},
|
||||
{
|
||||
accessorKey: "description_search",
|
||||
sortingFn: "text",
|
||||
},
|
||||
{
|
||||
accessorKey: "domain_search",
|
||||
sortingFn: "text",
|
||||
},
|
||||
{
|
||||
id: "enabled",
|
||||
accessorKey: "enabled",
|
||||
@@ -50,13 +59,22 @@ export const GroupedRouteTableColumns: ColumnDef<GroupedRoute>[] = [
|
||||
return row.group_names?.map((name) => name).join(", ");
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "domains",
|
||||
accessorFn: (row) => {
|
||||
return row.domains?.map((name) => name).join(", ");
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "network",
|
||||
header: ({ column }) => {
|
||||
return <DataTableHeader column={column}>Network Range</DataTableHeader>;
|
||||
return <DataTableHeader column={column}>Network</DataTableHeader>;
|
||||
},
|
||||
cell: ({ row }) => (
|
||||
<GroupedRouteNetworkRangeCell network={row.original.network} />
|
||||
<GroupedRouteNetworkRangeCell
|
||||
network={row.original.network}
|
||||
domains={row.original?.domains}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -121,127 +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,
|
||||
group_names: 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -53,15 +53,26 @@ export default function useGroupedRoutes({ routes }: Props) {
|
||||
});
|
||||
|
||||
const allGroupNames = [...peerGroupNames, ...distributionGroupNames];
|
||||
const hasDomains = routes[0].domains
|
||||
? routes[0].domains.length > 0
|
||||
: false;
|
||||
|
||||
const childDescriptions =
|
||||
routes?.map((r) => r?.description).join(", ") || "";
|
||||
const domainString = routes?.map((r) => r.domains?.join(", ")).join(", ");
|
||||
|
||||
results.push({
|
||||
id,
|
||||
enabled: routes.find((r) => r.enabled) != undefined,
|
||||
network: routes[0].network,
|
||||
network: !hasDomains ? routes[0].network : undefined,
|
||||
domains: hasDomains ? routes[0].domains || undefined : undefined,
|
||||
domain_search: domainString,
|
||||
keep_route: routes[0].keep_route || false,
|
||||
network_id: routes[0].network_id,
|
||||
high_availability_count: allPeers,
|
||||
is_using_route_groups: !!groupPeerRoute,
|
||||
description: groupPeerRoute ? groupPeerRoute?.description : undefined,
|
||||
description_search: childDescriptions,
|
||||
routes: routes,
|
||||
group_names: allGroupNames,
|
||||
});
|
||||
|
||||
@@ -89,6 +89,13 @@ function Content({ onSuccess, groupedRoute, peer }: ModalProps) {
|
||||
.map((g) => g.id)
|
||||
.filter((id) => id !== undefined) as string[];
|
||||
|
||||
let useRange = false;
|
||||
if (routeNetwork?.domains) {
|
||||
useRange = routeNetwork.domains.length <= 0;
|
||||
} else {
|
||||
useRange = true;
|
||||
}
|
||||
|
||||
createRoute(
|
||||
{
|
||||
network_id: routeNetwork.network_id,
|
||||
@@ -96,7 +103,9 @@ function Content({ onSuccess, groupedRoute, peer }: ModalProps) {
|
||||
enabled: true,
|
||||
peer: routingPeer?.id || undefined,
|
||||
peer_groups: undefined,
|
||||
network: routeNetwork.network,
|
||||
network: useRange ? routeNetwork.network : undefined,
|
||||
domains: useRange ? undefined : routeNetwork.domains,
|
||||
keep_route: routeNetwork.keep_route || false,
|
||||
metric: 9999,
|
||||
masquerade: true,
|
||||
groups: groupIds,
|
||||
@@ -139,7 +148,7 @@ function Content({ onSuccess, groupedRoute, peer }: ModalProps) {
|
||||
<div>
|
||||
<Label>Routing Peer</Label>
|
||||
<HelpText>
|
||||
Assign a single peer as a routing peer for the Network CIDR.
|
||||
Assign a single peer as a routing peer for the network route.
|
||||
</HelpText>
|
||||
<PeerSelector
|
||||
onChange={setRoutingPeer}
|
||||
|
||||
46
src/modules/routes/RouteAddRoutingPeerProvider.tsx
Normal file
46
src/modules/routes/RouteAddRoutingPeerProvider.tsx
Normal 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;
|
||||
};
|
||||
@@ -1,7 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import Button from "@components/Button";
|
||||
import ButtonGroup from "@components/ButtonGroup";
|
||||
import FancyToggleSwitch from "@components/FancyToggleSwitch";
|
||||
import FullTooltip from "@components/FullTooltip";
|
||||
import HelpText from "@components/HelpText";
|
||||
import InlineLink from "@components/InlineLink";
|
||||
import { Input } from "@components/Input";
|
||||
@@ -20,24 +22,29 @@ import { PeerSelector } from "@components/PeerSelector";
|
||||
import { SegmentedTabs } from "@components/SegmentedTabs";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@components/Tabs";
|
||||
import { Textarea } from "@components/Textarea";
|
||||
import InputDomain, { domainReducer } from "@components/ui/InputDomain";
|
||||
import { IconDirectionSign } from "@tabler/icons-react";
|
||||
import { cn } from "@utils/helpers";
|
||||
import cidr from "ip-cidr";
|
||||
import { uniqBy } from "lodash";
|
||||
import {
|
||||
ArrowDownWideNarrow,
|
||||
CircleHelp,
|
||||
ExternalLinkIcon,
|
||||
FolderGit2,
|
||||
GlobeIcon,
|
||||
GlobeLockIcon,
|
||||
MonitorSmartphoneIcon,
|
||||
NetworkIcon,
|
||||
PlusCircle,
|
||||
PlusIcon,
|
||||
Power,
|
||||
RouteIcon,
|
||||
Settings2,
|
||||
Text,
|
||||
VenetianMask,
|
||||
} from "lucide-react";
|
||||
import React, { useMemo, useRef, useState } from "react";
|
||||
import React, { useEffect, useMemo, useReducer, useRef, useState } from "react";
|
||||
import NetworkRoutesIcon from "@/assets/icons/NetworkRoutesIcon";
|
||||
import { useRoutes } from "@/contexts/RoutesProvider";
|
||||
import { Peer } from "@/interfaces/Peer";
|
||||
@@ -102,6 +109,32 @@ export function RouteModalContent({
|
||||
initial: [],
|
||||
});
|
||||
|
||||
/**
|
||||
* DNS Routes
|
||||
* IP Range or Domain Tab = ip-range or domains
|
||||
*/
|
||||
const [domainRoutes, setDomainRoutes] = useReducer(domainReducer, []);
|
||||
const [domainError, setDomainError] = useState<boolean>(false);
|
||||
const [routeType, setRouteTyp] = useState<string>("ip-range");
|
||||
const [keepRoute, setKeepRoute] = useState<boolean>(true);
|
||||
|
||||
const isMasqueradeDisabled = useMemo(() => {
|
||||
if (exitNode) return true;
|
||||
return routeType === "domains";
|
||||
}, [exitNode, routeType]);
|
||||
|
||||
const isDomainOrRangeEntered = useMemo(() => {
|
||||
if (routeType === "ip-range") return networkRange !== "";
|
||||
const isEmptyDomain = domainRoutes.some((d) => d.name === "");
|
||||
const isAtLeastOneDomain = domainRoutes.length > 0;
|
||||
return !isEmptyDomain && isAtLeastOneDomain && !domainError;
|
||||
}, [domainRoutes, routeType, networkRange, domainError]);
|
||||
|
||||
// Enable Masquerade if domain route type is selected
|
||||
useEffect(() => {
|
||||
if (routeType === "domains") setMasquerade(true);
|
||||
}, [routeType]);
|
||||
|
||||
/**
|
||||
* Distribution Groups
|
||||
*/
|
||||
@@ -142,6 +175,11 @@ export function RouteModalContent({
|
||||
.filter((g) => g !== undefined) as string[];
|
||||
|
||||
const useSinglePeer = peerTab === "routing-peer";
|
||||
const domainRouteNames =
|
||||
routeType === "domains"
|
||||
? domainRoutes.map((d) => d.name).filter((d) => d !== "")
|
||||
: undefined;
|
||||
const useKeepRoute = routeType === "domains" ? keepRoute : undefined;
|
||||
|
||||
createRoute(
|
||||
{
|
||||
@@ -150,7 +188,9 @@ export function RouteModalContent({
|
||||
enabled: enabled,
|
||||
peer: useSinglePeer ? routingPeer?.id : undefined,
|
||||
peer_groups: useSinglePeer ? undefined : peerGroups || undefined,
|
||||
network: networkRange,
|
||||
network: routeType === "ip-range" ? networkRange : undefined,
|
||||
domains: domainRouteNames,
|
||||
keep_route: useKeepRoute,
|
||||
metric: Number(metric) || 9999,
|
||||
masquerade: masquerade,
|
||||
groups: groupIds,
|
||||
@@ -184,7 +224,7 @@ export function RouteModalContent({
|
||||
(peerTab === "peer-group" && routingPeerGroups.length == 0) ||
|
||||
(peerTab === "routing-peer" && !routingPeer) ||
|
||||
groups.length == 0 ||
|
||||
networkRange == ""
|
||||
!isDomainOrRangeEntered
|
||||
);
|
||||
}, [
|
||||
cidrError,
|
||||
@@ -192,7 +232,7 @@ export function RouteModalContent({
|
||||
routingPeerGroups.length,
|
||||
routingPeer,
|
||||
groups,
|
||||
networkRange,
|
||||
isDomainOrRangeEntered,
|
||||
]);
|
||||
|
||||
const networkIdentifierError = useMemo(() => {
|
||||
@@ -228,7 +268,7 @@ export function RouteModalContent({
|
||||
title={
|
||||
exitNode
|
||||
? isFirstExitNode
|
||||
? "Setup Exit Node"
|
||||
? "Set Up Exit Node"
|
||||
: "Add Exit Node"
|
||||
: "Create New Route"
|
||||
}
|
||||
@@ -286,18 +326,136 @@ export function RouteModalContent({
|
||||
<TabsContent value={"network"} className={"pb-8"}>
|
||||
<div className={"px-8 flex-col flex gap-6"}>
|
||||
<div className={cn(exitNode && "hidden")}>
|
||||
<Label>Network Range</Label>
|
||||
<HelpText>Add a private IP address range</HelpText>
|
||||
<Input
|
||||
ref={networkRangeRef}
|
||||
customPrefix={<NetworkIcon size={16} />}
|
||||
placeholder={"e.g., 172.16.0.0/16"}
|
||||
value={networkRange}
|
||||
className={"font-mono !text-[13px]"}
|
||||
error={cidrError}
|
||||
onChange={(e) => setNetworkRange(e.target.value)}
|
||||
/>
|
||||
<Label>Route Type</Label>
|
||||
<HelpText>
|
||||
Select your route type to add either a network range or a list
|
||||
of domains.
|
||||
</HelpText>
|
||||
<div className={"flex justify-between items-center w-full"}>
|
||||
<ButtonGroup className={"w-full"}>
|
||||
<ButtonGroup.Button
|
||||
variant={routeType == "ip-range" ? "tertiary" : "secondary"}
|
||||
onClick={() => setRouteTyp("ip-range")}
|
||||
className={"w-full"}
|
||||
>
|
||||
<NetworkIcon size={16} />
|
||||
Network Range
|
||||
</ButtonGroup.Button>
|
||||
<ButtonGroup.Button
|
||||
variant={routeType == "domains" ? "tertiary" : "secondary"}
|
||||
onClick={() => setRouteTyp("domains")}
|
||||
className={"w-full"}
|
||||
>
|
||||
<GlobeIcon size={16} />
|
||||
Domains
|
||||
</ButtonGroup.Button>
|
||||
</ButtonGroup>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"mt-5 mb-3",
|
||||
routeType !== "ip-range" && "hidden",
|
||||
)}
|
||||
>
|
||||
<Label>Network Range</Label>
|
||||
<HelpText>Add a private IPv4 address range</HelpText>
|
||||
<Input
|
||||
ref={networkRangeRef}
|
||||
customPrefix={<NetworkIcon size={16} />}
|
||||
placeholder={"e.g., 172.16.0.0/16"}
|
||||
value={networkRange}
|
||||
className={"font-mono !text-[13px]"}
|
||||
error={cidrError}
|
||||
onChange={(e) => setNetworkRange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={cn("mt-5 mb-3", routeType !== "domains" && "hidden")}
|
||||
>
|
||||
<Label>Domains</Label>
|
||||
<HelpText>
|
||||
Add domains that dynamically resolve to one or more IPv4
|
||||
addresses
|
||||
</HelpText>
|
||||
<div>
|
||||
{domainRoutes.length > 0 && (
|
||||
<div className={"flex gap-3 w-full mb-3"}>
|
||||
<div className={"flex flex-col gap-2 w-full"}>
|
||||
{domainRoutes.map((domain, i) => {
|
||||
return (
|
||||
<InputDomain
|
||||
key={domain.id}
|
||||
value={domain}
|
||||
onChange={(d) =>
|
||||
setDomainRoutes({
|
||||
type: "UPDATE",
|
||||
index: i,
|
||||
d,
|
||||
})
|
||||
}
|
||||
onError={setDomainError}
|
||||
onRemove={() =>
|
||||
setDomainRoutes({
|
||||
type: "REMOVE",
|
||||
index: i,
|
||||
})
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
variant={"dotted"}
|
||||
className={"w-full"}
|
||||
size={"sm"}
|
||||
onClick={() => setDomainRoutes({ type: "ADD" })}
|
||||
>
|
||||
<PlusIcon size={14} />
|
||||
Add Domain
|
||||
</Button>
|
||||
</div>
|
||||
<div className={cn("mt-6 w-full")}>
|
||||
<FullTooltip
|
||||
side={"top"}
|
||||
content={
|
||||
<div className={"text-xs max-w-xs"}>
|
||||
DNS records for load-balanced systems often change.
|
||||
Keeping resolved addresses ensures ongoing connections
|
||||
to active resources remain uninterrupted.
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<FancyToggleSwitch
|
||||
value={keepRoute}
|
||||
onChange={setKeepRoute}
|
||||
label={
|
||||
<>
|
||||
<div className={"flex gap-2"}>
|
||||
<GlobeLockIcon size={14} />
|
||||
Keep Routes
|
||||
<CircleHelp
|
||||
size={12}
|
||||
className={"top-[1px] relative text-nb-gray-300"}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
helpText={
|
||||
<div>
|
||||
Retain previously resolved routes after IP address
|
||||
updates to maintain stable connections.
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</FullTooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{exitNode && peer ? (
|
||||
<></>
|
||||
) : (
|
||||
@@ -317,7 +475,7 @@ export function RouteModalContent({
|
||||
<div>
|
||||
<HelpText>
|
||||
Assign a single peer as a routing peer for the
|
||||
{exitNode ? " exit node." : " Network CIDR."}
|
||||
{exitNode ? " exit node." : " network route."}
|
||||
</HelpText>
|
||||
<PeerSelector
|
||||
onChange={setRoutingPeer}
|
||||
@@ -330,7 +488,7 @@ export function RouteModalContent({
|
||||
<div>
|
||||
<HelpText>
|
||||
Assign a peer group with Linux machines to be used as
|
||||
{exitNode ? " exit nodes." : "routing peers."}
|
||||
{exitNode ? " exit nodes." : " routing peers."}
|
||||
</HelpText>
|
||||
<PeerGroupSelector
|
||||
max={1}
|
||||
@@ -419,9 +577,9 @@ export function RouteModalContent({
|
||||
|
||||
<div className={cn("flex justify-between")}>
|
||||
<div>
|
||||
<Label>Metrics</Label>
|
||||
<Label>Metric</Label>
|
||||
<HelpText className={"max-w-[200px]"}>
|
||||
Lower metrics indicating higher priority routes.
|
||||
A lower metric indicates higher priority routes.
|
||||
</HelpText>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@ import RouteActiveCell from "@/modules/routes/RouteActiveCell";
|
||||
import RouteDistributionGroupsCell from "@/modules/routes/RouteDistributionGroupsCell";
|
||||
import RouteMetricCell from "@/modules/routes/RouteMetricCell";
|
||||
import RoutePeerCell from "@/modules/routes/RoutePeerCell";
|
||||
import RouteUpdateModal from "@/modules/routes/RouteUpdateModal";
|
||||
|
||||
type Props = {
|
||||
row: GroupedRoute;
|
||||
@@ -23,6 +22,20 @@ export const RouteTableColumns: ColumnDef<Route>[] = [
|
||||
sortingFn: "text",
|
||||
cell: ({ row }) => <RoutePeerCell route={row.original} />,
|
||||
},
|
||||
{
|
||||
accessorKey: "description",
|
||||
sortingFn: "text",
|
||||
},
|
||||
{
|
||||
accessorKey: "domain_search",
|
||||
sortingFn: "text",
|
||||
},
|
||||
{
|
||||
id: "domains",
|
||||
accessorFn: (row) => {
|
||||
return row.domains?.map((name) => name).join(", ");
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "metric",
|
||||
header: ({ column }) => {
|
||||
@@ -78,10 +91,6 @@ export default function RouteTable({ row }: Props) {
|
||||
},
|
||||
]);
|
||||
|
||||
const [editModal, setEditModal] = useState(false);
|
||||
const [currentRow, setCurrentRow] = useState<Route>();
|
||||
const [currentCellClicked, setCurrentCellClicked] = useState("");
|
||||
|
||||
const data = useMemo(() => {
|
||||
if (!row.routes) return [];
|
||||
// Get the group names for better search results
|
||||
@@ -95,23 +104,17 @@ export default function RouteTable({ row }: Props) {
|
||||
return groups?.find((g) => g.id === id)?.name || "";
|
||||
}) || [];
|
||||
const allGroupNames = [...distributionGroupNames, ...peerGroupNames];
|
||||
const domainString = route?.domains?.join(", ") || "";
|
||||
return {
|
||||
...route,
|
||||
group_names: allGroupNames,
|
||||
domain_search: domainString,
|
||||
} as Route;
|
||||
});
|
||||
}, [row.routes, groups]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{editModal && currentRow && (
|
||||
<RouteUpdateModal
|
||||
route={currentRow}
|
||||
open={editModal}
|
||||
onOpenChange={setEditModal}
|
||||
cell={currentCellClicked}
|
||||
/>
|
||||
)}
|
||||
<DataTable
|
||||
tableClassName={"mt-0"}
|
||||
minimal={true}
|
||||
@@ -122,11 +125,9 @@ export default function RouteTable({ row }: Props) {
|
||||
sorting={sorting}
|
||||
columnVisibility={{
|
||||
group_names: false,
|
||||
}}
|
||||
onRowClick={(row, cell) => {
|
||||
setCurrentRow(row.original);
|
||||
setEditModal(true);
|
||||
setCurrentCellClicked(cell);
|
||||
description: false,
|
||||
domains: false,
|
||||
domain_search: false,
|
||||
}}
|
||||
setSorting={setSorting}
|
||||
columns={RouteTableColumns}
|
||||
|
||||
@@ -18,6 +18,7 @@ import { PeerGroupSelector } from "@components/PeerGroupSelector";
|
||||
import { PeerSelector } from "@components/PeerSelector";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@components/Tabs";
|
||||
import { Textarea } from "@components/Textarea";
|
||||
import { DomainsTooltip } from "@components/ui/DomainListBadge";
|
||||
import { cn } from "@utils/helpers";
|
||||
import { uniqBy } from "lodash";
|
||||
import {
|
||||
@@ -84,6 +85,23 @@ function RouteUpdateModalContent({ onSuccess, route, cell }: ModalProps) {
|
||||
// General
|
||||
const [description, setDescription] = useState(route.description || "");
|
||||
|
||||
const isExitNode = useMemo(() => {
|
||||
return route?.network === "0.0.0.0/0";
|
||||
}, [route]);
|
||||
|
||||
const isUsingDomains = useMemo(() => {
|
||||
try {
|
||||
return route?.domains && route.domains.length > 0;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}, [route]);
|
||||
|
||||
const routeType = useMemo(() => {
|
||||
if (isUsingDomains) return "domains";
|
||||
return "ip-range";
|
||||
}, [isUsingDomains]);
|
||||
|
||||
// Network
|
||||
const [routingPeer, setRoutingPeer] = useState<Peer | undefined>(() => {
|
||||
if (route.peer && peers) {
|
||||
@@ -92,6 +110,11 @@ function RouteUpdateModalContent({ onSuccess, route, cell }: ModalProps) {
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const isMasqueradeDisabled = useMemo(() => {
|
||||
if (isExitNode) return true;
|
||||
return routeType === "domains";
|
||||
}, [isExitNode, routeType]);
|
||||
|
||||
const initialRoutingPeerGroups = useMemo(() => {
|
||||
if (!route) return [];
|
||||
if (route?.peer_groups && allGroups) {
|
||||
@@ -217,14 +240,36 @@ function RouteUpdateModalContent({ onSuccess, route, cell }: ModalProps) {
|
||||
cell && cell == "metric" ? "settings" : "network",
|
||||
);
|
||||
|
||||
const routeInfo = useMemo(() => {
|
||||
let hasDomains = route?.domains ? route.domains.length > 0 : false;
|
||||
try {
|
||||
if (hasDomains && route?.domains) {
|
||||
return route?.domains.join(", ");
|
||||
} else {
|
||||
return route.network;
|
||||
}
|
||||
} catch (e) {
|
||||
return route.network;
|
||||
}
|
||||
}, [route]);
|
||||
|
||||
return (
|
||||
<ModalContent maxWidthClass={"max-w-xl"}>
|
||||
<ModalHeader
|
||||
icon={<NetworkRoutesIcon className={"fill-netbird"} />}
|
||||
title={"Update " + route.network_id}
|
||||
description={route.network}
|
||||
description={routeInfo}
|
||||
color={"netbird"}
|
||||
/>
|
||||
truncate={true}
|
||||
>
|
||||
{route?.domains && (
|
||||
<DomainsTooltip domains={route.domains} className={"block"}>
|
||||
<Paragraph className={cn("text-sm", "!block truncate")}>
|
||||
{routeInfo}
|
||||
</Paragraph>
|
||||
</DomainsTooltip>
|
||||
)}
|
||||
</ModalHeader>
|
||||
|
||||
<Tabs defaultValue={tab} onValueChange={(v) => setTab(v)}>
|
||||
<TabsList justify={"start"} className={"px-8"}>
|
||||
@@ -269,7 +314,8 @@ function RouteUpdateModalContent({ onSuccess, route, cell }: ModalProps) {
|
||||
<div>
|
||||
<Label>Routing Peer</Label>
|
||||
<HelpText>
|
||||
Assign a single peer as a routing peer for the Network CIDR.
|
||||
Assign a single peer as a routing peer for the
|
||||
{isExitNode ? " exit node." : " network route."}
|
||||
</HelpText>
|
||||
<PeerSelector
|
||||
onChange={setRoutingPeer}
|
||||
@@ -281,8 +327,8 @@ function RouteUpdateModalContent({ onSuccess, route, cell }: ModalProps) {
|
||||
<div>
|
||||
<Label>Peer Group</Label>
|
||||
<HelpText>
|
||||
Assign peer group with Linux machines to be used as routing
|
||||
peers.
|
||||
Assign a peer group with Linux machines to be used as
|
||||
{isExitNode ? " exit nodes." : "routing peers."}
|
||||
</HelpText>
|
||||
<PeerGroupSelector
|
||||
max={1}
|
||||
@@ -333,24 +379,26 @@ function RouteUpdateModalContent({ onSuccess, route, cell }: ModalProps) {
|
||||
}
|
||||
helpText={"Use this switch to enable or disable the route."}
|
||||
/>
|
||||
<FancyToggleSwitch
|
||||
value={masquerade}
|
||||
onChange={setMasquerade}
|
||||
label={
|
||||
<>
|
||||
<VenetianMask size={15} />
|
||||
Masquerade
|
||||
</>
|
||||
}
|
||||
helpText={
|
||||
"Allow access to your private networks without configuring routes on your local routers or other devices."
|
||||
}
|
||||
/>
|
||||
{!isExitNode && (
|
||||
<FancyToggleSwitch
|
||||
value={masquerade}
|
||||
onChange={setMasquerade}
|
||||
label={
|
||||
<>
|
||||
<VenetianMask size={15} />
|
||||
Masquerade
|
||||
</>
|
||||
}
|
||||
helpText={
|
||||
"Allow access to your private networks without configuring routes on your local routers or other devices."
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<div className={cn("flex justify-between")}>
|
||||
<div>
|
||||
<Label>Metrics</Label>
|
||||
<Label>Metric</Label>
|
||||
<HelpText className={"max-w-[200px]"}>
|
||||
Lower metrics indicating higher priority routes.
|
||||
A lower metric indicates a higher priority route.
|
||||
</HelpText>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -99,6 +99,7 @@ export default function AuthenticationTab({ account }: Props) {
|
||||
.put({
|
||||
id: account.id,
|
||||
settings: {
|
||||
...account.settings,
|
||||
peer_login_expiration_enabled: loginExpiration,
|
||||
peer_login_expiration: loginExpiration ? expiration : 86400,
|
||||
extra: {
|
||||
|
||||
@@ -108,10 +108,8 @@ export default function GroupsTab({ account }: Props) {
|
||||
.put({
|
||||
id: account.id,
|
||||
settings: {
|
||||
...account.settings,
|
||||
groups_propagation_enabled: groupsPropagation,
|
||||
peer_login_expiration_enabled:
|
||||
account.settings.peer_login_expiration_enabled,
|
||||
peer_login_expiration: account.settings.peer_login_expiration,
|
||||
jwt_groups_enabled: jwtGroupSync,
|
||||
jwt_groups_claim_name: isEmpty(jwtGroupsClaimName)
|
||||
? undefined
|
||||
|
||||
@@ -33,15 +33,8 @@ export default function PermissionsTab({ account }: Props) {
|
||||
.put({
|
||||
id: account.id,
|
||||
settings: {
|
||||
...account.settings,
|
||||
regular_users_view_blocked: userViewBlocked,
|
||||
groups_propagation_enabled:
|
||||
account.settings?.groups_propagation_enabled,
|
||||
peer_login_expiration_enabled:
|
||||
account.settings?.peer_login_expiration_enabled,
|
||||
peer_login_expiration: account.settings?.peer_login_expiration,
|
||||
jwt_groups_enabled: account.settings?.jwt_groups_enabled,
|
||||
jwt_groups_claim_name: account.settings?.jwt_groups_claim_name,
|
||||
jwt_allow_groups: account.settings?.jwt_allow_groups,
|
||||
},
|
||||
})
|
||||
.then(() => {
|
||||
|
||||
37
src/modules/setup-keys/SetupKeyEphemeralCell.tsx
Normal file
37
src/modules/setup-keys/SetupKeyEphemeralCell.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import Badge from "@components/Badge";
|
||||
import FullTooltip from "@components/FullTooltip";
|
||||
import { cn } from "@utils/helpers";
|
||||
import { HelpCircle } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import EmptyRow from "@/modules/common-table-rows/EmptyRow";
|
||||
|
||||
type Props = {
|
||||
ephemeral: boolean;
|
||||
};
|
||||
export default function SetupKeyEphemeralCell({ ephemeral }: Props) {
|
||||
return ephemeral ? (
|
||||
<FullTooltip
|
||||
interactive={false}
|
||||
content={
|
||||
<div className={"max-w-xs text-xs"}>
|
||||
Peers that are offline for over 10 minutes will be removed
|
||||
automatically.
|
||||
</div>
|
||||
}
|
||||
disabled={!ephemeral}
|
||||
>
|
||||
<Badge variant={"gray"}>
|
||||
<span
|
||||
className={cn(
|
||||
"h-2 w-2 rounded-full mr-0.5",
|
||||
ephemeral ? "bg-yellow-500" : "bg-nb-gray-400",
|
||||
)}
|
||||
></span>
|
||||
Ephemeral
|
||||
<HelpCircle size={12} />
|
||||
</Badge>
|
||||
</FullTooltip>
|
||||
) : (
|
||||
<EmptyRow />
|
||||
);
|
||||
}
|
||||
@@ -46,6 +46,8 @@ export default function SetupKeyGroupsCell({ setupKey }: Props) {
|
||||
}
|
||||
groups={setupKey.auto_groups || []}
|
||||
onSave={handleSave}
|
||||
hideAllGroup={true}
|
||||
showAddGroupButton={true}
|
||||
modal={modal}
|
||||
setModal={setModal}
|
||||
/>
|
||||
|
||||
@@ -87,7 +87,11 @@ export default function SetupKeyModal({ children, open, setOpen }: Props) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={"px-8 pb-6"}>
|
||||
<div
|
||||
className={"px-8 pb-6"}
|
||||
data-cy={"setup-key-copy-input"}
|
||||
data-cy-setup-key-value={setupKey?.key || ""}
|
||||
>
|
||||
<Code message={copyMessage}>
|
||||
<Code.Line>
|
||||
{setupKey?.key || "Setup key could not be created..."}
|
||||
@@ -101,6 +105,7 @@ export default function SetupKeyModal({ children, open, setOpen }: Props) {
|
||||
variant={"secondary"}
|
||||
className={"w-full"}
|
||||
tabIndex={-1}
|
||||
data-cy={"setup-key-close"}
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
@@ -108,6 +113,7 @@ export default function SetupKeyModal({ children, open, setOpen }: Props) {
|
||||
<Button
|
||||
variant={"primary"}
|
||||
className={"w-full"}
|
||||
data-cy={"setup-key-copy"}
|
||||
onClick={() => copy(copyMessage)}
|
||||
>
|
||||
<CopyIcon size={14} />
|
||||
@@ -202,6 +208,7 @@ export function SetupKeyModalContent({ onSuccess }: ModalProps) {
|
||||
<Input
|
||||
placeholder={"e.g., AWS Servers"}
|
||||
value={name}
|
||||
data-cy={"setup-key-name"}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
@@ -233,6 +240,7 @@ export function SetupKeyModalContent({ onSuccess }: ModalProps) {
|
||||
disabled={!reusable}
|
||||
value={usageLimit}
|
||||
type={"number"}
|
||||
data-cy={"setup-key-usage-limit"}
|
||||
onChange={(e) => setUsageLimit(e.target.value)}
|
||||
placeholder={usageLimitPlaceholder}
|
||||
customPrefix={
|
||||
@@ -256,6 +264,7 @@ export function SetupKeyModalContent({ onSuccess }: ModalProps) {
|
||||
error={expiresInError}
|
||||
errorTooltip={true}
|
||||
type={"number"}
|
||||
data-cy={"setup-key-expire-in-days"}
|
||||
onChange={(e) => setExpiresIn(e.target.value)}
|
||||
customPrefix={
|
||||
<AlarmClock size={16} className={"text-nb-gray-300"} />
|
||||
@@ -288,6 +297,7 @@ export function SetupKeyModalContent({ onSuccess }: ModalProps) {
|
||||
<PeerGroupSelector
|
||||
onChange={setSelectedGroups}
|
||||
values={selectedGroups}
|
||||
hideAllGroup={true}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -312,7 +322,12 @@ export function SetupKeyModalContent({ onSuccess }: ModalProps) {
|
||||
<Button variant={"secondary"}>Cancel</Button>
|
||||
</ModalClose>
|
||||
|
||||
<Button variant={"primary"} onClick={submit} disabled={isDisabled}>
|
||||
<Button
|
||||
variant={"primary"}
|
||||
onClick={submit}
|
||||
disabled={isDisabled}
|
||||
data-cy={"create-setup-key"}
|
||||
>
|
||||
<PlusCircle size={16} />
|
||||
Create Setup Key
|
||||
</Button>
|
||||
|
||||
@@ -3,7 +3,20 @@ import ActiveInactiveRow from "@/modules/common-table-rows/ActiveInactiveRow";
|
||||
type Props = {
|
||||
name: string;
|
||||
valid: boolean;
|
||||
secret?: string;
|
||||
};
|
||||
export default function SetupKeyNameCell({ valid, name }: Props) {
|
||||
return <ActiveInactiveRow active={valid} inactiveDot={"red"} text={name} />;
|
||||
export default function SetupKeyNameCell({ name, valid, secret }: Props) {
|
||||
return (
|
||||
<ActiveInactiveRow
|
||||
active={valid || false}
|
||||
inactiveDot={"red"}
|
||||
text={name || ""}
|
||||
>
|
||||
{secret && (
|
||||
<span className={"font-mono text-xs text-nb-gray-400 mt-1"}>
|
||||
{secret.substring(0, 5) + "****"}
|
||||
</span>
|
||||
)}
|
||||
</ActiveInactiveRow>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { MonitorSmartphoneIcon } from "lucide-react";
|
||||
import { IconRepeat } from "@tabler/icons-react";
|
||||
import { Repeat1 } from "lucide-react";
|
||||
|
||||
type Props = {
|
||||
current: number;
|
||||
@@ -7,14 +8,16 @@ type Props = {
|
||||
};
|
||||
export default function SetupKeyUsageCell({ current, limit, reusable }: Props) {
|
||||
return reusable ? (
|
||||
<div className={"flex gap-1 flex-col"}>
|
||||
<div className={"flex items-center gap-2"}>
|
||||
<MonitorSmartphoneIcon size={14} />
|
||||
{current} of {limit} Peers
|
||||
</div>
|
||||
<div></div>
|
||||
<div className={"flex items-center text-[13px] text-nb-gray-300 gap-2"}>
|
||||
<IconRepeat size={14} className={"text-green-400"} />
|
||||
<span>
|
||||
<span className={"font-medium text-nb-gray-200"}> {current} </span> of{" "}
|
||||
{limit == 0 ? <>Unlimited</> : limit} Peers
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className={"text-nb-gray-800"}>-</div>
|
||||
<div className={"flex items-center text-[13px] text-nb-gray-300 gap-2"}>
|
||||
<Repeat1 size={14} /> One-off
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,10 +3,11 @@ import ButtonGroup from "@components/ButtonGroup";
|
||||
import InlineLink from "@components/InlineLink";
|
||||
import SquareIcon from "@components/SquareIcon";
|
||||
import { DataTable } from "@components/table/DataTable";
|
||||
import DataTableHeader from "@components/table/DataTableHeader";
|
||||
import DataTableRefreshButton from "@components/table/DataTableRefreshButton";
|
||||
import { DataTableRowsPerPage } from "@components/table/DataTableRowsPerPage";
|
||||
import GetStartedTest from "@components/ui/GetStartedTest";
|
||||
import { SortingState } from "@tanstack/react-table";
|
||||
import { ColumnDef, SortingState } from "@tanstack/react-table";
|
||||
import { ExternalLinkIcon, PlusCircle } from "lucide-react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import React, { useState } from "react";
|
||||
@@ -14,8 +15,96 @@ import { useSWRConfig } from "swr";
|
||||
import SetupKeysIcon from "@/assets/icons/SetupKeysIcon";
|
||||
import { useLocalStorage } from "@/hooks/useLocalStorage";
|
||||
import { SetupKey } from "@/interfaces/SetupKey";
|
||||
import ExpirationDateRow from "@/modules/common-table-rows/ExpirationDateRow";
|
||||
import LastTimeRow from "@/modules/common-table-rows/LastTimeRow";
|
||||
import SetupKeyActionCell from "@/modules/setup-keys/SetupKeyActionCell";
|
||||
import SetupKeyEphemeralCell from "@/modules/setup-keys/SetupKeyEphemeralCell";
|
||||
import SetupKeyGroupsCell from "@/modules/setup-keys/SetupKeyGroupsCell";
|
||||
import SetupKeyModal from "@/modules/setup-keys/SetupKeyModal";
|
||||
import { SetupKeysTableColumns } from "@/modules/setup-keys/SetupKeysTableColumns";
|
||||
import SetupKeyNameCell from "@/modules/setup-keys/SetupKeyNameCell";
|
||||
import SetupKeyUsageCell from "@/modules/setup-keys/SetupKeyUsageCell";
|
||||
|
||||
export const SetupKeysTableColumns: ColumnDef<SetupKey>[] = [
|
||||
{
|
||||
accessorKey: "name",
|
||||
header: ({ column }) => {
|
||||
return <DataTableHeader column={column}>Name & Key</DataTableHeader>;
|
||||
},
|
||||
sortingFn: "text",
|
||||
cell: ({ row }) => (
|
||||
<SetupKeyNameCell
|
||||
name={row.original.name}
|
||||
valid={row.original.valid}
|
||||
secret={row.original.key}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "valid",
|
||||
accessorKey: "valid",
|
||||
sortingFn: "basic",
|
||||
},
|
||||
{
|
||||
accessorKey: "usage_limit",
|
||||
header: ({ column }) => {
|
||||
return <DataTableHeader column={column}>Usage</DataTableHeader>;
|
||||
},
|
||||
cell: ({ row }) => (
|
||||
<SetupKeyUsageCell
|
||||
current={row.original.used_times}
|
||||
limit={row.original.usage_limit || 0}
|
||||
reusable={row.original.type == "reusable"}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "last_used",
|
||||
header: ({ column }) => {
|
||||
return <DataTableHeader column={column}>Last used</DataTableHeader>;
|
||||
},
|
||||
sortingFn: "datetime",
|
||||
cell: ({ row }) => (
|
||||
<LastTimeRow date={row.original.last_used} text={"Last used on"} />
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "group_strings",
|
||||
accessorKey: "group_strings",
|
||||
accessorFn: (s) => s.groups?.map((g) => g?.name || "").join(", "),
|
||||
},
|
||||
{
|
||||
accessorFn: (item) => item.auto_groups?.length,
|
||||
id: "groups",
|
||||
header: ({ column }) => {
|
||||
return <DataTableHeader column={column}>Groups</DataTableHeader>;
|
||||
},
|
||||
cell: ({ row }) => <SetupKeyGroupsCell setupKey={row.original} />,
|
||||
},
|
||||
{
|
||||
accessorKey: "ephemeral",
|
||||
header: ({ column }) => {
|
||||
return <DataTableHeader column={column}>Ephemeral</DataTableHeader>;
|
||||
},
|
||||
cell: ({ row }) => (
|
||||
<SetupKeyEphemeralCell ephemeral={row.original.ephemeral} />
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "expires",
|
||||
header: ({ column }) => {
|
||||
return <DataTableHeader column={column}>Expires</DataTableHeader>;
|
||||
},
|
||||
cell: ({ row }) => <ExpirationDateRow date={row.original.expires} />,
|
||||
},
|
||||
|
||||
{
|
||||
accessorKey: "id",
|
||||
header: "",
|
||||
cell: ({ row }) => {
|
||||
return <SetupKeyActionCell setupKey={row.original} />;
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
type Props = {
|
||||
setupKeys?: SetupKey[];
|
||||
@@ -33,10 +122,6 @@ export default function SetupKeysTable({ setupKeys, isLoading }: Props) {
|
||||
id: "valid",
|
||||
desc: true,
|
||||
},
|
||||
{
|
||||
id: "type",
|
||||
desc: true,
|
||||
},
|
||||
{
|
||||
id: "last_used",
|
||||
desc: true,
|
||||
|
||||
@@ -1,107 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import DataTableHeader from "@components/table/DataTableHeader";
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import * as React from "react";
|
||||
import { SetupKey } from "@/interfaces/SetupKey";
|
||||
import ExpirationDateRow from "@/modules/common-table-rows/ExpirationDateRow";
|
||||
import LastTimeRow from "@/modules/common-table-rows/LastTimeRow";
|
||||
import SetupKeyActionCell from "@/modules/setup-keys/SetupKeyActionCell";
|
||||
import SetupKeyGroupsCell from "@/modules/setup-keys/SetupKeyGroupsCell";
|
||||
import SetupKeyKeyCell from "@/modules/setup-keys/SetupKeyKeyCell";
|
||||
import SetupKeyNameCell from "@/modules/setup-keys/SetupKeyNameCell";
|
||||
import SetupKeyTypeCell from "@/modules/setup-keys/SetupKeyTypeCell";
|
||||
|
||||
export const SetupKeysTableColumns: ColumnDef<SetupKey>[] = [
|
||||
/* {
|
||||
id: "select",
|
||||
header: ({ table }) => (
|
||||
<Checkbox
|
||||
checked={table.getIsAllPageRowsSelected()}
|
||||
onCheckedChange={(value) => table.toggleAllRowsSelected(!!value)}
|
||||
aria-label="Select all"
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<Checkbox
|
||||
checked={row.getIsSelected()}
|
||||
onCheckedChange={(value) => row.toggleSelected(!!value)}
|
||||
aria-label="Select row"
|
||||
/>
|
||||
),
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
},*/
|
||||
{
|
||||
accessorKey: "name",
|
||||
header: ({ column }) => {
|
||||
return <DataTableHeader column={column}>Name</DataTableHeader>;
|
||||
},
|
||||
sortingFn: "text",
|
||||
cell: ({ row }) => (
|
||||
<SetupKeyNameCell
|
||||
valid={row.original.valid}
|
||||
name={row.original?.name || ""}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "valid",
|
||||
accessorKey: "valid",
|
||||
sortingFn: "basic",
|
||||
},
|
||||
{
|
||||
accessorKey: "type",
|
||||
header: ({ column }) => {
|
||||
return <DataTableHeader column={column}>Reusable</DataTableHeader>;
|
||||
},
|
||||
cell: ({ row }) => (
|
||||
<SetupKeyTypeCell reusable={row.original.type === "reusable"} />
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "key",
|
||||
header: ({ column }) => {
|
||||
return <DataTableHeader column={column}>Key</DataTableHeader>;
|
||||
},
|
||||
cell: ({ row }) => <SetupKeyKeyCell text={row.original.key} />,
|
||||
},
|
||||
{
|
||||
id: "group_strings",
|
||||
accessorKey: "group_strings",
|
||||
accessorFn: (s) => s.groups?.map((g) => g?.name || "").join(", "),
|
||||
},
|
||||
{
|
||||
accessorKey: "last_used",
|
||||
header: ({ column }) => {
|
||||
return <DataTableHeader column={column}>Last used</DataTableHeader>;
|
||||
},
|
||||
sortingFn: "datetime",
|
||||
cell: ({ row }) => (
|
||||
<LastTimeRow date={row.original.last_used} text={"Last used on"} />
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorFn: (item) => item.auto_groups?.length,
|
||||
id: "groups",
|
||||
header: ({ column }) => {
|
||||
return <DataTableHeader column={column}>Groups</DataTableHeader>;
|
||||
},
|
||||
cell: ({ row }) => <SetupKeyGroupsCell setupKey={row.original} />,
|
||||
},
|
||||
{
|
||||
accessorKey: "expires",
|
||||
header: ({ column }) => {
|
||||
return <DataTableHeader column={column}>Expires</DataTableHeader>;
|
||||
},
|
||||
cell: ({ row }) => <ExpirationDateRow date={row.original.expires} />,
|
||||
},
|
||||
|
||||
{
|
||||
accessorKey: "id",
|
||||
header: "",
|
||||
cell: ({ row }) => {
|
||||
return <SetupKeyActionCell setupKey={row.original} />;
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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");
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -155,6 +155,7 @@ export function UserInviteModalContent({ onSuccess }: ModalProps) {
|
||||
<PeerGroupSelector
|
||||
onChange={setSelectedGroups}
|
||||
values={selectedGroups}
|
||||
hideAllGroup={true}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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");
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -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);
|
||||
})
|
||||
|
||||
@@ -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>;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
@@ -27,9 +27,11 @@ const loadConfig = (): Config => {
|
||||
let silentRedirectURI = "/#silent-callback";
|
||||
let tokenSource = "accessToken";
|
||||
|
||||
if (process.env.NODE_ENV !== "production") {
|
||||
if (process.env.APP_ENV === "test") {
|
||||
configJson = require("@/config/test");
|
||||
} else if (process.env.NODE_ENV === "development") {
|
||||
configJson = require("@/config/local");
|
||||
} else {
|
||||
} else if (process.env.NODE_ENV === "production") {
|
||||
configJson = require("@/config/production");
|
||||
}
|
||||
|
||||
|
||||
@@ -41,10 +41,20 @@ export const sleep = (ms: number) => {
|
||||
|
||||
export const validator = {
|
||||
isValidDomain: (domain: string) => {
|
||||
const regExp =
|
||||
/^(?!.*\s)[a-zA-Z0-9](?!.*\s$)(?!.*\.$)(?:(?!-)[a-zA-Z0-9-]{1,63}(?<!-)\.){1,126}(?!-)[a-zA-Z0-9-]{1,63}(?<!-)$/;
|
||||
const unicodeDomain =
|
||||
/^(?!.*\.\.)(?!.*\.$)(?!.*\s)(?:(?!-)(?!.*--)[a-zA-Z0-9\u00A1-\uFFFF-]{1,63}(?<!-)\.)+(?!-)(?!.*--)[a-zA-Z0-9\u00A1-\uFFFF-]{2,63}$/u;
|
||||
try {
|
||||
return domain.match(regExp);
|
||||
const minMaxChars = [1, 255];
|
||||
const isValidDomainLength =
|
||||
domain.length >= minMaxChars[0] && domain.length <= minMaxChars[1];
|
||||
const includesDot = domain.includes(".");
|
||||
const hasNoWhitespace = !domain.includes(" ");
|
||||
return (
|
||||
unicodeDomain.test(domain) &&
|
||||
includesDot &&
|
||||
hasNoWhitespace &&
|
||||
isValidDomainLength
|
||||
);
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
@@ -60,7 +70,7 @@ export const validator = {
|
||||
isValidUrl: (urlString: string) => {
|
||||
const urlPattern = new RegExp(
|
||||
"^(https?:\\/\\/)?" + // validate protocol
|
||||
"((([a-z\\d]([a-z\\d-]*[a-z\\d])*)\\.)+[a-z]{2,}|" + // validate domain name
|
||||
"((([a-z\\d]([a-z\\d-]*[a-z\\d])*)\\.)+[a-z]{2,}|localhost|" + // validate domain name
|
||||
"((\\d{1,3}\\.){3}\\d{1,3}))" + // validate OR ip (v4) address
|
||||
"(\\:\\d+)?(\\/[-a-z\\d%_.~+]*)*" + // validate port and path
|
||||
"(\\?[;&a-z\\d%_.~+=-]*)?" + // validate query string
|
||||
@@ -74,8 +84,37 @@ export const validator = {
|
||||
/^(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$/;
|
||||
return semverRegex.test(version);
|
||||
},
|
||||
isValidUnixFilePath: (path: string) => {
|
||||
const endsWithSlash = path.endsWith("/");
|
||||
const unixPathRegex = /^\/(?:[^/]+\/)*[^/]+$/;
|
||||
const isValid = unixPathRegex.test(path);
|
||||
return isValid && !endsWithSlash;
|
||||
},
|
||||
isValidWindowsFilePath: (path: string) => {
|
||||
const endsWithBackSlash = path.endsWith("\\");
|
||||
const windowsPathRegex =
|
||||
/^[a-zA-Z]:\\(?:[^\\/:*?"<>|\r\n]+\\)*[^\\/:*?"<>|\r\n]*$/;
|
||||
const isValid = windowsPathRegex.test(path);
|
||||
return isValid && !endsWithBackSlash;
|
||||
},
|
||||
};
|
||||
|
||||
export function isInt(n: number) {
|
||||
return n % 1 === 0;
|
||||
}
|
||||
|
||||
export function tryGetProcessNameFromPath(path: string) {
|
||||
try {
|
||||
const canSplitByForwardSlash = path.includes("/");
|
||||
const canSplitByBackSlash = path.includes("\\");
|
||||
const byForwardSlash = canSplitByForwardSlash
|
||||
? path.split("/").pop()
|
||||
: undefined;
|
||||
const byBackSlash = canSplitByBackSlash
|
||||
? path.split("\\").pop()
|
||||
: undefined;
|
||||
return byForwardSlash || byBackSlash || path;
|
||||
} catch (e) {
|
||||
return path;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,6 +34,9 @@
|
||||
"@/config/local": [
|
||||
"./.local-config.json"
|
||||
],
|
||||
"@/config/test": [
|
||||
"./.test-config.json"
|
||||
],
|
||||
"@components/*": [
|
||||
"./src/components/*"
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user