mirror of
https://github.com/netbirdio/dashboard.git
synced 2026-01-26 01:21:04 +00:00
Feature/groups page (#498)
* move our group membership from the settings menu, into the Team menu * add action to the table and new group page * update group page and return group settings to settings menu * new update * fix bug * group action: add peer to group * group action: add user to group * Update wording, redirect to group page after creation * Add better table loading skeleton * Adjust group name cell * Update wording * Update sort order * Refactor * Merge main * Fix button height * Fix resources table * Adjust table loading skeleton * Adjust table loading skeleton * Add loading to tab triggers * Update meta * Update group location * Fix rename * Refactor group details * Fix linked peers * Fix group usage * Fix incrementing peer count * Prevent renaming to already existing group * Fix group name click * Update group nav * Make group table cells clickable * Fix breadcrumbs * Update wording * Add confirmation before removing users from group * Add permissions * Add initial group for network routes * Add acl and routing peer groups --------- Co-authored-by: aliamerj <aliamer19ali@gmail.com>
This commit is contained in:
@@ -33,7 +33,7 @@ export default function AccessControlPage() {
|
|||||||
<div className={"p-default py-6"}>
|
<div className={"p-default py-6"}>
|
||||||
<Breadcrumbs>
|
<Breadcrumbs>
|
||||||
<Breadcrumbs.Item
|
<Breadcrumbs.Item
|
||||||
href={"/policies"}
|
href={"/access-control"}
|
||||||
label={"Access Control"}
|
label={"Access Control"}
|
||||||
icon={<AccessControlIcon size={14} />}
|
icon={<AccessControlIcon size={14} />}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ export default function NameServers() {
|
|||||||
<div className={"p-default py-6"}>
|
<div className={"p-default py-6"}>
|
||||||
<Breadcrumbs>
|
<Breadcrumbs>
|
||||||
<Breadcrumbs.Item
|
<Breadcrumbs.Item
|
||||||
href={"/dns"}
|
href={"/dns/nameservers"}
|
||||||
label={"DNS"}
|
label={"DNS"}
|
||||||
icon={<DNSIcon size={13} />}
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -19,9 +19,9 @@ import { useAccount } from "@/modules/account/useAccount";
|
|||||||
import AuthenticationTab from "@/modules/settings/AuthenticationTab";
|
import AuthenticationTab from "@/modules/settings/AuthenticationTab";
|
||||||
import ClientSettingsTab from "@/modules/settings/ClientSettingsTab";
|
import ClientSettingsTab from "@/modules/settings/ClientSettingsTab";
|
||||||
import DangerZoneTab from "@/modules/settings/DangerZoneTab";
|
import DangerZoneTab from "@/modules/settings/DangerZoneTab";
|
||||||
import GroupsTab from "@/modules/settings/GroupsTab";
|
|
||||||
import NetworkSettingsTab from "@/modules/settings/NetworkSettingsTab";
|
import NetworkSettingsTab from "@/modules/settings/NetworkSettingsTab";
|
||||||
import PermissionsTab from "@/modules/settings/PermissionsTab";
|
import PermissionsTab from "@/modules/settings/PermissionsTab";
|
||||||
|
import GroupsSettings from "@/modules/settings/GroupsSettings";
|
||||||
|
|
||||||
export default function NetBirdSettings() {
|
export default function NetBirdSettings() {
|
||||||
const queryParams = useSearchParams();
|
const queryParams = useSearchParams();
|
||||||
@@ -81,7 +81,7 @@ export default function NetBirdSettings() {
|
|||||||
<div className={"border-l border-nb-gray-930 w-full"}>
|
<div className={"border-l border-nb-gray-930 w-full"}>
|
||||||
{account && <AuthenticationTab account={account} />}
|
{account && <AuthenticationTab account={account} />}
|
||||||
{account && <PermissionsTab account={account} />}
|
{account && <PermissionsTab account={account} />}
|
||||||
{account && <GroupsTab account={account} />}
|
{account && <GroupsSettings account={account} />}
|
||||||
{account && <NetworkSettingsTab account={account} />}
|
{account && <NetworkSettingsTab account={account} />}
|
||||||
{account && <ClientSettingsTab account={account} />}
|
{account && <ClientSettingsTab account={account} />}
|
||||||
{account && <DangerZoneTab account={account} />}
|
{account && <DangerZoneTab account={account} />}
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ const ButtonGroupButton = forwardRef(
|
|||||||
border={2}
|
border={2}
|
||||||
rounded={false}
|
rounded={false}
|
||||||
className={cn(
|
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",
|
"!py-2.5 !px-4",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -171,7 +171,15 @@ export function PeerGroupSelector({
|
|||||||
const groupResources: GroupResource[] | undefined =
|
const groupResources: GroupResource[] | undefined =
|
||||||
(group?.resources as GroupResource[]) || [];
|
(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) {
|
if (!group && !option) {
|
||||||
addDropdownOptions([
|
addDropdownOptions([
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export default function SkeletonTable({ withHeader = true }: Readonly<Props>) {
|
|||||||
return (
|
return (
|
||||||
<div className={"w-full"}>
|
<div className={"w-full"}>
|
||||||
{withHeader && <SkeletonTableHeader />}
|
{withHeader && <SkeletonTableHeader />}
|
||||||
<div className={"mt-6"}>
|
<div className={"mt-6 relative -top-1"}>
|
||||||
<TableSkeletonRow />
|
<TableSkeletonRow />
|
||||||
<TableSkeletonRow odd />
|
<TableSkeletonRow odd />
|
||||||
<TableSkeletonRow />
|
<TableSkeletonRow />
|
||||||
@@ -68,7 +68,7 @@ export const SkeletonTableHeader = ({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
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,
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -133,6 +133,7 @@ interface DataTableProps<TData, TValue> {
|
|||||||
getStartedCard?: React.ReactNode;
|
getStartedCard?: React.ReactNode;
|
||||||
placeholders?: TData[];
|
placeholders?: TData[];
|
||||||
renderExpandedRow?: (row: TData) => React.ReactNode;
|
renderExpandedRow?: (row: TData) => React.ReactNode;
|
||||||
|
renderRow?: (row: TData, children: React.ReactNode) => React.ReactNode;
|
||||||
minimal?: boolean;
|
minimal?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
inset?: boolean;
|
inset?: boolean;
|
||||||
@@ -193,6 +194,7 @@ export function DataTable<TData, TValue>({
|
|||||||
onRowClick,
|
onRowClick,
|
||||||
getStartedCard,
|
getStartedCard,
|
||||||
renderExpandedRow,
|
renderExpandedRow,
|
||||||
|
renderRow,
|
||||||
minimal,
|
minimal,
|
||||||
className,
|
className,
|
||||||
tableClassName,
|
tableClassName,
|
||||||
@@ -507,7 +509,7 @@ export function DataTable<TData, TValue>({
|
|||||||
{table.getRowModel().rows?.length ? (
|
{table.getRowModel().rows?.length ? (
|
||||||
table.getRowModel().rows.map((row) => {
|
table.getRowModel().rows.map((row) => {
|
||||||
const expandedRow = renderExpandedRow?.(row.original);
|
const expandedRow = renderExpandedRow?.(row.original);
|
||||||
return (
|
const rowContent = (
|
||||||
<AccordionItem
|
<AccordionItem
|
||||||
value={row.original.id}
|
value={row.original.id}
|
||||||
asChild={true}
|
asChild={true}
|
||||||
@@ -597,6 +599,8 @@ export function DataTable<TData, TValue>({
|
|||||||
</>
|
</>
|
||||||
</AccordionItem>
|
</AccordionItem>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
return renderRow ? renderRow(row.original, rowContent) : rowContent;
|
||||||
})
|
})
|
||||||
) : (
|
) : (
|
||||||
<TableRowUnstyledComponent>
|
<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
|
<Button
|
||||||
className={"h-[44px]"}
|
className={"h-[42px]"}
|
||||||
variant={"secondary"}
|
variant={"secondary"}
|
||||||
disabled={isDisabled == true ? true : disabled}
|
disabled={isDisabled == true ? true : disabled}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ export default function DataTableResetFilterButton<TData>({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
className={"h-[44px]"}
|
className={"h-[42px]"}
|
||||||
variant={"secondary"}
|
variant={"secondary"}
|
||||||
onClick={onClick}
|
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 Card from "@components/Card";
|
||||||
import Paragraph from "@components/Paragraph";
|
import Paragraph from "@components/Paragraph";
|
||||||
|
import { cn } from "@utils/helpers";
|
||||||
import { FilterX } from "lucide-react";
|
import { FilterX } from "lucide-react";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import Skeleton from "react-loading-skeleton";
|
import Skeleton from "react-loading-skeleton";
|
||||||
@@ -9,15 +10,18 @@ type Props = {
|
|||||||
title?: string;
|
title?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function NoResultsCard({
|
export default function NoResultsCard({
|
||||||
icon,
|
icon,
|
||||||
title = "Could not find any results",
|
title = "Could not find any results",
|
||||||
description = "We couldn't find any results. Please try a different search term or change your filters.",
|
description = "We couldn't find any results. Please try a different search term or change your filters.",
|
||||||
children,
|
children,
|
||||||
|
className,
|
||||||
}: Readonly<Props>) {
|
}: Readonly<Props>) {
|
||||||
return (
|
return (
|
||||||
<div className={"px-8 mt-8"}>
|
<div className={cn("px-8 mt-8", className)}>
|
||||||
<Card className={"w-full relative overflow-hidden"}>
|
<Card className={"w-full relative overflow-hidden"}>
|
||||||
<div
|
<div
|
||||||
className={
|
className={
|
||||||
|
|||||||
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>;
|
createOrUpdate: (group: Group) => Promise<Group>;
|
||||||
reset: () => void;
|
reset: () => void;
|
||||||
updateGroupDropdown: (oldGroupName: string, newGroup: Group) => 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 (
|
return (
|
||||||
<GroupContext.Provider
|
<GroupContext.Provider
|
||||||
value={{
|
value={{
|
||||||
@@ -144,6 +152,7 @@ export function GroupsProviderContent({
|
|||||||
createOrUpdate,
|
createOrUpdate,
|
||||||
reset,
|
reset,
|
||||||
updateGroupDropdown,
|
updateGroupDropdown,
|
||||||
|
deleteGroupDropdownOption,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -26,3 +26,14 @@ export enum GroupIssued {
|
|||||||
INTEGRATION = "integration",
|
INTEGRATION = "integration",
|
||||||
JWT = "jwt",
|
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.",
|
||||||
|
};
|
||||||
|
|||||||
@@ -28,3 +28,7 @@ export interface NetworkResource {
|
|||||||
type?: "domain" | "host" | "subnet";
|
type?: "domain" | "host" | "subnet";
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface NetworkResourceWithNetwork extends NetworkResource {
|
||||||
|
network: Network;
|
||||||
|
}
|
||||||
|
|||||||
@@ -113,6 +113,12 @@ export default function Navigation({
|
|||||||
exactPathMatch={true}
|
exactPathMatch={true}
|
||||||
visible={permission.policies.read}
|
visible={permission.policies.read}
|
||||||
/>
|
/>
|
||||||
|
<SidebarItem
|
||||||
|
label="Groups"
|
||||||
|
isChild
|
||||||
|
href={"/groups"}
|
||||||
|
visible={permission.policies.read}
|
||||||
|
/>
|
||||||
<SidebarItem
|
<SidebarItem
|
||||||
label="Posture Checks"
|
label="Posture Checks"
|
||||||
isChild
|
isChild
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import Button from "@components/Button";
|
import Button from "@components/Button";
|
||||||
import ButtonGroup from "@components/ButtonGroup";
|
import ButtonGroup from "@components/ButtonGroup";
|
||||||
|
import Card from "@components/Card";
|
||||||
import FullTooltip from "@components/FullTooltip";
|
import FullTooltip from "@components/FullTooltip";
|
||||||
import InlineLink from "@components/InlineLink";
|
import InlineLink from "@components/InlineLink";
|
||||||
import SquareIcon from "@components/SquareIcon";
|
import SquareIcon from "@components/SquareIcon";
|
||||||
@@ -15,6 +16,7 @@ import { usePathname, useSearchParams } from "next/navigation";
|
|||||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { useSWRConfig } from "swr";
|
import { useSWRConfig } from "swr";
|
||||||
import AccessControlIcon from "@/assets/icons/AccessControlIcon";
|
import AccessControlIcon from "@/assets/icons/AccessControlIcon";
|
||||||
|
import NoResults from "@/components/ui/NoResults";
|
||||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||||
import { useLocalStorage } from "@/hooks/useLocalStorage";
|
import { useLocalStorage } from "@/hooks/useLocalStorage";
|
||||||
import type { Policy } from "@/interfaces/Policy";
|
import type { Policy } from "@/interfaces/Policy";
|
||||||
@@ -35,6 +37,7 @@ type Props = {
|
|||||||
policies?: Policy[];
|
policies?: Policy[];
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
headingTarget?: HTMLHeadingElement | null;
|
headingTarget?: HTMLHeadingElement | null;
|
||||||
|
isGroupPage?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const AccessControlTableColumns: ColumnDef<Policy>[] = [
|
export const AccessControlTableColumns: ColumnDef<Policy>[] = [
|
||||||
@@ -179,12 +182,13 @@ export default function AccessControlTable({
|
|||||||
policies,
|
policies,
|
||||||
isLoading,
|
isLoading,
|
||||||
headingTarget,
|
headingTarget,
|
||||||
|
isGroupPage,
|
||||||
}: Readonly<Props>) {
|
}: Readonly<Props>) {
|
||||||
const { mutate } = useSWRConfig();
|
const { mutate } = useSWRConfig();
|
||||||
const path = usePathname();
|
const path = usePathname();
|
||||||
const { permission } = usePermissions();
|
const { permission } = usePermissions();
|
||||||
const params = useSearchParams();
|
const params = useSearchParams();
|
||||||
const idParam = params.get("id") ?? undefined;
|
const idParam = !isGroupPage ? params.get("id") : undefined;
|
||||||
|
|
||||||
// Default sorting state of the table
|
// Default sorting state of the table
|
||||||
const [sorting, setSorting] = useLocalStorage<SortingState>(
|
const [sorting, setSorting] = useLocalStorage<SortingState>(
|
||||||
@@ -195,6 +199,7 @@ export default function AccessControlTable({
|
|||||||
desc: true,
|
desc: true,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
!isGroupPage,
|
||||||
);
|
);
|
||||||
|
|
||||||
const [editModal, setEditModal] = useState(false);
|
const [editModal, setEditModal] = useState(false);
|
||||||
@@ -249,7 +254,13 @@ export default function AccessControlTable({
|
|||||||
<DataTable
|
<DataTable
|
||||||
headingTarget={headingTarget}
|
headingTarget={headingTarget}
|
||||||
isLoading={isLoading}
|
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}
|
initialSearch={idParam ? "" : undefined}
|
||||||
initialFilters={
|
initialFilters={
|
||||||
idParam
|
idParam
|
||||||
@@ -278,25 +289,22 @@ export default function AccessControlTable({
|
|||||||
}}
|
}}
|
||||||
searchPlaceholder={"Search by name and description..."}
|
searchPlaceholder={"Search by name and description..."}
|
||||||
getStartedCard={
|
getStartedCard={
|
||||||
<GetStartedTest
|
isGroupPage ? (
|
||||||
icon={
|
<NoResults
|
||||||
<SquareIcon
|
className={"py-4"}
|
||||||
icon={
|
title={"This group is not used within any policies yet"}
|
||||||
<AccessControlIcon className={"fill-nb-gray-200"} size={20} />
|
description={
|
||||||
}
|
"Assign this group as either a source or destination inside a policy to see them listed here."
|
||||||
color={"gray"}
|
}
|
||||||
size={"large"}
|
icon={
|
||||||
/>
|
<AccessControlIcon size={20} className={"fill-nb-gray-300"} />
|
||||||
}
|
}
|
||||||
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"}>
|
<div className={"flex gap-4 items-center justify-center"}>
|
||||||
<AccessControlModal>
|
<AccessControlModal>
|
||||||
<Button
|
<Button
|
||||||
variant={"primary"}
|
variant={"primary"}
|
||||||
|
className={"mt-4"}
|
||||||
disabled={!permission.policies.create}
|
disabled={!permission.policies.create}
|
||||||
>
|
>
|
||||||
<PlusCircle size={16} />
|
<PlusCircle size={16} />
|
||||||
@@ -304,25 +312,59 @@ export default function AccessControlTable({
|
|||||||
</Button>
|
</Button>
|
||||||
</AccessControlModal>
|
</AccessControlModal>
|
||||||
</div>
|
</div>
|
||||||
}
|
</NoResults>
|
||||||
learnMore={
|
) : (
|
||||||
<>
|
<GetStartedTest
|
||||||
Learn more about
|
icon={
|
||||||
<InlineLink
|
<SquareIcon
|
||||||
href={"https://docs.netbird.io/how-to/manage-network-access"}
|
icon={
|
||||||
target={"_blank"}
|
<AccessControlIcon
|
||||||
>
|
className={"fill-nb-gray-200"}
|
||||||
Access Controls
|
size={20}
|
||||||
<ExternalLinkIcon size={12} />
|
/>
|
||||||
</InlineLink>
|
}
|
||||||
</>
|
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={() => (
|
rightSide={() => (
|
||||||
<>
|
<>
|
||||||
{policies && policies?.length > 0 && (
|
{policies && policies?.length > 0 && (
|
||||||
<div className={"flex ml-auto gap-4"}>
|
<div className={"flex items-center ml-auto"}>
|
||||||
<AccessControlModal>
|
<AccessControlModal>
|
||||||
<Button
|
<Button
|
||||||
variant={"primary"}
|
variant={"primary"}
|
||||||
|
|||||||
@@ -9,14 +9,19 @@ import DNS0Logo from "@/assets/nameservers/dns0.svg";
|
|||||||
import DNS0ZeroLogo from "@/assets/nameservers/dns0-zero.svg";
|
import DNS0ZeroLogo from "@/assets/nameservers/dns0-zero.svg";
|
||||||
import GoogleLogo from "@/assets/nameservers/google.svg";
|
import GoogleLogo from "@/assets/nameservers/google.svg";
|
||||||
import Quad9Logo from "@/assets/nameservers/quad9.svg";
|
import Quad9Logo from "@/assets/nameservers/quad9.svg";
|
||||||
|
import { Group } from "@/interfaces/Group";
|
||||||
import { NameserverGroup, NameserverPresets } from "@/interfaces/Nameserver";
|
import { NameserverGroup, NameserverPresets } from "@/interfaces/Nameserver";
|
||||||
import NameserverModal from "@/modules/dns-nameservers/NameserverModal";
|
import NameserverModal from "@/modules/dns-nameservers/NameserverModal";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
children: React.ReactNode;
|
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 [open, setOpen] = useState(false);
|
||||||
const [presetModal, setPresetModal] = useState(false);
|
const [presetModal, setPresetModal] = useState(false);
|
||||||
const [preset, setPreset] = useState(NameserverPresets.Default);
|
const [preset, setPreset] = useState(NameserverPresets.Default);
|
||||||
@@ -39,7 +44,14 @@ export default function NameserverTemplateModal({ children }: Readonly<Props>) {
|
|||||||
setPresetModal(o);
|
setPresetModal(o);
|
||||||
if (!o) setOpen(false);
|
if (!o) setOpen(false);
|
||||||
}}
|
}}
|
||||||
preset={preset}
|
preset={{
|
||||||
|
...preset,
|
||||||
|
groups: distributionGroups
|
||||||
|
? distributionGroups
|
||||||
|
.map((group) => group.id)
|
||||||
|
.filter((id): id is string => !!id)
|
||||||
|
: [],
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import Button from "@components/Button";
|
import Button from "@components/Button";
|
||||||
import ButtonGroup from "@components/ButtonGroup";
|
import ButtonGroup from "@components/ButtonGroup";
|
||||||
|
import Card from "@components/Card";
|
||||||
import InlineLink from "@components/InlineLink";
|
import InlineLink from "@components/InlineLink";
|
||||||
import SquareIcon from "@components/SquareIcon";
|
import SquareIcon from "@components/SquareIcon";
|
||||||
import { DataTable } from "@components/table/DataTable";
|
import { DataTable } from "@components/table/DataTable";
|
||||||
@@ -13,8 +14,10 @@ import { usePathname } from "next/navigation";
|
|||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
import { useSWRConfig } from "swr";
|
import { useSWRConfig } from "swr";
|
||||||
import DNSIcon from "@/assets/icons/DNSIcon";
|
import DNSIcon from "@/assets/icons/DNSIcon";
|
||||||
|
import NoResults from "@/components/ui/NoResults";
|
||||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||||
import { useLocalStorage } from "@/hooks/useLocalStorage";
|
import { useLocalStorage } from "@/hooks/useLocalStorage";
|
||||||
|
import { Group } from "@/interfaces/Group";
|
||||||
import { NameserverGroup } from "@/interfaces/Nameserver";
|
import { NameserverGroup } from "@/interfaces/Nameserver";
|
||||||
import NameserverModal from "@/modules/dns-nameservers/NameserverModal";
|
import NameserverModal from "@/modules/dns-nameservers/NameserverModal";
|
||||||
import NameserverTemplateModal from "@/modules/dns-nameservers/NameserverTemplateModal";
|
import NameserverTemplateModal from "@/modules/dns-nameservers/NameserverTemplateModal";
|
||||||
@@ -91,12 +94,16 @@ type Props = {
|
|||||||
nameserverGroups?: NameserverGroup[];
|
nameserverGroups?: NameserverGroup[];
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
headingTarget?: HTMLHeadingElement | null;
|
headingTarget?: HTMLHeadingElement | null;
|
||||||
|
isGroupPage?: boolean;
|
||||||
|
distributionGroups?: Group[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function NameserverGroupTable({
|
export default function NameserverGroupTable({
|
||||||
nameserverGroups,
|
nameserverGroups,
|
||||||
isLoading,
|
isLoading,
|
||||||
headingTarget,
|
headingTarget,
|
||||||
|
isGroupPage,
|
||||||
|
distributionGroups,
|
||||||
}: Readonly<Props>) {
|
}: Readonly<Props>) {
|
||||||
const { mutate } = useSWRConfig();
|
const { mutate } = useSWRConfig();
|
||||||
const path = usePathname();
|
const path = usePathname();
|
||||||
@@ -111,6 +118,7 @@ export default function NameserverGroupTable({
|
|||||||
desc: true,
|
desc: true,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
!isGroupPage,
|
||||||
);
|
);
|
||||||
|
|
||||||
const [editModal, setEditModal] = useState(false);
|
const [editModal, setEditModal] = useState(false);
|
||||||
@@ -133,6 +141,14 @@ export default function NameserverGroupTable({
|
|||||||
text={"Network Routes"}
|
text={"Network Routes"}
|
||||||
sorting={sorting}
|
sorting={sorting}
|
||||||
setSorting={setSorting}
|
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={{
|
columnVisibility={{
|
||||||
description: false,
|
description: false,
|
||||||
domain_list: false,
|
domain_list: false,
|
||||||
@@ -147,54 +163,78 @@ export default function NameserverGroupTable({
|
|||||||
data={nameserverGroups}
|
data={nameserverGroups}
|
||||||
searchPlaceholder={"Search by name, domains or nameservers..."}
|
searchPlaceholder={"Search by name, domains or nameservers..."}
|
||||||
getStartedCard={
|
getStartedCard={
|
||||||
<GetStartedTest
|
isGroupPage ? (
|
||||||
icon={
|
<NoResults
|
||||||
<SquareIcon
|
icon={<DNSIcon className={"fill-nb-gray-200"} size={20} />}
|
||||||
icon={<DNSIcon className={"fill-nb-gray-200"} size={20} />}
|
className={"py-4"}
|
||||||
color={"gray"}
|
title={"This group is not used within any nameservers yet"}
|
||||||
size={"large"}
|
description={
|
||||||
/>
|
"Assign this group as a distribution group in your nameservers to see them listed here."
|
||||||
}
|
}
|
||||||
title={"Create Nameserver"}
|
>
|
||||||
description={
|
<NameserverTemplateModal distributionGroups={distributionGroups}>
|
||||||
"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
|
||||||
}
|
variant={"primary"}
|
||||||
button={
|
className={"mt-4"}
|
||||||
<div className={"flex flex-col"}>
|
disabled={!permission.nameservers.create}
|
||||||
<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"}
|
|
||||||
>
|
>
|
||||||
DNS
|
<PlusCircle size={16} />
|
||||||
<ExternalLinkIcon size={12} />
|
Add Nameserver
|
||||||
</InlineLink>
|
</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={() => (
|
rightSide={() => (
|
||||||
<>
|
<>
|
||||||
{nameserverGroups && nameserverGroups?.length > 0 && (
|
{nameserverGroups && nameserverGroups?.length > 0 && (
|
||||||
<NameserverTemplateModal>
|
<NameserverTemplateModal distributionGroups={distributionGroups}>
|
||||||
<Button
|
<Button
|
||||||
variant={"primary"}
|
variant={"primary"}
|
||||||
className={"ml-auto"}
|
className={"ml-auto"}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { IconCirclePlus, IconDirectionSign } from "@tabler/icons-react";
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||||
|
import { Group } from "@/interfaces/Group";
|
||||||
import { Peer } from "@/interfaces/Peer";
|
import { Peer } from "@/interfaces/Peer";
|
||||||
import { ExitNodeHelpTooltip } from "@/modules/exit-node/ExitNodeHelpTooltip";
|
import { ExitNodeHelpTooltip } from "@/modules/exit-node/ExitNodeHelpTooltip";
|
||||||
import { RouteModalContent } from "@/modules/routes/RouteModal";
|
import { RouteModalContent } from "@/modules/routes/RouteModal";
|
||||||
@@ -11,8 +12,13 @@ import { RouteModalContent } from "@/modules/routes/RouteModal";
|
|||||||
type Props = {
|
type Props = {
|
||||||
peer?: Peer;
|
peer?: Peer;
|
||||||
firstTime?: boolean;
|
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 [modal, setModal] = useState(false);
|
||||||
const { permission } = usePermissions();
|
const { permission } = usePermissions();
|
||||||
|
|
||||||
@@ -42,6 +48,7 @@ export const AddExitNodeButton = ({ peer, firstTime = false }: Props) => {
|
|||||||
<RouteModalContent
|
<RouteModalContent
|
||||||
onSuccess={() => setModal(false)}
|
onSuccess={() => setModal(false)}
|
||||||
peer={peer}
|
peer={peer}
|
||||||
|
distributionGroups={distributionGroups}
|
||||||
isFirstExitNode={firstTime}
|
isFirstExitNode={firstTime}
|
||||||
exitNode={true}
|
exitNode={true}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import useFetchApi, { useApiCall } from "@utils/api";
|
|||||||
import { cn } from "@utils/helpers";
|
import { cn } from "@utils/helpers";
|
||||||
import { FolderGit2, PencilLineIcon } from "lucide-react";
|
import { FolderGit2, PencilLineIcon } from "lucide-react";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { useSWRConfig } from "swr";
|
import { useSWRConfig } from "swr";
|
||||||
import PeerIcon from "@/assets/icons/PeerIcon";
|
import PeerIcon from "@/assets/icons/PeerIcon";
|
||||||
import { DataTable } from "@/components/table/DataTable";
|
import { DataTable } from "@/components/table/DataTable";
|
||||||
@@ -28,6 +28,11 @@ type Props = {
|
|||||||
setOpen: (open: boolean) => void;
|
setOpen: (open: boolean) => void;
|
||||||
onUpdate?: (g: Group) => void;
|
onUpdate?: (g: Group) => void;
|
||||||
useSave?: boolean;
|
useSave?: boolean;
|
||||||
|
excludedPeers?: Peer[];
|
||||||
|
showHeader?: boolean;
|
||||||
|
showClose?: boolean;
|
||||||
|
buttonText?: string;
|
||||||
|
selectInitialPeers?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const AssignPeerToGroupModal = ({
|
export const AssignPeerToGroupModal = ({
|
||||||
@@ -36,6 +41,11 @@ export const AssignPeerToGroupModal = ({
|
|||||||
setOpen,
|
setOpen,
|
||||||
onUpdate,
|
onUpdate,
|
||||||
useSave = true,
|
useSave = true,
|
||||||
|
excludedPeers,
|
||||||
|
showHeader,
|
||||||
|
showClose,
|
||||||
|
buttonText,
|
||||||
|
selectInitialPeers,
|
||||||
}: Props) => {
|
}: Props) => {
|
||||||
return (
|
return (
|
||||||
<Modal open={open} onOpenChange={setOpen} key={open ? "1" : "0"}>
|
<Modal open={open} onOpenChange={setOpen} key={open ? "1" : "0"}>
|
||||||
@@ -47,6 +57,11 @@ export const AssignPeerToGroupModal = ({
|
|||||||
onUpdate && onUpdate(g);
|
onUpdate && onUpdate(g);
|
||||||
}}
|
}}
|
||||||
useSave={useSave}
|
useSave={useSave}
|
||||||
|
excludedPeers={excludedPeers}
|
||||||
|
showHeader={showHeader}
|
||||||
|
showClose={showClose}
|
||||||
|
buttonText={buttonText}
|
||||||
|
selectInitialPeers={selectInitialPeers}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Modal>
|
</Modal>
|
||||||
@@ -57,12 +72,22 @@ type ContentProps = {
|
|||||||
group: Group;
|
group: Group;
|
||||||
onSuccess?: (g: Group) => void;
|
onSuccess?: (g: Group) => void;
|
||||||
useSave?: boolean;
|
useSave?: boolean;
|
||||||
|
excludedPeers?: Peer[];
|
||||||
|
showHeader?: boolean;
|
||||||
|
showClose?: boolean;
|
||||||
|
buttonText?: string;
|
||||||
|
selectInitialPeers?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const AssignGroupToPeerModalContent = ({
|
export const AssignGroupToPeerModalContent = ({
|
||||||
group,
|
group,
|
||||||
onSuccess,
|
onSuccess,
|
||||||
useSave,
|
useSave,
|
||||||
|
excludedPeers,
|
||||||
|
showHeader = true,
|
||||||
|
showClose = true,
|
||||||
|
buttonText = "Confirm Changes",
|
||||||
|
selectInitialPeers = true,
|
||||||
}: ContentProps) => {
|
}: ContentProps) => {
|
||||||
const { data: peers, isLoading } = useFetchApi<Peer[]>("/peers");
|
const { data: peers, isLoading } = useFetchApi<Peer[]>("/peers");
|
||||||
const { mutate } = useSWRConfig();
|
const { mutate } = useSWRConfig();
|
||||||
@@ -89,8 +114,9 @@ export const AssignGroupToPeerModalContent = ({
|
|||||||
setGroupName(name);
|
setGroupName(name);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Get initial selected peers
|
// Get initially selected peers
|
||||||
const getInitialSelectedPeers = useCallback(() => {
|
const getInitialSelectedPeers = useCallback(() => {
|
||||||
|
if (!selectInitialPeers) return {};
|
||||||
if (!group) return undefined;
|
if (!group) return undefined;
|
||||||
if (!peers) return undefined;
|
if (!peers) return undefined;
|
||||||
let initialSelectedPeers = group?.peers
|
let initialSelectedPeers = group?.peers
|
||||||
@@ -109,24 +135,23 @@ export const AssignGroupToPeerModalContent = ({
|
|||||||
},
|
},
|
||||||
{} as Record<string, boolean>,
|
{} as Record<string, boolean>,
|
||||||
);
|
);
|
||||||
}, [group, peers]);
|
}, [group, peers, selectInitialPeers]);
|
||||||
|
|
||||||
const handleOnSave = async (selectedPeers: Peer[]) => {
|
const handleOnSave = async (selectedPeers: Peer[]) => {
|
||||||
if (!useSave) {
|
if (!useSave) {
|
||||||
onSuccess &&
|
onSuccess?.({
|
||||||
onSuccess({
|
...group,
|
||||||
...group,
|
name: groupName,
|
||||||
name: groupName,
|
peers: selectedPeers.map((peer) => {
|
||||||
peers: selectedPeers.map((peer) => {
|
return {
|
||||||
return {
|
id: peer.id,
|
||||||
id: peer.id,
|
name: peer.name,
|
||||||
name: peer.name,
|
} as GroupPeer;
|
||||||
} as GroupPeer;
|
}),
|
||||||
}),
|
peers_count: selectedPeers.length,
|
||||||
peers_count: selectedPeers.length,
|
resources: group.resources,
|
||||||
resources: group.resources,
|
keepClientState: true,
|
||||||
keepClientState: true,
|
});
|
||||||
});
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -172,11 +197,19 @@ export const AssignGroupToPeerModalContent = ({
|
|||||||
setInitialPeersSet(true);
|
setInitialPeersSet(true);
|
||||||
}, [getInitialSelectedPeers, initialPeersSet]);
|
}, [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 (
|
return (
|
||||||
<ModalContent
|
<ModalContent
|
||||||
maxWidthClass={"max-w-4xl"}
|
maxWidthClass={"max-w-4xl"}
|
||||||
className={cn(peers && peers.length > 0 ? "pb-0" : "pb-8")}
|
className={cn(peers && peers.length > 0 ? "pb-0" : "pb-8")}
|
||||||
showClose={true}
|
showClose={showClose}
|
||||||
>
|
>
|
||||||
{groupNameModal && (
|
{groupNameModal && (
|
||||||
<EditGroupNameModal
|
<EditGroupNameModal
|
||||||
@@ -186,34 +219,37 @@ export const AssignGroupToPeerModalContent = ({
|
|||||||
onSuccess={onGroupNameUpdate}
|
onSuccess={onGroupNameUpdate}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<div className={"flex items-start justify-between pr-8"}>
|
|
||||||
<ModalHeader
|
{showHeader && (
|
||||||
title={
|
<div className={"flex items-start justify-between pr-8"}>
|
||||||
<div className={"flex items-center gap-2 mb-1 text-nb-gray-100"}>
|
<ModalHeader
|
||||||
<FolderGit2 size={16} className={"shrink-0"} />
|
title={
|
||||||
<div className={"flex gap-2 items-center"}>
|
<div className={"flex items-center gap-2 mb-1 text-nb-gray-100"}>
|
||||||
{groupName}
|
<FolderGit2 size={16} className={"shrink-0"} />
|
||||||
{groupName !== "All" && (
|
<div className={"flex gap-2 items-center"}>
|
||||||
<button
|
{groupName}
|
||||||
className={
|
{groupName !== "All" && (
|
||||||
"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"
|
<button
|
||||||
}
|
className={
|
||||||
onClick={() => setGroupNameModal(true)}
|
"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"
|
||||||
>
|
}
|
||||||
<PencilLineIcon size={16} />
|
onClick={() => setGroupNameModal(true)}
|
||||||
</button>
|
>
|
||||||
)}
|
<PencilLineIcon size={16} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
}
|
||||||
}
|
description={
|
||||||
description={
|
isAllGroup
|
||||||
isAllGroup
|
? "View assigned peers for this group"
|
||||||
? "View assigned peers for this group"
|
: "Manage assigned peers for this group"
|
||||||
: "Manage assigned peers for this group"
|
}
|
||||||
}
|
color={"blue"}
|
||||||
color={"blue"}
|
/>
|
||||||
/>
|
</div>
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
{initialPeersSet ? (
|
{initialPeersSet ? (
|
||||||
<DataTable
|
<DataTable
|
||||||
@@ -228,10 +264,11 @@ export const AssignGroupToPeerModalContent = ({
|
|||||||
keepStateInLocalStorage={false}
|
keepStateInLocalStorage={false}
|
||||||
setSorting={setSorting}
|
setSorting={setSorting}
|
||||||
columns={PeersTableColumns}
|
columns={PeersTableColumns}
|
||||||
data={initialPeersSet ? peers : undefined}
|
data={data}
|
||||||
isLoading={isLoading && !initialPeersSet}
|
isLoading={isLoading && !initialPeersSet}
|
||||||
tableCellClassName={"!py-1 scale-[95%]"}
|
tableCellClassName={"!py-1 scale-[95%]"}
|
||||||
searchPlaceholder={"Search by name, IP or owner..."}
|
searchPlaceholder={"Search by name, IP or owner..."}
|
||||||
|
searchClassName={"w-[350px]"}
|
||||||
minimal={false}
|
minimal={false}
|
||||||
columnVisibility={{
|
columnVisibility={{
|
||||||
connected: false,
|
connected: false,
|
||||||
@@ -245,9 +282,10 @@ export const AssignGroupToPeerModalContent = ({
|
|||||||
}}
|
}}
|
||||||
getStartedCard={
|
getStartedCard={
|
||||||
<NoResultsCard
|
<NoResultsCard
|
||||||
title={"Seems like you don't have any peers"}
|
className={"mb-8"}
|
||||||
|
title={"You don't have any peers to assign"}
|
||||||
description={
|
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} />}
|
icon={<PeerIcon className={"fill-nb-gray-200"} size={14} />}
|
||||||
/>
|
/>
|
||||||
@@ -268,7 +306,10 @@ export const AssignGroupToPeerModalContent = ({
|
|||||||
<Button
|
<Button
|
||||||
variant={"primary"}
|
variant={"primary"}
|
||||||
className={"ml-auto"}
|
className={"ml-auto"}
|
||||||
disabled={peers?.length === 0}
|
disabled={
|
||||||
|
peers?.length === 0 ||
|
||||||
|
Object.keys(selectedRows).length === 0
|
||||||
|
}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const selectedPeers = table
|
const selectedPeers = table
|
||||||
.getSelectedRowModel()
|
.getSelectedRowModel()
|
||||||
@@ -276,7 +317,7 @@ export const AssignGroupToPeerModalContent = ({
|
|||||||
handleOnSave(selectedPeers).then();
|
handleOnSave(selectedPeers).then();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Confirm Changes
|
{buttonText}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -289,7 +330,7 @@ export const AssignGroupToPeerModalContent = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const PeersTableColumns: ColumnDef<Peer>[] = [
|
export const PeersTableColumns: ColumnDef<Peer>[] = [
|
||||||
{
|
{
|
||||||
id: "select",
|
id: "select",
|
||||||
header: ({ table, column }) => (
|
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,
|
ModalFooter,
|
||||||
} from "@components/modal/Modal";
|
} from "@components/modal/Modal";
|
||||||
import ModalHeader from "@components/modal/ModalHeader";
|
import ModalHeader from "@components/modal/ModalHeader";
|
||||||
import { IconCornerDownLeft } from "@tabler/icons-react";
|
|
||||||
import { trim } from "lodash";
|
import { trim } from "lodash";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
|
import { useGroups } from "@/contexts/GroupsProvider";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
initialName: string;
|
initialName: string;
|
||||||
@@ -25,53 +25,66 @@ export const EditGroupNameModal = ({
|
|||||||
onSuccess,
|
onSuccess,
|
||||||
}: Props) => {
|
}: Props) => {
|
||||||
const [name, setName] = useState(initialName);
|
const [name, setName] = useState(initialName);
|
||||||
|
const { groups } = useGroups();
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
const isDisabled = useMemo(() => {
|
const isDisabled = useMemo(() => {
|
||||||
if (name === initialName) return true;
|
if (name === initialName) return true;
|
||||||
|
if (error !== "") return true;
|
||||||
const trimmedName = trim(name);
|
const trimmedName = trim(name);
|
||||||
return trimmedName.length === 0;
|
return trimmedName.length === 0;
|
||||||
}, [name, initialName]);
|
}, [name, initialName, error]);
|
||||||
|
|
||||||
|
const handleNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const newName = trim(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 (
|
return (
|
||||||
<Modal open={open} onOpenChange={onOpenChange}>
|
<Modal open={open} onOpenChange={onOpenChange}>
|
||||||
<ModalContent maxWidthClass={"max-w-md"}>
|
<ModalContent maxWidthClass={"max-w-md"}>
|
||||||
<form>
|
<ModalHeader
|
||||||
<ModalHeader
|
title={"Rename Group"}
|
||||||
title={"Edit Group Name"}
|
description={"Set an easily identifiable name for your group."}
|
||||||
description={"Set an easily identifiable name for your group."}
|
color={"blue"}
|
||||||
color={"blue"}
|
/>
|
||||||
/>
|
|
||||||
|
|
||||||
<div className={"p-default flex flex-col gap-4"}>
|
<div className={"p-default flex flex-col gap-4"}>
|
||||||
<div>
|
<div>
|
||||||
<Input
|
<Input
|
||||||
placeholder={"e.g., AWS Servers"}
|
placeholder={"e.g., Developers"}
|
||||||
value={name}
|
value={name}
|
||||||
onChange={(e) => setName(e.target.value)}
|
onChange={handleNameChange}
|
||||||
/>
|
error={error}
|
||||||
</div>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<ModalFooter className={"items-center"} separator={false}>
|
<ModalFooter className={"items-center"} separator={false}>
|
||||||
<div className={"flex gap-3 w-full justify-end"}>
|
<div className={"flex gap-3 w-full justify-end"}>
|
||||||
<ModalClose asChild={true}>
|
<ModalClose asChild={true}>
|
||||||
<Button variant={"secondary"} className={"w-full"}>
|
<Button variant={"secondary"} className={"w-full"}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
|
||||||
</ModalClose>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant={"primary"}
|
|
||||||
className={"w-full"}
|
|
||||||
onClick={() => onSuccess(name)}
|
|
||||||
disabled={isDisabled}
|
|
||||||
type={"submit"}
|
|
||||||
>
|
|
||||||
Confirm
|
|
||||||
<IconCornerDownLeft size={16} />
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</ModalClose>
|
||||||
</ModalFooter>
|
|
||||||
</form>
|
<Button
|
||||||
|
variant={"primary"}
|
||||||
|
className={"w-full"}
|
||||||
|
onClick={() => onSuccess(name)}
|
||||||
|
disabled={isDisabled}
|
||||||
|
type={"submit"}
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</ModalFooter>
|
||||||
</ModalContent>
|
</ModalContent>
|
||||||
</Modal>
|
</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,8 @@ import ButtonGroup from "@components/ButtonGroup";
|
|||||||
import { DataTable } from "@components/table/DataTable";
|
import { DataTable } from "@components/table/DataTable";
|
||||||
import DataTableHeader from "@components/table/DataTableHeader";
|
import DataTableHeader from "@components/table/DataTableHeader";
|
||||||
import { DataTableRowsPerPage } from "@components/table/DataTableRowsPerPage";
|
import { DataTableRowsPerPage } from "@components/table/DataTableRowsPerPage";
|
||||||
import NoResults from "@components/ui/NoResults";
|
|
||||||
import { ColumnDef, SortingState } from "@tanstack/react-table";
|
import { ColumnDef, SortingState } from "@tanstack/react-table";
|
||||||
import { FolderGit2Icon, Layers3Icon } from "lucide-react";
|
import { Layers3Icon } from "lucide-react";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import AccessControlIcon from "@/assets/icons/AccessControlIcon";
|
import AccessControlIcon from "@/assets/icons/AccessControlIcon";
|
||||||
@@ -13,13 +12,14 @@ import NetworkRoutesIcon from "@/assets/icons/NetworkRoutesIcon";
|
|||||||
import PeerIcon from "@/assets/icons/PeerIcon";
|
import PeerIcon from "@/assets/icons/PeerIcon";
|
||||||
import SetupKeysIcon from "@/assets/icons/SetupKeysIcon";
|
import SetupKeysIcon from "@/assets/icons/SetupKeysIcon";
|
||||||
import TeamIcon from "@/assets/icons/TeamIcon";
|
import TeamIcon from "@/assets/icons/TeamIcon";
|
||||||
|
import { AddGroupButton } from "@/components/ui/AddGroupButton";
|
||||||
|
import { GroupProvider } from "@/contexts/GroupProvider";
|
||||||
import { useLocalStorage } from "@/hooks/useLocalStorage";
|
import { useLocalStorage } from "@/hooks/useLocalStorage";
|
||||||
import GroupsActionCell from "@/modules/settings/GroupsActionCell";
|
import GroupsActionCell from "@/modules/groups/table/GroupsActionCell";
|
||||||
import GroupsCountCell from "@/modules/settings/GroupsCountCell";
|
import GroupsCountCell from "@/modules/groups/table/GroupsCountCell";
|
||||||
import GroupsNameCell from "@/modules/settings/GroupsNameCell";
|
import GroupsNameCell from "@/modules/groups/table/GroupsNameCell";
|
||||||
import useGroupsUsage, { GroupUsage } from "@/modules/settings/useGroupsUsage";
|
import useGroupsUsage, { GroupUsage } from "@/modules/groups/useGroupsUsage";
|
||||||
|
|
||||||
// Peers, Access Controls, DNS, Routes, Setup Keys, Users
|
|
||||||
export const GroupsTableColumns: ColumnDef<GroupUsage>[] = [
|
export const GroupsTableColumns: ColumnDef<GroupUsage>[] = [
|
||||||
{
|
{
|
||||||
accessorKey: "name",
|
accessorKey: "name",
|
||||||
@@ -42,24 +42,25 @@ export const GroupsTableColumns: ColumnDef<GroupUsage>[] = [
|
|||||||
sortingFn: "text",
|
sortingFn: "text",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "setup_keys_count",
|
accessorKey: "users_count",
|
||||||
header: ({ column }) => {
|
header: ({ column }) => {
|
||||||
return (
|
return (
|
||||||
<DataTableHeader
|
<DataTableHeader
|
||||||
column={column}
|
column={column}
|
||||||
center={true}
|
tooltip={<div className={"text-xs normal-case"}>Users</div>}
|
||||||
tooltip={<div className={"text-sm normal-case"}>Setup Keys</div>}
|
|
||||||
>
|
>
|
||||||
<SetupKeysIcon size={12} />
|
<TeamIcon size={12} />
|
||||||
</DataTableHeader>
|
</DataTableHeader>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<GroupsCountCell
|
<GroupsCountCell
|
||||||
icon={<SetupKeysIcon size={10} />}
|
icon={<TeamIcon size={10} />}
|
||||||
groupName={row.original.name}
|
groupName={row.original.name}
|
||||||
text={"Setup Key(s)"}
|
href={`/group?id=${row.original.id}&tab=users`}
|
||||||
count={row.original.setup_keys_count}
|
hidden={row.original.name === "All"}
|
||||||
|
text={"User(s)"}
|
||||||
|
count={row.original.users_count}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -69,7 +70,7 @@ export const GroupsTableColumns: ColumnDef<GroupUsage>[] = [
|
|||||||
return (
|
return (
|
||||||
<DataTableHeader
|
<DataTableHeader
|
||||||
column={column}
|
column={column}
|
||||||
tooltip={<div className={"text-sm normal-case"}>Peers</div>}
|
tooltip={<div className={"text-xs normal-case"}>Peers</div>}
|
||||||
>
|
>
|
||||||
<PeerIcon size={12} />
|
<PeerIcon size={12} />
|
||||||
</DataTableHeader>
|
</DataTableHeader>
|
||||||
@@ -79,39 +80,20 @@ export const GroupsTableColumns: ColumnDef<GroupUsage>[] = [
|
|||||||
<GroupsCountCell
|
<GroupsCountCell
|
||||||
icon={<PeerIcon size={10} />}
|
icon={<PeerIcon size={10} />}
|
||||||
groupName={row.original.name}
|
groupName={row.original.name}
|
||||||
|
href={`/group?id=${row.original.id}&tab=peers`}
|
||||||
|
hidden={row.original.name === "All"}
|
||||||
text={"Peer(s)"}
|
text={"Peer(s)"}
|
||||||
count={row.original.peers_count}
|
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",
|
accessorKey: "policies_count",
|
||||||
header: ({ column }) => {
|
header: ({ column }) => {
|
||||||
return (
|
return (
|
||||||
<DataTableHeader
|
<DataTableHeader
|
||||||
column={column}
|
column={column}
|
||||||
tooltip={<div className={"text-sm normal-case"}>Access Controls</div>}
|
tooltip={<div className={"text-xs normal-case"}>Policies</div>}
|
||||||
>
|
>
|
||||||
<AccessControlIcon size={12} />
|
<AccessControlIcon size={12} />
|
||||||
</DataTableHeader>
|
</DataTableHeader>
|
||||||
@@ -121,32 +103,12 @@ export const GroupsTableColumns: ColumnDef<GroupUsage>[] = [
|
|||||||
<GroupsCountCell
|
<GroupsCountCell
|
||||||
icon={<AccessControlIcon size={10} />}
|
icon={<AccessControlIcon size={10} />}
|
||||||
groupName={row.original.name}
|
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}
|
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",
|
accessorKey: "resources_count",
|
||||||
header: ({ column }) => {
|
header: ({ column }) => {
|
||||||
@@ -154,7 +116,7 @@ export const GroupsTableColumns: ColumnDef<GroupUsage>[] = [
|
|||||||
<DataTableHeader
|
<DataTableHeader
|
||||||
column={column}
|
column={column}
|
||||||
tooltip={
|
tooltip={
|
||||||
<div className={"text-sm normal-case"}>Network Resources</div>
|
<div className={"text-xs normal-case"}>Network Resources</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Layers3Icon size={12} />
|
<Layers3Icon size={12} />
|
||||||
@@ -165,29 +127,77 @@ export const GroupsTableColumns: ColumnDef<GroupUsage>[] = [
|
|||||||
<GroupsCountCell
|
<GroupsCountCell
|
||||||
icon={<Layers3Icon size={10} />}
|
icon={<Layers3Icon size={10} />}
|
||||||
groupName={row.original.name}
|
groupName={row.original.name}
|
||||||
|
href={`/group?id=${row.original.id}&tab=resources`}
|
||||||
text={"Network Resource(s)"}
|
text={"Network Resource(s)"}
|
||||||
count={row.original.resources_count}
|
count={row.original.resources_count}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "users_count",
|
accessorKey: "routes_count",
|
||||||
header: ({ column }) => {
|
header: ({ column }) => {
|
||||||
return (
|
return (
|
||||||
<DataTableHeader
|
<DataTableHeader
|
||||||
column={column}
|
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>
|
</DataTableHeader>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<GroupsCountCell
|
<GroupsCountCell
|
||||||
icon={<TeamIcon size={10} />}
|
icon={<NetworkRoutesIcon size={10} />}
|
||||||
groupName={row.original.name}
|
groupName={row.original.name}
|
||||||
text={"User(s)"}
|
href={`/group?id=${row.original.id}&tab=network-routes`}
|
||||||
count={row.original.users_count}
|
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,7 +223,9 @@ export const GroupsTableColumns: ColumnDef<GroupUsage>[] = [
|
|||||||
accessorKey: "id",
|
accessorKey: "id",
|
||||||
header: "",
|
header: "",
|
||||||
cell: ({ row }) => (
|
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>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@@ -223,7 +235,7 @@ type Props = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function GroupsTable({ headingTarget }: Readonly<Props>) {
|
export default function GroupsTable({ headingTarget }: Readonly<Props>) {
|
||||||
const groups = useGroupsUsage();
|
const { data: groups, isLoading } = useGroupsUsage();
|
||||||
const path = usePathname();
|
const path = usePathname();
|
||||||
|
|
||||||
// Default sorting state of the table
|
// Default sorting state of the table
|
||||||
@@ -231,88 +243,73 @@ export default function GroupsTable({ headingTarget }: Readonly<Props>) {
|
|||||||
"netbird-table-sort" + path,
|
"netbird-table-sort" + path,
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
id: "name",
|
id: "in_use",
|
||||||
desc: true,
|
desc: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "name",
|
||||||
|
desc: false,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<DataTable
|
||||||
{groups && groups.length > 0 ? (
|
headingTarget={headingTarget}
|
||||||
<DataTable
|
text={"Groups"}
|
||||||
headingTarget={headingTarget}
|
sorting={sorting}
|
||||||
text={"Groups"}
|
isLoading={isLoading}
|
||||||
inset={false}
|
setSorting={setSorting}
|
||||||
sorting={sorting}
|
columns={GroupsTableColumns}
|
||||||
setSorting={setSorting}
|
data={groups}
|
||||||
columns={GroupsTableColumns}
|
searchPlaceholder={"Search group by name..."}
|
||||||
data={groups}
|
rightSide={() => <AddGroupButton />}
|
||||||
searchPlaceholder={"Search group..."}
|
columnVisibility={{
|
||||||
columnVisibility={{
|
in_use: false,
|
||||||
in_use: false,
|
}}
|
||||||
}}
|
>
|
||||||
>
|
{(table) => (
|
||||||
{(table) => (
|
<>
|
||||||
<>
|
<ButtonGroup disabled={groups?.length == 0}>
|
||||||
<ButtonGroup disabled={groups?.length == 0}>
|
<ButtonGroup.Button
|
||||||
<ButtonGroup.Button
|
onClick={() =>
|
||||||
onClick={() =>
|
table.getColumn("in_use")?.setFilterValue(undefined)
|
||||||
table.getColumn("in_use")?.setFilterValue(undefined)
|
}
|
||||||
}
|
disabled={groups?.length == 0}
|
||||||
disabled={groups?.length == 0}
|
variant={
|
||||||
variant={
|
table.getColumn("in_use")?.getFilterValue() === undefined
|
||||||
table.getColumn("in_use")?.getFilterValue() === undefined
|
? "tertiary"
|
||||||
? "tertiary"
|
: "secondary"
|
||||||
: "secondary"
|
}
|
||||||
}
|
>
|
||||||
>
|
All
|
||||||
All
|
</ButtonGroup.Button>
|
||||||
</ButtonGroup.Button>
|
<ButtonGroup.Button
|
||||||
<ButtonGroup.Button
|
onClick={() => table.getColumn("in_use")?.setFilterValue(true)}
|
||||||
onClick={() =>
|
disabled={groups?.length == 0}
|
||||||
table.getColumn("in_use")?.setFilterValue(true)
|
variant={
|
||||||
}
|
table.getColumn("in_use")?.getFilterValue() === true
|
||||||
disabled={groups?.length == 0}
|
? "tertiary"
|
||||||
variant={
|
: "secondary"
|
||||||
table.getColumn("in_use")?.getFilterValue() === true
|
}
|
||||||
? "tertiary"
|
>
|
||||||
: "secondary"
|
Used
|
||||||
}
|
</ButtonGroup.Button>
|
||||||
>
|
<ButtonGroup.Button
|
||||||
Used
|
disabled={groups?.length == 0}
|
||||||
</ButtonGroup.Button>
|
onClick={() => table.getColumn("in_use")?.setFilterValue(false)}
|
||||||
<ButtonGroup.Button
|
variant={
|
||||||
disabled={groups?.length == 0}
|
table.getColumn("in_use")?.getFilterValue() === false
|
||||||
onClick={() =>
|
? "tertiary"
|
||||||
table.getColumn("in_use")?.setFilterValue(false)
|
: "secondary"
|
||||||
}
|
}
|
||||||
variant={
|
>
|
||||||
table.getColumn("in_use")?.getFilterValue() === false
|
Unused
|
||||||
? "tertiary"
|
</ButtonGroup.Button>
|
||||||
: "secondary"
|
</ButtonGroup>
|
||||||
}
|
<DataTableRowsPerPage table={table} disabled={groups?.length == 0} />
|
||||||
>
|
</>
|
||||||
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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -14,11 +14,14 @@ export const useGroupIdentification = ({ id, issued }: Props) => {
|
|||||||
const isRegularGroup =
|
const isRegularGroup =
|
||||||
!isJWTGroup && !isOktaGroup && !isGoogleGroup && !isAzureGroup;
|
!isJWTGroup && !isOktaGroup && !isGoogleGroup && !isAzureGroup;
|
||||||
|
|
||||||
|
const isIntegrationGroup = isOktaGroup || isGoogleGroup || isAzureGroup;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isOktaGroup,
|
isOktaGroup,
|
||||||
isGoogleGroup,
|
isGoogleGroup,
|
||||||
isAzureGroup,
|
isAzureGroup,
|
||||||
isJWTGroup,
|
isJWTGroup,
|
||||||
isRegularGroup,
|
isRegularGroup,
|
||||||
|
isIntegrationGroup,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,16 +1,13 @@
|
|||||||
import useFetchApi from "@utils/api";
|
import useFetchApi from "@utils/api";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { Group, GroupIssued } from "@/interfaces/Group";
|
import { Group } from "@/interfaces/Group";
|
||||||
import { NameserverGroup } from "@/interfaces/Nameserver";
|
import { NameserverGroup } from "@/interfaces/Nameserver";
|
||||||
import { Policy } from "@/interfaces/Policy";
|
import { Policy } from "@/interfaces/Policy";
|
||||||
import { Route } from "@/interfaces/Route";
|
import { Route } from "@/interfaces/Route";
|
||||||
import { SetupKey } from "@/interfaces/SetupKey";
|
import { SetupKey } from "@/interfaces/SetupKey";
|
||||||
import { User } from "@/interfaces/User";
|
import { User } from "@/interfaces/User";
|
||||||
|
|
||||||
export interface GroupUsage {
|
export interface GroupUsage extends Group {
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
issued: GroupIssued;
|
|
||||||
peers_count: number;
|
peers_count: number;
|
||||||
policies_count: number;
|
policies_count: number;
|
||||||
nameservers_count: number;
|
nameservers_count: number;
|
||||||
@@ -22,7 +19,7 @@ export interface GroupUsage {
|
|||||||
|
|
||||||
export default function useGroupsUsage() {
|
export default function useGroupsUsage() {
|
||||||
const { data: groups, isLoading: isGroupsLoading } =
|
const { data: groups, isLoading: isGroupsLoading } =
|
||||||
useFetchApi<Group[]>(`/groups`); // Groups , Peers count
|
useFetchApi<Group[]>(`/groups`); // Groups, Peers count
|
||||||
const { data: policies, isLoading: isPoliciesLoading } =
|
const { data: policies, isLoading: isPoliciesLoading } =
|
||||||
useFetchApi<Policy[]>(`/policies`); // Policies
|
useFetchApi<Policy[]>(`/policies`); // Policies
|
||||||
const { data: nameservers, isLoading: isNameserversLoading } =
|
const { data: nameservers, isLoading: isNameserversLoading } =
|
||||||
@@ -60,12 +57,6 @@ export default function useGroupsUsage() {
|
|||||||
.filter((u) => u !== undefined);
|
.filter((u) => u !== undefined);
|
||||||
}, [nameservers, isNameserversLoading]);
|
}, [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(() => {
|
const setupKeysGroups = useMemo(() => {
|
||||||
if (isSetupKeysLoading) return;
|
if (isSetupKeysLoading) return;
|
||||||
if (!setupKeys) return [];
|
if (!setupKeys) return [];
|
||||||
@@ -100,8 +91,9 @@ export default function useGroupsUsage() {
|
|||||||
isUsersLoading,
|
isUsersLoading,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return useMemo(() => {
|
const groupsUsage = useMemo(() => {
|
||||||
if (isLoading) return [];
|
if (isLoading) return [];
|
||||||
|
if (isRoutesLoading) return [];
|
||||||
if (!groups) return [];
|
if (!groups) return [];
|
||||||
return groups?.map((group) => {
|
return groups?.map((group) => {
|
||||||
const policyCount = policiesGroups?.filter((policy) => {
|
const policyCount = policiesGroups?.filter((policy) => {
|
||||||
@@ -112,9 +104,20 @@ export default function useGroupsUsage() {
|
|||||||
return nameserver.includes(group.id as string);
|
return nameserver.includes(group.id as string);
|
||||||
}).length;
|
}).length;
|
||||||
|
|
||||||
const routeCount = routesGroups?.filter((route) => {
|
const routeCount = (
|
||||||
return route.includes(group.id as string);
|
routes?.filter((route) => {
|
||||||
}).length;
|
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) => {
|
const setupKeyCount = setupKeysGroups?.filter((setupKey) => {
|
||||||
return setupKey.includes(group.id as string);
|
return setupKey.includes(group.id as string);
|
||||||
@@ -125,9 +128,7 @@ export default function useGroupsUsage() {
|
|||||||
}).length;
|
}).length;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: group.id,
|
...group,
|
||||||
issued: group.issued,
|
|
||||||
name: group.name,
|
|
||||||
peers_count: group.peers_count,
|
peers_count: group.peers_count,
|
||||||
resources_count: group.resources_count,
|
resources_count: group.resources_count,
|
||||||
policies_count: policyCount,
|
policies_count: policyCount,
|
||||||
@@ -142,8 +143,14 @@ export default function useGroupsUsage() {
|
|||||||
groups,
|
groups,
|
||||||
policiesGroups,
|
policiesGroups,
|
||||||
nameserversGroups,
|
nameserversGroups,
|
||||||
routesGroups,
|
routes,
|
||||||
|
isRoutesLoading,
|
||||||
setupKeysGroups,
|
setupKeysGroups,
|
||||||
usersGroups,
|
usersGroups,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: groupsUsage,
|
||||||
|
isLoading,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
@@ -16,6 +16,8 @@ import NetworkRoutingPeerModal from "@/modules/networks/routing-peers/NetworkRou
|
|||||||
type Props = {
|
type Props = {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
network?: Network;
|
network?: Network;
|
||||||
|
onResourceUpdate?: () => void;
|
||||||
|
onResourceDelete?: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const NetworksContext = React.createContext(
|
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 { mutate } = useSWRConfig();
|
||||||
const { confirm } = useDialog();
|
const { confirm } = useDialog();
|
||||||
const deleteCall = useApiCall("/networks").del;
|
const deleteCall = useApiCall("/networks").del;
|
||||||
@@ -160,6 +167,7 @@ export const NetworkProvider = ({ children, network }: Props) => {
|
|||||||
loadingMessage: "Deleting resource...",
|
loadingMessage: "Deleting resource...",
|
||||||
promise: deleteCall({}, `/${network.id}/resources/${resource.id}`).then(
|
promise: deleteCall({}, `/${network.id}/resources/${resource.id}`).then(
|
||||||
() => {
|
() => {
|
||||||
|
onResourceDelete?.();
|
||||||
mutate(`/networks/${network.id}/resources`);
|
mutate(`/networks/${network.id}/resources`);
|
||||||
mutate("/groups");
|
mutate("/groups");
|
||||||
},
|
},
|
||||||
@@ -276,6 +284,7 @@ export const NetworkProvider = ({ children, network }: Props) => {
|
|||||||
setPolicyDefaultSettings(undefined);
|
setPolicyDefaultSettings(undefined);
|
||||||
mutate("/networks");
|
mutate("/networks");
|
||||||
if (network) {
|
if (network) {
|
||||||
|
onResourceUpdate?.();
|
||||||
mutate(`/networks/${network.id}/resources`);
|
mutate(`/networks/${network.id}/resources`);
|
||||||
mutate(`/networks/${network.id}`);
|
mutate(`/networks/${network.id}`);
|
||||||
} else {
|
} else {
|
||||||
@@ -329,6 +338,7 @@ export const NetworkProvider = ({ children, network }: Props) => {
|
|||||||
setCurrentResource(undefined);
|
setCurrentResource(undefined);
|
||||||
mutate("/groups");
|
mutate("/groups");
|
||||||
if (network) {
|
if (network) {
|
||||||
|
onResourceUpdate?.();
|
||||||
mutate(`/networks/${network.id}/resources`);
|
mutate(`/networks/${network.id}/resources`);
|
||||||
mutate(`/networks/${network.id}`);
|
mutate(`/networks/${network.id}`);
|
||||||
}
|
}
|
||||||
@@ -356,6 +366,7 @@ export const NetworkProvider = ({ children, network }: Props) => {
|
|||||||
mutate("/networks");
|
mutate("/networks");
|
||||||
mutate("/groups");
|
mutate("/groups");
|
||||||
if (network) {
|
if (network) {
|
||||||
|
onResourceUpdate?.();
|
||||||
mutate(`/networks/${network.id}/resources`);
|
mutate(`/networks/${network.id}/resources`);
|
||||||
mutate(`/networks/${network.id}`);
|
mutate(`/networks/${network.id}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ export const ResourceActionCell = ({ resource }: Props) => {
|
|||||||
>
|
>
|
||||||
<div className={"flex gap-3 items-center"}>
|
<div className={"flex gap-3 items-center"}>
|
||||||
<Trash2 size={14} className={"shrink-0"} />
|
<Trash2 size={14} className={"shrink-0"} />
|
||||||
Remove
|
Delete
|
||||||
</div>
|
</div>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
|
|||||||
@@ -11,8 +11,12 @@ import { useNetworksContext } from "@/modules/networks/NetworkProvider";
|
|||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
resource: NetworkResource;
|
resource: NetworkResource;
|
||||||
|
mutateAllResourcesOnUpdate?: boolean;
|
||||||
};
|
};
|
||||||
export const ResourceEnabledCell = ({ resource }: Props) => {
|
export const ResourceEnabledCell = ({
|
||||||
|
resource,
|
||||||
|
mutateAllResourcesOnUpdate,
|
||||||
|
}: Props) => {
|
||||||
const { permission } = usePermissions();
|
const { permission } = usePermissions();
|
||||||
|
|
||||||
const { mutate } = useSWRConfig();
|
const { mutate } = useSWRConfig();
|
||||||
@@ -40,6 +44,7 @@ export const ResourceEnabledCell = ({ resource }: Props) => {
|
|||||||
.filter((g) => g !== undefined),
|
.filter((g) => g !== undefined),
|
||||||
enabled,
|
enabled,
|
||||||
}).then(() => {
|
}).then(() => {
|
||||||
|
mutateAllResourcesOnUpdate && mutate("/networks/resources");
|
||||||
mutate(`/networks/${network?.id}/resources`);
|
mutate(`/networks/${network?.id}/resources`);
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ import NoResults from "@components/ui/NoResults";
|
|||||||
import { IconCirclePlus } from "@tabler/icons-react";
|
import { IconCirclePlus } from "@tabler/icons-react";
|
||||||
import { ColumnDef, SortingState } from "@tanstack/react-table";
|
import { ColumnDef, SortingState } from "@tanstack/react-table";
|
||||||
import { removeAllSpaces } from "@utils/helpers";
|
import { removeAllSpaces } from "@utils/helpers";
|
||||||
import { Layers3Icon } from "lucide-react";
|
import { ArrowUpRightIcon, Layers3Icon } from "lucide-react";
|
||||||
import { useSearchParams } from "next/navigation";
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||||
@@ -26,6 +26,7 @@ type Props = {
|
|||||||
resources?: NetworkResource[];
|
resources?: NetworkResource[];
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
headingTarget?: HTMLHeadingElement | null;
|
headingTarget?: HTMLHeadingElement | null;
|
||||||
|
isGroupPage?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const NetworkResourceColumns: ColumnDef<NetworkResource>[] = [
|
const NetworkResourceColumns: ColumnDef<NetworkResource>[] = [
|
||||||
@@ -105,6 +106,7 @@ export default function ResourcesTable({
|
|||||||
resources,
|
resources,
|
||||||
isLoading,
|
isLoading,
|
||||||
headingTarget,
|
headingTarget,
|
||||||
|
isGroupPage,
|
||||||
}: Readonly<Props>) {
|
}: Readonly<Props>) {
|
||||||
const { permission } = usePermissions();
|
const { permission } = usePermissions();
|
||||||
const params = useSearchParams();
|
const params = useSearchParams();
|
||||||
@@ -112,6 +114,7 @@ export default function ResourcesTable({
|
|||||||
|
|
||||||
const [sorting, setSorting] = useState<SortingState>([]);
|
const [sorting, setSorting] = useState<SortingState>([]);
|
||||||
const { openResourceModal, network } = useNetworksContext();
|
const { openResourceModal, network } = useNetworksContext();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DataTable
|
<DataTable
|
||||||
@@ -137,29 +140,52 @@ export default function ResourcesTable({
|
|||||||
getStartedCard={
|
getStartedCard={
|
||||||
<NoResults
|
<NoResults
|
||||||
className={"py-4"}
|
className={"py-4"}
|
||||||
title={"This network has no resources"}
|
title={
|
||||||
|
isGroupPage
|
||||||
|
? "This group has no assigned resources"
|
||||||
|
: "This network has no resources"
|
||||||
|
}
|
||||||
description={
|
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} />}
|
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={{
|
columnVisibility={{
|
||||||
description: false,
|
description: false,
|
||||||
id: false,
|
id: false,
|
||||||
}}
|
}}
|
||||||
paginationPaddingClassName={"px-0 pt-8"}
|
paginationPaddingClassName={"px-0 pt-8"}
|
||||||
rightSide={() => (
|
rightSide={
|
||||||
<Button
|
!isGroupPage
|
||||||
variant={"primary"}
|
? () => (
|
||||||
className={"ml-auto"}
|
<Button
|
||||||
onClick={() => network && openResourceModal(network)}
|
variant={"primary"}
|
||||||
disabled={!permission.networks.update}
|
className={"ml-auto"}
|
||||||
>
|
onClick={() => network && openResourceModal(network)}
|
||||||
<IconCirclePlus size={16} />
|
disabled={!permission.networks.update}
|
||||||
Add Resource
|
>
|
||||||
</Button>
|
<IconCirclePlus size={16} />
|
||||||
)}
|
Add Resource
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{(table) => (
|
{(table) => (
|
||||||
<DataTableRowsPerPage
|
<DataTableRowsPerPage
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { useUsers } from "@/contexts/UsersProvider";
|
|||||||
import type { Peer } from "@/interfaces/Peer";
|
import type { Peer } from "@/interfaces/Peer";
|
||||||
|
|
||||||
const AccessiblePeersTable = lazy(
|
const AccessiblePeersTable = lazy(
|
||||||
() => import("@/modules/peer/AccessiblePeersTable"),
|
() => import("@/modules/peer/MinimalPeersTable"),
|
||||||
);
|
);
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
|
|||||||
@@ -5,11 +5,18 @@ import DataTableHeader from "@components/table/DataTableHeader";
|
|||||||
import DataTableRefreshButton from "@components/table/DataTableRefreshButton";
|
import DataTableRefreshButton from "@components/table/DataTableRefreshButton";
|
||||||
import { DataTableRowsPerPage } from "@components/table/DataTableRowsPerPage";
|
import { DataTableRowsPerPage } from "@components/table/DataTableRowsPerPage";
|
||||||
import NoResults from "@components/ui/NoResults";
|
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 * as React from "react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useSWRConfig } from "swr";
|
import { useSWRConfig } from "swr";
|
||||||
import PeerIcon from "@/assets/icons/PeerIcon";
|
import PeerIcon from "@/assets/icons/PeerIcon";
|
||||||
|
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||||
import { Peer } from "@/interfaces/Peer";
|
import { Peer } from "@/interfaces/Peer";
|
||||||
import PeerAddressCell from "@/modules/peers/PeerAddressCell";
|
import PeerAddressCell from "@/modules/peers/PeerAddressCell";
|
||||||
import PeerLastSeenCell from "@/modules/peers/PeerLastSeenCell";
|
import PeerLastSeenCell from "@/modules/peers/PeerLastSeenCell";
|
||||||
@@ -18,12 +25,18 @@ import { PeerOSCell } from "@/modules/peers/PeerOSCell";
|
|||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
peers?: Peer[];
|
peers?: Peer[];
|
||||||
peerID: string;
|
peerID?: string;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
headingTarget?: HTMLHeadingElement | null;
|
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",
|
accessorKey: "name",
|
||||||
header: ({ column }) => {
|
header: ({ column }) => {
|
||||||
@@ -73,13 +86,21 @@ const AccessiblePeersColumns: ColumnDef<Peer>[] = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function AccessiblePeersTable({
|
export default function MinimalPeersTable({
|
||||||
peers,
|
peers,
|
||||||
isLoading,
|
isLoading,
|
||||||
headingTarget,
|
headingTarget,
|
||||||
peerID,
|
peerID,
|
||||||
|
rightSide,
|
||||||
|
columns = MinimalPeersTableColumns,
|
||||||
|
selectedRows,
|
||||||
|
setSelectedRows,
|
||||||
|
onRowClick,
|
||||||
|
getStartedCard,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const { mutate } = useSWRConfig();
|
const { mutate } = useSWRConfig();
|
||||||
|
const { permission } = usePermissions();
|
||||||
|
|
||||||
// Default sorting state of the table
|
// Default sorting state of the table
|
||||||
const [sorting, setSorting] = useState<SortingState>([
|
const [sorting, setSorting] = useState<SortingState>([
|
||||||
{
|
{
|
||||||
@@ -104,27 +125,36 @@ export default function AccessiblePeersTable({
|
|||||||
useRowId={true}
|
useRowId={true}
|
||||||
sorting={sorting}
|
sorting={sorting}
|
||||||
setSorting={setSorting}
|
setSorting={setSorting}
|
||||||
|
rowSelection={selectedRows}
|
||||||
|
setRowSelection={setSelectedRows}
|
||||||
|
onRowClick={onRowClick}
|
||||||
minimal={true}
|
minimal={true}
|
||||||
showSearchAndFilters={true}
|
showSearchAndFilters={true}
|
||||||
inset={false}
|
inset={false}
|
||||||
tableClassName={"mt-0"}
|
tableClassName={"mt-0"}
|
||||||
text={"Peers"}
|
text={"Peers"}
|
||||||
columns={AccessiblePeersColumns}
|
columns={columns}
|
||||||
keepStateInLocalStorage={false}
|
keepStateInLocalStorage={false}
|
||||||
data={peers}
|
data={peers}
|
||||||
searchPlaceholder={"Search by name, IP, owner or group..."}
|
searchPlaceholder={"Search by name, IP, owner or group..."}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
getStartedCard={
|
getStartedCard={
|
||||||
<NoResults
|
!getStartedCard ? (
|
||||||
className={"py-4"}
|
<NoResults
|
||||||
title={"This peer has no accessible peers"}
|
className={"py-4"}
|
||||||
description={
|
title={"This peer has no accessible peers"}
|
||||||
"Add more peers to your network or check your access control policies."
|
description={
|
||||||
}
|
"Add more peers to your network or check your access control policies."
|
||||||
icon={<PeerIcon size={20} className={"fill-nb-gray-300"} />}
|
}
|
||||||
/>
|
icon={<PeerIcon size={20} className={"fill-nb-gray-300"} />}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
getStartedCard
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
rightSide={rightSide}
|
||||||
columnVisibility={{
|
columnVisibility={{
|
||||||
|
select: permission?.groups?.update && permission?.peers?.update,
|
||||||
connected: false,
|
connected: false,
|
||||||
ip: false,
|
ip: false,
|
||||||
user_name: false,
|
user_name: false,
|
||||||
@@ -200,7 +230,11 @@ export default function AccessiblePeersTable({
|
|||||||
isDisabled={peers?.length == 0}
|
isDisabled={peers?.length == 0}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
mutate("/users").then();
|
mutate("/users").then();
|
||||||
mutate(`/peers/${peerID}/accessible-peers`).then();
|
if (peerID) {
|
||||||
|
mutate(`/peers/${peerID}/accessible-peers`).then();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
mutate(`/peers`).then();
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
@@ -36,7 +36,12 @@ export default function PeerNameCell({ peer, linkToPeer = true }: Props) {
|
|||||||
)}
|
)}
|
||||||
data-testid="peer-name-cell"
|
data-testid="peer-name-cell"
|
||||||
aria-label={`View details of peer ${peer.name}`}
|
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
|
<ActiveInactiveRow
|
||||||
active={peer.connected}
|
active={peer.connected}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import Button from "@components/Button";
|
import Button from "@components/Button";
|
||||||
import ButtonGroup from "@components/ButtonGroup";
|
import ButtonGroup from "@components/ButtonGroup";
|
||||||
|
import Card from "@components/Card";
|
||||||
import InlineLink from "@components/InlineLink";
|
import InlineLink from "@components/InlineLink";
|
||||||
import SquareIcon from "@components/SquareIcon";
|
import SquareIcon from "@components/SquareIcon";
|
||||||
import { DataTable } from "@components/table/DataTable";
|
import { DataTable } from "@components/table/DataTable";
|
||||||
@@ -7,6 +8,7 @@ import DataTableHeader from "@components/table/DataTableHeader";
|
|||||||
import DataTableRefreshButton from "@components/table/DataTableRefreshButton";
|
import DataTableRefreshButton from "@components/table/DataTableRefreshButton";
|
||||||
import { DataTableRowsPerPage } from "@components/table/DataTableRowsPerPage";
|
import { DataTableRowsPerPage } from "@components/table/DataTableRowsPerPage";
|
||||||
import GetStartedTest from "@components/ui/GetStartedTest";
|
import GetStartedTest from "@components/ui/GetStartedTest";
|
||||||
|
import NoResults from "@components/ui/NoResults";
|
||||||
import { ColumnDef, SortingState } from "@tanstack/react-table";
|
import { ColumnDef, SortingState } from "@tanstack/react-table";
|
||||||
import { cloneDeep } from "lodash";
|
import { cloneDeep } from "lodash";
|
||||||
import { ExternalLinkIcon, PlusCircle } from "lucide-react";
|
import { ExternalLinkIcon, PlusCircle } from "lucide-react";
|
||||||
@@ -17,6 +19,7 @@ import NetworkRoutesIcon from "@/assets/icons/NetworkRoutesIcon";
|
|||||||
import GroupRouteProvider from "@/contexts/GroupRouteProvider";
|
import GroupRouteProvider from "@/contexts/GroupRouteProvider";
|
||||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||||
import { useLocalStorage } from "@/hooks/useLocalStorage";
|
import { useLocalStorage } from "@/hooks/useLocalStorage";
|
||||||
|
import { Group } from "@/interfaces/Group";
|
||||||
import { GroupedRoute, Route } from "@/interfaces/Route";
|
import { GroupedRoute, Route } from "@/interfaces/Route";
|
||||||
import { AddExitNodeButton } from "@/modules/exit-node/AddExitNodeButton";
|
import { AddExitNodeButton } from "@/modules/exit-node/AddExitNodeButton";
|
||||||
import GroupedRouteActionCell from "@/modules/route-group/GroupedRouteActionCell";
|
import GroupedRouteActionCell from "@/modules/route-group/GroupedRouteActionCell";
|
||||||
@@ -115,6 +118,8 @@ type Props = {
|
|||||||
groupedRoutes?: GroupedRoute[];
|
groupedRoutes?: GroupedRoute[];
|
||||||
routes?: Route[];
|
routes?: Route[];
|
||||||
headingTarget?: HTMLHeadingElement | null;
|
headingTarget?: HTMLHeadingElement | null;
|
||||||
|
isGroupPage?: boolean;
|
||||||
|
distributionGroups?: Group[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function NetworkRoutesTable({
|
export default function NetworkRoutesTable({
|
||||||
@@ -122,6 +127,8 @@ export default function NetworkRoutesTable({
|
|||||||
groupedRoutes,
|
groupedRoutes,
|
||||||
routes,
|
routes,
|
||||||
headingTarget,
|
headingTarget,
|
||||||
|
isGroupPage = false,
|
||||||
|
distributionGroups,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const { permission } = usePermissions();
|
const { permission } = usePermissions();
|
||||||
const { mutate } = useSWRConfig();
|
const { mutate } = useSWRConfig();
|
||||||
@@ -144,13 +151,18 @@ export default function NetworkRoutesTable({
|
|||||||
desc: true,
|
desc: true,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
!isGroupPage,
|
||||||
);
|
);
|
||||||
|
|
||||||
const [routeModal, setRouteModal] = useState(false);
|
const [routeModal, setRouteModal] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<RouteAddRoutingPeerProvider>
|
<RouteAddRoutingPeerProvider>
|
||||||
<RouteModal open={routeModal} setOpen={setRouteModal} />
|
<RouteModal
|
||||||
|
open={routeModal}
|
||||||
|
setOpen={setRouteModal}
|
||||||
|
distributionGroups={distributionGroups}
|
||||||
|
/>
|
||||||
<DataTable
|
<DataTable
|
||||||
headingTarget={headingTarget}
|
headingTarget={headingTarget}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
@@ -159,6 +171,13 @@ export default function NetworkRoutesTable({
|
|||||||
setSorting={setSorting}
|
setSorting={setSorting}
|
||||||
columns={GroupedRouteTableColumns}
|
columns={GroupedRouteTableColumns}
|
||||||
data={groupedRoutes}
|
data={groupedRoutes}
|
||||||
|
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}
|
||||||
searchPlaceholder={"Search by network, range, name or groups..."}
|
searchPlaceholder={"Search by network, range, name or groups..."}
|
||||||
columnVisibility={{
|
columnVisibility={{
|
||||||
enabled: false,
|
enabled: false,
|
||||||
@@ -178,23 +197,19 @@ export default function NetworkRoutesTable({
|
|||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
getStartedCard={
|
getStartedCard={
|
||||||
<GetStartedTest
|
isGroupPage ? (
|
||||||
icon={
|
<NoResults
|
||||||
<SquareIcon
|
icon={
|
||||||
icon={
|
<NetworkRoutesIcon className={"fill-nb-gray-200"} size={20} />
|
||||||
<NetworkRoutesIcon className={"fill-nb-gray-200"} size={20} />
|
}
|
||||||
}
|
className={"py-4"}
|
||||||
color={"gray"}
|
title={"This group is not used within any network routes yet"}
|
||||||
size={"large"}
|
description={
|
||||||
/>
|
"Assign this group when creating a new route to see them listed here."
|
||||||
}
|
}
|
||||||
title={"Create New Route"}
|
>
|
||||||
description={
|
<div className={"gap-x-4 flex items-center justify-center mt-4"}>
|
||||||
"It looks like you don't have any routes. Access LANs and VPC by adding a network route."
|
<AddExitNodeButton distributionGroups={distributionGroups} />
|
||||||
}
|
|
||||||
button={
|
|
||||||
<div className={"gap-x-4 flex items-center justify-center"}>
|
|
||||||
<AddExitNodeButton />
|
|
||||||
<Button
|
<Button
|
||||||
variant={"primary"}
|
variant={"primary"}
|
||||||
className={""}
|
className={""}
|
||||||
@@ -205,28 +220,61 @@ export default function NetworkRoutesTable({
|
|||||||
Add Route
|
Add Route
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
}
|
</NoResults>
|
||||||
learnMore={
|
) : (
|
||||||
<>
|
<GetStartedTest
|
||||||
Learn more about
|
icon={
|
||||||
<InlineLink
|
<SquareIcon
|
||||||
href={
|
icon={
|
||||||
"https://docs.netbird.io/how-to/routing-traffic-to-private-networks"
|
<NetworkRoutesIcon
|
||||||
|
className={"fill-nb-gray-200"}
|
||||||
|
size={20}
|
||||||
|
/>
|
||||||
}
|
}
|
||||||
target={"_blank"}
|
color={"gray"}
|
||||||
>
|
size={"large"}
|
||||||
Network Routes
|
/>
|
||||||
<ExternalLinkIcon size={12} />
|
}
|
||||||
</InlineLink>
|
title={"Create New Route"}
|
||||||
</>
|
description={
|
||||||
}
|
"It looks like you don't have any routes. Access LANs and VPC by adding a network route."
|
||||||
/>
|
}
|
||||||
|
button={
|
||||||
|
<div className={"gap-x-4 flex items-center justify-center"}>
|
||||||
|
<AddExitNodeButton distributionGroups={distributionGroups} />
|
||||||
|
<Button
|
||||||
|
variant={"primary"}
|
||||||
|
className={""}
|
||||||
|
onClick={() => setRouteModal(true)}
|
||||||
|
disabled={!permission.routes.create}
|
||||||
|
>
|
||||||
|
<PlusCircle size={16} />
|
||||||
|
Add Route
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
learnMore={
|
||||||
|
<>
|
||||||
|
Learn more about
|
||||||
|
<InlineLink
|
||||||
|
href={
|
||||||
|
"https://docs.netbird.io/how-to/routing-traffic-to-private-networks"
|
||||||
|
}
|
||||||
|
target={"_blank"}
|
||||||
|
>
|
||||||
|
Network Routes
|
||||||
|
<ExternalLinkIcon size={12} />
|
||||||
|
</InlineLink>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
rightSide={() => (
|
rightSide={() => (
|
||||||
<>
|
<>
|
||||||
{routes && routes?.length > 0 && (
|
{routes && routes?.length > 0 && (
|
||||||
<div className={"gap-x-4 ml-auto flex"}>
|
<div className={"gap-x-4 ml-auto flex"}>
|
||||||
<AddExitNodeButton />
|
<AddExitNodeButton distributionGroups={distributionGroups} />
|
||||||
<Button
|
<Button
|
||||||
variant={"primary"}
|
variant={"primary"}
|
||||||
className={""}
|
className={""}
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ import React, { useEffect, useMemo, useReducer, useRef, useState } from "react";
|
|||||||
import NetworkRoutesIcon from "@/assets/icons/NetworkRoutesIcon";
|
import NetworkRoutesIcon from "@/assets/icons/NetworkRoutesIcon";
|
||||||
import { useDialog } from "@/contexts/DialogProvider";
|
import { useDialog } from "@/contexts/DialogProvider";
|
||||||
import { useRoutes } from "@/contexts/RoutesProvider";
|
import { useRoutes } from "@/contexts/RoutesProvider";
|
||||||
|
import { Group } from "@/interfaces/Group";
|
||||||
import { OperatingSystem } from "@/interfaces/OperatingSystem";
|
import { OperatingSystem } from "@/interfaces/OperatingSystem";
|
||||||
import { Peer } from "@/interfaces/Peer";
|
import { Peer } from "@/interfaces/Peer";
|
||||||
import { Policy } from "@/interfaces/Policy";
|
import { Policy } from "@/interfaces/Policy";
|
||||||
@@ -61,9 +62,15 @@ type Props = {
|
|||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
open?: boolean;
|
open?: boolean;
|
||||||
setOpen?: (open: boolean) => void;
|
setOpen?: (open: boolean) => void;
|
||||||
|
distributionGroups?: Group[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RouteModal({ children, open, setOpen }: Props) {
|
export default function RouteModal({
|
||||||
|
children,
|
||||||
|
open,
|
||||||
|
setOpen,
|
||||||
|
distributionGroups,
|
||||||
|
}: Props) {
|
||||||
const { confirm } = useDialog();
|
const { confirm } = useDialog();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [routePolicyModal, setRoutePolicyModal] = useState(false);
|
const [routePolicyModal, setRoutePolicyModal] = useState(false);
|
||||||
@@ -116,6 +123,7 @@ export default function RouteModal({ children, open, setOpen }: Props) {
|
|||||||
await handleCreatePolicyPrompt(r);
|
await handleCreatePolicyPrompt(r);
|
||||||
setOpen?.(false);
|
setOpen?.(false);
|
||||||
}}
|
}}
|
||||||
|
distributionGroups={distributionGroups}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Modal>
|
</Modal>
|
||||||
@@ -139,6 +147,7 @@ type ModalProps = {
|
|||||||
peer?: Peer;
|
peer?: Peer;
|
||||||
exitNode?: boolean;
|
exitNode?: boolean;
|
||||||
isFirstExitNode?: boolean;
|
isFirstExitNode?: boolean;
|
||||||
|
distributionGroups?: Group[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export function RouteModalContent({
|
export function RouteModalContent({
|
||||||
@@ -146,6 +155,7 @@ export function RouteModalContent({
|
|||||||
peer,
|
peer,
|
||||||
exitNode,
|
exitNode,
|
||||||
isFirstExitNode = false,
|
isFirstExitNode = false,
|
||||||
|
distributionGroups,
|
||||||
}: ModalProps) {
|
}: ModalProps) {
|
||||||
const { createRoute } = useRoutes();
|
const { createRoute } = useRoutes();
|
||||||
const [tab, setTab] = useState(
|
const [tab, setTab] = useState(
|
||||||
@@ -207,7 +217,7 @@ export function RouteModalContent({
|
|||||||
* Distribution Groups
|
* Distribution Groups
|
||||||
*/
|
*/
|
||||||
const [groups, setGroups, { getGroupsToUpdate }] = useGroupHelper({
|
const [groups, setGroups, { getGroupsToUpdate }] = useGroupHelper({
|
||||||
initial: [],
|
initial: distributionGroups ?? [],
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -721,17 +731,19 @@ export function RouteModalContent({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{exitNode && (
|
{exitNode && (
|
||||||
<FancyToggleSwitch
|
<FancyToggleSwitch
|
||||||
value={isForced}
|
value={isForced}
|
||||||
onChange={setIsForced}
|
onChange={setIsForced}
|
||||||
label={
|
label={
|
||||||
<>
|
<>
|
||||||
<IconDirectionSign size={15} />
|
<IconDirectionSign size={15} />
|
||||||
Auto Apply Route
|
Auto Apply Route
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
helpText={"Automatically apply this exit node to your distribution groups. This requires NetBird client v0.55.0 or higher."}
|
helpText={
|
||||||
/>
|
"Automatically apply this exit node to your distribution groups. This requires NetBird client v0.55.0 or higher."
|
||||||
|
}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!exitNode && (
|
{!exitNode && (
|
||||||
|
|||||||
@@ -1,89 +0,0 @@
|
|||||||
import Button from "@components/Button";
|
|
||||||
import FullTooltip from "@components/FullTooltip";
|
|
||||||
import { notify } from "@components/Notification";
|
|
||||||
import { useApiCall } from "@utils/api";
|
|
||||||
import { Trash2 } from "lucide-react";
|
|
||||||
import React, { useMemo } from "react";
|
|
||||||
import { useSWRConfig } from "swr";
|
|
||||||
import { useDialog } from "@/contexts/DialogProvider";
|
|
||||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
|
||||||
import { SetupKey } from "@/interfaces/SetupKey";
|
|
||||||
import { useGroupIdentification } from "@/modules/groups/useGroupIdentification";
|
|
||||||
import { GroupUsage } from "@/modules/settings/useGroupsUsage";
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
group: GroupUsage;
|
|
||||||
in_use: boolean;
|
|
||||||
};
|
|
||||||
export default function GroupsActionCell({ group, in_use }: Readonly<Props>) {
|
|
||||||
const { permission } = usePermissions();
|
|
||||||
const { confirm } = useDialog();
|
|
||||||
const deleteRequest = useApiCall<SetupKey>("/groups/" + group.id);
|
|
||||||
const { mutate } = useSWRConfig();
|
|
||||||
|
|
||||||
const handleRevoke = async () => {
|
|
||||||
notify({
|
|
||||||
title: "Group: " + group.name,
|
|
||||||
description: "Group was successfully deleted.",
|
|
||||||
promise: deleteRequest.del().then(() => {
|
|
||||||
mutate("/groups");
|
|
||||||
}),
|
|
||||||
loadingMessage: "Deleting the group...",
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleConfirm = 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;
|
|
||||||
handleRevoke().then();
|
|
||||||
};
|
|
||||||
|
|
||||||
const { isRegularGroup, isJWTGroup } = useGroupIdentification({
|
|
||||||
id: group?.id,
|
|
||||||
issued: group?.issued,
|
|
||||||
});
|
|
||||||
|
|
||||||
const isDisabled = useMemo(() => {
|
|
||||||
if (!permission.groups.delete) return true;
|
|
||||||
if (in_use) return true;
|
|
||||||
if (isJWTGroup) return false;
|
|
||||||
return !isRegularGroup;
|
|
||||||
}, [permission, in_use, isJWTGroup, isRegularGroup]);
|
|
||||||
|
|
||||||
const getDisabledText = () => {
|
|
||||||
if (isRegularGroup) {
|
|
||||||
return "Remove dependencies to this group to delete it.";
|
|
||||||
} else if (isJWTGroup) {
|
|
||||||
return "This group is issued by JWT and cannot be deleted.";
|
|
||||||
} else {
|
|
||||||
return "This group is issued by an IdP and cannot be deleted";
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={"flex justify-end pr-4"}>
|
|
||||||
<FullTooltip
|
|
||||||
content={<div className={"text-xs max-w-xs"}>{getDisabledText()}</div>}
|
|
||||||
interactive={false}
|
|
||||||
disabled={!isDisabled}
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
variant={"danger-outline"}
|
|
||||||
size={"sm"}
|
|
||||||
onClick={handleConfirm}
|
|
||||||
disabled={isDisabled}
|
|
||||||
>
|
|
||||||
<Trash2 size={16} />
|
|
||||||
Delete
|
|
||||||
</Button>
|
|
||||||
</FullTooltip>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
import Badge from "@components/Badge";
|
|
||||||
import FullTooltip from "@components/FullTooltip";
|
|
||||||
import { cn } from "@utils/helpers";
|
|
||||||
import React from "react";
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
icon: React.ReactNode;
|
|
||||||
count: number;
|
|
||||||
groupName: string;
|
|
||||||
text?: string;
|
|
||||||
};
|
|
||||||
export default function GroupsCountCell({
|
|
||||||
icon,
|
|
||||||
count,
|
|
||||||
groupName,
|
|
||||||
text,
|
|
||||||
}: Props) {
|
|
||||||
return (
|
|
||||||
<FullTooltip
|
|
||||||
className={"w-full"}
|
|
||||||
content={
|
|
||||||
<div className={"text-sm"}>
|
|
||||||
Group <span className={"text-netbird font-medium"}>{groupName}</span>{" "}
|
|
||||||
is used in <span className={"font-medium text-netbird"}>{count}</span>{" "}
|
|
||||||
{text}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Badge
|
|
||||||
variant={"gray"}
|
|
||||||
className={cn("gap-2 w-full", count === 0 && "opacity-30")}
|
|
||||||
>
|
|
||||||
{icon}
|
|
||||||
{count}
|
|
||||||
</Badge>
|
|
||||||
</FullTooltip>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
import { GroupBadgeIcon } from "@components/ui/GroupBadgeIcon";
|
|
||||||
import TextWithTooltip from "@components/ui/TextWithTooltip";
|
|
||||||
import { cn } from "@utils/helpers";
|
|
||||||
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>) {
|
|
||||||
return (
|
|
||||||
<div className={cn("gap-3 dark:text-neutral-300 text-neutral-500 min-w-0")}>
|
|
||||||
<div className={"flex flex-col gap-1"}>
|
|
||||||
<div className={"flex gap-2.5 items-center"}>
|
|
||||||
<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"}>
|
|
||||||
<div
|
|
||||||
className={"font-medium flex gap-2 items-center justify-center"}
|
|
||||||
>
|
|
||||||
<TextWithTooltip text={group?.name} maxChars={25} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<CircleIcon
|
|
||||||
size={8}
|
|
||||||
active={active}
|
|
||||||
inactiveDot={"gray"}
|
|
||||||
className={"shrink-0"}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -5,10 +5,6 @@ import HelpText from "@components/HelpText";
|
|||||||
import { Input } from "@components/Input";
|
import { Input } from "@components/Input";
|
||||||
import { Label } from "@components/Label";
|
import { Label } from "@components/Label";
|
||||||
import { notify } from "@components/Notification";
|
import { notify } from "@components/Notification";
|
||||||
import Paragraph from "@components/Paragraph";
|
|
||||||
import Separator from "@components/Separator";
|
|
||||||
import SkeletonTable from "@components/skeletons/SkeletonTable";
|
|
||||||
import { usePortalElement } from "@hooks/usePortalElement";
|
|
||||||
import * as Tabs from "@radix-ui/react-tabs";
|
import * as Tabs from "@radix-ui/react-tabs";
|
||||||
import { useApiCall } from "@utils/api";
|
import { useApiCall } from "@utils/api";
|
||||||
import { cn } from "@utils/helpers";
|
import { cn } from "@utils/helpers";
|
||||||
@@ -24,7 +20,7 @@ import {
|
|||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
X,
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import React, { lazy, Suspense, useState } from "react";
|
import React, { useState } from "react";
|
||||||
import { useSWRConfig } from "swr";
|
import { useSWRConfig } from "swr";
|
||||||
import SettingsIcon from "@/assets/icons/SettingsIcon";
|
import SettingsIcon from "@/assets/icons/SettingsIcon";
|
||||||
import Badge from "@/components/Badge";
|
import Badge from "@/components/Badge";
|
||||||
@@ -33,13 +29,12 @@ import { usePermissions } from "@/contexts/PermissionsProvider";
|
|||||||
import { useHasChanges } from "@/hooks/useHasChanges";
|
import { useHasChanges } from "@/hooks/useHasChanges";
|
||||||
import { Account } from "@/interfaces/Account";
|
import { Account } from "@/interfaces/Account";
|
||||||
|
|
||||||
const GroupsTable = lazy(() => import("@/modules/settings/GroupsTable"));
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
account: Account;
|
account: Account;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function GroupsTab({ account }: Props) {
|
export default function GroupsSettings({ account }: Props) {
|
||||||
const { permission } = usePermissions();
|
const { permission } = usePermissions();
|
||||||
|
|
||||||
const { mutate } = useSWRConfig();
|
const { mutate } = useSWRConfig();
|
||||||
@@ -87,28 +82,22 @@ export default function GroupsTab({ account }: Props) {
|
|||||||
const showConfirm = jwtGroupSync && jwtGroupsEntered;
|
const showConfirm = jwtGroupSync && jwtGroupsEntered;
|
||||||
const choice = showConfirm
|
const choice = showConfirm
|
||||||
? await confirm({
|
? await confirm({
|
||||||
title: `JWT allow group${
|
title: `JWT allow group - ${jwtAllowGroups[0]}`,
|
||||||
jwtAllowGroups.length > 1 ? "s" : ""
|
description: `Only users part of the ${jwtAllowGroups[0]} group will be able to access NetBird. Are you sure you want to save the changes?`,
|
||||||
} - ${jwtAllowGroups.join(", ")}`,
|
confirmText: "Save",
|
||||||
description: `Only users part of ${
|
children: (
|
||||||
jwtAllowGroups.length > 1
|
<div
|
||||||
? `these groups (${jwtAllowGroups.join(", ")})`
|
className={
|
||||||
: `the ${jwtAllowGroups[0]} group`
|
"flex gap-2 items-center text-xs bg-netbird-950 px-4 justify-center py-3 rounded-md border border-netbird-500 text-netbird-200"
|
||||||
} will be able to access NetBird. Are you sure you want to save the changes?`,
|
}
|
||||||
confirmText: "Save",
|
>
|
||||||
children: (
|
<AlertCircle size={14} />
|
||||||
<div
|
To prevent losing access, ensure you are part of this group.
|
||||||
className={
|
</div>
|
||||||
"flex gap-2 items-center text-xs bg-netbird-950 px-4 justify-center py-3 rounded-md border border-netbird-500 text-netbird-200"
|
),
|
||||||
}
|
cancelText: "Cancel",
|
||||||
>
|
type: "default",
|
||||||
<AlertCircle size={14} />
|
})
|
||||||
To prevent losing access, ensure you are part of this group.
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
cancelText: "Cancel",
|
|
||||||
type: "default",
|
|
||||||
})
|
|
||||||
: true;
|
: true;
|
||||||
|
|
||||||
if (!choice) return;
|
if (!choice) return;
|
||||||
@@ -323,36 +312,6 @@ export default function GroupsTab({ account }: Props) {
|
|||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<GroupsSection />
|
|
||||||
</Tabs.Content>
|
</Tabs.Content>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const GroupsSection = () => {
|
|
||||||
const { ref: headingRef, portalTarget } =
|
|
||||||
usePortalElement<HTMLHeadingElement>();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Separator />
|
|
||||||
<div className={"px-8 py-6"}>
|
|
||||||
<div className={"max-w-6xl"}>
|
|
||||||
<div className={"flex justify-between items-center"}>
|
|
||||||
<div>
|
|
||||||
<h2 ref={headingRef}>Groups</h2>
|
|
||||||
<Paragraph>
|
|
||||||
Here is the overview of the groups of your account. You can
|
|
||||||
delete the unused ones.
|
|
||||||
</Paragraph>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className={"pb-10"}>
|
|
||||||
<Suspense fallback={<SkeletonTable />}>
|
|
||||||
<GroupsTable headingTarget={portalTarget} />
|
|
||||||
</Suspense>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -33,6 +33,7 @@ import {
|
|||||||
import React, { useMemo, useState } from "react";
|
import React, { useMemo, useState } from "react";
|
||||||
import { useSWRConfig } from "swr";
|
import { useSWRConfig } from "swr";
|
||||||
import SetupKeysIcon from "@/assets/icons/SetupKeysIcon";
|
import SetupKeysIcon from "@/assets/icons/SetupKeysIcon";
|
||||||
|
import { Group } from "@/interfaces/Group";
|
||||||
import { SetupKey } from "@/interfaces/SetupKey";
|
import { SetupKey } from "@/interfaces/SetupKey";
|
||||||
import useGroupHelper from "@/modules/groups/useGroupHelper";
|
import useGroupHelper from "@/modules/groups/useGroupHelper";
|
||||||
import SetupModal from "@/modules/setup-netbird-modal/SetupModal";
|
import SetupModal from "@/modules/setup-netbird-modal/SetupModal";
|
||||||
@@ -43,6 +44,7 @@ type Props = {
|
|||||||
setOpen: (open: boolean) => void;
|
setOpen: (open: boolean) => void;
|
||||||
name?: string;
|
name?: string;
|
||||||
showOnlyRoutingPeerOS?: boolean;
|
showOnlyRoutingPeerOS?: boolean;
|
||||||
|
groups?: Group[];
|
||||||
};
|
};
|
||||||
|
|
||||||
const copyMessage = "Setup-Key was copied to your clipboard!";
|
const copyMessage = "Setup-Key was copied to your clipboard!";
|
||||||
@@ -53,6 +55,7 @@ export default function SetupKeyModal({
|
|||||||
setOpen,
|
setOpen,
|
||||||
name,
|
name,
|
||||||
showOnlyRoutingPeerOS,
|
showOnlyRoutingPeerOS,
|
||||||
|
groups,
|
||||||
}: Readonly<Props>) {
|
}: Readonly<Props>) {
|
||||||
const [successModal, setSuccessModal] = useState(false);
|
const [successModal, setSuccessModal] = useState(false);
|
||||||
const [setupKey, setSetupKey] = useState<SetupKey>();
|
const [setupKey, setSetupKey] = useState<SetupKey>();
|
||||||
@@ -66,7 +69,11 @@ export default function SetupKeyModal({
|
|||||||
<>
|
<>
|
||||||
<Modal open={open} onOpenChange={setOpen} key={open ? 1 : 0}>
|
<Modal open={open} onOpenChange={setOpen} key={open ? 1 : 0}>
|
||||||
{children && <ModalTrigger asChild>{children}</ModalTrigger>}
|
{children && <ModalTrigger asChild>{children}</ModalTrigger>}
|
||||||
<SetupKeyModalContent onSuccess={handleSuccess} predefinedName={name} />
|
<SetupKeyModalContent
|
||||||
|
onSuccess={handleSuccess}
|
||||||
|
predefinedName={name}
|
||||||
|
groups={groups}
|
||||||
|
/>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
@@ -155,11 +162,13 @@ export default function SetupKeyModal({
|
|||||||
type ModalProps = {
|
type ModalProps = {
|
||||||
onSuccess?: (setupKey: SetupKey) => void;
|
onSuccess?: (setupKey: SetupKey) => void;
|
||||||
predefinedName?: string;
|
predefinedName?: string;
|
||||||
|
groups?: Group[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export function SetupKeyModalContent({
|
export function SetupKeyModalContent({
|
||||||
onSuccess,
|
onSuccess,
|
||||||
predefinedName = "",
|
predefinedName = "",
|
||||||
|
groups,
|
||||||
}: Readonly<ModalProps>) {
|
}: Readonly<ModalProps>) {
|
||||||
const setupKeyRequest = useApiCall<SetupKey>("/setup-keys", true);
|
const setupKeyRequest = useApiCall<SetupKey>("/setup-keys", true);
|
||||||
const { mutate } = useSWRConfig();
|
const { mutate } = useSWRConfig();
|
||||||
@@ -173,7 +182,7 @@ export function SetupKeyModalContent({
|
|||||||
|
|
||||||
const [selectedGroups, setSelectedGroups, { save: saveGroups }] =
|
const [selectedGroups, setSelectedGroups, { save: saveGroups }] =
|
||||||
useGroupHelper({
|
useGroupHelper({
|
||||||
initial: [],
|
initial: groups ?? [],
|
||||||
});
|
});
|
||||||
|
|
||||||
const usageLimitPlaceholder = useMemo(() => {
|
const usageLimitPlaceholder = useMemo(() => {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import Button from "@components/Button";
|
import Button from "@components/Button";
|
||||||
import ButtonGroup from "@components/ButtonGroup";
|
import ButtonGroup from "@components/ButtonGroup";
|
||||||
|
import Card from "@components/Card";
|
||||||
import InlineLink from "@components/InlineLink";
|
import InlineLink from "@components/InlineLink";
|
||||||
import SquareIcon from "@components/SquareIcon";
|
import SquareIcon from "@components/SquareIcon";
|
||||||
import { DataTable } from "@components/table/DataTable";
|
import { DataTable } from "@components/table/DataTable";
|
||||||
@@ -7,6 +8,7 @@ import DataTableHeader from "@components/table/DataTableHeader";
|
|||||||
import DataTableRefreshButton from "@components/table/DataTableRefreshButton";
|
import DataTableRefreshButton from "@components/table/DataTableRefreshButton";
|
||||||
import { DataTableRowsPerPage } from "@components/table/DataTableRowsPerPage";
|
import { DataTableRowsPerPage } from "@components/table/DataTableRowsPerPage";
|
||||||
import GetStartedTest from "@components/ui/GetStartedTest";
|
import GetStartedTest from "@components/ui/GetStartedTest";
|
||||||
|
import NoResults from "@components/ui/NoResults";
|
||||||
import { ColumnDef, SortingState } from "@tanstack/react-table";
|
import { ColumnDef, SortingState } from "@tanstack/react-table";
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
import { ExternalLinkIcon, PlusCircle } from "lucide-react";
|
import { ExternalLinkIcon, PlusCircle } from "lucide-react";
|
||||||
@@ -16,6 +18,7 @@ import { useSWRConfig } from "swr";
|
|||||||
import SetupKeysIcon from "@/assets/icons/SetupKeysIcon";
|
import SetupKeysIcon from "@/assets/icons/SetupKeysIcon";
|
||||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||||
import { useLocalStorage } from "@/hooks/useLocalStorage";
|
import { useLocalStorage } from "@/hooks/useLocalStorage";
|
||||||
|
import { Group } from "@/interfaces/Group";
|
||||||
import { SetupKey } from "@/interfaces/SetupKey";
|
import { SetupKey } from "@/interfaces/SetupKey";
|
||||||
import EmptyRow from "@/modules/common-table-rows/EmptyRow";
|
import EmptyRow from "@/modules/common-table-rows/EmptyRow";
|
||||||
import ExpirationDateRow from "@/modules/common-table-rows/ExpirationDateRow";
|
import ExpirationDateRow from "@/modules/common-table-rows/ExpirationDateRow";
|
||||||
@@ -118,12 +121,16 @@ type Props = {
|
|||||||
setupKeys?: SetupKey[];
|
setupKeys?: SetupKey[];
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
headingTarget?: HTMLHeadingElement | null;
|
headingTarget?: HTMLHeadingElement | null;
|
||||||
|
isGroupPage?: boolean;
|
||||||
|
groups?: Group[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function SetupKeysTable({
|
export default function SetupKeysTable({
|
||||||
setupKeys,
|
setupKeys,
|
||||||
isLoading,
|
isLoading,
|
||||||
headingTarget,
|
headingTarget,
|
||||||
|
isGroupPage,
|
||||||
|
groups,
|
||||||
}: Readonly<Props>) {
|
}: Readonly<Props>) {
|
||||||
const { mutate } = useSWRConfig();
|
const { mutate } = useSWRConfig();
|
||||||
const path = usePathname();
|
const path = usePathname();
|
||||||
@@ -146,16 +153,24 @@ export default function SetupKeysTable({
|
|||||||
desc: true,
|
desc: true,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
!isGroupPage,
|
||||||
);
|
);
|
||||||
|
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{open && <SetupKeyModal open={open} setOpen={setOpen} />}
|
{open && <SetupKeyModal open={open} setOpen={setOpen} groups={groups} />}
|
||||||
<DataTable
|
<DataTable
|
||||||
headingTarget={headingTarget}
|
headingTarget={headingTarget}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
|
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}
|
||||||
text={"Setup Keys"}
|
text={"Setup Keys"}
|
||||||
sorting={sorting}
|
sorting={sorting}
|
||||||
setSorting={setSorting}
|
setSorting={setSorting}
|
||||||
@@ -167,46 +182,67 @@ export default function SetupKeysTable({
|
|||||||
group_strings: false,
|
group_strings: false,
|
||||||
}}
|
}}
|
||||||
getStartedCard={
|
getStartedCard={
|
||||||
<GetStartedTest
|
isGroupPage ? (
|
||||||
icon={
|
<NoResults
|
||||||
<SquareIcon
|
icon={<SetupKeysIcon className={"fill-nb-gray-200"} size={20} />}
|
||||||
icon={
|
className={"py-4"}
|
||||||
<SetupKeysIcon className={"fill-nb-gray-200"} size={20} />
|
title={"This group is not used within any setup keys yet"}
|
||||||
}
|
description={
|
||||||
color={"gray"}
|
"Assign this group when creating a new setup key to see them listed here."
|
||||||
size={"large"}
|
}
|
||||||
/>
|
>
|
||||||
}
|
|
||||||
title={"Create Setup Key"}
|
|
||||||
description={
|
|
||||||
"Add a setup key to register new machines in your network. The key links machines to your account during initial setup."
|
|
||||||
}
|
|
||||||
button={
|
|
||||||
<Button
|
<Button
|
||||||
variant={"primary"}
|
variant={"primary"}
|
||||||
className={""}
|
className={"mt-4"}
|
||||||
onClick={() => setOpen(true)}
|
onClick={() => setOpen(true)}
|
||||||
disabled={!permission.setup_keys.create}
|
disabled={!permission.setup_keys.create}
|
||||||
>
|
>
|
||||||
<PlusCircle size={16} />
|
<PlusCircle size={16} />
|
||||||
Create Setup Key
|
Create Setup Key
|
||||||
</Button>
|
</Button>
|
||||||
}
|
</NoResults>
|
||||||
learnMore={
|
) : (
|
||||||
<>
|
<GetStartedTest
|
||||||
Learn more about
|
icon={
|
||||||
<InlineLink
|
<SquareIcon
|
||||||
href={
|
icon={
|
||||||
"https://docs.netbird.io/how-to/register-machines-using-setup-keys"
|
<SetupKeysIcon className={"fill-nb-gray-200"} size={20} />
|
||||||
}
|
}
|
||||||
target={"_blank"}
|
color={"gray"}
|
||||||
|
size={"large"}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
title={"Create Setup Key"}
|
||||||
|
description={
|
||||||
|
"Add a setup key to register new machines in your network. The key links machines to your account during initial setup."
|
||||||
|
}
|
||||||
|
button={
|
||||||
|
<Button
|
||||||
|
variant={"primary"}
|
||||||
|
className={""}
|
||||||
|
onClick={() => setOpen(true)}
|
||||||
|
disabled={!permission.setup_keys.create}
|
||||||
>
|
>
|
||||||
Setup Keys
|
<PlusCircle size={16} />
|
||||||
<ExternalLinkIcon size={12} />
|
Create Setup Key
|
||||||
</InlineLink>
|
</Button>
|
||||||
</>
|
}
|
||||||
}
|
learnMore={
|
||||||
/>
|
<>
|
||||||
|
Learn more about
|
||||||
|
<InlineLink
|
||||||
|
href={
|
||||||
|
"https://docs.netbird.io/how-to/register-machines-using-setup-keys"
|
||||||
|
}
|
||||||
|
target={"_blank"}
|
||||||
|
>
|
||||||
|
Setup Keys
|
||||||
|
<ExternalLinkIcon size={12} />
|
||||||
|
</InlineLink>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
rightSide={() => (
|
rightSide={() => (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -22,15 +22,17 @@ import Avatar1 from "@/assets/avatars/009.jpg";
|
|||||||
import Avatar2 from "@/assets/avatars/030.jpg";
|
import Avatar2 from "@/assets/avatars/030.jpg";
|
||||||
import Avatar3 from "@/assets/avatars/063.jpg";
|
import Avatar3 from "@/assets/avatars/063.jpg";
|
||||||
import Avatar4 from "@/assets/avatars/086.jpg";
|
import Avatar4 from "@/assets/avatars/086.jpg";
|
||||||
|
import { Group } from "@/interfaces/Group";
|
||||||
import { Role, User } from "@/interfaces/User";
|
import { Role, User } from "@/interfaces/User";
|
||||||
import useGroupHelper from "@/modules/groups/useGroupHelper";
|
import useGroupHelper from "@/modules/groups/useGroupHelper";
|
||||||
import { UserRoleSelector } from "@/modules/users/UserRoleSelector";
|
import { UserRoleSelector } from "@/modules/users/UserRoleSelector";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
|
groups?: Group[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function UserInviteModal({ children }: Readonly<Props>) {
|
export default function UserInviteModal({ children, groups }: Readonly<Props>) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const { mutate } = useSWRConfig();
|
const { mutate } = useSWRConfig();
|
||||||
|
|
||||||
@@ -44,16 +46,20 @@ export default function UserInviteModal({ children }: Readonly<Props>) {
|
|||||||
return (
|
return (
|
||||||
<Modal open={open} onOpenChange={setOpen} key={open ? 1 : 0}>
|
<Modal open={open} onOpenChange={setOpen} key={open ? 1 : 0}>
|
||||||
<ModalTrigger asChild={true}>{children}</ModalTrigger>
|
<ModalTrigger asChild={true}>{children}</ModalTrigger>
|
||||||
<UserInviteModalContent onSuccess={handleOnSuccess} />
|
<UserInviteModalContent onSuccess={handleOnSuccess} groups={groups} />
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
type ModalProps = {
|
type ModalProps = {
|
||||||
onSuccess: () => void;
|
onSuccess: () => void;
|
||||||
|
groups?: Group[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export function UserInviteModalContent({ onSuccess }: Readonly<ModalProps>) {
|
export function UserInviteModalContent({
|
||||||
|
onSuccess,
|
||||||
|
groups = [],
|
||||||
|
}: Readonly<ModalProps>) {
|
||||||
const userRequest = useApiCall<User>("/users");
|
const userRequest = useApiCall<User>("/users");
|
||||||
const { mutate } = useSWRConfig();
|
const { mutate } = useSWRConfig();
|
||||||
|
|
||||||
@@ -62,7 +68,7 @@ export function UserInviteModalContent({ onSuccess }: Readonly<ModalProps>) {
|
|||||||
const [role, setRole] = useState("user");
|
const [role, setRole] = useState("user");
|
||||||
const [selectedGroups, setSelectedGroups, { save: saveGroups }] =
|
const [selectedGroups, setSelectedGroups, { save: saveGroups }] =
|
||||||
useGroupHelper({
|
useGroupHelper({
|
||||||
initial: [],
|
initial: groups,
|
||||||
});
|
});
|
||||||
|
|
||||||
const sendInvite = async () => {
|
const sendInvite = async () => {
|
||||||
@@ -95,7 +101,7 @@ export function UserInviteModalContent({ onSuccess }: Readonly<ModalProps>) {
|
|||||||
}, [name, isValidEmail]);
|
}, [name, isValidEmail]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ModalContent maxWidthClass={"max-w-md relative"} showClose={true}>
|
<ModalContent maxWidthClass={"max-w-lg relative"} showClose={true}>
|
||||||
<div
|
<div
|
||||||
className={
|
className={
|
||||||
"h-full w-full absolute left-0 top-0 rounded-md overflow-hidden z-0"
|
"h-full w-full absolute left-0 top-0 rounded-md overflow-hidden z-0"
|
||||||
@@ -161,6 +167,8 @@ export function UserInviteModalContent({ onSuccess }: Readonly<ModalProps>) {
|
|||||||
<PeerGroupSelector
|
<PeerGroupSelector
|
||||||
onChange={setSelectedGroups}
|
onChange={setSelectedGroups}
|
||||||
values={selectedGroups}
|
values={selectedGroups}
|
||||||
|
showResources={false}
|
||||||
|
showRoutes={false}
|
||||||
hideAllGroup={true}
|
hideAllGroup={true}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import Button from "@components/Button";
|
import Button from "@components/Button";
|
||||||
|
import Card from "@components/Card";
|
||||||
import InlineLink from "@components/InlineLink";
|
import InlineLink from "@components/InlineLink";
|
||||||
import SquareIcon from "@components/SquareIcon";
|
import SquareIcon from "@components/SquareIcon";
|
||||||
import { DataTable } from "@components/table/DataTable";
|
import { DataTable } from "@components/table/DataTable";
|
||||||
@@ -6,8 +7,13 @@ import DataTableHeader from "@components/table/DataTableHeader";
|
|||||||
import DataTableRefreshButton from "@components/table/DataTableRefreshButton";
|
import DataTableRefreshButton from "@components/table/DataTableRefreshButton";
|
||||||
import { DataTableRowsPerPage } from "@components/table/DataTableRowsPerPage";
|
import { DataTableRowsPerPage } from "@components/table/DataTableRowsPerPage";
|
||||||
import GetStartedTest from "@components/ui/GetStartedTest";
|
import GetStartedTest from "@components/ui/GetStartedTest";
|
||||||
import { NotificationCountBadge } from "@components/ui/NotificationCountBadge";
|
import {
|
||||||
import { ColumnDef, SortingState } from "@tanstack/react-table";
|
ColumnDef,
|
||||||
|
Row,
|
||||||
|
RowSelectionState,
|
||||||
|
SortingState,
|
||||||
|
Table,
|
||||||
|
} from "@tanstack/react-table";
|
||||||
import useFetchApi from "@utils/api";
|
import useFetchApi from "@utils/api";
|
||||||
import { isLocalDev, isNetBirdHosted } from "@utils/netbird";
|
import { isLocalDev, isNetBirdHosted } from "@utils/netbird";
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
@@ -18,6 +24,7 @@ import { useSWRConfig } from "swr";
|
|||||||
import TeamIcon from "@/assets/icons/TeamIcon";
|
import TeamIcon from "@/assets/icons/TeamIcon";
|
||||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||||
import { useLocalStorage } from "@/hooks/useLocalStorage";
|
import { useLocalStorage } from "@/hooks/useLocalStorage";
|
||||||
|
import { Group } from "@/interfaces/Group";
|
||||||
import { User } from "@/interfaces/User";
|
import { User } from "@/interfaces/User";
|
||||||
import LastTimeRow from "@/modules/common-table-rows/LastTimeRow";
|
import LastTimeRow from "@/modules/common-table-rows/LastTimeRow";
|
||||||
import { PendingApprovalFilter } from "@/modules/users/PendingApprovalFilter";
|
import { PendingApprovalFilter } from "@/modules/users/PendingApprovalFilter";
|
||||||
@@ -108,12 +115,28 @@ type Props = {
|
|||||||
users?: User[];
|
users?: User[];
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
headingTarget?: HTMLHeadingElement | null;
|
headingTarget?: HTMLHeadingElement | null;
|
||||||
|
minimal?: boolean;
|
||||||
|
rightSide?: (table: Table<User>) => React.ReactNode;
|
||||||
|
getStartedCard?: React.ReactNode;
|
||||||
|
columns?: ColumnDef<User>[];
|
||||||
|
selectedRows?: RowSelectionState;
|
||||||
|
setSelectedRows?: (updater: React.SetStateAction<RowSelectionState>) => void;
|
||||||
|
onRowClick?: (row: Row<User>) => void;
|
||||||
|
keepStateInLocalStorage?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function UsersTable({
|
export default function UsersTable({
|
||||||
users,
|
users,
|
||||||
isLoading,
|
isLoading,
|
||||||
headingTarget,
|
headingTarget,
|
||||||
|
minimal,
|
||||||
|
rightSide,
|
||||||
|
getStartedCard,
|
||||||
|
columns = UsersTableColumns,
|
||||||
|
selectedRows,
|
||||||
|
setSelectedRows,
|
||||||
|
onRowClick,
|
||||||
|
keepStateInLocalStorage = true,
|
||||||
}: Readonly<Props>) {
|
}: Readonly<Props>) {
|
||||||
useFetchApi("/groups");
|
useFetchApi("/groups");
|
||||||
const { mutate } = useSWRConfig();
|
const { mutate } = useSWRConfig();
|
||||||
@@ -132,67 +155,89 @@ export default function UsersTable({
|
|||||||
desc: true,
|
desc: true,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
keepStateInLocalStorage,
|
||||||
);
|
);
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const { permission } = usePermissions();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DataTable
|
<DataTable
|
||||||
headingTarget={headingTarget}
|
headingTarget={headingTarget}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
|
keepStateInLocalStorage={keepStateInLocalStorage}
|
||||||
text={"Users"}
|
text={"Users"}
|
||||||
sorting={sorting}
|
sorting={sorting}
|
||||||
setSorting={setSorting}
|
setSorting={setSorting}
|
||||||
columns={UsersTableColumns}
|
columns={columns}
|
||||||
|
wrapperComponent={minimal ? Card : undefined}
|
||||||
|
wrapperProps={minimal && { className: "mt-6 w-full" }}
|
||||||
|
minimal={minimal}
|
||||||
data={users}
|
data={users}
|
||||||
|
rowSelection={selectedRows}
|
||||||
|
setRowSelection={setSelectedRows}
|
||||||
|
tableClassName={minimal ? "mt-0" : ""}
|
||||||
columnVisibility={{
|
columnVisibility={{
|
||||||
|
select: permission?.groups?.update,
|
||||||
is_current: false,
|
is_current: false,
|
||||||
approval_required: false,
|
approval_required: false,
|
||||||
}}
|
}}
|
||||||
onRowClick={(row) => {
|
onRowClick={
|
||||||
router.push(`/team/user?id=${row.original.id}`);
|
!onRowClick
|
||||||
}}
|
? (row) => {
|
||||||
|
router.push(`/team/user?id=${row.original.id}`);
|
||||||
|
}
|
||||||
|
: onRowClick
|
||||||
|
}
|
||||||
searchPlaceholder={"Search by name, email or role..."}
|
searchPlaceholder={"Search by name, email or role..."}
|
||||||
getStartedCard={
|
getStartedCard={
|
||||||
<GetStartedTest
|
!getStartedCard ? (
|
||||||
icon={
|
<GetStartedTest
|
||||||
<SquareIcon
|
icon={
|
||||||
icon={<TeamIcon className={"fill-nb-gray-200"} size={20} />}
|
<SquareIcon
|
||||||
color={"gray"}
|
icon={<TeamIcon className={"fill-nb-gray-200"} size={20} />}
|
||||||
size={"large"}
|
color={"gray"}
|
||||||
/>
|
size={"large"}
|
||||||
}
|
/>
|
||||||
title={"Add New Users"}
|
}
|
||||||
description={
|
title={"Add New Users"}
|
||||||
"It looks like you don't have any users yet. Get started by inviting users to your account."
|
description={
|
||||||
}
|
"It looks like you don't have any users yet. Get started by inviting users to your account."
|
||||||
button={
|
}
|
||||||
<div className={"flex flex-col items-center justify-center"}>
|
button={
|
||||||
<InviteUserButton show={true} />
|
<div className={"flex flex-col items-center justify-center"}>
|
||||||
</div>
|
<InviteUserButton show={true} />
|
||||||
}
|
</div>
|
||||||
learnMore={
|
}
|
||||||
<>
|
learnMore={
|
||||||
Learn more about
|
<>
|
||||||
<InlineLink
|
Learn more about
|
||||||
href={
|
<InlineLink
|
||||||
"https://docs.netbird.io/how-to/add-users-to-your-network"
|
href={
|
||||||
}
|
"https://docs.netbird.io/how-to/add-users-to-your-network"
|
||||||
target={"_blank"}
|
}
|
||||||
>
|
target={"_blank"}
|
||||||
Users
|
>
|
||||||
<ExternalLinkIcon size={12} />
|
Users
|
||||||
</InlineLink>
|
<ExternalLinkIcon size={12} />
|
||||||
</>
|
</InlineLink>
|
||||||
}
|
</>
|
||||||
/>
|
}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
getStartedCard
|
||||||
|
)
|
||||||
|
}
|
||||||
|
rightSide={
|
||||||
|
!rightSide
|
||||||
|
? () => (
|
||||||
|
<InviteUserButton
|
||||||
|
show={users && users?.length > 0}
|
||||||
|
className={"ml-auto"}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
: rightSide
|
||||||
}
|
}
|
||||||
rightSide={() => (
|
|
||||||
<InviteUserButton
|
|
||||||
show={users && users?.length > 0}
|
|
||||||
className={"ml-auto"}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
{(table) => {
|
{(table) => {
|
||||||
return (
|
return (
|
||||||
@@ -220,18 +265,20 @@ export default function UsersTable({
|
|||||||
type InviteUserButtonProps = {
|
type InviteUserButtonProps = {
|
||||||
show?: boolean;
|
show?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
groups?: Group[];
|
||||||
};
|
};
|
||||||
|
|
||||||
const InviteUserButton = ({
|
export const InviteUserButton = ({
|
||||||
show = false,
|
show = false,
|
||||||
className,
|
className,
|
||||||
|
groups,
|
||||||
}: InviteUserButtonProps) => {
|
}: InviteUserButtonProps) => {
|
||||||
const { permission } = usePermissions();
|
const { permission } = usePermissions();
|
||||||
if (!show) return null;
|
if (!show) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
(isLocalDev() || isNetBirdHosted()) && (
|
(isLocalDev() || isNetBirdHosted()) && (
|
||||||
<UserInviteModal>
|
<UserInviteModal groups={groups}>
|
||||||
<Button
|
<Button
|
||||||
variant={"primary"}
|
variant={"primary"}
|
||||||
className={className}
|
className={className}
|
||||||
|
|||||||
@@ -243,3 +243,13 @@ export const getBrowserInfo = () => {
|
|||||||
return { name, version };
|
return { name, version };
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const singularize = (word: string, count?: number) => {
|
||||||
|
if (!count) return word;
|
||||||
|
if (word.endsWith("ies") && count === 1) {
|
||||||
|
return count + " " + word.slice(0, -3) + "y";
|
||||||
|
} else if (word.endsWith("s") && count === 1) {
|
||||||
|
return count + " " + word.slice(0, -1);
|
||||||
|
}
|
||||||
|
return count + " " + word;
|
||||||
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user