mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-10-24 04:14:18 +00:00
feat(ui): gate the Issues feature behind the issues-feature flag (#13090)
This commit is contained in:
1 parent
ba0e78188b
commit
7f9eb2cb11
12 files changed
+318
-26
No files matched your search
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}</>;
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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`;
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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];
|
||||
Reference in new issue
Block a user