feat(ui): allow disabling self-registration in Cloud (#12815)

This commit is contained in:
Pedro Martín
2026-09-16 14:00:54 +02:00
committed by GitHub
parent 2198ba2d84
commit 9f616a5d43
27 changed files with 461 additions and 26 deletions
@@ -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`.
+1
View File
@@ -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
@@ -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()}
/>
);
};
@@ -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 }) => (
<div
data-testid="auth-form"
data-invitation-token={invitationToken ?? ""}
/>
),
}));
const renderPage = (searchParams: Record<string, string> = {}) =>
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();
});
});
});
+6 -1
View File
@@ -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");
@@ -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",
);
});
});
+6
View File
@@ -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");
}
@@ -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",
);
});
});
+6
View File
@@ -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");
}
@@ -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**
+3
View File
@@ -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}
/>
);
}
+14 -5
View File
@@ -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 = ({
<AuthLayout
title={title}
footer={
<AuthFooterLink
text="Need to create an account?"
linkText="Sign up"
href="/sign-up"
/>
isSelfRegistrationEnabled ? (
<AuthFooterLink
text="Need to create an account?"
linkText="Sign up"
href="/sign-up"
/>
) : undefined
}
>
<Form {...form}>
@@ -58,6 +58,7 @@ const runtimeConfig: RuntimePublicConfig = {
reoDevClientId: null,
cloudEnabled: false,
cloudBillingEnabled: false,
selfRegistrationEnabled: true,
stripePublishableKey: null,
stripePublishableKeyV2: null,
};
@@ -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(
<MembershipsCardClient
memberships={memberships}
tenantsMap={tenantsMap}
hasManageAccount={true}
sessionTenantId="tenant-1"
canCreateOrganization={false}
/>,
);
// Then
expect(
screen.queryByRole("button", { name: "Create organization" }),
).not.toBeInTheDocument();
});
});
@@ -52,6 +52,7 @@ interface MembershipsCardClientProps {
tenantsMap: Record<string, TenantDetailData>;
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 (
<>
<Modal
open={isCreateOpen}
onOpenChange={setIsCreateOpen}
title="Create organization"
>
<CreateTenantForm setIsOpen={setIsCreateOpen} />
</Modal>
{canCreateOrganization && (
<Modal
open={isCreateOpen}
onOpenChange={setIsCreateOpen}
title="Create organization"
>
<CreateTenantForm setIsOpen={setIsCreateOpen} />
</Modal>
)}
<Card variant="inner" padding="none" className="gap-4 p-4 md:p-5">
<CardHeader>
<div className="flex flex-col gap-1">
@@ -250,15 +254,17 @@ export const MembershipsCardClient = ({
</CustomLink>
</p>
</div>
<CardAction>
<Button
variant="default"
size="sm"
onClick={() => setIsCreateOpen(true)}
>
Create organization
</Button>
</CardAction>
{canCreateOrganization && (
<CardAction>
<Button
variant="default"
size="sm"
onClick={() => setIsCreateOpen(true)}
>
Create organization
</Button>
</CardAction>
)}
</CardHeader>
<CardContent>
{memberships.length === 0 ? (
@@ -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()}
/>
);
};
+38
View File
@@ -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);
});
});
+22
View File
@@ -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<boolean> => {
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;
}
};
+3
View File
@@ -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<string, unknown>).notAllowlisted,
).toBeUndefined();
+3
View File
@@ -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,
});
+24
View File
@@ -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);
});
});
+3 -1
View File
@@ -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<RuntimePublicConfig> {
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.
+27 -1
View File
@@ -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);
}
});
});
+6
View File
@@ -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";
}
+41 -1
View File
@@ -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, unknown> | 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);
});
});
+16 -1
View File
@@ -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");
}
@@ -23,6 +23,7 @@ export const RUNTIME_CONFIG_KEYS = [
"reoDevClientId",
"cloudBillingEnabled",
"cloudEnabled",
"selfRegistrationEnabled",
"stripePublishableKey",
"stripePublishableKeyV2",
] as const satisfies ReadonlyArray<keyof RuntimePublicConfig>;