Add new networks feature (#427)

This commit is contained in:
Eduard Gert
2024-12-23 11:20:01 +01:00
committed by GitHub
parent c7775ade8c
commit 3ba7acdecf
85 changed files with 3871 additions and 314 deletions

567
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -55,8 +55,8 @@
"framer-motion": "^10.16.4",
"ip-cidr": "^3.1.0",
"lodash": "^4.17.21",
"lucide-react": "^0.383.0",
"next": "13.5.5",
"lucide-react": "^0.460.0",
"next": "13.5.7",
"next-themes": "^0.2.1",
"punycode": "^2.3.1",
"react": "^18",
@@ -76,7 +76,7 @@
"typescript": "^5"
},
"devDependencies": {
"cypress": "^13.3.3",
"cypress": "^13.13.0",
"postcss": "^8",
"prettier": "3.0.3",
"tailwindcss": "^3"

View File

@@ -14,17 +14,24 @@ import { IconSettings2 } from "@tabler/icons-react";
import useFetchApi, { useApiCall } from "@utils/api";
import { ExternalLinkIcon } from "lucide-react";
import React from "react";
import Skeleton from "react-loading-skeleton";
import { useSWRConfig } from "swr";
import DNSIcon from "@/assets/icons/DNSIcon";
import { useHasChanges } from "@/hooks/useHasChanges";
import { Group } from "@/interfaces/Group";
import { NameserverSettings } from "@/interfaces/NameserverSettings";
import PageContainer from "@/layouts/PageContainer";
import useGroupHelper from "@/modules/groups/useGroupHelper";
import { useGroupIdsToGroups } from "@/modules/groups/useGroupIdsToGroups";
export default function NameServerSettings() {
const { data: settings, isLoading } =
useFetchApi<NameserverSettings>("/dns/settings");
const initialDNSGroups = useGroupIdsToGroups(
settings?.disabled_management_groups,
);
return (
<PageContainer>
<div className={"p-default py-6"}>
@@ -55,10 +62,16 @@ export default function NameServerSettings() {
in our documentation.
</Paragraph>
<RestrictedAccess page={"DNS Settings"}>
{!isLoading && (
<SettingDisabledManagementGroups
initial={settings?.disabled_management_groups}
/>
{!isLoading && initialDNSGroups !== undefined ? (
<SettingDisabledManagementGroups initialGroups={initialDNSGroups} />
) : (
<div>
<Skeleton
width={"100%"}
className={"mt-8 max-w-xl"}
height={240}
/>
</div>
)}
</RestrictedAccess>
</div>
@@ -67,16 +80,16 @@ export default function NameServerSettings() {
}
const SettingDisabledManagementGroups = ({
initial,
initialGroups,
}: {
initial: string[] | undefined;
initialGroups: Group[];
}) => {
const settingRequest = useApiCall<NameserverSettings>("/dns/settings");
const { mutate } = useSWRConfig();
const [selectedGroups, setSelectedGroups, { save: saveGroups }] =
useGroupHelper({
initial: initial || [],
initial: initialGroups,
});
const { hasChanges, updateRef: updateChangesRef } = useHasChanges([
@@ -108,6 +121,7 @@ const SettingDisabledManagementGroups = ({
Peers in these groups will require manual domain name resolution
</HelpText>
<PeerGroupSelector
dataCy={"dns-groups-selector"}
onChange={setSelectedGroups}
values={selectedGroups}
/>
@@ -122,6 +136,7 @@ const SettingDisabledManagementGroups = ({
size={"sm"}
onClick={saveSettings}
disabled={!hasChanges}
data-cy={"save-changes"}
>
Save Changes
</Button>

View File

@@ -14,6 +14,7 @@ import PeersProvider from "@/contexts/PeersProvider";
import RoutesProvider from "@/contexts/RoutesProvider";
import { Route } from "@/interfaces/Route";
import PageContainer from "@/layouts/PageContainer";
import { NetworkRoutesDeprecationInfo } from "@/modules/networks/misc/NetworkRoutesDeprecationInfo";
import useGroupedRoutes from "@/modules/route-group/useGroupedRoutes";
const NetworkRoutesTable = lazy(
@@ -39,7 +40,9 @@ export default function NetworkRoutes() {
icon={<NetworkRoutesIcon size={13} />}
/>
</Breadcrumbs>
<h1 ref={headingRef}>Network Routes</h1>
<h1 ref={headingRef}>
Network Routes <NetworkRoutesDeprecationInfo size={18} />
</h1>
<Paragraph>
Network routes allow you to access other networks like LANs and
VPCs without installing NetBird on every resource.

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: `Network - Networks - ${globalMetaTitle}`,
};
export default BlankLayout;

View File

@@ -0,0 +1,229 @@
"use client";
import Breadcrumbs from "@components/Breadcrumbs";
import Card from "@components/Card";
import FullTooltip from "@components/FullTooltip";
import InlineLink from "@components/InlineLink";
import Separator from "@components/Separator";
import FullScreenLoading from "@components/ui/FullScreenLoading";
import useRedirect from "@hooks/useRedirect";
import useFetchApi from "@utils/api";
import { cn } from "@utils/helpers";
import {
ArrowUpRightIcon,
HelpCircle,
PencilLineIcon,
ServerIcon,
ShieldCheckIcon,
ShieldXIcon,
} from "lucide-react";
import { useSearchParams } from "next/navigation";
import React, { useMemo, useState } from "react";
import { useSWRConfig } from "swr";
import NetworkRoutesIcon from "@/assets/icons/NetworkRoutesIcon";
import { useLoggedInUser } from "@/contexts/UsersProvider";
import { Network } from "@/interfaces/Network";
import PageContainer from "@/layouts/PageContainer";
import { NetworkInformationSquare } from "@/modules/networks/misc/NetworkInformationSquare";
import NetworkModal from "@/modules/networks/NetworkModal";
import { NetworkProvider } from "@/modules/networks/NetworkProvider";
import { ResourcesSection } from "@/modules/networks/resources/ResourcesSection";
import { NetworkRoutingPeersSection } from "@/modules/networks/routing-peers/NetworkRoutingPeersSection";
export default function NetworkDetailPage() {
const queryParameter = useSearchParams();
const networkId = queryParameter.get("id");
const { data: network, isLoading } = useFetchApi<Network>(
`/networks/${networkId}`,
true,
);
useRedirect("/networks", false, !networkId);
return network && !isLoading ? (
<NetworkOverview network={network} />
) : (
<FullScreenLoading />
);
}
function NetworkOverview({ network }: Readonly<{ network: Network }>) {
const { isUser } = useLoggedInUser();
const [networkModal, setNetworkModal] = useState(false);
const { mutate } = useSWRConfig();
const isActive = !!(
network?.routing_peers_count && network.routing_peers_count > 0
);
return (
<PageContainer>
<NetworkProvider network={network}>
<div className={"p-default py-6 mb-4"}>
<Breadcrumbs>
<Breadcrumbs.Item
href={"/networks"}
label={"Networks"}
disabled={isUser}
icon={<NetworkRoutesIcon size={13} />}
/>
<Breadcrumbs.Item
href={"/network"}
label={network.name}
active={true}
/>
</Breadcrumbs>
<div className={"flex justify-between max-w-6xl"}>
<div
className={cn(
"flex items-center",
!network.description && "gap-2",
)}
>
<NetworkInformationSquare
name={network.name}
active={isActive}
size={"lg"}
description={network.description}
/>
<button
className={
"flex items-center gap-2 dark:text-neutral-300 text-neutral-500 hover:text-neutral-100 transition-all hover:bg-nb-gray-800/60 py-2 px-3 rounded-md cursor-pointer"
}
onClick={() => setNetworkModal(true)}
>
<PencilLineIcon size={18} />
</button>
<NetworkModal
open={networkModal}
setOpen={setNetworkModal}
onUpdated={() => {
mutate(`/networks/${network.id}`);
}}
network={network}
/>
</div>
</div>
<div className={"flex gap-10 w-full mt-8 max-w-6xl items-start"}>
<NetworkInformationCard network={network} />
</div>
</div>
<Separator />
<ResourcesSection network={network} />
<div className={"h-3"} />
<Separator />
<NetworkRoutingPeersSection network={network} />
</NetworkProvider>
</PageContainer>
);
}
function NetworkInformationCard({ network }: Readonly<{ network: Network }>) {
const isHighlyAvailable = !!(
network?.routing_peers_count && network?.routing_peers_count >= 2
);
const disabledText = useMemo(
() => (
<>
High availability is currently{" "}
<span className={"text-yellow-400 font-medium"}>inactive</span> for this
network.
</>
),
[],
);
const enabledText = useMemo(
() => (
<>
High availability is{" "}
<span className={"text-green-500 font-medium"}>active</span> for this
network.
</>
),
[],
);
const policyCount = network.policies?.length ?? 0;
return (
<Card>
<Card.List>
<Card.ListItem
tooltip={false}
label={
<>
<ServerIcon size={16} />
High Availability
</>
}
value={
<FullTooltip
interactive={false}
content={
<div className={"max-w-xs text-xs"}>
{isHighlyAvailable ? enabledText : disabledText}
{isHighlyAvailable ? (
<div className={"inline-flex mt-2"}>
You can add more routing peers to increase the
availability of this network.
</div>
) : (
<div className={"inline-flex mt-2"}>
Go ahead and add more routing peers or groups with routing
peers to enable high availability for this network.
</div>
)}
</div>
}
>
<div
className={cn(
"flex gap-2.5 items-center text-nb-gray-300 text-sm cursor-help",
)}
>
<span
className={cn(
"h-2 w-2 rounded-full",
!isHighlyAvailable ? "bg-yellow-400" : "bg-green-500",
)}
></span>
{isHighlyAvailable ? "Active" : "Inactive"}
<HelpCircle size={12} />
</div>
</FullTooltip>
}
/>
<Card.ListItem
tooltip={false}
label={
policyCount > 0 ? (
<>
<ShieldCheckIcon size={16} className={"text-green-500"} />
{policyCount}{" "}
{policyCount === 1 ? "Active Policy" : "Active Policies"}
</>
) : (
<>
<ShieldXIcon size={16} className={"text-red-500"} />
No Active Policies
</>
)
}
value={
policyCount > 0 ? (
<InlineLink href={"/access-control"}>
Go to Policies
<ArrowUpRightIcon size={14} />
</InlineLink>
) : null
}
/>
</Card.List>
</Card>
);
}

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: `Networks - ${globalMetaTitle}`,
};
export default BlankLayout;

View File

@@ -0,0 +1,58 @@
"use client";
import Breadcrumbs from "@components/Breadcrumbs";
import InlineLink from "@components/InlineLink";
import Paragraph from "@components/Paragraph";
import SkeletonTable from "@components/skeletons/SkeletonTable";
import { RestrictedAccess } from "@components/ui/RestrictedAccess";
import { usePortalElement } from "@hooks/usePortalElement";
import useFetchApi from "@utils/api";
import { ExternalLinkIcon } from "lucide-react";
import React, { Suspense } from "react";
import NetworkRoutesIcon from "@/assets/icons/NetworkRoutesIcon";
import { Network } from "@/interfaces/Network";
import PageContainer from "@/layouts/PageContainer";
import NetworksTable from "@/modules/networks/table/NetworksTable";
export default function Networks() {
const { data: networks, isLoading } = useFetchApi<Network[]>("/networks");
const { ref: headingRef, portalTarget } =
usePortalElement<HTMLHeadingElement>();
return (
<PageContainer>
<div className={"p-default py-6"}>
<Breadcrumbs>
<Breadcrumbs.Item
href={"/networks"}
label={"Networks"}
icon={<NetworkRoutesIcon size={13} />}
/>
</Breadcrumbs>
<h1 ref={headingRef}>Networks</h1>
<Paragraph>
Networks allow you to access other resources like LANs and VPCs
without installing NetBird on every device.
</Paragraph>
<Paragraph>
Learn more about
<InlineLink href={"#"} target={"_blank"}>
Networks
<ExternalLinkIcon size={12} />
</InlineLink>
in our documentation.
</Paragraph>
</div>
<RestrictedAccess>
<Suspense fallback={<SkeletonTable />}>
<NetworksTable
data={networks}
isLoading={isLoading}
headingTarget={portalTarget}
/>
</Suspense>
</RestrictedAccess>
</PageContainer>
);
}

View File

@@ -6,6 +6,7 @@ import {
AlertOctagonIcon,
FolderGit2Icon,
LockIcon,
NetworkIcon,
ShieldIcon,
} from "lucide-react";
import { useSearchParams } from "next/navigation";
@@ -16,6 +17,7 @@ import { useAccount } from "@/modules/account/useAccount";
import AuthenticationTab from "@/modules/settings/AuthenticationTab";
import DangerZoneTab from "@/modules/settings/DangerZoneTab";
import GroupsTab from "@/modules/settings/GroupsTab";
import NetworkSettingsTab from "@/modules/settings/NetworkSettingsTab";
import PermissionsTab from "@/modules/settings/PermissionsTab";
export default function NetBirdSettings() {
@@ -47,6 +49,10 @@ export default function NetBirdSettings() {
<LockIcon size={14} />
Permissions
</VerticalTabs.Trigger>
<VerticalTabs.Trigger value="networks">
<NetworkIcon size={14} />
Networks
</VerticalTabs.Trigger>
<VerticalTabs.Trigger value="danger-zone" disabled={!isOwner}>
<AlertOctagonIcon size={14} />
Danger zone
@@ -57,6 +63,7 @@ export default function NetBirdSettings() {
{account && <AuthenticationTab account={account} />}
{account && <PermissionsTab account={account} />}
{account && <GroupsTab account={account} />}
{account && <NetworkSettingsTab account={account} />}
{account && <DangerZoneTab account={account} />}
</div>
</RestrictedAccess>

View File

@@ -22,11 +22,13 @@ import { useSWRConfig } from "swr";
import TeamIcon from "@/assets/icons/TeamIcon";
import { useLoggedInUser } from "@/contexts/UsersProvider";
import { useHasChanges } from "@/hooks/useHasChanges";
import { Group } from "@/interfaces/Group";
import { Role, User } from "@/interfaces/User";
import PageContainer from "@/layouts/PageContainer";
import AccessTokensTable from "@/modules/access-tokens/AccessTokensTable";
import CreateAccessTokenModal from "@/modules/access-tokens/CreateAccessTokenModal";
import useGroupHelper from "@/modules/groups/useGroupHelper";
import { useGroupIdsToGroups } from "@/modules/groups/useGroupIdsToGroups";
import UserBlockCell from "@/modules/users/table-cells/UserBlockCell";
import UserStatusCell from "@/modules/users/table-cells/UserStatusCell";
import { UserRoleSelector } from "@/modules/users/UserRoleSelector";
@@ -45,8 +47,10 @@ export default function UserPage() {
useRedirect("/team/users", false, !userId);
return !isLoading && user ? (
<UserOverview user={user} />
const userGroups = useGroupIdsToGroups(user?.auto_groups);
return !isLoading && user && userGroups !== undefined ? (
<UserOverview user={user} initialGroups={userGroups} />
) : (
<FullScreenLoading />
);
@@ -54,16 +58,16 @@ export default function UserPage() {
type Props = {
user: User;
initialGroups: Group[];
};
function UserOverview({ user }: Props) {
function UserOverview({ user, initialGroups }: Readonly<Props>) {
const router = useRouter();
const userRequest = useApiCall<User>("/users");
const { mutate } = useSWRConfig();
const { loggedInUser, isOwnerOrAdmin, isUser } = useLoggedInUser();
const isLoggedInUser = loggedInUser ? loggedInUser?.id === user.id : false;
const initialGroups = user.auto_groups;
const [selectedGroups, setSelectedGroups, { save: saveGroups }] =
useGroupHelper({
initial: initialGroups,
@@ -180,6 +184,7 @@ function UserOverview({ user }: Props) {
className={"w-full"}
disabled={!hasChanges}
onClick={save}
data-cy={"save-changes"}
>
Save Changes
</Button>
@@ -201,6 +206,7 @@ function UserOverview({ user }: Props) {
onChange={setSelectedGroups}
values={selectedGroups}
hideAllGroup={true}
dataCy={"user-group-selector"}
/>
</div>
)}
@@ -244,7 +250,10 @@ function UserOverview({ user }: Props) {
<div className={"inline-flex gap-4 justify-end"}>
<div>
<CreateAccessTokenModal user={user}>
<Button variant={"primary"}>
<Button
variant={"primary"}
data-cy={"access-token-open-modal"}
>
<IconCirclePlus size={16} />
Create Access Token
</Button>
@@ -293,6 +302,7 @@ function UserInformationCard({ user }: { user: User }) {
)}
<Card.ListItem
tooltip={false}
label={
<>
<GalleryHorizontalEnd size={16} />

View File

@@ -10,12 +10,14 @@ import useFetchApi from "@utils/api";
import { ExternalLinkIcon, User2 } from "lucide-react";
import React, { lazy, Suspense } from "react";
import TeamIcon from "@/assets/icons/TeamIcon";
import { useGroups } from "@/contexts/GroupsProvider";
import { User } from "@/interfaces/User";
import PageContainer from "@/layouts/PageContainer";
const UsersTable = lazy(() => import("@/modules/users/UsersTable"));
export default function TeamUsers() {
const { isLoading: isGroupsLoading } = useGroups();
const { data: users, isLoading } = useFetchApi<User[]>(
"/users?service_user=false",
);
@@ -60,7 +62,7 @@ export default function TeamUsers() {
<Suspense fallback={<SkeletonTable />}>
<UsersTable
users={users}
isLoading={isLoading}
isLoading={isLoading || isGroupsLoading}
headingTarget={portalTarget}
/>
</Suspense>

View File

@@ -15,7 +15,9 @@ export const OIDCError = () => {
const params = useSearchParams();
const errorParam = params.get("error");
const accessDenied = errorParam === "access_denied";
const invalidRequest = errorParam === "invalid_request";
const [title, setTitle] = useState(params.get("error_description"));
const errorDescription = params.get("error_description");
const { logout, login } = useOidc();
useEffect(() => {
@@ -72,9 +74,14 @@ export const OIDCError = () => {
</>
) : (
<>
<Paragraph className={"text-center mt-2"}>
<Paragraph className={"text-center mt-2 block"}>
There was an error logging you in. <br />
Error: {oidcUserLoadingState}
Error:{" "}
<span className={"inline capitalize"}>
{invalidRequest && errorDescription
? errorDescription
: oidcUserLoadingState}
</span>
</Paragraph>
<Button
variant={"primary"}

View File

@@ -35,6 +35,11 @@ export const buttonVariants = cva(
"dark:ring-offset-neutral-950/50 dark:focus:ring-neutral-500/20 ",
"dark:bg-nb-gray-900/30 dark:text-gray-400 dark:border-gray-700/40 dark:hover:text-white dark:hover:bg-zinc-800/50",
],
secondaryLighter: [
"bg-white hover:text-black focus:ring-zinc-200/50 hover:bg-gray-100 border-gray-200 text-gray-900",
"dark:ring-offset-neutral-950/50 dark:focus:ring-neutral-500/20 ",
"dark:bg-nb-gray-900/70 dark:text-gray-400 dark:border-gray-700/70 dark:hover:text-white dark:hover:bg-nb-gray-800/60",
],
input: [
"bg-white hover:text-black focus:ring-zinc-200/50 hover:bg-gray-100 border-neutral-200 text-gray-900",
"dark:ring-offset-neutral-950/50 dark:focus:ring-neutral-500/20 ",

View File

@@ -1,12 +1,15 @@
import FullTooltip from "@components/FullTooltip";
import Paragraph from "@components/Paragraph";
import { cn } from "@utils/helpers";
import { cva } from "class-variance-authority";
import { cva, VariantProps } from "class-variance-authority";
import { AlertCircle } from "lucide-react";
import * as React from "react";
type InputVariants = VariantProps<typeof inputVariants>;
export interface InputProps
extends React.InputHTMLAttributes<HTMLInputElement> {
extends React.InputHTMLAttributes<HTMLInputElement>,
InputVariants {
customPrefix?: React.ReactNode;
customSuffix?: React.ReactNode;
maxWidthClass?: string;
@@ -14,6 +17,7 @@ export interface InputProps
error?: string;
errorTooltip?: boolean;
errorTooltipPosition?: "top" | "top-right";
prefixClassName?: string;
}
const inputVariants = cva("", {
@@ -23,6 +27,10 @@ const inputVariants = cva("", {
"dark:bg-nb-gray-900 dark:placeholder:text-neutral-400/70 placeholder:text-neutral-500 border-neutral-200 dark:border-nb-gray-700",
"ring-offset-neutral-200/20 dark:ring-offset-neutral-950/50 dark:focus-visible:ring-neutral-500/20 focus-visible:ring-neutral-300/10",
],
darker: [
"dark:bg-nb-gray-920 dark:placeholder:text-neutral-400/70 placeholder:text-neutral-500 border-neutral-300 dark:border-nb-gray-800",
"ring-offset-neutral-200/20 dark:ring-offset-neutral-950/50 dark:focus-visible:ring-neutral-500/20 focus-visible:ring-neutral-300/10",
],
error: [
"dark:bg-nb-gray-900 dark:placeholder:text-neutral-400/70 placeholder:text-neutral-500 border-neutral-200 dark:border-red-500 text-red-500",
"ring-offset-red-500/10 dark:ring-offset-red-500/10 dark:focus-visible:ring-red-500/10 focus-visible:ring-red-500/10",
@@ -51,6 +59,8 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
error,
errorTooltip = false,
errorTooltipPosition = "top",
variant = "default",
prefixClassName,
...props
},
ref,
@@ -67,6 +77,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
"flex h-[42px] w-auto rounded-l-md bg-white px-3 py-2 text-sm ",
"border items-center whitespace-nowrap",
props.disabled && "opacity-20",
prefixClassName,
)}
>
{customPrefix}
@@ -87,7 +98,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
ref={ref}
{...props}
className={cn(
inputVariants({ variant: error ? "error" : "default" }),
inputVariants({ variant: error ? "error" : variant }),
"flex h-[42px] w-full rounded-md bg-white px-3 py-2 text-sm file:bg-transparent file:text-sm file:font-medium focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-20 ",
"file:border-0",
"focus-visible:ring-2 focus-visible:ring-offset-2",

View File

@@ -7,22 +7,31 @@ import { ScrollArea } from "@components/ScrollArea";
import { AccessControlGroupCount } from "@components/ui/AccessControlGroupCount";
import GroupBadge from "@components/ui/GroupBadge";
import GroupBadgeWithEditPeers from "@components/ui/GroupBadgeWithEditPeers";
import TextWithTooltip from "@components/ui/TextWithTooltip";
import { VirtualScrollAreaList } from "@components/VirtualScrollAreaList";
import { useSearch } from "@hooks/useSearch";
import useSortedDropdownOptions from "@hooks/useSortedDropdownOptions";
import { IconArrowBack } from "@tabler/icons-react";
import useFetchApi from "@utils/api";
import { cn } from "@utils/helpers";
import { Command, CommandGroup, CommandInput, CommandList } from "cmdk";
import { sortBy, trim, unionBy } from "lodash";
import {
ChevronsUpDown,
FolderGit2,
GlobeIcon,
Layers3,
MonitorSmartphoneIcon,
NetworkIcon,
SearchIcon,
WorkflowIcon,
} from "lucide-react";
import * as React from "react";
import { useEffect, useMemo, useState } from "react";
import { Fragment, useEffect, useMemo, useState } from "react";
import { useGroups } from "@/contexts/GroupsProvider";
import { useElementSize } from "@/hooks/useElementSize";
import type { Group, GroupPeer } from "@/interfaces/Group";
import { NetworkResource } from "@/interfaces/Network";
import type { Peer } from "@/interfaces/Peer";
interface MultiSelectProps {
@@ -241,7 +250,7 @@ export function PeerGroupSelector({
)}
</div>
<div className={"pl-2"}>
<div className={"pl-2"} data-cy={"group-selector-open-close"}>
<ChevronsUpDown
size={18}
className={"shrink-0 group-hover:text-nb-gray-300 transition-all"}
@@ -311,7 +320,10 @@ export function PeerGroupSelector({
<CommandGroup>
<ScrollArea
className={"max-h-[195px] flex flex-col gap-1 pl-2 py-2 pr-3"}
className={cn(
"max-h-[195px] flex flex-col gap-1 pl-2 py-2 pr-3",
sortedDropdownOptions.length == 0 && !search && "py-0",
)}
>
{searchedGroupNotFound && (
<CommandItem
@@ -382,6 +394,8 @@ export function PeerGroupSelector({
<AccessControlGroupCount group_id={option.id} />
)}
<ResourcesCounter group={option} />
<div
className={
"text-neutral-500 dark:text-nb-gray-300 font-medium flex items-center gap-2"
@@ -404,3 +418,99 @@ export function PeerGroupSelector({
</Popover>
);
}
const ResourcesCounter = ({ group }: { group: Group }) => {
return group?.resources_count && group.resources_count > 0 ? (
<div
className={
"text-nb-gray-300 font-medium flex items-center gap-2 transition-all"
}
>
<Layers3 size={14} className={"shrink-0"} />
{group.resources_count} Resource(s)
</div>
) : null;
};
const resourcesSearchPredicate = (item: NetworkResource, query: string) => {
const lowerCaseQuery = query.toLowerCase();
if (item.name.toLowerCase().includes(lowerCaseQuery)) return true;
return item.address.toLowerCase().includes(lowerCaseQuery);
};
const ResourcesList = ({ search }: { search: string }) => {
const { data: resources, isLoading } = useFetchApi<NetworkResource[]>(
"/networks/resources",
);
const [filteredItems, _, setSearch] = useSearch(
resources || [],
resourcesSearchPredicate,
{ filter: true, debounce: 150 },
);
useEffect(() => {
setSearch(search);
}, [search, setSearch]);
return isLoading ? (
<>Loading...</>
) : (
filteredItems.length > 0 && (
<VirtualScrollAreaList
items={filteredItems}
onSelect={(option) => null}
renderItem={(res) => {
const isSelected = false;
return (
<Fragment key={res.id}>
<div className={"flex items-center gap-2"}>
<Badge
useHover={true}
data-cy={"group-badge"}
variant={"gray-ghost"}
className={cn("transition-all group whitespace-nowrap")}
onClick={(e) => {
e.preventDefault();
}}
>
{res.type === "host" && (
<WorkflowIcon
size={12}
className={"text-yellow-400 shrink-0"}
/>
)}
{res.type === "domain" && (
<GlobeIcon
size={12}
className={"text-yellow-400 shrink-0"}
/>
)}
{res.type === "subnet" && (
<NetworkIcon
size={12}
className={"text-yellow-400 shrink-0"}
/>
)}
<TextWithTooltip text={res?.name || ""} maxChars={20} />
</Badge>
</div>
<div className={"flex items-center gap-5"}>
<div
className={
"text-neutral-500 dark:text-nb-gray-300 font-medium flex items-center gap-2"
}
>
<Checkbox checked={isSelected} />
</div>
</div>
</Fragment>
);
}}
/>
)
);
};

View File

@@ -39,7 +39,7 @@ const Tabs = React.forwardRef<
Tabs.displayName = TabsPrimitive.Root.displayName;
type TabListProps = {
justify?: "start" | "end" | "center";
justify?: "start" | "end" | "center" | "between";
};
const TabsList = React.forwardRef<
@@ -54,6 +54,7 @@ const TabsList = React.forwardRef<
justify == "center" && "justify-center justify-items-end",
justify == "start" && "justify-start",
justify == "end" && "justify-end",
justify == "between" && "justify-between",
)}
{...props}
>
@@ -63,7 +64,9 @@ const TabsList = React.forwardRef<
}
/>
<ScrollArea>
<div className={"relative z-[1] flex flex-nowrap"}>{props.children}</div>
<div className={"relative z-[1] flex flex-nowrap w-full "}>
{props.children}
</div>
<ScrollBar orientation="horizontal" />
</ScrollArea>
</TabsPrimitive.List>

View File

@@ -11,17 +11,36 @@ type Props = {
onChange: (value: string) => void;
children: React.ReactNode;
};
const TabSwitchContext = React.createContext<{
switchTab: (value: string) => void;
}>({
switchTab: () => {},
});
export const useTabSwitchContext = () => {
return React.useContext(TabSwitchContext);
};
function VerticalTabs({ value, onChange, children }: Props) {
return (
<TabContext.Provider value={value || ""}>
<Tabs.Root
orientation={"vertical"}
className={"block lg:flex bg-nb-gray"}
value={value}
onValueChange={(value) => onChange(value)}
<TabSwitchContext.Provider
value={{
switchTab: (value: string) => {
onChange(value);
},
}}
>
{children}
</Tabs.Root>
<Tabs.Root
orientation={"vertical"}
className={"block lg:flex bg-nb-gray"}
value={value}
onValueChange={(value) => onChange(value)}
>
{children}
</Tabs.Root>
</TabSwitchContext.Provider>
</TabContext.Provider>
);
}

View File

@@ -11,6 +11,7 @@ interface Props extends IconVariant {
margin?: string;
truncate?: boolean;
children?: React.ReactNode;
center?: boolean;
}
export default function ModalHeader({
icon,
@@ -21,13 +22,21 @@ export default function ModalHeader({
margin = "mt-0",
truncate = false,
children,
center,
}: Props) {
return (
<div className={cn(className, "min-w-0")}>
<div className={"flex items-start gap-5 pr-10 min-w-0"}>
<div className={"flex items-start gap-5 min-w-0"}>
{icon && <SquareIcon color={color} icon={icon} />}
<div className={"min-w-0"}>
<h2 className={"text-lg my-0 leading-[1.5]"}>{title}</h2>
<div className={cn("min-w-0", center && "text-center")}>
<h2
className={cn(
"text-lg my-0 leading-[1.5]",
center && "text-center",
)}
>
{title}
</h2>
{children ? (
<>{children}</>
) : (

View File

@@ -101,11 +101,11 @@ const TableRow = React.forwardRef<
<tr
ref={ref}
className={cn(
" transition-colors data-[state=selected]:bg-neutral-100 dark:data-[state=selected]:bg-nb-gray-930/70",
" transition-colors group/table-row data-[state=selected]:bg-neutral-100 dark:data-[state=selected]:bg-nb-gray-930/70",
"dark:data-[state=selected]:border-nb-gray-900",
minimal
? "dark:hover:bg-nb-gray-900/10"
: "border-b dark:border-zinc-700/40 dark:hover:bg-nb-gray-900/20 hover:bg-neutral-100/50",
: "border-b dark:border-zinc-700/40 dark:hover:bg-nb-gray-940 hover:bg-neutral-100/50",
className,
)}
{...props}

View File

@@ -4,7 +4,7 @@ export const GradientFadedBackground = () => {
return (
<div
className={
"h-full w-full absolute left-0 top-0 rounded-md overflow-hidden z-0"
"h-full w-full absolute left-0 top-0 rounded-md overflow-hidden z-0 pointer-events-none"
}
>
<div

View File

@@ -27,6 +27,7 @@ export default function GroupBadge({
<Badge
key={group.id || group.name}
useHover={true}
data-cy={"group-badge"}
variant={"gray-ghost"}
className={cn("transition-all group whitespace-nowrap", className)}
onClick={(e) => {

View File

@@ -34,7 +34,10 @@ export default function MultipleGroups({
<TooltipProvider disableHoverableContent={false}>
<Tooltip delayDuration={1}>
<TooltipTrigger asChild={true}>
<div className={"inline-flex items-center gap-2 z-0"}>
<div
className={"inline-flex items-center gap-2 z-0"}
data-cy={"multiple-groups"}
>
{firstGroup && <GroupBadge group={firstGroup} />}
{otherGroups && otherGroups.length > 0 && (
<Badge

View File

@@ -33,10 +33,9 @@ export default function UserDropdown() {
logout("/", { client_id: config.clientId }).then();
};
useHotkeys("shift+mod+l", () => logout(), []);
const [dropdownOpen, setDropdownOpen] = useState(false);
useHotkeys("shift+mod+l", () => logoutSession(), []);
const { permission } = useLoggedInUser();
const [dropdownOpen, setDropdownOpen] = useState(false);
return (
<DropdownMenu

View File

@@ -24,7 +24,7 @@ type DialogOptions = {
description?: string | React.ReactNode;
confirmText?: string;
cancelText?: string;
type?: "default" | "warning" | "danger";
type?: "default" | "warning" | "danger" | "center";
children?: React.ReactNode;
};
@@ -51,6 +51,7 @@ export default function DialogProvider({ children }: Props) {
default: "",
warning: <AlertCircle size={18} />,
danger: <AlertTriangle size={18} />,
center: "",
};
return (
@@ -61,8 +62,9 @@ export default function DialogProvider({ children }: Props) {
onOpenChange={(open) => fn.current && fn.current(open)}
>
{dialogOptions && (
<ModalContent maxWidthClass={"max-w-lg"} showClose={false}>
<ModalContent maxWidthClass={"max-w-[400px]"} showClose={false}>
<ModalHeader
center={dialogOptions.type == "center"}
title={dialogOptions.title || "Confirmation"}
margin={"mt-1"}
description={

View File

@@ -20,6 +20,7 @@ const RoutesContext = React.createContext(
toUpdate: Partial<Route>,
onSuccess?: (route: Route) => void,
message?: string,
options?: { remove_access_control_groups?: boolean },
) => void;
},
);
@@ -33,6 +34,7 @@ export default function RoutesProvider({ children }: Readonly<Props>) {
toUpdate: Partial<Route>,
onSuccess?: (route: Route) => void,
message?: string,
options?: { remove_access_control_groups?: boolean },
) => {
const hasDomains = route.domains ? route.domains.length > 0 : false;
@@ -54,10 +56,11 @@ export default function RoutesProvider({ children }: Readonly<Props>) {
metric: toUpdate.metric ?? route.metric ?? 9999,
masquerade: toUpdate.masquerade ?? route.masquerade ?? true,
groups: toUpdate.groups ?? route.groups ?? [],
access_control_groups:
toUpdate.access_control_groups ??
route.access_control_groups ??
undefined,
access_control_groups: options?.remove_access_control_groups
? undefined
: toUpdate.access_control_groups ??
route.access_control_groups ??
undefined,
},
`/${route.id}`,
)

View File

@@ -11,5 +11,6 @@ export interface Account {
jwt_groups_claim_name: string;
jwt_allow_groups: string[];
regular_users_view_blocked: boolean;
routing_peer_dns_resolution_enabled: boolean;
};
}

View File

@@ -3,6 +3,9 @@ export interface Group {
name: string;
peers?: GroupPeer[] | string[];
peers_count?: number;
resources?: string[];
resources_count?: number;
// Frontend only
keepClientState?: boolean;
}

28
src/interfaces/Network.ts Normal file
View File

@@ -0,0 +1,28 @@
import { Group } from "@/interfaces/Group";
export interface Network {
id: string;
name: string;
description?: string;
resources?: string[];
policies?: string[];
routers?: string[];
routing_peers_count?: number;
}
export interface NetworkRouter {
id: string;
peer?: string;
peer_groups?: string[];
metric: number;
masquerade: boolean;
}
export interface NetworkResource {
id: string;
name: string;
description?: string;
address: string;
groups?: string[] | Group[];
type?: "domain" | "host" | "subnet";
}

View File

@@ -35,4 +35,5 @@ export interface GroupedRoute {
description?: string;
description_search?: string;
domain_search?: string;
routes_search?: string;
}

View File

@@ -8,7 +8,6 @@ import AccessControlIcon from "@/assets/icons/AccessControlIcon";
import ActivityIcon from "@/assets/icons/ActivityIcon";
import DNSIcon from "@/assets/icons/DNSIcon";
import DocsIcon from "@/assets/icons/DocsIcon";
import NetworkRoutesIcon from "@/assets/icons/NetworkRoutesIcon";
import PeerIcon from "@/assets/icons/PeerIcon";
import SettingsIcon from "@/assets/icons/SettingsIcon";
import SetupKeysIcon from "@/assets/icons/SetupKeysIcon";
@@ -17,6 +16,7 @@ import SidebarItem from "@/components/SidebarItem";
import { useAnnouncement } from "@/contexts/AnnouncementProvider";
import { useLoggedInUser } from "@/contexts/UsersProvider";
import { headerHeight } from "@/layouts/Header";
import { NetworkNavigation } from "@/modules/networks/misc/NetworkNavigation";
const customTheme: CustomFlowbiteTheme["sidebar"] = {
root: {
@@ -34,6 +34,7 @@ export default function Navigation({
hideOnMobile = false,
}: Props) {
const { isUser } = useLoggedInUser();
const { isOwnerOrAdmin } = useLoggedInUser();
const { bannerHeight } = useAnnouncement();
return (
@@ -104,11 +105,8 @@ export default function Navigation({
/>
</SidebarItem>
<SidebarItem
icon={<NetworkRoutesIcon />}
label="Network Routes"
href={"/network-routes"}
/>
<NetworkNavigation />
<SidebarItem
icon={<DNSIcon />}
label="DNS"
@@ -141,33 +139,24 @@ export default function Navigation({
/>
</>
)}
{isUser && (
<SidebarItem
icon={<DocsIcon />}
href={"https://docs.netbird.io/"}
target={"_blank"}
label="Documentation"
/>
)}
</SidebarItemGroup>
{!isUser && (
<SidebarItemGroup>
<SidebarItemGroup>
{isOwnerOrAdmin && (
<SidebarItem
icon={<SettingsIcon />}
label="Settings"
href={"/settings"}
exactPathMatch={true}
/>
<SidebarItem
icon={<DocsIcon />}
href={"https://docs.netbird.io/"}
target={"_blank"}
label="Documentation"
/>
</SidebarItemGroup>
)}
)}
<SidebarItem
icon={<DocsIcon />}
href={"https://docs.netbird.io/"}
target={"_blank"}
label="Documentation"
/>
</SidebarItemGroup>
</div>
</div>
</ScrollArea>

View File

@@ -11,6 +11,7 @@ export default function PageContainer({ children, className }: Props) {
className={cn(
className,
"relative flex-auto overflow-auto bg-nb-gray z-1",
"focus:outline-none",
)}
>
{children}

View File

@@ -41,6 +41,7 @@ import {
} from "lucide-react";
import React, { useMemo, useState } from "react";
import AccessControlIcon from "@/assets/icons/AccessControlIcon";
import { Group } from "@/interfaces/Group";
import { Policy, Protocol } from "@/interfaces/Policy";
import { PostureCheck } from "@/interfaces/PostureCheck";
import { useAccessControl } from "@/modules/access-control/useAccessControl";
@@ -105,6 +106,9 @@ export function AccessControlUpdateModal({
type ModalProps = {
onSuccess?: (p: Policy) => void;
policy?: Policy;
initialDestinationGroups?: Group[] | string[];
initialName?: string;
initialDescription?: string;
cell?: string;
postureCheckTemplates?: PostureCheck[];
useSave?: boolean;
@@ -118,6 +122,9 @@ export function AccessControlModalContent({
postureCheckTemplates,
useSave = true,
allowEditPeers = false,
initialDestinationGroups,
initialName,
initialDescription,
}: Readonly<ModalProps>) {
const {
portAndDirectionDisabled,
@@ -142,7 +149,14 @@ export function AccessControlModalContent({
submit,
isPostureChecksLoading,
getPolicyData,
} = useAccessControl({ policy, postureCheckTemplates, onSuccess });
} = useAccessControl({
policy,
postureCheckTemplates,
onSuccess,
initialDestinationGroups,
initialName,
initialDescription,
});
const [tab, setTab] = useState(() => {
if (!cell) return "policy";

View File

@@ -15,6 +15,9 @@ type Props = {
policy?: Policy;
postureCheckTemplates?: PostureCheck[];
onSuccess?: (policy: Policy) => void;
initialDestinationGroups?: Group[] | string[];
initialName?: string;
initialDescription?: string;
};
// TODO add reducer
@@ -22,6 +25,9 @@ type Props = {
export const useAccessControl = ({
policy,
postureCheckTemplates,
initialDestinationGroups,
initialName,
initialDescription,
onSuccess,
}: Props = {}) => {
const { data: allPostureChecks, isLoading: isPostureChecksLoading } =
@@ -85,8 +91,10 @@ export const useAccessControl = ({
if (firstRule && firstRule?.bidirectional == false) return "in";
return "bi";
});
const [name, setName] = useState(policy?.name || "");
const [description, setDescription] = useState(policy?.description || "");
const [name, setName] = useState(policy?.name || initialName || "");
const [description, setDescription] = useState(
policy?.description || initialDescription || "",
);
const { mutate } = useSWRConfig();
const policyRequest = useApiCall<Policy>("/policies");
@@ -104,7 +112,9 @@ export const useAccessControl = ({
setDestinationGroups,
{ getGroupsToUpdate: getDestinationGroupsToUpdate },
] = useGroupHelper({
initial: firstRule ? (firstRule.destinations as Group[]) : [],
initial: firstRule
? (firstRule.destinations as Group[])
: initialDestinationGroups ?? [],
});
const { updateOrCreateAndNotify: checkToCreate } = usePostureCheck({});

View File

@@ -46,7 +46,12 @@ export default function AccessTokenActionCell({ access_token }: Props) {
return (
<div className={"flex justify-end pr-4"}>
<Button variant={"danger-outline"} size={"sm"} onClick={handleConfirm}>
<Button
variant={"danger-outline"}
size={"sm"}
onClick={handleConfirm}
data-cy={"access-token-delete"}
>
<Trash2 size={16} />
Delete
</Button>

View File

@@ -98,6 +98,7 @@ export default function CreateAccessTokenModal({ children, user }: Props) {
variant={"secondary"}
className={"w-full"}
tabIndex={-1}
data-cy={"access-token-copy-close"}
>
Close
</Button>
@@ -170,6 +171,7 @@ export function AccessTokenModalContent({ onSuccess, user }: ModalProps) {
<Label>Name</Label>
<HelpText>Set an easily identifiable name for your token</HelpText>
<Input
data-cy={"access-token-name"}
placeholder={"e.g., Infra token"}
value={name}
onChange={(e) => setName(e.target.value)}
@@ -184,6 +186,7 @@ export function AccessTokenModalContent({ onSuccess, user }: ModalProps) {
<Input
maxWidthClass={"max-w-[200px]"}
placeholder={"30"}
data-cy={"access-token-expires-in"}
min={1}
max={365}
value={expiresIn}
@@ -215,7 +218,12 @@ export function AccessTokenModalContent({ onSuccess, user }: ModalProps) {
<Button variant={"secondary"}>Cancel</Button>
</ModalClose>
<Button variant={"primary"} onClick={submit} disabled={isDisabled}>
<Button
variant={"primary"}
onClick={submit}
disabled={isDisabled}
data-cy={"create-access-token"}
>
<PlusCircle size={16} />
Create Token
</Button>

View File

@@ -3,7 +3,7 @@ import { useMemo } from "react";
import { Account } from "@/interfaces/Account";
export const useAccount = () => {
const { data: accounts } = useFetchApi<Account[]>("/accounts");
const { data: accounts } = useFetchApi<Account[]>("/accounts", true, true);
return useMemo(() => {
if (!accounts) return;

View File

@@ -543,6 +543,98 @@ export default function ActivityDescription({ event }: Props) {
</div>
);
/**
* Resource
*/
if (event.activity_code == "resource.group.add")
return (
<div className={"inline"}>
Group <Value>{m.resource_name}</Value> added to resource{" "}
<Value>{m.name}</Value>
</div>
);
if (event.activity_code == "resource.group.delete")
return (
<div className={"inline"}>
Group <Value>{m.resource_name}</Value> removed from resource{" "}
<Value>{m.name}</Value>
</div>
);
/**
* Networks
*/
if (event.activity_code == "network.resource.create")
return (
<div className={"inline"}>
Resource <Value>{m.name}</Value> created for network{" "}
<Value>{m.network_name}</Value>
</div>
);
if (event.activity_code == "network.resource.update")
return (
<div className={"inline"}>
Resource <Value>{m.name}</Value> updated for network{" "}
<Value>{m.network_name}</Value>
</div>
);
if (event.activity_code == "network.resource.delete")
return (
<div className={"inline"}>
Resource <Value>{m.name}</Value> deleted from network{" "}
<Value>{m.network_name}</Value>
</div>
);
if (event.activity_code == "network.router.create")
return (
<div className={"inline"}>
Routing peer created for network{" "}
<Value>{m.network_name}</Value>
</div>
);
if (event.activity_code == "network.router.delete")
return (
<div className={"inline"}>
Routing peer deleted from network{" "}
<Value>{m.network_name}</Value>
</div>
);
if (event.activity_code == "network.router.update")
return (
<div className={"inline"}>
Routing peer updated from network{" "}
<Value>{m.network_name}</Value>
</div>
);
if (event.activity_code == "network.create")
return (
<div className={"inline"}>
Network with name <Value>{m.name}</Value> created
</div>
);
if (event.activity_code == "network.delete")
return (
<div className={"inline"}>
Network with name <Value>{m.name}</Value> deleted
</div>
);
if (event.activity_code == "network.update")
return (
<div className={"inline"}>
Network with name <Value>{m.name}</Value> updated
</div>
);
return (
<div className={"flex gap-2.5 items-center"}>
<span className={"mb-[1px]"}>{event.activity}</span>

View File

@@ -8,6 +8,7 @@ import {
Globe,
HelpCircleIcon,
KeyRound,
Layers3Icon,
LogIn,
MonitorSmartphoneIcon,
NetworkIcon,
@@ -89,6 +90,14 @@ export default function ActivityTypeIcon({
return (
<RefreshCcw size={size} className={cn(DEFAULT_CLASSES, className)} />
);
} else if (code.startsWith("resource")) {
return (
<Layers3Icon size={size} className={cn(DEFAULT_CLASSES, className)} />
);
} else if (code.startsWith("network")) {
return (
<NetworkIcon size={size} className={cn(DEFAULT_CLASSES, className)} />
);
} else {
return (
<HelpCircleIcon size={size} className={cn(DEFAULT_CLASSES, className)} />

View File

@@ -0,0 +1,25 @@
import { uniq } from "lodash";
import { useEffect, useMemo, useState } from "react";
import { useGroups } from "@/contexts/GroupsProvider";
import type { Group } from "@/interfaces/Group";
export const useGroupIdsToGroups = (initial?: string[]) => {
const { groups, isLoading } = useGroups();
const [initialSet, setInitialSet] = useState(false);
const [mappedGroups, setMappedGroups] = useState<Group[] | undefined>(
undefined,
);
useEffect(() => {
// Only run the mapping once when groups are loaded and initial IDs are available
if (!initialSet && !isLoading && groups && initial) {
const mapped = uniq(initial)
.map((group) => groups.find((g) => g?.id === group))
.filter((g): g is Group => g !== undefined);
setMappedGroups(mapped);
setInitialSet(true); // Mark that we've done the initial mapping to prevent subsequent runs
}
}, [groups, initial, isLoading, initialSet]);
return useMemo(() => mappedGroups, [mappedGroups]);
};

View File

@@ -0,0 +1,164 @@
"use client";
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,
} from "@components/modal/Modal";
import ModalHeader from "@components/modal/ModalHeader";
import { notify } from "@components/Notification";
import Paragraph from "@components/Paragraph";
import Separator from "@components/Separator";
import { Textarea } from "@components/Textarea";
import { useApiCall } from "@utils/api";
import { ExternalLinkIcon, PlusCircle } from "lucide-react";
import React, { useState } from "react";
import NetworkRoutesIcon from "@/assets/icons/NetworkRoutesIcon";
import { Network } from "@/interfaces/Network";
type Props = {
open: boolean;
setOpen?: (open: boolean) => void;
network?: Network;
onCreated?: (network: Network) => void;
onUpdated?: (network: Network) => void;
};
export default function NetworkModal({
open,
setOpen,
network,
onCreated,
onUpdated,
}: Readonly<Props>) {
return (
<Modal open={open} onOpenChange={setOpen}>
<Content
network={network}
onCreated={(network) => {
setOpen?.(false);
onCreated?.(network);
}}
onUpdated={(network) => {
setOpen?.(false);
onUpdated?.(network);
}}
key={open ? "1" : "0"}
/>
</Modal>
);
}
type ContentProps = {
onCreated?: (network: Network) => void;
onUpdated?: (network: Network) => void;
network?: Network;
};
const Content = ({ network, onCreated, onUpdated }: ContentProps) => {
const [name, setName] = useState(network?.name || "");
const [description, setDescription] = useState(network?.description || "");
const create = useApiCall<Network>("/networks").post;
const update = useApiCall<Network>("/networks").put;
const updateNetwork = async () => {
notify({
title: name,
description: "Network updated successfully.",
loadingMessage: "Updating network...",
promise: update({ name, description }, `/${network?.id}`).then((n) => {
onUpdated?.(n);
}),
});
};
const createNetwork = async () => {
notify({
title: name,
description: "Network created successfully.",
loadingMessage: "Creating network...",
promise: create({ name, description }).then((n) => {
onCreated?.(n);
}),
});
};
return (
<ModalContent maxWidthClass={"max-w-xl"}>
<ModalHeader
icon={<NetworkRoutesIcon className={"fill-netbird"} />}
title={network ? "Update Network" : "Add Network"}
description={
network
? network.name
: "Access resources like LANs and VPC by adding a network."
}
color={"netbird"}
/>
<Separator />
<div className={"px-8 flex-col flex gap-6 py-6"}>
<div>
<Label>Network Name</Label>
<HelpText>Provide a unique name for the network.</HelpText>
<Input
tabIndex={0}
placeholder={"e.g., Office Network"}
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
<div>
<Label>Description (optional)</Label>
<HelpText>
Write a short description to add more context to this network.
</HelpText>
<Textarea
placeholder={"e.g., Berlin, Münzstraße 12 "}
value={description}
rows={3}
onChange={(e) => setDescription(e.target.value)}
/>
</div>
</div>
<ModalFooter className={"items-center"}>
<div className={"w-full"}>
<Paragraph className={"text-sm mt-auto"}>
Learn more about
<InlineLink href={"#"} target={"_blank"}>
Networks
<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={network ? updateNetwork : createNetwork}
>
{network ? (
"Save Changes"
) : (
<>
<PlusCircle size={16} />
Add Network
</>
)}
</Button>
</div>
</ModalFooter>
</ModalContent>
);
};

View File

@@ -0,0 +1,340 @@
import { Modal } from "@components/modal/Modal";
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 { Group } from "@/interfaces/Group";
import { Network, NetworkResource, NetworkRouter } from "@/interfaces/Network";
import { AccessControlModalContent } from "@/modules/access-control/AccessControlModal";
import NetworkModal from "@/modules/networks/NetworkModal";
import NetworkResourceModal from "@/modules/networks/resources/NetworkResourceModal";
import NetworkRoutingPeerModal from "@/modules/networks/routing-peers/NetworkRoutingPeerModal";
type Props = {
children: React.ReactNode;
network?: Network;
};
const NetworksContext = React.createContext(
{} as {
openAddRoutingPeerModal: (network: Network, router?: NetworkRouter) => void;
openEditNetworkModal: (network: Network) => void;
openCreateNetworkModal: () => void;
openResourceModal: (network: Network, resource?: NetworkResource) => void;
openPolicyModal: (network?: Network, resource?: NetworkResource) => void;
deleteNetwork: (network: Network) => void;
deleteResource: (network: Network, resource: NetworkResource) => void;
deleteRouter: (network: Network, router: NetworkRouter) => void;
network?: Network;
},
);
export const NetworkProvider = ({ children, network }: Props) => {
const { mutate } = useSWRConfig();
const { confirm } = useDialog();
const deleteCall = useApiCall("/networks").del;
const [currentNetwork, setCurrentNetwork] = useState<Network>();
const [currentResource, setCurrentResource] = useState<NetworkResource>();
const [currentRouter, setCurrentRouter] = useState<NetworkRouter>();
const [policyDefaultSettings, setPolicyDefaultSettings] = useState<{
name?: string;
description?: string;
destinationGroups?: Group[] | string[];
}>();
const [routingPeerModal, setRoutingPeerModal] = useState(false);
const [networkModal, setNetworkModal] = useState(false);
const [resourceModal, setResourceModal] = useState(false);
const [policyModal, setPolicyModal] = useState(false);
const openAddRoutingPeerModal = (
network: Network,
router?: NetworkRouter,
) => {
setCurrentNetwork(network);
router && setCurrentRouter(router);
setRoutingPeerModal(true);
};
const openEditNetworkModal = (network: Network) => {
setCurrentNetwork(network);
setNetworkModal(true);
};
const openCreateNetworkModal = () => {
setCurrentNetwork(undefined);
setNetworkModal(true);
};
const openResourceModal = (network: Network, resource?: NetworkResource) => {
setCurrentNetwork(network);
resource && setCurrentResource(resource);
setResourceModal(true);
};
const openPolicyModal = (network?: Network, resource?: NetworkResource) => {
setPolicyDefaultSettings({
destinationGroups: resource?.groups,
name:
network && !resource
? `${network?.name} Policy`
: resource
? `${resource?.name} Policy`
: "",
description:
network && !resource
? network?.description
: network
? `${network.name} ${
network.description ? ", " + network.description : ""
}`
: undefined,
});
setPolicyModal(true);
};
const deleteNetwork = async (network: Network) => {
const choice = await confirm({
title: `Delete network '${network.name}'?`,
description:
"Are you sure you want to delete this network? Every resource and routing peers will be removed from this network. This action cannot be undone.",
confirmText: "Delete",
cancelText: "Cancel",
type: "danger",
});
if (!choice) return;
notify({
title: network.name,
description: "Network deleted successfully.",
loadingMessage: "Deleting network...",
promise: deleteCall({}, `/${network.id}`).then(() => {
mutate("/networks");
mutate("/groups");
}),
});
};
const deleteResource = async (
network: Network,
resource: NetworkResource,
) => {
const choice = await confirm({
title: `Delete resource '${resource.name}'?`,
description:
"Are you sure you want to delete this resource? This action cannot be undone.",
confirmText: "Delete",
cancelText: "Cancel",
type: "danger",
});
if (!choice) return;
notify({
title: resource.name,
description: "Resource deleted successfully.",
loadingMessage: "Deleting resource...",
promise: deleteCall({}, `/${network.id}/resources/${resource.id}`).then(
() => {
mutate(`/networks/${network.id}/resources`);
mutate("/groups");
},
),
});
};
const deleteRouter = async (network: Network, router: NetworkRouter) => {
const choice = await confirm({
title: `Remove this router?`,
description: "Are you sure you want to remove this router?",
confirmText: "Remove",
cancelText: "Cancel",
type: "danger",
});
if (!choice) return;
notify({
title: "Router of " + network.name,
description: "Router deleted successfully.",
loadingMessage: "Deleting router...",
promise: deleteCall({}, `/${network.id}/routers/${router.id}`).then(
() => {
mutate(`/networks/${network.id}/routers`);
},
),
});
};
const askForRoutingPeer = async (network: Network) => {
const choice = await confirm({
title: `Add Routing Peer to '${network.name}'?`,
description:
"Without a routing peer, the resources inside this network will not be accessible by any peers.",
confirmText: "Add Routing Peer",
cancelText: "Later",
type: "default",
});
if (!choice) return;
openAddRoutingPeerModal(network);
};
const askForResource = async (network: Network) => {
const choice = await confirm({
title: `Add Resource to '${network.name}'?`,
description:
"Peers will be able to access your network resources once you add them.",
confirmText: "Add Resource",
cancelText: "Later",
type: "default",
});
if (!choice) return;
openResourceModal(network);
};
const askForAccessControlPolicy = async (res: NetworkResource) => {
const choice = await confirm({
title: `Add policy for '${res.name}'?`,
description:
"Without a policy, the resource will not be accessible by any peers. Create a policy to control access to this resource.",
confirmText: "Create Policy",
cancelText: "Later",
type: "default",
});
if (!choice) return;
openPolicyModal(currentNetwork, res);
};
return (
<NetworksContext.Provider
value={{
openAddRoutingPeerModal,
openEditNetworkModal,
openCreateNetworkModal,
openResourceModal,
openPolicyModal,
deleteNetwork,
deleteResource,
deleteRouter,
network,
}}
>
{children}
<NetworkModal
open={networkModal}
setOpen={setNetworkModal}
network={currentNetwork}
onCreated={async (network) => {
mutate("/networks");
await askForRoutingPeer(network);
}}
onUpdated={() => {
mutate("/networks");
}}
/>
<Modal
open={policyModal}
onOpenChange={(state) => {
setPolicyModal(state);
setPolicyDefaultSettings(undefined);
}}
>
<AccessControlModalContent
key={policyModal ? "1" : "0"}
initialDestinationGroups={policyDefaultSettings?.destinationGroups}
initialName={policyDefaultSettings?.name}
initialDescription={policyDefaultSettings?.description}
onSuccess={(p) => {
setPolicyModal(false);
setPolicyDefaultSettings(undefined);
mutate("/networks");
if (network) {
mutate(`/networks/${network.id}/resources`);
mutate(`/networks/${network.id}`);
}
}}
/>
</Modal>
{currentNetwork && (
<>
<NetworkRoutingPeerModal
network={currentNetwork}
router={currentRouter}
open={routingPeerModal}
onCreated={async () => {
setRoutingPeerModal(false);
setCurrentRouter(undefined);
mutate(`/networks`);
mutate("/groups");
if (network) {
mutate(`/networks/${currentNetwork.id}/routers`);
mutate(`/networks/${network.id}`);
} else {
await askForResource(currentNetwork);
}
}}
onUpdated={async () => {
setRoutingPeerModal(false);
setCurrentRouter(undefined);
mutate(`/networks`);
mutate("/groups");
if (network) {
mutate(`/networks/${network.id}`);
mutate(`/networks/${currentNetwork.id}/routers`);
}
}}
setOpen={(state) => {
setCurrentRouter(undefined);
setRoutingPeerModal(state);
}}
/>
<NetworkResourceModal
network={currentNetwork}
resource={currentResource}
onCreated={async (r) => {
setResourceModal(false);
setCurrentResource(undefined);
mutate("/networks");
mutate("/groups");
if (network) {
mutate(`/networks/${network.id}/resources`);
mutate(`/networks/${network.id}`);
} else {
await askForAccessControlPolicy(r);
}
}}
onUpdated={() => {
setResourceModal(false);
setCurrentResource(undefined);
mutate("/networks");
mutate("/groups");
if (network) {
mutate(`/networks/${network.id}/resources`);
mutate(`/networks/${network.id}`);
}
}}
open={resourceModal}
setOpen={(state) => {
setCurrentResource(undefined);
setResourceModal(state);
}}
/>
</>
)}
</NetworksContext.Provider>
);
};
export const useNetworksContext = () => {
const context = React.useContext(NetworksContext);
if (context === undefined) {
throw new Error("useNetworksContext must be used within a NetworkProvider");
}
return context;
};

View File

@@ -1,16 +1,20 @@
import { DomainListBadge } from "@components/ui/DomainListBadge";
import { IconDirectionSign } from "@tabler/icons-react";
import { InfoIcon } from "lucide-react";
import * as React from "react";
import { Route } from "@/interfaces/Route";
import { ExitNodeHelpTooltip } from "@/modules/exit-node/ExitNodeHelpTooltip";
type Props = {
route: Route;
network?: string;
domains?: string[];
};
export default function PeerRouteNetworkCell({ route }: Props) {
const isExitNode = route?.network === "0.0.0.0/0";
export default function NetworkRangeCell({ network, domains }: Props) {
const isExitNode = network === "0.0.0.0/0";
const hasDomains = domains ? domains.length > 0 : false;
return isExitNode ? (
return hasDomains && domains ? (
<DomainListBadge domains={domains} />
) : isExitNode ? (
<ExitNodeHelpTooltip>
<div className={"flex gap-2 items-center dark:text-nb-gray-300 group"}>
<IconDirectionSign size={16} className={"text-yellow-400"} />
@@ -24,8 +28,6 @@ export default function PeerRouteNetworkCell({ route }: Props) {
</div>
</ExitNodeHelpTooltip>
) : (
<div className={"font-mono dark:text-nb-gray-300 flex max-w-[10px]"}>
{route.network}
</div>
<div className={"font-mono dark:text-nb-gray-300 flex"}>{network}</div>
);
}

View File

@@ -0,0 +1,30 @@
import Badge from "@components/Badge";
import Button from "@components/Button";
import { PlusCircle, ShieldIcon } from "lucide-react";
import * as React from "react";
type Props = {
count: number;
};
export const PolicyCell = ({ count }: Props) => {
return count > 0 ? (
<div className={"flex gap-3"}>
<Badge variant={"gray"} useHover={true}>
<ShieldIcon size={14} className={"text-green-500"} />
<div>
<span className={"font-medium"}>{count}</span> Access Policie(s)
</div>
</Badge>
<Button size={"xs"} variant={"secondary"} className={"min-w-[130px]"}>
<PlusCircle size={12} />
Add Policy
</Button>
</div>
) : (
<Button size={"xs"} variant={"secondary"} className={"min-w-[130px]"}>
<PlusCircle size={12} />
Add Policy
</Button>
);
};

View File

@@ -0,0 +1,82 @@
import DescriptionWithTooltip from "@components/ui/DescriptionWithTooltip";
import { cn } from "@utils/helpers";
import { ArrowRightIcon } from "lucide-react";
import * as React from "react";
type Props = {
onClick?: () => void;
name: string;
description?: string;
active?: boolean;
size?: "md" | "lg";
};
export const NetworkInformationSquare = ({
onClick,
name,
description,
active = false,
size = "md",
}: Props) => {
return (
<button
className={cn(
"flex w-full items-center max-w-[300px] gap-4 dark:text-neutral-300 text-neutral-500 transition-all group/network rounded-md",
onClick
? "hover:text-neutral-100 hover:bg-nb-gray-910 cursor-pointer py-2 pl-3 pr-5 relative"
: "cursor-default",
)}
onClick={onClick}
>
<div
className={cn(
"bg-nb-gray-800 text-nb-gray-100 rounded-md flex items-center justify-center font-medium relative",
"uppercase",
size === "md" ? "h-10 w-10 text-md" : "h-12 w-12 text-lg",
"shrink-0",
)}
>
{name.substring(0, 2)}
<div
className={cn(
"h-2 w-2 rounded-full absolute bottom-0 right-0 z-10",
active ? "bg-green-500" : "bg-nb-gray-700",
)}
></div>
<div
className={cn(
"h-3 w-3 bg-nb-gray-950 rounded-tl-[8px] rounded-br absolute bottom-0 right-0 transition-all",
onClick && "group-hover/network:bg-nb-gray-910",
onClick && "group-hover/table-row:bg-nb-gray-940",
)}
></div>
</div>
<div className={"mt-[0px] flex items-center flex-wrap"}>
<p
className={cn(
"font-medium",
size == "md" ? "text-sm" : "text-xl leading-none mb-0.5",
)}
>
{name}
</p>
<DescriptionWithTooltip
className={cn(
"text-left",
size == "lg" && "text-md leading-none mt-0.5",
)}
maxChars={24}
text={description}
/>
</div>
{onClick && (
<div
className={
"absolute right-0 top-0 h-full flex items-center pr-4 text-nb-gray-200 opacity-0 group-hover/network:opacity-100"
}
>
<ArrowRightIcon size={18} />
</div>
)}
</button>
);
};

View File

@@ -0,0 +1,27 @@
import SidebarItem from "@components/SidebarItem";
import * as React from "react";
import NetworkRoutesIcon from "@/assets/icons/NetworkRoutesIcon";
import { NetworkRoutesDeprecationInfo } from "@/modules/networks/misc/NetworkRoutesDeprecationInfo";
export const NetworkNavigation = () => {
return (
<SidebarItem
icon={<NetworkRoutesIcon />}
label="Networks"
collapsible
exactPathMatch={false}
>
<SidebarItem label="Networks" isChild href={"/networks"} />
<SidebarItem
label={
<div className={"flex items-center"}>
Network Routes
<NetworkRoutesDeprecationInfo />
</div>
}
isChild
href={"/network-routes"}
/>
</SidebarItem>
);
};

View File

@@ -0,0 +1,23 @@
import FullTooltip from "@components/FullTooltip";
import { TriangleAlertIcon } from "lucide-react";
import * as React from "react";
type Props = {
size?: number;
};
export const NetworkRoutesDeprecationInfo = ({ size = 14 }: Props) => {
return (
<FullTooltip
content={
<div className={"text-xs max-w-[230px]"}>
Network Routes will be deprecated and replaced with Networks.
</div>
}
>
<TriangleAlertIcon
size={size}
className={"text-amber-500 ml-2.5 hover:text-amber-400 cursor-help"}
/>
</FullTooltip>
);
};

View File

@@ -0,0 +1,194 @@
"use client";
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,
} from "@components/modal/Modal";
import ModalHeader from "@components/modal/ModalHeader";
import { notify } from "@components/Notification";
import Paragraph from "@components/Paragraph";
import { PeerGroupSelector } from "@components/PeerGroupSelector";
import Separator from "@components/Separator";
import { useApiCall } from "@utils/api";
import { ExternalLinkIcon, PlusCircle, WorkflowIcon } from "lucide-react";
import React, { useMemo, useState } from "react";
import { Network, NetworkResource } from "@/interfaces/Network";
import useGroupHelper from "@/modules/groups/useGroupHelper";
import { ResourceSingleAddressInput } from "@/modules/networks/resources/ResourceSingleAddressInput";
type Props = {
open?: boolean;
setOpen?: (open: boolean) => void;
network: Network;
resource?: NetworkResource;
onCreated?: (r: NetworkResource) => void;
onUpdated?: (r: NetworkResource) => void;
};
export default function NetworkResourceModal({
network,
open,
setOpen,
resource,
onUpdated,
onCreated,
}: Props) {
return (
<Modal open={open} onOpenChange={setOpen}>
<ResourceModalContent
key={open ? "1" : "0"}
network={network}
resource={resource}
onCreated={onCreated}
onUpdated={onUpdated}
/>
</Modal>
);
}
type ModalProps = {
onCreated?: (r: NetworkResource) => void;
onUpdated?: (r: NetworkResource) => void;
network: Network;
resource?: NetworkResource;
};
export function ResourceModalContent({
onCreated,
onUpdated,
network,
resource,
}: ModalProps) {
const create = useApiCall<NetworkResource>(
`/networks/${network.id}/resources`,
).post;
const update = useApiCall<NetworkResource>(
`/networks/${network.id}/resources/${resource?.id}`,
).put;
const [name, setName] = useState(resource?.name || "");
const [description, setDescription] = useState(resource?.description || "");
const [address, setAddress] = useState(resource?.address || "");
const [groups, setGroups, { save: saveGroups }] = useGroupHelper({
initial: resource?.groups || [],
});
const createResource = async () => {
const savedGroups = await saveGroups();
notify({
title: "Resource Created",
description: `The resource "${name}" has been created successfully.`,
loadingMessage: "Creating resource...",
promise: create({
name,
description,
address,
groups: savedGroups.map((g) => g.id),
}).then((r) => {
onCreated?.(r);
}),
});
};
const updateResource = async () => {
const savedGroups = await saveGroups();
notify({
title: "Resource Updated",
description: `The resource "${name}" has been updated successfully.`,
loadingMessage: "Updating resource...",
promise: update({
name,
description,
address,
groups: savedGroups.map((g) => g.id),
}).then((r) => {
onUpdated?.(r);
}),
});
};
// TODO: Address validation is missing for proper handling of submit button
const canCreate = useMemo(() => {
return name.length > 0 && address.length > 0 && groups.length > 0;
}, [name, address, groups]);
return (
<ModalContent maxWidthClass={"max-w-xl"}>
<ModalHeader
icon={<WorkflowIcon size={20} />}
title={resource ? "Edit Resource" : "Add Resource"}
description={
resource
? `${resource.name}`
: `Add new resource to "${network?.name}"`
}
color={"yellow"}
/>
<Separator />
<div className={"px-8 flex-col flex gap-6 py-6"}>
<div>
<Label>Name</Label>
<HelpText>Provide a name for your resource</HelpText>
<Input
tabIndex={0}
placeholder={"e.g., Postgres Database"}
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
<ResourceSingleAddressInput value={address} onChange={setAddress} />
<div>
<Label>Assigned Groups</Label>
<HelpText>
Control access to this resource by assigning it to groups
</HelpText>
<PeerGroupSelector onChange={setGroups} values={groups} />
</div>
</div>
<ModalFooter className={"items-center"}>
<div className={"w-full"}>
<Paragraph className={"text-sm mt-auto"}>
Learn more about
<InlineLink href={"#"} target={"_blank"}>
Resources
<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"}
onClick={resource ? updateResource : createResource}
disabled={!canCreate}
>
{resource ? (
<>Save Changes</>
) : (
<>
<PlusCircle size={16} />
Add Resource
</>
)}
</Button>
</div>
</ModalFooter>
</ModalContent>
);
}

View File

@@ -0,0 +1,39 @@
import Button from "@components/Button";
import { SquarePenIcon, Trash2 } from "lucide-react";
import * as React from "react";
import { NetworkResource } from "@/interfaces/Network";
import { useNetworksContext } from "@/modules/networks/NetworkProvider";
type Props = {
resource: NetworkResource;
};
export const ResourceActionCell = ({ resource }: Props) => {
const { deleteResource, network, openResourceModal } = useNetworksContext();
return (
<div className={"flex justify-end pr-4"}>
<Button
variant={"default-outline"}
size={"sm"}
onClick={() => {
if (!network) return;
openResourceModal(network, resource);
}}
>
<SquarePenIcon size={16} />
Edit
</Button>
<Button
variant={"danger-outline"}
size={"sm"}
onClick={() => {
if (!network) return;
deleteResource(network, resource);
}}
>
<Trash2 size={16} />
Remove
</Button>
</div>
);
};

View File

@@ -0,0 +1,22 @@
import CopyToClipboardText from "@components/CopyToClipboardText";
import React from "react";
import { NetworkResource } from "@/interfaces/Network";
type Props = {
resource: NetworkResource;
};
export default function ResourceAddressCell({ resource }: Readonly<Props>) {
return (
<CopyToClipboardText
message={`${resource.address} has been copied to your clipboard`}
>
<div
className={
"font-mono dark:text-nb-gray-300 pt-1 flex gap-2 items-center text-[.82rem]"
}
>
{resource.address}
</div>
</CopyToClipboardText>
);
}

View File

@@ -0,0 +1,15 @@
import MultipleGroups from "@components/ui/MultipleGroups";
import * as React from "react";
import { Group } from "@/interfaces/Group";
import { NetworkResource } from "@/interfaces/Network";
type Props = {
resource?: NetworkResource;
};
export const ResourceGroupCell = ({ resource }: Props) => {
return (
<div className={"flex"}>
<MultipleGroups groups={resource?.groups as Group[]} />
</div>
);
};

View File

@@ -0,0 +1,32 @@
import DescriptionWithTooltip from "@components/ui/DescriptionWithTooltip";
import { cn } from "@utils/helpers";
import { GlobeIcon, NetworkIcon, WorkflowIcon } from "lucide-react";
import React from "react";
import { NetworkResource } from "@/interfaces/Network";
type Props = {
resource: NetworkResource;
};
export default function ResourceNameCell({ resource }: Readonly<Props>) {
return (
<div className={"flex gap-4 items-center"}>
<div
className={cn(
"flex items-center justify-center rounded-md h-9 w-9 shrink-0 bg-nb-gray-900 transition-all",
)}
>
{resource.type === "host" && <WorkflowIcon size={15} />}
{resource.type === "domain" && <GlobeIcon size={15} />}
{resource.type === "subnet" && <NetworkIcon size={15} />}
</div>
<div className="flex flex-col gap-0 dark:text-neutral-300 text-neutral-500 font-light truncate">
<span className={"font-normal truncate"}>{resource.name}</span>
<DescriptionWithTooltip
className={cn("font-normal mt-0.5 ")}
text={resource.description}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,101 @@
import Badge from "@components/Badge";
import Button from "@components/Button";
import FullTooltip from "@components/FullTooltip";
import useFetchApi from "@utils/api";
import { PlusCircle, ShieldIcon } from "lucide-react";
import * as React from "react";
import { useMemo } from "react";
import Skeleton from "react-loading-skeleton";
import { Group } from "@/interfaces/Group";
import { NetworkResource } from "@/interfaces/Network";
import { Policy } from "@/interfaces/Policy";
import { useNetworksContext } from "@/modules/networks/NetworkProvider";
type Props = {
resource?: NetworkResource;
};
export const ResourcePolicyCell = ({ resource }: Props) => {
const { openPolicyModal, network } = useNetworksContext();
const { data: policies, isLoading } = useFetchApi<Policy[]>("/policies");
const assignedPolicies = useMemo(() => {
const resourceGroups = resource?.groups as Group[];
return policies?.filter((policy) => {
if (!policy.enabled) return false;
const sourcePolicyGroups = policy.rules
?.map((rule) => rule?.sources)
.flat() as Group[];
const destinationPolicyGroups = policy.rules
?.map((rule) => rule?.destinations)
.flat() as Group[];
const policyGroups = [...sourcePolicyGroups, ...destinationPolicyGroups];
return resourceGroups.some((resourceGroup) =>
policyGroups.some((policyGroup) => policyGroup.id === resourceGroup.id),
);
});
}, [policies, resource]);
if (isLoading) {
return (
<div className={"flex gap-3"}>
<Skeleton height={34} width={220} />
</div>
);
}
const policyCount = assignedPolicies?.length || 0;
return (
network && (
<div className={"flex gap-3"}>
{policyCount > 0 && (
<FullTooltip
content={
<div className={"text-xs max-w-lg"}>
<span className={"font-medium text-nb-gray-100 text-sm"}>
Assigned Policies
</span>
<div className={"flex gap-2 pt-2 pb-2 flex-wrap"}>
{assignedPolicies?.map((policy: Policy, index: number) => {
return (
<Badge
variant={"gray-ghost"}
useHover={false}
key={index}
className={"justify-start font-medium"}
>
<ShieldIcon size={14} className={"text-green-500"} />
{policy.name}
</Badge>
);
})}
</div>
</div>
}
interactive={true}
>
<Badge variant={"gray"} useHover={true}>
<ShieldIcon size={14} className={"text-green-500"} />
<div>
<span className={"font-medium text-xs"}>
{" "}
{assignedPolicies?.length}
</span>
</div>
</Badge>
</FullTooltip>
)}
<Button
size={"xs"}
variant={"secondary"}
className={"min-w-[110px]"}
onClick={() => openPolicyModal(network, resource)}
>
<PlusCircle size={12} />
Add Policy
</Button>
</div>
)
);
};

View File

@@ -0,0 +1,65 @@
import HelpText from "@components/HelpText";
import { Input } from "@components/Input";
import { Label } from "@components/Label";
import { validator } from "@utils/helpers";
import cidr from "ip-cidr";
import { GlobeIcon, NetworkIcon, WorkflowIcon } from "lucide-react";
import * as React from "react";
import { useMemo } from "react";
type Props = {
value: string;
onChange: (value: string) => void;
};
export const ResourceSingleAddressInput = ({ value, onChange }: Props) => {
const hasChars = useMemo(() => {
return !!value.match(/[a-z*]/i);
}, [value]);
const isCIDRBlock = useMemo(() => {
return !!value.match(/\//);
}, [value]);
const PrefixIcon = useMemo(() => {
if (hasChars) return <GlobeIcon size={14} />;
if (isCIDRBlock) return <NetworkIcon size={14} />;
return <WorkflowIcon size={14} />;
}, [isCIDRBlock, hasChars]);
const error = useMemo(() => {
if (value === "") return "";
// Case 1: If it has characters (potential domain) but is not a CIDR block
if (hasChars && !isCIDRBlock) {
if (!validator.isValidDomainWithWildcard(value)) {
return "Please enter a valid domain, e.g. intra.example.com or *.example.com";
}
return ""; // Valid domain
}
// Case 2: If it's not a valid domain, check if it's a valid CIDR
if (!cidr.isValidAddress(value)) {
return "Please enter a valid IP or CIDR, e.g., 192.168.1.0/24";
}
return ""; // Valid CIDR
}, [value, hasChars, isCIDRBlock]);
return (
<>
<div>
<Label>Address</Label>
<HelpText>
Enter a single IP address, CIDR block or domain name
</HelpText>
<Input
customPrefix={PrefixIcon}
error={error}
placeholder={"Address (IP, CIDR or Domain)"}
value={value}
onChange={(e) => onChange(e.target.value)}
/>
</div>
</>
);
};

View File

@@ -0,0 +1,22 @@
import Badge from "@components/Badge";
import { NetworkIcon, WorkflowIcon } from "lucide-react";
import * as React from "react";
type Props = {
single: boolean;
};
export default function ResourceTypeCell({ single }: Props) {
return (
<div className={"inline-flex"}>
{single ? (
<Badge variant={"gray"} className={"min-w-[130px]"}>
<WorkflowIcon size={14} /> Single IP
</Badge>
) : (
<Badge variant={"gray"} className={"min-w-[130px]"}>
<NetworkIcon size={14} /> IP Range
</Badge>
)}
</div>
);
}

View File

@@ -0,0 +1,68 @@
import Button from "@components/Button";
import Paragraph from "@components/Paragraph";
import SkeletonTable, {
SkeletonTableHeader,
} from "@components/skeletons/SkeletonTable";
import { usePortalElement } from "@hooks/usePortalElement";
import { IconCirclePlus } from "@tabler/icons-react";
import useFetchApi from "@utils/api";
import * as React from "react";
import { Suspense } from "react";
import { Network, NetworkResource } from "@/interfaces/Network";
import { useNetworksContext } from "@/modules/networks/NetworkProvider";
import ResourcesTable from "@/modules/networks/resources/ResourcesTable";
type ResourcesSectionProps = {
network: Network;
};
export const ResourcesSection = ({ network }: ResourcesSectionProps) => {
const { data: resources, isLoading } = useFetchApi<NetworkResource[]>(
`/networks/${network.id}/resources`,
);
const { ref: headingRef, portalTarget } =
usePortalElement<HTMLHeadingElement>();
const { openResourceModal } = useNetworksContext();
return (
<div className={"py-7 px-8"}>
<div className={"max-w-6xl"}>
<div className={"flex justify-between items-center"}>
<div>
<h2 ref={headingRef}>Resources</h2>
<Paragraph>Add and manage resources for this network.</Paragraph>
</div>
<div className={"inline-flex gap-4 justify-end"}>
<div>
<Button
variant={"primary"}
onClick={() => openResourceModal(network)}
>
<IconCirclePlus size={16} />
Add Resource
</Button>
</div>
</div>
</div>
<Suspense
fallback={
<div>
<SkeletonTableHeader className={"!p-0"} />
<div className={"mt-8 w-full"}>
<SkeletonTable withHeader={false} />
</div>
</div>
}
>
<ResourcesTable
isLoading={isLoading}
headingTarget={portalTarget}
resources={resources}
/>
</Suspense>
</div>
</div>
);
};

View File

@@ -0,0 +1,113 @@
import Card from "@components/Card";
import { DataTable } from "@components/table/DataTable";
import DataTableHeader from "@components/table/DataTableHeader";
import NoResults from "@components/ui/NoResults";
import { ColumnDef, SortingState } from "@tanstack/react-table";
import { Layers3Icon } from "lucide-react";
import * as React from "react";
import { useState } from "react";
import { NetworkResource } from "@/interfaces/Network";
import { ResourceActionCell } from "@/modules/networks/resources/ResourceActionCell";
import ResourceAddressCell from "@/modules/networks/resources/ResourceAddressCell";
import { ResourceGroupCell } from "@/modules/networks/resources/ResourceGroupCell";
import ResourceNameCell from "@/modules/networks/resources/ResourceNameCell";
import { ResourcePolicyCell } from "@/modules/networks/resources/ResourcePolicyCell";
type Props = {
resources?: NetworkResource[];
isLoading: boolean;
headingTarget?: HTMLHeadingElement | null;
};
const NetworkResourceColumns: ColumnDef<NetworkResource>[] = [
{
id: "id",
accessorKey: "id",
header: ({ column }) => {
return <DataTableHeader column={column}>Resource</DataTableHeader>;
},
cell: ({ row }) => {
return <ResourceNameCell resource={row.original} />;
},
},
{
id: "address",
accessorKey: "address",
header: ({ column }) => {
return <DataTableHeader column={column}>Address</DataTableHeader>;
},
cell: ({ row }) => {
return <ResourceAddressCell resource={row.original} />;
},
},
{
id: "groups",
accessorKey: "id",
header: ({ column }) => {
return <DataTableHeader column={column}>Groups</DataTableHeader>;
},
cell: ({ row }) => {
return <ResourceGroupCell resource={row.original} />;
},
},
{
id: "policies",
accessorKey: "id",
header: ({ column }) => {
return <DataTableHeader column={column}>Policies</DataTableHeader>;
},
cell: ({ row }) => {
return <ResourcePolicyCell resource={row.original} />;
},
},
{
id: "actions",
accessorKey: "id",
header: "",
cell: ({ row }) => {
return <ResourceActionCell resource={row.original} />;
},
},
];
export default function ResourcesTable({
resources,
isLoading,
headingTarget,
}: Props) {
const [sorting, setSorting] = useState<SortingState>([]);
return (
<>
<DataTable
wrapperComponent={Card}
wrapperProps={{ className: "mt-6 w-full" }}
headingTarget={headingTarget}
sorting={sorting}
setSorting={setSorting}
minimal={true}
showSearchAndFilters={false}
inset={false}
tableClassName={"mt-0"}
text={"Peers"}
columns={NetworkResourceColumns}
keepStateInLocalStorage={false}
data={resources}
searchPlaceholder={"Search by name, IP, owner or group..."}
isLoading={isLoading}
getStartedCard={
<NoResults
className={"py-4"}
title={"This network has no resources"}
description={
"Add resources to this network to control what peers can access. Resources can be anything from a single IP, a subnet, or a domain."
}
icon={<Layers3Icon size={20} />}
/>
}
columnVisibility={{}}
paginationPaddingClassName={"px-0 pt-8"}
/>
</>
);
}

View File

@@ -0,0 +1,337 @@
"use client";
import Button from "@components/Button";
import FancyToggleSwitch from "@components/FancyToggleSwitch";
import HelpText from "@components/HelpText";
import InlineLink from "@components/InlineLink";
import { Input } from "@components/Input";
import { Label } from "@components/Label";
import {
Modal,
ModalClose,
ModalContent,
ModalFooter,
} from "@components/modal/Modal";
import ModalHeader from "@components/modal/ModalHeader";
import { notify } from "@components/Notification";
import Paragraph from "@components/Paragraph";
import { PeerGroupSelector } from "@components/PeerGroupSelector";
import { PeerSelector } from "@components/PeerSelector";
import { SegmentedTabs } from "@components/SegmentedTabs";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@components/Tabs";
import useFetchApi, { useApiCall } from "@utils/api";
import { cn } from "@utils/helpers";
import { uniqBy } from "lodash";
import {
ArrowDownWideNarrow,
ExternalLinkIcon,
FolderGit2,
MonitorSmartphoneIcon,
PlusCircle,
Settings2,
Share2Icon,
VenetianMask,
} from "lucide-react";
import React, { useState } from "react";
import { Network, NetworkRouter } from "@/interfaces/Network";
import { Peer } from "@/interfaces/Peer";
import useGroupHelper from "@/modules/groups/useGroupHelper";
type Props = {
network: Network;
open?: boolean;
setOpen?: (open: boolean) => void;
onCreated?: (r: NetworkRouter) => void;
onUpdated?: (r: NetworkRouter) => void;
router?: NetworkRouter;
};
export default function NetworkRoutingPeerModal({
network,
open,
setOpen,
onCreated,
onUpdated,
router,
}: Props) {
return (
<Modal open={open} onOpenChange={setOpen}>
<RoutingPeerModalContent
network={network}
router={router}
onCreated={onCreated}
onUpdated={onUpdated}
key={open ? "1" : "0"}
/>
</Modal>
);
}
type ContentProps = {
network: Network;
router?: NetworkRouter;
onCreated?: (r: NetworkRouter) => void;
onUpdated?: (r: NetworkRouter) => void;
};
function RoutingPeerModalContent({
network,
router,
onCreated,
onUpdated,
}: ContentProps) {
const isRoutingPeer = router ? router.peer != "" : true;
const [tab, setTab] = useState("router");
const [type, setType] = useState(isRoutingPeer ? "peer" : "group");
const create = useApiCall<NetworkRouter>(
`/networks/${network.id}/routers`,
).post;
const update = useApiCall<NetworkRouter>(
`/networks/${network.id}/routers/${router?.id}`,
).put;
const { data: peer } = useFetchApi<Peer>(
"/peers/" + router?.peer,
true,
false,
router ? router.peer != "" : false,
);
const [routingPeer, setRoutingPeer] = useState<Peer | undefined>(peer);
const [
routingPeerGroups,
setRoutingPeerGroups,
{ getGroupsToUpdate: getAllRoutingGroupsToUpdate },
] = useGroupHelper({
initial: router?.peer_groups || [],
});
const [masquerade, setMasquerade] = useState<boolean>(
router?.masquerade || true,
);
const [metric, setMetric] = useState(
router?.metric ? router.metric.toString() : "9999",
);
const addRouter = async () => {
// Create groups that do not exist
const g1 = getAllRoutingGroupsToUpdate();
const createOrUpdateGroups = uniqBy([...g1], "name").map((g) => g.promise);
const createdGroups = await Promise.all(
createOrUpdateGroups.map((call) => call()),
);
// Check if routing peer is selected
const isRoutingPeer = type === "peer";
notify({
title: "Network Routing Peer",
description: "Routing Peer added successfully.",
loadingMessage: "Adding Routing Peer...",
promise: create({
peer: isRoutingPeer ? routingPeer?.id : undefined,
peer_groups: !isRoutingPeer
? createdGroups.map((g) => g.id)
: undefined,
metric: parseInt(metric),
masquerade,
}).then((r) => {
onCreated?.(r);
}),
});
};
const updateRouter = async () => {
// Create groups that do not exist
const g1 = getAllRoutingGroupsToUpdate();
const createOrUpdateGroups = uniqBy([...g1], "name").map((g) => g.promise);
const createdGroups = await Promise.all(
createOrUpdateGroups.map((call) => call()),
);
// Check if routing peer is selected
const isRoutingPeer = type === "peer";
notify({
title: "Network Routing Peer",
description: "Routing Peer added successfully.",
loadingMessage: "Adding Routing Peer...",
promise: update({
peer: isRoutingPeer ? routingPeer?.id : undefined,
peer_groups: !isRoutingPeer
? createdGroups.map((g) => g.id)
: undefined,
metric: parseInt(metric),
masquerade,
}).then((r) => {
onUpdated?.(r);
}),
});
};
return (
<ModalContent maxWidthClass={"max-w-xl"}>
<ModalHeader
icon={<Share2Icon size={16} />}
title={router ? "Update Routing Peer" : "Add Routing Peer"}
description={`Route traffic to '${network.name}'`}
color={"netbird"}
/>
<Tabs defaultValue={tab} onValueChange={(v) => setTab(v)} value={tab}>
<TabsList justify={"between"} className={"px-8 justify-between w-full"}>
<TabsTrigger value={"router"}>
<Share2Icon
size={16}
className={
"text-nb-gray-500 group-data-[state=active]/trigger:text-netbird transition-all"
}
/>
Routers
</TabsTrigger>
<TabsTrigger value={"settings"} className={"ml-auto"}>
<Settings2
size={16}
className={
"text-nb-gray-500 group-data-[state=active]/trigger:text-netbird transition-all"
}
/>
Advanced Settings
</TabsTrigger>
</TabsList>
<TabsContent value={"router"} className={"pb-8"}>
<div className={"flex flex-col gap-4 px-8 "}>
<SegmentedTabs value={type} onChange={setType}>
<SegmentedTabs.List>
<SegmentedTabs.Trigger value={"peer"}>
<MonitorSmartphoneIcon size={16} />
Routing Peers
</SegmentedTabs.Trigger>
<SegmentedTabs.Trigger value={"group"}>
<FolderGit2 size={16} />
Peer Group
</SegmentedTabs.Trigger>
</SegmentedTabs.List>
<SegmentedTabs.Content value={"peer"}>
<div>
<HelpText>
Assign a single or multiple peers as a routing peers for the
network.
</HelpText>
<PeerSelector onChange={setRoutingPeer} value={routingPeer} />
</div>
</SegmentedTabs.Content>
<SegmentedTabs.Content value={"group"}>
<div>
<HelpText>
Assign a peer group with Linux machines to be used as
routing peers.
</HelpText>
<PeerGroupSelector
max={1}
onChange={setRoutingPeerGroups}
values={routingPeerGroups}
/>
</div>
</SegmentedTabs.Content>
</SegmentedTabs>
</div>
</TabsContent>
<TabsContent value={"settings"} className={"pb-4"}>
<div className={"px-8 flex flex-col gap-6"}>
<FancyToggleSwitch
value={masquerade}
onChange={setMasquerade}
label={
<>
<VenetianMask size={15} />
Masquerade
</>
}
helpText={
"Allow access to your private networks without configuring routes on your local routers or other devices."
}
/>
<div className={cn("flex justify-between")}>
<div>
<Label>Metric</Label>
<HelpText className={"max-w-[200px]"}>
A lower metric indicates higher priority routing peers.
</HelpText>
</div>
<Input
min={1}
max={9999}
maxWidthClass={"max-w-[200px]"}
value={metric}
data-cy={"metric"}
errorTooltip={true}
type={"number"}
onChange={(e) => setMetric(e.target.value)}
customPrefix={
<ArrowDownWideNarrow
size={16}
className={"text-nb-gray-300"}
/>
}
/>
</div>
</div>
</TabsContent>
</Tabs>
<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/routing-traffic-to-private-networks"
}
target={"_blank"}
>
Routing Peers
<ExternalLinkIcon size={12} />
</InlineLink>
</Paragraph>
</div>
<div className={"flex gap-3 w-full justify-end"}>
<ModalClose asChild={true}>
<Button variant={"secondary"}>Cancel</Button>
</ModalClose>
{tab == "router" && (
<Button variant={"primary"} onClick={() => setTab("settings")}>
Continue
</Button>
)}
{tab == "settings" && (
<Button
variant={"primary"}
disabled={
routingPeer == undefined && routingPeerGroups.length <= 0
}
onClick={router ? updateRouter : addRouter}
>
{router ? (
<>Save Changes</>
) : (
<>
<PlusCircle size={16} />
Add Routing Peer
</>
)}
</Button>
)}
</div>
</ModalFooter>
</ModalContent>
);
}

View File

@@ -0,0 +1,56 @@
import GroupBadge from "@components/ui/GroupBadge";
import PeerBadge from "@components/ui/PeerBadge";
import useFetchApi from "@utils/api";
import { ArrowRightIcon } from "lucide-react";
import * as React from "react";
import { useMemo } from "react";
import Skeleton from "react-loading-skeleton";
import { useGroups } from "@/contexts/GroupsProvider";
import { NetworkRouter } from "@/interfaces/Network";
import type { Peer } from "@/interfaces/Peer";
import PeerNameCell from "@/modules/peers/PeerNameCell";
type Props = {
router: NetworkRouter;
};
export const NetworkRoutingPeerName = ({ router }: Props) => {
const { groups, isLoading: isGroupsLoading } = useGroups();
const isRoutingPeer = router.peer != "";
const { data: peer, isLoading } = useFetchApi<Peer>(
"/peers/" + router.peer,
true,
false,
isRoutingPeer,
);
const routingPeerGroup = useMemo(() => {
return groups?.find((g) => {
if (router.peer_groups && router.peer_groups.length > 0) {
return g.id === router.peer_groups[0];
} else {
return false;
}
});
}, [groups, router.peer_groups]);
if (isLoading || isGroupsLoading) {
return <Skeleton height={36} />;
}
if (isRoutingPeer && peer) {
return <PeerNameCell peer={peer} />;
}
if (routingPeerGroup) {
return (
<>
<div className={"flex items-center gap-2 max-w-[295px] min-w-[295px]"}>
<GroupBadge group={routingPeerGroup} />
<ArrowRightIcon size={14} className={"shrink-0"} />
<PeerBadge> {routingPeerGroup.peers_count} Peer(s)</PeerBadge>
</div>
</>
);
}
};

View File

@@ -0,0 +1,70 @@
import Button from "@components/Button";
import Paragraph from "@components/Paragraph";
import SkeletonTable, {
SkeletonTableHeader,
} from "@components/skeletons/SkeletonTable";
import { usePortalElement } from "@hooks/usePortalElement";
import { IconCirclePlus } from "@tabler/icons-react";
import useFetchApi from "@utils/api";
import * as React from "react";
import { Suspense } from "react";
import { Network, NetworkRouter } from "@/interfaces/Network";
import { useNetworksContext } from "@/modules/networks/NetworkProvider";
import NetworkRoutingPeersTable from "@/modules/networks/routing-peers/NetworkRoutingPeersTable";
export const NetworkRoutingPeersSection = ({
network,
}: {
network: Network;
}) => {
const { data: routers, isLoading } = useFetchApi<NetworkRouter[]>(
`/networks/${network.id}/routers`,
);
const { ref: headingRef, portalTarget } =
usePortalElement<HTMLHeadingElement>();
const { openAddRoutingPeerModal } = useNetworksContext();
return (
<div className={"py-7 px-8"} id={"routing-peers"}>
<div className={"max-w-6xl"}>
<div className={"flex justify-between items-center"}>
<div>
<h2 ref={headingRef}>Routing Peers</h2>
<Paragraph>
Add and manage routing peers for this network.
</Paragraph>
</div>
<div className={"inline-flex gap-4 justify-end"}>
<div>
<Button
variant={"primary"}
onClick={() => openAddRoutingPeerModal(network)}
>
<IconCirclePlus size={16} />
Add Routing Peer
</Button>
</div>
</div>
</div>
<Suspense
fallback={
<div>
<SkeletonTableHeader className={"!p-0"} />
<div className={"mt-8 w-full"}>
<SkeletonTable withHeader={false} />
</div>
</div>
}
>
<NetworkRoutingPeersTable
isLoading={isLoading}
routers={routers}
headingTarget={portalTarget}
/>
</Suspense>
</div>
</div>
);
};

View File

@@ -0,0 +1,100 @@
import Card from "@components/Card";
import { DataTable } from "@components/table/DataTable";
import DataTableHeader from "@components/table/DataTableHeader";
import NoResults from "@components/ui/NoResults";
import { ColumnDef, SortingState } from "@tanstack/react-table";
import * as React from "react";
import { useState } from "react";
import PeerIcon from "@/assets/icons/PeerIcon";
import { NetworkRouter } from "@/interfaces/Network";
import { NetworkRoutingPeerName } from "@/modules/networks/routing-peers/NetworkRoutingPeerName";
import { RoutingPeersActionCell } from "@/modules/networks/routing-peers/RoutingPeersActionCell";
import { RoutingPeersMasqueradeCell } from "@/modules/networks/routing-peers/RoutingPeersMasqueradeCell";
import RouteMetricCell from "@/modules/routes/RouteMetricCell";
type Props = {
routers?: NetworkRouter[];
isLoading: boolean;
headingTarget?: HTMLHeadingElement | null;
};
const NetworkRouterColumns: ColumnDef<NetworkRouter>[] = [
{
id: "name",
accessorKey: "id",
header: ({ column }) => {
return <DataTableHeader column={column}>Peer</DataTableHeader>;
},
sortingFn: "text",
cell: ({ row }) => <NetworkRoutingPeerName router={row.original} />,
},
{
id: "metric",
accessorKey: "metric",
header: ({ column }) => {
return <DataTableHeader column={column}>Metric</DataTableHeader>;
},
cell: ({ row }) => <RouteMetricCell metric={row.original.metric} />,
},
{
id: "masquerade",
accessorKey: "masquerade",
header: ({ column }) => {
return <DataTableHeader column={column}>Masquerade</DataTableHeader>;
},
cell: ({ row }) => <RoutingPeersMasqueradeCell router={row.original} />,
},
{
id: "actions",
accessorKey: "id",
header: "",
cell: ({ row }) => {
return <RoutingPeersActionCell router={row.original} />;
},
},
];
export default function NetworkRoutingPeersTable({
routers,
isLoading,
headingTarget,
}: Props) {
const [sorting, setSorting] = useState<SortingState>([
{
id: "metric",
desc: false,
},
]);
return (
<DataTable
wrapperComponent={Card}
wrapperProps={{ className: "mt-6 w-full" }}
headingTarget={headingTarget}
sorting={sorting}
setSorting={setSorting}
minimal={true}
showSearchAndFilters={false}
inset={false}
tableClassName={"mt-0"}
text={"Peers"}
columns={NetworkRouterColumns}
keepStateInLocalStorage={false}
data={routers}
searchPlaceholder={"Search by name, IP, owner or group..."}
isLoading={isLoading}
getStartedCard={
<NoResults
className={"py-4"}
title={"This network has no routing peers"}
description={
"Add routing peers to this network to access resources inside this network."
}
icon={<PeerIcon size={20} className={"fill-nb-gray-300"} />}
/>
}
columnVisibility={{}}
paginationPaddingClassName={"px-0 pt-8"}
/>
);
}

View File

@@ -0,0 +1,40 @@
import Button from "@components/Button";
import { SquarePenIcon, Trash2 } from "lucide-react";
import * as React from "react";
import { NetworkRouter } from "@/interfaces/Network";
import { useNetworksContext } from "@/modules/networks/NetworkProvider";
type Props = {
router: NetworkRouter;
};
export const RoutingPeersActionCell = ({ router }: Props) => {
const { deleteRouter, network, openAddRoutingPeerModal } =
useNetworksContext();
return (
<div className={"flex justify-end pr-4"}>
<Button
variant={"default-outline"}
size={"sm"}
onClick={() => {
if (!network) return;
openAddRoutingPeerModal(network, router);
}}
>
<SquarePenIcon size={16} />
Edit
</Button>
<Button
variant={"danger-outline"}
size={"sm"}
onClick={() => {
if (!network) return;
deleteRouter(network, router);
}}
>
<Trash2 size={16} />
Remove
</Button>
</div>
);
};

View File

@@ -0,0 +1,48 @@
import { notify } from "@components/Notification";
import { ToggleSwitch } from "@components/ToggleSwitch";
import { useApiCall } from "@utils/api";
import * as React from "react";
import { useMemo } from "react";
import { useSWRConfig } from "swr";
import { NetworkRouter } from "@/interfaces/Network";
import { useNetworksContext } from "@/modules/networks/NetworkProvider";
type Props = {
router: NetworkRouter;
};
export const RoutingPeersMasqueradeCell = ({ router }: Props) => {
const { mutate } = useSWRConfig();
const { network } = useNetworksContext();
const update = useApiCall<NetworkRouter>(
`/networks/${network?.id}/routers/${router?.id}`,
).put;
const toggle = async (enabled: boolean) => {
notify({
title: "Network Routing Peer",
description: `Masquerade is now ${enabled ? "enabled" : "disabled"}`,
loadingMessage: "Updating masquerade...",
promise: update({
...router,
masquerade: enabled,
}).then(() => {
mutate(`/networks/${network?.id}/routers`);
}),
});
};
const isChecked = useMemo(() => {
return router.masquerade;
}, [router]);
return (
<div className={"flex"}>
<ToggleSwitch
checked={isChecked}
size={"small"}
onClick={() => toggle(!isChecked)}
/>
</div>
);
};

View File

@@ -0,0 +1,67 @@
import Button from "@components/Button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@components/DropdownMenu";
import { EyeIcon, MoreVertical, PencilLineIcon, Trash2 } from "lucide-react";
import { useRouter } from "next/navigation";
import * as React from "react";
import { Network } from "@/interfaces/Network";
import { useNetworksContext } from "@/modules/networks/NetworkProvider";
type Props = {
network: Network;
};
export default function NetworkActionCell({ network }: Props) {
const { deleteNetwork, openEditNetworkModal } = useNetworksContext();
const router = useRouter();
return (
<div className={"flex justify-end pr-4"}>
<DropdownMenu modal={false}>
<DropdownMenuTrigger
asChild={true}
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
}}
>
<Button variant={"secondary"} className={"!px-3"}>
<MoreVertical size={16} className={"shrink-0"} />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-auto" align="end">
<DropdownMenuItem
onClick={() => router.push(`/network?id=${network.id}`)}
>
<div className={"flex gap-3 items-center"}>
<EyeIcon size={14} className={"shrink-0"} />
View Details
</div>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => openEditNetworkModal(network)}>
<div className={"flex gap-3 items-center"}>
<PencilLineIcon size={14} className={"shrink-0"} />
Rename
</div>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => deleteNetwork(network)}
variant={"danger"}
>
<div className={"flex gap-3 items-center"}>
<Trash2 size={14} className={"shrink-0"} />
Delete
</div>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
}

View File

@@ -0,0 +1,25 @@
import { useRouter } from "next/navigation";
import { Network } from "@/interfaces/Network";
import { NetworkInformationSquare } from "@/modules/networks/misc/NetworkInformationSquare";
type Props = {
network: Network;
};
export default function NetworkNameCell({ network }: Readonly<Props>) {
const router = useRouter();
const isActive = !!(
network?.routing_peers_count && network.routing_peers_count > 0
);
return (
<div className={"flex gap-4 items-center min-w-[300px] max-w-[300px]"}>
<NetworkInformationSquare
name={network.name}
active={isActive}
onClick={() => router.push(`/network?id=${network.id}`)}
description={network.description}
/>
</div>
);
}

View File

@@ -0,0 +1,56 @@
import Badge from "@components/Badge";
import Button from "@components/Button";
import { PlusCircle, ShieldIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import * as React from "react";
import { Network } from "@/interfaces/Network";
import { useNetworksContext } from "@/modules/networks/NetworkProvider";
type Props = {
network: Network;
};
export const NetworkPolicyCell = ({ network }: Props) => {
const { openPolicyModal } = useNetworksContext();
const router = useRouter();
const hasPolicies = network?.policies && network?.policies?.length > 0;
const count = network?.policies?.length || 0;
return hasPolicies ? (
<div className={"flex gap-3"}>
<Badge
variant={"gray"}
useHover={true}
className={"cursor-pointer"}
onClick={() => router.push(`/network?id=${network.id}`)}
>
<ShieldIcon size={14} className={"text-green-500"} />
<div>
<span className={"font-medium text-xs"}>{count}</span>
</div>
</Badge>
<Button
size={"xs"}
variant={"secondary"}
className={"min-w-[130px]"}
onClick={() => openPolicyModal(network)}
>
<PlusCircle size={12} />
Add Policy
</Button>
</div>
) : (
<>
<Button
size={"xs"}
variant={"secondary"}
className={"min-w-[130px]"}
onClick={() => openPolicyModal(network)}
>
<PlusCircle size={12} />
Add Policy
</Button>
</>
);
};

View File

@@ -0,0 +1,56 @@
import Badge from "@components/Badge";
import Button from "@components/Button";
import { LayersIcon, PlusCircle } from "lucide-react";
import { useRouter } from "next/navigation";
import * as React from "react";
import { Network } from "@/interfaces/Network";
import { useNetworksContext } from "@/modules/networks/NetworkProvider";
type Props = {
network: Network;
};
export const NetworkResourceCell = ({ network }: Props) => {
const { openResourceModal } = useNetworksContext();
const router = useRouter();
const hasResources = network?.resources && network?.resources?.length > 0;
const count = network?.resources?.length || 0;
return hasResources ? (
<div className={"flex gap-3"}>
<Badge
variant={"gray"}
useHover={true}
className={"cursor-pointer"}
onClick={() => router.push(`/network?id=${network.id}`)}
>
<LayersIcon size={14} />
<div>
<span className={"font-medium text-xs"}>{count}</span>
</div>
</Badge>
<Button
size={"xs"}
variant={"secondary"}
className={"min-w-[130px]"}
onClick={() => openResourceModal(network)}
>
<PlusCircle size={12} />
Add Resource
</Button>
</div>
) : (
<>
<Button
size={"xs"}
variant={"secondary"}
className={"min-w-[130px]"}
onClick={() => openResourceModal(network)}
>
<PlusCircle size={12} />
Add Resource
</Button>
</>
);
};

View File

@@ -0,0 +1,108 @@
import Badge from "@components/Badge";
import Button from "@components/Button";
import FullTooltip from "@components/FullTooltip";
import { cn } from "@utils/helpers";
import { HelpCircle, PlusCircle } from "lucide-react";
import { useRouter } from "next/navigation";
import * as React from "react";
import { useMemo } from "react";
import { Network } from "@/interfaces/Network";
import { useNetworksContext } from "@/modules/networks/NetworkProvider";
type Props = {
network: Network;
};
export default function NetworkRoutingPeerCell({ network }: Props) {
const router = useRouter();
const disabledText = useMemo(
() => (
<>
High availability is currently{" "}
<span className={"text-yellow-400 font-medium"}>inactive</span> for this
network.
</>
),
[],
);
const enabledText = useMemo(
() => (
<>
High availability is{" "}
<span className={"text-green-500 font-medium"}>active</span> for this
network.
</>
),
[],
);
const { openAddRoutingPeerModal } = useNetworksContext();
const isHighlyAvailable = !!(
network?.routing_peers_count && network.routing_peers_count >= 2
);
const isActive = !!(
network?.routing_peers_count && network.routing_peers_count > 0
);
return (
<div className={"flex gap-3 items-center"}>
<FullTooltip
interactive={false}
content={
<div className={"max-w-xs text-xs"}>
<>
{isHighlyAvailable ? enabledText : disabledText}
{isHighlyAvailable ? (
<div className={"inline-flex mt-2"}>
You can add more routing peers to increase the availability of
this network.
</div>
) : (
<div className={"inline-flex mt-2"}>
Go ahead and add more routing peers or groups with routing
peers to enable high availability for this network.
</div>
)}
</>
</div>
}
>
{isActive && (
<Badge
variant={isHighlyAvailable ? "green" : "gray"}
className={cn(
"inline-flex gap-2 min-w-[110px] font-medium items-center justify-center min-h-[34px] cursor-pointer",
)}
onClick={() =>
router.push(`/network?id=${network.id}#routing-peers`)
}
useHover={true}
>
<>
<div
className={cn(
"h-2 w-2 rounded-full",
isHighlyAvailable ? "bg-green-500" : "bg-yellow-400",
)}
></div>
{network?.routing_peers_count && network.routing_peers_count}{" "}
Peer(s)
</>
<HelpCircle size={12} />
</Badge>
)}
</FullTooltip>
<Button
size={"xs"}
variant={"secondary"}
className={"min-w-[130px]"}
onClick={() => openAddRoutingPeerModal(network)}
>
<PlusCircle size={12} />
Add Routing Peer
</Button>
</div>
);
}

View File

@@ -0,0 +1,194 @@
import Button from "@components/Button";
import InlineLink from "@components/InlineLink";
import SquareIcon from "@components/SquareIcon";
import { DataTable } from "@components/table/DataTable";
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 { ColumnDef, SortingState } from "@tanstack/react-table";
import { cn } from "@utils/helpers";
import { ExternalLinkIcon, PlusCircle } from "lucide-react";
import { usePathname } from "next/navigation";
import React from "react";
import { useSWRConfig } from "swr";
import NetworkRoutesIcon from "@/assets/icons/NetworkRoutesIcon";
import { useDialog } from "@/contexts/DialogProvider";
import { useLocalStorage } from "@/hooks/useLocalStorage";
import { Network } from "@/interfaces/Network";
import {
NetworkProvider,
useNetworksContext,
} from "@/modules/networks/NetworkProvider";
import NetworkActionCell from "@/modules/networks/table/NetworkActionCell";
import NetworkNameCell from "@/modules/networks/table/NetworkNameCell";
import { NetworkPolicyCell } from "@/modules/networks/table/NetworkPolicyCell";
import { NetworkResourceCell } from "@/modules/networks/table/NetworkResourceCell";
import NetworkRoutingPeerCell from "@/modules/networks/table/NetworkRoutingPeerCell";
export const NetworkTableColumns: ColumnDef<Network>[] = [
{
accessorKey: "name",
header: ({ column }) => (
<DataTableHeader column={column}>Network</DataTableHeader>
),
sortingFn: "text",
cell: ({ row }) => <NetworkNameCell network={row.original} />,
},
{
accessorKey: "description",
},
{
accessorKey: "routers",
accessorFn: (network) => network?.routers?.length,
header: ({ column }) => {
return <DataTableHeader column={column}>Routing Peers</DataTableHeader>;
},
cell: ({ row }) => <NetworkRoutingPeerCell network={row.original} />,
},
{
accessorKey: "resources",
accessorFn: (network) => network?.resources?.length,
header: ({ column }) => {
return <DataTableHeader column={column}>Resources</DataTableHeader>;
},
cell: ({ row }) => <NetworkResourceCell network={row.original} />,
},
{
accessorKey: "policies",
accessorFn: (network) => network?.policies?.length,
header: ({ column }) => {
return <DataTableHeader column={column}>Policies</DataTableHeader>;
},
cell: ({ row }) => <NetworkPolicyCell network={row.original} />,
},
{
accessorKey: "id",
header: "",
cell: ({ row }) => <NetworkActionCell network={row.original} />,
},
];
type Props = {
data?: Network[];
isLoading: boolean;
headingTarget?: HTMLHeadingElement | null;
};
export default function NetworksTable({
isLoading,
data,
headingTarget,
}: Props) {
const { mutate } = useSWRConfig();
const path = usePathname();
// Default sorting state of the table
const [sorting, setSorting] = useLocalStorage<SortingState>(
"netbird-table-sort" + path,
[
{
id: "name",
desc: false,
},
],
);
const { confirm } = useDialog();
const showConfirm = async () => {
const choice = await confirm({
title: `Do you want to add a resource to 'Office Network' now?`,
description:
"Peers will be able to access your network resources once you add them.",
confirmText: "Add Resource",
cancelText: "Later",
type: "default",
});
if (!choice) return;
};
return (
<NetworkProvider>
<DataTable
headingTarget={headingTarget}
isLoading={isLoading}
text={"Networks"}
sorting={sorting}
setSorting={setSorting}
columns={NetworkTableColumns}
data={data}
searchPlaceholder={"Search by network name or description..."}
columnVisibility={{
description: false,
}}
getStartedCard={
<GetStartedTest
icon={
<SquareIcon
icon={
<NetworkRoutesIcon className={"fill-nb-gray-200"} size={20} />
}
color={"gray"}
size={"large"}
/>
}
title={"Create New Network"}
description={
"It looks like you don't have any networks. Access resources like LANs and VPC by adding a network."
}
button={
<div className={"gap-x-4 flex items-center justify-center"}>
<AddNetworkButton />
</div>
}
learnMore={
<>
Learn more about
<InlineLink
href={
"https://docs.netbird.io/how-to/routing-traffic-to-private-networks"
}
target={"_blank"}
>
Networks
<ExternalLinkIcon size={12} />
</InlineLink>
</>
}
/>
}
rightSide={() =>
data &&
data.length > 0 && (
<div className={cn("gap-x-4 ml-auto flex")}>
<AddNetworkButton />
</div>
)
}
>
{(table) => (
<>
<DataTableRowsPerPage table={table} disabled={data?.length == 0} />
<DataTableRefreshButton
isDisabled={data?.length == 0}
onClick={() => {
mutate("/networks").then();
}}
/>
</>
)}
</DataTable>
</NetworkProvider>
);
}
const AddNetworkButton = () => {
const { openCreateNetworkModal } = useNetworksContext();
return (
<Button variant={"primary"} onClick={openCreateNetworkModal}>
<PlusCircle size={16} />
Add Network
</Button>
);
};

View File

@@ -59,6 +59,9 @@ export const GroupedRouteTableColumns: ColumnDef<GroupedRoute>[] = [
return row.group_names?.map((name) => name).join(", ");
},
},
{
accessorKey: "routes_search",
},
{
id: "domains",
accessorFn: (row) => {
@@ -162,6 +165,7 @@ export default function NetworkRoutesTable({
group_names: false,
domains: false,
domain_search: false,
routes_search: false,
}}
renderExpandedRow={(row) => {
const data = cloneDeep(row);

View File

@@ -60,6 +60,7 @@ export default function useGroupedRoutes({ routes }: Props) {
const childDescriptions =
routes?.map((r) => r?.description).join(", ") || "";
const domainString = routes?.map((r) => r.domains?.join(", ")).join(", ");
const routesSearch = routes.map((r) => r?.network).join(", ");
results.push({
id,
@@ -73,6 +74,7 @@ export default function useGroupedRoutes({ routes }: Props) {
is_using_route_groups: !!groupPeerRoute,
description: groupPeerRoute ? groupPeerRoute?.description : undefined,
description_search: childDescriptions,
routes_search: routesSearch,
routes: routes,
group_names: allGroupNames,
});

View File

@@ -68,7 +68,6 @@ export default function RouteModal({ children, open, setOpen }: Props) {
const [newPolicy, setNewPolicy] = useState<Policy>();
const handleCreatePolicyPrompt = async (r: Route) => {
console.log(r);
if (!r?.access_control_groups) return;
const choice = await confirm({

View File

@@ -31,6 +31,9 @@ export const RouteTableColumns: ColumnDef<Route>[] = [
accessorKey: "domain_search",
sortingFn: "text",
},
{
accessorKey: "network",
},
{
id: "domains",
accessorFn: (row) => {
@@ -140,6 +143,7 @@ export default function RouteTable({ row }: Props) {
description: false,
domains: false,
domain_search: false,
network: false,
}}
setSorting={setSorting}
columns={RouteTableColumns}

View File

@@ -251,6 +251,10 @@ function RouteUpdateModalContent({ onSuccess, route, cell }: ModalProps) {
onSuccess && onSuccess(r);
mutate("/routes");
},
undefined,
{
remove_access_control_groups: !accessControlGroupIds,
},
);
};

View File

@@ -0,0 +1,88 @@
import Breadcrumbs from "@components/Breadcrumbs";
import FancyToggleSwitch from "@components/FancyToggleSwitch";
import { notify } from "@components/Notification";
import * as Tabs from "@radix-ui/react-tabs";
import { useApiCall } from "@utils/api";
import { GlobeIcon, NetworkIcon } from "lucide-react";
import React, { useState } from "react";
import { useSWRConfig } from "swr";
import SettingsIcon from "@/assets/icons/SettingsIcon";
import { Account } from "@/interfaces/Account";
type Props = {
account: Account;
};
export default function NetworkSettingsTab({ account }: Props) {
const { mutate } = useSWRConfig();
const saveRequest = useApiCall<Account>("/accounts/" + account.id);
const [routingPeerDNSSetting, setRoutingPeerDNSSetting] = useState(
account.settings.routing_peer_dns_resolution_enabled,
);
const toggleSetting = async (toggle: boolean) => {
notify({
title: "Save Network Settings",
description: "Network settings successfully saved.",
promise: saveRequest
.put({
id: account.id,
settings: {
...account.settings,
routing_peer_dns_resolution_enabled: toggle,
},
})
.then(() => {
setRoutingPeerDNSSetting(toggle);
mutate("/accounts");
}),
loadingMessage: "Saving the network settings...",
});
};
return (
<Tabs.Content value={"networks"}>
<div className={"p-default py-6 max-w-2xl"}>
<Breadcrumbs>
<Breadcrumbs.Item
href={"/settings"}
label={"Settings"}
icon={<SettingsIcon size={13} />}
/>
<Breadcrumbs.Item
href={"/settings#network"}
label={"Network"}
icon={<NetworkIcon size={14} />}
active
/>
</Breadcrumbs>
<div className={"flex items-start justify-between"}>
<h1>Networks</h1>
</div>
<div className={"flex flex-col gap-6 w-full mt-8"}>
<div>
<FancyToggleSwitch
value={routingPeerDNSSetting}
onChange={toggleSetting}
label={
<>
<GlobeIcon size={15} />
Enable DNS Wildcard Routing
</>
}
helpText={
<>
Allow routing using DNS wildcards. This requires NetBird
client v0.35 or higher. Changes will only take effect after
restarting the clients.
</>
}
/>
</div>
</div>
</div>
</Tabs.Content>
);
}

View File

@@ -96,6 +96,7 @@ export function ServiceUserModalContent({ onSuccess }: ModalProps) {
}
placeholder={"John Doe"}
value={name}
data-cy={"service-user-name"}
onChange={(e) => setName(e.target.value)}
/>
</div>
@@ -126,7 +127,12 @@ export function ServiceUserModalContent({ onSuccess }: ModalProps) {
<Button variant={"secondary"}>Cancel</Button>
</ModalClose>
<Button variant={"primary"} disabled={isDisabled} onClick={create}>
<Button
variant={"primary"}
disabled={isDisabled}
onClick={create}
data-cy={"create-service-user"}
>
<PlusCircle size={16} />
Create Service User
</Button>

View File

@@ -126,7 +126,11 @@ export default function ServiceUsersTable({
<div className={"flex flex-col"}>
<div>
<ServiceUserModal>
<Button variant={"primary"} className={""}>
<Button
variant={"primary"}
className={""}
data-cy={"open-service-user-modal"}
>
<PlusCircle size={16} />
Create Service User
</Button>
@@ -154,7 +158,11 @@ export default function ServiceUsersTable({
<>
{users && users?.length > 0 && (
<ServiceUserModal>
<Button variant={"primary"} className={"ml-auto"}>
<Button
variant={"primary"}
className={"ml-auto"}
data-cy={"open-service-user-modal"}
>
<PlusCircle size={16} />
Create Service User
</Button>

View File

@@ -104,6 +104,7 @@ export function UserRoleSelector({
disabled={disabled}
ref={inputRef}
className={"w-full"}
data-cy={"user-role-selector"}
>
<div className={"w-full flex justify-between items-center gap-2"}>
{selectedRole && (
@@ -160,6 +161,7 @@ export function UserRoleSelector({
<CommandItem
key={item.value}
value={item.value}
data-cy={"user-role-selector-item"}
className={"py-1 px-2"}
onSelect={() => toggle(item.value)}
onClick={(e) => e.preventDefault()}

View File

@@ -53,6 +53,7 @@ export default function UserActionCell({ user, serviceUser = false }: Props) {
variant={"danger-outline"}
size={"sm"}
onClick={openConfirm}
data-cy={"delete-user"}
disabled={disabled}
>
<Trash2 size={16} />

View File

@@ -1,6 +1,7 @@
import MultipleGroups from "@components/ui/MultipleGroups";
import { uniq } from "lodash";
import React, { useState } from "react";
import React, { useMemo } from "react";
import Skeleton from "react-loading-skeleton";
import { useGroups } from "@/contexts/GroupsProvider";
import { Group } from "@/interfaces/Group";
import { User } from "@/interfaces/User";
@@ -9,16 +10,23 @@ import EmptyRow from "@/modules/common-table-rows/EmptyRow";
type Props = {
user: User;
};
export default function UserGroupCell({ user }: Props) {
const { groups } = useGroups();
export default function UserGroupCell({ user }: Readonly<Props>) {
const { groups, isLoading } = useGroups();
const [allGroups] = useState(() => {
const allGroups = useMemo(() => {
if (isLoading) return [];
return uniq(user.auto_groups)
.map((group) => {
return groups?.find((g) => g.id == group);
})
.filter((g) => g != undefined) as Group[];
});
.map((group) => groups?.find((g) => g?.id == group))
.filter((g): g is Group => g !== undefined);
}, [user.auto_groups, groups, isLoading]);
if (isLoading)
return (
<div className={"flex gap-2"}>
<Skeleton height={34} width={90} />
<Skeleton height={34} width={45} />
</div>
);
return allGroups.length == 0 ? (
<EmptyRow />

View File

@@ -11,7 +11,10 @@ export default function UserNameCell({ user }: Props) {
const isCurrent = user.is_current;
return (
<div className={cn("flex gap-4 px-2 py-1 items-center")}>
<div
className={cn("flex gap-4 px-2 py-1 items-center")}
data-cy={"user-name-cell"}
>
<div
className={
"w-10 h-10 rounded-full relative flex items-center justify-center text-white uppercase text-md font-medium bg-nb-gray-900"

View File

@@ -9,7 +9,10 @@ export default function UserStatusCell({ user }: Props) {
const status = user.status;
return (
<div className={cn("flex gap-2.5 items-center text-nb-gray-300 text-sm")}>
<div
className={cn("flex gap-2.5 items-center text-nb-gray-300 text-sm")}
data-cy={"user-status-cell"}
>
<span
className={cn(
"h-2 w-2 rounded-full",

View File

@@ -21,6 +21,7 @@ const config = loadConfig();
type RequestOptions = {
signal?: AbortSignal;
origin?: string;
};
async function apiRequest<T>(
@@ -30,9 +31,9 @@ async function apiRequest<T>(
data?: any,
options?: RequestOptions,
) {
const origin = config.apiOrigin;
const origin = options?.origin ? options?.origin : config.apiOrigin + "/api";
const res = await oidcFetch(`${origin}/api${url}`, {
const res = await oidcFetch(`${origin}${url}`, {
method,
body: JSON.stringify(data),
signal: options?.signal,
@@ -108,6 +109,7 @@ export default function useFetchApi<T>(
ignoreError = false,
revalidate = true,
allowFetch = true,
options?: RequestOptions,
) {
const { fetch } = useNetBirdFetch(ignoreError);
const handleErrors = useApiErrorHandling(ignoreError);
@@ -116,7 +118,7 @@ export default function useFetchApi<T>(
url,
async (url) => {
if (!allowFetch) return;
return apiRequest<T>(fetch, "GET", url).catch((err) =>
return apiRequest<T>(fetch, "GET", url, undefined, options).catch((err) =>
handleErrors(err as ErrorResponse),
);
},
@@ -137,28 +139,56 @@ export default function useFetchApi<T>(
} as const;
}
export function useApiCall<T>(url: string, ignoreError = false) {
export function useApiCall<T>(
url: string,
ignoreError = false,
requestOptions?: RequestOptions,
) {
const { fetch } = useNetBirdFetch(ignoreError);
const handleErrors = useApiErrorHandling(ignoreError);
return {
post: async (data: any, suffix = "", options?: RequestOptions) => {
return apiRequest<T>(fetch, "POST", url + suffix, data, options)
return apiRequest<T>(
fetch,
"POST",
url + suffix,
data,
options || requestOptions,
)
.then((res) => Promise.resolve(res as T))
.catch((err) => handleErrors(err as ErrorResponse)) as Promise<T>;
},
put: async (data: any, suffix = "", options?: RequestOptions) => {
return apiRequest<T>(fetch, "PUT", url + suffix, data, options)
return apiRequest<T>(
fetch,
"PUT",
url + suffix,
data,
options || requestOptions,
)
.then((res) => Promise.resolve(res as T))
.catch((err) => handleErrors(err as ErrorResponse)) as Promise<T>;
},
del: async (data: any = "", suffix = "", options?: RequestOptions) => {
return apiRequest<T>(fetch, "DELETE", url + suffix, data, options)
return apiRequest<T>(
fetch,
"DELETE",
url + suffix,
data,
options || requestOptions,
)
.then((res) => Promise.resolve(res as T))
.catch((err) => handleErrors(err as ErrorResponse)) as Promise<T>;
},
get: async (suffix = "", options?: RequestOptions) => {
return apiRequest<T>(fetch, "GET", url + suffix, undefined, options)
return apiRequest<T>(
fetch,
"GET",
url + suffix,
undefined,
options || requestOptions,
)
.then((res) => Promise.resolve(res as T))
.catch((err) => handleErrors(err as ErrorResponse)) as Promise<T>;
},

View File

@@ -60,6 +60,29 @@ export const validator = {
return false;
}
},
isValidDomainWithWildcard: (domain: string) => {
// Basic checks
if (!domain || domain.length > 255 || domain.includes(" ")) {
return false;
}
// Handle wildcard
if (domain.includes("*")) {
if (!domain.startsWith("*.") || domain.indexOf("*", 1) !== -1) {
return false;
}
domain = "sub" + domain.slice(1); // Replace * with valid subdomain for testing
}
// Split and validate each part
const parts = domain.split(".");
if (parts.length < 2) {
return false;
}
const validPart = /^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$/;
return parts.every((part) => validPart.test(part));
},
isValidEmail: (email: string) => {
const regExp = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,63}$/i;
try {

View File

@@ -6,15 +6,15 @@ export const GRPC_API_ORIGIN = config.grpcApiOrigin;
export const getNetBirdUpCommand = () => {
let cmd = "netbird up";
if (GRPC_API_ORIGIN) {
cmd += " --management-url " + GRPC_API_ORIGIN
cmd += " --management-url " + GRPC_API_ORIGIN;
}
if (!isNetBirdHosted()) {
let admin_url = window.location.protocol + "//" + window.location.hostname
let admin_url = window.location.protocol + "//" + window.location.hostname;
if (window.location.port != "") {
admin_url += ":" + window.location.port
admin_url += ":" + window.location.port;
}
cmd += " --admin-url " + admin_url
};
cmd += " --admin-url " + admin_url;
}
return cmd;
};

View File

@@ -21,6 +21,7 @@ const config: Config = {
"700": "#474e57",
"800": "#3f444b",
"900": "#32363D",
"910": "#2b2f33",
"920": "#25282d",
"925": "#1e2123",
"930": "#25282c",