mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-10-29 14:54:30 +00:00
fix(ui): show registry provider logos and capitalize registry labels (#12985)
This commit is contained in:
1 parent
f44ea4b6fa
commit
d8bdc8553f
20 files changed
+416
-50
No files matched your search
@@ -10,6 +10,7 @@ import { MutedFindingsConfigButton } from "@/components/providers/muted-findings
|
||||
import { NoProvidersAdded } from "@/components/providers/no-providers-added";
|
||||
import { ProvidersAccountsTable } from "@/components/providers/providers-accounts-table";
|
||||
import { ProvidersFilters } from "@/components/providers/providers-filters";
|
||||
import { RegistryLogosContext } from "@/components/providers/table/registry-row-logo";
|
||||
import { ProviderWizardModal } from "@/components/providers/wizard";
|
||||
import type {
|
||||
OrgWizardInitialData,
|
||||
@@ -81,6 +82,8 @@ interface ProvidersAccountsViewProps {
|
||||
scanConfigStatus?: ScanConfigurationListStatus;
|
||||
isScanLimitReached?: boolean;
|
||||
hierarchyStatus?: HierarchyStatus;
|
||||
/** Registry logo per provider type; rows render before it resolves. */
|
||||
registryLogos?: Promise<Record<string, string>>;
|
||||
}
|
||||
|
||||
export function ProvidersAccountsView({
|
||||
@@ -95,6 +98,7 @@ export function ProvidersAccountsView({
|
||||
scanConfigStatus,
|
||||
isScanLimitReached,
|
||||
hierarchyStatus = HIERARCHY_STATUS.AVAILABLE,
|
||||
registryLogos,
|
||||
}: ProvidersAccountsViewProps) {
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
@@ -219,18 +223,20 @@ export function ProvidersAccountsView({
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<ProvidersAccountsTable
|
||||
isCloud={isCloud}
|
||||
metadata={metadata}
|
||||
rows={rows}
|
||||
scanScheduleCapability={scanScheduleCapability}
|
||||
scanConfigs={scanConfigs}
|
||||
scanConfigStatus={scanConfigStatus}
|
||||
onOpenProviderWizard={(initialData) =>
|
||||
openProviderWizard(WIZARD_OPEN_SOURCE.ROW_ACTION, initialData)
|
||||
}
|
||||
onOpenOrganizationWizard={openOrganizationWizard}
|
||||
/>
|
||||
<RegistryLogosContext value={registryLogos ?? null}>
|
||||
<ProvidersAccountsTable
|
||||
isCloud={isCloud}
|
||||
metadata={metadata}
|
||||
rows={rows}
|
||||
scanScheduleCapability={scanScheduleCapability}
|
||||
scanConfigs={scanConfigs}
|
||||
scanConfigStatus={scanConfigStatus}
|
||||
onOpenProviderWizard={(initialData) =>
|
||||
openProviderWizard(WIZARD_OPEN_SOURCE.ROW_ACTION, initialData)
|
||||
}
|
||||
onOpenOrganizationWizard={openOrganizationWizard}
|
||||
/>
|
||||
</RegistryLogosContext>
|
||||
</div>
|
||||
)}
|
||||
<ProviderWizardModal
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { useForm } from "react-hook-form";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { handleRegistryLogo } from "@/__tests__/msw/handlers/registry";
|
||||
import { worker } from "@/__tests__/msw/worker";
|
||||
import { render } from "@/__tests__/render-browser";
|
||||
import type { AddProviderFormValues } from "@/types/formSchemas";
|
||||
|
||||
import { RadioGroupProvider } from "./radio-group-provider";
|
||||
|
||||
const LOGO_URL = "https://media.registry.example.com/providers/vcf/logo.png";
|
||||
|
||||
function Selector() {
|
||||
const form = useForm<AddProviderFormValues>();
|
||||
return (
|
||||
<RadioGroupProvider
|
||||
control={form.control}
|
||||
isInvalid={false}
|
||||
registryAvailable
|
||||
registryOptions={[
|
||||
{
|
||||
type: "vcf",
|
||||
label: "Vcf",
|
||||
logoUrl: LOGO_URL,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// The logo is the first element of the option's label row.
|
||||
function logoBox(option: HTMLElement) {
|
||||
const logo = option.lastElementChild?.firstElementChild;
|
||||
if (!logo) throw new Error("Provider option without a logo");
|
||||
const { width, height } = logo.getBoundingClientRect();
|
||||
return { width, height };
|
||||
}
|
||||
|
||||
describe("provider selector logos", () => {
|
||||
it("draws a Registry provider's logo at the size of the built-in logos", async () => {
|
||||
// Given
|
||||
worker.use(handleRegistryLogo(LOGO_URL));
|
||||
const screen = await render(<Selector />);
|
||||
|
||||
// When
|
||||
const builtIn = screen
|
||||
.getByRole("option", { name: "Amazon Web Services" })
|
||||
.element() as HTMLElement;
|
||||
const registry = screen
|
||||
.getByRole("option", { name: "Vcf Registry" })
|
||||
.element() as HTMLElement;
|
||||
await expect
|
||||
.poll(() => registry.querySelector("img")?.getAttribute("src"))
|
||||
.toBe(LOGO_URL);
|
||||
|
||||
// Then
|
||||
expect(logoBox(registry)).toEqual(logoBox(builtIn));
|
||||
});
|
||||
});
|
||||
@@ -8,11 +8,6 @@ import {
|
||||
PROVIDER_TYPE_DATA,
|
||||
} from "@/components/icons/providers-badge/provider-type-icon";
|
||||
import { Badge, SearchInput } from "@/components/shadcn";
|
||||
import {
|
||||
Avatar,
|
||||
AvatarFallback,
|
||||
AvatarImage,
|
||||
} from "@/components/shadcn/avatar";
|
||||
import { FormMessage } from "@/components/shadcn/form";
|
||||
import {
|
||||
Tabs,
|
||||
@@ -24,6 +19,8 @@ import type { RegistryProviderOption } from "@/lib/registry/provider-options";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { AddProviderFormValues } from "@/types/formSchemas";
|
||||
|
||||
import { RegistryProviderLogo } from "./registry-provider-logo";
|
||||
|
||||
const PROVIDERS = Object.entries(PROVIDER_TYPE_DATA).map(
|
||||
([value, { label }]) => ({ value, label }),
|
||||
);
|
||||
@@ -135,19 +132,11 @@ export const RadioGroupProvider: FC<RadioGroupProviderProps> = ({
|
||||
|
||||
<div className="flex min-w-0 flex-1 items-center gap-1.5">
|
||||
{provider.registry ? (
|
||||
<Avatar>
|
||||
<AvatarImage
|
||||
src={
|
||||
provider.logoUrl?.startsWith("https://")
|
||||
? provider.logoUrl
|
||||
: undefined
|
||||
}
|
||||
alt=""
|
||||
/>
|
||||
<AvatarFallback>
|
||||
<ProviderTypeIcon type={provider.value} size={26} />
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<RegistryProviderLogo
|
||||
type={provider.value}
|
||||
logoUrl={provider.logoUrl}
|
||||
size={26}
|
||||
/>
|
||||
) : (
|
||||
<ProviderTypeIcon type={provider.value} size={26} />
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { ProviderTypeIcon } from "@/components/icons/providers-badge/provider-type-icon";
|
||||
import {
|
||||
Avatar,
|
||||
AvatarFallback,
|
||||
AvatarImage,
|
||||
} from "@/components/shadcn/avatar";
|
||||
import type { ProviderType } from "@/types";
|
||||
|
||||
interface RegistryProviderLogoProps {
|
||||
type: ProviderType;
|
||||
logoUrl?: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
/** The registry's logo, or the generic glyph while it loads or once its signed URL expires. */
|
||||
export function RegistryProviderLogo({
|
||||
type,
|
||||
logoUrl,
|
||||
size,
|
||||
}: RegistryProviderLogoProps) {
|
||||
// Same box as a built-in provider badge of this size.
|
||||
return (
|
||||
<Avatar style={{ width: size, height: size }}>
|
||||
<AvatarImage
|
||||
src={logoUrl?.startsWith("https://") ? logoUrl : undefined}
|
||||
alt=""
|
||||
/>
|
||||
<AvatarFallback>
|
||||
<ProviderTypeIcon type={type} size={size} />
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
);
|
||||
}
|
||||
@@ -41,6 +41,7 @@ import type {
|
||||
import { LinkToScans } from "../link-to-scans";
|
||||
|
||||
import { DataTableRowActions } from "./data-table-row-actions";
|
||||
import { RegistryRowLogo } from "./registry-row-logo";
|
||||
|
||||
interface GroupNameChipsProps {
|
||||
groupNames?: string[];
|
||||
@@ -247,6 +248,11 @@ export function getColumnProviders(
|
||||
>
|
||||
<EntityInfo
|
||||
cloudProvider={provider.attributes.provider}
|
||||
icon={
|
||||
provider.attributes.is_dynamic ? (
|
||||
<RegistryRowLogo type={provider.attributes.provider} />
|
||||
) : undefined
|
||||
}
|
||||
entityAlias={provider.attributes.alias}
|
||||
entityId={provider.attributes.uid}
|
||||
nameAction={
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { handleRegistryLogo } from "@/__tests__/msw/handlers/registry";
|
||||
import { worker } from "@/__tests__/msw/worker";
|
||||
import { render } from "@/__tests__/render-browser";
|
||||
|
||||
import { RegistryLogosContext, RegistryRowLogo } from "./registry-row-logo";
|
||||
|
||||
const LOGO_URL = "https://media.registry.example.com/providers/vcf/logo.png";
|
||||
|
||||
describe("registry row logo", () => {
|
||||
it("shows the generic glyph until the streamed logos arrive, then the registry logo", async () => {
|
||||
// Given
|
||||
worker.use(handleRegistryLogo(LOGO_URL));
|
||||
let resolveLogos!: (logos: Record<string, string>) => void;
|
||||
const logos = new Promise<Record<string, string>>((resolve) => {
|
||||
resolveLogos = resolve;
|
||||
});
|
||||
const screen = await render(
|
||||
<RegistryLogosContext value={logos}>
|
||||
<RegistryRowLogo type="vcf" />
|
||||
</RegistryLogosContext>,
|
||||
);
|
||||
const logoImage = () => screen.container.querySelector("img");
|
||||
|
||||
// Then: the row renders while the registry has not answered.
|
||||
expect(screen.container.querySelector("svg")).not.toBeNull();
|
||||
expect(logoImage()).toBeNull();
|
||||
|
||||
// When
|
||||
resolveLogos({ vcf: LOGO_URL });
|
||||
|
||||
// Then
|
||||
await expect.poll(() => logoImage()?.getAttribute("src")).toBe(LOGO_URL);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, Suspense, use } from "react";
|
||||
|
||||
import { RegistryProviderLogo } from "@/components/providers/registry-provider-logo";
|
||||
import type { ProviderType } from "@/types";
|
||||
|
||||
const ROW_LOGO_SIZE = 35;
|
||||
|
||||
/** Registry logo per provider type, resolved after the rows render. */
|
||||
export const RegistryLogosContext = createContext<Promise<
|
||||
Record<string, string>
|
||||
> | null>(null);
|
||||
|
||||
function ResolvedRegistryRowLogo({
|
||||
type,
|
||||
logos,
|
||||
}: {
|
||||
type: ProviderType;
|
||||
logos: Promise<Record<string, string>>;
|
||||
}) {
|
||||
const byType = use(logos);
|
||||
const logoUrl = Object.hasOwn(byType, type) ? byType[type] : undefined;
|
||||
return (
|
||||
<RegistryProviderLogo type={type} logoUrl={logoUrl} size={ROW_LOGO_SIZE} />
|
||||
);
|
||||
}
|
||||
|
||||
/** A registry row's logo; the generic glyph stands in until the logos arrive. */
|
||||
export function RegistryRowLogo({ type }: { type: ProviderType }) {
|
||||
const logos = use(RegistryLogosContext);
|
||||
const placeholder = <RegistryProviderLogo type={type} size={ROW_LOGO_SIZE} />;
|
||||
if (!logos) return placeholder;
|
||||
return (
|
||||
<Suspense fallback={placeholder}>
|
||||
<ResolvedRegistryRowLogo type={type} logos={logos} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -157,12 +157,16 @@ describe("dynamic credentials in the provider wizard", () => {
|
||||
expect(timeout).toHaveAttribute("min", "1");
|
||||
expect(timeout).toHaveAttribute("max", "300");
|
||||
expect(timeout).toHaveAttribute("step", "1");
|
||||
expect(
|
||||
screen.getByRole("combobox", { name: "Authentication Scheme" }),
|
||||
).toHaveTextContent("bearer");
|
||||
const authScheme = screen.getByRole("combobox", {
|
||||
name: "Authentication Scheme",
|
||||
});
|
||||
expect(authScheme).toHaveTextContent("Bearer");
|
||||
expect(apiUrl).toHaveValue("");
|
||||
|
||||
// When: false must remain a boolean and numeric input must become a number.
|
||||
// When: options read capitalized but submit their raw schema value.
|
||||
await user.click(authScheme);
|
||||
await user.click(screen.getByRole("option", { name: "Basic" }));
|
||||
// False must remain a boolean and numeric input must become a number.
|
||||
await user.type(apiUrl, "https://api.example.test");
|
||||
await user.type(apiKey, "fixture-key-not-a-secret");
|
||||
await user.click(verifyTls);
|
||||
@@ -180,7 +184,7 @@ describe("dynamic credentials in the provider wizard", () => {
|
||||
api_key: "fixture-key-not-a-secret",
|
||||
verify_tls: false,
|
||||
timeout_seconds: 60,
|
||||
auth_scheme: "bearer",
|
||||
auth_scheme: "basic",
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -347,7 +351,7 @@ describe("dynamic credentials in the provider wizard", () => {
|
||||
await user.click(
|
||||
screen.getByRole("combobox", { name: "Authentication method" }),
|
||||
);
|
||||
await user.click(screen.getByRole("option", { name: "personal token" }));
|
||||
await user.click(screen.getByRole("option", { name: "Personal token" }));
|
||||
expect(screen.getByLabelText(/API token/)).toHaveValue("");
|
||||
expect(JSON.stringify(sessionStorage)).not.toContain(
|
||||
"previous-method-secret",
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
getCredentialDefaults,
|
||||
validateCredentialValues,
|
||||
} from "@/lib/provider-credentials/provider-credential-values";
|
||||
import { formatRegistryLabel } from "@/lib/registry/labels";
|
||||
import { useProviderWizardStore } from "@/store/provider-wizard/store";
|
||||
import type {
|
||||
ProviderSchemasResult,
|
||||
@@ -58,7 +59,7 @@ function listCredentialMethods(
|
||||
secretTypes: ProviderSecretTypes,
|
||||
): CredentialMethod[] {
|
||||
return Object.entries(secretTypes).flatMap(([secretType, value]) => {
|
||||
const label = secretType.replaceAll("_", " ");
|
||||
const label = formatRegistryLabel(secretType);
|
||||
const variants = parseRegistryCredentialVariants(value);
|
||||
if (!variants || variants.length === 1) {
|
||||
return [
|
||||
|
||||
@@ -17,6 +17,7 @@ import type {
|
||||
RegistryCredentialSchema,
|
||||
RegistryCredentialValue,
|
||||
} from "@/lib/provider-credentials/provider-credential-schema";
|
||||
import { formatRegistryLabel } from "@/lib/registry/labels";
|
||||
|
||||
interface RegistryCredentialFieldsProps {
|
||||
readonly errors: Readonly<Record<string, string | undefined>>;
|
||||
@@ -107,7 +108,7 @@ export function RegistryCredentialFields({
|
||||
<SelectContent>
|
||||
{field.options?.map((option) => (
|
||||
<SelectItem key={option} value={option}>
|
||||
{option}
|
||||
{formatRegistryLabel(option)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
|
||||
Reference in new issue
Block a user