diff --git a/docs/developer-guide/environment-variables.mdx b/docs/developer-guide/environment-variables.mdx index a6ba5c9d54..d798faa6c7 100644 --- a/docs/developer-guide/environment-variables.mdx +++ b/docs/developer-guide/environment-variables.mdx @@ -40,6 +40,7 @@ The former build-time variables map to the new runtime variables as follows: `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. +`UI_SELF_REGISTRATION_ENABLED` is a runtime opt-out flag that Prowler Local Server reads only when `UI_CLOUD_ENABLED` is `"true"`. It defaults to on and turns off when set to `"false"`, matched case-insensitively so the same value can be shared with a backend setting written `False`. When it is off, the sign-up page only opens with an invitation token, the sign-in page drops its "Sign up" link, and the profile hides "Create organization"; invited users can still complete their registration. Outside a Prowler Cloud deployment the flag is ignored and account creation stays open. ## Registry UI Rollout and Rollback `UI_REGISTRY_ENABLED` is an optional runtime flag for Prowler Cloud and Private Cloud. Registry is eligible only when both `UI_CLOUD_ENABLED` and `UI_REGISTRY_ENABLED` are the exact string `"true"` and the current user has the backend-authorized `manage_registry` permission. Unset, `"false"`, or malformed values fail closed. The flag defaults to off and is not a replacement for backend authorization. Registry access is independent of billing; Private Cloud can use it with `CLOUD_BILLING_ENABLED=false`. diff --git a/ui/Dockerfile b/ui/Dockerfile index 278c8b8602..22149a9fa9 100644 --- a/ui/Dockerfile +++ b/ui/Dockerfile @@ -100,6 +100,7 @@ ENV HOSTNAME="0.0.0.0" # - 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) +# - optional: UI_SELF_REGISTRATION_ENABLED (Prowler Cloud only; "false" hides sign-up, invited users can still register) # - optional: UI_REGISTRY_ENABLED ("true" only after the Registry dependency, # Cloud role grant, and controlled acceptance are ready; unset/false hides Registry) # - gated integrations (load only when *_ENABLED="true"; the value is then diff --git a/ui/app/(auth)/(guest-only)/sign-in/page.tsx b/ui/app/(auth)/(guest-only)/sign-in/page.tsx index c36226e61f..1ee3c37989 100644 --- a/ui/app/(auth)/(guest-only)/sign-in/page.tsx +++ b/ui/app/(auth)/(guest-only)/sign-in/page.tsx @@ -4,6 +4,7 @@ import { isGithubOAuthEnabled, isGoogleOAuthEnabled, } from "@/lib/helper"; +import { isSelfRegistrationEnabled } from "@/lib/shared/env"; const SignIn = () => { const GOOGLE_AUTH_URL = getAuthUrl("google"); @@ -15,6 +16,7 @@ const SignIn = () => { githubAuthUrl={GITHUB_AUTH_URL} isGoogleOAuthEnabled={isGoogleOAuthEnabled} isGithubOAuthEnabled={isGithubOAuthEnabled} + isSelfRegistrationEnabled={isSelfRegistrationEnabled()} /> ); }; diff --git a/ui/app/(auth)/(guest-only)/sign-up/page.test.tsx b/ui/app/(auth)/(guest-only)/sign-up/page.test.tsx new file mode 100644 index 0000000000..d1abc10660 --- /dev/null +++ b/ui/app/(auth)/(guest-only)/sign-up/page.test.tsx @@ -0,0 +1,86 @@ +import { render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import SignUp from "./page"; + +const { redirectMock, isSelfRegistrationEnabledMock } = vi.hoisted(() => ({ + redirectMock: vi.fn(), + isSelfRegistrationEnabledMock: vi.fn(), +})); + +vi.mock("next/navigation", () => ({ + redirect: redirectMock, +})); + +vi.mock("@/lib/shared/env", () => ({ + isCloud: () => false, + isSelfRegistrationEnabled: isSelfRegistrationEnabledMock, +})); + +vi.mock("@/lib/helper", () => ({ + getAuthUrl: () => "", + isGithubOAuthEnabled: false, + isGoogleOAuthEnabled: false, +})); + +vi.mock("@/components/auth/oss", () => ({ + AuthForm: ({ invitationToken }: { invitationToken?: string | null }) => ( +
+ ), +})); + +const renderPage = (searchParams: Record = {}) => + SignUp({ searchParams: Promise.resolve(searchParams) }); + +describe("SignUp page", () => { + beforeEach(() => { + vi.clearAllMocks(); + // next/navigation's redirect() never returns; mirror that so the page + // stops rendering the way it does in Next. + redirectMock.mockImplementation((url: string) => { + throw new Error(`NEXT_REDIRECT:${url}`); + }); + }); + + describe("when self-registration is enabled", () => { + it("should render the sign-up form", async () => { + // Given + isSelfRegistrationEnabledMock.mockReturnValue(true); + + // When + render(await renderPage()); + + // Then + expect(screen.getByTestId("auth-form")).toBeInTheDocument(); + expect(redirectMock).not.toHaveBeenCalled(); + }); + }); + + describe("when self-registration is disabled", () => { + it("should redirect to sign-in without an invitation", async () => { + // Given + isSelfRegistrationEnabledMock.mockReturnValue(false); + + // When / Then + await expect(renderPage()).rejects.toThrow("NEXT_REDIRECT:/sign-in"); + }); + + it("should still render the form for an invited user", async () => { + // Given + isSelfRegistrationEnabledMock.mockReturnValue(false); + + // When + render(await renderPage({ invitation_token: "TESTING1234567" })); + + // Then + expect(screen.getByTestId("auth-form")).toHaveAttribute( + "data-invitation-token", + "TESTING1234567", + ); + expect(redirectMock).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/ui/app/(auth)/(guest-only)/sign-up/page.tsx b/ui/app/(auth)/(guest-only)/sign-up/page.tsx index 0415c6b0f3..884eedfa70 100644 --- a/ui/app/(auth)/(guest-only)/sign-up/page.tsx +++ b/ui/app/(auth)/(guest-only)/sign-up/page.tsx @@ -1,10 +1,12 @@ +import { redirect } from "next/navigation"; + import { AuthForm } from "@/components/auth/oss"; import { getAuthUrl, isGithubOAuthEnabled, isGoogleOAuthEnabled, } from "@/lib/helper"; -import { isCloud } from "@/lib/shared/env"; +import { isCloud, isSelfRegistrationEnabled } from "@/lib/shared/env"; import { SearchParamsProps } from "@/types"; const SignUp = async ({ @@ -17,6 +19,9 @@ const SignUp = async ({ typeof resolvedSearchParams?.invitation_token === "string" ? resolvedSearchParams.invitation_token : null; + if (!invitationToken && !isSelfRegistrationEnabled()) { + redirect("/sign-in"); + } const isCloudEnv = isCloud(); const GOOGLE_AUTH_URL = getAuthUrl("google"); diff --git a/ui/app/api/auth/callback/github/route.test.ts b/ui/app/api/auth/callback/github/route.test.ts new file mode 100644 index 0000000000..5e454e778e --- /dev/null +++ b/ui/app/api/auth/callback/github/route.test.ts @@ -0,0 +1,68 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { fetchMock, signInMock } = vi.hoisted(() => ({ + fetchMock: vi.fn(), + signInMock: vi.fn(), +})); + +vi.mock("@/auth.config", () => ({ + signIn: signInMock, +})); + +vi.mock("@/lib/helper", () => ({ + apiBaseUrl: "https://api.example.com/api/v1", + baseUrl: "https://app.example.com", +})); + +import { GET } from "./route"; + +describe("GitHub OAuth callback route", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.stubGlobal("fetch", fetchMock); + signInMock.mockResolvedValue({}); + }); + + it("redirects to sign-in with a specific error when self-registration is disabled", async () => { + // Given + fetchMock.mockResolvedValue( + Response.json( + { errors: [{ code: "self_registration_disabled", status: "403" }] }, + { status: 403 }, + ), + ); + const request = new Request( + "https://app.example.com/api/auth/callback/github?code=oauth-code", + ); + + // When + const response = await GET(request); + + // Then + expect(fetchMock.mock.calls[0][0]).toBe( + "https://api.example.com/api/v1/tokens/github", + ); + expect(response.headers.get("location")).toBe( + "https://app.example.com/sign-in?error=SelfRegistrationDisabled", + ); + expect(signInMock).not.toHaveBeenCalled(); + }); + + it("keeps the generic failure for other token exchange errors", async () => { + // Given + fetchMock.mockResolvedValue( + Response.json({ errors: [{ status: "400" }] }, { status: 400 }), + ); + const request = new Request( + "https://app.example.com/api/auth/callback/github?code=oauth-code", + ); + + // When + const response = await GET(request); + + // Then + expect(response.headers.get("location")).toBe( + "https://app.example.com/sign-in?error=AuthenticationFailed", + ); + }); +}); diff --git a/ui/app/api/auth/callback/github/route.ts b/ui/app/api/auth/callback/github/route.ts index a152206125..76f9ba790f 100644 --- a/ui/app/api/auth/callback/github/route.ts +++ b/ui/app/api/auth/callback/github/route.ts @@ -7,6 +7,7 @@ import { getAttributionParamsFromCallbackPath, getInvitationTokenFromCallbackPath, getSafeCallbackPath, + isSelfRegistrationDisabledResponse, } from "@/lib/auth-callback-url"; import { apiBaseUrl, baseUrl } from "@/lib/helper"; @@ -44,6 +45,11 @@ export async function GET(req: Request) { }); if (!response.ok) { + if (await isSelfRegistrationDisabledResponse(response)) { + return NextResponse.redirect( + new URL("/sign-in?error=SelfRegistrationDisabled", baseUrl), + ); + } throw new Error("Failed to exchange code for tokens"); } diff --git a/ui/app/api/auth/callback/google/route.test.ts b/ui/app/api/auth/callback/google/route.test.ts index 96599d345a..bb1b8de3f4 100644 --- a/ui/app/api/auth/callback/google/route.test.ts +++ b/ui/app/api/auth/callback/google/route.test.ts @@ -51,4 +51,44 @@ describe("Google OAuth callback route", () => { expect(body.get("promo_code")).toBe("black-hat-2026"); expect(body.get("utm_source")).toBe("blackhat"); }); + + it("redirects to sign-in with a specific error when self-registration is disabled", async () => { + // Given + fetchMock.mockResolvedValue( + Response.json( + { errors: [{ code: "self_registration_disabled", status: "403" }] }, + { status: 403 }, + ), + ); + const request = new Request( + "https://app.example.com/api/auth/callback/google?code=oauth-code", + ); + + // When + const response = await GET(request); + + // Then + expect(response.headers.get("location")).toBe( + "https://app.example.com/sign-in?error=SelfRegistrationDisabled", + ); + expect(signInMock).not.toHaveBeenCalled(); + }); + + it("keeps the generic failure for other token exchange errors", async () => { + // Given + fetchMock.mockResolvedValue( + Response.json({ errors: [{ status: "400" }] }, { status: 400 }), + ); + const request = new Request( + "https://app.example.com/api/auth/callback/google?code=oauth-code", + ); + + // When + const response = await GET(request); + + // Then + expect(response.headers.get("location")).toBe( + "https://app.example.com/sign-in?error=AuthenticationFailed", + ); + }); }); diff --git a/ui/app/api/auth/callback/google/route.ts b/ui/app/api/auth/callback/google/route.ts index fc8e263a94..fdf81c57bb 100644 --- a/ui/app/api/auth/callback/google/route.ts +++ b/ui/app/api/auth/callback/google/route.ts @@ -7,6 +7,7 @@ import { getAttributionParamsFromCallbackPath, getInvitationTokenFromCallbackPath, getSafeCallbackPath, + isSelfRegistrationDisabledResponse, } from "@/lib/auth-callback-url"; import { apiBaseUrl, baseUrl } from "@/lib/helper"; @@ -44,6 +45,11 @@ export async function GET(req: Request) { }); if (!response.ok) { + if (await isSelfRegistrationDisabledResponse(response)) { + return NextResponse.redirect( + new URL("/sign-in?error=SelfRegistrationDisabled", baseUrl), + ); + } throw new Error("Failed to exchange code for tokens"); } diff --git a/ui/changelog.d/disable-self-registration.added.md b/ui/changelog.d/disable-self-registration.added.md new file mode 100644 index 0000000000..46e62eb1b3 --- /dev/null +++ b/ui/changelog.d/disable-self-registration.added.md @@ -0,0 +1 @@ +`UI_SELF_REGISTRATION_ENABLED` flag for Prowler Private Cloud deployments; when `"false"`, `/sign-up` only opens with an invitation, the sign-in page drops the "Sign up" link and the profile hides **Create organization** diff --git a/ui/components/auth/oss/auth-form.tsx b/ui/components/auth/oss/auth-form.tsx index a9f3e003d3..953bc1235b 100644 --- a/ui/components/auth/oss/auth-form.tsx +++ b/ui/components/auth/oss/auth-form.tsx @@ -9,6 +9,7 @@ export const AuthForm = ({ githubAuthUrl, isGoogleOAuthEnabled, isGithubOAuthEnabled, + isSelfRegistrationEnabled = true, }: { type: string; invitationToken?: string | null; @@ -17,6 +18,7 @@ export const AuthForm = ({ githubAuthUrl?: string; isGoogleOAuthEnabled?: boolean; isGithubOAuthEnabled?: boolean; + isSelfRegistrationEnabled?: boolean; }) => { if (type === "sign-in") { return ( @@ -25,6 +27,7 @@ export const AuthForm = ({ githubAuthUrl={githubAuthUrl} isGoogleOAuthEnabled={isGoogleOAuthEnabled} isGithubOAuthEnabled={isGithubOAuthEnabled} + isSelfRegistrationEnabled={isSelfRegistrationEnabled} /> ); } diff --git a/ui/components/auth/oss/sign-in-form.tsx b/ui/components/auth/oss/sign-in-form.tsx index 43ad982217..ed51346738 100644 --- a/ui/components/auth/oss/sign-in-form.tsx +++ b/ui/components/auth/oss/sign-in-form.tsx @@ -34,11 +34,13 @@ export const SignInForm = ({ githubAuthUrl, isGoogleOAuthEnabled, isGithubOAuthEnabled, + isSelfRegistrationEnabled = true, }: { googleAuthUrl?: string; githubAuthUrl?: string; isGoogleOAuthEnabled?: boolean; isGithubOAuthEnabled?: boolean; + isSelfRegistrationEnabled?: boolean; }) => { const router = useRouter(); const searchParams = useSearchParams(); @@ -80,6 +82,11 @@ export const SignInForm = ({ description: "There was a problem with your session. Please sign in again.", }, + SelfRegistrationDisabled: { + title: "Registration Disabled", + description: + "Self-registration is disabled. Ask an administrator for an invitation.", + }, }; const errorConfig = errorMessages[sessionError] || { @@ -161,11 +168,13 @@ export const SignInForm = ({ + isSelfRegistrationEnabled ? ( + + ) : undefined } >
diff --git a/ui/components/integrations/saml/saml-config-form.test.tsx b/ui/components/integrations/saml/saml-config-form.test.tsx index 4949a9b255..5f567bba4e 100644 --- a/ui/components/integrations/saml/saml-config-form.test.tsx +++ b/ui/components/integrations/saml/saml-config-form.test.tsx @@ -58,6 +58,7 @@ const runtimeConfig: RuntimePublicConfig = { reoDevClientId: null, cloudEnabled: false, cloudBillingEnabled: false, + selfRegistrationEnabled: true, stripePublishableKey: null, stripePublishableKeyV2: null, }; diff --git a/ui/components/users/profile/memberships-card-client.test.tsx b/ui/components/users/profile/memberships-card-client.test.tsx index c5ae7f5e4e..f18b6cc36b 100644 --- a/ui/components/users/profile/memberships-card-client.test.tsx +++ b/ui/components/users/profile/memberships-card-client.test.tsx @@ -205,4 +205,22 @@ describe("MembershipsCardClient", () => { screen.getByRole("menuitem", { name: /delete organization/i }), ).toBeInTheDocument(); }); + + it("hides the create organization action when self-registration is disabled", () => { + // Given / When + render( + , + ); + + // Then + expect( + screen.queryByRole("button", { name: "Create organization" }), + ).not.toBeInTheDocument(); + }); }); diff --git a/ui/components/users/profile/memberships-card-client.tsx b/ui/components/users/profile/memberships-card-client.tsx index 10b7f28e24..7f05b9cd64 100644 --- a/ui/components/users/profile/memberships-card-client.tsx +++ b/ui/components/users/profile/memberships-card-client.tsx @@ -52,6 +52,7 @@ interface MembershipsCardClientProps { tenantsMap: Record; hasManageAccount: boolean; sessionTenantId: string | undefined; + canCreateOrganization?: boolean; } const OrganizationNameCell = ({ name }: { name: string }) => ( @@ -204,6 +205,7 @@ export const MembershipsCardClient = ({ tenantsMap, hasManageAccount, sessionTenantId, + canCreateOrganization = true, }: MembershipsCardClientProps) => { const [isCreateOpen, setIsCreateOpen] = useState(false); @@ -232,13 +234,15 @@ export const MembershipsCardClient = ({ return ( <> - - - + {canCreateOrganization && ( + + + + )}
@@ -250,15 +254,17 @@ export const MembershipsCardClient = ({

- - - + {canCreateOrganization && ( + + + + )}
{memberships.length === 0 ? ( diff --git a/ui/components/users/profile/memberships-card.tsx b/ui/components/users/profile/memberships-card.tsx index 56e03cb9f8..aa5b8884aa 100644 --- a/ui/components/users/profile/memberships-card.tsx +++ b/ui/components/users/profile/memberships-card.tsx @@ -1,3 +1,4 @@ +import { isSelfRegistrationEnabled } from "@/lib/shared/env"; import { MembershipDetailData, TenantDetailData } from "@/types/users"; import { MembershipsCardClient } from "./memberships-card-client"; @@ -19,6 +20,7 @@ export const MembershipsCard = ({ tenantsMap={tenantsMap} hasManageAccount={hasManageAccount} sessionTenantId={sessionTenantId} + canCreateOrganization={isSelfRegistrationEnabled()} /> ); }; diff --git a/ui/lib/auth-callback-url.test.ts b/ui/lib/auth-callback-url.test.ts index 3153a06eac..0bac5abcf8 100644 --- a/ui/lib/auth-callback-url.test.ts +++ b/ui/lib/auth-callback-url.test.ts @@ -6,6 +6,7 @@ import { getAttributionParamsFromCallbackPath, getInvitationTokenFromCallbackPath, getSafeCallbackPath, + isSelfRegistrationDisabledResponse, } from "@/lib/auth-callback-url"; describe("auth callback URL helpers", () => { @@ -150,3 +151,40 @@ describe("auth callback URL helpers", () => { }); }); }); + +describe("isSelfRegistrationDisabledResponse", () => { + it("is true for a 403 carrying the self_registration_disabled code", async () => { + const response = Response.json( + { errors: [{ code: "self_registration_disabled", status: "403" }] }, + { status: 403 }, + ); + + await expect(isSelfRegistrationDisabledResponse(response)).resolves.toBe( + true, + ); + }); + + it("is false for a 403 with another code", async () => { + const response = Response.json( + { errors: [{ code: "partner_provisioned", status: "403" }] }, + { status: 403 }, + ); + + await expect(isSelfRegistrationDisabledResponse(response)).resolves.toBe( + false, + ); + }); + + it("is false for non-403 responses and unparsable bodies", async () => { + await expect( + isSelfRegistrationDisabledResponse( + new Response("self_registration_disabled", { status: 400 }), + ), + ).resolves.toBe(false); + await expect( + isSelfRegistrationDisabledResponse( + new Response("not json", { status: 403 }), + ), + ).resolves.toBe(false); + }); +}); diff --git a/ui/lib/auth-callback-url.ts b/ui/lib/auth-callback-url.ts index 2cba07ff6d..1e370887e1 100644 --- a/ui/lib/auth-callback-url.ts +++ b/ui/lib/auth-callback-url.ts @@ -100,3 +100,25 @@ export const getAttributionParamsFromCallbackPath = ( return {}; } }; + +const SELF_REGISTRATION_DISABLED_CODE = "self_registration_disabled"; + +// The API answers a social login from a brand-new user with this error code +// when the deployment only allows invited users. +export const isSelfRegistrationDisabledResponse = async ( + response: Response, +): Promise => { + if (response.status !== 403) return false; + try { + const body = (await response.json()) as { + errors?: Array<{ code?: string }>; + }; + return ( + body.errors?.some( + (error) => error.code === SELF_REGISTRATION_DISABLED_CODE, + ) ?? false + ); + } catch (_error) { + return false; + } +}; diff --git a/ui/lib/get-runtime-config.client.test.ts b/ui/lib/get-runtime-config.client.test.ts index 2c0362cb40..3c4fc0fef5 100644 --- a/ui/lib/get-runtime-config.client.test.ts +++ b/ui/lib/get-runtime-config.client.test.ts @@ -146,6 +146,7 @@ describe("getRuntimeConfigClient", () => { "posthogKey", "posthogUiHost", "reoDevClientId", + "selfRegistrationEnabled", "sentryDsn", "sentryEnvironment", "stripePublishableKey", @@ -157,6 +158,8 @@ describe("getRuntimeConfigClient", () => { // false (not null) when absent from the island. expect(config.cloudBillingEnabled).toBe(false); expect(config.cloudEnabled).toBe(false); + // Opt-out flag: absent from the island means self-registration stays on. + expect(config.selfRegistrationEnabled).toBe(true); expect( (config as unknown as Record).notAllowlisted, ).toBeUndefined(); diff --git a/ui/lib/runtime-config.shared.ts b/ui/lib/runtime-config.shared.ts index db1318fb21..c6a5166cf6 100644 --- a/ui/lib/runtime-config.shared.ts +++ b/ui/lib/runtime-config.shared.ts @@ -13,6 +13,7 @@ export interface RuntimePublicConfig { reoDevClientId: string | null; // reserved cloudEnabled: boolean; cloudBillingEnabled: boolean; + selfRegistrationEnabled: boolean; stripePublishableKey: string | null; // reserved stripePublishableKeyV2: string | null; // reserved } @@ -33,6 +34,7 @@ export const EMPTY_RUNTIME_PUBLIC_CONFIG: RuntimePublicConfig = { reoDevClientId: null, cloudEnabled: false, cloudBillingEnabled: false, + selfRegistrationEnabled: true, stripePublishableKey: null, stripePublishableKeyV2: null, }; @@ -53,6 +55,7 @@ const pickConfig = ( reoDevClientId: parsed.reoDevClientId ?? null, cloudEnabled: parsed.cloudEnabled ?? false, cloudBillingEnabled: parsed.cloudBillingEnabled ?? false, + selfRegistrationEnabled: parsed.selfRegistrationEnabled ?? true, stripePublishableKey: parsed.stripePublishableKey ?? null, stripePublishableKeyV2: parsed.stripePublishableKeyV2 ?? null, }); diff --git a/ui/lib/runtime-config.test.ts b/ui/lib/runtime-config.test.ts index 412cc1e185..3ca2fcb405 100644 --- a/ui/lib/runtime-config.test.ts +++ b/ui/lib/runtime-config.test.ts @@ -72,3 +72,27 @@ describe("getRuntimePublicConfig PostHog hosts", () => { expect(config.posthogUiHost).toBeNull(); }); }); + +describe("getRuntimePublicConfig self-registration flag", () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it("is enabled when UI_SELF_REGISTRATION_ENABLED is unset", async () => { + vi.stubEnv("UI_SELF_REGISTRATION_ENABLED", undefined); + + const { getRuntimePublicConfig } = await importFresh(); + const config = await getRuntimePublicConfig(); + + expect(config.selfRegistrationEnabled).toBe(true); + }); + + it('is disabled only when UI_SELF_REGISTRATION_ENABLED is "false"', async () => { + vi.stubEnv("UI_SELF_REGISTRATION_ENABLED", "false"); + + const { getRuntimePublicConfig } = await importFresh(); + const config = await getRuntimePublicConfig(); + + expect(config.selfRegistrationEnabled).toBe(false); + }); +}); diff --git a/ui/lib/runtime-config.ts b/ui/lib/runtime-config.ts index 29d38ca952..ba71f22f7d 100644 --- a/ui/lib/runtime-config.ts +++ b/ui/lib/runtime-config.ts @@ -8,7 +8,7 @@ import { readGatedEnv, } from "@/lib/integrations"; import { type RuntimePublicConfig } from "@/lib/runtime-config.shared"; -import { readBoolEnv, readEnv } from "@/lib/runtime-env"; +import { readBoolEnv, readEnv, readOptOutEnv } 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 @@ -51,6 +51,8 @@ export async function getRuntimePublicConfig(): Promise { posthogUiHost: readGatedEnv("UI_POSTHOG_ENABLED", "UI_POSTHOG_UI_HOST"), reoDevClientId: readEnv("REO_DEV_CLIENT_ID"), cloudEnabled: readBoolEnv("UI_CLOUD_ENABLED"), + // Off only when explicitly "false": invited users can still register. + selfRegistrationEnabled: readOptOutEnv("UI_SELF_REGISTRATION_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/runtime-env.test.ts b/ui/lib/runtime-env.test.ts index 7735622c89..02ae75b972 100644 --- a/ui/lib/runtime-env.test.ts +++ b/ui/lib/runtime-env.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { readBoolEnv, readEnv } from "./runtime-env"; +import { readBoolEnv, readEnv, readOptOutEnv } from "./runtime-env"; describe("readEnv", () => { afterEach(() => { @@ -121,3 +121,29 @@ describe("readBoolEnv", () => { } }); }); + +describe("readOptOutEnv", () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it("is true when unset", () => { + vi.stubEnv("UI_SELF_REGISTRATION_ENABLED", undefined); + + expect(readOptOutEnv("UI_SELF_REGISTRATION_ENABLED")).toBe(true); + }); + + it('is false for "false" in any case, whitespace trimmed', () => { + for (const value of ["false", " False ", "FALSE"]) { + vi.stubEnv("UI_SELF_REGISTRATION_ENABLED", value); + expect(readOptOutEnv("UI_SELF_REGISTRATION_ENABLED")).toBe(false); + } + }); + + it('stays true for any other value ("true", "0", "no")', () => { + for (const value of ["true", "0", "no"]) { + vi.stubEnv("UI_SELF_REGISTRATION_ENABLED", value); + expect(readOptOutEnv("UI_SELF_REGISTRATION_ENABLED")).toBe(true); + } + }); +}); diff --git a/ui/lib/runtime-env.ts b/ui/lib/runtime-env.ts index 430d4f298d..92ad218b16 100644 --- a/ui/lib/runtime-env.ts +++ b/ui/lib/runtime-env.ts @@ -24,3 +24,9 @@ export function readEnv( export function readBoolEnv(key: keyof NodeJS.ProcessEnv): boolean { return (readEnv(key) ?? "").trim() === "true"; } + +// Reads a runtime boolean flag that is on unless set to "false". Case-insensitive +// because the same value is often shared with a Django setting written "False". +export function readOptOutEnv(key: keyof NodeJS.ProcessEnv): boolean { + return (readEnv(key) ?? "").trim().toLowerCase() !== "false"; +} diff --git a/ui/lib/shared/env.test.ts b/ui/lib/shared/env.test.ts index 6154171e94..715e829989 100644 --- a/ui/lib/shared/env.test.ts +++ b/ui/lib/shared/env.test.ts @@ -2,7 +2,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { RUNTIME_CONFIG_SCRIPT_ID } from "@/lib/runtime-config.shared"; -import { isCloud } from "./env"; +import { isCloud, isSelfRegistrationEnabled } from "./env"; const writeIsland = (content: Record | string) => { const el = document.createElement("script"); @@ -55,3 +55,43 @@ describe("isCloud", () => { }); }); }); + +describe("isSelfRegistrationEnabled", () => { + afterEach(() => { + vi.unstubAllEnvs(); + document.head.innerHTML = ""; + }); + + it('returns true outside Prowler Cloud even when UI_SELF_REGISTRATION_ENABLED is "false"', () => { + vi.stubEnv("UI_SELF_REGISTRATION_ENABLED", "false"); + expect(isSelfRegistrationEnabled()).toBe(true); + }); + + it("returns true in Prowler Cloud when UI_SELF_REGISTRATION_ENABLED is unset", () => { + vi.stubEnv("UI_CLOUD_ENABLED", "true"); + expect(isSelfRegistrationEnabled()).toBe(true); + }); + + it('returns false in Prowler Cloud when UI_SELF_REGISTRATION_ENABLED is "false"', () => { + vi.stubEnv("UI_CLOUD_ENABLED", "true"); + vi.stubEnv("UI_SELF_REGISTRATION_ENABLED", "false"); + expect(isSelfRegistrationEnabled()).toBe(false); + }); + + it("uses the island flags over the env vars", () => { + vi.stubEnv("UI_SELF_REGISTRATION_ENABLED", "true"); + writeIsland({ cloudEnabled: true, selfRegistrationEnabled: false }); + expect(isSelfRegistrationEnabled()).toBe(false); + }); + + it("ignores a disabled island flag outside Prowler Cloud", () => { + writeIsland({ cloudEnabled: false, selfRegistrationEnabled: false }); + expect(isSelfRegistrationEnabled()).toBe(true); + }); + + it("defaults to true when the island omits the flag", () => { + vi.stubEnv("UI_SELF_REGISTRATION_ENABLED", "false"); + writeIsland({ cloudEnabled: true }); + expect(isSelfRegistrationEnabled()).toBe(true); + }); +}); diff --git a/ui/lib/shared/env.ts b/ui/lib/shared/env.ts index 398822bd02..d54c9d04e0 100644 --- a/ui/lib/shared/env.ts +++ b/ui/lib/shared/env.ts @@ -2,7 +2,7 @@ * Shared environment helpers. */ import { readRuntimeConfigIsland } from "@/lib/runtime-config.shared"; -import { readBoolEnv } from "@/lib/runtime-env"; +import { readBoolEnv, readOptOutEnv } from "@/lib/runtime-env"; /** * Whether the UI is running inside a Prowler Cloud deployment. @@ -20,3 +20,18 @@ export function isCloud(): boolean { return readBoolEnv("UI_CLOUD_ENABLED"); } + +/** + * Whether visitors can create an account without an invitation. + * + * Prowler Cloud only: always on elsewhere. In Cloud it follows + * `UI_SELF_REGISTRATION_ENABLED` (island, then env), on unless "false". + */ +export function isSelfRegistrationEnabled(): boolean { + if (!isCloud()) return true; + + const islandConfig = readRuntimeConfigIsland(); + if (islandConfig) return islandConfig.selfRegistrationEnabled; + + return readOptOutEnv("UI_SELF_REGISTRATION_ENABLED"); +} diff --git a/ui/tests/runtime-config/runtime-config-page.ts b/ui/tests/runtime-config/runtime-config-page.ts index 35d66672c3..e2a651edec 100644 --- a/ui/tests/runtime-config/runtime-config-page.ts +++ b/ui/tests/runtime-config/runtime-config-page.ts @@ -23,6 +23,7 @@ export const RUNTIME_CONFIG_KEYS = [ "reoDevClientId", "cloudBillingEnabled", "cloudEnabled", + "selfRegistrationEnabled", "stripePublishableKey", "stripePublishableKeyV2", ] as const satisfies ReadonlyArray;