Compare commits

...

10 Commits

Author SHA1 Message Date
Eduard Gert
c0c1f4688e Add proxy events sort (#560)
Some checks failed
build and push / build_n_push (push) Has been cancelled
* Add proxy events sort

* Fix coderabbit comment

* Disable local sort when server pagination is used
2026-03-10 10:10:53 +01:00
Eduard Gert
b5a8f751ba Create policies inside resources (#568)
* Add acl tooltips

* Adjust resource modal and add tooltips

* Prevent nextjs navigation trigger on tab change

* Update wording

* add acl into resource

* Refactor resource policies

* Add prop to hide group edit and disable redirect

* Add skeleton loader to network page

* Create policy for new resources

* Show existing policies if groups are matching

* Add confirm dialog after creating resource without policy

* Add dialog if user edits policy that is used in multiple resources

* Add callout when selecting resource groups containing policies

* Add dialog if deleting policies containing resources

* Fix stale policies and new group creation in resource modal

* Remove whitespace

* Fix sort

* Cleanup

* Address coderabbit comments

* Fix policy alignment

* Fix initial resource

* disable selector if user did not select  resource groups

* Consider current resource when editing / deleting policy

* Remove unused mutate

* Fix dot position

* Remove ask for policy

* Fix policy index

* Fix multiple resource confirm dialog on policy cell
2026-03-10 10:10:38 +01:00
Eduard Gert
10a8e7b745 Fix stale certificate issued state (#575)
* Fix stale certificate issued state

* fix coderabbit
2026-03-09 10:08:35 +01:00
Viktor Liu
60e8394010 Add per-target options to reverse proxy (#576) 2026-03-06 18:55:28 +01:00
Eduard Gert
9420214059 Bump minimatch and ajv dependencies (#572) 2026-03-02 11:32:52 +01:00
Maycon Santos
b949f60afe Feature/client service expose (#567)
Some checks failed
build and push / build_n_push (push) Has been cancelled
* add draft

* add reverse proxy activities

* move peer expose settings into client settings tab and fix activity descriptions

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* prevent false positive group report

* add docs link

* allow save when groups are added to the setting

* Add loading skeleton to client settings, update icon, use grouphelper to allow creating new groups, remove .patch

* mv expose settings from extra settings

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Eduard Gert <kontakt@eduardgert.de>
2026-02-24 14:54:58 +01:00
Eduard Gert
d498e4cc25 Fix dns records pagination (#566)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2026-02-20 21:42:26 +01:00
Eduard Gert
130dc0c32c Fix group unused filter (#565)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2026-02-19 10:32:14 +01:00
Eduard Gert
f5824d6ddb Allow empty groups for reverse proxy sso auth (#563)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2026-02-18 16:27:13 +01:00
Eduard Gert
829395f908 Add hover to reverse proxy auth methods (#564) 2026-02-18 13:39:19 +01:00
53 changed files with 3003 additions and 1068 deletions

20
package-lock.json generated
View File

@@ -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"

View File

@@ -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",
}),
[],
);

View File

@@ -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>
);
}

View File

@@ -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>
</>
);

View File

@@ -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>
);
};

View File

@@ -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}

View File

@@ -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;

View File

@@ -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;

View 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>
);
};

View 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>
);
};

View File

@@ -296,6 +296,7 @@ export function DataTable<TData, TValue>({
autoResetAll: false,
autoResetExpanded: false,
manualPagination: manualPagination,
manualSorting: serverSidePagination,
manualFiltering: manualFiltering || manualColumnFiltering,
pageCount: pageCount,
state: {

View File

@@ -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 &&

View File

@@ -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>
)

View File

@@ -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}

View File

@@ -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`);
};

View File

@@ -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"

View File

@@ -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

View File

@@ -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;
}

View File

@@ -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>
);

View File

@@ -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];

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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} />

View File

@@ -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 (

View File

@@ -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");
},

View File

@@ -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"}>

View File

@@ -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 />

View File

@@ -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
*/

View File

@@ -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={{}}

View File

@@ -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>
);
};

View File

@@ -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
);
},
},

View 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;
};

View File

@@ -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 &apos;{policy.name}
&apos;?
</>
),
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>
);
}

View 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>
);
}

View File

@@ -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>

View File

@@ -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>
);
};

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>
)

View File

@@ -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}

View File

@@ -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();

View File

@@ -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>
</>
);
}

View File

@@ -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);

View File

@@ -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>
</>

View File

@@ -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

View File

@@ -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"}

View File

@@ -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"}>

View File

@@ -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} />,
},

View File

@@ -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>
);
}

View File

@@ -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&apos;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&apos;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&apos;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&apos;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&apos;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&apos;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">&nbsp;</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">&nbsp;</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>

View File

@@ -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} />

View File

@@ -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;
}

View File

@@ -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>