+ render={({ field }) => {
+ const searchInput = (
+
= ({
onClear={() => setSearchTerm("")}
/>
+ );
+ const providerList = (
+
+ {filteredProviders.length > 0 ? (
+ filteredProviders.map((provider) => {
+ const isSelected = field.value === provider.value;
-
-
- {filteredProviders.length > 0 ? (
- filteredProviders.map((provider) => {
- const BadgeComponent = provider.badge;
- const isSelected = field.value === provider.value;
-
- return (
-
+
+ {provider.registry ? (
+
+
+
+
+
+
+ ) : (
+
+ )}
+
+ {provider.label}
+
+ {provider.registry && (
+
Registry
+ )}
+
+
+ );
+ })
+ ) : (
+
+ {lowerSearch ? (
+ <>No providers found matching "{searchTerm}">
+ ) : (
+ "No Registry providers available."
+ )}
+
+ )}
+ );
+ const validationMessage = errorMessage && (
+
+ {errorMessage}
+
+ );
- {errorMessage && (
-
- {errorMessage}
-
- )}
-
- )}
+ if (!registryAvailable) {
+ return (
+
+ {searchInput}
+
{providerList}
+ {validationMessage}
+
+ );
+ }
+
+ return (
+
setSelectedTab(value as ProviderTab)}
+ >
+
+ All providers
+ Registry
+
+ {searchInput}
+ {providerList}
+ {validationMessage}
+
+ );
+ }}
/>
);
};
diff --git a/ui/components/providers/table/column-providers.tsx b/ui/components/providers/table/column-providers.tsx
index 78c59ac764..548dda2e82 100644
--- a/ui/components/providers/table/column-providers.tsx
+++ b/ui/components/providers/table/column-providers.tsx
@@ -251,7 +251,7 @@ export function getColumnProviders(
entityId={provider.attributes.uid}
nameAction={
provider.attributes.is_dynamic ? (
-
Custom
+
Registry
) : undefined
}
/>
diff --git a/ui/components/providers/table/data-table-row-actions.test.tsx b/ui/components/providers/table/data-table-row-actions.test.tsx
index 5fa6f3f409..c65863abd2 100644
--- a/ui/components/providers/table/data-table-row-actions.test.tsx
+++ b/ui/components/providers/table/data-table-row-actions.test.tsx
@@ -1,3 +1,17 @@
+vi.mock("@/actions/providers/registry-provider", () => ({
+ addRegistryProvider: vi.fn(),
+}));
+vi.mock("@/actions/registry/registry", () => ({
+ getInstalledRegistryProviderOptions: vi
+ .fn()
+ .mockResolvedValue({ status: "access_denied" }),
+}));
+vi.mock("@/actions/providers/provider-schemas", () => ({
+ getProviderSchemas: vi.fn(),
+}));
+vi.mock("@/actions/providers/dynamic-provider-credentials", () => ({
+ saveDynamicProviderCredentials: vi.fn(),
+}));
import { Row } from "@tanstack/react-table";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
@@ -327,7 +341,7 @@ describe("DataTableRowActions", () => {
expect(screen.queryByText("Update Credentials")).not.toBeInTheDocument();
});
- it("allows rename/delete and operational actions for a dynamic provider but hides credential management", async () => {
+ it("allows credential editing and operational actions for a dynamic provider", async () => {
// Given a dynamic provider outside the configurable set, with the advanced
// schedule capability enabled (so Edit Scan Schedule can show).
const user = userEvent.setup();
@@ -354,9 +368,9 @@ describe("DataTableRowActions", () => {
expect(screen.getByText("Test Connection")).toBeInTheDocument();
expect(screen.getByText("View Scan Jobs")).toBeInTheDocument();
expect(screen.getByText("Edit Scan Schedule")).toBeInTheDocument();
- // ...but credential management is hidden (no bespoke wizard for dynamic types)
+ // Existing dynamic accounts use the same wizard with schema-based credentials.
expect(screen.queryByText("Add Credentials")).not.toBeInTheDocument();
- expect(screen.queryByText("Update Credentials")).not.toBeInTheDocument();
+ expect(screen.getByText("Update Credentials")).toBeInTheDocument();
});
it("navigates to the provider-filtered scan jobs from View Scan Jobs", async () => {
diff --git a/ui/components/providers/table/data-table-row-actions.tsx b/ui/components/providers/table/data-table-row-actions.tsx
index 1866a44f73..bcbbd8e583 100644
--- a/ui/components/providers/table/data-table-row-actions.tsx
+++ b/ui/components/providers/table/data-table-row-actions.tsx
@@ -52,7 +52,6 @@ import {
OrgFlowType,
} from "@/types/organizations";
import { PROVIDER_WIZARD_MODE } from "@/types/provider-wizard";
-import { isConfigurableProvider } from "@/types/providers";
import {
isProvidersOrganizationRow,
PROVIDERS_GROUP_KIND,
@@ -355,8 +354,7 @@ export function DataTableRowActions({
const provider = isOrganizationRow ? null : rowData;
const providerId = provider?.id ?? "";
const providerType = provider?.attributes.provider ?? "";
- // Only predefined providers can manage credentials from the UI
- const canManageCredentials = isConfigurableProvider(providerType);
+ const canManageCredentials = Boolean(providerType);
const providerUid = provider?.attributes.uid ?? "";
const providerAlias = provider?.attributes.alias ?? null;
const providerSecretId = provider?.relationships.secret.data?.id ?? null;
diff --git a/ui/components/providers/wizard/provider-wizard-modal.test.tsx b/ui/components/providers/wizard/provider-wizard-modal.test.tsx
new file mode 100644
index 0000000000..fc913924bc
--- /dev/null
+++ b/ui/components/providers/wizard/provider-wizard-modal.test.tsx
@@ -0,0 +1,250 @@
+import { act, render, screen, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+import { Toaster } from "@/components/shadcn/toast/Toaster";
+import { resetToasts } from "@/components/shadcn/toast/use-toast";
+import { useProviderWizardStore } from "@/store/provider-wizard/store";
+
+import { ProviderWizardModal } from "./provider-wizard-modal";
+
+const { addRegistryProvider, getInstalledRegistryProviderOptions } = vi.hoisted(
+ () => ({
+ addRegistryProvider: vi.fn(),
+ getInstalledRegistryProviderOptions: vi.fn(),
+ }),
+);
+
+vi.mock("next/navigation", () => ({
+ useRouter: () => ({ refresh: vi.fn(), push: vi.fn() }),
+}));
+vi.mock("@/actions/providers/providers", () => ({ addProvider: vi.fn() }));
+vi.mock("@/actions/providers/registry-provider", () => ({
+ addRegistryProvider,
+}));
+vi.mock("@/actions/registry/registry", () => ({
+ getInstalledRegistryProviderOptions,
+}));
+vi.mock(
+ "@/components/providers/workflow/forms",
+ async () => import("../workflow/forms/connect-account-form"),
+);
+vi.mock("@/hooks/use-scroll-hint", () => ({
+ useScrollHint: () => ({ showScrollHint: false }),
+}));
+vi.mock("@/lib/tours/use-driver-tour", () => ({
+ advanceActiveTour: vi.fn(),
+ endActiveTour: vi.fn(),
+}));
+vi.mock("./steps/credentials-step", () => ({
+ CredentialsStep: () =>
Credential details
,
+}));
+vi.mock("./steps/test-connection-step", () => ({
+ TestConnectionStep: () => null,
+}));
+vi.mock("./steps/launch-step", () => ({ LaunchStep: () => null }));
+vi.mock("../organizations/azure-org-setup-form", () => ({
+ AzureOrgSetupForm: () => null,
+}));
+vi.mock("../organizations/gcp-org-setup-form", () => ({
+ GcpOrgSetupForm: () => null,
+}));
+vi.mock("../organizations/org-setup-form", () => ({
+ OrgSetupForm: () => null,
+}));
+vi.mock("../organizations/org-account-selection", () => ({
+ OrgAccountSelection: () => null,
+}));
+vi.mock("../organizations/org-launch-scan", () => ({
+ OrgLaunchScan: () => null,
+}));
+
+const createdAccount = {
+ data: {
+ id: "account",
+ attributes: { provider: "acme", uid: "acme-account", alias: null },
+ },
+};
+
+async function enterAccountDetails() {
+ const user = userEvent.setup();
+ render(
+ <>
+
+
+ >,
+ );
+ await user.click(
+ await screen.findByRole("option", { name: "Acme Cloud Registry" }),
+ );
+ await user.type(
+ screen.getByRole("textbox", { name: "Provider UID" }),
+ "acme-account",
+ );
+ await waitFor(() =>
+ expect(screen.getByRole("button", { name: "Next" })).toBeEnabled(),
+ );
+ return user;
+}
+
+describe("provider wizard account creation", () => {
+ beforeEach(() => {
+ useProviderWizardStore.getState().reset();
+ resetToasts();
+ getInstalledRegistryProviderOptions.mockResolvedValue({
+ status: "ready",
+ options: [{ type: "acme", label: "Acme Cloud" }],
+ });
+ });
+
+ it("shows progress, blocks repeat clicks, and advances after creation", async () => {
+ // Given
+ let resolveCreation!: (value: typeof createdAccount) => void;
+ addRegistryProvider.mockImplementationOnce(
+ () =>
+ new Promise((resolve) => {
+ resolveCreation = resolve;
+ }),
+ );
+ const user = await enterAccountDetails();
+
+ // When
+ await user.click(screen.getByRole("button", { name: "Next" }));
+
+ // Then
+ const pending = await screen.findByRole("button", {
+ name: "Creating provider...",
+ });
+ expect(pending).toBeDisabled();
+ expect(pending).toHaveAttribute("aria-busy", "true");
+ expect(screen.getByRole("button", { name: "Back" })).toBeDisabled();
+ await user.dblClick(pending);
+ expect(addRegistryProvider).toHaveBeenCalledOnce();
+
+ // When / Then
+ await act(async () => resolveCreation(createdAccount));
+ expect(await screen.findByText("Credential details")).toBeVisible();
+ });
+
+ it("restores Next after a failed creation and retries the same account", async () => {
+ // Given
+ const failure = { errors: [{ detail: "Creation failed. Try again." }] };
+ let resolveCreation!: (value: typeof failure) => void;
+ addRegistryProvider
+ .mockImplementationOnce(
+ () =>
+ new Promise((resolve) => {
+ resolveCreation = resolve;
+ }),
+ )
+ .mockResolvedValueOnce(createdAccount);
+ const user = await enterAccountDetails();
+
+ // When
+ await user.click(screen.getByRole("button", { name: "Next" }));
+ await screen.findByRole("button", { name: "Creating provider..." });
+ await act(async () => resolveCreation(failure));
+
+ // Then
+ expect(await screen.findByText(failure.errors[0].detail)).toBeVisible();
+ const next = screen.getByRole("button", { name: "Next" });
+ await waitFor(() => expect(next).toBeEnabled());
+ expect(next).not.toHaveAttribute("aria-busy", "true");
+ expect(screen.getByRole("button", { name: "Back" })).toBeEnabled();
+ expect(screen.getByRole("textbox", { name: "Provider UID" })).toHaveValue(
+ "acme-account",
+ );
+
+ // When / Then
+ await user.click(next);
+ expect(await screen.findByText("Credential details")).toBeVisible();
+ expect(addRegistryProvider).toHaveBeenCalledTimes(2);
+ expect(
+ Object.fromEntries(addRegistryProvider.mock.calls[1][0]),
+ ).toMatchObject({ providerType: "acme", providerUid: "acme-account" });
+ });
+
+ it("shows provider conflicts in the account step and allows retrying", async () => {
+ // Given
+ const detail =
+ "The artifact 'acme' is not installed on this deployment yet. Install it again and retry.";
+ addRegistryProvider
+ .mockResolvedValueOnce({
+ errors: [
+ {
+ status: "409",
+ detail,
+ source: { pointer: "/data/attributes/provider" },
+ },
+ ],
+ })
+ .mockResolvedValueOnce(createdAccount);
+ const user = await enterAccountDetails();
+
+ // When
+ await user.click(screen.getByRole("button", { name: "Next" }));
+
+ // Then
+ expect(await screen.findByRole("alert")).toHaveTextContent(detail);
+ expect(screen.getByRole("textbox", { name: "Provider UID" })).toHaveValue(
+ "acme-account",
+ );
+ const next = await screen.findByRole("button", { name: "Next" });
+ await waitFor(() => expect(next).toBeEnabled());
+ expect(screen.getByRole("button", { name: "Back" })).toBeEnabled();
+
+ // When / Then: the provider becomes available and the same account retries.
+ await user.click(next);
+ expect(await screen.findByText("Credential details")).toBeVisible();
+ expect(screen.queryByText(detail)).not.toBeInTheDocument();
+ expect(addRegistryProvider).toHaveBeenCalledTimes(2);
+ });
+
+ it("keeps native providers available during a Registry discovery error and retries", async () => {
+ // Given
+ getInstalledRegistryProviderOptions.mockRejectedValueOnce(
+ new Error("Unavailable"),
+ );
+ const user = userEvent.setup();
+ render(
);
+ await screen.findByText("Registry providers could not be loaded");
+ expect(
+ screen.getByRole("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("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 () => {
+ // Given: Cloud with Registry enabled, but the catalog read failed.
+ getInstalledRegistryProviderOptions.mockResolvedValueOnce({
+ status: "error",
+ });
+ const user = userEvent.setup();
+ render(
);
+ await screen.findByText("Registry providers could not be loaded");
+
+ // When
+ await user.click(screen.getByRole("tab", { name: "Registry" }));
+
+ // Then
+ expect(screen.getByText("No Registry providers available.")).toBeVisible();
+ });
+});
diff --git a/ui/components/providers/wizard/provider-wizard-modal.tsx b/ui/components/providers/wizard/provider-wizard-modal.tsx
index d39a491b17..fab1a4440f 100644
--- a/ui/components/providers/wizard/provider-wizard-modal.tsx
+++ b/ui/components/providers/wizard/provider-wizard-modal.tsx
@@ -1,6 +1,6 @@
"use client";
-import { ExternalLink, Info } from "lucide-react";
+import { ExternalLink, Info, Loader2 } from "lucide-react";
import { AzureOrgSetupForm } from "@/components/providers/organizations/azure-org-setup-form";
import { GcpOrgSetupForm } from "@/components/providers/organizations/gcp-org-setup-form";
@@ -405,7 +405,11 @@ export function ProviderWizardModal({
: "button"
}
form={resolvedFooterConfig.actionFormId}
- disabled={resolvedFooterConfig.actionDisabled}
+ disabled={
+ resolvedFooterConfig.actionDisabled ||
+ resolvedFooterConfig.actionLoading
+ }
+ aria-busy={resolvedFooterConfig.actionLoading || undefined}
onClick={
resolvedFooterConfig.actionType ===
WIZARD_FOOTER_ACTION_TYPE.BUTTON
@@ -413,6 +417,9 @@ export function ProviderWizardModal({
: undefined
}
>
+ {resolvedFooterConfig.actionLoading && (
+
+ )}
{resolvedFooterConfig.actionLabel}
)}
diff --git a/ui/components/providers/wizard/steps/connect-step.tsx b/ui/components/providers/wizard/steps/connect-step.tsx
index a26013aba3..649f0d1562 100644
--- a/ui/components/providers/wizard/steps/connect-step.tsx
+++ b/ui/components/providers/wizard/steps/connect-step.tsx
@@ -63,6 +63,7 @@ export function ConnectStep({
onBack: () => backHandlerRef.current?.(),
showAction: uiState.showAction,
actionLabel: uiState.actionLabel,
+ actionLoading: uiState.isLoading,
actionDisabled: uiState.actionDisabled || uiState.isLoading,
actionType: WIZARD_FOOTER_ACTION_TYPE.SUBMIT,
actionFormId: formId,
diff --git a/ui/components/providers/wizard/steps/credentials-step.test.tsx b/ui/components/providers/wizard/steps/credentials-step.test.tsx
index 7f4610b212..8fa035ea33 100644
--- a/ui/components/providers/wizard/steps/credentials-step.test.tsx
+++ b/ui/components/providers/wizard/steps/credentials-step.test.tsx
@@ -1,3 +1,6 @@
+vi.mock("./dynamic-credentials-step", () => ({
+ DynamicCredentialsStep: () =>
dynamic-credentials-form
,
+}));
import { render, screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
diff --git a/ui/components/providers/wizard/steps/credentials-step.tsx b/ui/components/providers/wizard/steps/credentials-step.tsx
index 371f356d30..75836a25e3 100644
--- a/ui/components/providers/wizard/steps/credentials-step.tsx
+++ b/ui/components/providers/wizard/steps/credentials-step.tsx
@@ -4,7 +4,7 @@ import { useEffect, useState } from "react";
import { getProviderFormType } from "@/lib/provider-helpers";
import { useProviderWizardStore } from "@/store/provider-wizard/store";
-import { ProviderType } from "@/types/providers";
+import { isKnownProviderType, ProviderType } from "@/types/providers";
import {
AddViaCredentialsForm,
@@ -23,6 +23,7 @@ import { SelectViaGitHub } from "../../workflow/forms/select-credentials-type/gi
import { SelectViaM365 } from "../../workflow/forms/select-credentials-type/m365";
import { UpdateViaServiceAccountForm } from "../../workflow/forms/update-via-service-account-key-form";
+import { DynamicCredentialsStep } from "./dynamic-credentials-step";
import {
WIZARD_FOOTER_ACTION_TYPE,
WizardFooterConfig,
@@ -34,7 +35,22 @@ interface CredentialsStepProps {
onFooterChange: (config: WizardFooterConfig) => void;
}
-export function CredentialsStep({
+export function CredentialsStep(props: CredentialsStepProps) {
+ const providerId = useProviderWizardStore((state) => state.providerId);
+ const providerType = useProviderWizardStore((state) => state.providerType);
+ if (providerId && providerType && !isKnownProviderType(providerType)) {
+ return (
+
+ );
+ }
+ return
;
+}
+
+function BuiltinCredentialsStep({
onNext,
onBack,
onFooterChange,
diff --git a/ui/components/providers/wizard/steps/dynamic-credentials-step.test.tsx b/ui/components/providers/wizard/steps/dynamic-credentials-step.test.tsx
new file mode 100644
index 0000000000..ce28a075b1
--- /dev/null
+++ b/ui/components/providers/wizard/steps/dynamic-credentials-step.test.tsx
@@ -0,0 +1,365 @@
+import {
+ act,
+ fireEvent,
+ render,
+ screen,
+ waitFor,
+} from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
+
+import openaiSchema from "@/lib/provider-credentials/fixtures/openai-credential-schema.json";
+import templateSchema from "@/lib/provider-credentials/fixtures/template-credential-schema.json";
+import { useProviderWizardStore } from "@/store/provider-wizard/store";
+import type { ProviderSchemasResult } from "@/types/provider-schema";
+
+const { getProviderSchemas, saveDynamicProviderCredentials, toast } =
+ vi.hoisted(() => ({
+ getProviderSchemas: vi.fn(),
+ saveDynamicProviderCredentials: vi.fn(),
+ toast: vi.fn(),
+ }));
+vi.mock("@/actions/providers/provider-schemas", () => ({ getProviderSchemas }));
+vi.mock("@/actions/providers/dynamic-provider-credentials", () => ({
+ saveDynamicProviderCredentials,
+}));
+vi.mock("@/components/shadcn/toast", () => ({ useToast: () => ({ toast }) }));
+
+import { DynamicCredentialsStep } from "./dynamic-credentials-step";
+
+beforeAll(() => {
+ for (const method of [
+ "hasPointerCapture",
+ "setPointerCapture",
+ "releasePointerCapture",
+ "scrollIntoView",
+ ]) {
+ Object.defineProperty(HTMLElement.prototype, method, {
+ configurable: true,
+ value: vi.fn(() => false),
+ });
+ }
+});
+
+const props = {
+ providerId: "account",
+ providerType: "acme",
+ onNext: vi.fn(),
+ onBack: vi.fn(),
+ onFooterChange: vi.fn(),
+};
+const schema = {
+ type: "object",
+ description: openaiSchema.description,
+ properties: {
+ token: {
+ type: "string",
+ title: "API token",
+ format: "password",
+ writeOnly: true,
+ },
+ },
+ required: ["token"],
+};
+
+describe("dynamic credentials in the provider wizard", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ sessionStorage.clear();
+ localStorage.clear();
+ useProviderWizardStore.getState().reset();
+ getProviderSchemas.mockResolvedValue({
+ status: "success",
+ providerType: "acme",
+ secretTypes: { api_key: schema },
+ });
+ saveDynamicProviderCredentials.mockResolvedValue({
+ status: "saved",
+ secretId: "secret",
+ });
+ });
+ it("saves through the dynamic action and never persists entered secrets", async () => {
+ render(
);
+ const field = await screen.findByLabelText(/API token/);
+ fireEvent.change(field, { target: { value: "only-in-memory" } });
+ expect(JSON.stringify(sessionStorage)).not.toContain("only-in-memory");
+ expect(JSON.stringify(localStorage)).not.toContain("only-in-memory");
+ fireEvent.submit(field.closest("form")!);
+ await waitFor(() => expect(props.onNext).toHaveBeenCalledOnce());
+ expect(saveDynamicProviderCredentials).toHaveBeenCalledWith({
+ providerId: "account",
+ secretType: "api_key",
+ secret: { token: "only-in-memory" },
+ });
+ expect(useProviderWizardStore.getState().secretId).toBe("secret");
+ expect(field).toHaveValue("");
+ });
+ it("masks the OpenAI API key and submits the original credential values", async () => {
+ // Given
+ const user = userEvent.setup();
+ getProviderSchemas.mockResolvedValue({
+ status: "success",
+ providerType: "openai",
+ secretTypes: { api_key: openaiSchema },
+ });
+ render(
);
+ const apiKey = await screen.findByLabelText(/Platform Api Key/);
+ const organization = screen.getByLabelText(/Organization Id/);
+ const baseUrl = screen.getByLabelText(/Base Url/);
+
+ // When
+ await user.type(organization, "org-fixture");
+ await user.type(apiKey, "fixture-key-not-a-secret");
+
+ // Then
+ expect(apiKey).toHaveAttribute("type", "password");
+ expect(apiKey).toHaveAttribute("autocomplete", "new-password");
+ expect(organization).toHaveAttribute("type", "text");
+ expect(baseUrl).toHaveAttribute("type", "text");
+ expect(
+ screen.queryByRole("button", { name: /show|reveal/i }),
+ ).not.toBeInTheDocument();
+
+ // When / Then: this form submits from the wizard's external footer.
+ act(() => apiKey.closest("form")!.requestSubmit());
+ await waitFor(() => expect(props.onNext).toHaveBeenCalledOnce());
+ expect(saveDynamicProviderCredentials).toHaveBeenCalledWith({
+ providerId: "account",
+ secretType: "api_key",
+ secret: {
+ organization_id: "org-fixture",
+ platform_api_key: "fixture-key-not-a-secret",
+ base_url: "https://api.openai.com/v1",
+ },
+ });
+ });
+ it("renders and submits the installed Template credential form with typed values", async () => {
+ // Given
+ const user = userEvent.setup();
+ getProviderSchemas.mockResolvedValue({
+ status: "success",
+ providerType: "template",
+ secretTypes: { static: templateSchema },
+ });
+ render(
);
+ const apiUrl = await screen.findByLabelText(/API URL/);
+ const apiKey = screen.getByLabelText(/API Key/);
+ const verifyTls = screen.getByRole("checkbox", { name: "Verify TLS" });
+ const timeout = screen.getByRole("spinbutton", { name: "Timeout" });
+
+ // Then
+ expect(apiUrl).toHaveAttribute("placeholder", "https://api.acme.com");
+ expect(apiKey).toHaveAttribute("type", "password");
+ expect(screen.getByLabelText("CA Bundle").tagName).toBe("TEXTAREA");
+ expect(verifyTls).toBeChecked();
+ expect(timeout).toHaveValue(30);
+ expect(timeout).toHaveAttribute("min", "1");
+ expect(timeout).toHaveAttribute("max", "300");
+ expect(timeout).toHaveAttribute("step", "1");
+ expect(
+ screen.getByRole("combobox", { name: "Authentication Scheme" }),
+ ).toHaveTextContent("bearer");
+ expect(apiUrl).toHaveValue("");
+
+ // When: false must remain a boolean and numeric input must become a number.
+ await user.type(apiUrl, "https://api.example.test");
+ await user.type(apiKey, "fixture-key-not-a-secret");
+ await user.click(verifyTls);
+ await user.clear(timeout);
+ await user.type(timeout, "60");
+ act(() => apiKey.closest("form")!.requestSubmit());
+
+ // Then
+ await waitFor(() => expect(props.onNext).toHaveBeenCalledOnce());
+ expect(saveDynamicProviderCredentials).toHaveBeenCalledWith({
+ providerId: "account",
+ secretType: "static",
+ secret: {
+ api_url: "https://api.example.test",
+ api_key: "fixture-key-not-a-secret",
+ verify_tls: false,
+ timeout_seconds: 60,
+ auth_scheme: "bearer",
+ },
+ });
+ });
+ it.each<{ result: ProviderSchemasResult; title: string }>([
+ {
+ result: { status: "success", providerType: "acme", secretTypes: {} },
+ title: "Credential form unavailable",
+ },
+ {
+ result: {
+ status: "success",
+ providerType: "acme",
+ secretTypes: {
+ api_key: {
+ type: "object",
+ properties: { nested: { type: "object" } },
+ },
+ },
+ },
+ title: "Credential form not supported",
+ },
+ {
+ result: { status: "access_denied" },
+ title: "Access required",
+ },
+ {
+ result: { status: "unavailable" },
+ title: "Provider installation unavailable",
+ },
+ ])(
+ "explains $title without allowing credential submission",
+ async ({ result, title }) => {
+ getProviderSchemas.mockResolvedValue(result);
+ render(
);
+ expect(
+ await screen.findByRole("button", { name: "Try again" }),
+ ).toBeVisible();
+ expect(screen.getByRole("alert")).toHaveTextContent(title);
+ expect(screen.queryByLabelText(/API token/)).not.toBeInTheDocument();
+ expect(saveDynamicProviderCredentials).not.toHaveBeenCalled();
+ },
+ );
+ it("explains a loading failure and recovers when retried", async () => {
+ // Given
+ getProviderSchemas.mockRejectedValueOnce(new Error("Network unavailable"));
+ const user = userEvent.setup();
+ render(
);
+ expect(await screen.findByRole("alert")).toHaveTextContent(
+ "Could not load credential form",
+ );
+ expect(screen.getByRole("alert")).toHaveTextContent(
+ "Check your connection and try again.",
+ );
+ expect(screen.getByRole("link", { name: "Open Registry" })).toHaveAttribute(
+ "href",
+ "/registry",
+ );
+
+ // When
+ await user.click(screen.getByRole("button", { name: "Try again" }));
+
+ // Then
+ expect(await screen.findByLabelText(/API token/)).toBeVisible();
+ expect(screen.queryByRole("alert")).not.toBeInTheDocument();
+ });
+ it("clears credentials when changing providers", async () => {
+ const view = render(
);
+ fireEvent.change(await screen.findByLabelText(/API token/), {
+ target: { value: "previous-secret" },
+ });
+ view.rerender(
+
,
+ );
+ await waitFor(() =>
+ expect(screen.getByLabelText(/API token/)).toHaveValue(""),
+ );
+ });
+ it("clears credentials when switching authentication methods", async () => {
+ getProviderSchemas.mockResolvedValue({
+ status: "success",
+ providerType: "acme",
+ secretTypes: { api_key: schema, personal_token: schema },
+ });
+ render(
);
+ fireEvent.change(await screen.findByLabelText(/API token/), {
+ target: { value: "previous-method-secret" },
+ });
+ const user = userEvent.setup();
+ await user.click(
+ screen.getByRole("combobox", { name: "Authentication method" }),
+ );
+ await user.click(screen.getByRole("option", { name: "personal token" }));
+ expect(screen.getByLabelText(/API token/)).toHaveValue("");
+ expect(JSON.stringify(sessionStorage)).not.toContain(
+ "previous-method-secret",
+ );
+ expect(JSON.stringify(localStorage)).not.toContain(
+ "previous-method-secret",
+ );
+ });
+ it("rejects double submission and retries a failed save for the same account", async () => {
+ let rejectSave!: (error: Error) => void;
+ saveDynamicProviderCredentials.mockImplementationOnce(
+ () =>
+ new Promise((_resolve, reject) => {
+ rejectSave = reject;
+ }),
+ );
+ render(
);
+ const field = await screen.findByLabelText(/API token/);
+ fireEvent.change(field, { target: { value: "retry-secret" } });
+ fireEvent.submit(field.closest("form")!);
+ fireEvent.submit(field.closest("form")!);
+ expect(saveDynamicProviderCredentials).toHaveBeenCalledOnce();
+ rejectSave(new Error("Network unavailable"));
+ await screen.findByText(
+ "Could not save the credentials. Check your connection and retry.",
+ );
+ expect(props.onNext).not.toHaveBeenCalled();
+ fireEvent.submit(field.closest("form")!);
+ await waitFor(() => expect(props.onNext).toHaveBeenCalledOnce());
+ expect(saveDynamicProviderCredentials).toHaveBeenCalledTimes(2);
+ expect(saveDynamicProviderCredentials).toHaveBeenLastCalledWith({
+ providerId: "account",
+ secretType: "api_key",
+ secret: { token: "retry-secret" },
+ });
+ });
+ it("keeps other field and form errors visible while editing one credential", async () => {
+ // Given
+ const user = userEvent.setup();
+ getProviderSchemas.mockResolvedValue({
+ status: "success",
+ providerType: "acme",
+ secretTypes: {
+ api_key: {
+ ...schema,
+ properties: {
+ ...schema.properties,
+ project: { type: "string", title: "Project" },
+ },
+ required: ["token", "project"],
+ },
+ },
+ });
+ saveDynamicProviderCredentials.mockResolvedValueOnce({
+ status: "invalid",
+ errors: {
+ token: "Token was rejected",
+ project: "Project is unavailable",
+ _form: "Review the credential fields",
+ },
+ });
+ render(
);
+ const token = await screen.findByLabelText(/API token/);
+ await user.type(token, "fixture-token");
+ await user.type(screen.getByLabelText(/Project/), "fixture-project");
+ act(() => token.closest("form")!.requestSubmit());
+ expect(await screen.findByText("Token was rejected")).toBeVisible();
+
+ // When
+ await user.type(token, "-edited");
+
+ // Then
+ expect(screen.queryByText("Token was rejected")).not.toBeInTheDocument();
+ expect(screen.getByText("Project is unavailable")).toBeVisible();
+ expect(screen.getByText("Review the credential fields")).toBeVisible();
+
+ // When / Then: submitting again replaces the earlier validation errors.
+ act(() => token.closest("form")!.requestSubmit());
+ await waitFor(() => expect(props.onNext).toHaveBeenCalledOnce());
+ expect(
+ screen.queryByText("Project is unavailable"),
+ ).not.toBeInTheDocument();
+ expect(
+ screen.queryByText("Review the credential fields"),
+ ).not.toBeInTheDocument();
+ });
+});
diff --git a/ui/components/providers/wizard/steps/dynamic-credentials-step.tsx b/ui/components/providers/wizard/steps/dynamic-credentials-step.tsx
new file mode 100644
index 0000000000..35fc5f0daa
--- /dev/null
+++ b/ui/components/providers/wizard/steps/dynamic-credentials-step.tsx
@@ -0,0 +1,336 @@
+"use client";
+
+import { RotateCcw } from "lucide-react";
+import Link from "next/link";
+import { useEffect, useRef, useState } from "react";
+
+import { saveDynamicProviderCredentials } from "@/actions/providers/dynamic-provider-credentials";
+import { getProviderSchemas } from "@/actions/providers/provider-schemas";
+import { RegistryCredentialFields } from "@/components/providers/workflow/provider-credential-fields";
+import { Button } from "@/components/shadcn/button/button";
+import { Field, FieldLabel } from "@/components/shadcn/field/field";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/shadcn/select/select";
+import { Skeleton } from "@/components/shadcn/skeleton/skeleton";
+import { useToast } from "@/components/shadcn/toast";
+import { StatusAlert } from "@/components/shared/status-alert";
+import {
+ parseRegistryCredentialSchema,
+ type RegistryCredentialSchema,
+} from "@/lib/provider-credentials/provider-credential-schema";
+import {
+ getCredentialDefaults,
+ validateCredentialValues,
+} from "@/lib/provider-credentials/provider-credential-values";
+import { useProviderWizardStore } from "@/store/provider-wizard/store";
+import type { ProviderSchemasResult } from "@/types/provider-schema";
+
+import {
+ WIZARD_FOOTER_ACTION_TYPE,
+ type WizardFooterConfig,
+} from "./footer-controls";
+
+interface DynamicCredentialsStepProps {
+ providerId: string;
+ providerType: string;
+ onNext: () => void;
+ onBack: () => void;
+ onFooterChange: (config: WizardFooterConfig) => void;
+}
+
+function credentialFormError(status: ProviderSchemasResult["status"]) {
+ switch (status) {
+ case "access_denied":
+ return {
+ title: "Access required",
+ description:
+ "Your session may have expired or you may not have permission. Sign in again or contact your administrator.",
+ };
+ case "unavailable":
+ return {
+ title: "Provider installation unavailable",
+ description:
+ "Install this provider's artifact again in Registry, then try again.",
+ };
+ case "not_found":
+ return {
+ title: "Credential form unavailable",
+ description:
+ "This provider does not provide a credential form. Contact its publisher or your administrator.",
+ };
+ case "success":
+ case "malformed":
+ return {
+ title: "Credential form not supported",
+ description:
+ "We could not display this provider's credential form. Contact its publisher or your administrator.",
+ };
+ default:
+ return {
+ title: "Could not load credential form",
+ description: "Check your connection and try again.",
+ };
+ }
+}
+
+function DynamicCredentialForm({
+ providerId,
+ secretType,
+ schema,
+ onNext,
+ onBack,
+ onFooterChange,
+ onLoadingChange,
+}: Omit
& {
+ secretType: string;
+ schema: RegistryCredentialSchema;
+ onLoadingChange: (value: boolean) => void;
+}) {
+ const { toast } = useToast();
+ const setSecretId = useProviderWizardStore((state) => state.setSecretId);
+ // Credentials belong only to this form. A new account or authentication
+ // method mounts a fresh instance; no values enter the persisted wizard store.
+ const [values, setValues] = useState(() => getCredentialDefaults(schema));
+ const [errors, setErrors] = useState>({});
+ const [saving, setSaving] = useState(false);
+ const inFlight = useRef(false);
+ const mounted = useRef(true);
+ const formId = "provider-wizard-dynamic-credentials-form";
+ const valid = validateCredentialValues(schema, values).valid;
+ useEffect(() => {
+ mounted.current = true;
+ return () => {
+ mounted.current = false;
+ };
+ }, []);
+
+ useEffect(() => {
+ onFooterChange({
+ showBack: true,
+ backLabel: "Back",
+ backDisabled: saving,
+ onBack,
+ showAction: true,
+ actionLabel: "Authenticate",
+ actionDisabled: saving || !valid,
+ actionType: WIZARD_FOOTER_ACTION_TYPE.SUBMIT,
+ actionFormId: formId,
+ });
+ }, [onBack, onFooterChange, saving, valid]);
+
+ return (
+
+ );
+}
+
+function DynamicCredentialsContent(props: DynamicCredentialsStepProps) {
+ const { providerType, onBack, onFooterChange } = props;
+ const [schemas, setSchemas] = useState(null);
+ const [selectedMethod, setSelectedMethod] = useState("");
+ const [attempt, setAttempt] = useState(0);
+ const [saving, setSaving] = useState(false);
+ useEffect(() => {
+ let active = true;
+ setSchemas(null);
+ setSelectedMethod("");
+ getProviderSchemas(providerType)
+ .then((result) => {
+ if (active) setSchemas(result);
+ })
+ .catch(() => {
+ if (active) setSchemas({ status: "error" });
+ });
+ return () => {
+ active = false;
+ };
+ }, [providerType, attempt]);
+
+ const methods =
+ schemas?.status === "success" ? Object.keys(schemas.secretTypes) : [];
+ const secretType = selectedMethod || methods[0];
+ const schema =
+ schemas?.status === "success" && secretType
+ ? parseRegistryCredentialSchema(schemas.secretTypes[secretType])
+ : null;
+ useEffect(() => {
+ if (!schema)
+ onFooterChange({
+ showBack: true,
+ backLabel: "Back",
+ onBack,
+ showAction: false,
+ actionLabel: "Authenticate",
+ actionType: WIZARD_FOOTER_ACTION_TYPE.BUTTON,
+ });
+ }, [schema, onBack, onFooterChange]);
+
+ if (!schemas)
+ return (
+
+
+
+
+ );
+
+ const error = credentialFormError(
+ schemas.status === "success" && methods.length === 0
+ ? "not_found"
+ : schemas.status,
+ );
+
+ return (
+
+ {methods.length > 1 && (
+
+
+ Authentication method
+
+
+
+ )}
+ {schema ? (
+
+ ) : (
+
+
+ {error.description}
+
+
+ setAttempt((value) => value + 1)}
+ >
+
+ Try again
+
+
+ Open Registry
+
+
+
+ )}
+
+ );
+}
+
+export function DynamicCredentialsStep(props: DynamicCredentialsStepProps) {
+ return (
+
+ );
+}
diff --git a/ui/components/providers/wizard/steps/footer-controls.ts b/ui/components/providers/wizard/steps/footer-controls.ts
index ff41ae8061..7bdd0d5d58 100644
--- a/ui/components/providers/wizard/steps/footer-controls.ts
+++ b/ui/components/providers/wizard/steps/footer-controls.ts
@@ -22,6 +22,7 @@ export interface WizardFooterConfig {
onSecondaryAction?: () => void;
showAction: boolean;
actionLabel: string;
+ actionLoading?: boolean;
actionDisabled?: boolean;
actionType: WizardFooterActionType;
actionFormId?: string;
diff --git a/ui/components/providers/workflow/forms/connect-account-form.test.tsx b/ui/components/providers/workflow/forms/connect-account-form.test.tsx
new file mode 100644
index 0000000000..2a906d431c
--- /dev/null
+++ b/ui/components/providers/workflow/forms/connect-account-form.test.tsx
@@ -0,0 +1,240 @@
+import { render, screen, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+const { addProvider, updateProvider, getInstalledRegistryProviderOptions } =
+ vi.hoisted(() => ({
+ addProvider: vi.fn(),
+ updateProvider: vi.fn(),
+ getInstalledRegistryProviderOptions: vi.fn(),
+ }));
+
+vi.mock("next/navigation", () => ({
+ useRouter: () => ({ push: vi.fn() }),
+}));
+vi.mock("@/actions/providers/providers", () => ({
+ addProvider,
+ updateProvider,
+}));
+vi.mock("@/actions/providers/registry-provider", () => ({
+ addRegistryProvider: vi.fn(),
+}));
+vi.mock("@/actions/registry/registry", () => ({
+ getInstalledRegistryProviderOptions,
+}));
+
+import { ConnectAccountForm } from "./connect-account-form";
+
+describe("provider account aliases", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ getInstalledRegistryProviderOptions.mockResolvedValue({
+ status: "access_denied",
+ });
+ });
+
+ it("saves an alias changed after an account was already created", async () => {
+ // Given
+ const onSuccess = vi.fn();
+ const user = userEvent.setup();
+ const account = {
+ id: "existing",
+ attributes: { provider: "github", uid: "octocat", alias: "Original" },
+ };
+ addProvider.mockResolvedValue({ data: account });
+ updateProvider.mockResolvedValue({
+ data: {
+ ...account,
+ attributes: { ...account.attributes, alias: "Edited" },
+ },
+ });
+ render();
+ await user.click(screen.getByRole("option", { name: "GitHub" }));
+ await user.type(
+ screen.getByRole("textbox", { name: "Username/Organization" }),
+ "octocat",
+ );
+ const alias = screen.getByRole("textbox", {
+ name: "Provider alias (optional)",
+ });
+ await user.type(alias, "Original");
+ await user.click(screen.getByRole("button", { name: "Next" }));
+ await waitFor(() => expect(onSuccess).toHaveBeenCalledOnce());
+
+ // When
+ await user.clear(alias);
+ await user.type(alias, "Edited");
+ await user.click(screen.getByRole("button", { name: "Next" }));
+
+ // Then
+ await waitFor(() =>
+ expect(onSuccess).toHaveBeenLastCalledWith({
+ id: "existing",
+ providerType: "github",
+ uid: "octocat",
+ alias: "Edited",
+ }),
+ );
+ expect(addProvider).toHaveBeenCalledOnce();
+ expect(Object.fromEntries(updateProvider.mock.calls[0][0])).toMatchObject({
+ providerId: "existing",
+ providerAlias: "Edited",
+ });
+ });
+});
+
+describe("Registry provider source tabs", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("hides the Registry tab when discovery denies access (Local or flag off)", async () => {
+ // Given
+ getInstalledRegistryProviderOptions.mockResolvedValue({
+ status: "access_denied",
+ });
+
+ // When
+ render();
+ await waitFor(() =>
+ expect(getInstalledRegistryProviderOptions).toHaveBeenCalled(),
+ );
+
+ // Then
+ expect(
+ screen.getByRole("option", { name: /Amazon Web Services/ }),
+ ).toBeVisible();
+ expect(
+ screen.queryByRole("tab", { name: "Registry" }),
+ ).not.toBeInTheDocument();
+ expect(
+ screen.queryByRole("tab", { name: "All providers" }),
+ ).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();
+
+ // 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 () => {
+ // Given
+ const user = userEvent.setup();
+ getInstalledRegistryProviderOptions
+ .mockResolvedValueOnce({ status: "unknown" })
+ .mockResolvedValueOnce({ status: "ready", options: [] });
+
+ // When
+ render();
+
+ // 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(
+ screen.getByRole("button", { name: "Retry Registry providers" }),
+ );
+
+ // Then
+ expect(await screen.findByRole("tab", { name: "Registry" })).toBeVisible();
+ expect(
+ screen.queryByText("Registry providers could not be loaded"),
+ ).not.toBeInTheDocument();
+ expect(getInstalledRegistryProviderOptions).toHaveBeenCalledTimes(2);
+ });
+
+ it("shows a retry in flight, ignores repeat clicks and keeps focus on the button", async () => {
+ // Given
+ const user = userEvent.setup();
+ let settleRetry: (result: { status: "error" }) => void = () => {};
+ getInstalledRegistryProviderOptions
+ .mockResolvedValueOnce({ status: "error" })
+ .mockReturnValueOnce(
+ new Promise((resolve) => {
+ settleRetry = resolve;
+ }),
+ );
+ render();
+ const retry = await screen.findByRole("button", {
+ name: "Retry Registry providers",
+ });
+
+ // When
+ await user.click(retry);
+ await user.click(retry);
+
+ // Then: the warning stays mounted, so the pressed button is never lost.
+ expect(retry).toHaveTextContent("Retrying…");
+ expect(retry).toHaveAttribute("aria-disabled", "true");
+ expect(retry).toHaveFocus();
+ expect(getInstalledRegistryProviderOptions).toHaveBeenCalledTimes(2);
+
+ // When: the retry fails again
+ settleRetry({ status: "error" });
+
+ // Then
+ await waitFor(() =>
+ expect(retry).toHaveTextContent("Retry Registry providers"),
+ );
+ expect(retry).not.toHaveAttribute("aria-disabled", "true");
+ });
+
+ it("keeps a retry in flight when an artifact change reloads discovery meanwhile", async () => {
+ // Given
+ const user = userEvent.setup();
+ let settleRetry: (result: { status: "error" }) => void = () => {};
+ getInstalledRegistryProviderOptions
+ .mockResolvedValueOnce({ status: "error" })
+ .mockReturnValueOnce(
+ new Promise((resolve) => {
+ settleRetry = resolve;
+ }),
+ )
+ .mockResolvedValueOnce({ status: "error" });
+ render();
+ const retry = await screen.findByRole("button", {
+ name: "Retry Registry providers",
+ });
+ await user.click(retry);
+
+ // When: an unrelated reload settles before the retry does
+ window.dispatchEvent(new CustomEvent("registry-artifacts-changed"));
+ await waitFor(() =>
+ expect(getInstalledRegistryProviderOptions).toHaveBeenCalledTimes(3),
+ );
+ await user.click(retry);
+
+ // Then: only the retry itself may end the retry
+ expect(retry).toHaveTextContent("Retrying…");
+ expect(getInstalledRegistryProviderOptions).toHaveBeenCalledTimes(3);
+
+ // When
+ settleRetry({ status: "error" });
+
+ // Then
+ await waitFor(() =>
+ expect(retry).toHaveTextContent("Retry Registry providers"),
+ );
+ });
+});
diff --git a/ui/components/providers/workflow/forms/connect-account-form.tsx b/ui/components/providers/workflow/forms/connect-account-form.tsx
index a46de4871d..ddc880e3e7 100644
--- a/ui/components/providers/workflow/forms/connect-account-form.tsx
+++ b/ui/components/providers/workflow/forms/connect-account-form.tsx
@@ -3,20 +3,28 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { ChevronLeftIcon, ChevronRightIcon, Loader2 } from "lucide-react";
import { useRouter } from "next/navigation";
-import { Dispatch, SetStateAction, useEffect, useState } from "react";
+import { Dispatch, SetStateAction, useEffect, useRef, useState } from "react";
import { useForm, UseFormReturn } from "react-hook-form";
-import { z } from "zod";
-import { addProvider } from "@/actions/providers/providers";
+import { addProvider, updateProvider } from "@/actions/providers/providers";
+import { addRegistryProvider } from "@/actions/providers/registry-provider";
+import { getInstalledRegistryProviderOptions } from "@/actions/registry/registry";
import { AwsMethodSelector } from "@/components/providers/organizations/aws-method-selector";
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 { 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 {
- addProviderFormSchema,
+ REGISTRY_PROVIDER_DISCOVERY,
+ type RegistryProviderOption,
+} from "@/lib/registry/provider-options";
+import {
+ createAddProviderFormSchema,
+ AddProviderFormValues,
ApiError,
KnownProviderType,
ProviderType,
@@ -26,10 +34,11 @@ import {
OrgFlowType,
toOrgFlowType,
} from "@/types/organizations";
+import { isKnownProviderType } from "@/types/providers";
import { RadioGroupProvider } from "../../radio-group-provider";
-export type FormValues = z.infer;
+export type FormValues = AddProviderFormValues;
export interface ConnectAccountSuccessData {
id: string;
@@ -209,7 +218,61 @@ export const ConnectAccountForm = ({
const [method, setMethod] = useState<"single" | null>(null);
const router = useRouter();
- const formSchema = addProviderFormSchema;
+ 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 [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(() => {
+ 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),
+ );
const form = useForm({
resolver: zodResolver(formSchema),
@@ -229,6 +292,22 @@ export const ConnectAccountForm = ({
const isLoading = form.formState.isSubmitting;
const onSubmitClient = async (values: FormValues) => {
+ if (submitting.current) return;
+ const existingAccount =
+ createdAccount.current?.providerType === values.providerType &&
+ createdAccount.current.uid === values.providerUid
+ ? createdAccount.current
+ : null;
+ if (
+ existingAccount &&
+ (existingAccount.alias ?? "") === (values.providerAlias?.trim() ?? "") &&
+ onSuccess
+ ) {
+ onSuccess(existingAccount);
+ return;
+ }
+ submitting.current = true;
+ setProviderError(null);
const formValues = { ...values };
const formData = new FormData();
@@ -237,7 +316,20 @@ export const ConnectAccountForm = ({
);
try {
- const data = await addProvider(formData);
+ let data;
+ if (existingAccount) {
+ const update = new FormData();
+ update.set(ProviderCredentialFields.PROVIDER_ID, existingAccount.id);
+ update.set(
+ ProviderCredentialFields.PROVIDER_ALIAS,
+ values.providerAlias?.trim() ?? "",
+ );
+ data = await updateProvider(update);
+ } else {
+ data = await (isKnownProviderType(values.providerType)
+ ? addProvider(formData)
+ : addRegistryProvider(formData));
+ }
if (data?.errors && data.errors.length > 0) {
data.errors.forEach((error: ApiError) => {
@@ -246,10 +338,9 @@ export const ConnectAccountForm = ({
switch (pointer) {
case "/data/attributes/provider":
- form.setError("providerType", {
- type: "server",
- message: errorMessage,
- });
+ // Provider selection is hidden here; keep failures visible and
+ // retryable when availability changes without editing the form.
+ setProviderError(errorMessage);
break;
case "/data/attributes/uid":
case "/data/attributes/__all__":
@@ -280,12 +371,13 @@ export const ConnectAccountForm = ({
} = data.data;
if (onSuccess) {
- onSuccess({
+ createdAccount.current = {
id,
providerType: createdProviderType,
uid: uid || values.providerUid,
alias: alias ?? values.providerAlias ?? null,
- });
+ };
+ onSuccess(createdAccount.current);
return;
}
@@ -301,10 +393,13 @@ export const ConnectAccountForm = ({
? error.message
: "Something went wrong. Please try again.",
});
+ } finally {
+ submitting.current = false;
}
};
const handleBackStep = () => {
+ setProviderError(null);
applyBackStep({
prevStep,
method,
@@ -327,6 +422,7 @@ export const ConnectAccountForm = ({
useEffect(() => {
onBackHandlerChange?.(() => {
+ setProviderError(null);
applyBackStep({
prevStep,
method,
@@ -352,7 +448,7 @@ export const ConnectAccountForm = ({
onUiStateChange?.({
showBack: prevStep === 2,
showAction: prevStep === 2 && showUidForm,
- actionLabel: "Next",
+ actionLabel: isLoading ? "Creating provider..." : "Next",
actionDisabled: !canSubmit || isLoading,
isLoading,
});
@@ -375,7 +471,33 @@ export const ConnectAccountForm = ({
{/* Step 1: Provider selection */}
{prevStep === 1 && (
+ {registryError && (
+
+ Registry providers could not be loaded
+
+ Built-in providers are available. Check the Registry
+ connection and try again.
+ {/* aria-disabled, not disabled: the pressed button keeps focus. */}
+ {
+ if (isRetryingDiscovery) return;
+ setIsRetryingDiscovery(true);
+ setDiscoveryAttempt((attempt) => attempt + 1);
+ }}
+ >
+ {isRetryingDiscovery
+ ? "Retrying…"
+ : "Retry Registry providers"}
+
+
+
+ )}
+ {providerError && (
+
+ Unable to create provider
+ {providerError}
+
+ )}
{isLoading ? (
-
+
) : (
)}
- {isLoading ? "Loading" : "Next"}
+ {isLoading ? "Creating provider..." : "Next"}
)}
diff --git a/ui/components/providers/workflow/forms/select-credentials-type/cloudflare/credentials-type/cloudflare-api-token-credentials-form.test.tsx b/ui/components/providers/workflow/forms/select-credentials-type/cloudflare/credentials-type/cloudflare-api-token-credentials-form.test.tsx
index d8278030f4..d51cf47ff1 100644
--- a/ui/components/providers/workflow/forms/select-credentials-type/cloudflare/credentials-type/cloudflare-api-token-credentials-form.test.tsx
+++ b/ui/components/providers/workflow/forms/select-credentials-type/cloudflare/credentials-type/cloudflare-api-token-credentials-form.test.tsx
@@ -21,7 +21,7 @@ const Harness = ({ providerUid }: { providerUid?: string }) => {
};
const USER_URL =
- "https://dash.cloudflare.com/profile/api-tokens?permissionGroupKeys=%5B%7B%22key%22%3A%22account_settings%22%2C%22type%22%3A%22read%22%7D%2C%7B%22key%22%3A%22zone%22%2C%22type%22%3A%22read%22%7D%2C%7B%22key%22%3A%22zone_settings%22%2C%22type%22%3A%22read%22%7D%2C%7B%22key%22%3A%22dns%22%2C%22type%22%3A%22read%22%7D%5D&accountId=%2A&zoneId=all&name=Prowler%20Security%20Scanner";
+ "https://dash.cloudflare.com/profile/api-tokens?permissionGroupKeys=%5B%7B%22key%22%3A%22account_settings%22%2C%22type%22%3A%22read%22%7D%2C%7B%22key%22%3A%22zone%22%2C%22type%22%3A%22read%22%7D%2C%7B%22key%22%3A%22zone_settings%22%2C%22type%22%3A%22read%22%7D%2C%7B%22key%22%3A%22dns%22%2C%22type%22%3A%22read%22%7D%2C%7B%22key%22%3A%22ssl_and_certificates%22%2C%22type%22%3A%22read%22%7D%2C%7B%22key%22%3A%22bot_management%22%2C%22type%22%3A%22read%22%7D%2C%7B%22key%22%3A%22zone_waf%22%2C%22type%22%3A%22read%22%7D%5D&accountId=%2A&zoneId=all&name=Prowler%20Security%20Scanner";
describe("CloudflareApiTokenCredentialsForm", () => {
it("always renders the User API Token link with the correct href and safe target attributes", () => {
diff --git a/ui/components/providers/workflow/provider-credential-fields.test.tsx b/ui/components/providers/workflow/provider-credential-fields.test.tsx
new file mode 100644
index 0000000000..6925925f23
--- /dev/null
+++ b/ui/components/providers/workflow/provider-credential-fields.test.tsx
@@ -0,0 +1,136 @@
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { beforeAll, describe, expect, it, vi } from "vitest";
+
+import type { RegistryCredentialSchema } from "@/lib/provider-credentials/provider-credential-schema";
+
+import { RegistryCredentialFields } from "./provider-credential-fields";
+
+const schema: RegistryCredentialSchema = {
+ fields: [
+ {
+ name: "api_key",
+ label: "API Key",
+ description: "Issued from the console.",
+ kind: "password",
+ required: true,
+ },
+ {
+ name: "scheme",
+ label: "Scheme",
+ kind: "select",
+ options: ["bearer", "basic"],
+ required: false,
+ },
+ { name: "notes", label: "Notes", kind: "textarea", required: false },
+ ],
+};
+
+beforeAll(() => {
+ for (const name of [
+ "hasPointerCapture",
+ "releasePointerCapture",
+ "scrollIntoView",
+ ]) {
+ Object.defineProperty(HTMLElement.prototype, name, {
+ configurable: true,
+ value: () => false,
+ });
+ }
+});
+
+describe("RegistryCredentialFields", () => {
+ it("renders accessible controlled credential fields and emits changes", async () => {
+ // Given
+ const user = userEvent.setup();
+ const onChange = vi.fn();
+
+ render(
+ ,
+ );
+
+ // When
+ await user.type(screen.getByLabelText(/API Key/), "x");
+ await user.click(screen.getByRole("combobox", { name: "Scheme" }));
+ await user.keyboard("{ArrowDown}{Enter}");
+
+ // Then
+ const apiKey = screen.getByLabelText(/API Key/);
+ const description = screen.getByText("Issued from the console.");
+ const error = screen.getByRole("alert");
+ expect(apiKey).toHaveAttribute("type", "password");
+ expect(apiKey).toHaveAttribute("autocomplete", "new-password");
+
+ expect(apiKey).toHaveAttribute(
+ "aria-describedby",
+ `${description.id} ${error.id}`,
+ );
+ expect(apiKey.id).toMatch(/-0-control$/);
+ expect(apiKey).toHaveAttribute("aria-invalid", "true");
+ expect(apiKey).toBeRequired();
+ expect(description.id).toMatch(/-0-description$/);
+ expect(error).toHaveTextContent("A key is required.");
+ expect(error.id).toMatch(/-0-error$/);
+ expect(onChange).toHaveBeenCalledWith("api_key", "x");
+ expect(onChange).toHaveBeenCalledWith("scheme", "basic");
+ });
+
+ it("uses unique index-based IDs for hostile field names and instances", () => {
+ // Given
+
+ const hostileSchema: RegistryCredentialSchema = {
+ fields: [
+ {
+ name: "x-description",
+ label: "First",
+ kind: "text",
+ required: false,
+ },
+ {
+ name: "registry-credential-x",
+ label: "Second",
+ description: "Second description.",
+ kind: "text",
+ required: false,
+ },
+ ],
+ };
+
+ const { container } = render(
+ <>
+
+
+
+ >,
+ );
+
+ // When / Then
+ expect(screen.getByLabelText("First").id).toMatch(/-0-control$/);
+
+ expect(screen.getByText("Second description.").id).toMatch(
+ /-1-description$/,
+ );
+ const ids = Array.from(container.querySelectorAll("[id]"), ({ id }) => id);
+ expect(new Set(ids).size).toBe(ids.length);
+ });
+});
diff --git a/ui/components/providers/workflow/provider-credential-fields.tsx b/ui/components/providers/workflow/provider-credential-fields.tsx
new file mode 100644
index 0000000000..157f4a0d7e
--- /dev/null
+++ b/ui/components/providers/workflow/provider-credential-fields.tsx
@@ -0,0 +1,152 @@
+"use client";
+
+import { type ChangeEvent, useId } from "react";
+
+import { Checkbox } from "@/components/shadcn/checkbox/checkbox";
+import { Field, FieldError, FieldLabel } from "@/components/shadcn/field/field";
+import { Input } from "@/components/shadcn/input/input";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/shadcn/select/select";
+import { Textarea } from "@/components/shadcn/textarea/textarea";
+import type {
+ RegistryCredentialSchema,
+ RegistryCredentialValue,
+} from "@/lib/provider-credentials/provider-credential-schema";
+
+interface RegistryCredentialFieldsProps {
+ readonly errors: Readonly>;
+ readonly onChange: (name: string, value: RegistryCredentialValue) => void;
+ readonly schema: RegistryCredentialSchema;
+ readonly values: Readonly<
+ Record
+ >;
+}
+
+export function RegistryCredentialFields({
+ errors,
+ onChange,
+ schema,
+ values,
+}: RegistryCredentialFieldsProps) {
+ const instanceId = useId();
+
+ return (
+
+ {schema.fields.map((field, index) => {
+ const error = errors[field.name];
+ const fieldId = `registry-credential-${instanceId}-${index}`;
+ const id = `${fieldId}-control`;
+ const descriptionId = field.description
+ ? `${fieldId}-description`
+ : undefined;
+ const errorId = error ? `${fieldId}-error` : undefined;
+ const describedBy =
+ [descriptionId, errorId].filter(Boolean).join(" ") || undefined;
+ const invalid = error ? true : undefined;
+ const value = values[field.name];
+ const textControlProps = {
+ "aria-describedby": describedBy,
+ "aria-invalid": invalid,
+ id,
+
+ onChange: (
+ event: ChangeEvent
,
+ ) => onChange(field.name, event.target.value),
+ required: field.required,
+ placeholder: field.placeholder,
+ spellCheck: false,
+ value:
+ typeof value === "string" || typeof value === "number" ? value : "",
+ };
+
+ return (
+
+ {field.kind === "checkbox" ? (
+
+
+ onChange(field.name, checked === true)
+ }
+ />
+
+ {field.label}
+ {field.required && *}
+
+
+ ) : (
+
+ {field.label}
+ {field.required && *}
+
+ )}
+ {field.kind === "checkbox" ? null : field.kind === "select" ? (
+
+ ) : field.kind === "textarea" ? (
+
+ ) : (
+
+ )}
+ {field.description && (
+
+ {field.description}
+
+ )}
+ {error && (
+
+ {error}
+
+ )}
+
+ );
+ })}
+
+ );
+}
diff --git a/ui/components/registry/registry-access-dialog.tsx b/ui/components/registry/registry-access-dialog.tsx
new file mode 100644
index 0000000000..882f176f54
--- /dev/null
+++ b/ui/components/registry/registry-access-dialog.tsx
@@ -0,0 +1,162 @@
+"use client";
+
+import { type FormEvent, type RefObject, useEffect, useRef } from "react";
+
+import { Button } from "@/components/shadcn/button/button";
+import { DialogFooter } from "@/components/shadcn/dialog";
+import { Input } from "@/components/shadcn/input/input";
+import { Modal } from "@/components/shadcn/modal/modal";
+
+interface RegistryAccessDialogCommonProps {
+ errorMessage?: string;
+ registryKeyUrl?: string;
+ onOpenChange: (open: boolean) => void;
+ onSubmit: (key: string) => Promise;
+ open: boolean;
+ pending: boolean;
+ returnFocusRef: RefObject;
+}
+
+type ConnectRegistryAccessDialogProps = RegistryAccessDialogCommonProps & {
+ mode: "connect";
+ onDisconnect?: never;
+};
+
+type ManageRegistryAccessDialogProps = RegistryAccessDialogCommonProps & {
+ mode: "manage";
+ onDisconnect: () => Promise;
+};
+
+type RegistryAccessDialogProps =
+ | ConnectRegistryAccessDialogProps
+ | ManageRegistryAccessDialogProps;
+
+export function RegistryAccessDialog({
+ errorMessage,
+ registryKeyUrl,
+ mode,
+ onDisconnect,
+ onOpenChange,
+ onSubmit,
+ open,
+ pending,
+ returnFocusRef,
+}: RegistryAccessDialogProps) {
+ const formRef = useRef(null);
+ const keyInputRef = useRef(null);
+ const wasPendingRef = useRef(pending);
+ const actionLabel = mode === "connect" ? "Connect" : "Replace key";
+
+ // Re-enabling the form after a watched validation settles loses focus from
+ // the disabled input; hand it back so a retry can start from the keyboard.
+ useEffect(() => {
+ if (wasPendingRef.current && !pending) keyInputRef.current?.focus();
+ wasPendingRef.current = pending;
+ }, [pending]);
+
+ async function handleSubmit(event: FormEvent) {
+ event.preventDefault();
+ if (pending) return;
+ const key = new FormData(event.currentTarget).get("registry-key");
+ if (typeof key !== "string" || key.trim().length === 0) return;
+
+ formRef.current?.reset();
+ await onSubmit(key.trim());
+ }
+
+ return (
+ {
+ event.preventDefault();
+ keyInputRef.current?.focus();
+ }}
+ onCloseAutoFocus={(event) => {
+ event.preventDefault();
+ returnFocusRef.current?.focus();
+ }}
+ onOpenChange={onOpenChange}
+ open={open}
+ size="md"
+ title={
+ mode === "connect"
+ ? "Connect Registry API key"
+ : "Manage Registry access"
+ }
+ >
+
+
+ );
+}
diff --git a/ui/components/registry/registry-artifact-card.integration.test.tsx b/ui/components/registry/registry-artifact-card.integration.test.tsx
new file mode 100644
index 0000000000..f7c0c48eba
--- /dev/null
+++ b/ui/components/registry/registry-artifact-card.integration.test.tsx
@@ -0,0 +1,340 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { page, userEvent } from "vitest/browser";
+
+import { render } from "@/__tests__/render-browser";
+
+import {
+ RegistryArtifactCard,
+ RegistryTenantArtifactCard,
+} from "./registry-artifact-card";
+import { RegistryArtifactGrid } from "./registry-artifact-grid";
+import type { RegistryMarketplaceArtifact } from "./registry-explorer.model";
+
+const artifact: RegistryMarketplaceArtifact = {
+ normalizedName: "prowler-provider-aws",
+ name: "AWS security",
+ description: "Security checks and compliance frameworks for AWS resources.",
+ latestVersion: "5.15.0",
+ providers: ["aws"],
+ isVerified: true,
+ isOfficial: true,
+ isBuiltin: true,
+ isMeta: false,
+ hasProvider: true,
+ hasChecks: true,
+ hasCompliance: true,
+ checkCount: 645,
+ complianceCount: 45,
+ versionCount: 1,
+ totalDownloads: 0,
+ owners: [{ name: "Prowler", type: "organization" }],
+ isInstallable: false,
+ notInstallableReason: "artifact_ships_with_prowler",
+ isAdded: false,
+ updateAvailable: false,
+ extendsProviderSlugs: [],
+};
+const checksArtifact: RegistryMarketplaceArtifact = {
+ ...artifact,
+ normalizedName: "acme-aws-checks",
+ name: "Acme AWS checks",
+ isBuiltin: false,
+ hasProvider: false,
+ hasCompliance: false,
+ isInstallable: true,
+ notInstallableReason: undefined,
+};
+
+describe("Registry card install verdict", () => {
+ it("offers Add for checks the API calls installable, though they define no provider", async () => {
+ // Given
+ const onAdd = vi.fn();
+ const screen = await render(
+ ,
+ );
+
+ // When
+ await screen.getByRole("button", { name: "Add Acme AWS checks" }).click();
+
+ // Then
+ expect(onAdd).toHaveBeenCalledOnce();
+ });
+
+ it("says why an artifact cannot be installed instead of leaving a dead control", async () => {
+ // Given / When
+ const screen = await render(
+ ,
+ );
+
+ // Then
+ await expect
+ .element(
+ screen.getByText(
+ "Its checks are written for a provider this deployment does not ship.",
+ ),
+ )
+ .toBeVisible();
+ await expect
+ .element(screen.getByRole("button", { name: /Add/ }))
+ .not.toBeInTheDocument();
+ });
+
+ it("names the built-in providers whose scans an installed checks artifact changed", async () => {
+ // Given / When
+ const screen = await render(
+ ,
+ );
+
+ // Then
+ await expect
+ .element(
+ screen.getByText("Adds checks to your AWS and Google Cloud scans."),
+ )
+ .toBeVisible();
+ });
+});
+
+describe("Registry tenant card", () => {
+ it("still names the extended providers when the catalog no longer lists the artifact", async () => {
+ // Given / When
+ const screen = await render(
+ ,
+ );
+
+ // Then
+ await expect
+ .element(screen.getByText("Adds checks to your AWS scans."))
+ .toBeVisible();
+ });
+});
+
+describe("Registry card metadata layout", () => {
+ it("keeps Added when the installed version is unknown", async () => {
+ // Given / When
+ const screen = await render(
+ ,
+ );
+ // Then
+ await expect
+ .element(screen.getByText("Added", { exact: true }))
+ .toBeVisible();
+ await expect
+ .element(screen.getByText("Unknown", { exact: true }))
+ .toBeVisible();
+ await expect
+ .element(screen.getByRole("button", { name: /Update/ }))
+ .not.toBeInTheDocument();
+ });
+ it("offers Update with installed and available versions instead of Added", async () => {
+ // Given
+ const onAdd = vi.fn();
+ const screen = await render(
+ ,
+ );
+ // When
+ await screen
+ .getByRole("button", { name: "Update AWS security to 5.15.0" })
+ .click();
+ // Then
+ expect(onAdd).toHaveBeenCalledOnce();
+ await expect
+ .element(screen.getByText("Added", { exact: true }))
+ .not.toBeInTheDocument();
+ await expect
+ .element(screen.getByText("Installed", { exact: true }))
+ .toBeVisible();
+ await expect
+ .element(screen.getByText("1.0.0", { exact: true }))
+ .toBeVisible();
+ await expect
+ .element(screen.getByText("Available", { exact: true }))
+ .toBeVisible();
+ });
+
+ afterEach(async () => {
+ localStorage.removeItem("theme");
+ await page.viewport(1280, 800);
+ });
+
+ it("identifies each provider logo on hover and keyboard focus", async () => {
+ // Given
+ const screen = await render(
+ ,
+ );
+
+ // When / Then: each visible provider uses its own display name.
+ for (const name of ["AWS", "Google Cloud", "Template"]) {
+ const logo = screen.getByRole("img", { name, exact: true });
+ await logo.hover();
+ await expect.element(screen.getByRole("tooltip")).toHaveTextContent(name);
+ await userEvent.keyboard("{Escape}");
+ await expect.element(screen.getByRole("tooltip")).not.toBeInTheDocument();
+ }
+
+ // When / Then: keyboard users can discover the same names.
+ await userEvent.tab();
+ await expect
+ .element(screen.getByRole("img", { name: "AWS", exact: true }))
+ .toHaveFocus();
+ await expect.element(screen.getByRole("tooltip")).toHaveTextContent("AWS");
+ await userEvent.tab();
+ await expect
+ .element(screen.getByRole("img", { name: "Google Cloud", exact: true }))
+ .toHaveFocus();
+ await expect
+ .element(screen.getByRole("tooltip"))
+ .toHaveTextContent("Google Cloud");
+ });
+
+ it.each([
+ { width: 320, theme: "dark" },
+ { width: 768, theme: "dark" },
+ { width: 1440, theme: "dark" },
+ { width: 320, theme: "light" },
+ { width: 1440, theme: "light" },
+ ])(
+ "contains long metadata at $width px in $theme mode",
+ async ({ width, theme }) => {
+ // Given: the real grid includes normal, long, and catalog-less cards.
+ await page.viewport(width, 1000);
+ localStorage.setItem("theme", theme);
+ const longVersion =
+ "2026.123456789.123456789-preview.0123456789abcdef0123456789abcdef";
+ const onAdd = vi.fn();
+ const screen = await render(
+
+
+
+
+
+
+
+
+
+
+
+
+ ,
+ );
+
+ // Then: values stay complete, contained, and grouped in each card's footer.
+ const metadataBlocks = screen.getByRole("group", {
+ name: "Artifact metadata",
+ });
+ await expect.element(metadataBlocks.nth(2)).toBeVisible();
+ await expect
+ .element(
+ metadataBlocks
+ .nth(1)
+ .getByText("9,007,199,254,740,991", { exact: true }),
+ )
+ .toBeVisible();
+ await expect
+ .element(
+ metadataBlocks.nth(1).getByText("9,876,543,210", { exact: true }),
+ )
+ .toBeVisible();
+ await expect
+ .element(metadataBlocks.nth(2).getByText(longVersion, { exact: true }))
+ .toBeVisible();
+ for (const item of screen.getByRole("listitem").elements()) {
+ const card = item.querySelector('[data-slot="card"]')!;
+ const metadata = item.querySelector("dl")!;
+ const bounds = card.getBoundingClientRect();
+ expect(card.scrollWidth).toBeLessThanOrEqual(card.clientWidth);
+ for (const value of Array.from(metadata.querySelectorAll("dt, dd"))) {
+ expect(value.scrollWidth).toBeLessThanOrEqual(value.clientWidth);
+ const range = document.createRange();
+ range.selectNodeContents(value);
+ for (const line of Array.from(range.getClientRects())) {
+ expect(line.left).toBeGreaterThanOrEqual(bounds.left);
+ expect(line.right).toBeLessThanOrEqual(bounds.right);
+ }
+ }
+ const description = item.querySelector("p");
+ expect(metadata.getBoundingClientRect().top).toBeGreaterThan(
+ description!.getBoundingClientRect().bottom,
+ );
+ }
+ expect(document.documentElement.scrollWidth).toBeLessThanOrEqual(width);
+
+ // When / Then: wrapping does not obstruct the card action.
+ await screen
+ .getByRole("button", { name: `Update Long metadata to ${longVersion}` })
+ .click();
+ expect(onAdd).toHaveBeenCalledOnce();
+ await screen.getByRole("main").screenshot();
+ },
+ );
+});
diff --git a/ui/components/registry/registry-artifact-card.tsx b/ui/components/registry/registry-artifact-card.tsx
new file mode 100644
index 0000000000..4e3b256d0f
--- /dev/null
+++ b/ui/components/registry/registry-artifact-card.tsx
@@ -0,0 +1,442 @@
+"use client";
+
+import {
+ BadgeCheck,
+ Check,
+ ClipboardCheck,
+ Download,
+ ListChecks,
+ Package,
+ ShieldCheck,
+ Tag,
+} from "lucide-react";
+
+import { ProviderTypeIcon } from "@/components/icons/providers-badge/provider-type-icon";
+import { ProwlerShort } from "@/components/icons/prowler/ProwlerIcons";
+import {
+ Avatar,
+ AvatarImage,
+ AvatarFallback,
+} from "@/components/shadcn/avatar/avatar";
+import { Badge } from "@/components/shadcn/badge/badge";
+import { Button } from "@/components/shadcn/button/button";
+import { Card } from "@/components/shadcn/card/card";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from "@/components/shadcn/tooltip";
+import { getRegistryNotInstallableMessage } from "@/lib/registry/installability";
+import { cn } from "@/lib/utils";
+import { getProviderDisplayName, isKnownProviderType } from "@/types/providers";
+import type { RegistryArtifactOwner } from "@/types/registry";
+
+import {
+ REGISTRY_CAPABILITY_LABELS,
+ type RegistryMarketplaceArtifact,
+} from "./registry-explorer.model";
+
+interface RegistryArtifactCardProps {
+ artifact: RegistryMarketplaceArtifact;
+ pendingAddName?: string;
+ onAdd: () => void;
+ onRemove: (trigger: HTMLButtonElement | null) => void;
+}
+
+function capabilitySummary(artifact: RegistryMarketplaceArtifact) {
+ const labels = [
+ artifact.hasProvider && REGISTRY_CAPABILITY_LABELS.provider,
+ artifact.hasChecks && REGISTRY_CAPABILITY_LABELS.checks,
+ artifact.hasCompliance && REGISTRY_CAPABILITY_LABELS.compliance,
+ ].filter((label) => label !== false);
+ return labels.join(", ");
+}
+
+/**
+ * Maximum provider logos rendered in the footer cluster before collapsing
+ * the remainder into a "+N" overflow badge (registry.dev card reference).
+ */
+const MAX_PROVIDER_LOGOS = 4;
+
+const PROVIDER_LIST_FORMAT = new Intl.ListFormat("en", {
+ style: "long",
+ type: "conjunction",
+});
+
+interface RegistryProviderClusterProps {
+ providers: string[];
+}
+
+function RegistryProviderCluster({ providers }: RegistryProviderClusterProps) {
+ if (providers.length === 0) return null;
+
+ const displayNames = providers.map(getProviderDisplayName);
+ const visibleProviders = providers.slice(0, MAX_PROVIDER_LOGOS);
+ const overflowCount = providers.length - visibleProviders.length;
+
+ return (
+
+ {/* Icons alone must never be the only carrier of the provider names. */}
+
+ {providers.length === 1
+ ? `Provider: ${displayNames[0]}`
+ : `Providers: ${displayNames.join(", ")}`}
+
+ {providers.length > 1 && (
+
+ {providers.length} providers
+
+ )}
+
+ {visibleProviders.map((provider) => (
+
+
+
+ {isKnownProviderType(provider) ? (
+
+ ) : (
+
+ {getProviderDisplayName(provider)}
+
+ )}
+
+
+
+ {getProviderDisplayName(provider)}
+
+
+ ))}
+
+ {overflowCount > 0 && (
+
+ +{overflowCount}
+
+ )}
+
+ );
+}
+
+interface RegistryExtendedProvidersProps {
+ slugs: string[];
+}
+
+/** The only thing explaining an install that shows no provider type. */
+function RegistryExtendedProviders({ slugs }: RegistryExtendedProvidersProps) {
+ if (slugs.length === 0) return null;
+
+ return (
+
+ Adds checks to your{" "}
+ {PROVIDER_LIST_FORMAT.format(slugs.map(getProviderDisplayName))} scans.
+
+ );
+}
+
+interface RegistryOwnerRowProps {
+ isOfficial: boolean;
+ isVerified: boolean;
+ owner?: RegistryArtifactOwner;
+}
+
+function RegistryOwnerRow({
+ isOfficial,
+ isVerified,
+ owner,
+}: RegistryOwnerRowProps) {
+ if (!owner && !isOfficial && !isVerified) return null;
+
+ return (
+
+ {owner &&
+ (owner.name.trim().toLowerCase() === "prowler" ? (
+
+ ) : (
+
+
+
+ {owner.name.charAt(0)}
+
+
+ {owner.name}
+
+
+ ))}
+ {isOfficial && (
+
+
+ Official
+
+ )}
+ {isVerified && (
+
+
+ Verified
+
+ )}
+
+ );
+}
+
+interface RegistryArtifactMetadataProps {
+ complianceCount?: number;
+ checkCount?: number;
+ version?: string;
+ isAdded?: boolean;
+ availableVersion?: string;
+ downloads?: number;
+}
+
+function RegistryArtifactMetadata({
+ complianceCount,
+ checkCount,
+ version,
+ isAdded,
+ availableVersion,
+ downloads,
+}: RegistryArtifactMetadataProps) {
+ const items = [
+ {
+ label: REGISTRY_CAPABILITY_LABELS.compliance,
+ value: complianceCount,
+ icon: ClipboardCheck,
+ },
+ {
+ label: REGISTRY_CAPABILITY_LABELS.checks,
+ value: checkCount,
+ icon: ListChecks,
+ },
+ { label: isAdded ? "Installed" : "Version", value: version, icon: Tag },
+ { label: "Available", value: availableVersion, icon: Tag },
+ { label: "Downloads", value: downloads, icon: Download },
+ ].filter(({ value }) => value !== undefined && value !== "");
+
+ if (items.length === 0) return null;
+
+ return (
+
+
1 && "grid-cols-2",
+ items.length === 3 && "@sm:grid-cols-3",
+ items.length === 4 && "@sm:grid-cols-4",
+ items.length === 5 && "@sm:grid-cols-3",
+ )}
+ >
+ {items.map(({ label, value, icon: Icon }) => (
+
+
-
+
+ {label}
+
+ -
+ {typeof value === "number"
+ ? value.toLocaleString("en-US")
+ : value}
+
+
+ ))}
+
+
+ );
+}
+
+export function RegistryArtifactCard({
+ artifact,
+ pendingAddName,
+ onAdd,
+ onRemove,
+}: RegistryArtifactCardProps) {
+ const displayName = artifact.name ?? artifact.normalizedName;
+ const subtitle = [
+ artifact.providers.map(getProviderDisplayName).join(", "),
+ capabilitySummary(artifact),
+ ]
+ .filter(Boolean)
+ .join(" · ");
+
+ return (
+
+
+
+ {/* Artifacts can span several providers, so the header shows a
+ neutral package mark instead of any single provider logo. */}
+
+
+
+
+ {displayName}
+
+ {subtitle && (
+
+ {subtitle}
+
+ )}
+
+
+ {artifact.description && (
+
+ {artifact.description}
+
+ )}
+
+
+
+
+ {!artifact.isAdded &&
+ !artifact.isInstallable &&
+ !artifact.isBuiltin && (
+
+ {getRegistryNotInstallableMessage(artifact.notInstallableReason)}
+
+ )}
+
+
+
+ {artifact.isBuiltin && (
+
+ Built in
+
+ )}
+ {artifact.isAdded ? (
+ <>
+ {artifact.updateAvailable && artifact.isInstallable ? (
+
+ {pendingAddName === artifact.normalizedName
+ ? "Updating…"
+ : "Update"}
+
+ ) : (
+
+
+ Added
+
+ )}
+ onRemove(event.currentTarget)}
+ size="sm"
+ type="button"
+ variant="outline"
+ >
+ Remove
+
+ >
+ ) : artifact.isInstallable ? (
+
+ {pendingAddName === artifact.normalizedName ? "Adding…" : "Add"}
+
+ ) : null}
+
+
+
+
+ );
+}
+
+interface RegistryTenantArtifactCardProps {
+ extendsProviderSlugs?: string[];
+ normalizedName: string;
+ onRemove: (trigger: HTMLButtonElement | null) => void;
+ resolvedVersion?: string;
+}
+
+export function RegistryTenantArtifactCard({
+ extendsProviderSlugs = [],
+ normalizedName,
+ onRemove,
+ resolvedVersion,
+}: RegistryTenantArtifactCardProps) {
+ return (
+
+
+
+ {/* Tenant artifacts carry no provider metadata; the neutral package
+ mark matches the marketplace card header. */}
+
+
+
+
+
+ Installed in this workspace. Catalog metadata is not available for this
+ artifact.
+
+
+
+
+
+ onRemove(event.currentTarget)}
+ size="sm"
+ type="button"
+ variant="outline"
+ >
+ Remove
+
+
+
+
+ );
+}
diff --git a/ui/components/registry/registry-artifact-grid.tsx b/ui/components/registry/registry-artifact-grid.tsx
new file mode 100644
index 0000000000..9fb27310a5
--- /dev/null
+++ b/ui/components/registry/registry-artifact-grid.tsx
@@ -0,0 +1,51 @@
+import { PackageSearch } from "lucide-react";
+import type { ReactNode } from "react";
+
+import { Button } from "@/components/shadcn/button/button";
+import { Card, CardContent } from "@/components/shadcn/card/card";
+
+interface RegistryArtifactGridProps {
+ children: ReactNode;
+ emptyMessage: string;
+ isEmpty: boolean;
+ emptyDescription?: string;
+ emptyActionLabel?: string;
+ onReset?: () => void;
+}
+
+export function RegistryArtifactGrid({
+ children,
+ emptyMessage,
+ isEmpty,
+ onReset,
+ emptyDescription = "Try another search or clear your filters to explore the catalog.",
+ emptyActionLabel = "Clear filters",
+}: RegistryArtifactGridProps) {
+ if (isEmpty)
+ return (
+
+
+
+
+ {emptyMessage}
+
+
+ {emptyDescription}
+
+ {onReset && (
+
+ {emptyActionLabel}
+
+ )}
+
+
+ );
+ return (
+
+ );
+}
diff --git a/ui/components/registry/registry-artifact-task-handler.test.tsx b/ui/components/registry/registry-artifact-task-handler.test.tsx
new file mode 100644
index 0000000000..138e656565
--- /dev/null
+++ b/ui/components/registry/registry-artifact-task-handler.test.tsx
@@ -0,0 +1,187 @@
+import { render, screen } from "@testing-library/react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+import { Toast, ToastProvider, ToastViewport } from "@/components/shadcn/toast";
+import type { WatchedTask } from "@/store/task-watcher/store";
+
+const { confirmRegistryArtifactAddition, toast } = vi.hoisted(() => ({
+ confirmRegistryArtifactAddition: vi.fn(),
+ toast: vi.fn(),
+}));
+vi.mock("@/actions/registry/registry", () => ({
+ addRegistryArtifact: vi.fn(),
+ confirmRegistryArtifactAddition,
+}));
+vi.mock("@/components/shadcn/toast", async (importOriginal) => ({
+ ...(await importOriginal()),
+ toast,
+}));
+vi.mock("@/store/task-watcher/store", () => ({
+ trackAndPollTask: vi.fn(),
+ TASK_WATCHER_STATUS: { READY: "ready" },
+}));
+
+import { registryArtifactTaskHandler } from "./registry-artifact-task-handler";
+
+const task: WatchedTask = {
+ taskId: "installation-task",
+ kind: "registry-artifact-add",
+ status: "ready",
+ startedAt: Date.now(),
+ meta: { normalizedName: "acme-provider" },
+ result: { installed: true, error: null },
+};
+
+describe("resumed Registry installations", () => {
+ beforeEach(() => vi.clearAllMocks());
+
+ it("resumes an update with its expected version and announces one update", async () => {
+ // Given
+ confirmRegistryArtifactAddition.mockResolvedValue({
+ status: "confirmed",
+ tenantArtifacts: [],
+ });
+ // When
+ await registryArtifactTaskHandler.onReady({
+ ...task,
+ meta: { ...task.meta, operation: "update", expectedVersion: "2.0.0" },
+ });
+ // Then
+ expect(confirmRegistryArtifactAddition).toHaveBeenCalledWith(
+ "acme-provider",
+ "2.0.0",
+ );
+ expect(toast).toHaveBeenCalledOnce();
+ expect(toast).toHaveBeenCalledWith(
+ expect.objectContaining({ title: "Artifact updated" }),
+ );
+ });
+
+ it.each(["refresh_failed", "error"])(
+ "announces an update failure for %s after reload",
+ async (status) => {
+ // Given
+ confirmRegistryArtifactAddition.mockResolvedValue({ status });
+ // When
+ await registryArtifactTaskHandler.onReady({
+ ...task,
+ meta: {
+ ...task.meta,
+ operation: "update",
+ expectedVersion: "2.0.0",
+ },
+ });
+ // Then
+ expect(toast).toHaveBeenCalledOnce();
+ expect(toast).toHaveBeenCalledWith(
+ expect.objectContaining({
+ title: "Artifact could not be updated",
+ variant: "destructive",
+ ...(status === "refresh_failed"
+ ? {
+ description:
+ "Update could not be confirmed. Refresh Registry before retrying.",
+ }
+ : {}),
+ }),
+ );
+ },
+ );
+
+ it("never confirms an update with missing persisted target metadata", async () => {
+ // Given / When
+ await registryArtifactTaskHandler.onReady({
+ ...task,
+ meta: { ...task.meta, operation: "update" },
+ });
+ // Then
+ expect(confirmRegistryArtifactAddition).not.toHaveBeenCalled();
+ expect(toast).toHaveBeenCalledWith(
+ expect.objectContaining({ title: "Artifact could not be updated" }),
+ );
+ });
+
+ it("waits for membership confirmation before one success notification and selector refresh", async () => {
+ let confirm!: (value: unknown) => void;
+ confirmRegistryArtifactAddition.mockReturnValue(
+ new Promise((resolve) => {
+ confirm = resolve;
+ }),
+ );
+ const listener = vi.fn();
+ window.addEventListener("registry-artifacts-changed", listener);
+ const completion = registryArtifactTaskHandler.onReady(task);
+ expect(confirmRegistryArtifactAddition).toHaveBeenCalledWith(
+ "acme-provider",
+ );
+ expect(toast).not.toHaveBeenCalled();
+ expect(listener).not.toHaveBeenCalled();
+ const tenantArtifacts = {
+ artifacts: [{ normalizedName: "acme-provider" }],
+ };
+ confirm({ status: "confirmed", tenantArtifacts });
+ await completion;
+ expect(toast).toHaveBeenCalledOnce();
+ expect(toast).toHaveBeenCalledWith(
+ expect.objectContaining({ title: "Artifact added" }),
+ );
+ expect(toast.mock.calls[0][0]).not.toHaveProperty("description");
+ render(
+
+ {toast.mock.calls[0][0].action}
+
+ ,
+ );
+ expect(
+ screen.getByRole("link", { name: "Go to Providers" }),
+ ).toHaveAttribute("href", "/providers");
+ expect(listener).toHaveBeenCalledOnce();
+ expect(listener.mock.calls[0][0].detail).toEqual(tenantArtifacts);
+ window.removeEventListener("registry-artifacts-changed", listener);
+ });
+
+ it.each([
+ { installed: false, error: "Installation rejected" },
+ { installed: true, error: "Partial failure" },
+ undefined,
+ ])(
+ "never announces success for unsuccessful task results",
+ async (result) => {
+ await registryArtifactTaskHandler.onReady({ ...task, result });
+ expect(confirmRegistryArtifactAddition).not.toHaveBeenCalled();
+ expect(toast).toHaveBeenCalledOnce();
+ expect(toast).toHaveBeenCalledWith(
+ expect.objectContaining({ variant: "destructive" }),
+ );
+ },
+ );
+
+ it("keeps backend diagnostics out of notifications after reload", async () => {
+ // Given
+ const result = {
+ installed: false,
+ error: "Private diagnostic: /srv/registry/customer",
+ };
+
+ // When
+ await registryArtifactTaskHandler.onReady({ ...task, result });
+
+ // Then
+ expect(toast).toHaveBeenCalledWith(
+ expect.objectContaining({
+ variant: "destructive",
+ description: "The artifact could not be installed.",
+ }),
+ );
+ expect(confirmRegistryArtifactAddition).not.toHaveBeenCalled();
+ });
+
+ it("reports a failed confirmation read without announcing availability", async () => {
+ confirmRegistryArtifactAddition.mockRejectedValue(new Error("Unavailable"));
+ await registryArtifactTaskHandler.onReady(task);
+ expect(toast).toHaveBeenCalledOnce();
+ expect(toast).toHaveBeenCalledWith(
+ expect.objectContaining({ variant: "destructive" }),
+ );
+ });
+});
diff --git a/ui/components/registry/registry-artifact-task-handler.ts b/ui/components/registry/registry-artifact-task-handler.ts
new file mode 100644
index 0000000000..f5f57c1f44
--- /dev/null
+++ b/ui/components/registry/registry-artifact-task-handler.ts
@@ -0,0 +1,37 @@
+"use client";
+
+import { confirmRegistryArtifactTask } from "@/lib/registry/artifact-execution";
+import { notifyRegistryArtifactOutcome } from "@/lib/registry/artifact-notifications";
+import type { TaskKindHandler } from "@/store/task-watcher/store";
+import { REGISTRY_INSTALL_OPERATION } from "@/types/registry";
+
+export const registryArtifactTaskHandler: TaskKindHandler = {
+ onReady: async (task) => {
+ const normalizedName = task.meta.normalizedName;
+ const operation =
+ task.meta.operation === REGISTRY_INSTALL_OPERATION.UPDATE
+ ? REGISTRY_INSTALL_OPERATION.UPDATE
+ : REGISTRY_INSTALL_OPERATION.ADD;
+ const expectedVersion =
+ operation === REGISTRY_INSTALL_OPERATION.UPDATE
+ ? task.meta.expectedVersion?.trim()
+ : undefined;
+ const result =
+ normalizedName &&
+ (operation !== REGISTRY_INSTALL_OPERATION.UPDATE || expectedVersion)
+ ? await confirmRegistryArtifactTask(
+ normalizedName,
+ task.result,
+ expectedVersion,
+ )
+ : { status: "error" as const };
+ notifyRegistryArtifactOutcome(result, operation);
+ },
+ onError: (task) =>
+ notifyRegistryArtifactOutcome(
+ { status: "error" },
+ task.meta.operation === REGISTRY_INSTALL_OPERATION.UPDATE
+ ? REGISTRY_INSTALL_OPERATION.UPDATE
+ : REGISTRY_INSTALL_OPERATION.ADD,
+ ),
+};
diff --git a/ui/components/registry/registry-credential-banner.tsx b/ui/components/registry/registry-credential-banner.tsx
new file mode 100644
index 0000000000..720ae84fcd
--- /dev/null
+++ b/ui/components/registry/registry-credential-banner.tsx
@@ -0,0 +1,64 @@
+import { KeyRound } from "lucide-react";
+import { type Ref } from "react";
+
+import { Button } from "@/components/shadcn/button/button";
+import { Card } from "@/components/shadcn/card/card";
+
+interface RegistryCredentialBannerProps {
+ connectButtonRef?: Ref;
+ onConnect: () => void;
+ tenantArtifactCount: number;
+ validationPending: boolean;
+}
+
+export function RegistryCredentialBanner({
+ connectButtonRef,
+ onConnect,
+ tenantArtifactCount,
+ validationPending,
+}: RegistryCredentialBannerProps) {
+ const title = validationPending
+ ? "Registry validation in progress"
+ : "Connect your Registry API key";
+ const copy = validationPending
+ ? "Your Registry key is being validated. Catalog exploration will be available after validation succeeds."
+ : "A Registry API key is required to install artifacts into this workspace.";
+
+ return (
+
+
+
+
+
+
+
{title}
+
{copy}
+ {tenantArtifactCount > 0 && (
+
+ Your {tenantArtifactCount} preserved tenant artifact
+ {tenantArtifactCount === 1 ? "" : "s"} will remain available in My
+ artifacts.
+
+ )}
+
+ {/* Stays enabled while validation is pending: submitting a
+ replacement key supersedes a validation that never settles. */}
+
+ Connect API key
+
+
+
+ Explore Prowler Registry
+
+
+
+
+
+
+ );
+}
diff --git a/ui/components/registry/registry-credential-task-handler.test.ts b/ui/components/registry/registry-credential-task-handler.test.ts
new file mode 100644
index 0000000000..efafbebe8a
--- /dev/null
+++ b/ui/components/registry/registry-credential-task-handler.test.ts
@@ -0,0 +1,112 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+import type { WatchedTask } from "@/store/task-watcher/store";
+
+import { registryCredentialTaskHandler } from "./registry-credential-task-handler";
+
+const {
+ refreshRegistryCollectionsMock,
+ refreshRegistryCredentialMock,
+ toastMock,
+} = vi.hoisted(() => ({
+ refreshRegistryCollectionsMock: vi.fn(),
+ refreshRegistryCredentialMock: vi.fn(),
+ toastMock: vi.fn(),
+}));
+
+vi.mock("@/actions/registry/registry", () => ({
+ refreshRegistryCollections: refreshRegistryCollectionsMock,
+ refreshRegistryCredential: refreshRegistryCredentialMock,
+}));
+
+vi.mock("@/store/task-watcher/store", () => ({
+ TASK_WATCHER_STATUS: { PENDING: "pending", READY: "ready", ERROR: "error" },
+}));
+
+vi.mock("@/components/shadcn/toast", () => ({ toast: toastMock }));
+
+const buildTask = (overrides: Partial = {}): WatchedTask => ({
+ taskId: "task-1",
+ kind: "registry-credential-validation",
+ status: "ready",
+ startedAt: Date.now(),
+ meta: {},
+ result: { stored: true, error: null },
+ ...overrides,
+});
+
+describe("registryCredentialTaskHandler", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ refreshRegistryCollectionsMock.mockResolvedValue({
+ status: "complete",
+ catalog: { status: "complete", artifacts: [] },
+ tenantArtifacts: [],
+ });
+ refreshRegistryCredentialMock.mockResolvedValue({ status: "error" });
+ });
+
+ it("announces the connected Registry after a resumed task completes validly", async () => {
+ // Given
+ refreshRegistryCredentialMock.mockResolvedValue({
+ status: "status",
+ credential: {
+ configured: true,
+ isValid: true,
+ scopes: ["catalog:read"],
+ validationPending: false,
+ },
+ });
+
+ // When
+ registryCredentialTaskHandler.onReady(buildTask());
+
+ // Then
+ await vi.waitFor(() =>
+ expect(toastMock).toHaveBeenCalledWith({ title: "Registry connected" }),
+ );
+ });
+
+ it("reports a safe failure when a resumed task settles in error", async () => {
+ // When
+ await registryCredentialTaskHandler.onError(
+ buildTask({ status: "error", error: 'Task ended in state "failed".' }),
+ );
+
+ // Then
+ expect(toastMock).toHaveBeenCalledWith({
+ variant: "destructive",
+ title: "Registry key validation failed",
+ description: "Registry key validation could not be completed. Try again.",
+ });
+ expect(refreshRegistryCredentialMock).toHaveBeenCalledTimes(1);
+ });
+ it("does not announce a rejected replacement as connected", async () => {
+ const refresh = vi.fn();
+ window.addEventListener("registry-credential-changed", refresh);
+ refreshRegistryCredentialMock.mockResolvedValue({
+ status: "status",
+ credential: {
+ configured: true,
+ isValid: true,
+ scopes: [],
+ validationPending: false,
+ },
+ });
+ await registryCredentialTaskHandler.onReady(
+ buildTask({
+ meta: { priorConfigured: "true" },
+ result: { stored: false, error: "Invalid key" },
+ }),
+ );
+ expect(toastMock).toHaveBeenCalledWith(
+ expect.objectContaining({
+ variant: "destructive",
+ description:
+ "Registry key validation failed. Existing access is unchanged.",
+ }),
+ );
+ expect(refresh).toHaveBeenCalledOnce();
+ window.removeEventListener("registry-credential-changed", refresh);
+ });
+});
diff --git a/ui/components/registry/registry-credential-task-handler.ts b/ui/components/registry/registry-credential-task-handler.ts
new file mode 100644
index 0000000000..26151d4d19
--- /dev/null
+++ b/ui/components/registry/registry-credential-task-handler.ts
@@ -0,0 +1,16 @@
+"use client";
+
+import { completeRegistryCredentialValidation } from "@/lib/registry/credential-result";
+import type { TaskKindHandler, WatchedTask } from "@/store/task-watcher/store";
+
+const complete = async (task: WatchedTask) => {
+ await completeRegistryCredentialValidation(
+ task,
+ task.meta.priorConfigured === "true",
+ );
+};
+
+export const registryCredentialTaskHandler: TaskKindHandler = {
+ onReady: complete,
+ onError: complete,
+};
diff --git a/ui/components/registry/registry-explorer.integration.test.tsx b/ui/components/registry/registry-explorer.integration.test.tsx
new file mode 100644
index 0000000000..ec19034181
--- /dev/null
+++ b/ui/components/registry/registry-explorer.integration.test.tsx
@@ -0,0 +1,2210 @@
+import { http, HttpResponse } from "msw";
+import { useRouter } from "next/navigation";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { userEvent } from "vitest/browser";
+
+import { worker } from "@/__tests__/msw/worker";
+import { render } from "@/__tests__/render-browser";
+import type {
+ RegistryArtifactRemovalResult,
+ RegistryBootstrapState,
+} from "@/types/registry";
+
+import { RegistryArtifactCard } from "./registry-artifact-card";
+import { RegistryExplorer } from "./registry-explorer";
+
+vi.mock("next/navigation", async () => {
+ const { useSyncExternalStore } = await import("react");
+ const router = { replace: vi.fn(), push: vi.fn(), refresh: vi.fn() };
+ const subscribe = (callback: () => void) => {
+ window.addEventListener("popstate", callback);
+ return () => window.removeEventListener("popstate", callback);
+ };
+ return {
+ useRouter: () => router,
+ usePathname: () => "/registry",
+ useSearchParams: () =>
+ new URLSearchParams(
+ useSyncExternalStore(
+ subscribe,
+ () => window.location.search,
+ () => "",
+ ),
+ ),
+ };
+});
+const originalReplaceState = window.history.replaceState.bind(window.history);
+
+const {
+ disconnectRegistryCredentialMock,
+ executeRegistryArtifactAdditionMock,
+ refreshRegistryCollectionsMock,
+ refreshRegistryCredentialMock,
+ removeRegistryArtifactMock,
+ submitRegistryCredentialMock,
+ trackAndPollTaskMock,
+} = vi.hoisted(() => ({
+ disconnectRegistryCredentialMock: vi.fn(),
+ executeRegistryArtifactAdditionMock: vi.fn(),
+ refreshRegistryCollectionsMock: vi.fn(),
+ refreshRegistryCredentialMock: vi.fn(),
+ removeRegistryArtifactMock: vi.fn(),
+ submitRegistryCredentialMock: vi.fn(),
+ trackAndPollTaskMock: vi.fn(),
+}));
+
+vi.mock("@/actions/registry/registry", () => ({
+ disconnectRegistryCredential: disconnectRegistryCredentialMock,
+ getRegistryBootstrap: vi.fn(),
+ refreshRegistryCollections: refreshRegistryCollectionsMock,
+ refreshRegistryCredential: refreshRegistryCredentialMock,
+ removeRegistryArtifact: removeRegistryArtifactMock,
+ submitRegistryCredential: submitRegistryCredentialMock,
+}));
+
+// The credential flow watches its validation task through the house task
+// watcher; integration tests drive settlement through this mock the same way
+// `lib/jira-dispatch-execution.test.ts` does.
+vi.mock("@/lib/registry/artifact-execution", () => ({
+ executeRegistryArtifactAddition: executeRegistryArtifactAdditionMock,
+}));
+
+vi.mock("@/store/task-watcher/store", () => ({
+ TASK_WATCHER_STATUS: { PENDING: "pending", READY: "ready", ERROR: "error" },
+ trackAndPollTask: trackAndPollTaskMock,
+ useTaskWatcherStore: (selector: (state: { tasks: {} }) => unknown) =>
+ selector({ tasks: {} }),
+}));
+
+// The integration setup mocks `next/navigation` with a module-level router,
+// so this "hook" is a plain function returning the shared router spies and
+// is safe to call outside a component.
+// eslint-disable-next-line react-hooks/rules-of-hooks
+const registryRouter = useRouter();
+
+const onboardingState: RegistryBootstrapState = {
+ status: "onboarding",
+ credential: {
+ configured: false,
+ isValid: false,
+ scopes: [],
+ validationPending: false,
+ },
+ tenantArtifacts: [],
+};
+
+const validationPendingState: RegistryBootstrapState = {
+ status: "validation_pending",
+ credential: {
+ configured: true,
+ isValid: false,
+ scopes: [],
+ validationPending: true,
+ },
+ tenantArtifacts: [],
+};
+
+const submittedResult = (priorConfigured = false) => ({
+ status: "submitted" as const,
+ taskId: "registry-task-1",
+ priorConfigured,
+});
+
+const readyState: RegistryBootstrapState = {
+ status: "ready",
+ credential: {
+ configured: true,
+ isValid: true,
+ scopes: ["catalog:read"],
+ validationPending: false,
+ },
+ catalog: {
+ status: "complete",
+ artifacts: [
+ {
+ normalizedName: "aws-guard",
+ name: "AWS guard",
+ description: "Already added artifact",
+ latestVersion: "1.2.3",
+ providers: ["aws"],
+ isVerified: true,
+ isOfficial: true,
+ isBuiltin: false,
+ isMeta: false,
+ hasProvider: true,
+ isInstallable: true,
+ hasChecks: true,
+ hasCompliance: false,
+ versionCount: 2,
+ totalDownloads: 12,
+ owners: [
+ {
+ name: "Prowler",
+ type: "organization",
+ logoUrl: "https://cdn.example/prowler-logo.png",
+ },
+ ],
+ },
+ {
+ normalizedName: "later-guard",
+ name: "Later guard",
+ description: "Artifact collected from a later page",
+ latestVersion: "2.0.0",
+ providers: ["azure"],
+ isVerified: false,
+ isOfficial: false,
+ isBuiltin: false,
+ isMeta: false,
+ hasProvider: true,
+ isInstallable: true,
+ hasChecks: true,
+ hasCompliance: true,
+ versionCount: 1,
+ totalDownloads: 3,
+ owners: [],
+ },
+ {
+ normalizedName: "cloud-guard",
+ name: "Cloud guard",
+ description: "Multi-provider artifact",
+ latestVersion: "3.0.0",
+ providers: ["aws", "gcp"],
+ isVerified: true,
+ isOfficial: true,
+ isBuiltin: false,
+ isMeta: true,
+ hasProvider: true,
+ isInstallable: true,
+ hasChecks: true,
+ hasCompliance: true,
+ versionCount: 4,
+ totalDownloads: 42,
+ owners: [{ name: "Registry team", type: "organization" }],
+ },
+ ],
+ },
+ tenantArtifacts: [
+ { normalizedName: "aws-guard", versionSpec: "latest" },
+ {
+ normalizedName: "saved-artifact",
+ versionSpec: "latest",
+ resolvedVersion: "1.0.0",
+ },
+ ],
+};
+
+async function expectRedirectedToProfile() {
+ await expect
+ .poll(() => vi.mocked(registryRouter.replace).mock.calls)
+ .toEqual([["/profile"]]);
+}
+
+const incompleteState: RegistryBootstrapState = {
+ status: "incomplete",
+ catalog: { status: "incomplete", reason: "page_failed", collectedCount: 100 },
+};
+
+function cardFor(name: string) {
+ const card = Array.from(document.querySelectorAll("li")).find((item) =>
+ item.textContent?.includes(name),
+ );
+ if (!card) throw new Error(`Expected a rendered card for ${name}`);
+ return card;
+}
+
+describe("RegistryExplorer", () => {
+ it("refreshes catalog and installed versions when switching tabs", async () => {
+ // Given
+ refreshRegistryCollectionsMock.mockResolvedValue({
+ status: "complete",
+ catalog: readyState.catalog,
+ tenantArtifacts: [
+ {
+ normalizedName: "aws-guard",
+ versionSpec: "latest",
+ resolvedVersion: "1.0.0",
+ },
+ ],
+ });
+ const screen = await render();
+ // When
+ await screen.getByRole("tab", { name: /My artifacts/ }).click();
+ // Then
+ await expect
+ .element(
+ screen.getByRole("button", { name: "Update AWS guard to 1.2.3" }),
+ )
+ .toBeVisible();
+ expect(refreshRegistryCollectionsMock).toHaveBeenCalled();
+ });
+
+ it("coalesces manual, tab and focus refreshes while preserving filters", async () => {
+ // Given
+ let resolveRefresh!: (value: unknown) => void;
+ refreshRegistryCollectionsMock.mockReturnValue(
+ new Promise((resolve) => {
+ resolveRefresh = resolve;
+ }),
+ );
+ const screen = await render();
+ await screen.getByLabelText("Search artifacts").fill("AWS");
+ // When: the tab change also requests a refresh, sharing any focus read.
+ await screen.getByRole("tab", { name: /My artifacts/ }).click();
+ window.dispatchEvent(new Event("focus"));
+ await screen.getByRole("tab", { name: /All/ }).click();
+ // Then
+ await expect
+ .element(screen.getByRole("button", { name: "Refresh Registry" }))
+ .toBeDisabled();
+ expect(document.body.textContent).toContain("Refreshing…");
+ expect(refreshRegistryCollectionsMock).toHaveBeenCalledOnce();
+ expect(cardFor("AWS guard")).toBeTruthy();
+ resolveRefresh({
+ status: "complete",
+ catalog: {
+ ...readyState.catalog,
+ artifacts: readyState.catalog.artifacts.map((artifact) => ({
+ ...artifact,
+ latestVersion: "2.0.0",
+ })),
+ },
+ tenantArtifacts: [
+ {
+ normalizedName: "aws-guard",
+ versionSpec: "latest",
+ resolvedVersion: "1.2.3",
+ },
+ ],
+ });
+ await expect
+ .element(
+ screen.getByRole("button", { name: "Update AWS guard to 2.0.0" }),
+ )
+ .toBeVisible();
+ await expect
+ .element(screen.getByLabelText("Search artifacts"))
+ .toHaveValue("AWS");
+ await expect
+ .element(screen.getByRole("button", { name: "Refresh Registry" }))
+ .toBeEnabled();
+ expect(document.body.textContent).not.toContain("Later guard");
+ });
+
+ it("loads a newly published artifact from the Refresh button without navigating", async () => {
+ // Given
+ const screen = await render();
+ refreshRegistryCollectionsMock.mockResolvedValue({
+ status: "complete",
+ tenantArtifacts: readyState.tenantArtifacts,
+ catalog: {
+ ...readyState.catalog,
+ artifacts: [
+ ...readyState.catalog.artifacts,
+ {
+ ...readyState.catalog.artifacts[0],
+ normalizedName: "new-artifact",
+ name: "New artifact",
+ },
+ ],
+ },
+ });
+ // When
+ await screen.getByRole("button", { name: "Refresh Registry" }).click();
+ // Then
+ await expect
+ .element(screen.getByText("New artifact", { exact: true }))
+ .toBeVisible();
+ expect(window.location.pathname).toBe("/registry");
+ expect(registryRouter.refresh).not.toHaveBeenCalled();
+ });
+
+ it.each(["error", "incomplete"])(
+ "preserves the last complete snapshot after a %s refresh and allows retry",
+ async (status) => {
+ // Given
+ const screen = await render(
+ ,
+ );
+ refreshRegistryCollectionsMock.mockResolvedValue({
+ status,
+ reason: "page_failed",
+ collectedCount: 1,
+ });
+ // When
+ await screen.getByRole("button", { name: "Refresh Registry" }).click();
+ // Then
+ await expect
+ .element(screen.getByRole("alert"))
+ .toHaveTextContent("Showing the last available data.");
+ expect(cardFor("AWS guard")).toBeTruthy();
+ expect(cardFor("Later guard")).toBeTruthy();
+ await expect
+ .element(screen.getByRole("button", { name: "Refresh Registry" }))
+ .toBeEnabled();
+ // When / Then
+ refreshRegistryCollectionsMock.mockResolvedValue({
+ status: "complete",
+ catalog: readyState.catalog,
+ tenantArtifacts: [],
+ });
+ await screen.getByRole("button", { name: "Refresh Registry" }).click();
+ await expect
+ .element(screen.getByRole("button", { name: "Add AWS guard" }))
+ .toBeVisible();
+ await expect.element(screen.getByRole("alert")).not.toBeInTheDocument();
+ },
+ );
+
+ it("discards a stale refresh and waits for an update before reading again", async () => {
+ // Given
+ const installed = {
+ normalizedName: "aws-guard",
+ versionSpec: "latest",
+ resolvedVersion: "1.0.0",
+ };
+ let stale!: (value: unknown) => void;
+ let update!: (value: unknown) => void;
+ let fresh!: (value: unknown) => void;
+ refreshRegistryCollectionsMock
+ .mockReturnValueOnce(
+ new Promise((resolve) => {
+ stale = resolve;
+ }),
+ )
+ .mockReturnValueOnce(
+ new Promise((resolve) => {
+ fresh = resolve;
+ }),
+ );
+ executeRegistryArtifactAdditionMock.mockReturnValue(
+ new Promise((resolve) => {
+ update = resolve;
+ }),
+ );
+ const screen = await render(
+ ,
+ );
+ await screen.getByRole("tab", { name: /My artifacts/ }).click();
+ // When
+ await screen
+ .getByRole("button", { name: "Update AWS guard to 1.2.3" })
+ .click();
+ window.dispatchEvent(new Event("focus"));
+ stale({
+ status: "complete",
+ catalog: { status: "complete", artifacts: [] },
+ tenantArtifacts: [],
+ });
+ // Then: old reads cannot erase an in-flight update.
+ await expect
+ .element(
+ screen.getByRole("button", { name: "Update AWS guard to 1.2.3" }),
+ )
+ .toBeDisabled();
+ expect(refreshRegistryCollectionsMock).toHaveBeenCalledOnce();
+ const tenantArtifacts = [{ ...installed, resolvedVersion: "1.2.3" }];
+ update({ status: "confirmed", tenantArtifacts });
+ await expect
+ .element(screen.getByText("Added", { exact: true }))
+ .toBeVisible();
+ await expect
+ .poll(() => refreshRegistryCollectionsMock.mock.calls.length)
+ .toBe(2);
+ fresh({ status: "complete", catalog: readyState.catalog, tenantArtifacts });
+ await expect
+ .element(screen.getByRole("button", { name: "Refresh Registry" }))
+ .toBeEnabled();
+ expect(cardFor("AWS guard").textContent).not.toContain("1.0.0");
+ await expect
+ .element(screen.getByText("Added", { exact: true }))
+ .toBeVisible();
+ });
+
+ it("keeps the list usable when the refresh request rejects", async () => {
+ // Given
+ const screen = await render();
+ refreshRegistryCollectionsMock.mockRejectedValue(
+ new Error("Network failure"),
+ );
+ // When
+ await screen.getByRole("button", { name: "Refresh Registry" }).click();
+ // Then
+ await expect
+ .element(screen.getByRole("alert"))
+ .toHaveTextContent("Registry could not be refreshed.");
+ await expect
+ .element(screen.getByRole("button", { name: "Refresh Registry" }))
+ .toBeEnabled();
+ expect(cardFor("AWS guard")).toBeTruthy();
+ });
+
+ it.each(["access_denied", "reconnect", "onboarding"])(
+ "honors %s from a collection refresh",
+ async (status) => {
+ // Given
+ const screen = await render(
+ ,
+ );
+ refreshRegistryCollectionsMock.mockResolvedValue({ status });
+ // When
+ await screen.getByRole("button", { name: "Refresh Registry" }).click();
+ // Then
+ if (status === "access_denied") {
+ await expectRedirectedToProfile();
+ } else {
+ await expect
+ .element(
+ screen.getByRole("button", {
+ name: status === "reconnect" ? "Replace key" : "Connect API key",
+ }),
+ )
+ .toBeVisible();
+ }
+ },
+ );
+
+ it("retries a deferred refresh after credential replacement fails", async () => {
+ // Given
+ let stale!: (value: unknown) => void;
+ refreshRegistryCollectionsMock
+ .mockReturnValueOnce(
+ new Promise((resolve) => {
+ stale = resolve;
+ }),
+ )
+ .mockResolvedValue({
+ status: "complete",
+ catalog: readyState.catalog,
+ tenantArtifacts: [],
+ });
+ submitRegistryCredentialMock.mockResolvedValue({
+ status: "replacement_failed",
+ });
+ const screen = await render();
+ await screen.getByRole("tab", { name: /My artifacts/ }).click();
+ // When
+ await screen.getByRole("button", { name: "Manage access" }).click();
+ await screen
+ .getByLabelText("Registry key")
+ .fill("synthetic-replacement-key");
+ await screen.getByRole("button", { name: "Replace key" }).click();
+ await expect.element(screen.getByRole("alert")).toBeVisible();
+ stale({
+ status: "complete",
+ catalog: readyState.catalog,
+ tenantArtifacts: readyState.tenantArtifacts,
+ });
+ // Then
+ await expect
+ .poll(() => refreshRegistryCollectionsMock.mock.calls.length)
+ .toBe(2);
+ await screen.getByRole("button", { name: "Close", exact: true }).click();
+ await expect
+ .element(screen.getByText("No artifacts in this workspace yet."))
+ .toBeVisible();
+ });
+
+ it("does not restore a removed artifact from an earlier refresh", async () => {
+ // Given
+ let stale!: (value: unknown) => void;
+ refreshRegistryCollectionsMock
+ .mockReturnValueOnce(
+ new Promise((resolve) => {
+ stale = resolve;
+ }),
+ )
+ .mockResolvedValue({
+ status: "complete",
+ catalog: readyState.catalog,
+ tenantArtifacts: [],
+ });
+ removeRegistryArtifactMock.mockResolvedValue({
+ status: "confirmed",
+ tenantArtifacts: [],
+ });
+ const screen = await render();
+ await screen.getByRole("tab", { name: /My artifacts/ }).click();
+ // When
+ await screen.getByRole("button", { name: "Remove AWS guard" }).click();
+ await screen.getByRole("button", { name: "Confirm Remove" }).click();
+ await expect
+ .element(screen.getByText("No artifacts in this workspace yet."))
+ .toBeVisible();
+ stale({
+ status: "complete",
+ catalog: readyState.catalog,
+ tenantArtifacts: readyState.tenantArtifacts,
+ });
+ // Then
+ await expect
+ .element(screen.getByRole("button", { name: "Refresh Registry" }))
+ .toBeEnabled();
+ await expect
+ .element(screen.getByText("No artifacts in this workspace yet."))
+ .toBeVisible();
+ expect(refreshRegistryCollectionsMock).toHaveBeenCalledTimes(2);
+ });
+
+ it("refreshes on returning focus and ignores focus while hidden", async () => {
+ // Given
+ const screen = await render();
+ const visibility = vi
+ .spyOn(document, "visibilityState", "get")
+ .mockReturnValue("hidden");
+ refreshRegistryCollectionsMock.mockClear();
+ // When / Then
+ window.dispatchEvent(new Event("focus"));
+ expect(refreshRegistryCollectionsMock).not.toHaveBeenCalled();
+ refreshRegistryCollectionsMock.mockResolvedValue({
+ status: "complete",
+ catalog: readyState.catalog,
+ tenantArtifacts: [
+ {
+ normalizedName: "aws-guard",
+ versionSpec: "latest",
+ resolvedVersion: "1.0.0",
+ },
+ ],
+ });
+ visibility.mockReturnValue("visible");
+ window.dispatchEvent(new Event("focus"));
+ await expect
+ .element(
+ screen.getByRole("button", { name: "Update AWS guard to 1.2.3" }),
+ )
+ .toBeVisible();
+ });
+
+ it.each([
+ [
+ { status: "refused", message: "This version has been withdrawn." },
+ "This version has been withdrawn.",
+ ],
+ [
+ { status: "unavailable" },
+ "The Registry operation could not be completed. Try again.",
+ ],
+ [
+ { status: "refresh_failed" },
+ "Update could not be confirmed. Refresh Registry before retrying.",
+ ],
+ ])(
+ "keeps Update available after %j and allows retry",
+ async (result, message) => {
+ // Given
+ const installed = {
+ normalizedName: "aws-guard",
+ versionSpec: "latest",
+ resolvedVersion: "1.0.0",
+ };
+ executeRegistryArtifactAdditionMock.mockResolvedValue(result);
+ const screen = await render(
+ ,
+ );
+ // When
+ await screen
+ .getByRole("button", { name: "Update AWS guard to 1.2.3" })
+ .click();
+ // Then
+ await expect
+ .element(screen.getByText(message as string, { exact: true }))
+ .toBeVisible();
+ await expect
+ .element(
+ screen.getByRole("button", { name: "Update AWS guard to 1.2.3" }),
+ )
+ .toBeEnabled();
+ expect(cardFor("AWS guard").textContent).toContain("1.0.0");
+ expect(document.body.textContent).not.toContain("Artifact updated");
+ // When / Then: retry can complete normally.
+ executeRegistryArtifactAdditionMock.mockResolvedValue({
+ status: "confirmed",
+ tenantArtifacts: [{ ...installed, resolvedVersion: "1.2.3" }],
+ });
+ await screen
+ .getByRole("button", { name: "Update AWS guard to 1.2.3" })
+ .click();
+ await expect
+ .element(screen.getByText("Added", { exact: true }))
+ .toBeVisible();
+ await expect
+ .element(screen.getByText(message as string, { exact: true }))
+ .not.toBeInTheDocument();
+ },
+ );
+
+ it("updates an installed artifact to the displayed version and returns to Added", async () => {
+ // Given
+ const installed = {
+ normalizedName: "aws-guard",
+ versionSpec: "latest",
+ resolvedVersion: "1.0.0",
+ };
+ let complete!: (value: unknown) => void;
+ executeRegistryArtifactAdditionMock.mockReturnValue(
+ new Promise((resolve) => {
+ complete = resolve;
+ }),
+ );
+ const screen = await render(
+ ,
+ );
+ // When
+ await screen
+ .getByRole("button", { name: "Update AWS guard to 1.2.3" })
+ .click();
+ // Then
+ expect(executeRegistryArtifactAdditionMock).toHaveBeenCalledWith({
+ normalizedName: "aws-guard",
+ versionSpec: "1.2.3",
+ operation: "update",
+ });
+ await expect
+ .element(
+ screen.getByRole("button", { name: "Update AWS guard to 1.2.3" }),
+ )
+ .toBeDisabled();
+ await expect
+ .element(screen.getByRole("button", { name: "Remove AWS guard" }))
+ .toBeDisabled();
+ expect(document.body.textContent).toContain("Updating…");
+ complete({
+ status: "confirmed",
+ tenantArtifacts: [{ ...installed, resolvedVersion: "1.2.3" }],
+ });
+ await expect
+ .element(screen.getByText("Added", { exact: true }))
+ .toBeVisible();
+ await expect
+ .element(screen.getByRole("button", { name: "Remove AWS guard" }))
+ .toBeEnabled();
+ expect(cardFor("AWS guard").textContent).not.toContain("1.0.0");
+ });
+
+ it("offers key replacement when the configured Registry rejects access", async () => {
+ const screen = await render(
+ ,
+ );
+ await screen.getByRole("button", { name: "Replace key" }).click();
+ await expect.element(screen.getByRole("dialog")).toBeVisible();
+ await expect.element(screen.getByLabelText("Registry key")).toBeEnabled();
+ });
+
+ beforeEach(() => {
+ worker.use(
+ http.get(
+ "https://cdn.example/prowler-logo.png",
+ () =>
+ new HttpResponse(
+ '',
+ { headers: { "Content-Type": "image/svg+xml" } },
+ ),
+ ),
+ http.get(
+ "https://cdn.example/expired.png",
+ () => new HttpResponse(null, { status: 403 }),
+ ),
+ );
+ originalReplaceState(null, "", "/registry");
+ vi.spyOn(window.history, "replaceState").mockImplementation(
+ (data, unused, url) => {
+ originalReplaceState(data, unused, url);
+ window.dispatchEvent(new PopStateEvent("popstate"));
+ },
+ );
+ disconnectRegistryCredentialMock.mockReset();
+ executeRegistryArtifactAdditionMock.mockReset();
+ refreshRegistryCollectionsMock.mockReset();
+ refreshRegistryCollectionsMock.mockResolvedValue({
+ status: "complete",
+ catalog: readyState.catalog,
+ tenantArtifacts: readyState.tenantArtifacts,
+ });
+ refreshRegistryCredentialMock.mockReset();
+ removeRegistryArtifactMock.mockReset();
+ submitRegistryCredentialMock.mockReset();
+ trackAndPollTaskMock.mockReset();
+ trackAndPollTaskMock.mockResolvedValue({
+ status: "ready",
+ result: { stored: true, error: null },
+ });
+ vi.mocked(registryRouter.replace).mockClear();
+ });
+
+ describe("when Registry access is not connected", () => {
+ it("shows the credential banner instead of a catalog", async () => {
+ // Given / When
+ await render();
+
+ // Then
+ expect(document.body.textContent).toContain(
+ "Connect your Registry API key",
+ );
+ expect(document.body.textContent).toContain(
+ "A Registry API key is required to install artifacts into this workspace.",
+ );
+ expect(document.body.textContent).not.toContain(
+ "preserved tenant artifact",
+ );
+ expect(document.body.textContent).toContain("Explore Prowler Registry");
+ expect(document.body.textContent).not.toContain("Search artifacts");
+ });
+
+ it("lets a replacement key supersede a pending validation from the banner", async () => {
+ // Given: a validation that never settled must not dead-end the user
+ submitRegistryCredentialMock.mockResolvedValue(submittedResult(true));
+ refreshRegistryCredentialMock.mockResolvedValue({
+ status: "status",
+ credential: readyState.credential,
+ });
+ refreshRegistryCollectionsMock.mockResolvedValue({
+ status: "complete",
+ catalog: readyState.catalog,
+ tenantArtifacts: readyState.tenantArtifacts,
+ });
+ const screen = await render(
+ ,
+ );
+
+ // Pending validation leaves the form available for a replacement.
+ expect(document.body.textContent).toContain(
+ "Registry validation in progress",
+ );
+ await expect
+ .element(screen.getByRole("button", { name: "Connect API key" }))
+ .toBeEnabled();
+ expect(document.body.textContent).not.toContain("Search artifacts");
+
+ // When
+ await screen.getByRole("button", { name: "Connect API key" }).click();
+ await screen.getByLabelText("Registry key").fill("replacement-key");
+ await screen
+ .getByRole("button", { name: "Connect", exact: true })
+ .click();
+
+ // Then: the replacement POST supersedes the pending validation
+ await expect
+ .poll(() => submitRegistryCredentialMock.mock.calls)
+ .toEqual([["replacement-key"]]);
+ await expect
+ .element(screen.getByRole("button", { name: "Manage access" }))
+ .toBeVisible();
+ });
+ });
+
+ it("moves focus into the access dialog and returns it to Connect API key", async () => {
+ // Given
+ const screen = await render(
+ ,
+ );
+ const connectButton = screen.getByRole("button", {
+ name: "Connect API key",
+ });
+
+ // When
+ await connectButton.click();
+
+ // Then
+ await expect.element(screen.getByLabelText("Registry key")).toHaveFocus();
+
+ // When
+ await userEvent.keyboard("{Escape}");
+
+ // Then
+ await expect.element(connectButton).toHaveFocus();
+ });
+
+ it("presents the connect dialog with help link and cancel action", async () => {
+ // Given
+ const screen = await render(
+ ,
+ );
+
+ // When
+ await screen.getByRole("button", { name: "Connect API key" }).click();
+
+ // Then
+ await expect
+ .element(
+ screen.getByRole("heading", { name: "Connect Registry API key" }),
+ )
+ .toBeVisible();
+ await expect
+ .element(screen.getByRole("link", { name: "Where do I find my key?" }))
+ .toHaveAttribute("href", "https://registry.private.test/keys");
+
+ // When
+ await screen.getByRole("button", { name: "Cancel", exact: true }).click();
+
+ // Then
+ await expect
+ .element(screen.getByLabelText("Registry key"))
+ .not.toBeInTheDocument();
+ });
+
+ it("preserves the catalog while a watched replacement keeps the form visible and disabled", async () => {
+ // Given
+ const key = "replacement-key";
+ submitRegistryCredentialMock.mockResolvedValue(submittedResult(true));
+ trackAndPollTaskMock.mockReturnValue(new Promise(() => {}));
+ const screen = await render();
+
+ // Access stays available by its accessible name, without a text label.
+ expect(document.body.textContent).not.toContain("API key connected");
+ expect(
+ document.querySelector('button[aria-label="Manage access"]')?.textContent,
+ ).toBe("");
+
+ // When
+ await screen.getByRole("button", { name: "Manage access" }).click();
+ await screen.getByLabelText("Registry key").fill(key);
+ await screen.getByRole("button", { name: "Replace key" }).click();
+
+ // Then: the form stays visible; submit shows a disabled Connecting… state
+ await expect
+ .element(screen.getByRole("button", { name: "Connecting…" }))
+ .toBeDisabled();
+ await expect.element(screen.getByLabelText("Registry key")).toBeDisabled();
+ await expect.element(screen.getByLabelText("Registry key")).toHaveValue("");
+ await expect
+ .element(screen.getByRole("button", { name: "Disconnect" }))
+ .toBeDisabled();
+ expect(document.body.textContent).toContain("Cloud guard");
+ expect(document.body.innerHTML).not.toContain(key);
+ });
+
+ it("shows the invalid-key error inline below the input and keeps the form retry-capable", async () => {
+ // Given
+ submitRegistryCredentialMock.mockResolvedValue(submittedResult());
+ refreshRegistryCredentialMock.mockResolvedValue({
+ status: "status",
+ credential: onboardingState.credential,
+ });
+ const screen = await render(
+ ,
+ );
+
+ // When
+ await screen.getByRole("button", { name: "Connect API key" }).click();
+ await screen.getByLabelText("Registry key").fill("bad-key");
+ await screen.getByRole("button", { name: "Connect", exact: true }).click();
+
+ // Then: the error renders inside the dialog, below the input
+ const dialog = document.querySelector('[role="dialog"]');
+ expect(dialog).not.toBeNull();
+ await expect
+ .poll(() => dialog!.querySelector('[role="alert"]')?.textContent)
+ .toContain("This Registry key is invalid. Check it and try again.");
+ const input = screen.getByLabelText("Registry key").element();
+ const alert = dialog!.querySelector('[role="alert"]');
+ expect(
+ input.compareDocumentPosition(alert!) & Node.DOCUMENT_POSITION_FOLLOWING,
+ ).toBeTruthy();
+
+ // Then: the form is re-enabled for a retry with the key cleared
+ await expect
+ .element(screen.getByRole("button", { name: "Connect", exact: true }))
+ .toBeEnabled();
+ await expect.element(screen.getByLabelText("Registry key")).toBeEnabled();
+ await expect.element(screen.getByLabelText("Registry key")).toHaveValue("");
+ await expect.element(screen.getByLabelText("Registry key")).toHaveFocus();
+
+ // When: a retry submits a fresh key through the same form
+ await screen.getByLabelText("Registry key").fill("second-key");
+ await screen.getByRole("button", { name: "Connect", exact: true }).click();
+
+ // Then
+ await expect
+ .poll(() => submitRegistryCredentialMock.mock.calls)
+ .toEqual([["bad-key"], ["second-key"]]);
+ });
+
+ it("keeps a retry-capable form after a watcher failure", async () => {
+ // Given
+ submitRegistryCredentialMock.mockResolvedValue(submittedResult());
+ trackAndPollTaskMock.mockRejectedValue(new Error("watcher crashed"));
+ const screen = await render(
+ ,
+ );
+
+ // When
+ await screen.getByRole("button", { name: "Connect API key" }).click();
+ await screen.getByLabelText("Registry key").fill("registry-test-key");
+ await screen.getByRole("button", { name: "Connect", exact: true }).click();
+
+ // Then
+ const dialog = document.querySelector('[role="dialog"]');
+ expect(dialog).not.toBeNull();
+ await expect
+ .poll(() => dialog!.querySelector('[role="alert"]')?.textContent)
+ .toContain("Registry key validation could not be completed. Try again.");
+ await expect
+ .element(screen.getByRole("button", { name: "Connect", exact: true }))
+ .toBeEnabled();
+ await expect.element(screen.getByLabelText("Registry key")).toBeEnabled();
+ });
+
+ it("recovers the form with an inline notice when the watch exhausts without settling", async () => {
+ // Given: the watcher gives up while the task is still unsettled (e.g. no
+ // worker consumes the queue) — the tracking result resolves as pending
+ submitRegistryCredentialMock.mockResolvedValue(submittedResult());
+ trackAndPollTaskMock.mockResolvedValue({ status: "pending" });
+ refreshRegistryCredentialMock.mockResolvedValue({
+ status: "status",
+ credential: validationPendingState.credential,
+ });
+ const screen = await render(
+ ,
+ );
+
+ // When
+ await screen.getByRole("button", { name: "Connect API key" }).click();
+ await screen.getByLabelText("Registry key").fill("stuck-key");
+ await screen.getByRole("button", { name: "Connect", exact: true }).click();
+
+ // Then: the dialog exits Connecting… into a retry-capable form
+ const dialog = document.querySelector('[role="dialog"]');
+ expect(dialog).not.toBeNull();
+ await expect
+ .poll(() => dialog!.querySelector('[role="alert"]')?.textContent)
+ .toContain(
+ "Registry key validation is taking longer than expected. Try again.",
+ );
+ await expect
+ .element(screen.getByRole("button", { name: "Connect", exact: true }))
+ .toBeEnabled();
+ await expect.element(screen.getByLabelText("Registry key")).toBeEnabled();
+ expect(document.body.textContent).toContain(
+ "Registry validation in progress",
+ );
+ });
+
+ it("recovers the form when the submit RPC rejects instead of stranding Connecting", async () => {
+ // Given: the server-action RPC itself rejects (network drop, dev reload)
+ submitRegistryCredentialMock.mockRejectedValue(new Error("rpc dropped"));
+ const screen = await render(
+ ,
+ );
+
+ // When
+ await screen.getByRole("button", { name: "Connect API key" }).click();
+ await screen.getByLabelText("Registry key").fill("registry-test-key");
+ await screen.getByRole("button", { name: "Connect", exact: true }).click();
+
+ // Then: no stranded Connecting… — the form recovers with an inline error
+ const dialog = document.querySelector('[role="dialog"]');
+ expect(dialog).not.toBeNull();
+ await expect
+ .poll(() => dialog!.querySelector('[role="alert"]')?.textContent)
+ .toContain("Registry key validation could not be completed. Try again.");
+ await expect
+ .element(screen.getByRole("button", { name: "Connect", exact: true }))
+ .toBeEnabled();
+ await expect.element(screen.getByLabelText("Registry key")).toBeEnabled();
+ });
+
+ it("recovers the form when the post-connect collections RPC rejects", async () => {
+ // Given: validation succeeds but the collections server action rejects
+ submitRegistryCredentialMock.mockResolvedValue(submittedResult());
+ refreshRegistryCredentialMock.mockResolvedValue({
+ status: "status",
+ credential: readyState.credential,
+ });
+ refreshRegistryCollectionsMock.mockRejectedValue(new Error("rpc dropped"));
+ const screen = await render(
+ ,
+ );
+
+ // When
+ await screen.getByRole("button", { name: "Connect API key" }).click();
+ await screen.getByLabelText("Registry key").fill("registry-test-key");
+ await screen.getByRole("button", { name: "Connect", exact: true }).click();
+
+ // Then: no stranded Connecting… — the form recovers with an inline error
+ const dialog = document.querySelector('[role="dialog"]');
+ expect(dialog).not.toBeNull();
+ await expect
+ .poll(() => dialog!.querySelector('[role="alert"]')?.textContent)
+ .toContain("Registry collections could not be loaded. Try again.");
+ await expect
+ .element(screen.getByRole("button", { name: "Connect", exact: true }))
+ .toBeEnabled();
+ });
+
+ it("keeps the dialog open with an inline notice when validation outlasts the watch", async () => {
+ // Given
+ submitRegistryCredentialMock.mockResolvedValue(submittedResult());
+ trackAndPollTaskMock.mockResolvedValue({
+ status: "error",
+ error: "The task expired before it could be tracked to completion.",
+ });
+ refreshRegistryCredentialMock.mockResolvedValue({
+ status: "status",
+ credential: validationPendingState.credential,
+ });
+ const screen = await render(
+ ,
+ );
+
+ // When
+ await screen.getByRole("button", { name: "Connect API key" }).click();
+ await screen.getByLabelText("Registry key").fill("slow-key");
+ await screen.getByRole("button", { name: "Connect", exact: true }).click();
+
+ // Then: an inline notice keeps the retry path available in the dialog
+ const dialog = document.querySelector('[role="dialog"]');
+ expect(dialog).not.toBeNull();
+ await expect
+ .poll(() => dialog!.querySelector('[role="alert"]')?.textContent)
+ .toContain(
+ "Registry key validation is taking longer than expected. Try again.",
+ );
+ await expect
+ .element(screen.getByRole("button", { name: "Connect", exact: true }))
+ .toBeEnabled();
+ // And the underlying banner reflects the pending validation
+ expect(document.body.textContent).toContain(
+ "Registry validation in progress",
+ );
+ });
+
+ it("trims and resets a write-only key before loading authoritative collections", async () => {
+ // Given
+ const key = "registry-test-key";
+ let resolveSubmission: ((result: unknown) => void) | undefined;
+ submitRegistryCredentialMock.mockImplementation(
+ () =>
+ new Promise((resolve) => {
+ resolveSubmission = resolve;
+ }),
+ );
+ refreshRegistryCredentialMock.mockResolvedValue({
+ status: "status",
+ credential: readyState.credential,
+ });
+ refreshRegistryCollectionsMock.mockResolvedValue({
+ status: "complete",
+ catalog: readyState.catalog,
+ tenantArtifacts: readyState.tenantArtifacts,
+ });
+ const screen = await render(
+ ,
+ );
+
+ // When
+ await screen.getByRole("button", { name: "Connect API key" }).click();
+ await screen.getByLabelText("Registry key").fill(` ${key} `);
+ await screen.getByRole("button", { name: "Connect", exact: true }).click();
+
+ // Then: repeat submission is disabled and the key has left the form.
+ await expect
+ .element(screen.getByRole("button", { name: "Connecting…", exact: true }))
+ .toBeDisabled();
+ await expect
+ .poll(() => submitRegistryCredentialMock.mock.calls)
+ .toEqual([[key]]);
+ await expect.element(screen.getByLabelText("Registry key")).toHaveValue("");
+ expect(document.body.innerHTML).not.toContain(key);
+ expect(window.location.href).not.toContain(key);
+ expect(localStorage.getItem("registry-key")).toBeNull();
+ expect(sessionStorage.getItem("registry-key")).toBeNull();
+
+ // When: the accepted task settles through the watcher
+ resolveSubmission?.(submittedResult());
+
+ // Then: the explorer lands in ready state and announces the connection
+ await expect
+ .element(screen.getByRole("tab", { name: /All/ }))
+ .toBeVisible();
+ await expect
+ .element(screen.getByLabelText("Registry key"))
+ .not.toBeInTheDocument();
+ expect(refreshRegistryCredentialMock).toHaveBeenCalledTimes(1);
+ expect(refreshRegistryCollectionsMock).toHaveBeenCalledTimes(1);
+ });
+
+ describe("when a Registry action loses authorization", () => {
+ it("routes to Profile once when Add is denied", async () => {
+ // Given
+ executeRegistryArtifactAdditionMock.mockResolvedValue({
+ status: "access_denied",
+ });
+ const screen = await render(
+ ,
+ );
+
+ // When
+ await screen.getByRole("button", { name: "Add Cloud guard" }).click();
+
+ // Then
+ await expectRedirectedToProfile();
+ });
+
+ it("routes to Profile once when Remove is denied", async () => {
+ // Given
+ removeRegistryArtifactMock.mockResolvedValue({ status: "access_denied" });
+ const screen = await render(
+ ,
+ );
+ await screen.getByRole("tab", { name: /My artifacts/ }).click();
+ await screen.getByRole("button", { name: "Remove AWS guard" }).click();
+
+ // When
+ await screen.getByRole("button", { name: "Confirm Remove" }).click();
+
+ // Then
+ await expectRedirectedToProfile();
+ });
+
+ it("routes to Profile once when credential submission is denied", async () => {
+ // Given
+ submitRegistryCredentialMock.mockResolvedValue({
+ status: "access_denied",
+ });
+ const screen = await render(
+ ,
+ );
+ await screen.getByRole("button", { name: "Connect API key" }).click();
+ await screen.getByLabelText("Registry key").fill("registry-test-key");
+
+ // When
+ await screen
+ .getByRole("button", { name: "Connect", exact: true })
+ .click();
+
+ // Then
+ await expectRedirectedToProfile();
+ });
+
+ it("routes to Profile once when disconnect is denied", async () => {
+ // Given
+ disconnectRegistryCredentialMock.mockResolvedValue({
+ status: "access_denied",
+ });
+ const screen = await render(
+ ,
+ );
+ await screen.getByRole("button", { name: "Manage access" }).click();
+
+ // When
+ await screen.getByRole("button", { name: "Disconnect" }).click();
+
+ // Then
+ await expectRedirectedToProfile();
+ });
+
+ it("routes to Profile once when post-connect collection refresh is denied", async () => {
+ // Given
+ submitRegistryCredentialMock.mockResolvedValue(submittedResult());
+ refreshRegistryCredentialMock.mockResolvedValue({
+ status: "status",
+ credential: readyState.credential,
+ });
+ refreshRegistryCollectionsMock.mockResolvedValue({
+ status: "access_denied",
+ });
+ const screen = await render(
+ ,
+ );
+ await screen.getByRole("button", { name: "Connect API key" }).click();
+ await screen.getByLabelText("Registry key").fill("registry-test-key");
+
+ // When
+ await screen
+ .getByRole("button", { name: "Connect", exact: true })
+ .click();
+
+ // Then
+ await expectRedirectedToProfile();
+ });
+ });
+
+ it("keeps the rendered catalog after a failed credential replacement", async () => {
+ // Given
+ const key = "replacement-key";
+ submitRegistryCredentialMock.mockResolvedValue({
+ status: "replacement_failed",
+ credential: readyState.credential,
+ });
+ const screen = await render();
+
+ // When
+ await screen.getByRole("button", { name: "Manage access" }).click();
+ await screen.getByLabelText("Registry key").fill(key);
+ await screen.getByRole("button", { name: "Replace key" }).click();
+
+ // Then
+ await expect
+ .element(screen.getByRole("dialog"))
+ .toHaveTextContent("Existing access is unchanged");
+ expect(document.body.textContent).toContain("Cloud guard");
+ await expect.element(screen.getByLabelText("Registry key")).toHaveValue("");
+ expect(document.body.innerHTML).not.toContain(key);
+ });
+
+ it("recovers and retries when disconnecting Registry rejects", async () => {
+ // Given
+ disconnectRegistryCredentialMock.mockRejectedValueOnce(
+ new Error("Disconnect transport failed"),
+ );
+ const screen = await render();
+ await screen.getByRole("button", { name: "Manage access" }).click();
+
+ // When
+ await screen.getByRole("button", { name: "Disconnect" }).click();
+
+ // Then
+ await expect
+ .element(screen.getByRole("alert"))
+ .toHaveTextContent(
+ "Registry access could not be disconnected. Try again.",
+ );
+ await expect
+ .element(screen.getByRole("button", { name: "Cancel", exact: true }))
+ .toBeEnabled();
+ expect(document.body.textContent).toContain("Cloud guard");
+ expect(document.body.textContent).not.toContain(
+ "Disconnect transport failed",
+ );
+
+ // When: retry succeeds without reopening the dialog.
+ disconnectRegistryCredentialMock.mockResolvedValueOnce({
+ status: "disconnected",
+ credential: onboardingState.credential,
+ tenantArtifacts: readyState.tenantArtifacts,
+ });
+ await screen.getByRole("button", { name: "Disconnect" }).click();
+
+ // Then
+ await expect
+ .element(screen.getByRole("button", { name: "Connect API key" }))
+ .toBeVisible();
+ });
+
+ it("returns to the credential banner after disconnecting Registry access", async () => {
+ // Given
+ disconnectRegistryCredentialMock.mockResolvedValue({
+ status: "disconnected",
+ credential: onboardingState.credential,
+ tenantArtifacts: readyState.tenantArtifacts,
+ });
+ const screen = await render();
+
+ // When
+ await screen.getByRole("button", { name: "Manage access" }).click();
+ await screen.getByRole("button", { name: "Disconnect" }).click();
+
+ // Then
+ await expect
+ .poll(() => document.body.textContent)
+ .toContain("Connect your Registry API key");
+ expect(document.body.textContent).toContain(
+ "Your 2 preserved tenant artifacts will remain available in My artifacts.",
+ );
+ });
+
+ describe("when the complete catalog is ready", () => {
+ it.each([
+ { isBuiltin: true, isInstallable: false },
+ {
+ isInstallable: false,
+ notInstallableReason: "artifact_defines_nothing_usable",
+ },
+ ])("keeps ineligible artifacts visible without Add: %j", async (flags) => {
+ const state: RegistryBootstrapState = {
+ ...readyState,
+ catalog: {
+ ...readyState.catalog,
+ artifacts: [
+ {
+ ...readyState.catalog.artifacts[2],
+ normalizedName: "ineligible",
+ name: "Ineligible artifact",
+ ...flags,
+ },
+ ],
+ },
+ tenantArtifacts: [],
+ };
+ const screen = await render();
+ expect(document.body.textContent).toContain("Ineligible artifact");
+ await expect
+ .element(
+ screen.getByRole("button", { name: "Add Ineligible artifact" }),
+ )
+ .not.toBeInTheDocument();
+ expect(executeRegistryArtifactAdditionMock).not.toHaveBeenCalled();
+ });
+
+ it("keeps an authoritative built-in membership removable", async () => {
+ // Given
+ removeRegistryArtifactMock.mockResolvedValue({
+ status: "confirmed",
+ tenantArtifacts: [
+ { normalizedName: "saved-artifact", versionSpec: "1.0.0" },
+ ],
+ });
+ const builtInMemberState: RegistryBootstrapState = {
+ ...readyState,
+ catalog: {
+ ...readyState.catalog,
+ artifacts: readyState.catalog.artifacts.map((artifact) =>
+ artifact.normalizedName === "aws-guard"
+ ? { ...artifact, isBuiltin: true }
+ : artifact,
+ ),
+ },
+ };
+ const screen = await render(
+ ,
+ );
+
+ // Then
+ await expect
+ .element(screen.getByRole("status", { name: "Built in" }))
+ .toBeVisible();
+ expect(document.body.textContent).toContain("Added");
+ const removeButton = screen.getByRole("button", {
+ name: "Remove AWS guard",
+ });
+ await expect.element(removeButton).toBeVisible();
+
+ // When
+ await removeButton.click();
+ await screen.getByRole("button", { name: "Confirm Remove" }).click();
+
+ // Then
+ await expect
+ .poll(() => removeRegistryArtifactMock.mock.calls)
+ .toEqual([["aws-guard"]]);
+ });
+
+ it("switches to authoritative My artifacts and back", async () => {
+ // Given
+ const screen = await render(
+ ,
+ );
+
+ await expect
+ .element(screen.getByRole("img", { name: "Prowler", exact: true }))
+ .toBeVisible();
+ await expect
+ .element(screen.getByText("Prowler", { exact: true }))
+ .not.toBeInTheDocument();
+
+ // When
+ await screen.getByRole("tab", { name: /My artifacts/ }).click();
+
+ // Then
+ await expect
+ .poll(() => document.body.textContent)
+ .toContain("saved-artifact");
+ expect(document.body.textContent).toContain("1.0.0");
+ expect(document.body.textContent).not.toContain("Later guard");
+
+ // When
+ await screen.getByRole("tab", { name: /All/ }).click();
+
+ await userEvent.keyboard("{Escape}");
+ // Then
+ await expect
+ .poll(() => document.body.textContent)
+ .toContain("Later guard");
+ });
+
+ it("derives search from all normalized catalog artifacts", async () => {
+ // Given
+ const screen = await render(
+ ,
+ );
+
+ // When
+ await screen.getByLabelText("Search artifacts").fill("later");
+
+ // Then
+ await expect
+ .poll(() => document.body.textContent)
+ .toContain("Later guard");
+ await expect
+ .poll(() => document.body.textContent)
+ .not.toContain("Cloud guard");
+ expect(document.body.textContent).toContain("1 artifact");
+ });
+
+ it("filters complete results by provider", async () => {
+ // Given
+ const screen = await render(
+ ,
+ );
+
+ // When
+ await screen.getByLabelText("Filter by provider").click();
+
+ // When
+ await screen.getByRole("option", { name: "Azure", exact: true }).click();
+
+ // Then
+ await expect
+ .poll(() => document.body.textContent)
+ .toContain("Later guard");
+ await expect
+ .poll(() => document.body.textContent)
+ .not.toContain("Cloud guard");
+ });
+
+ it("combines capability choices and restores the URL with Clear All", async () => {
+ const screen = await render(
+ ,
+ );
+ await screen.getByLabelText("Filter by capability").click();
+ await screen
+ .getByRole("option", { name: "Compliance", exact: true })
+ .click();
+ await userEvent.keyboard("{Escape}");
+ await expect
+ .poll(() => document.body.textContent)
+ .not.toContain("AWS guard");
+ expect(
+ new URLSearchParams(window.location.search).get("filter[capability]"),
+ ).toBe("compliance");
+ await screen.getByRole("button", { name: /Clear/ }).click();
+ await expect.poll(() => document.body.textContent).toContain("AWS guard");
+ expect(window.location.search).toBe("");
+ });
+
+ it("sorts by downloads with name order as the default", async () => {
+ // Given
+ const screen = await render(
+ ,
+ );
+ const order = () => {
+ const text = document.body.textContent ?? "";
+ return [
+ text.indexOf("Cloud guard"),
+ text.indexOf("AWS guard"),
+ text.indexOf("Later guard"),
+ ];
+ };
+
+ // Then: default name order puts AWS guard first
+ expect(order()[1]).toBeLessThan(order()[0]);
+
+ // When
+ await screen.getByLabelText("Sort artifacts").click();
+ await screen.getByRole("option", { name: "Most downloaded" }).click();
+
+ // Then: downloads order puts Cloud guard first
+ await expect.poll(() => order()[0] < order()[1]).toBe(true);
+ await expect.poll(() => order()[1] < order()[2]).toBe(true);
+ });
+
+ it("shows a complete empty catalog without degrading controls", async () => {
+ // Given / When
+ const screen = await render(
+ ,
+ );
+
+ // Then
+ expect(document.body.textContent).toContain(
+ "No Registry artifacts are available.",
+ );
+ await expect
+ .element(screen.getByLabelText("Search artifacts"))
+ .toBeVisible();
+ });
+
+ it("counts logos and pills together toward the four-item cap and overflow", async () => {
+ // Given: six providers alternating known logos and dynamic pills
+ const blendedArtifact = {
+ ...readyState.catalog.artifacts[2],
+ normalizedName: "blended-guard",
+ name: "Blended guard",
+ description: "Artifact spanning logos and pills",
+ latestVersion: "1.0.0",
+ providers: [
+ "aws",
+ "template",
+ "azure",
+ "custom-scan",
+ "gcp",
+ "local_thing",
+ ],
+ };
+
+ // When
+ const screen = await render(
+ ,
+ );
+
+ // Then: the cap keeps the first four items of BOTH kinds, so only the
+ // first two pills are visible and two items collapse into "+2".
+ const blendedCard = cardFor("Blended guard");
+ expect(blendedCard.textContent).toContain("6 providers");
+ expect(blendedCard.innerText).toContain("+2");
+ await expect
+ .element(
+ screen.getByRole("listitem").getByText("Template", { exact: true }),
+ )
+ .toBeVisible();
+ await expect
+ .element(
+ screen
+ .getByRole("listitem")
+ .getByText("Custom Scan", { exact: true }),
+ )
+ .toBeVisible();
+ await expect
+ .element(
+ screen
+ .getByRole("listitem")
+ .getByText("Local Thing", { exact: true }),
+ )
+ .not.toBeInTheDocument();
+ // Even collapsed providers remain named for assistive technology.
+ expect(blendedCard.textContent).toContain(
+ "Providers: AWS, Template, Azure, Custom Scan, Google Cloud, Local Thing",
+ );
+ });
+
+ it("groups artifact metadata, preserves zero counts, and hides missing values", async () => {
+ // Given
+ const artifact = {
+ ...readyState.catalog.artifacts[0],
+ isAdded: false,
+ updateAvailable: false,
+ extendsProviderSlugs: [],
+ checkCount: 645,
+ complianceCount: 45,
+ };
+ const screen = await render(
+ {}}
+ onRemove={() => {}}
+ />,
+ );
+
+ // Then: counts, version, and downloads share one labelled metadata block.
+ const metadata = screen.getByRole("group", { name: "Artifact metadata" });
+ await expect.element(metadata).toBeVisible();
+ for (const label of ["Compliance", "Checks", "Version", "Downloads"]) {
+ await expect
+ .element(metadata.getByText(label, { exact: true }))
+ .toBeVisible();
+ }
+ await expect
+ .element(metadata.getByText("45", { exact: true }))
+ .toBeVisible();
+ await expect
+ .element(metadata.getByText("645", { exact: true }))
+ .toBeVisible();
+ await expect
+ .element(metadata.getByText(artifact.latestVersion!, { exact: true }))
+ .toBeVisible();
+ await expect
+ .element(metadata.getByText("12", { exact: true }))
+ .toBeVisible();
+ await expect
+ .element(screen.getByText("45 Compliance", { exact: true }))
+ .not.toBeInTheDocument();
+
+ // When / Then: zero is a known count for every metric.
+ await screen.rerender(
+ {}}
+ onRemove={() => {}}
+ />,
+ );
+ expect(metadata.element().querySelectorAll("dd")).toHaveLength(4);
+ expect(
+ Array.from(
+ metadata.element().querySelectorAll("dd"),
+ (value) => value.textContent,
+ ),
+ ).toEqual(["0", "0", artifact.latestVersion, "0"]);
+
+ // When / Then: built-in artifacts keep their metadata without downloads.
+ await screen.rerender(
+ {}}
+ onRemove={() => {}}
+ />,
+ );
+ await expect
+ .element(metadata.getByText("Downloads", { exact: true }))
+ .not.toBeInTheDocument();
+ expect(
+ Array.from(
+ metadata.element().querySelectorAll("dd"),
+ (value) => value.textContent,
+ ),
+ ).toEqual(["45", "645", artifact.latestVersion]);
+
+ // When / Then: older responses have no counts, rather than zero counts.
+ await screen.rerender(
+ {}}
+ onRemove={() => {}}
+ />,
+ );
+ await expect
+ .element(metadata.getByText("Compliance", { exact: true }))
+ .not.toBeInTheDocument();
+ await expect
+ .element(metadata.getByText("Checks", { exact: true }))
+ .not.toBeInTheDocument();
+ await expect
+ .element(metadata.getByText("Version", { exact: true }))
+ .not.toBeInTheDocument();
+ await expect
+ .element(metadata.getByText("Downloads", { exact: true }))
+ .toBeVisible();
+ });
+
+ it("recovers the owner image when a fresh URL replaces an expired one", async () => {
+ const artifact = {
+ ...readyState.catalog.artifacts[0],
+ isAdded: false,
+ updateAvailable: false,
+ extendsProviderSlugs: [],
+ owners: [
+ {
+ name: "Registry team",
+ type: "organization",
+ logoUrl: "https://cdn.example/expired.png",
+ },
+ ],
+ };
+ const screen = await render(
+ {}}
+ onRemove={() => {}}
+ />,
+ );
+ await expect
+ .element(screen.getByText("R", { exact: true }))
+ .toBeVisible();
+ await screen.rerender(
+ {}}
+ onRemove={() => {}}
+ />,
+ );
+ await expect
+ .poll(() =>
+ document.querySelector(
+ 'img[src="https://cdn.example/prowler-logo.png"]',
+ ),
+ )
+ .not.toBeNull();
+ expect(document.body.textContent).toContain("Registry team");
+ });
+ });
+
+ it("keeps ordinary Add errors local without redirecting to Profile", async () => {
+ // Given
+ executeRegistryArtifactAdditionMock.mockResolvedValue({ status: "error" });
+ const screen = await render();
+
+ // When
+ await screen.getByRole("button", { name: "Add Cloud guard" }).click();
+
+ // Then
+ await expect
+ .element(screen.getByRole("alert"))
+ .toHaveTextContent("Registry operation could not be completed");
+ await expect
+ .element(screen.getByRole("button", { name: "Add Cloud guard" }))
+ .toBeEnabled();
+ expect(registryRouter.replace).not.toHaveBeenCalled();
+ });
+
+ it("adds the latest version directly from the card once confirmed", async () => {
+ // Given
+ executeRegistryArtifactAdditionMock.mockResolvedValue({
+ status: "confirmed",
+ tenantArtifacts: [
+ { normalizedName: "aws-guard", versionSpec: "latest" },
+ { normalizedName: "saved-artifact", versionSpec: "1.0.0" },
+ { normalizedName: "cloud-guard", versionSpec: "latest" },
+ ],
+ });
+ const screen = await render();
+ expect(document.body.textContent).not.toContain("Artifact added");
+
+ // When
+ await screen.getByRole("button", { name: "Add Cloud guard" }).click();
+
+ // Then
+ await expect
+ .poll(() => executeRegistryArtifactAdditionMock.mock.calls)
+ .toEqual([[{ normalizedName: "cloud-guard" }]]);
+ // The confirmed membership now offers Remove instead of Add on the card.
+ await expect
+ .element(screen.getByRole("button", { name: "Remove Cloud guard" }))
+ .toBeVisible();
+ await expect
+ .element(screen.getByRole("tab", { name: /My artifacts/ }))
+ .toHaveTextContent("3");
+ });
+
+ it("keeps membership unchanged when an accepted Add cannot be confirmed", async () => {
+ // Given
+ executeRegistryArtifactAdditionMock.mockResolvedValue({
+ status: "refresh_failed",
+ });
+ const screen = await render();
+
+ // When
+ await screen.getByRole("button", { name: "Add Cloud guard" }).click();
+
+ // Then
+ await expect
+ .poll(() => document.body.textContent)
+ .toContain("Registry membership could not be confirmed");
+ await expect
+ .element(screen.getByRole("button", { name: "Add Cloud guard" }))
+ .toBeEnabled();
+ await expect
+ .element(screen.getByRole("tab", { name: /My artifacts/ }))
+ .toHaveTextContent("2");
+ });
+
+ it("keeps documented Add refusals local without redirecting to Profile", async () => {
+ // Given
+ executeRegistryArtifactAdditionMock.mockResolvedValue({
+ status: "refused",
+ message: "This version is not verified and cannot be added.",
+ });
+ const screen = await render();
+
+ // When
+ await screen.getByRole("button", { name: "Add Cloud guard" }).click();
+
+ // Then
+ await expect
+ .poll(() => document.body.textContent)
+ .toContain("This version is not verified and cannot be added.");
+ await expect
+ .element(screen.getByRole("button", { name: "Add Cloud guard" }))
+ .toBeEnabled();
+ expect(registryRouter.replace).not.toHaveBeenCalled();
+ });
+
+ it("disables all Add buttons while an Add confirmation is pending", async () => {
+ // Given
+ executeRegistryArtifactAdditionMock.mockReturnValue(new Promise(() => {}));
+ const screen = await render();
+ const addCloudGuard = screen.getByRole("button", {
+ name: "Add Cloud guard",
+ });
+
+ // When
+ await addCloudGuard.click();
+
+ // Then
+ await expect.element(addCloudGuard).toBeDisabled();
+ await expect.element(addCloudGuard).toHaveTextContent("Adding…");
+ await expect
+ .element(screen.getByRole("button", { name: "Add Later guard" }))
+ .toBeDisabled();
+ expect(executeRegistryArtifactAdditionMock).toHaveBeenCalledTimes(1);
+ });
+
+ it("requires confirmation before Remove and commits only after confirmation", async () => {
+ // Given
+ removeRegistryArtifactMock.mockResolvedValue({
+ status: "confirmed",
+ tenantArtifacts: [
+ { normalizedName: "saved-artifact", versionSpec: "1.0.0" },
+ ],
+ });
+ const screen = await render();
+ await screen.getByRole("tab", { name: /My artifacts/ }).click();
+
+ // When
+ await screen.getByRole("button", { name: "Remove AWS guard" }).click();
+
+ // Then
+ await expect
+ .element(screen.getByRole("button", { name: "Confirm Remove" }))
+ .toBeVisible();
+ expect(removeRegistryArtifactMock).not.toHaveBeenCalled();
+
+ // When
+ await screen.getByRole("button", { name: "Cancel" }).click();
+
+ // Then
+ expect(removeRegistryArtifactMock).not.toHaveBeenCalled();
+
+ // When
+ await screen.getByRole("button", { name: "Remove AWS guard" }).click();
+ await screen.getByRole("button", { name: "Confirm Remove" }).click();
+
+ // Then
+ await expect
+ .poll(() => removeRegistryArtifactMock.mock.calls)
+ .toEqual([["aws-guard"]]);
+ await expect
+ .poll(() => document.body.textContent)
+ .toContain("Artifact removed");
+ });
+
+ it("moves focus into Remove confirmation and returns it to the invoking card button", async () => {
+ // Given: the tenant-only artifact carries its own card Remove action
+ const screen = await render();
+ await screen.getByRole("tab", { name: /My artifacts/ }).click();
+ const removeButton = screen.getByRole("button", {
+ name: "Remove saved-artifact",
+ });
+
+ // When
+ await removeButton.click();
+
+ // Then
+ await expect
+ .element(screen.getByRole("button", { name: "Cancel" }))
+ .toHaveFocus();
+
+ // When
+ await userEvent.keyboard("{Escape}");
+
+ // Then
+ await expect.element(removeButton).toHaveFocus();
+ });
+
+ it("disables duplicate Remove submission while confirmation is pending", async () => {
+ // Given
+ removeRegistryArtifactMock.mockReturnValue(new Promise(() => {}));
+ const screen = await render();
+ await screen.getByRole("tab", { name: /My artifacts/ }).click();
+ await screen.getByRole("button", { name: "Remove AWS guard" }).click();
+
+ // When
+ await screen.getByRole("button", { name: "Confirm Remove" }).click();
+
+ // Then
+ await expect
+ .element(screen.getByRole("button", { name: "Removing artifact" }))
+ .toBeDisabled();
+ expect(removeRegistryArtifactMock).toHaveBeenCalledTimes(1);
+ });
+
+ it("shows an in-use Remove error only inside the dialog with recovery actions", async () => {
+ // Given
+ let resolveRemoval!: (result: RegistryArtifactRemovalResult) => void;
+ removeRegistryArtifactMock.mockReturnValue(
+ new Promise((resolve) => {
+ resolveRemoval = resolve;
+ }),
+ );
+ const screen = await render();
+ await screen.getByRole("tab", { name: /My artifacts/ }).click();
+ await screen.getByRole("button", { name: "Remove AWS guard" }).click();
+
+ // When
+ await screen.getByRole("button", { name: "Confirm Remove" }).click();
+ await expect
+ .element(screen.getByRole("button", { name: "Removing artifact" }))
+ .toBeDisabled();
+ resolveRemoval({ status: "in_use" });
+
+ // Then
+ const dialog = screen.getByRole("dialog", { name: "Remove artifact" });
+ await expect
+ .element(dialog.getByRole("alert"))
+ .toHaveTextContent("Artifact in use");
+ await expect
+ .element(dialog.getByRole("alert"))
+ .toHaveTextContent(
+ "This artifact cannot be removed because one or more providers use it. Review the associated providers before trying again.",
+ );
+ expect(document.querySelectorAll('[role="alert"]')).toHaveLength(1);
+ await expect
+ .element(dialog.getByRole("button", { name: "Confirm Remove" }))
+ .not.toBeInTheDocument();
+ await expect
+ .element(dialog.getByRole("button", { name: "View providers" }))
+ .toBeVisible();
+ await expect
+ .poll(() => dialog.element().contains(document.activeElement))
+ .toBe(true);
+ expect(cardFor("AWS guard").textContent).toContain("Remove");
+ expect(document.body.textContent).not.toContain("Artifact removed");
+ expect(document.body.textContent).not.toContain(
+ "Existing provider accounts will remain",
+ );
+ });
+
+ it("treats a running scan as a wait, never as a reason to delete providers", async () => {
+ // Given
+ removeRegistryArtifactMock
+ .mockResolvedValueOnce({ status: "busy" })
+ .mockResolvedValueOnce({
+ status: "confirmed",
+ tenantArtifacts: [],
+ });
+ const screen = await render();
+ await screen.getByRole("tab", { name: /My artifacts/ }).click();
+ await screen.getByRole("button", { name: "Remove AWS guard" }).click();
+
+ // When
+ await screen.getByRole("button", { name: "Confirm Remove" }).click();
+
+ // Then
+ const dialog = screen.getByRole("dialog", { name: "Remove artifact" });
+ await expect
+ .element(dialog.getByRole("alert"))
+ .toHaveTextContent("A scan is using this artifact");
+ await expect
+ .element(dialog.getByRole("button", { name: "View providers" }))
+ .not.toBeInTheDocument();
+
+ // When: the scan finished, so the same dialog can simply try again.
+ await dialog.getByRole("button", { name: "Confirm Remove" }).click();
+
+ // Then
+ await expect.element(dialog).not.toBeInTheDocument();
+ expect(removeRegistryArtifactMock).toHaveBeenCalledTimes(2);
+ });
+
+ it("clears the in-use Remove error on close and restores focus before reopening", async () => {
+ // Given
+ removeRegistryArtifactMock.mockResolvedValue({ status: "in_use" });
+ const screen = await render();
+ await screen.getByRole("tab", { name: /My artifacts/ }).click();
+ const removeButton = screen.getByRole("button", {
+ name: "Remove AWS guard",
+ });
+ await removeButton.click();
+ await screen.getByRole("button", { name: "Confirm Remove" }).click();
+ const dialog = screen.getByRole("dialog", { name: "Remove artifact" });
+ await expect.element(dialog.getByRole("alert")).toBeVisible();
+
+ // When: use the footer Close action, not the modal's icon button
+ await dialog
+ .getByRole("button", { name: "Close", exact: true })
+ .first()
+ .click();
+
+ // Then
+ await expect.element(dialog).not.toBeInTheDocument();
+ await expect.element(removeButton).toHaveFocus();
+ expect(document.querySelectorAll('[role="alert"]')).toHaveLength(0);
+
+ // When
+ await removeButton.click();
+
+ // Then
+ await expect.element(dialog.getByRole("alert")).not.toBeInTheDocument();
+ await expect
+ .element(dialog.getByRole("button", { name: "Confirm Remove" }))
+ .toBeEnabled();
+
+ // When
+ await dialog.getByRole("button", { name: "Cancel" }).click();
+ await screen.getByRole("button", { name: "Remove saved-artifact" }).click();
+
+ // Then
+ await expect.element(dialog.getByRole("alert")).not.toBeInTheDocument();
+ await expect
+ .element(dialog.getByRole("button", { name: "Confirm Remove" }))
+ .toBeEnabled();
+ });
+
+ it("opens Providers from an in-use Remove error without another deletion", async () => {
+ // Given
+ removeRegistryArtifactMock.mockResolvedValue({ status: "in_use" });
+ const screen = await render();
+ await screen.getByRole("tab", { name: /My artifacts/ }).click();
+ await screen.getByRole("button", { name: "Remove AWS guard" }).click();
+ await screen.getByRole("button", { name: "Confirm Remove" }).click();
+
+ // When
+ await screen.getByRole("button", { name: "View providers" }).click();
+
+ // Then
+ expect(registryRouter.push).toHaveBeenCalledWith("/providers");
+ expect(removeRegistryArtifactMock).toHaveBeenCalledTimes(1);
+ });
+
+ it("recovers inside the Remove dialog when the server action rejects", async () => {
+ // Given
+ removeRegistryArtifactMock.mockRejectedValueOnce(
+ new Error("Failed to fetch"),
+ );
+ const screen = await render();
+ await screen.getByRole("tab", { name: /My artifacts/ }).click();
+ await screen.getByRole("button", { name: "Remove AWS guard" }).click();
+
+ // When
+ await screen.getByRole("button", { name: "Confirm Remove" }).click();
+
+ // Then
+ const dialog = screen.getByRole("dialog", { name: "Remove artifact" });
+ await expect
+ .element(dialog.getByRole("alert"))
+ .toHaveTextContent(
+ "The Registry operation could not be completed. Try again.",
+ );
+ await expect
+ .element(dialog.getByRole("button", { name: "Confirm Remove" }))
+ .toBeEnabled();
+ await expect
+ .element(dialog.getByRole("button", { name: "Cancel" }))
+ .toBeEnabled();
+ expect(document.querySelectorAll('[role="alert"]')).toHaveLength(1);
+ expect(document.body.textContent).not.toContain("Artifact removed");
+ });
+
+ it("clears a Remove error while retrying and commits only after confirmation", async () => {
+ // Given
+ let resolveRemoval!: (result: RegistryArtifactRemovalResult) => void;
+ removeRegistryArtifactMock
+ .mockResolvedValueOnce({ status: "error" })
+ .mockReturnValueOnce(
+ new Promise((resolve) => {
+ resolveRemoval = resolve;
+ }),
+ );
+ const screen = await render();
+ await screen.getByRole("tab", { name: /My artifacts/ }).click();
+ await screen.getByRole("button", { name: "Remove AWS guard" }).click();
+ await screen.getByRole("button", { name: "Confirm Remove" }).click();
+ const dialog = screen.getByRole("dialog", { name: "Remove artifact" });
+ await expect.element(dialog.getByRole("alert")).toBeVisible();
+ expect(document.querySelectorAll('[role="alert"]')).toHaveLength(1);
+
+ // When
+ await dialog.getByRole("button", { name: "Confirm Remove" }).click();
+
+ // Then
+ await expect.element(dialog.getByRole("alert")).not.toBeInTheDocument();
+ await expect
+ .element(dialog.getByRole("button", { name: "Removing artifact" }))
+ .toBeDisabled();
+ expect(cardFor("AWS guard").textContent).toContain("Remove");
+ expect(document.body.textContent).not.toContain("Artifact removed");
+ expect(removeRegistryArtifactMock).toHaveBeenCalledTimes(2);
+
+ // When
+ await userEvent.keyboard("{Escape}");
+
+ // Then
+ await expect.element(dialog).toBeVisible();
+
+ // When
+ resolveRemoval({
+ status: "confirmed",
+ tenantArtifacts: [
+ { normalizedName: "saved-artifact", versionSpec: "1.0.0" },
+ ],
+ });
+
+ // Then
+ await expect.element(dialog).not.toBeInTheDocument();
+ await expect
+ .element(screen.getByRole("button", { name: "Remove AWS guard" }))
+ .not.toBeInTheDocument();
+ await expect
+ .poll(() => document.body.textContent)
+ .toContain("Artifact removed");
+ expect(document.querySelectorAll('[role="alert"]')).toHaveLength(0);
+ });
+
+ it("keeps My artifacts visible when a Remove refresh cannot confirm absence", async () => {
+ // Given
+ removeRegistryArtifactMock.mockResolvedValue({ status: "refresh_failed" });
+ const screen = await render();
+ await screen.getByRole("tab", { name: /My artifacts/ }).click();
+ await screen.getByRole("button", { name: "Remove AWS guard" }).click();
+
+ // When
+ await screen.getByRole("button", { name: "Confirm Remove" }).click();
+
+ // Then
+ const dialog = screen.getByRole("dialog", { name: "Remove artifact" });
+ await expect
+ .element(dialog.getByRole("alert"))
+ .toHaveTextContent("Registry membership could not be confirmed");
+ expect(document.querySelectorAll('[role="alert"]')).toHaveLength(1);
+ await expect
+ .element(screen.getByRole("button", { name: "Confirm Remove" }))
+ .toBeVisible();
+ });
+
+ describe("when complete catalog data is unavailable", () => {
+ it("keeps incomplete catalog controls and metrics hidden while exposing Retry", async () => {
+ // Given / When
+ await render();
+
+ // Then
+ expect(document.body.textContent).toContain(
+ "Registry catalog is incomplete",
+ );
+ expect(document.body.textContent).toContain("Retry");
+ expect(document.body.textContent).not.toContain("Search artifacts");
+ });
+
+ it("labels documented unavailability as stale and leaves generic errors generic", async () => {
+ // Given / When
+ await render(
+ ,
+ );
+
+ // Then
+ expect(document.body.textContent).toContain("stale or unavailable");
+
+ // Given / When
+ await render();
+
+ // Then
+ expect(document.body.textContent).toContain("unexpected Registry error");
+ expect(document.body.textContent).not.toContain("Reconnect Registry");
+ });
+ });
+});
diff --git a/ui/components/registry/registry-explorer.model.test.ts b/ui/components/registry/registry-explorer.model.test.ts
new file mode 100644
index 0000000000..ddc4c7e847
--- /dev/null
+++ b/ui/components/registry/registry-explorer.model.test.ts
@@ -0,0 +1,304 @@
+import { describe, expect, it } from "vitest";
+
+import type { RegistryCatalogArtifact } from "@/types/registry";
+
+import { buildRegistryMarketplaceModel } from "./registry-explorer.model";
+
+const artifact = (
+ normalizedName: string,
+ overrides: Partial = {},
+): RegistryCatalogArtifact => ({
+ normalizedName,
+ name: normalizedName,
+ providers: [],
+ isVerified: false,
+ isOfficial: false,
+ isBuiltin: false,
+ isMeta: false,
+ hasProvider: false,
+ hasChecks: false,
+ hasCompliance: false,
+ isInstallable: false,
+ versionCount: 0,
+ totalDownloads: 0,
+ owners: [],
+ ...overrides,
+});
+
+describe("Registry marketplace model", () => {
+ it("reports a newer release for an installed checks artifact that defines no provider", () => {
+ // Given
+ const catalog = {
+ status: "complete" as const,
+ artifacts: [
+ artifact("aws-checks", {
+ latestVersion: "0.3.0",
+ hasChecks: true,
+ isInstallable: true,
+ }),
+ ],
+ };
+
+ // When
+ const model = buildRegistryMarketplaceModel(
+ catalog,
+ [
+ {
+ normalizedName: "aws-checks",
+ versionSpec: "latest",
+ resolvedVersion: "0.2.2",
+ extendsProviderSlugs: ["aws"],
+ },
+ ],
+ {},
+ "name",
+ );
+
+ // Then
+ expect(model).toMatchObject({
+ artifacts: [{ updateAvailable: true, extendsProviderSlugs: ["aws"] }],
+ myArtifacts: [
+ {
+ extendsProviderSlugs: ["aws"],
+ catalogArtifact: { extendsProviderSlugs: ["aws"] },
+ },
+ ],
+ });
+ });
+
+ it("offers the catalog version for an installed artifact with a different resolved version", () => {
+ // Given
+ const catalog = {
+ status: "complete" as const,
+ artifacts: [
+ artifact("template", { latestVersion: " 1.1.0 ", hasProvider: true }),
+ ],
+ };
+ // When
+ const model = buildRegistryMarketplaceModel(
+ catalog,
+ [
+ {
+ normalizedName: "template",
+ versionSpec: "latest",
+ resolvedVersion: " 1.0.0 ",
+ },
+ ],
+ {},
+ "name",
+ );
+ // Then
+ expect(model).toMatchObject({
+ artifacts: [
+ {
+ isAdded: true,
+ resolvedVersion: "1.0.0",
+ latestVersion: "1.1.0",
+ updateAvailable: true,
+ },
+ ],
+ myArtifacts: [{ catalogArtifact: { updateAvailable: true } }],
+ });
+ });
+
+ it.each([
+ { resolvedVersion: "1.0.0", latestVersion: "1.0.0", expected: false },
+ { resolvedVersion: "2.0.0", latestVersion: "1.0.0", expected: true },
+ { resolvedVersion: undefined, latestVersion: "1.0.0", expected: false },
+ { resolvedVersion: " ", latestVersion: "1.0.0", expected: false },
+ { resolvedVersion: "1.0.0", latestVersion: undefined, expected: false },
+ ])(
+ "compares resolved $resolvedVersion against catalog $latestVersion ($expected)",
+ (example) => {
+ // Given / When
+ const model = buildRegistryMarketplaceModel(
+ {
+ status: "complete",
+ artifacts: [artifact("template", { hasProvider: true, ...example })],
+ },
+ [
+ {
+ normalizedName: "template",
+ versionSpec: "latest",
+ resolvedVersion: example.resolvedVersion,
+ },
+ ],
+ {},
+ "name",
+ );
+ // Then
+ expect(model).toMatchObject({
+ artifacts: [{ updateAvailable: example.expected }],
+ });
+ },
+ );
+
+ it("keeps the full catalog visible with tenant membership merged in", () => {
+ // Given
+
+ const catalog = {
+ status: "complete" as const,
+ artifacts: [
+ artifact("zeta", { providers: ["azure"], hasProvider: true }),
+ artifact("core", { providers: ["aws"], isOfficial: true }),
+ artifact("global", {
+ name: "Global insight",
+ description: "Security checks",
+ providers: ["aws", "gcp"],
+ hasChecks: true,
+ isOfficial: true,
+ }),
+ ],
+ };
+
+ const mine = [
+ { normalizedName: "core", versionSpec: "latest" },
+ { normalizedName: "manual", versionSpec: "1.2.3" },
+ ];
+
+ // When
+ const model = buildRegistryMarketplaceModel(catalog, mine, {}, "name");
+
+ // Then
+
+ expect(model).toMatchObject({
+ isComplete: true,
+ providers: ["aws", "azure", "gcp"],
+ });
+ if (!model.isComplete) throw new Error("expected complete model");
+
+ expect(
+ model.artifacts.map(({ normalizedName, isAdded }) => ({
+ normalizedName,
+ isAdded,
+ })),
+ ).toEqual([
+ { normalizedName: "core", isAdded: true },
+ { normalizedName: "global", isAdded: false },
+ { normalizedName: "zeta", isAdded: false },
+ ]);
+
+ expect(model.myArtifacts).toEqual([
+ {
+ normalizedName: "core",
+ versionSpec: "latest",
+ extendsProviderSlugs: [],
+ catalogArtifact: expect.objectContaining({
+ normalizedName: "core",
+ isAdded: true,
+ }),
+ },
+ {
+ normalizedName: "manual",
+ versionSpec: "1.2.3",
+ extendsProviderSlugs: [],
+ catalogArtifact: undefined,
+ },
+ ]);
+ });
+
+ it("applies search, provider, and capability filters together", () => {
+ // Given
+
+ const catalog = {
+ status: "complete" as const,
+ artifacts: [
+ artifact("core", { providers: ["aws"] }),
+ artifact("global", {
+ name: "Global insight",
+ description: "Security checks",
+ providers: ["aws", "gcp"],
+ hasChecks: true,
+ }),
+ artifact("zeta", { providers: ["azure"], hasProvider: true }),
+ ],
+ };
+
+ // When
+
+ const model = buildRegistryMarketplaceModel(
+ catalog,
+ [],
+ { search: "security", providers: ["aws"], capabilities: ["checks"] },
+ "name",
+ );
+
+ // Then
+ if (!model.isComplete) throw new Error("expected complete model");
+ expect(model.artifacts.map(({ normalizedName }) => normalizedName)).toEqual(
+ ["global"],
+ );
+ });
+
+ it("unions providers and capabilities within each filter", () => {
+ // Given
+ const catalog = {
+ status: "complete" as const,
+ artifacts: [
+ artifact("aws-checks", { providers: ["aws"], hasChecks: true }),
+ artifact("gcp-provider", { providers: ["gcp"], hasProvider: true }),
+ artifact("azure-checks", { providers: ["azure"], hasChecks: true }),
+ ],
+ };
+ // When
+ const model = buildRegistryMarketplaceModel(
+ catalog,
+ [],
+ { providers: ["aws", "gcp"], capabilities: ["checks", "provider"] },
+ "name",
+ );
+ // Then
+ expect(model).toMatchObject({
+ artifacts: [
+ expect.objectContaining({ normalizedName: "aws-checks" }),
+ expect.objectContaining({ normalizedName: "gcp-provider" }),
+ ],
+ });
+ });
+
+ it("sorts by downloads descending with name as the tiebreak", () => {
+ // Given
+
+ const catalog = {
+ status: "complete" as const,
+ artifacts: [
+ artifact("alpha", { totalDownloads: 5 }),
+ artifact("delta", { totalDownloads: 9 }),
+ artifact("beta", { totalDownloads: 5 }),
+ ],
+ };
+
+ // When
+ const model = buildRegistryMarketplaceModel(catalog, [], {}, "downloads");
+
+ // Then
+ if (!model.isComplete) throw new Error("expected complete model");
+ expect(model.artifacts.map(({ normalizedName }) => normalizedName)).toEqual(
+ ["delta", "alpha", "beta"],
+ );
+ });
+
+ it("keeps incomplete catalogs out of complete-only controls and selectors", () => {
+ // Given
+
+ const catalog = {
+ status: "incomplete" as const,
+ reason: "page_failed" as const,
+ collectedCount: 3,
+ };
+
+ // When
+ const model = buildRegistryMarketplaceModel(
+ catalog,
+ [],
+ { search: "core" },
+ "name",
+ );
+
+ // Then
+
+ expect(model).toEqual({
+ isComplete: false,
+ });
+ });
+});
diff --git a/ui/components/registry/registry-explorer.model.ts b/ui/components/registry/registry-explorer.model.ts
new file mode 100644
index 0000000000..aa2ab8a822
--- /dev/null
+++ b/ui/components/registry/registry-explorer.model.ts
@@ -0,0 +1,156 @@
+import {
+ REGISTRY_CATALOG,
+ type RegistryCatalogArtifact,
+ type RegistryCatalogResult,
+ type RegistryTenantArtifact,
+} from "@/types/registry";
+
+export const REGISTRY_CATALOG_CAPABILITY = {
+ CHECKS: "checks",
+ COMPLIANCE: "compliance",
+ PROVIDER: "provider",
+} as const;
+
+export type RegistryCatalogCapability =
+ (typeof REGISTRY_CATALOG_CAPABILITY)[keyof typeof REGISTRY_CATALOG_CAPABILITY];
+
+export const REGISTRY_CAPABILITY_LABELS = {
+ checks: "Checks",
+ compliance: "Compliance",
+ provider: "Provider",
+} as const satisfies Record;
+
+export interface RegistryExplorerFilters {
+ search?: string;
+ providers?: string[];
+ capabilities?: RegistryCatalogCapability[];
+}
+
+export const REGISTRY_MARKETPLACE_SORT = {
+ NAME: "name",
+ DOWNLOADS: "downloads",
+} as const;
+
+export type RegistryMarketplaceSort =
+ (typeof REGISTRY_MARKETPLACE_SORT)[keyof typeof REGISTRY_MARKETPLACE_SORT];
+
+export interface RegistryMarketplaceArtifact extends RegistryCatalogArtifact {
+ isAdded: boolean;
+ resolvedVersion?: string;
+ updateAvailable: boolean;
+ /** Built-in providers the install adds checks to; empty until installed. */
+ extendsProviderSlugs: string[];
+}
+
+export interface RegistryMarketplaceMyArtifact extends RegistryTenantArtifact {
+ catalogArtifact?: RegistryMarketplaceArtifact;
+}
+
+export interface RegistryMarketplaceIncompleteModel {
+ isComplete: false;
+}
+
+export interface RegistryMarketplaceCompleteModel {
+ isComplete: true;
+ artifacts: RegistryMarketplaceArtifact[];
+ providers: string[];
+ myArtifacts: RegistryMarketplaceMyArtifact[];
+}
+
+export type RegistryMarketplaceModel =
+ | RegistryMarketplaceIncompleteModel
+ | RegistryMarketplaceCompleteModel;
+
+export function buildRegistryMarketplaceModel(
+ catalog: RegistryCatalogResult,
+ myArtifacts: RegistryTenantArtifact[],
+ filters: RegistryExplorerFilters,
+ sort: RegistryMarketplaceSort,
+): RegistryMarketplaceModel {
+ if (catalog.status !== REGISTRY_CATALOG.COMPLETE)
+ return {
+ isComplete: false,
+ };
+ const installedArtifacts = new Map(
+ myArtifacts.map((artifact) => [artifact.normalizedName, artifact]),
+ );
+ const merged = new Map(
+ catalog.artifacts.map((artifact) => {
+ const installed = installedArtifacts.get(artifact.normalizedName);
+ const resolvedVersion = installed?.resolvedVersion?.trim() || undefined;
+ const latestVersion = artifact.latestVersion?.trim() || undefined;
+ return [
+ artifact.normalizedName,
+ {
+ ...artifact,
+ latestVersion,
+ resolvedVersion,
+ isAdded: Boolean(installed),
+ // Versions alone: a checks artifact defines no provider yet updates.
+ updateAvailable: Boolean(
+ resolvedVersion &&
+ latestVersion &&
+ resolvedVersion !== latestVersion,
+ ),
+ extendsProviderSlugs: installed?.extendsProviderSlugs ?? [],
+ },
+ ];
+ }),
+ );
+ const artifacts = Array.from(merged.values())
+ .filter((artifact) => matches(artifact, filters))
+ .sort((left, right) =>
+ sort === REGISTRY_MARKETPLACE_SORT.DOWNLOADS
+ ? right.totalDownloads - left.totalDownloads ||
+ compare(left.normalizedName, right.normalizedName)
+ : compare(left.normalizedName, right.normalizedName),
+ );
+ return {
+ isComplete: true,
+ artifacts,
+ providers: Array.from(
+ new Set(catalog.artifacts.flatMap((artifact) => artifact.providers)),
+ ).sort(compare),
+ myArtifacts: myArtifacts
+ .map((installed) => ({
+ normalizedName: installed.normalizedName,
+ versionSpec: installed.versionSpec,
+ resolvedVersion: installed.resolvedVersion?.trim() || undefined,
+ extendsProviderSlugs: installed.extendsProviderSlugs ?? [],
+ catalogArtifact: merged.get(installed.normalizedName),
+ }))
+ .sort((left, right) =>
+ compare(left.normalizedName, right.normalizedName),
+ ),
+ };
+}
+
+function matches(
+ artifact: RegistryCatalogArtifact,
+ filters: RegistryExplorerFilters,
+) {
+ const search = filters.search?.trim().toLowerCase();
+ const providers = (filters.providers ?? []).map((provider) =>
+ provider.trim().toLowerCase(),
+ );
+ const text =
+ `${artifact.normalizedName} ${artifact.name ?? ""} ${artifact.description ?? ""}`.toLowerCase();
+ return (
+ (!search || text.includes(search)) &&
+ (providers.length === 0 ||
+ providers.some((provider) => artifact.providers.includes(provider))) &&
+ (filters.capabilities?.length ? filters.capabilities : [undefined]).some(
+ (capability) =>
+ !capability ||
+ (capability === REGISTRY_CATALOG_CAPABILITY.CHECKS &&
+ artifact.hasChecks) ||
+ (capability === REGISTRY_CATALOG_CAPABILITY.COMPLIANCE &&
+ artifact.hasCompliance) ||
+ (capability === REGISTRY_CATALOG_CAPABILITY.PROVIDER &&
+ artifact.hasProvider),
+ )
+ );
+}
+function compare(left: string, right: string) {
+ return left < right ? -1 : left > right ? 1 : 0;
+}
diff --git a/ui/components/registry/registry-explorer.tsx b/ui/components/registry/registry-explorer.tsx
new file mode 100644
index 0000000000..84cad6e893
--- /dev/null
+++ b/ui/components/registry/registry-explorer.tsx
@@ -0,0 +1,744 @@
+"use client";
+
+import { RefreshCw, Settings } from "lucide-react";
+import { useRouter, useSearchParams } from "next/navigation";
+import { useEffect, useEffectEvent, useRef, useState } from "react";
+
+import {
+ disconnectRegistryCredential,
+ removeRegistryArtifact,
+} from "@/actions/registry/registry";
+import { Alert, AlertDescription } from "@/components/shadcn/alert";
+import { Badge } from "@/components/shadcn/badge/badge";
+import { Button } from "@/components/shadcn/button/button";
+import {
+ Tabs,
+ TabsContent,
+ TabsList,
+ TabsTrigger,
+} from "@/components/shadcn/tabs/tabs";
+import { toast } from "@/components/shadcn/toast/use-toast";
+import { executeRegistryArtifactAddition } from "@/lib/registry/artifact-execution";
+import { executeRegistryCredentialValidation } from "@/lib/registry/credential-execution";
+import {
+ REGISTRY_CREDENTIAL_CHANGED,
+ credentialOutcomeMessage,
+ type RegistryCredentialValidationOutcome,
+} from "@/lib/registry/credential-result";
+import { useTaskWatcherStore } from "@/store/task-watcher/store";
+import {
+ REGISTRY_ARTIFACT_REMOVAL,
+ REGISTRY_BOOTSTRAP_STATE,
+ REGISTRY_CREDENTIAL_ACTION,
+ REGISTRY_FAILURE,
+ REGISTRY_INSTALL_OPERATION,
+ REGISTRY_MUTATION,
+ type RegistryArtifactRemovalResult,
+ type RegistryBootstrapState,
+ type RegistryMutationResult,
+ type RegistryRemoveDialogError,
+ type RegistryTenantArtifact,
+} from "@/types/registry";
+
+import { RegistryAccessDialog } from "./registry-access-dialog";
+import {
+ RegistryArtifactCard,
+ RegistryTenantArtifactCard,
+} from "./registry-artifact-card";
+import { RegistryArtifactGrid } from "./registry-artifact-grid";
+import { RegistryCredentialBanner } from "./registry-credential-banner";
+import {
+ buildRegistryMarketplaceModel,
+ REGISTRY_MARKETPLACE_SORT,
+ REGISTRY_CATALOG_CAPABILITY,
+ type RegistryCatalogCapability,
+ type RegistryExplorerFilters,
+ type RegistryMarketplaceArtifact,
+ type RegistryMarketplaceSort,
+} from "./registry-explorer.model";
+import { RegistryRemoveDialog } from "./registry-remove-dialog";
+import { RegistryToolbar } from "./registry-toolbar";
+import { useRegistryRefresh } from "./use-registry-refresh";
+
+const PAGE_SUBTITLE =
+ "Explore checks, compliance frameworks, and providers. Add artifacts to connect new providers or to run more checks on the ones you already scan.";
+
+const REGISTRY_TAB = { EXPLORE: "explore", MINE: "mine" } as const;
+type RegistryTab = (typeof REGISTRY_TAB)[keyof typeof REGISTRY_TAB];
+
+const REGISTRY_PENDING_OPERATION = {
+ CREDENTIAL: "credential",
+ REMOVE: "remove",
+} as const;
+type RegistryPendingOperation =
+ (typeof REGISTRY_PENDING_OPERATION)[keyof typeof REGISTRY_PENDING_OPERATION];
+
+const REGISTRY_ACCESS_DIALOG_MODE = {
+ CONNECT: "connect",
+ MANAGE: "manage",
+} as const;
+type RegistryAccessDialogMode =
+ (typeof REGISTRY_ACCESS_DIALOG_MODE)[keyof typeof REGISTRY_ACCESS_DIALOG_MODE];
+
+interface RetryStateProps {
+ title: string;
+ children: string;
+}
+
+function RetryState({ title, children }: RetryStateProps) {
+ return (
+
+ {title}
+ {children}
+
+ window.location.reload()}>Retry
+
+
+ );
+}
+
+function mutationFailureMessage(result: RegistryMutationResult) {
+ if (result.status === REGISTRY_MUTATION.REFUSED) return result.message;
+ if (result.status === REGISTRY_MUTATION.REFRESH_FAILED) {
+ return "Registry membership could not be confirmed. Try again.";
+ }
+ return "The Registry operation could not be completed. Try again.";
+}
+
+interface RegistryExplorerProps {
+ initialState: RegistryBootstrapState;
+ registryKeyUrl?: string;
+}
+
+export function RegistryExplorer({
+ initialState,
+ registryKeyUrl,
+}: RegistryExplorerProps) {
+ // The API is the sole access authority: a denied action result routes to
+ // Profile once, and the navigation unmounts this component with its state.
+ const router = useRouter();
+ const [state, setState] = useState(initialState);
+ const searchParams = useSearchParams();
+ const filters: RegistryExplorerFilters = {
+ search: searchParams.get("filter[search]") ?? undefined,
+ providers:
+ searchParams.get("filter[provider]")?.split(",").filter(Boolean) ?? [],
+ capabilities: (
+ searchParams.get("filter[capability]")?.split(",") ?? []
+ ).filter((value): value is RegistryCatalogCapability =>
+ Object.values(REGISTRY_CATALOG_CAPABILITY).includes(
+ value as RegistryCatalogCapability,
+ ),
+ ),
+ };
+ const sort =
+ searchParams.get("sort") === REGISTRY_MARKETPLACE_SORT.DOWNLOADS
+ ? REGISTRY_MARKETPLACE_SORT.DOWNLOADS
+ : REGISTRY_MARKETPLACE_SORT.NAME;
+ const activeTab =
+ searchParams.get("tab") === REGISTRY_TAB.MINE
+ ? REGISTRY_TAB.MINE
+ : REGISTRY_TAB.EXPLORE;
+ function updateView(values: Record) {
+ const next = new URLSearchParams(searchParams.toString());
+ Object.entries(values).forEach(([key, value]) =>
+ value ? next.set(key, value) : next.delete(key),
+ );
+ window.history.replaceState(
+ null,
+ "",
+ `/registry${next.size ? `?${next}` : ""}`,
+ );
+ }
+ const setFilters = (next: RegistryExplorerFilters) =>
+ updateView({
+ "filter[search]": next.search,
+ "filter[provider]": next.providers?.join(","),
+ "filter[capability]": next.capabilities?.join(","),
+ });
+ const setSort = (next: RegistryMarketplaceSort) =>
+ updateView({
+ sort: next === REGISTRY_MARKETPLACE_SORT.NAME ? undefined : next,
+ });
+ const setActiveTab = (next: RegistryTab) => {
+ updateView({ tab: next === REGISTRY_TAB.EXPLORE ? undefined : next });
+ if (next !== activeTab) requestRefresh();
+ };
+ const [pendingOperation, setPendingOperation] =
+ useState(null);
+ const [localPendingAddName, setPendingAddName] = useState();
+ const watchedTasks = useTaskWatcherStore((store) => store.tasks);
+ const pendingAddName =
+ localPendingAddName ||
+ Object.values(watchedTasks).find(
+ (task) =>
+ task.kind === "registry-artifact-add" && task.status === "pending",
+ )?.meta.normalizedName;
+ const [refreshMessage, setRefreshMessage] = useState();
+ const { isRefreshing, requestRefresh, invalidateRefresh } =
+ useRegistryRefresh({
+ enabled: state.status === REGISTRY_BOOTSTRAP_STATE.READY,
+ mutationPending: Boolean(pendingOperation || pendingAddName),
+ onResult: (result) => {
+ if (result.status === "access_denied") {
+ router.replace("/profile");
+ } else if (result.status === "complete") {
+ setRefreshMessage(undefined);
+ setState((current) =>
+ current.status === "ready"
+ ? {
+ ...current,
+ catalog: result.catalog,
+ tenantArtifacts: result.tenantArtifacts,
+ }
+ : current,
+ );
+ } else if (result.status === "reconnect") {
+ setState({ status: REGISTRY_BOOTSTRAP_STATE.RECONNECT });
+ } else if (result.status === "onboarding") {
+ setState((current) =>
+ current.status === "ready"
+ ? {
+ status: REGISTRY_BOOTSTRAP_STATE.ONBOARDING,
+ credential: {
+ configured: false,
+ isValid: false,
+ scopes: [],
+ validationPending: false,
+ },
+ tenantArtifacts: current.tenantArtifacts,
+ }
+ : current,
+ );
+ } else {
+ setRefreshMessage(
+ "Registry could not be refreshed. Showing the last available data. Try again.",
+ );
+ }
+ },
+ });
+ const consumeArtifactsChanged = useEffectEvent(
+ (artifacts: RegistryTenantArtifact[]) => {
+ invalidateRefresh();
+ setState((current) =>
+ current.status === "ready"
+ ? { ...current, tenantArtifacts: artifacts }
+ : current,
+ );
+ },
+ );
+ useEffect(() => {
+ const refresh = (event: Event) => {
+ if (!(event instanceof CustomEvent) || !Array.isArray(event.detail))
+ return;
+ consumeArtifactsChanged(event.detail);
+ };
+ window.addEventListener("registry-artifacts-changed", refresh);
+ return () =>
+ window.removeEventListener("registry-artifacts-changed", refresh);
+ }, []);
+ const [accessDialogMode, setAccessDialogMode] =
+ useState();
+ const [removeTarget, setRemoveTarget] = useState();
+ const [removeError, setRemoveError] = useState();
+ const [operationMessage, setOperationMessage] = useState();
+ const connectButtonRef = useRef(null);
+ const manageButtonRef = useRef(null);
+ const removeTriggerRef = useRef(null);
+ const operationGeneration = useRef(0);
+ const artifactSubmission = useRef(false);
+ const awaitingCredential = useRef(false);
+
+ useEffect(
+ () => () => {
+ operationGeneration.current += 1;
+ },
+ [],
+ );
+
+ const consumeCredentialOutcome = useEffectEvent(
+ (result: RegistryCredentialValidationOutcome) => {
+ if (!awaitingCredential.current) applyCredentialOutcome(result);
+ },
+ );
+ useEffect(() => {
+ const consume = (event: Event) => {
+ if (event instanceof CustomEvent) consumeCredentialOutcome(event.detail);
+ };
+ window.addEventListener(REGISTRY_CREDENTIAL_CHANGED, consume);
+ return () =>
+ window.removeEventListener(REGISTRY_CREDENTIAL_CHANGED, consume);
+ }, []);
+
+ function applyCredentialOutcome(result: RegistryCredentialValidationOutcome) {
+ // A connected outcome already includes fresh collections. A failed
+ // replacement must still resume any read deferred during that mutation.
+ invalidateRefresh(
+ result.status === REGISTRY_CREDENTIAL_ACTION.CONNECTED
+ ? false
+ : undefined,
+ );
+ if (result.status === REGISTRY_FAILURE.ACCESS_DENIED) {
+ router.replace("/profile");
+ return;
+ }
+ setPendingOperation(null);
+ if (result.status === REGISTRY_CREDENTIAL_ACTION.CONNECTED) {
+ setRefreshMessage(undefined);
+ setAccessDialogMode(undefined);
+ setOperationMessage(undefined);
+ setState({
+ status: REGISTRY_BOOTSTRAP_STATE.READY,
+ credential: result.credential,
+ catalog: result.collections.catalog,
+ tenantArtifacts: result.collections.tenantArtifacts,
+ });
+ return;
+ }
+ if (
+ result.status === REGISTRY_CREDENTIAL_ACTION.PENDING ||
+ result.status === REGISTRY_CREDENTIAL_ACTION.INVALID
+ ) {
+ setState((current) =>
+ current.status === REGISTRY_BOOTSTRAP_STATE.ONBOARDING ||
+ current.status === REGISTRY_BOOTSTRAP_STATE.VALIDATION_PENDING
+ ? {
+ status:
+ result.status === REGISTRY_CREDENTIAL_ACTION.PENDING
+ ? REGISTRY_BOOTSTRAP_STATE.VALIDATION_PENDING
+ : REGISTRY_BOOTSTRAP_STATE.ONBOARDING,
+ credential: result.credential ?? current.credential,
+ tenantArtifacts: current.tenantArtifacts,
+ }
+ : current,
+ );
+ }
+ setOperationMessage(credentialOutcomeMessage(result));
+ }
+
+ async function handleAdd(artifact: RegistryMarketplaceArtifact) {
+ if (
+ !artifact.isInstallable ||
+ (artifact.isAdded && !artifact.updateAvailable) ||
+ pendingAddName ||
+ pendingOperation ||
+ artifactSubmission.current
+ )
+ return;
+ const { normalizedName } = artifact;
+ invalidateRefresh();
+ artifactSubmission.current = true;
+ const generation = operationGeneration.current;
+ setOperationMessage(undefined);
+ setPendingAddName(normalizedName);
+ const result = await executeRegistryArtifactAddition(
+ artifact.updateAvailable && artifact.latestVersion
+ ? {
+ normalizedName,
+ versionSpec: artifact.latestVersion,
+ operation: REGISTRY_INSTALL_OPERATION.UPDATE,
+ }
+ : { normalizedName },
+ );
+ artifactSubmission.current = false;
+ if (generation !== operationGeneration.current) return;
+ if (result.status === REGISTRY_FAILURE.ACCESS_DENIED)
+ return router.replace("/profile");
+
+ setPendingAddName(undefined);
+ if (result.status !== REGISTRY_MUTATION.CONFIRMED) {
+ setOperationMessage(
+ artifact.updateAvailable &&
+ result.status === REGISTRY_MUTATION.REFRESH_FAILED
+ ? "Update could not be confirmed. Refresh Registry before retrying."
+ : mutationFailureMessage(result),
+ );
+ return;
+ }
+
+ setState((current) =>
+ current.status === REGISTRY_BOOTSTRAP_STATE.READY
+ ? { ...current, tenantArtifacts: result.tenantArtifacts }
+ : current,
+ );
+ }
+
+ async function handleCredentialSubmit(key: string) {
+ invalidateRefresh();
+ const generation = operationGeneration.current;
+ setOperationMessage(undefined);
+ setPendingOperation(REGISTRY_PENDING_OPERATION.CREDENTIAL);
+ awaitingCredential.current = true;
+ const result = await executeRegistryCredentialValidation(key);
+ awaitingCredential.current = false;
+ if (generation !== operationGeneration.current) return;
+ applyCredentialOutcome(result);
+ }
+
+ async function handleDisconnect() {
+ invalidateRefresh();
+ const generation = operationGeneration.current;
+ setOperationMessage(undefined);
+ setPendingOperation(REGISTRY_PENDING_OPERATION.CREDENTIAL);
+ const result = await disconnectRegistryCredential().catch(() => ({
+ status: REGISTRY_FAILURE.ERROR,
+ }));
+ if (generation !== operationGeneration.current) return;
+ if (result.status === REGISTRY_FAILURE.ACCESS_DENIED)
+ return router.replace("/profile");
+
+ setPendingOperation(null);
+ if (result.status !== REGISTRY_CREDENTIAL_ACTION.DISCONNECTED) {
+ setOperationMessage(
+ "Registry access could not be disconnected. Try again.",
+ );
+ return;
+ }
+
+ setAccessDialogMode(undefined);
+ setState({
+ status: REGISTRY_BOOTSTRAP_STATE.ONBOARDING,
+ credential: result.credential,
+ tenantArtifacts: result.tenantArtifacts,
+ });
+ }
+
+ async function handleRemove(normalizedName: string) {
+ if (
+ pendingOperation === REGISTRY_PENDING_OPERATION.REMOVE ||
+ pendingAddName === normalizedName
+ )
+ return;
+ invalidateRefresh();
+ const generation = operationGeneration.current;
+ setOperationMessage(undefined);
+ setRemoveError(undefined);
+ setPendingOperation(REGISTRY_PENDING_OPERATION.REMOVE);
+ let result: RegistryArtifactRemovalResult;
+ try {
+ result = await removeRegistryArtifact(normalizedName);
+ } catch {
+ result = { status: REGISTRY_FAILURE.ERROR };
+ }
+ if (generation !== operationGeneration.current) return;
+ setPendingOperation(null);
+ if (result.status === REGISTRY_FAILURE.ACCESS_DENIED)
+ return router.replace("/profile");
+
+ if (
+ result.status === REGISTRY_ARTIFACT_REMOVAL.IN_USE ||
+ result.status === REGISTRY_ARTIFACT_REMOVAL.BUSY
+ ) {
+ setRemoveError(result);
+ return;
+ }
+ if (result.status !== REGISTRY_MUTATION.CONFIRMED) {
+ setRemoveError({
+ status: REGISTRY_FAILURE.ERROR,
+ message: mutationFailureMessage(result),
+ });
+ return;
+ }
+
+ setRemoveTarget(undefined);
+ setState((current) =>
+ current.status === REGISTRY_BOOTSTRAP_STATE.READY
+ ? { ...current, tenantArtifacts: result.tenantArtifacts }
+ : current,
+ );
+ toast({ title: "Artifact removed" });
+ window.dispatchEvent(
+ new CustomEvent("registry-artifacts-changed", {
+ detail: result.tenantArtifacts,
+ }),
+ );
+ }
+
+ function openRemoveDialog(
+ normalizedName: string,
+ trigger: HTMLButtonElement | null,
+ ) {
+ removeTriggerRef.current = trigger;
+ setRemoveError(undefined);
+ setRemoveTarget(normalizedName);
+ }
+
+ const accessDialogProps = {
+ registryKeyUrl,
+ errorMessage: operationMessage,
+ onOpenChange: (open: boolean) => {
+ if (!open && pendingOperation !== REGISTRY_PENDING_OPERATION.CREDENTIAL) {
+ setAccessDialogMode(undefined);
+ }
+ },
+ onSubmit: handleCredentialSubmit,
+ open: true,
+ pending: pendingOperation === REGISTRY_PENDING_OPERATION.CREDENTIAL,
+ returnFocusRef:
+ accessDialogMode === REGISTRY_ACCESS_DIALOG_MODE.CONNECT
+ ? connectButtonRef
+ : manageButtonRef,
+ };
+ const accessDialog =
+ accessDialogMode === REGISTRY_ACCESS_DIALOG_MODE.CONNECT ? (
+
+ ) : accessDialogMode === REGISTRY_ACCESS_DIALOG_MODE.MANAGE ? (
+
+ ) : null;
+
+ if (
+ state.status === REGISTRY_BOOTSTRAP_STATE.ONBOARDING ||
+ state.status === REGISTRY_BOOTSTRAP_STATE.VALIDATION_PENDING
+ ) {
+ return (
+
+
{PAGE_SUBTITLE}
+ {!accessDialogMode && operationMessage && (
+
+ {operationMessage}
+
+ )}
+
+ setAccessDialogMode(REGISTRY_ACCESS_DIALOG_MODE.CONNECT)
+ }
+ tenantArtifactCount={state.tenantArtifacts.length}
+ validationPending={
+ state.status === REGISTRY_BOOTSTRAP_STATE.VALIDATION_PENDING
+ }
+ />
+ {accessDialog}
+
+ );
+ }
+ if (state.status !== REGISTRY_BOOTSTRAP_STATE.READY) {
+ const messages = {
+ [REGISTRY_BOOTSTRAP_STATE.INCOMPLETE]: [
+ "Registry catalog is incomplete",
+ "Complete catalog controls and metrics are unavailable until every catalog page loads. Retry to load the catalog again.",
+ ],
+ [REGISTRY_BOOTSTRAP_STATE.UNAVAILABLE]: [
+ "Registry is unavailable",
+ "Registry data may be stale or unavailable. Retry when the service is available.",
+ ],
+ [REGISTRY_BOOTSTRAP_STATE.RECONNECT]: [
+ "Reconnect Registry",
+ "Reconnect Registry before exploring artifacts.",
+ ],
+ [REGISTRY_BOOTSTRAP_STATE.ERROR]: [
+ "Registry could not be loaded",
+ "An unexpected Registry error occurred. Retry to load the explorer again.",
+ ],
+ } as const;
+ const [title, message] = messages[state.status];
+ return (
+ <>
+ {message}
+ {state.status === REGISTRY_BOOTSTRAP_STATE.RECONNECT && (
+
+
+ setAccessDialogMode(REGISTRY_ACCESS_DIALOG_MODE.MANAGE)
+ }
+ ref={manageButtonRef}
+ type="button"
+ >
+ Replace key
+
+
+ )}
+ {accessDialog}
+ >
+ );
+ }
+
+ const model = buildRegistryMarketplaceModel(
+ state.catalog,
+ state.tenantArtifacts,
+ filters,
+ sort,
+ );
+ if (!model.isComplete) {
+ return (
+
+ Complete catalog controls and metrics are unavailable.
+
+ );
+ }
+ return (
+
+
Registry marketplace
+
{PAGE_SUBTITLE}
+ {refreshMessage && (
+
+ {refreshMessage}
+
+ )}
+ {!accessDialogMode && operationMessage && (
+
+ {operationMessage}
+
+ )}
+
setActiveTab(value as RegistryTab)}
+ value={activeTab}
+ >
+
+
+
+
+ {state.catalog.artifacts.length}
+
+ }
+ value={REGISTRY_TAB.EXPLORE}
+ >
+ All
+
+
+ {state.tenantArtifacts.length}
+
+ }
+ value={REGISTRY_TAB.MINE}
+ >
+ My artifacts
+
+
+
+
+
+
+ {isRefreshing ? "Refreshing…" : "Refresh"}
+
+
+ setAccessDialogMode(REGISTRY_ACCESS_DIALOG_MODE.MANAGE)
+ }
+ ref={manageButtonRef}
+ size="icon"
+ type="button"
+ variant="ghost"
+ >
+
+
+
+
+
+
+ 0
+ ? () => setFilters({})
+ : requestRefresh
+ }
+ isEmpty={model.artifacts.length === 0}
+ >
+ {model.artifacts.map((artifact) => (
+
+ handleAdd(artifact)}
+ onRemove={(trigger) =>
+ openRemoveDialog(artifact.normalizedName, trigger)
+ }
+ />
+
+ ))}
+
+
+
+ setActiveTab(REGISTRY_TAB.EXPLORE)}
+ >
+ {model.myArtifacts.map((myArtifact) => (
+
+ {myArtifact.catalogArtifact ? (
+ handleAdd(myArtifact.catalogArtifact!)}
+ onRemove={(trigger) =>
+ openRemoveDialog(myArtifact.normalizedName, trigger)
+ }
+ />
+ ) : (
+
+ openRemoveDialog(myArtifact.normalizedName, trigger)
+ }
+ resolvedVersion={myArtifact.resolvedVersion}
+ />
+ )}
+
+ ))}
+
+
+
+ {accessDialog}
+
removeTarget && handleRemove(removeTarget)}
+ onOpenChange={(open) => {
+ if (!open && pendingOperation !== REGISTRY_PENDING_OPERATION.REMOVE) {
+ setRemoveError(undefined);
+ setRemoveTarget(undefined);
+ }
+ }}
+ onViewProviders={() => router.push("/providers")}
+ open={removeTarget !== undefined}
+ returnFocusRef={removeTriggerRef}
+ />
+
+ );
+}
diff --git a/ui/components/registry/registry-remove-dialog.tsx b/ui/components/registry/registry-remove-dialog.tsx
new file mode 100644
index 0000000000..d5c8a788d5
--- /dev/null
+++ b/ui/components/registry/registry-remove-dialog.tsx
@@ -0,0 +1,99 @@
+import { type RefObject, useEffect, useRef } from "react";
+
+import { Alert, AlertDescription, AlertTitle } from "@/components/shadcn/alert";
+import { Button } from "@/components/shadcn/button/button";
+import { Modal } from "@/components/shadcn/modal/modal";
+import {
+ REGISTRY_ARTIFACT_REMOVAL,
+ type RegistryRemoveDialogError,
+} from "@/types/registry";
+
+interface RegistryRemoveDialogProps {
+ artifactName?: string;
+ error?: RegistryRemoveDialogError;
+ isPending: boolean;
+ onConfirm: () => void;
+ onOpenChange: (open: boolean) => void;
+ onViewProviders: () => void;
+ open: boolean;
+ returnFocusRef: RefObject;
+}
+
+export function RegistryRemoveDialog({
+ artifactName,
+ error,
+ isPending,
+ onConfirm,
+ onOpenChange,
+ onViewProviders,
+ open,
+ returnFocusRef,
+}: RegistryRemoveDialogProps) {
+ const cancelButtonRef = useRef(null);
+ const isInUse = error?.status === REGISTRY_ARTIFACT_REMOVAL.IN_USE;
+ // Busy keeps Confirm Remove: waiting is the remedy, not deleting providers.
+ const isBusy = error?.status === REGISTRY_ARTIFACT_REMOVAL.BUSY;
+
+ // Disabling the submit button can lose focus; restore it inside the dialog
+ // when a failure re-enables the actions or replaces them with recovery actions.
+ useEffect(() => {
+ if (open && error) cancelButtonRef.current?.focus();
+ }, [error, open]);
+
+ return (
+ {
+ event.preventDefault();
+ cancelButtonRef.current?.focus();
+ }}
+ onCloseAutoFocus={(event) => {
+ event.preventDefault();
+ returnFocusRef.current?.focus();
+ }}
+ onOpenChange={onOpenChange}
+ open={open}
+ size="sm"
+ title="Remove artifact"
+ >
+ {error && (
+
+ {isInUse && Artifact in use}
+ {isBusy && A scan is using this artifact}
+
+ {isInUse
+ ? "This artifact cannot be removed because one or more providers use it. Review the associated providers before trying again."
+ : isBusy
+ ? "A scan is running the checks this artifact adds. It clears by itself when the scan finishes, so try again shortly."
+ : error.message}
+
+
+ )}
+
+ onOpenChange(false)}
+ ref={cancelButtonRef}
+ type="button"
+ variant="outline"
+ >
+ {isInUse ? "Close" : "Cancel"}
+
+ {isInUse ? (
+
+ View providers
+
+ ) : (
+
+ {isPending ? "Removing artifact" : "Confirm Remove"}
+
+ )}
+
+
+ );
+}
diff --git a/ui/components/registry/registry-toolbar.tsx b/ui/components/registry/registry-toolbar.tsx
new file mode 100644
index 0000000000..a4049267cf
--- /dev/null
+++ b/ui/components/registry/registry-toolbar.tsx
@@ -0,0 +1,156 @@
+"use client";
+
+import { ClearFiltersButton } from "@/components/filters/clear-filters-button";
+import { ProviderTypeIcon } from "@/components/icons/providers-badge/provider-type-icon";
+import { SearchInput } from "@/components/shadcn/search-input/search-input";
+import {
+ MultiSelect,
+ MultiSelectContent,
+ MultiSelectItem,
+ MultiSelectSelectAll,
+ MultiSelectSeparator,
+ MultiSelectTrigger,
+ MultiSelectValue,
+} from "@/components/shadcn/select/multiselect";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/shadcn/select/select";
+import { getProviderDisplayName } from "@/types/providers";
+
+import {
+ REGISTRY_CAPABILITY_LABELS,
+ REGISTRY_CATALOG_CAPABILITY,
+ REGISTRY_MARKETPLACE_SORT,
+ type RegistryCatalogCapability,
+ type RegistryExplorerFilters,
+ type RegistryMarketplaceSort,
+} from "./registry-explorer.model";
+
+interface RegistryToolbarProps {
+ filters: RegistryExplorerFilters;
+ onFiltersChange: (filters: RegistryExplorerFilters) => void;
+ onSortChange: (sort: RegistryMarketplaceSort) => void;
+ providers: string[];
+ resultsCount: number;
+ sort: RegistryMarketplaceSort;
+}
+
+export function RegistryToolbar({
+ filters,
+ onFiltersChange,
+ onSortChange,
+ providers,
+ resultsCount,
+ sort,
+}: RegistryToolbarProps) {
+ const activeCount =
+ Number(Boolean(filters.providers?.length)) +
+ Number(Boolean(filters.capabilities?.length)) +
+ Number(Boolean(filters.search));
+ return (
+
+
+
+ onFiltersChange({ ...filters, search: event.target.value })
+ }
+ onClear={() => onFiltersChange({ ...filters, search: undefined })}
+ />
+
+
+
+ onFiltersChange({ ...filters, providers: values })
+ }
+ >
+
+
+
+
+ Select All
+
+ {providers.map((provider) => (
+
+
+ {getProviderDisplayName(provider)}
+
+ ))}
+
+
+
+
+
+ onFiltersChange({
+ ...filters,
+ capabilities: values as RegistryCatalogCapability[],
+ })
+ }
+ >
+
+
+
+
+ {Object.values(REGISTRY_CATALOG_CAPABILITY).map((capability) => (
+
+ {REGISTRY_CAPABILITY_LABELS[capability]}
+
+ ))}
+
+
+
+
+
+
+
{
+ onFiltersChange({});
+ }}
+ />
+
+ {resultsCount} artifact{resultsCount === 1 ? "" : "s"}
+
+
+ );
+}
diff --git a/ui/components/registry/use-registry-refresh.ts b/ui/components/registry/use-registry-refresh.ts
new file mode 100644
index 0000000000..40ecaf3db9
--- /dev/null
+++ b/ui/components/registry/use-registry-refresh.ts
@@ -0,0 +1,97 @@
+"use client";
+
+import { useEffect, useEffectEvent, useRef, useState } from "react";
+
+import { refreshRegistryCollections } from "@/actions/registry/registry";
+import type { RegistryCollectionsResult } from "@/types/registry";
+
+interface RegistryRefreshOptions {
+ enabled: boolean;
+ mutationPending: boolean;
+ onResult: (result: RegistryCollectionsResult) => void;
+}
+
+export function useRegistryRefresh({
+ enabled,
+ mutationPending,
+ onResult,
+}: RegistryRefreshOptions) {
+ const [isRefreshing, setIsRefreshing] = useState(false);
+ const [requestId, setRequestId] = useState(0);
+ const generation = useRef(0);
+ const inFlight = useRef(null);
+ const requested = useRef(false);
+ const mounted = useRef(true);
+
+ useEffect(() => {
+ mounted.current = true;
+ return () => {
+ mounted.current = false;
+ generation.current += 1;
+ };
+ }, []);
+
+ function requestRefresh() {
+ // Tab/focus/button events share the current read unless a mutation invalidated it.
+ if (inFlight.current === generation.current) return;
+ requested.current = true;
+ setRequestId((value) => value + 1);
+ }
+
+ function invalidateRefresh(refreshAfter?: boolean) {
+ generation.current += 1;
+ requested.current =
+ refreshAfter ?? (requested.current || inFlight.current !== null);
+ setRequestId((value) => value + 1);
+ }
+
+ const readCollections = useEffectEvent(async () => {
+ if (!enabled) {
+ generation.current += 1;
+ requested.current = false;
+ return;
+ }
+ if (mutationPending) {
+ generation.current += 1;
+ requested.current = requested.current || inFlight.current !== null;
+ return;
+ }
+ if (!requested.current || inFlight.current !== null) return;
+ const currentGeneration = generation.current;
+ inFlight.current = currentGeneration;
+ requested.current = false;
+ setIsRefreshing(true);
+ try {
+ const result = await refreshRegistryCollections().catch(() => ({
+ status: "error" as const,
+ }));
+ if (mounted.current && currentGeneration === generation.current)
+ onResult(result);
+ } finally {
+ inFlight.current = null;
+ if (mounted.current) {
+ setIsRefreshing(false);
+ if (requested.current) setRequestId((value) => value + 1);
+ }
+ }
+ });
+
+ useEffect(() => {
+ void readCollections();
+ }, [enabled, mutationPending, requestId]);
+
+ const refreshOnFocus = useEffectEvent(() => {
+ if (enabled && document.visibilityState === "visible") requestRefresh();
+ });
+ useEffect(() => {
+ const refresh = () => refreshOnFocus();
+ window.addEventListener("focus", refresh);
+ document.addEventListener("visibilitychange", refresh);
+ return () => {
+ window.removeEventListener("focus", refresh);
+ document.removeEventListener("visibilitychange", refresh);
+ };
+ }, []);
+
+ return { isRefreshing, requestRefresh, invalidateRefresh };
+}
diff --git a/ui/components/roles/workflow/forms/add-role-form.test.tsx b/ui/components/roles/workflow/forms/add-role-form.test.tsx
index 5f285c0c35..13026220cf 100644
--- a/ui/components/roles/workflow/forms/add-role-form.test.tsx
+++ b/ui/components/roles/workflow/forms/add-role-form.test.tsx
@@ -68,6 +68,11 @@ vi.mock("@/lib", () => ({
description:
"Allows configuring Lighthouse AI, including its provider credentials, default model and business context",
},
+ {
+ field: "manage_registry",
+ label: "Manage Registry",
+ description: "Allows managing tenant Registry credentials and artifacts",
+ },
{
field: "manage_billing",
label: "Manage Billing",
@@ -147,9 +152,25 @@ describe("AddRoleForm", () => {
// Then
expect(screen.queryByText("Manage Alerts")).not.toBeInTheDocument();
expect(screen.queryByText("Manage Lighthouse AI")).not.toBeInTheDocument();
+ expect(screen.queryByText("Manage Registry")).not.toBeInTheDocument();
expect(screen.queryByText("Manage Billing")).not.toBeInTheDocument();
});
+ it("submits manage_registry when granted in Prowler Cloud", async () => {
+ // Given
+ vi.stubEnv("UI_CLOUD_ENABLED", "true");
+ const user = userEvent.setup();
+ render();
+
+ // When
+ await user.type(screen.getByPlaceholderText("Enter role name"), "New role");
+ await user.click(screen.getByRole("checkbox", { name: "Manage Registry" }));
+ await user.click(screen.getByRole("button", { name: "Add Role" }));
+
+ // Then
+ expect(submittedFormData().get("manage_registry")).toBe("true");
+ });
+
it("submits manage_lighthouse_ai_configuration when granted in Prowler Cloud", async () => {
// Given
vi.stubEnv("UI_CLOUD_ENABLED", "true");
@@ -193,6 +214,7 @@ describe("AddRoleForm", () => {
expect(submittedFormData().has("manage_lighthouse_ai_configuration")).toBe(
false,
);
+ expect(submittedFormData().has("manage_registry")).toBe(false);
});
it("navigates back to roles when cancel is clicked", async () => {
diff --git a/ui/components/roles/workflow/forms/add-role-form.tsx b/ui/components/roles/workflow/forms/add-role-form.tsx
index 19c19e2d48..95031cfa5e 100644
--- a/ui/components/roles/workflow/forms/add-role-form.tsx
+++ b/ui/components/roles/workflow/forms/add-role-form.tsx
@@ -28,6 +28,7 @@ export const AddRoleForm = ({ groups }: { groups: RoleGroupOption[] }) => {
manage_billing: false,
manage_alerts: false,
manage_lighthouse_ai_configuration: false,
+ manage_registry: false,
}),
};
@@ -56,6 +57,7 @@ export const AddRoleForm = ({ groups }: { groups: RoleGroupOption[] }) => {
"manage_lighthouse_ai_configuration",
String(values.manage_lighthouse_ai_configuration),
);
+ formData.append("manage_registry", String(values.manage_registry));
}
if (values.groups && values.groups.length > 0) {
diff --git a/ui/components/roles/workflow/forms/edit-role-form.test.tsx b/ui/components/roles/workflow/forms/edit-role-form.test.tsx
index f5ed520ee0..06d0da7b9c 100644
--- a/ui/components/roles/workflow/forms/edit-role-form.test.tsx
+++ b/ui/components/roles/workflow/forms/edit-role-form.test.tsx
@@ -68,6 +68,11 @@ vi.mock("@/lib", () => ({
description:
"Allows configuring Lighthouse AI, including its provider credentials, default model and business context",
},
+ {
+ field: "manage_registry",
+ label: "Manage Registry",
+ description: "Allows managing tenant Registry credentials and artifacts",
+ },
{
field: "manage_billing",
label: "Manage Billing",
@@ -97,9 +102,11 @@ beforeAll(() => {
const roleData = ({
manageProviders = false,
+ manageRegistry = false,
unlimitedVisibility = false,
}: {
manageProviders?: boolean;
+ manageRegistry?: boolean;
unlimitedVisibility?: boolean;
} = {}) => ({
data: {
@@ -109,6 +116,7 @@ const roleData = ({
manage_account: false,
manage_providers: manageProviders,
manage_integrations: false,
+ manage_registry: manageRegistry,
manage_scans: false,
unlimited_visibility: unlimitedVisibility,
groups: [],
@@ -139,6 +147,19 @@ describe("EditRoleForm", () => {
vi.unstubAllEnvs();
});
+ it("retains manage_registry when updating a role in Prowler Cloud", async () => {
+ // Given
+ vi.stubEnv("UI_CLOUD_ENABLED", "true");
+ const user = userEvent.setup();
+ renderEditRoleForm({ manageRegistry: true });
+
+ // When
+ await user.click(screen.getByRole("button", { name: "Update Role" }));
+
+ // Then
+ expect(submittedFormData().get("manage_registry")).toBe("true");
+ });
+
it("submits manage_lighthouse_ai_configuration when granted in Prowler Cloud", async () => {
// Given
vi.stubEnv("UI_CLOUD_ENABLED", "true");
@@ -186,6 +207,7 @@ describe("EditRoleForm", () => {
expect(submittedFormData().has("manage_lighthouse_ai_configuration")).toBe(
false,
);
+ expect(submittedFormData().has("manage_registry")).toBe(false);
});
it("shows the subtle Unlimited Visibility description inside Visibility", () => {
diff --git a/ui/components/roles/workflow/forms/edit-role-form.tsx b/ui/components/roles/workflow/forms/edit-role-form.tsx
index 2621b3c150..27971e26f8 100644
--- a/ui/components/roles/workflow/forms/edit-role-form.tsx
+++ b/ui/components/roles/workflow/forms/edit-role-form.tsx
@@ -35,6 +35,9 @@ export const EditRoleForm = ({
const defaultValues: DefaultValues = {
...roleData.data.attributes,
+ ...(isCloudEnvironment && {
+ manage_registry: roleData.data.attributes.manage_registry ?? false,
+ }),
groups:
roleData.data.relationships?.provider_groups?.data.map((g) => g.id) || [],
};
@@ -62,6 +65,7 @@ export const EditRoleForm = ({
updatedFields.manage_alerts = values.manage_alerts;
updatedFields.manage_lighthouse_ai_configuration =
values.manage_lighthouse_ai_configuration;
+ updatedFields.manage_registry = values.manage_registry;
}
if (
diff --git a/ui/components/shadcn/button/button.tsx b/ui/components/shadcn/button/button.tsx
index f34347507b..bc37c08bc9 100644
--- a/ui/components/shadcn/button/button.tsx
+++ b/ui/components/shadcn/button/button.tsx
@@ -19,6 +19,8 @@ const buttonVariants = cva(
"border border-transparent bg-bg-fail text-white hover:bg-bg-fail/90 active:bg-bg-fail/80 focus-visible:ring-bg-fail/50",
outline:
"border border-border-neutral-secondary bg-bg-neutral-secondary hover:bg-bg-neutral-tertiary active:bg-border-neutral-tertiary text-text-neutral-primary focus-visible:ring-border-neutral-tertiary/50",
+ "aws-marketplace":
+ "border-2 border-button-aws-marketplace bg-transparent text-button-aws-marketplace font-semibold hover:bg-button-aws-marketplace/5 active:bg-button-aws-marketplace/10 focus-visible:ring-button-aws-marketplace",
ghost:
"border border-transparent text-text-neutral-primary hover:bg-bg-neutral-tertiary active:bg-border-neutral-secondary focus-visible:ring-border-neutral-secondary/50",
link: "text-button-tertiary underline-offset-4 hover:text-button-tertiary-hover disabled:bg-transparent",
diff --git a/ui/components/shadcn/toast/Toast.tsx b/ui/components/shadcn/toast/Toast.tsx
index bff6da8e31..00171186e7 100644
--- a/ui/components/shadcn/toast/Toast.tsx
+++ b/ui/components/shadcn/toast/Toast.tsx
@@ -108,7 +108,7 @@ const ToastDescription = React.forwardRef<
{
+ afterEach(async () => {
+ await page.viewport(1280, 800);
+ });
+
+ it.each([1280, 393])(
+ "preserves words and contains long URLs at %ipx",
+ async (width) => {
+ // Given
+ await page.viewport(width, 800);
+ toast({
+ title: "Connection test failed",
+ description: DESCRIPTION,
+ duration: Infinity,
+ });
+
+ // When
+ const screen = await render();
+ const description = screen.getByText(DESCRIPTION, { exact: true });
+ await expect.element(description).toBeVisible();
+ const element = description.element();
+ const text = element.firstChild!;
+ const range = document.createRange();
+
+ // Then: regular words fit on one line rather than breaking mid-word.
+ for (const match of Array.from(MESSAGE.matchAll(/\S+/g))) {
+ range.setStart(text, match.index);
+ range.setEnd(text, match.index + match[0].length);
+ expect(Array.from(range.getClientRects()), match[0]).toHaveLength(1);
+ }
+
+ // Long unbroken strings wrap without clipping or horizontal scrolling.
+ range.setStart(text, MESSAGE.length + 1);
+ range.setEnd(text, MESSAGE.length + 1 + LONG_URL.length);
+ const urlLines = Array.from(range.getClientRects());
+ expect(urlLines.length).toBeGreaterThan(1);
+ const bounds = element.getBoundingClientRect();
+ expect(urlLines.every((line) => line.right <= bounds.right + 1)).toBe(
+ true,
+ );
+ expect(element.scrollWidth).toBeLessThanOrEqual(element.clientWidth);
+
+ // Explicit line breaks are preserved and tall descriptions remain scrollable.
+ range.setStart(text, DESCRIPTION.indexOf("Explicit"));
+ range.setEnd(text, DESCRIPTION.length);
+ expect(range.getBoundingClientRect().top).toBeGreaterThan(
+ urlLines.at(-1)!.top,
+ );
+ expect(getComputedStyle(element).overflowY).toBe("auto");
+ expect(element.clientHeight).toBeLessThanOrEqual(192);
+ await page.screenshot();
+ },
+ );
+});
diff --git a/ui/components/shadcn/toast/Toaster.test.tsx b/ui/components/shadcn/toast/Toaster.test.tsx
index 9ca363271d..3939171b2a 100644
--- a/ui/components/shadcn/toast/Toaster.test.tsx
+++ b/ui/components/shadcn/toast/Toaster.test.tsx
@@ -30,7 +30,8 @@ describe("Toaster", () => {
"max-h-48",
"overflow-x-hidden",
"overflow-y-auto",
- "break-all",
+ "wrap-anywhere",
+ "break-normal",
"whitespace-pre-wrap",
);
expect(description.parentElement).toHaveClass(
diff --git a/ui/components/shared/task-polling-watcher.tsx b/ui/components/shared/task-polling-watcher.tsx
index 6cbd2fd4d8..dd037f1567 100644
--- a/ui/components/shared/task-polling-watcher.tsx
+++ b/ui/components/shared/task-polling-watcher.tsx
@@ -10,7 +10,11 @@ import {
} from "@/app/(prowler)/compliance/_lib/cross-provider-pdf";
import { jiraDispatchTaskHandler } from "@/components/findings/jira-dispatch-task-handler";
import { integrationConnectionTaskHandler } from "@/components/integrations/integration-connection-task-handler";
+import { registryArtifactTaskHandler } from "@/components/registry/registry-artifact-task-handler";
+import { registryCredentialTaskHandler } from "@/components/registry/registry-credential-task-handler";
import { useMountEffect } from "@/hooks/use-mount-effect";
+import { REGISTRY_ARTIFACT_TASK_KIND } from "@/lib/registry/artifact-execution";
+import { REGISTRY_CREDENTIAL_TASK_KIND } from "@/lib/registry/credential-task";
import {
registerTaskKindHandler,
resumePendingTasks,
@@ -26,6 +30,14 @@ import {
registerTaskKindHandler(CROSS_PROVIDER_PDF_TASK_KIND, crossProviderPdfHandler);
registerTaskKindHandler(CROSS_ACCOUNT_PDF_TASK_KIND, crossAccountPdfHandler);
registerTaskKindHandler(JIRA_DISPATCH_TASK_KIND, jiraDispatchTaskHandler);
+registerTaskKindHandler(
+ REGISTRY_ARTIFACT_TASK_KIND,
+ registryArtifactTaskHandler,
+);
+registerTaskKindHandler(
+ REGISTRY_CREDENTIAL_TASK_KIND,
+ registryCredentialTaskHandler,
+);
registerTaskKindHandler(
INTEGRATION_CONNECTION_TASK_KIND,
integrationConnectionTaskHandler,
diff --git a/ui/components/users/profile/memberships-card-client.test.tsx b/ui/components/users/profile/memberships-card-client.test.tsx
index c5ae7f5e4e..f18b6cc36b 100644
--- a/ui/components/users/profile/memberships-card-client.test.tsx
+++ b/ui/components/users/profile/memberships-card-client.test.tsx
@@ -205,4 +205,22 @@ describe("MembershipsCardClient", () => {
screen.getByRole("menuitem", { name: /delete organization/i }),
).toBeInTheDocument();
});
+
+ it("hides the create organization action when self-registration is disabled", () => {
+ // Given / When
+ render(
+ ,
+ );
+
+ // Then
+ expect(
+ screen.queryByRole("button", { name: "Create organization" }),
+ ).not.toBeInTheDocument();
+ });
});
diff --git a/ui/components/users/profile/memberships-card-client.tsx b/ui/components/users/profile/memberships-card-client.tsx
index 10b7f28e24..7f05b9cd64 100644
--- a/ui/components/users/profile/memberships-card-client.tsx
+++ b/ui/components/users/profile/memberships-card-client.tsx
@@ -52,6 +52,7 @@ interface MembershipsCardClientProps {
tenantsMap: Record;
hasManageAccount: boolean;
sessionTenantId: string | undefined;
+ canCreateOrganization?: boolean;
}
const OrganizationNameCell = ({ name }: { name: string }) => (
@@ -204,6 +205,7 @@ export const MembershipsCardClient = ({
tenantsMap,
hasManageAccount,
sessionTenantId,
+ canCreateOrganization = true,
}: MembershipsCardClientProps) => {
const [isCreateOpen, setIsCreateOpen] = useState(false);
@@ -232,13 +234,15 @@ export const MembershipsCardClient = ({
return (
<>
-
-
-
+ {canCreateOrganization && (
+
+
+
+ )}
@@ -250,15 +254,17 @@ export const MembershipsCardClient = ({
-
- setIsCreateOpen(true)}
- >
- Create organization
-
-
+ {canCreateOrganization && (
+
+ setIsCreateOpen(true)}
+ >
+ Create organization
+
+
+ )}
{memberships.length === 0 ? (
diff --git a/ui/components/users/profile/memberships-card.tsx b/ui/components/users/profile/memberships-card.tsx
index 56e03cb9f8..aa5b8884aa 100644
--- a/ui/components/users/profile/memberships-card.tsx
+++ b/ui/components/users/profile/memberships-card.tsx
@@ -1,3 +1,4 @@
+import { isSelfRegistrationEnabled } from "@/lib/shared/env";
import { MembershipDetailData, TenantDetailData } from "@/types/users";
import { MembershipsCardClient } from "./memberships-card-client";
@@ -19,6 +20,7 @@ export const MembershipsCard = ({
tenantsMap={tenantsMap}
hasManageAccount={hasManageAccount}
sessionTenantId={sessionTenantId}
+ canCreateOrganization={isSelfRegistrationEnabled()}
/>
);
};
diff --git a/ui/hooks/use-auth.ts b/ui/hooks/use-auth.ts
index 4e5d5ea76c..20bc041f25 100644
--- a/ui/hooks/use-auth.ts
+++ b/ui/hooks/use-auth.ts
@@ -16,6 +16,7 @@ export function useAuth() {
manage_billing: false,
manage_alerts: false,
manage_lighthouse_ai_configuration: false,
+ manage_registry: false,
unlimited_visibility: false,
};
diff --git a/ui/lib/auth-callback-url.test.ts b/ui/lib/auth-callback-url.test.ts
index 3153a06eac..0bac5abcf8 100644
--- a/ui/lib/auth-callback-url.test.ts
+++ b/ui/lib/auth-callback-url.test.ts
@@ -6,6 +6,7 @@ import {
getAttributionParamsFromCallbackPath,
getInvitationTokenFromCallbackPath,
getSafeCallbackPath,
+ isSelfRegistrationDisabledResponse,
} from "@/lib/auth-callback-url";
describe("auth callback URL helpers", () => {
@@ -150,3 +151,40 @@ describe("auth callback URL helpers", () => {
});
});
});
+
+describe("isSelfRegistrationDisabledResponse", () => {
+ it("is true for a 403 carrying the self_registration_disabled code", async () => {
+ const response = Response.json(
+ { errors: [{ code: "self_registration_disabled", status: "403" }] },
+ { status: 403 },
+ );
+
+ await expect(isSelfRegistrationDisabledResponse(response)).resolves.toBe(
+ true,
+ );
+ });
+
+ it("is false for a 403 with another code", async () => {
+ const response = Response.json(
+ { errors: [{ code: "partner_provisioned", status: "403" }] },
+ { status: 403 },
+ );
+
+ await expect(isSelfRegistrationDisabledResponse(response)).resolves.toBe(
+ false,
+ );
+ });
+
+ it("is false for non-403 responses and unparsable bodies", async () => {
+ await expect(
+ isSelfRegistrationDisabledResponse(
+ new Response("self_registration_disabled", { status: 400 }),
+ ),
+ ).resolves.toBe(false);
+ await expect(
+ isSelfRegistrationDisabledResponse(
+ new Response("not json", { status: 403 }),
+ ),
+ ).resolves.toBe(false);
+ });
+});
diff --git a/ui/lib/auth-callback-url.ts b/ui/lib/auth-callback-url.ts
index 2cba07ff6d..1e370887e1 100644
--- a/ui/lib/auth-callback-url.ts
+++ b/ui/lib/auth-callback-url.ts
@@ -100,3 +100,25 @@ export const getAttributionParamsFromCallbackPath = (
return {};
}
};
+
+const SELF_REGISTRATION_DISABLED_CODE = "self_registration_disabled";
+
+// The API answers a social login from a brand-new user with this error code
+// when the deployment only allows invited users.
+export const isSelfRegistrationDisabledResponse = async (
+ response: Response,
+): Promise => {
+ if (response.status !== 403) return false;
+ try {
+ const body = (await response.json()) as {
+ errors?: Array<{ code?: string }>;
+ };
+ return (
+ body.errors?.some(
+ (error) => error.code === SELF_REGISTRATION_DISABLED_CODE,
+ ) ?? false
+ );
+ } catch (_error) {
+ return false;
+ }
+};
diff --git a/ui/lib/auth/current-user.test.ts b/ui/lib/auth/current-user.test.ts
new file mode 100644
index 0000000000..4236047bf2
--- /dev/null
+++ b/ui/lib/auth/current-user.test.ts
@@ -0,0 +1,146 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+const { fetchMock } = vi.hoisted(() => ({ fetchMock: vi.fn() }));
+vi.mock("@/lib", () => ({ apiBaseUrl: "https://api.example.com/api/v1" }));
+
+import { fetchCurrentUser } from "./current-user";
+
+const role = (manage_registry: unknown) => ({
+ type: "roles",
+ id: "role-1",
+ attributes: { manage_registry },
+});
+const document = (roles: unknown) => ({
+ data: {
+ type: "users",
+ id: "user-1",
+ attributes: { name: "Jane", email: "jane@example.com" },
+ },
+ included: roles,
+});
+const reply = (body: unknown, status = 200) =>
+ new Response(JSON.stringify(body), { status });
+
+describe("fetchCurrentUser", () => {
+ beforeEach(() => vi.stubGlobal("fetch", fetchMock));
+
+ it("accepts one current exact-true role without caching", async () => {
+ // Given
+ fetchMock.mockResolvedValue(reply(document([role(true)])));
+ const controller = new AbortController();
+ // When
+ const result = await fetchCurrentUser("access-token", {
+ signal: controller.signal,
+ });
+ // Then
+ expect(result.manageRegistry).toBe(true);
+ expect(fetchMock).toHaveBeenCalledWith(
+ "https://api.example.com/api/v1/users/me?include=roles",
+ expect.objectContaining({ cache: "no-store", signal: controller.signal }),
+ );
+ });
+
+ it.each([
+ [false, false],
+ [undefined, undefined],
+ ["true", undefined],
+ ])(
+ "keeps only exact boolean authority for %j",
+ async (permission, expected) => {
+ // Given
+ fetchMock.mockResolvedValue(reply(document([role(permission)])));
+ // When / Then
+ await expect(fetchCurrentUser("access-token")).resolves.toMatchObject({
+ manageRegistry: expected,
+ permissions: { manage_registry: permission === true },
+ });
+ },
+ );
+
+ it("combines exact-true permissions from every assigned role", async () => {
+ // Given
+ fetchMock.mockResolvedValue(
+ reply(
+ document([
+ {
+ ...role(false),
+ attributes: {
+ manage_providers: true,
+ manage_scans: false,
+ manage_registry: false,
+ manage_users: "true",
+ },
+ },
+ {
+ ...role(true),
+ id: "role-2",
+ attributes: {
+ manage_providers: false,
+ manage_scans: true,
+ manage_registry: true,
+ manage_users: 1,
+ },
+ },
+ ]),
+ ),
+ );
+
+ // When
+ const result = await fetchCurrentUser("access-token");
+
+ // Then
+ expect(result.permissions).toMatchObject({
+ manage_providers: true,
+ manage_scans: true,
+ manage_registry: true,
+ manage_users: false,
+ manage_account: false,
+ });
+ expect(result.manageRegistry).toBe(true);
+ });
+
+ it.each([
+ { assignments: [true, false], expected: true },
+ { assignments: [true, undefined], expected: true },
+ { assignments: [false, false], expected: false },
+ { assignments: [false, undefined], expected: undefined },
+ { assignments: [false, "true"], expected: undefined },
+ ])(
+ "resolves Registry authority across $assignments",
+ async ({ assignments, expected }) => {
+ // Given
+ fetchMock.mockResolvedValue(
+ reply(
+ document(
+ assignments.map((permission, index) => ({
+ ...role(permission),
+ id: `role-${index}`,
+ })),
+ ),
+ ),
+ );
+
+ // When / Then
+ await expect(fetchCurrentUser("access-token")).resolves.toMatchObject({
+ manageRegistry: expected,
+ permissions: { manage_registry: expected === true },
+ });
+ },
+ );
+
+ it.each([
+ [document([]), 200],
+ [{ data: { type: "users" } }, 200],
+ [document([role(true)]), 401],
+ [document([role(true)]), 403],
+ [document([role(true)]), 500],
+ ])(
+ "rejects absent, malformed, or unsuccessful evidence",
+ async (body, status) => {
+ // Given
+ fetchMock.mockResolvedValue(reply(body, status));
+ // When / Then
+ await expect(fetchCurrentUser("access-token")).rejects.toThrow();
+ },
+ );
+});
diff --git a/ui/lib/auth/current-user.ts b/ui/lib/auth/current-user.ts
new file mode 100644
index 0000000000..cc166187e0
--- /dev/null
+++ b/ui/lib/auth/current-user.ts
@@ -0,0 +1,102 @@
+import { z } from "zod";
+
+import { apiBaseUrl } from "@/lib";
+import { UserMeError } from "@/lib/auth-errors";
+import { PERMISSION_KEY, type RolePermissionAttributes } from "@/types/users";
+
+const currentUserDocumentSchema = z.object({
+ data: z.object({
+ type: z.literal("users"),
+ id: z.string().min(1),
+ attributes: z.object({
+ name: z.string(),
+ email: z.string(),
+ company_name: z.string().optional(),
+ date_joined: z.string().optional(),
+ }),
+ }),
+ included: z.array(
+ z.object({
+ type: z.literal("roles"),
+ id: z.string().min(1),
+ attributes: z.record(z.string(), z.unknown()),
+ }),
+ ),
+});
+
+export interface CurrentUser {
+ name: string;
+ email: string;
+ company?: string;
+ dateJoined?: string;
+ permissions: RolePermissionAttributes;
+ manageRegistry: true | false | undefined;
+}
+
+const toPermissions = (
+ roles: readonly Record[],
+): RolePermissionAttributes =>
+ Object.fromEntries(
+ Object.values(PERMISSION_KEY).map((key) => [
+ key,
+ roles.some((attributes) => attributes[key] === true),
+ ]),
+ ) as RolePermissionAttributes;
+
+export async function fetchCurrentUser(
+ accessToken: string,
+ options: { signal?: AbortSignal } = {},
+): Promise {
+ if (!accessToken.trim()) throw new Error("Current user token is required");
+
+ let response: Response;
+ try {
+ response = await fetch(`${apiBaseUrl}/users/me?include=roles`, {
+ method: "GET",
+ cache: "no-store",
+ signal: options.signal,
+ headers: {
+ Accept: "application/vnd.api+json",
+ Authorization: `Bearer ${accessToken}`,
+ },
+ });
+ } catch {
+ throw new UserMeError("Unable to load user");
+ }
+
+ if (!response.ok) {
+ const message =
+ response.status === 401
+ ? "Invalid or expired token"
+ : response.status === 403
+ ? "Access denied"
+ : response.status === 404
+ ? "User not found"
+ : "Unable to load user";
+ throw new UserMeError(message, response.status);
+ }
+
+ const parsed = currentUserDocumentSchema.safeParse(
+ await response.json().catch(() => undefined),
+ );
+ if (!parsed.success) throw new Error("Malformed current user response");
+
+ const roles = parsed.data.included.map((role) => role.attributes);
+ if (roles.length === 0) {
+ throw new Error("Missing current user role");
+ }
+
+ const permissions = toPermissions(roles);
+ return {
+ name: parsed.data.data.attributes.name,
+ email: parsed.data.data.attributes.email,
+ company: parsed.data.data.attributes.company_name,
+ dateJoined: parsed.data.data.attributes.date_joined,
+ permissions,
+ manageRegistry: permissions.manage_registry
+ ? true
+ : roles.every((attributes) => attributes.manage_registry === false)
+ ? false
+ : undefined,
+ };
+}
diff --git a/ui/lib/compliance/c5.tsx b/ui/lib/compliance/c5.tsx
index 4839df3d66..c39a2dd538 100644
--- a/ui/lib/compliance/c5.tsx
+++ b/ui/lib/compliance/c5.tsx
@@ -3,13 +3,12 @@ import { ComplianceAccordionRequirementTitle } from "@/components/compliance/com
import { ComplianceAccordionTitle } from "@/components/compliance/compliance-accordion/compliance-accordion-title";
import { AccordionItemProps } from "@/components/shadcn/accordion/Accordion";
import { FindingStatus } from "@/components/shadcn/table/status-finding-badge";
-import {
+import type {
AttributesData,
C5AttributesMetadata,
Control,
Framework,
Requirement,
- REQUIREMENT_STATUS,
RequirementsData,
RequirementStatus,
} from "@/types/compliance";
@@ -20,14 +19,9 @@ import {
findOrCreateCategory,
findOrCreateControl,
findOrCreateFramework,
+ getStatusCounters,
} from "./commons";
-const getStatusCounters = (status: RequirementStatus) => ({
- pass: status === REQUIREMENT_STATUS.PASS ? 1 : 0,
- fail: status === REQUIREMENT_STATUS.FAIL ? 1 : 0,
- manual: status === REQUIREMENT_STATUS.MANUAL ? 1 : 0,
-});
-
export const mapComplianceData = (
attributesData: AttributesData,
requirementsData: RequirementsData,
diff --git a/ui/lib/compliance/cis-controls.tsx b/ui/lib/compliance/cis-controls.tsx
index 7a7f283fc6..a1fab412e1 100644
--- a/ui/lib/compliance/cis-controls.tsx
+++ b/ui/lib/compliance/cis-controls.tsx
@@ -3,12 +3,11 @@ import { ComplianceAccordionRequirementTitle } from "@/components/compliance/com
import { ComplianceAccordionTitle } from "@/components/compliance/compliance-accordion/compliance-accordion-title";
import { AccordionItemProps } from "@/components/shadcn/accordion/Accordion";
import { FindingStatus } from "@/components/shadcn/table/status-finding-badge";
-import {
+import type {
AttributesData,
CISControlsAttributesMetadata,
Framework,
Requirement,
- REQUIREMENT_STATUS,
RequirementsData,
RequirementStatus,
} from "@/types/compliance";
@@ -19,14 +18,9 @@ import {
findOrCreateCategory,
findOrCreateControl,
findOrCreateFramework,
+ getStatusCounters,
} from "./commons";
-const getStatusCounters = (status: RequirementStatus) => ({
- pass: status === REQUIREMENT_STATUS.PASS ? 1 : 0,
- fail: status === REQUIREMENT_STATUS.FAIL ? 1 : 0,
- manual: status === REQUIREMENT_STATUS.MANUAL ? 1 : 0,
-});
-
// Sort the 18 CIS Controls by their leading number ("1. ...", "2. ...", ...,
// "18. ...") so the accordion always reads in canonical control order
// regardless of how the API returns the sections.
diff --git a/ui/lib/compliance/cmmc.tsx b/ui/lib/compliance/cmmc.ts
similarity index 56%
rename from ui/lib/compliance/cmmc.tsx
rename to ui/lib/compliance/cmmc.ts
index 557c28b95c..748d0ed39c 100644
--- a/ui/lib/compliance/cmmc.tsx
+++ b/ui/lib/compliance/cmmc.ts
@@ -1,14 +1,8 @@
-import { ClientAccordionContent } from "@/components/compliance/compliance-accordion/client-accordion-content";
-import { ComplianceAccordionRequirementTitle } from "@/components/compliance/compliance-accordion/compliance-accordion-requeriment-title";
-import { ComplianceAccordionTitle } from "@/components/compliance/compliance-accordion/compliance-accordion-title";
-import { AccordionItemProps } from "@/components/shadcn/accordion/Accordion";
-import { FindingStatus } from "@/components/shadcn/table/status-finding-badge";
-import {
+import type {
AttributesData,
CMMCAttributesMetadata,
Framework,
Requirement,
- REQUIREMENT_STATUS,
RequirementsData,
RequirementStatus,
} from "@/types/compliance";
@@ -19,8 +13,11 @@ import {
findOrCreateCategory,
findOrCreateControl,
findOrCreateFramework,
+ getStatusCounters,
} from "./commons";
+export { toGroupedAccordionItems as toAccordionItems } from "./grouped-accordion";
+
// Canonical NIST SP 800-171 family order for the 14 CMMC domains, so the
// accordion always reads in the same order regardless of the API response.
export const CMMC_DOMAIN_ORDER: readonly string[] = [
@@ -40,12 +37,6 @@ export const CMMC_DOMAIN_ORDER: readonly string[] = [
"System and Information Integrity",
];
-const getStatusCounters = (status: RequirementStatus) => ({
- pass: status === REQUIREMENT_STATUS.PASS ? 1 : 0,
- fail: status === REQUIREMENT_STATUS.FAIL ? 1 : 0,
- manual: status === REQUIREMENT_STATUS.MANUAL ? 1 : 0,
-});
-
export const mapComplianceData = (
attributesData: AttributesData,
requirementsData: RequirementsData,
@@ -109,56 +100,3 @@ export const mapComplianceData = (
return frameworks;
};
-
-export const toAccordionItems = (
- data: Framework[],
- scanId: string | undefined,
-): AccordionItemProps[] => {
- const safeId = scanId || "";
-
- return data.flatMap((framework) =>
- framework.categories.map((category) => ({
- key: `${framework.name}-${category.name}`,
- title: (
-
- ),
- content: "",
- // Domain → requirements (flat, no intermediate "control" level).
- // Keys are derived from the requirement name (which starts with the
- // unique CMMC id, e.g. "AC.L1-b.1.i") instead of the array index, so
- // expanded state stays attached to the right requirement even if the
- // list is reordered or filtered.
- items: category.controls.flatMap((control) =>
- control.requirements.map((requirement) => ({
- key: `${framework.name}-${category.name}-${requirement.name}`,
- title: (
-
- ),
- content: (
-
- ),
- items: [],
- })),
- ),
- })),
- );
-};
diff --git a/ui/lib/compliance/commons.tsx b/ui/lib/compliance/commons.tsx
index 24a7b8407c..15b315be4d 100644
--- a/ui/lib/compliance/commons.tsx
+++ b/ui/lib/compliance/commons.tsx
@@ -7,6 +7,7 @@ import {
REQUIREMENT_STATUS,
RequirementItemData,
RequirementsData,
+ RequirementsTotals,
RequirementStatus,
TOP_FAILED_DATA_TYPE,
TopFailedDataType,
@@ -66,7 +67,7 @@ const incrementFailedCount = (
};
export const updateCounters = (
- target: { pass: number; fail: number; manual: number },
+ target: RequirementsTotals,
status: RequirementStatus,
) => {
if (status === REQUIREMENT_STATUS.MANUAL) {
@@ -78,6 +79,14 @@ export const updateCounters = (
}
};
+export const getStatusCounters = (
+ status: RequirementStatus,
+): RequirementsTotals => {
+ const counters: RequirementsTotals = { pass: 0, fail: 0, manual: 0 };
+ updateCounters(counters, status);
+ return counters;
+};
+
export const getTopFailedSections = (
mappedData: Framework[],
): TopFailedResult => {
diff --git a/ui/lib/compliance/compliance-mapper.test.ts b/ui/lib/compliance/compliance-mapper.test.ts
index c7ce69040e..be7ba8074c 100644
--- a/ui/lib/compliance/compliance-mapper.test.ts
+++ b/ui/lib/compliance/compliance-mapper.test.ts
@@ -54,6 +54,13 @@ vi.mock(
"@/components/compliance/compliance-custom-details/ens-details",
() => ({ ENSCustomDetails: stubFactory("ENSStub") }),
);
+vi.mock(
+ "@/components/compliance/compliance-custom-details/fedramp-20x-details",
+ () => ({
+ FedRAMP20xFRRCustomDetails: stubFactory("FedRAMP20xFRRStub"),
+ FedRAMP20xKSICustomDetails: stubFactory("FedRAMP20xKSIStub"),
+ }),
+);
vi.mock(
"@/components/compliance/compliance-custom-details/generic-details",
() => ({ GenericCustomDetails: stubFactory("GenericStub") }),
@@ -159,6 +166,8 @@ describe("getComplianceMapper", () => {
{ framework: "CMMC", expected: "CMMCStub" },
{ framework: "Okta-IDaaS-STIG", expected: "OktaIDaaSStigStub" },
{ framework: "Cyber-Essentials", expected: "CyberEssentialsStub" },
+ { framework: "FedRAMP-20x-KSI", expected: "FedRAMP20xKSIStub" },
+ { framework: "FedRAMP-20x-FRR-Class-C", expected: "FedRAMP20xFRRStub" },
];
for (const { framework, expected } of wiring) {
@@ -206,6 +215,8 @@ describe("getComplianceMapper", () => {
"CMMC",
"Okta-IDaaS-STIG",
"Cyber-Essentials",
+ "FedRAMP-20x-KSI",
+ "FedRAMP-20x-FRR-Class-C",
]) {
const mapper = getComplianceMapper(framework);
expect(Object.keys(mapper).sort(), framework).toEqual(expectedKeys);
diff --git a/ui/lib/compliance/compliance-mapper.ts b/ui/lib/compliance/compliance-mapper.ts
index 80b3558aeb..d80a980485 100644
--- a/ui/lib/compliance/compliance-mapper.ts
+++ b/ui/lib/compliance/compliance-mapper.ts
@@ -11,6 +11,10 @@ import { CSACustomDetails } from "@/components/compliance/compliance-custom-deta
import { CyberEssentialsCustomDetails } from "@/components/compliance/compliance-custom-details/cyber-essentials-details";
import { DORACustomDetails } from "@/components/compliance/compliance-custom-details/dora-details";
import { ENSCustomDetails } from "@/components/compliance/compliance-custom-details/ens-details";
+import {
+ FedRAMP20xFRRCustomDetails,
+ FedRAMP20xKSICustomDetails,
+} from "@/components/compliance/compliance-custom-details/fedramp-20x-details";
import { GenericCustomDetails } from "@/components/compliance/compliance-custom-details/generic-details";
import { ISOCustomDetails } from "@/components/compliance/compliance-custom-details/iso-details";
import { KISACustomDetails } from "@/components/compliance/compliance-custom-details/kisa-details";
@@ -72,6 +76,11 @@ import {
mapComplianceData as mapENSComplianceData,
toAccordionItems as toENSAccordionItems,
} from "./ens";
+import {
+ mapFRRComplianceData as mapFedRAMP20xFRRComplianceData,
+ mapKSIComplianceData as mapFedRAMP20xKSIComplianceData,
+ toAccordionItems as toFedRAMP20xAccordionItems,
+} from "./fedramp-20x";
import {
mapComplianceData as mapGenericComplianceData,
toAccordionItems as toGenericAccordionItems,
@@ -296,6 +305,27 @@ const getComplianceMappers = (): Record => ({
getDetailsComponent: (requirement: Requirement) =>
createElement(CMMCCustomDetails, { requirement }),
},
+ // Universal frameworks must compose requirement names as `${id} - ${name}`
+ // (see `composeRequirementName`); the generic mapper does not, so they need
+ // a dedicated entry for the cross-provider breakdown to render.
+ "FedRAMP-20x-KSI": {
+ mapComplianceData: mapFedRAMP20xKSIComplianceData,
+ toAccordionItems: toFedRAMP20xAccordionItems,
+ getTopFailedSections,
+ calculateCategoryHeatmapData: (data: Framework[]) =>
+ calculateCategoryHeatmapData(data),
+ getDetailsComponent: (requirement: Requirement) =>
+ createElement(FedRAMP20xKSICustomDetails, { requirement }),
+ },
+ "FedRAMP-20x-FRR-Class-C": {
+ mapComplianceData: mapFedRAMP20xFRRComplianceData,
+ toAccordionItems: toFedRAMP20xAccordionItems,
+ getTopFailedSections,
+ calculateCategoryHeatmapData: (data: Framework[]) =>
+ calculateCategoryHeatmapData(data),
+ getDetailsComponent: (requirement: Requirement) =>
+ createElement(FedRAMP20xFRRCustomDetails, { requirement }),
+ },
});
/**
diff --git a/ui/lib/compliance/compliance-report-types.test.ts b/ui/lib/compliance/compliance-report-types.test.ts
index 8a431d9748..59b26beed3 100644
--- a/ui/lib/compliance/compliance-report-types.test.ts
+++ b/ui/lib/compliance/compliance-report-types.test.ts
@@ -41,6 +41,8 @@ describe("isOcsfSupported", () => {
expect(isOcsfSupported("csa_ccm_4.0")).toBe(true);
expect(isOcsfSupported("cis_controls_8.1")).toBe(true);
expect(isOcsfSupported("cmmc_2.0")).toBe(true);
+ expect(isOcsfSupported("fedramp_20x_ksi_2026")).toBe(true);
+ expect(isOcsfSupported("fedramp_20x_frr_class_c_2026")).toBe(true);
});
it("returns false for legacy/per-provider frameworks without OCSF output", () => {
diff --git a/ui/lib/compliance/compliance-report-types.ts b/ui/lib/compliance/compliance-report-types.ts
index 68ef3072de..2553df013d 100644
--- a/ui/lib/compliance/compliance-report-types.ts
+++ b/ui/lib/compliance/compliance-report-types.ts
@@ -167,7 +167,8 @@ export const pickLatestCisPerProvider = (
* Only universal compliance frameworks that declare an ``outputs`` block in
* their schema (see ``prowler/compliance/.json``) produce a dedicated
* OCSF artifact during scan output generation. Today that is DORA,
- * CSA CCM 4.0, CIS Controls 8.1 and CMMC 2.0. Any other framework only
+ * CSA CCM 4.0, CIS Controls 8.1, CMMC 2.0 and FedRAMP 20x (KSI and Class C
+ * FRR). Any other framework only
* offers CSV (and, for the curated list above, PDF).
*
* Keep this Set in lock-step with the backend: ``get_prowler_provider_compliance``
@@ -182,6 +183,8 @@ const OCSF_SUPPORTED_COMPLIANCE_IDS: ReadonlySet = new Set([
"csa_ccm_4.0",
"cis_controls_8.1",
"cmmc_2.0",
+ "fedramp_20x_ksi_2026",
+ "fedramp_20x_frr_class_c_2026",
]);
export const isOcsfSupported = (complianceId: string | undefined): boolean =>
diff --git a/ui/lib/compliance/csa.tsx b/ui/lib/compliance/csa.tsx
index 3bb601de0c..c38136b19f 100644
--- a/ui/lib/compliance/csa.tsx
+++ b/ui/lib/compliance/csa.tsx
@@ -4,12 +4,11 @@ import { ComplianceAccordionTitle } from "@/components/compliance/compliance-acc
import { ComplianceBadgeVariant } from "@/components/compliance/compliance-custom-details/shared-components";
import { AccordionItemProps } from "@/components/shadcn/accordion/Accordion";
import { FindingStatus } from "@/components/shadcn/table/status-finding-badge";
-import {
+import type {
AttributesData,
CSAAttributesMetadata,
Framework,
Requirement,
- REQUIREMENT_STATUS,
RequirementsData,
RequirementStatus,
} from "@/types/compliance";
@@ -20,6 +19,7 @@ import {
findOrCreateCategory,
findOrCreateControl,
findOrCreateFramework,
+ getStatusCounters,
} from "./commons";
export interface CSAMappingSection {
@@ -36,12 +36,6 @@ export const CSA_MAPPING_SECTIONS: CSAMappingSection[] = [
},
];
-const getStatusCounters = (status: RequirementStatus) => ({
- pass: status === REQUIREMENT_STATUS.PASS ? 1 : 0,
- fail: status === REQUIREMENT_STATUS.FAIL ? 1 : 0,
- manual: status === REQUIREMENT_STATUS.MANUAL ? 1 : 0,
-});
-
export const mapComplianceData = (
attributesData: AttributesData,
requirementsData: RequirementsData,
diff --git a/ui/lib/compliance/cyber-essentials.tsx b/ui/lib/compliance/cyber-essentials.tsx
index aee4d8c5db..ebdd73209d 100644
--- a/ui/lib/compliance/cyber-essentials.tsx
+++ b/ui/lib/compliance/cyber-essentials.tsx
@@ -3,12 +3,11 @@ import { ComplianceAccordionRequirementTitle } from "@/components/compliance/com
import { ComplianceAccordionTitle } from "@/components/compliance/compliance-accordion/compliance-accordion-title";
import { AccordionItemProps } from "@/components/shadcn/accordion/Accordion";
import { FindingStatus } from "@/components/shadcn/table/status-finding-badge";
-import {
+import type {
AttributesData,
CyberEssentialsAttributesMetadata,
Framework,
Requirement,
- REQUIREMENT_STATUS,
RequirementsData,
RequirementStatus,
} from "@/types/compliance";
@@ -19,6 +18,7 @@ import {
findOrCreateCategory,
findOrCreateControl,
findOrCreateFramework,
+ getStatusCounters,
} from "./commons";
// Display order for the five Cyber Essentials control themes in the accordion
@@ -33,12 +33,6 @@ export const CYBER_ESSENTIALS_THEME_ORDER: readonly string[] = [
"Malware Protection",
];
-const getStatusCounters = (status: RequirementStatus) => ({
- pass: status === REQUIREMENT_STATUS.PASS ? 1 : 0,
- fail: status === REQUIREMENT_STATUS.FAIL ? 1 : 0,
- manual: status === REQUIREMENT_STATUS.MANUAL ? 1 : 0,
-});
-
export const mapComplianceData = (
attributesData: AttributesData,
requirementsData: RequirementsData,
diff --git a/ui/lib/compliance/dora.tsx b/ui/lib/compliance/dora.tsx
index 8ac364f29f..7fc6bba26c 100644
--- a/ui/lib/compliance/dora.tsx
+++ b/ui/lib/compliance/dora.tsx
@@ -3,12 +3,11 @@ import { ComplianceAccordionRequirementTitle } from "@/components/compliance/com
import { ComplianceAccordionTitle } from "@/components/compliance/compliance-accordion/compliance-accordion-title";
import { AccordionItemProps } from "@/components/shadcn/accordion/Accordion";
import { FindingStatus } from "@/components/shadcn/table/status-finding-badge";
-import {
+import type {
AttributesData,
DORAAttributesMetadata,
Framework,
Requirement,
- REQUIREMENT_STATUS,
RequirementsData,
RequirementStatus,
} from "@/types/compliance";
@@ -19,6 +18,7 @@ import {
findOrCreateCategory,
findOrCreateControl,
findOrCreateFramework,
+ getStatusCounters,
} from "./commons";
// Display order for DORA pillars in the accordion and any grouped chart. The
@@ -33,12 +33,6 @@ export const DORA_PILLAR_ORDER: readonly string[] = [
"Information Sharing",
];
-const getStatusCounters = (status: RequirementStatus) => ({
- pass: status === REQUIREMENT_STATUS.PASS ? 1 : 0,
- fail: status === REQUIREMENT_STATUS.FAIL ? 1 : 0,
- manual: status === REQUIREMENT_STATUS.MANUAL ? 1 : 0,
-});
-
export const mapComplianceData = (
attributesData: AttributesData,
requirementsData: RequirementsData,
diff --git a/ui/lib/compliance/fedramp-20x.test.ts b/ui/lib/compliance/fedramp-20x.test.ts
new file mode 100644
index 0000000000..21d40c69bc
--- /dev/null
+++ b/ui/lib/compliance/fedramp-20x.test.ts
@@ -0,0 +1,183 @@
+import { describe, expect, it, vi } from "vitest";
+
+vi.mock(
+ "@/components/compliance/compliance-accordion/client-accordion-content",
+ () => ({ ClientAccordionContent: () => null }),
+);
+vi.mock(
+ "@/components/compliance/compliance-accordion/compliance-accordion-requeriment-title",
+ () => ({ ComplianceAccordionRequirementTitle: () => null }),
+);
+vi.mock(
+ "@/components/compliance/compliance-accordion/compliance-accordion-title",
+ () => ({ ComplianceAccordionTitle: () => null }),
+);
+
+import {
+ buildRequirementExtrasMap,
+ crossProviderToMapperInput,
+} from "@/app/(prowler)/compliance/_lib/cross-provider-adapter";
+import type { CrossProviderOverviewAttributes } from "@/app/(prowler)/compliance/_types";
+import { Framework, Requirement } from "@/types/compliance";
+
+import {
+ mapFRRComplianceData,
+ mapKSIComplianceData,
+ toAccordionItems,
+} from "./fedramp-20x";
+
+type OverviewRequirement =
+ CrossProviderOverviewAttributes["requirements"][number];
+
+const buildOverview = (
+ framework: string,
+ requirements: Array>,
+): CrossProviderOverviewAttributes =>
+ ({
+ framework,
+ version: "2026",
+ description: "",
+ providers: ["aws"],
+ compatible_providers: ["aws"],
+ scan_ids_by_provider: {},
+ requirements: requirements.map((requirement) => ({
+ ...requirement,
+ description: "Requirement text.",
+ status: "PASS",
+ providers: { aws: "PASS" },
+ check_ids_by_provider: { aws: ["check_one"] },
+ })),
+ }) as unknown as CrossProviderOverviewAttributes;
+
+const mapOverview = (
+ overview: CrossProviderOverviewAttributes,
+ mapper: typeof mapKSIComplianceData,
+): Framework[] => {
+ const { attributesData, requirementsData } =
+ crossProviderToMapperInput(overview);
+ return mapper(attributesData, requirementsData);
+};
+
+const allRequirements = (frameworks: Framework[]): Requirement[] =>
+ frameworks.flatMap((framework) =>
+ framework.categories.flatMap((category) =>
+ category.controls.flatMap((control) => control.requirements),
+ ),
+ );
+
+const KSI_OVERVIEW = buildOverview("FedRAMP-20x-KSI", [
+ {
+ id: "KSI-SVC-VRI",
+ name: "Validating Resource Integrity",
+ attributes: {
+ Theme: "KSI-SVC: Service Configuration",
+ NISTControls: "SC-13",
+ ClassApplicability: "Required for Classes B and C",
+ },
+ },
+ {
+ id: "KSI-CED-RAT",
+ name: "Reviewing All Training",
+ attributes: {
+ Theme: "KSI-CED: Cybersecurity Education",
+ NISTControls: null,
+ ClassApplicability: "Required for Classes B and C",
+ },
+ },
+]);
+
+const FRR_OVERVIEW = buildOverview("FedRAMP-20x-FRR-Class-C", [
+ {
+ id: "CMU-CSO-UVM",
+ name: "Using Validated Cryptographic Modules",
+ attributes: {
+ Ruleset: "CMU: Cryptographic Module Use",
+ Subset: "CSO: Cloud Service Provider Responsibilities",
+ Force: "MUST",
+ },
+ },
+ {
+ id: "AFC-CSO-INB",
+ name: "Maintain a FedRAMP Security Inbox",
+ attributes: {
+ Ruleset: "AFC: Addressing FedRAMP Communication",
+ Subset: "CSO: General Provider Responsibilities",
+ Force: "MUST",
+ },
+ },
+]);
+
+describe.each([
+ { label: "KSI", overview: KSI_OVERVIEW, mapper: mapKSIComplianceData },
+ {
+ label: "FRR Class C",
+ overview: FRR_OVERVIEW,
+ mapper: mapFRRComplianceData,
+ },
+])("FedRAMP 20x $label cross-provider join", ({ overview, mapper }) => {
+ it("names every requirement with a key of the per-provider breakdown", () => {
+ const extras = buildRequirementExtrasMap(overview);
+ const names = allRequirements(mapOverview(overview, mapper)).map(
+ (requirement) => requirement.name,
+ );
+
+ expect(names).toHaveLength(overview.requirements.length);
+ for (const name of names) {
+ expect(extras.has(name), name).toBe(true);
+ }
+ });
+});
+
+describe("mapKSIComplianceData", () => {
+ it("groups by Theme in catalog order and exposes KSI attributes", () => {
+ const [framework] = mapOverview(KSI_OVERVIEW, mapKSIComplianceData);
+
+ expect(framework.categories.map((category) => category.name)).toEqual([
+ "KSI-CED: Cybersecurity Education",
+ "KSI-SVC: Service Configuration",
+ ]);
+
+ const [svc] = framework.categories[1].controls[0].requirements;
+ expect(svc.name).toBe("KSI-SVC-VRI - Validating Resource Integrity");
+ expect(svc.theme).toBe("KSI-SVC: Service Configuration");
+ expect(svc.nist_controls).toBe("SC-13");
+ expect(svc.class_applicability).toBe("Required for Classes B and C");
+
+ const [ced] = framework.categories[0].controls[0].requirements;
+ expect(ced.nist_controls).toBeUndefined();
+ });
+});
+
+describe("mapFRRComplianceData", () => {
+ it("groups by Ruleset in catalog order and exposes FRR attributes", () => {
+ const [framework] = mapOverview(FRR_OVERVIEW, mapFRRComplianceData);
+
+ expect(framework.categories.map((category) => category.name)).toEqual([
+ "AFC: Addressing FedRAMP Communication",
+ "CMU: Cryptographic Module Use",
+ ]);
+
+ const [cmu] = framework.categories[1].controls[0].requirements;
+ expect(cmu.ruleset).toBe("CMU: Cryptographic Module Use");
+ expect(cmu.subset).toBe("CSO: Cloud Service Provider Responsibilities");
+ expect(cmu.force).toBe("MUST");
+ expect(framework.pass).toBe(2);
+ });
+});
+
+describe("toAccordionItems (FedRAMP 20x)", () => {
+ it("keys requirement leaves by name instead of position", () => {
+ const items = toAccordionItems(
+ mapOverview(FRR_OVERVIEW, mapFRRComplianceData),
+ "scan-1",
+ );
+
+ expect(items.map((item) => item.key)).toEqual([
+ "FedRAMP-20x-FRR-Class-C-AFC: Addressing FedRAMP Communication",
+ "FedRAMP-20x-FRR-Class-C-CMU: Cryptographic Module Use",
+ ]);
+ expect(items[1].items?.[0]?.key).toBe(
+ "FedRAMP-20x-FRR-Class-C-CMU: Cryptographic Module Use-CMU-CSO-UVM - Using Validated Cryptographic Modules",
+ );
+ });
+});
diff --git a/ui/lib/compliance/fedramp-20x.ts b/ui/lib/compliance/fedramp-20x.ts
new file mode 100644
index 0000000000..fd7d9fb1a3
--- /dev/null
+++ b/ui/lib/compliance/fedramp-20x.ts
@@ -0,0 +1,110 @@
+import type {
+ AttributesData,
+ FedRAMP20xFRRAttributesMetadata,
+ FedRAMP20xKSIAttributesMetadata,
+ Framework,
+ Requirement,
+ RequirementsData,
+ RequirementStatus,
+} from "@/types/compliance";
+
+import {
+ calculateFrameworkCounters,
+ createRequirementsMap,
+ findOrCreateCategory,
+ findOrCreateControl,
+ findOrCreateFramework,
+ getStatusCounters,
+} from "./commons";
+
+export { toGroupedAccordionItems as toAccordionItems } from "./grouped-accordion";
+
+type RequirementFields = Record;
+
+const mapByGroup = (
+ attributesData: AttributesData,
+ requirementsData: RequirementsData,
+ getGroup: (attrs: TMetadata) => string,
+ getFields: (attrs: TMetadata) => RequirementFields,
+): Framework[] => {
+ const attributes = attributesData?.data || [];
+ const requirementsMap = createRequirementsMap(requirementsData);
+ const frameworks: Framework[] = [];
+
+ for (const attributeItem of attributes) {
+ const id = attributeItem.id;
+ const metadataArray = attributeItem.attributes?.attributes
+ ?.metadata as unknown as TMetadata[];
+ const attrs = metadataArray?.[0];
+ if (!attrs) continue;
+
+ const requirementData = requirementsMap.get(id);
+ if (!requirementData) continue;
+
+ const categoryName = getGroup(attrs);
+ const requirementName = attributeItem.attributes.name || "";
+ const status = (requirementData.attributes.status ||
+ "") as RequirementStatus;
+
+ const framework = findOrCreateFramework(
+ frameworks,
+ attributeItem.attributes.framework,
+ );
+ const category = findOrCreateCategory(framework.categories, categoryName);
+ const control = findOrCreateControl(category.controls, categoryName);
+
+ // The name must match `composeRequirementName` in the cross-provider
+ // adapter, or the per-provider breakdown cannot be joined.
+ const requirement: Requirement = {
+ ...getFields(attrs),
+ name: requirementName ? `${id} - ${requirementName}` : id,
+ description: attributeItem.attributes.description,
+ status,
+ check_ids: attributeItem.attributes.attributes.check_ids || [],
+ invalid_config: requirementData.attributes.invalid_config || false,
+ ...getStatusCounters(status),
+ };
+
+ control.requirements.push(requirement);
+ }
+
+ // Theme and Ruleset names start with their catalog code, so alphabetical
+ // order is the catalog order.
+ for (const framework of frameworks) {
+ framework.categories.sort((a, b) => a.name.localeCompare(b.name));
+ }
+
+ calculateFrameworkCounters(frameworks);
+
+ return frameworks;
+};
+
+export const mapKSIComplianceData = (
+ attributesData: AttributesData,
+ requirementsData: RequirementsData,
+): Framework[] =>
+ mapByGroup(
+ attributesData,
+ requirementsData,
+ (attrs) => attrs.Theme,
+ (attrs) => ({
+ theme: attrs.Theme,
+ nist_controls: attrs.NISTControls || undefined,
+ class_applicability: attrs.ClassApplicability,
+ }),
+ );
+
+export const mapFRRComplianceData = (
+ attributesData: AttributesData,
+ requirementsData: RequirementsData,
+): Framework[] =>
+ mapByGroup(
+ attributesData,
+ requirementsData,
+ (attrs) => attrs.Ruleset,
+ (attrs) => ({
+ ruleset: attrs.Ruleset,
+ subset: attrs.Subset,
+ force: attrs.Force,
+ }),
+ );
diff --git a/ui/lib/compliance/grouped-accordion.tsx b/ui/lib/compliance/grouped-accordion.tsx
new file mode 100644
index 0000000000..cea6651079
--- /dev/null
+++ b/ui/lib/compliance/grouped-accordion.tsx
@@ -0,0 +1,55 @@
+import { ClientAccordionContent } from "@/components/compliance/compliance-accordion/client-accordion-content";
+import { ComplianceAccordionRequirementTitle } from "@/components/compliance/compliance-accordion/compliance-accordion-requeriment-title";
+import { ComplianceAccordionTitle } from "@/components/compliance/compliance-accordion/compliance-accordion-title";
+import type { AccordionItemProps } from "@/components/shadcn/accordion/Accordion";
+import type { FindingStatus } from "@/components/shadcn/table/status-finding-badge";
+import type { Framework } from "@/types/compliance";
+
+/** Category → requirement accordion, with stable keys across reordering. */
+export const toGroupedAccordionItems = (
+ data: Framework[],
+ scanId: string | undefined,
+): AccordionItemProps[] => {
+ const safeId = scanId || "";
+
+ return data.flatMap((framework) =>
+ framework.categories.map((category) => ({
+ key: `${framework.name}-${category.name}`,
+ title: (
+
+ ),
+ content: "",
+ items: category.controls.flatMap((control) =>
+ control.requirements.map((requirement) => ({
+ key: `${framework.name}-${category.name}-${requirement.name}`,
+ title: (
+
+ ),
+ content: (
+
+ ),
+ items: [],
+ })),
+ ),
+ })),
+ );
+};
diff --git a/ui/lib/compliance/okta-idaas-stig.tsx b/ui/lib/compliance/okta-idaas-stig.tsx
index 8603c33711..3d8b797b8a 100644
--- a/ui/lib/compliance/okta-idaas-stig.tsx
+++ b/ui/lib/compliance/okta-idaas-stig.tsx
@@ -10,7 +10,6 @@ import {
isOktaIDaaSStigAttributesMetadata,
OktaIDaaSStigRequirement,
Requirement,
- REQUIREMENT_STATUS,
RequirementsData,
RequirementStatus,
} from "@/types/compliance";
@@ -21,14 +20,9 @@ import {
findOrCreateCategory,
findOrCreateControl,
findOrCreateFramework,
+ getStatusCounters,
} from "./commons";
-const getStatusCounters = (status: RequirementStatus) => ({
- pass: status === REQUIREMENT_STATUS.PASS ? 1 : 0,
- fail: status === REQUIREMENT_STATUS.FAIL ? 1 : 0,
- manual: status === REQUIREMENT_STATUS.MANUAL ? 1 : 0,
-});
-
export const mapComplianceData = (
attributesData: AttributesData,
requirementsData: RequirementsData,
diff --git a/ui/lib/csp.ts b/ui/lib/csp.ts
index 77a41c2e3c..8f037482e8 100644
--- a/ui/lib/csp.ts
+++ b/ui/lib/csp.ts
@@ -4,6 +4,7 @@ const POSTHOG_CSP_SOURCE = "https://*.posthog.com";
interface CspOptions {
cloudEnabled: boolean;
+ registryImageOrigins?: string[];
posthogEnabled: boolean;
posthogKey: string | null;
posthogIngestionHost: string | null;
@@ -33,6 +34,7 @@ const getPosthogToolbarUiSource = (
export function getCspHeader({
cloudEnabled,
+ registryImageOrigins = [],
posthogEnabled,
posthogKey,
posthogIngestionHost,
@@ -66,7 +68,7 @@ export function getCspHeader({
default-src 'self';
script-src 'self' 'unsafe-inline' 'unsafe-eval' https://js.stripe.com https://www.googletagmanager.com https://browser.sentry-cdn.com${posthogSource}${toolbarUiSource};
connect-src 'self' https://api.iconify.design https://api.simplesvg.com https://api.unisvg.com https://js.stripe.com https://www.googletagmanager.com https://*.sentry.io https://*.ingest.sentry.io${posthogSource}${toolbarUiSource};
- img-src 'self' https://www.google-analytics.com https://www.googletagmanager.com${posthogSource}${toolbarUiSource};
+ img-src 'self' https://www.google-analytics.com https://www.googletagmanager.com${registryImageOrigins.map((origin) => ` ${origin}`).join("")}${posthogSource}${toolbarUiSource};
font-src 'self'${toolbarPosthogSource};
style-src 'self' 'unsafe-inline'${toolbarPosthogSource};
${toolbarMediaSource}
diff --git a/ui/lib/external-urls.test.ts b/ui/lib/external-urls.test.ts
index 86535e7794..84f7ab4d2b 100644
--- a/ui/lib/external-urls.test.ts
+++ b/ui/lib/external-urls.test.ts
@@ -114,20 +114,20 @@ describe("getAWSOrgDeploymentQuickLink", () => {
});
describe("PRECONFIGURED_CREDENTIAL_URLS", () => {
- it("keeps the Cloudflare User API Token URL under the profile route with the four required read scopes", () => {
+ it("keeps the Cloudflare User API Token URL under the profile route with the seven required read scopes", () => {
// Snapshot check: fixes the exact URL so a stray edit to the permission
// scopes, token name, account/zone selectors or console origin trips a
// failing test instead of silently shipping a broken pre-configured
// token flow to users. Matches the "User API Token" link in
// docs/user-guide/providers/cloudflare/authentication.mdx.
expect(PRECONFIGURED_CREDENTIAL_URLS.CLOUDFLARE_API_TOKEN_USER).toBe(
- "https://dash.cloudflare.com/profile/api-tokens?permissionGroupKeys=%5B%7B%22key%22%3A%22account_settings%22%2C%22type%22%3A%22read%22%7D%2C%7B%22key%22%3A%22zone%22%2C%22type%22%3A%22read%22%7D%2C%7B%22key%22%3A%22zone_settings%22%2C%22type%22%3A%22read%22%7D%2C%7B%22key%22%3A%22dns%22%2C%22type%22%3A%22read%22%7D%5D&accountId=%2A&zoneId=all&name=Prowler%20Security%20Scanner",
+ "https://dash.cloudflare.com/profile/api-tokens?permissionGroupKeys=%5B%7B%22key%22%3A%22account_settings%22%2C%22type%22%3A%22read%22%7D%2C%7B%22key%22%3A%22zone%22%2C%22type%22%3A%22read%22%7D%2C%7B%22key%22%3A%22zone_settings%22%2C%22type%22%3A%22read%22%7D%2C%7B%22key%22%3A%22dns%22%2C%22type%22%3A%22read%22%7D%2C%7B%22key%22%3A%22ssl_and_certificates%22%2C%22type%22%3A%22read%22%7D%2C%7B%22key%22%3A%22bot_management%22%2C%22type%22%3A%22read%22%7D%2C%7B%22key%22%3A%22zone_waf%22%2C%22type%22%3A%22read%22%7D%5D&accountId=%2A&zoneId=all&name=Prowler%20Security%20Scanner",
);
});
- it("carries the four Prowler read scopes as decoded permissionGroupKeys on the Cloudflare User API Token URL", () => {
+ it("carries the seven Prowler read scopes as decoded permissionGroupKeys on the Cloudflare User API Token URL", () => {
// Semantic contract: the URL must request read on account_settings, zone,
- // zone_settings and dns and reuse the shared Prowler token name.
+ // zone_settings, dns, ssl_and_certificates, bot_management and zone_waf and reuse the shared Prowler token name.
const parsed = new URL(
PRECONFIGURED_CREDENTIAL_URLS.CLOUDFLARE_API_TOKEN_USER,
);
@@ -140,6 +140,9 @@ describe("PRECONFIGURED_CREDENTIAL_URLS", () => {
{ key: "zone", type: "read" },
{ key: "zone_settings", type: "read" },
{ key: "dns", type: "read" },
+ { key: "ssl_and_certificates", type: "read" },
+ { key: "bot_management", type: "read" },
+ { key: "zone_waf", type: "read" },
]);
expect(parsed.searchParams.get("name")).toBe("Prowler Security Scanner");
});
@@ -214,6 +217,9 @@ describe("buildCloudflareAccountOwnedApiTokenUrl", () => {
{ key: "zone", type: "read" },
{ key: "zone_settings", type: "read" },
{ key: "dns", type: "read" },
+ { key: "ssl_and_certificates", type: "read" },
+ { key: "bot_management", type: "read" },
+ { key: "zone_waf", type: "read" },
]);
});
diff --git a/ui/lib/external-urls.ts b/ui/lib/external-urls.ts
index 10c240ba26..e8d264f70b 100644
--- a/ui/lib/external-urls.ts
+++ b/ui/lib/external-urls.ts
@@ -61,12 +61,13 @@ const CF_QUICKCREATE_BASE_URL =
// `getAWSCredentialsTemplateLinks` below.
export const PRECONFIGURED_CREDENTIAL_URLS = {
// Opens the Cloudflare "Create Custom Token" form under the user profile
- // pre-filled with the four read-only scopes Prowler needs
- // (`Account Settings`, `Zone`, `Zone Settings`, `DNS`) and the token name.
+ // pre-filled with the seven read-only scopes Prowler needs (`Account
+ // Settings`, `Zone`, `Zone Settings`, `DNS`, `SSL and Certificates`,
+ // `Bot Management`, `Zone WAF`) and the token name.
// Kept in sync with the "User API Token" URL published in
// docs/user-guide/providers/cloudflare/authentication.mdx.
CLOUDFLARE_API_TOKEN_USER:
- "https://dash.cloudflare.com/profile/api-tokens?permissionGroupKeys=%5B%7B%22key%22%3A%22account_settings%22%2C%22type%22%3A%22read%22%7D%2C%7B%22key%22%3A%22zone%22%2C%22type%22%3A%22read%22%7D%2C%7B%22key%22%3A%22zone_settings%22%2C%22type%22%3A%22read%22%7D%2C%7B%22key%22%3A%22dns%22%2C%22type%22%3A%22read%22%7D%5D&accountId=%2A&zoneId=all&name=Prowler%20Security%20Scanner",
+ "https://dash.cloudflare.com/profile/api-tokens?permissionGroupKeys=%5B%7B%22key%22%3A%22account_settings%22%2C%22type%22%3A%22read%22%7D%2C%7B%22key%22%3A%22zone%22%2C%22type%22%3A%22read%22%7D%2C%7B%22key%22%3A%22zone_settings%22%2C%22type%22%3A%22read%22%7D%2C%7B%22key%22%3A%22dns%22%2C%22type%22%3A%22read%22%7D%2C%7B%22key%22%3A%22ssl_and_certificates%22%2C%22type%22%3A%22read%22%7D%2C%7B%22key%22%3A%22bot_management%22%2C%22type%22%3A%22read%22%7D%2C%7B%22key%22%3A%22zone_waf%22%2C%22type%22%3A%22read%22%7D%5D&accountId=%2A&zoneId=all&name=Prowler%20Security%20Scanner",
// Opens the GitHub fine-grained PAT creation form pre-filled with the four
// read-only permissions Prowler needs to scan a user's own repositories.
// Kept in sync with the "user repositories" URL published in
@@ -82,7 +83,7 @@ export const PRECONFIGURED_CREDENTIAL_URLS = {
// avoid ambiguity when the user is signed into multiple accounts. Navigating
// directly to `//api-tokens/create` does NOT pre-fill the form —
// Cloudflare only reads the pre-fill params when they arrive via the router.
-// Same four read-only scopes as the user token URL.
+// Same seven read-only scopes as the user token URL.
export const buildCloudflareAccountOwnedApiTokenUrl = (
accountId: string,
): string => {
@@ -97,6 +98,9 @@ export const buildCloudflareAccountOwnedApiTokenUrl = (
{ key: "zone", type: "read" },
{ key: "zone_settings", type: "read" },
{ key: "dns", type: "read" },
+ { key: "ssl_and_certificates", type: "read" },
+ { key: "bot_management", type: "read" },
+ { key: "zone_waf", type: "read" },
]),
);
const name = encodeURIComponent("Prowler Security Scanner");
diff --git a/ui/lib/get-runtime-config.client.test.ts b/ui/lib/get-runtime-config.client.test.ts
index 2c0362cb40..3c4fc0fef5 100644
--- a/ui/lib/get-runtime-config.client.test.ts
+++ b/ui/lib/get-runtime-config.client.test.ts
@@ -146,6 +146,7 @@ describe("getRuntimeConfigClient", () => {
"posthogKey",
"posthogUiHost",
"reoDevClientId",
+ "selfRegistrationEnabled",
"sentryDsn",
"sentryEnvironment",
"stripePublishableKey",
@@ -157,6 +158,8 @@ describe("getRuntimeConfigClient", () => {
// false (not null) when absent from the island.
expect(config.cloudBillingEnabled).toBe(false);
expect(config.cloudEnabled).toBe(false);
+ // Opt-out flag: absent from the island means self-registration stays on.
+ expect(config.selfRegistrationEnabled).toBe(true);
expect(
(config as unknown as Record).notAllowlisted,
).toBeUndefined();
diff --git a/ui/lib/helper.ts b/ui/lib/helper.ts
index 35087a00d9..d62ddaf65a 100644
--- a/ui/lib/helper.ts
+++ b/ui/lib/helper.ts
@@ -475,6 +475,11 @@ export const permissionFormFields: PermissionInfo[] = [
description:
"Allows configuring Lighthouse AI, including its provider credentials, default model and business context",
},
+ {
+ field: "manage_registry",
+ label: "Manage Registry",
+ description: "Allows managing tenant Registry credentials and artifacts",
+ },
{
field: "manage_billing",
diff --git a/ui/lib/onboarding/README.md b/ui/lib/onboarding/README.md
index eab352ca2b..0b76ec6ced 100644
--- a/ui/lib/onboarding/README.md
+++ b/ui/lib/onboarding/README.md
@@ -2,8 +2,11 @@
The onboarding system runs short, anchored driver.js tours and orchestrates a
cross-route **guided sequence** after a user connects their first provider.
-Everything lives in client state and localStorage — there is **zero backend
-coupling**.
+The tours and the guided sequence run on client state (the sequence slice
+is ephemeral and resets on a hard reload); tour completion and the one-time
+markers persist in localStorage. Server input is the tri-state `hasProviders`
+the layout derives from `getProviders()`, plus the invitation the invite step
+posts to the API.
## Building blocks
@@ -17,6 +20,8 @@ coupling**.
| Ephemeral sequence slice | `ui/store/onboarding-sequence.ts` |
| Checkpoint watcher + dialog | `ui/components/onboarding/onboarding-checkpoint-{watcher,dialog}.tsx` |
| Mandatory new-user gate | `ui/components/onboarding/onboarding-gate.tsx` |
+| Step outcome events (window) | `ui/lib/onboarding/onboarding-events.ts` |
+| Invite step before the checkpoint | `ui/components/onboarding/onboarding-invite-{step,dialog}.tsx` |
| Manual replay list | `ui/components/ui/user-nav/user-nav.tsx` |
## How the guided sequence works
@@ -72,3 +77,18 @@ the sequence automatically.
`target` must resolve to a real `data-tour-id` anchor within its `coversFiles`.
- `pnpm exec vitest run --project unit` — pure logic (slice, helpers, registry,
tour shapes). The driver primitive short-circuits in `NODE_ENV==="test"`.
+
+## Invite step
+
+The first time the checkpoint opens (right after the first provider is
+connected), `OnboardingCheckpointWatcher` renders `OnboardingInviteStep` before
+the checkpoint dialog: the members-page `SendInvitationForm`, tagged
+`source=onboarding` for the API, plus a "Skip for now" action. If the roles
+cannot be loaded, or have not arrived after five seconds, only the skip is
+offered, so the checkpoint is never blocked. The store stays
+`open` while the step shows, so the checkpoint dialog follows unchanged once it
+resolves. A per-tenant localStorage marker (`prowler.onboarding.invite.`)
+keeps it to one offer; without a usable `tenantId` the step is not offered.
+
+Outcomes (`shown`, `submitted`, `skipped`) are announced as the
+`prowler:onboarding-invite-step` window event (`dispatchOnboardingInviteStep`).
diff --git a/ui/lib/onboarding/index.ts b/ui/lib/onboarding/index.ts
index cd6907c036..42e1811b28 100644
--- a/ui/lib/onboarding/index.ts
+++ b/ui/lib/onboarding/index.ts
@@ -5,3 +5,12 @@ export { shouldStartOnboarding } from "./gate-decision";
export { isOnFlowRoute } from "./flow-route";
export type { OnboardingContext, OnboardingFlow } from "./onboarding-types";
export { getFlowById, getOrderedFlows, onboardingFlows } from "./registry";
+export type {
+ OnboardingInviteStepDetail,
+ OnboardingStepOutcome,
+} from "./onboarding-events";
+export {
+ dispatchOnboardingInviteStep,
+ ONBOARDING_INVITE_STEP_EVENT,
+ ONBOARDING_STEP_OUTCOME,
+} from "./onboarding-events";
diff --git a/ui/lib/onboarding/invite-marker.ts b/ui/lib/onboarding/invite-marker.ts
new file mode 100644
index 0000000000..4fac2c5acb
--- /dev/null
+++ b/ui/lib/onboarding/invite-marker.ts
@@ -0,0 +1,48 @@
+// Durable "this browser already saw the invite step" memory, mirroring the
+// checkpoint marker: the step is offered once per tenant onboarding.
+//
+// The key carries the tenant, like the profile marker, because the offer is
+// tenant-scoped: a user who saw it for one tenant must still see it when they
+// onboard another.
+const ONBOARDING_INVITE_MARKER_PREFIX = "prowler.onboarding.invite";
+
+// Tenant ids are UUIDs; anything else is refused rather than concatenated
+// into a storage key.
+const TENANT_ID_PATTERN =
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
+
+export function onboardingInviteMarkerKey(
+ tenantId: string | null | undefined,
+): string | null {
+ if (!tenantId || !TENANT_ID_PATTERN.test(tenantId)) return null;
+ return `${ONBOARDING_INVITE_MARKER_PREFIX}.${tenantId.toLowerCase()}`;
+}
+
+export function isOnboardingInviteHandled(
+ tenantId: string | null | undefined,
+): boolean {
+ if (typeof window === "undefined") return true;
+ const key = onboardingInviteMarkerKey(tenantId);
+ // Without a usable tenant the step cannot be attributed to an onboarding,
+ // so it is not offered rather than offered to everyone.
+ if (!key) return true;
+ try {
+ return window.localStorage.getItem(key) !== null;
+ } catch {
+ // Unreadable storage must not re-open the step forever: treat as handled.
+ return true;
+ }
+}
+
+export function markOnboardingInviteHandled(
+ tenantId: string | null | undefined,
+): void {
+ if (typeof window === "undefined") return;
+ const key = onboardingInviteMarkerKey(tenantId);
+ if (!key) return;
+ try {
+ window.localStorage.setItem(key, "true");
+ } catch {
+ // Non-fatal: a re-shown step beats a thrown render.
+ }
+}
diff --git a/ui/lib/onboarding/onboarding-events.ts b/ui/lib/onboarding/onboarding-events.ts
new file mode 100644
index 0000000000..23e924e7ac
--- /dev/null
+++ b/ui/lib/onboarding/onboarding-events.ts
@@ -0,0 +1,33 @@
+// Window events the onboarding steps dispatch when they resolve. They carry
+// no listener of their own: a deployment that wants to observe the steps
+// (product analytics, for instance) subscribes from outside, so the steps
+// stay free of any tracking dependency.
+export const ONBOARDING_INVITE_STEP_EVENT = "prowler:onboarding-invite-step";
+
+export const ONBOARDING_STEP_OUTCOME = {
+ SHOWN: "shown",
+ SUBMITTED: "submitted",
+ SKIPPED: "skipped",
+} as const;
+
+export type OnboardingStepOutcome =
+ (typeof ONBOARDING_STEP_OUTCOME)[keyof typeof ONBOARDING_STEP_OUTCOME];
+
+export interface OnboardingInviteStepDetail {
+ outcome: OnboardingStepOutcome;
+}
+
+function dispatch(name: string, detail: Detail): void {
+ if (typeof window === "undefined") return;
+ try {
+ window.dispatchEvent(new CustomEvent(name, { detail }));
+ } catch {
+ // A listener that throws must never break the step that resolved.
+ }
+}
+
+export function dispatchOnboardingInviteStep(
+ detail: OnboardingInviteStepDetail,
+): void {
+ dispatch(ONBOARDING_INVITE_STEP_EVENT, detail);
+}
diff --git a/ui/lib/permissions.test.ts b/ui/lib/permissions.test.ts
index e9fb55a236..04f9771572 100644
--- a/ui/lib/permissions.test.ts
+++ b/ui/lib/permissions.test.ts
@@ -13,6 +13,7 @@ const attributes = {
manage_billing: false,
manage_alerts: true,
manage_lighthouse_ai_configuration: true,
+ manage_registry: true,
unlimited_visibility: false,
} satisfies RolePermissionAttributes;
@@ -21,6 +22,34 @@ describe("getRolePermissions", () => {
vi.unstubAllEnvs();
});
+ it("includes Manage Registry in Prowler Cloud when role attributes provide it", () => {
+ // Given
+ vi.stubEnv("UI_CLOUD_ENABLED", "true");
+
+ // When
+ const permissions = getRolePermissions(attributes);
+
+ // Then
+ expect(permissions).toContainEqual({
+ key: "manage_registry",
+ label: "Manage Registry",
+ enabled: true,
+ });
+ });
+
+ it("hides Manage Registry outside Prowler Cloud", () => {
+ // Given
+ vi.stubEnv("UI_CLOUD_ENABLED", "false");
+
+ // When
+ const permissions = getRolePermissions(attributes);
+
+ // Then
+ expect(
+ permissions.some((permission) => permission.key === "manage_registry"),
+ ).toBe(false);
+ });
+
it("includes Manage Alerts in Prowler Cloud when role attributes provide it", () => {
// Given
vi.stubEnv("UI_CLOUD_ENABLED", "true");
diff --git a/ui/lib/permissions.ts b/ui/lib/permissions.ts
index 8f641814c1..a155296e54 100644
--- a/ui/lib/permissions.ts
+++ b/ui/lib/permissions.ts
@@ -72,6 +72,11 @@ export const getRolePermissions = (attributes: RolePermissionAttributes) => {
label: "Manage Lighthouse AI",
enabled: attributes.manage_lighthouse_ai_configuration ?? false,
},
+ {
+ key: "manage_registry",
+ label: "Manage Registry",
+ enabled: attributes.manage_registry === true,
+ },
]
: []),
{
diff --git a/ui/lib/provider-credentials/fixtures/openai-credential-schema.json b/ui/lib/provider-credentials/fixtures/openai-credential-schema.json
new file mode 100644
index 0000000000..7b749bbb7d
--- /dev/null
+++ b/ui/lib/provider-credentials/fixtures/openai-credential-schema.json
@@ -0,0 +1,24 @@
+{
+ "description": "Static OpenAI Administration API credentials.\n\nOne pydantic model per secret type the provider accepts. The docstring\ndocuments the secret type; ``Field(description=...)`` documents each field;\nrequired fields have no default, optional ones use a default. Prowler reads\nthis (via ``OpenAIProvider.get_credentials_schema``) to validate the stored\nsecret and to document its shape.\n\nThe two secret fields are ``organization_id`` and ``platform_api_key``.\n``base_url`` is an optional non-secret override kept here so the credential\nshape stays the single source of truth across the light (API/web) and the\nruntime (worker) sides.",
+ "properties": {
+ "organization_id": {
+ "description": "OpenAI organization ID, e.g. org-xxxxxxxxxxxxxxxx",
+ "title": "Organization Id",
+ "type": "string"
+ },
+ "platform_api_key": {
+ "description": "OpenAI platform (Administration) API key used to read organization users and admin API keys",
+ "title": "Platform Api Key",
+ "type": "string"
+ },
+ "base_url": {
+ "default": "https://api.openai.com/v1",
+ "description": "OpenAI API base URL (defaults to https://api.openai.com/v1)",
+ "title": "Base Url",
+ "type": "string"
+ }
+ },
+ "required": ["organization_id", "platform_api_key"],
+ "title": "OpenAIStaticCredentials",
+ "type": "object"
+}
diff --git a/ui/lib/provider-credentials/fixtures/template-credential-schema.json b/ui/lib/provider-credentials/fixtures/template-credential-schema.json
new file mode 100644
index 0000000000..d6d3abcfe2
--- /dev/null
+++ b/ui/lib/provider-credentials/fixtures/template-credential-schema.json
@@ -0,0 +1,49 @@
+{
+ "description": "API key.\n\nLong-lived key issued from the provider's console.",
+ "properties": {
+ "api_url": {
+ "description": "Base URL of the provider API.",
+ "examples": ["https://api.acme.com"],
+ "title": "API URL",
+ "type": "string"
+ },
+ "api_key": {
+ "description": "Key used to authenticate against the provider API.",
+ "format": "password",
+ "title": "API Key",
+ "type": "string",
+ "writeOnly": true
+ },
+ "ca_bundle": {
+ "default": "",
+ "description": "Certificates of a private authority, in PEM format, for an API whose certificate no public authority signed. Leave empty to use public trust.",
+ "title": "CA Bundle",
+ "type": "string",
+ "x-prowler-widget": "textarea"
+ },
+ "verify_tls": {
+ "default": true,
+ "description": "Whether to verify the API's TLS certificate.",
+ "title": "Verify TLS",
+ "type": "boolean"
+ },
+ "timeout_seconds": {
+ "default": 30,
+ "description": "Seconds to wait for the API before giving up.",
+ "maximum": 300,
+ "minimum": 1,
+ "title": "Timeout",
+ "type": "integer"
+ },
+ "auth_scheme": {
+ "description": "How the key is sent on each request.",
+ "enum": ["bearer", "basic"],
+ "title": "Authentication Scheme",
+ "type": "string",
+ "default": "bearer"
+ }
+ },
+ "required": ["api_url", "api_key"],
+ "title": "TemplateStaticCredentials",
+ "type": "object"
+}
diff --git a/ui/lib/provider-credentials/provider-credential-schema.test.ts b/ui/lib/provider-credentials/provider-credential-schema.test.ts
new file mode 100644
index 0000000000..eac0ac044f
--- /dev/null
+++ b/ui/lib/provider-credentials/provider-credential-schema.test.ts
@@ -0,0 +1,266 @@
+import { describe, expect, it } from "vitest";
+
+import openaiSchema from "./fixtures/openai-credential-schema.json";
+import templateSchema from "./fixtures/template-credential-schema.json";
+import {
+ parseRegistryCredentialSchema,
+ REGISTRY_CREDENTIAL_SCHEMA_LIMITS,
+} from "./provider-credential-schema";
+
+const schema = {
+ type: "object",
+ properties: {
+ api_key: {
+ title: "API Key",
+ description: "The key.",
+ type: "string",
+ format: "password",
+ writeOnly: true,
+ },
+ scheme: {
+ title: "Scheme",
+ type: "string",
+ enum: ["bearer", "basic"],
+ default: "bearer",
+ },
+ notes: {
+ title: "Notes",
+ type: "string",
+ "x-prowler-widget": "textarea",
+ default: "",
+ },
+ },
+ required: ["api_key"],
+};
+
+describe("parseRegistryCredentialSchema", () => {
+ it("accepts the installed Template 0.2.5 schema with typed fields and examples", () => {
+ // Given / When
+ const result = parseRegistryCredentialSchema(templateSchema);
+
+ // Then
+ expect(result?.fields.map(({ name, kind }) => [name, kind])).toEqual([
+ ["api_url", "text"],
+ ["api_key", "password"],
+ ["ca_bundle", "textarea"],
+ ["verify_tls", "checkbox"],
+ ["timeout_seconds", "integer"],
+ ["auth_scheme", "select"],
+ ]);
+ expect(result?.fields[0]).toMatchObject({
+ placeholder: "https://api.acme.com",
+ required: true,
+ });
+ expect(result?.fields[3].defaultValue).toBe(true);
+ expect(result?.fields[4]).toMatchObject({
+ defaultValue: 30,
+ minimum: 1,
+ maximum: 300,
+ });
+ });
+ it.each([
+ "api_key",
+ "platform_api_key",
+ "platform-api-key",
+ "apiKey",
+ "platformApiKey",
+ "platformAPIKey",
+ "API_KEY",
+ "apikey",
+ ])("masks the plain API key field %s without schema annotations", (name) => {
+ // Given / When
+ const result = parseRegistryCredentialSchema({
+ type: "object",
+ properties: { [name]: { type: "string" } },
+ });
+
+ // Then
+ expect(result?.fields[0].kind).toBe("password");
+ });
+
+ it("keeps identifiers and explicitly configured widgets unchanged", () => {
+ // Given / When
+ const result = parseRegistryCredentialSchema({
+ type: "object",
+ properties: {
+ api_key_id: { type: "string" },
+ api_key_url: { type: "string" },
+ selected_api_key: { type: "string", enum: ["primary", "secondary"] },
+ multiline_api_key: { type: "string", "x-prowler-widget": "textarea" },
+ },
+ });
+
+ // Then
+ expect(result?.fields.map(({ kind }) => kind)).toEqual([
+ "text",
+ "text",
+ "select",
+ "textarea",
+ ]);
+ });
+
+ it("accepts the installed OpenAI schema with its full description", () => {
+ // Given / When: the materialized OpenAI 0.1.5 schema contains a long docstring.
+ const result = parseRegistryCredentialSchema(openaiSchema);
+
+ // Then
+ expect(result?.fields.map(({ name }) => name)).toEqual([
+ "organization_id",
+ "platform_api_key",
+ "base_url",
+ ]);
+ expect(result?.fields[2].defaultValue).toBe("https://api.openai.com/v1");
+ expect(result?.fields.map(({ kind }) => kind)).toEqual([
+ "text",
+ "password",
+ "text",
+ ]);
+ });
+
+ it("preserves long field descriptions without treating them as input limits", () => {
+ // Given
+ const description = openaiSchema.description;
+
+ // When
+ const result = parseRegistryCredentialSchema({
+ ...schema,
+ properties: { token: { type: "string", description } },
+ required: ["token"],
+ });
+
+ // Then
+ expect(result?.fields[0].description).toBe(description);
+ });
+
+ it("accepts the observed flat credential schema and preserves property order", () => {
+ // Given
+ const result = parseRegistryCredentialSchema(schema);
+
+ // When / Then
+
+ expect(result?.fields.map(({ name, kind }) => [name, kind])).toEqual([
+ ["api_key", "password"],
+ ["scheme", "select"],
+ ["notes", "textarea"],
+ ]);
+
+ expect(result?.fields[0]).toMatchObject({
+ description: "The key.",
+ label: "API Key",
+ required: true,
+ });
+ });
+
+ it.each([
+ ["$ref", { $ref: "#/$defs/credential" }],
+ ["$defs", { $defs: {} }],
+ ["definitions", { definitions: {} }],
+ ["combinators", { anyOf: [] }],
+ ["additional properties", { additionalProperties: true }],
+ ])("rejects risky root keywords: %s", (_name, keyword) => {
+ expect(parseRegistryCredentialSchema({ ...schema, ...keyword })).toBeNull();
+ });
+
+ it.each([
+ ["nested objects", { type: "object", properties: {} }],
+ ["arrays", { type: "array" }],
+ ["nullable unions", { type: ["string", "null"] }],
+ ["unsupported formats", { type: "string", format: "email" }],
+ ["passwords without writeOnly", { type: "string", format: "password" }],
+ ["maps", { type: "string", additionalProperties: true }],
+ ])("rejects unsupported fields: %s", (_name, apiKey) => {
+ expect(
+ parseRegistryCredentialSchema({
+ ...schema,
+ properties: { ...schema.properties, api_key: apiKey },
+ }),
+ ).toBeNull();
+ });
+
+ it.each([
+ { type: "boolean", default: "true" },
+ { type: "boolean", enum: [true] },
+ { type: "integer", default: "30" },
+ { type: "integer", default: 1.5 },
+ { type: "integer", minimum: 1, default: 0 },
+ { type: "integer", maximum: 300, default: 301 },
+ { type: "integer", minimum: 10, maximum: 1 },
+ { type: "integer", minimum: "1" },
+ { type: "integer", maximum: Infinity },
+ { type: "integer", multipleOf: 5 },
+ { type: "string", examples: "not-an-array" },
+ { type: "string", examples: [{ value: "unexpected" }] },
+ ])(
+ "rejects malformed annotations or unsupported constraints: %j",
+ (property) => {
+ expect(
+ parseRegistryCredentialSchema({
+ type: "object",
+ properties: { field: property },
+ }),
+ ).toBeNull();
+ },
+ );
+
+ it.each([
+ [
+ "invalid defaults",
+ { type: "string", enum: ["bearer", "basic"], default: "token" },
+ ],
+ ["duplicate values", { type: "string", enum: ["bearer", "bearer"] }],
+ ])("rejects enum definitions with %s", (_name, scheme) => {
+ expect(
+ parseRegistryCredentialSchema({
+ ...schema,
+ properties: { ...schema.properties, scheme },
+ }),
+ ).toBeNull();
+ });
+
+ it("rejects unsafe names, invalid required fields, and over-limit metadata", () => {
+ // Given
+
+ const fields = Object.fromEntries(
+ Array.from(
+ { length: REGISTRY_CREDENTIAL_SCHEMA_LIMITS.MAX_FIELDS + 1 },
+ (_, index) => [`field${index}`, { type: "string" }],
+ ),
+ );
+
+ const cases = [
+ { ...schema, required: ["missing"] },
+ { ...schema, description: { invalid: "not text" } },
+ {
+ ...schema,
+ properties: { api_key: { type: "string", description: 123 } },
+ },
+ JSON.parse(
+ '{"type":"object","properties":{"__proto__":{"type":"string"}}}',
+ ),
+ { type: "object", properties: fields },
+ {
+ ...schema,
+ properties: {
+ ...schema.properties,
+ notes: {
+ ...schema.properties.notes,
+ title: "a".repeat(
+ REGISTRY_CREDENTIAL_SCHEMA_LIMITS.MAX_TEXT_LENGTH + 1,
+ ),
+ },
+ },
+ },
+ ];
+
+ // When / Then
+
+ expect(cases.map(parseRegistryCredentialSchema)).toEqual([
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ ]);
+ });
+});
diff --git a/ui/lib/provider-credentials/provider-credential-schema.ts b/ui/lib/provider-credentials/provider-credential-schema.ts
new file mode 100644
index 0000000000..ed1e4ee0e7
--- /dev/null
+++ b/ui/lib/provider-credentials/provider-credential-schema.ts
@@ -0,0 +1,258 @@
+const FIELD_KIND = {
+ TEXT: "text",
+ PASSWORD: "password",
+ SELECT: "select",
+ TEXTAREA: "textarea",
+ CHECKBOX: "checkbox",
+ INTEGER: "integer",
+} as const;
+
+export const REGISTRY_CREDENTIAL_SCHEMA_LIMITS = {
+ MAX_FIELDS: 12,
+ MAX_NAME_LENGTH: 50,
+ MAX_TEXT_LENGTH: 200,
+ MAX_ENUM_OPTIONS: 20,
+} as const;
+
+type FieldKind = (typeof FIELD_KIND)[keyof typeof FIELD_KIND];
+export type RegistryCredentialValue = string | boolean | number;
+
+export interface RegistryCredentialField {
+ readonly name: string;
+ readonly label: string;
+ readonly description?: string;
+ readonly kind: FieldKind;
+ readonly options?: readonly string[];
+ readonly required: boolean;
+ readonly defaultValue?: RegistryCredentialValue;
+ readonly placeholder?: string;
+ readonly minimum?: number;
+ readonly maximum?: number;
+}
+
+export interface RegistryCredentialSchema {
+ readonly fields: readonly RegistryCredentialField[];
+}
+
+const ROOT = new Set("type title description properties required".split(" "));
+const FIELD = new Set(
+ "title description type format writeOnly enum default examples x-prowler-widget".split(
+ " ",
+ ),
+);
+const BOOLEAN_FIELD = new Set("title description type default".split(" "));
+const INTEGER_FIELD = new Set(
+ "title description type default minimum maximum".split(" "),
+);
+const FORBIDDEN_NAMES = new Set(["__proto__", "prototype", "constructor"]);
+const FIELD_NAME = /^[A-Za-z][A-Za-z0-9_-]*$/;
+
+// Some installed artifacts expose API keys as plain strings without secret metadata.
+function isApiKeyField(name: string): boolean {
+ const normalizedName = name
+ .replace(/([A-Z]+)([A-Z][a-z])/g, "$1_$2")
+ .replace(/([a-z0-9])([A-Z])/g, "$1_$2")
+ .replace(/-/g, "_")
+ .toLowerCase();
+ return /(?:^|_)api_?key$/.test(normalizedName);
+}
+
+function isRecord(value: unknown): value is Record {
+ return (
+ typeof value === "object" &&
+ value !== null &&
+ !Array.isArray(value) &&
+ Object.getPrototypeOf(value) === Object.prototype
+ );
+}
+
+function isText(value: unknown, allowEmpty = false): value is string {
+ return (
+ typeof value === "string" &&
+ value.length <= REGISTRY_CREDENTIAL_SCHEMA_LIMITS.MAX_TEXT_LENGTH &&
+ (allowEmpty || value.length > 0)
+ );
+}
+
+function hasOnly(
+ record: Record,
+ allowed: Set,
+): boolean {
+ for (const key in record) {
+ if (Object.hasOwn(record, key) && !allowed.has(key)) return false;
+ }
+ return true;
+}
+
+export function parseRegistryCredentialSchema(
+ value: unknown,
+): RegistryCredentialSchema | null {
+ if (!isRecord(value) || !hasOnly(value, ROOT) || value.type !== "object") {
+ return null;
+ }
+ if (
+ (value.title !== undefined && !isText(value.title)) ||
+ (value.description !== undefined && typeof value.description !== "string")
+ ) {
+ return null;
+ }
+
+ const properties = value.properties;
+ if (!isRecord(properties)) return null;
+ const entries: [string, unknown][] = [];
+ for (const name in properties) {
+ if (!Object.hasOwn(properties, name)) continue;
+ if (entries.length === REGISTRY_CREDENTIAL_SCHEMA_LIMITS.MAX_FIELDS)
+ return null;
+ entries.push([name, properties[name]]);
+ }
+
+ const required = value.required ?? [];
+ if (
+ !Array.isArray(required) ||
+ required.length > entries.length ||
+ !required.every((name) => typeof name === "string")
+ ) {
+ return null;
+ }
+ const requiredNames = new Set(required);
+ if (
+ requiredNames.size !== required.length ||
+ required.some((name) => !Object.hasOwn(properties, name))
+ ) {
+ return null;
+ }
+
+ const fields: RegistryCredentialField[] = [];
+ for (const [name, property] of entries) {
+ if (
+ FORBIDDEN_NAMES.has(name) ||
+ !FIELD_NAME.test(name) ||
+ name.length > REGISTRY_CREDENTIAL_SCHEMA_LIMITS.MAX_NAME_LENGTH ||
+ !isRecord(property)
+ ) {
+ return null;
+ }
+
+ const label = property.title ?? name;
+ const description = property.description;
+ if (
+ !isText(label) ||
+ (description !== undefined && typeof description !== "string")
+ ) {
+ return null;
+ }
+ const baseField = {
+ name,
+ label,
+ ...(description ? { description } : {}),
+ required: requiredNames.has(name),
+ };
+ const defaultValue = property.default;
+ if (property.type === "boolean") {
+ if (
+ !hasOnly(property, BOOLEAN_FIELD) ||
+ (defaultValue !== undefined && typeof defaultValue !== "boolean")
+ ) {
+ return null;
+ }
+ fields.push({
+ ...baseField,
+ kind: FIELD_KIND.CHECKBOX,
+ ...(typeof defaultValue === "boolean" ? { defaultValue } : {}),
+ });
+ continue;
+ }
+ if (property.type === "integer") {
+ const { minimum, maximum } = property;
+ if (
+ !hasOnly(property, INTEGER_FIELD) ||
+ (minimum !== undefined && !Number.isSafeInteger(minimum)) ||
+ (maximum !== undefined && !Number.isSafeInteger(maximum)) ||
+ (typeof minimum === "number" &&
+ typeof maximum === "number" &&
+ minimum > maximum) ||
+ (defaultValue !== undefined &&
+ (typeof defaultValue !== "number" ||
+ !Number.isSafeInteger(defaultValue) ||
+ (typeof minimum === "number" && defaultValue < minimum) ||
+ (typeof maximum === "number" && defaultValue > maximum)))
+ ) {
+ return null;
+ }
+ fields.push({
+ ...baseField,
+ kind: FIELD_KIND.INTEGER,
+ ...(typeof minimum === "number" ? { minimum } : {}),
+ ...(typeof maximum === "number" ? { maximum } : {}),
+ ...(typeof defaultValue === "number" ? { defaultValue } : {}),
+ });
+ continue;
+ }
+ if (property.type !== "string" || !hasOnly(property, FIELD)) return null;
+
+ const format = property.format;
+ const widget = property["x-prowler-widget"];
+ const options = property.enum;
+ const examples = property.examples;
+ const password = format === "password" && property.writeOnly === true;
+ if (
+ ((format !== undefined || property.writeOnly !== undefined) &&
+ !password) ||
+ (widget !== undefined && widget !== "textarea") ||
+ (defaultValue !== undefined && !isText(defaultValue, true)) ||
+ (examples !== undefined &&
+ (!Array.isArray(examples) ||
+ examples.length >
+ REGISTRY_CREDENTIAL_SCHEMA_LIMITS.MAX_ENUM_OPTIONS ||
+ !examples.every((example) => isText(example, true))))
+ ) {
+ return null;
+ }
+
+ if (options !== undefined) {
+ if (
+ format !== undefined ||
+ widget !== undefined ||
+ property.writeOnly !== undefined ||
+ !Array.isArray(options) ||
+ options.length === 0 ||
+ options.length > REGISTRY_CREDENTIAL_SCHEMA_LIMITS.MAX_ENUM_OPTIONS ||
+ !options.every((option) => isText(option)) ||
+ new Set(options).size !== options.length ||
+ (defaultValue !== undefined && !options.includes(defaultValue))
+ ) {
+ return null;
+ }
+ fields.push({
+ ...baseField,
+ kind: FIELD_KIND.SELECT,
+ options,
+ ...(typeof defaultValue === "string" ? { defaultValue } : {}),
+ });
+ continue;
+ }
+
+ if (
+ widget !== undefined &&
+ (format !== undefined || property.writeOnly !== undefined)
+ ) {
+ return null;
+ }
+ fields.push({
+ ...baseField,
+ kind: password
+ ? FIELD_KIND.PASSWORD
+ : widget === "textarea"
+ ? FIELD_KIND.TEXTAREA
+ : isApiKeyField(name)
+ ? FIELD_KIND.PASSWORD
+ : FIELD_KIND.TEXT,
+ ...(Array.isArray(examples) && typeof examples[0] === "string"
+ ? { placeholder: examples[0] }
+ : {}),
+ ...(typeof defaultValue === "string" ? { defaultValue } : {}),
+ });
+ }
+ return { fields };
+}
diff --git a/ui/lib/provider-credentials/provider-credential-values.test.ts b/ui/lib/provider-credentials/provider-credential-values.test.ts
new file mode 100644
index 0000000000..8e646c18d6
--- /dev/null
+++ b/ui/lib/provider-credentials/provider-credential-values.test.ts
@@ -0,0 +1,136 @@
+import { describe, expect, it } from "vitest";
+
+import templateSchema from "./fixtures/template-credential-schema.json";
+import { parseRegistryCredentialSchema } from "./provider-credential-schema";
+import {
+ getCredentialDefaults,
+ validateCredentialValues,
+} from "./provider-credential-values";
+
+const schema = parseRegistryCredentialSchema({
+ type: "object",
+ properties: {
+ token: { type: "string", format: "password", writeOnly: true },
+ region: { type: "string", enum: ["eu", "us"], default: "eu" },
+ notes: { type: "string", "x-prowler-widget": "textarea" },
+ },
+ required: ["token"],
+})!;
+
+describe("dynamic credential validation", () => {
+ const template = () => parseRegistryCredentialSchema(templateSchema)!;
+ const templateValues = {
+ api_url: "https://api.example.test",
+ api_key: "fixture-key-not-a-secret",
+ verify_tls: false,
+ timeout_seconds: "60",
+ };
+
+ it("preserves typed Template defaults and submits booleans and integers", () => {
+ // Given / When / Then
+ expect(getCredentialDefaults(template())).toEqual({
+ ca_bundle: "",
+ verify_tls: true,
+ timeout_seconds: 30,
+ auth_scheme: "bearer",
+ });
+ expect(validateCredentialValues(template(), templateValues)).toEqual({
+ valid: true,
+ secret: { ...templateValues, timeout_seconds: 60 },
+ errors: {},
+ });
+ });
+
+ it.each([1, 300, "1", "300"])("accepts timeout boundary %j", (timeout) => {
+ expect(
+ validateCredentialValues(template(), {
+ ...templateValues,
+ timeout_seconds: timeout,
+ }),
+ ).toMatchObject({
+ valid: true,
+ secret: { timeout_seconds: Number(timeout) },
+ });
+ });
+
+ it.each([
+ 0,
+ 301,
+ 1.5,
+ "1.5",
+ " ",
+ "1second",
+ "0x10",
+ true,
+ null,
+ Infinity,
+ NaN,
+ ])("rejects invalid Template timeouts: %j", (timeout) => {
+ expect(
+ validateCredentialValues(template(), {
+ ...templateValues,
+ timeout_seconds: timeout,
+ }),
+ ).toMatchObject({
+ valid: false,
+ errors: { timeout_seconds: expect.any(String) },
+ });
+ });
+
+ it.each(["true", "false", 0, 1, null])(
+ "rejects non-boolean TLS values: %j",
+ (verifyTls) => {
+ expect(
+ validateCredentialValues(template(), {
+ ...templateValues,
+ verify_tls: verifyTls,
+ }),
+ ).toMatchObject({
+ valid: false,
+ errors: { verify_tls: expect.any(String) },
+ });
+ },
+ );
+
+ it("accepts false and zero for required fields without treating them as missing", () => {
+ const requiredSchema = parseRegistryCredentialSchema({
+ type: "object",
+ properties: {
+ enabled: { type: "boolean" },
+ retries: { type: "integer" },
+ },
+ required: ["enabled", "retries"],
+ })!;
+ expect(getCredentialDefaults(requiredSchema)).toEqual({ enabled: false });
+ expect(
+ validateCredentialValues(requiredSchema, { enabled: false, retries: 0 }),
+ ).toEqual({
+ valid: true,
+ secret: { enabled: false, retries: 0 },
+ errors: {},
+ });
+ expect(validateCredentialValues(requiredSchema, {})).toMatchObject({
+ valid: false,
+ errors: { enabled: expect.any(String), retries: expect.any(String) },
+ });
+ });
+ it("uses declared defaults and preserves credential bytes", () => {
+ expect(getCredentialDefaults(schema)).toEqual({ region: "eu" });
+ expect(
+ validateCredentialValues(schema, { token: " secret ", region: "eu" }),
+ ).toEqual({
+ valid: true,
+ secret: { token: " secret ", region: "eu" },
+ errors: {},
+ });
+ });
+ it.each([
+ {},
+ { token: "" },
+ { token: "secret", region: "invalid" },
+ { token: 12 },
+ { token: "secret", extra: "hidden" },
+ ])("rejects invalid or undeclared values: %j", (values) => {
+ expect(validateCredentialValues(schema, values).valid).toBe(false);
+ });
+});
diff --git a/ui/lib/provider-credentials/provider-credential-values.ts b/ui/lib/provider-credentials/provider-credential-values.ts
new file mode 100644
index 0000000000..6673adf9ea
--- /dev/null
+++ b/ui/lib/provider-credentials/provider-credential-values.ts
@@ -0,0 +1,85 @@
+import type {
+ RegistryCredentialSchema,
+ RegistryCredentialValue,
+} from "./provider-credential-schema";
+
+export function getCredentialDefaults(
+ schema: RegistryCredentialSchema,
+): Record {
+ return Object.fromEntries(
+ schema.fields.flatMap((field) =>
+ field.defaultValue !== undefined
+ ? [[field.name, field.defaultValue]]
+ : field.kind === "checkbox" && field.required
+ ? [[field.name, false]]
+ : [],
+ ),
+ );
+}
+
+export function validateCredentialValues(
+ schema: RegistryCredentialSchema,
+ values: unknown,
+):
+ | {
+ valid: true;
+ secret: Record;
+ errors: Record;
+ }
+ | { valid: false; errors: Record } {
+ if (!values || typeof values !== "object" || Array.isArray(values))
+ return {
+ valid: false,
+ errors: { _form: "Enter the required credentials." },
+ };
+ const entries = Object.entries(values);
+ if (
+ entries.some(
+ ([name]) => !schema.fields.some((field) => field.name === name),
+ )
+ )
+ return {
+ valid: false,
+ errors: { _form: "The credential fields have changed. Reload the form." },
+ };
+ const fields = new Map(entries);
+ const errors: Record = {};
+ const secret: Record = {};
+ for (const field of schema.fields) {
+ const value = fields.get(field.name);
+ if (value === undefined || (value === "" && field.kind !== "checkbox")) {
+ if (field.required) errors[field.name] = `${field.label} is required`;
+ } else if (field.kind === "checkbox") {
+ if (typeof value !== "boolean") {
+ errors[field.name] = `Enter a valid ${field.label}`;
+ } else {
+ secret[field.name] = value;
+ }
+ } else if (field.kind === "integer") {
+ const number =
+ typeof value === "string" && /^[+-]?\d+$/.test(value)
+ ? Number(value)
+ : value;
+ if (
+ typeof number !== "number" ||
+ !Number.isSafeInteger(number) ||
+ (field.minimum !== undefined && number < field.minimum) ||
+ (field.maximum !== undefined && number > field.maximum)
+ ) {
+ errors[field.name] = `Enter a valid ${field.label}`;
+ } else {
+ secret[field.name] = number;
+ }
+ } else if (
+ typeof value !== "string" ||
+ (field.options && !field.options.includes(value))
+ ) {
+ errors[field.name] = `Enter a valid ${field.label}`;
+ } else {
+ secret[field.name] = value;
+ }
+ }
+ return Object.keys(errors).length > 0
+ ? { valid: false, errors }
+ : { valid: true, secret, errors };
+}
diff --git a/ui/lib/provider-helpers.test.ts b/ui/lib/provider-helpers.test.ts
new file mode 100644
index 0000000000..8efba9ac80
--- /dev/null
+++ b/ui/lib/provider-helpers.test.ts
@@ -0,0 +1,36 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+const { checkConnectionProvider, checkTaskStatus } = vi.hoisted(() => ({
+ checkConnectionProvider: vi.fn(),
+ checkTaskStatus: vi.fn(),
+}));
+vi.mock("@/actions/providers/providers", () => ({ checkConnectionProvider }));
+vi.mock("./helper", () => ({ checkTaskStatus }));
+
+import { testProviderConnection } from "./provider-helpers";
+
+describe("provider connection confirmation", () => {
+ beforeEach(() => {
+ checkConnectionProvider.mockResolvedValue({ data: { id: "task" } });
+ });
+ it.each([undefined, {}, { connected: "true" }, { connected: false }])(
+ "does not advance without explicit connected=true: %j",
+ async (result) => {
+ checkTaskStatus.mockResolvedValue({
+ completed: true,
+ task: { data: { attributes: { result } } },
+ });
+ expect((await testProviderConnection("account")).connected).toBe(false);
+ },
+ );
+ it("advances on an explicitly successful connection", async () => {
+ checkTaskStatus.mockResolvedValue({
+ completed: true,
+ task: { data: { attributes: { result: { connected: true } } } },
+ });
+ expect(await testProviderConnection("account")).toEqual({
+ connected: true,
+ error: null,
+ });
+ });
+});
diff --git a/ui/lib/provider-helpers.ts b/ui/lib/provider-helpers.ts
index 3faf8e2058..329833f384 100644
--- a/ui/lib/provider-helpers.ts
+++ b/ui/lib/provider-helpers.ts
@@ -212,14 +212,15 @@ export async function testProviderConnection(
};
}
- // Read from the task the poller already fetched. A completed task with no
- // readable `connected` counts as connected, as in the batched poller.
+ // Task completion alone does not confirm that the credentials connected.
const result = taskResult.task?.data?.attributes?.result;
- const connected =
- typeof result?.connected === "boolean" ? result.connected : true;
+ const connected = result?.connected === true;
return {
connected,
- error: connected ? null : result?.error || "Unknown error",
+ error: connected
+ ? null
+ : result?.error ||
+ "Connection was not confirmed. Test the connection again.",
};
}
diff --git a/ui/lib/registry/access.server.test.ts b/ui/lib/registry/access.server.test.ts
new file mode 100644
index 0000000000..1e3d133faa
--- /dev/null
+++ b/ui/lib/registry/access.server.test.ts
@@ -0,0 +1,177 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+const { fetchCurrentUserMock } = vi.hoisted(() => ({
+ fetchCurrentUserMock: vi.fn(),
+}));
+vi.mock("server-only", () => ({}));
+vi.mock("@/lib/auth/current-user", () => ({
+ fetchCurrentUser: fetchCurrentUserMock,
+}));
+
+import { REGISTRY_ACCESS } from "./access";
+import {
+ evaluateRegistryAccess,
+ evaluateRegistryProviderAccess,
+} from "./access.server";
+
+describe("Registry provider onboarding access", () => {
+ beforeEach(() => {
+ vi.stubEnv("UI_CLOUD_ENABLED", "true");
+ vi.stubEnv("UI_REGISTRY_ENABLED", "true");
+ });
+
+ it.each([false, undefined])(
+ "allows provider managers without Registry permission (%s)",
+ async (manageRegistry) => {
+ // Given
+ fetchCurrentUserMock.mockResolvedValue({
+ manageRegistry,
+ permissions: { manage_providers: true },
+ });
+ // When / Then
+ await expect(
+ evaluateRegistryProviderAccess("access-token"),
+ ).resolves.toEqual({
+ status: REGISTRY_ACCESS.ELIGIBLE,
+ });
+ await expect(evaluateRegistryAccess("access-token")).resolves.not.toEqual(
+ {
+ status: REGISTRY_ACCESS.ELIGIBLE,
+ },
+ );
+ },
+ );
+
+ it("denies onboarding to a Registry manager without manage_providers", async () => {
+ // Given
+ fetchCurrentUserMock.mockResolvedValue({
+ manageRegistry: true,
+ permissions: { manage_providers: false },
+ });
+ // When / Then
+ await expect(
+ evaluateRegistryProviderAccess("access-token"),
+ ).resolves.toEqual({
+ status: REGISTRY_ACCESS.INELIGIBLE,
+ });
+ await expect(evaluateRegistryAccess("access-token")).resolves.toEqual({
+ status: REGISTRY_ACCESS.ELIGIBLE,
+ });
+ });
+
+ it.each([
+ ["false", "true", "access-token"],
+ ["true", "false", "access-token"],
+ ["true", "true", ""],
+ ])(
+ "requires enabled flags and a token: %j / %j / %j",
+ async (cloud, flag, token) => {
+ // Given
+ vi.stubEnv("UI_CLOUD_ENABLED", cloud);
+ vi.stubEnv("UI_REGISTRY_ENABLED", flag);
+ // When / Then
+ await expect(evaluateRegistryProviderAccess(token)).resolves.toEqual({
+ status: REGISTRY_ACCESS.INELIGIBLE,
+ });
+ expect(fetchCurrentUserMock).not.toHaveBeenCalled();
+ },
+ );
+
+ it("checks current provider permission again after revocation", async () => {
+ // Given
+ fetchCurrentUserMock
+ .mockResolvedValueOnce({ permissions: { manage_providers: true } })
+ .mockResolvedValueOnce({ permissions: { manage_providers: false } });
+ // When / Then
+ await expect(
+ evaluateRegistryProviderAccess("access-token"),
+ ).resolves.toEqual({
+ status: REGISTRY_ACCESS.ELIGIBLE,
+ });
+ await expect(
+ evaluateRegistryProviderAccess("access-token"),
+ ).resolves.toEqual({
+ status: REGISTRY_ACCESS.INELIGIBLE,
+ });
+ });
+});
+
+describe("evaluateRegistryAccess", () => {
+ beforeEach(() => {
+ vi.stubEnv("UI_CLOUD_ENABLED", "true");
+ vi.stubEnv("UI_REGISTRY_ENABLED", "true");
+ fetchCurrentUserMock.mockResolvedValue({ manageRegistry: true });
+ });
+
+ it("allows a fresh exact-true current permission without lease metadata", async () => {
+ // Given / When
+ const result = await evaluateRegistryAccess("access-token");
+ // Then
+ expect(result).toStrictEqual({ status: REGISTRY_ACCESS.ELIGIBLE });
+ });
+
+ it.each([
+ [undefined, "true", "access-token", true, REGISTRY_ACCESS.INELIGIBLE, 0],
+ ["true", "false", "access-token", true, REGISTRY_ACCESS.INELIGIBLE, 0],
+ ["true", "true", "access-token", false, REGISTRY_ACCESS.INELIGIBLE, 1],
+ ["true", "true", "access-token", undefined, REGISTRY_ACCESS.UNKNOWN, 1],
+ ["true", "true", "", true, REGISTRY_ACCESS.INELIGIBLE, 0],
+ ])(
+ "fails closed without trusting stale JWT authority",
+ async (cloud, flag, token, permission, expected, calls) => {
+ // Given
+ vi.stubEnv("UI_CLOUD_ENABLED", cloud);
+ vi.stubEnv("UI_REGISTRY_ENABLED", flag);
+ fetchCurrentUserMock.mockResolvedValue({ manageRegistry: permission });
+ // When / Then
+ await expect(evaluateRegistryAccess(token)).resolves.toMatchObject({
+ status: expected,
+ });
+ expect(fetchCurrentUserMock).toHaveBeenCalledTimes(calls);
+ },
+ );
+
+ it.each([
+ [" true ", "true"],
+ ["true", " true "],
+ ["\ttrue\n", "\ttrue\n"],
+ ])(
+ "accepts whitespace around enabled flags: %j / %j",
+ async (cloud, flag) => {
+ // Given
+ vi.stubEnv("UI_CLOUD_ENABLED", cloud);
+ vi.stubEnv("UI_REGISTRY_ENABLED", flag);
+ // When / Then
+ await expect(evaluateRegistryAccess("access-token")).resolves.toEqual({
+ status: REGISTRY_ACCESS.ELIGIBLE,
+ });
+ },
+ );
+
+ it("returns unknown for malformed, network, abort, and timeout evidence", async () => {
+ // Given
+ fetchCurrentUserMock
+ .mockResolvedValueOnce({ manageRegistry: undefined })
+ .mockRejectedValueOnce(new Error("network"))
+ .mockRejectedValueOnce(new DOMException("aborted", "AbortError"))
+ .mockImplementationOnce(
+ (_token, { signal }) =>
+ new Promise((_, reject) => signal.addEventListener("abort", reject)),
+ );
+ // When / Then
+ const expectUnknown = () =>
+ expect(evaluateRegistryAccess("access-token")).resolves.toMatchObject({
+ status: REGISTRY_ACCESS.UNKNOWN,
+ });
+ await expectUnknown();
+ await expectUnknown();
+ await expectUnknown();
+ vi.useFakeTimers();
+ const result = evaluateRegistryAccess("access-token");
+ await vi.advanceTimersByTimeAsync(5_000);
+ await expect(result).resolves.toMatchObject({
+ status: REGISTRY_ACCESS.UNKNOWN,
+ });
+ vi.useRealTimers();
+ });
+});
diff --git a/ui/lib/registry/access.server.ts b/ui/lib/registry/access.server.ts
new file mode 100644
index 0000000000..ba4cd6ff56
--- /dev/null
+++ b/ui/lib/registry/access.server.ts
@@ -0,0 +1,61 @@
+import "server-only";
+
+import { fetchCurrentUser, type CurrentUser } from "@/lib/auth/current-user";
+import { readBoolEnv } from "@/lib/runtime-env";
+
+import {
+ isRegistryEligible,
+ REGISTRY_ACCESS,
+ type RegistryAccessResult,
+} from "./access";
+
+const CURRENT_USER_TIMEOUT_MS = 5_000;
+
+const hasEnabledProcessFlags = () =>
+ readBoolEnv("UI_CLOUD_ENABLED") && readBoolEnv("UI_REGISTRY_ENABLED");
+
+export async function evaluateRegistryAccess(
+ accessToken?: string | null,
+): Promise {
+ return evaluatePermission(accessToken, (user) => user.manageRegistry);
+}
+
+export async function evaluateRegistryProviderAccess(
+ accessToken?: string | null,
+): Promise {
+ return evaluatePermission(
+ accessToken,
+ (user) => user.permissions.manage_providers,
+ );
+}
+
+async function evaluatePermission(
+ accessToken: string | null | undefined,
+ readPermission: (user: CurrentUser) => boolean | undefined,
+): Promise {
+ if (!hasEnabledProcessFlags() || !accessToken?.trim()) {
+ return { status: REGISTRY_ACCESS.INELIGIBLE };
+ }
+
+ const controller = new AbortController();
+ const timeout = setTimeout(() => controller.abort(), CURRENT_USER_TIMEOUT_MS);
+
+ try {
+ const currentUser = await fetchCurrentUser(accessToken, {
+ signal: controller.signal,
+ });
+ const permission = readPermission(currentUser);
+ if (permission === undefined) {
+ return { status: REGISTRY_ACCESS.UNKNOWN };
+ }
+ return {
+ status: isRegistryEligible(true, true, permission)
+ ? REGISTRY_ACCESS.ELIGIBLE
+ : REGISTRY_ACCESS.INELIGIBLE,
+ };
+ } catch {
+ return { status: REGISTRY_ACCESS.UNKNOWN };
+ } finally {
+ clearTimeout(timeout);
+ }
+}
diff --git a/ui/lib/registry/access.test.ts b/ui/lib/registry/access.test.ts
new file mode 100644
index 0000000000..98213acc07
--- /dev/null
+++ b/ui/lib/registry/access.test.ts
@@ -0,0 +1,18 @@
+import { describe, expect, it } from "vitest";
+
+import { isRegistryEligible } from "./access";
+describe("Registry access", () => {
+ it.each([
+ [true, true, true, true],
+ [false, true, true, false],
+ [true, false, true, false],
+ [true, true, false, false],
+ [true, true, undefined, false],
+ [true, true, "true", false],
+ ])(
+ "allows only exact current authority",
+ (cloud, flag, permission, expected) => {
+ expect(isRegistryEligible(cloud, flag, permission)).toBe(expected);
+ },
+ );
+});
diff --git a/ui/lib/registry/access.ts b/ui/lib/registry/access.ts
new file mode 100644
index 0000000000..13cc07b6d1
--- /dev/null
+++ b/ui/lib/registry/access.ts
@@ -0,0 +1,19 @@
+export const REGISTRY_ACCESS = {
+ ELIGIBLE: "eligible",
+ INELIGIBLE: "ineligible",
+ UNKNOWN: "unknown",
+} as const;
+
+export type RegistryAccessStatus =
+ (typeof REGISTRY_ACCESS)[keyof typeof REGISTRY_ACCESS];
+
+export interface RegistryAccessResult {
+ status: RegistryAccessStatus;
+}
+
+export const isRegistryEligible = (
+ cloudEnabled: unknown,
+ registryEnabled: unknown,
+ manageRegistry: unknown,
+) =>
+ cloudEnabled === true && registryEnabled === true && manageRegistry === true;
diff --git a/ui/lib/registry/artifact-execution.test.ts b/ui/lib/registry/artifact-execution.test.ts
new file mode 100644
index 0000000000..c1ab275c04
--- /dev/null
+++ b/ui/lib/registry/artifact-execution.test.ts
@@ -0,0 +1,186 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+import { executeRegistryArtifactAddition } from "./artifact-execution";
+
+const {
+ addRegistryArtifactMock,
+ confirmRegistryArtifactAdditionMock,
+ trackAndPollTaskMock,
+} = vi.hoisted(() => ({
+ addRegistryArtifactMock: vi.fn(),
+ confirmRegistryArtifactAdditionMock: vi.fn(),
+ trackAndPollTaskMock: vi.fn(),
+}));
+
+vi.mock("@/actions/registry/registry", () => ({
+ addRegistryArtifact: addRegistryArtifactMock,
+ confirmRegistryArtifactAddition: confirmRegistryArtifactAdditionMock,
+}));
+
+vi.mock("@/store/task-watcher/store", () => ({
+ TASK_WATCHER_STATUS: { READY: "ready", ERROR: "error" },
+ trackAndPollTask: trackAndPollTaskMock,
+}));
+
+const artifactInput = { normalizedName: "prowler-aws", versionSpec: "2.0.0" };
+
+describe("executeRegistryArtifactAddition", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ addRegistryArtifactMock.mockResolvedValue({
+ status: "submitted",
+ taskId: "artifact-task",
+ });
+ trackAndPollTaskMock.mockResolvedValue({
+ status: "ready",
+ result: { installed: true, error: null },
+ });
+ confirmRegistryArtifactAdditionMock.mockResolvedValue({
+ status: "confirmed",
+ tenantArtifacts: [],
+ });
+ });
+
+ it("persists an update target and confirms that version after the task completes", async () => {
+ // Given / When
+ await executeRegistryArtifactAddition({
+ ...artifactInput,
+ operation: "update",
+ });
+ // Then
+ expect(trackAndPollTaskMock).toHaveBeenCalledWith(
+ expect.objectContaining({
+ meta: {
+ normalizedName: "prowler-aws",
+ operation: "update",
+ expectedVersion: "2.0.0",
+ },
+ }),
+ );
+ expect(confirmRegistryArtifactAdditionMock).toHaveBeenCalledWith(
+ "prowler-aws",
+ "2.0.0",
+ );
+ });
+
+ it.each([
+ [
+ "This version cannot be installed.",
+ "The artifact could not be installed.",
+ ],
+ [
+ "Private diagnostic: /srv/registry/customer",
+ "The artifact could not be installed.",
+ ],
+ [null, "The artifact could not be installed."],
+ ["", "The artifact could not be installed."],
+ [" ", "The artifact could not be installed."],
+ ])(
+ "returns a safe task refusal for error %j without confirmation",
+ async (error, message) => {
+ // Given
+ trackAndPollTaskMock.mockResolvedValue({
+ status: "ready",
+ result: { installed: false, error },
+ });
+ // When
+ const outcome = await executeRegistryArtifactAddition(artifactInput);
+ // Then
+ expect(outcome).toEqual({ status: "refused", message });
+ expect(confirmRegistryArtifactAdditionMock).not.toHaveBeenCalled();
+ },
+ );
+
+ it.each([
+ [
+ "an otherwise valid result with an extra field",
+ { installed: true, error: null, reason: "private deployment detail" },
+ ],
+ [
+ "an installed result with an error",
+ { installed: true, error: "unexpected failure" },
+ ],
+ ])("returns error for %s without confirmation", async (_case, result) => {
+ // Given
+ trackAndPollTaskMock.mockResolvedValue({ status: "ready", result });
+ // When
+ const outcome = await executeRegistryArtifactAddition(artifactInput);
+ // Then
+ expect(outcome).toEqual({ status: "error" });
+ expect(confirmRegistryArtifactAdditionMock).not.toHaveBeenCalled();
+ });
+
+ it("maps failed tasks to unavailable without confirmation", async () => {
+ // Given
+ trackAndPollTaskMock.mockResolvedValue({
+ status: "error",
+ error: "failed",
+ });
+ // When
+ const outcome = await executeRegistryArtifactAddition(artifactInput);
+ // Then
+ expect(outcome).toEqual({ status: "unavailable" });
+ expect(confirmRegistryArtifactAdditionMock).not.toHaveBeenCalled();
+ });
+
+ it("uses the error catch when polling throws", async () => {
+ // Given
+ trackAndPollTaskMock.mockRejectedValue(new Error("watcher crashed"));
+ // When
+ const outcome = await executeRegistryArtifactAddition(artifactInput);
+ // Then
+ expect(outcome).toEqual({ status: "error" });
+ expect(confirmRegistryArtifactAdditionMock).not.toHaveBeenCalled();
+ });
+
+ it("does not poll a synchronous 409 onboarding outcome", async () => {
+ // Given
+ addRegistryArtifactMock.mockResolvedValue({ status: "onboarding" });
+ // When
+ const outcome = await executeRegistryArtifactAddition(artifactInput);
+ // Then
+ expect(outcome).toEqual({ status: "onboarding" });
+ expect(confirmRegistryArtifactAdditionMock).not.toHaveBeenCalled();
+ expect(trackAndPollTaskMock).not.toHaveBeenCalled();
+ });
+
+ it("confirms presence after a completed installed task", async () => {
+ // Given
+ // When
+ const outcome = await executeRegistryArtifactAddition(artifactInput);
+ // Then
+ expect(outcome).toEqual({ status: "confirmed", tenantArtifacts: [] });
+ expect(trackAndPollTaskMock).toHaveBeenCalledWith({
+ taskId: "artifact-task",
+ kind: "registry-artifact-add",
+ meta: { normalizedName: "prowler-aws" },
+ notifyHandler: false,
+ });
+ expect(confirmRegistryArtifactAdditionMock).toHaveBeenCalledOnce();
+ expect(confirmRegistryArtifactAdditionMock).toHaveBeenCalledWith(
+ "prowler-aws",
+ );
+ });
+ it("deduplicates concurrent submissions for the same artifact", async () => {
+ await Promise.all([
+ executeRegistryArtifactAddition(artifactInput),
+ executeRegistryArtifactAddition(artifactInput),
+ ]);
+ expect(addRegistryArtifactMock).toHaveBeenCalledOnce();
+ expect(confirmRegistryArtifactAdditionMock).toHaveBeenCalledOnce();
+ });
+
+ it("reports an unconfirmed update if the confirmation request throws", async () => {
+ // Given
+ confirmRegistryArtifactAdditionMock.mockRejectedValue(
+ new Error("Network failure"),
+ );
+ // When
+ const result = await executeRegistryArtifactAddition({
+ ...artifactInput,
+ operation: "update",
+ });
+ // Then
+ expect(result).toEqual({ status: "refresh_failed" });
+ });
+});
diff --git a/ui/lib/registry/artifact-execution.ts b/ui/lib/registry/artifact-execution.ts
new file mode 100644
index 0000000000..6c08f78e02
--- /dev/null
+++ b/ui/lib/registry/artifact-execution.ts
@@ -0,0 +1,122 @@
+import { z } from "zod";
+
+import {
+ addRegistryArtifact,
+ confirmRegistryArtifactAddition,
+} from "@/actions/registry/registry";
+import { notifyRegistryArtifactOutcome } from "@/lib/registry/artifact-notifications";
+import {
+ TASK_WATCHER_STATUS,
+ trackAndPollTask,
+} from "@/store/task-watcher/store";
+import {
+ REGISTRY_ARTIFACT_ACTION,
+ REGISTRY_FAILURE,
+ REGISTRY_INSTALL_OPERATION,
+ REGISTRY_MUTATION,
+ type RegistryArtifactExecutionInput,
+ type RegistryArtifactTaskResult,
+ type RegistryMutationResult,
+} from "@/types/registry";
+
+export const REGISTRY_ARTIFACT_TASK_KIND = "registry-artifact-add";
+
+const artifactTaskResultSchema = z
+ .object({ installed: z.boolean(), error: z.string().nullable() })
+ .strict();
+
+async function runRegistryArtifactAddition(
+ input: RegistryArtifactExecutionInput,
+): Promise {
+ const expectedVersion =
+ input.operation === REGISTRY_INSTALL_OPERATION.UPDATE
+ ? input.versionSpec.trim()
+ : undefined;
+ if (expectedVersion === "") return { status: REGISTRY_FAILURE.ERROR };
+ let submitted;
+ try {
+ submitted = await addRegistryArtifact(input);
+ } catch {
+ return { status: REGISTRY_FAILURE.ERROR };
+ }
+ if (submitted.status !== REGISTRY_ARTIFACT_ACTION.SUBMITTED) {
+ return submitted;
+ }
+
+ let tracked;
+ try {
+ tracked = await trackAndPollTask({
+ taskId: submitted.taskId,
+ kind: REGISTRY_ARTIFACT_TASK_KIND,
+ meta: {
+ normalizedName: input.normalizedName,
+ ...(expectedVersion
+ ? { operation: REGISTRY_INSTALL_OPERATION.UPDATE, expectedVersion }
+ : {}),
+ },
+ notifyHandler: false,
+ });
+ } catch {
+ return { status: REGISTRY_FAILURE.ERROR };
+ }
+ if (tracked.status !== TASK_WATCHER_STATUS.READY) {
+ return { status: REGISTRY_FAILURE.UNAVAILABLE };
+ }
+
+ return confirmRegistryArtifactTask(
+ input.normalizedName,
+ tracked.result,
+ expectedVersion,
+ );
+}
+
+export async function confirmRegistryArtifactTask(
+ normalizedName: string,
+ taskResult: unknown,
+ expectedVersion?: string,
+): Promise {
+ const result = artifactTaskResultSchema.safeParse(taskResult);
+ if (
+ !result.success ||
+ (result.data.installed && result.data.error !== null)
+ ) {
+ return { status: REGISTRY_FAILURE.ERROR };
+ }
+ if (!result.data.installed) {
+ return {
+ status: REGISTRY_MUTATION.REFUSED,
+ // Task errors are backend diagnostics, not user-facing refusal codes.
+ message: "The artifact could not be installed.",
+ };
+ }
+
+ try {
+ return await (expectedVersion === undefined
+ ? confirmRegistryArtifactAddition(normalizedName)
+ : confirmRegistryArtifactAddition(normalizedName, expectedVersion));
+ } catch {
+ return {
+ status:
+ expectedVersion === undefined
+ ? REGISTRY_FAILURE.ERROR
+ : REGISTRY_MUTATION.REFRESH_FAILED,
+ };
+ }
+}
+
+const installations = new Map>();
+
+export function executeRegistryArtifactAddition(
+ input: RegistryArtifactExecutionInput,
+): Promise {
+ const pending = installations.get(input.normalizedName);
+ if (pending) return pending;
+ const execution = runRegistryArtifactAddition(input)
+ .then((result) => {
+ notifyRegistryArtifactOutcome(result, input.operation);
+ return result;
+ })
+ .finally(() => installations.delete(input.normalizedName));
+ installations.set(input.normalizedName, execution);
+ return execution;
+}
diff --git a/ui/lib/registry/artifact-notifications.tsx b/ui/lib/registry/artifact-notifications.tsx
new file mode 100644
index 0000000000..5a568ce29e
--- /dev/null
+++ b/ui/lib/registry/artifact-notifications.tsx
@@ -0,0 +1,45 @@
+import Link from "next/link";
+
+import { toast, ToastAction } from "@/components/shadcn/toast";
+import {
+ REGISTRY_INSTALL_OPERATION,
+ type RegistryInstallOperation,
+ type RegistryMutationResult,
+} from "@/types/registry";
+
+export function notifyRegistryArtifactOutcome(
+ result: RegistryMutationResult,
+ operation: RegistryInstallOperation = REGISTRY_INSTALL_OPERATION.ADD,
+): void {
+ const isUpdate = operation === REGISTRY_INSTALL_OPERATION.UPDATE;
+ if (result.status === "confirmed") {
+ toast({
+ title: isUpdate ? "Artifact updated" : "Artifact added",
+ action: (
+
+ Go to Providers
+
+ ),
+ });
+ window.dispatchEvent(
+ new CustomEvent("registry-artifacts-changed", {
+ detail: result.tenantArtifacts,
+ }),
+ );
+ } else {
+ toast({
+ variant: "destructive",
+ title: isUpdate
+ ? "Artifact could not be updated"
+ : "Artifact could not be added",
+ description:
+ result.status === "refused"
+ ? result.message
+ : result.status === "refresh_failed"
+ ? isUpdate
+ ? "Update could not be confirmed. Refresh Registry before retrying."
+ : "Installation could not be confirmed. Refresh Registry before retrying."
+ : "Check the Registry connection and try again.",
+ });
+ }
+}
diff --git a/ui/lib/registry/credential-execution.test.ts b/ui/lib/registry/credential-execution.test.ts
new file mode 100644
index 0000000000..38163a071f
--- /dev/null
+++ b/ui/lib/registry/credential-execution.test.ts
@@ -0,0 +1,311 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+import type { RegistryCredentialStatus } from "@/types/registry";
+
+import { executeRegistryCredentialValidation } from "./credential-execution";
+import { REGISTRY_CREDENTIAL_TASK_KIND } from "./credential-task";
+
+const {
+ refreshRegistryCollectionsMock,
+ refreshRegistryCredentialMock,
+ submitRegistryCredentialMock,
+ trackAndPollTaskMock,
+} = vi.hoisted(() => ({
+ refreshRegistryCollectionsMock: vi.fn(),
+ refreshRegistryCredentialMock: vi.fn(),
+ submitRegistryCredentialMock: vi.fn(),
+ trackAndPollTaskMock: vi.fn(),
+}));
+
+vi.mock("@/actions/registry/registry", () => ({
+ refreshRegistryCollections: refreshRegistryCollectionsMock,
+ refreshRegistryCredential: refreshRegistryCredentialMock,
+ submitRegistryCredential: submitRegistryCredentialMock,
+}));
+
+vi.mock("@/store/task-watcher/store", () => ({
+ TASK_WATCHER_STATUS: { PENDING: "pending", READY: "ready", ERROR: "error" },
+ trackAndPollTask: trackAndPollTaskMock,
+}));
+
+const activeCredential: RegistryCredentialStatus = {
+ configured: true,
+ isValid: true,
+ scopes: ["catalog:read"],
+ validationPending: false,
+};
+const noCredential: RegistryCredentialStatus = {
+ configured: false,
+ isValid: false,
+ scopes: [],
+ validationPending: false,
+};
+const pendingCredential: RegistryCredentialStatus = {
+ configured: true,
+ isValid: false,
+ scopes: [],
+ validationPending: true,
+};
+
+describe("executeRegistryCredentialValidation", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ refreshRegistryCollectionsMock.mockResolvedValue({
+ status: "complete",
+ catalog: { status: "complete", artifacts: [] },
+ tenantArtifacts: [],
+ });
+ submitRegistryCredentialMock.mockResolvedValue({
+ status: "submitted",
+ taskId: "task-1",
+ priorConfigured: false,
+ });
+ trackAndPollTaskMock.mockResolvedValue({
+ status: "ready",
+ result: { stored: true, error: null },
+ });
+ refreshRegistryCredentialMock.mockResolvedValue({
+ status: "status",
+ credential: activeCredential,
+ });
+ });
+
+ it("connects after the watched task settles and the credential is active", async () => {
+ // Given
+ const key = "registry-test-key";
+
+ // When
+ const outcome = await executeRegistryCredentialValidation(key);
+
+ // Then
+ expect(outcome).toEqual({
+ status: "connected",
+ credential: activeCredential,
+ collections: {
+ status: "complete",
+ catalog: { status: "complete", artifacts: [] },
+ tenantArtifacts: [],
+ },
+ });
+ expect(submitRegistryCredentialMock).toHaveBeenCalledWith(key);
+ expect(refreshRegistryCredentialMock).toHaveBeenCalledTimes(1);
+ expect(refreshRegistryCollectionsMock).toHaveBeenCalledTimes(1);
+ expect(trackAndPollTaskMock).toHaveBeenCalledWith({
+ taskId: "task-1",
+ kind: REGISTRY_CREDENTIAL_TASK_KIND,
+ meta: { priorConfigured: "false" },
+ notifyHandler: false,
+ });
+ // The key must never reach the persisted watcher record.
+ expect(JSON.stringify(trackAndPollTaskMock.mock.calls)).not.toContain(key);
+ });
+
+ it("reports an invalid key when the settled credential is not active", async () => {
+ // Given
+ refreshRegistryCredentialMock.mockResolvedValue({
+ status: "status",
+ credential: noCredential,
+ });
+
+ // When
+ const outcome = await executeRegistryCredentialValidation("bad-key");
+
+ // Then
+ expect(outcome).toEqual({ status: "invalid", credential: noCredential });
+ });
+
+ it("keeps a failed replacement distinct from a first invalid key", async () => {
+ // Given
+ submitRegistryCredentialMock.mockResolvedValue({
+ status: "submitted",
+ taskId: "task-2",
+ priorConfigured: true,
+ });
+ trackAndPollTaskMock.mockResolvedValue({
+ status: "error",
+ error: 'Task ended in state "failed".',
+ });
+ refreshRegistryCredentialMock.mockResolvedValue({
+ status: "status",
+ credential: activeCredential,
+ });
+
+ // When
+ const outcome = await executeRegistryCredentialValidation("replacement");
+
+ // Then
+ expect(outcome).toEqual({ status: "replacement_failed" });
+ });
+
+ it("reports a validation still pending after the watch settles", async () => {
+ // Given
+ trackAndPollTaskMock.mockResolvedValue({
+ status: "error",
+ error: "The task expired before it could be tracked to completion.",
+ });
+ refreshRegistryCredentialMock.mockResolvedValue({
+ status: "status",
+ credential: pendingCredential,
+ });
+
+ // When
+ const outcome = await executeRegistryCredentialValidation("slow-key");
+
+ // Then
+ expect(outcome).toEqual({
+ status: "pending",
+ credential: pendingCredential,
+ });
+ });
+
+ it("passes submit failures through without watching any task", async () => {
+ // Given
+ submitRegistryCredentialMock
+ .mockResolvedValueOnce({ status: "access_denied" })
+ .mockResolvedValueOnce({
+ status: "replacement_failed",
+ credential: activeCredential,
+ })
+ .mockResolvedValueOnce({ status: "error" });
+
+ // When
+ const denied = await executeRegistryCredentialValidation("key");
+ const replacementFailed = await executeRegistryCredentialValidation("key");
+ const failed = await executeRegistryCredentialValidation("key");
+
+ // Then
+ expect(denied).toEqual({ status: "access_denied" });
+ expect(replacementFailed).toEqual({ status: "replacement_failed" });
+ expect(failed).toEqual({ status: "error" });
+ expect(trackAndPollTaskMock).not.toHaveBeenCalled();
+ expect(refreshRegistryCredentialMock).not.toHaveBeenCalled();
+ });
+
+ it("confirms and publishes once when validation finishes after the dialog deadline", async () => {
+ vi.useFakeTimers();
+ const changed = vi.fn();
+ window.addEventListener("registry-credential-changed", changed);
+ try {
+ let finish: (result: unknown) => void = () => {};
+ trackAndPollTaskMock.mockReturnValue(
+ new Promise((resolve) => {
+ finish = resolve;
+ }),
+ );
+ const waiting = executeRegistryCredentialValidation("slow-key");
+ await vi.advanceTimersByTimeAsync(30_000);
+ await expect(waiting).resolves.toEqual({ status: "pending" });
+ expect(refreshRegistryCredentialMock).not.toHaveBeenCalled();
+ finish({ status: "ready", result: { stored: true, error: null } });
+ await vi.advanceTimersByTimeAsync(0);
+ expect(refreshRegistryCredentialMock).toHaveBeenCalledTimes(1);
+ expect(refreshRegistryCollectionsMock).toHaveBeenCalledTimes(1);
+ expect(changed).toHaveBeenCalledTimes(1);
+ expect(changed.mock.calls[0][0].detail.status).toBe("connected");
+ } finally {
+ window.removeEventListener("registry-credential-changed", changed);
+ vi.useRealTimers();
+ }
+ });
+
+ it("publishes the same failure returned to the dialog when collections fail", async () => {
+ const changed = vi.fn();
+ window.addEventListener("registry-credential-changed", changed);
+ try {
+ refreshRegistryCollectionsMock.mockResolvedValue({
+ status: "unavailable",
+ });
+ const result = await executeRegistryCredentialValidation("key");
+ expect(result).toEqual({
+ status: "error",
+ message: "Registry collections could not be loaded. Try again.",
+ });
+ expect(changed).toHaveBeenCalledTimes(1);
+ expect(changed.mock.calls[0][0].detail).toBe(result);
+ } finally {
+ window.removeEventListener("registry-credential-changed", changed);
+ }
+ });
+
+ it.each([false, true])(
+ "does not confirm an unsettled task (priorConfigured: %s)",
+ async (priorConfigured) => {
+ submitRegistryCredentialMock.mockResolvedValue({
+ status: "submitted",
+ taskId: "task-1",
+ priorConfigured,
+ });
+ trackAndPollTaskMock.mockResolvedValue({ status: "pending" });
+ expect(await executeRegistryCredentialValidation("race-key")).toEqual({
+ status: "pending",
+ });
+ expect(refreshRegistryCredentialMock).not.toHaveBeenCalled();
+ expect(refreshRegistryCollectionsMock).not.toHaveBeenCalled();
+ },
+ );
+
+ it("fails safely when the submit RPC rejects", async () => {
+ // Given
+ submitRegistryCredentialMock.mockRejectedValue(new Error("rpc dropped"));
+
+ // When
+ const outcome = await executeRegistryCredentialValidation("key");
+
+ // Then
+ expect(outcome).toEqual({ status: "error" });
+ expect(trackAndPollTaskMock).not.toHaveBeenCalled();
+ });
+
+ it("fails safely when the authoritative re-read RPC rejects", async () => {
+ // Given
+ refreshRegistryCredentialMock.mockRejectedValue(new Error("rpc dropped"));
+
+ // When
+ const outcome = await executeRegistryCredentialValidation("key");
+
+ // Then
+ expect(outcome).toEqual({ status: "error" });
+ });
+
+ it("fails safely when tracking throws before the authoritative re-read", async () => {
+ // Given
+ trackAndPollTaskMock.mockRejectedValue(new Error("watcher crashed"));
+
+ // When
+ const outcome = await executeRegistryCredentialValidation("key");
+
+ // Then
+ expect(outcome).toEqual({ status: "error" });
+ expect(refreshRegistryCredentialMock).not.toHaveBeenCalled();
+ });
+
+ it("propagates authoritative re-read failures after the watch", async () => {
+ // Given
+ refreshRegistryCredentialMock
+ .mockResolvedValueOnce({ status: "access_denied" })
+ .mockResolvedValueOnce({ status: "error" });
+
+ // When
+ const denied = await executeRegistryCredentialValidation("key");
+ const failed = await executeRegistryCredentialValidation("key");
+
+ // Then
+ expect(denied).toEqual({ status: "access_denied" });
+ expect(failed).toEqual({ status: "error" });
+ });
+
+ it("does not report a rejected replacement as connected when the prior key remains active", async () => {
+ submitRegistryCredentialMock.mockResolvedValue({
+ status: "submitted",
+ taskId: "task",
+ priorConfigured: true,
+ });
+ trackAndPollTaskMock.mockResolvedValue({
+ status: "ready",
+ result: { stored: false, error: "Invalid key" },
+ });
+ expect(await executeRegistryCredentialValidation("replacement")).toEqual({
+ status: "replacement_failed",
+ });
+ });
+});
diff --git a/ui/lib/registry/credential-execution.ts b/ui/lib/registry/credential-execution.ts
new file mode 100644
index 0000000000..5a549bce3c
--- /dev/null
+++ b/ui/lib/registry/credential-execution.ts
@@ -0,0 +1,63 @@
+import { submitRegistryCredential } from "@/actions/registry/registry";
+import {
+ completeRegistryCredentialValidation,
+ type RegistryCredentialValidationOutcome,
+} from "@/lib/registry/credential-result";
+import { REGISTRY_CREDENTIAL_TASK_KIND } from "@/lib/registry/credential-task";
+import {
+ TASK_WATCHER_STATUS,
+ trackAndPollTask,
+} from "@/store/task-watcher/store";
+import { REGISTRY_CREDENTIAL_ACTION, REGISTRY_FAILURE } from "@/types/registry";
+
+export const REGISTRY_CREDENTIAL_WATCH_TIMEOUT_MS = 30_000;
+
+/** The live operation owns confirmation. Reloads resume through the kind handler. */
+export async function executeRegistryCredentialValidation(
+ key: string,
+ options: { notifyHandler?: boolean } = {},
+): Promise {
+ let timer: ReturnType | undefined;
+ try {
+ const submitted = await submitRegistryCredential(key);
+ if (
+ submitted.status === REGISTRY_FAILURE.ACCESS_DENIED ||
+ submitted.status === REGISTRY_CREDENTIAL_ACTION.REPLACEMENT_FAILED
+ )
+ return { status: submitted.status };
+ if (submitted.status !== REGISTRY_CREDENTIAL_ACTION.SUBMITTED)
+ return { status: REGISTRY_FAILURE.ERROR };
+
+ // Only non-secret operation context survives reload. Suppression is in-memory
+ // and disappears on reload, when the registered handler takes ownership.
+ const completion = trackAndPollTask({
+ taskId: submitted.taskId,
+ kind: REGISTRY_CREDENTIAL_TASK_KIND,
+ meta: { priorConfigured: String(submitted.priorConfigured) },
+ notifyHandler: false,
+ }).then((tracked) =>
+ tracked.status === TASK_WATCHER_STATUS.PENDING
+ ? ({ status: REGISTRY_CREDENTIAL_ACTION.PENDING } as const)
+ : completeRegistryCredentialValidation(
+ tracked,
+ submitted.priorConfigured,
+ options.notifyHandler ?? true,
+ ),
+ );
+ // Let the dialog recover while completion continues across navigation.
+ // A deadline is not a verdict: only task settlement can confirm the key.
+ const deadline = new Promise(
+ (resolve) => {
+ timer = setTimeout(
+ () => resolve({ status: REGISTRY_CREDENTIAL_ACTION.PENDING }),
+ REGISTRY_CREDENTIAL_WATCH_TIMEOUT_MS,
+ );
+ },
+ );
+ return await Promise.race([completion, deadline]);
+ } catch {
+ return { status: REGISTRY_FAILURE.ERROR };
+ } finally {
+ clearTimeout(timer);
+ }
+}
diff --git a/ui/lib/registry/credential-result.ts b/ui/lib/registry/credential-result.ts
new file mode 100644
index 0000000000..cdaf440d6b
--- /dev/null
+++ b/ui/lib/registry/credential-result.ts
@@ -0,0 +1,142 @@
+import {
+ refreshRegistryCredential,
+ refreshRegistryCollections,
+} from "@/actions/registry/registry";
+import { toast } from "@/components/shadcn/toast";
+import {
+ getRegistryCredentialFailureMessage,
+ isActiveRegistryCredential,
+ isRegistryCredentialTaskSuccessful,
+} from "@/lib/registry/credential-task";
+import {
+ TASK_WATCHER_STATUS,
+ type TaskTrackingResult,
+} from "@/store/task-watcher/store";
+import {
+ REGISTRY_CATALOG,
+ REGISTRY_CREDENTIAL_ACTION,
+ REGISTRY_CREDENTIAL_READ,
+ REGISTRY_FAILURE,
+ type RegistryCredentialReadResult,
+ type RegistryCredentialStatus,
+ type RegistryCollectionsResult,
+} from "@/types/registry";
+
+export type RegistryCredentialValidationOutcome =
+ | {
+ status: typeof REGISTRY_CREDENTIAL_ACTION.CONNECTED;
+ collections: Extract;
+ credential: RegistryCredentialStatus;
+ }
+ | {
+ status: typeof REGISTRY_CREDENTIAL_ACTION.PENDING;
+ credential?: RegistryCredentialStatus;
+ }
+ | {
+ status: typeof REGISTRY_CREDENTIAL_ACTION.INVALID;
+ credential: RegistryCredentialStatus;
+ message?: string;
+ }
+ | { status: typeof REGISTRY_CREDENTIAL_ACTION.REPLACEMENT_FAILED }
+ | { status: typeof REGISTRY_FAILURE.ACCESS_DENIED }
+ | { status: typeof REGISTRY_FAILURE.ERROR; message?: string };
+
+export const REGISTRY_CREDENTIAL_CHANGED = "registry-credential-changed";
+
+async function confirmCredential(
+ tracked: TaskTrackingResult,
+ priorConfigured: boolean,
+): Promise {
+ let read: RegistryCredentialReadResult;
+ try {
+ read = await refreshRegistryCredential();
+ } catch {
+ return { status: REGISTRY_FAILURE.ERROR };
+ }
+ if (read.status === REGISTRY_FAILURE.ACCESS_DENIED) {
+ return { status: REGISTRY_FAILURE.ACCESS_DENIED };
+ }
+ if (read.status !== REGISTRY_CREDENTIAL_READ.STATUS) {
+ return { status: REGISTRY_FAILURE.ERROR };
+ }
+
+ const { credential } = read;
+ // Both the submitted task and the authoritative credential must confirm success.
+ if (
+ isActiveRegistryCredential(credential) &&
+ tracked.status === TASK_WATCHER_STATUS.READY &&
+ isRegistryCredentialTaskSuccessful(tracked.result)
+ ) {
+ const collections = await refreshRegistryCollections().catch(() => null);
+ if (collections?.status === REGISTRY_FAILURE.ACCESS_DENIED)
+ return { status: REGISTRY_FAILURE.ACCESS_DENIED };
+ if (collections?.status !== REGISTRY_CATALOG.COMPLETE)
+ return {
+ status: REGISTRY_FAILURE.ERROR,
+ message: "Registry collections could not be loaded. Try again.",
+ };
+ return {
+ status: REGISTRY_CREDENTIAL_ACTION.CONNECTED,
+ credential,
+ collections,
+ };
+ }
+ if (credential.validationPending) {
+ return { status: REGISTRY_CREDENTIAL_ACTION.PENDING, credential };
+ }
+ // An unsettled watch has not judged the key; report it still pending
+ // rather than invalid or a failed replacement.
+ if (tracked.status === TASK_WATCHER_STATUS.PENDING) {
+ return { status: REGISTRY_CREDENTIAL_ACTION.PENDING, credential };
+ }
+ if (priorConfigured) {
+ return { status: REGISTRY_CREDENTIAL_ACTION.REPLACEMENT_FAILED };
+ }
+ const message = getRegistryCredentialFailureMessage(tracked.result);
+ return {
+ status: REGISTRY_CREDENTIAL_ACTION.INVALID,
+ credential,
+ ...(message ? { message } : {}),
+ };
+}
+
+export function credentialOutcomeMessage(
+ result: RegistryCredentialValidationOutcome,
+): string {
+ if (result.status === REGISTRY_CREDENTIAL_ACTION.PENDING)
+ return "Registry key validation is taking longer than expected. Try again.";
+ if (result.status === REGISTRY_CREDENTIAL_ACTION.REPLACEMENT_FAILED)
+ return "Registry key validation failed. Existing access is unchanged.";
+ if (result.status === REGISTRY_CREDENTIAL_ACTION.INVALID)
+ return (
+ result.message ?? "This Registry key is invalid. Check it and try again."
+ );
+ return (
+ (result.status === REGISTRY_FAILURE.ERROR && result.message) ||
+ "Registry key validation could not be completed. Try again."
+ );
+}
+
+/** Shared by the active operation and the watcher after a document reload. */
+export async function completeRegistryCredentialValidation(
+ tracked: TaskTrackingResult,
+ priorConfigured: boolean,
+ notify = true,
+): Promise {
+ const outcome = await confirmCredential(tracked, priorConfigured);
+ if (notify) {
+ if (outcome.status === REGISTRY_CREDENTIAL_ACTION.CONNECTED) {
+ toast({ title: "Registry connected" });
+ } else if (outcome.status !== REGISTRY_FAILURE.ACCESS_DENIED) {
+ toast({
+ variant: "destructive",
+ title: "Registry key validation failed",
+ description: credentialOutcomeMessage(outcome),
+ });
+ }
+ window.dispatchEvent(
+ new CustomEvent(REGISTRY_CREDENTIAL_CHANGED, { detail: outcome }),
+ );
+ }
+ return outcome;
+}
diff --git a/ui/lib/registry/credential-task.test.ts b/ui/lib/registry/credential-task.test.ts
new file mode 100644
index 0000000000..cb187853c2
--- /dev/null
+++ b/ui/lib/registry/credential-task.test.ts
@@ -0,0 +1,20 @@
+import { describe, expect, it } from "vitest";
+
+import { getRegistryCredentialFailureMessage } from "./credential-task";
+
+describe("Registry credential rejection feedback", () => {
+ it("explains a wrong-environment key without reflecting server data", () => {
+ const message = getRegistryCredentialFailureMessage({
+ stored: false,
+ error: "Registry rejected the API key (HTTP 401). sensitive-input",
+ });
+ expect(message).toContain("Registry environment");
+ expect(message).toContain("HTTP 401");
+ expect(message).not.toContain("sensitive-input");
+ });
+ it("does not expose arbitrary backend errors", () => {
+ expect(
+ getRegistryCredentialFailureMessage({ error: "sensitive-input" }),
+ ).toBeUndefined();
+ });
+});
diff --git a/ui/lib/registry/credential-task.ts b/ui/lib/registry/credential-task.ts
new file mode 100644
index 0000000000..b7959921cd
--- /dev/null
+++ b/ui/lib/registry/credential-task.ts
@@ -0,0 +1,47 @@
+import type { RegistryCredentialStatus } from "@/types/registry";
+
+/**
+ * Watched-task kind for Registry API key validation. Metadata retains only
+ * whether a credential was already configured; the submitted key is write-only
+ * and must never reach the persisted watcher record.
+ */
+export const REGISTRY_CREDENTIAL_TASK_KIND = "registry-credential-validation";
+
+export const isActiveRegistryCredential = (
+ credential: RegistryCredentialStatus | null,
+) =>
+ Boolean(
+ credential?.configured &&
+ credential.isValid &&
+ !credential.validationPending,
+ );
+
+export const isRegistryCredentialTaskSuccessful = (result: unknown): boolean =>
+ typeof result === "object" &&
+ result !== null &&
+ "stored" in result &&
+ result.stored === true &&
+ "error" in result &&
+ result.error === null;
+
+/** Translate known rejection reasons without reflecting server payloads or secrets. */
+export function getRegistryCredentialFailureMessage(
+ result: unknown,
+): string | undefined {
+ if (
+ typeof result !== "object" ||
+ result === null ||
+ !("error" in result) ||
+ typeof result.error !== "string"
+ )
+ return;
+ const error = result.error.toLowerCase();
+ if (error.includes("http 401"))
+ return "The configured Registry rejected this key (HTTP 401). Check that the key belongs to this Registry environment and is still active.";
+ if (error.includes("organization keys are not supported"))
+ return "Use a customer download key for the official Registry. Organization upload keys cannot connect this workspace.";
+ if (error.includes("download scope"))
+ return "This key needs a download scope. Create a download key in Registry and try again.";
+ if (error.includes("customer accounts disabled"))
+ return "Customer accounts are disabled on the configured Registry. Contact its administrator.";
+}
diff --git a/ui/lib/registry/installability.ts b/ui/lib/registry/installability.ts
new file mode 100644
index 0000000000..19932302d9
--- /dev/null
+++ b/ui/lib/registry/installability.ts
@@ -0,0 +1,23 @@
+const REGISTRY_NOT_INSTALLABLE_COPY = {
+ checks_target_is_not_builtin:
+ "Its checks are written for a provider this deployment does not ship.",
+ artifact_compliance_not_supported:
+ "It ships compliance frameworks as well as checks, which is not supported yet.",
+ artifact_targets_no_provider:
+ "It ships checks but names no provider to extend.",
+ artifact_defines_nothing_usable: "It brings neither a provider nor checks.",
+ artifact_ships_with_prowler:
+ "Its code is already inside Prowler. Nothing to install.",
+} as const;
+
+const REGISTRY_NOT_INSTALLABLE_FALLBACK =
+ "This artifact cannot be installed in this deployment.";
+
+/** Explains the API's refusal code; codes it adds later get the fallback. */
+export function getRegistryNotInstallableMessage(reason?: string): string {
+ return reason && Object.hasOwn(REGISTRY_NOT_INSTALLABLE_COPY, reason)
+ ? REGISTRY_NOT_INSTALLABLE_COPY[
+ reason as keyof typeof REGISTRY_NOT_INSTALLABLE_COPY
+ ]
+ : REGISTRY_NOT_INSTALLABLE_FALLBACK;
+}
diff --git a/ui/lib/registry/presentation.test.ts b/ui/lib/registry/presentation.test.ts
new file mode 100644
index 0000000000..847fe1dc99
--- /dev/null
+++ b/ui/lib/registry/presentation.test.ts
@@ -0,0 +1,43 @@
+import { describe, expect, it } from "vitest";
+
+import { getRegistryPresentation } from "./presentation";
+
+describe("Registry presentation configuration", () => {
+ const urlWithCredentials = new URL("https://registry.test");
+ urlWithCredentials.username = "user";
+ urlWithCredentials.password = "pass";
+
+ it("uses the configured Registry and media origins", () => {
+ expect(
+ getRegistryPresentation(
+ "https://registry.private.test/keys",
+ "https://assets.private.test/media/",
+ ),
+ ).toEqual({
+ keyUrl: "https://registry.private.test/keys",
+ imageOrigins: [
+ "https://registry.private.test",
+ "https://assets.private.test",
+ ],
+ });
+ });
+
+ it("does not guess a Registry environment when configuration is missing", () => {
+ expect(getRegistryPresentation()).toEqual({
+ keyUrl: undefined,
+ imageOrigins: [],
+ });
+ });
+
+ it.each([
+ "javascript:alert(1)",
+ urlWithCredentials.href,
+ "https://registry.test; img-src *",
+ "invalid",
+ ])("rejects unsafe configuration: %s", (value) => {
+ expect(getRegistryPresentation(value, value)).toEqual({
+ keyUrl: undefined,
+ imageOrigins: [],
+ });
+ });
+});
diff --git a/ui/lib/registry/presentation.ts b/ui/lib/registry/presentation.ts
new file mode 100644
index 0000000000..91a8ac5656
--- /dev/null
+++ b/ui/lib/registry/presentation.ts
@@ -0,0 +1,33 @@
+/** Accept public HTTP URLs only; never expose URL credentials or CSP syntax. */
+function parsePublicUrl(value?: string | null): URL | undefined {
+ if (!value || /[\s;]/.test(value)) return;
+ try {
+ const url = new URL(value);
+ if (
+ (url.protocol === "https:" || url.protocol === "http:") &&
+ !url.username &&
+ !url.password
+ )
+ return url;
+ } catch {
+ return;
+ }
+}
+
+export function getRegistryPresentation(
+ registryUrl?: string | null,
+ mediaUrl?: string | null,
+) {
+ const registry = parsePublicUrl(registryUrl);
+ const media = parsePublicUrl(mediaUrl);
+ return {
+ keyUrl: registry?.href,
+ imageOrigins: Array.from(
+ new Set(
+ [registry?.origin, media?.origin].filter((origin): origin is string =>
+ Boolean(origin),
+ ),
+ ),
+ ),
+ };
+}
diff --git a/ui/lib/registry/provider-options.test.ts b/ui/lib/registry/provider-options.test.ts
new file mode 100644
index 0000000000..ecc46985b6
--- /dev/null
+++ b/ui/lib/registry/provider-options.test.ts
@@ -0,0 +1,146 @@
+import { describe, expect, it } from "vitest";
+
+import { collectCompleteRegistryCatalog } from "@/actions/registry/registry.adapter";
+import type { RegistryCatalogArtifact } from "@/types/registry";
+
+import { buildRegistryProviderOptions } from "./provider-options";
+
+const provider: RegistryCatalogArtifact = {
+ normalizedName: "acme-package",
+ name: "Acme",
+ providers: ["acme"],
+ providerSlug: "acme",
+ hasProvider: true,
+ isBuiltin: false,
+ isVerified: false,
+ isOfficial: false,
+ isMeta: false,
+ hasChecks: true,
+ hasCompliance: false,
+ isInstallable: true,
+ versionCount: 1,
+ totalDownloads: 0,
+ owners: [],
+};
+
+describe("installed Registry provider options", () => {
+ it("does not infer a provider from checks targets in merged catalog records", async () => {
+ // Given: only the checks record names a target provider.
+ const catalog = await collectCompleteRegistryCatalog(async () => ({
+ data: [
+ {
+ type: "registry-artifacts",
+ id: "external-package",
+ attributes: { has_provider: true },
+ },
+ {
+ type: "registry-artifacts",
+ id: "external-package",
+ attributes: {
+ has_provider: false,
+ providers: ["target-only"],
+ has_checks: true,
+ },
+ },
+ ],
+ meta: { pagination: { page: 1, pages: 1, count: 2 } },
+ }));
+ expect(catalog.status).toBe("complete");
+ if (catalog.status !== "complete") throw new Error("Incomplete fixture");
+
+ // When / Then: installed membership cannot manufacture a declared type.
+ expect(
+ buildRegistryProviderOptions(
+ catalog.artifacts,
+ [{ normalizedName: "external-package", versionSpec: "latest" }],
+ [],
+ ),
+ ).toEqual([]);
+ });
+
+ it("joins confirmed membership, uses backend slugs and excludes non-providers and built-ins", () => {
+ const catalog = [
+ provider,
+ { ...provider, normalizedName: "other-package", providers: ["acme"] },
+ {
+ ...provider,
+ normalizedName: "checks",
+ hasProvider: false,
+ providers: ["checks"],
+ },
+ {
+ ...provider,
+ normalizedName: "builtin",
+ isBuiltin: true,
+ providers: ["builtin"],
+ },
+ {
+ ...provider,
+ normalizedName: "known",
+ providers: ["aws"],
+ providerSlug: "aws",
+ },
+ {
+ ...provider,
+ normalizedName: "uninstalled",
+ providers: ["uninstalled"],
+ },
+ ];
+ const installed = catalog
+ .slice(0, -1)
+ .map(({ normalizedName }) => ({ normalizedName, versionSpec: "latest" }));
+ expect(
+ buildRegistryProviderOptions(catalog, installed, [
+ {
+ type: "acme",
+ label: "Acme Cloud",
+ logoUrl: "https://media.registry.dev.prowler.com/acme.svg",
+ },
+ ]),
+ ).toEqual([
+ {
+ type: "acme",
+ label: "Acme Cloud",
+ logoUrl: "https://media.registry.dev.prowler.com/acme.svg",
+ },
+ ]);
+ expect(buildRegistryProviderOptions(catalog, [], [])).toEqual([]);
+ });
+ it("asks whether an artifact defines a provider type, not whether it installs", () => {
+ // Given: installed checks for AWS, and a provider this deployment now refuses.
+ const catalog = [
+ {
+ ...provider,
+ normalizedName: "aws-checks",
+ hasProvider: false,
+ providerSlug: undefined,
+ providers: ["aws"],
+ },
+ { ...provider, isInstallable: false },
+ ];
+ const installed = catalog.map(({ normalizedName }) => ({
+ normalizedName,
+ versionSpec: "latest",
+ }));
+
+ // When / Then
+ expect(
+ buildRegistryProviderOptions(catalog, installed, []).map(
+ (option) => option.type,
+ ),
+ ).toEqual(["acme"]);
+ });
+
+ it("uses the declared provider rather than other providers targeted by the package", () => {
+ const catalog = [
+ { ...provider, providerSlug: "zeta", providers: ["acme", "zeta"] },
+ ];
+ expect(
+ buildRegistryProviderOptions(
+ catalog,
+ [{ normalizedName: provider.normalizedName, versionSpec: "latest" }],
+ [],
+ ).map((option) => option.type),
+ ).toEqual(["zeta"]);
+ });
+});
diff --git a/ui/lib/registry/provider-options.ts b/ui/lib/registry/provider-options.ts
new file mode 100644
index 0000000000..cfe391480f
--- /dev/null
+++ b/ui/lib/registry/provider-options.ts
@@ -0,0 +1,69 @@
+import { isKnownProviderType } from "@/types/providers";
+import type {
+ RegistryCatalogArtifact,
+ RegistryTenantArtifact,
+} from "@/types/registry";
+
+export interface RegistryProviderOption {
+ type: string;
+ label: string;
+ logoUrl?: string;
+}
+
+export const REGISTRY_PROVIDER_DISCOVERY = {
+ READY: "ready",
+ ACCESS_DENIED: "access_denied",
+ /** Access could not be evaluated: keep Registry hidden but retryable. */
+ UNKNOWN: "unknown",
+ ERROR: "error",
+} as const;
+
+export type RegistryProviderDiscoveryResult =
+ | {
+ status: typeof REGISTRY_PROVIDER_DISCOVERY.READY;
+ options: RegistryProviderOption[];
+ }
+ | { status: typeof REGISTRY_PROVIDER_DISCOVERY.ACCESS_DENIED }
+ | { status: typeof REGISTRY_PROVIDER_DISCOVERY.UNKNOWN }
+ | { status: typeof REGISTRY_PROVIDER_DISCOVERY.ERROR };
+
+export function buildRegistryProviderOptions(
+ catalog: RegistryCatalogArtifact[],
+ installed: RegistryTenantArtifact[],
+ metadata: RegistryProviderOption[],
+): RegistryProviderOption[] {
+ const membership = new Set(
+ installed.map((artifact) => artifact.normalizedName),
+ );
+ const providers = new Map(
+ metadata.map((provider) => [provider.type, provider]),
+ );
+ const options = new Map();
+ for (const artifact of catalog) {
+ // Defining a provider type, not installability: checks artifacts install too.
+ if (!membership.has(artifact.normalizedName) || !artifact.hasProvider)
+ continue;
+ const declaredType = artifact.providerSlug;
+ for (const type of declaredType ? [declaredType] : []) {
+ if (
+ isKnownProviderType(type) ||
+ !/^[a-z][a-z0-9_-]{0,49}$/.test(type) ||
+ options.has(type)
+ )
+ continue;
+ options.set(
+ type,
+ providers.get(type) ?? {
+ type,
+ label: artifact.name || type,
+ ...(artifact.owners[0]?.logoUrl
+ ? { logoUrl: artifact.owners[0].logoUrl }
+ : {}),
+ },
+ );
+ }
+ }
+ return Array.from(options.values()).sort((left, right) =>
+ left.label.localeCompare(right.label),
+ );
+}
diff --git a/ui/lib/role-permissions.ts b/ui/lib/role-permissions.ts
index 0ffba41963..3427e62041 100644
--- a/ui/lib/role-permissions.ts
+++ b/ui/lib/role-permissions.ts
@@ -4,6 +4,7 @@ const hiddenOutsideCloudFields = [
"manage_billing",
"manage_alerts",
"manage_lighthouse_ai_configuration",
+ "manage_registry",
];
export const getVisiblePermissionFormFields = (isCloudEnvironment: boolean) =>
diff --git a/ui/lib/runtime-config.shared.ts b/ui/lib/runtime-config.shared.ts
index db1318fb21..c6a5166cf6 100644
--- a/ui/lib/runtime-config.shared.ts
+++ b/ui/lib/runtime-config.shared.ts
@@ -13,6 +13,7 @@ export interface RuntimePublicConfig {
reoDevClientId: string | null; // reserved
cloudEnabled: boolean;
cloudBillingEnabled: boolean;
+ selfRegistrationEnabled: boolean;
stripePublishableKey: string | null; // reserved
stripePublishableKeyV2: string | null; // reserved
}
@@ -33,6 +34,7 @@ export const EMPTY_RUNTIME_PUBLIC_CONFIG: RuntimePublicConfig = {
reoDevClientId: null,
cloudEnabled: false,
cloudBillingEnabled: false,
+ selfRegistrationEnabled: true,
stripePublishableKey: null,
stripePublishableKeyV2: null,
};
@@ -53,6 +55,7 @@ const pickConfig = (
reoDevClientId: parsed.reoDevClientId ?? null,
cloudEnabled: parsed.cloudEnabled ?? false,
cloudBillingEnabled: parsed.cloudBillingEnabled ?? false,
+ selfRegistrationEnabled: parsed.selfRegistrationEnabled ?? true,
stripePublishableKey: parsed.stripePublishableKey ?? null,
stripePublishableKeyV2: parsed.stripePublishableKeyV2 ?? null,
});
diff --git a/ui/lib/runtime-config.test.ts b/ui/lib/runtime-config.test.ts
index 412cc1e185..3ca2fcb405 100644
--- a/ui/lib/runtime-config.test.ts
+++ b/ui/lib/runtime-config.test.ts
@@ -72,3 +72,27 @@ describe("getRuntimePublicConfig PostHog hosts", () => {
expect(config.posthogUiHost).toBeNull();
});
});
+
+describe("getRuntimePublicConfig self-registration flag", () => {
+ afterEach(() => {
+ vi.unstubAllEnvs();
+ });
+
+ it("is enabled when UI_SELF_REGISTRATION_ENABLED is unset", async () => {
+ vi.stubEnv("UI_SELF_REGISTRATION_ENABLED", undefined);
+
+ const { getRuntimePublicConfig } = await importFresh();
+ const config = await getRuntimePublicConfig();
+
+ expect(config.selfRegistrationEnabled).toBe(true);
+ });
+
+ it('is disabled only when UI_SELF_REGISTRATION_ENABLED is "false"', async () => {
+ vi.stubEnv("UI_SELF_REGISTRATION_ENABLED", "false");
+
+ const { getRuntimePublicConfig } = await importFresh();
+ const config = await getRuntimePublicConfig();
+
+ expect(config.selfRegistrationEnabled).toBe(false);
+ });
+});
diff --git a/ui/lib/runtime-config.ts b/ui/lib/runtime-config.ts
index 29d38ca952..ba71f22f7d 100644
--- a/ui/lib/runtime-config.ts
+++ b/ui/lib/runtime-config.ts
@@ -8,7 +8,7 @@ import {
readGatedEnv,
} from "@/lib/integrations";
import { type RuntimePublicConfig } from "@/lib/runtime-config.shared";
-import { readBoolEnv, readEnv } from "@/lib/runtime-env";
+import { readBoolEnv, readEnv, readOptOutEnv } from "@/lib/runtime-env";
// `connection()` forces a per-request runtime read (never build-snapshotted);
// only this allowlist reaches the client. Each migrated key falls back to its
@@ -51,6 +51,8 @@ export async function getRuntimePublicConfig(): Promise {
posthogUiHost: readGatedEnv("UI_POSTHOG_ENABLED", "UI_POSTHOG_UI_HOST"),
reoDevClientId: readEnv("REO_DEV_CLIENT_ID"),
cloudEnabled: readBoolEnv("UI_CLOUD_ENABLED"),
+ // Off only when explicitly "false": invited users can still register.
+ selfRegistrationEnabled: readOptOutEnv("UI_SELF_REGISTRATION_ENABLED"),
// Install-level selector "legacy" | "metronome" | "false"; the client only
// needs on/off, so expose a derived boolean (the raw selector is read
// server-side for V1/V2 routing). Default (unset) is off.
diff --git a/ui/lib/runtime-env.test.ts b/ui/lib/runtime-env.test.ts
index 7735622c89..02ae75b972 100644
--- a/ui/lib/runtime-env.test.ts
+++ b/ui/lib/runtime-env.test.ts
@@ -1,6 +1,6 @@
import { afterEach, describe, expect, it, vi } from "vitest";
-import { readBoolEnv, readEnv } from "./runtime-env";
+import { readBoolEnv, readEnv, readOptOutEnv } from "./runtime-env";
describe("readEnv", () => {
afterEach(() => {
@@ -121,3 +121,29 @@ describe("readBoolEnv", () => {
}
});
});
+
+describe("readOptOutEnv", () => {
+ afterEach(() => {
+ vi.unstubAllEnvs();
+ });
+
+ it("is true when unset", () => {
+ vi.stubEnv("UI_SELF_REGISTRATION_ENABLED", undefined);
+
+ expect(readOptOutEnv("UI_SELF_REGISTRATION_ENABLED")).toBe(true);
+ });
+
+ it('is false for "false" in any case, whitespace trimmed', () => {
+ for (const value of ["false", " False ", "FALSE"]) {
+ vi.stubEnv("UI_SELF_REGISTRATION_ENABLED", value);
+ expect(readOptOutEnv("UI_SELF_REGISTRATION_ENABLED")).toBe(false);
+ }
+ });
+
+ it('stays true for any other value ("true", "0", "no")', () => {
+ for (const value of ["true", "0", "no"]) {
+ vi.stubEnv("UI_SELF_REGISTRATION_ENABLED", value);
+ expect(readOptOutEnv("UI_SELF_REGISTRATION_ENABLED")).toBe(true);
+ }
+ });
+});
diff --git a/ui/lib/runtime-env.ts b/ui/lib/runtime-env.ts
index 430d4f298d..92ad218b16 100644
--- a/ui/lib/runtime-env.ts
+++ b/ui/lib/runtime-env.ts
@@ -24,3 +24,9 @@ export function readEnv(
export function readBoolEnv(key: keyof NodeJS.ProcessEnv): boolean {
return (readEnv(key) ?? "").trim() === "true";
}
+
+// Reads a runtime boolean flag that is on unless set to "false". Case-insensitive
+// because the same value is often shared with a Django setting written "False".
+export function readOptOutEnv(key: keyof NodeJS.ProcessEnv): boolean {
+ return (readEnv(key) ?? "").trim().toLowerCase() !== "false";
+}
diff --git a/ui/lib/shared/env.test.ts b/ui/lib/shared/env.test.ts
index 6154171e94..715e829989 100644
--- a/ui/lib/shared/env.test.ts
+++ b/ui/lib/shared/env.test.ts
@@ -2,7 +2,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import { RUNTIME_CONFIG_SCRIPT_ID } from "@/lib/runtime-config.shared";
-import { isCloud } from "./env";
+import { isCloud, isSelfRegistrationEnabled } from "./env";
const writeIsland = (content: Record | string) => {
const el = document.createElement("script");
@@ -55,3 +55,43 @@ describe("isCloud", () => {
});
});
});
+
+describe("isSelfRegistrationEnabled", () => {
+ afterEach(() => {
+ vi.unstubAllEnvs();
+ document.head.innerHTML = "";
+ });
+
+ it('returns true outside Prowler Cloud even when UI_SELF_REGISTRATION_ENABLED is "false"', () => {
+ vi.stubEnv("UI_SELF_REGISTRATION_ENABLED", "false");
+ expect(isSelfRegistrationEnabled()).toBe(true);
+ });
+
+ it("returns true in Prowler Cloud when UI_SELF_REGISTRATION_ENABLED is unset", () => {
+ vi.stubEnv("UI_CLOUD_ENABLED", "true");
+ expect(isSelfRegistrationEnabled()).toBe(true);
+ });
+
+ it('returns false in Prowler Cloud when UI_SELF_REGISTRATION_ENABLED is "false"', () => {
+ vi.stubEnv("UI_CLOUD_ENABLED", "true");
+ vi.stubEnv("UI_SELF_REGISTRATION_ENABLED", "false");
+ expect(isSelfRegistrationEnabled()).toBe(false);
+ });
+
+ it("uses the island flags over the env vars", () => {
+ vi.stubEnv("UI_SELF_REGISTRATION_ENABLED", "true");
+ writeIsland({ cloudEnabled: true, selfRegistrationEnabled: false });
+ expect(isSelfRegistrationEnabled()).toBe(false);
+ });
+
+ it("ignores a disabled island flag outside Prowler Cloud", () => {
+ writeIsland({ cloudEnabled: false, selfRegistrationEnabled: false });
+ expect(isSelfRegistrationEnabled()).toBe(true);
+ });
+
+ it("defaults to true when the island omits the flag", () => {
+ vi.stubEnv("UI_SELF_REGISTRATION_ENABLED", "false");
+ writeIsland({ cloudEnabled: true });
+ expect(isSelfRegistrationEnabled()).toBe(true);
+ });
+});
diff --git a/ui/lib/shared/env.ts b/ui/lib/shared/env.ts
index 398822bd02..d54c9d04e0 100644
--- a/ui/lib/shared/env.ts
+++ b/ui/lib/shared/env.ts
@@ -2,7 +2,7 @@
* Shared environment helpers.
*/
import { readRuntimeConfigIsland } from "@/lib/runtime-config.shared";
-import { readBoolEnv } from "@/lib/runtime-env";
+import { readBoolEnv, readOptOutEnv } from "@/lib/runtime-env";
/**
* Whether the UI is running inside a Prowler Cloud deployment.
@@ -20,3 +20,18 @@ export function isCloud(): boolean {
return readBoolEnv("UI_CLOUD_ENABLED");
}
+
+/**
+ * Whether visitors can create an account without an invitation.
+ *
+ * Prowler Cloud only: always on elsewhere. In Cloud it follows
+ * `UI_SELF_REGISTRATION_ENABLED` (island, then env), on unless "false".
+ */
+export function isSelfRegistrationEnabled(): boolean {
+ if (!isCloud()) return true;
+
+ const islandConfig = readRuntimeConfigIsland();
+ if (islandConfig) return islandConfig.selfRegistrationEnabled;
+
+ return readOptOutEnv("UI_SELF_REGISTRATION_ENABLED");
+}
diff --git a/ui/next.config.js b/ui/next.config.js
index 67ef650ebb..e9fd63d84b 100644
--- a/ui/next.config.js
+++ b/ui/next.config.js
@@ -8,6 +8,8 @@ const { withSentryConfig } = require("@sentry/nextjs");
// HTTP Security Headers
const nextConfig = {
poweredByHeader: false,
+ // Server Function arguments include write-only credentials.
+ logging: { serverFunctions: false },
// Dev-only. Lets the dev server accept HMR/asset requests from a tunnel
// hostname (ngrok/cloudflared), needed when testing the local UI through a
// public tunnel or from an external device.
diff --git a/ui/next.config.test.ts b/ui/next.config.test.ts
index 2154ea88f9..a464bb3602 100644
--- a/ui/next.config.test.ts
+++ b/ui/next.config.test.ts
@@ -208,3 +208,14 @@ describe("PostHog Content Security Policy", () => {
expect(Object.values(csp).flat()).not.toContain(POSTHOG_WILDCARD);
});
});
+
+it("allows configured private Registry images in the request CSP", () => {
+ const csp = parseCsp(
+ getCspHeader({
+ ...ENABLED_POSTHOG_CONFIG,
+ registryImageOrigins: ["https://media.private.test"],
+ }),
+ );
+ expect(csp["img-src"]).toContain("https://media.private.test");
+ expect(csp["connect-src"]).not.toContain("https://media.private.test");
+});
diff --git a/ui/package.json b/ui/package.json
index 9afd17df19..c0778f307d 100644
--- a/ui/package.json
+++ b/ui/package.json
@@ -25,6 +25,7 @@
"test:e2e:debug": "playwright test --project=auth --project=sign-up --project=providers --project=invitations --project=scans --project=runtime-config --project=navigation --debug",
"test:e2e:headed": "playwright test --project=auth --project=sign-up --project=providers --project=invitations --project=scans --project=runtime-config --project=navigation --headed",
"test:e2e:install": "playwright install",
+ "test:e2e:registry": "E2E_REGISTRY_ACCEPTANCE_MODE=fixture playwright test --config=playwright.registry.config.ts --project=registry --project=registry-flag-off --project=registry-local --project=registry-mobile",
"test:e2e:report": "playwright show-report",
"test:e2e:ui": "playwright test --project=auth --project=sign-up --project=providers --project=invitations --project=scans --project=runtime-config --ui",
"test:integration": "vitest run --project integration",
diff --git a/ui/playwright.base.ts b/ui/playwright.base.ts
new file mode 100644
index 0000000000..e021966f34
--- /dev/null
+++ b/ui/playwright.base.ts
@@ -0,0 +1,24 @@
+import { defineConfig } from "@playwright/test";
+
+export const getBaseConfig = () =>
+ defineConfig({
+ testDir: "./tests",
+ fullyParallel: true,
+ forbidOnly: !!process.env.CI,
+ retries: process.env.CI ? 2 : 0,
+ workers: process.env.CI ? 1 : undefined,
+ reporter: [["list"]],
+ outputDir: "/tmp/playwright-tests",
+ expect: {
+ timeout: 20000,
+ },
+
+ use: {
+ baseURL: process.env.AUTH_URL
+ ? process.env.AUTH_URL
+ : "http://localhost:3000",
+ trace: "off",
+ screenshot: "off",
+ video: "off",
+ },
+ });
diff --git a/ui/playwright.config.ts b/ui/playwright.config.ts
index 2ae3247997..570f519450 100644
--- a/ui/playwright.config.ts
+++ b/ui/playwright.config.ts
@@ -2,32 +2,13 @@ import { defineConfig, devices } from "@playwright/test";
import fs from "fs";
import path from "path";
+import { getBaseConfig } from "./playwright.base";
+
const localEnvPath = path.resolve(__dirname, ".env.local");
-if (fs.existsSync(localEnvPath)) {
- process.loadEnvFile(localEnvPath);
-}
+if (fs.existsSync(localEnvPath)) process.loadEnvFile(localEnvPath);
export default defineConfig({
- testDir: "./tests",
- fullyParallel: true,
- forbidOnly: !!process.env.CI,
- retries: process.env.CI ? 2 : 0,
- workers: process.env.CI ? 1 : undefined,
- reporter: [["list"]],
- outputDir: "/tmp/playwright-tests",
- expect: {
- timeout: 20000,
- },
-
- use: {
- baseURL: process.env.AUTH_URL
- ? process.env.AUTH_URL
- : "http://localhost:3000",
- trace: "off",
- screenshot: "off",
- video: "off",
- },
-
+ ...getBaseConfig(),
projects: [
// ===========================================
// Authentication Setup Projects
@@ -89,6 +70,7 @@ export default defineConfig({
{
name: "all.auth.setup",
testMatch: "**/*.auth.setup.ts",
+ testIgnore: "manage-registry.auth.setup.ts",
},
// ===========================================
@@ -172,6 +154,7 @@ export default defineConfig({
E2E_ADMIN_USER: process.env.E2E_ADMIN_USER || "e2e@prowler.com",
E2E_ADMIN_PASSWORD:
process.env.E2E_ADMIN_PASSWORD || "Thisisapassword123@",
+ UI_CLOUD_ENABLED: process.env.UI_CLOUD_ENABLED || "false",
},
},
});
diff --git a/ui/playwright.registry.config.ts b/ui/playwright.registry.config.ts
new file mode 100644
index 0000000000..28df0c7d66
--- /dev/null
+++ b/ui/playwright.registry.config.ts
@@ -0,0 +1,93 @@
+import { defineConfig, devices } from "@playwright/test";
+
+import { getBaseConfig } from "./playwright.base";
+
+const registryFixtureApiUrl = "http://127.0.0.1:4300/api/v1";
+const registryFixtureServer = {
+ command:
+ "node --experimental-strip-types tests/registry/controlled-registry-api.mts",
+ reuseExistingServer: false,
+ timeout: 120 * 1000,
+ url: "http://127.0.0.1:4300/health",
+};
+
+const registryFixtureUiServer = (
+ port: number,
+ cloudEnabled: boolean,
+ registryEnabled: boolean,
+) => ({
+ command: `pnpm exec next start --port ${port}`,
+ env: {
+ AUTH_SECRET: "fixture-next-auth-secret-not-a-secret",
+ AUTH_TRUST_HOST: "true",
+ AUTH_URL: `http://127.0.0.1:${port}`,
+ NEXTAUTH_URL: `http://127.0.0.1:${port}`,
+ UI_API_BASE_URL: registryFixtureApiUrl,
+ UI_CLOUD_ENABLED: String(cloudEnabled),
+ UI_REGISTRY_ENABLED: String(registryEnabled),
+ UI_REGISTRY_URL: "https://registry.dev.prowler.com",
+ UI_REGISTRY_MEDIA_URL: "https://media.registry.dev.prowler.com",
+ CLOUD_BILLING_ENABLED: "false",
+ },
+ reuseExistingServer: false,
+ timeout: 120 * 1000,
+ url: `http://127.0.0.1:${port}`,
+});
+
+export default defineConfig({
+ ...getBaseConfig(),
+ workers: 1,
+ projects: [
+ // Registry manager authentication setup
+ // Creates authenticated state for a user with current MANAGE_REGISTRY access
+ {
+ name: "manage-registry.auth.setup",
+ use: { baseURL: "http://127.0.0.1:4301" },
+ testMatch: "manage-registry.auth.setup.ts",
+ },
+
+ // Registry acceptance uses only the self-contained test fixture profile.
+ {
+ name: "registry",
+ use: {
+ ...devices["Desktop Chrome"],
+ baseURL: "http://127.0.0.1:4301",
+ },
+ testMatch: /registry\/.*\.spec\.ts/,
+ dependencies: ["manage-registry.auth.setup"],
+ },
+ {
+ name: "registry-flag-off",
+ use: {
+ ...devices["Desktop Chrome"],
+ baseURL: "http://127.0.0.1:4302",
+ },
+ testMatch: /registry\/.*\.spec\.ts/,
+ dependencies: ["manage-registry.auth.setup"],
+ },
+ {
+ name: "registry-local",
+ use: {
+ ...devices["Desktop Chrome"],
+ baseURL: "http://127.0.0.1:4303",
+ },
+ testMatch: /registry\/.*\.spec\.ts/,
+ dependencies: ["manage-registry.auth.setup"],
+ },
+ {
+ name: "registry-mobile",
+ use: {
+ ...devices["Pixel 5"],
+ baseURL: "http://127.0.0.1:4301",
+ },
+ testMatch: /registry\/.*\.spec\.ts/,
+ dependencies: ["manage-registry.auth.setup"],
+ },
+ ],
+ webServer: [
+ registryFixtureServer,
+ registryFixtureUiServer(4301, true, true),
+ registryFixtureUiServer(4302, true, false),
+ registryFixtureUiServer(4303, false, true),
+ ],
+});
diff --git a/ui/proxy.registry.test.ts b/ui/proxy.registry.test.ts
new file mode 100644
index 0000000000..379d7ff34a
--- /dev/null
+++ b/ui/proxy.registry.test.ts
@@ -0,0 +1,38 @@
+import { describe, expect, it, vi } from "vitest";
+
+const { evaluateAccessMock } = vi.hoisted(() => ({
+ evaluateAccessMock: vi.fn(),
+}));
+vi.mock("@/auth.config", () => ({ auth: (handler: unknown) => handler }));
+vi.mock("@/lib/csp", () => ({ getCspHeader: () => "default-src 'self'" }));
+vi.mock("@/lib/integrations", () => ({
+ GATED_INTEGRATIONS: { posthog: "posthog" },
+ isGatedIntegrationEnabled: () => false,
+ readGatedEnv: () => null,
+}));
+vi.mock("@/lib/registry/access.server", () => ({
+ evaluateRegistryAccess: evaluateAccessMock,
+}));
+vi.mock("@/lib/runtime-env", () => ({ readEnv: () => null }));
+vi.mock("@/lib/shared/env", () => ({ isCloud: () => true }));
+vi.mock("@/lib/utm", () => ({ copyAttributionParams: vi.fn() }));
+
+import proxy from "./proxy";
+
+const request = {
+ auth: { accessToken: "current-token", user: {} },
+ nextUrl: new URL("http://localhost/registry"),
+ url: "http://localhost/registry",
+};
+
+describe("proxy", () => {
+ it("redirects denied direct Registry requests to profile", async () => {
+ // Given / When
+ evaluateAccessMock.mockResolvedValue({ status: "ineligible" });
+ const response = (await proxy(request as never, {} as never)) as Response;
+
+ // Then
+ expect(evaluateAccessMock).toHaveBeenCalledWith("current-token");
+ expect(response.headers.get("location")).toBe("http://localhost/profile");
+ });
+});
diff --git a/ui/proxy.test.ts b/ui/proxy.test.ts
index a63068817e..c0d235cfe9 100644
--- a/ui/proxy.test.ts
+++ b/ui/proxy.test.ts
@@ -1,3 +1,6 @@
+vi.mock("@/lib/registry/access.server", () => ({
+ evaluateRegistryAccess: vi.fn(),
+}));
import type { NextAuthRequest } from "next-auth";
import { describe, expect, it, vi } from "vitest";
diff --git a/ui/proxy.ts b/ui/proxy.ts
index 137d108579..b26008ddfc 100644
--- a/ui/proxy.ts
+++ b/ui/proxy.ts
@@ -12,6 +12,9 @@ import {
SLACK_CALLBACK_PATH,
SLACK_EXPIRED_CALLBACK_URL,
} from "@/lib/integrations/slack-connect-status";
+import { REGISTRY_ACCESS } from "@/lib/registry/access";
+import { evaluateRegistryAccess } from "@/lib/registry/access.server";
+import { getRegistryPresentation } from "@/lib/registry/presentation";
import { readEnv } from "@/lib/runtime-env";
import { isCloud } from "@/lib/shared/env";
import { copyAttributionParams } from "@/lib/utm";
@@ -35,6 +38,10 @@ const withSecurityHeaders = (response: NextResponse): NextResponse => {
"Content-Security-Policy",
getCspHeader({
cloudEnabled: isCloud(),
+ registryImageOrigins: getRegistryPresentation(
+ readEnv("UI_REGISTRY_URL"),
+ readEnv("UI_REGISTRY_MEDIA_URL"),
+ ).imageOrigins,
posthogEnabled: isGatedIntegrationEnabled(GATED_INTEGRATIONS.posthog),
posthogKey: readGatedEnv(
"UI_POSTHOG_ENABLED",
@@ -57,7 +64,7 @@ const redirect = (url: URL): NextResponse =>
withSecurityHeaders(NextResponse.redirect(url));
// NextAuth's auth() wrapper - renamed from middleware to proxy
-export default auth((req: NextAuthRequest) => {
+export default auth(async (req: NextAuthRequest) => {
const { pathname } = req.nextUrl;
const user = req.auth?.user;
@@ -104,6 +111,14 @@ export default auth((req: NextAuthRequest) => {
return redirect(new URL("/profile", req.url));
}
+ if (
+ (pathname === "/registry" || pathname.startsWith("/registry/")) &&
+ (await evaluateRegistryAccess(req.auth?.accessToken)).status !==
+ REGISTRY_ACCESS.ELIGIBLE
+ ) {
+ return redirect(new URL("/profile", req.url));
+ }
+
if (user?.permissions) {
const permissions = user.permissions;
diff --git a/ui/store/task-watcher/store.test.ts b/ui/store/task-watcher/store.test.ts
index d260e35357..d9b5fa582c 100644
--- a/ui/store/task-watcher/store.test.ts
+++ b/ui/store/task-watcher/store.test.ts
@@ -23,6 +23,7 @@ describe("task watcher store", () => {
const onError = vi.fn();
beforeEach(() => {
+ window.dispatchEvent(new PageTransitionEvent("pageshow"));
vi.clearAllMocks();
localStorage.clear();
Object.defineProperty(navigator, "locks", {
@@ -33,6 +34,179 @@ describe("task watcher store", () => {
registerTaskKindHandler("test-kind", { onReady, onError });
});
+ it.each(["beforeunload", "pagehide"])(
+ "preserves pending work when %s aborts its RPC and resumes on return",
+ async (eventType) => {
+ let rejectPoll!: (error: Error) => void;
+ pollMock.mockImplementationOnce(
+ () =>
+ new Promise((_resolve, reject) => {
+ rejectPoll = reject;
+ }),
+ );
+ const tracking = trackAndPollTask({
+ taskId: "reload-task",
+ kind: "test-kind",
+ meta: {},
+ });
+ await vi.waitFor(() => expect(pollMock).toHaveBeenCalledOnce());
+ window.dispatchEvent(new Event(eventType));
+ rejectPoll(new Error("The document was unloaded"));
+ if (eventType === "beforeunload") {
+ window.dispatchEvent(new PageTransitionEvent("pagehide"));
+ }
+ expect(await tracking).toEqual({ status: TASK_WATCHER_STATUS.PENDING });
+ expect(useTaskWatcherStore.getState().tasks["reload-task"]?.status).toBe(
+ TASK_WATCHER_STATUS.PENDING,
+ );
+ expect(onError).not.toHaveBeenCalled();
+
+ if (eventType === "pagehide") {
+ await flush();
+ expect(pollMock).toHaveBeenCalledOnce();
+ expect(onReady).not.toHaveBeenCalled();
+ }
+
+ pollMock.mockResolvedValue({ ok: true, state: "completed" });
+ window.dispatchEvent(
+ new PageTransitionEvent("pageshow", { persisted: true }),
+ );
+ await vi.waitFor(() => expect(onReady).toHaveBeenCalledOnce());
+ },
+ );
+
+ it("keeps the caller's promise and notifications when beforeunload does not hide the page", async () => {
+ // Given: a download or cancelled navigation leaves this document alive.
+ let rejectPoll!: (error: Error) => void;
+ pollMock
+ .mockImplementationOnce(
+ () =>
+ new Promise((_resolve, reject) => {
+ rejectPoll = reject;
+ }),
+ )
+ .mockResolvedValue({ ok: true, state: "completed" });
+ const tracking = trackAndPollTask({
+ taskId: "download-task",
+ kind: "test-kind",
+ meta: {},
+ notifyHandler: false,
+ });
+ await vi.waitFor(() => expect(pollMock).toHaveBeenCalledOnce());
+
+ // When: only the RPC is interrupted; the caller remains in this document.
+ window.dispatchEvent(new Event("beforeunload"));
+ rejectPoll(new Error("The navigation interrupted the request"));
+ // Then: the original caller receives the final result and owns notification.
+ expect(await tracking).toEqual({ status: TASK_WATCHER_STATUS.READY });
+ expect(onReady).not.toHaveBeenCalled();
+ expect(onError).not.toHaveBeenCalled();
+ expect(useTaskWatcherStore.getState().tasks["download-task"]?.status).toBe(
+ TASK_WATCHER_STATUS.READY,
+ );
+ });
+
+ it("preserves a navigation abort delivered after the visible page has recovered", async () => {
+ // Given
+ let rejectPoll!: (error: Error) => void;
+ pollMock
+ .mockImplementationOnce(
+ () =>
+ new Promise((_resolve, reject) => {
+ rejectPoll = reject;
+ }),
+ )
+ .mockResolvedValue({ ok: true, state: "completed" });
+ const tracking = trackAndPollTask({
+ taskId: "delayed-abort",
+ kind: "test-kind",
+ meta: {},
+ });
+ await vi.waitFor(() => expect(pollMock).toHaveBeenCalledOnce());
+
+ // When: browser RPC cancellation arrives after the unload event's task.
+ window.dispatchEvent(new Event("beforeunload"));
+ await flush();
+ rejectPoll(new Error("The navigation interrupted the request"));
+
+ // Then: the original caller gets the final result, not a provisional failure.
+ expect(await tracking).toEqual({ status: TASK_WATCHER_STATUS.READY });
+ expect(onReady).toHaveBeenCalledOnce();
+ expect(onError).not.toHaveBeenCalled();
+ });
+
+ it("allows new tasks after a download without discarding existing results", async () => {
+ // Given
+ const existingResult = {
+ taskId: "previous-export",
+ kind: "export",
+ status: TASK_WATCHER_STATUS.READY,
+ meta: {},
+ startedAt: Date.now(),
+ result: { downloadUrl: "/download" },
+ };
+ useTaskWatcherStore.getState().upsertTask(existingResult);
+ pollMock.mockResolvedValue({ ok: true, state: "completed" });
+
+ // When: a download starts but leaves the document in place.
+ window.dispatchEvent(new Event("beforeunload"));
+ await flush();
+ const result = await trackAndPollTask({
+ taskId: "after-download",
+ kind: "test-kind",
+ meta: {},
+ });
+
+ // Then
+ expect(result.status).toBe(TASK_WATCHER_STATUS.READY);
+ expect(onReady).toHaveBeenCalledOnce();
+ expect(useTaskWatcherStore.getState().tasks["previous-export"]).toEqual(
+ existingResult,
+ );
+ });
+
+ it("keeps caller ownership when bfcache restores before its RPC rejects", async () => {
+ // Given
+ let rejectPoll!: (error: Error) => void;
+ pollMock
+ .mockImplementationOnce(
+ () =>
+ new Promise((_resolve, reject) => {
+ rejectPoll = reject;
+ }),
+ )
+ .mockResolvedValue({ ok: true, state: "completed" });
+ const tracking = trackAndPollTask({
+ taskId: "bfcache-task",
+ kind: "test-kind",
+ meta: {},
+ notifyHandler: false,
+ });
+ await vi.waitFor(() => expect(pollMock).toHaveBeenCalledOnce());
+
+ // When: the cached document returns before its interrupted RPC rejects.
+ window.dispatchEvent(
+ new PageTransitionEvent("pagehide", { persisted: true }),
+ );
+ window.dispatchEvent(
+ new PageTransitionEvent("pageshow", { persisted: true }),
+ );
+ rejectPoll(new Error("The cached document interrupted the request"));
+
+ // Then
+ expect(await tracking).toEqual({ status: TASK_WATCHER_STATUS.READY });
+ expect(onReady).not.toHaveBeenCalled();
+ window.dispatchEvent(
+ new PageTransitionEvent("pageshow", { persisted: true }),
+ );
+ await flush();
+ expect(onReady).not.toHaveBeenCalled();
+ expect(onError).not.toHaveBeenCalled();
+ expect(useTaskWatcherStore.getState().tasks["bfcache-task"]?.status).toBe(
+ TASK_WATCHER_STATUS.READY,
+ );
+ });
+
it("tracks a task, polls it to completion and fires onReady once", async () => {
pollMock.mockResolvedValue({ ok: true, state: "completed" });
diff --git a/ui/store/task-watcher/store.ts b/ui/store/task-watcher/store.ts
index e95389872e..4ddf9c22e8 100644
--- a/ui/store/task-watcher/store.ts
+++ b/ui/store/task-watcher/store.ts
@@ -117,12 +117,71 @@ export const useTaskWatcherStore = create()(
const activePolls = new Map>>();
const suppressedHandlers = new Set();
+// Navigation aborts outstanding Server Action requests. That is not a backend
+// task failure: keep its persisted identity for the next document to resume.
+let pageSuspended = false;
+let pageHidden = false;
+let navigationGeneration = 0;
+let pageRecoveryTimer: ReturnType | undefined;
+const pageRecoveryWaiters = new Set<(visible: boolean) => void>();
+
+const resolvePageRecovery = (visible: boolean) => {
+ pageRecoveryWaiters.forEach((resolve) => resolve(visible));
+ pageRecoveryWaiters.clear();
+};
+
+const resumeVisiblePage = () => {
+ clearTimeout(pageRecoveryTimer);
+ // Downloads and cancelled navigation emit beforeunload without pagehide.
+ // Let pagehide confirm navigation before retrying in the surviving document.
+ pageRecoveryTimer = setTimeout(() => {
+ pageRecoveryTimer = undefined;
+ if (pageHidden) return;
+ pageSuspended = false;
+ resolvePageRecovery(true);
+ void resumePendingTaskPolling();
+ }, 0);
+};
+
+const waitForVisiblePage = (): Promise => {
+ if (pageHidden) return Promise.resolve(false);
+ return new Promise((resolve) => {
+ pageRecoveryWaiters.add(resolve);
+ resumeVisiblePage();
+ });
+};
+
+if (typeof window !== "undefined") {
+ // Browsers can abort a Server Action before pagehide is dispatched.
+ // Mark the navigation at its start so that abort cannot discard the task.
+ window.addEventListener("beforeunload", () => {
+ navigationGeneration++;
+ pageSuspended = true;
+ resumeVisiblePage();
+ });
+ window.addEventListener("pagehide", () => {
+ navigationGeneration++;
+ clearTimeout(pageRecoveryTimer);
+ pageHidden = true;
+ pageSuspended = true;
+ resolvePageRecovery(false);
+ });
+ window.addEventListener("pageshow", (event) => {
+ clearTimeout(pageRecoveryTimer);
+ pageHidden = false;
+ pageSuspended = false;
+ resolvePageRecovery(true);
+ if (event.persisted) void resumePendingTaskPolling();
+ });
+}
+
const settleTask = (
taskId: string,
status: TaskWatcherStatus,
error?: string,
result?: unknown,
): TaskTrackingResult => {
+ if (pageSuspended) return { status: TASK_WATCHER_STATUS.PENDING };
const store = useTaskWatcherStore.getState();
const currentTask = store.tasks[taskId];
if (!currentTask || currentTask.status !== TASK_WATCHER_STATUS.PENDING) {
@@ -224,23 +283,37 @@ const pollUntilDone = (taskId: string): Promise> => {
return runPollLoop(taskId);
};
- if (typeof navigator !== "undefined" && navigator.locks) {
- return await navigator.locks.request(
- `task-watcher:${taskId}`,
- runIfPending,
- );
- }
+ for (;;) {
+ const pollNavigationGeneration = navigationGeneration;
+ try {
+ const result =
+ typeof navigator !== "undefined" && navigator.locks
+ ? await navigator.locks.request(
+ `task-watcher:${taskId}`,
+ runIfPending,
+ )
+ : await runIfPending();
+ if (result.status !== TASK_WATCHER_STATUS.PENDING) return result;
+ } catch {
+ if (
+ !pageSuspended &&
+ navigationGeneration === pollNavigationGeneration
+ ) {
+ return settleTask(
+ taskId,
+ TASK_WATCHER_STATUS.ERROR,
+ "Tracking the task failed unexpectedly. Try again later.",
+ ) as TaskTrackingResult;
+ }
+ }
- return await runIfPending();
- } catch {
- // A thrown poll (e.g. the server-action RPC failing on a network drop)
- // must still settle the task, or it stays PENDING in the persisted
- // store and blocks the UI until the staleness ceiling.
- return settleTask(
- taskId,
- TASK_WATCHER_STATUS.ERROR,
- "Tracking the task failed unexpectedly. Try again later.",
- ) as TaskTrackingResult;
+ // Downloads and cancelled navigation keep the original caller alive.
+ // Retry within its promise so it retains notification ownership and
+ // receives a terminal result. Only a hidden document hands off to resume.
+ if (!(await waitForVisiblePage())) {
+ return { status: TASK_WATCHER_STATUS.PENDING };
+ }
+ }
} finally {
activePolls.delete(taskId);
}
@@ -295,9 +368,6 @@ export const trackAndPollTask = async ({
export const resumePendingTasks = async (): Promise => {
const store = useTaskWatcherStore.getState();
const persistedTasks = Object.values(store.tasks);
- const pending = persistedTasks.filter(
- (task) => task.status === TASK_WATCHER_STATUS.PENDING,
- );
// Settled entries already surfaced in the previous browser session. The
// server-rendered feature UI resolves durable results again on reload, so
@@ -306,6 +376,13 @@ export const resumePendingTasks = async (): Promise => {
.filter((task) => task.status !== TASK_WATCHER_STATUS.PENDING)
.forEach((task) => store.dismissTask(task.taskId));
+ await resumePendingTaskPolling();
+};
+
+async function resumePendingTaskPolling(): Promise {
+ const pending = Object.values(useTaskWatcherStore.getState().tasks).filter(
+ (task) => task.status === TASK_WATCHER_STATUS.PENDING,
+ );
await Promise.all(
pending.map((task) => {
if (Date.now() - task.startedAt > STALE_TASK_MS) {
@@ -319,4 +396,4 @@ export const resumePendingTasks = async (): Promise => {
return pollUntilDone(task.taskId);
}),
);
-};
+}
diff --git a/ui/store/ui/store-initializer.test.tsx b/ui/store/ui/store-initializer.test.tsx
new file mode 100644
index 0000000000..01a3545bb9
--- /dev/null
+++ b/ui/store/ui/store-initializer.test.tsx
@@ -0,0 +1,36 @@
+import { render } from "@testing-library/react";
+import { beforeEach, describe, expect, it } from "vitest";
+
+import { useUIStore } from "./store";
+import { StoreInitializer } from "./store-initializer";
+
+describe("StoreInitializer", () => {
+ beforeEach(() => {
+ localStorage.clear();
+ useUIStore.setState({ hasProviders: false, registryEligible: false });
+ });
+
+ it("keeps Registry hidden when the server sends no eligibility decision", () => {
+ // Given / When
+ render();
+
+ // Then
+ expect(useUIStore.getState().registryEligible).toBe(false);
+ expect(useUIStore.getState().hasProviders).toBe(true);
+ });
+
+ it("never persists Registry eligibility across sessions", () => {
+ // Given / When
+ render(
+ ,
+ );
+
+ // Then
+ expect(useUIStore.getState().registryEligible).toBe(true);
+ const persisted = JSON.parse(localStorage.getItem("ui-store") ?? "{}");
+ expect(persisted.state?.hasProviders).toBe(true);
+ expect(persisted.state).not.toHaveProperty("registryEligible");
+ });
+});
diff --git a/ui/store/ui/store-initializer.tsx b/ui/store/ui/store-initializer.tsx
index d2f1d32965..b86c9934c7 100644
--- a/ui/store/ui/store-initializer.tsx
+++ b/ui/store/ui/store-initializer.tsx
@@ -7,6 +7,7 @@ import { useUIStore } from "@/store/ui/store";
interface StoreInitializerProps {
values: {
hasProviders?: boolean;
+ registryEligible?: boolean;
// Add more properties here as needed
// otherProperty?: string;
};
@@ -14,14 +15,23 @@ interface StoreInitializerProps {
export function StoreInitializer({ values }: StoreInitializerProps) {
const setHasProviders = useUIStore((state) => state.setHasProviders);
+ const setRegistryEligible = useUIStore((state) => state.setRegistryEligible);
useEffect(() => {
// Initialize store values from server
if (values.hasProviders !== undefined) {
setHasProviders(values.hasProviders);
}
+ if (values.registryEligible !== undefined) {
+ setRegistryEligible(values.registryEligible);
+ }
// Add more setters here as needed in the future
- }, [values.hasProviders, setHasProviders]);
+ }, [
+ values.hasProviders,
+ values.registryEligible,
+ setHasProviders,
+ setRegistryEligible,
+ ]);
return null;
}
diff --git a/ui/store/ui/store.ts b/ui/store/ui/store.ts
index 50cce2adf8..5aa321e0b1 100644
--- a/ui/store/ui/store.ts
+++ b/ui/store/ui/store.ts
@@ -4,10 +4,12 @@ import { persist } from "zustand/middleware";
interface UIStoreState {
isSideMenuOpen: boolean;
hasProviders: boolean;
+ registryEligible: boolean;
openSideMenu: () => void;
closeSideMenu: () => void;
setHasProviders: (value: boolean) => void;
+ setRegistryEligible: (value: boolean) => void;
}
export const useUIStore = create()(
@@ -15,12 +17,21 @@ export const useUIStore = create()(
(set) => ({
isSideMenuOpen: false,
hasProviders: false,
+ registryEligible: false,
openSideMenu: () => set({ isSideMenuOpen: true }),
closeSideMenu: () => set({ isSideMenuOpen: false }),
setHasProviders: (value: boolean) => set({ hasProviders: value }),
+ setRegistryEligible: (value: boolean) => set({ registryEligible: value }),
}),
{
name: "ui-store",
+ // Registry eligibility is a per-request server decision; persisting it
+ // would resurface a stale entry on the next session before the server
+ // seed corrects it.
+ partialize: ({ isSideMenuOpen, hasProviders }) => ({
+ isSideMenuOpen,
+ hasProviders,
+ }),
},
),
);
diff --git a/ui/styles/globals.css b/ui/styles/globals.css
index d879b452d2..c8f60e2992 100644
--- a/ui/styles/globals.css
+++ b/ui/styles/globals.css
@@ -92,6 +92,7 @@
--bg-button-tertiary-hover: var(--color-blue-500);
--bg-button-tertiary-active: var(--color-indigo-600);
--bg-button-disabled: var(--color-neutral-300);
+ --button-aws-marketplace: #232f3e;
/* Radar Map */
--bg-radar-map: #b51c8033;
@@ -218,6 +219,7 @@
--bg-button-tertiary-hover: var(--color-blue-400);
--bg-button-tertiary-active: var(--color-blue-600);
--bg-button-disabled: var(--color-neutral-700);
+ --button-aws-marketplace: var(--color-white);
/* Neutral Map */
--bg-neutral-map: var(--color-gray-800);
@@ -330,6 +332,7 @@
--color-button-tertiary-hover: var(--bg-button-tertiary-hover);
--color-button-tertiary-active: var(--bg-button-tertiary-active);
--color-button-disabled: var(--bg-button-disabled);
+ --color-button-aws-marketplace: var(--button-aws-marketplace);
/* Input Colors */
--color-bg-input-primary: var(--bg-input-primary);
diff --git a/ui/tests/onboarding/evidence/aws-marketplace-button-desktop.png b/ui/tests/onboarding/evidence/aws-marketplace-button-desktop.png
new file mode 100644
index 0000000000..ddfac1e755
Binary files /dev/null and b/ui/tests/onboarding/evidence/aws-marketplace-button-desktop.png differ
diff --git a/ui/tests/onboarding/evidence/aws-marketplace-button-mobile.png b/ui/tests/onboarding/evidence/aws-marketplace-button-mobile.png
new file mode 100644
index 0000000000..b0009e3b42
Binary files /dev/null and b/ui/tests/onboarding/evidence/aws-marketplace-button-mobile.png differ
diff --git a/ui/tests/onboarding/evidence/aws-marketplace-button-tablet.png b/ui/tests/onboarding/evidence/aws-marketplace-button-tablet.png
new file mode 100644
index 0000000000..30a2839c77
Binary files /dev/null and b/ui/tests/onboarding/evidence/aws-marketplace-button-tablet.png differ
diff --git a/ui/tests/providers/providers-page.ts b/ui/tests/providers/providers-page.ts
index 070649a4a7..e9bd96cf08 100644
--- a/ui/tests/providers/providers-page.ts
+++ b/ui/tests/providers/providers-page.ts
@@ -719,6 +719,15 @@ export class ProvidersPage extends BasePage {
await singleSubscriptionOption.click();
}
+ async selectGCPSingleProjectMethod(): Promise {
+ const singleProjectOption = this.page.getByRole("radio", {
+ name: "Add A Single GCP Project",
+ exact: true,
+ });
+ await expect(singleProjectOption).toBeVisible({ timeout: 10000 });
+ await singleProjectOption.click();
+ }
+
async selectAWSOrganizationsMethod(): Promise {
await this.page
.getByRole("radio", {
diff --git a/ui/tests/providers/providers.md b/ui/tests/providers/providers.md
index a2914c2cde..6aacca06da 100644
--- a/ui/tests/providers/providers.md
+++ b/ui/tests/providers/providers.md
@@ -374,12 +374,13 @@
1. Navigate to providers page
2. Click "Add Provider" button
3. Select GCP provider type
-4. Fill provider details (project ID and alias)
-5. Select service account credentials type
-6. Fill GCP service account key credentials
-7. Confirm provider connection without launching a scan
-8. Verify return to Providers page
-9. Verify provider exists in Providers table
+4. Select "Add A Single GCP Project"
+5. Fill provider details (project ID and alias)
+6. Select service account credentials type
+7. Fill GCP service account key credentials
+8. Confirm provider connection without launching a scan
+9. Verify return to Providers page
+10. Verify provider exists in Providers table
### Expected Result
diff --git a/ui/tests/providers/providers.spec.ts b/ui/tests/providers/providers.spec.ts
index 8900f3fd5f..724428df5e 100644
--- a/ui/tests/providers/providers.spec.ts
+++ b/ui/tests/providers/providers.spec.ts
@@ -744,8 +744,9 @@ test.describe("Add Provider", () => {
await providersPage.clickAddProvider();
await providersPage.verifyConnectAccountPageLoaded();
- // Select M365 provider
+ // Select GCP provider and the single-project onboarding method
await providersPage.selectGCPProvider();
+ await providersPage.selectGCPSingleProjectMethod();
// Fill provider details
await providersPage.fillGCPProviderDetails(gcpProviderData);
diff --git a/ui/tests/registry/add-provider-tour-report.md b/ui/tests/registry/add-provider-tour-report.md
new file mode 100644
index 0000000000..cdc2c172e7
--- /dev/null
+++ b/ui/tests/registry/add-provider-tour-report.md
@@ -0,0 +1,16 @@
+## Tour Alignment Report
+
+**Tour:** `add-provider@v2`
+**Files touched:** ui/components/providers/radio-group-provider.tsx, ui/components/providers/workflow/forms/connect-account-form.tsx, ui/components/providers/wizard/steps/credentials-step.tsx, ui/components/providers/wizard/steps/dynamic-credentials-step.tsx, ui/components/providers/table/data-table-row-actions.tsx
+
+### Drift detected
+
+No drift detected.
+
+### Recommended actions
+
+None required.
+
+### Version bump verdict
+
+- NO bump — The trigger, provider selector, and wizard body retain their existing anchors. Registry adds provider choices within the same account, credentials, connection, and scan sequence described by version 2.
diff --git a/ui/tests/registry/controlled-registry-api.mts b/ui/tests/registry/controlled-registry-api.mts
new file mode 100644
index 0000000000..a2c16ce582
--- /dev/null
+++ b/ui/tests/registry/controlled-registry-api.mts
@@ -0,0 +1,862 @@
+import {
+ createServer,
+ type IncomingMessage,
+ type ServerResponse,
+} from "node:http";
+
+const port = 4300;
+const taskId = "fixture-registry-validation-task";
+const artifactTaskId = "fixture-registry-artifact-task";
+const fixtureAccessToken = [
+ base64UrlJson({ alg: "none", typ: "JWT" }),
+ base64UrlJson({
+ exp: 4_102_444_800,
+ sub: "fixture-registry-user",
+ tenant_id: "fixture-registry-tenant",
+ }),
+ "fixture-signature-not-a-secret",
+].join(".");
+
+type CredentialState = "active" | "onboarding" | "pending";
+type DiscoveryMode = "error" | "ready" | "reconnect" | "unavailable";
+
+const fixtureProviderId = "d4e71fb8-c657-4c1b-a6ea-92fe611b3431";
+const fixtureSecretId = "e9d17da5-04d7-447b-a59d-8726794a6d55";
+const fixtureScanId = "9b82e67d-513b-4c41-b981-9e559f920f40";
+const fixtureConnectionTaskId = "2118a6a8-7795-4d70-822a-7256c837fd30";
+
+interface FixtureState {
+ publishedArtifacts: string[];
+ catalogReadCount: number;
+ catalogVersion: string;
+ artifactTaskError: string | null;
+ resolvedVersions: Map;
+ holdArtifactTask: boolean;
+ holdCredentialTask: boolean;
+ providerCreated: boolean;
+ providerUid: string;
+ providerAlias: string;
+ secretSaved: boolean;
+ connected: boolean;
+ scanCreated: boolean;
+ connectionReadCount: number;
+ artifactEvents: string[];
+ artifactReadCount: number;
+ artifactSubmissionCount: number;
+ artifactTaskNormalizedName?: string;
+ artifactTaskReadCount: number;
+ artifactTaskVersionSpec?: string;
+ credentialAccepted: boolean;
+ credentialReadCount: number;
+ credentialState: CredentialState;
+ discoveryMode: DiscoveryMode;
+ hasCurrentAuthority: boolean;
+ taskReadCount: number;
+ tenantArtifacts: Map;
+}
+
+const initialState = (): FixtureState => ({
+ publishedArtifacts: [],
+ catalogReadCount: 0,
+ catalogVersion: "1.2.3",
+ artifactTaskError: null,
+ resolvedVersions: new Map(),
+ holdArtifactTask: false,
+ holdCredentialTask: false,
+ providerCreated: false,
+ providerUid: "",
+ providerAlias: "",
+ secretSaved: false,
+ connected: false,
+ scanCreated: false,
+ connectionReadCount: 0,
+ artifactEvents: [],
+ artifactReadCount: 0,
+ artifactSubmissionCount: 0,
+ artifactTaskReadCount: 0,
+ credentialAccepted: false,
+ credentialReadCount: 0,
+ credentialState: "onboarding",
+ discoveryMode: "ready",
+ hasCurrentAuthority: true,
+ taskReadCount: 0,
+ tenantArtifacts: new Map(),
+});
+
+let state = initialState();
+
+const catalogPages = [
+ [
+ catalogArtifact("fixture-network-audit", {
+ description: "Synthetic Registry fixture network audit",
+ has_checks: true,
+ has_provider: true,
+ is_builtin: false,
+ is_installable: true,
+ not_installable_reason: null,
+ is_official: true,
+ is_verified: true,
+ latest_version: "1.2.3",
+ name: "Fixture network audit",
+ owner_logo_url:
+ "https://media.registry.dev.prowler.com/fixture-owner.svg",
+ owner_name: "Prowler Fixtures",
+ owner_slug: "prowler-fixtures",
+ owner_type: "organization",
+ providers: ["fixturecloud"],
+ }),
+ catalogArtifact("fixture-built-in-provider", {
+ description: "Synthetic Registry fixture built-in provider",
+ has_provider: true,
+ is_builtin: true,
+ is_installable: false,
+ not_installable_reason: "artifact_ships_with_prowler",
+ latest_version: "1.0.0",
+ name: "Fixture built-in provider",
+ providers: ["aws"],
+ }),
+ catalogArtifact("fixture-shared-policy", {
+ description: "Synthetic Registry fixture shared policy",
+ has_compliance: true,
+ is_installable: false,
+ not_installable_reason: "artifact_defines_nothing_usable",
+ latest_version: "2.0.0",
+ name: "Fixture shared policy",
+ owner_name: "Community Fixtures",
+ owner_slug: "community-fixtures",
+ owner_type: "organization",
+ providers: ["aws"],
+ }),
+ ],
+ [
+ catalogArtifact("fixture-shared-policy", {
+ description: "Synthetic Registry fixture shared policy",
+ has_compliance: true,
+ is_installable: false,
+ not_installable_reason: "artifact_defines_nothing_usable",
+ latest_version: "2.0.0",
+ name: "Fixture shared policy",
+ providers: ["gcp"],
+ }),
+ ],
+] as const;
+
+const server = createServer(async (request, response) => {
+ const url = new URL(request.url ?? "/", "http://127.0.0.1");
+
+ try {
+ if (url.pathname.startsWith("/__fixture__/registry/")) {
+ await handleFixtureControl(request, response, url.pathname);
+ return;
+ }
+
+ await handleApiRequest(request, response, url);
+ } catch {
+ sendJson(response, 500, { errors: [{ code: "fixture_request_failed" }] });
+ }
+});
+
+server.listen(port, "127.0.0.1");
+
+async function handleFixtureControl(
+ request: IncomingMessage,
+ response: ServerResponse,
+ pathname: string,
+) {
+ if (
+ request.method !== "POST" &&
+ pathname !== "/__fixture__/registry/snapshot"
+ ) {
+ sendJson(response, 405, { errors: [{ code: "method_not_allowed" }] });
+ return;
+ }
+
+ if (pathname === "/__fixture__/registry/reset") {
+ state = initialState();
+ sendJson(response, 200, { ok: true });
+ return;
+ }
+
+ if (pathname === "/__fixture__/registry/revoke-current-authority") {
+ state.hasCurrentAuthority = false;
+ sendJson(response, 200, { ok: true });
+ return;
+ }
+
+ if (pathname === "/__fixture__/registry/artifact-task-hold") {
+ const body = await readJson(request);
+ state.holdArtifactTask = readStringField(body, "hold") === "true";
+ sendJson(response, 200, { ok: true });
+ return;
+ }
+
+ if (pathname === "/__fixture__/registry/credential-task-hold") {
+ const body = await readJson(request);
+ state.holdCredentialTask = readStringField(body, "hold") === "true";
+ sendJson(response, 200, { ok: true });
+ return;
+ }
+
+ if (pathname === "/__fixture__/registry/catalog-version") {
+ const body = await readJson(request);
+ state.catalogVersion = readStringField(body, "version") || "1.2.3";
+ sendJson(response, 200, { ok: true });
+ return;
+ }
+
+ if (pathname === "/__fixture__/registry/publish-artifact") {
+ const name = readStringField(await readJson(request), "name");
+ if (name && !state.publishedArtifacts.includes(name))
+ state.publishedArtifacts.push(name);
+ sendJson(response, 200, { ok: true });
+ return;
+ }
+
+ if (pathname === "/__fixture__/registry/artifact-task-error") {
+ const body = await readJson(request);
+ state.artifactTaskError = readStringField(body, "error") || null;
+ sendJson(response, 200, { ok: true });
+ return;
+ }
+
+ if (pathname === "/__fixture__/registry/discovery-mode") {
+ const body = await readJson(request);
+ const mode = readStringField(body, "mode");
+ if (!mode || !["error", "reconnect", "unavailable"].includes(mode)) {
+ sendJson(response, 400, { errors: [{ code: "invalid_fixture_mode" }] });
+ return;
+ }
+ state.discoveryMode = mode as Exclude;
+ sendJson(response, 200, { ok: true });
+ return;
+ }
+
+ if (pathname === "/__fixture__/registry/snapshot") {
+ sendJson(response, 200, {
+ artifactEvents: state.artifactEvents,
+ catalogReadCount: state.catalogReadCount,
+ artifactReadCount: state.artifactReadCount,
+ artifactSubmissionCount: state.artifactSubmissionCount,
+ artifactTaskReadCount: state.artifactTaskReadCount,
+ artifactTaskVersionSpec: state.artifactTaskVersionSpec,
+ installedVersion: state.resolvedVersions.get("fixture-network-audit"),
+ credentialAccepted: state.credentialAccepted,
+ credentialReadCount: state.credentialReadCount,
+ taskReadCount: state.taskReadCount,
+ providerCreated: state.providerCreated,
+ secretSaved: state.secretSaved,
+ connected: state.connected,
+ scanCreated: state.scanCreated,
+ });
+ return;
+ }
+
+ sendJson(response, 404, { errors: [{ code: "fixture_not_found" }] });
+}
+
+async function handleApiRequest(
+ request: IncomingMessage,
+ response: ServerResponse,
+ url: URL,
+) {
+ const { method } = request;
+ const { pathname } = url;
+
+ if (method === "GET" && pathname === "/health") {
+ sendJson(response, 200, { status: "ready" });
+ return;
+ }
+
+ if (method === "POST" && pathname === "/api/v1/tokens") {
+ sendJson(response, 200, tokenDocument());
+ return;
+ }
+
+ if (method === "POST" && pathname === "/api/v1/tokens/refresh") {
+ sendJson(response, 200, tokenDocument());
+ return;
+ }
+
+ if (method === "GET" && pathname === "/api/v1/users/me") {
+ sendJson(response, 200, currentUserDocument());
+ return;
+ }
+
+ if (method === "GET" && pathname === "/api/v1/provider-groups") {
+ sendJson(response, 200, { data: [] });
+ return;
+ }
+
+ if (
+ method === "GET" &&
+ [
+ "/api/v1/organizations",
+ "/api/v1/scan-configurations",
+ "/api/v1/schedules",
+ ].includes(pathname)
+ ) {
+ sendJson(response, 200, collectionDocument([]));
+ return;
+ }
+ if (method === "GET" && pathname === "/api/v1/providers") {
+ sendJson(
+ response,
+ 200,
+ collectionDocument(state.providerCreated ? [providerResource()] : []),
+ );
+ return;
+ }
+ if (method === "POST" && pathname === "/api/v1/providers") {
+ const body = await readJson(request);
+ if (
+ !state.tenantArtifacts.has("fixture-network-audit") ||
+ state.providerCreated ||
+ readNestedString(body, ["data", "attributes", "provider"]) !==
+ "fixturecloud"
+ ) {
+ sendJson(response, 400, {
+ errors: [{ detail: "Provider is unavailable or already exists." }],
+ });
+ return;
+ }
+ state.providerCreated = true;
+ state.providerUid =
+ readNestedString(body, ["data", "attributes", "uid"]) || "";
+ state.providerAlias =
+ readNestedString(body, ["data", "attributes", "alias"]) || "";
+ sendJson(response, 201, { data: providerResource() });
+ return;
+ }
+ if (
+ method === "GET" &&
+ pathname === `/api/v1/providers/${fixtureProviderId}`
+ ) {
+ sendJson(response, 200, { data: providerResource() });
+ return;
+ }
+ if (
+ method === "GET" &&
+ pathname === "/api/v1/provider-schemas/fixturecloud"
+ ) {
+ sendJson(response, 200, {
+ data: {
+ id: "fixturecloud",
+ type: "provider-schemas",
+ attributes: {
+ secret_types: {
+ api_key: {
+ type: "object",
+ properties: {
+ token: {
+ type: "string",
+ title: "API token",
+ format: "password",
+ writeOnly: true,
+ },
+ },
+ required: ["token"],
+ },
+ },
+ },
+ },
+ });
+ return;
+ }
+ if (
+ (method === "POST" && pathname === "/api/v1/providers/secrets") ||
+ (method === "PATCH" &&
+ pathname === `/api/v1/providers/secrets/${fixtureSecretId}`)
+ ) {
+ const body = await readJson(request);
+ state.secretSaved =
+ readNestedString(body, ["data", "attributes", "secret", "token"]) ===
+ "fixture-provider-token-not-a-secret" &&
+ readNestedString(body, ["data", "attributes", "secret_type"]) ===
+ "api_key";
+ sendJson(
+ response,
+ state.secretSaved ? 201 : 400,
+ state.secretSaved
+ ? { data: { id: fixtureSecretId, type: "provider-secrets" } }
+ : { errors: [{ detail: "Invalid test credentials" }] },
+ );
+ return;
+ }
+ if (
+ method === "POST" &&
+ pathname === `/api/v1/providers/${fixtureProviderId}/connection`
+ ) {
+ state.connectionReadCount = 0;
+ sendJson(response, 202, {
+ data: { id: fixtureConnectionTaskId, type: "tasks" },
+ });
+ return;
+ }
+ if (
+ method === "GET" &&
+ pathname === `/api/v1/tasks/${fixtureConnectionTaskId}`
+ ) {
+ state.connectionReadCount += 1;
+ const complete = state.connectionReadCount >= 2;
+ state.connected = complete && state.secretSaved;
+ sendJson(response, 200, {
+ data: {
+ id: fixtureConnectionTaskId,
+ type: "tasks",
+ attributes: {
+ state: complete ? "completed" : "executing",
+ result: complete ? { connected: state.connected, error: null } : null,
+ },
+ },
+ });
+ return;
+ }
+ if (method === "POST" && pathname === "/api/v1/scans") {
+ if (!state.connected) {
+ sendJson(response, 400, {
+ errors: [{ detail: "Connect the provider first" }],
+ });
+ return;
+ }
+ state.scanCreated = true;
+ sendJson(response, 201, { data: scanResource() });
+ return;
+ }
+ if (method === "GET" && pathname === "/api/v1/scans") {
+ const states = url.searchParams.get("filter[state__in]");
+ const data =
+ state.scanCreated && (!states || states.includes("completed"))
+ ? [scanResource()]
+ : [];
+ sendJson(response, 200, {
+ ...collectionDocument(data),
+ included: state.providerCreated ? [providerResource()] : [],
+ });
+ return;
+ }
+
+ if (method === "GET" && pathname === "/api/v1/registry/credential") {
+ state.credentialReadCount += 1;
+ sendJson(response, 200, credentialDocument());
+ return;
+ }
+
+ if (method === "POST" && pathname === "/api/v1/registry/credential") {
+ const body = await readJson(request);
+ state.credentialAccepted =
+ readNestedString(body, ["data", "attributes", "api_key"]) ===
+ "fixture-registry-key-not-a-secret";
+ if (!state.credentialAccepted) {
+ sendJson(response, 422, { errors: [{ code: "invalid_fixture_key" }] });
+ return;
+ }
+ state.credentialState = "pending";
+ state.taskReadCount = 0;
+ sendJson(
+ response,
+ 202,
+ { data: { id: taskId, type: "tasks" } },
+ { "Content-Location": `/api/v1/tasks/${taskId}` },
+ );
+ return;
+ }
+
+ if (method === "DELETE" && pathname === "/api/v1/registry/credential") {
+ state.credentialState = "onboarding";
+ sendJson(response, 204);
+ return;
+ }
+
+ if (method === "GET" && pathname === `/api/v1/tasks/${taskId}`) {
+ state.taskReadCount += 1;
+ const complete = state.taskReadCount >= 2 && !state.holdCredentialTask;
+ if (complete) state.credentialState = "active";
+ sendJson(response, 200, {
+ data: {
+ attributes: {
+ state: complete ? "completed" : "executing",
+ ...(complete ? { result: { stored: true, error: null } } : {}),
+ },
+ id: taskId,
+ type: "tasks",
+ },
+ });
+ return;
+ }
+
+ if (method === "GET" && pathname === "/api/v1/registry/artifacts") {
+ state.artifactReadCount += 1;
+ state.artifactEvents.push("authoritative-read");
+ sendJson(response, 200, tenantArtifactsDocument());
+ return;
+ }
+
+ if (method === "POST" && pathname === "/api/v1/registry/artifacts") {
+ const body = bodyOrEmpty(await readJson(request));
+ const normalizedName = readNestedString(body, [
+ "data",
+ "attributes",
+ "normalized_name",
+ ]);
+ const versionSpec = readNestedString(body, [
+ "data",
+ "attributes",
+ "version_spec",
+ ]);
+ if (
+ !normalizedName ||
+ !versionSpec ||
+ !hasCatalogArtifact(normalizedName)
+ ) {
+ sendJson(response, 404, {
+ errors: [{ code: "registry_artifact_not_found" }],
+ });
+ return;
+ }
+ state.artifactEvents.push("submission");
+ state.artifactSubmissionCount += 1;
+ state.artifactTaskNormalizedName = normalizedName;
+ state.artifactTaskReadCount = 0;
+ state.artifactTaskVersionSpec = versionSpec;
+ sendJson(
+ response,
+ 202,
+ {
+ data: {
+ id: `${artifactTaskId}-${state.artifactSubmissionCount}`,
+ type: "tasks",
+ },
+ },
+ {
+ "Content-Location": `/api/v1/tasks/${artifactTaskId}-${state.artifactSubmissionCount}`,
+ },
+ );
+ return;
+ }
+
+ if (
+ method === "GET" &&
+ pathname ===
+ `/api/v1/tasks/${artifactTaskId}-${state.artifactSubmissionCount}`
+ ) {
+ if (!state.artifactTaskNormalizedName || !state.artifactTaskVersionSpec) {
+ sendJson(response, 404, { errors: [{ code: "fixture_task_not_found" }] });
+ return;
+ }
+
+ state.artifactEvents.push("task-poll");
+ state.artifactTaskReadCount += 1;
+ const complete =
+ state.artifactTaskReadCount >= 2 && !state.holdArtifactTask;
+ if (complete && !state.artifactTaskError) {
+ state.tenantArtifacts.set(
+ state.artifactTaskNormalizedName,
+ state.artifactTaskVersionSpec,
+ );
+ state.resolvedVersions.set(
+ state.artifactTaskNormalizedName,
+ state.artifactTaskVersionSpec === "latest"
+ ? state.catalogVersion
+ : state.artifactTaskVersionSpec,
+ );
+ }
+ sendJson(response, 200, {
+ data: {
+ attributes: complete
+ ? {
+ state: "completed",
+ result: {
+ installed: !state.artifactTaskError,
+ error: state.artifactTaskError,
+ },
+ }
+ : { state: "executing" },
+ id: `${artifactTaskId}-${state.artifactSubmissionCount}`,
+ type: "tasks",
+ },
+ });
+ return;
+ }
+
+ if (
+ method === "DELETE" &&
+ pathname.startsWith("/api/v1/registry/artifacts/")
+ ) {
+ const normalizedName = decodeURIComponent(
+ pathname.slice("/api/v1/registry/artifacts/".length),
+ );
+ state.tenantArtifacts.delete(normalizedName);
+ state.resolvedVersions.delete(normalizedName);
+ sendJson(response, 204);
+ return;
+ }
+
+ if (method === "GET" && pathname === "/api/v1/registry/providers") {
+ sendDiscoveryResponse(response);
+ return;
+ }
+
+ if (method === "GET" && pathname === "/api/v1/registry/available-artifacts") {
+ state.catalogReadCount += 1;
+ if (state.discoveryMode !== "ready") {
+ sendDiscoveryResponse(response);
+ return;
+ }
+ const page = Number(url.searchParams.get("page[number]") ?? "1");
+ const data = catalogPages[page - 1];
+ if (!data) {
+ sendJson(response, 400, { errors: [{ code: "invalid_fixture_page" }] });
+ return;
+ }
+ sendJson(response, 200, {
+ data: [
+ ...data.map((artifact) =>
+ artifact.id === "fixture-network-audit"
+ ? {
+ ...artifact,
+ attributes: {
+ ...artifact.attributes,
+ latest_version: state.catalogVersion,
+ },
+ }
+ : artifact,
+ ),
+ ...(page === 1
+ ? state.publishedArtifacts.map((name) =>
+ catalogArtifact(name.toLowerCase().replaceAll(" ", "-"), {
+ name,
+ latest_version: "1.0.0",
+ has_checks: true,
+ is_installable: true,
+ not_installable_reason: null,
+ providers: ["aws"],
+ }),
+ )
+ : []),
+ ],
+ meta: {
+ pagination: {
+ count: 4 + state.publishedArtifacts.length,
+ page,
+ pages: 2,
+ },
+ },
+ });
+ return;
+ }
+
+ sendJson(response, 404, { errors: [{ code: "fixture_route_not_found" }] });
+}
+
+function bodyOrEmpty(body: unknown) {
+ return body ?? {};
+}
+
+function sendDiscoveryResponse(response: ServerResponse) {
+ if (state.discoveryMode === "ready") {
+ sendJson(response, 200, {
+ data: [
+ {
+ id: "fixturecloud",
+ type: "registry-providers",
+ attributes: { name: "Fixture Cloud", logo_url: null },
+ },
+ ],
+ });
+ return;
+ }
+
+ const responseByMode = {
+ error: [500, "fixture_unexpected_failure"],
+ reconnect: [502, "registry_key_rejected"],
+ unavailable: [503, "registry_unavailable"],
+ } as const;
+ const [status, code] = responseByMode[state.discoveryMode];
+ sendJson(response, status, { errors: [{ code }] });
+}
+
+function credentialDocument() {
+ const active = state.credentialState === "active";
+ return {
+ data: {
+ attributes: {
+ configured: active || state.credentialState === "pending",
+ is_valid: active,
+ scopes: active ? ["fixture:registry"] : [],
+ validation_pending: state.credentialState === "pending",
+ validation_status: active ? "valid" : "pending",
+ },
+ type: "registry-credentials",
+ },
+ };
+}
+
+function currentUserDocument() {
+ return {
+ data: {
+ attributes: {
+ company_name: "Fixture Registry Company",
+ date_joined: "2026-01-01T00:00:00Z",
+ email: "registry-fixture-user@example.test",
+ name: "Fixture Registry Manager",
+ },
+ id: "fixture-registry-user",
+ type: "users",
+ },
+ included: [
+ {
+ attributes: {
+ manage_registry: state.hasCurrentAuthority,
+ manage_providers: true,
+ manage_scans: true,
+ unlimited_visibility: true,
+ manage_billing: false,
+ },
+ id: "fixture-registry-role",
+ type: "roles",
+ },
+ ],
+ };
+}
+
+function tenantArtifactsDocument() {
+ return {
+ data: Array.from(state.tenantArtifacts, ([id, versionSpec]) => ({
+ attributes: {
+ inserted_at: "2026-01-01T00:00:00Z",
+ updated_at: "2026-01-01T00:00:00Z",
+ version_spec: versionSpec,
+ resolved_version: state.resolvedVersions.get(id),
+ },
+ id,
+ type: "registry-artifacts",
+ })),
+ };
+}
+
+function tokenDocument() {
+ return {
+ data: {
+ attributes: {
+ access: fixtureAccessToken,
+ refresh: "fixture-refresh-token-not-a-secret",
+ },
+ type: "tokens",
+ },
+ };
+}
+
+function catalogArtifact(id: string, attributes: Record) {
+ return { attributes, id, type: "registry-artifacts" };
+}
+
+function hasCatalogArtifact(normalizedName: string) {
+ return catalogPages.flat().some((artifact) => artifact.id === normalizedName);
+}
+
+function base64UrlJson(value: Record) {
+ return Buffer.from(JSON.stringify(value)).toString("base64url");
+}
+
+async function readJson(request: IncomingMessage): Promise {
+ const chunks: Buffer[] = [];
+ for await (const chunk of request) chunks.push(Buffer.from(chunk));
+ if (chunks.length === 0) return undefined;
+ return JSON.parse(Buffer.concat(chunks).toString("utf8")) as unknown;
+}
+
+function readStringField(value: unknown, field: string) {
+ return isRecord(value) && typeof value[field] === "string"
+ ? value[field]
+ : undefined;
+}
+
+function readNestedString(value: unknown, path: string[]) {
+ let current = value;
+ for (const segment of path) {
+ if (!isRecord(current)) return undefined;
+ current = current[segment];
+ }
+ return typeof current === "string" ? current : undefined;
+}
+
+function isRecord(value: unknown): value is Record {
+ return typeof value === "object" && value !== null;
+}
+
+function sendJson(
+ response: ServerResponse,
+ status: number,
+ payload?: unknown,
+ headers: Record = {},
+) {
+ response.writeHead(status, {
+ "Cache-Control": "no-store",
+ ...(payload === undefined
+ ? {}
+ : { "Content-Type": "application/vnd.api+json" }),
+ ...headers,
+ });
+ response.end(payload === undefined ? undefined : JSON.stringify(payload));
+}
+
+function collectionDocument(data: unknown[]) {
+ return {
+ data,
+ meta: { pagination: { page: 1, pages: 1, count: data.length } },
+ };
+}
+function providerResource() {
+ return {
+ id: fixtureProviderId,
+ type: "providers",
+ attributes: {
+ provider: "fixturecloud",
+ uid: state.providerUid,
+ alias: state.providerAlias,
+ is_dynamic: true,
+ status: "completed",
+ available: true,
+ resources: state.scanCreated ? 1 : 0,
+ connection: {
+ connected: state.connected,
+ last_checked_at: state.connected ? "2026-01-01T00:00:00Z" : null,
+ },
+ scanner_args: {},
+ inserted_at: "2026-01-01T00:00:00Z",
+ updated_at: "2026-01-01T00:00:00Z",
+ },
+ relationships: {
+ secret: {
+ data: state.secretSaved
+ ? { id: fixtureSecretId, type: "provider-secrets" }
+ : null,
+ },
+ provider_groups: { data: [], meta: { count: 0 } },
+ },
+ };
+}
+function scanResource() {
+ return {
+ id: fixtureScanId,
+ type: "scans",
+ attributes: {
+ name: "Fixture Registry scan",
+ state: "completed",
+ trigger: "manual",
+ progress: 100,
+ unique_resource_count: 1,
+ duration: 1,
+ scanner_args: {},
+ started_at: "2026-01-01T00:00:00Z",
+ inserted_at: "2026-01-01T00:00:00Z",
+ completed_at: "2026-01-01T00:00:01Z",
+ scheduled_at: null,
+ next_scan_at: null,
+ },
+ relationships: {
+ provider: { data: { id: fixtureProviderId, type: "providers" } },
+ task: { data: null },
+ },
+ };
+}
diff --git a/ui/tests/registry/controlled-registry-fixture.ts b/ui/tests/registry/controlled-registry-fixture.ts
new file mode 100644
index 0000000000..f64603dd98
--- /dev/null
+++ b/ui/tests/registry/controlled-registry-fixture.ts
@@ -0,0 +1,89 @@
+const fixtureBaseUrl = "http://127.0.0.1:4300";
+
+export const FIXTURE_REGISTRY_KEY = "fixture-registry-key-not-a-secret";
+
+export const controlledRegistryFixture = {
+ async publishArtifact(name: string) {
+ await request("/__fixture__/registry/publish-artifact", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ name }),
+ });
+ },
+ async publishVersion(version: string) {
+ await request("/__fixture__/registry/catalog-version", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ version }),
+ });
+ },
+ async setArtifactTaskError(error: string | null) {
+ await request("/__fixture__/registry/artifact-task-error", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ error }),
+ });
+ },
+ async holdArtifactTask(hold: boolean) {
+ await request("/__fixture__/registry/artifact-task-hold", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ hold: String(hold) }),
+ });
+ },
+ async holdCredentialTask(hold: boolean) {
+ await request("/__fixture__/registry/credential-task-hold", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ hold: String(hold) }),
+ });
+ },
+ async reset() {
+ await request("/__fixture__/registry/reset", { method: "POST" });
+ },
+ async revokeCurrentAuthority() {
+ await request("/__fixture__/registry/revoke-current-authority", {
+ method: "POST",
+ });
+ },
+ async setDiscoveryMode(mode: "error" | "reconnect" | "unavailable") {
+ await request("/__fixture__/registry/discovery-mode", {
+ body: JSON.stringify({ mode }),
+ headers: { "Content-Type": "application/json" },
+ method: "POST",
+ });
+ },
+ async snapshot() {
+ return request("/__fixture__/registry/snapshot");
+ },
+};
+
+interface FixtureSnapshot {
+ catalogReadCount: number;
+ artifactTaskVersionSpec?: string;
+ installedVersion?: string;
+ providerCreated: boolean;
+ secretSaved: boolean;
+ connected: boolean;
+ scanCreated: boolean;
+ artifactEvents: string[];
+ artifactReadCount: number;
+ artifactSubmissionCount: number;
+ artifactTaskReadCount: number;
+ credentialAccepted: boolean;
+ credentialReadCount: number;
+ taskReadCount: number;
+}
+
+async function request(
+ path: string,
+ init?: RequestInit,
+): Promise {
+ const response = await fetch(`${fixtureBaseUrl}${path}`, init);
+ if (!response.ok) {
+ throw new Error(
+ `Controlled Registry fixture request failed: ${response.status}`,
+ );
+ }
+ return (await response.json()) as TResponse;
+}
diff --git a/ui/tests/registry/evidence/registry-catalog-desktop-dark.png b/ui/tests/registry/evidence/registry-catalog-desktop-dark.png
new file mode 100644
index 0000000000..52ab982d7a
Binary files /dev/null and b/ui/tests/registry/evidence/registry-catalog-desktop-dark.png differ
diff --git a/ui/tests/registry/evidence/registry-catalog-desktop-light.png b/ui/tests/registry/evidence/registry-catalog-desktop-light.png
new file mode 100644
index 0000000000..961731b65e
Binary files /dev/null and b/ui/tests/registry/evidence/registry-catalog-desktop-light.png differ
diff --git a/ui/tests/registry/evidence/registry-catalog-mobile-dark.png b/ui/tests/registry/evidence/registry-catalog-mobile-dark.png
new file mode 100644
index 0000000000..3a598b03e0
Binary files /dev/null and b/ui/tests/registry/evidence/registry-catalog-mobile-dark.png differ
diff --git a/ui/tests/registry/evidence/registry-catalog-tablet-light.png b/ui/tests/registry/evidence/registry-catalog-tablet-light.png
new file mode 100644
index 0000000000..390fe98f98
Binary files /dev/null and b/ui/tests/registry/evidence/registry-catalog-tablet-light.png differ
diff --git a/ui/tests/registry/evidence/registry-provider-credentials.png b/ui/tests/registry/evidence/registry-provider-credentials.png
new file mode 100644
index 0000000000..2fda38d5ef
Binary files /dev/null and b/ui/tests/registry/evidence/registry-provider-credentials.png differ
diff --git a/ui/tests/registry/evidence/registry-provider-scan-completed.png b/ui/tests/registry/evidence/registry-provider-scan-completed.png
new file mode 100644
index 0000000000..833eaff7a7
Binary files /dev/null and b/ui/tests/registry/evidence/registry-provider-scan-completed.png differ
diff --git a/ui/tests/registry/evidence/registry-provider-selector.png b/ui/tests/registry/evidence/registry-provider-selector.png
new file mode 100644
index 0000000000..adc04021dd
Binary files /dev/null and b/ui/tests/registry/evidence/registry-provider-selector.png differ
diff --git a/ui/tests/registry/registry-page.ts b/ui/tests/registry/registry-page.ts
new file mode 100644
index 0000000000..bf25ed5c29
--- /dev/null
+++ b/ui/tests/registry/registry-page.ts
@@ -0,0 +1,328 @@
+import { expect, type Locator, type Page, test } from "@playwright/test";
+
+import { BasePage } from "../base-page";
+
+export class RegistryPage extends BasePage {
+ async captureEvidence(name: string): Promise {
+ const path = test.info().outputPath(`${name}.png`);
+ await this.page.screenshot({
+ path,
+ fullPage: true,
+ animations: "disabled",
+ });
+ await test.info().attach(name, { path, contentType: "image/png" });
+ }
+ readonly connectButton: Locator;
+ readonly connectDialog: Locator;
+ readonly exploreTab: Locator;
+ readonly myArtifactsTab: Locator;
+ readonly registryKeyInput: Locator;
+ readonly registryLink: Locator;
+ readonly searchInput: Locator;
+ readonly refreshButton: Locator;
+
+ constructor(page: Page) {
+ super(page);
+ this.connectButton = page.getByRole("button", {
+ name: "Connect API key",
+ });
+ this.connectDialog = page.getByRole("dialog", {
+ name: "Connect Registry",
+ });
+ this.exploreTab = page.getByRole("tab", { name: /All/ });
+ this.myArtifactsTab = page.getByRole("tab", { name: /My artifacts/ });
+ this.registryKeyInput = page.getByLabel("Registry key");
+ this.registryLink = page.getByRole("link", { name: "Registry" });
+ this.searchInput = page.getByLabel("Search artifacts");
+ this.refreshButton = page.getByRole("button", { name: "Refresh Registry" });
+ }
+
+ async goto(): Promise {
+ await super.goto("/registry");
+ await this.dismissWelcomeDialog();
+ }
+
+ artifactCardFor(name: string): Locator {
+ return this.page
+ .getByRole("listitem")
+ .filter({ has: this.page.getByText(name, { exact: true }) });
+ }
+
+ addButtonFor(name: string): Locator {
+ return this.page.getByRole("button", { name: `Add ${name}` });
+ }
+
+ updateButtonFor(name: string, version: string): Locator {
+ return this.page.getByRole("button", {
+ name: `Update ${name} to ${version}`,
+ });
+ }
+
+ removeButtonFor(name: string): Locator {
+ return this.page.getByRole("button", { name: `Remove ${name}` });
+ }
+
+ async verifyDirectRouteDenied(): Promise {
+ await this.dismissWelcomeDialog();
+ await expect(this.page).not.toHaveURL(/\/registry(?:\?|$)/);
+ await expect(
+ this.page.getByRole("heading", { name: "Profile" }),
+ ).toBeVisible();
+ }
+
+ async verifyRegistryNavigationVisible(): Promise {
+ await this.dismissWelcomeDialog();
+ await expect(this.registryLink).toBeVisible();
+ }
+
+ async verifyRegistryNavigationHidden(): Promise {
+ await expect(this.registryLink).toBeHidden();
+ }
+
+ async verifyProviderSelectorWithoutRegistry(): Promise {
+ await this.page.goto("/providers");
+ await this.dismissWelcomeDialog();
+ await this.page.getByRole("button", { name: /Add (a )?Provider/i }).click();
+ await expect(
+ this.page.getByRole("option", {
+ name: "Amazon Web Services",
+ exact: true,
+ }),
+ ).toBeVisible();
+ await expect(
+ this.page.getByRole("tab", { name: "Registry", exact: true }),
+ ).toBeHidden();
+ await expect(
+ this.page.getByRole("tab", { name: "All providers", exact: true }),
+ ).toBeHidden();
+ }
+
+ async verifyOnboarding(): Promise {
+ await expect(this.connectButton).toBeVisible();
+ await expect(
+ this.page.getByRole("link", {
+ name: "Explore Prowler Registry (opens in a new tab)",
+ }),
+ ).toBeVisible();
+ }
+
+ async verifyMarketplaceReady(): Promise {
+ await expect(this.exploreTab).toBeVisible();
+ await expect(
+ this.page.getByRole("button", { name: "Manage access" }),
+ ).toBeVisible();
+ }
+
+ async verifySearchPreserved(search: string): Promise {
+ await expect(this.searchInput).toHaveValue(search);
+ expect(new URL(this.page.url()).searchParams.get("filter[search]")).toBe(
+ search,
+ );
+ }
+
+ async submitRegistryKey(key: string): Promise {
+ await this.connectButton.click();
+ await expect(this.connectDialog).toBeVisible();
+ await expect(this.registryKeyInput).toBeFocused();
+ await this.registryKeyInput.fill(key);
+ await this.page
+ .getByRole("button", { name: "Connect", exact: true })
+ .click();
+ }
+
+ async connectFixtureRegistry(): Promise {
+ await this.dismissWelcomeDialog();
+ await this.verifyOnboarding();
+ await this.submitRegistryKey("fixture-registry-key-not-a-secret");
+ await this.verifyMarketplaceReady();
+ }
+
+ async verifyCompleteCatalogSearchAndFilters(): Promise {
+ const sharedPolicyCard = this.page.getByText("Fixture shared policy", {
+ exact: true,
+ });
+ const networkAuditCard = this.page.getByText("Fixture network audit", {
+ exact: true,
+ });
+ await this.searchInput.fill("shared");
+ await expect(sharedPolicyCard).toBeVisible();
+
+ await this.page
+ .getByRole("combobox", { name: "Filter by provider" })
+ .press("Enter");
+ await this.page.getByRole("option", { name: "AWS", exact: true }).click();
+ await expect(this.page).toHaveURL(/filter%5Bprovider%5D=aws/);
+ await this.page.keyboard.press("Escape");
+ await expect(sharedPolicyCard).toBeVisible();
+
+ // The multi-provider artifact stays reachable through every provider it serves.
+ await this.page
+ .getByRole("combobox", { name: "Filter by provider" })
+ .press("Enter");
+ await this.page
+ .getByRole("option", { name: "Google Cloud", exact: true })
+ .click();
+ await this.page.keyboard.press("Escape");
+ await expect(sharedPolicyCard).toBeVisible();
+ await expect(networkAuditCard).toBeHidden();
+
+ await this.page
+ .getByRole("button", { name: "Clear filters", exact: true })
+ .click();
+ await expect(networkAuditCard).toBeVisible();
+ }
+
+ async verifyOwnerRows(): Promise {
+ // Logo-backed owner renders its image; the logo-less owner falls back to
+ // an initial avatar, so only its name is asserted.
+ await expect(this.page.getByText("Prowler Fixtures")).toBeVisible();
+ await expect(
+ this.page.locator('img[src$="/fixture-owner.svg"]'),
+ ).toBeVisible();
+ await expect(this.page.getByText("Community Fixtures")).toBeVisible();
+ }
+
+ async verifyBuiltInArtifactHasNoAdd(name: string): Promise {
+ const card = this.artifactCardFor(name);
+
+ await expect(card.getByRole("status", { name: "Built in" })).toBeVisible();
+ await expect(
+ card.getByRole("button", { name: `Add ${name}` }),
+ ).toBeHidden();
+ }
+
+ async addLatest(name: string): Promise {
+ await this.addButtonFor(name).click();
+ await expect(
+ this.artifactCardFor(name).getByText("Added", { exact: true }),
+ ).toBeVisible();
+ }
+
+ async verifyAddedInMyArtifacts(name: string): Promise {
+ await this.myArtifactsTab.click();
+ await expect(this.removeButtonFor(name)).toBeVisible();
+ }
+
+ async removeArtifact(name: string): Promise {
+ await this.removeButtonFor(name).click();
+ await expect(
+ this.page.getByRole("button", { name: "Cancel" }),
+ ).toBeFocused();
+ await this.page.getByRole("button", { name: "Confirm Remove" }).click();
+ await expect(
+ this.page.getByText("Artifact removed", { exact: true }),
+ ).toBeVisible();
+ }
+
+ async dismissWelcomeDialog(): Promise {
+ for (const name of ["Got it", "Skip for now"]) {
+ const dismiss = this.page.getByRole("button", { name, exact: true });
+ if (
+ await dismiss
+ .waitFor({ state: "visible", timeout: 1500 })
+ .then(() => true)
+ .catch(() => false)
+ )
+ await dismiss.click({ timeout: 2000 }).catch(async () => {
+ // A route transition can unmount the welcome popover while it animates.
+ await expect(dismiss).toBeHidden();
+ });
+ }
+ }
+
+ async verifyKeyIsNotDisclosed(
+ key: string,
+ requestUrls: string[],
+ ): Promise {
+ const literalKey = new RegExp(
+ key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"),
+ "u",
+ );
+ await expect(this.page).not.toHaveURL(literalKey);
+ await expect(this.page.locator("body")).not.toContainText(key);
+ for (const requestUrl of requestUrls) {
+ expect(requestUrl).not.toContain(key);
+ }
+
+ const storedValues = await this.page.evaluate(() => [
+ ...Object.values(localStorage),
+ ...Object.values(sessionStorage),
+ ]);
+ for (const storedValue of storedValues) {
+ expect(storedValue).not.toContain(key);
+ }
+ }
+ async connectInstalledProviderAndScan(): Promise {
+ await this.page.getByRole("link", { name: "Go to Providers" }).click();
+ await expect(this.page).toHaveURL(/\/providers$/);
+ await this.dismissWelcomeDialog();
+ await this.page.getByRole("button", { name: /Add (a )?Provider/i }).click();
+ const allTab = this.page.getByRole("tab", {
+ name: "All providers",
+ exact: true,
+ });
+ const registryTab = this.page.getByRole("tab", {
+ name: "Registry",
+ exact: true,
+ });
+ const nativeProvider = this.page.getByRole("option", {
+ name: "Amazon Web Services",
+ exact: true,
+ });
+ await expect(allTab).toHaveAttribute("aria-selected", "true");
+ await expect(nativeProvider).toBeVisible();
+ await registryTab.click();
+ await expect(nativeProvider).toBeHidden();
+ await expect(
+ this.page.getByRole("option", { name: "Fixture Cloud Registry" }),
+ ).toBeVisible();
+ await this.page
+ .getByRole("option", { name: "Fixture Cloud Registry" })
+ .scrollIntoViewIfNeeded();
+ await this.captureEvidence("registry-provider-selector");
+ await this.page
+ .getByRole("option", { name: "Fixture Cloud Registry" })
+ .click();
+ await this.page
+ .getByLabel("Provider UID", { exact: true })
+ .fill("fixture-account");
+ await this.page
+ .getByLabel("Provider alias (optional)")
+ .fill("Registry test account");
+ await this.page.getByRole("button", { name: "Next", exact: true }).click();
+ const token = this.page.getByLabel("API token", { exact: false });
+ await expect(token).toBeVisible();
+ await expect(token).toHaveAttribute("type", "password");
+ await this.captureEvidence("registry-provider-credentials");
+ await token.fill("fixture-provider-token-not-a-secret");
+ await this.page
+ .getByRole("button", { name: "Authenticate", exact: true })
+ .click();
+ await this.page
+ .getByRole("button", { name: "Check connection", exact: true })
+ .click();
+ await this.page
+ .getByRole("radio", { name: "Run now", exact: true })
+ .click();
+ await this.page
+ .getByRole("button", { name: "Launch scan", exact: true })
+ .click();
+ await expect(
+ this.page.getByText("Scan launched", { exact: true }),
+ ).toBeVisible();
+ await this.page.goto("/scans?tab=completed");
+ const completedScan = this.page
+ .getByRole("tabpanel", { name: "Completed", exact: true })
+ .getByRole("row")
+ .filter({
+ has: this.page.getByText("Fixture Registry scan", { exact: true }),
+ });
+ await expect(
+ completedScan.getByText("Fixture Registry scan", { exact: true }),
+ ).toBeVisible();
+ await expect(
+ completedScan.getByText("Registry test account", { exact: true }),
+ ).toBeVisible();
+ await this.captureEvidence("registry-provider-scan-completed");
+ }
+}
diff --git a/ui/tests/registry/registry.md b/ui/tests/registry/registry.md
new file mode 100644
index 0000000000..335c91fb3c
--- /dev/null
+++ b/ui/tests/registry/registry.md
@@ -0,0 +1,116 @@
+### E2E Tests: Registry
+
+**Suite ID:** `REGISTRY-E2E`
+**Feature:** Cloud Registry access, onboarding, and tenant artifact management.
+
+**Fixture boundary:** `pnpm run test:e2e:registry` uses `playwright.registry.config.ts` to start a test-only local API fixture and three real Next.js servers. It uses only synthetic fixture identities, token shapes, and Registry key data. It exercises the browser, NextAuth, proxy, server actions, and Registry UI; it does not prove a proprietary Registry deployment. Live controlled-backend acceptance remains a rollout prerequisite.
+
+---
+
+## Test Case: `REGISTRY-E2E-001` - Fail-Closed Runtime Profiles
+
+**Priority:** `critical`
+**Tags:** @e2e, @registry
+
+**Preconditions:** Local and Cloud-with-Registry-flag-off fixture servers.
+
+**Flow:** Verify Registry navigation and direct-route access, then open the Add Provider selector.
+
+**Expected Result:** Registry navigation is absent, the direct route redirects safely, and Add Provider lists built-in providers without Registry source tabs in both profiles.
+
+## Test Case: `REGISTRY-E2E-002` - Enabled Manager Discovery
+
+**Priority:** `critical`
+**Tags:** @e2e, @registry
+
+**Preconditions:** Enabled Cloud fixture server and synthetic manager session.
+
+**Expected Result:** Registry navigation is visible with the established New badge.
+
+## Test Case: `REGISTRY-E2E-003` - Current-Authority Revocation
+
+**Priority:** `critical`
+**Tags:** @e2e, @registry
+
+**Preconditions:** The fixture revokes current authority after the browser receives a manager session.
+
+**Expected Result:** The next page request refreshes navigation from current authority; stale browser state cannot open `/registry` or mutate artifacts.
+
+## Test Case: `REGISTRY-E2E-004` - Write-Only Credential Validation
+
+**Priority:** `critical`
+**Tags:** @e2e, @registry
+
+**Flow:** Hold the credential validation task, submit the synthetic key, verify the disabled Connecting… control and key field, check for disclosure, then release the task.
+
+**Expected Result:** The `202` task stays pending until explicitly released. The task watcher then settles through an authoritative status read into the connected marketplace with a "Registry connected" toast. The key does not appear in DOM text, the page URL, request URLs, or browser storage values, including inside JSON or longer strings.
+
+## Test Case: `REGISTRY-E2E-012` - Provider Management Without Registry Management
+
+**Priority:** `critical`
+**Tags:** @e2e, @registry
+
+**Preconditions:** Enabled Cloud fixture server with an external provider artifact installed. The fixture then revokes `manage_registry` while retaining `manage_providers`.
+
+**Flow:** Verify that the Registry route redirects and its navigation entry is hidden. Open Providers, select the installed provider from the Registry tab, and submit its UID and alias.
+
+**Expected Result:** The provider manager can discover the installed provider and create an account. The wizard advances to its API token credential form, and the new account persists in Providers. Registry navigation remains hidden. This synthetic browser acceptance exercises UI permissions and the fixture HTTP contract; it does not replace authorization tests against the real API.
+
+## Test Case: `REGISTRY-E2E-005` - Complete Catalog, Recovery, and Lifecycle
+
+**Priority:** `critical`
+**Tags:** @e2e, @registry
+
+**Expected Result:** The All tab displays the complete paginated catalog and supports search, combined provider and capability filters, URL state, and owner logos with fallback. Built-ins display Built in without Add. Add follows the API's `is_installable` verdict: artifacts it refuses remain visible without Add and state the reason. An external provider artifact installs through a 202 task and an authoritative membership read before Added appears. Removal preserves provider accounts. Reconnect, unavailable, and generic failures have actionable empty states.
+
+## Test Case: `REGISTRY-E2E-006` - Pixel 5 Reduced-Motion Browsing
+
+**Priority:** `high`
+**Tags:** @e2e, @registry
+
+**Expected Result:** Pixel 5 browsing with the reduced-motion preference enabled remains usable, and the card Add action stays fully keyboard-operable with an authoritative confirmation toast.
+
+## Test Case: `REGISTRY-E2E-007` - Registry Provider Onboarding and First Scan
+
+**Priority:** `critical`
+**Tags:** @e2e, @registry
+
+**Preconditions:** Private Cloud fixture profile with billing disabled, `manage_registry`, `manage_providers`, and `manage_scans`.
+
+**Expected Result:** Installing the external provider makes Fixture Cloud available with a Registry badge in the Add Provider selector. All shows native and installed Registry providers; switching to Registry shows only installed providers. The wizard accepts UID/alias, renders masked API token credentials from the backend schema, saves the synthetic secret, requires explicit connection success, and launches a scan visible in Scans. No credential values are stored in localStorage or sessionStorage. This synthetic acceptance covers the UI/HTTP contract; real Registry and provider credentials are still required for live validation.
+
+After the scan, removing the artifact preserves the account in Providers and removes the dynamic type from the next Add Provider selector.
+
+## Test Case: `REGISTRY-E2E-008` - Installation Across Reload
+
+**Priority:** `critical`
+**Tags:** @e2e, @registry
+
+**Expected Result:** A pending installation survives a hard reload. After the controlled task is released, an authoritative membership read updates My artifacts and emits one success notification. The browser submits the installation only once.
+
+## Test Case: `REGISTRY-E2E-009` - Version Updates and Recovery
+
+**Priority:** `critical`
+**Tags:** @e2e, @registry
+
+**Flow:** Install 1.2.3, publish 1.3.0 in the controlled catalog, reject the first update, retry and reload while pending, then offer and install 1.2.3 from My artifacts.
+
+**Expected Result:** Cards show installed and available versions. Rejection preserves 1.2.3 and allows retry. Its notification shows a generic installation failure without exposing the backend diagnostic. The update submits the exact target, blocks duplicate actions, survives reload and emits one update notification after version confirmation. A lower catalog version also offers Update.
+
+## Test Case: `REGISTRY-E2E-011` - Disclosure Assertion Regression
+
+**Priority:** `critical`
+**Tags:** @e2e, @registry
+
+**Flow:** Embed a synthetic key in a request URL and in nested JSON values in localStorage and sessionStorage, checking each surface independently. Remove each injected storage entry afterward.
+
+**Expected Result:** The disclosure helper rejects every embedded key and passes once all injected values are removed.
+
+## Test Case: `REGISTRY-E2E-010` - Catalog Refresh
+
+**Priority:** `high`
+**Tags:** @e2e, @registry
+
+**Flow:** Publish a new artifact before each trigger: switching tabs, clicking Refresh, and dispatching window focus.
+
+**Expected Result:** All three publications appear without leaving Registry. Search input and URL filters remain intact; the API receives new catalog reads.
diff --git a/ui/tests/registry/registry.spec.ts b/ui/tests/registry/registry.spec.ts
new file mode 100644
index 0000000000..a99617ba9b
--- /dev/null
+++ b/ui/tests/registry/registry.spec.ts
@@ -0,0 +1,493 @@
+import { expect, test } from "@playwright/test";
+
+import {
+ controlledRegistryFixture,
+ FIXTURE_REGISTRY_KEY,
+} from "./controlled-registry-fixture";
+import { RegistryPage } from "./registry-page";
+
+const fixtureMode = process.env.E2E_REGISTRY_ACCEPTANCE_MODE === "fixture";
+const enabledProject = "registry";
+const flagOffProject = "registry-flag-off";
+const localProject = "registry-local";
+const mobileProject = "registry-mobile";
+
+function skipUnlessProject(projectName: string) {
+ test.skip(
+ test.info().project.name !== projectName,
+ `This scenario runs in the ${projectName} fixture profile.`,
+ );
+}
+
+test.describe.serial("Registry", () => {
+ test.setTimeout(60_000);
+ test.use({ storageState: "playwright/.auth/manage_registry_user.json" });
+
+ test.beforeEach(async ({ page }) => {
+ test.skip(
+ !fixtureMode,
+ "Registry browser acceptance is available only through the self-contained fixture profile.",
+ );
+ await controlledRegistryFixture.reset();
+ // Exercise the real media CSP without contacting the Registry service.
+ await page.route(
+ "https://media.registry.dev.prowler.com/fixture-owner.svg",
+ (route) =>
+ route.fulfill({
+ contentType: "image/svg+xml",
+ body: '',
+ }),
+ );
+ });
+
+ test(
+ "detects keys embedded in request URLs and browser storage JSON",
+ { tag: ["@critical", "@e2e", "@registry", "@REGISTRY-E2E-011"] },
+ async ({ page }) => {
+ skipUnlessProject(enabledProject);
+ const registryPage = new RegistryPage(page);
+ const key = "synthetic-registry-disclosure-check";
+ await registryPage.goto();
+ await registryPage.verifyOnboarding();
+
+ await expect(
+ registryPage.verifyKeyIsNotDisclosed(key, [
+ `https://registry.test/request?key=${key}&source=test`,
+ ]),
+ ).rejects.toThrow();
+
+ for (const storage of ["localStorage", "sessionStorage"] as const) {
+ await page.evaluate(
+ ({ storage, key }) => {
+ window[storage].setItem(
+ "disclosure-regression",
+ JSON.stringify({ nested: { key } }),
+ );
+ },
+ { storage, key },
+ );
+ try {
+ await expect(
+ registryPage.verifyKeyIsNotDisclosed(key, []),
+ ).rejects.toThrow();
+ } finally {
+ await page.evaluate((storage) => {
+ window[storage].removeItem("disclosure-regression");
+ }, storage);
+ }
+ }
+ await registryPage.verifyKeyIsNotDisclosed(key, []);
+ },
+ );
+
+ test(
+ "fails closed in Local and Registry-flag-off process profiles",
+ { tag: ["@critical", "@e2e", "@registry", "@REGISTRY-E2E-001"] },
+ async ({ page }) => {
+ test.skip(
+ ![flagOffProject, localProject].includes(test.info().project.name),
+ "This assertion requires the Local or Registry-flag-off fixture profile.",
+ );
+ const registryPage = new RegistryPage(page);
+
+ await page.goto("/");
+ await registryPage.verifyRegistryNavigationHidden();
+ await registryPage.goto();
+ await registryPage.verifyDirectRouteDenied();
+ await registryPage.verifyProviderSelectorWithoutRegistry();
+ },
+ );
+
+ test(
+ "shows the New Registry navigation entry only in the enabled manager profile",
+ { tag: ["@critical", "@e2e", "@registry", "@REGISTRY-E2E-002"] },
+ async ({ page }) => {
+ skipUnlessProject(enabledProject);
+ const registryPage = new RegistryPage(page);
+
+ await page.goto("/");
+ await registryPage.verifyRegistryNavigationVisible();
+ await expect(
+ registryPage.registryLink.getByText("New", { exact: true }),
+ ).toBeVisible();
+ },
+ );
+
+ test(
+ "denies stale manager storage after controlled current-authority revocation",
+ { tag: ["@critical", "@e2e", "@registry", "@REGISTRY-E2E-003"] },
+ async ({ page }) => {
+ skipUnlessProject(enabledProject);
+ const registryPage = new RegistryPage(page);
+
+ await page.goto("/");
+ await registryPage.verifyRegistryNavigationVisible();
+ await controlledRegistryFixture.revokeCurrentAuthority();
+ // No client-side lease machinery: revocation is enforced by the API and
+ // lands on the next server-rendered request.
+ await page.goto("/");
+ await registryPage.verifyRegistryNavigationHidden();
+ await registryPage.goto();
+ await registryPage.verifyDirectRouteDenied();
+ },
+ );
+
+ test(
+ "lets provider managers create an installed Registry provider account without Registry management access",
+ { tag: ["@critical", "@e2e", "@registry", "@REGISTRY-E2E-012"] },
+ async ({ page }) => {
+ skipUnlessProject(enabledProject);
+ const registry = new RegistryPage(page);
+ await registry.goto();
+ await registry.connectFixtureRegistry();
+ await registry.addLatest("Fixture network audit");
+
+ // The synthetic current-user response keeps manage_providers=true while
+ // revoking manage_registry. Live API authorization needs separate coverage.
+ await controlledRegistryFixture.revokeCurrentAuthority();
+ await registry.goto();
+ await registry.verifyDirectRouteDenied();
+ await registry.verifyRegistryNavigationHidden();
+
+ await page.goto("/providers");
+ await registry.dismissWelcomeDialog();
+ await registry.verifyRegistryNavigationHidden();
+ await page.getByRole("button", { name: /Add (a )?Provider/i }).click();
+ await page.getByRole("tab", { name: "Registry", exact: true }).click();
+ const provider = page.getByRole("option", {
+ name: "Fixture Cloud Registry",
+ });
+ await expect(provider).toBeVisible();
+ await provider.click();
+ await page
+ .getByLabel("Provider UID", { exact: true })
+ .fill("fixture-provider-manager-account");
+ await page
+ .getByLabel("Provider alias (optional)")
+ .fill("Provider manager Registry account");
+ await page.getByRole("button", { name: "Next", exact: true }).click();
+ await expect(
+ page.getByLabel("API token", { exact: false }),
+ ).toBeVisible();
+ expect(await controlledRegistryFixture.snapshot()).toMatchObject({
+ providerCreated: true,
+ secretSaved: false,
+ });
+
+ await page.goto("/providers");
+ await expect(
+ page.getByRole("row").filter({
+ hasText: "Provider manager Registry account",
+ }),
+ ).toBeVisible();
+ await registry.verifyRegistryNavigationHidden();
+ },
+ );
+
+ test(
+ "keeps an onboarding key write-only while 202 validation settles through an authoritative read",
+ { tag: ["@critical", "@e2e", "@registry", "@REGISTRY-E2E-004"] },
+ async ({ page }) => {
+ skipUnlessProject(enabledProject);
+ const registryPage = new RegistryPage(page);
+ const requestUrls: string[] = [];
+ page.on("request", (request) => requestUrls.push(request.url()));
+
+ await registryPage.goto();
+ await registryPage.verifyOnboarding();
+ await controlledRegistryFixture.holdCredentialTask(true);
+ await registryPage.submitRegistryKey(FIXTURE_REGISTRY_KEY);
+ // The form stays visible while the task watcher tracks validation: the
+ // submit control flips to a disabled Connecting… state.
+ await expect(
+ page.getByRole("button", { name: "Connecting…" }),
+ ).toBeDisabled();
+ await expect(page.getByLabel("Registry key")).toBeDisabled();
+ await registryPage.verifyKeyIsNotDisclosed(
+ FIXTURE_REGISTRY_KEY,
+ requestUrls,
+ );
+ await controlledRegistryFixture.holdCredentialTask(false);
+ await registryPage.verifyMarketplaceReady();
+ await expect(
+ page.getByText("Registry connected", { exact: true }),
+ ).toBeVisible();
+
+ const snapshot = await controlledRegistryFixture.snapshot();
+ expect(snapshot.credentialAccepted).toBe(true);
+ expect(snapshot.credentialReadCount).toBeGreaterThanOrEqual(2);
+ expect(snapshot.taskReadCount).toBeGreaterThanOrEqual(2);
+ },
+ );
+
+ test(
+ "uses complete catalog data for recovery, direct card Add, and confirmed Remove",
+ { tag: ["@critical", "@e2e", "@registry", "@REGISTRY-E2E-005"] },
+ async ({ page }) => {
+ skipUnlessProject(enabledProject);
+ await page.setViewportSize({ height: 900, width: 1440 });
+ const registryPage = new RegistryPage(page);
+
+ await registryPage.goto();
+ await registryPage.connectFixtureRegistry();
+ await registryPage.dismissWelcomeDialog();
+ await registryPage.verifyCompleteCatalogSearchAndFilters();
+ await registryPage.verifyOwnerRows();
+ await registryPage.captureEvidence("registry-catalog-desktop-dark");
+ await page.getByRole("switch", { name: "Switch to light mode" }).click();
+ await registryPage.captureEvidence("registry-catalog-desktop-light");
+ await page.setViewportSize({ width: 800, height: 1000 });
+ await registryPage.captureEvidence("registry-catalog-tablet-light");
+ await page.setViewportSize({ width: 1440, height: 900 });
+ await registryPage.verifyBuiltInArtifactHasNoAdd(
+ "Fixture built-in provider",
+ );
+ await expect(
+ registryPage.addButtonFor("Fixture shared policy"),
+ ).toBeHidden();
+ const artifactSnapshotBefore = await controlledRegistryFixture.snapshot();
+ await registryPage.addLatest("Fixture network audit");
+ const artifactSnapshotAfter = await controlledRegistryFixture.snapshot();
+ expect(artifactSnapshotAfter.artifactSubmissionCount).toBe(
+ artifactSnapshotBefore.artifactSubmissionCount + 1,
+ );
+ expect(artifactSnapshotAfter.artifactTaskReadCount).toBe(2);
+ expect(artifactSnapshotAfter.artifactReadCount).toBeGreaterThan(
+ artifactSnapshotBefore.artifactReadCount,
+ );
+ expect(
+ artifactSnapshotAfter.artifactEvents.slice(
+ artifactSnapshotBefore.artifactEvents.length,
+ ),
+ ).toEqual(["submission", "task-poll", "task-poll", "authoritative-read"]);
+ await registryPage.verifyAddedInMyArtifacts("Fixture network audit");
+ await registryPage.removeArtifact("Fixture network audit");
+ await page.reload();
+ await registryPage.verifyMarketplaceReady();
+ await controlledRegistryFixture.setDiscoveryMode("reconnect");
+ await page.reload();
+ await expect(
+ page.getByRole("heading", { name: "Reconnect Registry" }),
+ ).toBeVisible();
+ await controlledRegistryFixture.setDiscoveryMode("unavailable");
+ await page.reload();
+ await expect(
+ page.getByRole("heading", { name: "Registry is unavailable" }),
+ ).toBeVisible();
+ await controlledRegistryFixture.setDiscoveryMode("error");
+ await page.reload();
+ await expect(
+ page.getByRole("heading", { name: "Registry could not be loaded" }),
+ ).toBeVisible();
+ },
+ );
+
+ test(
+ "keeps keyboard and reduced-motion Registry browsing usable on Pixel 5",
+ { tag: ["@high", "@e2e", "@registry", "@REGISTRY-E2E-006"] },
+ async ({ page }) => {
+ skipUnlessProject(mobileProject);
+ const registryPage = new RegistryPage(page);
+ await page.emulateMedia({ reducedMotion: "reduce" });
+ expect(
+ await page.evaluate(
+ () => window.matchMedia("(prefers-reduced-motion: reduce)").matches,
+ ),
+ ).toBe(true);
+
+ await registryPage.goto();
+ await registryPage.connectFixtureRegistry();
+ await registryPage.dismissWelcomeDialog();
+ // With no detail panel, direct card actions are the keyboard path.
+ await registryPage.captureEvidence("registry-catalog-mobile-dark");
+ const addButton = registryPage.addButtonFor("Fixture network audit");
+ await addButton.focus();
+ await addButton.press("Enter");
+ await expect(
+ page.getByText("Artifact added", { exact: true }),
+ ).toBeVisible();
+ },
+ );
+ test(
+ "installs an external provider and completes the existing account, credentials, connection and scan wizard",
+ { tag: ["@critical", "@e2e", "@registry", "@REGISTRY-E2E-007"] },
+ async ({ page }) => {
+ skipUnlessProject(enabledProject);
+ const registry = new RegistryPage(page);
+ await registry.goto();
+ await registry.connectFixtureRegistry();
+ await registry.addLatest("Fixture network audit");
+ await registry.connectInstalledProviderAndScan();
+ const snapshot = await controlledRegistryFixture.snapshot();
+ expect(snapshot).toMatchObject({
+ providerCreated: true,
+ secretSaved: true,
+ connected: true,
+ scanCreated: true,
+ });
+ const browserStorage = await page.evaluate(() =>
+ JSON.stringify({
+ local: { ...localStorage },
+ session: { ...sessionStorage },
+ }),
+ );
+ expect(browserStorage).not.toContain(
+ "fixture-provider-token-not-a-secret",
+ );
+ await registry.goto();
+ await registry.removeArtifact("Fixture network audit");
+ await page.goto("/providers");
+ await expect(
+ page.getByRole("row").filter({ hasText: "Registry test account" }),
+ ).toBeVisible();
+ const beforeOpen = await controlledRegistryFixture.snapshot();
+ await page.getByRole("button", { name: /Add (a )?Provider/i }).click();
+ await expect
+ .poll(
+ async () =>
+ (await controlledRegistryFixture.snapshot()).artifactReadCount,
+ )
+ .toBeGreaterThan(beforeOpen.artifactReadCount);
+ await expect(
+ page.getByRole("option", { name: "Fixture Cloud Registry" }),
+ ).toBeHidden();
+ },
+ );
+ test(
+ "updates and downgrades the installed version, including failure and reload recovery",
+ { tag: ["@critical", "@e2e", "@registry", "@REGISTRY-E2E-009"] },
+ async ({ page }) => {
+ skipUnlessProject(enabledProject);
+ test.setTimeout(90_000);
+ const registry = new RegistryPage(page);
+ const name = "Fixture network audit";
+ await registry.goto();
+ await registry.connectFixtureRegistry();
+ await registry.addLatest(name);
+ await controlledRegistryFixture.publishVersion("1.3.0");
+ await page.reload();
+ await expect(registry.updateButtonFor(name, "1.3.0")).toBeVisible();
+ await expect(registry.artifactCardFor(name)).toContainText("1.2.3");
+ await controlledRegistryFixture.setArtifactTaskError(
+ "This version has been withdrawn.",
+ );
+ await registry.updateButtonFor(name, "1.3.0").click();
+ await expect(
+ page.getByText("Artifact could not be updated", { exact: true }),
+ ).toBeVisible();
+ await expect(page.locator("body")).toContainText(
+ "The artifact could not be installed.",
+ );
+ await expect(page.locator("body")).not.toContainText(
+ "This version has been withdrawn.",
+ );
+ await expect(registry.updateButtonFor(name, "1.3.0")).toBeEnabled();
+ expect(
+ (await controlledRegistryFixture.snapshot()).installedVersion,
+ ).toBe("1.2.3");
+ await controlledRegistryFixture.setArtifactTaskError(null);
+ await controlledRegistryFixture.holdArtifactTask(true);
+ await registry.updateButtonFor(name, "1.3.0").click();
+ await expect(registry.removeButtonFor(name)).toBeDisabled();
+ await expect
+ .poll(() => page.evaluate(() => localStorage.getItem("task-watcher")))
+ .toContain('"expectedVersion":"1.3.0"');
+ await page.reload();
+ await registry.verifyMarketplaceReady();
+ await expect(registry.updateButtonFor(name, "1.3.0")).toBeDisabled();
+ await controlledRegistryFixture.holdArtifactTask(false);
+ await expect(
+ page.getByText("Artifact updated", { exact: true }),
+ ).toHaveCount(1);
+ await expect(
+ registry.artifactCardFor(name).getByText("Added", { exact: true }),
+ ).toBeVisible();
+ const upgraded = await controlledRegistryFixture.snapshot();
+ expect(upgraded.installedVersion).toBe("1.3.0");
+ expect(upgraded.artifactTaskVersionSpec).toBe("1.3.0");
+ expect(upgraded.artifactSubmissionCount).toBe(3);
+ await controlledRegistryFixture.publishVersion("1.2.3");
+ await page.reload();
+ await registry.myArtifactsTab.click();
+ await registry.updateButtonFor(name, "1.2.3").click();
+ await expect(
+ registry.artifactCardFor(name).getByText("Added", { exact: true }),
+ ).toBeVisible();
+ expect(
+ (await controlledRegistryFixture.snapshot()).installedVersion,
+ ).toBe("1.2.3");
+ await registry.captureEvidence("registry-version-updated");
+ },
+ );
+
+ test(
+ "refreshes publications on tab changes, manually and when returning focus",
+ { tag: ["@high", "@e2e", "@registry", "@REGISTRY-E2E-010"] },
+ async ({ page }) => {
+ skipUnlessProject(enabledProject);
+ const registry = new RegistryPage(page);
+ await registry.goto();
+ await registry.connectFixtureRegistry();
+ await registry.searchInput.fill("Fixture");
+ await controlledRegistryFixture.publishArtifact(
+ "Fixture tab publication",
+ );
+ await registry.myArtifactsTab.click();
+ await registry.exploreTab.click();
+ await expect(
+ registry.artifactCardFor("Fixture tab publication"),
+ ).toBeVisible();
+ await controlledRegistryFixture.publishArtifact(
+ "Fixture manual publication",
+ );
+ await registry.refreshButton.click();
+ await expect(
+ registry.artifactCardFor("Fixture manual publication"),
+ ).toBeVisible();
+ const beforeFocus = await controlledRegistryFixture.snapshot();
+ await controlledRegistryFixture.publishArtifact(
+ "Fixture focus publication",
+ );
+ // Synthetic focus dispatch also works in headless browsers, which do not
+ // reliably dispatch window focus when bringing an OS window to the front.
+ await page.evaluate(() => window.dispatchEvent(new Event("focus")));
+ await expect(
+ registry.artifactCardFor("Fixture focus publication"),
+ ).toBeVisible();
+ await registry.verifySearchPreserved("Fixture");
+ expect(
+ (await controlledRegistryFixture.snapshot()).catalogReadCount,
+ ).toBeGreaterThan(beforeFocus.catalogReadCount);
+ await registry.captureEvidence("registry-refreshed-publications");
+ },
+ );
+
+ test(
+ "resumes an installation after reload with one confirmation notification",
+ { tag: ["@critical", "@e2e", "@registry", "@REGISTRY-E2E-008"] },
+ async ({ page }) => {
+ skipUnlessProject(enabledProject);
+ const registry = new RegistryPage(page);
+ await registry.goto();
+ await registry.connectFixtureRegistry();
+ await controlledRegistryFixture.holdArtifactTask(true);
+ await registry.addButtonFor("Fixture network audit").click();
+ await expect
+ .poll(() => page.evaluate(() => localStorage.getItem("task-watcher")))
+ .toContain('"kind":"registry-artifact-add"');
+ await page.reload();
+ await registry.verifyMarketplaceReady();
+ await expect(
+ page.getByText("Artifact added", { exact: true }),
+ ).toBeHidden();
+ await controlledRegistryFixture.holdArtifactTask(false);
+ await expect(
+ page.getByText("Artifact added", { exact: true }),
+ ).toHaveCount(1);
+ await registry.verifyAddedInMyArtifacts("Fixture network audit");
+ expect(
+ (await controlledRegistryFixture.snapshot()).artifactSubmissionCount,
+ ).toBe(1);
+ },
+ );
+});
diff --git a/ui/tests/registry/validation.md b/ui/tests/registry/validation.md
new file mode 100644
index 0000000000..e0e9835e22
--- /dev/null
+++ b/ui/tests/registry/validation.md
@@ -0,0 +1,69 @@
+# Registry UI Validation
+
+The consolidated implementation provides Registry installation → Providers → account details → schema-driven credentials → explicit connection confirmation → scan launch. Registry management requires `manage_registry` and its feature flags, independently of billing. Discovering already-installed Registry providers and creating their accounts requires `manage_providers`, without `manage_registry`. Scans retain their existing permissions. The Cloud API allows `GET registry/providers` with `manage_providers`; listing `registry/available-artifacts` and `registry/artifacts` accepts either `manage_registry` or `manage_providers`. Registry credential operations and artifact mutations still require `manage_registry`.
+
+## Automated Evidence
+
+Validation after review fixes on September 9, 2026:
+
+| Check | Result |
+| ------------------------------- | ---------------------------------------------------------------------------------------- |
+| `pnpm run test:unit` | 475 files, 3,511 tests passed |
+| `pnpm run test:integration` | 11 files, 253 browser integration tests passed |
+| `pnpm run test:e2e:registry` | 10 passed; 23 excluded because each scenario runs only in its designated runtime profile |
+| `pnpm run typecheck` | Passed |
+| `pnpm run lint:check` | Passed; excluded the unrelated `.claude/` checkout |
+| Prettier check on changed files | Passed |
+| `pnpm run build` | Passed |
+| `pnpm run tour:check` | Six tours and 19 anchors checked |
+
+The Registry browser suite includes regressions for replacing a rejected key, disabling every Add button during an installation, refreshing an expired owner image, and confirming credential state and collections once. Unit tests cover late completion after the dialog deadline and a single shared failure result when catalog refresh fails. The three removed browser test files covered only the global animation changes withdrawn from this PR.
+
+The test scope review removed 29 net cases and 614 lines across nine test files. Browser tests for pixel spacing, icon classes, avatar internals, and repeated provider layouts were removed or consolidated. Duplicate model, credential-completion, static-label, and environment-parser cases were also pruned. Assertions for pending controls, write-only keys, accessible provider names, single confirmation, and non-persisted eligibility remain in broader behavioral tests. Both first-key and replacement validation now explicitly cover an unsettled task. Permission boundaries, schema validation, recovery, and the controlled E2E scenarios remain covered.
+
+The controlled E2E uses real Next.js servers, authentication, proxy, Server Actions, task polling, and browser storage against a synthetic HTTP API. It covers Registry enabled with billing disabled, Registry disabled, Cloud disabled, permission revocation, installation and removal, account preservation, the provider wizard, a completed scan, and recovery after a hard reload. It does not validate real Registry or provider services.
+
+The reload regression aborts a pending Server Action request. The shared watcher preserves the backend task ID on page hide, resumes it after reload or browser history restoration, and emits one confirmation after reading tenant membership. Pending metadata contains artifact identifiers or the prior-credential flag, never submitted keys.
+
+See [the scenario catalog](registry.md) and [the Add Provider tour report](add-provider-tour-report.md).
+
+## Registry Environment Configuration
+
+Set these runtime variables on the UI service to match the Registry used by the backend:
+
+| Variable | Purpose | Development Example |
+| ----------------------- | ---------------------------------------------------------------------------------------------------------- | ---------------------------------------- |
+| `UI_REGISTRY_URL` | Public Registry website or key-management page. Supplies the help link and permits images from its origin. | `https://registry.dev.prowler.com` |
+| `UI_REGISTRY_MEDIA_URL` | Registry media service. Only its HTTP(S) origin is added to `img-src`. | `https://media.registry.dev.prowler.com` |
+
+For production, use `https://registry.prowler.com` and `https://media.registry.prowler.com`. For a private Registry, use its website and media service URLs. These settings do not change the backend's Registry API endpoint. Keep both services aligned in deployment configuration: the current backend contract does not expose its Registry website URL to the UI.
+
+The help link is hidden when its URL is missing or invalid, so the UI cannot send a private Registry user to production by default. URLs containing credentials, non-HTTP schemes, or CSP separators are rejected. Unconfigured external images fall back to the owner initial.
+
+Acceptance profiles and fixture servers live in `playwright.registry.config.ts`, with common defaults in `playwright.base.ts`. The existing `pnpm run test:e2e:registry` command selects that configuration. The general Playwright configuration runs the ordinary suites without Registry fixtures.
+
+Credential validation has one completion path shared by active execution and reload recovery. After task settlement, it reads credential status once and collections once; the returned result drives both dialog state and notification. A deadline releases the form without reporting success; the same completion continues in the background.
+
+## Screenshots
+
+These captures contain synthetic fixture data. The credential form is empty in its screenshot.
+
+| View | Evidence |
+| -------------- | ----------------------------------------------------------------------- |
+| Desktop, dark | [Catalog](evidence/registry-catalog-desktop-dark.png) |
+| Desktop, light | [Catalog](evidence/registry-catalog-desktop-light.png) |
+| Tablet, light | [Catalog](evidence/registry-catalog-tablet-light.png) |
+| Mobile, dark | [Catalog](evidence/registry-catalog-mobile-dark.png) |
+| Add Provider | [Registry option](evidence/registry-provider-selector.png) |
+| Credentials | [Schema form](evidence/registry-provider-credentials.png) |
+| Scans | [Completed fixture scan](evidence/registry-provider-scan-completed.png) |
+
+Run `pnpm run test:e2e:registry` to regenerate screenshot attachments in the Playwright output directory.
+
+## Live Integration Status
+
+Live acceptance is incomplete. The isolated backend runs `chain/14-dynamic-provider-scans` with Registry mode `official`, development Registry API/index/media URLs, and billing disabled. The local UI uses `UI_CLOUD_ENABLED=true`, `UI_REGISTRY_ENABLED=true`, `CLOUD_BILLING_ENABLED=false`, and the local API. Distribution defaults remain disabled.
+
+The native macOS Celery worker repeatedly exited with `SIGSEGV` during key validation. Restarting this development worker with `--pool=solo --concurrency=1` allowed the queued validation to finish. The development Registry then rejected the submitted key with HTTP 401. No backend source changes were made.
+
+A valid key for the development Registry and credentials for an external test provider must be entered through the local UI to complete the real catalog, install, account, credential, connection, and scan checks. The PR remains a draft pending that evidence; automated fixture results do not satisfy live acceptance.
diff --git a/ui/tests/runtime-config/runtime-config-page.ts b/ui/tests/runtime-config/runtime-config-page.ts
index 35d66672c3..e2a651edec 100644
--- a/ui/tests/runtime-config/runtime-config-page.ts
+++ b/ui/tests/runtime-config/runtime-config-page.ts
@@ -23,6 +23,7 @@ export const RUNTIME_CONFIG_KEYS = [
"reoDevClientId",
"cloudBillingEnabled",
"cloudEnabled",
+ "selfRegistrationEnabled",
"stripePublishableKey",
"stripePublishableKeyV2",
] as const satisfies ReadonlyArray;
diff --git a/ui/tests/setups/manage-registry.auth.setup.ts b/ui/tests/setups/manage-registry.auth.setup.ts
new file mode 100644
index 0000000000..bf3daac561
--- /dev/null
+++ b/ui/tests/setups/manage-registry.auth.setup.ts
@@ -0,0 +1,30 @@
+import { test as authManageRegistrySetup } from "@playwright/test";
+
+import { RegistryPage } from "../registry/registry-page";
+import { SignInPage } from "../sign-in-base/sign-in-base-page";
+
+const manageRegistryUserFile = "playwright/.auth/manage_registry_user.json";
+const fixtureMode = process.env.E2E_REGISTRY_ACCEPTANCE_MODE === "fixture";
+
+const fixtureCredentials = {
+ email: "registry-fixture-user@example.test",
+ password: "fixture-password-not-a-secret",
+};
+
+authManageRegistrySetup(
+ "authenticate as Registry manager fixture user",
+ async ({ page }) => {
+ authManageRegistrySetup.skip(
+ !fixtureMode,
+ "Registry manager authentication is available only in self-contained fixture mode.",
+ );
+
+ const signInPage = new SignInPage(page);
+ await signInPage.goto();
+ await signInPage.login(fixtureCredentials);
+ await page.waitForURL("/");
+ await new RegistryPage(page).dismissWelcomeDialog();
+ await signInPage.verifySuccessfulLogin();
+ await page.context().storageState({ path: manageRegistryUserFile });
+ },
+);
diff --git a/ui/types/compliance.ts b/ui/types/compliance.ts
index be7462f549..e7f27a8e09 100644
--- a/ui/types/compliance.ts
+++ b/ui/types/compliance.ts
@@ -474,6 +474,20 @@ export interface CMMCAttributesMetadata {
SourceRequirement: string;
}
+// FedRAMP 20x KSI (`prowler/compliance/fedramp_20x_ksi_2026.json`), grouped by Theme.
+export interface FedRAMP20xKSIAttributesMetadata {
+ Theme: string;
+ NISTControls?: string | null;
+ ClassApplicability: string;
+}
+
+// FedRAMP 20x Class C FRR (`prowler/compliance/fedramp_20x_frr_class_c_2026.json`), grouped by Ruleset.
+export interface FedRAMP20xFRRAttributesMetadata {
+ Ruleset: string;
+ Subset: string;
+ Force: string;
+}
+
export interface AttributesItemData {
type: "compliance-requirements-attributes";
id: string;
@@ -501,6 +515,8 @@ export interface AttributesItemData {
| CISControlsAttributesMetadata[]
| CyberEssentialsAttributesMetadata[]
| CMMCAttributesMetadata[]
+ | FedRAMP20xKSIAttributesMetadata[]
+ | FedRAMP20xFRRAttributesMetadata[]
| GenericAttributesMetadata[];
check_ids: string[];
// MITRE structure
diff --git a/ui/types/dynamic-provider-form.test.ts b/ui/types/dynamic-provider-form.test.ts
new file mode 100644
index 0000000000..889df0846f
--- /dev/null
+++ b/ui/types/dynamic-provider-form.test.ts
@@ -0,0 +1,28 @@
+import { describe, expect, it } from "vitest";
+
+import { createAddProviderFormSchema } from "./formSchemas";
+
+describe("provider account validation", () => {
+ it("accepts installed dynamic types only and requires a UID", () => {
+ const schema = createAddProviderFormSchema(["acme", "aws"]);
+ const input = {
+ providerType: "acme",
+ providerUid: " account ",
+ providerAlias: "Test",
+ };
+ expect(schema.parse(input).providerUid).toBe("account");
+ expect(
+ schema.safeParse({ ...input, providerType: "unknown" }).success,
+ ).toBe(false);
+ expect(schema.safeParse({ ...input, providerUid: " " }).success).toBe(
+ false,
+ );
+ expect(
+ schema.safeParse({ ...input, providerType: "aws", providerUid: "short" })
+ .success,
+ ).toBe(false);
+ expect(createAddProviderFormSchema([]).safeParse(input).success).toBe(
+ false,
+ );
+ });
+});
diff --git a/ui/types/env.d.ts b/ui/types/env.d.ts
index 6815cb962d..1f0315284c 100644
--- a/ui/types/env.d.ts
+++ b/ui/types/env.d.ts
@@ -30,6 +30,7 @@ declare global {
// Prowler Cloud deployment flag — runtime read (server env, client island).
UI_CLOUD_ENABLED?: "true" | "false";
+ UI_REGISTRY_ENABLED?: "true" | "false";
CLOUD_BILLING_ENABLED?: "legacy" | "metronome" | "false";
@@ -98,6 +99,7 @@ declare global {
E2E_UNLIMITED_VISIBILITY_PASSWORD?: string;
E2E_MANAGE_INTEGRATIONS_USER?: string;
E2E_MANAGE_INTEGRATIONS_PASSWORD?: string;
+ E2E_REGISTRY_ACCEPTANCE_MODE?: "fixture";
E2E_MANAGE_ACCOUNT_USER?: string;
E2E_MANAGE_ACCOUNT_PASSWORD?: string;
E2E_MANAGE_SCANS_USER?: string;
diff --git a/ui/types/formSchemas.test.ts b/ui/types/formSchemas.test.ts
index c482901d53..ddb5f4e301 100644
--- a/ui/types/formSchemas.test.ts
+++ b/ui/types/formSchemas.test.ts
@@ -8,6 +8,7 @@ import {
addCredentialsRoleFormSchema,
addProviderFormSchema,
KUBECONFIG_UNSUPPORTED_COMMAND_AUTHENTICATION_ERROR,
+ roleFormSchema,
samlConfigFormSchema,
} from "./formSchemas";
@@ -20,6 +21,30 @@ const BASE_AWS_ROLE_VALUES = {
[ProviderCredentialFields.CREDENTIALS_TYPE]: "access-secret-key",
} as const;
+describe("roleFormSchema", () => {
+ it("defaults manage_registry to false", () => {
+ // Given / When
+ const result = roleFormSchema.parse({ name: "Registry manager" });
+
+ // Then
+ expect(result.manage_registry).toBe(false);
+ });
+
+ it.each(["true", 1, null])(
+ "rejects malformed manage_registry values of %j",
+ (manageRegistry) => {
+ // Given / When
+ const result = roleFormSchema.safeParse({
+ name: "Registry manager",
+ manage_registry: manageRegistry,
+ });
+
+ // Then
+ expect(result.success).toBe(false);
+ },
+ );
+});
+
describe("addCredentialsRoleFormSchema", () => {
it("accepts AWS role credentials when access and secret keys are present", () => {
const schema = addCredentialsRoleFormSchema("aws");
diff --git a/ui/types/formSchemas.ts b/ui/types/formSchemas.ts
index dfdf406d16..d34a1c84ef 100644
--- a/ui/types/formSchemas.ts
+++ b/ui/types/formSchemas.ts
@@ -5,7 +5,7 @@ import { ProviderCredentialFields } from "@/lib/provider-credentials/provider-cr
import { validateMutelistYaml, validateYaml } from "@/lib/yaml";
import { MAX_SAML_ADDITIONAL_EMAIL_DOMAINS } from "@/types/saml";
-import { PROVIDER_TYPES, ProviderType } from "./providers";
+import { isKnownProviderType, PROVIDER_TYPES, ProviderType } from "./providers";
export const KUBECONFIG_UNSUPPORTED_COMMAND_AUTHENTICATION_ERROR =
"Kubernetes kubeconfig command-based authentication is not supported in Prowler Cloud for security reasons.";
@@ -56,6 +56,7 @@ export const roleFormSchema = z.object({
manage_scans: z.boolean().default(false),
manage_alerts: z.boolean().default(false),
manage_lighthouse_ai_configuration: z.boolean().default(false),
+ manage_registry: z.boolean().default(false),
unlimited_visibility: z.boolean().default(false),
groups: z.array(z.string()).optional(),
});
@@ -192,6 +193,27 @@ export const addProviderFormSchema = z
]),
);
+export const createAddProviderFormSchema = (
+ installedTypes: readonly string[],
+) =>
+ z.union([
+ addProviderFormSchema,
+ z.object({
+ providerType: z
+ .string()
+ .refine(
+ (type) => !isKnownProviderType(type) && installedTypes.includes(type),
+ "Select an installed Registry provider",
+ ),
+ providerUid: z.string().trim().min(1, "Provider UID is required"),
+ providerAlias: z.string(),
+ }),
+ ]);
+
+export type AddProviderFormValues = z.infer<
+ ReturnType
+>;
+
export const addCredentialsFormSchema = (
providerType: ProviderType,
via?: string | null,
diff --git a/ui/types/onboarding-invite.ts b/ui/types/onboarding-invite.ts
new file mode 100644
index 0000000000..e1bcdec4d6
--- /dev/null
+++ b/ui/types/onboarding-invite.ts
@@ -0,0 +1,11 @@
+// Query param the API can read to tell where an invitation was sent from.
+export const INVITATION_SOURCE_PARAM = "source";
+
+export const INVITATION_SOURCE = {
+ ONBOARDING: "onboarding",
+} as const;
+
+export interface InvitationRoleOption {
+ id: string;
+ name: string;
+}
diff --git a/ui/types/provider-schema.ts b/ui/types/provider-schema.ts
new file mode 100644
index 0000000000..db50e86d49
--- /dev/null
+++ b/ui/types/provider-schema.ts
@@ -0,0 +1,33 @@
+export const PROVIDER_SCHEMA_STATUS = {
+ SUCCESS: "success",
+ NOT_FOUND: "not_found",
+ UNAVAILABLE: "unavailable",
+ ACCESS_DENIED: "access_denied",
+ ERROR: "error",
+ MALFORMED: "malformed",
+} as const;
+
+export interface ProviderSchemaObject {
+ readonly [keyword: string]: unknown;
+}
+
+export interface ProviderSecretTypes {
+ readonly [secretType: string]: ProviderSchemaObject;
+}
+
+export interface ProviderSchemasSuccessResult {
+ status: typeof PROVIDER_SCHEMA_STATUS.SUCCESS;
+ providerType: string;
+ secretTypes: ProviderSecretTypes;
+}
+
+export interface ProviderSchemasFailureResult {
+ status: Exclude<
+ (typeof PROVIDER_SCHEMA_STATUS)[keyof typeof PROVIDER_SCHEMA_STATUS],
+ typeof PROVIDER_SCHEMA_STATUS.SUCCESS
+ >;
+}
+
+export type ProviderSchemasResult =
+ | ProviderSchemasSuccessResult
+ | ProviderSchemasFailureResult;
diff --git a/ui/types/registry.ts b/ui/types/registry.ts
new file mode 100644
index 0000000000..954b794b81
--- /dev/null
+++ b/ui/types/registry.ts
@@ -0,0 +1,303 @@
+export const REGISTRY_ENDPOINT = {
+ PROVIDERS: "providers",
+ AVAILABLE_ARTIFACTS: "available_artifacts",
+ CREDENTIAL: "credential",
+ MUTATION: "mutation",
+} as const;
+
+export type RegistryEndpoint =
+ (typeof REGISTRY_ENDPOINT)[keyof typeof REGISTRY_ENDPOINT];
+
+export const REGISTRY_FAILURE = {
+ ACCESS_DENIED: "access_denied",
+ ONBOARDING: "onboarding",
+ RECONNECT: "reconnect",
+ UNAVAILABLE: "unavailable",
+ ERROR: "error",
+} as const;
+
+export type RegistryFailureResult =
+ | { status: typeof REGISTRY_FAILURE.ACCESS_DENIED }
+ | { status: typeof REGISTRY_FAILURE.ONBOARDING }
+ | { status: typeof REGISTRY_FAILURE.RECONNECT }
+ | { status: typeof REGISTRY_FAILURE.UNAVAILABLE }
+ | { status: typeof REGISTRY_FAILURE.ERROR };
+
+export const REGISTRY_SUBMISSION = {
+ PENDING: "pending",
+ ERROR: "error",
+} as const;
+
+export type RegistryTaskSubmissionResult =
+ | {
+ status: typeof REGISTRY_SUBMISSION.PENDING;
+ taskId: string;
+ }
+ | { status: typeof REGISTRY_SUBMISSION.ERROR };
+
+export type RegistryCredentialSubmissionResult = RegistryTaskSubmissionResult;
+
+export interface RegistryArtifactTaskResult {
+ installed: boolean;
+ error: string | null;
+}
+
+export interface RegistryAddArtifactInput {
+ normalizedName: string;
+ versionSpec?: string;
+}
+
+export const REGISTRY_INSTALL_OPERATION = {
+ ADD: "add",
+ UPDATE: "update",
+} as const;
+
+export type RegistryInstallOperation =
+ (typeof REGISTRY_INSTALL_OPERATION)[keyof typeof REGISTRY_INSTALL_OPERATION];
+
+export type RegistryArtifactExecutionInput =
+ | (RegistryAddArtifactInput & {
+ operation?: typeof REGISTRY_INSTALL_OPERATION.ADD;
+ })
+ | {
+ normalizedName: string;
+ versionSpec: string;
+ operation: typeof REGISTRY_INSTALL_OPERATION.UPDATE;
+ };
+
+export const REGISTRY_ARTIFACT_ACTION = {
+ SUBMITTED: "submitted",
+} as const;
+
+export type RegistryArtifactSubmitResult = {
+ status: typeof REGISTRY_ARTIFACT_ACTION.SUBMITTED;
+ taskId: string;
+};
+
+export interface RegistryCredentialStatus {
+ configured: boolean;
+ isValid: boolean;
+ scopes: string[];
+ lastValidatedAt?: string;
+ validationStatus?: string;
+ validationPending: boolean;
+}
+
+export const REGISTRY_CATALOG = {
+ COMPLETE: "complete",
+ INCOMPLETE: "incomplete",
+} as const;
+export const REGISTRY_CATALOG_INCOMPLETE_REASON = {
+ CONFLICTING_DUPLICATE: "conflicting_duplicate",
+ COUNT_MISMATCH: "count_mismatch",
+ GUARD_EXHAUSTED: "guard_exhausted",
+ INVALID_PAGE: "invalid_page",
+ INVALID_RESOURCE: "invalid_resource",
+ PAGE_FAILED: "page_failed",
+} as const;
+export type RegistryCatalogIncompleteReason =
+ (typeof REGISTRY_CATALOG_INCOMPLETE_REASON)[keyof typeof REGISTRY_CATALOG_INCOMPLETE_REASON];
+
+export interface RegistryArtifactOwner {
+ name: string;
+ type: string;
+ logoUrl?: string;
+}
+
+export interface RegistryCatalogArtifact {
+ normalizedName: string;
+ name?: string;
+ description?: string;
+ latestVersion?: string;
+ providers: string[];
+ /** Provider declared by a provider artifact; distinct from its target filters. */
+ providerSlug?: string;
+ isVerified: boolean;
+ isOfficial: boolean;
+ isBuiltin: boolean;
+ isMeta: boolean;
+ hasProvider: boolean;
+ hasChecks: boolean;
+ hasCompliance: boolean;
+ /** The API's verdict for this deployment; never recomputed client-side. */
+ isInstallable: boolean;
+ /** Stable API code; new codes can appear without a UI release. */
+ notInstallableReason?: string;
+ checkCount?: number;
+ complianceCount?: number;
+ versionCount: number;
+ totalDownloads: number;
+ owners: RegistryArtifactOwner[];
+}
+
+export interface RegistryTenantArtifact {
+ normalizedName: string;
+ versionSpec: string;
+ resolvedVersion?: string;
+ /** Built-in providers this install adds checks to without defining them. */
+ extendsProviderSlugs?: string[];
+ insertedAt?: string;
+ updatedAt?: string;
+}
+
+export type RegistryCatalogResult =
+ | {
+ status: typeof REGISTRY_CATALOG.COMPLETE;
+ artifacts: RegistryCatalogArtifact[];
+ }
+ | {
+ status: typeof REGISTRY_CATALOG.INCOMPLETE;
+ reason: RegistryCatalogIncompleteReason;
+ collectedCount: number;
+ };
+
+export const REGISTRY_CREDENTIAL_READ = {
+ STATUS: "status",
+} as const;
+
+export type RegistryCredentialReadResult =
+ | {
+ status: typeof REGISTRY_CREDENTIAL_READ.STATUS;
+ credential: RegistryCredentialStatus;
+ }
+ | RegistryFailureResult;
+
+export const REGISTRY_CREDENTIAL_ACTION = {
+ CONNECTED: "connected",
+ DISCONNECTED: "disconnected",
+ INVALID: "invalid",
+ PENDING: "pending",
+ REPLACEMENT_FAILED: "replacement_failed",
+ SUBMITTED: "submitted",
+} as const;
+
+export type RegistryCredentialSubmitResult =
+ | {
+ status: typeof REGISTRY_CREDENTIAL_ACTION.SUBMITTED;
+ taskId: string;
+ priorConfigured: boolean;
+ }
+ | {
+ status: typeof REGISTRY_CREDENTIAL_ACTION.REPLACEMENT_FAILED;
+ credential: RegistryCredentialStatus;
+ }
+ | RegistryFailureResult;
+
+export type RegistryCredentialActionResult =
+ | {
+ status: typeof REGISTRY_CREDENTIAL_ACTION.CONNECTED;
+ credential: RegistryCredentialStatus;
+ }
+ | {
+ status: typeof REGISTRY_CREDENTIAL_ACTION.DISCONNECTED;
+ credential: RegistryCredentialStatus;
+ tenantArtifacts: RegistryTenantArtifact[];
+ }
+ | {
+ status:
+ | typeof REGISTRY_CREDENTIAL_ACTION.INVALID
+ | typeof REGISTRY_CREDENTIAL_ACTION.PENDING
+ | typeof REGISTRY_CREDENTIAL_ACTION.REPLACEMENT_FAILED;
+ credential: RegistryCredentialStatus;
+ }
+ | RegistryFailureResult;
+
+type RegistryCompleteCatalog = Extract<
+ RegistryCatalogResult,
+ { status: typeof REGISTRY_CATALOG.COMPLETE }
+>;
+type RegistryIncompleteCatalog = Exclude<
+ RegistryCatalogResult,
+ RegistryCompleteCatalog
+>;
+
+export type RegistryCollectionsResult =
+ | {
+ status: typeof REGISTRY_CATALOG.COMPLETE;
+ catalog: RegistryCompleteCatalog;
+ tenantArtifacts: RegistryTenantArtifact[];
+ }
+ | RegistryIncompleteCatalog
+ | RegistryFailureResult;
+
+export const REGISTRY_MUTATION = {
+ CONFIRMED: "confirmed",
+ REFRESH_FAILED: "refresh_failed",
+ REFUSED: "refused",
+} as const;
+
+export type RegistryMutationResult =
+ | {
+ status: typeof REGISTRY_MUTATION.CONFIRMED;
+ tenantArtifacts: RegistryTenantArtifact[];
+ }
+ | {
+ status: typeof REGISTRY_MUTATION.REFRESH_FAILED;
+ }
+ | {
+ status: typeof REGISTRY_MUTATION.REFUSED;
+ message: string;
+ }
+ | RegistryArtifactSubmitResult
+ | RegistryFailureResult;
+
+export const REGISTRY_ARTIFACT_REMOVAL = {
+ /** Providers stand on it: they must be deleted first. */
+ IN_USE: "in_use",
+ /** A scan is running its checks: it clears by itself. */
+ BUSY: "busy",
+} as const;
+
+export type RegistryArtifactRemovalConflict =
+ | { status: typeof REGISTRY_ARTIFACT_REMOVAL.IN_USE }
+ | { status: typeof REGISTRY_ARTIFACT_REMOVAL.BUSY };
+
+export type RegistryArtifactRemovalResult =
+ | RegistryMutationResult
+ | RegistryArtifactRemovalConflict;
+
+export type RegistryRemoveDialogError =
+ | RegistryArtifactRemovalConflict
+ | { status: typeof REGISTRY_FAILURE.ERROR; message: string };
+
+export const REGISTRY_BOOTSTRAP_STATE = {
+ ONBOARDING: "onboarding",
+ VALIDATION_PENDING: "validation_pending",
+ READY: "ready",
+ RECONNECT: "reconnect",
+ UNAVAILABLE: "unavailable",
+ INCOMPLETE: "incomplete",
+ ERROR: "error",
+} as const;
+
+export type RegistryBootstrapState =
+ | {
+ status:
+ | typeof REGISTRY_BOOTSTRAP_STATE.ONBOARDING
+ | typeof REGISTRY_BOOTSTRAP_STATE.VALIDATION_PENDING;
+ credential: RegistryCredentialStatus;
+ tenantArtifacts: RegistryTenantArtifact[];
+ }
+ | {
+ status: typeof REGISTRY_BOOTSTRAP_STATE.READY;
+ credential: RegistryCredentialStatus;
+ catalog: RegistryCompleteCatalog;
+ tenantArtifacts: RegistryTenantArtifact[];
+ }
+ | {
+ status:
+ | typeof REGISTRY_BOOTSTRAP_STATE.RECONNECT
+ | typeof REGISTRY_BOOTSTRAP_STATE.UNAVAILABLE
+ | typeof REGISTRY_BOOTSTRAP_STATE.ERROR;
+ }
+ | {
+ status: typeof REGISTRY_BOOTSTRAP_STATE.INCOMPLETE;
+ catalog: RegistryIncompleteCatalog;
+ };
+
+export type RegistryBootstrapResult =
+ | {
+ status: typeof REGISTRY_BOOTSTRAP_STATE.READY;
+ state: RegistryBootstrapState;
+ }
+ | { status: typeof REGISTRY_FAILURE.ACCESS_DENIED };
diff --git a/ui/types/users.ts b/ui/types/users.ts
index 56dfe7c3e5..05785cf44b 100644
--- a/ui/types/users.ts
+++ b/ui/types/users.ts
@@ -92,6 +92,7 @@ export const PERMISSION_KEY = {
MANAGE_BILLING: "manage_billing",
MANAGE_ALERTS: "manage_alerts",
MANAGE_LIGHTHOUSE_AI_CONFIGURATION: "manage_lighthouse_ai_configuration",
+ MANAGE_REGISTRY: "manage_registry",
UNLIMITED_VISIBILITY: "unlimited_visibility",
} as const;
@@ -112,6 +113,7 @@ export type TenantMembershipRole =
(typeof TENANT_MEMBERSHIP_ROLE)[keyof typeof TENANT_MEMBERSHIP_ROLE];
export interface RoleDetailAttributes {
+ manage_registry?: boolean;
name: string;
manage_users: boolean;
manage_account: boolean;
diff --git a/ui/vitest.config.ts b/ui/vitest.config.ts
index 81b21dbd70..39d01398a2 100644
--- a/ui/vitest.config.ts
+++ b/ui/vitest.config.ts
@@ -73,6 +73,7 @@ export default defineConfig(() => {
setupFiles: ["./vitest.setup.ts"],
include: ["**/*.test.{ts,tsx}"],
exclude: [
+ ".claude/**",
"node_modules",
".next",
"tests/**/*",
@@ -87,7 +88,7 @@ export default defineConfig(() => {
name: "integration",
setupFiles: ["./vitest.integration.setup.ts"],
include: ["**/*.integration.test.{ts,tsx}"],
- exclude: ["node_modules", ".next", "tests/**/*"],
+ exclude: [".claude/**", "node_modules", ".next", "tests/**/*"],
browser: {
enabled: true,
// Vitest's browser default viewport is 414×896 (phone-sized),
@@ -147,6 +148,9 @@ export default defineConfig(() => {
// App component lib
"@iconify/react",
+ "react-day-picker",
+ "posthog-js",
+ "posthog-js/react",
// Radix
"@radix-ui/react-alert-dialog",
diff --git a/ui/vitest.integration.setup.ts b/ui/vitest.integration.setup.ts
index d8659c186e..c60cb00618 100644
--- a/ui/vitest.integration.setup.ts
+++ b/ui/vitest.integration.setup.ts
@@ -12,6 +12,10 @@ import { resetToasts } from "@/components/shadcn/toast/use-toast";
import { worker } from "./__tests__/msw/worker";
+// The browser harness executes Server Actions directly; Next.js enforces this
+// marker at build time in the actual application.
+vi.mock("server-only", () => ({}));
+
// Server Actions ("use server") are bundled by Vite as plain async functions
// — the directive is a Next.js compiler concept, not part of Vite. When the
// page invokes one, it runs in the browser and reaches `auth()` from
diff --git a/uv.lock b/uv.lock
index ca1afd8346..fbaa60fb40 100644
--- a/uv.lock
+++ b/uv.lock
@@ -104,6 +104,7 @@ constraints = [
{ name = "huaweicloudsdkkms", specifier = "==3.1.204" },
{ name = "huaweicloudsdkobs", specifier = "==3.1.204" },
{ name = "huaweicloudsdkrds", specifier = "==3.1.204" },
+ { name = "huaweicloudsdksmn", specifier = "==3.1.204" },
{ name = "huaweicloudsdkvpc", specifier = "==3.1.204" },
{ name = "huaweicloudsdkwaf", specifier = "==3.1.204" },
{ name = "hyperframe", specifier = "==6.1.0" },
@@ -2412,6 +2413,17 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/18/7d/721f162c46e3de604a73674223bf6c6bc6cf7ade25b3751a71288f4dd122/huaweicloudsdkrds-3.1.204-py3-none-any.whl", hash = "sha256:a790b5b3c457a608e5679c101f463b4d037dd9a8a66f6e46144a9e5a4b37780f", size = 1626906, upload-time = "2026-07-09T09:04:06.936Z" },
]
+[[package]]
+name = "huaweicloudsdksmn"
+version = "3.1.204"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "huaweicloudsdkcore" },
+]
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e9/5d/c0de47d011f1932c9c0ddf669c238d9fad01653d438d38203703526799d7/huaweicloudsdksmn-3.1.204-py3-none-any.whl", hash = "sha256:b0818ea9293e27458c8fa1d2da021c98006cbf9ce01cf17a0f1df598c4da3a6c", size = 323674, upload-time = "2026-07-09T09:04:24.804Z" },
+]
+
[[package]]
name = "huaweicloudsdkvpc"
version = "3.1.204"
@@ -3817,6 +3829,7 @@ dependencies = [
{ name = "huaweicloudsdkkms" },
{ name = "huaweicloudsdkobs" },
{ name = "huaweicloudsdkrds" },
+ { name = "huaweicloudsdksmn" },
{ name = "huaweicloudsdkvpc" },
{ name = "huaweicloudsdkwaf" },
{ name = "jsonschema" },
@@ -3940,6 +3953,7 @@ requires-dist = [
{ name = "huaweicloudsdkkms", specifier = "==3.1.204" },
{ name = "huaweicloudsdkobs", specifier = "==3.1.204" },
{ name = "huaweicloudsdkrds", specifier = "==3.1.204" },
+ { name = "huaweicloudsdksmn", specifier = "==3.1.204" },
{ name = "huaweicloudsdkvpc", specifier = "==3.1.204" },
{ name = "huaweicloudsdkwaf", specifier = "==3.1.204" },
{ name = "jsonschema", specifier = "==4.23.0" },