mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-10-04 02:04:06 +00:00
feat(ui): allow disabling self-registration in Cloud (#12815)
This commit is contained in:
@@ -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`.
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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**
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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={
|
||||
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,6 +234,7 @@ export const MembershipsCardClient = ({
|
||||
|
||||
return (
|
||||
<>
|
||||
{canCreateOrganization && (
|
||||
<Modal
|
||||
open={isCreateOpen}
|
||||
onOpenChange={setIsCreateOpen}
|
||||
@@ -239,6 +242,7 @@ export const MembershipsCardClient = ({
|
||||
>
|
||||
<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,6 +254,7 @@ export const MembershipsCardClient = ({
|
||||
</CustomLink>
|
||||
</p>
|
||||
</div>
|
||||
{canCreateOrganization && (
|
||||
<CardAction>
|
||||
<Button
|
||||
variant="default"
|
||||
@@ -259,6 +264,7 @@ export const MembershipsCardClient = ({
|
||||
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()}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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
@@ -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>;
|
||||
|
||||
Reference in New Issue
Block a user