feat(ui): add Mutelist menu item under Configuration (#8444)

Co-authored-by: Alejandro Bailo <59607668+alejandrobailo@users.noreply.github.com>
Co-authored-by: alejandrobailo <alejandrobailo94@gmail.com>
This commit is contained in:
Pablo Lara
2025-08-08 09:09:37 +02:00
committed by GitHub
parent 94e60f7329
commit df4bf18b97
15 changed files with 302 additions and 131 deletions

View File

@@ -7,6 +7,8 @@ All notable changes to the **Prowler UI** are documented in this file.
### 🚀 Added
- `Cloud Provider` type filter to providers page [(#8473)](https://github.com/prowler-cloud/prowler/pull/8473)
- New menu item under Configuration section for quick access to the Mutelist [(#8444)](https://github.com/prowler-cloud/prowler/pull/8444)
### 🔄 Changed

View File

@@ -3,11 +3,13 @@ import "@/styles/globals.css";
import { Metadata, Viewport } from "next";
import React from "react";
import { getProviders } from "@/actions/providers";
import MainLayout from "@/components/ui/main-layout/main-layout";
import { Toaster } from "@/components/ui/toast";
import { fontSans } from "@/config/fonts";
import { siteConfig } from "@/config/site";
import { cn } from "@/lib/utils";
import { StoreInitializer } from "@/store/ui/store-initializer";
import { Providers } from "../providers";
@@ -29,11 +31,14 @@ export const viewport: Viewport = {
],
};
export default function RootLayout({
export default async function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
const providersData = await getProviders({ page: 1, pageSize: 1 });
const hasProviders = !!(providersData?.data && providersData.data.length > 0);
return (
<html suppressHydrationWarning lang="en">
<head />
@@ -45,6 +50,7 @@ export default function RootLayout({
)}
>
<Providers themeProps={{ attribute: "class", defaultTheme: "dark" }}>
<StoreInitializer values={{ hasProviders }} />
<MainLayout>{children}</MainLayout>
<Toaster />
</Providers>

View File

@@ -33,7 +33,7 @@ export default async function Providers({
<>
<div className="flex items-center gap-4 md:justify-end">
<ManageGroupsButton />
<MutedFindingsConfigButton isDisabled={true} />
<MutedFindingsConfigButton />
<AddProviderButton />
</div>
<Spacer y={8} />
@@ -76,8 +76,6 @@ const ProvidersContent = async ({
pageSize,
});
const hasProviders = providersData?.data && providersData.data.length > 0;
const providerGroupDict =
providersData?.included
?.filter((item: any) => item.type === "provider-groups")
@@ -100,7 +98,7 @@ const ProvidersContent = async ({
<>
<div className="flex items-center gap-4 md:justify-end">
<ManageGroupsButton />
<MutedFindingsConfigButton isDisabled={!hasProviders} />
<MutedFindingsConfigButton />
<AddProviderButton />
</div>
<Spacer y={8} />

View File

@@ -94,7 +94,7 @@ export default async function Scans({
/>
<Spacer y={8} />
<div className="flex items-center justify-end gap-4">
<MutedFindingsConfigButton isDisabled={thereIsNoProvidersConnected} />
<MutedFindingsConfigButton />
</div>
<Spacer y={8} />
<Suspense key={searchParamsKey} fallback={<SkeletonTableScans />}>

View File

@@ -28,10 +28,12 @@ import {
interface MutedFindingsConfigFormProps {
setIsOpen: Dispatch<SetStateAction<boolean>>;
onCancel?: () => void;
}
export const MutedFindingsConfigForm = ({
setIsOpen,
onCancel,
}: MutedFindingsConfigFormProps) => {
const [config, setConfig] = useState<ProcessorData | null>(null);
const [configText, setConfigText] = useState("");
@@ -237,6 +239,7 @@ export const MutedFindingsConfigForm = ({
<div className="flex flex-col space-y-4">
<FormButtons
setIsOpen={setIsOpen}
onCancel={onCancel}
submitText={config ? "Update" : "Save"}
isDisabled={!yamlValidation.isValid || !configText.trim()}
/>

View File

@@ -1,3 +1,4 @@
export * from "../../store/ui/store-initializer";
export * from "./add-provider-button";
export * from "./credentials-update-info";
export * from "./forms/delete-form";

View File

@@ -1,36 +1,38 @@
"use client";
import { SettingsIcon } from "lucide-react";
import { useState } from "react";
import { CustomAlertModal, CustomButton } from "@/components/ui/custom";
import { useUIStore } from "@/store/ui/store";
import { MutedFindingsConfigForm } from "./forms";
interface MutedFindingsConfigButtonProps {
isDisabled?: boolean;
}
export const MutedFindingsConfigButton = ({
isDisabled = false,
}: MutedFindingsConfigButtonProps) => {
const [isOpen, setIsOpen] = useState(false);
export const MutedFindingsConfigButton = () => {
const {
isMutelistModalOpen,
openMutelistModal,
closeMutelistModal,
hasProviders,
} = useUIStore();
const handleOpenModal = () => {
if (!isDisabled) {
setIsOpen(true);
if (hasProviders) {
openMutelistModal();
}
};
return (
<>
<CustomAlertModal
isOpen={isOpen}
onOpenChange={setIsOpen}
isOpen={isMutelistModalOpen}
onOpenChange={closeMutelistModal}
title="Configure Mutelist"
size="3xl"
>
<MutedFindingsConfigForm setIsOpen={setIsOpen} />
<MutedFindingsConfigForm
setIsOpen={closeMutelistModal}
onCancel={closeMutelistModal}
/>
</CustomAlertModal>
<CustomButton
@@ -40,7 +42,7 @@ export const MutedFindingsConfigButton = ({
size="md"
startContent={<SettingsIcon size={20} />}
onPress={handleOpenModal}
isDisabled={isDisabled}
isDisabled={!hasProviders}
>
Configure Mutelist
</CustomButton>

View File

@@ -1,5 +1,6 @@
"use client";
import { Tooltip } from "@nextui-org/react";
import { DropdownMenuArrow } from "@radix-ui/react-dropdown-menu";
import { ChevronDown } from "lucide-react";
import Link from "next/link";
@@ -19,12 +20,6 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu/dropdown-menu";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip/tooltip";
import { cn } from "@/lib/utils";
import { CollapseMenuButtonProps } from "@/types";
@@ -94,90 +89,188 @@ export const CollapseMenuButton = ({
</CollapsibleTrigger>
<CollapsibleContent className="overflow-hidden data-[state=closed]:animate-collapsible-up data-[state=open]:animate-collapsible-down">
{submenus.map(
({ href, label, active, icon: SubIcon, target }, index) => (
<Button
key={index}
variant={
(active === undefined && pathname === href) || active
? "secondary"
: "ghost"
}
className="ml-4 h-8 w-full justify-start"
asChild
>
<Link href={href} target={target} className="flex items-center">
<div className="mr-4 h-full border-l border-default-200"></div>
<span className="mr-2">
<SubIcon size={16} />
</span>
<p
className={cn(
"max-w-[170px] truncate",
isOpen
? "translate-x-0 opacity-100"
: "-translate-x-96 opacity-0",
)}
(
{ href, label, active, icon: SubIcon, target, disabled, onClick },
index,
) => {
const isActive =
(active === undefined && pathname === href) || active;
if (disabled && label === "Mutelist") {
return (
<Tooltip
key={index}
content="The mutelist will be enabled after adding a provider"
className="text-xs"
placement="right"
>
{label}
</p>
</Link>
</Button>
),
<div className="w-full">
<Button
variant={isActive ? "secondary" : "ghost"}
className={cn(
"ml-4 h-8 w-full justify-start",
"cursor-not-allowed opacity-50",
)}
disabled={true}
>
<div className="flex items-center">
<div className="mr-4 h-full border-l border-default-200"></div>
<span className="mr-2">
<SubIcon size={16} />
</span>
<p
className={cn(
"max-w-[170px] truncate",
isOpen
? "translate-x-0 opacity-100"
: "-translate-x-96 opacity-0",
)}
>
{label}
</p>
</div>
</Button>
</div>
</Tooltip>
);
}
return (
<Button
key={index}
variant={isActive ? "secondary" : "ghost"}
className={cn(
"ml-4 h-8 w-full justify-start",
disabled && "cursor-not-allowed opacity-50",
)}
asChild={!disabled}
disabled={disabled}
>
<Link
href={href}
target={target}
className="flex items-center"
onClick={onClick}
>
<div className="mr-4 h-full border-l border-default-200"></div>
<span className="mr-2">
<SubIcon size={16} />
</span>
<p
className={cn(
"max-w-[170px] truncate",
isOpen
? "translate-x-0 opacity-100"
: "-translate-x-96 opacity-0",
)}
>
{label}
</p>
</Link>
</Button>
);
},
)}
</CollapsibleContent>
</Collapsible>
) : (
<DropdownMenu>
<TooltipProvider disableHoverableContent>
<Tooltip delayDuration={100}>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<Button
variant={isSubmenuActive ? "secondary" : "ghost"}
className="mb-1 h-10 w-full justify-start"
>
<div className="flex w-full items-center justify-between">
<div className="flex items-center">
<span className={cn(isOpen === false ? "" : "mr-4")}>
<Icon size={18} />
</span>
<p
className={cn(
"max-w-[200px] truncate",
isOpen === false ? "opacity-0" : "opacity-100",
)}
>
{label}
</p>
</div>
</div>
</Button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent side="right" align="start" alignOffset={2}>
{label}
</TooltipContent>
</Tooltip>
</TooltipProvider>
<Tooltip
content={label}
placement="right"
delay={100}
className="text-xs"
>
<DropdownMenuTrigger asChild>
<Button
variant={isSubmenuActive ? "secondary" : "ghost"}
className="mb-1 h-10 w-full justify-start"
>
<div className="flex w-full items-center justify-between">
<div className="flex items-center">
<span className={cn(isOpen === false ? "" : "mr-4")}>
<Icon size={18} />
</span>
<p
className={cn(
"max-w-[200px] truncate",
isOpen === false ? "opacity-0" : "opacity-100",
)}
>
{label}
</p>
</div>
</div>
</Button>
</DropdownMenuTrigger>
</Tooltip>
<DropdownMenuContent side="right" sideOffset={25} align="start">
<DropdownMenuLabel className="max-w-[190px] truncate">
{label}
</DropdownMenuLabel>
<DropdownMenuSeparator />
{submenus.map(({ href, label, active, icon: SubIcon }, index) => (
<DropdownMenuItem key={index} asChild>
<Link
className={`flex cursor-pointer items-center gap-2 ${
((active === undefined && pathname === href) || active) &&
"bg-secondary"
}`}
href={href}
>
<SubIcon size={16} />
<p className="max-w-[180px] truncate">{label}</p>
</Link>
</DropdownMenuItem>
))}
{submenus.map(
(
{ href, label, active, icon: SubIcon, disabled, onClick },
index,
) => {
const isActive =
(active === undefined && pathname === href) || active;
if (disabled && label === "Mutelist") {
return (
<Tooltip
key={index}
content="The mutelist will be enabled after adding a provider"
className="text-xs"
>
<div className="w-full">
<DropdownMenuItem
disabled={true}
className={cn(
"cursor-not-allowed opacity-50",
isActive && "bg-default-100 dark:bg-prowler-blue-400",
)}
>
<div className="flex items-center gap-2">
<SubIcon size={16} />
<p className="max-w-[180px] truncate">{label}</p>
</div>
</DropdownMenuItem>
</div>
</Tooltip>
);
}
return (
<DropdownMenuItem
key={index}
asChild={!disabled}
disabled={disabled}
className={cn(
disabled && "cursor-not-allowed opacity-50",
isActive && "bg-default-100 dark:bg-prowler-blue-400",
)}
>
{disabled ? (
<div className="flex items-center gap-2">
<SubIcon size={16} />
<p className="max-w-[180px] truncate">{label}</p>
</div>
) : (
<Link
className="flex cursor-pointer items-center gap-2"
href={href}
onClick={onClick}
>
<SubIcon size={16} />
<p className="max-w-[180px] truncate">{label}</p>
</Link>
)}
</DropdownMenuItem>
);
},
)}
<DropdownMenuArrow className="fill-border" />
</DropdownMenuContent>
</DropdownMenu>

View File

@@ -17,6 +17,9 @@ import {
import { useAuth } from "@/hooks";
import { getMenuList } from "@/lib/menu-list";
import { cn } from "@/lib/utils";
import { useUIStore } from "@/store/ui/store";
import { GroupProps } from "@/types";
import { RolePermissionAttributes } from "@/types/users";
import { Button } from "../button/button";
import { CustomButton } from "../custom/custom-button";
@@ -24,7 +27,7 @@ import { ScrollArea } from "../scroll-area/scroll-area";
interface MenuHideRule {
label: string;
condition: (permissions: any) => boolean;
condition: (permissions: RolePermissionAttributes) => boolean;
}
// Configuration for hiding menu items based on permissions
@@ -48,16 +51,16 @@ const MENU_HIDE_RULES: MenuHideRule[] = [
// },
];
const hideMenuItems = (menuGroups: any[], labelsToHide: string[]) => {
const hideMenuItems = (menuGroups: GroupProps[], labelsToHide: string[]) => {
return menuGroups.map((group) => ({
...group,
menus: group.menus
.filter((menu: any) => !labelsToHide.includes(menu.label))
.map((menu: any) => ({
.filter((menu) => !labelsToHide.includes(menu.label))
.map((menu) => ({
...menu,
submenus:
menu.submenus?.filter(
(submenu: any) => !labelsToHide.includes(submenu.label),
(submenu) => !labelsToHide.includes(submenu.label),
) || [],
})),
}));
@@ -66,7 +69,12 @@ const hideMenuItems = (menuGroups: any[], labelsToHide: string[]) => {
export const Menu = ({ isOpen }: { isOpen: boolean }) => {
const pathname = usePathname();
const { permissions } = useAuth();
const menuList = getMenuList(pathname);
const { hasProviders, openMutelistModal } = useUIStore();
const menuList = getMenuList({
pathname,
hasProviders,
openMutelistModal,
});
const labelsToHide = MENU_HIDE_RULES.filter((rule) =>
rule.condition(permissions),
@@ -122,7 +130,7 @@ export const Menu = ({ isOpen }: { isOpen: boolean }) => {
) : (
<p className="pb-2"></p>
)}
{menus.map((menu: any, index: number) => {
{menus.map((menu, index) => {
const {
href,
label,

View File

@@ -17,6 +17,7 @@ import {
User,
UserCog,
Users,
VolumeX,
Warehouse,
} from "lucide-react";
@@ -34,7 +35,17 @@ import {
} from "@/components/icons/Icons";
import { GroupProps } from "@/types";
export const getMenuList = (pathname: string): GroupProps[] => {
interface MenuListOptions {
pathname: string;
hasProviders?: boolean;
openMutelistModal?: () => void;
}
export const getMenuList = ({
pathname,
hasProviders,
openMutelistModal,
}: MenuListOptions): GroupProps[] => {
return [
{
groupLabel: "",
@@ -146,6 +157,14 @@ export const getMenuList = (pathname: string): GroupProps[] => {
icon: Settings,
submenus: [
{ href: "/providers", label: "Cloud Providers", icon: CloudCog },
{
// Use trailing slash to prevent both menu items from being active at /providers
href: "/providers/",
label: "Mutelist",
icon: VolumeX,
disabled: hasProviders === false,
onClick: openMutelistModal,
},
{ href: "/manage-groups", label: "Provider Groups", icon: Group },
{ href: "/scans", label: "Scan Jobs", icon: Timer },
{ href: "/integrations", label: "Integrations", icon: Puzzle },

View File

@@ -1 +1 @@
export * from "./ui/ui-store";
export * from "./ui/store";

View File

@@ -0,0 +1,27 @@
"use client";
import { useEffect } from "react";
import { useUIStore } from "@/store/ui/store";
interface StoreInitializerProps {
values: {
hasProviders?: boolean;
// Add more properties here as needed
// otherProperty?: string;
};
}
export function StoreInitializer({ values }: StoreInitializerProps) {
const setHasProviders = useUIStore((state) => state.setHasProviders);
useEffect(() => {
// Initialize store values from server
if (values.hasProviders !== undefined) {
setHasProviders(values.hasProviders);
}
// Add more setters here as needed in the future
}, [values.hasProviders, setHasProviders]);
return null;
}

32
ui/store/ui/store.ts Normal file
View File

@@ -0,0 +1,32 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";
interface UIStoreState {
isSideMenuOpen: boolean;
isMutelistModalOpen: boolean;
hasProviders: boolean;
openSideMenu: () => void;
closeSideMenu: () => void;
openMutelistModal: () => void;
closeMutelistModal: () => void;
setHasProviders: (value: boolean) => void;
}
export const useUIStore = create<UIStoreState>()(
persist(
(set) => ({
isSideMenuOpen: false,
isMutelistModalOpen: false,
hasProviders: false,
openSideMenu: () => set({ isSideMenuOpen: true }),
closeSideMenu: () => set({ isSideMenuOpen: false }),
openMutelistModal: () => set({ isMutelistModalOpen: true }),
closeMutelistModal: () => set({ isMutelistModalOpen: false }),
setHasProviders: (value: boolean) => set({ hasProviders: value }),
}),
{
name: "ui-store",
},
),
);

View File

@@ -1,22 +0,0 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";
interface SidebarStoreState {
isSideMenuOpen: boolean;
openSideMenu: () => void;
closeSideMenu: () => void;
}
export const useUIStore = create<SidebarStoreState>()(
persist(
(set) => ({
isSideMenuOpen: false,
openSideMenu: () => set({ isSideMenuOpen: true }),
closeSideMenu: () => set({ isSideMenuOpen: false }),
}),
{
name: "sidebar-store",
},
),
);

View File

@@ -20,6 +20,8 @@ export type SubmenuProps = {
label: string;
active?: boolean;
icon: IconComponent;
disabled?: boolean;
onClick?: () => void;
};
export type MenuProps = {