mirror of
https://github.com/netbirdio/dashboard.git
synced 2026-01-26 01:21:04 +00:00
Add new networks feature (#427)
This commit is contained in:
567
package-lock.json
generated
567
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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.
|
||||
|
||||
8
src/app/(dashboard)/network/layout.tsx
Normal file
8
src/app/(dashboard)/network/layout.tsx
Normal file
@@ -0,0 +1,8 @@
|
||||
import { globalMetaTitle } from "@utils/meta";
|
||||
import type { Metadata } from "next";
|
||||
import BlankLayout from "@/layouts/BlankLayout";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: `Network - Networks - ${globalMetaTitle}`,
|
||||
};
|
||||
export default BlankLayout;
|
||||
229
src/app/(dashboard)/network/page.tsx
Normal file
229
src/app/(dashboard)/network/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
8
src/app/(dashboard)/networks/layout.tsx
Normal file
8
src/app/(dashboard)/networks/layout.tsx
Normal file
@@ -0,0 +1,8 @@
|
||||
import { globalMetaTitle } from "@utils/meta";
|
||||
import type { Metadata } from "next";
|
||||
import BlankLayout from "@/layouts/BlankLayout";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: `Networks - ${globalMetaTitle}`,
|
||||
};
|
||||
export default BlankLayout;
|
||||
58
src/app/(dashboard)/networks/page.tsx
Normal file
58
src/app/(dashboard)/networks/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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 ",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}</>
|
||||
) : (
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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={
|
||||
|
||||
@@ -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}`,
|
||||
)
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
28
src/interfaces/Network.ts
Normal 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";
|
||||
}
|
||||
@@ -35,4 +35,5 @@ export interface GroupedRoute {
|
||||
description?: string;
|
||||
description_search?: string;
|
||||
domain_search?: string;
|
||||
routes_search?: string;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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({});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)} />
|
||||
|
||||
25
src/modules/groups/useGroupIdsToGroups.tsx
Normal file
25
src/modules/groups/useGroupIdsToGroups.tsx
Normal 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]);
|
||||
};
|
||||
164
src/modules/networks/NetworkModal.tsx
Normal file
164
src/modules/networks/NetworkModal.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
340
src/modules/networks/NetworkProvider.tsx
Normal file
340
src/modules/networks/NetworkProvider.tsx
Normal 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;
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
30
src/modules/networks/PolicyCell.tsx
Normal file
30
src/modules/networks/PolicyCell.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
82
src/modules/networks/misc/NetworkInformationSquare.tsx
Normal file
82
src/modules/networks/misc/NetworkInformationSquare.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
27
src/modules/networks/misc/NetworkNavigation.tsx
Normal file
27
src/modules/networks/misc/NetworkNavigation.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
23
src/modules/networks/misc/NetworkRoutesDeprecationInfo.tsx
Normal file
23
src/modules/networks/misc/NetworkRoutesDeprecationInfo.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
194
src/modules/networks/resources/NetworkResourceModal.tsx
Normal file
194
src/modules/networks/resources/NetworkResourceModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
39
src/modules/networks/resources/ResourceActionCell.tsx
Normal file
39
src/modules/networks/resources/ResourceActionCell.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
22
src/modules/networks/resources/ResourceAddressCell.tsx
Normal file
22
src/modules/networks/resources/ResourceAddressCell.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
15
src/modules/networks/resources/ResourceGroupCell.tsx
Normal file
15
src/modules/networks/resources/ResourceGroupCell.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
32
src/modules/networks/resources/ResourceNameCell.tsx
Normal file
32
src/modules/networks/resources/ResourceNameCell.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
101
src/modules/networks/resources/ResourcePolicyCell.tsx
Normal file
101
src/modules/networks/resources/ResourcePolicyCell.tsx
Normal 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>
|
||||
)
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
22
src/modules/networks/resources/ResourceTypeCell.tsx
Normal file
22
src/modules/networks/resources/ResourceTypeCell.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
68
src/modules/networks/resources/ResourcesSection.tsx
Normal file
68
src/modules/networks/resources/ResourcesSection.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
113
src/modules/networks/resources/ResourcesTable.tsx
Normal file
113
src/modules/networks/resources/ResourcesTable.tsx
Normal 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"}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
337
src/modules/networks/routing-peers/NetworkRoutingPeerModal.tsx
Normal file
337
src/modules/networks/routing-peers/NetworkRoutingPeerModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
100
src/modules/networks/routing-peers/NetworkRoutingPeersTable.tsx
Normal file
100
src/modules/networks/routing-peers/NetworkRoutingPeersTable.tsx
Normal 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"}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
67
src/modules/networks/table/NetworkActionCell.tsx
Normal file
67
src/modules/networks/table/NetworkActionCell.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
25
src/modules/networks/table/NetworkNameCell.tsx
Normal file
25
src/modules/networks/table/NetworkNameCell.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
56
src/modules/networks/table/NetworkPolicyCell.tsx
Normal file
56
src/modules/networks/table/NetworkPolicyCell.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
56
src/modules/networks/table/NetworkResourceCell.tsx
Normal file
56
src/modules/networks/table/NetworkResourceCell.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
108
src/modules/networks/table/NetworkRoutingPeerCell.tsx
Normal file
108
src/modules/networks/table/NetworkRoutingPeerCell.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
194
src/modules/networks/table/NetworksTable.tsx
Normal file
194
src/modules/networks/table/NetworksTable.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -251,6 +251,10 @@ function RouteUpdateModalContent({ onSuccess, route, cell }: ModalProps) {
|
||||
onSuccess && onSuccess(r);
|
||||
mutate("/routes");
|
||||
},
|
||||
undefined,
|
||||
{
|
||||
remove_access_control_groups: !accessControlGroupIds,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
88
src/modules/settings/NetworkSettingsTab.tsx
Normal file
88
src/modules/settings/NetworkSettingsTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()}
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>;
|
||||
},
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ const config: Config = {
|
||||
"700": "#474e57",
|
||||
"800": "#3f444b",
|
||||
"900": "#32363D",
|
||||
"910": "#2b2f33",
|
||||
"920": "#25282d",
|
||||
"925": "#1e2123",
|
||||
"930": "#25282c",
|
||||
|
||||
Reference in New Issue
Block a user