From 4027894a2e7d3429f11367d40ca83f122b555d98 Mon Sep 17 00:00:00 2001 From: Eduard Gert Date: Wed, 5 Nov 2025 12:08:49 +0100 Subject: [PATCH] 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 --- src/app/(dashboard)/access-control/page.tsx | 2 +- src/app/(dashboard)/dns/nameservers/page.tsx | 2 +- src/app/(dashboard)/group/layout.tsx | 8 + src/app/(dashboard)/group/page.tsx | 297 ++++++++++++++++ src/app/(dashboard)/groups/layout.tsx | 8 + src/app/(dashboard)/groups/page.tsx | 56 +++ src/app/(dashboard)/settings/page.tsx | 4 +- src/components/ButtonGroup.tsx | 2 +- src/components/PeerGroupSelector.tsx | 10 +- src/components/skeletons/SkeletonTable.tsx | 4 +- src/components/table/DataTable.tsx | 6 +- .../table/DataTableMultiSelectPopup.tsx | 89 +++++ .../table/DataTableRefreshButton.tsx | 2 +- .../table/DataTableResetFilterButton.tsx | 2 +- src/components/ui/AddGroupButton.tsx | 115 ++++++ src/components/ui/InstallNetBirdButton.tsx | 21 ++ src/components/ui/NoResultsCard.tsx | 6 +- src/contexts/GroupProvider.tsx | 335 ++++++++++++++++++ src/contexts/GroupsProvider.tsx | 9 + src/interfaces/Group.ts | 11 + src/interfaces/Network.ts | 4 + src/layouts/Navigation.tsx | 6 + .../table/AccessControlTable.tsx | 106 ++++-- .../NameserverTemplateModal.tsx | 16 +- .../table/NameserverGroupTable.tsx | 126 ++++--- src/modules/exit-node/AddExitNodeButton.tsx | 9 +- src/modules/groups/AssignPeerToGroupModal.tsx | 143 +++++--- src/modules/groups/AssignUserToGroupModal.tsx | 229 ++++++++++++ src/modules/groups/EditGroupNameModal.tsx | 85 +++-- .../groups/details/GroupDetailsRemoveCell.tsx | 47 +++ .../details/GroupDetailsTableContainer.tsx | 47 +++ .../details/GroupNameserversSection.tsx | 28 ++ .../details/GroupNetworkRoutesSection.tsx | 80 +++++ .../groups/details/GroupPeersSection.tsx | 214 +++++++++++ .../groups/details/GroupPoliciesSection.tsx | 22 ++ .../groups/details/GroupResourcesSection.tsx | 176 +++++++++ .../groups/details/GroupSetupKeysSection.tsx | 27 ++ .../groups/details/GroupUsersSection.tsx | 224 ++++++++++++ src/modules/groups/details/useGroupDetails.ts | 150 ++++++++ src/modules/groups/table/GroupsActionCell.tsx | 128 +++++++ src/modules/groups/table/GroupsCountCell.tsx | 59 +++ src/modules/groups/table/GroupsNameCell.tsx | 43 +++ .../table}/GroupsTable.tsx | 285 ++++++++------- src/modules/groups/useGroupIdentification.ts | 3 + .../{settings => groups}/useGroupsUsage.tsx | 47 +-- src/modules/networks/NetworkProvider.tsx | 13 +- .../networks/resources/ResourceActionCell.tsx | 2 +- .../resources/ResourceEnabledCell.tsx | 7 +- .../networks/resources/ResourcesTable.tsx | 58 ++- src/modules/peer/AccessiblePeersSection.tsx | 2 +- ...lePeersTable.tsx => MinimalPeersTable.tsx} | 62 +++- src/modules/peers/PeerNameCell.tsx | 7 +- .../route-group/NetworkRoutesTable.tsx | 116 ++++-- src/modules/routes/RouteModal.tsx | 38 +- src/modules/settings/GroupsActionCell.tsx | 89 ----- src/modules/settings/GroupsCountCell.tsx | 38 -- src/modules/settings/GroupsNameCell.tsx | 38 -- .../{GroupsTab.tsx => GroupsSettings.tsx} | 77 +--- src/modules/setup-keys/SetupKeyModal.tsx | 13 +- src/modules/setup-keys/SetupKeysTable.tsx | 98 +++-- src/modules/users/UserInviteModal.tsx | 18 +- src/modules/users/UsersTable.tsx | 139 +++++--- src/utils/helpers.ts | 10 + 63 files changed, 3387 insertions(+), 731 deletions(-) create mode 100644 src/app/(dashboard)/group/layout.tsx create mode 100644 src/app/(dashboard)/group/page.tsx create mode 100644 src/app/(dashboard)/groups/layout.tsx create mode 100644 src/app/(dashboard)/groups/page.tsx create mode 100644 src/components/table/DataTableMultiSelectPopup.tsx create mode 100644 src/components/ui/AddGroupButton.tsx create mode 100644 src/components/ui/InstallNetBirdButton.tsx create mode 100644 src/contexts/GroupProvider.tsx create mode 100644 src/modules/groups/AssignUserToGroupModal.tsx create mode 100644 src/modules/groups/details/GroupDetailsRemoveCell.tsx create mode 100644 src/modules/groups/details/GroupDetailsTableContainer.tsx create mode 100644 src/modules/groups/details/GroupNameserversSection.tsx create mode 100644 src/modules/groups/details/GroupNetworkRoutesSection.tsx create mode 100644 src/modules/groups/details/GroupPeersSection.tsx create mode 100644 src/modules/groups/details/GroupPoliciesSection.tsx create mode 100644 src/modules/groups/details/GroupResourcesSection.tsx create mode 100644 src/modules/groups/details/GroupSetupKeysSection.tsx create mode 100644 src/modules/groups/details/GroupUsersSection.tsx create mode 100644 src/modules/groups/details/useGroupDetails.ts create mode 100644 src/modules/groups/table/GroupsActionCell.tsx create mode 100644 src/modules/groups/table/GroupsCountCell.tsx create mode 100644 src/modules/groups/table/GroupsNameCell.tsx rename src/modules/{settings => groups/table}/GroupsTable.tsx (59%) rename src/modules/{settings => groups}/useGroupsUsage.tsx (82%) rename src/modules/peer/{AccessiblePeersTable.tsx => MinimalPeersTable.tsx} (77%) delete mode 100644 src/modules/settings/GroupsActionCell.tsx delete mode 100644 src/modules/settings/GroupsCountCell.tsx delete mode 100644 src/modules/settings/GroupsNameCell.tsx rename src/modules/settings/{GroupsTab.tsx => GroupsSettings.tsx} (83%) diff --git a/src/app/(dashboard)/access-control/page.tsx b/src/app/(dashboard)/access-control/page.tsx index 39a5432..c55fa06 100644 --- a/src/app/(dashboard)/access-control/page.tsx +++ b/src/app/(dashboard)/access-control/page.tsx @@ -33,7 +33,7 @@ export default function AccessControlPage() {
} /> diff --git a/src/app/(dashboard)/dns/nameservers/page.tsx b/src/app/(dashboard)/dns/nameservers/page.tsx index 2123364..7e66f2b 100644 --- a/src/app/(dashboard)/dns/nameservers/page.tsx +++ b/src/app/(dashboard)/dns/nameservers/page.tsx @@ -32,7 +32,7 @@ export default function NameServers() {
} /> diff --git a/src/app/(dashboard)/group/layout.tsx b/src/app/(dashboard)/group/layout.tsx new file mode 100644 index 0000000..9e1e360 --- /dev/null +++ b/src/app/(dashboard)/group/layout.tsx @@ -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; diff --git a/src/app/(dashboard)/group/page.tsx b/src/app/(dashboard)/group/page.tsx new file mode 100644 index 0000000..1834cf6 --- /dev/null +++ b/src/app/(dashboard)/group/page.tsx @@ -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(`/groups/${groupId}`, true); + + useRedirect("/groups", false, !groupId || isRestricted); + + if (isRestricted) { + return ( + + + + ); + } + + if (error) + return ( + + ); + + return group && !isLoading ? ( + + + +
+ + } + /> + + + +
+ +
+
+
+ ) : ( + + ); +} + +const GroupDetailsName = () => { + const { group, isJWTGroup, isAllowedToRename, openGroupRenameModal } = + useGroupContext(); + const { permission } = usePermissions(); + + return ( +
+

+ + {group.name} + {group.name !== "All" && permission?.groups?.update && ( +
+ + {isJWTGroup + ? GROUP_TOOLTIP_TEXT.RENAME.JWT + : GROUP_TOOLTIP_TEXT.RENAME.INTEGRATION} +
+ } + interactive={false} + disabled={isAllowedToRename} + className={"w-full block"} + > +
+ +
+ +

+ )} + +
+ ); +}; + +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 ( + setTab(v)} + value={tab} + className={"pt-2 pb-0 mb-0"} + > + + {group.name !== "All" && ( + + + {singularize("Users", usersCount)} + + )} + + {group.name !== "All" && ( + + + {singularize("Peers", peersCount)} + + )} + + + + {singularize("Policies", policiesCount)} + + + + + {singularize("Resources", resourcesCount)} + + + + + {singularize("Network Routes", routesCount)} + + + + + {singularize("Nameservers", nameserversCount)} + + + {group.name !== "All" && ( + + + {singularize("Setup Keys", setupKeysCount)} + + )} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +}; diff --git a/src/app/(dashboard)/groups/layout.tsx b/src/app/(dashboard)/groups/layout.tsx new file mode 100644 index 0000000..83b9505 --- /dev/null +++ b/src/app/(dashboard)/groups/layout.tsx @@ -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; diff --git a/src/app/(dashboard)/groups/page.tsx b/src/app/(dashboard)/groups/page.tsx new file mode 100644 index 0000000..cf99db4 --- /dev/null +++ b/src/app/(dashboard)/groups/page.tsx @@ -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(); + + return ( + +
+ + } + active + /> + +

Groups

+ + Here is the overview of the groups of your organization. You can + delete the unused ones. + + + Learn more about{" "} + + Groups + + + in our documentation. + +
+ + }> + + + +
+ ); +} diff --git a/src/app/(dashboard)/settings/page.tsx b/src/app/(dashboard)/settings/page.tsx index 956f690..a710bfe 100644 --- a/src/app/(dashboard)/settings/page.tsx +++ b/src/app/(dashboard)/settings/page.tsx @@ -19,9 +19,9 @@ import { useAccount } from "@/modules/account/useAccount"; import AuthenticationTab from "@/modules/settings/AuthenticationTab"; import ClientSettingsTab from "@/modules/settings/ClientSettingsTab"; import DangerZoneTab from "@/modules/settings/DangerZoneTab"; -import GroupsTab from "@/modules/settings/GroupsTab"; import NetworkSettingsTab from "@/modules/settings/NetworkSettingsTab"; import PermissionsTab from "@/modules/settings/PermissionsTab"; +import GroupsSettings from "@/modules/settings/GroupsSettings"; export default function NetBirdSettings() { const queryParams = useSearchParams(); @@ -81,7 +81,7 @@ export default function NetBirdSettings() {
{account && } {account && } - {account && } + {account && } {account && } {account && } {account && } diff --git a/src/components/ButtonGroup.tsx b/src/components/ButtonGroup.tsx index 0b14ed4..2e98417 100644 --- a/src/components/ButtonGroup.tsx +++ b/src/components/ButtonGroup.tsx @@ -34,7 +34,7 @@ const ButtonGroupButton = forwardRef( border={2} rounded={false} className={cn( - "first:border-l-0 last:border-r-0 border-t-0 border-b-0 h-[41px]", + "first:border-l-0 last:border-r-0 border-t-0 border-b-0 h-[40px]", "!py-2.5 !px-4", className, )} diff --git a/src/components/PeerGroupSelector.tsx b/src/components/PeerGroupSelector.tsx index c26323d..d063aed 100644 --- a/src/components/PeerGroupSelector.tsx +++ b/src/components/PeerGroupSelector.tsx @@ -171,7 +171,15 @@ export function PeerGroupSelector({ const groupResources: GroupResource[] | undefined = (group?.resources as GroupResource[]) || []; - if (peer) groupPeers?.push({ id: peer?.id as string, name: peer?.name }); + if (peer) { + const peerInGroup = groupPeers?.find((p) => p?.id === peer?.id); + if (!peerInGroup) { + groupPeers?.push({ + id: peer?.id as string, + name: peer?.name, + }); + } + } if (!group && !option) { addDropdownOptions([ diff --git a/src/components/skeletons/SkeletonTable.tsx b/src/components/skeletons/SkeletonTable.tsx index aebd6ae..f40e737 100644 --- a/src/components/skeletons/SkeletonTable.tsx +++ b/src/components/skeletons/SkeletonTable.tsx @@ -10,7 +10,7 @@ export default function SkeletonTable({ withHeader = true }: Readonly) { return (
{withHeader && } -
+
@@ -68,7 +68,7 @@ export const SkeletonTableHeader = ({ return (
diff --git a/src/components/table/DataTable.tsx b/src/components/table/DataTable.tsx index f442d22..00093fa 100644 --- a/src/components/table/DataTable.tsx +++ b/src/components/table/DataTable.tsx @@ -133,6 +133,7 @@ interface DataTableProps { getStartedCard?: React.ReactNode; placeholders?: TData[]; renderExpandedRow?: (row: TData) => React.ReactNode; + renderRow?: (row: TData, children: React.ReactNode) => React.ReactNode; minimal?: boolean; className?: string; inset?: boolean; @@ -193,6 +194,7 @@ export function DataTable({ onRowClick, getStartedCard, renderExpandedRow, + renderRow, minimal, className, tableClassName, @@ -507,7 +509,7 @@ export function DataTable({ {table.getRowModel().rows?.length ? ( table.getRowModel().rows.map((row) => { const expandedRow = renderExpandedRow?.(row.original); - return ( + const rowContent = ( ({ ); + + return renderRow ? renderRow(row.original, rowContent) : rowContent; }) ) : ( diff --git a/src/components/table/DataTableMultiSelectPopup.tsx b/src/components/table/DataTableMultiSelectPopup.tsx new file mode 100644 index 0000000..9f7210a --- /dev/null +++ b/src/components/table/DataTableMultiSelectPopup.tsx @@ -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 = { + selectedItems?: T[]; + label?: string; + onCanceled?: () => void; + rightSide?: React.ReactNode; +}; + +export function DataTableMultiSelectPopup({ + onCanceled, + label = "Peer(s) selected", + selectedItems, + rightSide, +}: Props) { + const count = selectedItems?.length || 0; + return ( + + {count > 0 && ( +
+ + + + +
+
+ + + + {count} + {" "} + {label} + +
+
+ {rightSide} + Cancel} + > + + +
+
+
+
+
+
+
+ )} +
+ ); +} diff --git a/src/components/table/DataTableRefreshButton.tsx b/src/components/table/DataTableRefreshButton.tsx index 0eb7010..18d5256 100644 --- a/src/components/table/DataTableRefreshButton.tsx +++ b/src/components/table/DataTableRefreshButton.tsx @@ -35,7 +35,7 @@ export default function DataTableRefreshButton({ onClick, isDisabled }: Props) { }} > + + + } + title="Create Group" + description="Create a group to manage and organize access in your network" + color="netbird" + /> + +
+
+ + + Set an easily identifiable name for your group + + setName(e.target.value)} + /> +
+
+ +
+ + Learn more about + + Groups + + + +
+
+ + + + + +
+
+
+ + ) + ); +}; diff --git a/src/components/ui/InstallNetBirdButton.tsx b/src/components/ui/InstallNetBirdButton.tsx new file mode 100644 index 0000000..6cf366e --- /dev/null +++ b/src/components/ui/InstallNetBirdButton.tsx @@ -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 ( + + + + + + + ); +} diff --git a/src/components/ui/NoResultsCard.tsx b/src/components/ui/NoResultsCard.tsx index 7abda92..338f731 100644 --- a/src/components/ui/NoResultsCard.tsx +++ b/src/components/ui/NoResultsCard.tsx @@ -1,5 +1,6 @@ import Card from "@components/Card"; import Paragraph from "@components/Paragraph"; +import { cn } from "@utils/helpers"; import { FilterX } from "lucide-react"; import React from "react"; import Skeleton from "react-loading-skeleton"; @@ -9,15 +10,18 @@ type Props = { title?: string; description?: string; children?: React.ReactNode; + className?: string; }; + export default function NoResultsCard({ icon, title = "Could not find any results", description = "We couldn't find any results. Please try a different search term or change your filters.", children, + className, }: Readonly) { return ( -
+
Promise; + renameGroup: (name: string) => Promise; + isRegularGroup: boolean; + isIntegrationGroup: boolean; + isJWTGroup: boolean; + isAllowedToDelete: boolean; + isAllowedToRename: boolean; + openGroupRenameModal?: () => void; + addPeersToGroup: (peers: Peer[]) => Promise; + removePeersFromGroup: (peer: Peer[]) => Promise; + addUsersToGroup: (users: User[]) => Promise; + removeUsersFromGroup: (users: User[]) => Promise; + }, +); + +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("/groups/" + group.id); + const userRequest = useApiCall("/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 ( + + + renameGroup(newName).then(() => { + setGroupNameModal(false); + }) + } + /> + {children} + + ); +}; + +export const useGroupContext = () => { + const context = React.useContext(GroupContext); + if (!context) { + throw new Error("useGroup must be used within a GroupProvider"); + } + return context; +}; diff --git a/src/contexts/GroupsProvider.tsx b/src/contexts/GroupsProvider.tsx index 6683591..848eac5 100644 --- a/src/contexts/GroupsProvider.tsx +++ b/src/contexts/GroupsProvider.tsx @@ -20,6 +20,7 @@ const GroupContext = React.createContext( createOrUpdate: (group: Group) => Promise; reset: () => void; updateGroupDropdown: (oldGroupName: string, newGroup: Group) => void; + deleteGroupDropdownOption: (name: string) => void; }, ); @@ -132,6 +133,13 @@ export function GroupsProviderContent({ } }; + const deleteGroupDropdownOption = (name: string) => { + setDropdownOptions((prev) => { + let updated = prev.filter((g) => g.name !== name); + return sortBy(updated, "name"); + }); + }; + return ( {children} diff --git a/src/interfaces/Group.ts b/src/interfaces/Group.ts index 8237b86..079224a 100644 --- a/src/interfaces/Group.ts +++ b/src/interfaces/Group.ts @@ -26,3 +26,14 @@ export enum GroupIssued { INTEGRATION = "integration", JWT = "jwt", } + +export const GROUP_TOOLTIP_TEXT = { + RENAME: { + JWT: "This group is issued by JWT and cannot be renamed.", + INTEGRATION: "This group is issued by an IdP and cannot be renamed.", + }, + DELETE: { + INTEGRATION: "This group is issued by an IdP and cannot be deleted.", + }, + IN_USE: "Remove dependencies to this group to delete it.", +}; diff --git a/src/interfaces/Network.ts b/src/interfaces/Network.ts index e7cd560..a7dc790 100644 --- a/src/interfaces/Network.ts +++ b/src/interfaces/Network.ts @@ -28,3 +28,7 @@ export interface NetworkResource { type?: "domain" | "host" | "subnet"; enabled: boolean; } + +export interface NetworkResourceWithNetwork extends NetworkResource { + network: Network; +} diff --git a/src/layouts/Navigation.tsx b/src/layouts/Navigation.tsx index a7f6fb5..dabf6c9 100644 --- a/src/layouts/Navigation.tsx +++ b/src/layouts/Navigation.tsx @@ -113,6 +113,12 @@ export default function Navigation({ exactPathMatch={true} visible={permission.policies.read} /> + [] = [ @@ -179,12 +182,13 @@ export default function AccessControlTable({ policies, isLoading, headingTarget, + isGroupPage, }: Readonly) { const { mutate } = useSWRConfig(); const path = usePathname(); const { permission } = usePermissions(); const params = useSearchParams(); - const idParam = params.get("id") ?? undefined; + const idParam = !isGroupPage ? params.get("id") : undefined; // Default sorting state of the table const [sorting, setSorting] = useLocalStorage( @@ -195,6 +199,7 @@ export default function AccessControlTable({ desc: true, }, ], + !isGroupPage, ); const [editModal, setEditModal] = useState(false); @@ -249,7 +254,13 @@ export default function AccessControlTable({ - } - color={"gray"} - size={"large"} - /> - } - title={"Create New Policy"} - description={ - "It looks like you don't have any policies yet. Policies can allow connections by specific protocol and ports." - } - button={ + isGroupPage ? ( + + } + >
- } - learnMore={ - <> - Learn more about - - Access Controls - - - - } - /> +
+ ) : ( + + } + 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={ +
+ + + +
+ } + learnMore={ + <> + Learn more about + + Access Controls + + + + } + /> + ) } rightSide={() => ( <> {policies && policies?.length > 0 && ( -
+
- -
-
- } - learnMore={ - <> - Learn more about - } + className={"py-4"} + title={"This group is not used within any nameservers yet"} + description={ + "Assign this group as a distribution group in your nameservers to see them listed here." + } + > + + + + + ) : ( + } + 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={ +
+
+ + + +
+
+ } + learnMore={ + <> + Learn more about + + DNS + + + + } + /> + ) } rightSide={() => ( <> {nameserverGroups && nameserverGroups?.length > 0 && ( - + - )} + + {showHeader && ( +
+ + +
+ {groupName} + {groupName !== "All" && ( + + )} +
-
- } - description={ - isAllGroup - ? "View assigned peers for this group" - : "Manage assigned peers for this group" - } - color={"blue"} - /> -
+ } + description={ + isAllGroup + ? "View assigned peers for this group" + : "Manage assigned peers for this group" + } + color={"blue"} + /> +
+ )} {initialPeersSet ? ( } /> @@ -268,7 +306,10 @@ export const AssignGroupToPeerModalContent = ({ )}
@@ -289,7 +330,7 @@ export const AssignGroupToPeerModalContent = ({ ); }; -const PeersTableColumns: ColumnDef[] = [ +export const PeersTableColumns: ColumnDef[] = [ { id: "select", header: ({ table, column }) => ( diff --git a/src/modules/groups/AssignUserToGroupModal.tsx b/src/modules/groups/AssignUserToGroupModal.tsx new file mode 100644 index 0000000..a027b81 --- /dev/null +++ b/src/modules/groups/AssignUserToGroupModal.tsx @@ -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 ( + + {open && ( + { + setOpen(false); + onSuccess?.(users); + }} + excludedUsers={excludedUsers} + showClose={showClose} + buttonText={buttonText} + /> + )} + + ); +}; + +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( + "/users?service_user=false", + ); + const [selectedRows, setSelectedRows] = useState({}); + 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 ( + 0 ? "pb-0" : "pb-8")} + showClose={showClose} + > + 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={ + } + /> + } + rightSide={(table) => ( +
+
+ {Object.keys(selectedRows).length > 0 && ( +
+ + {Object.keys(selectedRows).length} + {" "} + User(s) selected +
+ )} +
+ {!isAllGroup && ( + + )} +
+ )} + /> +
+ ); +}; + +const UsersTableColumns: ColumnDef[] = [ + { + id: "select", + header: ({ table }) => ( +
+ table.toggleAllRowsSelected(!!value)} + aria-label="Select all" + /> +
+ ), + cell: ({ row }) => ( +
+ row.toggleSelected(!!value)} + aria-label="Select row" + /> +
+ ), + enableSorting: false, + enableHiding: false, + }, + { + accessorKey: "name", + header: ({ column }) => { + return Name; + }, + accessorFn: (row) => row.name + " " + row.email, + sortingFn: "text", + cell: ({ row }) => , + }, + { + accessorKey: "role", + header: ({ column }) => { + return Role; + }, + sortingFn: "text", + cell: ({ row }) => , + }, + { + accessorKey: "status", + header: ({ column }) => { + return Status; + }, + sortingFn: "text", + cell: ({ row }) => , + }, + { + accessorKey: "last_login", + header: ({ column }) => { + return Last Login; + }, + sortingFn: "text", + cell: ({ row }) => ( + + ), + }, +]; diff --git a/src/modules/groups/EditGroupNameModal.tsx b/src/modules/groups/EditGroupNameModal.tsx index 4964357..e912bcf 100644 --- a/src/modules/groups/EditGroupNameModal.tsx +++ b/src/modules/groups/EditGroupNameModal.tsx @@ -7,10 +7,10 @@ import { ModalFooter, } from "@components/modal/Modal"; import ModalHeader from "@components/modal/ModalHeader"; -import { IconCornerDownLeft } from "@tabler/icons-react"; import { trim } from "lodash"; import * as React from "react"; import { useMemo, useState } from "react"; +import { useGroups } from "@/contexts/GroupsProvider"; type Props = { initialName: string; @@ -25,53 +25,66 @@ export const EditGroupNameModal = ({ onSuccess, }: Props) => { const [name, setName] = useState(initialName); + const { groups } = useGroups(); + const [error, setError] = useState(""); + const isDisabled = useMemo(() => { if (name === initialName) return true; + if (error !== "") return true; const trimmedName = trim(name); return trimmedName.length === 0; - }, [name, initialName]); + }, [name, initialName, error]); + + const handleNameChange = (e: React.ChangeEvent) => { + 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 ( -
- + -
-
- setName(e.target.value)} - /> -
+
+
+
+
- -
- - - - - -
-
- + + + +
+
); diff --git a/src/modules/groups/details/GroupDetailsRemoveCell.tsx b/src/modules/groups/details/GroupDetailsRemoveCell.tsx new file mode 100644 index 0000000..9ca78d8 --- /dev/null +++ b/src/modules/groups/details/GroupDetailsRemoveCell.tsx @@ -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 ( +
+ +
+ ); +} + +export const GroupPeersRemoveCell = ({ peer }: { peer: Peer }) => { + const { removePeersFromGroup } = useGroupContext(); + const { permission } = usePermissions(); + return ( + permission?.peers?.update && + permission?.groups?.update && ( + removePeersFromGroup([peer])} /> + ) + ); +}; + +export const GroupUsersRemoveCell = ({ user }: { user: User }) => { + const { removeUsersFromGroup } = useGroupContext(); + const { permission } = usePermissions(); + return ( + permission?.users?.update && ( + removeUsersFromGroup([user])} /> + ) + ); +}; diff --git a/src/modules/groups/details/GroupDetailsTableContainer.tsx b/src/modules/groups/details/GroupDetailsTableContainer.tsx new file mode 100644 index 0000000..0e281b6 --- /dev/null +++ b/src/modules/groups/details/GroupDetailsTableContainer.tsx @@ -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; + children: React.ReactNode; +}; + +export const GroupDetailsTableContainer = ({ + title, + description, + headingRef, + children, +}: Props) => { + return ( +
+
+ {(title || description) && ( +
+
+ {title &&

{title}

} + {description && {description}} +
+
+ )} + + + +
+ +
+
+ } + > + {children} + +
+
+ ); +}; diff --git a/src/modules/groups/details/GroupNameserversSection.tsx b/src/modules/groups/details/GroupNameserversSection.tsx new file mode 100644 index 0000000..5d59b0f --- /dev/null +++ b/src/modules/groups/details/GroupNameserversSection.tsx @@ -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 ( + + + + ); +}; diff --git a/src/modules/groups/details/GroupNetworkRoutesSection.tsx b/src/modules/groups/details/GroupNetworkRoutesSection.tsx new file mode 100644 index 0000000..6667f5e --- /dev/null +++ b/src/modules/groups/details/GroupNetworkRoutesSection.tsx @@ -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[] = [ + { + accessorKey: "network_id", + header: ({ column }) => { + return Name; + }, + sortingFn: "text", + cell: ({ row }) => , + }, + { + accessorKey: "description", + sortingFn: "text", + }, + { + accessorKey: "domain_search", + sortingFn: "text", + }, + { + accessorKey: "network", + header: ({ column }) => { + return Network; + }, + cell: ({ row }) => ( + + ), + }, + { + accessorKey: "metric", + header: ({ column }) => { + return Metric; + }, + cell: ({ row }) => , + sortingFn: "alphanumeric", + }, + { + id: "enabled", + accessorKey: "enabled", + sortingFn: "basic", + header: ({ column }) => ( + Active + ), + cell: ({ row }) => , + }, +]; + +export const GroupNetworkRoutesSection = ({ routes }: { routes?: Route[] }) => { + const groupedRoutes = useGroupedRoutes({ routes }); + const { group } = useGroupContext(); + + return ( + + + + ); +}; diff --git a/src/modules/groups/details/GroupPeersSection.tsx b/src/modules/groups/details/GroupPeersSection.tsx new file mode 100644 index 0000000..dd53882 --- /dev/null +++ b/src/modules/groups/details/GroupPeersSection.tsx @@ -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[] = [ + { + id: "select", + header: ({ table }) => ( +
+ table.toggleAllRowsSelected(!!value)} + aria-label="Select all" + /> +
+ ), + cell: ({ row }) => ( +
+ row.toggleSelected(!!value)} + aria-label="Select row" + /> +
+ ), + enableSorting: false, + enableHiding: false, + }, + { + accessorKey: "name", + header: ({ column }) => { + return Name; + }, + sortingFn: "text", + cell: ({ row }) => , + }, + { + 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 Address; + }, + cell: ({ row }) => , + }, + { + accessorKey: "last_seen", + header: ({ column }) => { + return Last seen; + }, + sortingFn: "datetime", + cell: ({ row }) => , + }, + { + accessorKey: "os", + header: ({ column }) => { + return OS; + }, + cell: ({ row }) => , + }, + { + id: "remove_from_group", + accessorKey: "id", + header: "", + cell: ({ row }) => , + }, +]; + +export const GroupPeersSection = ({ peers }: { peers?: Peer[] }) => { + const { group, addPeersToGroup, removePeersFromGroup } = useGroupContext(); + const [selectedRows, setSelectedRows] = useState({}); + const [open, setOpen] = useState(false); + const { permission } = usePermissions(); + + return ( + + } + > + {permission?.peers?.update && permission?.groups?.update && ( +
+ + +
+ )} + + } + onRowClick={(row) => row.toggleSelected()} + rightSide={(table) => ( + <> + row.original)} + onCanceled={() => setSelectedRows({})} + rightSide={ + <> + Remove Peers from Group + } + > + + + + } + /> + { + let peers = g.peers as Peer[]; + addPeersToGroup(peers).then(); + }} + /> + {peers && peers?.length > 0 && ( +
+
+ + {permission?.peers?.update && permission?.groups?.update && ( + + )} +
+
+ )} + + )} + /> +
+ ); +}; diff --git a/src/modules/groups/details/GroupPoliciesSection.tsx b/src/modules/groups/details/GroupPoliciesSection.tsx new file mode 100644 index 0000000..3e65823 --- /dev/null +++ b/src/modules/groups/details/GroupPoliciesSection.tsx @@ -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 ( + + + + + + ); +}; diff --git a/src/modules/groups/details/GroupResourcesSection.tsx b/src/modules/groups/details/GroupResourcesSection.tsx new file mode 100644 index 0000000..c428da1 --- /dev/null +++ b/src/modules/groups/details/GroupResourcesSection.tsx @@ -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[] = [ + { + id: "id", + accessorKey: "id", + filterFn: "exactMatch", + }, + { + id: "name", + accessorKey: "name", + header: ({ column }) => { + return Resource; + }, + cell: ({ row }) => { + return ; + }, + }, + { + id: "description", + accessorKey: "description", + accessorFn: (resource) => + removeAllSpaces(resource?.description || "").toLowerCase(), + }, + { + id: "address", + accessorKey: "address", + header: ({ column }) => { + return Address; + }, + cell: ({ row }) => { + return ; + }, + }, + { + id: "enabled", + accessorKey: "enabled", + header: ({ column }) => { + return Active; + }, + cell: ({ row }) => ( + + ), + }, + { + id: "groups", + accessorFn: (resource) => { + let groups = resource?.groups as Group[]; + return groups.map((group) => group.name).join(", "); + }, + header: ({ column }) => { + return Groups; + }, + cell: ({ row }) => { + return ; + }, + }, + { + id: "policies", + accessorKey: "id", + header: ({ column }) => { + return Policies; + }, + cell: ({ row }) => { + return ; + }, + }, + { + id: "actions", + accessorKey: "id", + header: "", + cell: ({ row }) => { + return ; + }, + }, +]; + +export const GroupResourcesSection = ({ + resources, +}: { + resources?: NetworkResourceWithNetwork[]; +}) => { + const [sorting, setSorting] = useState([]); + const { permission } = usePermissions(); + const router = useRouter(); + const { mutate } = useSWRConfig(); + + return ( + + ( + mutate("/networks/resources")} + onResourceDelete={() => mutate("/networks/resources")} + > + {children} + + )} + inset={false} + tableClassName={"mt-0"} + text={"Resources"} + columns={GroupResourcesColumns} + keepStateInLocalStorage={false} + data={resources} + searchPlaceholder={"Search by name, address or group..."} + getStartedCard={ + } + > + {permission?.networks?.create && ( + <> + + + )} + + } + columnVisibility={{ + description: false, + id: false, + }} + paginationPaddingClassName={"px-0 pt-8"} + > + {(table) => ( + + )} + + + ); +}; diff --git a/src/modules/groups/details/GroupSetupKeysSection.tsx b/src/modules/groups/details/GroupSetupKeysSection.tsx new file mode 100644 index 0000000..3dd6d58 --- /dev/null +++ b/src/modules/groups/details/GroupSetupKeysSection.tsx @@ -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 ( + + + + ); +}; diff --git a/src/modules/groups/details/GroupUsersSection.tsx b/src/modules/groups/details/GroupUsersSection.tsx new file mode 100644 index 0000000..16c7af6 --- /dev/null +++ b/src/modules/groups/details/GroupUsersSection.tsx @@ -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[] = [ + { + id: "select", + header: ({ table }) => ( +
+ table.toggleAllRowsSelected(!!value)} + aria-label="Select all" + /> +
+ ), + cell: ({ row }) => ( +
+ row.toggleSelected(!!value)} + aria-label="Select row" + /> +
+ ), + enableSorting: false, + enableHiding: false, + }, + { + accessorKey: "name", + header: ({ column }) => { + return Name; + }, + accessorFn: (row) => row.name + " " + row.email, + sortingFn: "text", + cell: ({ row }) => , + }, + { + accessorKey: "is_current", + sortingFn: "basic", + }, + { + accessorKey: "role", + header: ({ column }) => { + return Role; + }, + sortingFn: "text", + cell: ({ row }) => , + }, + { + accessorKey: "status", + header: ({ column }) => { + return Status; + }, + sortingFn: "text", + cell: ({ row }) => , + }, + { + accessorKey: "is_blocked", + header: ({ column }) => { + return Block User; + }, + sortingFn: "text", + cell: ({ row }) => , + }, + { + accessorKey: "last_login", + header: ({ column }) => { + return Last Login; + }, + sortingFn: "text", + cell: ({ row }) => ( + + ), + }, + { + id: "approval_required", + accessorKey: "approval_required", + sortingFn: "basic", + accessorFn: (u) => u?.pending_approval, + }, + { + id: "remove_from_group", + accessorKey: "id", + header: "", + cell: ({ row }) => , + }, +]; + +export const GroupUsersSection = ({ users }: { users?: User[] }) => { + const { group, addUsersToGroup, removeUsersFromGroup } = useGroupContext(); + const [selectedRows, setSelectedRows] = useState({}); + const [open, setOpen] = useState(false); + const { permission } = usePermissions(); + + return ( + + row.toggleSelected()} + keepStateInLocalStorage={false} + minimal={true} + users={users} + getStartedCard={ + } + > + {permission?.users?.update && ( +
+ + +
+ )} +
+ } + rightSide={(table) => { + return ( + <> + row.original)} + onCanceled={() => setSelectedRows({})} + rightSide={ + <> + + Remove Users from Group + + } + > + + + + } + /> + { + addUsersToGroup(newUsers).then(); + }} + /> + {users && users?.length > 0 && permission?.users?.update && ( +
+ + +
+ )} + + ); + }} + /> +
+ ); +}; diff --git a/src/modules/groups/details/useGroupDetails.ts b/src/modules/groups/details/useGroupDetails.ts new file mode 100644 index 0000000..524e40c --- /dev/null +++ b/src/modules/groups/details/useGroupDetails.ts @@ -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( + `/groups/${groupId}`, + ); + const { data: policies, isLoading: isPoliciesLoading } = + useFetchApi(`/policies`); + const { data: nameservers, isLoading: isNameserversLoading } = + useFetchApi(`/dns/nameservers`); + const { data: routes, isLoading: isRoutesLoading } = + useFetchApi(`/routes`); + const { data: setupKeys, isLoading: isSetupKeysLoading } = + useFetchApi(`/setup-keys`); + const { data: users, isLoading: isUsersLoading } = useFetchApi( + `/users?service_user=false`, + ); + const { data: peers, isLoading: isPeerLoading } = + useFetchApi(`/peers`); + const { data: resources, isLoading: isLoadingResources } = useFetchApi< + NetworkResource[] + >("/networks/resources"); + const { data: networks, isLoading: isNetworksLoading } = + useFetchApi("/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, + ]); +} diff --git a/src/modules/groups/table/GroupsActionCell.tsx b/src/modules/groups/table/GroupsActionCell.tsx new file mode 100644 index 0000000..19ace44 --- /dev/null +++ b/src/modules/groups/table/GroupsActionCell.tsx @@ -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) { + const { permission } = usePermissions(); + const router = useRouter(); + + const { + deleteGroup, + isAllowedToRename, + isAllowedToDelete, + isIntegrationGroup, + isJWTGroup, + openGroupRenameModal, + } = useGroupContext(); + + const canDelete = isAllowedToDelete && !inUse; + + return ( + <> +
+ + { + e.stopPropagation(); + e.preventDefault(); + }} + > + + + + + router.push("/group?id=" + group.id)} + disabled={!permission.groups.read} + > +
+ + View Details +
+
+ + {permission?.groups?.update && ( + <> + + + {isJWTGroup + ? GROUP_TOOLTIP_TEXT.RENAME.JWT + : GROUP_TOOLTIP_TEXT.RENAME.INTEGRATION} +
+ } + interactive={false} + disabled={isAllowedToRename} + className={"w-full block"} + > + +
+ + Rename +
+
+ + + )} + {permission?.groups?.delete && ( + + {isIntegrationGroup + ? GROUP_TOOLTIP_TEXT.DELETE.INTEGRATION + : GROUP_TOOLTIP_TEXT.IN_USE} +
+ } + interactive={false} + disabled={canDelete} + className={"w-full block"} + > + +
+ + Delete +
+
+ + )} + + +
+ + ); +} diff --git a/src/modules/groups/table/GroupsCountCell.tsx b/src/modules/groups/table/GroupsCountCell.tsx new file mode 100644 index 0000000..5594bcd --- /dev/null +++ b/src/modules/groups/table/GroupsCountCell.tsx @@ -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 && ( + + Group{" "} + {groupName} is + used in {count}{" "} + {text} +
+ } + disabled={count === 0} + > + + {icon} + {count} + + + ) + ); +} diff --git a/src/modules/groups/table/GroupsNameCell.tsx b/src/modules/groups/table/GroupsNameCell.tsx new file mode 100644 index 0000000..dd212d8 --- /dev/null +++ b/src/modules/groups/table/GroupsNameCell.tsx @@ -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) { + const router = useRouter(); + return ( +
+
router.push("/group?id=" + group.id)} + > +
+ +
+ +
+
+ +
+
+ +
+
+ ); +} diff --git a/src/modules/settings/GroupsTable.tsx b/src/modules/groups/table/GroupsTable.tsx similarity index 59% rename from src/modules/settings/GroupsTable.tsx rename to src/modules/groups/table/GroupsTable.tsx index 6365e8d..e051da9 100644 --- a/src/modules/settings/GroupsTable.tsx +++ b/src/modules/groups/table/GroupsTable.tsx @@ -2,9 +2,8 @@ import ButtonGroup from "@components/ButtonGroup"; import { DataTable } from "@components/table/DataTable"; import DataTableHeader from "@components/table/DataTableHeader"; import { DataTableRowsPerPage } from "@components/table/DataTableRowsPerPage"; -import NoResults from "@components/ui/NoResults"; import { ColumnDef, SortingState } from "@tanstack/react-table"; -import { FolderGit2Icon, Layers3Icon } from "lucide-react"; +import { Layers3Icon } from "lucide-react"; import { usePathname } from "next/navigation"; import React from "react"; import AccessControlIcon from "@/assets/icons/AccessControlIcon"; @@ -13,13 +12,14 @@ import NetworkRoutesIcon from "@/assets/icons/NetworkRoutesIcon"; import PeerIcon from "@/assets/icons/PeerIcon"; import SetupKeysIcon from "@/assets/icons/SetupKeysIcon"; import TeamIcon from "@/assets/icons/TeamIcon"; +import { AddGroupButton } from "@/components/ui/AddGroupButton"; +import { GroupProvider } from "@/contexts/GroupProvider"; import { useLocalStorage } from "@/hooks/useLocalStorage"; -import GroupsActionCell from "@/modules/settings/GroupsActionCell"; -import GroupsCountCell from "@/modules/settings/GroupsCountCell"; -import GroupsNameCell from "@/modules/settings/GroupsNameCell"; -import useGroupsUsage, { GroupUsage } from "@/modules/settings/useGroupsUsage"; +import GroupsActionCell from "@/modules/groups/table/GroupsActionCell"; +import GroupsCountCell from "@/modules/groups/table/GroupsCountCell"; +import GroupsNameCell from "@/modules/groups/table/GroupsNameCell"; +import useGroupsUsage, { GroupUsage } from "@/modules/groups/useGroupsUsage"; -// Peers, Access Controls, DNS, Routes, Setup Keys, Users export const GroupsTableColumns: ColumnDef[] = [ { accessorKey: "name", @@ -42,24 +42,25 @@ export const GroupsTableColumns: ColumnDef[] = [ sortingFn: "text", }, { - accessorKey: "setup_keys_count", + accessorKey: "users_count", header: ({ column }) => { return ( Setup Keys
} + tooltip={
Users
} > - + ); }, cell: ({ row }) => ( } + icon={} groupName={row.original.name} - text={"Setup Key(s)"} - count={row.original.setup_keys_count} + href={`/group?id=${row.original.id}&tab=users`} + hidden={row.original.name === "All"} + text={"User(s)"} + count={row.original.users_count} /> ), }, @@ -69,7 +70,7 @@ export const GroupsTableColumns: ColumnDef[] = [ return ( Peers} + tooltip={
Peers
} >
@@ -79,39 +80,20 @@ export const GroupsTableColumns: ColumnDef[] = [ } groupName={row.original.name} + href={`/group?id=${row.original.id}&tab=peers`} + hidden={row.original.name === "All"} text={"Peer(s)"} count={row.original.peers_count} /> ), }, - { - accessorKey: "nameservers_count", - header: ({ column }) => { - return ( - DNS} - > - - - ); - }, - cell: ({ row }) => ( - } - groupName={row.original.name} - text={"DNS"} - count={row.original.nameservers_count} - /> - ), - }, { accessorKey: "policies_count", header: ({ column }) => { return ( Access Controls} + tooltip={
Policies
} >
@@ -121,32 +103,12 @@ export const GroupsTableColumns: ColumnDef[] = [ } groupName={row.original.name} - text={"Access Control(s)"} + href={`/group?id=${row.original.id}&tab=policies`} + text={row.original.policies_count === 1 ? "Policy" : "Policies"} count={row.original.policies_count} /> ), }, - { - accessorKey: "routes_count", - header: ({ column }) => { - return ( - Network Routes} - > - - - ); - }, - cell: ({ row }) => ( - } - groupName={row.original.name} - text={"Network Route(s)"} - count={row.original.routes_count} - /> - ), - }, { accessorKey: "resources_count", header: ({ column }) => { @@ -154,7 +116,7 @@ export const GroupsTableColumns: ColumnDef[] = [ Network Resources +
Network Resources
} > @@ -165,29 +127,77 @@ export const GroupsTableColumns: ColumnDef[] = [ } groupName={row.original.name} + href={`/group?id=${row.original.id}&tab=resources`} text={"Network Resource(s)"} count={row.original.resources_count} /> ), }, { - accessorKey: "users_count", + accessorKey: "routes_count", header: ({ column }) => { return ( Users} + tooltip={
Network Routes
} > - +
); }, cell: ({ row }) => ( } + icon={} groupName={row.original.name} - text={"User(s)"} - count={row.original.users_count} + href={`/group?id=${row.original.id}&tab=network-routes`} + text={"Network Route(s)"} + count={row.original.routes_count} + /> + ), + }, + { + accessorKey: "nameservers_count", + header: ({ column }) => { + return ( + Nameservers} + > + + + ); + }, + cell: ({ row }) => ( + } + 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 ( + Setup Keys} + > + + + ); + }, + cell: ({ row }) => ( + } + 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[] = [ accessorKey: "id", header: "", cell: ({ row }) => ( - + + + ), }, ]; @@ -223,7 +235,7 @@ type Props = { }; export default function GroupsTable({ headingTarget }: Readonly) { - const groups = useGroupsUsage(); + const { data: groups, isLoading } = useGroupsUsage(); const path = usePathname(); // Default sorting state of the table @@ -231,88 +243,73 @@ export default function GroupsTable({ headingTarget }: Readonly) { "netbird-table-sort" + path, [ { - id: "name", + id: "in_use", desc: true, }, + { + id: "name", + desc: false, + }, ], ); return ( - <> - {groups && groups.length > 0 ? ( - - {(table) => ( - <> - - - table.getColumn("in_use")?.setFilterValue(undefined) - } - disabled={groups?.length == 0} - variant={ - table.getColumn("in_use")?.getFilterValue() === undefined - ? "tertiary" - : "secondary" - } - > - All - - - table.getColumn("in_use")?.setFilterValue(true) - } - disabled={groups?.length == 0} - variant={ - table.getColumn("in_use")?.getFilterValue() === true - ? "tertiary" - : "secondary" - } - > - Used - - - table.getColumn("in_use")?.setFilterValue(false) - } - variant={ - table.getColumn("in_use")?.getFilterValue() === false - ? "tertiary" - : "secondary" - } - > - Unused - - - - - )} - - ) : ( -
- } - /> -
+ } + columnVisibility={{ + in_use: false, + }} + > + {(table) => ( + <> + + + table.getColumn("in_use")?.setFilterValue(undefined) + } + disabled={groups?.length == 0} + variant={ + table.getColumn("in_use")?.getFilterValue() === undefined + ? "tertiary" + : "secondary" + } + > + All + + table.getColumn("in_use")?.setFilterValue(true)} + disabled={groups?.length == 0} + variant={ + table.getColumn("in_use")?.getFilterValue() === true + ? "tertiary" + : "secondary" + } + > + Used + + table.getColumn("in_use")?.setFilterValue(false)} + variant={ + table.getColumn("in_use")?.getFilterValue() === false + ? "tertiary" + : "secondary" + } + > + Unused + + + + )} - + ); } diff --git a/src/modules/groups/useGroupIdentification.ts b/src/modules/groups/useGroupIdentification.ts index 01927cf..af04e42 100644 --- a/src/modules/groups/useGroupIdentification.ts +++ b/src/modules/groups/useGroupIdentification.ts @@ -14,11 +14,14 @@ export const useGroupIdentification = ({ id, issued }: Props) => { const isRegularGroup = !isJWTGroup && !isOktaGroup && !isGoogleGroup && !isAzureGroup; + const isIntegrationGroup = isOktaGroup || isGoogleGroup || isAzureGroup; + return { isOktaGroup, isGoogleGroup, isAzureGroup, isJWTGroup, isRegularGroup, + isIntegrationGroup, }; }; diff --git a/src/modules/settings/useGroupsUsage.tsx b/src/modules/groups/useGroupsUsage.tsx similarity index 82% rename from src/modules/settings/useGroupsUsage.tsx rename to src/modules/groups/useGroupsUsage.tsx index 4afaa8d..22fef68 100644 --- a/src/modules/settings/useGroupsUsage.tsx +++ b/src/modules/groups/useGroupsUsage.tsx @@ -1,16 +1,13 @@ import useFetchApi from "@utils/api"; import { useMemo } from "react"; -import { Group, GroupIssued } from "@/interfaces/Group"; +import { Group } from "@/interfaces/Group"; import { NameserverGroup } from "@/interfaces/Nameserver"; import { Policy } from "@/interfaces/Policy"; import { Route } from "@/interfaces/Route"; import { SetupKey } from "@/interfaces/SetupKey"; import { User } from "@/interfaces/User"; -export interface GroupUsage { - id: string; - name: string; - issued: GroupIssued; +export interface GroupUsage extends Group { peers_count: number; policies_count: number; nameservers_count: number; @@ -22,7 +19,7 @@ export interface GroupUsage { export default function useGroupsUsage() { const { data: groups, isLoading: isGroupsLoading } = - useFetchApi(`/groups`); // Groups , Peers count + useFetchApi(`/groups`); // Groups, Peers count const { data: policies, isLoading: isPoliciesLoading } = useFetchApi(`/policies`); // Policies const { data: nameservers, isLoading: isNameserversLoading } = @@ -60,12 +57,6 @@ export default function useGroupsUsage() { .filter((u) => u !== undefined); }, [nameservers, isNameserversLoading]); - const routesGroups = useMemo(() => { - if (isRoutesLoading) return; - if (!routes) return []; - return routes?.map((route) => route.groups).filter((u) => u !== undefined); - }, [routes, isRoutesLoading]); - const setupKeysGroups = useMemo(() => { if (isSetupKeysLoading) return; if (!setupKeys) return []; @@ -100,8 +91,9 @@ export default function useGroupsUsage() { isUsersLoading, ]); - return useMemo(() => { + const groupsUsage = useMemo(() => { if (isLoading) return []; + if (isRoutesLoading) return []; if (!groups) return []; return groups?.map((group) => { const policyCount = policiesGroups?.filter((policy) => { @@ -112,9 +104,20 @@ export default function useGroupsUsage() { return nameserver.includes(group.id as string); }).length; - const routeCount = routesGroups?.filter((route) => { - return route.includes(group.id as string); - }).length; + const routeCount = ( + routes?.filter((route) => { + const groupId = group.id as string; + const isInDistributionGroups = + route.groups?.includes(groupId) ?? false; + const isInAccessControlGroups = + route.access_control_groups?.includes(groupId) ?? false; + const isInPeerGroups = route.peer_groups?.includes(groupId) ?? false; + + return ( + isInAccessControlGroups || isInDistributionGroups || isInPeerGroups + ); + }) || [] + ).length; const setupKeyCount = setupKeysGroups?.filter((setupKey) => { return setupKey.includes(group.id as string); @@ -125,9 +128,7 @@ export default function useGroupsUsage() { }).length; return { - id: group.id, - issued: group.issued, - name: group.name, + ...group, peers_count: group.peers_count, resources_count: group.resources_count, policies_count: policyCount, @@ -142,8 +143,14 @@ export default function useGroupsUsage() { groups, policiesGroups, nameserversGroups, - routesGroups, + routes, + isRoutesLoading, setupKeysGroups, usersGroups, ]); + + return { + data: groupsUsage, + isLoading, + }; } diff --git a/src/modules/networks/NetworkProvider.tsx b/src/modules/networks/NetworkProvider.tsx index a865f6c..5fe49de 100644 --- a/src/modules/networks/NetworkProvider.tsx +++ b/src/modules/networks/NetworkProvider.tsx @@ -16,6 +16,8 @@ import NetworkRoutingPeerModal from "@/modules/networks/routing-peers/NetworkRou type Props = { children: React.ReactNode; network?: Network; + onResourceUpdate?: () => void; + onResourceDelete?: () => void; }; const NetworksContext = React.createContext( @@ -36,7 +38,12 @@ const NetworksContext = React.createContext( }, ); -export const NetworkProvider = ({ children, network }: Props) => { +export const NetworkProvider = ({ + children, + network, + onResourceDelete, + onResourceUpdate, +}: Props) => { const { mutate } = useSWRConfig(); const { confirm } = useDialog(); const deleteCall = useApiCall("/networks").del; @@ -160,6 +167,7 @@ export const NetworkProvider = ({ children, network }: Props) => { loadingMessage: "Deleting resource...", promise: deleteCall({}, `/${network.id}/resources/${resource.id}`).then( () => { + onResourceDelete?.(); mutate(`/networks/${network.id}/resources`); mutate("/groups"); }, @@ -276,6 +284,7 @@ export const NetworkProvider = ({ children, network }: Props) => { setPolicyDefaultSettings(undefined); mutate("/networks"); if (network) { + onResourceUpdate?.(); mutate(`/networks/${network.id}/resources`); mutate(`/networks/${network.id}`); } else { @@ -329,6 +338,7 @@ export const NetworkProvider = ({ children, network }: Props) => { setCurrentResource(undefined); mutate("/groups"); if (network) { + onResourceUpdate?.(); mutate(`/networks/${network.id}/resources`); mutate(`/networks/${network.id}`); } @@ -356,6 +366,7 @@ export const NetworkProvider = ({ children, network }: Props) => { mutate("/networks"); mutate("/groups"); if (network) { + onResourceUpdate?.(); mutate(`/networks/${network.id}/resources`); mutate(`/networks/${network.id}`); } diff --git a/src/modules/networks/resources/ResourceActionCell.tsx b/src/modules/networks/resources/ResourceActionCell.tsx index eab88e7..c2d0439 100644 --- a/src/modules/networks/resources/ResourceActionCell.tsx +++ b/src/modules/networks/resources/ResourceActionCell.tsx @@ -62,7 +62,7 @@ export const ResourceActionCell = ({ resource }: Props) => { >
- Remove + Delete
diff --git a/src/modules/networks/resources/ResourceEnabledCell.tsx b/src/modules/networks/resources/ResourceEnabledCell.tsx index 716a60e..27fccf9 100644 --- a/src/modules/networks/resources/ResourceEnabledCell.tsx +++ b/src/modules/networks/resources/ResourceEnabledCell.tsx @@ -11,8 +11,12 @@ import { useNetworksContext } from "@/modules/networks/NetworkProvider"; type Props = { resource: NetworkResource; + mutateAllResourcesOnUpdate?: boolean; }; -export const ResourceEnabledCell = ({ resource }: Props) => { +export const ResourceEnabledCell = ({ + resource, + mutateAllResourcesOnUpdate, +}: Props) => { const { permission } = usePermissions(); const { mutate } = useSWRConfig(); @@ -40,6 +44,7 @@ export const ResourceEnabledCell = ({ resource }: Props) => { .filter((g) => g !== undefined), enabled, }).then(() => { + mutateAllResourcesOnUpdate && mutate("/networks/resources"); mutate(`/networks/${network?.id}/resources`); }), }); diff --git a/src/modules/networks/resources/ResourcesTable.tsx b/src/modules/networks/resources/ResourcesTable.tsx index 9341537..fc9ceb7 100644 --- a/src/modules/networks/resources/ResourcesTable.tsx +++ b/src/modules/networks/resources/ResourcesTable.tsx @@ -7,8 +7,8 @@ import NoResults from "@components/ui/NoResults"; import { IconCirclePlus } from "@tabler/icons-react"; import { ColumnDef, SortingState } from "@tanstack/react-table"; import { removeAllSpaces } from "@utils/helpers"; -import { Layers3Icon } from "lucide-react"; -import { useSearchParams } from "next/navigation"; +import { ArrowUpRightIcon, Layers3Icon } from "lucide-react"; +import { useRouter, useSearchParams } from "next/navigation"; import * as React from "react"; import { useState } from "react"; import { usePermissions } from "@/contexts/PermissionsProvider"; @@ -26,6 +26,7 @@ type Props = { resources?: NetworkResource[]; isLoading: boolean; headingTarget?: HTMLHeadingElement | null; + isGroupPage?: boolean; }; const NetworkResourceColumns: ColumnDef[] = [ @@ -105,6 +106,7 @@ export default function ResourcesTable({ resources, isLoading, headingTarget, + isGroupPage, }: Readonly) { const { permission } = usePermissions(); const params = useSearchParams(); @@ -112,6 +114,7 @@ export default function ResourcesTable({ const [sorting, setSorting] = useState([]); const { openResourceModal, network } = useNetworksContext(); + const router = useRouter(); return ( } - /> + > + {isGroupPage && permission?.networks?.create && ( + <> + + + )} + } columnVisibility={{ description: false, id: false, }} paginationPaddingClassName={"px-0 pt-8"} - rightSide={() => ( - - )} + rightSide={ + !isGroupPage + ? () => ( + + ) + : undefined + } > {(table) => ( import("@/modules/peer/AccessiblePeersTable"), + () => import("@/modules/peer/MinimalPeersTable"), ); type Props = { diff --git a/src/modules/peer/AccessiblePeersTable.tsx b/src/modules/peer/MinimalPeersTable.tsx similarity index 77% rename from src/modules/peer/AccessiblePeersTable.tsx rename to src/modules/peer/MinimalPeersTable.tsx index b015017..7d98fdc 100644 --- a/src/modules/peer/AccessiblePeersTable.tsx +++ b/src/modules/peer/MinimalPeersTable.tsx @@ -5,11 +5,18 @@ import DataTableHeader from "@components/table/DataTableHeader"; import DataTableRefreshButton from "@components/table/DataTableRefreshButton"; import { DataTableRowsPerPage } from "@components/table/DataTableRowsPerPage"; import NoResults from "@components/ui/NoResults"; -import { ColumnDef, SortingState } from "@tanstack/react-table"; +import { + ColumnDef, + Row, + RowSelectionState, + SortingState, + Table, +} from "@tanstack/react-table"; import * as React from "react"; import { useState } from "react"; import { useSWRConfig } from "swr"; import PeerIcon from "@/assets/icons/PeerIcon"; +import { usePermissions } from "@/contexts/PermissionsProvider"; import { Peer } from "@/interfaces/Peer"; import PeerAddressCell from "@/modules/peers/PeerAddressCell"; import PeerLastSeenCell from "@/modules/peers/PeerLastSeenCell"; @@ -18,12 +25,18 @@ import { PeerOSCell } from "@/modules/peers/PeerOSCell"; type Props = { peers?: Peer[]; - peerID: string; + peerID?: string; isLoading: boolean; headingTarget?: HTMLHeadingElement | null; + rightSide?: (table: Table) => React.ReactNode; + getStartedCard?: React.ReactNode; + columns?: ColumnDef[]; + selectedRows?: RowSelectionState; + setSelectedRows?: (updater: React.SetStateAction) => void; + onRowClick?: (row: Row) => void; }; -const AccessiblePeersColumns: ColumnDef[] = [ +const MinimalPeersTableColumns: ColumnDef[] = [ { accessorKey: "name", header: ({ column }) => { @@ -73,13 +86,21 @@ const AccessiblePeersColumns: ColumnDef[] = [ }, ]; -export default function AccessiblePeersTable({ +export default function MinimalPeersTable({ peers, isLoading, headingTarget, peerID, + rightSide, + columns = MinimalPeersTableColumns, + selectedRows, + setSelectedRows, + onRowClick, + getStartedCard, }: Props) { const { mutate } = useSWRConfig(); + const { permission } = usePermissions(); + // Default sorting state of the table const [sorting, setSorting] = useState([ { @@ -104,27 +125,36 @@ export default function AccessiblePeersTable({ useRowId={true} sorting={sorting} setSorting={setSorting} + rowSelection={selectedRows} + setRowSelection={setSelectedRows} + onRowClick={onRowClick} minimal={true} showSearchAndFilters={true} inset={false} tableClassName={"mt-0"} text={"Peers"} - columns={AccessiblePeersColumns} + columns={columns} keepStateInLocalStorage={false} data={peers} searchPlaceholder={"Search by name, IP, owner or group..."} isLoading={isLoading} getStartedCard={ - } - /> + !getStartedCard ? ( + } + /> + ) : ( + getStartedCard + ) } + rightSide={rightSide} columnVisibility={{ + select: permission?.groups?.update && permission?.peers?.update, connected: false, ip: false, user_name: false, @@ -200,7 +230,11 @@ export default function AccessiblePeersTable({ isDisabled={peers?.length == 0} onClick={() => { mutate("/users").then(); - mutate(`/peers/${peerID}/accessible-peers`).then(); + if (peerID) { + mutate(`/peers/${peerID}/accessible-peers`).then(); + return; + } + mutate(`/peers`).then(); }} /> diff --git a/src/modules/peers/PeerNameCell.tsx b/src/modules/peers/PeerNameCell.tsx index 4fe2475..355c69f 100644 --- a/src/modules/peers/PeerNameCell.tsx +++ b/src/modules/peers/PeerNameCell.tsx @@ -36,7 +36,12 @@ export default function PeerNameCell({ peer, linkToPeer = true }: Props) { )} data-testid="peer-name-cell" aria-label={`View details of peer ${peer.name}`} - onClick={() => linkToPeer && router.push("/peer?id=" + peer.id)} + onClick={(e) => { + if (!linkToPeer) return; + e.preventDefault(); + e.stopPropagation(); + router.push("/peer?id=" + peer.id); + }} > - + - } - color={"gray"} - size={"large"} - /> - } - title={"Create New Route"} - description={ - "It looks like you don't have any routes. Access LANs and VPC by adding a network route." - } - button={ -
- + isGroupPage ? ( + + } + className={"py-4"} + title={"This group is not used within any network routes yet"} + description={ + "Assign this group when creating a new route to see them listed here." + } + > +
+
- } - learnMore={ - <> - Learn more about - + ) : ( + } - target={"_blank"} - > - Network Routes - - - - } - /> + color={"gray"} + size={"large"} + /> + } + title={"Create New Route"} + description={ + "It looks like you don't have any routes. Access LANs and VPC by adding a network route." + } + button={ +
+ + +
+ } + learnMore={ + <> + Learn more about + + Network Routes + + + + } + /> + ) } rightSide={() => ( <> {routes && routes?.length > 0 && (
- + - -
- ); -} diff --git a/src/modules/settings/GroupsCountCell.tsx b/src/modules/settings/GroupsCountCell.tsx deleted file mode 100644 index b555d26..0000000 --- a/src/modules/settings/GroupsCountCell.tsx +++ /dev/null @@ -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 ( - - Group {groupName}{" "} - is used in {count}{" "} - {text} -
- } - > - - {icon} - {count} - - - ); -} diff --git a/src/modules/settings/GroupsNameCell.tsx b/src/modules/settings/GroupsNameCell.tsx deleted file mode 100644 index 10a2f32..0000000 --- a/src/modules/settings/GroupsNameCell.tsx +++ /dev/null @@ -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) { - return ( -
-
-
-
- -
- -
-
- -
-
- -
-
-
- ); -} diff --git a/src/modules/settings/GroupsTab.tsx b/src/modules/settings/GroupsSettings.tsx similarity index 83% rename from src/modules/settings/GroupsTab.tsx rename to src/modules/settings/GroupsSettings.tsx index 7221c12..fd9c34b 100644 --- a/src/modules/settings/GroupsTab.tsx +++ b/src/modules/settings/GroupsSettings.tsx @@ -5,10 +5,6 @@ import HelpText from "@components/HelpText"; import { Input } from "@components/Input"; import { Label } from "@components/Label"; 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 { useApiCall } from "@utils/api"; import { cn } from "@utils/helpers"; @@ -24,7 +20,7 @@ import { ShieldCheck, X, } from "lucide-react"; -import React, { lazy, Suspense, useState } from "react"; +import React, { useState } from "react"; import { useSWRConfig } from "swr"; import SettingsIcon from "@/assets/icons/SettingsIcon"; import Badge from "@/components/Badge"; @@ -33,13 +29,12 @@ import { usePermissions } from "@/contexts/PermissionsProvider"; import { useHasChanges } from "@/hooks/useHasChanges"; import { Account } from "@/interfaces/Account"; -const GroupsTable = lazy(() => import("@/modules/settings/GroupsTable")); type Props = { account: Account; }; -export default function GroupsTab({ account }: Props) { +export default function GroupsSettings({ account }: Props) { const { permission } = usePermissions(); const { mutate } = useSWRConfig(); @@ -87,28 +82,22 @@ export default function GroupsTab({ account }: Props) { const showConfirm = jwtGroupSync && jwtGroupsEntered; const choice = showConfirm ? await confirm({ - title: `JWT allow group${ - jwtAllowGroups.length > 1 ? "s" : "" - } - ${jwtAllowGroups.join(", ")}`, - description: `Only users part of ${ - jwtAllowGroups.length > 1 - ? `these groups (${jwtAllowGroups.join(", ")})` - : `the ${jwtAllowGroups[0]} group` - } will be able to access NetBird. Are you sure you want to save the changes?`, - confirmText: "Save", - children: ( -
- - To prevent losing access, ensure you are part of this group. -
- ), - cancelText: "Cancel", - type: "default", - }) + title: `JWT allow group - ${jwtAllowGroups[0]}`, + description: `Only users part of the ${jwtAllowGroups[0]} group will be able to access NetBird. Are you sure you want to save the changes?`, + confirmText: "Save", + children: ( +
+ + To prevent losing access, ensure you are part of this group. +
+ ), + cancelText: "Cancel", + type: "default", + }) : true; if (!choice) return; @@ -323,36 +312,6 @@ export default function GroupsTab({ account }: Props) { )} - ); } - -const GroupsSection = () => { - const { ref: headingRef, portalTarget } = - usePortalElement(); - - return ( - <> - -
-
-
-
-

Groups

- - Here is the overview of the groups of your account. You can - delete the unused ones. - -
-
-
-
-
- }> - - -
- - ); -}; diff --git a/src/modules/setup-keys/SetupKeyModal.tsx b/src/modules/setup-keys/SetupKeyModal.tsx index e3d9f34..78f373d 100644 --- a/src/modules/setup-keys/SetupKeyModal.tsx +++ b/src/modules/setup-keys/SetupKeyModal.tsx @@ -33,6 +33,7 @@ import { import React, { useMemo, useState } from "react"; import { useSWRConfig } from "swr"; import SetupKeysIcon from "@/assets/icons/SetupKeysIcon"; +import { Group } from "@/interfaces/Group"; import { SetupKey } from "@/interfaces/SetupKey"; import useGroupHelper from "@/modules/groups/useGroupHelper"; import SetupModal from "@/modules/setup-netbird-modal/SetupModal"; @@ -43,6 +44,7 @@ type Props = { setOpen: (open: boolean) => void; name?: string; showOnlyRoutingPeerOS?: boolean; + groups?: Group[]; }; const copyMessage = "Setup-Key was copied to your clipboard!"; @@ -53,6 +55,7 @@ export default function SetupKeyModal({ setOpen, name, showOnlyRoutingPeerOS, + groups, }: Readonly) { const [successModal, setSuccessModal] = useState(false); const [setupKey, setSetupKey] = useState(); @@ -66,7 +69,11 @@ export default function SetupKeyModal({ <> {children && {children}} - + void; predefinedName?: string; + groups?: Group[]; }; export function SetupKeyModalContent({ onSuccess, predefinedName = "", + groups, }: Readonly) { const setupKeyRequest = useApiCall("/setup-keys", true); const { mutate } = useSWRConfig(); @@ -173,7 +182,7 @@ export function SetupKeyModalContent({ const [selectedGroups, setSelectedGroups, { save: saveGroups }] = useGroupHelper({ - initial: [], + initial: groups ?? [], }); const usageLimitPlaceholder = useMemo(() => { diff --git a/src/modules/setup-keys/SetupKeysTable.tsx b/src/modules/setup-keys/SetupKeysTable.tsx index cba3292..179e2f7 100644 --- a/src/modules/setup-keys/SetupKeysTable.tsx +++ b/src/modules/setup-keys/SetupKeysTable.tsx @@ -1,5 +1,6 @@ import Button from "@components/Button"; import ButtonGroup from "@components/ButtonGroup"; +import Card from "@components/Card"; import InlineLink from "@components/InlineLink"; import SquareIcon from "@components/SquareIcon"; import { DataTable } from "@components/table/DataTable"; @@ -7,6 +8,7 @@ import DataTableHeader from "@components/table/DataTableHeader"; import DataTableRefreshButton from "@components/table/DataTableRefreshButton"; import { DataTableRowsPerPage } from "@components/table/DataTableRowsPerPage"; import GetStartedTest from "@components/ui/GetStartedTest"; +import NoResults from "@components/ui/NoResults"; import { ColumnDef, SortingState } from "@tanstack/react-table"; import dayjs from "dayjs"; import { ExternalLinkIcon, PlusCircle } from "lucide-react"; @@ -16,6 +18,7 @@ import { useSWRConfig } from "swr"; import SetupKeysIcon from "@/assets/icons/SetupKeysIcon"; import { usePermissions } from "@/contexts/PermissionsProvider"; import { useLocalStorage } from "@/hooks/useLocalStorage"; +import { Group } from "@/interfaces/Group"; import { SetupKey } from "@/interfaces/SetupKey"; import EmptyRow from "@/modules/common-table-rows/EmptyRow"; import ExpirationDateRow from "@/modules/common-table-rows/ExpirationDateRow"; @@ -118,12 +121,16 @@ type Props = { setupKeys?: SetupKey[]; isLoading: boolean; headingTarget?: HTMLHeadingElement | null; + isGroupPage?: boolean; + groups?: Group[]; }; export default function SetupKeysTable({ setupKeys, isLoading, headingTarget, + isGroupPage, + groups, }: Readonly) { const { mutate } = useSWRConfig(); const path = usePathname(); @@ -146,16 +153,24 @@ export default function SetupKeysTable({ desc: true, }, ], + !isGroupPage, ); const [open, setOpen] = useState(false); return ( <> - {open && } + {open && } - } - 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={ + isGroupPage ? ( + } + className={"py-4"} + title={"This group is not used within any setup keys yet"} + description={ + "Assign this group when creating a new setup key to see them listed here." + } + > - } - learnMore={ - <> - Learn more about - + ) : ( + } - 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={ + + } + learnMore={ + <> + Learn more about + + Setup Keys + + + + } + /> + ) } rightSide={() => ( <> diff --git a/src/modules/users/UserInviteModal.tsx b/src/modules/users/UserInviteModal.tsx index 4f4872e..3c48ad6 100644 --- a/src/modules/users/UserInviteModal.tsx +++ b/src/modules/users/UserInviteModal.tsx @@ -22,15 +22,17 @@ import Avatar1 from "@/assets/avatars/009.jpg"; import Avatar2 from "@/assets/avatars/030.jpg"; import Avatar3 from "@/assets/avatars/063.jpg"; import Avatar4 from "@/assets/avatars/086.jpg"; +import { Group } from "@/interfaces/Group"; import { Role, User } from "@/interfaces/User"; import useGroupHelper from "@/modules/groups/useGroupHelper"; import { UserRoleSelector } from "@/modules/users/UserRoleSelector"; type Props = { children: React.ReactNode; + groups?: Group[]; }; -export default function UserInviteModal({ children }: Readonly) { +export default function UserInviteModal({ children, groups }: Readonly) { const [open, setOpen] = useState(false); const { mutate } = useSWRConfig(); @@ -44,16 +46,20 @@ export default function UserInviteModal({ children }: Readonly) { return ( {children} - + ); } type ModalProps = { onSuccess: () => void; + groups?: Group[]; }; -export function UserInviteModalContent({ onSuccess }: Readonly) { +export function UserInviteModalContent({ + onSuccess, + groups = [], +}: Readonly) { const userRequest = useApiCall("/users"); const { mutate } = useSWRConfig(); @@ -62,7 +68,7 @@ export function UserInviteModalContent({ onSuccess }: Readonly) { const [role, setRole] = useState("user"); const [selectedGroups, setSelectedGroups, { save: saveGroups }] = useGroupHelper({ - initial: [], + initial: groups, }); const sendInvite = async () => { @@ -95,7 +101,7 @@ export function UserInviteModalContent({ onSuccess }: Readonly) { }, [name, isValidEmail]); return ( - +
) {
diff --git a/src/modules/users/UsersTable.tsx b/src/modules/users/UsersTable.tsx index efa68a8..07ace53 100644 --- a/src/modules/users/UsersTable.tsx +++ b/src/modules/users/UsersTable.tsx @@ -1,4 +1,5 @@ import Button from "@components/Button"; +import Card from "@components/Card"; import InlineLink from "@components/InlineLink"; import SquareIcon from "@components/SquareIcon"; import { DataTable } from "@components/table/DataTable"; @@ -6,8 +7,13 @@ import DataTableHeader from "@components/table/DataTableHeader"; import DataTableRefreshButton from "@components/table/DataTableRefreshButton"; import { DataTableRowsPerPage } from "@components/table/DataTableRowsPerPage"; import GetStartedTest from "@components/ui/GetStartedTest"; -import { NotificationCountBadge } from "@components/ui/NotificationCountBadge"; -import { ColumnDef, SortingState } from "@tanstack/react-table"; +import { + ColumnDef, + Row, + RowSelectionState, + SortingState, + Table, +} from "@tanstack/react-table"; import useFetchApi from "@utils/api"; import { isLocalDev, isNetBirdHosted } from "@utils/netbird"; import dayjs from "dayjs"; @@ -18,6 +24,7 @@ import { useSWRConfig } from "swr"; import TeamIcon from "@/assets/icons/TeamIcon"; import { usePermissions } from "@/contexts/PermissionsProvider"; import { useLocalStorage } from "@/hooks/useLocalStorage"; +import { Group } from "@/interfaces/Group"; import { User } from "@/interfaces/User"; import LastTimeRow from "@/modules/common-table-rows/LastTimeRow"; import { PendingApprovalFilter } from "@/modules/users/PendingApprovalFilter"; @@ -108,12 +115,28 @@ type Props = { users?: User[]; isLoading?: boolean; headingTarget?: HTMLHeadingElement | null; + minimal?: boolean; + rightSide?: (table: Table) => React.ReactNode; + getStartedCard?: React.ReactNode; + columns?: ColumnDef[]; + selectedRows?: RowSelectionState; + setSelectedRows?: (updater: React.SetStateAction) => void; + onRowClick?: (row: Row) => void; + keepStateInLocalStorage?: boolean; }; export default function UsersTable({ users, isLoading, headingTarget, + minimal, + rightSide, + getStartedCard, + columns = UsersTableColumns, + selectedRows, + setSelectedRows, + onRowClick, + keepStateInLocalStorage = true, }: Readonly) { useFetchApi("/groups"); const { mutate } = useSWRConfig(); @@ -132,67 +155,89 @@ export default function UsersTable({ desc: true, }, ], + keepStateInLocalStorage, ); const router = useRouter(); + const { permission } = usePermissions(); return ( { - router.push(`/team/user?id=${row.original.id}`); - }} + onRowClick={ + !onRowClick + ? (row) => { + router.push(`/team/user?id=${row.original.id}`); + } + : onRowClick + } searchPlaceholder={"Search by name, email or role..."} getStartedCard={ - } - color={"gray"} - size={"large"} - /> - } - title={"Add New Users"} - description={ - "It looks like you don't have any users yet. Get started by inviting users to your account." - } - button={ -
- -
- } - learnMore={ - <> - Learn more about - - Users - - - - } - /> + !getStartedCard ? ( + } + color={"gray"} + size={"large"} + /> + } + title={"Add New Users"} + description={ + "It looks like you don't have any users yet. Get started by inviting users to your account." + } + button={ +
+ +
+ } + learnMore={ + <> + Learn more about + + Users + + + + } + /> + ) : ( + getStartedCard + ) + } + rightSide={ + !rightSide + ? () => ( + 0} + className={"ml-auto"} + /> + ) + : rightSide } - rightSide={() => ( - 0} - className={"ml-auto"} - /> - )} > {(table) => { return ( @@ -220,18 +265,20 @@ export default function UsersTable({ type InviteUserButtonProps = { show?: boolean; className?: string; + groups?: Group[]; }; -const InviteUserButton = ({ +export const InviteUserButton = ({ show = false, className, + groups, }: InviteUserButtonProps) => { const { permission } = usePermissions(); if (!show) return null; return ( (isLocalDev() || isNetBirdHosted()) && ( - +