fix(ui): show Registry subscription and setup prompts (#13038)

This commit is contained in:
Alejandro Bailo authored and GitHub committed 2026-10-14 13:51:08 +02:00
1 parent 286ae6c7cf
commit 64c19b3db6
23 files changed
+571 -73

No files matched your search

@@ -9,6 +9,10 @@ const { addProvider, updateProvider, getInstalledRegistryProviderOptions } =
getInstalledRegistryProviderOptions: vi.fn(),
}));
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({ permissions: { manage_registry: true } }),
}));
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn() }),
}));
@@ -23,6 +27,8 @@ vi.mock("@/actions/registry/registry", () => ({
getInstalledRegistryProviderOptions,
}));
import { useCloudUpgradeStore } from "@/store/cloud-upgrade/store";
import { ConnectAccountForm } from "./connect-account-form";
describe("provider account aliases", () => {
@@ -84,6 +90,57 @@ describe("provider account aliases", () => {
});
describe("Registry provider source tabs", () => {
it("explains Registry setup without claiming a connection failure", async () => {
// Given
vi.stubEnv("UI_CLOUD_ENABLED", "true");
getInstalledRegistryProviderOptions.mockResolvedValue({
status: "onboarding",
});
// When
render(<ConnectAccountForm onSuccess={vi.fn()} />);
// Then
expect(
await screen.findByText("Registry is not connected yet"),
).toBeVisible();
expect(
screen.queryByRole("button", { name: "Unlock Registry" }),
).not.toBeInTheDocument();
expect(
screen.queryByText("Registry providers could not be loaded"),
).not.toBeInTheDocument();
});
it("offers the Registry paid plan upgrade 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();
// When
render(<ConnectAccountForm onSuccess={vi.fn()} />);
await user.click(
await screen.findByRole("button", { name: "Unlock Registry" }),
);
// Then
expect(useCloudUpgradeStore.getState().activeFeature).toBe("registry");
expect(
screen.getByRole("option", { name: /Amazon Web Services/ }),
).toBeVisible();
expect(
screen.queryByText("Registry providers could not be loaded"),
).not.toBeInTheDocument();
expect(
screen.queryByRole("button", { name: "Retry Registry providers" }),
).not.toBeInTheDocument();
useCloudUpgradeStore.getState().closeCloudUpgrade();
});
beforeEach(() => {
vi.clearAllMocks();
vi.stubEnv("UI_CLOUD_ENABLED", "true");
@@ -8,20 +8,19 @@ import { useForm, UseFormReturn } from "react-hook-form";
import { addProvider, updateProvider } from "@/actions/providers/providers";
import { addRegistryProvider } from "@/actions/providers/registry-provider";
import { getInstalledRegistryProviderOptions } from "@/actions/registry/registry";
import { AzureMethodSelector } from "@/components/providers/organizations/azure-method-selector";
import { GcpMethodSelector } from "@/components/providers/organizations/gcp-method-selector";
import { WizardInputField } from "@/components/providers/workflow/forms/fields";
import { ProviderTitleDocs } from "@/components/providers/workflow/provider-title-docs";
import {
RegistryUpgradePrompt,
RegistrySetupPrompt,
} from "@/components/registry/registry-upgrade-prompt";
import { Button, useToast } from "@/components/shadcn";
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,
type RegistryProviderOption,
} from "@/lib/registry/provider-options";
import { isCloud } from "@/lib/shared/env";
import { REGISTRY_PROVIDER_DISCOVERY } from "@/lib/registry/provider-options";
import {
createAddProviderFormSchema,
AddProviderFormValues,
@@ -38,6 +37,8 @@ import { isKnownProviderType } from "@/types/providers";
import { RadioGroupProvider } from "../../radio-group-provider";
import { useRegistryProviderDiscovery } from "./use-registry-provider-discovery";
export type FormValues = AddProviderFormValues;
export interface ConnectAccountSuccessData {
@@ -219,60 +220,25 @@ export const ConnectAccountForm = ({
const [method, setMethod] = useState<"single" | null>(null);
const router = useRouter();
const [registryOptions, setRegistryOptions] = useState<
RegistryProviderOption[]
>([]);
// Only confirmed Cloud and Private Cloud access enables Registry source tabs.
// Unknown access stays hidden but remains retryable through the warning.
const [registryAvailable, setRegistryAvailable] = useState(false);
const [registryError, setRegistryError] = useState(false);
const {
result: registryDiscovery,
isRetryingDiscovery,
retryDiscovery,
} = useRegistryProviderDiscovery();
const registryOptions =
registryDiscovery?.status === REGISTRY_PROVIDER_DISCOVERY.READY
? registryDiscovery.options
: [];
const registryAvailable =
registryDiscovery?.status === REGISTRY_PROVIDER_DISCOVERY.READY ||
registryDiscovery?.status === REGISTRY_PROVIDER_DISCOVERY.ERROR;
const registryError =
registryDiscovery?.status === REGISTRY_PROVIDER_DISCOVERY.ERROR ||
registryDiscovery?.status === REGISTRY_PROVIDER_DISCOVERY.UNKNOWN;
const [providerError, setProviderError] = useState<string | null>(null);
const [discoveryAttempt, setDiscoveryAttempt] = useState(0);
// Local state needed: a request in flight cannot be derived from the attempt count.
const [isRetryingDiscovery, setIsRetryingDiscovery] = useState(false);
const submitting = useRef(false);
const createdAccount = useRef<ConnectAccountSuccessData | null>(null);
useEffect(() => {
// Registry is Cloud-only: elsewhere never ask, so a failure cannot surface it.
if (!isCloud()) return;
let active = true;
const load = async () => {
try {
const result = await getInstalledRegistryProviderOptions();
if (!active) return;
setRegistryOptions(
result.status === REGISTRY_PROVIDER_DISCOVERY.READY
? result.options
: [],
);
setRegistryAvailable(
result.status === REGISTRY_PROVIDER_DISCOVERY.READY ||
result.status === REGISTRY_PROVIDER_DISCOVERY.ERROR,
);
setRegistryError(
result.status === REGISTRY_PROVIDER_DISCOVERY.ERROR ||
result.status === REGISTRY_PROVIDER_DISCOVERY.UNKNOWN,
);
} catch {
if (active) {
setRegistryOptions([]);
setRegistryAvailable(false);
setRegistryError(true);
}
}
};
// Only this effect's own load ends a retry; event reloads must not.
void load().then(() => {
if (active) setIsRetryingDiscovery(false);
});
window.addEventListener("registry-artifacts-changed", load);
return () => {
active = false;
window.removeEventListener("registry-artifacts-changed", load);
};
}, [discoveryAttempt]);
const formSchema = createAddProviderFormSchema(
registryOptions.map((option) => option.type),
);
@@ -474,6 +440,12 @@ export const ConnectAccountForm = ({
{/* Step 1: Provider selection */}
{prevStep === 1 && (
<div data-tour-id="add-provider-provider-type">
{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>
@@ -485,11 +457,7 @@ export const ConnectAccountForm = ({
type="button"
variant="outline"
aria-disabled={isRetryingDiscovery}
onClick={() => {
if (isRetryingDiscovery) return;
setIsRetryingDiscovery(true);
setDiscoveryAttempt((attempt) => attempt + 1);
}}
onClick={retryDiscovery}
>
{isRetryingDiscovery
? "Retrying…"
@@ -0,0 +1,77 @@
import { act, renderHook, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { getInstalledRegistryProviderOptions } from "@/actions/registry/registry";
import type { RegistryProviderDiscoveryResult } from "@/lib/registry/provider-options";
import { useRegistryProviderDiscovery } from "./use-registry-provider-discovery";
vi.mock("@/actions/registry/registry", () => ({
getInstalledRegistryProviderOptions: vi.fn(),
}));
function deferred() {
let resolve!: (value: RegistryProviderDiscoveryResult) => void;
const promise = new Promise<RegistryProviderDiscoveryResult>((done) => {
resolve = done;
});
return { promise, resolve };
}
describe("useRegistryProviderDiscovery", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.stubEnv("UI_CLOUD_ENABLED", "true");
});
it("ignores a stale discovery that settles after a newer one", async () => {
// Given
const initial = deferred();
const afterChange = deferred();
vi.mocked(getInstalledRegistryProviderOptions)
.mockReturnValueOnce(initial.promise)
.mockReturnValueOnce(afterChange.promise);
const { result } = renderHook(() => useRegistryProviderDiscovery());
// When
act(() => {
window.dispatchEvent(new Event("registry-artifacts-changed"));
});
await act(async () => {
afterChange.resolve({ status: "subscription_required" });
});
await act(async () => {
initial.resolve({ status: "ready", options: [] });
});
// Then
await waitFor(() =>
expect(result.current.result).toEqual({
status: "subscription_required",
}),
);
});
it("applies a retry even when the request it replaces settles later", async () => {
// Given
const initial = deferred();
const retry = deferred();
vi.mocked(getInstalledRegistryProviderOptions)
.mockReturnValueOnce(initial.promise)
.mockReturnValueOnce(retry.promise);
const { result } = renderHook(() => useRegistryProviderDiscovery());
// When
act(() => result.current.retryDiscovery());
await act(async () => {
retry.resolve({ status: "onboarding" });
});
await act(async () => {
initial.resolve({ status: "error" });
});
// Then
await waitFor(() => expect(result.current.isRetryingDiscovery).toBe(false));
expect(result.current.result).toEqual({ status: "onboarding" });
});
});
@@ -0,0 +1,53 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { getInstalledRegistryProviderOptions } from "@/actions/registry/registry";
import {
REGISTRY_PROVIDER_DISCOVERY,
type RegistryProviderDiscoveryResult,
} from "@/lib/registry/provider-options";
import { isCloud } from "@/lib/shared/env";
export function useRegistryProviderDiscovery() {
// Local state needed: discovery and retries resolve asynchronously.
const [result, setResult] = useState<RegistryProviderDiscoveryResult | null>(
null,
);
const [attempt, setAttempt] = useState(0);
const [isRetryingDiscovery, setIsRetryingDiscovery] = useState(false);
// Initial, retry and artifact-change loads overlap: only the newest applies.
const latestRequest = useRef(0);
useEffect(() => {
if (!isCloud()) return;
let active = true;
const load = async () => {
const request = ++latestRequest.current;
const isCurrent = () => active && request === latestRequest.current;
try {
const discovery = await getInstalledRegistryProviderOptions();
if (isCurrent()) setResult(discovery);
} catch {
if (isCurrent())
setResult({ status: REGISTRY_PROVIDER_DISCOVERY.UNKNOWN });
}
};
void load().then(() => {
if (active) setIsRetryingDiscovery(false);
});
window.addEventListener("registry-artifacts-changed", load);
return () => {
active = false;
window.removeEventListener("registry-artifacts-changed", load);
};
}, [attempt]);
const retryDiscovery = () => {
if (isRetryingDiscovery) return;
setIsRetryingDiscovery(true);
setAttempt((current) => current + 1);
};
return { result, isRetryingDiscovery, retryDiscovery };
}
@@ -440,6 +440,28 @@ describe("RegistryExplorer", () => {
expect(cardFor("AWS guard")).toBeTruthy();
});
it("re-renders the page when a refresh reports a lapsed subscription", async () => {
// Given
const screen = await render(<RegistryExplorer initialState={readyState} />);
refreshRegistryCollectionsMock.mockResolvedValue({
status: "subscription_required",
});
// When
await screen.getByRole("button", { name: "Refresh Registry" }).click();
// Then
// The mocked router never swaps the page, so the explorer may refresh again.
await expect
.poll(() => vi.mocked(registryRouter.refresh).mock.calls.length)
.toBeGreaterThanOrEqual(1);
expect(vi.mocked(registryRouter.replace)).not.toHaveBeenCalled();
await expect
.element(screen.getByRole("button", { name: "Refresh Registry" }))
.toBeEnabled();
expect(document.body.textContent).not.toContain(
"Registry could not be refreshed.",
);
});
it.each(["access_denied", "reconnect", "onboarding"])(
"honors %s from a collection refresh",
async (status) => {
@@ -182,6 +182,9 @@ export function RegistryExplorer({
onResult: (result) => {
if (result.status === "access_denied") {
router.replace("/profile");
} else if (result.status === "subscription_required") {
// The server page swaps the catalog for the paid-plan prompt.
router.refresh();
} else if (result.status === "complete") {
setRefreshMessage(undefined);
setState((current) =>
@@ -0,0 +1,63 @@
import { afterEach, describe, expect, vi } from "vitest";
import { page } from "vitest/browser";
import { it } from "@/__tests__/fixtures";
import { render } from "@/__tests__/render-browser";
import { CloudUpgradeModal } from "@/components/shared/cloud-upgrade-modal";
import { useCloudUpgradeStore } from "@/store/cloud-upgrade/store";
import { RegistryUpgradePrompt } from "./registry-upgrade-prompt";
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({ permissions: { manage_billing: true } }),
}));
const VIEWPORTS = [
{ name: "mobile", width: 390, height: 844 },
{ name: "tablet", width: 768, height: 1024 },
{ name: "desktop", width: 1280, height: 800 },
];
describe("Registry paid plan prompt", () => {
afterEach(() => {
useCloudUpgradeStore.getState().closeCloudUpgrade();
});
it.each(VIEWPORTS)(
"opens the shared paid plan modal on $name",
async ({ name, width, height }) => {
// Given
await page.viewport(width, height);
const screen = await render(
<main className="bg-bg-neutral-primary min-h-screen p-6">
<RegistryUpgradePrompt />
<CloudUpgradeModal />
</main>,
);
// When
await screen.getByRole("button", { name: "Unlock Registry" }).click();
// Then
await expect
.element(page.getByRole("dialog", { name: "Unlock Prowler Registry" }))
.toBeVisible();
await expect
.element(page.getByRole("link", { name: "Upgrade to Unlock Registry" }))
.toHaveAttribute("href", "/billing?feature=registry");
expect(document.documentElement.scrollWidth).toBeLessThanOrEqual(width);
await expect
.poll(() =>
page
.getByRole("dialog", { name: "Unlock Prowler Registry" })
.element()
.getAnimations({ subtree: true })
.every((animation) => animation.playState !== "running"),
)
.toBe(true);
await page.screenshot({
path: `../../tests/providers/evidence/registry-paywall-${name}.png`,
});
},
);
});
@@ -0,0 +1,46 @@
import { render, screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { RegistrySetupPrompt } from "./registry-upgrade-prompt";
const { permissions } = vi.hoisted(() => ({
permissions: { manage_registry: false },
}));
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({ permissions }),
}));
describe("RegistrySetupPrompt", () => {
beforeEach(() => {
permissions.manage_registry = false;
});
it("sends Registry managers to connect their key", () => {
// Given
permissions.manage_registry = true;
// When
render(<RegistrySetupPrompt />);
// Then
expect(
screen.getByRole("link", { name: "Connect Registry" }),
).toHaveAttribute("href", "/registry");
});
it("asks everyone else for an administrator", () => {
// When
render(<RegistrySetupPrompt />);
// Then
expect(
screen.getByText(
"Ask an administrator to connect Registry before adding custom providers.",
),
).toBeVisible();
expect(
screen.queryByRole("link", { name: "Connect Registry" }),
).not.toBeInTheDocument();
});
});
@@ -0,0 +1,49 @@
"use client";
import { Alert, AlertDescription, AlertTitle } from "@/components/shadcn/alert";
import { Button } from "@/components/shadcn/button/button";
import { useAuth } from "@/hooks/use-auth";
import { useCloudUpgradeStore } from "@/store/cloud-upgrade/store";
import { PAID_PLAN_UPGRADE_FEATURE } from "@/types/cloud-upgrade";
export function RegistryUpgradePrompt() {
const openCloudUpgrade = useCloudUpgradeStore(
(state) => state.openCloudUpgrade,
);
return (
<Alert>
<AlertTitle>Registry is available on paid plans</AlertTitle>
<AlertDescription>
Unlock custom providers, security checks, and compliance frameworks.
<Button
type="button"
onClick={() => openCloudUpgrade(PAID_PLAN_UPGRADE_FEATURE.REGISTRY)}
>
Unlock Registry
</Button>
</AlertDescription>
</Alert>
);
}
export function RegistrySetupPrompt() {
const { permissions } = useAuth();
return (
<Alert>
<AlertTitle>Registry is not connected yet</AlertTitle>
<AlertDescription>
{permissions.manage_registry === true ? (
<>
Connect your Registry API key to load custom providers.
<Button asChild variant="outline">
<a href="/registry">Connect Registry</a>
</Button>
</>
) : (
"Ask an administrator to connect Registry before adding custom providers."
)}
</AlertDescription>
</Alert>
);
}
@@ -51,6 +51,23 @@ vi.mock("@/components/shadcn/modal", async (importOriginal) => {
});
describe("CloudUpgradeModal", () => {
it("links the Registry paid plan upgrade to billing", async () => {
// Given
vi.stubEnv("UI_CLOUD_ENABLED", "true");
useCloudUpgradeStore.getState().openCloudUpgrade("registry");
// When
render(<CloudUpgradeModal />);
// Then
expect(
await screen.findByRole("dialog", { name: "Unlock Prowler Registry" }),
).toBeVisible();
expect(
screen.getByRole("link", { name: "Upgrade to Unlock Registry" }),
).toHaveAttribute("href", "/billing?feature=registry");
});
afterEach(() => {
cleanup();
modalTestState.keepContentMounted = false;