feat(ui): redesign app sidebar navigation (#11994)

This commit is contained in:
Alejandro Bailo
2026-07-16 11:40:08 +02:00
committed by GitHub
parent 0dc424031b
commit 1ee71c1f20
61 changed files with 2441 additions and 2128 deletions
+5 -3
View File
@@ -13,10 +13,12 @@ import {
import { LighthouseV2ChatPage } from "@/app/(prowler)/lighthouse/_components/chat";
import { loadLighthouseV2ConnectedModels } from "@/app/(prowler)/lighthouse/_lib/model-loading";
import { LighthouseIcon } from "@/components/icons/Icons";
import {
APP_SIDEBAR_MODE,
AppSidebarModeSync,
} from "@/components/layout/app-sidebar";
import { Chat } from "@/components/lighthouse-v1";
import { ContentLayout } from "@/components/shadcn/content-layout";
import { SidebarNavigationModeSync } from "@/components/sidebar/navigation-mode-sync";
import { SIDEBAR_NAVIGATION_MODE } from "@/hooks/use-sidebar";
import { LIGHTHOUSE_ROUTE } from "@/lib/lighthouse-routes";
import { isCloud } from "@/lib/shared/env";
@@ -78,7 +80,7 @@ export default async function AIChatbot({
return (
<ContentLayout title="Lighthouse AI" icon={<LighthouseIcon />}>
<SidebarNavigationModeSync mode={SIDEBAR_NAVIGATION_MODE.CHAT} />
<AppSidebarModeSync mode={APP_SIDEBAR_MODE.CHAT} />
{/* [contain:layout] traps streamdown's fixed fullscreen overlay inside
the chat area so it never covers the sidebar or navbar. */}
<div className="h-[calc(100dvh-6.5rem)] min-h-0 [contain:layout]">
+5 -3
View File
@@ -5,9 +5,11 @@ import { getAllProviders } from "@/actions/providers";
import { getLighthouseV2Configurations } from "@/app/(prowler)/lighthouse/_actions";
import { ProviderAccountSelectors } from "@/components/filters/provider-account-selectors";
import { ProviderGroupSelector } from "@/components/filters/provider-group-selector";
import {
APP_SIDEBAR_MODE,
AppSidebarModeSync,
} from "@/components/layout/app-sidebar";
import { ContentLayout } from "@/components/shadcn/content-layout";
import { SidebarNavigationModeSync } from "@/components/sidebar/navigation-mode-sync";
import { SIDEBAR_NAVIGATION_MODE } from "@/hooks/use-sidebar";
import { isCloud } from "@/lib/shared/env";
import { SearchParamsProps } from "@/types";
@@ -55,7 +57,7 @@ export default async function Home({
return (
<ContentLayout title="Overview" icon="lucide:square-chart-gantt">
<SidebarNavigationModeSync mode={SIDEBAR_NAVIGATION_MODE.BROWSE} />
<AppSidebarModeSync mode={APP_SIDEBAR_MODE.BROWSE} />
<div className="xxl:grid-cols-4 mb-6 grid grid-cols-1 gap-6 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
<ProviderAccountSelectors providers={providersData?.data ?? []} />
<ProviderGroupSelector groups={providerGroupsData?.data ?? []} />
@@ -0,0 +1 @@
Sidebar navigation with grouped sections, clearer active states, and a responsive mobile overlay
@@ -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: () => <div data-testid="lighthouse-chat-sidebar" />,
}));
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(<AppSidebarContent />);
// 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(<AppSidebarContent />);
// 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(<AppSidebarContent />);
await user.click(screen.getByRole("button", { name: "Launch Scan" }));
// Then
expect(openLaunchScanModalMock).toHaveBeenCalledOnce();
expect(
screen.queryByRole("link", { name: "Launch Scan" }),
).not.toBeInTheDocument();
});
});
@@ -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 (
<div className="relative flex h-full min-h-0 w-full flex-col overflow-hidden">
<div className="shrink-0 px-5 pt-6 pb-7">
<Link
href="/"
aria-label="Prowler home"
className="focus-visible:ring-button-primary/50 flex h-8 items-center rounded-md focus-visible:ring-2 focus-visible:outline-none"
onClick={onSelect}
>
<ProwlerBrand />
</Link>
</div>
<div className="shrink-0 space-y-3 px-3 pb-1">
<LaunchScanAction onSelect={onSelect} />
<AppSidebarModeToggle
chatEnabled={isCloudEnvironment}
onSelect={onSelect}
/>
</div>
<div className="min-h-0 flex-1 overflow-hidden">
{showChat ? (
<LighthouseV2SidebarChat isOpen />
) : (
<SidebarNavigation sections={sections} onSelect={onSelect} />
)}
</div>
<SidebarFooter
isCloudEnvironment={isCloudEnvironment}
onSelect={onSelect}
/>
</div>
);
}
@@ -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);
});
});
@@ -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<AppSidebarModeStore>()(
persist(
(set) => ({
mode: APP_SIDEBAR_MODE.BROWSE,
setMode: (mode) => set({ mode }),
}),
{
name: "sidebar",
storage: createJSONStorage(() => localStorage),
merge: (persistedState, currentState) => ({
...currentState,
...migrateAppSidebarState(persistedState),
}),
},
),
);
@@ -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(<AppSidebarModeSync mode={APP_SIDEBAR_MODE.BROWSE} />);
// Then
expect(useAppSidebarMode.getState().mode).toBe(APP_SIDEBAR_MODE.BROWSE);
});
});
@@ -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;
}
@@ -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 (
<div
role="group"
aria-label="Sidebar view"
className="border-border-sidebar-toggle bg-bg-sidebar-toggle grid grid-cols-2 gap-1 rounded-xl border p-1"
>
{MODES.map((item) => {
const Icon = item.icon;
const isActive = item.value === mode;
const isCloudUpsell =
item.value === APP_SIDEBAR_MODE.CHAT && !chatEnabled;
const button = (
<NavigationButton
key={item.value}
variant="toggle"
active={isActive}
aria-label={item.label}
aria-pressed={isActive}
onClick={() => selectMode(item.value)}
>
<Icon aria-hidden="true" className="size-4 shrink-0" />
<span>{item.label}</span>
{isCloudUpsell && (
<Badge variant="cloud" size="sm">
Cloud
</Badge>
)}
</NavigationButton>
);
if (!isCloudUpsell) return button;
return (
<Tooltip key={item.value} delayDuration={100}>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent side="right">
Available in Prowler Cloud
</TooltipContent>
</Tooltip>
);
})}
</div>
);
}
@@ -0,0 +1,10 @@
import { AppSidebarContent } from "./app-sidebar-content";
export function AppSidebar() {
return (
<aside className="border-border-neutral-secondary bg-bg-neutral-primary fixed inset-y-0 left-0 z-20 hidden w-[264px] overflow-hidden border-r lg:block">
<div aria-hidden="true" className="app-sidebar-halo" />
<AppSidebarContent />
</aside>
);
}
@@ -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";
@@ -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 (
<>
<ScanLine aria-hidden="true" className="size-5" />
<span>Launch Scan</span>
</>
);
}
export function LaunchScanAction({ onSelect }: LaunchScanActionProps) {
const pathname = usePathname();
const openLaunchScanModal = useScansStore(
(state) => state.openLaunchScanModal,
);
const isScansPage = pathname.startsWith("/scans");
if (isScansPage) {
return (
<Button
type="button"
size="lg"
className="w-full"
aria-label="Launch Scan"
onClick={() => {
openLaunchScanModal();
onSelect?.();
}}
>
<LaunchScanContent />
</Button>
);
}
return (
<Button asChild size="lg" className="w-full">
<Link href={LAUNCH_SCAN_HREF} aria-label="Launch Scan" onClick={onSelect}>
<LaunchScanContent />
</Link>
</Button>
);
}
@@ -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;
}) => (
<button type="button" onClick={onSelect}>
Alerts
</button>
),
}));
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(<MobileAppSidebar />);
// 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(<MobileAppSidebar />);
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();
});
});
@@ -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<HTMLButtonElement>(null);
const handleSelect = () => {
setOpen(false);
return triggerRef.current;
};
return (
<Sheet open={open} onOpenChange={setOpen}>
<SheetTrigger asChild>
<Button
ref={triggerRef}
type="button"
variant="bare"
size="icon-sm"
aria-label="Open menu"
className={cn("lg:hidden", open && "invisible")}
>
<MenuIcon aria-hidden="true" className="size-5" />
</Button>
</SheetTrigger>
<SheetContent side="left" variant="navigation" showCloseButton={false}>
<SheetHeader className="sr-only">
<SheetTitle>App sidebar</SheetTitle>
<SheetDescription>Primary application navigation</SheetDescription>
</SheetHeader>
<SheetClose asChild>
<Button
type="button"
variant="outline"
size="icon-sm"
aria-label="Close menu"
className="fixed top-4 right-4 z-[60]"
>
<X aria-hidden="true" className="size-5" />
</Button>
</SheetClose>
<AppSidebarContent onSelect={handleSelect} />
</SheetContent>
</Sheet>
);
}
@@ -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 }));
});
});
@@ -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);
}
@@ -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 (
<div className="shrink-0 px-3 pb-4">
{!isCloudEnvironment && (
<div className="pt-4 pb-3">
<Button
type="button"
variant="default"
className="w-full"
onClick={() => {
openCloudUpgrade(
CLOUD_UPGRADE_FEATURE.GENERAL,
onSelect?.() ?? undefined,
);
}}
>
<Cloud aria-hidden="true" className="size-4" />
Explore Prowler Cloud
</Button>
</div>
)}
<div className="border-border-neutral-secondary text-text-neutral-tertiary flex min-h-9 items-center border-t pt-3 text-[11px]">
{isCloudEnvironment && (
<Link
href="https://status.prowler.com"
target="_blank"
rel="noopener noreferrer"
className="hover:text-text-neutral-primary min-w-0 flex-1 transition-colors"
onClick={onSelect}
>
<span className="truncate">Service status</span>
</Link>
)}
<span className="ml-auto font-mono">{version}</span>
</div>
</div>
);
}
@@ -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(<SidebarNavigation sections={sections} />);
// 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(<SidebarNavigation sections={sections} />);
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(
<SidebarNavigation sections={setProviderActive(false)} />,
);
const configuration = screen.getByRole("button", {
name: "Configuration",
});
expect(configuration).toHaveAttribute("aria-expanded", "false");
// When
rerender(<SidebarNavigation sections={setProviderActive(true)} />);
// 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(<SidebarNavigation sections={setProviderActive(true)} />);
// 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(<SidebarNavigation sections={sections} onSelect={onSelect} />);
// 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",
);
});
});
@@ -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 = (
<NavigationButton asChild active={item.active}>
<Link
href={item.href}
target={item.target}
rel={isExternal ? "noopener noreferrer" : undefined}
aria-current={item.active ? "page" : undefined}
onClick={onSelect}
>
{item.active && (
<span
aria-hidden="true"
className="bg-sidebar-active-bar absolute top-2 bottom-2 -left-px w-0.5 rounded-full"
/>
)}
<Icon
aria-hidden="true"
className={cn(
"size-[18px] shrink-0",
item.active && "text-sidebar-active-icon",
)}
/>
<span className="min-w-0 flex-1 truncate">{item.label}</span>
{item.highlight && (
<Badge variant="new" size="sm">
New
</Badge>
)}
{isExternal && <ArrowUpRight aria-hidden="true" className="size-4" />}
</Link>
</NavigationButton>
);
if (!item.tooltip) return link;
return (
<Tooltip delayDuration={100}>
<TooltipTrigger asChild>{link}</TooltipTrigger>
<TooltipContent side="right">{item.tooltip}</TooltipContent>
</Tooltip>
);
}
function CloudUpgradeChild({
item,
onSelect,
}: {
item: NavigationCloudUpgrade;
onSelect?: AppSidebarSelectionHandler;
}) {
const openCloudUpgrade = useCloudUpgradeStore(
(state) => state.openCloudUpgrade,
);
return (
<NavigationButton
variant="subitem"
onClick={() => {
openCloudUpgrade(item.cloudUpgradeFeature, onSelect?.() ?? undefined);
}}
>
<span className="min-w-0 flex-1 truncate">{item.label}</span>
<Badge variant="cloud" size="sm">
Cloud
</Badge>
</NavigationButton>
);
}
function LinkChild({
item,
onSelect,
}: {
item: NavigationChildLink;
onSelect?: AppSidebarSelectionHandler;
}) {
const isExternal = item.target === "_blank";
if (item.disabled) {
return (
<NavigationButton asChild variant="subitem" disabledState>
<span aria-disabled="true">{item.label}</span>
</NavigationButton>
);
}
return (
<NavigationButton asChild variant="subitem" active={item.active}>
<Link
href={item.href}
target={item.target}
rel={isExternal ? "noopener noreferrer" : undefined}
aria-current={item.active ? "page" : undefined}
onClick={onSelect}
>
<span className="min-w-0 flex-1 truncate">{item.label}</span>
{item.highlight && (
<Badge variant="new" size="sm">
New
</Badge>
)}
{isExternal && <ArrowUpRight aria-hidden="true" className="size-3.5" />}
</Link>
</NavigationButton>
);
}
function NavigationChildItem({ item, onSelect }: NavigationChildProps) {
if (item.kind === NAVIGATION_ITEM_KIND.CLOUD_UPGRADE) {
return <CloudUpgradeChild item={item} onSelect={onSelect} />;
}
return <LinkChild item={item} onSelect={onSelect} />;
}
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 (
<Collapsible open={isOpen} onOpenChange={setExpanded}>
<CollapsibleTrigger asChild>
<NavigationButton active={hasActiveChild}>
{hasActiveChild && (
<span
aria-hidden="true"
className="bg-sidebar-active-bar absolute top-2 bottom-2 -left-px w-0.5 rounded-full"
/>
)}
<Icon
aria-hidden="true"
className={cn(
"size-[18px] shrink-0",
hasActiveChild && "text-sidebar-active-icon",
)}
/>
<span className="min-w-0 flex-1 truncate">{item.label}</span>
<ChevronDown
aria-hidden="true"
className={cn(
"size-4 transition-transform duration-200",
isOpen && "rotate-180",
)}
/>
</NavigationButton>
</CollapsibleTrigger>
<CollapsibleContent className="data-[state=closed]:animate-collapsible-up data-[state=open]:animate-collapsible-down overflow-hidden">
<ul className="border-sidebar-guide mt-1 ml-[21px] space-y-0.5 border-l pl-3">
{item.children.map((child) => (
<li key={child.label}>
<NavigationChildItem item={child} onSelect={onSelect} />
</li>
))}
</ul>
</CollapsibleContent>
</Collapsible>
);
}
function NavigationSectionList({
section,
onSelect,
}: {
section: NavigationSection;
onSelect?: AppSidebarSelectionHandler;
}) {
return (
<section
aria-labelledby={section.label ? `sidebar-${section.label}` : undefined}
>
{section.label && (
<h2
id={`sidebar-${section.label}`}
className="text-text-neutral-tertiary mb-1.5 px-3 text-[10px] font-semibold tracking-[0.14em]"
>
{section.label}
</h2>
)}
<ul className="space-y-1">
{section.items.map((item) => (
<li key={item.label}>
{item.kind === NAVIGATION_ITEM_KIND.COLLAPSIBLE ? (
<CollapsibleNavigationItem
key={getCollapsibleActivationKey(item)}
item={item}
onSelect={onSelect}
/>
) : (
<TopLevelLink item={item} onSelect={onSelect} />
)}
</li>
))}
</ul>
</section>
);
}
export function SidebarNavigation({
sections,
onSelect,
}: SidebarNavigationProps) {
return (
<ScrollArea className="h-full [&>div>div[style]]:block!">
<nav aria-label="Main navigation" className="space-y-5 px-3 py-4">
{sections.map((section, index) => (
<NavigationSectionList
key={section.label ?? `primary-${index}`}
section={section}
onSelect={onSelect}
/>
))}
</nav>
</ScrollArea>
);
}
+71
View File
@@ -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;
+1 -1
View File
@@ -1,4 +1,4 @@
export * from "./app-sidebar";
export * from "./main-layout";
export * from "./nav-bar";
export * from "./sidebar";
export * from "./user-nav";
@@ -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: () => <aside data-testid="sidebar" />,
vi.mock("@/components/layout/app-sidebar", () => ({
AppSidebar: () => <aside data-testid="sidebar" />,
}));
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();
});
});
@@ -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 (
<div className="relative flex h-dvh items-center justify-center overflow-hidden">
{/* Top-left gradient halo */}
<div
className="pointer-events-none fixed top-0 left-0 z-0 h-[120%] w-[160%] opacity-[7%] blur-3xl"
style={{
background: "linear-gradient(90deg, #31E59F 0%, #60E0EC 100%)",
transform: "translate(-50%, -50%)",
}}
/>
{/* Bottom-right gradient halo */}
<div
className="pointer-events-none fixed right-0 bottom-0 z-0 h-[50%] w-[50%] opacity-[7%] blur-3xl"
style={{
background: "linear-gradient(90deg, #31E59F 0%, #60E0EC 100%)",
transform: "translate(50%, 50%)",
}}
/>
<Sidebar />
<AppSidebar />
<CloudUpgradeModal />
<main
className={cn(
"no-scrollbar relative z-10 mb-auto h-full flex-1 flex-col overflow-y-auto transition-[margin-left] duration-300 ease-in-out",
!settings.disabled &&
(!getOpenState() ? "lg:ml-[90px]" : "lg:ml-[248px]"),
)}
>
{children}
<main className="no-scrollbar relative z-10 mb-auto ml-4 h-full flex-1 flex-col overflow-y-auto lg:ml-[280px]">
<Suspense fallback={null}>{children}</Suspense>
</main>
</div>
);
@@ -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: () => <button type="button">Theme switch</button>,
}));
@@ -54,12 +50,8 @@ vi.mock("@/components/shadcn", async (importOriginal) => ({
),
}));
vi.mock("../sidebar/sheet-menu", () => ({
SheetMenu: () => <button type="button">Open menu</button>,
}));
vi.mock("../sidebar/sidebar-toggle", () => ({
SidebarToggle: () => <button type="button">Toggle sidebar</button>,
vi.mock("@/components/layout/app-sidebar", () => ({
MobileAppSidebar: () => <button type="button">Open menu</button>,
}));
vi.mock("../user-nav/user-nav", () => ({
@@ -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({
<header className="sticky top-0 z-10 w-full pt-4 backdrop-blur-sm">
<div className="flex h-14 items-center pr-6">
<div className="flex items-center gap-2">
<SheetMenu />
<div className="hidden lg:block">
<SidebarToggle isOpen={isOpen} setIsOpen={toggleOpen} />
</div>
<MobileAppSidebar />
{/* Suspense contains the useSearchParams() CSR bailout in BreadcrumbNavigation
so statically prerendered pages don't fail the build. */}
<Suspense fallback={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 (
<Collapsible
open={isOpen}
onOpenChange={setIsCollapsed}
defaultOpen={defaultOpen}
className="group mb-1 w-full"
>
<Tooltip delayDuration={100}>
<TooltipTrigger asChild>
<CollapsibleTrigger asChild>
<Button
variant={isSubmenuActive ? "menu-active" : "menu-inactive"}
className={cn(
isSidebarOpen ? "w-full justify-start" : "w-14 justify-center",
)}
>
{isSidebarOpen ? (
<div className="flex w-full items-center justify-between">
<div className="flex items-center">
<span className="mr-4">
<Icon size={18} />
</span>
<p className="max-w-[150px] truncate">{label}</p>
</div>
<ChevronDown
size={18}
className="transition-transform duration-200 group-data-[state=open]:rotate-180"
/>
</div>
) : (
<Icon size={18} />
)}
</Button>
</CollapsibleTrigger>
</TooltipTrigger>
{!isSidebarOpen && (
<TooltipContent side="right">{label}</TooltipContent>
)}
</Tooltip>
<CollapsibleContent className="data-[state=closed]:animate-collapsible-up data-[state=open]:animate-collapsible-down flex flex-col items-end overflow-hidden">
{submenus.map((submenu, index) => (
<SubmenuItem key={index} {...submenu} onSelect={onSelect} />
))}
</CollapsibleContent>
</Collapsible>
);
};
-7
View File
@@ -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";
@@ -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 (
<Tooltip delayDuration={100}>
<TooltipTrigger asChild>
<Button
variant={isActive ? "menu-active" : "menu-inactive"}
className={cn(
isOpen ? "w-full justify-start" : "w-14 justify-center",
)}
asChild
>
<Link href={href} target={target} onClick={onSelect}>
<div className="flex items-center">
<span className={cn(isOpen ? "mr-4" : "")}>
<Icon size={18} />
</span>
{isOpen && (
<p className="flex max-w-[200px] items-center truncate">
<span>{label}</span>
{highlight && (
<Badge variant="new" size="sm" className="ml-2">
New
</Badge>
)}
</p>
)}
</div>
</Link>
</Button>
</TooltipTrigger>
{showTooltip && (
<TooltipContent side="right">{tooltip || label}</TooltipContent>
)}
</Tooltip>
);
};
-302
View File
@@ -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: () => <div data-testid="lighthouse-chat-sidebar" />,
}));
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<typeof import("@/hooks/use-sidebar")>();
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(<MenuComponent isOpen />);
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(<MenuComponent isOpen />);
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(<MenuComponent isOpen={false} />);
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(<MenuComponent isOpen />);
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(<MenuComponent isOpen />);
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(<MenuComponent isOpen />);
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(<MenuComponent isOpen />);
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(<MenuComponent isOpen />);
// 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(
<SidebarNavigationModeToggleComponent
isOpen
value={SIDEBAR_NAVIGATION_MODE.BROWSE}
onChange={onChange}
/>,
);
// 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(
<SidebarNavigationModeToggleComponent
isOpen
value={SIDEBAR_NAVIGATION_MODE.CHAT}
onChange={onChange}
/>,
);
// 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(
<SidebarNavigationModeToggleComponent
isOpen
value={SIDEBAR_NAVIGATION_MODE.BROWSE}
onChange={onChange}
chatEnabled={false}
/>,
);
// 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");
});
});
-271
View File
@@ -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 (
<div className="flex h-full flex-col overflow-hidden">
{/* Launch Scan Button — mt-1 aligns its top with the main content panel
(navbar 72px + py-4 = 88px), matching the fixed-height logo block. */}
<div className="mt-1 flex shrink-0 justify-center px-2">
<Tooltip delayDuration={100}>
<TooltipTrigger asChild>
{isScansPage ? (
<Button
type="button"
aria-label="Launch Scan"
className={cn(isOpen ? "h-14 w-full p-1" : "w-14")}
variant="default"
size="default"
onClick={() => {
openLaunchScanModal();
onSelect?.();
}}
>
<LaunchScanButtonContent isOpen={isOpen} />
</Button>
) : (
<Button
asChild
className={cn(isOpen ? "h-14 w-full p-1" : "w-14")}
variant="default"
size="default"
>
<Link
href={LAUNCH_SCAN_HREF}
aria-label="Launch Scan"
onClick={onSelect}
>
<LaunchScanButtonContent isOpen={isOpen} />
</Link>
</Button>
)}
</TooltipTrigger>
{!isOpen && <TooltipContent side="right">Launch Scan</TooltipContent>}
</Tooltip>
</div>
<SidebarNavigationModeToggle
isOpen={isOpen}
value={navigationMode}
onChange={setNavigationMode}
chatEnabled={isCloudEnv}
onSelect={onSelect}
/>
{/* Menu Items */}
<div className="flex-1 overflow-hidden">
{isCloudEnv && navigationMode === SIDEBAR_NAVIGATION_MODE.CHAT ? (
<LighthouseV2SidebarChat isOpen={isOpen} />
) : (
<ScrollArea className="h-full [&>div>div[style]]:block!">
<nav className="mt-2 w-full lg:mt-6">
<ul className="mx-2 flex flex-col items-start gap-1 pb-4">
{filteredMenuList.map((group, groupIndex) => (
<li key={groupIndex} className="w-full">
{group.menus.map((menu, menuIndex) => (
<div key={menuIndex} className="w-full">
{menu.submenus && menu.submenus.length > 0 ? (
<CollapsibleMenu
icon={menu.icon}
label={menu.label}
submenus={menu.submenus}
defaultOpen={menu.defaultOpen}
isOpen={isOpen}
onSelect={onSelect}
/>
) : (
<MenuItem
href={menu.href}
label={menu.label}
icon={menu.icon}
active={menu.active}
target={menu.target}
tooltip={menu.tooltip}
isOpen={isOpen}
highlight={menu.highlight}
onSelect={onSelect}
/>
)}
</div>
))}
</li>
))}
</ul>
</nav>
</ScrollArea>
)}
</div>
{!isCloudEnv && (
<div className="shrink-0 px-2 pt-4 pb-3">
<Tooltip delayDuration={100}>
<TooltipTrigger asChild>
<Button
type="button"
variant="default"
aria-label="Explore Prowler Cloud"
className={cn("w-full", isOpen ? "justify-center" : "px-0")}
onClick={() => {
openCloudUpgrade(
CLOUD_UPGRADE_FEATURE.GENERAL,
onSelect?.() ?? undefined,
);
}}
>
<Cloud aria-hidden="true" className="size-4" />
{isOpen && <span>Explore Prowler Cloud</span>}
</Button>
</TooltipTrigger>
{!isOpen && (
<TooltipContent side="right">
Explore Prowler Cloud
</TooltipContent>
)}
</Tooltip>
</div>
)}
{/* Footer */}
<div className="text-muted-foreground border-border-neutral-secondary flex shrink-0 items-center justify-center gap-2 border-t pt-4 pb-2 text-center text-xs">
{isOpen ? (
<>
<span>{process.env.NEXT_PUBLIC_PROWLER_RELEASE_VERSION}</span>
{isCloudEnv && (
<>
<Separator orientation="vertical" />
<Link
href="https://status.prowler.com"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1"
onClick={onSelect}
>
<InfoIcon size={16} />
<span className="text-muted-foreground font-normal opacity-80 transition-opacity hover:font-bold hover:opacity-100">
Service Status
</span>
</Link>
</>
)}
</>
) : (
isCloudEnv && (
<Tooltip>
<TooltipTrigger asChild>
<Link
href="https://status.prowler.com"
target="_blank"
rel="noopener noreferrer"
className="flex items-center"
onClick={onSelect}
>
<InfoIcon size={16} />
</Link>
</TooltipTrigger>
<TooltipContent side="right">Service Status</TooltipContent>
</Tooltip>
)
)}
</div>
</div>
);
};
function LaunchScanButtonContent({ isOpen }: { isOpen: boolean }) {
return (
<span className={cn("flex items-center", isOpen && "gap-2.5")}>
<ProwlerShort aria-hidden="true" className="size-5 text-current" />
{isOpen && <span className="text-xl leading-8">Scan</span>}
</span>
);
}
@@ -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: () => <span>Prowler</span>,
}));
vi.mock("@/components/layout/sidebar/menu", () => ({
Menu: ({ onSelect }: { onSelect?: () => void }) => (
<button type="button" onClick={onSelect}>
Alerts
</button>
),
}));
import { SheetMenu } from "./sheet-menu";
describe("SheetMenu", () => {
it("should close after selecting a menu action", async () => {
// Given
const user = userEvent.setup();
render(<SheetMenu />);
// 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();
});
});
@@ -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<HTMLButtonElement>(null);
const handleSelect = () => {
setOpen(false);
return triggerRef.current;
};
return (
<Sheet open={open} onOpenChange={setOpen}>
<SheetTrigger className="lg:hidden" asChild>
<Button
ref={triggerRef}
aria-label="Open menu"
className="h-8"
variant="outline"
size="icon"
>
<MenuIcon size={20} />
</Button>
</SheetTrigger>
<SheetContent className="flex h-full flex-col px-3 sm:w-72" side="left">
<SheetHeader>
<SheetTitle className="sr-only">Sidebar</SheetTitle>
<SheetDescription className="sr-only" />
<Button
className="flex items-center justify-center pt-1 pb-2"
variant="link"
asChild
>
<Link
href="/"
className="flex items-center justify-center"
onClick={handleSelect}
>
<ProwlerBrand />
</Link>
</Button>
</SheetHeader>
<Menu isOpen onSelect={handleSelect} />
</SheetContent>
</Sheet>
);
}
@@ -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 (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="bare"
size="icon-sm"
onClick={() => setIsOpen?.()}
aria-label={isClosed ? "Expand sidebar" : "Collapse sidebar"}
>
<Chevron className="size-5" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">
{isClosed ? "Expand Sidebar" : "Collapse Sidebar"}
</TooltipContent>
</Tooltip>
);
}
-59
View File
@@ -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 (
<aside
className={cn(
"fixed top-0 left-0 z-20 h-screen -translate-x-full transition-[width] duration-300 ease-in-out lg:translate-x-0",
!getOpenState() ? "w-[90px]" : "w-[248px]",
settings.disabled && "hidden",
)}
>
<div
onMouseEnter={() => setIsHover(true)}
onMouseLeave={() => setIsHover(false)}
className="no-scrollbar relative flex h-full flex-col overflow-x-hidden overflow-y-auto px-3 py-6"
>
<Link
href="/"
className={cn(
"mb-6 flex w-full flex-col items-center justify-center px-3 transition-transform duration-300 ease-in-out",
!getOpenState() ? "translate-x-1" : "translate-x-0",
!isOpen && "gap-0",
)}
>
<div
className={clsx({
hidden: isOpen,
})}
>
<ProwlerShort />
</div>
<div
className={clsx({
hidden: !isOpen,
"mt-0!": isOpen,
})}
>
<ProwlerBrand />
</div>
</Link>
<Menu isOpen={getOpenState()} />
</div>
</aside>
);
}
@@ -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 }) => (
<svg aria-hidden="true" height={size} width={size} />
);
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(
<SubmenuItem
kind={SUBMENU_KIND.CLOUD_UPGRADE}
label="Alerts"
icon={TestIcon}
cloudUpgradeFeature={CLOUD_UPGRADE_FEATURE.ALERTS}
onSelect={onSelect}
/>,
);
// 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,
);
});
});
@@ -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 (
<Button
type="button"
variant="menu-inactive"
className="mt-1 w-[calc(100%-12px)] justify-start px-2 py-1"
onClick={() => {
openCloudUpgrade(cloudUpgradeFeature, onSelect?.() ?? undefined);
}}
>
<span className="mr-2">
<Icon size={16} />
</span>
<span className="flex min-w-0 items-center gap-2">
<span className="truncate">{label}</span>
<Badge variant="cloud" size="sm">
Cloud
</Badge>
</span>
</Button>
);
}
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 (
<Tooltip>
<TooltipTrigger asChild>
<Button
className="pointer-events-none mt-1 w-[calc(100%-12px)] cursor-not-allowed justify-start px-2 py-1"
disabled
>
<span className="mr-2">
<Icon size={16} />
</span>
<p className="min-w-0 truncate">{label}</p>
</Button>
</TooltipTrigger>
<TooltipContent side="right">
The mutelist will be enabled after adding a provider
</TooltipContent>
</Tooltip>
);
}
if (disabled) {
const tooltip = cloudOnly
? "Available in Prowler Cloud"
: `${label} is unavailable.`;
return (
<Tooltip>
<TooltipTrigger asChild>
<span
className="group mt-1 inline-flex w-[calc(100%-12px)]"
tabIndex={0}
>
<Button
variant="menu-inactive"
className="text-text-neutral-tertiary w-full cursor-not-allowed justify-start px-2 py-1"
aria-disabled="true"
tabIndex={-1}
type="button"
>
<span className="mr-2">
<Icon size={16} />
</span>
<p className="flex min-w-0 items-center gap-2">
<span className="truncate">{label}</span>
{highlight && (
<Badge variant="new" size="sm">
New
</Badge>
)}
</p>
</Button>
</span>
</TooltipTrigger>
<TooltipContent side="right">{tooltip}</TooltipContent>
</Tooltip>
);
}
return (
<Button
variant={isActive ? "menu-active" : "menu-inactive"}
className="mt-1 w-[calc(100%-12px)] justify-start px-2 py-1"
asChild={!disabled}
disabled={disabled}
>
<Link
href={href}
target={target}
className="flex items-center"
onClick={onSelect}
>
<span className="mr-2">
<Icon size={16} />
</span>
<p className="flex min-w-0 items-center">
<span className="truncate">{label}</span>
{highlight && (
<Badge variant="new" size="sm" className="ml-2">
New
</Badge>
)}
</p>
</Link>
</Button>
);
};
-6
View File
@@ -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",
+1
View File
@@ -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";
@@ -0,0 +1 @@
export * from "./navigation-button";
@@ -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(
<NavigationButton asChild active>
<a href="/findings">Findings</a>
</NavigationButton>,
);
// 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(<NavigationButton variant="toggle">Chat</NavigationButton>);
// Then
const button = screen.getByRole("button", { name: "Chat" });
expect(button).toHaveAttribute("type", "button");
expect(button).toHaveAttribute("data-active", "false");
});
});
@@ -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<typeof navigationButtonVariants> {
asChild?: boolean;
}
function NavigationButton({
active = false,
asChild = false,
className,
disabledState = false,
type,
variant,
...props
}: NavigationButtonProps) {
const Comp = asChild ? Slot : "button";
return (
<Comp
data-slot="navigation-button"
data-active={active}
data-disabled={disabledState}
type={asChild ? undefined : (type ?? "button")}
className={cn(
navigationButtonVariants({
active,
className,
disabledState,
variant,
}),
)}
{...props}
/>
);
}
export { NavigationButton };
+31
View File
@@ -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(
<Sheet open>
<SheetContent variant="navigation" showCloseButton={false}>
<SheetHeader>
<SheetTitle>App navigation</SheetTitle>
<SheetDescription>Primary destinations</SheetDescription>
</SheetHeader>
</SheetContent>
</Sheet>,
);
// Then
expect(
screen.getByRole("dialog", { name: "App navigation" }),
).toHaveAttribute("data-variant", "navigation");
});
});
+90 -60
View File
@@ -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<typeof SheetPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Overlay
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/80",
className,
)}
{...props}
ref={ref}
/>
));
function SheetOverlay({
className,
ref,
...props
}: ComponentPropsWithRef<typeof SheetPrimitive.Overlay>) {
return (
<SheetPrimitive.Overlay
ref={ref}
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/80",
className,
)}
{...props}
/>
);
}
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<typeof SheetPrimitive.Content>,
VariantProps<typeof sheetVariants> {}
extends ComponentPropsWithRef<typeof SheetPrimitive.Content>,
VariantProps<typeof sheetVariants> {
showCloseButton?: boolean;
}
const SheetContent = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Content>,
SheetContentProps
>(({ side = "right", className, children, ...props }, ref) => (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
ref={ref}
className={cn(sheetVariants({ side }), className)}
{...props}
>
<SheetPrimitive.Close className="absolute top-4 right-4 rounded-sm opacity-70 ring-offset-white transition-opacity hover:opacity-100 focus:ring-2 focus:ring-neutral-950 focus:ring-offset-2 focus:outline-none disabled:pointer-events-none data-[state=open]:bg-neutral-100 dark:ring-offset-neutral-950 dark:focus:ring-neutral-300 dark:data-[state=open]:bg-neutral-800">
<Cross2Icon className="h-4 w-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
{children}
</SheetPrimitive.Content>
</SheetPortal>
));
function SheetContent({
children,
className,
ref,
showCloseButton = true,
side = "right",
variant = "default",
...props
}: SheetContentProps) {
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
ref={ref}
data-variant={variant}
className={cn(sheetVariants({ side, variant }), className)}
{...props}
>
{showCloseButton && (
<SheetPrimitive.Close className="absolute top-4 right-4 rounded-sm opacity-70 ring-offset-white transition-opacity hover:opacity-100 focus:ring-2 focus:ring-neutral-950 focus:ring-offset-2 focus:outline-none disabled:pointer-events-none data-[state=open]:bg-neutral-100 dark:ring-offset-neutral-950 dark:focus:ring-neutral-300 dark:data-[state=open]:bg-neutral-800">
<Cross2Icon className="h-4 w-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
)}
{children}
</SheetPrimitive.Content>
</SheetPortal>
);
}
SheetContent.displayName = SheetPrimitive.Content.displayName;
const SheetHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
}: HTMLAttributes<HTMLDivElement>) => (
<div
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
@@ -88,7 +109,7 @@ SheetHeader.displayName = "SheetHeader";
const SheetFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
}: HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:gap-2",
@@ -99,31 +120,40 @@ const SheetFooter = ({
);
SheetFooter.displayName = "SheetFooter";
const SheetTitle = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold text-neutral-950 dark:text-neutral-50",
className,
)}
{...props}
/>
));
function SheetTitle({
className,
ref,
...props
}: ComponentPropsWithRef<typeof SheetPrimitive.Title>) {
return (
<SheetPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold text-neutral-950 dark:text-neutral-50",
className,
)}
{...props}
/>
);
}
SheetTitle.displayName = SheetPrimitive.Title.displayName;
const SheetDescription = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Description
ref={ref}
className={cn("text-sm text-neutral-500 dark:text-neutral-400", className)}
{...props}
/>
));
function SheetDescription({
className,
ref,
...props
}: ComponentPropsWithRef<typeof SheetPrimitive.Description>) {
return (
<SheetPrimitive.Description
ref={ref}
className={cn(
"text-sm text-neutral-500 dark:text-neutral-400",
className,
)}
{...props}
/>
);
}
SheetDescription.displayName = SheetPrimitive.Description.displayName;
export {
@@ -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(<SidebarNavigationModeSync mode={SIDEBAR_NAVIGATION_MODE.BROWSE} />);
// Then
expect(useSidebar.getState().navigationMode).toBe(
SIDEBAR_NAVIGATION_MODE.BROWSE,
);
});
});
@@ -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;
}
@@ -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 (
<div className={cn("mt-4 shrink-0 px-2", !isOpen && "flex justify-center")}>
<div
className={cn(
"border-border-input-primary bg-bg-input-primary dark:bg-input/30 flex gap-1 rounded-lg border p-1",
isOpen ? "w-full" : "flex-col",
)}
>
{modes.map((mode) => {
const Icon = mode.icon;
const active = value === mode.value;
const disabled =
mode.value === SIDEBAR_NAVIGATION_MODE.CHAT && !chatEnabled;
const button = (
<button
key={mode.value}
type="button"
aria-label={mode.label}
className={cn(
"flex h-8 items-center justify-center rounded-[6px] border text-sm transition-all duration-200 ease-out",
isOpen
? disabled
? "min-w-0 gap-1 px-1.5"
: "min-w-0 gap-2 px-2"
: "w-8 px-2",
active
? "border-border-input-primary bg-bg-neutral-primary text-text-neutral-primary shadow-md"
: "text-text-neutral-secondary hover:text-text-neutral-primary border-transparent",
// Local Server gives the Chat upsell enough room to keep both
// the feature name and its Cloud badge visible.
isOpen &&
(!chatEnabled
? mode.value === SIDEBAR_NAVIGATION_MODE.CHAT
? "flex-[3]"
: "flex-[2]"
: active
? "flex-[11]"
: "flex-[9]"),
disabled && "text-text-neutral-secondary",
)}
onClick={() => handleModeChange(mode.value, disabled)}
>
<Icon aria-hidden="true" className="size-4 shrink-0" />
{isOpen && <span className="shrink-0">{mode.label}</span>}
{isOpen && disabled && (
<Badge variant="cloud" size="sm">
Cloud
</Badge>
)}
</button>
);
const tooltipContent = disabled
? "Available in Prowler Cloud"
: !isOpen
? mode.label
: null;
if (!tooltipContent) {
return button;
}
return (
<Tooltip key={mode.value} delayDuration={100}>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent side="right">{tooltipContent}</TooltipContent>
</Tooltip>
);
})}
</div>
</div>
);
}
-1
View File
@@ -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";
-3
View File
@@ -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";
-1
View File
@@ -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";
-54
View File
@@ -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<SidebarStore>(
(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),
},
),
);
-1
View File
@@ -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";
-210
View File
@@ -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<typeof getMenuList>[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");
});
});
-294
View File
@@ -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.",
},
],
},
];
};
+3 -3
View File
@@ -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",
+10
View File
@@ -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: {
+62
View File
@@ -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;
+62
View File
@@ -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<void> {
await super.goto("/");
}
async verifyPageLoaded(): Promise<void> {
await expect(this.openMenuButton).toBeVisible();
}
async openMobileSidebar(): Promise<void> {
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<void> {
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);
}
}
+36
View File
@@ -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
+22
View File
@@ -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();
},
);
});
-62
View File
@@ -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<SVGSVGElement> & {
@@ -17,67 +16,6 @@ export type IconProps = {
export type IconComponent = LucideIcon | React.FC<IconSvgProps>;
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",