Compare commits

...
5 Commits
Author SHA1 Message Date
Eduard Gert e2219aeea0 Add group update activity event (#504) 2025-11-10 10:50:04 +01:00
Eduard Gert 63f4c69eb4 Fix native ssh detection (#505) 2025-11-07 09:33:58 +01:00
Eduard Gert b1af256296 Add wasm client version (#503) 2025-11-06 10:59:41 +01:00
Eduard Gertandaliamerj 4027894a2e Feature/groups page (#498)
* move our group membership from the settings menu, into the Team menu

* add action to the table and new group page

* update group page and return group settings to settings menu

* new update

* fix bug

* group action: add peer to group

* group action: add user to group

* Update wording, redirect to group page after creation

* Add better table loading skeleton

* Adjust group name cell

* Update wording

* Update sort order

* Refactor

* Merge main

* Fix button height

* Fix resources table

* Adjust table loading skeleton

* Adjust table loading skeleton

* Add loading to tab triggers

* Update meta

* Update group location

* Fix rename

* Refactor group details

* Fix linked peers

* Fix group usage

* Fix incrementing peer count

* Prevent renaming to already existing group

* Fix group name click

* Update group nav

* Make group table cells clickable

* Fix breadcrumbs

* Update wording

* Add confirmation before removing users from group

* Add permissions

* Add initial group for network routes

* Add acl and routing peer groups

---------

Co-authored-by: aliamerj <aliamer19ali@gmail.com>
2025-11-05 12:08:49 +01:00
Yanis64andEduard Gert af90792595 Add multi-group support for JWT allow groups with tag system (#500)
* fix: add multi-group support for JWT allow groups with tag system

* Update src/modules/settings/GroupsTab.tsx to use the Badge component

Co-authored-by: Eduard Gert <kontakt@eduardgert.de>

* chore(GroupsTab): import Badge components

---------

Co-authored-by: Eduard Gert <kontakt@eduardgert.de>
2025-11-03 16:09:15 +01:00
68 changed files with 3485 additions and 766 deletions

No files matched your search

+1 -1
View File
@@ -61,7 +61,7 @@ export NETBIRD_GOOGLE_ANALYTICS_ID=${NETBIRD_GOOGLE_ANALYTICS_ID}
export NETBIRD_GOOGLE_TAG_MANAGER_ID=${NETBIRD_GOOGLE_TAG_MANAGER_ID}
export NETBIRD_TOKEN_SOURCE=${NETBIRD_TOKEN_SOURCE:-accessToken}
export NETBIRD_DRAG_QUERY_PARAMS=${NETBIRD_DRAG_QUERY_PARAMS:-false}
export NETBIRD_WASM_PATH=${NETBIRD_WASM_PATH:-https://pkgs.netbird.io/wasm/client}
export NETBIRD_WASM_PATH=${NETBIRD_WASM_PATH}
echo "NetBird latest version: ${NETBIRD_LATEST_VERSION}"
+1 -1
View File
@@ -33,7 +33,7 @@ export default function AccessControlPage() {
<div className={"p-default py-6"}>
<Breadcrumbs>
<Breadcrumbs.Item
href={"/policies"}
href={"/access-control"}
label={"Access Control"}
icon={<AccessControlIcon size={14} />}
/>
+1 -1
View File
@@ -32,7 +32,7 @@ export default function NameServers() {
<div className={"p-default py-6"}>
<Breadcrumbs>
<Breadcrumbs.Item
href={"/dns"}
href={"/dns/nameservers"}
label={"DNS"}
icon={<DNSIcon size={13} />}
/>
+8
View File
@@ -0,0 +1,8 @@
import { globalMetaTitle } from "@utils/meta";
import type { Metadata } from "next";
import BlankLayout from "@/layouts/BlankLayout";
export const metadata: Metadata = {
title: `Group - ${globalMetaTitle}`,
};
export default BlankLayout;
+297
View File
@@ -0,0 +1,297 @@
"use client";
import Breadcrumbs from "@components/Breadcrumbs";
import FullTooltip from "@components/FullTooltip";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@components/Tabs";
import FullScreenLoading from "@components/ui/FullScreenLoading";
import { GroupBadgeIcon } from "@components/ui/GroupBadgeIcon";
import { PageNotFound } from "@components/ui/PageNotFound";
import { RestrictedAccess } from "@components/ui/RestrictedAccess";
import useRedirect from "@hooks/useRedirect";
import useFetchApi from "@utils/api";
import { cn, singularize } from "@utils/helpers";
import { FolderGit2Icon, Layers3Icon, PencilIcon } from "lucide-react";
import { useSearchParams } from "next/navigation";
import React, { useState } from "react";
import AccessControlIcon from "@/assets/icons/AccessControlIcon";
import DNSIcon from "@/assets/icons/DNSIcon";
import NetworkRoutesIcon from "@/assets/icons/NetworkRoutesIcon";
import PeerIcon from "@/assets/icons/PeerIcon";
import SetupKeysIcon from "@/assets/icons/SetupKeysIcon";
import TeamIcon from "@/assets/icons/TeamIcon";
import { GroupProvider, useGroupContext } from "@/contexts/GroupProvider";
import { usePermissions } from "@/contexts/PermissionsProvider";
import RoutesProvider from "@/contexts/RoutesProvider";
import { Group, GROUP_TOOLTIP_TEXT } from "@/interfaces/Group";
import PageContainer from "@/layouts/PageContainer";
import { GroupNameserversSection } from "@/modules/groups/details/GroupNameserversSection";
import { GroupNetworkRoutesSection } from "@/modules/groups/details/GroupNetworkRoutesSection";
import { GroupPeersSection } from "@/modules/groups/details/GroupPeersSection";
import { GroupPoliciesSection } from "@/modules/groups/details/GroupPoliciesSection";
import { GroupResourcesSection } from "@/modules/groups/details/GroupResourcesSection";
import { GroupSetupKeysSection } from "@/modules/groups/details/GroupSetupKeysSection";
import { GroupUsersSection } from "@/modules/groups/details/GroupUsersSection";
import useGroupDetails from "@/modules/groups/details/useGroupDetails";
export default function GroupPage() {
const queryParameter = useSearchParams();
const { isRestricted } = usePermissions();
const groupId = queryParameter.get("id");
const {
data: group,
isLoading,
error,
} = useFetchApi<Group>(`/groups/${groupId}`, true);
useRedirect("/groups", false, !groupId || isRestricted);
if (isRestricted) {
return (
<PageContainer>
<RestrictedAccess page={"Group Information"} />
</PageContainer>
);
}
if (error)
return (
<PageNotFound
title={error?.message}
description={
"The group you are attempting to access cannot be found. It may have been deleted, or you may not have permission to view it. Please verify the URL or return to the dashboard."
}
/>
);
return group && !isLoading ? (
<PageContainer>
<RoutesProvider>
<GroupProvider group={group} isDetailPage={true}>
<div className={"p-default py-6 pb-0 w-full mb-[6px]"}>
<Breadcrumbs>
<Breadcrumbs.Item
href={"/groups"}
label={"Groups"}
icon={<FolderGit2Icon size={14} />}
/>
<Breadcrumbs.Item label={group.name} active />
</Breadcrumbs>
<GroupDetailsName />
</div>
<GroupOverviewTabs group={group} />
</GroupProvider>
</RoutesProvider>
</PageContainer>
) : (
<FullScreenLoading />
);
}
const GroupDetailsName = () => {
const { group, isJWTGroup, isAllowedToRename, openGroupRenameModal } =
useGroupContext();
const { permission } = usePermissions();
return (
<div className={"w-full"}>
<h1 className={"flex items-center gap-3 w-full whitespace-nowrap"}>
<GroupBadgeIcon id={group?.id} issued={group?.issued} size={20} />
{group.name}
{group.name !== "All" && permission?.groups?.update && (
<div>
<FullTooltip
content={
<div className={"text-xs max-w-xs"}>
{isJWTGroup
? GROUP_TOOLTIP_TEXT.RENAME.JWT
: GROUP_TOOLTIP_TEXT.RENAME.INTEGRATION}
</div>
}
interactive={false}
disabled={isAllowedToRename}
className={"w-full block"}
>
<div
className={cn(
"flex h-8 w-8 items-center justify-center gap-2 dark:text-neutral-300 text-neutral-500 hover:text-neutral-100 transition-all hover:bg-nb-gray-800/60 rounded-md cursor-pointer",
!isAllowedToRename &&
"opacity-40 cursor-not-allowed pointer-events-none",
)}
onClick={openGroupRenameModal}
>
<PencilIcon size={16} />
</div>
</FullTooltip>
</div>
)}
</h1>
</div>
);
};
const validAllGroupTabs = [
"policies",
"resources",
"network-routes",
"nameservers",
];
const validOtherGroupTabs = ["users", "peers", "setup-keys"];
const GroupOverviewTabs = ({ group }: { group: Group }) => {
const searchParams = useSearchParams();
const getInitialTab = () => {
const isAllGroup = group.name === "All";
const tabParam = searchParams.get("tab");
const validTabs = isAllGroup
? validAllGroupTabs
: [...validAllGroupTabs, ...validOtherGroupTabs];
if (tabParam === null) return isAllGroup ? "policies" : "users";
if (isAllGroup) {
return validTabs.includes(tabParam) ? tabParam : "policies";
}
return validTabs.includes(tabParam) ? tabParam : "users";
};
const [tab, setTab] = useState(getInitialTab());
const groupDetails = useGroupDetails(group?.id || "");
const peersCount = groupDetails?.peers_count || 0;
const usersCount = groupDetails?.users?.length || 0;
const policiesCount = groupDetails?.policies?.length || 0;
const resourcesCount = groupDetails?.resources_count || 0;
const routesCount = groupDetails?.routes?.length || 0;
const nameserversCount = groupDetails?.nameservers?.length || 0;
const setupKeysCount = groupDetails?.setupKeys?.length || 0;
return (
<Tabs
defaultValue={tab}
onValueChange={(v) => setTab(v)}
value={tab}
className={"pt-2 pb-0 mb-0"}
>
<TabsList justify={"start"} className={"px-8"}>
{group.name !== "All" && (
<TabsTrigger
value={"users"}
className={groupDetails === null ? "animate-pulse" : ""}
>
<TeamIcon
size={12}
className={
"fill-nb-gray-500 group-data-[state=active]/trigger:fill-netbird transition-all"
}
/>
{singularize("Users", usersCount)}
</TabsTrigger>
)}
{group.name !== "All" && (
<TabsTrigger
value={"peers"}
className={groupDetails === null ? "animate-pulse" : ""}
>
<PeerIcon
size={12}
className={
"fill-nb-gray-500 group-data-[state=active]/trigger:fill-netbird transition-all"
}
/>
{singularize("Peers", peersCount)}
</TabsTrigger>
)}
<TabsTrigger
value={"policies"}
className={groupDetails === null ? "animate-pulse" : ""}
>
<AccessControlIcon
size={12}
className={
"fill-nb-gray-500 group-data-[state=active]/trigger:fill-netbird transition-all"
}
/>
{singularize("Policies", policiesCount)}
</TabsTrigger>
<TabsTrigger
value={"resources"}
className={groupDetails === null ? "animate-pulse" : ""}
>
<Layers3Icon size={14} />
{singularize("Resources", resourcesCount)}
</TabsTrigger>
<TabsTrigger
value={"network-routes"}
className={groupDetails === null ? "animate-pulse" : ""}
>
<NetworkRoutesIcon
size={12}
className={
"fill-nb-gray-500 group-data-[state=active]/trigger:fill-netbird transition-all"
}
/>
{singularize("Network Routes", routesCount)}
</TabsTrigger>
<TabsTrigger
value={"nameservers"}
className={groupDetails === null ? "animate-pulse" : ""}
>
<DNSIcon
size={12}
className={
"fill-nb-gray-500 group-data-[state=active]/trigger:fill-netbird transition-all"
}
/>
{singularize("Nameservers", nameserversCount)}
</TabsTrigger>
{group.name !== "All" && (
<TabsTrigger
value={"setup-keys"}
className={groupDetails === null ? "animate-pulse" : ""}
>
<SetupKeysIcon
size={12}
className={
"fill-nb-gray-500 group-data-[state=active]/trigger:fill-netbird transition-all"
}
/>
{singularize("Setup Keys", setupKeysCount)}
</TabsTrigger>
)}
</TabsList>
<TabsContent value={"users"} className={"pb-8"}>
<GroupUsersSection users={groupDetails?.users} />
</TabsContent>
<TabsContent value={"peers"} className={"pb-8"}>
<GroupPeersSection peers={groupDetails?.peersOfGroup} />
</TabsContent>
<TabsContent value={"policies"} className={"pb-8"}>
<GroupPoliciesSection policies={groupDetails?.policies} />
</TabsContent>
<TabsContent value={"resources"} className={"pb-8"}>
<GroupResourcesSection resources={groupDetails?.networkResources} />
</TabsContent>
<TabsContent value={"network-routes"} className={"pb-8"}>
<GroupNetworkRoutesSection routes={groupDetails?.routes} />
</TabsContent>
<TabsContent value={"nameservers"} className={"pb-8"}>
<GroupNameserversSection nameserverGroups={groupDetails?.nameservers} />
</TabsContent>
<TabsContent value={"setup-keys"} className={"pb-8"}>
<GroupSetupKeysSection setupKeys={groupDetails?.setupKeys} />
</TabsContent>
</Tabs>
);
};
+8
View File
@@ -0,0 +1,8 @@
import { globalMetaTitle } from "@utils/meta";
import type { Metadata } from "next";
import BlankLayout from "@/layouts/BlankLayout";
export const metadata: Metadata = {
title: `Groups - ${globalMetaTitle}`,
};
export default BlankLayout;
+56
View File
@@ -0,0 +1,56 @@
"use client";
import Paragraph from "@components/Paragraph";
import SkeletonTable from "@components/skeletons/SkeletonTable";
import { RestrictedAccess } from "@components/ui/RestrictedAccess";
import { usePortalElement } from "@hooks/usePortalElement";
import { ExternalLinkIcon, FolderGit2Icon } from "lucide-react";
import React, { lazy, Suspense } from "react";
import Breadcrumbs from "@/components/Breadcrumbs";
import InlineLink from "@/components/InlineLink";
import { usePermissions } from "@/contexts/PermissionsProvider";
import PageContainer from "@/layouts/PageContainer";
const GroupsTable = lazy(() => import("@/modules/groups/table/GroupsTable"));
export default function GroupsPage() {
const { permission } = usePermissions();
const { ref: headingRef, portalTarget } =
usePortalElement<HTMLHeadingElement>();
return (
<PageContainer>
<div className={"p-default py-6"}>
<Breadcrumbs>
<Breadcrumbs.Item
href={"/groups"}
label={"Groups"}
icon={<FolderGit2Icon size={14} />}
active
/>
</Breadcrumbs>
<h1 ref={headingRef}>Groups</h1>
<Paragraph>
Here is the overview of the groups of your organization. You can
delete the unused ones.
</Paragraph>
<Paragraph>
Learn more about{" "}
<InlineLink
href={"https://docs.netbird.io/how-to/manage-network-access"}
target={"_blank"}
>
Groups
<ExternalLinkIcon size={12} />
</InlineLink>
in our documentation.
</Paragraph>
</div>
<RestrictedAccess hasAccess={permission.groups.read} page={"Groups"}>
<Suspense fallback={<SkeletonTable />}>
<GroupsTable headingTarget={portalTarget} />
</Suspense>
</RestrictedAccess>
</PageContainer>
);
}
+2 -2
View File
@@ -19,9 +19,9 @@ import { useAccount } from "@/modules/account/useAccount";
import AuthenticationTab from "@/modules/settings/AuthenticationTab";
import ClientSettingsTab from "@/modules/settings/ClientSettingsTab";
import DangerZoneTab from "@/modules/settings/DangerZoneTab";
import GroupsTab from "@/modules/settings/GroupsTab";
import NetworkSettingsTab from "@/modules/settings/NetworkSettingsTab";
import PermissionsTab from "@/modules/settings/PermissionsTab";
import GroupsSettings from "@/modules/settings/GroupsSettings";
export default function NetBirdSettings() {
const queryParams = useSearchParams();
@@ -81,7 +81,7 @@ export default function NetBirdSettings() {
<div className={"border-l border-nb-gray-930 w-full"}>
{account && <AuthenticationTab account={account} />}
{account && <PermissionsTab account={account} />}
{account && <GroupsTab account={account} />}
{account && <GroupsSettings account={account} />}
{account && <NetworkSettingsTab account={account} />}
{account && <ClientSettingsTab account={account} />}
{account && <DangerZoneTab account={account} />}
+1 -1
View File
@@ -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,
)}
+9 -1
View File
@@ -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([
+2 -2
View File
@@ -10,7 +10,7 @@ export default function SkeletonTable({ withHeader = true }: Readonly<Props>) {
return (
<div className={"w-full"}>
{withHeader && <SkeletonTableHeader />}
<div className={"mt-6"}>
<div className={"mt-6 relative -top-1"}>
<TableSkeletonRow />
<TableSkeletonRow odd />
<TableSkeletonRow />
@@ -68,7 +68,7 @@ export const SkeletonTableHeader = ({
return (
<div
className={cn(
"flex gap-x-4 gap-y-6 p-default flex-wrap w-full justify-between",
"flex gap-x-4 gap-y-6 p-default flex-wrap w-full justify-between relative -top-1",
className,
)}
>
+5 -1
View File
@@ -133,6 +133,7 @@ interface DataTableProps<TData, TValue> {
getStartedCard?: React.ReactNode;
placeholders?: TData[];
renderExpandedRow?: (row: TData) => React.ReactNode;
renderRow?: (row: TData, children: React.ReactNode) => React.ReactNode;
minimal?: boolean;
className?: string;
inset?: boolean;
@@ -193,6 +194,7 @@ export function DataTable<TData, TValue>({
onRowClick,
getStartedCard,
renderExpandedRow,
renderRow,
minimal,
className,
tableClassName,
@@ -507,7 +509,7 @@ export function DataTable<TData, TValue>({
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => {
const expandedRow = renderExpandedRow?.(row.original);
return (
const rowContent = (
<AccordionItem
value={row.original.id}
asChild={true}
@@ -597,6 +599,8 @@ export function DataTable<TData, TValue>({
</>
</AccordionItem>
);
return renderRow ? renderRow(row.original, rowContent) : rowContent;
})
) : (
<TableRowUnstyledComponent>
@@ -0,0 +1,89 @@
import Button from "@components/Button";
import FullTooltip from "@components/FullTooltip";
import { IconX } from "@tabler/icons-react";
import { cn } from "@utils/helpers";
import { AnimatePresence, motion } from "framer-motion";
import { MonitorSmartphoneIcon } from "lucide-react";
import * as React from "react";
type Props<T> = {
selectedItems?: T[];
label?: string;
onCanceled?: () => void;
rightSide?: React.ReactNode;
};
export function DataTableMultiSelectPopup<T>({
onCanceled,
label = "Peer(s) selected",
selectedItems,
rightSide,
}: Props<T>) {
const count = selectedItems?.length || 0;
return (
<AnimatePresence>
{count > 0 && (
<div
className={"fixed -bottom-16 z-50 w-full left-0 pointer-events-none"}
>
<motion.div
exit={{
y: 100,
}}
>
<AnimatePresence>
<motion.div
animate={{ y: 0 }}
initial={{ y: 100 }}
exit={{ y: 100 }}
transition={{
type: "spring",
stiffness: 270,
damping: 25,
duration: 0.35,
}}
className={cn(
"max-w-xl mx-auto border relative z-[50] bg-nb-gray-800 border-nb-gray-900 shadow-2xl border-b-0 overflow-hidden pointer-events-auto",
"rounded-t-lg",
)}
>
<AnimatePresence mode={"popLayout"}>
<div
className={
"flex gap-2 items-center text-sm px-6 pt-3.5 pb-20 bg-nb-gray-920/90 text-nb-gray-200 justify-between"
}
>
<div className={"flex gap-2 items-center"}>
<MonitorSmartphoneIcon size={16} className={""} />
<span>
<span className={"font-medium text-white"}>
{count}
</span>{" "}
{label}
</span>
</div>
<div className={"flex gap-2 items-center"}>
{rightSide}
<FullTooltip
content={<span className={"text-xs"}>Cancel</span>}
>
<Button
onClick={onCanceled}
variant={"default-outline"}
size={"xs"}
className={"!h-9 !w-9"}
>
<IconX size={16} className={"shrink-0"} />
</Button>
</FullTooltip>
</div>
</div>
</AnimatePresence>
</motion.div>
</AnimatePresence>
</motion.div>
</div>
)}
</AnimatePresence>
);
}
@@ -35,7 +35,7 @@ export default function DataTableRefreshButton({ onClick, isDisabled }: Props) {
}}
>
<Button
className={"h-[44px]"}
className={"h-[42px]"}
variant={"secondary"}
disabled={isDisabled == true ? true : disabled}
>
@@ -36,7 +36,7 @@ export default function DataTableResetFilterButton<TData>({
}}
>
<Button
className={"h-[44px]"}
className={"h-[42px]"}
variant={"secondary"}
onClick={onClick}
>
+115
View File
@@ -0,0 +1,115 @@
import Button from "@components/Button";
import HelpText from "@components/HelpText";
import InlineLink from "@components/InlineLink";
import { Input } from "@components/Input";
import { Label } from "@components/Label";
import {
Modal,
ModalClose,
ModalContent,
ModalFooter,
ModalTrigger,
} from "@components/modal/Modal";
import { ExternalLinkIcon, FolderGit2Icon, PlusCircle } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { useSWRConfig } from "swr";
import { usePermissions } from "@/contexts/PermissionsProvider";
import { Group } from "@/interfaces/Group";
import { useApiCall } from "@/utils/api";
import ModalHeader from "../modal/ModalHeader";
import { notify } from "../Notification";
import Paragraph from "../Paragraph";
import Separator from "../Separator";
export const AddGroupButton = () => {
const create = useApiCall<Group>("/groups", true).post;
const { mutate } = useSWRConfig();
const [name, setName] = useState<string>("");
const [open, setOpen] = useState(false);
const router = useRouter();
const { permission } = usePermissions();
const createGroup = () => {
notify({
title: "Create Group",
description: `Group '${name}' successfully created`,
loadingMessage: "Creating group...",
promise: create({ name }).then((g) => {
setOpen(false);
setName("");
mutate("/groups");
router.push(`/group?id=${g?.id}`);
}),
});
};
return (
permission?.groups?.create && (
<Modal open={open} onOpenChange={setOpen}>
<ModalTrigger asChild>
<Button
variant={"primary"}
size={"sm"}
className={"ml-auto h-[42px]"}
>
<PlusCircle size={16} />
Create Group
</Button>
</ModalTrigger>
<ModalContent maxWidthClass={"max-w-xl"}>
<ModalHeader
icon={<FolderGit2Icon size={18} />}
title="Create Group"
description="Create a group to manage and organize access in your network"
color="netbird"
/>
<Separator />
<div className={"px-8 flex-col flex gap-6 py-6"}>
<div>
<Label>Name</Label>
<HelpText>
Set an easily identifiable name for your group
</HelpText>
<Input
tabIndex={0}
placeholder={"e.g., Developers"}
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
</div>
<ModalFooter className={"items-center"}>
<div className={"w-full"}>
<Paragraph className={"text-sm mt-auto"}>
Learn more about
<InlineLink
href={"https://docs.netbird.io/how-to/manage-network-access"}
target={"_blank"}
>
Groups
<ExternalLinkIcon size={12} />
</InlineLink>
</Paragraph>
</div>
<div className={"flex gap-3 w-full justify-end"}>
<ModalClose asChild={true}>
<Button variant={"secondary"}>Cancel</Button>
</ModalClose>
<Button
variant={"primary"}
data-cy={"submit-route"}
disabled={!name}
onClick={createGroup}
>
<PlusCircle size={16} />
Create Group
</Button>
</div>
</ModalFooter>
</ModalContent>
</Modal>
)
);
};
@@ -0,0 +1,21 @@
import Button from "@components/Button";
import { Modal, ModalTrigger } from "@components/modal/Modal";
import { DownloadIcon } from "lucide-react";
import React, { useState } from "react";
import SetupModal from "@/modules/setup-netbird-modal/SetupModal";
export function InstallNetBirdButton() {
const [installModal, setInstallModal] = useState(false);
return (
<Modal open={installModal} onOpenChange={setInstallModal}>
<ModalTrigger asChild>
<Button variant={"secondary"} size={"sm"}>
<DownloadIcon size={16} />
Install NetBird
</Button>
</ModalTrigger>
<SetupModal />
</Modal>
);
}
+5 -1
View File
@@ -1,5 +1,6 @@
import Card from "@components/Card";
import Paragraph from "@components/Paragraph";
import { cn } from "@utils/helpers";
import { FilterX } from "lucide-react";
import React from "react";
import Skeleton from "react-loading-skeleton";
@@ -9,15 +10,18 @@ type Props = {
title?: string;
description?: string;
children?: React.ReactNode;
className?: string;
};
export default function NoResultsCard({
icon,
title = "Could not find any results",
description = "We couldn't find any results. Please try a different search term or change your filters.",
children,
className,
}: Readonly<Props>) {
return (
<div className={"px-8 mt-8"}>
<div className={cn("px-8 mt-8", className)}>
<Card className={"w-full relative overflow-hidden"}>
<div
className={
+335
View File
@@ -0,0 +1,335 @@
import { notify } from "@components/Notification";
import { useApiCall } from "@utils/api";
import * as React from "react";
import { useState } from "react";
import { useSWRConfig } from "swr";
import { useDialog } from "@/contexts/DialogProvider";
import { useGroups } from "@/contexts/GroupsProvider";
import { usePermissions } from "@/contexts/PermissionsProvider";
import { Group } from "@/interfaces/Group";
import { Peer } from "@/interfaces/Peer";
import { User } from "@/interfaces/User";
import { EditGroupNameModal } from "@/modules/groups/EditGroupNameModal";
import { useGroupIdentification } from "@/modules/groups/useGroupIdentification";
type Props = {
group: Group;
children?: React.ReactNode;
isDetailPage?: boolean;
};
const GroupContext = React.createContext(
{} as {
group: Group;
deleteGroup: () => Promise<void>;
renameGroup: (name: string) => Promise<void>;
isRegularGroup: boolean;
isIntegrationGroup: boolean;
isJWTGroup: boolean;
isAllowedToDelete: boolean;
isAllowedToRename: boolean;
openGroupRenameModal?: () => void;
addPeersToGroup: (peers: Peer[]) => Promise<void>;
removePeersFromGroup: (peer: Peer[]) => Promise<void>;
addUsersToGroup: (users: User[]) => Promise<void>;
removeUsersFromGroup: (users: User[]) => Promise<void>;
},
);
export const GroupProvider = ({
group,
children,
isDetailPage = true,
}: Props) => {
const { permission } = usePermissions();
const [groupNameModal, setGroupNameModal] = useState(false);
const { mutate } = useSWRConfig();
const { deleteGroupDropdownOption, updateGroupDropdown } = useGroups();
const groupRequest = useApiCall<Group>("/groups/" + group.id);
const userRequest = useApiCall<User>("/users");
const { confirm } = useDialog();
const { isRegularGroup, isIntegrationGroup, isJWTGroup } =
useGroupIdentification({
id: group?.id,
issued: group?.issued,
});
const isAllowedToRename = isRegularGroup && permission?.groups?.update;
const isAllowedToDelete = !isIntegrationGroup && permission?.groups?.delete;
const handleDelete = async () => {
if (!isAllowedToDelete) return Promise.reject("Not allowed to delete");
const promise = groupRequest.del().then(() => {
deleteGroupDropdownOption(group.name);
if (isDetailPage) mutate(`/groups/${group.id}`);
mutate("/groups");
});
notify({
title: "Delete Group " + group.name,
description: "Group successfully deleted",
promise,
loadingMessage: "Deleting group...",
});
return promise;
};
const deleteGroup = async () => {
const choice = await confirm({
title: `Delete '${group.name}'?`,
description:
"Are you sure you want to delete this group? This action cannot be undone.",
confirmText: "Delete",
cancelText: "Cancel",
type: "danger",
});
if (!choice) return;
handleDelete().then();
};
const renameGroup = (name: string) => {
if (!isAllowedToRename) return Promise.reject("Not allowed to rename");
const currentPeerIds =
group.peers?.map((p) => (typeof p === "string" ? p : p.id)) || [];
const promise = groupRequest
.put({ ...group, peers: currentPeerIds, name })
.then(() => {
updateGroupDropdown(group.name, { ...group, name });
if (isDetailPage) mutate(`/groups/${group.id}`);
mutate("/groups");
});
notify({
title: `Rename Group ${group.name}`,
description: "Group successfully renamed to " + name,
promise,
loadingMessage: "Renaming group...",
});
return promise;
};
const removePeersFromGroup = async (peers: Peer[]) => {
if (!permission?.groups?.update) return Promise.reject();
const peer = peers.length === 1 ? peers[0] : undefined;
const choice = await confirm({
title: peer
? `Remove peer '${peer.name}' from '${group.name}'?`
: `Remove peers from '${group.name}'?`,
description: peer
? `Are you sure you want to remove this peer from the group? You can add it back later if needed.`
: `Are you sure you want to remove these peers from the group? You can add them back later if needed.`,
confirmText: "Remove",
cancelText: "Cancel",
type: "warning",
maxWidthClass: "max-w-lg",
});
if (!choice) return Promise.resolve();
const currentPeerIds =
group.peers?.map((p) => (typeof p === "string" ? p : p.id)) || [];
const newPeerIds = currentPeerIds.filter((pid) => {
return !peers.find((peer) => peer.id === pid);
});
const promise = groupRequest
.put({ ...group, peers: newPeerIds })
.then(() => {
if (isDetailPage) mutate(`/groups/${group.id}`);
mutate("/groups");
});
notify({
title: `Remove Peer from Group`,
description: peer
? `Peer '${peer.name}' successfully removed from group '${group.name}'`
: `Peers successfully removed from group '${group.name}'`,
promise,
loadingMessage: peer
? "Removing peer from group..."
: `Removing peers from group...`,
});
return promise;
};
const addPeersToGroup = async (peers: Peer[]) => {
if (!permission?.groups?.update) return Promise.reject();
const currentPeerIds =
group.peers?.map((p) => (typeof p === "string" ? p : p.id)) || [];
const newPeerIds = [...currentPeerIds, ...peers.map((peer) => peer.id)];
const uniquePeerIds = Array.from(new Set(newPeerIds));
const promise = groupRequest
.put({ ...group, peers: uniquePeerIds })
.then(() => {
if (isDetailPage) mutate(`/groups/${group.id}`);
mutate("/groups");
});
notify({
title: "Adding peers to group",
description: `Peers were successfully added to ${group.name}.`,
promise,
loadingMessage: "Adding peers to group...",
});
return promise;
};
const removeUserFromGroup = async (
user: User,
returnOnlyPromise?: boolean,
) => {
if (!permission?.groups?.update) return Promise.reject();
if (!permission?.users?.update) return Promise.reject();
const currentGroupIds = user.auto_groups?.map((g) => g) || [];
const newGroupIds = currentGroupIds.filter((gid) => gid !== group.id);
const promise = userRequest
.put({ ...user, auto_groups: newGroupIds }, `/${user.id}`)
.then(() => {
if (returnOnlyPromise) return;
if (isDetailPage) mutate(`/groups/${group.id}`);
mutate("/groups");
mutate("/users?service_user=false");
});
if (!returnOnlyPromise) {
notify({
title: `Remove User from Group ${group.name}`,
description: `User '${user.name}' was successfully removed from group '${group.name}'.`,
promise,
loadingMessage: "Removing user from group...",
});
}
return promise;
};
const removeUsersFromGroup = async (users: User[]) => {
if (!permission?.groups?.update) return Promise.reject();
if (!permission?.users?.update) return Promise.reject();
let promises = users.map((user) => removeUserFromGroup(user, true));
const user = users.length === 1 ? users[0] : undefined;
const choice = await confirm({
title: user
? `Remove user '${user?.name ?? user?.id}' from '${group.name}'?`
: `Remove users from '${group.name}'?`,
description: user
? `Are you sure you want to remove this user from the group? You can add it back later if needed.`
: `Are you sure you want to remove these users from the group? You can add them back later if needed.`,
confirmText: "Remove",
cancelText: "Cancel",
type: "warning",
maxWidthClass: "max-w-lg",
});
if (!choice) return Promise.resolve();
const promise = Promise.all(promises).then(() => {
if (isDetailPage) mutate(`/groups/${group.id}`);
mutate("/groups");
mutate("/users?service_user=false");
});
notify({
title: `Remove Users from Group ${group.name}`,
description: `Users were successfully removed from group '${group.name}'.`,
promise,
loadingMessage: "Removing users from group...",
});
return promise;
};
const addUserToGroup = async (user: User, returnOnlyPromise?: boolean) => {
if (!permission?.groups?.update) return Promise.reject();
if (!permission?.users?.update) return Promise.reject();
const currentGroupIds = user.auto_groups?.map((g) => g) || [];
const newGroupIds = Array.from(new Set([...currentGroupIds, group.id]));
const promise = userRequest
.put({ ...user, auto_groups: newGroupIds }, `/${user.id}`)
.then(() => {
if (returnOnlyPromise) return;
if (isDetailPage) mutate(`/groups/${group.id}`);
mutate("/groups");
mutate("/users?service_user=false");
});
if (!returnOnlyPromise) {
notify({
title: `Add User to Group ${group.name}`,
description: `User '${user.name}' was successfully added to group '${group.name}'.`,
promise,
loadingMessage: "Adding user to group...",
});
}
return promise;
};
const addUsersToGroup = async (users: User[]) => {
let promises = users.map((user) => addUserToGroup(user, true));
const promise = Promise.all(promises).then(() => {
if (isDetailPage) mutate(`/groups/${group.id}`);
mutate("/groups");
mutate("/users?service_user=false");
});
notify({
title: `Add Users to Group ${group.name}`,
description: `Users were successfully added to group '${group.name}'.`,
promise,
loadingMessage: "Adding users to group...",
});
return promise;
};
const openGroupRenameModal = () => {
if (!isAllowedToRename) return;
setGroupNameModal(true);
};
return (
<GroupContext.Provider
value={{
group,
deleteGroup,
renameGroup,
isRegularGroup,
isIntegrationGroup,
isJWTGroup,
isAllowedToDelete,
isAllowedToRename,
openGroupRenameModal,
addPeersToGroup,
removePeersFromGroup,
addUsersToGroup,
removeUsersFromGroup,
}}
>
<EditGroupNameModal
initialName={group.name}
open={groupNameModal}
onOpenChange={setGroupNameModal}
onSuccess={(newName) =>
renameGroup(newName).then(() => {
setGroupNameModal(false);
})
}
/>
{children}
</GroupContext.Provider>
);
};
export const useGroupContext = () => {
const context = React.useContext(GroupContext);
if (!context) {
throw new Error("useGroup must be used within a GroupProvider");
}
return context;
};
+9
View File
@@ -20,6 +20,7 @@ const GroupContext = React.createContext(
createOrUpdate: (group: Group) => Promise<Group>;
reset: () => void;
updateGroupDropdown: (oldGroupName: string, newGroup: Group) => void;
deleteGroupDropdownOption: (name: string) => void;
},
);
@@ -132,6 +133,13 @@ export function GroupsProviderContent({
}
};
const deleteGroupDropdownOption = (name: string) => {
setDropdownOptions((prev) => {
let updated = prev.filter((g) => g.name !== name);
return sortBy(updated, "name");
});
};
return (
<GroupContext.Provider
value={{
@@ -144,6 +152,7 @@ export function GroupsProviderContent({
createOrUpdate,
reset,
updateGroupDropdown,
deleteGroupDropdownOption,
}}
>
{children}
Loaded 20 of 68 files, more files were not shown because too many files have changed in this diff. Show more