Compare commits

...

16 Commits

Author SHA1 Message Date
9bb8d2f09a fix(i18n): use users namespace for admin label in service users table
The admin key lives in the users namespace, not common. Remove the type-unsafe tCommon call and its fallback string.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-06-26 15:18:06 +00:00
083b5dbdbe i18n: replace string concatenations with message interpolation
Use ICU message placeholders for router removal, route/network deletion notifications and validation errors to support variable ordering in translations.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-06-26 15:17:38 +00:00
fdcf641f29 i18n: localize posture checks table and action components
Convert hard-coded labels, tooltips, notifications and dialog text in posture-checks components to use the postureChecks namespace.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-06-26 15:17:08 +00:00
b13b7de232 i18n: localize onboarding intent, completion and demo-call screens
Replace hard-coded English in OnboardingIntent, OnboardingEnd and OnboardingDemoCall with useTranslations calls, including rich-text demo-call variants.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-06-26 15:16:39 +00:00
92dc5b26c6 i18n: add translation keys for onboarding, posture checks and routes
Add English and Chinese strings for onboarding demo-call variants, posture-check labels/actions, and route/network interpolation messages.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-06-26 15:16:11 +00:00
1514a48f2b i18n: localize dashboard page metadata titles
Convert static metadata objects to async generateMetadata using getTranslations for six dashboard route groups.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-06-26 15:15:42 +00:00
43a9649e15 i18n: add IntlMessages type augmentation for next-intl
Declare the global IntlMessages interface from the English message catalog so translation keys are type-checked.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-06-26 15:15:14 +00:00
d9b9bb59ac i18n: remove unused navigation and routing modules
Remove dead i18n files (navigation.ts, routing.ts) and clean up the stale JSDoc link in config.ts.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-06-26 15:14:45 +00:00
sakuradairong
7e4e2f0169 fix(i18n): resolve build/type errors, sync main, and address CodeRabbit items 2026-06-24 17:40:30 +08:00
sakuradairong
2ce9f9020d Merge remote-tracking branch 'origin/main' into localize-zh-v2.39.0 2026-06-24 17:21:54 +08:00
2bd2e03fd6 i18n: add untranslated English string baseline
- Extract 485 remaining hardcoded English UI strings across
  app, cloud, components, contexts, hooks, and modules
- Append them to a new 'untranslated' namespace in en.ts as baseline
- Mirror same English values in zh.ts as placeholders to maintain key alignment
- No Chinese translations added yet - to be translated in future pass
2026-06-24 08:57:02 +00:00
5eb928f6bb i18n: localize Routes, DNS, Networks, Access Control modules
- Routes: 12 files internationalized (98 keys), covering route table,
  modal, action cells, and route-group sub-module
- DNS: 14 files internationalized (158 keys), covering nameserver
  and DNS zone management
- Networks: 28 files internationalized (184+ keys), covering network
  resources, routing peers, and provider-level notifications
- Access Control: 7 files internationalized (112 keys), covering
  SSH settings, posture checks, port/protocol filters, and table
- Add natural Chinese translations with consistent terminology
2026-06-24 08:46:05 +00:00
3bb1a61c3f i18n: localize Peers, Activity, Setup Keys modules
- PeerActionCell.tsx: replace ~20 hardcoded strings with t() calls
  (approve, bypass compliance, session expiration, SSH, delete actions)
- Activity module: internationalize 5 files including ActivityDescription.tsx
  with ~104 activity event description templates using t.rich()
- Setup Keys: replace ~18 hardcoded strings in SetupKeyActionCell
  and SetupKeyGroupsCell with translation keys
- Add Chinese translations with natural grammar and consistent terminology
2026-06-24 07:44:15 +00:00
46d20e5877 i18n: localize remaining UI strings in users module
- Add namePlaceholder, emailPlaceholder, userActions keys to users namespace
- Add Chinese translations for the new keys
- Fix remaining hardcoded placeholders in UserInviteModal (John Doe, hello@netbird.io)
- Fix hardcoded placeholder in ServiceUserModal (use serviceUserNamePlaceholder)
- Fix hardcoded aria-label in UserActionCell (use t('userActions'))
2026-06-24 07:00:26 +00:00
a6f46b1a47 i18n: localize critical UI components into Chinese
- Add translations for PageNotFound, NoResultsCard, Dialog close button
- Localize Navigation labels (Integrations, Traffic Events)
- Translate error page states (blocked, pending, access error)
- Localize UserDropdown, LoginExpiredBadge, DarkModeToggle, LocaleSwitcher
- Add missing keys to en.ts and zh.ts
2026-06-23 20:55:34 +00:00
Bethuel Mmbaga
76529fc089 Preserve inactivity expiration on partial peer updates (#676) 2026-06-23 17:30:06 +03:00
146 changed files with 7223 additions and 2127 deletions

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

136
package-lock.json generated
View File

@@ -32,7 +32,7 @@
"@tanstack/react-table": "^8.10.7",
"@types/crypto-js": "^4.2.2",
"@types/d3": "^7.4.3",
"@types/lodash": "^4.14.200",
"@types/lodash": "4.17.24",
"@types/node": "20.10.6",
"@types/react": "^19",
"@types/react-dom": "^19",
@@ -47,6 +47,7 @@
"classnames": "^2.5.1",
"clsx": "^2.0.0",
"cmdk": "^1.1.1",
"cross-env": "^7.0.3",
"crypto-js": "^4.2.0",
"d3": "^7.9.0",
"date-fns": "^2.30.0",
@@ -55,16 +56,18 @@
"eslint-config-prettier": "^9.0.0",
"eslint-plugin-simple-import-sort": "^10.0.0",
"framer-motion": "^12.29.2",
"ip-address": "^10.1.0",
"ip-address": "^10.2.0",
"ip-cidr": "^3.1.0",
"js-cookie": "^3.0.5",
"lodash": "^4.17.23",
"js-cookie": "^3.0.7",
"lodash": "4.18.1",
"lucide-react": "^0.566.0",
"next": "16.1.7",
"next-intl": "^4.13.0",
"next-themes": "^0.2.1",
"punycode": "^2.3.1",
"react": "^19.2.4",
"react-chartjs-2": "^5.3.0",
"react-confetti-explosion": "^3.0.3",
"react-day-picker": "^9.13.0",
"react-dom": "^19.2.4",
"react-ga4": "^2.1.0",
@@ -84,6 +87,7 @@
},
"devDependencies": {
"@faker-js/faker": "^9.5.1",
"@playwright/test": "^1.52.0",
"@types/chroma-js": "^3.1.1",
"@types/js-cookie": "^3.0.6",
"eslint": "^9.39.1",
@@ -1738,6 +1742,22 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/@playwright/test": {
"version": "1.61.1",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz",
"integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==",
"devOptional": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.61.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@radix-ui/number": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz",
@@ -3562,9 +3582,9 @@
"license": "MIT"
},
"node_modules/@types/lodash": {
"version": "4.17.23",
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.23.tgz",
"integrity": "sha512-RDvF6wTulMPjrNdCoYRC8gNR880JNGT8uB+REUpC2Ns4pRqQJhGz90wh7rgdXDPpCczF3VGktDuFGVnz8zP7HA==",
"version": "4.17.24",
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.24.tgz",
"integrity": "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==",
"license": "MIT"
},
"node_modules/@types/node": {
@@ -4850,6 +4870,24 @@
"dev": true,
"license": "MIT"
},
"node_modules/cross-env": {
"version": "7.0.3",
"resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz",
"integrity": "sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==",
"license": "MIT",
"dependencies": {
"cross-spawn": "^7.0.1"
},
"bin": {
"cross-env": "src/bin/cross-env.js",
"cross-env-shell": "src/bin/cross-env-shell.js"
},
"engines": {
"node": ">=10.14",
"npm": ">=6",
"yarn": ">=1"
}
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -6760,9 +6798,9 @@
}
},
"node_modules/ip-address": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz",
"integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==",
"version": "10.2.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz",
"integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==",
"license": "MIT",
"engines": {
"node": ">= 12"
@@ -7258,13 +7296,10 @@
}
},
"node_modules/js-cookie": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz",
"integrity": "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==",
"license": "MIT",
"engines": {
"node": ">=14"
}
"version": "3.0.8",
"resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.8.tgz",
"integrity": "sha512-yeJd4aNAdYZQjaon2bpD/Gb0B/omw7HQOsynXXcOiWVCacbBcPlgn8S/d1X6blFSaHao7ozqtW7NZW19xpCtIw==",
"license": "MIT"
},
"node_modules/js-tokens": {
"version": "4.0.0",
@@ -8084,6 +8119,53 @@
"node": ">= 6"
}
},
"node_modules/playwright": {
"version": "1.61.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz",
"integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==",
"devOptional": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.61.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.61.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz",
"integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==",
"devOptional": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/playwright/node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/po-parser": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/po-parser/-/po-parser-2.1.1.tgz",
@@ -8330,6 +8412,26 @@
"node": ">=0.10.0"
}
},
"node_modules/react-chartjs-2": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/react-chartjs-2/-/react-chartjs-2-5.3.1.tgz",
"integrity": "sha512-h5IPXKg9EXpjoBzUfyWJvllMjG2mQ4EiuHQFhms/AjUm0XSZHhyRy2xVmLXHKrtcdrPO4mnGqRtYoD0vp95A0A==",
"license": "MIT",
"peerDependencies": {
"chart.js": "^4.1.1",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/react-confetti-explosion": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/react-confetti-explosion/-/react-confetti-explosion-3.0.3.tgz",
"integrity": "sha512-ow5ns/1ttzXsIlbbfJmWJNiyQK8lTHBL6lRSUXGaK44K/3NIMngR57Ja96l+D6txTeFhfe0BfXGvORMxhtRDng==",
"license": "MIT",
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/react-day-picker": {
"version": "9.13.0",
"resolved": "https://registry.npmjs.org/react-day-picker/-/react-day-picker-9.13.0.tgz",

View File

@@ -1,8 +1,13 @@
import { getTranslations } from "next-intl/server";
import { globalMetaTitle } from "@utils/meta";
import type { Metadata } from "next";
import BlankLayout from "@/layouts/BlankLayout";
export const metadata: Metadata = {
title: `Access Control - ${globalMetaTitle}`,
};
export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations();
return {
title: `${t("navigation.accessControl")} - ${globalMetaTitle}`,
};
}
export default BlankLayout;

View File

@@ -1,8 +1,13 @@
import { getTranslations } from "next-intl/server";
import { globalMetaTitle } from "@utils/meta";
import type { Metadata } from "next";
import BlankLayout from "@/layouts/BlankLayout";
export const metadata: Metadata = {
title: `Control Center - ${globalMetaTitle}`,
};
export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations();
return {
title: `${t("navigation.controlCenter")} - ${globalMetaTitle}`,
};
}
export default BlankLayout;

View File

@@ -1,8 +1,13 @@
import { getTranslations } from "next-intl/server";
import { globalMetaTitle } from "@utils/meta";
import type { Metadata } from "next";
import BlankLayout from "@/layouts/BlankLayout";
export const metadata: Metadata = {
title: `Zones - DNS - ${globalMetaTitle}`,
};
export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations();
return {
title: `${t("dnsZones")} - ${globalMetaTitle}`,
};
}
export default BlankLayout;

View File

@@ -1,8 +1,13 @@
import { getTranslations } from "next-intl/server";
import { globalMetaTitle } from "@utils/meta";
import type { Metadata } from "next";
import BlankLayout from "@/layouts/BlankLayout";
export const metadata: Metadata = {
title: `Groups - ${globalMetaTitle}`,
};
export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations();
return {
title: `${t("navigation.groups")} - ${globalMetaTitle}`,
};
}
export default BlankLayout;

View File

@@ -1,8 +1,13 @@
import { getTranslations } from "next-intl/server";
import { globalMetaTitle } from "@utils/meta";
import type { Metadata } from "next";
import BlankLayout from "@/layouts/BlankLayout";
export const metadata: Metadata = {
title: `Network Routes - ${globalMetaTitle}`,
};
export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations();
return {
title: `${t("networkRoutes")} - ${globalMetaTitle}`,
};
}
export default BlankLayout;

View File

@@ -205,6 +205,8 @@ function NetworkOverview({ network }: Readonly<{ network: Network }>) {
}
function NetworkActions() {
const t = useTranslations("networks");
const tCommon = useTranslations("common");
const { permission } = usePermissions();
const { deleteNetwork, openEditNetworkModal, network } = useNetworksContext();
const router = useRouter();
@@ -232,7 +234,7 @@ function NetworkActions() {
>
<div className={"flex gap-3 items-center"}>
<PencilLineIcon size={14} className={"shrink-0"} />
Rename
{t("renameNetwork")}
</div>
</DropdownMenuItem>
@@ -247,7 +249,7 @@ function NetworkActions() {
>
<div className={"flex gap-3 items-center"}>
<Trash2 size={14} className={"shrink-0"} />
Delete
{tCommon("delete")}
</div>
</DropdownMenuItem>
</DropdownMenuContent>
@@ -265,27 +267,19 @@ function NetworkInformationCard({ network }: Readonly<{ network: Network }>) {
const disabledText = useMemo(
() => (
<>
High availability is currently{" "}
<span className={"text-yellow-400 font-medium"}>
{tCommon("inactive")}
</span>{" "}
for this network.
{t("highAvailabilityInactiveText", { status: tCommon("inactive") })}
</>
),
[tCommon],
[t, tCommon],
);
const enabledText = useMemo(
() => (
<>
High availability is{" "}
<span className={"text-green-500 font-medium"}>
{tCommon("active")}
</span>{" "}
for this network.
{t("highAvailabilityActiveText", { status: tCommon("active") })}
</>
),
[tCommon],
[t, tCommon],
);
const policyCount = network.policies?.length ?? 0;
@@ -298,7 +292,7 @@ function NetworkInformationCard({ network }: Readonly<{ network: Network }>) {
label={
<>
<ServerIcon size={16} />
High Availability
{t("highAvailability")}
</>
}
value={
@@ -309,13 +303,11 @@ function NetworkInformationCard({ network }: Readonly<{ network: Network }>) {
{isHighlyAvailable ? enabledText : disabledText}
{isHighlyAvailable ? (
<div className={"inline-flex mt-2"}>
You can add more routing peers to increase the
availability of this network.
{t("highAvailabilityHelpActive")}
</div>
) : (
<div className={"inline-flex mt-2"}>
Go ahead and add more routing peers or groups with routing
peers to enable high availability for this network.
{t("highAvailabilityHelpInactive")}
</div>
)}
</div>

View File

@@ -1,8 +1,13 @@
import { getTranslations } from "next-intl/server";
import { globalMetaTitle } from "@utils/meta";
import type { Metadata } from "next";
import BlankLayout from "@/layouts/BlankLayout";
export const metadata: Metadata = {
title: `Peers - ${globalMetaTitle}`,
};
export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations();
return {
title: `${t("navigation.peers")} - ${globalMetaTitle}`,
};
}
export default BlankLayout;

View File

@@ -10,12 +10,14 @@ import useFetchApi from "@utils/api";
import { isNetBirdCloud } from "@utils/netbird";
import { ExternalLinkIcon, User2 } from "lucide-react";
import { useTranslations } from "next-intl";
import React, { lazy, Suspense } from "react";
import { lazy, Suspense } from "react";
import TeamIcon from "@/assets/icons/TeamIcon";
import { useGroups } from "@/contexts/GroupsProvider";
import { usePermissions } from "@/contexts/PermissionsProvider";
import { User } from "@/interfaces/User";
import PageContainer from "@/layouts/PageContainer";
import { AccountMfaCard } from "@/cloud/mfa/AccountMFACard";
import { IdentityProviderCard } from "@/modules/integrations/idp-sync/IdentityProviderCard";
const UsersTable = lazy(() => import("@/modules/users/UsersTable"));

View File

@@ -5,6 +5,7 @@ import Button from "@components/Button";
import Paragraph from "@components/Paragraph";
import loadConfig from "@utils/config";
import { ArrowRightIcon, RefreshCw } from "lucide-react";
import { useTranslations } from "next-intl";
import { useRouter, useSearchParams } from "next/navigation";
import { useEffect, useState } from "react";
import NetBirdIcon from "@/assets/icons/NetBirdIcon";
@@ -15,6 +16,9 @@ export default function ErrorPage() {
const { logout, isAuthenticated } = useOidc();
const router = useRouter();
const searchParams = useSearchParams();
const t = useTranslations("errors");
const tAuth = useTranslations("auth");
const tCommon = useTranslations("common");
const [error, setError] = useState<{
code: number;
message: string;
@@ -58,19 +62,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("userAccountBlocked");
if (isPendingApproval) return t("userApprovalPending");
return t("accessError");
};
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("accessBlockedDescription");
}
if (isPendingApproval) {
return "Your account is pending approval from an administrator. Please wait for approval before accessing the dashboard.";
return t("pendingApprovalDescription");
}
return "An error occurred while trying to access the dashboard. Please try again or contact your administrator.";
return t("accessGenericDescription");
};
return (
@@ -94,19 +98,19 @@ export default function ErrorPage() {
)}
<Paragraph className="text-center mt-2 text-sm">
If you believe this is an error, please contact your administrator.
{t("contactAdminDescription")}
</Paragraph>
<div className="mt-5 space-y-3">
{!isBlockedUser && !isPendingApproval && (
<Button variant="default-outline" size="sm" onClick={handleRetry}>
<RefreshCw size={16} className="mr-2" />
Try Again
{tCommon("tryAgain")}
</Button>
)}
<Button variant="primary" size="sm" onClick={handleLogout}>
{isBlockedUser || isPendingApproval ? "Sign Out" : "Logout"}
{isBlockedUser || isPendingApproval ? tAuth("signOut") : tCommon("logout")}
<ArrowRightIcon size={16} />
</Button>
</div>

View File

@@ -3,6 +3,7 @@
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { cn } from "@utils/helpers";
import { X } from "lucide-react";
import { useTranslations } from "next-intl";
import * as React from "react";
const Dialog = DialogPrimitive.Root;
@@ -31,25 +32,28 @@ DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border border-neutral-200 bg-white p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg md:w-full dark:border-neutral-800 dark:bg-neutral-950",
className,
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-white transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-neutral-950 focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-neutral-100 data-[state=open]:text-neutral-500 dark:ring-offset-neutral-950 dark:focus:ring-neutral-300 dark:data-[state=open]:bg-neutral-800 dark:data-[state=open]:text-neutral-400">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
));
>(({ className, children, ...props }, ref) => {
const t = useTranslations("common");
return (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border border-neutral-200 bg-white p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg md:w-full dark:border-neutral-800 dark:bg-neutral-950",
className,
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-white transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-neutral-950 focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-neutral-100 data-[state=open]:text-neutral-500 dark:ring-offset-neutral-950 dark:focus:ring-neutral-300 dark:data-[state=open]:bg-neutral-800 dark:data-[state=open]:text-neutral-400">
<X className="h-4 w-4" />
<span className="sr-only">{t("close")}</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
);
});
DialogContent.displayName = DialogPrimitive.Content.displayName;
const DialogHeader = ({

View File

@@ -79,6 +79,7 @@ interface MultiSelectProps {
showRoutes?: boolean;
disabledGroups?: Group[];
"data-testid"?: string;
dataCy?: string;
showResourceCounter?: boolean;
showResources?: boolean;
showPeers?: boolean;
@@ -120,6 +121,7 @@ export function PeerGroupSelector({
showRoutes = false,
disabledGroups,
"data-testid": dataTestId = "group-selector-dropdown",
dataCy,
showResourceCounter = true,
showResources = false,
showPeers = false,
@@ -394,6 +396,7 @@ export function PeerGroupSelector({
)}
disabled={disabled}
data-testid={dataTestId}
data-cy={dataCy}
ref={inputRef}
>
<div

View File

@@ -2,6 +2,7 @@ import FullTooltip from "@components/FullTooltip";
import useFetchApi from "@utils/api";
import { uniqBy } from "lodash";
import { RouteIcon } from "lucide-react";
import { useTranslations } from "next-intl";
import * as React from "react";
import { useMemo } from "react";
import Skeleton from "react-loading-skeleton";
@@ -11,6 +12,7 @@ type Props = {
group_id: string;
};
export const AccessControlGroupCount = ({ group_id }: Props) => {
const t = useTranslations("common");
const { data, isLoading } = useFetchApi<Route[]>("/routes");
const routes = useMemo(() => {
@@ -60,7 +62,7 @@ export const AccessControlGroupCount = ({ group_id }: Props) => {
}
>
<RouteIcon size={14} className={"shrink-0"} />
{routes.length} Route(s)
{t("routeCount", { count: routes.length })}
</div>
</FullTooltip>
) : null;

View File

@@ -2,6 +2,7 @@ import InlineLink from "@components/InlineLink";
import { cn } from "@utils/helpers";
import { cva, VariantProps } from "class-variance-authority";
import { ArrowRightIcon, XIcon } from "lucide-react";
import { useTranslations } from "next-intl";
import { useEffect, useRef } from "react";
import { useAnnouncement } from "@/contexts/AnnouncementProvider";
@@ -36,6 +37,7 @@ const variants = cva(
export type AnnouncementVariant = VariantProps<typeof variants>;
export const AnnouncementBanner = () => {
const t = useTranslations("common");
const { closeAnnouncement, announcements, setBannerHeight } =
useAnnouncement();
const announcement = announcements?.find((a) => a.isOpen);
@@ -85,7 +87,7 @@ export const AnnouncementBanner = () => {
variants({ inlineLink: announcement.variant }),
)}
>
{announcement.linkText || "Learn more"}
{announcement.linkText || t("learnMore")}
<ArrowRightIcon size={14} />
</InlineLink>
)}

View File

@@ -9,9 +9,11 @@ import {
} from "@components/DropdownMenu";
import { MonitorIcon, MoonIcon, SunIcon } from "lucide-react";
import { useTheme } from "next-themes";
import { useTranslations } from "next-intl";
import { useEffect, useState } from "react";
export default function DarkModeToggle() {
const t = useTranslations("theme");
const [mounted, setMounted] = useState(false);
const { setTheme } = useTheme();
@@ -42,14 +44,14 @@ export default function DarkModeToggle() {
disabled={true}
>
<SunIcon size={16} />
Light
{t("light")}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setTheme("dark")}
className={"flex gap-2"}
>
<MoonIcon size={16} />
Dark
{t("dark")}
</DropdownMenuItem>
<DropdownMenuItem
disabled={true}
@@ -57,7 +59,7 @@ export default function DarkModeToggle() {
className={"flex gap-2"}
>
<MonitorIcon size={16} />
System
{t("system")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>

View File

@@ -2,6 +2,7 @@ import Badge from "@components/Badge";
import TextWithTooltip from "@components/ui/TextWithTooltip";
import { cn } from "@utils/helpers";
import { EyeIcon, FolderGit2, SquarePen } from "lucide-react";
import { useTranslations } from "next-intl";
import * as React from "react";
import { useMemo, useState } from "react";
import { useGroups } from "@/contexts/GroupsProvider";
@@ -24,6 +25,7 @@ export default function GroupBadgeWithEditPeers({
useSave = true,
onPeerAssignmentChange,
}: Readonly<Props>) {
const t = useTranslations("common");
const isNew = !group?.id;
const [editGroupPeersModal, setEditGroupPeersModal] = useState(false);
const { dropdownOptions, addDropdownOptions, updateGroupDropdown } =
@@ -87,7 +89,7 @@ export default function GroupBadgeWithEditPeers({
"text-[7px] relative -top-[0px] leading-[0] bg-green-900 border border-green-500/20 py-1.5 px-1 rounded-[3px] text-green-400"
}
>
NEW
{t("new")}
</span>
)}
</div>
@@ -104,7 +106,7 @@ export default function GroupBadgeWithEditPeers({
>
{peerCount}
</span>{" "}
Peers{" "}
{t("peers")}{" "}
</span>
{isAllGroup ? (
<EyeIcon size={11} className={"shrink-0"} />

View File

@@ -18,6 +18,7 @@ import {
MessagesSquareIcon,
TriangleAlert,
} from "lucide-react";
import { useTranslations } from "next-intl";
import { useState } from "react";
import Button from "@components/Button";
import { cn } from "@utils/helpers";
@@ -25,6 +26,7 @@ import SlackIcon from "@/assets/icons/SlackIcon";
import { isNetBirdCloud } from "@utils/netbird";
export default function HelpAndSupportButton() {
const tNav = useTranslations("navigation");
const [dropdownOpen, setDropdownOpen] = useState(false);
return (
@@ -49,7 +51,7 @@ export default function HelpAndSupportButton() {
<DropdownMenuLabel className="font-normal">
<div className="flex flex-col space-y-1 px-1">
<div className="text-sm font-normal leading-none text-nb-gray-200 py-1">
Help and Support
{tNav("helpAndSupport")}
</div>
</div>
</DropdownMenuLabel>
@@ -62,7 +64,7 @@ export default function HelpAndSupportButton() {
>
<div className={"flex gap-3 items-center"}>
<BookText size={14} />
Documentation
{tNav("documentation")}
</div>
<DropdownMenuShortcut>
<ArrowUpRightIcon size={16} />
@@ -76,7 +78,7 @@ export default function HelpAndSupportButton() {
>
<div className={"flex gap-3 items-center"}>
<TriangleAlert size={14} />
Troubleshooting
{tNav("troubleshooting")}
</div>
<DropdownMenuShortcut>
<ArrowUpRightIcon size={16} />
@@ -102,7 +104,7 @@ export default function HelpAndSupportButton() {
>
<div className={"flex gap-3 items-center"}>
<MessagesSquareIcon size={14} />
NetBird Forum
{tNav("forum")}
</div>
<DropdownMenuShortcut>
<ArrowUpRightIcon size={16} />
@@ -116,7 +118,7 @@ export default function HelpAndSupportButton() {
>
<div className={"flex gap-3 items-center"}>
<SlackIcon size={14} />
NetBird Slack
{tNav("slack")}
</div>
<DropdownMenuShortcut>
<ArrowUpRightIcon size={16} />
@@ -133,7 +135,7 @@ export default function HelpAndSupportButton() {
>
<div className={"flex gap-3 items-center"}>
<MessageSquareShare size={14} />
Feedback
{tNav("feedback")}
</div>
<DropdownMenuShortcut>
<ArrowUpRightIcon size={16} />

View File

@@ -3,6 +3,7 @@ import { Input } from "@components/Input";
import { validator } from "@utils/helpers";
import { uniqueId } from "lodash";
import { GlobeIcon, MinusCircleIcon } from "lucide-react";
import { useTranslations } from "next-intl";
import * as React from "react";
import { useEffect, useMemo, useState } from "react";
import { Domain } from "@/interfaces/Domain";
@@ -47,6 +48,7 @@ export default function InputDomain({
allowWildcard = true,
showRemoveButton = true,
}: Readonly<Props>) {
const t = useTranslations("common");
const [name, setName] = useState(value?.name || "");
const handleNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
@@ -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("validDomainError");
}
}, [name]);
@@ -80,7 +82,7 @@ export default function InputDomain({
<div className={"w-full"}>
<Input
customPrefix={<GlobeIcon size={15} />}
placeholder={"e.g., example.com"}
placeholder={t("domainPlaceholder")}
maxWidthClass={"w-full"}
data-testid={"domain-input"}
value={name}

View File

@@ -1,10 +1,12 @@
import Button from "@components/Button";
import { Modal, ModalTrigger } from "@components/modal/Modal";
import { DownloadIcon } from "lucide-react";
import { useTranslations } from "next-intl";
import React, { useState } from "react";
import SetupModal from "@/modules/setup-netbird-modal/SetupModal";
export function InstallNetBirdButton() {
const t = useTranslations("common");
const [installModal, setInstallModal] = useState(false);
return (
@@ -12,7 +14,7 @@ export function InstallNetBirdButton() {
<ModalTrigger asChild>
<Button variant={"secondary"} size={"sm"}>
<DownloadIcon size={16} />
Install NetBird
{t("installNetBird")}
</Button>
</ModalTrigger>
<SetupModal />

View File

@@ -14,15 +14,9 @@ import { cn } from "@utils/helpers";
import { CheckIcon, GlobeIcon } from "lucide-react";
import { useState } from "react";
import { useLocale } from "@/contexts/LocaleProvider";
import { locales, type Locale } from "@/i18n/config";
import { locales, type Locale, LOCALE_LABELS } from "@/i18n/config";
import { useTranslations } from "next-intl";
/** Human-readable label for each locale, shown in the switcher. */
const LOCALE_LABELS: Record<Locale, string> = {
en: "English",
zh: "中文",
};
/**
* Header control that switches the active locale. Writes the choice to the
* `NEXT_LOCALE` cookie (via {@link useLocale}) so it persists across reloads
@@ -70,7 +64,7 @@ export default function LocaleSwitcher() {
<DropdownMenuContent className="w-48" align="end" forceMount>
<DropdownMenuLabel className="font-normal">
<div className="text-sm font-normal leading-none text-nb-gray-200 py-1 px-1">
Language
{t("language")}
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />

View File

@@ -1,23 +1,25 @@
import Badge from "@components/Badge";
import { Tooltip, TooltipContent, TooltipTrigger } from "@components/Tooltip";
import { useTranslations } from "next-intl";
import { AlertTriangle } from "lucide-react";
type Props = {
loginExpired: boolean;
};
export default function LoginExpiredBadge({ loginExpired }: Props) {
const t = useTranslations("peers");
return loginExpired ? (
<Tooltip delayDuration={1}>
<TooltipTrigger>
<Badge variant={"red"} className={"px-2"}>
<AlertTriangle size={12} />
Login required
{t("loginRequired")}
</Badge>
</TooltipTrigger>
<TooltipContent>
<div className={"text-neutral-300 text-xs leading-1.5"}>
This peer is offline and needs to be <br />
re-authenticated because its login has expired.
{t("loginExpiredTooltip")}
</div>
</TooltipContent>
</Tooltip>

View File

@@ -6,12 +6,15 @@ import {
TooltipTrigger,
} from "@components/Tooltip";
import { GlobeIcon } from "lucide-react";
import { useTranslations } from "next-intl";
import * as React from "react";
type Props = {
domains: string[];
};
export default function MultipleDomains({ domains }: Props) {
const t = useTranslations("common");
if (domains.length === 0) {
return (
<Badge
@@ -19,7 +22,7 @@ export default function MultipleDomains({ domains }: Props) {
className={"uppercase tracking-wider font-medium"}
>
<GlobeIcon size={10} />
All
{t("allDomains")}
</Badge>
);
}
@@ -31,7 +34,7 @@ export default function MultipleDomains({ domains }: Props) {
<TooltipTrigger asChild={true}>
<Badge variant={"blue-darker"} className={"cursor-help"}>
<GlobeIcon size={10} />
{domains.length} Domains
{t("domainCount", { count: domains.length })}
</Badge>
</TooltipTrigger>
<TooltipContent className={"p-3"}>

View File

@@ -41,8 +41,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,
@@ -53,8 +53,12 @@ export default function MultipleGroups({
countThreshold = 1,
}: Readonly<Props>) {
const tGroups = useTranslations("groups");
const tCommon = useTranslations("common");
const { permission } = usePermissions();
const resolvedLabel = label ?? tCommon("assignedGroups");
const resolvedDescription = description ?? tCommon("assignedGroupsDescription");
if (!groups || groups?.length === 0) return <EmptyRow />;
const orderedGroups = [...groups].sort((a, b) => {
if (a.name === "All") return 1;
@@ -139,7 +143,7 @@ export default function MultipleGroups({
onClick={(e) => e.stopPropagation()}
>
<div className={"text-sm font-medium text-left px-5 pt-3"}>
{label}
{resolvedLabel}
</div>
<ScrollArea
className={

View File

@@ -2,6 +2,7 @@ import Card from "@components/Card";
import Paragraph from "@components/Paragraph";
import { cn } from "@utils/helpers";
import { FilterX } from "lucide-react";
import { useTranslations } from "next-intl";
import React from "react";
import Skeleton from "react-loading-skeleton";
@@ -15,11 +16,15 @@ 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<Props>) {
const t = useTranslations("table");
const displayTitle = title || t("noResultsCardTitle");
const displayDescription = description || t("noResultsDescription");
return (
<div className={cn("px-8 mt-8", className)}>
<Card className={"w-full relative overflow-hidden"}>
@@ -50,9 +55,11 @@ export default function NoResultsCard({
{icon || <FilterX size={24} />}
</div>
<div className={"text-center"}>
<h1 className={"text-2xl font-medium max-w-lg mx-auto"}>{title}</h1>
<h1 className={"text-2xl font-medium max-w-lg mx-auto"}>
{displayTitle}
</h1>
<Paragraph className={"justify-center my-2 !text-nb-gray-400"}>
{description}
{displayDescription}
</Paragraph>
{children}
</div>

View File

@@ -3,6 +3,7 @@ import Card from "@components/Card";
import Paragraph from "@components/Paragraph";
import SquareIcon from "@components/SquareIcon";
import { CircleAlertIcon, Undo2Icon } from "lucide-react";
import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation";
import * as React from "react";
import Skeleton from "react-loading-skeleton";
@@ -12,12 +13,14 @@ 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.",
}: Props) => {
export const PageNotFound = ({ title, description }: Props) => {
const t = useTranslations("pageNotFound");
const tCommon = useTranslations("common");
const router = useRouter();
const displayTitle = title || t("title");
const displayDescription = description || t("description");
return (
<PageContainer>
<div className={"px-8"}>
@@ -66,10 +69,10 @@ export const PageNotFound = ({
"text-3xl font-medium mx-auto mt-3 capitalize"
}
>
{title}
{displayTitle}
</h1>
<Paragraph className={"justify-center my-3 max-w-xl"}>
{description}
{displayDescription}
</Paragraph>
<Button
variant={"secondary"}
@@ -77,7 +80,7 @@ export const PageNotFound = ({
onClick={() => router.back()}
>
<Undo2Icon size={15} className={"shrink-0"} />
Go Back
{tCommon("goBack")}
</Button>
</div>
</div>

View File

@@ -1,6 +1,7 @@
import Badge, { BadgeVariants } from "@components/Badge";
import { cn, singularize } from "@utils/helpers";
import { MonitorSmartphoneIcon } from "lucide-react";
import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation";
import * as React from "react";
import { useMemo } from "react";
@@ -20,6 +21,7 @@ export default function PeerCountBadge({
className,
disableRedirect = false,
}: Props) {
const t = useTranslations("common");
const router = useRouter();
const { dropdownOptions, groups } = useGroups();
@@ -61,7 +63,7 @@ export default function PeerCountBadge({
useHover={canRedirect}
>
<MonitorSmartphoneIcon size={12} />
{singularize("Peers", peerCount, true)}
{t("peerCount", { count: peerCount })}
</Badge>
);
}

View File

@@ -1,6 +1,7 @@
import Badge, { BadgeVariants } from "@components/Badge";
import { cn, singularize } from "@utils/helpers";
import { LayersIcon } from "lucide-react";
import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation";
import * as React from "react";
import { Group } from "@/interfaces/Group";
@@ -15,6 +16,7 @@ export default function ResourceCountBadge({
group,
disableRedirect = false,
}: Props) {
const t = useTranslations("common");
const router = useRouter();
const hasId = !!group?.id;
@@ -32,7 +34,7 @@ export default function ResourceCountBadge({
useHover={hasId}
>
<LayersIcon size={12} />
{singularize("Resources", group?.resources_count, true)}
{t("resourceCount", { count: group?.resources_count ?? 0 })}
</Badge>
);
}

View File

@@ -1,6 +1,7 @@
import { cn } from "@utils/helpers";
import { AnimatePresence, motion } from "framer-motion";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { useTranslations } from "next-intl";
import * as React from "react";
import { useState } from "react";
@@ -137,6 +138,7 @@ export const SlidingTabsBackTrigger = ({
}: {
children: React.ReactNode;
}) => {
const t = useTranslations("common");
const { back } = useSlidingTabContext();
return (
<div
@@ -148,7 +150,7 @@ export const SlidingTabsBackTrigger = ({
className={"flex gap-2 items-center select-none cursor-pointer"}
>
<ChevronLeft size={18} />
Back
{t("back")}
</div>
);
};

View File

@@ -1,5 +1,6 @@
import { cn } from "@utils/helpers";
import { cva, type VariantProps } from "class-variance-authority";
import { useTranslations } from "next-intl";
import * as React from "react";
const smallBadgeVariants = cva("", {
@@ -28,17 +29,19 @@ type Props = {
} & VariantProps<typeof smallBadgeVariants>;
export const SmallBadge = ({
text = "NEW",
text,
className,
textClassName,
variant = "green",
children,
size = "default",
}: Props) => {
const t = useTranslations("common");
const resolvedText = text ?? t("new");
return (
<span className={cn(smallBadgeVariants({ variant, size }), className)}>
{children}
<span className={cn("relative top-[0.4px]", textClassName)}>{text}</span>
<span className={cn("relative top-[0.4px]", textClassName)}>{resolvedText}</span>
</span>
);
};

View File

@@ -12,6 +12,7 @@ import {
import TextWithTooltip from "@components/ui/TextWithTooltip";
import { UserAvatar } from "@components/ui/UserAvatar";
import { CreditCardIcon, KeyRound, LogOutIcon, User2 } from "lucide-react";
import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { useHotkeys } from "react-hotkeys-hook";
@@ -25,6 +26,9 @@ import { isNetBirdCloud } from "@utils/netbird";
import { Modal } from "@components/modal/Modal";
export default function UserDropdown() {
const t = useTranslations("userDropdown");
const tCommon = useTranslations("common");
const tSettings = useTranslations("settings");
const [dropdownOpen, setDropdownOpen] = useState(false);
const [changePasswordModal, setChangePasswordModal] = useState(false);
const { user } = useApplicationContext();
@@ -106,7 +110,7 @@ export default function UserDropdown() {
>
<div className={"flex gap-3 items-center"}>
<KeyRound size={14} />
Change Password
{tSettings("changePassword")}
</div>
</DropdownMenuItem>
)}
@@ -114,7 +118,7 @@ export default function UserDropdown() {
<DropdownMenuItem onClick={logout}>
<div className={"flex gap-3 items-center"}>
<LogOutIcon size={14} />
Log out
{tCommon("logout")}
</div>
<DropdownMenuShortcut>
{isMac ? "⇧⌘L" : "⇧ ⊞ L"}
@@ -127,6 +131,7 @@ export default function UserDropdown() {
}
const ProfileSettingsDropdownItem = ({ onClick }: { onClick: () => void }) => {
const t = useTranslations("userDropdown");
const { isMSPInTenantContext } = useMSP();
const { permission } = usePermissions();
@@ -137,13 +142,14 @@ const ProfileSettingsDropdownItem = ({ onClick }: { onClick: () => void }) => {
<DropdownMenuItem onClick={onClick}>
<div className={"flex gap-3 items-center"}>
<User2 size={14} />
Profile Settings
{t("profileSettings")}
</div>
</DropdownMenuItem>
);
};
const PlansAndBillingDropdownItem = ({ onClick }: { onClick: () => void }) => {
const t = useTranslations("userDropdown");
const { permission } = usePermissions();
const { isAccountWithMSPParent } = useMSP();
@@ -154,7 +160,7 @@ const PlansAndBillingDropdownItem = ({ onClick }: { onClick: () => void }) => {
<DropdownMenuItem onClick={onClick}>
<div className={"flex gap-3 items-center"}>
<CreditCardIcon size={14} />
Plans & Billing
{t("plansAndBilling")}
</div>
</DropdownMenuItem>
)

View File

@@ -96,7 +96,7 @@ export default function PeerProvider({
: peer.login_expiration_enabled,
inactivity_expiration_enabled:
props?.inactivityExpiration == undefined
? undefined
? peer.inactivity_expiration_enabled
: props.inactivityExpiration,
approval_required:
props?.approval_required == undefined

View File

@@ -4,7 +4,6 @@
* Shared by:
* - {@link './request.ts'} (next-intl plugin, build-time)
* - {@link './detection.ts'} (cookie + browser locale detection)
* - {@link './routing.ts'} (next-intl routing definition)
* - {@link '../contexts/LocaleProvider.tsx'} (runtime provider)
*
* NOTE: This app uses `output: "export"` (static export), so there is no
@@ -25,10 +24,16 @@ export type Locale = (typeof locales)[number];
/** Locale used when no preference is stored or detectable. */
export const defaultLocale: Locale = "en";
/** Human-readable label for each locale, shown in selectors and switchers. */
export const LOCALE_LABELS: Record<Locale, string> = {
en: "English",
zh: "中文",
};
/** Message catalog keyed by locale. */
export const messages: Record<Locale, typeof en> = {
en,
zh: zh as unknown as typeof en,
zh,
};
/** Cookie name used to persist the user's locale choice. */

9
src/i18n/global.d.ts vendored Normal file
View File

@@ -0,0 +1,9 @@
import en from "./messages/en";
type Messages = typeof en;
declare global {
interface IntlMessages extends Messages {}
}
export {};

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -1,8 +0,0 @@
import { defineRouting } from "next-intl/routing";
import { defaultLocale, locales } from "./config";
export const routing = defineRouting({
locales,
defaultLocale,
});

View File

@@ -251,7 +251,7 @@ label={t('team')}
<MSPNavigationItem />
<SidebarItem
icon={<IntegrationIcon />}
label="Integrations"
label={t("integrations")}
href={"/integrations"}
exactPathMatch={true}
visible={
@@ -315,7 +315,7 @@ label={t('activity')}
visible={permission.events.read}
/>
<SidebarItem
label="Traffic Events"
label={t("trafficEvents")}
isChild
href={"/events/traffic"}
exactPathMatch={true}

View File

@@ -1,3 +1,5 @@
"use client";
import * as React from "react";
import { Dispatch, SetStateAction } from "react";
import {
@@ -9,6 +11,7 @@ import {
} from "@components/Select";
import { usePermissions } from "@/contexts/PermissionsProvider";
import { ShieldHalfIcon, ShieldUserIcon } from "lucide-react";
import { useTranslations } from "next-intl";
type Props = {
value: "full" | "limited";
@@ -16,6 +19,7 @@ type Props = {
};
export const SSHAccessType = ({ value, onChange }: Props) => {
const t = useTranslations("policies");
const { permission } = usePermissions();
return (
@@ -34,15 +38,15 @@ export const SSHAccessType = ({ value, onChange }: Props) => {
) : (
<ShieldHalfIcon size={15} className={"text-nb-gray-300 shrink-0"} />
)}
<SelectValue placeholder="Select ssh access type..." />
<SelectValue placeholder={t("sshAccessPlaceholder")} />
</div>
</SelectTrigger>
<SelectContent data-testid={"ssh-access-selection"}>
<SelectItem value="full" className={"whitespace-nowrap"}>
Full Access
{t("sshFullAccess")}
</SelectItem>
<SelectItem value="limited" className={"whitespace-nowrap"}>
Limited Access
{t("sshLimitedAccess")}
</SelectItem>
</SelectContent>
</Select>

View File

@@ -1,3 +1,5 @@
"use client";
import { InfoIcon } from "lucide-react";
import React, { useCallback, useEffect, useMemo } from "react";
import { Group } from "@/interfaces/Group";
@@ -8,6 +10,7 @@ import { useUsers } from "@/contexts/UsersProvider";
import { cn } from "@utils/helpers";
import { Callout } from "@components/Callout";
import { SSHUsernameSelector } from "@/modules/access-control/ssh/SSHUsernameSelector";
import { useTranslations } from "next-intl";
type Props = {
sourceGroups?: Group[];
@@ -22,6 +25,7 @@ export function SSHAuthorizedGroups({
setAuthorizedGroups,
accessType,
}: Props) {
const t = useTranslations("policies");
const isEmpty =
!authorizedGroups || Object.keys(authorizedGroups).length === 0;
@@ -61,9 +65,7 @@ export function SSHAuthorizedGroups({
icon={<InfoIcon size={14} className={"shrink-0 relative top-[3px]"} />}
className="mt-3 py-[.75rem]"
>
You have not added any source groups yet, please add source groups in
order to specify which user group has access to which system users on
the destination machines.
{t("sshNoSourceGroups")}
</Callout>
);
}

View File

@@ -1,3 +1,5 @@
"use client";
import Badge from "@components/Badge";
import { Callout } from "@components/Callout";
import { Checkbox } from "@components/Checkbox";
@@ -18,6 +20,7 @@ import * as React from "react";
import { useMemo, useState } from "react";
import { useElementSize } from "@/hooks/useElementSize";
import { PostureCheck } from "@/interfaces/PostureCheck";
import { useTranslations } from "next-intl";
interface MultiSelectProps {
values?: string[];
@@ -32,6 +35,7 @@ export function SSHUsernameSelector({
disabled = false,
popoverWidth = "auto",
}: Readonly<MultiSelectProps>) {
const t = useTranslations("policies");
const searchRef = React.useRef<HTMLInputElement>(null);
const [open, setOpen] = useState(false);
const [inputRef, { width }] = useElementSize<HTMLButtonElement>();
@@ -88,7 +92,7 @@ export function SSHUsernameSelector({
{values?.length === 0 && (
<Badge variant={"gray"} className={"font-normal py-1"}>
<CircleUserIcon size={12} className={"shrink-0"} />
All Local Users
{t("sshAllLocalUsers")}
</Badge>
)}
@@ -147,7 +151,7 @@ export function SSHUsernameSelector({
ref={searchRef}
value={search}
onValueChange={setSearch}
placeholder={"E.g., root, ec2-user, ubuntu"}
placeholder={t("sshUsernamePlaceholder")}
/>
<div
className={
@@ -202,10 +206,7 @@ export function SSHUsernameSelector({
<div
className={"text-neutral-500 dark:text-nb-gray-300"}
>
Add username by pressing{" "}
<span className={"font-bold text-netbird"}>
{"'Enter'"}
</span>
{t("sshAddUsernameByPressing", { key: "Enter" })}
</div>
</CommandItem>
</div>

View File

@@ -1,14 +1,18 @@
"use client";
import { ToggleSwitch } from "@components/ToggleSwitch";
import React, { useMemo } from "react";
import { mutate } from "swr";
import { usePermissions } from "@/contexts/PermissionsProvider";
import { usePolicies } from "@/contexts/PoliciesProvider";
import { Policy } from "@/interfaces/Policy";
import { useTranslations } from "next-intl";
type Props = {
policy: Policy;
};
export default function AccessControlActiveCell({ policy }: Readonly<Props>) {
const t = useTranslations("policies");
const { updatePolicy, serializeRules } = usePolicies();
const { permission } = usePermissions();
@@ -23,9 +27,7 @@ export default function AccessControlActiveCell({ policy }: Readonly<Props>) {
() => {
mutate("/policies");
},
enabled
? "The rule was successfully enabled"
: "The rule was successfully disabled",
enabled ? t("policyEnabledSuccess") : t("policyDisabledSuccess"),
);
};

View File

@@ -1,14 +1,18 @@
"use client";
import Badge from "@components/Badge";
import { IconCirclePlus } from "@tabler/icons-react";
import { ShieldCheck } from "lucide-react";
import React from "react";
import { usePermissions } from "@/contexts/PermissionsProvider";
import { Policy } from "@/interfaces/Policy";
import { useTranslations } from "next-intl";
type Props = {
policy: Policy;
};
export default function AccessControlPostureCheckCell({ policy }: Props) {
const t = useTranslations("policies");
const { permission } = usePermissions();
const isDisabled = !permission.policies.create || !permission.policies.update;
@@ -18,7 +22,7 @@ export default function AccessControlPostureCheckCell({ policy }: Props) {
<div className={"flex"}>
<Badge variant={"gray"} useHover={true}>
<ShieldCheck size={14} className={"text-green-500"} />
{policy.source_posture_checks.length} Posture Check(s)
{t("postureCheckCount", { count: policy.source_posture_checks.length })}
</Badge>
</div>
) : (
@@ -32,7 +36,7 @@ export default function AccessControlPostureCheckCell({ policy }: Props) {
disabled={isDisabled}
>
<IconCirclePlus size={14} />
Add Posture Check
{t("addPostureCheck")}
</Badge>
</div>
);

View File

@@ -1,3 +1,5 @@
"use client";
import Badge from "@components/Badge";
import FullTooltip from "@components/FullTooltip";
import {
@@ -11,6 +13,7 @@ import React, { useMemo } from "react";
import { Policy } from "@/interfaces/Policy";
import EmptyRow from "@/modules/common-table-rows/EmptyRow";
import { parsePortsToStrings } from "@/modules/access-control/useAccessControl";
import { useTranslations } from "next-intl";
// AccessControlProtoPortsCell — single column combining the protocol
// and ports indicators. Protocol is always shown. Ports rendering:
@@ -24,6 +27,8 @@ type Props = {
export default function AccessControlProtoPortsCell({
policy,
}: Readonly<Props>) {
const t = useTranslations("policies");
const tCommon = useTranslations("common");
const rule = useMemo(() => {
if (policy.rules.length > 0) return policy.rules[0];
return undefined;
@@ -52,7 +57,7 @@ export default function AccessControlProtoPortsCell({
<FullTooltip
interactive={false}
content={
<span className={"text-xs text-nb-gray-100"}>NETBIRD-SSH</span>
<span className={"text-xs text-nb-gray-100"}>{t("netbirdSshTooltip")}</span>
}
>
<span className={"cursor-help"}>{protocolBadge}</span>
@@ -70,7 +75,7 @@ export default function AccessControlProtoPortsCell({
variant={"gray"}
className={"uppercase tracking-wider font-medium"}
>
All
{tCommon("all")}
</Badge>
)}
@@ -92,7 +97,7 @@ export default function AccessControlProtoPortsCell({
"px-3 whitespace-nowrap uppercase tracking-wider font-medium"
}
>
{allPorts.length} Ports
{t("nPorts", { count: allPorts.length })}
</Badge>
)}
</div>

View File

@@ -1,3 +1,5 @@
"use client";
import Button from "@components/Button";
import Card from "@components/Card";
import FullTooltip from "@components/FullTooltip";
@@ -34,6 +36,7 @@ import GetStartedTest from "@components/ui/GetStartedTest";
import type { ColumnDef, SortingState } from "@tanstack/react-table";
import { removeAllSpaces } from "@utils/helpers";
import { ClockFadingIcon, ExternalLinkIcon, PlusCircle } from "lucide-react";
import { useTranslations } from "next-intl";
import { usePathname, useSearchParams } from "next/navigation";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { useSWRConfig } from "swr";
@@ -61,155 +64,160 @@ type Props = {
isGroupPage?: boolean;
};
export const AccessControlTableColumns: ColumnDef<Policy>[] = [
{
id: "name",
accessorFn: (row) => removeAllSpaces(row?.name),
header: ({ column }) => {
return <DataTableHeader column={column}>Name</DataTableHeader>;
function createAccessControlTableColumns(
t: ReturnType<typeof useTranslations<"policies">>,
tCommon: ReturnType<typeof useTranslations<"common">>,
): ColumnDef<Policy>[] {
return [
{
id: "name",
accessorFn: (row) => removeAllSpaces(row?.name),
header: ({ column }) => {
return <DataTableHeader column={column}>{t("name")}</DataTableHeader>;
},
sortingFn: "text",
filterFn: "fuzzy",
cell: ({ cell }) => <AccessControlNameCell policy={cell.row.original} />,
},
sortingFn: "text",
filterFn: "fuzzy",
cell: ({ cell }) => <AccessControlNameCell policy={cell.row.original} />,
},
{
id: "description",
accessorFn: (row) => removeAllSpaces(row?.description),
sortingFn: "text",
filterFn: "fuzzy",
},
{
id: "enabled",
accessorKey: "enabled",
accessorFn: (row) => row.enabled,
sortingFn: "basic",
},
{
id: "sources",
accessorFn: (row) => {
try {
return row.rules[0].sources?.length || 0;
} catch (e) {
console.log(e);
}
return 0;
{
id: "description",
accessorFn: (row) => removeAllSpaces(row?.description),
sortingFn: "text",
filterFn: "fuzzy",
},
sortingFn: "basic",
header: ({ column }) => {
return <DataTableHeader column={column}>Sources</DataTableHeader>;
{
id: "enabled",
accessorKey: "enabled",
accessorFn: (row) => row.enabled,
sortingFn: "basic",
},
cell: ({ cell }) => <AccessControlSourcesCell policy={cell.row.original} />,
},
{
id: "direction",
accessorFn: (row) => {
try {
return row.rules[0].bidirectional || true;
} catch (e) {
console.log(e);
}
return 0;
{
id: "sources",
accessorFn: (row) => {
try {
return row.rules[0].sources?.length || 0;
} catch (e) {
console.log(e);
}
return 0;
},
sortingFn: "basic",
header: ({ column }) => {
return <DataTableHeader column={column}>{t("sources")}</DataTableHeader>;
},
cell: ({ cell }) => <AccessControlSourcesCell policy={cell.row.original} />,
},
sortingFn: "basic",
header: ({ column }) => {
return <DataTableHeader column={column}>Direction</DataTableHeader>;
{
id: "direction",
accessorFn: (row) => {
try {
return row.rules[0].bidirectional || true;
} catch (e) {
console.log(e);
}
return 0;
},
sortingFn: "basic",
header: ({ column }) => {
return <DataTableHeader column={column}>{t("direction")}</DataTableHeader>;
},
cell: ({ cell }) => (
<AccessControlDirectionCell policy={cell.row.original} />
),
},
cell: ({ cell }) => (
<AccessControlDirectionCell policy={cell.row.original} />
),
},
{
id: "destinations",
accessorFn: (row) => {
try {
return row.rules[0].destinations?.length || 0;
} catch (e) {
console.log(e);
}
return 0;
{
id: "destinations",
accessorFn: (row) => {
try {
return row.rules[0].destinations?.length || 0;
} catch (e) {
console.log(e);
}
return 0;
},
sortingFn: "basic",
header: ({ column }) => {
return <DataTableHeader column={column}>{t("destinations")}</DataTableHeader>;
},
cell: ({ cell }) => (
<AccessControlDestinationsCell policy={cell.row.original} />
),
},
sortingFn: "basic",
header: ({ column }) => {
return <DataTableHeader column={column}>Destinations</DataTableHeader>;
},
cell: ({ cell }) => (
<AccessControlDestinationsCell policy={cell.row.original} />
),
},
{
id: "proto_ports",
accessorFn: (row) => row.rules?.[0]?.protocol || "",
sortingFn: "text",
header: ({ column }) => {
return <DataTableHeader column={column}>Proto & Ports</DataTableHeader>;
{
id: "proto_ports",
accessorFn: (row) => row.rules?.[0]?.protocol || "",
sortingFn: "text",
header: ({ column }) => {
return <DataTableHeader column={column}>{t("protoPorts")}</DataTableHeader>;
},
cell: ({ cell }) => (
<AccessControlProtoPortsCell policy={cell.row.original} />
),
},
cell: ({ cell }) => (
<AccessControlProtoPortsCell policy={cell.row.original} />
),
},
{
id: "id",
accessorKey: "id",
filterFn: "exactMatch",
},
// Hidden filter columns powering the consolidated Filters UI.
{
id: "source_group_names",
accessorFn: (row) => {
const sources = row.rules?.[0]?.sources;
if (!sources) return [];
return (sources as { name?: string }[])
.map((s) => s?.name)
.filter((n): n is string => !!n);
{
id: "id",
accessorKey: "id",
filterFn: "exactMatch",
},
filterFn: "arrIncludesSome",
},
{
id: "destination_group_names",
accessorFn: (row) => {
const destinations = row.rules?.[0]?.destinations;
if (!destinations) return [];
return (destinations as { name?: string }[])
.map((d) => d?.name)
.filter((n): n is string => !!n);
// Hidden filter columns powering the consolidated Filters UI.
{
id: "source_group_names",
accessorFn: (row) => {
const sources = row.rules?.[0]?.sources;
if (!sources) return [];
return (sources as { name?: string }[])
.map((s) => s?.name)
.filter((n): n is string => !!n);
},
filterFn: "arrIncludesSome",
},
filterFn: "arrIncludesSome",
},
{
id: "protocol_filter",
accessorFn: (row) => [row.rules?.[0]?.protocol || "all"],
filterFn: "arrIncludesSome",
},
{
id: "ports_filter",
accessorFn: (row) => {
const rule = row.rules?.[0];
const ports = rule?.ports || [];
const ranges = (rule?.port_ranges || []).map(
(r) => `${r.start}-${r.end}`,
);
return [...ports, ...ranges].join(" ");
{
id: "destination_group_names",
accessorFn: (row) => {
const destinations = row.rules?.[0]?.destinations;
if (!destinations) return [];
return (destinations as { name?: string }[])
.map((d) => d?.name)
.filter((n): n is string => !!n);
},
filterFn: "arrIncludesSome",
},
filterFn: "includesString",
},
{
id: "has_posture_checks",
accessorFn: (row) =>
(row.source_posture_checks?.length ?? 0) > 0 ? "with" : "without",
filterFn: "equalsString",
},
{
id: "direction_filter",
accessorFn: (row) => !!row.rules?.[0]?.bidirectional,
},
{
id: "actions",
accessorKey: "id",
header: "",
cell: ({ cell }) => <AccessControlActionCell policy={cell.row.original} />,
},
];
{
id: "protocol_filter",
accessorFn: (row) => [row.rules?.[0]?.protocol || "all"],
filterFn: "arrIncludesSome",
},
{
id: "ports_filter",
accessorFn: (row) => {
const rule = row.rules?.[0];
const ports = rule?.ports || [];
const ranges = (rule?.port_ranges || []).map(
(r) => `${r.start}-${r.end}`,
);
return [...ports, ...ranges].join(" ");
},
filterFn: "includesString",
},
{
id: "has_posture_checks",
accessorFn: (row) =>
(row.source_posture_checks?.length ?? 0) > 0 ? "with" : "without",
filterFn: "equalsString",
},
{
id: "direction_filter",
accessorFn: (row) => !!row.rules?.[0]?.bidirectional,
},
{
id: "actions",
accessorKey: "id",
header: "",
cell: ({ cell }) => <AccessControlActionCell policy={cell.row.original} />,
},
];
}
export default function AccessControlTable({
policies,
@@ -241,6 +249,8 @@ export default function AccessControlTable({
const [firewallGPTOpen, setFirewallGPTOpen] = useState(false);
const t = useTranslations("policies");
const tCommon = useTranslations("common");
const [showTemporaryPolicies, setShowTemporaryPolicies] = useState(false);
const withTemporaryPolicies = useCallback(
@@ -280,37 +290,37 @@ export default function AccessControlTable({
// Inactive ButtonGroup. Routed through the consolidated Filters UI.
const statusOptions = useMemo<RadioOption<boolean | undefined>[]>(
() => [
{ value: undefined, label: "All", dotClass: "bg-nb-gray-500" },
{ value: true, label: "Enabled", dotClass: "bg-green-500" },
{ value: false, label: "Disabled", dotClass: "bg-nb-gray-700" },
{ value: undefined, label: tCommon("all"), dotClass: "bg-nb-gray-500" },
{ value: true, label: tCommon("enabled"), dotClass: "bg-green-500" },
{ value: false, label: tCommon("disabled"), dotClass: "bg-nb-gray-700" },
],
[],
);
const protocolOptions = useMemo<CheckboxOption<string>[]>(
() => [
{ value: "tcp", label: "TCP" },
{ value: "udp", label: "UDP" },
{ value: "icmp", label: "ICMP" },
{ value: "netbird-ssh", label: "NetBird SSH" },
{ value: "tcp", label: t("tcp") },
{ value: "udp", label: t("udp") },
{ value: "icmp", label: t("icmp") },
{ value: "netbird-ssh", label: t("netbirdSsh") },
],
[],
);
const postureOptions = useMemo<RadioOption<string | undefined>[]>(
() => [
{ value: undefined, label: "All" },
{ value: "with", label: "With" },
{ value: "without", label: "Without" },
{ value: undefined, label: tCommon("all") },
{ value: "with", label: t("filterWith") },
{ value: "without", label: t("filterWithout") },
],
[],
);
const directionOptions = useMemo<RadioOption<boolean | undefined>[]>(
() => [
{ value: undefined, label: "All" },
{ value: true, label: "Bidirectional" },
{ value: false, label: "One-way" },
{ value: undefined, label: tCommon("all") },
{ value: true, label: t("bidirectional") },
{ value: false, label: t("oneWay") },
],
[],
);
@@ -342,7 +352,7 @@ export default function AccessControlTable({
() => [
{
id: "enabled",
label: "Status",
label: tCommon("status"),
renderPicker: (p) => (
<RadioPicker
value={p.value as boolean | undefined}
@@ -356,7 +366,7 @@ export default function AccessControlTable({
},
{
id: "source_group_names",
label: "Sources",
label: t("sources"),
renderPicker: (p) => (
<GroupsPicker
value={p.value as string[] | undefined}
@@ -369,7 +379,7 @@ export default function AccessControlTable({
},
{
id: "destination_group_names",
label: "Destinations",
label: t("destinations"),
renderPicker: (p) => (
<GroupsPicker
value={p.value as string[] | undefined}
@@ -382,7 +392,7 @@ export default function AccessControlTable({
},
{
id: "direction_filter",
label: "Direction",
label: t("direction"),
renderPicker: (p) => (
<RadioPicker
value={p.value as boolean | undefined}
@@ -396,7 +406,7 @@ export default function AccessControlTable({
},
{
id: "protocol_filter",
label: "Protocol",
label: t("protocol"),
renderPicker: (p) => (
<CheckboxListPicker
value={p.value as string[] | undefined}
@@ -409,25 +419,25 @@ export default function AccessControlTable({
formatCheckboxChip(
v as string[] | undefined,
protocolOptions,
"protocols",
t("protocols"),
),
},
{
id: "ports_filter",
label: "Port",
label: t("filterPort"),
renderPicker: (p) => (
<TextInputPicker
value={p.value as string | undefined}
onChange={p.onChange}
close={p.close}
placeholder={"e.g. 443"}
placeholder={t("portsPlaceholder")}
/>
),
formatChip: (v) => formatTextChip(v as string | undefined),
},
{
id: "has_posture_checks",
label: "Posture Checks",
label: t("filterPostureChecks"),
renderPicker: (p) => (
<RadioPicker
value={p.value as string | undefined}
@@ -446,6 +456,8 @@ export default function AccessControlTable({
postureOptions,
directionOptions,
tableGroups,
t,
tCommon,
],
);
@@ -482,12 +494,12 @@ export default function AccessControlTable({
]
: undefined
}
text={"Access Control Policies"}
text={t("tableHeading")}
sorting={sorting}
setSorting={setSorting}
initialPageSize={25}
showResetFilterButton={false}
columns={AccessControlTableColumns}
columns={createAccessControlTableColumns(t, tCommon)}
aboveTable={(table) => (
<TableFilterChips table={table} filters={filterDefs} />
)}
@@ -510,15 +522,13 @@ export default function AccessControlTable({
setEditModal(true);
setCurrentCellClicked(cell);
}}
searchPlaceholder={"Search by name and description..."}
searchPlaceholder={t("searchByNameAndDescription")}
getStartedCard={
isGroupPage ? (
<NoResults
className={"py-4"}
title={"This group is not used within any policies yet"}
description={
"Assign this group as either a source or destination inside a policy to see them listed here."
}
title={t("noPoliciesForGroup")}
description={t("noPoliciesForGroupDescription")}
icon={
<AccessControlIcon size={20} className={"fill-nb-gray-300"} />
}
@@ -531,7 +541,7 @@ export default function AccessControlTable({
disabled={!permission.policies.create}
>
<PlusCircle size={16} />
Add Policy
{t("addPolicy")}
</Button>
</AccessControlModal>
</div>
@@ -550,10 +560,8 @@ export default function AccessControlTable({
size={"large"}
/>
}
title={"Create New Policy"}
description={
"It looks like you don't have any policies yet. Policies can allow connections by specific protocol and ports."
}
title={t("createNewPolicy")}
description={t("createNewPolicyDescription")}
button={
<div className={"flex gap-4 items-center justify-center"}>
<FirewallGPTButton onClick={() => setFirewallGPTOpen(true)} />
@@ -563,21 +571,21 @@ export default function AccessControlTable({
disabled={!permission.policies.create}
>
<PlusCircle size={16} />
Add Policy
{t("addPolicy")}
</Button>
</AccessControlModal>
</div>
}
learnMore={
<>
Learn more about
{t("learnMoreAbout")}
<InlineLink
href={
"https://docs.netbird.io/how-to/manage-network-access"
}
target={"_blank"}
>
Access Controls
{t("accessControls")}
<ExternalLinkIcon size={12} />
</InlineLink>
</>
@@ -598,7 +606,7 @@ export default function AccessControlTable({
data-testid="open-add-policy"
>
<PlusCircle size={16} />
Add Policy
{t("addPolicy")}
</Button>
</AccessControlModal>
</div>
@@ -628,9 +636,7 @@ export default function AccessControlTable({
<FullTooltip
content={
<div className={"max-w-sm text-xs"}>
Show temporary policies created by the NetBird browser
client. These policies are ephemeral and will be deleted
automatically after a short period of time.
{t("temporaryPoliciesTooltip")}
</div>
}
>

View File

@@ -4,6 +4,7 @@ import useFetchApi, { useApiCall } from "@utils/api";
import { merge, orderBy, uniqBy } from "lodash";
import { useEffect, useMemo, useRef, useState } from "react";
import { useSWRConfig } from "swr";
import { useTranslations } from "next-intl";
import { usePolicies } from "@/contexts/PoliciesProvider";
import { Group } from "@/interfaces/Group";
import {
@@ -43,6 +44,7 @@ export const useAccessControl = ({
initialPorts,
initialDestinationResource,
}: Props = {}) => {
const t = useTranslations("policies");
const { data: allPostureChecks, isLoading: isPostureChecksLoading } =
useFetchApi<PostureCheck[]>("/posture-checks");
@@ -320,13 +322,13 @@ export const useAccessControl = ({
mutate("/policies");
onSuccess && onSuccess(p);
},
"The policy was successfully saved",
t("policySaveSuccess"),
);
} else {
notify({
title: "Create Access Control Policy",
description: "Policy was created successfully.",
loadingMessage: "Creating your policy...",
title: t("createPolicyTitle"),
description: t("createPolicySuccess"),
loadingMessage: t("createPolicyLoading"),
promise: policyRequest.post(policyObj).then((policy) => {
mutate("/policies");
onSuccess && onSuccess(policy);

File diff suppressed because it is too large Load Diff

View File

@@ -4,6 +4,7 @@ import TextWithTooltip from "@components/ui/TextWithTooltip";
import { cn, generateColorFromUser } from "@utils/helpers";
import dayjs from "dayjs";
import { AlertCircle, ArrowUpRight, Cog, PlusIcon, XIcon } from "lucide-react";
import { useTranslations } from "next-intl";
import React, { useMemo } from "react";
import { useUsers } from "@/contexts/UsersProvider";
import { ActivityEvent } from "@/interfaces/ActivityEvent";
@@ -23,6 +24,7 @@ const ActionIcons: Record<ActionColor, React.ReactNode> = {
export const ActivityEntryRow = ({ event }: { event: ActivityEvent }) => {
const { users } = useUsers();
const t = useTranslations("activity");
const getActivityUser = () => {
let user;
@@ -95,7 +97,7 @@ export const ActivityEntryRow = ({ event }: { event: ActivityEvent }) => {
<span className={"text-sm text-nb-gray-200"}>
<TextWithTooltip
text={user?.name || user?.id || "System"}
text={user?.name || user?.id || t("system")}
maxChars={20}
/>
</span>
@@ -105,7 +107,7 @@ export const ActivityEntryRow = ({ event }: { event: ActivityEvent }) => {
{isExternal && (
<span className={"flex items-center"}>
<SmallBadge
text={"External"}
text={t("external")}
variant={"sky"}
className={
"text-[10px] py-[0.2rem] px-1.5 rounded-full leading-none -top-0"

View File

@@ -7,6 +7,7 @@ import { cn } from "@utils/helpers";
import { Command, CommandGroup, CommandInput, CommandList } from "cmdk";
import { trim, uniqBy } from "lodash";
import { ChevronsUpDown, Layers, SearchIcon } from "lucide-react";
import { useTranslations } from "next-intl";
import * as React from "react";
import { useMemo, useState } from "react";
import { useElementSize } from "@/hooks/useElementSize";
@@ -27,6 +28,7 @@ export function ActivityEventCodeSelector({
popoverWidth = 400,
events,
}: MultiSelectProps) {
const t = useTranslations("activity");
const searchRef = React.useRef<HTMLInputElement>(null);
const [inputRef, { width }] = useElementSize<HTMLButtonElement>();
const [search, setSearch] = useState("");
@@ -50,7 +52,7 @@ export function ActivityEventCodeSelector({
activity_code: event.activity_code,
activity: event.activity,
group: event.activity_code.startsWith("service.user")
? "Service User"
? t("serviceUser")
: event.activity_code.split(".")[0],
};
});
@@ -81,9 +83,9 @@ export function ActivityEventCodeSelector({
<Layers size={16} className={"shrink-0"} />
<div className={"w-full flex justify-between"}>
{values.length > 0 ? (
<div>{values.length} Event(s)</div>
<div>{t("eventCount", { count: values.length })}</div>
) : (
"All Event Types"
t("allEventTypes")
)}
<div className={"pl-2"}>
<ChevronsUpDown size={18} className={"shrink-0"} />
@@ -122,7 +124,7 @@ export function ActivityEventCodeSelector({
ref={searchRef}
value={search}
onValueChange={setSearch}
placeholder={"Search event..."}
placeholder={t("searchEvent")}
/>
<div
className={

View File

@@ -144,7 +144,7 @@ export default function ActivityTable({
events={events ?? []}
/>
),
formatChip: (v) => formatActivityTypeChip(v as string[] | undefined),
formatChip: (v) => formatActivityTypeChip(v as string[] | undefined, t),
},
{
id: "initiator_email",

View File

@@ -7,6 +7,7 @@ import { cn } from "@utils/helpers";
import { Command, CommandGroup, CommandInput, CommandList } from "cmdk";
import { trim, uniqBy } from "lodash";
import { SearchIcon } from "lucide-react";
import { useTranslations } from "next-intl";
import * as React from "react";
import { useMemo, useRef } from "react";
import { ActivityEvent } from "@/interfaces/ActivityEvent";
@@ -34,6 +35,7 @@ export function ActivityTypePicker({
onChange,
events,
}: Readonly<Props>) {
const t = useTranslations("activity");
const searchRef = useRef<HTMLInputElement>(null);
const selected = value ?? [];
@@ -43,7 +45,7 @@ export function ActivityTypePicker({
activity_code: event.activity_code,
activity: event.activity,
group: event.activity_code.startsWith("service.user")
? "Service User"
? t("serviceUser")
: event.activity_code.split(".")[0],
}));
return items.reduce<Record<string, GroupedItem[]>>((acc, item) => {
@@ -80,7 +82,7 @@ export function ActivityTypePicker({
"dark:placeholder:text-nb-gray-400 font-light placeholder:text-neutral-500 pl-9",
)}
ref={searchRef}
placeholder={"Search event..."}
placeholder={t("searchEvent")}
/>
<div
className={
@@ -150,8 +152,9 @@ export function ActivityTypePicker({
export function formatActivityTypeChip(
value: string[] | undefined,
t?: (key: string, params?: Record<string, any>) => string,
): string | null {
if (!value || value.length === 0) return null;
if (value.length === 1) return value[0];
return `${value.length} types`;
return t ? t("typeCount", { count: value.length }) : `${value.length} types`;
}

View File

@@ -9,6 +9,7 @@ import { useSearch } from "@hooks/useSearch";
import { generateColorFromString } from "@utils/helpers";
import { sortBy, uniqBy } from "lodash";
import { ChevronsUpDown, Cog, UserCircle2 } from "lucide-react";
import { useTranslations } from "next-intl";
import * as React from "react";
import { useMemo, useState } from "react";
import { useElementSize } from "@/hooks/useElementSize";
@@ -48,11 +49,12 @@ export function UsersDropdownSelector({
popoverWidth = 250,
options,
}: Readonly<Props>) {
const t = useTranslations("activity");
const [filteredItems, search, setSearch] = useSearch(
options.concat({
id: "all-users",
name: "All Users",
email: "Include all users",
name: t("allUsers"),
email: t("includeAllUsers"),
}),
searchPredicate,
{ filter: true, debounce: 150 },
@@ -107,7 +109,7 @@ export function UsersDropdownSelector({
{!selectedUser ? (
<React.Fragment>
<UserCircle2 size={16} />
All Users
{t("allUsers")}
</React.Fragment>
) : (
<React.Fragment>
@@ -136,7 +138,7 @@ export function UsersDropdownSelector({
<TextWithTooltip
text={
selectedUser?.email === "NetBird"
? "System"
? t("system")
: selectedUser?.name
}
maxChars={20}
@@ -165,14 +167,14 @@ export function UsersDropdownSelector({
<DropdownInput
value={search}
onChange={setSearch}
placeholder={"Search user..."}
placeholder={t("searchUser")}
hideEnterIcon={true}
/>
{options.length == 0 && !search && (
<div className={"max-w-xs mx-auto"}>
<DropdownInfoText>
{"No users available to select."}
{t("noUsersAvailable")}
</DropdownInfoText>
</div>
)}
@@ -180,7 +182,7 @@ export function UsersDropdownSelector({
{filteredItems.length == 0 && search != "" && (
<div className={"px-10"}>
<DropdownInfoText>
There are no users matching your search.
{t("noUsersMatching")}
</DropdownInfoText>
</div>
)}
@@ -227,7 +229,7 @@ export function UsersDropdownSelector({
>
<TextWithTooltip
text={
isSystemUser ? "System" : user?.name || user?.id
isSystemUser ? t("system") : user?.name || user?.id
}
maxChars={20}
/>
@@ -246,7 +248,7 @@ export function UsersDropdownSelector({
{user.external && (
<span className={"flex items-center ml-auto relative"}>
<SmallBadge
text={"External"}
text={t("external")}
variant={"sky"}
className={
"text-[8.5px] py-[0.15rem] px-[.32rem] leading-none rounded-full -top-0"

View File

@@ -1,3 +1,5 @@
"use client";
import Button from "@components/Button";
import FancyToggleSwitch from "@components/FancyToggleSwitch";
import HelpText from "@components/HelpText";
@@ -113,9 +115,9 @@ export function NameserverModalContent({
const update = async (groupIds: string[]) => {
notify({
title: "Update Nameserver",
description: "Nameserver was updated successfully.",
loadingMessage: "Updating your nameserver...",
title: t("updateNameserverNotify"),
description: t("nameserverUpdatedSuccess"),
loadingMessage: t("updatingNameserver"),
promise: nsRequest
.put(
{
@@ -139,9 +141,9 @@ export function NameserverModalContent({
const create = async (groupIds: string[]) => {
notify({
title: "Create Nameserver",
description: "Nameserver was created successfully.",
loadingMessage: "Creating your nameserver...",
title: t("createNameserver"),
description: t("nameserverCreatedSuccess"),
loadingMessage: t("creatingNameserver"),
promise: nsRequest
.post({
name: name,
@@ -223,7 +225,7 @@ export function NameserverModalContent({
}, [domains]);
const nameLengthError = useMemo(() => {
if (name.length > 40) return "Name should be less than 40 characters";
if (name.length > 40) return t("nameLengthError");
return "";
}, [name]);
@@ -581,6 +583,7 @@ function NameserverInput({
onError?: (error: boolean) => void;
disabled?: boolean;
}>) {
const t = useTranslations("dns");
const [ip, setIP] = useState(value.ip);
const [port, setPort] = useState<string>(value.port.toString());
@@ -601,7 +604,7 @@ function NameserverInput({
const validCIDR = cidr.isValidAddress(ip);
if (!validCIDR) {
onError && onError(true);
return "Please enter a valid IP, e.g., 192.168.1.0";
return t("validIPError");
}
onError && onError(false);
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -617,7 +620,7 @@ function NameserverInput({
<div className={"w-full"}>
<Input
customPrefix={"IP"}
placeholder={"e.g., 172.16.0.0"}
placeholder={t("ipPlaceholder")}
maxWidthClass={"w-full"}
value={ip}
className={"font-mono !text-[13px]"}
@@ -630,7 +633,7 @@ function NameserverInput({
<Input
maxWidthClass={"min-w-[150px] max-w-[150px]"}
customPrefix={"Port"}
customPrefix={t("port")}
placeholder={"53"}
value={port}
type={"number"}

View File

@@ -1,3 +1,5 @@
"use client";
import InlineLink from "@components/InlineLink";
import { Modal, ModalContent, ModalTrigger } from "@components/modal/Modal";
import { cn } from "@utils/helpers";
@@ -10,6 +12,7 @@ import Quad9Logo from "@/assets/nameservers/quad9.svg";
import { Group } from "@/interfaces/Group";
import { NameserverGroup, NameserverPresets } from "@/interfaces/Nameserver";
import NameserverModal from "@/modules/dns/nameservers/NameserverModal";
import { useTranslations } from "next-intl";
type Props = {
children: React.ReactNode;
@@ -63,6 +66,9 @@ type ModalProps = {
export function NameserverTemplateModalContent({
onePresetSelection,
}: Readonly<ModalProps>) {
const t = useTranslations("dns");
const tCommon = useTranslations("common");
return (
<ModalContent maxWidthClass={"max-w-xl"} showClose={true}>
<div className={"px-8 py-3 flex flex-col gap-6 mt-4"}>
@@ -100,10 +106,8 @@ export function NameserverTemplateModalContent({
<NameserverTemplate
onClick={() => onePresetSelection(NameserverPresets.Default)}
icon={<GlobeIcon size={30} className={"text-netbird"} />}
title={"Custom DNS"}
description={
"Use custom nameservers to resolve domains in your network. You can either use a public DNS or your own nameservers."
}
title={t("customDNS")}
description={t("customDNSDescription")}
data-testid="nameserver-preset-custom"
/>
</div>
@@ -131,6 +135,7 @@ function NameserverTemplate({
hrefTitle?: string;
"data-testid"?: string;
}>) {
const tCommon = useTranslations("common");
return (
<button
className={
@@ -165,7 +170,7 @@ function NameserverTemplate({
e.stopPropagation();
}}
>
{hrefTitle || "Learn more"}
{hrefTitle || tCommon("learnMore")}
<ExternalLinkIcon size={12} />
</InlineLink>
</div>

View File

@@ -1,3 +1,5 @@
"use client";
import Button from "@components/Button";
import {
DropdownMenu,
@@ -15,6 +17,7 @@ import { useSWRConfig } from "swr";
import { useDialog } from "@/contexts/DialogProvider";
import { usePermissions } from "@/contexts/PermissionsProvider";
import { NameserverGroup } from "@/interfaces/Nameserver";
import { useTranslations } from "next-intl";
type Props = {
ns: NameserverGroup;
@@ -25,6 +28,8 @@ export default function NameserverActionCell({ ns }: Readonly<Props>) {
const { mutate } = useSWRConfig();
const { permission } = usePermissions();
const [open, setOpen] = useState(false);
const t = useTranslations("dns");
const tCommon = useTranslations("common");
const canUpdate = permission.nameservers.update;
const canDelete = permission.nameservers.delete;
@@ -33,11 +38,10 @@ export default function NameserverActionCell({ ns }: Readonly<Props>) {
const enabled = !ns.enabled;
notify({
title: ns.name,
description:
"Nameserver was successfully" +
(enabled ? " enabled" : " disabled") +
".",
loadingMessage: "Updating your nameserver...",
description: t("nameserverToggleSuccess", {
status: enabled ? tCommon("enabled").toLowerCase() : tCommon("disabled").toLowerCase(),
}),
loadingMessage: t("nameserverToggleLoading"),
promise: nsRequest
.put(
{
@@ -60,22 +64,21 @@ export default function NameserverActionCell({ ns }: Readonly<Props>) {
const deleteRule = async () => {
notify({
title: "Nameserver " + ns.name,
description: "The nameserver was successfully removed.",
title: tCommon("delete") + " " + ns.name,
description: t("nameserverDeletedSuccess"),
promise: nsRequest.del("", `/${ns.id}`).then(() => {
mutate("/dns/nameservers");
}),
loadingMessage: "Deleting the nameserver...",
loadingMessage: t("deletingNameserver"),
});
};
const openConfirm = async () => {
const choice = await confirm({
title: `Delete '${ns.name}'?`,
description:
"Are you sure you want to delete this nameserver? This action cannot be undone.",
confirmText: "Delete",
cancelText: "Cancel",
title: t("confirmDeleteNameserverTitle", { name: ns.name }),
description: t("confirmDeleteNameserver"),
confirmText: tCommon("delete"),
cancelText: tCommon("cancel"),
type: "danger",
});
if (!choice) return;
@@ -95,7 +98,7 @@ export default function NameserverActionCell({ ns }: Readonly<Props>) {
<Button
variant={"secondary"}
className={"!px-3"}
aria-label={"Nameserver actions"}
aria-label={t("nameserverActionsAria")}
data-testid={"nameserver-actions"}
>
<MoreVertical size={16} className={"shrink-0"} />
@@ -112,7 +115,7 @@ export default function NameserverActionCell({ ns }: Readonly<Props>) {
>
<div className={"flex gap-3 items-center"}>
<PowerIcon size={14} className={"shrink-0"} />
{ns.enabled ? "Disable" : "Enable"}
{ns.enabled ? t("disable") : t("enable")}
</div>
</DropdownMenuItem>
<DropdownMenuSeparator />
@@ -124,7 +127,7 @@ export default function NameserverActionCell({ ns }: Readonly<Props>) {
>
<div className={"flex gap-3 items-center"}>
<Trash2 size={14} className={"shrink-0"} />
Delete
{tCommon("delete")}
</div>
</DropdownMenuItem>
</DropdownMenuContent>

View File

@@ -1,3 +1,5 @@
"use client";
import Button from "@components/Button";
import Card from "@components/Card";
import InlineLink from "@components/InlineLink";
@@ -40,12 +42,13 @@ import NameserverDistributionGroupsCell from "@/modules/dns/nameservers/table/Na
import NameserverMatchDomainsCell from "@/modules/dns/nameservers/table/NameserverMatchDomainsCell";
import NameserverNameCell from "@/modules/dns/nameservers/table/NameserverNameCell";
import NameserverNameserversCell from "@/modules/dns/nameservers/table/NameserverNameserversCell";
import { useTranslations } from "next-intl";
export const NameserverGroupTableColumns: ColumnDef<NameserverGroup>[] = [
const getColumns = (t: (key: string, values?: any) => string, tCommon: (key: string, values?: any) => string): ColumnDef<NameserverGroup>[] => [
{
accessorKey: "name",
header: ({ column }) => {
return <DataTableHeader column={column}>Name</DataTableHeader>;
return <DataTableHeader column={column}>{tCommon("name")}</DataTableHeader>;
},
sortingFn: "text",
cell: ({ row }) => <NameserverNameCell ns={row.original} />,
@@ -70,7 +73,7 @@ export const NameserverGroupTableColumns: ColumnDef<NameserverGroup>[] = [
accessorFn: (row) => row.domains?.length || 0,
id: "domains",
header: ({ column }) => {
return <DataTableHeader column={column}>Match Domains</DataTableHeader>;
return <DataTableHeader column={column}>{t("matchDomains")}</DataTableHeader>;
},
cell: ({ row }) => <NameserverMatchDomainsCell ns={row.original} />,
},
@@ -78,7 +81,7 @@ export const NameserverGroupTableColumns: ColumnDef<NameserverGroup>[] = [
accessorFn: (row) => row.nameservers?.length || 0,
id: "nameservers",
header: ({ column }) => {
return <DataTableHeader column={column}>Nameservers</DataTableHeader>;
return <DataTableHeader column={column}>{t("nameservers")}</DataTableHeader>;
},
cell: ({ row }) => <NameserverNameserversCell ns={row.original} />,
},
@@ -86,7 +89,7 @@ export const NameserverGroupTableColumns: ColumnDef<NameserverGroup>[] = [
accessorFn: (row) => row.groups?.length || 0,
id: "groups",
header: ({ column }) => {
return <DataTableHeader column={column}>Groups</DataTableHeader>;
return <DataTableHeader column={column}>{tCommon("distributionGroups")}</DataTableHeader>;
},
cell: ({ row }) => <NameserverDistributionGroupsCell ns={row.original} />,
},
@@ -123,6 +126,8 @@ export default function NameserverGroupTable({
const path = usePathname();
const { permission } = usePermissions();
const { groups } = useGroups();
const t = useTranslations("dns");
const tCommon = useTranslations("common");
const nameserverGroupsWithNames = useMemo(() => {
if (!nameserverGroups) return [];
@@ -162,18 +167,18 @@ export default function NameserverGroupTable({
const statusOptions = useMemo<RadioOption<boolean | undefined>[]>(
() => [
{ value: undefined, label: "All", dotClass: "bg-nb-gray-500" },
{ value: true, label: "Active", dotClass: "bg-green-500" },
{ value: false, label: "Inactive", dotClass: "bg-nb-gray-700" },
{ value: undefined, label: tCommon("all"), dotClass: "bg-nb-gray-500" },
{ value: true, label: tCommon("active"), dotClass: "bg-green-500" },
{ value: false, label: tCommon("inactive"), dotClass: "bg-nb-gray-700" },
],
[],
[tCommon],
);
const filterDefs = useMemo<TableFilterDef[]>(
() => [
{
id: "enabled",
label: "Status",
label: tCommon("status"),
renderPicker: (p) => (
<RadioPicker
value={p.value as boolean | undefined}
@@ -187,7 +192,7 @@ export default function NameserverGroupTable({
},
{
id: "group_names_filter",
label: "Groups",
label: tCommon("distributionGroups"),
renderPicker: (p) => (
<GroupsPicker
value={p.value as string[] | undefined}
@@ -199,7 +204,7 @@ export default function NameserverGroupTable({
formatChip: (v) => formatGroupsChip(v as string[] | undefined),
},
],
[statusOptions, tableGroups],
[statusOptions, tableGroups, tCommon],
);
return (
@@ -215,7 +220,7 @@ export default function NameserverGroupTable({
<DataTable
headingTarget={headingTarget}
isLoading={isLoading}
text={"Network Routes"}
text={t("nameservers")}
sorting={sorting}
setSorting={setSorting}
wrapperComponent={isGroupPage ? Card : undefined}
@@ -243,18 +248,16 @@ export default function NameserverGroupTable({
setEditModal(true);
setCurrentCellClicked(cell);
}}
columns={NameserverGroupTableColumns}
columns={getColumns(t, tCommon)}
data={nameserverGroupsWithNames}
searchPlaceholder={"Search by name, domains or nameservers..."}
searchPlaceholder={t("searchNameserverPlaceholder")}
getStartedCard={
isGroupPage ? (
<NoResults
icon={<DNSIcon className={"fill-nb-gray-200"} size={20} />}
className={"py-4"}
title={"This group is not used within any nameservers yet"}
description={
"Assign this group as a distribution group in your nameservers to see them listed here."
}
title={t("noNameserversGroupTitle")}
description={t("noNameserversGroupDesc")}
>
<NameserverTemplateModal distributionGroups={distributionGroups}>
<Button
@@ -263,7 +266,7 @@ export default function NameserverGroupTable({
disabled={!permission.nameservers.create}
>
<PlusCircle size={16} />
Add Nameserver
{t("createNameserver")}
</Button>
</NameserverTemplateModal>
</NoResults>
@@ -276,10 +279,8 @@ export default function NameserverGroupTable({
size={"large"}
/>
}
title={"Create Nameserver"}
description={
"It looks like you don't have any nameservers. Get started by adding one to your network. Select a predefined or add your custom nameservers."
}
title={t("createNameserver")}
description={t("noNameserversGetStartedDesc")}
button={
<div className={"flex flex-col"}>
<div>
@@ -293,7 +294,7 @@ export default function NameserverGroupTable({
data-testid="open-add-nameserver"
>
<PlusCircle size={16} />
Add Nameserver
{t("createNameserver")}
</Button>
</NameserverTemplateModal>
</div>
@@ -301,14 +302,14 @@ export default function NameserverGroupTable({
}
learnMore={
<>
Learn more about
{t("learnMoreAbout")}
<InlineLink
href={
"https://docs.netbird.io/how-to/manage-dns-in-your-network"
}
target={"_blank"}
>
DNS
{t("dns")}
<ExternalLinkIcon size={12} />
</InlineLink>
</>
@@ -327,7 +328,7 @@ export default function NameserverGroupTable({
data-testid="open-add-nameserver"
>
<PlusCircle size={16} />
Add Nameserver
{t("createNameserver")}
</Button>
</NameserverTemplateModal>
)}

View File

@@ -1,3 +1,5 @@
"use client";
import Badge from "@components/Badge";
import {
Tooltip,
@@ -8,11 +10,13 @@ import {
import { Server } from "lucide-react";
import React from "react";
import { NameserverGroup } from "@/interfaces/Nameserver";
import { useTranslations } from "next-intl";
type Props = {
ns: NameserverGroup;
};
export default function NameserverNameserversCell({ ns }: Props) {
const t = useTranslations("dns");
const nameservers = ns.nameservers ?? [];
if (nameservers.length > 3) {
@@ -22,7 +26,7 @@ export default function NameserverNameserversCell({ ns }: Props) {
<TooltipTrigger asChild={true}>
<Badge variant={"gray"} className={"font-mono cursor-help"}>
<Server size={10} className={"mr-1"} />
{nameservers.length} Servers
{t("serverCount", { count: nameservers.length })}
</Badge>
</TooltipTrigger>
<TooltipContent className={"p-3"}>

View File

@@ -1,3 +1,5 @@
"use client";
import Button from "@components/Button";
import HelpText from "@components/HelpText";
import InlineLink from "@components/InlineLink";
@@ -108,7 +110,7 @@ export function DNSRecordModalContent({
allowOnlyTld: true,
});
if (!valid) {
return "Please enter a valid domain, e.g. example.com or intra.example.com";
return tCommon("validDomainError");
}
}, [domain]);
@@ -116,7 +118,7 @@ export function DNSRecordModalContent({
if (recordValue === "" || type !== "A") return "";
const valid = Address4.isValid(recordValue);
if (!valid) {
return "Please enter a valid IPv4 address, e.g. 192.168.1.1";
return t("validIPv4Error");
}
}, [recordValue, type]);
@@ -124,7 +126,7 @@ export function DNSRecordModalContent({
if (recordValue === "" || type !== "AAAA") return "";
const valid = Address6.isValid(recordValue);
if (!valid) {
return "Please enter a valid IPv6 address, e.g. 2001:0db8:85a3::8a2e:0370:7334";
return t("validIPv6Error");
}
}, [recordValue, type]);
@@ -135,7 +137,7 @@ export function DNSRecordModalContent({
allowOnlyTld: false,
});
if (!valid) {
return "Please enter a valid domain, e.g. example.com or server.example.com";
return t("validCnameError");
}
}, [recordValue, type]);
@@ -176,8 +178,8 @@ export function DNSRecordModalContent({
title={record ? t("updateDNSRecord") : t("addDNSRecord")}
description={
record
? `Update record of '${zone.domain}' zone`
: `Add new record to the '${zone.domain}' zone`
? t("updateRecordDesc", { zone: zone.domain })
: t("addRecordDesc", { zone: zone.domain })
}
icon={<GlobeIcon size={16} />}
/>
@@ -242,7 +244,7 @@ export function DNSRecordModalContent({
<Label>{t("ipv4Address")}</Label>
<Input
className={"mt-1.5 font-mono text-[0.82rem]"}
placeholder={"192.168.1.1"}
placeholder={t("ipv4Placeholder")}
errorTooltip={false}
errorTooltipPosition={"top"}
error={ipv4Error}
@@ -259,7 +261,7 @@ export function DNSRecordModalContent({
<Label>{t("ipv6Address")}</Label>
<Input
className={"mt-1.5 font-mono text-[0.82rem]"}
placeholder={"2001:0db8:85a3::8a2e:0370:7334"}
placeholder={t("ipv6Placeholder")}
errorTooltip={false}
errorTooltipPosition={"top"}
error={ipv6Error}
@@ -276,7 +278,7 @@ export function DNSRecordModalContent({
<Label>{t("targetDomain")}</Label>
<Input
className={"mt-1.5"}
placeholder={"e.g., example.com or intra.example.com"}
placeholder={t("cnamePlaceholder")}
errorTooltip={false}
errorTooltipPosition={"top"}
error={cnameError}
@@ -302,16 +304,16 @@ export function DNSRecordModalContent({
</div>
</SelectTrigger>
<SelectContent>
<SelectItem value="60">{getTTLLabel(60)}</SelectItem>
<SelectItem value="120">{getTTLLabel(120)}</SelectItem>
<SelectItem value="300">{getTTLLabel(300)}</SelectItem>
<SelectItem value="600">{getTTLLabel(600)}</SelectItem>
<SelectItem value="900">{getTTLLabel(900)}</SelectItem>
<SelectItem value="1800">{getTTLLabel(1800)}</SelectItem>
<SelectItem value="3600">{getTTLLabel(3600)}</SelectItem>
<SelectItem value="7200">{getTTLLabel(7200)}</SelectItem>
<SelectItem value="43200">{getTTLLabel(43200)}</SelectItem>
<SelectItem value="86400">{getTTLLabel(86400)}</SelectItem>
<SelectItem value="60">{getTTLLabel(60, t)}</SelectItem>
<SelectItem value="120">{getTTLLabel(120, t)}</SelectItem>
<SelectItem value="300">{getTTLLabel(300, t)}</SelectItem>
<SelectItem value="600">{getTTLLabel(600, t)}</SelectItem>
<SelectItem value="900">{getTTLLabel(900, t)}</SelectItem>
<SelectItem value="1800">{getTTLLabel(1800, t)}</SelectItem>
<SelectItem value="3600">{getTTLLabel(3600, t)}</SelectItem>
<SelectItem value="7200">{getTTLLabel(7200, t)}</SelectItem>
<SelectItem value="43200">{getTTLLabel(43200, t)}</SelectItem>
<SelectItem value="86400">{getTTLLabel(86400, t)}</SelectItem>
</SelectContent>
</Select>
</div>
@@ -350,16 +352,22 @@ export function DNSRecordModalContent({
);
}
export const getTTLLabel = (seconds: number): string => {
if (seconds < 60) return `${seconds} Sec.`;
export const getTTLLabel = (seconds: number, t?: (key: string, values?: any) => string): string => {
const s = t ? t("sec") : "Sec.";
const m = t ? t("min") : "Min.";
const h = t ? t("hour") : "Hour";
const hs = t ? t("hours") : "Hours";
const d = t ? t("day") : "Day";
const ds = t ? t("days") : "Days";
if (seconds < 60) return `${seconds} ${s}`;
if (seconds < 3600) {
const minutes = seconds / 60;
return minutes === 1 ? "1 Min." : `${minutes} Min.`;
return `${minutes} ${m}`;
}
if (seconds < 86400) {
const hours = seconds / 3600;
return hours === 1 ? "1 Hour" : `${hours} Hours`;
return `${hours} ${hs}`;
}
const days = seconds / 86400;
return days === 1 ? "1 Day" : `${days} Days`;
return `${days} ${ds}`;
};

View File

@@ -1,3 +1,5 @@
"use client";
import Button from "@components/Button";
import FancyToggleSwitch from "@components/FancyToggleSwitch";
import HelpText from "@components/HelpText";
@@ -88,6 +90,8 @@ export function DNSZoneModalContent({
initial: initialDistributionGroups ?? zone?.distribution_groups ?? [],
});
const t = useTranslations("dns");
const domainError = useMemo(() => {
if (domain == "") return "";
const valid = validator.isValidDomain(domain, {
@@ -96,9 +100,9 @@ export function DNSZoneModalContent({
preventLeadingAndTrailingDots: true,
});
if (!valid) {
return "Please enter a valid domain, e.g. internal, company.internal or intra.example.com";
return t("validDomainErrorZone");
}
}, [domain]);
}, [domain, t]);
const handleOnSubmit = async () => {
return saveGroups().then((distributionGroups) => {
@@ -127,7 +131,6 @@ export function DNSZoneModalContent({
const canUpdateOrCreate = !domainError && groups?.length > 0 && domain !== "";
const t = useTranslations("dns");
const tCommon = useTranslations("common");
return (

View File

@@ -1,3 +1,5 @@
"use client";
import { notify } from "@components/Notification";
import { useApiCall } from "@utils/api";
import * as React from "react";
@@ -8,6 +10,7 @@ import { DNSRecord, DNSZone } from "@/interfaces/DNS";
import { Group } from "@/interfaces/Group";
import DNSRecordModal from "@/modules/dns/zones/DNSRecordModal";
import DNSZoneModal from "@/modules/dns/zones/DNSZoneModal";
import { useTranslations } from "next-intl";
type Props = {
children?: React.ReactNode;
@@ -41,6 +44,8 @@ export const DNSZonesProvider = ({ children }: Props) => {
const [initialDistributionGroups, setInitialDistributionGroups] =
useState<Group[]>();
const { confirm } = useDialog();
const t = useTranslations("dns");
const tCommon = useTranslations("common");
const createZone = async (zone: DNSZone): Promise<DNSZone> => {
const promise = zoneRequest.post(zone).then((zone) => {
@@ -49,10 +54,10 @@ export const DNSZonesProvider = ({ children }: Props) => {
});
notify({
title: `DNS Zone '${zone.domain}'`,
description: `DNS Zone was added successfully.`,
title: t("notifyZoneAddedTitle", { name: zone.domain }),
description: t("notifyZoneAddedDesc"),
promise: promise,
loadingMessage: "Adding DNS Zone...",
loadingMessage: t("notifyZoneAddedLoading"),
});
return promise;
@@ -66,10 +71,10 @@ export const DNSZonesProvider = ({ children }: Props) => {
});
notify({
title: `DNS Zone '${zone.domain}'`,
description: `DNS Zone was updated successfully.`,
title: t("notifyZoneUpdatedTitle", { name: zone.domain }),
description: t("notifyZoneUpdatedDesc"),
promise: promise,
loadingMessage: "Updating DNS Zone...",
loadingMessage: t("notifyZoneUpdatedLoading"),
});
return promise;
@@ -79,11 +84,10 @@ export const DNSZonesProvider = ({ children }: Props) => {
if (!zone?.id) return Promise.reject("Can not delete DNS Zone without ID");
const choice = await confirm({
title: `Delete zone '${zone.domain}'?`,
description:
"Are you sure you want to delete this zone? This action cannot be undone.",
confirmText: "Delete",
cancelText: "Cancel",
title: t("confirmDeleteZoneTitle", { name: zone.domain }),
description: t("confirmDeleteZoneDesc"),
confirmText: tCommon("delete"),
cancelText: tCommon("cancel"),
type: "danger",
maxWidthClass: "max-w-md",
});
@@ -95,10 +99,10 @@ export const DNSZonesProvider = ({ children }: Props) => {
});
notify({
title: `DNS Zone '${zone.domain}'`,
description: `DNS Zone was deleted successfully.`,
title: t("notifyZoneDeletedTitle", { name: zone.domain }),
description: t("notifyZoneDeletedDesc"),
promise: promise,
loadingMessage: "Deleting DNS Zone...",
loadingMessage: t("notifyZoneDeletedLoading"),
});
return promise;
@@ -118,10 +122,13 @@ export const DNSZonesProvider = ({ children }: Props) => {
});
notify({
title: `${record.type} Record '${record.name}'`,
description: `DNS Record was added successfully.`,
title: t("notifyRecordAddedTitle", {
type: record.type,
name: record.name,
}),
description: t("notifyRecordAddedDesc"),
promise: promise,
loadingMessage: "Adding DNS Record...",
loadingMessage: t("notifyRecordAddedLoading"),
});
return promise;
@@ -143,10 +150,13 @@ export const DNSZonesProvider = ({ children }: Props) => {
});
notify({
title: `${record.type} Record '${record.name}'`,
description: `DNS Record was updated successfully.`,
title: t("notifyRecordUpdatedTitle", {
type: record.type,
name: record.name,
}),
description: t("notifyRecordUpdatedDesc"),
promise: promise,
loadingMessage: "Updating DNS Record...",
loadingMessage: t("notifyRecordUpdatedLoading"),
});
return promise;
@@ -162,11 +172,10 @@ export const DNSZonesProvider = ({ children }: Props) => {
return Promise.reject("Can not delete DNS Record without ID");
const choice = await confirm({
title: `Delete record '${record.name}'?`,
description:
"Are you sure you want to delete this record? This action cannot be undone.",
confirmText: "Delete",
cancelText: "Cancel",
title: t("confirmDeleteRecordTitle", { name: record.name }),
description: t("confirmDeleteRecordDesc"),
confirmText: tCommon("delete"),
cancelText: tCommon("cancel"),
type: "danger",
maxWidthClass: "max-w-md",
});
@@ -180,10 +189,13 @@ export const DNSZonesProvider = ({ children }: Props) => {
});
notify({
title: `${record.type} Record '${record.name}'`,
description: `DNS Record was deleted successfully.`,
title: t("notifyRecordDeletedTitle", {
type: record.type,
name: record.name,
}),
description: t("notifyRecordDeletedDesc"),
promise: promise,
loadingMessage: "Deleting DNS Record...",
loadingMessage: t("notifyRecordDeletedLoading"),
});
return promise;
@@ -203,11 +215,10 @@ export const DNSZonesProvider = ({ children }: Props) => {
const askForRecord = async (zone: DNSZone) => {
const choice = await confirm({
title: `Add new record to '${zone.name}'?`,
description:
"Add either an A, AAAA or a CNAME record to control domain name resolution for your network.",
confirmText: "Add Record",
cancelText: "Later",
title: t("askForRecordTitle", { name: zone.name }),
description: t("askForRecordDesc"),
confirmText: t("addDNSRecord"),
cancelText: t("askForRecordCancel"),
type: "default",
maxWidthClass: "max-w-md",
});

View File

@@ -1,3 +1,5 @@
"use client";
import Button from "@components/Button";
import { PenSquare, Trash2 } from "lucide-react";
import * as React from "react";
@@ -5,6 +7,7 @@ import { usePermissions } from "@/contexts/PermissionsProvider";
import { DNSRecord } from "@/interfaces/DNS";
import { useDNSZones } from "@/modules/dns/zones/DNSZonesProvider";
import { useDNSZone } from "@/modules/dns/zones/records/DNSRecordsTable";
import { useTranslations } from "next-intl";
type Props = {
record: DNSRecord;
@@ -14,6 +17,7 @@ export const DNSRecordActionCell = ({ record }: Props) => {
const { permission } = usePermissions();
const { deleteRecord, openRecordModal } = useDNSZones();
const zone = useDNSZone();
const tCommon = useTranslations("common");
return (
<div className={"flex justify-end pr-4"}>
@@ -25,7 +29,7 @@ export const DNSRecordActionCell = ({ record }: Props) => {
data-testid="edit-dns-record"
>
<PenSquare size={16} />
Edit
{tCommon("edit")}
</Button>
<Button
variant={"danger-outline"}
@@ -35,7 +39,7 @@ export const DNSRecordActionCell = ({ record }: Props) => {
data-testid="delete-dns-record"
>
<Trash2 size={16} />
Delete
{tCommon("delete")}
</Button>
</div>
);

View File

@@ -1,13 +1,17 @@
"use client";
import { ClockIcon } from "lucide-react";
import * as React from "react";
import { DNSRecord } from "@/interfaces/DNS";
import { getTTLLabel } from "@/modules/dns/zones/DNSRecordModal";
import { useTranslations } from "next-intl";
type Props = {
record: DNSRecord;
};
export const DNSRecordTimeToLiveCell = ({ record }: Props) => {
const t = useTranslations("dns");
return (
<div
className={
@@ -15,7 +19,7 @@ export const DNSRecordTimeToLiveCell = ({ record }: Props) => {
}
>
<ClockIcon size={14} />
{getTTLLabel(record.ttl)}
{getTTLLabel(record.ttl, t)}
</div>
);
};

View File

@@ -1,3 +1,5 @@
"use client";
import { DataTable } from "@components/table/DataTable";
import DataTableHeader from "@components/table/DataTableHeader";
import { ColumnDef, SortingState } from "@tanstack/react-table";
@@ -8,37 +10,38 @@ import { DNSRecordContentCell } from "@/modules/dns/zones/records/DNSRecordConte
import { DNSRecordNameCell } from "@/modules/dns/zones/records/DNSRecordNameCell";
import { DNSRecordTimeToLiveCell } from "@/modules/dns/zones/records/DNSRecordTimeToLiveCell";
import { DNSRecordTypeCell } from "@/modules/dns/zones/records/DNSRecordTypeCell";
import { useTranslations } from "next-intl";
type Props = {
zone: DNSZone;
};
export const DNSRecordsTableColumns: ColumnDef<DNSRecord>[] = [
const getColumns = (t: (key: string, values?: any) => string, tCommon: (key: string, values?: any) => string): ColumnDef<DNSRecord>[] => [
{
accessorKey: "type",
header: ({ column }) => {
return <DataTableHeader column={column}>Type</DataTableHeader>;
return <DataTableHeader column={column}>{tCommon("type")}</DataTableHeader>;
},
cell: ({ row }) => <DNSRecordTypeCell record={row.original} />,
},
{
accessorKey: "name",
header: ({ column }) => {
return <DataTableHeader column={column}>Hostname</DataTableHeader>;
return <DataTableHeader column={column}>{t("hostname")}</DataTableHeader>;
},
cell: ({ row }) => <DNSRecordNameCell record={row.original} />,
},
{
accessorKey: "content",
header: ({ column }) => {
return <DataTableHeader column={column}>Content</DataTableHeader>;
return <DataTableHeader column={column}>{t("contentColumn")}</DataTableHeader>;
},
cell: ({ row }) => <DNSRecordContentCell record={row.original} />,
},
{
accessorKey: "ttl",
header: ({ column }) => {
return <DataTableHeader column={column}>TTL</DataTableHeader>;
return <DataTableHeader column={column}>{t("ttl")}</DataTableHeader>;
},
cell: ({ row }) => <DNSRecordTimeToLiveCell record={row.original} />,
},
@@ -53,6 +56,8 @@ const ZoneContext = createContext({} as DNSZone);
export default function DNSRecordsTable({ zone }: Props) {
const [sorting, setSorting] = useState<SortingState>([]);
const t = useTranslations("dns");
const tCommon = useTranslations("common");
return (
<ZoneContext.Provider value={zone}>
@@ -65,13 +70,13 @@ export default function DNSRecordsTable({ zone }: Props) {
rowClassName={"last:pb-10"}
className={"bg-nb-gray-960 py-2"}
inset={true}
text={"DNS Records"}
text={t("dnsRecords")}
initialPageSize={zone?.records?.length}
manualPagination={true}
sorting={sorting}
columnVisibility={{}}
setSorting={setSorting}
columns={DNSRecordsTableColumns}
columns={getColumns(t, tCommon)}
data={zone.records}
/>
</ZoneContext.Provider>

View File

@@ -1,3 +1,5 @@
"use client";
import Button from "@components/Button";
import {
DropdownMenu,
@@ -12,6 +14,7 @@ import { useState } from "react";
import { usePermissions } from "@/contexts/PermissionsProvider";
import { DNSZone } from "@/interfaces/DNS";
import { useDNSZones } from "@/modules/dns/zones/DNSZonesProvider";
import { useTranslations } from "next-intl";
type Props = {
zone: DNSZone;
@@ -21,6 +24,8 @@ export const DNSZonesActionCell = ({ zone }: Props) => {
const { permission } = usePermissions();
const { openZoneModal, deleteZone, updateZone } = useDNSZones();
const [open, setOpen] = useState(false);
const t = useTranslations("dns");
const tCommon = useTranslations("common");
return (
<div className={"flex justify-end pr-4"}>
@@ -35,7 +40,7 @@ export const DNSZonesActionCell = ({ zone }: Props) => {
<Button
variant={"secondary"}
className={"!px-3"}
aria-label={"Zone actions"}
aria-label={t("zoneActionsAria")}
data-testid="dns-zone-actions"
>
<MoreVertical size={16} className={"shrink-0"} />
@@ -48,7 +53,7 @@ export const DNSZonesActionCell = ({ zone }: Props) => {
>
<div className={"flex gap-3 items-center"}>
<SquarePenIcon size={14} className={"shrink-0"} />
Edit
{tCommon("edit")}
</div>
</DropdownMenuItem>
@@ -62,7 +67,7 @@ export const DNSZonesActionCell = ({ zone }: Props) => {
>
<div className={"flex gap-3 items-center"}>
<PowerIcon size={14} className={"shrink-0"} />
{zone.enabled ? "Disable" : "Enable"}
{zone.enabled ? t("disable") : t("enable")}
</div>
</DropdownMenuItem>
@@ -76,7 +81,7 @@ export const DNSZonesActionCell = ({ zone }: Props) => {
>
<div className={"flex gap-3 items-center"}>
<Trash2 size={14} className={"shrink-0"} />
Delete
{tCommon("delete")}
</div>
</DropdownMenuItem>
</DropdownMenuContent>

View File

@@ -1,3 +1,5 @@
"use client";
import Badge from "@components/Badge";
import Button from "@components/Button";
import { GlobeIcon, PlusCircle } from "lucide-react";
@@ -5,6 +7,7 @@ import * as React from "react";
import { usePermissions } from "@/contexts/PermissionsProvider";
import { DNSZone } from "@/interfaces/DNS";
import { useDNSZones } from "@/modules/dns/zones/DNSZonesProvider";
import { useTranslations } from "next-intl";
type Props = {
zone: DNSZone;
@@ -13,6 +16,7 @@ type Props = {
export const DNSZonesRecordsCell = ({ zone }: Props) => {
const { permission } = usePermissions();
const { openRecordModal } = useDNSZones();
const t = useTranslations("dns");
const recordsCount = zone?.records?.length ?? 0;
@@ -42,7 +46,7 @@ export const DNSZonesRecordsCell = ({ zone }: Props) => {
data-testid="add-dns-record"
>
<PlusCircle size={12} />
Add
{t("addRecordBtn")}
</Button>
</div>
);

View File

@@ -1,3 +1,5 @@
"use client";
import Button from "@components/Button";
import Card from "@components/Card";
import InlineLink from "@components/InlineLink";
@@ -40,12 +42,13 @@ import { DNSZonesSearchDomainCell } from "@/modules/dns/zones/table/DNSZonesSear
import { Group } from "@/interfaces/Group";
import DNSZoneIcon from "@/assets/icons/DNSZoneIcon";
import { useGroups } from "@/contexts/GroupsProvider";
import { useTranslations } from "next-intl";
export const DNSZonesColumns: ColumnDef<DNSZone>[] = [
const getColumns = (t: (key: string, values?: any) => string, tCommon: (key: string, values?: any) => string): ColumnDef<DNSZone>[] => [
{
accessorKey: "domain",
header: ({ column }) => (
<DataTableHeader column={column}>Zone</DataTableHeader>
<DataTableHeader column={column}>{t("zoneColumn")}</DataTableHeader>
),
sortingFn: "text",
cell: ({ row }) => <DNSZonesNameCell zone={row.original} />,
@@ -56,7 +59,7 @@ export const DNSZonesColumns: ColumnDef<DNSZone>[] = [
{
accessorKey: "records",
header: ({ column }) => (
<DataTableHeader column={column}>Records</DataTableHeader>
<DataTableHeader column={column}>{t("recordsColumn")}</DataTableHeader>
),
sortingFn: "text",
cell: ({ row }) => <DNSZonesRecordsCell zone={row.original} />,
@@ -64,7 +67,7 @@ export const DNSZonesColumns: ColumnDef<DNSZone>[] = [
{
accessorKey: "distribution_groups",
header: ({ column }) => (
<DataTableHeader column={column}>Groups</DataTableHeader>
<DataTableHeader column={column}>{tCommon("distributionGroups")}</DataTableHeader>
),
cell: ({ row }) => <DNSZonesGroupCell zone={row.original} />,
},
@@ -77,7 +80,7 @@ export const DNSZonesColumns: ColumnDef<DNSZone>[] = [
{
accessorKey: "enable_search_domain",
header: ({ column }) => (
<DataTableHeader column={column}>Search Domain</DataTableHeader>
<DataTableHeader column={column}>{t("searchDomainColumn")}</DataTableHeader>
),
cell: ({ row }) => <DNSZonesSearchDomainCell zone={row.original} />,
},
@@ -119,6 +122,8 @@ export default function DNSZonesTable({
const { mutate } = useSWRConfig();
const path = usePathname();
const { groups } = useGroups();
const t = useTranslations("dns");
const tCommon = useTranslations("common");
// Default sorting state of the table
const [sorting, setSorting] = useLocalStorage<SortingState>(
@@ -163,18 +168,18 @@ export default function DNSZonesTable({
const statusOptions = useMemo<RadioOption<boolean | undefined>[]>(
() => [
{ value: undefined, label: "All", dotClass: "bg-nb-gray-500" },
{ value: true, label: "Active", dotClass: "bg-green-500" },
{ value: false, label: "Inactive", dotClass: "bg-nb-gray-700" },
{ value: undefined, label: tCommon("all"), dotClass: "bg-nb-gray-500" },
{ value: true, label: tCommon("active"), dotClass: "bg-green-500" },
{ value: false, label: tCommon("inactive"), dotClass: "bg-nb-gray-700" },
],
[],
[tCommon],
);
const filterDefs = useMemo<TableFilterDef[]>(
() => [
{
id: "enabled",
label: "Status",
label: tCommon("status"),
renderPicker: (p) => (
<RadioPicker
value={p.value as boolean | undefined}
@@ -188,7 +193,7 @@ export default function DNSZonesTable({
},
{
id: "group_names_filter",
label: "Groups",
label: tCommon("distributionGroups"),
renderPicker: (p) => (
<GroupsPicker
value={p.value as string[] | undefined}
@@ -200,17 +205,17 @@ export default function DNSZonesTable({
formatChip: (v) => formatGroupsChip(v as string[] | undefined),
},
],
[statusOptions, tableGroups],
[statusOptions, tableGroups, tCommon],
);
return (
<DataTable
headingTarget={headingTarget}
isLoading={isLoading}
text={"DNS Zones"}
text={t("zones")}
sorting={sorting}
setSorting={setSorting}
columns={DNSZonesColumns}
columns={getColumns(t, tCommon)}
data={zonesWithGroups}
useRowId={true}
wrapperComponent={isGroupPage ? Card : undefined}
@@ -222,7 +227,7 @@ export default function DNSZonesTable({
keepStateInLocalStorage={!isGroupPage}
initialPageSize={25}
showResetFilterButton={false}
searchPlaceholder={"Search by domain, ip, content or group..."}
searchPlaceholder={t("searchZonePlaceholder")}
aboveTable={(table) => (
<TableFilterChips table={table} filters={filterDefs} />
)}
@@ -247,10 +252,8 @@ export default function DNSZonesTable({
icon={<DNSZoneIcon className={"fill-nb-gray-200"} size={24} />}
className={"py-4"}
contentClassName={"max-w-lg"}
title={"This group is not used within any zones yet"}
description={
"Assign this group as a distribution group in your zones to see them listed here."
}
title={t("noZonesGroupTitle")}
description={t("noZonesGroupDesc")}
>
<div className={"gap-x-4 flex items-center justify-center mt-4"}>
<AddZoneButton distributionGroups={distributionGroups} />
@@ -265,10 +268,8 @@ export default function DNSZonesTable({
size={"large"}
/>
}
title={"Create New Zone"}
description={
"It looks like you don't have any zones. Control domain name resolution for your network by adding a zone."
}
title={t("createZone")}
description={t("noZonesGetStartedDesc")}
button={
<div className={"gap-x-4 flex items-center justify-center"}>
<AddZoneButton distributionGroups={distributionGroups} />
@@ -276,9 +277,9 @@ export default function DNSZonesTable({
}
learnMore={
<>
Learn more about
{t("learnMoreAbout")}
<InlineLink href={DNS_ZONE_DOCS_LINK} target={"_blank"}>
DNS Zones
{t("dnsZones")}
<ExternalLinkIcon size={12} />
</InlineLink>
</>
@@ -331,6 +332,7 @@ type AddZoneButtonProps = {
const AddZoneButton = ({ distributionGroups }: AddZoneButtonProps) => {
const { permission } = usePermissions();
const { openZoneModal } = useDNSZones();
const t = useTranslations("dns");
return (
<Button
@@ -341,7 +343,7 @@ const AddZoneButton = ({ distributionGroups }: AddZoneButtonProps) => {
data-testid="add-dns-zone"
>
<PlusCircle size={16} />
Add Zone
{t("addZone")}
</Button>
);
};

View File

@@ -73,8 +73,8 @@ const Content = ({ network, onCreated, onUpdated }: ContentProps) => {
const updateNetwork = async () => {
notify({
title: name,
description: "Network updated successfully.",
loadingMessage: "Updating network...",
description: t("networkUpdated"),
loadingMessage: t("networkUpdating"),
promise: update({ name, description }, `/${network?.id}`).then((n) => {
onUpdated?.(n);
}),
@@ -84,8 +84,8 @@ const Content = ({ network, onCreated, onUpdated }: ContentProps) => {
const createNetwork = async () => {
notify({
title: name,
description: "Network created successfully.",
loadingMessage: "Creating network...",
description: t("networkCreated"),
loadingMessage: t("networkCreating"),
promise: create({ name, description }).then((n) => {
onCreated?.(n);
}),
@@ -100,7 +100,7 @@ const Content = ({ network, onCreated, onUpdated }: ContentProps) => {
description={
network
? network.name
: "Access internal resources in LANs and VPC by adding a network."
: t("modalAccessDescription")
}
color={"netbird"}
/>
@@ -133,12 +133,12 @@ data-testid="network-description-input"
<ModalFooter className={"items-center"}>
<div className={"w-full"}>
<Paragraph className={"text-sm mt-auto"}>
Learn more about
{t("learnMoreAbout")}
<InlineLink
href={"https://docs.netbird.io/how-to/networks"}
target={"_blank"}
>
Networks
{t("title")}
<ExternalLinkIcon size={12} />
</InlineLink>
</Paragraph>

View File

@@ -1,5 +1,8 @@
"use client";
import { Modal } from "@components/modal/Modal";
import { notify } from "@components/Notification";
import { useTranslations } from "next-intl";
import { useApiCall } from "@utils/api";
import * as React from "react";
import { useState } from "react";
@@ -73,6 +76,8 @@ export const NetworkProvider = ({
onResourceDelete,
onResourceUpdate,
}: Props) => {
const t = useTranslations("networks");
const tCommon = useTranslations("common");
const { mutate } = useSWRConfig();
const { confirm } = useDialog();
const deleteCall = useApiCall("/networks").del;
@@ -195,25 +200,29 @@ export const NetworkProvider = ({
if (!isMulti && action === "edit") return true;
return confirm({
title: isMulti ? (
<>This policy is used by multiple resources</>
<>{t("multiResourceTitle")}</>
) : (
<>
{action === "edit" ? "Edit" : "Delete"} policy &apos;{policy.name}
&apos;?
{action === "edit"
? t("editPolicyTitle", { name: policy.name })
: t("deletePolicyTitle", { name: policy.name })}
</>
),
description: isMulti
? `This policy uses one or many resource group(s) as destinations. ${
action === "edit" ? "Updating" : "Deleting"
} this policy will also affect following resources:`
? t("multiResourceDesc", {
action: action === "edit" ? tCommon("edit") : tCommon("delete"),
})
: action === "delete"
? "Are you sure you want to delete this policy? This action cannot be undone."
? t("deletePolicyDesc")
: undefined,
children: isMulti ? (
<AffectedResourceList resources={affectedResources} />
) : undefined,
confirmText: action === "edit" ? "Edit Policy" : "Delete Policy",
cancelText: "Cancel",
confirmText:
action === "edit"
? t("editPolicy")
: t("deletePolicy"),
cancelText: tCommon("cancel"),
hideIcon: isMulti,
type: action === "edit" ? "warning" : "danger",
maxWidthClass: isMulti ? "max-w-lg" : undefined,
@@ -222,11 +231,10 @@ export const NetworkProvider = ({
const deleteNetwork = async (network: Network) => {
const choice = await confirm({
title: `Delete network '${network.name}'?`,
description:
"Are you sure you want to delete this network? Every resource and routing peers will be removed from this network. This action cannot be undone.",
confirmText: "Delete",
cancelText: "Cancel",
title: t("confirmDeleteNetworkTitle", { name: network.name }),
description: t("confirmDeleteNetworkDesc"),
confirmText: tCommon("delete"),
cancelText: tCommon("cancel"),
type: "danger",
});
@@ -239,8 +247,8 @@ export const NetworkProvider = ({
notify({
title: network.name,
description: "Network deleted successfully.",
loadingMessage: "Deleting network...",
description: t("networkDeleted"),
loadingMessage: t("networkDeleting"),
promise,
});
@@ -252,11 +260,10 @@ export const NetworkProvider = ({
resource: NetworkResource,
) => {
const choice = await confirm({
title: `Delete resource '${resource.name}'?`,
description:
"Are you sure you want to delete this resource? This action cannot be undone.",
confirmText: "Delete",
cancelText: "Cancel",
title: t("confirmDeleteResourceTitle", { name: resource.name }),
description: t("confirmDeleteResourceDesc"),
confirmText: tCommon("delete"),
cancelText: tCommon("cancel"),
type: "danger",
});
@@ -264,8 +271,8 @@ export const NetworkProvider = ({
notify({
title: resource.name,
description: "Resource deleted successfully.",
loadingMessage: "Deleting resource...",
description: t("resourceDeleted"),
loadingMessage: t("resourceDeleting"),
promise: deleteCall({}, `/${network.id}/resources/${resource.id}`).then(
() => {
onResourceDelete?.();
@@ -279,19 +286,19 @@ export const NetworkProvider = ({
const deleteRouter = async (network: Network, router: NetworkRouter) => {
const choice = await confirm({
title: `Remove this router?`,
description: "Are you sure you want to remove this router?",
confirmText: "Remove",
cancelText: "Cancel",
title: t("confirmRemoveRouterTitle"),
description: t("confirmRemoveRouterDesc"),
confirmText: t("remove"),
cancelText: tCommon("cancel"),
type: "danger",
});
if (!choice) return;
notify({
title: "Router of " + network.name,
description: "Router deleted successfully.",
loadingMessage: "Deleting router...",
title: t("removeRouter", { name: network.name }),
description: t("routerRemoved"),
loadingMessage: t("routerRemoving"),
promise: deleteCall({}, `/${network.id}/routers/${router.id}`).then(
() => {
mutate(`/networks/${network.id}/routers`);
@@ -302,11 +309,10 @@ export const NetworkProvider = ({
const askForRoutingPeer = async (network: Network) => {
const choice = await confirm({
title: `Add Routing Peer to '${network.name}'?`,
description:
"Without a routing peer, the resources inside this network will not be accessible by any peers.",
confirmText: "Add Routing Peer",
cancelText: "Later",
title: t("confirmAddRoutingPeerTitle", { name: network.name }),
description: t("confirmAddRoutingPeerDesc"),
confirmText: t("confirmAddRoutingPeer"),
cancelText: t("later"),
type: "default",
});
if (!choice) return;
@@ -315,11 +321,10 @@ export const NetworkProvider = ({
const askForResource = async (network: Network) => {
const choice = await confirm({
title: `Add Resource to '${network.name}'?`,
description:
"Peers will be able to access your network resources once you add them.",
confirmText: "Add Resource",
cancelText: "Later",
title: t("confirmAddResourceTitle", { name: network.name }),
description: t("confirmAddResourceDesc"),
confirmText: t("addResource"),
cancelText: t("later"),
type: "default",
});
if (!choice) return;
@@ -502,6 +507,7 @@ export const useNetworksContext = () => {
};
function AffectedResourceList({ resources }: { resources: NetworkResource[] }) {
const t = useTranslations("networks");
const maxVisible = 6;
const visible = resources.slice(0, maxVisible);
const remaining = resources.length - maxVisible;
@@ -528,7 +534,7 @@ function AffectedResourceList({ resources }: { resources: NetworkResource[] }) {
))}
{remaining > 0 && (
<div className="border-t border-nb-gray-900 px-3 py-2 text-nb-gray-200">
+ {remaining} more
{t("remainingMore", { count: remaining })}
</div>
)}
</div>

View File

@@ -1,6 +1,9 @@
"use client";
import { DomainListBadge } from "@components/ui/DomainListBadge";
import { IconDirectionSign } from "@tabler/icons-react";
import { InfoIcon } from "lucide-react";
import { useTranslations } from "next-intl";
import * as React from "react";
import { ExitNodeHelpTooltip } from "@/modules/exit-node/ExitNodeHelpTooltip";
@@ -9,6 +12,7 @@ type Props = {
domains?: string[];
};
export default function NetworkRangeCell({ network, domains }: Props) {
const t = useTranslations("networks");
const isExitNode = network === "0.0.0.0/0";
const hasDomains = domains ? domains.length > 0 : false;
@@ -18,7 +22,7 @@ export default function NetworkRangeCell({ network, domains }: Props) {
<ExitNodeHelpTooltip>
<div className={"flex gap-2 items-center dark:text-nb-gray-300 group"}>
<IconDirectionSign size={16} className={"text-yellow-400"} />
Exit Node{" "}
{t("exitNode")}{" "}
<InfoIcon
size={14}
className={

View File

@@ -1,5 +1,8 @@
"use client";
import Badge from "@components/Badge";
import Button from "@components/Button";
import { useTranslations } from "next-intl";
import { PlusCircle, ShieldIcon } from "lucide-react";
import * as React from "react";
@@ -8,23 +11,26 @@ type Props = {
};
export const PolicyCell = ({ count }: Props) => {
const t = useTranslations("networks");
const tCommon = useTranslations("common");
return count > 0 ? (
<div className={"flex gap-3"}>
<Badge variant={"gray"} useHover={true}>
<ShieldIcon size={14} className={"text-green-500"} />
<div>
<span className={"font-medium"}>{count}</span> Access Policie(s)
<span className={"font-medium"}>{count}</span> {t("accessPolicies")}
</div>
</Badge>
<Button size={"xs"} variant={"secondary"} className={"min-w-[130px]"}>
<PlusCircle size={12} />
Add Policy
{tCommon("create")}
</Button>
</div>
) : (
<Button size={"xs"} variant={"secondary"} className={"min-w-[130px]"}>
<PlusCircle size={12} />
Add Policy
{tCommon("create")}
</Button>
);
};

View File

@@ -1,4 +1,7 @@
"use client";
import FullTooltip from "@components/FullTooltip";
import { useTranslations } from "next-intl";
import { TriangleAlertIcon } from "lucide-react";
import * as React from "react";
@@ -6,11 +9,12 @@ type Props = {
size?: number;
};
export const NetworkRoutesDeprecationInfo = ({ size = 14 }: Props) => {
const t = useTranslations("networks");
return (
<FullTooltip
content={
<div className={"text-xs max-w-[230px]"}>
Network Routes will be deprecated and replaced with Networks.
{t("routesDeprecationInfo")}
</div>
}
>

View File

@@ -1,3 +1,5 @@
"use client";
import Button from "@components/Button";
import HelpText from "@components/HelpText";
import { Label } from "@components/Label";
@@ -49,6 +51,7 @@ export default function NetworkResourceAccessControl({
hasResourceGroups = false,
}: Readonly<Props>) {
const t = useTranslations("networks");
const tCommon = useTranslations("common");
const { network, confirmMultiResourceAction } = useNetworksContext();
const { openEditPolicyModal, deletePolicy } = usePolicies();
const [policyModalOpen, setPolicyModalOpen] = useState(false);
@@ -140,13 +143,13 @@ export default function NetworkResourceAccessControl({
<thead>
<tr>
<th className="py-2 px-4 text-left text-[11px] uppercase tracking-wider text-nb-gray-400 font-medium">
Name
{t("resourceNameLabel")}
</th>
<th className="py-2 pl-5 pr-2 text-left text-[11px] uppercase tracking-wider text-nb-gray-400 font-medium">
Source Groups
{t("resourceGroupsLabel")}
</th>
<th className="py-2 px-4 text-left text-[11px] uppercase tracking-wider text-nb-gray-400 font-medium">
Protocol & Ports
{tCommon("configurePolicies")}
</th>
<th className="py-2 pr-4 pl-2" />
</tr>
@@ -233,7 +236,7 @@ export default function NetworkResourceAccessControl({
>
<div className="flex gap-3 items-center">
<Edit2 size={14} className="shrink-0" />
Edit Policy
{t("editPolicy")}
</div>
</DropdownMenuItem>
<DropdownMenuItem
@@ -242,7 +245,7 @@ export default function NetworkResourceAccessControl({
>
<div className="flex gap-3 items-center">
<Trash2 size={14} className="shrink-0" />
Delete Policy
{t("deletePolicy")}
</div>
</DropdownMenuItem>
</DropdownMenuContent>
@@ -257,16 +260,16 @@ export default function NetworkResourceAccessControl({
</div>
)}
<Button
variant="dotted"
className={"w-full mt-1"}
size="sm"
onClick={openAddPolicy}
data-testid="add-policy"
>
<PlusIcon size={14} />
{tCommon("addPolicy")}
</Button>
<Button
variant="dotted"
className={"w-full mt-1"}
size="sm"
onClick={openAddPolicy}
data-testid="add-policy"
>
<PlusIcon size={14} />
{tCommon("addPolicy")}
</Button>
<Modal
open={policyModalOpen}
@@ -302,5 +305,6 @@ export default function NetworkResourceAccessControl({
/>
</Modal>
</div>
</div>
);
}

View File

@@ -153,16 +153,16 @@ export function ResourceModalContent({
const nameError = useMemo(() => {
if (name === "") return "";
if (resourceExists(name, resource?.id))
return "A resource with this name already exists. Please use another name.";
return t("nameAlreadyExists");
return "";
}, [name, resourceExists, resource?.id]);
}, [name, resourceExists, resource?.id, t]);
const confirmMissingPolicies = async () => {
if (allResourcePolicies.length > 0) return true;
return confirm({
title: "No Access Control Policies Configured",
title: t("noPoliciesConfirmTitle"),
description:
"Without access control policies, this resource will not be accessible by any peers. You can also create policies later. Are you sure you want to continue?",
t("noPoliciesConfirmDesc"),
type: "warning",
confirmText: resource ? t("saveChanges") : t("addResource"),
cancelText: tCommon("cancel"),
@@ -185,9 +185,9 @@ export function ResourceModalContent({
});
notify({
title: "Resource Created",
description: `The resource "${name}" has been created successfully.`,
loadingMessage: "Creating resource...",
title: t("resourceCreated"),
description: t("resourceCreatedDesc", { name }),
loadingMessage: t("resourceCreating"),
promise,
});
@@ -208,9 +208,9 @@ export function ResourceModalContent({
onUpdated?.(r);
});
notify({
title: "Resource Updated",
description: `Resource "${name}" has been updated successfully.`,
loadingMessage: "Updating resource...",
title: t("resourceUpdated"),
description: t("resourceUpdatedDesc", { name }),
loadingMessage: t("resourceUpdating"),
promise,
});
};
@@ -231,7 +231,7 @@ export function ResourceModalContent({
description={
resource
? `${resource.name}`
: `Add new resource to "${network?.name}"`
: t("resourceAddNewDesc", { networkName: network?.name })
}
color={"yellow"}
/>
@@ -394,14 +394,17 @@ data-testid="resource-name-input"
<ModalFooter className={"items-center"}>
<div className={"w-full"}>
<Paragraph className={"text-sm mt-auto"}>
Learn more about
<InlineLink
href={"https://docs.netbird.io/how-to/networks#resources"}
target={"_blank"}
>
Resources
<ExternalLinkIcon size={12} />
</InlineLink>
{t.rich("resourceGroupsLearnMore", {
link: (chunks) => (
<InlineLink
href={"https://docs.netbird.io/how-to/networks#resources"}
target={"_blank"}
>
{chunks}
<ExternalLinkIcon size={12} />
</InlineLink>
),
})}
</Paragraph>
</div>
<div className={"flex gap-3 w-full justify-end"}>

View File

@@ -1,3 +1,5 @@
"use client";
import Button from "@components/Button";
import {
DropdownMenu,
@@ -7,6 +9,7 @@ import {
DropdownMenuTrigger,
} from "@components/DropdownMenu";
import { notify } from "@components/Notification";
import { useTranslations } from "next-intl";
import { useApiCall } from "@utils/api";
import {
MoreVertical,
@@ -26,6 +29,7 @@ type Props = {
resource: NetworkResource;
};
export const ResourceActionCell = ({ resource }: Props) => {
const t = useTranslations("networks");
const { permission } = usePermissions();
const { deleteResource, network, openResourceModal } = useNetworksContext();
const { mutate } = useSWRConfig();
@@ -38,11 +42,11 @@ export const ResourceActionCell = ({ resource }: Props) => {
const toggleEnabled = async () => {
const nextEnabled = !resource.enabled;
notify({
title: `Update Resource`,
description: `'${resource?.name}' is now ${
nextEnabled ? "enabled" : "disabled"
}`,
loadingMessage: "Updating resource...",
title: t("updateResource"),
description: nextEnabled
? t("resourceNowEnabled", { name: resource?.name })
: t("resourceNowDisabled", { name: resource?.name }),
loadingMessage: t("updatingResource"),
duration: 1200,
promise: update({
...resource,
@@ -73,7 +77,7 @@ export const ResourceActionCell = ({ resource }: Props) => {
disabled={
!permission.networks.update && !permission.networks.delete
}
aria-label={"Resource actions"}
aria-label={t("resourceEdit")}
>
<MoreVertical size={16} className={"shrink-0"} />
</Button>
@@ -88,7 +92,7 @@ export const ResourceActionCell = ({ resource }: Props) => {
>
<div className={"flex gap-3 items-center"}>
<SquarePenIcon size={14} className={"shrink-0"} />
Edit
{t("resourceEdit")}
</div>
</DropdownMenuItem>
<DropdownMenuItem
@@ -100,7 +104,7 @@ export const ResourceActionCell = ({ resource }: Props) => {
>
<div className={"flex gap-3 items-center"}>
<PowerIcon size={14} className={"shrink-0"} />
{resource.enabled ? "Disable" : "Enable"}
{resource.enabled ? t("resourceDisable") : t("resourceEnable")}
</div>
</DropdownMenuItem>
<DropdownMenuSeparator />
@@ -114,7 +118,7 @@ export const ResourceActionCell = ({ resource }: Props) => {
>
<div className={"flex gap-3 items-center"}>
<Trash2 size={14} className={"shrink-0"} />
Delete
{t("resourceDelete")}
</div>
</DropdownMenuItem>
</DropdownMenuContent>

View File

@@ -1,4 +1,7 @@
"use client";
import CopyToClipboardText from "@components/CopyToClipboardText";
import { useTranslations } from "next-intl";
import React from "react";
import { NetworkResource } from "@/interfaces/Network";
@@ -6,9 +9,10 @@ type Props = {
resource: NetworkResource;
};
export default function ResourceAddressCell({ resource }: Readonly<Props>) {
const t = useTranslations("networks");
return (
<CopyToClipboardText
message={`${resource.address} has been copied to your clipboard`}
message={t("addressCopied", { address: resource.address })}
>
<div
className={

View File

@@ -1,5 +1,8 @@
"use client";
import { notify } from "@components/Notification";
import { ToggleSwitch } from "@components/ToggleSwitch";
import { useTranslations } from "next-intl";
import { useApiCall } from "@utils/api";
import * as React from "react";
import { useMemo } from "react";
@@ -17,6 +20,7 @@ export const ResourceEnabledCell = ({
resource,
mutateAllResourcesOnUpdate,
}: Props) => {
const t = useTranslations("networks");
const { permission } = usePermissions();
const { mutate } = useSWRConfig();
@@ -28,11 +32,11 @@ export const ResourceEnabledCell = ({
const toggle = async (enabled: boolean) => {
notify({
title: `Update Resource`,
description: `'${resource?.name}' is now ${
enabled ? "enabled" : "disabled"
}`,
loadingMessage: "Updating resource...",
title: t("updateResource"),
description: enabled
? t("resourceNowEnabled", { name: resource?.name })
: t("resourceNowDisabled", { name: resource?.name }),
loadingMessage: t("updatingResource"),
duration: 1200,
promise: update({
...resource,

View File

@@ -1,4 +1,8 @@
"use client";
import Button from "@components/Button";
import Badge from "@components/Badge";
import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation";
import { useMemo } from "react";
import { usePermissions } from "@/contexts/PermissionsProvider";
@@ -9,7 +13,6 @@ import {
import { NetworkResource } from "@/interfaces/Network";
import { useNetworksContext } from "@/modules/networks/NetworkProvider";
import ReverseProxyIcon from "@/assets/icons/ReverseProxyIcon";
import Badge from "@components/Badge";
import { CirclePlusIcon } from "lucide-react";
type Props = {
@@ -17,6 +20,7 @@ type Props = {
};
export const ResourceExposeServiceCell = ({ resource }: Props) => {
const t = useTranslations("networks");
const { permission } = usePermissions();
const { openModal, reverseProxies } = useReverseProxies();
const { network } = useNetworksContext();
@@ -73,7 +77,7 @@ export const ResourceExposeServiceCell = ({ resource }: Props) => {
disabled={!permission.services?.create}
>
<CirclePlusIcon size={12} />
Expose
{t("expose")}
</Button>
</div>
);

View File

@@ -1,7 +1,10 @@
"use client";
import Badge from "@components/Badge";
import MultipleGroups, {
TransparentEditIconButton,
} from "@components/ui/MultipleGroups";
import { useTranslations } from "next-intl";
import { IconCirclePlus } from "@tabler/icons-react";
import * as React from "react";
import { usePermissions } from "@/contexts/PermissionsProvider";
@@ -13,6 +16,7 @@ type Props = {
resource?: NetworkResource;
};
export const ResourceGroupCell = ({ resource }: Props) => {
const t = useTranslations("networks");
const { permission } = usePermissions();
const { network, openResourceGroupModal } = useNetworksContext();
@@ -45,7 +49,7 @@ export const ResourceGroupCell = ({ resource }: Props) => {
disabled={!permission.networks.update}
>
<IconCirclePlus size={14} />
Add
{t("addResourceBtn")}
</Badge>
)}
</button>

View File

@@ -1,3 +1,5 @@
"use client";
import Button from "@components/Button";
import {
Modal,
@@ -56,7 +58,8 @@ const ResourceGroupModalContent = ({
network,
onUpdated,
}: ModalProps) => {
const t = useTranslations("common");
const t = useTranslations("networks");
const tCommon = useTranslations("common");
const update = useApiCall<NetworkResource>(
`/networks/${network?.id}/resources/${resource?.id}`,
).put;
@@ -69,9 +72,9 @@ const ResourceGroupModalContent = ({
const updateResource = async () => {
const savedGroups = await saveGroups();
notify({
title: "Update Resource",
description: `'${resource?.name}' groups updated`,
loadingMessage: "Updating resource groups...",
title: t("updateResource"),
description: t("groupUpdated", { name: resource?.name || "" }),
loadingMessage: t("updatingGroups"),
promise: update({
...resource,
groups: savedGroups.map((g) => g.id),
@@ -84,10 +87,8 @@ const ResourceGroupModalContent = ({
return (
<ModalContent maxWidthClass={"max-w-2xl"}>
<ModalHeader
title={"Resource Groups"}
description={
"Add this resource to a group (e.g., Databases, Web Servers) and reference the group in access policies to simplify management."
}
title={t("resourceGroupsModalTitle")}
description={t("resourceGroupsModalDesc")}
icon={<FolderGit2 size={18} />}
/>
@@ -99,7 +100,7 @@ const ResourceGroupModalContent = ({
onChange={setGroups}
values={groups}
showPeerCounter={false}
placeholder={"Add or select resource group(s)..."}
placeholder={t("resourceGroupsPlaceholder")}
policies={policies}
/>
</div>
@@ -112,7 +113,7 @@ const ResourceGroupModalContent = ({
</ModalClose>
<Button variant={"primary"} onClick={updateResource}>
Save Groups
{t("saveGroups")}
</Button>
</div>
</ModalFooter>

View File

@@ -1,6 +1,9 @@
"use client";
import HelpText from "@components/HelpText";
import { Input } from "@components/Input";
import { Label } from "@components/Label";
import { useTranslations } from "next-intl";
import { validator } from "@utils/helpers";
import cidr from "ip-cidr";
import { GlobeIcon, NetworkIcon, WorkflowIcon } from "lucide-react";
@@ -20,13 +23,18 @@ type Props = {
export const ResourceSingleAddressInput = ({
value,
onChange,
label = "Address",
label,
className = "",
onError,
description = "Enter a single IP address, CIDR block or domain name",
placeholder = "Address (IP, CIDR or Domain)",
description,
placeholder,
autoFocus,
}: Props) => {
const t = useTranslations("networks");
const resolvedLabel = label || t("addressLabel");
const resolvedDescription = description || t("addressDescription");
const resolvedPlaceholder = placeholder || t("addressPlaceholder");
const hasChars = useMemo(() => {
return !!value.match(/[a-z*]/i);
}, [value]);
@@ -51,18 +59,18 @@ export const ResourceSingleAddressInput = ({
!value.includes(".") ||
value.endsWith(".")
) {
return "Please enter a valid domain, e.g. service.internal, example.com or *.example.com";
return t("domainError");
}
return ""; // Valid domain
}
// Case 2: If it's not a valid domain, check if it's a valid CIDR
if (!cidr.isValidAddress(value)) {
return "Please enter a valid IP or CIDR, e.g., 10.0.0.21, 192.168.1.0/24, 2001:db8::1 or 2001:db8::/64";
return t("ipCidrError");
}
return ""; // Valid CIDR
}, [value, hasChars, isCIDRBlock]);
}, [value, hasChars, isCIDRBlock, t]);
useEffect(() => {
onError?.(error);
@@ -70,14 +78,14 @@ export const ResourceSingleAddressInput = ({
return (
<div className={className}>
<Label>{label}</Label>
<HelpText>{description}</HelpText>
<Label>{resolvedLabel}</Label>
<HelpText>{resolvedDescription}</HelpText>
<Input
autoFocus={autoFocus}
data-testid="resource-address-input"
customPrefix={PrefixIcon}
error={error}
placeholder={placeholder}
placeholder={resolvedPlaceholder}
value={value}
onChange={(e) => onChange(e.target.value)}
/>

View File

@@ -1,4 +1,7 @@
"use client";
import Badge from "@components/Badge";
import { useTranslations } from "next-intl";
import { NetworkIcon, WorkflowIcon } from "lucide-react";
import * as React from "react";
@@ -6,15 +9,16 @@ type Props = {
single: boolean;
};
export default function ResourceTypeCell({ single }: Props) {
const t = useTranslations("networks");
return (
<div className={"inline-flex"}>
{single ? (
<Badge variant={"gray"} className={"min-w-[130px]"}>
<WorkflowIcon size={14} /> Single IP
<WorkflowIcon size={14} /> {t("singleIP")}
</Badge>
) : (
<Badge variant={"gray"} className={"min-w-[130px]"}>
<NetworkIcon size={14} /> IP Range
<NetworkIcon size={14} /> {t("ipRange")}
</Badge>
)}
</div>

View File

@@ -1,6 +1,9 @@
"use client";
import SkeletonTable, {
SkeletonTableHeader,
} from "@components/skeletons/SkeletonTable";
import { useTranslations } from "next-intl";
import * as React from "react";
import { Suspense } from "react";
import { NetworkResource } from "@/interfaces/Network";
@@ -18,19 +21,23 @@ export const ResourcesTabContent = ({
data,
isLoading,
}: ResourcesSectionProps) => {
const t = useTranslations("networks");
return (
<div className={"px-8"}>
<div className={"flex justify-between items-center mb-5"}>
<div>
<Paragraph>
Add resources to this network to control what peers can access.{" "}
<InlineLink
href={"https://docs.netbird.io/how-to/networks#resources"}
target={"_blank"}
>
Learn more
<ExternalLinkIcon size={12} />
</InlineLink>
{t.rich("resourcesTabDescription", {
link: (chunks) => (
<InlineLink
href={"https://docs.netbird.io/how-to/networks#resources"}
target={"_blank"}
>
{chunks}
<ExternalLinkIcon size={12} />
</InlineLink>
),
})}
</Paragraph>
</div>
</div>

View File

@@ -1,3 +1,5 @@
"use client";
import Button from "@components/Button";
import Card from "@components/Card";
import { DataTable } from "@components/table/DataTable";
@@ -21,6 +23,7 @@ import NoResults from "@components/ui/NoResults";
import { IconCirclePlus } from "@tabler/icons-react";
import { ColumnDef, SortingState } from "@tanstack/react-table";
import { removeAllSpaces } from "@utils/helpers";
import { useTranslations } from "next-intl";
import { ArrowUpRightIcon, Layers3Icon } from "lucide-react";
import { useRouter, useSearchParams } from "next/navigation";
import * as React from "react";
@@ -47,97 +50,14 @@ type Props = {
isGroupPage?: boolean;
};
const NetworkResourceColumns: ColumnDef<NetworkResource>[] = [
{
id: "id",
accessorKey: "id",
filterFn: "exactMatch",
},
{
id: "name",
accessorKey: "name",
header: ({ column }) => {
return <DataTableHeader column={column}>Resource</DataTableHeader>;
},
cell: ({ row }) => {
return <ResourceNameCell resource={row.original} />;
},
},
{
id: "description",
accessorKey: "description",
accessorFn: (resource) =>
removeAllSpaces(resource?.description || "").toLowerCase(),
},
{
id: "address",
accessorKey: "address",
header: ({ column }) => {
return <DataTableHeader column={column}>Address</DataTableHeader>;
},
cell: ({ row }) => {
return <ResourceAddressCell resource={row.original} />;
},
},
{
id: "enabled",
accessorKey: "enabled",
},
{
id: "groups",
accessorFn: (resource) => {
let groups = (resource?.groups ?? []) as Group[];
return groups.map((group) => group.name).join(", ");
},
header: ({ column }) => {
return <DataTableHeader column={column}>Groups</DataTableHeader>;
},
cell: ({ row }) => {
return <ResourceGroupCell resource={row.original} />;
},
},
{
id: "group_names",
accessorFn: (resource) => {
const groups = (resource?.groups ?? []) as Group[];
return groups.map((g) => g.name).filter((n): n is string => !!n);
},
filterFn: "arrIncludesSome",
},
{
id: "policies",
accessorKey: "id",
header: ({ column }) => {
return <DataTableHeader column={column}>Policies</DataTableHeader>;
},
cell: ({ row }) => {
return <ResourcePolicyCell resource={row.original} />;
},
},
{
id: "expose_service",
accessorKey: "id",
header: "",
cell: ({ row }) => {
return <ResourceExposeServiceCell resource={row.original} />;
},
},
{
id: "actions",
accessorKey: "id",
header: "",
cell: ({ row }) => {
return <ResourceActionCell resource={row.original} />;
},
},
];
export default function ResourcesTable({
resources,
isLoading,
headingTarget,
isGroupPage,
}: Readonly<Props>) {
const t = useTranslations("networks");
const tCommon = useTranslations("common");
const { permission } = usePermissions();
const params = useSearchParams();
const resourceId = params.get("resource") ?? undefined;
@@ -181,39 +101,120 @@ export default function ResourcesTable({
const columns = useMemo<ColumnDef<NetworkResource>[]>(
() => [
...NetworkResourceColumns,
{
id: "id",
accessorKey: "id",
filterFn: "exactMatch",
},
{
id: "name",
accessorKey: "name",
header: ({ column }) => {
return <DataTableHeader column={column}>{t("resourceColumn")}</DataTableHeader>;
},
cell: ({ row }) => {
return <ResourceNameCell resource={row.original} />;
},
},
{
id: "description",
accessorKey: "description",
accessorFn: (resource) =>
removeAllSpaces(resource?.description || "").toLowerCase(),
},
{
id: "address",
accessorKey: "address",
header: ({ column }) => {
return <DataTableHeader column={column}>{t("address")}</DataTableHeader>;
},
cell: ({ row }) => {
return <ResourceAddressCell resource={row.original} />;
},
},
{
id: "enabled",
accessorKey: "enabled",
},
{
id: "groups",
accessorFn: (resource) => {
let groups = (resource?.groups ?? []) as Group[];
return groups.map((group) => group.name).join(", ");
},
header: ({ column }) => {
return <DataTableHeader column={column}>{tCommon("groups")}</DataTableHeader>;
},
cell: ({ row }) => {
return <ResourceGroupCell resource={row.original} />;
},
},
{
id: "group_names",
accessorFn: (resource) => {
const groups = (resource?.groups ?? []) as Group[];
return groups.map((g) => g.name).filter((n): n is string => !!n);
},
filterFn: "arrIncludesSome",
},
{
id: "policies",
accessorKey: "id",
header: ({ column }) => {
return <DataTableHeader column={column}>{t("policiesColumn")}</DataTableHeader>;
},
cell: ({ row }) => {
return <ResourcePolicyCell resource={row.original} />;
},
},
{
id: "expose_service",
accessorKey: "id",
header: "",
cell: ({ row }) => {
return <ResourceExposeServiceCell resource={row.original} />;
},
},
{
id: "actions",
accessorKey: "id",
header: "",
cell: ({ row }) => {
return <ResourceActionCell resource={row.original} />;
},
},
{
id: "exposed",
accessorFn: (resource) =>
resource?.id ? exposedResourceIds.has(resource.id) : false,
},
],
[exposedResourceIds],
[t, tCommon, exposedResourceIds],
);
const statusOptions = useMemo<RadioOption<boolean | undefined>[]>(
() => [
{ value: undefined, label: "All", dotClass: "bg-nb-gray-500" },
{ value: true, label: "Active", dotClass: "bg-green-500" },
{ value: false, label: "Inactive", dotClass: "bg-nb-gray-700" },
{ value: undefined, label: tCommon("all"), dotClass: "bg-nb-gray-500" },
{ value: true, label: tCommon("active"), dotClass: "bg-green-500" },
{ value: false, label: tCommon("inactive"), dotClass: "bg-nb-gray-700" },
],
[],
[tCommon],
);
const exposedOptions = useMemo<RadioOption<boolean | undefined>[]>(
() => [
{ value: undefined, label: "All" },
{ value: true, label: "Exposed" },
{ value: false, label: "Not Exposed" },
{ value: undefined, label: tCommon("all") },
{ value: true, label: t("exposed") },
{ value: false, label: t("notExposed") },
],
[],
[tCommon, t],
);
const filterDefs = useMemo<TableFilterDef[]>(
() => [
{
id: "enabled",
label: "Status",
label: tCommon("status"),
renderPicker: (p) => (
<RadioPicker
value={p.value as boolean | undefined}
@@ -227,7 +228,7 @@ export default function ResourcesTable({
},
{
id: "group_names",
label: "Groups",
label: tCommon("groups"),
renderPicker: (p) => (
<GroupsPicker
value={p.value as string[] | undefined}
@@ -240,7 +241,7 @@ export default function ResourcesTable({
},
{
id: "exposed",
label: "Service",
label: tCommon("settings"),
renderPicker: (p) => (
<RadioPicker
value={p.value as boolean | undefined}
@@ -253,7 +254,7 @@ export default function ResourcesTable({
formatRadioChip(v as boolean | undefined, exposedOptions),
},
],
[statusOptions, exposedOptions, tableGroups],
[statusOptions, exposedOptions, tableGroups, tCommon],
);
const removeResourceParam = React.useCallback(() => {
@@ -274,7 +275,7 @@ export default function ResourcesTable({
showSearchAndFilters={true}
inset={false}
tableClassName={"mt-0"}
text={"Resources"}
text={t("resources")}
columns={columns}
keepStateInLocalStorage={false}
initialPageSize={25}
@@ -288,20 +289,20 @@ export default function ResourcesTable({
<TableFilterChips table={table} filters={filterDefs} />
)}
data={resources}
searchPlaceholder={"Search by name, address or group..."}
searchPlaceholder={t("searchResources")}
isLoading={isLoading}
getStartedCard={
<NoResults
className={"py-4"}
title={
isGroupPage
? "This group has no assigned resources"
: "This network has no resources"
? t("noAssignedResources")
: t("noNetworkResources")
}
description={
isGroupPage
? "Assign this group to your resources inside your networks to see them listed here."
: "Add resources to this network to control what peers can access. Resources can be anything from a single IP, a subnet, or a domain."
? t("noAssignedResourcesDesc")
: t("noNetworkResourcesDesc")
}
icon={<Layers3Icon size={20} className={"text-nb-gray-400"} />}
>
@@ -312,7 +313,7 @@ export default function ResourcesTable({
className={"mt-4"}
onClick={() => router.push("/networks")}
>
Go to Networks
{t("goToNetworks")}
<ArrowUpRightIcon size={16} />
</Button>
</>
@@ -339,7 +340,7 @@ export default function ResourcesTable({
data-testid={"add-resource"}
>
<IconCirclePlus size={16} />
Add
{t("addResourceBtn")}
</Button>
)
: undefined

View File

@@ -1,6 +1,9 @@
"use client";
import SkeletonTable, {
SkeletonTableHeader,
} from "@components/skeletons/SkeletonTable";
import { useTranslations } from "next-intl";
import * as React from "react";
import { Suspense, useMemo } from "react";
import { NetworkRouter } from "@/interfaces/Network";
@@ -20,6 +23,7 @@ export const NetworkRoutingPeersTabContent = ({
routers?: NetworkRouter[];
isLoading: boolean;
}) => {
const t = useTranslations("networks");
const { groups } = useGroups();
const { users } = useUsers();
const { data: peers } = useFetchApi<Peer[]>(`/peers`);
@@ -44,15 +48,17 @@ export const NetworkRoutingPeersTabContent = ({
<div className={"flex justify-between items-center mb-5"}>
<div>
<Paragraph>
Add routing peers to this network to access resources inside this
network.{" "}
<InlineLink
href={"https://docs.netbird.io/manage/networks#routing-peers"}
target={"_blank"}
>
Learn more
<ExternalLinkIcon size={12} />
</InlineLink>
{t.rich("routingPeersTabDescription", {
link: (chunks) => (
<InlineLink
href={"https://docs.netbird.io/manage/networks#routing-peers"}
target={"_blank"}
>
{chunks}
<ExternalLinkIcon size={12} />
</InlineLink>
),
})}
</Paragraph>
</div>
</div>

View File

@@ -1,3 +1,5 @@
"use client";
import Button from "@components/Button";
import Card from "@components/Card";
import { DataTable } from "@components/table/DataTable";
@@ -16,6 +18,7 @@ import {
import NoResults from "@components/ui/NoResults";
import { IconCirclePlus } from "@tabler/icons-react";
import { ColumnDef, SortingState } from "@tanstack/react-table";
import { useTranslations } from "next-intl";
import * as React from "react";
import { useMemo, useState } from "react";
import PeerIcon from "@/assets/icons/PeerIcon";
@@ -33,59 +36,13 @@ type Props = {
headingTarget?: HTMLHeadingElement | null;
};
const NetworkRouterColumns: ColumnDef<NetworkRouter>[] = [
{
id: "name",
accessorKey: "id",
header: ({ column }) => {
return <DataTableHeader column={column}>Peer</DataTableHeader>;
},
sortingFn: "text",
cell: ({ row }) => <NetworkRoutingPeerName router={row.original} />,
},
{
id: "enabled",
accessorKey: "enabled",
},
{
id: "metric",
accessorKey: "metric",
header: ({ column }) => {
return <DataTableHeader column={column}>Metric</DataTableHeader>;
},
cell: ({ row }) => (
<RouteMetricCell metric={row.original.metric} useHoverStyle={false} />
),
},
{
id: "masquerade",
accessorKey: "masquerade",
header: ({ column }) => {
return <DataTableHeader column={column}>Masquerade</DataTableHeader>;
},
cell: ({ row }) => <RoutingPeersMasqueradeCell router={row.original} />,
},
{
id: "actions",
accessorKey: "id",
header: "",
cell: ({ row }) => {
return <RoutingPeersActionCell router={row.original} />;
},
},
{
id: "search",
accessorKey: "search",
header: "",
filterFn: "fuzzy",
},
];
export default function NetworkRoutingPeersTable({
routers,
isLoading,
headingTarget,
}: Readonly<Props>) {
const t = useTranslations("networks");
const tCommon = useTranslations("common");
const { permission } = usePermissions();
const { openAddRoutingPeerModal, network } = useNetworksContext();
@@ -98,18 +55,18 @@ export default function NetworkRoutingPeersTable({
const statusOptions = useMemo<RadioOption<boolean | undefined>[]>(
() => [
{ value: undefined, label: "All", dotClass: "bg-nb-gray-500" },
{ value: true, label: "Active", dotClass: "bg-green-500" },
{ value: false, label: "Inactive", dotClass: "bg-nb-gray-700" },
{ value: undefined, label: tCommon("all"), dotClass: "bg-nb-gray-500" },
{ value: true, label: tCommon("active"), dotClass: "bg-green-500" },
{ value: false, label: tCommon("inactive"), dotClass: "bg-nb-gray-700" },
],
[],
[tCommon],
);
const filterDefs = useMemo<TableFilterDef[]>(
() => [
{
id: "enabled",
label: "Status",
label: tCommon("status"),
renderPicker: (p) => (
<RadioPicker
value={p.value as boolean | undefined}
@@ -122,7 +79,58 @@ export default function NetworkRoutingPeersTable({
formatRadioChip(v as boolean | undefined, statusOptions),
},
],
[statusOptions],
[statusOptions, tCommon],
);
const columns = useMemo<ColumnDef<NetworkRouter>[]>(
() => [
{
id: "name",
accessorKey: "id",
header: ({ column }) => {
return <DataTableHeader column={column}>{t("peer")}</DataTableHeader>;
},
sortingFn: "text",
cell: ({ row }) => <NetworkRoutingPeerName router={row.original} />,
},
{
id: "enabled",
accessorKey: "enabled",
},
{
id: "metric",
accessorKey: "metric",
header: ({ column }) => {
return <DataTableHeader column={column}>{t("metric")}</DataTableHeader>;
},
cell: ({ row }) => (
<RouteMetricCell metric={row.original.metric} useHoverStyle={false} />
),
},
{
id: "masquerade",
accessorKey: "masquerade",
header: ({ column }) => {
return <DataTableHeader column={column}>{t("masquerade")}</DataTableHeader>;
},
cell: ({ row }) => <RoutingPeersMasqueradeCell router={row.original} />,
},
{
id: "actions",
accessorKey: "id",
header: "",
cell: ({ row }) => {
return <RoutingPeersActionCell router={row.original} />;
},
},
{
id: "search",
accessorKey: "search",
header: "",
filterFn: "fuzzy",
},
],
[t],
);
return (
@@ -136,8 +144,8 @@ export default function NetworkRoutingPeersTable({
showSearchAndFilters={true}
inset={false}
tableClassName={"mt-0"}
text={"Routing Peers"}
columns={NetworkRouterColumns}
text={t("routingPeers")}
columns={columns}
keepStateInLocalStorage={false}
initialPageSize={25}
showResetFilterButton={false}
@@ -145,15 +153,13 @@ export default function NetworkRoutingPeersTable({
<TableFilterChips table={table} filters={filterDefs} />
)}
data={routers}
searchPlaceholder={"Search by peer name, group name..."}
searchPlaceholder={t("searchRoutingPeers")}
isLoading={isLoading}
getStartedCard={
<NoResults
className={"py-4"}
title={"This network has no routing peers"}
description={
"Add routing peers to this network to access resources inside this network."
}
title={t("noRoutingPeers")}
description={t("noRoutingPeersDesc")}
icon={<PeerIcon size={18} className={"fill-nb-gray-400"} />}
/>
}
@@ -168,7 +174,7 @@ export default function NetworkRoutingPeersTable({
disabled={!permission.networks.update}
>
<IconCirclePlus size={16} />
Add
{t("addRoutingPeerBtn")}
</Button>
)}
>

View File

@@ -1,6 +1,9 @@
"use client";
import { Callout } from "@components/Callout";
import FancyToggleSwitch from "@components/FancyToggleSwitch";
import FullTooltip from "@components/FullTooltip";
import { useTranslations } from "next-intl";
import { getOperatingSystem } from "@hooks/useOperatingSystem";
import useFetchApi from "@utils/api";
import { cn } from "@utils/helpers";
@@ -25,6 +28,7 @@ export const RoutingPeerMasqueradeSwitch = ({
routingPeerGroupId,
"data-testid": dataTestId,
}: Props) => {
const t = useTranslations("networks");
return (
<RoutingPeerMasqueradeTooltip show={disabled}>
<div className={"flex flex-col gap-4"}>
@@ -36,12 +40,10 @@ export const RoutingPeerMasqueradeSwitch = ({
label={
<>
<VenetianMask size={15} />
Masquerade
{t("masquerade")}
</>
}
helpText={
"Allow access to your private networks without configuring routes on your local routers or other devices."
}
helpText={t("masqueradeHelp")}
/>
{routingPeerGroupId && !value && (
<RoutingPeerGroupNonLinuxWarning
@@ -62,11 +64,12 @@ export const RoutingPeerMasqueradeTooltip = ({
show = false,
children,
}: RoutingPeerMasqueradeTooltipProps) => {
const t = useTranslations("networks");
return (
<FullTooltip
content={
<div className={"text-xs"}>
Masquerade needs to be enabled for non-Linux routing peers.
{t("masqueradeTooltip")}
</div>
}
delayDuration={250}
@@ -84,6 +87,7 @@ const RoutingPeerGroupNonLinuxWarning = ({
}: {
routingPeerGroupId: string;
}) => {
const t = useTranslations("networks");
const { groups } = useGroups();
const { data: peers } = useFetchApi<Peer[]>("/peers", true);
const group = groups?.find((g) => g.id === routingPeerGroupId);
@@ -112,10 +116,12 @@ const RoutingPeerGroupNonLinuxWarning = ({
/>
}
>
Group <span className={"text-netbird font-normal"}>{group?.name}</span>{" "}
contains at least one non-Linux peer.
<br /> Disabled Masquerade will have no effect on non-Linux routing
peers.
{t.rich("masqueradeNonLinuxWarning", {
important: (chunks) => (
<span className={"text-netbird font-normal"}>{chunks}</span>
),
groupName: group?.name || "",
})}
</Callout>
)
);

View File

@@ -1,3 +1,5 @@
"use client";
import Button from "@components/Button";
import {
DropdownMenu,
@@ -7,6 +9,7 @@ import {
DropdownMenuTrigger,
} from "@components/DropdownMenu";
import { notify } from "@components/Notification";
import { useTranslations } from "next-intl";
import { useApiCall } from "@utils/api";
import {
MoreVertical,
@@ -25,6 +28,7 @@ type Props = {
router: NetworkRouter;
};
export const RoutingPeersActionCell = ({ router }: Props) => {
const t = useTranslations("networks");
const { permission } = usePermissions();
const { deleteRouter, network, openAddRoutingPeerModal } =
useNetworksContext();
@@ -38,9 +42,9 @@ export const RoutingPeersActionCell = ({ router }: Props) => {
const toggleEnabled = async () => {
const nextEnabled = !router.enabled;
notify({
title: "Network Routing Peer",
description: `Routing peer is now ${nextEnabled ? "enabled" : "disabled"}`,
loadingMessage: "Updating routing peer...",
title: t("networkRoutingPeer"),
description: nextEnabled ? t("routingPeerEnabled") : t("routingPeerDisabled"),
loadingMessage: t("updatingRoutingPeer"),
duration: 1200,
promise: update({
...router,
@@ -68,7 +72,7 @@ export const RoutingPeersActionCell = ({ router }: Props) => {
disabled={
!permission.networks.update && !permission.networks.delete
}
aria-label={"Routing peer actions"}
aria-label={t("routerEdit")}
>
<MoreVertical size={16} className={"shrink-0"} />
</Button>
@@ -83,7 +87,7 @@ export const RoutingPeersActionCell = ({ router }: Props) => {
>
<div className={"flex gap-3 items-center"}>
<SquarePenIcon size={14} className={"shrink-0"} />
Edit
{t("routerEdit")}
</div>
</DropdownMenuItem>
<DropdownMenuItem
@@ -95,7 +99,7 @@ export const RoutingPeersActionCell = ({ router }: Props) => {
>
<div className={"flex gap-3 items-center"}>
<PowerIcon size={14} className={"shrink-0"} />
{router.enabled ? "Disable" : "Enable"}
{router.enabled ? t("routerDisable") : t("routerEnable")}
</div>
</DropdownMenuItem>
<DropdownMenuSeparator />
@@ -109,7 +113,7 @@ export const RoutingPeersActionCell = ({ router }: Props) => {
>
<div className={"flex gap-3 items-center"}>
<Trash2 size={14} className={"shrink-0"} />
Remove
{t("remove")}
</div>
</DropdownMenuItem>
</DropdownMenuContent>

View File

@@ -1,5 +1,8 @@
"use client";
import { notify } from "@components/Notification";
import { ToggleSwitch } from "@components/ToggleSwitch";
import { useTranslations } from "next-intl";
import { useApiCall } from "@utils/api";
import * as React from "react";
import { useMemo } from "react";
@@ -12,6 +15,7 @@ type Props = {
router: NetworkRouter;
};
export const RoutingPeersEnabledCell = ({ router }: Props) => {
const t = useTranslations("networks");
const { permission } = usePermissions();
const { mutate } = useSWRConfig();
const { network } = useNetworksContext();
@@ -22,9 +26,9 @@ export const RoutingPeersEnabledCell = ({ router }: Props) => {
const toggle = async (enabled: boolean) => {
notify({
title: "Network Routing Peer",
description: `Routing peer is now ${enabled ? "enabled" : "disabled"}`,
loadingMessage: "Updating routing peer...",
title: t("networkRoutingPeer"),
description: enabled ? t("routingPeerEnabled") : t("routingPeerDisabled"),
loadingMessage: t("updatingRoutingPeer"),
promise: update({
...router,
enabled,

View File

@@ -1,5 +1,8 @@
"use client";
import { notify } from "@components/Notification";
import { ToggleSwitch } from "@components/ToggleSwitch";
import { useTranslations } from "next-intl";
import { getOperatingSystem } from "@hooks/useOperatingSystem";
import useFetchApi, { useApiCall } from "@utils/api";
import * as React from "react";
@@ -16,6 +19,7 @@ type Props = {
router: NetworkRouter;
};
export const RoutingPeersMasqueradeCell = ({ router }: Props) => {
const t = useTranslations("networks");
const { permission } = usePermissions();
const { mutate } = useSWRConfig();
const { network } = useNetworksContext();
@@ -40,9 +44,9 @@ export const RoutingPeersMasqueradeCell = ({ router }: Props) => {
const toggle = async (enabled: boolean) => {
notify({
title: "Network Routing Peer",
description: `Masquerade is now ${enabled ? "enabled" : "disabled"}`,
loadingMessage: "Updating masquerade...",
title: t("networkRoutingPeer"),
description: enabled ? t("masqueradeEnabled") : t("masqueradeDisabled"),
loadingMessage: t("updatingMasquerade"),
promise: update({
...router,
masquerade: enabled,

View File

@@ -1,3 +1,5 @@
"use client";
import Button from "@components/Button";
import {
DropdownMenu,
@@ -6,6 +8,7 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@components/DropdownMenu";
import { useTranslations } from "next-intl";
import { EyeIcon, MoreVertical, PencilLineIcon, Trash2 } from "lucide-react";
import { useRouter } from "next/navigation";
import * as React from "react";
@@ -17,6 +20,8 @@ type Props = {
network: Network;
};
export default function NetworkActionCell({ network }: Readonly<Props>) {
const t = useTranslations("networks");
const tCommon = useTranslations("common");
const { permission } = usePermissions();
const { deleteNetwork, openEditNetworkModal } = useNetworksContext();
const router = useRouter();
@@ -42,7 +47,7 @@ export default function NetworkActionCell({ network }: Readonly<Props>) {
>
<div className={"flex gap-3 items-center"}>
<EyeIcon size={14} className={"shrink-0"} />
View Details
{t("viewDetails")}
</div>
</DropdownMenuItem>
<DropdownMenuItem
@@ -52,7 +57,7 @@ export default function NetworkActionCell({ network }: Readonly<Props>) {
>
<div className={"flex gap-3 items-center"}>
<PencilLineIcon size={14} className={"shrink-0"} />
Rename
{t("renameNetwork")}
</div>
</DropdownMenuItem>
@@ -65,7 +70,7 @@ export default function NetworkActionCell({ network }: Readonly<Props>) {
>
<div className={"flex gap-3 items-center"}>
<Trash2 size={14} className={"shrink-0"} />
Delete
{tCommon("delete")}
</div>
</DropdownMenuItem>
</DropdownMenuContent>

View File

@@ -1,5 +1,8 @@
"use client";
import Badge from "@components/Badge";
import Button from "@components/Button";
import { useTranslations } from "next-intl";
import { LayersIcon, PlusCircle } from "lucide-react";
import { useRouter } from "next/navigation";
import * as React from "react";
@@ -12,6 +15,7 @@ type Props = {
};
export const NetworkResourceCell = ({ network }: Props) => {
const t = useTranslations("networks");
const { permission } = usePermissions();
const { openResourceModal } = useNetworksContext();
@@ -42,7 +46,7 @@ export const NetworkResourceCell = ({ network }: Props) => {
data-testid={"add-resource"}
>
<PlusCircle size={12} />
Add
{t("addResourceBtn")}
</Button>
</div>
) : (
@@ -55,7 +59,7 @@ export const NetworkResourceCell = ({ network }: Props) => {
data-testid={"add-resource"}
>
<PlusCircle size={12} />
Add
{t("addResourceBtn")}
</Button>
</>
);

View File

@@ -1,6 +1,9 @@
"use client";
import Badge from "@components/Badge";
import Button from "@components/Button";
import FullTooltip from "@components/FullTooltip";
import { useTranslations } from "next-intl";
import { cn } from "@utils/helpers";
import { HelpCircle, PlusCircle } from "lucide-react";
import { useRouter } from "next/navigation";
@@ -14,29 +17,10 @@ type Props = {
network: Network;
};
export default function NetworkRoutingPeerCell({ network }: Props) {
const t = useTranslations("networks");
const tCommon = useTranslations("common");
const { permission } = usePermissions();
const router = useRouter();
const disabledText = useMemo(
() => (
<>
High availability is currently{" "}
<span className={"text-yellow-400 font-medium"}>inactive</span> for this
network.
</>
),
[],
);
const enabledText = useMemo(
() => (
<>
High availability is{" "}
<span className={"text-green-500 font-medium"}>active</span> for this
network.
</>
),
[],
);
const { openAddRoutingPeerModal } = useNetworksContext();
@@ -47,26 +31,22 @@ export default function NetworkRoutingPeerCell({ network }: Props) {
network?.routing_peers_count && network.routing_peers_count > 0
);
const statusLabel = isHighlyAvailable ? tCommon("active") : tCommon("inactive");
const tooltipText = isHighlyAvailable
? t("highAvailabilityActiveText", { status: statusLabel })
: t("highAvailabilityInactiveText", { status: statusLabel });
const helpText = isHighlyAvailable
? t("highAvailabilityHelpActive")
: t("highAvailabilityHelpInactive");
return (
<div className={"flex gap-3 items-center"}>
<FullTooltip
interactive={false}
content={
<div className={"max-w-xs text-xs"}>
<>
{isHighlyAvailable ? enabledText : disabledText}
{isHighlyAvailable ? (
<div className={"inline-flex mt-2"}>
You can add more routing peers to increase the availability of
this network.
</div>
) : (
<div className={"inline-flex mt-2"}>
Go ahead and add more routing peers or groups with routing
peers to enable high availability for this network.
</div>
)}
</>
<div>{tooltipText}</div>
<div className={"inline-flex mt-2"}>{helpText}</div>
</div>
}
>
@@ -88,8 +68,7 @@ export default function NetworkRoutingPeerCell({ network }: Props) {
isHighlyAvailable ? "bg-green-500" : "bg-yellow-400",
)}
></div>
{network?.routing_peers_count && network.routing_peers_count}{" "}
Peer(s)
{t("peerCount", { count: network?.routing_peers_count ?? 0 })}
</>
<HelpCircle size={12} />
@@ -102,10 +81,10 @@ export default function NetworkRoutingPeerCell({ network }: Props) {
className={"!px-3"}
onClick={() => openAddRoutingPeerModal(network)}
disabled={!permission.networks.update}
aria-label={"Add routing peer"}
aria-label={t("addRoutingPeer")}
>
<PlusCircle size={12} />
Add
{t("addRoutingPeerBtn")}
</Button>
</div>
);

View File

@@ -8,12 +8,13 @@ import {
import { CalendarClockIcon, CalendarIcon, Check } from "lucide-react";
import Image from "next/image";
import * as React from "react";
import { useTranslations } from "next-intl";
import Avatar from "@/assets/avatars/jack.jpeg";
import NetBirdIcon from "@/assets/icons/NetBirdIcon";
import { useExperiment } from "@/cloud/cloud-hooks/useExperiment";
import { useAnalytics } from "@/contexts/AnalyticsProvider";
import { useAccount } from "@/modules/account/useAccount";
import { useLoggedInUser } from "@/contexts/UsersProvider";
import { useAccount } from "@/modules/account/useAccount";
type Props = {
open: boolean;
@@ -28,83 +29,58 @@ export const OnboardingDemoCall = ({ open, onOpenChange }: Props) => {
const { trackEventV2 } = useAnalytics();
const account = useAccount();
const { loggedInUser } = useLoggedInUser();
const t = useTranslations("onboarding");
const [variant, variantKey] = useExperiment("onboarding-call", {
v1: {
title: "Book a Technical Overview (Not a Sales Call)",
desc: "Youll meet with a solutions engineer who will walk through how NetBird works, answer your implementation questions - no slides, no hard sell.",
title: t("demoCall.v1.title"),
desc: t("demoCall.v1.desc"),
features: [
"Live walkthrough of setup and architecture",
"Implementation of use case, for your stack",
"Best practices and general overview",
t("demoCall.v1.feature1"),
t("demoCall.v1.feature2"),
t("demoCall.v1.feature3"),
],
cta: "Book Now",
cancel: "No Thanks",
cta: t("demoCall.v1.cta"),
cancel: t("demoCall.v1.cancel"),
},
v2: {
title: "Talk to our Solutions Engineer",
desc: (
<>
Get a 30-min technical overview. Well go over your specific use-case
and answer any technical questions you might have. <br /> Were
offering this as a technical onboard support for you. <br /> This is
NOT a sales call.
</>
),
title: t("demoCall.v2.title"),
desc: t.rich("demoCall.v2.desc", { br: () => <br /> }),
features: [],
cta: "Book Now",
cancel: "No Thanks",
cta: t("demoCall.v2.cta"),
cancel: t("demoCall.v2.cancel"),
},
v3: {
title: "Book a Technical Overview (Not a Sales Call)",
desc: (
<>
Get a 30-min technical overview. Well go over your specific use-case
and answer any technical questions you might have. <br /> Were
offering this as a technical onboard support for you. <br /> This is
NOT a sales call.
</>
),
title: t("demoCall.v3.title"),
desc: t.rich("demoCall.v3.desc", { br: () => <br /> }),
features: [],
cta: "Book Now",
cancel: "No Thanks",
cta: t("demoCall.v3.cta"),
cancel: t("demoCall.v3.cancel"),
},
v4: {
title: "Book a Technical Overview",
desc: "Youll meet with a solutions engineer who will walk through how NetBird works, answer your implementation questions - no slides, no hard sell.",
title: t("demoCall.v4.title"),
desc: t("demoCall.v4.desc"),
features: [
"Live walkthrough of setup and architecture",
"Implementation of use case, for your stack",
"Best practices and general overview",
t("demoCall.v4.feature1"),
t("demoCall.v4.feature2"),
t("demoCall.v4.feature3"),
],
cta: "Book Now",
cancel: "No Thanks",
cta: t("demoCall.v4.cta"),
cancel: t("demoCall.v4.cancel"),
},
v5: {
title: "Talk to our Solutions Engineer",
desc: (
<>
Get a 30-min technical overview. Well go over your specific use-case
and answer any technical questions you might have. <br /> Were
offering this as a technical onboard support for you.
</>
),
title: t("demoCall.v5.title"),
desc: t.rich("demoCall.v5.desc", { br: () => <br /> }),
features: [],
cta: "Book Now",
cancel: "No Thanks",
cta: t("demoCall.v5.cta"),
cancel: t("demoCall.v5.cancel"),
},
v6: {
title: "Book a Technical Overview",
desc: (
<>
Get a 30-min technical overview. Well go over your specific use-case
and answer any technical questions you might have. <br /> Were
offering this as a technical onboard support for you.
</>
),
title: t("demoCall.v6.title"),
desc: t.rich("demoCall.v6.desc", { br: () => <br /> }),
features: [],
cta: "Book Now",
cancel: "No Thanks",
cta: t("demoCall.v6.cta"),
cancel: t("demoCall.v6.cancel"),
},
});
@@ -251,8 +227,7 @@ export const OnboardingDemoCall = ({ open, onOpenChange }: Props) => {
>
<CalendarClockIcon size={12} />
<div>
The call usually takes around
<span className={"font-medium"}> 30 minutes</span>
{t("demoCall.duration", { duration: 30 })}
</div>
</div>
</ModalContent>

View File

@@ -4,6 +4,7 @@ import { ArrowRightIcon, PlayIcon } from "lucide-react";
import Image, { StaticImageData } from "next/image";
import Link from "next/link";
import * as React from "react";
import { useTranslations } from "next-intl";
import ACLImage from "@/assets/onboarding/acl.png";
import ActivityImage from "@/assets/onboarding/activity.png";
import PostureCheckImage from "@/assets/onboarding/posture.png";
@@ -13,78 +14,71 @@ type Props = {
};
export const OnboardingEnd = ({ onFinish }: Props) => {
const { oidcUser: user } = useOidcUser();
const name = user?.given_name || user?.name || user?.preferred_username;
const { oidcUser: user } = useOidcUser();
const name = user?.given_name || user?.name || user?.preferred_username;
const t = useTranslations("onboarding");
const title = name ? `Congratulations, ${name}!` : "Congratulations!";
const title = name ? t("congratulationsName", { name }) : t("congratulations");
return (
<div className={"relative flex flex-col h-full justify-between"}>
<div>
<h1 className={"text-xl text-center max-w-sm mx-auto"}>
{title} <br />
Youve completed the onboarding.
</h1>
<div
className={
"text-sm text-nb-gray-300 font-light mt-2 block text-center sm:px-4"
}
>
Whats next? Check out these guides to get the most out of NetBird. To
learn more, explore the dashboard, visit our documentation, or browse
our YouTube channel.
</div>
return (
<div className={"relative flex flex-col h-full justify-between"}>
<div>
<h1 className={"text-xl text-center max-w-sm mx-auto"}>
{title} <br />
{t("completedOnboarding")}
</h1>
<div
className={
"text-sm text-nb-gray-300 font-light mt-2 block text-center sm:px-4"
}
>
{t("whatsNext")}
</div>
<div className={"mt-8 flex flex-col gap-8"}>
<VideoGuide
title={"Access Control in Under 5 Minutes"}
src={ACLImage}
description={
"Learn how to manage access for your network resources effectively. Whether you want to restrict access to specific machines or allow certain users to connect."
}
href={"https://www.youtube.com/watch?v=WtZD_q-g_Jc"}
/>
<VideoGuide
title={"Provision Users & Groups From Your IdP"}
src={PostureCheckImage}
description={
"Learn how to provision users and groups from your identity provider, such as Okta, Azure AD, or Google Workspace, to manage access control in NetBird and automate onboarding and offboarding processes."
}
href={"https://www.youtube.com/watch?v=RxYWTpf7cgY"}
/>
<VideoGuide
title={"How NetBird Works"}
description={
"Learn more about how NetBird works, its architecture, and how it can help you build secure networks."
}
src={ActivityImage}
href={"https://www.youtube.com/watch?v=CFa7SY4Up9k&t=261s"}
/>
</div>
<div className={"mt-8 flex flex-col gap-8"}>
<VideoGuide
title={t("videoAccessControlTitle")}
src={ACLImage}
description={t("videoAccessControlDescription")}
href={"https://www.youtube.com/watch?v=WtZD_q-g_Jc"}
/>
<VideoGuide
title={t("videoIdPTitle")}
src={PostureCheckImage}
description={t("videoIdPDescription")}
href={"https://www.youtube.com/watch?v=RxYWTpf7cgY"}
/>
<VideoGuide
title={t("videoHowNetBirdWorksTitle")}
description={t("videoHowNetBirdWorksDescription")}
src={ActivityImage}
href={"https://www.youtube.com/watch?v=CFa7SY4Up9k&t=261s"}
/>
</div>
<div className={"mt-10 flex items-center justify-center"}>
<Button variant={"secondaryLighter"} onClick={onFinish}>
Go to Dashboard
<ArrowRightIcon size={16} />
</Button>
</div>
</div>
</div>
);
<div className={"mt-10 flex items-center justify-center"}>
<Button variant={"secondaryLighter"} onClick={onFinish}>
{t("goToDashboard")}
<ArrowRightIcon size={16} />
</Button>
</div>
</div>
</div>
);
};
type VideoGuideProps = {
src?: string | StaticImageData;
title?: string;
description?: string;
href?: string;
src?: string | StaticImageData;
title: string;
description: string;
href?: string;
};
const VideoGuide = ({
src = ACLImage,
title = "Access Control in Under 5 Minutes",
description = "Learn how to manage access for your network resources effectively. Whether you want to restrict access to specific machines or allow certain users to connect.",
href = "#",
src = ACLImage,
title,
description,
href = "#",
}: VideoGuideProps) => {
return (
<div

View File

@@ -66,9 +66,7 @@ export const OnboardingIntent = ({ onSelect, useCases, isBusiness }: Props) => {
"text-sm text-nb-gray-300 font-light mt-2 block text-center sm:px-4"
}
>
NetBird provides the flexibility of both a peer-to-peer overlay
network and a remote network access solution. Choose what fits your
needs, you can always combine both.
{t("description")}
</div>
<div
className={cn(
@@ -77,22 +75,22 @@ export const OnboardingIntent = ({ onSelect, useCases, isBusiness }: Props) => {
)}
>
<IntentCard
title={"Peer-to-Peer Network"}
title={t("p2pTitle")}
description={
isBusiness
? "Install NetBird on two or more devices to create secure, direct WireGuard connections, like laptop to server or server to database. Add at least two machines to get started."
: "Install NetBird on two or more devices in your homelab, such as your laptop, NAS, or Raspberry Pi, to create secure, direct WireGuard connections."
? t("p2pDescription_business")
: t("p2pDescription_personal")
}
recommended={isP2PRecommended}
icon={<PeerIcon size={18} className={"fill-netbird"} />}
onClick={() => onSelect(Intent.P2P)}
/>
<IntentCard
title={"Remote Network Access"}
title={t("remoteAccessTitle")}
description={
isBusiness
? "Enable employee remote access to VMs, Kubernetes clusters, and cloud or on-prem resources without installing NetBird on every machine."
: "Securely access your homelab remotely from anywhere without installing NetBird on every device."
? t("remoteAccessDescription_business")
: t("remoteAccessDescription_personal")
}
recommended={isNetworksRecommended}
icon={<NetworkRoutesIcon size={18} className={"fill-netbird"} />}
@@ -119,6 +117,7 @@ const IntentCard = ({
onClick,
recommended,
}: IntentCardProps) => {
const t = useTranslations("onboarding");
return (
<button
className={
@@ -143,27 +142,26 @@ const IntentCard = ({
}
>
{title}
{recommended && (
<FullTooltip
content={
<div className={"text-xs max-w-xs"}>
Based on your previous choices, we recommend starting with{" "}
{title}. You can always combine both options later.
</div>
}
>
<span
className={cn(
"relative",
"inline-flex text-[0.7rem] font-light bg-netbird/10 border border-netbird-400/30 text-netbird-400 rounded-full px-2 py-1 pb-0.5 leading-none",
"hover:bg-netbird/20 cursor-help transition-all self-center",
)}
{recommended && (
<FullTooltip
content={
<div className={"text-xs max-w-xs"}>
{t("recommendedTooltip", { title })}
</div>
}
>
Recommended
<HelpCircle size={10} className={"ml-1"} />
</span>
</FullTooltip>
)}
<span
className={cn(
"relative",
"inline-flex text-[0.7rem] font-light bg-netbird/10 border border-netbird-400/30 text-netbird-400 rounded-full px-2 py-1 pb-0.5 leading-none",
"hover:bg-netbird/20 cursor-help transition-all self-center",
)}
>
{t("recommended")}
<HelpCircle size={10} className={"ml-1"} />
</span>
</FullTooltip>
)}
</h2>
<p className={"!text-nb-gray-300 text-[.85rem]"}>{description}</p>
</div>

View File

@@ -1,3 +1,5 @@
"use client";
import Button from "@components/Button";
import {
DropdownMenu,
@@ -7,6 +9,7 @@ import {
DropdownMenuTrigger,
} from "@components/DropdownMenu";
import FullTooltip from "@components/FullTooltip";
import InlineLink from "@components/InlineLink";
import { notify } from "@components/Notification";
import { getOperatingSystem } from "@hooks/useOperatingSystem";
import { IconInfoCircle } from "@tabler/icons-react";
@@ -21,19 +24,19 @@ import {
TimerResetIcon,
Trash2,
} from "lucide-react";
import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation";
import React, { useMemo } from "react";
import { useSWRConfig } from "swr";
import { useBypass, useBypassedPeers } from "@/cloud/edr/useBypass";
import { usePeer } from "@/contexts/PeerProvider";
import { usePermissions } from "@/contexts/PermissionsProvider";
import { useDialog } from "@/contexts/DialogProvider";
import { OperatingSystem } from "@/interfaces/OperatingSystem";
import { ExitNodeDropdownButton } from "@/modules/exit-node/ExitNodeDropdownButton";
import { useIntegrations } from "@/modules/integrations/edr/useIntegrations";
import { RDPButton } from "@/modules/remote-access/rdp/RDPButton";
import { SSHButton } from "@/modules/remote-access/ssh/SSHButton";
import InlineLink from "@components/InlineLink";
import { useDialog } from "@/contexts/DialogProvider";
export default function PeerActionCell() {
const { peer, deletePeer, update, toggleSSH, setSSHInstructionsModal } =
@@ -42,6 +45,8 @@ export default function PeerActionCell() {
const { mutate } = useSWRConfig();
const { permission } = usePermissions();
const { confirm } = useDialog();
const t = useTranslations("peers");
const tCommon = useTranslations("common");
// Approval / EDR-bypass state. We pull this directly so the action
// menu can offer Approve / Bypass / Revoke without the inline badges
@@ -55,16 +60,16 @@ export default function PeerActionCell() {
const approvePeer = async () => {
const choice = await confirm({
title: `Approve peer '${peer.name}'?`,
description: "Are you sure you want to approve this peer?",
confirmText: "Approve",
cancelText: "Cancel",
title: t("confirmApprove", { name: peer.name }),
description: t("confirmApproveDescription"),
confirmText: t("approve"),
cancelText: tCommon("cancel"),
type: "default",
});
if (!choice) return;
notify({
title: `Peer ${peer.name} approved`,
description: `This peer was approved and can now connect to other peers.`,
title: t("approveSuccess", { name: peer.name }),
description: t("approveSuccessDescription"),
promise: update({
name: peer.name,
ssh: peer.ssh_enabled,
@@ -74,45 +79,41 @@ export default function PeerActionCell() {
mutate("/peers");
mutate("/groups");
}),
loadingMessage: "Approving peer...",
loadingMessage: t("approveLoading"),
});
};
const handleBypassCompliance = async () => {
const choice = await confirm({
title: `Bypass compliance for '${peer.name}'?`,
description:
"This will override the compliance check and allow this peer to connect. " +
"The bypass will be automatically removed if the device becomes compliant.",
confirmText: "Bypass Compliance",
cancelText: "Cancel",
title: t("bypassComplianceConfirmTitle", { name: peer.name }),
description: t("bypassComplianceConfirmDescription"),
confirmText: t("bypassCompliance"),
cancelText: tCommon("cancel"),
type: "warning",
});
if (!choice || !peer.id) return;
notify({
title: `Compliance bypassed for ${peer.name}`,
description: `This peer can now connect to other peers.`,
title: t("bypassComplianceSuccess", { name: peer.name }),
description: t("bypassComplianceSuccessDescription"),
promise: bypassCompliance(peer.id),
loadingMessage: "Bypassing compliance...",
loadingMessage: t("bypassComplianceLoading"),
});
};
const handleRevokeBypass = async () => {
const choice = await confirm({
title: `Revoke compliance bypass for '${peer.name}'?`,
description:
"This peer will be subject to normal compliance validation. " +
"If still non-compliant, it will lose network access.",
confirmText: "Revoke",
cancelText: "Cancel",
title: t("revokeBypassConfirmTitle", { name: peer.name }),
description: t("revokeBypassConfirmDescription"),
confirmText: t("revoke"),
cancelText: tCommon("cancel"),
type: "warning",
});
if (!choice || !peer.id) return;
notify({
title: `Compliance bypass revoked`,
description: `Peer ${peer.name} is now subject to normal compliance validation.`,
title: t("revokeBypassSuccess"),
description: t("revokeBypassSuccessDescription", { name: peer.name }),
promise: revokeBypass(peer.id),
loadingMessage: "Revoking compliance bypass...",
loadingMessage: t("revokeBypassLoading"),
});
};
@@ -143,11 +144,16 @@ export default function PeerActionCell() {
const showRemoteAccessItems = !isMobile && !!peer.connected;
const toggleLoginExpiration = async () => {
const text = peer.login_expiration_enabled ? "disabled" : "enabled";
const state = peer.login_expiration_enabled
? tCommon("disabled")
: tCommon("enabled");
const disableLoginExpiration = peer.login_expiration_enabled;
notify({
title: `Session expiration is ${text}`,
description: `Session expiration for peer ${peer.name} was successfully ${text}.`,
title: t("loginExpirationUpdated", { state }),
description: t("loginExpirationUpdateDescription", {
name: peer.name,
state,
}),
promise: update({
loginExpiration: !peer.login_expiration_enabled,
inactivityExpiration: disableLoginExpiration
@@ -157,31 +163,28 @@ export default function PeerActionCell() {
mutate("/peers");
mutate("/groups");
}),
loadingMessage: "Updating session expiration...",
loadingMessage: t("loginExpirationUpdating"),
});
};
const disableDashboardSSH = async () => {
const choice = await confirm({
title: `Disable SSH Access?`,
title: t("disableSSHConfirmation"),
description: (
<div>
Starting from NetBird v0.61.0, once SSH access is disabled, you cannot
re-enable it again from the dashboard. You&apos;ll need to create an
explicit access control policy and update your NetBird client to
restore SSH functionality.{" "}
{t("disableSSHDescription")}{" "}
<InlineLink
href={"https://docs.netbird.io/manage/peers/ssh"}
target={"_blank"}
onClick={(e) => e.stopPropagation()}
>
Learn more
{tCommon("learnMore")}
<ExternalLinkIcon size={12} />
</InlineLink>
</div>
),
confirmText: "Disable",
cancelText: "Cancel",
confirmText: tCommon("disable"),
cancelText: tCommon("cancel"),
type: "warning",
maxWidthClass: "max-w-xl",
});
@@ -210,7 +213,7 @@ export default function PeerActionCell() {
>
<div className={"flex gap-3 items-center"}>
<MonitorIcon size={14} className={"shrink-0"} />
View Details
{t("viewDetails")}
</div>
</DropdownMenuItem>
@@ -221,7 +224,7 @@ export default function PeerActionCell() {
<DropdownMenuItem onClick={approvePeer}>
<div className={"flex gap-3 items-center"}>
<CheckCircle2 size={14} className={"shrink-0"} />
Approve
{t("approve")}
</div>
</DropdownMenuItem>
)}
@@ -230,16 +233,16 @@ export default function PeerActionCell() {
className={"w-full block"}
content={
<div className={"text-xs max-w-xs"}>
Bypass {activeIntegrationName} compliance check and
allow this peer to connect. The bypass is automatically
removed when the device becomes compliant.
{t("bypassTooltip", {
integrationName: activeIntegrationName,
})}
</div>
}
>
<DropdownMenuItem onClick={handleBypassCompliance}>
<div className={"flex gap-3 items-center w-full"}>
<ShieldCheck size={14} className={"shrink-0"} />
Bypass Compliance
{t("bypassCompliance")}
</div>
</DropdownMenuItem>
</FullTooltip>
@@ -248,7 +251,7 @@ export default function PeerActionCell() {
<DropdownMenuItem onClick={handleRevokeBypass}>
<div className={"flex gap-3 items-center"}>
<ShieldOff size={14} className={"shrink-0"} />
Revoke Bypass
{t("revokeBypass")}
</div>
</DropdownMenuItem>
)}
@@ -270,9 +273,7 @@ export default function PeerActionCell() {
className={"flex gap-2 items-center !text-nb-gray-300 text-xs"}
>
<IconInfoCircle size={14} />
<span>
Expiration is disabled for all peers added with an setup-key.
</span>
<span>{t("expirationDisabledTooltip")}</span>
</div>
}
className={"w-full block"}
@@ -284,8 +285,9 @@ export default function PeerActionCell() {
>
<div className={"flex gap-3 items-center w-full"}>
<TimerResetIcon size={14} className={"shrink-0"} />
{peer.login_expiration_enabled ? "Disable" : "Enable"} Session
Expiration
{peer.login_expiration_enabled
? t("disableLoginExpiration")
: t("enableLoginExpiration")}
</div>
</DropdownMenuItem>
</FullTooltip>
@@ -302,7 +304,7 @@ export default function PeerActionCell() {
<div className={"flex gap-3 items-center w-full"}>
<TerminalSquare size={14} className={"shrink-0"} />
<div className={"flex justify-between items-center w-full"}>
{peer.ssh_enabled ? "Disable" : "Enable"} SSH Access
{peer.ssh_enabled ? t("disableSSH") : t("enableSSH")}
</div>
</div>
</DropdownMenuItem>
@@ -319,7 +321,7 @@ export default function PeerActionCell() {
>
<div className={"flex gap-3 items-center"}>
<Trash2 size={14} className={"shrink-0"} />
Delete
{tCommon("delete")}
</div>
</DropdownMenuItem>
</DropdownMenuContent>

View File

@@ -40,7 +40,6 @@ type Props = {
onCanceled?: () => void;
};
export const PeerMultiSelect = ({ selectedPeers = {}, onCanceled }: Props) => {
const t = useTranslations("peers");
return (
<AnimatePresence>
{Object.keys(selectedPeers).length > 0 && (
@@ -57,6 +56,7 @@ const PeerGroupMassAssignmentContent = ({
selectedPeers = {},
onCanceled,
}: Props) => {
const t = useTranslations("peers");
const { mutate } = useSWRConfig();
const { confirm } = useDialog();
const { permission } = usePermissions();

View File

@@ -22,6 +22,7 @@ type Props = {
export default function PostureCheckBrowseTable({ onAdd }: Readonly<Props>) {
const t = useTranslations("common");
const tPosture = useTranslations("postureChecks");
const { data: postureChecks, isLoading } =
useFetchApi<PostureCheck[]>("/posture-checks");
@@ -45,11 +46,11 @@ export default function PostureCheckBrowseTable({ onAdd }: Readonly<Props>) {
setRowSelection={setSelectedRows}
isLoading={isLoading}
keepStateInLocalStorage={false}
text={"Posture Check"}
text={tPosture("postureCheck")}
sorting={sorting}
wrapperClassName={""}
setSorting={setSorting}
columns={PostureChecksColumns(t)}
columns={PostureChecksColumns(t, tPosture)}
showHeader={true}
columnVisibility={{
description: false,
@@ -57,7 +58,7 @@ export default function PostureCheckBrowseTable({ onAdd }: Readonly<Props>) {
tableClassName={"mt-6 !border-0"}
rowClassName={"!border-b-0 px-10"}
data={postureChecks}
searchPlaceholder={"Search by name and description..."}
searchPlaceholder={tPosture("searchByNameAndDescription")}
onRowClick={(row) => row.toggleSelected()}
rightSide={(table) => (
<>
@@ -72,7 +73,9 @@ export default function PostureCheckBrowseTable({ onAdd }: Readonly<Props>) {
}
disabled={table.getSelectedRowModel().rows.length <= 0}
>
Add Posture Checks ({table.getSelectedRowModel().rows.length})
{tPosture("addPostureChecks", {
count: table.getSelectedRowModel().rows.length,
})}
</Button>
)}
</>
@@ -95,6 +98,7 @@ export default function PostureCheckBrowseTable({ onAdd }: Readonly<Props>) {
export function PostureChecksColumns(
t: (key: string) => string,
tPosture: (key: string) => string,
): ColumnDef<PostureCheck>[] {
return [
{
@@ -124,7 +128,7 @@ export function PostureChecksColumns(
{
accessorKey: "name",
header: ({ column }) => {
return <DataTableHeader column={column}>Name</DataTableHeader>;
return <DataTableHeader column={column}>{tPosture("name")}</DataTableHeader>;
},
cell: ({ row }) => (
<PostureCheckNameCell small={true} check={row.original} />
@@ -133,7 +137,7 @@ export function PostureChecksColumns(
{
accessorKey: "id",
header: ({ column }) => {
return <DataTableHeader column={column}>Checks</DataTableHeader>;
return <DataTableHeader column={column}>{tPosture("checks")}</DataTableHeader>;
},
cell: ({ row }) => <PostureCheckChecksCell check={row.original} />,
},

Some files were not shown because too many files have changed in this diff Show More