- {showIcon && typeof icon === "string" ? (
-
- ) : showIcon && icon ? (
-
+ {showIcon && icon ? (
+
{icon}
) : null}
@@ -170,20 +177,10 @@ export function BreadcrumbNavigation({
href={buildNavigationUrl(breadcrumb.path)}
className="flex cursor-pointer items-center gap-2"
>
- {index === 0 &&
- breadcrumb.icon &&
- typeof breadcrumb.icon === "string" ? (
-
- ) : index === 0 && breadcrumb.icon ? (
-
+ {index === 0 && breadcrumb.icon ? (
+
{breadcrumb.icon}
-
+
) : null}
{breadcrumb.name}
@@ -194,20 +191,10 @@ export function BreadcrumbNavigation({
onClick={breadcrumb.onClick}
className="text-text-neutral-primary hover:text-text-neutral-primary-hover flex cursor-pointer items-center gap-2 text-sm font-medium transition-colors"
>
- {index === 0 &&
- breadcrumb.icon &&
- typeof breadcrumb.icon === "string" ? (
-
- ) : index === 0 && breadcrumb.icon ? (
-
+ {index === 0 && breadcrumb.icon ? (
+
{breadcrumb.icon}
-
+
) : null}
{breadcrumb.name}
@@ -215,20 +202,10 @@ export function BreadcrumbNavigation({
) : (
- {index === 0 &&
- breadcrumb.icon &&
- typeof breadcrumb.icon === "string" ? (
-
- ) : index === 0 && breadcrumb.icon ? (
-
+ {index === 0 && breadcrumb.icon ? (
+
{breadcrumb.icon}
-
+
) : null}
{breadcrumb.name}
@@ -250,3 +227,23 @@ export function BreadcrumbNavigation({
);
}
+
+function BreadcrumbIcon({
+ children,
+ className,
+}: {
+ children: ReactNode;
+ className: string;
+}) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/ui/components/shadcn/content-layout/content-layout.tsx b/ui/components/shadcn/content-layout/content-layout.tsx
index 86d02db15e..1e8dd94bcb 100644
--- a/ui/components/shadcn/content-layout/content-layout.tsx
+++ b/ui/components/shadcn/content-layout/content-layout.tsx
@@ -1,4 +1,4 @@
-import { ReactNode } from "react";
+import { type ReactElement } from "react";
import {
Navbar,
@@ -7,7 +7,7 @@ import {
interface ContentLayoutProps {
title: string;
- icon?: string | ReactNode;
+ icon?: ReactElement;
onboardingAction?: OnboardingActionConfig;
children: React.ReactNode;
}
diff --git a/ui/components/shadcn/custom/custom-input.tsx b/ui/components/shadcn/custom/custom-input.tsx
index 6edca37fa8..3f5fde9ad3 100644
--- a/ui/components/shadcn/custom/custom-input.tsx
+++ b/ui/components/shadcn/custom/custom-input.tsx
@@ -1,6 +1,6 @@
"use client";
-import { Icon } from "@iconify/react";
+import { Eye, EyeOff } from "lucide-react";
import { useState } from "react";
import { Control, FieldPath, FieldValues } from "react-hook-form";
@@ -127,15 +127,18 @@ export const CustomInput = ({
inputType === "password" ? "Show password" : "Hide password"
}
>
-
+ {(password && isPasswordVisible) ||
+ (confirmPassword && isConfirmPasswordVisible) ? (
+
+ ) : (
+
+ )}
)}
diff --git a/ui/components/shadcn/headers/navigation-header.tsx b/ui/components/shadcn/headers/navigation-header.tsx
index 99acb2b368..3418d88adf 100644
--- a/ui/components/shadcn/headers/navigation-header.tsx
+++ b/ui/components/shadcn/headers/navigation-header.tsx
@@ -1,12 +1,12 @@
-import { Icon } from "@iconify/react";
import Link from "next/link";
+import type { ReactElement } from "react";
import { Button } from "@/components/shadcn/button/button";
import { Separator } from "@/components/shadcn/separator/separator";
interface NavigationHeaderProps {
title: string;
- icon: string;
+ icon: ReactElement;
href?: string;
}
@@ -25,8 +25,8 @@ export const NavigationHeader = ({
size="icon"
asChild
>
-
-
+
+ {icon}
diff --git a/ui/dependency-log.json b/ui/dependency-log.json
index af0cf7a367..8db88c384b 100644
--- a/ui/dependency-log.json
+++ b/ui/dependency-log.json
@@ -663,14 +663,6 @@
"strategy": "installed",
"generatedAt": "2025-10-22T12:36:37.962Z"
},
- {
- "section": "devDependencies",
- "name": "@iconify/react",
- "from": "5.2.1",
- "to": "5.2.1",
- "strategy": "installed",
- "generatedAt": "2025-10-22T12:36:37.962Z"
- },
{
"section": "devDependencies",
"name": "@next/eslint-plugin-next",
diff --git a/ui/eslint.config.ts b/ui/eslint.config.ts
index dc0658649f..f57c2fa486 100644
--- a/ui/eslint.config.ts
+++ b/ui/eslint.config.ts
@@ -105,6 +105,21 @@ export default tseslint.config(
"security/detect-object-injection": "off",
+ // Icons must ship in the bundle: air-gapped deployments cannot reach
+ // runtime icon APIs.
+ "no-restricted-imports": [
+ "error",
+ {
+ patterns: [
+ {
+ group: ["@iconify/*"],
+ message:
+ "Iconify loads icons over the network. Use lucide-react or components/icons.",
+ },
+ ],
+ },
+ ],
+
"eol-last": ["error", "always"],
"import-x/order": [
diff --git a/ui/lib/csp.ts b/ui/lib/csp.ts
index 8f037482e8..854d94bef7 100644
--- a/ui/lib/csp.ts
+++ b/ui/lib/csp.ts
@@ -67,7 +67,7 @@ export function getCspHeader({
return `
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};
+ connect-src 'self' 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${registryImageOrigins.map((origin) => ` ${origin}`).join("")}${posthogSource}${toolbarUiSource};
font-src 'self'${toolbarPosthogSource};
style-src 'self' 'unsafe-inline'${toolbarPosthogSource};
diff --git a/ui/lib/registry/presentation.test.ts b/ui/lib/registry/presentation.test.ts
index 847fe1dc99..8cf103679f 100644
--- a/ui/lib/registry/presentation.test.ts
+++ b/ui/lib/registry/presentation.test.ts
@@ -1,6 +1,9 @@
-import { describe, expect, it } from "vitest";
+import { afterEach, describe, expect, it, vi } from "vitest";
-import { getRegistryPresentation } from "./presentation";
+import {
+ getRegistryPresentation,
+ readRegistryPresentation,
+} from "./presentation";
describe("Registry presentation configuration", () => {
const urlWithCredentials = new URL("https://registry.test");
@@ -10,11 +13,11 @@ describe("Registry presentation configuration", () => {
it("uses the configured Registry and media origins", () => {
expect(
getRegistryPresentation(
- "https://registry.private.test/keys",
+ "https://registry.private.test/",
"https://assets.private.test/media/",
),
).toEqual({
- keyUrl: "https://registry.private.test/keys",
+ registryUrl: "https://registry.private.test/",
imageOrigins: [
"https://registry.private.test",
"https://assets.private.test",
@@ -24,7 +27,7 @@ describe("Registry presentation configuration", () => {
it("does not guess a Registry environment when configuration is missing", () => {
expect(getRegistryPresentation()).toEqual({
- keyUrl: undefined,
+ registryUrl: undefined,
imageOrigins: [],
});
});
@@ -36,7 +39,41 @@ describe("Registry presentation configuration", () => {
"invalid",
])("rejects unsafe configuration: %s", (value) => {
expect(getRegistryPresentation(value, value)).toEqual({
- keyUrl: undefined,
+ registryUrl: undefined,
+ imageOrigins: [],
+ });
+ });
+});
+
+describe("Registry presentation from the runtime environment", () => {
+ afterEach(() => {
+ vi.unstubAllEnvs();
+ });
+
+ it("links to the Registry the backend installs from", () => {
+ // Given
+ vi.stubEnv("PROWLER_REGISTRY_INDEX_URL", "https://registry.internal.test");
+ vi.stubEnv("UI_REGISTRY_MEDIA_URL", "https://media.internal.test");
+
+ // When / Then
+ expect(readRegistryPresentation()).toEqual({
+ registryUrl: "https://registry.internal.test/",
+ imageOrigins: [
+ "https://registry.internal.test",
+ "https://media.internal.test",
+ ],
+ });
+ });
+
+ it("ignores the retired UI_REGISTRY_URL variable", () => {
+ // Given
+ vi.stubEnv("PROWLER_REGISTRY_INDEX_URL", "");
+ vi.stubEnv("UI_REGISTRY_MEDIA_URL", "");
+ vi.stubEnv("UI_REGISTRY_URL", "https://registry.prowler.com");
+
+ // When / Then
+ expect(readRegistryPresentation()).toEqual({
+ registryUrl: undefined,
imageOrigins: [],
});
});
diff --git a/ui/lib/registry/presentation.ts b/ui/lib/registry/presentation.ts
index 91a8ac5656..19547eeb12 100644
--- a/ui/lib/registry/presentation.ts
+++ b/ui/lib/registry/presentation.ts
@@ -1,3 +1,5 @@
+import { readEnv } from "@/lib/runtime-env";
+
/** 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;
@@ -21,7 +23,7 @@ export function getRegistryPresentation(
const registry = parsePublicUrl(registryUrl);
const media = parsePublicUrl(mediaUrl);
return {
- keyUrl: registry?.href,
+ registryUrl: registry?.href,
imageOrigins: Array.from(
new Set(
[registry?.origin, media?.origin].filter((origin): origin is string =>
@@ -31,3 +33,11 @@ export function getRegistryPresentation(
),
};
}
+
+/** Same Registry base URL the backend installs artifacts from. */
+export function readRegistryPresentation() {
+ return getRegistryPresentation(
+ readEnv("PROWLER_REGISTRY_INDEX_URL"),
+ readEnv("UI_REGISTRY_MEDIA_URL"),
+ );
+}
diff --git a/ui/next.config.test.ts b/ui/next.config.test.ts
index a464bb3602..90f58dde5d 100644
--- a/ui/next.config.test.ts
+++ b/ui/next.config.test.ts
@@ -33,9 +33,6 @@ const BASELINE_CSP = {
],
"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",
diff --git a/ui/package.json b/ui/package.json
index c0778f307d..bc72f8f318 100644
--- a/ui/package.json
+++ b/ui/package.json
@@ -121,7 +121,6 @@
"zustand": "5.0.8"
},
"devDependencies": {
- "@iconify/react": "5.2.1",
"@next/eslint-plugin-next": "16.2.9",
"@playwright/test": "1.56.1",
"@testing-library/jest-dom": "6.9.1",
diff --git a/ui/playwright.registry.config.ts b/ui/playwright.registry.config.ts
index 28df0c7d66..2ba76d136e 100644
--- a/ui/playwright.registry.config.ts
+++ b/ui/playwright.registry.config.ts
@@ -25,7 +25,7 @@ const registryFixtureUiServer = (
UI_API_BASE_URL: registryFixtureApiUrl,
UI_CLOUD_ENABLED: String(cloudEnabled),
UI_REGISTRY_ENABLED: String(registryEnabled),
- UI_REGISTRY_URL: "https://registry.dev.prowler.com",
+ PROWLER_REGISTRY_INDEX_URL: "https://registry.dev.prowler.com",
UI_REGISTRY_MEDIA_URL: "https://media.registry.dev.prowler.com",
CLOUD_BILLING_ENABLED: "false",
},
diff --git a/ui/pnpm-lock.yaml b/ui/pnpm-lock.yaml
index 6ae1c07bdf..088af80569 100644
--- a/ui/pnpm-lock.yaml
+++ b/ui/pnpm-lock.yaml
@@ -301,9 +301,6 @@ importers:
specifier: 5.0.8
version: 5.0.8(@types/react@19.2.17)(react@19.2.7)(use-sync-external-store@1.6.0(react@19.2.7))
devDependencies:
- '@iconify/react':
- specifier: 5.2.1
- version: 5.2.1(react@19.2.7)
'@next/eslint-plugin-next':
specifier: 16.2.9
version: 16.2.9
@@ -1094,11 +1091,6 @@ packages:
resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==}
engines: {node: '>=18.18'}
- '@iconify/react@5.2.1':
- resolution: {integrity: sha512-37GDR3fYDZmnmUn9RagyaX+zca24jfVOMY8E1IXTqJuE8pxNtN51KWPQe3VODOWvuUurq7q9uUu3CFrpqj5Iqg==}
- peerDependencies:
- react: '>=16'
-
'@iconify/types@2.0.0':
resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==}
@@ -8308,11 +8300,6 @@ snapshots:
'@humanwhocodes/retry@0.4.3': {}
- '@iconify/react@5.2.1(react@19.2.7)':
- dependencies:
- '@iconify/types': 2.0.0
- react: 19.2.7
-
'@iconify/types@2.0.0': {}
'@iconify/utils@3.1.0':
diff --git a/ui/proxy.ts b/ui/proxy.ts
index b26008ddfc..24c15623a6 100644
--- a/ui/proxy.ts
+++ b/ui/proxy.ts
@@ -14,7 +14,7 @@ import {
} 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 { readRegistryPresentation } from "@/lib/registry/presentation";
import { readEnv } from "@/lib/runtime-env";
import { isCloud } from "@/lib/shared/env";
import { copyAttributionParams } from "@/lib/utm";
@@ -38,10 +38,7 @@ const withSecurityHeaders = (response: NextResponse): NextResponse => {
"Content-Security-Policy",
getCspHeader({
cloudEnabled: isCloud(),
- registryImageOrigins: getRegistryPresentation(
- readEnv("UI_REGISTRY_URL"),
- readEnv("UI_REGISTRY_MEDIA_URL"),
- ).imageOrigins,
+ registryImageOrigins: readRegistryPresentation().imageOrigins,
posthogEnabled: isGatedIntegrationEnabled(GATED_INTEGRATIONS.posthog),
posthogKey: readGatedEnv(
"UI_POSTHOG_ENABLED",
diff --git a/ui/tests/registry/validation.md b/ui/tests/registry/validation.md
index e0e9835e22..e73d51c049 100644
--- a/ui/tests/registry/validation.md
+++ b/ui/tests/registry/validation.md
@@ -31,14 +31,14 @@ See [the scenario catalog](registry.md) and [the Add Provider tour report](add-p
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` |
+| Variable | Purpose | Development Example |
+| ---------------------------- | ---------------------------------------------------------------------------------------------------------------- | ---------------------------------------- |
+| `PROWLER_REGISTRY_INDEX_URL` | Registry base URL shared with the backend installer. Supplies the help links 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.
+For production, use `https://registry.prowler.com` and `https://media.registry.prowler.com`. For a private Registry, use its website and media service URLs. `PROWLER_REGISTRY_INDEX_URL` is the same variable the backend reads, so one value in the shared `.env` configures both services.
-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.
+The help links are hidden when the 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.
diff --git a/ui/types/env.d.ts b/ui/types/env.d.ts
index 1f0315284c..b2532f6b95 100644
--- a/ui/types/env.d.ts
+++ b/ui/types/env.d.ts
@@ -31,6 +31,9 @@ declare global {
// Prowler Cloud deployment flag — runtime read (server env, client island).
UI_CLOUD_ENABLED?: "true" | "false";
UI_REGISTRY_ENABLED?: "true" | "false";
+ // Registry base URL, shared with the backend installer.
+ PROWLER_REGISTRY_INDEX_URL?: string;
+ UI_REGISTRY_MEDIA_URL?: string;
CLOUD_BILLING_ENABLED?: "legacy" | "metronome" | "false";
diff --git a/ui/vitest.config.ts b/ui/vitest.config.ts
index 39d01398a2..d4485dc474 100644
--- a/ui/vitest.config.ts
+++ b/ui/vitest.config.ts
@@ -147,7 +147,6 @@ export default defineConfig(() => {
"next-themes",
// App component lib
- "@iconify/react",
"react-day-picker",
"posthog-js",
"posthog-js/react",