From 7d2a22c45ac324eabffc33049c24e710abe546fd Mon Sep 17 00:00:00 2001 From: "Pablo Fernandez Guerra (PFE)" <148432447+pfe-nazaries@users.noreply.github.com> Date: Wed, 22 Jul 2026 11:31:22 +0200 Subject: [PATCH] feat(ui): make the Cloud flag a runtime variable (UI_CLOUD_ENABLED) (#12061) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Pablo F.G Co-authored-by: César Arroba <19954079+cesararroba@users.noreply.github.com> --- .../developer-guide/environment-variables.mdx | 3 + ui/Dockerfile | 5 +- .../finding-groups.adapter.test.ts | 4 +- .../findings/findings-triage.options.ts | 3 +- ui/actions/findings/findings.test.ts | 4 +- ui/actions/resources/resources.ts | 3 +- ui/actions/roles/roles.test.ts | 6 +- ui/actions/roles/roles.ts | 5 +- ui/app/(auth)/(guest-only)/sign-up/page.tsx | 3 +- .../settings/(connect-llm)/connect/page.tsx | 3 +- .../(connect-llm)/select-model/page.tsx | 3 +- .../icons/prowler/ProwlerIcons.test.tsx | 4 +- .../integrations/s3/s3-integration-form.tsx | 8 +- .../app-sidebar/app-sidebar-content.test.tsx | 6 +- .../app-sidebar/navigation-config.test.ts | 24 ++-- .../layout/nav-bar/navbar-client.test.tsx | 6 +- .../__tests__/onboarding-trigger.test.tsx | 6 +- .../aws-method-selector.test.tsx | 2 +- .../table/data-table-row-actions.test.tsx | 12 +- .../use-provider-wizard-controller.test.tsx | 4 +- .../wizard/steps/launch-step.test.tsx | 6 +- .../aws-role-credentials-form.tsx | 3 +- .../workflow/forms/add-role-form.test.tsx | 4 +- .../roles/workflow/forms/add-role-form.tsx | 3 +- .../roles/workflow/forms/edit-role-form.tsx | 3 +- .../roles/workflow/forms/role-form.tsx | 3 +- ui/components/scans/scans-page-shell.test.tsx | 30 ++--- .../table/scan-jobs-row-actions.test.tsx | 10 +- .../shared/cloud-upgrade-modal.test.tsx | 8 +- .../users/profile/role-item.test.tsx | 8 +- ui/config/site.test.ts | 4 +- ui/hooks/use-credentials-form.ts | 3 +- ui/hooks/use-scan-schedule-capability.test.ts | 4 +- ui/lib/env.test.ts | 108 ++++++++++++++++++ ui/lib/env.ts | 33 ++++++ ui/lib/get-runtime-config.client.test.ts | 20 +++- ui/lib/get-runtime-config.client.ts | 35 +----- ui/lib/permissions.test.ts | 4 +- ui/lib/permissions.ts | 3 +- ui/lib/runtime-config.shared.ts | 37 ++++++ ui/lib/runtime-config.ts | 5 +- ui/lib/shared/env.test.ts | 57 +++++++++ ui/lib/shared/env.ts | 16 ++- ui/proxy.ts | 5 +- ui/tests/invitations/invitations.spec.ts | 3 +- ui/tests/providers/providers.spec.ts | 4 +- .../runtime-config/runtime-config-page.ts | 1 + ui/types/authFormSchema.ts | 13 ++- ui/types/env.d.ts | 4 +- ui/vitest.setup.ts | 4 + 50 files changed, 408 insertions(+), 147 deletions(-) create mode 100644 ui/lib/shared/env.test.ts diff --git a/docs/developer-guide/environment-variables.mdx b/docs/developer-guide/environment-variables.mdx index 913444d84b..e2bec0f94b 100644 --- a/docs/developer-guide/environment-variables.mdx +++ b/docs/developer-guide/environment-variables.mdx @@ -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. diff --git a/ui/Dockerfile b/ui/Dockerfile index 3c3d3124f0..41484a3fbe 100644 --- a/ui/Dockerfile +++ b/ui/Dockerfile @@ -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) diff --git a/ui/actions/finding-groups/finding-groups.adapter.test.ts b/ui/actions/finding-groups/finding-groups.adapter.test.ts index e4dcb66804..6d93467fd2 100644 --- a/ui/actions/finding-groups/finding-groups.adapter.test.ts +++ b/ui/actions/finding-groups/finding-groups.adapter.test.ts @@ -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: [ { diff --git a/ui/actions/findings/findings-triage.options.ts b/ui/actions/findings/findings-triage.options.ts index 1d0ad6314a..1f1578ec5c 100644 --- a/ui/actions/findings/findings-triage.options.ts +++ b/ui/actions/findings/findings-triage.options.ts @@ -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, diff --git a/ui/actions/findings/findings.test.ts b/ui/actions/findings/findings.test.ts index efe04f119c..349de1adfe 100644 --- a/ui/actions/findings/findings.test.ts +++ b/ui/actions/findings/findings.test.ts @@ -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 }); diff --git a/ui/actions/resources/resources.ts b/ui/actions/resources/resources.ts index 3ab84efa4f..7150fc2646 100644 --- a/ui/actions/resources/resources.ts +++ b/ui/actions/resources/resources.ts @@ -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([ diff --git a/ui/actions/roles/roles.test.ts b/ui/actions/roles/roles.test.ts index 1e5dfaf189..3546649604 100644 --- a/ui/actions/roles/roles.test.ts +++ b/ui/actions/roles/roles.test.ts @@ -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"); diff --git a/ui/actions/roles/roles.ts b/ui/actions/roles/roles.ts index 645db72951..cfe3837414 100644 --- a/ui/actions/roles/roles.ts +++ b/ui/actions/roles/roles.ts @@ -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 = diff --git a/ui/app/(auth)/(guest-only)/sign-up/page.tsx b/ui/app/(auth)/(guest-only)/sign-up/page.tsx index c625415ce0..0415c6b0f3 100644 --- a/ui/app/(auth)/(guest-only)/sign-up/page.tsx +++ b/ui/app/(auth)/(guest-only)/sign-up/page.tsx @@ -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"); diff --git a/ui/app/(prowler)/lighthouse/settings/(connect-llm)/connect/page.tsx b/ui/app/(prowler)/lighthouse/settings/(connect-llm)/connect/page.tsx index acebbf9cb4..f0f7c29a57 100644 --- a/ui/app/(prowler)/lighthouse/settings/(connect-llm)/connect/page.tsx +++ b/ui/app/(prowler)/lighthouse/settings/(connect-llm)/connect/page.tsx @@ -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); } diff --git a/ui/app/(prowler)/lighthouse/settings/(connect-llm)/select-model/page.tsx b/ui/app/(prowler)/lighthouse/settings/(connect-llm)/select-model/page.tsx index 7b61e92c62..f3e72b3063 100644 --- a/ui/app/(prowler)/lighthouse/settings/(connect-llm)/select-model/page.tsx +++ b/ui/app/(prowler)/lighthouse/settings/(connect-llm)/select-model/page.tsx @@ -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); } diff --git a/ui/components/icons/prowler/ProwlerIcons.test.tsx b/ui/components/icons/prowler/ProwlerIcons.test.tsx index 5fca4420e4..b75c5402af 100644 --- a/ui/components/icons/prowler/ProwlerIcons.test.tsx +++ b/ui/components/icons/prowler/ProwlerIcons.test.tsx @@ -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(); @@ -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(); diff --git a/ui/components/integrations/s3/s3-integration-form.tsx b/ui/components/integrations/s3/s3-integration-form.tsx index fab405dd2b..30587fc3b3 100644 --- a/ui/components/integrations/s3/s3-integration-form.tsx +++ b/ui/components/integrations/s3/s3-integration-form.tsx @@ -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( 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 54dedad07c..d17432e78c 100644 --- a/ui/components/layout/app-sidebar/app-sidebar-content.test.tsx +++ b/ui/components/layout/app-sidebar/app-sidebar-content.test.tsx @@ -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(); diff --git a/ui/components/layout/app-sidebar/navigation-config.test.ts b/ui/components/layout/app-sidebar/navigation-config.test.ts index 9b3cab3501..d79144e5d0 100644 --- a/ui/components/layout/app-sidebar/navigation-config.test.ts +++ b/ui/components/layout/app-sidebar/navigation-config.test.ts @@ -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({ diff --git a/ui/components/layout/nav-bar/navbar-client.test.tsx b/ui/components/layout/nav-bar/navbar-client.test.tsx index c93ed3f6e5..5be780cc30 100644 --- a/ui/components/layout/nav-bar/navbar-client.test.tsx +++ b/ui/components/layout/nav-bar/navbar-client.test.tsx @@ -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( { 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(); diff --git a/ui/components/onboarding/__tests__/onboarding-trigger.test.tsx b/ui/components/onboarding/__tests__/onboarding-trigger.test.tsx index ca9f2cd106..e0f040f832 100644 --- a/ui/components/onboarding/__tests__/onboarding-trigger.test.tsx +++ b/ui/components/onboarding/__tests__/onboarding-trigger.test.tsx @@ -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", diff --git a/ui/components/providers/organizations/aws-method-selector.test.tsx b/ui/components/providers/organizations/aws-method-selector.test.tsx index 9279e8dfb2..7ea1d9a95e 100644 --- a/ui/components/providers/organizations/aws-method-selector.test.tsx +++ b/ui/components/providers/organizations/aws-method-selector.test.tsx @@ -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(); diff --git a/ui/components/providers/table/data-table-row-actions.test.tsx b/ui/components/providers/table/data-table-row-actions.test.tsx index 0a0a774b50..3166cca65d 100644 --- a/ui/components/providers/table/data-table-row-actions.test.tsx +++ b/ui/components/providers/table/data-table-row-actions.test.tsx @@ -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( diff --git a/ui/components/providers/wizard/hooks/use-provider-wizard-controller.test.tsx b/ui/components/providers/wizard/hooks/use-provider-wizard-controller.test.tsx index 6603c90091..b12a541697 100644 --- a/ui/components/providers/wizard/hooks/use-provider-wizard-controller.test.tsx +++ b/ui/components/providers/wizard/hooks/use-provider-wizard-controller.test.tsx @@ -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({ diff --git a/ui/components/providers/wizard/steps/launch-step.test.tsx b/ui/components/providers/wizard/steps/launch-step.test.tsx index dbc31771d9..11bd3bbba6 100644 --- a/ui/components/providers/wizard/steps/launch-step.test.tsx +++ b/ui/components/providers/wizard/steps/launch-step.test.tsx @@ -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" } }); }); diff --git a/ui/components/providers/workflow/forms/select-credentials-type/aws/credentials-type/aws-role-credentials-form.tsx b/ui/components/providers/workflow/forms/select-credentials-type/aws/credentials-type/aws-role-credentials-form.tsx index 44bfde7de4..e344b7e92b 100644 --- a/ui/components/providers/workflow/forms/select-credentials-type/aws/credentials-type/aws-role-credentials-form.tsx +++ b/ui/components/providers/workflow/forms/select-credentials-type/aws/credentials-type/aws-role-credentials-form.tsx @@ -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"; diff --git a/ui/components/roles/workflow/forms/add-role-form.test.tsx b/ui/components/roles/workflow/forms/add-role-form.test.tsx index e5e791fe07..e39683e7dc 100644 --- a/ui/components/roles/workflow/forms/add-role-form.test.tsx +++ b/ui/components/roles/workflow/forms/add-role-form.test.tsx @@ -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(); @@ -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(); diff --git a/ui/components/roles/workflow/forms/add-role-form.tsx b/ui/components/roles/workflow/forms/add-role-form.tsx index 7795500e15..eb16ea4ebe 100644 --- a/ui/components/roles/workflow/forms/add-role-form.tsx +++ b/ui/components/roles/workflow/forms/add-role-form.tsx @@ -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 = { name: "", diff --git a/ui/components/roles/workflow/forms/edit-role-form.tsx b/ui/components/roles/workflow/forms/edit-role-form.tsx index fb42e628a0..f64c7aee3c 100644 --- a/ui/components/roles/workflow/forms/edit-role-form.tsx +++ b/ui/components/roles/workflow/forms/edit-role-form.tsx @@ -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 = { ...roleData.data.attributes, diff --git a/ui/components/roles/workflow/forms/role-form.tsx b/ui/components/roles/workflow/forms/role-form.tsx index d0978f7721..bf6f46b905 100644 --- a/ui/components/roles/workflow/forms/role-form.tsx +++ b/ui/components/roles/workflow/forms/role-form.tsx @@ -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(); diff --git a/ui/components/scans/scans-page-shell.test.tsx b/ui/components/scans/scans-page-shell.test.tsx index c142c2e3ac..bfafb77f13 100644 --- a/ui/components/scans/scans-page-shell.test.tsx +++ b/ui/components/scans/scans-page-shell.test.tsx @@ -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( @@ -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( @@ -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( @@ -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( @@ -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( @@ -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( @@ -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( { }); 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(); diff --git a/ui/components/scans/table/scan-jobs-row-actions.test.tsx b/ui/components/scans/table/scan-jobs-row-actions.test.tsx index e547374098..de4e89ebdc 100644 --- a/ui/components/scans/table/scan-jobs-row-actions.test.tsx +++ b/ui/components/scans/table/scan-jobs-row-actions.test.tsx @@ -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(); @@ -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(); @@ -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( diff --git a/ui/components/shared/cloud-upgrade-modal.test.tsx b/ui/components/shared/cloud-upgrade-modal.test.tsx index 3b2c747dd6..ecfc381796 100644 --- a/ui/components/shared/cloud-upgrade-modal.test.tsx +++ b/ui/components/shared/cloud-upgrade-modal.test.tsx @@ -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); diff --git a/ui/components/users/profile/role-item.test.tsx b/ui/components/users/profile/role-item.test.tsx index 947da3515c..2df8d92ae4 100644 --- a/ui/components/users/profile/role-item.test.tsx +++ b/ui/components/users/profile/role-item.test.tsx @@ -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(); @@ -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(); @@ -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(); @@ -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(); diff --git a/ui/config/site.test.ts b/ui/config/site.test.ts index 686b38c988..ed647ff8ce 100644 --- a/ui/config/site.test.ts +++ b/ui/config/site.test.ts @@ -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"); diff --git a/ui/hooks/use-credentials-form.ts b/ui/hooks/use-credentials-form.ts index 6fee1ac9e5..5dd896cd0b 100644 --- a/ui/hooks/use-credentials-form.ts +++ b/ui/hooks/use-credentials-form.ts @@ -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"; diff --git a/ui/hooks/use-scan-schedule-capability.test.ts b/ui/hooks/use-scan-schedule-capability.test.ts index 9310365cf9..18cee616cb 100644 --- a/ui/hooks/use-scan-schedule-capability.test.ts +++ b/ui/hooks/use-scan-schedule-capability.test.ts @@ -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()); diff --git a/ui/lib/env.test.ts b/ui/lib/env.test.ts index eaeab7e92d..ee916e6b12 100644 --- a/ui/lib/env.test.ts +++ b/ui/lib/env.test.ts @@ -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; + + 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(); + }); +}); diff --git a/ui/lib/env.ts b/ui/lib/env.ts index 46b019d7df..791715afb2 100644 --- a/ui/lib/env.ts +++ b/ui/lib/env.ts @@ -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 {}; diff --git a/ui/lib/get-runtime-config.client.test.ts b/ui/lib/get-runtime-config.client.test.ts index d788a01132..403f66001d 100644 --- a/ui/lib/get-runtime-config.client.test.ts +++ b/ui/lib/get-runtime-config.client.test.ts @@ -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).notAllowlisted, ).toBeUndefined(); diff --git a/ui/lib/get-runtime-config.client.ts b/ui/lib/get-runtime-config.client.ts index e1bcd2bf1a..2b6e98c245 100644 --- a/ui/lib/get-runtime-config.client.ts +++ b/ui/lib/get-runtime-config.client.ts @@ -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 => ({ - 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 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) - : EMPTY_RUNTIME_PUBLIC_CONFIG; - } catch { - resolved = EMPTY_RUNTIME_PUBLIC_CONFIG; - } - - cached = resolved; - return resolved; + cached = readRuntimeConfigIsland() ?? EMPTY_RUNTIME_PUBLIC_CONFIG; + return cached; } diff --git a/ui/lib/permissions.test.ts b/ui/lib/permissions.test.ts index 828b7e8daa..00e409097d 100644 --- a/ui/lib/permissions.test.ts +++ b/ui/lib/permissions.test.ts @@ -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); diff --git a/ui/lib/permissions.ts b/ui/lib/permissions.ts index 9a83582d4a..f34f6d6c0e 100644 --- a/ui/lib/permissions.ts +++ b/ui/lib/permissions.ts @@ -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 = [ { diff --git a/ui/lib/runtime-config.shared.ts b/ui/lib/runtime-config.shared.ts index 6e820296d9..11aa25e52d 100644 --- a/ui/lib/runtime-config.shared.ts +++ b/ui/lib/runtime-config.shared.ts @@ -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 => ({ + 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 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, + ); + } catch { + return null; + } +} diff --git a/ui/lib/runtime-config.ts b/ui/lib/runtime-config.ts index f1ef52900b..889b375ce6 100644 --- a/ui/lib/runtime-config.ts +++ b/ui/lib/runtime-config.ts @@ -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 { "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. diff --git a/ui/lib/shared/env.test.ts b/ui/lib/shared/env.test.ts new file mode 100644 index 0000000000..6154171e94 --- /dev/null +++ b/ui/lib/shared/env.test.ts @@ -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) => { + 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); + }); + }); +}); diff --git a/ui/lib/shared/env.ts b/ui/lib/shared/env.ts index 766394b970..398822bd02 100644 --- a/ui/lib/shared/env.ts +++ b/ui/lib/shared/env.ts @@ -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 + * 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"); } diff --git a/ui/proxy.ts b/ui/proxy.ts index 8784fd260f..4766d8b6ea 100644 --- a/ui/proxy.ts +++ b/ui/proxy.ts @@ -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)); } diff --git a/ui/tests/invitations/invitations.spec.ts b/ui/tests/invitations/invitations.spec.ts index cf19f5181a..d583851e1c 100644 --- a/ui/tests/invitations/invitations.spec.ts +++ b/ui/tests/invitations/invitations.spec.ts @@ -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; diff --git a/ui/tests/providers/providers.spec.ts b/ui/tests/providers/providers.spec.ts index b461808c65..8900f3fd5f 100644 --- a/ui/tests/providers/providers.spec.ts +++ b/ui/tests/providers/providers.spec.ts @@ -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", ); diff --git a/ui/tests/runtime-config/runtime-config-page.ts b/ui/tests/runtime-config/runtime-config-page.ts index 430033eba4..a9e5242b92 100644 --- a/ui/tests/runtime-config/runtime-config-page.ts +++ b/ui/tests/runtime-config/runtime-config-page.ts @@ -20,6 +20,7 @@ export const RUNTIME_CONFIG_KEYS = [ "posthogHost", "reoDevClientId", "cloudBillingEnabled", + "cloudEnabled", "stripePublishableKey", "stripePublishableKeyV2", ] as const satisfies ReadonlyArray; diff --git a/ui/types/authFormSchema.ts b/ui/types/authFormSchema.ts index 42e1d1d594..1d2c1074f9 100644 --- a/ui/types/authFormSchema.ts +++ b/ui/types/authFormSchema.ts @@ -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) => { diff --git a/ui/types/env.d.ts b/ui/types/env.d.ts index c4ec4d85a3..8b5a1ce039 100644 --- a/ui/types/env.d.ts +++ b/ui/types/env.d.ts @@ -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) diff --git a/ui/vitest.setup.ts b/ui/vitest.setup.ts index 796e3ea900..2cc2aa8a8a 100644 --- a/ui/vitest.setup.ts +++ b/ui/vitest.setup.ts @@ -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();