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

+7 -3
View File
@@ -190,6 +190,9 @@ export async function classifyRegistryFailure(
endpoint: RegistryEndpoint,
credentialStatus: RegistryCredentialStatus | null,
): Promise<RegistryFailureResult> {
if (response.status === 402) {
return { status: REGISTRY_FAILURE.SUBSCRIPTION_REQUIRED };
}
if (response.status === 401 || response.status === 403) {
return { status: REGISTRY_FAILURE.ACCESS_DENIED };
}
@@ -198,15 +201,16 @@ export async function classifyRegistryFailure(
return { status: REGISTRY_FAILURE.ERROR };
}
const code = await getRegistryErrorCode(response);
if (
response.status === 409 &&
credentialStatus !== null &&
!isActiveRegistryCredential(credentialStatus)
(code === "registry_credential_not_configured" ||
(credentialStatus !== null &&
!isActiveRegistryCredential(credentialStatus)))
) {
return { status: REGISTRY_FAILURE.ONBOARDING };
}
const code = await getRegistryErrorCode(response);
if (response.status === 502 && code === REGISTRY_ERROR_CODE.KEY_REJECTED) {
return { status: REGISTRY_FAILURE.RECONNECT };
}
+39
View File
@@ -115,6 +115,45 @@ function mockRequestDeadlines() {
}
describe("installed Registry provider discovery", () => {
it("reports a missing Registry key as setup rather than a connection failure", async () => {
// Given
mockDiscovery({
failedEndpoint: "providers",
failureStatus: 409,
failureBody: { errors: [{ code: "registry_credential_not_configured" }] },
});
// When / Then
expect(await getInstalledRegistryProviderOptions()).toEqual({
status: "onboarding",
});
});
it("shows the paywall when bootstrapping Registry requires a subscription", async () => {
// Given
fetchMock.mockResolvedValueOnce(jsonResponse({}, 402));
// When / Then
expect(await getRegistryBootstrap()).toEqual({
status: "subscription_required",
});
expect(fetchMock).toHaveBeenCalledOnce();
});
it.each(["available-artifacts", "artifacts", "providers"])(
"offers a paid plan when the %s read requires a subscription",
async (failedEndpoint) => {
// Given
mockDiscovery({ failedEndpoint, failureStatus: 402 });
// When
const result = await getInstalledRegistryProviderOptions();
// Then
expect(result).toEqual({ status: "subscription_required" });
},
);
function mockDiscovery({
emptyMetadata = false,
failedEndpoint,
+24 -4
View File
@@ -138,6 +138,8 @@ async function classifyDiscoveryFailure(
credential: RegistryCredentialStatus | null,
) {
if (response.status === 409 && credential === null) {
const failure = await classifyRegistryFailure(response, endpoint, null);
if (failure.status === REGISTRY_FAILURE.ONBOARDING) return failure;
const currentCredential = await readRegistryCredential(accessToken);
if (currentCredential.status === REGISTRY_FAILURE.ACCESS_DENIED) {
return currentCredential;
@@ -211,6 +213,18 @@ export async function getInstalledRegistryProviderOptions(): Promise<RegistryPro
)
)
return { status: REGISTRY_PROVIDER_DISCOVERY.ACCESS_DENIED };
if (
[catalog.status, installed.status, providers.status].some(
(status) => status === REGISTRY_FAILURE.SUBSCRIPTION_REQUIRED,
)
)
return { status: REGISTRY_PROVIDER_DISCOVERY.SUBSCRIPTION_REQUIRED };
if (
[catalog.status, providers.status].some(
(status) => status === REGISTRY_FAILURE.ONBOARDING,
)
)
return { status: REGISTRY_PROVIDER_DISCOVERY.ONBOARDING };
if (
catalog.status !== REGISTRY_CATALOG.COMPLETE ||
installed.status !== "ready" ||
@@ -303,8 +317,11 @@ function bootstrapReady(
function bootstrapFailure(
failure: RegistryFailureResult,
): RegistryBootstrapResult {
if (failure.status === REGISTRY_FAILURE.ACCESS_DENIED) {
return { status: REGISTRY_FAILURE.ACCESS_DENIED };
if (
failure.status === REGISTRY_FAILURE.ACCESS_DENIED ||
failure.status === REGISTRY_FAILURE.SUBSCRIPTION_REQUIRED
) {
return { status: failure.status };
}
return bootstrapReady({
status:
@@ -340,8 +357,11 @@ export async function getRegistryBootstrap(): Promise<RegistryBootstrapResult> {
}
const catalog = await readCompleteRegistryCatalog(access, credential);
if (catalog.status === REGISTRY_FAILURE.ACCESS_DENIED) {
return { status: REGISTRY_FAILURE.ACCESS_DENIED };
if (
catalog.status === REGISTRY_FAILURE.ACCESS_DENIED ||
catalog.status === REGISTRY_FAILURE.SUBSCRIPTION_REQUIRED
) {
return { status: catalog.status };
}
if (catalog.status === REGISTRY_CATALOG.INCOMPLETE) {
return bootstrapReady({
+55
View File
@@ -0,0 +1,55 @@
import { render, screen } from "@testing-library/react";
import type { ReactNode } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { getRegistryBootstrap } from "@/actions/registry/registry";
import RegistryPage from "./page";
vi.mock("@/actions/registry/registry", () => ({
getRegistryBootstrap: vi.fn(),
}));
vi.mock("@/lib/registry/presentation", () => ({
readRegistryPresentation: () => ({}),
}));
vi.mock("@/components/shadcn/content-layout/content-layout", () => ({
ContentLayout: ({ children }: { children: ReactNode }) => children,
}));
vi.mock("@/components/registry/registry-explorer", () => ({
RegistryExplorer: () => <p>Registry catalog</p>,
}));
vi.mock("next/navigation", () => ({
redirect: vi.fn((path: string) => {
throw new Error(`redirect:${path}`);
}),
}));
describe("Registry paid access", () => {
beforeEach(() => vi.clearAllMocks());
it("offers an upgrade on direct visits without loading the catalog", async () => {
// Given
vi.mocked(getRegistryBootstrap).mockResolvedValue({
status: "subscription_required",
});
// When
render(await RegistryPage());
// Then
expect(
screen.getByRole("button", { name: "Unlock Registry" }),
).toBeVisible();
expect(screen.queryByText("Registry catalog")).not.toBeInTheDocument();
});
it("keeps access denial separate from a paid plan upgrade", async () => {
// Given
vi.mocked(getRegistryBootstrap).mockResolvedValue({
status: "access_denied",
});
// When / Then
await expect(RegistryPage()).rejects.toThrow("redirect:/profile");
});
});
+9 -4
View File
@@ -3,6 +3,7 @@ import { redirect } from "next/navigation";
import { getRegistryBootstrap } from "@/actions/registry/registry";
import { RegistryExplorer } from "@/components/registry/registry-explorer";
import { RegistryUpgradePrompt } from "@/components/registry/registry-upgrade-prompt";
import { ContentLayout } from "@/components/shadcn/content-layout/content-layout";
import { readRegistryPresentation } from "@/lib/registry/presentation";
import { REGISTRY_FAILURE } from "@/types/registry";
@@ -15,10 +16,14 @@ export default async function RegistryPage() {
return (
<ContentLayout title="Registry" icon={<Package />}>
<RegistryExplorer
initialState={bootstrap.state}
registryUrl={readRegistryPresentation().registryUrl}
/>
{bootstrap.status === REGISTRY_FAILURE.SUBSCRIPTION_REQUIRED ? (
<RegistryUpgradePrompt />
) : (
<RegistryExplorer
initialState={bootstrap.state}
registryUrl={readRegistryPresentation().registryUrl}
/>
)}
</ContentLayout>
);
}
@@ -0,0 +1 @@
Registry provider discovery distinguishes paid-plan upgrades, missing credentials, and connection failures
@@ -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;
+11
View File
@@ -215,10 +215,21 @@ export const PAID_PLAN_UPGRADE_ADMIN_NOTE =
const CLOUD_UTM_SOURCE = "prowler-cloud";
const PAID_PLAN_UPGRADE_UTM_CONTENT = {
[PAID_PLAN_UPGRADE_FEATURE.REGISTRY]: "registry",
[PAID_PLAN_UPGRADE_FEATURE.REPORT_DOWNLOAD]: "report-download",
} as const satisfies Record<PaidPlanUpgradeFeature, string>;
export const PAID_PLAN_UPGRADE_CONTENT = {
[PAID_PLAN_UPGRADE_FEATURE.REGISTRY]: {
title: "Unlock Prowler Registry",
description: "Prowler Registry is included in paid plans.",
benefits: [
"Connect custom cloud providers",
"Extend your assessments with custom security checks",
"Use private compliance frameworks across your accounts",
],
primaryCta: "Upgrade to Unlock Registry",
},
[PAID_PLAN_UPGRADE_FEATURE.REPORT_DOWNLOAD]: {
title: "Download Your Scan Reports",
description: "Report downloads are included in Prowler Cloud paid plans.",
+4
View File
@@ -12,6 +12,8 @@ export interface RegistryProviderOption {
}
export const REGISTRY_PROVIDER_DISCOVERY = {
SUBSCRIPTION_REQUIRED: "subscription_required",
ONBOARDING: "onboarding",
READY: "ready",
ACCESS_DENIED: "access_denied",
/** Access could not be evaluated: keep Registry hidden but retryable. */
@@ -20,6 +22,8 @@ export const REGISTRY_PROVIDER_DISCOVERY = {
} as const;
export type RegistryProviderDiscoveryResult =
| { status: typeof REGISTRY_PROVIDER_DISCOVERY.SUBSCRIPTION_REQUIRED }
| { status: typeof REGISTRY_PROVIDER_DISCOVERY.ONBOARDING }
| {
status: typeof REGISTRY_PROVIDER_DISCOVERY.READY;
options: RegistryProviderOption[];
Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

+1
View File
@@ -19,6 +19,7 @@ export type CloudUpgradeFeature =
// Prowler Cloud features gated behind a paid plan.
export const PAID_PLAN_UPGRADE_FEATURE = {
REGISTRY: "registry",
REPORT_DOWNLOAD: "report_download",
} as const;
+4 -1
View File
@@ -9,6 +9,7 @@ export type RegistryEndpoint =
(typeof REGISTRY_ENDPOINT)[keyof typeof REGISTRY_ENDPOINT];
export const REGISTRY_FAILURE = {
SUBSCRIPTION_REQUIRED: "subscription_required",
ACCESS_DENIED: "access_denied",
ONBOARDING: "onboarding",
RECONNECT: "reconnect",
@@ -17,6 +18,7 @@ export const REGISTRY_FAILURE = {
} as const;
export type RegistryFailureResult =
| { status: typeof REGISTRY_FAILURE.SUBSCRIPTION_REQUIRED }
| { status: typeof REGISTRY_FAILURE.ACCESS_DENIED }
| { status: typeof REGISTRY_FAILURE.ONBOARDING }
| { status: typeof REGISTRY_FAILURE.RECONNECT }
@@ -300,4 +302,5 @@ export type RegistryBootstrapResult =
status: typeof REGISTRY_BOOTSTRAP_STATE.READY;
state: RegistryBootstrapState;
}
| { status: typeof REGISTRY_FAILURE.ACCESS_DENIED };
| { status: typeof REGISTRY_FAILURE.ACCESS_DENIED }
| { status: typeof REGISTRY_FAILURE.SUBSCRIPTION_REQUIRED };