Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bf81aeb02d | ||
|
|
b058e66e32 | ||
|
|
8d6b617cbd | ||
|
|
47db655e9f | ||
|
|
0661cbf9f4 | ||
|
|
240a96fa8b | ||
|
|
43bc069a49 | ||
|
|
936de0f4f3 |
1
.github/workflows/build_and_push.yml
vendored
1
.github/workflows/build_and_push.yml
vendored
@@ -2,7 +2,6 @@ name: build and push
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- "feature/**"
|
||||
- main
|
||||
tags:
|
||||
- "**"
|
||||
|
||||
5290
package-lock.json
generated
5290
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -56,7 +56,6 @@
|
||||
"date-fns": "^2.30.0",
|
||||
"dayjs": "^1.11.10",
|
||||
"elkjs": "^0.10.0",
|
||||
"eslint": "^8",
|
||||
"eslint-config-prettier": "^9.0.0",
|
||||
"eslint-plugin-simple-import-sort": "^10.0.0",
|
||||
"flowbite": "^1.8.1",
|
||||
@@ -66,7 +65,7 @@
|
||||
"js-cookie": "^3.0.5",
|
||||
"lodash": "^4.17.21",
|
||||
"lucide-react": "^0.539.0",
|
||||
"next": "^14.2.28",
|
||||
"next": "^14.2.35",
|
||||
"next-themes": "^0.2.1",
|
||||
"punycode": "^2.3.1",
|
||||
"react": "^18.3.1",
|
||||
@@ -90,9 +89,10 @@
|
||||
"@faker-js/faker": "^9.5.1",
|
||||
"@types/chroma-js": "^3.1.1",
|
||||
"@types/js-cookie": "^3.0.6",
|
||||
"eslint-config-next": "^14.2.28",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-config-next": "^16.0.5",
|
||||
"postcss": "^8",
|
||||
"prettier": "3.0.3",
|
||||
"tailwindcss": "^3"
|
||||
"tailwindcss": "^3.4.17"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -154,7 +154,7 @@ const GroupOverviewTabs = ({ group }: { group: Group }) => {
|
||||
};
|
||||
|
||||
const [tab, setTab] = useState(getInitialTab());
|
||||
const groupDetails = useGroupDetails(group?.id || "");
|
||||
const { groupDetails, isLoading } = useGroupDetails(group?.id || "");
|
||||
|
||||
const peersCount = groupDetails?.peers_count || 0;
|
||||
const usersCount = groupDetails?.users?.length || 0;
|
||||
@@ -266,31 +266,49 @@ const GroupOverviewTabs = ({ group }: { group: Group }) => {
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value={"users"} className={"pb-8"}>
|
||||
<GroupUsersSection users={groupDetails?.users} />
|
||||
<GroupUsersSection users={groupDetails?.users} isLoading={isLoading} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value={"peers"} className={"pb-8"}>
|
||||
<GroupPeersSection peers={groupDetails?.peersOfGroup} />
|
||||
<GroupPeersSection
|
||||
peers={groupDetails?.peersOfGroup}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value={"policies"} className={"pb-8"}>
|
||||
<GroupPoliciesSection policies={groupDetails?.policies} />
|
||||
<GroupPoliciesSection
|
||||
policies={groupDetails?.policies}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value={"resources"} className={"pb-8"}>
|
||||
<GroupResourcesSection resources={groupDetails?.networkResources} />
|
||||
<GroupResourcesSection
|
||||
resources={groupDetails?.networkResources}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value={"network-routes"} className={"pb-8"}>
|
||||
<GroupNetworkRoutesSection routes={groupDetails?.routes} />
|
||||
<GroupNetworkRoutesSection
|
||||
routes={groupDetails?.routes}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value={"nameservers"} className={"pb-8"}>
|
||||
<GroupNameserversSection nameserverGroups={groupDetails?.nameservers} />
|
||||
<GroupNameserversSection
|
||||
nameserverGroups={groupDetails?.nameservers}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value={"setup-keys"} className={"pb-8"}>
|
||||
<GroupSetupKeysSection setupKeys={groupDetails?.setupKeys} />
|
||||
<GroupSetupKeysSection
|
||||
setupKeys={groupDetails?.setupKeys}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import Breadcrumbs from "@components/Breadcrumbs";
|
||||
import { Callout } from "@components/Callout";
|
||||
import InlineLink from "@components/InlineLink";
|
||||
import Paragraph from "@components/Paragraph";
|
||||
import SkeletonTable from "@components/skeletons/SkeletonTable";
|
||||
@@ -17,6 +16,7 @@ import RoutesProvider from "@/contexts/RoutesProvider";
|
||||
import { Route } from "@/interfaces/Route";
|
||||
import PageContainer from "@/layouts/PageContainer";
|
||||
import useGroupedRoutes from "@/modules/route-group/useGroupedRoutes";
|
||||
import { Callout } from "@components/Callout";
|
||||
|
||||
const NetworkRoutesTable = lazy(
|
||||
() => import("@/modules/route-group/NetworkRoutesTable"),
|
||||
|
||||
@@ -30,6 +30,7 @@ import { cn } from "@utils/helpers";
|
||||
import dayjs from "dayjs";
|
||||
import { isEmpty, trim } from "lodash";
|
||||
import {
|
||||
ArrowRightIcon,
|
||||
Barcode,
|
||||
CalendarDays,
|
||||
Cpu,
|
||||
@@ -65,6 +66,7 @@ import { PeerNetworkRoutesSection } from "@/modules/peer/PeerNetworkRoutesSectio
|
||||
import { PeerSSHToggle } from "@/modules/peer/PeerSSHToggle";
|
||||
import { RDPButton } from "@/modules/remote-access/rdp/RDPButton";
|
||||
import { SSHButton } from "@/modules/remote-access/ssh/SSHButton";
|
||||
import Link from "next/link";
|
||||
|
||||
export default function PeerPage() {
|
||||
const queryParameter = useSearchParams();
|
||||
@@ -148,7 +150,7 @@ const PeerGeneralInformation = () => {
|
||||
);
|
||||
const [selectedGroups, setSelectedGroups, { getAllGroupCalls }] =
|
||||
useGroupHelper({
|
||||
initial: peerGroups,
|
||||
initial: peerGroups?.filter((g) => g?.name !== "All"),
|
||||
peer,
|
||||
});
|
||||
|
||||
@@ -237,9 +239,21 @@ const PeerGeneralInformation = () => {
|
||||
</h1>
|
||||
<LoginExpiredBadge loginExpired={peer.login_expired} />
|
||||
</div>
|
||||
<div className={"flex items-center gap-8"}>
|
||||
<Paragraph className={"flex items-center"}>{user?.email}</Paragraph>
|
||||
</div>
|
||||
{(user?.id || user?.email) && (
|
||||
<div className={"flex items-center gap-8"}>
|
||||
<Paragraph className={"flex items-center"}>
|
||||
<Link
|
||||
href={`/team/user?id=${user?.id}`}
|
||||
className={
|
||||
"hover:text-nb-gray-200 transition-all flex items-center gap-1"
|
||||
}
|
||||
>
|
||||
{user?.email || user?.id}
|
||||
<ArrowRightIcon size={14} />
|
||||
</Link>
|
||||
</Paragraph>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className={"flex gap-4"}>
|
||||
<Button
|
||||
|
||||
@@ -9,6 +9,7 @@ import { notify } from "@components/Notification";
|
||||
import Paragraph from "@components/Paragraph";
|
||||
import { PeerGroupSelector } from "@components/PeerGroupSelector";
|
||||
import Separator from "@components/Separator";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@components/Tabs";
|
||||
import FullScreenLoading from "@components/ui/FullScreenLoading";
|
||||
import { RestrictedAccess } from "@components/ui/RestrictedAccess";
|
||||
import useRedirect from "@hooks/useRedirect";
|
||||
@@ -16,7 +17,15 @@ import { IconCirclePlus, IconSettings2 } from "@tabler/icons-react";
|
||||
import useFetchApi, { useApiCall } from "@utils/api";
|
||||
import { generateColorFromString } from "@utils/helpers";
|
||||
import dayjs from "dayjs";
|
||||
import { Ban, GalleryHorizontalEnd, History, Mail, User2 } from "lucide-react";
|
||||
import {
|
||||
Ban,
|
||||
GalleryHorizontalEnd,
|
||||
History,
|
||||
KeyRoundIcon,
|
||||
Mail,
|
||||
MonitorSmartphoneIcon,
|
||||
User2,
|
||||
} from "lucide-react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import React, { useMemo, useState } from "react";
|
||||
import { useSWRConfig } from "swr";
|
||||
@@ -33,6 +42,7 @@ import useGroupHelper from "@/modules/groups/useGroupHelper";
|
||||
import { useGroupIdsToGroups } from "@/modules/groups/useGroupIdsToGroups";
|
||||
import UserBlockCell from "@/modules/users/table-cells/UserBlockCell";
|
||||
import UserStatusCell from "@/modules/users/table-cells/UserStatusCell";
|
||||
import { UserPeersSection } from "@/modules/users/UserPeersSection";
|
||||
import { UserRoleSelector } from "@/modules/users/UserRoleSelector";
|
||||
|
||||
export default function UserPage() {
|
||||
@@ -80,6 +90,7 @@ type Props = {
|
||||
function UserOverview({ user, initialGroups }: Readonly<Props>) {
|
||||
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;
|
||||
@@ -91,7 +102,6 @@ function UserOverview({ user, initialGroups }: Readonly<Props>) {
|
||||
});
|
||||
|
||||
const [role, setRole] = useState(user.role || Role.User);
|
||||
|
||||
const { hasChanges, updateRef: updateChangesRef } = useHasChanges([
|
||||
role,
|
||||
selectedGroups,
|
||||
@@ -114,13 +124,24 @@ function UserOverview({ user, initialGroups }: Readonly<Props>) {
|
||||
`/${user.id}`,
|
||||
)
|
||||
.then(() => {
|
||||
mutate(`/users?service_user=${user.is_service_user}`);
|
||||
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 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");
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<div className={"p-default py-6 mb-4"}>
|
||||
@@ -132,7 +153,7 @@ function UserOverview({ user, initialGroups }: Readonly<Props>) {
|
||||
icon={<TeamIcon size={13} />}
|
||||
/>
|
||||
|
||||
{user.is_service_user ? (
|
||||
{isServiceUser ? (
|
||||
<Breadcrumbs.Item
|
||||
href={"/team/service-users"}
|
||||
label={"Service Users"}
|
||||
@@ -158,7 +179,7 @@ function UserOverview({ user, initialGroups }: Readonly<Props>) {
|
||||
"w-10 h-10 rounded-full relative flex items-center justify-center text-white uppercase text-md font-medium bg-nb-gray-900"
|
||||
}
|
||||
style={
|
||||
user.is_service_user
|
||||
isServiceUser
|
||||
? {
|
||||
color: "white",
|
||||
}
|
||||
@@ -171,13 +192,13 @@ function UserOverview({ user, initialGroups }: Readonly<Props>) {
|
||||
}
|
||||
}
|
||||
>
|
||||
{user.is_service_user ? (
|
||||
{isServiceUser ? (
|
||||
<IconSettings2 size={16} />
|
||||
) : (
|
||||
user?.name?.charAt(0) || user?.id?.charAt(0)
|
||||
)}
|
||||
</div>
|
||||
<h1 className={"flex items-center gap-3"}>
|
||||
<h1 className={"flex items-center gap-3"} title={user?.id}>
|
||||
{user.name || user.id}
|
||||
</h1>
|
||||
</div>
|
||||
@@ -188,7 +209,7 @@ function UserOverview({ user, initialGroups }: Readonly<Props>) {
|
||||
variant={"default"}
|
||||
className={"w-full"}
|
||||
onClick={() => {
|
||||
user.is_service_user
|
||||
isServiceUser
|
||||
? router.push("/team/service-users")
|
||||
: router.push("/team/users");
|
||||
}}
|
||||
@@ -212,7 +233,7 @@ function UserOverview({ user, initialGroups }: Readonly<Props>) {
|
||||
<div className={"flex gap-10 w-full mt-8 max-w-6xl items-start"}>
|
||||
<UserInformationCard user={user} />
|
||||
<div className={"flex flex-col gap-8 w-1/2 "}>
|
||||
{!user.is_service_user && isOwnerOrAdmin && (
|
||||
{!isServiceUser && isOwnerOrAdmin && (
|
||||
<div>
|
||||
<Label>Auto-assigned groups</Label>
|
||||
<HelpText>
|
||||
@@ -238,7 +259,7 @@ function UserOverview({ user, initialGroups }: Readonly<Props>) {
|
||||
<UserRoleSelector
|
||||
value={role}
|
||||
onChange={setRole}
|
||||
hideOwner={user.is_service_user}
|
||||
hideOwner={isServiceUser}
|
||||
currentUser={user}
|
||||
disabled={isLoggedInUser || !permission.users.update}
|
||||
/>
|
||||
@@ -248,38 +269,65 @@ function UserOverview({ user, initialGroups }: Readonly<Props>) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(user.is_current || user.is_service_user) && permission.pats.read && (
|
||||
<>
|
||||
<Separator />
|
||||
<div className={"px-8 py-6"}>
|
||||
<div className={"max-w-6xl"}>
|
||||
<div className={"flex justify-between items-center"}>
|
||||
<div>
|
||||
<h2>Access Tokens</h2>
|
||||
<Paragraph>
|
||||
Access tokens give access to NetBird API.
|
||||
</Paragraph>
|
||||
</div>
|
||||
<div className={"inline-flex gap-4 justify-end"}>
|
||||
{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} />
|
||||
Access Tokens
|
||||
</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>
|
||||
<CreateAccessTokenModal user={user}>
|
||||
<Button
|
||||
variant={"primary"}
|
||||
data-cy={"access-token-open-modal"}
|
||||
disabled={!permission.pats.create}
|
||||
>
|
||||
<IconCirclePlus size={16} />
|
||||
Create Access Token
|
||||
</Button>
|
||||
</CreateAccessTokenModal>
|
||||
<h2>Access Tokens</h2>
|
||||
<Paragraph>
|
||||
Access tokens give access to NetBird API.
|
||||
</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>
|
||||
<AccessTokensTable user={user} />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</TabsContent>
|
||||
)}
|
||||
</Tabs>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import { notify } from "@components/Notification";
|
||||
import FullScreenLoading from "@components/ui/FullScreenLoading";
|
||||
import { IconCircleX } from "@tabler/icons-react";
|
||||
import useFetchApi from "@utils/api";
|
||||
import { cn } from "@utils/helpers";
|
||||
import { Loader2Icon } from "lucide-react";
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { Peer } from "@/interfaces/Peer";
|
||||
@@ -20,6 +19,8 @@ import {
|
||||
NetBirdStatus,
|
||||
useNetBirdClient,
|
||||
} from "@/modules/remote-access/useNetBirdClient";
|
||||
import { cn } from "@utils/helpers";
|
||||
import { isNetbirdSSHProtocolSupported } from "@utils/version";
|
||||
|
||||
export default function RDPPage() {
|
||||
const { peerId } = useRDPQueryParams();
|
||||
@@ -84,7 +85,12 @@ function RDPSession({ peer }: Props) {
|
||||
try {
|
||||
setCredentials(rdpCredentials);
|
||||
setIsNetBirdConnecting(true);
|
||||
await client.connectTemporary(peer.id, [`tcp/${rdpCredentials.port}`]);
|
||||
const protocol = isNetbirdSSHProtocolSupported(peer.version)
|
||||
? "netbird-ssh"
|
||||
: "tcp";
|
||||
await client.connectTemporary(peer.id, [
|
||||
`${protocol}/${rdpCredentials.port}`,
|
||||
]);
|
||||
setIsNetBirdConnecting(false);
|
||||
} catch (error) {
|
||||
sendErrorNotification(
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import { PageNotFound } from "@components/ui/PageNotFound";
|
||||
import useFetchApi, { ErrorResponse } from "@utils/api";
|
||||
import { isNativeSSHSupported } from "@utils/version";
|
||||
import { CircleXIcon, InfoIcon, Loader2Icon } from "lucide-react";
|
||||
import React, { useEffect, useRef } from "react";
|
||||
import type { Peer } from "@/interfaces/Peer";
|
||||
@@ -13,6 +12,10 @@ import {
|
||||
NetBirdStatus,
|
||||
useNetBirdClient,
|
||||
} from "@/modules/remote-access/useNetBirdClient";
|
||||
import {
|
||||
isNativeSSHSupported,
|
||||
isNetbirdSSHProtocolSupported,
|
||||
} from "@utils/version";
|
||||
|
||||
export default function SSHPage() {
|
||||
const { peerId, username, port } = useSSHQueryParams();
|
||||
@@ -88,7 +91,10 @@ function SSHTerminal({ username, port, peer }: Props) {
|
||||
connected.current = false;
|
||||
try {
|
||||
const aclPort = isNativeSSHSupported(peer.version) ? "22022" : port;
|
||||
const rules = [`tcp/${aclPort}`];
|
||||
const protocol = isNetbirdSSHProtocolSupported(peer.version)
|
||||
? "netbird-ssh"
|
||||
: "tcp";
|
||||
const rules = [`${protocol}/${aclPort}`];
|
||||
await client?.connectTemporary(peer.id, rules);
|
||||
await ssh({
|
||||
hostname: peer.ip,
|
||||
@@ -108,9 +114,13 @@ function SSHTerminal({ username, port, peer }: Props) {
|
||||
if (!peer.id) return;
|
||||
if (connected.current) return;
|
||||
connected.current = true;
|
||||
|
||||
try {
|
||||
const aclPort = isNativeSSHSupported(peer.version) ? "22022" : port;
|
||||
const rules = [`tcp/${aclPort}`];
|
||||
const protocol = isNetbirdSSHProtocolSupported(peer.version)
|
||||
? "netbird-ssh"
|
||||
: "tcp";
|
||||
const rules = [`${protocol}/${aclPort}`];
|
||||
await client?.connectTemporary(peer.id, rules);
|
||||
const res = await ssh({
|
||||
hostname: peer.ip,
|
||||
@@ -121,7 +131,7 @@ function SSHTerminal({ username, port, peer }: Props) {
|
||||
sshConnectedOnce.current = true;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Connection failed:", error);
|
||||
console.error("Connection error:", error);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
19
src/assets/icons/JumpcloudIcon.tsx
Normal file
19
src/assets/icons/JumpcloudIcon.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import { iconProperties, IconProps } from "@/assets/icons/IconProperties";
|
||||
|
||||
export default function JumpcloudIcon(props: Readonly<IconProps>) {
|
||||
return (
|
||||
<svg
|
||||
width="167"
|
||||
height="82"
|
||||
viewBox="0 0 167 82"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...iconProperties(props)}
|
||||
>
|
||||
<path
|
||||
d="M166.911 58.3592C166.911 64.3815 164.519 70.1571 160.26 74.4155C156.002 78.6739 150.226 81.0662 144.204 81.0662H137.961C137.31 73.4972 129.5 67.0612 118.46 64.0722C121.244 61.3253 123.148 57.8124 123.931 53.9803C124.713 50.1482 124.338 46.17 122.854 42.5515C121.369 38.933 118.842 35.8378 115.594 33.6594C112.345 31.481 108.522 30.3178 104.611 30.3178C100.7 30.3178 96.8772 31.481 93.6289 33.6594C90.3805 35.8378 87.8534 38.933 86.3689 42.5515C84.8843 46.17 84.5094 50.1482 85.2918 53.9803C86.0743 57.8124 87.9786 61.3253 90.7628 64.0722C85.5111 65.3278 80.6301 67.8055 76.5167 71.3037C73.9207 69.8152 71.1411 68.6726 68.2487 67.9049C70.6422 65.5587 72.2829 62.5529 72.9614 59.2707C73.6399 55.9884 73.3255 52.5784 72.0584 49.4755C70.7913 46.3726 68.6288 43.7174 65.8467 41.8484C63.0646 39.9793 59.7888 38.9812 56.4372 38.9812C53.0855 38.9812 49.8098 39.9793 47.0277 41.8484C44.2455 43.7174 42.0831 46.3726 40.816 49.4755C39.5488 52.5784 39.2345 55.9884 39.913 59.2707C40.5915 62.5529 42.2321 65.5587 44.6257 67.9049C35.9237 70.3154 29.5841 75.1364 28.2342 80.9698H21.991C16.0936 80.7777 10.502 78.2999 6.39821 74.0603C2.2944 69.8206 0 64.1513 0 58.2508C0 52.3503 2.2944 46.681 6.39821 42.4413C10.502 38.2016 16.0936 35.7238 21.991 35.5317C24.8814 35.5419 27.7438 36.0981 30.4278 37.1709C32.2478 33.2162 35.1686 29.8695 38.8407 27.5312C42.5128 25.1928 46.7807 23.9618 51.1341 23.9854C51.6885 23.9854 52.2429 23.9854 52.7732 23.9854C53.9093 18.1059 56.8018 12.7093 61.0689 8.50798C65.336 4.30669 70.7769 1.49837 76.6733 0.453829C82.5698 -0.590709 88.6443 0.177651 94.095 2.65746C99.546 5.13728 104.116 9.21191 107.203 14.3434C110.733 13.2708 114.463 13.023 118.104 13.6193C121.746 14.2155 125.202 15.6397 128.206 17.7822C131.21 19.9247 133.682 22.7283 135.432 25.977C137.182 29.2257 138.162 32.8326 138.298 36.52C141.665 35.6031 145.198 35.4762 148.622 36.1492C152.046 36.8222 155.269 38.277 158.038 40.4001C160.808 42.5233 163.049 45.2574 164.588 48.3892C166.127 51.5211 166.922 54.9661 166.911 58.4557V58.3592Z"
|
||||
fill="#4CC2BF"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
27
src/assets/icons/OIDCIcon.tsx
Normal file
27
src/assets/icons/OIDCIcon.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { iconProperties, IconProps } from "@/assets/icons/IconProperties";
|
||||
|
||||
export default function OIDCIcon(props: Readonly<IconProps>) {
|
||||
return (
|
||||
<svg
|
||||
width="173"
|
||||
height="174"
|
||||
viewBox="0 0 173 174"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...iconProperties(props)}
|
||||
>
|
||||
<path
|
||||
d="M76.3945 173.48L103.325 154.065L102.072 0L76.3945 20.041V173.48Z"
|
||||
fill="#FF8E00"
|
||||
/>
|
||||
<path
|
||||
d="M76.7077 173.48C-24.0221 157.466 -26.8926 69.7689 76.0814 50.7288L76.3945 68.8909C3.35034 81.0694 12.6045 146.598 76.3945 156.257L76.7077 173.48Z"
|
||||
fill="white"
|
||||
/>
|
||||
<path
|
||||
d="M103.011 68.2646C115.468 68.3493 126.32 74.0515 137.144 79.8508L121.174 91.7502H172.216L172.529 56.9916L156.558 68.8909C140.397 60.7278 125.542 50.9315 103.011 50.7288V68.2646Z"
|
||||
fill="white"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
BIN
src/assets/onboarding/acl.png
Normal file
BIN
src/assets/onboarding/acl.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 106 KiB |
BIN
src/assets/onboarding/activity.png
Normal file
BIN
src/assets/onboarding/activity.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 261 KiB |
BIN
src/assets/onboarding/posture.png
Normal file
BIN
src/assets/onboarding/posture.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 283 KiB |
@@ -32,6 +32,10 @@ const variants = cva("", {
|
||||
green: ["bg-green-950 border-green-500 border text-green-400"],
|
||||
netbird: ["bg-netbird-950 border-netbird-500 border text-netbird-500"],
|
||||
},
|
||||
size: {
|
||||
default: "text-[0.75rem] py-1.5 px-3",
|
||||
xs: "text-[0.6rem] py-[0.3rem] px-2",
|
||||
},
|
||||
hover: {
|
||||
none: [],
|
||||
blue: ["hover:bg-sky-200"],
|
||||
@@ -42,7 +46,7 @@ const variants = cva("", {
|
||||
red: ["hover:bg-red-950/40"],
|
||||
gray: ["hover:bg-nb-gray-900"],
|
||||
grayer: ["hover:bg-nb-gray-900"],
|
||||
"gray-ghost": ["hover:bg-nb-gray-900"],
|
||||
"gray-ghost": ["hover:bg-nb-gray-800 cursor-pointer"],
|
||||
green: ["hover:bg-green-950/50"],
|
||||
netbird: ["hover:bg-netbird-950/50"],
|
||||
},
|
||||
@@ -53,6 +57,7 @@ export default function Badge({
|
||||
children,
|
||||
className,
|
||||
variant = "blue",
|
||||
size = "default",
|
||||
useHover = false,
|
||||
disabled = false,
|
||||
...props
|
||||
@@ -60,8 +65,8 @@ export default function Badge({
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative z-10 cursor-inherit whitespace-nowrap rounded-md text-[12px] py-1.5 px-3 font-normal flex gap-1.5 items-center justify-center transition-all",
|
||||
variants({ variant, hover: useHover ? variant : "none" }),
|
||||
"relative z-10 cursor-inherit whitespace-nowrap rounded-md font-normal flex gap-1.5 items-center justify-center transition-all",
|
||||
variants({ variant, hover: useHover ? variant : "none", size }),
|
||||
disabled && "cursor-not-allowed opacity-50 select-none",
|
||||
className,
|
||||
)}
|
||||
|
||||
@@ -81,7 +81,7 @@ const menuItemVariants = cva("", {
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"dark:focus:bg-nb-gray-900 dark:focus:text-gray-50 dark:text-gray-400 dark:data-[state=open]:bg-nb-gray-900 dark:data-[state=open]:text-gray-50",
|
||||
"dark:focus:bg-nb-gray-900 dark:focus:text-gray-50 dark:text-nb-gray-300 dark:data-[state=open]:bg-nb-gray-900 dark:data-[state=open]:text-gray-50",
|
||||
danger:
|
||||
"dark:focus:bg-red-900/20 dark:focus:text-red-500 dark:text-red-500",
|
||||
},
|
||||
|
||||
43
src/components/HoverCard.tsx
Normal file
43
src/components/HoverCard.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
"use client";
|
||||
|
||||
import * as HoverCardPrimitive from "@radix-ui/react-hover-card";
|
||||
import { cn } from "@utils/helpers";
|
||||
import * as React from "react";
|
||||
import { TooltipVariants, tooltipVariants } from "./Tooltip";
|
||||
|
||||
const HoverCard = HoverCardPrimitive.Root;
|
||||
|
||||
const HoverCardTrigger = HoverCardPrimitive.Trigger;
|
||||
|
||||
const HoverCardContent = React.forwardRef<
|
||||
React.ElementRef<typeof HoverCardPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof HoverCardPrimitive.Content> &
|
||||
TooltipVariants
|
||||
>(
|
||||
(
|
||||
{
|
||||
className = "px-4 py-2.5",
|
||||
sideOffset = 7,
|
||||
side = "top",
|
||||
variant = "default",
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) => (
|
||||
<HoverCardPrimitive.Portal>
|
||||
<HoverCardPrimitive.Content
|
||||
ref={ref}
|
||||
asChild={true}
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(tooltipVariants({ variant }), className)}
|
||||
{...props}
|
||||
>
|
||||
<div>{props.children}</div>
|
||||
</HoverCardPrimitive.Content>
|
||||
</HoverCardPrimitive.Portal>
|
||||
),
|
||||
);
|
||||
HoverCardContent.displayName = HoverCardPrimitive.Content.displayName;
|
||||
|
||||
export { HoverCard, HoverCardContent, HoverCardTrigger };
|
||||
@@ -6,7 +6,7 @@ import { cva, type VariantProps } from "class-variance-authority";
|
||||
import * as React from "react";
|
||||
|
||||
const labelVariants = cva(
|
||||
"text-sm font-medium tracking-wider leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 mb-1 inline-block dark:text-nb-gray-200 flex items-center gap-2",
|
||||
"text-sm font-medium tracking-wider leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 mb-1.5 inline-block dark:text-nb-gray-200 flex items-center gap-2",
|
||||
);
|
||||
|
||||
const Label = React.forwardRef<
|
||||
|
||||
@@ -42,8 +42,8 @@ import { NetworkResource } from "@/interfaces/Network";
|
||||
import type { Peer } from "@/interfaces/Peer";
|
||||
import { PolicyRuleResource } from "@/interfaces/Policy";
|
||||
import { User } from "@/interfaces/User";
|
||||
import { PeerOperatingSystemIcon } from "@/modules/peers/PeerOperatingSystemIcon";
|
||||
import { HorizontalUsersStack } from "@/modules/users/HorizontalUsersStack";
|
||||
import { PeerOperatingSystemIcon } from "@/modules/peers/PeerOperatingSystemIcon";
|
||||
|
||||
const groupsSearchPredicate = (item: Group, query: string) => {
|
||||
const lowerCaseQuery = query.toLowerCase();
|
||||
@@ -526,7 +526,7 @@ export function PeerGroupSelector({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={"flex items-center gap-5"}>
|
||||
<div className={"flex items-center gap-4"}>
|
||||
{option?.id && showRoutes && (
|
||||
<AccessControlGroupCount group_id={option.id} />
|
||||
)}
|
||||
@@ -535,19 +535,12 @@ export function PeerGroupSelector({
|
||||
<ResourcesCounter group={option} />
|
||||
)}
|
||||
|
||||
<div className={"flex gap-3 items-center"}>
|
||||
<div className={"flex gap-4 items-center"}>
|
||||
{!users ? (
|
||||
<div
|
||||
className={
|
||||
"text-neutral-500 dark:text-nb-gray-300 font-medium flex items-center gap-2"
|
||||
}
|
||||
>
|
||||
<MonitorSmartphoneIcon
|
||||
size={14}
|
||||
className={"shrink-0"}
|
||||
/>
|
||||
{peerCount} Peer(s)
|
||||
</div>
|
||||
<PeerCounter
|
||||
group={option}
|
||||
showResourceCounter={showResourceCounter}
|
||||
/>
|
||||
) : (
|
||||
<UsersCounter
|
||||
group={option}
|
||||
@@ -555,7 +548,6 @@ export function PeerGroupSelector({
|
||||
selected={isSelected}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Checkbox checked={isSelected} />
|
||||
</div>
|
||||
</div>
|
||||
@@ -671,7 +663,14 @@ const UsersCounter = ({
|
||||
users?.filter((user) => user.auto_groups.includes(group.id as string)) ||
|
||||
[];
|
||||
|
||||
if (usersOfGroup.length === 0) return null;
|
||||
if (usersOfGroup.length === 0)
|
||||
return (
|
||||
<span
|
||||
className={"group-hover/user-stack:text-nb-gray-200 text-nb-gray-300"}
|
||||
>
|
||||
0 User(s)
|
||||
</span>
|
||||
);
|
||||
|
||||
return (
|
||||
<HorizontalUsersStack
|
||||
@@ -686,6 +685,31 @@ const UsersCounter = ({
|
||||
);
|
||||
};
|
||||
|
||||
const PeerCounter = ({
|
||||
group,
|
||||
showResourceCounter,
|
||||
}: {
|
||||
group: Group;
|
||||
showResourceCounter?: boolean;
|
||||
}) => {
|
||||
const peerCount = group.peers?.length ?? group?.peers_count ?? 0;
|
||||
const resourcesCount = group?.resources_count ?? 0;
|
||||
const hidePeerCounter =
|
||||
showResourceCounter && peerCount === 0 && resourcesCount > 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"text-neutral-500 dark:text-nb-gray-300 font-medium flex items-center gap-2",
|
||||
hidePeerCounter && "hidden",
|
||||
)}
|
||||
>
|
||||
<MonitorSmartphoneIcon size={14} className={"shrink-0"} />
|
||||
{peerCount} Peer(s)
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ResourcesCounter = ({ group }: { group: Group }) => {
|
||||
return group?.resources_count && group.resources_count > 0 ? (
|
||||
<div
|
||||
|
||||
@@ -139,7 +139,11 @@ export function PortSelector({
|
||||
<Badge
|
||||
key={x}
|
||||
variant={"gray"}
|
||||
onClick={() => toggle(x)}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
toggle(x);
|
||||
}}
|
||||
className={"uppercase tracking-wider font-medium py-1"}
|
||||
>
|
||||
{x}
|
||||
|
||||
@@ -39,38 +39,43 @@ const Tabs = React.forwardRef<
|
||||
Tabs.displayName = TabsPrimitive.Root.displayName;
|
||||
|
||||
type TabListProps = {
|
||||
hidden?: boolean;
|
||||
justify?: "start" | "end" | "center" | "between";
|
||||
};
|
||||
|
||||
const TabsList = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List> & TabListProps
|
||||
>(({ className, justify = "center", ...props }, ref) => (
|
||||
<TabsPrimitive.List
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex flex-nowrap text-neutral-500 dark:text-nb-gray-400 w-full relative",
|
||||
className,
|
||||
justify == "center" && "justify-center justify-items-end",
|
||||
justify == "start" && "justify-start",
|
||||
justify == "end" && "justify-end",
|
||||
justify == "between" && "justify-between",
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span
|
||||
className={
|
||||
"absolute left-0 dark:bg-nb-gray-900 bg-nb-gray-100 w-full h-[1px] bottom-0 z-0"
|
||||
}
|
||||
/>
|
||||
<ScrollArea>
|
||||
<div className={"relative z-[1] flex flex-nowrap w-full "}>
|
||||
{props.children}
|
||||
</div>
|
||||
<ScrollBar orientation="horizontal" />
|
||||
</ScrollArea>
|
||||
</TabsPrimitive.List>
|
||||
));
|
||||
>(({ className, justify = "center", hidden = false, ...props }, ref) => {
|
||||
return (
|
||||
!hidden && (
|
||||
<TabsPrimitive.List
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex flex-nowrap text-neutral-500 dark:text-nb-gray-400 w-full relative",
|
||||
className,
|
||||
justify == "center" && "justify-center justify-items-end",
|
||||
justify == "start" && "justify-start",
|
||||
justify == "end" && "justify-end",
|
||||
justify == "between" && "justify-between",
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span
|
||||
className={
|
||||
"absolute left-0 dark:bg-nb-gray-900 bg-nb-gray-100 w-full h-[1px] bottom-0 z-0"
|
||||
}
|
||||
/>
|
||||
<ScrollArea>
|
||||
<div className={"relative z-[1] flex flex-nowrap w-full "}>
|
||||
{props.children}
|
||||
</div>
|
||||
<ScrollBar orientation="horizontal" />
|
||||
</ScrollArea>
|
||||
</TabsPrimitive.List>
|
||||
)
|
||||
);
|
||||
});
|
||||
TabsList.displayName = TabsPrimitive.List.displayName;
|
||||
|
||||
const TabsTrigger = React.forwardRef<
|
||||
|
||||
@@ -22,14 +22,14 @@ export const tooltipVariants = cva(
|
||||
variants: {
|
||||
variant: {
|
||||
default: [
|
||||
"bg-white dark:bg-nb-gray-940",
|
||||
"text-neutral-950 dark:text-neutral-50",
|
||||
"border-neutral-200 dark:border-nb-gray-930",
|
||||
"bg-nb-gray-940",
|
||||
"text-neutral-50",
|
||||
"border-neutral-200 border-nb-gray-930",
|
||||
],
|
||||
lighter: [
|
||||
"bg-white dark:bg-nb-gray-920",
|
||||
"text-neutral-950 dark:text-neutral-50",
|
||||
"border-neutral-200 dark:border-nb-gray-900",
|
||||
"bg-nb-gray-920",
|
||||
"text-neutral-50",
|
||||
"border-neutral-200 border-nb-gray-900",
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -6,6 +6,7 @@ import { cn } from "@utils/helpers";
|
||||
import { XIcon } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { Group } from "@/interfaces/Group";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
type Props = {
|
||||
group: Group;
|
||||
@@ -17,6 +18,9 @@ type Props = {
|
||||
maxChars?: number;
|
||||
maxWidth?: string;
|
||||
hideTooltip?: boolean;
|
||||
textClassName?: string;
|
||||
redirectGroupTab?: string;
|
||||
redirectToGroupPage?: boolean;
|
||||
};
|
||||
|
||||
export default function GroupBadge({
|
||||
@@ -29,19 +33,33 @@ export default function GroupBadge({
|
||||
maxChars = 20,
|
||||
maxWidth,
|
||||
hideTooltip = false,
|
||||
textClassName,
|
||||
redirectGroupTab,
|
||||
redirectToGroupPage = false,
|
||||
}: Readonly<Props>) {
|
||||
const isNew = !group?.id;
|
||||
const router = useRouter();
|
||||
|
||||
const handleGroupPageRedirect = () => {
|
||||
if (!group?.id) return;
|
||||
let redirectUrl = `/group?id=${group.id}`;
|
||||
if (redirectGroupTab) {
|
||||
redirectUrl += `&tab=${encodeURIComponent(redirectGroupTab)}`;
|
||||
}
|
||||
router.push(redirectUrl);
|
||||
};
|
||||
|
||||
return (
|
||||
<Badge
|
||||
key={group.id ?? group.name}
|
||||
useHover={true}
|
||||
useHover={!!onClick || redirectToGroupPage}
|
||||
data-cy={"group-badge"}
|
||||
variant={"gray-ghost"}
|
||||
className={cn("transition-all group whitespace-nowrap", className)}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
onClick?.(e);
|
||||
if (redirectToGroupPage) handleGroupPageRedirect();
|
||||
}}
|
||||
>
|
||||
<GroupBadgeIcon id={group?.id} issued={group?.issued} />
|
||||
@@ -49,6 +67,7 @@ export default function GroupBadge({
|
||||
text={group?.name || ""}
|
||||
maxChars={maxChars}
|
||||
maxWidth={maxWidth}
|
||||
className={textClassName}
|
||||
hideTooltip={hideTooltip}
|
||||
/>
|
||||
{children}
|
||||
|
||||
@@ -2,7 +2,9 @@ import { FolderGit2 } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import EntraIcon from "@/assets/icons/EntraIcon";
|
||||
import GoogleIcon from "@/assets/icons/GoogleIcon";
|
||||
import JumpcloudIcon from "@/assets/icons/JumpcloudIcon";
|
||||
import JWTIcon from "@/assets/icons/JWTIcon";
|
||||
import OIDCIcon from "@/assets/icons/OIDCIcon";
|
||||
import OktaIcon from "@/assets/icons/OktaIcon";
|
||||
import { useGroups } from "@/contexts/GroupsProvider";
|
||||
import { GroupIssued } from "@/interfaces/Group";
|
||||
@@ -20,8 +22,14 @@ export const GroupBadgeIcon = ({
|
||||
const { groups } = useGroups();
|
||||
const group = groups?.find((g) => g.id === id);
|
||||
|
||||
const { isAzureGroup, isGoogleGroup, isOktaGroup, isJWTGroup } =
|
||||
useGroupIdentification({ id, issued: issued ?? group?.issued });
|
||||
const {
|
||||
isAzureGroup,
|
||||
isGoogleGroup,
|
||||
isOktaGroup,
|
||||
isJWTGroup,
|
||||
isJumpcloudGroup,
|
||||
isOIDCGroup,
|
||||
} = useGroupIdentification({ id, issued: issued ?? group?.issued });
|
||||
|
||||
if (isGoogleGroup)
|
||||
return <GoogleIcon size={size - 1} className={"shrink-0 mr-0.5"} />;
|
||||
@@ -29,6 +37,10 @@ export const GroupBadgeIcon = ({
|
||||
return <EntraIcon size={size + 1} className={"shrink-0 mr-0.5"} />;
|
||||
if (isOktaGroup)
|
||||
return <OktaIcon size={size - 1} className={"shrink-0 mr-0.5"} />;
|
||||
if (isJumpcloudGroup)
|
||||
return <JumpcloudIcon size={size + 2} className={"shrink-0 mr-0.5"} />;
|
||||
if (isOIDCGroup)
|
||||
return <OIDCIcon size={size} className={"shrink-0 mr-0.5"} />;
|
||||
if (isJWTGroup) return <JWTIcon size={size} className={"shrink-0"} />;
|
||||
|
||||
return <FolderGit2 size={size} className={"shrink-0"} />;
|
||||
|
||||
@@ -1,19 +1,21 @@
|
||||
import Badge from "@components/Badge";
|
||||
import { ScrollArea } from "@components/ScrollArea";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@components/Tooltip";
|
||||
HoverCard,
|
||||
HoverCardContent,
|
||||
HoverCardTrigger,
|
||||
} from "@components/HoverCard";
|
||||
import { ScrollArea } from "@components/ScrollArea";
|
||||
import GroupBadge from "@components/ui/GroupBadge";
|
||||
import PeerBadge from "@components/ui/PeerBadge";
|
||||
import PeerCountBadge from "@components/ui/PeerCountBadge";
|
||||
import ResourceCountBadge from "@components/ui/ResourceCountBadge";
|
||||
import { cn } from "@utils/helpers";
|
||||
import { ArrowRightIcon, PencilLineIcon } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import { useUsers } from "@/contexts/UsersProvider";
|
||||
import { Group } from "@/interfaces/Group";
|
||||
import EmptyRow from "@/modules/common-table-rows/EmptyRow";
|
||||
import { HorizontalUsersStack } from "@/modules/users/HorizontalUsersStack";
|
||||
|
||||
type Props = {
|
||||
groups: Group[];
|
||||
@@ -21,6 +23,9 @@ type Props = {
|
||||
description?: string;
|
||||
onClick?: () => void;
|
||||
className?: string;
|
||||
showResources?: boolean;
|
||||
redirectGroupTab?: string;
|
||||
showUsers?: boolean;
|
||||
};
|
||||
|
||||
export default function MultipleGroups({
|
||||
@@ -29,6 +34,9 @@ export default function MultipleGroups({
|
||||
description = "Use groups to control what this peer can access",
|
||||
onClick,
|
||||
className,
|
||||
showResources = false,
|
||||
showUsers = false,
|
||||
redirectGroupTab,
|
||||
}: Readonly<Props>) {
|
||||
const { permission } = usePermissions();
|
||||
|
||||
@@ -45,13 +53,9 @@ export default function MultipleGroups({
|
||||
const otherGroups = orderedGroups.length > 0 ? orderedGroups.slice(1) : [];
|
||||
|
||||
return (
|
||||
<TooltipProvider
|
||||
disableHoverableContent={false}
|
||||
delayDuration={200}
|
||||
skipDelayDuration={200}
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild={true}>
|
||||
<div className={"flex"}>
|
||||
<HoverCard openDelay={200} closeDelay={100}>
|
||||
<HoverCardTrigger>
|
||||
<div
|
||||
className={cn("inline-flex items-center gap-2 z-0", className)}
|
||||
data-cy={"multiple-groups"}
|
||||
@@ -78,9 +82,9 @@ export default function MultipleGroups({
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
</HoverCardTrigger>
|
||||
{orderedGroups && orderedGroups.length > 0 && (
|
||||
<TooltipContent
|
||||
<HoverCardContent
|
||||
className={"p-0"}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
@@ -102,19 +106,31 @@ export default function MultipleGroups({
|
||||
"flex gap-2 items-center justify-between w-full"
|
||||
}
|
||||
>
|
||||
<GroupBadge group={group}></GroupBadge>
|
||||
<GroupBadge
|
||||
group={group}
|
||||
className={"py-0"}
|
||||
textClassName={"py-1.5"}
|
||||
redirectToGroupPage={true}
|
||||
redirectGroupTab={redirectGroupTab}
|
||||
></GroupBadge>
|
||||
<ArrowRightIcon size={14} />
|
||||
<PeerBadge> {group.peers_count} Peer(s)</PeerBadge>
|
||||
{showResources ? (
|
||||
<ResourceCountBadge group={group} />
|
||||
) : showUsers ? (
|
||||
<UserCountStack group={group} />
|
||||
) : (
|
||||
<PeerCountBadge group={group} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</TooltipContent>
|
||||
</HoverCardContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</HoverCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -129,3 +145,17 @@ export const TransparentEditIconButton = () => {
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const UserCountStack = ({ group }: { group: Group }) => {
|
||||
const { users } = useUsers();
|
||||
const usersOfGroup =
|
||||
users?.filter((user) => user.auto_groups.includes(group.id as string)) ||
|
||||
[];
|
||||
return (
|
||||
<HorizontalUsersStack
|
||||
users={usersOfGroup}
|
||||
side={"right"}
|
||||
isAllGroup={group?.name === "All"}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
64
src/components/ui/PeerCountBadge.tsx
Normal file
64
src/components/ui/PeerCountBadge.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import Badge, { BadgeVariants } from "@components/Badge";
|
||||
import { cn, singularize } from "@utils/helpers";
|
||||
import { MonitorSmartphoneIcon } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import * as React from "react";
|
||||
import { useMemo } from "react";
|
||||
import { useGroups } from "@/contexts/GroupsProvider";
|
||||
import { Group } from "@/interfaces/Group";
|
||||
import ResourceCountBadge from "@components/ui/ResourceCountBadge";
|
||||
|
||||
type Props = {
|
||||
group?: Group;
|
||||
} & React.HTMLAttributes<HTMLDivElement> &
|
||||
BadgeVariants;
|
||||
|
||||
export default function PeerCountBadge({
|
||||
group,
|
||||
variant = "gray",
|
||||
className,
|
||||
}: Props) {
|
||||
const router = useRouter();
|
||||
const { dropdownOptions } = useGroups();
|
||||
|
||||
const currentGroup = useMemo(() => {
|
||||
return dropdownOptions?.find((g) => g.name === group?.name);
|
||||
}, [group, dropdownOptions]);
|
||||
|
||||
const peerCount = useMemo(() => {
|
||||
let peerCount = currentGroup?.peers_count ?? 0;
|
||||
let countedPeers = currentGroup?.peers?.length ?? 0;
|
||||
if (peerCount !== countedPeers) {
|
||||
peerCount = countedPeers;
|
||||
}
|
||||
return peerCount;
|
||||
}, [currentGroup]);
|
||||
|
||||
const canRedirect = !!group?.id && group?.name !== "All";
|
||||
|
||||
const onClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
e.stopPropagation();
|
||||
if (canRedirect) router.push(`/group?id=${group?.id}&tab=peers`);
|
||||
};
|
||||
|
||||
const resourcesCount = group?.resources_count ?? 0;
|
||||
const showResources = resourcesCount > 0 && peerCount === 0;
|
||||
|
||||
return showResources ? (
|
||||
<ResourceCountBadge group={group} />
|
||||
) : (
|
||||
<Badge
|
||||
variant={variant}
|
||||
className={cn(
|
||||
className,
|
||||
"px-3 gap-2 whitespace-nowrap",
|
||||
canRedirect && "cursor-pointer",
|
||||
)}
|
||||
onClick={onClick}
|
||||
useHover={canRedirect}
|
||||
>
|
||||
<MonitorSmartphoneIcon size={12} />
|
||||
{singularize("Peers", peerCount, true)}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import Badge from "@components/Badge";
|
||||
import { cn } from "@utils/helpers";
|
||||
import React, { useEffect, useMemo } from "react";
|
||||
import LongArrowLeftIcon from "@/assets/icons/LongArrowLeftIcon";
|
||||
import { PolicyRuleResource } from "@/interfaces/Policy";
|
||||
import { PolicyRuleResource, Protocol } from "@/interfaces/Policy";
|
||||
|
||||
type Props = {
|
||||
disabled?: boolean;
|
||||
@@ -10,6 +10,7 @@ type Props = {
|
||||
onChange: (value: Direction) => void;
|
||||
className?: string;
|
||||
destinationResource?: PolicyRuleResource;
|
||||
protocol?: Protocol;
|
||||
};
|
||||
|
||||
export type Direction = "bi" | "in" | "out";
|
||||
@@ -20,8 +21,10 @@ export default function PolicyDirection({
|
||||
onChange,
|
||||
className,
|
||||
destinationResource,
|
||||
protocol,
|
||||
}: Readonly<Props>) {
|
||||
const toggleDirection = () => {
|
||||
if (protocol === "netbird-ssh") return;
|
||||
if (value == "bi") {
|
||||
onChange("in");
|
||||
} else {
|
||||
@@ -30,9 +33,13 @@ export default function PolicyDirection({
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (protocol === "netbird-ssh") {
|
||||
onChange("in");
|
||||
return;
|
||||
}
|
||||
if (disabled) onChange("bi");
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [disabled]);
|
||||
}, [disabled, protocol]);
|
||||
|
||||
const isNetworkResource =
|
||||
!!destinationResource && destinationResource?.type !== "peer";
|
||||
@@ -67,7 +74,8 @@ export default function PolicyDirection({
|
||||
<button
|
||||
className={cn(
|
||||
"flex flex-col gap-2 mt-[23px] cursor-pointer select-none",
|
||||
disabled && "opacity-50 pointer-events-none",
|
||||
(disabled || protocol === "netbird-ssh") &&
|
||||
"opacity-50 pointer-events-none",
|
||||
"hover:opacity-80 transition-all",
|
||||
className,
|
||||
)}
|
||||
|
||||
33
src/components/ui/ResourceCountBadge.tsx
Normal file
33
src/components/ui/ResourceCountBadge.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import Badge, { BadgeVariants } from "@components/Badge";
|
||||
import { cn, singularize } from "@utils/helpers";
|
||||
import { LayersIcon } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import * as React from "react";
|
||||
import { Group } from "@/interfaces/Group";
|
||||
|
||||
type Props = {
|
||||
group?: Group;
|
||||
} & React.HTMLAttributes<HTMLDivElement> &
|
||||
BadgeVariants;
|
||||
|
||||
export default function ResourceCountBadge({ group }: Props) {
|
||||
const router = useRouter();
|
||||
const hasId = !!group?.id;
|
||||
|
||||
const onClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
e.stopPropagation();
|
||||
if (hasId) router.push(`/group?id=${group?.id}&tab=resources`);
|
||||
};
|
||||
|
||||
return (
|
||||
<Badge
|
||||
className={cn("px-3 gap-2 whitespace-nowrap", hasId && "cursor-pointer")}
|
||||
variant={"gray"}
|
||||
onClick={onClick}
|
||||
useHover={hasId}
|
||||
>
|
||||
<LayersIcon size={12} />
|
||||
{singularize("Resources", group?.resources_count, true)}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import * as HoverCard from "@radix-ui/react-hover-card";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@components/Tooltip";
|
||||
import { cn } from "@utils/helpers";
|
||||
import React, { useMemo, useState } from "react";
|
||||
|
||||
@@ -55,38 +55,28 @@ export default function TruncatedText({
|
||||
}
|
||||
|
||||
return (
|
||||
<HoverCard.Root
|
||||
openDelay={650}
|
||||
closeDelay={100}
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
>
|
||||
<HoverCard.Trigger asChild={true}>
|
||||
<Tooltip delayDuration={650} open={open} onOpenChange={setOpen}>
|
||||
<TooltipTrigger asChild={true}>
|
||||
<div className="w-full min-w-0 inline-block" style={containerStyle}>
|
||||
<div ref={contentRef} className={cn(className, "truncate")}>
|
||||
{text}
|
||||
</div>
|
||||
</div>
|
||||
</HoverCard.Trigger>
|
||||
<HoverCard.Portal>
|
||||
<HoverCard.Content
|
||||
onMouseLeave={() => setOpen(false)}
|
||||
onMouseEnter={() => setOpen(false)}
|
||||
alignOffset={20}
|
||||
sideOffset={4}
|
||||
className={cn(
|
||||
"z-[9999] overflow-hidden rounded-md border border-neutral-200 bg-white text-sm text-neutral-950 shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 dark:border-nb-gray-930 dark:bg-nb-gray-940 dark:text-neutral-50",
|
||||
className,
|
||||
"px-3 py-1.5",
|
||||
)}
|
||||
>
|
||||
<div className="text-neutral-300 flex flex-col gap-1">
|
||||
<div className="max-w-xs break-all whitespace-normal text-xs">
|
||||
{text}
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
alignOffset={20}
|
||||
sideOffset={4}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
className={cn(className, "px-3 py-1.5")}
|
||||
>
|
||||
<div className="text-neutral-300 flex flex-col gap-1">
|
||||
<div className="max-w-xs break-all whitespace-normal text-xs">
|
||||
{text}
|
||||
</div>
|
||||
</HoverCard.Content>
|
||||
</HoverCard.Portal>
|
||||
</HoverCard.Root>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -17,6 +17,12 @@ declare global {
|
||||
}
|
||||
}
|
||||
|
||||
export type HubspotFormField = {
|
||||
objectTypeId?: string;
|
||||
name: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
const AnalyticsContext = React.createContext(
|
||||
{} as {
|
||||
initialized: boolean;
|
||||
|
||||
@@ -4,7 +4,18 @@ import md5 from "crypto-js/md5";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
|
||||
const initialAnnouncements: Announcement[] = [];
|
||||
const initialAnnouncements: Announcement[] = [
|
||||
{
|
||||
tag: "New",
|
||||
text: "NetBird v0.60.0 - Identity-aware, private SSH over your NetBird network.",
|
||||
link: "https://docs.netbird.io/how-to/ssh",
|
||||
linkText: "Documentation",
|
||||
variant: "default", // "default" or "important"
|
||||
isExternal: true,
|
||||
closeable: true,
|
||||
isCloudOnly: false,
|
||||
},
|
||||
];
|
||||
|
||||
export interface Announcement extends AnnouncementVariant {
|
||||
tag: string;
|
||||
|
||||
@@ -138,7 +138,11 @@ export default function PeerProvider({
|
||||
<PeerSSHInstructions
|
||||
open={sshInstructionsModal}
|
||||
onOpenChange={setSSHInstructionsModal}
|
||||
onSuccess={() => toggleSSH(true)}
|
||||
peer={peer}
|
||||
onSuccess={() => {
|
||||
mutate(`/peers/${peer.id}`);
|
||||
setSSHInstructionsModal(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { Modal } from "@components/modal/Modal";
|
||||
import { notify } from "@components/Notification";
|
||||
import { useApiCall } from "@utils/api";
|
||||
import React from "react";
|
||||
import React, { useState } from "react";
|
||||
import { Policy } from "@/interfaces/Policy";
|
||||
import { AccessControlModalContent } from "@/modules/access-control/AccessControlModal";
|
||||
|
||||
type Props = {
|
||||
children: React.ReactNode;
|
||||
@@ -16,11 +18,15 @@ const PoliciesContext = React.createContext(
|
||||
message?: string,
|
||||
) => void;
|
||||
createPolicy: (policy: Policy) => Promise<Policy>;
|
||||
openEditPolicyModal: (policy: Policy, tab?: string) => void;
|
||||
},
|
||||
);
|
||||
|
||||
export default function PoliciesProvider({ children }: Props) {
|
||||
const request = useApiCall<Policy>("/policies");
|
||||
const [policyModal, setPolicyModal] = useState(false);
|
||||
const [currentPolicy, setCurrentPolicy] = useState<Policy>();
|
||||
const [initialPolicyTab, setInitialPolicyTab] = useState("");
|
||||
|
||||
const createPolicy = async (policy: Policy) => request.post(policy);
|
||||
|
||||
@@ -56,9 +62,34 @@ export default function PoliciesProvider({ children }: Props) {
|
||||
});
|
||||
};
|
||||
|
||||
const openEditPolicyModal = (policy: Policy, tab?: string) => {
|
||||
setCurrentPolicy(policy);
|
||||
tab && setInitialPolicyTab(tab);
|
||||
setPolicyModal(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<PoliciesContext.Provider value={{ updatePolicy, createPolicy }}>
|
||||
<PoliciesContext.Provider
|
||||
value={{ updatePolicy, createPolicy, openEditPolicyModal }}
|
||||
>
|
||||
{children}
|
||||
<Modal
|
||||
open={policyModal}
|
||||
onOpenChange={(state) => {
|
||||
setPolicyModal(state);
|
||||
setCurrentPolicy(undefined);
|
||||
}}
|
||||
>
|
||||
<AccessControlModalContent
|
||||
key={policyModal ? "1" : "0"}
|
||||
policy={currentPolicy}
|
||||
initialTab={initialPolicyTab}
|
||||
onSuccess={async (p) => {
|
||||
setPolicyModal(false);
|
||||
setCurrentPolicy(undefined);
|
||||
}}
|
||||
/>
|
||||
</Modal>
|
||||
</PoliciesContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -22,5 +22,12 @@ export interface Account {
|
||||
dns_domain: string;
|
||||
network_range?: string;
|
||||
lazy_connection_enabled: boolean;
|
||||
auto_update_version: string;
|
||||
};
|
||||
onboarding?: AccountOnboarding;
|
||||
}
|
||||
|
||||
export interface AccountOnboarding {
|
||||
onboarding_flow_pending: boolean;
|
||||
signup_form_pending: boolean;
|
||||
}
|
||||
|
||||
@@ -24,9 +24,24 @@ export interface Peer {
|
||||
login_expiration_enabled: boolean;
|
||||
inactivity_expiration_enabled: boolean;
|
||||
approval_required: boolean;
|
||||
disapproval_reason?: string;
|
||||
city_name: string;
|
||||
country_code: string;
|
||||
connection_ip: string;
|
||||
serial_number: string;
|
||||
ephemeral: boolean;
|
||||
local_flags?: PeerLocalFlags;
|
||||
}
|
||||
|
||||
export interface PeerLocalFlags {
|
||||
block_inbound: boolean;
|
||||
block_lan_access: boolean;
|
||||
disable_client_routes: boolean;
|
||||
disable_dns: boolean;
|
||||
disable_firewall: boolean;
|
||||
disable_server_routes: boolean;
|
||||
lazy_connection_enabled: boolean;
|
||||
rosenpass_enabled: boolean;
|
||||
rosenpass_permissive: boolean;
|
||||
server_ssh_allowed: boolean;
|
||||
}
|
||||
|
||||
@@ -25,8 +25,11 @@ export interface PolicyRule {
|
||||
port_ranges?: PortRange[];
|
||||
sourceResource?: PolicyRuleResource;
|
||||
destinationResource?: PolicyRuleResource;
|
||||
authorized_groups?: AuthorizedGroups;
|
||||
}
|
||||
|
||||
export type AuthorizedGroups = Record<string, string[]>; // group_id, local machine usernames
|
||||
|
||||
export interface PortRange {
|
||||
start: number;
|
||||
end: number;
|
||||
@@ -37,4 +40,4 @@ export interface PolicyRuleResource {
|
||||
type?: "domain" | "host" | "subnet" | "peer";
|
||||
}
|
||||
|
||||
export type Protocol = "all" | "tcp" | "udp" | "icmp";
|
||||
export type Protocol = "all" | "tcp" | "udp" | "icmp" | "netbird-ssh";
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useOidcUser } from "@axa-fr/react-oidc";
|
||||
import Button from "@components/Button";
|
||||
import { UserAvatar } from "@components/ui/UserAvatar";
|
||||
import { cn } from "@utils/helpers";
|
||||
import { isNetBirdHosted } from "@utils/netbird";
|
||||
import { useIsSm, useIsXs } from "@utils/responsive";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { XIcon } from "lucide-react";
|
||||
@@ -20,6 +21,7 @@ import GroupsProvider from "@/contexts/GroupsProvider";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import UsersProvider from "@/contexts/UsersProvider";
|
||||
import Navigation from "@/layouts/Navigation";
|
||||
import { OnboardingProvider } from "@/modules/onboarding/OnboardingProvider";
|
||||
import Header, { headerHeight } from "./Header";
|
||||
|
||||
export default function DashboardLayout({
|
||||
@@ -33,6 +35,7 @@ export default function DashboardLayout({
|
||||
<AnnouncementProvider>
|
||||
<GroupsProvider>
|
||||
<CountryProvider>
|
||||
{!isNetBirdHosted() && <OnboardingProvider />}
|
||||
<DashboardPageContent>{children}</DashboardPageContent>
|
||||
</CountryProvider>
|
||||
</GroupsProvider>
|
||||
|
||||
@@ -39,17 +39,21 @@ import {
|
||||
Power,
|
||||
Share2,
|
||||
Shield,
|
||||
SquareTerminalIcon,
|
||||
Text,
|
||||
} from "lucide-react";
|
||||
import React, { useMemo, useState } from "react";
|
||||
import AccessControlIcon from "@/assets/icons/AccessControlIcon";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import { Group } from "@/interfaces/Group";
|
||||
import { Policy, Protocol } from "@/interfaces/Policy";
|
||||
import { Policy, PolicyRuleResource, Protocol } from "@/interfaces/Policy";
|
||||
import { PostureCheck } from "@/interfaces/PostureCheck";
|
||||
import { useAccessControl } from "@/modules/access-control/useAccessControl";
|
||||
import { PostureCheckTab } from "@/modules/posture-checks/ui/PostureCheckTab";
|
||||
import { PostureCheckTabTrigger } from "@/modules/posture-checks/ui/PostureCheckTabTrigger";
|
||||
import { SSHAccessType } from "@/modules/access-control/ssh/SSHAccessType";
|
||||
import { SSHAuthorizedGroups } from "@/modules/access-control/ssh/SSHAuthorizedGroups";
|
||||
import { useUsers } from "@/contexts/UsersProvider";
|
||||
|
||||
type Props = {
|
||||
children?: React.ReactNode;
|
||||
@@ -116,6 +120,10 @@ type ModalProps = {
|
||||
postureCheckTemplates?: PostureCheck[];
|
||||
useSave?: boolean;
|
||||
allowEditPeers?: boolean;
|
||||
initialProtocol?: Protocol;
|
||||
initialPorts?: number[];
|
||||
initialDestinationResource?: PolicyRuleResource;
|
||||
initialTab?: string;
|
||||
};
|
||||
|
||||
export function AccessControlModalContent({
|
||||
@@ -128,8 +136,13 @@ export function AccessControlModalContent({
|
||||
initialDestinationGroups,
|
||||
initialName,
|
||||
initialDescription,
|
||||
initialProtocol,
|
||||
initialPorts,
|
||||
initialDestinationResource,
|
||||
initialTab,
|
||||
}: Readonly<ModalProps>) {
|
||||
const { permission } = usePermissions();
|
||||
const { users } = useUsers();
|
||||
|
||||
const {
|
||||
portDisabled,
|
||||
@@ -163,6 +176,10 @@ export function AccessControlModalContent({
|
||||
portRanges,
|
||||
setPortRanges,
|
||||
hasPortSupport,
|
||||
sshAccessType,
|
||||
setSshAccessType,
|
||||
sshAuthorizedGroups,
|
||||
setSshAuthorizedGroups,
|
||||
} = useAccessControl({
|
||||
policy,
|
||||
postureCheckTemplates,
|
||||
@@ -170,9 +187,13 @@ export function AccessControlModalContent({
|
||||
initialDestinationGroups,
|
||||
initialName,
|
||||
initialDescription,
|
||||
initialPorts,
|
||||
initialProtocol,
|
||||
initialDestinationResource,
|
||||
});
|
||||
|
||||
const [tab, setTab] = useState(() => {
|
||||
if (initialTab && initialTab !== "") return initialTab;
|
||||
if (!cell) return "policy";
|
||||
if (cell == "posture_checks") return "posture_checks";
|
||||
return "policy";
|
||||
@@ -239,10 +260,10 @@ export function AccessControlModalContent({
|
||||
<TabsContent value={"policy"} className={"pb-8"}>
|
||||
<div className={"px-8 flex-col flex gap-6"}>
|
||||
<div
|
||||
className={"flex justify-between items-center"}
|
||||
className={"flex justify-between items-center gap-10"}
|
||||
data-cy={"protocol-wrapper"}
|
||||
>
|
||||
<div>
|
||||
<div className={"w-full"}>
|
||||
<Label>Protocol</Label>
|
||||
<HelpText className={"max-w-sm"}>
|
||||
Allow only specified network protocols. To change traffic
|
||||
@@ -258,7 +279,7 @@ export function AccessControlModalContent({
|
||||
!permission.policies.update || !permission.policies.create
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-[140px]">
|
||||
<SelectTrigger className="w-[280px]">
|
||||
<div
|
||||
className={"flex items-center gap-3"}
|
||||
data-cy={"protocol-select-button"}
|
||||
@@ -272,6 +293,7 @@ export function AccessControlModalContent({
|
||||
<SelectItem value="tcp">TCP</SelectItem>
|
||||
<SelectItem value="udp">UDP</SelectItem>
|
||||
<SelectItem value="icmp">ICMP</SelectItem>
|
||||
<SelectItem value="netbird-ssh">NetBird SSH</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
@@ -286,14 +308,15 @@ export function AccessControlModalContent({
|
||||
dataCy={"source-group-selector"}
|
||||
popoverWidth={500}
|
||||
placeholder={"Select source(s)..."}
|
||||
showRoutes={true}
|
||||
showRoutes={protocol !== "netbird-ssh"}
|
||||
showResources={false}
|
||||
showPeers={true}
|
||||
showPeers={protocol !== "netbird-ssh"}
|
||||
showResourceCounter={false}
|
||||
showPeerCount={allowEditPeers}
|
||||
disableInlineRemoveGroup={false}
|
||||
values={sourceGroups}
|
||||
onChange={setSourceGroups}
|
||||
users={protocol === "netbird-ssh" ? users : undefined}
|
||||
resource={sourceResource}
|
||||
onResourceChange={setSourceResource}
|
||||
saveGroupAssignments={useSave}
|
||||
@@ -306,6 +329,7 @@ export function AccessControlModalContent({
|
||||
value={direction}
|
||||
onChange={setDirection}
|
||||
disabled={destinationOnlyResources}
|
||||
protocol={protocol}
|
||||
destinationResource={destinationResource}
|
||||
/>
|
||||
|
||||
@@ -319,7 +343,7 @@ export function AccessControlModalContent({
|
||||
popoverWidth={500}
|
||||
placeholder={"Select destination(s)..."}
|
||||
showRoutes={true}
|
||||
showResources={true}
|
||||
showResources={protocol !== "netbird-ssh"}
|
||||
showPeers={true}
|
||||
showResourceCounter={true}
|
||||
showPeerCount={allowEditPeers}
|
||||
@@ -354,33 +378,79 @@ export function AccessControlModalContent({
|
||||
</Callout>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"mb-2",
|
||||
portDisabled && "opacity-30 pointer-events-none",
|
||||
)}
|
||||
>
|
||||
{protocol === "netbird-ssh" ? (
|
||||
<div>
|
||||
<Label className={"flex items-center gap-2"}>
|
||||
<Shield size={14} />
|
||||
Ports
|
||||
</Label>
|
||||
<HelpText>
|
||||
Allow network traffic and access only to specified ports.
|
||||
Select ports or port ranges between 1 and 65535.
|
||||
</HelpText>
|
||||
</div>
|
||||
<div className={""}>
|
||||
<PortSelector
|
||||
showAll={true}
|
||||
ports={ports}
|
||||
onPortsChange={setPorts}
|
||||
portRanges={portRanges}
|
||||
onPortRangesChange={setPortRanges}
|
||||
disabled={portDisabled}
|
||||
{destinationHasResources && (
|
||||
<Callout
|
||||
variant={"warning"}
|
||||
icon={
|
||||
<AlertCircleIcon
|
||||
size={14}
|
||||
className={"shrink-0 relative top-[3px] text-netbird"}
|
||||
/>
|
||||
}
|
||||
className="mb-6"
|
||||
>
|
||||
SSH access only works on peers, not on routed resources.
|
||||
Please ensure your destination groups contain peers for SSH
|
||||
connectivity.
|
||||
</Callout>
|
||||
)}
|
||||
<div
|
||||
className={"flex justify-between items-center gap-10 mt-2"}
|
||||
>
|
||||
<div className={"w-full"}>
|
||||
<Label className={"flex items-center gap-2"}>
|
||||
<SquareTerminalIcon size={15} />
|
||||
SSH Access
|
||||
</Label>
|
||||
<HelpText>
|
||||
Select {`'Full Access'`} to allow SSH as any local user,
|
||||
or {`'Limited Access'`} to specify which local users each
|
||||
group is allowed to use.
|
||||
</HelpText>
|
||||
</div>
|
||||
<SSHAccessType
|
||||
value={sshAccessType}
|
||||
onChange={setSshAccessType}
|
||||
/>
|
||||
</div>
|
||||
<SSHAuthorizedGroups
|
||||
sourceGroups={sourceGroups}
|
||||
authorizedGroups={sshAuthorizedGroups}
|
||||
setAuthorizedGroups={setSshAuthorizedGroups}
|
||||
accessType={sshAccessType}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={cn(
|
||||
"mb-2 mt-2",
|
||||
portDisabled && "opacity-30 pointer-events-none",
|
||||
)}
|
||||
>
|
||||
<div>
|
||||
<Label className={"flex items-center gap-2"}>
|
||||
<Shield size={14} />
|
||||
Ports
|
||||
</Label>
|
||||
<HelpText>
|
||||
Allow network traffic and access only to specified ports.
|
||||
Select ports or port ranges between 1 and 65535.
|
||||
</HelpText>
|
||||
</div>
|
||||
<div className={""}>
|
||||
<PortSelector
|
||||
showAll={true}
|
||||
ports={ports}
|
||||
onPortsChange={setPorts}
|
||||
portRanges={portRanges}
|
||||
onPortRangesChange={setPortRanges}
|
||||
disabled={portDisabled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<FancyToggleSwitch
|
||||
value={enabled}
|
||||
|
||||
50
src/modules/access-control/ssh/SSHAccessType.tsx
Normal file
50
src/modules/access-control/ssh/SSHAccessType.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import * as React from "react";
|
||||
import { Dispatch, SetStateAction } from "react";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@components/Select";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import { ShieldHalfIcon, ShieldUserIcon } from "lucide-react";
|
||||
|
||||
type Props = {
|
||||
value: "full" | "limited";
|
||||
onChange: Dispatch<SetStateAction<"full" | "limited">>;
|
||||
};
|
||||
|
||||
export const SSHAccessType = ({ value, onChange }: Props) => {
|
||||
const { permission } = usePermissions();
|
||||
|
||||
return (
|
||||
<Select
|
||||
value={value}
|
||||
onValueChange={(v) => onChange(v as "full" | "limited")}
|
||||
disabled={!permission?.policies?.update || !permission?.policies?.create}
|
||||
>
|
||||
<SelectTrigger className="w-[280px]">
|
||||
<div
|
||||
className={"flex items-center gap-3"}
|
||||
data-cy={"protocol-select-button"}
|
||||
>
|
||||
{value === "full" ? (
|
||||
<ShieldUserIcon size={15} className={"text-nb-gray-300 shrink-0"} />
|
||||
) : (
|
||||
<ShieldHalfIcon size={15} className={"text-nb-gray-300 shrink-0"} />
|
||||
)}
|
||||
<SelectValue placeholder="Select ssh access type..." />
|
||||
</div>
|
||||
</SelectTrigger>
|
||||
<SelectContent data-cy={"ssh-access-selection"}>
|
||||
<SelectItem value="full" className={"whitespace-nowrap"}>
|
||||
Full Access
|
||||
</SelectItem>
|
||||
<SelectItem value="limited" className={"whitespace-nowrap"}>
|
||||
Limited Access
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
};
|
||||
139
src/modules/access-control/ssh/SSHAuthorizedGroups.tsx
Normal file
139
src/modules/access-control/ssh/SSHAuthorizedGroups.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
import { InfoIcon } from "lucide-react";
|
||||
import React, { useCallback, useEffect, useMemo } from "react";
|
||||
import { Group } from "@/interfaces/Group";
|
||||
import { AuthorizedGroups } from "@/interfaces/Policy";
|
||||
import GroupBadge from "@components/ui/GroupBadge";
|
||||
import { HorizontalUsersStack } from "@/modules/users/HorizontalUsersStack";
|
||||
import { useUsers } from "@/contexts/UsersProvider";
|
||||
import { cn } from "@utils/helpers";
|
||||
import { Callout } from "@components/Callout";
|
||||
import { SSHUsernameSelector } from "@/modules/access-control/ssh/SSHUsernameSelector";
|
||||
|
||||
type Props = {
|
||||
sourceGroups?: Group[];
|
||||
accessType?: "full" | "limited";
|
||||
authorizedGroups?: AuthorizedGroups;
|
||||
setAuthorizedGroups?: (authorizedGroups: AuthorizedGroups) => void;
|
||||
};
|
||||
|
||||
export function SSHAuthorizedGroups({
|
||||
sourceGroups,
|
||||
authorizedGroups,
|
||||
setAuthorizedGroups,
|
||||
accessType,
|
||||
}: Props) {
|
||||
const isEmpty =
|
||||
!authorizedGroups || Object.keys(authorizedGroups).length === 0;
|
||||
|
||||
useEffect(() => {
|
||||
if (sourceGroups) {
|
||||
let groupsMap: AuthorizedGroups = {};
|
||||
sourceGroups.forEach((sourceGroup) => {
|
||||
if (!sourceGroup?.name) return;
|
||||
|
||||
const groupId = sourceGroup?.id;
|
||||
if (groupId) {
|
||||
groupsMap[sourceGroup.name] = authorizedGroups?.[groupId] || [];
|
||||
} else {
|
||||
groupsMap[sourceGroup.name] = [];
|
||||
}
|
||||
});
|
||||
setAuthorizedGroups?.(groupsMap);
|
||||
}
|
||||
}, [sourceGroups]);
|
||||
|
||||
const handleUserNamesChange = useCallback(
|
||||
(groupName: string, values: string[]) => {
|
||||
setAuthorizedGroups?.({
|
||||
...authorizedGroups,
|
||||
[groupName]: values || [],
|
||||
});
|
||||
},
|
||||
[authorizedGroups, setAuthorizedGroups],
|
||||
);
|
||||
|
||||
if (accessType === "full") return;
|
||||
|
||||
if ((accessType === "limited" && isEmpty) || !authorizedGroups) {
|
||||
return (
|
||||
<Callout
|
||||
variant={"info"}
|
||||
icon={<InfoIcon size={14} className={"shrink-0 relative top-[3px]"} />}
|
||||
className="mt-3 py-[.75rem]"
|
||||
>
|
||||
You have not added any source groups yet, please add source groups in
|
||||
order to specify which user group has access to which system users on
|
||||
the destination machines.
|
||||
</Callout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-md overflow-hidden mt-3 py-2",
|
||||
"border border-nb-gray-900 bg-nb-gray-920/30",
|
||||
)}
|
||||
>
|
||||
{Object.entries(authorizedGroups).map(([groupName, usernames]) => (
|
||||
<AuthorizedUserRow
|
||||
key={groupName}
|
||||
groupName={groupName}
|
||||
usernames={usernames}
|
||||
sourceGroups={sourceGroups}
|
||||
handleUserNamesChange={(values) =>
|
||||
handleUserNamesChange(groupName, values)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type RowProps = {
|
||||
sourceGroups?: Group[];
|
||||
groupName: string;
|
||||
usernames: string[];
|
||||
handleUserNamesChange: (usernames: string[]) => void;
|
||||
};
|
||||
|
||||
function AuthorizedUserRow({
|
||||
sourceGroups,
|
||||
usernames,
|
||||
groupName,
|
||||
handleUserNamesChange,
|
||||
}: RowProps) {
|
||||
const { users } = useUsers();
|
||||
|
||||
const group = useMemo(
|
||||
() => sourceGroups?.find((g) => g.name === groupName),
|
||||
[sourceGroups, groupName],
|
||||
);
|
||||
|
||||
const usersOfGroup = useMemo(
|
||||
() =>
|
||||
users?.filter((user) => user.auto_groups.includes(group?.id || "")) || [],
|
||||
[users, group],
|
||||
);
|
||||
|
||||
return (
|
||||
group && (
|
||||
<div className="flex gap-6 w-full items-center py-2 px-4">
|
||||
<div className={"flex items-center gap-2 col-span-3"}>
|
||||
<GroupBadge group={group} showNewBadge={true} />
|
||||
<HorizontalUsersStack users={usersOfGroup} />
|
||||
</div>
|
||||
<div
|
||||
className={
|
||||
"flex items-center gap-4 min-w-[340px] max-w-[340px] ml-auto"
|
||||
}
|
||||
>
|
||||
<SSHUsernameSelector
|
||||
onChange={handleUserNamesChange}
|
||||
values={usernames}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
261
src/modules/access-control/ssh/SSHUsernameSelector.tsx
Normal file
261
src/modules/access-control/ssh/SSHUsernameSelector.tsx
Normal file
@@ -0,0 +1,261 @@
|
||||
import Badge from "@components/Badge";
|
||||
import { Callout } from "@components/Callout";
|
||||
import { Checkbox } from "@components/Checkbox";
|
||||
import { CommandItem } from "@components/Command";
|
||||
import { DropdownInfoText } from "@components/DropdownInfoText";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@components/Popover";
|
||||
import { IconArrowBack } from "@tabler/icons-react";
|
||||
import { cn } from "@utils/helpers";
|
||||
import { Command, CommandGroup, CommandInput, CommandList } from "cmdk";
|
||||
import { trim } from "lodash";
|
||||
import {
|
||||
ChevronsUpDown,
|
||||
CircleUserIcon,
|
||||
SearchIcon,
|
||||
XIcon,
|
||||
} from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useElementSize } from "@/hooks/useElementSize";
|
||||
import { PostureCheck } from "@/interfaces/PostureCheck";
|
||||
|
||||
interface MultiSelectProps {
|
||||
values?: string[];
|
||||
onChange: (value: string[]) => void;
|
||||
disabled?: boolean;
|
||||
popoverWidth?: "auto" | number;
|
||||
}
|
||||
|
||||
export function SSHUsernameSelector({
|
||||
values,
|
||||
onChange,
|
||||
disabled = false,
|
||||
popoverWidth = "auto",
|
||||
}: Readonly<MultiSelectProps>) {
|
||||
const searchRef = React.useRef<HTMLInputElement>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [inputRef, { width }] = useElementSize<HTMLButtonElement>();
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
const toggle = (value: string) => {
|
||||
if (disabled) return;
|
||||
|
||||
const previous = values || [];
|
||||
if (previous.includes(value)) {
|
||||
onChange(previous.filter((item) => item !== value));
|
||||
} else {
|
||||
onChange([...previous, value]);
|
||||
}
|
||||
|
||||
setSearch("");
|
||||
};
|
||||
|
||||
const notFound = useMemo(() => {
|
||||
const isSearching = search.length > 0;
|
||||
const trimmed = trim(search);
|
||||
return trimmed && !values?.includes(trimmed) && isSearching;
|
||||
}, [search, values]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={(isOpen) => {
|
||||
if (!isOpen) {
|
||||
setTimeout(() => {
|
||||
setSearch("");
|
||||
}, 100);
|
||||
}
|
||||
setOpen(isOpen);
|
||||
}}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
className={cn(
|
||||
"min-h-[42px] w-full relative items-center",
|
||||
"border border-neutral-200 dark:border-nb-gray-700 justify-between py-1.5 px-2.5",
|
||||
"rounded-md bg-white text-sm dark:bg-nb-gray-900/40 flex dark:text-neutral-400/70 text-neutral-500 cursor-pointer hover:dark:bg-nb-gray-900/50",
|
||||
)}
|
||||
data-cy={"ssh-username-selector"}
|
||||
disabled={disabled}
|
||||
ref={inputRef}
|
||||
>
|
||||
<div
|
||||
className={
|
||||
"flex items-center gap-2 border-nb-gray-700 flex-wrap h-full"
|
||||
}
|
||||
>
|
||||
{values?.length === 0 && (
|
||||
<Badge variant={"gray"} className={"font-normal py-1"}>
|
||||
<CircleUserIcon size={12} className={"shrink-0"} />
|
||||
All Local Users
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{values?.map((user) => (
|
||||
<Badge
|
||||
key={user}
|
||||
variant={"gray"}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
toggle(user);
|
||||
}}
|
||||
className={"font-normal py-1"}
|
||||
>
|
||||
{user}
|
||||
<XIcon
|
||||
size={12}
|
||||
className={"cursor-pointer group-hover:text-black"}
|
||||
/>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<ChevronsUpDown size={18} className={"shrink-0"} />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-full p-0 shadow-sm shadow-nb-gray-950"
|
||||
style={{
|
||||
width: popoverWidth === "auto" ? width : popoverWidth,
|
||||
}}
|
||||
align="start"
|
||||
side={"top"}
|
||||
sideOffset={10}
|
||||
>
|
||||
<Command
|
||||
className={"w-full flex"}
|
||||
loop
|
||||
filter={(value, search) => {
|
||||
const formatValue = trim(value.toLowerCase());
|
||||
const formatSearch = trim(search.toLowerCase());
|
||||
if (formatValue.includes(formatSearch)) return 1;
|
||||
return 0;
|
||||
}}
|
||||
>
|
||||
<CommandList className={"w-full"}>
|
||||
<div className={"relative"}>
|
||||
<CommandInput
|
||||
className={cn(
|
||||
"min-h-[42px] w-full relative",
|
||||
"border-b-0 border-t-0 border-r-0 border-l-0 border-neutral-200 dark:border-nb-gray-700 items-center",
|
||||
"bg-transparent text-sm outline-none focus-visible:outline-none ring-0 focus-visible:ring-0",
|
||||
"dark:placeholder:text-nb-gray-400 font-light placeholder:text-neutral-500 pl-10",
|
||||
)}
|
||||
data-cy={"ssh-username-input"}
|
||||
ref={searchRef}
|
||||
value={search}
|
||||
onValueChange={setSearch}
|
||||
placeholder={"E.g., root, ec2-user, ubuntu"}
|
||||
/>
|
||||
<div
|
||||
className={
|
||||
"absolute left-0 top-0 h-full flex items-center pl-4"
|
||||
}
|
||||
>
|
||||
<div className={"flex items-center"}>
|
||||
<SearchIcon size={14} />
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={
|
||||
"absolute right-0 top-0 h-full flex items-center pr-4"
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={
|
||||
"flex items-center bg-nb-gray-800 py-1 px-1.5 rounded-[4px] border border-nb-gray-500"
|
||||
}
|
||||
>
|
||||
<IconArrowBack size={10} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col gap-2",
|
||||
values?.length != 0 && "p-2",
|
||||
values?.length != 0 && search && "p-2",
|
||||
)}
|
||||
>
|
||||
{notFound && (
|
||||
<CommandGroup>
|
||||
<div
|
||||
className={cn(
|
||||
"max-h-[180px] overflow-y-auto flex flex-col gap-1",
|
||||
)}
|
||||
>
|
||||
<CommandItem
|
||||
key={search}
|
||||
onSelect={() => {
|
||||
toggle(search);
|
||||
searchRef.current?.focus();
|
||||
}}
|
||||
value={search}
|
||||
onClick={(e) => e.preventDefault()}
|
||||
>
|
||||
<Badge variant={"gray"} className={"font-normal py-1"}>
|
||||
{search}
|
||||
</Badge>
|
||||
<div
|
||||
className={"text-neutral-500 dark:text-nb-gray-300"}
|
||||
>
|
||||
Add username by pressing{" "}
|
||||
<span className={"font-bold text-netbird"}>
|
||||
{"'Enter'"}
|
||||
</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
</div>
|
||||
</CommandGroup>
|
||||
)}
|
||||
|
||||
<CommandGroup>
|
||||
<div
|
||||
className={cn(
|
||||
"max-h-[180px] overflow-y-auto flex flex-col gap-1",
|
||||
)}
|
||||
>
|
||||
{values?.map((user) => {
|
||||
const isSelected = values?.includes(user);
|
||||
return (
|
||||
<CommandItem
|
||||
key={user}
|
||||
value={user.toString()}
|
||||
onSelect={() => {
|
||||
toggle(user);
|
||||
searchRef.current?.focus();
|
||||
}}
|
||||
onClick={(e) => e.preventDefault()}
|
||||
>
|
||||
<div className={"flex items-center gap-2"}>
|
||||
<Badge
|
||||
variant={"gray"}
|
||||
className={"font-normal py-1"}
|
||||
>
|
||||
{user}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={
|
||||
"text-neutral-500 dark:text-nb-gray-300 font-medium flex items-center gap-2"
|
||||
}
|
||||
>
|
||||
<Checkbox checked={isSelected} />
|
||||
</div>
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CommandGroup>
|
||||
</div>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,9 @@
|
||||
import MultipleGroups from "@components/ui/MultipleGroups";
|
||||
import MultipleGroups, {
|
||||
TransparentEditIconButton,
|
||||
} from "@components/ui/MultipleGroups";
|
||||
import { cn } from "@utils/helpers";
|
||||
import React, { useMemo } from "react";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import { Group } from "@/interfaces/Group";
|
||||
import { Policy } from "@/interfaces/Policy";
|
||||
import { AccessControlResourceCell } from "@/modules/access-control/table/AccessControlResourceCell";
|
||||
@@ -8,9 +12,13 @@ import EmptyRow from "@/modules/common-table-rows/EmptyRow";
|
||||
type Props = {
|
||||
policy: Policy;
|
||||
};
|
||||
|
||||
export default function AccessControlDestinationsCell({
|
||||
policy,
|
||||
}: Readonly<Props>) {
|
||||
const { permission } = usePermissions();
|
||||
const canUpdate = permission?.policies?.update;
|
||||
|
||||
const firstRule = useMemo(() => {
|
||||
if (policy.rules.length > 0) return policy.rules[0];
|
||||
return undefined;
|
||||
@@ -23,7 +31,10 @@ export default function AccessControlDestinationsCell({
|
||||
}
|
||||
|
||||
return firstRule ? (
|
||||
<MultipleGroups groups={firstRule.destinations as Group[]} />
|
||||
<div className={cn("flex items-center gap-1", canUpdate && "group")}>
|
||||
<MultipleGroups groups={firstRule.destinations as Group[]} />
|
||||
{canUpdate && <TransparentEditIconButton />}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyRow />
|
||||
);
|
||||
|
||||
@@ -5,9 +5,9 @@ import {
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@components/Tooltip";
|
||||
import { orderBy } from "lodash";
|
||||
import React, { useMemo } from "react";
|
||||
import { Policy } from "@/interfaces/Policy";
|
||||
import { parsePortsToStrings } from "@/modules/access-control/useAccessControl";
|
||||
|
||||
type Props = {
|
||||
policy: Policy;
|
||||
@@ -23,19 +23,7 @@ export default function AccessControlPortsCell({ policy }: Readonly<Props>) {
|
||||
const hasPortRanges = rule?.port_ranges && rule?.port_ranges?.length > 0;
|
||||
const hasAnyPorts = hasPorts || hasPortRanges;
|
||||
|
||||
const allPorts = useMemo(() => {
|
||||
const ports = rule?.ports ?? [];
|
||||
const portRanges =
|
||||
rule?.port_ranges?.map((r) => {
|
||||
if (r.start === r.end) return `${r.start}`;
|
||||
return `${r.start}-${r.end}`;
|
||||
}) ?? [];
|
||||
return orderBy(
|
||||
[...portRanges, ...ports],
|
||||
[(p) => Number(p.split("-")[0])],
|
||||
["asc"],
|
||||
);
|
||||
}, [rule]);
|
||||
const allPorts = useMemo(() => parsePortsToStrings(rule), [rule]);
|
||||
|
||||
const firstTwoPorts = useMemo(() => {
|
||||
return allPorts?.slice(0, 2) ?? [];
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import MultipleGroups from "@components/ui/MultipleGroups";
|
||||
import MultipleGroups, {
|
||||
TransparentEditIconButton,
|
||||
} from "@components/ui/MultipleGroups";
|
||||
import { cn } from "@utils/helpers";
|
||||
import React, { useMemo } from "react";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import { Group } from "@/interfaces/Group";
|
||||
import { Policy } from "@/interfaces/Policy";
|
||||
import { AccessControlResourceCell } from "@/modules/access-control/table/AccessControlResourceCell";
|
||||
@@ -8,7 +12,11 @@ import EmptyRow from "@/modules/common-table-rows/EmptyRow";
|
||||
type Props = {
|
||||
policy: Policy;
|
||||
};
|
||||
|
||||
export default function AccessControlSourcesCell({ policy }: Props) {
|
||||
const { permission } = usePermissions();
|
||||
const canUpdate = permission?.policies?.update;
|
||||
|
||||
const firstRule = useMemo(() => {
|
||||
if (policy.rules.length > 0) return policy.rules[0];
|
||||
return undefined;
|
||||
@@ -19,7 +27,13 @@ export default function AccessControlSourcesCell({ policy }: Props) {
|
||||
}
|
||||
|
||||
return firstRule ? (
|
||||
<MultipleGroups groups={firstRule.sources as Group[]} />
|
||||
<div className={cn("flex items-center gap-1", canUpdate && "group")}>
|
||||
<MultipleGroups
|
||||
groups={firstRule.sources as Group[]}
|
||||
showUsers={firstRule.protocol === "netbird-ssh"}
|
||||
/>
|
||||
{canUpdate && <TransparentEditIconButton />}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyRow />
|
||||
);
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
import { notify } from "@components/Notification";
|
||||
import { Direction } from "@components/ui/PolicyDirection";
|
||||
import useFetchApi, { useApiCall } from "@utils/api";
|
||||
import { merge, uniqBy } from "lodash";
|
||||
import { merge, orderBy, uniqBy } from "lodash";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useSWRConfig } from "swr";
|
||||
import { usePolicies } from "@/contexts/PoliciesProvider";
|
||||
import { Group } from "@/interfaces/Group";
|
||||
import { Policy, PortRange, Protocol } from "@/interfaces/Policy";
|
||||
import {
|
||||
AuthorizedGroups,
|
||||
Policy,
|
||||
PolicyRule,
|
||||
PolicyRuleResource,
|
||||
PortRange,
|
||||
Protocol,
|
||||
} from "@/interfaces/Policy";
|
||||
import { PostureCheck } from "@/interfaces/PostureCheck";
|
||||
import useGroupHelper from "@/modules/groups/useGroupHelper";
|
||||
import { usePostureCheck } from "@/modules/posture-checks/usePostureCheck";
|
||||
@@ -18,6 +25,9 @@ type Props = {
|
||||
initialDestinationGroups?: Group[] | string[];
|
||||
initialName?: string;
|
||||
initialDescription?: string;
|
||||
initialProtocol?: Protocol;
|
||||
initialPorts?: number[];
|
||||
initialDestinationResource?: PolicyRuleResource;
|
||||
};
|
||||
|
||||
// TODO add reducer
|
||||
@@ -29,6 +39,9 @@ export const useAccessControl = ({
|
||||
initialName,
|
||||
initialDescription,
|
||||
onSuccess,
|
||||
initialProtocol,
|
||||
initialPorts,
|
||||
initialDestinationResource,
|
||||
}: Props = {}) => {
|
||||
const { data: allPostureChecks, isLoading: isPostureChecksLoading } =
|
||||
useFetchApi<PostureCheck[]>("/posture-checks");
|
||||
@@ -75,6 +88,7 @@ export const useAccessControl = ({
|
||||
const [enabled, setEnabled] = useState<boolean>(policy?.enabled ?? true);
|
||||
|
||||
const [ports, setPorts] = useState<number[]>(() => {
|
||||
if (initialPorts) return initialPorts;
|
||||
if (!firstRule) return [];
|
||||
if (firstRule.ports == undefined) return [];
|
||||
if (firstRule.ports.length > 0) {
|
||||
@@ -93,7 +107,7 @@ export const useAccessControl = ({
|
||||
});
|
||||
|
||||
const [protocol, setProtocol] = useState<Protocol>(
|
||||
firstRule ? firstRule.protocol : "all",
|
||||
firstRule ? firstRule.protocol : initialProtocol ?? "all",
|
||||
);
|
||||
const [direction, setDirection] = useState<Direction>(() => {
|
||||
if (!firstRule) return "bi";
|
||||
@@ -131,9 +145,24 @@ export const useAccessControl = ({
|
||||
);
|
||||
|
||||
const [destinationResource, setDestinationResource] = useState(
|
||||
firstRule?.destinationResource,
|
||||
firstRule?.destinationResource ?? initialDestinationResource,
|
||||
);
|
||||
|
||||
const [sshAccessType, setSshAccessType] = useState<"full" | "limited">(() => {
|
||||
if (protocol === "netbird-ssh") {
|
||||
return firstRule?.authorized_groups !== undefined &&
|
||||
Object.keys(firstRule?.authorized_groups).length > 0
|
||||
? "limited"
|
||||
: "full";
|
||||
} else {
|
||||
return "full";
|
||||
}
|
||||
});
|
||||
|
||||
const [sshAuthorizedGroups, setSshAuthorizedGroups] = useState<
|
||||
AuthorizedGroups | undefined
|
||||
>(firstRule?.authorized_groups);
|
||||
|
||||
const { updateOrCreateAndNotify: checkToCreate } = usePostureCheck({});
|
||||
const createPostureChecksWithoutID = async () => {
|
||||
const checks = postureChecks.filter(
|
||||
@@ -176,6 +205,7 @@ export const useAccessControl = ({
|
||||
enabled,
|
||||
ports: newPorts,
|
||||
port_ranges: newPortRanges,
|
||||
authorized_groups: sshAuthorizedGroups,
|
||||
},
|
||||
],
|
||||
} as Policy;
|
||||
@@ -226,10 +256,34 @@ export const useAccessControl = ({
|
||||
destinations = tmp;
|
||||
}
|
||||
|
||||
const [newPorts, newPortRanges] = parseAccessControlPorts(
|
||||
ports,
|
||||
portRanges,
|
||||
);
|
||||
let [newPorts, newPortRanges] = parseAccessControlPorts(ports, portRanges);
|
||||
|
||||
let authorizedGroups: AuthorizedGroups = {};
|
||||
if (protocol === "netbird-ssh") {
|
||||
// Set port 22 for SSH protocol
|
||||
newPorts = ["22"];
|
||||
newPortRanges = [];
|
||||
|
||||
const isEmpty =
|
||||
!sshAuthorizedGroups ||
|
||||
Object.keys(sshAuthorizedGroups).length === 0 ||
|
||||
sshAccessType === "full";
|
||||
|
||||
if (!isEmpty) {
|
||||
Object.entries(sshAuthorizedGroups).reduce(
|
||||
(acc, [groupName, usernames]) => {
|
||||
const group = groups?.find((group) => group.name === groupName);
|
||||
if (group?.id) {
|
||||
authorizedGroups[group.id] = usernames;
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{} as AuthorizedGroups,
|
||||
);
|
||||
} else {
|
||||
authorizedGroups = {};
|
||||
}
|
||||
}
|
||||
|
||||
const policyObj = {
|
||||
name,
|
||||
@@ -252,6 +306,8 @@ export const useAccessControl = ({
|
||||
destinationResource: destinationResource || undefined,
|
||||
ports: newPorts,
|
||||
port_ranges: newPortRanges,
|
||||
authorized_groups:
|
||||
protocol === "netbird-ssh" ? authorizedGroups : undefined,
|
||||
},
|
||||
],
|
||||
} as Policy;
|
||||
@@ -362,6 +418,10 @@ export const useAccessControl = ({
|
||||
destinationHasResources,
|
||||
destinationOnlyResources,
|
||||
hasPortSupport,
|
||||
sshAccessType,
|
||||
setSshAccessType,
|
||||
sshAuthorizedGroups,
|
||||
setSshAuthorizedGroups,
|
||||
} as const;
|
||||
};
|
||||
|
||||
@@ -380,3 +440,18 @@ const parseAccessControlPorts = (ports: number[], portRanges: PortRange[]) => {
|
||||
const allRanges = [...portRanges, ...portRangesFromPorts];
|
||||
return [undefined, allRanges];
|
||||
};
|
||||
|
||||
export const parsePortsToStrings = (rule?: PolicyRule): string[] => {
|
||||
if (!rule) return [];
|
||||
const ports = rule?.ports ?? [];
|
||||
const portRanges =
|
||||
rule?.port_ranges?.map((r) => {
|
||||
if (r.start === r.end) return `${r.start}`;
|
||||
return `${r.start}-${r.end}`;
|
||||
}) ?? [];
|
||||
return orderBy(
|
||||
[...portRanges, ...ports],
|
||||
[(p) => Number(p.split("-")[0])],
|
||||
["asc"],
|
||||
);
|
||||
};
|
||||
|
||||
@@ -203,6 +203,14 @@ export default function ActivityDescription({ event }: Props) {
|
||||
</div>
|
||||
);
|
||||
|
||||
if (event.activity_code == "user.create")
|
||||
return (
|
||||
<div className={"inline"}>
|
||||
<Value>{event.meta.username}</Value> <Value>{event.meta.email}</Value>{" "}
|
||||
was created by <Value>{event?.initiator_name || "NetBird"}</Value>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (event.activity_code == "user.group.add")
|
||||
return (
|
||||
<div className={"inline"}>
|
||||
|
||||
@@ -1,14 +1,21 @@
|
||||
import MultipleGroups from "@components/ui/MultipleGroups";
|
||||
import MultipleGroups, {
|
||||
TransparentEditIconButton,
|
||||
} from "@components/ui/MultipleGroups";
|
||||
import { cn } from "@utils/helpers";
|
||||
import * as React from "react";
|
||||
import { useGroups } from "@/contexts/GroupsProvider";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import { Group } from "@/interfaces/Group";
|
||||
import { NameserverGroup } from "@/interfaces/Nameserver";
|
||||
|
||||
type Props = {
|
||||
ns: NameserverGroup;
|
||||
};
|
||||
|
||||
export default function NameserverDistributionGroupsCell({ ns }: Props) {
|
||||
const { groups } = useGroups();
|
||||
const { permission } = usePermissions();
|
||||
const canUpdate = permission?.nameservers?.update;
|
||||
|
||||
const allGroups = ns.groups
|
||||
.map((group) => {
|
||||
@@ -16,5 +23,10 @@ export default function NameserverDistributionGroupsCell({ ns }: Props) {
|
||||
})
|
||||
.filter((g) => g != undefined) as Group[];
|
||||
|
||||
return <MultipleGroups groups={allGroups} />;
|
||||
return (
|
||||
<div className={cn("flex items-center gap-1", canUpdate && "group")}>
|
||||
<MultipleGroups groups={allGroups} />
|
||||
{canUpdate && <TransparentEditIconButton />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -145,7 +145,7 @@ export default function NameserverGroupTable({
|
||||
wrapperProps={isGroupPage ? { className: "mt-6 w-full" } : undefined}
|
||||
paginationPaddingClassName={isGroupPage ? "px-0 pt-8" : undefined}
|
||||
tableClassName={isGroupPage ? "mt-0" : undefined}
|
||||
inset={!isGroupPage}
|
||||
inset={false}
|
||||
minimal={isGroupPage}
|
||||
showSearchAndFilters={isGroupPage}
|
||||
keepStateInLocalStorage={!isGroupPage}
|
||||
|
||||
@@ -8,17 +8,21 @@ const NameserverGroupTable = lazy(
|
||||
() => import("@/modules/dns-nameservers/table/NameserverGroupTable"),
|
||||
);
|
||||
|
||||
type Props = {
|
||||
nameserverGroups?: NameserverGroup[];
|
||||
isLoading?: boolean;
|
||||
};
|
||||
|
||||
export const GroupNameserversSection = ({
|
||||
nameserverGroups,
|
||||
}: {
|
||||
nameserverGroups?: NameserverGroup[];
|
||||
}) => {
|
||||
isLoading = true,
|
||||
}: Props) => {
|
||||
const { group } = useGroupContext();
|
||||
|
||||
return (
|
||||
<GroupDetailsTableContainer>
|
||||
<NameserverGroupTable
|
||||
isLoading={false}
|
||||
isLoading={isLoading}
|
||||
nameserverGroups={nameserverGroups}
|
||||
isGroupPage={true}
|
||||
distributionGroups={[group]}
|
||||
|
||||
@@ -1,68 +1,19 @@
|
||||
import { DataTable } from "@components/table/DataTable";
|
||||
import DataTableHeader from "@components/table/DataTableHeader";
|
||||
import { DataTableRowsPerPage } from "@components/table/DataTableRowsPerPage";
|
||||
import { usePortalElement } from "@hooks/usePortalElement";
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import React from "react";
|
||||
import { useGroupContext } from "@/contexts/GroupProvider";
|
||||
import { Route } from "@/interfaces/Route";
|
||||
import { GroupDetailsTableContainer } from "@/modules/groups/details/GroupDetailsTableContainer";
|
||||
import PeerRouteNameCell from "@/modules/peer/PeerRouteNameCell";
|
||||
import GroupedRouteNetworkRangeCell from "@/modules/route-group/GroupedRouteNetworkRangeCell";
|
||||
import NetworkRoutesTable from "@/modules/route-group/NetworkRoutesTable";
|
||||
import useGroupedRoutes from "@/modules/route-group/useGroupedRoutes";
|
||||
import RouteActiveCell from "@/modules/routes/RouteActiveCell";
|
||||
import RouteMetricCell from "@/modules/routes/RouteMetricCell";
|
||||
|
||||
export const GroupNetworkRoutesTableColumns: ColumnDef<Route>[] = [
|
||||
{
|
||||
accessorKey: "network_id",
|
||||
header: ({ column }) => {
|
||||
return <DataTableHeader column={column}>Name</DataTableHeader>;
|
||||
},
|
||||
sortingFn: "text",
|
||||
cell: ({ row }) => <PeerRouteNameCell route={row.original} />,
|
||||
},
|
||||
{
|
||||
accessorKey: "description",
|
||||
sortingFn: "text",
|
||||
},
|
||||
{
|
||||
accessorKey: "domain_search",
|
||||
sortingFn: "text",
|
||||
},
|
||||
{
|
||||
accessorKey: "network",
|
||||
header: ({ column }) => {
|
||||
return <DataTableHeader column={column}>Network</DataTableHeader>;
|
||||
},
|
||||
cell: ({ row }) => (
|
||||
<GroupedRouteNetworkRangeCell
|
||||
domains={row.original?.domains}
|
||||
network={row.original?.network}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "metric",
|
||||
header: ({ column }) => {
|
||||
return <DataTableHeader column={column}>Metric</DataTableHeader>;
|
||||
},
|
||||
cell: ({ row }) => <RouteMetricCell metric={row.original.metric} />,
|
||||
sortingFn: "alphanumeric",
|
||||
},
|
||||
{
|
||||
id: "enabled",
|
||||
accessorKey: "enabled",
|
||||
sortingFn: "basic",
|
||||
header: ({ column }) => (
|
||||
<DataTableHeader column={column}>Active</DataTableHeader>
|
||||
),
|
||||
cell: ({ row }) => <RouteActiveCell route={row.original} />,
|
||||
},
|
||||
];
|
||||
type Props = {
|
||||
routes?: Route[];
|
||||
isLoading?: boolean;
|
||||
};
|
||||
|
||||
export const GroupNetworkRoutesSection = ({ routes }: { routes?: Route[] }) => {
|
||||
export const GroupNetworkRoutesSection = ({
|
||||
routes,
|
||||
isLoading = true,
|
||||
}: Props) => {
|
||||
const groupedRoutes = useGroupedRoutes({ routes });
|
||||
const { group } = useGroupContext();
|
||||
|
||||
@@ -70,7 +21,7 @@ export const GroupNetworkRoutesSection = ({ routes }: { routes?: Route[] }) => {
|
||||
<GroupDetailsTableContainer>
|
||||
<NetworkRoutesTable
|
||||
isGroupPage={true}
|
||||
isLoading={false}
|
||||
isLoading={isLoading}
|
||||
groupedRoutes={groupedRoutes}
|
||||
routes={routes}
|
||||
distributionGroups={[group]}
|
||||
|
||||
@@ -103,7 +103,12 @@ const GroupPeersTableColumns: ColumnDef<Peer>[] = [
|
||||
},
|
||||
];
|
||||
|
||||
export const GroupPeersSection = ({ peers }: { peers?: Peer[] }) => {
|
||||
type Props = {
|
||||
peers?: Peer[];
|
||||
isLoading?: boolean;
|
||||
};
|
||||
|
||||
export const GroupPeersSection = ({ peers, isLoading = true }: Props) => {
|
||||
const { group, addPeersToGroup, removePeersFromGroup } = useGroupContext();
|
||||
const [selectedRows, setSelectedRows] = useState<RowSelectionState>({});
|
||||
const [open, setOpen] = useState(false);
|
||||
@@ -112,7 +117,7 @@ export const GroupPeersSection = ({ peers }: { peers?: Peer[] }) => {
|
||||
return (
|
||||
<GroupDetailsTableContainer>
|
||||
<GroupPeersTable
|
||||
isLoading={false}
|
||||
isLoading={isLoading}
|
||||
peers={peers}
|
||||
columns={GroupPeersTableColumns}
|
||||
selectedRows={selectedRows}
|
||||
|
||||
@@ -6,13 +6,17 @@ import { GroupDetailsTableContainer } from "@/modules/groups/details/GroupDetail
|
||||
const AccessControlTable = lazy(
|
||||
() => import("@/modules/access-control/table/AccessControlTable"),
|
||||
);
|
||||
type Props = {
|
||||
policies?: Policy[];
|
||||
isLoading?: boolean;
|
||||
};
|
||||
|
||||
export const GroupPoliciesSection = ({ policies }: { policies?: Policy[] }) => {
|
||||
export const GroupPoliciesSection = ({ policies, isLoading = true }: Props) => {
|
||||
return (
|
||||
<GroupDetailsTableContainer>
|
||||
<PoliciesProvider>
|
||||
<AccessControlTable
|
||||
isLoading={false}
|
||||
isLoading={isLoading}
|
||||
policies={policies}
|
||||
isGroupPage={true}
|
||||
/>
|
||||
|
||||
@@ -100,11 +100,15 @@ const GroupResourcesColumns: ColumnDef<NetworkResourceWithNetwork>[] = [
|
||||
},
|
||||
];
|
||||
|
||||
type Props = {
|
||||
resources?: NetworkResourceWithNetwork[];
|
||||
isLoading?: boolean;
|
||||
};
|
||||
|
||||
export const GroupResourcesSection = ({
|
||||
resources,
|
||||
}: {
|
||||
resources?: NetworkResourceWithNetwork[];
|
||||
}) => {
|
||||
isLoading = true,
|
||||
}: Props) => {
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
const { permission } = usePermissions();
|
||||
const router = useRouter();
|
||||
@@ -118,6 +122,7 @@ export const GroupResourcesSection = ({
|
||||
sorting={sorting}
|
||||
setSorting={setSorting}
|
||||
minimal={true}
|
||||
isLoading={isLoading}
|
||||
showSearchAndFilters={true}
|
||||
renderRow={(row, children) => (
|
||||
<NetworkProvider
|
||||
|
||||
@@ -7,17 +7,21 @@ const SetupKeysTable = lazy(
|
||||
() => import("@/modules/setup-keys/SetupKeysTable"),
|
||||
);
|
||||
|
||||
type Props = {
|
||||
setupKeys?: SetupKey[];
|
||||
isLoading?: boolean;
|
||||
};
|
||||
|
||||
export const GroupSetupKeysSection = ({
|
||||
setupKeys,
|
||||
}: {
|
||||
setupKeys?: SetupKey[];
|
||||
}) => {
|
||||
isLoading = true,
|
||||
}: Props) => {
|
||||
const { group } = useGroupContext();
|
||||
|
||||
return (
|
||||
<GroupDetailsTableContainer>
|
||||
<SetupKeysTable
|
||||
isLoading={false}
|
||||
isLoading={isLoading}
|
||||
setupKeys={setupKeys}
|
||||
isGroupPage={true}
|
||||
groups={[group]}
|
||||
|
||||
@@ -113,7 +113,12 @@ export const GroupUsersTableColumns: ColumnDef<User>[] = [
|
||||
},
|
||||
];
|
||||
|
||||
export const GroupUsersSection = ({ users }: { users?: User[] }) => {
|
||||
type Props = {
|
||||
users?: User[];
|
||||
isLoading?: boolean;
|
||||
};
|
||||
|
||||
export const GroupUsersSection = ({ users, isLoading = true }: Props) => {
|
||||
const { group, addUsersToGroup, removeUsersFromGroup } = useGroupContext();
|
||||
const [selectedRows, setSelectedRows] = useState<RowSelectionState>({});
|
||||
const [open, setOpen] = useState(false);
|
||||
@@ -122,7 +127,7 @@ export const GroupUsersSection = ({ users }: { users?: User[] }) => {
|
||||
return (
|
||||
<GroupDetailsTableContainer>
|
||||
<UsersTable
|
||||
isLoading={false}
|
||||
isLoading={isLoading}
|
||||
columns={GroupUsersTableColumns}
|
||||
selectedRows={selectedRows}
|
||||
setSelectedRows={setSelectedRows}
|
||||
|
||||
@@ -121,9 +121,10 @@ export default function useGroupDetails(groupId: string) {
|
||||
isSetupKeysLoading ||
|
||||
isUsersLoading ||
|
||||
isPeerLoading ||
|
||||
isLoadingResources;
|
||||
isLoadingResources ||
|
||||
isNetworksLoading;
|
||||
|
||||
return useMemo(() => {
|
||||
const groupDetails = useMemo(() => {
|
||||
if (isLoading || !group) return null;
|
||||
|
||||
return {
|
||||
@@ -147,4 +148,9 @@ export default function useGroupDetails(groupId: string) {
|
||||
linkedPeers,
|
||||
linkedNetworkResources,
|
||||
]);
|
||||
|
||||
return {
|
||||
groupDetails,
|
||||
isLoading,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ import { DataTable } from "@components/table/DataTable";
|
||||
import DataTableHeader from "@components/table/DataTableHeader";
|
||||
import { DataTableRowsPerPage } from "@components/table/DataTableRowsPerPage";
|
||||
import { ColumnDef, SortingState } from "@tanstack/react-table";
|
||||
import { removeAllSpaces } from "@utils/helpers";
|
||||
import { Layers3Icon } from "lucide-react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import React from "react";
|
||||
@@ -20,6 +19,7 @@ import GroupsActionCell from "@/modules/groups/table/GroupsActionCell";
|
||||
import GroupsCountCell from "@/modules/groups/table/GroupsCountCell";
|
||||
import GroupsNameCell from "@/modules/groups/table/GroupsNameCell";
|
||||
import useGroupsUsage, { GroupUsage } from "@/modules/groups/useGroupsUsage";
|
||||
import { removeAllSpaces } from "@utils/helpers";
|
||||
|
||||
export const GroupsTableColumns: ColumnDef<GroupUsage>[] = [
|
||||
{
|
||||
|
||||
@@ -6,15 +6,15 @@ type Props = {
|
||||
};
|
||||
|
||||
export const useGroupIdentification = ({ id, issued }: Props) => {
|
||||
const isJWTGroup = issued === GroupIssued.JWT;
|
||||
const isOktaGroup = !!id?.includes("okta");
|
||||
const isGoogleGroup = !!id?.includes("google");
|
||||
const isAzureGroup = !!id?.includes("azure");
|
||||
const isJumpcloudGroup = !!id?.includes("jumpcloud");
|
||||
const isOIDCGroup = !!id?.includes("oidc");
|
||||
|
||||
const isRegularGroup =
|
||||
!isJWTGroup && !isOktaGroup && !isGoogleGroup && !isAzureGroup;
|
||||
|
||||
const isIntegrationGroup = isOktaGroup || isGoogleGroup || isAzureGroup;
|
||||
const isJWTGroup = issued === GroupIssued.JWT;
|
||||
const isIntegrationGroup = issued === GroupIssued.INTEGRATION;
|
||||
const isRegularGroup = issued === GroupIssued.API || isJWTGroup;
|
||||
|
||||
return {
|
||||
isOktaGroup,
|
||||
@@ -22,6 +22,8 @@ export const useGroupIdentification = ({ id, issued }: Props) => {
|
||||
isAzureGroup,
|
||||
isJWTGroup,
|
||||
isRegularGroup,
|
||||
isJumpcloudGroup,
|
||||
isOIDCGroup,
|
||||
isIntegrationGroup,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -12,6 +12,8 @@ import NetworkModal from "@/modules/networks/NetworkModal";
|
||||
import NetworkResourceModal from "@/modules/networks/resources/NetworkResourceModal";
|
||||
import { ResourceGroupModal } from "@/modules/networks/resources/ResourceGroupModal";
|
||||
import NetworkRoutingPeerModal from "@/modules/networks/routing-peers/NetworkRoutingPeerModal";
|
||||
import { Policy } from "@/interfaces/Policy";
|
||||
import PoliciesProvider from "@/contexts/PoliciesProvider";
|
||||
|
||||
type Props = {
|
||||
children: React.ReactNode;
|
||||
@@ -31,6 +33,7 @@ const NetworksContext = React.createContext(
|
||||
resource?: NetworkResource,
|
||||
) => void;
|
||||
openPolicyModal: (network?: Network, resource?: NetworkResource) => void;
|
||||
openEditPolicyModal: (policy: Policy) => void;
|
||||
deleteNetwork: (network: Network) => Promise<void>;
|
||||
deleteResource: (network: Network, resource: NetworkResource) => void;
|
||||
deleteRouter: (network: Network, router: NetworkRouter) => void;
|
||||
@@ -57,6 +60,7 @@ export const NetworkProvider = ({
|
||||
description?: string;
|
||||
destinationGroups?: Group[] | string[];
|
||||
}>();
|
||||
const [currentPolicy, setCurrentPolicy] = useState<Policy>();
|
||||
|
||||
const [routingPeerModal, setRoutingPeerModal] = useState(false);
|
||||
const [networkModal, setNetworkModal] = useState(false);
|
||||
@@ -119,6 +123,11 @@ export const NetworkProvider = ({
|
||||
setPolicyModal(true);
|
||||
};
|
||||
|
||||
const openEditPolicyModal = (policy: Policy) => {
|
||||
setCurrentPolicy(policy);
|
||||
setPolicyModal(true);
|
||||
};
|
||||
|
||||
const deleteNetwork = async (network: Network) => {
|
||||
const choice = await confirm({
|
||||
title: `Delete network '${network.name}'?`,
|
||||
@@ -246,6 +255,7 @@ export const NetworkProvider = ({
|
||||
openResourceModal,
|
||||
openResourceGroupModal,
|
||||
openPolicyModal,
|
||||
openEditPolicyModal,
|
||||
deleteNetwork,
|
||||
deleteResource,
|
||||
deleteRouter,
|
||||
@@ -267,32 +277,37 @@ export const NetworkProvider = ({
|
||||
mutate(`/networks/${n.id}`);
|
||||
}}
|
||||
/>
|
||||
<Modal
|
||||
open={policyModal}
|
||||
onOpenChange={(state) => {
|
||||
setPolicyModal(state);
|
||||
setPolicyDefaultSettings(undefined);
|
||||
}}
|
||||
>
|
||||
<AccessControlModalContent
|
||||
key={policyModal ? "1" : "0"}
|
||||
initialDestinationGroups={policyDefaultSettings?.destinationGroups}
|
||||
initialName={policyDefaultSettings?.name}
|
||||
initialDescription={policyDefaultSettings?.description}
|
||||
onSuccess={async (p) => {
|
||||
setPolicyModal(false);
|
||||
<PoliciesProvider>
|
||||
<Modal
|
||||
open={policyModal}
|
||||
onOpenChange={(state) => {
|
||||
setPolicyModal(state);
|
||||
setPolicyDefaultSettings(undefined);
|
||||
mutate("/networks");
|
||||
if (network) {
|
||||
onResourceUpdate?.();
|
||||
mutate(`/networks/${network.id}/resources`);
|
||||
mutate(`/networks/${network.id}`);
|
||||
} else {
|
||||
currentNetwork && (await askForRoutingPeer(currentNetwork));
|
||||
}
|
||||
setCurrentPolicy(undefined);
|
||||
}}
|
||||
/>
|
||||
</Modal>
|
||||
>
|
||||
<AccessControlModalContent
|
||||
key={policyModal ? "1" : "0"}
|
||||
initialDestinationGroups={policyDefaultSettings?.destinationGroups}
|
||||
initialName={policyDefaultSettings?.name}
|
||||
initialDescription={policyDefaultSettings?.description}
|
||||
policy={currentPolicy}
|
||||
onSuccess={async (p) => {
|
||||
setPolicyModal(false);
|
||||
setPolicyDefaultSettings(undefined);
|
||||
setCurrentPolicy(undefined);
|
||||
mutate("/networks");
|
||||
if (network) {
|
||||
onResourceUpdate?.();
|
||||
mutate(`/networks/${network.id}/resources`);
|
||||
mutate(`/networks/${network.id}`);
|
||||
} else {
|
||||
currentNetwork && (await askForRoutingPeer(currentNetwork));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Modal>
|
||||
</PoliciesProvider>
|
||||
{currentNetwork && (
|
||||
<>
|
||||
<NetworkRoutingPeerModal
|
||||
|
||||
@@ -23,7 +23,11 @@ export const ResourceGroupCell = ({ resource }: Props) => {
|
||||
openResourceGroupModal(network, resource);
|
||||
}}
|
||||
>
|
||||
<MultipleGroups groups={resource?.groups as Group[]} />
|
||||
<MultipleGroups
|
||||
groups={resource?.groups as Group[]}
|
||||
showResources={true}
|
||||
redirectGroupTab={"resources"}
|
||||
/>
|
||||
{permission.networks.update && <TransparentEditIconButton />}
|
||||
</button>
|
||||
);
|
||||
|
||||
@@ -2,10 +2,12 @@ import Badge from "@components/Badge";
|
||||
import Button from "@components/Button";
|
||||
import FullTooltip from "@components/FullTooltip";
|
||||
import useFetchApi from "@utils/api";
|
||||
import { PlusCircle, ShieldIcon } from "lucide-react";
|
||||
import { orderBy } from "lodash";
|
||||
import { PlusCircle, ShieldIcon, SquarePenIcon } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { useMemo } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import Skeleton from "react-loading-skeleton";
|
||||
import CircleIcon from "@/assets/icons/CircleIcon";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import { Group } from "@/interfaces/Group";
|
||||
import { NetworkResource } from "@/interfaces/Network";
|
||||
@@ -17,27 +19,32 @@ type Props = {
|
||||
};
|
||||
export const ResourcePolicyCell = ({ resource }: Props) => {
|
||||
const { permission } = usePermissions();
|
||||
const { openPolicyModal, network } = useNetworksContext();
|
||||
const { openPolicyModal, network, openEditPolicyModal } =
|
||||
useNetworksContext();
|
||||
const { data: policies, isLoading } = useFetchApi<Policy[]>("/policies");
|
||||
const [tooltipOpen, setTooltipOpen] = useState(false);
|
||||
|
||||
const assignedPolicies = useMemo(() => {
|
||||
const resourceGroups = resource?.groups as Group[];
|
||||
return policies?.filter((policy) => {
|
||||
if (!policy.enabled) return false;
|
||||
const destinationResource = policy.rules
|
||||
?.map((rule) => rule?.destinationResource?.id === resource?.id)
|
||||
.some((id) => id);
|
||||
if (destinationResource) return true;
|
||||
const destinationPolicyGroups = policy.rules
|
||||
?.map((rule) => rule?.destinations)
|
||||
.flat() as Group[];
|
||||
const policyGroups = [...destinationPolicyGroups];
|
||||
return resourceGroups?.some((resourceGroup) =>
|
||||
policyGroups.some(
|
||||
(policyGroup) => policyGroup?.id === resourceGroup.id,
|
||||
),
|
||||
);
|
||||
});
|
||||
return orderBy(
|
||||
policies?.filter((policy) => {
|
||||
const destinationResource = policy.rules
|
||||
?.map((rule) => rule?.destinationResource?.id === resource?.id)
|
||||
.some((id) => id);
|
||||
if (destinationResource) return true;
|
||||
const destinationPolicyGroups = policy.rules
|
||||
?.map((rule) => rule?.destinations)
|
||||
.flat() as Group[];
|
||||
const policyGroups = [...destinationPolicyGroups];
|
||||
return resourceGroups?.some((resourceGroup) =>
|
||||
policyGroups.some(
|
||||
(policyGroup) => policyGroup?.id === resourceGroup.id,
|
||||
),
|
||||
);
|
||||
}),
|
||||
"enabled",
|
||||
"desc",
|
||||
);
|
||||
}, [policies, resource]);
|
||||
|
||||
if (isLoading) {
|
||||
@@ -48,6 +55,8 @@ export const ResourcePolicyCell = ({ resource }: Props) => {
|
||||
);
|
||||
}
|
||||
|
||||
const enabledPolicies = assignedPolicies?.filter((policy) => policy?.enabled);
|
||||
|
||||
const policyCount = assignedPolicies?.length || 0;
|
||||
|
||||
return (
|
||||
@@ -55,36 +64,72 @@ export const ResourcePolicyCell = ({ resource }: Props) => {
|
||||
<div className={"flex gap-3"}>
|
||||
{policyCount > 0 && (
|
||||
<FullTooltip
|
||||
contentClassName={"p-0"}
|
||||
delayDuration={200}
|
||||
skipDelayDuration={200}
|
||||
customOpen={tooltipOpen}
|
||||
customOnOpenChange={setTooltipOpen}
|
||||
className={"border-nb-gray-800"}
|
||||
content={
|
||||
<div className={"text-xs max-w-lg"}>
|
||||
<span className={"font-medium text-nb-gray-100 text-sm"}>
|
||||
Assigned Policies
|
||||
</span>
|
||||
<div className={"flex gap-2 pt-2 pb-2 flex-wrap"}>
|
||||
{assignedPolicies?.map((policy: Policy, index: number) => {
|
||||
return (
|
||||
<Badge
|
||||
variant={"gray-ghost"}
|
||||
useHover={false}
|
||||
key={index}
|
||||
className={"justify-start font-medium"}
|
||||
<div className={"text-xs flex flex-col p-1"}>
|
||||
{assignedPolicies?.map((policy: Policy) => {
|
||||
const rule = policy?.rules?.[0];
|
||||
if (!rule) return;
|
||||
return (
|
||||
<button
|
||||
key={policy.id}
|
||||
className={
|
||||
"m-0 pl-3 py-2.5 leading-none flex justify-between group hover:bg-nb-gray-900 rounded-md"
|
||||
}
|
||||
onClick={() => {
|
||||
setTooltipOpen(false);
|
||||
openEditPolicyModal(policy);
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={
|
||||
" flex items-center gap-2 leading-none font-medium text-nb-gray-300 group-hover:text-nb-gray-200 whitespace-nowrap"
|
||||
}
|
||||
>
|
||||
<ShieldIcon size={14} className={"text-green-500"} />
|
||||
<CircleIcon
|
||||
size={8}
|
||||
active={policy.enabled}
|
||||
className={"shrink-0"}
|
||||
/>
|
||||
{policy.name}
|
||||
</Badge>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={
|
||||
"text-nb-gray-300 px-2 ml-4 uppercase font-mono opacity-0 group-hover:opacity-100"
|
||||
}
|
||||
>
|
||||
<SquarePenIcon size={12} />
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
}
|
||||
interactive={true}
|
||||
align={"start"}
|
||||
alignOffset={0}
|
||||
sideOffset={14}
|
||||
>
|
||||
<Badge variant={"gray"} useHover={true}>
|
||||
<Badge
|
||||
variant={"gray"}
|
||||
useHover={false}
|
||||
className={"select-none hover:bg-nb-gray-910"}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (!tooltipOpen) setTooltipOpen(true);
|
||||
}}
|
||||
>
|
||||
<ShieldIcon size={14} className={"text-green-500"} />
|
||||
<div>
|
||||
<span className={"font-medium text-xs"}>
|
||||
{" "}
|
||||
{assignedPolicies?.length}
|
||||
{enabledPolicies?.length}
|
||||
</span>
|
||||
</div>
|
||||
</Badge>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import GroupBadge from "@components/ui/GroupBadge";
|
||||
import PeerBadge from "@components/ui/PeerBadge";
|
||||
import PeerCountBadge from "@components/ui/PeerCountBadge";
|
||||
import useFetchApi from "@utils/api";
|
||||
import { ArrowRightIcon } from "lucide-react";
|
||||
import * as React from "react";
|
||||
@@ -45,9 +45,13 @@ export const NetworkRoutingPeerName = ({ router }: Props) => {
|
||||
if (routingPeerGroup) {
|
||||
return (
|
||||
<div className={"flex items-center gap-2 max-w-[295px] min-w-[295px]"}>
|
||||
<GroupBadge group={routingPeerGroup} />
|
||||
<GroupBadge
|
||||
group={routingPeerGroup}
|
||||
redirectToGroupPage={true}
|
||||
redirectGroupTab={"peers"}
|
||||
/>
|
||||
<ArrowRightIcon size={14} className={"shrink-0"} />
|
||||
<PeerBadge> {routingPeerGroup.peers_count} Peer(s)</PeerBadge>
|
||||
<PeerCountBadge group={routingPeerGroup} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ import { useNetworksContext } from "@/modules/networks/NetworkProvider";
|
||||
type Props = {
|
||||
network: Network;
|
||||
};
|
||||
export default function NetworkActionCell({ network }: Props) {
|
||||
export default function NetworkActionCell({ network }: Readonly<Props>) {
|
||||
const { permission } = usePermissions();
|
||||
const { deleteNetwork, openEditNetworkModal } = useNetworksContext();
|
||||
const router = useRouter();
|
||||
|
||||
643
src/modules/onboarding/Onboarding.tsx
Normal file
643
src/modules/onboarding/Onboarding.tsx
Normal file
@@ -0,0 +1,643 @@
|
||||
import InlineLink from "@components/InlineLink";
|
||||
import { Modal, ModalPortal } from "@components/modal/Modal";
|
||||
import { NetBirdLogo } from "@components/NetBirdLogo";
|
||||
import { notify } from "@components/Notification";
|
||||
import { GradientFadedBackground } from "@components/ui/GradientFadedBackground";
|
||||
import { DialogContent } from "@radix-ui/react-dialog";
|
||||
import useFetchApi, { useApiCall } from "@utils/api";
|
||||
import { cn } from "@utils/helpers";
|
||||
import { isNetBirdHosted } from "@utils/netbird";
|
||||
import { useRouter } from "next/navigation";
|
||||
import * as React from "react";
|
||||
import { useEffect, useMemo, useReducer, useState } from "react";
|
||||
import { useSWRConfig } from "swr";
|
||||
import { HubspotFormField } from "@/contexts/AnalyticsProvider";
|
||||
import { Group } from "@/interfaces/Group";
|
||||
import { Network, NetworkResource, NetworkRouter } from "@/interfaces/Network";
|
||||
import type { Peer } from "@/interfaces/Peer";
|
||||
import { Policy } from "@/interfaces/Policy";
|
||||
import { OnboardingAddResource } from "@/modules/onboarding/networks/OnboardingAddResource";
|
||||
import { OnboardingAddRoutingPeer } from "@/modules/onboarding/networks/OnboardingAddRoutingPeer";
|
||||
import { OnboardingAddUserDevice } from "@/modules/onboarding/networks/OnboardingAddUserDevice";
|
||||
import { OnboardingExplainPolicy } from "@/modules/onboarding/networks/OnboardingExplainPolicy";
|
||||
import { OnboardingTestResource } from "@/modules/onboarding/networks/OnboardingTestResource";
|
||||
import { OnboardingDevices } from "@/modules/onboarding/OnboardingDevices";
|
||||
import { OnboardingEnd } from "@/modules/onboarding/OnboardingEnd";
|
||||
import { OnboardingIntent } from "@/modules/onboarding/OnboardingIntent";
|
||||
import { OnboardingSurvey } from "@/modules/onboarding/OnboardingSurvey";
|
||||
import { OnboardingExplainDefaultPolicy } from "@/modules/onboarding/p2p/OnboardingExplainDefaultPolicy";
|
||||
import { OnboardingFirstDevice } from "@/modules/onboarding/p2p/OnboardingFirstDevice";
|
||||
import { OnboardingSecondDevice } from "@/modules/onboarding/p2p/OnboardingSecondDevice";
|
||||
import { OnboardingTestP2P } from "@/modules/onboarding/p2p/OnboardingTestP2P";
|
||||
|
||||
export interface OnboardingState {
|
||||
intent: Intent;
|
||||
step: number;
|
||||
finished_at?: string;
|
||||
survey_submitted_at?: string;
|
||||
skipped?: boolean;
|
||||
}
|
||||
|
||||
export enum Intent {
|
||||
P2P = "p2p",
|
||||
NETWORKS = "networks",
|
||||
}
|
||||
|
||||
type OnboardingAction =
|
||||
| { type: "SET_INTENT"; payload: OnboardingState["intent"] }
|
||||
| { type: "SET_FINISHED_AT"; payload: string }
|
||||
| { type: "SET_STEP"; payload: number }
|
||||
| { type: "SET_SURVEY_SUBMITTED_AT"; payload: string }
|
||||
| { type: "RESET" }
|
||||
| { type: "SKIP" };
|
||||
|
||||
const onboardingReducer = (
|
||||
state: OnboardingState,
|
||||
action: OnboardingAction,
|
||||
): OnboardingState => {
|
||||
switch (action.type) {
|
||||
case "SET_INTENT":
|
||||
return { ...state, intent: action.payload };
|
||||
case "SET_STEP":
|
||||
return { ...state, step: action.payload };
|
||||
case "SET_FINISHED_AT":
|
||||
return { ...state, finished_at: action.payload };
|
||||
case "SET_SURVEY_SUBMITTED_AT":
|
||||
return { ...state, survey_submitted_at: action.payload };
|
||||
case "RESET":
|
||||
return { intent: Intent.P2P, step: 1 };
|
||||
case "SKIP":
|
||||
return { ...state, skipped: true };
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
};
|
||||
|
||||
type Props = {
|
||||
initial: OnboardingState;
|
||||
setLocalOnboarding: (onboarding: OnboardingState) => void;
|
||||
peers: Peer[];
|
||||
onSurveySubmit?: (fields: HubspotFormField[]) => void;
|
||||
onSkip: (intent: Intent, step: number) => void;
|
||||
onFinish: (n?: Network) => void;
|
||||
formSubmitted: boolean;
|
||||
onTroubleshootingClick?: (intent: Intent) => void;
|
||||
isOnboardingPending: boolean;
|
||||
domainCategory?: string;
|
||||
};
|
||||
|
||||
export const Onboarding = ({
|
||||
initial,
|
||||
setLocalOnboarding,
|
||||
peers,
|
||||
onSurveySubmit,
|
||||
onSkip,
|
||||
onFinish,
|
||||
formSubmitted,
|
||||
onTroubleshootingClick,
|
||||
isOnboardingPending,
|
||||
domainCategory,
|
||||
}: Props) => {
|
||||
const { data: networks } = useFetchApi<Network[]>("/networks", true, false);
|
||||
const { data: policies } = useFetchApi<Policy[]>("/policies", true);
|
||||
const router = useRouter();
|
||||
|
||||
const resourceRequest = useApiCall<NetworkResource>("/networks", true);
|
||||
const routerRequest = useApiCall<NetworkRouter>("/networks", true);
|
||||
const policyRequest = useApiCall<Policy>("/policies", true);
|
||||
const { mutate } = useSWRConfig();
|
||||
|
||||
const [onboarding, dispatch] = useReducer(onboardingReducer, initial);
|
||||
const { step, intent } = onboarding;
|
||||
|
||||
const [resource, setResource] = useState<NetworkResource>();
|
||||
const [firstRoutingPeer, setFirstRoutingPeer] = useState<Peer>();
|
||||
const [useCases, setUseCases] = useState("");
|
||||
const [isBusiness, setIsBusiness] = useState(false);
|
||||
|
||||
const firstNetwork = useMemo(() => {
|
||||
return networks?.find((n) => n.name === "My First Network") ?? undefined;
|
||||
}, [networks]);
|
||||
|
||||
const firstDevice = useMemo(() => {
|
||||
return (
|
||||
peers?.find((p) => p.id !== firstRoutingPeer?.id && p.user_id !== "") ??
|
||||
undefined
|
||||
);
|
||||
}, [firstRoutingPeer?.id, peers]);
|
||||
|
||||
const secondDevice = useMemo(() => {
|
||||
return (
|
||||
peers?.find(
|
||||
(p) => p.id !== firstDevice?.id && p.id !== firstRoutingPeer?.id,
|
||||
) ?? undefined
|
||||
);
|
||||
}, [peers, firstDevice, firstRoutingPeer]);
|
||||
|
||||
const maxSteps = useMemo(() => {
|
||||
if (intent === Intent.P2P) return 7;
|
||||
return 8;
|
||||
}, [intent]);
|
||||
|
||||
const showWaitingForDevices = useMemo(() => {
|
||||
if (intent === Intent.NETWORKS) {
|
||||
return step === 4 || step === 5 || step === 6 || step === 7;
|
||||
} else {
|
||||
return step === 3 || step === 4 || step === 5 || step === 6;
|
||||
}
|
||||
}, [intent, step]);
|
||||
|
||||
const policy = useMemo(() => {
|
||||
if (intent === Intent.P2P) {
|
||||
return policies?.find((p) => p.name === "Default");
|
||||
} else if (resource) {
|
||||
return policies?.find((p) => p.name.includes(resource?.name));
|
||||
}
|
||||
}, [intent, policies, resource]);
|
||||
|
||||
const defaultPolicy = useMemo(() => {
|
||||
return policies?.find((p) => p.name === "Default");
|
||||
}, [policies]);
|
||||
|
||||
const disableDefaultPolicy = async () => {
|
||||
if (!defaultPolicy) return;
|
||||
if (defaultPolicy.enabled) return await togglePolicy(defaultPolicy, true);
|
||||
};
|
||||
|
||||
const togglePolicy = async (p: Policy, ignoreNotification = false) => {
|
||||
if (!p) return;
|
||||
const rule = p?.rules?.[0];
|
||||
if (!rule) return;
|
||||
|
||||
const enabled = p?.enabled || false;
|
||||
|
||||
const sources = rule.sources
|
||||
?.map((group) => {
|
||||
const g = group as Group;
|
||||
return g?.id;
|
||||
})
|
||||
.filter((x) => x !== undefined);
|
||||
const destinations = rule.destinations
|
||||
?.map((group) => {
|
||||
const g = group as Group;
|
||||
return g?.id;
|
||||
})
|
||||
.filter((x) => x !== undefined);
|
||||
|
||||
const request = policyRequest.put(
|
||||
{
|
||||
...p,
|
||||
rules: [
|
||||
{
|
||||
...rule,
|
||||
sources: sources || [],
|
||||
destinations: rule.destinationResource
|
||||
? undefined
|
||||
: destinations || [],
|
||||
},
|
||||
],
|
||||
enabled: !enabled,
|
||||
},
|
||||
`/${p.id}`,
|
||||
);
|
||||
|
||||
if (ignoreNotification) {
|
||||
return request.then(() => mutate("/policies"));
|
||||
} else {
|
||||
notify({
|
||||
title: p.name + " Policy",
|
||||
description: `Policy was successfully ${
|
||||
!enabled ? "enabled" : "disabled"
|
||||
}`,
|
||||
loadingMessage: "Updating policy...",
|
||||
promise: request.then(() => mutate("/policies")),
|
||||
duration: 800,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (firstNetwork && intent === Intent.NETWORKS && !firstRoutingPeer) {
|
||||
const firstRouterId = firstNetwork?.routers?.[0];
|
||||
if (firstRouterId) {
|
||||
routerRequest
|
||||
.get(`/${firstNetwork?.id}/routers/${firstRouterId}`)
|
||||
.then((r) => {
|
||||
const routingPeer =
|
||||
peers?.find((p) => p.id === r.peer) ?? undefined;
|
||||
if (!routingPeer) return;
|
||||
setFirstRoutingPeer(routingPeer);
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [intent, firstNetwork, peers]);
|
||||
|
||||
useEffect(() => {
|
||||
if (firstNetwork && intent === Intent.NETWORKS) {
|
||||
const firstResourceId = firstNetwork?.resources?.[0];
|
||||
if (firstResourceId) {
|
||||
resourceRequest
|
||||
.get(`/${firstNetwork?.id}/resources/${firstResourceId}`)
|
||||
.then((r) => {
|
||||
setResource(r);
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [intent, firstNetwork]);
|
||||
|
||||
/**
|
||||
* Polling every 5s if we are still waiting for devices to connect, in case browser focus does not trigger a refresh
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (
|
||||
(firstDevice && secondDevice) ||
|
||||
(firstDevice && firstRoutingPeer) ||
|
||||
!(step === 3 || step === 4 || step === 5)
|
||||
) {
|
||||
return; // Stop polling if condition is met
|
||||
}
|
||||
|
||||
const interval = setInterval(() => {
|
||||
mutate("/peers");
|
||||
}, 5000);
|
||||
|
||||
return () => clearInterval(interval); // Clean up when dependencies change
|
||||
}, [firstDevice, secondDevice, firstRoutingPeer, step, mutate]);
|
||||
|
||||
/**
|
||||
* Skip form if already submitted
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (formSubmitted && step === 1) {
|
||||
dispatch({
|
||||
type: "SET_STEP",
|
||||
payload: 2,
|
||||
});
|
||||
}
|
||||
}, [formSubmitted, step]);
|
||||
|
||||
/**
|
||||
* Sync state with local storage
|
||||
*/
|
||||
useEffect(() => {
|
||||
setLocalOnboarding(onboarding);
|
||||
}, [onboarding, setLocalOnboarding]);
|
||||
|
||||
/**
|
||||
* Prefetch the first network page if it exists for faster navigation
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!firstNetwork) return;
|
||||
router.prefetch(`/network?id=${firstNetwork.id}`);
|
||||
}, [firstNetwork, router]);
|
||||
|
||||
return (
|
||||
<Modal open={true}>
|
||||
<ModalPortal>
|
||||
<DialogContent
|
||||
onEscapeKeyDown={(e) => e.preventDefault()}
|
||||
onInteractOutside={(e) => e.preventDefault()}
|
||||
onPointerDownOutside={(e) => e.preventDefault()}
|
||||
asChild={true}
|
||||
className={
|
||||
"h-full w-screen fixed z-[50] left-0 top-0 bg-nb-gray-950 flex overflow-y-auto"
|
||||
}
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
className={cn(
|
||||
"sm:px-4 py-10 max-w-6xl mx-auto flex flex-col items-center",
|
||||
intent === Intent.P2P && step === 3 && "max-w-4xl",
|
||||
intent === Intent.NETWORKS && step === 7 && "max-w-5xl",
|
||||
)}
|
||||
>
|
||||
<NetBirdLogo size={"large"} mobile={false} />
|
||||
|
||||
<div
|
||||
className={
|
||||
"grid grid-cols-1 md:grid-cols-12 gap-4 pb-10 mt-8 sm:mt-10"
|
||||
}
|
||||
>
|
||||
<Card
|
||||
className={cn(
|
||||
"max-w-2xl md:col-span-12",
|
||||
step === 1 && "max-w-lg",
|
||||
step === 3 &&
|
||||
intent == "p2p" &&
|
||||
"md:col-span-7 lg:col-span-6",
|
||||
step === 4 &&
|
||||
intent == "p2p" &&
|
||||
"md:col-span-7 lg:col-span-6",
|
||||
step === 5 &&
|
||||
intent == "p2p" &&
|
||||
"md:col-span-7 lg:col-span-6",
|
||||
step === 6 &&
|
||||
intent == "p2p" &&
|
||||
"md:col-span-7 lg:col-span-6",
|
||||
step === 3 && intent == "networks" && "max-w-xl ",
|
||||
step === 4 &&
|
||||
intent == "networks" &&
|
||||
"md:col-span-7 lg:col-span-6",
|
||||
step === 5 &&
|
||||
intent == "networks" &&
|
||||
"md:col-span-7 lg:col-span-6",
|
||||
step === 6 &&
|
||||
intent == "networks" &&
|
||||
"md:col-span-7 lg:col-span-6",
|
||||
step === 7 &&
|
||||
intent == "networks" &&
|
||||
"md:col-span-7 lg:col-span-6",
|
||||
step === maxSteps && "max-w-2xl",
|
||||
)}
|
||||
>
|
||||
{isOnboardingPending && (
|
||||
<Stepper
|
||||
step={isNetBirdHosted() ? step : step - 1}
|
||||
maxSteps={isNetBirdHosted() ? maxSteps : maxSteps - 1}
|
||||
/>
|
||||
)}
|
||||
|
||||
{step === 1 && domainCategory && (
|
||||
<OnboardingSurvey
|
||||
domainCategory={domainCategory}
|
||||
onSubmit={(fields) => {
|
||||
dispatch({
|
||||
type: "SET_SURVEY_SUBMITTED_AT",
|
||||
payload: new Date().toISOString(),
|
||||
});
|
||||
onSurveySubmit?.(fields);
|
||||
|
||||
let u = fields?.find((f) => f.name === "use_case");
|
||||
if (u) setUseCases(u.value);
|
||||
|
||||
let businessOrPersonal = fields?.find(
|
||||
(f) => f.name === "is_company",
|
||||
);
|
||||
if (businessOrPersonal)
|
||||
setIsBusiness(
|
||||
businessOrPersonal.value === "Business",
|
||||
);
|
||||
|
||||
if (isOnboardingPending) {
|
||||
dispatch({
|
||||
type: "SET_STEP",
|
||||
payload: 2,
|
||||
});
|
||||
} else {
|
||||
dispatch({
|
||||
type: "SET_FINISHED_AT",
|
||||
payload: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{step === 2 && (
|
||||
<OnboardingIntent
|
||||
useCases={useCases}
|
||||
isBusiness={isBusiness}
|
||||
onSelect={(val) => {
|
||||
dispatch({
|
||||
type: "SET_INTENT",
|
||||
payload: val,
|
||||
});
|
||||
dispatch({
|
||||
type: "SET_STEP",
|
||||
payload: 3,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{intent === Intent.P2P && (
|
||||
<>
|
||||
{step === 3 && (
|
||||
<OnboardingFirstDevice
|
||||
firstDevice={firstDevice}
|
||||
onFinish={() => {
|
||||
dispatch({
|
||||
type: "SET_STEP",
|
||||
payload: 4,
|
||||
});
|
||||
}}
|
||||
onBack={() => {
|
||||
dispatch({
|
||||
type: "SET_STEP",
|
||||
payload: 2,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{step === 4 && (
|
||||
<OnboardingSecondDevice
|
||||
secondDevice={secondDevice}
|
||||
onFinish={() => {
|
||||
dispatch({
|
||||
type: "SET_STEP",
|
||||
payload: 5,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{step === 5 && (
|
||||
<OnboardingTestP2P
|
||||
firstDevice={firstDevice}
|
||||
secondDevice={secondDevice}
|
||||
onTroubleshootingClick={() =>
|
||||
onTroubleshootingClick?.(intent)
|
||||
}
|
||||
onNext={() => {
|
||||
dispatch({
|
||||
type: "SET_STEP",
|
||||
payload: 6,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{step === 6 && (
|
||||
<OnboardingExplainDefaultPolicy
|
||||
policy={policy}
|
||||
onToggle={togglePolicy}
|
||||
onNext={() => {
|
||||
dispatch({
|
||||
type: "SET_STEP",
|
||||
payload: 7,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{intent === Intent.NETWORKS && (
|
||||
<>
|
||||
{step === 3 && (
|
||||
<OnboardingAddResource
|
||||
onResourceCreation={(res) => {
|
||||
setResource(res);
|
||||
dispatch({
|
||||
type: "SET_STEP",
|
||||
payload: 4,
|
||||
});
|
||||
mutate("/networks");
|
||||
}}
|
||||
onBack={() => {
|
||||
dispatch({
|
||||
type: "SET_STEP",
|
||||
payload: 2,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{step === 4 && (
|
||||
<OnboardingAddRoutingPeer
|
||||
network={firstNetwork}
|
||||
peers={peers}
|
||||
onRoutingPeerAdded={(p) => {
|
||||
setFirstRoutingPeer(p);
|
||||
dispatch({
|
||||
type: "SET_STEP",
|
||||
payload: 5,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{step === 5 && (
|
||||
<OnboardingAddUserDevice
|
||||
device={firstDevice}
|
||||
policy={policy}
|
||||
onNext={() => {
|
||||
dispatch({
|
||||
type: "SET_STEP",
|
||||
payload: 6,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{step === 6 && (
|
||||
<OnboardingTestResource
|
||||
resource={resource}
|
||||
device={firstDevice}
|
||||
onTroubleshootingClick={() =>
|
||||
onTroubleshootingClick?.(intent)
|
||||
}
|
||||
onNext={() => {
|
||||
dispatch({
|
||||
type: "SET_STEP",
|
||||
payload: 7,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{step === 7 && (
|
||||
<OnboardingExplainPolicy
|
||||
policy={policy}
|
||||
onToggle={togglePolicy}
|
||||
onNext={() => {
|
||||
dispatch({
|
||||
type: "SET_STEP",
|
||||
payload: 8,
|
||||
});
|
||||
disableDefaultPolicy().then();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{step === maxSteps && (
|
||||
<OnboardingEnd
|
||||
onFinish={() => {
|
||||
dispatch({
|
||||
type: "SET_FINISHED_AT",
|
||||
payload: new Date().toISOString(),
|
||||
});
|
||||
|
||||
if (intent === Intent.NETWORKS) {
|
||||
onFinish(firstNetwork);
|
||||
} else {
|
||||
onFinish();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{showWaitingForDevices && (
|
||||
<Card className={"md:col-span-5 lg:col-span-6"}>
|
||||
<OnboardingDevices
|
||||
intent={intent}
|
||||
resource={resource}
|
||||
firstDevice={firstDevice}
|
||||
secondDevice={secondDevice}
|
||||
firstRoutingPeer={firstRoutingPeer}
|
||||
enabled={policy?.enabled}
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{step !== 1 && step !== maxSteps && (
|
||||
<span
|
||||
className={
|
||||
"text-sm text-nb-gray-400 font-light pb-10 text-center px-4"
|
||||
}
|
||||
>
|
||||
Already know how NetBird works?
|
||||
<InlineLink
|
||||
href={"#"}
|
||||
className={"!text-nb-gray-200 ml-1"}
|
||||
onClick={() => {
|
||||
dispatch({
|
||||
type: "SKIP",
|
||||
});
|
||||
onSkip(intent, step);
|
||||
}}
|
||||
>
|
||||
Skip to Dashboard
|
||||
</InlineLink>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</ModalPortal>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
const Stepper = ({ step, maxSteps }: { step: number; maxSteps: number }) => {
|
||||
if (step <= 0) return;
|
||||
|
||||
return (
|
||||
<div className={"flex gap-2 w-full items-center justify-center mb-6 mt-2"}>
|
||||
{Array.from({ length: maxSteps }).map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={cn(
|
||||
"w-8 h-1 rounded-full bg-nb-gray-800",
|
||||
step >= index + 1 && "bg-netbird",
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Card = ({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"px-6 sm:px-8 py-8 pt-6",
|
||||
"bg-nb-gray-940 border border-nb-gray-910 rounded-lg relative",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<GradientFadedBackground className={"opacity-0"} />
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
297
src/modules/onboarding/OnboardingDevices.tsx
Normal file
297
src/modules/onboarding/OnboardingDevices.tsx
Normal file
@@ -0,0 +1,297 @@
|
||||
import TruncatedText from "@components/ui/TruncatedText";
|
||||
import { getOperatingSystem } from "@hooks/useOperatingSystem";
|
||||
import { cn } from "@utils/helpers";
|
||||
import {
|
||||
GlobeIcon,
|
||||
NetworkIcon,
|
||||
ShieldCheckIcon,
|
||||
ShieldXIcon,
|
||||
WorkflowIcon,
|
||||
} from "lucide-react";
|
||||
import * as React from "react";
|
||||
import RoundedFlag from "@/assets/countries/RoundedFlag";
|
||||
import { NetworkResource } from "@/interfaces/Network";
|
||||
import { OperatingSystem } from "@/interfaces/OperatingSystem";
|
||||
import type { Peer } from "@/interfaces/Peer";
|
||||
import { Intent } from "@/modules/onboarding/Onboarding";
|
||||
import { OSLogo } from "@/modules/peers/PeerOSCell";
|
||||
|
||||
type Props = {
|
||||
intent?: Intent;
|
||||
resource?: NetworkResource;
|
||||
firstDevice?: Peer;
|
||||
secondDevice?: Peer;
|
||||
firstRoutingPeer?: Peer;
|
||||
enabled?: boolean;
|
||||
};
|
||||
|
||||
export const OnboardingDevices = ({
|
||||
intent,
|
||||
resource,
|
||||
firstDevice,
|
||||
secondDevice,
|
||||
firstRoutingPeer,
|
||||
enabled = false,
|
||||
}: Props) => {
|
||||
return intent === Intent.P2P ? (
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-full flex-col items-center justify-center text-center text-nb-gray-300 py-8 w-full relative",
|
||||
!firstDevice && !secondDevice ? "gap-y-8" : "gap-y-2",
|
||||
)}
|
||||
>
|
||||
<DeviceCard device={firstDevice} />
|
||||
{firstDevice && secondDevice && (
|
||||
<div
|
||||
className={cn(
|
||||
"h-[70px] w-[2px] rounded-full border-l border-dashed border-green-400 relative",
|
||||
!enabled && "border-nb-gray-600",
|
||||
)}
|
||||
></div>
|
||||
)}
|
||||
|
||||
{firstDevice && secondDevice && (
|
||||
<div
|
||||
className={
|
||||
"absolute top-1/2 -translate-y-1/2 left-1/2 -translate-x-1/2 bg-nb-gray-940 p-2 "
|
||||
}
|
||||
>
|
||||
{enabled ? (
|
||||
<ShieldCheckIcon size={16} className={"text-green-500"} />
|
||||
) : (
|
||||
<ShieldXIcon size={16} className={"text-red-500"} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DeviceCard device={secondDevice} />
|
||||
{(!firstDevice || !secondDevice) && (
|
||||
<WaitingForDevice
|
||||
text={
|
||||
!firstDevice
|
||||
? "Waiting for your first device to connect"
|
||||
: "Waiting for your second device to connect"
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-full flex-col items-center justify-center text-center text-nb-gray-300 w-full",
|
||||
"gap-y-2",
|
||||
firstRoutingPeer && "h-full",
|
||||
)}
|
||||
>
|
||||
{firstRoutingPeer && resource && (
|
||||
<span className={"text-xs text-nb-gray-500"}>Network</span>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col items-center justify-center gap-y-1",
|
||||
resource &&
|
||||
firstRoutingPeer &&
|
||||
"border px-4 py-5 bg-nb-gray-940 border-nb-gray-900 rounded-lg border-dashed",
|
||||
)}
|
||||
>
|
||||
<DeviceCard resource={resource} />
|
||||
{resource && (
|
||||
<Line
|
||||
className={cn(
|
||||
firstRoutingPeer && firstDevice && enabled
|
||||
? "bg-green-400 animate-bg-scroll-faster"
|
||||
: "bg-nb-gray-600",
|
||||
)}
|
||||
height={"30px"}
|
||||
bg={"#1c1d21"}
|
||||
config={["4px", "4px", "8px", "7.5px"]}
|
||||
/>
|
||||
)}
|
||||
<DeviceCard device={firstRoutingPeer} />
|
||||
</div>
|
||||
|
||||
<div className={"flex flex-col items-center justify-center relative"}>
|
||||
{firstRoutingPeer && (
|
||||
<Line
|
||||
className={cn(
|
||||
firstRoutingPeer && firstDevice && enabled
|
||||
? "bg-green-400 animate-bg-scroll"
|
||||
: "bg-nb-gray-600",
|
||||
)}
|
||||
height={firstDevice && firstRoutingPeer ? "65px" : "25px"}
|
||||
bg={"#1c1d21"}
|
||||
/>
|
||||
)}
|
||||
<DeviceCard device={firstDevice} />
|
||||
{(!firstDevice || !firstRoutingPeer) && (
|
||||
<WaitingForDevice
|
||||
text={
|
||||
!firstRoutingPeer
|
||||
? "Waiting for your routing peer to connect"
|
||||
: "Waiting for your own device to connect"
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{firstDevice && firstRoutingPeer && (
|
||||
<div
|
||||
className={
|
||||
"absolute top-0 left-1/2 -translate-x-1/2 bg-nb-gray-940 p-1 mt-[20px]"
|
||||
}
|
||||
>
|
||||
{enabled ? (
|
||||
<ShieldCheckIcon size={16} className={"text-green-500"} />
|
||||
) : (
|
||||
<ShieldXIcon size={16} className={"text-red-500"} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const WaitingForDevice = ({
|
||||
text = "Waiting for your first device to connect",
|
||||
}: {
|
||||
text: string;
|
||||
}) => {
|
||||
return (
|
||||
<div className={"flex flex-col items-center justify-center mt-3"}>
|
||||
<div className="relative h-10 w-10 mt-4">
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="h-10 w-10 rounded-full bg-netbird/10 border border-netbird/60 animate-slow-ping "></div>
|
||||
</div>
|
||||
<div className="absolute top-1/2 left-1/2 h-4 w-4 -translate-x-1/2 -translate-y-1/2 rounded-full bg-netbird z-10" />
|
||||
</div>
|
||||
<div className="text-sm font-light animate-slow-pulse mt-6">{text}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type DeviceCardProps = {
|
||||
device?: Peer;
|
||||
resource?: NetworkResource;
|
||||
};
|
||||
|
||||
export const DeviceCard = ({ device, resource }: DeviceCardProps) => {
|
||||
if (!device && !resource) return;
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex shrink-0 items-center gap-2.5 text-nb-gray-300 text-left py-1 pl-3 pr-4 rounded-md group/machine my-0 w-[200px]",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-center rounded-md h-9 w-9 shrink-0 bg-nb-gray-900 transition-all",
|
||||
"group-hover:bg-nb-gray-800 relative",
|
||||
)}
|
||||
>
|
||||
{device && <PeerOSIcon os={device.os} />}
|
||||
{resource?.type && <ResourceIcon type={resource.type} />}
|
||||
|
||||
{device?.country_code && (
|
||||
<div className={"absolute -bottom-[4px] -right-[4px]"}>
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-center rounded-full border-[3px] shrink-0",
|
||||
"border-nb-gray-940",
|
||||
)}
|
||||
>
|
||||
<RoundedFlag country={device?.country_code} size={10} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className={"flex flex-col gap-0 justify-center mt-2 leading-tight"}>
|
||||
<span
|
||||
className={
|
||||
"mb-1.5 font-normal text-[0.85rem] text-nb-gray-100 flex items-center gap-2"
|
||||
}
|
||||
>
|
||||
<TruncatedText
|
||||
text={device?.name || resource?.name || "Unknown"}
|
||||
maxWidth={"150px"}
|
||||
hideTooltip={true}
|
||||
/>
|
||||
</span>
|
||||
<span
|
||||
className={
|
||||
"text-sm font-normal text-nb-gray-400 -top-[0.3rem] relative"
|
||||
}
|
||||
>
|
||||
{device?.ip || resource?.address}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const PeerOSIcon = ({ os }: { os: string }) => {
|
||||
const osType = getOperatingSystem(os);
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-center grayscale brightness-[100%] contrast-[40%]",
|
||||
"w-4 h-4 shrink-0",
|
||||
osType === OperatingSystem.WINDOWS && "p-[2.5px]",
|
||||
osType === OperatingSystem.APPLE && "p-[2.7px]",
|
||||
osType === OperatingSystem.FREEBSD && "p-[1.5px]",
|
||||
)}
|
||||
>
|
||||
<OSLogo os={os} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ResourceIcon = ({
|
||||
type,
|
||||
size = 15,
|
||||
}: {
|
||||
type: "domain" | "host" | "subnet";
|
||||
size?: number;
|
||||
}) => {
|
||||
switch (type) {
|
||||
case "domain":
|
||||
return <GlobeIcon size={size} />;
|
||||
case "subnet":
|
||||
return <NetworkIcon size={size} />;
|
||||
case "host":
|
||||
return <WorkflowIcon size={size} />;
|
||||
default:
|
||||
return <WorkflowIcon size={size} />;
|
||||
}
|
||||
};
|
||||
|
||||
const Line = ({
|
||||
className,
|
||||
height = "100%",
|
||||
bg = "#1c1d21",
|
||||
config = ["2px", "3px", "6px", "8.2px"],
|
||||
}: {
|
||||
className?: string;
|
||||
height?: string;
|
||||
bg?: string;
|
||||
config?: string[];
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
className,
|
||||
"w-[1px] overflow-hidden relative -left-[0.5px]",
|
||||
)}
|
||||
style={{
|
||||
height: height,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={cn("absolute inset-0 w-full", className)}
|
||||
style={{
|
||||
backgroundImage: `repeating-linear-gradient(to bottom, transparent 0%, transparent ${config?.[0]}, ${bg} ${config?.[1]}, ${bg} ${config?.[2]})`,
|
||||
backgroundSize: `100% ${config?.[3]}`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
131
src/modules/onboarding/OnboardingEnd.tsx
Normal file
131
src/modules/onboarding/OnboardingEnd.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
import { useOidcUser } from "@axa-fr/react-oidc";
|
||||
import Button from "@components/Button";
|
||||
import { ArrowRightIcon, PlayIcon } from "lucide-react";
|
||||
import Image, { StaticImageData } from "next/image";
|
||||
import Link from "next/link";
|
||||
import * as React from "react";
|
||||
import ACLImage from "@/assets/onboarding/acl.png";
|
||||
import ActivityImage from "@/assets/onboarding/activity.png";
|
||||
import PostureCheckImage from "@/assets/onboarding/posture.png";
|
||||
|
||||
type Props = {
|
||||
onFinish?: () => void;
|
||||
};
|
||||
|
||||
export const OnboardingEnd = ({ onFinish }: Props) => {
|
||||
const { oidcUser: user } = useOidcUser();
|
||||
const name = user?.given_name || user?.name || user?.preferred_username;
|
||||
|
||||
const title = name ? `Congratulations, ${name}!` : "Congratulations!";
|
||||
|
||||
return (
|
||||
<div className={"relative flex flex-col h-full justify-between"}>
|
||||
<div>
|
||||
<h1 className={"text-xl text-center max-w-sm mx-auto"}>
|
||||
{title} <br />
|
||||
You’ve completed the onboarding.
|
||||
</h1>
|
||||
<div
|
||||
className={
|
||||
"text-sm text-nb-gray-300 font-light mt-2 block text-center sm:px-4"
|
||||
}
|
||||
>
|
||||
What’s next? Check out these guides to get the most out of NetBird. To
|
||||
learn more, explore the dashboard, visit our documentation, or browse
|
||||
our YouTube channel.
|
||||
</div>
|
||||
|
||||
<div className={"mt-8 flex flex-col gap-8"}>
|
||||
<VideoGuide
|
||||
title={"Access Control in Under 5 Minutes"}
|
||||
src={ACLImage}
|
||||
description={
|
||||
"Learn how to manage access for your network resources effectively. Whether you want to restrict access to specific machines or allow certain users to connect."
|
||||
}
|
||||
href={"https://www.youtube.com/watch?v=WtZD_q-g_Jc"}
|
||||
/>
|
||||
<VideoGuide
|
||||
title={"Provision Users & Groups From Your IdP"}
|
||||
src={PostureCheckImage}
|
||||
description={
|
||||
"Learn how to provision users and groups from your identity provider, such as Okta, Azure AD, or Google Workspace, to manage access control in NetBird and automate onboarding and offboarding processes."
|
||||
}
|
||||
href={"https://www.youtube.com/watch?v=RxYWTpf7cgY"}
|
||||
/>
|
||||
<VideoGuide
|
||||
title={"How NetBird Works"}
|
||||
description={
|
||||
"Learn more about how NetBird works, its architecture, and how it can help you build secure networks."
|
||||
}
|
||||
src={ActivityImage}
|
||||
href={"https://www.youtube.com/watch?v=CFa7SY4Up9k&t=261s"}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={"mt-10 flex items-center justify-center"}>
|
||||
<Button variant={"secondaryLighter"} onClick={onFinish}>
|
||||
Go to Dashboard
|
||||
<ArrowRightIcon size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type VideoGuideProps = {
|
||||
src?: string | StaticImageData;
|
||||
title?: string;
|
||||
description?: string;
|
||||
href?: string;
|
||||
};
|
||||
|
||||
const VideoGuide = ({
|
||||
src = ACLImage,
|
||||
title = "Access Control in Under 5 Minutes",
|
||||
description = "Learn how to manage access for your network resources effectively. Whether you want to restrict access to specific machines or allow certain users to connect.",
|
||||
href = "#",
|
||||
}: VideoGuideProps) => {
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
"flex flex-col sm:flex-row gap-3 items-center text-center sm:text-left sm:gap-6"
|
||||
}
|
||||
>
|
||||
<Link
|
||||
className={
|
||||
"border border-nb-gray-900 rounded-lg p-[2px] bg-nb-gray-920 min-w-[160px] max-w-[160px] relative group hover:bg-nb-gray-900 transition-all"
|
||||
}
|
||||
target={"_blank"}
|
||||
href={href}
|
||||
>
|
||||
<span
|
||||
className={
|
||||
"flex items-center justify-center absolute left-0 top-0 h-full w-full"
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={
|
||||
"bg-nb-gray-900/50 group-hover:bg-nb-gray-600/50 backdrop-blur h-8 w-8 flex items-center justify-center rounded-full"
|
||||
}
|
||||
>
|
||||
<PlayIcon size={14} />
|
||||
</div>
|
||||
</span>
|
||||
<Image
|
||||
src={src}
|
||||
alt={title}
|
||||
className={"border border-nb-gray-900 rounded-md"}
|
||||
/>
|
||||
</Link>
|
||||
<div>
|
||||
<div className={"text-md"}>{title}</div>
|
||||
<div
|
||||
className={"text-[0.8rem] text-nb-gray-300 font-light mt-1.5 block"}
|
||||
>
|
||||
{description}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
176
src/modules/onboarding/OnboardingIntent.tsx
Normal file
176
src/modules/onboarding/OnboardingIntent.tsx
Normal file
@@ -0,0 +1,176 @@
|
||||
import FullTooltip from "@components/FullTooltip";
|
||||
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 NetworkRoutesIcon from "@/assets/icons/NetworkRoutesIcon";
|
||||
import PeerIcon from "@/assets/icons/PeerIcon";
|
||||
import {Intent} from "@/modules/onboarding/Onboarding";
|
||||
|
||||
type Props = {
|
||||
onSelect: (intent: Intent) => void,
|
||||
useCases?: string,
|
||||
isBusiness?: boolean
|
||||
};
|
||||
|
||||
export const OnboardingIntent = ({onSelect, useCases, isBusiness}: Props) => {
|
||||
/**
|
||||
* 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]);
|
||||
|
||||
return (
|
||||
<div className={"relative flex flex-col h-full justify-between"}>
|
||||
<div>
|
||||
<h1 className={"text-xl text-center"}>Get started with NetBird</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;
|
||||
};
|
||||
|
||||
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>
|
||||
);
|
||||
};
|
||||
39
src/modules/onboarding/OnboardingPolicy.tsx
Normal file
39
src/modules/onboarding/OnboardingPolicy.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import { ToggleSwitch } from "@components/ToggleSwitch";
|
||||
import { cn } from "@utils/helpers";
|
||||
import { ShieldIcon } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { Policy } from "@/interfaces/Policy";
|
||||
|
||||
type Props = {
|
||||
policy?: Policy;
|
||||
onToggle?: (policy: Policy) => void;
|
||||
};
|
||||
|
||||
export const OnboardingPolicy = ({ policy, onToggle }: Props) => {
|
||||
if (!policy) return;
|
||||
|
||||
return (
|
||||
<label
|
||||
className={cn(
|
||||
"relative block rounded-lg border border-nb-gray-900 px-5 py-3 transition-all",
|
||||
"flex justify-between items-center mt-3 cursor-pointer",
|
||||
)}
|
||||
>
|
||||
<div>
|
||||
<div className="text-nb-gray-100 font-normal text-sm text-left gap-2 flex items-center">
|
||||
<ShieldIcon size={12} className={"shrink-0"} />
|
||||
{policy?.name} Policy
|
||||
</div>
|
||||
<div className={"text-nb-gray-300 text-[0.8rem] text-left mt-0.5"}>
|
||||
{policy?.name.includes("Default")
|
||||
? "Allows connections between all your devices"
|
||||
: policy?.description}
|
||||
</div>
|
||||
</div>
|
||||
<ToggleSwitch
|
||||
onCheckedChange={() => onToggle?.(policy)}
|
||||
checked={policy?.enabled || false}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
};
|
||||
170
src/modules/onboarding/OnboardingProvider.tsx
Normal file
170
src/modules/onboarding/OnboardingProvider.tsx
Normal file
@@ -0,0 +1,170 @@
|
||||
import { useLocalStorage } from "@hooks/useLocalStorage";
|
||||
import useFetchApi, { useApiCall } from "@utils/api";
|
||||
import { isLocalDev, isNetBirdHosted } from "@utils/netbird";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useMemo } from "react";
|
||||
import { useSWRConfig } from "swr";
|
||||
import { HubspotFormField, useAnalytics } from "@/contexts/AnalyticsProvider";
|
||||
import { useLoggedInUser } from "@/contexts/UsersProvider";
|
||||
import { Account } from "@/interfaces/Account";
|
||||
import { Network } from "@/interfaces/Network";
|
||||
import type { Peer } from "@/interfaces/Peer";
|
||||
import { useAccount } from "@/modules/account/useAccount";
|
||||
import {
|
||||
Intent,
|
||||
Onboarding,
|
||||
OnboardingState,
|
||||
} from "@/modules/onboarding/Onboarding";
|
||||
|
||||
type Props = {
|
||||
onSurveySubmit?: (data: {
|
||||
fields: HubspotFormField[];
|
||||
hsId: string;
|
||||
gaId: string;
|
||||
accountId?: string;
|
||||
userId?: string;
|
||||
}) => void;
|
||||
domainCategory?: string;
|
||||
};
|
||||
|
||||
export const OnboardingProvider = ({
|
||||
onSurveySubmit,
|
||||
domainCategory,
|
||||
}: Props) => {
|
||||
const { data: peers } = useFetchApi<Peer[]>("/peers");
|
||||
const accountRequest = useApiCall<Account>("/accounts", true);
|
||||
const account = useAccount();
|
||||
const router = useRouter();
|
||||
const { isOwner, loggedInUser } = useLoggedInUser();
|
||||
const { mutate } = useSWRConfig();
|
||||
const { trackEventV2 } = useAnalytics();
|
||||
const params = useSearchParams();
|
||||
const hsId = params?.get("hs_id") ?? "";
|
||||
const gaId = params?.get("ga_id") ?? "";
|
||||
|
||||
const accountId = account?.id ?? "unknown";
|
||||
const onboardingKey = `netbird-onboarding-flow:${accountId}`;
|
||||
|
||||
// Migrate old onboarding state to new key if needed
|
||||
if (typeof window !== "undefined" && account?.id) {
|
||||
const oldKey = "netbird-onboarding-flow";
|
||||
const oldValue = window.localStorage.getItem(oldKey);
|
||||
const newValue = window.localStorage.getItem(onboardingKey);
|
||||
if (oldValue && !newValue) {
|
||||
window.localStorage.setItem(onboardingKey, oldValue);
|
||||
window.localStorage.removeItem(oldKey);
|
||||
}
|
||||
}
|
||||
|
||||
const [onboarding, setOnboarding] = useLocalStorage<OnboardingState>(
|
||||
onboardingKey,
|
||||
{
|
||||
intent: Intent.P2P,
|
||||
step: 1,
|
||||
},
|
||||
);
|
||||
|
||||
const showOnboarding = useMemo(() => {
|
||||
if (process.env.APP_ENV === "test") return false;
|
||||
if (!account) return false;
|
||||
const isSignupFormPending = isNetBirdHosted()
|
||||
? !!account?.onboarding?.signup_form_pending
|
||||
: false;
|
||||
const show =
|
||||
!!account?.onboarding?.onboarding_flow_pending || isSignupFormPending;
|
||||
return isOwner && show;
|
||||
}, [account, isOwner]);
|
||||
|
||||
const updateAccountMeta = async (meta: Partial<Account["onboarding"]>) => {
|
||||
if (!account) return;
|
||||
await accountRequest
|
||||
.put(
|
||||
{
|
||||
...account,
|
||||
id: account.id,
|
||||
onboarding: {
|
||||
...account.onboarding,
|
||||
...meta,
|
||||
},
|
||||
},
|
||||
`/${account.id}`,
|
||||
)
|
||||
.then(() => mutate("/accounts"));
|
||||
};
|
||||
|
||||
const onSkip = async (intent: Intent, step: number) => {
|
||||
await updateAccountMeta({
|
||||
onboarding_flow_pending: false,
|
||||
});
|
||||
trackEventV2(
|
||||
"Onboarding",
|
||||
`Skipped Onboarding - ${intent} (Step ${step})`,
|
||||
account?.id,
|
||||
loggedInUser?.id,
|
||||
);
|
||||
};
|
||||
|
||||
const onFinish = async (n?: Network) => {
|
||||
await updateAccountMeta({
|
||||
onboarding_flow_pending: false,
|
||||
});
|
||||
trackEventV2(
|
||||
"Onboarding",
|
||||
"Finished Onboarding",
|
||||
account?.id,
|
||||
loggedInUser?.id,
|
||||
);
|
||||
if (n) {
|
||||
// router.push(`/network?id=${n.id}`);
|
||||
router.push("/control-center?tab=networks");
|
||||
} else {
|
||||
router.push("/control-center");
|
||||
}
|
||||
};
|
||||
|
||||
const onTroubleshootingClick = (intent: Intent) => {
|
||||
trackEventV2(
|
||||
"Onboarding",
|
||||
`Troubleshooting - ${intent}`,
|
||||
account?.id,
|
||||
loggedInUser?.id,
|
||||
);
|
||||
};
|
||||
|
||||
const submitSurvey = async (fields: HubspotFormField[]) => {
|
||||
await updateAccountMeta({
|
||||
signup_form_pending: false,
|
||||
});
|
||||
if (isLocalDev()) return;
|
||||
onSurveySubmit?.({
|
||||
fields,
|
||||
hsId,
|
||||
gaId,
|
||||
accountId: account?.id,
|
||||
userId: loggedInUser?.id,
|
||||
});
|
||||
};
|
||||
|
||||
const formSubmitted = isNetBirdHosted()
|
||||
? !account?.onboarding?.signup_form_pending
|
||||
: true;
|
||||
|
||||
return (
|
||||
<>
|
||||
{showOnboarding && peers && (
|
||||
<Onboarding
|
||||
formSubmitted={formSubmitted}
|
||||
isOnboardingPending={!!account?.onboarding?.onboarding_flow_pending}
|
||||
initial={onboarding}
|
||||
setLocalOnboarding={setOnboarding}
|
||||
peers={peers}
|
||||
onSurveySubmit={submitSurvey}
|
||||
onTroubleshootingClick={onTroubleshootingClick}
|
||||
onSkip={onSkip}
|
||||
onFinish={onFinish}
|
||||
domainCategory={domainCategory}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
516
src/modules/onboarding/OnboardingSurvey.tsx
Normal file
516
src/modules/onboarding/OnboardingSurvey.tsx
Normal file
@@ -0,0 +1,516 @@
|
||||
import { useOidcUser } from "@axa-fr/react-oidc";
|
||||
import Button from "@components/Button";
|
||||
import ButtonGroup from "@components/ButtonGroup";
|
||||
import { Checkbox } from "@components/Checkbox";
|
||||
import HelpText from "@components/HelpText";
|
||||
import { Input } from "@components/Input";
|
||||
import { Label } from "@components/Label";
|
||||
import { SegmentedTabs } from "@components/SegmentedTabs";
|
||||
import { SelectDropdown } from "@components/select/SelectDropdown";
|
||||
import { cn } from "@utils/helpers";
|
||||
import {
|
||||
BriefcaseIcon,
|
||||
FolderIcon,
|
||||
Gamepad2,
|
||||
HomeIcon,
|
||||
Laptop,
|
||||
Layers,
|
||||
Server,
|
||||
ShieldCheck,
|
||||
UserIcon,
|
||||
Waypoints,
|
||||
} from "lucide-react";
|
||||
import React, { useMemo, useState } from "react";
|
||||
import { useLoggedInUser } from "@/contexts/UsersProvider";
|
||||
import { HubspotFormField } from "@/contexts/AnalyticsProvider";
|
||||
|
||||
type Props = {
|
||||
domainCategory: string;
|
||||
onSubmit?: (fields: HubspotFormField[]) => void;
|
||||
};
|
||||
|
||||
export const companySizes = [
|
||||
{
|
||||
label: "1-5",
|
||||
value: "1",
|
||||
},
|
||||
{
|
||||
label: "5-50",
|
||||
value: "5",
|
||||
},
|
||||
{
|
||||
label: "50-300",
|
||||
value: "50",
|
||||
},
|
||||
{
|
||||
label: "300-1000",
|
||||
value: "300",
|
||||
},
|
||||
{
|
||||
label: "1000+",
|
||||
value: "1000",
|
||||
},
|
||||
];
|
||||
|
||||
export const referralSourceOptions = [
|
||||
{
|
||||
label: "Search Engines (Google, Bing etc.)",
|
||||
value: "Search Engines (Google, Bing etc.)",
|
||||
},
|
||||
{
|
||||
label: "Coworker or Friend",
|
||||
value: "Coworker or Friend",
|
||||
},
|
||||
{
|
||||
label: "Trade Show or Event",
|
||||
value: "Trade Show or Event",
|
||||
},
|
||||
{
|
||||
label: "Blogs",
|
||||
value: "Blogs",
|
||||
},
|
||||
{
|
||||
label: "Comparison Sites",
|
||||
value: "Comparison Sites",
|
||||
},
|
||||
{
|
||||
label: "Slack",
|
||||
value: "Slack",
|
||||
},
|
||||
{
|
||||
label: "Other",
|
||||
value: "Other",
|
||||
},
|
||||
{
|
||||
label: "NetBird YouTube Channel",
|
||||
value: "NetBird YouTube Channel",
|
||||
},
|
||||
{
|
||||
label: "Other YouTube Channel",
|
||||
value: "Other YouTube Channel",
|
||||
},
|
||||
{
|
||||
label: "NetBird SubReddit",
|
||||
value: "NetBird SubReddit",
|
||||
},
|
||||
{
|
||||
label: "Other Reddit Thread",
|
||||
value: "Other Reddit Thread",
|
||||
},
|
||||
{
|
||||
label: "GitHub",
|
||||
value: "GitHub",
|
||||
},
|
||||
];
|
||||
|
||||
export const OnboardingSurvey = ({ domainCategory, onSubmit }: Props) => {
|
||||
const { oidcUser: user } = useOidcUser();
|
||||
const name = user?.given_name || user?.name || user?.preferred_username;
|
||||
const welcomeMessage = name
|
||||
? `Welcome to NetBird, ${name}!`
|
||||
: "Welcome to NetBird!";
|
||||
|
||||
const isPrivate = domainCategory === "private";
|
||||
const [personalOrBusiness, setPersonalOrBusiness] = useState(
|
||||
isPrivate ? "business" : "personal",
|
||||
);
|
||||
const [companySize, setCompanySize] = useState<string>("");
|
||||
const isCompanySizeSelected = (size: string) => companySize === size;
|
||||
const isBusiness = personalOrBusiness === "business";
|
||||
|
||||
const [homelab, setHomelab] = useState(false);
|
||||
const [remoteAccess, setRemoteAccess] = useState(false);
|
||||
const [homeRemoteAccess, setHomeRemoteAccess] = useState(false);
|
||||
const [fileAccess, setFileAccess] = useState(false);
|
||||
const [gaming, setGaming] = useState(false);
|
||||
const [zeroTrust, setZeroTrust] = useState(false);
|
||||
const [ioT, setIoT] = useState(false);
|
||||
const [siteToSite, setSiteToSite] = useState(false);
|
||||
const [businessVPN, setBusinessVPN] = useState(false);
|
||||
const [referralSource, setReferralSource] = useState("");
|
||||
const [msp, setMsp] = useState(false);
|
||||
|
||||
const [other, setOther] = useState(false);
|
||||
const [otherUseCase, setOtherUseCase] = useState("");
|
||||
const inputRef = React.useRef<HTMLInputElement>(null);
|
||||
|
||||
const { loggedInUser } = useLoggedInUser();
|
||||
|
||||
const getUseCases = () => {
|
||||
const hl = homelab && !isBusiness ? "Homelab Automation" : "";
|
||||
const hra = homeRemoteAccess && !isBusiness ? "Home Remote Access" : "";
|
||||
const fa = fileAccess && !isBusiness ? "File Access" : "";
|
||||
const g = gaming && !isBusiness ? "Gaming" : "";
|
||||
|
||||
const zt = zeroTrust && isBusiness ? "Zero Trust Security" : "";
|
||||
const ra = remoteAccess && isBusiness ? "Employee Remote Access" : "";
|
||||
const bv = businessVPN && isBusiness ? "Business VPN" : "";
|
||||
const st = siteToSite && isBusiness ? "Site-to-Site Connectivity" : "";
|
||||
const iot = ioT && isBusiness ? "IoT (Internet of Things)" : "";
|
||||
const mp = msp && isBusiness ? "MSP (Managed Service Provider)" : "";
|
||||
|
||||
const ou = other ? otherUseCase : "";
|
||||
return [hl, hra, fa, g, zt, ra, bv, st, iot, mp, ou]
|
||||
.filter((s) => s != "")
|
||||
.join(", ");
|
||||
};
|
||||
|
||||
const hasSelectedUseCase = useMemo(() => {
|
||||
if (isBusiness) {
|
||||
return (
|
||||
zeroTrust ||
|
||||
remoteAccess ||
|
||||
businessVPN ||
|
||||
siteToSite ||
|
||||
ioT ||
|
||||
msp ||
|
||||
(other && otherUseCase !== "")
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
homelab ||
|
||||
homeRemoteAccess ||
|
||||
fileAccess ||
|
||||
gaming ||
|
||||
(other && otherUseCase !== "")
|
||||
);
|
||||
}
|
||||
}, [
|
||||
businessVPN,
|
||||
fileAccess,
|
||||
gaming,
|
||||
homeRemoteAccess,
|
||||
homelab,
|
||||
ioT,
|
||||
isBusiness,
|
||||
other,
|
||||
otherUseCase,
|
||||
remoteAccess,
|
||||
siteToSite,
|
||||
zeroTrust,
|
||||
msp,
|
||||
]);
|
||||
|
||||
const hasCompanySizeSelected = useMemo(() => {
|
||||
return companySize !== "";
|
||||
}, [companySize]);
|
||||
|
||||
const hasHowDidYouHearAboutUsSelected = useMemo(() => {
|
||||
return referralSource !== "";
|
||||
}, [referralSource]);
|
||||
|
||||
const canSubmit = useMemo(() => {
|
||||
if (isBusiness) {
|
||||
return (
|
||||
hasCompanySizeSelected &&
|
||||
hasSelectedUseCase &&
|
||||
hasHowDidYouHearAboutUsSelected
|
||||
);
|
||||
} else {
|
||||
return hasSelectedUseCase && hasHowDidYouHearAboutUsSelected;
|
||||
}
|
||||
}, [
|
||||
hasSelectedUseCase,
|
||||
isBusiness,
|
||||
hasCompanySizeSelected,
|
||||
hasHowDidYouHearAboutUsSelected,
|
||||
]);
|
||||
|
||||
const randomizedOptions = useMemo(() => {
|
||||
return referralSourceOptions.sort(() => Math.random() - 0.5);
|
||||
}, []);
|
||||
|
||||
const submitForm = () => {
|
||||
let fields: HubspotFormField[] = [];
|
||||
try {
|
||||
// Fallback: use OIDC user email if loggedInUser?.email is missing
|
||||
const email = loggedInUser?.email || user?.email || "";
|
||||
if (loggedInUser || user) {
|
||||
fields = [
|
||||
{
|
||||
name: "email",
|
||||
value: email,
|
||||
},
|
||||
{
|
||||
name: "is_company",
|
||||
value: personalOrBusiness === "business" ? "Business" : "Personal",
|
||||
},
|
||||
{
|
||||
name: "use_case",
|
||||
value: getUseCases(),
|
||||
},
|
||||
{
|
||||
name: "how_did_you_hear_about_us",
|
||||
value: referralSource || "Other",
|
||||
},
|
||||
];
|
||||
|
||||
let accountCategory;
|
||||
switch (personalOrBusiness) {
|
||||
case "business":
|
||||
accountCategory = "business";
|
||||
break;
|
||||
case "personal":
|
||||
accountCategory = "personal";
|
||||
break;
|
||||
default:
|
||||
accountCategory = "unknown";
|
||||
}
|
||||
|
||||
fields.push({
|
||||
name: "account_category",
|
||||
value: accountCategory,
|
||||
});
|
||||
|
||||
if (domainCategory) {
|
||||
if (domainCategory === "business") {
|
||||
fields.push({
|
||||
name: "0-2/domain",
|
||||
value: email.split("@")[1] || "",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (personalOrBusiness === "business" && companySize !== "") {
|
||||
fields.push({
|
||||
name: "planned_users",
|
||||
value: companySize,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (e) {}
|
||||
onSubmit?.(fields);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={"relative"}>
|
||||
<h1 className={"text-xl text-center"}>{welcomeMessage}</h1>
|
||||
<div
|
||||
className={
|
||||
"text-sm text-nb-gray-300 font-light mt-2 block text-center max-w-md px-10"
|
||||
}
|
||||
>
|
||||
Share a few details about your use case to help us get you started
|
||||
smoothly.
|
||||
</div>
|
||||
<div className={"flex flex-col mt-8 z-0 gap-8"}>
|
||||
<SegmentedTabs
|
||||
value={personalOrBusiness}
|
||||
onChange={setPersonalOrBusiness}
|
||||
>
|
||||
<SegmentedTabs.List className={"rounded-lg border"}>
|
||||
<SegmentedTabs.Trigger value={"business"}>
|
||||
<BriefcaseIcon size={16} />
|
||||
Business
|
||||
</SegmentedTabs.Trigger>
|
||||
<SegmentedTabs.Trigger value={"personal"}>
|
||||
<UserIcon size={16} />
|
||||
Personal
|
||||
</SegmentedTabs.Trigger>
|
||||
</SegmentedTabs.List>
|
||||
</SegmentedTabs>
|
||||
|
||||
{personalOrBusiness === "business" && (
|
||||
<div className={"flex w-full flex-col gap-2"}>
|
||||
<div>
|
||||
<Label>
|
||||
How many people in your company will use NetBird?
|
||||
<RequiredAsterisk />
|
||||
</Label>
|
||||
</div>
|
||||
<ButtonGroup>
|
||||
{companySizes.map((size) => (
|
||||
<ButtonGroup.Button
|
||||
key={size.value}
|
||||
className={"w-full"}
|
||||
onClick={() => setCompanySize(size.value)}
|
||||
variant={
|
||||
isCompanySizeSelected(size.value)
|
||||
? "tertiary"
|
||||
: "secondary"
|
||||
}
|
||||
>
|
||||
{size.label}
|
||||
</ButtonGroup.Button>
|
||||
))}
|
||||
</ButtonGroup>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={"flex w-full flex-col gap-2"}>
|
||||
<Label>
|
||||
How did you hear about NetBird?
|
||||
<RequiredAsterisk />
|
||||
</Label>
|
||||
<SelectDropdown
|
||||
value={referralSource}
|
||||
onChange={setReferralSource}
|
||||
options={randomizedOptions}
|
||||
showValues={false}
|
||||
placeholder={"Please select an option..."}
|
||||
variant={"dropdown"}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={"flex w-full flex-col gap-2"}>
|
||||
<div>
|
||||
<Label>
|
||||
How do you plan to use NetBird?
|
||||
<RequiredAsterisk />
|
||||
</Label>
|
||||
<HelpText className={"mt-1.5"}>
|
||||
You can also select multiple use cases.
|
||||
</HelpText>
|
||||
</div>
|
||||
|
||||
<div className={"flex flex-col gap-3"}>
|
||||
{isBusiness ? (
|
||||
<>
|
||||
<OnboardingCheckbox value={zeroTrust} setValue={setZeroTrust}>
|
||||
<ShieldCheck size={16} />
|
||||
Zero Trust Security
|
||||
</OnboardingCheckbox>
|
||||
<OnboardingCheckbox
|
||||
value={remoteAccess}
|
||||
setValue={setRemoteAccess}
|
||||
>
|
||||
<Laptop size={16} />
|
||||
Employee Remote Access
|
||||
</OnboardingCheckbox>
|
||||
<OnboardingCheckbox
|
||||
value={businessVPN}
|
||||
setValue={setBusinessVPN}
|
||||
>
|
||||
<BriefcaseIcon size={16} />
|
||||
Business VPN
|
||||
</OnboardingCheckbox>
|
||||
<OnboardingCheckbox
|
||||
value={siteToSite}
|
||||
setValue={setSiteToSite}
|
||||
>
|
||||
<Layers size={16} />
|
||||
Site-to-Site Connectivity
|
||||
</OnboardingCheckbox>
|
||||
<OnboardingCheckbox value={ioT} setValue={setIoT}>
|
||||
<Waypoints size={16} />
|
||||
IoT (Internet of Things)
|
||||
</OnboardingCheckbox>
|
||||
<OnboardingCheckbox value={msp} setValue={setMsp}>
|
||||
<Server size={15} />
|
||||
MSP (Managed Service Provider)
|
||||
</OnboardingCheckbox>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<OnboardingCheckbox value={homelab} setValue={setHomelab}>
|
||||
<HomeIcon size={16} />
|
||||
Homelab Automation
|
||||
</OnboardingCheckbox>
|
||||
<OnboardingCheckbox
|
||||
value={homeRemoteAccess}
|
||||
setValue={setHomeRemoteAccess}
|
||||
>
|
||||
<Laptop size={16} />
|
||||
Home Remote Access
|
||||
</OnboardingCheckbox>
|
||||
<OnboardingCheckbox
|
||||
value={fileAccess}
|
||||
setValue={setFileAccess}
|
||||
>
|
||||
<FolderIcon size={16} />
|
||||
File Access
|
||||
</OnboardingCheckbox>
|
||||
<OnboardingCheckbox value={gaming} setValue={setGaming}>
|
||||
<Gamepad2 size={16} />
|
||||
Gaming
|
||||
</OnboardingCheckbox>
|
||||
</>
|
||||
)}
|
||||
|
||||
<label
|
||||
className={
|
||||
"text-neutral-500 dark:text-nb-gray-300 font-normal flex items-center gap-4 cursor-pointer"
|
||||
}
|
||||
>
|
||||
<Checkbox
|
||||
checked={other}
|
||||
onCheckedChange={(v) => {
|
||||
setOther(!other);
|
||||
inputRef.current?.focus();
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className={
|
||||
"flex items-center gap-1.5 whitespace-nowrap text-sm select-none"
|
||||
}
|
||||
>
|
||||
Other (Please specify)
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
!other && "!h-0 opacity-0",
|
||||
"mt-2",
|
||||
other && "mb-3",
|
||||
)}
|
||||
>
|
||||
<Input
|
||||
ref={inputRef}
|
||||
placeholder={
|
||||
isBusiness
|
||||
? "e.g. DNS Management, File Access"
|
||||
: "e.g. DNS Management, IoT"
|
||||
}
|
||||
value={otherUseCase}
|
||||
onChange={(e) => setOtherUseCase(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant={"primary"}
|
||||
className={"w-full mt-4"}
|
||||
onClick={submitForm}
|
||||
disabled={!canSubmit}
|
||||
>
|
||||
Continue
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const OnboardingCheckbox = ({
|
||||
value,
|
||||
setValue,
|
||||
children,
|
||||
}: {
|
||||
value: boolean;
|
||||
setValue: (value: boolean) => void;
|
||||
children: React.ReactNode;
|
||||
}) => {
|
||||
return (
|
||||
<label
|
||||
className={
|
||||
"text-neutral-500 dark:text-nb-gray-300 font-normal flex items-center gap-4 cursor-pointer"
|
||||
}
|
||||
>
|
||||
<Checkbox checked={value} onCheckedChange={setValue} />
|
||||
<div
|
||||
className={
|
||||
"flex items-center gap-1.5 whitespace-nowrap text-sm select-none"
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
};
|
||||
|
||||
const RequiredAsterisk = () => (
|
||||
<span className={"text-red-500 relative -top-[2.5px]"}>*</span>
|
||||
);
|
||||
259
src/modules/onboarding/networks/OnboardingAddResource.tsx
Normal file
259
src/modules/onboarding/networks/OnboardingAddResource.tsx
Normal file
@@ -0,0 +1,259 @@
|
||||
import Button from "@components/Button";
|
||||
import { notify } from "@components/Notification";
|
||||
import { RadioCard, RadioCardGroup } from "@components/RadioCard";
|
||||
import { useApiCall } from "@utils/api";
|
||||
import { GlobeIcon, NetworkIcon, WorkflowIcon } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useSWRConfig } from "swr";
|
||||
import { useGroups } from "@/contexts/GroupsProvider";
|
||||
import { Group } from "@/interfaces/Group";
|
||||
import { Network, NetworkResource } from "@/interfaces/Network";
|
||||
import { Policy } from "@/interfaces/Policy";
|
||||
import { ResourceSingleAddressInput } from "@/modules/networks/resources/ResourceSingleAddressInput";
|
||||
|
||||
type Props = {
|
||||
onNetworkCreation?: (network: Network) => void;
|
||||
onResourceCreation?: (resource: NetworkResource) => void;
|
||||
onBack: () => void;
|
||||
};
|
||||
|
||||
export const OnboardingAddResource = ({
|
||||
onNetworkCreation,
|
||||
onResourceCreation,
|
||||
onBack,
|
||||
}: Props) => {
|
||||
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 allGroupId = groups?.find((g) => g.name === "All")?.id;
|
||||
|
||||
/**
|
||||
* 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 (!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: 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 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,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 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 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";
|
||||
if (resourceType === "subnet") return "e.g., 192.168.1.0/24";
|
||||
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"}>Add your first resource</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 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"}
|
||||
/>
|
||||
<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}
|
||||
/>
|
||||
)}
|
||||
|
||||
<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>
|
||||
);
|
||||
};
|
||||
185
src/modules/onboarding/networks/OnboardingAddRoutingPeer.tsx
Normal file
185
src/modules/onboarding/networks/OnboardingAddRoutingPeer.tsx
Normal file
@@ -0,0 +1,185 @@
|
||||
import Button from "@components/Button";
|
||||
import { Modal, ModalContent } from "@components/modal/Modal";
|
||||
import { notify } from "@components/Notification";
|
||||
import { useApiCall } from "@utils/api";
|
||||
import { cn } from "@utils/helpers";
|
||||
import { CopyIcon, DownloadIcon, KeyRoundIcon } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useGroups } from "@/contexts/GroupsProvider";
|
||||
import { Group } from "@/interfaces/Group";
|
||||
import { Network, NetworkRouter } from "@/interfaces/Network";
|
||||
import { Peer } from "@/interfaces/Peer";
|
||||
import { SetupKey } from "@/interfaces/SetupKey";
|
||||
import { SetupModalContent } from "@/modules/setup-netbird-modal/SetupModal";
|
||||
|
||||
type Props = {
|
||||
network?: Network;
|
||||
peers?: Peer[];
|
||||
onRoutingPeerAdded: (peer: Peer) => void;
|
||||
};
|
||||
|
||||
export const OnboardingAddRoutingPeer = ({
|
||||
network,
|
||||
peers,
|
||||
onRoutingPeerAdded,
|
||||
}: Props) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [setupKey, setSetupKey] = useState<SetupKey>();
|
||||
const { groups } = useGroups();
|
||||
const setupKeyRequest = useApiCall<SetupKey>("/setup-keys", true);
|
||||
const groupRequest = useApiCall<Group>("/groups", true);
|
||||
const routerRequest = useApiCall<NetworkRouter>("/networks", true);
|
||||
|
||||
/**
|
||||
* Generate a new setup key for the routing peer
|
||||
*/
|
||||
const generateSetupKey = async () => {
|
||||
let routingPeerGroup = groups?.find(
|
||||
(group) => group.name === "Routing Peers",
|
||||
);
|
||||
if (!routingPeerGroup) {
|
||||
routingPeerGroup = await groupRequest.post({
|
||||
name: "Routing Peers",
|
||||
});
|
||||
}
|
||||
|
||||
notify({
|
||||
title: "Setup Key Created",
|
||||
description: "Successfully copied to clipboard.",
|
||||
loadingMessage: "Generating setup key...",
|
||||
promise: setupKeyRequest
|
||||
.post({
|
||||
name: "Routing Peer (My First Network)",
|
||||
type: "one-off",
|
||||
expires_in: 24 * 60 * 60, // 1 day expiration
|
||||
revoked: false,
|
||||
auto_groups: routingPeerGroup ? [routingPeerGroup.id] : [],
|
||||
usage_limit: 1,
|
||||
ephemeral: false,
|
||||
allow_extra_dns_labels: false,
|
||||
})
|
||||
.then((setupKey) => {
|
||||
setSetupKey(setupKey);
|
||||
copySetupKey(setupKey.key);
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Detect routing peer based on group and add it to the network
|
||||
*/
|
||||
useEffect(() => {
|
||||
const routingPeer = peers?.find(
|
||||
(p) => p.groups?.some((g) => g.name === "Routing Peers"),
|
||||
);
|
||||
const hasNetworkRoutingPeer =
|
||||
network?.routers?.find((r) => r === routingPeer?.id) !== undefined;
|
||||
if (routingPeer && network && !hasNetworkRoutingPeer) {
|
||||
routerRequest
|
||||
.post(
|
||||
{
|
||||
peer: routingPeer.id,
|
||||
metric: 9999,
|
||||
masquerade: true,
|
||||
enabled: true,
|
||||
},
|
||||
`/${network.id}/routers`,
|
||||
)
|
||||
.then(() => {
|
||||
onRoutingPeerAdded(routingPeer);
|
||||
});
|
||||
}
|
||||
}, [network, peers]);
|
||||
|
||||
/**
|
||||
* Copy the setup key to clipboard
|
||||
*/
|
||||
const copySetupKey = async (key: string, showMessage = false) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(key || "");
|
||||
if (showMessage) {
|
||||
notify({
|
||||
title: "Setup Key Copied",
|
||||
description: "Successfully copied to clipboard.",
|
||||
});
|
||||
}
|
||||
} catch (e) {}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={"relative flex flex-col h-full gap-4"}>
|
||||
<div>
|
||||
<h1 className={"text-xl text-center"}>
|
||||
Add a routing peer and get the traffic flowing
|
||||
</h1>
|
||||
<div
|
||||
className={
|
||||
"text-sm text-nb-gray-300 font-light mt-2 block text-center sm:px-4"
|
||||
}
|
||||
>
|
||||
Think of a routing peer as a connector to your internal network.
|
||||
It runs NetBird and lets your remote devices access internal resources, while enforcing access control policies.
|
||||
</div>
|
||||
<div
|
||||
className={
|
||||
"text-sm text-nb-gray-300 font-light mt-2 block text-center sm:px-4"
|
||||
}
|
||||
>
|
||||
Generate a setup key and install NetBird on that machine.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"relative block rounded-lg border border-nb-gray-900 px-5 py-3 transition-all",
|
||||
"flex justify-between items-center mt-3",
|
||||
)}
|
||||
>
|
||||
<div>
|
||||
<div className="text-nb-gray-100 font-normal text-sm text-left gap-2 flex items-center">
|
||||
<KeyRoundIcon size={12} />
|
||||
Setup-Key
|
||||
</div>
|
||||
<div className={"text-nb-gray-300 text-[0.8rem] text-left mt-0.5"}>
|
||||
{setupKey?.key || "Not yet generated"}
|
||||
</div>
|
||||
</div>
|
||||
{setupKey ? (
|
||||
<Button
|
||||
variant={"secondary"}
|
||||
onClick={() => copySetupKey(setupKey.key, true)}
|
||||
>
|
||||
<CopyIcon size={14} />
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant={"primary"} onClick={generateSetupKey} size={"xs"}>
|
||||
Generate Setup Key
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant={"primary"}
|
||||
className={""}
|
||||
disabled={!setupKey}
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
<DownloadIcon size={16} />
|
||||
Install Routing Peer
|
||||
</Button>
|
||||
|
||||
{setupKey && (
|
||||
<Modal open={open} onOpenChange={setOpen}>
|
||||
<ModalContent>
|
||||
<SetupModalContent
|
||||
hostname={"routing-peer"}
|
||||
title={"Install NetBird"}
|
||||
setupKey={setupKey.key}
|
||||
/>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
95
src/modules/onboarding/networks/OnboardingAddUserDevice.tsx
Normal file
95
src/modules/onboarding/networks/OnboardingAddUserDevice.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
import Button from "@components/Button";
|
||||
import { Modal, ModalContent } from "@components/modal/Modal";
|
||||
import { useApiCall } from "@utils/api";
|
||||
import { DownloadIcon } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useSWRConfig } from "swr";
|
||||
import { Group, GroupPeer } from "@/interfaces/Group";
|
||||
import { Peer } from "@/interfaces/Peer";
|
||||
import { Policy } from "@/interfaces/Policy";
|
||||
import { SetupModalContent } from "@/modules/setup-netbird-modal/SetupModal";
|
||||
|
||||
type Props = {
|
||||
device?: Peer;
|
||||
policy?: Policy;
|
||||
onNext?: () => void;
|
||||
};
|
||||
|
||||
export const OnboardingAddUserDevice = ({ device, policy, onNext }: Props) => {
|
||||
const groupRequest = useApiCall<Group>("/groups", true);
|
||||
const { mutate } = useSWRConfig();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const usersGroup = useMemo(() => {
|
||||
let rule = policy?.rules?.[0];
|
||||
const sourceGroups = rule?.sources as Group[];
|
||||
return sourceGroups?.find((g) => g.name === "Users");
|
||||
}, [policy]);
|
||||
|
||||
const hasDeviceUsersGroup = device?.groups?.find((g) => g.name === "Users");
|
||||
|
||||
/**
|
||||
* Detect the device and add it to the "Users" group
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!hasDeviceUsersGroup && usersGroup && device) {
|
||||
let peersOfGroup = (usersGroup.peers as GroupPeer[]) || [];
|
||||
let newPeers = peersOfGroup
|
||||
.map((p) => p.id)
|
||||
.filter((x) => x !== undefined);
|
||||
if (device?.id) newPeers.push(device.id);
|
||||
groupRequest
|
||||
.put(
|
||||
{
|
||||
...usersGroup,
|
||||
peers: newPeers,
|
||||
},
|
||||
`/${usersGroup.id}`,
|
||||
)
|
||||
.then(() => {
|
||||
mutate("/peers");
|
||||
mutate("/groups");
|
||||
});
|
||||
}
|
||||
}, [usersGroup, device, hasDeviceUsersGroup]);
|
||||
|
||||
/**
|
||||
* Continue to next step once device is recognized
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (device && hasDeviceUsersGroup) {
|
||||
onNext?.();
|
||||
}
|
||||
}, [device, hasDeviceUsersGroup]);
|
||||
|
||||
return (
|
||||
<div className={"relative flex flex-col h-full gap-4"}>
|
||||
<div>
|
||||
<h1 className={"text-xl text-center max-w-sm mx-auto"}>
|
||||
{"Time to add your client device"}
|
||||
</h1>
|
||||
<div
|
||||
className={
|
||||
"text-sm text-nb-gray-300 font-light mt-2 block text-center sm:px-4"
|
||||
}
|
||||
>
|
||||
{`Your first resource and routing peer are all set. Now, take your device, install NetBird, and let's get you connected.`}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={"flex items-center justify-center mt-3"}>
|
||||
<Button variant={"primary"} onClick={() => setOpen(true)}>
|
||||
<DownloadIcon size={16} />
|
||||
Install NetBird
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Modal open={open} onOpenChange={setOpen}>
|
||||
<ModalContent>
|
||||
<SetupModalContent title={"Install NetBird"} hideDocker={true} />
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
52
src/modules/onboarding/networks/OnboardingExplainPolicy.tsx
Normal file
52
src/modules/onboarding/networks/OnboardingExplainPolicy.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import Button from "@components/Button";
|
||||
import * as React from "react";
|
||||
import { Policy } from "@/interfaces/Policy";
|
||||
import { OnboardingPolicy } from "@/modules/onboarding/OnboardingPolicy";
|
||||
|
||||
type Props = {
|
||||
policy?: Policy;
|
||||
onNext?: () => void;
|
||||
onToggle?: (policy: Policy) => void;
|
||||
};
|
||||
|
||||
export const OnboardingExplainPolicy = ({
|
||||
policy,
|
||||
onNext,
|
||||
onToggle,
|
||||
}: Props) => {
|
||||
return (
|
||||
<div className={"relative flex flex-col h-full gap-4"}>
|
||||
<div>
|
||||
<h1 className={"text-xl text-center max-w-sm mx-auto"}>
|
||||
{`Set the rules. You're in control`}
|
||||
</h1>
|
||||
<div
|
||||
className={
|
||||
"text-sm text-nb-gray-300 font-light mt-2 block text-center sm:px-4"
|
||||
}
|
||||
>
|
||||
{`NetBird makes it easy for admins to enforce least-privilege access with access control policies.
|
||||
We've already created one for your resource during onboarding.`}
|
||||
</div>
|
||||
|
||||
{policy && (
|
||||
<div
|
||||
className={
|
||||
"text-sm text-nb-gray-300 font-light mt-2 block text-center sm:px-4"
|
||||
}
|
||||
>
|
||||
Flip the switch, then try pinging your resource again to see how it affects the connection.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<OnboardingPolicy policy={policy} onToggle={onToggle} />
|
||||
</div>
|
||||
|
||||
<Button variant={"primary"} onClick={onNext}>
|
||||
Continue
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
102
src/modules/onboarding/networks/OnboardingTestResource.tsx
Normal file
102
src/modules/onboarding/networks/OnboardingTestResource.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
import Button from "@components/Button";
|
||||
import Code from "@components/Code";
|
||||
import InlineLink from "@components/InlineLink";
|
||||
import { Modal, ModalContent } from "@components/modal/Modal";
|
||||
import Steps from "@components/Steps";
|
||||
import { cn } from "@utils/helpers";
|
||||
import { ExternalLinkIcon } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { NetworkResource } from "@/interfaces/Network";
|
||||
import { Peer } from "@/interfaces/Peer";
|
||||
import { SetupModalContent } from "@/modules/setup-netbird-modal/SetupModal";
|
||||
|
||||
type Props = {
|
||||
resource?: NetworkResource;
|
||||
device?: Peer;
|
||||
onNext?: () => void;
|
||||
onTroubleshootingClick?: () => void;
|
||||
};
|
||||
|
||||
export const OnboardingTestResource = ({
|
||||
resource,
|
||||
device,
|
||||
onNext,
|
||||
onTroubleshootingClick,
|
||||
}: Props) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const isSubnet = resource?.type === "subnet";
|
||||
const isWildCard = resource?.address.includes("*");
|
||||
const isHost = resource?.type === "host";
|
||||
|
||||
const pingAddress = useMemo(() => {
|
||||
let a = resource?.address || "";
|
||||
if (isHost && a.endsWith("/32")) {
|
||||
a = a.slice(0, -3);
|
||||
}
|
||||
if (isWildCard) return `(any subdomain of ${a})`;
|
||||
return isSubnet ? `(resource ip in your subnet)` : a;
|
||||
}, [isWildCard, isHost, isSubnet, resource?.address]);
|
||||
|
||||
return (
|
||||
<div className={"relative flex flex-col h-full gap-4"}>
|
||||
<div>
|
||||
<h1 className={"text-xl text-center max-w-sm mx-auto"}>
|
||||
{`Let's put that connection to the test`}
|
||||
</h1>
|
||||
<div
|
||||
className={
|
||||
"text-sm text-nb-gray-300 font-light mt-2 block text-center sm:px-4"
|
||||
}
|
||||
>
|
||||
{`Nice work connecting your client device! Now, let’s have a little fun and test if it can reach your resource.`}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Steps className={"stepper-bg-variant"}>
|
||||
<Steps.Step step={1}>
|
||||
<p className={"!text-nb-gray-300"}>
|
||||
Open your command line and run this command from{" "}
|
||||
<span className={cn(device && "text-white")}>
|
||||
{device?.name || "your device"}
|
||||
</span>{" "}
|
||||
to ping your resource.
|
||||
</p>
|
||||
<Code showCopyIcon={!isSubnet && !isWildCard}>
|
||||
ping {pingAddress}
|
||||
</Code>
|
||||
</Steps.Step>
|
||||
<Steps.Step step={2} line={false} className={"pb-0"} disabled={!device}>
|
||||
<p className={"!text-nb-gray-300"}>
|
||||
Everything working? Great! You can now continue with the onboarding.
|
||||
If something isn’t right, please check our{" "}
|
||||
<InlineLink
|
||||
href={"https://docs.netbird.io/how-to/troubleshooting-client"}
|
||||
target={"_blank"}
|
||||
onClick={onTroubleshootingClick}
|
||||
>
|
||||
troubleshooting guide
|
||||
<ExternalLinkIcon size={12} />
|
||||
</InlineLink>
|
||||
</p>
|
||||
<div className={"mt-2"}>
|
||||
<Button
|
||||
variant={"secondaryLighter"}
|
||||
onClick={onNext}
|
||||
className={"w-full"}
|
||||
>
|
||||
It works! - Continue
|
||||
</Button>
|
||||
</div>
|
||||
</Steps.Step>
|
||||
</Steps>
|
||||
|
||||
<Modal open={open} onOpenChange={setOpen}>
|
||||
<ModalContent>
|
||||
<SetupModalContent title={"Install NetBird"} />
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,52 @@
|
||||
import Button from "@components/Button";
|
||||
import * as React from "react";
|
||||
import { Policy } from "@/interfaces/Policy";
|
||||
import { OnboardingPolicy } from "@/modules/onboarding/OnboardingPolicy";
|
||||
|
||||
type Props = {
|
||||
policy?: Policy;
|
||||
onNext?: () => void;
|
||||
onToggle?: (policy: Policy) => void;
|
||||
};
|
||||
|
||||
export const OnboardingExplainDefaultPolicy = ({
|
||||
policy,
|
||||
onNext,
|
||||
onToggle,
|
||||
}: Props) => {
|
||||
return (
|
||||
<div className={"relative flex flex-col h-full gap-4"}>
|
||||
<div>
|
||||
<h1 className={"text-xl text-center max-w-sm mx-auto"}>
|
||||
{`Set the rules. You're in control`}
|
||||
</h1>
|
||||
<div
|
||||
className={
|
||||
"text-sm text-nb-gray-300 font-light mt-2 block text-center sm:px-4"
|
||||
}
|
||||
>
|
||||
{`With NetBird, you decide who gets access to what.
|
||||
We've already set up an access policy for your devices.`}
|
||||
</div>
|
||||
|
||||
{policy && (
|
||||
<div
|
||||
className={
|
||||
"text-sm text-nb-gray-300 font-light mt-2 block text-center sm:px-4"
|
||||
}
|
||||
>
|
||||
Flip the switch, then try pinging your other device again to see how it affects the connection.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<OnboardingPolicy policy={policy} onToggle={onToggle} />
|
||||
</div>
|
||||
|
||||
<Button variant={"primary"} onClick={onNext}>
|
||||
Continue
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
62
src/modules/onboarding/p2p/OnboardingFirstDevice.tsx
Normal file
62
src/modules/onboarding/p2p/OnboardingFirstDevice.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import Button from "@components/Button";
|
||||
import { Modal, ModalContent } from "@components/modal/Modal";
|
||||
import { DownloadIcon } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Peer } from "@/interfaces/Peer";
|
||||
import { SetupModalContent } from "@/modules/setup-netbird-modal/SetupModal";
|
||||
|
||||
type Props = {
|
||||
onBack: () => void;
|
||||
firstDevice?: Peer;
|
||||
onFinish?: () => void;
|
||||
};
|
||||
|
||||
export const OnboardingFirstDevice = ({
|
||||
onBack,
|
||||
firstDevice,
|
||||
onFinish,
|
||||
}: Props) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
/**
|
||||
* Continue to next step once first device is recognized
|
||||
*/
|
||||
useEffect(() => {
|
||||
firstDevice && onFinish?.();
|
||||
}, [firstDevice]);
|
||||
|
||||
return (
|
||||
<div className={"relative flex flex-col h-full gap-4"}>
|
||||
<div>
|
||||
<h1 className={"text-xl text-center"}>
|
||||
{`Let's get your first device online`}
|
||||
</h1>
|
||||
<div
|
||||
className={
|
||||
"text-sm text-nb-gray-300 font-light mt-2 block text-center sm:px-4"
|
||||
}
|
||||
>
|
||||
{`To access other machines, install NetBird, sign in, and your device joins the network.
|
||||
Every device you add becomes a NetBird peer in your network. It's that simple.`}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={"flex items-center justify-center mt-4 gap-3"}>
|
||||
<Button variant={"secondary"} onClick={onBack}>
|
||||
Go Back
|
||||
</Button>
|
||||
<Button variant={"primary"} onClick={() => setOpen(true)}>
|
||||
<DownloadIcon size={16} />
|
||||
Install NetBird
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Modal open={open} onOpenChange={setOpen}>
|
||||
<ModalContent className={"!z-[70]"}>
|
||||
<SetupModalContent title={"Install NetBird"} hideDocker={true} />
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
133
src/modules/onboarding/p2p/OnboardingSecondDevice.tsx
Normal file
133
src/modules/onboarding/p2p/OnboardingSecondDevice.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
import Button from "@components/Button";
|
||||
import Code from "@components/Code";
|
||||
import InlineLink from "@components/InlineLink";
|
||||
import { Modal, ModalContent } from "@components/modal/Modal";
|
||||
import { useApiCall } from "@utils/api";
|
||||
import { cn } from "@utils/helpers";
|
||||
import { getInstallUrl } from "@utils/netbird";
|
||||
import { ArrowUpRightIcon, ShareIcon } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useDialog } from "@/contexts/DialogProvider";
|
||||
import { Peer } from "@/interfaces/Peer";
|
||||
import { SetupKey } from "@/interfaces/SetupKey";
|
||||
import { SetupModalContent } from "@/modules/setup-netbird-modal/SetupModal";
|
||||
|
||||
type Props = {
|
||||
secondDevice?: Peer;
|
||||
onFinish?: () => void;
|
||||
};
|
||||
|
||||
export const OnboardingSecondDevice = ({ secondDevice, onFinish }: Props) => {
|
||||
const setupKeyRequest = useApiCall<SetupKey>("/setup-keys", true);
|
||||
const [setupKey, setSetupKey] = useState<SetupKey>();
|
||||
const { confirm } = useDialog();
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const isShareSupported = navigator.share !== undefined;
|
||||
|
||||
/**
|
||||
* Continue to next step once second device is recognized
|
||||
*/
|
||||
useEffect(() => {
|
||||
secondDevice && onFinish?.();
|
||||
}, [secondDevice]);
|
||||
|
||||
const openNavigatorShare = () => {
|
||||
if (navigator.share) {
|
||||
navigator.share({
|
||||
title: "Install NetBird",
|
||||
text: "Install NetBird on another device using this link.",
|
||||
url: getInstallUrl(),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const installUsingSetupKey = async () => {
|
||||
const choice = await confirm({
|
||||
title: `Create a Setup Key?`,
|
||||
description:
|
||||
"If you continue, a one-off setup key will be automatically created and you will be able to install NetBird.",
|
||||
confirmText: "Continue",
|
||||
cancelText: "Cancel",
|
||||
type: "default",
|
||||
});
|
||||
if (!choice) return;
|
||||
|
||||
await setupKeyRequest
|
||||
.post({
|
||||
name: "Onboarding (Second Device)",
|
||||
type: "one-off",
|
||||
expires_in: 24 * 60 * 60, // 1 day expiration
|
||||
revoked: false,
|
||||
auto_groups: [],
|
||||
usage_limit: 1,
|
||||
ephemeral: false,
|
||||
allow_extra_dns_labels: false,
|
||||
})
|
||||
.then((setupKey) => {
|
||||
setOpen(true);
|
||||
setSetupKey(setupKey);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={"relative flex flex-col h-full gap-4"}>
|
||||
<div>
|
||||
<h1 className={"text-xl text-center max-w-sm mx-auto"}>
|
||||
{`Time to bring in your second device`}
|
||||
</h1>
|
||||
<div className="text-sm text-nb-gray-300 font-light mt-2 block text-center">
|
||||
Each device (a.k.a. peer) in your NetBird network gets its own private IP and name to communicate securely in the network.
|
||||
</div>
|
||||
<div className="text-sm text-nb-gray-300 font-light mt-2 block text-center">
|
||||
To complete the setup, just share this link or email it to yourself to set up your next device
|
||||
with ease.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-wrap sm:flex-nowrap md:!flex-wrap gap-3 items-center justify-center",
|
||||
)}
|
||||
>
|
||||
<div>
|
||||
<Code
|
||||
message={"Installation link successfully copied"}
|
||||
className={"text-[0.8rem]"}
|
||||
>
|
||||
{getInstallUrl()}
|
||||
</Code>
|
||||
</div>
|
||||
{isShareSupported && (
|
||||
<Button
|
||||
variant={"input"}
|
||||
onClick={openNavigatorShare}
|
||||
className={"h-[42px]"}
|
||||
>
|
||||
<ShareIcon size={16} />
|
||||
<span className={"lg:hidden"}>Share Link</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm text-nb-gray-300 font-light mt-2 block text-center sm:px-4">
|
||||
Use the headless setup to register a peer without a browser or user interaction.{" "}
|
||||
<InlineLink onClick={installUsingSetupKey} href={"#"}>
|
||||
Install with a setup key
|
||||
<ArrowUpRightIcon size={12} />
|
||||
</InlineLink>{" "}
|
||||
</div>
|
||||
|
||||
{setupKey && (
|
||||
<Modal open={open} onOpenChange={setOpen}>
|
||||
<ModalContent>
|
||||
<SetupModalContent
|
||||
title={"Install NetBird"}
|
||||
setupKey={setupKey.key}
|
||||
/>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
79
src/modules/onboarding/p2p/OnboardingTestP2P.tsx
Normal file
79
src/modules/onboarding/p2p/OnboardingTestP2P.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import Button from "@components/Button";
|
||||
import Code from "@components/Code";
|
||||
import InlineLink from "@components/InlineLink";
|
||||
import Steps from "@components/Steps";
|
||||
import { ExternalLinkIcon } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { Peer } from "@/interfaces/Peer";
|
||||
import { Policy } from "@/interfaces/Policy";
|
||||
|
||||
type Props = {
|
||||
firstDevice?: Peer;
|
||||
secondDevice?: Peer;
|
||||
policy?: Policy;
|
||||
onNext?: () => void;
|
||||
onTroubleshootingClick?: () => void;
|
||||
};
|
||||
|
||||
export const OnboardingTestP2P = ({
|
||||
firstDevice,
|
||||
secondDevice,
|
||||
onNext,
|
||||
onTroubleshootingClick,
|
||||
}: Props) => {
|
||||
return (
|
||||
<div className={"relative flex flex-col h-full gap-4"}>
|
||||
<div>
|
||||
<h1 className={"text-xl text-center max-w-sm mx-auto"}>
|
||||
{`Let's put that connection to the test`}
|
||||
</h1>
|
||||
<div
|
||||
className={
|
||||
"text-sm text-nb-gray-300 font-light mt-2 block text-center sm:px-4"
|
||||
}
|
||||
>
|
||||
{
|
||||
"Nice work connecting your devices! Now, let’s have a little fun and test if they can talk to each other."
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Steps className={"stepper-bg-variant"}>
|
||||
<Steps.Step step={1}>
|
||||
<p className={"!text-nb-gray-300"}>
|
||||
Run this command from{" "}
|
||||
<span className={"text-white"}>{firstDevice?.name}</span> to ping{" "}
|
||||
<span className={"text-white"}>{secondDevice?.name}</span>.
|
||||
You should receive a response if the connection is working.
|
||||
</p>
|
||||
<Code message={"Command has been copied successfully"}>
|
||||
ping {secondDevice?.ip}
|
||||
</Code>
|
||||
</Steps.Step>
|
||||
<Steps.Step step={2} line={false} className={"pb-0"}>
|
||||
<p className={"!text-nb-gray-300"}>
|
||||
Everything working? Great! You can now continue with the onboarding.
|
||||
If something isn’t right, please check our{" "}
|
||||
<InlineLink
|
||||
onClick={onTroubleshootingClick}
|
||||
href={"https://docs.netbird.io/how-to/troubleshooting-client"}
|
||||
target={"_blank"}
|
||||
>
|
||||
troubleshooting guide
|
||||
<ExternalLinkIcon size={12} />
|
||||
</InlineLink>
|
||||
</p>
|
||||
<div className={"mt-2"}>
|
||||
<Button
|
||||
variant={"secondaryLighter"}
|
||||
className={"w-full"}
|
||||
onClick={onNext}
|
||||
>
|
||||
It works! - Continue
|
||||
</Button>
|
||||
</div>
|
||||
</Steps.Step>
|
||||
</Steps>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -14,21 +14,31 @@ import Steps from "@components/Steps";
|
||||
import { Lightbox } from "@components/ui/Lightbox";
|
||||
import { Mark } from "@components/ui/Mark";
|
||||
import { cn } from "@utils/helpers";
|
||||
import { ExternalLinkIcon, TerminalSquare } from "lucide-react";
|
||||
import { ExternalLinkIcon, PlusCircle, TerminalSquare } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { useState } from "react";
|
||||
import sshImage from "@/assets/ssh/ssh-client.png";
|
||||
import { SegmentedTabs } from "@components/SegmentedTabs";
|
||||
import NetBirdIcon from "@/assets/icons/NetBirdIcon";
|
||||
import { Peer } from "@/interfaces/Peer";
|
||||
import { PeerSSHPolicyModal } from "@/modules/peer/PeerSSHPolicyModal";
|
||||
|
||||
type Props = {
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
onSuccess?: () => void;
|
||||
peer?: Peer;
|
||||
};
|
||||
|
||||
export const PeerSSHInstructions = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
onSuccess,
|
||||
peer,
|
||||
}: Props) => {
|
||||
const [client, setClient] = useState("cli");
|
||||
const [policyModal, setPolicyModal] = useState(false);
|
||||
|
||||
return (
|
||||
<Modal open={open} onOpenChange={onOpenChange}>
|
||||
<ModalContent
|
||||
@@ -39,36 +49,69 @@ export const PeerSSHInstructions = ({
|
||||
icon={<TerminalSquare size={16} className={"text-netbird"} />}
|
||||
title={"Enable SSH Access"}
|
||||
description={
|
||||
"Allow remote SSH access to this machine from other connected network participants."
|
||||
"Allow remote SSH access from other connected network participants."
|
||||
}
|
||||
color={"netbird"}
|
||||
/>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className={"px-8 py-3 flex flex-col gap-0 z-0"}>
|
||||
<div className={"px-8 py-3 flex flex-col gap-0 z-0 mt-1"}>
|
||||
<SegmentedTabs value={client} onChange={setClient}>
|
||||
<SegmentedTabs.List className={"rounded-lg border"}>
|
||||
<SegmentedTabs.Trigger value={"cli"}>
|
||||
<TerminalSquare size={16} />
|
||||
CLI
|
||||
</SegmentedTabs.Trigger>
|
||||
<SegmentedTabs.Trigger value={"gui"}>
|
||||
<NetBirdIcon size={16} />
|
||||
Desktop Client
|
||||
</SegmentedTabs.Trigger>
|
||||
</SegmentedTabs.List>
|
||||
</SegmentedTabs>
|
||||
|
||||
<Steps>
|
||||
<Steps.Step step={1}>
|
||||
<p className={"font-normal"}>
|
||||
If you are using NetBird via CLI, you can enable SSH by running
|
||||
</p>
|
||||
<Code codeToCopy={"netbird down"}>
|
||||
<Code.Line>{`netbird down # if NetBird is already running`}</Code.Line>
|
||||
</Code>
|
||||
<Code>
|
||||
<Code.Line>{`netbird up --allow-server-ssh --enable-ssh-root`}</Code.Line>
|
||||
</Code>
|
||||
</Steps.Step>
|
||||
{client === "cli" ? (
|
||||
<Steps.Step step={1}>
|
||||
<p className={"font-normal"}>
|
||||
If you are using NetBird via CLI, you can enable SSH by
|
||||
running
|
||||
</p>
|
||||
<Code codeToCopy={"netbird down"}>
|
||||
<Code.Line>{`netbird down # if NetBird is already running`}</Code.Line>
|
||||
</Code>
|
||||
<Code>
|
||||
<Code.Line>{`netbird up --allow-server-ssh --enable-ssh-root`}</Code.Line>
|
||||
</Code>
|
||||
</Steps.Step>
|
||||
) : (
|
||||
<Steps.Step step={1}>
|
||||
<p className={"font-normal"}>
|
||||
If you are using NetBird via the Desktop Client, click on the
|
||||
NetBird tray icon, go to <Mark>Settings</Mark> and click{" "}
|
||||
<Mark>Allow SSH</Mark>. If you want to enable Root Login go to{" "}
|
||||
<Mark>Settings > Advanced Settings</Mark> and enable SSH
|
||||
Root Login under the SSH tab.
|
||||
</p>
|
||||
<Lightbox image={sshImage} />
|
||||
</Steps.Step>
|
||||
)}
|
||||
|
||||
<Steps.Step step={2}>
|
||||
<p className={"font-normal"}>
|
||||
If you are using NetBird via the Desktop Client, click on the
|
||||
NetBird tray icon, go to <Mark>Settings</Mark> and click{" "}
|
||||
<Mark>Allow SSH</Mark> <br />
|
||||
Starting from NetBird v0.61.0, SSH requires an explicit access
|
||||
control policy to allow SSH connections to this machine.
|
||||
</p>
|
||||
<Lightbox image={sshImage} />
|
||||
<div className={"mt-2"}>
|
||||
<Button
|
||||
variant={"secondary"}
|
||||
onClick={() => setPolicyModal(true)}
|
||||
>
|
||||
<PlusCircle size={16} />
|
||||
Create SSH Policy
|
||||
</Button>
|
||||
</div>
|
||||
</Steps.Step>
|
||||
|
||||
<Steps.Step step={3} line={false}>
|
||||
<p className={"font-normal"}>
|
||||
Once the NetBird SSH server is allowed on the client, <br />
|
||||
@@ -96,15 +139,17 @@ export const PeerSSHInstructions = ({
|
||||
<Button variant={"secondary"}>Cancel</Button>
|
||||
</ModalClose>
|
||||
|
||||
<Button
|
||||
variant={"primary"}
|
||||
onClick={onSuccess}
|
||||
data-cy={"create-setup-key"}
|
||||
>
|
||||
Confirm & Enable
|
||||
<Button variant={"primary"} onClick={onSuccess}>
|
||||
Finish Setup
|
||||
</Button>
|
||||
</div>
|
||||
</ModalFooter>
|
||||
|
||||
<PeerSSHPolicyModal
|
||||
open={policyModal}
|
||||
onOpenChange={setPolicyModal}
|
||||
peer={peer}
|
||||
/>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
38
src/modules/peer/PeerSSHPolicyInfo.tsx
Normal file
38
src/modules/peer/PeerSSHPolicyInfo.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import { Callout } from "@components/Callout";
|
||||
import { InlineButtonLink } from "@components/InlineLink";
|
||||
import { cn } from "@utils/helpers";
|
||||
import * as React from "react";
|
||||
import { useState } from "react";
|
||||
import { Peer } from "@/interfaces/Peer";
|
||||
import { PeerSSHPolicyModal } from "@/modules/peer/PeerSSHPolicyModal";
|
||||
import { usePeerSSHPolicyCheck } from "@/modules/peer/usePeerSSHPolicyCheck";
|
||||
|
||||
type Props = {
|
||||
peer?: Peer;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export const PeerSSHPolicyInfo = ({ peer, className }: Props) => {
|
||||
const { showSSHPolicyInfo } = usePeerSSHPolicyCheck(peer);
|
||||
const [policyModal, setPolicyModal] = useState(false);
|
||||
return (
|
||||
showSSHPolicyInfo && (
|
||||
<>
|
||||
<Callout className={cn("max-w-xl", className)} variant={"warning"}>
|
||||
<span>
|
||||
Starting from NetBird v0.61.0, SSH requires an explicit access
|
||||
control policy to allow SSH connections to this machine.{" "}
|
||||
<InlineButtonLink onClick={() => setPolicyModal(true)}>
|
||||
Create SSH Policy
|
||||
</InlineButtonLink>
|
||||
</span>
|
||||
</Callout>
|
||||
<PeerSSHPolicyModal
|
||||
open={policyModal}
|
||||
onOpenChange={setPolicyModal}
|
||||
peer={peer}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
);
|
||||
};
|
||||
34
src/modules/peer/PeerSSHPolicyModal.tsx
Normal file
34
src/modules/peer/PeerSSHPolicyModal.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import { Modal } from "@components/modal/Modal";
|
||||
import * as React from "react";
|
||||
import { Peer } from "@/interfaces/Peer";
|
||||
import { PolicyRuleResource } from "@/interfaces/Policy";
|
||||
import { AccessControlModalContent } from "@/modules/access-control/AccessControlModal";
|
||||
|
||||
type Props = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
peer?: Peer;
|
||||
};
|
||||
|
||||
export const PeerSSHPolicyModal = ({ open, onOpenChange, peer }: Props) => {
|
||||
return (
|
||||
<Modal open={open} onOpenChange={onOpenChange}>
|
||||
<AccessControlModalContent
|
||||
key={open ? "1" : "0"}
|
||||
initialProtocol={"netbird-ssh"}
|
||||
initialName={"SSH Access"}
|
||||
initialDestinationResource={
|
||||
peer
|
||||
? ({
|
||||
id: peer.id,
|
||||
type: "peer",
|
||||
} as PolicyRuleResource)
|
||||
: undefined
|
||||
}
|
||||
onSuccess={async (p) => {
|
||||
onOpenChange(false);
|
||||
}}
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -1,23 +1,114 @@
|
||||
import FancyToggleSwitch from "@components/FancyToggleSwitch";
|
||||
import FullTooltip from "@components/FullTooltip";
|
||||
import { LockIcon, TerminalSquare } from "lucide-react";
|
||||
import {
|
||||
AlertCircleIcon,
|
||||
ArrowUpRightIcon,
|
||||
CirclePlusIcon,
|
||||
ExternalLinkIcon,
|
||||
LockIcon,
|
||||
ShieldIcon,
|
||||
SquarePenIcon,
|
||||
TerminalSquare,
|
||||
} from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { usePeer } from "@/contexts/PeerProvider";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import { PeerSSHPolicyInfo } from "@/modules/peer/PeerSSHPolicyInfo";
|
||||
import { Label } from "@components/Label";
|
||||
import HelpText from "@components/HelpText";
|
||||
import Button from "@components/Button";
|
||||
import useFetchApi from "@utils/api";
|
||||
import { Policy } from "@/interfaces/Policy";
|
||||
import { Group } from "@/interfaces/Group";
|
||||
import { orderBy } from "lodash";
|
||||
import CircleIcon from "@/assets/icons/CircleIcon";
|
||||
import Badge from "@components/Badge";
|
||||
import { cn, singularize } from "@utils/helpers";
|
||||
import { Modal } from "@components/modal/Modal";
|
||||
import { AccessControlModalContent } from "@/modules/access-control/AccessControlModal";
|
||||
import PoliciesProvider from "@/contexts/PoliciesProvider";
|
||||
import { PeerSSHPolicyModal } from "@/modules/peer/PeerSSHPolicyModal";
|
||||
import { Callout } from "@components/Callout";
|
||||
import { useDialog } from "@/contexts/DialogProvider";
|
||||
import InlineLink from "@components/InlineLink";
|
||||
import { isNetbirdSSHProtocolSupported } from "@utils/version";
|
||||
|
||||
export const PeerSSHToggle = () => {
|
||||
const { permission } = usePermissions();
|
||||
const { peer, toggleSSH, setSSHInstructionsModal } = usePeer();
|
||||
const { data: policies, isLoading } = useFetchApi<Policy[]>("/policies");
|
||||
const [tooltipOpen, setTooltipOpen] = useState(false);
|
||||
const [policyModal, setPolicyModal] = useState(false);
|
||||
const [sshPolicyModal, setSshPolicyModal] = useState(false);
|
||||
const [currentPolicy, setCurrentPolicy] = useState<Policy>();
|
||||
const { confirm } = useDialog();
|
||||
|
||||
return (
|
||||
const isSSHDashboardEnabled = peer?.ssh_enabled;
|
||||
const isSSHClientEnabled = peer?.local_flags?.server_ssh_allowed;
|
||||
|
||||
const assignedPolicies = useMemo(() => {
|
||||
const peerGroups = peer?.groups as Group[];
|
||||
return orderBy(
|
||||
policies?.filter((policy) => {
|
||||
const rule = policy?.rules?.[0];
|
||||
const isSSHProtocol = rule?.protocol === "netbird-ssh";
|
||||
if (!isSSHProtocol) return false;
|
||||
const destinationResource = policy.rules
|
||||
?.map((rule) => rule?.destinationResource?.id === peer?.id)
|
||||
.some((id) => id);
|
||||
if (destinationResource) return true;
|
||||
const destinationPolicyGroups = policy.rules
|
||||
?.map((rule) => rule?.destinations)
|
||||
.flat() as Group[];
|
||||
const policyGroups = [...destinationPolicyGroups];
|
||||
return peerGroups?.some((peerGroup) =>
|
||||
policyGroups.some((policyGroup) => policyGroup?.id === peerGroup.id),
|
||||
);
|
||||
}),
|
||||
"enabled",
|
||||
"desc",
|
||||
);
|
||||
}, [policies, peer]);
|
||||
|
||||
const enabledPolicies = assignedPolicies?.filter((policy) => policy?.enabled);
|
||||
|
||||
const disableDashboardSSH = async () => {
|
||||
const choice = await confirm({
|
||||
title: `Disable SSH Access?`,
|
||||
description: (
|
||||
<div>
|
||||
Starting from NetBird v0.61.0, once SSH access is disabled, you cannot
|
||||
re-enable it again from the dashboard. You'll need to create an
|
||||
explicit access control policy and update your NetBird client to
|
||||
restore SSH functionality.{" "}
|
||||
<InlineLink
|
||||
href={"https://docs.netbird.io/manage/peers/ssh"}
|
||||
target={"_blank"}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
Learn more
|
||||
<ExternalLinkIcon size={12} />
|
||||
</InlineLink>
|
||||
</div>
|
||||
),
|
||||
confirmText: "Disable",
|
||||
cancelText: "Cancel",
|
||||
type: "warning",
|
||||
maxWidthClass: "max-w-xl",
|
||||
});
|
||||
if (!choice) return;
|
||||
toggleSSH(false);
|
||||
};
|
||||
|
||||
return isSSHDashboardEnabled ? (
|
||||
<>
|
||||
<FullTooltip
|
||||
content={
|
||||
<div className={"flex gap-2 items-center !text-nb-gray-300 text-xs"}>
|
||||
<LockIcon size={14} />
|
||||
<span>
|
||||
{`You don't have the required permissions to update this
|
||||
setting.`}
|
||||
{`You don't have the required permissions to update this setting.`}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
@@ -29,7 +120,7 @@ export const PeerSSHToggle = () => {
|
||||
value={peer.ssh_enabled}
|
||||
disabled={!permission.peers.update}
|
||||
onChange={(enable) =>
|
||||
enable ? setSSHInstructionsModal(true) : toggleSSH(false)
|
||||
enable ? setSSHInstructionsModal(true) : disableDashboardSSH()
|
||||
}
|
||||
label={
|
||||
<>
|
||||
@@ -42,6 +133,197 @@ export const PeerSSHToggle = () => {
|
||||
}
|
||||
/>
|
||||
</FullTooltip>
|
||||
<PeerSSHPolicyInfo peer={peer} />
|
||||
</>
|
||||
) : (
|
||||
<div>
|
||||
<div className={"flex gap-2 items-center w-full"}>
|
||||
<Label>SSH Access</Label>
|
||||
</div>
|
||||
|
||||
<HelpText>
|
||||
Set up SSH and create an explicit access control policy defining which
|
||||
users can access specific local usernames of this machine via SSH.
|
||||
</HelpText>
|
||||
|
||||
{!isNetbirdSSHProtocolSupported(peer.version) &&
|
||||
enabledPolicies?.length > 0 &&
|
||||
isSSHClientEnabled && (
|
||||
<Callout
|
||||
variant={"warning"}
|
||||
icon={
|
||||
<AlertCircleIcon
|
||||
size={14}
|
||||
className={"shrink-0 relative top-[3px] text-netbird"}
|
||||
/>
|
||||
}
|
||||
className="my-3"
|
||||
>
|
||||
You have SSH access configured but your client runs on an older
|
||||
NetBird version. Please update your NetBird client to v.0.61.0+ in
|
||||
order to allow SSH connections.
|
||||
</Callout>
|
||||
)}
|
||||
|
||||
{!isSSHClientEnabled && enabledPolicies?.length > 0 && (
|
||||
<Callout
|
||||
variant={"warning"}
|
||||
icon={
|
||||
<AlertCircleIcon
|
||||
size={14}
|
||||
className={"shrink-0 relative top-[3px] text-netbird"}
|
||||
/>
|
||||
}
|
||||
className="my-3"
|
||||
>
|
||||
You have an SSH access policy configured, but the SSH server
|
||||
isn't enabled on this client. Enable the SSH server to allow SSH
|
||||
connections.
|
||||
</Callout>
|
||||
)}
|
||||
|
||||
{isSSHClientEnabled && enabledPolicies?.length === 0 && (
|
||||
<Callout
|
||||
variant={"warning"}
|
||||
icon={
|
||||
<AlertCircleIcon
|
||||
size={14}
|
||||
className={"shrink-0 relative top-[3px] text-netbird"}
|
||||
/>
|
||||
}
|
||||
className="my-3"
|
||||
>
|
||||
Your SSH server is enabled, but starting from NetBird v0.61.0, SSH
|
||||
requires an explicit access control policy. Please create an SSH
|
||||
access control policy in order to allow SSH connections.
|
||||
</Callout>
|
||||
)}
|
||||
|
||||
<div className={"flex gap-3"}>
|
||||
{isSSHClientEnabled ? (
|
||||
<Button variant={"secondary"} onClick={() => setSshPolicyModal(true)}>
|
||||
<CirclePlusIcon size={14} />
|
||||
Create SSH Policy
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant={"secondary"}
|
||||
onClick={() => setSSHInstructionsModal(true)}
|
||||
>
|
||||
Enable SSH Access <ArrowUpRightIcon size={14} />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{enabledPolicies?.length > 0 && (
|
||||
<FullTooltip
|
||||
contentClassName={"p-0"}
|
||||
delayDuration={200}
|
||||
skipDelayDuration={200}
|
||||
customOpen={tooltipOpen}
|
||||
customOnOpenChange={setTooltipOpen}
|
||||
className={"border-nb-gray-800"}
|
||||
content={
|
||||
<div className={"text-xs flex flex-col p-1"}>
|
||||
{assignedPolicies?.map((policy: Policy) => {
|
||||
const rule = policy?.rules?.[0];
|
||||
if (!rule) return;
|
||||
return (
|
||||
<button
|
||||
key={policy.id}
|
||||
className={
|
||||
"m-0 pl-3 py-2.5 leading-none flex justify-between group hover:bg-nb-gray-900 rounded-md"
|
||||
}
|
||||
onClick={() => {
|
||||
setTooltipOpen(false);
|
||||
setCurrentPolicy(policy);
|
||||
setPolicyModal(true);
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={
|
||||
" flex items-center gap-2 leading-none font-medium text-nb-gray-300 group-hover:text-nb-gray-200 whitespace-nowrap"
|
||||
}
|
||||
>
|
||||
<CircleIcon
|
||||
size={8}
|
||||
active={policy.enabled}
|
||||
className={"shrink-0"}
|
||||
/>
|
||||
{policy.name}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={
|
||||
"text-nb-gray-300 px-2 ml-4 uppercase font-mono opacity-0 group-hover:opacity-100"
|
||||
}
|
||||
>
|
||||
<SquarePenIcon size={12} />
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
}
|
||||
interactive={true}
|
||||
align={"start"}
|
||||
alignOffset={0}
|
||||
sideOffset={8}
|
||||
>
|
||||
<Badge
|
||||
variant={"gray"}
|
||||
useHover={false}
|
||||
className={"select-none hover:bg-nb-gray-910 px-4"}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (!tooltipOpen) setTooltipOpen(true);
|
||||
}}
|
||||
>
|
||||
<ShieldIcon
|
||||
size={14}
|
||||
className={cn(
|
||||
enabledPolicies?.length > 0
|
||||
? "text-green-500"
|
||||
: "text-nb-gray-300",
|
||||
)}
|
||||
/>
|
||||
<div>
|
||||
<span className={"font-medium text-xs"}>
|
||||
{singularize(
|
||||
"Active Policies",
|
||||
enabledPolicies?.length,
|
||||
true,
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</Badge>
|
||||
</FullTooltip>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<PoliciesProvider>
|
||||
<Modal
|
||||
open={policyModal}
|
||||
onOpenChange={(state) => {
|
||||
setPolicyModal(state);
|
||||
setCurrentPolicy(undefined);
|
||||
}}
|
||||
>
|
||||
<AccessControlModalContent
|
||||
key={policyModal ? "1" : "0"}
|
||||
policy={currentPolicy}
|
||||
onSuccess={async (p) => {
|
||||
setPolicyModal(false);
|
||||
setCurrentPolicy(undefined);
|
||||
}}
|
||||
/>
|
||||
</Modal>
|
||||
<PeerSSHPolicyModal
|
||||
open={sshPolicyModal}
|
||||
onOpenChange={setSshPolicyModal}
|
||||
peer={peer}
|
||||
/>
|
||||
</PoliciesProvider>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
77
src/modules/peer/usePeerSSHPolicyCheck.ts
Normal file
77
src/modules/peer/usePeerSSHPolicyCheck.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import useFetchApi from "@utils/api";
|
||||
import { Peer } from "@/interfaces/Peer";
|
||||
import { Policy } from "@/interfaces/Policy";
|
||||
import { isNativeSSHSupported } from "@utils/version";
|
||||
|
||||
export const usePeerSSHPolicyCheck = (peer?: Peer) => {
|
||||
const { data: policies, isLoading } = useFetchApi<Policy[]>(
|
||||
"/policies",
|
||||
true,
|
||||
false,
|
||||
);
|
||||
const peerGroupIds = peer?.groups?.map((p) => p.id);
|
||||
|
||||
const peerPolicies = policies?.filter((policy) => {
|
||||
// Skip disabled policies
|
||||
if (!policy?.enabled) return false;
|
||||
|
||||
const rule = policy?.rules?.[0];
|
||||
if (!rule) return false;
|
||||
|
||||
// Skip icmp and udp
|
||||
if (rule.protocol === "icmp" || rule.protocol === "udp") return false;
|
||||
|
||||
// Check resource and groups
|
||||
const isPeerInDestinationResource =
|
||||
rule.destinationResource?.id === peer?.id;
|
||||
const isPeerInDestinationGroup =
|
||||
rule.destinations?.some((group) => {
|
||||
const groupId = typeof group === "string" ? group : group?.id;
|
||||
return peerGroupIds?.includes(groupId);
|
||||
}) ?? false;
|
||||
|
||||
const isPeerInDestination =
|
||||
isPeerInDestinationResource || isPeerInDestinationGroup;
|
||||
|
||||
// If bidirectional, also check if peer is in source
|
||||
let isPeerInSource = false;
|
||||
if (rule.bidirectional) {
|
||||
const isPeerInSourceResource = rule.sourceResource?.id === peer?.id;
|
||||
const isPeerInSourceGroup =
|
||||
rule.sources?.some((group) => {
|
||||
const groupId = typeof group === "string" ? group : group?.id;
|
||||
return peerGroupIds?.includes(groupId);
|
||||
}) ?? false;
|
||||
|
||||
isPeerInSource = isPeerInSourceResource || isPeerInSourceGroup;
|
||||
}
|
||||
|
||||
const isInSourceOrDestination = isPeerInDestination || isPeerInSource;
|
||||
if (!isInSourceOrDestination) return false;
|
||||
|
||||
if (rule.protocol === "all") return true;
|
||||
|
||||
// Check ports
|
||||
const hasNoPortRestrictions = rule.ports === undefined;
|
||||
const hasExplicitPort22 = rule.ports?.includes("22");
|
||||
const hasPort22InRange = rule.port_ranges?.some(
|
||||
(range) => 22 >= range.start && 22 <= range.end,
|
||||
);
|
||||
|
||||
return hasNoPortRestrictions || hasExplicitPort22 || hasPort22InRange;
|
||||
});
|
||||
|
||||
const hasSSHPolicy = (peerPolicies?.length ?? 0) > 0;
|
||||
const showSSHPolicyInfo =
|
||||
!hasSSHPolicy &&
|
||||
!isLoading &&
|
||||
!!peer?.ssh_enabled &&
|
||||
isNativeSSHSupported(peer.version);
|
||||
|
||||
return {
|
||||
peerPolicies,
|
||||
isCheckLoading: isLoading,
|
||||
hasSSHPolicy,
|
||||
showSSHPolicyInfo,
|
||||
};
|
||||
};
|
||||
@@ -10,6 +10,7 @@ import FullTooltip from "@components/FullTooltip";
|
||||
import { notify } from "@components/Notification";
|
||||
import { IconInfoCircle } from "@tabler/icons-react";
|
||||
import {
|
||||
ExternalLinkIcon,
|
||||
MonitorIcon,
|
||||
MoreVertical,
|
||||
TerminalSquare,
|
||||
@@ -17,11 +18,13 @@ import {
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import React from "react";
|
||||
import React, { useMemo } from "react";
|
||||
import { useSWRConfig } from "swr";
|
||||
import { usePeer } from "@/contexts/PeerProvider";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import { ExitNodeDropdownButton } from "@/modules/exit-node/ExitNodeDropdownButton";
|
||||
import InlineLink from "@components/InlineLink";
|
||||
import { useDialog } from "@/contexts/DialogProvider";
|
||||
|
||||
export default function PeerActionCell() {
|
||||
const { peer, deletePeer, update, toggleSSH, setSSHInstructionsModal } =
|
||||
@@ -29,6 +32,14 @@ export default function PeerActionCell() {
|
||||
const router = useRouter();
|
||||
const { mutate } = useSWRConfig();
|
||||
const { permission } = usePermissions();
|
||||
const { confirm } = useDialog();
|
||||
|
||||
const showSSHButton = useMemo(() => {
|
||||
const isClientSSHEnabled = peer?.local_flags?.server_ssh_allowed;
|
||||
const isDashboardSSHEnabled = peer?.ssh_enabled;
|
||||
if (isDashboardSSHEnabled) return true;
|
||||
return !isClientSSHEnabled;
|
||||
}, [peer]);
|
||||
|
||||
const toggleLoginExpiration = async () => {
|
||||
const text = peer.login_expiration_enabled ? "disabled" : "enabled";
|
||||
@@ -49,6 +60,34 @@ export default function PeerActionCell() {
|
||||
});
|
||||
};
|
||||
|
||||
const disableDashboardSSH = async () => {
|
||||
const choice = await confirm({
|
||||
title: `Disable SSH Access?`,
|
||||
description: (
|
||||
<div>
|
||||
Starting from NetBird v0.61.0, once SSH access is disabled, you cannot
|
||||
re-enable it again from the dashboard. You'll need to create an
|
||||
explicit access control policy and update your NetBird client to
|
||||
restore SSH functionality.{" "}
|
||||
<InlineLink
|
||||
href={"https://docs.netbird.io/manage/peers/ssh"}
|
||||
target={"_blank"}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
Learn more
|
||||
<ExternalLinkIcon size={12} />
|
||||
</InlineLink>
|
||||
</div>
|
||||
),
|
||||
confirmText: "Disable",
|
||||
cancelText: "Cancel",
|
||||
type: "warning",
|
||||
maxWidthClass: "max-w-xl",
|
||||
});
|
||||
if (!choice) return;
|
||||
toggleSSH(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={"flex justify-end pr-4 gap-3"}>
|
||||
<DropdownMenu modal={false}>
|
||||
@@ -101,21 +140,23 @@ export default function PeerActionCell() {
|
||||
</DropdownMenuItem>
|
||||
</FullTooltip>
|
||||
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
peer.ssh_enabled
|
||||
? toggleSSH(false)
|
||||
: setSSHInstructionsModal(true)
|
||||
}
|
||||
disabled={!permission.peers.update}
|
||||
>
|
||||
<div className={"flex gap-3 items-center w-full"}>
|
||||
<TerminalSquare size={14} className={"shrink-0"} />
|
||||
<div className={"flex justify-between items-center w-full"}>
|
||||
{peer.ssh_enabled ? "Disable" : "Enable"} SSH Access
|
||||
{showSSHButton && (
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
peer.ssh_enabled
|
||||
? disableDashboardSSH()
|
||||
: setSSHInstructionsModal(true)
|
||||
}
|
||||
disabled={!permission.peers.update}
|
||||
>
|
||||
<div className={"flex gap-3 items-center w-full"}>
|
||||
<TerminalSquare size={14} className={"shrink-0"} />
|
||||
<div className={"flex justify-between items-center w-full"}>
|
||||
{peer.ssh_enabled ? "Disable" : "Enable"} SSH Access
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
<ExitNodeDropdownButton peer={peer} />
|
||||
|
||||
|
||||
@@ -6,12 +6,12 @@ import {
|
||||
import FullTooltip from "@components/FullTooltip";
|
||||
import { getOperatingSystem } from "@hooks/useOperatingSystem";
|
||||
import { IconChevronDown } from "@tabler/icons-react";
|
||||
import { cn } from "@utils/helpers";
|
||||
import * as React from "react";
|
||||
import { usePeer } from "@/contexts/PeerProvider";
|
||||
import { OperatingSystem } from "@/interfaces/OperatingSystem";
|
||||
import { RDPButton } from "@/modules/remote-access/rdp/RDPButton";
|
||||
import { SSHButton } from "@/modules/remote-access/ssh/SSHButton";
|
||||
import { cn } from "@utils/helpers";
|
||||
|
||||
export const PeerConnectButton = () => {
|
||||
const { peer } = usePeer();
|
||||
|
||||
@@ -27,8 +27,13 @@ export default function PeerGroupCell() {
|
||||
|
||||
const groupIDs = useMemo(() => {
|
||||
return peerGroups
|
||||
?.map((group) => group.id)
|
||||
.filter((id) => id !== undefined) as string[];
|
||||
?.map((group) => {
|
||||
if (group?.name === "All") return;
|
||||
return group.id;
|
||||
})
|
||||
.filter((id) => {
|
||||
return id !== undefined;
|
||||
}) as string[];
|
||||
}, [peerGroups]);
|
||||
|
||||
return (
|
||||
@@ -37,6 +42,7 @@ export default function PeerGroupCell() {
|
||||
description={"Use groups to control what this peer can access"}
|
||||
groups={groupIDs || []}
|
||||
hideAllGroup={true}
|
||||
showAddGroupButton={true}
|
||||
disabled={!permission.groups.update}
|
||||
onSave={handleSave}
|
||||
modal={modal}
|
||||
|
||||
@@ -2,81 +2,101 @@ import Badge from "@components/Badge";
|
||||
import Button from "@components/Button";
|
||||
import FullTooltip from "@components/FullTooltip";
|
||||
import { cn } from "@utils/helpers";
|
||||
import { ArrowUpRightSquareIcon } from "lucide-react";
|
||||
import { ArrowUpRightIcon, ShieldIcon, SquarePenIcon } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import * as React from "react";
|
||||
import AccessControlIcon from "@/assets/icons/AccessControlIcon";
|
||||
import { useState } from "react";
|
||||
import CircleIcon from "@/assets/icons/CircleIcon";
|
||||
import { usePolicies } from "@/contexts/PoliciesProvider";
|
||||
import { Policy } from "@/interfaces/Policy";
|
||||
import { PostureCheck } from "@/interfaces/PostureCheck";
|
||||
|
||||
type Props = {
|
||||
check: PostureCheck;
|
||||
};
|
||||
|
||||
export const PostureCheckPolicyUsageCell = ({ check }: Props) => {
|
||||
const router = useRouter();
|
||||
const [tooltipOpen, setTooltipOpen] = useState(false);
|
||||
const policyCount = check?.policies?.length || 0;
|
||||
const policies = check?.policies;
|
||||
const { openEditPolicyModal } = usePolicies();
|
||||
|
||||
return (
|
||||
<div className={cn("flex gap-4")}>
|
||||
<FullTooltip
|
||||
disabled={!(check.policies && check.policies?.length > 0)}
|
||||
content={
|
||||
<div className={"text-xs max-w-lg"}>
|
||||
<span className={"font-medium text-nb-gray-100 text-sm"}>
|
||||
Assigned
|
||||
{check.policies && check.policies?.length > 1
|
||||
? " Policies"
|
||||
: " Policy"}
|
||||
</span>
|
||||
<div className={"flex gap-2 pt-3 pb-2 flex-wrap"}>
|
||||
{check.policies &&
|
||||
check.policies?.length > 0 &&
|
||||
check.policies?.map((policy: Policy, index: number) => {
|
||||
return (
|
||||
<Badge
|
||||
variant={"gray-ghost"}
|
||||
useHover={false}
|
||||
key={index}
|
||||
className={"justify-start font-medium"}
|
||||
{policyCount > 0 && (
|
||||
<FullTooltip
|
||||
contentClassName={"p-0"}
|
||||
delayDuration={200}
|
||||
skipDelayDuration={200}
|
||||
customOpen={tooltipOpen}
|
||||
customOnOpenChange={setTooltipOpen}
|
||||
className={"border-nb-gray-800"}
|
||||
content={
|
||||
<div className={"text-xs flex flex-col p-1"}>
|
||||
{policies?.map((policy: Policy) => {
|
||||
const rule = policy?.rules?.[0];
|
||||
if (!rule) return;
|
||||
return (
|
||||
<button
|
||||
key={policy.id}
|
||||
className={
|
||||
"m-0 pl-3 py-2.5 leading-none flex justify-between group hover:bg-nb-gray-900 rounded-md"
|
||||
}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setTooltipOpen(false);
|
||||
openEditPolicyModal(policy, "posture_checks");
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={
|
||||
" flex items-center gap-2 leading-none font-medium text-nb-gray-300 group-hover:text-nb-gray-200 whitespace-nowrap"
|
||||
}
|
||||
>
|
||||
<AccessControlIcon size={12} />
|
||||
<CircleIcon
|
||||
size={8}
|
||||
active={policy.enabled}
|
||||
className={"shrink-0"}
|
||||
/>
|
||||
{policy.name}
|
||||
</Badge>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={
|
||||
"text-nb-gray-300 px-2 ml-4 uppercase font-mono opacity-0 group-hover:opacity-100"
|
||||
}
|
||||
>
|
||||
<SquarePenIcon size={12} />
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
interactive={false}
|
||||
>
|
||||
<Badge
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
router.push("/access-control");
|
||||
}}
|
||||
variant={"gray"}
|
||||
useHover={!!(check.policies && check.policies?.length > 0)}
|
||||
className={cn(
|
||||
"min-w-[110px] font-medium cursor-pointer",
|
||||
check.policies &&
|
||||
check.policies.length == 0 &&
|
||||
"opacity-30 pointer-events-none",
|
||||
)}
|
||||
}
|
||||
interactive={true}
|
||||
align={"start"}
|
||||
alignOffset={0}
|
||||
sideOffset={14}
|
||||
>
|
||||
<AccessControlIcon size={12} />
|
||||
<span>
|
||||
<span className={"font-bold"}>
|
||||
{check.policies && check.policies?.length > 0
|
||||
? check.policies && check.policies?.length
|
||||
: ""}
|
||||
</span>{" "}
|
||||
{check.policies && check.policies?.length == 0
|
||||
? "No Policies"
|
||||
: check.policies && check.policies?.length > 1
|
||||
? "Policies"
|
||||
: "Policy"}
|
||||
</span>
|
||||
</Badge>
|
||||
</FullTooltip>
|
||||
<Badge
|
||||
variant={"gray"}
|
||||
useHover={false}
|
||||
className={"select-none hover:bg-nb-gray-910"}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (!tooltipOpen) setTooltipOpen(true);
|
||||
}}
|
||||
>
|
||||
<ShieldIcon size={14} className={"text-green-500"} />
|
||||
<div>
|
||||
<span className={"font-medium text-xs"}>{policyCount}</span>
|
||||
</div>
|
||||
</Badge>
|
||||
</FullTooltip>
|
||||
)}
|
||||
<FullTooltip
|
||||
content={
|
||||
<div className={"text-xs max-w-[260px]"}>
|
||||
@@ -93,8 +113,8 @@ export const PostureCheckPolicyUsageCell = ({ check }: Props) => {
|
||||
onClick={() => router.push("/access-control")}
|
||||
>
|
||||
<>
|
||||
<ArrowUpRightSquareIcon size={12} />
|
||||
Go to Policies
|
||||
<ArrowUpRightIcon size={12} />
|
||||
</>
|
||||
</Button>
|
||||
</FullTooltip>
|
||||
|
||||
@@ -1,13 +1,8 @@
|
||||
import Button from "@components/Button";
|
||||
import HelpText from "@components/HelpText";
|
||||
import InlineLink from "@components/InlineLink";
|
||||
import { Input } from "@components/Input";
|
||||
import { Label } from "@components/Label";
|
||||
import * as React from "react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { Modal, ModalContent, ModalFooter } from "@components/modal/Modal";
|
||||
import ModalHeader from "@components/modal/ModalHeader";
|
||||
import Paragraph from "@components/Paragraph";
|
||||
import Separator from "@components/Separator";
|
||||
import { IconLoader2 } from "@tabler/icons-react";
|
||||
import { Peer } from "@/interfaces/Peer";
|
||||
import {
|
||||
ChevronsLeftRightEllipsis,
|
||||
ExternalLinkIcon,
|
||||
@@ -15,13 +10,18 @@ import {
|
||||
MonitorIcon,
|
||||
User2,
|
||||
} from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { Peer } from "@/interfaces/Peer";
|
||||
import Separator from "@components/Separator";
|
||||
import Paragraph from "@components/Paragraph";
|
||||
import InlineLink from "@components/InlineLink";
|
||||
import Button from "@components/Button";
|
||||
import { Label } from "@components/Label";
|
||||
import HelpText from "@components/HelpText";
|
||||
import { Input } from "@components/Input";
|
||||
import {
|
||||
RDP_DOCS_LINK,
|
||||
RDPCredentials,
|
||||
} from "@/modules/remote-access/rdp/useRemoteDesktop";
|
||||
import { IconLoader2 } from "@tabler/icons-react";
|
||||
|
||||
type Props = {
|
||||
open: boolean;
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import Button from "@components/Button";
|
||||
import { DropdownMenuItem } from "@components/DropdownMenu";
|
||||
import { getOperatingSystem } from "@hooks/useOperatingSystem";
|
||||
import { CircleHelpIcon, TerminalIcon } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { useState } from "react";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import { OperatingSystem } from "@/interfaces/OperatingSystem";
|
||||
import { Peer } from "@/interfaces/Peer";
|
||||
import { SSHCredentialsModal } from "@/modules/remote-access/ssh/SSHCredentialsModal";
|
||||
import { SSHTooltip } from "@/modules/remote-access/ssh/SSHTooltip";
|
||||
import { getOperatingSystem } from "@hooks/useOperatingSystem";
|
||||
import { OperatingSystem } from "@/interfaces/OperatingSystem";
|
||||
|
||||
type Props = {
|
||||
peer: Peer;
|
||||
@@ -19,8 +19,9 @@ export const SSHButton = ({ peer, isDropdown = false }: Props) => {
|
||||
const [modal, setModal] = useState(false);
|
||||
const { permission } = usePermissions();
|
||||
|
||||
const disabled =
|
||||
!peer.connected || !peer.ssh_enabled || !permission.peers.update;
|
||||
const isSSHEnabled =
|
||||
peer?.local_flags?.server_ssh_allowed || peer?.ssh_enabled;
|
||||
const disabled = !peer.connected || !permission.peers.update || !isSSHEnabled;
|
||||
|
||||
const hasPermission = permission.peers.update;
|
||||
|
||||
@@ -42,7 +43,7 @@ export const SSHButton = ({ peer, isDropdown = false }: Props) => {
|
||||
<div>
|
||||
<SSHTooltip
|
||||
isOnline={peer.connected}
|
||||
isSSHEnabled={peer.ssh_enabled}
|
||||
isSSHEnabled={isSSHEnabled}
|
||||
hasPermission={hasPermission}
|
||||
side={isDropdown ? "left" : "top"}
|
||||
>
|
||||
|
||||
@@ -78,7 +78,8 @@ const SSHDisabledText = ({
|
||||
<div className={"max-w-xs text-xs flex flex-col gap-2"}>
|
||||
<div>
|
||||
SSH Access is currently disabled for this peer. Please enable SSH access
|
||||
for this peer and make sure SSH is allowed in the NetBird Client.
|
||||
for this peer and make sure to add an explicit access control policy
|
||||
allowing SSH access.
|
||||
</div>
|
||||
<div>
|
||||
<InlineLink
|
||||
|
||||
@@ -24,6 +24,8 @@ export enum SSHStatus {
|
||||
export const SSH_DOCS_LINK =
|
||||
"https://docs.netbird.io/how-to/browser-client#ssh-connection";
|
||||
|
||||
const SSH_DETECTION_TIMEOUT_MS = 20000;
|
||||
|
||||
export const useSSH = (client: any) => {
|
||||
const [status, setStatus] = useState(SSHStatus.DISCONNECTED);
|
||||
const [config, setConfig] = useState<SSHConfig | null>(null);
|
||||
@@ -38,12 +40,31 @@ export const useSSH = (client: any) => {
|
||||
|
||||
setStatus(SSHStatus.CONNECTING);
|
||||
setConfig(config);
|
||||
setError("");
|
||||
|
||||
try {
|
||||
const requiresJwt = await client.detectSSHServerType(
|
||||
config.hostname,
|
||||
config.port,
|
||||
);
|
||||
let requiresJwt = false;
|
||||
try {
|
||||
requiresJwt = await client.detectSSHServerType(
|
||||
config.hostname,
|
||||
config.port,
|
||||
SSH_DETECTION_TIMEOUT_MS,
|
||||
);
|
||||
console.log("Detection:", { requiresJwt, hasToken: !!accessToken });
|
||||
} catch (detectionErr) {
|
||||
console.error(
|
||||
"Detection failed, falling back to pubkey:",
|
||||
detectionErr,
|
||||
);
|
||||
}
|
||||
|
||||
if (requiresJwt && !accessToken) {
|
||||
console.error("No access token available");
|
||||
setError("No access token available");
|
||||
setStatus(SSHStatus.DISCONNECTED);
|
||||
setConfig(null);
|
||||
return SSHStatus.DISCONNECTED;
|
||||
}
|
||||
|
||||
const ssh = await client.createSSHConnection(
|
||||
config.hostname,
|
||||
@@ -63,7 +84,7 @@ export const useSSH = (client: any) => {
|
||||
setStatus(SSHStatus.CONNECTED);
|
||||
return SSHStatus.CONNECTED;
|
||||
} catch (err) {
|
||||
console.error("SSH connection failed:", err);
|
||||
console.error("Connection failed:", err);
|
||||
session.current = null;
|
||||
setStatus(SSHStatus.DISCONNECTED);
|
||||
setError("SSH connection failed. Check the console for details.");
|
||||
|
||||
@@ -209,11 +209,11 @@ export const useNetBirdClient = () => {
|
||||
}, []);
|
||||
|
||||
const detectSSHServerType = useCallback(
|
||||
async (host: string, port: number): Promise<boolean> => {
|
||||
async (host: string, port: number, timeoutMs: number): Promise<boolean> => {
|
||||
if (!netBirdClient.current?.detectSSHServerType) {
|
||||
throw new Error("NetBird client not ready");
|
||||
}
|
||||
return netBirdClient.current.detectSSHServerType(host, port);
|
||||
return netBirdClient.current.detectSSHServerType(host, port, timeoutMs);
|
||||
},
|
||||
[],
|
||||
);
|
||||
@@ -228,7 +228,12 @@ export const useNetBirdClient = () => {
|
||||
if (!netBirdClient.current?.createSSHConnection) {
|
||||
throw new Error("Go client not ready");
|
||||
}
|
||||
return netBirdClient.current.createSSHConnection(host, port, username);
|
||||
return netBirdClient.current.createSSHConnection(
|
||||
host,
|
||||
port,
|
||||
username,
|
||||
jwtToken,
|
||||
);
|
||||
},
|
||||
[],
|
||||
);
|
||||
@@ -282,7 +287,12 @@ export const useNetBirdClient = () => {
|
||||
{
|
||||
name,
|
||||
wg_pub_key: keyPairs.publicKey,
|
||||
rules: rules ?? ["tcp/22022", "tcp/3389", "tcp/44338"],
|
||||
rules: rules ?? [
|
||||
"tcp/22022",
|
||||
"tcp/3389",
|
||||
"tcp/44338",
|
||||
"netbird-ssh/22",
|
||||
],
|
||||
},
|
||||
`/${peerId}/temporary-access`,
|
||||
);
|
||||
|
||||
@@ -175,7 +175,7 @@ export default function NetworkRoutesTable({
|
||||
wrapperProps={isGroupPage ? { className: "mt-6 w-full" } : undefined}
|
||||
paginationPaddingClassName={isGroupPage ? "px-0 pt-8" : undefined}
|
||||
tableClassName={isGroupPage ? "mt-0 mb-2" : undefined}
|
||||
inset={!isGroupPage}
|
||||
inset={false}
|
||||
minimal={isGroupPage}
|
||||
keepStateInLocalStorage={!isGroupPage}
|
||||
searchPlaceholder={"Search by network, range, name or groups..."}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import DescriptionWithTooltip from "@components/ui/DescriptionWithTooltip";
|
||||
import GroupBadge from "@components/ui/GroupBadge";
|
||||
import PeerBadge from "@components/ui/PeerBadge";
|
||||
import PeerCountBadge from "@components/ui/PeerCountBadge";
|
||||
import { ArrowRightIcon } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { useMemo } from "react";
|
||||
@@ -44,9 +44,13 @@ export default function RoutePeerCell({ route }: Props) {
|
||||
|
||||
{group && (
|
||||
<>
|
||||
<GroupBadge group={group} />
|
||||
<GroupBadge
|
||||
group={group}
|
||||
redirectToGroupPage={true}
|
||||
redirectGroupTab={"peers"}
|
||||
/>
|
||||
<ArrowRightIcon size={14} className={"shrink-0"} />
|
||||
<PeerBadge> {group.peers_count} Peer(s)</PeerBadge>
|
||||
<PeerCountBadge group={group} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,16 +1,27 @@
|
||||
import Breadcrumbs from "@components/Breadcrumbs";
|
||||
import Button from "@components/Button";
|
||||
import FancyToggleSwitch from "@components/FancyToggleSwitch";
|
||||
import HelpText from "@components/HelpText";
|
||||
import InlineLink from "@components/InlineLink";
|
||||
import { Input } from "@components/Input";
|
||||
import { Label } from "@components/Label";
|
||||
import { notify } from "@components/Notification";
|
||||
import {
|
||||
SelectDropdown,
|
||||
SelectOption,
|
||||
} from "@components/select/SelectDropdown";
|
||||
import { useHasChanges } from "@hooks/useHasChanges";
|
||||
import * as Tabs from "@radix-ui/react-tabs";
|
||||
import { useApiCall } from "@utils/api";
|
||||
import { validator } from "@utils/helpers";
|
||||
import {
|
||||
ClockFadingIcon,
|
||||
ExternalLinkIcon,
|
||||
FlaskConicalIcon,
|
||||
MonitorSmartphoneIcon,
|
||||
RefreshCcw,
|
||||
} from "lucide-react";
|
||||
import React, { useState } from "react";
|
||||
import React, { useMemo, useState } from "react";
|
||||
import { useSWRConfig } from "swr";
|
||||
import SettingsIcon from "@/assets/icons/SettingsIcon";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
@@ -20,6 +31,21 @@ type Props = {
|
||||
account: Account;
|
||||
};
|
||||
|
||||
const latestOrCustomVersion = [
|
||||
{
|
||||
label: "Disabled",
|
||||
value: "disabled",
|
||||
},
|
||||
{
|
||||
label: "Latest Version",
|
||||
value: "latest",
|
||||
},
|
||||
{
|
||||
label: "Custom Version",
|
||||
value: "custom",
|
||||
},
|
||||
] as SelectOption[];
|
||||
|
||||
export default function ClientSettingsTab({ account }: Readonly<Props>) {
|
||||
const { permission } = usePermissions();
|
||||
|
||||
@@ -30,6 +56,77 @@ export default function ClientSettingsTab({ account }: Readonly<Props>) {
|
||||
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 [autoUpdateCustomVersion, setAutoUpdateCustomVersion] = useState(
|
||||
isCustomVersion ? autoUpdateSetting : "",
|
||||
);
|
||||
|
||||
const { hasChanges, updateRef } = useHasChanges([
|
||||
autoUpdateMethod,
|
||||
autoUpdateCustomVersion,
|
||||
]);
|
||||
|
||||
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 canSaveCustomVersion =
|
||||
autoUpdateCustomVersion !== "" &&
|
||||
autoUpdateMethod === "custom" &&
|
||||
versionError === "";
|
||||
|
||||
const isSaveButtonDisabled = useMemo(() => {
|
||||
return (
|
||||
!hasChanges ||
|
||||
!permission.settings.update ||
|
||||
(autoUpdateMethod === "custom" && !canSaveCustomVersion)
|
||||
);
|
||||
}, [
|
||||
hasChanges,
|
||||
permission.settings.update,
|
||||
autoUpdateMethod,
|
||||
canSaveCustomVersion,
|
||||
]);
|
||||
|
||||
const saveChanges = async () => {
|
||||
notify({
|
||||
title: "Client Settings",
|
||||
description: `Client settings successfully updated.`,
|
||||
promise: saveRequest
|
||||
.put({
|
||||
id: account.id,
|
||||
settings: {
|
||||
...account.settings,
|
||||
auto_update_version: autoUpdateCustomVersion || autoUpdateMethod,
|
||||
},
|
||||
})
|
||||
.then(() => {
|
||||
mutate("/accounts");
|
||||
updateRef([autoUpdateMethod, autoUpdateCustomVersion]);
|
||||
}),
|
||||
loadingMessage: "Updating client settings...",
|
||||
});
|
||||
};
|
||||
|
||||
const toggleLazyConnection = async (toggle: boolean) => {
|
||||
notify({
|
||||
title: "Lazy Connections",
|
||||
@@ -70,10 +167,48 @@ export default function ClientSettingsTab({ account }: Readonly<Props>) {
|
||||
</Breadcrumbs>
|
||||
<div className={"flex items-start justify-between"}>
|
||||
<h1>Clients</h1>
|
||||
<Button
|
||||
variant={"primary"}
|
||||
disabled={isSaveButtonDisabled}
|
||||
onClick={saveChanges}
|
||||
data-cy={"save-clients-settings"}
|
||||
>
|
||||
Save Changes
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className={"flex flex-col gap-6 w-full mt-8"}>
|
||||
<div className={"mt-0"}>
|
||||
<div className={"flex flex-col relative"}>
|
||||
<Label>
|
||||
<RefreshCcw size={15} />
|
||||
Automatic Updates
|
||||
</Label>
|
||||
<HelpText>
|
||||
Select how NetBird clients handle automatic updates by choosing
|
||||
the latest version, a custom version, or disabling updates
|
||||
altogether.
|
||||
</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>
|
||||
</div>
|
||||
|
||||
<div className={"mt-3"}>
|
||||
<h2 className={"text-lg font-medium"}>
|
||||
Experimental
|
||||
<FlaskConicalIcon
|
||||
|
||||
@@ -168,7 +168,7 @@ export default function SetupKeysTable({
|
||||
wrapperProps={isGroupPage ? { className: "mt-6 w-full" } : undefined}
|
||||
paginationPaddingClassName={isGroupPage ? "px-0 pt-8" : undefined}
|
||||
tableClassName={isGroupPage ? "mt-0 mb-2" : undefined}
|
||||
inset={!isGroupPage}
|
||||
inset={false}
|
||||
minimal={isGroupPage}
|
||||
keepStateInLocalStorage={!isGroupPage}
|
||||
text={"Setup Keys"}
|
||||
|
||||
@@ -4,6 +4,7 @@ import TextWithTooltip from "@components/ui/TextWithTooltip";
|
||||
import { cn, generateColorFromString } from "@utils/helpers";
|
||||
import { orderBy } from "lodash";
|
||||
import * as React from "react";
|
||||
import { useMemo } from "react";
|
||||
import { User } from "@/interfaces/User";
|
||||
import { SmallUserAvatar } from "@/modules/users/SmallUserAvatar";
|
||||
|
||||
@@ -12,6 +13,7 @@ type Props = {
|
||||
max?: number;
|
||||
avatarClassName?: string;
|
||||
side?: "left" | "right" | "top" | "bottom";
|
||||
isAllGroup?: boolean;
|
||||
};
|
||||
|
||||
export const HorizontalUsersStack = ({
|
||||
@@ -19,9 +21,15 @@ export const HorizontalUsersStack = ({
|
||||
max = 3,
|
||||
avatarClassName,
|
||||
side = "top",
|
||||
isAllGroup = false,
|
||||
}: Props) => {
|
||||
let usersToDisplay = orderBy(users?.slice(0, max) || [], ["name"]);
|
||||
|
||||
const userCountText = useMemo(() => {
|
||||
if (isAllGroup) return "All Users";
|
||||
return `${users?.length || 0} User(s)`;
|
||||
}, [users, isAllGroup]);
|
||||
|
||||
return (
|
||||
<FullTooltip
|
||||
side={side}
|
||||
@@ -84,7 +92,7 @@ export const HorizontalUsersStack = ({
|
||||
}}
|
||||
>
|
||||
<UserAvatarCircle
|
||||
name={user.name}
|
||||
name={user?.name || user?.id}
|
||||
className={avatarClassName}
|
||||
hoverEffect={true}
|
||||
/>
|
||||
@@ -97,7 +105,7 @@ export const HorizontalUsersStack = ({
|
||||
users.length > 0 && "group-hover/user-stack:text-nb-gray-200 ",
|
||||
)}
|
||||
>
|
||||
{users?.length || 0} User(s)
|
||||
{userCountText}
|
||||
</div>
|
||||
</div>
|
||||
</FullTooltip>
|
||||
|
||||
73
src/modules/users/UserPeersSection.tsx
Normal file
73
src/modules/users/UserPeersSection.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import * as React from "react";
|
||||
import { Suspense, useMemo } from "react";
|
||||
import { usePortalElement } from "@hooks/usePortalElement";
|
||||
import SkeletonTable, {
|
||||
SkeletonTableHeader,
|
||||
} from "@components/skeletons/SkeletonTable";
|
||||
import { User } from "@/interfaces/User";
|
||||
import useFetchApi from "@utils/api";
|
||||
import { Peer } from "@/interfaces/Peer";
|
||||
import MinimalPeersTable from "@/modules/peer/MinimalPeersTable";
|
||||
import NoResults from "@components/ui/NoResults";
|
||||
import PeerIcon from "@/assets/icons/PeerIcon";
|
||||
import Paragraph from "@components/Paragraph";
|
||||
|
||||
type Props = {
|
||||
user: User;
|
||||
};
|
||||
|
||||
export const UserPeersSection = ({ user }: Props) => {
|
||||
const { ref: headingRef, portalTarget } =
|
||||
usePortalElement<HTMLHeadingElement>();
|
||||
|
||||
const { data: peers, isLoading: isPeersLoading } =
|
||||
useFetchApi<Peer[]>("/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}>Peers</h2>
|
||||
<Paragraph>View all peers registered by this user.</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>
|
||||
);
|
||||
};
|
||||
@@ -68,7 +68,7 @@ const loadConfig = (): Config => {
|
||||
googleAnalyticsID: configJson?.googleAnalyticsID || undefined,
|
||||
googleTagManagerID: configJson?.googleTagManagerID || undefined,
|
||||
wasmPath:
|
||||
configJson?.wasmPath || "https://pkgs.netbird.io/wasm/client/v0.60.0",
|
||||
configJson?.wasmPath || "https://pkgs.netbird.io/wasm/client/v0.60.2",
|
||||
} as Config;
|
||||
};
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user