Compare commits

..
Author SHA1 Message Date
Eduard Gert d91fc33bfe Add support for multiple targets for the announcement banner 2026-03-06 10:42:04 +01:00
42 changed files with 992 additions and 2638 deletions

No files matched your search

+1 -1
View File
@@ -7,6 +7,6 @@
"variant": "important",
"isExternal": true,
"closeable": true,
"isCloudOnly": false
"targets": ["selfhosted"]
}
]
@@ -25,8 +25,6 @@ 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",
}),
[],
);
+88 -89
View File
@@ -12,6 +12,7 @@ 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";
@@ -34,7 +35,6 @@ 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,7 +49,6 @@ import ReverseProxiesProvider, {
flattenReverseProxies,
useReverseProxies,
} from "@/contexts/ReverseProxiesProvider";
import { SkeletonNetwork } from "@components/skeletons/SkeletonNetwork";
export default function NetworkDetailPage() {
const queryParameter = useSearchParams();
@@ -66,7 +65,7 @@ export default function NetworkDetailPage() {
<NetworkOverview network={network} />
</ReverseProxiesProvider>
) : (
<SkeletonNetwork />
<FullScreenLoading />
);
}
@@ -97,103 +96,103 @@ function NetworkOverview({ network }: Readonly<{ network: Network }>) {
return (
<PageContainer>
<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>
<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={"flex justify-between max-w-6xl"}>
<div
className={"w-full lg:w-1/2 flex justify-between items-center"}
>
<div
className={"w-full lg:w-1/2 flex justify-between items-center"}
className={cn(
"flex items-center w-full",
!network.description && "gap-2",
)}
>
<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 />
<NetworkInformationSquare
name={network.name}
active={isActive}
size={"lg"}
description={network.description}
/>
</div>
</div>
<div className={"flex gap-10 w-full mt-8 max-w-6xl items-start"}>
<NetworkInformationCard network={network} />
<NetworkProvider network={network}>
<NetworkActions />
</NetworkProvider>
</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>
<div className={"flex gap-10 w-full mt-8 max-w-6xl items-start"}>
<NetworkInformationCard network={network} />
</div>
</div>
<TabsContent value={"resources"} className={"pb-8"}>
<ResourcesTabContent
data={resources}
isLoading={isResourcesLoading}
<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>
{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>
<TabsContent value={"routing-peers"} className={"pb-8"}>
<NetworkRoutingPeersTabContent
routers={routers}
isLoading={isRoutersLoading}
/>
</TabsContent>
<TabsContent value={"resources"} className={"pb-8"}>
<ResourcesTabContent
data={resources}
isLoading={isResourcesLoading}
/>
</TabsContent>
<TabsContent value={"services"} className={"pb-8"}>
<ReverseProxyFlatTargetsTabContent
targets={services}
isLoading={isServicesLoading}
/>
</TabsContent>
</Tabs>
</NetworkProvider>
</NetworkAccessControlProvider>
<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>
</PageContainer>
);
}
+8 -47
View File
@@ -1,68 +1,29 @@
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 = false,
className,
variant = "default",
triggerClassName,
align = "start",
side = "top",
alignOffset = 0,
sideOffset,
iconSize = 12,
delayDuration = 300,
interactive = true,
}: Props) => {
return (
<>
<FullTooltip
interactive={interactive}
side={side}
align={align}
alignOffset={alignOffset}
sideOffset={sideOffset}
delayDuration={delayDuration}
variant={variant}
side={"top"}
align={"start"}
alignOffset={0}
className={
"inline underline decoration-dashed underline-offset-[3px] decoration-nb-gray-300 cursor-help transition-all hover:decoration-white"
}
content={
<div className={cn("max-w-xs text-xs", className)}>{content}</div>
}
content={content}
>
{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>
)}
{children}
</FullTooltip>
</>
);
+6 -17
View File
@@ -18,7 +18,6 @@ export interface NotifyProps<T> {
icon?: React.ReactNode;
backgroundColor?: string;
preventSuccessToast?: boolean;
showOnlyError?: boolean;
errorMessages?: ErrorResponse[];
}
@@ -37,7 +36,6 @@ export default function Notification<T>({
loadingMessage,
duration = 3500,
preventSuccessToast = false,
showOnlyError = false,
errorMessages,
}: NotificationProps<T>) {
const [error, setError] = useState("");
@@ -51,13 +49,10 @@ 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(() => {
@@ -93,10 +88,7 @@ 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";
@@ -114,7 +106,7 @@ export default function Notification<T>({
promise
.then(() => {
setLoading(false);
if (showOnlyError || preventSuccessToast) {
if (preventSuccessToast) {
toast.dismiss(toastId);
} else {
setReadyToDismiss(true);
@@ -144,9 +136,6 @@ 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}
+7 -61
View File
@@ -30,7 +30,6 @@ import {
MonitorSmartphoneIcon,
NetworkIcon,
SearchIcon,
ShieldCheck,
WorkflowIcon,
} from "lucide-react";
import * as React from "react";
@@ -41,7 +40,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 { Policy, PolicyRuleResource } from "@/interfaces/Policy";
import { PolicyRuleResource } from "@/interfaces/Policy";
import { User } from "@/interfaces/User";
import { HorizontalUsersStack } from "@/modules/users/HorizontalUsersStack";
import { PeerOperatingSystemIcon } from "@/modules/peers/PeerOperatingSystemIcon";
@@ -72,7 +71,6 @@ interface MultiSelectProps {
showResourceCounter?: boolean;
showResources?: boolean;
showPeers?: boolean;
showPeerCounter?: boolean;
hideGroupsTab?: boolean;
tabOrder?: ("groups" | "peers" | "resources")[];
closeOnSelect?: boolean;
@@ -85,7 +83,6 @@ interface MultiSelectProps {
users?: User[];
placeholderForSearch?: string;
resourceIds?: string[];
policies?: Policy[];
}
export function PeerGroupSelector({
onChange,
@@ -104,7 +101,6 @@ export function PeerGroupSelector({
showResourceCounter = true,
showResources = false,
showPeers = false,
showPeerCounter = true,
hideGroupsTab = false,
tabOrder,
closeOnSelect = false,
@@ -117,7 +113,6 @@ 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[]
@@ -334,7 +329,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-60 transition-all",
"disabled:pointer-events-none disabled:opacity-30 transition-all",
)}
disabled={disabled}
data-cy={dataCy}
@@ -348,14 +343,7 @@ export function PeerGroupSelector({
{resource && (
<ResourceBadge
className={"py-[3px]"}
resource={
resources?.find((r) => r.id === resource.id) ??
({
id: resource.id,
name: resource.id,
type: resource.type,
} as NetworkResource)
}
resource={resources?.find((r) => r.id === resource.id)}
peer={peers?.find((p) => p.id === resource.id)}
onClick={(e) => {
e.preventDefault();
@@ -581,21 +569,12 @@ export function PeerGroupSelector({
<ResourcesCounter group={option} />
)}
{policies && (
<PolicyCounter
group={option}
policies={policies}
/>
)}
<div className={"flex gap-4 items-center"}>
{!users ? (
showPeerCounter && (
<PeerCounter
group={option}
showResourceCounter={showResourceCounter}
/>
)
<PeerCounter
group={option}
showResourceCounter={showResourceCounter}
/>
) : (
<UsersCounter
group={option}
@@ -811,39 +790,6 @@ 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;
+2 -5
View File
@@ -75,10 +75,8 @@ SelectLabel.displayName = SelectPrimitive.Label.displayName;
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item> & {
extra?: React.ReactNode;
}
>(({ className, children, extra, ...props }, ref) => (
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
@@ -94,7 +92,6 @@ const SelectItem = React.forwardRef<
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
{extra}
</SelectPrimitive.Item>
));
SelectItem.displayName = SelectPrimitive.Item.displayName;
@@ -1,42 +0,0 @@
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>
);
};
-1
View File
@@ -296,7 +296,6 @@ export function DataTable<TData, TValue>({
autoResetAll: false,
autoResetExpanded: false,
manualPagination: manualPagination,
manualSorting: serverSidePagination,
manualFiltering: manualFiltering || manualColumnFiltering,
pageCount: pageCount,
state: {
+5 -14
View File
@@ -5,7 +5,6 @@ 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>;
@@ -14,7 +13,6 @@ type Props = {
center?: boolean;
className?: string;
sorting?: boolean;
name?: string;
};
export default function DataTableHeader({
children,
@@ -23,22 +21,15 @@ 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 ? handleSort : undefined}
onClick={
sorting
? () => column.toggleSorting(column.getIsSorted() === "asc")
: undefined
}
className={cn(
"flex items-center whitespace-nowrap gap-2 dark:text-gray-400 transition-all select-none text-xs tracking-wide",
sorting &&
+4 -14
View File
@@ -26,7 +26,6 @@ type Props = {
showResources?: boolean;
redirectGroupTab?: string;
showUsers?: boolean;
disableRedirect?: boolean;
};
export default function MultipleGroups({
@@ -38,7 +37,6 @@ export default function MultipleGroups({
showResources = false,
showUsers = false,
redirectGroupTab,
disableRedirect = false,
}: Readonly<Props>) {
const { permission } = usePermissions();
@@ -66,7 +64,6 @@ export default function MultipleGroups({
{firstGroup && (
<GroupBadge
group={firstGroup}
showNewBadge={true}
className={
permission.groups.update ? "group-hover:bg-nb-gray-800" : ""
}
@@ -104,7 +101,7 @@ export default function MultipleGroups({
return (
group && (
<div
key={group?.id || group?.name}
key={group.id}
className={
"flex gap-2 items-center justify-between w-full"
}
@@ -113,23 +110,16 @@ export default function MultipleGroups({
group={group}
className={"py-0"}
textClassName={"py-1.5"}
showNewBadge={true}
redirectToGroupPage={!disableRedirect}
redirectToGroupPage={true}
redirectGroupTab={redirectGroupTab}
></GroupBadge>
<ArrowRightIcon size={14} />
{showResources ? (
<ResourceCountBadge
group={group}
disableRedirect={disableRedirect}
/>
<ResourceCountBadge group={group} />
) : showUsers ? (
<UserCountStack group={group} />
) : (
<PeerCountBadge
group={group}
disableRedirect={disableRedirect}
/>
<PeerCountBadge group={group} />
)}
</div>
)
+2 -5
View File
@@ -10,7 +10,6 @@ import ResourceCountBadge from "@components/ui/ResourceCountBadge";
type Props = {
group?: Group;
disableRedirect?: boolean;
} & React.HTMLAttributes<HTMLDivElement> &
BadgeVariants;
@@ -18,7 +17,6 @@ export default function PeerCountBadge({
group,
variant = "gray",
className,
disableRedirect = false,
}: Props) {
const router = useRouter();
const { dropdownOptions, groups } = useGroups();
@@ -37,8 +35,7 @@ export default function PeerCountBadge({
return peerCount;
}, [currentGroup]);
const canRedirect =
!!group?.id && group?.name !== "All" && !disableRedirect;
const canRedirect = !!group?.id && group?.name !== "All";
const onClick = (e: React.MouseEvent<HTMLDivElement>) => {
e.stopPropagation();
@@ -49,7 +46,7 @@ export default function PeerCountBadge({
const showResources = resourcesCount > 0 && peerCount === 0;
return showResources ? (
<ResourceCountBadge group={group} disableRedirect={disableRedirect} />
<ResourceCountBadge group={group} />
) : (
<Badge
variant={variant}
+1 -6
View File
@@ -7,20 +7,15 @@ import { Group } from "@/interfaces/Group";
type Props = {
group?: Group;
disableRedirect?: boolean;
} & React.HTMLAttributes<HTMLDivElement> &
BadgeVariants;
export default function ResourceCountBadge({
group,
disableRedirect = false,
}: Props) {
export default function ResourceCountBadge({ group }: 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`);
};
+4 -4
View File
@@ -8,13 +8,14 @@ import React, {
useState,
} from "react";
import { usePermissions } from "@/contexts/PermissionsProvider";
import { isLocalDev, isNetBirdHosted } from "@utils/netbird";
import { isLocalDev } from "@utils/netbird";
import announcementFile from "../../announcements.json";
const ANNOUNCEMENTS_URL =
"https://raw.githubusercontent.com/netbirdio/dashboard/main/announcements.json";
const STORAGE_KEY = "netbird-announcements";
const CACHE_DURATION_MS = 30 * 60 * 1000;
const TARGET = "selfhosted";
const BANNER_HEIGHT = 40;
interface AnnouncementStore {
@@ -30,7 +31,7 @@ export interface Announcement extends AnnouncementVariant {
linkText?: string;
isExternal?: boolean;
closeable: boolean;
isCloudOnly: boolean;
targets?: string[];
}
interface AnnouncementInfo extends Announcement {
@@ -76,8 +77,7 @@ const getAnnouncements = async (): Promise<AnnouncementInfo[]> => {
raw = await response.json();
}
const isCloud = isNetBirdHosted();
const filtered = raw.filter((a) => !a.isCloudOnly || isCloud);
const filtered = raw.filter((a) => a.targets?.includes(TARGET));
const hashes = new Set(filtered.map((a) => md5(a.text).toString()));
const closed = (stored?.closedAnnouncements ?? []).filter((h) =>
hashes.has(h),
+2 -4
View File
@@ -27,8 +27,6 @@ type DialogOptions = {
type?: "default" | "warning" | "danger" | "center";
children?: React.ReactNode;
maxWidthClass?: string;
hideIcon?: boolean;
center?: boolean;
};
export default function DialogProvider({ children }: Props) {
@@ -72,14 +70,14 @@ export default function DialogProvider({ children }: Props) {
onPointerDownOutside={(e) => e.preventDefault()}
>
<ModalHeader
center={dialogOptions.center ?? dialogOptions.type == "center"}
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={dialogOptions.hideIcon ? "" : dialogTypes[dialogOptions.type || "default"]}
icon={dialogTypes[dialogOptions.type || "default"]}
color={
dialogOptions.type == "default"
? "blue"
+1 -125
View File
@@ -1,12 +1,7 @@
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";
@@ -23,116 +18,18 @@ 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>,
@@ -165,20 +62,6 @@ 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);
@@ -187,14 +70,7 @@ export default function PoliciesProvider({ children }: Props) {
return (
<PoliciesContext.Provider
value={{
updatePolicy,
createPolicy,
createPoliciesForResource,
openEditPolicyModal,
deletePolicy,
serializeRules,
}}
value={{ updatePolicy, createPolicy, openEditPolicyModal }}
>
{children}
<Modal
-17
View File
@@ -31,7 +31,6 @@ 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;
@@ -147,15 +146,6 @@ 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(
@@ -180,7 +170,6 @@ export default function ServerPaginationProvider({
mutate,
setFilter,
getFilter,
setSort,
hasActiveFilters,
resetFilters,
pagination: { pageIndex: page - 1, pageSize },
@@ -204,7 +193,6 @@ export default function ServerPaginationProvider({
mutate,
setFilter,
getFilter,
setSort,
hasActiveFilters,
resetFilters,
page,
@@ -232,8 +220,3 @@ export function useServerPagination<T>() {
}
return context as ServerPaginationContextValue<T>;
}
export function useOptionalServerPagination<T>() {
const context = useContext(ServerPaginationContext);
return context as ServerPaginationContextValue<T> | null;
}
+12 -23
View File
@@ -1,37 +1,26 @@
import { useSearchParams } from "next/navigation";
import { useCallback, useEffect, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { useCallback, useMemo } from "react";
export default function useUrlTab(
validTabs: string[],
defaultTab: string,
): [string, (value: string) => void] {
const searchParams = useSearchParams();
const router = useRouter();
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 tab = useMemo(() => {
const tabParam = searchParams.get("tab");
if (tabParam && validTabs.includes(tabParam)) return tabParam;
return defaultTab;
}, [searchParams, validTabs, defaultTab]);
const setTab = useCallback(
(value: string) => {
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()}`);
const params = new URLSearchParams(searchParams.toString());
params.set("tab", value);
router.replace(`?${params.toString()}`, { scroll: false });
},
[validTabs, defaultTab],
[searchParams, router],
);
return [tab, setTab];
-10
View File
@@ -26,15 +26,6 @@ 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;
@@ -44,7 +35,6 @@ export interface ReverseProxyTarget {
port: number;
enabled: boolean;
access_local?: boolean;
options?: ServiceTargetOptions;
// Frontend
destination?: string;
}
@@ -54,7 +54,6 @@ 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;
@@ -125,7 +124,6 @@ type ModalProps = {
initialPorts?: number[];
initialDestinationResource?: PolicyRuleResource;
initialTab?: string;
disableDestinationSelector?: boolean;
};
export function AccessControlModalContent({
@@ -142,7 +140,6 @@ export function AccessControlModalContent({
initialPorts,
initialDestinationResource,
initialTab,
disableDestinationSelector = false,
}: Readonly<ModalProps>) {
const { permission } = usePermissions();
const { users } = useUsers();
@@ -296,25 +293,7 @@ export function AccessControlModalContent({
<SelectItem value="tcp">TCP</SelectItem>
<SelectItem value="udp">UDP</SelectItem>
<SelectItem value="icmp">ICMP</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>
<SelectItem value="netbird-ssh">NetBird SSH</SelectItem>
</SelectContent>
</Select>
</div>
@@ -324,15 +303,6 @@ 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"}
@@ -367,15 +337,6 @@ 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"}
@@ -393,9 +354,7 @@ export function AccessControlModalContent({
onResourceChange={setDestinationResource}
saveGroupAssignments={useSave}
disabled={
disableDestinationSelector ||
!permission.policies.update ||
!permission.policies.create
!permission.policies.update || !permission.policies.create
}
/>
</div>
@@ -616,13 +575,7 @@ export function AccessControlModalContent({
<Button
variant={"primary"}
disabled={submitDisabled || !permission.policies.create}
onClick={() => {
if (useSave) {
submit();
} else {
close();
}
}}
onClick={submit}
data-cy={"submit-policy"}
>
<PlusCircle size={16} />
Loaded 20 of 42 files, more files were not shown because too many files have changed in this diff. Show more