diff --git a/ui/app/(prowler)/lighthouse/page.tsx b/ui/app/(prowler)/lighthouse/page.tsx index cd3ad44ccf..625aaf795a 100644 --- a/ui/app/(prowler)/lighthouse/page.tsx +++ b/ui/app/(prowler)/lighthouse/page.tsx @@ -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 ( }> - + {/* [contain:layout] traps streamdown's fixed fullscreen overlay inside the chat area so it never covers the sidebar or navbar. */}
diff --git a/ui/app/(prowler)/page.tsx b/ui/app/(prowler)/page.tsx index 5111baf656..ff70583e19 100644 --- a/ui/app/(prowler)/page.tsx +++ b/ui/app/(prowler)/page.tsx @@ -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 ( - +
diff --git a/ui/changelog.d/sidebar-redesign.changed.md b/ui/changelog.d/sidebar-redesign.changed.md new file mode 100644 index 0000000000..9d45d8ffdf --- /dev/null +++ b/ui/changelog.d/sidebar-redesign.changed.md @@ -0,0 +1 @@ +Sidebar navigation with grouped sections, clearer active states, and a responsive mobile overlay diff --git a/ui/components/layout/app-sidebar/app-sidebar-content.test.tsx b/ui/components/layout/app-sidebar/app-sidebar-content.test.tsx new file mode 100644 index 0000000000..54dedad07c --- /dev/null +++ b/ui/components/layout/app-sidebar/app-sidebar-content.test.tsx @@ -0,0 +1,126 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { CLOUD_UPGRADE_FEATURE } from "@/types/cloud-upgrade"; + +import { AppSidebarContent } from "./app-sidebar-content"; +import { useAppSidebarMode } from "./app-sidebar-mode-store"; +import { APP_SIDEBAR_MODE } from "./types"; + +const { + openCloudUpgradeMock, + openLaunchScanModalMock, + pathnameValue, + pushMock, +} = vi.hoisted(() => ({ + openCloudUpgradeMock: vi.fn(), + openLaunchScanModalMock: vi.fn(), + pathnameValue: { current: "/findings" }, + pushMock: vi.fn(), +})); + +vi.mock("next/navigation", () => ({ + usePathname: () => pathnameValue.current, + useRouter: () => ({ push: pushMock }), +})); + +vi.mock("@/hooks", () => ({ + useAuth: () => ({ permissions: {} }), +})); + +vi.mock("@/hooks/use-runtime-config", () => ({ + useRuntimeConfig: () => ({ apiDocsUrl: "https://local.example/docs" }), +})); + +vi.mock("@/store", () => ({ + useScansStore: ( + selector: (state: { + openLaunchScanModal: typeof openLaunchScanModalMock; + }) => unknown, + ) => selector({ openLaunchScanModal: openLaunchScanModalMock }), + useCloudUpgradeStore: ( + selector: (state: { + openCloudUpgrade: typeof openCloudUpgradeMock; + }) => unknown, + ) => selector({ openCloudUpgrade: openCloudUpgradeMock }), +})); + +vi.mock("@/app/(prowler)/lighthouse/_components/navigation", () => ({ + LighthouseV2SidebarChat: () =>
, +})); + +describe("AppSidebarContent", () => { + beforeEach(() => { + pathnameValue.current = "/findings"; + pushMock.mockClear(); + openCloudUpgradeMock.mockClear(); + openLaunchScanModalMock.mockClear(); + useAppSidebarMode.setState({ mode: APP_SIDEBAR_MODE.BROWSE }); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it("shares the brand, Launch Scan action and Local Server Cloud affordances", async () => { + // Given + vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false"); + vi.stubEnv("NEXT_PUBLIC_PROWLER_RELEASE_VERSION", "5.8.0"); + const user = userEvent.setup(); + + // When + render(); + + // Then + const homeLink = screen.getByRole("link", { name: "Prowler home" }); + expect(homeLink).toBeVisible(); + expect(screen.getByRole("link", { name: "Launch Scan" })).toHaveAttribute( + "href", + "/scans?launchScan=true", + ); + expect(screen.getByText("5.8.0")).toBeVisible(); + + await user.click(screen.getByRole("button", { name: "Chat" })); + expect(openCloudUpgradeMock).toHaveBeenCalledWith( + CLOUD_UPGRADE_FEATURE.LIGHTHOUSE_AI, + undefined, + ); + expect(screen.getAllByText("Cloud").length).toBeGreaterThan(0); + }); + + it("keeps the existing Lighthouse chat sidebar in Cloud Chat mode", () => { + // Given + vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true"); + useAppSidebarMode.setState({ mode: APP_SIDEBAR_MODE.CHAT }); + + // When + render(); + + // Then + expect(screen.getByTestId("lighthouse-chat-sidebar")).toBeVisible(); + expect( + screen.getByRole("link", { name: "Service status" }), + ).toHaveAttribute("href", "https://status.prowler.com"); + expect( + screen.queryByText("All systems operational"), + ).not.toBeInTheDocument(); + }); + + it("opens the current scan modal instead of navigating from the scans route", async () => { + // Given + vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true"); + pathnameValue.current = "/scans"; + const user = userEvent.setup(); + + // When + render(); + await user.click(screen.getByRole("button", { name: "Launch Scan" })); + + // Then + expect(openLaunchScanModalMock).toHaveBeenCalledOnce(); + expect( + screen.queryByRole("link", { name: "Launch Scan" }), + ).not.toBeInTheDocument(); + }); +}); diff --git a/ui/components/layout/app-sidebar/app-sidebar-content.tsx b/ui/components/layout/app-sidebar/app-sidebar-content.tsx new file mode 100644 index 0000000000..c32a7d5048 --- /dev/null +++ b/ui/components/layout/app-sidebar/app-sidebar-content.tsx @@ -0,0 +1,68 @@ +"use client"; + +import Link from "next/link"; +import { usePathname } from "next/navigation"; + +import { LighthouseV2SidebarChat } from "@/app/(prowler)/lighthouse/_components/navigation"; +import { ProwlerBrand } from "@/components/icons"; +import { useAuth } from "@/hooks"; +import { useRuntimeConfig } from "@/hooks/use-runtime-config"; +import { isCloud } from "@/lib/shared/env"; + +import { useAppSidebarMode } from "./app-sidebar-mode-store"; +import { AppSidebarModeToggle } from "./app-sidebar-mode-toggle"; +import { LaunchScanAction } from "./launch-scan-action"; +import { getNavigationConfig } from "./navigation-config"; +import { SidebarFooter } from "./sidebar-footer"; +import { SidebarNavigation } from "./sidebar-navigation"; +import { APP_SIDEBAR_MODE, type AppSidebarSelectionHandler } from "./types"; + +interface AppSidebarContentProps { + onSelect?: AppSidebarSelectionHandler; +} + +export function AppSidebarContent({ onSelect }: AppSidebarContentProps) { + const pathname = usePathname(); + const { permissions } = useAuth(); + const { apiDocsUrl } = useRuntimeConfig(); + const mode = useAppSidebarMode((state) => state.mode); + const isCloudEnvironment = isCloud(); + const sections = getNavigationConfig({ pathname, apiDocsUrl, permissions }); + const showChat = isCloudEnvironment && mode === APP_SIDEBAR_MODE.CHAT; + + return ( +
+
+ + + +
+ +
+ + +
+ +
+ {showChat ? ( + + ) : ( + + )} +
+ + +
+ ); +} diff --git a/ui/components/layout/app-sidebar/app-sidebar-mode-store.test.ts b/ui/components/layout/app-sidebar/app-sidebar-mode-store.test.ts new file mode 100644 index 0000000000..ba85902736 --- /dev/null +++ b/ui/components/layout/app-sidebar/app-sidebar-mode-store.test.ts @@ -0,0 +1,73 @@ +import { beforeEach, describe, expect, it } from "vitest"; + +import { + migrateAppSidebarState, + useAppSidebarMode, +} from "./app-sidebar-mode-store"; +import { APP_SIDEBAR_MODE } from "./types"; + +describe("app sidebar mode store", () => { + beforeEach(() => { + localStorage.clear(); + useAppSidebarMode.setState({ mode: APP_SIDEBAR_MODE.BROWSE }); + }); + + it("keeps the persisted chat mode while discarding legacy sidebar state", () => { + // Given + const legacyState = { + isOpen: false, + isHover: true, + navigationMode: APP_SIDEBAR_MODE.CHAT, + settings: { disabled: false, isHoverOpen: false }, + }; + + // When + const migrated = migrateAppSidebarState(legacyState); + + // Then + expect(migrated).toEqual({ mode: APP_SIDEBAR_MODE.CHAT }); + expect(migrated).not.toHaveProperty("isOpen"); + expect(migrated).not.toHaveProperty("settings"); + }); + + it("falls back to browse for an invalid persisted mode", () => { + // Given / When + const migrated = migrateAppSidebarState({ navigationMode: "invalid" }); + + // Then + expect(migrated).toEqual({ mode: APP_SIDEBAR_MODE.BROWSE }); + }); + + it("rehydrates the legacy payload under the existing sidebar key", async () => { + // Given + localStorage.setItem( + "sidebar", + JSON.stringify({ + state: { + navigationMode: APP_SIDEBAR_MODE.CHAT, + isOpen: false, + isHover: true, + settings: { disabled: true }, + }, + version: 0, + }), + ); + + // When + await useAppSidebarMode.persist.rehydrate(); + + // Then + expect(useAppSidebarMode.getState().mode).toBe(APP_SIDEBAR_MODE.CHAT); + expect(useAppSidebarMode.getState()).not.toEqual( + expect.objectContaining({ isOpen: expect.anything() }), + ); + }); + + it("updates the current navigation mode", () => { + // Given / When + useAppSidebarMode.getState().setMode(APP_SIDEBAR_MODE.CHAT); + + // Then + expect(useAppSidebarMode.getState().mode).toBe(APP_SIDEBAR_MODE.CHAT); + }); +}); diff --git a/ui/components/layout/app-sidebar/app-sidebar-mode-store.ts b/ui/components/layout/app-sidebar/app-sidebar-mode-store.ts new file mode 100644 index 0000000000..fc076c0296 --- /dev/null +++ b/ui/components/layout/app-sidebar/app-sidebar-mode-store.ts @@ -0,0 +1,54 @@ +import { create } from "zustand"; +import { createJSONStorage, persist } from "zustand/middleware"; + +import { APP_SIDEBAR_MODE, type AppSidebarMode } from "./types"; + +interface PersistedAppSidebarState { + mode: AppSidebarMode; +} + +interface AppSidebarModeStore extends PersistedAppSidebarState { + setMode: (mode: AppSidebarMode) => void; +} + +function isAppSidebarMode(value: unknown): value is AppSidebarMode { + return Object.values(APP_SIDEBAR_MODE).some((mode) => mode === value); +} + +export function migrateAppSidebarState( + persistedState: unknown, +): PersistedAppSidebarState { + if (typeof persistedState !== "object" || persistedState === null) { + return { mode: APP_SIDEBAR_MODE.BROWSE }; + } + + if ("mode" in persistedState && isAppSidebarMode(persistedState.mode)) { + return { mode: persistedState.mode }; + } + + if ( + "navigationMode" in persistedState && + isAppSidebarMode(persistedState.navigationMode) + ) { + return { mode: persistedState.navigationMode }; + } + + return { mode: APP_SIDEBAR_MODE.BROWSE }; +} + +export const useAppSidebarMode = create()( + persist( + (set) => ({ + mode: APP_SIDEBAR_MODE.BROWSE, + setMode: (mode) => set({ mode }), + }), + { + name: "sidebar", + storage: createJSONStorage(() => localStorage), + merge: (persistedState, currentState) => ({ + ...currentState, + ...migrateAppSidebarState(persistedState), + }), + }, + ), +); diff --git a/ui/components/layout/app-sidebar/app-sidebar-mode-sync.test.tsx b/ui/components/layout/app-sidebar/app-sidebar-mode-sync.test.tsx new file mode 100644 index 0000000000..59caaae738 --- /dev/null +++ b/ui/components/layout/app-sidebar/app-sidebar-mode-sync.test.tsx @@ -0,0 +1,20 @@ +import { render } from "@testing-library/react"; +import { beforeEach, describe, expect, it } from "vitest"; + +import { useAppSidebarMode } from "./app-sidebar-mode-store"; +import { AppSidebarModeSync } from "./app-sidebar-mode-sync"; +import { APP_SIDEBAR_MODE } from "./types"; + +describe("AppSidebarModeSync", () => { + beforeEach(() => { + useAppSidebarMode.setState({ mode: APP_SIDEBAR_MODE.CHAT }); + }); + + it("restores the requested sidebar mode when a route mounts", () => { + // Given / When + render(); + + // Then + expect(useAppSidebarMode.getState().mode).toBe(APP_SIDEBAR_MODE.BROWSE); + }); +}); diff --git a/ui/components/layout/app-sidebar/app-sidebar-mode-sync.tsx b/ui/components/layout/app-sidebar/app-sidebar-mode-sync.tsx new file mode 100644 index 0000000000..c97d84a14d --- /dev/null +++ b/ui/components/layout/app-sidebar/app-sidebar-mode-sync.tsx @@ -0,0 +1,20 @@ +"use client"; + +import { useMountEffect } from "@/hooks/use-mount-effect"; + +import { useAppSidebarMode } from "./app-sidebar-mode-store"; +import type { AppSidebarMode } from "./types"; + +interface AppSidebarModeSyncProps { + mode: AppSidebarMode; +} + +export function AppSidebarModeSync({ mode }: AppSidebarModeSyncProps) { + const setMode = useAppSidebarMode((state) => state.setMode); + + useMountEffect(() => { + setMode(mode); + }); + + return null; +} diff --git a/ui/components/layout/app-sidebar/app-sidebar-mode-toggle.tsx b/ui/components/layout/app-sidebar/app-sidebar-mode-toggle.tsx new file mode 100644 index 0000000000..0631d09dbf --- /dev/null +++ b/ui/components/layout/app-sidebar/app-sidebar-mode-toggle.tsx @@ -0,0 +1,115 @@ +"use client"; + +import { Home } from "lucide-react"; +import { useRouter } from "next/navigation"; + +import { LighthouseIcon } from "@/components/icons/Icons"; +import { Badge } from "@/components/shadcn/badge/badge"; +import { NavigationButton } from "@/components/shadcn/navigation-button"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/shadcn/tooltip"; +import { useCloudUpgradeStore } from "@/store"; +import { CLOUD_UPGRADE_FEATURE } from "@/types/cloud-upgrade"; + +import { useAppSidebarMode } from "./app-sidebar-mode-store"; +import { + APP_SIDEBAR_MODE, + type AppSidebarMode, + type AppSidebarSelectionHandler, +} from "./types"; + +interface AppSidebarModeToggleProps { + chatEnabled: boolean; + onSelect?: AppSidebarSelectionHandler; +} + +const MODES = [ + { + value: APP_SIDEBAR_MODE.BROWSE, + label: "Home", + icon: Home, + }, + { + value: APP_SIDEBAR_MODE.CHAT, + label: "Chat", + icon: LighthouseIcon, + }, +] as const; + +export function AppSidebarModeToggle({ + chatEnabled, + onSelect, +}: AppSidebarModeToggleProps) { + const router = useRouter(); + const mode = useAppSidebarMode((state) => state.mode); + const setMode = useAppSidebarMode((state) => state.setMode); + const openCloudUpgrade = useCloudUpgradeStore( + (state) => state.openCloudUpgrade, + ); + + const selectMode = (nextMode: AppSidebarMode) => { + const isChatUpsell = nextMode === APP_SIDEBAR_MODE.CHAT && !chatEnabled; + + if (isChatUpsell) { + openCloudUpgrade( + CLOUD_UPGRADE_FEATURE.LIGHTHOUSE_AI, + onSelect?.() ?? undefined, + ); + return; + } + + setMode(nextMode); + onSelect?.(); + + if (nextMode === APP_SIDEBAR_MODE.CHAT) { + router.push("/lighthouse"); + } + }; + + return ( +
+ {MODES.map((item) => { + const Icon = item.icon; + const isActive = item.value === mode; + const isCloudUpsell = + item.value === APP_SIDEBAR_MODE.CHAT && !chatEnabled; + const button = ( + selectMode(item.value)} + > + + ); + + if (!isCloudUpsell) return button; + + return ( + + {button} + + Available in Prowler Cloud + + + ); + })} +
+ ); +} diff --git a/ui/components/layout/app-sidebar/app-sidebar.tsx b/ui/components/layout/app-sidebar/app-sidebar.tsx new file mode 100644 index 0000000000..6864cd64a9 --- /dev/null +++ b/ui/components/layout/app-sidebar/app-sidebar.tsx @@ -0,0 +1,10 @@ +import { AppSidebarContent } from "./app-sidebar-content"; + +export function AppSidebar() { + return ( + + ); +} diff --git a/ui/components/layout/app-sidebar/index.ts b/ui/components/layout/app-sidebar/index.ts new file mode 100644 index 0000000000..5ee953a19d --- /dev/null +++ b/ui/components/layout/app-sidebar/index.ts @@ -0,0 +1,5 @@ +export { AppSidebar } from "./app-sidebar"; +export { AppSidebarModeSync } from "./app-sidebar-mode-sync"; +export { MobileAppSidebar } from "./mobile-app-sidebar"; +export type { AppSidebarMode } from "./types"; +export { APP_SIDEBAR_MODE } from "./types"; diff --git a/ui/components/layout/app-sidebar/launch-scan-action.tsx b/ui/components/layout/app-sidebar/launch-scan-action.tsx new file mode 100644 index 0000000000..1da429811f --- /dev/null +++ b/ui/components/layout/app-sidebar/launch-scan-action.tsx @@ -0,0 +1,57 @@ +"use client"; + +import { ScanLine } from "lucide-react"; +import Link from "next/link"; +import { usePathname } from "next/navigation"; + +import { Button } from "@/components/shadcn/button/button"; +import { LAUNCH_SCAN_HREF } from "@/lib/scans-navigation"; +import { useScansStore } from "@/store"; + +import type { AppSidebarSelectionHandler } from "./types"; + +interface LaunchScanActionProps { + onSelect?: AppSidebarSelectionHandler; +} + +function LaunchScanContent() { + return ( + <> +