feat(i18n): localize Peers, Access Control, Groups modules to Chinese

- Expand en.ts/zh.ts with extensive translation keys for all modules
- Localize Peers table, peer detail page, peer action cells
- Localize Access Control table, modal, action cells
- Localize Groups table, action cells, main page
- Add common helpers (GroupsRow, NoPeersGettingStarted) translations

Continuation of the localization effort.
This commit is contained in:
sakuradairong
2026-06-18 21:22:45 +08:00
parent e8f0f20455
commit f2fc11b89e
44 changed files with 3236 additions and 992 deletions

View File

@@ -1,3 +1,7 @@
const createNextIntlPlugin = require('next-intl/plugin');
const withNextIntl = createNextIntlPlugin('./src/i18n/request.ts');
/** @type {import('next').NextConfig} */ /** @type {import('next').NextConfig} */
const nextConfig = { const nextConfig = {
output: "export", output: "export",
@@ -12,4 +16,4 @@ const nextConfig = {
}, },
}; };
module.exports = nextConfig; module.exports = withNextIntl(nextConfig);

727
package-lock.json generated
View File

@@ -61,6 +61,7 @@
"lodash": "^4.17.23", "lodash": "^4.17.23",
"lucide-react": "^0.566.0", "lucide-react": "^0.566.0",
"next": "16.1.7", "next": "16.1.7",
"next-intl": "^4.13.0",
"next-themes": "^0.2.1", "next-themes": "^0.2.1",
"punycode": "^2.3.1", "punycode": "^2.3.1",
"react": "^19.2.4", "react": "^19.2.4",
@@ -168,7 +169,6 @@
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/code-frame": "^7.29.0", "@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0", "@babel/generator": "^7.29.0",
@@ -633,6 +633,36 @@
"integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==", "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@formatjs/fast-memoize": {
"version": "3.1.6",
"resolved": "https://registry.npmjs.org/@formatjs/fast-memoize/-/fast-memoize-3.1.6.tgz",
"integrity": "sha512-H5aexk1Le7T9TPmscacZ+1pR6CTa2n1wq+HDVGXhH8TzUlQQpeXzZs91dRtmFHrbeNbjPFPfQujUqm7MHgVoXQ==",
"license": "MIT"
},
"node_modules/@formatjs/icu-messageformat-parser": {
"version": "3.5.11",
"resolved": "https://registry.npmjs.org/@formatjs/icu-messageformat-parser/-/icu-messageformat-parser-3.5.11.tgz",
"integrity": "sha512-NVsuNsc2dUVG9+4HBJ/srScxtA/18LqGgwtop/tuN/OIBjVl6QA+0KhfZQddDD9sEh2LeVjLFPGVU3ixa3blcA==",
"license": "MIT",
"dependencies": {
"@formatjs/icu-skeleton-parser": "2.1.10"
}
},
"node_modules/@formatjs/icu-skeleton-parser": {
"version": "2.1.10",
"resolved": "https://registry.npmjs.org/@formatjs/icu-skeleton-parser/-/icu-skeleton-parser-2.1.10.tgz",
"integrity": "sha512-XuSva+8ZGawk8VnD5VD6UeH8KarQ/Z022zgjHDoHmlNiAewstXuuzXc0Hk5pGFSdG+nNw5bfJKXqj1ZXHn9yUA==",
"license": "MIT"
},
"node_modules/@formatjs/intl-localematcher": {
"version": "0.8.10",
"resolved": "https://registry.npmjs.org/@formatjs/intl-localematcher/-/intl-localematcher-0.8.10.tgz",
"integrity": "sha512-P/IC3qws3jH+1fEs+o0RIFgXKRaQlFehjS5W0FPAqdo6hgzawLl+eD0q0JjheQ3XtoOe5n8WSYfX06KQZI/QJA==",
"license": "MIT",
"dependencies": {
"@formatjs/fast-memoize": "3.1.6"
}
},
"node_modules/@humanfs/core": { "node_modules/@humanfs/core": {
"version": "0.19.1", "version": "0.19.1",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
@@ -1401,6 +1431,313 @@
"node": ">=12.4.0" "node": ">=12.4.0"
} }
}, },
"node_modules/@parcel/watcher": {
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz",
"integrity": "sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"detect-libc": "^2.0.3",
"is-glob": "^4.0.3",
"node-addon-api": "^7.0.0",
"picomatch": "^4.0.3"
},
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
},
"optionalDependencies": {
"@parcel/watcher-android-arm64": "2.5.6",
"@parcel/watcher-darwin-arm64": "2.5.6",
"@parcel/watcher-darwin-x64": "2.5.6",
"@parcel/watcher-freebsd-x64": "2.5.6",
"@parcel/watcher-linux-arm-glibc": "2.5.6",
"@parcel/watcher-linux-arm-musl": "2.5.6",
"@parcel/watcher-linux-arm64-glibc": "2.5.6",
"@parcel/watcher-linux-arm64-musl": "2.5.6",
"@parcel/watcher-linux-x64-glibc": "2.5.6",
"@parcel/watcher-linux-x64-musl": "2.5.6",
"@parcel/watcher-win32-arm64": "2.5.6",
"@parcel/watcher-win32-ia32": "2.5.6",
"@parcel/watcher-win32-x64": "2.5.6"
}
},
"node_modules/@parcel/watcher-android-arm64": {
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.6.tgz",
"integrity": "sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-darwin-arm64": {
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.6.tgz",
"integrity": "sha512-Z2ZdrnwyXvvvdtRHLmM4knydIdU9adO3D4n/0cVipF3rRiwP+3/sfzpAwA/qKFL6i1ModaabkU7IbpeMBgiVEA==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-darwin-x64": {
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.6.tgz",
"integrity": "sha512-HgvOf3W9dhithcwOWX9uDZyn1lW9R+7tPZ4sug+NGrGIo4Rk1hAXLEbcH1TQSqxts0NYXXlOWqVpvS1SFS4fRg==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-freebsd-x64": {
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.6.tgz",
"integrity": "sha512-vJVi8yd/qzJxEKHkeemh7w3YAn6RJCtYlE4HPMoVnCpIXEzSrxErBW5SJBgKLbXU3WdIpkjBTeUNtyBVn8TRng==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-linux-arm-glibc": {
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.6.tgz",
"integrity": "sha512-9JiYfB6h6BgV50CCfasfLf/uvOcJskMSwcdH1PHH9rvS1IrNy8zad6IUVPVUfmXr+u+Km9IxcfMLzgdOudz9EQ==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-linux-arm-musl": {
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.6.tgz",
"integrity": "sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-linux-arm64-glibc": {
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.6.tgz",
"integrity": "sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-linux-arm64-musl": {
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.6.tgz",
"integrity": "sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-linux-x64-glibc": {
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.6.tgz",
"integrity": "sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-linux-x64-musl": {
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.6.tgz",
"integrity": "sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-win32-arm64": {
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.6.tgz",
"integrity": "sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-win32-ia32": {
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.6.tgz",
"integrity": "sha512-k35yLp1ZMwwee3Ez/pxBi5cf4AoBKYXj00CZ80jUz5h8prpiaQsiRPKQMxoLstNuqe2vR4RNPEAEcjEFzhEz/g==",
"cpu": [
"ia32"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-win32-x64": {
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.6.tgz",
"integrity": "sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher/node_modules/picomatch": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/@radix-ui/number": { "node_modules/@radix-ui/number": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz",
@@ -2618,6 +2955,210 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@schummar/icu-type-parser": {
"version": "1.21.5",
"resolved": "https://registry.npmjs.org/@schummar/icu-type-parser/-/icu-type-parser-1.21.5.tgz",
"integrity": "sha512-bXHSaW5jRTmke9Vd0h5P7BtWZG9Znqb8gSDxZnxaGSJnGwPLDPfS+3g0BKzeWqzgZPsIVZkM7m2tbo18cm5HBw==",
"license": "MIT"
},
"node_modules/@swc/core-darwin-arm64": {
"version": "1.15.41",
"resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.15.41.tgz",
"integrity": "sha512-kREh6J5paQFvP3i7f/4FbqRNOJREutVFVOkder4GVyCBQ39YmER55cW/y1NNjwrchzFqgYswFn0mMDCqbqKzrw==",
"cpu": [
"arm64"
],
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=10"
}
},
"node_modules/@swc/core-darwin-x64": {
"version": "1.15.41",
"resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.15.41.tgz",
"integrity": "sha512-N8B56ESFazZAWZyIkecADSPCwlLEinW7QLMEeotCpv4J7VXwfH+OLkmRL8o96UZ+1355fwHxDTS6/wK7yucvkA==",
"cpu": [
"x64"
],
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=10"
}
},
"node_modules/@swc/core-linux-arm-gnueabihf": {
"version": "1.15.41",
"resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.15.41.tgz",
"integrity": "sha512-6XrId2fyle0mS5xxON8rU84mPd2Cq1kDJRj+4BnQKTd7u+2kSA6Ww+JkOP0iTNqOqt9OXhPOEAjBHAuonWcdCg==",
"cpu": [
"arm"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=10"
}
},
"node_modules/@swc/core-linux-arm64-gnu": {
"version": "1.15.41",
"resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.15.41.tgz",
"integrity": "sha512-ynLIarxlkVnqHn1D0fKOVht6mNU5ks6lrH+MY3kkS+XFaGGgDxFZVjWKJlkYTKm3RCvBTfA8Ng5fLufXheMRKQ==",
"cpu": [
"arm64"
],
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=10"
}
},
"node_modules/@swc/core-linux-arm64-musl": {
"version": "1.15.41",
"resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.15.41.tgz",
"integrity": "sha512-dXu/5vd4gh8symyhRF+4G7gOPkjmb4pONhh7sl+6GSiW0LOKZlfu5kXmyFbTz9smOT7jgr002qY9b1nujjXt2A==",
"cpu": [
"arm64"
],
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=10"
}
},
"node_modules/@swc/core-linux-ppc64-gnu": {
"version": "1.15.41",
"resolved": "https://registry.npmjs.org/@swc/core-linux-ppc64-gnu/-/core-linux-ppc64-gnu-1.15.41.tgz",
"integrity": "sha512-XGO6zVPXoPE0gf/XnI4jBbafNT13AYgoh6ns0JCSdOetI/kqVf0vhpz7NuNgAzZrMVCsmieqjPoTwViDgh4mOQ==",
"cpu": [
"ppc64"
],
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=10"
}
},
"node_modules/@swc/core-linux-s390x-gnu": {
"version": "1.15.41",
"resolved": "https://registry.npmjs.org/@swc/core-linux-s390x-gnu/-/core-linux-s390x-gnu-1.15.41.tgz",
"integrity": "sha512-0WUglRwyZtW+iMi7J3iFdrCxreZZIKf4egTwEQfIYRsqFax69A0OrFj+NIoFSE03xBT/IFRrg+S8K6f9Ky+4hA==",
"cpu": [
"s390x"
],
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=10"
}
},
"node_modules/@swc/core-linux-x64-gnu": {
"version": "1.15.41",
"resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.15.41.tgz",
"integrity": "sha512-VxkuQK59c0tHm6uJZCUrS3cyA2JhGGfdU6e41SZz0x/JS+4Sm7C1mIc97In14vkZJopEt7yXA2TouCqZDSygEA==",
"cpu": [
"x64"
],
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=10"
}
},
"node_modules/@swc/core-linux-x64-musl": {
"version": "1.15.41",
"resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.15.41.tgz",
"integrity": "sha512-/0qXIu1ZxggLuovLb22vFfKHq2AA4n6Whw5UwmVCHk4pkw7KWnPIQpMCEqUMPsNkFJig7PPp/TSYFu8ZEb2rtQ==",
"cpu": [
"x64"
],
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=10"
}
},
"node_modules/@swc/core-win32-arm64-msvc": {
"version": "1.15.41",
"resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.15.41.tgz",
"integrity": "sha512-Y481sMNZM6rECh9VO4+y26N1lWEDAyxnBZskUf37fl90uHE946VHfmiVQWT0uMFOhyJJFovGTRuF4W82dwewUg==",
"cpu": [
"arm64"
],
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=10"
}
},
"node_modules/@swc/core-win32-ia32-msvc": {
"version": "1.15.41",
"resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.15.41.tgz",
"integrity": "sha512-BAchBD5qeUzy3hiPSLJtaaoSm4blCLyYffOF1bGE4ETcV+OisqjUAwDQMJj++4bTpvMCDzwC+Bj3PmQyBCtscw==",
"cpu": [
"ia32"
],
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=10"
}
},
"node_modules/@swc/core-win32-x64-msvc": {
"version": "1.15.41",
"resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.15.41.tgz",
"integrity": "sha512-WOkA+fJ/ViVBQDsSV9JC52NACTe5PhlurA6viASDZGb7HR3KS01ZG7RZ+Bg6SVQFIoq3gSbTsskQVe6EbHFAYw==",
"cpu": [
"x64"
],
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=10"
}
},
"node_modules/@swc/counter": {
"version": "0.1.3",
"resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz",
"integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==",
"license": "Apache-2.0"
},
"node_modules/@swc/helpers": { "node_modules/@swc/helpers": {
"version": "0.5.15", "version": "0.5.15",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
@@ -2627,6 +3168,15 @@
"tslib": "^2.8.0" "tslib": "^2.8.0"
} }
}, },
"node_modules/@swc/types": {
"version": "0.1.27",
"resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.27.tgz",
"integrity": "sha512-K6h3iUlqeM946U4sXFYeahefR1YBbXJvko+hv8WS8/0BNJ4OHiHRywMnQUJCqkR7Y9+hqQ1TvEpiKqUhz7NEFg==",
"license": "Apache-2.0",
"dependencies": {
"@swc/counter": "^0.1.3"
}
},
"node_modules/@tabler/icons": { "node_modules/@tabler/icons": {
"version": "3.36.1", "version": "3.36.1",
"resolved": "https://registry.npmjs.org/@tabler/icons/-/icons-3.36.1.tgz", "resolved": "https://registry.npmjs.org/@tabler/icons/-/icons-3.36.1.tgz",
@@ -3031,7 +3581,6 @@
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.10.tgz", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.10.tgz",
"integrity": "sha512-WPigyYuGhgZ/cTPRXB2EwUw+XvsRA3GqHlsP4qteqrnnjDrApbS7MxcGr/hke5iUoeB7E/gQtrs9I37zAJ0Vjw==", "integrity": "sha512-WPigyYuGhgZ/cTPRXB2EwUw+XvsRA3GqHlsP4qteqrnnjDrApbS7MxcGr/hke5iUoeB7E/gQtrs9I37zAJ0Vjw==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"csstype": "^3.2.2" "csstype": "^3.2.2"
} }
@@ -3041,7 +3590,6 @@
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"peerDependencies": { "peerDependencies": {
"@types/react": "^19.2.0" "@types/react": "^19.2.0"
} }
@@ -3100,7 +3648,6 @@
"integrity": "sha512-BtE0k6cjwjLZoZixN0t5AKP0kSzlGu7FctRXYuPAm//aaiZhmfq1JwdYpYr1brzEspYyFeF+8XF5j2VK6oalrA==", "integrity": "sha512-BtE0k6cjwjLZoZixN0t5AKP0kSzlGu7FctRXYuPAm//aaiZhmfq1JwdYpYr1brzEspYyFeF+8XF5j2VK6oalrA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@typescript-eslint/scope-manager": "8.54.0", "@typescript-eslint/scope-manager": "8.54.0",
"@typescript-eslint/types": "8.54.0", "@typescript-eslint/types": "8.54.0",
@@ -3581,8 +4128,7 @@
"version": "5.5.0", "version": "5.5.0",
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz", "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz",
"integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==", "integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==",
"license": "MIT", "license": "MIT"
"peer": true
}, },
"node_modules/@xyflow/react": { "node_modules/@xyflow/react": {
"version": "12.10.0", "version": "12.10.0",
@@ -3621,7 +4167,6 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"acorn": "bin/acorn" "acorn": "bin/acorn"
}, },
@@ -4044,7 +4589,6 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"baseline-browser-mapping": "^2.9.0", "baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759", "caniuse-lite": "^1.0.30001759",
@@ -4667,7 +5211,6 @@
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
"license": "ISC", "license": "ISC",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
} }
@@ -4923,7 +5466,6 @@
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true,
"engines": { "engines": {
"node": ">=8" "node": ">=8"
} }
@@ -5196,7 +5738,6 @@
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.2.tgz", "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.2.tgz",
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1", "@eslint-community/regexpp": "^4.12.1",
@@ -5394,7 +5935,6 @@
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@rtsao/scc": "^1.1.0", "@rtsao/scc": "^1.1.0",
"array-includes": "^3.1.9", "array-includes": "^3.1.9",
@@ -6136,6 +6676,21 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/icu-minify": {
"version": "4.13.0",
"resolved": "https://registry.npmjs.org/icu-minify/-/icu-minify-4.13.0.tgz",
"integrity": "sha512-SIFMeUHZJjzS5RvIGvybKvWoHjDm9cGVEs2EpJ8PmywOdJLWyblPm7TdPLLoUtkJtwQD7iGhl2WMptZ+N0on+w==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/amannn"
}
],
"license": "MIT",
"dependencies": {
"@formatjs/icu-messageformat-parser": "^3.4.0"
}
},
"node_modules/ignore": { "node_modules/ignore": {
"version": "5.3.2", "version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@@ -6194,6 +6749,16 @@
"node": ">=12" "node": ">=12"
} }
}, },
"node_modules/intl-messageformat": {
"version": "11.2.8",
"resolved": "https://registry.npmjs.org/intl-messageformat/-/intl-messageformat-11.2.8.tgz",
"integrity": "sha512-l323RCl3qJDVQ8U9j74ut/hVMdg3VPsOHpVMDvFfz9qiq4dPO5ooVYFNVUzzrpgG39a+RLzcXyJb8VFgIU+tUA==",
"license": "BSD-3-Clause",
"dependencies": {
"@formatjs/fast-memoize": "3.1.6",
"@formatjs/icu-messageformat-parser": "3.5.11"
}
},
"node_modules/ip-address": { "node_modules/ip-address": {
"version": "10.1.0", "version": "10.1.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz",
@@ -6688,7 +7253,6 @@
"resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz",
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"jiti": "bin/jiti.js" "jiti": "bin/jiti.js"
} }
@@ -7042,6 +7606,15 @@
"integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/negotiator": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz",
"integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/next": { "node_modules/next": {
"version": "16.1.7", "version": "16.1.7",
"resolved": "https://registry.npmjs.org/next/-/next-16.1.7.tgz", "resolved": "https://registry.npmjs.org/next/-/next-16.1.7.tgz",
@@ -7095,6 +7668,94 @@
} }
} }
}, },
"node_modules/next-intl": {
"version": "4.13.0",
"resolved": "https://registry.npmjs.org/next-intl/-/next-intl-4.13.0.tgz",
"integrity": "sha512-OvNq2v5XLx4EkQOsAhVE9g+6zdb83XHusADCXXtIW4LILYnjEVaeINdr1lkVWKSjzwNUiMSlH5N4K0OQTRiv6A==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/amannn"
}
],
"license": "MIT",
"dependencies": {
"@formatjs/intl-localematcher": "^0.8.1",
"@parcel/watcher": "^2.4.1",
"@swc/core": "^1.15.2",
"icu-minify": "^4.13.0",
"negotiator": "^1.0.0",
"next-intl-swc-plugin-extractor": "^4.13.0",
"po-parser": "^2.1.1",
"use-intl": "^4.13.0"
},
"peerDependencies": {
"next": "^12.0.0 || ^13.0.0 || ^14.0.0 || ^15.0.0 || ^16.0.0",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/next-intl-swc-plugin-extractor": {
"version": "4.13.0",
"resolved": "https://registry.npmjs.org/next-intl-swc-plugin-extractor/-/next-intl-swc-plugin-extractor-4.13.0.tgz",
"integrity": "sha512-6S/fJI0KXvLCL8nhBo9P8eGaJPzmwJBTCzX0NaUIj0VyU8U89d//T+vjMLdNIXl5MlLaYH7B9MbAjb8Mvu+tqQ==",
"license": "MIT"
},
"node_modules/next-intl/node_modules/@swc/core": {
"version": "1.15.41",
"resolved": "https://registry.npmjs.org/@swc/core/-/core-1.15.41.tgz",
"integrity": "sha512-03nQq/082QRJJiOvp3FGbgxTGyyxMxohPTjhk/W9bD2J0tk4ukITI7goOhOO2WbaHn/lsPmo/zf8+DIXhwpgYQ==",
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
"@swc/counter": "^0.1.3",
"@swc/types": "^0.1.26"
},
"engines": {
"node": ">=10"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/swc"
},
"optionalDependencies": {
"@swc/core-darwin-arm64": "1.15.41",
"@swc/core-darwin-x64": "1.15.41",
"@swc/core-linux-arm-gnueabihf": "1.15.41",
"@swc/core-linux-arm64-gnu": "1.15.41",
"@swc/core-linux-arm64-musl": "1.15.41",
"@swc/core-linux-ppc64-gnu": "1.15.41",
"@swc/core-linux-s390x-gnu": "1.15.41",
"@swc/core-linux-x64-gnu": "1.15.41",
"@swc/core-linux-x64-musl": "1.15.41",
"@swc/core-win32-arm64-msvc": "1.15.41",
"@swc/core-win32-ia32-msvc": "1.15.41",
"@swc/core-win32-x64-msvc": "1.15.41"
},
"peerDependencies": {
"@swc/helpers": ">=0.5.17"
},
"peerDependenciesMeta": {
"@swc/helpers": {
"optional": true
}
}
},
"node_modules/next-intl/node_modules/@swc/helpers": {
"version": "0.5.23",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz",
"integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==",
"license": "Apache-2.0",
"optional": true,
"peer": true,
"dependencies": {
"tslib": "^2.8.0"
}
},
"node_modules/next-themes": { "node_modules/next-themes": {
"version": "0.2.1", "version": "0.2.1",
"resolved": "https://registry.npmjs.org/next-themes/-/next-themes-0.2.1.tgz", "resolved": "https://registry.npmjs.org/next-themes/-/next-themes-0.2.1.tgz",
@@ -7134,6 +7795,12 @@
"node": "^10 || ^12 || >=14" "node": "^10 || ^12 || >=14"
} }
}, },
"node_modules/node-addon-api": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz",
"integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==",
"license": "MIT"
},
"node_modules/node-releases": { "node_modules/node-releases": {
"version": "2.0.27", "version": "2.0.27",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz",
@@ -7417,6 +8084,12 @@
"node": ">= 6" "node": ">= 6"
} }
}, },
"node_modules/po-parser": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/po-parser/-/po-parser-2.1.1.tgz",
"integrity": "sha512-ECF4zHLbUItpUgE3OTtLKlPjeBN+fKEczj2zYjDfCGOzicNs0GK3Vg2IoAYwx7LH/XYw43fZQP6xnZ4TkNxSLQ==",
"license": "MIT"
},
"node_modules/possible-typed-array-names": { "node_modules/possible-typed-array-names": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
@@ -7446,7 +8119,6 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"nanoid": "^3.3.11", "nanoid": "^3.3.11",
"picocolors": "^1.1.1", "picocolors": "^1.1.1",
@@ -7654,7 +8326,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
} }
@@ -7695,7 +8366,6 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
"integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"scheduler": "^0.27.0" "scheduler": "^0.27.0"
}, },
@@ -8590,7 +9260,6 @@
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz",
"integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@alloc/quick-lru": "^5.2.0", "@alloc/quick-lru": "^5.2.0",
"arg": "^5.0.2", "arg": "^5.0.2",
@@ -8757,7 +9426,6 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },
@@ -8923,7 +9591,6 @@
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
"tsserver": "bin/tsserver" "tsserver": "bin/tsserver"
@@ -9076,6 +9743,27 @@
} }
} }
}, },
"node_modules/use-intl": {
"version": "4.13.0",
"resolved": "https://registry.npmjs.org/use-intl/-/use-intl-4.13.0.tgz",
"integrity": "sha512-fAFDrWaASxlhXOipcOyb5VDD+YONqj6+8O8EcG/J7RBoOUF3A8YahRWLN+mBxYMrlMQB8N6Voqk5X+YC+HSL0A==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/amannn"
}
],
"license": "MIT",
"dependencies": {
"@formatjs/fast-memoize": "^3.1.0",
"@schummar/icu-type-parser": "1.21.5",
"icu-minify": "^4.13.0",
"intl-messageformat": "^11.1.0"
},
"peerDependencies": {
"react": "^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0"
}
},
"node_modules/use-sidecar": { "node_modules/use-sidecar": {
"version": "1.1.3", "version": "1.1.3",
"resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz", "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz",
@@ -9251,7 +9939,6 @@
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"funding": { "funding": {
"url": "https://github.com/sponsors/colinhacks" "url": "https://github.com/sponsors/colinhacks"
} }

View File

@@ -69,6 +69,7 @@
"lodash": "^4.17.23", "lodash": "^4.17.23",
"lucide-react": "^0.566.0", "lucide-react": "^0.566.0",
"next": "16.1.7", "next": "16.1.7",
"next-intl": "^4.13.0",
"next-themes": "^0.2.1", "next-themes": "^0.2.1",
"punycode": "^2.3.1", "punycode": "^2.3.1",
"react": "^19.2.4", "react": "^19.2.4",

View File

@@ -8,6 +8,7 @@ import { RestrictedAccess } from "@components/ui/RestrictedAccess";
import { usePortalElement } from "@hooks/usePortalElement"; import { usePortalElement } from "@hooks/usePortalElement";
import useFetchApi from "@utils/api"; import useFetchApi from "@utils/api";
import { ExternalLinkIcon } from "lucide-react"; import { ExternalLinkIcon } from "lucide-react";
import { useTranslations } from 'next-intl';
import React, { lazy, Suspense } from "react"; import React, { lazy, Suspense } from "react";
import AccessControlIcon from "@/assets/icons/AccessControlIcon"; import AccessControlIcon from "@/assets/icons/AccessControlIcon";
import GroupsProvider from "@/contexts/GroupsProvider"; import GroupsProvider from "@/contexts/GroupsProvider";
@@ -20,6 +21,7 @@ const AccessControlTable = lazy(
() => import("@/modules/access-control/table/AccessControlTable"), () => import("@/modules/access-control/table/AccessControlTable"),
); );
export default function AccessControlPage() { export default function AccessControlPage() {
const t = useTranslations('policies');
const { permission } = usePermissions(); const { permission } = usePermissions();
const { data: policies, isLoading } = useFetchApi<Policy[]>("/policies"); const { data: policies, isLoading } = useFetchApi<Policy[]>("/policies");
@@ -34,26 +36,25 @@ export default function AccessControlPage() {
<Breadcrumbs> <Breadcrumbs>
<Breadcrumbs.Item <Breadcrumbs.Item
href={"/access-control"} href={"/access-control"}
label={"Access Control"} label={t('title')}
icon={<AccessControlIcon size={14} />} icon={<AccessControlIcon size={14} />}
/> />
</Breadcrumbs> </Breadcrumbs>
<h1 ref={headingRef}>Access Control Policies</h1> <h1 ref={headingRef}>{t('title')}</h1>
<Paragraph> <Paragraph>
Create rules to manage access in your network and define what peers {t('accessControlDescription')}{" "}
can connect.{" "}
<InlineLink <InlineLink
href={"https://docs.netbird.io/how-to/manage-network-access"} href={"https://docs.netbird.io/how-to/manage-network-access"}
target={"_blank"} target={"_blank"}
> >
Learn more {t('learnMore')}
<ExternalLinkIcon size={12} /> <ExternalLinkIcon size={12} />
</InlineLink> </InlineLink>
</Paragraph> </Paragraph>
</div> </div>
<RestrictedAccess <RestrictedAccess
page={"Access Control"} page={t('title')}
hasAccess={permission.policies.read} hasAccess={permission.policies.read}
> >
<PoliciesProvider> <PoliciesProvider>

View File

@@ -5,6 +5,7 @@ import SkeletonTable from "@components/skeletons/SkeletonTable";
import { RestrictedAccess } from "@components/ui/RestrictedAccess"; import { RestrictedAccess } from "@components/ui/RestrictedAccess";
import { usePortalElement } from "@hooks/usePortalElement"; import { usePortalElement } from "@hooks/usePortalElement";
import { ExternalLinkIcon, FolderGit2Icon } from "lucide-react"; import { ExternalLinkIcon, FolderGit2Icon } from "lucide-react";
import { useTranslations } from 'next-intl';
import React, { lazy, Suspense } from "react"; import React, { lazy, Suspense } from "react";
import Breadcrumbs from "@/components/Breadcrumbs"; import Breadcrumbs from "@/components/Breadcrumbs";
import InlineLink from "@/components/InlineLink"; import InlineLink from "@/components/InlineLink";
@@ -14,6 +15,7 @@ import PageContainer from "@/layouts/PageContainer";
const GroupsTable = lazy(() => import("@/modules/groups/table/GroupsTable")); const GroupsTable = lazy(() => import("@/modules/groups/table/GroupsTable"));
export default function GroupsPage() { export default function GroupsPage() {
const t = useTranslations('groups');
const { permission } = usePermissions(); const { permission } = usePermissions();
const { ref: headingRef, portalTarget } = const { ref: headingRef, portalTarget } =
usePortalElement<HTMLHeadingElement>(); usePortalElement<HTMLHeadingElement>();
@@ -24,24 +26,24 @@ export default function GroupsPage() {
<Breadcrumbs> <Breadcrumbs>
<Breadcrumbs.Item <Breadcrumbs.Item
href={"/groups"} href={"/groups"}
label={"Groups"} label={t('title')}
icon={<FolderGit2Icon size={14} />} icon={<FolderGit2Icon size={14} />}
active active
/> />
</Breadcrumbs> </Breadcrumbs>
<h1 ref={headingRef}>Groups</h1> <h1 ref={headingRef}>{t('title')}</h1>
<Paragraph> <Paragraph>
Organize peers, users and resources into groups to manage access.{" "} {t('groupsDescription')}{" "}
<InlineLink <InlineLink
href={"https://docs.netbird.io/how-to/manage-network-access"} href={"https://docs.netbird.io/how-to/manage-network-access"}
target={"_blank"} target={"_blank"}
> >
Learn more {t('learnMore')}
<ExternalLinkIcon size={12} /> <ExternalLinkIcon size={12} />
</InlineLink> </InlineLink>
</Paragraph> </Paragraph>
</div> </div>
<RestrictedAccess hasAccess={permission.groups.read} page={"Groups"}> <RestrictedAccess hasAccess={permission.groups.read} page={t('title')}>
<Suspense fallback={<SkeletonTable />}> <Suspense fallback={<SkeletonTable />}>
<GroupsTable headingTarget={portalTarget} /> <GroupsTable headingTarget={portalTarget} />
</Suspense> </Suspense>

View File

@@ -43,6 +43,7 @@ import {
PencilIcon, PencilIcon,
RadioTowerIcon, RadioTowerIcon,
} from "lucide-react"; } from "lucide-react";
import { useTranslations } from 'next-intl';
import Link from "next/link"; import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation"; import { useRouter, useSearchParams } from "next/navigation";
import { toASCII } from "punycode"; import { toASCII } from "punycode";
@@ -79,6 +80,7 @@ import { SSHButton } from "@/modules/remote-access/ssh/SSHButton";
import { PeerExpirationSettings } from "@/modules/peer/PeerExpirationSettings"; import { PeerExpirationSettings } from "@/modules/peer/PeerExpirationSettings";
export default function PeerPage() { export default function PeerPage() {
const t = useTranslations('peers');
const queryParameter = useSearchParams(); const queryParameter = useSearchParams();
const { isRestricted } = usePermissions(); const { isRestricted } = usePermissions();
const peerId = queryParameter.get("id"); const peerId = queryParameter.get("id");
@@ -93,7 +95,7 @@ export default function PeerPage() {
if (isRestricted) { if (isRestricted) {
return ( return (
<PageContainer> <PageContainer>
<RestrictedAccess page={"Peer Information"} /> <RestrictedAccess page={t('title')} />
</PageContainer> </PageContainer>
); );
} }
@@ -102,9 +104,7 @@ export default function PeerPage() {
return ( return (
<PageNotFound <PageNotFound
title={error?.message} title={error?.message}
description={ description={t('peerNotFoundDescription')}
"The peer you are attempting to access cannot be found. It may have been deleted, or you may not have permission to view it. Please verify the URL or return to the dashboard."
}
/> />
); );
@@ -129,6 +129,7 @@ function peerListPath(user: User | undefined): string {
} }
function PeerOverview() { function PeerOverview() {
const t = useTranslations('peers');
const { peer, user } = usePeer(); const { peer, user } = usePeer();
return ( return (
@@ -139,7 +140,7 @@ function PeerOverview() {
<Breadcrumbs> <Breadcrumbs>
<Breadcrumbs.Item <Breadcrumbs.Item
href={peerListPath(user)} href={peerListPath(user)}
label={"Peers"} label={t('title')}
icon={<PeerIcon size={13} />} icon={<PeerIcon size={13} />}
/> />
<Breadcrumbs.Item label={peer.ip} active /> <Breadcrumbs.Item label={peer.ip} active />
@@ -177,6 +178,7 @@ const usePeerSettings = () => {
}; };
const PeerSettingsProvider = ({ children }: { children: React.ReactNode }) => { const PeerSettingsProvider = ({ children }: { children: React.ReactNode }) => {
const t = useTranslations('peers');
const { mutate } = useSWRConfig(); const { mutate } = useSWRConfig();
const { peer, peerGroups, update } = usePeer(); const { peer, peerGroups, update } = usePeer();
const { permission } = usePermissions(); const { permission } = usePermissions();
@@ -207,13 +209,13 @@ const PeerSettingsProvider = ({ children }: { children: React.ReactNode }) => {
notify({ notify({
title: name, title: name,
description: "Peer was successfully saved", description: t('peerSaved'),
promise: Promise.all(batchCall).then(() => { promise: Promise.all(batchCall).then(() => {
mutate("/peers/" + peer.id); mutate("/peers/" + peer.id);
mutate("/groups"); mutate("/groups");
updateHasChangedRef([selectedGroups]); updateHasChangedRef([selectedGroups]);
}), }),
loadingMessage: "Saving the peer...", loadingMessage: t('peerSaving'),
}); });
}; };
@@ -236,6 +238,8 @@ const PeerSettingsProvider = ({ children }: { children: React.ReactNode }) => {
}; };
const PeerHeader = () => { const PeerHeader = () => {
const t = useTranslations('peers');
const tCommon = useTranslations('common');
const router = useRouter(); const router = useRouter();
const { peer, user } = usePeer(); const { peer, user } = usePeer();
const { permission } = usePermissions(); const { permission } = usePermissions();
@@ -309,7 +313,7 @@ const PeerHeader = () => {
className={"w-full"} className={"w-full"}
onClick={() => router.push(peerListPath(user))} onClick={() => router.push(peerListPath(user))}
> >
Cancel {tCommon('cancel')}
</Button> </Button>
<Button <Button
variant={"primary"} variant={"primary"}
@@ -321,7 +325,7 @@ const PeerHeader = () => {
!permission.groups.update !permission.groups.update
} }
> >
Save Changes {tCommon('save')}
</Button> </Button>
</div> </div>
)} )}
@@ -331,6 +335,8 @@ const PeerHeader = () => {
}; };
const PeerOverviewTabs = () => { const PeerOverviewTabs = () => {
const t = useTranslations('peers');
const tReverse = useTranslations('reverseProxy');
const { peer } = usePeer(); const { peer } = usePeer();
const { permission } = usePermissions(); const { permission } = usePermissions();
const { reverseProxies, isLoading: isServicesLoading } = useReverseProxies(); const { reverseProxies, isLoading: isServicesLoading } = useReverseProxies();
@@ -351,20 +357,20 @@ const PeerOverviewTabs = () => {
<TabsList justify={"start"} className={"px-8"}> <TabsList justify={"start"} className={"px-8"}>
<TabsTrigger value={"overview"}> <TabsTrigger value={"overview"}>
<ListIcon size={16} /> <ListIcon size={16} />
Overview {t('tabOverview')}
</TabsTrigger> </TabsTrigger>
{permission.routes.read && ( {permission.routes.read && (
<TabsTrigger value={"network-routes"}> <TabsTrigger value={"network-routes"}>
<NetworkIcon size={16} /> <NetworkIcon size={16} />
Network Routes {t('tabNetworkRoutes')}
</TabsTrigger> </TabsTrigger>
)} )}
{peer?.id && permission.peers.read && ( {peer?.id && permission.peers.read && (
<TabsTrigger value={"accessible-peers"}> <TabsTrigger value={"accessible-peers"}>
<MonitorSmartphoneIcon size={16} /> <MonitorSmartphoneIcon size={16} />
Accessible Peers {t('tabAccessiblePeers')}
</TabsTrigger> </TabsTrigger>
)} )}
@@ -374,14 +380,14 @@ const PeerOverviewTabs = () => {
size={16} size={16}
className="fill-nb-gray-400 group-data-[state=active]/trigger:fill-netbird" className="fill-nb-gray-400 group-data-[state=active]/trigger:fill-netbird"
/> />
{singularize("Services", flatTargets.length)} {singularize(tReverse('services'), flatTargets.length)}
</TabsTrigger> </TabsTrigger>
)} )}
{peer?.id && permission.peers.delete && ( {peer?.id && permission.peers.delete && (
<TabsTrigger value={"peer-job"}> <TabsTrigger value={"peer-job"}>
<RadioTowerIcon size={16} /> <RadioTowerIcon size={16} />
Remote Jobs {t('tabRemoteJobs')}
</TabsTrigger> </TabsTrigger>
)} )}
</TabsList> </TabsList>
@@ -408,10 +414,8 @@ const PeerOverviewTabs = () => {
targets={flatTargets} targets={flatTargets}
isLoading={isServicesLoading} isLoading={isServicesLoading}
hideResourceColumn hideResourceColumn
emptyTableTitle={"This peer has no services"} emptyTableTitle={t('noServicesForPeer')}
emptyTableDescription={ emptyTableDescription={t('addServicesDescription')}
"Add your services to this peer and securely expose them through NetBird's reverse proxy"
}
/> />
</TabsContent> </TabsContent>
)} )}
@@ -426,6 +430,7 @@ const PeerOverviewTabs = () => {
}; };
const PeerOverviewTabContent = () => { const PeerOverviewTabContent = () => {
const t = useTranslations('peers');
const { peer } = usePeer(); const { peer } = usePeer();
const { permission } = usePermissions(); const { permission } = usePermissions();
const { selectedGroups, setSelectedGroups } = usePeerSettings(); const { selectedGroups, setSelectedGroups } = usePeerSettings();
@@ -443,9 +448,9 @@ const PeerOverviewTabContent = () => {
<PeerExpirationSettings /> <PeerExpirationSettings />
{permission.groups.read && ( {permission.groups.read && (
<div> <div>
<Label>Assigned Groups</Label> <Label>{t('assignedGroups')}</Label>
<HelpText> <HelpText>
Use groups to control what this peer can access. {t('assignedGroupsDescription')}
</HelpText> </HelpText>
<PeerGroupSelector <PeerGroupSelector
disabled={!permission.groups.update} disabled={!permission.groups.update}
@@ -461,8 +466,8 @@ const PeerOverviewTabContent = () => {
{/* Remote Access Buttons */} {/* Remote Access Buttons */}
<div> <div>
<Label>Remote Access</Label> <Label>{t('remoteAccess')}</Label>
<HelpText>Connect directly to this peer via SSH or RDP.</HelpText> <HelpText>{t('remoteAccessDescription')}</HelpText>
<div className="flex gap-3"> <div className="flex gap-3">
<SSHButton peer={peer} /> <SSHButton peer={peer} />
<RDPButton peer={peer} /> <RDPButton peer={peer} />
@@ -475,6 +480,8 @@ const PeerOverviewTabContent = () => {
}; };
function PeerInformationCard({ peer }: Readonly<{ peer: Peer }>) { function PeerInformationCard({ peer }: Readonly<{ peer: Peer }>) {
const t = useTranslations('peers');
const tCommon = useTranslations('common');
const { isLoading, getRegionByPeer } = useCountries(); const { isLoading, getRegionByPeer } = useCountries();
const { update } = usePeer(); const { update } = usePeer();
const { mutate } = useSWRConfig(); const { mutate } = useSWRConfig();
@@ -489,24 +496,24 @@ function PeerInformationCard({ peer }: Readonly<{ peer: Peer }>) {
const handleSaveIP = (newIP: string) => { const handleSaveIP = (newIP: string) => {
notify({ notify({
title: peer.name, title: peer.name,
description: "NetBird Peer IP was successfully updated", description: t('peerIpUpdated'),
promise: update({ ip: newIP }).then(() => { promise: update({ ip: newIP }).then(() => {
mutate("/peers/" + peer.id); mutate("/peers/" + peer.id);
setShowEditIPModal(false); setShowEditIPModal(false);
}), }),
loadingMessage: "Updating peer IP...", loadingMessage: t('peerIpUpdating'),
}); });
}; };
const handleSaveIPv6 = (newIPv6: string) => { const handleSaveIPv6 = (newIPv6: string) => {
notify({ notify({
title: peer.name, title: peer.name,
description: "NetBird Peer IPv6 was successfully updated", description: t('peerIpv6Updated'),
promise: update({ ipv6: newIPv6 }).then(() => { promise: update({ ipv6: newIPv6 }).then(() => {
mutate("/peers/" + peer.id); mutate("/peers/" + peer.id);
setShowEditIPv6Modal(false); setShowEditIPv6Modal(false);
}), }),
loadingMessage: "Updating peer IPv6...", loadingMessage: t('peerIpv6Updating'),
}); });
}; };
@@ -533,11 +540,11 @@ function PeerInformationCard({ peer }: Readonly<{ peer: Peer }>) {
<Card.ListItem <Card.ListItem
copy copy
tooltip={false} tooltip={false}
copyText={"NetBird IP Address"} copyText={t('netbirdIp')}
label={ label={
<> <>
<MapPin size={16} className={"shrink-0"} /> <MapPin size={16} className={"shrink-0"} />
NetBird IP Address {t('netbirdIp')}
</> </>
} }
valueToCopy={peer.ip} valueToCopy={peer.ip}
@@ -554,11 +561,11 @@ function PeerInformationCard({ peer }: Readonly<{ peer: Peer }>) {
<Card.ListItem <Card.ListItem
copy copy
tooltip={false} tooltip={false}
copyText={"NetBird IPv6 Address"} copyText={t('netbirdIpv6')}
label={ label={
<> <>
<MapPin size={16} className={"shrink-0"} /> <MapPin size={16} className={"shrink-0"} />
NetBird IPv6 Address {t('netbirdIpv6')}
</> </>
} }
valueToCopy={peer.ipv6} valueToCopy={peer.ipv6}
@@ -574,11 +581,11 @@ function PeerInformationCard({ peer }: Readonly<{ peer: Peer }>) {
<Card.ListItem <Card.ListItem
copy copy
copyText={"Public IP Address"} copyText={t('publicIp')}
label={ label={
<> <>
<NetworkIcon size={16} className={"shrink-0"} /> <NetworkIcon size={16} className={"shrink-0"} />
Public IP Address {t('publicIp')}
</> </>
} }
value={peer.connection_ip} value={peer.connection_ip}
@@ -586,11 +593,11 @@ function PeerInformationCard({ peer }: Readonly<{ peer: Peer }>) {
<Card.ListItem <Card.ListItem
copy copy
copyText={"DNS label"} copyText={t('domain')}
label={ label={
<> <>
<Globe size={16} className={"shrink-0"} /> <Globe size={16} className={"shrink-0"} />
Domain Name {t('domainName')}
</> </>
} }
className={ className={
@@ -604,11 +611,11 @@ function PeerInformationCard({ peer }: Readonly<{ peer: Peer }>) {
<Card.ListItem <Card.ListItem
copy copy
copyText={"Hostname"} copyText={t('hostname')}
label={ label={
<> <>
<MonitorSmartphoneIcon size={16} className={"shrink-0"} /> <MonitorSmartphoneIcon size={16} className={"shrink-0"} />
Hostname {t('hostname')}
</> </>
} }
value={peer.hostname} value={peer.hostname}
@@ -618,13 +625,13 @@ function PeerInformationCard({ peer }: Readonly<{ peer: Peer }>) {
label={ label={
<> <>
<FlagIcon size={16} className={"shrink-0"} /> <FlagIcon size={16} className={"shrink-0"} />
Region {t('region')}
</> </>
} }
tooltip={false} tooltip={false}
value={ value={
isEmpty(peer.country_code) ? ( isEmpty(peer.country_code) ? (
"Unknown" tCommon('unknown')
) : ( ) : (
<> <>
{isLoading ? ( {isLoading ? (
@@ -648,7 +655,7 @@ function PeerInformationCard({ peer }: Readonly<{ peer: Peer }>) {
label={ label={
<> <>
<Cpu size={16} className={"shrink-0"} /> <Cpu size={16} className={"shrink-0"} />
Operating System {t('operatingSystemLabel')}
</> </>
} }
value={peer.os} value={peer.os}
@@ -659,7 +666,7 @@ function PeerInformationCard({ peer }: Readonly<{ peer: Peer }>) {
label={ label={
<> <>
<Barcode size={16} className={"shrink-0"} /> <Barcode size={16} className={"shrink-0"} />
Serial Number {t('serialNumber')}
</> </>
} }
value={peer.serial_number} value={peer.serial_number}
@@ -671,7 +678,7 @@ function PeerInformationCard({ peer }: Readonly<{ peer: Peer }>) {
label={ label={
<> <>
<CalendarDays size={16} className={"shrink-0"} /> <CalendarDays size={16} className={"shrink-0"} />
Registered on {t('registeredOn')}
</> </>
} }
value={ value={
@@ -687,12 +694,12 @@ function PeerInformationCard({ peer }: Readonly<{ peer: Peer }>) {
label={ label={
<> <>
<History size={16} className={"shrink-0"} /> <History size={16} className={"shrink-0"} />
Last seen {t('lastSeen')}
</> </>
} }
value={ value={
peer.connected peer.connected
? "just now" ? t('justNow')
: dayjs(peer.last_seen).format("D MMMM, YYYY [at] h:mm A") + : dayjs(peer.last_seen).format("D MMMM, YYYY [at] h:mm A") +
" (" + " (" +
dayjs().to(peer.last_seen) + dayjs().to(peer.last_seen) +
@@ -704,7 +711,7 @@ function PeerInformationCard({ peer }: Readonly<{ peer: Peer }>) {
label={ label={
<> <>
<NetBirdIcon size={16} className={"shrink-0"} /> <NetBirdIcon size={16} className={"shrink-0"} />
Agent Version {t('agentVersion')}
</> </>
} }
value={peer.version} value={peer.version}
@@ -715,7 +722,7 @@ function PeerInformationCard({ peer }: Readonly<{ peer: Peer }>) {
label={ label={
<> <>
<NetBirdIcon size={16} className={"shrink-0"} /> <NetBirdIcon size={16} className={"shrink-0"} />
UI Version {t('uiVersion')}
</> </>
} }
value={peer.ui_version?.replace("netbird-desktop-ui/", "")} value={peer.ui_version?.replace("netbird-desktop-ui/", "")}
@@ -734,6 +741,8 @@ interface ModalProps {
} }
function EditNameModal({ onSuccess, peer, initialName }: Readonly<ModalProps>) { function EditNameModal({ onSuccess, peer, initialName }: Readonly<ModalProps>) {
const t = useTranslations('peers');
const tCommon = useTranslations('common');
const [name, setName] = useState(initialName); const [name, setName] = useState(initialName);
const isDisabled = useMemo(() => { const isDisabled = useMemo(() => {
@@ -760,15 +769,15 @@ function EditNameModal({ onSuccess, peer, initialName }: Readonly<ModalProps>) {
<ModalContent maxWidthClass={"max-w-md"}> <ModalContent maxWidthClass={"max-w-md"}>
<form> <form>
<ModalHeader <ModalHeader
title={"Edit Peer Name"} title={t('editPeerName')}
description={"Set an easily identifiable name for your peer."} description={t('editPeerNameDescription')}
color={"blue"} color={"blue"}
/> />
<div className={"p-default flex flex-col gap-4"}> <div className={"p-default flex flex-col gap-4"}>
<div> <div>
<Input <Input
placeholder={"e.g., AWS Servers"} placeholder={t('peerNamePlaceholder')}
value={name} value={name}
onChange={(e) => setName(e.target.value)} onChange={(e) => setName(e.target.value)}
/> />
@@ -776,11 +785,10 @@ function EditNameModal({ onSuccess, peer, initialName }: Readonly<ModalProps>) {
<Card className={"w-full px-6 pt-5 pb-4"}> <Card className={"w-full px-6 pt-5 pb-4"}>
<Label> <Label>
<Globe size={15} /> <Globe size={15} />
Domain Name Preview {t('domainNamePreview')}
</Label> </Label>
<HelpText className={"mt-2"}> <HelpText className={"mt-2"}>
If the domain name already exists, we add an increment number {t('domainNamePreviewHelp')}
suffix to it.
</HelpText> </HelpText>
<div className={"text-netbird text-sm break-all whitespace-normal"}> <div className={"text-netbird text-sm break-all whitespace-normal"}>
{domainNamePreview} {domainNamePreview}
@@ -792,7 +800,7 @@ function EditNameModal({ onSuccess, peer, initialName }: Readonly<ModalProps>) {
<div className={"flex gap-3 w-full justify-end"}> <div className={"flex gap-3 w-full justify-end"}>
<ModalClose asChild={true}> <ModalClose asChild={true}>
<Button variant={"secondary"} className={"w-full"}> <Button variant={"secondary"} className={"w-full"}>
Cancel {tCommon('cancel')}
</Button> </Button>
</ModalClose> </ModalClose>
@@ -803,7 +811,7 @@ function EditNameModal({ onSuccess, peer, initialName }: Readonly<ModalProps>) {
disabled={isDisabled} disabled={isDisabled}
type={"submit"} type={"submit"}
> >
Save {tCommon('save')}
</Button> </Button>
</div> </div>
</ModalFooter> </ModalFooter>

View File

@@ -6,6 +6,7 @@ import Paragraph from "@components/Paragraph";
import SkeletonTable from "@components/skeletons/SkeletonTable"; import SkeletonTable from "@components/skeletons/SkeletonTable";
import { usePortalElement } from "@hooks/usePortalElement"; import { usePortalElement } from "@hooks/usePortalElement";
import { ExternalLinkIcon } from "lucide-react"; import { ExternalLinkIcon } from "lucide-react";
import { useTranslations } from 'next-intl';
import React, { lazy, Suspense, useMemo } from "react"; import React, { lazy, Suspense, useMemo } from "react";
import PeerIcon from "@/assets/icons/PeerIcon"; import PeerIcon from "@/assets/icons/PeerIcon";
import PeersProvider, { usePeers } from "@/contexts/PeersProvider"; import PeersProvider, { usePeers } from "@/contexts/PeersProvider";
@@ -33,6 +34,7 @@ export default function ServersPage() {
} }
function ServersView() { function ServersView() {
const t = useTranslations('peers');
const { peers, isLoading: isPeersLoading } = usePeers(); const { peers, isLoading: isPeersLoading } = usePeers();
const { users, isLoading: isUsersLoading } = useUsers(); const { users, isLoading: isUsersLoading } = useUsers();
const { ref: headingRef, portalTarget } = const { ref: headingRef, portalTarget } =
@@ -56,26 +58,25 @@ function ServersView() {
<div className={"p-default py-6"}> <div className={"p-default py-6"}>
<Breadcrumbs> <Breadcrumbs>
<Breadcrumbs.Item <Breadcrumbs.Item
label={"Peers"} label={t('title')}
icon={<PeerIcon size={13} />} icon={<PeerIcon size={13} />}
/> />
<Breadcrumbs.Item <Breadcrumbs.Item
href={"/peers/servers"} href={"/peers/servers"}
label={"Servers"} label={t('servers')}
active active
/> />
</Breadcrumbs> </Breadcrumbs>
<h1 ref={headingRef}>Servers</h1> <h1 ref={headingRef}>{t('servers')}</h1>
<Paragraph> <Paragraph>
Servers, VMs, autonomous agents and other unattended machines with no {t('serversDescription')}{" "}
user behind them, typically enrolled with a setup key.{" "}
<InlineLink <InlineLink
href={ href={
"https://docs.netbird.io/how-to/register-machines-using-setup-keys" "https://docs.netbird.io/how-to/register-machines-using-setup-keys"
} }
target={"_blank"} target={"_blank"}
> >
Learn more {t('learnMore')}
<ExternalLinkIcon size={12} /> <ExternalLinkIcon size={12} />
</InlineLink> </InlineLink>
</Paragraph> </Paragraph>
@@ -93,18 +94,18 @@ function ServersView() {
} }
function ServersBlockedView() { function ServersBlockedView() {
const t = useTranslations('peers');
return ( return (
<div className={"flex items-center justify-center flex-col"}> <div className={"flex items-center justify-center flex-col"}>
<div className={"p-default py-6 max-w-3xl text-center"}> <div className={"p-default py-6 max-w-3xl text-center"}>
<h1>Add new server to your network</h1> <h1>{t('addNewServerTitle')}</h1>
<Paragraph className={"inline"}> <Paragraph className={"inline"}>
To get started, install NetBird on the server and enroll it using a {t('addNewServerDescription')}{" "}
setup key. If you have further questions check out our{" "}
<InlineLink <InlineLink
href={"https://docs.netbird.io/how-to/getting-started#installation"} href={"https://docs.netbird.io/how-to/getting-started#installation"}
target={"_blank"} target={"_blank"}
> >
Installation Guide {t('installationGuide')}
<ExternalLinkIcon size={12} /> <ExternalLinkIcon size={12} />
</InlineLink> </InlineLink>
</Paragraph> </Paragraph>

View File

@@ -6,6 +6,7 @@ import Paragraph from "@components/Paragraph";
import SkeletonTable from "@components/skeletons/SkeletonTable"; import SkeletonTable from "@components/skeletons/SkeletonTable";
import { usePortalElement } from "@hooks/usePortalElement"; import { usePortalElement } from "@hooks/usePortalElement";
import { ExternalLinkIcon } from "lucide-react"; import { ExternalLinkIcon } from "lucide-react";
import { useTranslations } from 'next-intl';
import React, { lazy, Suspense, useMemo } from "react"; import React, { lazy, Suspense, useMemo } from "react";
import PeerIcon from "@/assets/icons/PeerIcon"; import PeerIcon from "@/assets/icons/PeerIcon";
import PeersProvider, { usePeers } from "@/contexts/PeersProvider"; import PeersProvider, { usePeers } from "@/contexts/PeersProvider";
@@ -33,6 +34,7 @@ export default function UserDevicesPage() {
} }
function UserDevicesView() { function UserDevicesView() {
const t = useTranslations('peers');
const { peers, isLoading: isPeersLoading } = usePeers(); const { peers, isLoading: isPeersLoading } = usePeers();
const { users, isLoading: isUsersLoading } = useUsers(); const { users, isLoading: isUsersLoading } = useUsers();
const { ref: headingRef, portalTarget } = const { ref: headingRef, portalTarget } =
@@ -56,24 +58,23 @@ function UserDevicesView() {
<div className={"p-default py-6"}> <div className={"p-default py-6"}>
<Breadcrumbs> <Breadcrumbs>
<Breadcrumbs.Item <Breadcrumbs.Item
label={"Peers"} label={t('title')}
icon={<PeerIcon size={13} />} icon={<PeerIcon size={13} />}
/> />
<Breadcrumbs.Item <Breadcrumbs.Item
href={"/peers/users"} href={"/peers/users"}
label={"User Devices"} label={t('userDevices')}
active active
/> />
</Breadcrumbs> </Breadcrumbs>
<h1 ref={headingRef}>User Devices</h1> <h1 ref={headingRef}>{t('userDevices')}</h1>
<Paragraph> <Paragraph>
Laptops, phones and other personal devices with a user behind them, {t('userDevicesDescription')}{" "}
typically added when the user signs in with SSO.{" "}
<InlineLink <InlineLink
href={"https://docs.netbird.io/how-to/add-machines-to-your-network"} href={"https://docs.netbird.io/how-to/add-machines-to-your-network"}
target={"_blank"} target={"_blank"}
> >
Learn more {t('learnMore')}
<ExternalLinkIcon size={12} /> <ExternalLinkIcon size={12} />
</InlineLink> </InlineLink>
</Paragraph> </Paragraph>
@@ -91,19 +92,18 @@ function UserDevicesView() {
} }
function UserDevicesBlockedView() { function UserDevicesBlockedView() {
const t = useTranslations('peers');
return ( return (
<div className={"flex items-center justify-center flex-col"}> <div className={"flex items-center justify-center flex-col"}>
<div className={"p-default py-6 max-w-3xl text-center"}> <div className={"p-default py-6 max-w-3xl text-center"}>
<h1>Add new device to your network</h1> <h1>{t('addNewDeviceTitle')}</h1>
<Paragraph className={"inline"}> <Paragraph className={"inline"}>
To get started, install NetBird and log in using your email account. {t('addNewDeviceDescription')}{" "}
After that you should be connected. If you have further questions
check out our{" "}
<InlineLink <InlineLink
href={"https://docs.netbird.io/how-to/getting-started#installation"} href={"https://docs.netbird.io/how-to/getting-started#installation"}
target={"_blank"} target={"_blank"}
> >
Installation Guide {t('installationGuide')}
<ExternalLinkIcon size={12} /> <ExternalLinkIcon size={12} />
</InlineLink> </InlineLink>
</Paragraph> </Paragraph>

View File

@@ -3,6 +3,7 @@ import SquareIcon from "@components/SquareIcon";
import AddPeerButton from "@components/ui/AddPeerButton"; import AddPeerButton from "@components/ui/AddPeerButton";
import GetStartedTest from "@components/ui/GetStartedTest"; import GetStartedTest from "@components/ui/GetStartedTest";
import { ExternalLinkIcon } from "lucide-react"; import { ExternalLinkIcon } from "lucide-react";
import { useTranslations } from 'next-intl';
import * as React from "react"; import * as React from "react";
import PeerIcon from "@/assets/icons/PeerIcon"; import PeerIcon from "@/assets/icons/PeerIcon";
@@ -20,6 +21,7 @@ export const NoPeersGettingStarted = ({
showBackground = true, showBackground = true,
isUserDevice, isUserDevice,
}: Readonly<Props>) => { }: Readonly<Props>) => {
const t = useTranslations('peers');
return ( return (
<GetStartedTest <GetStartedTest
showBackground={showBackground} showBackground={showBackground}
@@ -30,20 +32,17 @@ export const NoPeersGettingStarted = ({
size={"large"} size={"large"}
/> />
} }
title={"Get Started with NetBird"} title={t('getStarted')}
description={ description={t('getStartedDescription')}
"It looks like you don't have any connected machines.\n" +
"Get started by adding one to your network."
}
button={<AddPeerButton isUserDevice={isUserDevice} />} button={<AddPeerButton isUserDevice={isUserDevice} />}
learnMore={ learnMore={
<> <>
Learn more in our{" "} {t('learnMoreInOur')}{" "}
<InlineLink <InlineLink
href={"https://docs.netbird.io/how-to/getting-started"} href={"https://docs.netbird.io/how-to/getting-started"}
target={"_blank"} target={"_blank"}
> >
Getting Started Guide {t('gettingStartedGuide')}
<ExternalLinkIcon size={12} /> <ExternalLinkIcon size={12} />
</InlineLink> </InlineLink>
</> </>

View File

@@ -10,6 +10,7 @@ import { cn } from "@utils/helpers";
import { isRoutingPeerSupported } from "@utils/version"; import { isRoutingPeerSupported } from "@utils/version";
import { sortBy, unionBy } from "lodash"; import { sortBy, unionBy } from "lodash";
import { ArrowUpCircleIcon, ChevronsUpDown, MapPin } from "lucide-react"; import { ArrowUpCircleIcon, ChevronsUpDown, MapPin } from "lucide-react";
import { useTranslations } from 'next-intl';
import * as React from "react"; import * as React from "react";
import { memo, useEffect, useState } from "react"; import { memo, useEffect, useState } from "react";
import { useElementSize } from "@/hooks/useElementSize"; import { useElementSize } from "@/hooks/useElementSize";
@@ -40,6 +41,7 @@ export function PeerSelector({
excludedPeers, excludedPeers,
disabled = false, disabled = false,
}: MultiSelectProps) { }: MultiSelectProps) {
const t = useTranslations('peers');
const { data: peers } = useFetchApi<Peer[]>("/peers"); const { data: peers } = useFetchApi<Peer[]>("/peers");
const [inputRef, { width }] = useElementSize<HTMLButtonElement>(); const [inputRef, { width }] = useElementSize<HTMLButtonElement>();
@@ -129,7 +131,7 @@ export function PeerSelector({
</div> </div>
</div> </div>
) : ( ) : (
<span>Select a peer...</span> <span>{t('selectPeer')}</span>
)} )}
</div> </div>
@@ -150,20 +152,20 @@ export function PeerSelector({
<DropdownInput <DropdownInput
value={search} value={search}
onChange={setSearch} onChange={setSearch}
placeholder={"Search for peers by name or ip..."} placeholder={t('searchPlaceholder')}
/> />
{unfilteredItems.length == 0 && !search && ( {unfilteredItems.length == 0 && !search && (
<div className={"max-w-xs mx-auto"}> <div className={"max-w-xs mx-auto"}>
<DropdownInfoText> <DropdownInfoText>
{"No peers available to select."} {t('noPeersAvailable')}
</DropdownInfoText> </DropdownInfoText>
</div> </div>
)} )}
{filteredItems.length == 0 && search != "" && ( {filteredItems.length == 0 && search != "" && (
<DropdownInfoText> <DropdownInfoText>
There are no peers matching your search. {t('noPeersMatching')}
</DropdownInfoText> </DropdownInfoText>
)} )}
@@ -193,9 +195,7 @@ export function PeerSelector({
className={"w-full flex items-center justify-between"} className={"w-full flex items-center justify-between"}
content={ content={
<div className={"max-w-[240px] text-xs"}> <div className={"max-w-[240px] text-xs"}>
Please update NetBird to at least{" "} {t('updateRequired')}
<span className={"text-netbird"}>v0.36.6</span> or later
to use this peer as a routing peer.
</div> </div>
} }
> >

View File

@@ -14,6 +14,7 @@ import {
TableWrapper, TableWrapper,
} from "@components/table/Table"; } from "@components/table/Table";
import NoResults from "@components/ui/NoResults"; import NoResults from "@components/ui/NoResults";
import { useTranslations } from 'next-intl';
import { RankingInfo } from "@tanstack/match-sorter-utils"; import { RankingInfo } from "@tanstack/match-sorter-utils";
import { import {
ColumnDef, ColumnDef,
@@ -193,12 +194,12 @@ export function DataTable<TData, TValue>({
columns, columns,
data, data,
children, children,
searchPlaceholder = "Search...", searchPlaceholder,
columnVisibility = {}, columnVisibility = {},
setColumnVisibility, setColumnVisibility,
sorting = [], sorting = [],
setSorting, setSorting,
text = "rows", text,
onRowClick, onRowClick,
getStartedCard, getStartedCard,
renderExpandedRow, renderExpandedRow,
@@ -249,9 +250,13 @@ export function DataTable<TData, TValue>({
initialSearch, initialSearch,
onSearchClick, onSearchClick,
}: Readonly<DataTableProps<TData, TValue>>) { }: Readonly<DataTableProps<TData, TValue>>) {
const t = useTranslations('table');
const path = usePathname(); const path = usePathname();
const isInitialRender = useRef(true); const isInitialRender = useRef(true);
const resolvedSearchPlaceholder = searchPlaceholder || t('search');
const resolvedText = text || t('rows');
const [showOverlay, setShowOverlay] = useState(false); const [showOverlay, setShowOverlay] = useState(false);
const overlayTimer = useRef<ReturnType<typeof setTimeout>>(undefined); const overlayTimer = useRef<ReturnType<typeof setTimeout>>(undefined);
useEffect(() => { useEffect(() => {
@@ -461,7 +466,7 @@ export function DataTable<TData, TValue>({
} }
resetRowSelectionOnSearch && setRowSelection?.({}); resetRowSelectionOnSearch && setRowSelection?.({});
}} }}
placeholder={searchPlaceholder} placeholder={resolvedSearchPlaceholder}
/> />
{children?.(table)} {children?.(table)}
{showResetFilterButton && ( {showResetFilterButton && (
@@ -634,7 +639,7 @@ export function DataTable<TData, TValue>({
<div className={paginationClassName}> <div className={paginationClassName}>
<DataTablePagination <DataTablePagination
table={table} table={table}
text={text} text={resolvedText}
paginationPadding={paginationPaddingClassName} paginationPadding={paginationPaddingClassName}
totalRecords={totalRecords} totalRecords={totalRecords}
/> />

View File

@@ -4,6 +4,7 @@ import { IconX } from "@tabler/icons-react";
import { cn } from "@utils/helpers"; import { cn } from "@utils/helpers";
import { AnimatePresence, motion } from "framer-motion"; import { AnimatePresence, motion } from "framer-motion";
import { MonitorSmartphoneIcon } from "lucide-react"; import { MonitorSmartphoneIcon } from "lucide-react";
import { useTranslations } from 'next-intl';
import * as React from "react"; import * as React from "react";
type Props<T> = { type Props<T> = {
@@ -15,11 +16,14 @@ type Props<T> = {
export function DataTableMultiSelectPopup<T>({ export function DataTableMultiSelectPopup<T>({
onCanceled, onCanceled,
label = "Peer(s) selected", label,
selectedItems, selectedItems,
rightSide, rightSide,
}: Props<T>) { }: Props<T>) {
const t = useTranslations('table');
const count = selectedItems?.length || 0; const count = selectedItems?.length || 0;
const defaultLabel = label || t('selected', { count });
return ( return (
<AnimatePresence> <AnimatePresence>
{count > 0 && ( {count > 0 && (
@@ -59,13 +63,13 @@ export function DataTableMultiSelectPopup<T>({
<span className={"font-medium text-white"}> <span className={"font-medium text-white"}>
{count} {count}
</span>{" "} </span>{" "}
{label} {defaultLabel}
</span> </span>
</div> </div>
<div className={"flex gap-2 items-center"}> <div className={"flex gap-2 items-center"}>
{rightSide} {rightSide}
<FullTooltip <FullTooltip
content={<span className={"text-xs"}>Cancel</span>} content={<span className={"text-xs"}>{t('cancel')}</span>}
> >
<Button <Button
onClick={onCanceled} onClick={onCanceled}

View File

@@ -7,6 +7,7 @@ import {
ChevronsLeft, ChevronsLeft,
ChevronsRight, ChevronsRight,
} from "lucide-react"; } from "lucide-react";
import { useTranslations } from 'next-intl';
interface DataTablePaginationProps<TData> { interface DataTablePaginationProps<TData> {
table: Table<TData>; table: Table<TData>;
@@ -21,6 +22,7 @@ export function DataTablePagination<TData>({
paginationPadding = "px-8 py-8", paginationPadding = "px-8 py-8",
totalRecords, totalRecords,
}: DataTablePaginationProps<TData>) { }: DataTablePaginationProps<TData>) {
const t = useTranslations('table');
const rowsPerPage = table.getState().pagination.pageSize; const rowsPerPage = table.getState().pagination.pageSize;
const currentPage = table.getState().pagination.pageIndex + 1; const currentPage = table.getState().pagination.pageIndex + 1;
const pageCount = table.getPageCount(); const pageCount = table.getPageCount();
@@ -39,11 +41,11 @@ export function DataTablePagination<TData>({
className={cn("flex items-center justify-between", paginationPadding)} className={cn("flex items-center justify-between", paginationPadding)}
> >
<div className="text-nb-gray-400"> <div className="text-nb-gray-400">
Showing{" "} {t('showing')}{" "}
<span className={"font-medium text-white"}> <span className={"font-medium text-white"}>
{showingFrom} to {showingTo} {showingFrom} {t('to')} {showingTo}
</span>{" "} </span>{" "}
of <span className={"font-medium text-white"}>{totalRows}</span>{" "} {t('of')} <span className={"font-medium text-white"}>{totalRows}</span>{" "}
{text} {text}
</div> </div>
{pageCount > 1 && ( {pageCount > 1 && (

View File

@@ -2,6 +2,7 @@ import Button from "@components/Button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@components/Tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "@components/Tooltip";
import { Table } from "@tanstack/react-table"; import { Table } from "@tanstack/react-table";
import { FilterX } from "lucide-react"; import { FilterX } from "lucide-react";
import { useTranslations } from 'next-intl';
import * as React from "react"; import * as React from "react";
import { useState } from "react"; import { useState } from "react";
@@ -16,6 +17,7 @@ export default function DataTableResetFilterButton<TData>({
onClick, onClick,
hasServerSideFilters = undefined, hasServerSideFilters = undefined,
}: Props<TData>) { }: Props<TData>) {
const t = useTranslations('table');
const [hovered, setHovered] = useState(false); const [hovered, setHovered] = useState(false);
const hasClientSideFilters = const hasClientSideFilters =
@@ -52,7 +54,7 @@ export default function DataTableResetFilterButton<TData>({
}} }}
> >
<span className={"text-xs text-neutral-300"}> <span className={"text-xs text-neutral-300"}>
Reset Filters & Search {t('resetFilters')}
</span> </span>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>

View File

@@ -4,6 +4,7 @@ import { Table } from "@tanstack/react-table";
import { cn } from "@utils/helpers"; import { cn } from "@utils/helpers";
import { Command, CommandGroup, CommandItem } from "cmdk"; import { Command, CommandGroup, CommandItem } from "cmdk";
import { Check, ChevronDown, RowsIcon } from "lucide-react"; import { Check, ChevronDown, RowsIcon } from "lucide-react";
import { useTranslations } from 'next-intl';
import * as React from "react"; import * as React from "react";
interface DataTablePaginationProps<TData> { interface DataTablePaginationProps<TData> {
@@ -17,6 +18,7 @@ export function DataTableRowsPerPage<TData>({
table, table,
disabled, disabled,
}: DataTablePaginationProps<TData>) { }: DataTablePaginationProps<TData>) {
const t = useTranslations('table');
const [open, setOpen] = React.useState(false); const [open, setOpen] = React.useState(false);
return ( return (
@@ -36,7 +38,7 @@ export function DataTableRowsPerPage<TData>({
<span className={"text-white"}> <span className={"text-white"}>
{table.getState().pagination.pageSize} {table.getState().pagination.pageSize}
</span> </span>
<span className={"text-nb-gray-300"}> rows per page</span> <span className={"text-nb-gray-300"}> {t('rowsPerPage')}</span>
</div> </div>
<ChevronDown className="h-4 w-4 opacity-50" /> <ChevronDown className="h-4 w-4 opacity-50" />
</Button> </Button>

View File

@@ -11,6 +11,7 @@ import {
ModalTrigger, ModalTrigger,
} from "@components/modal/Modal"; } from "@components/modal/Modal";
import { ExternalLinkIcon, FolderGit2Icon, PlusCircle } from "lucide-react"; import { ExternalLinkIcon, FolderGit2Icon, PlusCircle } from "lucide-react";
import { useTranslations } from 'next-intl';
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useState } from "react"; import { useState } from "react";
import { useSWRConfig } from "swr"; import { useSWRConfig } from "swr";
@@ -23,6 +24,7 @@ import Paragraph from "../Paragraph";
import Separator from "../Separator"; import Separator from "../Separator";
export const AddGroupButton = () => { export const AddGroupButton = () => {
const t = useTranslations('groups');
const create = useApiCall<Group>("/groups", true).post; const create = useApiCall<Group>("/groups", true).post;
const { mutate } = useSWRConfig(); const { mutate } = useSWRConfig();
const [name, setName] = useState<string>(""); const [name, setName] = useState<string>("");
@@ -32,9 +34,9 @@ export const AddGroupButton = () => {
const createGroup = () => { const createGroup = () => {
notify({ notify({
title: "Create Group", title: t('create'),
description: `Group '${name}' successfully created`, description: t('createSuccess', { name }),
loadingMessage: "Creating group...", loadingMessage: t('creating'),
promise: create({ name }).then((g) => { promise: create({ name }).then((g) => {
setOpen(false); setOpen(false);
setName(""); setName("");
@@ -54,26 +56,26 @@ export const AddGroupButton = () => {
className={"ml-auto h-[42px]"} className={"ml-auto h-[42px]"}
> >
<PlusCircle size={16} /> <PlusCircle size={16} />
Create Group {t('create')}
</Button> </Button>
</ModalTrigger> </ModalTrigger>
<ModalContent maxWidthClass={"max-w-xl"}> <ModalContent maxWidthClass={"max-w-xl"}>
<ModalHeader <ModalHeader
icon={<FolderGit2Icon size={18} />} icon={<FolderGit2Icon size={18} />}
title="Create Group" title={t('create')}
description="Create a group to manage and organize access in your network" description={t('createDescription')}
color="netbird" color="netbird"
/> />
<Separator /> <Separator />
<div className={"px-8 flex-col flex gap-6 py-6"}> <div className={"px-8 flex-col flex gap-6 py-6"}>
<div> <div>
<Label>Name</Label> <Label>{t('name')}</Label>
<HelpText> <HelpText>
Set an easily identifiable name for your group {t('nameHelp')}
</HelpText> </HelpText>
<Input <Input
tabIndex={0} tabIndex={0}
placeholder={"e.g., Developers"} placeholder={t('namePlaceholder')}
value={name} value={name}
onChange={(e) => setName(e.target.value)} onChange={(e) => setName(e.target.value)}
/> />
@@ -82,19 +84,19 @@ export const AddGroupButton = () => {
<ModalFooter className={"items-center"}> <ModalFooter className={"items-center"}>
<div className={"w-full"}> <div className={"w-full"}>
<Paragraph className={"text-sm mt-auto"}> <Paragraph className={"text-sm mt-auto"}>
Learn more about {t('learnMore')}
<InlineLink <InlineLink
href={"https://docs.netbird.io/how-to/manage-network-access"} href={"https://docs.netbird.io/how-to/manage-network-access"}
target={"_blank"} target={"_blank"}
> >
Groups {t('title')}
<ExternalLinkIcon size={12} /> <ExternalLinkIcon size={12} />
</InlineLink> </InlineLink>
</Paragraph> </Paragraph>
</div> </div>
<div className={"flex gap-3 w-full justify-end"}> <div className={"flex gap-3 w-full justify-end"}>
<ModalClose asChild={true}> <ModalClose asChild={true}>
<Button variant={"secondary"}>Cancel</Button> <Button variant={"secondary"}>{t('cancel')}</Button>
</ModalClose> </ModalClose>
<Button <Button
@@ -104,7 +106,7 @@ export const AddGroupButton = () => {
onClick={createGroup} onClick={createGroup}
> >
<PlusCircle size={16} /> <PlusCircle size={16} />
Create Group {t('create')}
</Button> </Button>
</div> </div>
</ModalFooter> </ModalFooter>

View File

@@ -3,6 +3,7 @@ import Button from "@components/Button";
import { Modal, ModalTrigger } from "@components/modal/Modal"; import { Modal, ModalTrigger } from "@components/modal/Modal";
import useFetchApi from "@utils/api"; import useFetchApi from "@utils/api";
import { PlusCircle } from "lucide-react"; import { PlusCircle } from "lucide-react";
import { useTranslations } from 'next-intl';
import React, { memo, useState } from "react"; import React, { memo, useState } from "react";
import { useLocalStorage } from "@/hooks/useLocalStorage"; import { useLocalStorage } from "@/hooks/useLocalStorage";
import { Peer } from "@/interfaces/Peer"; import { Peer } from "@/interfaces/Peer";
@@ -13,6 +14,7 @@ type Props = {
}; };
function AddPeerButton({ isUserDevice }: Readonly<Props>) { function AddPeerButton({ isUserDevice }: Readonly<Props>) {
const t = useTranslations('peers');
const { data: peers } = useFetchApi<Peer[]>("/peers"); const { data: peers } = useFetchApi<Peer[]>("/peers");
const { oidcUser: user } = useOidcUser(); const { oidcUser: user } = useOidcUser();
@@ -45,7 +47,7 @@ function AddPeerButton({ isUserDevice }: Readonly<Props>) {
<ModalTrigger asChild> <ModalTrigger asChild>
<Button variant={"primary"} size={"sm"} className={"ml-auto"}> <Button variant={"primary"} size={"sm"} className={"ml-auto"}>
<PlusCircle size={16} /> <PlusCircle size={16} />
Add Peer {t('addPeer')}
</Button> </Button>
</ModalTrigger> </ModalTrigger>
<SetupModal user={user} isUserDevice={isUserDevice} /> <SetupModal user={user} isUserDevice={isUserDevice} />

View File

@@ -4,6 +4,7 @@ import {
} from "@components/select/SelectDropdown"; } from "@components/select/SelectDropdown";
import useFetchApi from "@utils/api"; import useFetchApi from "@utils/api";
import { MapPin } from "lucide-react"; import { MapPin } from "lucide-react";
import { useTranslations } from 'next-intl';
import { createElement, useMemo } from "react"; import { createElement, useMemo } from "react";
import { City } from "@/interfaces/City"; import { City } from "@/interfaces/City";
@@ -13,6 +14,7 @@ type Props = {
country: string; country: string;
}; };
export const CitySelector = ({ value, onChange, country = "de" }: Props) => { export const CitySelector = ({ value, onChange, country = "de" }: Props) => {
const t = useTranslations('common');
const { data: cities, isLoading } = useFetchApi<City[]>( const { data: cities, isLoading } = useFetchApi<City[]>(
`/locations/countries/${country}/cities`, `/locations/countries/${country}/cities`,
); );
@@ -36,17 +38,17 @@ export const CitySelector = ({ value, onChange, country = "de" }: Props) => {
} as SelectOption; } as SelectOption;
}) as SelectOption[]; }) as SelectOption[];
all.unshift({ label: "All Locations", value: "", icon: pinIcon }); all.unshift({ label: t('allLocations'), value: "", icon: pinIcon });
return all; return all;
}, [cities]); }, [cities, t]);
return ( return (
<div className={"block w-full"}> <div className={"block w-full"}>
<SelectDropdown <SelectDropdown
isLoading={isLoading} isLoading={isLoading}
showSearch={true} showSearch={true}
placeholder={"Select city (optional)..."} placeholder={t('selectCityOptional')}
searchPlaceholder={"Search city..."} searchPlaceholder={t('searchCity')}
value={value} value={value}
onChange={onChange} onChange={onChange}
options={cityList || []} options={cityList || []}

View File

@@ -2,6 +2,7 @@ import {
SelectDropdown, SelectDropdown,
SelectOption, SelectOption,
} from "@components/select/SelectDropdown"; } from "@components/select/SelectDropdown";
import { useTranslations } from 'next-intl';
import { createElement, useMemo } from "react"; import { createElement, useMemo } from "react";
import RoundedFlag from "@/assets/countries/RoundedFlag"; import RoundedFlag from "@/assets/countries/RoundedFlag";
import { useCountries } from "@/contexts/CountryProvider"; import { useCountries } from "@/contexts/CountryProvider";
@@ -14,6 +15,7 @@ type Props = {
truncate?: boolean; truncate?: boolean;
}; };
export const CountrySelector = ({ value, onChange, iconSize = 20, popoverWidth, truncate }: Props) => { export const CountrySelector = ({ value, onChange, iconSize = 20, popoverWidth, truncate }: Props) => {
const t = useTranslations('common');
const { countries, isLoading } = useCountries(); const { countries, isLoading } = useCountries();
const countryList = useMemo(() => { const countryList = useMemo(() => {
@@ -41,8 +43,8 @@ export const CountrySelector = ({ value, onChange, iconSize = 20, popoverWidth,
<SelectDropdown <SelectDropdown
isLoading={isLoading} isLoading={isLoading}
showSearch={true} showSearch={true}
placeholder={"Select country..."} placeholder={t('selectCountry')}
searchPlaceholder={"Search country..."} searchPlaceholder={t('searchCountry')}
value={value} value={value}
onChange={onChange} onChange={onChange}
iconSize={iconSize} iconSize={iconSize}

View File

@@ -2,6 +2,7 @@ import Button from "@components/Button";
import Paragraph from "@components/Paragraph"; import Paragraph from "@components/Paragraph";
import { cn } from "@utils/helpers"; import { cn } from "@utils/helpers";
import { FilterX } from "lucide-react"; import { FilterX } from "lucide-react";
import { useTranslations } from 'next-intl';
import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { usePathname, useRouter, useSearchParams } from "next/navigation";
import React, { useCallback } from "react"; import React, { useCallback } from "react";
import Skeleton from "react-loading-skeleton"; import Skeleton from "react-loading-skeleton";
@@ -20,18 +21,22 @@ type Props = {
export default function NoResults({ export default function NoResults({
icon, icon,
title = "Could not find any results", title,
description = "We couldn't find any results. Please try a different search term or change your filters.", description,
children, children,
className, className,
hasFiltersApplied = false, hasFiltersApplied = false,
onResetFilters, onResetFilters,
contentClassName, contentClassName,
}: Props) { }: Props) {
const t = useTranslations('table');
const router = useRouter(); const router = useRouter();
const pathname = usePathname(); const pathname = usePathname();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const defaultTitle = title || t('noResults');
const defaultDescription = description || t('noResultsDescription');
const handleResetClick = useCallback(() => { const handleResetClick = useCallback(() => {
if (onResetFilters) { if (onResetFilters) {
onResetFilters(); onResetFilters();
@@ -83,9 +88,9 @@ export default function NoResults({
</div> </div>
<div className={"text-center"}> <div className={"text-center"}>
<h1 className={"text-2xl font-medium max-w-lg mx-auto"}>{title}</h1> <h1 className={"text-2xl font-medium max-w-lg mx-auto"}>{defaultTitle}</h1>
<Paragraph className={"justify-center my-2 !text-nb-gray-400"}> <Paragraph className={"justify-center my-2 !text-nb-gray-400"}>
{description} {defaultDescription}
</Paragraph> </Paragraph>
{hasFiltersApplied && onResetFilters && ( {hasFiltersApplied && onResetFilters && (
<Button <Button
@@ -94,7 +99,7 @@ export default function NoResults({
className="mt-4" className="mt-4"
> >
<FilterX size={16} /> <FilterX size={16} />
Reset Filters & Search {t('resetFilters')}
</Button> </Button>
)} )}
{children} {children}

727
src/i18n/messages/en.ts Normal file
View File

@@ -0,0 +1,727 @@
export default {
common: {
loading: "Loading...",
error: "Error",
success: "Success",
cancel: "Cancel",
confirm: "Confirm",
save: "Save",
delete: "Delete",
edit: "Edit",
create: "Create",
search: "Search",
refresh: "Refresh",
close: "Close",
yes: "Yes",
no: "No",
back: "Back",
next: "Next",
previous: "Previous",
actions: "Actions",
status: "Status",
name: "Name",
description: "Description",
type: "Type",
enabled: "Enabled",
disabled: "Disabled",
active: "Active",
inactive: "Inactive",
connected: "Connected",
disconnected: "Disconnected",
online: "Online",
offline: "Offline",
unknown: "Unknown",
all: "All",
none: "None",
select: "Select",
selected: "Selected",
filter: "Filter",
filters: "Filters",
reset: "Reset",
apply: "Apply",
export: "Export",
import: "Import",
download: "Download",
upload: "Upload",
copy: "Copy",
settings: "Settings",
logout: "Logout",
login: "Login",
profile: "Profile",
allLocations: "All locations",
selectCityOptional: "Select city (optional)...",
searchCity: "Search city...",
selectCountry: "Select country...",
searchCountry: "Search country..."
},
navigation: {
controlCenter: "Control Center",
peers: "Peers",
userDevices: "User Devices",
servers: "Servers",
accessControl: "Access Control",
policies: "Policies",
groups: "Groups",
postureChecks: "Posture Checks",
networkRouting: "Network Routing",
reverseProxy: "Reverse Proxy",
services: "Services",
customDomains: "Custom Domains",
clusters: "Clusters",
accessLogs: "Access Logs",
dns: "DNS",
nameservers: "Nameservers",
zones: "Zones",
dnsSettings: "DNS Settings",
team: "Team",
users: "Users",
serviceUsers: "Service Users",
activity: "Activity",
auditEvents: "Audit Events",
settings: "Settings",
documentation: "Documentation",
helpAndSupport: "Help and Support"
},
table: {
search: "Search...",
noResults: "No results",
noResultsDescription: "We couldn't find any results. Please try a different search term or change your filters.",
rowsPerPage: "Rows per page",
previous: "Previous",
next: "Next",
resetFilters: "Reset Filters & Search",
loading: "Loading...",
error: "Error loading data",
empty: "No data available",
selected: "{count} selected",
total: "Total {total} items",
showing: "Showing",
to: "to",
of: "of",
cancel: "Cancel",
rows: "rows",
selectAll: "Select all",
selectRow: "Select row"
},
auth: {
login: "Login",
logout: "Logout",
signIn: "Sign In",
signOut: "Sign Out",
email: "Email",
password: "Password",
rememberMe: "Remember me",
forgotPassword: "Forgot password?",
resetPassword: "Reset password",
createAccount: "Create Account",
accountCreated: "Account Created",
invalidCredentials: "Invalid credentials",
accountBlocked: "Account Blocked",
accountPending: "Account Pending",
sessionExpired: "Session Expired",
loginRequired: "Login Required",
unauthorized: "Unauthorized",
forbidden: "Forbidden",
accessDenied: "Access Denied",
tooManyRequests: "Too Many Requests",
tryAgainLater: "Please try again later",
contactAdmin: "Please contact your administrator"
},
errors: {
generic: "An error occurred",
networkError: "Network error",
timeout: "Request timeout",
notFound: "Not found",
serverError: "Server error",
validationError: "Validation error",
permissionDenied: "Permission denied",
resourceNotFound: "Resource not found",
serviceUnavailable: "Service unavailable"
},
peers: {
title: "Peers",
name: "Name",
ip: "IP Address",
status: "Status",
connected: "Connected",
disconnected: "Disconnected",
lastSeen: "Last Seen",
os: "OS",
version: "Version",
address: "Address",
location: "Location",
groups: "Groups",
users: "Users",
routes: "Routes",
ssh: "SSH",
rdp: "RDP",
actions: "Actions",
approve: "Approve",
reject: "Reject",
delete: "Delete",
edit: "Edit",
view: "View",
viewDetails: "View Details",
connect: "Connect",
disconnect: "Disconnect",
approvePeer: "Approve Peer",
rejectPeer: "Reject Peer",
deletePeer: "Delete Peer",
confirmDelete: "Are you sure you want to delete this peer?",
confirmApprove: "Approve peer '{name}'?",
confirmApproveDescription: "Are you sure you want to approve this peer?",
approveSuccess: "Peer {name} approved",
approveSuccessDescription: "This peer was approved and can now connect to other peers.",
approveLoading: "Approving peer...",
approved: "Approved",
pending: "Pending",
pendingApprovals: "Pending Approvals",
noPeers: "No peers available",
searchPlaceholder: "Search by name, IP, owner or group...",
selectPeer: "Select a peer...",
noPeersAvailable: "No peers available to select.",
noPeersMatching: "No peers matching your search.",
updateRequired: "Please update NetBird to at least v0.36.6 or later to use this peer as a routing peer.",
serialNumber: "Serial number",
loginExpiration: "Session Expiration",
enableLoginExpiration: "Enable Session Expiration",
disableLoginExpiration: "Disable Session Expiration",
loginExpirationUpdated: "Session expiration is {state}",
loginExpirationUpdateDescription: "Session expiration for peer {name} was successfully {state}.",
loginExpirationUpdating: "Updating session expiration...",
enableSSH: "Enable SSH Access",
disableSSH: "Disable SSH Access",
disableSSHConfirmation: "Disable SSH Access?",
disableSSHDescription: "Starting from NetBird v0.61.0, once SSH access is disabled, you cannot re-enable it again from the dashboard. You'll need to create an explicit access control policy and update your NetBird client to restore SSH functionality.",
sshLearnMore: "Learn more",
browserPeerTooltip: "Show temporary peers created by the NetBird browser client. These peers are ephemeral and will be deleted automatically after a short period of time.",
connectTooltipOffline: "Connecting via SSH or RDP is only available when the peer is online.",
expirationDisabledTooltip: "Expiration is disabled for all peers added with an setup-key.",
justNow: "just now",
searchByNameIpOwnerOrGroup: "Search by name, IP, owner or group...",
selectedCount: "{count} Peer(s) selected",
assignGroups: "Assign Groups",
deleteAll: "Delete All",
deleteAllConfirm: "Delete '{count}' {peerWord}?",
deleteAllConfirmDescription: "Are you sure you want to delete these peers? This action cannot be undone.",
deleteAllConfirmText: "Delete All",
peersDeleted: "Peers were successfully deleted",
peersDeleting: "Deleting the selected peers...",
groupsAssigned: "Groups were successfully assigned to the peers",
groupsAssigning: "Updating the groups of the selected peers...",
assigningGroups: "Assigning groups...",
groupsAssignedSuccess: "Groups successfully assigned",
assignGroupsDescription: "Assign the following groups to the selected peers. Previously assigned groups will be kept unless you choose to overwrite them.",
overwriteGroups: "Overwrite Existing Groups",
overwriteGroupsHelp: "Overwrite the existing groups of the peers with the selected ones. Previously assigned groups will be removed.",
overwrite: "Overwrite",
overwriteGroupsConfirm: "Overwrite existing groups?",
overwriteGroupsConfirmDescription: "Are you sure you want to overwrite the existing groups of your {count} selected peer(s)? This action cannot be undone.",
addGroups: "Add Groups",
assignedGroups: "Assigned Groups",
groupsSaved: "Groups of the peer were successfully saved",
groupsSaving: "Saving the groups of the peer...",
assignedGroupsDescription: "Use groups to control what this peer can access",
peerWord: "peer",
peersWord: "peers",
createPeer: "Create Peer",
addPeer: "Add Peer",
server: "Server",
servers: "Servers",
userDevice: "User Device",
userDevices: "User Devices",
operatingSystem: {
linux: "Linux",
windows: "Windows",
macos: "macOS",
android: "Android",
ios: "iOS"
},
updateAvailable: "Update available",
updateDescription: "A new version of Netbird is available. Please update your client to get the latest features and bug fixes.",
downloadChangelog: "Download & Changelog",
dnsLabelCopied: "DNS label has been copied to your clipboard",
ipCopied: "IP address has been copied to your clipboard",
viewDetailsOf: "View details of peer",
userLabel: "user: {id}",
netbirdIp: "NetBird IP",
netbirdIpv6: "NetBird IPv6",
netbirdIpCopied: "NetBird IP has been copied to your clipboard",
netbirdIpv6Copied: "NetBird IPv6 has been copied to your clipboard",
publicIp: "Public IP",
publicIpCopied: "Public IP has been copied to your clipboard",
domain: "Domain",
region: "Region",
regionCopied: "Region has been copied to your clipboard",
peerNotFoundDescription: "The peer you are attempting to access cannot be found. It may have been deleted, or you may not have permission to view it. Please verify the URL or return to the dashboard.",
tabOverview: "Overview",
tabNetworkRoutes: "Network Routes",
tabAccessiblePeers: "Accessible Peers",
tabRemoteJobs: "Remote Jobs",
peerSaved: "Peer was successfully saved",
peerSaving: "Saving the peer...",
remoteAccess: "Remote Access",
remoteAccessDescription: "Connect directly to this peer via SSH or RDP.",
domainName: "Domain Name",
hostname: "Hostname",
operatingSystemLabel: "Operating System",
registeredOn: "Registered on",
agentVersion: "Agent Version",
uiVersion: "UI Version",
peerIpUpdated: "NetBird Peer IP was successfully updated",
peerIpUpdating: "Updating peer IP...",
peerIpv6Updated: "NetBird Peer IPv6 was successfully updated",
peerIpv6Updating: "Updating peer IPv6...",
noServicesForPeer: "This peer has no services",
addServicesDescription: "Add your services to this peer and securely expose them through NetBird's reverse proxy",
editPeerName: "Edit Peer Name",
editPeerNameDescription: "Set an easily identifiable name for your peer.",
peerNamePlaceholder: "e.g., AWS Servers",
domainNamePreview: "Domain Name Preview",
domainNamePreviewHelp: "If the domain name already exists, we add an increment number suffix to it.",
userDevicesDescription: "Laptops, phones and other personal devices with a user behind them, typically added when the user signs in with SSO.",
learnMore: "Learn more",
addNewDeviceTitle: "Add new device to your network",
addNewDeviceDescription: "To get started, install NetBird and log in using your email account. After that you should be connected. If you have further questions check out our",
installationGuide: "Installation Guide",
serversDescription: "Servers, VMs, autonomous agents and other unattended machines with no user behind them, typically enrolled with a setup key.",
addNewServerTitle: "Add new server to your network",
addNewServerDescription: "To get started, install NetBird on the server and enroll it using a setup key. If you have further questions check out our",
saveGroups: "Save Groups",
getStarted: "Get Started with NetBird",
getStartedDescription: "It looks like you don't have any connected machines.\nGet started by adding one to your network.",
learnMoreInOur: "Learn more in our",
gettingStartedGuide: "Getting Started Guide"
},
policies: {
title: "Policies",
name: "Name",
description: "Description",
enabled: "Enabled",
disabled: "Disabled",
rules: "Rules",
sources: "Sources",
destinations: "Destinations",
actions: "Actions",
create: "Create Policy",
edit: "Edit Policy",
delete: "Delete Policy",
enable: "Enable",
disable: "Disable",
confirmDelete: "Are you sure you want to delete this policy?",
noPolicies: "No policies available",
searchPlaceholder: "Search policies...",
policyName: "Policy Name",
policyNameHelp: "Set an easily identifiable name for your policy",
policyNamePlaceholder: "e.g., Engineering Access",
enabledDescription: "Enable or disable this policy",
rule: "Rule",
addRule: "Add Rule",
removeRule: "Remove Rule",
source: "Source",
destination: "Destination",
protocol: "Protocol",
action: "Action",
port: "Port",
portRange: "Port Range",
direction: "Direction",
bidirectional: "Bidirectional",
oneWay: "One Way",
allow: "Allow",
deny: "Deny",
allProtocols: "All Protocols",
tcp: "TCP",
udp: "UDP",
icmp: "ICMP",
any: "Any",
netbirdSsh: "NetBird SSH",
protoPorts: "Proto & Ports",
portsPlaceholder: "e.g. 443",
addPolicy: "Add Policy",
createNewPolicy: "Create New Policy",
createNewPolicyDescription: "It looks like you don't have any policies yet. Policies can allow connections by specific protocol and ports.",
noPoliciesForGroup: "This group is not used within any policies yet",
noPoliciesForGroupDescription: "Assign this group as either a source or destination inside a policy to see them listed here.",
temporaryPoliciesTooltip: "Show temporary policies created by the NetBird browser client. These policies are ephemeral and will be deleted automatically after a short period of time.",
learnMoreAbout: "Learn more about",
accessControls: "Access Controls",
policyActions: "Policy actions",
policyEnabledSuccess: "The rule was successfully enabled",
policyDisabledSuccess: "The rule was successfully disabled",
confirmDeleteTitle: "Delete '{name}'?",
confirmDeleteDescription: "Are you sure you want to delete this access control policy? This action cannot be undone.",
updatePolicy: "Update Access Control Policy",
modalDescription: "Use this policy to restrict access to groups of resources.",
tabPolicy: "Policy",
tabNameDescription: "Name & Description",
protocolHelp: "Allow only specified network protocols. To change traffic direction and ports, select TCP or UDP protocol.",
selectProtocol: "Select protocol...",
netbirdSshHelp: "Select NetBird SSH for SSH-specific policies with fine-grained access control, or use TCP with port 22 for basic network-level SSH access",
sourceHelp: "Typically a group of user devices (e.g., Developers, Marketing) or individual devices in peer-to-peer connections that will access the destination.",
selectSource: "Select source(s)...",
destinationHelp: "Typically a group of peers or resources (e.g., Servers, Databases, Internal Services) that will be accessed by the source. Can also be an individual peer or resource.",
selectDestination: "Select destination(s)...",
resourcesBidirectionalWarning: "Some destination groups contain resources. Resources only support incoming traffic and cannot initiate connections.",
sshResourceWarning: "SSH access only works on peers, not on routed resources. Please ensure your destination groups contain peers for SSH connectivity.",
sshAccess: "SSH Access",
sshAccessHelp: "Select 'Full Access' to allow SSH as any local user, or 'Limited Access' to specify which local users each group is allowed to use.",
ports: "Ports",
portsHelp: "Allow network traffic and access only to specified ports. Select ports or port ranges between 1 and 65535.",
enablePolicy: "Enable Policy",
enablePolicyHelp: "Use this switch to enable or disable the policy.",
ruleName: "Name of the Rule",
ruleNameHelp: "Set an easily identifiable name for your policy.",
ruleNamePlaceholder: "e.g., Devs to Servers",
policyDescriptionLabel: "Description (optional)",
policyDescriptionHelp: "Write a short description to add more context to this policy.",
policyDescriptionPlaceholder: "e.g., Devs are allowed to access servers and servers are allowed to access Devs.",
accessControlDescription: "Create rules to manage access in your network and define what peers can connect.",
policyCreated: "Policy '{name}' successfully created",
policyUpdated: "Policy '{name}' successfully updated",
policyDeleted: "Policy '{name}' successfully deleted",
policyEnableLoading: "Enabling policy...",
policyDisableLoading: "Disabling policy...",
policySaveLoading: "Saving policy...",
policyDeleteLoading: "Deleting policy..."
},
groups: {
title: "Groups",
name: "Name",
peers: "Peers",
users: "Users",
policies: "Policies",
resources: "Resources",
create: "Create Group",
createDescription: "Create a group to manage and organize access in your network",
createSuccess: "Group '{name}' successfully created",
creating: "Creating group...",
nameHelp: "Set an easily identifiable name for your group",
namePlaceholder: "e.g., Developers",
learnMore: "Learn more about",
edit: "Edit Group",
delete: "Delete Group",
confirmDelete: "Are you sure you want to delete this group?",
noGroups: "No groups available",
searchPlaceholder: "Search groups...",
cancel: "Cancel",
addPeerToGroup: "Add Peer",
addUserToGroup: "Add User",
editName: "Edit Name",
groupName: "Group Name",
groupUpdated: "Group '{name}' successfully updated",
groupUpdating: "Updating group...",
assignedPeers: "Assigned Peers",
assignedUsers: "Assigned Users",
assignedResources: "Assigned Resources",
assignPeersDescription: "Use peers to control what this group can access",
assignUsersDescription: "Use users to control what this group can access",
assignResourcesDescription: "Use resources to control what this group can access",
addPeerToGroupTitle: "Add Peers to Group",
addUserToGroupTitle: "Add Users to Group",
addResourcesToGroupTitle: "Add Resources to Group",
searchPeer: "Search peers...",
searchUser: "Search users...",
searchResource: "Search resources...",
used: "Used",
unused: "Unused",
usage: "Usage",
inUse: "In Use",
nameservers: "Nameservers",
zones: "Zones",
routes: "Routes",
setupKeys: "Setup Keys",
viewDetails: "View Details",
rename: "Rename",
groupsDescription: "Organize peers, users and resources into groups to manage access."
},
users: {
title: "Users",
name: "Name",
email: "Email",
role: "Role",
status: "Status",
lastLogin: "Last Login",
actions: "Actions",
invite: "Invite User",
edit: "Edit User",
delete: "Delete User",
block: "Block",
unblock: "Unblock",
approve: "Approve",
reject: "Reject",
resendInvite: "Resend Invite",
confirmDelete: "Are you sure you want to delete this user?",
noUsers: "No users available",
searchPlaceholder: "Search users...",
owner: "Owner",
user: "User",
admin: "Admin",
blocked: "Blocked",
invited: "Invited",
active: "Active",
inactive: "Inactive",
roleOwner: "Owner",
roleAdmin: "Admin",
roleUser: "User",
roleServiceUser: "Service User",
inviteUser: "Invite User",
inviteUserDescription: "Invite a user to join your NetBird network",
userEmail: "User Email",
userEmailPlaceholder: "user@example.com",
userEmailHelp: "Enter the email address of the user you want to invite",
group: "Group",
selectGroup: "Select group...",
selectRole: "Select role...",
autoSSODescription: "This user will be added automatically via SSO when they first sign in.",
inviteSuccess: "User {email} has been invited",
inviting: "Inviting user...",
userBlocked: "User {name} has been blocked",
userUnblocked: "User {name} has been unblocked",
blocking: "Blocking user...",
unblocking: "Unblocking user...",
userDeleted: "User {name} has been deleted",
deleting: "Deleting user...",
inviteResent: "Invite has been resent",
resending: "Resending invite...",
userApproved: "User {name} has been approved",
approving: "Approving user...",
userRejected: "User {name} has been rejected",
rejecting: "Rejecting user...",
copyUserId: "Copy User ID",
copyUserIdSuccess: "User ID copied to clipboard"
},
serviceUsers: {
title: "Service Users",
name: "Name",
userId: "User ID",
createdAt: "Created At",
actions: "Actions",
create: "Create Service User",
edit: "Edit Service User",
delete: "Delete Service User",
noServiceUsers: "No service users available",
searchPlaceholder: "Search service users...",
confirmDelete: "Are you sure you want to delete this service user?",
serviceUserName: "Service User Name",
serviceUserNamePlaceholder: "e.g., CI/CD Pipeline",
serviceUserNameHelp: "Set a descriptive name for this service user",
copyUserId: "Copy User ID",
autoGroups: "Auto Assign Groups",
autoGroupsDescription: "Automatically assign these groups to peers that use this service user",
copySuccess: "Copied to clipboard",
userCreated: "Service user '{name}' successfully created",
userUpdated: "Service user '{name}' successfully updated",
userDeleted: "Service user '{name}' successfully deleted",
createLoading: "Creating service user...",
updateLoading: "Updating service user...",
deleteLoading: "Deleting service user..."
},
settings: {
title: "Settings",
general: "General",
account: "Account",
security: "Security",
notifications: "Notifications",
appearance: "Appearance",
language: "Language",
theme: "Theme",
darkMode: "Dark Mode",
lightMode: "Light Mode",
systemDefault: "System Default",
save: "Save Settings",
cancel: "Cancel",
reset: "Reset to Default",
saved: "Settings successfully saved",
saving: "Saving settings...",
profile: "Profile",
changePassword: "Change Password",
currentPassword: "Current Password",
newPassword: "New Password",
confirmPassword: "Confirm Password",
twoFactor: "Two-Factor Authentication",
enable2FA: "Enable 2FA",
disable2FA: "Disable 2FA"
},
reverseProxy: {
title: "Reverse Proxy",
description: "Configure reverse proxy services and domains",
services: "Services",
customDomains: "Custom Domains",
clusters: "Clusters",
accessLogs: "Access Logs",
createService: "Create Service",
editService: "Edit Service",
deleteService: "Delete Service",
confirmDeleteService: "Are you sure you want to delete this service?",
serviceName: "Service Name",
serviceNamePlaceholder: "e.g., My Web App",
source: "Source Peer",
destination: "Destination",
port: "Port",
protocol: "Protocol",
enabled: "Enabled",
noServices: "No services available",
searchServices: "Search services...",
domain: "Domain",
path: "Path",
target: "Target",
selectSourcePeer: "Select source peer...",
selectTargetPeer: "Select target peer...",
selectPort: "Select port...",
customDomainsDescription: "Configure custom domains for your services",
noCustomDomains: "No custom domains available",
createDomain: "Create Custom Domain",
editDomain: "Edit Custom Domain",
domainName: "Domain Name",
domainNamePlaceholder: "example.com",
clustersDescription: "Configure clusters for high availability",
noClusters: "No clusters available",
createCluster: "Create Cluster",
editCluster: "Edit Cluster",
clusterName: "Cluster Name",
clusterNamePlaceholder: "e.g., Production Cluster",
clusterDescription: "Description",
accessLogsDescription: "View access logs for your reverse proxy services",
noAccessLogs: "No access logs available"
},
dns: {
title: "DNS",
description: "Manage DNS nameservers and zones for your network",
nameservers: "Nameservers",
zones: "Zones",
settings: "DNS Settings",
createNameserver: "Create Nameserver",
editNameserver: "Edit Nameserver",
deleteNameserver: "Delete Nameserver",
confirmDeleteNameserver: "Are you sure you want to delete this nameserver?",
nameserverName: "Nameserver Name",
nameserverNamePlaceholder: "e.g., Internal DNS",
nameserverDescription: "Description",
nameserverDomains: "Domains",
noNameservers: "No nameservers available",
searchNameservers: "Search nameservers...",
createZone: "Create Zone",
editZone: "Edit Zone",
deleteZone: "Delete Zone",
confirmDeleteZone: "Are you sure you want to delete this zone?",
zoneName: "Zone Name",
zoneNamePlaceholder: "e.g., example.com",
zoneDomains: "Domains",
noZones: "No zones available",
searchZones: "Search zones...",
dnsSettings: "DNS Settings",
disabledManagementGroup: "Disabled Management Group"
},
networks: {
title: "Networks",
description: "Manage networks and routing for your organization",
networkName: "Network Name",
networkNamePlaceholder: "e.g., Engineering Network",
createNetwork: "Create Network",
editNetwork: "Edit Network",
deleteNetwork: "Delete Network",
confirmDeleteNetwork: "Are you sure you want to delete this network?",
noNetworks: "No networks available",
searchNetworks: "Search networks...",
resources: "Resources",
routingPeers: "Routing Peers",
createResource: "Create Resource",
editResource: "Edit Resource",
deleteResource: "Delete Resource",
confirmDeleteResource: "Are you sure you want to delete this resource?",
resourceName: "Resource Name",
resourceAddress: "Address",
resourceDescription: "Description",
noResources: "No resources available",
addRoutingPeer: "Add Routing Peer",
removeRoutingPeer: "Remove Routing Peer",
networkRoutes: "Network Routes",
createRoute: "Create Route",
editRoute: "Edit Route",
deleteRoute: "Delete Route",
confirmDeleteRoute: "Are you sure you want to delete this route?",
routeName: "Route Name",
routeDescription: "Description",
routeNetwork: "Network",
noRoutes: "No routes available"
},
postureChecks: {
title: "Posture Checks",
description: "Define posture checks to ensure peers meet security requirements",
createPostureCheck: "Create Posture Check",
editPostureCheck: "Edit Posture Check",
deletePostureCheck: "Delete Posture Check",
confirmDelete: "Are you sure you want to delete this posture check?",
name: "Name",
namePlaceholder: "e.g., OS Check",
checkDescription: "Description",
checks: "Checks",
addCheck: "Add Check",
removeCheck: "Remove Check",
noPostureChecks: "No posture checks available",
searchPlaceholder: "Search posture checks...",
operatingSystem: "Operating System",
geoLocation: "Geo Location",
peerNetworkRange: "Peer Network Range",
osVersion: "OS Version",
minVersion: "Minimum Version",
maxVersion: "Maximum Version",
country: "Country",
selectCountry: "Select country...",
networkRange: "Network Range"
},
setupKeys: {
title: "Setup Keys",
description: "Manage setup keys to onboard peers automatically",
createSetupKey: "Create Setup Key",
editSetupKey: "Edit Setup Key",
deleteSetupKey: "Delete Setup Key",
confirmDelete: "Are you sure you want to delete this setup key?",
name: "Name",
namePlaceholder: "e.g., Production Key",
expires: "Expires",
usageLimit: "Usage Limit",
usageCount: "Usage Count",
unlimited: "Unlimited",
never: "Never",
ephemeral: "Ephemeral",
pinnedOwner: "Pin to Owner",
autoAssignGroups: "Auto Assign Groups",
revoked: "Revoked",
active: "Active",
expired: "Expired",
copyKey: "Copy Key",
keyCopied: "Setup key copied to clipboard",
key: "Key",
copyWarning: "This is the only time the key will be shown. Copy it now and store it in a safe place.",
created: "Setup key '{name}' successfully created",
updated: "Setup key '{name}' successfully updated",
deleted: "Setup key '{name}' successfully deleted",
noSetupKeys: "No setup keys available",
searchPlaceholder: "Search setup keys..."
},
activity: {
title: "Activity",
description: "View audit events and activity logs",
auditEvents: "Audit Events",
noEvents: "No events available",
searchPlaceholder: "Search events...",
actor: "Actor",
action: "Action",
target: "Target",
timestamp: "Timestamp",
ipAddress: "IP Address",
details: "Details"
},
controlCenter: {
title: "Control Center",
description: "Overview of your network",
totalPeers: "Total Peers",
activePeers: "Active Peers",
totalPolicies: "Total Policies",
totalGroups: "Total Groups",
totalUsers: "Total Users",
totalNetworks: "Total Networks",
networkOverview: "Network Overview"
}
};

727
src/i18n/messages/zh.ts Normal file
View File

@@ -0,0 +1,727 @@
export default {
common: {
loading: "加载中...",
error: "错误",
success: "成功",
cancel: "取消",
confirm: "确认",
save: "保存",
delete: "删除",
edit: "编辑",
create: "创建",
search: "搜索",
refresh: "刷新",
close: "关闭",
yes: "是",
no: "否",
back: "返回",
next: "下一步",
previous: "上一步",
actions: "操作",
status: "状态",
name: "名称",
description: "描述",
type: "类型",
enabled: "已启用",
disabled: "已禁用",
active: "活跃",
inactive: "非活跃",
connected: "已连接",
disconnected: "已断开",
online: "在线",
offline: "离线",
unknown: "未知",
all: "全部",
none: "无",
select: "选择",
selected: "已选择",
filter: "筛选",
filters: "筛选器",
reset: "重置",
apply: "应用",
export: "导出",
import: "导入",
download: "下载",
upload: "上传",
copy: "复制",
settings: "设置",
logout: "退出登录",
login: "登录",
profile: "个人资料",
allLocations: "所有位置",
selectCityOptional: "选择城市(可选)...",
searchCity: "搜索城市...",
selectCountry: "选择国家...",
searchCountry: "搜索国家..."
},
navigation: {
controlCenter: "控制中心",
peers: "节点",
userDevices: "用户设备",
servers: "服务器",
accessControl: "访问控制",
policies: "策略",
groups: "组",
postureChecks: "姿态检查",
networkRouting: "网络路由",
reverseProxy: "反向代理",
services: "服务",
customDomains: "自定义域名",
clusters: "集群",
accessLogs: "访问日志",
dns: "DNS",
nameservers: "名称服务器",
zones: "区域",
dnsSettings: "DNS 设置",
team: "团队",
users: "用户",
serviceUsers: "服务用户",
activity: "活动",
auditEvents: "审计事件",
settings: "设置",
documentation: "文档",
helpAndSupport: "帮助和支持"
},
table: {
search: "搜索...",
noResults: "无结果",
noResultsDescription: "未找到任何结果。请尝试不同的搜索词或更改筛选条件。",
rowsPerPage: "每页行数",
previous: "上一页",
next: "下一页",
resetFilters: "重置筛选和搜索",
loading: "加载中...",
error: "加载数据时出错",
empty: "暂无数据",
selected: "已选择 {count} 项",
total: "共 {total} 项",
showing: "显示",
to: "至",
of: "共",
cancel: "取消",
rows: "行",
selectAll: "全选",
selectRow: "选择行"
},
auth: {
login: "登录",
logout: "退出登录",
signIn: "登录",
signOut: "退出",
email: "邮箱",
password: "密码",
rememberMe: "记住我",
forgotPassword: "忘记密码?",
resetPassword: "重置密码",
createAccount: "创建账户",
accountCreated: "账户已创建",
invalidCredentials: "无效的凭据",
accountBlocked: "账户已被阻止",
accountPending: "账户待审批",
sessionExpired: "会话已过期",
loginRequired: "需要登录",
unauthorized: "未授权",
forbidden: "禁止访问",
accessDenied: "访问被拒绝",
tooManyRequests: "请求过多",
tryAgainLater: "请稍后再试",
contactAdmin: "请联系管理员"
},
errors: {
generic: "发生错误",
networkError: "网络错误",
timeout: "请求超时",
notFound: "未找到",
serverError: "服务器错误",
validationError: "验证错误",
permissionDenied: "权限被拒绝",
resourceNotFound: "资源未找到",
serviceUnavailable: "服务不可用"
},
peers: {
title: "节点",
name: "名称",
ip: "IP 地址",
status: "状态",
connected: "已连接",
disconnected: "已断开",
lastSeen: "最后上线",
os: "操作系统",
version: "版本",
address: "地址",
location: "位置",
groups: "组",
users: "用户",
routes: "路由",
ssh: "SSH",
rdp: "RDP",
actions: "操作",
approve: "批准",
reject: "拒绝",
delete: "删除",
edit: "编辑",
view: "查看",
viewDetails: "查看详情",
connect: "连接",
disconnect: "断开",
approvePeer: "批准节点",
rejectPeer: "拒绝节点",
deletePeer: "删除节点",
confirmDelete: "确定要删除此节点吗?",
confirmApprove: "批准节点 '{name}'",
confirmApproveDescription: "确定要批准此节点吗?",
approveSuccess: "节点 {name} 已批准",
approveSuccessDescription: "此节点已获批准,现在可以连接到其他节点。",
approveLoading: "正在批准节点...",
approved: "已批准",
pending: "待审批",
pendingApprovals: "待审批",
noPeers: "暂无节点",
searchPlaceholder: "按名称或 IP 搜索节点...",
selectPeer: "选择一个节点...",
noPeersAvailable: "没有可选的节点。",
noPeersMatching: "没有匹配的节点。",
updateRequired: "请将 NetBird 更新到 v0.36.6 或更高版本,才能将此节点用作路由节点。",
serialNumber: "序列号",
loginExpiration: "会话过期",
enableLoginExpiration: "启用人会话过期",
disableLoginExpiration: "禁用人会话过期",
loginExpirationUpdated: "会话过期已{state}",
loginExpirationUpdateDescription: "节点 {name} 的会话过期已成功{state}。",
loginExpirationUpdating: "正在更新会话过期...",
enableSSH: "启用 SSH 访问",
disableSSH: "禁用 SSH 访问",
disableSSHConfirmation: "禁用 SSH 访问?",
disableSSHDescription: "从 NetBird v0.61.0 开始,一旦 SSH 访问被禁用,将无法再从控制台重新启用。您需要创建一个明确的访问控制策略并更新 NetBird 客户端以恢复 SSH 功能。",
sshLearnMore: "了解更多",
browserPeerTooltip: "显示由 NetBird 浏览器客户端创建的临时节点。这些节点是临时的,将在一段时间后自动删除。",
connectTooltipOffline: "只有节点在线时才能通过 SSH 或 RDP 连接。",
expirationDisabledTooltip: "通过 setup-key 添加的所有节点都会禁用过期。",
justNow: "刚刚",
searchByNameIpOwnerOrGroup: "按名称、IP、所有者或组搜索...",
selectedCount: "已选择 {count} 个节点",
assignGroups: "分配组",
deleteAll: "全部删除",
deleteAllConfirm: "删除 {count} 个{peerWord}",
deleteAllConfirmDescription: "确定要删除这些节点吗?此操作无法撤销。",
deleteAllConfirmText: "全部删除",
peersDeleted: "节点已成功删除",
peersDeleting: "正在删除所选节点...",
groupsAssigned: "组已成功分配给节点",
groupsAssigning: "正在更新所选节点的组...",
assigningGroups: "正在分配组...",
groupsAssignedSuccess: "组已成功分配",
assignGroupsDescription: "将以下组分配给所选节点。除非选择覆盖,否则将保留先前分配的组。",
overwriteGroups: "覆盖现有组",
overwriteGroupsHelp: "使用所选组覆盖节点的现有组。先前分配的组将被移除。",
overwrite: "覆盖",
overwriteGroupsConfirm: "覆盖现有组?",
overwriteGroupsConfirmDescription: "确定要覆盖所选 {count} 个节点的现有组吗?此操作无法撤销。",
addGroups: "添加组",
assignedGroups: "已分配的组",
groupsSaved: "节点的组已成功保存",
groupsSaving: "正在保存节点的组...",
assignedGroupsDescription: "使用组来控制此节点可以访问的内容",
peerWord: "节点",
peersWord: "节点",
createPeer: "创建节点",
addPeer: "添加节点",
server: "服务器",
servers: "服务器",
userDevice: "用户设备",
userDevices: "用户设备",
operatingSystem: {
linux: "Linux",
windows: "Windows",
macos: "macOS",
android: "Android",
ios: "iOS"
},
updateAvailable: "有新版本可用",
updateDescription: "NetBird 有新版本可用。请更新客户端以获取最新功能和错误修复。",
downloadChangelog: "下载与更新日志",
dnsLabelCopied: "DNS 标签已复制到剪贴板",
ipCopied: "IP 地址已复制到剪贴板",
viewDetailsOf: "查看节点详情",
userLabel: "用户:{id}",
netbirdIp: "NetBird IP",
netbirdIpv6: "NetBird IPv6",
netbirdIpCopied: "NetBird IP 已复制到剪贴板",
netbirdIpv6Copied: "NetBird IPv6 已复制到剪贴板",
publicIp: "公网 IP",
publicIpCopied: "公网 IP 已复制到剪贴板",
domain: "域名",
region: "地区",
regionCopied: "地区已复制到剪贴板",
peerNotFoundDescription: "您尝试访问的节点不存在。可能已被删除,或您没有查看权限。请验证 URL 或返回控制台。",
tabOverview: "概览",
tabNetworkRoutes: "网络路由",
tabAccessiblePeers: "可访问节点",
tabRemoteJobs: "远程任务",
peerSaved: "节点已成功保存",
peerSaving: "正在保存节点...",
remoteAccess: "远程访问",
remoteAccessDescription: "通过 SSH 或 RDP 直接连接到此节点。",
domainName: "域名",
hostname: "主机名",
operatingSystemLabel: "操作系统",
registeredOn: "注册时间",
agentVersion: "代理版本",
uiVersion: "UI 版本",
peerIpUpdated: "NetBird 节点 IP 已成功更新",
peerIpUpdating: "正在更新节点 IP...",
peerIpv6Updated: "NetBird 节点 IPv6 已成功更新",
peerIpv6Updating: "正在更新节点 IPv6...",
noServicesForPeer: "此节点未配置服务",
addServicesDescription: "将您的服务添加到此节点,并通过 NetBird 反向代理安全地暴露它们",
editPeerName: "编辑节点名称",
editPeerNameDescription: "为您的节点设置一个易于识别的名称。",
peerNamePlaceholder: "例如AWS 服务器",
domainNamePreview: "域名预览",
domainNamePreviewHelp: "如果域名已存在,我们会添加一个递增数字后缀。",
userDevicesDescription: "笔记本电脑、手机和其他由用户操作的私人设备,通常在用户使用 SSO 登录时添加。",
learnMore: "了解更多",
addNewDeviceTitle: "添加新设备到您的网络",
addNewDeviceDescription: "首先,安装 NetBird 并使用您的电子邮件账户登录。之后您应该已连接。如有其他问题,请查看我们的",
installationGuide: "安装指南",
serversDescription: "服务器、虚拟机、自治代理和其他无用户的无人值守机器,通常使用安装密钥注册。",
addNewServerTitle: "添加新服务器到您的网络",
addNewServerDescription: "首先,在服务器上安装 NetBird 并使用安装密钥注册。如有其他问题,请查看我们的",
saveGroups: "保存组",
getStarted: "开始使用 NetBird",
getStartedDescription: "看起来您还没有任何连接的设备。\n开始使用向您的网络中添加一台设备。",
learnMoreInOur: "在我们的",
gettingStartedGuide: "入门指南"
},
policies: {
title: "策略",
name: "名称",
description: "描述",
enabled: "已启用",
disabled: "已禁用",
rules: "规则",
sources: "源",
destinations: "目标",
actions: "操作",
create: "创建策略",
edit: "编辑策略",
delete: "删除策略",
enable: "启用",
disable: "禁用",
confirmDelete: "确定要删除此策略吗?",
noPolicies: "暂无策略",
searchPlaceholder: "搜索策略...",
policyName: "策略名称",
policyNameHelp: "为策略设置一个易于识别的名称",
policyNamePlaceholder: "例如:工程师访问",
enabledDescription: "启用或禁用此策略",
rule: "规则",
addRule: "添加规则",
removeRule: "删除规则",
source: "源",
destination: "目标",
protocol: "协议",
action: "动作",
port: "端口",
portRange: "端口范围",
direction: "方向",
bidirectional: "双向",
oneWay: "单向",
allow: "允许",
deny: "拒绝",
allProtocols: "所有协议",
tcp: "TCP",
udp: "UDP",
icmp: "ICMP",
any: "任意",
netbirdSsh: "NetBird SSH",
protoPorts: "协议与端口",
portsPlaceholder: "例如443",
addPolicy: "添加策略",
createNewPolicy: "创建新策略",
createNewPolicyDescription: "看起来您还没有任何策略。策略可以按特定协议和端口允许连接。",
noPoliciesForGroup: "此组尚未在任何策略中使用",
noPoliciesForGroupDescription: "将组作为策略中的源或目标分配,以在此处查看其列表。",
temporaryPoliciesTooltip: "显示由 NetBird 浏览器客户端创建的临时策略。这些策略是临时的,将在一段时间后自动删除。",
learnMoreAbout: "了解更多关于",
accessControls: "访问控制",
policyActions: "策略操作",
policyEnabledSuccess: "策略已成功启用",
policyDisabledSuccess: "策略已成功禁用",
confirmDeleteTitle: "删除 '{name}'",
confirmDeleteDescription: "确定要删除此访问控制策略吗?此操作无法撤销。",
updatePolicy: "更新访问控制策略",
modalDescription: "使用此策略限制对资源组的访问。",
tabPolicy: "策略",
tabNameDescription: "名称与描述",
protocolHelp: "仅允许指定的网络协议。要更改流量方向和端口,请选择 TCP 或 UDP 协议。",
selectProtocol: "选择协议...",
netbirdSshHelp: "为 SSH 专用策略选择 NetBird SSH 以进行细粒度访问控制,或使用端口 22 的 TCP 进行基本网络级 SSH 访问",
sourceHelp: "通常是一组用户设备(例如开发人员、市场人员)或点对点连接中将访问目标的单个设备。",
selectSource: "选择源...",
destinationHelp: "通常是一组节点或资源(例如服务器、数据库、内部服务),将由源访问。也可以是单个节点或资源。",
selectDestination: "选择目标...",
resourcesBidirectionalWarning: "某些目标组包含资源。资源仅支持入站流量,无法发起连接。",
sshResourceWarning: "SSH 访问仅适用于节点,不适用于路由资源。请确保您的目标组包含用于 SSH 连接的节点。",
sshAccess: "SSH 访问",
sshAccessHelp: "选择'完全访问'以允许任何本地用户进行 SSH或选择'受限访问'以指定每个组允许使用的本地用户。",
ports: "端口",
portsHelp: "仅允许对指定端口的网络流量和访问。选择 1 到 65535 之间的端口或端口范围。",
enablePolicy: "启用策略",
enablePolicyHelp: "使用此开关启用或禁用策略。",
ruleName: "规则名称",
ruleNameHelp: "为策略设置一个易于识别的名称。",
ruleNamePlaceholder: "例如:开发人员到服务器",
policyDescriptionLabel: "描述(可选)",
policyDescriptionHelp: "写一个简短的描述为此策略添加更多上下文。",
policyDescriptionPlaceholder: "例如:允许开发人员访问服务器,并允许服务器访问开发人员。",
accessControlDescription: "创建规则以管理网络中的访问,并定义节点可以连接的内容。",
policyCreated: "策略 '{name}' 创建成功",
policyUpdated: "策略 '{name}' 更新成功",
policyDeleted: "策略 '{name}' 已删除",
policyEnableLoading: "正在启用策略...",
policyDisableLoading: "正在禁用策略...",
policySaveLoading: "正在保存策略...",
policyDeleteLoading: "正在删除策略..."
},
groups: {
title: "组",
name: "名称",
peers: "节点",
users: "用户",
policies: "策略",
resources: "资源",
create: "创建组",
createDescription: "创建一个组来管理和组织网络中的访问权限",
createSuccess: "组 '{name}' 创建成功",
creating: "正在创建组...",
nameHelp: "为组设置一个易于识别的名称",
namePlaceholder: "例如:开发人员",
learnMore: "了解更多关于",
edit: "编辑组",
delete: "删除组",
confirmDelete: "确定要删除此组吗?",
noGroups: "暂无组",
searchPlaceholder: "搜索组...",
cancel: "取消",
addPeerToGroup: "添加节点",
addUserToGroup: "添加用户",
editName: "编辑名称",
groupName: "组名称",
groupUpdated: "组 '{name}' 更新成功",
groupUpdating: "正在更新组...",
assignedPeers: "已分配的节点",
assignedUsers: "已分配的用户",
assignedResources: "已分配的资源",
assignPeersDescription: "使用节点来控制此组可以访问的内容",
assignUsersDescription: "使用用户来控制此组可以访问的内容",
assignResourcesDescription: "使用资源来控制此组可以访问的内容",
addPeerToGroupTitle: "将节点添加到组",
addUserToGroupTitle: "将用户添加到组",
addResourcesToGroupTitle: "将资源添加到组",
searchPeer: "搜索节点...",
searchUser: "搜索用户...",
searchResource: "搜索资源...",
used: "已使用",
unused: "未使用",
usage: "使用情况",
inUse: "正在使用",
nameservers: "名称服务器",
zones: "区域",
routes: "路由",
setupKeys: "安装密钥",
viewDetails: "查看详情",
rename: "重命名",
groupsDescription: "将节点、用户和资源组织到组中以管理访问。"
},
users: {
title: "用户",
name: "名称",
email: "邮箱",
role: "角色",
status: "状态",
lastLogin: "最后登录",
actions: "操作",
invite: "邀请用户",
edit: "编辑用户",
delete: "删除用户",
block: "阻止",
unblock: "取消阻止",
approve: "批准",
reject: "拒绝",
resendInvite: "重新发送邀请",
confirmDelete: "确定要删除此用户吗?",
noUsers: "暂无用户",
searchPlaceholder: "搜索用户...",
owner: "所有者",
user: "用户",
admin: "管理员",
blocked: "已阻止",
invited: "已邀请",
active: "活跃",
inactive: "非活跃",
roleOwner: "所有者",
roleAdmin: "管理员",
roleUser: "用户",
roleServiceUser: "服务用户",
inviteUser: "邀请用户",
inviteUserDescription: "邀请用户加入您的 NetBird 网络",
userEmail: "用户邮箱",
userEmailPlaceholder: "user@example.com",
userEmailHelp: "输入要邀请的用户的电子邮件地址",
group: "组",
selectGroup: "选择组...",
selectRole: "选择角色...",
autoSSODescription: "此用户将在首次登录时通过 SSO 自动添加。",
inviteSuccess: "用户 {email} 已收到邀请",
inviting: "正在邀请用户...",
userBlocked: "用户 {name} 已被阻止",
userUnblocked: "用户 {name} 已取消阻止",
blocking: "正在阻止用户...",
unblocking: "正在取消阻止...",
userDeleted: "用户 {name} 已删除",
deleting: "正在删除用户...",
inviteResent: "邀请已重新发送",
resending: "正在重新发送邀请...",
userApproved: "用户 {name} 已批准",
approving: "正在批准用户...",
userRejected: "用户 {name} 已拒绝",
rejecting: "正在拒绝用户...",
copyUserId: "复制用户 ID",
copyUserIdSuccess: "用户 ID 已复制到剪贴板"
},
serviceUsers: {
title: "服务用户",
name: "名称",
userId: "用户 ID",
createdAt: "创建时间",
actions: "操作",
create: "创建服务用户",
edit: "编辑服务用户",
delete: "删除服务用户",
noServiceUsers: "暂无服务用户",
searchPlaceholder: "搜索服务用户...",
confirmDelete: "确定要删除此服务用户吗?",
serviceUserName: "服务用户名称",
serviceUserNamePlaceholder: "例如CI/CD 流水线",
serviceUserNameHelp: "为此服务用户设置一个描述性名称",
copyUserId: "复制用户 ID",
autoGroups: "自动分配组",
autoGroupsDescription: "自动将这些组分配给使用此服务用户的节点",
copySuccess: "已复制到剪贴板",
userCreated: "服务用户 '{name}' 创建成功",
userUpdated: "服务用户 '{name}' 更新成功",
userDeleted: "服务用户 '{name}' 已删除",
createLoading: "正在创建服务用户...",
updateLoading: "正在更新服务用户...",
deleteLoading: "正在删除服务用户..."
},
settings: {
title: "设置",
general: "常规",
account: "账户",
security: "安全",
notifications: "通知",
appearance: "外观",
language: "语言",
theme: "主题",
darkMode: "深色模式",
lightMode: "浅色模式",
systemDefault: "跟随系统",
save: "保存设置",
cancel: "取消",
reset: "重置为默认",
saved: "设置已保存",
saving: "正在保存设置...",
profile: "个人资料",
changePassword: "修改密码",
currentPassword: "当前密码",
newPassword: "新密码",
confirmPassword: "确认密码",
twoFactor: "两步验证",
enable2FA: "启用两步验证",
disable2FA: "禁用两步验证"
},
reverseProxy: {
title: "反向代理",
description: "配置反向代理服务和域名",
services: "服务",
customDomains: "自定义域名",
clusters: "集群",
accessLogs: "访问日志",
createService: "创建服务",
editService: "编辑服务",
deleteService: "删除服务",
confirmDeleteService: "确定要删除此服务吗?",
serviceName: "服务名称",
serviceNamePlaceholder: "例如:我的 Web 应用",
source: "源节点",
destination: "目标",
port: "端口",
protocol: "协议",
enabled: "已启用",
noServices: "暂无服务",
searchServices: "搜索服务...",
domain: "域名",
path: "路径",
target: "目标",
selectSourcePeer: "选择源节点...",
selectTargetPeer: "选择目标节点...",
selectPort: "选择端口...",
customDomainsDescription: "为您的服务配置自定义域名",
noCustomDomains: "暂无自定义域名",
createDomain: "创建自定义域名",
editDomain: "编辑自定义域名",
domainName: "域名",
domainNamePlaceholder: "example.com",
clustersDescription: "为高可用性配置集群",
noClusters: "暂无集群",
createCluster: "创建集群",
editCluster: "编辑集群",
clusterName: "集群名称",
clusterNamePlaceholder: "例如:生产集群",
clusterDescription: "描述",
accessLogsDescription: "查看反向代理服务的访问日志",
noAccessLogs: "暂无访问日志"
},
dns: {
title: "DNS",
description: "管理网络的 DNS 名称服务器和区域",
nameservers: "名称服务器",
zones: "区域",
settings: "DNS 设置",
createNameserver: "创建名称服务器",
editNameserver: "编辑名称服务器",
deleteNameserver: "删除名称服务器",
confirmDeleteNameserver: "确定要删除此名称服务器吗?",
nameserverName: "名称服务器名称",
nameserverNamePlaceholder: "例如:内部 DNS",
nameserverDescription: "描述",
nameserverDomains: "域名",
noNameservers: "暂无名称服务器",
searchNameservers: "搜索名称服务器...",
createZone: "创建区域",
editZone: "编辑区域",
deleteZone: "删除区域",
confirmDeleteZone: "确定要删除此区域吗?",
zoneName: "区域名称",
zoneNamePlaceholder: "例如example.com",
zoneDomains: "域名",
noZones: "暂无区域",
searchZones: "搜索区域...",
dnsSettings: "DNS 设置",
disabledManagementGroup: "禁用的管理组"
},
networks: {
title: "网络",
description: "管理组织的网络和路由",
networkName: "网络名称",
networkNamePlaceholder: "例如:工程网络",
createNetwork: "创建网络",
editNetwork: "编辑网络",
deleteNetwork: "删除网络",
confirmDeleteNetwork: "确定要删除此网络吗?",
noNetworks: "暂无网络",
searchNetworks: "搜索网络...",
resources: "资源",
routingPeers: "路由节点",
createResource: "创建资源",
editResource: "编辑资源",
deleteResource: "删除资源",
confirmDeleteResource: "确定要删除此资源吗?",
resourceName: "资源名称",
resourceAddress: "地址",
resourceDescription: "描述",
noResources: "暂无资源",
addRoutingPeer: "添加路由节点",
removeRoutingPeer: "移除路由节点",
networkRoutes: "网络路由",
createRoute: "创建路由",
editRoute: "编辑路由",
deleteRoute: "删除路由",
confirmDeleteRoute: "确定要删除此路由吗?",
routeName: "路由名称",
routeDescription: "描述",
routeNetwork: "网络",
noRoutes: "暂无路由"
},
postureChecks: {
title: "姿态检查",
description: "定义姿态检查以确保节点满足安全要求",
createPostureCheck: "创建姿态检查",
editPostureCheck: "编辑姿态检查",
deletePostureCheck: "删除姿态检查",
confirmDelete: "确定要删除此姿态检查吗?",
name: "名称",
namePlaceholder: "例如:操作系统检查",
checkDescription: "描述",
checks: "检查项",
addCheck: "添加检查",
removeCheck: "删除检查",
noPostureChecks: "暂无姿态检查",
searchPlaceholder: "搜索姿态检查...",
operatingSystem: "操作系统",
geoLocation: "地理位置",
peerNetworkRange: "节点网络范围",
osVersion: "操作系统版本",
minVersion: "最低版本",
maxVersion: "最高版本",
country: "国家",
selectCountry: "选择国家...",
networkRange: "网络范围"
},
setupKeys: {
title: "安装密钥",
description: "管理安装密钥以自动加入节点",
createSetupKey: "创建安装密钥",
editSetupKey: "编辑安装密钥",
deleteSetupKey: "删除安装密钥",
confirmDelete: "确定要删除此安装密钥吗?",
name: "名称",
namePlaceholder: "例如:生产密钥",
expires: "过期时间",
usageLimit: "使用次数限制",
usageCount: "已使用次数",
unlimited: "无限制",
never: "永不过期",
ephemeral: "临时",
pinnedOwner: "绑定所有者",
autoAssignGroups: "自动分配组",
revoked: "已撤销",
active: "活跃",
expired: "已过期",
copyKey: "复制密钥",
keyCopied: "安装密钥已复制到剪贴板",
key: "密钥",
copyWarning: "这是密钥唯一会显示的时间。请立即复制并妥善保管。",
created: "安装密钥 '{name}' 创建成功",
updated: "安装密钥 '{name}' 更新成功",
deleted: "安装密钥 '{name}' 已删除",
noSetupKeys: "暂无安装密钥",
searchPlaceholder: "搜索安装密钥..."
},
activity: {
title: "活动",
description: "查看审计事件和活动日志",
auditEvents: "审计事件",
noEvents: "暂无事件",
searchPlaceholder: "搜索事件...",
actor: "操作者",
action: "操作",
target: "目标",
timestamp: "时间戳",
ipAddress: "IP 地址",
details: "详情"
},
controlCenter: {
title: "控制中心",
description: "网络概览",
totalPeers: "节点总数",
activePeers: "活跃节点",
totalPolicies: "策略总数",
totalGroups: "组总数",
totalUsers: "用户总数",
totalNetworks: "网络总数",
networkOverview: "网络概览"
}
};

10
src/i18n/navigation.ts Normal file
View File

@@ -0,0 +1,10 @@
import { createNavigation } from 'next-intl/navigation';
import { defineRouting } from 'next-intl/routing';
export const routing = defineRouting({
locales: ['en', 'zh'],
defaultLocale: 'zh'
});
export const { Link, redirect, usePathname, useRouter } =
createNavigation(routing);

13
src/i18n/request.ts Normal file
View File

@@ -0,0 +1,13 @@
import { getRequestConfig } from 'next-intl/server';
import en from './messages/en';
import zh from './messages/zh';
const messages = { en, zh };
export default getRequestConfig(async ({ locale }) => {
const resolvedLocale = locale || 'zh';
return {
locale: resolvedLocale,
messages: messages[resolvedLocale as keyof typeof messages] || messages.zh
};
});

6
src/i18n/routing.ts Normal file
View File

@@ -0,0 +1,6 @@
import { defineRouting } from 'next-intl/routing';
export const routing = defineRouting({
locales: ['en', 'zh'],
defaultLocale: 'zh'
});

View File

@@ -20,6 +20,7 @@ import { SmallBadge } from "@components/ui/SmallBadge";
import * as React from "react"; import * as React from "react";
import ReverseProxyIcon from "@/assets/icons/ReverseProxyIcon"; import ReverseProxyIcon from "@/assets/icons/ReverseProxyIcon";
import ActivityIcon from "@/assets/icons/ActivityIcon"; import ActivityIcon from "@/assets/icons/ActivityIcon";
import { useTranslations } from 'next-intl';
type Props = { type Props = {
fullWidth?: boolean; fullWidth?: boolean;
@@ -33,6 +34,7 @@ export default function Navigation({
const { bannerHeight } = useAnnouncement(); const { bannerHeight } = useAnnouncement();
const { isNavigationCollapsed } = useApplicationContext(); const { isNavigationCollapsed } = useApplicationContext();
const { permission, isRestricted } = usePermissions(); const { permission, isRestricted } = usePermissions();
const t = useTranslations('navigation');
return ( return (
<div <div
@@ -74,27 +76,27 @@ export default function Navigation({
<SidebarItemGroup> <SidebarItemGroup>
<SidebarItem <SidebarItem
icon={<ControlCenterIcon size={16} />} icon={<ControlCenterIcon size={16} />}
label="Control Center" label={t('controlCenter')}
href={"/control-center"} href={"/control-center"}
visible={permission.policies.read} visible={permission.policies.read}
/> />
<SidebarItem <SidebarItem
icon={<PeerIcon />} icon={<PeerIcon />}
label="Peers" label={t('peers')}
href={"/peers"} href={"/peers"}
collapsible collapsible
visible={!isRestricted} visible={!isRestricted}
> >
<SidebarItem <SidebarItem
label="User Devices" label={t('userDevices')}
isChild isChild
href={"/peers/users"} href={"/peers/users"}
exactPathMatch={true} exactPathMatch={true}
visible={!isRestricted} visible={!isRestricted}
/> />
<SidebarItem <SidebarItem
label="Servers" label={t('servers')}
isChild isChild
href={"/peers/servers"} href={"/peers/servers"}
exactPathMatch={true} exactPathMatch={true}
@@ -104,25 +106,25 @@ export default function Navigation({
<SidebarItem <SidebarItem
icon={<AccessControlIcon />} icon={<AccessControlIcon />}
label="Access Control" label={t('accessControl')}
collapsible collapsible
visible={permission.policies.read} visible={permission.policies.read}
> >
<SidebarItem <SidebarItem
label="Policies" label={t('policies')}
href={"/access-control"} href={"/access-control"}
isChild isChild
exactPathMatch={true} exactPathMatch={true}
visible={permission.policies.read} visible={permission.policies.read}
/> />
<SidebarItem <SidebarItem
label="Groups" label={t('groups')}
isChild isChild
href={"/groups"} href={"/groups"}
visible={permission.policies.read} visible={permission.policies.read}
/> />
<SidebarItem <SidebarItem
label="Posture Checks" label={t('postureChecks')}
isChild isChild
href={"/posture-checks"} href={"/posture-checks"}
exactPathMatch={true} exactPathMatch={true}
@@ -137,7 +139,7 @@ export default function Navigation({
labelClassName={"pr-0"} labelClassName={"pr-0"}
label={ label={
<div className={"flex items-center gap-2"}> <div className={"flex items-center gap-2"}>
Reverse Proxy {t('reverseProxy')}
<SmallBadge <SmallBadge
text={"Beta"} text={"Beta"}
variant={"sky"} variant={"sky"}
@@ -152,28 +154,28 @@ export default function Navigation({
visible={permission?.services?.read} visible={permission?.services?.read}
> >
<SidebarItem <SidebarItem
label="Services" label={t('services')}
isChild isChild
href={"/reverse-proxy/services"} href={"/reverse-proxy/services"}
exactPathMatch={true} exactPathMatch={true}
visible={permission?.services?.read} visible={permission?.services?.read}
/> />
<SidebarItem <SidebarItem
label="Custom Domains" label={t('customDomains')}
isChild isChild
href={"/reverse-proxy/custom-domains"} href={"/reverse-proxy/custom-domains"}
exactPathMatch={true} exactPathMatch={true}
visible={permission?.services?.read} visible={permission?.services?.read}
/> />
<SidebarItem <SidebarItem
label="Clusters" label={t('clusters')}
isChild isChild
href={"/reverse-proxy/clusters"} href={"/reverse-proxy/clusters"}
exactPathMatch={true} exactPathMatch={true}
visible={permission?.services?.read} visible={permission?.services?.read}
/> />
<SidebarItem <SidebarItem
label="Access Logs" label={t('accessLogs')}
isChild isChild
href={"/reverse-proxy/logs"} href={"/reverse-proxy/logs"}
exactPathMatch={true} exactPathMatch={true}
@@ -183,25 +185,25 @@ export default function Navigation({
<SidebarItem <SidebarItem
icon={<DNSIcon />} icon={<DNSIcon />}
label="DNS" label={t('dns')}
collapsible collapsible
exactPathMatch={true} exactPathMatch={true}
visible={permission.dns.read || permission.nameservers.read} visible={permission.dns.read || permission.nameservers.read}
> >
<SidebarItem <SidebarItem
label="Nameservers" label={t('nameservers')}
isChild isChild
href={"/dns/nameservers"} href={"/dns/nameservers"}
visible={permission.nameservers.read} visible={permission.nameservers.read}
/> />
<SidebarItem <SidebarItem
label="Zones" label={t('zones')}
isChild isChild
href={"/dns/zones"} href={"/dns/zones"}
visible={permission?.dns?.read} visible={permission?.dns?.read}
/> />
<SidebarItem <SidebarItem
label="DNS Settings" label={t('dnsSettings')}
isChild isChild
href={"/dns/settings"} href={"/dns/settings"}
visible={permission.dns.read} visible={permission.dns.read}
@@ -209,18 +211,18 @@ export default function Navigation({
</SidebarItem> </SidebarItem>
<SidebarItem <SidebarItem
icon={<TeamIcon />} icon={<TeamIcon />}
label="Team" label={t('team')}
collapsible collapsible
visible={permission.users.read} visible={permission.users.read}
> >
<SidebarItem <SidebarItem
label="Users" label={t('users')}
isChild isChild
href={"/team/users"} href={"/team/users"}
visible={permission.users.read} visible={permission.users.read}
/> />
<SidebarItem <SidebarItem
label="Service Users" label={t('serviceUsers')}
isChild isChild
href={"/team/service-users"} href={"/team/service-users"}
visible={permission.users.read} visible={permission.users.read}
@@ -232,7 +234,7 @@ export default function Navigation({
<SidebarItemGroup> <SidebarItemGroup>
<SidebarItem <SidebarItem
icon={<SettingsIcon />} icon={<SettingsIcon />}
label="Settings" label={t('settings')}
href={"/settings"} href={"/settings"}
exactPathMatch={true} exactPathMatch={true}
visible={permission.settings.read} visible={permission.settings.read}
@@ -241,7 +243,7 @@ export default function Navigation({
icon={<DocsIcon />} icon={<DocsIcon />}
href={"https://docs.netbird.io/"} href={"https://docs.netbird.io/"}
target={"_blank"} target={"_blank"}
label="Documentation" label={t('documentation')}
visible={true} visible={true}
/> />
</SidebarItemGroup> </SidebarItemGroup>
@@ -272,16 +274,17 @@ export function SidebarItemGroup({ children }: SidebarItemGroupProps) {
const ActivityNavigationItem = () => { const ActivityNavigationItem = () => {
const { permission } = usePermissions(); const { permission } = usePermissions();
const t = useTranslations('navigation');
return ( return (
<SidebarItem <SidebarItem
icon={<ActivityIcon />} icon={<ActivityIcon />}
label="Activity" label={t('activity')}
collapsible collapsible
visible={permission.events.read} visible={permission.events.read}
> >
<SidebarItem <SidebarItem
label="Audit Events" label={t('auditEvents')}
href={"/events/audit"} href={"/events/audit"}
isChild isChild
exactPathMatch={true} exactPathMatch={true}

8
src/middleware.ts Normal file
View File

@@ -0,0 +1,8 @@
import createMiddleware from 'next-intl/middleware';
import { routing } from './i18n/routing';
export default createMiddleware(routing);
export const config = {
matcher: ['/((?!api|_next|_vercel|.*\\..*).*)']
};

View File

@@ -42,6 +42,7 @@ import {
SquareTerminalIcon, SquareTerminalIcon,
Text, Text,
} from "lucide-react"; } from "lucide-react";
import { useTranslations } from 'next-intl';
import React, { useMemo, useState } from "react"; import React, { useMemo, useState } from "react";
import AccessControlIcon from "@/assets/icons/AccessControlIcon"; import AccessControlIcon from "@/assets/icons/AccessControlIcon";
import { usePermissions } from "@/contexts/PermissionsProvider"; import { usePermissions } from "@/contexts/PermissionsProvider";
@@ -147,6 +148,8 @@ export function AccessControlModalContent({
disableDestinationSelector = false, disableDestinationSelector = false,
additionalResources, additionalResources,
}: Readonly<ModalProps>) { }: Readonly<ModalProps>) {
const t = useTranslations('policies');
const tCommon = useTranslations('common');
const { permission } = usePermissions(); const { permission } = usePermissions();
const { users } = useUsers(); const { users } = useUsers();
@@ -236,12 +239,10 @@ export function AccessControlModalContent({
icon={<AccessControlIcon className={"fill-netbird"} />} icon={<AccessControlIcon className={"fill-netbird"} />}
title={ title={
policy policy
? "Update Access Control Policy" ? t('updatePolicy')
: "Create New Access Control Policy" : t('createNewPolicy')
}
description={
"Use this policy to restrict access to groups of resources."
} }
description={t('modalDescription')}
color={"netbird"} color={"netbird"}
/> />
@@ -249,7 +250,7 @@ export function AccessControlModalContent({
<TabsList justify={"start"} className={"px-8"}> <TabsList justify={"start"} className={"px-8"}>
<TabsTrigger value={"policy"}> <TabsTrigger value={"policy"}>
<ArrowRightLeft size={16} /> <ArrowRightLeft size={16} />
Policy {t('tabPolicy')}
</TabsTrigger> </TabsTrigger>
<PostureCheckTabTrigger disabled={!canContinueToPostureChecks} /> <PostureCheckTabTrigger disabled={!canContinueToPostureChecks} />
<TabsTrigger value={"general"} disabled={!canContinueToPostureChecks}> <TabsTrigger value={"general"} disabled={!canContinueToPostureChecks}>
@@ -259,7 +260,7 @@ export function AccessControlModalContent({
"text-nb-gray-500 group-data-[state=active]/trigger:text-netbird transition-all" "text-nb-gray-500 group-data-[state=active]/trigger:text-netbird transition-all"
} }
/> />
Name & Description {t('tabNameDescription')}
</TabsTrigger> </TabsTrigger>
</TabsList> </TabsList>
@@ -270,12 +271,9 @@ export function AccessControlModalContent({
data-cy={"protocol-wrapper"} data-cy={"protocol-wrapper"}
> >
<div className={"w-full"}> <div className={"w-full"}>
<Label>Protocol</Label> <Label>{t('protocol')}</Label>
<HelpText className={"max-w-sm"}> <HelpText className={"max-w-sm"}>
Allow only specified network protocols. To change traffic {t('protocolHelp')}
direction and ports, select{" "}
<b className={"text-white"}>TCP</b> or{" "}
<b className={"text-white"}>UDP</b> protocol.
</HelpText> </HelpText>
</div> </div>
<Select <Select
@@ -291,14 +289,14 @@ export function AccessControlModalContent({
data-cy={"protocol-select-button"} data-cy={"protocol-select-button"}
> >
<Share2 size={15} className={"text-nb-gray-300"} /> <Share2 size={15} className={"text-nb-gray-300"} />
<SelectValue placeholder="Select protocol..." /> <SelectValue placeholder={t('selectProtocol')} />
</div> </div>
</SelectTrigger> </SelectTrigger>
<SelectContent data-cy={"protocol-selection"}> <SelectContent data-cy={"protocol-selection"}>
<SelectItem value="all">ALL</SelectItem> <SelectItem value="all">ALL</SelectItem>
<SelectItem value="tcp">TCP</SelectItem> <SelectItem value="tcp">{t('tcp')}</SelectItem>
<SelectItem value="udp">UDP</SelectItem> <SelectItem value="udp">{t('udp')}</SelectItem>
<SelectItem value="icmp">ICMP</SelectItem> <SelectItem value="icmp">{t('icmp')}</SelectItem>
<SelectItem <SelectItem
value="netbird-ssh" value="netbird-ssh"
extra={ extra={
@@ -308,15 +306,13 @@ export function AccessControlModalContent({
side={"right"} side={"right"}
content={ content={
<> <>
Select NetBird SSH for SSH-specific policies with {t('netbirdSshHelp')}
fine-grained access control, or use TCP with port 22
for basic network-level SSH access
</> </>
} }
/> />
} }
> >
NetBird SSH {t('netbirdSsh')}
</SelectItem> </SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
@@ -326,13 +322,11 @@ export function AccessControlModalContent({
<div className={"w-full self-start"}> <div className={"w-full self-start"}>
<Label className={"mb-2"}> <Label className={"mb-2"}>
<FolderDown size={15} /> <FolderDown size={15} />
Source {t('source')}
<HelpTooltip <HelpTooltip
content={ content={
<> <>
Typically a group of user devices (e.g., Developers, {t('sourceHelp')}
Marketing) or individual devices in peer-to-peer
connections that will access the destination.
</> </>
} }
/> />
@@ -340,7 +334,7 @@ export function AccessControlModalContent({
<PeerGroupSelector <PeerGroupSelector
dataCy={"source-group-selector"} dataCy={"source-group-selector"}
popoverWidth={500} popoverWidth={500}
placeholder={"Select source(s)..."} placeholder={t('selectSource')}
showRoutes={protocol !== "netbird-ssh"} showRoutes={protocol !== "netbird-ssh"}
showResources={false} showResources={false}
showPeers={protocol !== "netbird-ssh"} showPeers={protocol !== "netbird-ssh"}
@@ -369,13 +363,11 @@ export function AccessControlModalContent({
<div className={"w-full self-start"}> <div className={"w-full self-start"}>
<Label className={"mb-2"}> <Label className={"mb-2"}>
<FolderInput size={15} /> <FolderInput size={15} />
Destination {t('destination')}
<HelpTooltip <HelpTooltip
content={ content={
<> <>
Typically a group of peers or resources (e.g., Servers, {t('destinationHelp')}
Databases, Internal Services) that will be accessed by
the source. Can also be an individual peer or resource.
</> </>
} }
/> />
@@ -383,7 +375,7 @@ export function AccessControlModalContent({
<PeerGroupSelector <PeerGroupSelector
dataCy={"destination-group-selector"} dataCy={"destination-group-selector"}
popoverWidth={500} popoverWidth={500}
placeholder={"Select destination(s)..."} placeholder={t('selectDestination')}
showRoutes={true} showRoutes={true}
showResources={protocol !== "netbird-ssh"} showResources={protocol !== "netbird-ssh"}
showPeers={true} showPeers={true}
@@ -418,8 +410,7 @@ export function AccessControlModalContent({
} }
className="mb-4" className="mb-4"
> >
Some destination groups contain resources. Resources only {t('resourcesBidirectionalWarning')}
support incoming traffic and cannot initiate connections.
</Callout> </Callout>
)} )}
@@ -436,9 +427,7 @@ export function AccessControlModalContent({
} }
className="mb-6" className="mb-6"
> >
SSH access only works on peers, not on routed resources. {t('sshResourceWarning')}
Please ensure your destination groups contain peers for SSH
connectivity.
</Callout> </Callout>
)} )}
<div <div
@@ -447,12 +436,10 @@ export function AccessControlModalContent({
<div className={"w-full"}> <div className={"w-full"}>
<Label className={"flex items-center gap-2"}> <Label className={"flex items-center gap-2"}>
<SquareTerminalIcon size={15} /> <SquareTerminalIcon size={15} />
SSH Access {t('sshAccess')}
</Label> </Label>
<HelpText> <HelpText>
Select {`'Full Access'`} to allow SSH as any local user, {t('sshAccessHelp')}
or {`'Limited Access'`} to specify which local users each
group is allowed to use.
</HelpText> </HelpText>
</div> </div>
<SSHAccessType <SSHAccessType
@@ -477,11 +464,10 @@ export function AccessControlModalContent({
<div> <div>
<Label className={"flex items-center gap-2"}> <Label className={"flex items-center gap-2"}>
<Shield size={14} /> <Shield size={14} />
Ports {t('ports')}
</Label> </Label>
<HelpText> <HelpText>
Allow network traffic and access only to specified ports. {t('portsHelp')}
Select ports or port ranges between 1 and 65535.
</HelpText> </HelpText>
</div> </div>
<div className={""}> <div className={""}>
@@ -506,10 +492,10 @@ export function AccessControlModalContent({
label={ label={
<> <>
<Power size={15} /> <Power size={15} />
Enable Policy {t('enablePolicy')}
</> </>
} }
helpText={"Use this switch to enable or disable the policy."} helpText={t('enablePolicyHelp')}
/> />
</div> </div>
</TabsContent> </TabsContent>
@@ -521,9 +507,9 @@ export function AccessControlModalContent({
<TabsContent value={"general"} className={"px-8 pb-6"}> <TabsContent value={"general"} className={"px-8 pb-6"}>
<div className={"flex flex-col gap-6"}> <div className={"flex flex-col gap-6"}>
<div> <div>
<Label>Name of the Rule</Label> <Label>{t('ruleName')}</Label>
<HelpText> <HelpText>
Set an easily identifiable name for your policy. {t('ruleNameHelp')}
</HelpText> </HelpText>
<Input <Input
autoFocus={true} autoFocus={true}
@@ -531,24 +517,22 @@ export function AccessControlModalContent({
value={name} value={name}
data-cy={"policy-name"} data-cy={"policy-name"}
onChange={(e) => setName(e.target.value)} onChange={(e) => setName(e.target.value)}
placeholder={"e.g., Devs to Servers"} placeholder={t('ruleNamePlaceholder')}
disabled={ disabled={
!permission.policies.update || !permission.policies.create !permission.policies.update || !permission.policies.create
} }
/> />
</div> </div>
<div> <div>
<Label>Description (optional)</Label> <Label>{t('policyDescriptionLabel')}</Label>
<HelpText> <HelpText>
Write a short description to add more context to this policy. {t('policyDescriptionHelp')}
</HelpText> </HelpText>
<Textarea <Textarea
value={description} value={description}
data-cy={"policy-description"} data-cy={"policy-description"}
onChange={(e) => setDescription(e.target.value)} onChange={(e) => setDescription(e.target.value)}
placeholder={ placeholder={t('policyDescriptionPlaceholder')}
"e.g., Devs are allowed to access servers and servers are allowed to access Devs."
}
rows={3} rows={3}
disabled={ disabled={
!permission.policies.update || !permission.policies.create !permission.policies.update || !permission.policies.create
@@ -562,12 +546,12 @@ export function AccessControlModalContent({
<ModalFooter className={"items-center"}> <ModalFooter className={"items-center"}>
<div className={"w-full"}> <div className={"w-full"}>
<Paragraph className={"text-sm mt-auto"}> <Paragraph className={"text-sm mt-auto"}>
Learn more about {t('learnMoreAbout')}
<InlineLink <InlineLink
href={"https://docs.netbird.io/how-to/manage-network-access"} href={"https://docs.netbird.io/how-to/manage-network-access"}
target={"_blank"} target={"_blank"}
> >
Access Controls {t('accessControls')}
<ExternalLinkIcon size={12} /> <ExternalLinkIcon size={12} />
</InlineLink> </InlineLink>
</Paragraph> </Paragraph>
@@ -578,14 +562,14 @@ export function AccessControlModalContent({
{tab == "policy" && ( {tab == "policy" && (
<> <>
<ModalClose asChild={true}> <ModalClose asChild={true}>
<Button variant={"secondary"}>Cancel</Button> <Button variant={"secondary"}>{tCommon('cancel')}</Button>
</ModalClose> </ModalClose>
<Button <Button
variant={"primary"} variant={"primary"}
onClick={() => setTab("posture_checks")} onClick={() => setTab("posture_checks")}
disabled={!canContinueToPostureChecks} disabled={!canContinueToPostureChecks}
> >
Continue {tCommon('next')}
</Button> </Button>
</> </>
)} )}
@@ -596,14 +580,14 @@ export function AccessControlModalContent({
variant={"secondary"} variant={"secondary"}
onClick={() => setTab("policy")} onClick={() => setTab("policy")}
> >
Back {tCommon('back')}
</Button> </Button>
<Button <Button
variant={"primary"} variant={"primary"}
onClick={() => setTab("general")} onClick={() => setTab("general")}
disabled={!canContinueToPostureChecks} disabled={!canContinueToPostureChecks}
> >
Continue {tCommon('next')}
</Button> </Button>
</> </>
)} )}
@@ -614,7 +598,7 @@ export function AccessControlModalContent({
variant={"secondary"} variant={"secondary"}
onClick={() => setTab("posture_checks")} onClick={() => setTab("posture_checks")}
> >
Back {tCommon('back')}
</Button> </Button>
<Button <Button
@@ -630,7 +614,7 @@ export function AccessControlModalContent({
data-cy={"submit-policy"} data-cy={"submit-policy"}
> >
<PlusCircle size={16} /> <PlusCircle size={16} />
Add Policy {t('addPolicy')}
</Button> </Button>
</> </>
)} )}
@@ -638,7 +622,7 @@ export function AccessControlModalContent({
) : ( ) : (
<> <>
<ModalClose asChild={true}> <ModalClose asChild={true}>
<Button variant={"secondary"}>Cancel</Button> <Button variant={"secondary"}>{tCommon('cancel')}</Button>
</ModalClose> </ModalClose>
<Button <Button
variant={"primary"} variant={"primary"}
@@ -651,7 +635,7 @@ export function AccessControlModalContent({
} }
}} }}
> >
Save Changes {tCommon('save')}
</Button> </Button>
</> </>
)} )}

View File

@@ -7,6 +7,7 @@ import {
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@components/DropdownMenu"; } from "@components/DropdownMenu";
import { MoreVertical, PowerIcon, Trash2 } from "lucide-react"; import { MoreVertical, PowerIcon, Trash2 } from "lucide-react";
import { useTranslations } from 'next-intl';
import * as React from "react"; import * as React from "react";
import { useState } from "react"; import { useState } from "react";
import { mutate } from "swr"; import { mutate } from "swr";
@@ -20,6 +21,8 @@ type Props = {
}; };
export default function AccessControlActionCell({ policy }: Readonly<Props>) { export default function AccessControlActionCell({ policy }: Readonly<Props>) {
const t = useTranslations('policies');
const tCommon = useTranslations('common');
const { confirm } = useDialog(); const { confirm } = useDialog();
const { permission } = usePermissions(); const { permission } = usePermissions();
const { deletePolicy, updatePolicy, serializeRules } = usePolicies(); const { deletePolicy, updatePolicy, serializeRules } = usePolicies();
@@ -37,18 +40,17 @@ export default function AccessControlActionCell({ policy }: Readonly<Props>) {
mutate("/policies"); mutate("/policies");
}, },
nextEnabled nextEnabled
? "The rule was successfully enabled" ? t('policyEnabledSuccess')
: "The rule was successfully disabled", : t('policyDisabledSuccess'),
); );
}; };
const handleDelete = async () => { const handleDelete = async () => {
const choice = await confirm({ const choice = await confirm({
title: `Delete '${policy.name}'?`, title: t('confirmDeleteTitle', { name: policy.name }),
description: description: t('confirmDeleteDescription'),
"Are you sure you want to delete this access control policy? This action cannot be undone.", confirmText: tCommon('delete'),
confirmText: "Delete", cancelText: tCommon('cancel'),
cancelText: "Cancel",
type: "danger", type: "danger",
}); });
if (!choice) return; if (!choice) return;
@@ -68,7 +70,7 @@ export default function AccessControlActionCell({ policy }: Readonly<Props>) {
<Button <Button
variant={"secondary"} variant={"secondary"}
className={"!px-3"} className={"!px-3"}
aria-label={"Policy actions"} aria-label={t('policyActions')}
> >
<MoreVertical size={16} className={"shrink-0"} /> <MoreVertical size={16} className={"shrink-0"} />
</Button> </Button>
@@ -83,7 +85,7 @@ export default function AccessControlActionCell({ policy }: Readonly<Props>) {
> >
<div className={"flex gap-3 items-center"}> <div className={"flex gap-3 items-center"}>
<PowerIcon size={14} className={"shrink-0"} /> <PowerIcon size={14} className={"shrink-0"} />
{policy.enabled ? "Disable" : "Enable"} {policy.enabled ? t('disable') : t('enable')}
</div> </div>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
@@ -94,7 +96,7 @@ export default function AccessControlActionCell({ policy }: Readonly<Props>) {
> >
<div className={"flex gap-3 items-center"}> <div className={"flex gap-3 items-center"}>
<Trash2 size={14} className={"shrink-0"} /> <Trash2 size={14} className={"shrink-0"} />
Delete {tCommon('delete')}
</div> </div>
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>

View File

@@ -34,8 +34,9 @@ import GetStartedTest from "@components/ui/GetStartedTest";
import type { ColumnDef, SortingState } from "@tanstack/react-table"; import type { ColumnDef, SortingState } from "@tanstack/react-table";
import { removeAllSpaces } from "@utils/helpers"; import { removeAllSpaces } from "@utils/helpers";
import { ClockFadingIcon, ExternalLinkIcon, PlusCircle } from "lucide-react"; import { ClockFadingIcon, ExternalLinkIcon, PlusCircle } from "lucide-react";
import { useTranslations } from 'next-intl';
import { usePathname, useSearchParams } from "next/navigation"; import { usePathname, useSearchParams } from "next/navigation";
import React, { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { useSWRConfig } from "swr"; import { useSWRConfig } from "swr";
import AccessControlIcon from "@/assets/icons/AccessControlIcon"; import AccessControlIcon from "@/assets/icons/AccessControlIcon";
import NoResults from "@/components/ui/NoResults"; import NoResults from "@/components/ui/NoResults";
@@ -59,12 +60,254 @@ type Props = {
isGroupPage?: boolean; isGroupPage?: boolean;
}; };
export const AccessControlTableColumns: ColumnDef<Policy>[] = [ export default function AccessControlTable({
policies,
isLoading,
headingTarget,
isGroupPage,
}: Readonly<Props>) {
const t = useTranslations('policies');
const tCommon = useTranslations('common');
const tTable = useTranslations('table');
const { mutate } = useSWRConfig();
const path = usePathname();
const { permission } = usePermissions();
const params = useSearchParams();
const idParam = !isGroupPage ? params.get("id") : undefined;
// Default sorting state of the table
const [sorting, setSorting] = useLocalStorage<SortingState>(
"netbird-table-sort" + path,
[
{
id: "name",
desc: true,
},
],
!isGroupPage,
);
const [editModal, setEditModal] = useState(false);
const [currentRow, setCurrentRow] = useState<Policy>();
const [currentCellClicked, setCurrentCellClicked] = useState("");
const [showTemporaryPolicies, setShowTemporaryPolicies] = useState(false);
const withTemporaryPolicies = useCallback(
(condition: boolean) =>
policies?.filter((policy) =>
condition
? policy?.name?.startsWith("Temporary") &&
policy?.name?.endsWith("client") &&
policy?.description?.startsWith("Temporary") &&
policy?.description?.endsWith("client")
: !(
policy?.name?.startsWith("Temporary") &&
policy?.name?.endsWith("client") &&
policy?.description?.startsWith("Temporary") &&
policy?.description?.endsWith("client")
),
) ?? [],
[policies],
);
const tempPolicies = useMemo(
() => withTemporaryPolicies(true),
[withTemporaryPolicies],
);
const regularPolicies = useMemo(
() => withTemporaryPolicies(false),
[withTemporaryPolicies],
);
useEffect(() => {
if (showTemporaryPolicies && tempPolicies?.length === 0) {
setShowTemporaryPolicies(false);
}
}, [showTemporaryPolicies, tempPolicies]);
// Single-radio status filter mirroring the previous All / Active /
// Inactive ButtonGroup. Routed through the consolidated Filters UI.
const statusOptions = useMemo<RadioOption<boolean | undefined>[]>(
() => [
{ value: undefined, label: tCommon('all'), dotClass: "bg-nb-gray-500" },
{ value: true, label: tCommon('enabled'), dotClass: "bg-green-500" },
{ value: false, label: tCommon('disabled'), dotClass: "bg-nb-gray-700" },
],
[tCommon],
);
const protocolOptions = useMemo<CheckboxOption<string>[]>(
() => [
{ value: "tcp", label: t('tcp') },
{ value: "udp", label: t('udp') },
{ value: "icmp", label: t('icmp') },
{ value: "netbird-ssh", label: t('netbirdSsh') },
],
[t],
);
const postureOptions = useMemo<RadioOption<string | undefined>[]>(
() => [
{ value: undefined, label: tCommon('all') },
{ value: "with", label: tCommon('enabled') },
{ value: "without", label: tCommon('disabled') },
],
[tCommon],
);
const directionOptions = useMemo<RadioOption<boolean | undefined>[]>(
() => [
{ value: undefined, label: tCommon('all') },
{ value: true, label: t('bidirectional') },
{ value: false, label: t('oneWay') },
],
[t, tCommon],
);
// Groups derived from the current policies' sources + destinations,
// so the Sources/Destinations filters only offer groups that actually
// appear in the table.
const tableGroups = useMemo(() => {
if (!policies) return [];
const map = new Map<string, { id?: string; name: string }>();
for (const policy of policies) {
const rule = policy.rules?.[0];
if (!rule) continue;
const both = [
...((rule.sources as { id?: string; name?: string }[] | null) ?? []),
...((rule.destinations as { id?: string; name?: string }[] | null) ??
[]),
];
for (const g of both) {
if (g?.name && !map.has(g.name)) {
map.set(g.name, { id: g.id, name: g.name });
}
}
}
return Array.from(map.values());
}, [policies]);
const filterDefs = useMemo<TableFilterDef[]>(
() => [
{
id: "enabled",
label: t('status'),
renderPicker: (p) => (
<RadioPicker
value={p.value as boolean | undefined}
onChange={p.onChange}
close={p.close}
options={statusOptions}
/>
),
formatChip: (v) =>
formatRadioChip(v as boolean | undefined, statusOptions),
},
{
id: "source_group_names",
label: t('sources'),
renderPicker: (p) => (
<GroupsPicker
value={p.value as string[] | undefined}
onChange={p.onChange}
close={p.close}
groups={tableGroups}
/>
),
formatChip: (v) => formatGroupsChip(v as string[] | undefined),
},
{
id: "destination_group_names",
label: t('destinations'),
renderPicker: (p) => (
<GroupsPicker
value={p.value as string[] | undefined}
onChange={p.onChange}
close={p.close}
groups={tableGroups}
/>
),
formatChip: (v) => formatGroupsChip(v as string[] | undefined),
},
{
id: "direction_filter",
label: t('direction'),
renderPicker: (p) => (
<RadioPicker
value={p.value as boolean | undefined}
onChange={p.onChange}
close={p.close}
options={directionOptions}
/>
),
formatChip: (v) =>
formatRadioChip(v as boolean | undefined, directionOptions),
},
{
id: "protocol_filter",
label: t('protocol'),
renderPicker: (p) => (
<CheckboxListPicker
value={p.value as string[] | undefined}
onChange={p.onChange}
close={p.close}
options={protocolOptions}
/>
),
formatChip: (v) =>
formatCheckboxChip(
v as string[] | undefined,
protocolOptions,
tTable('of'),
),
},
{
id: "ports_filter",
label: t('port'),
renderPicker: (p) => (
<TextInputPicker
value={p.value as string | undefined}
onChange={p.onChange}
close={p.close}
placeholder={t('portsPlaceholder')}
/>
),
formatChip: (v) => formatTextChip(v as string | undefined),
},
{
id: "has_posture_checks",
label: t('postureChecks'),
renderPicker: (p) => (
<RadioPicker
value={p.value as string | undefined}
onChange={p.onChange}
close={p.close}
options={postureOptions}
/>
),
formatChip: (v) =>
formatRadioChip(v as string | undefined, postureOptions),
},
],
[
statusOptions,
protocolOptions,
postureOptions,
directionOptions,
tableGroups,
t,
tCommon,
tTable,
],
);
const columns = useMemo<ColumnDef<Policy>[]>(() => [
{ {
id: "name", id: "name",
accessorFn: (row) => removeAllSpaces(row?.name), accessorFn: (row) => removeAllSpaces(row?.name),
header: ({ column }) => { header: ({ column }) => {
return <DataTableHeader column={column}>Name</DataTableHeader>; return <DataTableHeader column={column}>{t('name')}</DataTableHeader>;
}, },
sortingFn: "text", sortingFn: "text",
filterFn: "fuzzy", filterFn: "fuzzy",
@@ -94,7 +337,7 @@ export const AccessControlTableColumns: ColumnDef<Policy>[] = [
}, },
sortingFn: "basic", sortingFn: "basic",
header: ({ column }) => { header: ({ column }) => {
return <DataTableHeader column={column}>Sources</DataTableHeader>; return <DataTableHeader column={column}>{t('sources')}</DataTableHeader>;
}, },
cell: ({ cell }) => <AccessControlSourcesCell policy={cell.row.original} />, cell: ({ cell }) => <AccessControlSourcesCell policy={cell.row.original} />,
}, },
@@ -110,7 +353,7 @@ export const AccessControlTableColumns: ColumnDef<Policy>[] = [
}, },
sortingFn: "basic", sortingFn: "basic",
header: ({ column }) => { header: ({ column }) => {
return <DataTableHeader column={column}>Direction</DataTableHeader>; return <DataTableHeader column={column}>{t('direction')}</DataTableHeader>;
}, },
cell: ({ cell }) => ( cell: ({ cell }) => (
<AccessControlDirectionCell policy={cell.row.original} /> <AccessControlDirectionCell policy={cell.row.original} />
@@ -128,7 +371,7 @@ export const AccessControlTableColumns: ColumnDef<Policy>[] = [
}, },
sortingFn: "basic", sortingFn: "basic",
header: ({ column }) => { header: ({ column }) => {
return <DataTableHeader column={column}>Destinations</DataTableHeader>; return <DataTableHeader column={column}>{t('destinations')}</DataTableHeader>;
}, },
cell: ({ cell }) => ( cell: ({ cell }) => (
<AccessControlDestinationsCell policy={cell.row.original} /> <AccessControlDestinationsCell policy={cell.row.original} />
@@ -140,7 +383,7 @@ export const AccessControlTableColumns: ColumnDef<Policy>[] = [
accessorFn: (row) => row.rules?.[0]?.protocol || "", accessorFn: (row) => row.rules?.[0]?.protocol || "",
sortingFn: "text", sortingFn: "text",
header: ({ column }) => { header: ({ column }) => {
return <DataTableHeader column={column}>Proto & Ports</DataTableHeader>; return <DataTableHeader column={column}>{t('protoPorts')}</DataTableHeader>;
}, },
cell: ({ cell }) => ( cell: ({ cell }) => (
<AccessControlProtoPortsCell policy={cell.row.original} /> <AccessControlProtoPortsCell policy={cell.row.original} />
@@ -207,243 +450,7 @@ export const AccessControlTableColumns: ColumnDef<Policy>[] = [
header: "", header: "",
cell: ({ cell }) => <AccessControlActionCell policy={cell.row.original} />, cell: ({ cell }) => <AccessControlActionCell policy={cell.row.original} />,
}, },
]; ], [t]);
export default function AccessControlTable({
policies,
isLoading,
headingTarget,
isGroupPage,
}: Readonly<Props>) {
const { mutate } = useSWRConfig();
const path = usePathname();
const { permission } = usePermissions();
const params = useSearchParams();
const idParam = !isGroupPage ? params.get("id") : undefined;
// Default sorting state of the table
const [sorting, setSorting] = useLocalStorage<SortingState>(
"netbird-table-sort" + path,
[
{
id: "name",
desc: true,
},
],
!isGroupPage,
);
const [editModal, setEditModal] = useState(false);
const [currentRow, setCurrentRow] = useState<Policy>();
const [currentCellClicked, setCurrentCellClicked] = useState("");
const [showTemporaryPolicies, setShowTemporaryPolicies] = useState(false);
const withTemporaryPolicies = useCallback(
(condition: boolean) =>
policies?.filter((policy) =>
condition
? policy?.name?.startsWith("Temporary") &&
policy?.name?.endsWith("client") &&
policy?.description?.startsWith("Temporary") &&
policy?.description?.endsWith("client")
: !(
policy?.name?.startsWith("Temporary") &&
policy?.name?.endsWith("client") &&
policy?.description?.startsWith("Temporary") &&
policy?.description?.endsWith("client")
),
) ?? [],
[policies],
);
const tempPolicies = useMemo(
() => withTemporaryPolicies(true),
[withTemporaryPolicies],
);
const regularPolicies = useMemo(
() => withTemporaryPolicies(false),
[withTemporaryPolicies],
);
useEffect(() => {
if (showTemporaryPolicies && tempPolicies?.length === 0) {
setShowTemporaryPolicies(false);
}
}, [showTemporaryPolicies, tempPolicies]);
// Single-radio status filter mirroring the previous All / Active /
// Inactive ButtonGroup. Routed through the consolidated Filters UI.
const statusOptions = useMemo<RadioOption<boolean | undefined>[]>(
() => [
{ value: undefined, label: "All", dotClass: "bg-nb-gray-500" },
{ value: true, label: "Enabled", dotClass: "bg-green-500" },
{ value: false, label: "Disabled", dotClass: "bg-nb-gray-700" },
],
[],
);
const protocolOptions = useMemo<CheckboxOption<string>[]>(
() => [
{ value: "tcp", label: "TCP" },
{ value: "udp", label: "UDP" },
{ value: "icmp", label: "ICMP" },
{ value: "netbird-ssh", label: "NetBird SSH" },
],
[],
);
const postureOptions = useMemo<RadioOption<string | undefined>[]>(
() => [
{ value: undefined, label: "All" },
{ value: "with", label: "With" },
{ value: "without", label: "Without" },
],
[],
);
const directionOptions = useMemo<RadioOption<boolean | undefined>[]>(
() => [
{ value: undefined, label: "All" },
{ value: true, label: "Bidirectional" },
{ value: false, label: "One-way" },
],
[],
);
// Groups derived from the current policies' sources + destinations,
// so the Sources/Destinations filters only offer groups that actually
// appear in the table.
const tableGroups = useMemo(() => {
if (!policies) return [];
const map = new Map<string, { id?: string; name: string }>();
for (const policy of policies) {
const rule = policy.rules?.[0];
if (!rule) continue;
const both = [
...((rule.sources as { id?: string; name?: string }[] | null) ?? []),
...((rule.destinations as { id?: string; name?: string }[] | null) ??
[]),
];
for (const g of both) {
if (g?.name && !map.has(g.name)) {
map.set(g.name, { id: g.id, name: g.name });
}
}
}
return Array.from(map.values());
}, [policies]);
const filterDefs = useMemo<TableFilterDef[]>(
() => [
{
id: "enabled",
label: "Status",
renderPicker: (p) => (
<RadioPicker
value={p.value as boolean | undefined}
onChange={p.onChange}
close={p.close}
options={statusOptions}
/>
),
formatChip: (v) =>
formatRadioChip(v as boolean | undefined, statusOptions),
},
{
id: "source_group_names",
label: "Sources",
renderPicker: (p) => (
<GroupsPicker
value={p.value as string[] | undefined}
onChange={p.onChange}
close={p.close}
groups={tableGroups}
/>
),
formatChip: (v) => formatGroupsChip(v as string[] | undefined),
},
{
id: "destination_group_names",
label: "Destinations",
renderPicker: (p) => (
<GroupsPicker
value={p.value as string[] | undefined}
onChange={p.onChange}
close={p.close}
groups={tableGroups}
/>
),
formatChip: (v) => formatGroupsChip(v as string[] | undefined),
},
{
id: "direction_filter",
label: "Direction",
renderPicker: (p) => (
<RadioPicker
value={p.value as boolean | undefined}
onChange={p.onChange}
close={p.close}
options={directionOptions}
/>
),
formatChip: (v) =>
formatRadioChip(v as boolean | undefined, directionOptions),
},
{
id: "protocol_filter",
label: "Protocol",
renderPicker: (p) => (
<CheckboxListPicker
value={p.value as string[] | undefined}
onChange={p.onChange}
close={p.close}
options={protocolOptions}
/>
),
formatChip: (v) =>
formatCheckboxChip(
v as string[] | undefined,
protocolOptions,
"protocols",
),
},
{
id: "ports_filter",
label: "Port",
renderPicker: (p) => (
<TextInputPicker
value={p.value as string | undefined}
onChange={p.onChange}
close={p.close}
placeholder={"e.g. 443"}
/>
),
formatChip: (v) => formatTextChip(v as string | undefined),
},
{
id: "has_posture_checks",
label: "Posture Checks",
renderPicker: (p) => (
<RadioPicker
value={p.value as string | undefined}
onChange={p.onChange}
close={p.close}
options={postureOptions}
/>
),
formatChip: (v) =>
formatRadioChip(v as string | undefined, postureOptions),
},
],
[
statusOptions,
protocolOptions,
postureOptions,
directionOptions,
tableGroups,
],
);
return ( return (
<> <>
@@ -476,12 +483,12 @@ export default function AccessControlTable({
] ]
: undefined : undefined
} }
text={"Access Control Policies"} text={t('title')}
sorting={sorting} sorting={sorting}
setSorting={setSorting} setSorting={setSorting}
initialPageSize={25} initialPageSize={25}
showResetFilterButton={false} showResetFilterButton={false}
columns={AccessControlTableColumns} columns={columns}
aboveTable={(table) => ( aboveTable={(table) => (
<TableFilterChips table={table} filters={filterDefs} /> <TableFilterChips table={table} filters={filterDefs} />
)} )}
@@ -504,15 +511,13 @@ export default function AccessControlTable({
setEditModal(true); setEditModal(true);
setCurrentCellClicked(cell); setCurrentCellClicked(cell);
}} }}
searchPlaceholder={"Search by name and description..."} searchPlaceholder={t('searchPlaceholder')}
getStartedCard={ getStartedCard={
isGroupPage ? ( isGroupPage ? (
<NoResults <NoResults
className={"py-4"} className={"py-4"}
title={"This group is not used within any policies yet"} title={t('noPoliciesForGroup')}
description={ description={t('noPoliciesForGroupDescription')}
"Assign this group as either a source or destination inside a policy to see them listed here."
}
icon={ icon={
<AccessControlIcon size={20} className={"fill-nb-gray-300"} /> <AccessControlIcon size={20} className={"fill-nb-gray-300"} />
} }
@@ -525,7 +530,7 @@ export default function AccessControlTable({
disabled={!permission.policies.create} disabled={!permission.policies.create}
> >
<PlusCircle size={16} /> <PlusCircle size={16} />
Add Policy {t('addPolicy')}
</Button> </Button>
</AccessControlModal> </AccessControlModal>
</div> </div>
@@ -544,10 +549,8 @@ export default function AccessControlTable({
size={"large"} size={"large"}
/> />
} }
title={"Create New Policy"} title={t('createNewPolicy')}
description={ description={t('createNewPolicyDescription')}
"It looks like you don't have any policies yet. Policies can allow connections by specific protocol and ports."
}
button={ button={
<div className={"flex gap-4 items-center justify-center"}> <div className={"flex gap-4 items-center justify-center"}>
<AccessControlModal> <AccessControlModal>
@@ -556,21 +559,21 @@ export default function AccessControlTable({
disabled={!permission.policies.create} disabled={!permission.policies.create}
> >
<PlusCircle size={16} /> <PlusCircle size={16} />
Add Policy {t('addPolicy')}
</Button> </Button>
</AccessControlModal> </AccessControlModal>
</div> </div>
} }
learnMore={ learnMore={
<> <>
Learn more about {t('learnMoreAbout')}
<InlineLink <InlineLink
href={ href={
"https://docs.netbird.io/how-to/manage-network-access" "https://docs.netbird.io/how-to/manage-network-access"
} }
target={"_blank"} target={"_blank"}
> >
Access Controls {t('accessControls')}
<ExternalLinkIcon size={12} /> <ExternalLinkIcon size={12} />
</InlineLink> </InlineLink>
</> </>
@@ -589,7 +592,7 @@ export default function AccessControlTable({
disabled={!permission.policies.create} disabled={!permission.policies.create}
> >
<PlusCircle size={16} /> <PlusCircle size={16} />
Add Policy {t('addPolicy')}
</Button> </Button>
</AccessControlModal> </AccessControlModal>
</div> </div>
@@ -619,9 +622,7 @@ export default function AccessControlTable({
<FullTooltip <FullTooltip
content={ content={
<div className={"max-w-sm text-xs"}> <div className={"max-w-sm text-xs"}>
Show temporary policies created by the NetBird browser {t('temporaryPoliciesTooltip')}
client. These policies are ephemeral and will be deleted
automatically after a short period of time.
</div> </div>
} }
> >

View File

@@ -16,6 +16,7 @@ import MultipleGroups, {
import { IconCirclePlus } from "@tabler/icons-react"; import { IconCirclePlus } from "@tabler/icons-react";
import { cn } from "@utils/helpers"; import { cn } from "@utils/helpers";
import { FolderGit2 } from "lucide-react"; import { FolderGit2 } from "lucide-react";
import { useTranslations } from 'next-intl';
import * as React from "react"; import * as React from "react";
import { useMemo } from "react"; import { useMemo } from "react";
import { useGroups } from "@/contexts/GroupsProvider"; import { useGroups } from "@/contexts/GroupsProvider";
@@ -43,14 +44,18 @@ export default function GroupsRow({
onSave, onSave,
modal, modal,
setModal, setModal,
label = "Assigned Groups", label,
description = "Use groups to control what this peer can access", description,
peer, peer,
showAddGroupButton = false, showAddGroupButton = false,
hideAllGroup = false, hideAllGroup = false,
disabled = false, disabled = false,
countOnly = false, countOnly = false,
}: Readonly<Props>) { }: Readonly<Props>) {
const t = useTranslations('peers');
const tCommon = useTranslations('common');
const resolvedLabel = label ?? t('assignedGroups');
const resolvedDescription = description ?? t('assignedGroupsDescription');
const { groups: allGroups } = useGroups(); const { groups: allGroups } = useGroups();
const { permission } = usePermissions(); const { permission } = usePermissions();
@@ -75,7 +80,7 @@ export default function GroupsRow({
{foundGroups?.length == 0 && showAddGroupButton ? ( {foundGroups?.length == 0 && showAddGroupButton ? (
<Badge variant={"gray"} useHover={true}> <Badge variant={"gray"} useHover={true}>
<IconCirclePlus size={14} /> <IconCirclePlus size={14} />
Add Groups {t('addGroups')}
</Badge> </Badge>
) : ( ) : (
<div <div
@@ -86,7 +91,7 @@ export default function GroupsRow({
> >
<MultipleGroups <MultipleGroups
groups={foundGroups} groups={foundGroups}
label={label} label={resolvedLabel}
countOnly={countOnly} countOnly={countOnly}
/> />
{!disabled && <TransparentEditIconButton />} {!disabled && <TransparentEditIconButton />}
@@ -96,8 +101,8 @@ export default function GroupsRow({
<EditGroupsModal <EditGroupsModal
groups={foundGroups} groups={foundGroups}
onSave={onSave} onSave={onSave}
label={label} label={resolvedLabel}
description={description} description={resolvedDescription}
peer={peer} peer={peer}
hideAllGroup={hideAllGroup} hideAllGroup={hideAllGroup}
disabled={disabled} disabled={disabled}
@@ -125,6 +130,9 @@ export function EditGroupsModal({
hideAllGroup = false, hideAllGroup = false,
disabled, disabled,
}: Readonly<EditGroupsModalProps>) { }: Readonly<EditGroupsModalProps>) {
const t = useTranslations('peers');
const tCommon = useTranslations('common');
const resolvedLabel = label ?? t('assignedGroups');
const [selectedGroups, setSelectedGroups, { getAllGroupCalls }] = const [selectedGroups, setSelectedGroups, { getAllGroupCalls }] =
useGroupHelper({ useGroupHelper({
initial: groups, initial: groups,
@@ -139,7 +147,7 @@ export function EditGroupsModal({
<ModalContent maxWidthClass={"max-w-xl"}> <ModalContent maxWidthClass={"max-w-xl"}>
<ModalHeader <ModalHeader
icon={<FolderGit2 size={18} />} icon={<FolderGit2 size={18} />}
title={label || "Assigned Groups"} title={resolvedLabel}
description={description} description={description}
color={"blue"} color={"blue"}
/> />
@@ -160,11 +168,11 @@ export function EditGroupsModal({
<ModalFooter className={"items-center"}> <ModalFooter className={"items-center"}>
<div className={"flex gap-3 w-full justify-end"}> <div className={"flex gap-3 w-full justify-end"}>
<ModalClose asChild={true}> <ModalClose asChild={true}>
<Button variant={"secondary"}>Cancel</Button> <Button variant={"secondary"}>{tCommon('cancel')}</Button>
</ModalClose> </ModalClose>
<Button variant={"primary"} onClick={handleSave} disabled={disabled}> <Button variant={"primary"} onClick={handleSave} disabled={disabled}>
Save Groups {t('saveGroups')}
</Button> </Button>
</div> </div>
</ModalFooter> </ModalFooter>

View File

@@ -9,6 +9,7 @@ import {
import FullTooltip from "@components/FullTooltip"; import FullTooltip from "@components/FullTooltip";
import { cn } from "@utils/helpers"; import { cn } from "@utils/helpers";
import { FolderIcon, MoreVertical, Pencil, Trash2 } from "lucide-react"; import { FolderIcon, MoreVertical, Pencil, Trash2 } from "lucide-react";
import { useTranslations } from 'next-intl';
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import React from "react"; import React from "react";
import { useGroupContext } from "@/contexts/GroupProvider"; import { useGroupContext } from "@/contexts/GroupProvider";
@@ -22,6 +23,8 @@ type Props = {
}; };
export default function GroupsActionCell({ group, inUse }: Readonly<Props>) { export default function GroupsActionCell({ group, inUse }: Readonly<Props>) {
const t = useTranslations('groups');
const tCommon = useTranslations('common');
const { permission } = usePermissions(); const { permission } = usePermissions();
const router = useRouter(); const router = useRouter();
@@ -64,7 +67,7 @@ export default function GroupsActionCell({ group, inUse }: Readonly<Props>) {
> >
<div className="flex gap-3 items-center"> <div className="flex gap-3 items-center">
<FolderIcon size={14} className="shrink-0" /> <FolderIcon size={14} className="shrink-0" />
View Details {t('viewDetails')}
</div> </div>
</DropdownMenuItem> </DropdownMenuItem>
@@ -89,7 +92,7 @@ export default function GroupsActionCell({ group, inUse }: Readonly<Props>) {
> >
<div className="flex gap-3 items-center"> <div className="flex gap-3 items-center">
<Pencil size={14} className="shrink-0" /> <Pencil size={14} className="shrink-0" />
Rename {t('rename')}
</div> </div>
</DropdownMenuItem> </DropdownMenuItem>
</FullTooltip> </FullTooltip>
@@ -115,7 +118,7 @@ export default function GroupsActionCell({ group, inUse }: Readonly<Props>) {
> >
<div className="flex gap-3 items-center"> <div className="flex gap-3 items-center">
<Trash2 size={14} className="shrink-0" /> <Trash2 size={14} className="shrink-0" />
Delete {tCommon('delete')}
</div> </div>
</DropdownMenuItem> </DropdownMenuItem>
</FullTooltip> </FullTooltip>

View File

@@ -14,8 +14,9 @@ import {
import { ColumnDef, SortingState } from "@tanstack/react-table"; import { ColumnDef, SortingState } from "@tanstack/react-table";
import { removeAllSpaces } from "@utils/helpers"; import { removeAllSpaces } from "@utils/helpers";
import { Layers3Icon } from "lucide-react"; import { Layers3Icon } from "lucide-react";
import { useTranslations } from 'next-intl';
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import React, { useMemo } from "react"; import { useMemo } from "react";
import AccessControlIcon from "@/assets/icons/AccessControlIcon"; import AccessControlIcon from "@/assets/icons/AccessControlIcon";
import DNSIcon from "@/assets/icons/DNSIcon"; import DNSIcon from "@/assets/icons/DNSIcon";
import NetworkRoutesIcon from "@/assets/icons/NetworkRoutesIcon"; import NetworkRoutesIcon from "@/assets/icons/NetworkRoutesIcon";
@@ -31,11 +32,66 @@ import GroupsNameCell from "@/modules/groups/table/GroupsNameCell";
import useGroupsUsage, { GroupUsage } from "@/modules/groups/useGroupsUsage"; import useGroupsUsage, { GroupUsage } from "@/modules/groups/useGroupsUsage";
import DNSZoneIcon from "@/assets/icons/DNSZoneIcon"; import DNSZoneIcon from "@/assets/icons/DNSZoneIcon";
export const GroupsTableColumns: ColumnDef<GroupUsage>[] = [ type Props = {
headingTarget?: HTMLHeadingElement | null;
};
export default function GroupsTable({ headingTarget }: Readonly<Props>) {
const t = useTranslations('groups');
const tTable = useTranslations('table');
const tCommon = useTranslations('common');
const { data: groups, isLoading } = useGroupsUsage();
const path = usePathname();
// Default sorting state of the table
const [sorting, setSorting] = useLocalStorage<SortingState>(
"netbird-table-sort" + path,
[
{
id: "in_use",
desc: true,
},
{
id: "name",
desc: false,
},
],
);
const usageOptions = useMemo<RadioOption<boolean | undefined>[]>(
() => [
{ value: undefined, label: tCommon('all') },
{ value: true, label: t('used') },
{ value: false, label: t('unused') },
],
[t, tCommon],
);
const filterDefs = useMemo<TableFilterDef[]>(
() => [
{
id: "in_use",
label: t('usage'),
renderPicker: (p) => (
<RadioPicker
value={p.value as boolean | undefined}
onChange={p.onChange}
close={p.close}
options={usageOptions}
/>
),
formatChip: (v) =>
formatRadioChip(v as boolean | undefined, usageOptions),
},
],
[usageOptions, t],
);
const columns = useMemo<ColumnDef<GroupUsage>[]>(() => [
{ {
accessorKey: "name", accessorKey: "name",
header: ({ column }) => { header: ({ column }) => {
return <DataTableHeader column={column}>Name</DataTableHeader>; return <DataTableHeader column={column}>{t('name')}</DataTableHeader>;
}, },
cell: ({ row }) => { cell: ({ row }) => {
const in_use = !!row.getValue("in_use"); const in_use = !!row.getValue("in_use");
@@ -58,7 +114,7 @@ export const GroupsTableColumns: ColumnDef<GroupUsage>[] = [
return ( return (
<DataTableHeader <DataTableHeader
column={column} column={column}
tooltip={<div className={"text-xs normal-case"}>Users</div>} tooltip={<div className={"text-xs normal-case"}>{t('users')}</div>}
> >
<TeamIcon size={12} /> <TeamIcon size={12} />
</DataTableHeader> </DataTableHeader>
@@ -70,7 +126,7 @@ export const GroupsTableColumns: ColumnDef<GroupUsage>[] = [
groupName={row.original.name} groupName={row.original.name}
href={`/group?id=${row.original.id}&tab=users`} href={`/group?id=${row.original.id}&tab=users`}
hidden={row.original.name === "All"} hidden={row.original.name === "All"}
text={"User(s)"} text={tTable('rows')}
count={row.original.users_count} count={row.original.users_count}
/> />
), ),
@@ -81,7 +137,7 @@ export const GroupsTableColumns: ColumnDef<GroupUsage>[] = [
return ( return (
<DataTableHeader <DataTableHeader
column={column} column={column}
tooltip={<div className={"text-xs normal-case"}>Peers</div>} tooltip={<div className={"text-xs normal-case"}>{t('peers')}</div>}
> >
<PeerIcon size={12} /> <PeerIcon size={12} />
</DataTableHeader> </DataTableHeader>
@@ -93,7 +149,7 @@ export const GroupsTableColumns: ColumnDef<GroupUsage>[] = [
groupName={row.original.name} groupName={row.original.name}
href={`/group?id=${row.original.id}&tab=peers`} href={`/group?id=${row.original.id}&tab=peers`}
hidden={row.original.name === "All"} hidden={row.original.name === "All"}
text={"Peer(s)"} text={tTable('rows')}
count={row.original.peers_count} count={row.original.peers_count}
/> />
), ),
@@ -104,7 +160,7 @@ export const GroupsTableColumns: ColumnDef<GroupUsage>[] = [
return ( return (
<DataTableHeader <DataTableHeader
column={column} column={column}
tooltip={<div className={"text-xs normal-case"}>Policies</div>} tooltip={<div className={"text-xs normal-case"}>{tTable('of')}</div>}
> >
<AccessControlIcon size={12} /> <AccessControlIcon size={12} />
</DataTableHeader> </DataTableHeader>
@@ -115,7 +171,7 @@ export const GroupsTableColumns: ColumnDef<GroupUsage>[] = [
icon={<AccessControlIcon size={10} />} icon={<AccessControlIcon size={10} />}
groupName={row.original.name} groupName={row.original.name}
href={`/group?id=${row.original.id}&tab=policies`} href={`/group?id=${row.original.id}&tab=policies`}
text={row.original.policies_count === 1 ? "Policy" : "Policies"} text={t('policies')}
count={row.original.policies_count} count={row.original.policies_count}
/> />
), ),
@@ -127,7 +183,7 @@ export const GroupsTableColumns: ColumnDef<GroupUsage>[] = [
<DataTableHeader <DataTableHeader
column={column} column={column}
tooltip={ tooltip={
<div className={"text-xs normal-case"}>Network Resources</div> <div className={"text-xs normal-case"}>{t('resources')}</div>
} }
> >
<Layers3Icon size={12} /> <Layers3Icon size={12} />
@@ -139,7 +195,7 @@ export const GroupsTableColumns: ColumnDef<GroupUsage>[] = [
icon={<Layers3Icon size={10} />} icon={<Layers3Icon size={10} />}
groupName={row.original.name} groupName={row.original.name}
href={`/group?id=${row.original.id}&tab=resources`} href={`/group?id=${row.original.id}&tab=resources`}
text={"Network Resource(s)"} text={t('resources')}
count={row.original.resources_count} count={row.original.resources_count}
/> />
), ),
@@ -150,7 +206,7 @@ export const GroupsTableColumns: ColumnDef<GroupUsage>[] = [
return ( return (
<DataTableHeader <DataTableHeader
column={column} column={column}
tooltip={<div className={"text-xs normal-case"}>Network Routes</div>} tooltip={<div className={"text-xs normal-case"}>{t('routes')}</div>}
> >
<NetworkRoutesIcon size={12} /> <NetworkRoutesIcon size={12} />
</DataTableHeader> </DataTableHeader>
@@ -161,7 +217,7 @@ export const GroupsTableColumns: ColumnDef<GroupUsage>[] = [
icon={<NetworkRoutesIcon size={10} />} icon={<NetworkRoutesIcon size={10} />}
groupName={row.original.name} groupName={row.original.name}
href={`/group?id=${row.original.id}&tab=network-routes`} href={`/group?id=${row.original.id}&tab=network-routes`}
text={"Network Route(s)"} text={t('routes')}
count={row.original.routes_count} count={row.original.routes_count}
/> />
), ),
@@ -172,7 +228,7 @@ export const GroupsTableColumns: ColumnDef<GroupUsage>[] = [
return ( return (
<DataTableHeader <DataTableHeader
column={column} column={column}
tooltip={<div className={"text-xs normal-case"}>Nameservers</div>} tooltip={<div className={"text-xs normal-case"}>{t('nameservers')}</div>}
> >
<DNSIcon size={12} /> <DNSIcon size={12} />
</DataTableHeader> </DataTableHeader>
@@ -183,7 +239,7 @@ export const GroupsTableColumns: ColumnDef<GroupUsage>[] = [
icon={<DNSIcon size={10} />} icon={<DNSIcon size={10} />}
groupName={row.original.name} groupName={row.original.name}
href={`/group?id=${row.original.id}&tab=nameservers`} href={`/group?id=${row.original.id}&tab=nameservers`}
text={"Nameserver(s)"} text={t('nameservers')}
count={row.original.nameservers_count} count={row.original.nameservers_count}
/> />
), ),
@@ -194,7 +250,7 @@ export const GroupsTableColumns: ColumnDef<GroupUsage>[] = [
return ( return (
<DataTableHeader <DataTableHeader
column={column} column={column}
tooltip={<div className={"text-xs normal-case"}>Zones</div>} tooltip={<div className={"text-xs normal-case"}>{t('zones')}</div>}
> >
<DNSZoneIcon size={16} /> <DNSZoneIcon size={16} />
</DataTableHeader> </DataTableHeader>
@@ -205,7 +261,7 @@ export const GroupsTableColumns: ColumnDef<GroupUsage>[] = [
icon={<DNSZoneIcon size={14} />} icon={<DNSZoneIcon size={14} />}
groupName={row.original.name} groupName={row.original.name}
href={`/group?id=${row.original.id}&tab=zones`} href={`/group?id=${row.original.id}&tab=zones`}
text={"Zone(s)"} text={t('zones')}
count={row.original.zones_count} count={row.original.zones_count}
/> />
), ),
@@ -217,7 +273,7 @@ export const GroupsTableColumns: ColumnDef<GroupUsage>[] = [
<DataTableHeader <DataTableHeader
column={column} column={column}
center={true} center={true}
tooltip={<div className={"text-xs normal-case"}>Setup Keys</div>} tooltip={<div className={"text-xs normal-case"}>{t('setupKeys')}</div>}
> >
<SetupKeysIcon size={12} /> <SetupKeysIcon size={12} />
</DataTableHeader> </DataTableHeader>
@@ -229,7 +285,7 @@ export const GroupsTableColumns: ColumnDef<GroupUsage>[] = [
groupName={row.original.name} groupName={row.original.name}
href={`/group?id=${row.original.id}&tab=setup-keys`} href={`/group?id=${row.original.id}&tab=setup-keys`}
hidden={row.original.name === "All"} hidden={row.original.name === "All"}
text={"Setup Key(s)"} text={t('setupKeys')}
count={row.original.setup_keys_count} count={row.original.setup_keys_count}
/> />
), ),
@@ -237,7 +293,7 @@ export const GroupsTableColumns: ColumnDef<GroupUsage>[] = [
{ {
id: "in_use", id: "in_use",
header: ({ column }) => { header: ({ column }) => {
return <DataTableHeader column={column}>In Use</DataTableHeader>; return <DataTableHeader column={column}>{t('inUse')}</DataTableHeader>;
}, },
sortingFn: "basic", sortingFn: "basic",
accessorFn: (row) => { accessorFn: (row) => {
@@ -267,72 +323,20 @@ export const GroupsTableColumns: ColumnDef<GroupUsage>[] = [
accessorFn: (row) => removeAllSpaces(row.name), accessorFn: (row) => removeAllSpaces(row.name),
filterFn: "fuzzy", filterFn: "fuzzy",
}, },
]; ], [t, tTable]);
type Props = {
headingTarget?: HTMLHeadingElement | null;
};
export default function GroupsTable({ headingTarget }: Readonly<Props>) {
const { data: groups, isLoading } = useGroupsUsage();
const path = usePathname();
// Default sorting state of the table
const [sorting, setSorting] = useLocalStorage<SortingState>(
"netbird-table-sort" + path,
[
{
id: "in_use",
desc: true,
},
{
id: "name",
desc: false,
},
],
);
const usageOptions = useMemo<RadioOption<boolean | undefined>[]>(
() => [
{ value: undefined, label: "All" },
{ value: true, label: "Used" },
{ value: false, label: "Unused" },
],
[],
);
const filterDefs = useMemo<TableFilterDef[]>(
() => [
{
id: "in_use",
label: "Usage",
renderPicker: (p) => (
<RadioPicker
value={p.value as boolean | undefined}
onChange={p.onChange}
close={p.close}
options={usageOptions}
/>
),
formatChip: (v) =>
formatRadioChip(v as boolean | undefined, usageOptions),
},
],
[usageOptions],
);
return ( return (
<DataTable <DataTable
headingTarget={headingTarget} headingTarget={headingTarget}
text={"Groups"} text={t('title')}
sorting={sorting} sorting={sorting}
isLoading={isLoading} isLoading={isLoading}
setSorting={setSorting} setSorting={setSorting}
columns={GroupsTableColumns} columns={columns}
data={groups} data={groups}
initialPageSize={25} initialPageSize={25}
showResetFilterButton={false} showResetFilterButton={false}
searchPlaceholder={"Search group by name..."} searchPlaceholder={t('searchPlaceholder')}
rightSide={() => <AddGroupButton />} rightSide={() => <AddGroupButton />}
aboveTable={(table) => ( aboveTable={(table) => (
<TableFilterChips table={table} filters={filterDefs} /> <TableFilterChips table={table} filters={filterDefs} />

View File

@@ -19,6 +19,7 @@ import {
TimerResetIcon, TimerResetIcon,
Trash2, Trash2,
} from "lucide-react"; } from "lucide-react";
import { useTranslations } from 'next-intl';
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import React, { useMemo } from "react"; import React, { useMemo } from "react";
import { useSWRConfig } from "swr"; import { useSWRConfig } from "swr";
@@ -32,6 +33,8 @@ import InlineLink from "@components/InlineLink";
import { useDialog } from "@/contexts/DialogProvider"; import { useDialog } from "@/contexts/DialogProvider";
export default function PeerActionCell() { export default function PeerActionCell() {
const t = useTranslations('peers');
const tCommon = useTranslations('common');
const { peer, deletePeer, update, toggleSSH, setSSHInstructionsModal } = const { peer, deletePeer, update, toggleSSH, setSSHInstructionsModal } =
usePeer(); usePeer();
const router = useRouter(); const router = useRouter();
@@ -50,16 +53,16 @@ export default function PeerActionCell() {
const approvePeer = async () => { const approvePeer = async () => {
const choice = await confirm({ const choice = await confirm({
title: `Approve peer '${peer.name}'?`, title: t('confirmApprove', { name: peer.name }),
description: "Are you sure you want to approve this peer?", description: t('confirmApproveDescription'),
confirmText: "Approve", confirmText: t('approve'),
cancelText: "Cancel", cancelText: tCommon('cancel'),
type: "default", type: "default",
}); });
if (!choice) return; if (!choice) return;
notify({ notify({
title: `Peer ${peer.name} approved`, title: t('approveSuccess', { name: peer.name }),
description: `This peer was approved and can now connect to other peers.`, description: t('approveSuccessDescription'),
promise: update({ promise: update({
name: peer.name, name: peer.name,
ssh: peer.ssh_enabled, ssh: peer.ssh_enabled,
@@ -69,7 +72,7 @@ export default function PeerActionCell() {
mutate("/peers"); mutate("/peers");
mutate("/groups"); mutate("/groups");
}), }),
loadingMessage: "Approving peer...", loadingMessage: t('approveLoading'),
}); });
}; };
@@ -83,11 +86,11 @@ export default function PeerActionCell() {
const showRemoteAccessItems = !isMobile && !!peer.connected; const showRemoteAccessItems = !isMobile && !!peer.connected;
const toggleLoginExpiration = async () => { const toggleLoginExpiration = async () => {
const text = peer.login_expiration_enabled ? "disabled" : "enabled"; const text = peer.login_expiration_enabled ? tCommon('disabled') : tCommon('enabled');
const disableLoginExpiration = peer.login_expiration_enabled; const disableLoginExpiration = peer.login_expiration_enabled;
notify({ notify({
title: `Session expiration is ${text}`, title: t('loginExpirationUpdated', { state: text }),
description: `Session expiration for peer ${peer.name} was successfully ${text}.`, description: t('loginExpirationUpdateDescription', { name: peer.name, state: text }),
promise: update({ promise: update({
loginExpiration: !peer.login_expiration_enabled, loginExpiration: !peer.login_expiration_enabled,
inactivityExpiration: disableLoginExpiration inactivityExpiration: disableLoginExpiration
@@ -97,31 +100,28 @@ export default function PeerActionCell() {
mutate("/peers"); mutate("/peers");
mutate("/groups"); mutate("/groups");
}), }),
loadingMessage: "Updating session expiration...", loadingMessage: t('loginExpirationUpdating'),
}); });
}; };
const disableDashboardSSH = async () => { const disableDashboardSSH = async () => {
const choice = await confirm({ const choice = await confirm({
title: `Disable SSH Access?`, title: t('disableSSHConfirmation'),
description: ( description: (
<div> <div>
Starting from NetBird v0.61.0, once SSH access is disabled, you cannot {t('disableSSHDescription')}{" "}
re-enable it again from the dashboard. You&apos;ll need to create an
explicit access control policy and update your NetBird client to
restore SSH functionality.{" "}
<InlineLink <InlineLink
href={"https://docs.netbird.io/manage/peers/ssh"} href={"https://docs.netbird.io/manage/peers/ssh"}
target={"_blank"} target={"_blank"}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
Learn more {t('sshLearnMore')}
<ExternalLinkIcon size={12} /> <ExternalLinkIcon size={12} />
</InlineLink> </InlineLink>
</div> </div>
), ),
confirmText: "Disable", confirmText: t('disableSSH'),
cancelText: "Cancel", cancelText: tCommon('cancel'),
type: "warning", type: "warning",
maxWidthClass: "max-w-xl", maxWidthClass: "max-w-xl",
}); });
@@ -150,7 +150,7 @@ export default function PeerActionCell() {
> >
<div className={"flex gap-3 items-center"}> <div className={"flex gap-3 items-center"}>
<MonitorIcon size={14} className={"shrink-0"} /> <MonitorIcon size={14} className={"shrink-0"} />
View Details {t('viewDetails')}
</div> </div>
</DropdownMenuItem> </DropdownMenuItem>
@@ -160,7 +160,7 @@ export default function PeerActionCell() {
<DropdownMenuItem onClick={approvePeer}> <DropdownMenuItem onClick={approvePeer}>
<div className={"flex gap-3 items-center"}> <div className={"flex gap-3 items-center"}>
<CheckCircle2 size={14} className={"shrink-0"} /> <CheckCircle2 size={14} className={"shrink-0"} />
Approve {t('approve')}
</div> </div>
</DropdownMenuItem> </DropdownMenuItem>
</> </>
@@ -181,9 +181,7 @@ export default function PeerActionCell() {
className={"flex gap-2 items-center !text-nb-gray-300 text-xs"} className={"flex gap-2 items-center !text-nb-gray-300 text-xs"}
> >
<IconInfoCircle size={14} /> <IconInfoCircle size={14} />
<span> <span>{t('expirationDisabledTooltip')}</span>
Expiration is disabled for all peers added with an setup-key.
</span>
</div> </div>
} }
className={"w-full block"} className={"w-full block"}
@@ -195,8 +193,7 @@ export default function PeerActionCell() {
> >
<div className={"flex gap-3 items-center w-full"}> <div className={"flex gap-3 items-center w-full"}>
<TimerResetIcon size={14} className={"shrink-0"} /> <TimerResetIcon size={14} className={"shrink-0"} />
{peer.login_expiration_enabled ? "Disable" : "Enable"} Session {peer.login_expiration_enabled ? t('disableLoginExpiration') : t('enableLoginExpiration')}
Expiration
</div> </div>
</DropdownMenuItem> </DropdownMenuItem>
</FullTooltip> </FullTooltip>
@@ -213,7 +210,7 @@ export default function PeerActionCell() {
<div className={"flex gap-3 items-center w-full"}> <div className={"flex gap-3 items-center w-full"}>
<TerminalSquare size={14} className={"shrink-0"} /> <TerminalSquare size={14} className={"shrink-0"} />
<div className={"flex justify-between items-center w-full"}> <div className={"flex justify-between items-center w-full"}>
{peer.ssh_enabled ? "Disable" : "Enable"} SSH Access {peer.ssh_enabled ? t('disableSSH') : t('enableSSH')}
</div> </div>
</div> </div>
</DropdownMenuItem> </DropdownMenuItem>
@@ -230,7 +227,7 @@ export default function PeerActionCell() {
> >
<div className={"flex gap-3 items-center"}> <div className={"flex gap-3 items-center"}>
<Trash2 size={14} className={"shrink-0"} /> <Trash2 size={14} className={"shrink-0"} />
Delete {t('delete')}
</div> </div>
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>

View File

@@ -3,6 +3,7 @@ import FullTooltip from "@components/FullTooltip";
import { cn } from "@utils/helpers"; import { cn } from "@utils/helpers";
import { isEmpty } from "lodash"; import { isEmpty } from "lodash";
import { GlobeIcon } from "lucide-react"; import { GlobeIcon } from "lucide-react";
import { useTranslations } from 'next-intl';
import React from "react"; import React from "react";
import RoundedFlag from "@/assets/countries/RoundedFlag"; import RoundedFlag from "@/assets/countries/RoundedFlag";
import { Peer } from "@/interfaces/Peer"; import { Peer } from "@/interfaces/Peer";
@@ -24,6 +25,7 @@ function shortDnsLabel(label: string | undefined | null): string {
} }
export default function PeerAddressCell({ peer }: Props) { export default function PeerAddressCell({ peer }: Props) {
const t = useTranslations('peers');
const shortLabel = shortDnsLabel(peer.dns_label); const shortLabel = shortDnsLabel(peer.dns_label);
return ( return (
<FullTooltip <FullTooltip
@@ -56,13 +58,13 @@ export default function PeerAddressCell({ peer }: Props) {
</div> </div>
<div className="flex flex-col gap-0 dark:text-neutral-300 text-neutral-500 font-light truncate"> <div className="flex flex-col gap-0 dark:text-neutral-300 text-neutral-500 font-light truncate">
<CopyToClipboardText <CopyToClipboardText
message={"DNS label has been copied to your clipboard"} message={t('dnsLabelCopied')}
textToCopy={peer.dns_label} textToCopy={peer.dns_label}
> >
<span className={"font-normal truncate"}>{shortLabel}</span> <span className={"font-normal truncate"}>{shortLabel}</span>
</CopyToClipboardText> </CopyToClipboardText>
<CopyToClipboardText <CopyToClipboardText
message={"IP address has been copied to your clipboard"} message={t('ipCopied')}
> >
<span <span
className={"dark:text-nb-gray-400 font-mono font-thin text-xs"} className={"dark:text-nb-gray-400 font-mono font-thin text-xs"}

View File

@@ -1,6 +1,7 @@
import CopyToClipboardText from "@components/CopyToClipboardText"; import CopyToClipboardText from "@components/CopyToClipboardText";
import { ListItem } from "@components/ListItem"; import { ListItem } from "@components/ListItem";
import { FlagIcon, GlobeIcon, MapPin, NetworkIcon } from "lucide-react"; import { FlagIcon, GlobeIcon, MapPin, NetworkIcon } from "lucide-react";
import { useTranslations } from 'next-intl';
import * as React from "react"; import * as React from "react";
import { useMemo } from "react"; import { useMemo } from "react";
import Skeleton from "react-loading-skeleton"; import Skeleton from "react-loading-skeleton";
@@ -11,6 +12,7 @@ type Props = {
peer: Peer; peer: Peer;
}; };
export const PeerAddressTooltipContent = ({ peer }: Props) => { export const PeerAddressTooltipContent = ({ peer }: Props) => {
const t = useTranslations('peers');
const { isLoading, getRegionByPeer } = useCountries(); const { isLoading, getRegionByPeer } = useCountries();
const countryText = useMemo(() => { const countryText = useMemo(() => {
@@ -27,11 +29,11 @@ export const PeerAddressTooltipContent = ({ peer }: Props) => {
> >
<ListItem <ListItem
icon={<MapPin size={14} />} icon={<MapPin size={14} />}
label={"NetBird IP"} label={t('netbirdIp')}
value={ value={
<CopyToClipboardText <CopyToClipboardText
iconAlignment={"right"} iconAlignment={"right"}
message={"NetBird IP has been copied to your clipboard"} message={t('netbirdIpCopied')}
alwaysShowIcon={true} alwaysShowIcon={true}
> >
{peer.ip} {peer.ip}
@@ -41,11 +43,11 @@ export const PeerAddressTooltipContent = ({ peer }: Props) => {
{peer.ipv6 && ( {peer.ipv6 && (
<ListItem <ListItem
icon={<MapPin size={14} />} icon={<MapPin size={14} />}
label={"NetBird IPv6"} label={t('netbirdIpv6')}
value={ value={
<CopyToClipboardText <CopyToClipboardText
iconAlignment={"right"} iconAlignment={"right"}
message={"NetBird IPv6 has been copied to your clipboard"} message={t('netbirdIpv6Copied')}
alwaysShowIcon={true} alwaysShowIcon={true}
> >
{peer.ipv6} {peer.ipv6}
@@ -55,11 +57,11 @@ export const PeerAddressTooltipContent = ({ peer }: Props) => {
)} )}
<ListItem <ListItem
icon={<NetworkIcon size={14} />} icon={<NetworkIcon size={14} />}
label={"Public IP"} label={t('publicIp')}
value={ value={
<CopyToClipboardText <CopyToClipboardText
iconAlignment={"right"} iconAlignment={"right"}
message={"Public IP has been copied to your clipboard"} message={t('publicIpCopied')}
alwaysShowIcon={true} alwaysShowIcon={true}
> >
{peer.connection_ip} {peer.connection_ip}
@@ -68,7 +70,7 @@ export const PeerAddressTooltipContent = ({ peer }: Props) => {
/> />
<ListItem <ListItem
icon={<GlobeIcon size={14} />} icon={<GlobeIcon size={14} />}
label={"Domain"} label={t('domain')}
className={ className={
peer?.extra_dns_labels && peer.extra_dns_labels.length > 0 peer?.extra_dns_labels && peer.extra_dns_labels.length > 0
? "items-start" ? "items-start"
@@ -78,7 +80,7 @@ export const PeerAddressTooltipContent = ({ peer }: Props) => {
<div className={"text-right flex flex-col gap-[6px]"}> <div className={"text-right flex flex-col gap-[6px]"}>
<CopyToClipboardText <CopyToClipboardText
iconAlignment={"right"} iconAlignment={"right"}
message={"DNS label has been copied to your clipboard"} message={t('dnsLabelCopied')}
className={"text-right justify-end"} className={"text-right justify-end"}
alwaysShowIcon={true} alwaysShowIcon={true}
> >
@@ -90,7 +92,7 @@ export const PeerAddressTooltipContent = ({ peer }: Props) => {
key={label} key={label}
className={"text-right justify-end"} className={"text-right justify-end"}
iconAlignment={"right"} iconAlignment={"right"}
message={"DNS label has been copied to your clipboard"} message={t('dnsLabelCopied')}
alwaysShowIcon={true} alwaysShowIcon={true}
> >
{label} {label}
@@ -101,14 +103,14 @@ export const PeerAddressTooltipContent = ({ peer }: Props) => {
/> />
<ListItem <ListItem
icon={<FlagIcon size={14} />} icon={<FlagIcon size={14} />}
label={"Region"} label={t('region')}
value={ value={
isLoading && !countryText ? ( isLoading && !countryText ? (
<Skeleton width={100} /> <Skeleton width={100} />
) : ( ) : (
<CopyToClipboardText <CopyToClipboardText
iconAlignment={"right"} iconAlignment={"right"}
message={"Region has been copied to your clipboard"} message={t('regionCopied')}
alwaysShowIcon={true} alwaysShowIcon={true}
> >
{countryText} {countryText}

View File

@@ -6,6 +6,7 @@ import {
import FullTooltip from "@components/FullTooltip"; import FullTooltip from "@components/FullTooltip";
import { getOperatingSystem } from "@hooks/useOperatingSystem"; import { getOperatingSystem } from "@hooks/useOperatingSystem";
import { IconChevronDown } from "@tabler/icons-react"; import { IconChevronDown } from "@tabler/icons-react";
import { useTranslations } from 'next-intl';
import * as React from "react"; import * as React from "react";
import { usePeer } from "@/contexts/PeerProvider"; import { usePeer } from "@/contexts/PeerProvider";
import { OperatingSystem } from "@/interfaces/OperatingSystem"; import { OperatingSystem } from "@/interfaces/OperatingSystem";
@@ -14,6 +15,7 @@ import { SSHButton } from "@/modules/remote-access/ssh/SSHButton";
import { cn } from "@utils/helpers"; import { cn } from "@utils/helpers";
export const PeerConnectButton = () => { export const PeerConnectButton = () => {
const t = useTranslations('peers');
const { peer } = usePeer(); const { peer } = usePeer();
const isConnected = peer.connected; const isConnected = peer.connected;
const os = getOperatingSystem(peer?.os); const os = getOperatingSystem(peer?.os);
@@ -50,7 +52,7 @@ export const PeerConnectButton = () => {
<FullTooltip <FullTooltip
content={ content={
<div className={"max-w-[200px] text-xs"}> <div className={"max-w-[200px] text-xs"}>
Connecting via SSH or RDP is only available when the peer is online. {t('connectTooltipOffline')}
</div> </div>
} }
> >
@@ -60,6 +62,7 @@ export const PeerConnectButton = () => {
}; };
const ConnectButton = ({ disabled }: { disabled?: boolean }) => { const ConnectButton = ({ disabled }: { disabled?: boolean }) => {
const t = useTranslations('peers');
return ( return (
<button <button
className={cn( className={cn(
@@ -73,7 +76,7 @@ const ConnectButton = ({ disabled }: { disabled?: boolean }) => {
e.preventDefault(); e.preventDefault();
}} }}
> >
Connect {t('connect')}
<IconChevronDown size={14} /> <IconChevronDown size={14} />
</button> </button>
); );

View File

@@ -1,4 +1,5 @@
import { notify } from "@components/Notification"; import { notify } from "@components/Notification";
import { useTranslations } from 'next-intl';
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { useSWRConfig } from "swr"; import { useSWRConfig } from "swr";
import { usePeer } from "@/contexts/PeerProvider"; import { usePeer } from "@/contexts/PeerProvider";
@@ -7,6 +8,7 @@ import { Group } from "@/interfaces/Group";
import GroupsRow from "@/modules/common-table-rows/GroupsRow"; import GroupsRow from "@/modules/common-table-rows/GroupsRow";
export default function PeerGroupCell() { export default function PeerGroupCell() {
const t = useTranslations('peers');
const { peer, peerGroups } = usePeer(); const { peer, peerGroups } = usePeer();
const [modal, setModal] = useState(false); const [modal, setModal] = useState(false);
const { mutate } = useSWRConfig(); const { mutate } = useSWRConfig();
@@ -15,13 +17,13 @@ export default function PeerGroupCell() {
const handleSave = async (promises: Promise<Group>[]) => { const handleSave = async (promises: Promise<Group>[]) => {
notify({ notify({
title: peer.name, title: peer.name,
description: "Groups of the peer were successfully saved", description: t('groupsSaved'),
promise: Promise.all(promises).then(() => { promise: Promise.all(promises).then(() => {
setModal(false); setModal(false);
mutate("/peers"); mutate("/peers");
mutate("/groups"); mutate("/groups");
}), }),
loadingMessage: "Saving the groups of the peer...", loadingMessage: t('groupsSaving'),
}); });
}; };
@@ -38,8 +40,8 @@ export default function PeerGroupCell() {
return ( return (
<GroupsRow <GroupsRow
label={"Assigned Groups"} label={t('assignedGroups')}
description={"Use groups to control what this peer can access"} description={t('assignedGroupsDescription')}
groups={groupIDs || []} groups={groupIDs || []}
hideAllGroup={true} hideAllGroup={true}
showAddGroupButton={true} showAddGroupButton={true}

View File

@@ -1,4 +1,5 @@
import { History } from "lucide-react"; import { History } from "lucide-react";
import { useTranslations } from 'next-intl';
import * as React from "react"; import * as React from "react";
import { Peer } from "@/interfaces/Peer"; import { Peer } from "@/interfaces/Peer";
import LastTimeRow from "@/modules/common-table-rows/LastTimeRow"; import LastTimeRow from "@/modules/common-table-rows/LastTimeRow";
@@ -7,6 +8,7 @@ type Props = {
peer: Peer; peer: Peer;
}; };
export default function PeerLastSeenCell({ peer }: Props) { export default function PeerLastSeenCell({ peer }: Props) {
const t = useTranslations('peers');
return !peer.connected ? ( return !peer.connected ? (
<LastTimeRow date={peer.last_seen} /> <LastTimeRow date={peer.last_seen} />
) : ( ) : (
@@ -17,7 +19,7 @@ export default function PeerLastSeenCell({ peer }: Props) {
> >
<> <>
<History size={14} /> <History size={14} />
just now {t('justNow')}
</> </>
</div> </div>
); );

View File

@@ -20,6 +20,7 @@ import {
RedoDot, RedoDot,
Trash2, Trash2,
} from "lucide-react"; } from "lucide-react";
import { useTranslations } from 'next-intl';
import * as React from "react"; import * as React from "react";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { useSWRConfig } from "swr"; import { useSWRConfig } from "swr";
@@ -51,6 +52,8 @@ const PeerGroupMassAssignmentContent = ({
selectedPeers = {}, selectedPeers = {},
onCanceled, onCanceled,
}: Props) => { }: Props) => {
const t = useTranslations('peers');
const tCommon = useTranslations('common');
const { mutate } = useSWRConfig(); const { mutate } = useSWRConfig();
const { confirm } = useDialog(); const { confirm } = useDialog();
const { permission } = usePermissions(); const { permission } = usePermissions();
@@ -89,10 +92,10 @@ const PeerGroupMassAssignmentContent = ({
const addGroupsToPeers = async () => { const addGroupsToPeers = async () => {
if (replaceAllGroups) { if (replaceAllGroups) {
const choice = await confirm({ const choice = await confirm({
title: `Overwrite existing groups?`, title: t('overwriteGroupsConfirm'),
description: `Are you sure you want to overwrite the existing groups of your ${peerCount} selected peer(s)? This action cannot be undone.`, description: t('overwriteGroupsConfirmDescription', { count: peerCount }),
confirmText: "Overwrite", confirmText: t('overwrite'),
cancelText: "Cancel", cancelText: tCommon('cancel'),
type: "warning", type: "warning",
}); });
if (!choice) return; if (!choice) return;
@@ -192,8 +195,8 @@ const PeerGroupMassAssignmentContent = ({
); );
notify({ notify({
title: "Assign Groups to Peers", title: t('assignGroups'),
description: "Groups were successfully assigned to the peers", description: t('groupsAssigned'),
promise: updateGroupCalls() promise: updateGroupCalls()
.then(() => { .then(() => {
if (currentSelectedGroups.length > 0) { if (currentSelectedGroups.length > 0) {
@@ -205,7 +208,7 @@ const PeerGroupMassAssignmentContent = ({
.finally(() => { .finally(() => {
setIsLoading(false); setIsLoading(false);
}), }),
loadingMessage: "Updating the groups of the selected peers...", loadingMessage: t('groupsAssigning'),
}); });
} catch (e) { } catch (e) {
setIsLoading(false); setIsLoading(false);
@@ -213,11 +216,12 @@ const PeerGroupMassAssignmentContent = ({
}; };
const deleteAllPeers = async () => { const deleteAllPeers = async () => {
const peerWord = peerCount > 1 ? t('peersWord') : t('peerWord');
const choice = await confirm({ const choice = await confirm({
title: `Delete '${peerCount}' ${peerCount > 1 ? "peers" : "peer"}?`, title: t('deleteAllConfirm', { count: peerCount, peerWord }),
description: `Are you sure you want to delete these peers? This action cannot be undone.`, description: t('deleteAllConfirmDescription'),
confirmText: "Delete All", confirmText: t('deleteAllConfirmText'),
cancelText: "Cancel", cancelText: tCommon('cancel'),
type: "danger", type: "danger",
}); });
if (!choice) return; if (!choice) return;
@@ -228,13 +232,13 @@ const PeerGroupMassAssignmentContent = ({
}); });
notify({ notify({
title: "Delete Peers", title: t('deleteAll'),
description: "Peers were successfully deleted", description: t('peersDeleted'),
promise: Promise.all(batchDeleteCalls()).then(() => { promise: Promise.all(batchDeleteCalls()).then(() => {
mutate("/peers"); mutate("/peers");
onCanceled?.(); onCanceled?.();
}), }),
loadingMessage: "Deleting the selected peers...", loadingMessage: t('peersDeleting'),
}); });
}; };
@@ -299,13 +303,13 @@ const PeerGroupMassAssignmentContent = ({
{isLoading && ( {isLoading && (
<> <>
<Loader2 size={14} className={"animate-spin"} /> <Loader2 size={14} className={"animate-spin"} />
<span>Assigning groups...</span> <span>{t('assigningGroups')}</span>
</> </>
)} )}
{!isLoading && isSuccess && ( {!isLoading && isSuccess && (
<> <>
<CheckCircle size={14} className={"text-green-400"} /> <CheckCircle size={14} className={"text-green-400"} />
<span>Groups successfully assigned</span> <span>{t('groupsAssignedSuccess')}</span>
</> </>
)} )}
</motion.span> </motion.span>
@@ -313,11 +317,9 @@ const PeerGroupMassAssignmentContent = ({
)} )}
</AnimatePresence> </AnimatePresence>
<div> <div>
<Label>Assign Groups</Label> <Label>{t('assignGroups')}</Label>
<HelpText> <HelpText>
Assign the following groups to the selected peers. Previously {t('assignGroupsDescription')}
assigned groups will be kept unless you choose to overwrite
them.
</HelpText> </HelpText>
<PeerGroupSelector <PeerGroupSelector
onChange={setSelectedGroups} onChange={setSelectedGroups}
@@ -331,13 +333,12 @@ const PeerGroupMassAssignmentContent = ({
label={ label={
<div className={"flex gap-2"}> <div className={"flex gap-2"}>
<RedoDot size={14} /> <RedoDot size={14} />
Overwrite Existing Groups {t('overwriteGroups')}
</div> </div>
} }
helpText={ helpText={
<div> <div>
Overwrite the existing groups of the peers with the selected {t('overwriteGroupsHelp')}
ones. Previously assigned groups will be removed.
</div> </div>
} }
/> />
@@ -372,7 +373,7 @@ const PeerGroupMassAssignmentContent = ({
<span className={"font-medium text-white"}> <span className={"font-medium text-white"}>
{peerCount} {peerCount}
</span>{" "} </span>{" "}
Peer(s) selected {t('selectedCount', { count: peerCount })}
</span> </span>
</div> </div>
<div className={"flex gap-2 items-center"}> <div className={"flex gap-2 items-center"}>
@@ -380,7 +381,7 @@ const PeerGroupMassAssignmentContent = ({
<> <>
<FullTooltip <FullTooltip
content={ content={
<span className={"text-xs"}>Assign Groups</span> <span className={"text-xs"}>{t('assignGroups')}</span>
} }
> >
<Button <Button
@@ -398,7 +399,7 @@ const PeerGroupMassAssignmentContent = ({
</Button> </Button>
</FullTooltip> </FullTooltip>
<FullTooltip <FullTooltip
content={<span className={"text-xs"}>Delete All</span>} content={<span className={"text-xs"}>{t('deleteAll')}</span>}
> >
<Button <Button
variant={"danger-outline"} variant={"danger-outline"}
@@ -411,7 +412,7 @@ const PeerGroupMassAssignmentContent = ({
</Button> </Button>
</FullTooltip> </FullTooltip>
<FullTooltip <FullTooltip
content={<span className={"text-xs"}>Cancel</span>} content={<span className={"text-xs"}>{tCommon('cancel')}</span>}
> >
<Button <Button
onClick={onCanceled} onClick={onCanceled}
@@ -431,7 +432,7 @@ const PeerGroupMassAssignmentContent = ({
className={"!h-9 !px-3.5"} className={"!h-9 !px-3.5"}
onClick={onCanceled} onClick={onCanceled}
> >
Cancel {tCommon('cancel')}
</Button> </Button>
<Button <Button
size={"xs"} size={"xs"}
@@ -449,7 +450,7 @@ const PeerGroupMassAssignmentContent = ({
) : ( ) : (
<CirclePlus size={14} /> <CirclePlus size={14} />
)} )}
{replaceAllGroups ? "Overwrite" : "Add"} Groups {replaceAllGroups ? t('overwrite') : t('addGroups')}
</Button> </Button>
</> </>
)} )}

View File

@@ -1,4 +1,5 @@
import { cn } from "@utils/helpers"; import { cn } from "@utils/helpers";
import { useTranslations } from 'next-intl';
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import * as React from "react"; import * as React from "react";
import { useMemo } from "react"; import { useMemo } from "react";
@@ -15,6 +16,7 @@ type Props = {
linkToPeer?: boolean; linkToPeer?: boolean;
}; };
export default function PeerNameCell({ peer, linkToPeer = true }: Props) { export default function PeerNameCell({ peer, linkToPeer = true }: Props) {
const t = useTranslations('peers');
const { users } = useUsers(); const { users } = useUsers();
const router = useRouter(); const router = useRouter();
const { isOwnerOrAdmin } = useLoggedInUser(); const { isOwnerOrAdmin } = useLoggedInUser();
@@ -36,7 +38,7 @@ export default function PeerNameCell({ peer, linkToPeer = true }: Props) {
"hover:text-neutral-100 hover:bg-nb-gray-900/60 cursor-pointer", "hover:text-neutral-100 hover:bg-nb-gray-900/60 cursor-pointer",
)} )}
data-testid="peer-name-cell" data-testid="peer-name-cell"
aria-label={`View details of peer ${peer.name}`} aria-label={`${t('viewDetailsOf')} ${peer.name}`}
onClick={(e) => { onClick={(e) => {
if (!linkToPeer) return; if (!linkToPeer) return;
e.preventDefault(); e.preventDefault();
@@ -59,7 +61,7 @@ export default function PeerNameCell({ peer, linkToPeer = true }: Props) {
> >
<div className={"text-nb-gray-400 font-light truncate"}> <div className={"text-nb-gray-400 font-light truncate"}>
{displayUserEmailOrName || {displayUserEmailOrName ||
(displayUserId && `user: ${displayUserId}`)} (displayUserId && t('userLabel', { id: displayUserId }))}
</div> </div>
</ActiveInactiveRow> </ActiveInactiveRow>
{isOwnerOrAdmin && ( {isOwnerOrAdmin && (

View File

@@ -5,6 +5,7 @@ import {
TooltipTrigger, TooltipTrigger,
} from "@components/Tooltip"; } from "@components/Tooltip";
import { Barcode, CpuIcon } from "lucide-react"; import { Barcode, CpuIcon } from "lucide-react";
import { useTranslations } from 'next-intl';
import Image from "next/image"; import Image from "next/image";
import React, { useMemo } from "react"; import React, { useMemo } from "react";
import { FaWindows } from "react-icons/fa6"; import { FaWindows } from "react-icons/fa6";
@@ -20,6 +21,7 @@ type Props = {
serial?: string; serial?: string;
}; };
export function PeerOSCell({ os, serial }: Readonly<Props>) { export function PeerOSCell({ os, serial }: Readonly<Props>) {
const t = useTranslations('peers');
return ( return (
<TooltipProvider> <TooltipProvider>
<Tooltip delayDuration={1}> <Tooltip delayDuration={1}>
@@ -40,11 +42,11 @@ export function PeerOSCell({ os, serial }: Readonly<Props>) {
</TooltipTrigger> </TooltipTrigger>
<TooltipContent className={"!p-0"}> <TooltipContent className={"!p-0"}>
<div> <div>
<ListItem icon={<CpuIcon size={14} />} label={"OS"} value={os} /> <ListItem icon={<CpuIcon size={14} />} label={t('os')} value={os} />
{serial && serial !== "" && ( {serial && serial !== "" && (
<ListItem <ListItem
icon={<Barcode size={14} />} icon={<Barcode size={14} />}
label={"Serial Number"} label={t('serialNumber')}
value={serial} value={serial}
/> />
)} )}

View File

@@ -9,6 +9,7 @@ import MemoizedNetBirdIcon from "@components/ui/MemoizedNetBirdIcon";
import { getOperatingSystem } from "@hooks/useOperatingSystem"; import { getOperatingSystem } from "@hooks/useOperatingSystem";
import { compareVersions } from "@utils/version"; import { compareVersions } from "@utils/version";
import { ArrowRightIcon, ArrowUpCircleIcon } from "lucide-react"; import { ArrowRightIcon, ArrowUpCircleIcon } from "lucide-react";
import { useTranslations } from 'next-intl';
import * as React from "react"; import * as React from "react";
import { useMemo } from "react"; import { useMemo } from "react";
import { useApplicationContext } from "@/contexts/ApplicationProvider"; import { useApplicationContext } from "@/contexts/ApplicationProvider";
@@ -28,6 +29,7 @@ export default function PeerVersionCell({
serial, serial,
ephemeral, ephemeral,
}: Props) { }: Props) {
const t = useTranslations('peers');
const { latestVersion, latestUrl } = useApplicationContext(); const { latestVersion, latestUrl } = useApplicationContext();
const updateAvailable = useMemo(() => { const updateAvailable = useMemo(() => {
@@ -73,15 +75,14 @@ export default function PeerVersionCell({
<ArrowRightIcon size={16} className={"text-netbird"} /> <ArrowRightIcon size={16} className={"text-netbird"} />
<span className={"text-netbird"}>{latestVersion}</span> <span className={"text-netbird"}>{latestVersion}</span>
</div> </div>
<p className={"font-medium"}>Update available </p> <p className={"font-medium"}>{t('updateAvailable')}</p>
<div <div
className={ className={
"text-neutral-300 flex flex-col gap-1 max-w-[300px] text-xs mt-1" "text-neutral-300 flex flex-col gap-1 max-w-[300px] text-xs mt-1"
} }
> >
A new version of Netbird is available. Please update your client {t('updateDescription')}
to get the latest features and bug fixes.
</div> </div>
<InlineLink <InlineLink
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
@@ -89,7 +90,7 @@ export default function PeerVersionCell({
target={"_blank"} target={"_blank"}
className={"mt-2 mb-2 text-xs"} className={"mt-2 mb-2 text-xs"}
> >
Download & Changelog {t('downloadChangelog')}
</InlineLink> </InlineLink>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
@@ -107,7 +108,7 @@ export default function PeerVersionCell({
disabled={!serial || serial === ""} disabled={!serial || serial === ""}
content={ content={
<div className={"text-xs"}> <div className={"text-xs"}>
<span className={"text-nb-gray-100 font-medium"}>Serial: </span> <span className={"text-nb-gray-100 font-medium"}>{t('serialNumber')}: </span>
{serial} {serial}
</div> </div>
} }

View File

@@ -38,6 +38,7 @@ import {
} from "@tanstack/react-table"; } from "@tanstack/react-table";
import { trim, uniqBy } from "lodash"; import { trim, uniqBy } from "lodash";
import { MonitorDotIcon } from "lucide-react"; import { MonitorDotIcon } from "lucide-react";
import { useTranslations } from 'next-intl';
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import React, { useCallback, useEffect, useMemo, useState } from "react"; import React, { useCallback, useEffect, useMemo, useState } from "react";
import { useSWRConfig } from "swr"; import { useSWRConfig } from "swr";
@@ -79,184 +80,6 @@ function peerOsKey(os: string | undefined): string {
} }
} }
const PeersTableColumns: ColumnDef<Peer>[] = [
{
id: "select",
header: ({ table }) => (
<div className={"min-w-[20px] max-w-[20px]"}>
<Checkbox
checked={table.getIsAllPageRowsSelected()}
onCheckedChange={(value) => table.toggleAllRowsSelected(!!value)}
aria-label="Select all"
/>
</div>
),
cell: ({ row }) => (
<div className={"min-w-[20px] max-w-[20px]"}>
<Checkbox
checked={row.getIsSelected()}
variant={"tableCell"}
onCheckedChange={(value) => row.toggleSelected(!!value)}
aria-label="Select row"
/>
</div>
),
enableSorting: false,
enableHiding: false,
},
{
id: "name",
accessorFn: (peer) => `${peer?.name}${peer?.dns_label}`,
header: ({ column }) => {
return <DataTableHeader column={column}>Name</DataTableHeader>;
},
sortingFn: "text",
cell: ({ row }) => <PeerNameCell peer={row.original} />,
},
{
id: "approval_required",
accessorKey: "approval_required",
sortingFn: "basic",
accessorFn: (peer) => peer.approval_required,
},
{
id: "connected",
accessorKey: "connected",
accessorFn: (peer) => peer.connected,
},
{
accessorKey: "ip",
sortingFn: "text",
},
{
id: "user_name",
accessorFn: (peer) => (peer.user ? peer.user?.name : "Unknown"),
},
{
id: "user_email",
accessorFn: (peer) => (peer.user ? peer.user?.email : "Unknown"),
filterFn: "equalsString",
},
{
id: "dns_label",
accessorKey: "dns_label",
header: ({ column }) => {
return <DataTableHeader column={column}>Address</DataTableHeader>;
},
cell: ({ row }) => <PeerAddressCell peer={row.original} />,
},
{
accessorKey: "group_name_strings",
accessorFn: (peer) => peer.groups?.map((g) => g?.name || "").join(", "),
sortingFn: "text",
},
{
accessorKey: "group_names",
accessorFn: (peer) => peer.groups?.map((g) => g?.name || ""),
sortingFn: "text",
filterFn: "arrIncludesSome",
},
{
accessorFn: (peer) => peer.groups?.length,
id: "groups",
header: ({ column }) => {
return <DataTableHeader column={column}>Groups</DataTableHeader>;
},
cell: ({ row }) => (
<PeerProvider peer={row.original}>
<PeerGroupCell />
</PeerProvider>
),
},
{
accessorKey: "last_seen",
header: ({ column, table }) => {
return (
<DataTableHeader
column={column}
onSort={() => {
const desc = column.getIsSorted() === "desc";
table.setSorting([{ id: "last_seen", desc: !desc }]);
}}
>
Last seen
</DataTableHeader>
);
},
sortingFn: "datetime",
cell: ({ row }) => <PeerLastSeenCell peer={row.original} />,
},
{
id: "os",
accessorFn: (peer) => removeAllSpaces(peer?.os),
header: ({ column }) => {
return <DataTableHeader column={column}>OS</DataTableHeader>;
},
cell: ({ row }) => (
<PeerOSCell os={row.original.os} serial={row.original.serial_number} />
),
},
{
id: "os_kind",
accessorFn: (peer) => peerOsKey(peer.os),
filterFn: "arrIncludesSome",
},
{
id: "serial",
header: ({ column }) => {
return <DataTableHeader column={column}>Serial number</DataTableHeader>;
},
accessorFn: (peer) => peer.serial_number,
sortingFn: "text",
},
{
accessorKey: "version",
header: ({ column }) => {
return <DataTableHeader column={column}>Version</DataTableHeader>;
},
cell: ({ row }) => (
<PeerVersionCell
version={row.original.version}
os={row.original.os}
serial={row.original.serial_number}
ephemeral={row.original.ephemeral}
/>
),
},
{
id: "status",
accessorFn: (peer) => {
let statusCount = 0;
if (peer.login_expired) statusCount++;
if (peer.approval_required) statusCount++;
return statusCount;
},
header: () => {
return "";
},
sortingFn: "text",
cell: ({ row }) => (
<PeerProvider peer={row.original}>
<PeerStatusCell peer={row.original} />
</PeerProvider>
),
},
{
id: "actions",
accessorKey: "id",
header: "",
cell: ({ row }) => (
<PeerProvider peer={row.original}>
<PeerActionCell />
</PeerProvider>
),
},
{
id: "ipv6",
accessorFn: (row) => row.ipv6,
},
];
export type PeersTableKind = "users" | "servers"; export type PeersTableKind = "users" | "servers";
type Props = { type Props = {
@@ -281,6 +104,8 @@ export default function PeersTable({
headingTarget, headingTarget,
kind, kind,
}: Readonly<Props>) { }: Readonly<Props>) {
const t = useTranslations('peers');
const tTable = useTranslations('table');
const { mutate } = useSWRConfig(); const { mutate } = useSWRConfig();
const { permission } = usePermissions(); const { permission } = usePermissions();
const path = usePathname(); const path = usePathname();
@@ -376,13 +201,13 @@ export default function PeersTable({
// entries since they fold into Linux for the chosen icon. // entries since they fold into Linux for the chosen icon.
const osOptions = useMemo<CheckboxOption<string>[]>( const osOptions = useMemo<CheckboxOption<string>[]>(
() => [ () => [
{ value: "linux", label: "Linux" }, { value: "linux", label: t('operatingSystem.linux') },
{ value: "windows", label: "Windows" }, { value: "windows", label: t('operatingSystem.windows') },
{ value: "mac", label: "macOS" }, { value: "mac", label: t('operatingSystem.macos') },
{ value: "android", label: "Android" }, { value: "android", label: t('operatingSystem.android') },
{ value: "ios", label: "iOS" }, { value: "ios", label: t('operatingSystem.ios') },
], ],
[], [t],
); );
// Filter definitions powering the consolidated `Filters` button + // Filter definitions powering the consolidated `Filters` button +
@@ -392,7 +217,7 @@ export default function PeersTable({
const defs: TableFilterDef[] = [ const defs: TableFilterDef[] = [
{ {
id: "connected", id: "connected",
label: "Status", label: t('status'),
renderPicker: (p) => ( renderPicker: (p) => (
<StatusPicker <StatusPicker
value={p.value as boolean | undefined} value={p.value as boolean | undefined}
@@ -404,7 +229,7 @@ export default function PeersTable({
}, },
{ {
id: "os_kind", id: "os_kind",
label: "OS", label: t('os'),
renderPicker: (p) => ( renderPicker: (p) => (
<CheckboxListPicker <CheckboxListPicker
value={p.value as string[] | undefined} value={p.value as string[] | undefined}
@@ -414,13 +239,13 @@ export default function PeersTable({
/> />
), ),
formatChip: (v) => formatChip: (v) =>
formatCheckboxChip(v as string[] | undefined, osOptions, "platforms"), formatCheckboxChip(v as string[] | undefined, osOptions, tTable('of')),
}, },
]; ];
if (!isUser) { if (!isUser) {
defs.push({ defs.push({
id: "group_names", id: "group_names",
label: "Groups", label: t('groups'),
renderPicker: (p) => ( renderPicker: (p) => (
<GroupsPicker <GroupsPicker
value={p.value as string[] | undefined} value={p.value as string[] | undefined}
@@ -435,7 +260,7 @@ export default function PeersTable({
if (kind === "users" && !isUser && tableUsers.length > 0) { if (kind === "users" && !isUser && tableUsers.length > 0) {
defs.push({ defs.push({
id: "user_email", id: "user_email",
label: "Users", label: t('users'),
renderPicker: (p) => ( renderPicker: (p) => (
<UsersPicker <UsersPicker
value={p.value as string | undefined} value={p.value as string | undefined}
@@ -448,7 +273,187 @@ export default function PeersTable({
}); });
} }
return defs; return defs;
}, [isUser, kind, osOptions, tableGroups, tableUsers]); }, [isUser, kind, osOptions, tableGroups, tableUsers, t, tTable]);
// Columns are defined inside the component so the header labels and
// copy text can call useTranslations via the `t` and `tTable` hooks.
const columns = useMemo<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={tTable('selectAll')}
/>
</div>
),
cell: ({ row }) => (
<div className={"min-w-[20px] max-w-[20px]"}>
<Checkbox
checked={row.getIsSelected()}
variant={"tableCell"}
onCheckedChange={(value) => row.toggleSelected(!!value)}
aria-label={tTable('selectRow')}
/>
</div>
),
enableSorting: false,
enableHiding: false,
},
{
id: "name",
accessorFn: (peer) => `${peer?.name}${peer?.dns_label}`,
header: ({ column }) => {
return <DataTableHeader column={column}>{t('name')}</DataTableHeader>;
},
sortingFn: "text",
cell: ({ row }) => <PeerNameCell peer={row.original} />,
},
{
id: "approval_required",
accessorKey: "approval_required",
sortingFn: "basic",
accessorFn: (peer) => peer.approval_required,
},
{
id: "connected",
accessorKey: "connected",
accessorFn: (peer) => peer.connected,
},
{
accessorKey: "ip",
sortingFn: "text",
},
{
id: "user_name",
accessorFn: (peer) => (peer.user ? peer.user?.name : t('unknown')),
},
{
id: "user_email",
accessorFn: (peer) => (peer.user ? peer.user?.email : t('unknown')),
filterFn: "equalsString",
},
{
id: "dns_label",
accessorKey: "dns_label",
header: ({ column }) => {
return <DataTableHeader column={column}>{t('address')}</DataTableHeader>;
},
cell: ({ row }) => <PeerAddressCell peer={row.original} />,
},
{
accessorKey: "group_name_strings",
accessorFn: (peer) => peer.groups?.map((g) => g?.name || "").join(", "),
sortingFn: "text",
},
{
accessorKey: "group_names",
accessorFn: (peer) => peer.groups?.map((g) => g?.name || ""),
sortingFn: "text",
filterFn: "arrIncludesSome",
},
{
accessorFn: (peer) => peer.groups?.length,
id: "groups",
header: ({ column }) => {
return <DataTableHeader column={column}>{t('groups')}</DataTableHeader>;
},
cell: ({ row }) => (
<PeerProvider peer={row.original}>
<PeerGroupCell />
</PeerProvider>
),
},
{
accessorKey: "last_seen",
header: ({ column, table }) => {
return (
<DataTableHeader
column={column}
onSort={() => {
const desc = column.getIsSorted() === "desc";
table.setSorting([{ id: "last_seen", desc: !desc }]);
}}
>
{t('lastSeen')}
</DataTableHeader>
);
},
sortingFn: "datetime",
cell: ({ row }) => <PeerLastSeenCell peer={row.original} />,
},
{
id: "os",
accessorFn: (peer) => removeAllSpaces(peer?.os),
header: ({ column }) => {
return <DataTableHeader column={column}>{t('os')}</DataTableHeader>;
},
cell: ({ row }) => (
<PeerOSCell os={row.original.os} serial={row.original.serial_number} />
),
},
{
id: "os_kind",
accessorFn: (peer) => peerOsKey(peer.os),
filterFn: "arrIncludesSome",
},
{
id: "serial",
header: ({ column }) => {
return <DataTableHeader column={column}>{t('serialNumber')}</DataTableHeader>;
},
accessorFn: (peer) => peer.serial_number,
sortingFn: "text",
},
{
accessorKey: "version",
header: ({ column }) => {
return <DataTableHeader column={column}>{t('version')}</DataTableHeader>;
},
cell: ({ row }) => (
<PeerVersionCell
version={row.original.version}
os={row.original.os}
serial={row.original.serial_number}
ephemeral={row.original.ephemeral}
/>
),
},
{
id: "status",
accessorFn: (peer) => {
let statusCount = 0;
if (peer.login_expired) statusCount++;
if (peer.approval_required) statusCount++;
return statusCount;
},
header: () => {
return "";
},
sortingFn: "text",
cell: ({ row }) => (
<PeerProvider peer={row.original}>
<PeerStatusCell peer={row.original} />
</PeerProvider>
),
},
{
id: "actions",
accessorKey: "id",
header: "",
cell: ({ row }) => (
<PeerProvider peer={row.original}>
<PeerActionCell />
</PeerProvider>
),
},
{
id: "ipv6",
accessorFn: (row) => row.ipv6,
},
], [t, tTable]);
return ( return (
<> <>
@@ -461,14 +466,14 @@ export default function PeersTable({
rowSelection={selectedRows} rowSelection={selectedRows}
setRowSelection={setSelectedRows} setRowSelection={setSelectedRows}
useRowId={true} useRowId={true}
text={"Peers"} text={t('title')}
sorting={sorting} sorting={sorting}
setSorting={setSorting} setSorting={setSorting}
initialPageSize={25} initialPageSize={25}
showResetFilterButton={false} showResetFilterButton={false}
columns={PeersTableColumns} columns={columns}
data={showBrowserPeers ? browserPeers : regularPeers} data={showBrowserPeers ? browserPeers : regularPeers}
searchPlaceholder={"Search by name, IP, owner or group..."} searchPlaceholder={t('searchByNameIpOwnerOrGroup')}
columnVisibility={{ columnVisibility={{
select: permission.groups.read, select: permission.groups.read,
connected: false, connected: false,
@@ -552,7 +557,7 @@ export default function PeersTable({
: "secondary" : "secondary"
} }
> >
Pending Approvals {t('pendingApprovals')}
<NotificationCountBadge count={pendingApprovalCount} /> <NotificationCountBadge count={pendingApprovalCount} />
</Button> </Button>
)} )}
@@ -561,9 +566,7 @@ export default function PeersTable({
<FullTooltip <FullTooltip
content={ content={
<div className={"max-w-sm text-xs"}> <div className={"max-w-sm text-xs"}>
Show temporary peers created by the NetBird browser client. {t('browserPeerTooltip')}
These peers are ephemeral and will be deleted automatically
after a short period of time.
</div> </div>
} }
> >