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];