diff --git a/.github/workflows/build_and_push.yml b/.github/workflows/build_and_push.yml index fcf415e5..e89d4204 100644 --- a/.github/workflows/build_and_push.yml +++ b/.github/workflows/build_and_push.yml @@ -8,7 +8,7 @@ on: pull_request: env: - IMAGE_NAME: netbirdio/dashboard + IMAGE_NAME: ohoimager/cloink-dashboard jobs: build_n_push: @@ -19,8 +19,8 @@ jobs: - name: setup-node uses: actions/setup-node@v3 with: - node-version: '20' - cache: 'npm' + node-version: "20" + cache: "npm" - name: Install dependencies run: npm install @@ -34,7 +34,7 @@ jobs: repository: netbirdio/IronRDP latest: true fileName: "*.ts" - out-file-path: 'public/ironrdp-pkg' + out-file-path: "public/ironrdp-pkg" - name: Download IronRDP release JS files uses: robinraju/release-downloader@v1.7 @@ -43,7 +43,7 @@ jobs: repository: netbirdio/IronRDP latest: true fileName: "*.js" - out-file-path: 'public/ironrdp-pkg' + out-file-path: "public/ironrdp-pkg" - name: Download IronRDP release WASM file uses: robinraju/release-downloader@v1.7 @@ -52,7 +52,7 @@ jobs: repository: netbirdio/IronRDP latest: true fileName: "ironrdp_web_bg.wasm" - out-file-path: 'public/ironrdp-pkg' + out-file-path: "public/ironrdp-pkg" - name: Get version from tag id: version @@ -67,26 +67,21 @@ jobs: run: npm run build env: NEXT_PUBLIC_DASHBOARD_VERSION: ${{ steps.version.outputs.version }} - - - name: Set up QEMU + - name: Set up QEMU uses: docker/setup-qemu-action@v2 - - - name: Set up Docker Buildx + - name: Set up Docker Buildx uses: docker/setup-buildx-action@v2 - - - name: Docker meta + - name: Docker meta id: meta uses: docker/metadata-action@v4 with: images: ${{ env.IMAGE_NAME }} - - - name: Login to DockerHub + - name: Login to DockerHub uses: docker/login-action@v2 with: username: ${{ secrets.NB_DOCKER_USER }} password: ${{ secrets.NB_DOCKER_TOKEN }} - - - name: Docker build and push + - name: Docker build and push uses: docker/build-push-action@v3 with: context: . diff --git a/.gitignore b/.gitignore index 86420ac0..3128e9b1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,10 @@ # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# trae +/.trae/ +netbird-dashboard-dev.tar + # dependencies /node_modules /.pnp @@ -47,4 +52,4 @@ cypress.env.json /public/ironrdp-pkg/ /public/netbird.wasm .idea -src/.local-config* \ No newline at end of file +src/.local-config* diff --git a/README.md b/README.md index 6c5ddb5d..24f0d88d 100644 Binary files a/README.md and b/README.md differ diff --git a/docker/Dockerfile b/docker/Dockerfile index 360da5e0..39f16e9d 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -1,4 +1,4 @@ -FROM alpine:3.14 +FROM alpine:latest RUN apk add --no-cache bash curl less ca-certificates git tzdata zip gettext \ nginx curl supervisor certbot-nginx && \ diff --git a/package-lock.json b/package-lock.json index 00a5a540..074a58ba 100644 --- a/package-lock.json +++ b/package-lock.json @@ -60,7 +60,7 @@ "js-cookie": "^3.0.5", "lodash": "^4.17.23", "lucide-react": "^0.566.0", - "next": "16.1.7", + "next": "^16.1.7", "next-themes": "^0.2.1", "punycode": "^2.3.1", "react": "^19.2.4", @@ -168,7 +168,6 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -3031,7 +3030,6 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.10.tgz", "integrity": "sha512-WPigyYuGhgZ/cTPRXB2EwUw+XvsRA3GqHlsP4qteqrnnjDrApbS7MxcGr/hke5iUoeB7E/gQtrs9I37zAJ0Vjw==", "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -3041,7 +3039,6 @@ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -3100,7 +3097,6 @@ "integrity": "sha512-BtE0k6cjwjLZoZixN0t5AKP0kSzlGu7FctRXYuPAm//aaiZhmfq1JwdYpYr1brzEspYyFeF+8XF5j2VK6oalrA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.54.0", "@typescript-eslint/types": "8.54.0", @@ -3244,6 +3240,32 @@ "typescript": ">=4.8.4 <6.0.0" } }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { "version": "7.7.3", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", @@ -3581,8 +3603,7 @@ "version": "5.5.0", "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz", "integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@xyflow/react": { "version": "12.10.0", @@ -3621,7 +3642,6 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3972,13 +3992,10 @@ } }, "node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" }, "node_modules/baseline-browser-mapping": { "version": "2.9.19", @@ -4002,15 +4019,13 @@ } }, "node_modules/brace-expansion": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", - "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", "license": "MIT", "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" } }, "node_modules/braces": { @@ -4044,7 +4059,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -4299,6 +4313,12 @@ "node": ">= 10" } }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "license": "MIT" + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -4667,7 +4687,6 @@ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "license": "ISC", - "peer": true, "engines": { "node": ">=12" } @@ -5196,7 +5215,6 @@ "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.2.tgz", "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -5394,7 +5412,6 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -6688,7 +6705,6 @@ "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "license": "MIT", - "peer": true, "bin": { "jiti": "bin/jiti.js" } @@ -6862,9 +6878,9 @@ } }, "node_modules/lodash": { - "version": "4.18.1", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", - "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", "license": "MIT" }, "node_modules/lodash.merge": { @@ -6946,18 +6962,15 @@ } }, "node_modules/minimatch": { - "version": "10.2.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", - "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", - "license": "BlueOak-1.0.0", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "license": "ISC", "dependencies": { - "brace-expansion": "^5.0.5" + "brace-expansion": "^1.1.7" }, "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "node": "*" } }, "node_modules/minimist": { @@ -7388,9 +7401,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", - "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", "license": "MIT", "engines": { "node": ">=8.6" @@ -7446,7 +7459,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -7654,7 +7666,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -7695,7 +7706,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -8590,7 +8600,6 @@ "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", "license": "MIT", - "peer": true, "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", @@ -8753,11 +8762,10 @@ } }, "node_modules/tinyglobby/node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -8923,7 +8931,6 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -9251,7 +9258,6 @@ "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "dev": true, "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index 9523e5aa..0e5ec0fb 100644 --- a/package.json +++ b/package.json @@ -68,7 +68,7 @@ "js-cookie": "^3.0.5", "lodash": "^4.17.23", "lucide-react": "^0.566.0", - "next": "16.1.7", + "next": "^16.1.7", "next-themes": "^0.2.1", "punycode": "^2.3.1", "react": "^19.2.4", @@ -89,9 +89,6 @@ "timescape": "^0.7.1", "typescript": "^5" }, - "overrides": { - "minimatch": ">=10.2.1" - }, "devDependencies": { "@faker-js/faker": "^9.5.1", "@types/chroma-js": "^3.1.1", diff --git a/src/app/(dashboard)/access-control/page.tsx b/src/app/(dashboard)/access-control/page.tsx index c55fa068..18ee9d6b 100644 --- a/src/app/(dashboard)/access-control/page.tsx +++ b/src/app/(dashboard)/access-control/page.tsx @@ -12,6 +12,7 @@ import React, { lazy, Suspense } from "react"; import AccessControlIcon from "@/assets/icons/AccessControlIcon"; import GroupsProvider from "@/contexts/GroupsProvider"; import { usePermissions } from "@/contexts/PermissionsProvider"; +import { useI18n } from "@/i18n/I18nProvider"; import PoliciesProvider from "@/contexts/PoliciesProvider"; import { Policy } from "@/interfaces/Policy"; import PageContainer from "@/layouts/PageContainer"; @@ -21,6 +22,7 @@ const AccessControlTable = lazy( ); export default function AccessControlPage() { const { permission } = usePermissions(); + const { t } = useI18n(); const { data: policies, isLoading } = useFetchApi("/policies"); @@ -34,30 +36,27 @@ export default function AccessControlPage() { } /> -

Access Control Policies

+

{t("accessControl.policiesTitle")}

+ {t("accessControl.description")} - Create rules to manage access in your network and define what peers - can connect. - - - Learn more about + {t("common.learnMorePrefix")}{" "} - Access Controls + {t("accessControl.learnMoreLink")} - in our documentation. + {t("common.inDocumentationSuffix")} diff --git a/src/app/(dashboard)/control-center/page.tsx b/src/app/(dashboard)/control-center/page.tsx index d4aa5591..a14da175 100644 --- a/src/app/(dashboard)/control-center/page.tsx +++ b/src/app/(dashboard)/control-center/page.tsx @@ -39,6 +39,7 @@ import PeersProvider from "@/contexts/PeersProvider"; import { usePermissions } from "@/contexts/PermissionsProvider"; import PoliciesProvider from "@/contexts/PoliciesProvider"; import { useLoggedInUser } from "@/contexts/UsersProvider"; +import { useI18n } from "@/i18n/I18nProvider"; import { Group } from "@/interfaces/Group"; import { Network, NetworkResource } from "@/interfaces/Network"; import { Peer } from "@/interfaces/Peer"; @@ -74,6 +75,7 @@ export default function ControlCenter() { } function ControlCenterView() { + const { t } = useI18n(); const [nodes, setNodes, onNodesChange] = useNodesState([]); const [edges, setEdges, onEdgesChange] = useEdgesState([]); const reactFlow = useReactFlow(); @@ -141,11 +143,11 @@ function ControlCenterView() { ); allNetworks.unshift({ value: "", - label: "All Networks", + label: t("controlCenter.allNetworks"), icon: () => , } as SelectOption); return allNetworks; - }, [networks]); + }, [networks, t]); const onDestinationGroupSelect = useCallback( (groupId: string) => { @@ -1466,7 +1468,7 @@ function ControlCenterView() { currentPeer: peerId, onPeerChange: handlePeerChange, userId: userId, - placeholder: "Search peers of user...", + placeholder: t("controlCenter.searchPeersOfUser"), }, }; setNodes([selectPeerNode]); @@ -1740,10 +1742,8 @@ function ControlCenterView() { size={"large"} /> } - title={"Create New Network"} - description={ - "It looks like you don't have any networks. Access internal resources in your LANs and VPC by adding a network." - } + title={t("controlCenter.emptyNetworkTitle")} + description={t("controlCenter.emptyNetworkDescription")} button={
} learnMore={ <> - Learn more about + {t("common.learnMorePrefix")} - Networks + {t("networks.title")} @@ -1833,7 +1833,7 @@ function ControlCenterView() {
diff --git a/src/app/(dashboard)/dns/nameservers/page.tsx b/src/app/(dashboard)/dns/nameservers/page.tsx index b287ea09..9ed18e46 100644 --- a/src/app/(dashboard)/dns/nameservers/page.tsx +++ b/src/app/(dashboard)/dns/nameservers/page.tsx @@ -11,6 +11,7 @@ import { ExternalLinkIcon } from "lucide-react"; import React, { lazy, Suspense } from "react"; import DNSIcon from "@/assets/icons/DNSIcon"; import { usePermissions } from "@/contexts/PermissionsProvider"; +import { useI18n } from "@/i18n/I18nProvider"; import { NameserverGroup } from "@/interfaces/Nameserver"; import PageContainer from "@/layouts/PageContainer"; @@ -20,6 +21,7 @@ const NameserverGroupTable = lazy( export default function NameServers() { const { permission } = usePermissions(); + const { t } = useI18n(); const { data: nameserverGroups, isLoading } = useFetchApi("/dns/nameservers"); @@ -33,35 +35,33 @@ export default function NameServers() { } /> } /> -

Nameservers

+

{t("nameservers.title")}

+ {t("nameservers.description")} - Add nameservers for domain name resolution in your NetBird network. - - - Learn more about + {t("common.learnMorePrefix")}{" "} - DNS + {t("dns.title")} - in our documentation. + {t("common.inDocumentationSuffix")}
}> diff --git a/src/app/(dashboard)/dns/settings/page.tsx b/src/app/(dashboard)/dns/settings/page.tsx index 573a49eb..e9c38c92 100644 --- a/src/app/(dashboard)/dns/settings/page.tsx +++ b/src/app/(dashboard)/dns/settings/page.tsx @@ -19,6 +19,7 @@ import { useSWRConfig } from "swr"; import DNSIcon from "@/assets/icons/DNSIcon"; import { usePermissions } from "@/contexts/PermissionsProvider"; import { useHasChanges } from "@/hooks/useHasChanges"; +import { useI18n } from "@/i18n/I18nProvider"; import { Group } from "@/interfaces/Group"; import { NameserverSettings } from "@/interfaces/NameserverSettings"; import PageContainer from "@/layouts/PageContainer"; @@ -27,6 +28,7 @@ import { useGroupIdsToGroups } from "@/modules/groups/useGroupIdsToGroups"; export default function NameServerSettings() { const { permission } = usePermissions(); + const { t } = useI18n(); const { data: settings, isLoading } = useFetchApi("/dns/settings"); @@ -41,30 +43,33 @@ export default function NameServerSettings() { } /> } /> -

DNS Settings

- {"Manage your account's DNS settings."} +

{t("dnsSettingsPage.title")}

+ {t("dnsSettingsPage.description")} - Learn more about + {t("common.learnMorePrefix")}{" "} - DNS + {t("dns.title")} - in our documentation. + {" "}{t("common.inDocumentationSuffix")} - + {!isLoading && initialDNSGroups !== undefined ? ( ) : ( @@ -90,6 +95,7 @@ const SettingDisabledManagementGroups = ({ const settingRequest = useApiCall("/dns/settings"); const { mutate } = useSWRConfig(); const { permission } = usePermissions(); + const { t } = useI18n(); const [selectedGroups, setSelectedGroups, { save: saveGroups }] = useGroupHelper({ @@ -103,8 +109,8 @@ const SettingDisabledManagementGroups = ({ const saveSettings = async () => { const savedGroups = await saveGroups(); notify({ - title: "DNS Settings", - description: "Settings saved successfully.", + title: t("dnsSettingsPage.title"), + description: t("dnsSettingsPage.saved"), promise: settingRequest .put({ disabled_management_groups: savedGroups.map((g) => g.id), @@ -113,17 +119,15 @@ const SettingDisabledManagementGroups = ({ mutate("/dns/settings"); updateChangesRef([selectedGroups]); }), - loadingMessage: "Saving the settings...", + loadingMessage: t("dnsSettingsPage.saving"), }); }; return (
- - - Peers in these groups will require manual domain name resolution - + + {t("dnsSettingsPage.disableManagementHelp")} - Save Changes + {t("actions.saveChanges")}
diff --git a/src/app/(dashboard)/dns/zones/page.tsx b/src/app/(dashboard)/dns/zones/page.tsx index 0abf50f1..e351e9ab 100644 --- a/src/app/(dashboard)/dns/zones/page.tsx +++ b/src/app/(dashboard)/dns/zones/page.tsx @@ -11,6 +11,7 @@ import { ExternalLinkIcon } from "lucide-react"; import React, { lazy, Suspense } from "react"; import DNSIcon from "@/assets/icons/DNSIcon"; import { usePermissions } from "@/contexts/PermissionsProvider"; +import { useI18n } from "@/i18n/I18nProvider"; import { DNS_ZONE_DOCS_LINK, DNSZone } from "@/interfaces/DNS"; import PageContainer from "@/layouts/PageContainer"; import { DNSZonesProvider } from "@/modules/dns/zones/DNSZonesProvider"; @@ -22,6 +23,7 @@ const DNSZonesTable = lazy( export default function DNSZonePage() { const { permission } = usePermissions(); + const { t } = useI18n(); const { data: zones, isLoading } = useFetchApi("/dns/zones"); @@ -32,29 +34,30 @@ export default function DNSZonePage() {
- } /> + } /> } /> -

Zones

+

{t("zones.title")}

+ {t("zones.description")} - Manage DNS zones to control domain name resolution for your network. - - - Learn more about + {t("common.learnMorePrefix")}{" "} - DNS Zones + {t("zones.learnMoreLink")} - in our documentation. + {t("common.inDocumentationSuffix")}
- + }> ("/events/audit"); @@ -28,31 +30,31 @@ export default function Activity() {
} /> } /> -

Audit Events

- Here you can see all the audit activity events. +

{t("activity.auditEventsTitle")}

+ {t("activity.auditEventsDescription")} - Learn more about{" "} + {t("common.learnMorePrefix")}{" "} - Audit Events + {t("nav.auditEvents")} - in our documentation. + {" "}{t("common.inDocumentationSuffix")}
- + (); @@ -36,35 +38,32 @@ export default function ProxyEventsPage() {
} /> } /> -

Proxy Events

+

{t("proxyEvents.title")}

- - View access logs for your reverse proxy services, including allowed - and denied requests. - + {t("proxyEvents.description")} - Learn more about{" "} + {t("common.learnMorePrefix")}{" "} - Proxy Events + {t("nav.proxyEvents")} {" "} - in our documentation. + {t("common.inDocumentationSuffix")}
- +
); } @@ -59,9 +61,7 @@ export default function GroupPage() { return ( ); @@ -73,7 +73,7 @@ export default function GroupPage() { } /> @@ -142,6 +142,7 @@ const validAllGroupTabs = [ const validOtherGroupTabs = ["users", "peers", "setup-keys"]; const GroupOverviewTabs = ({ group }: { group: Group }) => { + const { t } = useI18n(); const searchParams = useSearchParams(); const getInitialTab = () => { @@ -188,7 +189,7 @@ const GroupOverviewTabs = ({ group }: { group: Group }) => { "fill-nb-gray-500 group-data-[state=active]/trigger:fill-netbird transition-all" } /> - {singularize("Users", usersCount)} + {singularize(t("users.title"), usersCount)} )} @@ -203,7 +204,7 @@ const GroupOverviewTabs = ({ group }: { group: Group }) => { "fill-nb-gray-500 group-data-[state=active]/trigger:fill-netbird transition-all" } /> - {singularize("Peers", peersCount)} + {singularize(t("peers.title"), peersCount)} )} @@ -217,7 +218,7 @@ const GroupOverviewTabs = ({ group }: { group: Group }) => { "fill-nb-gray-500 group-data-[state=active]/trigger:fill-netbird transition-all" } /> - {singularize("Policies", policiesCount)} + {singularize(t("nav.policies"), policiesCount)} { className={groupDetails === null ? "animate-pulse" : ""} > - {singularize("Resources", resourcesCount)} + {singularize(t("networkDetails.resources"), resourcesCount)} { "fill-nb-gray-500 group-data-[state=active]/trigger:fill-netbird transition-all" } /> - {singularize("Network Routes", routesCount)} + {singularize(t("networkRoutesPage.title"), routesCount)} { "fill-nb-gray-500 group-data-[state=active]/trigger:fill-netbird transition-all" } /> - {singularize("Nameservers", nameserversCount)} + {singularize(t("nameservers.title"), nameserversCount)} { "fill-nb-gray-500 group-data-[state=active]/trigger:fill-netbird transition-all" } /> - {singularize("Zones", zonesCount)} + {singularize(t("zones.title"), zonesCount)} {group.name !== "All" && ( @@ -278,7 +279,7 @@ const GroupOverviewTabs = ({ group }: { group: Group }) => { "fill-nb-gray-500 group-data-[state=active]/trigger:fill-netbird transition-all" } /> - {singularize("Setup Keys", setupKeysCount)} + {singularize(t("setupKeys.title"), setupKeysCount)} )} diff --git a/src/app/(dashboard)/groups/page.tsx b/src/app/(dashboard)/groups/page.tsx index cf99db40..d2596d77 100644 --- a/src/app/(dashboard)/groups/page.tsx +++ b/src/app/(dashboard)/groups/page.tsx @@ -9,12 +9,14 @@ import React, { lazy, Suspense } from "react"; import Breadcrumbs from "@/components/Breadcrumbs"; import InlineLink from "@/components/InlineLink"; import { usePermissions } from "@/contexts/PermissionsProvider"; +import { useI18n } from "@/i18n/I18nProvider"; import PageContainer from "@/layouts/PageContainer"; const GroupsTable = lazy(() => import("@/modules/groups/table/GroupsTable")); export default function GroupsPage() { const { permission } = usePermissions(); + const { t } = useI18n(); const { ref: headingRef, portalTarget } = usePortalElement(); @@ -24,29 +26,29 @@ export default function GroupsPage() { } active /> -

Groups

+

{t("groups.title")}

+ {t("groups.description")} - Here is the overview of the groups of your organization. You can - delete the unused ones. - - - Learn more about{" "} + {t("common.learnMorePrefix")}{" "} - Groups + {t("groups.title")} - in our documentation. + {t("common.inDocumentationSuffix")} - + }> diff --git a/src/app/(dashboard)/network-routes/page.tsx b/src/app/(dashboard)/network-routes/page.tsx index eeb3d0f1..37c453c4 100644 --- a/src/app/(dashboard)/network-routes/page.tsx +++ b/src/app/(dashboard)/network-routes/page.tsx @@ -17,6 +17,7 @@ import { Route } from "@/interfaces/Route"; import PageContainer from "@/layouts/PageContainer"; import useGroupedRoutes from "@/modules/route-group/useGroupedRoutes"; import { Callout } from "@components/Callout"; +import { useI18n } from "@/i18n/I18nProvider"; const NetworkRoutesTable = lazy( () => import("@/modules/route-group/NetworkRoutesTable"), @@ -24,6 +25,7 @@ const NetworkRoutesTable = lazy( export default function NetworkRoutes() { const { permission } = usePermissions(); + const { t } = useI18n(); const { data: routes, isLoading } = useFetchApi("/routes"); const groupedRoutes = useGroupedRoutes({ routes }); @@ -38,35 +40,31 @@ export default function NetworkRoutes() { } /> -

Network Routes

+

{t("networkRoutesPage.title")}

+ {t("networkRoutesPage.description")} - Network routes allow you to access other networks like LANs and - VPCs without installing NetBird on every resource. - - - Learn more about + {t("common.learnMorePrefix")}{" "} - Network Routes + {t("networkRoutesPage.title")} - in our documentation. + {" "}{t("common.inDocumentationSuffix")} - We recommend using the new Networks concept to easier visualise - and manage access to your resources.{" "} + {t("networkRoutesPage.callout")}{" "} - Go to Networks + {t("networkRoutesPage.goToNetworks")} diff --git a/src/app/(dashboard)/network/page.tsx b/src/app/(dashboard)/network/page.tsx index f6962e94..4023b1d1 100644 --- a/src/app/(dashboard)/network/page.tsx +++ b/src/app/(dashboard)/network/page.tsx @@ -50,6 +50,7 @@ import ReverseProxiesProvider, { useReverseProxies, } from "@/contexts/ReverseProxiesProvider"; import { SkeletonNetwork } from "@components/skeletons/SkeletonNetwork"; +import { useI18n } from "@/i18n/I18nProvider"; export default function NetworkDetailPage() { const queryParameter = useSearchParams(); @@ -71,6 +72,7 @@ export default function NetworkDetailPage() { } function NetworkOverview({ network }: Readonly<{ network: Network }>) { + const { t } = useI18n(); const { permission } = usePermissions(); const { data: resources, isLoading: isResourcesLoading } = useFetchApi< @@ -103,7 +105,7 @@ function NetworkOverview({ network }: Readonly<{ network: Network }>) { } /> @@ -149,7 +151,10 @@ function NetworkOverview({ network }: Readonly<{ network: Network }>) { - {singularize("Resources", network?.resources?.length)} + {singularize( + t("networkDetails.resources"), + network?.resources?.length, + )} ) { "fill-nb-gray-500 group-data-[state=active]/trigger:fill-netbird transition-all" } /> - {singularize("Routing Peers", network?.routing_peers_count)} + {singularize( + t("networkDetails.routingPeers"), + network?.routing_peers_count, + )} ) { "fill-nb-gray-500 group-data-[state=active]/trigger:fill-netbird transition-all" } /> - {singularize("Services", services.length)} + {singularize(t("networkDetails.services"), services.length)} @@ -199,6 +207,7 @@ function NetworkOverview({ network }: Readonly<{ network: Network }>) { } function NetworkActions() { + const { t } = useI18n(); const { permission } = usePermissions(); const { deleteNetwork, openEditNetworkModal, network } = useNetworksContext(); const router = useRouter(); @@ -225,7 +234,7 @@ function NetworkActions() { >
- Rename + {t("actions.rename")}
@@ -240,7 +249,7 @@ function NetworkActions() { >
- Delete + {t("actions.delete")}
@@ -249,6 +258,7 @@ function NetworkActions() { } function NetworkInformationCard({ network }: Readonly<{ network: Network }>) { + const { t } = useI18n(); const isHighlyAvailable = !!( network?.routing_peers_count && network?.routing_peers_count >= 2 ); @@ -256,23 +266,27 @@ function NetworkInformationCard({ network }: Readonly<{ network: Network }>) { const disabledText = useMemo( () => ( <> - High availability is currently{" "} - inactive for this - network. + {t("networkDetails.highAvailabilityInactivePrefix")}{" "} + + {t("networkDetails.inactive")} + {" "} + {t("networkDetails.highAvailabilitySuffix")} ), - [], + [t], ); const enabledText = useMemo( () => ( <> - High availability is{" "} - active for this - network. + {t("networkDetails.highAvailabilityActivePrefix")}{" "} + + {t("networkDetails.active")} + {" "} + {t("networkDetails.highAvailabilitySuffix")} ), - [], + [t], ); const policyCount = network.policies?.length ?? 0; @@ -285,7 +299,7 @@ function NetworkInformationCard({ network }: Readonly<{ network: Network }>) { label={ <> - High Availability + {t("networkDetails.highAvailability")} } value={ @@ -296,13 +310,11 @@ function NetworkInformationCard({ network }: Readonly<{ network: Network }>) { {isHighlyAvailable ? enabledText : disabledText} {isHighlyAvailable ? (
- You can add more routing peers to increase the - availability of this network. + {t("networkDetails.highAvailabilityEnabledHelp")}
) : (
- Go ahead and add more routing peers or groups with routing - peers to enable high availability for this network. + {t("networkDetails.highAvailabilityDisabledHelp")}
)} @@ -319,7 +331,9 @@ function NetworkInformationCard({ network }: Readonly<{ network: Network }>) { !isHighlyAvailable ? "bg-yellow-400" : "bg-green-500", )} > - {isHighlyAvailable ? "Active" : "Inactive"} + {isHighlyAvailable + ? t("networkDetails.active") + : t("networkDetails.inactive")} @@ -332,19 +346,21 @@ function NetworkInformationCard({ network }: Readonly<{ network: Network }>) { <> {policyCount}{" "} - {policyCount === 1 ? "Active Policy" : "Active Policies"} + {policyCount === 1 + ? t("networkDetails.activePolicy") + : t("networkDetails.activePolicies")} ) : ( <> - No Active Policies + {t("networkDetails.noActivePolicies")} ) } value={ policyCount > 0 ? ( - Go to Policies + {t("networkDetails.goToPolicies")} ) : null diff --git a/src/app/(dashboard)/networks/page.tsx b/src/app/(dashboard)/networks/page.tsx index d464495b..d57ade72 100644 --- a/src/app/(dashboard)/networks/page.tsx +++ b/src/app/(dashboard)/networks/page.tsx @@ -11,6 +11,7 @@ import { ExternalLinkIcon } from "lucide-react"; import React, { Suspense } from "react"; import NetworkRoutesIcon from "@/assets/icons/NetworkRoutesIcon"; import { usePermissions } from "@/contexts/PermissionsProvider"; +import { useI18n } from "@/i18n/I18nProvider"; import { Network } from "@/interfaces/Network"; import PageContainer from "@/layouts/PageContainer"; import NetworksTable from "@/modules/networks/table/NetworksTable"; @@ -18,6 +19,7 @@ import NetworksTable from "@/modules/networks/table/NetworksTable"; export default function Networks() { const { data: networks, isLoading } = useFetchApi("/networks"); const { permission } = usePermissions(); + const { t } = useI18n(); const { ref: headingRef, portalTarget } = usePortalElement(); @@ -27,29 +29,29 @@ export default function Networks() { } /> -

Networks

+

{t("networks.title")}

+ {t("networks.description")} - Networks allow you to access internal resources in LANs and VPCs - without installing NetBird on every machine. - - - Learn more about + {t("common.learnMorePrefix")}{" "} - Networks + {t("networks.title")} - in our documentation. + {t("common.inDocumentationSuffix")} - + }> - + ); } @@ -101,9 +102,7 @@ export default function PeerPage() { return ( ); @@ -119,6 +118,7 @@ export default function PeerPage() { } function PeerOverview() { + const { t } = useI18n(); const { peer } = usePeer(); return ( @@ -129,7 +129,7 @@ function PeerOverview() { } /> @@ -167,6 +167,7 @@ const usePeerSettings = () => { }; const PeerSettingsProvider = ({ children }: { children: React.ReactNode }) => { + const { t } = useI18n(); const { mutate } = useSWRConfig(); const { peer, peerGroups, update } = usePeer(); const { permission } = usePermissions(); @@ -197,13 +198,13 @@ const PeerSettingsProvider = ({ children }: { children: React.ReactNode }) => { notify({ title: name, - description: "Peer was successfully saved", + description: t("peerDetails.saved"), promise: Promise.all(batchCall).then(() => { mutate("/peers/" + peer.id); mutate("/groups"); updateHasChangedRef([selectedGroups]); }), - loadingMessage: "Saving the peer...", + loadingMessage: t("peerDetails.saving"), }); }; @@ -226,6 +227,7 @@ const PeerSettingsProvider = ({ children }: { children: React.ReactNode }) => { }; const PeerHeader = () => { + const { t } = useI18n(); const router = useRouter(); const { peer, user } = usePeer(); const { permission } = usePermissions(); @@ -299,7 +301,7 @@ const PeerHeader = () => { className={"w-full"} onClick={() => router.push("/peers")} > - Cancel + {t("actions.cancel")} )} @@ -321,6 +323,7 @@ const PeerHeader = () => { }; const PeerOverviewTabs = () => { + const { t } = useI18n(); const { peer } = usePeer(); const { permission } = usePermissions(); const { reverseProxies, isLoading: isServicesLoading } = useReverseProxies(); @@ -341,20 +344,20 @@ const PeerOverviewTabs = () => { - Overview + {t("peerDetails.overview")} {permission.routes.read && ( - Network Routes + {t("networkRoutesPage.title")} )} {peer?.id && permission.peers.read && ( - Accessible Peers + {t("peerDetails.accessiblePeers")} )} @@ -364,14 +367,14 @@ const PeerOverviewTabs = () => { size={16} className="fill-nb-gray-400 group-data-[state=active]/trigger:fill-netbird" /> - {singularize("Services", flatTargets.length)} + {t("nav.services")} )} {peer?.id && permission.peers.delete && ( - Remote Jobs + {t("jobs.title")} )} @@ -398,10 +401,8 @@ const PeerOverviewTabs = () => { targets={flatTargets} isLoading={isServicesLoading} hideResourceColumn - emptyTableTitle={"This peer has no services"} - emptyTableDescription={ - "Add your services to this peer and securely expose them through NetBird's reverse proxy" - } + emptyTableTitle={t("peerDetails.noServicesTitle")} + emptyTableDescription={t("peerDetails.noServicesDescription")} /> )} @@ -416,6 +417,7 @@ const PeerOverviewTabs = () => { }; const PeerOverviewTabContent = () => { + const { t } = useI18n(); const { peer } = usePeer(); const { permission } = usePermissions(); const { selectedGroups, setSelectedGroups } = usePeerSettings(); @@ -433,10 +435,8 @@ const PeerOverviewTabContent = () => { {permission.groups.read && (
- - - Use groups to control what this peer can access. - + + {t("peerDetails.assignedGroupsHelp")} { {/* Remote Access Buttons */}
- - Connect directly to this peer via SSH or RDP. + + {t("peerDetails.remoteAccessHelp")}
@@ -465,6 +465,7 @@ const PeerOverviewTabContent = () => { }; function PeerInformationCard({ peer }: Readonly<{ peer: Peer }>) { + const { t } = useI18n(); const { isLoading, getRegionByPeer } = useCountries(); const { update } = usePeer(); const { mutate } = useSWRConfig(); @@ -482,12 +483,12 @@ function PeerInformationCard({ peer }: Readonly<{ peer: Peer }>) { onSuccess={(newIP) => { notify({ title: peer.name, - description: "Peer IP was successfully updated", + description: t("peerDetails.ipUpdated"), promise: update({ ip: newIP }).then(() => { mutate("/peers/" + peer.id); setShowEditIPModal(false); }), - loadingMessage: "Updating peer IP...", + loadingMessage: t("peerDetails.ipUpdating"), }); }} peer={peer} @@ -499,11 +500,11 @@ function PeerInformationCard({ peer }: Readonly<{ peer: Peer }>) { - NetBird IP Address + {t("peerDetails.netbirdIpAddress")} } valueToCopy={peer.ip} @@ -527,11 +528,11 @@ function PeerInformationCard({ peer }: Readonly<{ peer: Peer }>) { - Public IP Address + {t("peerDetails.publicIpAddress")} } value={peer.connection_ip} @@ -539,11 +540,11 @@ function PeerInformationCard({ peer }: Readonly<{ peer: Peer }>) { - Domain Name + {t("peerDetails.domainName")} } className={ @@ -557,11 +558,11 @@ function PeerInformationCard({ peer }: Readonly<{ peer: Peer }>) { - Hostname + {t("peerDetails.hostname")} } value={peer.hostname} @@ -571,13 +572,13 @@ function PeerInformationCard({ peer }: Readonly<{ peer: Peer }>) { label={ <> - Region + {t("peerDetails.region")} } tooltip={false} value={ isEmpty(peer.country_code) ? ( - "Unknown" + t("peerDetails.unknown") ) : ( <> {isLoading ? ( @@ -601,7 +602,7 @@ function PeerInformationCard({ peer }: Readonly<{ peer: Peer }>) { label={ <> - Operating System + {t("peerDetails.operatingSystem")} } value={peer.os} @@ -612,7 +613,7 @@ function PeerInformationCard({ peer }: Readonly<{ peer: Peer }>) { label={ <> - Serial Number + {t("peerDetails.serialNumber")} } value={peer.serial_number} @@ -624,11 +625,11 @@ function PeerInformationCard({ peer }: Readonly<{ peer: Peer }>) { label={ <> - Registered on + {t("peerDetails.registeredOn")} } value={ - dayjs(peer.created_at).format("D MMMM, YYYY [at] h:mm A") + + dayjs(peer.created_at).format(t("peerDetails.dateTimeFormat")) + " (" + dayjs().to(peer.created_at) + ")" @@ -640,13 +641,13 @@ function PeerInformationCard({ peer }: Readonly<{ peer: Peer }>) { label={ <> - Last seen + {t("peerDetails.lastSeen")} } - value={ - peer.connected - ? "just now" - : dayjs(peer.last_seen).format("D MMMM, YYYY [at] h:mm A") + + value={ + peer.connected + ? t("peerDetails.justNow") + : dayjs(peer.last_seen).format(t("peerDetails.dateTimeFormat")) + " (" + dayjs().to(peer.last_seen) + ")" @@ -657,7 +658,7 @@ function PeerInformationCard({ peer }: Readonly<{ peer: Peer }>) { label={ <> - Agent Version + {t("peerDetails.agentVersion")} } value={peer.version} @@ -668,7 +669,7 @@ function PeerInformationCard({ peer }: Readonly<{ peer: Peer }>) { label={ <> - UI Version + {t("peerDetails.uiVersion")} } value={peer.ui_version?.replace("netbird-desktop-ui/", "")} @@ -687,6 +688,7 @@ interface ModalProps { } function EditNameModal({ onSuccess, peer, initialName }: Readonly) { + const { t } = useI18n(); const [name, setName] = useState(initialName); const isDisabled = useMemo(() => { @@ -713,15 +715,15 @@ function EditNameModal({ onSuccess, peer, initialName }: Readonly) {
setName(e.target.value)} /> @@ -729,11 +731,10 @@ function EditNameModal({ onSuccess, peer, initialName }: Readonly) { - If the domain name already exists, we add an increment number - suffix to it. + {t("peerDetails.domainPreviewHelp")}
{domainNamePreview} @@ -745,7 +746,7 @@ function EditNameModal({ onSuccess, peer, initialName }: Readonly) {
@@ -756,7 +757,7 @@ function EditNameModal({ onSuccess, peer, initialName }: Readonly) { disabled={isDisabled} type={"submit"} > - Save + {t("actions.save")}
@@ -771,6 +772,7 @@ interface EditIPModalProps { } function EditIPModal({ onSuccess, peer }: Readonly) { + const { t } = useI18n(); const [ip, setIP] = useState(peer.ip); const [error, setError] = useState(""); @@ -792,7 +794,7 @@ function EditIPModal({ onSuccess, peer }: Readonly) { setError(""); break; case !validateIP(ip): - setError("Please enter a valid IP, e.g., 100.64.0.15"); + setError(t("peerDetails.validIpError")); break; default: setError(""); @@ -804,29 +806,29 @@ function EditIPModal({ onSuccess, peer }: Readonly) {
setIP(e.target.value)} error={error} />
- Changes take effect when the peer reconnects. + {t("peerDetails.reconnectNotice")}
@@ -836,7 +838,7 @@ function EditIPModal({ onSuccess, peer }: Readonly) { onClick={() => onSuccess(ip)} disabled={isDisabled} > - Save + {t("actions.save")}
diff --git a/src/app/(dashboard)/peers/page.tsx b/src/app/(dashboard)/peers/page.tsx index becc3065..0a372b1d 100644 --- a/src/app/(dashboard)/peers/page.tsx +++ b/src/app/(dashboard)/peers/page.tsx @@ -11,6 +11,7 @@ import PeerIcon from "@/assets/icons/PeerIcon"; import PeersProvider, { usePeers } from "@/contexts/PeersProvider"; import { usePermissions } from "@/contexts/PermissionsProvider"; import { useUsers } from "@/contexts/UsersProvider"; +import { useI18n } from "@/i18n/I18nProvider"; import PageContainer from "@/layouts/PageContainer"; import { SetupModalContent } from "@/modules/setup-netbird-modal/SetupModal"; @@ -35,6 +36,7 @@ export default function Peers() { function PeersView() { const { peers, isLoading } = usePeers(); const { users } = useUsers(); + const { t } = useI18n(); const { ref: headingRef, portalTarget } = usePortalElement(); @@ -52,25 +54,22 @@ function PeersView() { } /> -

Peers

+

{t("peers.title")}

+ {t("peers.description")} - A list of all machines and devices connected to your private network. - Use this view to manage peers. - - - Learn more about{" "} + {t("peers.learnMorePrefix")}{" "} - Peers + {t("peers.learnMoreLink")} - in our documentation. + {t("peers.learnMoreSuffix")}
}> @@ -85,19 +84,19 @@ function PeersView() { } function PeersBlockedView() { + const { t } = useI18n(); + return (
-

Add new device to your network

+

{t("peers.blockedTitle")}

- 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{" "} + {t("peers.blockedDescription")}{" "} - Installation Guide + {t("peers.installationGuide")} diff --git a/src/app/(dashboard)/posture-checks/page.tsx b/src/app/(dashboard)/posture-checks/page.tsx index 6a7dda53..5b5257fa 100644 --- a/src/app/(dashboard)/posture-checks/page.tsx +++ b/src/app/(dashboard)/posture-checks/page.tsx @@ -12,6 +12,7 @@ import React, { lazy, Suspense } from "react"; import AccessControlIcon from "@/assets/icons/AccessControlIcon"; import GroupsProvider from "@/contexts/GroupsProvider"; import { usePermissions } from "@/contexts/PermissionsProvider"; +import { useI18n } from "@/i18n/I18nProvider"; import PoliciesProvider from "@/contexts/PoliciesProvider"; import { PostureCheck } from "@/interfaces/PostureCheck"; import PageContainer from "@/layouts/PageContainer"; @@ -21,6 +22,7 @@ const PostureCheckTable = lazy( ); export default function PostureChecksPage() { const { permission } = usePermissions(); + const { t } = useI18n(); const { data: postureChecks, isLoading } = useFetchApi("/posture-checks"); @@ -34,35 +36,33 @@ export default function PostureChecksPage() { } /> } /> -

Posture Checks

+

{t("nav.postureChecks")}

+ {t("postureChecks.pageDescription")} - Use posture checks to further restrict access in your network. - - - Learn more about + {t("common.learnMorePrefix")}{" "} - Posture Checks + {t("nav.postureChecks")} - in our documentation. + {" "}{t("common.inDocumentationSuffix")}
diff --git a/src/app/(dashboard)/reverse-proxy/custom-domains/page.tsx b/src/app/(dashboard)/reverse-proxy/custom-domains/page.tsx index d052c0ce..a7dfae54 100644 --- a/src/app/(dashboard)/reverse-proxy/custom-domains/page.tsx +++ b/src/app/(dashboard)/reverse-proxy/custom-domains/page.tsx @@ -11,6 +11,7 @@ import React, { lazy, Suspense } from "react"; import ReverseProxyIcon from "@/assets/icons/ReverseProxyIcon"; import { usePermissions } from "@/contexts/PermissionsProvider"; import ReverseProxiesProvider from "@/contexts/ReverseProxiesProvider"; +import { useI18n } from "@/i18n/I18nProvider"; import { REVERSE_PROXY_CUSTOM_DOMAINS_DOCS_LINK } from "@/interfaces/ReverseProxy"; import PageContainer from "@/layouts/PageContainer"; @@ -20,6 +21,7 @@ const CustomDomainsTable = lazy( export default function ReverseProxyCustomDomainsPage() { const { permission } = usePermissions(); + const { t } = useI18n(); const { ref: headingRef, portalTarget } = usePortalElement(); @@ -30,33 +32,31 @@ export default function ReverseProxyCustomDomainsPage() { } /> -

Domains

+

{t("customDomains.title")}

+ {t("customDomains.description")} - Add and manage custom domains for your reverse proxy services. - - - Learn more about + {t("common.learnMorePrefix")}{" "} - Custom Domains + {t("nav.customDomains")} - in our documentation. + {" "}{t("common.inDocumentationSuffix")}
diff --git a/src/app/(dashboard)/reverse-proxy/services/page.tsx b/src/app/(dashboard)/reverse-proxy/services/page.tsx index 24238b49..f697187d 100644 --- a/src/app/(dashboard)/reverse-proxy/services/page.tsx +++ b/src/app/(dashboard)/reverse-proxy/services/page.tsx @@ -11,6 +11,7 @@ import React, { lazy, Suspense } from "react"; import ReverseProxyIcon from "@/assets/icons/ReverseProxyIcon"; import { usePermissions } from "@/contexts/PermissionsProvider"; import ReverseProxiesProvider from "@/contexts/ReverseProxiesProvider"; +import { useI18n } from "@/i18n/I18nProvider"; import { REVERSE_PROXY_DOCS_LINK } from "@/interfaces/ReverseProxy"; import PageContainer from "@/layouts/PageContainer"; import { Callout } from "@components/Callout"; @@ -22,6 +23,7 @@ const ReverseProxyTable = lazy( export default function ReverseProxyServicesPage() { const { permission } = usePermissions(); + const { t } = useI18n(); const { ref: headingRef, portalTarget } = usePortalElement(); @@ -32,44 +34,39 @@ export default function ReverseProxyServicesPage() { } /> -

Services

+

{t("reverseProxy.servicesTitle")}

+ {t("reverseProxy.servicesDescription")} - Expose services securely through NetBird's reverse proxy. - - - Learn more about + {t("common.learnMorePrefix")}{" "} - Services + {t("reverseProxy.servicesTitle")} - in our documentation. + {t("common.inDocumentationSuffix")} {isNetBirdHosted() ? ( - NetBird's Reverse Proxy is currently in beta and available at - no cost during this period. Features, functionality, and pricing are - subject to change upon release. + {t("reverseProxy.betaHosted")} ) : ( - NetBird's Reverse Proxy is currently in beta.
Features - and functionality are subject to change upon release. + {t("reverseProxy.betaSelfHosted")}
)}
diff --git a/src/app/(dashboard)/settings/page.tsx b/src/app/(dashboard)/settings/page.tsx index 149602f9..b9af5e00 100644 --- a/src/app/(dashboard)/settings/page.tsx +++ b/src/app/(dashboard)/settings/page.tsx @@ -15,6 +15,7 @@ import { useSearchParams } from "next/navigation"; import React, { useEffect, useMemo, useState } from "react"; import { usePermissions } from "@/contexts/PermissionsProvider"; import { useLoggedInUser } from "@/contexts/UsersProvider"; +import { useI18n } from "@/i18n/I18nProvider"; import PageContainer from "@/layouts/PageContainer"; import { useAccount } from "@/modules/account/useAccount"; import AuthenticationTab from "@/modules/settings/AuthenticationTab"; @@ -29,6 +30,7 @@ export default function NetBirdSettings() { const queryParams = useSearchParams(); const queryTab = queryParams.get("tab"); const { permission } = usePermissions(); + const { t } = useI18n(); const initialTab = useMemo(() => { if (permission.settings.read) return "authentication"; @@ -53,30 +55,30 @@ export default function NetBirdSettings() { <> - Authentication + {t("settings.authentication")} {account?.settings?.embedded_idp_enabled && permission?.identity_providers?.read && ( - Identity Providers + {t("settings.identityProviders")} )} - Groups + {t("settings.groups")} - Permissions + {t("settings.permissions")} - Networks + {t("settings.networks")} - Clients + {t("settings.clients")} )} @@ -84,7 +86,7 @@ export default function NetBirdSettings() {
@@ -105,12 +107,13 @@ export default function NetBirdSettings() { const DangerZoneTabTrigger = () => { const { isOwner } = useLoggedInUser(); + const { t } = useI18n(); return ( isOwner && ( - Danger zone + {t("settings.dangerZone")} ) ); diff --git a/src/app/(dashboard)/setup-keys/page.tsx b/src/app/(dashboard)/setup-keys/page.tsx index f7f7b0f3..30514f66 100644 --- a/src/app/(dashboard)/setup-keys/page.tsx +++ b/src/app/(dashboard)/setup-keys/page.tsx @@ -12,6 +12,7 @@ import React, { lazy, Suspense, useMemo } from "react"; import SetupKeysIcon from "@/assets/icons/SetupKeysIcon"; import { useGroups } from "@/contexts/GroupsProvider"; import { usePermissions } from "@/contexts/PermissionsProvider"; +import { useI18n } from "@/i18n/I18nProvider"; import { Group } from "@/interfaces/Group"; import { SetupKey } from "@/interfaces/SetupKey"; import PageContainer from "@/layouts/PageContainer"; @@ -24,6 +25,7 @@ export default function SetupKeys() { const { data: setupKeys, isLoading } = useFetchApi("/setup-keys"); const { permission } = usePermissions(); const { groups } = useGroups(); + const { t } = useI18n(); const setupKeysWithGroups = useMemo(() => { if (!setupKeys) return []; @@ -50,31 +52,28 @@ export default function SetupKeys() { } /> -

Setup Keys

+

{t("setupKeys.title")}

+ {t("setupKeys.description")} - Setup keys are pre-authentication keys that allow to register new - machines in your network. - - - Learn more about + {t("common.learnMorePrefix")}{" "} - Setup Keys + {t("setupKeys.title")} - in our documentation. + {t("common.inDocumentationSuffix")}
}> diff --git a/src/app/(dashboard)/team/service-users/page.tsx b/src/app/(dashboard)/team/service-users/page.tsx index f4f4a0e3..736e0e09 100644 --- a/src/app/(dashboard)/team/service-users/page.tsx +++ b/src/app/(dashboard)/team/service-users/page.tsx @@ -12,6 +12,7 @@ import { ExternalLinkIcon } from "lucide-react"; import React, { lazy, Suspense } from "react"; import TeamIcon from "@/assets/icons/TeamIcon"; import { usePermissions } from "@/contexts/PermissionsProvider"; +import { useI18n } from "@/i18n/I18nProvider"; import { User } from "@/interfaces/User"; import PageContainer from "@/layouts/PageContainer"; @@ -21,6 +22,7 @@ const ServiceUsersTable = lazy( export default function ServiceUsers() { const { permission } = usePermissions(); + const { t } = useI18n(); const { data: users, isLoading } = useFetchApi( "/users?service_user=true", ); @@ -34,35 +36,32 @@ export default function ServiceUsers() { } /> } /> -

Service Users

+

{t("serviceUsers.title")}

+ {t("serviceUsers.description")} - Use service users to create API tokens and avoid losing automated - access. - - - Learn more about + {t("common.learnMorePrefix")}{" "} - Service Users + {t("serviceUsers.title")} - in our documentation. + {t("common.inDocumentationSuffix")}
}> diff --git a/src/app/(dashboard)/team/user/page.tsx b/src/app/(dashboard)/team/user/page.tsx index 39fd7873..72576cb6 100644 --- a/src/app/(dashboard)/team/user/page.tsx +++ b/src/app/(dashboard)/team/user/page.tsx @@ -33,6 +33,7 @@ import TeamIcon from "@/assets/icons/TeamIcon"; import { usePermissions } from "@/contexts/PermissionsProvider"; import { useLoggedInUser } from "@/contexts/UsersProvider"; import { useHasChanges } from "@/hooks/useHasChanges"; +import { useI18n } from "@/i18n/I18nProvider"; import { Group } from "@/interfaces/Group"; import { Role, User } from "@/interfaces/User"; import PageContainer from "@/layouts/PageContainer"; @@ -46,6 +47,7 @@ import { UserPeersSection } from "@/modules/users/UserPeersSection"; import { UserRoleSelector } from "@/modules/users/UserRoleSelector"; export default function UserPage() { + const { t } = useI18n(); const queryParameter = useSearchParams(); const userId = queryParameter.get("id"); const { permission } = usePermissions(); @@ -66,7 +68,7 @@ export default function UserPage() { if (!permission.users.read) { return ( - + ); } @@ -88,6 +90,7 @@ type Props = { }; function UserOverview({ user, initialGroups }: Readonly) { + const { t } = useI18n(); const router = useRouter(); const userRequest = useApiCall("/users"); const isServiceUser = !!user?.is_service_user; @@ -113,7 +116,7 @@ function UserOverview({ user, initialGroups }: Readonly) { notify({ title: user.name, - description: "Changes successfully saved.", + description: t("userDetails.saved"), promise: userRequest .put( { @@ -127,7 +130,7 @@ function UserOverview({ user, initialGroups }: Readonly) { mutate(`/users?service_user=${isServiceUser}`); updateChangesRef([role, selectedGroups]); }), - loadingMessage: "Saving changes...", + loadingMessage: t("userDetails.saving"), }); }; @@ -148,7 +151,7 @@ function UserOverview({ user, initialGroups }: Readonly) { } /> @@ -156,13 +159,13 @@ function UserOverview({ user, initialGroups }: Readonly) { {isServiceUser ? ( } /> ) : ( } /> @@ -186,7 +189,7 @@ function UserOverview({ user, initialGroups }: Readonly) { : { color: user?.name ? generateColorFromString( - user?.name || user?.id || "System User", + user?.name || user?.id || t("users.system"), ) : "#808080", } @@ -214,7 +217,7 @@ function UserOverview({ user, initialGroups }: Readonly) { : router.push("/team/users"); }} > - Cancel + {t("actions.cancel")}
)} @@ -235,10 +238,8 @@ function UserOverview({ user, initialGroups }: Readonly) {
{!isServiceUser && isOwnerOrAdmin && (
- - - Groups will be assigned to peers added by this user. - + + {t("userDetails.autoAssignedGroupsHelp")} ) { )}
- - - Set a role for the user to assign access permissions. - + + {t("userDetails.userRoleHelp")}
) { {showPeers && ( - Peers + {t("peers.title")} )} {showAccessTokens && ( - Access Tokens + {t("accessTokens.title")} )} @@ -302,10 +301,8 @@ function UserOverview({ user, initialGroups }: Readonly) {
-

Access Tokens

- - Access tokens give access to NetBird API. - +

{t("accessTokens.title")}

+ {t("userDetails.accessTokensDescription")}
@@ -316,7 +313,7 @@ function UserOverview({ user, initialGroups }: Readonly) { disabled={!permission.pats.create} > - Create Access Token + {t("userDetails.createAccessToken")}
@@ -333,6 +330,7 @@ function UserOverview({ user, initialGroups }: Readonly) { } function UserInformationCard({ user }: Readonly<{ user: User }>) { + const { t } = useI18n(); const isServiceUser = user.is_service_user || false; const neverLoggedIn = dayjs(user.last_login).isBefore( dayjs().subtract(1000, "years"), @@ -346,7 +344,7 @@ function UserInformationCard({ user }: Readonly<{ user: User }>) { label={ <> - {user.name ? "Name" : "User ID"} + {user.name ? t("userDetails.name") : t("userDetails.userId")} } value={user.name || user.id} @@ -357,7 +355,7 @@ function UserInformationCard({ user }: Readonly<{ user: User }>) { label={ <> - E-Mail + {t("userDetails.email")} } value={user.email || "-"} @@ -369,7 +367,7 @@ function UserInformationCard({ user }: Readonly<{ user: User }>) { label={ <> - Status + {t("table.status")} } value={} @@ -385,7 +383,7 @@ function UserInformationCard({ user }: Readonly<{ user: User }>) { label={ <> - Block User + {t("table.blockUser")} } value={} @@ -396,13 +394,13 @@ function UserInformationCard({ user }: Readonly<{ user: User }>) { label={ <> - Last login + {t("table.lastLogin")} } value={ neverLoggedIn - ? "Never" - : dayjs(user.last_login).format("D MMMM, YYYY [at] h:mm A") + + ? t("userDetails.never") + : dayjs(user.last_login).format(t("userDetails.lastLoginFormat")) + " (" + dayjs().to(user.last_login) + ")" diff --git a/src/app/(dashboard)/team/users/page.tsx b/src/app/(dashboard)/team/users/page.tsx index 3a7169d3..2fa7f469 100644 --- a/src/app/(dashboard)/team/users/page.tsx +++ b/src/app/(dashboard)/team/users/page.tsx @@ -12,6 +12,7 @@ import React, { lazy, Suspense } from "react"; import TeamIcon from "@/assets/icons/TeamIcon"; import { useGroups } from "@/contexts/GroupsProvider"; import { usePermissions } from "@/contexts/PermissionsProvider"; +import { useI18n } from "@/i18n/I18nProvider"; import { User } from "@/interfaces/User"; import PageContainer from "@/layouts/PageContainer"; @@ -20,6 +21,7 @@ const UsersTable = lazy(() => import("@/modules/users/UsersTable")); export default function TeamUsers() { const { isLoading: isGroupsLoading } = useGroups(); const { permission } = usePermissions(); + const { t } = useI18n(); const { data: users, isLoading } = useFetchApi( "/users?service_user=false", ); @@ -33,34 +35,31 @@ export default function TeamUsers() { } /> } /> -

Users

+

{t("users.title")}

+ {t("users.description")} - Manage users and their permissions. Same-domain email users are added - automatically on first sign-in. - - - Learn more about + {t("common.learnMorePrefix")}{" "} - Users + {t("users.title")} - in our documentation. + {t("common.inDocumentationSuffix")}
- + }> (`/peers/${peerId}`, true, false, !!peerId); + if (error) { + return ( + + ); + } + return (
{peerId && peer && !isLoading ? ( @@ -46,6 +58,7 @@ type Props = { }; function RDPSession({ peer }: Props) { + const { t } = useI18n(); const client = useNetBirdClient(); const [isNetBirdConnecting, setIsNetBirdConnecting] = useState(false); const rdp = useRemoteDesktop(client); @@ -90,7 +103,7 @@ function RDPSession({ peer }: Props) { setIsNetBirdConnecting(false); } catch (error) { sendErrorNotification( - "NetBird Connection Error", + t("remoteAccess.netbirdConnectionError"), (error as Error).message, ); setIsNetBirdConnecting(false); @@ -115,11 +128,14 @@ function RDPSession({ peer }: Props) { } else { } } catch (error) { - sendErrorNotification("RDP Connection Error", (error as Error).message); + sendErrorNotification( + t("remoteAccess.rdpConnectionError"), + (error as Error).message, + ); setCredentialsModal(true); await reset(); } - }, [credentials, peer.ip, rdp, reset]); + }, [credentials, peer.ip, rdp, reset, t]); /** * Establish RDP session when NetBird connection is ready @@ -148,12 +164,15 @@ function RDPSession({ peer }: Props) { */ useEffect(() => { if (rdp.error) { - sendErrorNotification("RDP Error", rdp.error); + sendErrorNotification(t("remoteAccess.rdpError"), rdp.error); } if (client.error) { - sendErrorNotification("NetBird Client Error", client.error); + sendErrorNotification( + t("remoteAccess.netbirdClientError"), + client.error, + ); } - }, [rdp, client]); + }, [rdp, client, t]); /** * Close credentials modal when RDP is connected diff --git a/src/app/(remote-access)/peer/ssh/page.tsx b/src/app/(remote-access)/peer/ssh/page.tsx index e24035b2..d18409e4 100644 --- a/src/app/(remote-access)/peer/ssh/page.tsx +++ b/src/app/(remote-access)/peer/ssh/page.tsx @@ -1,9 +1,9 @@ "use client"; -import { PageNotFound } from "@components/ui/PageNotFound"; import useFetchApi, { ErrorResponse } from "@utils/api"; import { CircleXIcon, InfoIcon, Loader2Icon } from "lucide-react"; import React, { useEffect, useRef } from "react"; +import { useI18n } from "@/i18n/I18nProvider"; import type { Peer } from "@/interfaces/Peer"; import { Terminal } from "@/modules/remote-access/ssh/Terminal"; import { SSHStatus, useSSH } from "@/modules/remote-access/ssh/useSSH"; @@ -18,6 +18,7 @@ import { } from "@utils/version"; export default function SSHPage() { + const { t } = useI18n(); const { peerId, username, port } = useSSHQueryParams(); const { @@ -31,8 +32,7 @@ export default function SSHPage() {
@@ -50,7 +50,7 @@ export default function SSHPage() { port={port} /> ) : ( - + )}
); @@ -63,6 +63,7 @@ type Props = { }; function SSHTerminal({ username, port, peer }: Props) { + const { t } = useI18n(); const client = useNetBirdClient(); const connected = useRef(false); const sshConnectedOnce = useRef(false); @@ -171,7 +172,11 @@ function SSHTerminal({ username, port, peer }: Props) { <> {session && } {!isSSHConnected && ( - + )} ); @@ -223,6 +228,8 @@ const DisconnectedMessage = ({ peerIp, onReconnect, }: DisconnectedMessageProps) => { + const { t } = useI18n(); + return (
- Disconnected from {username}@{peerIp} + {t("remoteAccess.sshDisconnectedFrom", { + target: `${username}@${peerIp}`, + })}
diff --git a/src/app/apple-icon.png b/src/app/apple-icon.png index 24b6e102..94dfebb3 100644 Binary files a/src/app/apple-icon.png and b/src/app/apple-icon.png differ diff --git a/src/app/error/page.tsx b/src/app/error/page.tsx index 72a6a3f2..9bc07fc6 100644 --- a/src/app/error/page.tsx +++ b/src/app/error/page.tsx @@ -8,10 +8,12 @@ import { ArrowRightIcon, RefreshCw } from "lucide-react"; import { useRouter, useSearchParams } from "next/navigation"; import { useEffect, useState } from "react"; import NetBirdIcon from "@/assets/icons/NetBirdIcon"; +import { useI18n } from "@/i18n/I18nProvider"; const config = loadConfig(); export default function ErrorPage() { + const { t } = useI18n(); const { logout, isAuthenticated } = useOidc(); const router = useRouter(); const searchParams = useSearchParams(); @@ -58,19 +60,19 @@ export default function ErrorPage() { error?.message?.toLowerCase().includes("pending approval"); const getTitle = () => { - if (isBlockedUser) return "User Account Blocked"; - if (isPendingApproval) return "User Approval Pending"; - return "Access Error"; + if (isBlockedUser) return t("errorPage.blockedTitle"); + if (isPendingApproval) return t("errorPage.pendingTitle"); + return t("errorPage.defaultTitle"); }; const getDescription = () => { if (isBlockedUser) { - return "Your access has been blocked by the NetBird account administrator, possibly due to new user approval requirements or security policies. Please contact your administrator to regain access."; + return t("errorPage.blockedDescription"); } if (isPendingApproval) { - return "Your account is pending approval from an administrator. Please wait for approval before accessing the dashboard."; + return t("errorPage.pendingDescription"); } - return "An error occurred while trying to access the dashboard. Please try again or contact your administrator."; + return t("errorPage.defaultDescription"); }; return ( @@ -88,25 +90,29 @@ export default function ErrorPage() { {error && (
-
response_message: {error.message}
+
+ {t("errorPage.responseMessage")}: {error.message} +
)} - If you believe this is an error, please contact your administrator. + {t("errorPage.contactAdmin")}
{!isBlockedUser && !isPendingApproval && ( )}
diff --git a/src/app/favicon.ico b/src/app/favicon.ico index 50bb8096..5b30a1cd 100644 Binary files a/src/app/favicon.ico and b/src/app/favicon.ico differ diff --git a/src/app/invite/page.tsx b/src/app/invite/page.tsx index 175afd34..a7d9d59c 100644 --- a/src/app/invite/page.tsx +++ b/src/app/invite/page.tsx @@ -17,6 +17,7 @@ import dayjs from "dayjs"; import { useRouter, useSearchParams } from "next/navigation"; import { Suspense, useEffect, useMemo, useState } from "react"; import NetBirdIcon from "@/assets/icons/NetBirdIcon"; +import { useI18n } from "@/i18n/I18nProvider"; import { UserInviteInfo } from "@/interfaces/User"; export default function InviteAcceptPage() { @@ -28,6 +29,7 @@ export default function InviteAcceptPage() { } function InviteAcceptContent() { + const { t } = useI18n(); const searchParams = useSearchParams(); const router = useRouter(); const token = searchParams?.get("token"); @@ -44,7 +46,7 @@ function InviteAcceptContent() { useEffect(() => { if (!token) { - setError("No invite token provided"); + setError(t("invite.acceptMissingToken")); setLoading(false); return; } @@ -56,15 +58,15 @@ function InviteAcceptContent() { }) .catch((err) => { if (err.code === 429) { - setError("Too many attempts. Please wait a moment and try again."); + setError(t("invite.acceptRateLimited")); setIsRateLimited(true); } else { - setError(err.message || "Invalid or expired invite link"); + setError(err.message || t("invite.acceptInvalidLink")); setIsRateLimited(false); } setLoading(false); }); - }, [token]); + }, [token, t]); const passwordsMatch = password === confirmPassword; const hasMinLength = password.length >= 8; @@ -86,7 +88,7 @@ function InviteAcceptContent() { await acceptInvite(token, password); setSuccess(true); } catch (err: any) { - setError(err.message || "Failed to accept invite"); + setError(err.message || t("invite.acceptFailed")); } finally { setSubmitting(false); } @@ -112,18 +114,17 @@ function InviteAcceptContent() {

- Too Many Requests + {t("invite.tooManyRequests")}

- You've made too many requests. Please wait a moment and try - again. + {t("invite.tooManyRequestsDescription")}
@@ -139,18 +140,17 @@ function InviteAcceptContent() {

- Invalid Invite + {t("invite.invalidTitle")}

- This invite link is invalid or has expired. Please contact your - administrator to receive a new invitation. + {t("invite.invalidDescription")}
@@ -167,18 +167,17 @@ function InviteAcceptContent() {

- Account Created! + {t("invite.accountCreatedTitle")}

- Your account has been created successfully. You can now log in with - your email and password. + {t("invite.accountCreatedDescription")} @@ -195,18 +194,17 @@ function InviteAcceptContent() {

- Invite Expired + {t("invite.expiredTitle")}

- This invite link has expired. Please contact your administrator to - receive a new invitation. + {t("invite.expiredDescription")} @@ -222,10 +220,12 @@ function InviteAcceptContent() {

- Welcome to NetBird + {t("invite.welcomeTitle")}

- You've been invited by {inviteInfo.invited_by} to join the network. Set your password to complete your account setup. + {t("invite.welcomeDescription", { + invitedBy: inviteInfo.invited_by, + })}

@@ -247,7 +247,7 @@ function InviteAcceptContent() {
setPassword(e.target.value)} customPrefix={ @@ -256,11 +256,26 @@ function InviteAcceptContent() { /> {password && (
- - - - - + + + + +
)}
@@ -268,7 +283,7 @@ function InviteAcceptContent() {
setConfirmPassword(e.target.value)} customPrefix={ @@ -277,7 +292,7 @@ function InviteAcceptContent() { /> {confirmPassword && !passwordsMatch && (

- Passwords do not match + {t("changePassword.passwordMatchError")}

)}
@@ -294,13 +309,18 @@ function InviteAcceptContent() { className="w-full" disabled={!canSubmit} > - {submitting ? "Creating Account..." : "Create Account"} + {submitting + ? t("invite.creatingAccount") + : t("invite.createAccount")}

- Invite expires on {dayjs(inviteInfo.expires_at).format("D MMMM, YYYY [at] h:mm A")} + {t("invite.expiresOn")}{" "} + {dayjs(inviteInfo.expires_at).format( + t("invite.acceptExpiresOnFormat"), + )}

@@ -318,4 +338,4 @@ function PasswordRule({ met, text }: { met: boolean; text: string }) { {text} ); -} \ No newline at end of file +} diff --git a/src/assets/icons/IdentityProviderIcons.tsx b/src/assets/icons/IdentityProviderIcons.tsx index bf969b65..9a18d27f 100644 --- a/src/assets/icons/IdentityProviderIcons.tsx +++ b/src/assets/icons/IdentityProviderIcons.tsx @@ -24,6 +24,7 @@ export const idpIcon = ( authentik: , keycloak: , oidc: , + wechatwork: , }; return icons[type]; diff --git a/src/assets/netbird-full.svg b/src/assets/netbird-full.svg index f925d576..d2751977 100644 --- a/src/assets/netbird-full.svg +++ b/src/assets/netbird-full.svg @@ -1,19 +1,21 @@ - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + diff --git a/src/assets/netbird.svg b/src/assets/netbird.svg index 6254931c..e129c9f8 100644 --- a/src/assets/netbird.svg +++ b/src/assets/netbird.svg @@ -1,5 +1,8 @@ - - - - + + + + + + + diff --git a/src/auth/OIDCError.tsx b/src/auth/OIDCError.tsx index aa04d882..ca8a8a9a 100644 --- a/src/auth/OIDCError.tsx +++ b/src/auth/OIDCError.tsx @@ -7,10 +7,12 @@ import { useSearchParams } from "next/navigation"; import * as React from "react"; import { useEffect, useState } from "react"; import NetBirdIcon from "@/assets/icons/NetBirdIcon"; +import { useI18n } from "@/i18n/I18nProvider"; const config = loadConfig(); export const OIDCError = () => { + const { t } = useI18n(); const { oidcUserLoadingState } = useOidcUser(); const params = useSearchParams(); const errorParam = params.get("error"); @@ -24,13 +26,13 @@ export const OIDCError = () => { if (accessDenied) { if (title === "account linked successfully") { setTitle( - "Your account has been linked successfully. Please log in again to complete the setup.", + t("auth.accountLinkedSuccessfully"), ); } } else { - setTitle("Oops, something went wrong"); + setTitle(t("auth.somethingWentWrong")); } - }, [accessDenied, title]); + }, [accessDenied, title, t]); return (
{ {accessDenied ? ( <> - Already verified your email address? + {t("auth.alreadyVerifiedEmail")} @@ -69,19 +71,18 @@ export const OIDCError = () => { className={"mt-5"} onClick={() => logout("/", { client_id: config.clientId })} > - Trouble logging in? Try again. + {t("auth.troubleLoggingIn")} ) : ( <> - There was an error logging you in.
- Error:{" "} - - {invalidRequest && errorDescription + {t("auth.errorLoggingIn")}
+ {t("auth.error")}:{ + invalidRequest && errorDescription ? errorDescription - : oidcUserLoadingState} -
+ : oidcUserLoadingState + }
)} diff --git a/src/auth/SessionLost.tsx b/src/auth/SessionLost.tsx index 72b6ce3c..b07b4750 100644 --- a/src/auth/SessionLost.tsx +++ b/src/auth/SessionLost.tsx @@ -7,12 +7,14 @@ import { useRouter } from "next/navigation"; import * as React from "react"; import { useEffect } from "react"; import NetBirdIcon from "@/assets/icons/NetBirdIcon"; +import { useI18n } from "@/i18n/I18nProvider"; const config = loadConfig(); export const SessionLost = () => { const router = useRouter(); const { logout } = useOidc(); + const { t } = useI18n(); useEffect(() => { router.push("/peers"); @@ -31,10 +33,9 @@ export const SessionLost = () => { >
-

Session Expired

+

{t("session.expiredTitle")}

- It looks like your login session is no longer active or has expired. - Please login again to continue using the app. + {t("session.expiredDescription")} diff --git a/src/components/Badge.tsx b/src/components/Badge.tsx index bd495167..d7ee3c3b 100644 --- a/src/components/Badge.tsx +++ b/src/components/Badge.tsx @@ -23,7 +23,6 @@ const variants = cva("", { purple: ["bg-purple-950/50 border-purple-500 border text-purple-500"], yellow: ["bg-yellow-950 border-yellow-500 border text-yellow-400"], gray: ["bg-nb-gray-930/60 border-nb-gray-800/40 text-nb-gray-300 border"], - lightGray: ["bg-nb-gray-910 text-nb-gray-200 border border-nb-gray-900"], grayer: [ "bg-nb-gray-900/40 border-nb-gray-800/40 text-nb-gray-300 border", ], @@ -46,7 +45,6 @@ const variants = cva("", { "blue-darker": ["hover:bg-sky-800"], red: ["hover:bg-red-950/40"], gray: ["hover:bg-nb-gray-900"], - lightGray: ["hover:bg-nb-gray-900"], grayer: ["hover:bg-nb-gray-900"], "gray-ghost": ["hover:bg-nb-gray-800 cursor-pointer"], green: ["hover:bg-green-950/50"], diff --git a/src/components/Card.tsx b/src/components/Card.tsx index e2611761..4e9aba42 100644 --- a/src/components/Card.tsx +++ b/src/components/Card.tsx @@ -3,6 +3,7 @@ import useCopyToClipboard from "@hooks/useCopyToClipboard"; import { cn } from "@utils/helpers"; import { Copy } from "lucide-react"; import React from "react"; +import { useI18n } from "@/i18n/I18nProvider"; interface Props extends React.HTMLAttributes { children: React.ReactNode; @@ -96,6 +97,7 @@ const CardTextItem = ({ copyText, tooltip = true, }: CardTextItemProps) => { + const { t } = useI18n(); const [, copyToClipBoard] = useCopyToClipboard(valueToCopy ?? `${value}`); return (
copy && copyToClipBoard( - `${copyText ? copyText : label} has been copied to clipboard.`, + `${copyText ? copyText : label} ${t('common.copiedToClipboard')}`, ) } > diff --git a/src/components/DatePickerWithRange.tsx b/src/components/DatePickerWithRange.tsx index 874063c8..205c43ef 100644 --- a/src/components/DatePickerWithRange.tsx +++ b/src/components/DatePickerWithRange.tsx @@ -10,6 +10,7 @@ import { debounce } from "lodash"; import { Calendar as CalendarIcon } from "lucide-react"; import React, { useMemo, useState } from "react"; import { DateRange } from "react-day-picker"; +import { useI18n } from "@/i18n/I18nProvider"; interface Props { value?: DateRange; @@ -64,6 +65,7 @@ export function DatePickerWithRange({ onChange, disabled = false, }: Readonly) { + const { t } = useI18n(); const isActive = useMemo(() => { return { today: isEqualDateRange(value, defaultRanges.today), @@ -77,21 +79,21 @@ export function DatePickerWithRange({ }, [value]); const displayDateValue = useMemo(() => { - if (!value) return "Select date range"; + if (!value) return t("datePicker.selectDateRange"); - if (isActive.allTime) return "All Time"; - if (isActive.lastMonth) return "Last Month"; - if (isActive.last14Days) return "Last 14 Days"; - if (isActive.last2Days) return "Last 2 Days"; - if (isActive.last7Days) return "Last 7 Days"; - if (isActive.yesterday) return "Yesterday"; - if (isActive.today) return "Today"; + if (isActive.allTime) return t("datePicker.allTime"); + if (isActive.lastMonth) return t("datePicker.lastMonth"); + if (isActive.last14Days) return t("datePicker.last14Days"); + if (isActive.last2Days) return t("datePicker.last2Days"); + if (isActive.last7Days) return t("datePicker.last7Days"); + if (isActive.yesterday) return t("datePicker.yesterday"); + if (isActive.today) return t("datePicker.today"); if (!value.to) return dayjs(value.from).format("MMM DD, YYYY").toString(); return `${dayjs(value.from).format("MMM DD, YYYY")} - ${dayjs( value.to, ).format("MMM DD, YYYY")}`; - }, [value, isActive]); + }, [value, isActive, t]); const [calendarOpen, setCalendarOpen] = useState(false); @@ -146,7 +148,7 @@ export function DatePickerWithRange({ label={ <> - All Time + {t("datePicker.allTime")} } active={isActive.allTime} @@ -155,22 +157,22 @@ export function DatePickerWithRange({
updateRangeAndClose(defaultRanges.lastMonth)} /> updateRangeAndClose(defaultRanges.last14Days)} /> updateRangeAndClose(defaultRanges.yesterday)} /> updateRangeAndClose(defaultRanges.today)} /> diff --git a/src/components/DeviceCard.tsx b/src/components/DeviceCard.tsx index 9f0b74c6..2fec612c 100644 --- a/src/components/DeviceCard.tsx +++ b/src/components/DeviceCard.tsx @@ -7,6 +7,7 @@ import { PeerOSIcon } from "@/assets/icons/PeerOSIcon"; import { ResourceIcon } from "@/assets/icons/ResourceIcon"; import { NetworkResource } from "@/interfaces/Network"; import type { Peer } from "@/interfaces/Peer"; +import { useI18n } from "@/i18n/I18nProvider"; type DeviceCardProps = { device?: Peer; @@ -23,6 +24,7 @@ export const DeviceCard = ({ address, description, }: DeviceCardProps) => { + const { t } = useI18n(); if (!device && !resource) return null; const descriptionText = useMemo(() => { @@ -75,7 +77,7 @@ export const DeviceCard = ({ } > diff --git a/src/components/Dialog.tsx b/src/components/Dialog.tsx index 7e3dcd87..968935a2 100644 --- a/src/components/Dialog.tsx +++ b/src/components/Dialog.tsx @@ -4,6 +4,7 @@ import * as DialogPrimitive from "@radix-ui/react-dialog"; import { cn } from "@utils/helpers"; import { X } from "lucide-react"; import * as React from "react"; +import { useI18n } from "@/i18n/I18nProvider"; const Dialog = DialogPrimitive.Root; @@ -31,7 +32,9 @@ DialogOverlay.displayName = DialogPrimitive.Overlay.displayName; const DialogContent = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef ->(({ className, children, ...props }, ref) => ( +>(({ className, children, ...props }, ref) => { + const { t } = useI18n(); + return ( - Close + {t('common.close')} -)); + ); +}); DialogContent.displayName = DialogPrimitive.Content.displayName; const DialogHeader = ({ diff --git a/src/components/DropdownInput.tsx b/src/components/DropdownInput.tsx index 40aeed95..b4279ea8 100644 --- a/src/components/DropdownInput.tsx +++ b/src/components/DropdownInput.tsx @@ -3,6 +3,7 @@ import { cn } from "@utils/helpers"; import { SearchIcon } from "lucide-react"; import * as React from "react"; import { forwardRef } from "react"; +import { useI18n } from "@/i18n/I18nProvider"; type Props = { value: string; @@ -17,13 +18,14 @@ export const DropdownInput = forwardRef( { value, onChange, - placeholder = "Search...", + placeholder, className, hideEnterIcon = false, ...props }, ref, ) => { + const { t } = useI18n(); return (
( )} value={value} onChange={(e) => onChange(e.target.value)} - placeholder={placeholder} + placeholder={placeholder ?? t("dropdownInput.searchPlaceholder")} {...props} />
diff --git a/src/components/FancyToggleSwitch.tsx b/src/components/FancyToggleSwitch.tsx index 3fed401a..334b1684 100644 --- a/src/components/FancyToggleSwitch.tsx +++ b/src/components/FancyToggleSwitch.tsx @@ -8,7 +8,7 @@ import React from "react"; export const fancyToggleSwitchVariants = cva([], { variants: { variant: { - default: ["px-5 py-4 border rounded-md"], + default: ["px-6 py-4 border rounded-md"], blank: null, }, state: { @@ -45,8 +45,6 @@ interface Props extends FancyToggleSwitchVariants { disabled?: boolean; dataCy?: string; className?: string; - labelClassName?: string; - textWrapperClassName?: string; } export default function FancyToggleSwitch({ @@ -59,8 +57,6 @@ export default function FancyToggleSwitch({ dataCy, className, variant = "default", - labelClassName, - textWrapperClassName = "max-w-sm", }: Readonly) { const handleToggle = () => { if (disabled) return; @@ -91,8 +87,8 @@ export default function FancyToggleSwitch({ )} >
-
- +
+ {helpText}
diff --git a/src/components/Input.tsx b/src/components/Input.tsx index 142bb7aa..0845676a 100644 --- a/src/components/Input.tsx +++ b/src/components/Input.tsx @@ -5,6 +5,7 @@ import { cva, VariantProps } from "class-variance-authority"; import { AlertCircle, Eye, EyeOff } from "lucide-react"; import * as React from "react"; import { useState } from "react"; +import { useI18n } from "@/i18n/I18nProvider"; type InputVariants = VariantProps; @@ -68,6 +69,7 @@ const Input = React.forwardRef( }, ref, ) => { + const { t } = useI18n(); const [showPassword, setShowPassword] = useState(false); const isPasswordType = type === "password"; const inputType = isPasswordType && showPassword ? "text" : type; @@ -78,7 +80,7 @@ const Input = React.forwardRef( type="button" onClick={() => setShowPassword(!showPassword)} className={"hover:text-white transition-all"} - aria-label={"Toggle password visibility"} + aria-label={t("input.togglePasswordVisibility")} > {showPassword ? : } diff --git a/src/components/JSONFileUpload.tsx b/src/components/JSONFileUpload.tsx index 665cb040..ba8159e8 100644 --- a/src/components/JSONFileUpload.tsx +++ b/src/components/JSONFileUpload.tsx @@ -3,6 +3,7 @@ import { cn } from "@utils/helpers"; import { FileJson2 } from "lucide-react"; import * as React from "react"; import { useState } from "react"; +import { useI18n } from "@/i18n/I18nProvider"; type Props = { value: string; @@ -10,6 +11,7 @@ type Props = { }; export const JSONFileUpload = ({ onChange }: Props) => { + const { t } = useI18n(); const [dragActive, setDragActive] = React.useState(false); const [, setFileName] = useState(""); @@ -19,8 +21,8 @@ export const JSONFileUpload = ({ onChange }: Props) => { // check if file is json if (files[0].type !== "application/json") { notify({ - title: "You uploaded the wrong file type", - description: "Please upload a JSON file", + title: t("jsonFileUpload.wrongFileType"), + description: t("jsonFileUpload.uploadJsonFile"), icon: , backgroundColor: "bg-red-500", }); @@ -35,8 +37,8 @@ export const JSONFileUpload = ({ onChange }: Props) => { if (e.target === null) return; onChange(e.target.result as string); notify({ - title: "Google Workspace", - description: "You successfully uploaded your service account key", + title: t("jsonFileUpload.googleWorkspace"), + description: t("jsonFileUpload.successDescription"), icon: , }); }; @@ -102,7 +104,7 @@ export const JSONFileUpload = ({ onChange }: Props) => {

- Upload your service account key (.json) + {t("jsonFileUpload.uploadLabel")}

{ "underline underline-offset-4 group-hover/upload:text-nb-gray-200 transition-all" } > - Click to upload + {t("jsonFileUpload.clickToUpload")} {" "} - or drag and drop your file here + {t("jsonFileUpload.dragAndDrop")}

diff --git a/src/components/NetworkRouteSelector.tsx b/src/components/NetworkRouteSelector.tsx index 01cfb064..ac24fe41 100644 --- a/src/components/NetworkRouteSelector.tsx +++ b/src/components/NetworkRouteSelector.tsx @@ -12,6 +12,7 @@ import * as React from "react"; import { useEffect, useMemo, useState } from "react"; import NetworkRoutesIcon from "@/assets/icons/NetworkRoutesIcon"; import { useElementSize } from "@/hooks/useElementSize"; +import { useI18n } from "@/i18n/I18nProvider"; import { GroupedRoute, Route } from "@/interfaces/Route"; import useGroupedRoutes from "@/modules/route-group/useGroupedRoutes"; @@ -26,6 +27,7 @@ export function NetworkRouteSelector({ value, disabled = false, }: MultiSelectProps) { + const { t } = useI18n(); const { data: routes } = useFetchApi("/routes"); const groupedRoutes = useGroupedRoutes({ routes }); @@ -127,7 +129,7 @@ export function NetworkRouteSelector({
) : ( - Select an existing network... + {t("networkRouteSelector.selectNetwork")} )}
@@ -165,7 +167,7 @@ export function NetworkRouteSelector({ ref={searchRef} value={search} onValueChange={setSearch} - placeholder={"Search for network by name or cidr..."} + placeholder={t("networkRouteSelector.searchPlaceholder")} />
- {"Seems like you don't have any network routes created yet."} + {t("networkRouteSelector.noRoutes")}
)} {notFound && (
- There are no networks matching your search. + {t("networkRouteSelector.noMatchingNetworks")}
)} diff --git a/src/components/NoPeersGettingStarted.tsx b/src/components/NoPeersGettingStarted.tsx index dfb7360e..f1d200bd 100644 --- a/src/components/NoPeersGettingStarted.tsx +++ b/src/components/NoPeersGettingStarted.tsx @@ -4,6 +4,7 @@ import AddPeerButton from "@components/ui/AddPeerButton"; import GetStartedTest from "@components/ui/GetStartedTest"; import { ExternalLinkIcon } from "lucide-react"; import * as React from "react"; +import { useI18n } from "@/i18n/I18nProvider"; import PeerIcon from "@/assets/icons/PeerIcon"; type Props = { @@ -11,6 +12,7 @@ type Props = { }; export const NoPeersGettingStarted = ({ showBackground = true }) => { + const { t } = useI18n(); return ( { size={"large"} /> } - title={"Get Started with NetBird"} - description={ - "It looks like you don't have any connected machines.\n" + - "Get started by adding one to your network." - } + title={t("noPeersGettingStarted.title")} + description={t("noPeersGettingStarted.description")} button={} learnMore={ <> - Learn more in our{" "} + {t("noPeersGettingStarted.learnMorePrefix")}{" "} - Getting Started Guide + {t("noPeersGettingStarted.gettingStartedGuide")} + {t("noPeersGettingStarted.learnMoreSuffix")} } /> diff --git a/src/components/Notification.tsx b/src/components/Notification.tsx index 045d5117..64c0d61c 100644 --- a/src/components/Notification.tsx +++ b/src/components/Notification.tsx @@ -1,3 +1,5 @@ +"use client"; + import { IconCircleX } from "@tabler/icons-react"; import type { ErrorResponse } from "@utils/api"; import { cn } from "@utils/helpers"; @@ -7,6 +9,7 @@ import { CheckIcon, Loader2, XIcon } from "lucide-react"; import * as React from "react"; import { useCallback, useEffect, useRef, useState } from "react"; import { toast } from "sonner"; +import { useI18n } from "@/i18n/I18nProvider"; export interface NotifyProps { title: string; @@ -43,6 +46,7 @@ export default function Notification({ const [error, setError] = useState(""); const [loading, setLoading] = useState(!!promise); const [readyToDismiss, setReadyToDismiss] = useState(!promise); + const { t } = useI18n(); const timerRef = useRef | null>(null); const remainingRef = useRef(duration); @@ -122,7 +126,7 @@ export default function Notification({ }) .catch((e) => { const err = e as ErrorResponse; - let message = err.message || "Something went wrong..."; + let message = err.message || t("notification.genericError"); message = message.charAt(0).toUpperCase() + message.slice(1); const code: number = err.code || 418; diff --git a/src/components/PeerGroupSelector.tsx b/src/components/PeerGroupSelector.tsx index c8248352..9b8b237f 100644 --- a/src/components/PeerGroupSelector.tsx +++ b/src/components/PeerGroupSelector.tsx @@ -36,6 +36,7 @@ import { import * as React from "react"; import { Fragment, useEffect, useMemo, useState } from "react"; import Skeleton from "react-loading-skeleton"; +import { useI18n } from "@/i18n/I18nProvider"; import { useGroups } from "@/contexts/GroupsProvider"; import { useElementSize } from "@/hooks/useElementSize"; import type { Group, GroupPeer, GroupResource } from "@/interfaces/Group"; @@ -111,12 +112,12 @@ export function PeerGroupSelector({ closeOnSelect = false, resource, onResourceChange, - placeholder = "Add or select group(s)...", + placeholder, customTrigger, align = "start", side = "bottom", users, - placeholderForSearch = 'Search groups or add new group by pressing "Enter"...', + placeholderForSearch, resourceIds, additionalResources, policies, @@ -139,6 +140,12 @@ export function PeerGroupSelector({ const { groups, dropdownOptions, setDropdownOptions, addDropdownOptions } = useGroups(); + const { t } = useI18n(); + const resolvedPlaceholder = + placeholder ?? t("peerGroupSelector.addOrSelectGroups"); + const resolvedSearchPlaceholder = + placeholderForSearch ?? t("peerGroupSelector.searchGroups"); + const searchRef = React.useRef(null); const [inputRef, { width }] = useElementSize< @@ -288,11 +295,11 @@ export function PeerGroupSelector({ }, [tab]); const searchPlaceholder = useMemo(() => { - if (tab === "groups") return placeholderForSearch; - if (tab === "resources") return "Search resource..."; - if (tab === "peers") return "Search peer..."; - return "Search..."; - }, [tab, placeholderForSearch]); + if (tab === "groups") return resolvedSearchPlaceholder; + if (tab === "resources") return t("peerGroupSelector.searchResource"); + if (tab === "peers") return t("peerGroupSelector.searchPeer"); + return t("peerGroupSelector.search"); + }, [resolvedSearchPlaceholder, tab, t]); const selectResource = (resource?: NetworkResource) => { onResourceChange?.( @@ -419,8 +426,12 @@ export function PeerGroupSelector({ })} {values.length == 0 && !resource && ( - - {placeholder} + + {resolvedPlaceholder} )}
@@ -525,10 +536,11 @@ export function PeerGroupSelector({
- Add this group by pressing{" "} + {t("peerGroupSelector.addGroupByPressingPrefix")}{" "} - {"'Enter'"} + {t("peerGroupSelector.enterKey")} + {t("peerGroupSelector.addGroupByPressingSuffix")}
)} @@ -552,8 +564,7 @@ export function PeerGroupSelector({ - This group is already part of the routing peer and - can not be used for the access control groups. + {t("peerGroupSelector.routingPeerGroupDisabled")}
} disabled={!isDisabled} @@ -670,6 +681,7 @@ const TabTriggers = ({ hideGroupsTab?: boolean; tabOrder?: ("groups" | "peers" | "resources")[]; }) => { + const { t } = useI18n(); const tabCount = (!hideGroupsTab ? 1 : 0) + (showResources ? 1 : 0) + (showPeers ? 1 : 0); if (tabCount <= 1) return null; @@ -687,7 +699,7 @@ const TabTriggers = ({ } size={14} /> - Groups + {t("groups.title")} ); @@ -704,7 +716,7 @@ const TabTriggers = ({ } size={14} /> - Resources + {t("networkResources.linkLabel")} ); @@ -721,7 +733,7 @@ const TabTriggers = ({ } size={14} /> - Peers + {t("peers.title")} ); @@ -757,6 +769,7 @@ const UsersCounter = ({ users: User[]; selected: boolean; }) => { + const { t } = useI18n(); const usersOfGroup = users?.filter((user) => user.auto_groups.includes(group.id as string)) || []; @@ -766,7 +779,7 @@ const UsersCounter = ({ - 0 User(s) + {t("peerGroupSelector.zeroUsers")} ); @@ -790,6 +803,7 @@ const PeerCounter = ({ group: Group; showResourceCounter?: boolean; }) => { + const { t } = useI18n(); const peerCount = group.peers?.length ?? group?.peers_count ?? 0; const resourcesCount = group?.resources_count ?? 0; const hidePeerCounter = @@ -803,12 +817,13 @@ const PeerCounter = ({ )} > - {peerCount} Peer(s) + {peerCount} {t("groups.count.peers")}
); }; const ResourcesCounter = ({ group }: { group: Group }) => { + const { t } = useI18n(); return group?.resources_count && group.resources_count > 0 ? (
{ } > - {group.resources_count} Resource(s) + {t("peerGroupSelector.resourceCount", { count: group.resources_count })}
) : null; }; @@ -842,6 +857,8 @@ const PolicyCounter = ({ if (count === 0) return null; + const { t } = useI18n(); + return (
- {count} {count === 1 ? "Policy" : "Policies"} + {count} {count === 1 ? t("groups.count.policy") : t("groups.count.policies")}
); }; @@ -873,6 +890,7 @@ const ResourcesList = ({ value?: PolicyRuleResource; onChange: (resource: NetworkResource) => void; }) => { + const { t } = useI18n(); const [filteredItems, _, setSearch] = useSearch( resources || [], resourcesSearchPredicate, @@ -897,8 +915,7 @@ const ResourcesList = ({ if (search != "" && filteredItems.length == 0) { return ( - There are no resources matching your search. Please try a different - search term. + {t("peerGroupSelector.noMatchingResources")} ); } @@ -906,9 +923,8 @@ const ResourcesList = ({ if (search == "" && filteredItems.length == 0) { return ( - There are no resources available yet.
- Go to Networks to add some - resources. + {t("peerGroupSelector.noResourcesAvailable")}
+ {t("peerGroupSelector.toAddResourcesPrefix")} {t("peerGroupSelector.goToNetworksToAdd")} {t("peerGroupSelector.toAddResourcesSuffix")}
); } @@ -984,6 +1000,7 @@ const PeersList = ({ value?: PolicyRuleResource; onChange: (peer: Peer) => void; }) => { + const { t } = useI18n(); const [filteredItems, _, setSearch] = useSearch( peers || [], peersSearchPredicate, @@ -1008,8 +1025,7 @@ const PeersList = ({ if (search != "" && filteredItems.length == 0) { return ( - There are no peers matching your search. Please try a different search - term. + {t("peerGroupSelector.noMatchingPeers")} ); } @@ -1017,8 +1033,8 @@ const PeersList = ({ if (search == "" && filteredItems.length == 0) { return ( - There are no peers available yet.
- Go to Peers to add some peers. + {t("peerGroupSelector.noPeersAvailable")}
+ {t("peerGroupSelector.toAddPeersPrefix")} {t("peerGroupSelector.goToPeersToAdd")} {t("peerGroupSelector.toAddPeersSuffix")}
); } diff --git a/src/components/PeerSelector.tsx b/src/components/PeerSelector.tsx index df7101b3..84aa0995 100644 --- a/src/components/PeerSelector.tsx +++ b/src/components/PeerSelector.tsx @@ -13,6 +13,7 @@ import { ArrowUpCircleIcon, ChevronsUpDown, MapPin } from "lucide-react"; import * as React from "react"; import { memo, useEffect, useState } from "react"; import { useElementSize } from "@/hooks/useElementSize"; +import { useI18n } from "@/i18n/I18nProvider"; import { Peer } from "@/interfaces/Peer"; import { PeerOperatingSystemIcon } from "@/modules/peers/PeerOperatingSystemIcon"; @@ -39,6 +40,7 @@ export function PeerSelector({ excludedPeers, disabled = false, }: MultiSelectProps) { + const { t } = useI18n(); const { data: peers } = useFetchApi("/peers"); const [inputRef, { width }] = useElementSize(); @@ -129,7 +131,7 @@ export function PeerSelector({ ) : ( - Select a peer... + {t("peerSelector.selectPeer")} )} @@ -150,20 +152,20 @@ export function PeerSelector({ {unfilteredItems.length == 0 && !search && (
- {"No peers available to select."} + {t("peerSelector.noPeersAvailable")}
)} {filteredItems.length == 0 && search != "" && ( - There are no peers matching your search. + {t("peerSelector.noMatchingPeers")} )} @@ -193,9 +195,7 @@ export function PeerSelector({ className={"w-full flex items-center justify-between"} content={
- Please update NetBird to at least{" "} - v0.36.6 or later - to use this peer as a routing peer. + {t("peerSelector.updateNetBirdTooltip")}
} > diff --git a/src/components/PortSelector.tsx b/src/components/PortSelector.tsx index 7d176a95..9db09ec9 100644 --- a/src/components/PortSelector.tsx +++ b/src/components/PortSelector.tsx @@ -12,6 +12,7 @@ import { ChevronsUpDown, SearchIcon, XIcon } from "lucide-react"; import * as React from "react"; import { useEffect, useMemo, useState } from "react"; import { useElementSize } from "@/hooks/useElementSize"; +import { useI18n } from "@/i18n/I18nProvider"; import { PortRange } from "@/interfaces/Policy"; interface MultiSelectProps { @@ -52,6 +53,7 @@ export function PortSelector({ popoverWidth = "auto", showAll = false, }: Readonly) { + const { t } = useI18n(); const searchRef = React.useRef(null); const [open, setOpen] = useState(false); const [inputRef, { width }] = useElementSize(); @@ -131,7 +133,7 @@ export function PortSelector({ variant={"gray"} className={"uppercase tracking-wider font-medium py-1"} > - All + {t("portSelector.all")} )} @@ -153,7 +155,7 @@ export function PortSelector({ /> ))} - {ports.length == 0 && Select ports...} + {ports.length == 0 && {t("portSelector.selectPorts")}} @@ -191,9 +193,7 @@ export function PortSelector({ ref={searchRef} value={search} onValueChange={setSearch} - placeholder={ - 'Add a port or a range e.g. 80 or 1-1023 and press "Enter" to add...' - } + placeholder={t("portSelector.placeholder")} />
- { - "Please add a valid port or port range (e.g. 80, 443, 1-1023)" - } + {t("portSelector.invalidPort")}
)} @@ -264,10 +262,11 @@ export function PortSelector({
- Add this port or range by pressing{" "} + {t("portSelector.addByEnter")}{" "} - {"'Enter'"} - + {t("portSelector.enterKey")} + {" "} + {t("portSelector.addThisPort")}
@@ -322,8 +321,9 @@ export function PortSelector({ {portRanges?.length > 0 && ( - Port ranges requires NetBird client{" "} - v0.48 or higher. + {t("portSelector.portRangesRequire")}{" "} + v0.48{" "} + {t("portSelector.orHigher")} )} diff --git a/src/components/SettingCard.tsx b/src/components/SettingCard.tsx index 242607bb..85fbb51f 100644 --- a/src/components/SettingCard.tsx +++ b/src/components/SettingCard.tsx @@ -7,6 +7,7 @@ import { SmallBadge } from "@components/ui/SmallBadge"; import { cn } from "@utils/helpers"; import { PlusCircle, SquarePen } from "lucide-react"; import React from "react"; +import { useI18n } from "@/i18n/I18nProvider"; type SettingCardItemProps = { label: React.ReactNode; @@ -21,6 +22,7 @@ function SettingCardItem({ enabled, onClick, }: Readonly) { + const { t } = useI18n(); return (
{label} {enabled && ( - Edit + {t("actions.edit")} ) : ( )}
diff --git a/src/components/UserSelector.tsx b/src/components/UserSelector.tsx index 7c762117..18bef4b4 100644 --- a/src/components/UserSelector.tsx +++ b/src/components/UserSelector.tsx @@ -9,6 +9,7 @@ import { ChevronsUpDown, MapPin } from "lucide-react"; import * as React from "react"; import { memo, useState } from "react"; import { useElementSize } from "@/hooks/useElementSize"; +import { useI18n } from "@/i18n/I18nProvider"; import { User } from "@/interfaces/User"; import { SmallUserAvatar } from "@/modules/users/SmallUserAvatar"; @@ -39,8 +40,10 @@ export function UserSelector({ value, disabled = false, options = [], - placeholder = "Select a user...", + placeholder, }: MultiSelectProps) { + const { t } = useI18n(); + const resolvedPlaceholder = placeholder ?? t("userSelector.selectUser"); const [inputRef, { width }] = useElementSize(); const [filteredItems, search, setSearch] = useSearch( @@ -97,7 +100,7 @@ export function UserSelector({ variant={"selected"} /> ) : ( - {placeholder} + {resolvedPlaceholder} )} @@ -119,22 +122,20 @@ export function UserSelector({ value={search} onChange={setSearch} hideEnterIcon={true} - placeholder={"Search for users by name or email..."} + placeholder={t("userSelector.searchPlaceholder")} /> {options.length == 0 && !search && (
- { - "There are no users to select. Invite some users for this tenant before unlinking." - } + {t("userSelector.noUsers")}
)} {filteredItems.length == 0 && search != "" && ( - There are no users matching your search. + {t("userSelector.noMatchingUsers")} )} @@ -170,6 +171,7 @@ export const UserListItem = ({ className, variant, }: UserListItemProps) => { + const { t } = useI18n(); const isSystemUser = user?.email === "NetBird" || user?.email === ""; const maxChars = variant === "selected" ? 30 : 20; @@ -197,7 +199,7 @@ export const UserListItem = ({ )} > diff --git a/src/components/VersionInfo.tsx b/src/components/VersionInfo.tsx index 17d5a26c..9dd8dbd3 100644 --- a/src/components/VersionInfo.tsx +++ b/src/components/VersionInfo.tsx @@ -2,50 +2,20 @@ import FullTooltip from "@components/FullTooltip"; import { cn } from "@utils/helpers"; -import { ArrowUpCircle } from "lucide-react"; import * as React from "react"; -import Skeleton from "react-loading-skeleton"; -import useFetchApi from "@utils/api"; import { isNetBirdHosted } from "@utils/netbird"; import { useApplicationContext } from "@/contexts/ApplicationProvider"; -import { VersionInfo as VersionInfoType } from "@/interfaces/Instance"; +import { useI18n } from "@/i18n/I18nProvider"; function formatVersion(version: string): string { if (!version) return ""; - // Add "v" prefix if version starts with a number if (/^\d/.test(version)) return `v${version}`; return version; } -function compareVersions(current: string, latest: string): boolean { - // Returns true if latest is newer than current - if (!current || !latest) return false; - if (current === "development") return false; - - // Strip "v" prefix if present - const normalizedCurrent = current.replace(/^v/, ""); - const normalizedLatest = latest.replace(/^v/, ""); - - const currentParts = normalizedCurrent - .split(".") - .map((p) => parseInt(p, 10) || 0); - const latestParts = normalizedLatest - .split(".") - .map((p) => parseInt(p, 10) || 0); - - for (let i = 0; i < Math.max(currentParts.length, latestParts.length); i++) { - const c = currentParts[i] || 0; - const l = latestParts[i] || 0; - if (l > c) return true; - if (l < c) return false; - } - return false; -} - export const NavigationVersionInfo = () => { const { isNavigationCollapsed, mobileNavOpen } = useApplicationContext(); - // Only show for self-hosted, not cloud if (isNetBirdHosted()) return null; return ( @@ -63,31 +33,11 @@ export const NavigationVersionInfo = () => { }; const NavigationVersionInfoContent = () => { - const { data: versionInfo, isLoading } = useFetchApi( - "/instance/version", - true, // ignore errors - false, // don't revalidate on focus - ); + const { t } = useI18n(); const dashboardVersion = process.env.NEXT_PUBLIC_DASHBOARD_VERSION || "development"; - if (isLoading) - return ; - - if (!versionInfo) return null; - - // Compare versions to detect updates (returns false for "development" versions) - const managementUpdateAvailable = compareVersions( - versionInfo.management_current_version, - versionInfo.management_available_version, - ); - const dashboardUpdateAvailable = compareVersions( - dashboardVersion, - versionInfo.dashboard_available_version, - ); - const hasUpdate = managementUpdateAvailable || dashboardUpdateAvailable; - return (
{ - Latest: {formatVersion(versionInfo.management_available_version)} + {t("versionInfo.dashboard")} } side="top" className="w-full" >
- Management - - {formatVersion(versionInfo.management_current_version)} - -
-
- - Latest: {formatVersion(versionInfo.dashboard_available_version)} - - } - side="top" - className="w-full" - > -
- Dashboard + {t("versionInfo.dashboard")} {formatVersion(dashboardVersion)}
- - {hasUpdate && ( - - - Update available - - )} ); }; -export default NavigationVersionInfo; \ No newline at end of file +export default NavigationVersionInfo; diff --git a/src/components/modal/Modal.tsx b/src/components/modal/Modal.tsx index a5c36b3c..cf44bab2 100644 --- a/src/components/modal/Modal.tsx +++ b/src/components/modal/Modal.tsx @@ -6,6 +6,7 @@ import { VisuallyHidden } from "@radix-ui/react-visually-hidden"; import { cn } from "@utils/helpers"; import { X } from "lucide-react"; import * as React from "react"; +import { useI18n } from "@/i18n/I18nProvider"; import { headerHeight } from "@/layouts/Header"; const Modal = DialogPrimitive.Root; @@ -64,7 +65,9 @@ const ModalContent = React.forwardRef< ...props }, ref, - ) => ( + ) => { + const { t } = useI18n(); + return ( - Close + {t('common.close')} )} - ), + ); + }, ); ModalContent.displayName = DialogPrimitive.Content.displayName; @@ -125,6 +129,7 @@ const SidebarModalContent = React.forwardRef< }, ref, ) => { + const { t } = useI18n(); return (
- Close + {t('common.close')} )} diff --git a/src/components/select/SelectDropdown.tsx b/src/components/select/SelectDropdown.tsx index 3bf2e025..c8ce4e79 100644 --- a/src/components/select/SelectDropdown.tsx +++ b/src/components/select/SelectDropdown.tsx @@ -15,6 +15,7 @@ import { useEffect, useRef, useState } from "react"; import Skeleton from "react-loading-skeleton"; import { useElementSize } from "@/hooks/useElementSize"; import { DropdownInfoText } from "@components/DropdownInfoText"; +import { useI18n } from "@/i18n/I18nProvider"; export interface SelectOption { label: string | React.ReactNode; @@ -62,8 +63,8 @@ export function SelectDropdown({ options, showSearch = false, showValues = false, - placeholder = "Select...", - searchPlaceholder = "Search...", + placeholder, + searchPlaceholder, isLoading = false, variant = "input", className, @@ -75,6 +76,9 @@ export function SelectDropdown({ truncate = false, compact = false, }: Readonly) { + const { t } = useI18n(); + const resolvedPlaceholder = placeholder ?? t("selectDropdown.placeholder"); + const resolvedSearchPlaceholder = searchPlaceholder ?? t("selectDropdown.searchPlaceholder"); const [inputRef, { width }] = useElementSize(); const toggle = (selectedValue: string) => { @@ -139,7 +143,7 @@ export function SelectDropdown({ size === "xs" && "text-xs", )} > - {placeholder} + {resolvedPlaceholder}
); @@ -212,14 +216,13 @@ export function SelectDropdown({ search={search} setSearch={setSearch} ref={searchRef} - placeholder={searchPlaceholder} + placeholder={resolvedSearchPlaceholder} /> )} {filteredItems.length == 0 && ( - There are no results matching your search. Please try a - different search term. + {t("selectDropdown.noResults")} )} diff --git a/src/components/select/SelectDropdownSearchInput.tsx b/src/components/select/SelectDropdownSearchInput.tsx index d49b266c..af24c229 100644 --- a/src/components/select/SelectDropdownSearchInput.tsx +++ b/src/components/select/SelectDropdownSearchInput.tsx @@ -2,6 +2,7 @@ import { cn } from "@utils/helpers"; import { SearchIcon } from "lucide-react"; import * as React from "react"; import { Dispatch, forwardRef } from "react"; +import { useI18n } from "@/i18n/I18nProvider"; type Props = { search: string; @@ -14,10 +15,12 @@ export const SelectDropdownSearchInput = forwardRef( { search, setSearch, - placeholder = "Search for peers by name or ip...", + placeholder, }: Props, ref, ) => { + const { t } = useI18n(); + const resolvedPlaceholder = placeholder ?? t("selectDropdown.searchPlaceholder"); return (
( ref={ref} value={search} onChange={(e) => setSearch(e.target.value)} - placeholder={placeholder} + placeholder={resolvedPlaceholder} />
diff --git a/src/components/table/DataTable.tsx b/src/components/table/DataTable.tsx index 990ad300..33085a86 100644 --- a/src/components/table/DataTable.tsx +++ b/src/components/table/DataTable.tsx @@ -40,6 +40,7 @@ import { usePathname } from "next/navigation"; import * as React from "react"; import { useEffect, useRef, useState } from "react"; import { useLocalStorage } from "@/hooks/useLocalStorage"; +import { useI18n } from "@/i18n/I18nProvider"; declare module "@tanstack/table-core" { interface FilterFns { @@ -193,7 +194,7 @@ export function DataTable({ columns, data, children, - searchPlaceholder = "Search...", + searchPlaceholder, columnVisibility = {}, setColumnVisibility, sorting = [], @@ -249,6 +250,7 @@ export function DataTable({ initialSearch, onSearchClick, }: Readonly>) { + const { t } = useI18n(); const path = usePathname(); const isInitialRender = useRef(true); @@ -461,7 +463,7 @@ export function DataTable({ } resetRowSelectionOnSearch && setRowSelection?.({}); }} - placeholder={searchPlaceholder} + placeholder={searchPlaceholder ?? t("dataTable.searchPlaceholder")} /> {children?.(table)} {showResetFilterButton && ( diff --git a/src/components/table/DataTableFilter.tsx b/src/components/table/DataTableFilter.tsx index 856f4353..063b68e9 100644 --- a/src/components/table/DataTableFilter.tsx +++ b/src/components/table/DataTableFilter.tsx @@ -10,6 +10,7 @@ import { concat, sortBy, uniqBy } from "lodash"; import { FilterIcon } from "lucide-react"; import * as React from "react"; import { useCallback, useMemo, useState } from "react"; +import { useI18n } from "@/i18n/I18nProvider"; interface Props { table: Table; @@ -74,6 +75,7 @@ export function DataTableFilter({ filters, disabled = false, }: Readonly>) { + const { t } = useI18n(); const searchRef = React.useRef(null); const [open, setOpen] = useState(false); @@ -191,7 +193,7 @@ export function DataTableFilter({ {activeFiltersCount > 0 && activeFiltersCount} - {activeFiltersCount > 0 ? ` Filter(s)` : "Filter"} + {activeFiltersCount > 0 ? ` ${t("dataTable.filters")}` : t("dataTable.filter")} @@ -210,13 +212,13 @@ export function DataTableFilter({ ref={searchRef} value={search} onChange={setSearch} - placeholder={"Search filters..."} + placeholder={t("dataTable.searchFilters")} hideEnterIcon={true} /> {filteredItems.length == 0 && search != "" && ( - There are no filters matching your search. + {t("dataTable.noMatchingFilters")} )} diff --git a/src/components/table/DataTableGlobalSearch.tsx b/src/components/table/DataTableGlobalSearch.tsx index 71bd1f5c..a0c2f362 100644 --- a/src/components/table/DataTableGlobalSearch.tsx +++ b/src/components/table/DataTableGlobalSearch.tsx @@ -4,6 +4,7 @@ import { useDebounce } from "@hooks/useDebounce"; import { Search } from "lucide-react"; import React, { useEffect, useState } from "react"; import { useHotkeys } from "react-hotkeys-hook"; +import { useI18n } from "@/i18n/I18nProvider"; interface Props extends React.InputHTMLAttributes { setGlobalSearch: (value: string) => void; @@ -21,6 +22,7 @@ export default function DataTableGlobalSearch({ onClick, ...props }: Readonly) { + const { t } = useI18n(); const ref = React.useRef(null); const [inputValue, setInputValue] = useState(globalSearch || ""); const debouncedValue = useDebounce(inputValue, 800); @@ -67,7 +69,7 @@ export default function DataTableGlobalSearch({ value={inputValue} // Shows immediate updates onChange={handleChange} maxWidthClass={className} - customSuffix={⌘ K} + customSuffix={{t('dataTable.searchShortcut')}} disabled={false} /> ); diff --git a/src/components/table/DataTableMultiSelectPopup.tsx b/src/components/table/DataTableMultiSelectPopup.tsx index 9f7210ac..690d3a54 100644 --- a/src/components/table/DataTableMultiSelectPopup.tsx +++ b/src/components/table/DataTableMultiSelectPopup.tsx @@ -5,6 +5,7 @@ import { cn } from "@utils/helpers"; import { AnimatePresence, motion } from "framer-motion"; import { MonitorSmartphoneIcon } from "lucide-react"; import * as React from "react"; +import { useI18n } from "@/i18n/I18nProvider"; type Props = { selectedItems?: T[]; @@ -15,10 +16,11 @@ type Props = { export function DataTableMultiSelectPopup({ onCanceled, - label = "Peer(s) selected", + label, selectedItems, rightSide, }: Props) { + const { t } = useI18n(); const count = selectedItems?.length || 0; return ( @@ -57,15 +59,15 @@ export function DataTableMultiSelectPopup({ - {count} - {" "} - {label} + {count} + {" "} + {label ?? t("selection.peersSelected")}
{rightSide} Cancel} + content={{t("common.cancel")}} > diff --git a/src/components/ui/AccessControlGroupCount.tsx b/src/components/ui/AccessControlGroupCount.tsx index 7d01c009..9399bd71 100644 --- a/src/components/ui/AccessControlGroupCount.tsx +++ b/src/components/ui/AccessControlGroupCount.tsx @@ -6,11 +6,13 @@ import * as React from "react"; import { useMemo } from "react"; import Skeleton from "react-loading-skeleton"; import { Route } from "@/interfaces/Route"; +import { useI18n } from "@/i18n/I18nProvider"; type Props = { group_id: string; }; export const AccessControlGroupCount = ({ group_id }: Props) => { + const { t } = useI18n(); const { data, isLoading } = useFetchApi("/routes"); const routes = useMemo(() => { @@ -60,7 +62,7 @@ export const AccessControlGroupCount = ({ group_id }: Props) => { } > - {routes.length} Route(s) + {routes.length} {t('accessControlGroupCount.routes')}
) : null; diff --git a/src/components/ui/AddGroupButton.tsx b/src/components/ui/AddGroupButton.tsx index 7935cc1e..cbecdcf6 100644 --- a/src/components/ui/AddGroupButton.tsx +++ b/src/components/ui/AddGroupButton.tsx @@ -15,6 +15,7 @@ import { useRouter } from "next/navigation"; import { useState } from "react"; import { useSWRConfig } from "swr"; import { usePermissions } from "@/contexts/PermissionsProvider"; +import { useI18n } from "@/i18n/I18nProvider"; import { Group } from "@/interfaces/Group"; import { useApiCall } from "@/utils/api"; import ModalHeader from "../modal/ModalHeader"; @@ -23,6 +24,7 @@ import Paragraph from "../Paragraph"; import Separator from "../Separator"; export const AddGroupButton = () => { + const { t } = useI18n(); const create = useApiCall("/groups", true).post; const { mutate } = useSWRConfig(); const [name, setName] = useState(""); @@ -32,9 +34,9 @@ export const AddGroupButton = () => { const createGroup = () => { notify({ - title: "Create Group", - description: `Group '${name}' successfully created`, - loadingMessage: "Creating group...", + title: t("groups.createTitle"), + description: t("groups.createdDescription", { name }), + loadingMessage: t("groups.creating"), promise: create({ name }).then((g) => { setOpen(false); setName(""); @@ -54,26 +56,26 @@ export const AddGroupButton = () => { className={"ml-auto h-[42px]"} > - Create Group + {t("groups.createTitle")} } - title="Create Group" - description="Create a group to manage and organize access in your network" + title={t("groups.createTitle")} + description={t("groups.createDescription")} color="netbird" />
- + - Set an easily identifiable name for your group + {t("groups.nameHelp")} setName(e.target.value)} /> @@ -82,19 +84,19 @@ export const AddGroupButton = () => {
- Learn more about + {t("common.learnMorePrefix")}{" "} - Groups + {t("groups.title")}
- +
diff --git a/src/components/ui/AddPeerButton.tsx b/src/components/ui/AddPeerButton.tsx index 1fefdff6..72947d3f 100644 --- a/src/components/ui/AddPeerButton.tsx +++ b/src/components/ui/AddPeerButton.tsx @@ -5,10 +5,12 @@ import useFetchApi from "@utils/api"; import { PlusCircle } from "lucide-react"; import React, { memo, useState } from "react"; import { useLocalStorage } from "@/hooks/useLocalStorage"; +import { useI18n } from "@/i18n/I18nProvider"; import { Peer } from "@/interfaces/Peer"; import SetupModal from "@/modules/setup-netbird-modal/SetupModal"; function AddPeerButton() { + const { t } = useI18n(); const { data: peers } = useFetchApi("/peers"); const { oidcUser: user } = useOidcUser(); @@ -41,7 +43,7 @@ function AddPeerButton() { diff --git a/src/components/ui/AnnouncementBanner.tsx b/src/components/ui/AnnouncementBanner.tsx index de1eb890..82f78467 100644 --- a/src/components/ui/AnnouncementBanner.tsx +++ b/src/components/ui/AnnouncementBanner.tsx @@ -4,6 +4,7 @@ import { cva, VariantProps } from "class-variance-authority"; import { ArrowRightIcon, XIcon } from "lucide-react"; import * as React from "react"; import { useAnnouncement } from "@/contexts/AnnouncementProvider"; +import { useI18n } from "@/i18n/I18nProvider"; const variants = cva( {}, @@ -36,6 +37,7 @@ const variants = cva( export type AnnouncementVariant = VariantProps; export const AnnouncementBanner = () => { + const { t } = useI18n(); const { bannerHeight, closeAnnouncement, announcements } = useAnnouncement(); const announcement = announcements?.find((a) => a.isOpen); @@ -69,7 +71,7 @@ export const AnnouncementBanner = () => { variants({ inlineLink: announcement.variant }), )} > - {announcement.linkText || "Learn more"} + {announcement.linkText || t("common.learnMore")} )} diff --git a/src/components/ui/CitySelector.tsx b/src/components/ui/CitySelector.tsx index 21f46164..c8671279 100644 --- a/src/components/ui/CitySelector.tsx +++ b/src/components/ui/CitySelector.tsx @@ -6,6 +6,7 @@ import useFetchApi from "@utils/api"; import { MapPin } from "lucide-react"; import { createElement, useMemo } from "react"; import { City } from "@/interfaces/City"; +import { useI18n } from "@/i18n/I18nProvider"; type Props = { value: string; @@ -13,6 +14,7 @@ type Props = { country: string; }; export const CitySelector = ({ value, onChange, country = "de" }: Props) => { + const { t } = useI18n(); const { data: cities, isLoading } = useFetchApi( `/locations/countries/${country}/cities`, ); @@ -36,7 +38,7 @@ export const CitySelector = ({ value, onChange, country = "de" }: Props) => { } as SelectOption; }) as SelectOption[]; - all.unshift({ label: "All Locations", value: "", icon: pinIcon }); + all.unshift({ label: t("citySelector.allLocations"), value: "", icon: pinIcon }); return all; }, [cities]); @@ -45,8 +47,8 @@ export const CitySelector = ({ value, onChange, country = "de" }: Props) => { { + const { t } = useI18n(); const { countries, isLoading } = useCountries(); const countryList = useMemo(() => { @@ -41,8 +43,8 @@ export const CountrySelector = ({ value, onChange, iconSize = 20, popoverWidth, { setMounted(true); @@ -42,14 +44,14 @@ export default function DarkModeToggle() { disabled={true} > - Light + {t('darkModeToggle.light')} setTheme("dark")} className={"flex gap-2"} > - Dark + {t('darkModeToggle.dark')} - System + {t('darkModeToggle.system')} diff --git a/src/components/ui/HelpAndSupportButton.tsx b/src/components/ui/HelpAndSupportButton.tsx index f6426ee2..f984a214 100644 --- a/src/components/ui/HelpAndSupportButton.tsx +++ b/src/components/ui/HelpAndSupportButton.tsx @@ -22,10 +22,12 @@ import { useState } from "react"; import Button from "@components/Button"; import { cn } from "@utils/helpers"; import SlackIcon from "@/assets/icons/SlackIcon"; +import { useI18n } from "@/i18n/I18nProvider"; import { isNetBirdHosted } from "@utils/netbird"; export default function HelpAndSupportButton() { const [dropdownOpen, setDropdownOpen] = useState(false); + const { t } = useI18n(); return (
- Help and Support + {t("help.title")}
@@ -62,7 +64,7 @@ export default function HelpAndSupportButton() { >
- Documentation + {t("help.documentation")}
@@ -76,7 +78,7 @@ export default function HelpAndSupportButton() { >
- Troubleshooting + {t("help.troubleshooting")}
@@ -102,7 +104,7 @@ export default function HelpAndSupportButton() { >
- NetBird Forum + {t("help.forum")}
@@ -116,7 +118,7 @@ export default function HelpAndSupportButton() { >
- NetBird Slack + {t("help.slack")}
@@ -133,7 +135,7 @@ export default function HelpAndSupportButton() { >
- Feedback + {t("help.feedback")}
diff --git a/src/components/ui/InputDomain.tsx b/src/components/ui/InputDomain.tsx index 3f646932..1e68e247 100644 --- a/src/components/ui/InputDomain.tsx +++ b/src/components/ui/InputDomain.tsx @@ -6,6 +6,7 @@ import { GlobeIcon, MinusCircleIcon } from "lucide-react"; import * as React from "react"; import { useEffect, useMemo, useState } from "react"; import { Domain } from "@/interfaces/Domain"; +import { useI18n } from "@/i18n/I18nProvider"; type Props = { value: Domain; @@ -47,6 +48,7 @@ export default function InputDomain({ allowWildcard = true, showRemoveButton = true, }: Readonly) { + const { t } = useI18n(); const [name, setName] = useState(value?.name || ""); const handleNameChange = (e: React.ChangeEvent) => { @@ -64,7 +66,7 @@ export default function InputDomain({ preventLeadingAndTrailingDots, }); if (!valid) { - return "Please enter a valid domain, e.g. example.com or intra.example.com"; + return t("inputDomain.error"); } }, [name]); @@ -80,7 +82,7 @@ export default function InputDomain({
} - placeholder={"e.g., example.com"} + placeholder={t("inputDomain.placeholder")} maxWidthClass={"w-full"} data-cy={"domain-input"} value={name} diff --git a/src/components/ui/InstallNetBirdButton.tsx b/src/components/ui/InstallNetBirdButton.tsx index 6cf366e9..4a649221 100644 --- a/src/components/ui/InstallNetBirdButton.tsx +++ b/src/components/ui/InstallNetBirdButton.tsx @@ -2,9 +2,11 @@ import Button from "@components/Button"; import { Modal, ModalTrigger } from "@components/modal/Modal"; import { DownloadIcon } from "lucide-react"; import React, { useState } from "react"; +import { useI18n } from "@/i18n/I18nProvider"; import SetupModal from "@/modules/setup-netbird-modal/SetupModal"; export function InstallNetBirdButton() { + const { t } = useI18n(); const [installModal, setInstallModal] = useState(false); return ( @@ -12,7 +14,7 @@ export function InstallNetBirdButton() { diff --git a/src/components/ui/MultipleGroups.tsx b/src/components/ui/MultipleGroups.tsx index 5083248b..4edf530e 100644 --- a/src/components/ui/MultipleGroups.tsx +++ b/src/components/ui/MultipleGroups.tsx @@ -16,6 +16,7 @@ import { useUsers } from "@/contexts/UsersProvider"; import { Group } from "@/interfaces/Group"; import EmptyRow from "@/modules/common-table-rows/EmptyRow"; import { HorizontalUsersStack } from "@/modules/users/HorizontalUsersStack"; +import { useI18n } from "@/i18n/I18nProvider"; type Props = { groups: Group[]; @@ -31,8 +32,8 @@ type Props = { export default function MultipleGroups({ groups, - label = "Assigned Groups", - description = "Use groups to control what this peer can access", + label, + description, onClick, className, showResources = false, @@ -40,7 +41,11 @@ export default function MultipleGroups({ redirectGroupTab, disableRedirect = false, }: Readonly) { + const { t } = useI18n(); const { permission } = usePermissions(); + + const defaultLabel = label || t("groups.assignedGroups"); + const defaultDescription = description || t("groups.useGroupsToControlAccess"); if (!groups || groups?.length === 0) return ; const orderedGroups = groups.sort((a, b) => { @@ -92,7 +97,7 @@ export default function MultipleGroups({ onClick={(e) => e.stopPropagation()} >
- {label} + {defaultLabel}
-

{title}

+

{title ?? t("noResults.title")}

- {description} + {description ?? t("noResults.description")} {hasFiltersApplied && onResetFilters && ( )} {children} diff --git a/src/components/ui/NoResultsCard.tsx b/src/components/ui/NoResultsCard.tsx index 338f731e..bd1055b2 100644 --- a/src/components/ui/NoResultsCard.tsx +++ b/src/components/ui/NoResultsCard.tsx @@ -4,6 +4,7 @@ import { cn } from "@utils/helpers"; import { FilterX } from "lucide-react"; import React from "react"; import Skeleton from "react-loading-skeleton"; +import { useI18n } from "@/i18n/I18nProvider"; type Props = { icon?: React.ReactNode; @@ -15,11 +16,12 @@ type Props = { export default function NoResultsCard({ icon, - title = "Could not find any results", - description = "We couldn't find any results. Please try a different search term or change your filters.", + title, + description, children, className, }: Readonly) { + const { t } = useI18n(); return (
@@ -50,9 +52,9 @@ export default function NoResultsCard({ {icon || }
-

{title}

+

{title ?? t("noResults.title")}

- {description} + {description ?? t("noResults.description")} {children}
diff --git a/src/components/ui/PageNotFound.tsx b/src/components/ui/PageNotFound.tsx index b641cbab..a22c6b2b 100644 --- a/src/components/ui/PageNotFound.tsx +++ b/src/components/ui/PageNotFound.tsx @@ -7,16 +7,18 @@ import { useRouter } from "next/navigation"; import * as React from "react"; import Skeleton from "react-loading-skeleton"; import PageContainer from "@/layouts/PageContainer"; +import { useI18n } from "@/i18n/I18nProvider"; type Props = { title?: string; description?: string; }; export const PageNotFound = ({ - title = "The requested page was not found", - description = "The page you are attempting to access cannot be found. Please verify the URL or return to the dashboard to continue browsing.", + title, + description, }: Props) => { const router = useRouter(); + const { t } = useI18n(); return ( @@ -66,10 +68,10 @@ export const PageNotFound = ({ "text-3xl font-medium mx-auto mt-3 capitalize" } > - {title} + {title ?? t("pageNotFound.title")} - {description} + {description ?? t("pageNotFound.description")}
diff --git a/src/components/ui/PeerCountBadge.tsx b/src/components/ui/PeerCountBadge.tsx index 0864852a..d4a50db0 100644 --- a/src/components/ui/PeerCountBadge.tsx +++ b/src/components/ui/PeerCountBadge.tsx @@ -1,9 +1,10 @@ import Badge, { BadgeVariants } from "@components/Badge"; -import { cn, singularize } from "@utils/helpers"; +import { cn } from "@utils/helpers"; import { MonitorSmartphoneIcon } from "lucide-react"; import { useRouter } from "next/navigation"; import * as React from "react"; import { useMemo } from "react"; +import { useI18n } from "@/i18n/I18nProvider"; import { useGroups } from "@/contexts/GroupsProvider"; import { Group } from "@/interfaces/Group"; import ResourceCountBadge from "@components/ui/ResourceCountBadge"; @@ -21,6 +22,7 @@ export default function PeerCountBadge({ disableRedirect = false, }: Props) { const router = useRouter(); + const { t } = useI18n(); const { dropdownOptions, groups } = useGroups(); const currentGroup = useMemo(() => { @@ -62,7 +64,7 @@ export default function PeerCountBadge({ useHover={canRedirect} > - {singularize("Peers", peerCount, true)} + {peerCount} {t("groups.count.peers")} ); } diff --git a/src/components/ui/RestrictedAccess.tsx b/src/components/ui/RestrictedAccess.tsx index 50cec24d..04ba862c 100644 --- a/src/components/ui/RestrictedAccess.tsx +++ b/src/components/ui/RestrictedAccess.tsx @@ -1,9 +1,12 @@ +"use client"; + import Card from "@components/Card"; import Paragraph from "@components/Paragraph"; import SquareIcon from "@components/SquareIcon"; import { LockIcon } from "lucide-react"; import * as React from "react"; import Skeleton from "react-loading-skeleton"; +import { useI18n } from "@/i18n/I18nProvider"; type Props = { children?: React.ReactNode; @@ -14,8 +17,11 @@ type Props = { export const RestrictedAccess = ({ children, hasAccess = false, - page = "this page", + page, }: Props) => { + const { t } = useI18n(); + const currentPage = page ?? t("common.thisPage"); + if (hasAccess) return children; return ( @@ -61,12 +67,10 @@ export const RestrictedAccess = ({

- {"You don't have access to"}
{page} + {t("restricted.titlePrefix")}
{currentPage}

- { - "Seems like you don't have access to this page. Only users with proper permissions can visit this page. Please contact your network administrator for further information." - } + {t("restricted.description")}
diff --git a/src/components/ui/UserDropdown.tsx b/src/components/ui/UserDropdown.tsx index 6f1c943b..865cbb65 100644 --- a/src/components/ui/UserDropdown.tsx +++ b/src/components/ui/UserDropdown.tsx @@ -7,11 +7,15 @@ import { DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuShortcut, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, DropdownMenuTrigger, } from "@components/DropdownMenu"; +import { Modal } from "@components/modal/Modal"; import TextWithTooltip from "@components/ui/TextWithTooltip"; import { UserAvatar } from "@components/ui/UserAvatar"; -import { KeyRound, LogOutIcon, User2 } from "lucide-react"; +import { GlobeIcon, KeyRound, LogOutIcon, User2 } from "lucide-react"; import { useRouter } from "next/navigation"; import { useState } from "react"; import { useHotkeys } from "react-hotkeys-hook"; @@ -19,18 +23,19 @@ import { useApplicationContext } from "@/contexts/ApplicationProvider"; import { usePermissions } from "@/contexts/PermissionsProvider"; import { useLoggedInUser } from "@/contexts/UsersProvider"; import useOSDetection from "@/hooks/useOperatingSystem"; +import { useI18n } from "@/i18n/I18nProvider"; import { ChangePasswordModalContent } from "@/modules/users/ChangePasswordModal"; import { isNetBirdHosted } from "@utils/netbird"; -import { Modal } from "@components/modal/Modal"; export default function UserDropdown() { const [dropdownOpen, setDropdownOpen] = useState(false); const [changePasswordModal, setChangePasswordModal] = useState(false); const { user } = useApplicationContext(); const { loggedInUser, logout } = useLoggedInUser(); - const { isRestricted, permission } = usePermissions(); + const { isRestricted } = usePermissions(); const isMac = useOSDetection(); const router = useRouter(); + const { locale, locales, setLocale, t } = useI18n(); useHotkeys("shift+mod+l", () => logout(), []); @@ -51,43 +56,44 @@ export default function UserDropdown() { open={dropdownOpen} onOpenChange={setDropdownOpen} > - - - - - -
-
- -
-
- + + + + + +
+
+ +
+
+ +
-
- + - + - {!isRestricted && ( - { - setDropdownOpen(false); - if (loggedInUser) { - router.push(`/team/user?id=${loggedInUser.id}`); - } - }} - /> - )} + {!isRestricted && ( + { + setDropdownOpen(false); + if (loggedInUser) { + router.push(`/team/user?id=${loggedInUser.id}`); + } + }} + /> + )} - {!isNetBirdHosted() && loggedInUser?.idp_id === "local" && ( + {!isNetBirdHosted() && loggedInUser?.idp_id === "local" && ( { setDropdownOpen(false); @@ -96,30 +102,69 @@ export default function UserDropdown() { >
- Change Password + {t("user.changePassword")}
)} - -
- - Log out -
- {isMac ? "⇧⌘L" : "⇧ ⊞ L"} -
- - + + +
+ + {t("common.language")} +
+
+ + {locales.map((language) => ( + { + setLocale(language); + setDropdownOpen(false); + }} + > +
+ + {t( + language === "en" + ? "common.language.en" + : "common.language.zh-CN", + )} + + {locale === language && {t("common.selected")}} +
+
+ ))} +
+
+ + +
+ + {t("user.logout")} +
+ + {isMac ? "Shift+Cmd+L" : "Shift+Ctrl+L"} + +
+ + ); } -const ProfileSettingsDropdownItem = ({ onClick }: { onClick: () => void }) => { +const ProfileSettingsDropdownItem = ({ + onClick, + label, +}: { + onClick: () => void; + label: string; +}) => { return (
- Profile Settings + {label}
); diff --git a/src/contexts/AnalyticsProvider.tsx b/src/contexts/AnalyticsProvider.tsx index 8f077c2b..c6cb6547 100644 --- a/src/contexts/AnalyticsProvider.tsx +++ b/src/contexts/AnalyticsProvider.tsx @@ -5,6 +5,7 @@ import Script from "next/script"; import React, { useEffect, useState } from "react"; import ReactGA from "react-ga4"; import { hotjar } from "react-hotjar"; +import { useI18n } from "@/i18n/I18nProvider"; type Props = { children: React.ReactNode; @@ -135,12 +136,13 @@ export const GoogleTagManagerHeadScript = () => { }; const GoogleTageManagerBodyScript = () => { + const { t } = useI18n(); if (!config.googleTagManagerID) return null; return ( isProduction() && (