feat(ui): make the Cloud flag a runtime variable (UI_CLOUD_ENABLED) (#12061)

Co-authored-by: Pablo F.G <pablo.fernandez@prowler.com>
Co-authored-by: César Arroba <19954079+cesararroba@users.noreply.github.com>
This commit is contained in:
Pablo Fernandez Guerra (PFE)
2026-07-22 11:31:22 +02:00
committed by GitHub
parent bf82e9ff3d
commit 7d2a22c45a
50 changed files with 408 additions and 147 deletions
@@ -36,6 +36,9 @@ The former build-time variables map to the new runtime variables as follows:
| `NEXT_PUBLIC_GOOGLE_TAG_MANAGER_ID` | `UI_GOOGLE_TAG_MANAGER_ID` |
| `NEXT_PUBLIC_SENTRY_DSN`, `SENTRY_DSN` | `UI_SENTRY_DSN` |
| `NEXT_PUBLIC_SENTRY_ENVIRONMENT`, `SENTRY_ENVIRONMENT` | `UI_SENTRY_ENVIRONMENT` |
| `NEXT_PUBLIC_IS_CLOUD_ENV` | `UI_CLOUD_ENABLED` |
`UI_CLOUD_ENABLED` is a plain runtime boolean flag that enables Prowler Cloud behavior when set to the exact string `"true"` and defaults to off; unlike the other renamed variables it has no legacy fallback, so `NEXT_PUBLIC_IS_CLOUD_ENV` is no longer read.
The build-time-only Sentry variables used for source-map upload — `SENTRY_ORG`, `SENTRY_PROJECT`, `SENTRY_AUTH_TOKEN`, and `SENTRY_RELEASE` — keep their names, as they are not part of Prowler Local Server's runtime configuration.
+3 -2
View File
@@ -81,9 +81,10 @@ ENV HOSTNAME="0.0.0.0"
# Helm/K8s):
# - required: UI_API_BASE_URL, AUTH_URL, AUTH_SECRET (missing ⇒ fail fast at boot)
# - optional: UI_API_DOCS_URL
# - optional: UI_CLOUD_ENABLED ("true" only in Prowler Cloud deployments)
# - gated integrations (load only when *_ENABLED="true"; the value is then
# required or boot fails). Legacy names (NEXT_PUBLIC_*, POSTHOG_KEY/HOST)
# still activate without the flag:
# required or boot fails). Their legacy names (NEXT_PUBLIC_SENTRY_*,
# NEXT_PUBLIC_GOOGLE_TAG_MANAGER_ID, POSTHOG_KEY/HOST) still work:
# UI_SENTRY_ENABLED + UI_SENTRY_DSN (+ optional UI_SENTRY_ENVIRONMENT)
# UI_GOOGLE_TAG_MANAGER_ENABLED + UI_GOOGLE_TAG_MANAGER_ID
# UI_POSTHOG_ENABLED + UI_POSTHOG_KEY + UI_POSTHOG_HOST (no consumer yet)
@@ -235,7 +235,7 @@ describe("adaptFindingGroupResourcesResponse — malformed input", () => {
it("should attach adapter-produced triage DTOs to finding-level resource rows", () => {
// Given
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true");
vi.stubEnv("UI_CLOUD_ENABLED", "true");
const input = {
data: [
{
@@ -291,7 +291,7 @@ describe("adaptFindingGroupResourcesResponse — malformed input", () => {
it("should leave triage editing disabled until a real capability is provided", () => {
// Given
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false");
vi.stubEnv("UI_CLOUD_ENABLED", "false");
const input = {
data: [
{
@@ -1,3 +1,4 @@
import { isCloud } from "@/lib/shared/env";
import {
FINDING_TRIAGE_DISABLED_REASON,
type FindingTriageDisabledReason,
@@ -9,7 +10,7 @@ interface FindingTriageAdapterOptions {
}
export function getFindingTriageAdapterOptions(): FindingTriageAdapterOptions {
const isCloudEnvironment = process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true";
const isCloudEnvironment = isCloud();
return {
canEdit: isCloudEnvironment,
+2 -2
View File
@@ -55,7 +55,7 @@ describe("findings actions triage projection", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.stubGlobal("fetch", fetchMock);
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false");
vi.stubEnv("UI_CLOUD_ENABLED", "false");
getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" });
fetchMock.mockResolvedValue(new Response("", { status: 200 }));
handleApiResponseMock.mockResolvedValue(findingsResponse);
@@ -83,7 +83,7 @@ describe("findings actions triage projection", () => {
it("should attach domain triage DTOs to latest findings responses", async () => {
// Given
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true");
vi.stubEnv("UI_CLOUD_ENABLED", "true");
// When
const result = await getLatestFindings({ page: 1, pageSize: 10 });
+2 -1
View File
@@ -13,6 +13,7 @@ import {
} from "@/lib";
import { appendSanitizedProviderTypeFilters } from "@/lib/provider-filters";
import { handleApiResponse } from "@/lib/server-actions-helper";
import { isCloud } from "@/lib/shared/env";
import { OrganizationResource } from "@/types/organizations";
export const getResources = async ({
@@ -287,7 +288,7 @@ export const getResourceDrawerData = async ({
pageSize?: number;
query?: string;
}) => {
const isCloudEnv = process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true";
const isCloudEnv = isCloud();
const [resourceData, findingsResponse, organizationsResponse] =
await Promise.all([
+3 -3
View File
@@ -74,7 +74,7 @@ describe("role actions", () => {
it("includes manage_alerts when creating a role in Prowler Cloud", async () => {
// Given
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true");
vi.stubEnv("UI_CLOUD_ENABLED", "true");
// When
await addRole(makeRoleFormData());
@@ -85,7 +85,7 @@ describe("role actions", () => {
it("omits manage_alerts when creating a role outside Prowler Cloud", async () => {
// Given
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false");
vi.stubEnv("UI_CLOUD_ENABLED", "false");
// When
await addRole(makeRoleFormData());
@@ -98,7 +98,7 @@ describe("role actions", () => {
it("includes manage_alerts when updating a role in Prowler Cloud", async () => {
// Given
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true");
vi.stubEnv("UI_CLOUD_ENABLED", "true");
// When
await updateRole(makeRoleFormData(), "role-1");
+3 -2
View File
@@ -5,6 +5,7 @@ import { redirect } from "next/navigation";
import { apiBaseUrl, getAuthHeaders } from "@/lib";
import { handleApiError, handleApiResponse } from "@/lib/server-actions-helper";
import { isCloud } from "@/lib/shared/env";
export const getRoles = async ({
page = 1,
@@ -108,7 +109,7 @@ export const addRole = async (formData: FormData) => {
};
// Conditionally include Prowler Cloud permissions.
if (process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true") {
if (isCloud()) {
payload.data.attributes.manage_billing =
formData.get("manage_billing") === "true";
payload.data.attributes.manage_alerts =
@@ -165,7 +166,7 @@ export const updateRole = async (formData: FormData, roleId: string) => {
};
// Conditionally include Prowler Cloud permissions.
if (process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true") {
if (isCloud()) {
payload.data.attributes.manage_billing =
formData.get("manage_billing") === "true";
payload.data.attributes.manage_alerts =
+2 -1
View File
@@ -4,6 +4,7 @@ import {
isGithubOAuthEnabled,
isGoogleOAuthEnabled,
} from "@/lib/helper";
import { isCloud } from "@/lib/shared/env";
import { SearchParamsProps } from "@/types";
const SignUp = async ({
@@ -16,7 +17,7 @@ const SignUp = async ({
typeof resolvedSearchParams?.invitation_token === "string"
? resolvedSearchParams.invitation_token
: null;
const isCloudEnv = process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true";
const isCloudEnv = isCloud();
const GOOGLE_AUTH_URL = getAuthUrl("google");
const GITHUB_AUTH_URL = getAuthUrl("github");
@@ -6,6 +6,7 @@ import { Suspense } from "react";
import { ConnectLLMProvider } from "@/components/lighthouse-v1/connect-llm-provider";
import { SelectBedrockAuthMethod } from "@/components/lighthouse-v1/select-bedrock-auth-method";
import { LIGHTHOUSE_ROUTE } from "@/lib/lighthouse-routes";
import { isCloud } from "@/lib/shared/env";
import type { LighthouseProvider } from "@/types/lighthouse-v1";
export const BEDROCK_AUTH_MODES = {
@@ -44,7 +45,7 @@ function ConnectContent() {
}
export default function ConnectLLMProviderPage() {
if (process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true") {
if (isCloud()) {
redirect(LIGHTHOUSE_ROUTE.SETTINGS);
}
@@ -5,6 +5,7 @@ import { Suspense } from "react";
import { SelectModel } from "@/components/lighthouse-v1/select-model";
import { LIGHTHOUSE_ROUTE } from "@/lib/lighthouse-routes";
import { isCloud } from "@/lib/shared/env";
import type { LighthouseProvider } from "@/types/lighthouse-v1";
function SelectModelContent() {
@@ -27,7 +28,7 @@ function SelectModelContent() {
}
export default function SelectModelPage() {
if (process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true") {
if (isCloud()) {
redirect(LIGHTHOUSE_ROUTE.SETTINGS);
}
@@ -11,7 +11,7 @@ describe("ProwlerBrand", () => {
it("should render the Local Server lockups outside Cloud", () => {
// Given
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false");
vi.stubEnv("UI_CLOUD_ENABLED", "false");
// When
render(<ProwlerBrand />);
@@ -30,7 +30,7 @@ describe("ProwlerBrand", () => {
it("should render the Prowler Cloud lockups in Cloud", () => {
// Given
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true");
vi.stubEnv("UI_CLOUD_ENABLED", "true");
// When
render(<ProwlerBrand />);
@@ -25,6 +25,7 @@ import {
import { FormButtons } from "@/components/shadcn/form/form-buttons";
import { EnhancedMultiSelect } from "@/components/shadcn/select/enhanced-multi-select";
import { getAWSCredentialsTemplateLinks } from "@/lib";
import { isCloud } from "@/lib/shared/env";
import type { AWSCredentialsRole } from "@/types";
import type { IntegrationProps } from "@/types/integrations";
import {
@@ -77,10 +78,9 @@ export const S3IntegrationForm = ({
const isEditingConfig = editMode === "configuration";
const isEditingCredentials = editMode === "credentials";
const defaultCredentialsType =
process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true"
? "aws-sdk-default"
: "access-secret-key";
const defaultCredentialsType = isCloud()
? "aws-sdk-default"
: "access-secret-key";
const form = useForm({
resolver: zodResolver(
@@ -65,7 +65,7 @@ describe("AppSidebarContent", () => {
it("shares the brand, Launch Scan action and Local Server Cloud affordances", async () => {
// Given
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false");
vi.stubEnv("UI_CLOUD_ENABLED", "false");
vi.stubEnv("NEXT_PUBLIC_PROWLER_RELEASE_VERSION", "5.8.0");
const user = userEvent.setup();
@@ -91,7 +91,7 @@ describe("AppSidebarContent", () => {
it("keeps the existing Lighthouse chat sidebar in Cloud Chat mode", () => {
// Given
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true");
vi.stubEnv("UI_CLOUD_ENABLED", "true");
useAppSidebarMode.setState({ mode: APP_SIDEBAR_MODE.CHAT });
// When
@@ -109,7 +109,7 @@ describe("AppSidebarContent", () => {
it("opens the current scan modal instead of navigating from the scans route", async () => {
// Given
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true");
vi.stubEnv("UI_CLOUD_ENABLED", "true");
pathnameValue.current = "/scans";
const user = userEvent.setup();
@@ -31,7 +31,7 @@ describe("getNavigationConfig", () => {
it("groups the Local Server navigation without losing available features", () => {
// Given
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false");
vi.stubEnv("UI_CLOUD_ENABLED", "false");
// When
const sections = getNavigationConfig({
@@ -71,7 +71,7 @@ describe("getNavigationConfig", () => {
it("models Local Server Cloud features as contextual upgrade actions", () => {
// Given
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false");
vi.stubEnv("UI_CLOUD_ENABLED", "false");
// When
const children = getConfigurationChildren();
@@ -108,7 +108,7 @@ describe("getNavigationConfig", () => {
it("uses Cloud destinations and current New badges", () => {
// Given
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true");
vi.stubEnv("UI_CLOUD_ENABLED", "true");
// When
const sections = getNavigationConfig({
@@ -148,7 +148,7 @@ describe("getNavigationConfig", () => {
it("keeps the Cloud Billing destination for users with billing permission", () => {
// Given
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true");
vi.stubEnv("UI_CLOUD_ENABLED", "true");
const permissions = {
manage_billing: true,
} as RolePermissionAttributes;
@@ -178,7 +178,7 @@ describe("getNavigationConfig", () => {
const permissions = {
manage_billing: false,
} as RolePermissionAttributes;
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true");
vi.stubEnv("UI_CLOUD_ENABLED", "true");
// When
const cloudItems = getNavigationConfig({
@@ -193,7 +193,7 @@ describe("getNavigationConfig", () => {
cloudBillingEnabled: false,
permissions: { ...permissions, manage_billing: true },
}).flatMap((section) => section.items);
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false");
vi.stubEnv("UI_CLOUD_ENABLED", "false");
const localItems = getNavigationConfig({
pathname: "/",
apiDocsUrl: null,
@@ -211,7 +211,7 @@ describe("getNavigationConfig", () => {
it("keeps environment-specific API documentation destinations", () => {
// Given
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false");
vi.stubEnv("UI_CLOUD_ENABLED", "false");
// When
const localApiReference = getNavigationConfig({
@@ -221,7 +221,7 @@ describe("getNavigationConfig", () => {
.flatMap((section) => section.items)
.find((item) => item.label === "API Reference");
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true");
vi.stubEnv("UI_CLOUD_ENABLED", "true");
const cloudApiReference = getNavigationConfig({
pathname: "/",
apiDocsUrl: "https://ignored.example/docs",
@@ -240,7 +240,7 @@ describe("getNavigationConfig", () => {
it("omits the Local Server API reference when no URL is configured", () => {
// Given
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false");
vi.stubEnv("UI_CLOUD_ENABLED", "false");
// When
const items = getNavigationConfig({
@@ -256,7 +256,7 @@ describe("getNavigationConfig", () => {
it("filters navigation by required permission after visible copy changes", () => {
// Given
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true");
vi.stubEnv("UI_CLOUD_ENABLED", "true");
const sections = getNavigationConfig({
pathname: "/integrations",
apiDocsUrl: null,
@@ -294,7 +294,7 @@ describe("getNavigationConfig", () => {
it("keeps navigation when the required permission is granted", () => {
// Given
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true");
vi.stubEnv("UI_CLOUD_ENABLED", "true");
const permissions = {
manage_integrations: true,
} as RolePermissionAttributes;
@@ -320,7 +320,7 @@ describe("getNavigationConfig", () => {
it("matches complete route segments without stealing nested settings routes", () => {
// Given
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false");
vi.stubEnv("UI_CLOUD_ENABLED", "false");
// When
const scanDetails = getNavigationConfig({
@@ -67,7 +67,7 @@ describe("NavbarClient", () => {
navigationMocks.searchParams = new URLSearchParams();
window.localStorage.clear();
// Replay icon is Cloud-only.
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true");
vi.stubEnv("UI_CLOUD_ENABLED", "true");
// Default: the current route's content has loaded, so the icon is enabled.
usePageReadyStore.setState({ readyPath: "/findings" });
useSidePanelStore.setState({
@@ -223,7 +223,7 @@ describe("NavbarClient", () => {
});
it("hides the replay icon entirely in self-hosted (OSS) deployments", () => {
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false");
vi.stubEnv("UI_CLOUD_ENABLED", "false");
render(
<NavbarClient
@@ -280,7 +280,7 @@ describe("NavbarClient", () => {
it("hides the Lighthouse AI side-panel trigger in self-hosted (OSS) deployments", () => {
// Given
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false");
vi.stubEnv("UI_CLOUD_ENABLED", "false");
// When
render(<NavbarClient title="Findings" />);
@@ -84,7 +84,7 @@ describe("OnboardingTrigger", () => {
useDriverTourMock.mockClear();
capturedOnClosed = undefined;
// Trigger only resolves in cloud.
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true");
vi.stubEnv("UI_CLOUD_ENABLED", "true");
searchParamsValue = new URLSearchParams();
sliceState = {
active: false,
@@ -196,7 +196,7 @@ describe("OnboardingTrigger", () => {
describe("in self-hosted (OSS) deployments", () => {
it("renders null and never starts the tour, even with a matching param", async () => {
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false");
vi.stubEnv("UI_CLOUD_ENABLED", "false");
searchParamsValue = new URLSearchParams("onboarding=add-provider");
const { container } = render(
@@ -208,7 +208,7 @@ describe("OnboardingTrigger", () => {
});
it("ignores an active sequence slice in OSS", async () => {
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false");
vi.stubEnv("UI_CLOUD_ENABLED", "false");
setSlice({
active: true,
currentFlowId: "add-provider",
@@ -15,7 +15,7 @@ describe("AwsMethodSelector", () => {
it("opens the AWS Organizations upgrade in Local Server", async () => {
// Given
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false");
vi.stubEnv("UI_CLOUD_ENABLED", "false");
const user = userEvent.setup();
const onSelectOrganizations = vi.fn();
@@ -393,7 +393,7 @@ describe("DataTableRowActions", () => {
it("opens Edit Scan Schedule for Prowler Cloud subscribed provider rows", async () => {
// Given
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true");
vi.stubEnv("UI_CLOUD_ENABLED", "true");
const user = userEvent.setup();
render(
@@ -420,7 +420,7 @@ describe("DataTableRowActions", () => {
it("hides Edit Scan Schedule for manual-only Cloud provider rows", async () => {
// Given
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true");
vi.stubEnv("UI_CLOUD_ENABLED", "true");
const user = userEvent.setup();
render(
@@ -445,7 +445,7 @@ describe("DataTableRowActions", () => {
it("hides Edit Scan Schedule for blocked Cloud provider rows", async () => {
// Given
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true");
vi.stubEnv("UI_CLOUD_ENABLED", "true");
const user = userEvent.setup();
render(
@@ -470,7 +470,7 @@ describe("DataTableRowActions", () => {
it("opens scan config management with the precomputed current config id", async () => {
// Given
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true");
vi.stubEnv("UI_CLOUD_ENABLED", "true");
const user = userEvent.setup();
render(
@@ -500,7 +500,7 @@ describe("DataTableRowActions", () => {
it("shows scan config management as unavailable when scan configs failed to load", async () => {
// Given
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true");
vi.stubEnv("UI_CLOUD_ENABLED", "true");
const user = userEvent.setup();
render(
@@ -528,7 +528,7 @@ describe("DataTableRowActions", () => {
it("hides Edit Scan Configuration for dynamic providers in Prowler Cloud", async () => {
// Given a dynamic provider in a Cloud tenant with scan configs available.
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true");
vi.stubEnv("UI_CLOUD_ENABLED", "true");
const user = userEvent.setup();
render(
@@ -47,7 +47,7 @@ describe("useProviderWizardController", () => {
sessionStorage.clear();
localStorage.clear();
// Checkpoint is Cloud-only.
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true");
vi.stubEnv("UI_CLOUD_ENABLED", "true");
useProviderWizardStore.getState().reset();
useOrgSetupStore.getState().reset();
});
@@ -118,7 +118,7 @@ describe("useProviderWizardController", () => {
});
it("does not request the onboarding checkpoint in self-hosted (OSS) deployments", () => {
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false");
vi.stubEnv("UI_CLOUD_ENABLED", "false");
const onOpenChange = vi.fn();
const { result } = renderHook(() =>
useProviderWizardController({
@@ -76,7 +76,7 @@ describe("LaunchStep", () => {
describe("Prowler OSS (non-Cloud)", () => {
beforeEach(() => {
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false");
vi.stubEnv("UI_CLOUD_ENABLED", "false");
scanOnDemandMock.mockResolvedValue({ data: { id: "scan-1" } });
});
@@ -174,7 +174,7 @@ describe("LaunchStep", () => {
describe("Prowler Cloud subscribed", () => {
beforeEach(() => {
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true");
vi.stubEnv("UI_CLOUD_ENABLED", "true");
updateScheduleMock.mockResolvedValue({ data: { id: "provider-1" } });
});
@@ -370,7 +370,7 @@ describe("LaunchStep", () => {
describe("Prowler Cloud trial/onboarding (manual scan only)", () => {
beforeEach(() => {
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true");
vi.stubEnv("UI_CLOUD_ENABLED", "true");
scanOnDemandMock.mockResolvedValue({ data: { id: "scan-1" } });
});
@@ -14,6 +14,7 @@ import {
} from "@/components/shadcn/select/select";
import { Separator } from "@/components/shadcn/separator/separator";
import { ProviderCredentialFields } from "@/lib/provider-credentials/provider-credential-fields";
import { isCloud } from "@/lib/shared/env";
import { AWSCredentialsRole } from "@/types";
import { IntegrationType } from "@/types/integrations";
@@ -36,7 +37,7 @@ export const AWSRoleCredentialsForm = ({
type?: "providers" | "integrations";
integrationType?: IntegrationType;
}) => {
const isCloudEnv = process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true";
const isCloudEnv = isCloud();
const defaultCredentialsType = isCloudEnv
? "aws-sdk-default"
: "access-secret-key";
@@ -96,7 +96,7 @@ describe("AddRoleForm", () => {
it("shows Manage Alerts in Prowler Cloud", () => {
// Given
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true");
vi.stubEnv("UI_CLOUD_ENABLED", "true");
// When
render(<AddRoleForm groups={[]} />);
@@ -108,7 +108,7 @@ describe("AddRoleForm", () => {
it("hides Manage Alerts outside Prowler Cloud", () => {
// Given
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false");
vi.stubEnv("UI_CLOUD_ENABLED", "false");
// When
render(<AddRoleForm groups={[]} />);
@@ -6,6 +6,7 @@ import { DefaultValues } from "react-hook-form";
import { addRole } from "@/actions/roles/roles";
import { useToast } from "@/components/shadcn";
import { getErrorMessage } from "@/lib";
import { isCloud } from "@/lib/shared/env";
import { RoleFormValues } from "@/types";
import { RoleForm, RoleFormSubmitContext, RoleGroupOption } from "./role-form";
@@ -13,7 +14,7 @@ import { RoleForm, RoleFormSubmitContext, RoleGroupOption } from "./role-form";
export const AddRoleForm = ({ groups }: { groups: RoleGroupOption[] }) => {
const { toast } = useToast();
const router = useRouter();
const isCloudEnvironment = process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true";
const isCloudEnvironment = isCloud();
const defaultValues: DefaultValues<RoleFormValues> = {
name: "",
@@ -6,6 +6,7 @@ import { DefaultValues } from "react-hook-form";
import { updateRole } from "@/actions/roles/roles";
import { useToast } from "@/components/shadcn";
import { getErrorMessage } from "@/lib";
import { isCloud } from "@/lib/shared/env";
import { RoleFormValues } from "@/types";
import { RoleForm, RoleFormSubmitContext, RoleGroupOption } from "./role-form";
@@ -30,7 +31,7 @@ export const EditRoleForm = ({
}) => {
const { toast } = useToast();
const router = useRouter();
const isCloudEnvironment = process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true";
const isCloudEnvironment = isCloud();
const defaultValues: DefaultValues<RoleFormValues> = {
...roleData.data.attributes,
@@ -35,6 +35,7 @@ import {
getUnlimitedVisibilityField,
getVisiblePermissionFormFields,
} from "@/lib/role-permissions";
import { isCloud } from "@/lib/shared/env";
import { roleFormSchema, RoleFormValues } from "@/types";
import { UnlimitedVisibilityField } from "./unlimited-visibility-section";
@@ -74,7 +75,7 @@ export const RoleForm = ({
defaultValues,
});
const isCloudEnvironment = process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true";
const isCloudEnvironment = isCloud();
const visiblePermissionFormFields =
getVisiblePermissionFormFields(isCloudEnvironment);
const showUnlimitedVisibilityField = !!getUnlimitedVisibilityField();
+15 -15
View File
@@ -154,7 +154,7 @@ describe("ScansPageShell", () => {
});
it("does not render an imported findings tab", () => {
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false");
vi.stubEnv("UI_CLOUD_ENABLED", "false");
render(
<ScansPageShell providers={providers} hasManageScansPermission>
@@ -172,7 +172,7 @@ describe("ScansPageShell", () => {
});
it("uses the shared scan filter bar for scan filters", () => {
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false");
vi.stubEnv("UI_CLOUD_ENABLED", "false");
render(
<ScansPageShell providers={providers} hasManageScansPermission>
@@ -190,7 +190,7 @@ describe("ScansPageShell", () => {
});
it("clears the active sort when switching tabs", async () => {
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false");
vi.stubEnv("UI_CLOUD_ENABLED", "false");
searchParamsValue.current = "tab=active&sort=trigger";
const user = userEvent.setup();
@@ -209,7 +209,7 @@ describe("ScansPageShell", () => {
});
it("uses a generic type filter label in Cloud", () => {
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true");
vi.stubEnv("UI_CLOUD_ENABLED", "true");
render(
<ScansPageShell providers={providers} hasManageScansPermission>
@@ -221,7 +221,7 @@ describe("ScansPageShell", () => {
});
it("shows the CLI import banner in Cloud", () => {
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true");
vi.stubEnv("UI_CLOUD_ENABLED", "true");
render(
<ScansPageShell providers={providers} hasManageScansPermission>
@@ -239,7 +239,7 @@ describe("ScansPageShell", () => {
});
it("hides the CLI import banner outside Cloud", () => {
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false");
vi.stubEnv("UI_CLOUD_ENABLED", "false");
render(
<ScansPageShell providers={providers} hasManageScansPermission>
@@ -251,7 +251,7 @@ describe("ScansPageShell", () => {
});
it("keeps launch scan with filters and mutelist with tabs", () => {
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false");
vi.stubEnv("UI_CLOUD_ENABLED", "false");
render(
<ScansPageShell providers={providers} hasManageScansPermission>
@@ -273,7 +273,7 @@ describe("ScansPageShell", () => {
});
it("shows the active scans count in the in progress tab", () => {
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false");
vi.stubEnv("UI_CLOUD_ENABLED", "false");
render(
<ScansPageShell
@@ -292,7 +292,7 @@ describe("ScansPageShell", () => {
});
it("opens the launch scan modal from the URL", () => {
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false");
vi.stubEnv("UI_CLOUD_ENABLED", "false");
searchParamsValue.current = "launchScan=true";
render(
@@ -305,7 +305,7 @@ describe("ScansPageShell", () => {
});
it("strips the launchScan URL param via the History API when closing the URL-opened modal", async () => {
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false");
vi.stubEnv("UI_CLOUD_ENABLED", "false");
searchParamsValue.current = "tab=completed&launchScan=true";
const replaceStateSpy = vi.spyOn(window.history, "replaceState");
const user = userEvent.setup();
@@ -333,7 +333,7 @@ describe("ScansPageShell", () => {
});
it("opens and closes the launch scan modal from client state without navigation", async () => {
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false");
vi.stubEnv("UI_CLOUD_ENABLED", "false");
const user = userEvent.setup();
useScansStore.getState().openLaunchScanModal();
@@ -352,7 +352,7 @@ describe("ScansPageShell", () => {
});
it("shows the status filter only on the completed tab", () => {
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false");
vi.stubEnv("UI_CLOUD_ENABLED", "false");
searchParamsValue.current = "tab=completed";
render(
@@ -367,7 +367,7 @@ describe("ScansPageShell", () => {
});
it("hides the status filter outside of the completed tab", () => {
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false");
vi.stubEnv("UI_CLOUD_ENABLED", "false");
searchParamsValue.current = "tab=active";
render(
@@ -382,7 +382,7 @@ describe("ScansPageShell", () => {
});
it("clears status filter when switching scan tabs", async () => {
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false");
vi.stubEnv("UI_CLOUD_ENABLED", "false");
searchParamsValue.current = "tab=completed&filter%5Bstate__in%5D=failed";
const user = userEvent.setup();
@@ -400,7 +400,7 @@ describe("ScansPageShell", () => {
});
it("clears type filter when switching to scheduled scans", async () => {
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false");
vi.stubEnv("UI_CLOUD_ENABLED", "false");
searchParamsValue.current = "tab=completed&filter%5Btrigger%5D=manual";
const user = userEvent.setup();
@@ -151,7 +151,7 @@ describe("ScanJobsRowActions", () => {
it("opens Edit Scan Schedule for Prowler Cloud subscribed scan rows", async () => {
// Given
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true");
vi.stubEnv("UI_CLOUD_ENABLED", "true");
const user = userEvent.setup();
render(<ScanJobsRowActions scan={makeScan()} tab="scheduled" />);
@@ -173,7 +173,7 @@ describe("ScanJobsRowActions", () => {
it("hides Edit Scan Schedule outside Prowler Cloud (OSS)", async () => {
// Given
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false");
vi.stubEnv("UI_CLOUD_ENABLED", "false");
const user = userEvent.setup();
render(<ScanJobsRowActions scan={makeScan()} tab="scheduled" />);
@@ -191,7 +191,7 @@ describe("ScanJobsRowActions", () => {
it("hides Edit Scan Schedule outside the Scheduled tab even on Cloud", async () => {
// Given - advanced capability (Cloud) but rendered in the Completed tab.
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true");
vi.stubEnv("UI_CLOUD_ENABLED", "true");
const user = userEvent.setup();
render(
@@ -214,7 +214,7 @@ describe("ScanJobsRowActions", () => {
it("hides Edit Scan Schedule for manual-only Cloud scan rows", async () => {
// Given
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true");
vi.stubEnv("UI_CLOUD_ENABLED", "true");
const user = userEvent.setup();
render(
@@ -238,7 +238,7 @@ describe("ScanJobsRowActions", () => {
it("hides Edit Scan Schedule for blocked Cloud scan rows", async () => {
// Given
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true");
vi.stubEnv("UI_CLOUD_ENABLED", "true");
const user = userEvent.setup();
render(
@@ -47,7 +47,7 @@ describe("CloudUpgradeModal", () => {
it("renders the active contextual upgrade in Local Server", async () => {
// Given
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false");
vi.stubEnv("UI_CLOUD_ENABLED", "false");
useCloudUpgradeStore
.getState()
.openCloudUpgrade(CLOUD_UPGRADE_FEATURE.ALERTS);
@@ -76,7 +76,7 @@ describe("CloudUpgradeModal", () => {
it("uses the standard equal-width CTA layout", async () => {
// Given
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false");
vi.stubEnv("UI_CLOUD_ENABLED", "false");
useCloudUpgradeStore
.getState()
.openCloudUpgrade(CLOUD_UPGRADE_FEATURE.AWS_ORGANIZATIONS);
@@ -118,7 +118,7 @@ describe("CloudUpgradeModal", () => {
it("closes the active upgrade and returns focus to its trigger", async () => {
// Given
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false");
vi.stubEnv("UI_CLOUD_ENABLED", "false");
const user = userEvent.setup();
render(
@@ -189,7 +189,7 @@ describe("CloudUpgradeModal", () => {
it("does not render upgrade UI in Prowler Cloud", () => {
// Given
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true");
vi.stubEnv("UI_CLOUD_ENABLED", "true");
useCloudUpgradeStore
.getState()
.openCloudUpgrade(CLOUD_UPGRADE_FEATURE.ALERTS);
@@ -34,7 +34,7 @@ describe("RoleItem", () => {
it("shows Manage Alerts in Prowler Cloud role details", () => {
// Given
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true");
vi.stubEnv("UI_CLOUD_ENABLED", "true");
// When
render(<RoleItem role={role} roleDetail={roleDetail} />);
@@ -45,7 +45,7 @@ describe("RoleItem", () => {
it("hides Manage Alerts outside Prowler Cloud role details", () => {
// Given
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false");
vi.stubEnv("UI_CLOUD_ENABLED", "false");
// When
render(<RoleItem role={role} roleDetail={roleDetail} />);
@@ -56,7 +56,7 @@ describe("RoleItem", () => {
it("displays the permission state as a badge", () => {
// Given
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true");
vi.stubEnv("UI_CLOUD_ENABLED", "true");
// When
render(<RoleItem role={role} roleDetail={roleDetail} />);
@@ -67,7 +67,7 @@ describe("RoleItem", () => {
it("does not render the details toggle", () => {
// Given
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true");
vi.stubEnv("UI_CLOUD_ENABLED", "true");
// When
render(<RoleItem role={role} roleDetail={roleDetail} />);
+2 -2
View File
@@ -8,7 +8,7 @@ describe("siteConfig", () => {
it("names the open-source application Prowler Local Server", async () => {
// Given
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false");
vi.stubEnv("UI_CLOUD_ENABLED", "false");
// When
const { siteConfig } = await import("./site");
@@ -19,7 +19,7 @@ describe("siteConfig", () => {
it("keeps the Prowler Cloud name in Cloud", async () => {
// Given
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true");
vi.stubEnv("UI_CLOUD_ENABLED", "true");
// When
const { siteConfig } = await import("./site");
+2 -1
View File
@@ -7,6 +7,7 @@ import { useFormServerErrors } from "@/hooks/use-form-server-errors";
import { filterEmptyValues } from "@/lib";
import { PROVIDER_CREDENTIALS_ERROR_MAPPING } from "@/lib/error-mappings";
import { ProviderCredentialFields } from "@/lib/provider-credentials/provider-credential-fields";
import { isCloud } from "@/lib/shared/env";
import {
addCredentialsFormSchema,
addCredentialsRoleFormSchema,
@@ -76,7 +77,7 @@ export const useCredentialsForm = ({
// AWS Role credentials
if (providerType === "aws" && effectiveVia === "role") {
const isCloudEnv = process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true";
const isCloudEnv = isCloud();
const defaultCredentialsType = isCloudEnv
? "aws-sdk-default"
: "access-secret-key";
@@ -12,7 +12,7 @@ describe("useScanScheduleCapability", () => {
it("returns DAILY_LEGACY for OSS without loading", () => {
// Given
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false");
vi.stubEnv("UI_CLOUD_ENABLED", "false");
// When
const { result } = renderHook(() => useScanScheduleCapability());
@@ -26,7 +26,7 @@ describe("useScanScheduleCapability", () => {
it("returns ADVANCED for Cloud env without loading", () => {
// Given
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true");
vi.stubEnv("UI_CLOUD_ENABLED", "true");
// When
const { result } = renderHook(() => useScanScheduleCapability());
+108
View File
@@ -169,3 +169,111 @@ describe("lib/env gated integration validation", () => {
await expect(import("@/lib/env")).resolves.toBeDefined();
});
});
describe("lib/env billing and Stripe boot warnings", () => {
// Clear billing, cloud, Stripe and gated flags so ambient shell env cannot
// affect assertions, then satisfy the unconditional REQUIRED vars.
const CLEARED_ENV_VARS = [
"UI_CLOUD_ENABLED",
"CLOUD_BILLING_ENABLED",
"UI_SENTRY_ENABLED",
"UI_SENTRY_DSN",
"NEXT_PUBLIC_SENTRY_DSN",
"UI_SENTRY_ENVIRONMENT",
"NEXT_PUBLIC_SENTRY_ENVIRONMENT",
"UI_GOOGLE_TAG_MANAGER_ENABLED",
"UI_GOOGLE_TAG_MANAGER_ID",
"NEXT_PUBLIC_GOOGLE_TAG_MANAGER_ID",
"UI_POSTHOG_ENABLED",
"UI_POSTHOG_KEY",
"POSTHOG_KEY",
"UI_POSTHOG_HOST",
"POSTHOG_HOST",
"UI_CLOUD_STRIPE_PUBLISHABLE_KEY",
"NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY",
"UI_CLOUD_STRIPE_PUBLISHABLE_KEY_V2",
"NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY_V2",
] as const;
let warnSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
vi.resetModules();
for (const key of CLEARED_ENV_VARS) {
vi.stubEnv(key, undefined);
}
vi.stubEnv("UI_API_BASE_URL", "https://api.example.com/api/v1");
vi.stubEnv("AUTH_URL", "http://localhost:3000");
vi.stubEnv("AUTH_SECRET", "secret");
warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
});
afterEach(() => {
vi.unstubAllEnvs();
vi.restoreAllMocks();
});
it('warns when billing is "legacy" without the cloud flag', async () => {
vi.stubEnv("CLOUD_BILLING_ENABLED", "legacy");
await import("@/lib/env");
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining(
'CLOUD_BILLING_ENABLED is "legacy" but UI_CLOUD_ENABLED is not "true"',
),
);
});
it('warns when billing is "metronome" (PostHog enabled) without the cloud flag', async () => {
vi.stubEnv("CLOUD_BILLING_ENABLED", "metronome");
vi.stubEnv("UI_POSTHOG_ENABLED", "true");
vi.stubEnv("UI_POSTHOG_KEY", "phc_key");
vi.stubEnv("UI_POSTHOG_HOST", "https://eu.i.posthog.com");
await import("@/lib/env");
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining(
'CLOUD_BILLING_ENABLED is "metronome" but UI_CLOUD_ENABLED is not "true"',
),
);
});
it("does not warn about billing when the cloud flag is set", async () => {
vi.stubEnv("CLOUD_BILLING_ENABLED", "legacy");
vi.stubEnv("UI_CLOUD_ENABLED", "true");
await import("@/lib/env");
expect(warnSpy).not.toHaveBeenCalled();
});
it("does not warn when billing is off and no Stripe keys are set", async () => {
await import("@/lib/env");
expect(warnSpy).not.toHaveBeenCalled();
});
it("warns when a Stripe key is set without billing enabled", async () => {
vi.stubEnv("UI_CLOUD_STRIPE_PUBLISHABLE_KEY", "pk_test_123");
await import("@/lib/env");
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining(
"UI_CLOUD_STRIPE_PUBLISHABLE_KEY is set but CLOUD_BILLING_ENABLED is not enabled; Stripe will not load.",
),
);
});
it("does not warn about Stripe when cloud, billing, and Stripe are all set", async () => {
vi.stubEnv("UI_CLOUD_ENABLED", "true");
vi.stubEnv("CLOUD_BILLING_ENABLED", "legacy");
vi.stubEnv("UI_CLOUD_STRIPE_PUBLISHABLE_KEY", "pk_test_123");
await import("@/lib/env");
expect(warnSpy).not.toHaveBeenCalled();
});
});
+33
View File
@@ -38,4 +38,37 @@ if (
warnGatedIntegrationsMisconfig();
// The billing UI is Cloud-only: navigation (navigation-config.ts) and the
// /billing route (proxy.ts) additionally gate on the cloud flag, so billing
// enabled without it is inert — warn, don't throw.
const cloudEnabled = readBoolEnv("UI_CLOUD_ENABLED");
const cloudBillingSelector = readEnv("CLOUD_BILLING_ENABLED");
const cloudBillingOn =
cloudBillingSelector !== null && cloudBillingSelector !== "false";
if (cloudBillingOn && !cloudEnabled) {
// eslint-disable-next-line no-console
console.warn(
`CLOUD_BILLING_ENABLED is "${cloudBillingSelector}" but UI_CLOUD_ENABLED is not "true"; the billing UI will not be shown.`,
);
}
// Stripe publishable keys load only on billing flows; a key without billing
// enabled is inert.
if (!cloudBillingOn) {
for (const name of [
"UI_CLOUD_STRIPE_PUBLISHABLE_KEY",
"NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY",
"UI_CLOUD_STRIPE_PUBLISHABLE_KEY_V2",
"NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY_V2",
] as const) {
if (readEnv(name)) {
// eslint-disable-next-line no-console
console.warn(
`${name} is set but CLOUD_BILLING_ENABLED is not enabled; Stripe will not load.`,
);
}
}
}
export {};
+18 -2
View File
@@ -62,6 +62,20 @@ describe("getRuntimeConfigClient", () => {
expect(config.reoDevClientId).toBeNull();
});
it("reads the cloudEnabled flag from the island", async () => {
// Given
writeIsland(JSON.stringify({ cloudEnabled: true }));
const { getRuntimeConfigClient } = await import(
"./get-runtime-config.client"
);
// When
const config = getRuntimeConfigClient();
// Then
expect(config.cloudEnabled).toBe(true);
});
it("falls back to an all-null config when the island is malformed JSON", async () => {
// Given
writeIsland("{ not valid json");
@@ -99,6 +113,7 @@ describe("getRuntimeConfigClient", () => {
"apiBaseUrl",
"apiDocsUrl",
"cloudBillingEnabled",
"cloudEnabled",
"googleTagManagerId",
"posthogHost",
"posthogKey",
@@ -110,9 +125,10 @@ describe("getRuntimeConfigClient", () => {
].sort(),
);
expect(config.apiBaseUrl).toBe("https://api.example.com/api/v1");
// cloudBillingEnabled is a boolean flag, so it defaults to false (not null)
// when absent from the island.
// cloudBillingEnabled and cloudEnabled are boolean flags, so they default to
// false (not null) when absent from the island.
expect(config.cloudBillingEnabled).toBe(false);
expect(config.cloudEnabled).toBe(false);
expect(
(config as unknown as Record<string, unknown>).notAllowlisted,
).toBeUndefined();
+3 -32
View File
@@ -2,45 +2,16 @@
import {
EMPTY_RUNTIME_PUBLIC_CONFIG,
RUNTIME_CONFIG_SCRIPT_ID,
readRuntimeConfigIsland,
type RuntimePublicConfig,
} from "@/lib/runtime-config.shared";
let cached: RuntimePublicConfig | null = null;
// Explicit per-key copy (not a spread) so unexpected island keys can't leak through.
const pickConfig = (
parsed: Partial<RuntimePublicConfig>,
): RuntimePublicConfig => ({
sentryDsn: parsed.sentryDsn ?? null,
sentryEnvironment: parsed.sentryEnvironment ?? null,
googleTagManagerId: parsed.googleTagManagerId ?? null,
apiBaseUrl: parsed.apiBaseUrl ?? null,
apiDocsUrl: parsed.apiDocsUrl ?? null,
posthogKey: parsed.posthogKey ?? null,
posthogHost: parsed.posthogHost ?? null,
reoDevClientId: parsed.reoDevClientId ?? null,
cloudBillingEnabled: parsed.cloudBillingEnabled ?? false,
stripePublishableKey: parsed.stripePublishableKey ?? null,
stripePublishableKeyV2: parsed.stripePublishableKeyV2 ?? null,
});
// Reads the <head> island once (memoized); all-null during SSR or if it's
// missing/malformed, so callers can treat every integration as disabled.
export function getRuntimeConfigClient(): RuntimePublicConfig {
if (cached) return cached;
if (typeof document === "undefined") return EMPTY_RUNTIME_PUBLIC_CONFIG;
const el = document.getElementById(RUNTIME_CONFIG_SCRIPT_ID);
let resolved: RuntimePublicConfig;
try {
resolved = el?.textContent
? pickConfig(JSON.parse(el.textContent) as Partial<RuntimePublicConfig>)
: EMPTY_RUNTIME_PUBLIC_CONFIG;
} catch {
resolved = EMPTY_RUNTIME_PUBLIC_CONFIG;
}
cached = resolved;
return resolved;
cached = readRuntimeConfigIsland() ?? EMPTY_RUNTIME_PUBLIC_CONFIG;
return cached;
}
+2 -2
View File
@@ -22,7 +22,7 @@ describe("getRolePermissions", () => {
it("includes Manage Alerts in Prowler Cloud when role attributes provide it", () => {
// Given
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true");
vi.stubEnv("UI_CLOUD_ENABLED", "true");
// When
const permissions = getRolePermissions(attributes);
@@ -37,7 +37,7 @@ describe("getRolePermissions", () => {
it("hides Manage Alerts outside Prowler Cloud", () => {
// Given
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false");
vi.stubEnv("UI_CLOUD_ENABLED", "false");
// When
const permissions = getRolePermissions(attributes);
+2 -1
View File
@@ -1,3 +1,4 @@
import { isCloud } from "@/lib/shared/env";
import { RolePermissionAttributes } from "@/types/users";
/**
@@ -30,7 +31,7 @@ export const isUserOwnerAndHasManageAccount = (
* @returns The permissions for the user role
*/
export const getRolePermissions = (attributes: RolePermissionAttributes) => {
const isCloudEnvironment = process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true";
const isCloudEnvironment = isCloud();
const permissions = [
{
+37
View File
@@ -9,6 +9,7 @@ export interface RuntimePublicConfig {
posthogKey: string | null; // reserved
posthogHost: string | null; // reserved
reoDevClientId: string | null; // reserved
cloudEnabled: boolean;
cloudBillingEnabled: boolean;
stripePublishableKey: string | null; // reserved
stripePublishableKeyV2: string | null; // reserved
@@ -26,7 +27,43 @@ export const EMPTY_RUNTIME_PUBLIC_CONFIG: RuntimePublicConfig = {
posthogKey: null,
posthogHost: null,
reoDevClientId: null,
cloudEnabled: false,
cloudBillingEnabled: false,
stripePublishableKey: null,
stripePublishableKeyV2: null,
};
// Explicit per-key copy (not a spread) so unexpected island keys can't leak through.
const pickConfig = (
parsed: Partial<RuntimePublicConfig>,
): RuntimePublicConfig => ({
sentryDsn: parsed.sentryDsn ?? null,
sentryEnvironment: parsed.sentryEnvironment ?? null,
googleTagManagerId: parsed.googleTagManagerId ?? null,
apiBaseUrl: parsed.apiBaseUrl ?? null,
apiDocsUrl: parsed.apiDocsUrl ?? null,
posthogKey: parsed.posthogKey ?? null,
posthogHost: parsed.posthogHost ?? null,
reoDevClientId: parsed.reoDevClientId ?? null,
cloudEnabled: parsed.cloudEnabled ?? false,
cloudBillingEnabled: parsed.cloudBillingEnabled ?? false,
stripePublishableKey: parsed.stripePublishableKey ?? null,
stripePublishableKeyV2: parsed.stripePublishableKeyV2 ?? null,
});
// Reads and validates the <head> island. Null when there is no DOM (server /
// edge), no island (jsdom unit tests), or the JSON is malformed — callers
// choose the fallback. Deliberately uncached: a module-level cache would
// leak state across jsdom tests.
export function readRuntimeConfigIsland(): RuntimePublicConfig | null {
if (typeof document === "undefined") return null;
const el = document.getElementById(RUNTIME_CONFIG_SCRIPT_ID);
if (!el?.textContent) return null;
try {
return pickConfig(
JSON.parse(el.textContent) as Partial<RuntimePublicConfig>,
);
} catch {
return null;
}
}
+3 -2
View File
@@ -3,8 +3,8 @@ import "server-only";
import { connection } from "next/server";
import { readGatedEnv } from "@/lib/integrations";
import type { RuntimePublicConfig } from "@/lib/runtime-config.shared";
import { readEnv } from "@/lib/runtime-env";
import { type RuntimePublicConfig } from "@/lib/runtime-config.shared";
import { readBoolEnv, readEnv } from "@/lib/runtime-env";
// `connection()` forces a per-request runtime read (never build-snapshotted);
// only this allowlist reaches the client. Each migrated key falls back to its
@@ -44,6 +44,7 @@ export async function getRuntimePublicConfig(): Promise<RuntimePublicConfig> {
"POSTHOG_HOST",
),
reoDevClientId: readEnv("REO_DEV_CLIENT_ID"),
cloudEnabled: readBoolEnv("UI_CLOUD_ENABLED"),
// Install-level selector "legacy" | "metronome" | "false"; the client only
// needs on/off, so expose a derived boolean (the raw selector is read
// server-side for V1/V2 routing). Default (unset) is off.
+57
View File
@@ -0,0 +1,57 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { RUNTIME_CONFIG_SCRIPT_ID } from "@/lib/runtime-config.shared";
import { isCloud } from "./env";
const writeIsland = (content: Record<string, unknown> | string) => {
const el = document.createElement("script");
el.id = RUNTIME_CONFIG_SCRIPT_ID;
el.type = "application/json";
el.textContent =
typeof content === "string" ? content : JSON.stringify(content);
document.head.appendChild(el);
};
describe("isCloud", () => {
afterEach(() => {
vi.unstubAllEnvs();
document.head.innerHTML = "";
});
describe("without an island (server / jsdom env fallback)", () => {
it('returns true when UI_CLOUD_ENABLED is "true"', () => {
vi.stubEnv("UI_CLOUD_ENABLED", "true");
expect(isCloud()).toBe(true);
});
it('returns false when UI_CLOUD_ENABLED is "false"', () => {
vi.stubEnv("UI_CLOUD_ENABLED", "false");
expect(isCloud()).toBe(false);
});
it("returns false when UI_CLOUD_ENABLED is unset", () => {
expect(isCloud()).toBe(false);
});
});
describe("with an island (browser)", () => {
it("uses the island flag over the env var (island true, env false)", () => {
vi.stubEnv("UI_CLOUD_ENABLED", "false");
writeIsland({ cloudEnabled: true });
expect(isCloud()).toBe(true);
});
it("uses the island flag over the env var (island false, env true)", () => {
vi.stubEnv("UI_CLOUD_ENABLED", "true");
writeIsland({ cloudEnabled: false });
expect(isCloud()).toBe(false);
});
it("falls back to the env var when the island is malformed", () => {
vi.stubEnv("UI_CLOUD_ENABLED", "true");
writeIsland("{ not valid json");
expect(isCloud()).toBe(true);
});
});
});
+12 -4
View File
@@ -1,14 +1,22 @@
/**
* Shared environment helpers.
*/
import { readRuntimeConfigIsland } from "@/lib/runtime-config.shared";
import { readBoolEnv } from "@/lib/runtime-env";
/**
* Whether the UI is running inside a Prowler Cloud deployment.
*
* `NEXT_PUBLIC_*` vars are statically inlined by Next.js wherever the literal
* `process.env.NEXT_PUBLIC_IS_CLOUD_ENV` appears in source, so keeping this read
* inside a helper is safe.
* Runtime read, resolved from two sources:
* - Browser: the runtime public-config island (`cloudEnabled`), rendered in
* <head> before any bundle runs, so calling this at module scope is safe.
* - Without a DOM (RSC, server actions, SSR, edge, Node) and jsdom tests
* without an island: `UI_CLOUD_ENABLED`. The island is produced from the
* same env var (lib/runtime-config.ts), so SSR and hydration always agree.
*/
export function isCloud(): boolean {
return process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true";
const islandConfig = readRuntimeConfigIsland();
if (islandConfig) return islandConfig.cloudEnabled;
return readBoolEnv("UI_CLOUD_ENABLED");
}
+4 -1
View File
@@ -3,6 +3,7 @@ import type { NextAuthRequest } from "next-auth";
import { auth } from "@/auth.config";
import { readEnv } from "@/lib/runtime-env";
import { isCloud } from "@/lib/shared/env";
const publicRoutes = [
"/sign-in",
@@ -43,7 +44,9 @@ export default auth((req: NextAuthRequest) => {
if (
pathname.startsWith("/billing") &&
(!cloudBillingEnabled || user?.permissions?.manage_billing !== true)
(!isCloud() ||
!cloudBillingEnabled ||
user?.permissions?.manage_billing !== true)
) {
return NextResponse.redirect(new URL("/profile", req.url));
}
+2 -1
View File
@@ -4,8 +4,9 @@ import { makeSuffix } from "../helpers";
import { SignUpPage } from "../sign-up/sign-up-page";
import { SignInPage } from "../sign-in-base/sign-in-base-page";
import { UserProfilePage } from "../profile/profile-page";
import { isCloud } from "@/lib/shared/env";
const isCloudEnv = process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true";
const isCloudEnv = isCloud();
test.describe("New user invitation", () => {
let invitationsPage: InvitationsPage;
+3 -1
View File
@@ -1,4 +1,6 @@
import { test } from "@playwright/test";
import { isCloud } from "@/lib/shared/env";
import {
ProvidersPage,
AWSProviderData,
@@ -281,7 +283,7 @@ test.describe("Add Provider", () => {
// so this test must never run in the OSS CI. Gate explicitly on the
// Cloud env flag instead of relying on the org env vars being absent.
test.skip(
process.env.NEXT_PUBLIC_IS_CLOUD_ENV !== "true",
!isCloud(),
"AWS Organizations multi-account onboarding is a Cloud-only feature",
);
@@ -20,6 +20,7 @@ export const RUNTIME_CONFIG_KEYS = [
"posthogHost",
"reoDevClientId",
"cloudBillingEnabled",
"cloudEnabled",
"stripePublishableKey",
"stripePublishableKeyV2",
] as const satisfies ReadonlyArray<keyof RuntimePublicConfig>;
+7 -6
View File
@@ -1,5 +1,6 @@
import { z } from "zod";
import { isCloud } from "@/lib/shared/env";
import { SPECIAL_CHARACTERS } from "@/lib/utils";
export type AuthSocialProvider = "google" | "github";
@@ -104,12 +105,12 @@ export const signUpSchema = baseAuthSchema
}),
company: z.string().optional(),
invitationToken: z.string().optional(),
termsAndConditions:
process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true"
? z.boolean().refine((value) => value === true, {
message: "You must accept the terms and conditions.",
})
: z.boolean().optional(),
termsAndConditions: z
.boolean()
.optional()
.refine((value) => !isCloud() || value === true, {
error: "You must accept the terms and conditions.",
}),
})
.refine(
(data) => {
+3 -1
View File
@@ -28,6 +28,9 @@ declare global {
NEXT_PUBLIC_SENTRY_ENVIRONMENT?: string;
UI_SENTRY_ENVIRONMENT?: string;
// Prowler Cloud deployment flag — runtime read (server env, client island).
UI_CLOUD_ENABLED?: "true" | "false";
CLOUD_BILLING_ENABLED?: "legacy" | "metronome" | "false";
// Cloud-only Stripe publishable keys (public; shipped to the browser).
@@ -40,7 +43,6 @@ declare global {
UI_CLOUD_STRIPE_PUBLISHABLE_KEY_V2?: string;
// Build-time public config
NEXT_PUBLIC_IS_CLOUD_ENV?: "true" | "false";
NEXT_PUBLIC_PROWLER_RELEASE_VERSION?: string;
// Auth (NextAuth)
+4
View File
@@ -1,5 +1,9 @@
import "@testing-library/jest-dom/vitest";
// An ambient UI_CLOUD_ENABLED in a developer's shell would silently flip
// every test that relies on the OSS default — clear it.
delete process.env.UI_CLOUD_ENABLED;
class MockStorage implements Storage {
private readonly store = new Map<string, string>();