mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-10-03 17:54:05 +00:00
fix(ui): bundle all icons so the UI renders without internet access (#12892)
This commit is contained in:
@@ -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.
|
||||
|
||||
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.
|
||||
|
||||
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";
|
||||
|
||||
import { Icon } from "@iconify/react";
|
||||
import { Mail, TriangleAlert } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from "@/app/(auth)/invitation/_lib/invitation-errors";
|
||||
import { AuthBrand } from "@/components/auth/oss/auth-brand";
|
||||
import { Button } from "@/components/shadcn";
|
||||
import { Spinner } from "@/components/shadcn/spinner/spinner";
|
||||
|
||||
type AcceptState =
|
||||
| { kind: "no-token" }
|
||||
@@ -74,10 +75,9 @@ export function AcceptInvitationClient({
|
||||
{/* No token */}
|
||||
{state.kind === "no-token" && (
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<Icon
|
||||
icon="solar:danger-triangle-bold"
|
||||
className="text-text-warning-primary"
|
||||
width={48}
|
||||
<TriangleAlert
|
||||
aria-hidden="true"
|
||||
className="text-text-warning-primary size-12"
|
||||
/>
|
||||
<h1 className="text-xl font-semibold">Invalid Invitation Link</h1>
|
||||
<p className="text-text-neutral-tertiary">
|
||||
@@ -93,11 +93,7 @@ export function AcceptInvitationClient({
|
||||
{/* Accepting */}
|
||||
{state.kind === "accepting" && (
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<Icon
|
||||
icon="eos-icons:loading"
|
||||
className="text-text-neutral-tertiary"
|
||||
width={48}
|
||||
/>
|
||||
<Spinner className="size-12" />
|
||||
<h1 className="text-xl font-semibold">Accepting Invitation...</h1>
|
||||
<p className="text-text-neutral-tertiary">
|
||||
Please wait while we process your invitation.
|
||||
@@ -108,10 +104,9 @@ export function AcceptInvitationClient({
|
||||
{/* Error */}
|
||||
{state.kind === "error" && (
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<Icon
|
||||
icon="solar:danger-triangle-bold"
|
||||
className="text-text-error-primary"
|
||||
width={48}
|
||||
<TriangleAlert
|
||||
aria-hidden="true"
|
||||
className="text-text-error-primary size-12"
|
||||
/>
|
||||
<h1 className="text-xl font-semibold">
|
||||
Could Not Accept Invitation
|
||||
@@ -129,11 +124,7 @@ export function AcceptInvitationClient({
|
||||
{/* Choice page for unauthenticated users */}
|
||||
{state.kind === "choose" && (
|
||||
<div className="flex flex-col items-center gap-6">
|
||||
<Icon
|
||||
icon="solar:letter-bold"
|
||||
className="text-button-primary"
|
||||
width={48}
|
||||
/>
|
||||
<Mail aria-hidden="true" className="text-button-primary size-12" />
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold">
|
||||
You've Been Invited
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { BellRing } from "lucide-react";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { getLatestMetadataInfo } from "@/actions/findings";
|
||||
@@ -101,7 +102,7 @@ export default async function AlertsPage({ searchParams }: AlertsPageProps) {
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<ContentLayout title="Alerts" icon="lucide:bell-ring">
|
||||
<ContentLayout title="Alerts" icon={<BellRing />}>
|
||||
{!hasError ? (
|
||||
<AlertsLighthouseContext
|
||||
totalCount={apiMeta?.pagination?.count ?? alerts.length}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { GitBranch } from "lucide-react";
|
||||
|
||||
import { ContentLayout } from "@/components/shadcn/content-layout";
|
||||
|
||||
export default function AttackPathsLayout({
|
||||
@@ -8,7 +10,7 @@ export default function AttackPathsLayout({
|
||||
return (
|
||||
<ContentLayout
|
||||
title="Attack Paths"
|
||||
icon="lucide:git-branch"
|
||||
icon={<GitBranch />}
|
||||
onboardingAction={{ flowId: "attack-paths" }}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Info } from "lucide-react";
|
||||
import { Info, ShieldCheck } from "lucide-react";
|
||||
import { Suspense } from "react";
|
||||
|
||||
import {
|
||||
@@ -105,7 +105,7 @@ export default async function Compliance({
|
||||
return (
|
||||
<ContentLayout
|
||||
title="Compliance"
|
||||
icon="lucide:shield-check"
|
||||
icon={<ShieldCheck />}
|
||||
onboardingAction={
|
||||
hasCompletedScan
|
||||
? { flowId: "view-compliance" }
|
||||
@@ -172,7 +172,7 @@ export default async function Compliance({
|
||||
return (
|
||||
<ContentLayout
|
||||
title="Compliance"
|
||||
icon="lucide:shield-check"
|
||||
icon={<ShieldCheck />}
|
||||
onboardingAction={{
|
||||
flowId: "view-compliance",
|
||||
fallbackFlowId: "view-first-scan",
|
||||
@@ -323,7 +323,7 @@ export default async function Compliance({
|
||||
return (
|
||||
<ContentLayout
|
||||
title="Compliance"
|
||||
icon="lucide:shield-check"
|
||||
icon={<ShieldCheck />}
|
||||
onboardingAction={onboardingAction}
|
||||
>
|
||||
<CompliancePageTabs
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { Icon } from "@iconify/react";
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
import { RefreshCw, ServerOff, TriangleAlert } from "lucide-react";
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { Button } from "@/components/shadcn";
|
||||
@@ -84,10 +84,17 @@ export default function Error({
|
||||
<Card variant="base" className="w-full max-w-lg">
|
||||
<CardHeader>
|
||||
<div className="flex items-start gap-3">
|
||||
<Icon
|
||||
icon={is500Error ? "tabler:server-off" : "tabler:rocket-off"}
|
||||
className="mt-0.5 h-5 w-5 flex-shrink-0 text-red-500"
|
||||
/>
|
||||
{is500Error ? (
|
||||
<ServerOff
|
||||
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">
|
||||
<CardTitle className="text-lg">
|
||||
{is500Error
|
||||
@@ -105,7 +112,7 @@ export default function Error({
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-start gap-3">
|
||||
<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
|
||||
</Button>
|
||||
<CustomLink href="/" target="_self" className="font-bold">
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { Tag } from "lucide-react";
|
||||
|
||||
import { SkeletonTableFindings } from "@/components/findings/table";
|
||||
import { ContentLayout } from "@/components/shadcn/content-layout";
|
||||
|
||||
@@ -7,7 +9,7 @@ export default function FindingsLoading() {
|
||||
return (
|
||||
<ContentLayout
|
||||
title="Findings"
|
||||
icon="lucide:tag"
|
||||
icon={<Tag />}
|
||||
onboardingAction={{ flowId: "explore-findings" }}
|
||||
>
|
||||
<div className="mb-6">
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Tag } from "lucide-react";
|
||||
import { Suspense } from "react";
|
||||
|
||||
import {
|
||||
@@ -75,7 +76,7 @@ export default async function Findings({
|
||||
return (
|
||||
<ContentLayout
|
||||
title="Findings"
|
||||
icon="lucide:tag"
|
||||
icon={<Tag />}
|
||||
onboardingAction={onboardingAction}
|
||||
>
|
||||
<FilterTransitionWrapper>
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { Puzzle } from "lucide-react";
|
||||
|
||||
import { ContentLayout } from "@/components/shadcn/content-layout";
|
||||
|
||||
import { IntegrationsContent } from "./integrations-content";
|
||||
|
||||
export default async function Integrations() {
|
||||
return (
|
||||
<ContentLayout title="Integrations" icon="lucide:puzzle">
|
||||
<ContentLayout title="Integrations" icon={<Puzzle />}>
|
||||
<IntegrationsContent />
|
||||
</ContentLayout>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import "@/styles/globals.css";
|
||||
|
||||
import { X } from "lucide-react";
|
||||
import React from "react";
|
||||
|
||||
import { WorkflowSendInvite } from "@/components/invitations/workflow";
|
||||
@@ -14,7 +15,7 @@ export default function InvitationLayout({ children }: InvitationLayoutProps) {
|
||||
<>
|
||||
<NavigationHeader
|
||||
title="Send Invitation"
|
||||
icon="icon-park-outline:close-small"
|
||||
icon={<X />}
|
||||
href="/invitations"
|
||||
/>
|
||||
<div className="h-16" />
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Mail } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { Suspense } from "react";
|
||||
|
||||
@@ -22,7 +23,7 @@ export default async function Invitations({
|
||||
const searchParamsKey = JSON.stringify(resolvedSearchParams || {});
|
||||
|
||||
return (
|
||||
<ContentLayout title="Invitations" icon="lucide:mail">
|
||||
<ContentLayout title="Invitations" icon={<Mail />}>
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-row items-end justify-between">
|
||||
<DataTableFilterCustom
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { Icon } from "@iconify/react";
|
||||
|
||||
import {
|
||||
LIGHTHOUSE_V2_PROVIDER_TYPE,
|
||||
type LighthouseV2ProviderType,
|
||||
} from "@/app/(prowler)/lighthouse/_types";
|
||||
import { AmazonWebServicesIcon, OpenAIIcon } from "@/components/icons/Icons";
|
||||
import type { IconComponent } from "@/types/components";
|
||||
|
||||
const LIGHTHOUSE_V2_PROVIDER_ICONS = {
|
||||
[LIGHTHOUSE_V2_PROVIDER_TYPE.OPENAI]: "simple-icons:openai",
|
||||
[LIGHTHOUSE_V2_PROVIDER_TYPE.BEDROCK]: "simple-icons:amazonwebservices",
|
||||
[LIGHTHOUSE_V2_PROVIDER_TYPE.OPENAI_COMPATIBLE]: "simple-icons:openai",
|
||||
} as const satisfies Record<LighthouseV2ProviderType, string>;
|
||||
[LIGHTHOUSE_V2_PROVIDER_TYPE.OPENAI]: OpenAIIcon,
|
||||
[LIGHTHOUSE_V2_PROVIDER_TYPE.BEDROCK]: AmazonWebServicesIcon,
|
||||
[LIGHTHOUSE_V2_PROVIDER_TYPE.OPENAI_COMPATIBLE]: OpenAIIcon,
|
||||
} as const satisfies Record<LighthouseV2ProviderType, IconComponent>;
|
||||
|
||||
export function ProviderIcon({
|
||||
provider,
|
||||
@@ -18,11 +18,6 @@ export function ProviderIcon({
|
||||
provider: LighthouseV2ProviderType;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<Icon
|
||||
aria-hidden="true"
|
||||
className={className}
|
||||
icon={LIGHTHOUSE_V2_PROVIDER_ICONS[provider]}
|
||||
/>
|
||||
);
|
||||
const Icon = LIGHTHOUSE_V2_PROVIDER_ICONS[provider];
|
||||
return <Icon aria-hidden="true" className={className} />;
|
||||
}
|
||||
|
||||
@@ -56,7 +56,7 @@ export default async function AIChatbot({
|
||||
const chatRouteKey = validSessionId ?? initialPrompt ?? "new";
|
||||
|
||||
return (
|
||||
<ContentLayout title="Lighthouse AI" icon={<LighthouseIcon />}>
|
||||
<ContentLayout title="Lighthouse AI" icon={<LighthouseIcon size={32} />}>
|
||||
<AppSidebarModeSync mode={APP_SIDEBAR_MODE.CHAT} closeSidePanel />
|
||||
{/* [contain:layout] traps streamdown's fixed fullscreen overlay inside
|
||||
the chat area so it never covers the sidebar or navbar. */}
|
||||
@@ -91,7 +91,7 @@ export default async function AIChatbot({
|
||||
}
|
||||
|
||||
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">
|
||||
<Chat
|
||||
hasConfig={hasConfig}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import "@/styles/globals.css";
|
||||
|
||||
import { Icon } from "@iconify/react";
|
||||
import { Star, Trash2, X } from "lucide-react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import React, { useEffect, useState } from "react";
|
||||
|
||||
@@ -76,7 +76,7 @@ export default function ConnectLLMLayout({ children }: ConnectLLMLayoutProps) {
|
||||
|
||||
<NavigationHeader
|
||||
title={isEditMode ? "Configure LLM Provider" : "Connect LLM Provider"}
|
||||
icon="icon-park-outline:close-small"
|
||||
icon={<X />}
|
||||
href={LIGHTHOUSE_ROUTE.SETTINGS}
|
||||
/>
|
||||
<div className="h-8" />
|
||||
@@ -96,7 +96,7 @@ export default function ConnectLLMLayout({ children }: ConnectLLMLayoutProps) {
|
||||
onClick={handleSetDefault}
|
||||
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
|
||||
</Button>
|
||||
)}
|
||||
@@ -108,7 +108,7 @@ export default function ConnectLLMLayout({ children }: ConnectLLMLayoutProps) {
|
||||
onClick={() => setIsDeleteOpen(true)}
|
||||
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
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import "@/styles/globals.css";
|
||||
|
||||
import { Group } from "lucide-react";
|
||||
import React from "react";
|
||||
|
||||
import { ContentLayout } from "@/components/shadcn/content-layout";
|
||||
@@ -10,7 +11,7 @@ interface ProviderLayoutProps {
|
||||
|
||||
export default function ProviderLayout({ children }: ProviderLayoutProps) {
|
||||
return (
|
||||
<ContentLayout title="Manage Groups" icon="lucide:group">
|
||||
<ContentLayout title="Manage Groups" icon={<Group />}>
|
||||
{children}
|
||||
</ContentLayout>
|
||||
);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { VolumeX } from "lucide-react";
|
||||
import { Suspense } from "react";
|
||||
|
||||
import { ContentLayout } from "@/components/shadcn/content-layout";
|
||||
@@ -15,7 +16,7 @@ export default async function MutelistPage({
|
||||
const searchParamsKey = JSON.stringify(resolvedSearchParams);
|
||||
|
||||
return (
|
||||
<ContentLayout title="Mutelist" icon="lucide:volume-x">
|
||||
<ContentLayout title="Mutelist" icon={<VolumeX />}>
|
||||
<MutelistTabs
|
||||
simpleContent={
|
||||
<Suspense key={searchParamsKey} fallback={<MuteRulesTableSkeleton />}>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { SquareChartGantt } from "lucide-react";
|
||||
import { Suspense } from "react";
|
||||
|
||||
import { getAllProviderGroups } from "@/actions/manage-groups/manage-groups";
|
||||
@@ -59,7 +60,7 @@ export default async function Home({
|
||||
]);
|
||||
|
||||
return (
|
||||
<ContentLayout title="Overview" icon="lucide:square-chart-gantt">
|
||||
<ContentLayout title="Overview" icon={<SquareChartGantt />}>
|
||||
<AppSidebarModeSync mode={APP_SIDEBAR_MODE.BROWSE} />
|
||||
<OverviewProviderContext
|
||||
searchParams={resolvedSearchParams}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Users } from "lucide-react";
|
||||
import { Suspense } from "react";
|
||||
|
||||
import { getSamlConfig } from "@/actions/integrations/saml";
|
||||
@@ -26,7 +27,7 @@ export default async function Profile({
|
||||
const resolvedSearchParams = await searchParams;
|
||||
|
||||
return (
|
||||
<ContentLayout title="User Profile" icon="lucide:users">
|
||||
<ContentLayout title="User Profile" icon={<Users />}>
|
||||
<Suspense fallback={<SkeletonUserInfo />}>
|
||||
<SSRDataUser searchParams={resolvedSearchParams} />
|
||||
</Suspense>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { CloudCog } from "lucide-react";
|
||||
import { Suspense } from "react";
|
||||
|
||||
import { SkeletonTableProviders } from "@/components/providers/table";
|
||||
@@ -35,7 +36,7 @@ export default async function Providers({
|
||||
return (
|
||||
<ContentLayout
|
||||
title="Providers"
|
||||
icon="lucide:cloud-cog"
|
||||
icon={<CloudCog />}
|
||||
onboardingAction={{ flowId: "add-provider" }}
|
||||
>
|
||||
{isCloudEnvironment && <CliImportBanner className="mb-6" />}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { Package } from "lucide-react";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { getRegistryBootstrap } from "@/actions/registry/registry";
|
||||
import { RegistryExplorer } from "@/components/registry/registry-explorer";
|
||||
import { ContentLayout } from "@/components/shadcn/content-layout/content-layout";
|
||||
import { getRegistryPresentation } from "@/lib/registry/presentation";
|
||||
import { readEnv } from "@/lib/runtime-env";
|
||||
import { readRegistryPresentation } from "@/lib/registry/presentation";
|
||||
import { REGISTRY_FAILURE } from "@/types/registry";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -14,12 +14,10 @@ export default async function RegistryPage() {
|
||||
if (bootstrap.status === REGISTRY_FAILURE.ACCESS_DENIED) redirect("/profile");
|
||||
|
||||
return (
|
||||
<ContentLayout title="Registry" icon="lucide:package">
|
||||
<ContentLayout title="Registry" icon={<Package />}>
|
||||
<RegistryExplorer
|
||||
initialState={bootstrap.state}
|
||||
registryKeyUrl={
|
||||
getRegistryPresentation(readEnv("UI_REGISTRY_URL")).keyUrl
|
||||
}
|
||||
registryUrl={readRegistryPresentation().registryUrl}
|
||||
/>
|
||||
</ContentLayout>
|
||||
);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Warehouse } from "lucide-react";
|
||||
import { Suspense } from "react";
|
||||
|
||||
import { getAllProviderGroups } from "@/actions/manage-groups/manage-groups";
|
||||
@@ -80,7 +81,7 @@ export default async function Resources({
|
||||
const uniqueGroups = metadataInfoData?.data?.attributes?.groups || [];
|
||||
|
||||
return (
|
||||
<ContentLayout title="Resources" icon="lucide:warehouse">
|
||||
<ContentLayout title="Resources" icon={<Warehouse />}>
|
||||
<FilterTransitionWrapper>
|
||||
<div className="mb-6">
|
||||
<ResourcesFilters
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import "@/styles/globals.css";
|
||||
|
||||
import { X } from "lucide-react";
|
||||
import React from "react";
|
||||
|
||||
import { WorkflowAddEditRole } from "@/components/roles/workflow";
|
||||
@@ -12,11 +13,7 @@ interface RoleLayoutProps {
|
||||
export default function RoleLayout({ children }: RoleLayoutProps) {
|
||||
return (
|
||||
<>
|
||||
<NavigationHeader
|
||||
title="Role Management"
|
||||
icon="icon-park-outline:close-small"
|
||||
href="/roles"
|
||||
/>
|
||||
<NavigationHeader title="Role Management" icon={<X />} href="/roles" />
|
||||
<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="order-1 my-auto hidden h-full lg:col-span-4 lg:col-start-2 lg:block">
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { UserCog } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { Suspense } from "react";
|
||||
|
||||
@@ -18,7 +19,7 @@ export default async function Roles({
|
||||
const searchParamsKey = JSON.stringify(resolvedSearchParams || {});
|
||||
|
||||
return (
|
||||
<ContentLayout title="Roles" icon="lucide:user-cog">
|
||||
<ContentLayout title="Roles" icon={<UserCog />}>
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-row items-end justify-between">
|
||||
<DataTableFilterCustom
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { SlidersVertical } from "lucide-react";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { getProviders } from "@/actions/providers";
|
||||
@@ -28,7 +29,7 @@ export default async function ScanConfigPage() {
|
||||
const richProviders = providersResponse.data;
|
||||
|
||||
return (
|
||||
<ContentLayout title="Configuration" icon="lucide:sliders">
|
||||
<ContentLayout title="Configuration" icon={<SlidersVertical />}>
|
||||
<ScanConfigurationsManager
|
||||
initialConfigs={configs}
|
||||
richProviders={richProviders}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Timer } from "lucide-react";
|
||||
import { Suspense } from "react";
|
||||
|
||||
import { getAllProviderGroups } from "@/actions/manage-groups/manage-groups";
|
||||
@@ -218,7 +219,7 @@ export default async function Scans({
|
||||
return (
|
||||
<ContentLayout
|
||||
title="Scans"
|
||||
icon="lucide:timer"
|
||||
icon={<Timer />}
|
||||
onboardingAction={onboardingAction}
|
||||
>
|
||||
<ScansPageShell
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import { Server } from "lucide-react";
|
||||
|
||||
import { FilterControls } from "@/components/filters";
|
||||
import { ContentLayout } from "@/components/shadcn/content-layout";
|
||||
|
||||
export default async function Services() {
|
||||
// const searchParamsKey = JSON.stringify(searchParams || {});
|
||||
return (
|
||||
<ContentLayout
|
||||
title="Services"
|
||||
icon="material-symbols:linked-services-outline"
|
||||
>
|
||||
<ContentLayout title="Services" icon={<Server />}>
|
||||
<div className="h-4" />
|
||||
<FilterControls />
|
||||
<div className="h-4" />
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { User } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { Suspense } from "react";
|
||||
|
||||
@@ -20,7 +21,7 @@ export default async function Users({
|
||||
const searchParamsKey = JSON.stringify(resolvedSearchParams || {});
|
||||
|
||||
return (
|
||||
<ContentLayout title="Users" icon="lucide:user">
|
||||
<ContentLayout title="Users" icon={<User />}>
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-row items-end justify-end">
|
||||
<Button asChild>
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Tags } from "lucide-react";
|
||||
|
||||
import { ContentLayout } from "@/components/shadcn/content-layout";
|
||||
|
||||
export default async function Workloads() {
|
||||
return (
|
||||
<ContentLayout title="Workloads" icon="lucide:tags">
|
||||
<ContentLayout title="Workloads" icon={<Tags />}>
|
||||
<p>Workloads</p>
|
||||
</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
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Icon } from "@iconify/react";
|
||||
import { KeyRound } from "lucide-react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
@@ -241,10 +241,9 @@ export const SignInForm = ({
|
||||
form.setValue("isSamlMode", true);
|
||||
}}
|
||||
>
|
||||
<Icon
|
||||
<KeyRound
|
||||
aria-hidden="true"
|
||||
className="text-text-neutral-tertiary"
|
||||
icon="mdi:shield-key"
|
||||
width={24}
|
||||
/>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
|
||||
@@ -1,15 +1,8 @@
|
||||
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";
|
||||
|
||||
// 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", () => {
|
||||
it("renders icon-only provider links that keep their accessible names", () => {
|
||||
render(
|
||||
@@ -28,6 +21,23 @@ describe("SocialButtons", () => {
|
||||
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", () => {
|
||||
render(<SocialButtons />);
|
||||
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { Icon } from "@iconify/react";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import {
|
||||
GithubIcon,
|
||||
GoogleIcon,
|
||||
GoogleMonoIcon,
|
||||
} from "@/components/icons/Icons";
|
||||
import {
|
||||
Button,
|
||||
Tooltip,
|
||||
@@ -9,14 +13,15 @@ import {
|
||||
} from "@/components/shadcn";
|
||||
import { CustomLink } from "@/components/shadcn/custom/custom-link";
|
||||
import { appendCallbackState } from "@/lib/auth-callback-url";
|
||||
import type { IconComponent } from "@/types/components";
|
||||
|
||||
type SocialProvider = {
|
||||
key: string;
|
||||
label: string;
|
||||
url?: string;
|
||||
isOAuthEnabled?: boolean;
|
||||
enabledIcon: string;
|
||||
disabledIcon: string;
|
||||
enabledIcon: IconComponent;
|
||||
disabledIcon: IconComponent;
|
||||
disabledDocs: {
|
||||
message: string;
|
||||
href: string;
|
||||
@@ -42,18 +47,15 @@ const SocialButton = ({
|
||||
>
|
||||
{isDisabled ? (
|
||||
<span className="flex items-center justify-center">
|
||||
<Icon
|
||||
icon={
|
||||
provider.isOAuthEnabled
|
||||
? provider.enabledIcon
|
||||
: provider.disabledIcon
|
||||
}
|
||||
width={24}
|
||||
/>
|
||||
{provider.isOAuthEnabled ? (
|
||||
<provider.enabledIcon aria-hidden="true" />
|
||||
) : (
|
||||
<provider.disabledIcon aria-hidden="true" />
|
||||
)}
|
||||
</span>
|
||||
) : (
|
||||
<a href={provider.url} className="flex items-center justify-center">
|
||||
<Icon icon={provider.enabledIcon} width={24} />
|
||||
<provider.enabledIcon aria-hidden="true" />
|
||||
</a>
|
||||
)}
|
||||
</Button>
|
||||
@@ -121,8 +123,8 @@ export const SocialButtons = ({
|
||||
label: "Continue with Google",
|
||||
url: googleUrl,
|
||||
isOAuthEnabled: isGoogleOAuthEnabled,
|
||||
enabledIcon: "flat-color-icons:google",
|
||||
disabledIcon: "simple-icons:google",
|
||||
enabledIcon: GoogleIcon,
|
||||
disabledIcon: GoogleMonoIcon,
|
||||
disabledDocs: {
|
||||
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",
|
||||
@@ -133,8 +135,8 @@ export const SocialButtons = ({
|
||||
label: "Continue with Github",
|
||||
url: githubUrl,
|
||||
isOAuthEnabled: isGithubOAuthEnabled,
|
||||
enabledIcon: "simple-icons:github",
|
||||
disabledIcon: "simple-icons:github",
|
||||
enabledIcon: GithubIcon,
|
||||
disabledIcon: GithubIcon,
|
||||
disabledDocs: {
|
||||
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",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { Icon } from "@iconify/react";
|
||||
import { Clock, RefreshCw } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import { Button } from "@/components/shadcn/button/button";
|
||||
@@ -16,8 +16,8 @@ export const ComplianceWarming = () => {
|
||||
<CardContent>
|
||||
<div className="flex w-full items-center justify-between gap-6">
|
||||
<div className="flex items-start gap-4">
|
||||
<Icon
|
||||
icon="tabler:clock"
|
||||
<Clock
|
||||
aria-hidden="true"
|
||||
className="mt-1 h-5 w-5 text-gray-400 dark:text-gray-300"
|
||||
/>
|
||||
<div>
|
||||
@@ -37,7 +37,7 @@ export const ComplianceWarming = () => {
|
||||
onClick={() => router.refresh()}
|
||||
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
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -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> = ({
|
||||
size = 24,
|
||||
width,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { BellRing, Info } from "lucide-react";
|
||||
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 {
|
||||
@@ -31,7 +31,7 @@ export interface OnboardingActionConfig {
|
||||
|
||||
interface NavbarClientProps {
|
||||
title: string;
|
||||
icon?: string | ReactNode;
|
||||
icon?: ReactElement;
|
||||
onboardingAction?: OnboardingActionConfig;
|
||||
feedsSlot?: ReactNode;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ReactNode, Suspense } from "react";
|
||||
import { Suspense, type ReactElement } from "react";
|
||||
|
||||
import { FeedsServer } from "@/components/feeds";
|
||||
|
||||
@@ -12,7 +12,7 @@ export type { OnboardingActionConfig };
|
||||
|
||||
interface NavbarProps {
|
||||
title: string;
|
||||
icon?: string | ReactNode;
|
||||
icon?: ReactElement;
|
||||
onboardingAction?: OnboardingActionConfig;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Icon } from "@iconify/react";
|
||||
import { RefreshCw } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import * as z from "zod";
|
||||
@@ -110,8 +110,8 @@ export const LighthouseSettings = () => {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Icon
|
||||
icon="heroicons:arrow-path"
|
||||
<RefreshCw
|
||||
aria-hidden="true"
|
||||
className="h-8 w-8 animate-spin text-gray-400"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { AmazonWebServicesIcon, OpenAIIcon } from "@/components/icons/Icons";
|
||||
import type { IconComponent } from "@/types/components";
|
||||
import type { LighthouseProvider } from "@/types/lighthouse-v1";
|
||||
|
||||
export type LLMProviderFieldType = "text" | "password";
|
||||
@@ -17,7 +19,7 @@ export interface LLMProviderConfig {
|
||||
id: LighthouseProvider;
|
||||
name: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
icon: IconComponent;
|
||||
fields: LLMProviderField[];
|
||||
}
|
||||
|
||||
@@ -29,7 +31,7 @@ export const LLM_PROVIDER_REGISTRY: Record<
|
||||
id: "openai",
|
||||
name: "OpenAI",
|
||||
description: "Industry-leading GPT models for general-purpose AI",
|
||||
icon: "simple-icons:openai",
|
||||
icon: OpenAIIcon,
|
||||
fields: [
|
||||
{
|
||||
name: "api_key",
|
||||
@@ -45,7 +47,7 @@ export const LLM_PROVIDER_REGISTRY: Record<
|
||||
id: "bedrock",
|
||||
name: "Amazon Bedrock",
|
||||
description: "AWS-managed AI with Claude, Llama, Titan & more",
|
||||
icon: "simple-icons:amazonwebservices",
|
||||
icon: AmazonWebServicesIcon,
|
||||
fields: [
|
||||
{
|
||||
name: "access_key_id",
|
||||
@@ -77,7 +79,7 @@ export const LLM_PROVIDER_REGISTRY: Record<
|
||||
id: "openai_compatible",
|
||||
name: "OpenAI Compatible",
|
||||
description: "Connect to custom OpenAI-compatible endpoints",
|
||||
icon: "simple-icons:openai",
|
||||
icon: OpenAIIcon,
|
||||
fields: [
|
||||
{
|
||||
name: "api_key",
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { Icon } from "@iconify/react";
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
@@ -9,6 +8,7 @@ import {
|
||||
getTenantConfig,
|
||||
} from "@/actions/lighthouse-v1/lighthouse";
|
||||
import { Button, Card, CardContent, CardHeader } from "@/components/shadcn";
|
||||
import type { IconComponent } from "@/types/components";
|
||||
|
||||
import { getAllProviders } from "./llm-provider-registry";
|
||||
|
||||
@@ -25,7 +25,7 @@ type LLMProvider = {
|
||||
provider: string;
|
||||
description: string;
|
||||
defaultModel: string;
|
||||
icon: string;
|
||||
icon: IconComponent;
|
||||
isConnected: boolean;
|
||||
isActive: boolean;
|
||||
isDefaultProvider: boolean;
|
||||
@@ -156,7 +156,7 @@ export const LLMProvidersTable = () => {
|
||||
{/* Header */}
|
||||
<CardHeader>
|
||||
<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 items-center gap-2">
|
||||
<h3 className="text-lg font-semibold">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { Icon } from "@iconify/react";
|
||||
import { RefreshCw, Search, Star } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
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"
|
||||
aria-label="Refresh models"
|
||||
>
|
||||
<Icon
|
||||
icon="heroicons:arrow-path"
|
||||
<RefreshCw
|
||||
aria-hidden="true"
|
||||
className={`h-5 w-5 ${isLoading ? "animate-spin" : ""}`}
|
||||
/>
|
||||
<span>{isLoading ? "Refreshing..." : "Refresh"}</span>
|
||||
@@ -183,8 +183,8 @@ export const SelectModel = ({
|
||||
|
||||
{!isLoading && models.length > 0 && (
|
||||
<div className="relative">
|
||||
<Icon
|
||||
icon="heroicons:magnifying-glass"
|
||||
<Search
|
||||
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"
|
||||
/>
|
||||
<input
|
||||
@@ -199,8 +199,8 @@ export const SelectModel = ({
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Icon
|
||||
icon="heroicons:arrow-path"
|
||||
<RefreshCw
|
||||
aria-hidden="true"
|
||||
className="text-text-neutral-tertiary h-8 w-8 animate-spin"
|
||||
/>
|
||||
</div>
|
||||
@@ -242,7 +242,10 @@ export const SelectModel = ({
|
||||
<span className="text-sm font-medium">{model.name}</span>
|
||||
{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">
|
||||
<Icon icon="heroicons:star-solid" className="h-3 w-3" />
|
||||
<Star
|
||||
aria-hidden="true"
|
||||
className="h-3 w-3 fill-current"
|
||||
/>
|
||||
Recommended
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { Icon } from "@iconify/react";
|
||||
import { Eye, EyeOff } from "lucide-react";
|
||||
import { InputHTMLAttributes, useState } from "react";
|
||||
import { Control, FieldPath, FieldValues } from "react-hook-form";
|
||||
|
||||
@@ -149,16 +149,19 @@ export const WizardInputField = <T extends FieldValues>({
|
||||
: "Hide password"
|
||||
}
|
||||
>
|
||||
<Icon
|
||||
className="pointer-events-none text-xl"
|
||||
icon={
|
||||
(password && isPasswordVisible) ||
|
||||
(confirmPassword && isConfirmPasswordVisible) ||
|
||||
(type === "password" && isPasswordVisible)
|
||||
? "solar:eye-closed-linear"
|
||||
: "solar:eye-bold"
|
||||
}
|
||||
/>
|
||||
{(password && isPasswordVisible) ||
|
||||
(confirmPassword && isConfirmPasswordVisible) ||
|
||||
(type === "password" && isPasswordVisible) ? (
|
||||
<EyeOff
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none size-5"
|
||||
/>
|
||||
) : (
|
||||
<Eye
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none size-5"
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Icon } from "@iconify/react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { CircleAlert, Loader2 } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
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"
|
||||
>
|
||||
<div className="flex shrink-0 items-center">
|
||||
<Icon
|
||||
icon="heroicons:exclamation-circle"
|
||||
<CircleAlert
|
||||
className="text-text-error-primary h-5 w-5"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
@@ -9,7 +9,7 @@ import { Modal } from "@/components/shadcn/modal/modal";
|
||||
|
||||
interface RegistryAccessDialogCommonProps {
|
||||
errorMessage?: string;
|
||||
registryKeyUrl?: string;
|
||||
registryUrl?: string;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSubmit: (key: string) => Promise<void>;
|
||||
open: boolean;
|
||||
@@ -33,7 +33,7 @@ type RegistryAccessDialogProps =
|
||||
|
||||
export function RegistryAccessDialog({
|
||||
errorMessage,
|
||||
registryKeyUrl,
|
||||
registryUrl,
|
||||
mode,
|
||||
onDisconnect,
|
||||
onOpenChange,
|
||||
@@ -112,18 +112,14 @@ export function RegistryAccessDialog({
|
||||
{errorMessage}
|
||||
</p>
|
||||
)}
|
||||
{registryKeyUrl && (
|
||||
{registryUrl && (
|
||||
<Button
|
||||
asChild
|
||||
className="self-start"
|
||||
size="link-sm"
|
||||
variant="link"
|
||||
>
|
||||
<a
|
||||
href={registryKeyUrl}
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
<a href={registryUrl} rel="noopener noreferrer" target="_blank">
|
||||
Where do I find my key?
|
||||
</a>
|
||||
</Button>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Card } from "@/components/shadcn/card/card";
|
||||
interface RegistryCredentialBannerProps {
|
||||
connectButtonRef?: Ref<HTMLButtonElement>;
|
||||
onConnect: () => void;
|
||||
registryUrl?: string;
|
||||
tenantArtifactCount: number;
|
||||
validationPending: boolean;
|
||||
}
|
||||
@@ -14,6 +15,7 @@ interface RegistryCredentialBannerProps {
|
||||
export function RegistryCredentialBanner({
|
||||
connectButtonRef,
|
||||
onConnect,
|
||||
registryUrl,
|
||||
tenantArtifactCount,
|
||||
validationPending,
|
||||
}: RegistryCredentialBannerProps) {
|
||||
@@ -46,16 +48,18 @@ export function RegistryCredentialBanner({
|
||||
<Button onClick={onConnect} ref={connectButtonRef} type="button">
|
||||
Connect API key
|
||||
</Button>
|
||||
<Button asChild variant="outline">
|
||||
<a
|
||||
aria-label="Explore Prowler Registry (opens in a new tab)"
|
||||
href="https://registry.prowler.com"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
Explore Prowler Registry
|
||||
</a>
|
||||
</Button>
|
||||
{registryUrl && (
|
||||
<Button asChild variant="outline">
|
||||
<a
|
||||
aria-label="Explore Prowler Registry (opens in a new tab)"
|
||||
href={registryUrl}
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
Explore Prowler Registry
|
||||
</a>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -752,10 +752,31 @@ describe("RegistryExplorer", () => {
|
||||
expect(document.body.textContent).not.toContain(
|
||||
"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");
|
||||
});
|
||||
|
||||
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 () => {
|
||||
// Given: a validation that never settled must not dead-end the user
|
||||
submitRegistryCredentialMock.mockResolvedValue(submittedResult(true));
|
||||
@@ -825,7 +846,7 @@ describe("RegistryExplorer", () => {
|
||||
const screen = await render(
|
||||
<RegistryExplorer
|
||||
initialState={onboardingState}
|
||||
registryKeyUrl="https://registry.private.test/keys"
|
||||
registryUrl="https://registry.private.test/"
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -840,7 +861,7 @@ describe("RegistryExplorer", () => {
|
||||
.toBeVisible();
|
||||
await expect
|
||||
.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
|
||||
await screen.getByRole("button", { name: "Cancel", exact: true }).click();
|
||||
|
||||
@@ -107,12 +107,12 @@ function mutationFailureMessage(result: RegistryMutationResult) {
|
||||
|
||||
interface RegistryExplorerProps {
|
||||
initialState: RegistryBootstrapState;
|
||||
registryKeyUrl?: string;
|
||||
registryUrl?: string;
|
||||
}
|
||||
|
||||
export function RegistryExplorer({
|
||||
initialState,
|
||||
registryKeyUrl,
|
||||
registryUrl,
|
||||
}: RegistryExplorerProps) {
|
||||
// The API is the sole access authority: a denied action result routes to
|
||||
// Profile once, and the navigation unmounts this component with its state.
|
||||
@@ -464,7 +464,7 @@ export function RegistryExplorer({
|
||||
}
|
||||
|
||||
const accessDialogProps = {
|
||||
registryKeyUrl,
|
||||
registryUrl,
|
||||
errorMessage: operationMessage,
|
||||
onOpenChange: (open: boolean) => {
|
||||
if (!open && pendingOperation !== REGISTRY_PENDING_OPERATION.CREDENTIAL) {
|
||||
@@ -504,6 +504,7 @@ export function RegistryExplorer({
|
||||
)}
|
||||
<RegistryCredentialBanner
|
||||
connectButtonRef={connectButtonRef}
|
||||
registryUrl={registryUrl}
|
||||
onConnect={() =>
|
||||
setAccessDialogMode(REGISTRY_ACCESS_DIALOG_MODE.CONNECT)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { Icon } from "@iconify/react";
|
||||
import type { ReactElement } from "react";
|
||||
|
||||
import {
|
||||
Card,
|
||||
@@ -40,7 +40,7 @@ const COLOR_STYLES = {
|
||||
} as const;
|
||||
|
||||
export type ActionCardProps = CardProps & {
|
||||
icon: string;
|
||||
icon: ReactElement;
|
||||
title: string;
|
||||
color?: "success" | "secondary" | "warning" | "fail";
|
||||
description: string;
|
||||
@@ -76,7 +76,12 @@ export const ActionCard = ({
|
||||
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 className="flex flex-col">
|
||||
<p className="text-md">{title}</p>
|
||||
|
||||
@@ -12,10 +12,6 @@ vi.mock("next/navigation", () => ({
|
||||
useSearchParams: () => new URLSearchParams(),
|
||||
}));
|
||||
|
||||
vi.mock("@iconify/react", () => ({
|
||||
Icon: ({ icon }: { icon: string }) => <span aria-label={icon} />,
|
||||
}));
|
||||
|
||||
describe("BreadcrumbNavigation", () => {
|
||||
afterEach(() => {
|
||||
navigationMock.pathname = "/findings";
|
||||
@@ -40,22 +36,36 @@ describe("BreadcrumbNavigation", () => {
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not render icons for secondary breadcrumb items", () => {
|
||||
// Given
|
||||
navigationMock.pathname = "/scans/config";
|
||||
|
||||
// When
|
||||
it("renders the page icon next to a top-level title", () => {
|
||||
// Given / When
|
||||
render(
|
||||
<BreadcrumbNavigation
|
||||
mode="auto"
|
||||
title="Configuration"
|
||||
icon="lucide:sliders"
|
||||
title="Findings"
|
||||
icon={<svg data-testid="page-icon" />}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(screen.getByLabelText("lucide:timer")).toBeInTheDocument();
|
||||
expect(screen.queryByLabelText("lucide:sliders")).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId("page-icon")).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(
|
||||
screen.getByRole("heading", { name: "Configuration" }),
|
||||
).toBeInTheDocument();
|
||||
|
||||
@@ -1,9 +1,23 @@
|
||||
"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 { usePathname, useSearchParams } from "next/navigation";
|
||||
import { ReactNode } from "react";
|
||||
import type { ReactElement, ReactNode } from "react";
|
||||
|
||||
import { LighthouseIcon } from "@/components/icons/Icons";
|
||||
import { buildPerScanComplianceHref } from "@/lib/compliance/compliance-tab-url";
|
||||
@@ -12,7 +26,7 @@ import { cn } from "@/lib/utils";
|
||||
export interface CustomBreadcrumbItem {
|
||||
name: string;
|
||||
path?: string;
|
||||
icon?: string | ReactNode;
|
||||
icon?: ReactElement;
|
||||
isLast?: boolean;
|
||||
isClickable?: boolean;
|
||||
onClick?: () => void;
|
||||
@@ -21,7 +35,7 @@ export interface CustomBreadcrumbItem {
|
||||
interface BreadcrumbNavigationProps {
|
||||
mode?: "auto" | "custom" | "hybrid";
|
||||
title?: string;
|
||||
icon?: string | ReactNode;
|
||||
icon?: ReactElement;
|
||||
titleAction?: ReactNode;
|
||||
customItems?: CustomBreadcrumbItem[];
|
||||
className?: string;
|
||||
@@ -43,21 +57,21 @@ export function BreadcrumbNavigation({
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const generateAutoBreadcrumbs = (): CustomBreadcrumbItem[] => {
|
||||
const pathIconMapping: Record<string, string | ReactNode> = {
|
||||
"/integrations": "lucide:puzzle",
|
||||
"/alerts": "lucide:bell-ring",
|
||||
"/providers": "lucide:cloud",
|
||||
"/users": "lucide:users",
|
||||
"/compliance": "lucide:shield-check",
|
||||
"/findings": "lucide:search",
|
||||
"/scans": "lucide:timer",
|
||||
"/roles": "lucide:key",
|
||||
"/resources": "lucide:database",
|
||||
const pathIconMapping: Record<string, ReactElement> = {
|
||||
"/integrations": <Puzzle />,
|
||||
"/alerts": <BellRing />,
|
||||
"/providers": <Cloud />,
|
||||
"/users": <Users />,
|
||||
"/compliance": <ShieldCheck />,
|
||||
"/findings": <Search />,
|
||||
"/scans": <Timer />,
|
||||
"/roles": <Key />,
|
||||
"/resources": <Database />,
|
||||
"/lighthouse": <LighthouseIcon />,
|
||||
"/manage-groups": "lucide:users-2",
|
||||
"/services": "lucide:server",
|
||||
"/workloads": "lucide:layers",
|
||||
"/attack-paths": "lucide:git-branch",
|
||||
"/manage-groups": <UsersRound />,
|
||||
"/services": <Server />,
|
||||
"/workloads": <Layers />,
|
||||
"/attack-paths": <GitBranch />,
|
||||
};
|
||||
|
||||
const pathSegments = pathname
|
||||
@@ -116,15 +130,8 @@ export function BreadcrumbNavigation({
|
||||
showIcon: boolean = true,
|
||||
) => (
|
||||
<div className="flex items-center gap-2">
|
||||
{showIcon && typeof icon === "string" ? (
|
||||
<Icon
|
||||
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">
|
||||
{showIcon && icon ? (
|
||||
<div className="text-text-neutral-primary flex shrink-0 items-center justify-center">
|
||||
{icon}
|
||||
</div>
|
||||
) : 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" ? (
|
||||
<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">
|
||||
{index === 0 && breadcrumb.icon ? (
|
||||
<BreadcrumbIcon className="text-text-neutral-primary">
|
||||
{breadcrumb.icon}
|
||||
</div>
|
||||
</BreadcrumbIcon>
|
||||
) : 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">
|
||||
{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" ? (
|
||||
<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">
|
||||
{index === 0 && breadcrumb.icon ? (
|
||||
<BreadcrumbIcon className="text-text-neutral-primary">
|
||||
{breadcrumb.icon}
|
||||
</div>
|
||||
</BreadcrumbIcon>
|
||||
) : null}
|
||||
<span className="max-w-[150px] truncate sm:max-w-none">
|
||||
{breadcrumb.name}
|
||||
@@ -215,20 +202,10 @@ export function BreadcrumbNavigation({
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
{index === 0 &&
|
||||
breadcrumb.icon &&
|
||||
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">
|
||||
{index === 0 && breadcrumb.icon ? (
|
||||
<BreadcrumbIcon className="text-text-neutral-tertiary">
|
||||
{breadcrumb.icon}
|
||||
</div>
|
||||
</BreadcrumbIcon>
|
||||
) : null}
|
||||
<span className="max-w-[150px] truncate text-sm font-medium text-gray-900 sm:max-w-none dark:text-gray-100">
|
||||
{breadcrumb.name}
|
||||
@@ -250,3 +227,23 @@ export function BreadcrumbNavigation({
|
||||
</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 {
|
||||
Navbar,
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
|
||||
interface ContentLayoutProps {
|
||||
title: string;
|
||||
icon?: string | ReactNode;
|
||||
icon?: ReactElement;
|
||||
onboardingAction?: OnboardingActionConfig;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
@@ -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 = <T extends FieldValues>({
|
||||
inputType === "password" ? "Show password" : "Hide password"
|
||||
}
|
||||
>
|
||||
<Icon
|
||||
className="text-text-neutral-tertiary pointer-events-none text-2xl"
|
||||
icon={
|
||||
(password && isPasswordVisible) ||
|
||||
(confirmPassword && isConfirmPasswordVisible)
|
||||
? "solar:eye-closed-linear"
|
||||
: "solar:eye-bold"
|
||||
}
|
||||
/>
|
||||
{(password && isPasswordVisible) ||
|
||||
(confirmPassword && isConfirmPasswordVisible) ? (
|
||||
<EyeOff
|
||||
aria-hidden="true"
|
||||
className="text-text-neutral-tertiary pointer-events-none size-6"
|
||||
/>
|
||||
) : (
|
||||
<Eye
|
||||
aria-hidden="true"
|
||||
className="text-text-neutral-tertiary pointer-events-none size-6"
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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
|
||||
>
|
||||
<Link href={href || ""}>
|
||||
<Icon icon={icon} className="text-text-neutral-secondary" />
|
||||
<Link href={href || ""} className="text-text-neutral-secondary">
|
||||
{icon}
|
||||
</Link>
|
||||
</Button>
|
||||
<Separator orientation="vertical" className="h-6" />
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": [
|
||||
|
||||
+1
-1
@@ -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};
|
||||
|
||||
@@ -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: [],
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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"),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
Generated
-13
@@ -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':
|
||||
|
||||
+2
-5
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
Vendored
+3
@@ -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";
|
||||
|
||||
|
||||
@@ -147,7 +147,6 @@ export default defineConfig(() => {
|
||||
"next-themes",
|
||||
|
||||
// App component lib
|
||||
"@iconify/react",
|
||||
"react-day-picker",
|
||||
"posthog-js",
|
||||
"posthog-js/react",
|
||||
|
||||
Reference in New Issue
Block a user