Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
61e11d3740 | ||
|
|
c8e3b50f1b | ||
|
|
25be69e7bb | ||
|
|
43e5d5cf53 | ||
|
|
18819d6fdf | ||
|
|
158804c1ac | ||
|
|
14d2d68819 | ||
|
|
40902b3629 | ||
|
|
fa9bcea4ab | ||
|
|
3ba7acdecf | ||
|
|
c7775ade8c |
914
package-lock.json
generated
914
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -19,6 +19,7 @@
|
||||
"@radix-ui/react-collapsible": "^1.0.3",
|
||||
"@radix-ui/react-dialog": "^1.0.5",
|
||||
"@radix-ui/react-dropdown-menu": "^2.0.6",
|
||||
"@radix-ui/react-hover-card": "^1.1.4",
|
||||
"@radix-ui/react-label": "^2.0.2",
|
||||
"@radix-ui/react-popover": "^1.0.7",
|
||||
"@radix-ui/react-radio-group": "^1.1.3",
|
||||
@@ -55,8 +56,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 +77,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>
|
||||
|
||||
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;
|
||||
61
src/app/(dashboard)/networks/page.tsx
Normal file
61
src/app/(dashboard)/networks/page.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
"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 internal resources in LANs and VPCs without
|
||||
installing NetBird on every machine.
|
||||
</Paragraph>
|
||||
<Paragraph>
|
||||
Learn more about
|
||||
<InlineLink
|
||||
href={"https://docs.netbird.io/how-to/networks"}
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -23,13 +23,13 @@ import Separator from "@components/Separator";
|
||||
import FullScreenLoading from "@components/ui/FullScreenLoading";
|
||||
import LoginExpiredBadge from "@components/ui/LoginExpiredBadge";
|
||||
import TextWithTooltip from "@components/ui/TextWithTooltip";
|
||||
import { getOperatingSystem } from "@hooks/useOperatingSystem";
|
||||
import useRedirect from "@hooks/useRedirect";
|
||||
import { IconCloudLock, IconInfoCircle } from "@tabler/icons-react";
|
||||
import useFetchApi from "@utils/api";
|
||||
import { cn } from "@utils/helpers";
|
||||
import dayjs from "dayjs";
|
||||
import { isEmpty, trim } from "lodash";
|
||||
import {
|
||||
Barcode,
|
||||
Cpu,
|
||||
FlagIcon,
|
||||
Globe,
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
NetworkIcon,
|
||||
PencilIcon,
|
||||
TerminalSquare,
|
||||
TimerResetIcon,
|
||||
} from "lucide-react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { toASCII } from "punycode";
|
||||
@@ -55,11 +56,11 @@ import PeerProvider, { usePeer } from "@/contexts/PeerProvider";
|
||||
import RoutesProvider from "@/contexts/RoutesProvider";
|
||||
import { useLoggedInUser } from "@/contexts/UsersProvider";
|
||||
import { useHasChanges } from "@/hooks/useHasChanges";
|
||||
import { OperatingSystem } from "@/interfaces/OperatingSystem";
|
||||
import type { Peer } from "@/interfaces/Peer";
|
||||
import PageContainer from "@/layouts/PageContainer";
|
||||
import useGroupHelper from "@/modules/groups/useGroupHelper";
|
||||
import { AccessiblePeersSection } from "@/modules/peer/AccessiblePeersSection";
|
||||
import { PeerExpirationToggle } from "@/modules/peer/PeerExpirationToggle";
|
||||
import { PeerNetworkRoutesSection } from "@/modules/peer/PeerNetworkRoutesSection";
|
||||
|
||||
export default function PeerPage() {
|
||||
@@ -69,9 +70,16 @@ export default function PeerPage() {
|
||||
|
||||
useRedirect("/peers", false, !peerId);
|
||||
|
||||
const peerKey = useMemo(() => {
|
||||
let id = peer?.id ?? "";
|
||||
let ssh = peer?.ssh_enabled ? "1" : "0";
|
||||
let expiration = peer?.login_expiration_enabled ? "1" : "0";
|
||||
return `${id}-${ssh}-${expiration}`;
|
||||
}, [peer]);
|
||||
|
||||
return peer && !isLoading ? (
|
||||
<PeerProvider peer={peer} key={peerId}>
|
||||
<PeerOverview />
|
||||
<PeerOverview key={peerKey} />
|
||||
</PeerProvider>
|
||||
) : (
|
||||
<FullScreenLoading />
|
||||
@@ -88,20 +96,15 @@ function PeerOverview() {
|
||||
const [loginExpiration, setLoginExpiration] = useState(
|
||||
peer.login_expiration_enabled,
|
||||
);
|
||||
const [inactivityExpiration, setInactivityExpiration] = useState(
|
||||
peer.inactivity_expiration_enabled,
|
||||
);
|
||||
const [selectedGroups, setSelectedGroups, { getAllGroupCalls }] =
|
||||
useGroupHelper({
|
||||
initial: peerGroups,
|
||||
peer,
|
||||
});
|
||||
|
||||
/**
|
||||
* Check the operating system of the peer, if it is linux, then show the routes table, otherwise hide it.
|
||||
*/
|
||||
const isLinux = useMemo(() => {
|
||||
const operatingSystem = getOperatingSystem(peer.os);
|
||||
return operatingSystem == OperatingSystem.LINUX;
|
||||
}, [peer.os]);
|
||||
|
||||
/**
|
||||
* Detect if there are changes in the peer information, if there are changes, then enable the save button.
|
||||
*/
|
||||
@@ -110,10 +113,16 @@ function PeerOverview() {
|
||||
ssh,
|
||||
selectedGroups,
|
||||
loginExpiration,
|
||||
inactivityExpiration,
|
||||
]);
|
||||
|
||||
const updatePeer = async () => {
|
||||
const updateRequest = update(name, ssh, loginExpiration);
|
||||
const updateRequest = update({
|
||||
name,
|
||||
ssh,
|
||||
loginExpiration,
|
||||
inactivityExpiration,
|
||||
});
|
||||
const groupCalls = getAllGroupCalls();
|
||||
const batchCall = groupCalls
|
||||
? [...groupCalls, updateRequest]
|
||||
@@ -124,13 +133,19 @@ function PeerOverview() {
|
||||
promise: Promise.all(batchCall).then(() => {
|
||||
mutate("/peers/" + peer.id);
|
||||
mutate("/groups");
|
||||
updateHasChangedRef([name, ssh, selectedGroups, loginExpiration]);
|
||||
updateHasChangedRef([
|
||||
name,
|
||||
ssh,
|
||||
selectedGroups,
|
||||
loginExpiration,
|
||||
inactivityExpiration,
|
||||
]);
|
||||
}),
|
||||
loadingMessage: "Saving the peer...",
|
||||
});
|
||||
};
|
||||
|
||||
const { isUser } = useLoggedInUser();
|
||||
const { isUser, isOwnerOrAdmin } = useLoggedInUser();
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
@@ -212,53 +227,43 @@ function PeerOverview() {
|
||||
<div className={"flex gap-10 w-full mt-5 max-w-6xl"}>
|
||||
<PeerInformationCard peer={peer} />
|
||||
|
||||
<div className={"flex flex-col gap-6 w-1/2"}>
|
||||
<FullTooltip
|
||||
content={
|
||||
<div
|
||||
className={
|
||||
"flex gap-2 items-center !text-nb-gray-300 text-xs"
|
||||
}
|
||||
>
|
||||
{!peer.user_id ? (
|
||||
<>
|
||||
<>
|
||||
<IconInfoCircle size={14} />
|
||||
<span>
|
||||
Login expiration is disabled for all peers added
|
||||
with an setup-key.
|
||||
</span>
|
||||
</>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<LockIcon size={14} />
|
||||
<span>
|
||||
{`You don't have the required permissions to update this
|
||||
setting.`}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
className={"w-full block"}
|
||||
disabled={!!peer.user_id && !isUser}
|
||||
>
|
||||
<FancyToggleSwitch
|
||||
disabled={!peer.user_id || isUser}
|
||||
<div className={"flex flex-col gap-6 w-1/2 transition-all"}>
|
||||
<div>
|
||||
<PeerExpirationToggle
|
||||
peer={peer}
|
||||
value={loginExpiration}
|
||||
onChange={setLoginExpiration}
|
||||
label={
|
||||
<>
|
||||
<IconCloudLock size={16} />
|
||||
Login Expiration
|
||||
</>
|
||||
}
|
||||
helpText={
|
||||
"Enable to require SSO login peers to re-authenticate when their login expires."
|
||||
}
|
||||
icon={<TimerResetIcon size={16} />}
|
||||
onChange={(state) => {
|
||||
setLoginExpiration(state);
|
||||
!state && setInactivityExpiration(false);
|
||||
}}
|
||||
/>
|
||||
</FullTooltip>
|
||||
{isOwnerOrAdmin && !!peer?.user_id && (
|
||||
<div
|
||||
className={cn(
|
||||
"border border-nb-gray-900 border-t-0 rounded-b-md bg-nb-gray-940 px-[1.28rem] pt-3 pb-5 flex flex-col gap-4 mx-[0.25rem]",
|
||||
!loginExpiration
|
||||
? "opacity-50 pointer-events-none"
|
||||
: "bg-nb-gray-930/80",
|
||||
)}
|
||||
>
|
||||
<PeerExpirationToggle
|
||||
peer={peer}
|
||||
variant={"blank"}
|
||||
value={inactivityExpiration}
|
||||
onChange={setInactivityExpiration}
|
||||
title={"Require login after disconnect"}
|
||||
description={
|
||||
"Enable to require authentication after users disconnect from management for 10 minutes."
|
||||
}
|
||||
className={
|
||||
!loginExpiration ? "opacity-40 pointer-events-none" : ""
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<FullTooltip
|
||||
content={
|
||||
<div
|
||||
@@ -297,29 +302,12 @@ function PeerOverview() {
|
||||
/>
|
||||
</FullTooltip>
|
||||
|
||||
<div>
|
||||
<Label>Assigned Groups</Label>
|
||||
<HelpText>
|
||||
Use groups to control what this peer can access.
|
||||
</HelpText>
|
||||
<FullTooltip
|
||||
content={
|
||||
<div
|
||||
className={
|
||||
"flex gap-2 items-center !text-nb-gray-300 text-xs"
|
||||
}
|
||||
>
|
||||
<LockIcon size={14} />
|
||||
<span>
|
||||
{`You don't have the required permissions to update this
|
||||
setting.`}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
interactive={false}
|
||||
className={"w-full block"}
|
||||
disabled={!isUser}
|
||||
>
|
||||
{!isUser && (
|
||||
<div>
|
||||
<Label>Assigned Groups</Label>
|
||||
<HelpText>
|
||||
Use groups to control what this peer can access.
|
||||
</HelpText>
|
||||
<PeerGroupSelector
|
||||
disabled={isUser}
|
||||
onChange={setSelectedGroups}
|
||||
@@ -327,13 +315,13 @@ function PeerOverview() {
|
||||
hideAllGroup={true}
|
||||
peer={peer}
|
||||
/>
|
||||
</FullTooltip>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLinux && !isUser ? (
|
||||
{!isUser ? (
|
||||
<>
|
||||
<Separator />
|
||||
<PeerNetworkRoutesSection peer={peer} />
|
||||
@@ -351,7 +339,7 @@ function PeerOverview() {
|
||||
);
|
||||
}
|
||||
|
||||
function PeerInformationCard({ peer }: { peer: Peer }) {
|
||||
function PeerInformationCard({ peer }: Readonly<{ peer: Peer }>) {
|
||||
const { isLoading, getRegionByPeer } = useCountries();
|
||||
|
||||
const countryText = useMemo(() => {
|
||||
@@ -387,14 +375,20 @@ function PeerInformationCard({ peer }: { peer: Peer }) {
|
||||
|
||||
<Card.ListItem
|
||||
copy
|
||||
copyText={"Domain name"}
|
||||
copyText={"DNS label"}
|
||||
label={
|
||||
<>
|
||||
<Globe size={16} />
|
||||
Domain Name
|
||||
</>
|
||||
}
|
||||
className={
|
||||
peer?.extra_dns_labels && peer.extra_dns_labels.length > 0
|
||||
? "items-start"
|
||||
: ""
|
||||
}
|
||||
value={peer.dns_label}
|
||||
extraText={peer?.extra_dns_labels}
|
||||
/>
|
||||
|
||||
<Card.ListItem
|
||||
@@ -446,6 +440,19 @@ function PeerInformationCard({ peer }: { peer: Peer }) {
|
||||
}
|
||||
value={peer.os}
|
||||
/>
|
||||
|
||||
{peer.serial_number && peer.serial_number !== "" && (
|
||||
<Card.ListItem
|
||||
label={
|
||||
<>
|
||||
<Barcode size={16} />
|
||||
Serial Number
|
||||
</>
|
||||
}
|
||||
value={peer.serial_number}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Card.ListItem
|
||||
label={
|
||||
<>
|
||||
@@ -492,7 +499,7 @@ interface ModalProps {
|
||||
peer: Peer;
|
||||
initialName: string;
|
||||
}
|
||||
function EditNameModal({ onSuccess, peer, initialName }: ModalProps) {
|
||||
function EditNameModal({ onSuccess, peer, initialName }: Readonly<ModalProps>) {
|
||||
const [name, setName] = useState(initialName);
|
||||
|
||||
const isDisabled = useMemo(() => {
|
||||
|
||||
@@ -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";
|
||||
@@ -38,6 +40,7 @@ export default function UserPage() {
|
||||
const { data: users, isLoading } = useFetchApi<User[]>(
|
||||
`/users?service_user=${isServiceUser}`,
|
||||
);
|
||||
const { isOwnerOrAdmin } = useLoggedInUser();
|
||||
|
||||
const user = useMemo(() => {
|
||||
return users?.find((u) => u.id === userId);
|
||||
@@ -45,25 +48,31 @@ export default function UserPage() {
|
||||
|
||||
useRedirect("/team/users", false, !userId);
|
||||
|
||||
return !isLoading && user ? (
|
||||
<UserOverview user={user} />
|
||||
) : (
|
||||
<FullScreenLoading />
|
||||
);
|
||||
const userGroups = useGroupIdsToGroups(user?.auto_groups);
|
||||
|
||||
if (!isOwnerOrAdmin && user && !isLoading) {
|
||||
return <UserOverview user={user} initialGroups={[]} />;
|
||||
}
|
||||
|
||||
if (isOwnerOrAdmin && user && !isLoading && userGroups) {
|
||||
return <UserOverview user={user} initialGroups={userGroups} />;
|
||||
}
|
||||
|
||||
return <FullScreenLoading />;
|
||||
}
|
||||
|
||||
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 +189,7 @@ function UserOverview({ user }: Props) {
|
||||
className={"w-full"}
|
||||
disabled={!hasChanges}
|
||||
onClick={save}
|
||||
data-cy={"save-changes"}
|
||||
>
|
||||
Save Changes
|
||||
</Button>
|
||||
@@ -190,7 +200,7 @@ function UserOverview({ user }: Props) {
|
||||
<div className={"flex gap-10 w-full mt-8 max-w-6xl items-start"}>
|
||||
<UserInformationCard user={user} />
|
||||
<div className={"flex flex-col gap-8 w-1/2 "}>
|
||||
{!user.is_service_user && (
|
||||
{!user.is_service_user && isOwnerOrAdmin && (
|
||||
<div>
|
||||
<Label>Auto-assigned groups</Label>
|
||||
<HelpText>
|
||||
@@ -201,6 +211,7 @@ function UserOverview({ user }: Props) {
|
||||
onChange={setSelectedGroups}
|
||||
values={selectedGroups}
|
||||
hideAllGroup={true}
|
||||
dataCy={"user-group-selector"}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -244,7 +255,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 +307,7 @@ function UserInformationCard({ user }: { user: User }) {
|
||||
)}
|
||||
|
||||
<Card.ListItem
|
||||
tooltip={false}
|
||||
label={
|
||||
<>
|
||||
<GalleryHorizontalEnd size={16} />
|
||||
@@ -306,6 +321,7 @@ function UserInformationCard({ user }: { user: User }) {
|
||||
<>
|
||||
{!user.is_current && user.role != Role.Owner && (
|
||||
<Card.ListItem
|
||||
tooltip={false}
|
||||
label={
|
||||
<>
|
||||
<Ban 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>
|
||||
|
||||
@@ -12,7 +12,7 @@ export default function CircleIcon({
|
||||
size = 11,
|
||||
inactiveDot = "gray",
|
||||
className,
|
||||
}: Props) {
|
||||
}: Readonly<Props>) {
|
||||
return (
|
||||
<span
|
||||
style={{ width: size + "px", height: size + "px" }}
|
||||
|
||||
39
src/assets/icons/EntraIcon.tsx
Normal file
39
src/assets/icons/EntraIcon.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import { iconProperties, IconProps } from "@/assets/icons/IconProperties";
|
||||
|
||||
export default function EntraIcon(props: Readonly<IconProps>) {
|
||||
return (
|
||||
<svg
|
||||
width="231"
|
||||
height="231"
|
||||
viewBox="0 0 231 231"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...iconProperties(props)}
|
||||
>
|
||||
<path
|
||||
d="M48.7923 180.077C53.7717 183.183 62.0492 186.635 70.8015 186.635C78.771 186.635 86.1758 184.325 92.3102 180.385C92.3102 180.385 92.323 180.385 92.3358 180.373L115.5 165.896V218.167C111.83 218.167 108.134 217.166 104.925 215.164L48.7923 180.077Z"
|
||||
fill="#225086"
|
||||
/>
|
||||
<path
|
||||
d="M100.78 19.3398L4.53017 127.91C-2.90033 136.303 -0.962501 148.982 8.67533 155.001C8.67533 155.001 44.3007 177.267 48.7923 180.077C53.7717 183.183 62.0492 186.635 70.8015 186.635C78.771 186.635 86.1758 184.325 92.3102 180.385C92.3102 180.385 92.323 180.385 92.3358 180.373L115.5 165.896L59.4953 130.887L115.513 67.6958V12.8333C110.072 12.8333 104.63 15.0022 100.78 19.3398Z"
|
||||
fill="#66DDFF"
|
||||
/>
|
||||
<path
|
||||
d="M59.4953 130.887L60.1627 131.298L115.5 165.896H115.513V67.7087L115.5 67.6958L59.4953 130.887Z"
|
||||
fill="#CBF8FF"
|
||||
/>
|
||||
<path
|
||||
d="M222.325 155.001C231.963 148.982 233.9 136.303 226.47 127.91L163.317 56.672C158.222 54.2978 152.511 52.9375 146.467 52.9375C134.596 52.9375 123.983 58.058 116.925 66.1045L115.526 67.683L171.53 130.874L115.513 165.884V218.154C119.196 218.154 122.866 217.153 126.075 215.151L222.325 154.988V155.001Z"
|
||||
fill="#074793"
|
||||
/>
|
||||
<path
|
||||
d="M115.513 12.8333V67.6958L116.912 66.1173C123.97 58.0708 134.583 52.9503 146.454 52.9503C152.511 52.9503 158.209 54.3235 163.304 56.6848L130.207 19.3527C126.37 15.015 120.929 12.8462 115.5 12.8462L115.513 12.8333Z"
|
||||
fill="#0294E4"
|
||||
/>
|
||||
<path
|
||||
d="M171.518 130.887L115.513 67.7087V165.884L171.518 130.887Z"
|
||||
fill="#96BCC2"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
31
src/assets/icons/GoogleIcon.tsx
Normal file
31
src/assets/icons/GoogleIcon.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { iconProperties, IconProps } from "@/assets/icons/IconProperties";
|
||||
|
||||
export default function GoogleIcon(props: Readonly<IconProps>) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
{...iconProperties(props)}
|
||||
>
|
||||
<path
|
||||
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
|
||||
fill="#4285F4"
|
||||
/>
|
||||
<path
|
||||
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
|
||||
fill="#34A853"
|
||||
/>
|
||||
<path
|
||||
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
|
||||
fill="#FBBC05"
|
||||
/>
|
||||
<path
|
||||
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
|
||||
fill="#EA4335"
|
||||
/>
|
||||
<path d="M1 1h22v22H1z" fill="none" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
36
src/assets/icons/JWTIcon.tsx
Normal file
36
src/assets/icons/JWTIcon.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { iconProperties, IconProps } from "@/assets/icons/IconProperties";
|
||||
|
||||
export default function JWTIcon(props: Readonly<IconProps>) {
|
||||
return (
|
||||
<svg
|
||||
height="2500"
|
||||
viewBox=".4 .3 99.7 100"
|
||||
width="2500"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...iconProperties(props)}
|
||||
>
|
||||
<g fill="none">
|
||||
<path
|
||||
d="m57.8 27.2-.1-26.9h-15l.1 26.9 7.5 10.3zm-15 46.1v27h15v-27l-7.5-10.3z"
|
||||
fill="#fff"
|
||||
/>
|
||||
<path
|
||||
d="m57.8 73.3 15.8 21.8 12.1-8.8-15.8-21.8-12.1-3.9zm-15-46.1-15.9-21.8-12.1 8.8 15.8 21.8 12.2 3.9z"
|
||||
fill="#00f2e6"
|
||||
/>
|
||||
<path
|
||||
d="m30.6 36-25.6-8.3-4.6 14.2 25.6 8.4 12.1-4zm31.8 18.2 7.5 10.3 25.6 8.3 4.6-14.2-25.6-8.3z"
|
||||
fill="#00b9f1"
|
||||
/>
|
||||
<path
|
||||
d="m74.5 50.3 25.6-8.4-4.6-14.2-25.6 8.3-7.5 10.3zm-48.5 0-25.6 8.3 4.6 14.2 25.6-8.3 7.5-10.3z"
|
||||
fill="#d63aff"
|
||||
/>
|
||||
<path
|
||||
d="m30.6 64.5-15.8 21.8 12.1 8.8 15.9-21.8v-12.7zm39.3-28.5 15.8-21.8-12.1-8.8-15.8 21.8v12.7z"
|
||||
fill="#fb015b"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
18
src/assets/icons/OktaIcon.tsx
Normal file
18
src/assets/icons/OktaIcon.tsx
Normal file
File diff suppressed because one or more lines are too long
@@ -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 ",
|
||||
|
||||
@@ -7,6 +7,7 @@ import React from "react";
|
||||
interface Props extends React.HTMLAttributes<HTMLDivElement> {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
function Card({ children, className, ...props }: Props) {
|
||||
return (
|
||||
<div
|
||||
@@ -32,6 +33,7 @@ type CardListItemProps = {
|
||||
copy?: boolean;
|
||||
copyText?: string;
|
||||
tooltip?: boolean;
|
||||
extraText?: string[];
|
||||
};
|
||||
|
||||
function CardListItem({
|
||||
@@ -41,9 +43,8 @@ function CardListItem({
|
||||
copy = false,
|
||||
copyText,
|
||||
tooltip = true,
|
||||
extraText = [],
|
||||
}: CardListItemProps) {
|
||||
const [, copyToClipBoard] = useCopyToClipboard(value as string);
|
||||
|
||||
return (
|
||||
<li
|
||||
className={cn(
|
||||
@@ -52,29 +53,68 @@ function CardListItem({
|
||||
)}
|
||||
>
|
||||
<div className={"flex gap-2.5 items-center text-sm"}>{label}</div>
|
||||
<div
|
||||
className={cn(
|
||||
"text-right text-nb-gray-400 text-sm flex items-center gap-2",
|
||||
copy && "cursor-pointer hover:text-nb-gray-300 transition-all",
|
||||
)}
|
||||
onClick={() =>
|
||||
copy &&
|
||||
copyToClipBoard(
|
||||
`${copyText ? copyText : label} has been copied to clipboard.`,
|
||||
)
|
||||
}
|
||||
>
|
||||
{tooltip ? (
|
||||
<TextWithTooltip text={value as string} maxChars={40} />
|
||||
) : (
|
||||
value
|
||||
)}
|
||||
{copy && <Copy size={13} className={"shrink-0"} />}
|
||||
<div className={"flex flex-col gap-2"}>
|
||||
<CardTextItem
|
||||
label={label}
|
||||
value={value}
|
||||
copy={copy}
|
||||
copyText={copyText}
|
||||
tooltip={tooltip}
|
||||
/>
|
||||
{extraText?.map((extraLabel, index) => (
|
||||
<CardTextItem
|
||||
key={index}
|
||||
label={label}
|
||||
value={extraLabel}
|
||||
copy={copy}
|
||||
copyText={copyText}
|
||||
tooltip={tooltip}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
type CardTextItemProps = {
|
||||
label: React.ReactNode;
|
||||
value: React.ReactNode;
|
||||
copy?: boolean;
|
||||
copyText?: string;
|
||||
tooltip?: boolean;
|
||||
};
|
||||
|
||||
const CardTextItem = ({
|
||||
label,
|
||||
value,
|
||||
copy = false,
|
||||
copyText,
|
||||
tooltip = true,
|
||||
}: CardTextItemProps) => {
|
||||
const [, copyToClipBoard] = useCopyToClipboard(value as string);
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"text-right text-nb-gray-400 text-sm flex items-center gap-2",
|
||||
copy && "cursor-pointer hover:text-nb-gray-300 transition-all",
|
||||
)}
|
||||
onClick={() =>
|
||||
copy &&
|
||||
copyToClipBoard(
|
||||
`${copyText ? copyText : label} has been copied to clipboard.`,
|
||||
)
|
||||
}
|
||||
>
|
||||
{tooltip ? (
|
||||
<TextWithTooltip text={value as string} maxChars={40} />
|
||||
) : (
|
||||
value
|
||||
)}
|
||||
{copy && <Copy size={13} className={"shrink-0"} />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Card.List = CardList;
|
||||
Card.ListItem = CardListItem;
|
||||
|
||||
|
||||
@@ -6,9 +6,18 @@ import useCopyToClipboard from "@/hooks/useCopyToClipboard";
|
||||
type Props = {
|
||||
children: React.ReactNode;
|
||||
message?: string;
|
||||
iconAlignment?: "left" | "right";
|
||||
className?: string;
|
||||
alwaysShowIcon?: boolean;
|
||||
};
|
||||
|
||||
export default function CopyToClipboardText({ children, message }: Props) {
|
||||
export default function CopyToClipboardText({
|
||||
children,
|
||||
message,
|
||||
iconAlignment = "right",
|
||||
className,
|
||||
alwaysShowIcon = false,
|
||||
}: Props) {
|
||||
const [wrapper, copyToClipboard, copied] = useCopyToClipboard();
|
||||
|
||||
return (
|
||||
@@ -16,6 +25,7 @@ export default function CopyToClipboardText({ children, message }: Props) {
|
||||
className={cn(
|
||||
"flex gap-2 items-center group cursor-pointer transition-all hover:underline underline-offset-4 decoration-dashed decoration-nb-gray-600",
|
||||
!copied && "hover:opacity-90",
|
||||
className,
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
@@ -28,17 +38,21 @@ export default function CopyToClipboardText({ children, message }: Props) {
|
||||
|
||||
{copied ? (
|
||||
<CheckIcon
|
||||
className={
|
||||
"text-nb-gray-100 opacity-0 group-hover:opacity-100 shrink-0"
|
||||
}
|
||||
size={12}
|
||||
className={cn(
|
||||
"text-nb-gray-100 group-hover:opacity-100 shrink-0",
|
||||
iconAlignment === "left" ? "order-first" : "order-last",
|
||||
!alwaysShowIcon && "opacity-0",
|
||||
)}
|
||||
size={11}
|
||||
/>
|
||||
) : (
|
||||
<CopyIcon
|
||||
className={
|
||||
"text-nb-gray-100 opacity-0 group-hover:opacity-100 shrink-0"
|
||||
}
|
||||
size={12}
|
||||
className={cn(
|
||||
"text-nb-gray-100 group-hover:opacity-100 shrink-0",
|
||||
iconAlignment === "left" ? "order-first" : "order-last",
|
||||
!alwaysShowIcon && "opacity-0",
|
||||
)}
|
||||
size={11}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import { cn } from "@utils/helpers";
|
||||
import * as React from "react";
|
||||
|
||||
type Props = {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export const DropdownInfoText = ({ children }: Props) => {
|
||||
export const DropdownInfoText = ({ children, className }: Props) => {
|
||||
return (
|
||||
<div className={"text-center pt-2 mb-6 text-nb-gray-400"}>{children}</div>
|
||||
<div className={cn("text-center pt-2 mb-6 text-nb-gray-400", className)}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -2,16 +2,51 @@ import HelpText from "@components/HelpText";
|
||||
import { Label } from "@components/Label";
|
||||
import { ToggleSwitch } from "@components/ToggleSwitch";
|
||||
import { cn } from "@utils/helpers";
|
||||
import { cva, VariantProps } from "class-variance-authority";
|
||||
import React from "react";
|
||||
|
||||
type Props = {
|
||||
export const fancyToggleSwitchVariants = cva([], {
|
||||
variants: {
|
||||
variant: {
|
||||
default: ["px-6 py-4 border rounded-md"],
|
||||
blank: null,
|
||||
},
|
||||
state: {
|
||||
true: null,
|
||||
false: null,
|
||||
},
|
||||
},
|
||||
compoundVariants: [
|
||||
{
|
||||
variant: "default",
|
||||
state: true,
|
||||
className: ["border-nb-gray-800 bg-nb-gray-900/70"],
|
||||
},
|
||||
{
|
||||
variant: "default",
|
||||
state: false,
|
||||
className: [
|
||||
"border-nb-gray-910 bg-nb-gray-900/30 hover:bg-nb-gray-900/40",
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
export type FancyToggleSwitchVariants = VariantProps<
|
||||
typeof fancyToggleSwitchVariants
|
||||
>;
|
||||
|
||||
interface Props extends FancyToggleSwitchVariants {
|
||||
value: boolean;
|
||||
onChange: (value: boolean) => void;
|
||||
helpText?: React.ReactNode;
|
||||
label?: React.ReactNode;
|
||||
children?: React.ReactNode;
|
||||
disabled?: boolean;
|
||||
};
|
||||
dataCy?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function FancyToggleSwitch({
|
||||
value,
|
||||
onChange,
|
||||
@@ -19,28 +54,49 @@ export default function FancyToggleSwitch({
|
||||
label,
|
||||
children,
|
||||
disabled = false,
|
||||
}: Props) {
|
||||
dataCy,
|
||||
className,
|
||||
variant = "default",
|
||||
}: Readonly<Props>) {
|
||||
const handleToggle = () => {
|
||||
if (disabled) return;
|
||||
onChange(!value);
|
||||
};
|
||||
|
||||
const handleKeyDown = (event: React.KeyboardEvent) => {
|
||||
if (disabled) return;
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
handleToggle();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={() => {
|
||||
if (disabled) return;
|
||||
onChange(!value);
|
||||
}}
|
||||
onClick={handleToggle}
|
||||
onKeyDown={handleKeyDown}
|
||||
tabIndex={-1}
|
||||
role={"switch"}
|
||||
aria-checked={value}
|
||||
className={cn(
|
||||
"px-5 py-3.5 border rounded-md cursor-pointer transition-all duration-300 relative z-[1]",
|
||||
value
|
||||
? "border-nb-gray-800 bg-nb-gray-900/70"
|
||||
: "border-nb-gray-800 bg-nb-gray-900/30 hover:bg-nb-gray-900/40",
|
||||
"cursor-pointer transition-all duration-300 relative z-[1]",
|
||||
"inline-block text-left w-full",
|
||||
disabled && "opacity-50 pointer-events-none",
|
||||
fancyToggleSwitchVariants({ variant, state: value }),
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className={"flex justify-between gap-10 "}>
|
||||
<div className={"flex justify-between gap-10"}>
|
||||
<div className={"max-w-sm"}>
|
||||
<Label>{label}</Label>
|
||||
<HelpText margin={false}>{helpText}</HelpText>
|
||||
</div>
|
||||
<div className={"mt-2"}>
|
||||
<ToggleSwitch checked={value} onCheckedChange={onChange} />
|
||||
<div className={"mt-2 pr-1"}>
|
||||
<ToggleSwitch
|
||||
checked={value}
|
||||
onCheckedChange={onChange}
|
||||
dataCy={dataCy}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>{children && value ? children : null}</div>
|
||||
|
||||
@@ -22,6 +22,8 @@ type Props = {
|
||||
keepOpen?: boolean;
|
||||
customOpen?: boolean;
|
||||
customOnOpenChange?: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
delayDuration?: number;
|
||||
skipDelayDuration?: number;
|
||||
} & TooltipProps;
|
||||
export default function FullTooltip({
|
||||
children,
|
||||
@@ -37,6 +39,8 @@ export default function FullTooltip({
|
||||
keepOpen = false,
|
||||
customOpen,
|
||||
customOnOpenChange,
|
||||
delayDuration = 1,
|
||||
skipDelayDuration = 300,
|
||||
}: Props) {
|
||||
const [open, setOpen] = useState(!!keepOpen);
|
||||
|
||||
@@ -46,9 +50,13 @@ export default function FullTooltip({
|
||||
};
|
||||
|
||||
return !disabled ? (
|
||||
<TooltipProvider disableHoverableContent={!interactive}>
|
||||
<TooltipProvider
|
||||
disableHoverableContent={!interactive}
|
||||
delayDuration={delayDuration}
|
||||
skipDelayDuration={skipDelayDuration}
|
||||
>
|
||||
<Tooltip
|
||||
delayDuration={1}
|
||||
delayDuration={delayDuration}
|
||||
open={customOpen || open}
|
||||
onOpenChange={customOnOpenChange || handleOpen}
|
||||
>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,29 +1,46 @@
|
||||
import Badge from "@components/Badge";
|
||||
import { Checkbox } from "@components/Checkbox";
|
||||
import { CommandItem } from "@components/Command";
|
||||
import { DropdownInfoText } from "@components/DropdownInfoText";
|
||||
import FullTooltip from "@components/FullTooltip";
|
||||
import InlineLink from "@components/InlineLink";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@components/Popover";
|
||||
import { Radio, RadioItem } from "@components/Radio";
|
||||
import { ScrollArea } from "@components/ScrollArea";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@components/Tabs";
|
||||
import { AccessControlGroupCount } from "@components/ui/AccessControlGroupCount";
|
||||
import GroupBadge from "@components/ui/GroupBadge";
|
||||
import GroupBadgeWithEditPeers from "@components/ui/GroupBadgeWithEditPeers";
|
||||
import ResourceBadge from "@components/ui/ResourceBadge";
|
||||
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,
|
||||
Layers3Icon,
|
||||
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 Skeleton from "react-loading-skeleton";
|
||||
import { useGroups } from "@/contexts/GroupsProvider";
|
||||
import { useElementSize } from "@/hooks/useElementSize";
|
||||
import type { Group, GroupPeer } from "@/interfaces/Group";
|
||||
import type { Group, GroupPeer, GroupResource } from "@/interfaces/Group";
|
||||
import { NetworkResource } from "@/interfaces/Network";
|
||||
import type { Peer } from "@/interfaces/Peer";
|
||||
import { PolicyRuleResource } from "@/interfaces/Policy";
|
||||
|
||||
interface MultiSelectProps {
|
||||
values: Group[];
|
||||
@@ -39,6 +56,11 @@ interface MultiSelectProps {
|
||||
showRoutes?: boolean;
|
||||
disabledGroups?: Group[];
|
||||
dataCy?: string;
|
||||
showResourceCounter?: boolean;
|
||||
showResources?: boolean;
|
||||
resource?: PolicyRuleResource;
|
||||
onResourceChange?: (resource?: PolicyRuleResource) => void;
|
||||
placeholder?: string;
|
||||
}
|
||||
export function PeerGroupSelector({
|
||||
onChange,
|
||||
@@ -54,12 +76,20 @@ export function PeerGroupSelector({
|
||||
showRoutes = false,
|
||||
disabledGroups,
|
||||
dataCy = "group-selector-dropdown",
|
||||
showResourceCounter = true,
|
||||
showResources = false,
|
||||
resource,
|
||||
onResourceChange,
|
||||
placeholder = "Add or select group(s)...",
|
||||
}: Readonly<MultiSelectProps>) {
|
||||
const { groups, dropdownOptions, setDropdownOptions, addDropdownOptions } =
|
||||
useGroups();
|
||||
const searchRef = React.useRef<HTMLInputElement>(null);
|
||||
const [inputRef, { width }] = useElementSize<HTMLButtonElement>();
|
||||
const [search, setSearch] = useState("");
|
||||
const { data: resources, isLoading } = useFetchApi<NetworkResource[]>(
|
||||
"/networks/resources",
|
||||
);
|
||||
|
||||
// Update dropdown options when groups change
|
||||
useEffect(() => {
|
||||
@@ -91,23 +121,40 @@ export function PeerGroupSelector({
|
||||
|
||||
// Add group to the groupOptions if it does not exist
|
||||
const selectGroup = (name: string) => {
|
||||
onResourceChange?.(undefined);
|
||||
const group = groups?.find((group) => group.name == name);
|
||||
const option = dropdownOptions.find((option) => option.name == name);
|
||||
const groupPeers: GroupPeer[] | undefined =
|
||||
(group?.peers as GroupPeer[]) || [];
|
||||
const groupResources: GroupResource[] | undefined =
|
||||
(group?.resources as GroupResource[]) || [];
|
||||
|
||||
if (peer) groupPeers?.push({ id: peer?.id as string, name: peer?.name });
|
||||
|
||||
if (!group && !option) {
|
||||
addDropdownOptions([{ name: name, peers: groupPeers }]);
|
||||
addDropdownOptions([
|
||||
{ name: name, peers: groupPeers, resources: groupResources },
|
||||
]);
|
||||
}
|
||||
|
||||
if (max == 1 && values.length == 1) {
|
||||
onChange([{ name: name, id: group?.id, peers: groupPeers }]);
|
||||
onChange([
|
||||
{
|
||||
name: name,
|
||||
id: group?.id,
|
||||
peers: groupPeers,
|
||||
resources: groupResources,
|
||||
},
|
||||
]);
|
||||
} else {
|
||||
onChange((previous) => [
|
||||
...previous,
|
||||
{ name: name, id: group?.id, peers: groupPeers },
|
||||
{
|
||||
name: name,
|
||||
id: group?.id,
|
||||
peers: groupPeers,
|
||||
resources: groupResources,
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -142,6 +189,8 @@ export function PeerGroupSelector({
|
||||
|
||||
const [slice, setSlice] = useState(10);
|
||||
|
||||
const [tab, setTab] = useState("groups");
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setTimeout(() => {
|
||||
@@ -164,16 +213,41 @@ export function PeerGroupSelector({
|
||||
open,
|
||||
);
|
||||
|
||||
// Reset the search input when switching tabs
|
||||
useEffect(() => {
|
||||
setSearch("");
|
||||
setTimeout(() => {
|
||||
searchRef.current?.focus();
|
||||
}, 0);
|
||||
}, [tab]);
|
||||
|
||||
const searchPlaceholder =
|
||||
tab === "groups"
|
||||
? 'Search groups or add new group by pressing "Enter"...'
|
||||
: "Search resource...";
|
||||
|
||||
const selectResource = (resource?: NetworkResource) => {
|
||||
onResourceChange?.(
|
||||
resource
|
||||
? ({
|
||||
id: resource?.id,
|
||||
type: resource?.type,
|
||||
} as PolicyRuleResource)
|
||||
: undefined,
|
||||
);
|
||||
onChange([]);
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={(isOpen) => {
|
||||
setOpen(isOpen);
|
||||
if (!isOpen && search.length > 0) {
|
||||
setTimeout(() => {
|
||||
setSearch("");
|
||||
}, 100);
|
||||
}, 200);
|
||||
}
|
||||
setOpen(isOpen);
|
||||
}}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
@@ -193,6 +267,18 @@ export function PeerGroupSelector({
|
||||
"flex items-center gap-2 border-nb-gray-700 flex-wrap h-full"
|
||||
}
|
||||
>
|
||||
{resource && showResources && (
|
||||
<ResourceBadge
|
||||
className={"py-[3px]"}
|
||||
resource={resources?.find((r) => r.id === resource.id)}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
selectResource();
|
||||
}}
|
||||
showX={true}
|
||||
/>
|
||||
)}
|
||||
{values.map((group) => {
|
||||
return (
|
||||
<div
|
||||
@@ -236,12 +322,12 @@ export function PeerGroupSelector({
|
||||
);
|
||||
})}
|
||||
|
||||
{values.length == 0 && (
|
||||
<span className={"pl-1"}>Add or select group(s)...</span>
|
||||
{values.length == 0 && !resource && (
|
||||
<span className={"pl-1"}>{placeholder}</span>
|
||||
)}
|
||||
</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"}
|
||||
@@ -250,7 +336,7 @@ export function PeerGroupSelector({
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-full p-0 shadow-sm shadow-nb-gray-950"
|
||||
className="w-full p-0 shadow-sm shadow-nb-gray-950"
|
||||
style={{
|
||||
width: popoverWidth === "auto" ? width : popoverWidth,
|
||||
}}
|
||||
@@ -281,9 +367,7 @@ export function PeerGroupSelector({
|
||||
ref={searchRef}
|
||||
value={search}
|
||||
onValueChange={setSearch}
|
||||
placeholder={
|
||||
'Search groups or add new group by pressing "Enter"...'
|
||||
}
|
||||
placeholder={searchPlaceholder}
|
||||
/>
|
||||
<div
|
||||
className={
|
||||
@@ -309,98 +393,287 @@ export function PeerGroupSelector({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CommandGroup>
|
||||
<ScrollArea
|
||||
className={"max-h-[195px] flex flex-col gap-1 pl-2 py-2 pr-3"}
|
||||
>
|
||||
{searchedGroupNotFound && (
|
||||
<CommandItem
|
||||
key={search}
|
||||
onSelect={() => {
|
||||
toggleGroupByName(search);
|
||||
searchRef.current?.focus();
|
||||
}}
|
||||
value={search}
|
||||
onClick={(e) => e.preventDefault()}
|
||||
<Tabs defaultValue={"groups"} value={tab} onValueChange={setTab}>
|
||||
{showResources && <TabTriggers searchRef={searchRef} />}
|
||||
<TabsContent value={"groups"} className={"p-0 my-0"}>
|
||||
<CommandGroup>
|
||||
<ScrollArea
|
||||
className={cn(
|
||||
"max-h-[195px] flex flex-col gap-1 pl-2 py-2 pr-3",
|
||||
sortedDropdownOptions.length == 0 && !search && "py-0",
|
||||
)}
|
||||
>
|
||||
<Badge variant={"gray-ghost"}>
|
||||
{folderIcon}
|
||||
{search}
|
||||
</Badge>
|
||||
<div className={"text-neutral-500 dark:text-nb-gray-300"}>
|
||||
Add this group by pressing{" "}
|
||||
<span className={"font-bold text-netbird"}>
|
||||
{"'Enter'"}
|
||||
</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
)}
|
||||
|
||||
{sortedDropdownOptions.slice(0, slice).map((option) => {
|
||||
const isSelected =
|
||||
values.find((group) => group.name == option.name) !=
|
||||
undefined;
|
||||
const peerCount =
|
||||
option.peers?.length ?? option?.peers_count ?? 0;
|
||||
|
||||
const isDisabled = disabledGroups
|
||||
? disabledGroups?.findIndex((g) => g.id === option.id) !==
|
||||
-1
|
||||
: false;
|
||||
|
||||
return (
|
||||
<FullTooltip
|
||||
content={
|
||||
<div className={"text-xs max-w-xs"}>
|
||||
This group is already part of the routing peer and can
|
||||
not be used for the access control groups.
|
||||
</div>
|
||||
}
|
||||
disabled={!isDisabled}
|
||||
className={"w-full block"}
|
||||
key={option.name}
|
||||
>
|
||||
{searchedGroupNotFound && (
|
||||
<CommandItem
|
||||
key={option.name}
|
||||
value={option.name + option.id}
|
||||
disabled={isDisabled}
|
||||
key={search}
|
||||
onSelect={() => {
|
||||
if (peer != undefined && option.name == "All") return; // Prevent removing the "All" group
|
||||
if (isDisabled) return;
|
||||
toggleGroupByName(option.name);
|
||||
toggleGroupByName(search);
|
||||
searchRef.current?.focus();
|
||||
}}
|
||||
className={cn(isDisabled && "opacity-40")}
|
||||
value={search}
|
||||
onClick={(e) => e.preventDefault()}
|
||||
>
|
||||
<div className={"flex items-center gap-2"}>
|
||||
<GroupBadge group={option} showNewBadge={true} />
|
||||
</div>
|
||||
|
||||
<div className={"flex items-center gap-5"}>
|
||||
{option?.id && showRoutes && (
|
||||
<AccessControlGroupCount group_id={option.id} />
|
||||
)}
|
||||
|
||||
<div
|
||||
className={
|
||||
"text-neutral-500 dark:text-nb-gray-300 font-medium flex items-center gap-2"
|
||||
}
|
||||
>
|
||||
{peerIcon}
|
||||
{peerCount} Peer(s)
|
||||
<Checkbox checked={isSelected} />
|
||||
</div>
|
||||
<Badge variant={"gray-ghost"}>
|
||||
{folderIcon}
|
||||
{search}
|
||||
</Badge>
|
||||
<div
|
||||
className={"text-neutral-500 dark:text-nb-gray-300"}
|
||||
>
|
||||
Add this group by pressing{" "}
|
||||
<span className={"font-bold text-netbird"}>
|
||||
{"'Enter'"}
|
||||
</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
</FullTooltip>
|
||||
);
|
||||
})}
|
||||
</ScrollArea>
|
||||
</CommandGroup>
|
||||
)}
|
||||
|
||||
{sortedDropdownOptions.slice(0, slice).map((option) => {
|
||||
const isSelected =
|
||||
values.find((group) => group.name == option.name) !=
|
||||
undefined;
|
||||
const peerCount =
|
||||
option.peers?.length ?? option?.peers_count ?? 0;
|
||||
|
||||
const isDisabled = disabledGroups
|
||||
? disabledGroups?.findIndex(
|
||||
(g) => g.id === option.id,
|
||||
) !== -1
|
||||
: false;
|
||||
|
||||
if (hideAllGroup && option?.name === "All") return;
|
||||
|
||||
return (
|
||||
<FullTooltip
|
||||
content={
|
||||
<div className={"text-xs max-w-xs"}>
|
||||
This group is already part of the routing peer and
|
||||
can not be used for the access control groups.
|
||||
</div>
|
||||
}
|
||||
disabled={!isDisabled}
|
||||
className={"w-full block"}
|
||||
key={option.name}
|
||||
>
|
||||
<CommandItem
|
||||
key={option.name}
|
||||
value={option.name + option.id}
|
||||
disabled={isDisabled}
|
||||
onSelect={() => {
|
||||
if (peer != undefined && option.name == "All")
|
||||
return; // Prevent removing the "All" group
|
||||
if (isDisabled) return;
|
||||
toggleGroupByName(option.name);
|
||||
searchRef.current?.focus();
|
||||
}}
|
||||
className={cn(isDisabled && "opacity-40")}
|
||||
onClick={(e) => e.preventDefault()}
|
||||
>
|
||||
<div className={"flex items-center gap-2"}>
|
||||
<GroupBadge group={option} showNewBadge={true} />
|
||||
</div>
|
||||
|
||||
<div className={"flex items-center gap-5"}>
|
||||
{option?.id && showRoutes && (
|
||||
<AccessControlGroupCount group_id={option.id} />
|
||||
)}
|
||||
|
||||
{showResourceCounter && (
|
||||
<ResourcesCounter group={option} />
|
||||
)}
|
||||
|
||||
<div
|
||||
className={
|
||||
"text-neutral-500 dark:text-nb-gray-300 font-medium flex items-center gap-2"
|
||||
}
|
||||
>
|
||||
{peerIcon}
|
||||
{peerCount} Peer(s)
|
||||
<Checkbox checked={isSelected} />
|
||||
</div>
|
||||
</div>
|
||||
</CommandItem>
|
||||
</FullTooltip>
|
||||
);
|
||||
})}
|
||||
</ScrollArea>
|
||||
</CommandGroup>
|
||||
</TabsContent>
|
||||
{showResources && (
|
||||
<TabsContent value={"resources"} className={"p-0 my-0"}>
|
||||
<ResourcesList
|
||||
search={search}
|
||||
resources={resources}
|
||||
isLoading={isLoading}
|
||||
value={resource}
|
||||
onChange={selectResource}
|
||||
/>
|
||||
</TabsContent>
|
||||
)}
|
||||
</Tabs>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
const TabTriggers = ({
|
||||
searchRef,
|
||||
}: {
|
||||
searchRef: React.MutableRefObject<HTMLInputElement | null>;
|
||||
}) => {
|
||||
return (
|
||||
<TabsList justify={"start"} className={"px-3"}>
|
||||
<TabsTrigger
|
||||
value={"groups"}
|
||||
className={"text-[.8rem] font-normal"}
|
||||
onClick={() => searchRef.current?.focus()}
|
||||
>
|
||||
<FolderGit2
|
||||
className={
|
||||
"text-nb-gray-500 group-data-[state=active]/trigger:text-netbird transition-all"
|
||||
}
|
||||
size={14}
|
||||
/>
|
||||
Groups
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value={"resources"}
|
||||
className={"text-[.8rem] font-normal"}
|
||||
onClick={() => searchRef.current?.focus()}
|
||||
>
|
||||
<Layers3Icon
|
||||
className={
|
||||
"text-nb-gray-500 group-data-[state=active]/trigger:text-netbird transition-all"
|
||||
}
|
||||
size={14}
|
||||
/>
|
||||
Resource
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
);
|
||||
};
|
||||
|
||||
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,
|
||||
resources,
|
||||
isLoading,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
search: string;
|
||||
resources?: NetworkResource[];
|
||||
isLoading: boolean;
|
||||
value?: PolicyRuleResource;
|
||||
onChange: (resource: NetworkResource) => void;
|
||||
}) => {
|
||||
const [filteredItems, _, setSearch] = useSearch(
|
||||
resources || [],
|
||||
resourcesSearchPredicate,
|
||||
{ filter: true, debounce: 150 },
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setSearch(search);
|
||||
}, [search, setSearch]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className={"max-h-[195px] flex flex-col gap-1 py-2 px-2"}>
|
||||
<Skeleton height={42} className={"rounded-md"} />
|
||||
<Skeleton height={42} className={"rounded-md"} />
|
||||
<Skeleton height={42} className={"rounded-md"} />
|
||||
<Skeleton height={42} className={"rounded-md"} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (search != "" && filteredItems.length == 0) {
|
||||
return (
|
||||
<DropdownInfoText className={"mt-5 max-w-sm mx-auto"}>
|
||||
There are no resources matching your search. Please try a different
|
||||
search term.
|
||||
</DropdownInfoText>
|
||||
);
|
||||
}
|
||||
|
||||
if (search == "" && filteredItems.length == 0) {
|
||||
return (
|
||||
<DropdownInfoText className={"mt-5 max-w-sm mx-auto"}>
|
||||
There are no resources available yet. <br />
|
||||
Go to <InlineLink href={"/networks"}>Networks</InlineLink> to add some
|
||||
resources.
|
||||
</DropdownInfoText>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Radio defaultValue={value?.id} name={"resource"} value={value?.id}>
|
||||
<VirtualScrollAreaList
|
||||
items={filteredItems}
|
||||
onSelect={onChange}
|
||||
itemClassName={"dark:aria-selected:bg-nb-gray-800/20"}
|
||||
renderItem={(res) => {
|
||||
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={"shrink-0"} />
|
||||
)}
|
||||
{res.type === "domain" && (
|
||||
<GlobeIcon size={12} className={"shrink-0"} />
|
||||
)}
|
||||
{res.type === "subnet" && (
|
||||
<NetworkIcon size={12} className={"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"
|
||||
}
|
||||
>
|
||||
{res.address}
|
||||
<RadioItem value={res.id} />
|
||||
</div>
|
||||
</div>
|
||||
</Fragment>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</Radio>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,31 +1,26 @@
|
||||
import { DropdownInfoText } from "@components/DropdownInfoText";
|
||||
import { DropdownInput } from "@components/DropdownInput";
|
||||
import FullTooltip from "@components/FullTooltip";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@components/Popover";
|
||||
import TextWithTooltip from "@components/ui/TextWithTooltip";
|
||||
import { VirtualScrollAreaList } from "@components/VirtualScrollAreaList";
|
||||
import { getOperatingSystem } from "@hooks/useOperatingSystem";
|
||||
import { useSearch } from "@hooks/useSearch";
|
||||
import useFetchApi from "@utils/api";
|
||||
import { cn } from "@utils/helpers";
|
||||
import { isRoutingPeerSupported } from "@utils/version";
|
||||
import { sortBy, unionBy } from "lodash";
|
||||
import { ChevronsUpDown, MapPin } from "lucide-react";
|
||||
import { ArrowUpCircleIcon, ChevronsUpDown, MapPin } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { memo, useEffect, useState } from "react";
|
||||
import { FcLinux } from "react-icons/fc";
|
||||
import { useElementSize } from "@/hooks/useElementSize";
|
||||
import { getOperatingSystem } from "@/hooks/useOperatingSystem";
|
||||
import { OperatingSystem } from "@/interfaces/OperatingSystem";
|
||||
import { Peer } from "@/interfaces/Peer";
|
||||
import { OSLogo } from "@/modules/peers/PeerOSCell";
|
||||
|
||||
const MapPinIcon = memo(() => <MapPin size={12} />);
|
||||
MapPinIcon.displayName = "MapPinIcon";
|
||||
|
||||
const LinuxIcon = memo(() => (
|
||||
<span className={"grayscale brightness-[100%] contrast-[40%]"}>
|
||||
<FcLinux className={"text-white text-lg min-w-[20px] brightness-150"} />
|
||||
</span>
|
||||
));
|
||||
LinuxIcon.displayName = "LinuxIcon";
|
||||
|
||||
interface MultiSelectProps {
|
||||
value?: Peer;
|
||||
onChange: React.Dispatch<React.SetStateAction<Peer | undefined>>;
|
||||
@@ -63,11 +58,6 @@ export function PeerSelector({
|
||||
// Sort
|
||||
let options = sortBy([...peers], "name") as Peer[];
|
||||
|
||||
// Filter out peers that are not linux
|
||||
options = options.filter((peer) => {
|
||||
return getOperatingSystem(peer.os) === OperatingSystem.LINUX;
|
||||
});
|
||||
|
||||
// Filter out excluded peers
|
||||
if (excludedPeers) {
|
||||
options = options.filter((peer) => {
|
||||
@@ -128,8 +118,7 @@ export function PeerSelector({
|
||||
}
|
||||
>
|
||||
<div className={"flex items-center gap-2.5 text-sm"}>
|
||||
<LinuxIcon />
|
||||
<TextWithTooltip text={value.name} maxChars={20} />
|
||||
<TextWithTooltip text={value.name} maxChars={22} />
|
||||
</div>
|
||||
|
||||
<div
|
||||
@@ -151,7 +140,7 @@ export function PeerSelector({
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
hideWhenDetached={false}
|
||||
className="w-full p-0 shadow-sm shadow-nb-gray-950"
|
||||
className="w-full p-0 shadow-sm shadow-nb-gray-950"
|
||||
style={{
|
||||
width: width,
|
||||
}}
|
||||
@@ -166,15 +155,15 @@ export function PeerSelector({
|
||||
placeholder={"Search for peers by name or ip..."}
|
||||
/>
|
||||
|
||||
{unfilteredItems.length == 0 && (
|
||||
<DropdownInfoText>
|
||||
{
|
||||
"Seems like you don't have any linux peers to assign as a routing peer."
|
||||
}
|
||||
</DropdownInfoText>
|
||||
{unfilteredItems.length == 0 && !search && (
|
||||
<div className={"max-w-xs mx-auto"}>
|
||||
<DropdownInfoText>
|
||||
{"No peers available to select."}
|
||||
</DropdownInfoText>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{filteredItems.length == 0 && (
|
||||
{filteredItems.length == 0 && search != "" && (
|
||||
<DropdownInfoText>
|
||||
There are no peers matching your search.
|
||||
</DropdownInfoText>
|
||||
@@ -183,10 +172,35 @@ export function PeerSelector({
|
||||
{filteredItems.length > 0 && (
|
||||
<VirtualScrollAreaList
|
||||
items={filteredItems}
|
||||
onSelect={togglePeer}
|
||||
onSelect={(item) => {
|
||||
const isSupported = isRoutingPeerSupported(
|
||||
item.version,
|
||||
item.os,
|
||||
);
|
||||
if (!isSupported) return;
|
||||
togglePeer(item);
|
||||
}}
|
||||
renderItem={(option) => {
|
||||
const os = getOperatingSystem(option.os);
|
||||
const isSupported = isRoutingPeerSupported(
|
||||
option.version,
|
||||
option.os,
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<FullTooltip
|
||||
disabled={isSupported}
|
||||
interactive={false}
|
||||
delayDuration={200}
|
||||
skipDelayDuration={350}
|
||||
className={"w-full flex items-center justify-between"}
|
||||
content={
|
||||
<div className={"max-w-[240px] text-xs"}>
|
||||
Please update NetBird to at least{" "}
|
||||
<span className={"text-netbird"}>v0.36.6</span> or later
|
||||
to use this peer as a routing peer.
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-2.5 text-sm",
|
||||
@@ -195,8 +209,35 @@ export function PeerSelector({
|
||||
: "text-nb-gray-300",
|
||||
)}
|
||||
>
|
||||
<LinuxIcon />
|
||||
<TextWithTooltip text={option.name} maxChars={20} />
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-center grayscale brightness-[100%] contrast-[40%]",
|
||||
"w-4 h-4 shrink-0",
|
||||
os === OperatingSystem.WINDOWS && "p-[2.5px]",
|
||||
os === OperatingSystem.APPLE && "p-[2.7px]",
|
||||
os === OperatingSystem.FREEBSD && "p-[1.5px]",
|
||||
!isSupported && "opacity-50",
|
||||
)}
|
||||
>
|
||||
<OSLogo os={option.os} />
|
||||
</div>
|
||||
|
||||
<div className={cn(!isSupported && "opacity-50")}>
|
||||
<TextWithTooltip
|
||||
text={option.name}
|
||||
maxChars={22}
|
||||
hideTooltip={!isSupported}
|
||||
/>
|
||||
</div>
|
||||
{!isSupported && (
|
||||
<div className={"relative"}>
|
||||
<span className="animate-ping absolute left-0 inline-flex h-[14px] w-[14px] rounded-full bg-netbird opacity-20"></span>
|
||||
<ArrowUpCircleIcon
|
||||
size={14}
|
||||
className={"text-netbird"}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
@@ -205,12 +246,13 @@ export function PeerSelector({
|
||||
value && value.id == option.id
|
||||
? "text-white"
|
||||
: "text-nb-gray-300",
|
||||
!isSupported && "opacity-50",
|
||||
)}
|
||||
>
|
||||
<MapPinIcon />
|
||||
{option.ip}
|
||||
</div>
|
||||
</>
|
||||
</FullTooltip>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
71
src/components/Radio.tsx
Normal file
71
src/components/Radio.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import * as RadioPrimitive from "@radix-ui/react-radio-group";
|
||||
import { cn } from "@utils/helpers";
|
||||
import { cva, VariantProps } from "class-variance-authority";
|
||||
import * as React from "react";
|
||||
import { forwardRef } from "react";
|
||||
|
||||
type RadioVariants = VariantProps<typeof variants>;
|
||||
|
||||
const variants = cva([], {
|
||||
variants: {
|
||||
variant: {
|
||||
default: [
|
||||
"dark:data-[state=unchecked]:bg-nb-gray-950 dark:border-nb-gray-900 dark:ring-offset-neutral-950 dark:focus-visible:ring-neutral-300",
|
||||
"dark:data-[state=checked]:bg-netbird",
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const Radio = forwardRef<
|
||||
React.ElementRef<typeof RadioPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof RadioPrimitive.Root> & RadioVariants
|
||||
>(
|
||||
(
|
||||
{ className, children, variant = "default", defaultValue, ...props },
|
||||
ref,
|
||||
) => (
|
||||
<RadioPrimitive.Root
|
||||
ref={ref}
|
||||
defaultValue={defaultValue}
|
||||
name={props.name}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</RadioPrimitive.Root>
|
||||
),
|
||||
);
|
||||
Radio.displayName = RadioPrimitive.Root.displayName;
|
||||
|
||||
type Props = {
|
||||
value: string;
|
||||
className?: string;
|
||||
} & RadioVariants;
|
||||
|
||||
const RadioItem = ({ value, className, variant = "default" }: Props) => {
|
||||
return (
|
||||
<RadioPrimitive.Item
|
||||
value={value}
|
||||
className={cn(
|
||||
variants({ variant }),
|
||||
"border-neutral-900",
|
||||
"peer h-5 w-5 shrink-0 rounded-full border",
|
||||
"ring-offset-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-neutral-950 focus-visible:ring-offset-2",
|
||||
"disabled:cursor-not-allowed disabled:opacity-50 relative",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<RadioPrimitive.Indicator asChild={true}>
|
||||
<div
|
||||
className={cn(
|
||||
"h-2 w-2 bg-netbird absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 flex items-center justify-center rounded-full",
|
||||
"data-[state=checked]:bg-white data-[state=checked]:text-neutral-50 ",
|
||||
)}
|
||||
></div>
|
||||
</RadioPrimitive.Indicator>
|
||||
</RadioPrimitive.Item>
|
||||
);
|
||||
};
|
||||
RadioItem.displayName = RadioPrimitive.Item.displayName;
|
||||
|
||||
export { Radio, RadioItem };
|
||||
@@ -1,5 +1,3 @@
|
||||
"use client";
|
||||
|
||||
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
|
||||
import { cn } from "@utils/helpers";
|
||||
import * as React from "react";
|
||||
@@ -15,46 +13,31 @@ const ScrollArea = React.forwardRef<
|
||||
>(({ className, children, withoutViewport = false, ...props }, ref) => (
|
||||
<ScrollAreaPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative will-change-scroll webkit-scroll",
|
||||
className,
|
||||
"overflow-hidden",
|
||||
)}
|
||||
className={cn("relative overflow-hidden", className)}
|
||||
{...props}
|
||||
>
|
||||
{withoutViewport ? (
|
||||
children
|
||||
) : (
|
||||
<ScrollAreaViewport disableOverflowY={false}>
|
||||
{children}
|
||||
</ScrollAreaViewport>
|
||||
<ScrollAreaViewport>{children}</ScrollAreaViewport>
|
||||
)}
|
||||
<ScrollBar />
|
||||
<ScrollBar orientation="horizontal" />
|
||||
<ScrollBar orientation="vertical" />
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
));
|
||||
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName;
|
||||
|
||||
type AdditionalScrollAreaViewportProps = {
|
||||
disableOverflowY?: boolean;
|
||||
};
|
||||
|
||||
const ScrollAreaViewport = React.forwardRef<
|
||||
React.ElementRef<typeof ScrollAreaPrimitive.Viewport>,
|
||||
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Viewport> &
|
||||
AdditionalScrollAreaViewportProps
|
||||
>(({ disableOverflowY = true, ...props }, ref) => {
|
||||
return (
|
||||
<ScrollAreaPrimitive.Viewport
|
||||
ref={ref}
|
||||
className="h-full w-full rounded-[inherit] will-change-scroll webkit-scroll"
|
||||
{...props}
|
||||
style={
|
||||
disableOverflowY ? { overflowY: undefined, ...props.style } : undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
});
|
||||
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Viewport>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ScrollAreaPrimitive.Viewport
|
||||
ref={ref}
|
||||
className={cn("h-full w-full rounded-[inherit]", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
ScrollAreaViewport.displayName = ScrollAreaPrimitive.Viewport.displayName;
|
||||
|
||||
const ScrollBar = React.forwardRef<
|
||||
@@ -63,14 +46,11 @@ const ScrollBar = React.forwardRef<
|
||||
>(({ className, orientation = "vertical", ...props }, ref) => (
|
||||
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||
ref={ref}
|
||||
style={{ boxSizing: "unset", overflow: undefined }}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"flex touch-none select-none transition-colors",
|
||||
orientation === "vertical" &&
|
||||
"h-full w-2.5 border-l border-l-transparent p-[1px]",
|
||||
orientation === "horizontal" &&
|
||||
"h-2.5 border-t border-t-transparent p-[1px]",
|
||||
"flex select-none touch-none transition-colors",
|
||||
orientation === "vertical" && "h-full w-2.5 p-[1px]",
|
||||
orientation === "horizontal" && "w-full h-2.5 p-[1px] bottom-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -79,6 +59,7 @@ const ScrollBar = React.forwardRef<
|
||||
className={cn(
|
||||
"relative rounded-full bg-neutral-200 dark:bg-nb-gray-800",
|
||||
orientation === "vertical" && "flex-1",
|
||||
orientation === "horizontal" && "h-full",
|
||||
)}
|
||||
/>
|
||||
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
export default function Separator() {
|
||||
return (
|
||||
<span
|
||||
className={"h-[1px] w-full dark:bg-nb-gray-900 bg-nb-gray-100 block"}
|
||||
></span>
|
||||
);
|
||||
return <span className={"h-[1px] w-full bg-zinc-700/40 block"}></span>;
|
||||
}
|
||||
|
||||
@@ -60,10 +60,12 @@ export default function SidebarItem({
|
||||
<li className={"px-4 cursor-pointer"}>
|
||||
<button
|
||||
className={classNames(
|
||||
"rounded-lg text-[.95rem] w-full ",
|
||||
"rounded-lg text-[.87rem] w-full ",
|
||||
"font-normal ",
|
||||
className,
|
||||
isChild ? "pl-7 pr-2 py-2 mt-1 mb-0.5" : "py-2 px-3",
|
||||
isChild
|
||||
? "pl-7 pr-2 py-[.45rem] mt-1 mb-0.5"
|
||||
: "py-[.45rem] px-3",
|
||||
isActive
|
||||
? "text-gray-900 bg-gray-200 dark:text-white dark:bg-nb-gray-900"
|
||||
: "text-gray-600 hover:bg-gray-200 dark:text-nb-gray-400 dark:hover:bg-nb-gray-900/50",
|
||||
|
||||
@@ -4,9 +4,18 @@ import React from "react";
|
||||
type Props = {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
horizontal?: boolean;
|
||||
};
|
||||
export default function Steps({ children, className }: Props) {
|
||||
return <div className={cn("pt-4", className)}>{children}</div>;
|
||||
export default function Steps({
|
||||
children,
|
||||
className,
|
||||
horizontal = false,
|
||||
}: Readonly<Props>) {
|
||||
return (
|
||||
<div className={cn("pt-4", horizontal && "flex", className)}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type StepProps = {
|
||||
@@ -14,21 +23,32 @@ type StepProps = {
|
||||
step: number;
|
||||
line?: boolean;
|
||||
center?: boolean;
|
||||
horizontal?: boolean;
|
||||
};
|
||||
|
||||
const Step = ({ children, step, line = true, center = false }: StepProps) => {
|
||||
const Step = ({
|
||||
children,
|
||||
step,
|
||||
line = true,
|
||||
center = false,
|
||||
horizontal,
|
||||
}: StepProps) => {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex gap-4 items-start min-w-full justify-start relative pb-6 -mx-1.5 group px-[2px]",
|
||||
"flex gap-4 items-start justify-start relative pb-6 -mx-1.5 group px-[2px]",
|
||||
center && "items-center",
|
||||
horizontal ? "flex-col items-center" : "min-w-full",
|
||||
)}
|
||||
>
|
||||
{line && (
|
||||
<span
|
||||
className={
|
||||
"h-full w-[2px] bg-nb-gray-100 dark:bg-nb-gray-800 absolute left-0 ml-[18px] z-0 transition-all"
|
||||
}
|
||||
className={cn(
|
||||
"bg-nb-gray-100 dark:bg-nb-gray-800 z-0 transition-all",
|
||||
horizontal
|
||||
? "w-full h-[2px] absolute mt-[16px] transform translate-x-1/2"
|
||||
: "h-full w-[2px] absolute left-0 ml-[18px]",
|
||||
)}
|
||||
></span>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -36,29 +36,36 @@ const switchVariants = cva("", {
|
||||
|
||||
const ToggleSwitch = React.forwardRef<
|
||||
React.ElementRef<typeof SwitchPrimitives.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root> & SwitchVariants
|
||||
>(({ className, size = "default", variant = "default", ...props }, ref) => (
|
||||
<SwitchPrimitives.Root
|
||||
className={cn(
|
||||
"peer inline-flex shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-neutral-950 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:opacity-50 dark:focus-visible:ring-neutral-300 dark:focus-visible:ring-offset-neutral-950 ",
|
||||
className,
|
||||
switchVariants({ size, variant }),
|
||||
)}
|
||||
{...props}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
props.onClick?.(e);
|
||||
}}
|
||||
ref={ref}
|
||||
>
|
||||
<SwitchPrimitives.Thumb
|
||||
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root> &
|
||||
SwitchVariants & { dataCy?: string }
|
||||
>(
|
||||
(
|
||||
{ className, size = "default", variant = "default", dataCy, ...props },
|
||||
ref,
|
||||
) => (
|
||||
<SwitchPrimitives.Root
|
||||
className={cn(
|
||||
switchVariants({ "thumb-size": size }),
|
||||
"pointer-events-none block rounded-full bg-white shadow-lg ring-0 transition-transform data-[state=unchecked]:translate-x-0 dark:bg-white",
|
||||
"peer inline-flex shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-neutral-950 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:opacity-50 dark:focus-visible:ring-neutral-300 dark:focus-visible:ring-offset-neutral-950 ",
|
||||
className,
|
||||
switchVariants({ size, variant }),
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitives.Root>
|
||||
));
|
||||
{...props}
|
||||
data-cy={dataCy}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
props.onClick?.(e);
|
||||
}}
|
||||
ref={ref}
|
||||
>
|
||||
<SwitchPrimitives.Thumb
|
||||
className={cn(
|
||||
switchVariants({ "thumb-size": size }),
|
||||
"pointer-events-none block rounded-full bg-white shadow-lg ring-0 transition-transform data-[state=unchecked]:translate-x-0 dark:bg-white",
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitives.Root>
|
||||
),
|
||||
);
|
||||
ToggleSwitch.displayName = SwitchPrimitives.Root.displayName;
|
||||
|
||||
export { ToggleSwitch };
|
||||
|
||||
@@ -10,6 +10,9 @@ const Tooltip = TooltipPrimitive.Root;
|
||||
|
||||
const TooltipTrigger = TooltipPrimitive.Trigger;
|
||||
|
||||
export const tooltipClasses =
|
||||
"z-[9999] overflow-hidden rounded-md border border-neutral-200 bg-white text-sm text-neutral-950 shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 dark:border-nb-gray-930 dark:bg-nb-gray-940 dark:text-neutral-50";
|
||||
|
||||
const TooltipContent = React.forwardRef<
|
||||
React.ElementRef<typeof TooltipPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
|
||||
@@ -19,10 +22,7 @@ const TooltipContent = React.forwardRef<
|
||||
ref={ref}
|
||||
asChild={true}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-[9999] overflow-hidden rounded-md border border-neutral-200 bg-white text-sm text-neutral-950 shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 dark:border-nb-gray-930 dark:bg-nb-gray-940 dark:text-neutral-50",
|
||||
className,
|
||||
)}
|
||||
className={cn(tooltipClasses, className)}
|
||||
{...props}
|
||||
>
|
||||
<div>{props.children}</div>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -32,7 +51,7 @@ function List({ children }: { children: React.ReactNode }) {
|
||||
<Tabs.List
|
||||
className={cn(
|
||||
"px-4 py-4 whitespace-nowrap overflow-y-hidden shrink-0 no-scrollbar",
|
||||
"lg:h-full items-start bg-nb-gray border-b border-nb-gray-930",
|
||||
"lg:h-full items-start bg-nb-gray border-b-0 border-nb-gray-930",
|
||||
"flex lg:flex-col lg:gap-1",
|
||||
)}
|
||||
style={{
|
||||
|
||||
@@ -11,13 +11,15 @@ type Props<T extends { id?: string }> = {
|
||||
items: T[];
|
||||
onSelect: (item: T) => void;
|
||||
renderItem?: (item: T) => React.ReactNode;
|
||||
itemClassName?: string;
|
||||
};
|
||||
|
||||
export function VirtualScrollAreaList<T extends { id?: string }>({
|
||||
items,
|
||||
onSelect,
|
||||
renderItem,
|
||||
}: Props<T>) {
|
||||
itemClassName,
|
||||
}: Readonly<Props<T>>) {
|
||||
const virtuosoRef = useRef<VirtuosoHandle>(null);
|
||||
const [selected, setSelected] = useState(0);
|
||||
|
||||
@@ -81,8 +83,9 @@ export function VirtualScrollAreaList<T extends { id?: string }>({
|
||||
<VirtualScrollListItemWrapper
|
||||
onMouseEnter={() => setSelected(index)}
|
||||
id={option.id}
|
||||
onClick={() => onClick(option as T)}
|
||||
onClick={() => onClick(option)}
|
||||
ariaSelected={selected === index}
|
||||
className={itemClassName}
|
||||
>
|
||||
{renderMemoizedItem ? renderMemoizedItem(option) : option.id}
|
||||
</VirtualScrollListItemWrapper>
|
||||
@@ -103,10 +106,18 @@ type ItemWrapperProps = {
|
||||
onMouseEnter?: () => void;
|
||||
onClick?: () => void;
|
||||
ariaSelected?: boolean;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export const VirtualScrollListItemWrapper = memo(
|
||||
({ id, children, onClick, onMouseEnter, ariaSelected }: ItemWrapperProps) => {
|
||||
({
|
||||
id,
|
||||
children,
|
||||
onClick,
|
||||
onMouseEnter,
|
||||
ariaSelected,
|
||||
className,
|
||||
}: ItemWrapperProps) => {
|
||||
return (
|
||||
<div
|
||||
key={id ?? undefined}
|
||||
@@ -118,6 +129,7 @@ export const VirtualScrollListItemWrapper = memo(
|
||||
className={cn(
|
||||
"text-xs flex justify-between py-2 px-3 cursor-pointer items-center rounded-md",
|
||||
"bg-transparent dark:aria-selected:bg-nb-gray-800/50",
|
||||
className,
|
||||
)}
|
||||
aria-selected={ariaSelected}
|
||||
role={"listitem"}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import Badge from "@components/Badge";
|
||||
import TextWithTooltip from "@components/ui/TextWithTooltip";
|
||||
import { GroupBadgeIcon } from "@components/ui/GroupBadgeIcon";
|
||||
import { SmallBadge } from "@components/ui/SmallBadge";
|
||||
import TruncatedText from "@components/ui/TruncatedText";
|
||||
import { cn } from "@utils/helpers";
|
||||
import { FolderGit2, XIcon } from "lucide-react";
|
||||
import { XIcon } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { Group } from "@/interfaces/Group";
|
||||
|
||||
@@ -13,6 +15,7 @@ type Props = {
|
||||
className?: string;
|
||||
showNewBadge?: boolean;
|
||||
};
|
||||
|
||||
export default function GroupBadge({
|
||||
onClick,
|
||||
group,
|
||||
@@ -20,13 +23,14 @@ export default function GroupBadge({
|
||||
children,
|
||||
className,
|
||||
showNewBadge = false,
|
||||
}: Props) {
|
||||
}: Readonly<Props>) {
|
||||
const isNew = !group?.id;
|
||||
|
||||
return (
|
||||
<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,20 +38,10 @@ export default function GroupBadge({
|
||||
onClick?.(e);
|
||||
}}
|
||||
>
|
||||
<FolderGit2 size={12} className={"shrink-0"} />
|
||||
|
||||
<TextWithTooltip text={group?.name || ""} maxChars={20} />
|
||||
<GroupBadgeIcon id={group?.id} issued={group?.issued} />
|
||||
<TruncatedText text={group?.name || ""} maxChars={20} />
|
||||
{children}
|
||||
{isNew && showNewBadge && (
|
||||
<span
|
||||
className={
|
||||
"text-[7px] relative top-[.25px] leading-[0] bg-green-900 border border-green-500/20 py-1.5 px-1 rounded-[3px] text-green-400"
|
||||
}
|
||||
>
|
||||
NEW
|
||||
</span>
|
||||
)}
|
||||
|
||||
{isNew && showNewBadge && <SmallBadge />}
|
||||
{showX && (
|
||||
<XIcon
|
||||
size={12}
|
||||
|
||||
32
src/components/ui/GroupBadgeIcon.tsx
Normal file
32
src/components/ui/GroupBadgeIcon.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { FolderGit2 } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import EntraIcon from "@/assets/icons/EntraIcon";
|
||||
import GoogleIcon from "@/assets/icons/GoogleIcon";
|
||||
import JWTIcon from "@/assets/icons/JWTIcon";
|
||||
import OktaIcon from "@/assets/icons/OktaIcon";
|
||||
import { useGroups } from "@/contexts/GroupsProvider";
|
||||
import { GroupIssued } from "@/interfaces/Group";
|
||||
import { useGroupIdentification } from "@/modules/groups/useGroupIdentification";
|
||||
|
||||
export const GroupBadgeIcon = ({
|
||||
id,
|
||||
issued,
|
||||
}: {
|
||||
id?: string;
|
||||
issued?: GroupIssued;
|
||||
}) => {
|
||||
const { groups } = useGroups();
|
||||
const group = groups?.find((g) => g.id === id);
|
||||
|
||||
const { isAzureGroup, isGoogleGroup, isOktaGroup, isJWTGroup } =
|
||||
useGroupIdentification({ id, issued: issued ?? group?.issued });
|
||||
|
||||
if (isGoogleGroup)
|
||||
return <GoogleIcon size={11} className={"shrink-0 mr-0.5"} />;
|
||||
if (isAzureGroup)
|
||||
return <EntraIcon size={13} className={"shrink-0 mr-0.5"} />;
|
||||
if (isOktaGroup) return <OktaIcon size={11} className={"shrink-0 mr-0.5"} />;
|
||||
if (isJWTGroup) return <JWTIcon size={12} className={"shrink-0"} />;
|
||||
|
||||
return <FolderGit2 size={12} className={"shrink-0"} />;
|
||||
};
|
||||
@@ -9,8 +9,8 @@ export default function LoginExpiredBadge({ loginExpired }: Props) {
|
||||
return loginExpired ? (
|
||||
<Tooltip delayDuration={1}>
|
||||
<TooltipTrigger>
|
||||
<Badge variant={"red"} className={"px-3"}>
|
||||
<AlertTriangle size={13} className={"mr-1"} />
|
||||
<Badge variant={"red"} className={"px-2"}>
|
||||
<AlertTriangle size={12} />
|
||||
Login required
|
||||
</Badge>
|
||||
</TooltipTrigger>
|
||||
|
||||
@@ -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
|
||||
|
||||
16
src/components/ui/NewBadge.tsx
Normal file
16
src/components/ui/NewBadge.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import * as React from "react";
|
||||
|
||||
type Props = {
|
||||
text?: string;
|
||||
};
|
||||
export const NewBadge = ({ text = "NEW" }: Props) => {
|
||||
return (
|
||||
<span
|
||||
className={
|
||||
"text-[7px] relative top-[.25px] leading-[0] bg-green-900 border border-green-500/20 py-1.5 px-1 rounded-[3px] text-green-400"
|
||||
}
|
||||
>
|
||||
{text}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
58
src/components/ui/ResourceBadge.tsx
Normal file
58
src/components/ui/ResourceBadge.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import Badge from "@components/Badge";
|
||||
import TruncatedText from "@components/ui/TruncatedText";
|
||||
import { cn } from "@utils/helpers";
|
||||
import { GlobeIcon, NetworkIcon, WorkflowIcon, XIcon } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { NetworkResource } from "@/interfaces/Network";
|
||||
|
||||
type Props = {
|
||||
resource?: NetworkResource;
|
||||
onClick?: (e: React.MouseEvent<HTMLDivElement>) => void;
|
||||
showX?: boolean;
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
export default function ResourceBadge({
|
||||
onClick,
|
||||
resource,
|
||||
showX = false,
|
||||
children,
|
||||
className,
|
||||
}: Readonly<Props>) {
|
||||
if (!resource) return;
|
||||
|
||||
return (
|
||||
<Badge
|
||||
key={resource.id || resource?.name}
|
||||
useHover={true}
|
||||
data-cy={"resource-badge"}
|
||||
variant={"gray-ghost"}
|
||||
className={cn("transition-all group whitespace-nowrap", className)}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
onClick?.(e);
|
||||
}}
|
||||
>
|
||||
{resource.type === "host" && (
|
||||
<WorkflowIcon size={12} className={"shrink-0"} />
|
||||
)}
|
||||
{resource.type === "domain" && (
|
||||
<GlobeIcon size={12} className={"shrink-0"} />
|
||||
)}
|
||||
{resource.type === "subnet" && (
|
||||
<NetworkIcon size={12} className={"shrink-0"} />
|
||||
)}
|
||||
|
||||
<TruncatedText text={resource?.name || ""} maxChars={20} />
|
||||
{children}
|
||||
{showX && (
|
||||
<XIcon
|
||||
size={12}
|
||||
className={
|
||||
"cursor-pointer group-hover:text-nb-gray-100 transition-all shrink-0"
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
39
src/components/ui/SmallBadge.tsx
Normal file
39
src/components/ui/SmallBadge.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import { cn } from "@utils/helpers";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import * as React from "react";
|
||||
|
||||
const smallBadgeVariants = cva("", {
|
||||
variants: {
|
||||
variant: {
|
||||
green: "bg-green-900 border border-green-500/20 text-green-400",
|
||||
white: "bg-white/20 border border-white/10 text-white",
|
||||
sky: "bg-sky-900 border border-sky-500/20 text-white",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
type Props = {
|
||||
text?: string;
|
||||
className?: string;
|
||||
children?: React.ReactNode;
|
||||
} & VariantProps<typeof smallBadgeVariants>;
|
||||
|
||||
export const SmallBadge = ({
|
||||
text = "NEW",
|
||||
className,
|
||||
variant = "green",
|
||||
children,
|
||||
}: Props) => {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
smallBadgeVariants({ variant }),
|
||||
"text-[7px] relative -top-[.25px] leading-[0] py-[0.39rem] px-1 rounded-[3px]",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
<span className={"relative top-[0.4px]"}>{text}</span>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
@@ -25,6 +25,8 @@ export default function TextWithTooltip({
|
||||
disabled={charCount <= maxChars || hideTooltip}
|
||||
interactive={false}
|
||||
className={"truncate w-full min-w-0"}
|
||||
skipDelayDuration={350}
|
||||
delayDuration={200}
|
||||
content={
|
||||
<div className={"max-w-xs break-all whitespace-normal text-xs"}>
|
||||
{text}
|
||||
|
||||
78
src/components/ui/TruncatedText.tsx
Normal file
78
src/components/ui/TruncatedText.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import * as HoverCard from "@radix-ui/react-hover-card";
|
||||
import { cn } from "@utils/helpers";
|
||||
import React, { useMemo, useState } from "react";
|
||||
|
||||
type Props = {
|
||||
text?: string;
|
||||
className?: string;
|
||||
maxChars?: number;
|
||||
hideTooltip?: boolean;
|
||||
};
|
||||
|
||||
export default function TruncatedText({
|
||||
text,
|
||||
className,
|
||||
maxChars = 40,
|
||||
hideTooltip = false,
|
||||
}: Props) {
|
||||
const charCount = useMemo(() => {
|
||||
if (!text) return 0;
|
||||
return text.length;
|
||||
}, [text]);
|
||||
|
||||
const isDisabled = charCount <= maxChars || hideTooltip;
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
if (isDisabled) {
|
||||
return (
|
||||
<div
|
||||
className={"w-full min-w-0 inline-block"}
|
||||
style={{
|
||||
maxWidth: `${maxChars - 2}ch`,
|
||||
}}
|
||||
>
|
||||
<div className={cn(className, "truncate")}>{text}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<HoverCard.Root
|
||||
openDelay={650}
|
||||
closeDelay={100}
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
>
|
||||
<HoverCard.Trigger asChild={true}>
|
||||
<div
|
||||
className={"w-full min-w-0 inline-block"}
|
||||
style={{
|
||||
maxWidth: `${maxChars - 2}ch`,
|
||||
}}
|
||||
>
|
||||
<div className={cn(className, "truncate")}>{text}</div>
|
||||
</div>
|
||||
</HoverCard.Trigger>
|
||||
<HoverCard.Portal>
|
||||
<HoverCard.Content
|
||||
onMouseLeave={() => setOpen(false)}
|
||||
onMouseEnter={() => setOpen(false)}
|
||||
alignOffset={20}
|
||||
sideOffset={4}
|
||||
className={cn(
|
||||
"z-[9999] overflow-hidden rounded-md border border-neutral-200 bg-white text-sm text-neutral-950 shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 dark:border-nb-gray-930 dark:bg-nb-gray-940 dark:text-neutral-50",
|
||||
className,
|
||||
"px-3 py-1.5",
|
||||
)}
|
||||
>
|
||||
<div className={"text-neutral-300 flex flex-col gap-1"}>
|
||||
<div className={"max-w-xs break-all whitespace-normal text-xs"}>
|
||||
{text}
|
||||
</div>
|
||||
</div>
|
||||
</HoverCard.Content>
|
||||
</HoverCard.Portal>
|
||||
</HoverCard.Root>
|
||||
);
|
||||
}
|
||||
@@ -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={
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import useFetchApi, { useApiCall } from "@utils/api";
|
||||
import { merge, sortBy, unionBy } from "lodash";
|
||||
import { usePathname } from "next/navigation";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useLoggedInUser } from "@/contexts/UsersProvider";
|
||||
import { Group } from "@/interfaces/Group";
|
||||
import {Group, GroupResource} from "@/interfaces/Group";
|
||||
import { Peer } from "@/interfaces/Peer";
|
||||
|
||||
type Props = {
|
||||
@@ -25,18 +24,29 @@ const GroupContext = React.createContext(
|
||||
);
|
||||
|
||||
export default function GroupsProvider({ children }: Props) {
|
||||
const path = usePathname();
|
||||
const { permission } = useLoggedInUser();
|
||||
const { permission, isUser } = useLoggedInUser();
|
||||
|
||||
return path === "/peers" && permission.dashboard_view == "blocked" ? (
|
||||
return permission.dashboard_view == "blocked" ? (
|
||||
<>{children}</>
|
||||
) : (
|
||||
<GroupsProviderContent>{children}</GroupsProviderContent>
|
||||
<GroupsProviderContent isUser={isUser}>{children}</GroupsProviderContent>
|
||||
);
|
||||
}
|
||||
|
||||
export function GroupsProviderContent({ children }: Props) {
|
||||
const { data: groups, mutate, isLoading } = useFetchApi<Group[]>("/groups");
|
||||
type ProviderContentProps = {
|
||||
children: React.ReactNode;
|
||||
isUser: boolean;
|
||||
};
|
||||
|
||||
export function GroupsProviderContent({
|
||||
children,
|
||||
isUser,
|
||||
}: Readonly<ProviderContentProps>) {
|
||||
const {
|
||||
data: groups,
|
||||
mutate,
|
||||
isLoading,
|
||||
} = useFetchApi<Group[]>("/groups", false, true, !isUser);
|
||||
const groupRequest = useApiCall<Group>("/groups", true);
|
||||
const [dropdownOptions, setDropdownOptions] = useState<Group[]>([]);
|
||||
|
||||
@@ -92,6 +102,13 @@ export function GroupsProviderContent({ children }: Props) {
|
||||
return peer.id;
|
||||
}) as string[];
|
||||
|
||||
let resources = group?.resources?.map((r) => {
|
||||
let isString = typeof r === "string";
|
||||
if (isString) return r;
|
||||
let resource = r as GroupResource;
|
||||
return resource.id;
|
||||
}) as string[];
|
||||
|
||||
if (group.name === "All") return Promise.resolve(group);
|
||||
|
||||
const groupID =
|
||||
@@ -102,6 +119,7 @@ export function GroupsProviderContent({ children }: Props) {
|
||||
{
|
||||
name: group.name,
|
||||
peers: peers,
|
||||
resources: resources,
|
||||
},
|
||||
`/${group.id}`,
|
||||
);
|
||||
@@ -109,6 +127,7 @@ export function GroupsProviderContent({ children }: Props) {
|
||||
return groupRequest.post({
|
||||
name: group.name,
|
||||
peers: peers,
|
||||
resources: resources,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -20,12 +20,13 @@ const PeerContext = React.createContext(
|
||||
peer: Peer;
|
||||
user?: User;
|
||||
peerGroups: Group[];
|
||||
update: (
|
||||
name: string,
|
||||
ssh: boolean,
|
||||
loginExpiration: boolean,
|
||||
approval_required?: boolean,
|
||||
) => Promise<Peer>;
|
||||
update: (props: {
|
||||
name?: string;
|
||||
ssh?: boolean;
|
||||
loginExpiration?: boolean;
|
||||
inactivityExpiration?: boolean;
|
||||
approval_required?: boolean;
|
||||
}) => Promise<Peer>;
|
||||
openSSHDialog: () => Promise<boolean>;
|
||||
deletePeer: () => void;
|
||||
isLoading: boolean;
|
||||
@@ -61,23 +62,30 @@ export default function PeerProvider({ children, peer }: Props) {
|
||||
}
|
||||
};
|
||||
|
||||
const update = async (
|
||||
name: string,
|
||||
ssh: boolean,
|
||||
loginExpiration: boolean,
|
||||
approval_required?: boolean,
|
||||
) => {
|
||||
const update = async (props: {
|
||||
name?: string;
|
||||
ssh?: boolean;
|
||||
loginExpiration?: boolean;
|
||||
inactivityExpiration?: boolean;
|
||||
approval_required?: boolean;
|
||||
}) => {
|
||||
return peerRequest.put(
|
||||
{
|
||||
peerId: peer?.id,
|
||||
name: name != undefined ? name : peer.name,
|
||||
ssh_enabled: ssh != undefined ? ssh : peer.ssh_enabled,
|
||||
name: props.name != undefined ? props.name : peer.name,
|
||||
ssh_enabled: props.ssh != undefined ? props.ssh : peer.ssh_enabled,
|
||||
login_expiration_enabled:
|
||||
loginExpiration != undefined
|
||||
? loginExpiration
|
||||
props.loginExpiration != undefined
|
||||
? props.loginExpiration
|
||||
: peer.login_expiration_enabled,
|
||||
inactivity_expiration_enabled:
|
||||
props?.inactivityExpiration == undefined
|
||||
? undefined
|
||||
: props.inactivityExpiration,
|
||||
approval_required:
|
||||
approval_required == undefined ? undefined : approval_required,
|
||||
props?.approval_required == undefined
|
||||
? undefined
|
||||
: props.approval_required,
|
||||
},
|
||||
`/${peer.id}`,
|
||||
);
|
||||
|
||||
@@ -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}`,
|
||||
)
|
||||
|
||||
@@ -2,7 +2,7 @@ import FullScreenLoading from "@components/ui/FullScreenLoading";
|
||||
import useFetchApi from "@utils/api";
|
||||
import React, { useMemo } from "react";
|
||||
import { Permission } from "@/interfaces/Permission";
|
||||
import { User } from "@/interfaces/User";
|
||||
import { Role, User } from "@/interfaces/User";
|
||||
|
||||
type Props = {
|
||||
children: React.ReactNode;
|
||||
@@ -40,8 +40,8 @@ export const useUsers = () => React.useContext(UsersContext);
|
||||
|
||||
export const useLoggedInUser = () => {
|
||||
const { loggedInUser } = useUsers();
|
||||
const isOwner = loggedInUser ? loggedInUser?.role === "owner" : false;
|
||||
const isAdmin = loggedInUser ? loggedInUser?.role === "admin" : false;
|
||||
const isOwner = loggedInUser ? loggedInUser?.role === Role.Owner : false;
|
||||
const isAdmin = loggedInUser ? loggedInUser?.role === Role.Admin : false;
|
||||
const isUser = !isOwner && !isAdmin;
|
||||
const isOwnerOrAdmin = isOwner || isAdmin;
|
||||
|
||||
|
||||
33
src/hooks/useExpirationState.tsx
Normal file
33
src/hooks/useExpirationState.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { TimeRange, useTimeFormatter } from "@hooks/useTimeFormatter";
|
||||
import { useState } from "react";
|
||||
|
||||
type Props = {
|
||||
enabled: boolean;
|
||||
expirationInSeconds: number;
|
||||
timeRange?: TimeRange;
|
||||
};
|
||||
export const useExpirationState = ({
|
||||
enabled,
|
||||
expirationInSeconds,
|
||||
timeRange = ["hours", "days"],
|
||||
}: Props) => {
|
||||
const [isEnabled, setIsEnabled] = useState(enabled);
|
||||
const [expiresInSeconds] = useState(expirationInSeconds || 86400);
|
||||
|
||||
const { value: seconds, time: unit } = useTimeFormatter(
|
||||
expiresInSeconds,
|
||||
timeRange,
|
||||
);
|
||||
|
||||
const [expiresIn, setExpiresIn] = useState(seconds);
|
||||
const [expireInterval, setExpireInterval] = useState<string>(unit);
|
||||
|
||||
return [
|
||||
isEnabled,
|
||||
setIsEnabled,
|
||||
expiresIn,
|
||||
setExpiresIn,
|
||||
expireInterval,
|
||||
setExpireInterval,
|
||||
] as const;
|
||||
};
|
||||
@@ -25,6 +25,7 @@ export function useSetupKeyPlaceholders() {
|
||||
expires_in: 0,
|
||||
usage_limit: null,
|
||||
ephemeral: randomBoolean(),
|
||||
allow_extra_dns_labels: randomBoolean(),
|
||||
} as SetupKey);
|
||||
}
|
||||
|
||||
|
||||
63
src/hooks/useTimeFormatter.tsx
Normal file
63
src/hooks/useTimeFormatter.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import { useMemo } from "react";
|
||||
|
||||
export type TimeUnit = "seconds" | "minutes" | "hours" | "days";
|
||||
export type TimeRange = TimeUnit[];
|
||||
|
||||
const TIME_CONVERSIONS: Record<string, number> = {
|
||||
seconds: 1,
|
||||
minutes: 60,
|
||||
hours: 3600,
|
||||
days: 86400,
|
||||
};
|
||||
|
||||
interface FormattedTime {
|
||||
value: string;
|
||||
time: TimeUnit | string;
|
||||
}
|
||||
|
||||
export const isValidTimeUnit = (unit: string): unit is TimeUnit => {
|
||||
return unit in TIME_CONVERSIONS;
|
||||
};
|
||||
|
||||
export const convertToSeconds = (
|
||||
value: string,
|
||||
unit: TimeUnit | string,
|
||||
): number => {
|
||||
if (!isValidTimeUnit(unit)) {
|
||||
console.warn(`Invalid time unit: ${unit}`);
|
||||
}
|
||||
return Math.round(parseFloat(value) * TIME_CONVERSIONS[unit]);
|
||||
};
|
||||
|
||||
export const useTimeFormatter = (
|
||||
seconds: number,
|
||||
range: TimeRange,
|
||||
): FormattedTime => {
|
||||
return useMemo(() => {
|
||||
const smallerUnit = range[0];
|
||||
const largestUnit = range[range.length - 1];
|
||||
const largestIndex = range.indexOf(largestUnit);
|
||||
|
||||
if (TIME_CONVERSIONS[smallerUnit] >= TIME_CONVERSIONS[largestUnit]) {
|
||||
console.warn("First unit must be smaller than second unit");
|
||||
}
|
||||
|
||||
if (seconds === TIME_CONVERSIONS.days && largestUnit === "days") {
|
||||
return { value: "24", time: "hours" };
|
||||
}
|
||||
|
||||
// Convert seconds to all units in range
|
||||
const converted = range.map((unit) => {
|
||||
const value = seconds / TIME_CONVERSIONS[unit];
|
||||
return {
|
||||
value: Number.isInteger(value) ? value.toString() : value.toFixed(2),
|
||||
time: unit,
|
||||
};
|
||||
});
|
||||
|
||||
const { value, time } =
|
||||
converted.reverse().find(({ value }) => parseFloat(value) >= 1) ||
|
||||
converted[largestIndex];
|
||||
return { value, time };
|
||||
}, [seconds, range]);
|
||||
};
|
||||
@@ -6,10 +6,13 @@ export interface Account {
|
||||
};
|
||||
peer_login_expiration_enabled: boolean;
|
||||
peer_login_expiration: number;
|
||||
peer_inactivity_expiration_enabled: boolean;
|
||||
peer_inactivity_expiration: number;
|
||||
groups_propagation_enabled: boolean;
|
||||
jwt_groups_enabled: boolean;
|
||||
jwt_groups_claim_name: string;
|
||||
jwt_allow_groups: string[];
|
||||
regular_users_view_blocked: boolean;
|
||||
routing_peer_dns_resolution_enabled: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3,6 +3,10 @@ export interface Group {
|
||||
name: string;
|
||||
peers?: GroupPeer[] | string[];
|
||||
peers_count?: number;
|
||||
resources?: GroupResource[] | string[];
|
||||
resources_count?: number;
|
||||
issued?: GroupIssued;
|
||||
|
||||
// Frontend only
|
||||
keepClientState?: boolean;
|
||||
}
|
||||
@@ -11,3 +15,14 @@ export interface GroupPeer {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface GroupResource {
|
||||
id: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
export enum GroupIssued {
|
||||
API = "api",
|
||||
INTEGRATION = "integration",
|
||||
JWT = "jwt",
|
||||
}
|
||||
|
||||
30
src/interfaces/Network.ts
Normal file
30
src/interfaces/Network.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
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;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface NetworkResource {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
address: string;
|
||||
groups?: string[] | Group[];
|
||||
type?: "domain" | "host" | "subnet";
|
||||
enabled: boolean;
|
||||
}
|
||||
@@ -16,11 +16,14 @@ export interface Peer {
|
||||
user?: User;
|
||||
ui_version?: string;
|
||||
dns_label: string;
|
||||
extra_dns_labels?: string[];
|
||||
last_login: Date;
|
||||
login_expired: boolean;
|
||||
login_expiration_enabled: boolean;
|
||||
inactivity_expiration_enabled: boolean;
|
||||
approval_required: boolean;
|
||||
city_name: string;
|
||||
country_code: string;
|
||||
connection_ip: string;
|
||||
serial_number: string;
|
||||
}
|
||||
|
||||
@@ -22,6 +22,13 @@ export interface PolicyRule {
|
||||
action: string;
|
||||
protocol: Protocol;
|
||||
ports: string[];
|
||||
sourceResource?: PolicyRuleResource;
|
||||
destinationResource?: PolicyRuleResource;
|
||||
}
|
||||
|
||||
export interface PolicyRuleResource {
|
||||
id: string;
|
||||
type: "domain" | "host" | "subnet" | undefined;
|
||||
}
|
||||
|
||||
export type Protocol = "all" | "tcp" | "udp" | "icmp";
|
||||
|
||||
@@ -66,47 +66,19 @@ export interface Process {
|
||||
}
|
||||
|
||||
export const windowsKernelVersions: SelectOption[] = [
|
||||
{ value: "5.0", label: "Windows 2000" },
|
||||
{ value: "5.1", label: "Windows XP" },
|
||||
{ value: "6.0", label: "Windows Vista" },
|
||||
{ value: "6.1", label: "Windows 7" },
|
||||
{ value: "6.2", label: "Windows 8" },
|
||||
{ value: "6.3", label: "Windows 8.1" },
|
||||
{ value: "10.0", label: "Windows 10" },
|
||||
{ value: "10.0.2", label: "Windows 11" },
|
||||
];
|
||||
|
||||
export const iOSVersions: SelectOption[] = [
|
||||
{ value: "1.0", label: "iPhone OS 1.x" },
|
||||
{ value: "2.0", label: "iPhone OS 2.x" },
|
||||
{ value: "3.0", label: "iPhone OS 3.x" },
|
||||
{ value: "4.0", label: "iOS 4.x" },
|
||||
{ value: "5.0", label: "iOS 5.x" },
|
||||
{ value: "6.0", label: "iOS 6.x" },
|
||||
{ value: "7.0", label: "iOS 7.x" },
|
||||
{ value: "8.0", label: "iOS 8.x" },
|
||||
{ value: "9.0", label: "iOS 9.x" },
|
||||
{ value: "10.0", label: "iOS 10.x" },
|
||||
{ value: "11.0", label: "iOS 11.x" },
|
||||
{ value: "12.0", label: "iOS 12.x" },
|
||||
{ value: "13.0", label: "iOS 13.x" },
|
||||
{ value: "14.0", label: "iOS 14.x" },
|
||||
{ value: "15.0", label: "iOS 15.x" },
|
||||
{ value: "16.0", label: "iOS 16.x" },
|
||||
{ value: "17.0", label: "iOS 17.x" },
|
||||
{ value: "18.0", label: "iOS 18.x" },
|
||||
];
|
||||
|
||||
export const macOSVersions: SelectOption[] = [
|
||||
{ value: "10.0", label: "Mac OS X Cheetah" },
|
||||
{ value: "10.1", label: "Mac OS X Puma" },
|
||||
{ value: "10.2", label: "Mac OS X Jaguar" },
|
||||
{ value: "10.3", label: "Mac OS X Panther" },
|
||||
{ value: "10.4", label: "Mac OS X Tiger" },
|
||||
{ value: "10.5", label: "Mac OS X Leopard" },
|
||||
{ value: "10.6", label: "Mac OS X Snow Leopard" },
|
||||
{ value: "10.7", label: "Mac OS X Lion" },
|
||||
{ value: "10.8", label: "OS X Mountain Lion" },
|
||||
{ value: "10.9", label: "OS X Mavericks" },
|
||||
{ value: "10.10", label: "OS X Yosemite" },
|
||||
{ value: "10.11", label: "OS X El Capitan" },
|
||||
{ value: "10.12", label: "macOS Sierra" },
|
||||
@@ -117,21 +89,10 @@ export const macOSVersions: SelectOption[] = [
|
||||
{ value: "12.0", label: "macOS Monterey" },
|
||||
{ value: "13.0", label: "macOS Ventura" },
|
||||
{ value: "14.0", label: "macOS Sonoma" },
|
||||
{ value: "15.0", label: "macOS Sequoia" },
|
||||
];
|
||||
|
||||
export const androidVersions: SelectOption[] = [
|
||||
{ value: "1.5", label: "Android Cupcake" },
|
||||
{ value: "1.6", label: "Android Donut" },
|
||||
{ value: "2.0", label: "Android Eclair" },
|
||||
{ value: "2.2", label: "Android Froyo" },
|
||||
{ value: "2.3", label: "Android Gingerbread" },
|
||||
{ value: "3.0", label: "Android Honeycomb" },
|
||||
{ value: "4.0", label: "Android Ice Cream Sandwich" },
|
||||
{ value: "4.1", label: "Android Jelly Bean" },
|
||||
{ value: "4.4", label: "Android KitKat" },
|
||||
{ value: "5.0", label: "Android Lollipop" },
|
||||
{ value: "6.0", label: "Android Marshmallow" },
|
||||
{ value: "7.0", label: "Android Nougat" },
|
||||
{ value: "8.0", label: "Android Oreo" },
|
||||
{ value: "9.0", label: "Android Pie" },
|
||||
{ value: "10", label: "Android 10" },
|
||||
@@ -140,4 +101,5 @@ export const androidVersions: SelectOption[] = [
|
||||
{ value: "13", label: "Android 13" },
|
||||
{ value: "14", label: "Android 14" },
|
||||
{ value: "15", label: "Android 15" },
|
||||
{ value: "16", label: "Android 16" },
|
||||
];
|
||||
|
||||
@@ -35,4 +35,5 @@ export interface GroupedRoute {
|
||||
description?: string;
|
||||
description_search?: string;
|
||||
domain_search?: string;
|
||||
routes_search?: string;
|
||||
}
|
||||
|
||||
@@ -16,4 +16,5 @@ export interface SetupKey {
|
||||
expires_in: number;
|
||||
usage_limit: number | null;
|
||||
ephemeral: boolean;
|
||||
allow_extra_dns_labels: boolean;
|
||||
}
|
||||
|
||||
@@ -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,16 @@ export function AccessControlModalContent({
|
||||
submit,
|
||||
isPostureChecksLoading,
|
||||
getPolicyData,
|
||||
} = useAccessControl({ policy, postureCheckTemplates, onSuccess });
|
||||
destinationResource,
|
||||
setDestinationResource,
|
||||
} = useAccessControl({
|
||||
policy,
|
||||
postureCheckTemplates,
|
||||
onSuccess,
|
||||
initialDestinationGroups,
|
||||
initialName,
|
||||
initialDescription,
|
||||
});
|
||||
|
||||
const [tab, setTab] = useState(() => {
|
||||
if (!cell) return "policy";
|
||||
@@ -152,9 +168,10 @@ export function AccessControlModalContent({
|
||||
});
|
||||
|
||||
const continuePostureChecksDisabled = useMemo(() => {
|
||||
if (sourceGroups.length == 0 || destinationGroups.length == 0) return true;
|
||||
if (direction != "bi" && ports.length == 0) return true;
|
||||
}, [sourceGroups, destinationGroups, direction, ports]);
|
||||
if (sourceGroups.length > 0 && destinationResource) return false;
|
||||
if (sourceGroups.length == 0 || destinationGroups.length == 0) return true;
|
||||
}, [sourceGroups, destinationGroups, direction, ports, destinationResource]);
|
||||
|
||||
const submitDisabled = useMemo(() => {
|
||||
if (name.length == 0) return true;
|
||||
@@ -267,6 +284,7 @@ export function AccessControlModalContent({
|
||||
onChange={setSourceGroups}
|
||||
values={sourceGroups}
|
||||
saveGroupAssignments={useSave}
|
||||
showResourceCounter={false}
|
||||
/>
|
||||
</div>
|
||||
<PolicyDirection
|
||||
@@ -289,6 +307,10 @@ export function AccessControlModalContent({
|
||||
onChange={setDestinationGroups}
|
||||
values={destinationGroups}
|
||||
saveGroupAssignments={useSave}
|
||||
resource={destinationResource}
|
||||
onResourceChange={setDestinationResource}
|
||||
showResources={true}
|
||||
placeholder={"Select destination(s)..."}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -377,9 +399,7 @@ export function AccessControlModalContent({
|
||||
<Paragraph className={"text-sm mt-auto"}>
|
||||
Learn more about
|
||||
<InlineLink
|
||||
href={
|
||||
"https://docs.netbird.io/how-to/routing-traffic-to-private-networks"
|
||||
}
|
||||
href={"https://docs.netbird.io/how-to/manage-network-access"}
|
||||
target={"_blank"}
|
||||
>
|
||||
Access Controls
|
||||
|
||||
@@ -32,6 +32,9 @@ export default function AccessControlActiveCell({ policy }: Readonly<Props>) {
|
||||
return group.id;
|
||||
}) as string[])
|
||||
: [];
|
||||
if (rule.destinationResource) {
|
||||
rule.destinations = null;
|
||||
}
|
||||
});
|
||||
|
||||
updatePolicy(
|
||||
|
||||
@@ -1,18 +1,49 @@
|
||||
import MultipleGroups from "@components/ui/MultipleGroups";
|
||||
import ResourceBadge from "@components/ui/ResourceBadge";
|
||||
import useFetchApi from "@utils/api";
|
||||
import React, { useMemo } from "react";
|
||||
import Skeleton from "react-loading-skeleton";
|
||||
import { Group } from "@/interfaces/Group";
|
||||
import { Policy } from "@/interfaces/Policy";
|
||||
import { NetworkResource } from "@/interfaces/Network";
|
||||
import { Policy, PolicyRuleResource } from "@/interfaces/Policy";
|
||||
|
||||
type Props = {
|
||||
policy: Policy;
|
||||
};
|
||||
export default function AccessControlDestinationsCell({ policy }: Props) {
|
||||
export default function AccessControlDestinationsCell({
|
||||
policy,
|
||||
}: Readonly<Props>) {
|
||||
const firstRule = useMemo(() => {
|
||||
if (policy.rules.length > 0) return policy.rules[0];
|
||||
return undefined;
|
||||
}, [policy]);
|
||||
|
||||
if (firstRule?.destinationResource) {
|
||||
return (
|
||||
<AccessControlDestinationResourceCell
|
||||
resource={firstRule.destinationResource}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return firstRule ? (
|
||||
<MultipleGroups groups={firstRule.destinations as Group[]} />
|
||||
) : null;
|
||||
}
|
||||
|
||||
const AccessControlDestinationResourceCell = ({
|
||||
resource,
|
||||
}: {
|
||||
resource: PolicyRuleResource;
|
||||
}) => {
|
||||
const { data: resources, isLoading } = useFetchApi<NetworkResource[]>(
|
||||
"/networks/resources",
|
||||
);
|
||||
if (isLoading) return <Skeleton height={35} width={"50%"} />;
|
||||
|
||||
return (
|
||||
<div className={"flex"}>
|
||||
<ResourceBadge resource={resources?.find((r) => r.id === resource.id)} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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,9 +112,15 @@ export const useAccessControl = ({
|
||||
setDestinationGroups,
|
||||
{ getGroupsToUpdate: getDestinationGroupsToUpdate },
|
||||
] = useGroupHelper({
|
||||
initial: firstRule ? (firstRule.destinations as Group[]) : [],
|
||||
initial: firstRule
|
||||
? (firstRule.destinations as Group[])
|
||||
: initialDestinationGroups ?? [],
|
||||
});
|
||||
|
||||
const [destinationResource, setDestinationResource] = useState(
|
||||
firstRule?.destinationResource,
|
||||
);
|
||||
|
||||
const { updateOrCreateAndNotify: checkToCreate } = usePostureCheck({});
|
||||
const createPostureChecksWithoutID = async () => {
|
||||
const checks = postureChecks.filter(
|
||||
@@ -136,7 +150,8 @@ export const useAccessControl = ({
|
||||
description,
|
||||
name,
|
||||
sources: sources,
|
||||
destinations: destinations,
|
||||
destinations: destinationResource ? undefined : destinations,
|
||||
destinationResource: destinationResource || undefined,
|
||||
action: "accept",
|
||||
protocol,
|
||||
enabled,
|
||||
@@ -204,7 +219,8 @@ export const useAccessControl = ({
|
||||
protocol,
|
||||
enabled,
|
||||
sources,
|
||||
destinations,
|
||||
destinations: destinationResource ? undefined : destinations,
|
||||
destinationResource: destinationResource || undefined,
|
||||
ports: ports.length > 0 ? ports.map((p) => p.toString()) : undefined,
|
||||
},
|
||||
],
|
||||
@@ -258,5 +274,7 @@ export const useAccessControl = ({
|
||||
getPolicyData,
|
||||
portAndDirectionDisabled,
|
||||
isPostureChecksLoading,
|
||||
destinationResource,
|
||||
setDestinationResource,
|
||||
} as const;
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -37,6 +37,22 @@ const ActivityFeedColumnsTable: ColumnDef<ActivityEvent>[] = [
|
||||
filterFn: "arrIncludesSomeExact",
|
||||
cell: ({ row }) => <ActivityEntryRow event={row.original} />,
|
||||
},
|
||||
{
|
||||
id: "activity_text",
|
||||
accessorFn: (event) => {
|
||||
try {
|
||||
if (event.meta) {
|
||||
return Object.keys(event.meta)
|
||||
.map((key) => {
|
||||
return `${event?.meta[key]}`;
|
||||
})
|
||||
.join(" ");
|
||||
}
|
||||
} catch (error) {
|
||||
return "";
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "timestamp",
|
||||
id: "timestamp",
|
||||
@@ -103,6 +119,7 @@ export default function ActivityTable({
|
||||
columnVisibility={{
|
||||
timestamp: false,
|
||||
name: false,
|
||||
activity_text: false,
|
||||
initiator_email: false,
|
||||
}}
|
||||
getStartedCard={
|
||||
|
||||
@@ -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)} />
|
||||
|
||||
@@ -26,7 +26,7 @@ export default function LastTimeRow({
|
||||
<TooltipTrigger>
|
||||
<div
|
||||
className={
|
||||
"flex items-center whitespace-nowrap 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"
|
||||
"flex items-center whitespace-nowrap 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-default"
|
||||
}
|
||||
>
|
||||
<>
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import { DropdownMenuItem } from "@components/DropdownMenu";
|
||||
import { Modal } from "@components/modal/Modal";
|
||||
import { getOperatingSystem } from "@hooks/useOperatingSystem";
|
||||
import { IconCirclePlus, IconDirectionSign } from "@tabler/icons-react";
|
||||
import * as React from "react";
|
||||
import { useState } from "react";
|
||||
import RoutesProvider from "@/contexts/RoutesProvider";
|
||||
import { OperatingSystem } from "@/interfaces/OperatingSystem";
|
||||
import { Peer } from "@/interfaces/Peer";
|
||||
import { useHasExitNodes } from "@/modules/exit-node/useHasExitNodes";
|
||||
import { RouteModalContent } from "@/modules/routes/RouteModal";
|
||||
@@ -16,10 +14,9 @@ type Props = {
|
||||
|
||||
export const ExitNodeDropdownButton = ({ peer }: Props) => {
|
||||
const [modal, setModal] = useState(false);
|
||||
const isLinux = getOperatingSystem(peer.os) === OperatingSystem.LINUX;
|
||||
const hasExitNodes = useHasExitNodes(peer);
|
||||
|
||||
return isLinux ? (
|
||||
return (
|
||||
<>
|
||||
<DropdownMenuItem onClick={() => setModal(true)}>
|
||||
<div className={"flex gap-3 items-center w-full"}>
|
||||
@@ -55,5 +52,5 @@ export const ExitNodeDropdownButton = ({ peer }: Props) => {
|
||||
)}
|
||||
</Modal>
|
||||
</>
|
||||
) : null;
|
||||
);
|
||||
};
|
||||
|
||||
@@ -124,6 +124,7 @@ export const AssignGroupToPeerModalContent = ({
|
||||
} as GroupPeer;
|
||||
}),
|
||||
peers_count: selectedPeers.length,
|
||||
resources: group.resources,
|
||||
keepClientState: true,
|
||||
});
|
||||
return;
|
||||
@@ -138,6 +139,7 @@ export const AssignGroupToPeerModalContent = ({
|
||||
{
|
||||
name: group.name,
|
||||
peers: selectedPeers.map((peer) => peer.id),
|
||||
resources: group.resources,
|
||||
},
|
||||
"/" + group?.id,
|
||||
);
|
||||
@@ -146,6 +148,7 @@ export const AssignGroupToPeerModalContent = ({
|
||||
groupRequest.post({
|
||||
name: group.name,
|
||||
peers: selectedPeers.map((peer) => peer.id),
|
||||
resources: group.resources,
|
||||
});
|
||||
}
|
||||
notify({
|
||||
@@ -367,6 +370,6 @@ const PeersTableColumns: ColumnDef<Peer>[] = [
|
||||
header: ({ column }) => {
|
||||
return <DataTableHeader column={column}>OS</DataTableHeader>;
|
||||
},
|
||||
cell: ({ row }) => <PeerOSCell os={row.original.os} />,
|
||||
cell: ({ row }) => <PeerOSCell os={row.original.os} serial={row.original.serial_number} />,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Checkbox } from "@components/Checkbox";
|
||||
import { CommandItem } from "@components/Command";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@components/Popover";
|
||||
import { ScrollArea } from "@components/ScrollArea";
|
||||
import { GroupBadgeIcon } from "@components/ui/GroupBadgeIcon";
|
||||
import TextWithTooltip from "@components/ui/TextWithTooltip";
|
||||
import { IconArrowBack } from "@tabler/icons-react";
|
||||
import { cn } from "@utils/helpers";
|
||||
@@ -171,10 +172,13 @@ export function GroupSelector({
|
||||
>
|
||||
<div
|
||||
className={
|
||||
"flex items-center gap-2 whitespace-nowrap text-sm"
|
||||
"flex items-center gap-2 whitespace-nowrap text-sm font-normal"
|
||||
}
|
||||
>
|
||||
<FolderGit2 size={13} className={"shrink-0"} />
|
||||
<GroupBadgeIcon
|
||||
id={item?.id}
|
||||
issued={item?.issued}
|
||||
/>
|
||||
<TextWithTooltip text={value} maxChars={15} />
|
||||
</div>
|
||||
<div
|
||||
|
||||
@@ -80,6 +80,7 @@ export default function useGroupHelper({ initial = [], peer }: Props) {
|
||||
return groupRequest.put(
|
||||
{
|
||||
name: g.name,
|
||||
resources: g.resources,
|
||||
peers: newPeerGroups
|
||||
? newPeerGroups.map((p) => {
|
||||
const groupPeer = p as GroupPeer;
|
||||
@@ -112,6 +113,7 @@ export default function useGroupHelper({ initial = [], peer }: Props) {
|
||||
{
|
||||
name: selectedGroup.name,
|
||||
peers: peers,
|
||||
resources: selectedGroup.resources,
|
||||
},
|
||||
`/${selectedGroup.id}`,
|
||||
);
|
||||
@@ -122,6 +124,7 @@ export default function useGroupHelper({ initial = [], peer }: Props) {
|
||||
.post({
|
||||
name: selectedGroup.name,
|
||||
peers: groupPeers || [],
|
||||
resources: selectedGroup.resources,
|
||||
})
|
||||
.then((group) => {
|
||||
setSelectedGroups((prev) => {
|
||||
|
||||
24
src/modules/groups/useGroupIdentification.ts
Normal file
24
src/modules/groups/useGroupIdentification.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { GroupIssued } from "@/interfaces/Group";
|
||||
|
||||
type Props = {
|
||||
id?: string;
|
||||
issued?: string;
|
||||
};
|
||||
|
||||
export const useGroupIdentification = ({ id, issued }: Props) => {
|
||||
const isJWTGroup = issued === GroupIssued.JWT;
|
||||
const isOktaGroup = !!id?.includes("okta");
|
||||
const isGoogleGroup = !!id?.includes("google");
|
||||
const isAzureGroup = !!id?.includes("azure");
|
||||
|
||||
const isRegularGroup =
|
||||
!isJWTGroup && !isOktaGroup && !isGoogleGroup && !isAzureGroup;
|
||||
|
||||
return {
|
||||
isOktaGroup,
|
||||
isGoogleGroup,
|
||||
isAzureGroup,
|
||||
isJWTGroup,
|
||||
isRegularGroup,
|
||||
};
|
||||
};
|
||||
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]);
|
||||
};
|
||||
167
src/modules/networks/NetworkModal.tsx
Normal file
167
src/modules/networks/NetworkModal.tsx
Normal file
@@ -0,0 +1,167 @@
|
||||
"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 internal resources in 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={"https://docs.netbird.io/how-to/networks"}
|
||||
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>
|
||||
);
|
||||
};
|
||||
376
src/modules/networks/NetworkProvider.tsx
Normal file
376
src/modules/networks/NetworkProvider.tsx
Normal file
@@ -0,0 +1,376 @@
|
||||
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 { ResourceGroupModal } from "@/modules/networks/resources/ResourceGroupModal";
|
||||
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;
|
||||
openResourceGroupModal: (
|
||||
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 [resourceGroupModal, setResourceGroupModal] = 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 openResourceGroupModal = (
|
||||
network: Network,
|
||||
resource?: NetworkResource,
|
||||
) => {
|
||||
setCurrentNetwork(network);
|
||||
resource && setCurrentResource(resource);
|
||||
setResourceGroupModal(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,
|
||||
openResourceGroupModal,
|
||||
openPolicyModal,
|
||||
deleteNetwork,
|
||||
deleteResource,
|
||||
deleteRouter,
|
||||
network,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
||||
<NetworkModal
|
||||
open={networkModal}
|
||||
setOpen={setNetworkModal}
|
||||
network={currentNetwork}
|
||||
onCreated={async (network) => {
|
||||
mutate("/networks");
|
||||
await askForResource(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={async (p) => {
|
||||
setPolicyModal(false);
|
||||
setPolicyDefaultSettings(undefined);
|
||||
mutate("/networks");
|
||||
if (network) {
|
||||
mutate(`/networks/${network.id}/resources`);
|
||||
mutate(`/networks/${network.id}`);
|
||||
} else {
|
||||
currentNetwork && (await askForRoutingPeer(currentNetwork));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</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}`);
|
||||
}
|
||||
}}
|
||||
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);
|
||||
}}
|
||||
/>
|
||||
|
||||
<ResourceGroupModal
|
||||
network={currentNetwork}
|
||||
resource={currentResource}
|
||||
open={resourceGroupModal}
|
||||
onOpenChange={(state) => {
|
||||
setCurrentResource(undefined);
|
||||
setResourceGroupModal(state);
|
||||
}}
|
||||
onUpdated={() => {
|
||||
setResourceGroupModal(false);
|
||||
setCurrentResource(undefined);
|
||||
mutate("/groups");
|
||||
if (network) {
|
||||
mutate(`/networks/${network.id}/resources`);
|
||||
mutate(`/networks/${network.id}`);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<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 TruncatedText from "@components/ui/TruncatedText";
|
||||
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"}>
|
||||
<TruncatedText
|
||||
className={cn(
|
||||
"font-medium text-white text-left",
|
||||
size == "md" ? "text-sm" : "text-xl leading-none mb-0.5",
|
||||
)}
|
||||
maxChars={24}
|
||||
text={name}
|
||||
/>
|
||||
<TruncatedText
|
||||
className={cn(
|
||||
"text-left text-sm text-nb-gray-400",
|
||||
size == "lg" && "text-md 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>
|
||||
);
|
||||
};
|
||||
26
src/modules/networks/misc/NetworkNavigation.tsx
Normal file
26
src/modules/networks/misc/NetworkNavigation.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import SidebarItem from "@components/SidebarItem";
|
||||
import { SmallBadge } from "@components/ui/SmallBadge";
|
||||
import * as React from "react";
|
||||
import NetworkRoutesIcon from "@/assets/icons/NetworkRoutesIcon";
|
||||
|
||||
export const NetworkNavigation = () => {
|
||||
return (
|
||||
<>
|
||||
<SidebarItem
|
||||
icon={<NetworkRoutesIcon />}
|
||||
label={
|
||||
<div className={"flex items-center gap-2"}>
|
||||
Networks
|
||||
<SmallBadge />
|
||||
</div>
|
||||
}
|
||||
href={"/networks"}
|
||||
/>
|
||||
<SidebarItem
|
||||
icon={<NetworkRoutesIcon />}
|
||||
href={"/network-routes"}
|
||||
label={"Network Routes"}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
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>
|
||||
);
|
||||
};
|
||||
235
src/modules/networks/resources/NetworkResourceModal.tsx
Normal file
235
src/modules/networks/resources/NetworkResourceModal.tsx
Normal file
@@ -0,0 +1,235 @@
|
||||
"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 Separator from "@components/Separator";
|
||||
import { Textarea } from "@components/Textarea";
|
||||
import { useApiCall } from "@utils/api";
|
||||
import {
|
||||
ExternalLinkIcon,
|
||||
PlusCircle,
|
||||
Power,
|
||||
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 [enabled, setEnabled] = useState<boolean>(
|
||||
resource ? resource.enabled : true,
|
||||
);
|
||||
|
||||
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),
|
||||
enabled,
|
||||
}).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),
|
||||
enabled,
|
||||
}).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>
|
||||
<div>
|
||||
<Label>Description (optional)</Label>
|
||||
<HelpText>
|
||||
Write a short description to add more context to this resource.
|
||||
</HelpText>
|
||||
<Textarea
|
||||
placeholder={"e.g., Production, Development"}
|
||||
value={description}
|
||||
rows={1}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ResourceSingleAddressInput value={address} onChange={setAddress} />
|
||||
|
||||
<div>
|
||||
<Label>Assigned Groups</Label>
|
||||
<HelpText>
|
||||
Add this resource to groups and use them as destinations when
|
||||
creating policies
|
||||
</HelpText>
|
||||
<PeerGroupSelector onChange={setGroups} values={groups} />
|
||||
</div>
|
||||
<div className={"mt-3"}>
|
||||
<FancyToggleSwitch
|
||||
value={enabled}
|
||||
onChange={setEnabled}
|
||||
label={
|
||||
<>
|
||||
<Power size={15} />
|
||||
Enable Resource
|
||||
</>
|
||||
}
|
||||
helpText={"Use this switch to enable or disable the resource."}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ModalFooter className={"items-center"}>
|
||||
<div className={"w-full"}>
|
||||
<Paragraph className={"text-sm mt-auto"}>
|
||||
Learn more about
|
||||
<InlineLink
|
||||
href={"https://docs.netbird.io/how-to/networks#resources"}
|
||||
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>
|
||||
);
|
||||
}
|
||||
61
src/modules/networks/resources/ResourceActionCell.tsx
Normal file
61
src/modules/networks/resources/ResourceActionCell.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import Button from "@components/Button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@components/DropdownMenu";
|
||||
import { MoreVertical, 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"}>
|
||||
<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={() => {
|
||||
if (!network) return;
|
||||
openResourceModal(network, resource);
|
||||
}}
|
||||
>
|
||||
<div className={"flex gap-3 items-center"}>
|
||||
<SquarePenIcon size={14} className={"shrink-0"} />
|
||||
Edit
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
if (!network) return;
|
||||
deleteResource(network, resource);
|
||||
}}
|
||||
variant={"danger"}
|
||||
>
|
||||
<div className={"flex gap-3 items-center"}>
|
||||
<Trash2 size={14} className={"shrink-0"} />
|
||||
Remove
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
58
src/modules/networks/resources/ResourceEnabledCell.tsx
Normal file
58
src/modules/networks/resources/ResourceEnabledCell.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
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 { Group } from "@/interfaces/Group";
|
||||
import { NetworkResource } from "@/interfaces/Network";
|
||||
import { useNetworksContext } from "@/modules/networks/NetworkProvider";
|
||||
|
||||
type Props = {
|
||||
resource: NetworkResource;
|
||||
};
|
||||
export const ResourceEnabledCell = ({ resource }: Props) => {
|
||||
const { mutate } = useSWRConfig();
|
||||
const { network } = useNetworksContext();
|
||||
|
||||
const update = useApiCall<NetworkResource>(
|
||||
`/networks/${network?.id}/resources/${resource?.id}`,
|
||||
).put;
|
||||
|
||||
const toggle = async (enabled: boolean) => {
|
||||
notify({
|
||||
title: `Update Resource`,
|
||||
description: `'${resource?.name}' is now ${
|
||||
enabled ? "enabled" : "disabled"
|
||||
}`,
|
||||
loadingMessage: "Updating resource...",
|
||||
duration: 1200,
|
||||
promise: update({
|
||||
...resource,
|
||||
groups: resource.groups
|
||||
?.map((g) => {
|
||||
let group = g as Group;
|
||||
return group.id;
|
||||
})
|
||||
.filter((g) => g !== undefined),
|
||||
enabled,
|
||||
}).then(() => {
|
||||
mutate(`/networks/${network?.id}/resources`);
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
const isChecked = useMemo(() => {
|
||||
return resource.enabled;
|
||||
}, [resource]);
|
||||
|
||||
return (
|
||||
<div className={"flex"}>
|
||||
<ToggleSwitch
|
||||
checked={isChecked}
|
||||
size={"small"}
|
||||
onClick={() => toggle(!isChecked)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
24
src/modules/networks/resources/ResourceGroupCell.tsx
Normal file
24
src/modules/networks/resources/ResourceGroupCell.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import MultipleGroups from "@components/ui/MultipleGroups";
|
||||
import * as React from "react";
|
||||
import { Group } from "@/interfaces/Group";
|
||||
import { NetworkResource } from "@/interfaces/Network";
|
||||
import { useNetworksContext } from "@/modules/networks/NetworkProvider";
|
||||
|
||||
type Props = {
|
||||
resource?: NetworkResource;
|
||||
};
|
||||
export const ResourceGroupCell = ({ resource }: Props) => {
|
||||
const { network, openResourceGroupModal } = useNetworksContext();
|
||||
|
||||
return (
|
||||
<button
|
||||
className={"flex cursor-pointer"}
|
||||
onClick={() => {
|
||||
if (!network) return;
|
||||
openResourceGroupModal(network, resource);
|
||||
}}
|
||||
>
|
||||
<MultipleGroups groups={resource?.groups as Group[]} />
|
||||
</button>
|
||||
);
|
||||
};
|
||||
121
src/modules/networks/resources/ResourceGroupModal.tsx
Normal file
121
src/modules/networks/resources/ResourceGroupModal.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
import Button from "@components/Button";
|
||||
import {
|
||||
Modal,
|
||||
ModalClose,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
} from "@components/modal/Modal";
|
||||
import ModalHeader from "@components/modal/ModalHeader";
|
||||
import { notify } from "@components/Notification";
|
||||
import { PeerGroupSelector } from "@components/PeerGroupSelector";
|
||||
import Separator from "@components/Separator";
|
||||
import { useApiCall } from "@utils/api";
|
||||
import { FolderGit2 } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { useMemo } from "react";
|
||||
import { Network, NetworkResource } from "@/interfaces/Network";
|
||||
import useGroupHelper from "@/modules/groups/useGroupHelper";
|
||||
|
||||
type ResourceGroupModalProps = {
|
||||
resource?: NetworkResource;
|
||||
network?: Network;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onUpdated?: (r: NetworkResource) => void;
|
||||
};
|
||||
export const ResourceGroupModal = ({
|
||||
resource,
|
||||
network,
|
||||
open,
|
||||
onOpenChange,
|
||||
onUpdated,
|
||||
}: ResourceGroupModalProps) => {
|
||||
return (
|
||||
<Modal open={open} onOpenChange={onOpenChange}>
|
||||
{network && resource && (
|
||||
<ResourceGroupModalContent
|
||||
network={network}
|
||||
resource={resource}
|
||||
onUpdated={onUpdated}
|
||||
key={open ? "1" : "0"}
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
type ModalProps = {
|
||||
onUpdated?: (r: NetworkResource) => void;
|
||||
network?: Network;
|
||||
resource?: NetworkResource;
|
||||
};
|
||||
|
||||
const ResourceGroupModalContent = ({
|
||||
resource,
|
||||
network,
|
||||
onUpdated,
|
||||
}: ModalProps) => {
|
||||
const update = useApiCall<NetworkResource>(
|
||||
`/networks/${network?.id}/resources/${resource?.id}`,
|
||||
).put;
|
||||
|
||||
const [groups, setGroups, { save: saveGroups }] = useGroupHelper({
|
||||
initial: resource?.groups || [],
|
||||
});
|
||||
|
||||
const updateResource = async () => {
|
||||
const savedGroups = await saveGroups();
|
||||
notify({
|
||||
title: "Update Resource",
|
||||
description: `'${resource?.name}' groups updated`,
|
||||
loadingMessage: "Updating resource groups...",
|
||||
promise: update({
|
||||
...resource,
|
||||
groups: savedGroups.map((g) => g.id),
|
||||
}).then((r) => {
|
||||
onUpdated?.(r);
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
const canSave = useMemo(() => {
|
||||
return groups.length > 0;
|
||||
}, [groups]);
|
||||
|
||||
return (
|
||||
<ModalContent maxWidthClass={"max-w-xl"}>
|
||||
<ModalHeader
|
||||
icon={<FolderGit2 size={18} />}
|
||||
title={"Assigned Groups"}
|
||||
description={
|
||||
"Add this resource to groups and use them as destinations when creating policies"
|
||||
}
|
||||
color={"blue"}
|
||||
/>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className={"px-8 py-6 flex flex-col gap-8"}>
|
||||
<div>
|
||||
<PeerGroupSelector onChange={setGroups} values={groups} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ModalFooter className={"items-center"}>
|
||||
<div className={"flex gap-3 w-full justify-end"}>
|
||||
<ModalClose asChild={true}>
|
||||
<Button variant={"secondary"}>Cancel</Button>
|
||||
</ModalClose>
|
||||
|
||||
<Button
|
||||
variant={"primary"}
|
||||
onClick={updateResource}
|
||||
disabled={!canSave}
|
||||
>
|
||||
Save Groups
|
||||
</Button>
|
||||
</div>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
);
|
||||
};
|
||||
53
src/modules/networks/resources/ResourceNameCell.tsx
Normal file
53
src/modules/networks/resources/ResourceNameCell.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import DescriptionWithTooltip from "@components/ui/DescriptionWithTooltip";
|
||||
import TextWithTooltip from "@components/ui/TextWithTooltip";
|
||||
import { cn } from "@utils/helpers";
|
||||
import { GlobeIcon, NetworkIcon, WorkflowIcon } from "lucide-react";
|
||||
import React from "react";
|
||||
import { NetworkResource } from "@/interfaces/Network";
|
||||
import { useNetworksContext } from "@/modules/networks/NetworkProvider";
|
||||
|
||||
type Props = {
|
||||
resource: NetworkResource;
|
||||
};
|
||||
|
||||
export default function ResourceNameCell({ resource }: Readonly<Props>) {
|
||||
const { network, openResourceModal } = useNetworksContext();
|
||||
|
||||
return (
|
||||
<button
|
||||
className={"flex gap-4 items-center group"}
|
||||
onClick={() => {
|
||||
if (!network) return;
|
||||
openResourceModal(network, resource);
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-center rounded-md h-9 w-9 shrink-0 bg-nb-gray-900 transition-all",
|
||||
"group-hover:bg-nb-gray-800",
|
||||
)}
|
||||
>
|
||||
{resource.type === "host" && <WorkflowIcon size={15} />}
|
||||
{resource.type === "domain" && <GlobeIcon size={15} />}
|
||||
{resource.type === "subnet" && <NetworkIcon size={15} />}
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col gap-0 text-neutral-300 font-light truncate",
|
||||
"group-hover:text-neutral-100 text-left",
|
||||
)}
|
||||
>
|
||||
<TextWithTooltip
|
||||
text={resource.name}
|
||||
maxChars={25}
|
||||
className={"font-normal"}
|
||||
/>
|
||||
<DescriptionWithTooltip
|
||||
maxChars={25}
|
||||
className={cn("font-normal mt-0.5 ")}
|
||||
text={resource.description}
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
104
src/modules/networks/resources/ResourcePolicyCell.tsx
Normal file
104
src/modules/networks/resources/ResourcePolicyCell.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
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 destinationResource = policy.rules
|
||||
?.map((rule) => rule?.destinationResource?.id === resource?.id)
|
||||
.some((id) => id);
|
||||
if (destinationResource) return true;
|
||||
const destinationPolicyGroups = policy.rules
|
||||
?.map((rule) => rule?.destinations)
|
||||
.flat() as Group[];
|
||||
const policyGroups = [...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-[100px]"}
|
||||
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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user