fix(ui): show usage-limit banner across authenticated pages

This commit is contained in:
Hugo P.Brito
2026-08-10 10:34:51 +01:00
parent 3ca3a977a9
commit cdff1cc0c6
12 changed files with 360 additions and 3 deletions
+77
View File
@@ -0,0 +1,77 @@
import { render, screen } from "@testing-library/react";
import type { ReactNode } from "react";
import { describe, expect, it, vi } from "vitest";
vi.mock("@sentry/nextjs", () => ({ getTraceData: () => ({}) }));
vi.mock("@/actions/providers", () => ({
getProviders: vi.fn().mockResolvedValue({ data: [] }),
}));
vi.mock("@/actions/scans/scans", () => ({
getScansByState: vi.fn().mockResolvedValue({ data: [] }),
}));
vi.mock("@/components/providers/usage-limit-banner.server", () => ({
UsageLimitBannerSSR: () => <div role="alert">Usage limit exceeded</div>,
}));
vi.mock("@/components/layout/main-layout/main-layout", () => ({
default: ({
usageLimitBanner,
children,
}: {
usageLimitBanner?: ReactNode;
children: ReactNode;
}) => (
<main>
{usageLimitBanner}
{children}
</main>
),
}));
vi.mock("@/components/onboarding", () => ({
OnboardingCheckpointWatcher: () => null,
OnboardingGate: () => null,
OnboardingSequenceBanner: () => null,
}));
vi.mock("@/components/runtime-config/runtime-public-config", () => ({
RuntimePublicConfig: () => null,
}));
vi.mock("@/components/shadcn/navigation-progress", () => ({
NavigationProgress: () => null,
}));
vi.mock("@/components/shadcn/toast", () => ({ Toaster: () => null }));
vi.mock("@/components/shared/task-polling-watcher", () => ({
TaskPollingWatcher: () => null,
}));
vi.mock("@/components/side-panel", () => ({ GlobalSidePanel: () => null }));
vi.mock("@/components/survey/feedback-survey", () => ({
FeedbackSurvey: () => null,
}));
vi.mock("@/config/fonts", () => ({
fontMono: { variable: "font-mono" },
fontSans: { variable: "font-sans" },
}));
vi.mock("@/config/site", () => ({
siteConfig: { name: "Prowler", description: "Prowler" },
}));
vi.mock("@/lib/shared/env", () => ({ isCloud: () => false }));
vi.mock("@/lib/utils", () => ({
cn: (...classes: string[]) => classes.join(" "),
}));
vi.mock("@/store/ui/store-initializer", () => ({
StoreInitializer: () => null,
}));
vi.mock("../providers", () => ({
Providers: ({ children }: { children: ReactNode }) => <>{children}</>,
}));
import RootLayout from "./layout";
describe("authenticated layout", () => {
it("composes the usage-limit banner into the main application shell", async () => {
// Given / When
render(await RootLayout({ children: <div>Page content</div> }));
// Then
expect(screen.getByRole("alert")).toHaveTextContent("Usage limit exceeded");
expect(screen.getByText("Page content")).toBeVisible();
});
});
+10 -1
View File
@@ -12,6 +12,7 @@ import {
OnboardingGate,
OnboardingSequenceBanner,
} from "@/components/onboarding";
import { UsageLimitBannerSSR } from "@/components/providers/usage-limit-banner.server";
import { RuntimePublicConfig } from "@/components/runtime-config/runtime-public-config";
import { NavigationProgress } from "@/components/shadcn/navigation-progress";
import { Toaster } from "@/components/shadcn/toast";
@@ -108,7 +109,15 @@ export default async function RootLayout({
<OnboardingSequenceBanner hasCompletedScan={hasCompletedScan} />
</>
)}
<MainLayout>{children}</MainLayout>
<MainLayout
usageLimitBanner={
<Suspense fallback={null}>
<UsageLimitBannerSSR allowHide className="m-4 mr-6" />
</Suspense>
}
>
{children}
</MainLayout>
{cloudEnabled && <FeedbackSurvey />}
{/* Always mounted: it hosts the detail (finding/resource) views in
every deployment; the AI tab inside is cloud-gated on its own. */}
@@ -0,0 +1 @@
Prowler Cloud usage-limit banner across authenticated application pages
@@ -1,8 +1,14 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import MainLayout from "./main-layout";
const navigationMocks = vi.hoisted(() => ({ pathname: "/findings" }));
vi.mock("next/navigation", () => ({
usePathname: () => navigationMocks.pathname,
}));
vi.mock("@/components/layout/app-sidebar", () => ({
AppSidebar: () => <aside data-testid="sidebar" />,
}));
@@ -16,6 +22,43 @@ vi.mock("@/components/findings/jira-dispatch-modal-host", () => ({
}));
describe("MainLayout", () => {
beforeEach(() => {
navigationMocks.pathname = "/findings";
});
it("renders the usage-limit banner before page content", () => {
// Given / When
render(
<MainLayout usageLimitBanner={<div role="alert">Usage limit</div>}>
<div>Page content</div>
</MainLayout>,
);
// Then
const banner = screen.getByRole("alert");
const content = screen.getByText("Page content");
expect(
banner.compareDocumentPosition(content) &
Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();
});
it("omits the usage-limit banner from billing routes", () => {
// Given
navigationMocks.pathname = "/billing/invoices";
// When
render(
<MainLayout usageLimitBanner={<div role="alert">Usage limit</div>}>
<div>Billing content</div>
</MainLayout>,
);
// Then
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
expect(screen.getByText("Billing content")).toBeVisible();
});
it("mounts the shared Cloud upgrade modal with page content", () => {
render(
<MainLayout>
@@ -16,8 +16,18 @@ import {
import { cn } from "@/lib/utils";
import { useSidePanelStore } from "@/store/side-panel";
export default function MainLayout({ children }: { children: ReactNode }) {
interface MainLayoutProps {
usageLimitBanner?: ReactNode;
children: ReactNode;
}
export default function MainLayout({
usageLimitBanner,
children,
}: MainLayoutProps) {
const pathname = usePathname();
const isBillingRoute =
pathname === "/billing" || pathname.startsWith("/billing/");
// Push (not overlay): the open side panel shrinks the page by exactly its
// (user-resizable) width so everything stays reachable. Below `sm` the
// panel overlays full-width instead, where pushing would leave no page. The
@@ -57,6 +67,7 @@ export default function MainLayout({ children }: { children: ReactNode }) {
)}
style={{ marginRight: pushWidth }}
>
{!isBillingRoute && usageLimitBanner}
<Suspense fallback={null}>{children}</Suspense>
</main>
</div>
+2
View File
@@ -9,3 +9,5 @@ export * from "./providers-accounts-view";
export * from "./providers-filters";
export * from "./radio-card";
export * from "./radio-group-provider";
export * from "./usage-limit-banner";
export * from "./usage-limit-banner.server";
@@ -0,0 +1,9 @@
import { describe, expect, it } from "vitest";
import { shouldDisplayUsageLimitBanner } from "./usage-limit-banner.resolver";
describe("shouldDisplayUsageLimitBanner", () => {
it("fails closed for deployments without a private billing resolver", async () => {
await expect(shouldDisplayUsageLimitBanner()).resolves.toBe(false);
});
});
@@ -0,0 +1,2 @@
export const shouldDisplayUsageLimitBanner = async (): Promise<boolean> =>
false;
@@ -0,0 +1,63 @@
import { render, screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
const { shouldDisplayUsageLimitBannerMock, usageLimitBannerSpy } = vi.hoisted(
() => ({
shouldDisplayUsageLimitBannerMock: vi.fn(),
usageLimitBannerSpy: vi.fn(),
}),
);
vi.mock("./usage-limit-banner.resolver", () => ({
shouldDisplayUsageLimitBanner: shouldDisplayUsageLimitBannerMock,
}));
vi.mock("./usage-limit-banner", () => ({
UsageLimitBanner: (props: unknown) => {
usageLimitBannerSpy(props);
return <div role="alert">Usage limit exceeded</div>;
},
}));
import { UsageLimitBannerSSR } from "./usage-limit-banner.server";
describe("UsageLimitBannerSSR", () => {
beforeEach(() => {
shouldDisplayUsageLimitBannerMock.mockReset();
usageLimitBannerSpy.mockReset();
});
it("renders the existing banner when the neutral resolver returns true", async () => {
// Given
shouldDisplayUsageLimitBannerMock.mockResolvedValue(true);
// When
render(
await UsageLimitBannerSSR({
allowHide: true,
showBillingButton: false,
className: "m-4",
}),
);
// Then
expect(screen.getByRole("alert")).toBeVisible();
expect(screen.getByRole("alert").parentElement).toHaveClass("m-4");
expect(usageLimitBannerSpy).toHaveBeenCalledWith({
allowHide: true,
showBillingButton: false,
});
});
it("renders nothing when the neutral resolver returns false", async () => {
// Given
shouldDisplayUsageLimitBannerMock.mockResolvedValue(false);
// When
const { container } = render(await UsageLimitBannerSSR({}));
// Then
expect(container).toBeEmptyDOMElement();
expect(usageLimitBannerSpy).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,25 @@
import { UsageLimitBanner } from "./usage-limit-banner";
import { shouldDisplayUsageLimitBanner } from "./usage-limit-banner.resolver";
interface UsageLimitBannerSSRProps {
allowHide?: boolean;
showBillingButton?: boolean;
className?: string;
}
export const UsageLimitBannerSSR = async ({
allowHide = false,
showBillingButton = true,
className,
}: UsageLimitBannerSSRProps) => {
if (!(await shouldDisplayUsageLimitBanner())) return null;
return (
<div className={className}>
<UsageLimitBanner
allowHide={allowHide}
showBillingButton={showBillingButton}
/>
</div>
);
};
@@ -0,0 +1,49 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it } from "vitest";
import { UsageLimitBanner } from "./usage-limit-banner";
describe("UsageLimitBanner", () => {
beforeEach(() => {
window.sessionStorage.clear();
});
it("preserves the existing Cloud usage-limit presentation", async () => {
// Given / When
render(<UsageLimitBanner />);
// Then
const alert = await screen.findByRole("alert");
expect(alert).toHaveClass(
"animate-fade-in",
"border-orange-500",
"bg-orange-50",
);
expect(alert.querySelector("svg")).toHaveClass("lucide-triangle-alert");
expect(screen.getByText("Usage limit exceeded")).toBeVisible();
expect(
screen.getByText(
"You have exceeded the usage limit of one provider. You can add more providers and run unlimited scans by adding a subscription.",
),
).toBeVisible();
expect(
screen.getByRole("link", { name: "Manage Billing" }),
).toHaveAttribute("href", "/billing");
});
it("preserves the existing session dismissal behavior", async () => {
// Given
const user = userEvent.setup();
render(<UsageLimitBanner allowHide />);
// When
await user.click(await screen.findByRole("button", { name: "Close" }));
// Then
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
expect(window.sessionStorage.getItem("usage-limit-banner-dismissed")).toBe(
"true",
);
});
});
@@ -0,0 +1,66 @@
"use client";
import { AlertTriangle } from "lucide-react";
import Link from "next/link";
import { useEffect, useState } from "react";
import {
Alert,
AlertDescription,
AlertTitle,
Button,
} from "@/components/shadcn";
const STORAGE_KEY = "usage-limit-banner-dismissed";
interface UsageLimitBannerProps {
allowHide?: boolean;
showBillingButton?: boolean;
}
export const UsageLimitBanner = ({
allowHide = false,
showBillingButton = true,
}: UsageLimitBannerProps) => {
const [isVisible, setIsVisible] = useState<boolean | null>(null);
useEffect(() => {
const isDismissed = sessionStorage.getItem(STORAGE_KEY) === "true";
setIsVisible(!isDismissed);
}, []);
const handleClose = () => {
sessionStorage.setItem(STORAGE_KEY, "true");
setIsVisible(false);
};
// Don't render until we've checked sessionStorage (prevents hydration mismatch)
if (isVisible === null || !isVisible) return null;
return (
<Alert
variant="warning"
onClose={allowHide ? handleClose : undefined}
className="animate-fade-in"
>
<AlertTriangle />
<AlertTitle>Usage limit exceeded</AlertTitle>
<AlertDescription className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<span>
You have exceeded the usage limit of one provider. You can add more
providers and run unlimited scans by adding a subscription.
</span>
{showBillingButton && (
<Button
asChild
variant="secondary"
size="sm"
className="w-fit shrink-0"
>
<Link href="/billing">Manage Billing</Link>
</Button>
)}
</AlertDescription>
</Alert>
);
};