fix(ui): stop the add-provider modal from shifting while it loads (#13179)

This commit is contained in:
Alejandro Bailo authored and GitHub committed 2026-10-27 20:53:00 +01:00
1 parent 75c9a6bf51
commit dcd3d5a0fb
22 files changed
+799 -151

No files matched your search

@@ -0,0 +1,51 @@
import { describe, expect } from "vitest";
import { it } from "@/__tests__/fixtures";
import { render } from "@/__tests__/render-browser";
import { ComplianceDownloadContainer } from "./compliance-download-container";
// Like <main>: a marked size container, so sm: follows its width.
function renderInPage(width: number) {
return render(
<div data-responsive-container className="@container" style={{ width }}>
<ComplianceDownloadContainer
compact
scanId="scan-1"
complianceId="compliance-1"
reportType="threatscore"
/>
</div>,
);
}
// sr-only text keeps a 1px box, so its width tells whether it takes room.
function labelWidth(screen: Awaited<ReturnType<typeof renderInPage>>) {
return screen
.getByText("CSV", { exact: true })
.element()
.getBoundingClientRect().width;
}
describe("compact compliance downloads", () => {
it("shows only the icons on a narrow page", async () => {
// When
const screen = await renderInPage(400);
// Then
await expect
.element(
screen.getByRole("button", { name: "Download compliance CSV report" }),
)
.toBeVisible();
expect(labelWidth(screen)).toBeLessThanOrEqual(1);
});
it("names each format beside its icon from sm up", async () => {
// When
const screen = await renderInPage(800);
// Then
expect(labelWidth(screen)).toBeGreaterThan(1);
});
});
@@ -58,6 +58,7 @@ const runtimeConfig: RuntimePublicConfig = {
reoDevClientId: null,
cloudEnabled: false,
cloudBillingEnabled: false,
registryEnabled: false,
selfRegistrationEnabled: true,
stripePublishableKey: null,
stripePublishableKeyV2: null,
@@ -1,6 +1,6 @@
"use client";
import { FC, useState } from "react";
import { FC, ReactNode, useState } from "react";
import { Control, Controller } from "react-hook-form";
import {
@@ -9,6 +9,7 @@ import {
} from "@/components/icons/providers-badge/provider-type-icon";
import { Badge, SearchInput } from "@/components/shadcn";
import { FormMessage } from "@/components/shadcn/form";
import { Skeleton } from "@/components/shadcn/skeleton/skeleton";
import {
Tabs,
TabsContent,
@@ -26,6 +27,7 @@ const PROVIDERS = Object.entries(PROVIDER_TYPE_DATA).map(
);
const PROVIDER_TAB = { ALL: "all", REGISTRY: "registry" } as const;
const REGISTRY_SKELETON_ROWS = 3;
type ProviderTab = (typeof PROVIDER_TAB)[keyof typeof PROVIDER_TAB];
interface RadioGroupProviderProps {
@@ -36,6 +38,10 @@ interface RadioGroupProviderProps {
* Local (OSS) and flag-off deployments, where the source tabs are hidden.
*/
registryAvailable?: boolean;
/** Discovery in flight: the Registry tab shows placeholder rows. */
registryLoading?: boolean;
/** Shown in the Registry tab instead of its list (upgrade, setup, retry). */
registryNotice?: ReactNode;
registryOptions?: RegistryProviderOption[];
isInvalid: boolean;
errorMessage?: string;
@@ -46,6 +52,8 @@ export const RadioGroupProvider: FC<RadioGroupProviderProps> = ({
isInvalid,
errorMessage,
registryAvailable = false,
registryLoading = false,
registryNotice,
registryOptions = [],
}) => {
const [searchTerm, setSearchTerm] = useState("");
@@ -161,6 +169,19 @@ export const RadioGroupProvider: FC<RadioGroupProviderProps> = ({
)}
</div>
);
const registryPlaceholder = registryLoading ? (
<div
role="status"
aria-label="Loading Registry providers"
className="flex flex-col gap-3"
>
{Array.from({ length: REGISTRY_SKELETON_ROWS }, (_, index) => (
<Skeleton key={index} className="h-[72px] w-full rounded-lg" />
))}
</div>
) : (
registryNotice
);
const validationMessage = errorMessage && (
<FormMessage className="text-text-error-primary">
{errorMessage}
@@ -188,7 +209,11 @@ export const RadioGroupProvider: FC<RadioGroupProviderProps> = ({
<TabsTrigger value={PROVIDER_TAB.REGISTRY}>Registry</TabsTrigger>
</TabsList>
{searchInput}
<TabsContent value={activeTab}>{providerList}</TabsContent>
<TabsContent value={activeTab}>
{activeTab === PROVIDER_TAB.REGISTRY && registryPlaceholder
? registryPlaceholder
: providerList}
</TabsContent>
{validationMessage}
</Tabs>
);
@@ -0,0 +1,68 @@
import { describe, expect, vi } from "vitest";
import { it } from "@/__tests__/fixtures";
import { render } from "@/__tests__/render-browser";
import { LighthouseConnectHelp } from "./lighthouse-connect-help";
vi.mock(import("@/lib/shared/env"), async (importOriginal) => ({
...(await importOriginal()),
isCloud: () => true,
}));
vi.mock("@/app/(prowler)/lighthouse/_actions", () => ({
getLighthouseV2Configurations: () =>
Promise.resolve({ data: [{ id: "config-1", connected: true }] }),
}));
const DESCRIPTION =
"Lighthouse AI walks you through it with Prowler's docs, right beside this form.";
// Like the modal frame: a marked size container, so lg: follows its width.
function renderInWizardFrame(width: number) {
return render(
<div data-responsive-container className="@container" style={{ width }}>
<LighthouseConnectHelp
docsLink="https://goto.prowler.com/provider-aws"
docsDestination="Provider"
providerType={null}
mode="add"
stepName="connect"
stepLabel="Link a Provider"
isOrganizationsFlow={false}
/>
</div>,
);
}
describe("Lighthouse help in the wizard header", () => {
it("keeps to a single line while the wizard stacks its steps", async () => {
// When
const screen = await renderInWizardFrame(720);
// Then
const callout = screen.getByRole("region", {
name: "Help from Lighthouse AI",
});
await expect
.element(screen.getByRole("button", { name: "Ask Lighthouse AI" }))
.toBeVisible();
await expect
.element(screen.getByText("Not sure how to connect your cloud?"))
.toBeVisible();
await expect.element(screen.getByText(DESCRIPTION)).not.toBeVisible();
expect(
callout.element().getBoundingClientRect().height,
).toBeLessThanOrEqual(72);
});
it("explains what Lighthouse AI does beside a wide wizard", async () => {
// When
const screen = await renderInWizardFrame(1200);
// Then
await expect
.element(screen.getByRole("button", { name: "Ask Lighthouse AI" }))
.toBeVisible();
await expect.element(screen.getByText(DESCRIPTION)).toBeVisible();
});
});
@@ -8,6 +8,7 @@ import {
type ProviderFunnelDetail,
} from "@/lib/provider-funnel/provider-funnel-events";
import { useLighthouseContextStore } from "@/store/lighthouse-context/store";
import { useLighthouseLlmStore } from "@/store/lighthouse-llm/store";
import { useProviderWizardStore } from "@/store/provider-wizard/store";
import { SIDE_PANEL_TAB, useSidePanelStore } from "@/store/side-panel";
@@ -73,6 +74,17 @@ function connectedConfiguration(connected: boolean) {
return { data: [{ id: "config-1", connected }] };
}
// An LLM check the test settles by hand.
function pendingCheck() {
let settle: (
result: ReturnType<typeof connectedConfiguration>,
) => void = () => {};
const promise = new Promise((resolve) => {
settle = resolve;
});
return { promise, settle };
}
describe("LighthouseConnectHelp", () => {
beforeEach(() => {
getConfigurationsMock.mockResolvedValue(connectedConfiguration(true));
@@ -82,6 +94,7 @@ describe("LighthouseConnectHelp", () => {
});
useProviderWizardStore.getState().reset();
useLighthouseContextStore.setState({ contributions: {} });
useLighthouseLlmStore.setState({ hasConnectedLlm: null, latestCheck: 0 });
});
it("invites the user to ask Lighthouse AI about the provider they picked", async () => {
@@ -99,6 +112,142 @@ describe("LighthouseConnectHelp", () => {
);
});
it("holds the callout's place while it checks for a connected LLM", async () => {
// Given
let settleCheck: (
result: ReturnType<typeof connectedConfiguration>,
) => void = () => {};
getConfigurationsMock.mockReturnValue(
new Promise((resolve) => {
settleCheck = resolve;
}),
);
const funnelEvents = recordFunnelEvents();
// When
renderHelp("aws");
// Then: a placeholder, never the docs line the callout would replace
expect(
screen.getByRole("status", { name: "Loading Lighthouse AI help" }),
).toBeVisible();
expect(
screen.queryByRole("link", { name: /AWS documentation/i }),
).not.toBeInTheDocument();
expect(
screen.queryByRole("button", { name: "Ask Lighthouse AI" }),
).not.toBeInTheDocument();
// When
await act(async () => settleCheck(connectedConfiguration(true)));
// Then
expect(
screen.getByRole("button", { name: "Ask Lighthouse AI" }),
).toBeVisible();
expect(
screen.queryByRole("status", { name: "Loading Lighthouse AI help" }),
).not.toBeInTheDocument();
expect(funnelEvents.stop()).toHaveLength(1);
});
it("shows the last answer at once when the wizard opens again, then refreshes it", async () => {
// Given: an earlier opening found a connected LLM
const firstOpening = renderHelp("aws");
await screen.findByRole("button", { name: "Ask Lighthouse AI" });
firstOpening.unmount();
let settleCheck: (
result: ReturnType<typeof connectedConfiguration>,
) => void = () => {};
getConfigurationsMock.mockReturnValue(
new Promise((resolve) => {
settleCheck = resolve;
}),
);
// When
renderHelp("aws");
// Then: no placeholder while the fresh check runs
expect(
screen.getByRole("button", { name: "Ask Lighthouse AI" }),
).toBeVisible();
expect(
screen.queryByRole("status", { name: "Loading Lighthouse AI help" }),
).not.toBeInTheDocument();
// When: the LLM was disconnected meanwhile
await act(async () => settleCheck(connectedConfiguration(false)));
// Then
expect(
screen.queryByRole("button", { name: "Ask Lighthouse AI" }),
).not.toBeInTheDocument();
expect(
screen.getByRole("link", { name: /AWS documentation/i }),
).toBeVisible();
});
it("ignores an older check that answers after a newer opening", async () => {
// Given: the wizard closes while its check runs, then opens again
const olderCheck = pendingCheck();
const newerCheck = pendingCheck();
getConfigurationsMock
.mockReturnValueOnce(olderCheck.promise)
.mockReturnValueOnce(newerCheck.promise);
const firstOpening = renderHelp("aws");
await waitFor(() => expect(getConfigurationsMock).toHaveBeenCalledOnce());
firstOpening.unmount();
renderHelp("aws");
await waitFor(() => expect(getConfigurationsMock).toHaveBeenCalledTimes(2));
// When: the newer check answers first, then the older one
await act(async () => newerCheck.settle(connectedConfiguration(false)));
await act(async () => olderCheck.settle(connectedConfiguration(true)));
// Then
expect(
screen.queryByRole("button", { name: "Ask Lighthouse AI" }),
).not.toBeInTheDocument();
expect(
screen.getByRole("link", { name: /AWS documentation/i }),
).toBeVisible();
});
it("keeps the remembered answer when a later check fails", async () => {
// Given: an earlier opening found a connected LLM
const firstOpening = renderHelp("aws");
await screen.findByRole("button", { name: "Ask Lighthouse AI" });
firstOpening.unmount();
getConfigurationsMock.mockRejectedValueOnce(new Error("network"));
// When
renderHelp("aws");
await waitFor(() => expect(getConfigurationsMock).toHaveBeenCalledTimes(2));
await act(() => Promise.resolve());
// Then: a failed refresh does not swap the callout for the docs line
expect(
screen.getByRole("button", { name: "Ask Lighthouse AI" }),
).toBeVisible();
});
it("falls back to the documentation link when the check fails", async () => {
// Given
getConfigurationsMock.mockRejectedValue(new Error("network"));
// When
renderHelp("aws");
// Then
expect(
await screen.findByRole("link", { name: /AWS documentation/i }),
).toBeVisible();
expect(
screen.queryByRole("status", { name: "Loading Lighthouse AI help" }),
).not.toBeInTheDocument();
});
it("keeps only the documentation link while Lighthouse AI is not set up", async () => {
// Given
getConfigurationsMock.mockResolvedValue(connectedConfiguration(false));
@@ -15,6 +15,7 @@ import {
LighthouseCalloutIcon,
LighthouseCalloutTitle,
} from "@/components/shadcn/lighthouse-callout";
import { Skeleton } from "@/components/shadcn/skeleton/skeleton";
import { getVisibleSidePanelTabs } from "@/components/side-panel/side-panel-tabs";
import { useMediaQuery } from "@/hooks/use-media-query";
import { useMountEffect } from "@/hooks/use-mount-effect";
@@ -25,6 +26,7 @@ import {
PROVIDER_FUNNEL_STEP,
} from "@/lib/provider-funnel/provider-funnel-events";
import { SIDE_PANEL_PUSH_MEDIA_QUERY } from "@/lib/ui-layout";
import { useLighthouseLlmStore } from "@/store/lighthouse-llm/store";
import { useProviderWizardStore } from "@/store/provider-wizard/store";
import { SIDE_PANEL_TAB, useSidePanelStore } from "@/store/side-panel";
import { LIGHTHOUSE_SKILL_ID } from "@/types/lighthouse-skills";
@@ -40,6 +42,13 @@ const CONNECTION_GUIDE_SKILL = getSkillById(
LIGHTHOUSE_SKILL_ID.PROVIDER_CONNECTION_GUIDE,
);
const HELP_STATE = {
DOCS: "docs",
CHECKING: "checking",
LIGHTHOUSE: "lighthouse",
} as const;
type HelpState = (typeof HELP_STATE)[keyof typeof HELP_STATE];
interface LighthouseConnectHelpProps {
docsLink: string;
docsDestination: string;
@@ -61,7 +70,7 @@ export function LighthouseConnectHelp({
stepLabel,
isOrganizationsFlow,
}: LighthouseConnectHelpProps) {
const isLighthouseReady = useIsLighthouseReady();
const helpState = useHelpState();
const isGuiding = useSidePanelStore(
(state) => state.isOpen && state.selectedTab === SIDE_PANEL_TAB.AI_CHAT,
);
@@ -73,7 +82,7 @@ export function LighthouseConnectHelp({
(state) => state.awsConnectDraft?.method,
);
if (!isLighthouseReady || !CONNECTION_GUIDE_SKILL) {
if (helpState === HELP_STATE.DOCS || !CONNECTION_GUIDE_SKILL) {
return (
<div className="text-muted-foreground flex flex-wrap items-center gap-2 text-sm">
<Info className="size-4 shrink-0" />
@@ -120,17 +129,13 @@ export function LighthouseConnectHelp({
? getProviderDisplayName(providerType)
: null;
const copy = getHelpCopy({ isGuiding, providerName });
return (
<LighthouseCallout
aria-label="Help from Lighthouse AI"
className="animate-in fade-in-0 slide-in-from-top-1 mt-2 duration-300"
>
<HelpShownSignal providerType={providerType} wizardStep={stepName} />
const calloutContent = (
<>
<LighthouseCalloutIcon />
<LighthouseCalloutBody>
<LighthouseCalloutTitle>{copy.title}</LighthouseCalloutTitle>
<LighthouseCalloutDescription>
{/* A stacked wizard keeps the list in view: title and actions only. */}
<LighthouseCalloutDescription className="hidden lg:block">
{copy.description}
</LighthouseCalloutDescription>
</LighthouseCalloutBody>
@@ -145,6 +150,32 @@ export function LighthouseConnectHelp({
{copy.action}
</LighthouseCalloutAction>
</LighthouseCalloutActions>
</>
);
if (helpState === HELP_STATE.CHECKING) {
return (
<div
role="status"
aria-label="Loading Lighthouse AI help"
className="relative mt-2"
>
{/* Invisible copy sizes the placeholder exactly like the callout. */}
<LighthouseCallout aria-hidden inert className="invisible">
{calloutContent}
</LighthouseCallout>
<Skeleton className="absolute inset-0 rounded-2xl" />
</div>
);
}
return (
<LighthouseCallout
aria-label="Help from Lighthouse AI"
className="animate-in fade-in-0 slide-in-from-top-1 mt-2 duration-300"
>
<HelpShownSignal providerType={providerType} wizardStep={stepName} />
{calloutContent}
{/* Follow-ups typed in the panel carry the wizard's current state. */}
<LighthouseContextContributor
contributorId="provider-wizard"
@@ -196,34 +227,47 @@ function getHelpCopy({ isGuiding, providerName }: HelpCopyInput) {
// Lighthouse AI can only help beside the wizard when the panel hosts the chat
// (cloud), the panel pushes instead of covering it, and an LLM is connected.
function useIsLighthouseReady(): boolean {
function useHelpState(): HelpState {
const canHostChat = getVisibleSidePanelTabs().some(
(tab) => tab.id === SIDE_PANEL_TAB.AI_CHAT,
);
const isPushViewport = useMediaQuery(SIDE_PANEL_PUSH_MEDIA_QUERY);
// Local state needed: whether an LLM is connected is fetched once per open.
const [hasConnectedLlm, setHasConnectedLlm] = useState(false);
// Kept across openings; every opening still refreshes it.
const hasConnectedLlm = useLighthouseLlmStore(
(state) => state.hasConnectedLlm,
);
const startCheck = useLighthouseLlmStore((state) => state.startCheck);
const settleCheck = useLighthouseLlmStore((state) => state.settleCheck);
// Local state needed: with nothing remembered, a failed check shows the docs.
const [checkFailed, setCheckFailed] = useState(false);
useMountEffect(() => {
if (!canHostChat) return;
let isMounted = true;
const check = startCheck();
// Lazy: the Lighthouse actions only load where the chat can exist (cloud).
import("@/app/(prowler)/lighthouse/_actions")
.then(({ getLighthouseV2Configurations }) =>
getLighthouseV2Configurations(),
)
.then((result) => {
if (!isMounted || "error" in result) return;
setHasConnectedLlm(
result.data.some((configuration) => configuration.connected),
);
})
// Optional help: a failed check just leaves the docs link in place.
.catch(() => {});
.then((result) =>
"error" in result
? null
: result.data.some((configuration) => configuration.connected),
)
// Optional help: a failed check keeps the last answer, else the docs.
.catch(() => null)
.then((connected) => {
if (connected !== null) settleCheck(check, connected);
else if (isMounted) setCheckFailed(true);
});
return () => {
isMounted = false;
};
});
return canHostChat && isPushViewport && hasConnectedLlm;
if (!canHostChat || !isPushViewport) return HELP_STATE.DOCS;
if (hasConnectedLlm === null)
return checkFailed ? HELP_STATE.DOCS : HELP_STATE.CHECKING;
return hasConnectedLlm ? HELP_STATE.LIGHTHOUSE : HELP_STATE.DOCS;
}
@@ -154,8 +154,9 @@ async function enterAccountDetails() {
describe("provider wizard account creation", () => {
beforeEach(() => {
// Registry discovery only runs in Cloud.
// Registry discovery only runs in Cloud with Registry enabled.
vi.stubEnv("UI_CLOUD_ENABLED", "true");
vi.stubEnv("UI_REGISTRY_ENABLED", "true");
useProviderWizardStore.getState().reset();
resetToasts();
getInstalledRegistryProviderOptions.mockResolvedValue({
@@ -525,44 +526,45 @@ describe("provider wizard account creation", () => {
);
const user = userEvent.setup();
render(<ProviderWizardModal open onOpenChange={vi.fn()} />);
await screen.findByText("Registry providers could not be loaded");
expect(
screen.getByRole("option", { name: /Amazon Web Services/ }),
await screen.findByRole("option", { name: /Amazon Web Services/ }),
).toBeVisible();
// Then: an unanswered discovery cannot vouch for Registry availability.
expect(
screen.queryByRole("tab", { name: "Registry" }),
).not.toBeInTheDocument();
// When
await user.click(screen.getByRole("tab", { name: "Registry" }));
await user.click(
screen.getByRole("button", { name: "Retry Registry providers" }),
await screen.findByRole("button", { name: "Retry Registry providers" }),
);
// Then
expect(
await screen.findByRole("option", { name: "Acme Cloud Registry" }),
).toBeVisible();
expect(screen.getByRole("tab", { name: "Registry" })).toBeVisible();
expect(
screen.queryByText("Registry providers could not be loaded"),
).not.toBeInTheDocument();
});
it("keeps the Registry tab with a retry when eligible discovery fails", async () => {
it("shows the retry inside the Registry tab when eligible discovery fails", async () => {
// Given: Cloud with Registry enabled, but the catalog read failed.
getInstalledRegistryProviderOptions.mockResolvedValueOnce({
status: "error",
});
const user = userEvent.setup();
render(<ProviderWizardModal open onOpenChange={vi.fn()} />);
await screen.findByText("Registry providers could not be loaded");
// When
await user.click(screen.getByRole("tab", { name: "Registry" }));
await user.click(await screen.findByRole("tab", { name: "Registry" }));
// Then
expect(screen.getByText("No Registry providers available.")).toBeVisible();
expect(
await screen.findByText("Registry providers could not be loaded"),
).toBeVisible();
expect(
screen.getByRole("button", { name: "Retry Registry providers" }),
).toBeVisible();
expect(
screen.queryByText("No Registry providers available."),
).not.toBeInTheDocument();
});
});
@@ -0,0 +1,80 @@
import { describe, expect } from "vitest";
import { it } from "@/__tests__/fixtures";
import { render } from "@/__tests__/render-browser";
import { WizardStepper } from "./wizard-stepper";
// Like the modal frame: a marked size container, so lg: follows its width.
function renderInWizardFrame(width: number) {
return render(
<div data-responsive-container className="@container" style={{ width }}>
<WizardStepper currentStep={1} />
</div>,
);
}
function boxOf(element: Element) {
const { width, height } = element.getBoundingClientRect();
return { width, height };
}
describe("wizard stepper", () => {
it("fits on one row naming only the current step while the wizard stacks", async () => {
// When
const screen = await renderInWizardFrame(720);
// Then
const progress = screen
.getByRole("navigation", { name: "Wizard progress" })
.element();
expect(boxOf(progress).height).toBeLessThanOrEqual(40);
await expect
.element(screen.getByText("Authenticate Credentials"))
.toBeVisible();
// Other steps keep their names for assistive tech but take no room.
await expect.element(
screen.getByRole("navigation", { name: "Wizard progress" }),
).toMatchAriaInlineSnapshot(`
- navigation "Wizard progress":
- text: Link a Provider Authenticate Credentials Validate Connection Launch Scan
`);
const otherStep = screen.getByText("Link a Provider").element();
expect(otherStep.checkVisibility()).toBe(true);
// The block holding the name is clipped to 1px; the text inside overflows it.
expect(boxOf(otherStep.closest("div")!).width).toBeLessThanOrEqual(1);
await expect
.element(
screen.getByText(
"Authorize a secure connection between Prowler and your provider.",
),
)
.not.toBeVisible();
expect(
screen
.getByText("Authenticate Credentials")
.element()
.closest("[aria-current]"),
).toHaveAttribute("aria-current", "step");
});
it("lists every step with its description beside a wide wizard", async () => {
// When
const screen = await renderInWizardFrame(1200);
// Then
await expect
.element(
screen.getByText(
"Enter the provider details you would like to add in Prowler.",
),
)
.toBeVisible();
await expect.element(screen.getByText("Link a Provider")).toBeVisible();
expect(
boxOf(
screen.getByRole("navigation", { name: "Wizard progress" }).element(),
).height,
).toBeGreaterThan(300);
});
});
@@ -1,7 +1,7 @@
"use client";
import { CircleCheckBig, FolderGit2, KeyRound, Rocket } from "lucide-react";
import { ReactElement } from "react";
import { Fragment, ReactElement } from "react";
import { ProwlerShort } from "@/components/icons/prowler/ProwlerIcons";
import { cn } from "@/lib/utils";
@@ -67,6 +67,13 @@ export function getActiveStepIndex(
return Math.max(0, Math.min(currentStep + stepOffset, stepCount - 1));
}
const CONNECTOR_DIRECTION = {
VERTICAL: "to bottom",
HORIZONTAL: "to right",
} as const;
type ConnectorDirection =
(typeof CONNECTOR_DIRECTION)[keyof typeof CONNECTOR_DIRECTION];
export function WizardStepper({
currentStep,
stepOffset = 0,
@@ -78,42 +85,71 @@ export function WizardStepper({
steps.length,
);
// Below lg the wizard stacks: one row of steps, naming only the current one.
return (
<nav aria-label="Wizard progress" className="flex flex-col gap-0">
<nav
aria-label="Wizard progress"
className="flex items-center gap-2 lg:flex-col lg:items-stretch lg:gap-0"
>
{steps.map((step, index) => {
const isComplete = index < activeVisualStep;
const isActive = index === activeVisualStep;
const isInactive = index > activeVisualStep;
const hasNext = index < steps.length - 1;
return (
<div key={step.label} className="flex items-start gap-3">
<div className="flex flex-col items-center">
<StepCircle
isComplete={isComplete}
isActive={isActive}
icon={step.icon}
/>
{index < steps.length - 1 && (
<StepConnector isComplete={isComplete} />
<Fragment key={step.label}>
<div
aria-current={isActive ? "step" : undefined}
className={cn(
"flex min-w-0 items-center gap-2 lg:items-start lg:gap-3",
!isActive && "shrink-0",
)}
</div>
>
<div className="flex flex-col items-center">
<StepCircle
isComplete={isComplete}
isActive={isActive}
icon={step.icon}
/>
{hasNext && (
<StepConnector
isComplete={isComplete}
className="hidden h-14 w-px lg:block"
direction={CONNECTOR_DIRECTION.VERTICAL}
/>
)}
</div>
<div className="flex flex-col gap-1 pt-[10px]">
<span
<div
className={cn(
"text-lg leading-7 font-normal",
isActive && "text-text-neutral-primary",
isComplete && "text-text-neutral-primary",
isInactive && "text-text-neutral-tertiary",
"min-w-0 flex-col gap-1 lg:flex lg:pt-[10px]",
isActive ? "flex" : "sr-only lg:not-sr-only",
)}
>
{step.label}
</span>
<p className="text-text-neutral-secondary text-xs leading-5">
{step.description}
</p>
<span
className={cn(
"truncate text-sm font-normal lg:overflow-visible lg:text-lg lg:leading-7 lg:whitespace-normal",
isActive && "text-text-neutral-primary",
isComplete && "text-text-neutral-primary",
isInactive && "text-text-neutral-tertiary",
)}
>
{step.label}
</span>
<p className="text-text-neutral-secondary hidden text-xs leading-5 lg:block">
{step.description}
</p>
</div>
</div>
</div>
{hasNext && (
<StepConnector
isComplete={isComplete}
className="h-px min-w-3 flex-1 lg:hidden"
direction={CONNECTOR_DIRECTION.HORIZONTAL}
/>
)}
</Fragment>
);
})}
</nav>
@@ -126,41 +162,58 @@ interface StepCircleProps {
icon: IconComponent;
}
const CIRCLE_SIZE = "size-8 lg:size-[44px]";
function StepCircle({ isComplete, isActive, icon: Icon }: StepCircleProps) {
if (isComplete) {
return (
<div className="bg-button-primary-press flex size-[44px] shrink-0 items-center justify-center rounded-full">
<CircleCheckBig className="text-bg-neutral-primary size-6" />
<div
className={`bg-button-primary-press flex shrink-0 items-center justify-center rounded-full ${CIRCLE_SIZE}`}
>
<CircleCheckBig className="text-bg-neutral-primary size-4 lg:size-6" />
</div>
);
}
if (isActive) {
return (
<div className="border-border-input-primary-pressed bg-bg-neutral-secondary flex size-[44px] shrink-0 items-center justify-center rounded-full border">
<div
className={`border-border-input-primary-pressed bg-bg-neutral-secondary flex shrink-0 items-center justify-center rounded-full border ${CIRCLE_SIZE}`}
>
<StepIcon icon={Icon} className="text-border-input-primary-pressed" />
</div>
);
}
return (
<div className="border-border-neutral-secondary bg-bg-neutral-secondary flex size-[44px] shrink-0 items-center justify-center rounded-full border">
<div
className={`border-border-neutral-secondary bg-bg-neutral-secondary flex shrink-0 items-center justify-center rounded-full border ${CIRCLE_SIZE}`}
>
<StepIcon icon={Icon} className="text-text-neutral-tertiary" />
</div>
);
}
function StepConnector({ isComplete }: { isComplete: boolean }) {
interface StepConnectorProps {
isComplete: boolean;
className: string;
direction: ConnectorDirection;
}
function StepConnector({
isComplete,
className,
direction,
}: StepConnectorProps) {
if (isComplete) {
return <div className="bg-border-input-primary-pressed h-14 w-px" />;
return <div className={cn("bg-border-input-primary-pressed", className)} />;
}
return (
<div
className="h-14 w-px"
className={className}
style={{
backgroundImage:
"repeating-linear-gradient(to bottom, var(--color-bg-data-muted) 0px, var(--color-bg-data-muted) 4px, transparent 4px, transparent 8px)",
backgroundImage: `repeating-linear-gradient(${direction}, var(--color-bg-data-muted) 0px, var(--color-bg-data-muted) 4px, transparent 4px, transparent 8px)`,
}}
/>
);
@@ -173,10 +226,17 @@ function StepIcon({
icon: IconComponent;
className: string;
}) {
// Decorative like lucide's; size classes beat the SVG's width/height attributes.
if (isCustomSvgIcon(Icon)) {
return <Icon size={24} className={className} />;
return (
<Icon
aria-hidden
size={24}
className={cn("size-4 lg:size-6", className)}
/>
);
}
return <Icon className={cn("size-6", className)} />;
return <Icon className={cn("size-4 lg:size-6", className)} />;
}
function isCustomSvgIcon(
@@ -1,4 +1,4 @@
import { render, screen, waitFor } from "@testing-library/react";
import { act, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
@@ -90,15 +90,16 @@ describe("provider account aliases", () => {
});
describe("Registry provider source tabs", () => {
it("explains Registry setup without claiming a connection failure", async () => {
it("explains Registry setup in the Registry tab without claiming a connection failure", async () => {
// Given
vi.stubEnv("UI_CLOUD_ENABLED", "true");
const user = userEvent.setup();
getInstalledRegistryProviderOptions.mockResolvedValue({
status: "onboarding",
});
render(<ConnectAccountForm onSuccess={vi.fn()} />);
// When
render(<ConnectAccountForm onSuccess={vi.fn()} />);
await user.click(screen.getByRole("tab", { name: "Registry" }));
// Then
expect(
@@ -110,28 +111,42 @@ describe("Registry provider source tabs", () => {
expect(
screen.queryByText("Registry providers could not be loaded"),
).not.toBeInTheDocument();
// When / Then: the built-in list never gets a banner pushed above it.
await user.click(screen.getByRole("tab", { name: "All providers" }));
expect(
screen.queryByText("Registry is not connected yet"),
).not.toBeInTheDocument();
});
it("offers the Registry paid plan upgrade while keeping built-in providers usable", async () => {
it("offers the Registry paid plan upgrade in the Registry tab while keeping built-in providers usable", async () => {
// Given
vi.stubEnv("UI_CLOUD_ENABLED", "true");
getInstalledRegistryProviderOptions.mockResolvedValue({
status: "subscription_required",
});
const user = userEvent.setup();
useCloudUpgradeStore.getState().closeCloudUpgrade();
render(<ConnectAccountForm onSuccess={vi.fn()} />);
// When
render(<ConnectAccountForm onSuccess={vi.fn()} />);
await user.click(screen.getByRole("tab", { name: "Registry" }));
await user.click(
await screen.findByRole("button", { name: "Unlock Registry" }),
);
// Then
expect(useCloudUpgradeStore.getState().activeFeature).toBe("registry");
// When
await user.click(screen.getByRole("tab", { name: "All providers" }));
// Then
expect(
screen.getByRole("option", { name: /Amazon Web Services/ }),
).toBeVisible();
expect(
screen.queryByRole("button", { name: "Unlock Registry" }),
).not.toBeInTheDocument();
expect(
screen.queryByText("Registry providers could not be loaded"),
).not.toBeInTheDocument();
@@ -144,12 +159,64 @@ describe("Registry provider source tabs", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.stubEnv("UI_CLOUD_ENABLED", "true");
vi.stubEnv("UI_REGISTRY_ENABLED", "true");
});
afterEach(() => {
vi.unstubAllEnvs();
});
it("shows both source tabs before discovery settles and loads Registry providers in place", async () => {
// Given: Registry discovery is still in flight.
const user = userEvent.setup();
let settleDiscovery: (result: {
status: "ready";
options: { type: string; label: string }[];
}) => void = () => {};
getInstalledRegistryProviderOptions.mockReturnValue(
new Promise((resolve) => {
settleDiscovery = resolve;
}),
);
// When
render(<ConnectAccountForm onSuccess={vi.fn()} />);
// Then: the tabs are there from the first render.
expect(screen.getByRole("tab", { name: "All providers" })).toHaveAttribute(
"aria-selected",
"true",
);
expect(screen.getByRole("tab", { name: "Registry" })).toBeVisible();
// When
await user.click(screen.getByRole("tab", { name: "Registry" }));
// Then
expect(
screen.getByRole("status", { name: "Loading Registry providers" }),
).toBeVisible();
expect(
screen.queryByText("No Registry providers available."),
).not.toBeInTheDocument();
// When
await act(async () => {
settleDiscovery({
status: "ready",
options: [{ type: "acme", label: "Acme Cloud" }],
});
});
// Then
expect(
screen.getByRole("option", { name: /Acme Cloud Registry/ }),
).toBeVisible();
expect(
screen.queryByRole("status", { name: "Loading Registry providers" }),
).not.toBeInTheDocument();
});
it("never mentions Registry outside Cloud, even when discovery would fail", async () => {
// Given
vi.stubEnv("UI_CLOUD_ENABLED", "false");
@@ -171,7 +238,22 @@ describe("Registry provider source tabs", () => {
).not.toBeInTheDocument();
});
it("hides the Registry tab when discovery denies access (Local or flag off)", async () => {
it("hides the source tabs without asking discovery when the deployment has Registry off", () => {
// Given
vi.stubEnv("UI_REGISTRY_ENABLED", "false");
// When
render(<ConnectAccountForm onSuccess={vi.fn()} />);
// Then
expect(getInstalledRegistryProviderOptions).not.toHaveBeenCalled();
expect(screen.queryByRole("tablist")).not.toBeInTheDocument();
expect(
screen.getByRole("option", { name: /Amazon Web Services/ }),
).toBeVisible();
});
it("hides the source tabs when discovery denies the user access", async () => {
// Given
getInstalledRegistryProviderOptions.mockResolvedValue({
status: "access_denied",
@@ -195,44 +277,21 @@ describe("Registry provider source tabs", () => {
).not.toBeInTheDocument();
});
it("shows the Registry tab once discovery confirms the deployment offers it", async () => {
// Given: Cloud or Private Cloud with Registry enabled and no artifacts yet.
getInstalledRegistryProviderOptions.mockResolvedValue({
status: "ready",
options: [],
});
// When
render(<ConnectAccountForm onSuccess={vi.fn()} />);
// Then
expect(await screen.findByRole("tab", { name: "Registry" })).toBeVisible();
expect(screen.getByRole("tab", { name: "All providers" })).toHaveAttribute(
"aria-selected",
"true",
);
});
it("keeps Registry hidden and offers a retry when access is unknown", async () => {
it("offers a retry in the Registry tab when access is unknown", async () => {
// Given
const user = userEvent.setup();
getInstalledRegistryProviderOptions
.mockResolvedValueOnce({ status: "unknown" })
.mockResolvedValueOnce({ status: "ready", options: [] });
render(<ConnectAccountForm onSuccess={vi.fn()} />);
// When
render(<ConnectAccountForm onSuccess={vi.fn()} />);
await user.click(screen.getByRole("tab", { name: "Registry" }));
// Then
expect(
await screen.findByText("Registry providers could not be loaded"),
).toBeVisible();
expect(
screen.queryByRole("tab", { name: "Registry" }),
).not.toBeInTheDocument();
expect(
screen.getByRole("button", { name: "Retry Registry providers" }),
).toBeVisible();
// When
await user.click(
@@ -240,7 +299,9 @@ describe("Registry provider source tabs", () => {
);
// Then
expect(await screen.findByRole("tab", { name: "Registry" })).toBeVisible();
expect(
await screen.findByText("No Registry providers available."),
).toBeVisible();
expect(
screen.queryByText("Registry providers could not be loaded"),
).not.toBeInTheDocument();
@@ -259,6 +320,7 @@ describe("Registry provider source tabs", () => {
}),
);
render(<ConnectAccountForm onSuccess={vi.fn()} />);
await user.click(screen.getByRole("tab", { name: "Registry" }));
const retry = await screen.findByRole("button", {
name: "Retry Registry providers",
});
@@ -296,6 +358,7 @@ describe("Registry provider source tabs", () => {
)
.mockResolvedValueOnce({ status: "error" });
render(<ConnectAccountForm onSuccess={vi.fn()} />);
await user.click(screen.getByRole("tab", { name: "Registry" }));
const retry = await screen.findByRole("button", {
name: "Retry Registry providers",
});
@@ -21,6 +21,7 @@ import { Alert, AlertDescription, AlertTitle } from "@/components/shadcn/alert";
import { Form } from "@/components/shadcn/form";
import { ProviderCredentialFields } from "@/lib/provider-credentials/provider-credential-fields";
import { REGISTRY_PROVIDER_DISCOVERY } from "@/lib/registry/provider-options";
import { isRegistryEnabled } from "@/lib/shared/env";
import {
createAddProviderFormSchema,
AddProviderFormValues,
@@ -229,9 +230,10 @@ export const ConnectAccountForm = ({
registryDiscovery?.status === REGISTRY_PROVIDER_DISCOVERY.READY
? registryDiscovery.options
: [];
// The deployment flag is known up front, so the tabs render before discovery.
const registryAvailable =
registryDiscovery?.status === REGISTRY_PROVIDER_DISCOVERY.READY ||
registryDiscovery?.status === REGISTRY_PROVIDER_DISCOVERY.ERROR;
isRegistryEnabled() &&
registryDiscovery?.status !== REGISTRY_PROVIDER_DISCOVERY.ACCESS_DENIED;
const registryError =
registryDiscovery?.status === REGISTRY_PROVIDER_DISCOVERY.ERROR ||
registryDiscovery?.status === REGISTRY_PROVIDER_DISCOVERY.UNKNOWN;
@@ -440,34 +442,41 @@ export const ConnectAccountForm = ({
{/* Step 1: Provider selection */}
{prevStep === 1 && (
<div>
{registryDiscovery?.status ===
REGISTRY_PROVIDER_DISCOVERY.SUBSCRIPTION_REQUIRED && (
<RegistryUpgradePrompt />
)}
{registryDiscovery?.status ===
REGISTRY_PROVIDER_DISCOVERY.ONBOARDING && <RegistrySetupPrompt />}
{registryError && (
<Alert variant="warning">
<AlertTitle>Registry providers could not be loaded</AlertTitle>
<AlertDescription>
Built-in providers are available. Check the Registry
connection and try again.
{/* aria-disabled, not disabled: the pressed button keeps focus. */}
<Button
type="button"
variant="outline"
aria-disabled={isRetryingDiscovery}
onClick={retryDiscovery}
>
{isRetryingDiscovery
? "Retrying…"
: "Retry Registry providers"}
</Button>
</AlertDescription>
</Alert>
)}
<RadioGroupProvider
registryAvailable={registryAvailable}
registryLoading={registryAvailable && registryDiscovery === null}
registryNotice={
registryDiscovery?.status ===
REGISTRY_PROVIDER_DISCOVERY.SUBSCRIPTION_REQUIRED ? (
<RegistryUpgradePrompt />
) : registryDiscovery?.status ===
REGISTRY_PROVIDER_DISCOVERY.ONBOARDING ? (
<RegistrySetupPrompt />
) : (
registryError && (
<Alert variant="warning">
<AlertTitle>
Registry providers could not be loaded
</AlertTitle>
<AlertDescription>
Built-in providers are available. Check the Registry
connection and try again.
{/* aria-disabled, not disabled: the pressed button keeps focus. */}
<Button
type="button"
variant="outline"
aria-disabled={isRetryingDiscovery}
onClick={retryDiscovery}
>
{isRetryingDiscovery
? "Retrying…"
: "Retry Registry providers"}
</Button>
</AlertDescription>
</Alert>
)
)
}
registryOptions={registryOptions}
control={form.control}
isInvalid={!!form.formState.errors.providerType}
@@ -22,6 +22,7 @@ describe("useRegistryProviderDiscovery", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.stubEnv("UI_CLOUD_ENABLED", "true");
vi.stubEnv("UI_REGISTRY_ENABLED", "true");
});
it("ignores a stale discovery that settles after a newer one", async () => {
@@ -7,7 +7,7 @@ import {
REGISTRY_PROVIDER_DISCOVERY,
type RegistryProviderDiscoveryResult,
} from "@/lib/registry/provider-options";
import { isCloud } from "@/lib/shared/env";
import { isRegistryEnabled } from "@/lib/shared/env";
export function useRegistryProviderDiscovery() {
// Local state needed: discovery and retries resolve asynchronously.
@@ -20,7 +20,7 @@ export function useRegistryProviderDiscovery() {
const latestRequest = useRef(0);
useEffect(() => {
if (!isCloud()) return;
if (!isRegistryEnabled()) return;
let active = true;
const load = async () => {
const request = ++latestRequest.current;
+1
View File
@@ -145,6 +145,7 @@ describe("getRuntimeConfigClient", () => {
"posthogIngestionHost",
"posthogKey",
"posthogUiHost",
"registryEnabled",
"reoDevClientId",
"selfRegistrationEnabled",
"sentryDsn",
+3
View File
@@ -13,6 +13,7 @@ export interface RuntimePublicConfig {
reoDevClientId: string | null; // reserved
cloudEnabled: boolean;
cloudBillingEnabled: boolean;
registryEnabled: boolean;
selfRegistrationEnabled: boolean;
stripePublishableKey: string | null; // reserved
stripePublishableKeyV2: string | null; // reserved
@@ -34,6 +35,7 @@ export const EMPTY_RUNTIME_PUBLIC_CONFIG: RuntimePublicConfig = {
reoDevClientId: null,
cloudEnabled: false,
cloudBillingEnabled: false,
registryEnabled: false,
selfRegistrationEnabled: true,
stripePublishableKey: null,
stripePublishableKeyV2: null,
@@ -55,6 +57,7 @@ const pickConfig = (
reoDevClientId: parsed.reoDevClientId ?? null,
cloudEnabled: parsed.cloudEnabled ?? false,
cloudBillingEnabled: parsed.cloudBillingEnabled ?? false,
registryEnabled: parsed.registryEnabled ?? false,
selfRegistrationEnabled: parsed.selfRegistrationEnabled ?? true,
stripePublishableKey: parsed.stripePublishableKey ?? null,
stripePublishableKeyV2: parsed.stripePublishableKeyV2 ?? null,
+24
View File
@@ -96,3 +96,27 @@ describe("getRuntimePublicConfig self-registration flag", () => {
expect(config.selfRegistrationEnabled).toBe(false);
});
});
describe("getRuntimePublicConfig Registry flag", () => {
afterEach(() => {
vi.unstubAllEnvs();
});
it('is enabled when UI_REGISTRY_ENABLED is "true"', async () => {
vi.stubEnv("UI_REGISTRY_ENABLED", "true");
const { getRuntimePublicConfig } = await importFresh();
const config = await getRuntimePublicConfig();
expect(config.registryEnabled).toBe(true);
});
it("is disabled when UI_REGISTRY_ENABLED is unset", async () => {
vi.stubEnv("UI_REGISTRY_ENABLED", undefined);
const { getRuntimePublicConfig } = await importFresh();
const config = await getRuntimePublicConfig();
expect(config.registryEnabled).toBe(false);
});
});
+2
View File
@@ -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"),
// Deployment gate only: per-user Registry access is still checked server-side.
registryEnabled: readBoolEnv("UI_REGISTRY_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
+36 -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, isSelfRegistrationEnabled } from "./env";
import { isCloud, isRegistryEnabled, isSelfRegistrationEnabled } from "./env";
const writeIsland = (content: Record<string, unknown> | string) => {
const el = document.createElement("script");
@@ -95,3 +95,38 @@ describe("isSelfRegistrationEnabled", () => {
expect(isSelfRegistrationEnabled()).toBe(true);
});
});
describe("isRegistryEnabled", () => {
afterEach(() => {
vi.unstubAllEnvs();
document.head.innerHTML = "";
});
it('returns true in Prowler Cloud when UI_REGISTRY_ENABLED is "true"', () => {
vi.stubEnv("UI_CLOUD_ENABLED", "true");
vi.stubEnv("UI_REGISTRY_ENABLED", "true");
expect(isRegistryEnabled()).toBe(true);
});
it("returns false in Prowler Cloud when UI_REGISTRY_ENABLED is unset", () => {
vi.stubEnv("UI_CLOUD_ENABLED", "true");
expect(isRegistryEnabled()).toBe(false);
});
it('returns false outside Prowler Cloud even when UI_REGISTRY_ENABLED is "true"', () => {
vi.stubEnv("UI_REGISTRY_ENABLED", "true");
expect(isRegistryEnabled()).toBe(false);
});
it("uses the island flags over the env vars", () => {
vi.stubEnv("UI_CLOUD_ENABLED", "true");
vi.stubEnv("UI_REGISTRY_ENABLED", "true");
writeIsland({ cloudEnabled: true, registryEnabled: false });
expect(isRegistryEnabled()).toBe(false);
document.head.innerHTML = "";
vi.stubEnv("UI_REGISTRY_ENABLED", "false");
writeIsland({ cloudEnabled: true, registryEnabled: true });
expect(isRegistryEnabled()).toBe(true);
});
});
+15
View File
@@ -21,6 +21,21 @@ export function isCloud(): boolean {
return readBoolEnv("UI_CLOUD_ENABLED");
}
/**
* Whether this Cloud deployment offers Registry providers (`UI_REGISTRY_ENABLED`).
*
* Read from the island in the browser, then the env, like `isCloud()`. It only
* gates the deployment: per-user Registry access is still decided server-side.
*/
export function isRegistryEnabled(): boolean {
if (!isCloud()) return false;
const islandConfig = readRuntimeConfigIsland();
if (islandConfig) return islandConfig.registryEnabled;
return readBoolEnv("UI_REGISTRY_ENABLED");
}
/**
* Whether visitors can create an account without an invitation.
*
+25
View File
@@ -0,0 +1,25 @@
import { create } from "zustand";
interface LighthouseLlmStoreState {
// Last answer this page load (null: never checked), shown at once on reopen.
hasConnectedLlm: boolean | null;
// Id of the newest check: an older check that answers late is ignored.
latestCheck: number;
startCheck: () => number;
settleCheck: (check: number, hasConnectedLlm: boolean) => void;
}
export const useLighthouseLlmStore = create<LighthouseLlmStoreState>(
(set, get) => ({
hasConnectedLlm: null,
latestCheck: 0,
startCheck: () => {
const check = get().latestCheck + 1;
set({ latestCheck: check });
return check;
},
settleCheck: (check, hasConnectedLlm) => {
if (check === get().latestCheck) set({ hasConnectedLlm });
},
}),
);
+1 -12
View File
@@ -717,18 +717,7 @@
/* ===== UTILITY LAYER ===== */
@layer utilities {
/* Screen reader only - visually hidden but accessible */
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border-width: 0;
}
/* No .sr-only here: Tailwind ships it, and a copy would beat lg:not-sr-only. */
/* Hide scrollbar */
.no-scrollbar {
@@ -23,6 +23,7 @@ export const RUNTIME_CONFIG_KEYS = [
"reoDevClientId",
"cloudBillingEnabled",
"cloudEnabled",
"registryEnabled",
"selfRegistrationEnabled",
"stripePublishableKey",
"stripePublishableKeyV2",