fix(i18n): localize Cancel button text across 25 modal components

Replaces all hardcoded "Cancel" button text with {t("cancel")} using the
common namespace in every modal component, plus a few adjacent button
texts. Adds import { useTranslations } + useTranslations("common") to
each component that needed it.

Files covered: access-tokens, jobs, networks, peer, posture-checks,
remote-access/ssh, reverse-proxy/auth, reverse-proxy/clusters,
reverse-proxy/domain, routes, settings, setup-keys, users — all modal
dialogs with Cancel buttons now respect the active locale.
This commit is contained in:
sakuradairong
2026-06-20 19:52:21 +08:00
parent 96f15d7b48
commit 2dc523ca9e
38 changed files with 2615 additions and 2554 deletions

View File

@@ -18,13 +18,13 @@ import useFetchApi, { useApiCall } from "@utils/api";
import { generateColorFromString } from "@utils/helpers";
import dayjs from "dayjs";
import {
Ban,
GalleryHorizontalEnd,
History,
KeyRoundIcon,
Mail,
MonitorSmartphoneIcon,
User2,
Ban,
GalleryHorizontalEnd,
History,
KeyRoundIcon,
Mail,
MonitorSmartphoneIcon,
User2,
} from "lucide-react";
import { useRouter, useSearchParams } from "next/navigation";
import React, { useMemo, useState } from "react";
@@ -47,372 +47,370 @@ import { UserPeersSection } from "@/modules/users/UserPeersSection";
import { UserRoleSelector } from "@/modules/users/UserRoleSelector";
export default function UserPage() {
const queryParameter = useSearchParams();
const userId = queryParameter.get("id");
const { permission } = usePermissions();
const isServiceUser = queryParameter.get("service_user") === "true";
const { data: users, isLoading } = useFetchApi<User[]>(
`/users?service_user=${isServiceUser}`,
);
const { isOwnerOrAdmin } = useLoggedInUser();
const queryParameter = useSearchParams();
const userId = queryParameter.get("id");
const { permission } = usePermissions();
const isServiceUser = queryParameter.get("service_user") === "true";
const { data: users, isLoading } = useFetchApi<User[]>(
`/users?service_user=${isServiceUser}`,
);
const { isOwnerOrAdmin } = useLoggedInUser();
const user = useMemo(() => {
return users?.find((u) => u.id === userId);
}, [users, userId]);
const user = useMemo(() => {
return users?.find((u) => u.id === userId);
}, [users, userId]);
useRedirect("/team/users", false, !userId);
useRedirect("/team/users", false, !userId);
const userGroups = useGroupIdsToGroups(user?.auto_groups);
const userGroups = useGroupIdsToGroups(user?.auto_groups);
if (!permission.users.read) {
return (
<PageContainer>
<RestrictedAccess page={"User Information"} />
</PageContainer>
);
}
if (!permission.users.read) {
return (
<PageContainer>
<RestrictedAccess page={"User Information"} />
</PageContainer>
);
}
if (!isOwnerOrAdmin && user && !isLoading) {
return <UserOverview user={user} initialGroups={[]} />;
}
if (!isOwnerOrAdmin && user && !isLoading) {
return <UserOverview user={user} initialGroups={[]} />;
}
if (isOwnerOrAdmin && user && !isLoading && userGroups) {
return <UserOverview user={user} initialGroups={userGroups} />;
}
if (isOwnerOrAdmin && user && !isLoading && userGroups) {
return <UserOverview user={user} initialGroups={userGroups} />;
}
return <FullScreenLoading />;
return <FullScreenLoading />;
}
type Props = {
user: User;
initialGroups: Group[];
user: User;
initialGroups: Group[];
};
function UserOverview({ user, initialGroups }: Readonly<Props>) {
const t = useTranslations("users");
const router = useRouter();
const userRequest = useApiCall<User>("/users");
const isServiceUser = !!user?.is_service_user;
const { mutate } = useSWRConfig();
const { loggedInUser, isOwnerOrAdmin, isUser } = useLoggedInUser();
const isLoggedInUser = loggedInUser ? loggedInUser?.id === user.id : false;
const { permission } = usePermissions();
const t = useTranslations("users");
const router = useRouter();
const userRequest = useApiCall<User>("/users");
const isServiceUser = !!user?.is_service_user;
const { mutate } = useSWRConfig();
const { loggedInUser, isOwnerOrAdmin, isUser } = useLoggedInUser();
const isLoggedInUser = loggedInUser ? loggedInUser?.id === user.id : false;
const { permission } = usePermissions();
const [selectedGroups, setSelectedGroups, { save: saveGroups }] =
useGroupHelper({
initial: initialGroups,
});
const [selectedGroups, setSelectedGroups, { save: saveGroups }] =
useGroupHelper({
initial: initialGroups,
});
const [role, setRole] = useState(user.role || Role.User);
const { hasChanges, updateRef: updateChangesRef } = useHasChanges([
role,
selectedGroups,
]);
const [role, setRole] = useState(user.role || Role.User);
const { hasChanges, updateRef: updateChangesRef } = useHasChanges([
role,
selectedGroups,
]);
const save = async () => {
const groups = await saveGroups();
const groupIds = groups.map((group) => group.id) as string[];
const save = async () => {
const groups = await saveGroups();
const groupIds = groups.map((group) => group.id) as string[];
notify({
title: user.name,
description: "Changes successfully saved.",
promise: userRequest
.put(
{
role: role,
auto_groups: groupIds,
is_blocked: user.is_blocked,
},
`/${user.id}`,
)
.then(() => {
mutate(`/users?service_user=${isServiceUser}`);
updateChangesRef([role, selectedGroups]);
}),
loadingMessage: "Saving changes...",
});
};
notify({
title: user.name,
description: "Changes successfully saved.",
promise: userRequest
.put(
{
role: role,
auto_groups: groupIds,
is_blocked: user.is_blocked,
},
`/${user.id}`,
)
.then(() => {
mutate(`/users?service_user=${isServiceUser}`);
updateChangesRef([role, selectedGroups]);
}),
loadingMessage: "Saving changes...",
});
};
const isProfilePage = !!user?.is_current && !isServiceUser;
const canViewTokens = permission?.pats?.read;
const canViewPeers = permission?.peers?.read;
const isProfilePage = !!user?.is_current && !isServiceUser;
const canViewTokens = permission?.pats?.read;
const canViewPeers = permission?.peers?.read;
const showAccessTokens = (user?.is_current || isServiceUser) && canViewTokens;
const showPeers = !isServiceUser && canViewPeers;
const showTabs = isProfilePage && showPeers && showAccessTokens;
const showSeparator = !showTabs;
const showAccessTokens = (user?.is_current || isServiceUser) && canViewTokens;
const showPeers = !isServiceUser && canViewPeers;
const showTabs = isProfilePage && showPeers && showAccessTokens;
const showSeparator = !showTabs;
const [tab, setTab] = useState(isServiceUser ? "access-tokens" : "peers");
const [tab, setTab] = useState(isServiceUser ? "access-tokens" : "peers");
return (
<PageContainer>
<div className={"p-default py-6 mb-4"}>
<Breadcrumbs>
<Breadcrumbs.Item
href={"/team"}
label={"Team"}
disabled={!permission.users.read}
icon={<TeamIcon size={13} />}
/>
return (
<PageContainer>
<div className={"p-default py-6 mb-4"}>
<Breadcrumbs>
<Breadcrumbs.Item
href={"/team"}
label={"Team"}
disabled={!permission.users.read}
icon={<TeamIcon size={13} />}
/>
{isServiceUser ? (
<Breadcrumbs.Item
href={"/team/service-users"}
label={"Service Users"}
icon={<IconSettings2 size={17} />}
/>
) : (
<Breadcrumbs.Item
href={"/team/users"}
label={"Users"}
disabled={!permission.users.read}
icon={<User2 size={16} />}
/>
)}
{isServiceUser ? (
<Breadcrumbs.Item
href={"/team/service-users"}
label={"Service Users"}
icon={<IconSettings2 size={17} />}
/>
) : (
<Breadcrumbs.Item
href={"/team/users"}
label={"Users"}
disabled={!permission.users.read}
icon={<User2 size={16} />}
/>
)}
<Breadcrumbs.Item label={user.name || user.id} active />
</Breadcrumbs>
<Breadcrumbs.Item label={user.name || user.id} active />
</Breadcrumbs>
<div className={"flex justify-between max-w-6xl"}>
<div>
<div className={"flex items-center gap-3"}>
<div
className={
"w-10 h-10 rounded-full relative flex items-center justify-center text-white uppercase text-md font-medium bg-nb-gray-900"
}
style={
isServiceUser
? {
color: "white",
}
: {
color: user?.name
? generateColorFromString(
user?.name || user?.id || "System User",
)
: "#808080",
}
}
>
{isServiceUser ? (
<IconSettings2 size={16} />
) : (
user?.name?.charAt(0) || user?.id?.charAt(0)
)}
</div>
<h1 className={"flex items-center gap-3"} title={user?.id}>
{user.name || user.id}
</h1>
</div>
</div>
{!isUser && (
<div className={"flex gap-4"}>
<Button
variant={"default"}
className={"w-full"}
onClick={() => {
isServiceUser
? router.push("/team/service-users")
: router.push("/team/users");
}}
>
Cancel
</Button>
<div className={"flex justify-between max-w-6xl"}>
<div>
<div className={"flex items-center gap-3"}>
<div
className={
"w-10 h-10 rounded-full relative flex items-center justify-center text-white uppercase text-md font-medium bg-nb-gray-900"
}
style={
isServiceUser
? {
color: "white",
}
: {
color: user?.name
? generateColorFromString(
user?.name || user?.id || "System User",
)
: "#808080",
}
}
>
{isServiceUser ? (
<IconSettings2 size={16} />
) : (
user?.name?.charAt(0) || user?.id?.charAt(0)
)}
</div>
<h1 className={"flex items-center gap-3"} title={user?.id}>
{user.name || user.id}
</h1>
</div>
</div>
{!isUser && (
<div className={"flex gap-4"}>
<Button
variant={"default"}
className={"w-full"}
onClick={() => {
isServiceUser
? router.push("/team/service-users")
: router.push("/team/users");
}}
>
Cancel
</Button>
<Button
variant={"primary"}
className={"w-full"}
disabled={!hasChanges || !permission.users.update}
onClick={save}
data-cy={"save-changes"}
>
Save Changes
</Button>
</div>
)}
</div>
<Button
variant={"primary"}
className={"w-full"}
disabled={!hasChanges || !permission.users.update}
onClick={save}
data-cy={"save-changes"}
>
Save Changes
</Button>
</div>
)}
</div>
<div className={"flex gap-10 w-full mt-8 max-w-6xl items-start"}>
<UserInformationCard user={user} />
<div className={"flex flex-col gap-8 w-1/2 "}>
{!isServiceUser && isOwnerOrAdmin && (
<div>
<Label>Auto-assigned groups</Label>
<HelpText>
Groups will be assigned to peers added by this user.
</HelpText>
<PeerGroupSelector
disabled={isUser}
onChange={setSelectedGroups}
values={selectedGroups}
hideAllGroup={true}
dataCy={"user-group-selector"}
/>
</div>
)}
<div className={"flex items-start"}>
<div className={"w-2/3"}>
<Label>User Role</Label>
<HelpText>
Set a role for the user to assign access permissions.
</HelpText>
</div>
<div className={"w-1/3"}>
<UserRoleSelector
value={role}
onChange={setRole}
hideOwner={isServiceUser}
currentUser={user}
disabled={isLoggedInUser || !permission.users.update}
/>
</div>
</div>
</div>
</div>
</div>
<div className={"flex gap-10 w-full mt-8 max-w-6xl items-start"}>
<UserInformationCard user={user} />
<div className={"flex flex-col gap-8 w-1/2 "}>
{!isServiceUser && isOwnerOrAdmin && (
<div>
<Label>Auto-assigned groups</Label>
<HelpText>
Groups will be assigned to peers added by this user.
</HelpText>
<PeerGroupSelector
disabled={isUser}
onChange={setSelectedGroups}
values={selectedGroups}
hideAllGroup={true}
dataCy={"user-group-selector"}
/>
</div>
)}
<div className={"flex items-start"}>
<div className={"w-2/3"}>
<Label>User Role</Label>
<HelpText>
Set a role for the user to assign access permissions.
</HelpText>
</div>
<div className={"w-1/3"}>
<UserRoleSelector
value={role}
onChange={setRole}
hideOwner={isServiceUser}
currentUser={user}
disabled={isLoggedInUser || !permission.users.update}
/>
</div>
</div>
</div>
</div>
</div>
{showSeparator && <Separator />}
{showSeparator && <Separator />}
<Tabs
defaultValue={tab}
onValueChange={setTab}
value={tab}
className={"pb-0 mb-0"}
>
<TabsList justify={"start"} className={"px-8"} hidden={!showTabs}>
{showPeers && (
<TabsTrigger value={"peers"}>
<MonitorSmartphoneIcon size={16} />
Peers
</TabsTrigger>
)}
{showAccessTokens && (
<TabsTrigger value={"access-tokens"}>
<KeyRoundIcon size={16} />
{t("accessTokens")}
</TabsTrigger>
)}
</TabsList>
{showPeers && (
<TabsContent value={"peers"} className={"pb-8"}>
<UserPeersSection user={user} />
</TabsContent>
)}
{showAccessTokens && (
<TabsContent value={"access-tokens"} className={"pb-8"}>
<div className={"px-8"}>
<div className={"max-w-6xl"}>
<div className={"flex justify-between items-center"}>
<div>
<h2>{t("accessTokens")}</h2>
<Paragraph>
{t("accessTokensDescription")}
</Paragraph>
</div>
<div className={"inline-flex gap-4 justify-end"}>
<div>
<CreateAccessTokenModal user={user}>
<Button
variant={"primary"}
data-cy={"access-token-open-modal"}
disabled={!permission.pats.create}
>
<IconCirclePlus size={16} />
Create Access Token
</Button>
</CreateAccessTokenModal>
</div>
</div>
</div>
<AccessTokensTable user={user} />
</div>
</div>
</TabsContent>
)}
</Tabs>
</PageContainer>
);
<Tabs
defaultValue={tab}
onValueChange={setTab}
value={tab}
className={"pb-0 mb-0"}
>
<TabsList justify={"start"} className={"px-8"} hidden={!showTabs}>
{showPeers && (
<TabsTrigger value={"peers"}>
<MonitorSmartphoneIcon size={16} />
Peers
</TabsTrigger>
)}
{showAccessTokens && (
<TabsTrigger value={"access-tokens"}>
<KeyRoundIcon size={16} />
{t("accessTokens")}
</TabsTrigger>
)}
</TabsList>
{showPeers && (
<TabsContent value={"peers"} className={"pb-8"}>
<UserPeersSection user={user} />
</TabsContent>
)}
{showAccessTokens && (
<TabsContent value={"access-tokens"} className={"pb-8"}>
<div className={"px-8"}>
<div className={"max-w-6xl"}>
<div className={"flex justify-between items-center"}>
<div>
<h2>{t("accessTokens")}</h2>
<Paragraph>{t("accessTokensDescription")}</Paragraph>
</div>
<div className={"inline-flex gap-4 justify-end"}>
<div>
<CreateAccessTokenModal user={user}>
<Button
variant={"primary"}
data-cy={"access-token-open-modal"}
disabled={!permission.pats.create}
>
<IconCirclePlus size={16} />
Create Access Token
</Button>
</CreateAccessTokenModal>
</div>
</div>
</div>
<AccessTokensTable user={user} />
</div>
</div>
</TabsContent>
)}
</Tabs>
</PageContainer>
);
}
function UserInformationCard({ user }: Readonly<{ user: User }>) {
const isServiceUser = user.is_service_user || false;
const neverLoggedIn = dayjs(user.last_login).isBefore(
dayjs().subtract(1000, "years"),
);
const isPendingApproval = user?.pending_approval;
const isServiceUser = user.is_service_user || false;
const neverLoggedIn = dayjs(user.last_login).isBefore(
dayjs().subtract(1000, "years"),
);
const isPendingApproval = user?.pending_approval;
return (
<Card>
<Card.List>
<Card.ListItem
label={
<>
<User2 size={16} />
{user.name ? "Name" : "User ID"}
</>
}
value={user.name || user.id}
/>
return (
<Card>
<Card.List>
<Card.ListItem
label={
<>
<User2 size={16} />
{user.name ? "Name" : "User ID"}
</>
}
value={user.name || user.id}
/>
{!isServiceUser && (
<Card.ListItem
label={
<>
<Mail size={16} />
E-Mail
</>
}
value={user.email || "-"}
/>
)}
{!isServiceUser && (
<Card.ListItem
label={
<>
<Mail size={16} />
E-Mail
</>
}
value={user.email || "-"}
/>
)}
<Card.ListItem
tooltip={false}
label={
<>
<GalleryHorizontalEnd size={16} />
Status
</>
}
value={<UserStatusCell user={user} />}
/>
<Card.ListItem
tooltip={false}
label={
<>
<GalleryHorizontalEnd size={16} />
Status
</>
}
value={<UserStatusCell user={user} />}
/>
{!isServiceUser && (
<>
{!user.is_current &&
user.role != Role.Owner &&
!isPendingApproval && (
<Card.ListItem
tooltip={false}
label={
<>
<Ban size={16} />
Block User
</>
}
value={<UserBlockCell user={user} isUserPage={true} />}
/>
)}
{!isServiceUser && (
<>
{!user.is_current &&
user.role != Role.Owner &&
!isPendingApproval && (
<Card.ListItem
tooltip={false}
label={
<>
<Ban size={16} />
Block User
</>
}
value={<UserBlockCell user={user} isUserPage={true} />}
/>
)}
<Card.ListItem
label={
<>
<History size={16} />
Last login
</>
}
value={
neverLoggedIn
? "Never"
: dayjs(user.last_login).format("D MMMM, YYYY [at] h:mm A") +
" (" +
dayjs().to(user.last_login) +
")"
}
/>
</>
)}
</Card.List>
</Card>
);
<Card.ListItem
label={
<>
<History size={16} />
Last login
</>
}
value={
neverLoggedIn
? "Never"
: dayjs(user.last_login).format("D MMMM, YYYY [at] h:mm A") +
" (" +
dayjs().to(user.last_login) +
")"
}
/>
</>
)}
</Card.List>
</Card>
);
}

View File

@@ -12,40 +12,40 @@ import NetBirdIcon from "@/assets/icons/NetBirdIcon";
const config = loadConfig();
export const SessionLost = () => {
const t = useTranslations("auth");
const router = useRouter();
const { logout } = useOidc();
const t = useTranslations("auth");
const router = useRouter();
const { logout } = useOidc();
useEffect(() => {
router.push("/peers");
});
useEffect(() => {
router.push("/peers");
});
return (
<div
className={
"flex items-center justify-center flex-col h-screen max-w-md mx-auto"
}
>
<div
className={
"bg-nb-gray-930 mb-3 border border-nb-gray-900 h-10 w-10 rounded-md flex items-center justify-center "
}
>
<NetBirdIcon size={20} />
</div>
<h1>{t("sessionExpired")}</h1>
<Paragraph className={"text-center"}>
{t("sessionExpiredDescription")}
</Paragraph>
<Button
variant={"primary"}
size={"sm"}
className={"mt-5"}
onClick={() => logout("", { client_id: config.clientId })}
>
{t("login")}
<LogIn size={16} />
</Button>
</div>
);
return (
<div
className={
"flex items-center justify-center flex-col h-screen max-w-md mx-auto"
}
>
<div
className={
"bg-nb-gray-930 mb-3 border border-nb-gray-900 h-10 w-10 rounded-md flex items-center justify-center "
}
>
<NetBirdIcon size={20} />
</div>
<h1>{t("sessionExpired")}</h1>
<Paragraph className={"text-center"}>
{t("sessionExpiredDescription")}
</Paragraph>
<Button
variant={"primary"}
size={"sm"}
className={"mt-5"}
onClick={() => logout("", { client_id: config.clientId })}
>
{t("login")}
<LogIn size={16} />
</Button>
</div>
);
};

View File

@@ -31,6 +31,7 @@ import { useSWRConfig } from "swr";
import useCopyToClipboard from "@/hooks/useCopyToClipboard";
import { AccessToken } from "@/interfaces/AccessToken";
import { User } from "@/interfaces/User";
import { useTranslations } from "next-intl";
type Props = {
children: React.ReactNode;
@@ -41,6 +42,7 @@ export default function CreateAccessTokenModal({
children,
user,
}: Readonly<Props>) {
const t = useTranslations("common");
const [modal, setModal] = useState(false);
const [successModal, setSuccessModal] = useState(false);
const [token, setToken] = useState<string>("");
@@ -132,6 +134,7 @@ export function AccessTokenModalContent({
onSuccess,
user,
}: Readonly<ModalProps>) {
const t = useTranslations("common");
const tokenRequest = useApiCall<AccessToken>(`/users/${user.id}/tokens`);
const { mutate } = useSWRConfig();
@@ -221,7 +224,7 @@ export function AccessTokenModalContent({
</div>
<div className={"flex gap-3 w-full justify-end"}>
<ModalClose asChild={true}>
<Button variant={"secondary"}>Cancel</Button>
<Button variant={"secondary"}>{t("cancel")}</Button>
</ModalClose>
<Button

View File

@@ -22,6 +22,7 @@ import { notify } from "@/components/Notification";
import Separator from "@/components/Separator";
import { Workload } from "@/interfaces/Job";
import { useApiCall } from "@/utils/api";
import { useTranslations } from "next-intl";
type Props = {
peerID: string;
@@ -29,6 +30,7 @@ type Props = {
};
export function CreateDebugJobModalContent({ peerID, onSuccess }: Props) {
const t = useTranslations("common");
const jobRequest = useApiCall<Workload>(`/peers/${peerID}/jobs`, true);
const { mutate } = useSWRConfig();
@@ -177,7 +179,7 @@ export function CreateDebugJobModalContent({ peerID, onSuccess }: Props) {
<ModalFooter className="items-center">
<div className="flex gap-3 w-full justify-end">
<ModalClose asChild>
<Button variant="secondary">Cancel</Button>
<Button variant="secondary">{t("cancel")}</Button>
</ModalClose>
<Button
variant="primary"

View File

@@ -15,6 +15,7 @@ import useGroupHelper from "@/modules/groups/useGroupHelper";
import { useNetworksContext } from "@/modules/networks/NetworkProvider";
import { FolderGit2 } from "lucide-react";
import Separator from "@components/Separator";
import { useTranslations } from "next-intl";
type ResourceGroupModalProps = {
resource?: NetworkResource;
@@ -55,6 +56,7 @@ const ResourceGroupModalContent = ({
network,
onUpdated,
}: ModalProps) => {
const t = useTranslations("common");
const update = useApiCall<NetworkResource>(
`/networks/${network?.id}/resources/${resource?.id}`,
).put;
@@ -106,7 +108,7 @@ const ResourceGroupModalContent = ({
<ModalFooter className={"items-center"}>
<div className={"flex gap-3 w-full justify-end"}>
<ModalClose asChild={true}>
<Button variant={"secondary"}>Cancel</Button>
<Button variant={"secondary"}>{t("cancel")}</Button>
</ModalClose>
<Button variant={"primary"} onClick={updateResource}>

View File

@@ -45,6 +45,7 @@ import { SetupKey } from "@/interfaces/SetupKey";
import useGroupHelper from "@/modules/groups/useGroupHelper";
import { RoutingPeerMasqueradeSwitch } from "@/modules/networks/routing-peers/RoutingPeerMasqueradeSwitch";
import SetupModal from "@/modules/setup-netbird-modal/SetupModal";
import { useTranslations } from "next-intl";
type Props = {
network: Network;
@@ -63,6 +64,7 @@ export default function NetworkRoutingPeerModal({
onUpdated,
router,
}: Props) {
const t = useTranslations("common");
return (
<Modal open={open} onOpenChange={setOpen}>
<RoutingPeerModalContent
@@ -89,6 +91,7 @@ function RoutingPeerModalContent({
onCreated,
onUpdated,
}: ContentProps) {
const t = useTranslations("common");
const isRoutingPeer = router ? router.peer != "" : true;
const [tab, setTab] = useState("router");
@@ -363,7 +366,7 @@ function RoutingPeerModalContent({
{tab == "router" && (
<>
<ModalClose asChild={true}>
<Button variant={"secondary"}>Cancel</Button>
<Button variant={"secondary"}>{t("cancel")}</Button>
</ModalClose>
<Button
variant={"primary"}

View File

@@ -1,178 +1,182 @@
import FullTooltip from "@components/FullTooltip";
import {IconArrowRight} from "@tabler/icons-react";
import {cn} from "@utils/helpers";
import {HelpCircle} from "lucide-react";
import { IconArrowRight } from "@tabler/icons-react";
import { cn } from "@utils/helpers";
import { HelpCircle } from "lucide-react";
import * as React from "react";
import {useMemo} from "react";
import { useMemo } from "react";
import { useTranslations } from "next-intl";
import NetworkRoutesIcon from "@/assets/icons/NetworkRoutesIcon";
import PeerIcon from "@/assets/icons/PeerIcon";
import {Intent} from "@/modules/onboarding/Onboarding";
import { Intent } from "@/modules/onboarding/Onboarding";
type Props = {
onSelect: (intent: Intent) => void,
useCases?: string,
isBusiness?: boolean
type Props = {
onSelect: (intent: Intent) => void;
useCases?: string;
isBusiness?: boolean;
};
export const OnboardingIntent = ({onSelect, useCases, isBusiness}: Props) => {
const t = useTranslations("onboarding");
/**
* Recommend Networks if users ticks any of these use cases
*/
const isNetworksRecommended = useMemo(() => {
if (!useCases) return false;
const intents = [
"Zero Trust Security",
"Employee Remote Access",
"Business VPN",
"Site-to-Site Connectivity",
"IoT (Internet of Things)",
"MSP (Managed Service Provider)",
];
for (const intent of intents) {
if (useCases.toLowerCase().includes(intent.toLowerCase())) {
return true;
}
}
return false;
}, [useCases]);
export const OnboardingIntent = ({ onSelect, useCases, isBusiness }: Props) => {
const t = useTranslations("onboarding");
/**
* Recommend Networks if users ticks any of these use cases
*/
const isNetworksRecommended = useMemo(() => {
if (!useCases) return false;
const intents = [
"Zero Trust Security",
"Employee Remote Access",
"Business VPN",
"Site-to-Site Connectivity",
"IoT (Internet of Things)",
"MSP (Managed Service Provider)",
];
for (const intent of intents) {
if (useCases.toLowerCase().includes(intent.toLowerCase())) {
return true;
}
}
return false;
}, [useCases]);
/**
* Recommend P2P if users ticks any of these use cases
*/
const isP2PRecommended = useMemo(() => {
if (!useCases) return false;
const intents = [
"Homelab Automation",
"Home Remote Access",
"File Access",
"Gaming",
];
for (const intent of intents) {
if (useCases.toLowerCase().includes(intent.toLowerCase())) {
return true;
}
}
return false;
}, [useCases]);
/**
* Recommend P2P if users ticks any of these use cases
*/
const isP2PRecommended = useMemo(() => {
if (!useCases) return false;
const intents = [
"Homelab Automation",
"Home Remote Access",
"File Access",
"Gaming",
];
for (const intent of intents) {
if (useCases.toLowerCase().includes(intent.toLowerCase())) {
return true;
}
}
return false;
}, [useCases]);
return (
<div className={"relative flex flex-col h-full justify-between"}>
<div>
<h1 className={"text-xl text-center"}>{t("title")}</h1>
<div
className={
"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.
</div>
<div
className={cn(
"grid grid-cols-1 mt-8",
"border border-nb-gray-900 rounded-lg flex items-start flex-col relative bg-nb-gray-930/60 transition-all ",
)}
>
<IntentCard
title={"Peer-to-Peer Network"}
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."
}
recommended={isP2PRecommended}
icon={<PeerIcon size={18} className={"fill-netbird"}/>}
onClick={() => onSelect(Intent.P2P)}
/>
<IntentCard
title={"Remote Network Access"}
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."
}
recommended={isNetworksRecommended}
icon={<NetworkRoutesIcon size={18} className={"fill-netbird"}/>}
onClick={() => onSelect(Intent.NETWORKS)}
/>
</div>
</div>
</div>
);
return (
<div className={"relative flex flex-col h-full justify-between"}>
<div>
<h1 className={"text-xl text-center"}>{t("title")}</h1>
<div
className={
"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.
</div>
<div
className={cn(
"grid grid-cols-1 mt-8",
"border border-nb-gray-900 rounded-lg flex items-start flex-col relative bg-nb-gray-930/60 transition-all ",
)}
>
<IntentCard
title={"Peer-to-Peer Network"}
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."
}
recommended={isP2PRecommended}
icon={<PeerIcon size={18} className={"fill-netbird"} />}
onClick={() => onSelect(Intent.P2P)}
/>
<IntentCard
title={"Remote Network Access"}
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."
}
recommended={isNetworksRecommended}
icon={<NetworkRoutesIcon size={18} className={"fill-netbird"} />}
onClick={() => onSelect(Intent.NETWORKS)}
/>
</div>
</div>
</div>
);
};
type IntentCardProps = {
title: string;
description: string;
icon: React.ReactNode;
onClick: () => void;
recommended?: boolean;
title: string;
description: string;
icon: React.ReactNode;
onClick: () => void;
recommended?: boolean;
};
const IntentCard = ({
title,
description,
icon,
onClick,
recommended,
}: IntentCardProps) => {
return (
<button
className={
"px-6 py-6 flex items-start flex-col relative hover:bg-nb-gray-920 transition-all group first:border-b border-nb-gray-900"
}
onClick={onClick}
>
<div className={"flex gap-6"}>
<div
className={cn(
"h-10 w-10 flex items-center justify-center rounded-md shrink-0 mt-2",
"bg-nb-gray-900 border border-nb-gray-800 ",
)}
>
{icon}
</div>
<div className={"flex gap-4 items-center"}>
<div className={"text-left"}>
<h2
className={
"text-base font-medium mb-.5 group-hover:text-netbird transition-all inline-flex gap-x-2 gap-y-1 flex-wrap"
}
>
{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
<HelpCircle size={10} className={"ml-1"}/>
</span>
</FullTooltip>
)}
</h2>
<p className={"!text-nb-gray-300 text-[.85rem]"}>{description}</p>
</div>
<div
className={"h-full items-center text-nb-gray-400 hidden sm:flex"}
>
<IconArrowRight
size={24}
className={"shrink-0 group-hover:text-netbird"}
/>
</div>
</div>
</div>
</button>
);
title,
description,
icon,
onClick,
recommended,
}: IntentCardProps) => {
return (
<button
className={
"px-6 py-6 flex items-start flex-col relative hover:bg-nb-gray-920 transition-all group first:border-b border-nb-gray-900"
}
onClick={onClick}
>
<div className={"flex gap-6"}>
<div
className={cn(
"h-10 w-10 flex items-center justify-center rounded-md shrink-0 mt-2",
"bg-nb-gray-900 border border-nb-gray-800 ",
)}
>
{icon}
</div>
<div className={"flex gap-4 items-center"}>
<div className={"text-left"}>
<h2
className={
"text-base font-medium mb-.5 group-hover:text-netbird transition-all inline-flex gap-x-2 gap-y-1 flex-wrap"
}
>
{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
<HelpCircle size={10} className={"ml-1"} />
</span>
</FullTooltip>
)}
</h2>
<p className={"!text-nb-gray-300 text-[.85rem]"}>{description}</p>
</div>
<div
className={"h-full items-center text-nb-gray-400 hidden sm:flex"}
>
<IconArrowRight
size={24}
className={"shrink-0 group-hover:text-netbird"}
/>
</div>
</div>
</div>
</button>
);
};

View File

@@ -15,248 +15,249 @@ import { Policy } from "@/interfaces/Policy";
import { ResourceSingleAddressInput } from "@/modules/networks/resources/ResourceSingleAddressInput";
type Props = {
onNetworkCreation?: (network: Network) => void;
onResourceCreation?: (resource: NetworkResource) => void;
onBack: () => void;
onNetworkCreation?: (network: Network) => void;
onResourceCreation?: (resource: NetworkResource) => void;
onBack: () => void;
};
export const OnboardingAddResource = ({
onNetworkCreation,
onResourceCreation,
onBack,
onNetworkCreation,
onResourceCreation,
onBack,
}: Props) => {
const t = useTranslations("onboarding");
const [resourceType, setResourceType] = useState("");
const [resourceAddress, setResourceAddress] = useState("");
const [error, setError] = useState("");
const [network, setNetwork] = useState<Network>();
const { mutate } = useSWRConfig();
const { groups } = useGroups();
const t = useTranslations("onboarding");
const [resourceType, setResourceType] = useState("");
const [resourceAddress, setResourceAddress] = useState("");
const [error, setError] = useState("");
const [network, setNetwork] = useState<Network>();
const { mutate } = useSWRConfig();
const { groups } = useGroups();
const networkRequest = useApiCall<Network>("/networks", true);
const resourceRequest = useApiCall<NetworkResource>("/networks", true);
const policyRequest = useApiCall<Policy>("/policies", true);
const groupRequest = useApiCall<Group>("/groups", true);
const networkRequest = useApiCall<Network>("/networks", true);
const resourceRequest = useApiCall<NetworkResource>("/networks", true);
const policyRequest = useApiCall<Policy>("/policies", true);
const groupRequest = useApiCall<Group>("/groups", true);
const allGroupId = groups?.find((g) => g.name === "All")?.id;
const allGroupId = groups?.find((g) => g.name === "All")?.id;
/**
* Create a new network and add a resource to it
*/
const createResource = async () => {
let myNetwork = network;
/**
* Create a new network and add a resource to it
*/
const createResource = async () => {
let myNetwork = network;
if (!network) {
await networkRequest
.post({
name: "My First Network",
description: "Created during onboarding",
})
.then((n) => {
myNetwork = n;
onNetworkCreation?.(n);
setNetwork(n);
});
}
if (!network) {
await networkRequest
.post({
name: "My First Network",
description: "Created during onboarding",
})
.then((n) => {
myNetwork = n;
onNetworkCreation?.(n);
setNetwork(n);
});
}
if (!myNetwork) return;
if (!myNetwork) return;
notify({
title: "My First Network",
description: "Network & Resource created successfully",
loadingMessage: "Creating your resource...",
promise: resourceRequest
.post(
{
name: resourceType === "subnet" ? "My Subnet" : "My Resource",
description: "Created during onboarding",
address: normalizeHostCIDR(resourceAddress),
enabled: true,
groups: [],
},
`/${myNetwork.id}/resources`,
)
.then((r) => {
onResourceCreation?.(r);
createOnboardingGroups().then(({ usersGroup, routingPeersGroup }) => {
createUsersToResourcePolicy(r, usersGroup);
createUsersToRoutingPeersPolicy(r, usersGroup, routingPeersGroup);
});
}),
});
};
notify({
title: "My First Network",
description: "Network & Resource created successfully",
loadingMessage: "Creating your resource...",
promise: resourceRequest
.post(
{
name: resourceType === "subnet" ? "My Subnet" : "My Resource",
description: "Created during onboarding",
address: normalizeHostCIDR(resourceAddress),
enabled: true,
groups: [],
},
`/${myNetwork.id}/resources`,
)
.then((r) => {
onResourceCreation?.(r);
createOnboardingGroups().then(({ usersGroup, routingPeersGroup }) => {
createUsersToResourcePolicy(r, usersGroup);
createUsersToRoutingPeersPolicy(r, usersGroup, routingPeersGroup);
});
}),
});
};
/**
* Create Users and Routing Peers groups if they do not exist
*/
const createOnboardingGroups = async () => {
let usersGroup = groups?.find((group) => group.name === "Users");
let routingPeersGroup = groups?.find(
(group) => group.name === "Routing Peers",
);
if (!usersGroup) {
usersGroup = await groupRequest.post({
name: "Users",
});
}
if (!routingPeersGroup) {
routingPeersGroup = await groupRequest.post({
name: "Routing Peers",
});
}
return {
usersGroup,
routingPeersGroup,
};
};
/**
* Create Users and Routing Peers groups if they do not exist
*/
const createOnboardingGroups = async () => {
let usersGroup = groups?.find((group) => group.name === "Users");
let routingPeersGroup = groups?.find(
(group) => group.name === "Routing Peers",
);
if (!usersGroup) {
usersGroup = await groupRequest.post({
name: "Users",
});
}
if (!routingPeersGroup) {
routingPeersGroup = await groupRequest.post({
name: "Routing Peers",
});
}
return {
usersGroup,
routingPeersGroup,
};
};
/**
* Create a policy that allows users to access the resource
*/
const createUsersToResourcePolicy = async (
r: NetworkResource,
usersGroup: Group,
) => {
const isSubnet = r.type === "subnet";
/**
* Create a policy that allows users to access the resource
*/
const createUsersToResourcePolicy = async (
r: NetworkResource,
usersGroup: Group,
) => {
const isSubnet = r.type === "subnet";
await policyRequest.post({
name: `Users to ${r.name}`,
description: `Allows access to this ${
isSubnet ? `subnet ${r.address}` : `resource ${r.address}`
}`,
enabled: true,
rules: [
{
name: `Users to ${r.name}`,
description: `Allows access to this ${
isSubnet ? `subnet ${r.address}` : `resource ${r.address}`
}`,
enabled: true,
action: "accept",
bidirectional: true,
protocol: "all",
sources: usersGroup ? [usersGroup.id] : [allGroupId],
destinationResource: {
type: r.type,
id: r.id,
},
},
],
});
};
await policyRequest.post({
name: `Users to ${r.name}`,
description: `Allows access to this ${
isSubnet ? `subnet ${r.address}` : `resource ${r.address}`
}`,
enabled: true,
rules: [
{
name: `Users to ${r.name}`,
description: `Allows access to this ${
isSubnet ? `subnet ${r.address}` : `resource ${r.address}`
}`,
enabled: true,
action: "accept",
bidirectional: true,
protocol: "all",
sources: usersGroup ? [usersGroup.id] : [allGroupId],
destinationResource: {
type: r.type,
id: r.id,
},
},
],
});
};
/**
* Create a policy that allows users to access routing peers
*/
const createUsersToRoutingPeersPolicy = async (
r: NetworkResource,
usersGroup: Group,
routingPeersGroup: Group,
) => {
await policyRequest
.post({
name: `Users to Routing Peers`,
description: `Allows users to access routing peers`,
enabled: true,
rules: [
{
name: `Users to Routing Peers`,
description: `Allows users to access routing peers`,
enabled: true,
action: "accept",
bidirectional: true,
protocol: "all",
sources: usersGroup ? [usersGroup.id] : [allGroupId],
destinations: routingPeersGroup
? [routingPeersGroup.id]
: [allGroupId],
},
],
})
.then(() => {
mutate("/policies");
mutate("/groups");
});
};
/**
* Create a policy that allows users to access routing peers
*/
const createUsersToRoutingPeersPolicy = async (
r: NetworkResource,
usersGroup: Group,
routingPeersGroup: Group,
) => {
await policyRequest
.post({
name: `Users to Routing Peers`,
description: `Allows users to access routing peers`,
enabled: true,
rules: [
{
name: `Users to Routing Peers`,
description: `Allows users to access routing peers`,
enabled: true,
action: "accept",
bidirectional: true,
protocol: "all",
sources: usersGroup ? [usersGroup.id] : [allGroupId],
destinations: routingPeersGroup
? [routingPeersGroup.id]
: [allGroupId],
},
],
})
.then(() => {
mutate("/policies");
mutate("/groups");
});
};
const description = useMemo(() => {
if (resourceType === "ip")
return "Enter a single IPv4 or IPv6 address of your resource";
if (resourceType === "subnet") return "Enter a CIDR range of your network";
if (resourceType === "domain")
return "Enter a domain name of your resource";
}, [resourceType]);
const description = useMemo(() => {
if (resourceType === "ip")
return "Enter a single IPv4 or IPv6 address of your resource";
if (resourceType === "subnet") return "Enter a CIDR range of your network";
if (resourceType === "domain")
return "Enter a domain name of your resource";
}, [resourceType]);
const placeholder = useMemo(() => {
if (resourceType === "ip") return "e.g., 192.168.31.45 or 2001:db8::1";
if (resourceType === "subnet") return "e.g., 192.168.1.0/24 or 2001:db8::/64";
if (resourceType === "domain")
return "e.g., service.internal or *.services.internal";
}, [resourceType]);
const placeholder = useMemo(() => {
if (resourceType === "ip") return "e.g., 192.168.31.45 or 2001:db8::1";
if (resourceType === "subnet")
return "e.g., 192.168.1.0/24 or 2001:db8::/64";
if (resourceType === "domain")
return "e.g., service.internal or *.services.internal";
}, [resourceType]);
return (
<div className={"relative flex flex-col h-full gap-4"}>
<div className={"flex flex-col gap-8"}>
<div>
<h1 className={"text-xl text-center"}>{t("addResource")}</h1>
<div
className={
"text-sm text-nb-gray-300 font-light mt-2 block text-center sm:px-4"
}
>
Resources are your subnets, services, or machines inside your network.
Pick the type you want to connect to.
</div>
</div>
return (
<div className={"relative flex flex-col h-full gap-4"}>
<div className={"flex flex-col gap-8"}>
<div>
<h1 className={"text-xl text-center"}>{t("addResource")}</h1>
<div
className={
"text-sm text-nb-gray-300 font-light mt-2 block text-center sm:px-4"
}
>
Resources are your subnets, services, or machines inside your
network. Pick the type you want to connect to.
</div>
</div>
<RadioCardGroup value={resourceType} onValueChange={setResourceType}>
<RadioCard
value={"ip"}
title={"Single IP Address"}
icon={<WorkflowIcon size={12} />}
description={"IPv4 or IPv6 address like 192.168.31.45"}
/>
<RadioCard
value={"subnet"}
title={"Entire Subnet"}
icon={<NetworkIcon size={12} />}
description={"CIDR range like 192.168.0.0/24 or 2001:db8::/64"}
/>
<RadioCard
value={"domain"}
title={"Domain"}
icon={<GlobeIcon size={12} />}
description={
"A domain like service.internal or a wildcard like *.services.internal"
}
/>
</RadioCardGroup>
<RadioCardGroup value={resourceType} onValueChange={setResourceType}>
<RadioCard
value={"ip"}
title={"Single IP Address"}
icon={<WorkflowIcon size={12} />}
description={"IPv4 or IPv6 address like 192.168.31.45"}
/>
<RadioCard
value={"subnet"}
title={"Entire Subnet"}
icon={<NetworkIcon size={12} />}
description={"CIDR range like 192.168.0.0/24 or 2001:db8::/64"}
/>
<RadioCard
value={"domain"}
title={"Domain"}
icon={<GlobeIcon size={12} />}
description={
"A domain like service.internal or a wildcard like *.services.internal"
}
/>
</RadioCardGroup>
{resourceType && (
<ResourceSingleAddressInput
label={"What is the address of your resource?"}
value={resourceAddress}
onChange={setResourceAddress}
onError={setError}
description={description}
placeholder={placeholder}
/>
)}
{resourceType && (
<ResourceSingleAddressInput
label={"What is the address of your resource?"}
value={resourceAddress}
onChange={setResourceAddress}
onError={setError}
description={description}
placeholder={placeholder}
/>
)}
<div className={"flex gap-4"}>
<Button variant={"secondary"} className={"w-full"} onClick={onBack}>
Go Back
</Button>
<Button
variant={"primary"}
className={"w-full"}
onClick={createResource}
disabled={resourceAddress === "" || error !== ""}
>
Create Resource
</Button>
</div>
</div>
</div>
);
<div className={"flex gap-4"}>
<Button variant={"secondary"} className={"w-full"} onClick={onBack}>
Go Back
</Button>
<Button
variant={"primary"}
className={"w-full"}
onClick={createResource}
disabled={resourceAddress === "" || error !== ""}
>
Create Resource
</Button>
</div>
</div>
</div>
);
};

View File

@@ -22,6 +22,7 @@ import { SegmentedTabs } from "@components/SegmentedTabs";
import NetBirdIcon from "@/assets/icons/NetBirdIcon";
import { Peer } from "@/interfaces/Peer";
import { PeerSSHPolicyModal } from "@/modules/peer/PeerSSHPolicyModal";
import { useTranslations } from "next-intl";
type Props = {
open?: boolean;
@@ -36,6 +37,7 @@ export const PeerSSHInstructions = ({
onSuccess,
peer,
}: Props) => {
const t = useTranslations("common");
const [client, setClient] = useState("cli");
const [policyModal, setPolicyModal] = useState(false);
@@ -136,7 +138,7 @@ export const PeerSSHInstructions = ({
</div>
<div className={"flex gap-3 w-full justify-end"}>
<ModalClose asChild={true}>
<Button variant={"secondary"}>Cancel</Button>
<Button variant={"secondary"}>{t("cancel")}</Button>
</ModalClose>
<Button variant={"primary"} onClick={onSuccess}>

View File

@@ -20,6 +20,7 @@ import * as React from "react";
import { useState } from "react";
import { GeoLocation, GeoLocationCheck } from "@/interfaces/PostureCheck";
import { PostureCheckCard } from "@/modules/posture-checks/ui/PostureCheckCard";
import { useTranslations } from "next-intl";
type Props = {
value?: GeoLocationCheck;
@@ -69,6 +70,7 @@ export const PostureCheckGeoLocation = ({
};
const CheckContent = ({ value, onChange, disabled }: Props) => {
const t = useTranslations("common");
const [allowDenyLocation, setAllowDenyLocation] = useState<string>(
value?.action ? value.action : "allow",
);
@@ -190,7 +192,7 @@ const CheckContent = ({ value, onChange, disabled }: Props) => {
</div>
<div className={"flex gap-3 w-full justify-end"}>
<ModalClose asChild={true}>
<Button variant={"secondary"}>Cancel</Button>
<Button variant={"secondary"}>{t("cancel")}</Button>
</ModalClose>
<Button
variant={"primary"}

View File

@@ -13,6 +13,7 @@ import { useMemo, useState } from "react";
import NetBirdIcon from "@/assets/icons/NetBirdIcon";
import { NetBirdVersionCheck } from "@/interfaces/PostureCheck";
import { PostureCheckCard } from "@/modules/posture-checks/ui/PostureCheckCard";
import { useTranslations } from "next-intl";
type Props = {
value?: NetBirdVersionCheck;
@@ -54,6 +55,7 @@ export const PostureCheckNetBirdVersion = ({
};
const CheckContent = ({ value, onChange, disabled }: Props) => {
const t = useTranslations("common");
const [version, setVersion] = useState(value?.min_version || "");
const versionError = useMemo(() => {
@@ -111,7 +113,7 @@ const CheckContent = ({ value, onChange, disabled }: Props) => {
</div>
<div className={"flex gap-3 w-full justify-end"}>
<ModalClose asChild={true}>
<Button variant={"secondary"}>Cancel</Button>
<Button variant={"secondary"}>{t("cancel")}</Button>
</ModalClose>
<Button
variant={"primary"}

View File

@@ -39,6 +39,7 @@ import {
windowsKernelVersions,
} from "@/interfaces/PostureCheck";
import { PostureCheckCard } from "@/modules/posture-checks/ui/PostureCheckCard";
import { useTranslations } from "next-intl";
type Props = {
value?: OperatingSystemVersionCheck;
@@ -81,6 +82,7 @@ export const PostureCheckOperatingSystem = ({
};
const CheckContent = ({ value, onChange, disabled }: Props) => {
const t = useTranslations("common");
const [tab] = useState(String(OperatingSystem.LINUX));
const firstTimeCheck = value === undefined;
@@ -244,7 +246,7 @@ const CheckContent = ({ value, onChange, disabled }: Props) => {
</div>
<div className={"flex gap-3 w-full justify-end"}>
<ModalClose asChild={true}>
<Button variant={"secondary"}>Cancel</Button>
<Button variant={"secondary"}>{t("cancel")}</Button>
</ModalClose>
<Button
disabled={!!versionError}

View File

@@ -20,6 +20,7 @@ import * as React from "react";
import { useMemo, useState } from "react";
import { PeerNetworkRangeCheck } from "@/interfaces/PostureCheck";
import { PostureCheckCard } from "@/modules/posture-checks/ui/PostureCheckCard";
import { useTranslations } from "next-intl";
type Props = {
value?: PeerNetworkRangeCheck;
@@ -67,6 +68,7 @@ interface NetworkRange {
}
const CheckContent = ({ value, onChange, disabled }: Props) => {
const t = useTranslations("common");
const [allowOrDeny, setAllowOrDeny] = useState<string>(
value?.action ? value.action : "allow",
);
@@ -205,7 +207,7 @@ const CheckContent = ({ value, onChange, disabled }: Props) => {
</div>
<div className={"flex gap-3 w-full justify-end"}>
<ModalClose asChild={true}>
<Button variant={"secondary"}>Cancel</Button>
<Button variant={"secondary"}>{t("cancel")}</Button>
</ModalClose>
<Button
variant={"primary"}

View File

@@ -20,6 +20,7 @@ import AppleIcon from "@/assets/icons/AppleIcon";
import WindowsIcon from "@/assets/icons/WindowsIcon";
import { Process, ProcessCheck } from "@/interfaces/PostureCheck";
import { PostureCheckCard } from "@/modules/posture-checks/ui/PostureCheckCard";
import { useTranslations } from "next-intl";
type Props = {
value?: ProcessCheck;
@@ -58,6 +59,7 @@ export const PostureCheckProcess = ({ value, onChange, disabled }: Props) => {
};
const CheckContent = ({ value, onChange, disabled }: Props) => {
const t = useTranslations("common");
const [processes, setProcesses] = useState<Process[]>(
value?.processes
? value.processes.map((p) => {
@@ -288,7 +290,7 @@ const CheckContent = ({ value, onChange, disabled }: Props) => {
</div>
<div className={"flex gap-3 w-full justify-end"}>
<ModalClose asChild={true}>
<Button variant={"secondary"}>Cancel</Button>
<Button variant={"secondary"}>{t("cancel")}</Button>
</ModalClose>
<Button
variant={"primary"}

View File

@@ -25,6 +25,7 @@ import { useMemo, useState } from "react";
import { OperatingSystem } from "@/interfaces/OperatingSystem";
import { Peer } from "@/interfaces/Peer";
import { SSH_DOCS_LINK } from "@/modules/remote-access/ssh/useSSH";
import { useTranslations } from "next-intl";
type Props = {
open: boolean;
@@ -33,6 +34,7 @@ type Props = {
};
export const SSHCredentialsModal = ({ open, onOpenChange, peer }: Props) => {
const t = useTranslations("common");
const [username, setUsername] = useState(
getOperatingSystem(peer.os) === OperatingSystem.WINDOWS
? "Administrator"
@@ -43,6 +45,7 @@ export const SSHCredentialsModal = ({ open, onOpenChange, peer }: Props) => {
const [port, setPort] = useState(initialPort);
const userNameError = useMemo(() => {
if (username?.length === 0) return "Username cannot be empty";
}, [username]);
@@ -136,7 +139,7 @@ export const SSHCredentialsModal = ({ open, onOpenChange, peer }: Props) => {
</div>
<div className={"flex gap-3 w-full justify-end"}>
<ModalClose asChild={true}>
<Button variant={"secondary"}>Cancel</Button>
<Button variant={"secondary"}>{t("cancel")}</Button>
</ModalClose>
<Button

View File

@@ -17,6 +17,7 @@ import {
UserIcon,
} from "lucide-react";
import React, { useMemo, useReducer, useRef } from "react";
import { useTranslations } from "next-intl";
import { useHasChanges } from "@/hooks/useHasChanges";
import type { HeaderAuthConfig } from "@/interfaces/ReverseProxy";
@@ -201,6 +202,7 @@ export default function AuthHeaderModal({
onSave,
onRemove,
}: Readonly<Props>) {
const t = useTranslations("common");
const [items, dispatch] = useReducer(
headersReducer,
currentHeaders,
@@ -280,7 +282,7 @@ export default function AuthHeaderModal({
</Button>
<div className="flex gap-3">
<ModalClose asChild>
<Button variant="secondary">Cancel</Button>
<Button variant="secondary">{t("cancel")}</Button>
</ModalClose>
<Button
variant="primary"
@@ -296,7 +298,7 @@ export default function AuthHeaderModal({
<div />
<div className="flex gap-3">
<ModalClose asChild>
<Button variant="secondary">Cancel</Button>
<Button variant="secondary">{t("cancel")}</Button>
</ModalClose>
<Button
variant="primary"

View File

@@ -8,6 +8,7 @@ import { Group } from "@/interfaces/Group";
import { useUsers } from "@/contexts/UsersProvider";
import Badge from "@components/Badge";
import { CircleUser } from "lucide-react";
import { useTranslations } from "next-intl";
type Props = {
open: boolean;
@@ -26,6 +27,7 @@ export default function AuthNetBirdOnlyModal({
onSave,
onRemove,
}: Readonly<Props>) {
const t = useTranslations("common");
const { users } = useUsers();
const [groups, setGroups] = useState<Group[]>(currentGroups);
const isEditing = isEnabled;
@@ -74,7 +76,7 @@ export default function AuthNetBirdOnlyModal({
</Button>
<div className="flex gap-3">
<ModalClose asChild>
<Button variant="secondary">Cancel</Button>
<Button variant="secondary">{t("cancel")}</Button>
</ModalClose>
<Button
variant="primary"
@@ -90,7 +92,7 @@ export default function AuthNetBirdOnlyModal({
<div />
<div className="flex gap-3">
<ModalClose asChild>
<Button variant="secondary">Cancel</Button>
<Button variant="secondary">{t("cancel")}</Button>
</ModalClose>
<Button
variant="primary"

View File

@@ -4,6 +4,7 @@ import { Modal, ModalClose, ModalContent } from "@components/modal/Modal";
import ModalHeader from "@components/modal/ModalHeader";
import React, { useState } from "react";
import { GradientFadedBackground } from "@components/ui/GradientFadedBackground";
import { useTranslations } from "next-intl";
type Props = {
open: boolean;
@@ -22,6 +23,7 @@ export default function AuthPasswordModal({
onSave,
onRemove,
}: Readonly<Props>) {
const t = useTranslations("common");
const [password, setPassword] = useState(currentPassword);
const [isMasked, setIsMasked] = useState(isEnabled && currentPassword === "");
const isEditing = isEnabled;
@@ -82,7 +84,7 @@ export default function AuthPasswordModal({
</Button>
<div className="flex gap-3">
<ModalClose asChild>
<Button variant="secondary">Cancel</Button>
<Button variant="secondary">{t("cancel")}</Button>
</ModalClose>
<Button
variant="primary"
@@ -98,7 +100,7 @@ export default function AuthPasswordModal({
<div />
<div className="flex gap-3">
<ModalClose asChild>
<Button variant="secondary">Cancel</Button>
<Button variant="secondary">{t("cancel")}</Button>
</ModalClose>
<Button
variant="primary"

View File

@@ -4,6 +4,7 @@ import ModalHeader from "@components/modal/ModalHeader";
import PinCodeInput from "@components/PinCodeInput";
import { GradientFadedBackground } from "@components/ui/GradientFadedBackground";
import React, { useState } from "react";
import { useTranslations } from "next-intl";
type Props = {
open: boolean;
@@ -22,6 +23,7 @@ export default function AuthPinModal({
onSave,
onRemove,
}: Readonly<Props>) {
const t = useTranslations("common");
const [pin, setPin] = useState(currentPin);
const [isMasked, setIsMasked] = useState(isEnabled && currentPin === "");
const isEditing = isEnabled;
@@ -73,7 +75,7 @@ export default function AuthPinModal({
</Button>
<div className="flex gap-3">
<ModalClose asChild>
<Button variant="secondary">Cancel</Button>
<Button variant="secondary">{t("cancel")}</Button>
</ModalClose>
<Button
variant="primary"
@@ -89,7 +91,7 @@ export default function AuthPinModal({
<div />
<div className="flex gap-3">
<ModalClose asChild>
<Button variant="secondary">Cancel</Button>
<Button variant="secondary">{t("cancel")}</Button>
</ModalClose>
<Button
variant="primary"

View File

@@ -8,6 +8,7 @@ import { Group } from "@/interfaces/Group";
import { useUsers } from "@/contexts/UsersProvider";
import Badge from "@components/Badge";
import { CircleUser } from "lucide-react";
import { useTranslations } from "next-intl";
type Props = {
open: boolean;
@@ -26,6 +27,7 @@ export default function AuthSSOModal({
onSave,
onRemove,
}: Readonly<Props>) {
const t = useTranslations("common");
const { users } = useUsers();
const [groups, setGroups] = useState<Group[]>(currentGroups);
const isEditing = isEnabled;
@@ -75,7 +77,7 @@ export default function AuthSSOModal({
</Button>
<div className="flex gap-3">
<ModalClose asChild>
<Button variant="secondary">Cancel</Button>
<Button variant="secondary">{t("cancel")}</Button>
</ModalClose>
<Button variant="primary" onClick={handleSave}>
Save
@@ -87,7 +89,7 @@ export default function AuthSSOModal({
<div />
<div className="flex gap-3">
<ModalClose asChild>
<Button variant="secondary">Cancel</Button>
<Button variant="secondary">{t("cancel")}</Button>
</ModalClose>
<Button variant="primary" onClick={handleSave}>
Add SSO

View File

@@ -36,6 +36,7 @@ import {
REVERSE_PROXY_SELFHOSTED_ROUTING_DOCS_LINK,
ReverseProxyClusterToken,
} from "@/interfaces/ReverseProxy";
import { useTranslations } from "next-intl";
type Props = {
open: boolean;
@@ -73,6 +74,7 @@ const renderHighlightedCommand = (command: string, highlights: string[]) => {
};
export const ClustersModal = ({ open, onOpenChange }: Props) => {
const t = useTranslations("common");
const { mutate } = useSWRConfig();
const [tab, setTab] = useState("domain");
const [domain, setDomain] = useState("");
@@ -461,7 +463,7 @@ spec:
{tab === "domain" && (
<>
<ModalClose asChild={true}>
<Button variant={"secondary"}>Cancel</Button>
<Button variant={"secondary"}>{t("cancel")}</Button>
</ModalClose>
<Button
variant={"primary"}

View File

@@ -28,6 +28,7 @@ import {
SelectDropdown,
SelectOption,
} from "@components/select/SelectDropdown";
import { useTranslations } from "next-intl";
type Props = {
open: boolean;
@@ -40,6 +41,7 @@ export const CustomDomainModal = ({
onOpenChange,
onDomainSubmit,
}: Props) => {
const t = useTranslations("common");
const { domains } = useReverseProxies();
const [domain, setDomain] = useState("");
const [selectedCluster, setSelectedCluster] = useState("");
@@ -178,7 +180,7 @@ export const CustomDomainModal = ({
</div>
<div className={"flex gap-3 w-full justify-end"}>
<ModalClose asChild={true}>
<Button variant={"secondary"}>Cancel</Button>
<Button variant={"secondary"}>{t("cancel")}</Button>
</ModalClose>
<Button

View File

@@ -24,6 +24,7 @@ import { useRoutes } from "@/contexts/RoutesProvider";
import { Peer } from "@/interfaces/Peer";
import { GroupedRoute, Route } from "@/interfaces/Route";
import useGroupHelper from "@/modules/groups/useGroupHelper";
import { useTranslations } from "next-intl";
type Props = {
groupedRoute?: GroupedRoute;
@@ -38,6 +39,7 @@ export default function RouteAddRoutingPeerModal({
setModal,
peer,
}: Props) {
const t = useTranslations("common");
return (
<Modal open={modal} onOpenChange={setModal}>
{modal && (
@@ -58,6 +60,7 @@ type ModalProps = {
};
function Content({ onSuccess, groupedRoute, peer }: ModalProps) {
const t = useTranslations("common");
const { createRoute } = useRoutes();
const [routingPeer, setRoutingPeer] = useState<Peer | undefined>(
@@ -234,7 +237,7 @@ function Content({ onSuccess, groupedRoute, peer }: ModalProps) {
</div>
<div className={"flex gap-3 w-full justify-end"}>
<ModalClose asChild={true}>
<Button variant={"secondary"}>Cancel</Button>
<Button variant={"secondary"}>{t("cancel")}</Button>
</ModalClose>
<Button

View File

@@ -58,6 +58,7 @@ import { Route } from "@/interfaces/Route";
import { AccessControlModalContent } from "@/modules/access-control/AccessControlModal";
import useGroupHelper from "@/modules/groups/useGroupHelper";
import { RoutingPeerMasqueradeSwitch } from "@/modules/networks/routing-peers/RoutingPeerMasqueradeSwitch";
import { useTranslations } from "next-intl";
type Props = {
children?: React.ReactNode;
@@ -72,6 +73,7 @@ export default function RouteModal({
setOpen,
distributionGroups,
}: Props) {
const t = useTranslations("common");
const { confirm } = useDialog();
const router = useRouter();
const [routePolicyModal, setRoutePolicyModal] = useState(false);
@@ -158,6 +160,7 @@ export function RouteModalContent({
isFirstExitNode = false,
distributionGroups,
}: ModalProps) {
const t = useTranslations("common");
const { createRoute } = useRoutes();
const [tab, setTab] = useState(
exitNode && peer ? "access-control" : "network",
@@ -806,7 +809,7 @@ export function RouteModalContent({
<div className={"flex gap-3 w-full justify-end"}>
{(tab == "network" || (tab == "access-control" && exitNode)) && (
<ModalClose asChild={true}>
<Button variant={"secondary"}>Cancel</Button>
<Button variant={"secondary"}>{t("cancel")}</Button>
</ModalClose>
)}

View File

@@ -43,6 +43,7 @@ import { Peer } from "@/interfaces/Peer";
import { Route } from "@/interfaces/Route";
import useGroupHelper from "@/modules/groups/useGroupHelper";
import { RoutingPeerMasqueradeSwitch } from "@/modules/networks/routing-peers/RoutingPeerMasqueradeSwitch";
import { useTranslations } from "next-intl";
type Props = {
route: Route;
@@ -57,6 +58,7 @@ export default function RouteUpdateModal({
onOpenChange,
cell,
}: Props) {
const t = useTranslations("common");
return (
<>
<Modal open={open} onOpenChange={onOpenChange} key={open ? 1 : 0}>
@@ -79,6 +81,7 @@ type ModalProps = {
};
function RouteUpdateModalContent({ onSuccess, route, cell }: ModalProps) {
const t = useTranslations("common");
const { updateRoute } = useRoutes();
const { peers } = usePeers();
const { groups: allGroups } = useGroups();
@@ -528,7 +531,7 @@ function RouteUpdateModalContent({ onSuccess, route, cell }: ModalProps) {
</div>
<div className={"flex gap-3 w-full justify-end"}>
<ModalClose asChild={true}>
<Button variant={"secondary"}>Cancel</Button>
<Button variant={"secondary"}>{t("cancel")}</Button>
</ModalClose>
<Button

View File

@@ -9,11 +9,11 @@ import { notify } from "@components/Notification";
import Paragraph from "@components/Paragraph";
import { SmallBadge } from "@components/ui/SmallBadge";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@components/Select";
import { useExpirationState } from "@hooks/useExpirationState";
import { convertToSeconds } from "@hooks/useTimeFormatter";
@@ -22,12 +22,12 @@ import { useApiCall } from "@utils/api";
import { cn } from "@utils/helpers";
import { useTranslations } from "next-intl";
import {
CalendarClock,
ExternalLinkIcon,
KeyRound,
ShieldIcon,
ShieldUserIcon,
TimerResetIcon,
CalendarClock,
ExternalLinkIcon,
KeyRound,
ShieldIcon,
ShieldUserIcon,
TimerResetIcon,
} from "lucide-react";
import React, { useState } from "react";
import { useSWRConfig } from "swr";
@@ -37,329 +37,327 @@ import { useHasChanges } from "@/hooks/useHasChanges";
import { Account } from "@/interfaces/Account";
type Props = {
account: Account;
account: Account;
};
export default function AuthenticationTab({ account }: Readonly<Props>) {
const t = useTranslations("settings");
const { permission } = usePermissions();
const t = useTranslations("settings");
const { permission } = usePermissions();
const { mutate } = useSWRConfig();
const { mutate } = useSWRConfig();
/**
* Peer approval enabled
*/
const [peerApproval, setPeerApproval] = useState<boolean>(() => {
try {
return account?.settings?.extra?.peer_approval_enabled || false;
} catch (error) {
return false;
}
});
/**
* Peer approval enabled
*/
const [peerApproval, setPeerApproval] = useState<boolean>(() => {
try {
return account?.settings?.extra?.peer_approval_enabled || false;
} catch (error) {
return false;
}
});
/**
* User approval required
*/
const [userApprovalRequired, setUserApprovalRequired] = useState<boolean>(
() => {
try {
return account?.settings?.extra?.user_approval_required || false;
} catch (error) {
return false;
}
},
);
/**
* User approval required
*/
const [userApprovalRequired, setUserApprovalRequired] = useState<boolean>(
() => {
try {
return account?.settings?.extra?.user_approval_required || false;
} catch (error) {
return false;
}
},
);
// Local MFA (UI only, not wired to the backend yet)
const [isLocalMFAEnabled, setIsLocalMFAEnabled] = useState<boolean>(() => {
try {
return account?.settings?.local_mfa_enabled || false;
} catch (error) {
return false;
}
});
// Local MFA (UI only, not wired to the backend yet)
const [isLocalMFAEnabled, setIsLocalMFAEnabled] = useState<boolean>(() => {
try {
return account?.settings?.local_mfa_enabled || false;
} catch (error) {
return false;
}
});
// Peer Expiration
const [
loginExpiration,
setLoginExpiration,
expiresIn,
setExpiresIn,
expireInterval,
setExpireInterval,
] = useExpirationState({
enabled: account.settings.peer_login_expiration_enabled,
expirationInSeconds: account.settings.peer_login_expiration || 86400,
});
// Peer Expiration
const [
loginExpiration,
setLoginExpiration,
expiresIn,
setExpiresIn,
expireInterval,
setExpireInterval,
] = useExpirationState({
enabled: account.settings.peer_login_expiration_enabled,
expirationInSeconds: account.settings.peer_login_expiration || 86400,
});
// Peer Inactivity Expiration
const [
peerInactivityExpirationEnabled,
setPeerInactivityExpirationEnabled,
peerInactivityExpiresIn,
peerInactivityExpireInterval,
] = useExpirationState({
enabled: account.settings.peer_inactivity_expiration_enabled,
expirationInSeconds: account.settings.peer_inactivity_expiration || 600,
timeRange: ["minutes", "hours", "days"],
});
// Peer Inactivity Expiration
const [
peerInactivityExpirationEnabled,
setPeerInactivityExpirationEnabled,
peerInactivityExpiresIn,
peerInactivityExpireInterval,
] = useExpirationState({
enabled: account.settings.peer_inactivity_expiration_enabled,
expirationInSeconds: account.settings.peer_inactivity_expiration || 600,
timeRange: ["minutes", "hours", "days"],
});
/**
* Save changes
*/
const saveRequest = useApiCall<Account>("/accounts/" + account.id);
/**
* Save changes
*/
const saveRequest = useApiCall<Account>("/accounts/" + account.id);
const { hasChanges, updateRef } = useHasChanges([
peerApproval,
userApprovalRequired,
loginExpiration,
expiresIn,
expireInterval,
peerInactivityExpirationEnabled,
peerInactivityExpiresIn,
peerInactivityExpireInterval,
isLocalMFAEnabled,
]);
const { hasChanges, updateRef } = useHasChanges([
peerApproval,
userApprovalRequired,
loginExpiration,
expiresIn,
expireInterval,
peerInactivityExpirationEnabled,
peerInactivityExpiresIn,
peerInactivityExpireInterval,
isLocalMFAEnabled,
]);
const saveChanges = async () => {
const expiration = convertToSeconds(expiresIn, expireInterval);
const saveChanges = async () => {
const expiration = convertToSeconds(expiresIn, expireInterval);
notify({
title: "Save Authentication Settings",
description: "Authentication settings successfully saved.",
promise: saveRequest
.put({
id: account.id,
settings: {
...account.settings,
peer_login_expiration_enabled: loginExpiration,
peer_login_expiration: loginExpiration ? expiration : 86400,
peer_inactivity_expiration_enabled: loginExpiration
? peerInactivityExpirationEnabled
: false,
peer_inactivity_expiration: 600,
extra: {
...account.settings?.extra,
peer_approval_enabled: peerApproval,
user_approval_required: userApprovalRequired,
},
local_mfa_enabled: isLocalMFAEnabled
},
} as Account)
.then(() => {
mutate("/accounts");
updateRef([
peerApproval,
userApprovalRequired,
loginExpiration,
expiresIn,
expireInterval,
peerInactivityExpirationEnabled,
peerInactivityExpiresIn,
peerInactivityExpireInterval,
]);
}),
loadingMessage: "Saving the authentication settings...",
});
};
notify({
title: "Save Authentication Settings",
description: "Authentication settings successfully saved.",
promise: saveRequest
.put({
id: account.id,
settings: {
...account.settings,
peer_login_expiration_enabled: loginExpiration,
peer_login_expiration: loginExpiration ? expiration : 86400,
peer_inactivity_expiration_enabled: loginExpiration
? peerInactivityExpirationEnabled
: false,
peer_inactivity_expiration: 600,
extra: {
...account.settings?.extra,
peer_approval_enabled: peerApproval,
user_approval_required: userApprovalRequired,
},
local_mfa_enabled: isLocalMFAEnabled,
},
} as Account)
.then(() => {
mutate("/accounts");
updateRef([
peerApproval,
userApprovalRequired,
loginExpiration,
expiresIn,
expireInterval,
peerInactivityExpirationEnabled,
peerInactivityExpiresIn,
peerInactivityExpireInterval,
]);
}),
loadingMessage: "Saving the authentication settings...",
});
};
return (
<Tabs.Content value={"authentication"}>
<div className={"p-default py-6 max-w-2xl"}>
<Breadcrumbs>
<Breadcrumbs.Item
href={"/settings"}
label={"Settings"}
icon={<SettingsIcon size={13} />}
/>
<Breadcrumbs.Item
href={"/settings"}
label={t("authentication")}
icon={<ShieldIcon size={14} />}
active
/>
</Breadcrumbs>
<div className={"flex items-start justify-between"}>
<div>
<h1>{t("authentication")}</h1>
<Paragraph>
Learn more about
<InlineLink
href={
"https://docs.netbird.io/how-to/enforce-periodic-user-authentication"
}
target={"_blank"}
>
Authentication
<ExternalLinkIcon size={12} />
</InlineLink>
</Paragraph>
</div>
return (
<Tabs.Content value={"authentication"}>
<div className={"p-default py-6 max-w-2xl"}>
<Breadcrumbs>
<Breadcrumbs.Item
href={"/settings"}
label={"Settings"}
icon={<SettingsIcon size={13} />}
/>
<Breadcrumbs.Item
href={"/settings"}
label={t("authentication")}
icon={<ShieldIcon size={14} />}
active
/>
</Breadcrumbs>
<div className={"flex items-start justify-between"}>
<div>
<h1>{t("authentication")}</h1>
<Paragraph>
Learn more about
<InlineLink
href={
"https://docs.netbird.io/how-to/enforce-periodic-user-authentication"
}
target={"_blank"}
>
Authentication
<ExternalLinkIcon size={12} />
</InlineLink>
</Paragraph>
</div>
<Button
variant={"primary"}
disabled={!hasChanges || !permission.settings.update}
onClick={saveChanges}
data-cy={"save-authentication-settings"}
>
Save Changes
</Button>
</div>
<Button
variant={"primary"}
disabled={!hasChanges || !permission.settings.update}
onClick={saveChanges}
data-cy={"save-authentication-settings"}
>
Save Changes
</Button>
</div>
<div className={"flex flex-col gap-6 w-full mt-8 mb-3"}>
<div className={"flex flex-col"}>
<FancyToggleSwitch
value={userApprovalRequired}
onChange={setUserApprovalRequired}
dataCy={"user-approval-required"}
label={
<>
<ShieldUserIcon size={15} />
User Approval Required
</>
}
helpText={
<>
Require manual approval for new users joining via <br />
domain matching. Users will be blocked until approved.
</>
}
disabled={!permission.settings.update}
/>
</div>
<div className={"flex flex-col gap-6 w-full mt-8 mb-3"}>
<div className={"flex flex-col"}>
<FancyToggleSwitch
value={userApprovalRequired}
onChange={setUserApprovalRequired}
dataCy={"user-approval-required"}
label={
<>
<ShieldUserIcon size={15} />
User Approval Required
</>
}
helpText={
<>
Require manual approval for new users joining via <br />
domain matching. Users will be blocked until approved.
</>
}
disabled={!permission.settings.update}
/>
</div>
{!account.settings.local_auth_disabled && account.settings.embedded_idp_enabled ?
(
<div className={"flex flex-col"}>
<FancyToggleSwitch
value={isLocalMFAEnabled}
onChange={setIsLocalMFAEnabled}
dataCy={"local-mfa-enabled"}
label={
<>
<KeyRound size={15} />
Enable Local MFA
<SmallBadge
text={"Beta"}
variant={"sky"}
className={"text-[9px] leading-none py-[3px] px-[5px]"}
textClassName={"top-0"}
/>
</>
}
helpText={
<>
Require multi-factor authentication for users
<br />
authenticating with local credentials.
</>
}
disabled={!permission.settings.update}
/>
</div>
) : null
}
{!account.settings.local_auth_disabled &&
account.settings.embedded_idp_enabled ? (
<div className={"flex flex-col"}>
<FancyToggleSwitch
value={isLocalMFAEnabled}
onChange={setIsLocalMFAEnabled}
dataCy={"local-mfa-enabled"}
label={
<>
<KeyRound size={15} />
Enable Local MFA
<SmallBadge
text={"Beta"}
variant={"sky"}
className={"text-[9px] leading-none py-[3px] px-[5px]"}
textClassName={"top-0"}
/>
</>
}
helpText={
<>
Require multi-factor authentication for users
<br />
authenticating with local credentials.
</>
}
disabled={!permission.settings.update}
/>
</div>
) : null}
<div className={"flex flex-col"}>
<FancyToggleSwitch
value={loginExpiration}
onChange={(state) => {
setLoginExpiration(state);
!state && setPeerInactivityExpirationEnabled(false);
}}
dataCy={"peer-login-expiration"}
label={
<>
<TimerResetIcon size={15} />
Peer Session Expiration
</>
}
helpText={
<>
Request periodic re-authentication of peers <br />
registered with SSO.
</>
}
disabled={!permission.settings.update}
/>
<div className={"flex flex-col"}>
<FancyToggleSwitch
value={loginExpiration}
onChange={(state) => {
setLoginExpiration(state);
!state && setPeerInactivityExpirationEnabled(false);
}}
dataCy={"peer-login-expiration"}
label={
<>
<TimerResetIcon size={15} />
Peer Session Expiration
</>
}
helpText={
<>
Request periodic re-authentication of peers <br />
registered with SSO.
</>
}
disabled={!permission.settings.update}
/>
<div
className={cn(
"border border-nb-gray-900 border-t-0 rounded-b-md bg-nb-gray-940 px-[1.28rem] pt-3 pb-5 flex flex-col gap-4 mx-[0.25rem]",
!loginExpiration || !permission.settings.update
? "opacity-50 pointer-events-none"
: "bg-nb-gray-930/80",
)}
>
<div className={cn("flex justify-between gap-10 mt-2")}>
<div className={"w-full"}>
<Label>Session Expiration</Label>
<HelpText>
Time after which every peer added with SSO login will
require re-authentication.
</HelpText>
</div>
<div className={"w-full flex gap-3"}>
<Input
placeholder={"7"}
maxWidthClass={"min-w-[100px]"}
min={1}
disabled={!loginExpiration || !permission.settings.update}
data-cy={"peer-login-expiration-input"}
max={180}
className={"w-full"}
value={expiresIn}
type={"number"}
onChange={(e) => setExpiresIn(e.target.value)}
/>
<Select
disabled={!loginExpiration || !permission.settings.update}
value={expireInterval}
onValueChange={(v) => setExpireInterval(v)}
>
<SelectTrigger
className="w-full"
data-cy={"peer-login-expiration-select"}
>
<div className={"flex items-center gap-3"}>
<CalendarClock
size={15}
className={"text-nb-gray-300"}
/>
<SelectValue
placeholder="Select interval..."
data-cy={"peer-login-expiration-select-value"}
/>
</div>
</SelectTrigger>
<SelectContent
data-cy={"peer-login-expiration-select-content"}
>
<SelectItem value="days">Days</SelectItem>
<SelectItem value="hours">Hours</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<FancyToggleSwitch
variant={"blank"}
value={peerInactivityExpirationEnabled}
onChange={setPeerInactivityExpirationEnabled}
dataCy={"peer-inactivity-expiration"}
label={<>Require login after disconnect</>}
disabled={!permission.settings.update}
helpText={
<>
Enable to require authentication after users disconnect from
management for 10 minutes.
</>
}
/>
</div>
</div>
</div>
</div>
</Tabs.Content>
);
<div
className={cn(
"border border-nb-gray-900 border-t-0 rounded-b-md bg-nb-gray-940 px-[1.28rem] pt-3 pb-5 flex flex-col gap-4 mx-[0.25rem]",
!loginExpiration || !permission.settings.update
? "opacity-50 pointer-events-none"
: "bg-nb-gray-930/80",
)}
>
<div className={cn("flex justify-between gap-10 mt-2")}>
<div className={"w-full"}>
<Label>Session Expiration</Label>
<HelpText>
Time after which every peer added with SSO login will
require re-authentication.
</HelpText>
</div>
<div className={"w-full flex gap-3"}>
<Input
placeholder={"7"}
maxWidthClass={"min-w-[100px]"}
min={1}
disabled={!loginExpiration || !permission.settings.update}
data-cy={"peer-login-expiration-input"}
max={180}
className={"w-full"}
value={expiresIn}
type={"number"}
onChange={(e) => setExpiresIn(e.target.value)}
/>
<Select
disabled={!loginExpiration || !permission.settings.update}
value={expireInterval}
onValueChange={(v) => setExpireInterval(v)}
>
<SelectTrigger
className="w-full"
data-cy={"peer-login-expiration-select"}
>
<div className={"flex items-center gap-3"}>
<CalendarClock
size={15}
className={"text-nb-gray-300"}
/>
<SelectValue
placeholder="Select interval..."
data-cy={"peer-login-expiration-select-value"}
/>
</div>
</SelectTrigger>
<SelectContent
data-cy={"peer-login-expiration-select-content"}
>
<SelectItem value="days">Days</SelectItem>
<SelectItem value="hours">Hours</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<FancyToggleSwitch
variant={"blank"}
value={peerInactivityExpirationEnabled}
onChange={setPeerInactivityExpirationEnabled}
dataCy={"peer-inactivity-expiration"}
label={<>Require login after disconnect</>}
disabled={!permission.settings.update}
helpText={
<>
Enable to require authentication after users disconnect from
management for 10 minutes.
</>
}
/>
</div>
</div>
</div>
</div>
</Tabs.Content>
);
}

View File

@@ -8,8 +8,8 @@ import { Label } from "@components/Label";
import { notify } from "@components/Notification";
import { PeerGroupSelector } from "@components/PeerGroupSelector";
import {
SelectDropdown,
SelectOption,
SelectDropdown,
SelectOption,
} from "@components/select/SelectDropdown";
import { Callout } from "@components/Callout";
import { useHasChanges } from "@hooks/useHasChanges";
@@ -18,12 +18,12 @@ import { useApiCall } from "@utils/api";
import { cn, validator } from "@utils/helpers";
import { useTranslations } from "next-intl";
import {
ClockFadingIcon,
ExternalLinkIcon,
FlaskConicalIcon,
MonitorSmartphoneIcon,
AlertTriangle,
RefreshCcw,
ClockFadingIcon,
ExternalLinkIcon,
FlaskConicalIcon,
MonitorSmartphoneIcon,
AlertTriangle,
RefreshCcw,
} from "lucide-react";
import React, { useMemo, useState } from "react";
import { useSWRConfig } from "swr";
@@ -37,376 +37,376 @@ import { useGroups } from "@/contexts/GroupsProvider";
import { SkeletonSettings } from "@components/skeletons/SkeletonSettings";
type Props = {
account: Account;
account: Account;
};
const latestOrCustomVersion = [
{
label: "Disabled",
value: "disabled",
},
{
label: "Latest Version",
value: "latest",
},
{
label: "Custom Version",
value: "custom",
},
{
label: "Disabled",
value: "disabled",
},
{
label: "Latest Version",
value: "latest",
},
{
label: "Custom Version",
value: "custom",
},
] as SelectOption[];
export default function ClientSettingsTab({ account }: Readonly<Props>) {
const { isLoading: isGroupsLoading } = useGroups();
const { isLoading: isGroupsLoading } = useGroups();
return isGroupsLoading ? (
<SkeletonSettings />
) : (
<ClientSettingsTabContent account={account} />
);
return isGroupsLoading ? (
<SkeletonSettings />
) : (
<ClientSettingsTabContent account={account} />
);
}
function ClientSettingsTabContent({ account }: Readonly<Props>) {
const t = useTranslations("settings");
const { permission } = usePermissions();
const t = useTranslations("settings");
const { permission } = usePermissions();
const { mutate } = useSWRConfig();
const saveRequest = useApiCall<Account>("/accounts/" + account.id, true);
const { mutate } = useSWRConfig();
const saveRequest = useApiCall<Account>("/accounts/" + account.id, true);
const [lazyConnection, setLazyConnection] = useState(
account.settings?.lazy_connection_enabled ?? false,
);
const [lazyConnection, setLazyConnection] = useState(
account.settings?.lazy_connection_enabled ?? false,
);
const autoUpdateSetting = account.settings?.auto_update_version;
const isAutoUpdateEnabled =
!!autoUpdateSetting && autoUpdateSetting !== "disabled";
const isCustomVersion = validator.isValidVersion(autoUpdateSetting);
const [autoUpdateMethod, setAutoUpdateMethod] = useState(
isAutoUpdateEnabled ? (isCustomVersion ? "custom" : "latest") : "disabled",
);
const autoUpdateSetting = account.settings?.auto_update_version;
const isAutoUpdateEnabled =
!!autoUpdateSetting && autoUpdateSetting !== "disabled";
const isCustomVersion = validator.isValidVersion(autoUpdateSetting);
const [autoUpdateMethod, setAutoUpdateMethod] = useState(
isAutoUpdateEnabled ? (isCustomVersion ? "custom" : "latest") : "disabled",
);
const [autoUpdateCustomVersion, setAutoUpdateCustomVersion] = useState(
isCustomVersion ? autoUpdateSetting : "",
);
const [autoUpdateCustomVersion, setAutoUpdateCustomVersion] = useState(
isCustomVersion ? autoUpdateSetting : "",
);
const [autoUpdateAlways, setAutoUpdateAlways] = useState(
account.settings?.auto_update_always ?? false,
);
const [autoUpdateAlways, setAutoUpdateAlways] = useState(
account.settings?.auto_update_always ?? false,
);
const [peerExposeEnabled, setPeerExposeEnabled] = useState<boolean>(
account?.settings?.peer_expose_enabled ?? false,
);
const [peerExposeGroups, setPeerExposeGroups, { save: saveGroups }] =
useGroupHelper({
initial: account.settings?.peer_expose_groups,
});
const peerExposeGroupNames = useMemo(
() => peerExposeGroups.map((g) => g.name).sort(),
[peerExposeGroups],
);
const [peerExposeEnabled, setPeerExposeEnabled] = useState<boolean>(
account?.settings?.peer_expose_enabled ?? false,
);
const [peerExposeGroups, setPeerExposeGroups, { save: saveGroups }] =
useGroupHelper({
initial: account.settings?.peer_expose_groups,
});
const peerExposeGroupNames = useMemo(
() => peerExposeGroups.map((g) => g.name).sort(),
[peerExposeGroups],
);
const { hasChanges, updateRef } = useHasChanges([
autoUpdateMethod,
autoUpdateCustomVersion,
autoUpdateAlways,
peerExposeEnabled,
peerExposeGroupNames,
]);
const { hasChanges, updateRef } = useHasChanges([
autoUpdateMethod,
autoUpdateCustomVersion,
autoUpdateAlways,
peerExposeEnabled,
peerExposeGroupNames,
]);
const handleUpdateMethodChange = (value: string) => {
setAutoUpdateMethod(value);
if (value === "disabled" || value === "latest") {
setAutoUpdateCustomVersion("");
}
};
const handleUpdateMethodChange = (value: string) => {
setAutoUpdateMethod(value);
if (value === "disabled" || value === "latest") {
setAutoUpdateCustomVersion("");
}
};
const versionError = useMemo(() => {
const msg = "Please enter a valid version, e.g., 0.2, 0.2.0, 0.2.0-alpha.1";
if (autoUpdateCustomVersion == "") return "";
if (autoUpdateCustomVersion == "-") return "";
const validSemver = validator.isValidVersion(autoUpdateCustomVersion);
if (!validSemver) return msg;
return "";
}, [autoUpdateCustomVersion]);
const versionError = useMemo(() => {
const msg = "Please enter a valid version, e.g., 0.2, 0.2.0, 0.2.0-alpha.1";
if (autoUpdateCustomVersion == "") return "";
if (autoUpdateCustomVersion == "-") return "";
const validSemver = validator.isValidVersion(autoUpdateCustomVersion);
if (!validSemver) return msg;
return "";
}, [autoUpdateCustomVersion]);
const canSaveCustomVersion =
autoUpdateCustomVersion !== "" &&
autoUpdateMethod === "custom" &&
versionError === "";
const canSaveCustomVersion =
autoUpdateCustomVersion !== "" &&
autoUpdateMethod === "custom" &&
versionError === "";
const isSaveButtonDisabled = useMemo(() => {
return (
!hasChanges ||
!permission.settings.update ||
(autoUpdateMethod === "custom" && !canSaveCustomVersion) ||
(peerExposeEnabled && peerExposeGroups.length === 0)
);
}, [
hasChanges,
permission.settings.update,
autoUpdateMethod,
canSaveCustomVersion,
peerExposeEnabled,
peerExposeGroups,
]);
const isSaveButtonDisabled = useMemo(() => {
return (
!hasChanges ||
!permission.settings.update ||
(autoUpdateMethod === "custom" && !canSaveCustomVersion) ||
(peerExposeEnabled && peerExposeGroups.length === 0)
);
}, [
hasChanges,
permission.settings.update,
autoUpdateMethod,
canSaveCustomVersion,
peerExposeEnabled,
peerExposeGroups,
]);
const saveChanges = async () => {
const groups = await saveGroups();
const peerExposeGroupIds = groups
.map((group) => group.id)
.filter(Boolean) as string[];
const saveChanges = async () => {
const groups = await saveGroups();
const peerExposeGroupIds = groups
.map((group) => group.id)
.filter(Boolean) as string[];
notify({
title: "Client Settings",
description: `Client settings successfully updated.`,
promise: saveRequest
.put({
id: account.id,
settings: {
...account.settings,
auto_update_version: autoUpdateCustomVersion || autoUpdateMethod,
auto_update_always: autoUpdateAlways,
peer_expose_enabled: peerExposeEnabled,
peer_expose_groups: peerExposeGroupIds,
},
})
.then(() => {
mutate("/accounts");
updateRef([
autoUpdateMethod,
autoUpdateCustomVersion,
autoUpdateAlways,
peerExposeEnabled,
peerExposeGroupNames,
]);
}),
loadingMessage: "Updating client settings...",
});
};
notify({
title: "Client Settings",
description: `Client settings successfully updated.`,
promise: saveRequest
.put({
id: account.id,
settings: {
...account.settings,
auto_update_version: autoUpdateCustomVersion || autoUpdateMethod,
auto_update_always: autoUpdateAlways,
peer_expose_enabled: peerExposeEnabled,
peer_expose_groups: peerExposeGroupIds,
},
})
.then(() => {
mutate("/accounts");
updateRef([
autoUpdateMethod,
autoUpdateCustomVersion,
autoUpdateAlways,
peerExposeEnabled,
peerExposeGroupNames,
]);
}),
loadingMessage: "Updating client settings...",
});
};
const toggleLazyConnection = async (toggle: boolean) => {
notify({
title: "Lazy Connections",
description: `Lazy Connections successfully ${
toggle ? "enabled" : "disabled"
}.`,
promise: saveRequest
.put({
id: account.id,
settings: {
...account.settings,
lazy_connection_enabled: toggle,
},
})
.then(() => {
setLazyConnection(toggle);
mutate("/accounts");
}),
loadingMessage: "Updating Lazy Connections setting...",
});
};
const toggleLazyConnection = async (toggle: boolean) => {
notify({
title: "Lazy Connections",
description: `Lazy Connections successfully ${
toggle ? "enabled" : "disabled"
}.`,
promise: saveRequest
.put({
id: account.id,
settings: {
...account.settings,
lazy_connection_enabled: toggle,
},
})
.then(() => {
setLazyConnection(toggle);
mutate("/accounts");
}),
loadingMessage: "Updating Lazy Connections setting...",
});
};
return (
<Tabs.Content value={"clients"}>
<div className={"p-default py-6 max-w-2xl"}>
<Breadcrumbs>
<Breadcrumbs.Item
href={"/settings"}
label={t("title")}
icon={<SettingsIcon size={13} />}
/>
<Breadcrumbs.Item
href={"/settings?tab=clients"}
label={t("clients")}
icon={<MonitorSmartphoneIcon size={14} />}
active
/>
</Breadcrumbs>
<div className={"flex items-start justify-between"}>
<h1>{t("clients")}</h1>
<Button
variant={"primary"}
disabled={isSaveButtonDisabled}
onClick={saveChanges}
data-cy={"save-clients-settings"}
>
Save Changes
</Button>
</div>
return (
<Tabs.Content value={"clients"}>
<div className={"p-default py-6 max-w-2xl"}>
<Breadcrumbs>
<Breadcrumbs.Item
href={"/settings"}
label={t("title")}
icon={<SettingsIcon size={13} />}
/>
<Breadcrumbs.Item
href={"/settings?tab=clients"}
label={t("clients")}
icon={<MonitorSmartphoneIcon size={14} />}
active
/>
</Breadcrumbs>
<div className={"flex items-start justify-between"}>
<h1>{t("clients")}</h1>
<Button
variant={"primary"}
disabled={isSaveButtonDisabled}
onClick={saveChanges}
data-cy={"save-clients-settings"}
>
Save Changes
</Button>
</div>
<div className={"flex flex-col gap-10 w-full mt-8"}>
<div className={"flex flex-col relative"}>
<Label>
<RefreshCcw size={15} />
Automatic Updates
<SmallBadge
text={"Beta"}
variant={"sky"}
className={"text-[9px] leading-none py-[3px] px-[5px]"}
textClassName={"top-0"}
/>
</Label>
<HelpText>
Configure how NetBird clients receive update notifications.
When enabled, users will be prompted to install the selected
version. This requires at least NetBird{" "}
<span className={"text-white font-medium"}>v0.61.0</span>.{" "}
<InlineLink
href={"https://docs.netbird.io/manage/peers/auto-update"}
target={"_blank"}
>
Learn more
<ExternalLinkIcon size={12} />
</InlineLink>
</HelpText>
<div className={"gap-4 items-center grid grid-cols-2"}>
<SelectDropdown
value={autoUpdateMethod}
onChange={handleUpdateMethodChange}
options={latestOrCustomVersion}
/>
<Input
value={autoUpdateCustomVersion}
customPrefix={"Version"}
placeholder={"e.g., 0.52.2"}
error={versionError}
errorTooltip={true}
disabled={autoUpdateMethod !== "custom"}
onChange={(v) => {
setAutoUpdateCustomVersion(v.target.value);
}}
/>
</div>
<FancyToggleSwitch
className={"mt-4"}
value={autoUpdateAlways}
onChange={setAutoUpdateAlways}
label={
<>
<AlertTriangle size={15} className={"text-yellow-400"} />
Force Automatic Updates
</>
}
helpText={
"When enabled, updates are installed automatically in the background without user interaction."
}
disabled={
!permission.settings.update || autoUpdateMethod === "disabled"
}
/>
{autoUpdateAlways && autoUpdateMethod !== "disabled" && (
<Callout
className={"mt-3"}
variant={"warning"}
icon={
<AlertTriangle
size={14}
className={"shrink-0 relative top-[3px]"}
/>
}
>
Enabling automatic updates will restart the NetBird client
during updates, which can temporarily disrupt active
connections. Use with caution in production environments.
</Callout>
)}
</div>
<div className={"flex flex-col gap-10 w-full mt-8"}>
<div className={"flex flex-col relative"}>
<Label>
<RefreshCcw size={15} />
Automatic Updates
<SmallBadge
text={"Beta"}
variant={"sky"}
className={"text-[9px] leading-none py-[3px] px-[5px]"}
textClassName={"top-0"}
/>
</Label>
<HelpText>
Configure how NetBird clients receive update notifications. When
enabled, users will be prompted to install the selected version.
This requires at least NetBird{" "}
<span className={"text-white font-medium"}>v0.61.0</span>.{" "}
<InlineLink
href={"https://docs.netbird.io/manage/peers/auto-update"}
target={"_blank"}
>
Learn more
<ExternalLinkIcon size={12} />
</InlineLink>
</HelpText>
<div className={"gap-4 items-center grid grid-cols-2"}>
<SelectDropdown
value={autoUpdateMethod}
onChange={handleUpdateMethodChange}
options={latestOrCustomVersion}
/>
<Input
value={autoUpdateCustomVersion}
customPrefix={"Version"}
placeholder={"e.g., 0.52.2"}
error={versionError}
errorTooltip={true}
disabled={autoUpdateMethod !== "custom"}
onChange={(v) => {
setAutoUpdateCustomVersion(v.target.value);
}}
/>
</div>
<FancyToggleSwitch
className={"mt-4"}
value={autoUpdateAlways}
onChange={setAutoUpdateAlways}
label={
<>
<AlertTriangle size={15} className={"text-yellow-400"} />
Force Automatic Updates
</>
}
helpText={
"When enabled, updates are installed automatically in the background without user interaction."
}
disabled={
!permission.settings.update || autoUpdateMethod === "disabled"
}
/>
{autoUpdateAlways && autoUpdateMethod !== "disabled" && (
<Callout
className={"mt-3"}
variant={"warning"}
icon={
<AlertTriangle
size={14}
className={"shrink-0 relative top-[3px]"}
/>
}
>
Enabling automatic updates will restart the NetBird client
during updates, which can temporarily disrupt active
connections. Use with caution in production environments.
</Callout>
)}
</div>
<div>
<div>
<Label>
<ReverseProxyIcon size={15} className={"fill-nb-gray-300"} />
Expose Services from CLI
</Label>
<HelpText>
Allow peers to expose local services through the NetBird reverse
proxy using the CLI. <br /> This requires at least NetBird{" "}
<span className={"text-white font-medium"}>v0.66.0</span>.{" "}
<InlineLink
href={
"https://docs.netbird.io/manage/reverse-proxy/expose-from-cli"
}
target={"_blank"}
>
Learn more
<ExternalLinkIcon size={12} />
</InlineLink>
</HelpText>
</div>
<div>
<div>
<Label>
<ReverseProxyIcon size={15} className={"fill-nb-gray-300"} />
Expose Services from CLI
</Label>
<HelpText>
Allow peers to expose local services through the NetBird reverse
proxy using the CLI. <br /> This requires at least NetBird{" "}
<span className={"text-white font-medium"}>v0.66.0</span>.{" "}
<InlineLink
href={
"https://docs.netbird.io/manage/reverse-proxy/expose-from-cli"
}
target={"_blank"}
>
Learn more
<ExternalLinkIcon size={12} />
</InlineLink>
</HelpText>
</div>
<FancyToggleSwitch
className={"mt-2"}
value={peerExposeEnabled}
onChange={setPeerExposeEnabled}
label={"Enable Peer Expose"}
helpText={
"When enabled, peers can expose local HTTP services accessible via a public URL."
}
disabled={!permission.settings.update}
/>
<FancyToggleSwitch
className={"mt-2"}
value={peerExposeEnabled}
onChange={setPeerExposeEnabled}
label={"Enable Peer Expose"}
helpText={
"When enabled, peers can expose local HTTP services accessible via a public URL."
}
disabled={!permission.settings.update}
/>
<div
className={cn(
"border border-nb-gray-900 border-t-0 rounded-b-md bg-nb-gray-940 px-[1.28rem] pt-3 pb-5 flex flex-col gap-4 mx-[0.25rem]",
!peerExposeEnabled
? "opacity-50 pointer-events-none"
: "bg-nb-gray-930/80",
)}
>
<div className={"mt-2"}>
<Label>Allowed peer groups</Label>
<HelpText>
Select which peer groups are allowed to expose services. At
least one group is required.
</HelpText>
<PeerGroupSelector
values={peerExposeGroups}
onChange={setPeerExposeGroups}
placeholder="Select peer groups..."
/>
</div>
</div>
</div>
<div
className={cn(
"border border-nb-gray-900 border-t-0 rounded-b-md bg-nb-gray-940 px-[1.28rem] pt-3 pb-5 flex flex-col gap-4 mx-[0.25rem]",
!peerExposeEnabled
? "opacity-50 pointer-events-none"
: "bg-nb-gray-930/80",
)}
>
<div className={"mt-2"}>
<Label>Allowed peer groups</Label>
<HelpText>
Select which peer groups are allowed to expose services. At
least one group is required.
</HelpText>
<PeerGroupSelector
values={peerExposeGroups}
onChange={setPeerExposeGroups}
placeholder="Select peer groups..."
/>
</div>
</div>
</div>
<div>
<Label>
<FlaskConicalIcon size={15} />
Experimental
</Label>
<div>
<Label>
<FlaskConicalIcon size={15} />
Experimental
</Label>
<HelpText>
Lazy connections are an experimental feature. Functionality and
behavior may evolve. Instead of maintaining always-on connections,
NetBird activates them on-demand based on activity or signaling.{" "}
<InlineLink
href={"https://docs.netbird.io/how-to/lazy-connection"}
target={"_blank"}
>
Learn more
<ExternalLinkIcon size={12} />
</InlineLink>
</HelpText>
<FancyToggleSwitch
className={"mt-2"}
value={lazyConnection}
onChange={toggleLazyConnection}
label={
<>
<ClockFadingIcon size={15} />
Enable Lazy Connections
</>
}
helpText={
<>
Allow to establish connections between peers only when
required. This requires NetBird client v0.45 or higher.
Changes will only take effect after restarting the clients.
</>
}
disabled={!permission.settings.update}
/>
</div>
</div>
</div>
</Tabs.Content>
);
<HelpText>
Lazy connections are an experimental feature. Functionality and
behavior may evolve. Instead of maintaining always-on connections,
NetBird activates them on-demand based on activity or signaling.{" "}
<InlineLink
href={"https://docs.netbird.io/how-to/lazy-connection"}
target={"_blank"}
>
Learn more
<ExternalLinkIcon size={12} />
</InlineLink>
</HelpText>
<FancyToggleSwitch
className={"mt-2"}
value={lazyConnection}
onChange={toggleLazyConnection}
label={
<>
<ClockFadingIcon size={15} />
Enable Lazy Connections
</>
}
helpText={
<>
Allow to establish connections between peers only when
required. This requires NetBird client v0.45 or higher.
Changes will only take effect after restarting the clients.
</>
}
disabled={!permission.settings.update}
/>
</div>
</div>
</div>
</Tabs.Content>
);
}

View File

@@ -14,97 +14,97 @@ import { useLoggedInUser } from "@/contexts/UsersProvider";
import { Account } from "@/interfaces/Account";
type Props = {
account: Account;
account: Account;
};
const config = loadConfig();
export default function DangerZoneTab({ account }: Props) {
const t = useTranslations("settings");
const { confirm } = useDialog();
const deleteRequest = useApiCall<Account>("/accounts/" + account.id);
const { logout } = useLoggedInUser();
const t = useTranslations("settings");
const { confirm } = useDialog();
const deleteRequest = useApiCall<Account>("/accounts/" + account.id);
const { logout } = useLoggedInUser();
const deleteAccount = async () => {
const deletePromise = new Promise<void>((resolve, reject) => {
return deleteRequest
.del()
.then(() => {
// Clear browser storage only after confirmed account deletion
if (typeof window !== "undefined") {
localStorage.clear();
sessionStorage.clear();
// Optionally, clear cookies if needed
// document.cookie = ... (set cookies to expire)
}
logout().then();
resolve();
})
.catch((error) => reject(error));
});
const deleteAccount = async () => {
const deletePromise = new Promise<void>((resolve, reject) => {
return deleteRequest
.del()
.then(() => {
// Clear browser storage only after confirmed account deletion
if (typeof window !== "undefined") {
localStorage.clear();
sessionStorage.clear();
// Optionally, clear cookies if needed
// document.cookie = ... (set cookies to expire)
}
logout().then();
resolve();
})
.catch((error) => reject(error));
});
notify({
title: "Delete NetBird account",
description: "NetBird account was successfully deleted.",
promise: deletePromise,
loadingMessage: "Deleting the account...",
});
};
notify({
title: "Delete NetBird account",
description: "NetBird account was successfully deleted.",
promise: deletePromise,
loadingMessage: "Deleting the account...",
});
};
const handleConfirm = async () => {
const choice = await confirm({
title: "Delete NetBird account",
description:
"Are you sure you want to delete your NetBird account? This action cannot be undone.",
confirmText: "Delete",
cancelText: "Cancel",
type: "danger",
});
if (!choice) return;
deleteAccount().then();
};
const handleConfirm = async () => {
const choice = await confirm({
title: "Delete NetBird account",
description:
"Are you sure you want to delete your NetBird account? This action cannot be undone.",
confirmText: "Delete",
cancelText: "Cancel",
type: "danger",
});
if (!choice) return;
deleteAccount().then();
};
return (
<Tabs.Content value={"danger-zone"}>
<div className={"p-default py-6"}>
<Breadcrumbs>
<Breadcrumbs.Item
href={"/settings"}
label={t("title")}
icon={<SettingsIcon size={13} />}
/>
<Breadcrumbs.Item
href={"/settings"}
label={t("dangerZone")}
icon={<AlertOctagonIcon size={14} />}
active
/>
</Breadcrumbs>
<h1>{t("dangerZone")}</h1>
<div className={"gap-6 mt-6 max-w-lg"}>
<Card
className={
"w-full flex flex-col gap-2 border-red-600 bg-red-950/50"
}
>
<div className={"px-8 py-6"}>
<p className={"text-xl font-medium mb-2 !text-red-50"}>
Delete NetBird account
</p>
<p className={"!text-red-50/80"}>
Before proceeding to delete your Netbird account, please be
aware that this action is irreversible. Once your account is
deleted, you will permanently lose access to all associated
data, including your peers, users, groups, policies, and routes.
</p>
<div className={"mt-6"}>
<Button variant={"danger"} onClick={handleConfirm} size={"xs"}>
Delete Account
</Button>
</div>
</div>
</Card>
</div>
</div>
</Tabs.Content>
);
return (
<Tabs.Content value={"danger-zone"}>
<div className={"p-default py-6"}>
<Breadcrumbs>
<Breadcrumbs.Item
href={"/settings"}
label={t("title")}
icon={<SettingsIcon size={13} />}
/>
<Breadcrumbs.Item
href={"/settings"}
label={t("dangerZone")}
icon={<AlertOctagonIcon size={14} />}
active
/>
</Breadcrumbs>
<h1>{t("dangerZone")}</h1>
<div className={"gap-6 mt-6 max-w-lg"}>
<Card
className={
"w-full flex flex-col gap-2 border-red-600 bg-red-950/50"
}
>
<div className={"px-8 py-6"}>
<p className={"text-xl font-medium mb-2 !text-red-50"}>
Delete NetBird account
</p>
<p className={"!text-red-50/80"}>
Before proceeding to delete your Netbird account, please be
aware that this action is irreversible. Once your account is
deleted, you will permanently lose access to all associated
data, including your peers, users, groups, policies, and routes.
</p>
<div className={"mt-6"}>
<Button variant={"danger"} onClick={handleConfirm} size={"xs"}>
Delete Account
</Button>
</div>
</div>
</Card>
</div>
</div>
</Tabs.Content>
);
}

View File

@@ -12,13 +12,13 @@ import { isLocalDev, isNetBirdHosted } from "@utils/netbird";
import { AnimatePresence, motion } from "framer-motion";
import { isEmpty } from "lodash";
import {
AlertCircle,
Braces,
FolderGit2Icon,
FolderInput,
FolderSync,
ShieldCheck,
X,
AlertCircle,
Braces,
FolderGit2Icon,
FolderInput,
FolderSync,
ShieldCheck,
X,
} from "lucide-react";
import React, { useState } from "react";
import { useSWRConfig } from "swr";
@@ -34,299 +34,299 @@ import { InlineButtonLink } from "@components/InlineLink";
import { useRouter } from "next/navigation";
type Props = {
account: Account;
account: Account;
};
export default function GroupsSettings({ account }: Props) {
const t = useTranslations("settings");
const { permission } = usePermissions();
const router = useRouter();
const { mutate } = useSWRConfig();
const { confirm } = useDialog();
const t = useTranslations("settings");
const { permission } = usePermissions();
const router = useRouter();
const { mutate } = useSWRConfig();
const { confirm } = useDialog();
/**
* Group Propagation
*/
const [groupsPropagation, setGroupsPropagation] = useState<boolean>(
account.settings.groups_propagation_enabled,
);
/**
* Group Propagation
*/
const [groupsPropagation, setGroupsPropagation] = useState<boolean>(
account.settings.groups_propagation_enabled,
);
/**
* JWT Group Sync
*/
const [jwtGroupSync, setJwtGroupSync] = useState<boolean>(
account.settings.jwt_groups_enabled,
);
const [jwtGroupsClaimName, setJwtGroupsClaimName] = useState(
account.settings.jwt_groups_claim_name,
);
const [jwtAllowGroups, setJwtAllowGroups] = useState<string[]>(
account.settings.jwt_allow_groups,
);
const [jwtAllowGroupsWarning, setJwtAllowGroupsWarning] = useState(false);
/**
* JWT Group Sync
*/
const [jwtGroupSync, setJwtGroupSync] = useState<boolean>(
account.settings.jwt_groups_enabled,
);
const [jwtGroupsClaimName, setJwtGroupsClaimName] = useState(
account.settings.jwt_groups_claim_name,
);
const [jwtAllowGroups, setJwtAllowGroups] = useState<string[]>(
account.settings.jwt_allow_groups,
);
const [jwtAllowGroupsWarning, setJwtAllowGroupsWarning] = useState(false);
/**
* Detect changes
*/
const { hasChanges, updateRef } = useHasChanges([
groupsPropagation,
jwtAllowGroups,
jwtGroupsClaimName,
jwtGroupSync,
]);
/**
* Detect changes
*/
const { hasChanges, updateRef } = useHasChanges([
groupsPropagation,
jwtAllowGroups,
jwtGroupsClaimName,
jwtGroupSync,
]);
/**
* Save Group Propagation
*/
const saveRequest = useApiCall<Account>("/accounts/" + account.id);
/**
* Save Group Propagation
*/
const saveRequest = useApiCall<Account>("/accounts/" + account.id);
const saveChanges = async () => {
const jwtGroupsEntered =
jwtAllowGroups.filter((g) => !isEmpty(g)).length > 0;
const showConfirm = jwtGroupSync && jwtGroupsEntered;
const choice = showConfirm
? await confirm({
title: `JWT allow group - ${jwtAllowGroups[0]}`,
description: `Only users part of the ${jwtAllowGroups[0]} group will be able to access NetBird. Are you sure you want to save the changes?`,
confirmText: "Save",
children: (
<div
className={
"flex gap-2 items-center text-xs bg-netbird-950 px-4 justify-center py-3 rounded-md border border-netbird-500 text-netbird-200"
}
>
<AlertCircle size={14} />
To prevent losing access, ensure you are part of this group.
</div>
),
cancelText: "Cancel",
type: "default",
})
: true;
const saveChanges = async () => {
const jwtGroupsEntered =
jwtAllowGroups.filter((g) => !isEmpty(g)).length > 0;
const showConfirm = jwtGroupSync && jwtGroupsEntered;
const choice = showConfirm
? await confirm({
title: `JWT allow group - ${jwtAllowGroups[0]}`,
description: `Only users part of the ${jwtAllowGroups[0]} group will be able to access NetBird. Are you sure you want to save the changes?`,
confirmText: "Save",
children: (
<div
className={
"flex gap-2 items-center text-xs bg-netbird-950 px-4 justify-center py-3 rounded-md border border-netbird-500 text-netbird-200"
}
>
<AlertCircle size={14} />
To prevent losing access, ensure you are part of this group.
</div>
),
cancelText: "Cancel",
type: "default",
})
: true;
if (!choice) return;
if (!choice) return;
notify({
title: "Group Settings",
description: "Group settings were updated successfully.",
promise: saveRequest
.put({
id: account.id,
settings: {
...account.settings,
groups_propagation_enabled: groupsPropagation,
jwt_groups_enabled: jwtGroupSync,
jwt_groups_claim_name: isEmpty(jwtGroupsClaimName)
? undefined
: jwtGroupsClaimName,
jwt_allow_groups: jwtGroupsEntered ? jwtAllowGroups : undefined,
},
})
.then(() => {
mutate("/accounts");
updateRef([
groupsPropagation,
jwtAllowGroups,
jwtGroupsClaimName,
jwtGroupSync,
]);
}),
loadingMessage: "Updating group settings...",
});
};
notify({
title: "Group Settings",
description: "Group settings were updated successfully.",
promise: saveRequest
.put({
id: account.id,
settings: {
...account.settings,
groups_propagation_enabled: groupsPropagation,
jwt_groups_enabled: jwtGroupSync,
jwt_groups_claim_name: isEmpty(jwtGroupsClaimName)
? undefined
: jwtGroupsClaimName,
jwt_allow_groups: jwtGroupsEntered ? jwtAllowGroups : undefined,
},
})
.then(() => {
mutate("/accounts");
updateRef([
groupsPropagation,
jwtAllowGroups,
jwtGroupsClaimName,
jwtGroupSync,
]);
}),
loadingMessage: "Updating group settings...",
});
};
return (
<Tabs.Content value={"groups"} className={"w-full"}>
<div className={"p-default py-6 max-w-xl"}>
<Breadcrumbs>
<Breadcrumbs.Item
href={"/settings"}
label={t("title")}
icon={<SettingsIcon size={13} />}
/>
<Breadcrumbs.Item
href={"/settings"}
label={t("groupsTab")}
icon={<FolderGit2Icon size={14} />}
active
/>
</Breadcrumbs>
<div className={"flex items-start justify-between"}>
<h1>{t("groupsTab")}</h1>
<Button
variant={"primary"}
disabled={!hasChanges}
onClick={saveChanges}
>
Save Changes
</Button>
</div>
return (
<Tabs.Content value={"groups"} className={"w-full"}>
<div className={"p-default py-6 max-w-xl"}>
<Breadcrumbs>
<Breadcrumbs.Item
href={"/settings"}
label={t("title")}
icon={<SettingsIcon size={13} />}
/>
<Breadcrumbs.Item
href={"/settings"}
label={t("groupsTab")}
icon={<FolderGit2Icon size={14} />}
active
/>
</Breadcrumbs>
<div className={"flex items-start justify-between"}>
<h1>{t("groupsTab")}</h1>
<Button
variant={"primary"}
disabled={!hasChanges}
onClick={saveChanges}
>
Save Changes
</Button>
</div>
<div className={"flex flex-col gap-6 mt-8 mb-3"}>
<FancyToggleSwitch
value={groupsPropagation}
onChange={setGroupsPropagation}
label={
<>
<FolderInput size={15} />
Enable user group propagation
</>
}
helpText={
"Allow group propagation from user's auto-groups to peers, sharing membership information."
}
disabled={!permission.settings.update}
/>
{(!isNetBirdHosted() || isLocalDev()) && (
<FancyToggleSwitch
value={jwtGroupSync}
onChange={setJwtGroupSync}
label={
<>
<FolderSync size={15} />
Enable JWT group sync
</>
}
helpText={
"Extract & sync groups from JWT claims with user's auto-groups, auto-creating groups from tokens."
}
disabled={!permission.settings.update}
/>
)}
</div>
<div className={"flex flex-col gap-6 mt-8 mb-3"}>
<FancyToggleSwitch
value={groupsPropagation}
onChange={setGroupsPropagation}
label={
<>
<FolderInput size={15} />
Enable user group propagation
</>
}
helpText={
"Allow group propagation from user's auto-groups to peers, sharing membership information."
}
disabled={!permission.settings.update}
/>
{(!isNetBirdHosted() || isLocalDev()) && (
<FancyToggleSwitch
value={jwtGroupSync}
onChange={setJwtGroupSync}
label={
<>
<FolderSync size={15} />
Enable JWT group sync
</>
}
helpText={
"Extract & sync groups from JWT claims with user's auto-groups, auto-creating groups from tokens."
}
disabled={!permission.settings.update}
/>
)}
</div>
{(!isNetBirdHosted() || isLocalDev()) && (
<AnimatePresence>
{jwtGroupSync && (
<div className={"overflow-hidden -top-4 relative z-0"}>
<motion.div
className={""}
initial={{ opacity: 0, height: 0, scale: 0.98 }}
animate={{ opacity: 1, height: "auto", scale: 1 }}
exit={{ opacity: 0, height: 0, scale: 0.98 }}
>
<div
className={cn(
!jwtGroupSync && "opacity-50 pointer-events-none",
"flex flex-col gap-6 bg-nb-gray-940 px-6 pt-5 pb-6 border border-nb-gray-930 rounded-b-md relative mx-3",
)}
>
<div>
<Label>JWT claim</Label>
<HelpText>
Specify the JWT claim for extracting group names, e.g.,
roles or groups, to add to account groups (this claim
should contain a list of group names).
</HelpText>
<Input
customPrefix={
<Braces size={16} className={"text-nb-gray-300"} />
}
onKeyDown={(event) => {
if (event.code === "Space") event.preventDefault();
}}
placeholder={"e.g., roles"}
value={jwtGroupsClaimName}
onChange={(e) => {
setJwtGroupsClaimName(
e.target.value.replace(/ /g, ""),
);
}}
/>
</div>
<div>
<Label>JWT allow groups</Label>
<HelpText>
Limit access to NetBird for the specified group names,
e.g., NetBird users. To use the groups, you need to
configure them first in your IdP.
</HelpText>
<div>
{jwtAllowGroups.length > 0 && (
<div className="flex flex-wrap gap-2 mb-3">
{jwtAllowGroups.map((group, index) => (
<Badge
key={group}
variant={"gray-ghost"}
className={cn(
"transition-all group whitespace-nowrap cursor-pointer",
)}
onClick={(e) => {
e.preventDefault();
const newGroups = jwtAllowGroups.filter(
(_, i) => i !== index,
);
setJwtAllowGroups(newGroups);
setJwtAllowGroupsWarning(
newGroups.length > 0,
);
}}
>
{group}
<X
size={12}
className={
"cursor-pointer group-hover:text-nb-gray-100 transition-all shrink-0"
}
/>
</Badge>
))}
</div>
)}
<Input
customPrefix={
<ShieldCheck
size={16}
className={"text-nb-gray-300"}
/>
}
placeholder={"Add a group and press Enter"}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
const input = e.currentTarget;
if (input.value.trim()) {
setJwtAllowGroups([
...jwtAllowGroups,
input.value.trim(),
]);
setJwtAllowGroupsWarning(true);
input.value = "";
}
}
}}
/>
</div>
</div>
{jwtAllowGroupsWarning && (
<div
className={
"flex gap-2 items-center text-xs bg-netbird-950 px-4 justify-center py-3 rounded-md border border-netbird-500 text-netbird-200"
}
>
<AlertCircle size={14} />
To prevent losing access, ensure you are part of this
group.
</div>
)}
</div>
</motion.div>
</div>
)}
</AnimatePresence>
)}
{(!isNetBirdHosted() || isLocalDev()) && (
<AnimatePresence>
{jwtGroupSync && (
<div className={"overflow-hidden -top-4 relative z-0"}>
<motion.div
className={""}
initial={{ opacity: 0, height: 0, scale: 0.98 }}
animate={{ opacity: 1, height: "auto", scale: 1 }}
exit={{ opacity: 0, height: 0, scale: 0.98 }}
>
<div
className={cn(
!jwtGroupSync && "opacity-50 pointer-events-none",
"flex flex-col gap-6 bg-nb-gray-940 px-6 pt-5 pb-6 border border-nb-gray-930 rounded-b-md relative mx-3",
)}
>
<div>
<Label>JWT claim</Label>
<HelpText>
Specify the JWT claim for extracting group names, e.g.,
roles or groups, to add to account groups (this claim
should contain a list of group names).
</HelpText>
<Input
customPrefix={
<Braces size={16} className={"text-nb-gray-300"} />
}
onKeyDown={(event) => {
if (event.code === "Space") event.preventDefault();
}}
placeholder={"e.g., roles"}
value={jwtGroupsClaimName}
onChange={(e) => {
setJwtGroupsClaimName(
e.target.value.replace(/ /g, ""),
);
}}
/>
</div>
<div>
<Label>JWT allow groups</Label>
<HelpText>
Limit access to NetBird for the specified group names,
e.g., NetBird users. To use the groups, you need to
configure them first in your IdP.
</HelpText>
<div>
{jwtAllowGroups.length > 0 && (
<div className="flex flex-wrap gap-2 mb-3">
{jwtAllowGroups.map((group, index) => (
<Badge
key={group}
variant={"gray-ghost"}
className={cn(
"transition-all group whitespace-nowrap cursor-pointer",
)}
onClick={(e) => {
e.preventDefault();
const newGroups = jwtAllowGroups.filter(
(_, i) => i !== index,
);
setJwtAllowGroups(newGroups);
setJwtAllowGroupsWarning(
newGroups.length > 0,
);
}}
>
{group}
<X
size={12}
className={
"cursor-pointer group-hover:text-nb-gray-100 transition-all shrink-0"
}
/>
</Badge>
))}
</div>
)}
<Input
customPrefix={
<ShieldCheck
size={16}
className={"text-nb-gray-300"}
/>
}
placeholder={"Add a group and press Enter"}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
const input = e.currentTarget;
if (input.value.trim()) {
setJwtAllowGroups([
...jwtAllowGroups,
input.value.trim(),
]);
setJwtAllowGroupsWarning(true);
input.value = "";
}
}
}}
/>
</div>
</div>
{jwtAllowGroupsWarning && (
<div
className={
"flex gap-2 items-center text-xs bg-netbird-950 px-4 justify-center py-3 rounded-md border border-netbird-500 text-netbird-200"
}
>
<AlertCircle size={14} />
To prevent losing access, ensure you are part of this
group.
</div>
)}
</div>
</motion.div>
</div>
)}
</AnimatePresence>
)}
<Callout variant={"info"} className={"mt-6"}>
Looking to view and manage your groups? You can find group management
under{" "}
<InlineButtonLink
onClick={() => router.push("/groups")}
variant={"dashed"}
>
{`Access Control Groups`}
</InlineButtonLink>
</Callout>
</div>
</Tabs.Content>
);
<Callout variant={"info"} className={"mt-6"}>
Looking to view and manage your groups? You can find group management
under{" "}
<InlineButtonLink
onClick={() => router.push("/groups")}
variant={"dashed"}
>
{`Access Control Groups`}
</InlineButtonLink>
</Callout>
</div>
</Tabs.Content>
);
}

View File

@@ -11,6 +11,7 @@ import {
} from "@components/modal/Modal";
import ModalHeader from "@components/modal/ModalHeader";
import { notify } from "@components/Notification";
import { useTranslations } from "next-intl";
import {
Select,
SelectContent,
@@ -80,6 +81,7 @@ export default function IdentityProviderModal({
onClose,
provider,
}: Readonly<Props>) {
const t = useTranslations("common");
const { mutate } = useSWRConfig();
const { permission } = usePermissions();
const isEditing = !!provider;
@@ -277,7 +279,7 @@ export default function IdentityProviderModal({
<ModalFooter className={"items-center"}>
<div className={"flex gap-3 w-full justify-end"}>
<ModalClose asChild={true}>
<Button variant={"secondary"}>Cancel</Button>
<Button variant={"secondary"}>{t("cancel")}</Button>
</ModalClose>
<Button

View File

@@ -12,12 +12,12 @@ import * as Tabs from "@radix-ui/react-tabs";
import useFetchApi, { useApiCall } from "@utils/api";
import { ColumnDef, SortingState } from "@tanstack/react-table";
import {
FingerprintIcon,
KeyRound,
MoreVertical,
PencilIcon,
PlusCircle,
Trash2,
FingerprintIcon,
KeyRound,
MoreVertical,
PencilIcon,
PlusCircle,
Trash2,
} from "lucide-react";
import React, { useState } from "react";
import { useSWRConfig } from "swr";
@@ -27,264 +27,264 @@ import { useDialog } from "@/contexts/DialogProvider";
import { usePermissions } from "@/contexts/PermissionsProvider";
import { useLocalStorage } from "@/hooks/useLocalStorage";
import {
getSSOIdentityProviderLabelByType,
SSOIdentityProvider,
SSOIdentityProviderType,
getSSOIdentityProviderLabelByType,
SSOIdentityProvider,
SSOIdentityProviderType,
} from "@/interfaces/IdentityProvider";
import IdentityProviderModal from "@/modules/settings/IdentityProviderModal";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@components/DropdownMenu";
import { idpIcon } from "@/assets/icons/IdentityProviderIcons";
export const idpTypeLabels: Record<SSOIdentityProviderType, string> = {
oidc: "OIDC",
zitadel: "Zitadel",
entra: "Microsoft Entra",
google: "Google",
okta: "Okta",
pocketid: "PocketID",
microsoft: "Microsoft",
authentik: "Authentik",
keycloak: "Keycloak",
adfs: "Microsoft AD FS",
oidc: "OIDC",
zitadel: "Zitadel",
entra: "Microsoft Entra",
google: "Google",
okta: "Okta",
pocketid: "PocketID",
microsoft: "Microsoft",
authentik: "Authentik",
keycloak: "Keycloak",
adfs: "Microsoft AD FS",
};
type ActionCellProps = {
provider: SSOIdentityProvider;
onEdit: (provider: SSOIdentityProvider) => void;
provider: SSOIdentityProvider;
onEdit: (provider: SSOIdentityProvider) => void;
};
function ActionCell({ provider, onEdit }: ActionCellProps) {
const { confirm } = useDialog();
const { mutate } = useSWRConfig();
const deleteRequest = useApiCall<SSOIdentityProvider>(
"/identity-providers/" + provider.id,
);
const { permission } = usePermissions();
const { confirm } = useDialog();
const { mutate } = useSWRConfig();
const deleteRequest = useApiCall<SSOIdentityProvider>(
"/identity-providers/" + provider.id,
);
const { permission } = usePermissions();
const handleDelete = async () => {
const choice = await confirm({
title: `Delete '${provider.name}'?`,
description:
"Are you sure you want to delete this identity provider? This action cannot be undone.",
confirmText: "Delete",
cancelText: "Cancel",
type: "danger",
});
const handleDelete = async () => {
const choice = await confirm({
title: `Delete '${provider.name}'?`,
description:
"Are you sure you want to delete this identity provider? This action cannot be undone.",
confirmText: "Delete",
cancelText: "Cancel",
type: "danger",
});
if (!choice) return;
if (!choice) return;
notify({
title: "Delete Identity Provider",
description: "Identity provider was deleted successfully.",
promise: deleteRequest.del().then(() => {
mutate("/identity-providers");
}),
loadingMessage: "Deleting identity provider...",
});
};
notify({
title: "Delete Identity Provider",
description: "Identity provider was deleted successfully.",
promise: deleteRequest.del().then(() => {
mutate("/identity-providers");
}),
loadingMessage: "Deleting identity provider...",
});
};
return (
<div className="flex justify-end">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="secondary" className="p-2">
<MoreVertical size={16} />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => onEdit(provider)}
disabled={!permission.identity_providers.update}
>
<PencilIcon size={14} className="mr-2" />
Edit
</DropdownMenuItem>
<DropdownMenuItem
onClick={handleDelete}
disabled={!permission.identity_providers.delete}
className="text-red-500 focus:text-red-500"
>
<Trash2 size={14} className="mr-2" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
return (
<div className="flex justify-end">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="secondary" className="p-2">
<MoreVertical size={16} />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => onEdit(provider)}
disabled={!permission.identity_providers.update}
>
<PencilIcon size={14} className="mr-2" />
Edit
</DropdownMenuItem>
<DropdownMenuItem
onClick={handleDelete}
disabled={!permission.identity_providers.delete}
className="text-red-500 focus:text-red-500"
>
<Trash2 size={14} className="mr-2" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
}
export default function IdentityProvidersTab() {
const t = useTranslations("settings");
const { permission } = usePermissions();
const { mutate } = useSWRConfig();
const { data: providers, isLoading } = useFetchApi<SSOIdentityProvider[]>(
"/identity-providers",
);
const t = useTranslations("settings");
const { permission } = usePermissions();
const { mutate } = useSWRConfig();
const { data: providers, isLoading } = useFetchApi<SSOIdentityProvider[]>(
"/identity-providers",
);
const [modalOpen, setModalOpen] = useState(false);
const [editProvider, setEditProvider] = useState<SSOIdentityProvider | null>(
null,
);
const [modalOpen, setModalOpen] = useState(false);
const [editProvider, setEditProvider] = useState<SSOIdentityProvider | null>(
null,
);
const [sorting, setSorting] = useLocalStorage<SortingState>(
"netbird-table-sort-identity-providers",
[
{
id: "name",
desc: false,
},
],
);
const [sorting, setSorting] = useLocalStorage<SortingState>(
"netbird-table-sort-identity-providers",
[
{
id: "name",
desc: false,
},
],
);
const handleEdit = (provider: SSOIdentityProvider) => {
setEditProvider(provider);
setModalOpen(true);
};
const handleEdit = (provider: SSOIdentityProvider) => {
setEditProvider(provider);
setModalOpen(true);
};
const handleCloseModal = () => {
setModalOpen(false);
setEditProvider(null);
};
const handleCloseModal = () => {
setModalOpen(false);
setEditProvider(null);
};
const columns: ColumnDef<SSOIdentityProvider>[] = [
{
accessorKey: "name",
header: ({ column }) => (
<DataTableHeader column={column}>Name</DataTableHeader>
),
sortingFn: "text",
cell: ({ row }) => (
<div className="flex items-center gap-3">
{idpIcon(row.original.type) || (
<KeyRound size={16} className="text-nb-gray-400" />
)}
<span className="font-medium">{row.original.name}</span>
</div>
),
},
{
accessorKey: "type",
header: ({ column }) => (
<DataTableHeader column={column}>Type</DataTableHeader>
),
cell: ({ row }) => (
<span className="text-nb-gray-400">
{getSSOIdentityProviderLabelByType(row.original.type)}
</span>
),
},
{
id: "actions",
accessorKey: "id",
header: "",
cell: ({ row }) => (
<ActionCell provider={row.original} onEdit={handleEdit} />
),
},
];
const columns: ColumnDef<SSOIdentityProvider>[] = [
{
accessorKey: "name",
header: ({ column }) => (
<DataTableHeader column={column}>Name</DataTableHeader>
),
sortingFn: "text",
cell: ({ row }) => (
<div className="flex items-center gap-3">
{idpIcon(row.original.type) || (
<KeyRound size={16} className="text-nb-gray-400" />
)}
<span className="font-medium">{row.original.name}</span>
</div>
),
},
{
accessorKey: "type",
header: ({ column }) => (
<DataTableHeader column={column}>Type</DataTableHeader>
),
cell: ({ row }) => (
<span className="text-nb-gray-400">
{getSSOIdentityProviderLabelByType(row.original.type)}
</span>
),
},
{
id: "actions",
accessorKey: "id",
header: "",
cell: ({ row }) => (
<ActionCell provider={row.original} onEdit={handleEdit} />
),
},
];
return (
<Tabs.Content value={"identity-providers"} className={"w-full"}>
<div className={"p-default py-6"}>
<Breadcrumbs>
<Breadcrumbs.Item
href={"/settings"}
label={t("title")}
icon={<SettingsIcon size={13} />}
/>
<Breadcrumbs.Item
href={"/settings?tab=identity-providers"}
label={t("identityProviders")}
icon={<FingerprintIcon size={14} />}
active
/>
</Breadcrumbs>
<div className={"flex items-start justify-between"}>
<div>
<h1>{t("identityProviders")}</h1>
<Paragraph>
Configure identity providers for user authentication in your
network.
</Paragraph>
</div>
</div>
</div>
return (
<Tabs.Content value={"identity-providers"} className={"w-full"}>
<div className={"p-default py-6"}>
<Breadcrumbs>
<Breadcrumbs.Item
href={"/settings"}
label={t("title")}
icon={<SettingsIcon size={13} />}
/>
<Breadcrumbs.Item
href={"/settings?tab=identity-providers"}
label={t("identityProviders")}
icon={<FingerprintIcon size={14} />}
active
/>
</Breadcrumbs>
<div className={"flex items-start justify-between"}>
<div>
<h1>{t("identityProviders")}</h1>
<Paragraph>
Configure identity providers for user authentication in your
network.
</Paragraph>
</div>
</div>
</div>
<IdentityProviderModal
open={modalOpen}
key={modalOpen ? 1 : 0}
onClose={handleCloseModal}
provider={editProvider}
/>
<IdentityProviderModal
open={modalOpen}
key={modalOpen ? 1 : 0}
onClose={handleCloseModal}
provider={editProvider}
/>
<DataTable
isLoading={isLoading}
text={"Identity Providers"}
sorting={sorting}
setSorting={setSorting}
columns={columns}
data={providers}
onRowClick={(row) => handleEdit(row.original)}
searchPlaceholder={"Search by name or type..."}
getStartedCard={
<GetStartedTest
icon={
<SquareIcon
icon={<FingerprintIcon size={20} />}
color={"gray"}
size={"large"}
/>
}
title={"Add Identity Provider"}
description={
"Configure an identity provider to enable SSO authentication for your users."
}
button={
<Button
variant={"primary"}
onClick={() => setModalOpen(true)}
disabled={!permission.identity_providers.create}
>
<PlusCircle size={16} />
Add Identity Provider
</Button>
}
/>
}
rightSide={() => (
<>
{providers && providers.length > 0 && (
<Button
variant={"primary"}
className={"ml-auto"}
onClick={() => setModalOpen(true)}
disabled={!permission.identity_providers.create}
>
<PlusCircle size={16} />
Add Identity Provider
</Button>
)}
</>
)}
>
{(table) => (
<>
<DataTableRowsPerPage
table={table}
disabled={!providers || providers.length === 0}
/>
<DataTableRefreshButton
isDisabled={!providers || providers.length === 0}
onClick={() => mutate("/identity-providers")}
/>
</>
)}
</DataTable>
</Tabs.Content>
);
<DataTable
isLoading={isLoading}
text={"Identity Providers"}
sorting={sorting}
setSorting={setSorting}
columns={columns}
data={providers}
onRowClick={(row) => handleEdit(row.original)}
searchPlaceholder={"Search by name or type..."}
getStartedCard={
<GetStartedTest
icon={
<SquareIcon
icon={<FingerprintIcon size={20} />}
color={"gray"}
size={"large"}
/>
}
title={"Add Identity Provider"}
description={
"Configure an identity provider to enable SSO authentication for your users."
}
button={
<Button
variant={"primary"}
onClick={() => setModalOpen(true)}
disabled={!permission.identity_providers.create}
>
<PlusCircle size={16} />
Add Identity Provider
</Button>
}
/>
}
rightSide={() => (
<>
{providers && providers.length > 0 && (
<Button
variant={"primary"}
className={"ml-auto"}
onClick={() => setModalOpen(true)}
disabled={!permission.identity_providers.create}
>
<PlusCircle size={16} />
Add Identity Provider
</Button>
)}
</>
)}
>
{(table) => (
<>
<DataTableRowsPerPage
table={table}
disabled={!providers || providers.length === 0}
/>
<DataTableRefreshButton
isDisabled={!providers || providers.length === 0}
onClick={() => mutate("/identity-providers")}
/>
</>
)}
</DataTable>
</Tabs.Content>
);
}

View File

@@ -25,334 +25,334 @@ import { useGroups } from "@/contexts/GroupsProvider";
import { SkeletonSettings } from "@components/skeletons/SkeletonSettings";
type Props = {
account: Account;
account: Account;
};
export default function NetworkSettingsTab({ account }: Readonly<Props>) {
const { isLoading: isGroupsLoading } = useGroups();
const { isLoading: isGroupsLoading } = useGroups();
return isGroupsLoading ? (
<SkeletonSettings />
) : (
<NetworkSettingsTabContent account={account} />
);
return isGroupsLoading ? (
<SkeletonSettings />
) : (
<NetworkSettingsTabContent account={account} />
);
}
function NetworkSettingsTabContent({ account }: Readonly<Props>) {
const t = useTranslations("settings");
const { permission } = usePermissions();
const t = useTranslations("settings");
const { permission } = usePermissions();
const { mutate } = useSWRConfig();
const saveRequest = useApiCall<Account>("/accounts/" + account.id, true);
const { mutate } = useSWRConfig();
const saveRequest = useApiCall<Account>("/accounts/" + account.id, true);
const [routingPeerDNSSetting, setRoutingPeerDNSSetting] = useState(
account.settings.routing_peer_dns_resolution_enabled,
);
const [customDNSDomain, setCustomDNSDomain] = useState(
account.settings.dns_domain || "",
);
const [networkRange, setNetworkRange] = useState(
account.settings.network_range || "",
);
const [networkRangeV6, setNetworkRangeV6] = useState(
account.settings.network_range_v6 || "",
);
const [ipv6EnabledGroups, setIpv6EnabledGroups, { save: saveGroups }] =
useGroupHelper({
initial: account.settings?.ipv6_enabled_groups,
});
const ipv6GroupNames = useMemo(
() => ipv6EnabledGroups.map((g) => g.name).sort(),
[ipv6EnabledGroups],
);
const [routingPeerDNSSetting, setRoutingPeerDNSSetting] = useState(
account.settings.routing_peer_dns_resolution_enabled,
);
const [customDNSDomain, setCustomDNSDomain] = useState(
account.settings.dns_domain || "",
);
const [networkRange, setNetworkRange] = useState(
account.settings.network_range || "",
);
const [networkRangeV6, setNetworkRangeV6] = useState(
account.settings.network_range_v6 || "",
);
const [ipv6EnabledGroups, setIpv6EnabledGroups, { save: saveGroups }] =
useGroupHelper({
initial: account.settings?.ipv6_enabled_groups,
});
const ipv6GroupNames = useMemo(
() => ipv6EnabledGroups.map((g) => g.name).sort(),
[ipv6EnabledGroups],
);
const toggleNetworkDNSSetting = async (toggle: boolean) => {
notify({
title: "DNS Wildcard Routing",
description: `DNS Wildcard Routing successfully ${
toggle ? "enabled" : "disabled"
}.`,
promise: saveRequest
.put({
id: account.id,
settings: {
...account.settings,
routing_peer_dns_resolution_enabled: toggle,
},
})
.then(() => {
setRoutingPeerDNSSetting(toggle);
mutate("/accounts");
}),
loadingMessage: "Updating DNS wildcard setting...",
});
};
const toggleNetworkDNSSetting = async (toggle: boolean) => {
notify({
title: "DNS Wildcard Routing",
description: `DNS Wildcard Routing successfully ${
toggle ? "enabled" : "disabled"
}.`,
promise: saveRequest
.put({
id: account.id,
settings: {
...account.settings,
routing_peer_dns_resolution_enabled: toggle,
},
})
.then(() => {
setRoutingPeerDNSSetting(toggle);
mutate("/accounts");
}),
loadingMessage: "Updating DNS wildcard setting...",
});
};
const { hasChanges, updateRef } = useHasChanges([
customDNSDomain,
networkRange,
networkRangeV6,
ipv6GroupNames,
]);
const { hasChanges, updateRef } = useHasChanges([
customDNSDomain,
networkRange,
networkRangeV6,
ipv6GroupNames,
]);
const saveChanges = async () => {
const groups = await saveGroups();
const ipv6EnabledGroupIds = groups
.map((group) => group.id)
.filter(Boolean) as string[];
const saveChanges = async () => {
const groups = await saveGroups();
const ipv6EnabledGroupIds = groups
.map((group) => group.id)
.filter(Boolean) as string[];
const updatedSettings = {
...account.settings,
ipv6_enabled_groups: ipv6EnabledGroupIds,
};
const updatedSettings = {
...account.settings,
ipv6_enabled_groups: ipv6EnabledGroupIds,
};
if (customDNSDomain !== "" || account.settings.dns_domain) {
updatedSettings.dns_domain = customDNSDomain;
}
if (customDNSDomain !== "" || account.settings.dns_domain) {
updatedSettings.dns_domain = customDNSDomain;
}
// Only send network ranges when the user actually changed them, to avoid
// triggering a reallocation when the server hasn't stored an explicit override.
if (networkRange !== (account.settings.network_range || "")) {
updatedSettings.network_range = networkRange;
} else {
delete updatedSettings.network_range;
}
// Only send network ranges when the user actually changed them, to avoid
// triggering a reallocation when the server hasn't stored an explicit override.
if (networkRange !== (account.settings.network_range || "")) {
updatedSettings.network_range = networkRange;
} else {
delete updatedSettings.network_range;
}
if (networkRangeV6 !== (account.settings.network_range_v6 || "")) {
updatedSettings.network_range_v6 = networkRangeV6;
} else {
delete updatedSettings.network_range_v6;
}
if (networkRangeV6 !== (account.settings.network_range_v6 || "")) {
updatedSettings.network_range_v6 = networkRangeV6;
} else {
delete updatedSettings.network_range_v6;
}
notify({
title: "Network Settings",
description: `Network settings successfully updated.`,
promise: saveRequest
.put({
id: account.id,
settings: updatedSettings,
})
.then(() => {
mutate("/accounts");
updateRef([
customDNSDomain,
networkRange,
networkRangeV6,
ipv6GroupNames,
]);
}),
loadingMessage: "Updating network settings...",
});
};
notify({
title: "Network Settings",
description: `Network settings successfully updated.`,
promise: saveRequest
.put({
id: account.id,
settings: updatedSettings,
})
.then(() => {
mutate("/accounts");
updateRef([
customDNSDomain,
networkRange,
networkRangeV6,
ipv6GroupNames,
]);
}),
loadingMessage: "Updating network settings...",
});
};
const domainError = useMemo(() => {
if (customDNSDomain == "") return "";
const valid = validator.isValidDomain(customDNSDomain, {
allowWildcard: false,
allowOnlyTld: false,
});
if (!valid) {
return "Please enter a valid domain, e.g. example.com or intra.example.com";
}
}, [customDNSDomain]);
const domainError = useMemo(() => {
if (customDNSDomain == "") return "";
const valid = validator.isValidDomain(customDNSDomain, {
allowWildcard: false,
allowOnlyTld: false,
});
if (!valid) {
return "Please enter a valid domain, e.g. example.com or intra.example.com";
}
}, [customDNSDomain]);
const networkRangeError = useMemo(() => {
if (networkRange == "") {
if (account.settings.network_range) {
return "Network range cannot be empty";
}
return "";
}
const networkRangeError = useMemo(() => {
if (networkRange == "") {
if (account.settings.network_range) {
return "Network range cannot be empty";
}
return "";
}
try {
const validCIDR = cidr.isValidCIDR(networkRange);
if (!validCIDR) {
return "Please enter a valid IPv4 CIDR range, e.g. 100.64.0.0/16 or 192.168.1.0/24";
}
} catch (error) {
return "Please enter a valid IPv4 CIDR range, e.g. 100.64.0.0/16 or 192.168.1.0/24";
}
}, [networkRange, account.settings.network_range]);
try {
const validCIDR = cidr.isValidCIDR(networkRange);
if (!validCIDR) {
return "Please enter a valid IPv4 CIDR range, e.g. 100.64.0.0/16 or 192.168.1.0/24";
}
} catch (error) {
return "Please enter a valid IPv4 CIDR range, e.g. 100.64.0.0/16 or 192.168.1.0/24";
}
}, [networkRange, account.settings.network_range]);
const networkRangeV6Error = useMemo(() => {
if (networkRangeV6 == "") return "";
if (!networkRangeV6.includes(":") || !cidr.isValidCIDR(networkRangeV6)) {
return "Please enter a valid IPv6 CIDR range, e.g. fd00:1234::/64";
}
const prefixLen = parseInt(networkRangeV6.split("/")[1], 10);
if (prefixLen < 48 || prefixLen > 112) {
return "Prefix length must be between /48 and /112";
}
}, [networkRangeV6]);
const networkRangeV6Error = useMemo(() => {
if (networkRangeV6 == "") return "";
if (!networkRangeV6.includes(":") || !cidr.isValidCIDR(networkRangeV6)) {
return "Please enter a valid IPv6 CIDR range, e.g. fd00:1234::/64";
}
const prefixLen = parseInt(networkRangeV6.split("/")[1], 10);
if (prefixLen < 48 || prefixLen > 112) {
return "Prefix length must be between /48 and /112";
}
}, [networkRangeV6]);
return (
<Tabs.Content value={"networks"}>
<div className={"p-default py-6 max-w-2xl"}>
<Breadcrumbs>
<Breadcrumbs.Item
href={"/settings"}
label={t("title")}
icon={<SettingsIcon size={13} />}
/>
<Breadcrumbs.Item
href={"/settings?tab=networks"}
label={t("networksTab")}
icon={<NetworkIcon size={14} />}
active
/>
</Breadcrumbs>
<div className={"flex items-start justify-between"}>
<div>
<h1>{t("networksTab")}</h1>
</div>
<Button
variant={"primary"}
disabled={
!hasChanges ||
!permission.settings.update ||
!!domainError ||
!!networkRangeError ||
!!networkRangeV6Error
}
onClick={saveChanges}
>
Save Changes
</Button>
</div>
return (
<Tabs.Content value={"networks"}>
<div className={"p-default py-6 max-w-2xl"}>
<Breadcrumbs>
<Breadcrumbs.Item
href={"/settings"}
label={t("title")}
icon={<SettingsIcon size={13} />}
/>
<Breadcrumbs.Item
href={"/settings?tab=networks"}
label={t("networksTab")}
icon={<NetworkIcon size={14} />}
active
/>
</Breadcrumbs>
<div className={"flex items-start justify-between"}>
<div>
<h1>{t("networksTab")}</h1>
</div>
<Button
variant={"primary"}
disabled={
!hasChanges ||
!permission.settings.update ||
!!domainError ||
!!networkRangeError ||
!!networkRangeV6Error
}
onClick={saveChanges}
>
Save Changes
</Button>
</div>
<div className={"flex flex-col gap-6 w-full mt-8"}>
<div>
<div
className={
"flex flex-col gap-1 sm:flex-row w-full sm:gap-4 items-center"
}
>
<div className={"min-w-[330px]"}>
<Label>DNS Domain</Label>
<HelpText>
Specify a custom peer DNS domain for your network. This should
not point to a domain that is already in use elsewhere, to
avoid overriding DNS results.
</HelpText>
</div>
<div className={"w-full"}>
<Input
placeholder={
isNetBirdHosted() ? "netbird.cloud" : "netbird.selfhosted"
}
errorTooltip={true}
errorTooltipPosition={"top"}
error={domainError}
value={customDNSDomain}
disabled={!permission.settings.update}
onChange={(e) => setCustomDNSDomain(e.target.value)}
/>
</div>
</div>
</div>
<div className={"flex flex-col gap-6 w-full mt-8"}>
<div>
<div
className={
"flex flex-col gap-1 sm:flex-row w-full sm:gap-4 items-center"
}
>
<div className={"min-w-[330px]"}>
<Label>DNS Domain</Label>
<HelpText>
Specify a custom peer DNS domain for your network. This should
not point to a domain that is already in use elsewhere, to
avoid overriding DNS results.
</HelpText>
</div>
<div className={"w-full"}>
<Input
placeholder={
isNetBirdHosted() ? "netbird.cloud" : "netbird.selfhosted"
}
errorTooltip={true}
errorTooltipPosition={"top"}
error={domainError}
value={customDNSDomain}
disabled={!permission.settings.update}
onChange={(e) => setCustomDNSDomain(e.target.value)}
/>
</div>
</div>
</div>
<div>
<div
className={
"flex flex-col gap-1 sm:flex-row w-full sm:gap-4 items-center"
}
>
<div className={"min-w-[330px]"}>
<Label>Network Range</Label>
<HelpText>
Specify a custom IPv4 range for your network in CIDR format.
All peer IPs will be re-allocated when changed.
</HelpText>
</div>
<div className={"w-full"}>
<Input
placeholder={"e.g. 100.64.0.0/16"}
errorTooltip={true}
errorTooltipPosition={"top"}
error={networkRangeError}
value={networkRange}
disabled={!permission.settings.update}
onChange={(e) => setNetworkRange(e.target.value)}
/>
</div>
</div>
</div>
<div>
<div
className={
"flex flex-col gap-1 sm:flex-row w-full sm:gap-4 items-center"
}
>
<div className={"min-w-[330px]"}>
<Label>Network Range</Label>
<HelpText>
Specify a custom IPv4 range for your network in CIDR format.
All peer IPs will be re-allocated when changed.
</HelpText>
</div>
<div className={"w-full"}>
<Input
placeholder={"e.g. 100.64.0.0/16"}
errorTooltip={true}
errorTooltipPosition={"top"}
error={networkRangeError}
value={networkRange}
disabled={!permission.settings.update}
onChange={(e) => setNetworkRange(e.target.value)}
/>
</div>
</div>
</div>
<div>
<div
className={
"flex flex-col gap-1 sm:flex-row w-full sm:gap-4 items-center"
}
>
<div className={"min-w-[330px]"}>
<Label>IPv6 Network Range</Label>
<HelpText>
Specify a custom IPv6 range for your network in CIDR format.
All peer IPv6 addresses will be re-allocated when changed.
</HelpText>
</div>
<div className={"w-full"}>
<Input
placeholder={"e.g. fd00:1234:5678::/64"}
errorTooltip={true}
errorTooltipPosition={"top"}
error={networkRangeV6Error}
value={networkRangeV6}
disabled={!permission.settings.update}
onChange={(e) => setNetworkRangeV6(e.target.value)}
/>
</div>
</div>
</div>
<div>
<div
className={
"flex flex-col gap-1 sm:flex-row w-full sm:gap-4 items-center"
}
>
<div className={"min-w-[330px]"}>
<Label>IPv6 Network Range</Label>
<HelpText>
Specify a custom IPv6 range for your network in CIDR format.
All peer IPv6 addresses will be re-allocated when changed.
</HelpText>
</div>
<div className={"w-full"}>
<Input
placeholder={"e.g. fd00:1234:5678::/64"}
errorTooltip={true}
errorTooltipPosition={"top"}
error={networkRangeV6Error}
value={networkRangeV6}
disabled={!permission.settings.update}
onChange={(e) => setNetworkRangeV6(e.target.value)}
/>
</div>
</div>
</div>
<div>
<Label>IPv6 Enabled Groups</Label>
<HelpText>
Peers in the selected groups will receive IPv6 overlay addresses
(dual-stack). Remove all groups to disable IPv6. Changes apply on
save and will restart affected clients.
</HelpText>
<PeerGroupSelector
values={ipv6EnabledGroups}
onChange={setIpv6EnabledGroups}
placeholder="Select groups to enable IPv6..."
showResourceCounter={false}
disabled={!permission.settings.update}
/>
</div>
<div>
<Label>IPv6 Enabled Groups</Label>
<HelpText>
Peers in the selected groups will receive IPv6 overlay addresses
(dual-stack). Remove all groups to disable IPv6. Changes apply on
save and will restart affected clients.
</HelpText>
<PeerGroupSelector
values={ipv6EnabledGroups}
onChange={setIpv6EnabledGroups}
placeholder="Select groups to enable IPv6..."
showResourceCounter={false}
disabled={!permission.settings.update}
/>
</div>
<div className={"mt-4"} />
<div className={"mt-4"} />
<FancyToggleSwitch
value={routingPeerDNSSetting}
onChange={toggleNetworkDNSSetting}
label={
<>
<GlobeIcon size={15} />
Enable DNS Wildcard Routing
</>
}
helpText={
<>
Allow routing using DNS wildcards. This requires NetBird client
v0.35 or higher. Changes will only take effect after restarting
the clients.{" "}
<InlineLink
href={
"https://docs.netbird.io/how-to/accessing-entire-domains-within-networks#enabling-dns-wildcard-routing"
}
target={"_blank"}
onClick={(e) => e.stopPropagation()}
>
Learn more
<ExternalLinkIcon size={12} />
</InlineLink>
</>
}
disabled={!permission.settings.update}
/>
</div>
</div>
</Tabs.Content>
);
<FancyToggleSwitch
value={routingPeerDNSSetting}
onChange={toggleNetworkDNSSetting}
label={
<>
<GlobeIcon size={15} />
Enable DNS Wildcard Routing
</>
}
helpText={
<>
Allow routing using DNS wildcards. This requires NetBird client
v0.35 or higher. Changes will only take effect after restarting
the clients.{" "}
<InlineLink
href={
"https://docs.netbird.io/how-to/accessing-entire-domains-within-networks#enabling-dns-wildcard-routing"
}
target={"_blank"}
onClick={(e) => e.stopPropagation()}
>
Learn more
<ExternalLinkIcon size={12} />
</InlineLink>
</>
}
disabled={!permission.settings.update}
/>
</div>
</div>
</Tabs.Content>
);
}

View File

@@ -14,86 +14,86 @@ import { useHasChanges } from "@/hooks/useHasChanges";
import { Account } from "@/interfaces/Account";
type Props = {
account: Account;
account: Account;
};
export default function PermissionsTab({ account }: Props) {
const t = useTranslations("settings");
const { permission } = usePermissions();
const t = useTranslations("settings");
const { permission } = usePermissions();
const { mutate } = useSWRConfig();
const saveRequest = useApiCall<Account>("/accounts/" + account.id);
const { mutate } = useSWRConfig();
const saveRequest = useApiCall<Account>("/accounts/" + account.id);
const [userViewBlocked, setUserViewBlocked] = useState<boolean>(
account?.settings.regular_users_view_blocked ?? false,
);
const [userViewBlocked, setUserViewBlocked] = useState<boolean>(
account?.settings.regular_users_view_blocked ?? false,
);
const { hasChanges, updateRef } = useHasChanges([userViewBlocked]);
const { hasChanges, updateRef } = useHasChanges([userViewBlocked]);
const saveChanges = async () => {
notify({
title: "Permission Settings",
description: "Permissions were updated successfully.",
promise: saveRequest
.put({
id: account.id,
settings: {
...account.settings,
regular_users_view_blocked: userViewBlocked,
},
})
.then(() => {
mutate("/accounts");
updateRef([userViewBlocked]);
}),
loadingMessage: "Updating permissions...",
});
};
const saveChanges = async () => {
notify({
title: "Permission Settings",
description: "Permissions were updated successfully.",
promise: saveRequest
.put({
id: account.id,
settings: {
...account.settings,
regular_users_view_blocked: userViewBlocked,
},
})
.then(() => {
mutate("/accounts");
updateRef([userViewBlocked]);
}),
loadingMessage: "Updating permissions...",
});
};
return (
<Tabs.Content value={"permissions"} className={"w-full"}>
<div className={"p-default py-6 max-w-xl"}>
<Breadcrumbs>
<Breadcrumbs.Item
href={"/settings"}
label={t("title")}
icon={<SettingsIcon size={13} />}
/>
<Breadcrumbs.Item
href={"/settings?tab=permissions"}
label={t("permissions")}
icon={<LockIcon size={14} />}
active
/>
</Breadcrumbs>
<div className={"flex items-start justify-between"}>
<h1>{t("permissions")}</h1>
<Button
variant={"primary"}
disabled={!hasChanges || !permission.settings.update}
onClick={saveChanges}
>
Save Changes
</Button>
</div>
return (
<Tabs.Content value={"permissions"} className={"w-full"}>
<div className={"p-default py-6 max-w-xl"}>
<Breadcrumbs>
<Breadcrumbs.Item
href={"/settings"}
label={t("title")}
icon={<SettingsIcon size={13} />}
/>
<Breadcrumbs.Item
href={"/settings?tab=permissions"}
label={t("permissions")}
icon={<LockIcon size={14} />}
active
/>
</Breadcrumbs>
<div className={"flex items-start justify-between"}>
<h1>{t("permissions")}</h1>
<Button
variant={"primary"}
disabled={!hasChanges || !permission.settings.update}
onClick={saveChanges}
>
Save Changes
</Button>
</div>
<div className={"flex flex-col gap-6 mt-8 mb-3"}>
<FancyToggleSwitch
value={userViewBlocked}
onChange={setUserViewBlocked}
label={
<>
<GaugeIcon size={15} />
Restrict dashboard for regular users
</>
}
helpText={
"Access to the dashboard will be limited and regular users will not be able to view any peers."
}
disabled={!permission.settings.update}
/>
</div>
</div>
</Tabs.Content>
);
<div className={"flex flex-col gap-6 mt-8 mb-3"}>
<FancyToggleSwitch
value={userViewBlocked}
onChange={setUserViewBlocked}
label={
<>
<GaugeIcon size={15} />
Restrict dashboard for regular users
</>
}
helpText={
"Access to the dashboard will be limited and regular users will not be able to view any peers."
}
disabled={!permission.settings.update}
/>
</div>
</div>
</Tabs.Content>
);
}

View File

@@ -15,71 +15,71 @@ import { Group } from "@/interfaces/Group";
import { SetupKey } from "@/interfaces/SetupKey";
const SetupKeysTable = lazy(
() => import("@/modules/setup-keys/SetupKeysTable"),
() => import("@/modules/setup-keys/SetupKeysTable"),
);
export default function SetupKeysTab() {
const t = useTranslations("settings");
const { data: setupKeys, isLoading } = useFetchApi<SetupKey[]>("/setup-keys");
const { permission } = usePermissions();
const { groups } = useGroups();
const t = useTranslations("settings");
const { data: setupKeys, isLoading } = useFetchApi<SetupKey[]>("/setup-keys");
const { permission } = usePermissions();
const { groups } = useGroups();
const setupKeysWithGroups = useMemo(() => {
if (!setupKeys) return [];
return setupKeys.map((setupKey) => {
if (!setupKey.auto_groups) return setupKey;
if (!groups) return setupKey;
return {
...setupKey,
groups: setupKey.auto_groups
?.map((group) => groups.find((g) => g.id === group) || undefined)
.filter((group) => group !== undefined) as Group[],
};
});
}, [setupKeys, groups]);
const setupKeysWithGroups = useMemo(() => {
if (!setupKeys) return [];
return setupKeys.map((setupKey) => {
if (!setupKey.auto_groups) return setupKey;
if (!groups) return setupKey;
return {
...setupKey,
groups: setupKey.auto_groups
?.map((group) => groups.find((g) => g.id === group) || undefined)
.filter((group) => group !== undefined) as Group[],
};
});
}, [setupKeys, groups]);
return (
<Tabs.Content value={"setup-keys"} className={"w-full"}>
<div className={"p-default py-6"}>
<Breadcrumbs>
<Breadcrumbs.Item
href={"/settings"}
label={t("title")}
icon={<SettingsIcon size={13} />}
/>
<Breadcrumbs.Item
href={"/settings?tab=setup-keys"}
label={t("setupKeys")}
icon={<KeyRound size={14} />}
active
/>
</Breadcrumbs>
<h1>{t("setupKeys")}</h1>
<Paragraph>
Setup keys are pre-authentication keys that allow to register new
machines in your network.{" "}
<InlineLink
href={
"https://docs.netbird.io/how-to/register-machines-using-setup-keys"
}
target={"_blank"}
>
Learn more
<ExternalLinkIcon size={12} />
</InlineLink>
</Paragraph>
</div>
<RestrictedAccess
page={"Setup Keys"}
hasAccess={permission.setup_keys.read}
>
<Suspense fallback={<SkeletonTable />}>
<SetupKeysTable
setupKeys={setupKeysWithGroups}
isLoading={isLoading}
/>
</Suspense>
</RestrictedAccess>
</Tabs.Content>
);
}
return (
<Tabs.Content value={"setup-keys"} className={"w-full"}>
<div className={"p-default py-6"}>
<Breadcrumbs>
<Breadcrumbs.Item
href={"/settings"}
label={t("title")}
icon={<SettingsIcon size={13} />}
/>
<Breadcrumbs.Item
href={"/settings?tab=setup-keys"}
label={t("setupKeys")}
icon={<KeyRound size={14} />}
active
/>
</Breadcrumbs>
<h1>{t("setupKeys")}</h1>
<Paragraph>
Setup keys are pre-authentication keys that allow to register new
machines in your network.{" "}
<InlineLink
href={
"https://docs.netbird.io/how-to/register-machines-using-setup-keys"
}
target={"_blank"}
>
Learn more
<ExternalLinkIcon size={12} />
</InlineLink>
</Paragraph>
</div>
<RestrictedAccess
page={"Setup Keys"}
hasAccess={permission.setup_keys.read}
>
<Suspense fallback={<SkeletonTable />}>
<SetupKeysTable
setupKeys={setupKeysWithGroups}
isLoading={isLoading}
/>
</Suspense>
</RestrictedAccess>
</Tabs.Content>
);
}

View File

@@ -37,6 +37,7 @@ import { Group } from "@/interfaces/Group";
import { SetupKey } from "@/interfaces/SetupKey";
import useGroupHelper from "@/modules/groups/useGroupHelper";
import SetupModal from "@/modules/setup-netbird-modal/SetupModal";
import { useTranslations } from "next-intl";
type Props = {
children?: React.ReactNode;
@@ -57,6 +58,7 @@ export default function SetupKeyModal({
showOnlyRoutingPeerOS,
groups,
}: Readonly<Props>) {
const t = useTranslations("common");
const [successModal, setSuccessModal] = useState(false);
const [setupKey, setSetupKey] = useState<SetupKey>();
const [installModal, setInstallModal] = useState(false);
@@ -170,6 +172,7 @@ export function SetupKeyModalContent({
predefinedName = "",
groups,
}: Readonly<ModalProps>) {
const t = useTranslations("common");
const setupKeyRequest = useApiCall<SetupKey>("/setup-keys", true);
const { mutate } = useSWRConfig();
@@ -380,7 +383,7 @@ export function SetupKeyModalContent({
</div>
<div className={"flex gap-3 w-full justify-end"}>
<ModalClose asChild={true}>
<Button variant={"secondary"}>Cancel</Button>
<Button variant={"secondary"}>{t("cancel")}</Button>
</ModalClose>
<Button

View File

@@ -17,6 +17,7 @@ import HelpText from "@components/HelpText";
import { useApiCall } from "@utils/api";
import { KeyRound, LockIcon } from "lucide-react";
import React, { useMemo, useState } from "react";
import { useTranslations } from "next-intl";
type Props = {
children: React.ReactNode;
@@ -27,6 +28,7 @@ export default function ChangePasswordModal({
children,
userId,
}: Readonly<Props>) {
const t = useTranslations("common");
const [modal, setModal] = useState(false);
return (
@@ -49,6 +51,7 @@ export function ChangePasswordModalContent({
userId,
onSuccess,
}: Readonly<ModalProps>) {
const t = useTranslations("common");
const passwordRequest = useApiCall<void>(`/users/${userId}/password`, true);
const [currentPassword, setCurrentPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
@@ -177,7 +180,7 @@ export function ChangePasswordModalContent({
<ModalFooter className={"items-center"}>
<div className={"flex gap-3 w-full justify-end"}>
<ModalClose asChild={true}>
<Button variant={"secondary"}>Cancel</Button>
<Button variant={"secondary"}>{t("cancel")}</Button>
</ModalClose>
<Button

View File

@@ -21,6 +21,7 @@ import React, { useMemo, useState } from "react";
import { useSWRConfig } from "swr";
import { Role, User } from "@/interfaces/User";
import { UserRoleSelector } from "@/modules/users/UserRoleSelector";
import { useTranslations } from "next-intl";
type Props = {
children: React.ReactNode;
@@ -47,6 +48,8 @@ export function ServiceUserModalContent({ onSuccess }: Readonly<ModalProps>) {
const [name, setName] = useState("");
const [role, setRole] = useState("user");
const t = useTranslations("common");
const create = async () => {
notify({
title: "Service user created",
@@ -123,7 +126,7 @@ export function ServiceUserModalContent({ onSuccess }: Readonly<ModalProps>) {
</div>
<div className={"flex gap-3 w-full justify-end"}>
<ModalClose asChild={true}>
<Button variant={"secondary"}>Cancel</Button>
<Button variant={"secondary"}>{t("cancel")}</Button>
</ModalClose>
<Button

View File

@@ -3,7 +3,7 @@ import { Suspense, useMemo } from "react";
import { usePortalElement } from "@hooks/usePortalElement";
import { useTranslations } from "next-intl";
import SkeletonTable, {
SkeletonTableHeader,
SkeletonTableHeader,
} from "@components/skeletons/SkeletonTable";
import { User } from "@/interfaces/User";
import useFetchApi from "@utils/api";
@@ -14,61 +14,62 @@ import PeerIcon from "@/assets/icons/PeerIcon";
import Paragraph from "@components/Paragraph";
type Props = {
user: User;
user: User;
};
export const UserPeersSection = ({ user }: Props) => {
const t = useTranslations("peers"); const { ref: headingRef, portalTarget } =
usePortalElement<HTMLHeadingElement>();
const t = useTranslations("peers");
const { ref: headingRef, portalTarget } =
usePortalElement<HTMLHeadingElement>();
const { data: peers, isLoading: isPeersLoading } =
useFetchApi<Peer[]>("/peers");
const { data: peers, isLoading: isPeersLoading } =
useFetchApi<Peer[]>("/peers");
const userPeers = useMemo(() => {
return (
peers?.filter((peer) => {
return peer?.user_id === user.id;
}) || []
);
}, [user, peers]);
const userPeers = useMemo(() => {
return (
peers?.filter((peer) => {
return peer?.user_id === user.id;
}) || []
);
}, [user, peers]);
return (
<div className={"pb-10 px-8"}>
<div className={"max-w-6xl"}>
<div className={"flex justify-between items-center mb-5"}>
<div>
<h2 ref={headingRef}>{t("title")}</h2>
<Paragraph>{t("userPeersDescription")}</Paragraph>
</div>
</div>
return (
<div className={"pb-10 px-8"}>
<div className={"max-w-6xl"}>
<div className={"flex justify-between items-center mb-5"}>
<div>
<h2 ref={headingRef}>{t("title")}</h2>
<Paragraph>{t("userPeersDescription")}</Paragraph>
</div>
</div>
<Suspense
fallback={
<div>
<SkeletonTableHeader className={"!p-0"} />
<div className={"mt-8 w-full"}>
<SkeletonTable withHeader={false} />
</div>
</div>
}
>
<MinimalPeersTable
isLoading={isPeersLoading}
peers={userPeers}
headingTarget={portalTarget}
getStartedCard={
<NoResults
className={"py-4"}
title={"This user has no registered peers"}
description={
"Install NetBird and sign in as this user to register peers."
}
icon={<PeerIcon size={20} className={"fill-nb-gray-300"} />}
/>
}
/>
</Suspense>
</div>
</div>
);
<Suspense
fallback={
<div>
<SkeletonTableHeader className={"!p-0"} />
<div className={"mt-8 w-full"}>
<SkeletonTable withHeader={false} />
</div>
</div>
}
>
<MinimalPeersTable
isLoading={isPeersLoading}
peers={userPeers}
headingTarget={portalTarget}
getStartedCard={
<NoResults
className={"py-4"}
title={"This user has no registered peers"}
description={
"Install NetBird and sign in as this user to register peers."
}
icon={<PeerIcon size={20} className={"fill-nb-gray-300"} />}
/>
}
/>
</Suspense>
</div>
</div>
);
};