feat(ui): gate the Issues feature behind the issues-feature flag (#13090)

This commit is contained in:
Alejandro Bailo authored and GitHub committed 2026-10-19 16:00:26 +02:00
1 parent ba0e78188b
commit 7f9eb2cb11
12 files changed
+318 -26

No files matched your search

+31 -12
View File
@@ -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 (
<ContentLayout title="Issues" icon={<ShieldAlert />}>
<Suspense
{/* Deployment gate above (notFound); per-account rollout here. The route
stays reachable by URL, so the off state explains itself. */}
<FeatureFlagGate
flag={FEATURE_FLAG.ISSUES}
fallback={
<p className="text-text-neutral-tertiary text-sm">Loading issues…</p>
<p className="text-text-neutral-tertiary text-sm">
Issues is not available for this account yet.
</p>
}
>
<IssuesList
searchParams={resolvedSearchParams}
filters={
// Same layout as the overview's selector row.
<div className="mb-6 grid grid-cols-1 gap-6 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
<ProviderAccountSelectors providers={providersData?.data ?? []} />
<ProviderGroupSelector groups={providerGroupsData?.data ?? []} />
</div>
<Suspense
fallback={
<p className="text-text-neutral-tertiary text-sm">
Loading issues…
</p>
}
/>
</Suspense>
>
<IssuesList
searchParams={resolvedSearchParams}
filters={
// Same layout as the overview's selector row.
<div className="mb-6 grid grid-cols-1 gap-6 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
<ProviderAccountSelectors
providers={providersData?.data ?? []}
/>
<ProviderGroupSelector
groups={providerGroupsData?.data ?? []}
/>
</div>
}
/>
</Suspense>
</FeatureFlagGate>
</ContentLayout>
);
}
+12 -6
View File
@@ -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({
</Suspense>
{isCloud() && (
<Suspense fallback={<ExploitabilityChartSkeleton />}>
<ExploitabilityChartSSR searchParams={resolvedSearchParams} />
</Suspense>
<FeatureFlagGate flag={FEATURE_FLAG.ISSUES}>
<Suspense fallback={<ExploitabilityChartSkeleton />}>
<ExploitabilityChartSSR searchParams={resolvedSearchParams} />
</Suspense>
</FeatureFlagGate>
)}
<Suspense fallback={<StatusChartSkeleton />}>
@@ -154,9 +158,11 @@ export default async function Home({
<AttackSurfaceSSR searchParams={resolvedSearchParams} />
</Suspense>
{isCloud() && (
<Suspense fallback={<TopIssuesSkeleton />}>
<TopIssuesSSR searchParams={resolvedSearchParams} />
</Suspense>
<FeatureFlagGate flag={FEATURE_FLAG.ISSUES}>
<Suspense fallback={<TopIssuesSkeleton />}>
<TopIssuesSSR searchParams={resolvedSearchParams} />
</Suspense>
</FeatureFlagGate>
)}
<Suspense fallback={<FindingSeverityOverTimeSkeleton />}>
<FindingSeverityOverTimeSSR searchParams={resolvedSearchParams} />
@@ -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<typeof import("@/lib/feature-flags")>()),
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(<AppSidebarContent />);
// 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(<AppSidebarContent />);
// 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");
@@ -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;
@@ -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");
@@ -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,
@@ -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<typeof import("@/lib/feature-flags")>()),
useFeatureFlagState: useFeatureFlagStateMock,
}));
import { FEATURE_FLAG, FEATURE_FLAG_STATE } from "@/lib/feature-flags";
import { FeatureFlagGate } from "./feature-flag-gate";
const renderGate = () =>
render(
<FeatureFlagGate
flag={FEATURE_FLAG.ISSUES}
fallback={<p>off state</p>}
pending={<p>pending state</p>}
>
<p>on state</p>
</FeatureFlagGate>,
);
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(
<FeatureFlagGate flag={FEATURE_FLAG.ISSUES}>
<p>on state</p>
</FeatureFlagGate>,
);
expect(container).toBeEmptyDOMElement();
},
);
});
@@ -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}</>;
}
+38 -2
View File
@@ -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();
+17 -4
View File
@@ -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`;
+11 -1
View File
@@ -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",
});
});
});
+12
View File
@@ -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];