mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-10-10 05:24:20 +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
@@ -0,0 +1,17 @@
|
||||
import { http, HttpResponse } from "msw";
|
||||
|
||||
// A 1×1 transparent PNG.
|
||||
const PNG = Uint8Array.from(
|
||||
atob(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=",
|
||||
),
|
||||
(char) => char.charCodeAt(0),
|
||||
);
|
||||
|
||||
/** Serves a registry logo image, so a test never reaches the real registry. */
|
||||
export function handleRegistryLogo(url: string) {
|
||||
return http.get(
|
||||
url,
|
||||
() => new HttpResponse(PNG, { headers: { "Content-Type": "image/png" } }),
|
||||
);
|
||||
}
|
||||
@@ -227,6 +227,22 @@ export async function getInstalledRegistryProviderOptions(): Promise<RegistryPro
|
||||
};
|
||||
}
|
||||
|
||||
/** Logo of each registry provider type; empty whenever Registry is out of reach. */
|
||||
export async function getRegistryProviderLogos(): Promise<
|
||||
Record<string, string>
|
||||
> {
|
||||
const access = (await auth())?.accessToken;
|
||||
const permission = await evaluateRegistryProviderAccess(access);
|
||||
if (!access || permission.status !== REGISTRY_ACCESS.ELIGIBLE) return {};
|
||||
const result = await readRegistryProviders(access, null);
|
||||
if (result.status !== "ready") return {};
|
||||
return Object.fromEntries(
|
||||
result.providers.flatMap(({ type, logoUrl }) =>
|
||||
logoUrl?.startsWith("https://") ? [[type, logoUrl]] : [],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async function readCompleteRegistryCatalog(
|
||||
accessToken: string,
|
||||
credential: RegistryCredentialStatus | null,
|
||||
|
||||
@@ -22,6 +22,10 @@ const manageGroupsActionsMock = vi.hoisted(() => ({
|
||||
getAllProviderGroups: vi.fn(),
|
||||
}));
|
||||
|
||||
const registryActionsMock = vi.hoisted(() => ({
|
||||
getRegistryProviderLogos: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/actions/providers", () => providersActionsMock);
|
||||
vi.mock(
|
||||
"@/actions/organizations/organizations",
|
||||
@@ -30,6 +34,7 @@ vi.mock(
|
||||
vi.mock("@/actions/scans", () => scansActionsMock);
|
||||
vi.mock("@/actions/schedules", () => schedulesActionsMock);
|
||||
vi.mock("@/actions/manage-groups/manage-groups", () => manageGroupsActionsMock);
|
||||
vi.mock("@/actions/registry/registry", () => registryActionsMock);
|
||||
|
||||
import { SearchParamsProps } from "@/types";
|
||||
import { NODE_KIND } from "@/types/organizations";
|
||||
@@ -1313,4 +1318,96 @@ describe("loadProvidersAccountsViewData", () => {
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
const givenRegistryProviderPage = (
|
||||
logos: Promise<Record<string, string>>,
|
||||
) => {
|
||||
const registryProvider = {
|
||||
...providersResponse.data[1],
|
||||
id: "provider-vcf",
|
||||
attributes: {
|
||||
...providersResponse.data[1].attributes,
|
||||
provider: "vcf",
|
||||
is_dynamic: true,
|
||||
},
|
||||
};
|
||||
providersActionsMock.getProviders.mockResolvedValue({
|
||||
...providersResponse,
|
||||
data: [providersResponse.data[0], registryProvider],
|
||||
});
|
||||
providersActionsMock.getAllProviders.mockResolvedValue(providersResponse);
|
||||
schedulesActionsMock.getSchedules.mockResolvedValue({ data: [] });
|
||||
organizationsActionsMock.listOrganizationsSafe.mockResolvedValue({
|
||||
data: [],
|
||||
});
|
||||
organizationsActionsMock.listOrganizationNodesSafe.mockResolvedValue({
|
||||
data: [],
|
||||
});
|
||||
registryActionsMock.getRegistryProviderLogos.mockReturnValue(logos);
|
||||
};
|
||||
|
||||
it("streams the logo the registry publishes for each provider type", async () => {
|
||||
// Given
|
||||
givenRegistryProviderPage(
|
||||
Promise.resolve({
|
||||
vcf: "https://media.registry.example.com/providers/vcf/logo.png",
|
||||
}),
|
||||
);
|
||||
|
||||
// When
|
||||
const viewData = await loadProvidersAccountsViewData({
|
||||
searchParams: {} satisfies SearchParamsProps,
|
||||
isCloud: true,
|
||||
});
|
||||
|
||||
// Then
|
||||
await expect(viewData.registryLogos).resolves.toEqual({
|
||||
vcf: "https://media.registry.example.com/providers/vcf/logo.png",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns the provider rows without waiting for registry logos", async () => {
|
||||
// Given: a registry that never answers.
|
||||
givenRegistryProviderPage(new Promise(() => {}));
|
||||
|
||||
// When
|
||||
const result = await Promise.race([
|
||||
loadProvidersAccountsViewData({
|
||||
searchParams: {} satisfies SearchParamsProps,
|
||||
isCloud: true,
|
||||
}),
|
||||
new Promise<"blocked">((resolve) =>
|
||||
setTimeout(() => resolve("blocked"), 200),
|
||||
),
|
||||
]);
|
||||
|
||||
// Then
|
||||
expect(result).not.toBe("blocked");
|
||||
if (result === "blocked") return;
|
||||
expect(findProviderRow(result.rows, "provider-vcf")).toBeDefined();
|
||||
});
|
||||
|
||||
it("does not ask the registry for logos when the page lists no registry providers", async () => {
|
||||
// Given
|
||||
registryActionsMock.getRegistryProviderLogos.mockClear();
|
||||
providersActionsMock.getProviders.mockResolvedValue(providersResponse);
|
||||
providersActionsMock.getAllProviders.mockResolvedValue(providersResponse);
|
||||
schedulesActionsMock.getSchedules.mockResolvedValue({ data: [] });
|
||||
organizationsActionsMock.listOrganizationsSafe.mockResolvedValue({
|
||||
data: [],
|
||||
});
|
||||
organizationsActionsMock.listOrganizationNodesSafe.mockResolvedValue({
|
||||
data: [],
|
||||
});
|
||||
|
||||
// When
|
||||
const viewData = await loadProvidersAccountsViewData({
|
||||
searchParams: {} satisfies SearchParamsProps,
|
||||
isCloud: true,
|
||||
});
|
||||
|
||||
// Then
|
||||
await expect(viewData.registryLogos).resolves.toEqual({});
|
||||
expect(registryActionsMock.getRegistryProviderLogos).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
} from "@/actions/organizations/organizations";
|
||||
import { getAllProviders, getProviders } from "@/actions/providers";
|
||||
import { PROVIDERS_FILTER_PARAM } from "@/actions/providers/providers-filters";
|
||||
import { getRegistryProviderLogos } from "@/actions/registry/registry";
|
||||
import { getSchedules } from "@/actions/schedules";
|
||||
import {
|
||||
extractFiltersAndQuery,
|
||||
@@ -142,6 +143,14 @@ const getProviderLastScanAt = (
|
||||
return provider.attributes.connection.last_checked_at ?? null;
|
||||
};
|
||||
|
||||
// Only a page that lists a registry provider pays for the registry round trip.
|
||||
const loadRegistryLogos = async (
|
||||
providersResponse: ProvidersApiResponse | undefined,
|
||||
): Promise<Record<string, string>> =>
|
||||
providersResponse?.data.some((provider) => provider.attributes.is_dynamic)
|
||||
? getRegistryProviderLogos().catch(() => ({}))
|
||||
: {};
|
||||
|
||||
const enrichProviders = (
|
||||
providersResponse: ProvidersApiResponse | undefined,
|
||||
schedulesByProviderId: Record<string, ScheduleAttributes>,
|
||||
@@ -557,6 +566,16 @@ export async function loadProvidersAccountsViewData({
|
||||
data: [],
|
||||
};
|
||||
|
||||
const providersRequest = resolveActionResult(
|
||||
getProviders({
|
||||
filters: providerFilters,
|
||||
page,
|
||||
pageSize,
|
||||
query,
|
||||
sort: encodedSort,
|
||||
}),
|
||||
);
|
||||
|
||||
const [
|
||||
providersResponse,
|
||||
allProvidersResponse,
|
||||
@@ -565,15 +584,7 @@ export async function loadProvidersAccountsViewData({
|
||||
organizationsResponse,
|
||||
organizationNodesResponse,
|
||||
] = await Promise.all([
|
||||
resolveActionResult(
|
||||
getProviders({
|
||||
filters: providerFilters,
|
||||
page,
|
||||
pageSize,
|
||||
query,
|
||||
sort: encodedSort,
|
||||
}),
|
||||
),
|
||||
providersRequest,
|
||||
// Unfiltered fetch for ProviderTypeSelector — only needs distinct types;
|
||||
// TODO: Replace with a dedicated lightweight endpoint when available.
|
||||
resolveActionResult(getAllProviders()),
|
||||
@@ -619,6 +630,7 @@ export async function loadProvidersAccountsViewData({
|
||||
providerGroups: allProviderGroupsResponse?.data ?? [],
|
||||
rows,
|
||||
hierarchyStatus,
|
||||
registryLogos: providersRequest.then(loadRegistryLogos),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -50,6 +50,7 @@ export const ProvidersTabContent = async ({
|
||||
metadata={providersView.metadata}
|
||||
rows={providersView.rows}
|
||||
hierarchyStatus={providersView.hierarchyStatus}
|
||||
registryLogos={providersView.registryLogos}
|
||||
scanConfigs={scanConfigsState.data}
|
||||
scanConfigStatus={scanConfigsState.status}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { formatRegistryLabel } from "./labels";
|
||||
|
||||
describe("formatRegistryLabel", () => {
|
||||
it("capitalizes plain identifiers and keeps any other value as written", () => {
|
||||
// When
|
||||
const labels = ["token", "service_account", "us-east-1", "OAuth2"].map(
|
||||
formatRegistryLabel,
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(labels).toEqual(["Token", "Service account", "us-east-1", "OAuth2"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
// Only plain lowercase identifiers: "us-east-1" or "OAuth2" already read as meant.
|
||||
const PLAIN_IDENTIFIER = /^[a-z][a-z0-9]*(?:[_ ][a-z0-9]+)*$/;
|
||||
|
||||
/** Display text for a Registry identifier ("service_account" → "Service account"). */
|
||||
export function formatRegistryLabel(value: string): string {
|
||||
if (!PLAIN_IDENTIFIER.test(value)) return value;
|
||||
const words = value.replaceAll("_", " ");
|
||||
return words.charAt(0).toUpperCase() + words.slice(1);
|
||||
}
|
||||
@@ -143,4 +143,25 @@ describe("installed Registry provider options", () => {
|
||||
).map((option) => option.type),
|
||||
).toEqual(["zeta"]);
|
||||
});
|
||||
|
||||
it("capitalizes provider names the registry publishes as plain slugs", () => {
|
||||
// Given: the registry names one provider by its slug and another properly.
|
||||
const catalog = [
|
||||
{ ...provider, normalizedName: "vcf-package", providerSlug: "vcf" },
|
||||
{ ...provider, normalizedName: "openai-package", providerSlug: "openai" },
|
||||
];
|
||||
const installed = catalog.map(({ normalizedName }) => ({
|
||||
normalizedName,
|
||||
versionSpec: "latest",
|
||||
}));
|
||||
|
||||
// When
|
||||
const labels = buildRegistryProviderOptions(catalog, installed, [
|
||||
{ type: "vcf", label: "vcf" },
|
||||
{ type: "openai", label: "OpenAI" },
|
||||
]).map((option) => option.label);
|
||||
|
||||
// Then
|
||||
expect(labels).toEqual(["OpenAI", "Vcf"]);
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import { formatRegistryLabel } from "@/lib/registry/labels";
|
||||
import { isKnownProviderType } from "@/types/providers";
|
||||
import type {
|
||||
RegistryCatalogArtifact,
|
||||
@@ -63,7 +64,8 @@ export function buildRegistryProviderOptions(
|
||||
);
|
||||
}
|
||||
}
|
||||
return Array.from(options.values()).sort((left, right) =>
|
||||
left.label.localeCompare(right.label),
|
||||
);
|
||||
return Array.from(options.values(), (option) => ({
|
||||
...option,
|
||||
label: formatRegistryLabel(option.label),
|
||||
})).sort((left, right) => left.label.localeCompare(right.label));
|
||||
}
|
||||
@@ -105,6 +105,8 @@ export interface ProvidersAccountsViewData {
|
||||
rows: ProvidersTableRow[];
|
||||
/** `unavailable` when the hierarchy fetch failed (drives the degraded notice). */
|
||||
hierarchyStatus: HierarchyStatus;
|
||||
/** Registry logo per provider type, streamed so rows never wait for it. */
|
||||
registryLogos: Promise<Record<string, string>>;
|
||||
}
|
||||
|
||||
export function isProvidersOrganizationRow(
|
||||
|
||||
Reference in new issue
Block a user