Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion ui/app/(prowler)/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
OnboardingGate,
OnboardingSequenceBanner,
} from "@/components/onboarding";
import { RegistryEligibilityProvider } from "@/components/registry/registry-eligibility-provider";
import { RuntimePublicConfig } from "@/components/runtime-config/runtime-public-config";
import { NavigationProgress } from "@/components/shadcn/navigation-progress";
import { Toaster } from "@/components/shadcn/toast";
Expand Down Expand Up @@ -108,7 +109,9 @@ export default async function RootLayout({
<OnboardingSequenceBanner hasCompletedScan={hasCompletedScan} />
</>
)}
<MainLayout>{children}</MainLayout>
<RegistryEligibilityProvider>
<MainLayout>{children}</MainLayout>
</RegistryEligibilityProvider>
{cloudEnabled && <FeedbackSurvey />}
{/* Always mounted: it hosts the detail (finding/resource) views in
every deployment; the AI tab inside is cloud-gated on its own. */}
Expand Down
18 changes: 18 additions & 0 deletions ui/app/(prowler)/registry/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { redirect } from "next/navigation";

import { auth } from "@/auth.config";
import { RegistryAccessBoundary } from "@/components/registry/registry-access-boundary";
import { REGISTRY_ACCESS } from "@/lib/registry/access";
import { evaluateRegistryAccess } from "@/lib/registry/access.server";

export const dynamic = "force-dynamic";

export default async function RegistryPage() {
const access = await evaluateRegistryAccess((await auth())?.accessToken);
if (access.status !== REGISTRY_ACCESS.ELIGIBLE) redirect("/profile");
return (
<RegistryAccessBoundary initialLeaseDurationMs={access.leaseDurationMs}>
{null}
</RegistryAccessBoundary>
);
}
16 changes: 16 additions & 0 deletions ui/components/layout/app-sidebar/app-sidebar-content.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@ vi.mock("@/hooks/use-runtime-config", () => ({
useRuntimeConfig: () => ({ apiDocsUrl: "https://local.example/docs" }),
}));

vi.mock("@/components/registry/registry-eligibility-provider", () => ({
useRegistryEligibility: () => ({ isEligible: true }),
}));

vi.mock("@/store", () => ({
useScansStore: (
selector: (state: {
Expand Down Expand Up @@ -89,6 +93,18 @@ describe("AppSidebarContent", () => {
expect(screen.getAllByText("Cloud").length).toBeGreaterThan(0);
});

it("uses the eligibility lease for Registry navigation", () => {
// Given / When
vi.stubEnv("UI_CLOUD_ENABLED", "true");
render(<AppSidebarContent />);

// Then
expect(screen.getByRole("link", { name: /Registry/ })).toHaveAttribute(
"href",
"/registry",
);
});

it("keeps the existing Lighthouse chat sidebar in Cloud Chat mode", () => {
// Given
vi.stubEnv("UI_CLOUD_ENABLED", "true");
Expand Down
3 changes: 3 additions & 0 deletions ui/components/layout/app-sidebar/app-sidebar-content.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { usePathname } from "next/navigation";

import { LighthouseV2SidebarChat } from "@/app/(prowler)/lighthouse/_components/navigation";
import { ProwlerBrand } from "@/components/icons";
import { useRegistryEligibility } from "@/components/registry/registry-eligibility-provider";
import { useAuth } from "@/hooks";
import { useRuntimeConfig } from "@/hooks/use-runtime-config";
import { isCloud } from "@/lib/shared/env";
Expand All @@ -24,13 +25,15 @@ interface AppSidebarContentProps {
export function AppSidebarContent({ onSelect }: AppSidebarContentProps) {
const pathname = usePathname();
const { permissions } = useAuth();
const { isEligible: registryEligible } = useRegistryEligibility();
const { apiDocsUrl, cloudBillingEnabled } = useRuntimeConfig();
const mode = useAppSidebarMode((state) => state.mode);
const isCloudEnvironment = isCloud();
const sections = getNavigationConfig({
pathname,
apiDocsUrl,
cloudBillingEnabled,
registryEligible,
permissions,
});
const showChat = isCloudEnvironment && mode === APP_SIDEBAR_MODE.CHAT;
Expand Down
19 changes: 19 additions & 0 deletions ui/components/layout/app-sidebar/navigation-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,25 @@ describe("getNavigationConfig", () => {
]);
});

it("shows the New Registry link for a fresh eligibility lease", () => {
// Given / When
vi.stubEnv("UI_CLOUD_ENABLED", "true");
const registry = getNavigationConfig({
pathname: "/registry",
apiDocsUrl: null,
registryEligible: true,
})
.flatMap((section) => section.items)
.find((item) => item.label === "Registry");

// Then
expect(registry).toMatchObject({
href: "/registry",
active: true,
highlight: true,
});
});

it("keeps the Cloud Billing destination for users with billing permission", () => {
// Given
vi.stubEnv("UI_CLOUD_ENABLED", "true");
Expand Down
15 changes: 15 additions & 0 deletions ui/components/layout/app-sidebar/navigation-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
GitBranch,
LayoutGrid,
MessageCircleQuestion,
Package,
Settings,
ShieldCheck,
SquareChartGantt,
Expand Down Expand Up @@ -32,6 +33,7 @@ interface NavigationConfigOptions {
pathname: string;
apiDocsUrl?: string | null;
cloudBillingEnabled?: boolean;
registryEligible?: boolean;
permissions?: RolePermissionAttributes;
}

Expand Down Expand Up @@ -108,6 +110,7 @@ export function getNavigationConfig({
pathname,
apiDocsUrl = null,
cloudBillingEnabled = false,
registryEligible = false,
permissions,
}: NavigationConfigOptions): NavigationSection[] {
const isCloudEnvironment = isCloud();
Expand Down Expand Up @@ -180,6 +183,18 @@ export function getNavigationConfig({
icon: Warehouse,
active: isRouteActive(pathname, "/resources"),
},
...(registryEligible
? [
{
kind: NAVIGATION_ITEM_KIND.LINK,
href: "/registry",
label: "Registry",
icon: Package,
active: isRouteActive(pathname, "/registry"),
highlight: true,
} as const,
]
: []),
],
},
{
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { describe, expect, it, vi } from "vitest";

import { render } from "@/__tests__/render-browser";

vi.mock("@/lib/registry/access.server", () => ({
refreshRegistryEligibility: () => Promise.resolve({ status: "ineligible" }),
}));

import { RegistryAccessBoundary } from "./registry-access-boundary";
import { RegistryEligibilityProvider } from "./registry-eligibility-provider";

describe("RegistryAccessBoundary", () => {
it("unmounts protected Registry state after client access denial", async () => {
// Given / When
await render(
<RegistryEligibilityProvider>
<RegistryAccessBoundary initialLeaseDurationMs={30_000}>
<p>Protected Registry state</p>
</RegistryAccessBoundary>
</RegistryEligibilityProvider>,
);

// Then
await expect
.poll(() => document.body.textContent)
.not.toContain("Protected Registry state");
});
});
39 changes: 39 additions & 0 deletions ui/components/registry/registry-access-boundary.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
"use client";

import { useRouter } from "next/navigation";
import { type ReactNode, useEffect, useRef, useState } from "react";

import { REGISTRY_ACCESS } from "@/lib/registry/access";

import { useRegistryEligibility } from "./registry-eligibility-provider";

export function RegistryAccessBoundary({
children,
initialLeaseDurationMs,
}: {
children: ReactNode;
initialLeaseDurationMs: number;
}) {
const router = useRouter();
const { generation, isEligible, status } = useRegistryEligibility();
const expiresAt = useRef(Date.now() + initialLeaseDurationMs);
const [now, setNow] = useState(Date.now());
Comment on lines +19 to +20

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I may be missing some context about the requirements, but i see a lot of timing check if the user can access the registry, why is so crucial to be aware this fast? Should not be enought on each user token refresh and get the current permissions there?

const allowed =
isEligible ||
(status === REGISTRY_ACCESS.UNKNOWN &&
generation <= 1 &&
now < expiresAt.current);

useEffect(() => {
const timer = window.setTimeout(
() => setNow(Date.now()),
Math.max(0, expiresAt.current - Date.now()),
);
return () => window.clearTimeout(timer);
}, []);
useEffect(() => {
if (!allowed) router.replace("/profile");
}, [allowed, router]);

return allowed ? children : null;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { describe, expect, it, vi } from "vitest";

import { render } from "@/__tests__/render-browser";

const { refreshAccessMock } = vi.hoisted(() => ({
refreshAccessMock: vi.fn(),
}));
vi.mock("@/lib/registry/access.server", () => ({
refreshRegistryEligibility: refreshAccessMock,
}));

import {
RegistryEligibilityProvider,
useRegistryEligibility,
} from "./registry-eligibility-provider";

function Probe() {
const { isEligible, status } = useRegistryEligibility();
return <p>{isEligible ? "eligible" : status}</p>;
}

const renderProbe = () =>
render(
<RegistryEligibilityProvider>
<Probe />
</RegistryEligibilityProvider>,
);

describe("RegistryEligibilityProvider", () => {
it("requires a server lease, renews visible access, and expires hidden access", async () => {
// Given
vi.useFakeTimers();
refreshAccessMock.mockResolvedValue({
status: "eligible",
leaseDurationMs: 30_000,
});
const view = await renderProbe();
await expect.element(view.getByText("eligible")).toBeVisible();

// When
await vi.advanceTimersByTimeAsync(15_000);
Object.defineProperty(document, "visibilityState", {
configurable: true,
value: "hidden",
});
await vi.advanceTimersByTimeAsync(30_000);

// Then
expect(refreshAccessMock).toHaveBeenCalledTimes(2);
await expect.element(view.getByText("unknown")).toBeVisible();
vi.useRealTimers();
Object.defineProperty(document, "visibilityState", {
configurable: true,
value: "visible",
});
});

it("rejects a late lease after a newer foreground denial", async () => {
// Given
let resolveFirst!: (value: unknown) => void;
refreshAccessMock
.mockImplementationOnce(
() =>
new Promise<unknown>((resolve) => {
resolveFirst = resolve;
}),
)
.mockResolvedValueOnce({ status: "ineligible" });
const view = await renderProbe();

// When
window.dispatchEvent(new Event("focus"));
resolveFirst!({ status: "eligible", leaseDurationMs: 30_000 });

// Then
await expect.element(view.getByText("ineligible")).toBeVisible();
});
});
Loading