fix(ui): bundle all icons so the UI renders without internet access (#12892)

This commit is contained in:
Alejandro Bailo
2026-09-28 15:58:32 +02:00
committed by GitHub
parent 4195a4f818
commit e6320b178a
65 changed files with 524 additions and 318 deletions
@@ -47,6 +47,8 @@ The former build-time variables map to the new runtime variables as follows:
Roll out Registry only after the Registry backend dependency is deployed, intended roles have `manage_registry`, and acceptance with real credentials has exercised installation, provider account creation, credentials, connection, and scan launch. Deploy the UI with `UI_REGISTRY_ENABLED` unset or `"false"`; set it to `"true"` only in the prepared process environment, then restart or otherwise apply the environment update required by the platform. A Registry key must belong to the configured Registry environment; a production key does not authenticate against a development Registry. Roll out Registry only after the Registry backend dependency is deployed, intended roles have `manage_registry`, and acceptance with real credentials has exercised installation, provider account creation, credentials, connection, and scan launch. Deploy the UI with `UI_REGISTRY_ENABLED` unset or `"false"`; set it to `"true"` only in the prepared process environment, then restart or otherwise apply the environment update required by the platform. A Registry key must belong to the configured Registry environment; a production key does not authenticate against a development Registry.
The Registry links read `PROWLER_REGISTRY_INDEX_URL`, the same base URL the backend installs artifacts from. The Registry page and the credential banner link to it, and its origin is allowed for images. `UI_REGISTRY_MEDIA_URL` adds the Registry media service origin so artifact logos load. When `PROWLER_REGISTRY_INDEX_URL` is unset or invalid, the links are hidden instead of pointing to the public Prowler Registry, which keeps private and air-gapped deployments from sending users to an unreachable host. `UI_REGISTRY_URL` is no longer read.
The catalog displays all artifacts, including built-ins and packages containing only checks or compliance frameworks. Only external provider artifacts support Add. After confirmed installation, open Providers and select the option labeled Registry to configure an account. Creating accounts and running scans also require the corresponding provider and scan permissions. Removing an artifact keeps existing provider accounts, but future connections or scans can fail until the artifact is installed again. The catalog displays all artifacts, including built-ins and packages containing only checks or compliance frameworks. Only external provider artifacts support Add. After confirmed installation, open Providers and select the option labeled Registry to configure an account. Creating accounts and running scans also require the corresponding provider and scan permissions. Removing an artifact keeps existing provider accounts, but future connections or scans can fail until the artifact is installed again.
To roll back, set `UI_REGISTRY_ENABLED=false` or remove it and apply the environment update. Proxy, page, and action checks deny on their next request. Navigation refreshes from server-authorized access when the page is requested again. Rollback does not delete Registry credentials, tenant artifact records, or provider accounts. To roll back, set `UI_REGISTRY_ENABLED=false` or remove it and apply the environment update. Proxy, page, and action checks deny on their next request. Navigation refreshes from server-authorized access when the page is requested again. Rollback does not delete Registry credentials, tenant artifact records, or provider accounts.
@@ -1,6 +1,6 @@
"use client"; "use client";
import { Icon } from "@iconify/react"; import { Mail, TriangleAlert } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
@@ -12,6 +12,7 @@ import {
} from "@/app/(auth)/invitation/_lib/invitation-errors"; } from "@/app/(auth)/invitation/_lib/invitation-errors";
import { AuthBrand } from "@/components/auth/oss/auth-brand"; import { AuthBrand } from "@/components/auth/oss/auth-brand";
import { Button } from "@/components/shadcn"; import { Button } from "@/components/shadcn";
import { Spinner } from "@/components/shadcn/spinner/spinner";
type AcceptState = type AcceptState =
| { kind: "no-token" } | { kind: "no-token" }
@@ -74,10 +75,9 @@ export function AcceptInvitationClient({
{/* No token */} {/* No token */}
{state.kind === "no-token" && ( {state.kind === "no-token" && (
<div className="flex flex-col items-center gap-4"> <div className="flex flex-col items-center gap-4">
<Icon <TriangleAlert
icon="solar:danger-triangle-bold" aria-hidden="true"
className="text-text-warning-primary" className="text-text-warning-primary size-12"
width={48}
/> />
<h1 className="text-xl font-semibold">Invalid Invitation Link</h1> <h1 className="text-xl font-semibold">Invalid Invitation Link</h1>
<p className="text-text-neutral-tertiary"> <p className="text-text-neutral-tertiary">
@@ -93,11 +93,7 @@ export function AcceptInvitationClient({
{/* Accepting */} {/* Accepting */}
{state.kind === "accepting" && ( {state.kind === "accepting" && (
<div className="flex flex-col items-center gap-4"> <div className="flex flex-col items-center gap-4">
<Icon <Spinner className="size-12" />
icon="eos-icons:loading"
className="text-text-neutral-tertiary"
width={48}
/>
<h1 className="text-xl font-semibold">Accepting Invitation...</h1> <h1 className="text-xl font-semibold">Accepting Invitation...</h1>
<p className="text-text-neutral-tertiary"> <p className="text-text-neutral-tertiary">
Please wait while we process your invitation. Please wait while we process your invitation.
@@ -108,10 +104,9 @@ export function AcceptInvitationClient({
{/* Error */} {/* Error */}
{state.kind === "error" && ( {state.kind === "error" && (
<div className="flex flex-col items-center gap-4"> <div className="flex flex-col items-center gap-4">
<Icon <TriangleAlert
icon="solar:danger-triangle-bold" aria-hidden="true"
className="text-text-error-primary" className="text-text-error-primary size-12"
width={48}
/> />
<h1 className="text-xl font-semibold"> <h1 className="text-xl font-semibold">
Could Not Accept Invitation Could Not Accept Invitation
@@ -129,11 +124,7 @@ export function AcceptInvitationClient({
{/* Choice page for unauthenticated users */} {/* Choice page for unauthenticated users */}
{state.kind === "choose" && ( {state.kind === "choose" && (
<div className="flex flex-col items-center gap-6"> <div className="flex flex-col items-center gap-6">
<Icon <Mail aria-hidden="true" className="text-button-primary size-12" />
icon="solar:letter-bold"
className="text-button-primary"
width={48}
/>
<div> <div>
<h1 className="text-xl font-semibold"> <h1 className="text-xl font-semibold">
You&apos;ve Been Invited You&apos;ve Been Invited
+2 -1
View File
@@ -1,3 +1,4 @@
import { BellRing } from "lucide-react";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { getLatestMetadataInfo } from "@/actions/findings"; import { getLatestMetadataInfo } from "@/actions/findings";
@@ -101,7 +102,7 @@ export default async function AlertsPage({ searchParams }: AlertsPageProps) {
: undefined; : undefined;
return ( return (
<ContentLayout title="Alerts" icon="lucide:bell-ring"> <ContentLayout title="Alerts" icon={<BellRing />}>
{!hasError ? ( {!hasError ? (
<AlertsLighthouseContext <AlertsLighthouseContext
totalCount={apiMeta?.pagination?.count ?? alerts.length} totalCount={apiMeta?.pagination?.count ?? alerts.length}
+3 -1
View File
@@ -1,3 +1,5 @@
import { GitBranch } from "lucide-react";
import { ContentLayout } from "@/components/shadcn/content-layout"; import { ContentLayout } from "@/components/shadcn/content-layout";
export default function AttackPathsLayout({ export default function AttackPathsLayout({
@@ -8,7 +10,7 @@ export default function AttackPathsLayout({
return ( return (
<ContentLayout <ContentLayout
title="Attack Paths" title="Attack Paths"
icon="lucide:git-branch" icon={<GitBranch />}
onboardingAction={{ flowId: "attack-paths" }} onboardingAction={{ flowId: "attack-paths" }}
> >
{children} {children}
+4 -4
View File
@@ -1,4 +1,4 @@
import { Info } from "lucide-react"; import { Info, ShieldCheck } from "lucide-react";
import { Suspense } from "react"; import { Suspense } from "react";
import { import {
@@ -105,7 +105,7 @@ export default async function Compliance({
return ( return (
<ContentLayout <ContentLayout
title="Compliance" title="Compliance"
icon="lucide:shield-check" icon={<ShieldCheck />}
onboardingAction={ onboardingAction={
hasCompletedScan hasCompletedScan
? { flowId: "view-compliance" } ? { flowId: "view-compliance" }
@@ -172,7 +172,7 @@ export default async function Compliance({
return ( return (
<ContentLayout <ContentLayout
title="Compliance" title="Compliance"
icon="lucide:shield-check" icon={<ShieldCheck />}
onboardingAction={{ onboardingAction={{
flowId: "view-compliance", flowId: "view-compliance",
fallbackFlowId: "view-first-scan", fallbackFlowId: "view-first-scan",
@@ -323,7 +323,7 @@ export default async function Compliance({
return ( return (
<ContentLayout <ContentLayout
title="Compliance" title="Compliance"
icon="lucide:shield-check" icon={<ShieldCheck />}
onboardingAction={onboardingAction} onboardingAction={onboardingAction}
> >
<CompliancePageTabs <CompliancePageTabs
+13 -6
View File
@@ -1,7 +1,7 @@
"use client"; "use client";
import { Icon } from "@iconify/react";
import * as Sentry from "@sentry/nextjs"; import * as Sentry from "@sentry/nextjs";
import { RefreshCw, ServerOff, TriangleAlert } from "lucide-react";
import { useEffect } from "react"; import { useEffect } from "react";
import { Button } from "@/components/shadcn"; import { Button } from "@/components/shadcn";
@@ -84,10 +84,17 @@ export default function Error({
<Card variant="base" className="w-full max-w-lg"> <Card variant="base" className="w-full max-w-lg">
<CardHeader> <CardHeader>
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<Icon {is500Error ? (
icon={is500Error ? "tabler:server-off" : "tabler:rocket-off"} <ServerOff
className="mt-0.5 h-5 w-5 flex-shrink-0 text-red-500" aria-hidden="true"
/> className="mt-0.5 h-5 w-5 flex-shrink-0 text-red-500"
/>
) : (
<TriangleAlert
aria-hidden="true"
className="mt-0.5 h-5 w-5 flex-shrink-0 text-red-500"
/>
)}
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<CardTitle className="text-lg"> <CardTitle className="text-lg">
{is500Error {is500Error
@@ -105,7 +112,7 @@ export default function Error({
<CardContent> <CardContent>
<div className="flex items-center justify-start gap-3"> <div className="flex items-center justify-start gap-3">
<Button onClick={reset} size="sm" className="gap-2"> <Button onClick={reset} size="sm" className="gap-2">
<Icon icon="tabler:refresh" className="h-4 w-4" /> <RefreshCw aria-hidden="true" className="h-4 w-4" />
Try Again Try Again
</Button> </Button>
<CustomLink href="/" target="_self" className="font-bold"> <CustomLink href="/" target="_self" className="font-bold">
+3 -1
View File
@@ -1,3 +1,5 @@
import { Tag } from "lucide-react";
import { SkeletonTableFindings } from "@/components/findings/table"; import { SkeletonTableFindings } from "@/components/findings/table";
import { ContentLayout } from "@/components/shadcn/content-layout"; import { ContentLayout } from "@/components/shadcn/content-layout";
@@ -7,7 +9,7 @@ export default function FindingsLoading() {
return ( return (
<ContentLayout <ContentLayout
title="Findings" title="Findings"
icon="lucide:tag" icon={<Tag />}
onboardingAction={{ flowId: "explore-findings" }} onboardingAction={{ flowId: "explore-findings" }}
> >
<div className="mb-6"> <div className="mb-6">
+2 -1
View File
@@ -1,3 +1,4 @@
import { Tag } from "lucide-react";
import { Suspense } from "react"; import { Suspense } from "react";
import { import {
@@ -75,7 +76,7 @@ export default async function Findings({
return ( return (
<ContentLayout <ContentLayout
title="Findings" title="Findings"
icon="lucide:tag" icon={<Tag />}
onboardingAction={onboardingAction} onboardingAction={onboardingAction}
> >
<FilterTransitionWrapper> <FilterTransitionWrapper>
+3 -1
View File
@@ -1,10 +1,12 @@
import { Puzzle } from "lucide-react";
import { ContentLayout } from "@/components/shadcn/content-layout"; import { ContentLayout } from "@/components/shadcn/content-layout";
import { IntegrationsContent } from "./integrations-content"; import { IntegrationsContent } from "./integrations-content";
export default async function Integrations() { export default async function Integrations() {
return ( return (
<ContentLayout title="Integrations" icon="lucide:puzzle"> <ContentLayout title="Integrations" icon={<Puzzle />}>
<IntegrationsContent /> <IntegrationsContent />
</ContentLayout> </ContentLayout>
); );
@@ -1,5 +1,6 @@
import "@/styles/globals.css"; import "@/styles/globals.css";
import { X } from "lucide-react";
import React from "react"; import React from "react";
import { WorkflowSendInvite } from "@/components/invitations/workflow"; import { WorkflowSendInvite } from "@/components/invitations/workflow";
@@ -14,7 +15,7 @@ export default function InvitationLayout({ children }: InvitationLayoutProps) {
<> <>
<NavigationHeader <NavigationHeader
title="Send Invitation" title="Send Invitation"
icon="icon-park-outline:close-small" icon={<X />}
href="/invitations" href="/invitations"
/> />
<div className="h-16" /> <div className="h-16" />
+2 -1
View File
@@ -1,3 +1,4 @@
import { Mail } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { Suspense } from "react"; import { Suspense } from "react";
@@ -22,7 +23,7 @@ export default async function Invitations({
const searchParamsKey = JSON.stringify(resolvedSearchParams || {}); const searchParamsKey = JSON.stringify(resolvedSearchParams || {});
return ( return (
<ContentLayout title="Invitations" icon="lucide:mail"> <ContentLayout title="Invitations" icon={<Mail />}>
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<div className="flex flex-row items-end justify-between"> <div className="flex flex-row items-end justify-between">
<DataTableFilterCustom <DataTableFilterCustom
@@ -1,15 +1,15 @@
import { Icon } from "@iconify/react";
import { import {
LIGHTHOUSE_V2_PROVIDER_TYPE, LIGHTHOUSE_V2_PROVIDER_TYPE,
type LighthouseV2ProviderType, type LighthouseV2ProviderType,
} from "@/app/(prowler)/lighthouse/_types"; } from "@/app/(prowler)/lighthouse/_types";
import { AmazonWebServicesIcon, OpenAIIcon } from "@/components/icons/Icons";
import type { IconComponent } from "@/types/components";
const LIGHTHOUSE_V2_PROVIDER_ICONS = { const LIGHTHOUSE_V2_PROVIDER_ICONS = {
[LIGHTHOUSE_V2_PROVIDER_TYPE.OPENAI]: "simple-icons:openai", [LIGHTHOUSE_V2_PROVIDER_TYPE.OPENAI]: OpenAIIcon,
[LIGHTHOUSE_V2_PROVIDER_TYPE.BEDROCK]: "simple-icons:amazonwebservices", [LIGHTHOUSE_V2_PROVIDER_TYPE.BEDROCK]: AmazonWebServicesIcon,
[LIGHTHOUSE_V2_PROVIDER_TYPE.OPENAI_COMPATIBLE]: "simple-icons:openai", [LIGHTHOUSE_V2_PROVIDER_TYPE.OPENAI_COMPATIBLE]: OpenAIIcon,
} as const satisfies Record<LighthouseV2ProviderType, string>; } as const satisfies Record<LighthouseV2ProviderType, IconComponent>;
export function ProviderIcon({ export function ProviderIcon({
provider, provider,
@@ -18,11 +18,6 @@ export function ProviderIcon({
provider: LighthouseV2ProviderType; provider: LighthouseV2ProviderType;
className?: string; className?: string;
}) { }) {
return ( const Icon = LIGHTHOUSE_V2_PROVIDER_ICONS[provider];
<Icon return <Icon aria-hidden="true" className={className} />;
aria-hidden="true"
className={className}
icon={LIGHTHOUSE_V2_PROVIDER_ICONS[provider]}
/>
);
} }
+2 -2
View File
@@ -56,7 +56,7 @@ export default async function AIChatbot({
const chatRouteKey = validSessionId ?? initialPrompt ?? "new"; const chatRouteKey = validSessionId ?? initialPrompt ?? "new";
return ( return (
<ContentLayout title="Lighthouse AI" icon={<LighthouseIcon />}> <ContentLayout title="Lighthouse AI" icon={<LighthouseIcon size={32} />}>
<AppSidebarModeSync mode={APP_SIDEBAR_MODE.CHAT} closeSidePanel /> <AppSidebarModeSync mode={APP_SIDEBAR_MODE.CHAT} closeSidePanel />
{/* [contain:layout] traps streamdown's fixed fullscreen overlay inside {/* [contain:layout] traps streamdown's fixed fullscreen overlay inside
the chat area so it never covers the sidebar or navbar. */} the chat area so it never covers the sidebar or navbar. */}
@@ -91,7 +91,7 @@ export default async function AIChatbot({
} }
return ( return (
<ContentLayout title="Lighthouse AI" icon={<LighthouseIcon />}> <ContentLayout title="Lighthouse AI" icon={<LighthouseIcon size={32} />}>
<div className="-mx-6 -my-4 h-[calc(100dvh-4.5rem)] sm:-mx-8"> <div className="-mx-6 -my-4 h-[calc(100dvh-4.5rem)] sm:-mx-8">
<Chat <Chat
hasConfig={hasConfig} hasConfig={hasConfig}
@@ -2,7 +2,7 @@
import "@/styles/globals.css"; import "@/styles/globals.css";
import { Icon } from "@iconify/react"; import { Star, Trash2, X } from "lucide-react";
import { useRouter, useSearchParams } from "next/navigation"; import { useRouter, useSearchParams } from "next/navigation";
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
@@ -76,7 +76,7 @@ export default function ConnectLLMLayout({ children }: ConnectLLMLayoutProps) {
<NavigationHeader <NavigationHeader
title={isEditMode ? "Configure LLM Provider" : "Connect LLM Provider"} title={isEditMode ? "Configure LLM Provider" : "Connect LLM Provider"}
icon="icon-park-outline:close-small" icon={<X />}
href={LIGHTHOUSE_ROUTE.SETTINGS} href={LIGHTHOUSE_ROUTE.SETTINGS}
/> />
<div className="h-8" /> <div className="h-8" />
@@ -96,7 +96,7 @@ export default function ConnectLLMLayout({ children }: ConnectLLMLayoutProps) {
onClick={handleSetDefault} onClick={handleSetDefault}
className="w-full sm:w-auto" className="w-full sm:w-auto"
> >
<Icon icon="heroicons:star" className="h-4 w-4" /> <Star aria-hidden="true" className="h-4 w-4" />
Set as Default Set as Default
</Button> </Button>
)} )}
@@ -108,7 +108,7 @@ export default function ConnectLLMLayout({ children }: ConnectLLMLayoutProps) {
onClick={() => setIsDeleteOpen(true)} onClick={() => setIsDeleteOpen(true)}
className="w-full sm:w-auto" className="w-full sm:w-auto"
> >
<Icon icon="heroicons:trash" className="h-4 w-4" /> <Trash2 aria-hidden="true" className="h-4 w-4" />
Delete Provider Delete Provider
</Button> </Button>
</div> </div>
+2 -1
View File
@@ -1,5 +1,6 @@
import "@/styles/globals.css"; import "@/styles/globals.css";
import { Group } from "lucide-react";
import React from "react"; import React from "react";
import { ContentLayout } from "@/components/shadcn/content-layout"; import { ContentLayout } from "@/components/shadcn/content-layout";
@@ -10,7 +11,7 @@ interface ProviderLayoutProps {
export default function ProviderLayout({ children }: ProviderLayoutProps) { export default function ProviderLayout({ children }: ProviderLayoutProps) {
return ( return (
<ContentLayout title="Manage Groups" icon="lucide:group"> <ContentLayout title="Manage Groups" icon={<Group />}>
{children} {children}
</ContentLayout> </ContentLayout>
); );
+2 -1
View File
@@ -1,3 +1,4 @@
import { VolumeX } from "lucide-react";
import { Suspense } from "react"; import { Suspense } from "react";
import { ContentLayout } from "@/components/shadcn/content-layout"; import { ContentLayout } from "@/components/shadcn/content-layout";
@@ -15,7 +16,7 @@ export default async function MutelistPage({
const searchParamsKey = JSON.stringify(resolvedSearchParams); const searchParamsKey = JSON.stringify(resolvedSearchParams);
return ( return (
<ContentLayout title="Mutelist" icon="lucide:volume-x"> <ContentLayout title="Mutelist" icon={<VolumeX />}>
<MutelistTabs <MutelistTabs
simpleContent={ simpleContent={
<Suspense key={searchParamsKey} fallback={<MuteRulesTableSkeleton />}> <Suspense key={searchParamsKey} fallback={<MuteRulesTableSkeleton />}>
+2 -1
View File
@@ -1,3 +1,4 @@
import { SquareChartGantt } from "lucide-react";
import { Suspense } from "react"; import { Suspense } from "react";
import { getAllProviderGroups } from "@/actions/manage-groups/manage-groups"; import { getAllProviderGroups } from "@/actions/manage-groups/manage-groups";
@@ -59,7 +60,7 @@ export default async function Home({
]); ]);
return ( return (
<ContentLayout title="Overview" icon="lucide:square-chart-gantt"> <ContentLayout title="Overview" icon={<SquareChartGantt />}>
<AppSidebarModeSync mode={APP_SIDEBAR_MODE.BROWSE} /> <AppSidebarModeSync mode={APP_SIDEBAR_MODE.BROWSE} />
<OverviewProviderContext <OverviewProviderContext
searchParams={resolvedSearchParams} searchParams={resolvedSearchParams}
+2 -1
View File
@@ -1,3 +1,4 @@
import { Users } from "lucide-react";
import { Suspense } from "react"; import { Suspense } from "react";
import { getSamlConfig } from "@/actions/integrations/saml"; import { getSamlConfig } from "@/actions/integrations/saml";
@@ -26,7 +27,7 @@ export default async function Profile({
const resolvedSearchParams = await searchParams; const resolvedSearchParams = await searchParams;
return ( return (
<ContentLayout title="User Profile" icon="lucide:users"> <ContentLayout title="User Profile" icon={<Users />}>
<Suspense fallback={<SkeletonUserInfo />}> <Suspense fallback={<SkeletonUserInfo />}>
<SSRDataUser searchParams={resolvedSearchParams} /> <SSRDataUser searchParams={resolvedSearchParams} />
</Suspense> </Suspense>
+2 -1
View File
@@ -1,3 +1,4 @@
import { CloudCog } from "lucide-react";
import { Suspense } from "react"; import { Suspense } from "react";
import { SkeletonTableProviders } from "@/components/providers/table"; import { SkeletonTableProviders } from "@/components/providers/table";
@@ -35,7 +36,7 @@ export default async function Providers({
return ( return (
<ContentLayout <ContentLayout
title="Providers" title="Providers"
icon="lucide:cloud-cog" icon={<CloudCog />}
onboardingAction={{ flowId: "add-provider" }} onboardingAction={{ flowId: "add-provider" }}
> >
{isCloudEnvironment && <CliImportBanner className="mb-6" />} {isCloudEnvironment && <CliImportBanner className="mb-6" />}
+4 -6
View File
@@ -1,10 +1,10 @@
import { Package } from "lucide-react";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { getRegistryBootstrap } from "@/actions/registry/registry"; import { getRegistryBootstrap } from "@/actions/registry/registry";
import { RegistryExplorer } from "@/components/registry/registry-explorer"; import { RegistryExplorer } from "@/components/registry/registry-explorer";
import { ContentLayout } from "@/components/shadcn/content-layout/content-layout"; import { ContentLayout } from "@/components/shadcn/content-layout/content-layout";
import { getRegistryPresentation } from "@/lib/registry/presentation"; import { readRegistryPresentation } from "@/lib/registry/presentation";
import { readEnv } from "@/lib/runtime-env";
import { REGISTRY_FAILURE } from "@/types/registry"; import { REGISTRY_FAILURE } from "@/types/registry";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -14,12 +14,10 @@ export default async function RegistryPage() {
if (bootstrap.status === REGISTRY_FAILURE.ACCESS_DENIED) redirect("/profile"); if (bootstrap.status === REGISTRY_FAILURE.ACCESS_DENIED) redirect("/profile");
return ( return (
<ContentLayout title="Registry" icon="lucide:package"> <ContentLayout title="Registry" icon={<Package />}>
<RegistryExplorer <RegistryExplorer
initialState={bootstrap.state} initialState={bootstrap.state}
registryKeyUrl={ registryUrl={readRegistryPresentation().registryUrl}
getRegistryPresentation(readEnv("UI_REGISTRY_URL")).keyUrl
}
/> />
</ContentLayout> </ContentLayout>
); );
+2 -1
View File
@@ -1,3 +1,4 @@
import { Warehouse } from "lucide-react";
import { Suspense } from "react"; import { Suspense } from "react";
import { getAllProviderGroups } from "@/actions/manage-groups/manage-groups"; import { getAllProviderGroups } from "@/actions/manage-groups/manage-groups";
@@ -80,7 +81,7 @@ export default async function Resources({
const uniqueGroups = metadataInfoData?.data?.attributes?.groups || []; const uniqueGroups = metadataInfoData?.data?.attributes?.groups || [];
return ( return (
<ContentLayout title="Resources" icon="lucide:warehouse"> <ContentLayout title="Resources" icon={<Warehouse />}>
<FilterTransitionWrapper> <FilterTransitionWrapper>
<div className="mb-6"> <div className="mb-6">
<ResourcesFilters <ResourcesFilters
+2 -5
View File
@@ -1,5 +1,6 @@
import "@/styles/globals.css"; import "@/styles/globals.css";
import { X } from "lucide-react";
import React from "react"; import React from "react";
import { WorkflowAddEditRole } from "@/components/roles/workflow"; import { WorkflowAddEditRole } from "@/components/roles/workflow";
@@ -12,11 +13,7 @@ interface RoleLayoutProps {
export default function RoleLayout({ children }: RoleLayoutProps) { export default function RoleLayout({ children }: RoleLayoutProps) {
return ( return (
<> <>
<NavigationHeader <NavigationHeader title="Role Management" icon={<X />} href="/roles" />
title="Role Management"
icon="icon-park-outline:close-small"
href="/roles"
/>
<div className="h-16" /> <div className="h-16" />
<div className="grid grid-cols-1 gap-8 px-4 sm:px-6 lg:grid-cols-12 lg:px-0"> <div className="grid grid-cols-1 gap-8 px-4 sm:px-6 lg:grid-cols-12 lg:px-0">
<div className="order-1 my-auto hidden h-full lg:col-span-4 lg:col-start-2 lg:block"> <div className="order-1 my-auto hidden h-full lg:col-span-4 lg:col-start-2 lg:block">
+2 -1
View File
@@ -1,3 +1,4 @@
import { UserCog } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { Suspense } from "react"; import { Suspense } from "react";
@@ -18,7 +19,7 @@ export default async function Roles({
const searchParamsKey = JSON.stringify(resolvedSearchParams || {}); const searchParamsKey = JSON.stringify(resolvedSearchParams || {});
return ( return (
<ContentLayout title="Roles" icon="lucide:user-cog"> <ContentLayout title="Roles" icon={<UserCog />}>
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<div className="flex flex-row items-end justify-between"> <div className="flex flex-row items-end justify-between">
<DataTableFilterCustom <DataTableFilterCustom
+2 -1
View File
@@ -1,3 +1,4 @@
import { SlidersVertical } from "lucide-react";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { getProviders } from "@/actions/providers"; import { getProviders } from "@/actions/providers";
@@ -28,7 +29,7 @@ export default async function ScanConfigPage() {
const richProviders = providersResponse.data; const richProviders = providersResponse.data;
return ( return (
<ContentLayout title="Configuration" icon="lucide:sliders"> <ContentLayout title="Configuration" icon={<SlidersVertical />}>
<ScanConfigurationsManager <ScanConfigurationsManager
initialConfigs={configs} initialConfigs={configs}
richProviders={richProviders} richProviders={richProviders}
+2 -1
View File
@@ -1,3 +1,4 @@
import { Timer } from "lucide-react";
import { Suspense } from "react"; import { Suspense } from "react";
import { getAllProviderGroups } from "@/actions/manage-groups/manage-groups"; import { getAllProviderGroups } from "@/actions/manage-groups/manage-groups";
@@ -218,7 +219,7 @@ export default async function Scans({
return ( return (
<ContentLayout <ContentLayout
title="Scans" title="Scans"
icon="lucide:timer" icon={<Timer />}
onboardingAction={onboardingAction} onboardingAction={onboardingAction}
> >
<ScansPageShell <ScansPageShell
+3 -4
View File
@@ -1,13 +1,12 @@
import { Server } from "lucide-react";
import { FilterControls } from "@/components/filters"; import { FilterControls } from "@/components/filters";
import { ContentLayout } from "@/components/shadcn/content-layout"; import { ContentLayout } from "@/components/shadcn/content-layout";
export default async function Services() { export default async function Services() {
// const searchParamsKey = JSON.stringify(searchParams || {}); // const searchParamsKey = JSON.stringify(searchParams || {});
return ( return (
<ContentLayout <ContentLayout title="Services" icon={<Server />}>
title="Services"
icon="material-symbols:linked-services-outline"
>
<div className="h-4" /> <div className="h-4" />
<FilterControls /> <FilterControls />
<div className="h-4" /> <div className="h-4" />
+2 -1
View File
@@ -1,3 +1,4 @@
import { User } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { Suspense } from "react"; import { Suspense } from "react";
@@ -20,7 +21,7 @@ export default async function Users({
const searchParamsKey = JSON.stringify(resolvedSearchParams || {}); const searchParamsKey = JSON.stringify(resolvedSearchParams || {});
return ( return (
<ContentLayout title="Users" icon="lucide:user"> <ContentLayout title="Users" icon={<User />}>
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<div className="flex flex-row items-end justify-end"> <div className="flex flex-row items-end justify-end">
<Button asChild> <Button asChild>
+3 -1
View File
@@ -1,8 +1,10 @@
import { Tags } from "lucide-react";
import { ContentLayout } from "@/components/shadcn/content-layout"; import { ContentLayout } from "@/components/shadcn/content-layout";
export default async function Workloads() { export default async function Workloads() {
return ( return (
<ContentLayout title="Workloads" icon="lucide:tags"> <ContentLayout title="Workloads" icon={<Tags />}>
<p>Workloads</p> <p>Workloads</p>
</ContentLayout> </ContentLayout>
); );
@@ -0,0 +1 @@
Icons now ship in the UI bundle instead of being fetched from `api.iconify.design`, so pages render correctly in air-gapped deployments
+3 -4
View File
@@ -1,7 +1,7 @@
"use client"; "use client";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { Icon } from "@iconify/react"; import { KeyRound } from "lucide-react";
import { useRouter, useSearchParams } from "next/navigation"; import { useRouter, useSearchParams } from "next/navigation";
import { useEffect } from "react"; import { useEffect } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
@@ -241,10 +241,9 @@ export const SignInForm = ({
form.setValue("isSamlMode", true); form.setValue("isSamlMode", true);
}} }}
> >
<Icon <KeyRound
aria-hidden="true"
className="text-text-neutral-tertiary" className="text-text-neutral-tertiary"
icon="mdi:shield-key"
width={24}
/> />
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
+18 -8
View File
@@ -1,15 +1,8 @@
import { render, screen } from "@testing-library/react"; import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it } from "vitest";
import { SocialButtons } from "./social-buttons"; import { SocialButtons } from "./social-buttons";
// Stub Iconify: the real <Icon> fetches icon data over the network and its
// retry timers can fire after the jsdom environment is torn down, crashing the
// worker with "window is not defined".
vi.mock("@iconify/react", () => ({
Icon: ({ icon }: { icon: string }) => <span aria-label={icon} />,
}));
describe("SocialButtons", () => { describe("SocialButtons", () => {
it("renders icon-only provider links that keep their accessible names", () => { it("renders icon-only provider links that keep their accessible names", () => {
render( render(
@@ -28,6 +21,23 @@ describe("SocialButtons", () => {
expect(github.textContent).toBe(""); expect(github.textContent).toBe("");
}); });
it("renders bundled provider logos without fetching icon data", () => {
render(
<SocialButtons
googleAuthUrl="https://accounts.google.com/auth"
githubAuthUrl="https://github.com/login/oauth"
isGoogleOAuthEnabled
isGithubOAuthEnabled
/>,
);
const google = screen.getByRole("link", { name: "Continue with Google" });
const github = screen.getByRole("link", { name: "Continue with Github" });
expect(google.querySelector("svg path")).toBeInTheDocument();
expect(github.querySelector("svg path")).toBeInTheDocument();
});
it("keeps accessible names on disabled providers", () => { it("keeps accessible names on disabled providers", () => {
render(<SocialButtons />); render(<SocialButtons />);
+18 -16
View File
@@ -1,6 +1,10 @@
import { Icon } from "@iconify/react";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import {
GithubIcon,
GoogleIcon,
GoogleMonoIcon,
} from "@/components/icons/Icons";
import { import {
Button, Button,
Tooltip, Tooltip,
@@ -9,14 +13,15 @@ import {
} from "@/components/shadcn"; } from "@/components/shadcn";
import { CustomLink } from "@/components/shadcn/custom/custom-link"; import { CustomLink } from "@/components/shadcn/custom/custom-link";
import { appendCallbackState } from "@/lib/auth-callback-url"; import { appendCallbackState } from "@/lib/auth-callback-url";
import type { IconComponent } from "@/types/components";
type SocialProvider = { type SocialProvider = {
key: string; key: string;
label: string; label: string;
url?: string; url?: string;
isOAuthEnabled?: boolean; isOAuthEnabled?: boolean;
enabledIcon: string; enabledIcon: IconComponent;
disabledIcon: string; disabledIcon: IconComponent;
disabledDocs: { disabledDocs: {
message: string; message: string;
href: string; href: string;
@@ -42,18 +47,15 @@ const SocialButton = ({
> >
{isDisabled ? ( {isDisabled ? (
<span className="flex items-center justify-center"> <span className="flex items-center justify-center">
<Icon {provider.isOAuthEnabled ? (
icon={ <provider.enabledIcon aria-hidden="true" />
provider.isOAuthEnabled ) : (
? provider.enabledIcon <provider.disabledIcon aria-hidden="true" />
: provider.disabledIcon )}
}
width={24}
/>
</span> </span>
) : ( ) : (
<a href={provider.url} className="flex items-center justify-center"> <a href={provider.url} className="flex items-center justify-center">
<Icon icon={provider.enabledIcon} width={24} /> <provider.enabledIcon aria-hidden="true" />
</a> </a>
)} )}
</Button> </Button>
@@ -121,8 +123,8 @@ export const SocialButtons = ({
label: "Continue with Google", label: "Continue with Google",
url: googleUrl, url: googleUrl,
isOAuthEnabled: isGoogleOAuthEnabled, isOAuthEnabled: isGoogleOAuthEnabled,
enabledIcon: "flat-color-icons:google", enabledIcon: GoogleIcon,
disabledIcon: "simple-icons:google", disabledIcon: GoogleMonoIcon,
disabledDocs: { disabledDocs: {
message: "Social Login with Google is not enabled.", message: "Social Login with Google is not enabled.",
href: "https://docs.prowler.com/projects/prowler-open-source/en/latest/tutorials/prowler-app-social-login/#google-oauth-configuration", href: "https://docs.prowler.com/projects/prowler-open-source/en/latest/tutorials/prowler-app-social-login/#google-oauth-configuration",
@@ -133,8 +135,8 @@ export const SocialButtons = ({
label: "Continue with Github", label: "Continue with Github",
url: githubUrl, url: githubUrl,
isOAuthEnabled: isGithubOAuthEnabled, isOAuthEnabled: isGithubOAuthEnabled,
enabledIcon: "simple-icons:github", enabledIcon: GithubIcon,
disabledIcon: "simple-icons:github", disabledIcon: GithubIcon,
disabledDocs: { disabledDocs: {
message: "Social Login with Github is not enabled.", message: "Social Login with Github is not enabled.",
href: "https://docs.prowler.com/projects/prowler-open-source/en/latest/tutorials/prowler-app-social-login/#github-oauth-configuration", href: "https://docs.prowler.com/projects/prowler-open-source/en/latest/tutorials/prowler-app-social-login/#github-oauth-configuration",
@@ -1,6 +1,6 @@
"use client"; "use client";
import { Icon } from "@iconify/react"; import { Clock, RefreshCw } from "lucide-react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { Button } from "@/components/shadcn/button/button"; import { Button } from "@/components/shadcn/button/button";
@@ -16,8 +16,8 @@ export const ComplianceWarming = () => {
<CardContent> <CardContent>
<div className="flex w-full items-center justify-between gap-6"> <div className="flex w-full items-center justify-between gap-6">
<div className="flex items-start gap-4"> <div className="flex items-start gap-4">
<Icon <Clock
icon="tabler:clock" aria-hidden="true"
className="mt-1 h-5 w-5 text-gray-400 dark:text-gray-300" className="mt-1 h-5 w-5 text-gray-400 dark:text-gray-300"
/> />
<div> <div>
@@ -37,7 +37,7 @@ export const ComplianceWarming = () => {
onClick={() => router.refresh()} onClick={() => router.refresh()}
aria-label="Reload compliance data" aria-label="Reload compliance data"
> >
<Icon icon="tabler:refresh" className="h-4 w-4" /> <RefreshCw aria-hidden="true" className="h-4 w-4" />
Try Again Try Again
</Button> </Button>
</div> </div>
+104
View File
@@ -46,6 +46,110 @@ export const GithubIcon: React.FC<IconSvgProps> = ({
); );
}; };
// Multicolor Google "G" (flat-color-icons, MIT).
export const GoogleIcon: React.FC<IconSvgProps> = ({
size = 24,
width,
height,
...props
}) => {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 48 48"
width={size || width}
height={size || height}
{...props}
>
<path
fill="#FFC107"
d="M43.611 20.083H42V20H24v8h11.303c-1.649 4.657-6.08 8-11.303 8c-6.627 0-12-5.373-12-12s5.373-12 12-12c3.059 0 5.842 1.154 7.961 3.039l5.657-5.657C34.046 6.053 29.268 4 24 4C12.955 4 4 12.955 4 24s8.955 20 20 20s20-8.955 20-20c0-1.341-.138-2.65-.389-3.917"
/>
<path
fill="#FF3D00"
d="m6.306 14.691l6.571 4.819C14.655 15.108 18.961 12 24 12c3.059 0 5.842 1.154 7.961 3.039l5.657-5.657C34.046 6.053 29.268 4 24 4C16.318 4 9.656 8.337 6.306 14.691"
/>
<path
fill="#4CAF50"
d="M24 44c5.166 0 9.86-1.977 13.409-5.192l-6.19-5.238A11.9 11.9 0 0 1 24 36c-5.202 0-9.619-3.317-11.283-7.946l-6.522 5.025C9.505 39.556 16.227 44 24 44"
/>
<path
fill="#1976D2"
d="M43.611 20.083H42V20H24v8h11.303a12.04 12.04 0 0 1-4.087 5.571l.003-.002l6.19 5.238C36.971 39.205 44 34 44 24c0-1.341-.138-2.65-.389-3.917"
/>
</svg>
);
};
// Monochrome Google mark (simple-icons, CC0).
export const GoogleMonoIcon: React.FC<IconSvgProps> = ({
size = 24,
width,
height,
...props
}) => {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
width={size || width}
height={size || height}
{...props}
>
<path
fill="currentColor"
d="M12.48 10.92v3.28h7.84c-.24 1.84-.853 3.187-1.787 4.133c-1.147 1.147-2.933 2.4-6.053 2.4c-4.827 0-8.6-3.893-8.6-8.72s3.773-8.72 8.6-8.72c2.6 0 4.507 1.027 5.907 2.347l2.307-2.307C18.747 1.44 16.133 0 12.48 0C5.867 0 .307 5.387.307 12s5.56 12 12.173 12c3.573 0 6.267-1.173 8.373-3.36c2.16-2.16 2.84-5.213 2.84-7.667c0-.76-.053-1.467-.173-2.053z"
/>
</svg>
);
};
// OpenAI mark (simple-icons, CC0).
export const OpenAIIcon: React.FC<IconSvgProps> = ({
size = 24,
width,
height,
...props
}) => {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
width={size || width}
height={size || height}
{...props}
>
<path
fill="currentColor"
d="M22.282 9.821a6 6 0 0 0-.516-4.91a6.05 6.05 0 0 0-6.51-2.9A6.065 6.065 0 0 0 4.981 4.18a6 6 0 0 0-3.998 2.9a6.05 6.05 0 0 0 .743 7.097a5.98 5.98 0 0 0 .51 4.911a6.05 6.05 0 0 0 6.515 2.9A6 6 0 0 0 13.26 24a6.06 6.06 0 0 0 5.772-4.206a6 6 0 0 0 3.997-2.9a6.06 6.06 0 0 0-.747-7.073M13.26 22.43a4.48 4.48 0 0 1-2.876-1.04l.141-.081l4.779-2.758a.8.8 0 0 0 .392-.681v-6.737l2.02 1.168a.07.07 0 0 1 .038.052v5.583a4.504 4.504 0 0 1-4.494 4.494M3.6 18.304a4.47 4.47 0 0 1-.535-3.014l.142.085l4.783 2.759a.77.77 0 0 0 .78 0l5.843-3.369v2.332a.08.08 0 0 1-.033.062L9.74 19.95a4.5 4.5 0 0 1-6.14-1.646M2.34 7.896a4.5 4.5 0 0 1 2.366-1.973V11.6a.77.77 0 0 0 .388.677l5.815 3.354l-2.02 1.168a.08.08 0 0 1-.071 0l-4.83-2.786A4.504 4.504 0 0 1 2.34 7.872zm16.597 3.855l-5.833-3.387L15.119 7.2a.08.08 0 0 1 .071 0l4.83 2.791a4.494 4.494 0 0 1-.676 8.105v-5.678a.79.79 0 0 0-.407-.667m2.01-3.023l-.141-.085l-4.774-2.782a.78.78 0 0 0-.785 0L9.409 9.23V6.897a.07.07 0 0 1 .028-.061l4.83-2.787a4.5 4.5 0 0 1 6.68 4.66zm-12.64 4.135l-2.02-1.164a.08.08 0 0 1-.038-.057V6.075a4.5 4.5 0 0 1 7.375-3.453l-.142.08L8.704 5.46a.8.8 0 0 0-.393.681zm1.097-2.365l2.602-1.5l2.607 1.5v2.999l-2.597 1.5l-2.607-1.5Z"
/>
</svg>
);
};
// AWS wordmark (simple-icons, CC0).
export const AmazonWebServicesIcon: React.FC<IconSvgProps> = ({
size = 24,
width,
height,
...props
}) => {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
width={size || width}
height={size || height}
{...props}
>
<path
fill="currentColor"
d="M6.763 10.036q.002.446.088.71c.064.176.144.368.256.576c.04.063.056.127.056.183q.002.12-.152.24l-.503.335a.4.4 0 0 1-.208.072q-.12-.002-.239-.112a2.5 2.5 0 0 1-.287-.375a6 6 0 0 1-.248-.471q-.934 1.101-2.347 1.101c-.67 0-1.205-.191-1.596-.574q-.588-.575-.59-1.533c0-.678.239-1.23.726-1.644c.487-.415 1.133-.623 1.955-.623c.272 0 .551.024.846.064c.296.04.6.104.918.176v-.583q-.001-.909-.375-1.277c-.255-.248-.686-.367-1.3-.367c-.28 0-.568.031-.863.103q-.443.106-.862.272a2 2 0 0 1-.28.104a.5.5 0 0 1-.127.023q-.168.002-.168-.247v-.391c0-.128.016-.224.056-.28a.6.6 0 0 1 .224-.167a4.6 4.6 0 0 1 1.005-.36a4.8 4.8 0 0 1 1.246-.151c.95 0 1.644.216 2.091.647q.66.645.662 1.963v2.586zm-3.24 1.214c.263 0 .534-.048.822-.144a1.8 1.8 0 0 0 .758-.51a1.3 1.3 0 0 0 .272-.512c.047-.191.08-.423.08-.694v-.335a7 7 0 0 0-.735-.136a6 6 0 0 0-.75-.048c-.535 0-.926.104-1.19.32c-.263.215-.39.518-.39.917c0 .375.095.655.295.846c.191.2.47.296.838.296m6.41.862c-.144 0-.24-.024-.304-.08c-.064-.048-.12-.16-.168-.311L7.586 5.55a1.4 1.4 0 0 1-.072-.32c0-.128.064-.2.191-.2h.783q.227-.001.31.08c.065.048.113.16.16.312l1.342 5.284l1.245-5.284q.058-.24.151-.312a.55.55 0 0 1 .32-.08h.638c.152 0 .256.025.32.08c.063.048.12.16.151.312l1.261 5.348l1.381-5.348q.074-.24.16-.312a.52.52 0 0 1 .311-.08h.743c.127 0 .2.065.2.2c0 .04-.009.08-.017.128a1 1 0 0 1-.056.2l-1.923 6.17q-.072.24-.168.311a.5.5 0 0 1-.303.08h-.687c-.151 0-.255-.024-.32-.08c-.063-.056-.119-.16-.15-.32l-1.238-5.148l-1.23 5.14c-.04.16-.087.264-.15.32c-.065.056-.177.08-.32.08zm10.256.215c-.415 0-.83-.048-1.229-.143c-.399-.096-.71-.2-.918-.32c-.128-.071-.215-.151-.247-.223a.6.6 0 0 1-.048-.224v-.407c0-.167.064-.247.183-.247q.072 0 .144.024c.048.016.12.048.2.08q.408.181.878.279c.319.064.63.096.95.096c.502 0 .894-.088 1.165-.264a.86.86 0 0 0 .415-.758a.78.78 0 0 0-.215-.559c-.144-.151-.416-.287-.807-.415l-1.157-.36c-.583-.183-1.014-.454-1.277-.813a1.9 1.9 0 0 1-.4-1.158q0-.502.216-.886c.144-.255.335-.479.575-.654c.24-.184.51-.32.83-.415c.32-.096.655-.136 1.006-.136c.175 0 .359.008.535.032c.183.024.35.056.518.088q.24.058.455.127q.216.072.336.144a.7.7 0 0 1 .24.2a.43.43 0 0 1 .071.263v.375q-.002.254-.184.256a.8.8 0 0 1-.303-.096a3.65 3.65 0 0 0-1.532-.311c-.455 0-.815.071-1.062.223s-.375.383-.375.71c0 .224.08.416.24.567c.159.152.454.304.877.44l1.134.358c.574.184.99.44 1.237.767s.367.702.367 1.117c0 .343-.072.655-.207.926a2.2 2.2 0 0 1-.583.703c-.248.2-.543.343-.886.447c-.36.111-.734.167-1.142.167m1.509 3.88c-2.626 1.94-6.442 2.969-9.722 2.969c-4.598 0-8.74-1.7-11.87-4.526c-.247-.223-.024-.527.272-.351c3.384 1.963 7.559 3.153 11.877 3.153c2.914 0 6.114-.607 9.06-1.852c.439-.2.814.287.383.607m1.094-1.246c-.336-.43-2.22-.207-3.074-.103c-.255.032-.295-.192-.063-.36c1.5-1.053 3.967-.75 4.254-.399c.287.36-.08 2.826-1.485 4.007c-.215.184-.423.088-.327-.151c.32-.79 1.03-2.57.695-2.994"
/>
</svg>
);
};
export const MoonFilledIcon: React.FC<IconSvgProps> = ({ export const MoonFilledIcon: React.FC<IconSvgProps> = ({
size = 24, size = 24,
width, width,
@@ -2,7 +2,7 @@
import { BellRing, Info } from "lucide-react"; import { BellRing, Info } from "lucide-react";
import { usePathname, useRouter } from "next/navigation"; import { usePathname, useRouter } from "next/navigation";
import { ReactNode, Suspense } from "react"; import { ReactNode, Suspense, type ReactElement } from "react";
import { MobileAppSidebar } from "@/components/layout/app-sidebar"; import { MobileAppSidebar } from "@/components/layout/app-sidebar";
import { import {
@@ -31,7 +31,7 @@ export interface OnboardingActionConfig {
interface NavbarClientProps { interface NavbarClientProps {
title: string; title: string;
icon?: string | ReactNode; icon?: ReactElement;
onboardingAction?: OnboardingActionConfig; onboardingAction?: OnboardingActionConfig;
feedsSlot?: ReactNode; feedsSlot?: ReactNode;
} }
+2 -2
View File
@@ -1,4 +1,4 @@
import { ReactNode, Suspense } from "react"; import { Suspense, type ReactElement } from "react";
import { FeedsServer } from "@/components/feeds"; import { FeedsServer } from "@/components/feeds";
@@ -12,7 +12,7 @@ export type { OnboardingActionConfig };
interface NavbarProps { interface NavbarProps {
title: string; title: string;
icon?: string | ReactNode; icon?: ReactElement;
onboardingAction?: OnboardingActionConfig; onboardingAction?: OnboardingActionConfig;
} }
@@ -1,7 +1,7 @@
"use client"; "use client";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { Icon } from "@iconify/react"; import { RefreshCw } from "lucide-react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import * as z from "zod"; import * as z from "zod";
@@ -110,8 +110,8 @@ export const LighthouseSettings = () => {
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="flex items-center justify-center py-12"> <div className="flex items-center justify-center py-12">
<Icon <RefreshCw
icon="heroicons:arrow-path" aria-hidden="true"
className="h-8 w-8 animate-spin text-gray-400" className="h-8 w-8 animate-spin text-gray-400"
/> />
</div> </div>
@@ -1,5 +1,7 @@
"use client"; "use client";
import { AmazonWebServicesIcon, OpenAIIcon } from "@/components/icons/Icons";
import type { IconComponent } from "@/types/components";
import type { LighthouseProvider } from "@/types/lighthouse-v1"; import type { LighthouseProvider } from "@/types/lighthouse-v1";
export type LLMProviderFieldType = "text" | "password"; export type LLMProviderFieldType = "text" | "password";
@@ -17,7 +19,7 @@ export interface LLMProviderConfig {
id: LighthouseProvider; id: LighthouseProvider;
name: string; name: string;
description: string; description: string;
icon: string; icon: IconComponent;
fields: LLMProviderField[]; fields: LLMProviderField[];
} }
@@ -29,7 +31,7 @@ export const LLM_PROVIDER_REGISTRY: Record<
id: "openai", id: "openai",
name: "OpenAI", name: "OpenAI",
description: "Industry-leading GPT models for general-purpose AI", description: "Industry-leading GPT models for general-purpose AI",
icon: "simple-icons:openai", icon: OpenAIIcon,
fields: [ fields: [
{ {
name: "api_key", name: "api_key",
@@ -45,7 +47,7 @@ export const LLM_PROVIDER_REGISTRY: Record<
id: "bedrock", id: "bedrock",
name: "Amazon Bedrock", name: "Amazon Bedrock",
description: "AWS-managed AI with Claude, Llama, Titan & more", description: "AWS-managed AI with Claude, Llama, Titan & more",
icon: "simple-icons:amazonwebservices", icon: AmazonWebServicesIcon,
fields: [ fields: [
{ {
name: "access_key_id", name: "access_key_id",
@@ -77,7 +79,7 @@ export const LLM_PROVIDER_REGISTRY: Record<
id: "openai_compatible", id: "openai_compatible",
name: "OpenAI Compatible", name: "OpenAI Compatible",
description: "Connect to custom OpenAI-compatible endpoints", description: "Connect to custom OpenAI-compatible endpoints",
icon: "simple-icons:openai", icon: OpenAIIcon,
fields: [ fields: [
{ {
name: "api_key", name: "api_key",
@@ -1,6 +1,5 @@
"use client"; "use client";
import { Icon } from "@iconify/react";
import Link from "next/link"; import Link from "next/link";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
@@ -9,6 +8,7 @@ import {
getTenantConfig, getTenantConfig,
} from "@/actions/lighthouse-v1/lighthouse"; } from "@/actions/lighthouse-v1/lighthouse";
import { Button, Card, CardContent, CardHeader } from "@/components/shadcn"; import { Button, Card, CardContent, CardHeader } from "@/components/shadcn";
import type { IconComponent } from "@/types/components";
import { getAllProviders } from "./llm-provider-registry"; import { getAllProviders } from "./llm-provider-registry";
@@ -25,7 +25,7 @@ type LLMProvider = {
provider: string; provider: string;
description: string; description: string;
defaultModel: string; defaultModel: string;
icon: string; icon: IconComponent;
isConnected: boolean; isConnected: boolean;
isActive: boolean; isActive: boolean;
isDefaultProvider: boolean; isDefaultProvider: boolean;
@@ -156,7 +156,7 @@ export const LLMProvidersTable = () => {
{/* Header */} {/* Header */}
<CardHeader> <CardHeader>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Icon icon={provider.icon} width={40} height={40} /> <provider.icon aria-hidden="true" size={40} />
<div className="flex flex-1 flex-col"> <div className="flex flex-1 flex-col">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<h3 className="text-lg font-semibold"> <h3 className="text-lg font-semibold">
+11 -8
View File
@@ -1,6 +1,6 @@
"use client"; "use client";
import { Icon } from "@iconify/react"; import { RefreshCw, Search, Star } from "lucide-react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { import {
@@ -167,8 +167,8 @@ export const SelectModel = ({
className="text-text-neutral-secondary hover:bg-bg-neutral-tertiary flex items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium disabled:opacity-50" className="text-text-neutral-secondary hover:bg-bg-neutral-tertiary flex items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium disabled:opacity-50"
aria-label="Refresh models" aria-label="Refresh models"
> >
<Icon <RefreshCw
icon="heroicons:arrow-path" aria-hidden="true"
className={`h-5 w-5 ${isLoading ? "animate-spin" : ""}`} className={`h-5 w-5 ${isLoading ? "animate-spin" : ""}`}
/> />
<span>{isLoading ? "Refreshing..." : "Refresh"}</span> <span>{isLoading ? "Refreshing..." : "Refresh"}</span>
@@ -183,8 +183,8 @@ export const SelectModel = ({
{!isLoading && models.length > 0 && ( {!isLoading && models.length > 0 && (
<div className="relative"> <div className="relative">
<Icon <Search
icon="heroicons:magnifying-glass" aria-hidden="true"
className="text-text-neutral-tertiary pointer-events-none absolute top-1/2 left-3 h-5 w-5 -translate-y-1/2" className="text-text-neutral-tertiary pointer-events-none absolute top-1/2 left-3 h-5 w-5 -translate-y-1/2"
/> />
<input <input
@@ -199,8 +199,8 @@ export const SelectModel = ({
{isLoading ? ( {isLoading ? (
<div className="flex items-center justify-center py-12"> <div className="flex items-center justify-center py-12">
<Icon <RefreshCw
icon="heroicons:arrow-path" aria-hidden="true"
className="text-text-neutral-tertiary h-8 w-8 animate-spin" className="text-text-neutral-tertiary h-8 w-8 animate-spin"
/> />
</div> </div>
@@ -242,7 +242,10 @@ export const SelectModel = ({
<span className="text-sm font-medium">{model.name}</span> <span className="text-sm font-medium">{model.name}</span>
{isRecommended(model.id) && ( {isRecommended(model.id) && (
<span className="bg-bg-pass-secondary text-text-success-primary inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium"> <span className="bg-bg-pass-secondary text-text-success-primary inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium">
<Icon icon="heroicons:star-solid" className="h-3 w-3" /> <Star
aria-hidden="true"
className="h-3 w-3 fill-current"
/>
Recommended Recommended
</span> </span>
)} )}
@@ -1,6 +1,6 @@
"use client"; "use client";
import { Icon } from "@iconify/react"; import { Eye, EyeOff } from "lucide-react";
import { InputHTMLAttributes, useState } from "react"; import { InputHTMLAttributes, useState } from "react";
import { Control, FieldPath, FieldValues } from "react-hook-form"; import { Control, FieldPath, FieldValues } from "react-hook-form";
@@ -149,16 +149,19 @@ export const WizardInputField = <T extends FieldValues>({
: "Hide password" : "Hide password"
} }
> >
<Icon {(password && isPasswordVisible) ||
className="pointer-events-none text-xl" (confirmPassword && isConfirmPasswordVisible) ||
icon={ (type === "password" && isPasswordVisible) ? (
(password && isPasswordVisible) || <EyeOff
(confirmPassword && isConfirmPasswordVisible) || aria-hidden="true"
(type === "password" && isPasswordVisible) className="pointer-events-none size-5"
? "solar:eye-closed-linear" />
: "solar:eye-bold" ) : (
} <Eye
/> aria-hidden="true"
className="pointer-events-none size-5"
/>
)}
</button> </button>
)} )}
</div> </div>
@@ -1,8 +1,7 @@
"use client"; "use client";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { Icon } from "@iconify/react"; import { CircleAlert, Loader2 } from "lucide-react";
import { Loader2 } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
@@ -176,8 +175,7 @@ export const TestConnectionForm = ({
className="border-border-error flex items-start gap-4 rounded-lg border p-4" className="border-border-error flex items-start gap-4 rounded-lg border p-4"
> >
<div className="flex shrink-0 items-center"> <div className="flex shrink-0 items-center">
<Icon <CircleAlert
icon="heroicons:exclamation-circle"
className="text-text-error-primary h-5 w-5" className="text-text-error-primary h-5 w-5"
aria-hidden="true" aria-hidden="true"
/> />
@@ -9,7 +9,7 @@ import { Modal } from "@/components/shadcn/modal/modal";
interface RegistryAccessDialogCommonProps { interface RegistryAccessDialogCommonProps {
errorMessage?: string; errorMessage?: string;
registryKeyUrl?: string; registryUrl?: string;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
onSubmit: (key: string) => Promise<void>; onSubmit: (key: string) => Promise<void>;
open: boolean; open: boolean;
@@ -33,7 +33,7 @@ type RegistryAccessDialogProps =
export function RegistryAccessDialog({ export function RegistryAccessDialog({
errorMessage, errorMessage,
registryKeyUrl, registryUrl,
mode, mode,
onDisconnect, onDisconnect,
onOpenChange, onOpenChange,
@@ -112,18 +112,14 @@ export function RegistryAccessDialog({
{errorMessage} {errorMessage}
</p> </p>
)} )}
{registryKeyUrl && ( {registryUrl && (
<Button <Button
asChild asChild
className="self-start" className="self-start"
size="link-sm" size="link-sm"
variant="link" variant="link"
> >
<a <a href={registryUrl} rel="noopener noreferrer" target="_blank">
href={registryKeyUrl}
rel="noopener noreferrer"
target="_blank"
>
Where do I find my key? Where do I find my key?
</a> </a>
</Button> </Button>
@@ -7,6 +7,7 @@ import { Card } from "@/components/shadcn/card/card";
interface RegistryCredentialBannerProps { interface RegistryCredentialBannerProps {
connectButtonRef?: Ref<HTMLButtonElement>; connectButtonRef?: Ref<HTMLButtonElement>;
onConnect: () => void; onConnect: () => void;
registryUrl?: string;
tenantArtifactCount: number; tenantArtifactCount: number;
validationPending: boolean; validationPending: boolean;
} }
@@ -14,6 +15,7 @@ interface RegistryCredentialBannerProps {
export function RegistryCredentialBanner({ export function RegistryCredentialBanner({
connectButtonRef, connectButtonRef,
onConnect, onConnect,
registryUrl,
tenantArtifactCount, tenantArtifactCount,
validationPending, validationPending,
}: RegistryCredentialBannerProps) { }: RegistryCredentialBannerProps) {
@@ -46,16 +48,18 @@ export function RegistryCredentialBanner({
<Button onClick={onConnect} ref={connectButtonRef} type="button"> <Button onClick={onConnect} ref={connectButtonRef} type="button">
Connect API key Connect API key
</Button> </Button>
<Button asChild variant="outline"> {registryUrl && (
<a <Button asChild variant="outline">
aria-label="Explore Prowler Registry (opens in a new tab)" <a
href="https://registry.prowler.com" aria-label="Explore Prowler Registry (opens in a new tab)"
rel="noopener noreferrer" href={registryUrl}
target="_blank" rel="noopener noreferrer"
> target="_blank"
Explore Prowler Registry >
</a> Explore Prowler Registry
</Button> </a>
</Button>
)}
</div> </div>
</div> </div>
</div> </div>
@@ -752,10 +752,31 @@ describe("RegistryExplorer", () => {
expect(document.body.textContent).not.toContain( expect(document.body.textContent).not.toContain(
"preserved tenant artifact", "preserved tenant artifact",
); );
expect(document.body.textContent).toContain("Explore Prowler Registry"); expect(document.body.textContent).not.toContain(
"Explore Prowler Registry",
);
expect(document.body.textContent).not.toContain("Search artifacts"); expect(document.body.textContent).not.toContain("Search artifacts");
}); });
it("links the banner to the configured Registry", async () => {
// Given
const screen = await render(
<RegistryExplorer
initialState={onboardingState}
registryUrl="https://registry.internal.test/"
/>,
);
// Then
await expect
.element(
screen.getByRole("link", {
name: "Explore Prowler Registry (opens in a new tab)",
}),
)
.toHaveAttribute("href", "https://registry.internal.test/");
});
it("lets a replacement key supersede a pending validation from the banner", async () => { 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 // Given: a validation that never settled must not dead-end the user
submitRegistryCredentialMock.mockResolvedValue(submittedResult(true)); submitRegistryCredentialMock.mockResolvedValue(submittedResult(true));
@@ -825,7 +846,7 @@ describe("RegistryExplorer", () => {
const screen = await render( const screen = await render(
<RegistryExplorer <RegistryExplorer
initialState={onboardingState} initialState={onboardingState}
registryKeyUrl="https://registry.private.test/keys" registryUrl="https://registry.private.test/"
/>, />,
); );
@@ -840,7 +861,7 @@ describe("RegistryExplorer", () => {
.toBeVisible(); .toBeVisible();
await expect await expect
.element(screen.getByRole("link", { name: "Where do I find my key?" })) .element(screen.getByRole("link", { name: "Where do I find my key?" }))
.toHaveAttribute("href", "https://registry.private.test/keys"); .toHaveAttribute("href", "https://registry.private.test/");
// When // When
await screen.getByRole("button", { name: "Cancel", exact: true }).click(); await screen.getByRole("button", { name: "Cancel", exact: true }).click();
+4 -3
View File
@@ -107,12 +107,12 @@ function mutationFailureMessage(result: RegistryMutationResult) {
interface RegistryExplorerProps { interface RegistryExplorerProps {
initialState: RegistryBootstrapState; initialState: RegistryBootstrapState;
registryKeyUrl?: string; registryUrl?: string;
} }
export function RegistryExplorer({ export function RegistryExplorer({
initialState, initialState,
registryKeyUrl, registryUrl,
}: RegistryExplorerProps) { }: RegistryExplorerProps) {
// The API is the sole access authority: a denied action result routes to // The API is the sole access authority: a denied action result routes to
// Profile once, and the navigation unmounts this component with its state. // Profile once, and the navigation unmounts this component with its state.
@@ -464,7 +464,7 @@ export function RegistryExplorer({
} }
const accessDialogProps = { const accessDialogProps = {
registryKeyUrl, registryUrl,
errorMessage: operationMessage, errorMessage: operationMessage,
onOpenChange: (open: boolean) => { onOpenChange: (open: boolean) => {
if (!open && pendingOperation !== REGISTRY_PENDING_OPERATION.CREDENTIAL) { if (!open && pendingOperation !== REGISTRY_PENDING_OPERATION.CREDENTIAL) {
@@ -504,6 +504,7 @@ export function RegistryExplorer({
)} )}
<RegistryCredentialBanner <RegistryCredentialBanner
connectButtonRef={connectButtonRef} connectButtonRef={connectButtonRef}
registryUrl={registryUrl}
onConnect={() => onConnect={() =>
setAccessDialogMode(REGISTRY_ACCESS_DIALOG_MODE.CONNECT) setAccessDialogMode(REGISTRY_ACCESS_DIALOG_MODE.CONNECT)
} }
@@ -1,6 +1,6 @@
"use client"; "use client";
import { Icon } from "@iconify/react"; import type { ReactElement } from "react";
import { import {
Card, Card,
@@ -40,7 +40,7 @@ const COLOR_STYLES = {
} as const; } as const;
export type ActionCardProps = CardProps & { export type ActionCardProps = CardProps & {
icon: string; icon: ReactElement;
title: string; title: string;
color?: "success" | "secondary" | "warning" | "fail"; color?: "success" | "secondary" | "warning" | "fail";
description: string; description: string;
@@ -76,7 +76,12 @@ export const ActionCard = ({
colors.iconWrapper, colors.iconWrapper,
)} )}
> >
<Icon className={colors.icon} icon={icon} width={24} /> <span
aria-hidden="true"
className={cn("flex size-6 *:size-full", colors.icon)}
>
{icon}
</span>
</div> </div>
<div className="flex flex-col"> <div className="flex flex-col">
<p className="text-md">{title}</p> <p className="text-md">{title}</p>
@@ -12,10 +12,6 @@ vi.mock("next/navigation", () => ({
useSearchParams: () => new URLSearchParams(), useSearchParams: () => new URLSearchParams(),
})); }));
vi.mock("@iconify/react", () => ({
Icon: ({ icon }: { icon: string }) => <span aria-label={icon} />,
}));
describe("BreadcrumbNavigation", () => { describe("BreadcrumbNavigation", () => {
afterEach(() => { afterEach(() => {
navigationMock.pathname = "/findings"; navigationMock.pathname = "/findings";
@@ -40,22 +36,36 @@ describe("BreadcrumbNavigation", () => {
).toBeInTheDocument(); ).toBeInTheDocument();
}); });
it("does not render icons for secondary breadcrumb items", () => { it("renders the page icon next to a top-level title", () => {
// Given // Given / When
navigationMock.pathname = "/scans/config";
// When
render( render(
<BreadcrumbNavigation <BreadcrumbNavigation
mode="auto" mode="auto"
title="Configuration" title="Findings"
icon="lucide:sliders" icon={<svg data-testid="page-icon" />}
/>, />,
); );
// Then // Then
expect(screen.getByLabelText("lucide:timer")).toBeInTheDocument(); expect(screen.getByTestId("page-icon")).toBeInTheDocument();
expect(screen.queryByLabelText("lucide:sliders")).not.toBeInTheDocument(); });
it("shows the bundled section icon only on the first breadcrumb", () => {
// Given
navigationMock.pathname = "/scans/config";
// When
const { container } = render(
<BreadcrumbNavigation
mode="auto"
title="Configuration"
icon={<svg data-testid="page-icon" />}
/>,
);
// Then
expect(container.querySelector("svg.lucide-timer")).toBeInTheDocument();
expect(screen.queryByTestId("page-icon")).not.toBeInTheDocument();
expect( expect(
screen.getByRole("heading", { name: "Configuration" }), screen.getByRole("heading", { name: "Configuration" }),
).toBeInTheDocument(); ).toBeInTheDocument();
@@ -1,9 +1,23 @@
"use client"; "use client";
import { Icon } from "@iconify/react"; import {
BellRing,
Cloud,
Database,
GitBranch,
Key,
Layers,
Puzzle,
Search,
Server,
ShieldCheck,
Timer,
Users,
UsersRound,
} from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { usePathname, useSearchParams } from "next/navigation"; import { usePathname, useSearchParams } from "next/navigation";
import { ReactNode } from "react"; import type { ReactElement, ReactNode } from "react";
import { LighthouseIcon } from "@/components/icons/Icons"; import { LighthouseIcon } from "@/components/icons/Icons";
import { buildPerScanComplianceHref } from "@/lib/compliance/compliance-tab-url"; import { buildPerScanComplianceHref } from "@/lib/compliance/compliance-tab-url";
@@ -12,7 +26,7 @@ import { cn } from "@/lib/utils";
export interface CustomBreadcrumbItem { export interface CustomBreadcrumbItem {
name: string; name: string;
path?: string; path?: string;
icon?: string | ReactNode; icon?: ReactElement;
isLast?: boolean; isLast?: boolean;
isClickable?: boolean; isClickable?: boolean;
onClick?: () => void; onClick?: () => void;
@@ -21,7 +35,7 @@ export interface CustomBreadcrumbItem {
interface BreadcrumbNavigationProps { interface BreadcrumbNavigationProps {
mode?: "auto" | "custom" | "hybrid"; mode?: "auto" | "custom" | "hybrid";
title?: string; title?: string;
icon?: string | ReactNode; icon?: ReactElement;
titleAction?: ReactNode; titleAction?: ReactNode;
customItems?: CustomBreadcrumbItem[]; customItems?: CustomBreadcrumbItem[];
className?: string; className?: string;
@@ -43,21 +57,21 @@ export function BreadcrumbNavigation({
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const generateAutoBreadcrumbs = (): CustomBreadcrumbItem[] => { const generateAutoBreadcrumbs = (): CustomBreadcrumbItem[] => {
const pathIconMapping: Record<string, string | ReactNode> = { const pathIconMapping: Record<string, ReactElement> = {
"/integrations": "lucide:puzzle", "/integrations": <Puzzle />,
"/alerts": "lucide:bell-ring", "/alerts": <BellRing />,
"/providers": "lucide:cloud", "/providers": <Cloud />,
"/users": "lucide:users", "/users": <Users />,
"/compliance": "lucide:shield-check", "/compliance": <ShieldCheck />,
"/findings": "lucide:search", "/findings": <Search />,
"/scans": "lucide:timer", "/scans": <Timer />,
"/roles": "lucide:key", "/roles": <Key />,
"/resources": "lucide:database", "/resources": <Database />,
"/lighthouse": <LighthouseIcon />, "/lighthouse": <LighthouseIcon />,
"/manage-groups": "lucide:users-2", "/manage-groups": <UsersRound />,
"/services": "lucide:server", "/services": <Server />,
"/workloads": "lucide:layers", "/workloads": <Layers />,
"/attack-paths": "lucide:git-branch", "/attack-paths": <GitBranch />,
}; };
const pathSegments = pathname const pathSegments = pathname
@@ -116,15 +130,8 @@ export function BreadcrumbNavigation({
showIcon: boolean = true, showIcon: boolean = true,
) => ( ) => (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{showIcon && typeof icon === "string" ? ( {showIcon && icon ? (
<Icon <div className="text-text-neutral-primary flex shrink-0 items-center justify-center">
className="text-text-neutral-primary"
height={24}
icon={icon}
width={24}
/>
) : showIcon && icon ? (
<div className="flex h-8 w-8 items-center justify-center *:h-full *:w-full">
{icon} {icon}
</div> </div>
) : null} ) : null}
@@ -170,20 +177,10 @@ export function BreadcrumbNavigation({
href={buildNavigationUrl(breadcrumb.path)} href={buildNavigationUrl(breadcrumb.path)}
className="flex cursor-pointer items-center gap-2" className="flex cursor-pointer items-center gap-2"
> >
{index === 0 && {index === 0 && breadcrumb.icon ? (
breadcrumb.icon && <BreadcrumbIcon className="text-text-neutral-primary">
typeof breadcrumb.icon === "string" ? (
<Icon
aria-hidden="true"
className="text-text-neutral-primary"
height={24}
icon={breadcrumb.icon}
width={24}
/>
) : index === 0 && breadcrumb.icon ? (
<div className="flex h-6 w-6 items-center justify-center *:h-full *:w-full">
{breadcrumb.icon} {breadcrumb.icon}
</div> </BreadcrumbIcon>
) : null} ) : null}
<span className="text-text-neutral-primary hover:text-button-primary max-w-[150px] truncate text-sm font-bold transition-colors sm:max-w-none"> <span className="text-text-neutral-primary hover:text-button-primary max-w-[150px] truncate text-sm font-bold transition-colors sm:max-w-none">
{breadcrumb.name} {breadcrumb.name}
@@ -194,20 +191,10 @@ export function BreadcrumbNavigation({
onClick={breadcrumb.onClick} 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" 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 && {index === 0 && breadcrumb.icon ? (
breadcrumb.icon && <BreadcrumbIcon className="text-text-neutral-primary">
typeof breadcrumb.icon === "string" ? (
<Icon
aria-hidden="true"
className="text-text-neutral-primary"
height={24}
icon={breadcrumb.icon}
width={24}
/>
) : index === 0 && breadcrumb.icon ? (
<div className="flex h-6 w-6 items-center justify-center *:h-full *:w-full">
{breadcrumb.icon} {breadcrumb.icon}
</div> </BreadcrumbIcon>
) : null} ) : null}
<span className="max-w-[150px] truncate sm:max-w-none"> <span className="max-w-[150px] truncate sm:max-w-none">
{breadcrumb.name} {breadcrumb.name}
@@ -215,20 +202,10 @@ export function BreadcrumbNavigation({
</button> </button>
) : ( ) : (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{index === 0 && {index === 0 && breadcrumb.icon ? (
breadcrumb.icon && <BreadcrumbIcon className="text-text-neutral-tertiary">
typeof breadcrumb.icon === "string" ? (
<Icon
aria-hidden="true"
className="text-text-neutral-tertiary"
height={24}
icon={breadcrumb.icon}
width={24}
/>
) : index === 0 && breadcrumb.icon ? (
<div className="flex h-6 w-6 items-center justify-center *:h-full *:w-full">
{breadcrumb.icon} {breadcrumb.icon}
</div> </BreadcrumbIcon>
) : null} ) : null}
<span className="max-w-[150px] truncate text-sm font-medium text-gray-900 sm:max-w-none dark:text-gray-100"> <span className="max-w-[150px] truncate text-sm font-medium text-gray-900 sm:max-w-none dark:text-gray-100">
{breadcrumb.name} {breadcrumb.name}
@@ -250,3 +227,23 @@ export function BreadcrumbNavigation({
</div> </div>
); );
} }
function BreadcrumbIcon({
children,
className,
}: {
children: ReactNode;
className: string;
}) {
return (
<div
aria-hidden="true"
className={cn(
"flex h-6 w-6 items-center justify-center *:h-full *:w-full",
className,
)}
>
{children}
</div>
);
}
@@ -1,4 +1,4 @@
import { ReactNode } from "react"; import { type ReactElement } from "react";
import { import {
Navbar, Navbar,
@@ -7,7 +7,7 @@ import {
interface ContentLayoutProps { interface ContentLayoutProps {
title: string; title: string;
icon?: string | ReactNode; icon?: ReactElement;
onboardingAction?: OnboardingActionConfig; onboardingAction?: OnboardingActionConfig;
children: React.ReactNode; children: React.ReactNode;
} }
+13 -10
View File
@@ -1,6 +1,6 @@
"use client"; "use client";
import { Icon } from "@iconify/react"; import { Eye, EyeOff } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import { Control, FieldPath, FieldValues } from "react-hook-form"; import { Control, FieldPath, FieldValues } from "react-hook-form";
@@ -127,15 +127,18 @@ export const CustomInput = <T extends FieldValues>({
inputType === "password" ? "Show password" : "Hide password" inputType === "password" ? "Show password" : "Hide password"
} }
> >
<Icon {(password && isPasswordVisible) ||
className="text-text-neutral-tertiary pointer-events-none text-2xl" (confirmPassword && isConfirmPasswordVisible) ? (
icon={ <EyeOff
(password && isPasswordVisible) || aria-hidden="true"
(confirmPassword && isConfirmPasswordVisible) className="text-text-neutral-tertiary pointer-events-none size-6"
? "solar:eye-closed-linear" />
: "solar:eye-bold" ) : (
} <Eye
/> aria-hidden="true"
className="text-text-neutral-tertiary pointer-events-none size-6"
/>
)}
</button> </button>
)} )}
</div> </div>
@@ -1,12 +1,12 @@
import { Icon } from "@iconify/react";
import Link from "next/link"; import Link from "next/link";
import type { ReactElement } from "react";
import { Button } from "@/components/shadcn/button/button"; import { Button } from "@/components/shadcn/button/button";
import { Separator } from "@/components/shadcn/separator/separator"; import { Separator } from "@/components/shadcn/separator/separator";
interface NavigationHeaderProps { interface NavigationHeaderProps {
title: string; title: string;
icon: string; icon: ReactElement;
href?: string; href?: string;
} }
@@ -25,8 +25,8 @@ export const NavigationHeader = ({
size="icon" size="icon"
asChild asChild
> >
<Link href={href || ""}> <Link href={href || ""} className="text-text-neutral-secondary">
<Icon icon={icon} className="text-text-neutral-secondary" /> {icon}
</Link> </Link>
</Button> </Button>
<Separator orientation="vertical" className="h-6" /> <Separator orientation="vertical" className="h-6" />
-8
View File
@@ -663,14 +663,6 @@
"strategy": "installed", "strategy": "installed",
"generatedAt": "2025-10-22T12:36:37.962Z" "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", "section": "devDependencies",
"name": "@next/eslint-plugin-next", "name": "@next/eslint-plugin-next",
+15
View File
@@ -105,6 +105,21 @@ export default tseslint.config(
"security/detect-object-injection": "off", "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"], "eol-last": ["error", "always"],
"import-x/order": [ "import-x/order": [
+1 -1
View File
@@ -67,7 +67,7 @@ export function getCspHeader({
return ` return `
default-src 'self'; 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}; 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}; img-src 'self' https://www.google-analytics.com https://www.googletagmanager.com${registryImageOrigins.map((origin) => ` ${origin}`).join("")}${posthogSource}${toolbarUiSource};
font-src 'self'${toolbarPosthogSource}; font-src 'self'${toolbarPosthogSource};
style-src 'self' 'unsafe-inline'${toolbarPosthogSource}; style-src 'self' 'unsafe-inline'${toolbarPosthogSource};
+43 -6
View File
@@ -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", () => { describe("Registry presentation configuration", () => {
const urlWithCredentials = new URL("https://registry.test"); const urlWithCredentials = new URL("https://registry.test");
@@ -10,11 +13,11 @@ describe("Registry presentation configuration", () => {
it("uses the configured Registry and media origins", () => { it("uses the configured Registry and media origins", () => {
expect( expect(
getRegistryPresentation( getRegistryPresentation(
"https://registry.private.test/keys", "https://registry.private.test/",
"https://assets.private.test/media/", "https://assets.private.test/media/",
), ),
).toEqual({ ).toEqual({
keyUrl: "https://registry.private.test/keys", registryUrl: "https://registry.private.test/",
imageOrigins: [ imageOrigins: [
"https://registry.private.test", "https://registry.private.test",
"https://assets.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", () => { it("does not guess a Registry environment when configuration is missing", () => {
expect(getRegistryPresentation()).toEqual({ expect(getRegistryPresentation()).toEqual({
keyUrl: undefined, registryUrl: undefined,
imageOrigins: [], imageOrigins: [],
}); });
}); });
@@ -36,7 +39,41 @@ describe("Registry presentation configuration", () => {
"invalid", "invalid",
])("rejects unsafe configuration: %s", (value) => { ])("rejects unsafe configuration: %s", (value) => {
expect(getRegistryPresentation(value, value)).toEqual({ 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: [], imageOrigins: [],
}); });
}); });
+11 -1
View File
@@ -1,3 +1,5 @@
import { readEnv } from "@/lib/runtime-env";
/** Accept public HTTP URLs only; never expose URL credentials or CSP syntax. */ /** Accept public HTTP URLs only; never expose URL credentials or CSP syntax. */
function parsePublicUrl(value?: string | null): URL | undefined { function parsePublicUrl(value?: string | null): URL | undefined {
if (!value || /[\s;]/.test(value)) return; if (!value || /[\s;]/.test(value)) return;
@@ -21,7 +23,7 @@ export function getRegistryPresentation(
const registry = parsePublicUrl(registryUrl); const registry = parsePublicUrl(registryUrl);
const media = parsePublicUrl(mediaUrl); const media = parsePublicUrl(mediaUrl);
return { return {
keyUrl: registry?.href, registryUrl: registry?.href,
imageOrigins: Array.from( imageOrigins: Array.from(
new Set( new Set(
[registry?.origin, media?.origin].filter((origin): origin is string => [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"),
);
}
-3
View File
@@ -33,9 +33,6 @@ const BASELINE_CSP = {
], ],
"connect-src": [ "connect-src": [
"'self'", "'self'",
"https://api.iconify.design",
"https://api.simplesvg.com",
"https://api.unisvg.com",
"https://js.stripe.com", "https://js.stripe.com",
"https://www.googletagmanager.com", "https://www.googletagmanager.com",
"https://*.sentry.io", "https://*.sentry.io",
-1
View File
@@ -121,7 +121,6 @@
"zustand": "5.0.8" "zustand": "5.0.8"
}, },
"devDependencies": { "devDependencies": {
"@iconify/react": "5.2.1",
"@next/eslint-plugin-next": "16.2.9", "@next/eslint-plugin-next": "16.2.9",
"@playwright/test": "1.56.1", "@playwright/test": "1.56.1",
"@testing-library/jest-dom": "6.9.1", "@testing-library/jest-dom": "6.9.1",
+1 -1
View File
@@ -25,7 +25,7 @@ const registryFixtureUiServer = (
UI_API_BASE_URL: registryFixtureApiUrl, UI_API_BASE_URL: registryFixtureApiUrl,
UI_CLOUD_ENABLED: String(cloudEnabled), UI_CLOUD_ENABLED: String(cloudEnabled),
UI_REGISTRY_ENABLED: String(registryEnabled), 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", UI_REGISTRY_MEDIA_URL: "https://media.registry.dev.prowler.com",
CLOUD_BILLING_ENABLED: "false", CLOUD_BILLING_ENABLED: "false",
}, },
-13
View File
@@ -301,9 +301,6 @@ importers:
specifier: 5.0.8 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)) 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: devDependencies:
'@iconify/react':
specifier: 5.2.1
version: 5.2.1(react@19.2.7)
'@next/eslint-plugin-next': '@next/eslint-plugin-next':
specifier: 16.2.9 specifier: 16.2.9
version: 16.2.9 version: 16.2.9
@@ -1094,11 +1091,6 @@ packages:
resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==}
engines: {node: '>=18.18'} engines: {node: '>=18.18'}
'@iconify/react@5.2.1':
resolution: {integrity: sha512-37GDR3fYDZmnmUn9RagyaX+zca24jfVOMY8E1IXTqJuE8pxNtN51KWPQe3VODOWvuUurq7q9uUu3CFrpqj5Iqg==}
peerDependencies:
react: '>=16'
'@iconify/types@2.0.0': '@iconify/types@2.0.0':
resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==} resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==}
@@ -8308,11 +8300,6 @@ snapshots:
'@humanwhocodes/retry@0.4.3': {} '@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/types@2.0.0': {}
'@iconify/utils@3.1.0': '@iconify/utils@3.1.0':
+2 -5
View File
@@ -14,7 +14,7 @@ import {
} from "@/lib/integrations/slack-connect-status"; } from "@/lib/integrations/slack-connect-status";
import { REGISTRY_ACCESS } from "@/lib/registry/access"; import { REGISTRY_ACCESS } from "@/lib/registry/access";
import { evaluateRegistryAccess } from "@/lib/registry/access.server"; 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 { readEnv } from "@/lib/runtime-env";
import { isCloud } from "@/lib/shared/env"; import { isCloud } from "@/lib/shared/env";
import { copyAttributionParams } from "@/lib/utm"; import { copyAttributionParams } from "@/lib/utm";
@@ -38,10 +38,7 @@ const withSecurityHeaders = (response: NextResponse): NextResponse => {
"Content-Security-Policy", "Content-Security-Policy",
getCspHeader({ getCspHeader({
cloudEnabled: isCloud(), cloudEnabled: isCloud(),
registryImageOrigins: getRegistryPresentation( registryImageOrigins: readRegistryPresentation().imageOrigins,
readEnv("UI_REGISTRY_URL"),
readEnv("UI_REGISTRY_MEDIA_URL"),
).imageOrigins,
posthogEnabled: isGatedIntegrationEnabled(GATED_INTEGRATIONS.posthog), posthogEnabled: isGatedIntegrationEnabled(GATED_INTEGRATIONS.posthog),
posthogKey: readGatedEnv( posthogKey: readGatedEnv(
"UI_POSTHOG_ENABLED", "UI_POSTHOG_ENABLED",
+6 -6
View File
@@ -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: Set these runtime variables on the UI service to match the Registry used by the backend:
| Variable | Purpose | Development Example | | 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` | | `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` | | `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. 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.
+3
View File
@@ -31,6 +31,9 @@ declare global {
// Prowler Cloud deployment flag — runtime read (server env, client island). // Prowler Cloud deployment flag — runtime read (server env, client island).
UI_CLOUD_ENABLED?: "true" | "false"; UI_CLOUD_ENABLED?: "true" | "false";
UI_REGISTRY_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"; CLOUD_BILLING_ENABLED?: "legacy" | "metronome" | "false";
-1
View File
@@ -147,7 +147,6 @@ export default defineConfig(() => {
"next-themes", "next-themes",
// App component lib // App component lib
"@iconify/react",
"react-day-picker", "react-day-picker",
"posthog-js", "posthog-js",
"posthog-js/react", "posthog-js/react",