diff --git a/ui/changelog.d/sidebar-redesign.changed.md b/ui/changelog.d/sidebar-redesign.changed.md
new file mode 100644
index 0000000000..9d45d8ffdf
--- /dev/null
+++ b/ui/changelog.d/sidebar-redesign.changed.md
@@ -0,0 +1 @@
+Sidebar navigation with grouped sections, clearer active states, and a responsive mobile overlay
diff --git a/ui/components/layout/app-sidebar/app-sidebar-content.test.tsx b/ui/components/layout/app-sidebar/app-sidebar-content.test.tsx
new file mode 100644
index 0000000000..54dedad07c
--- /dev/null
+++ b/ui/components/layout/app-sidebar/app-sidebar-content.test.tsx
@@ -0,0 +1,126 @@
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+import { CLOUD_UPGRADE_FEATURE } from "@/types/cloud-upgrade";
+
+import { AppSidebarContent } from "./app-sidebar-content";
+import { useAppSidebarMode } from "./app-sidebar-mode-store";
+import { APP_SIDEBAR_MODE } from "./types";
+
+const {
+ openCloudUpgradeMock,
+ openLaunchScanModalMock,
+ pathnameValue,
+ pushMock,
+} = vi.hoisted(() => ({
+ openCloudUpgradeMock: vi.fn(),
+ openLaunchScanModalMock: vi.fn(),
+ pathnameValue: { current: "/findings" },
+ pushMock: vi.fn(),
+}));
+
+vi.mock("next/navigation", () => ({
+ usePathname: () => pathnameValue.current,
+ useRouter: () => ({ push: pushMock }),
+}));
+
+vi.mock("@/hooks", () => ({
+ useAuth: () => ({ permissions: {} }),
+}));
+
+vi.mock("@/hooks/use-runtime-config", () => ({
+ useRuntimeConfig: () => ({ apiDocsUrl: "https://local.example/docs" }),
+}));
+
+vi.mock("@/store", () => ({
+ useScansStore: (
+ selector: (state: {
+ openLaunchScanModal: typeof openLaunchScanModalMock;
+ }) => unknown,
+ ) => selector({ openLaunchScanModal: openLaunchScanModalMock }),
+ useCloudUpgradeStore: (
+ selector: (state: {
+ openCloudUpgrade: typeof openCloudUpgradeMock;
+ }) => unknown,
+ ) => selector({ openCloudUpgrade: openCloudUpgradeMock }),
+}));
+
+vi.mock("@/app/(prowler)/lighthouse/_components/navigation", () => ({
+ LighthouseV2SidebarChat: () =>
,
+}));
+
+describe("AppSidebarContent", () => {
+ beforeEach(() => {
+ pathnameValue.current = "/findings";
+ pushMock.mockClear();
+ openCloudUpgradeMock.mockClear();
+ openLaunchScanModalMock.mockClear();
+ useAppSidebarMode.setState({ mode: APP_SIDEBAR_MODE.BROWSE });
+ });
+
+ afterEach(() => {
+ vi.unstubAllEnvs();
+ });
+
+ it("shares the brand, Launch Scan action and Local Server Cloud affordances", async () => {
+ // Given
+ vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false");
+ vi.stubEnv("NEXT_PUBLIC_PROWLER_RELEASE_VERSION", "5.8.0");
+ const user = userEvent.setup();
+
+ // When
+ render(
);
+
+ // Then
+ const homeLink = screen.getByRole("link", { name: "Prowler home" });
+ expect(homeLink).toBeVisible();
+ expect(screen.getByRole("link", { name: "Launch Scan" })).toHaveAttribute(
+ "href",
+ "/scans?launchScan=true",
+ );
+ expect(screen.getByText("5.8.0")).toBeVisible();
+
+ await user.click(screen.getByRole("button", { name: "Chat" }));
+ expect(openCloudUpgradeMock).toHaveBeenCalledWith(
+ CLOUD_UPGRADE_FEATURE.LIGHTHOUSE_AI,
+ undefined,
+ );
+ expect(screen.getAllByText("Cloud").length).toBeGreaterThan(0);
+ });
+
+ it("keeps the existing Lighthouse chat sidebar in Cloud Chat mode", () => {
+ // Given
+ vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true");
+ useAppSidebarMode.setState({ mode: APP_SIDEBAR_MODE.CHAT });
+
+ // When
+ render(
);
+
+ // Then
+ expect(screen.getByTestId("lighthouse-chat-sidebar")).toBeVisible();
+ expect(
+ screen.getByRole("link", { name: "Service status" }),
+ ).toHaveAttribute("href", "https://status.prowler.com");
+ expect(
+ screen.queryByText("All systems operational"),
+ ).not.toBeInTheDocument();
+ });
+
+ it("opens the current scan modal instead of navigating from the scans route", async () => {
+ // Given
+ vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true");
+ pathnameValue.current = "/scans";
+ const user = userEvent.setup();
+
+ // When
+ render(
);
+ await user.click(screen.getByRole("button", { name: "Launch Scan" }));
+
+ // Then
+ expect(openLaunchScanModalMock).toHaveBeenCalledOnce();
+ expect(
+ screen.queryByRole("link", { name: "Launch Scan" }),
+ ).not.toBeInTheDocument();
+ });
+});
diff --git a/ui/components/layout/app-sidebar/app-sidebar-content.tsx b/ui/components/layout/app-sidebar/app-sidebar-content.tsx
new file mode 100644
index 0000000000..c32a7d5048
--- /dev/null
+++ b/ui/components/layout/app-sidebar/app-sidebar-content.tsx
@@ -0,0 +1,68 @@
+"use client";
+
+import Link from "next/link";
+import { usePathname } from "next/navigation";
+
+import { LighthouseV2SidebarChat } from "@/app/(prowler)/lighthouse/_components/navigation";
+import { ProwlerBrand } from "@/components/icons";
+import { useAuth } from "@/hooks";
+import { useRuntimeConfig } from "@/hooks/use-runtime-config";
+import { isCloud } from "@/lib/shared/env";
+
+import { useAppSidebarMode } from "./app-sidebar-mode-store";
+import { AppSidebarModeToggle } from "./app-sidebar-mode-toggle";
+import { LaunchScanAction } from "./launch-scan-action";
+import { getNavigationConfig } from "./navigation-config";
+import { SidebarFooter } from "./sidebar-footer";
+import { SidebarNavigation } from "./sidebar-navigation";
+import { APP_SIDEBAR_MODE, type AppSidebarSelectionHandler } from "./types";
+
+interface AppSidebarContentProps {
+ onSelect?: AppSidebarSelectionHandler;
+}
+
+export function AppSidebarContent({ onSelect }: AppSidebarContentProps) {
+ const pathname = usePathname();
+ const { permissions } = useAuth();
+ const { apiDocsUrl } = useRuntimeConfig();
+ const mode = useAppSidebarMode((state) => state.mode);
+ const isCloudEnvironment = isCloud();
+ const sections = getNavigationConfig({ pathname, apiDocsUrl, permissions });
+ const showChat = isCloudEnvironment && mode === APP_SIDEBAR_MODE.CHAT;
+
+ return (
+
+
+
+
+
+
+ {showChat ? (
+
+ ) : (
+
+ )}
+
+
+
+
+ );
+}
diff --git a/ui/components/layout/app-sidebar/app-sidebar-mode-store.test.ts b/ui/components/layout/app-sidebar/app-sidebar-mode-store.test.ts
new file mode 100644
index 0000000000..ba85902736
--- /dev/null
+++ b/ui/components/layout/app-sidebar/app-sidebar-mode-store.test.ts
@@ -0,0 +1,73 @@
+import { beforeEach, describe, expect, it } from "vitest";
+
+import {
+ migrateAppSidebarState,
+ useAppSidebarMode,
+} from "./app-sidebar-mode-store";
+import { APP_SIDEBAR_MODE } from "./types";
+
+describe("app sidebar mode store", () => {
+ beforeEach(() => {
+ localStorage.clear();
+ useAppSidebarMode.setState({ mode: APP_SIDEBAR_MODE.BROWSE });
+ });
+
+ it("keeps the persisted chat mode while discarding legacy sidebar state", () => {
+ // Given
+ const legacyState = {
+ isOpen: false,
+ isHover: true,
+ navigationMode: APP_SIDEBAR_MODE.CHAT,
+ settings: { disabled: false, isHoverOpen: false },
+ };
+
+ // When
+ const migrated = migrateAppSidebarState(legacyState);
+
+ // Then
+ expect(migrated).toEqual({ mode: APP_SIDEBAR_MODE.CHAT });
+ expect(migrated).not.toHaveProperty("isOpen");
+ expect(migrated).not.toHaveProperty("settings");
+ });
+
+ it("falls back to browse for an invalid persisted mode", () => {
+ // Given / When
+ const migrated = migrateAppSidebarState({ navigationMode: "invalid" });
+
+ // Then
+ expect(migrated).toEqual({ mode: APP_SIDEBAR_MODE.BROWSE });
+ });
+
+ it("rehydrates the legacy payload under the existing sidebar key", async () => {
+ // Given
+ localStorage.setItem(
+ "sidebar",
+ JSON.stringify({
+ state: {
+ navigationMode: APP_SIDEBAR_MODE.CHAT,
+ isOpen: false,
+ isHover: true,
+ settings: { disabled: true },
+ },
+ version: 0,
+ }),
+ );
+
+ // When
+ await useAppSidebarMode.persist.rehydrate();
+
+ // Then
+ expect(useAppSidebarMode.getState().mode).toBe(APP_SIDEBAR_MODE.CHAT);
+ expect(useAppSidebarMode.getState()).not.toEqual(
+ expect.objectContaining({ isOpen: expect.anything() }),
+ );
+ });
+
+ it("updates the current navigation mode", () => {
+ // Given / When
+ useAppSidebarMode.getState().setMode(APP_SIDEBAR_MODE.CHAT);
+
+ // Then
+ expect(useAppSidebarMode.getState().mode).toBe(APP_SIDEBAR_MODE.CHAT);
+ });
+});
diff --git a/ui/components/layout/app-sidebar/app-sidebar-mode-store.ts b/ui/components/layout/app-sidebar/app-sidebar-mode-store.ts
new file mode 100644
index 0000000000..fc076c0296
--- /dev/null
+++ b/ui/components/layout/app-sidebar/app-sidebar-mode-store.ts
@@ -0,0 +1,54 @@
+import { create } from "zustand";
+import { createJSONStorage, persist } from "zustand/middleware";
+
+import { APP_SIDEBAR_MODE, type AppSidebarMode } from "./types";
+
+interface PersistedAppSidebarState {
+ mode: AppSidebarMode;
+}
+
+interface AppSidebarModeStore extends PersistedAppSidebarState {
+ setMode: (mode: AppSidebarMode) => void;
+}
+
+function isAppSidebarMode(value: unknown): value is AppSidebarMode {
+ return Object.values(APP_SIDEBAR_MODE).some((mode) => mode === value);
+}
+
+export function migrateAppSidebarState(
+ persistedState: unknown,
+): PersistedAppSidebarState {
+ if (typeof persistedState !== "object" || persistedState === null) {
+ return { mode: APP_SIDEBAR_MODE.BROWSE };
+ }
+
+ if ("mode" in persistedState && isAppSidebarMode(persistedState.mode)) {
+ return { mode: persistedState.mode };
+ }
+
+ if (
+ "navigationMode" in persistedState &&
+ isAppSidebarMode(persistedState.navigationMode)
+ ) {
+ return { mode: persistedState.navigationMode };
+ }
+
+ return { mode: APP_SIDEBAR_MODE.BROWSE };
+}
+
+export const useAppSidebarMode = create
()(
+ persist(
+ (set) => ({
+ mode: APP_SIDEBAR_MODE.BROWSE,
+ setMode: (mode) => set({ mode }),
+ }),
+ {
+ name: "sidebar",
+ storage: createJSONStorage(() => localStorage),
+ merge: (persistedState, currentState) => ({
+ ...currentState,
+ ...migrateAppSidebarState(persistedState),
+ }),
+ },
+ ),
+);
diff --git a/ui/components/layout/app-sidebar/app-sidebar-mode-sync.test.tsx b/ui/components/layout/app-sidebar/app-sidebar-mode-sync.test.tsx
new file mode 100644
index 0000000000..59caaae738
--- /dev/null
+++ b/ui/components/layout/app-sidebar/app-sidebar-mode-sync.test.tsx
@@ -0,0 +1,20 @@
+import { render } from "@testing-library/react";
+import { beforeEach, describe, expect, it } from "vitest";
+
+import { useAppSidebarMode } from "./app-sidebar-mode-store";
+import { AppSidebarModeSync } from "./app-sidebar-mode-sync";
+import { APP_SIDEBAR_MODE } from "./types";
+
+describe("AppSidebarModeSync", () => {
+ beforeEach(() => {
+ useAppSidebarMode.setState({ mode: APP_SIDEBAR_MODE.CHAT });
+ });
+
+ it("restores the requested sidebar mode when a route mounts", () => {
+ // Given / When
+ render();
+
+ // Then
+ expect(useAppSidebarMode.getState().mode).toBe(APP_SIDEBAR_MODE.BROWSE);
+ });
+});
diff --git a/ui/components/layout/app-sidebar/app-sidebar-mode-sync.tsx b/ui/components/layout/app-sidebar/app-sidebar-mode-sync.tsx
new file mode 100644
index 0000000000..c97d84a14d
--- /dev/null
+++ b/ui/components/layout/app-sidebar/app-sidebar-mode-sync.tsx
@@ -0,0 +1,20 @@
+"use client";
+
+import { useMountEffect } from "@/hooks/use-mount-effect";
+
+import { useAppSidebarMode } from "./app-sidebar-mode-store";
+import type { AppSidebarMode } from "./types";
+
+interface AppSidebarModeSyncProps {
+ mode: AppSidebarMode;
+}
+
+export function AppSidebarModeSync({ mode }: AppSidebarModeSyncProps) {
+ const setMode = useAppSidebarMode((state) => state.setMode);
+
+ useMountEffect(() => {
+ setMode(mode);
+ });
+
+ return null;
+}
diff --git a/ui/components/layout/app-sidebar/app-sidebar-mode-toggle.tsx b/ui/components/layout/app-sidebar/app-sidebar-mode-toggle.tsx
new file mode 100644
index 0000000000..0631d09dbf
--- /dev/null
+++ b/ui/components/layout/app-sidebar/app-sidebar-mode-toggle.tsx
@@ -0,0 +1,115 @@
+"use client";
+
+import { Home } from "lucide-react";
+import { useRouter } from "next/navigation";
+
+import { LighthouseIcon } from "@/components/icons/Icons";
+import { Badge } from "@/components/shadcn/badge/badge";
+import { NavigationButton } from "@/components/shadcn/navigation-button";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from "@/components/shadcn/tooltip";
+import { useCloudUpgradeStore } from "@/store";
+import { CLOUD_UPGRADE_FEATURE } from "@/types/cloud-upgrade";
+
+import { useAppSidebarMode } from "./app-sidebar-mode-store";
+import {
+ APP_SIDEBAR_MODE,
+ type AppSidebarMode,
+ type AppSidebarSelectionHandler,
+} from "./types";
+
+interface AppSidebarModeToggleProps {
+ chatEnabled: boolean;
+ onSelect?: AppSidebarSelectionHandler;
+}
+
+const MODES = [
+ {
+ value: APP_SIDEBAR_MODE.BROWSE,
+ label: "Home",
+ icon: Home,
+ },
+ {
+ value: APP_SIDEBAR_MODE.CHAT,
+ label: "Chat",
+ icon: LighthouseIcon,
+ },
+] as const;
+
+export function AppSidebarModeToggle({
+ chatEnabled,
+ onSelect,
+}: AppSidebarModeToggleProps) {
+ const router = useRouter();
+ const mode = useAppSidebarMode((state) => state.mode);
+ const setMode = useAppSidebarMode((state) => state.setMode);
+ const openCloudUpgrade = useCloudUpgradeStore(
+ (state) => state.openCloudUpgrade,
+ );
+
+ const selectMode = (nextMode: AppSidebarMode) => {
+ const isChatUpsell = nextMode === APP_SIDEBAR_MODE.CHAT && !chatEnabled;
+
+ if (isChatUpsell) {
+ openCloudUpgrade(
+ CLOUD_UPGRADE_FEATURE.LIGHTHOUSE_AI,
+ onSelect?.() ?? undefined,
+ );
+ return;
+ }
+
+ setMode(nextMode);
+ onSelect?.();
+
+ if (nextMode === APP_SIDEBAR_MODE.CHAT) {
+ router.push("/lighthouse");
+ }
+ };
+
+ return (
+
+ {MODES.map((item) => {
+ const Icon = item.icon;
+ const isActive = item.value === mode;
+ const isCloudUpsell =
+ item.value === APP_SIDEBAR_MODE.CHAT && !chatEnabled;
+ const button = (
+ selectMode(item.value)}
+ >
+
+ {item.label}
+ {isCloudUpsell && (
+
+ Cloud
+
+ )}
+
+ );
+
+ if (!isCloudUpsell) return button;
+
+ return (
+
+ {button}
+
+ Available in Prowler Cloud
+
+
+ );
+ })}
+
+ );
+}
diff --git a/ui/components/layout/app-sidebar/app-sidebar.tsx b/ui/components/layout/app-sidebar/app-sidebar.tsx
new file mode 100644
index 0000000000..6864cd64a9
--- /dev/null
+++ b/ui/components/layout/app-sidebar/app-sidebar.tsx
@@ -0,0 +1,10 @@
+import { AppSidebarContent } from "./app-sidebar-content";
+
+export function AppSidebar() {
+ return (
+
+ );
+}
diff --git a/ui/components/layout/app-sidebar/index.ts b/ui/components/layout/app-sidebar/index.ts
new file mode 100644
index 0000000000..5ee953a19d
--- /dev/null
+++ b/ui/components/layout/app-sidebar/index.ts
@@ -0,0 +1,5 @@
+export { AppSidebar } from "./app-sidebar";
+export { AppSidebarModeSync } from "./app-sidebar-mode-sync";
+export { MobileAppSidebar } from "./mobile-app-sidebar";
+export type { AppSidebarMode } from "./types";
+export { APP_SIDEBAR_MODE } from "./types";
diff --git a/ui/components/layout/app-sidebar/launch-scan-action.tsx b/ui/components/layout/app-sidebar/launch-scan-action.tsx
new file mode 100644
index 0000000000..1da429811f
--- /dev/null
+++ b/ui/components/layout/app-sidebar/launch-scan-action.tsx
@@ -0,0 +1,57 @@
+"use client";
+
+import { ScanLine } from "lucide-react";
+import Link from "next/link";
+import { usePathname } from "next/navigation";
+
+import { Button } from "@/components/shadcn/button/button";
+import { LAUNCH_SCAN_HREF } from "@/lib/scans-navigation";
+import { useScansStore } from "@/store";
+
+import type { AppSidebarSelectionHandler } from "./types";
+
+interface LaunchScanActionProps {
+ onSelect?: AppSidebarSelectionHandler;
+}
+
+function LaunchScanContent() {
+ return (
+ <>
+
+ Launch Scan
+ >
+ );
+}
+
+export function LaunchScanAction({ onSelect }: LaunchScanActionProps) {
+ const pathname = usePathname();
+ const openLaunchScanModal = useScansStore(
+ (state) => state.openLaunchScanModal,
+ );
+ const isScansPage = pathname.startsWith("/scans");
+
+ if (isScansPage) {
+ return (
+
+ );
+ }
+
+ return (
+
+ );
+}
diff --git a/ui/components/layout/app-sidebar/mobile-app-sidebar.test.tsx b/ui/components/layout/app-sidebar/mobile-app-sidebar.test.tsx
new file mode 100644
index 0000000000..75aae87f5a
--- /dev/null
+++ b/ui/components/layout/app-sidebar/mobile-app-sidebar.test.tsx
@@ -0,0 +1,62 @@
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { describe, expect, it, vi } from "vitest";
+
+vi.mock("./app-sidebar-content", () => ({
+ AppSidebarContent: ({
+ onSelect,
+ }: {
+ onSelect?: () => HTMLElement | null;
+ }) => (
+
+ ),
+}));
+
+import { MobileAppSidebar } from "./mobile-app-sidebar";
+
+describe("MobileAppSidebar", () => {
+ it("replaces the open hamburger with a viewport X while the overlay is visible", async () => {
+ // Given
+ const user = userEvent.setup();
+ render();
+
+ // When
+ const openButton = screen.getByRole("button", { name: "Open menu" });
+ await user.click(openButton);
+
+ // Then
+ expect(screen.getByRole("dialog", { name: "App sidebar" })).toBeVisible();
+ const closeButton = screen.getByRole("button", { name: "Close menu" });
+ expect(closeButton).toBeVisible();
+ expect(closeButton.querySelector(".lucide-x")).toBeInTheDocument();
+ expect(
+ screen.queryByRole("button", { name: "Close" }),
+ ).not.toBeInTheDocument();
+
+ // When
+ await user.click(closeButton);
+
+ // Then
+ expect(
+ screen.queryByRole("dialog", { name: "App sidebar" }),
+ ).not.toBeInTheDocument();
+ expect(openButton).toHaveFocus();
+ });
+
+ it("closes after selecting an item from the shared sidebar content", async () => {
+ // Given
+ const user = userEvent.setup();
+ render();
+ await user.click(screen.getByRole("button", { name: "Open menu" }));
+
+ // When
+ await user.click(screen.getByRole("button", { name: "Alerts" }));
+
+ // Then
+ expect(
+ screen.queryByRole("dialog", { name: "App sidebar" }),
+ ).not.toBeInTheDocument();
+ });
+});
diff --git a/ui/components/layout/app-sidebar/mobile-app-sidebar.tsx b/ui/components/layout/app-sidebar/mobile-app-sidebar.tsx
new file mode 100644
index 0000000000..abb6fbbfe7
--- /dev/null
+++ b/ui/components/layout/app-sidebar/mobile-app-sidebar.tsx
@@ -0,0 +1,63 @@
+"use client";
+
+import { MenuIcon, X } from "lucide-react";
+import { useRef, useState } from "react";
+
+import { Button } from "@/components/shadcn/button/button";
+import {
+ Sheet,
+ SheetClose,
+ SheetContent,
+ SheetDescription,
+ SheetHeader,
+ SheetTitle,
+ SheetTrigger,
+} from "@/components/shadcn/sheet";
+import { cn } from "@/lib/utils";
+
+import { AppSidebarContent } from "./app-sidebar-content";
+
+export function MobileAppSidebar() {
+ const [open, setOpen] = useState(false);
+ const triggerRef = useRef(null);
+
+ const handleSelect = () => {
+ setOpen(false);
+ return triggerRef.current;
+ };
+
+ return (
+
+
+
+
+
+
+ App sidebar
+ Primary application navigation
+
+
+
+
+
+
+
+ );
+}
diff --git a/ui/components/layout/app-sidebar/navigation-config.test.ts b/ui/components/layout/app-sidebar/navigation-config.test.ts
new file mode 100644
index 0000000000..1ad3fc7228
--- /dev/null
+++ b/ui/components/layout/app-sidebar/navigation-config.test.ts
@@ -0,0 +1,335 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+
+import { CLOUD_UPGRADE_FEATURE } from "@/types/cloud-upgrade";
+import type { RolePermissionAttributes } from "@/types/users";
+
+import {
+ filterNavigationByPermissions,
+ getNavigationConfig,
+} from "./navigation-config";
+import { NAVIGATION_ITEM_KIND } from "./types";
+
+const getItem = (label: string) =>
+ getNavigationConfig({ pathname: "/alerts", apiDocsUrl: null })
+ .flatMap((section) => section.items)
+ .find((item) => item.label === label);
+
+const getConfigurationChildren = () => {
+ const configuration = getItem("Configuration");
+
+ if (configuration?.kind !== NAVIGATION_ITEM_KIND.COLLAPSIBLE) {
+ throw new Error("Configuration must be a collapsible navigation item");
+ }
+
+ return configuration.children;
+};
+
+describe("getNavigationConfig", () => {
+ afterEach(() => {
+ vi.unstubAllEnvs();
+ });
+
+ it("groups the Local Server navigation without losing available features", () => {
+ // Given
+ vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false");
+
+ // When
+ const sections = getNavigationConfig({
+ pathname: "/",
+ apiDocsUrl: "https://local.example/api/v1/docs",
+ });
+
+ // Then
+ expect(sections.map((section) => section.label ?? null)).toEqual([
+ null,
+ "SECURITY",
+ "SETTINGS",
+ "HELP",
+ ]);
+ expect(sections[0]?.items.map((item) => item.label)).toEqual([
+ "Overview",
+ "Lighthouse AI",
+ ]);
+ expect(sections[1]?.items.map((item) => item.label)).toEqual([
+ "Compliance",
+ "Findings",
+ "Attack Paths",
+ "Scans",
+ "Resources",
+ ]);
+ expect(sections[2]?.items.map((item) => item.label)).toEqual([
+ "Configuration",
+ "Organization",
+ ]);
+ expect(sections[3]?.items.map((item) => item.label)).toEqual([
+ "Documentation",
+ "API Reference",
+ "Community Support",
+ "Prowler Hub",
+ ]);
+ });
+
+ it("models Local Server Cloud features as contextual upgrade actions", () => {
+ // Given
+ vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false");
+
+ // When
+ const children = getConfigurationChildren();
+
+ // Then
+ expect(children.map((item) => item.label)).toEqual([
+ "Providers",
+ "Alerts",
+ "Mutelist",
+ "Scans",
+ "CLI Import",
+ "Integrations",
+ "Lighthouse AI",
+ ]);
+ expect(children.find((item) => item.label === "Alerts")).toEqual(
+ expect.objectContaining({
+ kind: NAVIGATION_ITEM_KIND.CLOUD_UPGRADE,
+ cloudUpgradeFeature: CLOUD_UPGRADE_FEATURE.ALERTS,
+ }),
+ );
+ expect(children.find((item) => item.label === "Scans")).toEqual(
+ expect.objectContaining({
+ kind: NAVIGATION_ITEM_KIND.CLOUD_UPGRADE,
+ cloudUpgradeFeature: CLOUD_UPGRADE_FEATURE.SCAN_CONFIGURATION,
+ }),
+ );
+ expect(children.find((item) => item.label === "CLI Import")).toEqual(
+ expect.objectContaining({
+ kind: NAVIGATION_ITEM_KIND.CLOUD_UPGRADE,
+ cloudUpgradeFeature: CLOUD_UPGRADE_FEATURE.CLI_IMPORT,
+ }),
+ );
+ });
+
+ it("uses Cloud destinations and current New badges", () => {
+ // Given
+ vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true");
+
+ // When
+ const sections = getNavigationConfig({
+ pathname: "/scans/config",
+ apiDocsUrl: "https://ignored.example/docs",
+ });
+ const items = sections.flatMap((section) => section.items);
+ const configuration = items.find((item) => item.label === "Configuration");
+
+ if (configuration?.kind !== NAVIGATION_ITEM_KIND.COLLAPSIBLE) {
+ throw new Error("Configuration must be a collapsible navigation item");
+ }
+
+ // Then
+ expect(sections[0]?.items.map((item) => item.label)).toEqual(["Overview"]);
+ expect(configuration.children).not.toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({ label: "CLI Import" }),
+ ]),
+ );
+ expect(
+ configuration.children.find((item) => item.label === "Alerts"),
+ ).toEqual(expect.objectContaining({ highlight: true }));
+ expect(
+ configuration.children.find((item) => item.label === "Scans"),
+ ).toEqual(expect.objectContaining({ active: true, highlight: true }));
+ expect(items.find((item) => item.label === "Attack Paths")).not.toEqual(
+ expect.objectContaining({ highlight: true }),
+ );
+ expect(sections[3]?.items.map((item) => item.label)).toEqual([
+ "Documentation",
+ "API Reference",
+ "Support Desk",
+ "Prowler Hub",
+ ]);
+ });
+
+ it("keeps the Cloud Billing destination for users with billing permission", () => {
+ // Given
+ vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true");
+ const permissions = {
+ manage_billing: true,
+ } as RolePermissionAttributes;
+
+ // When
+ const billing = getNavigationConfig({
+ pathname: "/billing",
+ apiDocsUrl: null,
+ permissions,
+ })
+ .flatMap((section) => section.items)
+ .find((item) => item.label === "Billing");
+
+ // Then
+ expect(billing).toEqual(
+ expect.objectContaining({
+ href: "/billing",
+ active: true,
+ requiredPermission: "manage_billing",
+ }),
+ );
+ });
+
+ it("hides Billing without permission and in Local Server", () => {
+ // Given
+ const permissions = {
+ manage_billing: false,
+ } as RolePermissionAttributes;
+ vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true");
+
+ // When
+ const cloudItems = getNavigationConfig({
+ pathname: "/",
+ apiDocsUrl: null,
+ permissions,
+ }).flatMap((section) => section.items);
+ vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false");
+ const localItems = getNavigationConfig({
+ pathname: "/",
+ apiDocsUrl: null,
+ permissions: { ...permissions, manage_billing: true },
+ }).flatMap((section) => section.items);
+
+ // Then
+ expect(cloudItems.find((item) => item.label === "Billing")).toBeUndefined();
+ expect(localItems.find((item) => item.label === "Billing")).toBeUndefined();
+ });
+
+ it("keeps environment-specific API documentation destinations", () => {
+ // Given
+ vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false");
+
+ // When
+ const localApiReference = getNavigationConfig({
+ pathname: "/",
+ apiDocsUrl: "https://local.example/api/v1/docs",
+ })
+ .flatMap((section) => section.items)
+ .find((item) => item.label === "API Reference");
+
+ vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true");
+ const cloudApiReference = getNavigationConfig({
+ pathname: "/",
+ apiDocsUrl: "https://ignored.example/docs",
+ })
+ .flatMap((section) => section.items)
+ .find((item) => item.label === "API Reference");
+
+ // Then
+ expect(localApiReference).toEqual(
+ expect.objectContaining({ href: "https://local.example/api/v1/docs" }),
+ );
+ expect(cloudApiReference).toEqual(
+ expect.objectContaining({ href: "https://api.prowler.com/api/v1/docs" }),
+ );
+ });
+
+ it("omits the Local Server API reference when no URL is configured", () => {
+ // Given
+ vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false");
+
+ // When
+ const items = getNavigationConfig({
+ pathname: "/",
+ apiDocsUrl: null,
+ }).flatMap((section) => section.items);
+
+ // Then
+ expect(
+ items.find((item) => item.label === "API Reference"),
+ ).toBeUndefined();
+ });
+
+ it("filters navigation by required permission after visible copy changes", () => {
+ // Given
+ vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true");
+ const sections = getNavigationConfig({
+ pathname: "/integrations",
+ apiDocsUrl: null,
+ }).map((section) => ({
+ ...section,
+ items: section.items.map((item) =>
+ item.kind === NAVIGATION_ITEM_KIND.COLLAPSIBLE
+ ? {
+ ...item,
+ children: item.children.map((child) =>
+ child.label === "Integrations"
+ ? { ...child, label: "Connected apps" }
+ : child,
+ ),
+ }
+ : item,
+ ),
+ }));
+ const permissions = {
+ manage_integrations: false,
+ } as RolePermissionAttributes;
+
+ // When
+ const filtered = filterNavigationByPermissions(sections, permissions);
+
+ // Then
+ expect(
+ filtered
+ .flatMap((section) => section.items)
+ .filter((item) => item.kind === NAVIGATION_ITEM_KIND.COLLAPSIBLE)
+ .flatMap((item) => item.children)
+ .find((item) => item.label === "Connected apps"),
+ ).toBeUndefined();
+ });
+
+ it("keeps navigation when the required permission is granted", () => {
+ // Given
+ vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true");
+ const permissions = {
+ manage_integrations: true,
+ } as RolePermissionAttributes;
+
+ // When
+ const configuration = getNavigationConfig({
+ pathname: "/integrations",
+ apiDocsUrl: null,
+ permissions,
+ })
+ .flatMap((section) => section.items)
+ .find((item) => item.label === "Configuration");
+
+ // Then
+ expect(configuration).toEqual(
+ expect.objectContaining({
+ children: expect.arrayContaining([
+ expect.objectContaining({ label: "Integrations" }),
+ ]),
+ }),
+ );
+ });
+
+ it("matches complete route segments without stealing nested settings routes", () => {
+ // Given
+ vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false");
+
+ // When
+ const scanDetails = getNavigationConfig({
+ pathname: "/scans/scan-123",
+ apiDocsUrl: null,
+ });
+ const scanSettings = getNavigationConfig({
+ pathname: "/scans/config/edit",
+ apiDocsUrl: null,
+ });
+
+ // Then
+ expect(
+ scanDetails
+ .flatMap((section) => section.items)
+ .find((item) => item.label === "Scans"),
+ ).toEqual(expect.objectContaining({ active: true }));
+ expect(
+ scanSettings
+ .flatMap((section) => section.items)
+ .find((item) => item.label === "Scans"),
+ ).toEqual(expect.objectContaining({ active: false }));
+ });
+});
diff --git a/ui/components/layout/app-sidebar/navigation-config.ts b/ui/components/layout/app-sidebar/navigation-config.ts
new file mode 100644
index 0000000000..40e15cb6e7
--- /dev/null
+++ b/ui/components/layout/app-sidebar/navigation-config.ts
@@ -0,0 +1,325 @@
+import {
+ Code,
+ CreditCard,
+ FileText,
+ GitBranch,
+ LayoutGrid,
+ MessageCircleQuestion,
+ Settings,
+ ShieldCheck,
+ SquareChartGantt,
+ Tag,
+ Timer,
+ Users,
+ Warehouse,
+} from "lucide-react";
+
+import { LighthouseIcon } from "@/components/icons/Icons";
+import { isCloud } from "@/lib/shared/env";
+import type { CloudUpgradeFeature } from "@/types/cloud-upgrade";
+import { CLOUD_UPGRADE_FEATURE } from "@/types/cloud-upgrade";
+import type { RolePermissionAttributes } from "@/types/users";
+
+import {
+ NAVIGATION_ITEM_KIND,
+ NAVIGATION_PERMISSION,
+ type NavigationChild,
+ type NavigationItem,
+ type NavigationSection,
+} from "./types";
+
+interface NavigationConfigOptions {
+ pathname: string;
+ apiDocsUrl?: string | null;
+ permissions?: RolePermissionAttributes;
+}
+
+interface CloudFeatureOptions {
+ isCloudEnvironment: boolean;
+ href: string;
+ label: string;
+ active: boolean;
+ feature: CloudUpgradeFeature;
+}
+
+function getCloudFeature({
+ isCloudEnvironment,
+ href,
+ label,
+ active,
+ feature,
+}: CloudFeatureOptions): NavigationChild {
+ if (!isCloudEnvironment) {
+ return {
+ kind: NAVIGATION_ITEM_KIND.CLOUD_UPGRADE,
+ label,
+ cloudUpgradeFeature: feature,
+ };
+ }
+
+ return {
+ kind: NAVIGATION_ITEM_KIND.LINK,
+ href,
+ label,
+ active,
+ highlight: true,
+ };
+}
+
+function isRouteActive(pathname: string, href: string) {
+ return pathname === href || pathname.startsWith(`${href}/`);
+}
+
+function hasRequiredPermission(
+ item: NavigationItem | NavigationChild,
+ permissions?: RolePermissionAttributes,
+) {
+ return (
+ item.requiredPermission === undefined ||
+ permissions?.[item.requiredPermission] !== false
+ );
+}
+
+export function filterNavigationByPermissions(
+ sections: NavigationSection[],
+ permissions?: RolePermissionAttributes,
+) {
+ return sections
+ .map((section) => ({
+ ...section,
+ items: section.items
+ .filter((item) => hasRequiredPermission(item, permissions))
+ .map((item) =>
+ item.kind === NAVIGATION_ITEM_KIND.COLLAPSIBLE
+ ? {
+ ...item,
+ children: item.children.filter((child) =>
+ hasRequiredPermission(child, permissions),
+ ),
+ }
+ : item,
+ ),
+ }))
+ .filter((section) => section.items.length > 0);
+}
+
+export function getNavigationConfig({
+ pathname,
+ apiDocsUrl = null,
+ permissions,
+}: NavigationConfigOptions): NavigationSection[] {
+ const isCloudEnvironment = isCloud();
+ const apiReferenceUrl = isCloudEnvironment
+ ? "https://api.prowler.com/api/v1/docs"
+ : apiDocsUrl;
+
+ const sections: NavigationSection[] = [
+ {
+ items: [
+ {
+ kind: NAVIGATION_ITEM_KIND.LINK,
+ href: "/",
+ label: "Overview",
+ icon: SquareChartGantt,
+ active: pathname === "/",
+ },
+ ...(!isCloudEnvironment
+ ? [
+ {
+ kind: NAVIGATION_ITEM_KIND.LINK,
+ href: "/lighthouse",
+ label: "Lighthouse AI",
+ icon: LighthouseIcon,
+ active:
+ isRouteActive(pathname, "/lighthouse") &&
+ !isRouteActive(pathname, "/lighthouse/settings"),
+ } as const,
+ ]
+ : []),
+ ],
+ },
+ {
+ label: "SECURITY",
+ items: [
+ {
+ kind: NAVIGATION_ITEM_KIND.LINK,
+ href: "/compliance",
+ label: "Compliance",
+ icon: ShieldCheck,
+ active: isRouteActive(pathname, "/compliance"),
+ },
+ {
+ kind: NAVIGATION_ITEM_KIND.LINK,
+ href: "/findings?filter[muted]=false&filter[status__in]=FAIL",
+ label: "Findings",
+ icon: Tag,
+ active: isRouteActive(pathname, "/findings"),
+ },
+ {
+ kind: NAVIGATION_ITEM_KIND.LINK,
+ href: "/attack-paths",
+ label: "Attack Paths",
+ icon: GitBranch,
+ active: isRouteActive(pathname, "/attack-paths"),
+ },
+ {
+ kind: NAVIGATION_ITEM_KIND.LINK,
+ href: "/scans",
+ label: "Scans",
+ icon: Timer,
+ active:
+ isRouteActive(pathname, "/scans") &&
+ !isRouteActive(pathname, "/scans/config"),
+ },
+ {
+ kind: NAVIGATION_ITEM_KIND.LINK,
+ href: "/resources",
+ label: "Resources",
+ icon: Warehouse,
+ active: isRouteActive(pathname, "/resources"),
+ },
+ ],
+ },
+ {
+ label: "SETTINGS",
+ items: [
+ {
+ kind: NAVIGATION_ITEM_KIND.COLLAPSIBLE,
+ label: "Configuration",
+ icon: Settings,
+ defaultOpen: true,
+ children: [
+ {
+ kind: NAVIGATION_ITEM_KIND.LINK,
+ href: "/providers",
+ label: "Providers",
+ active: isRouteActive(pathname, "/providers"),
+ },
+ getCloudFeature({
+ isCloudEnvironment,
+ href: "/alerts",
+ label: "Alerts",
+ active: isRouteActive(pathname, "/alerts"),
+ feature: CLOUD_UPGRADE_FEATURE.ALERTS,
+ }),
+ {
+ kind: NAVIGATION_ITEM_KIND.LINK,
+ href: "/mutelist",
+ label: "Mutelist",
+ active: pathname === "/mutelist",
+ },
+ getCloudFeature({
+ isCloudEnvironment,
+ href: "/scans/config",
+ label: "Scans",
+ active: isRouteActive(pathname, "/scans/config"),
+ feature: CLOUD_UPGRADE_FEATURE.SCAN_CONFIGURATION,
+ }),
+ ...(!isCloudEnvironment
+ ? [
+ {
+ kind: NAVIGATION_ITEM_KIND.CLOUD_UPGRADE,
+ label: "CLI Import",
+ cloudUpgradeFeature: CLOUD_UPGRADE_FEATURE.CLI_IMPORT,
+ } as const,
+ ]
+ : []),
+ {
+ kind: NAVIGATION_ITEM_KIND.LINK,
+ href: "/integrations",
+ label: "Integrations",
+ requiredPermission: NAVIGATION_PERMISSION.MANAGE_INTEGRATIONS,
+ active: isRouteActive(pathname, "/integrations"),
+ },
+ {
+ kind: NAVIGATION_ITEM_KIND.LINK,
+ href: "/lighthouse/settings",
+ label: "Lighthouse AI",
+ active: isRouteActive(pathname, "/lighthouse/settings"),
+ },
+ ],
+ },
+ {
+ kind: NAVIGATION_ITEM_KIND.COLLAPSIBLE,
+ label: "Organization",
+ icon: Users,
+ defaultOpen: false,
+ children: [
+ {
+ kind: NAVIGATION_ITEM_KIND.LINK,
+ href: "/users",
+ label: "Users",
+ active: isRouteActive(pathname, "/users"),
+ },
+ {
+ kind: NAVIGATION_ITEM_KIND.LINK,
+ href: "/invitations",
+ label: "Invitations",
+ active: isRouteActive(pathname, "/invitations"),
+ },
+ {
+ kind: NAVIGATION_ITEM_KIND.LINK,
+ href: "/roles",
+ label: "Roles",
+ active: isRouteActive(pathname, "/roles"),
+ },
+ ],
+ },
+ ...(isCloudEnvironment
+ ? [
+ {
+ kind: NAVIGATION_ITEM_KIND.LINK,
+ href: "/billing",
+ label: "Billing",
+ icon: CreditCard,
+ requiredPermission: NAVIGATION_PERMISSION.MANAGE_BILLING,
+ active: isRouteActive(pathname, "/billing"),
+ } as const,
+ ]
+ : []),
+ ],
+ },
+ {
+ label: "HELP",
+ items: [
+ {
+ kind: NAVIGATION_ITEM_KIND.LINK,
+ href: "https://docs.prowler.com/",
+ label: "Documentation",
+ icon: FileText,
+ target: "_blank",
+ },
+ ...(apiReferenceUrl
+ ? [
+ {
+ kind: NAVIGATION_ITEM_KIND.LINK,
+ href: apiReferenceUrl,
+ label: "API Reference",
+ icon: Code,
+ target: "_blank",
+ } as const,
+ ]
+ : []),
+ {
+ kind: NAVIGATION_ITEM_KIND.LINK,
+ href: isCloudEnvironment
+ ? "https://customer.support.prowler.com/servicedesk/customer/portal/9/create/102"
+ : "https://github.com/prowler-cloud/prowler/issues",
+ label: isCloudEnvironment ? "Support Desk" : "Community Support",
+ icon: MessageCircleQuestion,
+ target: "_blank",
+ },
+ {
+ kind: NAVIGATION_ITEM_KIND.LINK,
+ href: "https://hub.prowler.com/",
+ label: "Prowler Hub",
+ icon: LayoutGrid,
+ target: "_blank",
+ tooltip: "Looking for all available checks? learn more.",
+ },
+ ],
+ },
+ ];
+
+ return filterNavigationByPermissions(sections, permissions);
+}
diff --git a/ui/components/layout/app-sidebar/sidebar-footer.tsx b/ui/components/layout/app-sidebar/sidebar-footer.tsx
new file mode 100644
index 0000000000..66c45a5bcb
--- /dev/null
+++ b/ui/components/layout/app-sidebar/sidebar-footer.tsx
@@ -0,0 +1,63 @@
+"use client";
+
+import { Cloud } from "lucide-react";
+import Link from "next/link";
+
+import { Button } from "@/components/shadcn/button/button";
+import { useCloudUpgradeStore } from "@/store";
+import { CLOUD_UPGRADE_FEATURE } from "@/types/cloud-upgrade";
+
+import type { AppSidebarSelectionHandler } from "./types";
+
+interface SidebarFooterProps {
+ isCloudEnvironment: boolean;
+ onSelect?: AppSidebarSelectionHandler;
+}
+
+export function SidebarFooter({
+ isCloudEnvironment,
+ onSelect,
+}: SidebarFooterProps) {
+ const openCloudUpgrade = useCloudUpgradeStore(
+ (state) => state.openCloudUpgrade,
+ );
+ const version = process.env.NEXT_PUBLIC_PROWLER_RELEASE_VERSION;
+
+ return (
+
+ {!isCloudEnvironment && (
+
+
+
+ )}
+
+
+ {isCloudEnvironment && (
+
+ Service status
+
+ )}
+ {version}
+
+
+ );
+}
diff --git a/ui/components/layout/app-sidebar/sidebar-navigation.test.tsx b/ui/components/layout/app-sidebar/sidebar-navigation.test.tsx
new file mode 100644
index 0000000000..196a20a2cf
--- /dev/null
+++ b/ui/components/layout/app-sidebar/sidebar-navigation.test.tsx
@@ -0,0 +1,196 @@
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { FileText, Settings, ShieldCheck } from "lucide-react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+import { CLOUD_UPGRADE_FEATURE } from "@/types/cloud-upgrade";
+
+import { SidebarNavigation } from "./sidebar-navigation";
+import { NAVIGATION_ITEM_KIND, type NavigationSection } from "./types";
+
+const { openCloudUpgradeMock } = vi.hoisted(() => ({
+ openCloudUpgradeMock: vi.fn(),
+}));
+
+vi.mock("@/store", () => ({
+ useCloudUpgradeStore: (
+ selector: (state: {
+ openCloudUpgrade: typeof openCloudUpgradeMock;
+ }) => unknown,
+ ) => selector({ openCloudUpgrade: openCloudUpgradeMock }),
+}));
+
+const sections: NavigationSection[] = [
+ {
+ label: "SECURITY",
+ items: [
+ {
+ kind: NAVIGATION_ITEM_KIND.LINK,
+ href: "/compliance",
+ label: "Compliance",
+ icon: ShieldCheck,
+ active: true,
+ },
+ ],
+ },
+ {
+ label: "SETTINGS",
+ items: [
+ {
+ kind: NAVIGATION_ITEM_KIND.COLLAPSIBLE,
+ label: "Configuration",
+ icon: Settings,
+ defaultOpen: false,
+ children: [
+ {
+ kind: NAVIGATION_ITEM_KIND.LINK,
+ href: "/providers",
+ label: "Providers",
+ active: true,
+ },
+ {
+ kind: NAVIGATION_ITEM_KIND.CLOUD_UPGRADE,
+ label: "Alerts",
+ cloudUpgradeFeature: CLOUD_UPGRADE_FEATURE.ALERTS,
+ },
+ ],
+ },
+ ],
+ },
+ {
+ label: "HELP",
+ items: [
+ {
+ kind: NAVIGATION_ITEM_KIND.LINK,
+ href: "https://docs.prowler.com/",
+ label: "Documentation",
+ icon: FileText,
+ target: "_blank",
+ },
+ ],
+ },
+];
+
+function setProviderActive(active: boolean): NavigationSection[] {
+ return sections.map((section) => ({
+ ...section,
+ items: section.items.map((item) =>
+ item.kind === NAVIGATION_ITEM_KIND.COLLAPSIBLE
+ ? {
+ ...item,
+ children: item.children.map((child) =>
+ child.kind === NAVIGATION_ITEM_KIND.LINK &&
+ child.label === "Providers"
+ ? { ...child, active }
+ : child,
+ ),
+ }
+ : item,
+ ),
+ }));
+}
+
+describe("SidebarNavigation", () => {
+ beforeEach(() => {
+ openCloudUpgradeMock.mockClear();
+ });
+
+ it("renders grouped semantic navigation with accessible active destinations", () => {
+ // Given / When
+ render();
+
+ // Then
+ expect(
+ screen.getByRole("navigation", { name: "Main navigation" }),
+ ).toBeVisible();
+ expect(screen.getByText("SECURITY")).toBeVisible();
+ expect(screen.getByRole("link", { name: "Compliance" })).toHaveAttribute(
+ "aria-current",
+ "page",
+ );
+ expect(screen.getByRole("link", { name: "Providers" })).toHaveAttribute(
+ "aria-current",
+ "page",
+ );
+ const activeParent = screen.getByRole("button", {
+ name: "Configuration",
+ });
+ expect(activeParent).toHaveAttribute("aria-expanded", "true");
+ });
+
+ it("allows manually collapsing a section with an active destination", async () => {
+ // Given
+ const user = userEvent.setup();
+ render();
+ const activeParent = screen.getByRole("button", {
+ name: "Configuration",
+ });
+
+ // When
+ await user.click(activeParent);
+
+ // Then
+ expect(activeParent).toHaveAttribute("aria-expanded", "false");
+ });
+
+ it("expands a collapsed section when one of its destinations becomes active", async () => {
+ // Given
+ const user = userEvent.setup();
+ const { rerender } = render(
+ ,
+ );
+ const configuration = screen.getByRole("button", {
+ name: "Configuration",
+ });
+ expect(configuration).toHaveAttribute("aria-expanded", "false");
+
+ // When
+ rerender();
+
+ // Then
+ const activeConfiguration = screen.getByRole("button", {
+ name: "Configuration",
+ });
+ expect(activeConfiguration).toHaveAttribute("aria-expanded", "true");
+
+ // When
+ await user.click(activeConfiguration);
+
+ // Then
+ expect(activeConfiguration).toHaveAttribute("aria-expanded", "false");
+
+ // When
+ rerender();
+
+ // Then
+ expect(
+ screen.getByRole("button", { name: "Configuration" }),
+ ).toHaveAttribute("aria-expanded", "false");
+ });
+
+ it("marks external links and opens contextual Cloud upgrades", async () => {
+ // Given
+ const user = userEvent.setup();
+ const returnFocusElement = document.createElement("button");
+ const onSelect = vi.fn(() => returnFocusElement);
+ render();
+
+ // When
+ await user.click(screen.getByRole("button", { name: /alerts/i }));
+
+ // Then
+ expect(openCloudUpgradeMock).toHaveBeenCalledWith(
+ CLOUD_UPGRADE_FEATURE.ALERTS,
+ returnFocusElement,
+ );
+ expect(screen.getByText("Cloud")).toBeVisible();
+ expect(screen.getByRole("link", { name: "Documentation" })).toHaveAttribute(
+ "target",
+ "_blank",
+ );
+ expect(screen.getByRole("link", { name: "Documentation" })).toHaveAttribute(
+ "rel",
+ "noopener noreferrer",
+ );
+ });
+});
diff --git a/ui/components/layout/app-sidebar/sidebar-navigation.tsx b/ui/components/layout/app-sidebar/sidebar-navigation.tsx
new file mode 100644
index 0000000000..341aacfebf
--- /dev/null
+++ b/ui/components/layout/app-sidebar/sidebar-navigation.tsx
@@ -0,0 +1,288 @@
+"use client";
+
+import { ArrowUpRight, ChevronDown } from "lucide-react";
+import Link from "next/link";
+import { useState } from "react";
+
+import { Badge } from "@/components/shadcn/badge/badge";
+import {
+ Collapsible,
+ CollapsibleContent,
+ CollapsibleTrigger,
+} from "@/components/shadcn/collapsible";
+import { NavigationButton } from "@/components/shadcn/navigation-button";
+import { ScrollArea } from "@/components/shadcn/scroll-area/scroll-area";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from "@/components/shadcn/tooltip";
+import { cn } from "@/lib/utils";
+import { useCloudUpgradeStore } from "@/store";
+
+import {
+ type AppSidebarSelectionHandler,
+ NAVIGATION_ITEM_KIND,
+ type NavigationChild,
+ type NavigationChildLink,
+ type NavigationCloudUpgrade,
+ type NavigationCollapsible,
+ type NavigationLink,
+ type NavigationSection,
+} from "./types";
+
+interface SidebarNavigationProps {
+ sections: NavigationSection[];
+ onSelect?: AppSidebarSelectionHandler;
+}
+
+interface NavigationLinkProps {
+ item: NavigationLink;
+ onSelect?: AppSidebarSelectionHandler;
+}
+
+interface NavigationCollapsibleProps {
+ item: NavigationCollapsible;
+ onSelect?: AppSidebarSelectionHandler;
+}
+
+interface NavigationChildProps {
+ item: NavigationChild;
+ onSelect?: AppSidebarSelectionHandler;
+}
+
+function getCollapsibleActivationKey(item: NavigationCollapsible) {
+ const activeChild = item.children.find(
+ (child) =>
+ child.kind === NAVIGATION_ITEM_KIND.LINK && child.active === true,
+ );
+
+ return `${item.label}-${activeChild?.label ?? "inactive"}`;
+}
+
+function TopLevelLink({ item, onSelect }: NavigationLinkProps) {
+ const Icon = item.icon;
+ const isExternal = item.target === "_blank";
+ const link = (
+
+
+ {item.active && (
+
+ )}
+
+ {item.label}
+ {item.highlight && (
+
+ New
+
+ )}
+ {isExternal && }
+
+
+ );
+
+ if (!item.tooltip) return link;
+
+ return (
+
+ {link}
+ {item.tooltip}
+
+ );
+}
+
+function CloudUpgradeChild({
+ item,
+ onSelect,
+}: {
+ item: NavigationCloudUpgrade;
+ onSelect?: AppSidebarSelectionHandler;
+}) {
+ const openCloudUpgrade = useCloudUpgradeStore(
+ (state) => state.openCloudUpgrade,
+ );
+
+ return (
+ {
+ openCloudUpgrade(item.cloudUpgradeFeature, onSelect?.() ?? undefined);
+ }}
+ >
+ {item.label}
+
+ Cloud
+
+
+ );
+}
+
+function LinkChild({
+ item,
+ onSelect,
+}: {
+ item: NavigationChildLink;
+ onSelect?: AppSidebarSelectionHandler;
+}) {
+ const isExternal = item.target === "_blank";
+
+ if (item.disabled) {
+ return (
+
+ {item.label}
+
+ );
+ }
+
+ return (
+
+
+ {item.label}
+ {item.highlight && (
+
+ New
+
+ )}
+ {isExternal && }
+
+
+ );
+}
+
+function NavigationChildItem({ item, onSelect }: NavigationChildProps) {
+ if (item.kind === NAVIGATION_ITEM_KIND.CLOUD_UPGRADE) {
+ return ;
+ }
+
+ return ;
+}
+
+function CollapsibleNavigationItem({
+ item,
+ onSelect,
+}: NavigationCollapsibleProps) {
+ const hasActiveChild = item.children.some(
+ (child) =>
+ child.kind === NAVIGATION_ITEM_KIND.LINK && child.active === true,
+ );
+ const [expanded, setExpanded] = useState(item.defaultOpen || hasActiveChild);
+ const isOpen = expanded;
+ const Icon = item.icon;
+
+ return (
+
+
+
+ {hasActiveChild && (
+
+ )}
+
+ {item.label}
+
+
+
+
+
+ {item.children.map((child) => (
+ -
+
+
+ ))}
+
+
+
+ );
+}
+
+function NavigationSectionList({
+ section,
+ onSelect,
+}: {
+ section: NavigationSection;
+ onSelect?: AppSidebarSelectionHandler;
+}) {
+ return (
+
+ {section.label && (
+
+ )}
+
+ {section.items.map((item) => (
+ -
+ {item.kind === NAVIGATION_ITEM_KIND.COLLAPSIBLE ? (
+
+ ) : (
+
+ )}
+
+ ))}
+
+
+ );
+}
+
+export function SidebarNavigation({
+ sections,
+ onSelect,
+}: SidebarNavigationProps) {
+ return (
+
+
+
+ );
+}
diff --git a/ui/components/layout/app-sidebar/types.ts b/ui/components/layout/app-sidebar/types.ts
new file mode 100644
index 0000000000..a05d593779
--- /dev/null
+++ b/ui/components/layout/app-sidebar/types.ts
@@ -0,0 +1,71 @@
+import type { IconComponent } from "@/types";
+import type { CloudUpgradeFeature } from "@/types/cloud-upgrade";
+
+export const APP_SIDEBAR_MODE = {
+ BROWSE: "browse",
+ CHAT: "chat",
+} as const;
+
+export type AppSidebarMode =
+ (typeof APP_SIDEBAR_MODE)[keyof typeof APP_SIDEBAR_MODE];
+
+export const NAVIGATION_ITEM_KIND = {
+ LINK: "link",
+ COLLAPSIBLE: "collapsible",
+ CLOUD_UPGRADE: "cloud_upgrade",
+} as const;
+
+export const NAVIGATION_PERMISSION = {
+ MANAGE_BILLING: "manage_billing",
+ MANAGE_INTEGRATIONS: "manage_integrations",
+} as const;
+
+export type NavigationPermission =
+ (typeof NAVIGATION_PERMISSION)[keyof typeof NAVIGATION_PERMISSION];
+
+interface NavigationLabel {
+ label: string;
+ requiredPermission?: NavigationPermission;
+}
+
+export interface NavigationLink extends NavigationLabel {
+ kind: typeof NAVIGATION_ITEM_KIND.LINK;
+ href: string;
+ icon: IconComponent;
+ active?: boolean;
+ highlight?: boolean;
+ target?: string;
+ tooltip?: string;
+}
+
+export interface NavigationChildLink extends NavigationLabel {
+ kind: typeof NAVIGATION_ITEM_KIND.LINK;
+ href: string;
+ active?: boolean;
+ disabled?: boolean;
+ highlight?: boolean;
+ target?: string;
+}
+
+export interface NavigationCloudUpgrade extends NavigationLabel {
+ kind: typeof NAVIGATION_ITEM_KIND.CLOUD_UPGRADE;
+ cloudUpgradeFeature: CloudUpgradeFeature;
+}
+
+export type NavigationChild = NavigationChildLink | NavigationCloudUpgrade;
+
+export interface NavigationCollapsible extends NavigationLabel {
+ kind: typeof NAVIGATION_ITEM_KIND.COLLAPSIBLE;
+ icon: IconComponent;
+ children: NavigationChild[];
+ defaultOpen: boolean;
+}
+
+export type NavigationItem = NavigationLink | NavigationCollapsible;
+
+export interface NavigationSection {
+ label?: string;
+ items: NavigationItem[];
+}
+
+export type AppSidebarSelectionHandler = () => HTMLElement | null;
diff --git a/ui/components/layout/index.ts b/ui/components/layout/index.ts
index 33b087bb29..bdc128d36c 100644
--- a/ui/components/layout/index.ts
+++ b/ui/components/layout/index.ts
@@ -1,4 +1,4 @@
+export * from "./app-sidebar";
export * from "./main-layout";
export * from "./nav-bar";
-export * from "./sidebar";
export * from "./user-nav";
diff --git a/ui/components/layout/main-layout/main-layout.test.tsx b/ui/components/layout/main-layout/main-layout.test.tsx
index 90c28cfa0c..9fc9ae816e 100644
--- a/ui/components/layout/main-layout/main-layout.test.tsx
+++ b/ui/components/layout/main-layout/main-layout.test.tsx
@@ -3,19 +3,8 @@ import { describe, expect, it, vi } from "vitest";
import MainLayout from "./main-layout";
-vi.mock("@/hooks/use-sidebar", () => ({
- useSidebar: vi.fn(),
-}));
-
-vi.mock("@/hooks/use-store", () => ({
- useStore: () => ({
- getOpenState: () => true,
- settings: { disabled: false },
- }),
-}));
-
-vi.mock("../sidebar/sidebar", () => ({
- Sidebar: () => ,
+vi.mock("@/components/layout/app-sidebar", () => ({
+ AppSidebar: () => ,
}));
vi.mock("@/components/shared/cloud-upgrade-modal", () => ({
@@ -31,6 +20,8 @@ describe("MainLayout", () => {
);
expect(screen.getByTestId("cloud-upgrade-modal")).toBeInTheDocument();
+ expect(screen.getByTestId("sidebar")).toBeInTheDocument();
expect(screen.getByText("Page content")).toBeVisible();
+ expect(screen.getByRole("main")).toBeVisible();
});
});
diff --git a/ui/components/layout/main-layout/main-layout.tsx b/ui/components/layout/main-layout/main-layout.tsx
index e320e0b3d2..5f12e22aba 100644
--- a/ui/components/layout/main-layout/main-layout.tsx
+++ b/ui/components/layout/main-layout/main-layout.tsx
@@ -1,49 +1,15 @@
-"use client";
+import { type ReactNode, Suspense } from "react";
+import { AppSidebar } from "@/components/layout/app-sidebar";
import { CloudUpgradeModal } from "@/components/shared/cloud-upgrade-modal";
-import { useSidebar } from "@/hooks/use-sidebar";
-import { useStore } from "@/hooks/use-store";
-import { cn } from "@/lib/utils";
-import { Sidebar } from "../sidebar/sidebar";
-export default function MainLayout({
- children,
-}: {
- children: React.ReactNode;
-}) {
- const sidebar = useStore(useSidebar, (x) => x);
- if (!sidebar) return null;
- const { getOpenState, settings } = sidebar;
+export default function MainLayout({ children }: { children: ReactNode }) {
return (
- {/* Top-left gradient halo */}
-
-
- {/* Bottom-right gradient halo */}
-
-
-
+
-
- {children}
+
+ {children}
);
diff --git a/ui/components/layout/nav-bar/navbar-client.test.tsx b/ui/components/layout/nav-bar/navbar-client.test.tsx
index 0638837dad..60147a24f7 100644
--- a/ui/components/layout/nav-bar/navbar-client.test.tsx
+++ b/ui/components/layout/nav-bar/navbar-client.test.tsx
@@ -30,10 +30,6 @@ vi.mock("@/store/onboarding-replay", () => {
return { useOnboardingReplayStore: hook };
});
-vi.mock("@/hooks/use-sidebar", () => ({
- useSidebar: () => ({ isOpen: true, toggleOpen: vi.fn() }),
-}));
-
vi.mock("@/components/ThemeSwitch", () => ({
ThemeSwitch: () => ,
}));
@@ -54,12 +50,8 @@ vi.mock("@/components/shadcn", async (importOriginal) => ({
),
}));
-vi.mock("../sidebar/sheet-menu", () => ({
- SheetMenu: () => ,
-}));
-
-vi.mock("../sidebar/sidebar-toggle", () => ({
- SidebarToggle: () => ,
+vi.mock("@/components/layout/app-sidebar", () => ({
+ MobileAppSidebar: () => ,
}));
vi.mock("../user-nav/user-nav", () => ({
diff --git a/ui/components/layout/nav-bar/navbar-client.tsx b/ui/components/layout/nav-bar/navbar-client.tsx
index e2992b1d11..5ee6a6a0b6 100644
--- a/ui/components/layout/nav-bar/navbar-client.tsx
+++ b/ui/components/layout/nav-bar/navbar-client.tsx
@@ -4,6 +4,7 @@ import { BellRing, Info } from "lucide-react";
import { usePathname, useRouter } from "next/navigation";
import { ReactNode, Suspense } from "react";
+import { MobileAppSidebar } from "@/components/layout/app-sidebar";
import {
BreadcrumbNavigation,
Button,
@@ -12,7 +13,6 @@ import {
TooltipTrigger,
} from "@/components/shadcn";
import { ThemeSwitch } from "@/components/ThemeSwitch";
-import { useSidebar } from "@/hooks/use-sidebar";
import { getFlowById } from "@/lib/onboarding";
import { isCloud } from "@/lib/shared/env";
import { useTourCompletion } from "@/lib/tours/use-tour-completion";
@@ -20,8 +20,6 @@ import { cn } from "@/lib/utils";
import { useOnboardingReplayStore } from "@/store/onboarding-replay";
import { usePageReadyStore } from "@/store/page-ready";
-import { SheetMenu } from "../sidebar/sheet-menu";
-import { SidebarToggle } from "../sidebar/sidebar-toggle";
import { UserNav } from "../user-nav/user-nav";
export interface OnboardingActionConfig {
@@ -43,7 +41,6 @@ export function NavbarClient({
onboardingAction,
feedsSlot,
}: NavbarClientProps) {
- const { isOpen, toggleOpen } = useSidebar();
const pathname = usePathname();
const router = useRouter();
const requestReplay = useOnboardingReplayStore(
@@ -85,10 +82,7 @@ export function NavbarClient({
-
-
-
-
+
{/* Suspense contains the useSearchParams() CSR bailout in BreadcrumbNavigation
so statically prerendered pages don't fail the build. */}
diff --git a/ui/components/layout/sidebar/collapsible-menu.tsx b/ui/components/layout/sidebar/collapsible-menu.tsx
deleted file mode 100644
index ed9ea29b2f..0000000000
--- a/ui/components/layout/sidebar/collapsible-menu.tsx
+++ /dev/null
@@ -1,103 +0,0 @@
-"use client";
-
-import { ChevronDown } from "lucide-react";
-import { usePathname } from "next/navigation";
-import { useState } from "react";
-
-import { SubmenuItem } from "@/components/layout/sidebar/submenu-item";
-import { Button } from "@/components/shadcn/button/button";
-import {
- Collapsible,
- CollapsibleContent,
- CollapsibleTrigger,
-} from "@/components/shadcn/collapsible";
-import {
- Tooltip,
- TooltipContent,
- TooltipTrigger,
-} from "@/components/shadcn/tooltip";
-import { cn } from "@/lib/utils";
-import {
- IconComponent,
- type MenuSelectionHandler,
- SUBMENU_KIND,
- SubmenuProps,
-} from "@/types";
-
-interface CollapsibleMenuProps {
- icon: IconComponent;
- label: string;
- submenus: SubmenuProps[];
- defaultOpen?: boolean;
- isOpen: boolean;
- onSelect?: MenuSelectionHandler;
-}
-
-export const CollapsibleMenu = ({
- icon: Icon,
- label,
- submenus,
- defaultOpen = false,
- isOpen: isSidebarOpen,
- onSelect,
-}: CollapsibleMenuProps) => {
- const pathname = usePathname();
- const isSubmenuActive = submenus.some(
- (submenu) =>
- submenu.kind !== SUBMENU_KIND.CLOUD_UPGRADE &&
- (submenu.active === undefined
- ? submenu.href === pathname
- : submenu.active),
- );
- const [isCollapsed, setIsCollapsed] = useState(
- isSubmenuActive || defaultOpen,
- );
- const isOpen = isSidebarOpen && isCollapsed;
-
- return (
-
-
-
-
-
-
-
- {!isSidebarOpen && (
- {label}
- )}
-
-
- {submenus.map((submenu, index) => (
-
- ))}
-
-
- );
-};
diff --git a/ui/components/layout/sidebar/index.ts b/ui/components/layout/sidebar/index.ts
deleted file mode 100644
index 6fe9b4c4e2..0000000000
--- a/ui/components/layout/sidebar/index.ts
+++ /dev/null
@@ -1,7 +0,0 @@
-export * from "./collapsible-menu";
-export * from "./menu";
-export * from "./menu-item";
-export * from "./sheet-menu";
-export * from "./sidebar";
-export * from "./sidebar-toggle";
-export * from "./submenu-item";
diff --git a/ui/components/layout/sidebar/menu-item.tsx b/ui/components/layout/sidebar/menu-item.tsx
deleted file mode 100644
index 739041ee82..0000000000
--- a/ui/components/layout/sidebar/menu-item.tsx
+++ /dev/null
@@ -1,82 +0,0 @@
-"use client";
-
-import Link from "next/link";
-import { usePathname } from "next/navigation";
-
-import { Badge } from "@/components/shadcn/badge/badge";
-import { Button } from "@/components/shadcn/button/button";
-import {
- Tooltip,
- TooltipContent,
- TooltipTrigger,
-} from "@/components/shadcn/tooltip";
-import { cn } from "@/lib/utils";
-import { IconComponent, type MenuSelectionHandler } from "@/types";
-
-interface MenuItemProps {
- href: string;
- label: string;
- icon: IconComponent;
- active?: boolean;
- target?: string;
- tooltip?: string;
- isOpen: boolean;
- highlight?: boolean;
- onSelect?: MenuSelectionHandler;
-}
-
-export const MenuItem = ({
- href,
- label,
- icon: Icon,
- active,
- target,
- tooltip,
- isOpen,
- highlight,
- onSelect,
-}: MenuItemProps) => {
- const pathname = usePathname();
- // Extract only the pathname from href (without query parameters) for comparison
- const hrefPathname = href.split("?")[0];
- const isActive =
- active !== undefined ? active : pathname.startsWith(hrefPathname);
-
- // Show tooltip always for Prowler Hub, or when sidebar is collapsed
- const showTooltip = label === "Prowler Hub" ? !!tooltip : !isOpen;
-
- return (
-
-
-
-
- {showTooltip && (
- {tooltip || label}
- )}
-
- );
-};
diff --git a/ui/components/layout/sidebar/menu.test.tsx b/ui/components/layout/sidebar/menu.test.tsx
deleted file mode 100644
index 5e756ecbad..0000000000
--- a/ui/components/layout/sidebar/menu.test.tsx
+++ /dev/null
@@ -1,302 +0,0 @@
-import { render, screen } from "@testing-library/react";
-import userEvent from "@testing-library/user-event";
-import {
- afterEach,
- beforeAll,
- beforeEach,
- describe,
- expect,
- it,
- vi,
-} from "vitest";
-
-import { SIDEBAR_NAVIGATION_MODE } from "@/hooks/use-sidebar";
-
-const {
- openLaunchScanModalMock,
- openCloudUpgradeMock,
- pathnameValue,
- pushMock,
- navigationModeValue,
- setNavigationModeMock,
-} = vi.hoisted(() => ({
- openLaunchScanModalMock: vi.fn(),
- openCloudUpgradeMock: vi.fn(),
- pathnameValue: { current: "/findings" },
- pushMock: vi.fn(),
- navigationModeValue: { current: "browse" },
- setNavigationModeMock: vi.fn(),
-}));
-
-vi.mock("next/navigation", () => ({
- usePathname: () => pathnameValue.current,
- useRouter: () => ({
- push: pushMock,
- }),
-}));
-
-vi.mock("next-auth/react", () => ({
- useSession: () => ({
- data: { user: { permissions: {} } },
- status: "authenticated",
- }),
-}));
-
-vi.mock("@/hooks", () => ({
- useAuth: () => ({
- permissions: {},
- }),
-}));
-
-vi.mock("@/lib/menu-list", () => ({
- getMenuList: () => [],
-}));
-
-vi.mock("@/app/(prowler)/lighthouse/_components/navigation", () => ({
- LighthouseV2SidebarChat: () => ,
-}));
-
-vi.mock("@/store", () => ({
- useScansStore: (
- selector: (state: { openLaunchScanModal: () => void }) => unknown,
- ) => selector({ openLaunchScanModal: openLaunchScanModalMock }),
- useCloudUpgradeStore: (
- selector: (state: {
- openCloudUpgrade: (feature: string) => void;
- }) => unknown,
- ) => selector({ openCloudUpgrade: openCloudUpgradeMock }),
-}));
-
-vi.mock("@/hooks/use-sidebar", async (importActual) => {
- const actual = await importActual();
- return {
- ...actual,
- useSidebar: (
- selector: (state: {
- navigationMode: string;
- setNavigationMode: (mode: string) => void;
- }) => unknown,
- ) =>
- selector({
- navigationMode: navigationModeValue.current,
- setNavigationMode: setNavigationModeMock,
- }),
- };
-});
-
-let MenuComponent: typeof import("./menu").Menu;
-let SidebarNavigationModeToggleComponent: typeof import("@/components/sidebar/navigation-mode-toggle").SidebarNavigationModeToggle;
-
-const expectLastCloudUpgrade = (feature: string) => {
- expect(openCloudUpgradeMock.mock.calls.at(-1)?.[0]).toBe(feature);
-};
-
-beforeAll(async () => {
- MenuComponent = (await import("./menu")).Menu;
- SidebarNavigationModeToggleComponent = (
- await import("@/components/sidebar/navigation-mode-toggle")
- ).SidebarNavigationModeToggle;
-});
-
-afterEach(() => {
- vi.unstubAllEnvs();
- navigationModeValue.current = "browse";
- openCloudUpgradeMock.mockClear();
-});
-
-describe("Menu", () => {
- it("links scan to the scans page with the modal open", () => {
- pathnameValue.current = "/findings";
-
- render();
-
- const launchScanLink = screen.getByRole("link", { name: /launch scan/i });
- const launchScanWrapper = launchScanLink.closest("div.flex.shrink-0");
-
- expect(launchScanLink).toHaveAttribute("href", "/scans?launchScan=true");
- expect(launchScanWrapper).toHaveClass("flex", "justify-center");
- expect(launchScanLink).toHaveClass("h-14", "w-full", "p-1");
- expect(launchScanLink).not.toHaveClass("h-8", "h-9", "h-10");
- expect(screen.getByText("Scan")).toHaveClass("text-xl", "leading-8");
- expect(screen.getByText("Scan")).not.toHaveClass("text-2xl", "font-bold");
- expect(
- launchScanLink.querySelector('svg[viewBox="0 0 432.08 396.77"]'),
- ).toBeInTheDocument();
- });
-
- it("opens the launch scan modal without navigation when already on scans", async () => {
- pathnameValue.current = "/scans";
-
- render();
-
- await screen.getByRole("button", { name: /launch scan/i }).click();
-
- expect(openLaunchScanModalMock).toHaveBeenCalledTimes(1);
- expect(
- screen.queryByRole("link", { name: /launch scan/i }),
- ).not.toBeInTheDocument();
- });
-
- it("shows the Prowler icon when the menu is collapsed", () => {
- pathnameValue.current = "/findings";
-
- render();
-
- const launchScanLink = screen.getByRole("link", { name: /launch scan/i });
-
- expect(launchScanLink).toHaveClass("h-9", "w-14");
- expect(launchScanLink).not.toHaveClass("h-14");
- expect(
- launchScanLink.querySelector('svg[viewBox="0 0 432.08 396.77"]'),
- ).toBeInTheDocument();
- });
-
- it("swaps to the Lighthouse chat sidebar in cloud CHAT mode", () => {
- pathnameValue.current = "/lighthouse";
- vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true");
- navigationModeValue.current = SIDEBAR_NAVIGATION_MODE.CHAT;
-
- render();
-
- expect(screen.getByTestId("lighthouse-chat-sidebar")).toBeInTheDocument();
- expect(screen.getByRole("button", { name: "Chat" })).toBeInTheDocument();
- });
-
- it("keeps the navigation menu in cloud BROWSE mode", () => {
- pathnameValue.current = "/findings";
- vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true");
- navigationModeValue.current = SIDEBAR_NAVIGATION_MODE.BROWSE;
-
- render();
-
- expect(
- screen.queryByTestId("lighthouse-chat-sidebar"),
- ).not.toBeInTheDocument();
- expect(screen.getByRole("button", { name: "Home" })).toBeInTheDocument();
- });
-
- it("opens the managed Lighthouse upgrade from Chat in Local Server", async () => {
- pathnameValue.current = "/findings";
- vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false");
-
- render();
-
- expect(screen.getByRole("button", { name: "Home" })).toBeInTheDocument();
- const chatButton = screen.getByRole("button", { name: "Chat" });
- expect(chatButton).not.toHaveAttribute("aria-disabled");
-
- await userEvent.click(chatButton);
-
- expectLastCloudUpgrade("lighthouse_ai");
- });
-
- it("shows a persistent Cloud upgrade action only in Local Server", async () => {
- pathnameValue.current = "/findings";
- vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false");
-
- render();
-
- const upgradeButton = screen.getByRole("button", {
- name: "Explore Prowler Cloud",
- });
- expect(upgradeButton).toHaveClass("bg-button-primary");
-
- await userEvent.click(upgradeButton);
-
- expectLastCloudUpgrade("general");
- });
-
- it("separates the persistent Cloud upgrade action from the navigation", () => {
- // Given
- pathnameValue.current = "/findings";
- vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false");
-
- // When
- render();
-
- // Then
- expect(
- screen.getByRole("button", { name: "Explore Prowler Cloud" })
- .parentElement,
- ).toHaveClass("pt-4");
- });
-});
-
-describe("SidebarNavigationModeToggle", () => {
- beforeEach(() => {
- pushMock.mockClear();
- });
-
- it("navigates to Lighthouse when Chat mode is selected", async () => {
- // Given
- const user = userEvent.setup();
- const onChange = vi.fn();
-
- render(
- ,
- );
-
- // When
- await user.click(screen.getByRole("button", { name: "Chat" }));
-
- // Then
- expect(onChange).toHaveBeenCalledWith(SIDEBAR_NAVIGATION_MODE.CHAT);
- expect(pushMock).toHaveBeenCalledWith("/lighthouse");
- });
-
- it("does not navigate when Home mode is selected", async () => {
- // Given
- const user = userEvent.setup();
- const onChange = vi.fn();
-
- render(
- ,
- );
-
- // When
- await user.click(screen.getByRole("button", { name: "Home" }));
-
- // Then
- expect(onChange).toHaveBeenCalledWith(SIDEBAR_NAVIGATION_MODE.BROWSE);
- expect(pushMock).not.toHaveBeenCalled();
- });
-
- it("opens the Cloud upgrade and shows the cloud tooltip when chat is unavailable", async () => {
- // Given
- const user = userEvent.setup();
- const onChange = vi.fn();
-
- render(
- ,
- );
-
- // When
- const chatButton = screen.getByRole("button", { name: "Chat" });
- await user.hover(chatButton);
-
- // Then
- const tooltip = await screen.findByRole("tooltip");
- expect(tooltip).toHaveTextContent("Available in Prowler Cloud");
-
- // When
- await user.click(chatButton);
-
- // Then
- expect(onChange).not.toHaveBeenCalled();
- expect(pushMock).not.toHaveBeenCalled();
- expectLastCloudUpgrade("lighthouse_ai");
- });
-});
diff --git a/ui/components/layout/sidebar/menu.tsx b/ui/components/layout/sidebar/menu.tsx
deleted file mode 100644
index c64588d384..0000000000
--- a/ui/components/layout/sidebar/menu.tsx
+++ /dev/null
@@ -1,271 +0,0 @@
-"use client";
-
-import { Cloud } from "lucide-react";
-import Link from "next/link";
-import { usePathname } from "next/navigation";
-
-import { LighthouseV2SidebarChat } from "@/app/(prowler)/lighthouse/_components/navigation";
-import { InfoIcon, ProwlerShort } from "@/components/icons";
-import { CollapsibleMenu } from "@/components/layout/sidebar/collapsible-menu";
-import { MenuItem } from "@/components/layout/sidebar/menu-item";
-import { Separator } from "@/components/shadcn";
-import { Button } from "@/components/shadcn/button/button";
-import { ScrollArea } from "@/components/shadcn/scroll-area/scroll-area";
-import {
- Tooltip,
- TooltipContent,
- TooltipTrigger,
-} from "@/components/shadcn/tooltip";
-import { SidebarNavigationModeToggle } from "@/components/sidebar/navigation-mode-toggle";
-import { useAuth } from "@/hooks";
-import { useRuntimeConfig } from "@/hooks/use-runtime-config";
-import { SIDEBAR_NAVIGATION_MODE, useSidebar } from "@/hooks/use-sidebar";
-import { getMenuList } from "@/lib/menu-list";
-import { LAUNCH_SCAN_HREF } from "@/lib/scans-navigation";
-import { isCloud } from "@/lib/shared/env";
-import { cn } from "@/lib/utils";
-import { useCloudUpgradeStore, useScansStore } from "@/store";
-import { GroupProps, type MenuSelectionHandler } from "@/types";
-import { CLOUD_UPGRADE_FEATURE } from "@/types/cloud-upgrade";
-import { RolePermissionAttributes } from "@/types/users";
-
-interface MenuHideRule {
- label: string;
- condition: (permissions: RolePermissionAttributes) => boolean;
-}
-
-const MENU_HIDE_RULES: MenuHideRule[] = [
- {
- label: "Billing",
- condition: (permissions) => permissions?.manage_billing === false,
- },
- {
- label: "Integrations",
- condition: (permissions) => permissions?.manage_integrations === false,
- },
-];
-
-const filterMenus = (menuGroups: GroupProps[], labelsToHide: string[]) => {
- return menuGroups
- .map((group) => ({
- ...group,
- menus: group.menus
- .filter((menu) => !labelsToHide.includes(menu.label))
- .map((menu) => ({
- ...menu,
- submenus: menu.submenus?.filter(
- (submenu) => !labelsToHide.includes(submenu.label),
- ),
- })),
- }))
- .filter((group) => group.menus.length > 0);
-};
-
-interface SidebarMenuProps {
- isOpen: boolean;
- onSelect?: MenuSelectionHandler;
-}
-
-export const Menu = ({ isOpen, onSelect }: SidebarMenuProps) => {
- const pathname = usePathname();
- const { permissions } = useAuth();
- const openLaunchScanModal = useScansStore(
- (state) => state.openLaunchScanModal,
- );
- const isScansPage = pathname.startsWith("/scans");
- const { apiDocsUrl } = useRuntimeConfig();
- const isCloudEnv = isCloud();
- const openCloudUpgrade = useCloudUpgradeStore(
- (state) => state.openCloudUpgrade,
- );
- const navigationMode = useSidebar((state) => state.navigationMode);
- const setNavigationMode = useSidebar((state) => state.setNavigationMode);
-
- const menuList = getMenuList({
- pathname,
- apiDocsUrl,
- });
-
- const labelsToHide = MENU_HIDE_RULES.filter((rule) =>
- rule.condition(permissions),
- ).map((rule) => rule.label);
-
- const filteredMenuList = filterMenus(menuList, labelsToHide);
-
- return (
-
- {/* Launch Scan Button — mt-1 aligns its top with the main content panel
- (navbar 72px + py-4 = 88px), matching the fixed-height logo block. */}
-
-
-
- {isScansPage ? (
-
- ) : (
-
- )}
-
- {!isOpen && Launch Scan}
-
-
-
-
-
- {/* Menu Items */}
-
- {isCloudEnv && navigationMode === SIDEBAR_NAVIGATION_MODE.CHAT ? (
-
- ) : (
-
-
-
- )}
-
-
- {!isCloudEnv && (
-
-
-
-
-
- {!isOpen && (
-
- Explore Prowler Cloud
-
- )}
-
-
- )}
-
- {/* Footer */}
-
- {isOpen ? (
- <>
- {process.env.NEXT_PUBLIC_PROWLER_RELEASE_VERSION}
- {isCloudEnv && (
- <>
-
-
-
-
- Service Status
-
-
- >
- )}
- >
- ) : (
- isCloudEnv && (
-
-
-
-
-
-
- Service Status
-
- )
- )}
-
-
- );
-};
-
-function LaunchScanButtonContent({ isOpen }: { isOpen: boolean }) {
- return (
-
-
- {isOpen && Scan}
-
- );
-}
diff --git a/ui/components/layout/sidebar/sheet-menu.test.tsx b/ui/components/layout/sidebar/sheet-menu.test.tsx
deleted file mode 100644
index c57e7ecb54..0000000000
--- a/ui/components/layout/sidebar/sheet-menu.test.tsx
+++ /dev/null
@@ -1,35 +0,0 @@
-import { render, screen } from "@testing-library/react";
-import userEvent from "@testing-library/user-event";
-import { describe, expect, it, vi } from "vitest";
-
-vi.mock("@/components/icons", () => ({
- ProwlerBrand: () => Prowler,
-}));
-
-vi.mock("@/components/layout/sidebar/menu", () => ({
- Menu: ({ onSelect }: { onSelect?: () => void }) => (
-
- ),
-}));
-
-import { SheetMenu } from "./sheet-menu";
-
-describe("SheetMenu", () => {
- it("should close after selecting a menu action", async () => {
- // Given
- const user = userEvent.setup();
- render();
-
- // When
- await user.click(screen.getByRole("button", { name: "Open menu" }));
- expect(screen.getByRole("dialog", { name: "Sidebar" })).toBeVisible();
- await user.click(screen.getByRole("button", { name: "Alerts" }));
-
- // Then
- expect(
- screen.queryByRole("dialog", { name: "Sidebar" }),
- ).not.toBeInTheDocument();
- });
-});
diff --git a/ui/components/layout/sidebar/sheet-menu.tsx b/ui/components/layout/sidebar/sheet-menu.tsx
deleted file mode 100644
index 842986f8eb..0000000000
--- a/ui/components/layout/sidebar/sheet-menu.tsx
+++ /dev/null
@@ -1,63 +0,0 @@
-"use client";
-
-import { MenuIcon } from "lucide-react";
-import Link from "next/link";
-import { useRef, useState } from "react";
-
-import { ProwlerBrand } from "@/components/icons";
-import { Menu } from "@/components/layout/sidebar/menu";
-import { Button } from "@/components/shadcn/button/button";
-import {
- Sheet,
- SheetContent,
- SheetDescription,
- SheetHeader,
- SheetTitle,
- SheetTrigger,
-} from "@/components/shadcn/sheet";
-
-export function SheetMenu() {
- const [open, setOpen] = useState(false);
- const triggerRef = useRef(null);
-
- const handleSelect = () => {
- setOpen(false);
- return triggerRef.current;
- };
-
- return (
-
-
-
-
-
-
- Sidebar
-
-
-
-
-
-
- );
-}
diff --git a/ui/components/layout/sidebar/sidebar-toggle.tsx b/ui/components/layout/sidebar/sidebar-toggle.tsx
deleted file mode 100644
index 27e1fdab27..0000000000
--- a/ui/components/layout/sidebar/sidebar-toggle.tsx
+++ /dev/null
@@ -1,38 +0,0 @@
-import { ChevronLeft, ChevronRight } from "lucide-react";
-
-import { Button } from "@/components/shadcn/button/button";
-import {
- Tooltip,
- TooltipContent,
- TooltipTrigger,
-} from "@/components/shadcn/tooltip";
-
-interface SidebarToggleProps {
- isOpen: boolean | undefined;
- setIsOpen?: () => void;
-}
-
-export function SidebarToggle({ isOpen, setIsOpen }: SidebarToggleProps) {
- // Closed → chevron right (will open); open/undefined → chevron left (will collapse).
- const isClosed = isOpen === false;
- const Chevron = isClosed ? ChevronRight : ChevronLeft;
-
- return (
-
-
-
-
-
- {isClosed ? "Expand Sidebar" : "Collapse Sidebar"}
-
-
- );
-}
diff --git a/ui/components/layout/sidebar/sidebar.tsx b/ui/components/layout/sidebar/sidebar.tsx
deleted file mode 100644
index f6f788563d..0000000000
--- a/ui/components/layout/sidebar/sidebar.tsx
+++ /dev/null
@@ -1,59 +0,0 @@
-"use client";
-
-import clsx from "clsx";
-import Link from "next/link";
-
-import { ProwlerBrand, ProwlerShort } from "@/components/icons";
-import { useSidebar } from "@/hooks/use-sidebar";
-import { useStore } from "@/hooks/use-store";
-import { cn } from "@/lib/utils";
-
-import { Menu } from "./menu";
-
-export function Sidebar() {
- const sidebar = useStore(useSidebar, (x) => x);
- if (!sidebar) return null;
- const { isOpen, getOpenState, setIsHover, settings } = sidebar;
- return (
-
- );
-}
diff --git a/ui/components/layout/sidebar/submenu-item.test.tsx b/ui/components/layout/sidebar/submenu-item.test.tsx
deleted file mode 100644
index 053a50f3fc..0000000000
--- a/ui/components/layout/sidebar/submenu-item.test.tsx
+++ /dev/null
@@ -1,57 +0,0 @@
-import { render, screen } from "@testing-library/react";
-import userEvent from "@testing-library/user-event";
-import { afterEach, describe, expect, it, vi } from "vitest";
-
-import { useCloudUpgradeStore } from "@/store";
-import { CLOUD_UPGRADE_FEATURE } from "@/types/cloud-upgrade";
-import { SUBMENU_KIND } from "@/types/components";
-
-import { SubmenuItem } from "./submenu-item";
-
-vi.mock("next/navigation", () => ({
- usePathname: () => "/",
-}));
-
-const TestIcon = ({ size = 16 }: { size?: number }) => (
-
-);
-
-describe("SubmenuItem", () => {
- afterEach(() => {
- useCloudUpgradeStore.getState().closeCloudUpgrade();
- });
-
- it("should open the Alerts upgrade modal from a Local Server menu item", async () => {
- // Given
- const user = userEvent.setup();
- const returnFocusElement = document.createElement("button");
- const onSelect = vi.fn(() => returnFocusElement);
- render(
- ,
- );
-
- // When
- const button = screen.getByRole("button", { name: /alerts/i });
- await user.click(button);
-
- // Then
- expect(button).not.toHaveAttribute("aria-disabled");
- expect(screen.getByText("Cloud")).toBeVisible();
- expect(
- screen.queryByRole("link", { name: /alerts/i }),
- ).not.toBeInTheDocument();
- expect(useCloudUpgradeStore.getState().activeFeature).toBe(
- CLOUD_UPGRADE_FEATURE.ALERTS,
- );
- expect(onSelect).toHaveBeenCalledOnce();
- expect(useCloudUpgradeStore.getState().returnFocusElement).toBe(
- returnFocusElement,
- );
- });
-});
diff --git a/ui/components/layout/sidebar/submenu-item.tsx b/ui/components/layout/sidebar/submenu-item.tsx
deleted file mode 100644
index e481de6f5d..0000000000
--- a/ui/components/layout/sidebar/submenu-item.tsx
+++ /dev/null
@@ -1,155 +0,0 @@
-"use client";
-
-import Link from "next/link";
-import { usePathname } from "next/navigation";
-
-import { Badge } from "@/components/shadcn/badge/badge";
-import { Button } from "@/components/shadcn/button/button";
-import {
- Tooltip,
- TooltipContent,
- TooltipTrigger,
-} from "@/components/shadcn/tooltip";
-import { useCloudUpgradeStore } from "@/store";
-import {
- type MenuSelectionHandler,
- SUBMENU_KIND,
- type SubmenuProps,
-} from "@/types";
-
-type SubmenuItemProps = SubmenuProps & {
- onSelect?: MenuSelectionHandler;
-};
-
-export const SubmenuItem = (props: SubmenuItemProps) => {
- const pathname = usePathname();
- const openCloudUpgrade = useCloudUpgradeStore(
- (state) => state.openCloudUpgrade,
- );
-
- if (props.kind === SUBMENU_KIND.CLOUD_UPGRADE) {
- const { cloudUpgradeFeature, icon: Icon, label, onSelect } = props;
-
- return (
-
- );
- }
-
- const {
- active,
- cloudOnly,
- disabled,
- highlight,
- href,
- icon: Icon,
- label,
- onSelect,
- target,
- } = props;
- const isActive = active !== undefined ? active : pathname === href;
-
- // Special case: Mutelist with tooltip when disabled
- if (disabled && label === "Mutelist") {
- return (
-
-
-
-
-
- The mutelist will be enabled after adding a provider
-
-
- );
- }
-
- if (disabled) {
- const tooltip = cloudOnly
- ? "Available in Prowler Cloud"
- : `${label} is unavailable.`;
-
- return (
-
-
-
-
-
-
- {tooltip}
-
- );
- }
-
- return (
-
- );
-};
diff --git a/ui/components/shadcn/button/button.tsx b/ui/components/shadcn/button/button.tsx
index 44c7788cb4..b74997a43b 100644
--- a/ui/components/shadcn/button/button.tsx
+++ b/ui/components/shadcn/button/button.tsx
@@ -25,12 +25,6 @@ const buttonVariants = cva(
// Chrome-free: no border/background, only the icon shows. Hover/active shift
// the icon color instead of painting a box. Pair with an `icon*` size.
bare: "border-0 bg-transparent p-0 text-text-neutral-secondary hover:text-text-neutral-primary active:text-text-neutral-primary focus-visible:ring-border-neutral-secondary/50 disabled:bg-transparent",
- // Menu variant like secondary but more padding and the back is almost transparent
- menu: "backdrop-blur-xl bg-white/60 dark:bg-white/5 border border-white/80 dark:border-white/10 text-text-neutral-primary dark:text-white shadow-lg hover:bg-white/70 dark:hover:bg-white/10 hover:border-white/90 dark:hover:border-white/30 active:bg-white/80 dark:active:bg-white/15 active:scale-[0.98] focus-visible:ring-button-primary/50 transition-all duration-200",
- "menu-active":
- "backdrop-blur-xl bg-white/50 dark:bg-white/5 border border-black/[0.08] dark:border-white/10 text-text-neutral-primary dark:text-white shadow-sm hover:bg-white/60 dark:hover:bg-white/10 hover:border-black/[0.12] dark:hover:border-white/30 active:bg-white/70 dark:active:bg-white/15 active:scale-[0.98] focus-visible:ring-button-primary/50 transition-all duration-200",
- "menu-inactive":
- "text-text-neutral-primary border border-transparent hover:backdrop-blur-xl hover:bg-white/40 dark:hover:bg-white/5 hover:border-black/[0.08] dark:hover:border-white/10 hover:shadow-sm active:bg-white/50 dark:active:bg-white/15 active:scale-[0.98] focus-visible:ring-border-neutral-secondary/50 transition-all duration-200",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
diff --git a/ui/components/shadcn/index.ts b/ui/components/shadcn/index.ts
index 7cafb2490d..a36273fe55 100644
--- a/ui/components/shadcn/index.ts
+++ b/ui/components/shadcn/index.ts
@@ -21,6 +21,7 @@ export * from "./headers";
export * from "./info-field";
export * from "./input/input";
export * from "./label";
+export * from "./navigation-button";
export * from "./navigation-progress";
export * from "./progress";
export * from "./search-input/search-input";
diff --git a/ui/components/shadcn/navigation-button/index.ts b/ui/components/shadcn/navigation-button/index.ts
new file mode 100644
index 0000000000..5b57be94a2
--- /dev/null
+++ b/ui/components/shadcn/navigation-button/index.ts
@@ -0,0 +1 @@
+export * from "./navigation-button";
diff --git a/ui/components/shadcn/navigation-button/navigation-button.test.tsx b/ui/components/shadcn/navigation-button/navigation-button.test.tsx
new file mode 100644
index 0000000000..7951fcbcc8
--- /dev/null
+++ b/ui/components/shadcn/navigation-button/navigation-button.test.tsx
@@ -0,0 +1,31 @@
+import { render, screen } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+
+import { NavigationButton } from "./navigation-button";
+
+describe("NavigationButton", () => {
+ it("composes an active navigation link without rendering a nested button", () => {
+ // Given / When
+ render(
+
+ Findings
+ ,
+ );
+
+ // Then
+ const link = screen.getByRole("link", { name: "Findings" });
+ expect(link).toHaveAttribute("data-slot", "navigation-button");
+ expect(link).toHaveAttribute("data-active", "true");
+ expect(screen.queryByRole("button")).not.toBeInTheDocument();
+ });
+
+ it("defaults native navigation controls to non-submitting buttons", () => {
+ // Given / When
+ render(Chat);
+
+ // Then
+ const button = screen.getByRole("button", { name: "Chat" });
+ expect(button).toHaveAttribute("type", "button");
+ expect(button).toHaveAttribute("data-active", "false");
+ });
+});
diff --git a/ui/components/shadcn/navigation-button/navigation-button.tsx b/ui/components/shadcn/navigation-button/navigation-button.tsx
new file mode 100644
index 0000000000..5c72605438
--- /dev/null
+++ b/ui/components/shadcn/navigation-button/navigation-button.tsx
@@ -0,0 +1,115 @@
+import { Slot } from "@radix-ui/react-slot";
+import { cva, type VariantProps } from "class-variance-authority";
+import type { ComponentProps } from "react";
+
+import { cn } from "@/lib/utils";
+
+const navigationButtonVariants = cva(
+ "focus-visible:ring-button-primary/50 flex min-w-0 items-center focus-visible:ring-2 focus-visible:outline-none",
+ {
+ variants: {
+ variant: {
+ item: "relative min-h-10 w-full justify-start gap-3 rounded-lg border px-3 py-2 text-left text-sm font-medium transition-all duration-200",
+ subitem:
+ "min-h-8 w-full justify-start gap-2 rounded-md px-3 py-1.5 text-left text-sm transition-colors",
+ toggle:
+ "h-8 flex-1 justify-center gap-1.5 rounded-lg border px-2 text-sm transition-all",
+ },
+ active: {
+ true: "",
+ false: "",
+ },
+ disabledState: {
+ true: "pointer-events-none text-text-neutral-tertiary",
+ false: "",
+ },
+ },
+ compoundVariants: [
+ {
+ variant: "item",
+ active: true,
+ disabledState: false,
+ class:
+ "border-border-sidebar-active bg-bg-sidebar-active text-text-neutral-primary shadow-sidebar-active",
+ },
+ {
+ variant: "item",
+ active: false,
+ disabledState: false,
+ class:
+ "text-text-neutral-secondary hover:border-border-sidebar-hover hover:bg-bg-sidebar-hover hover:text-text-neutral-primary border-transparent",
+ },
+ {
+ variant: "subitem",
+ active: true,
+ disabledState: false,
+ class:
+ "bg-bg-sidebar-subitem-active text-text-neutral-primary font-medium",
+ },
+ {
+ variant: "subitem",
+ active: false,
+ disabledState: false,
+ class:
+ "text-text-neutral-secondary hover:bg-bg-sidebar-hover hover:text-text-neutral-primary",
+ },
+ {
+ variant: "toggle",
+ active: true,
+ disabledState: false,
+ class:
+ "border-border-sidebar-active bg-bg-sidebar-active text-text-neutral-primary shadow-sidebar-active",
+ },
+ {
+ variant: "toggle",
+ active: false,
+ disabledState: false,
+ class:
+ "text-text-neutral-secondary hover:bg-bg-sidebar-hover hover:text-text-neutral-primary border-transparent",
+ },
+ ],
+ defaultVariants: {
+ variant: "item",
+ active: false,
+ disabledState: false,
+ },
+ },
+);
+
+interface NavigationButtonProps
+ extends ComponentProps<"button">,
+ VariantProps {
+ asChild?: boolean;
+}
+
+function NavigationButton({
+ active = false,
+ asChild = false,
+ className,
+ disabledState = false,
+ type,
+ variant,
+ ...props
+}: NavigationButtonProps) {
+ const Comp = asChild ? Slot : "button";
+
+ return (
+
+ );
+}
+
+export { NavigationButton };
diff --git a/ui/components/shadcn/sheet/sheet.test.tsx b/ui/components/shadcn/sheet/sheet.test.tsx
new file mode 100644
index 0000000000..a472d08fd1
--- /dev/null
+++ b/ui/components/shadcn/sheet/sheet.test.tsx
@@ -0,0 +1,31 @@
+import { render, screen } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+
+import {
+ Sheet,
+ SheetContent,
+ SheetDescription,
+ SheetHeader,
+ SheetTitle,
+} from "./sheet";
+
+describe("SheetContent", () => {
+ it("exposes the navigation drawer variant as a design-system contract", () => {
+ // Given / When
+ render(
+
+
+
+ App navigation
+ Primary destinations
+
+
+ ,
+ );
+
+ // Then
+ expect(
+ screen.getByRole("dialog", { name: "App navigation" }),
+ ).toHaveAttribute("data-variant", "navigation");
+ });
+});
diff --git a/ui/components/shadcn/sheet/sheet.tsx b/ui/components/shadcn/sheet/sheet.tsx
index 49ba1e7f92..2d5b79de8c 100644
--- a/ui/components/shadcn/sheet/sheet.tsx
+++ b/ui/components/shadcn/sheet/sheet.tsx
@@ -3,7 +3,7 @@
import * as SheetPrimitive from "@radix-ui/react-dialog";
import { Cross2Icon } from "@radix-ui/react-icons";
import { cva, type VariantProps } from "class-variance-authority";
-import * as React from "react";
+import type { ComponentPropsWithRef, HTMLAttributes } from "react";
import { cn } from "@/lib/utils";
@@ -15,19 +15,22 @@ const SheetClose = SheetPrimitive.Close;
const SheetPortal = SheetPrimitive.Portal;
-const SheetOverlay = React.forwardRef<
- React.ElementRef,
- React.ComponentPropsWithoutRef
->(({ className, ...props }, ref) => (
-
-));
+function SheetOverlay({
+ className,
+ ref,
+ ...props
+}: ComponentPropsWithRef) {
+ return (
+
+ );
+}
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName;
const sheetVariants = cva(
@@ -42,42 +45,60 @@ const sheetVariants = cva(
right:
"inset-y-0 right-0 h-full w-3/4 rounded-l-xl data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right",
},
+ variant: {
+ default: "",
+ navigation:
+ "bg-bg-neutral-primary w-[264px] max-w-[264px] gap-0 rounded-none p-0",
+ },
},
defaultVariants: {
side: "right",
+ variant: "default",
},
},
);
interface SheetContentProps
- extends React.ComponentPropsWithoutRef,
- VariantProps {}
+ extends ComponentPropsWithRef,
+ VariantProps {
+ showCloseButton?: boolean;
+}
-const SheetContent = React.forwardRef<
- React.ElementRef,
- SheetContentProps
->(({ side = "right", className, children, ...props }, ref) => (
-
-
-
-
-
- Close
-
- {children}
-
-
-));
+function SheetContent({
+ children,
+ className,
+ ref,
+ showCloseButton = true,
+ side = "right",
+ variant = "default",
+ ...props
+}: SheetContentProps) {
+ return (
+
+
+
+ {showCloseButton && (
+
+
+ Close
+
+ )}
+ {children}
+
+
+ );
+}
SheetContent.displayName = SheetPrimitive.Content.displayName;
const SheetHeader = ({
className,
...props
-}: React.HTMLAttributes) => (
+}: HTMLAttributes) => (
) => (
+}: HTMLAttributes
) => (
,
- React.ComponentPropsWithoutRef
->(({ className, ...props }, ref) => (
-
-));
+function SheetTitle({
+ className,
+ ref,
+ ...props
+}: ComponentPropsWithRef) {
+ return (
+
+ );
+}
SheetTitle.displayName = SheetPrimitive.Title.displayName;
-const SheetDescription = React.forwardRef<
- React.ElementRef,
- React.ComponentPropsWithoutRef
->(({ className, ...props }, ref) => (
-
-));
+function SheetDescription({
+ className,
+ ref,
+ ...props
+}: ComponentPropsWithRef) {
+ return (
+
+ );
+}
SheetDescription.displayName = SheetPrimitive.Description.displayName;
export {
diff --git a/ui/components/sidebar/navigation-mode-sync.test.tsx b/ui/components/sidebar/navigation-mode-sync.test.tsx
deleted file mode 100644
index 935c20762a..0000000000
--- a/ui/components/sidebar/navigation-mode-sync.test.tsx
+++ /dev/null
@@ -1,24 +0,0 @@
-import { render } from "@testing-library/react";
-import { beforeEach, describe, expect, it } from "vitest";
-
-import { SIDEBAR_NAVIGATION_MODE, useSidebar } from "@/hooks/use-sidebar";
-
-import { SidebarNavigationModeSync } from "./navigation-mode-sync";
-
-describe("SidebarNavigationModeSync", () => {
- beforeEach(() => {
- useSidebar.setState({
- navigationMode: SIDEBAR_NAVIGATION_MODE.CHAT,
- });
- });
-
- it("restores app navigation mode when the overview mounts", () => {
- // Given / When
- render();
-
- // Then
- expect(useSidebar.getState().navigationMode).toBe(
- SIDEBAR_NAVIGATION_MODE.BROWSE,
- );
- });
-});
diff --git a/ui/components/sidebar/navigation-mode-sync.tsx b/ui/components/sidebar/navigation-mode-sync.tsx
deleted file mode 100644
index d79a596936..0000000000
--- a/ui/components/sidebar/navigation-mode-sync.tsx
+++ /dev/null
@@ -1,20 +0,0 @@
-"use client";
-
-import { useMountEffect } from "@/hooks/use-mount-effect";
-import { type SidebarNavigationMode, useSidebar } from "@/hooks/use-sidebar";
-
-interface SidebarNavigationModeSyncProps {
- mode: SidebarNavigationMode;
-}
-
-export function SidebarNavigationModeSync({
- mode,
-}: SidebarNavigationModeSyncProps) {
- const setNavigationMode = useSidebar((state) => state.setNavigationMode);
-
- useMountEffect(() => {
- setNavigationMode(mode);
- });
-
- return null;
-}
diff --git a/ui/components/sidebar/navigation-mode-toggle.tsx b/ui/components/sidebar/navigation-mode-toggle.tsx
deleted file mode 100644
index 73954e48ab..0000000000
--- a/ui/components/sidebar/navigation-mode-toggle.tsx
+++ /dev/null
@@ -1,139 +0,0 @@
-"use client";
-
-import { Home } from "lucide-react";
-import { useRouter } from "next/navigation";
-
-import { LighthouseIcon } from "@/components/icons/Icons";
-import { Badge } from "@/components/shadcn/badge/badge";
-import {
- Tooltip,
- TooltipContent,
- TooltipTrigger,
-} from "@/components/shadcn/tooltip";
-import {
- SIDEBAR_NAVIGATION_MODE,
- type SidebarNavigationMode,
-} from "@/hooks/use-sidebar";
-import { cn } from "@/lib/utils";
-import { useCloudUpgradeStore } from "@/store";
-import { CLOUD_UPGRADE_FEATURE } from "@/types/cloud-upgrade";
-import type { MenuSelectionHandler } from "@/types/components";
-
-export function SidebarNavigationModeToggle({
- isOpen,
- value,
- onChange,
- chatEnabled = true,
- onSelect,
-}: {
- isOpen: boolean;
- value: SidebarNavigationMode;
- onChange: (value: SidebarNavigationMode) => void;
- chatEnabled?: boolean;
- onSelect?: MenuSelectionHandler;
-}) {
- const router = useRouter();
- const openCloudUpgrade = useCloudUpgradeStore(
- (state) => state.openCloudUpgrade,
- );
- const modes = [
- {
- value: SIDEBAR_NAVIGATION_MODE.BROWSE,
- label: "Home",
- icon: Home,
- },
- {
- value: SIDEBAR_NAVIGATION_MODE.CHAT,
- label: "Chat",
- icon: LighthouseIcon,
- },
- ] as const;
-
- const handleModeChange = (mode: SidebarNavigationMode, disabled: boolean) => {
- if (disabled) {
- openCloudUpgrade(
- CLOUD_UPGRADE_FEATURE.LIGHTHOUSE_AI,
- onSelect?.() ?? undefined,
- );
- return;
- }
- onChange(mode);
- onSelect?.();
- if (mode === SIDEBAR_NAVIGATION_MODE.CHAT) {
- router.push("/lighthouse");
- }
- };
-
- return (
-
-
- {modes.map((mode) => {
- const Icon = mode.icon;
- const active = value === mode.value;
- const disabled =
- mode.value === SIDEBAR_NAVIGATION_MODE.CHAT && !chatEnabled;
- const button = (
-
- );
-
- const tooltipContent = disabled
- ? "Available in Prowler Cloud"
- : !isOpen
- ? mode.label
- : null;
-
- if (!tooltipContent) {
- return button;
- }
-
- return (
-
- {button}
- {tooltipContent}
-
- );
- })}
-
-
- );
-}
diff --git a/ui/components/ui/index.ts b/ui/components/ui/index.ts
index f6736b7281..6d4e9aa30f 100644
--- a/ui/components/ui/index.ts
+++ b/ui/components/ui/index.ts
@@ -6,7 +6,6 @@ export * from "./dialog/dialog";
export * from "./dropdown/Dropdown";
export * from "./select";
export * from "@/components/layout/main-layout";
-export * from "@/components/layout/sidebar";
export * from "@/components/shadcn/accordion";
export * from "@/components/shadcn/action-card";
export * from "@/components/shadcn/alert-dialog";
diff --git a/ui/components/ui/sidebar/index.ts b/ui/components/ui/sidebar/index.ts
deleted file mode 100644
index 6e8ca7b249..0000000000
--- a/ui/components/ui/sidebar/index.ts
+++ /dev/null
@@ -1,3 +0,0 @@
-// Temporary re-export shim for prowler-cloud overlay imports.
-// Remove after the cloud repo migrates to @/components/shadcn paths.
-export * from "@/components/layout/sidebar";
diff --git a/ui/hooks/index.ts b/ui/hooks/index.ts
index 8231eef4d5..c1c150cf27 100644
--- a/ui/hooks/index.ts
+++ b/ui/hooks/index.ts
@@ -5,6 +5,5 @@ export * from "./use-local-storage";
export * from "./use-mount-effect";
export * from "./use-related-filters";
export * from "./use-scroll-hint";
-export * from "./use-sidebar";
export * from "./use-store";
export * from "./use-url-filters";
diff --git a/ui/hooks/use-sidebar.ts b/ui/hooks/use-sidebar.ts
deleted file mode 100644
index a7bef587c9..0000000000
--- a/ui/hooks/use-sidebar.ts
+++ /dev/null
@@ -1,54 +0,0 @@
-import { create } from "zustand";
-import { createJSONStorage, persist } from "zustand/middleware";
-
-export const SIDEBAR_NAVIGATION_MODE = {
- BROWSE: "browse",
- CHAT: "chat",
-} as const;
-
-export type SidebarNavigationMode =
- (typeof SIDEBAR_NAVIGATION_MODE)[keyof typeof SIDEBAR_NAVIGATION_MODE];
-
-type SidebarSettings = { disabled: boolean; isHoverOpen: boolean };
-type SidebarStore = {
- isOpen: boolean;
- isHover: boolean;
- navigationMode: SidebarNavigationMode;
- settings: SidebarSettings;
- toggleOpen: () => void;
- setIsOpen: (isOpen: boolean) => void;
- setIsHover: (isHover: boolean) => void;
- setNavigationMode: (navigationMode: SidebarNavigationMode) => void;
- getOpenState: () => boolean;
-};
-
-export const useSidebar = create(
- persist(
- (set, get) => ({
- isOpen: true,
- isHover: false,
- navigationMode: SIDEBAR_NAVIGATION_MODE.BROWSE,
- settings: { disabled: false, isHoverOpen: false },
- toggleOpen: () => {
- set({ isOpen: !get().isOpen });
- },
- setIsOpen: (isOpen: boolean) => {
- set({ isOpen });
- },
- setIsHover: (isHover: boolean) => {
- set({ isHover });
- },
- setNavigationMode: (navigationMode: SidebarNavigationMode) => {
- set({ navigationMode });
- },
- getOpenState: () => {
- const state = get();
- return state.isOpen || (state.settings.isHoverOpen && state.isHover);
- },
- }),
- {
- name: "sidebar",
- storage: createJSONStorage(() => localStorage),
- },
- ),
-);
diff --git a/ui/lib/index.ts b/ui/lib/index.ts
index 6860785995..1714e64618 100644
--- a/ui/lib/index.ts
+++ b/ui/lib/index.ts
@@ -4,7 +4,6 @@ export * from "./findings-filters";
export * from "./findings-sort";
export * from "./helper";
export * from "./helper-filters";
-export * from "./menu-list";
export * from "./mute-rules";
export * from "./permissions";
export * from "./provider-helpers";
diff --git a/ui/lib/menu-list.test.ts b/ui/lib/menu-list.test.ts
deleted file mode 100644
index 201799b6f5..0000000000
--- a/ui/lib/menu-list.test.ts
+++ /dev/null
@@ -1,210 +0,0 @@
-import { afterEach, describe, expect, it } from "vitest";
-
-import { CLOUD_UPGRADE_FEATURE } from "@/types/cloud-upgrade";
-import { SUBMENU_KIND } from "@/types/components";
-
-import { getMenuList } from "./menu-list";
-
-const findMenu = (label: string) =>
- getMenuList({ pathname: "/alerts" })
- .flatMap((group) => group.menus)
- .find((menu) => menu.label === label);
-
-const findSubmenu = (label: string) =>
- getMenuList({ pathname: "/alerts" })
- .flatMap((group) => group.menus)
- .flatMap((menu) => menu.submenus ?? [])
- .find((submenu) => submenu.label === label);
-
-const findApiReference = (options: Parameters[0]) =>
- getMenuList(options)
- .flatMap((group) => group.menus)
- .flatMap((menu) => menu.submenus ?? [])
- .find((submenu) => submenu.label === "API reference");
-
-const getTopLevelLabels = () =>
- getMenuList({ pathname: "/", apiDocsUrl: null }).flatMap((group) =>
- group.menus.map((menu) => menu.label),
- );
-
-const getConfigurationLabels = () =>
- getMenuList({ pathname: "/lighthouse/settings", apiDocsUrl: null })
- .flatMap((group) => group.menus)
- .find((menu) => menu.label === "Configuration")
- ?.submenus?.map((submenu) => submenu.label) ?? [];
-
-const getConfigurationSubmenu = (label: string) =>
- getMenuList({ pathname: "/lighthouse/settings", apiDocsUrl: null })
- .flatMap((group) => group.menus)
- .flatMap((menu) => menu.submenus ?? [])
- .find((submenu) => submenu.label === label);
-
-describe("getMenuList", () => {
- afterEach(() => {
- delete process.env.NEXT_PUBLIC_IS_CLOUD_ENV;
- });
-
- describe("API reference link", () => {
- it("should use the apiDocsUrl provided by the caller in OSS", () => {
- // Given / When — the caller resolves the runtime value (hydration-safe)
- const apiRef = findApiReference({
- pathname: "/",
- apiDocsUrl: "https://self-hosted.example/api/v1/docs",
- });
-
- // Then
- expect(apiRef?.href).toBe("https://self-hosted.example/api/v1/docs");
- });
-
- it("should default to an empty href when no apiDocsUrl is provided", () => {
- // Given / When — no island read here, so SSR and client agree
- const apiRef = findApiReference({ pathname: "/" });
-
- // Then
- expect(apiRef?.href).toBe("");
- });
-
- it("should use the Cloud docs URL and ignore apiDocsUrl when Cloud is enabled", () => {
- // Given
- process.env.NEXT_PUBLIC_IS_CLOUD_ENV = "true";
-
- // When
- const apiRef = findApiReference({
- pathname: "/",
- apiDocsUrl: "https://ignored.example/docs",
- });
-
- // Then
- expect(apiRef?.href).toBe("https://api.prowler.com/api/v1/docs");
- });
- });
-
- it("should show Alerts as a contextual Cloud upgrade in Local Server", () => {
- // Given / When
- const alerts = findSubmenu("Alerts");
-
- // Then
- expect(alerts).toEqual(
- expect.objectContaining({
- kind: SUBMENU_KIND.CLOUD_UPGRADE,
- cloudUpgradeFeature: CLOUD_UPGRADE_FEATURE.ALERTS,
- }),
- );
- expect(alerts).not.toHaveProperty("href");
- });
-
- it("should show Alerts as new under Configuration when Cloud is enabled", () => {
- // Given
- process.env.NEXT_PUBLIC_IS_CLOUD_ENV = "true";
-
- // When
- const alerts = findSubmenu("Alerts");
-
- // Then
- expect(alerts).toEqual(
- expect.objectContaining({
- href: "/alerts",
- active: true,
- highlight: true,
- }),
- );
- });
-
- it("should show Scan as a contextual Cloud upgrade in Local Server", () => {
- // Given / When
- const scanConfig = findSubmenu("Scan");
-
- // Then
- expect(scanConfig).toEqual(
- expect.objectContaining({
- kind: SUBMENU_KIND.CLOUD_UPGRADE,
- cloudUpgradeFeature: CLOUD_UPGRADE_FEATURE.SCAN_CONFIGURATION,
- }),
- );
- expect(scanConfig).not.toHaveProperty("href");
- });
-
- it("should expose CLI Import as a contextual Cloud upgrade in Local Server", () => {
- // Given / When
- const cliImport = findSubmenu("CLI Import");
-
- // Then
- expect(cliImport).toEqual(
- expect.objectContaining({
- kind: SUBMENU_KIND.CLOUD_UPGRADE,
- cloudUpgradeFeature: CLOUD_UPGRADE_FEATURE.CLI_IMPORT,
- }),
- );
- expect(cliImport).not.toHaveProperty("href");
- });
-
- it("should omit CLI Import from the Cloud menu", () => {
- // Given
- process.env.NEXT_PUBLIC_IS_CLOUD_ENV = "true";
-
- // When
- const cliImport = findSubmenu("CLI Import");
-
- // Then
- expect(cliImport).toBeUndefined();
- });
-
- it("should show Scan as new under Configuration when Cloud is enabled", () => {
- // Given
- process.env.NEXT_PUBLIC_IS_CLOUD_ENV = "true";
-
- // When
- const menus = getMenuList({ pathname: "/scans/config" }).flatMap(
- (group) => group.menus,
- );
- const scanConfig = menus
- .flatMap((menu) => menu.submenus ?? [])
- .find((submenu) => submenu.label === "Scan");
- const scans = menus.find((menu) => menu.label === "Scans");
-
- // Then
- expect(scanConfig).toEqual(
- expect.objectContaining({
- href: "/scans/config",
- active: true,
- highlight: true,
- }),
- );
- // The top-level Scans item uses an exact-match active rule, so it must stay
- // inactive on the `/scans/config` sub-route.
- expect(scans).toEqual(expect.objectContaining({ active: false }));
- });
-
- it("should remove the new highlight from Attack Paths", () => {
- // Given / When
- const attackPaths = findMenu("Attack Paths");
-
- // Then
- expect(attackPaths).toEqual(
- expect.not.objectContaining({ highlight: true }),
- );
- });
-
- it("should keep Lighthouse as a browse item in OSS", () => {
- // Given / When
- const labels = getTopLevelLabels();
-
- // Then
- expect(labels).toContain("Lighthouse AI");
- });
-
- it("should move Lighthouse out of the Cloud browse menu but keep its configuration entry", () => {
- // Given
- process.env.NEXT_PUBLIC_IS_CLOUD_ENV = "true";
-
- // When
- const labels = getTopLevelLabels();
- const configLabels = getConfigurationLabels();
- const lighthouseSettings = getConfigurationSubmenu("Lighthouse AI");
-
- // Then
- expect(labels).not.toContain("Lighthouse AI");
- expect(configLabels).toContain("Lighthouse AI");
- expect(lighthouseSettings?.href).toBe("/lighthouse/settings");
- });
-});
diff --git a/ui/lib/menu-list.ts b/ui/lib/menu-list.ts
deleted file mode 100644
index 58de6e61c2..0000000000
--- a/ui/lib/menu-list.ts
+++ /dev/null
@@ -1,294 +0,0 @@
-import {
- BellRing,
- CloudCog,
- Cog,
- GitBranch,
- Mail,
- MessageCircleQuestion,
- Puzzle,
- Settings,
- ShieldCheck,
- SlidersHorizontal,
- SquareChartGantt,
- Tag,
- Timer,
- Upload,
- User,
- UserCog,
- Users,
- VolumeX,
- Warehouse,
-} from "lucide-react";
-
-import { ProwlerShort } from "@/components/icons";
-import {
- APIdocIcon,
- DocIcon,
- GithubIcon,
- LighthouseIcon,
- SupportIcon,
-} from "@/components/icons/Icons";
-import { isCloud } from "@/lib/shared/env";
-import {
- type CloudUpgradeFeature,
- type GroupProps,
- type IconComponent,
- SUBMENU_KIND,
- type SubmenuProps,
-} from "@/types";
-import { CLOUD_UPGRADE_FEATURE } from "@/types/cloud-upgrade";
-
-interface MenuListOptions {
- pathname: string;
- // Passed in (not read here) so the island isn't read during SSR — that would
- // cause a hydration mismatch. See useRuntimeConfig.
- apiDocsUrl?: string | null;
-}
-
-interface CloudFeatureSubmenuOptions {
- isCloudEnvironment: boolean;
- href: string;
- label: string;
- icon: IconComponent;
- active: boolean;
- feature: CloudUpgradeFeature;
-}
-
-const getCloudFeatureSubmenu = ({
- isCloudEnvironment,
- href,
- label,
- icon,
- active,
- feature,
-}: CloudFeatureSubmenuOptions): SubmenuProps => {
- if (!isCloudEnvironment) {
- return {
- kind: SUBMENU_KIND.CLOUD_UPGRADE,
- label,
- icon,
- cloudUpgradeFeature: feature,
- };
- }
-
- return {
- kind: SUBMENU_KIND.LINK,
- href,
- label,
- icon,
- active,
- highlight: true,
- };
-};
-
-export const getMenuList = ({
- pathname,
- apiDocsUrl = null,
-}: MenuListOptions): GroupProps[] => {
- const isCloudEnv = isCloud();
-
- return [
- {
- groupLabel: "",
- menus: [
- {
- href: "/",
- label: "Overview",
- icon: SquareChartGantt,
- active: pathname === "/",
- },
- ],
- },
- {
- groupLabel: "",
- menus: [
- {
- href: "/compliance",
- label: "Compliance",
- icon: ShieldCheck,
- active: pathname === "/compliance",
- },
- ],
- },
- ...(isCloudEnv
- ? []
- : [
- {
- groupLabel: "",
- menus: [
- {
- href: "/lighthouse",
- label: "Lighthouse AI",
- icon: LighthouseIcon,
- active: pathname === "/lighthouse",
- },
- ],
- },
- ]),
- {
- groupLabel: "",
- menus: [
- {
- href: "/attack-paths",
- label: "Attack Paths",
- icon: GitBranch,
- active: pathname.startsWith("/attack-paths"),
- },
- ],
- },
-
- {
- groupLabel: "",
- menus: [
- {
- href: "/findings?filter[muted]=false&filter[status__in]=FAIL",
- label: "Findings",
- icon: Tag,
- },
- ],
- },
- {
- groupLabel: "",
- menus: [
- {
- href: "/scans",
- label: "Scans",
- icon: Timer,
- // Exact match so it isn't also marked active on the `/scans/config`
- // sub-route (mirrors the top-level Lighthouse entry).
- active: pathname === "/scans",
- },
- ],
- },
- {
- groupLabel: "",
- menus: [
- {
- href: "/resources",
- label: "Resources",
- icon: Warehouse,
- },
- ],
- },
- {
- groupLabel: "",
- menus: [
- {
- href: "",
- label: "Configuration",
- icon: Settings,
- submenus: [
- { href: "/providers", label: "Providers", icon: CloudCog },
- getCloudFeatureSubmenu({
- isCloudEnvironment: isCloudEnv,
- href: "/alerts",
- label: "Alerts",
- icon: BellRing,
- active: pathname.startsWith("/alerts"),
- feature: CLOUD_UPGRADE_FEATURE.ALERTS,
- }),
- {
- href: "/mutelist",
- label: "Mutelist",
- icon: VolumeX,
- active: pathname === "/mutelist",
- },
- getCloudFeatureSubmenu({
- isCloudEnvironment: isCloudEnv,
- href: "/scans/config",
- label: "Scan",
- icon: SlidersHorizontal,
- active: pathname.startsWith("/scans/config"),
- feature: CLOUD_UPGRADE_FEATURE.SCAN_CONFIGURATION,
- }),
- ...(!isCloudEnv
- ? [
- {
- kind: SUBMENU_KIND.CLOUD_UPGRADE,
- label: "CLI Import",
- icon: Upload,
- cloudUpgradeFeature: CLOUD_UPGRADE_FEATURE.CLI_IMPORT,
- },
- ]
- : []),
- { href: "/integrations", label: "Integrations", icon: Puzzle },
- { href: "/lighthouse/settings", label: "Lighthouse AI", icon: Cog },
- ],
- defaultOpen: true,
- },
- ],
- },
- {
- groupLabel: "",
- menus: [
- {
- href: "",
- label: "Organization",
- icon: Users,
- submenus: [
- { href: "/users", label: "Users", icon: User },
- { href: "/invitations", label: "Invitations", icon: Mail },
- { href: "/roles", label: "Roles", icon: UserCog },
- ],
- defaultOpen: false,
- },
- ],
- },
- {
- groupLabel: "",
- menus: [
- {
- href: "",
- label: "Support & Help",
- icon: SupportIcon,
- submenus: [
- {
- href: "https://docs.prowler.com/",
- target: "_blank",
- label: "Documentation",
- icon: DocIcon,
- },
- {
- href: isCloudEnv
- ? "https://api.prowler.com/api/v1/docs"
- : (apiDocsUrl ?? ""),
- target: "_blank",
- label: "API reference",
- icon: APIdocIcon,
- },
- ...(isCloudEnv
- ? [
- {
- href: "https://customer.support.prowler.com/servicedesk/customer/portal/9/create/102",
- target: "_blank",
- label: "Support Desk",
- icon: MessageCircleQuestion,
- },
- ]
- : [
- {
- href: "https://github.com/prowler-cloud/prowler/issues",
- target: "_blank",
- label: "Community Support",
- icon: GithubIcon,
- },
- ]),
- ],
- defaultOpen: false,
- },
- ],
- },
- {
- groupLabel: "",
- menus: [
- {
- href: "https://hub.prowler.com/",
- label: "Prowler Hub",
- icon: ProwlerShort,
- target: "_blank",
- tooltip: "Looking for all available checks? learn more.",
- },
- ],
- },
- ];
-};
diff --git a/ui/package.json b/ui/package.json
index 7dae1ddb3b..5fe0023d07 100644
--- a/ui/package.json
+++ b/ui/package.json
@@ -23,9 +23,9 @@
"test:browser": "vitest run --project browser",
"test:browser:watch": "vitest --project browser",
"test:coverage": "vitest run --coverage",
- "test:e2e": "playwright test --project=auth --project=sign-up --project=providers --project=invitations --project=scans --project=runtime-config",
- "test:e2e:debug": "playwright test --project=auth --project=sign-up --project=providers --project=invitations --project=scans --project=runtime-config --debug",
- "test:e2e:headed": "playwright test --project=auth --project=sign-up --project=providers --project=invitations --project=scans --project=runtime-config --headed",
+ "test:e2e": "playwright test --project=auth --project=sign-up --project=providers --project=invitations --project=scans --project=runtime-config --project=navigation",
+ "test:e2e:debug": "playwright test --project=auth --project=sign-up --project=providers --project=invitations --project=scans --project=runtime-config --project=navigation --debug",
+ "test:e2e:headed": "playwright test --project=auth --project=sign-up --project=providers --project=invitations --project=scans --project=runtime-config --project=navigation --headed",
"test:e2e:install": "playwright install",
"test:e2e:report": "playwright show-report",
"test:e2e:ui": "playwright test --project=auth --project=sign-up --project=providers --project=invitations --project=scans --project=runtime-config --ui",
diff --git a/ui/playwright.config.ts b/ui/playwright.config.ts
index b9730e0de6..2ae3247997 100644
--- a/ui/playwright.config.ts
+++ b/ui/playwright.config.ts
@@ -145,6 +145,16 @@ export default defineConfig({
testMatch: "invitations.spec.ts",
dependencies: ["admin.auth.setup"],
},
+ // This project validates the responsive application navigation shell
+ {
+ name: "navigation",
+ use: {
+ ...devices["Pixel 5"],
+ viewport: { width: 390, height: 844 },
+ },
+ testMatch: /navigation\/.*\.spec\.ts/,
+ dependencies: ["admin.auth.setup"],
+ },
],
webServer: {
diff --git a/ui/styles/globals.css b/ui/styles/globals.css
index 14112b2313..6934c72933 100644
--- a/ui/styles/globals.css
+++ b/ui/styles/globals.css
@@ -93,6 +93,25 @@
--shadow-progress-glow:
0 0 10px var(--bg-button-primary), 0 0 5px var(--bg-button-primary);
+ /* App sidebar */
+ --bg-sidebar-active: rgba(255, 255, 255, 0.78);
+ --bg-sidebar-hover: rgba(255, 255, 255, 0.55);
+ --bg-sidebar-subitem-active: rgba(16, 185, 129, 0.1);
+ --bg-sidebar-toggle: rgba(255, 255, 255, 0.4);
+ --border-sidebar-active: rgba(15, 23, 42, 0.09);
+ --border-sidebar-hover: rgba(15, 23, 42, 0.06);
+ --border-sidebar-guide: rgba(15, 23, 42, 0.12);
+ --border-sidebar-toggle: rgba(15, 23, 42, 0.08);
+ --sidebar-active-bar: var(--color-emerald-400);
+ --sidebar-active-icon: var(--color-emerald-600);
+ --shadow-sidebar-active: 0 8px 24px rgba(15, 23, 42, 0.06);
+ --gradient-sidebar-halo: radial-gradient(
+ circle,
+ rgba(46, 229, 155, 0.14) 0%,
+ rgba(98, 223, 240, 0.06) 42%,
+ transparent 72%
+ );
+
/* Lighthouse AI */
--gradient-lighthouse: linear-gradient(96deg, #2ee59b 3.55%, #62dff0 98.85%);
@@ -182,6 +201,25 @@
/* Progress Bar */
--shadow-progress-glow:
0 0 10px var(--bg-button-primary), 0 0 5px var(--bg-button-primary);
+
+ /* App sidebar */
+ --bg-sidebar-active: rgba(255, 255, 255, 0.06);
+ --bg-sidebar-hover: rgba(255, 255, 255, 0.045);
+ --bg-sidebar-subitem-active: rgba(46, 229, 155, 0.09);
+ --bg-sidebar-toggle: rgba(255, 255, 255, 0.025);
+ --border-sidebar-active: rgba(255, 255, 255, 0.11);
+ --border-sidebar-hover: rgba(255, 255, 255, 0.07);
+ --border-sidebar-guide: rgba(255, 255, 255, 0.1);
+ --border-sidebar-toggle: rgba(255, 255, 255, 0.08);
+ --sidebar-active-bar: var(--color-emerald-300);
+ --sidebar-active-icon: var(--color-emerald-300);
+ --shadow-sidebar-active: 0 10px 28px rgba(0, 0, 0, 0.22);
+ --gradient-sidebar-halo: radial-gradient(
+ circle,
+ rgba(46, 229, 155, 0.16) 0%,
+ rgba(98, 223, 240, 0.07) 40%,
+ transparent 72%
+ );
}
/* ===== TAILWIND THEME MAPPINGS ===== */
@@ -254,8 +292,21 @@
--color-bg-feature-new: var(--bg-feature-new);
--color-text-feature-new: var(--text-feature-new);
+ /* App sidebar */
+ --color-bg-sidebar-active: var(--bg-sidebar-active);
+ --color-bg-sidebar-hover: var(--bg-sidebar-hover);
+ --color-bg-sidebar-subitem-active: var(--bg-sidebar-subitem-active);
+ --color-bg-sidebar-toggle: var(--bg-sidebar-toggle);
+ --color-border-sidebar-active: var(--border-sidebar-active);
+ --color-border-sidebar-hover: var(--border-sidebar-hover);
+ --color-border-sidebar-guide: var(--border-sidebar-guide);
+ --color-border-sidebar-toggle: var(--border-sidebar-toggle);
+ --color-sidebar-active-bar: var(--sidebar-active-bar);
+ --color-sidebar-active-icon: var(--sidebar-active-icon);
+
/* Shadows */
--shadow-progress-glow: var(--shadow-progress-glow);
+ --shadow-sidebar-active: var(--shadow-sidebar-active);
/* Background images */
--background-image-feature-cloud: var(--gradient-feature-cloud);
@@ -320,6 +371,17 @@
/* ===== COMPONENT LAYER ===== */
@layer components {
+ .app-sidebar-halo {
+ position: absolute;
+ top: -8rem;
+ left: -9rem;
+ width: 26rem;
+ height: 26rem;
+ pointer-events: none;
+ background: var(--gradient-sidebar-halo);
+ filter: blur(28px);
+ }
+
button:not(:disabled),
[role="button"]:not(:disabled) {
cursor: pointer;
diff --git a/ui/tests/navigation/navigation-page.ts b/ui/tests/navigation/navigation-page.ts
new file mode 100644
index 0000000000..44c6655ad9
--- /dev/null
+++ b/ui/tests/navigation/navigation-page.ts
@@ -0,0 +1,62 @@
+import { expect, type Locator, type Page } from "@playwright/test";
+
+import { BasePage } from "../base-page";
+
+export class NavigationPage extends BasePage {
+ readonly appSidebar: Locator;
+ readonly closeMenuButton: Locator;
+ readonly openMenuButton: Locator;
+
+ constructor(page: Page) {
+ super(page);
+ this.appSidebar = page.getByRole("dialog", { name: "App sidebar" });
+ this.closeMenuButton = page.getByRole("button", { name: "Close menu" });
+ this.openMenuButton = page.getByRole("button", { name: "Open menu" });
+ }
+
+ async goto(): Promise {
+ await super.goto("/");
+ }
+
+ async verifyPageLoaded(): Promise {
+ await expect(this.openMenuButton).toBeVisible();
+ }
+
+ async openMobileSidebar(): Promise {
+ await this.openMenuButton.click();
+ await expect(this.appSidebar).toBeVisible();
+ await expect(this.openMenuButton).toBeHidden();
+ await this.appSidebar.evaluate(async (element) => {
+ await Promise.all(
+ element
+ .getAnimations()
+ .map((animation) => animation.finished.catch(() => undefined)),
+ );
+ });
+ }
+
+ async verifyMobileSidebarFitsViewport(): Promise {
+ const viewport = this.page.viewportSize();
+ const sidebarBox = await this.appSidebar.boundingBox();
+ const closeButtonBox = await this.closeMenuButton.boundingBox();
+
+ expect(viewport).not.toBeNull();
+ expect(sidebarBox).not.toBeNull();
+ expect(closeButtonBox).not.toBeNull();
+
+ if (!viewport || !sidebarBox || !closeButtonBox) return;
+
+ for (const box of [sidebarBox, closeButtonBox]) {
+ expect(box.x).toBeGreaterThanOrEqual(0);
+ expect(box.y).toBeGreaterThanOrEqual(0);
+ expect(box.x + box.width).toBeLessThanOrEqual(viewport.width);
+ expect(box.y + box.height).toBeLessThanOrEqual(viewport.height);
+ }
+
+ const bodyWidth = await this.page.locator("body").evaluate((element) => ({
+ clientWidth: element.clientWidth,
+ scrollWidth: element.scrollWidth,
+ }));
+ expect(bodyWidth.scrollWidth).toBeLessThanOrEqual(bodyWidth.clientWidth);
+ }
+}
diff --git a/ui/tests/navigation/navigation.md b/ui/tests/navigation/navigation.md
new file mode 100644
index 0000000000..f888789ded
--- /dev/null
+++ b/ui/tests/navigation/navigation.md
@@ -0,0 +1,36 @@
+### E2E Tests: App Navigation
+
+**Suite ID:** `NAV-E2E`
+**Feature:** Responsive application sidebar.
+
+---
+
+## Test Case: `NAV-E2E-001` - Mobile sidebar fits viewport
+
+**Priority:** `high`
+**Tags:** @e2e, @navigation
+
+**Preconditions:**
+
+- Admin user authentication state exists
+- Chromium mobile viewport is 390 x 844 CSS pixels
+
+### Flow Steps
+
+1. Navigate to Overview
+2. Open mobile application menu
+3. Wait for drawer animation to finish
+4. Measure drawer, close control, viewport, and document width
+
+### Expected Result
+
+- Drawer stays inside viewport
+- Close control stays visible inside viewport
+- Open-menu control is hidden while drawer is open
+- Page has no horizontal overflow
+
+### Key Verification Points
+
+- Drawer uses accessible name "App sidebar"
+- Drawer and close control edges do not exceed viewport edges
+- Body scroll width does not exceed client width
diff --git a/ui/tests/navigation/navigation.spec.ts b/ui/tests/navigation/navigation.spec.ts
new file mode 100644
index 0000000000..69de1d02d8
--- /dev/null
+++ b/ui/tests/navigation/navigation.spec.ts
@@ -0,0 +1,22 @@
+import { test } from "@playwright/test";
+
+import { NavigationPage } from "./navigation-page";
+
+test.describe("App navigation", () => {
+ test.use({ storageState: "playwright/.auth/admin_user.json" });
+
+ test(
+ "keeps the mobile sidebar and close control inside the viewport",
+ {
+ tag: ["@e2e", "@navigation", "@high", "@NAV-E2E-001"],
+ },
+ async ({ page }) => {
+ const navigationPage = new NavigationPage(page);
+
+ await navigationPage.goto();
+ await navigationPage.verifyPageLoaded();
+ await navigationPage.openMobileSidebar();
+ await navigationPage.verifyMobileSidebarFitsViewport();
+ },
+ );
+});
diff --git a/ui/types/components.ts b/ui/types/components.ts
index 5ba0ea12fd..cb18986e24 100644
--- a/ui/types/components.ts
+++ b/ui/types/components.ts
@@ -3,7 +3,6 @@ import { SVGProps } from "react";
import { ProviderCredentialFields } from "@/lib/provider-credentials/provider-credential-fields";
-import type { CloudUpgradeFeature } from "./cloud-upgrade";
import type { FindingTriageSummary } from "./findings-triage";
export type IconSvgProps = SVGProps & {
@@ -17,67 +16,6 @@ export type IconProps = {
export type IconComponent = LucideIcon | React.FC;
-export const SUBMENU_KIND = {
- LINK: "link",
- CLOUD_UPGRADE: "cloud_upgrade",
-} as const;
-
-interface SubmenuBaseProps {
- label: string;
- icon: IconComponent;
-}
-
-export interface SubmenuLinkProps extends SubmenuBaseProps {
- kind?: typeof SUBMENU_KIND.LINK;
- href: string;
- target?: string;
- active?: boolean;
- disabled?: boolean;
- highlight?: boolean;
- cloudOnly?: boolean;
- cloudUpgradeFeature?: never;
-}
-
-export interface SubmenuCloudUpgradeProps extends SubmenuBaseProps {
- kind: typeof SUBMENU_KIND.CLOUD_UPGRADE;
- cloudUpgradeFeature: CloudUpgradeFeature;
- href?: never;
- target?: never;
- active?: never;
- disabled?: never;
- highlight?: never;
- cloudOnly?: never;
-}
-
-export type SubmenuProps = SubmenuLinkProps | SubmenuCloudUpgradeProps;
-
-export type MenuSelectionHandler = () => HTMLElement | null;
-
-export type MenuProps = {
- href: string;
- label: string;
- active?: boolean;
- icon: IconComponent;
- submenus?: SubmenuProps[];
- defaultOpen?: boolean;
- target?: string;
- tooltip?: string;
- highlight?: boolean;
-};
-
-export type GroupProps = {
- groupLabel: string;
- menus: MenuProps[];
-};
-
-export interface CollapseMenuButtonProps {
- icon: IconComponent;
- label: string;
- submenus: SubmenuProps[];
- defaultOpen: boolean;
- isOpen: boolean | undefined;
-}
-
export const NEXT_UI_VARIANTS = {
SOLID: "solid",
FADED: "faded",