From 7f9eb2cb11ea931f54acccb6e49152358ccf949d Mon Sep 17 00:00:00 2001
From: Alejandro Bailo <59607668+alejandrobailo@users.noreply.github.com>
Date: Mon, 19 Oct 2026 16:00:26 +0200
Subject: [PATCH] feat(ui): gate the Issues feature behind the issues-feature
flag (#13090)
---
ui/app/(prowler)/issues/page.tsx | 43 ++++++---
ui/app/(prowler)/page.tsx | 18 ++--
.../app-sidebar/app-sidebar-content.test.tsx | 34 ++++++++
.../app-sidebar/app-sidebar-content.tsx | 3 +
.../app-sidebar/navigation-config.test.ts | 34 ++++++++
.../layout/app-sidebar/navigation-config.ts | 5 +-
.../shared/feature-flag-gate.test.tsx | 87 +++++++++++++++++++
ui/components/shared/feature-flag-gate.tsx | 35 ++++++++
ui/lib/feature-flags/client.test.ts | 40 ++++++++-
ui/lib/feature-flags/client.ts | 21 ++++-
ui/lib/feature-flags/shared.test.ts | 12 ++-
ui/lib/feature-flags/shared.ts | 12 +++
12 files changed, 318 insertions(+), 26 deletions(-)
create mode 100644 ui/components/shared/feature-flag-gate.test.tsx
create mode 100644 ui/components/shared/feature-flag-gate.tsx
diff --git a/ui/app/(prowler)/issues/page.tsx b/ui/app/(prowler)/issues/page.tsx
index 5280a9f271..b9558f7f2a 100644
--- a/ui/app/(prowler)/issues/page.tsx
+++ b/ui/app/(prowler)/issues/page.tsx
@@ -7,6 +7,8 @@ import { getAllProviders } from "@/actions/providers";
import { ProviderAccountSelectors } from "@/components/filters/provider-account-selectors";
import { ProviderGroupSelector } from "@/components/filters/provider-group-selector";
import { ContentLayout } from "@/components/shadcn/content-layout";
+import { FeatureFlagGate } from "@/components/shared/feature-flag-gate";
+import { FEATURE_FLAG } from "@/lib/feature-flags";
import { isCloud } from "@/lib/shared/env";
import { SearchParamsProps } from "@/types";
@@ -27,22 +29,39 @@ export default async function Issues({
return (
}>
- Loading issues…
+
+ Issues is not available for this account yet.
+
}
>
-
-
-
-
+
+ Loading issues…
+
}
- />
-
+ >
+
+
+
+
+ }
+ />
+
+
);
}
diff --git a/ui/app/(prowler)/page.tsx b/ui/app/(prowler)/page.tsx
index 9df682c902..32201e0475 100644
--- a/ui/app/(prowler)/page.tsx
+++ b/ui/app/(prowler)/page.tsx
@@ -11,7 +11,9 @@ import {
AppSidebarModeSync,
} from "@/components/layout/app-sidebar";
import { ContentLayout } from "@/components/shadcn/content-layout";
+import { FeatureFlagGate } from "@/components/shared/feature-flag-gate";
import { DOCS_URLS } from "@/lib/external-urls";
+import { FEATURE_FLAG } from "@/lib/feature-flags";
import { isCloud } from "@/lib/shared/env";
import { SearchParamsProps } from "@/types";
@@ -103,9 +105,11 @@ export default async function Home({
{isCloud() && (
- }>
-
-
+
+ }>
+
+
+
)}
}>
@@ -154,9 +158,11 @@ export default async function Home({
{isCloud() && (
- }>
-
-
+
+ }>
+
+
+
)}
}>
diff --git a/ui/components/layout/app-sidebar/app-sidebar-content.test.tsx b/ui/components/layout/app-sidebar/app-sidebar-content.test.tsx
index 055d173ae3..d6e9773121 100644
--- a/ui/components/layout/app-sidebar/app-sidebar-content.test.tsx
+++ b/ui/components/layout/app-sidebar/app-sidebar-content.test.tsx
@@ -10,12 +10,14 @@ import { useAppSidebarMode } from "./app-sidebar-mode-store";
import { APP_SIDEBAR_MODE } from "./types";
const {
+ issuesFlagValue,
openCloudUpgradeMock,
openLaunchScanModalMock,
pathnameValue,
permissionsValue,
pushMock,
} = vi.hoisted(() => ({
+ issuesFlagValue: { current: false },
openCloudUpgradeMock: vi.fn(),
openLaunchScanModalMock: vi.fn(),
pathnameValue: { current: "/findings" },
@@ -36,6 +38,11 @@ vi.mock("@/hooks/use-runtime-config", () => ({
useRuntimeConfig: () => ({ apiDocsUrl: "https://local.example/docs" }),
}));
+vi.mock("@/lib/feature-flags", async (importOriginal) => ({
+ ...(await importOriginal()),
+ useFeatureFlag: () => issuesFlagValue.current,
+}));
+
vi.mock("@/store", () => ({
useScansStore: (
selector: (state: {
@@ -57,6 +64,7 @@ describe("AppSidebarContent", () => {
beforeEach(() => {
pathnameValue.current = "/findings";
permissionsValue.current = { manage_providers: true };
+ issuesFlagValue.current = false;
pushMock.mockClear();
openCloudUpgradeMock.mockClear();
openLaunchScanModalMock.mockClear();
@@ -113,6 +121,32 @@ describe("AppSidebarContent", () => {
);
});
+ it("shows Issues navigation in Cloud once the feature flag is on", () => {
+ // Given
+ vi.stubEnv("UI_CLOUD_ENABLED", "true");
+ issuesFlagValue.current = true;
+
+ // When
+ render();
+
+ // Then
+ expect(screen.getByRole("link", { name: /Issues/ })).toHaveAttribute(
+ "href",
+ "/issues",
+ );
+ });
+
+ it("hides Issues navigation in Cloud while the feature flag is off", () => {
+ // Given / When
+ vi.stubEnv("UI_CLOUD_ENABLED", "true");
+ render();
+
+ // Then
+ expect(
+ screen.queryByRole("link", { name: /Issues/ }),
+ ).not.toBeInTheDocument();
+ });
+
it("hides Registry navigation without a server eligibility decision", () => {
// Given / When
vi.stubEnv("UI_CLOUD_ENABLED", "true");
diff --git a/ui/components/layout/app-sidebar/app-sidebar-content.tsx b/ui/components/layout/app-sidebar/app-sidebar-content.tsx
index e98e0cb9b3..046e54bc64 100644
--- a/ui/components/layout/app-sidebar/app-sidebar-content.tsx
+++ b/ui/components/layout/app-sidebar/app-sidebar-content.tsx
@@ -7,6 +7,7 @@ import { LighthouseV2SidebarChat } from "@/app/(prowler)/lighthouse/_components/
import { ProwlerBrand } from "@/components/icons";
import { useAuth } from "@/hooks";
import { useRuntimeConfig } from "@/hooks/use-runtime-config";
+import { FEATURE_FLAG, useFeatureFlag } from "@/lib/feature-flags";
import { isCloud } from "@/lib/shared/env";
import { useUIStore } from "@/store/ui/store";
@@ -30,11 +31,13 @@ export function AppSidebarContent({ onSelect }: AppSidebarContentProps) {
const { apiDocsUrl, cloudBillingEnabled } = useRuntimeConfig();
const mode = useHydratedAppSidebarMode();
const isCloudEnvironment = isCloud();
+ const issuesEnabled = useFeatureFlag(FEATURE_FLAG.ISSUES);
const sections = getNavigationConfig({
pathname,
apiDocsUrl,
cloudBillingEnabled,
registryEligible,
+ issuesEnabled,
permissions,
});
const showChat = isCloudEnvironment && mode === APP_SIDEBAR_MODE.CHAT;
diff --git a/ui/components/layout/app-sidebar/navigation-config.test.ts b/ui/components/layout/app-sidebar/navigation-config.test.ts
index 5eceb3b38e..57793169f3 100644
--- a/ui/components/layout/app-sidebar/navigation-config.test.ts
+++ b/ui/components/layout/app-sidebar/navigation-config.test.ts
@@ -106,6 +106,39 @@ describe("getNavigationConfig", () => {
);
});
+ it("hides Issues in Cloud until the feature flag resolves on", () => {
+ // Given
+ vi.stubEnv("UI_CLOUD_ENABLED", "true");
+
+ // When
+ const labels = getNavigationConfig({
+ pathname: "/",
+ apiDocsUrl: null,
+ })
+ .flatMap((section) => section.items)
+ .map((item) => item.label);
+
+ // Then
+ expect(labels).not.toContain("Issues");
+ });
+
+ it("never shows Issues in Local Server, even with the flag on", () => {
+ // Given
+ vi.stubEnv("UI_CLOUD_ENABLED", "false");
+
+ // When
+ const labels = getNavigationConfig({
+ pathname: "/",
+ apiDocsUrl: null,
+ issuesEnabled: true,
+ })
+ .flatMap((section) => section.items)
+ .map((item) => item.label);
+
+ // Then
+ expect(labels).not.toContain("Issues");
+ });
+
it("uses Cloud destinations and current New badges", () => {
// Given
vi.stubEnv("UI_CLOUD_ENABLED", "true");
@@ -114,6 +147,7 @@ describe("getNavigationConfig", () => {
const sections = getNavigationConfig({
pathname: "/scans/config",
apiDocsUrl: "https://ignored.example/docs",
+ issuesEnabled: true,
});
const items = sections.flatMap((section) => section.items);
const configuration = items.find((item) => item.label === "Configuration");
diff --git a/ui/components/layout/app-sidebar/navigation-config.ts b/ui/components/layout/app-sidebar/navigation-config.ts
index 9fb7d5b010..9c7386c51d 100644
--- a/ui/components/layout/app-sidebar/navigation-config.ts
+++ b/ui/components/layout/app-sidebar/navigation-config.ts
@@ -35,6 +35,8 @@ interface NavigationConfigOptions {
apiDocsUrl?: string | null;
cloudBillingEnabled?: boolean;
registryEligible?: boolean;
+ // PostHog FEATURE_FLAG.ISSUES, resolved by the caller; Cloud only.
+ issuesEnabled?: boolean;
permissions?: RolePermissionAttributes;
}
@@ -112,6 +114,7 @@ export function getNavigationConfig({
apiDocsUrl = null,
cloudBillingEnabled = false,
registryEligible = false,
+ issuesEnabled = false,
permissions,
}: NavigationConfigOptions): NavigationSection[] {
const isCloudEnvironment = isCloud();
@@ -161,7 +164,7 @@ export function getNavigationConfig({
icon: Tag,
active: isRouteActive(pathname, "/findings"),
},
- ...(isCloudEnvironment
+ ...(isCloudEnvironment && issuesEnabled
? [
{
kind: NAVIGATION_ITEM_KIND.LINK,
diff --git a/ui/components/shared/feature-flag-gate.test.tsx b/ui/components/shared/feature-flag-gate.test.tsx
new file mode 100644
index 0000000000..a4eeb7d1f6
--- /dev/null
+++ b/ui/components/shared/feature-flag-gate.test.tsx
@@ -0,0 +1,87 @@
+import { render, screen } from "@testing-library/react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+const { useFeatureFlagStateMock } = vi.hoisted(() => ({
+ useFeatureFlagStateMock: vi.fn(),
+}));
+
+vi.mock("@/lib/feature-flags", async (importOriginal) => ({
+ ...(await importOriginal()),
+ useFeatureFlagState: useFeatureFlagStateMock,
+}));
+
+import { FEATURE_FLAG, FEATURE_FLAG_STATE } from "@/lib/feature-flags";
+
+import { FeatureFlagGate } from "./feature-flag-gate";
+
+const renderGate = () =>
+ render(
+ off state}
+ pending={pending state
}
+ >
+ on state
+ ,
+ );
+
+describe("FeatureFlagGate", () => {
+ beforeEach(() => {
+ useFeatureFlagStateMock.mockReset();
+ });
+
+ it("should ask for the given flag", () => {
+ useFeatureFlagStateMock.mockReturnValue(FEATURE_FLAG_STATE.OFF);
+
+ renderGate();
+
+ expect(useFeatureFlagStateMock).toHaveBeenCalledWith("issues-feature");
+ });
+
+ it("should render the children when the flag is on", () => {
+ useFeatureFlagStateMock.mockReturnValue(FEATURE_FLAG_STATE.ON);
+
+ renderGate();
+
+ expect(screen.getByText("on state")).toBeInTheDocument();
+ expect(screen.queryByText("off state")).not.toBeInTheDocument();
+ expect(screen.queryByText("pending state")).not.toBeInTheDocument();
+ });
+
+ it("should render the fallback when the flag is off", () => {
+ useFeatureFlagStateMock.mockReturnValue(FEATURE_FLAG_STATE.OFF);
+
+ renderGate();
+
+ expect(screen.getByText("off state")).toBeInTheDocument();
+ expect(screen.queryByText("on state")).not.toBeInTheDocument();
+ });
+
+ it("should render the pending slot while the flag is unresolved", () => {
+ useFeatureFlagStateMock.mockReturnValue(FEATURE_FLAG_STATE.PENDING);
+
+ renderGate();
+
+ expect(screen.getByText("pending state")).toBeInTheDocument();
+ expect(screen.queryByText("on state")).not.toBeInTheDocument();
+ expect(screen.queryByText("off state")).not.toBeInTheDocument();
+ });
+
+ it.each([
+ ["off", FEATURE_FLAG_STATE.OFF],
+ ["pending", FEATURE_FLAG_STATE.PENDING],
+ ])(
+ "should render nothing by default when the flag is %s",
+ (_label, state) => {
+ useFeatureFlagStateMock.mockReturnValue(state);
+
+ const { container } = render(
+
+ on state
+ ,
+ );
+
+ expect(container).toBeEmptyDOMElement();
+ },
+ );
+});
diff --git a/ui/components/shared/feature-flag-gate.tsx b/ui/components/shared/feature-flag-gate.tsx
new file mode 100644
index 0000000000..3c3a24f01a
--- /dev/null
+++ b/ui/components/shared/feature-flag-gate.tsx
@@ -0,0 +1,35 @@
+"use client";
+
+import type { ReactNode } from "react";
+
+import {
+ FEATURE_FLAG_STATE,
+ type FeatureFlag,
+ useFeatureFlagState,
+} from "@/lib/feature-flags";
+
+interface FeatureFlagGateProps {
+ flag: FeatureFlag;
+ children: ReactNode;
+ // Rendered once the flag resolves to off. Defaults to nothing.
+ fallback?: ReactNode;
+ // Rendered while the flag is unresolved. Defaults to nothing so an enabled
+ // user never sees the off-state fallback flash before PostHog answers, and
+ // a build that never initializes PostHog shows neither branch.
+ pending?: ReactNode;
+}
+
+// Client gate over a boolean feature flag. Server-rendered children are still
+// produced on the server (RSC payload); the gate only decides what the
+// browser shows, so keep it for presentation, not for authorization.
+export function FeatureFlagGate({
+ flag,
+ children,
+ fallback = null,
+ pending = null,
+}: FeatureFlagGateProps) {
+ const state = useFeatureFlagState(flag);
+ if (state === FEATURE_FLAG_STATE.ON) return <>{children}>;
+ if (state === FEATURE_FLAG_STATE.PENDING) return <>{pending}>;
+ return <>{fallback}>;
+}
diff --git a/ui/lib/feature-flags/client.test.ts b/ui/lib/feature-flags/client.test.ts
index d73c122ee0..8a9e0829ed 100644
--- a/ui/lib/feature-flags/client.test.ts
+++ b/ui/lib/feature-flags/client.test.ts
@@ -13,11 +13,47 @@ vi.mock("posthog-js/react", () => ({
useFeatureFlagVariantKey: useFeatureFlagVariantKeyMock,
}));
-import { useFeatureFlag, useFeatureFlagVariant } from "./client";
-import { FEATURE_FLAG } from "./shared";
+import {
+ useFeatureFlag,
+ useFeatureFlagState,
+ useFeatureFlagVariant,
+} from "./client";
+import { FEATURE_FLAG, FEATURE_FLAG_STATE } from "./shared";
const VARIANTS = ["card", "dropdown"] as const;
+describe("useFeatureFlagState", () => {
+ beforeEach(() => {
+ useFeatureFlagEnabledMock.mockReset();
+ });
+
+ it("should pass the flag key through to PostHog", () => {
+ useFeatureFlagEnabledMock.mockReturnValue(undefined);
+
+ renderHook(() => useFeatureFlagState(FEATURE_FLAG.ISSUES));
+
+ expect(useFeatureFlagEnabledMock).toHaveBeenCalledWith("issues-feature");
+ });
+
+ it.each([
+ ["on", true, FEATURE_FLAG_STATE.ON],
+ ["off", false, FEATURE_FLAG_STATE.OFF],
+ [
+ "pending (not initialized or still loading)",
+ undefined,
+ FEATURE_FLAG_STATE.PENDING,
+ ],
+ ])("should report %s", (_label, flagValue, expected) => {
+ useFeatureFlagEnabledMock.mockReturnValue(flagValue);
+
+ const { result } = renderHook(() =>
+ useFeatureFlagState(FEATURE_FLAG.ISSUES),
+ );
+
+ expect(result.current).toBe(expected);
+ });
+});
+
describe("useFeatureFlag", () => {
beforeEach(() => {
useFeatureFlagEnabledMock.mockReset();
diff --git a/ui/lib/feature-flags/client.ts b/ui/lib/feature-flags/client.ts
index 8ec4eb2ddf..6d3eca1d6f 100644
--- a/ui/lib/feature-flags/client.ts
+++ b/ui/lib/feature-flags/client.ts
@@ -5,7 +5,11 @@ import {
useFeatureFlagVariantKey,
} from "posthog-js/react";
-import type { FeatureFlag } from "./shared";
+import {
+ FEATURE_FLAG_STATE,
+ type FeatureFlag,
+ type FeatureFlagState,
+} from "./shared";
// The only place in ui/ that imports posthog-js/react's flag hooks, so
// swapping the flag provider later is a one-file change. The hooks read the
@@ -13,10 +17,19 @@ import type { FeatureFlag } from "./shared";
// no provider here: whoever initializes PostHog (the cloud fork) feeds them.
// OSS builds that never init PostHog resolve every read to its fallback.
-// Boolean flag. Only a resolved `true` counts: undefined (still loading),
-// disabled PostHog and a disabled flag all read as false.
+// Boolean flag with its resolution state. PostHog reports `undefined` until
+// the flags request lands (or forever when it was never initialized), `true`
+// or `false` afterwards; a flag missing from the project resolves to OFF.
+export function useFeatureFlagState(flag: FeatureFlag): FeatureFlagState {
+ const value = useFeatureFlagEnabled(flag);
+ if (value === undefined) return FEATURE_FLAG_STATE.PENDING;
+ return value ? FEATURE_FLAG_STATE.ON : FEATURE_FLAG_STATE.OFF;
+}
+
+// Boolean flag. Only a resolved `true` counts: pending, disabled PostHog and
+// a disabled flag all read as false.
export function useFeatureFlag(flag: FeatureFlag): boolean {
- return useFeatureFlagEnabled(flag) === true;
+ return useFeatureFlagState(flag) === FEATURE_FLAG_STATE.ON;
}
// Multivariate flag. Returns the resolved key when it is one of `variants`;
diff --git a/ui/lib/feature-flags/shared.test.ts b/ui/lib/feature-flags/shared.test.ts
index dbace91a1e..e134cb41a7 100644
--- a/ui/lib/feature-flags/shared.test.ts
+++ b/ui/lib/feature-flags/shared.test.ts
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
-import { FEATURE_FLAG } from "./shared";
+import { FEATURE_FLAG, FEATURE_FLAG_STATE } from "./shared";
describe("FEATURE_FLAG", () => {
it("should map each flag to the key PostHog knows it by", () => {
@@ -17,3 +17,13 @@ describe("FEATURE_FLAG", () => {
expect(new Set(keys).size).toBe(keys.length);
});
});
+
+describe("FEATURE_FLAG_STATE", () => {
+ it("should distinguish the unresolved state from a resolved off", () => {
+ expect(FEATURE_FLAG_STATE).toEqual({
+ PENDING: "pending",
+ ON: "on",
+ OFF: "off",
+ });
+ });
+});
diff --git a/ui/lib/feature-flags/shared.ts b/ui/lib/feature-flags/shared.ts
index 72dabd38f1..9828d00e5c 100644
--- a/ui/lib/feature-flags/shared.ts
+++ b/ui/lib/feature-flags/shared.ts
@@ -15,3 +15,15 @@ export const FEATURE_FLAG = {
} as const;
export type FeatureFlag = (typeof FEATURE_FLAG)[keyof typeof FEATURE_FLAG];
+
+// Resolution state of a boolean flag. PENDING covers both "flags still
+// loading" and "PostHog never initialized" (OSS builds): neither case can
+// tell whether the flag is on, so gates render neither branch yet.
+export const FEATURE_FLAG_STATE = {
+ PENDING: "pending",
+ ON: "on",
+ OFF: "off",
+} as const;
+
+export type FeatureFlagState =
+ (typeof FEATURE_FLAG_STATE)[keyof typeof FEATURE_FLAG_STATE];