diff --git a/ui/actions/registry/registry.adapter.ts b/ui/actions/registry/registry.adapter.ts index e84fab1218..e0d82ba91d 100644 --- a/ui/actions/registry/registry.adapter.ts +++ b/ui/actions/registry/registry.adapter.ts @@ -190,6 +190,9 @@ export async function classifyRegistryFailure( endpoint: RegistryEndpoint, credentialStatus: RegistryCredentialStatus | null, ): Promise { + 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 }; } diff --git a/ui/actions/registry/registry.test.ts b/ui/actions/registry/registry.test.ts index a3a44ffcf9..5f85fcc27a 100644 --- a/ui/actions/registry/registry.test.ts +++ b/ui/actions/registry/registry.test.ts @@ -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, diff --git a/ui/actions/registry/registry.ts b/ui/actions/registry/registry.ts index 18cd2322ce..e6a1ae3964 100644 --- a/ui/actions/registry/registry.ts +++ b/ui/actions/registry/registry.ts @@ -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 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 { } 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({ diff --git a/ui/app/(prowler)/registry/page.test.tsx b/ui/app/(prowler)/registry/page.test.tsx new file mode 100644 index 0000000000..c87e2014ae --- /dev/null +++ b/ui/app/(prowler)/registry/page.test.tsx @@ -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: () =>

Registry catalog

, +})); +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"); + }); +}); diff --git a/ui/app/(prowler)/registry/page.tsx b/ui/app/(prowler)/registry/page.tsx index 36c2bb90f6..cf0c61c1d3 100644 --- a/ui/app/(prowler)/registry/page.tsx +++ b/ui/app/(prowler)/registry/page.tsx @@ -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 ( }> - + {bootstrap.status === REGISTRY_FAILURE.SUBSCRIPTION_REQUIRED ? ( + + ) : ( + + )} ); } diff --git a/ui/changelog.d/registry-access-prompts.fixed.md b/ui/changelog.d/registry-access-prompts.fixed.md new file mode 100644 index 0000000000..19b79647ce --- /dev/null +++ b/ui/changelog.d/registry-access-prompts.fixed.md @@ -0,0 +1 @@ +Registry provider discovery distinguishes paid-plan upgrades, missing credentials, and connection failures diff --git a/ui/components/providers/workflow/forms/connect-account-form.test.tsx b/ui/components/providers/workflow/forms/connect-account-form.test.tsx index 763cbcb4b3..4f090b7fce 100644 --- a/ui/components/providers/workflow/forms/connect-account-form.test.tsx +++ b/ui/components/providers/workflow/forms/connect-account-form.test.tsx @@ -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(); + + // 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(); + 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"); diff --git a/ui/components/providers/workflow/forms/connect-account-form.tsx b/ui/components/providers/workflow/forms/connect-account-form.tsx index f0199a0c80..3380aa9c0d 100644 --- a/ui/components/providers/workflow/forms/connect-account-form.tsx +++ b/ui/components/providers/workflow/forms/connect-account-form.tsx @@ -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(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(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 && (
+ {registryDiscovery?.status === + REGISTRY_PROVIDER_DISCOVERY.SUBSCRIPTION_REQUIRED && ( + + )} + {registryDiscovery?.status === + REGISTRY_PROVIDER_DISCOVERY.ONBOARDING && } {registryError && ( Registry providers could not be loaded @@ -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…" diff --git a/ui/components/providers/workflow/forms/use-registry-provider-discovery.test.ts b/ui/components/providers/workflow/forms/use-registry-provider-discovery.test.ts new file mode 100644 index 0000000000..b16784f16d --- /dev/null +++ b/ui/components/providers/workflow/forms/use-registry-provider-discovery.test.ts @@ -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((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" }); + }); +}); diff --git a/ui/components/providers/workflow/forms/use-registry-provider-discovery.ts b/ui/components/providers/workflow/forms/use-registry-provider-discovery.ts new file mode 100644 index 0000000000..f6add31968 --- /dev/null +++ b/ui/components/providers/workflow/forms/use-registry-provider-discovery.ts @@ -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( + 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 }; +} diff --git a/ui/components/registry/registry-explorer.integration.test.tsx b/ui/components/registry/registry-explorer.integration.test.tsx index 4641c873c3..bf2101b318 100644 --- a/ui/components/registry/registry-explorer.integration.test.tsx +++ b/ui/components/registry/registry-explorer.integration.test.tsx @@ -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(); + 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) => { diff --git a/ui/components/registry/registry-explorer.tsx b/ui/components/registry/registry-explorer.tsx index 8ac02f93ec..14b04b52a9 100644 --- a/ui/components/registry/registry-explorer.tsx +++ b/ui/components/registry/registry-explorer.tsx @@ -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) => diff --git a/ui/components/registry/registry-upgrade-prompt.integration.test.tsx b/ui/components/registry/registry-upgrade-prompt.integration.test.tsx new file mode 100644 index 0000000000..2c32e3e568 --- /dev/null +++ b/ui/components/registry/registry-upgrade-prompt.integration.test.tsx @@ -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( +
+ + +
, + ); + + // 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`, + }); + }, + ); +}); diff --git a/ui/components/registry/registry-upgrade-prompt.test.tsx b/ui/components/registry/registry-upgrade-prompt.test.tsx new file mode 100644 index 0000000000..c548e14262 --- /dev/null +++ b/ui/components/registry/registry-upgrade-prompt.test.tsx @@ -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(); + + // Then + expect( + screen.getByRole("link", { name: "Connect Registry" }), + ).toHaveAttribute("href", "/registry"); + }); + + it("asks everyone else for an administrator", () => { + // When + render(); + + // Then + expect( + screen.getByText( + "Ask an administrator to connect Registry before adding custom providers.", + ), + ).toBeVisible(); + expect( + screen.queryByRole("link", { name: "Connect Registry" }), + ).not.toBeInTheDocument(); + }); +}); diff --git a/ui/components/registry/registry-upgrade-prompt.tsx b/ui/components/registry/registry-upgrade-prompt.tsx new file mode 100644 index 0000000000..4ab5321879 --- /dev/null +++ b/ui/components/registry/registry-upgrade-prompt.tsx @@ -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 ( + + Registry is available on paid plans + + Unlock custom providers, security checks, and compliance frameworks. + + + + ); +} + +export function RegistrySetupPrompt() { + const { permissions } = useAuth(); + return ( + + Registry is not connected yet + + {permissions.manage_registry === true ? ( + <> + Connect your Registry API key to load custom providers. + + + ) : ( + "Ask an administrator to connect Registry before adding custom providers." + )} + + + ); +} diff --git a/ui/components/shared/cloud-upgrade-modal.test.tsx b/ui/components/shared/cloud-upgrade-modal.test.tsx index 0687d9c564..5b50a7a5b1 100644 --- a/ui/components/shared/cloud-upgrade-modal.test.tsx +++ b/ui/components/shared/cloud-upgrade-modal.test.tsx @@ -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(); + + // 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; diff --git a/ui/lib/cloud-upgrade.ts b/ui/lib/cloud-upgrade.ts index a3a6dc0e8c..3f4c3bbb49 100644 --- a/ui/lib/cloud-upgrade.ts +++ b/ui/lib/cloud-upgrade.ts @@ -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; 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.", diff --git a/ui/lib/registry/provider-options.ts b/ui/lib/registry/provider-options.ts index 0c586484df..567a1b760f 100644 --- a/ui/lib/registry/provider-options.ts +++ b/ui/lib/registry/provider-options.ts @@ -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[]; diff --git a/ui/tests/providers/evidence/registry-paywall-desktop.png b/ui/tests/providers/evidence/registry-paywall-desktop.png new file mode 100644 index 0000000000..40615c3110 Binary files /dev/null and b/ui/tests/providers/evidence/registry-paywall-desktop.png differ diff --git a/ui/tests/providers/evidence/registry-paywall-mobile.png b/ui/tests/providers/evidence/registry-paywall-mobile.png new file mode 100644 index 0000000000..981004a300 Binary files /dev/null and b/ui/tests/providers/evidence/registry-paywall-mobile.png differ diff --git a/ui/tests/providers/evidence/registry-paywall-tablet.png b/ui/tests/providers/evidence/registry-paywall-tablet.png new file mode 100644 index 0000000000..832c6cc067 Binary files /dev/null and b/ui/tests/providers/evidence/registry-paywall-tablet.png differ diff --git a/ui/types/cloud-upgrade.ts b/ui/types/cloud-upgrade.ts index 39d7fd113e..e612f72f3e 100644 --- a/ui/types/cloud-upgrade.ts +++ b/ui/types/cloud-upgrade.ts @@ -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; diff --git a/ui/types/registry.ts b/ui/types/registry.ts index 954b794b81..5820214a4e 100644 --- a/ui/types/registry.ts +++ b/ui/types/registry.ts @@ -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 };