Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c0c1f4688e | ||
|
|
b5a8f751ba | ||
|
|
10a8e7b745 | ||
|
|
60e8394010 | ||
|
|
9420214059 | ||
|
|
b949f60afe | ||
|
|
d498e4cc25 | ||
|
|
130dc0c32c | ||
|
|
f5824d6ddb | ||
|
|
829395f908 |
20
package-lock.json
generated
20
package-lock.json
generated
@@ -3255,13 +3255,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": {
|
||||
"version": "9.0.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
|
||||
"integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
|
||||
"version": "9.0.9",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
|
||||
"integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^2.0.1"
|
||||
"brace-expansion": "^2.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16 || 14 >=14.17"
|
||||
@@ -3665,9 +3665,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/ajv": {
|
||||
"version": "6.12.6",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
|
||||
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
|
||||
"version": "6.14.0",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz",
|
||||
"integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.1",
|
||||
@@ -6973,9 +6973,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/minimatch": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
|
||||
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
||||
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^1.1.7"
|
||||
|
||||
@@ -25,6 +25,8 @@ export default function ProxyEventsPage() {
|
||||
() => ({
|
||||
start_date: dayjs().subtract(7, "day").startOf("day").toISOString(),
|
||||
end_date: dayjs().endOf("day").toISOString(),
|
||||
sort_by: "timestamp",
|
||||
sort_order: "desc",
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
} from "@components/DropdownMenu";
|
||||
import FullTooltip from "@components/FullTooltip";
|
||||
import InlineLink from "@components/InlineLink";
|
||||
import FullScreenLoading from "@components/ui/FullScreenLoading";
|
||||
import useRedirect from "@hooks/useRedirect";
|
||||
import useFetchApi from "@utils/api";
|
||||
import { cn, singularize } from "@utils/helpers";
|
||||
@@ -35,6 +34,7 @@ import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import { Network, NetworkResource, NetworkRouter } from "@/interfaces/Network";
|
||||
import PageContainer from "@/layouts/PageContainer";
|
||||
import { NetworkInformationSquare } from "@/modules/networks/misc/NetworkInformationSquare";
|
||||
import { NetworkAccessControlProvider } from "@/modules/networks/NetworkAccessControlProvider";
|
||||
import {
|
||||
NetworkProvider,
|
||||
useNetworksContext,
|
||||
@@ -49,6 +49,7 @@ import ReverseProxiesProvider, {
|
||||
flattenReverseProxies,
|
||||
useReverseProxies,
|
||||
} from "@/contexts/ReverseProxiesProvider";
|
||||
import { SkeletonNetwork } from "@components/skeletons/SkeletonNetwork";
|
||||
|
||||
export default function NetworkDetailPage() {
|
||||
const queryParameter = useSearchParams();
|
||||
@@ -65,7 +66,7 @@ export default function NetworkDetailPage() {
|
||||
<NetworkOverview network={network} />
|
||||
</ReverseProxiesProvider>
|
||||
) : (
|
||||
<FullScreenLoading />
|
||||
<SkeletonNetwork />
|
||||
);
|
||||
}
|
||||
|
||||
@@ -96,103 +97,103 @@ function NetworkOverview({ network }: Readonly<{ network: Network }>) {
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<NetworkProvider network={network}>
|
||||
<div className={"p-default py-6"}>
|
||||
<Breadcrumbs>
|
||||
<Breadcrumbs.Item
|
||||
href={"/networks"}
|
||||
label={"Networks"}
|
||||
disabled={!permission.networks.read}
|
||||
icon={<NetworkRoutesIcon size={13} />}
|
||||
/>
|
||||
<Breadcrumbs.Item
|
||||
href={"/network"}
|
||||
label={network.name}
|
||||
active={true}
|
||||
/>
|
||||
</Breadcrumbs>
|
||||
<NetworkAccessControlProvider>
|
||||
<NetworkProvider network={network}>
|
||||
<div className={"p-default py-6"}>
|
||||
<Breadcrumbs>
|
||||
<Breadcrumbs.Item
|
||||
href={"/networks"}
|
||||
label={"Networks"}
|
||||
disabled={!permission.networks.read}
|
||||
icon={<NetworkRoutesIcon size={13} />}
|
||||
/>
|
||||
<Breadcrumbs.Item
|
||||
href={"/network"}
|
||||
label={network.name}
|
||||
active={true}
|
||||
/>
|
||||
</Breadcrumbs>
|
||||
|
||||
<div className={"flex justify-between max-w-6xl"}>
|
||||
<div
|
||||
className={"w-full lg:w-1/2 flex justify-between items-center"}
|
||||
>
|
||||
<div className={"flex justify-between max-w-6xl"}>
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center w-full",
|
||||
!network.description && "gap-2",
|
||||
)}
|
||||
className={"w-full lg:w-1/2 flex justify-between items-center"}
|
||||
>
|
||||
<NetworkInformationSquare
|
||||
name={network.name}
|
||||
active={isActive}
|
||||
size={"lg"}
|
||||
description={network.description}
|
||||
/>
|
||||
</div>
|
||||
<NetworkProvider network={network}>
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center w-full",
|
||||
!network.description && "gap-2",
|
||||
)}
|
||||
>
|
||||
<NetworkInformationSquare
|
||||
name={network.name}
|
||||
active={isActive}
|
||||
size={"lg"}
|
||||
description={network.description}
|
||||
/>
|
||||
</div>
|
||||
<NetworkActions />
|
||||
</NetworkProvider>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={"flex gap-10 w-full mt-8 max-w-6xl items-start"}>
|
||||
<NetworkInformationCard network={network} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={"flex gap-10 w-full mt-8 max-w-6xl items-start"}>
|
||||
<NetworkInformationCard network={network} />
|
||||
</div>
|
||||
</div>
|
||||
<Tabs
|
||||
defaultValue={tab}
|
||||
onValueChange={setTab}
|
||||
value={tab}
|
||||
className={"pb-0 mb-0"}
|
||||
>
|
||||
<TabsList justify={"start"} className={"px-8"}>
|
||||
<TabsTrigger value={"resources"}>
|
||||
<Layers3Icon size={14} />
|
||||
{singularize("Resources", network?.resources?.length)}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value={"routing-peers"}>
|
||||
<PeerIcon
|
||||
size={12}
|
||||
className={
|
||||
"fill-nb-gray-500 group-data-[state=active]/trigger:fill-netbird transition-all"
|
||||
}
|
||||
/>
|
||||
{singularize("Routing Peers", network?.routing_peers_count)}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value={"services"}>
|
||||
<ReverseProxyIcon
|
||||
size={16}
|
||||
className={
|
||||
"fill-nb-gray-500 group-data-[state=active]/trigger:fill-netbird transition-all"
|
||||
}
|
||||
/>
|
||||
{singularize("Services", services.length)}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<Tabs
|
||||
defaultValue={tab}
|
||||
onValueChange={setTab}
|
||||
value={tab}
|
||||
className={"pb-0 mb-0"}
|
||||
>
|
||||
<TabsList justify={"start"} className={"px-8"}>
|
||||
<TabsTrigger value={"resources"}>
|
||||
<Layers3Icon size={14} />
|
||||
{singularize("Resources", network?.resources?.length)}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value={"routing-peers"}>
|
||||
<PeerIcon
|
||||
size={12}
|
||||
className={
|
||||
"fill-nb-gray-500 group-data-[state=active]/trigger:fill-netbird transition-all"
|
||||
}
|
||||
<TabsContent value={"resources"} className={"pb-8"}>
|
||||
<ResourcesTabContent
|
||||
data={resources}
|
||||
isLoading={isResourcesLoading}
|
||||
/>
|
||||
{singularize("Routing Peers", network?.routing_peers_count)}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value={"services"}>
|
||||
<ReverseProxyIcon
|
||||
size={16}
|
||||
className={
|
||||
"fill-nb-gray-500 group-data-[state=active]/trigger:fill-netbird transition-all"
|
||||
}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value={"routing-peers"} className={"pb-8"}>
|
||||
<NetworkRoutingPeersTabContent
|
||||
routers={routers}
|
||||
isLoading={isRoutersLoading}
|
||||
/>
|
||||
{singularize("Services", services.length)}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value={"resources"} className={"pb-8"}>
|
||||
<ResourcesTabContent
|
||||
data={resources}
|
||||
isLoading={isResourcesLoading}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value={"routing-peers"} className={"pb-8"}>
|
||||
<NetworkRoutingPeersTabContent
|
||||
routers={routers}
|
||||
isLoading={isRoutersLoading}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value={"services"} className={"pb-8"}>
|
||||
<ReverseProxyFlatTargetsTabContent
|
||||
targets={services}
|
||||
isLoading={isServicesLoading}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</NetworkProvider>
|
||||
<TabsContent value={"services"} className={"pb-8"}>
|
||||
<ReverseProxyFlatTargetsTabContent
|
||||
targets={services}
|
||||
isLoading={isServicesLoading}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</NetworkProvider>
|
||||
</NetworkAccessControlProvider>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,29 +1,68 @@
|
||||
import * as React from "react";
|
||||
import FullTooltip from "@components/FullTooltip";
|
||||
import { HelpCircle } from "lucide-react";
|
||||
import { cn } from "@utils/helpers";
|
||||
import { TooltipVariants } from "@components/Tooltip";
|
||||
|
||||
type Props = {
|
||||
content: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
children?: React.ReactNode;
|
||||
interactive?: boolean;
|
||||
};
|
||||
className?: string;
|
||||
triggerClassName?: string;
|
||||
align?: "start" | "center" | "end";
|
||||
side?: "top" | "right" | "bottom" | "left";
|
||||
alignOffset?: number;
|
||||
sideOffset?: number;
|
||||
iconSize?: number;
|
||||
delayDuration?: number;
|
||||
} & TooltipVariants;
|
||||
export const HelpTooltip = ({
|
||||
content,
|
||||
children,
|
||||
interactive = true,
|
||||
interactive = false,
|
||||
className,
|
||||
variant = "default",
|
||||
triggerClassName,
|
||||
align = "start",
|
||||
side = "top",
|
||||
alignOffset = 0,
|
||||
sideOffset,
|
||||
iconSize = 12,
|
||||
delayDuration = 300,
|
||||
}: Props) => {
|
||||
return (
|
||||
<>
|
||||
<FullTooltip
|
||||
interactive={interactive}
|
||||
side={"top"}
|
||||
align={"start"}
|
||||
alignOffset={0}
|
||||
side={side}
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
sideOffset={sideOffset}
|
||||
delayDuration={delayDuration}
|
||||
variant={variant}
|
||||
className={
|
||||
"inline underline decoration-dashed underline-offset-[3px] decoration-nb-gray-300 cursor-help transition-all hover:decoration-white"
|
||||
}
|
||||
content={content}
|
||||
content={
|
||||
<div className={cn("max-w-xs text-xs", className)}>{content}</div>
|
||||
}
|
||||
>
|
||||
{children}
|
||||
{children ? (
|
||||
children
|
||||
) : (
|
||||
<span
|
||||
className={cn(
|
||||
"p-2 -m-2 inline-flex items-center justify-center relative top-[1px] group/help",
|
||||
triggerClassName,
|
||||
)}
|
||||
>
|
||||
<HelpCircle
|
||||
size={iconSize}
|
||||
className={"text-nb-gray-300 group-hover/help:text-nb-gray-100"}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
</FullTooltip>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -6,24 +6,26 @@ export const ListItem = ({
|
||||
label,
|
||||
value,
|
||||
className,
|
||||
children,
|
||||
}: {
|
||||
icon?: React.ReactNode;
|
||||
label: string;
|
||||
value: string | React.ReactNode;
|
||||
className?: string;
|
||||
children?: React.ReactNode;
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex justify-between gap-12 border-b border-nb-gray-920 py-2 px-4 last:border-b-0",
|
||||
className,
|
||||
)}
|
||||
className={cn(" border-b border-nb-gray-920 last:border-b-0", className)}
|
||||
>
|
||||
<div className={"flex items-center gap-2 text-nb-gray-100 font-medium"}>
|
||||
{icon}
|
||||
{label}
|
||||
<div className={cn("flex justify-between gap-12 py-2 px-4")}>
|
||||
<div className={"flex items-center gap-2 text-nb-gray-100 font-medium"}>
|
||||
{icon}
|
||||
{label}
|
||||
</div>
|
||||
<div className={"text-nb-gray-300"}>{value}</div>
|
||||
</div>
|
||||
<div className={"text-nb-gray-300"}>{value}</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -18,6 +18,7 @@ export interface NotifyProps<T> {
|
||||
icon?: React.ReactNode;
|
||||
backgroundColor?: string;
|
||||
preventSuccessToast?: boolean;
|
||||
showOnlyError?: boolean;
|
||||
errorMessages?: ErrorResponse[];
|
||||
}
|
||||
|
||||
@@ -36,6 +37,7 @@ export default function Notification<T>({
|
||||
loadingMessage,
|
||||
duration = 3500,
|
||||
preventSuccessToast = false,
|
||||
showOnlyError = false,
|
||||
errorMessages,
|
||||
}: NotificationProps<T>) {
|
||||
const [error, setError] = useState("");
|
||||
@@ -49,10 +51,13 @@ export default function Notification<T>({
|
||||
const startTimer = useCallback(() => {
|
||||
if (timerRef.current) return;
|
||||
startTimeRef.current = Date.now();
|
||||
timerRef.current = setTimeout(() => {
|
||||
timerRef.current = null;
|
||||
toast.dismiss(toastId);
|
||||
}, Math.max(0, remainingRef.current));
|
||||
timerRef.current = setTimeout(
|
||||
() => {
|
||||
timerRef.current = null;
|
||||
toast.dismiss(toastId);
|
||||
},
|
||||
Math.max(0, remainingRef.current),
|
||||
);
|
||||
}, [toastId]);
|
||||
|
||||
const pauseTimer = useCallback(() => {
|
||||
@@ -88,7 +93,10 @@ export default function Notification<T>({
|
||||
}
|
||||
});
|
||||
|
||||
observer.observe(toastEl, { attributes: true, attributeFilter: ["data-expanded"] });
|
||||
observer.observe(toastEl, {
|
||||
attributes: true,
|
||||
attributeFilter: ["data-expanded"],
|
||||
});
|
||||
|
||||
// Start immediately if not expanded
|
||||
const expanded = toastEl.getAttribute("data-expanded") === "true";
|
||||
@@ -106,7 +114,7 @@ export default function Notification<T>({
|
||||
promise
|
||||
.then(() => {
|
||||
setLoading(false);
|
||||
if (preventSuccessToast) {
|
||||
if (showOnlyError || preventSuccessToast) {
|
||||
toast.dismiss(toastId);
|
||||
} else {
|
||||
setReadyToDismiss(true);
|
||||
@@ -136,6 +144,9 @@ export default function Notification<T>({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const hideUntilError = showOnlyError && loading && !error;
|
||||
if (hideUntilError) return null;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
ref={notificationRef}
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
MonitorSmartphoneIcon,
|
||||
NetworkIcon,
|
||||
SearchIcon,
|
||||
ShieldCheck,
|
||||
WorkflowIcon,
|
||||
} from "lucide-react";
|
||||
import * as React from "react";
|
||||
@@ -40,7 +41,7 @@ import { useElementSize } from "@/hooks/useElementSize";
|
||||
import type { Group, GroupPeer, GroupResource } from "@/interfaces/Group";
|
||||
import { NetworkResource } from "@/interfaces/Network";
|
||||
import type { Peer } from "@/interfaces/Peer";
|
||||
import { PolicyRuleResource } from "@/interfaces/Policy";
|
||||
import { Policy, PolicyRuleResource } from "@/interfaces/Policy";
|
||||
import { User } from "@/interfaces/User";
|
||||
import { HorizontalUsersStack } from "@/modules/users/HorizontalUsersStack";
|
||||
import { PeerOperatingSystemIcon } from "@/modules/peers/PeerOperatingSystemIcon";
|
||||
@@ -71,18 +72,20 @@ interface MultiSelectProps {
|
||||
showResourceCounter?: boolean;
|
||||
showResources?: boolean;
|
||||
showPeers?: boolean;
|
||||
showPeerCounter?: boolean;
|
||||
hideGroupsTab?: boolean;
|
||||
tabOrder?: ("groups" | "peers" | "resources")[];
|
||||
closeOnSelect?: boolean;
|
||||
resource?: PolicyRuleResource;
|
||||
onResourceChange?: (resource?: PolicyRuleResource) => void;
|
||||
placeholder?: string;
|
||||
placeholder?: React.ReactNode | string;
|
||||
customTrigger?: React.ReactNode;
|
||||
align?: "start" | "end";
|
||||
side?: "top" | "bottom";
|
||||
users?: User[];
|
||||
placeholderForSearch?: string;
|
||||
resourceIds?: string[];
|
||||
policies?: Policy[];
|
||||
}
|
||||
export function PeerGroupSelector({
|
||||
onChange,
|
||||
@@ -101,6 +104,7 @@ export function PeerGroupSelector({
|
||||
showResourceCounter = true,
|
||||
showResources = false,
|
||||
showPeers = false,
|
||||
showPeerCounter = true,
|
||||
hideGroupsTab = false,
|
||||
tabOrder,
|
||||
closeOnSelect = false,
|
||||
@@ -113,6 +117,7 @@ export function PeerGroupSelector({
|
||||
users,
|
||||
placeholderForSearch = 'Search groups or add new group by pressing "Enter"...',
|
||||
resourceIds,
|
||||
policies,
|
||||
}: Readonly<MultiSelectProps>) {
|
||||
const { data: resources, isLoading: isResourcesLoading } = useFetchApi<
|
||||
NetworkResource[]
|
||||
@@ -329,7 +334,7 @@ export function PeerGroupSelector({
|
||||
"min-h-[46px] w-full relative items-center group",
|
||||
"border border-neutral-200 dark:border-nb-gray-700 justify-between py-2 px-3",
|
||||
"rounded-md bg-white text-sm dark:bg-nb-gray-900/40 flex dark:text-neutral-400/70 text-neutral-500 cursor-pointer hover:dark:bg-nb-gray-900/50",
|
||||
"disabled:pointer-events-none disabled:opacity-30 transition-all",
|
||||
"disabled:pointer-events-none disabled:opacity-60 transition-all",
|
||||
)}
|
||||
disabled={disabled}
|
||||
data-cy={dataCy}
|
||||
@@ -343,7 +348,14 @@ export function PeerGroupSelector({
|
||||
{resource && (
|
||||
<ResourceBadge
|
||||
className={"py-[3px]"}
|
||||
resource={resources?.find((r) => r.id === resource.id)}
|
||||
resource={
|
||||
resources?.find((r) => r.id === resource.id) ??
|
||||
({
|
||||
id: resource.id,
|
||||
name: resource.id,
|
||||
type: resource.type,
|
||||
} as NetworkResource)
|
||||
}
|
||||
peer={peers?.find((p) => p.id === resource.id)}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
@@ -397,7 +409,9 @@ export function PeerGroupSelector({
|
||||
})}
|
||||
|
||||
{values.length == 0 && !resource && (
|
||||
<span className={"pl-1"}>{placeholder}</span>
|
||||
<span className={cn(typeof placeholder === "string" && "pl-1")}>
|
||||
{placeholder}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -567,12 +581,21 @@ export function PeerGroupSelector({
|
||||
<ResourcesCounter group={option} />
|
||||
)}
|
||||
|
||||
{policies && (
|
||||
<PolicyCounter
|
||||
group={option}
|
||||
policies={policies}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className={"flex gap-4 items-center"}>
|
||||
{!users ? (
|
||||
<PeerCounter
|
||||
group={option}
|
||||
showResourceCounter={showResourceCounter}
|
||||
/>
|
||||
showPeerCounter && (
|
||||
<PeerCounter
|
||||
group={option}
|
||||
showResourceCounter={showResourceCounter}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<UsersCounter
|
||||
group={option}
|
||||
@@ -788,6 +811,39 @@ const ResourcesCounter = ({ group }: { group: Group }) => {
|
||||
) : null;
|
||||
};
|
||||
|
||||
const PolicyCounter = ({
|
||||
group,
|
||||
policies,
|
||||
}: {
|
||||
group: Group;
|
||||
policies: Policy[];
|
||||
}) => {
|
||||
const count = useMemo(() => {
|
||||
if (!group.id) return 0;
|
||||
return policies.filter((policy) => {
|
||||
const destinations = policy.rules?.[0]?.destinations as
|
||||
| (Group | string)[]
|
||||
| undefined;
|
||||
return destinations?.some((d) =>
|
||||
typeof d === "string" ? d === group.id : d.id === group.id,
|
||||
);
|
||||
}).length;
|
||||
}, [group.id, policies]);
|
||||
|
||||
if (count === 0) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
"text-nb-gray-300 font-medium flex items-center gap-2 transition-all"
|
||||
}
|
||||
>
|
||||
<ShieldCheck size={14} className={"shrink-0"} />
|
||||
{count} {count === 1 ? "Policy" : "Policies"}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const resourcesSearchPredicate = (item: NetworkResource, query: string) => {
|
||||
const lowerCaseQuery = query.toLowerCase();
|
||||
if (item.name.toLowerCase().includes(lowerCaseQuery)) return true;
|
||||
|
||||
@@ -75,8 +75,10 @@ SelectLabel.displayName = SelectPrimitive.Label.displayName;
|
||||
|
||||
const SelectItem = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item> & {
|
||||
extra?: React.ReactNode;
|
||||
}
|
||||
>(({ className, children, extra, ...props }, ref) => (
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
@@ -92,6 +94,7 @@ const SelectItem = React.forwardRef<
|
||||
</span>
|
||||
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
{extra}
|
||||
</SelectPrimitive.Item>
|
||||
));
|
||||
SelectItem.displayName = SelectPrimitive.Item.displayName;
|
||||
|
||||
42
src/components/skeletons/SkeletonNetwork.tsx
Normal file
42
src/components/skeletons/SkeletonNetwork.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import * as React from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import Skeleton from "react-loading-skeleton";
|
||||
import SkeletonTable from "@components/skeletons/SkeletonTable";
|
||||
|
||||
export const SkeletonNetwork = ({ delay = 400 }: { delay?: number }) => {
|
||||
const [show, setShow] = useState(delay === 0);
|
||||
|
||||
useEffect(() => {
|
||||
if (delay === 0) return;
|
||||
const timer = setTimeout(() => setShow(true), delay);
|
||||
return () => clearTimeout(timer);
|
||||
}, [delay]);
|
||||
|
||||
if (!show) return null;
|
||||
|
||||
return (
|
||||
<div className={"p-default py-6 w-full"}>
|
||||
<Skeleton height={24} width={240} className={"mb-4"} />
|
||||
<div className={"mb-8 flex items-center gap-4"}>
|
||||
<Skeleton height={48} width={48} />
|
||||
<Skeleton height={20} width={200} />
|
||||
</div>
|
||||
<div className={"mb-4"}>
|
||||
<Skeleton height={106} className={"mb-2 w-full max-w-[574px]"} />
|
||||
</div>
|
||||
<div className={"flex items-center gap-4 mb-8"}>
|
||||
<Skeleton height={24} width={130} />
|
||||
<Skeleton height={24} width={130} />
|
||||
<Skeleton height={24} width={130} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Skeleton height={16} width={530} className={"w-full max-w-[530px]"} />
|
||||
<Skeleton height={16} width={430} className={"w-full max-w-[430px]"} />
|
||||
</div>
|
||||
<div className={"w-full"}>
|
||||
<SkeletonTable withHeader={false} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
20
src/components/skeletons/SkeletonSettings.tsx
Normal file
20
src/components/skeletons/SkeletonSettings.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import * as React from "react";
|
||||
import Skeleton from "react-loading-skeleton";
|
||||
|
||||
export const SkeletonSettings = () => {
|
||||
return (
|
||||
<div className={"p-default py-6 max-w-2xl"}>
|
||||
<Skeleton height={24} width={200} className={"mb-6"} />
|
||||
<Skeleton height={32} width={110} className={"mb-10"} />
|
||||
<div className={"mb-8"}>
|
||||
<Skeleton height={17} width={200} className={"mb-2"} />
|
||||
<Skeleton height={80} width={"100%"} />
|
||||
</div>
|
||||
<div className={"mb-8"}>
|
||||
<Skeleton height={17} width={200} className={"mb-2"} />
|
||||
<Skeleton height={80} width={"100%"} />
|
||||
</div>
|
||||
<Skeleton height={80} width={"100%"} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -296,6 +296,7 @@ export function DataTable<TData, TValue>({
|
||||
autoResetAll: false,
|
||||
autoResetExpanded: false,
|
||||
manualPagination: manualPagination,
|
||||
manualSorting: serverSidePagination,
|
||||
manualFiltering: manualFiltering || manualColumnFiltering,
|
||||
pageCount: pageCount,
|
||||
state: {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { IconSortAscending, IconSortDescending } from "@tabler/icons-react";
|
||||
import type { Column } from "@tanstack/table-core";
|
||||
import { cn } from "@utils/helpers";
|
||||
import React from "react";
|
||||
import { useOptionalServerPagination } from "@/contexts/ServerPaginationProvider";
|
||||
|
||||
type Props = {
|
||||
column: Column<any>;
|
||||
@@ -13,6 +14,7 @@ type Props = {
|
||||
center?: boolean;
|
||||
className?: string;
|
||||
sorting?: boolean;
|
||||
name?: string;
|
||||
};
|
||||
export default function DataTableHeader({
|
||||
children,
|
||||
@@ -21,15 +23,22 @@ export default function DataTableHeader({
|
||||
center,
|
||||
className,
|
||||
sorting = true,
|
||||
name,
|
||||
}: Props) {
|
||||
const serverPagination = useOptionalServerPagination();
|
||||
|
||||
const handleSort = () => {
|
||||
const direction = column.getIsSorted() === "asc" ? "desc" : "asc";
|
||||
column.toggleSorting(direction === "desc");
|
||||
if (name && serverPagination?.setSort) {
|
||||
serverPagination.setSort(name, direction);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<FullTooltip content={tooltip} disabled={!tooltip}>
|
||||
<div
|
||||
onClick={
|
||||
sorting
|
||||
? () => column.toggleSorting(column.getIsSorted() === "asc")
|
||||
: undefined
|
||||
}
|
||||
onClick={sorting ? handleSort : undefined}
|
||||
className={cn(
|
||||
"flex items-center whitespace-nowrap gap-2 dark:text-gray-400 transition-all select-none text-xs tracking-wide",
|
||||
sorting &&
|
||||
|
||||
@@ -26,6 +26,7 @@ type Props = {
|
||||
showResources?: boolean;
|
||||
redirectGroupTab?: string;
|
||||
showUsers?: boolean;
|
||||
disableRedirect?: boolean;
|
||||
};
|
||||
|
||||
export default function MultipleGroups({
|
||||
@@ -37,6 +38,7 @@ export default function MultipleGroups({
|
||||
showResources = false,
|
||||
showUsers = false,
|
||||
redirectGroupTab,
|
||||
disableRedirect = false,
|
||||
}: Readonly<Props>) {
|
||||
const { permission } = usePermissions();
|
||||
|
||||
@@ -64,6 +66,7 @@ export default function MultipleGroups({
|
||||
{firstGroup && (
|
||||
<GroupBadge
|
||||
group={firstGroup}
|
||||
showNewBadge={true}
|
||||
className={
|
||||
permission.groups.update ? "group-hover:bg-nb-gray-800" : ""
|
||||
}
|
||||
@@ -101,7 +104,7 @@ export default function MultipleGroups({
|
||||
return (
|
||||
group && (
|
||||
<div
|
||||
key={group.id}
|
||||
key={group?.id || group?.name}
|
||||
className={
|
||||
"flex gap-2 items-center justify-between w-full"
|
||||
}
|
||||
@@ -110,16 +113,23 @@ export default function MultipleGroups({
|
||||
group={group}
|
||||
className={"py-0"}
|
||||
textClassName={"py-1.5"}
|
||||
redirectToGroupPage={true}
|
||||
showNewBadge={true}
|
||||
redirectToGroupPage={!disableRedirect}
|
||||
redirectGroupTab={redirectGroupTab}
|
||||
></GroupBadge>
|
||||
<ArrowRightIcon size={14} />
|
||||
{showResources ? (
|
||||
<ResourceCountBadge group={group} />
|
||||
<ResourceCountBadge
|
||||
group={group}
|
||||
disableRedirect={disableRedirect}
|
||||
/>
|
||||
) : showUsers ? (
|
||||
<UserCountStack group={group} />
|
||||
) : (
|
||||
<PeerCountBadge group={group} />
|
||||
<PeerCountBadge
|
||||
group={group}
|
||||
disableRedirect={disableRedirect}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -10,6 +10,7 @@ import ResourceCountBadge from "@components/ui/ResourceCountBadge";
|
||||
|
||||
type Props = {
|
||||
group?: Group;
|
||||
disableRedirect?: boolean;
|
||||
} & React.HTMLAttributes<HTMLDivElement> &
|
||||
BadgeVariants;
|
||||
|
||||
@@ -17,6 +18,7 @@ export default function PeerCountBadge({
|
||||
group,
|
||||
variant = "gray",
|
||||
className,
|
||||
disableRedirect = false,
|
||||
}: Props) {
|
||||
const router = useRouter();
|
||||
const { dropdownOptions, groups } = useGroups();
|
||||
@@ -35,7 +37,8 @@ export default function PeerCountBadge({
|
||||
return peerCount;
|
||||
}, [currentGroup]);
|
||||
|
||||
const canRedirect = !!group?.id && group?.name !== "All";
|
||||
const canRedirect =
|
||||
!!group?.id && group?.name !== "All" && !disableRedirect;
|
||||
|
||||
const onClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
e.stopPropagation();
|
||||
@@ -46,7 +49,7 @@ export default function PeerCountBadge({
|
||||
const showResources = resourcesCount > 0 && peerCount === 0;
|
||||
|
||||
return showResources ? (
|
||||
<ResourceCountBadge group={group} />
|
||||
<ResourceCountBadge group={group} disableRedirect={disableRedirect} />
|
||||
) : (
|
||||
<Badge
|
||||
variant={variant}
|
||||
|
||||
@@ -7,15 +7,20 @@ import { Group } from "@/interfaces/Group";
|
||||
|
||||
type Props = {
|
||||
group?: Group;
|
||||
disableRedirect?: boolean;
|
||||
} & React.HTMLAttributes<HTMLDivElement> &
|
||||
BadgeVariants;
|
||||
|
||||
export default function ResourceCountBadge({ group }: Props) {
|
||||
export default function ResourceCountBadge({
|
||||
group,
|
||||
disableRedirect = false,
|
||||
}: Props) {
|
||||
const router = useRouter();
|
||||
const hasId = !!group?.id;
|
||||
|
||||
const onClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
e.stopPropagation();
|
||||
if (disableRedirect) return;
|
||||
if (hasId) router.push(`/group?id=${group?.id}&tab=resources`);
|
||||
};
|
||||
|
||||
|
||||
@@ -27,6 +27,8 @@ type DialogOptions = {
|
||||
type?: "default" | "warning" | "danger" | "center";
|
||||
children?: React.ReactNode;
|
||||
maxWidthClass?: string;
|
||||
hideIcon?: boolean;
|
||||
center?: boolean;
|
||||
};
|
||||
|
||||
export default function DialogProvider({ children }: Props) {
|
||||
@@ -70,14 +72,14 @@ export default function DialogProvider({ children }: Props) {
|
||||
onPointerDownOutside={(e) => e.preventDefault()}
|
||||
>
|
||||
<ModalHeader
|
||||
center={dialogOptions.type == "center"}
|
||||
center={dialogOptions.center ?? dialogOptions.type == "center"}
|
||||
title={dialogOptions.title || "Confirmation"}
|
||||
margin={"mt-1"}
|
||||
description={
|
||||
dialogOptions.description ||
|
||||
"Are you sure you want to continue? This action cannot be undone."
|
||||
}
|
||||
icon={dialogTypes[dialogOptions.type || "default"]}
|
||||
icon={dialogOptions.hideIcon ? "" : dialogTypes[dialogOptions.type || "default"]}
|
||||
color={
|
||||
dialogOptions.type == "default"
|
||||
? "blue"
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { Modal } from "@components/modal/Modal";
|
||||
import { notify } from "@components/Notification";
|
||||
import { useApiCall } from "@utils/api";
|
||||
import { cloneDeep } from "@utils/helpers";
|
||||
import React, { useState } from "react";
|
||||
import { useSWRConfig } from "swr";
|
||||
import { useGroups } from "@/contexts/GroupsProvider";
|
||||
import { Group } from "@/interfaces/Group";
|
||||
import { NetworkResource } from "@/interfaces/Network";
|
||||
import { Policy } from "@/interfaces/Policy";
|
||||
import { AccessControlModalContent } from "@/modules/access-control/AccessControlModal";
|
||||
|
||||
@@ -18,18 +23,116 @@ const PoliciesContext = React.createContext(
|
||||
message?: string,
|
||||
) => void;
|
||||
createPolicy: (policy: Policy) => Promise<Policy>;
|
||||
createPoliciesForResource: (
|
||||
policies: Policy[],
|
||||
resource: NetworkResource,
|
||||
) => Promise<void>;
|
||||
openEditPolicyModal: (policy: Policy, tab?: string) => void;
|
||||
deletePolicy: (policy: Policy, onSuccess?: () => void) => Promise<void>;
|
||||
serializeRules: (
|
||||
rules: Policy["rules"],
|
||||
enabled?: boolean,
|
||||
) => Policy["rules"];
|
||||
},
|
||||
);
|
||||
|
||||
export default function PoliciesProvider({ children }: Props) {
|
||||
const { mutate } = useSWRConfig();
|
||||
const request = useApiCall<Policy>("/policies");
|
||||
const { createOrUpdate: createOrUpdateGroup } = useGroups();
|
||||
const [policyModal, setPolicyModal] = useState(false);
|
||||
const [currentPolicy, setCurrentPolicy] = useState<Policy>();
|
||||
const [initialPolicyTab, setInitialPolicyTab] = useState("");
|
||||
|
||||
const createPolicy = async (policy: Policy) => request.post(policy);
|
||||
|
||||
const createPolicyForResource = async (
|
||||
policy: Policy,
|
||||
resource: NetworkResource,
|
||||
) => {
|
||||
const rule = policy.rules[0];
|
||||
|
||||
const sources = await Promise.all(
|
||||
(rule.sources ?? []).map((g) => {
|
||||
if (typeof g === "string") return g;
|
||||
if (g.id) return g.id;
|
||||
return createOrUpdateGroup(g).then((r) => r.id);
|
||||
}),
|
||||
).then((ids) => ids.filter(Boolean) as string[]);
|
||||
|
||||
const hasGroups = resource.groups && resource.groups.length > 0;
|
||||
|
||||
const destinations = hasGroups
|
||||
? await Promise.all(
|
||||
(resource.groups as (Group | string)[]).map((g) => {
|
||||
if (typeof g === "string") return g;
|
||||
if (g.id) return g.id;
|
||||
return createOrUpdateGroup(g).then((r) => r.id);
|
||||
}),
|
||||
).then((ids) => ids.filter(Boolean) as string[])
|
||||
: null;
|
||||
|
||||
return createPolicy({
|
||||
...policy,
|
||||
source_posture_checks: (policy.source_posture_checks ?? []).map((c) =>
|
||||
typeof c === "string" ? c : c.id,
|
||||
),
|
||||
rules: [
|
||||
{
|
||||
...rule,
|
||||
sources,
|
||||
destinations,
|
||||
destinationResource: hasGroups
|
||||
? undefined
|
||||
: { id: resource.id, type: resource.type },
|
||||
},
|
||||
],
|
||||
} as Policy);
|
||||
};
|
||||
|
||||
const createPoliciesForResource = async (
|
||||
newPolicies: Policy[],
|
||||
resource: NetworkResource,
|
||||
) => {
|
||||
const policiesToCreate = newPolicies.filter((p) => !p.id);
|
||||
if (policiesToCreate.length === 0) return;
|
||||
|
||||
const promise = Promise.all(
|
||||
policiesToCreate.map((p) => createPolicyForResource(p, resource)),
|
||||
).then(() => mutate("/policies"));
|
||||
|
||||
notify({
|
||||
title: "Create Policies",
|
||||
description: "Successfully created policies for resource.",
|
||||
promise,
|
||||
showOnlyError: true,
|
||||
});
|
||||
|
||||
return promise;
|
||||
};
|
||||
|
||||
const serializeRules = (rules: Policy["rules"], enabled?: boolean) => {
|
||||
rules = cloneDeep(rules);
|
||||
rules.forEach((rule) => {
|
||||
if (enabled !== undefined) rule.enabled = enabled;
|
||||
rule.sources = rule.sources
|
||||
? (rule.sources.map((s) => {
|
||||
const group = s as Group;
|
||||
return group.id ?? s;
|
||||
}) as string[])
|
||||
: [];
|
||||
rule.destinations = rule.destinations
|
||||
? (rule.destinations.map((d) => {
|
||||
const group = d as Group;
|
||||
return group.id ?? d;
|
||||
}) as string[])
|
||||
: [];
|
||||
if (rule.destinationResource) rule.destinations = null;
|
||||
if (rule.sourceResource) rule.sources = null;
|
||||
});
|
||||
return rules;
|
||||
};
|
||||
|
||||
const updatePolicy = async (
|
||||
policy: Policy,
|
||||
toUpdate: Partial<Policy>,
|
||||
@@ -62,6 +165,20 @@ export default function PoliciesProvider({ children }: Props) {
|
||||
});
|
||||
};
|
||||
|
||||
const deletePolicy = async (policy: Policy, onSuccess?: () => void) => {
|
||||
const promise = request.del("", `/${policy.id}`).then(() => {
|
||||
mutate("/policies");
|
||||
onSuccess?.();
|
||||
});
|
||||
notify({
|
||||
title: "Access Control Policy " + policy.name,
|
||||
description: "The policy was successfully deleted.",
|
||||
promise,
|
||||
loadingMessage: "Deleting policy...",
|
||||
});
|
||||
return promise;
|
||||
};
|
||||
|
||||
const openEditPolicyModal = (policy: Policy, tab?: string) => {
|
||||
setCurrentPolicy(policy);
|
||||
tab && setInitialPolicyTab(tab);
|
||||
@@ -70,7 +187,14 @@ export default function PoliciesProvider({ children }: Props) {
|
||||
|
||||
return (
|
||||
<PoliciesContext.Provider
|
||||
value={{ updatePolicy, createPolicy, openEditPolicyModal }}
|
||||
value={{
|
||||
updatePolicy,
|
||||
createPolicy,
|
||||
createPoliciesForResource,
|
||||
openEditPolicyModal,
|
||||
deletePolicy,
|
||||
serializeRules,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
<Modal
|
||||
|
||||
@@ -31,6 +31,7 @@ type ServerPaginationContextValue<T = unknown> = {
|
||||
onGlobalFilterChange: (value: string) => void;
|
||||
setFilter: (key: string, value: string | undefined) => void;
|
||||
getFilter: (key: string) => string | undefined;
|
||||
setSort: (name: string, direction: "asc" | "desc") => void;
|
||||
hasActiveFilters: boolean;
|
||||
resetFilters: () => void;
|
||||
onFilterReset: () => void;
|
||||
@@ -146,6 +147,15 @@ export default function ServerPaginationProvider({
|
||||
|
||||
const getFilter = useCallback((key: string) => filters[key], [filters]);
|
||||
|
||||
const setSort = useCallback((name: string, direction: "asc" | "desc") => {
|
||||
setFilters((prev) => ({
|
||||
...prev,
|
||||
sort_by: name,
|
||||
sort_order: direction,
|
||||
}));
|
||||
setPage(1);
|
||||
}, []);
|
||||
|
||||
const hasActiveFilters =
|
||||
search !== "" ||
|
||||
Object.entries(filters).some(
|
||||
@@ -170,6 +180,7 @@ export default function ServerPaginationProvider({
|
||||
mutate,
|
||||
setFilter,
|
||||
getFilter,
|
||||
setSort,
|
||||
hasActiveFilters,
|
||||
resetFilters,
|
||||
pagination: { pageIndex: page - 1, pageSize },
|
||||
@@ -193,6 +204,7 @@ export default function ServerPaginationProvider({
|
||||
mutate,
|
||||
setFilter,
|
||||
getFilter,
|
||||
setSort,
|
||||
hasActiveFilters,
|
||||
resetFilters,
|
||||
page,
|
||||
@@ -220,3 +232,8 @@ export function useServerPagination<T>() {
|
||||
}
|
||||
return context as ServerPaginationContextValue<T>;
|
||||
}
|
||||
|
||||
export function useOptionalServerPagination<T>() {
|
||||
const context = useContext(ServerPaginationContext);
|
||||
return context as ServerPaginationContextValue<T> | null;
|
||||
}
|
||||
|
||||
@@ -28,14 +28,30 @@ const UserProfileContext = React.createContext(
|
||||
);
|
||||
|
||||
export default function UsersProvider({ children }: Readonly<Props>) {
|
||||
const { data: users, mutate, isLoading } = useFetchApi<User[]>("/users");
|
||||
const { data: users, mutate, isLoading } = useFetchApi<User[]>(
|
||||
"/users?service_user=false",
|
||||
);
|
||||
const { data: serviceUsers, mutate: mutateServiceUsers, isLoading: isLoadingServiceUsers } = useFetchApi<
|
||||
User[]
|
||||
>("/users?service_user=true");
|
||||
|
||||
const refresh = () => {
|
||||
mutate().then();
|
||||
mutateServiceUsers().then();
|
||||
};
|
||||
|
||||
const allUsers = useMemo(() => {
|
||||
return [...(users ?? []), ...(serviceUsers ?? [])];
|
||||
}, [users, serviceUsers]);
|
||||
|
||||
return (
|
||||
<UsersContext.Provider value={{ users, refresh, isLoading }}>
|
||||
<UsersContext.Provider
|
||||
value={{
|
||||
users: allUsers,
|
||||
refresh,
|
||||
isLoading: isLoading || isLoadingServiceUsers,
|
||||
}}
|
||||
>
|
||||
<UserProfileProvider>{children}</UserProfileProvider>
|
||||
</UsersContext.Provider>
|
||||
);
|
||||
|
||||
@@ -1,26 +1,37 @@
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
export default function useUrlTab(
|
||||
validTabs: string[],
|
||||
defaultTab: string,
|
||||
): [string, (value: string) => void] {
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
|
||||
const tab = useMemo(() => {
|
||||
const tabParam = searchParams.get("tab");
|
||||
if (tabParam && validTabs.includes(tabParam)) return tabParam;
|
||||
return defaultTab;
|
||||
}, [searchParams, validTabs, defaultTab]);
|
||||
const getTab = useCallback(
|
||||
(params: URLSearchParams) => {
|
||||
const tabParam = params.get("tab");
|
||||
if (tabParam && validTabs.includes(tabParam)) return tabParam;
|
||||
return defaultTab;
|
||||
},
|
||||
[validTabs, defaultTab],
|
||||
);
|
||||
|
||||
const [tab, setTabState] = useState(() => getTab(searchParams));
|
||||
|
||||
useEffect(() => {
|
||||
const newTab = getTab(searchParams);
|
||||
setTabState(newTab);
|
||||
}, [searchParams, getTab]);
|
||||
|
||||
const setTab = useCallback(
|
||||
(value: string) => {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.set("tab", value);
|
||||
router.replace(`?${params.toString()}`, { scroll: false });
|
||||
const nextTab = validTabs.includes(value) ? value : defaultTab;
|
||||
setTabState(nextTab);
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
params.set("tab", nextTab);
|
||||
window.history.replaceState(null, "", `?${params.toString()}`);
|
||||
},
|
||||
[searchParams, router],
|
||||
[validTabs, defaultTab],
|
||||
);
|
||||
|
||||
return [tab, setTab];
|
||||
|
||||
@@ -10,6 +10,8 @@ export interface Account {
|
||||
user_approval_required: boolean;
|
||||
};
|
||||
peer_login_expiration_enabled: boolean;
|
||||
peer_expose_enabled?: boolean;
|
||||
peer_expose_groups?: string[];
|
||||
peer_login_expiration: number;
|
||||
peer_inactivity_expiration_enabled: boolean;
|
||||
peer_inactivity_expiration: number;
|
||||
|
||||
@@ -26,6 +26,15 @@ export enum ReverseProxyStatus {
|
||||
ERROR = "error",
|
||||
}
|
||||
|
||||
export type ServiceTargetOptionsPathRewrite = "preserve";
|
||||
|
||||
export interface ServiceTargetOptions {
|
||||
skip_tls_verify?: boolean;
|
||||
request_timeout?: string;
|
||||
path_rewrite?: ServiceTargetOptionsPathRewrite;
|
||||
custom_headers?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface ReverseProxyTarget {
|
||||
target_id?: string;
|
||||
target_type: ReverseProxyTargetType;
|
||||
@@ -35,6 +44,7 @@ export interface ReverseProxyTarget {
|
||||
port: number;
|
||||
enabled: boolean;
|
||||
access_local?: boolean;
|
||||
options?: ServiceTargetOptions;
|
||||
// Frontend
|
||||
destination?: string;
|
||||
}
|
||||
|
||||
@@ -54,6 +54,7 @@ import { PostureCheckTabTrigger } from "@/modules/posture-checks/ui/PostureCheck
|
||||
import { SSHAccessType } from "@/modules/access-control/ssh/SSHAccessType";
|
||||
import { SSHAuthorizedGroups } from "@/modules/access-control/ssh/SSHAuthorizedGroups";
|
||||
import { useUsers } from "@/contexts/UsersProvider";
|
||||
import { HelpTooltip } from "@components/HelpTooltip";
|
||||
|
||||
type Props = {
|
||||
children?: React.ReactNode;
|
||||
@@ -124,6 +125,7 @@ type ModalProps = {
|
||||
initialPorts?: number[];
|
||||
initialDestinationResource?: PolicyRuleResource;
|
||||
initialTab?: string;
|
||||
disableDestinationSelector?: boolean;
|
||||
};
|
||||
|
||||
export function AccessControlModalContent({
|
||||
@@ -140,6 +142,7 @@ export function AccessControlModalContent({
|
||||
initialPorts,
|
||||
initialDestinationResource,
|
||||
initialTab,
|
||||
disableDestinationSelector = false,
|
||||
}: Readonly<ModalProps>) {
|
||||
const { permission } = usePermissions();
|
||||
const { users } = useUsers();
|
||||
@@ -293,7 +296,25 @@ export function AccessControlModalContent({
|
||||
<SelectItem value="tcp">TCP</SelectItem>
|
||||
<SelectItem value="udp">UDP</SelectItem>
|
||||
<SelectItem value="icmp">ICMP</SelectItem>
|
||||
<SelectItem value="netbird-ssh">NetBird SSH</SelectItem>
|
||||
<SelectItem
|
||||
value="netbird-ssh"
|
||||
extra={
|
||||
<HelpTooltip
|
||||
triggerClassName={"ml-[0.01rem]"}
|
||||
align={"center"}
|
||||
side={"right"}
|
||||
content={
|
||||
<>
|
||||
Select NetBird SSH for SSH-specific policies with
|
||||
fine-grained access control, or use TCP with port 22
|
||||
for basic network-level SSH access
|
||||
</>
|
||||
}
|
||||
/>
|
||||
}
|
||||
>
|
||||
NetBird SSH
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
@@ -303,6 +324,15 @@ export function AccessControlModalContent({
|
||||
<Label className={"mb-2"}>
|
||||
<FolderDown size={15} />
|
||||
Source
|
||||
<HelpTooltip
|
||||
content={
|
||||
<>
|
||||
Typically a group of user devices (e.g., Developers,
|
||||
Marketing) or individual devices in peer-to-peer
|
||||
connections that will access the destination.
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</Label>
|
||||
<PeerGroupSelector
|
||||
dataCy={"source-group-selector"}
|
||||
@@ -337,6 +367,15 @@ export function AccessControlModalContent({
|
||||
<Label className={"mb-2"}>
|
||||
<FolderInput size={15} />
|
||||
Destination
|
||||
<HelpTooltip
|
||||
content={
|
||||
<>
|
||||
Typically a group of peers or resources (e.g., Servers,
|
||||
Databases, Internal Services) that will be accessed by
|
||||
the source. Can also be an individual peer or resource.
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</Label>
|
||||
<PeerGroupSelector
|
||||
dataCy={"destination-group-selector"}
|
||||
@@ -354,7 +393,9 @@ export function AccessControlModalContent({
|
||||
onResourceChange={setDestinationResource}
|
||||
saveGroupAssignments={useSave}
|
||||
disabled={
|
||||
!permission.policies.update || !permission.policies.create
|
||||
disableDestinationSelector ||
|
||||
!permission.policies.update ||
|
||||
!permission.policies.create
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
@@ -575,7 +616,13 @@ export function AccessControlModalContent({
|
||||
<Button
|
||||
variant={"primary"}
|
||||
disabled={submitDisabled || !permission.policies.create}
|
||||
onClick={submit}
|
||||
onClick={() => {
|
||||
if (useSave) {
|
||||
submit();
|
||||
} else {
|
||||
close();
|
||||
}
|
||||
}}
|
||||
data-cy={"submit-policy"}
|
||||
>
|
||||
<PlusCircle size={16} />
|
||||
|
||||
@@ -1,33 +1,19 @@
|
||||
import Button from "@components/Button";
|
||||
import { notify } from "@components/Notification";
|
||||
import { useApiCall } from "@utils/api";
|
||||
import { Trash2 } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { useSWRConfig } from "swr";
|
||||
import { useDialog } from "@/contexts/DialogProvider";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import { Policy } from "@/interfaces/Policy";
|
||||
import { Route } from "@/interfaces/Route";
|
||||
import { usePolicies } from "@/contexts/PoliciesProvider";
|
||||
|
||||
type Props = {
|
||||
policy: Policy;
|
||||
};
|
||||
|
||||
export default function AccessControlActionCell({ policy }: Readonly<Props>) {
|
||||
const { confirm } = useDialog();
|
||||
const policyRequest = useApiCall<Route>("/policies");
|
||||
const { mutate } = useSWRConfig();
|
||||
const { permission } = usePermissions();
|
||||
|
||||
const deleteRule = async () => {
|
||||
notify({
|
||||
title: "Access Control Policy " + policy.name,
|
||||
description: "The policy was successfully removed.",
|
||||
promise: policyRequest.del("", `/${policy.id}`).then(() => {
|
||||
mutate("/policies");
|
||||
}),
|
||||
loadingMessage: "Deleting the policy...",
|
||||
});
|
||||
};
|
||||
const { deletePolicy } = usePolicies();
|
||||
|
||||
const openConfirm = async () => {
|
||||
const choice = await confirm({
|
||||
@@ -39,7 +25,7 @@ export default function AccessControlActionCell({ policy }: Readonly<Props>) {
|
||||
type: "danger",
|
||||
});
|
||||
if (!choice) return;
|
||||
deleteRule().then();
|
||||
await deletePolicy(policy);
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,17 +1,15 @@
|
||||
import { ToggleSwitch } from "@components/ToggleSwitch";
|
||||
import { cloneDeep } from "@utils/helpers";
|
||||
import React, { useMemo } from "react";
|
||||
import { mutate } from "swr";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import { usePolicies } from "@/contexts/PoliciesProvider";
|
||||
import { Group } from "@/interfaces/Group";
|
||||
import { Policy } from "@/interfaces/Policy";
|
||||
|
||||
type Props = {
|
||||
policy: Policy;
|
||||
};
|
||||
export default function AccessControlActiveCell({ policy }: Readonly<Props>) {
|
||||
const { updatePolicy } = usePolicies();
|
||||
const { updatePolicy, serializeRules } = usePolicies();
|
||||
const { permission } = usePermissions();
|
||||
|
||||
const isChecked = useMemo(() => {
|
||||
@@ -19,32 +17,9 @@ export default function AccessControlActiveCell({ policy }: Readonly<Props>) {
|
||||
}, [policy]);
|
||||
|
||||
const update = async (enabled: boolean) => {
|
||||
const rules = cloneDeep(policy.rules);
|
||||
rules.forEach((rule) => {
|
||||
rule.enabled = enabled;
|
||||
rule.sources = rule.sources
|
||||
? (rule.sources.map((source) => {
|
||||
const group = source as Group;
|
||||
return group.id;
|
||||
}) as string[])
|
||||
: [];
|
||||
rule.destinations = rule.destinations
|
||||
? (rule.destinations.map((destination) => {
|
||||
const group = destination as Group;
|
||||
return group.id;
|
||||
}) as string[])
|
||||
: [];
|
||||
if (rule.destinationResource) {
|
||||
rule.destinations = null;
|
||||
}
|
||||
if (rule.sourceResource) {
|
||||
rule.sources = null;
|
||||
}
|
||||
});
|
||||
|
||||
updatePolicy(
|
||||
policy,
|
||||
{ enabled, rules },
|
||||
{ enabled, rules: serializeRules(policy.rules, enabled) },
|
||||
() => {
|
||||
mutate("/policies");
|
||||
},
|
||||
|
||||
@@ -11,9 +11,13 @@ import { parsePortsToStrings } from "@/modules/access-control/useAccessControl";
|
||||
|
||||
type Props = {
|
||||
policy: Policy;
|
||||
visiblePorts?: number;
|
||||
};
|
||||
|
||||
export default function AccessControlPortsCell({ policy }: Readonly<Props>) {
|
||||
export default function AccessControlPortsCell({
|
||||
policy,
|
||||
visiblePorts = 2,
|
||||
}: Readonly<Props>) {
|
||||
const rule = useMemo(() => {
|
||||
if (policy.rules.length > 0) return policy.rules[0];
|
||||
return undefined;
|
||||
@@ -25,13 +29,13 @@ export default function AccessControlPortsCell({ policy }: Readonly<Props>) {
|
||||
|
||||
const allPorts = useMemo(() => parsePortsToStrings(rule), [rule]);
|
||||
|
||||
const firstTwoPorts = useMemo(() => {
|
||||
return allPorts?.slice(0, 2) ?? [];
|
||||
}, [allPorts]);
|
||||
const visiblePortsList = useMemo(() => {
|
||||
return allPorts?.slice(0, visiblePorts) ?? [];
|
||||
}, [allPorts, visiblePorts]);
|
||||
|
||||
const otherPorts = useMemo(() => {
|
||||
return allPorts?.slice(2) ?? [];
|
||||
}, [allPorts]);
|
||||
return allPorts?.slice(visiblePorts) ?? [];
|
||||
}, [allPorts, visiblePorts]);
|
||||
|
||||
return (
|
||||
<div className={"flex-1"}>
|
||||
@@ -48,7 +52,7 @@ export default function AccessControlPortsCell({ policy }: Readonly<Props>) {
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{firstTwoPorts?.map((port) => {
|
||||
{visiblePortsList?.map((port) => {
|
||||
return (
|
||||
<Badge
|
||||
key={port}
|
||||
@@ -75,12 +79,8 @@ export default function AccessControlPortsCell({ policy }: Readonly<Props>) {
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
{otherPorts && otherPorts.length > 0 && (
|
||||
<TooltipContent>
|
||||
<div
|
||||
className={
|
||||
"flex gap-2 items-start mt-3 mb-2 flex-wrap max-w-sm"
|
||||
}
|
||||
>
|
||||
<TooltipContent className={"p-3"}>
|
||||
<div className={"flex gap-2 items-start flex-wrap max-w-sm"}>
|
||||
{otherPorts.map((port) => {
|
||||
return (
|
||||
<Badge key={port} variant={"gray"}>
|
||||
|
||||
@@ -11,9 +11,15 @@ import EmptyRow from "@/modules/common-table-rows/EmptyRow";
|
||||
|
||||
type Props = {
|
||||
policy: Policy;
|
||||
hideEdit?: boolean;
|
||||
disableRedirect?: boolean;
|
||||
};
|
||||
|
||||
export default function AccessControlSourcesCell({ policy }: Props) {
|
||||
export default function AccessControlSourcesCell({
|
||||
policy,
|
||||
hideEdit = false,
|
||||
disableRedirect = false,
|
||||
}: Props) {
|
||||
const { permission } = usePermissions();
|
||||
const canUpdate = permission?.policies?.update;
|
||||
|
||||
@@ -27,12 +33,18 @@ export default function AccessControlSourcesCell({ policy }: Props) {
|
||||
}
|
||||
|
||||
return firstRule ? (
|
||||
<div className={cn("flex items-center gap-1", canUpdate && "group")}>
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-1",
|
||||
canUpdate && !hideEdit && "group",
|
||||
)}
|
||||
>
|
||||
<MultipleGroups
|
||||
groups={firstRule.sources as Group[]}
|
||||
showUsers={firstRule.protocol === "netbird-ssh"}
|
||||
disableRedirect={disableRedirect}
|
||||
/>
|
||||
{canUpdate && <TransparentEditIconButton />}
|
||||
{canUpdate && !hideEdit && <TransparentEditIconButton />}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyRow />
|
||||
|
||||
@@ -664,6 +664,35 @@ export default function ActivityDescription({ event }: Props) {
|
||||
</div>
|
||||
);
|
||||
|
||||
/**
|
||||
* Reverse Proxy
|
||||
*/
|
||||
|
||||
if (event.activity_code == "service.peer.expose")
|
||||
return (
|
||||
<div className={"inline"}>
|
||||
Peer <Value>{m.peer_name}</Value> exposed service{" "}
|
||||
<Value>{m.domain}</Value> with auth{" "}
|
||||
<Value>{m.auth ? "Enabled" : "Disabled"}</Value>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (event.activity_code == "service.peer.unexpose")
|
||||
return (
|
||||
<div className={"inline"}>
|
||||
Peer <Value>{m.peer_name}</Value> unexposed service{" "}
|
||||
<Value>{m.domain}</Value>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (event.activity_code == "service.peer.expose.expire")
|
||||
return (
|
||||
<div className={"inline"}>
|
||||
Service <Value>{m.domain}</Value> exposed by peer{" "}
|
||||
<Value>{m.peer_name}</Value> was removed due to renewal expiration
|
||||
</div>
|
||||
);
|
||||
|
||||
/**
|
||||
* Networks
|
||||
*/
|
||||
|
||||
@@ -66,6 +66,7 @@ export default function DNSRecordsTable({ zone }: Props) {
|
||||
className={"bg-nb-gray-960 py-2"}
|
||||
inset={true}
|
||||
text={"DNS Records"}
|
||||
initialPageSize={zone?.records?.length}
|
||||
manualPagination={true}
|
||||
sorting={sorting}
|
||||
columnVisibility={{}}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { ArrowUpRightIcon, Layers3Icon } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import React, { useState } from "react";
|
||||
import { useSWRConfig } from "swr";
|
||||
import { NetworkAccessControlProvider } from "@/modules/networks/NetworkAccessControlProvider";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import { Group } from "@/interfaces/Group";
|
||||
import { NetworkResourceWithNetwork } from "@/interfaces/Network";
|
||||
@@ -115,67 +116,70 @@ export const GroupResourcesSection = ({
|
||||
const { mutate } = useSWRConfig();
|
||||
|
||||
return (
|
||||
<GroupDetailsTableContainer>
|
||||
<DataTable
|
||||
wrapperComponent={Card}
|
||||
wrapperProps={{ className: "mt-6 pb-2 w-full" }}
|
||||
sorting={sorting}
|
||||
setSorting={setSorting}
|
||||
minimal={true}
|
||||
isLoading={isLoading}
|
||||
showSearchAndFilters={true}
|
||||
renderRow={(row, children) => (
|
||||
<NetworkProvider
|
||||
network={row.network}
|
||||
onResourceUpdate={() => mutate("/networks/resources")}
|
||||
onResourceDelete={() => mutate("/networks/resources")}
|
||||
>
|
||||
{children}
|
||||
</NetworkProvider>
|
||||
)}
|
||||
inset={false}
|
||||
tableClassName={"mt-0"}
|
||||
text={"Resources"}
|
||||
columns={GroupResourcesColumns}
|
||||
keepStateInLocalStorage={false}
|
||||
data={resources}
|
||||
searchPlaceholder={"Search by name, address or group..."}
|
||||
getStartedCard={
|
||||
<NoResults
|
||||
className={"py-4"}
|
||||
title={"This group has no assigned resources"}
|
||||
description={
|
||||
"Assign this group to your resources inside your networks to see them listed here."
|
||||
}
|
||||
icon={<Layers3Icon size={20} />}
|
||||
>
|
||||
{permission?.networks?.create && (
|
||||
<>
|
||||
<Button
|
||||
variant={"primary"}
|
||||
className={"mt-4"}
|
||||
onClick={() => router.push("/networks")}
|
||||
>
|
||||
Go to Networks
|
||||
<ArrowUpRightIcon size={16} />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</NoResults>
|
||||
}
|
||||
columnVisibility={{
|
||||
description: false,
|
||||
id: false,
|
||||
}}
|
||||
paginationPaddingClassName={"px-0 pt-8"}
|
||||
>
|
||||
{(table) => (
|
||||
<DataTableRowsPerPage
|
||||
table={table}
|
||||
disabled={!resources || resources?.length == 0}
|
||||
/>
|
||||
)}
|
||||
</DataTable>
|
||||
</GroupDetailsTableContainer>
|
||||
<NetworkAccessControlProvider>
|
||||
<GroupDetailsTableContainer>
|
||||
<DataTable
|
||||
wrapperComponent={Card}
|
||||
wrapperProps={{ className: "mt-6 pb-2 w-full" }}
|
||||
sorting={sorting}
|
||||
setSorting={setSorting}
|
||||
minimal={true}
|
||||
isLoading={isLoading}
|
||||
showSearchAndFilters={true}
|
||||
renderRow={(row, children) => (
|
||||
<NetworkProvider
|
||||
key={row.network.id + row.name}
|
||||
network={row.network}
|
||||
onResourceUpdate={() => mutate("/networks/resources")}
|
||||
onResourceDelete={() => mutate("/networks/resources")}
|
||||
>
|
||||
{children}
|
||||
</NetworkProvider>
|
||||
)}
|
||||
inset={false}
|
||||
tableClassName={"mt-0"}
|
||||
text={"Resources"}
|
||||
columns={GroupResourcesColumns}
|
||||
keepStateInLocalStorage={false}
|
||||
data={resources}
|
||||
searchPlaceholder={"Search by name, address or group..."}
|
||||
getStartedCard={
|
||||
<NoResults
|
||||
className={"py-4"}
|
||||
title={"This group has no assigned resources"}
|
||||
description={
|
||||
"Assign this group to your resources inside your networks to see them listed here."
|
||||
}
|
||||
icon={<Layers3Icon size={20} />}
|
||||
>
|
||||
{permission?.networks?.create && (
|
||||
<>
|
||||
<Button
|
||||
variant={"primary"}
|
||||
className={"mt-4"}
|
||||
onClick={() => router.push("/networks")}
|
||||
>
|
||||
Go to Networks
|
||||
<ArrowUpRightIcon size={16} />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</NoResults>
|
||||
}
|
||||
columnVisibility={{
|
||||
description: false,
|
||||
id: false,
|
||||
}}
|
||||
paginationPaddingClassName={"px-0 pt-8"}
|
||||
>
|
||||
{(table) => (
|
||||
<DataTableRowsPerPage
|
||||
table={table}
|
||||
disabled={!resources || resources?.length == 0}
|
||||
/>
|
||||
)}
|
||||
</DataTable>
|
||||
</GroupDetailsTableContainer>
|
||||
</NetworkAccessControlProvider>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -240,7 +240,7 @@ export const GroupsTableColumns: ColumnDef<GroupUsage>[] = [
|
||||
row.setup_keys_count > 0 ||
|
||||
row.users_count > 0 ||
|
||||
row.resources_count > 0 ||
|
||||
row.zones_count
|
||||
row.zones_count > 0
|
||||
);
|
||||
},
|
||||
},
|
||||
|
||||
151
src/modules/networks/NetworkAccessControlProvider.tsx
Normal file
151
src/modules/networks/NetworkAccessControlProvider.tsx
Normal file
@@ -0,0 +1,151 @@
|
||||
import useFetchApi from "@utils/api";
|
||||
import { orderBy } from "lodash";
|
||||
import * as React from "react";
|
||||
import { useCallback, useContext } from "react";
|
||||
import { Group } from "@/interfaces/Group";
|
||||
import { NetworkResource } from "@/interfaces/Network";
|
||||
import { Policy } from "@/interfaces/Policy";
|
||||
|
||||
type NetworkAccessControlContextValue = {
|
||||
policies?: Policy[];
|
||||
policiesLoading: boolean;
|
||||
resources?: NetworkResource[];
|
||||
assignedPolicies: (
|
||||
resource?: NetworkResource,
|
||||
groups?: Group[],
|
||||
) => {
|
||||
policies: Policy[];
|
||||
enabledPolicies: Policy[];
|
||||
isLoading: boolean;
|
||||
policyCount: number;
|
||||
};
|
||||
resourceExists: (name: string, excludeId?: string) => boolean;
|
||||
getPolicyDestinationResources: (policy: Policy) => NetworkResource[];
|
||||
};
|
||||
|
||||
const NetworkAccessControlContext =
|
||||
React.createContext<NetworkAccessControlContextValue | null>(null);
|
||||
|
||||
type Props = {
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
const toGroupId = (g: Group | string): string | undefined =>
|
||||
typeof g === "string" ? g : g?.id;
|
||||
|
||||
export const NetworkAccessControlProvider = ({ children }: Props) => {
|
||||
const { data: policies, isLoading: policiesLoading } =
|
||||
useFetchApi<Policy[]>("/policies");
|
||||
const { data: resources } = useFetchApi<NetworkResource[]>(
|
||||
"/networks/resources",
|
||||
);
|
||||
|
||||
const resourceExists = useCallback(
|
||||
(name: string, excludeId?: string) => {
|
||||
if (!name) return false;
|
||||
return !!resources?.find(
|
||||
(r) =>
|
||||
r.name.toLowerCase() === name.toLowerCase() && r.id !== excludeId,
|
||||
);
|
||||
},
|
||||
[resources],
|
||||
);
|
||||
|
||||
const assignedPolicies = useCallback(
|
||||
(resource?: NetworkResource, groups?: Group[]) => {
|
||||
const resourceGroups = (groups || resource?.groups) as
|
||||
| (Group | string)[]
|
||||
| undefined;
|
||||
if (!resource && !resourceGroups?.length) {
|
||||
return {
|
||||
policies: [],
|
||||
enabledPolicies: [],
|
||||
isLoading: policiesLoading,
|
||||
policyCount: 0,
|
||||
};
|
||||
}
|
||||
const resourceGroupIds = new Set(
|
||||
resourceGroups?.map(toGroupId).filter(Boolean),
|
||||
);
|
||||
const resourcePolicies = orderBy(
|
||||
policies?.filter((policy) => {
|
||||
const rule = policy.rules?.[0];
|
||||
if (!rule) return false;
|
||||
if (resource && rule.destinationResource?.id === resource.id)
|
||||
return true;
|
||||
const destinations = (rule.destinations ?? []) as (Group | string)[];
|
||||
return destinations.some((d) => {
|
||||
const destId = toGroupId(d);
|
||||
return !!destId && resourceGroupIds.has(destId);
|
||||
});
|
||||
}),
|
||||
"enabled",
|
||||
"desc",
|
||||
);
|
||||
const enabledPolicies = resourcePolicies?.filter(
|
||||
(policy) => policy?.enabled,
|
||||
);
|
||||
return {
|
||||
policies: resourcePolicies,
|
||||
enabledPolicies,
|
||||
isLoading: policiesLoading,
|
||||
policyCount: resourcePolicies?.length || 0,
|
||||
};
|
||||
},
|
||||
[policies, policiesLoading],
|
||||
);
|
||||
|
||||
const getPolicyDestinationResources = useCallback(
|
||||
(policy: Policy): NetworkResource[] => {
|
||||
const rule = policy?.rules?.[0];
|
||||
const destinationGroups = rule?.destinations as
|
||||
| (Group | string)[]
|
||||
| undefined;
|
||||
const destinationGroupIds = new Set(
|
||||
destinationGroups?.map(toGroupId).filter(Boolean),
|
||||
);
|
||||
const directDestinationId = rule?.destinationResource?.id;
|
||||
|
||||
return (
|
||||
resources?.filter((resource) => {
|
||||
if (directDestinationId && resource.id === directDestinationId)
|
||||
return true;
|
||||
const resourceGroups = resource.groups as
|
||||
| (Group | string)[]
|
||||
| undefined;
|
||||
return resourceGroups?.some((g) => {
|
||||
const groupId = toGroupId(g);
|
||||
return !!groupId && destinationGroupIds.has(groupId);
|
||||
});
|
||||
}) ?? []
|
||||
);
|
||||
},
|
||||
[resources],
|
||||
);
|
||||
|
||||
return (
|
||||
<NetworkAccessControlContext.Provider
|
||||
value={{
|
||||
policies,
|
||||
policiesLoading,
|
||||
resources,
|
||||
assignedPolicies,
|
||||
resourceExists,
|
||||
getPolicyDestinationResources,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</NetworkAccessControlContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useNetworkAccessControl =
|
||||
(): NetworkAccessControlContextValue => {
|
||||
const context = useContext(NetworkAccessControlContext);
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
"useNetworkAccessControl must be used within a NetworkAccessControlProvider",
|
||||
);
|
||||
}
|
||||
return context;
|
||||
};
|
||||
@@ -5,6 +5,7 @@ import * as React from "react";
|
||||
import { useState } from "react";
|
||||
import { useSWRConfig } from "swr";
|
||||
import { useDialog } from "@/contexts/DialogProvider";
|
||||
import { useNetworkAccessControl } from "@/modules/networks/NetworkAccessControlProvider";
|
||||
import { Group } from "@/interfaces/Group";
|
||||
import { Network, NetworkResource, NetworkRouter } from "@/interfaces/Network";
|
||||
import { AccessControlModalContent } from "@/modules/access-control/AccessControlModal";
|
||||
@@ -14,6 +15,9 @@ import { ResourceGroupModal } from "@/modules/networks/resources/ResourceGroupMo
|
||||
import NetworkRoutingPeerModal from "@/modules/networks/routing-peers/NetworkRoutingPeerModal";
|
||||
import { Policy, PolicyRuleResource } from "@/interfaces/Policy";
|
||||
import PoliciesProvider from "@/contexts/PoliciesProvider";
|
||||
import { ResourceIcon } from "@/assets/icons/ResourceIcon";
|
||||
import CopyToClipboardText from "@components/CopyToClipboardText";
|
||||
import { cn } from "@utils/helpers";
|
||||
|
||||
type Props = {
|
||||
children: React.ReactNode;
|
||||
@@ -27,7 +31,11 @@ const NetworksContext = React.createContext(
|
||||
openAddRoutingPeerModal: (network: Network, router?: NetworkRouter) => void;
|
||||
openEditNetworkModal: (network: Network) => void;
|
||||
openCreateNetworkModal: () => void;
|
||||
openResourceModal: (network: Network, resource?: NetworkResource) => void;
|
||||
openResourceModal: (
|
||||
network: Network,
|
||||
resource?: NetworkResource,
|
||||
initialTab?: string,
|
||||
) => void;
|
||||
openResourceGroupModal: (
|
||||
network: Network,
|
||||
resource?: NetworkResource,
|
||||
@@ -38,6 +46,24 @@ const NetworksContext = React.createContext(
|
||||
deleteResource: (network: Network, resource: NetworkResource) => void;
|
||||
deleteRouter: (network: Network, router: NetworkRouter) => void;
|
||||
network?: Network;
|
||||
assignedPolicies: (
|
||||
resource?: NetworkResource,
|
||||
groups?: Group[],
|
||||
) => {
|
||||
policies: Policy[];
|
||||
enabledPolicies: Policy[];
|
||||
isLoading: boolean;
|
||||
policyCount: number;
|
||||
};
|
||||
resourceExists: (name: string, excludeId?: string) => boolean;
|
||||
resources?: NetworkResource[];
|
||||
getPolicyDestinationResources: (policy: Policy) => NetworkResource[];
|
||||
confirmMultiResourceAction: (
|
||||
policy: Policy,
|
||||
action: "edit" | "delete",
|
||||
additionalResource?: NetworkResource,
|
||||
) => Promise<boolean>;
|
||||
policies?: Policy[];
|
||||
},
|
||||
);
|
||||
|
||||
@@ -50,6 +76,13 @@ export const NetworkProvider = ({
|
||||
const { mutate } = useSWRConfig();
|
||||
const { confirm } = useDialog();
|
||||
const deleteCall = useApiCall("/networks").del;
|
||||
const {
|
||||
policies,
|
||||
resources,
|
||||
assignedPolicies,
|
||||
resourceExists,
|
||||
getPolicyDestinationResources,
|
||||
} = useNetworkAccessControl();
|
||||
|
||||
const [currentNetwork, setCurrentNetwork] = useState<Network>();
|
||||
const [currentResource, setCurrentResource] = useState<NetworkResource>();
|
||||
@@ -88,9 +121,18 @@ export const NetworkProvider = ({
|
||||
setNetworkModal(true);
|
||||
};
|
||||
|
||||
const openResourceModal = (network: Network, resource?: NetworkResource) => {
|
||||
const [resourceModalInitialTab, setResourceModalInitialTab] = useState<
|
||||
string | undefined
|
||||
>();
|
||||
|
||||
const openResourceModal = (
|
||||
network: Network,
|
||||
resource?: NetworkResource,
|
||||
initialTab?: string,
|
||||
) => {
|
||||
setCurrentNetwork(network);
|
||||
resource && setCurrentResource(resource);
|
||||
setResourceModalInitialTab(initialTab);
|
||||
setResourceModal(true);
|
||||
};
|
||||
|
||||
@@ -110,11 +152,11 @@ export const NetworkProvider = ({
|
||||
destinationResource: hasResourceGroups
|
||||
? undefined
|
||||
: resource
|
||||
? ({
|
||||
id: resource.id,
|
||||
type: resource.type,
|
||||
} as PolicyRuleResource)
|
||||
: undefined,
|
||||
? ({
|
||||
id: resource.id,
|
||||
type: resource.type,
|
||||
} as PolicyRuleResource)
|
||||
: undefined,
|
||||
name:
|
||||
network && !resource
|
||||
? `${network?.name} Policy`
|
||||
@@ -138,6 +180,45 @@ export const NetworkProvider = ({
|
||||
setPolicyModal(true);
|
||||
};
|
||||
|
||||
const confirmMultiResourceAction = async (
|
||||
policy: Policy,
|
||||
action: "edit" | "delete",
|
||||
additionalResource?: NetworkResource,
|
||||
) => {
|
||||
const fetchedResources = getPolicyDestinationResources(policy);
|
||||
const affectedResources =
|
||||
additionalResource &&
|
||||
!fetchedResources.some((r) => r.id === additionalResource.id)
|
||||
? [...fetchedResources, additionalResource]
|
||||
: fetchedResources;
|
||||
const isMulti = affectedResources.length > 1;
|
||||
return confirm({
|
||||
title: isMulti ? (
|
||||
<>This policy is used by multiple resources</>
|
||||
) : (
|
||||
<>
|
||||
{action === "edit" ? "Edit" : "Delete"} policy '{policy.name}
|
||||
'?
|
||||
</>
|
||||
),
|
||||
description: isMulti
|
||||
? `This policy uses one or many resource group(s) as destinations. ${
|
||||
action === "edit" ? "Updating" : "Deleting"
|
||||
} this policy will also affect following resources:`
|
||||
: action === "delete"
|
||||
? "Are you sure you want to delete this policy? This action cannot be undone."
|
||||
: undefined,
|
||||
children: isMulti ? (
|
||||
<AffectedResourceList resources={affectedResources} />
|
||||
) : undefined,
|
||||
confirmText: action === "edit" ? "Edit Policy" : "Delete Policy",
|
||||
cancelText: "Cancel",
|
||||
hideIcon: isMulti,
|
||||
type: action === "edit" ? "warning" : "danger",
|
||||
maxWidthClass: isMulti ? "max-w-lg" : undefined,
|
||||
});
|
||||
};
|
||||
|
||||
const deleteNetwork = async (network: Network) => {
|
||||
const choice = await confirm({
|
||||
title: `Delete network '${network.name}'?`,
|
||||
@@ -244,19 +325,6 @@ export const NetworkProvider = ({
|
||||
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={{
|
||||
@@ -271,24 +339,30 @@ export const NetworkProvider = ({
|
||||
deleteResource,
|
||||
deleteRouter,
|
||||
network,
|
||||
assignedPolicies,
|
||||
resourceExists,
|
||||
resources,
|
||||
getPolicyDestinationResources,
|
||||
confirmMultiResourceAction,
|
||||
policies,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
||||
<NetworkModal
|
||||
open={networkModal}
|
||||
setOpen={setNetworkModal}
|
||||
network={currentNetwork}
|
||||
onCreated={async (network) => {
|
||||
mutate("/networks");
|
||||
await askForResource(network);
|
||||
}}
|
||||
onUpdated={(n) => {
|
||||
mutate("/networks");
|
||||
mutate(`/networks/${n.id}`);
|
||||
}}
|
||||
/>
|
||||
<PoliciesProvider>
|
||||
{children}
|
||||
|
||||
<NetworkModal
|
||||
open={networkModal}
|
||||
setOpen={setNetworkModal}
|
||||
network={currentNetwork}
|
||||
onCreated={async (network) => {
|
||||
mutate("/networks");
|
||||
await askForResource(network);
|
||||
}}
|
||||
onUpdated={(n) => {
|
||||
mutate("/networks");
|
||||
mutate(`/networks/${n.id}`);
|
||||
}}
|
||||
/>
|
||||
<Modal
|
||||
open={policyModal}
|
||||
onOpenChange={(state) => {
|
||||
@@ -321,93 +395,99 @@ export const NetworkProvider = ({
|
||||
}}
|
||||
/>
|
||||
</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");
|
||||
mutate("/networks/resources");
|
||||
if (network) {
|
||||
onResourceUpdate?.();
|
||||
mutate(`/networks/${network.id}/resources`);
|
||||
mutate(`/networks/${network.id}`);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<NetworkResourceModal
|
||||
network={currentNetwork}
|
||||
resource={currentResource}
|
||||
initialTab={resourceModalInitialTab}
|
||||
onCreated={async (r) => {
|
||||
setResourceModal(false);
|
||||
setCurrentResource(undefined);
|
||||
mutate("/networks");
|
||||
mutate("/groups");
|
||||
mutate("/networks/resources");
|
||||
if (network) {
|
||||
mutate(`/networks/${network.id}/resources`);
|
||||
mutate(`/networks/${network.id}`);
|
||||
} else {
|
||||
currentNetwork?.routing_peers_count === 0 &&
|
||||
(await askForRoutingPeer(currentNetwork));
|
||||
}
|
||||
}}
|
||||
onUpdated={() => {
|
||||
setResourceModal(false);
|
||||
setCurrentResource(undefined);
|
||||
mutate("/networks");
|
||||
mutate("/groups");
|
||||
mutate("/networks/resources");
|
||||
if (network) {
|
||||
onResourceUpdate?.();
|
||||
mutate(`/networks/${network.id}/resources`);
|
||||
mutate(`/networks/${network.id}`);
|
||||
}
|
||||
}}
|
||||
open={resourceModal}
|
||||
setOpen={(state) => {
|
||||
setCurrentResource(undefined);
|
||||
setResourceModalInitialTab(undefined);
|
||||
setResourceModal(state);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</PoliciesProvider>
|
||||
{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) {
|
||||
onResourceUpdate?.();
|
||||
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) {
|
||||
onResourceUpdate?.();
|
||||
mutate(`/networks/${network.id}/resources`);
|
||||
mutate(`/networks/${network.id}`);
|
||||
}
|
||||
}}
|
||||
open={resourceModal}
|
||||
setOpen={(state) => {
|
||||
setCurrentResource(undefined);
|
||||
setResourceModal(state);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</NetworksContext.Provider>
|
||||
);
|
||||
};
|
||||
@@ -419,3 +499,37 @@ export const useNetworksContext = () => {
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
function AffectedResourceList({ resources }: { resources: NetworkResource[] }) {
|
||||
const maxVisible = 6;
|
||||
const visible = resources.slice(0, maxVisible);
|
||||
const remaining = resources.length - maxVisible;
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-md bg-nb-gray-930 border border-nb-gray-900 text-xs mt-4",
|
||||
)}
|
||||
>
|
||||
{visible.map((r, i) => (
|
||||
<div
|
||||
key={r.id}
|
||||
className={cn(
|
||||
"flex items-center gap-2.5 px-3 py-2.5",
|
||||
i > 0 && "border-t border-nb-gray-900",
|
||||
)}
|
||||
>
|
||||
<ResourceIcon type={r.type || "host"} size={12} />
|
||||
<span className="font-medium text-nb-gray-200">{r.name}</span>
|
||||
<CopyToClipboardText className={"text-nb-gray-300"}>
|
||||
{r.address}
|
||||
</CopyToClipboardText>
|
||||
</div>
|
||||
))}
|
||||
{remaining > 0 && (
|
||||
<div className="border-t border-nb-gray-900 px-3 py-2 text-nb-gray-200">
|
||||
+ {remaining} more
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
307
src/modules/networks/resources/NetworkResourceAccessControl.tsx
Normal file
307
src/modules/networks/resources/NetworkResourceAccessControl.tsx
Normal file
@@ -0,0 +1,307 @@
|
||||
import Button from "@components/Button";
|
||||
import HelpText from "@components/HelpText";
|
||||
import { Label } from "@components/Label";
|
||||
import { Modal } from "@components/modal/Modal";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@components/DropdownMenu";
|
||||
import { Edit2, MoreVertical, PlusIcon, Trash2 } from "lucide-react";
|
||||
import React, { useMemo, useState } from "react";
|
||||
import { NetworkResource } from "@/interfaces/Network";
|
||||
import { Policy, PolicyRuleResource } from "@/interfaces/Policy";
|
||||
import { cn } from "@utils/helpers";
|
||||
import { usePolicies } from "@/contexts/PoliciesProvider";
|
||||
import AccessControlSourcesCell from "@/modules/access-control/table/AccessControlSourcesCell";
|
||||
import { useNetworksContext } from "@/modules/networks/NetworkProvider";
|
||||
import { AccessControlModalContent } from "@/modules/access-control/AccessControlModal";
|
||||
import CircleIcon from "@/assets/icons/CircleIcon";
|
||||
import AccessControlProtocolCell from "@/modules/access-control/table/AccessControlProtocolCell";
|
||||
import AccessControlPortsCell from "@/modules/access-control/table/AccessControlPortsCell";
|
||||
import TruncatedText from "@components/ui/TruncatedText";
|
||||
|
||||
type Props = {
|
||||
existingPolicies: Policy[];
|
||||
newPolicies: Policy[];
|
||||
onNewPoliciesChange: (policies: Policy[]) => void;
|
||||
address: string;
|
||||
resourceName?: string;
|
||||
resourceId?: string;
|
||||
hasResourceGroups?: boolean;
|
||||
};
|
||||
|
||||
function getResourceType(address: string): "domain" | "host" | "subnet" {
|
||||
const hasChars = !!address.match(/[a-z*]/i);
|
||||
const isCIDR = !!address.match(/\//);
|
||||
return hasChars ? "domain" : isCIDR ? "subnet" : "host";
|
||||
}
|
||||
|
||||
export default function NetworkResourceAccessControl({
|
||||
existingPolicies,
|
||||
newPolicies,
|
||||
onNewPoliciesChange,
|
||||
address,
|
||||
resourceName,
|
||||
resourceId,
|
||||
hasResourceGroups = false,
|
||||
}: Readonly<Props>) {
|
||||
const { network, confirmMultiResourceAction } = useNetworksContext();
|
||||
const { openEditPolicyModal, deletePolicy } = usePolicies();
|
||||
const [policyModalOpen, setPolicyModalOpen] = useState(false);
|
||||
const [editingPolicyIndex, setEditingPolicyIndex] = useState<number | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const allPolicies = useMemo(
|
||||
() => [...existingPolicies, ...newPolicies],
|
||||
[existingPolicies, newPolicies],
|
||||
);
|
||||
|
||||
const destinationResource: PolicyRuleResource = useMemo(() => {
|
||||
return {
|
||||
id: resourceId || resourceName || address,
|
||||
type: getResourceType(address),
|
||||
};
|
||||
}, [address, resourceName, resourceId]);
|
||||
|
||||
const currentResource = useMemo<NetworkResource>(() => {
|
||||
return {
|
||||
id: resourceId || "",
|
||||
name: resourceName || address,
|
||||
address,
|
||||
type: getResourceType(address),
|
||||
enabled: true,
|
||||
};
|
||||
}, [resourceId, resourceName, address]);
|
||||
|
||||
const openAddPolicy = () => {
|
||||
setEditingPolicyIndex(null);
|
||||
setPolicyModalOpen(true);
|
||||
};
|
||||
|
||||
const openEditPolicy = async (policy: Policy) => {
|
||||
if (policy.id) {
|
||||
const confirm = await confirmMultiResourceAction(
|
||||
policy,
|
||||
"edit",
|
||||
currentResource,
|
||||
);
|
||||
if (!confirm) return;
|
||||
openEditPolicyModal(policy);
|
||||
} else {
|
||||
const idx = newPolicies.indexOf(policy);
|
||||
if (idx === -1) return;
|
||||
setEditingPolicyIndex(idx);
|
||||
setPolicyModalOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
const savePolicy = (policy: Policy) => {
|
||||
if (editingPolicyIndex !== null) {
|
||||
onNewPoliciesChange(
|
||||
newPolicies.map((p, i) => (i === editingPolicyIndex ? policy : p)),
|
||||
);
|
||||
} else {
|
||||
onNewPoliciesChange([...newPolicies, policy]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeletePolicy = async (policy: Policy) => {
|
||||
const confirm = await confirmMultiResourceAction(
|
||||
policy,
|
||||
"delete",
|
||||
currentResource,
|
||||
);
|
||||
if (!confirm) return;
|
||||
if (policy.id) {
|
||||
await deletePolicy(policy);
|
||||
} else {
|
||||
onNewPoliciesChange(newPolicies.filter((p) => p !== policy));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={"px-8 flex-col flex gap-6"}>
|
||||
<div>
|
||||
<Label>Access Control Policies</Label>
|
||||
<HelpText>
|
||||
Define which source groups are allowed to access this resource. You
|
||||
can also restrict access to specific protocols and ports. Without
|
||||
policies access to this resource will not be possible.
|
||||
</HelpText>
|
||||
|
||||
{allPolicies.length > 0 && (
|
||||
<div
|
||||
className={
|
||||
"mt-3 mb-3 overflow-hidden border border-nb-gray-900 bg-nb-gray-920/30 py-1 px-1 rounded-md"
|
||||
}
|
||||
>
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="py-2 px-4 text-left text-[11px] uppercase tracking-wider text-nb-gray-400 font-medium">
|
||||
Name
|
||||
</th>
|
||||
<th className="py-2 pl-5 pr-2 text-left text-[11px] uppercase tracking-wider text-nb-gray-400 font-medium">
|
||||
Source Groups
|
||||
</th>
|
||||
<th className="py-2 px-4 text-left text-[11px] uppercase tracking-wider text-nb-gray-400 font-medium">
|
||||
Protocol & Ports
|
||||
</th>
|
||||
<th className="py-2 pr-4 pl-2" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{allPolicies.map((policy, index) => {
|
||||
return (
|
||||
<tr
|
||||
key={policy.id || `new-${index}`}
|
||||
onClick={() => openEditPolicy(policy)}
|
||||
className="rounded-md hover:bg-nb-gray-900/30 cursor-pointer transition-all"
|
||||
>
|
||||
<td className="py-2.5 px-4 align-middle">
|
||||
<div
|
||||
className={
|
||||
"text-[13px] mt-1 flex items-center gap-2 leading-none font-medium text-nb-gray-300 group-hover:text-nb-gray-200 whitespace-nowrap"
|
||||
}
|
||||
>
|
||||
<div className={"self-start flex"}>
|
||||
<CircleIcon
|
||||
size={8}
|
||||
active={policy.enabled}
|
||||
className={cn("shrink-0 relative top-[5px]")}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className={
|
||||
"flex flex-col items-start justify-start"
|
||||
}
|
||||
>
|
||||
<TruncatedText
|
||||
text={policy.name}
|
||||
maxWidth={"130px"}
|
||||
className={"leading-normal"}
|
||||
/>
|
||||
{policy.description && (
|
||||
<div className={"text-nb-gray-400 text-xs"}>
|
||||
<TruncatedText
|
||||
text={policy.description}
|
||||
maxWidth={"130px"}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-2.5 pl-5 pr-2 align-middle">
|
||||
<AccessControlSourcesCell
|
||||
policy={policy}
|
||||
hideEdit
|
||||
disableRedirect
|
||||
/>
|
||||
</td>
|
||||
<td className="py-2.5 pl-3 pr-2 align-middle">
|
||||
<div className={"flex items-center gap-2"}>
|
||||
<AccessControlProtocolCell policy={policy} />
|
||||
<AccessControlPortsCell
|
||||
policy={policy}
|
||||
visiblePorts={1}
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<td className="py-2.5 pl-2 pr-3">
|
||||
<div
|
||||
className="flex items-center gap-6 justify-end"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="default-outline"
|
||||
className="!px-3"
|
||||
>
|
||||
<MoreVertical size={16} className="shrink-0" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
className="w-auto min-w-[200px]"
|
||||
align="end"
|
||||
>
|
||||
<DropdownMenuItem
|
||||
onClick={() => openEditPolicy(policy)}
|
||||
>
|
||||
<div className="flex gap-3 items-center">
|
||||
<Edit2 size={14} className="shrink-0" />
|
||||
Edit Policy
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
variant={"danger"}
|
||||
onClick={() => handleDeletePolicy(policy)}
|
||||
>
|
||||
<div className="flex gap-3 items-center">
|
||||
<Trash2 size={14} className="shrink-0" />
|
||||
Delete Policy
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="dotted"
|
||||
className={"w-full mt-1"}
|
||||
size="sm"
|
||||
onClick={openAddPolicy}
|
||||
>
|
||||
<PlusIcon size={14} />
|
||||
Add Policy
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
open={policyModalOpen}
|
||||
onOpenChange={(open) => {
|
||||
setPolicyModalOpen(open);
|
||||
if (!open) setEditingPolicyIndex(null);
|
||||
}}
|
||||
key={policyModalOpen ? 1 : 0}
|
||||
>
|
||||
<AccessControlModalContent
|
||||
useSave={false}
|
||||
policy={
|
||||
editingPolicyIndex !== null
|
||||
? newPolicies[editingPolicyIndex]
|
||||
: undefined
|
||||
}
|
||||
initialDestinationResource={
|
||||
editingPolicyIndex === null ? destinationResource : undefined
|
||||
}
|
||||
disableDestinationSelector={!hasResourceGroups}
|
||||
initialName={`${resourceName || address} Policy`}
|
||||
initialDescription={
|
||||
network?.description
|
||||
? `${network.name}, ${network.description}`
|
||||
: network?.name || ""
|
||||
}
|
||||
onSuccess={(policy) => {
|
||||
savePolicy(policy);
|
||||
setPolicyModalOpen(false);
|
||||
setEditingPolicyIndex(null);
|
||||
}}
|
||||
/>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import Button from "@components/Button";
|
||||
import { Callout } from "@components/Callout";
|
||||
import FancyToggleSwitch from "@components/FancyToggleSwitch";
|
||||
import HelpText from "@components/HelpText";
|
||||
import InlineLink from "@components/InlineLink";
|
||||
import InlineLink, { InlineButtonLink } from "@components/InlineLink";
|
||||
import { Input } from "@components/Input";
|
||||
import { Label } from "@components/Label";
|
||||
import {
|
||||
@@ -15,18 +16,27 @@ import {
|
||||
import ModalHeader from "@components/modal/ModalHeader";
|
||||
import { notify } from "@components/Notification";
|
||||
import Paragraph from "@components/Paragraph";
|
||||
import { HelpTooltip } from "@components/HelpTooltip";
|
||||
import { PeerGroupSelector } from "@components/PeerGroupSelector";
|
||||
import Separator from "@components/Separator";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@components/Tabs";
|
||||
import { useApiCall } from "@utils/api";
|
||||
import { useDialog } from "@/contexts/DialogProvider";
|
||||
import { usePolicies } from "@/contexts/PoliciesProvider";
|
||||
import { useNetworksContext } from "@/modules/networks/NetworkProvider";
|
||||
import {
|
||||
ExternalLinkIcon,
|
||||
PlusCircle,
|
||||
Power,
|
||||
ShieldCheck,
|
||||
Text,
|
||||
WorkflowIcon,
|
||||
} from "lucide-react";
|
||||
import React, { useMemo, useState } from "react";
|
||||
import React, { useMemo, useRef, useState } from "react";
|
||||
import { Group } from "@/interfaces/Group";
|
||||
import { Network, NetworkResource } from "@/interfaces/Network";
|
||||
import { Policy } from "@/interfaces/Policy";
|
||||
import useGroupHelper from "@/modules/groups/useGroupHelper";
|
||||
import NetworkResourceAccessControl from "@/modules/networks/resources/NetworkResourceAccessControl";
|
||||
import { ResourceSingleAddressInput } from "@/modules/networks/resources/ResourceSingleAddressInput";
|
||||
|
||||
type Props = {
|
||||
@@ -36,6 +46,7 @@ type Props = {
|
||||
resource?: NetworkResource;
|
||||
onCreated?: (r: NetworkResource) => void;
|
||||
onUpdated?: (r: NetworkResource) => void;
|
||||
initialTab?: string;
|
||||
};
|
||||
|
||||
export default function NetworkResourceModal({
|
||||
@@ -45,6 +56,7 @@ export default function NetworkResourceModal({
|
||||
resource,
|
||||
onUpdated,
|
||||
onCreated,
|
||||
initialTab,
|
||||
}: Props) {
|
||||
return (
|
||||
<Modal open={open} onOpenChange={setOpen}>
|
||||
@@ -54,6 +66,7 @@ export default function NetworkResourceModal({
|
||||
resource={resource}
|
||||
onCreated={onCreated}
|
||||
onUpdated={onUpdated}
|
||||
initialTab={initialTab}
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
@@ -64,6 +77,7 @@ type ModalProps = {
|
||||
onUpdated?: (r: NetworkResource) => void;
|
||||
network: Network;
|
||||
resource?: NetworkResource;
|
||||
initialTab?: string;
|
||||
};
|
||||
|
||||
export function ResourceModalContent({
|
||||
@@ -71,6 +85,7 @@ export function ResourceModalContent({
|
||||
onUpdated,
|
||||
network,
|
||||
resource,
|
||||
initialTab,
|
||||
}: ModalProps) {
|
||||
const create = useApiCall<NetworkResource>(
|
||||
`/networks/${network.id}/resources`,
|
||||
@@ -88,50 +103,123 @@ export function ResourceModalContent({
|
||||
const [enabled, setEnabled] = useState<boolean>(
|
||||
resource ? resource.enabled : true,
|
||||
);
|
||||
const nameRef = useRef<HTMLInputElement>(null);
|
||||
const [tab, setTab] = useState(initialTab || "resource");
|
||||
const [addressError, setAddressError] = useState("");
|
||||
|
||||
const { confirm } = useDialog();
|
||||
|
||||
// Access control policies
|
||||
const [policies, setPolicies] = useState<Policy[]>([]);
|
||||
const { createPoliciesForResource } = usePolicies();
|
||||
const {
|
||||
assignedPolicies,
|
||||
resourceExists,
|
||||
policies: allPolicies,
|
||||
} = useNetworksContext();
|
||||
|
||||
const { policies: existingPolicies } = useMemo(
|
||||
() => assignedPolicies(resource, groups),
|
||||
[assignedPolicies, resource, groups],
|
||||
);
|
||||
|
||||
const allResourcePolicies = useMemo(() => {
|
||||
return [...(existingPolicies || []), ...policies];
|
||||
}, [existingPolicies, policies]);
|
||||
|
||||
const groupPolicyCount = useMemo(() => {
|
||||
if (!groups.length || !allPolicies) return 0;
|
||||
const groupIds = new Set(groups.map((g) => g.id));
|
||||
return allPolicies.filter((policy) => {
|
||||
const rule = policy.rules?.[0];
|
||||
if (!rule || rule.destinationResource) return false;
|
||||
const destinations = rule.destinations as
|
||||
| (Group | string)[]
|
||||
| undefined;
|
||||
return destinations?.some((d) => {
|
||||
const id = typeof d === "string" ? d : d.id;
|
||||
return !!id && groupIds.has(id);
|
||||
});
|
||||
}).length;
|
||||
}, [groups, allPolicies]);
|
||||
|
||||
const isAddressValid = address.length > 0 && addressError === "";
|
||||
|
||||
const nameError = useMemo(() => {
|
||||
if (name === "") return "";
|
||||
if (resourceExists(name, resource?.id))
|
||||
return "A resource with this name already exists. Please use another name.";
|
||||
return "";
|
||||
}, [name, resourceExists, resource?.id]);
|
||||
|
||||
const confirmMissingPolicies = async () => {
|
||||
if (allResourcePolicies.length > 0) return true;
|
||||
return confirm({
|
||||
title: "No Access Control Policies Configured",
|
||||
description:
|
||||
"Without access control policies, this resource will not be accessible by any peers. You can also create policies later. Are you sure you want to continue?",
|
||||
type: "warning",
|
||||
confirmText: resource ? "Save Changes" : "Add Resource",
|
||||
cancelText: "Cancel",
|
||||
maxWidthClass: "max-w-lg",
|
||||
});
|
||||
};
|
||||
|
||||
const createResource = async () => {
|
||||
if (!(await confirmMissingPolicies())) return;
|
||||
const savedGroups = await saveGroups();
|
||||
const promise = create({
|
||||
name,
|
||||
description,
|
||||
address,
|
||||
groups: savedGroups ? savedGroups.map((g) => g.id) : undefined,
|
||||
enabled,
|
||||
}).then(async (r) => {
|
||||
await createPoliciesForResource(policies, r);
|
||||
onCreated?.(r);
|
||||
});
|
||||
|
||||
notify({
|
||||
title: "Resource Created",
|
||||
description: `The resource "${name}" has been created successfully.`,
|
||||
loadingMessage: "Creating resource...",
|
||||
promise: create({
|
||||
name,
|
||||
description,
|
||||
address,
|
||||
groups: savedGroups ? savedGroups.map((g) => g.id) : undefined,
|
||||
enabled,
|
||||
}).then((r) => {
|
||||
onCreated?.(r);
|
||||
}),
|
||||
promise,
|
||||
});
|
||||
|
||||
return promise;
|
||||
};
|
||||
|
||||
const updateResource = async () => {
|
||||
if (!(await confirmMissingPolicies())) return;
|
||||
const savedGroups = await saveGroups();
|
||||
const promise = update({
|
||||
name,
|
||||
description,
|
||||
address,
|
||||
groups: savedGroups ? savedGroups.map((g) => g.id) : undefined,
|
||||
enabled,
|
||||
}).then(async (r) => {
|
||||
await createPoliciesForResource(policies, r);
|
||||
onUpdated?.(r);
|
||||
});
|
||||
notify({
|
||||
title: "Resource Updated",
|
||||
description: `The resource "${name}" has been updated successfully.`,
|
||||
description: `Resource "${name}" has been updated successfully.`,
|
||||
loadingMessage: "Updating resource...",
|
||||
promise: update({
|
||||
name,
|
||||
description,
|
||||
address,
|
||||
groups: savedGroups ? savedGroups.map((g) => g.id) : undefined,
|
||||
enabled,
|
||||
}).then((r) => {
|
||||
onUpdated?.(r);
|
||||
}),
|
||||
promise,
|
||||
});
|
||||
};
|
||||
|
||||
// TODO: Address validation is missing for proper handling of submit button
|
||||
const canCreate = useMemo(() => {
|
||||
return name.length > 0 && address.length > 0;
|
||||
}, [name, address, groups]);
|
||||
return name.length > 0 && isAddressValid && nameError === "";
|
||||
}, [name, isAddressValid, nameError]);
|
||||
|
||||
return (
|
||||
<ModalContent maxWidthClass={"max-w-xl"}>
|
||||
<ModalContent
|
||||
maxWidthClass={
|
||||
tab === "access-control" ? "max-w-[790px]" : "max-w-[720px]"
|
||||
}
|
||||
>
|
||||
<ModalHeader
|
||||
icon={<WorkflowIcon size={20} />}
|
||||
title={resource ? "Edit Resource" : "Add Resource"}
|
||||
@@ -143,55 +231,168 @@ export function ResourceModalContent({
|
||||
color={"yellow"}
|
||||
/>
|
||||
|
||||
<Separator />
|
||||
<Tabs defaultValue={tab} onValueChange={(v) => setTab(v)} value={tab}>
|
||||
<TabsList justify={"start"} className={"px-8"}>
|
||||
<TabsTrigger value={"resource"}>
|
||||
<WorkflowIcon size={16} />
|
||||
Resource
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value={"access-control"}
|
||||
disabled={!resource && !isAddressValid}
|
||||
>
|
||||
<ShieldCheck size={16} />
|
||||
Access Control
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value={"general"}
|
||||
disabled={!resource && !isAddressValid}
|
||||
>
|
||||
<Text
|
||||
size={16}
|
||||
className={
|
||||
"text-nb-gray-500 group-data-[state=active]/trigger:text-netbird transition-all"
|
||||
}
|
||||
/>
|
||||
Name & Description
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<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>
|
||||
<Input
|
||||
placeholder={"e.g., Production, Development"}
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<TabsContent value={"resource"} className={"pb-8"}>
|
||||
<div className={"px-8 flex-col flex gap-8"}>
|
||||
<ResourceSingleAddressInput
|
||||
value={address}
|
||||
onChange={setAddress}
|
||||
onError={setAddressError}
|
||||
autoFocus={true}
|
||||
description={
|
||||
<>
|
||||
Enter a single{" "}
|
||||
<HelpTooltip
|
||||
content={
|
||||
"A single host address, e.g., 10.0.0.1 or 192.168.1.5. Use this to give access to a specific machine or service."
|
||||
}
|
||||
>
|
||||
IP Address
|
||||
</HelpTooltip>
|
||||
,{" "}
|
||||
<HelpTooltip
|
||||
content={
|
||||
"To give access to an entire subnet, use a CIDR block. For example, 10.0.0.0/24 or 192.168.1.0/24."
|
||||
}
|
||||
>
|
||||
CIDR Block
|
||||
</HelpTooltip>{" "}
|
||||
or{" "}
|
||||
<HelpTooltip
|
||||
content={
|
||||
"A DNS domain name, e.g., service.internal, example.com or *.example.com to match all subdomains."
|
||||
}
|
||||
>
|
||||
Domain Name
|
||||
</HelpTooltip>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<ResourceSingleAddressInput value={address} onChange={setAddress} />
|
||||
<div>
|
||||
<Label>Resource Groups (optional)</Label>
|
||||
<HelpText>
|
||||
Organize this resource into a group (e.g., Databases, Web
|
||||
Servers) and reference the group in access policies to keep
|
||||
rules reusable and easy to maintain.
|
||||
</HelpText>
|
||||
<PeerGroupSelector
|
||||
side={"top"}
|
||||
onChange={setGroups}
|
||||
values={groups}
|
||||
showPeerCounter={false}
|
||||
placeholder={"Add or select resource group(s)..."}
|
||||
policies={allPolicies}
|
||||
/>
|
||||
{groupPolicyCount > 0 && (
|
||||
<Callout variant={"info"} className={"mt-3"}>
|
||||
Your selected resource groups are used in{" "}
|
||||
<span className="text-white font-medium">
|
||||
{groupPolicyCount} Access Control{" "}
|
||||
{groupPolicyCount === 1 ? "Policy" : "Policies"}
|
||||
</span>
|
||||
. This resource will inherit access from{" "}
|
||||
{groupPolicyCount === 1 ? "this policy" : "these policies"}.
|
||||
{isAddressValid || resource ? (
|
||||
<>
|
||||
{" "}
|
||||
Please review them in the{" "}
|
||||
<InlineButtonLink
|
||||
onClick={() => setTab("access-control")}
|
||||
variant={"dashed"}
|
||||
>
|
||||
Access Control
|
||||
</InlineButtonLink>{" "}
|
||||
tab.
|
||||
</>
|
||||
) : (
|
||||
" Please review them in the Access Control tab."
|
||||
)}
|
||||
</Callout>
|
||||
)}
|
||||
</div>
|
||||
<FancyToggleSwitch
|
||||
value={enabled}
|
||||
onChange={setEnabled}
|
||||
label={
|
||||
<>
|
||||
<Power size={15} />
|
||||
Enable Resource
|
||||
</>
|
||||
}
|
||||
helpText={"Use this switch to enable or disable the resource."}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<div>
|
||||
<Label>Destination Groups (optional)</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-2 mb-2"}>
|
||||
<FancyToggleSwitch
|
||||
value={enabled}
|
||||
onChange={setEnabled}
|
||||
label={
|
||||
<>
|
||||
<Power size={15} />
|
||||
Enable Resource
|
||||
</>
|
||||
}
|
||||
helpText={"Use this switch to enable or disable the resource."}
|
||||
<TabsContent value={"access-control"} className={"pb-8"}>
|
||||
<NetworkResourceAccessControl
|
||||
existingPolicies={existingPolicies || []}
|
||||
newPolicies={policies}
|
||||
onNewPoliciesChange={setPolicies}
|
||||
address={address}
|
||||
resourceName={name}
|
||||
resourceId={resource?.id}
|
||||
hasResourceGroups={groups.length > 0}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value={"general"} className={"px-8 pb-8"}>
|
||||
<div className={"flex flex-col gap-6"}>
|
||||
<div>
|
||||
<Label>Name</Label>
|
||||
<HelpText>
|
||||
Set an easily identifiable name for your resource
|
||||
</HelpText>
|
||||
<Input
|
||||
ref={nameRef}
|
||||
tabIndex={0}
|
||||
placeholder={"e.g., Postgres Database"}
|
||||
value={name}
|
||||
error={nameError}
|
||||
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>
|
||||
<Input
|
||||
placeholder={"e.g., Production, Development"}
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<ModalFooter className={"items-center"}>
|
||||
<div className={"w-full"}>
|
||||
@@ -207,25 +408,78 @@ export function ResourceModalContent({
|
||||
</Paragraph>
|
||||
</div>
|
||||
<div className={"flex gap-3 w-full justify-end"}>
|
||||
<ModalClose asChild={true}>
|
||||
<Button variant={"secondary"}>Cancel</Button>
|
||||
</ModalClose>
|
||||
{!resource ? (
|
||||
<>
|
||||
{tab === "resource" && (
|
||||
<>
|
||||
<ModalClose asChild={true}>
|
||||
<Button variant={"secondary"}>Cancel</Button>
|
||||
</ModalClose>
|
||||
<Button
|
||||
variant={"primary"}
|
||||
onClick={() => setTab("access-control")}
|
||||
disabled={!isAddressValid}
|
||||
>
|
||||
Continue
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant={"primary"}
|
||||
data-cy={"submit-route"}
|
||||
onClick={resource ? updateResource : createResource}
|
||||
disabled={!canCreate}
|
||||
>
|
||||
{resource ? (
|
||||
<>Save Changes</>
|
||||
) : (
|
||||
<>
|
||||
<PlusCircle size={16} />
|
||||
Add Resource
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
{tab === "access-control" && (
|
||||
<>
|
||||
<Button
|
||||
variant={"secondary"}
|
||||
onClick={() => setTab("resource")}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
<Button
|
||||
variant={"primary"}
|
||||
onClick={() => {
|
||||
setTab("general");
|
||||
setTimeout(() => nameRef.current?.focus(), 0);
|
||||
}}
|
||||
>
|
||||
Continue
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{tab === "general" && (
|
||||
<>
|
||||
<Button
|
||||
variant={"secondary"}
|
||||
onClick={() => setTab("access-control")}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
<Button
|
||||
variant={"primary"}
|
||||
data-cy={"submit-route"}
|
||||
onClick={createResource}
|
||||
disabled={!canCreate}
|
||||
>
|
||||
<PlusCircle size={16} />
|
||||
Add Resource
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ModalClose asChild={true}>
|
||||
<Button variant={"secondary"}>Cancel</Button>
|
||||
</ModalClose>
|
||||
<Button
|
||||
variant={"primary"}
|
||||
data-cy={"submit-route"}
|
||||
onClick={updateResource}
|
||||
disabled={!canCreate}
|
||||
>
|
||||
Save Changes
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import Badge from "@components/Badge";
|
||||
import MultipleGroups, {
|
||||
TransparentEditIconButton,
|
||||
} from "@components/ui/MultipleGroups";
|
||||
import { IconCirclePlus } from "@tabler/icons-react";
|
||||
import * as React from "react";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import { Group } from "@/interfaces/Group";
|
||||
@@ -15,6 +17,9 @@ export const ResourceGroupCell = ({ resource }: Props) => {
|
||||
|
||||
const { network, openResourceGroupModal } = useNetworksContext();
|
||||
|
||||
const groups = resource?.groups as Group[] | undefined;
|
||||
const hasGroups = groups && groups.length > 0;
|
||||
|
||||
return (
|
||||
<button
|
||||
className={"flex cursor-pointer items-center justify-center gap-1 group"}
|
||||
@@ -23,12 +28,25 @@ export const ResourceGroupCell = ({ resource }: Props) => {
|
||||
openResourceGroupModal(network, resource);
|
||||
}}
|
||||
>
|
||||
<MultipleGroups
|
||||
groups={resource?.groups as Group[]}
|
||||
showResources={true}
|
||||
redirectGroupTab={"resources"}
|
||||
/>
|
||||
{permission.networks.update && <TransparentEditIconButton />}
|
||||
{hasGroups ? (
|
||||
<>
|
||||
<MultipleGroups
|
||||
groups={groups}
|
||||
showResources={true}
|
||||
redirectGroupTab={"resources"}
|
||||
/>
|
||||
{permission.networks.update && <TransparentEditIconButton />}
|
||||
</>
|
||||
) : (
|
||||
<Badge
|
||||
variant={"gray"}
|
||||
useHover={true}
|
||||
disabled={!permission.networks.update}
|
||||
>
|
||||
<IconCirclePlus size={14} />
|
||||
Add Groups
|
||||
</Badge>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -8,9 +8,7 @@ import {
|
||||
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 { Network, NetworkResource } from "@/interfaces/Network";
|
||||
import useGroupHelper from "@/modules/groups/useGroupHelper";
|
||||
@@ -78,21 +76,22 @@ const ResourceGroupModalContent = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<ModalContent maxWidthClass={"max-w-xl"}>
|
||||
<ModalContent maxWidthClass={"max-w-2xl"}>
|
||||
<ModalHeader
|
||||
icon={<FolderGit2 size={18} />}
|
||||
title={"Assigned Groups"}
|
||||
title={"Resource Groups"}
|
||||
description={
|
||||
"Add this resource to groups and use them as destinations when creating policies"
|
||||
"Organize this resource into a group (e.g., Databases, Web Servers) and reference the group in access policies to keep rules reusable and easy to maintain."
|
||||
}
|
||||
color={"blue"}
|
||||
/>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className={"px-8 py-6 flex flex-col gap-8"}>
|
||||
<div className={"px-8 py-6 pt-0 flex flex-col gap-8"}>
|
||||
<div>
|
||||
<PeerGroupSelector onChange={setGroups} values={groups} />
|
||||
<PeerGroupSelector
|
||||
onChange={setGroups}
|
||||
values={groups}
|
||||
showPeerCounter={false}
|
||||
placeholder={"Add or select resource group(s)..."}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ export default function ResourceNameCell({ resource }: Readonly<Props>) {
|
||||
/>
|
||||
<DescriptionWithTooltip
|
||||
maxChars={25}
|
||||
className={cn("font-normal mt-0.5 ")}
|
||||
className={cn("font-normal")}
|
||||
text={resource.description}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,52 +1,37 @@
|
||||
import Badge from "@components/Badge";
|
||||
import Button from "@components/Button";
|
||||
import FullTooltip from "@components/FullTooltip";
|
||||
import useFetchApi from "@utils/api";
|
||||
import { orderBy } from "lodash";
|
||||
import { PlusCircle, ShieldIcon, SquarePenIcon } from "lucide-react";
|
||||
import { Settings, ShieldIcon, ShieldOff, SquarePenIcon } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useState } from "react";
|
||||
import Skeleton from "react-loading-skeleton";
|
||||
import CircleIcon from "@/assets/icons/CircleIcon";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import { Group } from "@/interfaces/Group";
|
||||
import { NetworkResource } from "@/interfaces/Network";
|
||||
import { Policy } from "@/interfaces/Policy";
|
||||
import { useNetworksContext } from "@/modules/networks/NetworkProvider";
|
||||
import { cn } from "@utils/helpers";
|
||||
|
||||
type Props = {
|
||||
resource?: NetworkResource;
|
||||
};
|
||||
export const ResourcePolicyCell = ({ resource }: Props) => {
|
||||
const { permission } = usePermissions();
|
||||
const { openPolicyModal, network, openEditPolicyModal } =
|
||||
useNetworksContext();
|
||||
const { data: policies, isLoading } = useFetchApi<Policy[]>("/policies");
|
||||
const {
|
||||
openResourceModal,
|
||||
network,
|
||||
openEditPolicyModal,
|
||||
assignedPolicies,
|
||||
confirmMultiResourceAction,
|
||||
} = useNetworksContext();
|
||||
const {
|
||||
policies: resourcePolicies,
|
||||
enabledPolicies,
|
||||
isLoading,
|
||||
policyCount,
|
||||
} = assignedPolicies(resource);
|
||||
const [tooltipOpen, setTooltipOpen] = useState(false);
|
||||
|
||||
const assignedPolicies = useMemo(() => {
|
||||
const resourceGroups = resource?.groups as Group[];
|
||||
return orderBy(
|
||||
policies?.filter((policy) => {
|
||||
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,
|
||||
),
|
||||
);
|
||||
}),
|
||||
"enabled",
|
||||
"desc",
|
||||
);
|
||||
}, [policies, resource]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className={"flex gap-3"}>
|
||||
@@ -55,13 +40,16 @@ export const ResourcePolicyCell = ({ resource }: Props) => {
|
||||
);
|
||||
}
|
||||
|
||||
const enabledPolicies = assignedPolicies?.filter((policy) => policy?.enabled);
|
||||
|
||||
const policyCount = assignedPolicies?.length || 0;
|
||||
|
||||
return (
|
||||
network && (
|
||||
<div className={"flex gap-3"}>
|
||||
{policyCount === 0 && (
|
||||
<Badge variant={"gray"}>
|
||||
<ShieldOff size={12} className="text-red-500" />
|
||||
<span className={"font-medium text-xs"}>None</span>
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{policyCount > 0 && (
|
||||
<FullTooltip
|
||||
contentClassName={"p-0"}
|
||||
@@ -72,17 +60,23 @@ export const ResourcePolicyCell = ({ resource }: Props) => {
|
||||
className={"border-nb-gray-800"}
|
||||
content={
|
||||
<div className={"text-xs flex flex-col p-1"}>
|
||||
{assignedPolicies?.map((policy: Policy) => {
|
||||
{resourcePolicies?.map((policy: Policy) => {
|
||||
const rule = policy?.rules?.[0];
|
||||
if (!rule) return;
|
||||
if (!rule) return null;
|
||||
return (
|
||||
<button
|
||||
key={policy.id}
|
||||
className={
|
||||
"m-0 pl-3 py-2.5 leading-none flex justify-between group hover:bg-nb-gray-900 rounded-md"
|
||||
}
|
||||
onClick={() => {
|
||||
onClick={async () => {
|
||||
setTooltipOpen(false);
|
||||
const confirm = await confirmMultiResourceAction(
|
||||
policy,
|
||||
"edit",
|
||||
resource,
|
||||
);
|
||||
if (!confirm) return;
|
||||
openEditPolicyModal(policy);
|
||||
}}
|
||||
>
|
||||
@@ -118,18 +112,29 @@ export const ResourcePolicyCell = ({ resource }: Props) => {
|
||||
>
|
||||
<Badge
|
||||
variant={"gray"}
|
||||
useHover={false}
|
||||
className={"select-none hover:bg-nb-gray-910"}
|
||||
useHover={true}
|
||||
className={"select-none hover:bg-nb-gray-910 cursor-pointer"}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (!tooltipOpen) setTooltipOpen(true);
|
||||
if (!permission.networks.update) return;
|
||||
if (tooltipOpen) setTooltipOpen(false);
|
||||
openResourceModal(network, resource, "access-control");
|
||||
}}
|
||||
>
|
||||
<ShieldIcon size={14} className={"text-green-500"} />
|
||||
<ShieldIcon
|
||||
size={14}
|
||||
className={cn(
|
||||
enabledPolicies?.length > 0
|
||||
? "text-green-500"
|
||||
: "text-nb-gray-400",
|
||||
)}
|
||||
/>
|
||||
<div>
|
||||
<span className={"font-medium text-xs"}>
|
||||
{enabledPolicies?.length}
|
||||
{enabledPolicies?.length > 0
|
||||
? enabledPolicies?.length
|
||||
: `${policyCount} Disabled`}
|
||||
</span>
|
||||
</div>
|
||||
</Badge>
|
||||
@@ -139,11 +144,12 @@ export const ResourcePolicyCell = ({ resource }: Props) => {
|
||||
<Button
|
||||
size={"xs"}
|
||||
variant={"secondary"}
|
||||
className={"!px-3"}
|
||||
disabled={!permission.networks.update}
|
||||
onClick={() => openPolicyModal(network, resource)}
|
||||
onClick={() => openResourceModal(network, resource, "access-control")}
|
||||
>
|
||||
<PlusCircle size={12} />
|
||||
Add Policy
|
||||
<Settings size={12} />
|
||||
Configure
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -13,8 +13,9 @@ type Props = {
|
||||
label?: string;
|
||||
className?: string;
|
||||
onError?: (error: string) => void;
|
||||
description?: string;
|
||||
description?: React.ReactNode;
|
||||
placeholder?: string;
|
||||
autoFocus?: boolean;
|
||||
};
|
||||
export const ResourceSingleAddressInput = ({
|
||||
value,
|
||||
@@ -24,6 +25,7 @@ export const ResourceSingleAddressInput = ({
|
||||
onError,
|
||||
description = "Enter a single IP address, CIDR block or domain name",
|
||||
placeholder = "Address (IP, CIDR or Domain)",
|
||||
autoFocus,
|
||||
}: Props) => {
|
||||
const hasChars = useMemo(() => {
|
||||
return !!value.match(/[a-z*]/i);
|
||||
@@ -71,6 +73,7 @@ export const ResourceSingleAddressInput = ({
|
||||
<Label>{label}</Label>
|
||||
<HelpText>{description}</HelpText>
|
||||
<Input
|
||||
autoFocus={autoFocus}
|
||||
customPrefix={PrefixIcon}
|
||||
error={error}
|
||||
placeholder={placeholder}
|
||||
|
||||
@@ -77,7 +77,7 @@ const NetworkResourceColumns: ColumnDef<NetworkResource>[] = [
|
||||
return groups.map((group) => group.name).join(", ");
|
||||
},
|
||||
header: ({ column }) => {
|
||||
return <DataTableHeader column={column}>Groups</DataTableHeader>;
|
||||
return <DataTableHeader column={column}>Resource Groups</DataTableHeader>;
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
return <ResourceGroupCell resource={row.original} />;
|
||||
@@ -121,7 +121,12 @@ export default function ResourcesTable({
|
||||
const params = useSearchParams();
|
||||
const resourceId = params.get("resource") ?? undefined;
|
||||
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
const [sorting, setSorting] = useState<SortingState>([
|
||||
{
|
||||
id: "name",
|
||||
desc: false,
|
||||
},
|
||||
]);
|
||||
const { openResourceModal, network } = useNetworksContext();
|
||||
const router = useRouter();
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import NetworkRoutesIcon from "@/assets/icons/NetworkRoutesIcon";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import { useLocalStorage } from "@/hooks/useLocalStorage";
|
||||
import { Network } from "@/interfaces/Network";
|
||||
import { NetworkAccessControlProvider } from "@/modules/networks/NetworkAccessControlProvider";
|
||||
import {
|
||||
NetworkProvider,
|
||||
useNetworksContext,
|
||||
@@ -99,82 +100,84 @@ export default function NetworksTable({
|
||||
return (
|
||||
<>
|
||||
<GlobalSearchModal open={searchModal} setOpen={setSearchModal} />
|
||||
<NetworkProvider>
|
||||
<DataTable
|
||||
headingTarget={headingTarget}
|
||||
isLoading={isLoading}
|
||||
text={"Networks"}
|
||||
sorting={sorting}
|
||||
setSorting={setSorting}
|
||||
columns={NetworkTableColumns}
|
||||
data={data}
|
||||
searchPlaceholder={"Search by network name or description..."}
|
||||
columnVisibility={{
|
||||
description: false,
|
||||
}}
|
||||
onSearchClick={() => setSearchModal(true)}
|
||||
getStartedCard={
|
||||
<GetStartedTest
|
||||
icon={
|
||||
<SquareIcon
|
||||
icon={
|
||||
<NetworkRoutesIcon
|
||||
className={"fill-nb-gray-200"}
|
||||
size={20}
|
||||
/>
|
||||
}
|
||||
color={"gray"}
|
||||
size={"large"}
|
||||
/>
|
||||
}
|
||||
title={"Create New Network"}
|
||||
description={
|
||||
"It looks like you don't have any networks. Access internal resources in your LANs and VPC by adding a network."
|
||||
}
|
||||
button={
|
||||
<div className={"gap-x-4 flex items-center justify-center"}>
|
||||
<NetworkAccessControlProvider>
|
||||
<NetworkProvider>
|
||||
<DataTable
|
||||
headingTarget={headingTarget}
|
||||
isLoading={isLoading}
|
||||
text={"Networks"}
|
||||
sorting={sorting}
|
||||
setSorting={setSorting}
|
||||
columns={NetworkTableColumns}
|
||||
data={data}
|
||||
searchPlaceholder={"Search by network name or description..."}
|
||||
columnVisibility={{
|
||||
description: false,
|
||||
}}
|
||||
onSearchClick={() => setSearchModal(true)}
|
||||
getStartedCard={
|
||||
<GetStartedTest
|
||||
icon={
|
||||
<SquareIcon
|
||||
icon={
|
||||
<NetworkRoutesIcon
|
||||
className={"fill-nb-gray-200"}
|
||||
size={20}
|
||||
/>
|
||||
}
|
||||
color={"gray"}
|
||||
size={"large"}
|
||||
/>
|
||||
}
|
||||
title={"Create New Network"}
|
||||
description={
|
||||
"It looks like you don't have any networks. Access internal resources in your LANs and VPC by adding a network."
|
||||
}
|
||||
button={
|
||||
<div className={"gap-x-4 flex items-center justify-center"}>
|
||||
<AddNetworkButton />
|
||||
</div>
|
||||
}
|
||||
learnMore={
|
||||
<>
|
||||
Learn more about
|
||||
<InlineLink
|
||||
href={"https://docs.netbird.io/how-to/networks"}
|
||||
target={"_blank"}
|
||||
>
|
||||
Networks
|
||||
<ExternalLinkIcon size={12} />
|
||||
</InlineLink>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
}
|
||||
rightSide={() =>
|
||||
data &&
|
||||
data.length > 0 && (
|
||||
<div className={cn("gap-x-4 ml-auto flex")}>
|
||||
<AddNetworkButton />
|
||||
</div>
|
||||
}
|
||||
learnMore={
|
||||
<>
|
||||
Learn more about
|
||||
<InlineLink
|
||||
href={"https://docs.netbird.io/how-to/networks"}
|
||||
target={"_blank"}
|
||||
>
|
||||
Networks
|
||||
<ExternalLinkIcon size={12} />
|
||||
</InlineLink>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
}
|
||||
rightSide={() =>
|
||||
data &&
|
||||
data.length > 0 && (
|
||||
<div className={cn("gap-x-4 ml-auto flex")}>
|
||||
<AddNetworkButton />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
>
|
||||
{(table) => (
|
||||
<>
|
||||
<DataTableRowsPerPage
|
||||
table={table}
|
||||
disabled={data?.length == 0}
|
||||
/>
|
||||
<DataTableRefreshButton
|
||||
isDisabled={data?.length == 0}
|
||||
onClick={() => {
|
||||
mutate("/networks").then();
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</DataTable>
|
||||
</NetworkProvider>
|
||||
)
|
||||
}
|
||||
>
|
||||
{(table) => (
|
||||
<>
|
||||
<DataTableRowsPerPage
|
||||
table={table}
|
||||
disabled={data?.length == 0}
|
||||
/>
|
||||
<DataTableRefreshButton
|
||||
isDisabled={data?.length == 0}
|
||||
onClick={() => {
|
||||
mutate("/networks").then();
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</DataTable>
|
||||
</NetworkProvider>
|
||||
</NetworkAccessControlProvider>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -360,7 +360,7 @@ export default function ReverseProxyModal({
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value={"settings"} disabled={!canContinueToSettings}>
|
||||
<Settings size={14} />
|
||||
Settings
|
||||
Advanced Settings
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
@@ -791,6 +791,7 @@ export default function ReverseProxyModal({
|
||||
onOpenChange={setSsoModalOpen}
|
||||
key={ssoModalOpen ? "sso1" : "sso0"}
|
||||
currentGroups={bearerGroups}
|
||||
isEnabled={bearerEnabled}
|
||||
onSave={(groups) => {
|
||||
setTimeout(() => {
|
||||
setBearerGroups(groups);
|
||||
|
||||
@@ -5,11 +5,15 @@ import { PeerGroupSelector } from "@components/PeerGroupSelector";
|
||||
import { GradientFadedBackground } from "@components/ui/GradientFadedBackground";
|
||||
import React, { useState } from "react";
|
||||
import { Group } from "@/interfaces/Group";
|
||||
import { useUsers } from "@/contexts/UsersProvider";
|
||||
import Badge from "@components/Badge";
|
||||
import { CircleUser } from "lucide-react";
|
||||
|
||||
type Props = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
currentGroups: Group[];
|
||||
isEnabled: boolean;
|
||||
onSave: (groups: Group[]) => void;
|
||||
onRemove: () => void;
|
||||
};
|
||||
@@ -18,17 +22,17 @@ export default function AuthSSOModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
currentGroups,
|
||||
isEnabled,
|
||||
onSave,
|
||||
onRemove,
|
||||
}: Readonly<Props>) {
|
||||
const { users } = useUsers();
|
||||
const [groups, setGroups] = useState<Group[]>(currentGroups);
|
||||
const isEditing = currentGroups.length > 0;
|
||||
const isEditing = isEnabled;
|
||||
|
||||
const handleSave = () => {
|
||||
if (groups.length > 0) {
|
||||
onOpenChange(false);
|
||||
onSave(groups);
|
||||
}
|
||||
onOpenChange(false);
|
||||
onSave(groups);
|
||||
};
|
||||
|
||||
const handleRemove = () => {
|
||||
@@ -51,7 +55,17 @@ export default function AuthSSOModal({
|
||||
<PeerGroupSelector
|
||||
values={groups}
|
||||
onChange={setGroups}
|
||||
placeholder="Select distribution groups..."
|
||||
placeholder={
|
||||
<div className={"flex items-center gap-2"}>
|
||||
<Badge className={"py-[3px]"} variant={"gray-ghost"}>
|
||||
<CircleUser size={12} />
|
||||
All Users
|
||||
</Badge>
|
||||
Select user groups...
|
||||
</div>
|
||||
}
|
||||
users={users}
|
||||
hideAllGroup={true}
|
||||
/>
|
||||
<div className="flex gap-3 w-full justify-between mt-6">
|
||||
{isEditing ? (
|
||||
@@ -63,11 +77,7 @@ export default function AuthSSOModal({
|
||||
<ModalClose asChild>
|
||||
<Button variant="secondary">Cancel</Button>
|
||||
</ModalClose>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleSave}
|
||||
disabled={groups.length === 0}
|
||||
>
|
||||
<Button variant="primary" onClick={handleSave}>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
@@ -79,12 +89,8 @@ export default function AuthSSOModal({
|
||||
<ModalClose asChild>
|
||||
<Button variant="secondary">Cancel</Button>
|
||||
</ModalClose>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleSave}
|
||||
disabled={groups.length === 0}
|
||||
>
|
||||
Add Groups
|
||||
<Button variant="primary" onClick={handleSave}>
|
||||
Add SSO
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -11,12 +11,10 @@ import GetStartedTest from "@components/ui/GetStartedTest";
|
||||
import type { ColumnDef, SortingState } from "@tanstack/react-table";
|
||||
import { ExternalLinkIcon } from "lucide-react";
|
||||
import ReverseProxyIcon from "@/assets/icons/ReverseProxyIcon";
|
||||
import { usePathname } from "next/navigation";
|
||||
import dayjs from "dayjs";
|
||||
import React, { useCallback, useMemo } from "react";
|
||||
import React, { useCallback, useMemo, useState } from "react";
|
||||
import { DateRange } from "react-day-picker";
|
||||
import { DatePickerWithRange } from "@components/DatePickerWithRange";
|
||||
import { useLocalStorage } from "@/hooks/useLocalStorage";
|
||||
import { useServerPagination } from "@/contexts/ServerPaginationProvider";
|
||||
import {
|
||||
REVERSE_PROXY_EVENTS_DOCS_LINK,
|
||||
@@ -38,7 +36,9 @@ export const ReverseProxyEventsTableColumns: ColumnDef<ReverseProxyEvent>[] = [
|
||||
{
|
||||
id: "timestamp",
|
||||
header: ({ column }) => (
|
||||
<DataTableHeader column={column} sorting={false}>Time</DataTableHeader>
|
||||
<DataTableHeader column={column} name="timestamp">
|
||||
Time
|
||||
</DataTableHeader>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<ReverseProxyEventsTimeCell timestamp={row.original.timestamp} />
|
||||
@@ -52,7 +52,9 @@ export const ReverseProxyEventsTableColumns: ColumnDef<ReverseProxyEvent>[] = [
|
||||
accessorFn: (row) =>
|
||||
`${row.source_ip} ${row.city_name || ""} ${row.country_code || ""}`,
|
||||
header: ({ column }) => (
|
||||
<DataTableHeader column={column} sorting={false}>Location / IP</DataTableHeader>
|
||||
<DataTableHeader column={column} name="source_ip">
|
||||
Location / IP
|
||||
</DataTableHeader>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<ReverseProxyEventsLocationIpCell event={row.original} />
|
||||
@@ -62,7 +64,9 @@ export const ReverseProxyEventsTableColumns: ColumnDef<ReverseProxyEvent>[] = [
|
||||
id: "method",
|
||||
accessorKey: "method",
|
||||
header: ({ column }) => (
|
||||
<DataTableHeader column={column} sorting={false}>Method</DataTableHeader>
|
||||
<DataTableHeader column={column} name="method">
|
||||
Method
|
||||
</DataTableHeader>
|
||||
),
|
||||
cell: ({ row }) => <ReverseProxyEventsMethodCell event={row.original} />,
|
||||
filterFn: "arrIncludesSomeExact",
|
||||
@@ -71,7 +75,9 @@ export const ReverseProxyEventsTableColumns: ColumnDef<ReverseProxyEvent>[] = [
|
||||
id: "url",
|
||||
accessorFn: (row) => `${row.host} ${row.path}`,
|
||||
header: ({ column }) => (
|
||||
<DataTableHeader column={column} sorting={false}>URL</DataTableHeader>
|
||||
<DataTableHeader column={column} name="url">
|
||||
URL
|
||||
</DataTableHeader>
|
||||
),
|
||||
cell: ({ row }) => <ReverseProxyEventsUrlCell event={row.original} />,
|
||||
},
|
||||
@@ -79,7 +85,9 @@ export const ReverseProxyEventsTableColumns: ColumnDef<ReverseProxyEvent>[] = [
|
||||
id: "status",
|
||||
accessorKey: "status_code",
|
||||
header: ({ column }) => (
|
||||
<DataTableHeader column={column} sorting={false}>Status</DataTableHeader>
|
||||
<DataTableHeader column={column} name="status_code">
|
||||
Status
|
||||
</DataTableHeader>
|
||||
),
|
||||
cell: ({ row }) => <ReverseProxyEventsStatusCell event={row.original} />,
|
||||
size: 80,
|
||||
@@ -94,7 +102,9 @@ export const ReverseProxyEventsTableColumns: ColumnDef<ReverseProxyEvent>[] = [
|
||||
id: "duration",
|
||||
accessorKey: "duration_ms",
|
||||
header: ({ column }) => (
|
||||
<DataTableHeader column={column} sorting={false}>Duration</DataTableHeader>
|
||||
<DataTableHeader column={column} name="duration">
|
||||
Duration
|
||||
</DataTableHeader>
|
||||
),
|
||||
cell: ({ row }) => <ReverseProxyEventsDurationCell event={row.original} />,
|
||||
},
|
||||
@@ -102,7 +112,9 @@ export const ReverseProxyEventsTableColumns: ColumnDef<ReverseProxyEvent>[] = [
|
||||
id: "auth_method",
|
||||
accessorKey: "auth_method_used",
|
||||
header: ({ column }) => (
|
||||
<DataTableHeader column={column} sorting={false}>Auth Method</DataTableHeader>
|
||||
<DataTableHeader column={column} name="auth_method">
|
||||
Auth Method
|
||||
</DataTableHeader>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<ReverseProxyEventsAuthMethodCell event={row.original} />
|
||||
@@ -112,7 +124,9 @@ export const ReverseProxyEventsTableColumns: ColumnDef<ReverseProxyEvent>[] = [
|
||||
id: "reason",
|
||||
accessorKey: "reason",
|
||||
header: ({ column }) => (
|
||||
<DataTableHeader column={column} sorting={false}>Reason</DataTableHeader>
|
||||
<DataTableHeader column={column} name="reason">
|
||||
Reason
|
||||
</DataTableHeader>
|
||||
),
|
||||
cell: ({ row }) => <ReverseProxyEventsReasonCell event={row.original} />,
|
||||
},
|
||||
@@ -120,7 +134,9 @@ export const ReverseProxyEventsTableColumns: ColumnDef<ReverseProxyEvent>[] = [
|
||||
id: "user",
|
||||
accessorFn: (row) => row.user_id || "",
|
||||
header: ({ column }) => (
|
||||
<DataTableHeader column={column} sorting={false}>User</DataTableHeader>
|
||||
<DataTableHeader column={column} name="user_id">
|
||||
User
|
||||
</DataTableHeader>
|
||||
),
|
||||
cell: ({ row }) => <ReverseProxyEventsUserCell event={row.original} />,
|
||||
},
|
||||
@@ -136,8 +152,6 @@ type Props = {
|
||||
export default function ReverseProxyEventsTable({
|
||||
headingTarget,
|
||||
}: Readonly<Props>) {
|
||||
const path = usePathname();
|
||||
|
||||
const {
|
||||
data: events,
|
||||
isLoading,
|
||||
@@ -174,15 +188,12 @@ export default function ReverseProxyEventsTable({
|
||||
[setFilter],
|
||||
);
|
||||
|
||||
const [sorting, setSorting] = useLocalStorage<SortingState>(
|
||||
"netbird-table-sort" + path,
|
||||
[
|
||||
{
|
||||
id: "timestamp",
|
||||
desc: true,
|
||||
},
|
||||
],
|
||||
);
|
||||
const [sorting, setSorting] = useState<SortingState>([
|
||||
{
|
||||
id: "timestamp",
|
||||
desc: true,
|
||||
},
|
||||
]);
|
||||
|
||||
return (
|
||||
<DataTable
|
||||
|
||||
@@ -1,11 +1,51 @@
|
||||
import Badge from "@components/Badge";
|
||||
import Button from "@components/Button";
|
||||
import { Settings, ShieldCheck, ShieldOff } from "lucide-react";
|
||||
import {
|
||||
HoverCard,
|
||||
HoverCardContent,
|
||||
HoverCardTrigger,
|
||||
} from "@components/HoverCard";
|
||||
import { ListItem } from "@components/ListItem";
|
||||
import GroupBadge from "@components/ui/GroupBadge";
|
||||
import { UserCountStack } from "@components/ui/MultipleGroups";
|
||||
import {
|
||||
ArrowRightIcon,
|
||||
Binary,
|
||||
LucideIcon,
|
||||
RectangleEllipsis,
|
||||
Settings,
|
||||
ShieldCheck,
|
||||
ShieldOff,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { useGroups } from "@/contexts/GroupsProvider";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import { useReverseProxies } from "@/contexts/ReverseProxiesProvider";
|
||||
import { Group } from "@/interfaces/Group";
|
||||
import { ReverseProxy } from "@/interfaces/ReverseProxy";
|
||||
|
||||
const AUTH_METHODS: {
|
||||
key: "password_auth" | "pin_auth" | "bearer_auth";
|
||||
label: string;
|
||||
hoverLabel: string;
|
||||
Icon: LucideIcon;
|
||||
}[] = [
|
||||
{
|
||||
key: "password_auth",
|
||||
label: "Password",
|
||||
hoverLabel: "Password",
|
||||
Icon: RectangleEllipsis,
|
||||
},
|
||||
{ key: "pin_auth", label: "PIN Code", hoverLabel: "PIN Code", Icon: Binary },
|
||||
{
|
||||
key: "bearer_auth",
|
||||
label: "SSO",
|
||||
hoverLabel: "SSO (Single Sign On)",
|
||||
Icon: Users,
|
||||
},
|
||||
];
|
||||
|
||||
type Props = {
|
||||
reverseProxy: ReverseProxy;
|
||||
};
|
||||
@@ -15,14 +55,36 @@ export default function ReverseProxyAuthCell({
|
||||
}: Readonly<Props>) {
|
||||
const { permission } = usePermissions();
|
||||
const { openModal } = useReverseProxies();
|
||||
const { groups } = useGroups();
|
||||
const auth = reverseProxy.auth;
|
||||
|
||||
const enabledCount = [
|
||||
auth?.bearer_auth?.enabled,
|
||||
auth?.link_auth?.enabled,
|
||||
auth?.password_auth?.enabled,
|
||||
auth?.pin_auth?.enabled,
|
||||
].filter(Boolean).length;
|
||||
const enabled = AUTH_METHODS.filter((m) => auth?.[m.key]?.enabled);
|
||||
|
||||
const ssoGroups = auth?.bearer_auth?.enabled
|
||||
? (auth.bearer_auth.distribution_groups ?? [])
|
||||
.map((groupId) => groups?.find((g) => g.id === groupId))
|
||||
.filter((g): g is Group => g != undefined)
|
||||
: [];
|
||||
|
||||
const showHoverContent =
|
||||
enabled.length > 1 || (enabled.length === 1 && auth?.bearer_auth?.enabled);
|
||||
|
||||
const SingleIcon = enabled.length === 1 ? enabled[0].Icon : null;
|
||||
|
||||
const badgeContent =
|
||||
SingleIcon ? (
|
||||
<>
|
||||
<SingleIcon size={12} className="text-green-500" />
|
||||
<span className={"font-medium text-xs"}>{enabled[0].label}</span>
|
||||
</>
|
||||
) : enabled.length > 1 ? (
|
||||
<>
|
||||
<ShieldCheck size={12} className="text-green-400" />
|
||||
<span className={"font-medium text-xs"}>
|
||||
{enabled.length} Enabled
|
||||
</span>
|
||||
</>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -32,19 +94,66 @@ export default function ReverseProxyAuthCell({
|
||||
openModal({ proxy: reverseProxy, initialTab: "auth" });
|
||||
}}
|
||||
>
|
||||
{enabledCount > 0 ? (
|
||||
<Badge variant={"gray"} useHover={false} className={"cursor-pointer"}>
|
||||
<ShieldCheck size={12} className="text-green-400" />
|
||||
<div>
|
||||
<span className={"font-medium text-xs"}>Enabled</span>
|
||||
</div>
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant={"gray"}>
|
||||
<ShieldOff size={12} className="text-red-500" />
|
||||
<span className={"font-medium text-xs"}>None</span>
|
||||
</Badge>
|
||||
)}
|
||||
<HoverCard openDelay={200} closeDelay={100}>
|
||||
<HoverCardTrigger asChild={true}>
|
||||
{badgeContent ? (
|
||||
<Badge
|
||||
variant={"gray"}
|
||||
useHover={false}
|
||||
className={"cursor-pointer"}
|
||||
>
|
||||
{badgeContent}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant={"gray"}>
|
||||
<ShieldOff size={12} className="text-red-500" />
|
||||
<span className={"font-medium text-xs"}>None</span>
|
||||
</Badge>
|
||||
)}
|
||||
</HoverCardTrigger>
|
||||
{showHoverContent && (
|
||||
<HoverCardContent
|
||||
className={"p-0"}
|
||||
sideOffset={14}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className={"text-xs"}>
|
||||
{enabled.map(({ key, hoverLabel, Icon }) => (
|
||||
<ListItem
|
||||
key={key}
|
||||
className={"py-0.5"}
|
||||
icon={<Icon size={14} />}
|
||||
label={hoverLabel}
|
||||
value={
|
||||
<div className={"text-green-500"}>
|
||||
{key === "bearer_auth" && ssoGroups.length === 0
|
||||
? "All Users"
|
||||
: "Enabled"}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{key === "bearer_auth" && ssoGroups.length > 0 && (
|
||||
<div className={"flex flex-col gap-2 px-4 pt-2 pb-3"}>
|
||||
{ssoGroups.map((group) => (
|
||||
<div
|
||||
key={group.id}
|
||||
className={
|
||||
"flex gap-2 items-center justify-between"
|
||||
}
|
||||
>
|
||||
<GroupBadge group={group} />
|
||||
<ArrowRightIcon size={14} />
|
||||
<UserCountStack group={group} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</ListItem>
|
||||
))}
|
||||
</div>
|
||||
</HoverCardContent>
|
||||
)}
|
||||
</HoverCard>
|
||||
|
||||
<Button
|
||||
size={"xs"}
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
import useFetchApi from "@utils/api";
|
||||
import Badge from "@components/Badge";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useMemo } from "react";
|
||||
import { useRef } from "react";
|
||||
|
||||
type Props = {
|
||||
serviceId: string;
|
||||
@@ -21,38 +21,33 @@ export default function ReverseProxyStatusCell({
|
||||
meta,
|
||||
enabled,
|
||||
}: Readonly<Props>) {
|
||||
const status = meta?.status;
|
||||
const certificateIssued = !!meta?.certificate_issued_at;
|
||||
const dataRef = useRef<ReverseProxy | undefined>(undefined);
|
||||
|
||||
const isSettingUp =
|
||||
enabled &&
|
||||
status !== undefined &&
|
||||
status !== ReverseProxyStatus.ACTIVE &&
|
||||
!certificateIssued;
|
||||
const isActive =
|
||||
meta?.status === ReverseProxyStatus.ACTIVE ||
|
||||
dataRef.current?.meta?.status === ReverseProxyStatus.ACTIVE;
|
||||
|
||||
const certificateIssued =
|
||||
!!meta?.certificate_issued_at ||
|
||||
!!dataRef.current?.meta?.certificate_issued_at;
|
||||
|
||||
const shouldPoll = !!enabled && !(isActive && certificateIssued);
|
||||
|
||||
const { data } = useFetchApi<ReverseProxy>(
|
||||
`/reverse-proxies/services/${serviceId}`,
|
||||
true,
|
||||
false,
|
||||
isSettingUp,
|
||||
shouldPoll,
|
||||
{ refreshInterval: POLL_INTERVAL_MS },
|
||||
);
|
||||
|
||||
const currentStatus = data?.meta?.status ?? status;
|
||||
dataRef.current = data;
|
||||
|
||||
const currentCertificateIssued = useMemo(() => {
|
||||
if (data && data?.meta) return !!data?.meta?.certificate_issued_at;
|
||||
return certificateIssued;
|
||||
}, [data]);
|
||||
|
||||
if (
|
||||
!enabled ||
|
||||
(currentStatus === ReverseProxyStatus.ACTIVE && currentCertificateIssued)
|
||||
) {
|
||||
if (!enabled || (isActive && certificateIssued)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!currentCertificateIssued) {
|
||||
if (!certificateIssued) {
|
||||
return (
|
||||
<div className={"flex"}>
|
||||
<Badge variant={"yellow"}>
|
||||
|
||||
@@ -75,7 +75,7 @@ const ReverseProxyColumns: ColumnDef<ReverseProxy>[] = [
|
||||
{
|
||||
accessorKey: "auth",
|
||||
header: ({ column }) => {
|
||||
return <DataTableHeader column={column}>Authentication</DataTableHeader>;
|
||||
return <DataTableHeader column={column}>Auth Methods</DataTableHeader>;
|
||||
},
|
||||
cell: ({ row }) => <ReverseProxyAuthCell reverseProxy={row.original} />,
|
||||
},
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
import Button from "@components/Button";
|
||||
import HelpText from "@components/HelpText";
|
||||
import { Input } from "@components/Input";
|
||||
import { Label } from "@components/Label";
|
||||
import { MinusCircleIcon, PlusIcon } from "lucide-react";
|
||||
import { useCallback, useState } from "react";
|
||||
|
||||
const HEADER_NAME_RE = /^[A-Za-z0-9!#$%&'*+\-.^_`|~]+$/;
|
||||
const BLOCKED_HEADERS = new Set([
|
||||
"host",
|
||||
"connection",
|
||||
"transfer-encoding",
|
||||
"keep-alive",
|
||||
"proxy-authenticate",
|
||||
"proxy-authorization",
|
||||
"te",
|
||||
"trailer",
|
||||
"upgrade",
|
||||
]);
|
||||
|
||||
type HeaderEntry = { id: number; name: string; value: string };
|
||||
|
||||
function recordToHeaderEntries(
|
||||
record: Record<string, string> | undefined,
|
||||
nextId: () => number,
|
||||
): HeaderEntry[] {
|
||||
if (!record) return [];
|
||||
return Object.entries(record).map(([name, value]) => ({
|
||||
id: nextId(),
|
||||
name,
|
||||
value,
|
||||
}));
|
||||
}
|
||||
|
||||
export function headerEntriesToRecord(
|
||||
entries: HeaderEntry[],
|
||||
): Record<string, string> | undefined {
|
||||
if (entries.length === 0) return undefined;
|
||||
const record: Record<string, string> = {};
|
||||
for (const entry of entries) {
|
||||
if (entry.name) record[entry.name] = entry.value;
|
||||
}
|
||||
return Object.keys(record).length > 0 ? record : undefined;
|
||||
}
|
||||
|
||||
function validateHeaderName(
|
||||
name: string,
|
||||
allNames: string[],
|
||||
): string | undefined {
|
||||
if (!name) return undefined;
|
||||
if (!HEADER_NAME_RE.test(name))
|
||||
return "Invalid characters in header name. Please use another one.";
|
||||
if (BLOCKED_HEADERS.has(name.toLowerCase()))
|
||||
return `"${name}" is a reserved header. Please use another one.`;
|
||||
const dupeCount = allNames.filter(
|
||||
(n) => n.toLowerCase() === name.toLowerCase(),
|
||||
).length;
|
||||
if (dupeCount > 1) return "Duplicate header name. Please use another one.";
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function validateHeaderValue(value: string): string | undefined {
|
||||
if (value.includes("\r") || value.includes("\n"))
|
||||
return "Value must not contain line breaks";
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function useCustomHeaders(initialHeaders?: Record<string, string>) {
|
||||
const [nextId] = useState(() => {
|
||||
let id = 0;
|
||||
return () => ++id;
|
||||
});
|
||||
|
||||
const [headerEntries, setHeaderEntries] = useState<HeaderEntry[]>(() =>
|
||||
recordToHeaderEntries(initialHeaders, nextId),
|
||||
);
|
||||
|
||||
const addHeader = useCallback(() => {
|
||||
setHeaderEntries((prev) => [
|
||||
...prev,
|
||||
{ id: nextId(), name: "", value: "" },
|
||||
]);
|
||||
}, [nextId]);
|
||||
|
||||
const removeHeader = useCallback((id: number) => {
|
||||
setHeaderEntries((prev) => prev.filter((h) => h.id !== id));
|
||||
}, []);
|
||||
|
||||
const updateHeaderEntry = useCallback(
|
||||
(id: number, field: "name" | "value", fieldValue: string) => {
|
||||
setHeaderEntries((prev) =>
|
||||
prev.map((h) => (h.id === id ? { ...h, [field]: fieldValue } : h)),
|
||||
);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const allHeaderNames = headerEntries.map((h) => h.name);
|
||||
const headerErrors = headerEntries.map((entry) => ({
|
||||
name: validateHeaderName(entry.name, allHeaderNames),
|
||||
value: validateHeaderValue(entry.value),
|
||||
}));
|
||||
|
||||
const hasHeaderErrors = headerErrors.some((e) => e.name || e.value);
|
||||
|
||||
return {
|
||||
headerEntries,
|
||||
setHeaderEntries,
|
||||
addHeader,
|
||||
removeHeader,
|
||||
updateHeaderEntry,
|
||||
headerErrors,
|
||||
hasHeaderErrors,
|
||||
};
|
||||
}
|
||||
|
||||
export type CustomHeadersProps = Pick<
|
||||
ReturnType<typeof useCustomHeaders>,
|
||||
| "headerEntries"
|
||||
| "addHeader"
|
||||
| "removeHeader"
|
||||
| "updateHeaderEntry"
|
||||
| "headerErrors"
|
||||
>;
|
||||
|
||||
export default function ReverseProxyTargetCustomHeaders({
|
||||
headerEntries,
|
||||
addHeader,
|
||||
removeHeader,
|
||||
updateHeaderEntry,
|
||||
headerErrors,
|
||||
}: CustomHeadersProps) {
|
||||
return (
|
||||
<div>
|
||||
<Label>Custom Headers</Label>
|
||||
<HelpText>
|
||||
Add additional headers to include when forwarding requests.
|
||||
<br />
|
||||
Hop-by-hop headers like Host or Connection are not allowed.
|
||||
</HelpText>
|
||||
{headerEntries.length > 0 && (
|
||||
<div className="flex flex-col gap-2 mb-3">
|
||||
{headerEntries.map((entry, index) => (
|
||||
<div key={entry.id} className="flex items-center gap-2">
|
||||
<Input
|
||||
placeholder="Header, e.g., Authorization"
|
||||
aria-label={`Header name for entry ${entry.id}`}
|
||||
value={entry.name}
|
||||
onChange={(e) =>
|
||||
updateHeaderEntry(entry.id, "name", e.target.value)
|
||||
}
|
||||
maxWidthClass="flex-1"
|
||||
error={headerErrors[index]?.name}
|
||||
errorTooltip
|
||||
/>
|
||||
<Input
|
||||
placeholder="Value, e.g., Bearer eyJhbGci..."
|
||||
aria-label={`Header value for entry ${entry.id}`}
|
||||
value={entry.value}
|
||||
onChange={(e) =>
|
||||
updateHeaderEntry(entry.id, "value", e.target.value)
|
||||
}
|
||||
maxWidthClass="flex-1"
|
||||
error={headerErrors[index]?.value}
|
||||
errorTooltip
|
||||
/>
|
||||
<Button
|
||||
variant="default-outline"
|
||||
className="!px-2 shrink-0"
|
||||
onClick={() => removeHeader(entry.id)}
|
||||
aria-label="Remove header"
|
||||
>
|
||||
<MinusCircleIcon size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<Button variant="dotted" className="w-full" size="sm" onClick={addHeader}>
|
||||
<PlusIcon size={14} />
|
||||
Add Header
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@components/Accordion";
|
||||
import Button from "@components/Button";
|
||||
import { Checkbox } from "@components/Checkbox";
|
||||
import FancyToggleSwitch from "@components/FancyToggleSwitch";
|
||||
import HelpText from "@components/HelpText";
|
||||
import { Input } from "@components/Input";
|
||||
import { Label } from "@components/Label";
|
||||
@@ -15,14 +9,17 @@ import { Modal, ModalContent, ModalFooter } from "@components/modal/Modal";
|
||||
import ModalHeader from "@components/modal/ModalHeader";
|
||||
import { PeerGroupSelector } from "@components/PeerGroupSelector";
|
||||
import { SelectDropdown } from "@components/select/SelectDropdown";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@components/Tabs";
|
||||
import useFetchApi from "@utils/api";
|
||||
import {
|
||||
AlertTriangle,
|
||||
ClockFadingIcon,
|
||||
ExternalLinkIcon,
|
||||
HelpCircle,
|
||||
PlusCircle,
|
||||
Server,
|
||||
Settings,
|
||||
ShieldXIcon,
|
||||
Text,
|
||||
} from "lucide-react";
|
||||
import { Callout } from "@components/Callout";
|
||||
import cidr from "ip-cidr";
|
||||
@@ -35,18 +32,19 @@ import {
|
||||
ReverseProxyTarget,
|
||||
ReverseProxyTargetProtocol,
|
||||
ReverseProxyTargetType,
|
||||
ServiceTargetOptionsPathRewrite,
|
||||
} from "@/interfaces/ReverseProxy";
|
||||
import {
|
||||
defaultPortForProtocol,
|
||||
isResourceTargetType,
|
||||
} from "@/contexts/ReverseProxiesProvider";
|
||||
import Separator from "@components/Separator";
|
||||
import { cn } from "@utils/helpers";
|
||||
import { HelpTooltip } from "@components/HelpTooltip";
|
||||
import InlineLink, { InlineButtonLink } from "@components/InlineLink";
|
||||
import SetupModal from "@/modules/setup-netbird-modal/SetupModal";
|
||||
import FullTooltip from "@components/FullTooltip";
|
||||
import Paragraph from "@components/Paragraph";
|
||||
import ReverseProxyTargetCustomHeaders from "@/modules/reverse-proxy/targets/ReverseProxyTargetCustomHeaders";
|
||||
import { useReverseProxyTargetOptions } from "@/modules/reverse-proxy/targets/useReverseProxyTargetOptions";
|
||||
|
||||
/** Get initial host value based on target, resource, or peer */
|
||||
function getInitialHost(
|
||||
@@ -93,6 +91,8 @@ export default function ReverseProxyTargetModal({
|
||||
);
|
||||
const { data: peers } = useFetchApi<Peer[]>("/peers");
|
||||
|
||||
const [tab, setTab] = useState("details");
|
||||
|
||||
const [targetType, setTargetType] = useState<ReverseProxyTargetType>(
|
||||
currentTarget?.target_type ??
|
||||
(initialResource
|
||||
@@ -121,9 +121,9 @@ export default function ReverseProxyTargetModal({
|
||||
currentTarget?.port ?? 0,
|
||||
);
|
||||
const [targetPath, setTargetPath] = useState(currentTarget?.path ?? "");
|
||||
const [accessLocal, setAccessLocal] = useState(
|
||||
currentTarget?.access_local ?? false,
|
||||
);
|
||||
const [accessLocal] = useState(currentTarget?.access_local ?? false);
|
||||
const [options, setOption, { getTargetOptions, headers, errors }] =
|
||||
useReverseProxyTargetOptions(currentTarget?.options);
|
||||
const portInputRef = useRef<HTMLInputElement>(null);
|
||||
const [installModal, setInstallModal] = useState(false);
|
||||
|
||||
@@ -232,7 +232,8 @@ export default function ReverseProxyTargetModal({
|
||||
|
||||
const handleSave = () => {
|
||||
const resolvedType = initialPeer ? ReverseProxyTargetType.PEER : targetType;
|
||||
const isResource = isResourceTargetType(resolvedType) || !!initialResource;
|
||||
const resolvedIsResource =
|
||||
isResourceTargetType(resolvedType) || !!initialResource;
|
||||
const targetData: ReverseProxyTarget = {
|
||||
target_type: resolvedType,
|
||||
target_id:
|
||||
@@ -245,19 +246,17 @@ export default function ReverseProxyTargetModal({
|
||||
port: targetPort,
|
||||
path: targetPath || undefined,
|
||||
enabled: currentTarget?.enabled ?? true,
|
||||
access_local: isResource ? accessLocal : undefined,
|
||||
access_local: resolvedIsResource ? accessLocal : undefined,
|
||||
options: getTargetOptions(),
|
||||
};
|
||||
onSave(targetData);
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const showAdvancedSettings = false;
|
||||
// const showAdvancedSettings = !!hasTarget && (isResourceTargetType(targetType) || !!initialResource);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal open={open} onOpenChange={onOpenChange}>
|
||||
<ModalContent maxWidthClass="max-w-xl">
|
||||
<ModalContent maxWidthClass="max-w-2xl">
|
||||
<ModalHeader
|
||||
icon={<Server className="text-netbird" size={16} />}
|
||||
title={currentTarget ? "Edit Target" : "Add Target"}
|
||||
@@ -265,308 +264,397 @@ export default function ReverseProxyTargetModal({
|
||||
color="netbird"
|
||||
/>
|
||||
|
||||
<Separator />
|
||||
<Tabs value={tab} onValueChange={setTab}>
|
||||
<TabsList justify={"start"} className={"px-8"}>
|
||||
<TabsTrigger value={"details"}>
|
||||
<Text size={14} />
|
||||
Details
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value={"options"} disabled={!canAddTarget}>
|
||||
<Settings size={14} />
|
||||
Advanced Settings
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"px-8 py-6 pt-4 flex flex-col gap-6",
|
||||
!showAdvancedSettings && "mb-3",
|
||||
)}
|
||||
>
|
||||
{!initialResource && !initialPeer && (
|
||||
<div>
|
||||
<Label className={"gap-0 inline"}>
|
||||
{initialNetwork ? (
|
||||
"Select Resource"
|
||||
) : (
|
||||
<>
|
||||
Select{" "}
|
||||
<HelpTooltip
|
||||
content={
|
||||
<div className="max-w-sm text-xs">
|
||||
A{" "}
|
||||
<span className={"text-white font-medium"}>
|
||||
peer
|
||||
</span>{" "}
|
||||
is a machine (e.g., laptop, server, container)
|
||||
running NetBird. Select a peer if your service runs
|
||||
directly on it.
|
||||
<span className={"mt-1 block"}>
|
||||
If you don't have a peer yet, you can{" "}
|
||||
<InlineButtonLink
|
||||
onClick={() => setInstallModal(true)}
|
||||
>
|
||||
Install NetBird
|
||||
</InlineButtonLink>
|
||||
.
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
Peer
|
||||
</HelpTooltip>{" "}
|
||||
or{" "}
|
||||
<HelpTooltip
|
||||
content={
|
||||
<div className="max-w-sm text-xs">
|
||||
A{" "}
|
||||
<span className={"text-white font-medium"}>
|
||||
resource
|
||||
</span>{" "}
|
||||
is a destination (IP, subnet, or domain) that
|
||||
can't run NetBird directly. Resources are part
|
||||
of a network and are reached through a routing peer
|
||||
that forwards traffic to them.
|
||||
<span className={"mt-1 block"}>
|
||||
If you don't have resources yet, go to{" "}
|
||||
<InlineLink href={"/networks"}>
|
||||
Networks
|
||||
</InlineLink>{" "}
|
||||
to create some.
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
Resource
|
||||
</HelpTooltip>
|
||||
</>
|
||||
)}
|
||||
</Label>
|
||||
<TabsContent value={"details"} className={"pb-8"}>
|
||||
<div className="px-8 flex flex-col gap-8">
|
||||
{!initialResource && !initialPeer && (
|
||||
<div>
|
||||
<Label className={"gap-0 inline"}>
|
||||
{initialNetwork ? (
|
||||
"Select Resource"
|
||||
) : (
|
||||
<>
|
||||
Select{" "}
|
||||
<HelpTooltip
|
||||
className={"max-w-sm"}
|
||||
content={
|
||||
<>
|
||||
A{" "}
|
||||
<span className={"text-white font-medium"}>
|
||||
peer
|
||||
</span>{" "}
|
||||
is a machine (e.g., laptop, server, container)
|
||||
running NetBird. Select a peer if your service
|
||||
runs directly on it.
|
||||
<span className={"mt-1 block"}>
|
||||
If you don't have a peer yet, you can{" "}
|
||||
<InlineButtonLink
|
||||
onClick={() => setInstallModal(true)}
|
||||
>
|
||||
Install NetBird
|
||||
</InlineButtonLink>
|
||||
.
|
||||
</span>
|
||||
</>
|
||||
}
|
||||
interactive={true}
|
||||
>
|
||||
Peer
|
||||
</HelpTooltip>{" "}
|
||||
or{" "}
|
||||
<HelpTooltip
|
||||
className={"max-w-sm"}
|
||||
content={
|
||||
<>
|
||||
A{" "}
|
||||
<span className={"text-white font-medium"}>
|
||||
resource
|
||||
</span>{" "}
|
||||
is a destination (IP, subnet, or domain) that
|
||||
can't run NetBird directly. Resources are
|
||||
part of a network and are reached through a
|
||||
routing peer that forwards traffic to them.
|
||||
<span className={"mt-1 block"}>
|
||||
If you don't have resources yet, go to{" "}
|
||||
<InlineLink href={"/networks"}>
|
||||
Networks
|
||||
</InlineLink>{" "}
|
||||
to create some.
|
||||
</span>
|
||||
</>
|
||||
}
|
||||
interactive={true}
|
||||
>
|
||||
Resource
|
||||
</HelpTooltip>
|
||||
</>
|
||||
)}
|
||||
</Label>
|
||||
|
||||
<HelpText>
|
||||
{initialNetwork
|
||||
? "Select the resource from your network you want to expose."
|
||||
: "Select the peer where your service is running or select a resource to expose it."}
|
||||
</HelpText>
|
||||
<PeerGroupSelector
|
||||
values={[]}
|
||||
onChange={() => {}}
|
||||
placeholder={
|
||||
initialNetwork
|
||||
? "Select a resource..."
|
||||
: "Select a peer or resource..."
|
||||
}
|
||||
showPeers={!initialNetwork}
|
||||
showResources={true}
|
||||
showRoutes={false}
|
||||
hideAllGroup={true}
|
||||
hideGroupsTab={true}
|
||||
resourceIds={
|
||||
initialNetwork ? initialNetwork.resources ?? [] : undefined
|
||||
}
|
||||
tabOrder={
|
||||
initialNetwork ? ["resources"] : ["peers", "resources"]
|
||||
}
|
||||
closeOnSelect={true}
|
||||
max={1}
|
||||
resource={
|
||||
isResourceTargetType(targetType) && targetResourceId
|
||||
? { id: targetResourceId, type: "host" }
|
||||
: targetType === ReverseProxyTargetType.PEER &&
|
||||
targetPeerId
|
||||
? { id: targetPeerId, type: "peer" }
|
||||
: undefined
|
||||
}
|
||||
onResourceChange={(res) => {
|
||||
if (res) {
|
||||
if (res.type === "peer") {
|
||||
setTargetType(ReverseProxyTargetType.PEER);
|
||||
setTargetPeerId(res.id);
|
||||
setTargetResourceId(undefined);
|
||||
const peer = peers?.find((p) => p.id === res.id);
|
||||
setTargetHost(peer?.ip || "localhost");
|
||||
} else {
|
||||
const selectedResource = resources?.find(
|
||||
(r) => r.id === res.id,
|
||||
);
|
||||
setTargetType(
|
||||
(selectedResource?.type as ReverseProxyTargetType) ??
|
||||
ReverseProxyTargetType.HOST,
|
||||
);
|
||||
setTargetResourceId(res.id);
|
||||
setTargetPeerId(undefined);
|
||||
const address = selectedResource?.address || "";
|
||||
// If CIDR range, pre-fill with base IP
|
||||
if (address.includes("/")) {
|
||||
setTargetHost(address.split("/")[0]);
|
||||
<HelpText>
|
||||
{initialNetwork
|
||||
? "Select the resource from your network you want to expose."
|
||||
: "Select the peer where your service is running or select a resource to expose it."}
|
||||
</HelpText>
|
||||
<PeerGroupSelector
|
||||
values={[]}
|
||||
onChange={() => {}}
|
||||
placeholder={
|
||||
initialNetwork
|
||||
? "Select a resource..."
|
||||
: "Select a peer or resource..."
|
||||
}
|
||||
showPeers={!initialNetwork}
|
||||
showResources={true}
|
||||
showRoutes={false}
|
||||
hideAllGroup={true}
|
||||
hideGroupsTab={true}
|
||||
resourceIds={
|
||||
initialNetwork
|
||||
? initialNetwork.resources ?? []
|
||||
: undefined
|
||||
}
|
||||
tabOrder={
|
||||
initialNetwork ? ["resources"] : ["peers", "resources"]
|
||||
}
|
||||
closeOnSelect={true}
|
||||
max={1}
|
||||
resource={
|
||||
isResourceTargetType(targetType) && targetResourceId
|
||||
? { id: targetResourceId, type: "host" }
|
||||
: targetType === ReverseProxyTargetType.PEER &&
|
||||
targetPeerId
|
||||
? { id: targetPeerId, type: "peer" }
|
||||
: undefined
|
||||
}
|
||||
onResourceChange={(res) => {
|
||||
if (res) {
|
||||
if (res.type === "peer") {
|
||||
setTargetType(ReverseProxyTargetType.PEER);
|
||||
setTargetPeerId(res.id);
|
||||
setTargetResourceId(undefined);
|
||||
const peer = peers?.find((p) => p.id === res.id);
|
||||
setTargetHost(peer?.ip || "localhost");
|
||||
} else {
|
||||
const selectedResource = resources?.find(
|
||||
(r) => r.id === res.id,
|
||||
);
|
||||
setTargetType(
|
||||
(selectedResource?.type as ReverseProxyTargetType) ??
|
||||
ReverseProxyTargetType.HOST,
|
||||
);
|
||||
setTargetResourceId(res.id);
|
||||
setTargetPeerId(undefined);
|
||||
const address = selectedResource?.address || "";
|
||||
// If CIDR range, pre-fill with base IP
|
||||
if (address.includes("/")) {
|
||||
setTargetHost(address.split("/")[0]);
|
||||
} else {
|
||||
setTargetHost(address);
|
||||
}
|
||||
}
|
||||
setTimeout(() => portInputRef.current?.focus(), 0);
|
||||
} else {
|
||||
setTargetHost(address);
|
||||
setTargetPeerId(undefined);
|
||||
setTargetResourceId(undefined);
|
||||
setTargetHost("");
|
||||
}
|
||||
}
|
||||
setTimeout(() => portInputRef.current?.focus(), 0);
|
||||
} else {
|
||||
setTargetPeerId(undefined);
|
||||
setTargetResourceId(undefined);
|
||||
setTargetHost("");
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<Label>Location (Optional)</Label>
|
||||
<HelpText>
|
||||
Specify an optional path from where requests are routed to your
|
||||
service.
|
||||
</HelpText>
|
||||
<div className="flex w-full">
|
||||
<div
|
||||
className={`bg-nb-gray-900 rounded-l-md border text-nb-gray-300 border-r-0 text-sm border-nb-gray-700 flex items-center justify-center whitespace-nowrap px-4 ${
|
||||
!hasTarget ? "opacity-50" : "opacity-80"
|
||||
}`}
|
||||
>
|
||||
{domain || "domain.example.com"}
|
||||
</div>
|
||||
<Input
|
||||
placeholder="/"
|
||||
value={targetPath}
|
||||
className={cn("rounded-l-none")}
|
||||
maxWidthClass="w-full"
|
||||
disabled={!hasTarget}
|
||||
onChange={(e) => {
|
||||
let value = e.target.value;
|
||||
if (value && !value.startsWith("/")) {
|
||||
value = "/" + value;
|
||||
}
|
||||
setTargetPath(value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{isPathDuplicate && hasTarget && (
|
||||
<Callout
|
||||
variant="warning"
|
||||
className="mt-3"
|
||||
icon={
|
||||
<AlertTriangle
|
||||
size={14}
|
||||
className="shrink-0 relative top-[3px]"
|
||||
/>
|
||||
}
|
||||
>
|
||||
Please use a different location. This location is already used
|
||||
by another target and cannot be added.
|
||||
</Callout>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 mt-1">
|
||||
<div className="flex-1">
|
||||
<Label>Protocol & Host / IP</Label>
|
||||
{cidrInfo && (
|
||||
<HelpText className="!mt-1">
|
||||
Enter an IP address within {currentResourceAddress}
|
||||
</HelpText>
|
||||
)}
|
||||
<div className="flex items-center mt-2">
|
||||
<div className="w-[120px]">
|
||||
<SelectDropdown
|
||||
value={targetProtocol}
|
||||
onChange={(v) =>
|
||||
setTargetProtocol(v as ReverseProxyTargetProtocol)
|
||||
}
|
||||
options={[
|
||||
{
|
||||
value: ReverseProxyTargetProtocol.HTTP,
|
||||
label: "http://",
|
||||
},
|
||||
{
|
||||
value: ReverseProxyTargetProtocol.HTTPS,
|
||||
label: "https://",
|
||||
},
|
||||
]}
|
||||
className="!rounded-r-none !border-r-0"
|
||||
disabled={!hasTarget}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<Input
|
||||
value={targetHost}
|
||||
onChange={(e) => {
|
||||
// Only allow valid IP characters for CIDR ranges
|
||||
const value = isHostEditable
|
||||
? e.target.value.replace(/[^0-9.]/g, "")
|
||||
: e.target.value;
|
||||
setTargetHost(value);
|
||||
}}
|
||||
placeholder="e.g., 192.168.0.10"
|
||||
className="!rounded-l-none"
|
||||
disabled={!hasTarget}
|
||||
readOnly={hasTarget && !isHostEditable ? true : undefined}
|
||||
autoFocus={!!initialResource && isHostEditable}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<Label>Location (Optional)</Label>
|
||||
<HelpText>
|
||||
Specify an optional path from where requests are routed to
|
||||
your service.
|
||||
</HelpText>
|
||||
<div className="flex w-full">
|
||||
<div
|
||||
className={`bg-nb-gray-900 rounded-l-md border text-nb-gray-300 border-r-0 text-sm border-nb-gray-700 flex items-center justify-center whitespace-nowrap px-4 ${
|
||||
!hasTarget ? "opacity-50" : "opacity-80"
|
||||
}`}
|
||||
>
|
||||
{domain || "domain.example.com"}
|
||||
</div>
|
||||
<Input
|
||||
placeholder="/"
|
||||
value={targetPath}
|
||||
className={cn("rounded-l-none")}
|
||||
maxWidthClass="w-full"
|
||||
disabled={!hasTarget}
|
||||
onChange={(e) => {
|
||||
let value = e.target.value;
|
||||
if (value && !value.startsWith("/")) {
|
||||
value = "/" + value;
|
||||
}
|
||||
setTargetPath(value);
|
||||
if (!value || value === "/") {
|
||||
setOption("path_rewrite", undefined);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{isPathDuplicate && hasTarget && (
|
||||
<Callout
|
||||
variant="warning"
|
||||
className="mt-3"
|
||||
icon={
|
||||
<AlertTriangle
|
||||
size={14}
|
||||
className="shrink-0 relative top-[3px]"
|
||||
/>
|
||||
}
|
||||
>
|
||||
This location is already used by another target and cannot
|
||||
be added. <br /> Please use a different location.
|
||||
</Callout>
|
||||
)}
|
||||
{targetPath &&
|
||||
targetPath !== "/" &&
|
||||
hasTarget &&
|
||||
!isPathDuplicate && (
|
||||
<FancyToggleSwitch
|
||||
value={options.path_rewrite === "preserve"}
|
||||
onChange={(v) =>
|
||||
setOption(
|
||||
"path_rewrite",
|
||||
v
|
||||
? ("preserve" as ServiceTargetOptionsPathRewrite)
|
||||
: undefined,
|
||||
)
|
||||
}
|
||||
className={"mt-3.5"}
|
||||
label={
|
||||
<>
|
||||
Preserve Full Path
|
||||
<HelpTooltip
|
||||
content={
|
||||
<div className="text-xs max-w-xs flex flex-col gap-2">
|
||||
<div>
|
||||
When disabled, a request to e.g.,{" "}
|
||||
<span className="font-mono text-white">
|
||||
{targetPath}/users
|
||||
</span>{" "}
|
||||
is forwarded as{" "}
|
||||
<span className="font-mono text-white">
|
||||
/users
|
||||
</span>
|
||||
.
|
||||
</div>
|
||||
<div>
|
||||
When enabled, a request to e.g.,{" "}
|
||||
<span className="font-mono text-white">
|
||||
{targetPath}/users
|
||||
</span>{" "}
|
||||
is forwarded as{" "}
|
||||
<span className="font-mono text-white">
|
||||
{targetPath}/users
|
||||
</span>
|
||||
.
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
helpText={
|
||||
<div>
|
||||
Keep the original full request path when forwarding.{" "}
|
||||
<br />
|
||||
When disabled the matched prefix path is stripped.
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex gap-3 mt-1">
|
||||
<div className="flex-1">
|
||||
<Label>Protocol & Host / IP</Label>
|
||||
{cidrInfo && (
|
||||
<HelpText className="!mt-1">
|
||||
Enter an IP address within {currentResourceAddress}
|
||||
</HelpText>
|
||||
)}
|
||||
<div className="flex items-center mt-2">
|
||||
<div className="w-[120px]">
|
||||
<SelectDropdown
|
||||
value={targetProtocol}
|
||||
onChange={(v) => {
|
||||
const proto = v as ReverseProxyTargetProtocol;
|
||||
setTargetProtocol(proto);
|
||||
if (proto !== ReverseProxyTargetProtocol.HTTPS) {
|
||||
setOption("skip_tls_verify", undefined);
|
||||
}
|
||||
}}
|
||||
options={[
|
||||
{
|
||||
value: ReverseProxyTargetProtocol.HTTP,
|
||||
label: "http://",
|
||||
},
|
||||
{
|
||||
value: ReverseProxyTargetProtocol.HTTPS,
|
||||
label: "https://",
|
||||
},
|
||||
]}
|
||||
className="!rounded-r-none !border-r-0"
|
||||
disabled={!hasTarget}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<Input
|
||||
value={targetHost}
|
||||
onChange={(e) => {
|
||||
// Only allow valid IP characters for CIDR ranges
|
||||
const value = isHostEditable
|
||||
? e.target.value.replace(/[^0-9.]/g, "")
|
||||
: e.target.value;
|
||||
setTargetHost(value);
|
||||
}}
|
||||
placeholder="e.g., 192.168.0.10"
|
||||
className="!rounded-l-none"
|
||||
disabled={!hasTarget}
|
||||
readOnly={
|
||||
hasTarget && !isHostEditable ? true : undefined
|
||||
}
|
||||
autoFocus={!!initialResource && isHostEditable}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-[150px]">
|
||||
<Label>
|
||||
Port
|
||||
<HelpTooltip
|
||||
content={
|
||||
"Enter the port where your service (e.g., webserver, app, API) is currently listening. If left empty, defaults to port 80 for HTTP or 443 for HTTPS."
|
||||
}
|
||||
/>
|
||||
</Label>
|
||||
{cidrInfo && (
|
||||
<HelpText className="!mt-1"> </HelpText>
|
||||
)}
|
||||
<div className="mt-2">
|
||||
<Input
|
||||
ref={portInputRef}
|
||||
type="number"
|
||||
value={targetPort === 0 ? "" : targetPort}
|
||||
onChange={(e) =>
|
||||
setTargetPort(parseInt(e.target.value) || 0)
|
||||
}
|
||||
placeholder={String(
|
||||
defaultPortForProtocol(targetProtocol),
|
||||
)}
|
||||
min={0}
|
||||
max={65535}
|
||||
disabled={!hasTarget}
|
||||
autoFocus={!!initialResource && !isHostEditable}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{targetProtocol === ReverseProxyTargetProtocol.HTTPS &&
|
||||
hasTarget && (
|
||||
<FancyToggleSwitch
|
||||
className={"mt-3.5"}
|
||||
value={options.skip_tls_verify ?? false}
|
||||
onChange={(v) =>
|
||||
setOption("skip_tls_verify", v || undefined)
|
||||
}
|
||||
label={
|
||||
<>
|
||||
<ShieldXIcon size={15} />
|
||||
Skip TLS Verification
|
||||
</>
|
||||
}
|
||||
helpText="Skip certificate verification when connecting to this target. Useful if your service already uses a self-signed certificate."
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-[150px]">
|
||||
<Label>
|
||||
Port
|
||||
<FullTooltip
|
||||
content={
|
||||
<div className={"text-xs max-w-xs"}>
|
||||
Enter the port where your service (e.g., webserver, app,
|
||||
API) is currently listening. If left empty, defaults to
|
||||
port 80 for HTTP or 443 for HTTPS.
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<HelpCircle
|
||||
size={12}
|
||||
className="cursor-help hover:text-nb-gray-100 transition-colors"
|
||||
/>
|
||||
</FullTooltip>
|
||||
</Label>
|
||||
{cidrInfo && <HelpText className="!mt-1"> </HelpText>}
|
||||
<div className="mt-2">
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value={"options"} className={"pb-8"}>
|
||||
<div className="px-8 flex flex-col gap-8 pt-1.5">
|
||||
<div className={"flex items-center justify-between"}>
|
||||
<div>
|
||||
<Label>Request Timeout</Label>
|
||||
<HelpText className={"mb-0"}>
|
||||
Max time to wait for a response as duration string (max
|
||||
5m). <br /> Leave this field empty for no timeout.
|
||||
</HelpText>
|
||||
</div>
|
||||
<Input
|
||||
ref={portInputRef}
|
||||
type="number"
|
||||
value={targetPort === 0 ? "" : targetPort}
|
||||
customPrefix={<ClockFadingIcon size={16} />}
|
||||
placeholder="e.g. 10s, 30s, 1m"
|
||||
value={options.request_timeout ?? ""}
|
||||
onChange={(e) =>
|
||||
setTargetPort(parseInt(e.target.value) || 0)
|
||||
setOption("request_timeout", e.target.value || undefined)
|
||||
}
|
||||
placeholder={String(defaultPortForProtocol(targetProtocol))}
|
||||
min={0}
|
||||
max={65535}
|
||||
disabled={!hasTarget}
|
||||
autoFocus={!!initialResource && !isHostEditable}
|
||||
maxWidthClass="w-[180px]"
|
||||
errorTooltip={true}
|
||||
error={errors.timeout}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showAdvancedSettings && (
|
||||
<Accordion type="single" collapsible>
|
||||
<AccordionItem value="advanced">
|
||||
<AccordionTrigger className="text-sm text-nb-gray-300">
|
||||
<div className="flex items-center gap-2">
|
||||
<Settings size={14} />
|
||||
Advanced Settings
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<div className="flex flex-col gap-4 mt-4">
|
||||
<label className="flex items-start gap-3 cursor-pointer">
|
||||
<Checkbox
|
||||
checked={accessLocal}
|
||||
onCheckedChange={(v) => setAccessLocal(v === true)}
|
||||
/>
|
||||
<div>
|
||||
<Label className="!mb-0" as={"div"}>
|
||||
This is the routing peer
|
||||
</Label>
|
||||
<HelpText className="!mt-1">
|
||||
Enable if the service runs directly on the routing
|
||||
peer rather than behind it.
|
||||
</HelpText>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
)}
|
||||
</div>
|
||||
<ReverseProxyTargetCustomHeaders {...headers} />
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<ModalFooter className={"items-center"}>
|
||||
<div className={"w-full"}>
|
||||
@@ -582,23 +670,61 @@ export default function ReverseProxyTargetModal({
|
||||
</Paragraph>
|
||||
</div>
|
||||
<div className="flex gap-3 w-full justify-end">
|
||||
<Button variant="secondary" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleSave}
|
||||
disabled={!canAddTarget}
|
||||
>
|
||||
{currentTarget ? (
|
||||
"Save Changes"
|
||||
) : (
|
||||
<>
|
||||
<PlusCircle size={16} />
|
||||
Add Target
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
{currentTarget ? (
|
||||
<>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleSave}
|
||||
disabled={!canAddTarget || errors.options}
|
||||
>
|
||||
Save Changes
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{tab === "details" && (
|
||||
<>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => setTab("options")}
|
||||
disabled={!canAddTarget}
|
||||
>
|
||||
Continue
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{tab === "options" && (
|
||||
<>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => setTab("details")}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleSave}
|
||||
disabled={!canAddTarget || errors.options}
|
||||
>
|
||||
<PlusCircle size={16} />
|
||||
Add Target
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
|
||||
@@ -104,7 +104,7 @@ const FlatTargetsTableColumns: ColumnDef<ReverseProxyFlatTarget>[] = [
|
||||
{
|
||||
accessorKey: "auth",
|
||||
header: ({ column }) => (
|
||||
<DataTableHeader column={column}>Authentication</DataTableHeader>
|
||||
<DataTableHeader column={column}>Auth Methods</DataTableHeader>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<ReverseProxyAuthCell reverseProxy={row.original.proxy} />
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { ServiceTargetOptions } from "@/interfaces/ReverseProxy";
|
||||
import {
|
||||
headerEntriesToRecord,
|
||||
useCustomHeaders,
|
||||
} from "@/modules/reverse-proxy/targets/ReverseProxyTargetCustomHeaders";
|
||||
|
||||
// Go time.ParseDuration format: one or more {number}{unit} pairs
|
||||
const DURATION_RE = /^(\d+(\.\d+)?(ns|us|µs|ms|s|m|h))+$/;
|
||||
const MAX_TIMEOUT_MS = 5 * 60 * 1000; // 5m
|
||||
|
||||
function parseDurationMs(duration: string): number {
|
||||
const units: Record<string, number> = {
|
||||
ns: 1e-6,
|
||||
us: 1e-3,
|
||||
µs: 1e-3,
|
||||
ms: 1,
|
||||
s: 1000,
|
||||
m: 60_000,
|
||||
h: 3_600_000,
|
||||
};
|
||||
let total = 0;
|
||||
for (const [, val, , unit] of duration.matchAll(
|
||||
/(\d+(\.\d+)?)(ns|us|µs|ms|s|m|h)/g,
|
||||
)) {
|
||||
total += parseFloat(val) * units[unit];
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
function validateTimeout(timeout: string): string | undefined {
|
||||
if (!timeout) return undefined;
|
||||
if (!DURATION_RE.test(timeout))
|
||||
return 'Invalid duration, use e.g., "10s", "30s", "1m"';
|
||||
if (parseDurationMs(timeout) > MAX_TIMEOUT_MS)
|
||||
return "Timeout cannot exceed the maximum of 5m.";
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function useReverseProxyTargetOptions(
|
||||
initialOptions?: ServiceTargetOptions,
|
||||
) {
|
||||
const [targetOptions, setTargetOptions] = useState<ServiceTargetOptions>(
|
||||
() => {
|
||||
const { custom_headers: _, ...rest } = initialOptions ?? {};
|
||||
return rest;
|
||||
},
|
||||
);
|
||||
|
||||
const {
|
||||
headerEntries,
|
||||
addHeader,
|
||||
removeHeader,
|
||||
updateHeaderEntry,
|
||||
headerErrors,
|
||||
hasHeaderErrors,
|
||||
} = useCustomHeaders(initialOptions?.custom_headers);
|
||||
|
||||
const updateOption = useCallback(
|
||||
<K extends keyof ServiceTargetOptions>(
|
||||
key: K,
|
||||
value: ServiceTargetOptions[K],
|
||||
) => {
|
||||
setTargetOptions((prev) => ({ ...prev, [key]: value }));
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const timeoutError = validateTimeout(targetOptions.request_timeout ?? "");
|
||||
const hasOptionsErrors = !!timeoutError || hasHeaderErrors;
|
||||
|
||||
const getTargetOptions = useCallback((): ServiceTargetOptions | undefined => {
|
||||
const customHeaders = headerEntriesToRecord(headerEntries);
|
||||
const merged: ServiceTargetOptions = {
|
||||
...targetOptions,
|
||||
custom_headers: customHeaders,
|
||||
};
|
||||
const hasOptions = Object.values(merged).some((v) => v !== undefined);
|
||||
return hasOptions ? merged : undefined;
|
||||
}, [targetOptions, headerEntries]);
|
||||
|
||||
return [
|
||||
targetOptions,
|
||||
updateOption,
|
||||
{
|
||||
getTargetOptions,
|
||||
headers: {
|
||||
headerEntries,
|
||||
addHeader,
|
||||
removeHeader,
|
||||
updateHeaderEntry,
|
||||
headerErrors,
|
||||
hasHeaderErrors,
|
||||
},
|
||||
errors: {
|
||||
timeout: timeoutError,
|
||||
options: hasOptionsErrors,
|
||||
},
|
||||
},
|
||||
] as const;
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import InlineLink from "@components/InlineLink";
|
||||
import { Input } from "@components/Input";
|
||||
import { Label } from "@components/Label";
|
||||
import { notify } from "@components/Notification";
|
||||
import { PeerGroupSelector } from "@components/PeerGroupSelector";
|
||||
import {
|
||||
SelectDropdown,
|
||||
SelectOption,
|
||||
@@ -13,7 +14,7 @@ import {
|
||||
import { useHasChanges } from "@hooks/useHasChanges";
|
||||
import * as Tabs from "@radix-ui/react-tabs";
|
||||
import { useApiCall } from "@utils/api";
|
||||
import { validator } from "@utils/helpers";
|
||||
import { cn, validator } from "@utils/helpers";
|
||||
import {
|
||||
ClockFadingIcon,
|
||||
ExternalLinkIcon,
|
||||
@@ -27,6 +28,10 @@ import SettingsIcon from "@/assets/icons/SettingsIcon";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import { Account } from "@/interfaces/Account";
|
||||
import { SmallBadge } from "@components/ui/SmallBadge";
|
||||
import ReverseProxyIcon from "@/assets/icons/ReverseProxyIcon";
|
||||
import useGroupHelper from "@/modules/groups/useGroupHelper";
|
||||
import { useGroups } from "@/contexts/GroupsProvider";
|
||||
import { SkeletonSettings } from "@components/skeletons/SkeletonSettings";
|
||||
|
||||
type Props = {
|
||||
account: Account;
|
||||
@@ -48,6 +53,16 @@ const latestOrCustomVersion = [
|
||||
] as SelectOption[];
|
||||
|
||||
export default function ClientSettingsTab({ account }: Readonly<Props>) {
|
||||
const { isLoading: isGroupsLoading } = useGroups();
|
||||
|
||||
return isGroupsLoading ? (
|
||||
<SkeletonSettings />
|
||||
) : (
|
||||
<ClientSettingsTabContent account={account} />
|
||||
);
|
||||
}
|
||||
|
||||
function ClientSettingsTabContent({ account }: Readonly<Props>) {
|
||||
const { permission } = usePermissions();
|
||||
|
||||
const { mutate } = useSWRConfig();
|
||||
@@ -69,9 +84,23 @@ export default function ClientSettingsTab({ account }: Readonly<Props>) {
|
||||
isCustomVersion ? autoUpdateSetting : "",
|
||||
);
|
||||
|
||||
const [peerExposeEnabled, setPeerExposeEnabled] = useState<boolean>(
|
||||
account?.settings?.peer_expose_enabled ?? false,
|
||||
);
|
||||
const [peerExposeGroups, setPeerExposeGroups, { save: saveGroups }] =
|
||||
useGroupHelper({
|
||||
initial: account.settings?.peer_expose_groups,
|
||||
});
|
||||
const peerExposeGroupNames = useMemo(
|
||||
() => peerExposeGroups.map((g) => g.name).sort(),
|
||||
[peerExposeGroups],
|
||||
);
|
||||
|
||||
const { hasChanges, updateRef } = useHasChanges([
|
||||
autoUpdateMethod,
|
||||
autoUpdateCustomVersion,
|
||||
peerExposeEnabled,
|
||||
peerExposeGroupNames,
|
||||
]);
|
||||
|
||||
const handleUpdateMethodChange = (value: string) => {
|
||||
@@ -99,16 +128,24 @@ export default function ClientSettingsTab({ account }: Readonly<Props>) {
|
||||
return (
|
||||
!hasChanges ||
|
||||
!permission.settings.update ||
|
||||
(autoUpdateMethod === "custom" && !canSaveCustomVersion)
|
||||
(autoUpdateMethod === "custom" && !canSaveCustomVersion) ||
|
||||
(peerExposeEnabled && peerExposeGroups.length === 0)
|
||||
);
|
||||
}, [
|
||||
hasChanges,
|
||||
permission.settings.update,
|
||||
autoUpdateMethod,
|
||||
canSaveCustomVersion,
|
||||
peerExposeEnabled,
|
||||
peerExposeGroups,
|
||||
]);
|
||||
|
||||
const saveChanges = async () => {
|
||||
const groups = await saveGroups();
|
||||
const peerExposeGroupIds = groups
|
||||
.map((group) => group.id)
|
||||
.filter(Boolean) as string[];
|
||||
|
||||
notify({
|
||||
title: "Client Settings",
|
||||
description: `Client settings successfully updated.`,
|
||||
@@ -118,11 +155,18 @@ export default function ClientSettingsTab({ account }: Readonly<Props>) {
|
||||
settings: {
|
||||
...account.settings,
|
||||
auto_update_version: autoUpdateCustomVersion || autoUpdateMethod,
|
||||
peer_expose_enabled: peerExposeEnabled,
|
||||
peer_expose_groups: peerExposeGroupIds,
|
||||
},
|
||||
})
|
||||
.then(() => {
|
||||
mutate("/accounts");
|
||||
updateRef([autoUpdateMethod, autoUpdateCustomVersion]);
|
||||
updateRef([
|
||||
autoUpdateMethod,
|
||||
autoUpdateCustomVersion,
|
||||
peerExposeEnabled,
|
||||
peerExposeGroupNames,
|
||||
]);
|
||||
}),
|
||||
loadingMessage: "Updating client settings...",
|
||||
});
|
||||
@@ -152,7 +196,7 @@ export default function ClientSettingsTab({ account }: Readonly<Props>) {
|
||||
|
||||
return (
|
||||
<Tabs.Content value={"clients"}>
|
||||
<div className={"p-default py-6 max-w-xl"}>
|
||||
<div className={"p-default py-6 max-w-2xl"}>
|
||||
<Breadcrumbs>
|
||||
<Breadcrumbs.Item
|
||||
href={"/settings"}
|
||||
@@ -178,7 +222,7 @@ export default function ClientSettingsTab({ account }: Readonly<Props>) {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className={"flex flex-col gap-6 w-full mt-8"}>
|
||||
<div className={"flex flex-col gap-10 w-full mt-8"}>
|
||||
<div className={"flex flex-col relative"}>
|
||||
<Label>
|
||||
<RefreshCcw size={15} />
|
||||
@@ -223,7 +267,63 @@ export default function ClientSettingsTab({ account }: Readonly<Props>) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={"mt-3"}>
|
||||
<div>
|
||||
<div>
|
||||
<Label>
|
||||
<ReverseProxyIcon size={15} className={"fill-nb-gray-300"} />
|
||||
Expose Services from CLI
|
||||
</Label>
|
||||
<HelpText>
|
||||
Allow peers to expose local services through the NetBird reverse
|
||||
proxy using the CLI. <br /> This requires at least NetBird{" "}
|
||||
<span className={"text-white font-medium"}>v0.66.0</span>.{" "}
|
||||
<InlineLink
|
||||
href={
|
||||
"https://docs.netbird.io/manage/reverse-proxy/expose-from-cli"
|
||||
}
|
||||
target={"_blank"}
|
||||
>
|
||||
Learn more
|
||||
<ExternalLinkIcon size={12} />
|
||||
</InlineLink>
|
||||
</HelpText>
|
||||
</div>
|
||||
|
||||
<FancyToggleSwitch
|
||||
className={"mt-2"}
|
||||
value={peerExposeEnabled}
|
||||
onChange={setPeerExposeEnabled}
|
||||
label={"Enable Peer Expose"}
|
||||
helpText={
|
||||
"When enabled, peers can expose local HTTP services accessible via a public URL."
|
||||
}
|
||||
disabled={!permission.settings.update}
|
||||
/>
|
||||
|
||||
<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]",
|
||||
!peerExposeEnabled
|
||||
? "opacity-50 pointer-events-none"
|
||||
: "bg-nb-gray-930/80",
|
||||
)}
|
||||
>
|
||||
<div className={"mt-2"}>
|
||||
<Label>Allowed peer groups</Label>
|
||||
<HelpText>
|
||||
Select which peer groups are allowed to expose services. At
|
||||
least one group is required.
|
||||
</HelpText>
|
||||
<PeerGroupSelector
|
||||
values={peerExposeGroups}
|
||||
onChange={setPeerExposeGroups}
|
||||
placeholder="Select peer groups..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>
|
||||
<FlaskConicalIcon size={15} />
|
||||
Experimental
|
||||
@@ -241,25 +341,26 @@ export default function ClientSettingsTab({ account }: Readonly<Props>) {
|
||||
<ExternalLinkIcon size={12} />
|
||||
</InlineLink>
|
||||
</HelpText>
|
||||
<FancyToggleSwitch
|
||||
className={"mt-2"}
|
||||
value={lazyConnection}
|
||||
onChange={toggleLazyConnection}
|
||||
label={
|
||||
<>
|
||||
<ClockFadingIcon size={15} />
|
||||
Enable Lazy Connections
|
||||
</>
|
||||
}
|
||||
helpText={
|
||||
<>
|
||||
Allow to establish connections between peers only when
|
||||
required. This requires NetBird client v0.45 or higher.
|
||||
Changes will only take effect after restarting the clients.
|
||||
</>
|
||||
}
|
||||
disabled={!permission.settings.update}
|
||||
/>
|
||||
</div>
|
||||
<FancyToggleSwitch
|
||||
value={lazyConnection}
|
||||
onChange={toggleLazyConnection}
|
||||
label={
|
||||
<>
|
||||
<ClockFadingIcon size={15} />
|
||||
Enable Lazy Connections
|
||||
</>
|
||||
}
|
||||
helpText={
|
||||
<>
|
||||
Allow to establish connections between peers only when required.
|
||||
This requires NetBird client v0.45 or higher. Changes will only
|
||||
take effect after restarting the clients.
|
||||
</>
|
||||
}
|
||||
disabled={!permission.settings.update}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
|
||||
Reference in New Issue
Block a user