Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8d6b617cbd | ||
|
|
47db655e9f | ||
|
|
0661cbf9f4 | ||
|
|
240a96fa8b | ||
|
|
43bc069a49 | ||
|
|
936de0f4f3 | ||
|
|
d81b75a946 | ||
|
|
a632eeeef0 | ||
|
|
e2219aeea0 | ||
|
|
63f4c69eb4 | ||
|
|
b1af256296 | ||
|
|
4027894a2e | ||
|
|
af90792595 | ||
|
|
9a401733b3 |
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:
|
||||
- "**"
|
||||
|
||||
@@ -61,7 +61,7 @@ export NETBIRD_GOOGLE_ANALYTICS_ID=${NETBIRD_GOOGLE_ANALYTICS_ID}
|
||||
export NETBIRD_GOOGLE_TAG_MANAGER_ID=${NETBIRD_GOOGLE_TAG_MANAGER_ID}
|
||||
export NETBIRD_TOKEN_SOURCE=${NETBIRD_TOKEN_SOURCE:-accessToken}
|
||||
export NETBIRD_DRAG_QUERY_PARAMS=${NETBIRD_DRAG_QUERY_PARAMS:-false}
|
||||
export NETBIRD_WASM_PATH=${NETBIRD_WASM_PATH:-https://pkgs.netbird.io/wasm/client}
|
||||
export NETBIRD_WASM_PATH=${NETBIRD_WASM_PATH}
|
||||
|
||||
echo "NetBird latest version: ${NETBIRD_LATEST_VERSION}"
|
||||
|
||||
|
||||
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,10 +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",
|
||||
"cypress": "^13.13.0",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-config-next": "^16.0.5",
|
||||
"postcss": "^8",
|
||||
"prettier": "3.0.3",
|
||||
"tailwindcss": "^3"
|
||||
"tailwindcss": "^3.4.17"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ export default function AccessControlPage() {
|
||||
<div className={"p-default py-6"}>
|
||||
<Breadcrumbs>
|
||||
<Breadcrumbs.Item
|
||||
href={"/policies"}
|
||||
href={"/access-control"}
|
||||
label={"Access Control"}
|
||||
icon={<AccessControlIcon size={14} />}
|
||||
/>
|
||||
|
||||
@@ -32,7 +32,7 @@ export default function NameServers() {
|
||||
<div className={"p-default py-6"}>
|
||||
<Breadcrumbs>
|
||||
<Breadcrumbs.Item
|
||||
href={"/dns"}
|
||||
href={"/dns/nameservers"}
|
||||
label={"DNS"}
|
||||
icon={<DNSIcon size={13} />}
|
||||
/>
|
||||
|
||||
8
src/app/(dashboard)/group/layout.tsx
Normal file
8
src/app/(dashboard)/group/layout.tsx
Normal file
@@ -0,0 +1,8 @@
|
||||
import { globalMetaTitle } from "@utils/meta";
|
||||
import type { Metadata } from "next";
|
||||
import BlankLayout from "@/layouts/BlankLayout";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: `Group - ${globalMetaTitle}`,
|
||||
};
|
||||
export default BlankLayout;
|
||||
297
src/app/(dashboard)/group/page.tsx
Normal file
297
src/app/(dashboard)/group/page.tsx
Normal file
@@ -0,0 +1,297 @@
|
||||
"use client";
|
||||
|
||||
import Breadcrumbs from "@components/Breadcrumbs";
|
||||
import FullTooltip from "@components/FullTooltip";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@components/Tabs";
|
||||
import FullScreenLoading from "@components/ui/FullScreenLoading";
|
||||
import { GroupBadgeIcon } from "@components/ui/GroupBadgeIcon";
|
||||
import { PageNotFound } from "@components/ui/PageNotFound";
|
||||
import { RestrictedAccess } from "@components/ui/RestrictedAccess";
|
||||
import useRedirect from "@hooks/useRedirect";
|
||||
import useFetchApi from "@utils/api";
|
||||
import { cn, singularize } from "@utils/helpers";
|
||||
import { FolderGit2Icon, Layers3Icon, PencilIcon } from "lucide-react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import React, { useState } from "react";
|
||||
import AccessControlIcon from "@/assets/icons/AccessControlIcon";
|
||||
import DNSIcon from "@/assets/icons/DNSIcon";
|
||||
import NetworkRoutesIcon from "@/assets/icons/NetworkRoutesIcon";
|
||||
import PeerIcon from "@/assets/icons/PeerIcon";
|
||||
import SetupKeysIcon from "@/assets/icons/SetupKeysIcon";
|
||||
import TeamIcon from "@/assets/icons/TeamIcon";
|
||||
import { GroupProvider, useGroupContext } from "@/contexts/GroupProvider";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import RoutesProvider from "@/contexts/RoutesProvider";
|
||||
import { Group, GROUP_TOOLTIP_TEXT } from "@/interfaces/Group";
|
||||
import PageContainer from "@/layouts/PageContainer";
|
||||
import { GroupNameserversSection } from "@/modules/groups/details/GroupNameserversSection";
|
||||
import { GroupNetworkRoutesSection } from "@/modules/groups/details/GroupNetworkRoutesSection";
|
||||
import { GroupPeersSection } from "@/modules/groups/details/GroupPeersSection";
|
||||
import { GroupPoliciesSection } from "@/modules/groups/details/GroupPoliciesSection";
|
||||
import { GroupResourcesSection } from "@/modules/groups/details/GroupResourcesSection";
|
||||
import { GroupSetupKeysSection } from "@/modules/groups/details/GroupSetupKeysSection";
|
||||
import { GroupUsersSection } from "@/modules/groups/details/GroupUsersSection";
|
||||
import useGroupDetails from "@/modules/groups/details/useGroupDetails";
|
||||
|
||||
export default function GroupPage() {
|
||||
const queryParameter = useSearchParams();
|
||||
const { isRestricted } = usePermissions();
|
||||
const groupId = queryParameter.get("id");
|
||||
const {
|
||||
data: group,
|
||||
isLoading,
|
||||
error,
|
||||
} = useFetchApi<Group>(`/groups/${groupId}`, true);
|
||||
|
||||
useRedirect("/groups", false, !groupId || isRestricted);
|
||||
|
||||
if (isRestricted) {
|
||||
return (
|
||||
<PageContainer>
|
||||
<RestrictedAccess page={"Group Information"} />
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
if (error)
|
||||
return (
|
||||
<PageNotFound
|
||||
title={error?.message}
|
||||
description={
|
||||
"The group you are attempting to access cannot be found. It may have been deleted, or you may not have permission to view it. Please verify the URL or return to the dashboard."
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
return group && !isLoading ? (
|
||||
<PageContainer>
|
||||
<RoutesProvider>
|
||||
<GroupProvider group={group} isDetailPage={true}>
|
||||
<div className={"p-default py-6 pb-0 w-full mb-[6px]"}>
|
||||
<Breadcrumbs>
|
||||
<Breadcrumbs.Item
|
||||
href={"/groups"}
|
||||
label={"Groups"}
|
||||
icon={<FolderGit2Icon size={14} />}
|
||||
/>
|
||||
<Breadcrumbs.Item label={group.name} active />
|
||||
</Breadcrumbs>
|
||||
<GroupDetailsName />
|
||||
</div>
|
||||
<GroupOverviewTabs group={group} />
|
||||
</GroupProvider>
|
||||
</RoutesProvider>
|
||||
</PageContainer>
|
||||
) : (
|
||||
<FullScreenLoading />
|
||||
);
|
||||
}
|
||||
|
||||
const GroupDetailsName = () => {
|
||||
const { group, isJWTGroup, isAllowedToRename, openGroupRenameModal } =
|
||||
useGroupContext();
|
||||
const { permission } = usePermissions();
|
||||
|
||||
return (
|
||||
<div className={"w-full"}>
|
||||
<h1 className={"flex items-center gap-3 w-full whitespace-nowrap"}>
|
||||
<GroupBadgeIcon id={group?.id} issued={group?.issued} size={20} />
|
||||
{group.name}
|
||||
{group.name !== "All" && permission?.groups?.update && (
|
||||
<div>
|
||||
<FullTooltip
|
||||
content={
|
||||
<div className={"text-xs max-w-xs"}>
|
||||
{isJWTGroup
|
||||
? GROUP_TOOLTIP_TEXT.RENAME.JWT
|
||||
: GROUP_TOOLTIP_TEXT.RENAME.INTEGRATION}
|
||||
</div>
|
||||
}
|
||||
interactive={false}
|
||||
disabled={isAllowedToRename}
|
||||
className={"w-full block"}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-8 w-8 items-center justify-center gap-2 dark:text-neutral-300 text-neutral-500 hover:text-neutral-100 transition-all hover:bg-nb-gray-800/60 rounded-md cursor-pointer",
|
||||
!isAllowedToRename &&
|
||||
"opacity-40 cursor-not-allowed pointer-events-none",
|
||||
)}
|
||||
onClick={openGroupRenameModal}
|
||||
>
|
||||
<PencilIcon size={16} />
|
||||
</div>
|
||||
</FullTooltip>
|
||||
</div>
|
||||
)}
|
||||
</h1>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const validAllGroupTabs = [
|
||||
"policies",
|
||||
"resources",
|
||||
"network-routes",
|
||||
"nameservers",
|
||||
];
|
||||
const validOtherGroupTabs = ["users", "peers", "setup-keys"];
|
||||
|
||||
const GroupOverviewTabs = ({ group }: { group: Group }) => {
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const getInitialTab = () => {
|
||||
const isAllGroup = group.name === "All";
|
||||
const tabParam = searchParams.get("tab");
|
||||
const validTabs = isAllGroup
|
||||
? validAllGroupTabs
|
||||
: [...validAllGroupTabs, ...validOtherGroupTabs];
|
||||
if (tabParam === null) return isAllGroup ? "policies" : "users";
|
||||
if (isAllGroup) {
|
||||
return validTabs.includes(tabParam) ? tabParam : "policies";
|
||||
}
|
||||
return validTabs.includes(tabParam) ? tabParam : "users";
|
||||
};
|
||||
|
||||
const [tab, setTab] = useState(getInitialTab());
|
||||
const groupDetails = useGroupDetails(group?.id || "");
|
||||
|
||||
const peersCount = groupDetails?.peers_count || 0;
|
||||
const usersCount = groupDetails?.users?.length || 0;
|
||||
const policiesCount = groupDetails?.policies?.length || 0;
|
||||
const resourcesCount = groupDetails?.resources_count || 0;
|
||||
const routesCount = groupDetails?.routes?.length || 0;
|
||||
const nameserversCount = groupDetails?.nameservers?.length || 0;
|
||||
const setupKeysCount = groupDetails?.setupKeys?.length || 0;
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
defaultValue={tab}
|
||||
onValueChange={(v) => setTab(v)}
|
||||
value={tab}
|
||||
className={"pt-2 pb-0 mb-0"}
|
||||
>
|
||||
<TabsList justify={"start"} className={"px-8"}>
|
||||
{group.name !== "All" && (
|
||||
<TabsTrigger
|
||||
value={"users"}
|
||||
className={groupDetails === null ? "animate-pulse" : ""}
|
||||
>
|
||||
<TeamIcon
|
||||
size={12}
|
||||
className={
|
||||
"fill-nb-gray-500 group-data-[state=active]/trigger:fill-netbird transition-all"
|
||||
}
|
||||
/>
|
||||
{singularize("Users", usersCount)}
|
||||
</TabsTrigger>
|
||||
)}
|
||||
|
||||
{group.name !== "All" && (
|
||||
<TabsTrigger
|
||||
value={"peers"}
|
||||
className={groupDetails === null ? "animate-pulse" : ""}
|
||||
>
|
||||
<PeerIcon
|
||||
size={12}
|
||||
className={
|
||||
"fill-nb-gray-500 group-data-[state=active]/trigger:fill-netbird transition-all"
|
||||
}
|
||||
/>
|
||||
{singularize("Peers", peersCount)}
|
||||
</TabsTrigger>
|
||||
)}
|
||||
|
||||
<TabsTrigger
|
||||
value={"policies"}
|
||||
className={groupDetails === null ? "animate-pulse" : ""}
|
||||
>
|
||||
<AccessControlIcon
|
||||
size={12}
|
||||
className={
|
||||
"fill-nb-gray-500 group-data-[state=active]/trigger:fill-netbird transition-all"
|
||||
}
|
||||
/>
|
||||
{singularize("Policies", policiesCount)}
|
||||
</TabsTrigger>
|
||||
|
||||
<TabsTrigger
|
||||
value={"resources"}
|
||||
className={groupDetails === null ? "animate-pulse" : ""}
|
||||
>
|
||||
<Layers3Icon size={14} />
|
||||
{singularize("Resources", resourcesCount)}
|
||||
</TabsTrigger>
|
||||
|
||||
<TabsTrigger
|
||||
value={"network-routes"}
|
||||
className={groupDetails === null ? "animate-pulse" : ""}
|
||||
>
|
||||
<NetworkRoutesIcon
|
||||
size={12}
|
||||
className={
|
||||
"fill-nb-gray-500 group-data-[state=active]/trigger:fill-netbird transition-all"
|
||||
}
|
||||
/>
|
||||
{singularize("Network Routes", routesCount)}
|
||||
</TabsTrigger>
|
||||
|
||||
<TabsTrigger
|
||||
value={"nameservers"}
|
||||
className={groupDetails === null ? "animate-pulse" : ""}
|
||||
>
|
||||
<DNSIcon
|
||||
size={12}
|
||||
className={
|
||||
"fill-nb-gray-500 group-data-[state=active]/trigger:fill-netbird transition-all"
|
||||
}
|
||||
/>
|
||||
{singularize("Nameservers", nameserversCount)}
|
||||
</TabsTrigger>
|
||||
|
||||
{group.name !== "All" && (
|
||||
<TabsTrigger
|
||||
value={"setup-keys"}
|
||||
className={groupDetails === null ? "animate-pulse" : ""}
|
||||
>
|
||||
<SetupKeysIcon
|
||||
size={12}
|
||||
className={
|
||||
"fill-nb-gray-500 group-data-[state=active]/trigger:fill-netbird transition-all"
|
||||
}
|
||||
/>
|
||||
{singularize("Setup Keys", setupKeysCount)}
|
||||
</TabsTrigger>
|
||||
)}
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value={"users"} className={"pb-8"}>
|
||||
<GroupUsersSection users={groupDetails?.users} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value={"peers"} className={"pb-8"}>
|
||||
<GroupPeersSection peers={groupDetails?.peersOfGroup} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value={"policies"} className={"pb-8"}>
|
||||
<GroupPoliciesSection policies={groupDetails?.policies} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value={"resources"} className={"pb-8"}>
|
||||
<GroupResourcesSection resources={groupDetails?.networkResources} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value={"network-routes"} className={"pb-8"}>
|
||||
<GroupNetworkRoutesSection routes={groupDetails?.routes} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value={"nameservers"} className={"pb-8"}>
|
||||
<GroupNameserversSection nameserverGroups={groupDetails?.nameservers} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value={"setup-keys"} className={"pb-8"}>
|
||||
<GroupSetupKeysSection setupKeys={groupDetails?.setupKeys} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
);
|
||||
};
|
||||
8
src/app/(dashboard)/groups/layout.tsx
Normal file
8
src/app/(dashboard)/groups/layout.tsx
Normal file
@@ -0,0 +1,8 @@
|
||||
import { globalMetaTitle } from "@utils/meta";
|
||||
import type { Metadata } from "next";
|
||||
import BlankLayout from "@/layouts/BlankLayout";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: `Groups - ${globalMetaTitle}`,
|
||||
};
|
||||
export default BlankLayout;
|
||||
56
src/app/(dashboard)/groups/page.tsx
Normal file
56
src/app/(dashboard)/groups/page.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
"use client";
|
||||
|
||||
import Paragraph from "@components/Paragraph";
|
||||
import SkeletonTable from "@components/skeletons/SkeletonTable";
|
||||
import { RestrictedAccess } from "@components/ui/RestrictedAccess";
|
||||
import { usePortalElement } from "@hooks/usePortalElement";
|
||||
import { ExternalLinkIcon, FolderGit2Icon } from "lucide-react";
|
||||
import React, { lazy, Suspense } from "react";
|
||||
import Breadcrumbs from "@/components/Breadcrumbs";
|
||||
import InlineLink from "@/components/InlineLink";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import PageContainer from "@/layouts/PageContainer";
|
||||
|
||||
const GroupsTable = lazy(() => import("@/modules/groups/table/GroupsTable"));
|
||||
|
||||
export default function GroupsPage() {
|
||||
const { permission } = usePermissions();
|
||||
const { ref: headingRef, portalTarget } =
|
||||
usePortalElement<HTMLHeadingElement>();
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<div className={"p-default py-6"}>
|
||||
<Breadcrumbs>
|
||||
<Breadcrumbs.Item
|
||||
href={"/groups"}
|
||||
label={"Groups"}
|
||||
icon={<FolderGit2Icon size={14} />}
|
||||
active
|
||||
/>
|
||||
</Breadcrumbs>
|
||||
<h1 ref={headingRef}>Groups</h1>
|
||||
<Paragraph>
|
||||
Here is the overview of the groups of your organization. You can
|
||||
delete the unused ones.
|
||||
</Paragraph>
|
||||
<Paragraph>
|
||||
Learn more about{" "}
|
||||
<InlineLink
|
||||
href={"https://docs.netbird.io/how-to/manage-network-access"}
|
||||
target={"_blank"}
|
||||
>
|
||||
Groups
|
||||
<ExternalLinkIcon size={12} />
|
||||
</InlineLink>
|
||||
in our documentation.
|
||||
</Paragraph>
|
||||
</div>
|
||||
<RestrictedAccess hasAccess={permission.groups.read} page={"Groups"}>
|
||||
<Suspense fallback={<SkeletonTable />}>
|
||||
<GroupsTable headingTarget={portalTarget} />
|
||||
</Suspense>
|
||||
</RestrictedAccess>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1,14 @@
|
||||
"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";
|
||||
import { RestrictedAccess } from "@components/ui/RestrictedAccess";
|
||||
import { usePortalElement } from "@hooks/usePortalElement";
|
||||
import useFetchApi from "@utils/api";
|
||||
import { ExternalLinkIcon } from "lucide-react";
|
||||
import { ArrowUpRightIcon, ExternalLinkIcon } from "lucide-react";
|
||||
import React, { lazy, Suspense } from "react";
|
||||
import NetworkRoutesIcon from "@/assets/icons/NetworkRoutesIcon";
|
||||
import PeersProvider from "@/contexts/PeersProvider";
|
||||
@@ -59,6 +60,17 @@ export default function NetworkRoutes() {
|
||||
</InlineLink>
|
||||
in our documentation.
|
||||
</Paragraph>
|
||||
|
||||
<Callout className={"max-w-xl mt-3"} variant={"warning"}>
|
||||
<span>
|
||||
We recommend using the new Networks concept to easier visualise
|
||||
and manage access to your resources.{" "}
|
||||
<InlineLink href={"/networks"}>
|
||||
Go to Networks
|
||||
<ArrowUpRightIcon size={14} />
|
||||
</InlineLink>
|
||||
</span>
|
||||
</Callout>
|
||||
</div>
|
||||
|
||||
<RestrictedAccess hasAccess={permission.routes.read}>
|
||||
|
||||
@@ -19,9 +19,9 @@ import { useAccount } from "@/modules/account/useAccount";
|
||||
import AuthenticationTab from "@/modules/settings/AuthenticationTab";
|
||||
import ClientSettingsTab from "@/modules/settings/ClientSettingsTab";
|
||||
import DangerZoneTab from "@/modules/settings/DangerZoneTab";
|
||||
import GroupsTab from "@/modules/settings/GroupsTab";
|
||||
import NetworkSettingsTab from "@/modules/settings/NetworkSettingsTab";
|
||||
import PermissionsTab from "@/modules/settings/PermissionsTab";
|
||||
import GroupsSettings from "@/modules/settings/GroupsSettings";
|
||||
|
||||
export default function NetBirdSettings() {
|
||||
const queryParams = useSearchParams();
|
||||
@@ -81,7 +81,7 @@ export default function NetBirdSettings() {
|
||||
<div className={"border-l border-nb-gray-930 w-full"}>
|
||||
{account && <AuthenticationTab account={account} />}
|
||||
{account && <PermissionsTab account={account} />}
|
||||
{account && <GroupsTab account={account} />}
|
||||
{account && <GroupsSettings account={account} />}
|
||||
{account && <NetworkSettingsTab account={account} />}
|
||||
{account && <ClientSettingsTab account={account} />}
|
||||
{account && <DangerZoneTab account={account} />}
|
||||
|
||||
@@ -31,7 +31,7 @@ export default function RDPPage() {
|
||||
} = useFetchApi<Peer>(`/peers/${peerId}`, true, false, !!peerId);
|
||||
|
||||
return (
|
||||
<div className={"w-screen h-screen overflow-hidden"}>
|
||||
<div className={"w-screen h-screen overflow-hidden fixed inset-0"}>
|
||||
{peerId && peer && !isLoading ? (
|
||||
<RDPSession key={peer.id} peer={peer} />
|
||||
) : (
|
||||
|
||||
@@ -108,6 +108,7 @@ 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}`];
|
||||
@@ -121,7 +122,7 @@ function SSHTerminal({ username, port, peer }: Props) {
|
||||
sshConnectedOnce.current = true;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Connection failed:", error);
|
||||
console.error("Connection error:", error);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
<svg width="573" height="148" viewBox="0 0 573 148" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_4_4)">
|
||||
<path d="M0.739014 125.602V33.09H28.239C34.491 33.09 39.919 34.274 44.524 36.638C49.128 39.004 52.698 42.341 55.233 46.65C57.767 50.958 59.034 56.071 59.034 61.984V96.58C59.034 102.41 57.767 107.5 55.233 111.85C52.698 116.203 49.128 119.581 44.523 121.99C39.918 124.397 34.491 125.601 28.239 125.601L0.739014 125.602ZM16.58 111.028H28.24C32.801 111.028 36.433 109.741 39.138 107.163C41.841 104.587 43.193 101.06 43.193 96.581V61.984C43.193 57.592 41.841 54.107 39.138 51.529C36.433 48.952 32.801 47.663 28.239 47.663H16.58V111.028ZM76.396 125.602V33.09H95.786L121.512 107.86C121.216 104.673 120.942 101.483 120.688 98.292C120.386 94.5366 120.154 90.776 119.991 87.012C119.821 83.169 119.738 79.812 119.738 76.938V33.09H134.185V125.602H114.795L89.323 50.832C89.491 53.283 89.703 56.239 89.956 59.702C90.2137 63.2478 90.4251 66.7967 90.59 70.348C90.758 73.982 90.844 77.318 90.844 80.36V125.602H76.396ZM181.582 126.869C175.245 126.869 169.752 125.812 165.107 123.701C160.46 121.591 156.889 118.569 154.398 114.64C151.905 110.711 150.616 106.086 150.533 100.763H166.374C166.374 104.565 167.747 107.543 170.494 109.698C173.238 111.852 176.976 112.929 181.709 112.929C186.271 112.929 189.839 111.874 192.417 109.761C194.993 107.65 196.282 104.735 196.282 101.017C196.282 97.892 195.374 95.167 193.558 92.842C191.74 90.52 189.142 88.936 185.764 88.09L175.119 85.175C167.852 83.318 162.256 79.979 158.327 75.164C154.398 70.348 152.434 64.518 152.434 57.675C152.434 52.438 153.616 47.875 155.982 43.988C158.347 40.103 161.705 37.103 166.058 34.99C170.408 32.88 175.54 31.822 181.455 31.822C190.409 31.822 197.506 34.125 202.745 38.729C207.983 43.335 210.645 49.523 210.73 57.295H194.888C194.888 53.663 193.704 50.812 191.34 48.741C188.974 46.671 185.637 45.636 181.328 45.636C177.188 45.636 173.978 46.608 171.697 48.551C169.416 50.495 168.275 53.24 168.275 56.788C168.275 60 169.141 62.724 170.873 64.962C172.603 67.202 175.119 68.786 178.413 69.714L189.439 72.756C196.789 74.616 202.407 77.932 206.294 82.704C210.179 87.478 212.124 93.371 212.124 100.383C212.124 105.623 210.856 110.248 208.322 114.26C205.787 118.273 202.239 121.378 197.677 123.574C193.114 125.77 187.748 126.869 181.582 126.869ZM257.366 126.869C251.366 126.869 246.17 125.729 241.778 123.448C237.384 121.167 233.985 117.957 231.577 113.816C229.169 109.678 227.965 104.818 227.965 99.242V59.45C227.965 53.874 229.169 49.017 231.577 44.876C233.984 40.738 237.384 37.526 241.778 35.245C246.17 32.964 251.366 31.823 257.366 31.823C263.449 31.823 268.665 32.963 273.017 35.245C277.367 37.526 280.747 40.738 283.156 44.876C285.563 49.016 286.767 53.874 286.767 59.45V99.243C286.767 104.819 285.563 109.679 283.156 113.817C280.748 117.957 277.346 121.167 272.954 123.449C268.56 125.729 263.364 126.869 257.366 126.869ZM257.366 113.183C261.758 113.183 265.265 111.915 267.885 109.381C270.502 106.846 271.813 103.468 271.813 99.242V59.45C271.813 55.227 270.503 51.846 267.885 49.312C265.265 46.777 261.758 45.51 257.366 45.51C252.972 45.51 249.466 46.777 246.848 49.312C244.228 51.846 242.918 55.227 242.918 59.45V99.243C242.918 103.469 244.228 106.847 246.848 109.382C249.465 111.916 252.972 113.183 257.366 113.183ZM257.366 87.583C254.915 87.583 252.909 86.781 251.346 85.175C249.782 83.571 249.002 81.5 249.002 78.965C249.002 76.431 249.762 74.403 251.283 72.883C252.803 71.362 254.831 70.601 257.366 70.601C259.901 70.601 261.928 71.361 263.449 72.883C264.969 74.403 265.73 76.431 265.73 78.966C265.73 81.5 264.97 83.571 263.45 85.176C261.928 86.781 259.9 87.583 257.366 87.583Z" fill="black"/>
|
||||
<path d="M332.69 126.999C329.057 126.999 326.164 125.941 324.01 123.831C321.855 121.72 320.778 118.847 320.778 115.213C320.778 111.581 321.855 108.686 324.01 106.532C326.164 104.378 329.057 103.3 332.69 103.3C336.322 103.3 339.217 104.378 341.372 106.532C343.526 108.686 344.603 111.582 344.603 115.213C344.603 118.847 343.526 121.72 341.372 123.831C339.217 125.941 336.322 126.999 332.691 126.999H332.69ZM381.862 125.732V33.219H437.369V47.032H397.449V71.112H432.934V84.798H397.45V111.918H437.37V125.732H381.862ZM484.766 126.999C475.725 126.999 468.65 124.528 463.539 119.585C458.426 114.643 455.872 107.906 455.872 99.372V33.219H471.84V99.245C471.84 103.639 472.937 107.061 475.135 109.51C477.331 111.962 480.54 113.185 484.766 113.185C488.905 113.185 492.095 111.962 494.334 109.51C496.572 107.06 497.693 103.639 497.693 99.245V33.219H513.66V99.372C513.66 107.906 511.126 114.642 506.057 119.585C500.987 124.528 493.891 126.999 484.767 126.999H484.766Z" fill="#686868"/>
|
||||
<path d="M549 0H573V148H549V0Z" fill="#FFCC03"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_4_4">
|
||||
<rect width="573" height="148" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 4.7 KiB |
@@ -1,12 +0,0 @@
|
||||
<svg width="573" height="148" viewBox="0 0 573 148" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_4_4)">
|
||||
<path d="M0.739014 125.602V33.09H28.239C34.491 33.09 39.919 34.274 44.524 36.638C49.128 39.004 52.698 42.341 55.233 46.65C57.767 50.958 59.034 56.071 59.034 61.984V96.58C59.034 102.41 57.767 107.5 55.233 111.85C52.698 116.203 49.128 119.581 44.523 121.99C39.918 124.397 34.491 125.601 28.239 125.601L0.739014 125.602ZM16.58 111.028H28.24C32.801 111.028 36.433 109.741 39.138 107.163C41.841 104.587 43.193 101.06 43.193 96.581V61.984C43.193 57.592 41.841 54.107 39.138 51.529C36.433 48.952 32.801 47.663 28.239 47.663H16.58V111.028ZM76.396 125.602V33.09H95.786L121.512 107.86C121.216 104.673 120.942 101.483 120.688 98.292C120.386 94.5366 120.154 90.776 119.991 87.012C119.821 83.169 119.738 79.812 119.738 76.938V33.09H134.185V125.602H114.795L89.323 50.832C89.491 53.283 89.703 56.239 89.956 59.702C90.2137 63.2478 90.4251 66.7967 90.59 70.348C90.758 73.982 90.844 77.318 90.844 80.36V125.602H76.396ZM181.582 126.869C175.245 126.869 169.752 125.812 165.107 123.701C160.46 121.591 156.889 118.569 154.398 114.64C151.905 110.711 150.616 106.086 150.533 100.763H166.374C166.374 104.565 167.747 107.543 170.494 109.698C173.238 111.852 176.976 112.929 181.709 112.929C186.271 112.929 189.839 111.874 192.417 109.761C194.993 107.65 196.282 104.735 196.282 101.017C196.282 97.892 195.374 95.167 193.558 92.842C191.74 90.52 189.142 88.936 185.764 88.09L175.119 85.175C167.852 83.318 162.256 79.979 158.327 75.164C154.398 70.348 152.434 64.518 152.434 57.675C152.434 52.438 153.616 47.875 155.982 43.988C158.347 40.103 161.705 37.103 166.058 34.99C170.408 32.88 175.54 31.822 181.455 31.822C190.409 31.822 197.506 34.125 202.745 38.729C207.983 43.335 210.645 49.523 210.73 57.295H194.888C194.888 53.663 193.704 50.812 191.34 48.741C188.974 46.671 185.637 45.636 181.328 45.636C177.188 45.636 173.978 46.608 171.697 48.551C169.416 50.495 168.275 53.24 168.275 56.788C168.275 60 169.141 62.724 170.873 64.962C172.603 67.202 175.119 68.786 178.413 69.714L189.439 72.756C196.789 74.616 202.407 77.932 206.294 82.704C210.179 87.478 212.124 93.371 212.124 100.383C212.124 105.623 210.856 110.248 208.322 114.26C205.787 118.273 202.239 121.378 197.677 123.574C193.114 125.77 187.748 126.869 181.582 126.869ZM257.366 126.869C251.366 126.869 246.17 125.729 241.778 123.448C237.384 121.167 233.985 117.957 231.577 113.816C229.169 109.678 227.965 104.818 227.965 99.242V59.45C227.965 53.874 229.169 49.017 231.577 44.876C233.984 40.738 237.384 37.526 241.778 35.245C246.17 32.964 251.366 31.823 257.366 31.823C263.449 31.823 268.665 32.963 273.017 35.245C277.367 37.526 280.747 40.738 283.156 44.876C285.563 49.016 286.767 53.874 286.767 59.45V99.243C286.767 104.819 285.563 109.679 283.156 113.817C280.748 117.957 277.346 121.167 272.954 123.449C268.56 125.729 263.364 126.869 257.366 126.869ZM257.366 113.183C261.758 113.183 265.265 111.915 267.885 109.381C270.502 106.846 271.813 103.468 271.813 99.242V59.45C271.813 55.227 270.503 51.846 267.885 49.312C265.265 46.777 261.758 45.51 257.366 45.51C252.972 45.51 249.466 46.777 246.848 49.312C244.228 51.846 242.918 55.227 242.918 59.45V99.243C242.918 103.469 244.228 106.847 246.848 109.382C249.465 111.916 252.972 113.183 257.366 113.183ZM257.366 87.583C254.915 87.583 252.909 86.781 251.346 85.175C249.782 83.571 249.002 81.5 249.002 78.965C249.002 76.431 249.762 74.403 251.283 72.883C252.803 71.362 254.831 70.601 257.366 70.601C259.901 70.601 261.928 71.361 263.449 72.883C264.969 74.403 265.73 76.431 265.73 78.966C265.73 81.5 264.97 83.571 263.45 85.176C261.928 86.781 259.9 87.583 257.366 87.583Z" fill="black"/>
|
||||
<path d="M332.69 126.999C329.057 126.999 326.164 125.941 324.01 123.831C321.855 121.72 320.778 118.847 320.778 115.213C320.778 111.581 321.855 108.686 324.01 106.532C326.164 104.378 329.057 103.3 332.69 103.3C336.322 103.3 339.217 104.378 341.372 106.532C343.526 108.686 344.603 111.582 344.603 115.213C344.603 118.847 343.526 121.72 341.372 123.831C339.217 125.941 336.322 126.999 332.691 126.999H332.69ZM381.862 125.732V33.219H437.369V47.032H397.449V71.112H432.934V84.798H397.45V111.918H437.37V125.732H381.862ZM484.766 126.999C475.725 126.999 468.65 124.528 463.539 119.585C458.426 114.643 455.872 107.906 455.872 99.372V33.219H471.84V99.245C471.84 103.639 472.937 107.061 475.135 109.51C477.331 111.962 480.54 113.185 484.766 113.185C488.905 113.185 492.095 111.962 494.334 109.51C496.572 107.06 497.693 103.639 497.693 99.245V33.219H513.66V99.372C513.66 107.906 511.126 114.642 506.057 119.585C500.987 124.528 493.891 126.999 484.767 126.999H484.766Z" fill="#359CEF"/>
|
||||
<path d="M549 0H573V148H549V0Z" fill="#FFCC03"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_4_4">
|
||||
<rect width="573" height="148" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 4.7 KiB |
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 |
@@ -34,7 +34,7 @@ const ButtonGroupButton = forwardRef(
|
||||
border={2}
|
||||
rounded={false}
|
||||
className={cn(
|
||||
"first:border-l-0 last:border-r-0 border-t-0 border-b-0 h-[41px]",
|
||||
"first:border-l-0 last:border-r-0 border-t-0 border-b-0 h-[40px]",
|
||||
"!py-2.5 !px-4",
|
||||
className,
|
||||
)}
|
||||
|
||||
@@ -171,7 +171,15 @@ export function PeerGroupSelector({
|
||||
const groupResources: GroupResource[] | undefined =
|
||||
(group?.resources as GroupResource[]) || [];
|
||||
|
||||
if (peer) groupPeers?.push({ id: peer?.id as string, name: peer?.name });
|
||||
if (peer) {
|
||||
const peerInGroup = groupPeers?.find((p) => p?.id === peer?.id);
|
||||
if (!peerInGroup) {
|
||||
groupPeers?.push({
|
||||
id: peer?.id as string,
|
||||
name: peer?.name,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!group && !option) {
|
||||
addDropdownOptions([
|
||||
|
||||
@@ -10,7 +10,7 @@ export default function SkeletonTable({ withHeader = true }: Readonly<Props>) {
|
||||
return (
|
||||
<div className={"w-full"}>
|
||||
{withHeader && <SkeletonTableHeader />}
|
||||
<div className={"mt-6"}>
|
||||
<div className={"mt-6 relative -top-1"}>
|
||||
<TableSkeletonRow />
|
||||
<TableSkeletonRow odd />
|
||||
<TableSkeletonRow />
|
||||
@@ -68,7 +68,7 @@ export const SkeletonTableHeader = ({
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex gap-x-4 gap-y-6 p-default flex-wrap w-full justify-between",
|
||||
"flex gap-x-4 gap-y-6 p-default flex-wrap w-full justify-between relative -top-1",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -133,6 +133,7 @@ interface DataTableProps<TData, TValue> {
|
||||
getStartedCard?: React.ReactNode;
|
||||
placeholders?: TData[];
|
||||
renderExpandedRow?: (row: TData) => React.ReactNode;
|
||||
renderRow?: (row: TData, children: React.ReactNode) => React.ReactNode;
|
||||
minimal?: boolean;
|
||||
className?: string;
|
||||
inset?: boolean;
|
||||
@@ -193,6 +194,7 @@ export function DataTable<TData, TValue>({
|
||||
onRowClick,
|
||||
getStartedCard,
|
||||
renderExpandedRow,
|
||||
renderRow,
|
||||
minimal,
|
||||
className,
|
||||
tableClassName,
|
||||
@@ -507,7 +509,7 @@ export function DataTable<TData, TValue>({
|
||||
{table.getRowModel().rows?.length ? (
|
||||
table.getRowModel().rows.map((row) => {
|
||||
const expandedRow = renderExpandedRow?.(row.original);
|
||||
return (
|
||||
const rowContent = (
|
||||
<AccordionItem
|
||||
value={row.original.id}
|
||||
asChild={true}
|
||||
@@ -597,6 +599,8 @@ export function DataTable<TData, TValue>({
|
||||
</>
|
||||
</AccordionItem>
|
||||
);
|
||||
|
||||
return renderRow ? renderRow(row.original, rowContent) : rowContent;
|
||||
})
|
||||
) : (
|
||||
<TableRowUnstyledComponent>
|
||||
|
||||
89
src/components/table/DataTableMultiSelectPopup.tsx
Normal file
89
src/components/table/DataTableMultiSelectPopup.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import Button from "@components/Button";
|
||||
import FullTooltip from "@components/FullTooltip";
|
||||
import { IconX } from "@tabler/icons-react";
|
||||
import { cn } from "@utils/helpers";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { MonitorSmartphoneIcon } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
type Props<T> = {
|
||||
selectedItems?: T[];
|
||||
label?: string;
|
||||
onCanceled?: () => void;
|
||||
rightSide?: React.ReactNode;
|
||||
};
|
||||
|
||||
export function DataTableMultiSelectPopup<T>({
|
||||
onCanceled,
|
||||
label = "Peer(s) selected",
|
||||
selectedItems,
|
||||
rightSide,
|
||||
}: Props<T>) {
|
||||
const count = selectedItems?.length || 0;
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{count > 0 && (
|
||||
<div
|
||||
className={"fixed -bottom-16 z-50 w-full left-0 pointer-events-none"}
|
||||
>
|
||||
<motion.div
|
||||
exit={{
|
||||
y: 100,
|
||||
}}
|
||||
>
|
||||
<AnimatePresence>
|
||||
<motion.div
|
||||
animate={{ y: 0 }}
|
||||
initial={{ y: 100 }}
|
||||
exit={{ y: 100 }}
|
||||
transition={{
|
||||
type: "spring",
|
||||
stiffness: 270,
|
||||
damping: 25,
|
||||
duration: 0.35,
|
||||
}}
|
||||
className={cn(
|
||||
"max-w-xl mx-auto border relative z-[50] bg-nb-gray-800 border-nb-gray-900 shadow-2xl border-b-0 overflow-hidden pointer-events-auto",
|
||||
"rounded-t-lg",
|
||||
)}
|
||||
>
|
||||
<AnimatePresence mode={"popLayout"}>
|
||||
<div
|
||||
className={
|
||||
"flex gap-2 items-center text-sm px-6 pt-3.5 pb-20 bg-nb-gray-920/90 text-nb-gray-200 justify-between"
|
||||
}
|
||||
>
|
||||
<div className={"flex gap-2 items-center"}>
|
||||
<MonitorSmartphoneIcon size={16} className={""} />
|
||||
<span>
|
||||
<span className={"font-medium text-white"}>
|
||||
{count}
|
||||
</span>{" "}
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
<div className={"flex gap-2 items-center"}>
|
||||
{rightSide}
|
||||
<FullTooltip
|
||||
content={<span className={"text-xs"}>Cancel</span>}
|
||||
>
|
||||
<Button
|
||||
onClick={onCanceled}
|
||||
variant={"default-outline"}
|
||||
size={"xs"}
|
||||
className={"!h-9 !w-9"}
|
||||
>
|
||||
<IconX size={16} className={"shrink-0"} />
|
||||
</Button>
|
||||
</FullTooltip>
|
||||
</div>
|
||||
</div>
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
@@ -35,7 +35,7 @@ export default function DataTableRefreshButton({ onClick, isDisabled }: Props) {
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
className={"h-[44px]"}
|
||||
className={"h-[42px]"}
|
||||
variant={"secondary"}
|
||||
disabled={isDisabled == true ? true : disabled}
|
||||
>
|
||||
|
||||
@@ -36,7 +36,7 @@ export default function DataTableResetFilterButton<TData>({
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
className={"h-[44px]"}
|
||||
className={"h-[42px]"}
|
||||
variant={"secondary"}
|
||||
onClick={onClick}
|
||||
>
|
||||
|
||||
115
src/components/ui/AddGroupButton.tsx
Normal file
115
src/components/ui/AddGroupButton.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
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 {
|
||||
Modal,
|
||||
ModalClose,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalTrigger,
|
||||
} from "@components/modal/Modal";
|
||||
import { ExternalLinkIcon, FolderGit2Icon, PlusCircle } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { useSWRConfig } from "swr";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import { Group } from "@/interfaces/Group";
|
||||
import { useApiCall } from "@/utils/api";
|
||||
import ModalHeader from "../modal/ModalHeader";
|
||||
import { notify } from "../Notification";
|
||||
import Paragraph from "../Paragraph";
|
||||
import Separator from "../Separator";
|
||||
|
||||
export const AddGroupButton = () => {
|
||||
const create = useApiCall<Group>("/groups", true).post;
|
||||
const { mutate } = useSWRConfig();
|
||||
const [name, setName] = useState<string>("");
|
||||
const [open, setOpen] = useState(false);
|
||||
const router = useRouter();
|
||||
const { permission } = usePermissions();
|
||||
|
||||
const createGroup = () => {
|
||||
notify({
|
||||
title: "Create Group",
|
||||
description: `Group '${name}' successfully created`,
|
||||
loadingMessage: "Creating group...",
|
||||
promise: create({ name }).then((g) => {
|
||||
setOpen(false);
|
||||
setName("");
|
||||
mutate("/groups");
|
||||
router.push(`/group?id=${g?.id}`);
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
permission?.groups?.create && (
|
||||
<Modal open={open} onOpenChange={setOpen}>
|
||||
<ModalTrigger asChild>
|
||||
<Button
|
||||
variant={"primary"}
|
||||
size={"sm"}
|
||||
className={"ml-auto h-[42px]"}
|
||||
>
|
||||
<PlusCircle size={16} />
|
||||
Create Group
|
||||
</Button>
|
||||
</ModalTrigger>
|
||||
<ModalContent maxWidthClass={"max-w-xl"}>
|
||||
<ModalHeader
|
||||
icon={<FolderGit2Icon size={18} />}
|
||||
title="Create Group"
|
||||
description="Create a group to manage and organize access in your network"
|
||||
color="netbird"
|
||||
/>
|
||||
<Separator />
|
||||
<div className={"px-8 flex-col flex gap-6 py-6"}>
|
||||
<div>
|
||||
<Label>Name</Label>
|
||||
<HelpText>
|
||||
Set an easily identifiable name for your group
|
||||
</HelpText>
|
||||
<Input
|
||||
tabIndex={0}
|
||||
placeholder={"e.g., Developers"}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<ModalFooter className={"items-center"}>
|
||||
<div className={"w-full"}>
|
||||
<Paragraph className={"text-sm mt-auto"}>
|
||||
Learn more about
|
||||
<InlineLink
|
||||
href={"https://docs.netbird.io/how-to/manage-network-access"}
|
||||
target={"_blank"}
|
||||
>
|
||||
Groups
|
||||
<ExternalLinkIcon size={12} />
|
||||
</InlineLink>
|
||||
</Paragraph>
|
||||
</div>
|
||||
<div className={"flex gap-3 w-full justify-end"}>
|
||||
<ModalClose asChild={true}>
|
||||
<Button variant={"secondary"}>Cancel</Button>
|
||||
</ModalClose>
|
||||
|
||||
<Button
|
||||
variant={"primary"}
|
||||
data-cy={"submit-route"}
|
||||
disabled={!name}
|
||||
onClick={createGroup}
|
||||
>
|
||||
<PlusCircle size={16} />
|
||||
Create Group
|
||||
</Button>
|
||||
</div>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
)
|
||||
);
|
||||
};
|
||||
21
src/components/ui/InstallNetBirdButton.tsx
Normal file
21
src/components/ui/InstallNetBirdButton.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import Button from "@components/Button";
|
||||
import { Modal, ModalTrigger } from "@components/modal/Modal";
|
||||
import { DownloadIcon } from "lucide-react";
|
||||
import React, { useState } from "react";
|
||||
import SetupModal from "@/modules/setup-netbird-modal/SetupModal";
|
||||
|
||||
export function InstallNetBirdButton() {
|
||||
const [installModal, setInstallModal] = useState(false);
|
||||
|
||||
return (
|
||||
<Modal open={installModal} onOpenChange={setInstallModal}>
|
||||
<ModalTrigger asChild>
|
||||
<Button variant={"secondary"} size={"sm"}>
|
||||
<DownloadIcon size={16} />
|
||||
Install NetBird
|
||||
</Button>
|
||||
</ModalTrigger>
|
||||
<SetupModal />
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import Card from "@components/Card";
|
||||
import Paragraph from "@components/Paragraph";
|
||||
import { cn } from "@utils/helpers";
|
||||
import { FilterX } from "lucide-react";
|
||||
import React from "react";
|
||||
import Skeleton from "react-loading-skeleton";
|
||||
@@ -9,15 +10,18 @@ type Props = {
|
||||
title?: string;
|
||||
description?: string;
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export default function NoResultsCard({
|
||||
icon,
|
||||
title = "Could not find any results",
|
||||
description = "We couldn't find any results. Please try a different search term or change your filters.",
|
||||
children,
|
||||
className,
|
||||
}: Readonly<Props>) {
|
||||
return (
|
||||
<div className={"px-8 mt-8"}>
|
||||
<div className={cn("px-8 mt-8", className)}>
|
||||
<Card className={"w-full relative overflow-hidden"}>
|
||||
<div
|
||||
className={
|
||||
|
||||
@@ -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;
|
||||
|
||||
335
src/contexts/GroupProvider.tsx
Normal file
335
src/contexts/GroupProvider.tsx
Normal file
@@ -0,0 +1,335 @@
|
||||
import { notify } from "@components/Notification";
|
||||
import { useApiCall } from "@utils/api";
|
||||
import * as React from "react";
|
||||
import { useState } from "react";
|
||||
import { useSWRConfig } from "swr";
|
||||
import { useDialog } from "@/contexts/DialogProvider";
|
||||
import { useGroups } from "@/contexts/GroupsProvider";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import { Group } from "@/interfaces/Group";
|
||||
import { Peer } from "@/interfaces/Peer";
|
||||
import { User } from "@/interfaces/User";
|
||||
import { EditGroupNameModal } from "@/modules/groups/EditGroupNameModal";
|
||||
import { useGroupIdentification } from "@/modules/groups/useGroupIdentification";
|
||||
|
||||
type Props = {
|
||||
group: Group;
|
||||
children?: React.ReactNode;
|
||||
isDetailPage?: boolean;
|
||||
};
|
||||
|
||||
const GroupContext = React.createContext(
|
||||
{} as {
|
||||
group: Group;
|
||||
deleteGroup: () => Promise<void>;
|
||||
renameGroup: (name: string) => Promise<void>;
|
||||
isRegularGroup: boolean;
|
||||
isIntegrationGroup: boolean;
|
||||
isJWTGroup: boolean;
|
||||
isAllowedToDelete: boolean;
|
||||
isAllowedToRename: boolean;
|
||||
openGroupRenameModal?: () => void;
|
||||
addPeersToGroup: (peers: Peer[]) => Promise<void>;
|
||||
removePeersFromGroup: (peer: Peer[]) => Promise<void>;
|
||||
addUsersToGroup: (users: User[]) => Promise<void>;
|
||||
removeUsersFromGroup: (users: User[]) => Promise<void>;
|
||||
},
|
||||
);
|
||||
|
||||
export const GroupProvider = ({
|
||||
group,
|
||||
children,
|
||||
isDetailPage = true,
|
||||
}: Props) => {
|
||||
const { permission } = usePermissions();
|
||||
const [groupNameModal, setGroupNameModal] = useState(false);
|
||||
const { mutate } = useSWRConfig();
|
||||
const { deleteGroupDropdownOption, updateGroupDropdown } = useGroups();
|
||||
const groupRequest = useApiCall<Group>("/groups/" + group.id);
|
||||
const userRequest = useApiCall<User>("/users");
|
||||
const { confirm } = useDialog();
|
||||
const { isRegularGroup, isIntegrationGroup, isJWTGroup } =
|
||||
useGroupIdentification({
|
||||
id: group?.id,
|
||||
issued: group?.issued,
|
||||
});
|
||||
|
||||
const isAllowedToRename = isRegularGroup && permission?.groups?.update;
|
||||
const isAllowedToDelete = !isIntegrationGroup && permission?.groups?.delete;
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!isAllowedToDelete) return Promise.reject("Not allowed to delete");
|
||||
|
||||
const promise = groupRequest.del().then(() => {
|
||||
deleteGroupDropdownOption(group.name);
|
||||
if (isDetailPage) mutate(`/groups/${group.id}`);
|
||||
mutate("/groups");
|
||||
});
|
||||
|
||||
notify({
|
||||
title: "Delete Group " + group.name,
|
||||
description: "Group successfully deleted",
|
||||
promise,
|
||||
loadingMessage: "Deleting group...",
|
||||
});
|
||||
|
||||
return promise;
|
||||
};
|
||||
|
||||
const deleteGroup = async () => {
|
||||
const choice = await confirm({
|
||||
title: `Delete '${group.name}'?`,
|
||||
description:
|
||||
"Are you sure you want to delete this group? This action cannot be undone.",
|
||||
confirmText: "Delete",
|
||||
cancelText: "Cancel",
|
||||
type: "danger",
|
||||
});
|
||||
if (!choice) return;
|
||||
handleDelete().then();
|
||||
};
|
||||
|
||||
const renameGroup = (name: string) => {
|
||||
if (!isAllowedToRename) return Promise.reject("Not allowed to rename");
|
||||
|
||||
const currentPeerIds =
|
||||
group.peers?.map((p) => (typeof p === "string" ? p : p.id)) || [];
|
||||
const promise = groupRequest
|
||||
.put({ ...group, peers: currentPeerIds, name })
|
||||
.then(() => {
|
||||
updateGroupDropdown(group.name, { ...group, name });
|
||||
if (isDetailPage) mutate(`/groups/${group.id}`);
|
||||
mutate("/groups");
|
||||
});
|
||||
|
||||
notify({
|
||||
title: `Rename Group ${group.name}`,
|
||||
description: "Group successfully renamed to " + name,
|
||||
promise,
|
||||
loadingMessage: "Renaming group...",
|
||||
});
|
||||
|
||||
return promise;
|
||||
};
|
||||
|
||||
const removePeersFromGroup = async (peers: Peer[]) => {
|
||||
if (!permission?.groups?.update) return Promise.reject();
|
||||
const peer = peers.length === 1 ? peers[0] : undefined;
|
||||
|
||||
const choice = await confirm({
|
||||
title: peer
|
||||
? `Remove peer '${peer.name}' from '${group.name}'?`
|
||||
: `Remove peers from '${group.name}'?`,
|
||||
description: peer
|
||||
? `Are you sure you want to remove this peer from the group? You can add it back later if needed.`
|
||||
: `Are you sure you want to remove these peers from the group? You can add them back later if needed.`,
|
||||
confirmText: "Remove",
|
||||
cancelText: "Cancel",
|
||||
type: "warning",
|
||||
maxWidthClass: "max-w-lg",
|
||||
});
|
||||
|
||||
if (!choice) return Promise.resolve();
|
||||
|
||||
const currentPeerIds =
|
||||
group.peers?.map((p) => (typeof p === "string" ? p : p.id)) || [];
|
||||
const newPeerIds = currentPeerIds.filter((pid) => {
|
||||
return !peers.find((peer) => peer.id === pid);
|
||||
});
|
||||
const promise = groupRequest
|
||||
.put({ ...group, peers: newPeerIds })
|
||||
.then(() => {
|
||||
if (isDetailPage) mutate(`/groups/${group.id}`);
|
||||
mutate("/groups");
|
||||
});
|
||||
|
||||
notify({
|
||||
title: `Remove Peer from Group`,
|
||||
description: peer
|
||||
? `Peer '${peer.name}' successfully removed from group '${group.name}'`
|
||||
: `Peers successfully removed from group '${group.name}'`,
|
||||
promise,
|
||||
loadingMessage: peer
|
||||
? "Removing peer from group..."
|
||||
: `Removing peers from group...`,
|
||||
});
|
||||
|
||||
return promise;
|
||||
};
|
||||
|
||||
const addPeersToGroup = async (peers: Peer[]) => {
|
||||
if (!permission?.groups?.update) return Promise.reject();
|
||||
|
||||
const currentPeerIds =
|
||||
group.peers?.map((p) => (typeof p === "string" ? p : p.id)) || [];
|
||||
const newPeerIds = [...currentPeerIds, ...peers.map((peer) => peer.id)];
|
||||
|
||||
const uniquePeerIds = Array.from(new Set(newPeerIds));
|
||||
|
||||
const promise = groupRequest
|
||||
.put({ ...group, peers: uniquePeerIds })
|
||||
.then(() => {
|
||||
if (isDetailPage) mutate(`/groups/${group.id}`);
|
||||
mutate("/groups");
|
||||
});
|
||||
|
||||
notify({
|
||||
title: "Adding peers to group",
|
||||
description: `Peers were successfully added to ${group.name}.`,
|
||||
promise,
|
||||
loadingMessage: "Adding peers to group...",
|
||||
});
|
||||
|
||||
return promise;
|
||||
};
|
||||
|
||||
const removeUserFromGroup = async (
|
||||
user: User,
|
||||
returnOnlyPromise?: boolean,
|
||||
) => {
|
||||
if (!permission?.groups?.update) return Promise.reject();
|
||||
if (!permission?.users?.update) return Promise.reject();
|
||||
|
||||
const currentGroupIds = user.auto_groups?.map((g) => g) || [];
|
||||
const newGroupIds = currentGroupIds.filter((gid) => gid !== group.id);
|
||||
const promise = userRequest
|
||||
.put({ ...user, auto_groups: newGroupIds }, `/${user.id}`)
|
||||
.then(() => {
|
||||
if (returnOnlyPromise) return;
|
||||
if (isDetailPage) mutate(`/groups/${group.id}`);
|
||||
mutate("/groups");
|
||||
mutate("/users?service_user=false");
|
||||
});
|
||||
|
||||
if (!returnOnlyPromise) {
|
||||
notify({
|
||||
title: `Remove User from Group ${group.name}`,
|
||||
description: `User '${user.name}' was successfully removed from group '${group.name}'.`,
|
||||
promise,
|
||||
loadingMessage: "Removing user from group...",
|
||||
});
|
||||
}
|
||||
|
||||
return promise;
|
||||
};
|
||||
|
||||
const removeUsersFromGroup = async (users: User[]) => {
|
||||
if (!permission?.groups?.update) return Promise.reject();
|
||||
if (!permission?.users?.update) return Promise.reject();
|
||||
let promises = users.map((user) => removeUserFromGroup(user, true));
|
||||
|
||||
const user = users.length === 1 ? users[0] : undefined;
|
||||
|
||||
const choice = await confirm({
|
||||
title: user
|
||||
? `Remove user '${user?.name ?? user?.id}' from '${group.name}'?`
|
||||
: `Remove users from '${group.name}'?`,
|
||||
description: user
|
||||
? `Are you sure you want to remove this user from the group? You can add it back later if needed.`
|
||||
: `Are you sure you want to remove these users from the group? You can add them back later if needed.`,
|
||||
confirmText: "Remove",
|
||||
cancelText: "Cancel",
|
||||
type: "warning",
|
||||
maxWidthClass: "max-w-lg",
|
||||
});
|
||||
if (!choice) return Promise.resolve();
|
||||
|
||||
const promise = Promise.all(promises).then(() => {
|
||||
if (isDetailPage) mutate(`/groups/${group.id}`);
|
||||
mutate("/groups");
|
||||
mutate("/users?service_user=false");
|
||||
});
|
||||
notify({
|
||||
title: `Remove Users from Group ${group.name}`,
|
||||
description: `Users were successfully removed from group '${group.name}'.`,
|
||||
promise,
|
||||
loadingMessage: "Removing users from group...",
|
||||
});
|
||||
return promise;
|
||||
};
|
||||
|
||||
const addUserToGroup = async (user: User, returnOnlyPromise?: boolean) => {
|
||||
if (!permission?.groups?.update) return Promise.reject();
|
||||
if (!permission?.users?.update) return Promise.reject();
|
||||
const currentGroupIds = user.auto_groups?.map((g) => g) || [];
|
||||
const newGroupIds = Array.from(new Set([...currentGroupIds, group.id]));
|
||||
const promise = userRequest
|
||||
.put({ ...user, auto_groups: newGroupIds }, `/${user.id}`)
|
||||
.then(() => {
|
||||
if (returnOnlyPromise) return;
|
||||
if (isDetailPage) mutate(`/groups/${group.id}`);
|
||||
mutate("/groups");
|
||||
mutate("/users?service_user=false");
|
||||
});
|
||||
if (!returnOnlyPromise) {
|
||||
notify({
|
||||
title: `Add User to Group ${group.name}`,
|
||||
description: `User '${user.name}' was successfully added to group '${group.name}'.`,
|
||||
promise,
|
||||
loadingMessage: "Adding user to group...",
|
||||
});
|
||||
}
|
||||
return promise;
|
||||
};
|
||||
|
||||
const addUsersToGroup = async (users: User[]) => {
|
||||
let promises = users.map((user) => addUserToGroup(user, true));
|
||||
const promise = Promise.all(promises).then(() => {
|
||||
if (isDetailPage) mutate(`/groups/${group.id}`);
|
||||
mutate("/groups");
|
||||
mutate("/users?service_user=false");
|
||||
});
|
||||
notify({
|
||||
title: `Add Users to Group ${group.name}`,
|
||||
description: `Users were successfully added to group '${group.name}'.`,
|
||||
promise,
|
||||
loadingMessage: "Adding users to group...",
|
||||
});
|
||||
return promise;
|
||||
};
|
||||
|
||||
const openGroupRenameModal = () => {
|
||||
if (!isAllowedToRename) return;
|
||||
setGroupNameModal(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<GroupContext.Provider
|
||||
value={{
|
||||
group,
|
||||
deleteGroup,
|
||||
renameGroup,
|
||||
isRegularGroup,
|
||||
isIntegrationGroup,
|
||||
isJWTGroup,
|
||||
isAllowedToDelete,
|
||||
isAllowedToRename,
|
||||
openGroupRenameModal,
|
||||
addPeersToGroup,
|
||||
removePeersFromGroup,
|
||||
addUsersToGroup,
|
||||
removeUsersFromGroup,
|
||||
}}
|
||||
>
|
||||
<EditGroupNameModal
|
||||
initialName={group.name}
|
||||
open={groupNameModal}
|
||||
onOpenChange={setGroupNameModal}
|
||||
onSuccess={(newName) =>
|
||||
renameGroup(newName).then(() => {
|
||||
setGroupNameModal(false);
|
||||
})
|
||||
}
|
||||
/>
|
||||
{children}
|
||||
</GroupContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useGroupContext = () => {
|
||||
const context = React.useContext(GroupContext);
|
||||
if (!context) {
|
||||
throw new Error("useGroup must be used within a GroupProvider");
|
||||
}
|
||||
return context;
|
||||
};
|
||||
@@ -20,6 +20,7 @@ const GroupContext = React.createContext(
|
||||
createOrUpdate: (group: Group) => Promise<Group>;
|
||||
reset: () => void;
|
||||
updateGroupDropdown: (oldGroupName: string, newGroup: Group) => void;
|
||||
deleteGroupDropdownOption: (name: string) => void;
|
||||
},
|
||||
);
|
||||
|
||||
@@ -132,6 +133,13 @@ export function GroupsProviderContent({
|
||||
}
|
||||
};
|
||||
|
||||
const deleteGroupDropdownOption = (name: string) => {
|
||||
setDropdownOptions((prev) => {
|
||||
let updated = prev.filter((g) => g.name !== name);
|
||||
return sortBy(updated, "name");
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<GroupContext.Provider
|
||||
value={{
|
||||
@@ -144,6 +152,7 @@ export function GroupsProviderContent({
|
||||
createOrUpdate,
|
||||
reset,
|
||||
updateGroupDropdown,
|
||||
deleteGroupDropdownOption,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -138,6 +138,7 @@ export default function PeerProvider({
|
||||
<PeerSSHInstructions
|
||||
open={sshInstructionsModal}
|
||||
onOpenChange={setSSHInstructionsModal}
|
||||
peer={peer}
|
||||
onSuccess={() => toggleSSH(true)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -23,4 +23,10 @@ export interface Account {
|
||||
network_range?: string;
|
||||
lazy_connection_enabled: boolean;
|
||||
};
|
||||
onboarding?: AccountOnboarding;
|
||||
}
|
||||
|
||||
export interface AccountOnboarding {
|
||||
onboarding_flow_pending: boolean;
|
||||
signup_form_pending: boolean;
|
||||
}
|
||||
|
||||
@@ -26,3 +26,14 @@ export enum GroupIssued {
|
||||
INTEGRATION = "integration",
|
||||
JWT = "jwt",
|
||||
}
|
||||
|
||||
export const GROUP_TOOLTIP_TEXT = {
|
||||
RENAME: {
|
||||
JWT: "This group is issued by JWT and cannot be renamed.",
|
||||
INTEGRATION: "This group is issued by an IdP and cannot be renamed.",
|
||||
},
|
||||
DELETE: {
|
||||
INTEGRATION: "This group is issued by an IdP and cannot be deleted.",
|
||||
},
|
||||
IN_USE: "Remove dependencies to this group to delete it.",
|
||||
};
|
||||
|
||||
@@ -104,50 +104,4 @@ export const NameserverPresets: Record<string, NameserverGroup> = {
|
||||
enabled: true,
|
||||
search_domains_enabled: false,
|
||||
},
|
||||
DNS0: {
|
||||
name: "DNS0.EU",
|
||||
description: "DNS0.EU DNS Servers",
|
||||
primary: true,
|
||||
domains: [],
|
||||
nameservers: [
|
||||
{
|
||||
ip: "193.110.81.0",
|
||||
ns_type: "udp",
|
||||
port: 53,
|
||||
id: "1",
|
||||
},
|
||||
{
|
||||
ip: "185.253.5.0",
|
||||
ns_type: "udp",
|
||||
port: 53,
|
||||
id: "2",
|
||||
},
|
||||
],
|
||||
groups: [],
|
||||
enabled: true,
|
||||
search_domains_enabled: false,
|
||||
},
|
||||
DNS0Zero: {
|
||||
name: "DNS0.EU Zero",
|
||||
description: "DNS0.EU Zero DNS Servers",
|
||||
primary: true,
|
||||
domains: [],
|
||||
nameservers: [
|
||||
{
|
||||
ip: "193.110.81.9",
|
||||
ns_type: "udp",
|
||||
port: 53,
|
||||
id: "1",
|
||||
},
|
||||
{
|
||||
ip: "185.253.5.9",
|
||||
ns_type: "udp",
|
||||
port: 53,
|
||||
id: "2",
|
||||
},
|
||||
],
|
||||
groups: [],
|
||||
enabled: true,
|
||||
search_domains_enabled: false,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -28,3 +28,7 @@ export interface NetworkResource {
|
||||
type?: "domain" | "host" | "subnet";
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface NetworkResourceWithNetwork extends NetworkResource {
|
||||
network: Network;
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ 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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -113,6 +113,12 @@ export default function Navigation({
|
||||
exactPathMatch={true}
|
||||
visible={permission.policies.read}
|
||||
/>
|
||||
<SidebarItem
|
||||
label="Groups"
|
||||
isChild
|
||||
href={"/groups"}
|
||||
visible={permission.policies.read}
|
||||
/>
|
||||
<SidebarItem
|
||||
label="Posture Checks"
|
||||
isChild
|
||||
|
||||
@@ -45,7 +45,7 @@ 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";
|
||||
@@ -116,6 +116,9 @@ type ModalProps = {
|
||||
postureCheckTemplates?: PostureCheck[];
|
||||
useSave?: boolean;
|
||||
allowEditPeers?: boolean;
|
||||
initialProtocol?: Protocol;
|
||||
initialPorts?: number[];
|
||||
initialDestinationResource?: PolicyRuleResource;
|
||||
};
|
||||
|
||||
export function AccessControlModalContent({
|
||||
@@ -128,6 +131,9 @@ export function AccessControlModalContent({
|
||||
initialDestinationGroups,
|
||||
initialName,
|
||||
initialDescription,
|
||||
initialProtocol,
|
||||
initialPorts,
|
||||
initialDestinationResource,
|
||||
}: Readonly<ModalProps>) {
|
||||
const { permission } = usePermissions();
|
||||
|
||||
@@ -170,6 +176,9 @@ export function AccessControlModalContent({
|
||||
initialDestinationGroups,
|
||||
initialName,
|
||||
initialDescription,
|
||||
initialPorts,
|
||||
initialProtocol,
|
||||
initialDestinationResource,
|
||||
});
|
||||
|
||||
const [tab, setTab] = useState(() => {
|
||||
|
||||
@@ -37,6 +37,9 @@ export default function AccessControlActiveCell({ policy }: Readonly<Props>) {
|
||||
if (rule.destinationResource) {
|
||||
rule.destinations = null;
|
||||
}
|
||||
if (rule.sourceResource) {
|
||||
rule.sources = null;
|
||||
}
|
||||
});
|
||||
|
||||
updatePolicy(
|
||||
|
||||
@@ -16,7 +16,9 @@ export default function AccessControlDirectionCell({
|
||||
}, [policy]);
|
||||
|
||||
const bidirectional = firstRule ? firstRule.bidirectional : false;
|
||||
const isSingleResource = !!firstRule?.destinationResource;
|
||||
const isSingleResource =
|
||||
!!firstRule?.destinationResource &&
|
||||
firstRule?.destinationResource?.type !== "peer";
|
||||
|
||||
return (
|
||||
<div className={"flex h-full"}>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import Button from "@components/Button";
|
||||
import ButtonGroup from "@components/ButtonGroup";
|
||||
import Card from "@components/Card";
|
||||
import FullTooltip from "@components/FullTooltip";
|
||||
import InlineLink from "@components/InlineLink";
|
||||
import SquareIcon from "@components/SquareIcon";
|
||||
@@ -15,6 +16,7 @@ import { usePathname, useSearchParams } from "next/navigation";
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useSWRConfig } from "swr";
|
||||
import AccessControlIcon from "@/assets/icons/AccessControlIcon";
|
||||
import NoResults from "@/components/ui/NoResults";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import { useLocalStorage } from "@/hooks/useLocalStorage";
|
||||
import type { Policy } from "@/interfaces/Policy";
|
||||
@@ -35,6 +37,7 @@ type Props = {
|
||||
policies?: Policy[];
|
||||
isLoading: boolean;
|
||||
headingTarget?: HTMLHeadingElement | null;
|
||||
isGroupPage?: boolean;
|
||||
};
|
||||
|
||||
export const AccessControlTableColumns: ColumnDef<Policy>[] = [
|
||||
@@ -179,12 +182,13 @@ export default function AccessControlTable({
|
||||
policies,
|
||||
isLoading,
|
||||
headingTarget,
|
||||
isGroupPage,
|
||||
}: Readonly<Props>) {
|
||||
const { mutate } = useSWRConfig();
|
||||
const path = usePathname();
|
||||
const { permission } = usePermissions();
|
||||
const params = useSearchParams();
|
||||
const idParam = params.get("id") ?? undefined;
|
||||
const idParam = !isGroupPage ? params.get("id") : undefined;
|
||||
|
||||
// Default sorting state of the table
|
||||
const [sorting, setSorting] = useLocalStorage<SortingState>(
|
||||
@@ -195,6 +199,7 @@ export default function AccessControlTable({
|
||||
desc: true,
|
||||
},
|
||||
],
|
||||
!isGroupPage,
|
||||
);
|
||||
|
||||
const [editModal, setEditModal] = useState(false);
|
||||
@@ -249,7 +254,13 @@ export default function AccessControlTable({
|
||||
<DataTable
|
||||
headingTarget={headingTarget}
|
||||
isLoading={isLoading}
|
||||
keepStateInLocalStorage={!idParam}
|
||||
wrapperComponent={isGroupPage ? Card : undefined}
|
||||
wrapperProps={isGroupPage ? { className: "mt-6 w-full" } : undefined}
|
||||
paginationPaddingClassName={isGroupPage ? "px-0 pt-8" : undefined}
|
||||
tableClassName={isGroupPage ? "mt-0 mb-2" : undefined}
|
||||
inset={!isGroupPage}
|
||||
minimal={isGroupPage}
|
||||
keepStateInLocalStorage={!isGroupPage || !idParam}
|
||||
initialSearch={idParam ? "" : undefined}
|
||||
initialFilters={
|
||||
idParam
|
||||
@@ -278,25 +289,22 @@ export default function AccessControlTable({
|
||||
}}
|
||||
searchPlaceholder={"Search by name and description..."}
|
||||
getStartedCard={
|
||||
<GetStartedTest
|
||||
icon={
|
||||
<SquareIcon
|
||||
icon={
|
||||
<AccessControlIcon className={"fill-nb-gray-200"} size={20} />
|
||||
}
|
||||
color={"gray"}
|
||||
size={"large"}
|
||||
/>
|
||||
}
|
||||
title={"Create New Policy"}
|
||||
description={
|
||||
"It looks like you don't have any policies yet. Policies can allow connections by specific protocol and ports."
|
||||
}
|
||||
button={
|
||||
isGroupPage ? (
|
||||
<NoResults
|
||||
className={"py-4"}
|
||||
title={"This group is not used within any policies yet"}
|
||||
description={
|
||||
"Assign this group as either a source or destination inside a policy to see them listed here."
|
||||
}
|
||||
icon={
|
||||
<AccessControlIcon size={20} className={"fill-nb-gray-300"} />
|
||||
}
|
||||
>
|
||||
<div className={"flex gap-4 items-center justify-center"}>
|
||||
<AccessControlModal>
|
||||
<Button
|
||||
variant={"primary"}
|
||||
className={"mt-4"}
|
||||
disabled={!permission.policies.create}
|
||||
>
|
||||
<PlusCircle size={16} />
|
||||
@@ -304,25 +312,59 @@ export default function AccessControlTable({
|
||||
</Button>
|
||||
</AccessControlModal>
|
||||
</div>
|
||||
}
|
||||
learnMore={
|
||||
<>
|
||||
Learn more about
|
||||
<InlineLink
|
||||
href={"https://docs.netbird.io/how-to/manage-network-access"}
|
||||
target={"_blank"}
|
||||
>
|
||||
Access Controls
|
||||
<ExternalLinkIcon size={12} />
|
||||
</InlineLink>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</NoResults>
|
||||
) : (
|
||||
<GetStartedTest
|
||||
icon={
|
||||
<SquareIcon
|
||||
icon={
|
||||
<AccessControlIcon
|
||||
className={"fill-nb-gray-200"}
|
||||
size={20}
|
||||
/>
|
||||
}
|
||||
color={"gray"}
|
||||
size={"large"}
|
||||
/>
|
||||
}
|
||||
title={"Create New Policy"}
|
||||
description={
|
||||
"It looks like you don't have any policies yet. Policies can allow connections by specific protocol and ports."
|
||||
}
|
||||
button={
|
||||
<div className={"flex gap-4 items-center justify-center"}>
|
||||
<AccessControlModal>
|
||||
<Button
|
||||
variant={"primary"}
|
||||
disabled={!permission.policies.create}
|
||||
>
|
||||
<PlusCircle size={16} />
|
||||
Add Policy
|
||||
</Button>
|
||||
</AccessControlModal>
|
||||
</div>
|
||||
}
|
||||
learnMore={
|
||||
<>
|
||||
Learn more about
|
||||
<InlineLink
|
||||
href={
|
||||
"https://docs.netbird.io/how-to/manage-network-access"
|
||||
}
|
||||
target={"_blank"}
|
||||
>
|
||||
Access Controls
|
||||
<ExternalLinkIcon size={12} />
|
||||
</InlineLink>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
rightSide={() => (
|
||||
<>
|
||||
{policies && policies?.length > 0 && (
|
||||
<div className={"flex ml-auto gap-4"}>
|
||||
<div className={"flex items-center ml-auto"}>
|
||||
<AccessControlModal>
|
||||
<Button
|
||||
variant={"primary"}
|
||||
|
||||
@@ -6,7 +6,12 @@ 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 {
|
||||
Policy,
|
||||
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 +23,9 @@ type Props = {
|
||||
initialDestinationGroups?: Group[] | string[];
|
||||
initialName?: string;
|
||||
initialDescription?: string;
|
||||
initialProtocol?: Protocol;
|
||||
initialPorts?: number[];
|
||||
initialDestinationResource?: PolicyRuleResource;
|
||||
};
|
||||
|
||||
// TODO add reducer
|
||||
@@ -29,6 +37,9 @@ export const useAccessControl = ({
|
||||
initialName,
|
||||
initialDescription,
|
||||
onSuccess,
|
||||
initialProtocol,
|
||||
initialPorts,
|
||||
initialDestinationResource,
|
||||
}: Props = {}) => {
|
||||
const { data: allPostureChecks, isLoading: isPostureChecksLoading } =
|
||||
useFetchApi<PostureCheck[]>("/posture-checks");
|
||||
@@ -75,6 +86,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 +105,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,7 +143,7 @@ export const useAccessControl = ({
|
||||
);
|
||||
|
||||
const [destinationResource, setDestinationResource] = useState(
|
||||
firstRule?.destinationResource,
|
||||
firstRule?.destinationResource ?? initialDestinationResource,
|
||||
);
|
||||
|
||||
const { updateOrCreateAndNotify: checkToCreate } = usePostureCheck({});
|
||||
|
||||
@@ -391,6 +391,14 @@ export default function ActivityDescription({ event }: Props) {
|
||||
</div>
|
||||
);
|
||||
|
||||
if (event.activity_code == "group.update")
|
||||
return (
|
||||
<div className={"inline"}>
|
||||
Group <Value>{event.meta.old_name}</Value> was renamed to{" "}
|
||||
<Value>{event.meta.new_name}</Value>
|
||||
</div>
|
||||
);
|
||||
|
||||
/**
|
||||
* Account
|
||||
*/
|
||||
|
||||
@@ -5,18 +5,21 @@ import { ExternalLinkIcon, GlobeIcon } from "lucide-react";
|
||||
import Image, { StaticImageData } from "next/image";
|
||||
import React, { useState } from "react";
|
||||
import CloudflareLogo from "@/assets/nameservers/cloudflare.svg";
|
||||
import DNS0Logo from "@/assets/nameservers/dns0.svg";
|
||||
import DNS0ZeroLogo from "@/assets/nameservers/dns0-zero.svg";
|
||||
import GoogleLogo from "@/assets/nameservers/google.svg";
|
||||
import Quad9Logo from "@/assets/nameservers/quad9.svg";
|
||||
import { Group } from "@/interfaces/Group";
|
||||
import { NameserverGroup, NameserverPresets } from "@/interfaces/Nameserver";
|
||||
import NameserverModal from "@/modules/dns-nameservers/NameserverModal";
|
||||
|
||||
type Props = {
|
||||
children: React.ReactNode;
|
||||
distributionGroups?: Group[];
|
||||
};
|
||||
|
||||
export default function NameserverTemplateModal({ children }: Readonly<Props>) {
|
||||
export default function NameserverTemplateModal({
|
||||
children,
|
||||
distributionGroups,
|
||||
}: Readonly<Props>) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [presetModal, setPresetModal] = useState(false);
|
||||
const [preset, setPreset] = useState(NameserverPresets.Default);
|
||||
@@ -39,7 +42,14 @@ export default function NameserverTemplateModal({ children }: Readonly<Props>) {
|
||||
setPresetModal(o);
|
||||
if (!o) setOpen(false);
|
||||
}}
|
||||
preset={preset}
|
||||
preset={{
|
||||
...preset,
|
||||
groups: distributionGroups
|
||||
? distributionGroups
|
||||
.map((group) => group.id)
|
||||
.filter((id): id is string => !!id)
|
||||
: [],
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
@@ -54,9 +64,9 @@ export function NameserverTemplateModalContent({
|
||||
onePresetSelection,
|
||||
}: Readonly<ModalProps>) {
|
||||
return (
|
||||
<ModalContent maxWidthClass={"max-w-5xl"} showClose={true}>
|
||||
<ModalContent maxWidthClass={"max-w-xl"} showClose={true}>
|
||||
<div className={"px-8 py-3 flex flex-col gap-6 mt-4"}>
|
||||
<div className={"grid grid-cols-1 md:grid-cols-2 gap-4"}>
|
||||
<div className={"grid grid-cols-1 md:grid-cols-1 gap-4"}>
|
||||
<NameserverTemplate
|
||||
onClick={() => onePresetSelection(NameserverPresets.Google)}
|
||||
src={GoogleLogo}
|
||||
@@ -75,25 +85,6 @@ export function NameserverTemplateModalContent({
|
||||
}
|
||||
href={"https://www.cloudflare.com/learning/dns/what-is-1.1.1.1/"}
|
||||
/>
|
||||
|
||||
<NameserverTemplate
|
||||
onClick={() => onePresetSelection(NameserverPresets.DNS0)}
|
||||
src={DNS0Logo}
|
||||
title={"DNS0.EU DNS"}
|
||||
description={
|
||||
"A free, sovereign and GDPR-compliant DNS resolver with a strong focus on security to protect the citizens and organizations of the European Union."
|
||||
}
|
||||
href={"https://www.dns0.eu/"}
|
||||
/>
|
||||
<NameserverTemplate
|
||||
onClick={() => onePresetSelection(NameserverPresets.DNS0Zero)}
|
||||
src={DNS0ZeroLogo}
|
||||
title={"DNS0.EU Zero DNS"}
|
||||
description={
|
||||
"Increase the catch rate for malicious domains by combining human-vetted threat intelligence with advanced heuristics that automatically identify high-risk patterns."
|
||||
}
|
||||
href={"https://www.dns0.eu/zero"}
|
||||
/>
|
||||
<NameserverTemplate
|
||||
onClick={() => onePresetSelection(NameserverPresets.Quad9)}
|
||||
src={Quad9Logo}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import Button from "@components/Button";
|
||||
import ButtonGroup from "@components/ButtonGroup";
|
||||
import Card from "@components/Card";
|
||||
import InlineLink from "@components/InlineLink";
|
||||
import SquareIcon from "@components/SquareIcon";
|
||||
import { DataTable } from "@components/table/DataTable";
|
||||
@@ -13,8 +14,10 @@ import { usePathname } from "next/navigation";
|
||||
import React, { useState } from "react";
|
||||
import { useSWRConfig } from "swr";
|
||||
import DNSIcon from "@/assets/icons/DNSIcon";
|
||||
import NoResults from "@/components/ui/NoResults";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import { useLocalStorage } from "@/hooks/useLocalStorage";
|
||||
import { Group } from "@/interfaces/Group";
|
||||
import { NameserverGroup } from "@/interfaces/Nameserver";
|
||||
import NameserverModal from "@/modules/dns-nameservers/NameserverModal";
|
||||
import NameserverTemplateModal from "@/modules/dns-nameservers/NameserverTemplateModal";
|
||||
@@ -91,12 +94,16 @@ type Props = {
|
||||
nameserverGroups?: NameserverGroup[];
|
||||
isLoading?: boolean;
|
||||
headingTarget?: HTMLHeadingElement | null;
|
||||
isGroupPage?: boolean;
|
||||
distributionGroups?: Group[];
|
||||
};
|
||||
|
||||
export default function NameserverGroupTable({
|
||||
nameserverGroups,
|
||||
isLoading,
|
||||
headingTarget,
|
||||
isGroupPage,
|
||||
distributionGroups,
|
||||
}: Readonly<Props>) {
|
||||
const { mutate } = useSWRConfig();
|
||||
const path = usePathname();
|
||||
@@ -111,6 +118,7 @@ export default function NameserverGroupTable({
|
||||
desc: true,
|
||||
},
|
||||
],
|
||||
!isGroupPage,
|
||||
);
|
||||
|
||||
const [editModal, setEditModal] = useState(false);
|
||||
@@ -133,6 +141,14 @@ export default function NameserverGroupTable({
|
||||
text={"Network Routes"}
|
||||
sorting={sorting}
|
||||
setSorting={setSorting}
|
||||
wrapperComponent={isGroupPage ? Card : undefined}
|
||||
wrapperProps={isGroupPage ? { className: "mt-6 w-full" } : undefined}
|
||||
paginationPaddingClassName={isGroupPage ? "px-0 pt-8" : undefined}
|
||||
tableClassName={isGroupPage ? "mt-0" : undefined}
|
||||
inset={!isGroupPage}
|
||||
minimal={isGroupPage}
|
||||
showSearchAndFilters={isGroupPage}
|
||||
keepStateInLocalStorage={!isGroupPage}
|
||||
columnVisibility={{
|
||||
description: false,
|
||||
domain_list: false,
|
||||
@@ -147,54 +163,78 @@ export default function NameserverGroupTable({
|
||||
data={nameserverGroups}
|
||||
searchPlaceholder={"Search by name, domains or nameservers..."}
|
||||
getStartedCard={
|
||||
<GetStartedTest
|
||||
icon={
|
||||
<SquareIcon
|
||||
icon={<DNSIcon className={"fill-nb-gray-200"} size={20} />}
|
||||
color={"gray"}
|
||||
size={"large"}
|
||||
/>
|
||||
}
|
||||
title={"Create Nameserver"}
|
||||
description={
|
||||
"It looks like you don't have any nameservers. Get started by adding one to your network. Select a predefined or add your custom nameservers."
|
||||
}
|
||||
button={
|
||||
<div className={"flex flex-col"}>
|
||||
<div>
|
||||
<NameserverTemplateModal>
|
||||
<Button
|
||||
variant={"primary"}
|
||||
className={""}
|
||||
disabled={!permission.nameservers.create}
|
||||
>
|
||||
<PlusCircle size={16} />
|
||||
Add Nameserver
|
||||
</Button>
|
||||
</NameserverTemplateModal>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
learnMore={
|
||||
<>
|
||||
Learn more about
|
||||
<InlineLink
|
||||
href={
|
||||
"https://docs.netbird.io/how-to/manage-dns-in-your-network"
|
||||
}
|
||||
target={"_blank"}
|
||||
isGroupPage ? (
|
||||
<NoResults
|
||||
icon={<DNSIcon className={"fill-nb-gray-200"} size={20} />}
|
||||
className={"py-4"}
|
||||
title={"This group is not used within any nameservers yet"}
|
||||
description={
|
||||
"Assign this group as a distribution group in your nameservers to see them listed here."
|
||||
}
|
||||
>
|
||||
<NameserverTemplateModal distributionGroups={distributionGroups}>
|
||||
<Button
|
||||
variant={"primary"}
|
||||
className={"mt-4"}
|
||||
disabled={!permission.nameservers.create}
|
||||
>
|
||||
DNS
|
||||
<ExternalLinkIcon size={12} />
|
||||
</InlineLink>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<PlusCircle size={16} />
|
||||
Add Nameserver
|
||||
</Button>
|
||||
</NameserverTemplateModal>
|
||||
</NoResults>
|
||||
) : (
|
||||
<GetStartedTest
|
||||
icon={
|
||||
<SquareIcon
|
||||
icon={<DNSIcon className={"fill-nb-gray-200"} size={20} />}
|
||||
color={"gray"}
|
||||
size={"large"}
|
||||
/>
|
||||
}
|
||||
title={"Create Nameserver"}
|
||||
description={
|
||||
"It looks like you don't have any nameservers. Get started by adding one to your network. Select a predefined or add your custom nameservers."
|
||||
}
|
||||
button={
|
||||
<div className={"flex flex-col"}>
|
||||
<div>
|
||||
<NameserverTemplateModal
|
||||
distributionGroups={distributionGroups}
|
||||
>
|
||||
<Button
|
||||
variant={"primary"}
|
||||
className={""}
|
||||
disabled={!permission.nameservers.create}
|
||||
>
|
||||
<PlusCircle size={16} />
|
||||
Add Nameserver
|
||||
</Button>
|
||||
</NameserverTemplateModal>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
learnMore={
|
||||
<>
|
||||
Learn more about
|
||||
<InlineLink
|
||||
href={
|
||||
"https://docs.netbird.io/how-to/manage-dns-in-your-network"
|
||||
}
|
||||
target={"_blank"}
|
||||
>
|
||||
DNS
|
||||
<ExternalLinkIcon size={12} />
|
||||
</InlineLink>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
rightSide={() => (
|
||||
<>
|
||||
{nameserverGroups && nameserverGroups?.length > 0 && (
|
||||
<NameserverTemplateModal>
|
||||
<NameserverTemplateModal distributionGroups={distributionGroups}>
|
||||
<Button
|
||||
variant={"primary"}
|
||||
className={"ml-auto"}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { IconCirclePlus, IconDirectionSign } from "@tabler/icons-react";
|
||||
import * as React from "react";
|
||||
import { useState } from "react";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import { Group } from "@/interfaces/Group";
|
||||
import { Peer } from "@/interfaces/Peer";
|
||||
import { ExitNodeHelpTooltip } from "@/modules/exit-node/ExitNodeHelpTooltip";
|
||||
import { RouteModalContent } from "@/modules/routes/RouteModal";
|
||||
@@ -11,8 +12,13 @@ import { RouteModalContent } from "@/modules/routes/RouteModal";
|
||||
type Props = {
|
||||
peer?: Peer;
|
||||
firstTime?: boolean;
|
||||
distributionGroups?: Group[];
|
||||
};
|
||||
export const AddExitNodeButton = ({ peer, firstTime = false }: Props) => {
|
||||
export const AddExitNodeButton = ({
|
||||
peer,
|
||||
firstTime = false,
|
||||
distributionGroups,
|
||||
}: Props) => {
|
||||
const [modal, setModal] = useState(false);
|
||||
const { permission } = usePermissions();
|
||||
|
||||
@@ -42,6 +48,7 @@ export const AddExitNodeButton = ({ peer, firstTime = false }: Props) => {
|
||||
<RouteModalContent
|
||||
onSuccess={() => setModal(false)}
|
||||
peer={peer}
|
||||
distributionGroups={distributionGroups}
|
||||
isFirstExitNode={firstTime}
|
||||
exitNode={true}
|
||||
/>
|
||||
|
||||
@@ -11,7 +11,7 @@ import useFetchApi, { useApiCall } from "@utils/api";
|
||||
import { cn } from "@utils/helpers";
|
||||
import { FolderGit2, PencilLineIcon } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useSWRConfig } from "swr";
|
||||
import PeerIcon from "@/assets/icons/PeerIcon";
|
||||
import { DataTable } from "@/components/table/DataTable";
|
||||
@@ -28,6 +28,11 @@ type Props = {
|
||||
setOpen: (open: boolean) => void;
|
||||
onUpdate?: (g: Group) => void;
|
||||
useSave?: boolean;
|
||||
excludedPeers?: Peer[];
|
||||
showHeader?: boolean;
|
||||
showClose?: boolean;
|
||||
buttonText?: string;
|
||||
selectInitialPeers?: boolean;
|
||||
};
|
||||
|
||||
export const AssignPeerToGroupModal = ({
|
||||
@@ -36,6 +41,11 @@ export const AssignPeerToGroupModal = ({
|
||||
setOpen,
|
||||
onUpdate,
|
||||
useSave = true,
|
||||
excludedPeers,
|
||||
showHeader,
|
||||
showClose,
|
||||
buttonText,
|
||||
selectInitialPeers,
|
||||
}: Props) => {
|
||||
return (
|
||||
<Modal open={open} onOpenChange={setOpen} key={open ? "1" : "0"}>
|
||||
@@ -47,6 +57,11 @@ export const AssignPeerToGroupModal = ({
|
||||
onUpdate && onUpdate(g);
|
||||
}}
|
||||
useSave={useSave}
|
||||
excludedPeers={excludedPeers}
|
||||
showHeader={showHeader}
|
||||
showClose={showClose}
|
||||
buttonText={buttonText}
|
||||
selectInitialPeers={selectInitialPeers}
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
@@ -57,12 +72,22 @@ type ContentProps = {
|
||||
group: Group;
|
||||
onSuccess?: (g: Group) => void;
|
||||
useSave?: boolean;
|
||||
excludedPeers?: Peer[];
|
||||
showHeader?: boolean;
|
||||
showClose?: boolean;
|
||||
buttonText?: string;
|
||||
selectInitialPeers?: boolean;
|
||||
};
|
||||
|
||||
export const AssignGroupToPeerModalContent = ({
|
||||
group,
|
||||
onSuccess,
|
||||
useSave,
|
||||
excludedPeers,
|
||||
showHeader = true,
|
||||
showClose = true,
|
||||
buttonText = "Confirm Changes",
|
||||
selectInitialPeers = true,
|
||||
}: ContentProps) => {
|
||||
const { data: peers, isLoading } = useFetchApi<Peer[]>("/peers");
|
||||
const { mutate } = useSWRConfig();
|
||||
@@ -89,8 +114,9 @@ export const AssignGroupToPeerModalContent = ({
|
||||
setGroupName(name);
|
||||
};
|
||||
|
||||
// Get initial selected peers
|
||||
// Get initially selected peers
|
||||
const getInitialSelectedPeers = useCallback(() => {
|
||||
if (!selectInitialPeers) return {};
|
||||
if (!group) return undefined;
|
||||
if (!peers) return undefined;
|
||||
let initialSelectedPeers = group?.peers
|
||||
@@ -109,24 +135,23 @@ export const AssignGroupToPeerModalContent = ({
|
||||
},
|
||||
{} as Record<string, boolean>,
|
||||
);
|
||||
}, [group, peers]);
|
||||
}, [group, peers, selectInitialPeers]);
|
||||
|
||||
const handleOnSave = async (selectedPeers: Peer[]) => {
|
||||
if (!useSave) {
|
||||
onSuccess &&
|
||||
onSuccess({
|
||||
...group,
|
||||
name: groupName,
|
||||
peers: selectedPeers.map((peer) => {
|
||||
return {
|
||||
id: peer.id,
|
||||
name: peer.name,
|
||||
} as GroupPeer;
|
||||
}),
|
||||
peers_count: selectedPeers.length,
|
||||
resources: group.resources,
|
||||
keepClientState: true,
|
||||
});
|
||||
onSuccess?.({
|
||||
...group,
|
||||
name: groupName,
|
||||
peers: selectedPeers.map((peer) => {
|
||||
return {
|
||||
id: peer.id,
|
||||
name: peer.name,
|
||||
} as GroupPeer;
|
||||
}),
|
||||
peers_count: selectedPeers.length,
|
||||
resources: group.resources,
|
||||
keepClientState: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -172,11 +197,19 @@ export const AssignGroupToPeerModalContent = ({
|
||||
setInitialPeersSet(true);
|
||||
}, [getInitialSelectedPeers, initialPeersSet]);
|
||||
|
||||
const data = useMemo(() => {
|
||||
if (!initialPeersSet) return;
|
||||
return peers?.filter((p) => {
|
||||
if (!excludedPeers || excludedPeers.length === 0) return true;
|
||||
return !excludedPeers.find((ep) => ep.id === p.id);
|
||||
});
|
||||
}, [initialPeersSet, peers, excludedPeers]);
|
||||
|
||||
return (
|
||||
<ModalContent
|
||||
maxWidthClass={"max-w-4xl"}
|
||||
className={cn(peers && peers.length > 0 ? "pb-0" : "pb-8")}
|
||||
showClose={true}
|
||||
showClose={showClose}
|
||||
>
|
||||
{groupNameModal && (
|
||||
<EditGroupNameModal
|
||||
@@ -186,34 +219,37 @@ export const AssignGroupToPeerModalContent = ({
|
||||
onSuccess={onGroupNameUpdate}
|
||||
/>
|
||||
)}
|
||||
<div className={"flex items-start justify-between pr-8"}>
|
||||
<ModalHeader
|
||||
title={
|
||||
<div className={"flex items-center gap-2 mb-1 text-nb-gray-100"}>
|
||||
<FolderGit2 size={16} className={"shrink-0"} />
|
||||
<div className={"flex gap-2 items-center"}>
|
||||
{groupName}
|
||||
{groupName !== "All" && (
|
||||
<button
|
||||
className={
|
||||
"flex items-center gap-2 dark:text-neutral-300 text-neutral-500 hover:text-neutral-100 transition-all hover:bg-nb-gray-800/60 py-2 px-3 rounded-md cursor-pointer"
|
||||
}
|
||||
onClick={() => setGroupNameModal(true)}
|
||||
>
|
||||
<PencilLineIcon size={16} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{showHeader && (
|
||||
<div className={"flex items-start justify-between pr-8"}>
|
||||
<ModalHeader
|
||||
title={
|
||||
<div className={"flex items-center gap-2 mb-1 text-nb-gray-100"}>
|
||||
<FolderGit2 size={16} className={"shrink-0"} />
|
||||
<div className={"flex gap-2 items-center"}>
|
||||
{groupName}
|
||||
{groupName !== "All" && (
|
||||
<button
|
||||
className={
|
||||
"flex items-center gap-2 dark:text-neutral-300 text-neutral-500 hover:text-neutral-100 transition-all hover:bg-nb-gray-800/60 py-2 px-3 rounded-md cursor-pointer"
|
||||
}
|
||||
onClick={() => setGroupNameModal(true)}
|
||||
>
|
||||
<PencilLineIcon size={16} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
description={
|
||||
isAllGroup
|
||||
? "View assigned peers for this group"
|
||||
: "Manage assigned peers for this group"
|
||||
}
|
||||
color={"blue"}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
description={
|
||||
isAllGroup
|
||||
? "View assigned peers for this group"
|
||||
: "Manage assigned peers for this group"
|
||||
}
|
||||
color={"blue"}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{initialPeersSet ? (
|
||||
<DataTable
|
||||
@@ -228,10 +264,11 @@ export const AssignGroupToPeerModalContent = ({
|
||||
keepStateInLocalStorage={false}
|
||||
setSorting={setSorting}
|
||||
columns={PeersTableColumns}
|
||||
data={initialPeersSet ? peers : undefined}
|
||||
data={data}
|
||||
isLoading={isLoading && !initialPeersSet}
|
||||
tableCellClassName={"!py-1 scale-[95%]"}
|
||||
searchPlaceholder={"Search by name, IP or owner..."}
|
||||
searchClassName={"w-[350px]"}
|
||||
minimal={false}
|
||||
columnVisibility={{
|
||||
connected: false,
|
||||
@@ -245,9 +282,10 @@ export const AssignGroupToPeerModalContent = ({
|
||||
}}
|
||||
getStartedCard={
|
||||
<NoResultsCard
|
||||
title={"Seems like you don't have any peers"}
|
||||
className={"mb-8"}
|
||||
title={"You don't have any peers to assign"}
|
||||
description={
|
||||
"In order to view or assign peers to a group, you need to have at least one peer."
|
||||
"In order to assign peers to this group you need to have at least one peer that is not already part of this group."
|
||||
}
|
||||
icon={<PeerIcon className={"fill-nb-gray-200"} size={14} />}
|
||||
/>
|
||||
@@ -268,7 +306,10 @@ export const AssignGroupToPeerModalContent = ({
|
||||
<Button
|
||||
variant={"primary"}
|
||||
className={"ml-auto"}
|
||||
disabled={peers?.length === 0}
|
||||
disabled={
|
||||
peers?.length === 0 ||
|
||||
Object.keys(selectedRows).length === 0
|
||||
}
|
||||
onClick={() => {
|
||||
const selectedPeers = table
|
||||
.getSelectedRowModel()
|
||||
@@ -276,7 +317,7 @@ export const AssignGroupToPeerModalContent = ({
|
||||
handleOnSave(selectedPeers).then();
|
||||
}}
|
||||
>
|
||||
Confirm Changes
|
||||
{buttonText}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -289,7 +330,7 @@ export const AssignGroupToPeerModalContent = ({
|
||||
);
|
||||
};
|
||||
|
||||
const PeersTableColumns: ColumnDef<Peer>[] = [
|
||||
export const PeersTableColumns: ColumnDef<Peer>[] = [
|
||||
{
|
||||
id: "select",
|
||||
header: ({ table, column }) => (
|
||||
|
||||
229
src/modules/groups/AssignUserToGroupModal.tsx
Normal file
229
src/modules/groups/AssignUserToGroupModal.tsx
Normal file
@@ -0,0 +1,229 @@
|
||||
import Button from "@components/Button";
|
||||
import { Checkbox } from "@components/Checkbox";
|
||||
import { Modal, ModalContent } from "@components/modal/Modal";
|
||||
import DataTableHeader from "@components/table/DataTableHeader";
|
||||
import NoResultsCard from "@components/ui/NoResultsCard";
|
||||
import { ColumnDef, RowSelectionState } from "@tanstack/react-table";
|
||||
import useFetchApi from "@utils/api";
|
||||
import { cn } from "@utils/helpers";
|
||||
import dayjs from "dayjs";
|
||||
import * as React from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import TeamIcon from "@/assets/icons/TeamIcon";
|
||||
import { DataTable } from "@/components/table/DataTable";
|
||||
import { Group } from "@/interfaces/Group";
|
||||
import { Peer } from "@/interfaces/Peer";
|
||||
import { User } from "@/interfaces/User";
|
||||
import LastTimeRow from "@/modules/common-table-rows/LastTimeRow";
|
||||
import { EditGroupNameModal } from "@/modules/groups/EditGroupNameModal";
|
||||
import { PeerOSCell } from "@/modules/peers/PeerOSCell";
|
||||
import UserNameCell from "@/modules/users/table-cells/UserNameCell";
|
||||
import UserRoleCell from "@/modules/users/table-cells/UserRoleCell";
|
||||
import UserStatusCell from "@/modules/users/table-cells/UserStatusCell";
|
||||
|
||||
type Props = {
|
||||
group: Group;
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
onSuccess?: (users: User[]) => void;
|
||||
excludedUsers?: User[];
|
||||
showClose?: boolean;
|
||||
buttonText?: string;
|
||||
};
|
||||
|
||||
export const AssignUserToGroupModal = ({
|
||||
group,
|
||||
open = false,
|
||||
setOpen,
|
||||
onSuccess,
|
||||
excludedUsers,
|
||||
showClose,
|
||||
buttonText,
|
||||
}: Props) => {
|
||||
return (
|
||||
<Modal open={open} onOpenChange={setOpen} key={open ? "1" : "0"}>
|
||||
{open && (
|
||||
<AssignUserToGroupModalContent
|
||||
group={group}
|
||||
onSuccess={(users) => {
|
||||
setOpen(false);
|
||||
onSuccess?.(users);
|
||||
}}
|
||||
excludedUsers={excludedUsers}
|
||||
showClose={showClose}
|
||||
buttonText={buttonText}
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
type ContentProps = {
|
||||
group: Group;
|
||||
onSuccess?: (users: User[]) => void;
|
||||
excludedUsers?: User[];
|
||||
showClose?: boolean;
|
||||
buttonText?: string;
|
||||
};
|
||||
|
||||
export const AssignUserToGroupModalContent = ({
|
||||
group,
|
||||
onSuccess,
|
||||
excludedUsers,
|
||||
showClose = true,
|
||||
buttonText = "Assign Users",
|
||||
}: ContentProps) => {
|
||||
const { data: users, isLoading } = useFetchApi<User[]>(
|
||||
"/users?service_user=false",
|
||||
);
|
||||
const [selectedRows, setSelectedRows] = useState<RowSelectionState>({});
|
||||
const isAllGroup = group.name === "All";
|
||||
const [sorting, setSorting] = useState([
|
||||
{
|
||||
id: "name",
|
||||
desc: false,
|
||||
},
|
||||
]);
|
||||
|
||||
const data = useMemo(() => {
|
||||
return users?.filter((p) => {
|
||||
if (!excludedUsers || excludedUsers.length === 0) return true;
|
||||
return !excludedUsers.find((ep) => ep.id === p.id);
|
||||
});
|
||||
}, [users, excludedUsers]);
|
||||
|
||||
return (
|
||||
<ModalContent
|
||||
maxWidthClass={"max-w-4xl"}
|
||||
className={cn(users && users.length > 0 ? "pb-0" : "pb-8")}
|
||||
showClose={showClose}
|
||||
>
|
||||
<DataTable
|
||||
useRowId={true}
|
||||
rowSelection={selectedRows}
|
||||
setRowSelection={setSelectedRows}
|
||||
onRowClick={(row) => row.toggleSelected()}
|
||||
text={"Users"}
|
||||
resetRowSelectionOnSearch={false}
|
||||
uniqueKey={group?.id ?? group?.name}
|
||||
sorting={sorting}
|
||||
keepStateInLocalStorage={false}
|
||||
setSorting={setSorting}
|
||||
columns={UsersTableColumns}
|
||||
data={data}
|
||||
isLoading={isLoading}
|
||||
tableCellClassName={"!py-1 scale-[95%]"}
|
||||
searchPlaceholder={"Search by name, email or role..."}
|
||||
searchClassName={"w-[350px]"}
|
||||
minimal={false}
|
||||
columnVisibility={{}}
|
||||
getStartedCard={
|
||||
<NoResultsCard
|
||||
className={"mb-8"}
|
||||
title={"You don't have any users to assign"}
|
||||
description={
|
||||
"In order to assign users to this group you need to have at least one user that is not already part of this group."
|
||||
}
|
||||
icon={<TeamIcon className={"fill-nb-gray-200"} size={14} />}
|
||||
/>
|
||||
}
|
||||
rightSide={(table) => (
|
||||
<div className={"ml-auto flex items-center gap-5"}>
|
||||
<div className={"text-sm"}>
|
||||
{Object.keys(selectedRows).length > 0 && (
|
||||
<div className={"text-nb-gray-200"}>
|
||||
<span className={"text-netbird font-medium"}>
|
||||
{Object.keys(selectedRows).length}
|
||||
</span>{" "}
|
||||
User(s) selected
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{!isAllGroup && (
|
||||
<Button
|
||||
variant={"primary"}
|
||||
className={"ml-auto"}
|
||||
disabled={
|
||||
users?.length === 0 || Object.keys(selectedRows).length === 0
|
||||
}
|
||||
onClick={() => {
|
||||
const selectedUsers = table
|
||||
.getSelectedRowModel()
|
||||
.rows.map((row) => row.original);
|
||||
onSuccess?.(selectedUsers);
|
||||
}}
|
||||
>
|
||||
{buttonText}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</ModalContent>
|
||||
);
|
||||
};
|
||||
|
||||
const UsersTableColumns: ColumnDef<User>[] = [
|
||||
{
|
||||
id: "select",
|
||||
header: ({ table }) => (
|
||||
<div className={"min-w-[20px] max-w-[20px]"}>
|
||||
<Checkbox
|
||||
checked={table.getIsAllPageRowsSelected()}
|
||||
onCheckedChange={(value) => table.toggleAllRowsSelected(!!value)}
|
||||
aria-label="Select all"
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<div className={"min-w-[20px] max-w-[20px]"}>
|
||||
<Checkbox
|
||||
checked={row.getIsSelected()}
|
||||
variant={"tableCell"}
|
||||
onCheckedChange={(value) => row.toggleSelected(!!value)}
|
||||
aria-label="Select row"
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
},
|
||||
{
|
||||
accessorKey: "name",
|
||||
header: ({ column }) => {
|
||||
return <DataTableHeader column={column}>Name</DataTableHeader>;
|
||||
},
|
||||
accessorFn: (row) => row.name + " " + row.email,
|
||||
sortingFn: "text",
|
||||
cell: ({ row }) => <UserNameCell user={row.original} />,
|
||||
},
|
||||
{
|
||||
accessorKey: "role",
|
||||
header: ({ column }) => {
|
||||
return <DataTableHeader column={column}>Role</DataTableHeader>;
|
||||
},
|
||||
sortingFn: "text",
|
||||
cell: ({ row }) => <UserRoleCell user={row.original} />,
|
||||
},
|
||||
{
|
||||
accessorKey: "status",
|
||||
header: ({ column }) => {
|
||||
return <DataTableHeader column={column}>Status</DataTableHeader>;
|
||||
},
|
||||
sortingFn: "text",
|
||||
cell: ({ row }) => <UserStatusCell user={row.original} />,
|
||||
},
|
||||
{
|
||||
accessorKey: "last_login",
|
||||
header: ({ column }) => {
|
||||
return <DataTableHeader column={column}>Last Login</DataTableHeader>;
|
||||
},
|
||||
sortingFn: "text",
|
||||
cell: ({ row }) => (
|
||||
<LastTimeRow
|
||||
date={dayjs(row.original.last_login).toDate()}
|
||||
text={"Last login on"}
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
@@ -7,10 +7,10 @@ import {
|
||||
ModalFooter,
|
||||
} from "@components/modal/Modal";
|
||||
import ModalHeader from "@components/modal/ModalHeader";
|
||||
import { IconCornerDownLeft } from "@tabler/icons-react";
|
||||
import { trim } from "lodash";
|
||||
import * as React from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useGroups } from "@/contexts/GroupsProvider";
|
||||
|
||||
type Props = {
|
||||
initialName: string;
|
||||
@@ -25,53 +25,66 @@ export const EditGroupNameModal = ({
|
||||
onSuccess,
|
||||
}: Props) => {
|
||||
const [name, setName] = useState(initialName);
|
||||
const { groups } = useGroups();
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const isDisabled = useMemo(() => {
|
||||
if (name === initialName) return true;
|
||||
if (error !== "") return true;
|
||||
const trimmedName = trim(name);
|
||||
return trimmedName.length === 0;
|
||||
}, [name, initialName]);
|
||||
}, [name, initialName, error]);
|
||||
|
||||
const handleNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newName = e.target.value;
|
||||
const findGroup = groups?.find((g) => g.name === newName);
|
||||
if (findGroup) {
|
||||
setError("This group already exists. Please choose another name.");
|
||||
} else {
|
||||
setError("");
|
||||
}
|
||||
setName(newName);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal open={open} onOpenChange={onOpenChange}>
|
||||
<ModalContent maxWidthClass={"max-w-md"}>
|
||||
<form>
|
||||
<ModalHeader
|
||||
title={"Edit Group Name"}
|
||||
description={"Set an easily identifiable name for your group."}
|
||||
color={"blue"}
|
||||
/>
|
||||
<ModalHeader
|
||||
title={"Rename Group"}
|
||||
description={"Set an easily identifiable name for your group."}
|
||||
color={"blue"}
|
||||
/>
|
||||
|
||||
<div className={"p-default flex flex-col gap-4"}>
|
||||
<div>
|
||||
<Input
|
||||
placeholder={"e.g., AWS Servers"}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className={"p-default flex flex-col gap-4"}>
|
||||
<div>
|
||||
<Input
|
||||
placeholder={"e.g., Developers"}
|
||||
value={name}
|
||||
onChange={handleNameChange}
|
||||
error={error}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ModalFooter className={"items-center"} separator={false}>
|
||||
<div className={"flex gap-3 w-full justify-end"}>
|
||||
<ModalClose asChild={true}>
|
||||
<Button variant={"secondary"} className={"w-full"}>
|
||||
Cancel
|
||||
</Button>
|
||||
</ModalClose>
|
||||
|
||||
<Button
|
||||
variant={"primary"}
|
||||
className={"w-full"}
|
||||
onClick={() => onSuccess(name)}
|
||||
disabled={isDisabled}
|
||||
type={"submit"}
|
||||
>
|
||||
Confirm
|
||||
<IconCornerDownLeft size={16} />
|
||||
<ModalFooter className={"items-center"} separator={false}>
|
||||
<div className={"flex gap-3 w-full justify-end"}>
|
||||
<ModalClose asChild={true}>
|
||||
<Button variant={"secondary"} className={"w-full"}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</ModalFooter>
|
||||
</form>
|
||||
</ModalClose>
|
||||
|
||||
<Button
|
||||
variant={"primary"}
|
||||
className={"w-full"}
|
||||
onClick={() => onSuccess(name)}
|
||||
disabled={isDisabled}
|
||||
type={"submit"}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
47
src/modules/groups/details/GroupDetailsRemoveCell.tsx
Normal file
47
src/modules/groups/details/GroupDetailsRemoveCell.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import { MinusCircle } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import Button from "@/components/Button";
|
||||
import { useGroupContext } from "@/contexts/GroupProvider";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import { Peer } from "@/interfaces/Peer";
|
||||
import { User } from "@/interfaces/User";
|
||||
|
||||
type Props = {
|
||||
onRemove: () => void;
|
||||
};
|
||||
|
||||
export function GroupDetailsRemoveCell({ onRemove }: Props) {
|
||||
return (
|
||||
<div className={"flex justify-end pr-4"}>
|
||||
<Button
|
||||
variant={"default-outline"}
|
||||
size={"sm"}
|
||||
onClick={() => onRemove()}
|
||||
>
|
||||
<MinusCircle size={14} />
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const GroupPeersRemoveCell = ({ peer }: { peer: Peer }) => {
|
||||
const { removePeersFromGroup } = useGroupContext();
|
||||
const { permission } = usePermissions();
|
||||
return (
|
||||
permission?.peers?.update &&
|
||||
permission?.groups?.update && (
|
||||
<GroupDetailsRemoveCell onRemove={() => removePeersFromGroup([peer])} />
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
export const GroupUsersRemoveCell = ({ user }: { user: User }) => {
|
||||
const { removeUsersFromGroup } = useGroupContext();
|
||||
const { permission } = usePermissions();
|
||||
return (
|
||||
permission?.users?.update && (
|
||||
<GroupDetailsRemoveCell onRemove={() => removeUsersFromGroup([user])} />
|
||||
)
|
||||
);
|
||||
};
|
||||
47
src/modules/groups/details/GroupDetailsTableContainer.tsx
Normal file
47
src/modules/groups/details/GroupDetailsTableContainer.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import Paragraph from "@components/Paragraph";
|
||||
import SkeletonTable, {
|
||||
SkeletonTableHeader,
|
||||
} from "@components/skeletons/SkeletonTable";
|
||||
import React, { Suspense } from "react";
|
||||
|
||||
type Props = {
|
||||
title?: string;
|
||||
description?: string;
|
||||
headingRef?: React.RefObject<HTMLHeadingElement>;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
export const GroupDetailsTableContainer = ({
|
||||
title,
|
||||
description,
|
||||
headingRef,
|
||||
children,
|
||||
}: Props) => {
|
||||
return (
|
||||
<div className={"pb-10 px-8"}>
|
||||
<div className={"w-full"}>
|
||||
{(title || description) && (
|
||||
<div className={"flex justify-between items-center mb-5"}>
|
||||
<div>
|
||||
{title && <h2 ref={headingRef}>{title}</h2>}
|
||||
{description && <Paragraph>{description}</Paragraph>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className={"relative"}>
|
||||
<SkeletonTableHeader className={"!p-0"} />
|
||||
<div className={"mt-6 w-full"}>
|
||||
<SkeletonTable withHeader={false} />
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
28
src/modules/groups/details/GroupNameserversSection.tsx
Normal file
28
src/modules/groups/details/GroupNameserversSection.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { usePortalElement } from "@hooks/usePortalElement";
|
||||
import React, { lazy } from "react";
|
||||
import { useGroupContext } from "@/contexts/GroupProvider";
|
||||
import { NameserverGroup } from "@/interfaces/Nameserver";
|
||||
import { GroupDetailsTableContainer } from "@/modules/groups/details/GroupDetailsTableContainer";
|
||||
|
||||
const NameserverGroupTable = lazy(
|
||||
() => import("@/modules/dns-nameservers/table/NameserverGroupTable"),
|
||||
);
|
||||
|
||||
export const GroupNameserversSection = ({
|
||||
nameserverGroups,
|
||||
}: {
|
||||
nameserverGroups?: NameserverGroup[];
|
||||
}) => {
|
||||
const { group } = useGroupContext();
|
||||
|
||||
return (
|
||||
<GroupDetailsTableContainer>
|
||||
<NameserverGroupTable
|
||||
isLoading={false}
|
||||
nameserverGroups={nameserverGroups}
|
||||
isGroupPage={true}
|
||||
distributionGroups={[group]}
|
||||
/>
|
||||
</GroupDetailsTableContainer>
|
||||
);
|
||||
};
|
||||
80
src/modules/groups/details/GroupNetworkRoutesSection.tsx
Normal file
80
src/modules/groups/details/GroupNetworkRoutesSection.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
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} />,
|
||||
},
|
||||
];
|
||||
|
||||
export const GroupNetworkRoutesSection = ({ routes }: { routes?: Route[] }) => {
|
||||
const groupedRoutes = useGroupedRoutes({ routes });
|
||||
const { group } = useGroupContext();
|
||||
|
||||
return (
|
||||
<GroupDetailsTableContainer>
|
||||
<NetworkRoutesTable
|
||||
isGroupPage={true}
|
||||
isLoading={false}
|
||||
groupedRoutes={groupedRoutes}
|
||||
routes={routes}
|
||||
distributionGroups={[group]}
|
||||
/>
|
||||
</GroupDetailsTableContainer>
|
||||
);
|
||||
};
|
||||
214
src/modules/groups/details/GroupPeersSection.tsx
Normal file
214
src/modules/groups/details/GroupPeersSection.tsx
Normal file
@@ -0,0 +1,214 @@
|
||||
import Button from "@components/Button";
|
||||
import { Checkbox } from "@components/Checkbox";
|
||||
import FullTooltip from "@components/FullTooltip";
|
||||
import DataTableHeader from "@components/table/DataTableHeader";
|
||||
import { DataTableMultiSelectPopup } from "@components/table/DataTableMultiSelectPopup";
|
||||
import { InstallNetBirdButton } from "@components/ui/InstallNetBirdButton";
|
||||
import NoResults from "@components/ui/NoResults";
|
||||
import { ColumnDef, RowSelectionState } from "@tanstack/react-table";
|
||||
import { MinusCircle, PlusCircle } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { lazy, useState } from "react";
|
||||
import PeerIcon from "@/assets/icons/PeerIcon";
|
||||
import { useGroupContext } from "@/contexts/GroupProvider";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import { Peer } from "@/interfaces/Peer";
|
||||
import { AssignPeerToGroupModal } from "@/modules/groups/AssignPeerToGroupModal";
|
||||
import { GroupPeersRemoveCell } from "@/modules/groups/details/GroupDetailsRemoveCell";
|
||||
import { GroupDetailsTableContainer } from "@/modules/groups/details/GroupDetailsTableContainer";
|
||||
import PeerAddressCell from "@/modules/peers/PeerAddressCell";
|
||||
import PeerLastSeenCell from "@/modules/peers/PeerLastSeenCell";
|
||||
import PeerNameCell from "@/modules/peers/PeerNameCell";
|
||||
import { PeerOSCell } from "@/modules/peers/PeerOSCell";
|
||||
|
||||
const GroupPeersTable = lazy(() => import("@/modules/peer/MinimalPeersTable"));
|
||||
|
||||
const GroupPeersTableColumns: ColumnDef<Peer>[] = [
|
||||
{
|
||||
id: "select",
|
||||
header: ({ table }) => (
|
||||
<div className={"min-w-[20px] max-w-[20px]"}>
|
||||
<Checkbox
|
||||
checked={table.getIsAllPageRowsSelected()}
|
||||
onCheckedChange={(value) => table.toggleAllRowsSelected(!!value)}
|
||||
aria-label="Select all"
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<div className={"min-w-[20px] max-w-[20px]"}>
|
||||
<Checkbox
|
||||
checked={row.getIsSelected()}
|
||||
variant={"tableCell"}
|
||||
onCheckedChange={(value) => row.toggleSelected(!!value)}
|
||||
aria-label="Select row"
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
},
|
||||
{
|
||||
accessorKey: "name",
|
||||
header: ({ column }) => {
|
||||
return <DataTableHeader column={column}>Name</DataTableHeader>;
|
||||
},
|
||||
sortingFn: "text",
|
||||
cell: ({ row }) => <PeerNameCell peer={row.original} />,
|
||||
},
|
||||
{
|
||||
id: "connected",
|
||||
accessorKey: "connected",
|
||||
accessorFn: (peer) => peer.connected,
|
||||
},
|
||||
{
|
||||
accessorKey: "ip",
|
||||
sortingFn: "text",
|
||||
},
|
||||
{
|
||||
id: "user_name",
|
||||
accessorFn: (peer) => (peer.user ? peer.user?.name : "Unknown"),
|
||||
},
|
||||
{
|
||||
id: "user_email",
|
||||
accessorFn: (peer) => (peer.user ? peer.user?.email : "Unknown"),
|
||||
},
|
||||
{
|
||||
accessorKey: "dns_label",
|
||||
header: ({ column }) => {
|
||||
return <DataTableHeader column={column}>Address</DataTableHeader>;
|
||||
},
|
||||
cell: ({ row }) => <PeerAddressCell peer={row.original} />,
|
||||
},
|
||||
{
|
||||
accessorKey: "last_seen",
|
||||
header: ({ column }) => {
|
||||
return <DataTableHeader column={column}>Last seen</DataTableHeader>;
|
||||
},
|
||||
sortingFn: "datetime",
|
||||
cell: ({ row }) => <PeerLastSeenCell peer={row.original} />,
|
||||
},
|
||||
{
|
||||
accessorKey: "os",
|
||||
header: ({ column }) => {
|
||||
return <DataTableHeader column={column}>OS</DataTableHeader>;
|
||||
},
|
||||
cell: ({ row }) => <PeerOSCell os={row.original.os} />,
|
||||
},
|
||||
{
|
||||
id: "remove_from_group",
|
||||
accessorKey: "id",
|
||||
header: "",
|
||||
cell: ({ row }) => <GroupPeersRemoveCell peer={row.original} />,
|
||||
},
|
||||
];
|
||||
|
||||
export const GroupPeersSection = ({ peers }: { peers?: Peer[] }) => {
|
||||
const { group, addPeersToGroup, removePeersFromGroup } = useGroupContext();
|
||||
const [selectedRows, setSelectedRows] = useState<RowSelectionState>({});
|
||||
const [open, setOpen] = useState(false);
|
||||
const { permission } = usePermissions();
|
||||
|
||||
return (
|
||||
<GroupDetailsTableContainer>
|
||||
<GroupPeersTable
|
||||
isLoading={false}
|
||||
peers={peers}
|
||||
columns={GroupPeersTableColumns}
|
||||
selectedRows={selectedRows}
|
||||
setSelectedRows={setSelectedRows}
|
||||
getStartedCard={
|
||||
<NoResults
|
||||
className={"py-4"}
|
||||
title={"This group has no assigned peers yet"}
|
||||
description={
|
||||
"Install NetBird and assign existing peers to this group to see them listed here."
|
||||
}
|
||||
icon={<PeerIcon size={20} className={"fill-nb-gray-300"} />}
|
||||
>
|
||||
{permission?.peers?.update && permission?.groups?.update && (
|
||||
<div className={"flex items-center justify-center mt-4 gap-4"}>
|
||||
<InstallNetBirdButton />
|
||||
<Button
|
||||
variant={"primary"}
|
||||
size={"sm"}
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
<PlusCircle size={16} />
|
||||
Assign Peers
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</NoResults>
|
||||
}
|
||||
onRowClick={(row) => row.toggleSelected()}
|
||||
rightSide={(table) => (
|
||||
<>
|
||||
<DataTableMultiSelectPopup
|
||||
selectedItems={table
|
||||
.getSelectedRowModel()
|
||||
.rows.map((row) => row.original)}
|
||||
onCanceled={() => setSelectedRows({})}
|
||||
rightSide={
|
||||
<>
|
||||
<FullTooltip
|
||||
content={
|
||||
<span className={"text-xs"}>Remove Peers from Group</span>
|
||||
}
|
||||
>
|
||||
<Button
|
||||
variant={"default-outline"}
|
||||
size={"xs"}
|
||||
className={"!h-9 !w-9"}
|
||||
onClick={() => {
|
||||
let peers = table
|
||||
.getSelectedRowModel()
|
||||
.rows.map((row) => row.original);
|
||||
removePeersFromGroup(peers).then();
|
||||
setSelectedRows({});
|
||||
}}
|
||||
>
|
||||
<MinusCircle size={16} className={"shrink-0"} />
|
||||
</Button>
|
||||
</FullTooltip>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<AssignPeerToGroupModal
|
||||
group={group}
|
||||
open={open}
|
||||
setOpen={setOpen}
|
||||
useSave={false}
|
||||
showHeader={false}
|
||||
showClose={false}
|
||||
buttonText={"Assign Peers"}
|
||||
selectInitialPeers={false}
|
||||
excludedPeers={peers}
|
||||
onUpdate={(g) => {
|
||||
let peers = g.peers as Peer[];
|
||||
addPeersToGroup(peers).then();
|
||||
}}
|
||||
/>
|
||||
{peers && peers?.length > 0 && (
|
||||
<div className={"ml-auto flex items-center"}>
|
||||
<div className={"flex items-center justify-center gap-4"}>
|
||||
<InstallNetBirdButton />
|
||||
{permission?.peers?.update && permission?.groups?.update && (
|
||||
<Button
|
||||
variant={"primary"}
|
||||
size={"sm"}
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
<PlusCircle size={16} />
|
||||
Assign Peers
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
</GroupDetailsTableContainer>
|
||||
);
|
||||
};
|
||||
22
src/modules/groups/details/GroupPoliciesSection.tsx
Normal file
22
src/modules/groups/details/GroupPoliciesSection.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import React, { lazy } from "react";
|
||||
import PoliciesProvider from "@/contexts/PoliciesProvider";
|
||||
import { Policy } from "@/interfaces/Policy";
|
||||
import { GroupDetailsTableContainer } from "@/modules/groups/details/GroupDetailsTableContainer";
|
||||
|
||||
const AccessControlTable = lazy(
|
||||
() => import("@/modules/access-control/table/AccessControlTable"),
|
||||
);
|
||||
|
||||
export const GroupPoliciesSection = ({ policies }: { policies?: Policy[] }) => {
|
||||
return (
|
||||
<GroupDetailsTableContainer>
|
||||
<PoliciesProvider>
|
||||
<AccessControlTable
|
||||
isLoading={false}
|
||||
policies={policies}
|
||||
isGroupPage={true}
|
||||
/>
|
||||
</PoliciesProvider>
|
||||
</GroupDetailsTableContainer>
|
||||
);
|
||||
};
|
||||
176
src/modules/groups/details/GroupResourcesSection.tsx
Normal file
176
src/modules/groups/details/GroupResourcesSection.tsx
Normal file
@@ -0,0 +1,176 @@
|
||||
import Button from "@components/Button";
|
||||
import Card from "@components/Card";
|
||||
import { DataTable } from "@components/table/DataTable";
|
||||
import DataTableHeader from "@components/table/DataTableHeader";
|
||||
import { DataTableRowsPerPage } from "@components/table/DataTableRowsPerPage";
|
||||
import NoResults from "@components/ui/NoResults";
|
||||
import { ColumnDef, SortingState } from "@tanstack/react-table";
|
||||
import { removeAllSpaces } from "@utils/helpers";
|
||||
import { ArrowUpRightIcon, Layers3Icon } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import React, { useState } from "react";
|
||||
import { useSWRConfig } from "swr";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import { Group } from "@/interfaces/Group";
|
||||
import { NetworkResourceWithNetwork } from "@/interfaces/Network";
|
||||
import { GroupDetailsTableContainer } from "@/modules/groups/details/GroupDetailsTableContainer";
|
||||
import { NetworkProvider } from "@/modules/networks/NetworkProvider";
|
||||
import { ResourceActionCell } from "@/modules/networks/resources/ResourceActionCell";
|
||||
import ResourceAddressCell from "@/modules/networks/resources/ResourceAddressCell";
|
||||
import { ResourceEnabledCell } from "@/modules/networks/resources/ResourceEnabledCell";
|
||||
import { ResourceGroupCell } from "@/modules/networks/resources/ResourceGroupCell";
|
||||
import ResourceNameCell from "@/modules/networks/resources/ResourceNameCell";
|
||||
import { ResourcePolicyCell } from "@/modules/networks/resources/ResourcePolicyCell";
|
||||
|
||||
const GroupResourcesColumns: ColumnDef<NetworkResourceWithNetwork>[] = [
|
||||
{
|
||||
id: "id",
|
||||
accessorKey: "id",
|
||||
filterFn: "exactMatch",
|
||||
},
|
||||
{
|
||||
id: "name",
|
||||
accessorKey: "name",
|
||||
header: ({ column }) => {
|
||||
return <DataTableHeader column={column}>Resource</DataTableHeader>;
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
return <ResourceNameCell resource={row.original} />;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "description",
|
||||
accessorKey: "description",
|
||||
accessorFn: (resource) =>
|
||||
removeAllSpaces(resource?.description || "").toLowerCase(),
|
||||
},
|
||||
{
|
||||
id: "address",
|
||||
accessorKey: "address",
|
||||
header: ({ column }) => {
|
||||
return <DataTableHeader column={column}>Address</DataTableHeader>;
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
return <ResourceAddressCell resource={row.original} />;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "enabled",
|
||||
accessorKey: "enabled",
|
||||
header: ({ column }) => {
|
||||
return <DataTableHeader column={column}>Active</DataTableHeader>;
|
||||
},
|
||||
cell: ({ row }) => (
|
||||
<ResourceEnabledCell
|
||||
resource={row.original}
|
||||
mutateAllResourcesOnUpdate={true}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "groups",
|
||||
accessorFn: (resource) => {
|
||||
let groups = resource?.groups as Group[];
|
||||
return groups.map((group) => group.name).join(", ");
|
||||
},
|
||||
header: ({ column }) => {
|
||||
return <DataTableHeader column={column}>Groups</DataTableHeader>;
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
return <ResourceGroupCell resource={row.original} />;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "policies",
|
||||
accessorKey: "id",
|
||||
header: ({ column }) => {
|
||||
return <DataTableHeader column={column}>Policies</DataTableHeader>;
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
return <ResourcePolicyCell resource={row.original} />;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
accessorKey: "id",
|
||||
header: "",
|
||||
cell: ({ row }) => {
|
||||
return <ResourceActionCell resource={row.original} />;
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export const GroupResourcesSection = ({
|
||||
resources,
|
||||
}: {
|
||||
resources?: NetworkResourceWithNetwork[];
|
||||
}) => {
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
const { permission } = usePermissions();
|
||||
const router = useRouter();
|
||||
const { mutate } = useSWRConfig();
|
||||
|
||||
return (
|
||||
<GroupDetailsTableContainer>
|
||||
<DataTable
|
||||
wrapperComponent={Card}
|
||||
wrapperProps={{ className: "mt-6 pb-2 w-full" }}
|
||||
sorting={sorting}
|
||||
setSorting={setSorting}
|
||||
minimal={true}
|
||||
showSearchAndFilters={true}
|
||||
renderRow={(row, children) => (
|
||||
<NetworkProvider
|
||||
network={row.network}
|
||||
onResourceUpdate={() => mutate("/networks/resources")}
|
||||
onResourceDelete={() => mutate("/networks/resources")}
|
||||
>
|
||||
{children}
|
||||
</NetworkProvider>
|
||||
)}
|
||||
inset={false}
|
||||
tableClassName={"mt-0"}
|
||||
text={"Resources"}
|
||||
columns={GroupResourcesColumns}
|
||||
keepStateInLocalStorage={false}
|
||||
data={resources}
|
||||
searchPlaceholder={"Search by name, address or group..."}
|
||||
getStartedCard={
|
||||
<NoResults
|
||||
className={"py-4"}
|
||||
title={"This group has no assigned resources"}
|
||||
description={
|
||||
"Assign this group to your resources inside your networks to see them listed here."
|
||||
}
|
||||
icon={<Layers3Icon size={20} />}
|
||||
>
|
||||
{permission?.networks?.create && (
|
||||
<>
|
||||
<Button
|
||||
variant={"primary"}
|
||||
className={"mt-4"}
|
||||
onClick={() => router.push("/networks")}
|
||||
>
|
||||
Go to Networks
|
||||
<ArrowUpRightIcon size={16} />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</NoResults>
|
||||
}
|
||||
columnVisibility={{
|
||||
description: false,
|
||||
id: false,
|
||||
}}
|
||||
paginationPaddingClassName={"px-0 pt-8"}
|
||||
>
|
||||
{(table) => (
|
||||
<DataTableRowsPerPage
|
||||
table={table}
|
||||
disabled={!resources || resources?.length == 0}
|
||||
/>
|
||||
)}
|
||||
</DataTable>
|
||||
</GroupDetailsTableContainer>
|
||||
);
|
||||
};
|
||||
27
src/modules/groups/details/GroupSetupKeysSection.tsx
Normal file
27
src/modules/groups/details/GroupSetupKeysSection.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import React, { lazy } from "react";
|
||||
import { useGroupContext } from "@/contexts/GroupProvider";
|
||||
import { SetupKey } from "@/interfaces/SetupKey";
|
||||
import { GroupDetailsTableContainer } from "@/modules/groups/details/GroupDetailsTableContainer";
|
||||
|
||||
const SetupKeysTable = lazy(
|
||||
() => import("@/modules/setup-keys/SetupKeysTable"),
|
||||
);
|
||||
|
||||
export const GroupSetupKeysSection = ({
|
||||
setupKeys,
|
||||
}: {
|
||||
setupKeys?: SetupKey[];
|
||||
}) => {
|
||||
const { group } = useGroupContext();
|
||||
|
||||
return (
|
||||
<GroupDetailsTableContainer>
|
||||
<SetupKeysTable
|
||||
isLoading={false}
|
||||
setupKeys={setupKeys}
|
||||
isGroupPage={true}
|
||||
groups={[group]}
|
||||
/>
|
||||
</GroupDetailsTableContainer>
|
||||
);
|
||||
};
|
||||
224
src/modules/groups/details/GroupUsersSection.tsx
Normal file
224
src/modules/groups/details/GroupUsersSection.tsx
Normal file
@@ -0,0 +1,224 @@
|
||||
import Button from "@components/Button";
|
||||
import { Checkbox } from "@components/Checkbox";
|
||||
import FullTooltip from "@components/FullTooltip";
|
||||
import DataTableHeader from "@components/table/DataTableHeader";
|
||||
import { DataTableMultiSelectPopup } from "@components/table/DataTableMultiSelectPopup";
|
||||
import NoResults from "@components/ui/NoResults";
|
||||
import { ColumnDef, RowSelectionState } from "@tanstack/react-table";
|
||||
import dayjs from "dayjs";
|
||||
import { MinusCircle, PlusCircle } from "lucide-react";
|
||||
import React, { lazy, useState } from "react";
|
||||
import TeamIcon from "@/assets/icons/TeamIcon";
|
||||
import { useGroupContext } from "@/contexts/GroupProvider";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import { User } from "@/interfaces/User";
|
||||
import LastTimeRow from "@/modules/common-table-rows/LastTimeRow";
|
||||
import { AssignUserToGroupModal } from "@/modules/groups/AssignUserToGroupModal";
|
||||
import { GroupUsersRemoveCell } from "@/modules/groups/details/GroupDetailsRemoveCell";
|
||||
import { GroupDetailsTableContainer } from "@/modules/groups/details/GroupDetailsTableContainer";
|
||||
import UserBlockCell from "@/modules/users/table-cells/UserBlockCell";
|
||||
import UserNameCell from "@/modules/users/table-cells/UserNameCell";
|
||||
import UserRoleCell from "@/modules/users/table-cells/UserRoleCell";
|
||||
import UserStatusCell from "@/modules/users/table-cells/UserStatusCell";
|
||||
import { InviteUserButton } from "@/modules/users/UsersTable";
|
||||
|
||||
const UsersTable = lazy(() => import("@/modules/users/UsersTable"));
|
||||
|
||||
export const GroupUsersTableColumns: ColumnDef<User>[] = [
|
||||
{
|
||||
id: "select",
|
||||
header: ({ table }) => (
|
||||
<div className={"min-w-[20px] max-w-[20px]"}>
|
||||
<Checkbox
|
||||
checked={table.getIsAllPageRowsSelected()}
|
||||
onCheckedChange={(value) => table.toggleAllRowsSelected(!!value)}
|
||||
aria-label="Select all"
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<div className={"min-w-[20px] max-w-[20px]"}>
|
||||
<Checkbox
|
||||
checked={row.getIsSelected()}
|
||||
variant={"tableCell"}
|
||||
onCheckedChange={(value) => row.toggleSelected(!!value)}
|
||||
aria-label="Select row"
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
},
|
||||
{
|
||||
accessorKey: "name",
|
||||
header: ({ column }) => {
|
||||
return <DataTableHeader column={column}>Name</DataTableHeader>;
|
||||
},
|
||||
accessorFn: (row) => row.name + " " + row.email,
|
||||
sortingFn: "text",
|
||||
cell: ({ row }) => <UserNameCell user={row.original} />,
|
||||
},
|
||||
{
|
||||
accessorKey: "is_current",
|
||||
sortingFn: "basic",
|
||||
},
|
||||
{
|
||||
accessorKey: "role",
|
||||
header: ({ column }) => {
|
||||
return <DataTableHeader column={column}>Role</DataTableHeader>;
|
||||
},
|
||||
sortingFn: "text",
|
||||
cell: ({ row }) => <UserRoleCell user={row.original} />,
|
||||
},
|
||||
{
|
||||
accessorKey: "status",
|
||||
header: ({ column }) => {
|
||||
return <DataTableHeader column={column}>Status</DataTableHeader>;
|
||||
},
|
||||
sortingFn: "text",
|
||||
cell: ({ row }) => <UserStatusCell user={row.original} />,
|
||||
},
|
||||
{
|
||||
accessorKey: "is_blocked",
|
||||
header: ({ column }) => {
|
||||
return <DataTableHeader column={column}>Block User</DataTableHeader>;
|
||||
},
|
||||
sortingFn: "text",
|
||||
cell: ({ row }) => <UserBlockCell user={row.original} />,
|
||||
},
|
||||
{
|
||||
accessorKey: "last_login",
|
||||
header: ({ column }) => {
|
||||
return <DataTableHeader column={column}>Last Login</DataTableHeader>;
|
||||
},
|
||||
sortingFn: "text",
|
||||
cell: ({ row }) => (
|
||||
<LastTimeRow
|
||||
date={dayjs(row.original.last_login).toDate()}
|
||||
text={"Last login on"}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "approval_required",
|
||||
accessorKey: "approval_required",
|
||||
sortingFn: "basic",
|
||||
accessorFn: (u) => u?.pending_approval,
|
||||
},
|
||||
{
|
||||
id: "remove_from_group",
|
||||
accessorKey: "id",
|
||||
header: "",
|
||||
cell: ({ row }) => <GroupUsersRemoveCell user={row.original} />,
|
||||
},
|
||||
];
|
||||
|
||||
export const GroupUsersSection = ({ users }: { users?: User[] }) => {
|
||||
const { group, addUsersToGroup, removeUsersFromGroup } = useGroupContext();
|
||||
const [selectedRows, setSelectedRows] = useState<RowSelectionState>({});
|
||||
const [open, setOpen] = useState(false);
|
||||
const { permission } = usePermissions();
|
||||
|
||||
return (
|
||||
<GroupDetailsTableContainer>
|
||||
<UsersTable
|
||||
isLoading={false}
|
||||
columns={GroupUsersTableColumns}
|
||||
selectedRows={selectedRows}
|
||||
setSelectedRows={setSelectedRows}
|
||||
onRowClick={(row) => row.toggleSelected()}
|
||||
keepStateInLocalStorage={false}
|
||||
minimal={true}
|
||||
users={users}
|
||||
getStartedCard={
|
||||
<NoResults
|
||||
className={"py-4"}
|
||||
title={"This group has no assigned users yet"}
|
||||
description={
|
||||
"Invite new users or assign existing ones to this group to see them listed here."
|
||||
}
|
||||
icon={<TeamIcon size={20} className={"fill-nb-gray-300"} />}
|
||||
>
|
||||
{permission?.users?.update && (
|
||||
<div className={"flex gap-4 items-center justify-center mt-4"}>
|
||||
<Button
|
||||
variant={"secondary"}
|
||||
size={"sm"}
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
<PlusCircle size={16} />
|
||||
Assign Users
|
||||
</Button>
|
||||
<InviteUserButton show={true} groups={[group]} />
|
||||
</div>
|
||||
)}
|
||||
</NoResults>
|
||||
}
|
||||
rightSide={(table) => {
|
||||
return (
|
||||
<>
|
||||
<DataTableMultiSelectPopup
|
||||
label={"User(s) selected"}
|
||||
selectedItems={table
|
||||
.getSelectedRowModel()
|
||||
.rows.map((row) => row.original)}
|
||||
onCanceled={() => setSelectedRows({})}
|
||||
rightSide={
|
||||
<>
|
||||
<FullTooltip
|
||||
content={
|
||||
<span className={"text-xs"}>
|
||||
Remove Users from Group
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<Button
|
||||
variant={"default-outline"}
|
||||
size={"xs"}
|
||||
className={"!h-9 !w-9"}
|
||||
onClick={() => {
|
||||
let usersToRemove = table
|
||||
.getSelectedRowModel()
|
||||
.rows.map((row) => row.original);
|
||||
removeUsersFromGroup(usersToRemove).then();
|
||||
setSelectedRows({});
|
||||
}}
|
||||
>
|
||||
<MinusCircle size={16} className={"shrink-0"} />
|
||||
</Button>
|
||||
</FullTooltip>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<AssignUserToGroupModal
|
||||
group={group}
|
||||
open={open}
|
||||
setOpen={setOpen}
|
||||
showClose={false}
|
||||
excludedUsers={users}
|
||||
onSuccess={(newUsers) => {
|
||||
addUsersToGroup(newUsers).then();
|
||||
}}
|
||||
/>
|
||||
{users && users?.length > 0 && permission?.users?.update && (
|
||||
<div
|
||||
className={"flex gap-4 items-center justify-center ml-auto"}
|
||||
>
|
||||
<Button
|
||||
variant={"secondary"}
|
||||
size={"sm"}
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
<PlusCircle size={16} />
|
||||
Assign Users
|
||||
</Button>
|
||||
<InviteUserButton show={true} groups={[group]} />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</GroupDetailsTableContainer>
|
||||
);
|
||||
};
|
||||
150
src/modules/groups/details/useGroupDetails.ts
Normal file
150
src/modules/groups/details/useGroupDetails.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
import { useMemo } from "react";
|
||||
import { Group, GroupPeer, GroupResource } from "@/interfaces/Group";
|
||||
import { NameserverGroup } from "@/interfaces/Nameserver";
|
||||
import {
|
||||
Network,
|
||||
NetworkResource,
|
||||
NetworkResourceWithNetwork,
|
||||
} from "@/interfaces/Network";
|
||||
import { Peer } from "@/interfaces/Peer";
|
||||
import { Policy } from "@/interfaces/Policy";
|
||||
import { Route } from "@/interfaces/Route";
|
||||
import { SetupKey } from "@/interfaces/SetupKey";
|
||||
import { User } from "@/interfaces/User";
|
||||
import useFetchApi from "@/utils/api";
|
||||
|
||||
export interface GroupDetails extends Group {
|
||||
policies: Policy[];
|
||||
nameservers: NameserverGroup[];
|
||||
routes: Route[];
|
||||
setupKeys: SetupKey[];
|
||||
users: User[];
|
||||
peersOfGroup: Peer[];
|
||||
networkResources: NetworkResourceWithNetwork[];
|
||||
}
|
||||
|
||||
export default function useGroupDetails(groupId: string) {
|
||||
const { data: group, isLoading: isGroupsLoading } = useFetchApi<Group>(
|
||||
`/groups/${groupId}`,
|
||||
);
|
||||
const { data: policies, isLoading: isPoliciesLoading } =
|
||||
useFetchApi<Policy[]>(`/policies`);
|
||||
const { data: nameservers, isLoading: isNameserversLoading } =
|
||||
useFetchApi<NameserverGroup[]>(`/dns/nameservers`);
|
||||
const { data: routes, isLoading: isRoutesLoading } =
|
||||
useFetchApi<Route[]>(`/routes`);
|
||||
const { data: setupKeys, isLoading: isSetupKeysLoading } =
|
||||
useFetchApi<SetupKey[]>(`/setup-keys`);
|
||||
const { data: users, isLoading: isUsersLoading } = useFetchApi<User[]>(
|
||||
`/users?service_user=false`,
|
||||
);
|
||||
const { data: peers, isLoading: isPeerLoading } =
|
||||
useFetchApi<Peer[]>(`/peers`);
|
||||
const { data: resources, isLoading: isLoadingResources } = useFetchApi<
|
||||
NetworkResource[]
|
||||
>("/networks/resources");
|
||||
const { data: networks, isLoading: isNetworksLoading } =
|
||||
useFetchApi<Network[]>("/networks");
|
||||
|
||||
const linkedPolicies = useMemo(() => {
|
||||
return (
|
||||
policies?.filter((policy) => {
|
||||
let rule = policy.rules?.[0] ?? undefined;
|
||||
const sourceGroups = (rule.sources as Group[]) || [];
|
||||
const destinationGroups = (rule.destinations as Group[]) || [];
|
||||
const isInSources = sourceGroups.some((g) => g.id === groupId);
|
||||
const isInDestinations = destinationGroups.some(
|
||||
(g) => g.id === groupId,
|
||||
);
|
||||
return isInSources || isInDestinations;
|
||||
}) || []
|
||||
);
|
||||
}, [policies, groupId]);
|
||||
|
||||
const linkedNameservers = useMemo(() => {
|
||||
return nameservers?.filter((ns) => ns.groups?.includes(groupId)) || [];
|
||||
}, [nameservers, groupId]);
|
||||
|
||||
const linkedRoutes = useMemo(() => {
|
||||
return (
|
||||
routes?.filter((route) => {
|
||||
const isInDistributionGroups = route.groups?.includes(groupId);
|
||||
const isInAccessControlGroups =
|
||||
route.access_control_groups?.includes(groupId);
|
||||
const isInPeerGroups = route.peer_groups?.includes(groupId);
|
||||
|
||||
return (
|
||||
isInAccessControlGroups || isInDistributionGroups || isInPeerGroups
|
||||
);
|
||||
}) || []
|
||||
);
|
||||
}, [routes, groupId]);
|
||||
|
||||
const linkedSetupKeys = useMemo(() => {
|
||||
return setupKeys?.filter((key) => key.auto_groups?.includes(groupId)) || [];
|
||||
}, [setupKeys, groupId]);
|
||||
|
||||
const linkedUsers = useMemo(() => {
|
||||
return users?.filter((user) => user.auto_groups?.includes(groupId)) || [];
|
||||
}, [users, groupId]);
|
||||
|
||||
const linkedPeers = useMemo(() => {
|
||||
const groupPeerIds = (group?.peers as GroupPeer[])?.map((p) => p.id);
|
||||
return peers?.filter((p) => groupPeerIds?.includes(p.id!)) || [];
|
||||
}, [peers, group]);
|
||||
|
||||
const linkedNetworkResources = useMemo(() => {
|
||||
if (!resources || !group?.resources) return [];
|
||||
const resourcesIds = (group?.resources as GroupResource[])?.map(
|
||||
(p) => p.id,
|
||||
);
|
||||
let networkResources = resources.filter(
|
||||
(p) => resourcesIds?.includes(p.id),
|
||||
);
|
||||
|
||||
return networkResources.map((networkResource) => {
|
||||
const network = networks?.find(
|
||||
(n) => n.resources?.includes(networkResource.id),
|
||||
);
|
||||
return {
|
||||
...networkResource,
|
||||
network: network,
|
||||
} as NetworkResourceWithNetwork;
|
||||
});
|
||||
}, [group?.resources, networks, resources]);
|
||||
|
||||
const isLoading =
|
||||
isGroupsLoading ||
|
||||
isPoliciesLoading ||
|
||||
isNameserversLoading ||
|
||||
isRoutesLoading ||
|
||||
isSetupKeysLoading ||
|
||||
isUsersLoading ||
|
||||
isPeerLoading ||
|
||||
isLoadingResources;
|
||||
|
||||
return useMemo(() => {
|
||||
if (isLoading || !group) return null;
|
||||
|
||||
return {
|
||||
...group,
|
||||
policies: linkedPolicies,
|
||||
nameservers: linkedNameservers,
|
||||
routes: linkedRoutes,
|
||||
setupKeys: linkedSetupKeys,
|
||||
users: linkedUsers,
|
||||
peersOfGroup: linkedPeers,
|
||||
networkResources: linkedNetworkResources,
|
||||
} as GroupDetails;
|
||||
}, [
|
||||
isLoading,
|
||||
group,
|
||||
linkedPolicies,
|
||||
linkedNameservers,
|
||||
linkedRoutes,
|
||||
linkedSetupKeys,
|
||||
linkedUsers,
|
||||
linkedPeers,
|
||||
linkedNetworkResources,
|
||||
]);
|
||||
}
|
||||
128
src/modules/groups/table/GroupsActionCell.tsx
Normal file
128
src/modules/groups/table/GroupsActionCell.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
import Button from "@components/Button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@components/DropdownMenu";
|
||||
import FullTooltip from "@components/FullTooltip";
|
||||
import { cn } from "@utils/helpers";
|
||||
import { FolderIcon, MoreVertical, Pencil, Trash2 } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import React from "react";
|
||||
import { useGroupContext } from "@/contexts/GroupProvider";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import { GROUP_TOOLTIP_TEXT } from "@/interfaces/Group";
|
||||
import { GroupUsage } from "@/modules/groups/useGroupsUsage";
|
||||
|
||||
type Props = {
|
||||
group: GroupUsage;
|
||||
inUse: boolean;
|
||||
};
|
||||
|
||||
export default function GroupsActionCell({ group, inUse }: Readonly<Props>) {
|
||||
const { permission } = usePermissions();
|
||||
const router = useRouter();
|
||||
|
||||
const {
|
||||
deleteGroup,
|
||||
isAllowedToRename,
|
||||
isAllowedToDelete,
|
||||
isIntegrationGroup,
|
||||
isJWTGroup,
|
||||
openGroupRenameModal,
|
||||
} = useGroupContext();
|
||||
|
||||
const canDelete = isAllowedToDelete && !inUse;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
"flex justify-end pr-4 gap-3",
|
||||
group.name === "All" && "pointer-events-none opacity-0",
|
||||
)}
|
||||
>
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger
|
||||
asChild
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
}}
|
||||
>
|
||||
<Button variant={"secondary"} className={"!px-3"}>
|
||||
<MoreVertical size={16} className={"shrink-0"} />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent className="w-auto" align="end">
|
||||
<DropdownMenuItem
|
||||
onClick={() => router.push("/group?id=" + group.id)}
|
||||
disabled={!permission.groups.read}
|
||||
>
|
||||
<div className="flex gap-3 items-center">
|
||||
<FolderIcon size={14} className="shrink-0" />
|
||||
View Details
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
|
||||
{permission?.groups?.update && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<FullTooltip
|
||||
content={
|
||||
<div className={"text-xs max-w-xs"}>
|
||||
{isJWTGroup
|
||||
? GROUP_TOOLTIP_TEXT.RENAME.JWT
|
||||
: GROUP_TOOLTIP_TEXT.RENAME.INTEGRATION}
|
||||
</div>
|
||||
}
|
||||
interactive={false}
|
||||
disabled={isAllowedToRename}
|
||||
className={"w-full block"}
|
||||
>
|
||||
<DropdownMenuItem
|
||||
onClick={openGroupRenameModal}
|
||||
disabled={!isAllowedToRename}
|
||||
>
|
||||
<div className="flex gap-3 items-center">
|
||||
<Pencil size={14} className="shrink-0" />
|
||||
Rename
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
</FullTooltip>
|
||||
</>
|
||||
)}
|
||||
{permission?.groups?.delete && (
|
||||
<FullTooltip
|
||||
content={
|
||||
<div className={"text-xs max-w-xs"}>
|
||||
{isIntegrationGroup
|
||||
? GROUP_TOOLTIP_TEXT.DELETE.INTEGRATION
|
||||
: GROUP_TOOLTIP_TEXT.IN_USE}
|
||||
</div>
|
||||
}
|
||||
interactive={false}
|
||||
disabled={canDelete}
|
||||
className={"w-full block"}
|
||||
>
|
||||
<DropdownMenuItem
|
||||
onClick={deleteGroup}
|
||||
variant={"danger"}
|
||||
disabled={!canDelete}
|
||||
>
|
||||
<div className="flex gap-3 items-center">
|
||||
<Trash2 size={14} className="shrink-0" />
|
||||
Delete
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
</FullTooltip>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
59
src/modules/groups/table/GroupsCountCell.tsx
Normal file
59
src/modules/groups/table/GroupsCountCell.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import Badge from "@components/Badge";
|
||||
import FullTooltip from "@components/FullTooltip";
|
||||
import { cn } from "@utils/helpers";
|
||||
import { useRouter } from "next/navigation";
|
||||
import React from "react";
|
||||
|
||||
type Props = {
|
||||
icon: React.ReactNode;
|
||||
count: number;
|
||||
groupName: string;
|
||||
text?: string;
|
||||
href?: string;
|
||||
hidden?: boolean;
|
||||
};
|
||||
export default function GroupsCountCell({
|
||||
icon,
|
||||
count = 0,
|
||||
groupName,
|
||||
text,
|
||||
href,
|
||||
hidden = false,
|
||||
}: Props) {
|
||||
const router = useRouter();
|
||||
|
||||
const handleClick = () => {
|
||||
href && router.push(href);
|
||||
};
|
||||
|
||||
return (
|
||||
!hidden && (
|
||||
<FullTooltip
|
||||
className={"w-full"}
|
||||
content={
|
||||
<div className={"text-xs"}>
|
||||
Group{" "}
|
||||
<span className={"text-netbird font-medium"}>{groupName}</span> is
|
||||
used in <span className={"font-medium text-netbird"}>{count}</span>{" "}
|
||||
{text}
|
||||
</div>
|
||||
}
|
||||
disabled={count === 0}
|
||||
>
|
||||
<Badge
|
||||
variant={"gray"}
|
||||
useHover={!!href}
|
||||
onClick={href ? handleClick : undefined}
|
||||
className={cn(
|
||||
"gap-2 w-full",
|
||||
count === 0 && "opacity-30",
|
||||
href && "cursor-pointer",
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
{count}
|
||||
</Badge>
|
||||
</FullTooltip>
|
||||
)
|
||||
);
|
||||
}
|
||||
43
src/modules/groups/table/GroupsNameCell.tsx
Normal file
43
src/modules/groups/table/GroupsNameCell.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import { GroupBadgeIcon } from "@components/ui/GroupBadgeIcon";
|
||||
import TextWithTooltip from "@components/ui/TextWithTooltip";
|
||||
import { useRouter } from "next/navigation";
|
||||
import React from "react";
|
||||
import CircleIcon from "@/assets/icons/CircleIcon";
|
||||
import { Group } from "@/interfaces/Group";
|
||||
|
||||
type Props = {
|
||||
active: boolean;
|
||||
group: Group;
|
||||
};
|
||||
export default function GroupsNameCell({ active, group }: Readonly<Props>) {
|
||||
const router = useRouter();
|
||||
return (
|
||||
<div className={""}>
|
||||
<div
|
||||
className={
|
||||
"inline-flex items-center justify-start text-neutral-300 gap-2.5 py-2 px-3 pr-4 hover:bg-nb-gray-800/60 cursor-pointer rounded-md"
|
||||
}
|
||||
onClick={() => router.push("/group?id=" + group.id)}
|
||||
>
|
||||
<div className={"flex items-center justify-center h-full"}>
|
||||
<GroupBadgeIcon id={group?.id} issued={group?.issued} />
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={"flex flex-col min-w-0 cursor-pointer"}
|
||||
aria-label={`View details of group ${group.name}`}
|
||||
>
|
||||
<div className={"font-medium flex gap-2 items-center justify-center"}>
|
||||
<TextWithTooltip text={group?.name} maxChars={50} />
|
||||
</div>
|
||||
</div>
|
||||
<CircleIcon
|
||||
size={8}
|
||||
active={active}
|
||||
inactiveDot={"gray"}
|
||||
className={"shrink-0"}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,9 +2,9 @@ import ButtonGroup from "@components/ButtonGroup";
|
||||
import { DataTable } from "@components/table/DataTable";
|
||||
import DataTableHeader from "@components/table/DataTableHeader";
|
||||
import { DataTableRowsPerPage } from "@components/table/DataTableRowsPerPage";
|
||||
import NoResults from "@components/ui/NoResults";
|
||||
import { ColumnDef, SortingState } from "@tanstack/react-table";
|
||||
import { FolderGit2Icon, Layers3Icon } from "lucide-react";
|
||||
import { removeAllSpaces } from "@utils/helpers";
|
||||
import { Layers3Icon } from "lucide-react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import React from "react";
|
||||
import AccessControlIcon from "@/assets/icons/AccessControlIcon";
|
||||
@@ -13,13 +13,14 @@ import NetworkRoutesIcon from "@/assets/icons/NetworkRoutesIcon";
|
||||
import PeerIcon from "@/assets/icons/PeerIcon";
|
||||
import SetupKeysIcon from "@/assets/icons/SetupKeysIcon";
|
||||
import TeamIcon from "@/assets/icons/TeamIcon";
|
||||
import { AddGroupButton } from "@/components/ui/AddGroupButton";
|
||||
import { GroupProvider } from "@/contexts/GroupProvider";
|
||||
import { useLocalStorage } from "@/hooks/useLocalStorage";
|
||||
import GroupsActionCell from "@/modules/settings/GroupsActionCell";
|
||||
import GroupsCountCell from "@/modules/settings/GroupsCountCell";
|
||||
import GroupsNameCell from "@/modules/settings/GroupsNameCell";
|
||||
import useGroupsUsage, { GroupUsage } from "@/modules/settings/useGroupsUsage";
|
||||
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";
|
||||
|
||||
// Peers, Access Controls, DNS, Routes, Setup Keys, Users
|
||||
export const GroupsTableColumns: ColumnDef<GroupUsage>[] = [
|
||||
{
|
||||
accessorKey: "name",
|
||||
@@ -42,24 +43,25 @@ export const GroupsTableColumns: ColumnDef<GroupUsage>[] = [
|
||||
sortingFn: "text",
|
||||
},
|
||||
{
|
||||
accessorKey: "setup_keys_count",
|
||||
accessorKey: "users_count",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<DataTableHeader
|
||||
column={column}
|
||||
center={true}
|
||||
tooltip={<div className={"text-sm normal-case"}>Setup Keys</div>}
|
||||
tooltip={<div className={"text-xs normal-case"}>Users</div>}
|
||||
>
|
||||
<SetupKeysIcon size={12} />
|
||||
<TeamIcon size={12} />
|
||||
</DataTableHeader>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => (
|
||||
<GroupsCountCell
|
||||
icon={<SetupKeysIcon size={10} />}
|
||||
icon={<TeamIcon size={10} />}
|
||||
groupName={row.original.name}
|
||||
text={"Setup Key(s)"}
|
||||
count={row.original.setup_keys_count}
|
||||
href={`/group?id=${row.original.id}&tab=users`}
|
||||
hidden={row.original.name === "All"}
|
||||
text={"User(s)"}
|
||||
count={row.original.users_count}
|
||||
/>
|
||||
),
|
||||
},
|
||||
@@ -69,7 +71,7 @@ export const GroupsTableColumns: ColumnDef<GroupUsage>[] = [
|
||||
return (
|
||||
<DataTableHeader
|
||||
column={column}
|
||||
tooltip={<div className={"text-sm normal-case"}>Peers</div>}
|
||||
tooltip={<div className={"text-xs normal-case"}>Peers</div>}
|
||||
>
|
||||
<PeerIcon size={12} />
|
||||
</DataTableHeader>
|
||||
@@ -79,39 +81,20 @@ export const GroupsTableColumns: ColumnDef<GroupUsage>[] = [
|
||||
<GroupsCountCell
|
||||
icon={<PeerIcon size={10} />}
|
||||
groupName={row.original.name}
|
||||
href={`/group?id=${row.original.id}&tab=peers`}
|
||||
hidden={row.original.name === "All"}
|
||||
text={"Peer(s)"}
|
||||
count={row.original.peers_count}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "nameservers_count",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<DataTableHeader
|
||||
column={column}
|
||||
tooltip={<div className={"text-sm normal-case"}>DNS</div>}
|
||||
>
|
||||
<DNSIcon size={12} />
|
||||
</DataTableHeader>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => (
|
||||
<GroupsCountCell
|
||||
icon={<DNSIcon size={10} />}
|
||||
groupName={row.original.name}
|
||||
text={"DNS"}
|
||||
count={row.original.nameservers_count}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "policies_count",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<DataTableHeader
|
||||
column={column}
|
||||
tooltip={<div className={"text-sm normal-case"}>Access Controls</div>}
|
||||
tooltip={<div className={"text-xs normal-case"}>Policies</div>}
|
||||
>
|
||||
<AccessControlIcon size={12} />
|
||||
</DataTableHeader>
|
||||
@@ -121,32 +104,12 @@ export const GroupsTableColumns: ColumnDef<GroupUsage>[] = [
|
||||
<GroupsCountCell
|
||||
icon={<AccessControlIcon size={10} />}
|
||||
groupName={row.original.name}
|
||||
text={"Access Control(s)"}
|
||||
href={`/group?id=${row.original.id}&tab=policies`}
|
||||
text={row.original.policies_count === 1 ? "Policy" : "Policies"}
|
||||
count={row.original.policies_count}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "routes_count",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<DataTableHeader
|
||||
column={column}
|
||||
tooltip={<div className={"text-sm normal-case"}>Network Routes</div>}
|
||||
>
|
||||
<NetworkRoutesIcon size={12} />
|
||||
</DataTableHeader>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => (
|
||||
<GroupsCountCell
|
||||
icon={<NetworkRoutesIcon size={10} />}
|
||||
groupName={row.original.name}
|
||||
text={"Network Route(s)"}
|
||||
count={row.original.routes_count}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "resources_count",
|
||||
header: ({ column }) => {
|
||||
@@ -154,7 +117,7 @@ export const GroupsTableColumns: ColumnDef<GroupUsage>[] = [
|
||||
<DataTableHeader
|
||||
column={column}
|
||||
tooltip={
|
||||
<div className={"text-sm normal-case"}>Network Resources</div>
|
||||
<div className={"text-xs normal-case"}>Network Resources</div>
|
||||
}
|
||||
>
|
||||
<Layers3Icon size={12} />
|
||||
@@ -165,29 +128,77 @@ export const GroupsTableColumns: ColumnDef<GroupUsage>[] = [
|
||||
<GroupsCountCell
|
||||
icon={<Layers3Icon size={10} />}
|
||||
groupName={row.original.name}
|
||||
href={`/group?id=${row.original.id}&tab=resources`}
|
||||
text={"Network Resource(s)"}
|
||||
count={row.original.resources_count}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "users_count",
|
||||
accessorKey: "routes_count",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<DataTableHeader
|
||||
column={column}
|
||||
tooltip={<div className={"text-sm normal-case"}>Users</div>}
|
||||
tooltip={<div className={"text-xs normal-case"}>Network Routes</div>}
|
||||
>
|
||||
<TeamIcon size={12} />
|
||||
<NetworkRoutesIcon size={12} />
|
||||
</DataTableHeader>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => (
|
||||
<GroupsCountCell
|
||||
icon={<TeamIcon size={10} />}
|
||||
icon={<NetworkRoutesIcon size={10} />}
|
||||
groupName={row.original.name}
|
||||
text={"User(s)"}
|
||||
count={row.original.users_count}
|
||||
href={`/group?id=${row.original.id}&tab=network-routes`}
|
||||
text={"Network Route(s)"}
|
||||
count={row.original.routes_count}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "nameservers_count",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<DataTableHeader
|
||||
column={column}
|
||||
tooltip={<div className={"text-xs normal-case"}>Nameservers</div>}
|
||||
>
|
||||
<DNSIcon size={12} />
|
||||
</DataTableHeader>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => (
|
||||
<GroupsCountCell
|
||||
icon={<DNSIcon size={10} />}
|
||||
groupName={row.original.name}
|
||||
href={`/group?id=${row.original.id}&tab=nameservers`}
|
||||
text={"Nameserver(s)"}
|
||||
count={row.original.nameservers_count}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "setup_keys_count",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<DataTableHeader
|
||||
column={column}
|
||||
center={true}
|
||||
tooltip={<div className={"text-xs normal-case"}>Setup Keys</div>}
|
||||
>
|
||||
<SetupKeysIcon size={12} />
|
||||
</DataTableHeader>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => (
|
||||
<GroupsCountCell
|
||||
icon={<SetupKeysIcon size={10} />}
|
||||
groupName={row.original.name}
|
||||
href={`/group?id=${row.original.id}&tab=setup-keys`}
|
||||
hidden={row.original.name === "All"}
|
||||
text={"Setup Key(s)"}
|
||||
count={row.original.setup_keys_count}
|
||||
/>
|
||||
),
|
||||
},
|
||||
@@ -213,9 +224,16 @@ export const GroupsTableColumns: ColumnDef<GroupUsage>[] = [
|
||||
accessorKey: "id",
|
||||
header: "",
|
||||
cell: ({ row }) => (
|
||||
<GroupsActionCell group={row.original} in_use={row.getValue("in_use")} />
|
||||
<GroupProvider group={row.original} isDetailPage={false}>
|
||||
<GroupsActionCell group={row.original} inUse={row.getValue("in_use")} />
|
||||
</GroupProvider>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "search",
|
||||
accessorFn: (row) => removeAllSpaces(row.name),
|
||||
filterFn: "fuzzy",
|
||||
},
|
||||
];
|
||||
|
||||
type Props = {
|
||||
@@ -223,7 +241,7 @@ type Props = {
|
||||
};
|
||||
|
||||
export default function GroupsTable({ headingTarget }: Readonly<Props>) {
|
||||
const groups = useGroupsUsage();
|
||||
const { data: groups, isLoading } = useGroupsUsage();
|
||||
const path = usePathname();
|
||||
|
||||
// Default sorting state of the table
|
||||
@@ -231,88 +249,74 @@ export default function GroupsTable({ headingTarget }: Readonly<Props>) {
|
||||
"netbird-table-sort" + path,
|
||||
[
|
||||
{
|
||||
id: "name",
|
||||
id: "in_use",
|
||||
desc: true,
|
||||
},
|
||||
{
|
||||
id: "name",
|
||||
desc: false,
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{groups && groups.length > 0 ? (
|
||||
<DataTable
|
||||
headingTarget={headingTarget}
|
||||
text={"Groups"}
|
||||
inset={false}
|
||||
sorting={sorting}
|
||||
setSorting={setSorting}
|
||||
columns={GroupsTableColumns}
|
||||
data={groups}
|
||||
searchPlaceholder={"Search group..."}
|
||||
columnVisibility={{
|
||||
in_use: false,
|
||||
}}
|
||||
>
|
||||
{(table) => (
|
||||
<>
|
||||
<ButtonGroup disabled={groups?.length == 0}>
|
||||
<ButtonGroup.Button
|
||||
onClick={() =>
|
||||
table.getColumn("in_use")?.setFilterValue(undefined)
|
||||
}
|
||||
disabled={groups?.length == 0}
|
||||
variant={
|
||||
table.getColumn("in_use")?.getFilterValue() === undefined
|
||||
? "tertiary"
|
||||
: "secondary"
|
||||
}
|
||||
>
|
||||
All
|
||||
</ButtonGroup.Button>
|
||||
<ButtonGroup.Button
|
||||
onClick={() =>
|
||||
table.getColumn("in_use")?.setFilterValue(true)
|
||||
}
|
||||
disabled={groups?.length == 0}
|
||||
variant={
|
||||
table.getColumn("in_use")?.getFilterValue() === true
|
||||
? "tertiary"
|
||||
: "secondary"
|
||||
}
|
||||
>
|
||||
Used
|
||||
</ButtonGroup.Button>
|
||||
<ButtonGroup.Button
|
||||
disabled={groups?.length == 0}
|
||||
onClick={() =>
|
||||
table.getColumn("in_use")?.setFilterValue(false)
|
||||
}
|
||||
variant={
|
||||
table.getColumn("in_use")?.getFilterValue() === false
|
||||
? "tertiary"
|
||||
: "secondary"
|
||||
}
|
||||
>
|
||||
Unused
|
||||
</ButtonGroup.Button>
|
||||
</ButtonGroup>
|
||||
<DataTableRowsPerPage
|
||||
table={table}
|
||||
disabled={groups?.length == 0}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</DataTable>
|
||||
) : (
|
||||
<div className={"bg-nb-gray-950 overflow-hidden"}>
|
||||
<NoResults
|
||||
className={"py-3"}
|
||||
title={"No groups"}
|
||||
description={"You don't have any groups created yet."}
|
||||
icon={<FolderGit2Icon size={20} className={"fill-nb-gray-300"} />}
|
||||
/>
|
||||
</div>
|
||||
<DataTable
|
||||
headingTarget={headingTarget}
|
||||
text={"Groups"}
|
||||
sorting={sorting}
|
||||
isLoading={isLoading}
|
||||
setSorting={setSorting}
|
||||
columns={GroupsTableColumns}
|
||||
data={groups}
|
||||
searchPlaceholder={"Search group by name..."}
|
||||
rightSide={() => <AddGroupButton />}
|
||||
columnVisibility={{
|
||||
in_use: false,
|
||||
search: false,
|
||||
}}
|
||||
>
|
||||
{(table) => (
|
||||
<>
|
||||
<ButtonGroup disabled={groups?.length == 0}>
|
||||
<ButtonGroup.Button
|
||||
onClick={() =>
|
||||
table.getColumn("in_use")?.setFilterValue(undefined)
|
||||
}
|
||||
disabled={groups?.length == 0}
|
||||
variant={
|
||||
table.getColumn("in_use")?.getFilterValue() === undefined
|
||||
? "tertiary"
|
||||
: "secondary"
|
||||
}
|
||||
>
|
||||
All
|
||||
</ButtonGroup.Button>
|
||||
<ButtonGroup.Button
|
||||
onClick={() => table.getColumn("in_use")?.setFilterValue(true)}
|
||||
disabled={groups?.length == 0}
|
||||
variant={
|
||||
table.getColumn("in_use")?.getFilterValue() === true
|
||||
? "tertiary"
|
||||
: "secondary"
|
||||
}
|
||||
>
|
||||
Used
|
||||
</ButtonGroup.Button>
|
||||
<ButtonGroup.Button
|
||||
disabled={groups?.length == 0}
|
||||
onClick={() => table.getColumn("in_use")?.setFilterValue(false)}
|
||||
variant={
|
||||
table.getColumn("in_use")?.getFilterValue() === false
|
||||
? "tertiary"
|
||||
: "secondary"
|
||||
}
|
||||
>
|
||||
Unused
|
||||
</ButtonGroup.Button>
|
||||
</ButtonGroup>
|
||||
<DataTableRowsPerPage table={table} disabled={groups?.length == 0} />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
</DataTable>
|
||||
);
|
||||
}
|
||||
@@ -72,6 +72,8 @@ export default function useGroupHelper({ initial = [], peer }: Props) {
|
||||
};
|
||||
|
||||
const removePeerFromGroup = async (g: Group) => {
|
||||
if (g.name === "All") return Promise.resolve(g);
|
||||
|
||||
const newPeerGroups = g.peers?.filter((p) => {
|
||||
const groupPeer = p as GroupPeer;
|
||||
return groupPeer.id !== peer?.id;
|
||||
|
||||
@@ -14,11 +14,14 @@ export const useGroupIdentification = ({ id, issued }: Props) => {
|
||||
const isRegularGroup =
|
||||
!isJWTGroup && !isOktaGroup && !isGoogleGroup && !isAzureGroup;
|
||||
|
||||
const isIntegrationGroup = isOktaGroup || isGoogleGroup || isAzureGroup;
|
||||
|
||||
return {
|
||||
isOktaGroup,
|
||||
isGoogleGroup,
|
||||
isAzureGroup,
|
||||
isJWTGroup,
|
||||
isRegularGroup,
|
||||
isIntegrationGroup,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,16 +1,13 @@
|
||||
import useFetchApi from "@utils/api";
|
||||
import { useMemo } from "react";
|
||||
import { Group, GroupIssued } from "@/interfaces/Group";
|
||||
import { Group } from "@/interfaces/Group";
|
||||
import { NameserverGroup } from "@/interfaces/Nameserver";
|
||||
import { Policy } from "@/interfaces/Policy";
|
||||
import { Route } from "@/interfaces/Route";
|
||||
import { SetupKey } from "@/interfaces/SetupKey";
|
||||
import { User } from "@/interfaces/User";
|
||||
|
||||
export interface GroupUsage {
|
||||
id: string;
|
||||
name: string;
|
||||
issued: GroupIssued;
|
||||
export interface GroupUsage extends Group {
|
||||
peers_count: number;
|
||||
policies_count: number;
|
||||
nameservers_count: number;
|
||||
@@ -22,7 +19,7 @@ export interface GroupUsage {
|
||||
|
||||
export default function useGroupsUsage() {
|
||||
const { data: groups, isLoading: isGroupsLoading } =
|
||||
useFetchApi<Group[]>(`/groups`); // Groups , Peers count
|
||||
useFetchApi<Group[]>(`/groups`); // Groups, Peers count
|
||||
const { data: policies, isLoading: isPoliciesLoading } =
|
||||
useFetchApi<Policy[]>(`/policies`); // Policies
|
||||
const { data: nameservers, isLoading: isNameserversLoading } =
|
||||
@@ -60,12 +57,6 @@ export default function useGroupsUsage() {
|
||||
.filter((u) => u !== undefined);
|
||||
}, [nameservers, isNameserversLoading]);
|
||||
|
||||
const routesGroups = useMemo(() => {
|
||||
if (isRoutesLoading) return;
|
||||
if (!routes) return [];
|
||||
return routes?.map((route) => route.groups).filter((u) => u !== undefined);
|
||||
}, [routes, isRoutesLoading]);
|
||||
|
||||
const setupKeysGroups = useMemo(() => {
|
||||
if (isSetupKeysLoading) return;
|
||||
if (!setupKeys) return [];
|
||||
@@ -100,8 +91,9 @@ export default function useGroupsUsage() {
|
||||
isUsersLoading,
|
||||
]);
|
||||
|
||||
return useMemo(() => {
|
||||
const groupsUsage = useMemo(() => {
|
||||
if (isLoading) return [];
|
||||
if (isRoutesLoading) return [];
|
||||
if (!groups) return [];
|
||||
return groups?.map((group) => {
|
||||
const policyCount = policiesGroups?.filter((policy) => {
|
||||
@@ -112,9 +104,20 @@ export default function useGroupsUsage() {
|
||||
return nameserver.includes(group.id as string);
|
||||
}).length;
|
||||
|
||||
const routeCount = routesGroups?.filter((route) => {
|
||||
return route.includes(group.id as string);
|
||||
}).length;
|
||||
const routeCount = (
|
||||
routes?.filter((route) => {
|
||||
const groupId = group.id as string;
|
||||
const isInDistributionGroups =
|
||||
route.groups?.includes(groupId) ?? false;
|
||||
const isInAccessControlGroups =
|
||||
route.access_control_groups?.includes(groupId) ?? false;
|
||||
const isInPeerGroups = route.peer_groups?.includes(groupId) ?? false;
|
||||
|
||||
return (
|
||||
isInAccessControlGroups || isInDistributionGroups || isInPeerGroups
|
||||
);
|
||||
}) || []
|
||||
).length;
|
||||
|
||||
const setupKeyCount = setupKeysGroups?.filter((setupKey) => {
|
||||
return setupKey.includes(group.id as string);
|
||||
@@ -125,9 +128,7 @@ export default function useGroupsUsage() {
|
||||
}).length;
|
||||
|
||||
return {
|
||||
id: group.id,
|
||||
issued: group.issued,
|
||||
name: group.name,
|
||||
...group,
|
||||
peers_count: group.peers_count,
|
||||
resources_count: group.resources_count,
|
||||
policies_count: policyCount,
|
||||
@@ -142,8 +143,14 @@ export default function useGroupsUsage() {
|
||||
groups,
|
||||
policiesGroups,
|
||||
nameserversGroups,
|
||||
routesGroups,
|
||||
routes,
|
||||
isRoutesLoading,
|
||||
setupKeysGroups,
|
||||
usersGroups,
|
||||
]);
|
||||
|
||||
return {
|
||||
data: groupsUsage,
|
||||
isLoading,
|
||||
};
|
||||
}
|
||||
@@ -16,6 +16,8 @@ import NetworkRoutingPeerModal from "@/modules/networks/routing-peers/NetworkRou
|
||||
type Props = {
|
||||
children: React.ReactNode;
|
||||
network?: Network;
|
||||
onResourceUpdate?: () => void;
|
||||
onResourceDelete?: () => void;
|
||||
};
|
||||
|
||||
const NetworksContext = React.createContext(
|
||||
@@ -36,7 +38,12 @@ const NetworksContext = React.createContext(
|
||||
},
|
||||
);
|
||||
|
||||
export const NetworkProvider = ({ children, network }: Props) => {
|
||||
export const NetworkProvider = ({
|
||||
children,
|
||||
network,
|
||||
onResourceDelete,
|
||||
onResourceUpdate,
|
||||
}: Props) => {
|
||||
const { mutate } = useSWRConfig();
|
||||
const { confirm } = useDialog();
|
||||
const deleteCall = useApiCall("/networks").del;
|
||||
@@ -160,6 +167,7 @@ export const NetworkProvider = ({ children, network }: Props) => {
|
||||
loadingMessage: "Deleting resource...",
|
||||
promise: deleteCall({}, `/${network.id}/resources/${resource.id}`).then(
|
||||
() => {
|
||||
onResourceDelete?.();
|
||||
mutate(`/networks/${network.id}/resources`);
|
||||
mutate("/groups");
|
||||
},
|
||||
@@ -276,6 +284,7 @@ export const NetworkProvider = ({ children, network }: Props) => {
|
||||
setPolicyDefaultSettings(undefined);
|
||||
mutate("/networks");
|
||||
if (network) {
|
||||
onResourceUpdate?.();
|
||||
mutate(`/networks/${network.id}/resources`);
|
||||
mutate(`/networks/${network.id}`);
|
||||
} else {
|
||||
@@ -329,6 +338,7 @@ export const NetworkProvider = ({ children, network }: Props) => {
|
||||
setCurrentResource(undefined);
|
||||
mutate("/groups");
|
||||
if (network) {
|
||||
onResourceUpdate?.();
|
||||
mutate(`/networks/${network.id}/resources`);
|
||||
mutate(`/networks/${network.id}`);
|
||||
}
|
||||
@@ -356,6 +366,7 @@ export const NetworkProvider = ({ children, network }: Props) => {
|
||||
mutate("/networks");
|
||||
mutate("/groups");
|
||||
if (network) {
|
||||
onResourceUpdate?.();
|
||||
mutate(`/networks/${network.id}/resources`);
|
||||
mutate(`/networks/${network.id}`);
|
||||
}
|
||||
|
||||
@@ -62,7 +62,7 @@ export const ResourceActionCell = ({ resource }: Props) => {
|
||||
>
|
||||
<div className={"flex gap-3 items-center"}>
|
||||
<Trash2 size={14} className={"shrink-0"} />
|
||||
Remove
|
||||
Delete
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
|
||||
@@ -11,8 +11,12 @@ import { useNetworksContext } from "@/modules/networks/NetworkProvider";
|
||||
|
||||
type Props = {
|
||||
resource: NetworkResource;
|
||||
mutateAllResourcesOnUpdate?: boolean;
|
||||
};
|
||||
export const ResourceEnabledCell = ({ resource }: Props) => {
|
||||
export const ResourceEnabledCell = ({
|
||||
resource,
|
||||
mutateAllResourcesOnUpdate,
|
||||
}: Props) => {
|
||||
const { permission } = usePermissions();
|
||||
|
||||
const { mutate } = useSWRConfig();
|
||||
@@ -40,6 +44,7 @@ export const ResourceEnabledCell = ({ resource }: Props) => {
|
||||
.filter((g) => g !== undefined),
|
||||
enabled,
|
||||
}).then(() => {
|
||||
mutateAllResourcesOnUpdate && mutate("/networks/resources");
|
||||
mutate(`/networks/${network?.id}/resources`);
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -7,8 +7,8 @@ import NoResults from "@components/ui/NoResults";
|
||||
import { IconCirclePlus } from "@tabler/icons-react";
|
||||
import { ColumnDef, SortingState } from "@tanstack/react-table";
|
||||
import { removeAllSpaces } from "@utils/helpers";
|
||||
import { Layers3Icon } from "lucide-react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { ArrowUpRightIcon, Layers3Icon } from "lucide-react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import * as React from "react";
|
||||
import { useState } from "react";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
@@ -26,6 +26,7 @@ type Props = {
|
||||
resources?: NetworkResource[];
|
||||
isLoading: boolean;
|
||||
headingTarget?: HTMLHeadingElement | null;
|
||||
isGroupPage?: boolean;
|
||||
};
|
||||
|
||||
const NetworkResourceColumns: ColumnDef<NetworkResource>[] = [
|
||||
@@ -105,6 +106,7 @@ export default function ResourcesTable({
|
||||
resources,
|
||||
isLoading,
|
||||
headingTarget,
|
||||
isGroupPage,
|
||||
}: Readonly<Props>) {
|
||||
const { permission } = usePermissions();
|
||||
const params = useSearchParams();
|
||||
@@ -112,6 +114,7 @@ export default function ResourcesTable({
|
||||
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
const { openResourceModal, network } = useNetworksContext();
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<DataTable
|
||||
@@ -137,29 +140,52 @@ export default function ResourcesTable({
|
||||
getStartedCard={
|
||||
<NoResults
|
||||
className={"py-4"}
|
||||
title={"This network has no resources"}
|
||||
title={
|
||||
isGroupPage
|
||||
? "This group has no assigned resources"
|
||||
: "This network has no resources"
|
||||
}
|
||||
description={
|
||||
"Add resources to this network to control what peers can access. Resources can be anything from a single IP, a subnet, or a domain."
|
||||
isGroupPage
|
||||
? "Assign this group to your resources inside your networks to see them listed here."
|
||||
: "Add resources to this network to control what peers can access. Resources can be anything from a single IP, a subnet, or a domain."
|
||||
}
|
||||
icon={<Layers3Icon size={20} />}
|
||||
/>
|
||||
>
|
||||
{isGroupPage && permission?.networks?.create && (
|
||||
<>
|
||||
<Button
|
||||
variant={"primary"}
|
||||
className={"mt-4"}
|
||||
onClick={() => router.push("/networks")}
|
||||
>
|
||||
Go to Networks
|
||||
<ArrowUpRightIcon size={16} />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</NoResults>
|
||||
}
|
||||
columnVisibility={{
|
||||
description: false,
|
||||
id: false,
|
||||
}}
|
||||
paginationPaddingClassName={"px-0 pt-8"}
|
||||
rightSide={() => (
|
||||
<Button
|
||||
variant={"primary"}
|
||||
className={"ml-auto"}
|
||||
onClick={() => network && openResourceModal(network)}
|
||||
disabled={!permission.networks.update}
|
||||
>
|
||||
<IconCirclePlus size={16} />
|
||||
Add Resource
|
||||
</Button>
|
||||
)}
|
||||
rightSide={
|
||||
!isGroupPage
|
||||
? () => (
|
||||
<Button
|
||||
variant={"primary"}
|
||||
className={"ml-auto"}
|
||||
onClick={() => network && openResourceModal(network)}
|
||||
disabled={!permission.networks.update}
|
||||
>
|
||||
<IconCirclePlus size={16} />
|
||||
Add Resource
|
||||
</Button>
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{(table) => (
|
||||
<DataTableRowsPerPage
|
||||
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -10,7 +10,7 @@ import { useUsers } from "@/contexts/UsersProvider";
|
||||
import type { Peer } from "@/interfaces/Peer";
|
||||
|
||||
const AccessiblePeersTable = lazy(
|
||||
() => import("@/modules/peer/AccessiblePeersTable"),
|
||||
() => import("@/modules/peer/MinimalPeersTable"),
|
||||
);
|
||||
|
||||
type Props = {
|
||||
|
||||
@@ -5,11 +5,18 @@ import DataTableHeader from "@components/table/DataTableHeader";
|
||||
import DataTableRefreshButton from "@components/table/DataTableRefreshButton";
|
||||
import { DataTableRowsPerPage } from "@components/table/DataTableRowsPerPage";
|
||||
import NoResults from "@components/ui/NoResults";
|
||||
import { ColumnDef, SortingState } from "@tanstack/react-table";
|
||||
import {
|
||||
ColumnDef,
|
||||
Row,
|
||||
RowSelectionState,
|
||||
SortingState,
|
||||
Table,
|
||||
} from "@tanstack/react-table";
|
||||
import * as React from "react";
|
||||
import { useState } from "react";
|
||||
import { useSWRConfig } from "swr";
|
||||
import PeerIcon from "@/assets/icons/PeerIcon";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import { Peer } from "@/interfaces/Peer";
|
||||
import PeerAddressCell from "@/modules/peers/PeerAddressCell";
|
||||
import PeerLastSeenCell from "@/modules/peers/PeerLastSeenCell";
|
||||
@@ -18,12 +25,18 @@ import { PeerOSCell } from "@/modules/peers/PeerOSCell";
|
||||
|
||||
type Props = {
|
||||
peers?: Peer[];
|
||||
peerID: string;
|
||||
peerID?: string;
|
||||
isLoading: boolean;
|
||||
headingTarget?: HTMLHeadingElement | null;
|
||||
rightSide?: (table: Table<Peer>) => React.ReactNode;
|
||||
getStartedCard?: React.ReactNode;
|
||||
columns?: ColumnDef<Peer>[];
|
||||
selectedRows?: RowSelectionState;
|
||||
setSelectedRows?: (updater: React.SetStateAction<RowSelectionState>) => void;
|
||||
onRowClick?: (row: Row<Peer>) => void;
|
||||
};
|
||||
|
||||
const AccessiblePeersColumns: ColumnDef<Peer>[] = [
|
||||
const MinimalPeersTableColumns: ColumnDef<Peer>[] = [
|
||||
{
|
||||
accessorKey: "name",
|
||||
header: ({ column }) => {
|
||||
@@ -73,13 +86,21 @@ const AccessiblePeersColumns: ColumnDef<Peer>[] = [
|
||||
},
|
||||
];
|
||||
|
||||
export default function AccessiblePeersTable({
|
||||
export default function MinimalPeersTable({
|
||||
peers,
|
||||
isLoading,
|
||||
headingTarget,
|
||||
peerID,
|
||||
rightSide,
|
||||
columns = MinimalPeersTableColumns,
|
||||
selectedRows,
|
||||
setSelectedRows,
|
||||
onRowClick,
|
||||
getStartedCard,
|
||||
}: Props) {
|
||||
const { mutate } = useSWRConfig();
|
||||
const { permission } = usePermissions();
|
||||
|
||||
// Default sorting state of the table
|
||||
const [sorting, setSorting] = useState<SortingState>([
|
||||
{
|
||||
@@ -104,27 +125,36 @@ export default function AccessiblePeersTable({
|
||||
useRowId={true}
|
||||
sorting={sorting}
|
||||
setSorting={setSorting}
|
||||
rowSelection={selectedRows}
|
||||
setRowSelection={setSelectedRows}
|
||||
onRowClick={onRowClick}
|
||||
minimal={true}
|
||||
showSearchAndFilters={true}
|
||||
inset={false}
|
||||
tableClassName={"mt-0"}
|
||||
text={"Peers"}
|
||||
columns={AccessiblePeersColumns}
|
||||
columns={columns}
|
||||
keepStateInLocalStorage={false}
|
||||
data={peers}
|
||||
searchPlaceholder={"Search by name, IP, owner or group..."}
|
||||
isLoading={isLoading}
|
||||
getStartedCard={
|
||||
<NoResults
|
||||
className={"py-4"}
|
||||
title={"This peer has no accessible peers"}
|
||||
description={
|
||||
"Add more peers to your network or check your access control policies."
|
||||
}
|
||||
icon={<PeerIcon size={20} className={"fill-nb-gray-300"} />}
|
||||
/>
|
||||
!getStartedCard ? (
|
||||
<NoResults
|
||||
className={"py-4"}
|
||||
title={"This peer has no accessible peers"}
|
||||
description={
|
||||
"Add more peers to your network or check your access control policies."
|
||||
}
|
||||
icon={<PeerIcon size={20} className={"fill-nb-gray-300"} />}
|
||||
/>
|
||||
) : (
|
||||
getStartedCard
|
||||
)
|
||||
}
|
||||
rightSide={rightSide}
|
||||
columnVisibility={{
|
||||
select: permission?.groups?.update && permission?.peers?.update,
|
||||
connected: false,
|
||||
ip: false,
|
||||
user_name: false,
|
||||
@@ -200,7 +230,11 @@ export default function AccessiblePeersTable({
|
||||
isDisabled={peers?.length == 0}
|
||||
onClick={() => {
|
||||
mutate("/users").then();
|
||||
mutate(`/peers/${peerID}/accessible-peers`).then();
|
||||
if (peerID) {
|
||||
mutate(`/peers/${peerID}/accessible-peers`).then();
|
||||
return;
|
||||
}
|
||||
mutate(`/peers`).then();
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
@@ -9,26 +9,37 @@ import {
|
||||
} from "@components/modal/Modal";
|
||||
import ModalHeader from "@components/modal/ModalHeader";
|
||||
import Paragraph from "@components/Paragraph";
|
||||
import { SegmentedTabs } from "@components/SegmentedTabs";
|
||||
import Separator from "@components/Separator";
|
||||
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 NetBirdIcon from "@/assets/icons/NetBirdIcon";
|
||||
import sshImage from "@/assets/ssh/ssh-client.png";
|
||||
import { Peer } from "@/interfaces/Peer";
|
||||
import { PeerSSHPolicyModal } from "@/modules/peer/PeerSSHPolicyModal";
|
||||
import { Terminal } from "@/modules/remote-access/ssh/Terminal";
|
||||
|
||||
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 +50,70 @@ 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. NetBird's embedded SSH server is running on port 44338."
|
||||
"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`}</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.60.0, SSH requires an explicit access
|
||||
control policy that allows <Mark>TCP</Mark> traffic on port{" "}
|
||||
<Mark>22</Mark>
|
||||
</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 +141,17 @@ export const PeerSSHInstructions = ({
|
||||
<Button variant={"secondary"}>Cancel</Button>
|
||||
</ModalClose>
|
||||
|
||||
<Button
|
||||
variant={"primary"}
|
||||
onClick={onSuccess}
|
||||
data-cy={"create-setup-key"}
|
||||
>
|
||||
<Button variant={"primary"} onClick={onSuccess}>
|
||||
Confirm & Enable
|
||||
</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.60.0, SSH requires an explicit access
|
||||
control policy that allows TCP traffic on port 22.{" "}
|
||||
<InlineButtonLink onClick={() => setPolicyModal(true)}>
|
||||
Create SSH Policy
|
||||
</InlineButtonLink>
|
||||
</span>
|
||||
</Callout>
|
||||
<PeerSSHPolicyModal
|
||||
open={policyModal}
|
||||
onOpenChange={setPolicyModal}
|
||||
peer={peer}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
);
|
||||
};
|
||||
35
src/modules/peer/PeerSSHPolicyModal.tsx
Normal file
35
src/modules/peer/PeerSSHPolicyModal.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
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"}
|
||||
initialPorts={[22]}
|
||||
initialProtocol={"tcp"}
|
||||
initialName={"SSH Access"}
|
||||
initialDestinationResource={
|
||||
peer
|
||||
? ({
|
||||
id: peer.id,
|
||||
type: "peer",
|
||||
} as PolicyRuleResource)
|
||||
: undefined
|
||||
}
|
||||
onSuccess={async (p) => {
|
||||
onOpenChange(false);
|
||||
}}
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -4,6 +4,7 @@ import { LockIcon, TerminalSquare } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { usePeer } from "@/contexts/PeerProvider";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import { PeerSSHPolicyInfo } from "@/modules/peer/PeerSSHPolicyInfo";
|
||||
|
||||
export const PeerSSHToggle = () => {
|
||||
const { permission } = usePermissions();
|
||||
@@ -42,6 +43,7 @@ export const PeerSSHToggle = () => {
|
||||
}
|
||||
/>
|
||||
</FullTooltip>
|
||||
<PeerSSHPolicyInfo peer={peer} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
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,
|
||||
};
|
||||
};
|
||||
@@ -36,7 +36,12 @@ export default function PeerNameCell({ peer, linkToPeer = true }: Props) {
|
||||
)}
|
||||
data-testid="peer-name-cell"
|
||||
aria-label={`View details of peer ${peer.name}`}
|
||||
onClick={() => linkToPeer && router.push("/peer?id=" + peer.id)}
|
||||
onClick={(e) => {
|
||||
if (!linkToPeer) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
router.push("/peer?id=" + peer.id);
|
||||
}}
|
||||
>
|
||||
<ActiveInactiveRow
|
||||
active={peer.connected}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import FullTooltip from "@components/FullTooltip";
|
||||
import InlineLink from "@components/InlineLink";
|
||||
import {
|
||||
Tooltip,
|
||||
@@ -8,13 +7,14 @@ import {
|
||||
} from "@components/Tooltip";
|
||||
import MemoizedNetBirdIcon from "@components/ui/MemoizedNetBirdIcon";
|
||||
import { getOperatingSystem } from "@hooks/useOperatingSystem";
|
||||
import { parseVersionString } from "@utils/version";
|
||||
import { compareVersions } from "@utils/version";
|
||||
import { ArrowRightIcon, ArrowUpCircleIcon } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { useMemo } from "react";
|
||||
import { useApplicationContext } from "@/contexts/ApplicationProvider";
|
||||
import { OperatingSystem } from "@/interfaces/OperatingSystem";
|
||||
import { PeerOperatingSystemIcon } from "@/modules/peers/PeerOperatingSystemIcon";
|
||||
import FullTooltip from "@components/FullTooltip";
|
||||
|
||||
type Props = {
|
||||
version: string;
|
||||
@@ -31,7 +31,8 @@ export default function PeerVersionCell({ version, os, serial }: Props) {
|
||||
operatingSystem === OperatingSystem.ANDROID
|
||||
)
|
||||
return false;
|
||||
return parseVersionString(version) < parseVersionString(latestVersion);
|
||||
if (!latestVersion) return false;
|
||||
return !compareVersions(version, latestVersion);
|
||||
}, [os, version, latestVersion]);
|
||||
|
||||
const updateIcon = useMemo(() => {
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import * as React from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
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 {
|
||||
Modal,
|
||||
ModalClose,
|
||||
@@ -7,23 +10,20 @@ import {
|
||||
ModalFooter,
|
||||
} from "@components/modal/Modal";
|
||||
import ModalHeader from "@components/modal/ModalHeader";
|
||||
import { Peer } from "@/interfaces/Peer";
|
||||
import Paragraph from "@components/Paragraph";
|
||||
import Separator from "@components/Separator";
|
||||
import { getOperatingSystem } from "@hooks/useOperatingSystem";
|
||||
import { isNativeSSHSupported } from "@utils/version";
|
||||
import {
|
||||
ChevronsLeftRightEllipsis,
|
||||
ExternalLinkIcon,
|
||||
TerminalIcon,
|
||||
User2,
|
||||
} from "lucide-react";
|
||||
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 { getOperatingSystem } from "@hooks/useOperatingSystem";
|
||||
import * as React from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { OperatingSystem } from "@/interfaces/OperatingSystem";
|
||||
import { isNativeSSHSupported } from "@utils/version";
|
||||
import { Peer } from "@/interfaces/Peer";
|
||||
import { SSH_DOCS_LINK } from "@/modules/remote-access/ssh/useSSH";
|
||||
|
||||
type Props = {
|
||||
@@ -39,9 +39,8 @@ export const SSHCredentialsModal = ({ open, onOpenChange, peer }: Props) => {
|
||||
: "root",
|
||||
);
|
||||
|
||||
const [port, setPort] = useState(
|
||||
isNativeSSHSupported(peer.version) ? "22" : "44338",
|
||||
);
|
||||
const initialPort = isNativeSSHSupported(peer.version) ? "22" : "44338";
|
||||
const [port, setPort] = useState(initialPort);
|
||||
|
||||
const userNameError = useMemo(() => {
|
||||
if (username?.length === 0) return "Username cannot be empty";
|
||||
@@ -105,7 +104,7 @@ export const SSHCredentialsModal = ({ open, onOpenChange, peer }: Props) => {
|
||||
/>
|
||||
<Input
|
||||
maxWidthClass={""}
|
||||
placeholder={"22"}
|
||||
placeholder={initialPort}
|
||||
min={1}
|
||||
max={65535}
|
||||
value={port}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user