mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-07-24 21:11:53 +00:00
feat(ui): support dynamic providers (#11869)
Co-authored-by: Pablo F.G <pablo.fernandez@prowler.com>
This commit is contained in:
committed by
GitHub
parent
3369e48260
commit
58fd4170b5
@@ -210,7 +210,7 @@ export function adaptFindingGroupResourcesResponse(
|
||||
rowType: FINDINGS_ROW_TYPE.RESOURCE,
|
||||
findingId: item.attributes.finding_id || item.id,
|
||||
checkId,
|
||||
providerType: (item.attributes.provider?.type || "aws") as ProviderType,
|
||||
providerType: (item.attributes.provider?.type ?? "") as ProviderType,
|
||||
providerAlias: item.attributes.provider?.alias || "",
|
||||
providerUid: item.attributes.provider?.uid || "",
|
||||
resourceName: item.attributes.resource?.name || "-",
|
||||
|
||||
@@ -14,10 +14,7 @@ import {
|
||||
type ComplianceReportType,
|
||||
} from "@/lib/compliance/compliance-report-types";
|
||||
import { runWithConcurrencyLimit } from "@/lib/concurrency";
|
||||
import {
|
||||
appendSanitizedProviderTypeFilters,
|
||||
sanitizeProviderTypesCsv,
|
||||
} from "@/lib/provider-filters";
|
||||
import { appendSanitizedProviderTypeFilters } from "@/lib/provider-filters";
|
||||
import { addScanOperation } from "@/lib/sentry-breadcrumbs";
|
||||
import { handleApiError, handleApiResponse } from "@/lib/server-actions-helper";
|
||||
import { SCAN_STATES } from "@/types/attack-paths";
|
||||
@@ -66,10 +63,6 @@ export const getScansByState = async () => {
|
||||
const url = new URL(`${apiBaseUrl}/scans`);
|
||||
// Request only the necessary fields to optimize the response
|
||||
url.searchParams.append("fields[scans]", "state");
|
||||
url.searchParams.append(
|
||||
"filter[provider_type__in]",
|
||||
sanitizeProviderTypesCsv(),
|
||||
);
|
||||
// Only need to know whether at least one completed scan exists; filter server-side
|
||||
// and cap to a single row so the answer is correct regardless of total scan count.
|
||||
url.searchParams.append("filter[state]", SCAN_STATES.COMPLETED);
|
||||
|
||||
@@ -2,6 +2,7 @@ import { render, screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { ProviderProps } from "@/types";
|
||||
import { FILTER_FIELD } from "@/types/filters";
|
||||
|
||||
import { AccountsSelector } from "./accounts-selector";
|
||||
@@ -99,12 +100,13 @@ vi.mock("@/components/shadcn/select/multiselect", () => ({
|
||||
),
|
||||
}));
|
||||
|
||||
const providers = [
|
||||
const providers: ProviderProps[] = [
|
||||
{
|
||||
id: "provider-1",
|
||||
type: "providers" as const,
|
||||
attributes: {
|
||||
provider: "aws" as const,
|
||||
is_dynamic: false,
|
||||
uid: "123456789012",
|
||||
alias: "Production AWS",
|
||||
status: "completed" as const,
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { ProviderProps } from "@/types";
|
||||
|
||||
import { ProviderTypeSelector } from "./provider-type-selector";
|
||||
|
||||
const multiSelectContentSpy = vi.fn();
|
||||
@@ -69,12 +71,13 @@ vi.mock("@/components/shadcn/select/multiselect", () => ({
|
||||
),
|
||||
}));
|
||||
|
||||
const providers = [
|
||||
const providers: ProviderProps[] = [
|
||||
{
|
||||
id: "provider-1",
|
||||
type: "providers" as const,
|
||||
attributes: {
|
||||
provider: "aws" as const,
|
||||
is_dynamic: false,
|
||||
uid: "123456789012",
|
||||
alias: "Production AWS",
|
||||
status: "completed" as const,
|
||||
|
||||
@@ -16,7 +16,22 @@ import {
|
||||
MultiSelectValue,
|
||||
} from "@/components/shadcn/select/multiselect";
|
||||
import { useUrlFilters } from "@/hooks/use-url-filters";
|
||||
import { type ProviderProps, ProviderType } from "@/types/providers";
|
||||
import {
|
||||
humanizeProviderId,
|
||||
isKnownProviderType,
|
||||
type ProviderProps,
|
||||
ProviderType,
|
||||
} from "@/types/providers";
|
||||
|
||||
/**
|
||||
* Label for a provider type: the rich configured label for known types, a
|
||||
* humanized id for anything the API returns outside the known set (dynamic
|
||||
* plug-ins). Icons fall back to the generic glyph via `ProviderTypeIcon`.
|
||||
*/
|
||||
const providerTypeLabel = (type: ProviderType): string =>
|
||||
isKnownProviderType(type)
|
||||
? PROVIDER_TYPE_DATA[type].label
|
||||
: humanizeProviderId(type);
|
||||
|
||||
/** Common props shared by both batch and instant modes. */
|
||||
interface ProviderTypeSelectorBaseProps {
|
||||
@@ -88,16 +103,8 @@ export const ProviderTypeSelector = ({
|
||||
};
|
||||
|
||||
const availableTypes = Array.from(
|
||||
new Set(
|
||||
providers
|
||||
// .filter((p) => p.attributes.connection?.connected)
|
||||
.map((p) => p.attributes.provider),
|
||||
),
|
||||
)
|
||||
.filter((type): type is ProviderType => type in PROVIDER_TYPE_DATA)
|
||||
.sort((a, b) =>
|
||||
PROVIDER_TYPE_DATA[a].label.localeCompare(PROVIDER_TYPE_DATA[b].label),
|
||||
);
|
||||
new Set(providers.map((p) => p.attributes.provider)),
|
||||
).sort((a, b) => providerTypeLabel(a).localeCompare(providerTypeLabel(b)));
|
||||
|
||||
const selectedLabel = () => {
|
||||
if (selectedTypes.length === 0) return null;
|
||||
@@ -108,9 +115,7 @@ export const ProviderTypeSelector = ({
|
||||
<span aria-hidden="true">
|
||||
<ProviderTypeIcon type={providerType} />
|
||||
</span>
|
||||
<span className="truncate">
|
||||
{PROVIDER_TYPE_DATA[providerType].label}
|
||||
</span>
|
||||
<span className="truncate">{providerTypeLabel(providerType)}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -120,7 +125,7 @@ export const ProviderTypeSelector = ({
|
||||
items={(selectedTypes as ProviderType[]).map((type) => ({
|
||||
key: type,
|
||||
type,
|
||||
tooltip: PROVIDER_TYPE_DATA[type].label,
|
||||
tooltip: providerTypeLabel(type),
|
||||
}))}
|
||||
/>
|
||||
<span className="min-w-0 truncate">
|
||||
@@ -176,23 +181,24 @@ export const ProviderTypeSelector = ({
|
||||
>
|
||||
{selectedTypes.length === 0 ? "All selected" : "Select All"}
|
||||
</div>
|
||||
{availableTypes.map((providerType) => (
|
||||
<MultiSelectItem
|
||||
key={providerType}
|
||||
value={providerType}
|
||||
badgeLabel={PROVIDER_TYPE_DATA[providerType].label}
|
||||
keywords={[
|
||||
providerType,
|
||||
PROVIDER_TYPE_DATA[providerType].label,
|
||||
]}
|
||||
aria-label={`${PROVIDER_TYPE_DATA[providerType].label} Provider Type`}
|
||||
>
|
||||
<span aria-hidden="true">
|
||||
<ProviderTypeIcon type={providerType} size={24} />
|
||||
</span>
|
||||
<span>{PROVIDER_TYPE_DATA[providerType].label}</span>
|
||||
</MultiSelectItem>
|
||||
))}
|
||||
{availableTypes.map((providerType) => {
|
||||
const label = providerTypeLabel(providerType);
|
||||
|
||||
return (
|
||||
<MultiSelectItem
|
||||
key={providerType}
|
||||
value={providerType}
|
||||
badgeLabel={label}
|
||||
keywords={[providerType, label]}
|
||||
aria-label={`${label} Provider Type`}
|
||||
>
|
||||
<span aria-hidden="true">
|
||||
<ProviderTypeIcon type={providerType} size={24} />
|
||||
</span>
|
||||
<span>{label}</span>
|
||||
</MultiSelectItem>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
) : (
|
||||
<div className="px-3 py-2 text-sm text-slate-500 dark:text-slate-400">
|
||||
|
||||
@@ -104,6 +104,7 @@ const mockProviders: ProviderProps[] = [
|
||||
type: "providers",
|
||||
attributes: {
|
||||
provider: "aws",
|
||||
is_dynamic: false,
|
||||
uid: "123456789012",
|
||||
alias: "Production AWS",
|
||||
status: "completed",
|
||||
@@ -131,6 +132,7 @@ const mockProviders: ProviderProps[] = [
|
||||
type: "providers",
|
||||
attributes: {
|
||||
provider: "gcp",
|
||||
is_dynamic: false,
|
||||
uid: "prowler-prod-project",
|
||||
alias: "Production GCP",
|
||||
status: "completed",
|
||||
|
||||
@@ -62,6 +62,7 @@ const providersResponse: ProvidersApiResponse = {
|
||||
type: "providers",
|
||||
attributes: {
|
||||
provider: "aws",
|
||||
is_dynamic: false,
|
||||
uid: "111111111111",
|
||||
alias: "AWS App Account",
|
||||
status: "completed",
|
||||
@@ -107,6 +108,7 @@ const providersResponse: ProvidersApiResponse = {
|
||||
type: "providers",
|
||||
attributes: {
|
||||
provider: "aws",
|
||||
is_dynamic: false,
|
||||
uid: "222222222222",
|
||||
alias: "Standalone Account",
|
||||
status: "completed",
|
||||
|
||||
@@ -31,6 +31,8 @@ import { scanConfigurationFormSchema } from "@/types/formSchemas";
|
||||
import { ProviderProps } from "@/types/providers";
|
||||
import { ScanConfigurationData } from "@/types/scan-configurations";
|
||||
|
||||
import { getSelectableProviders } from "./scan-configuration-providers";
|
||||
|
||||
interface ScanConfigurationEditorProps {
|
||||
open: boolean;
|
||||
onClose: (saved: boolean) => void;
|
||||
@@ -90,21 +92,12 @@ function ScanConfigurationForm({
|
||||
? validateYaml(configText)
|
||||
: { isValid: true as const };
|
||||
|
||||
// A provider can only be attached to one config at a time. We exclude
|
||||
// providers that are owned by *other* configs from the selector so the user
|
||||
// can't double-attach them. (AccountsSelector doesn't expose a per-option
|
||||
// disabled state, so filtering out is the cleanest contract here.)
|
||||
const ownerByProvider = new Map<string, string>();
|
||||
for (const c of existingConfigs) {
|
||||
if (config && c.id === config.id) continue;
|
||||
for (const pid of c.attributes.providers || []) {
|
||||
ownerByProvider.set(pid, c.attributes.name);
|
||||
}
|
||||
}
|
||||
const selectableProviders = richProviders.filter(
|
||||
(p) => !ownerByProvider.has(p.id),
|
||||
);
|
||||
const lockedCount = richProviders.length - selectableProviders.length;
|
||||
// Dynamic providers can't use a Scan Configuration, and a provider can only be
|
||||
// attached to one config at a time, so both are excluded from the selector.
|
||||
// (AccountsSelector doesn't expose a per-option disabled state, so filtering
|
||||
// out is the cleanest contract here.)
|
||||
const { selectableProviders, configurableCount, lockedCount } =
|
||||
getSelectableProviders(richProviders, existingConfigs, config?.id ?? null);
|
||||
|
||||
const onSubmit = form.handleSubmit(async (values) => {
|
||||
// Block on a YAML syntax error (the inline message already explains it); the
|
||||
@@ -252,7 +245,9 @@ function ScanConfigurationForm({
|
||||
<p className="text-text-neutral-tertiary text-xs italic">
|
||||
{richProviders.length === 0
|
||||
? "No providers available in this tenant."
|
||||
: "All providers are already attached to other Scan Configurations."}
|
||||
: configurableCount === 0
|
||||
? "Dynamic providers can't use custom Scan Configurations, so there are none to attach."
|
||||
: "All providers are already attached to other Scan Configurations."}
|
||||
</p>
|
||||
) : (
|
||||
<AccountsSelector
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { ProviderProps } from "@/types/providers";
|
||||
import type { ScanConfigurationData } from "@/types/scan-configurations";
|
||||
|
||||
import { getSelectableProviders } from "./scan-configuration-providers";
|
||||
|
||||
const provider = (id: string, isDynamic = false): ProviderProps =>
|
||||
({
|
||||
id,
|
||||
type: "providers",
|
||||
attributes: {
|
||||
provider: isDynamic ? "template" : "aws",
|
||||
is_dynamic: isDynamic,
|
||||
},
|
||||
}) as unknown as ProviderProps;
|
||||
|
||||
const config = (id: string, providers: string[]): ScanConfigurationData => ({
|
||||
type: "scan-configurations",
|
||||
id,
|
||||
attributes: {
|
||||
inserted_at: "2026-01-01T00:00:00Z",
|
||||
updated_at: "2026-01-01T00:00:00Z",
|
||||
name: id,
|
||||
configuration: {},
|
||||
providers,
|
||||
},
|
||||
});
|
||||
|
||||
describe("getSelectableProviders", () => {
|
||||
it("excludes dynamic providers (they have no config baseline to override)", () => {
|
||||
const result = getSelectableProviders(
|
||||
[provider("aws-1"), provider("dyn-1", true)],
|
||||
[],
|
||||
null,
|
||||
);
|
||||
|
||||
expect(result.selectableProviders.map((p) => p.id)).toEqual(["aws-1"]);
|
||||
expect(result.configurableCount).toBe(1);
|
||||
expect(result.lockedCount).toBe(0);
|
||||
});
|
||||
|
||||
it("excludes providers already attached to another config", () => {
|
||||
const result = getSelectableProviders(
|
||||
[provider("aws-1"), provider("aws-2")],
|
||||
[config("other", ["aws-2"])],
|
||||
null,
|
||||
);
|
||||
|
||||
expect(result.selectableProviders.map((p) => p.id)).toEqual(["aws-1"]);
|
||||
expect(result.lockedCount).toBe(1);
|
||||
});
|
||||
|
||||
it("keeps providers attached to the config being edited selectable", () => {
|
||||
const result = getSelectableProviders(
|
||||
[provider("aws-1"), provider("aws-2")],
|
||||
[config("current", ["aws-1"])],
|
||||
"current",
|
||||
);
|
||||
|
||||
expect(result.selectableProviders.map((p) => p.id)).toEqual([
|
||||
"aws-1",
|
||||
"aws-2",
|
||||
]);
|
||||
expect(result.lockedCount).toBe(0);
|
||||
});
|
||||
|
||||
it("lockedCount counts only configurable providers attached elsewhere, not dynamic ones", () => {
|
||||
const result = getSelectableProviders(
|
||||
[provider("aws-1"), provider("aws-2"), provider("dyn-1", true)],
|
||||
[config("other", ["aws-2"])],
|
||||
null,
|
||||
);
|
||||
|
||||
expect(result.selectableProviders.map((p) => p.id)).toEqual(["aws-1"]);
|
||||
expect(result.configurableCount).toBe(2);
|
||||
expect(result.lockedCount).toBe(1);
|
||||
});
|
||||
|
||||
it("reports zero configurable providers when every provider is dynamic", () => {
|
||||
const result = getSelectableProviders(
|
||||
[provider("dyn-1", true), provider("dyn-2", true)],
|
||||
[],
|
||||
null,
|
||||
);
|
||||
|
||||
expect(result.selectableProviders).toEqual([]);
|
||||
expect(result.configurableCount).toBe(0);
|
||||
expect(result.lockedCount).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import { ProviderProps } from "@/types/providers";
|
||||
import { ScanConfigurationData } from "@/types/scan-configurations";
|
||||
|
||||
export interface SelectableProvidersResult {
|
||||
selectableProviders: ProviderProps[];
|
||||
/** Providers eligible for a Scan Configuration at all (i.e. non-dynamic). */
|
||||
configurableCount: number;
|
||||
/** Configurable providers hidden because another config already owns them. */
|
||||
lockedCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Providers that can be attached to a Scan Configuration.
|
||||
*
|
||||
* Dynamic providers are SDK-defined and have no `config.yaml` baseline to
|
||||
* override, so a Scan Configuration can't apply to them — they are never
|
||||
* selectable. A provider can also belong to only one config at a time, so those
|
||||
* already attached to *another* config are excluded (the one being edited is
|
||||
* kept selectable).
|
||||
*/
|
||||
export function getSelectableProviders(
|
||||
richProviders: ProviderProps[],
|
||||
existingConfigs: ScanConfigurationData[],
|
||||
currentConfigId: string | null,
|
||||
): SelectableProvidersResult {
|
||||
const attachedElsewhere = new Set<string>();
|
||||
for (const c of existingConfigs) {
|
||||
if (currentConfigId && c.id === currentConfigId) continue;
|
||||
for (const pid of c.attributes.providers || []) {
|
||||
attachedElsewhere.add(pid);
|
||||
}
|
||||
}
|
||||
|
||||
const configurableProviders = richProviders.filter(
|
||||
(p) => !p.attributes.is_dynamic,
|
||||
);
|
||||
const selectableProviders = configurableProviders.filter(
|
||||
(p) => !attachedElsewhere.has(p.id),
|
||||
);
|
||||
|
||||
return {
|
||||
selectableProviders,
|
||||
configurableCount: configurableProviders.length,
|
||||
lockedCount: configurableProviders.length - selectableProviders.length,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
Dynamically registered providers are now listed, filtered, and rendered across the UI, using a generic icon and humanized label when no bespoke assets exist, a "Custom" badge, and read-only handling for non-configurable providers
|
||||
@@ -77,6 +77,7 @@ const makeProvider = ({
|
||||
type: "providers",
|
||||
attributes: {
|
||||
provider,
|
||||
is_dynamic: false,
|
||||
uid,
|
||||
alias,
|
||||
status: "completed",
|
||||
|
||||
@@ -33,13 +33,15 @@ describe("ProviderIconCell", () => {
|
||||
expect(await screen.findByText("AWS")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders a '?' placeholder for a provider type missing from the map", () => {
|
||||
render(
|
||||
it("renders the neutral generic glyph for a provider missing from the map", () => {
|
||||
// Dynamic/unknown providers render the shared generic glyph, not a "?".
|
||||
const { container } = render(
|
||||
<ProviderIconCell
|
||||
provider={"future-provider" as unknown as ProviderType}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("?")).toBeInTheDocument();
|
||||
expect(screen.queryByText("?")).not.toBeInTheDocument();
|
||||
expect(container.querySelector("svg")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
import {
|
||||
PROVIDER_TYPE_DATA,
|
||||
ProviderTypeIcon,
|
||||
} from "@/components/icons/providers-badge/provider-type-icon";
|
||||
import { ProviderTypeIcon } from "@/components/icons/providers-badge/provider-type-icon";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ProviderType } from "@/types";
|
||||
|
||||
@@ -16,16 +13,6 @@ export const ProviderIconCell = ({
|
||||
size = 26,
|
||||
className = "size-8 rounded-md bg-white",
|
||||
}: ProviderIconCellProps) => {
|
||||
// Unknown provider types (present in the data but missing from the shared
|
||||
// PROVIDER_TYPE_DATA map) render an explicit "?" rather than an empty icon.
|
||||
if (!(provider in PROVIDER_TYPE_DATA)) {
|
||||
return (
|
||||
<div className={cn("flex items-center justify-center", className)}>
|
||||
<span className="text-text-neutral-secondary text-xs">?</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Boxes } from "lucide-react";
|
||||
import { type FC } from "react";
|
||||
|
||||
import { IconSvgProps } from "@/types";
|
||||
|
||||
/**
|
||||
* Neutral fallback glyph for any dynamic provider
|
||||
*/
|
||||
export const GenericProviderBadge: FC<IconSvgProps> = ({
|
||||
size,
|
||||
width,
|
||||
height,
|
||||
...props
|
||||
}) => (
|
||||
<Boxes
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
width={size ?? width}
|
||||
height={size ?? height}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
@@ -50,17 +50,17 @@ describe("ProviderTypeIcon", () => {
|
||||
expect(await screen.findByText("AWS")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders a sized placeholder instead of crashing for an unknown type", () => {
|
||||
// Regression guard for #9991: an unknown provider type must not throw.
|
||||
it("renders the neutral generic glyph for an unknown type (no crash)", () => {
|
||||
// Regression guard for #9991 + dynamic providers: an unknown provider type
|
||||
// renders the shared generic glyph, not a brand badge or an empty box.
|
||||
const { container } = render(
|
||||
<ProviderTypeIcon type={UNKNOWN_TYPE} size={24} />,
|
||||
);
|
||||
|
||||
expect(screen.queryByText("AWS")).not.toBeInTheDocument();
|
||||
expect(container.querySelector("div")).toHaveStyle({
|
||||
width: "24px",
|
||||
height: "24px",
|
||||
});
|
||||
const glyph = container.querySelector("svg");
|
||||
expect(glyph).toBeInTheDocument();
|
||||
expect(glyph).toHaveAttribute("width", "24");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -111,9 +111,10 @@ describe("ProviderTypeIconStack", () => {
|
||||
expect(screen.queryByText("Okta")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders known icons and skips unknown types without crashing", async () => {
|
||||
// Regression guard for #9991: an unknown type in the stack must not throw.
|
||||
render(
|
||||
it("renders known icons and the generic glyph for unknown types without crashing", async () => {
|
||||
// Regression guard for #9991: an unknown type in the stack must not throw;
|
||||
// it now renders the neutral generic glyph alongside the known badge.
|
||||
const { container } = render(
|
||||
<ProviderTypeIconStack
|
||||
items={[
|
||||
{ key: "a", type: "aws", tooltip: "111" },
|
||||
@@ -123,5 +124,7 @@ describe("ProviderTypeIconStack", () => {
|
||||
);
|
||||
|
||||
expect(await screen.findByText("AWS")).toBeInTheDocument();
|
||||
// The unknown item renders the generic glyph (an svg), not an empty slot.
|
||||
expect(container.querySelector("svg")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,7 +9,13 @@ import {
|
||||
TooltipTrigger,
|
||||
} from "@/components/shadcn";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ProviderType } from "@/types/providers";
|
||||
import {
|
||||
isKnownProviderType,
|
||||
type KnownProviderType,
|
||||
type ProviderType,
|
||||
} from "@/types/providers";
|
||||
|
||||
import { GenericProviderBadge } from "./generic-provider-badge";
|
||||
|
||||
type IconProps = { width: number; height: number };
|
||||
|
||||
@@ -106,7 +112,7 @@ const OktaProviderBadge = lazy(() =>
|
||||
* selectors so both stay in sync on labels, icons, and sizing.
|
||||
*/
|
||||
export const PROVIDER_TYPE_DATA: Record<
|
||||
ProviderType,
|
||||
KnownProviderType,
|
||||
{ label: string; icon: ComponentType<IconProps> }
|
||||
> = {
|
||||
aws: { label: "Amazon Web Services", icon: AWSProviderBadge },
|
||||
@@ -139,21 +145,21 @@ interface ProviderTypeIconProps {
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a single provider-type badge with a sized placeholder fallback.
|
||||
* Renders a single provider-type badge.
|
||||
*
|
||||
* Falls back to the placeholder for provider types missing from
|
||||
* `PROVIDER_TYPE_DATA` (e.g. a brand-new provider the API knows but this UI
|
||||
* build does not). The `type` is statically typed as `ProviderType`, so this
|
||||
* only guards the runtime case — see #9991, which fixed the same crash class.
|
||||
* Providers outside the known set — a dynamic SDK plug-in — render the neutral
|
||||
* generic glyph instead of a bespoke brand badge. The widened `ProviderType`
|
||||
* forces this fallback branch to be handled at compile time.
|
||||
*/
|
||||
export const ProviderTypeIcon = ({
|
||||
type,
|
||||
size = 18,
|
||||
}: ProviderTypeIconProps) => {
|
||||
const data = PROVIDER_TYPE_DATA[type];
|
||||
if (!data) return <IconPlaceholder width={size} height={size} />;
|
||||
if (!isKnownProviderType(type)) {
|
||||
return <GenericProviderBadge size={size} />;
|
||||
}
|
||||
|
||||
const Icon = data.icon;
|
||||
const Icon = PROVIDER_TYPE_DATA[type].icon;
|
||||
return (
|
||||
<Suspense fallback={<IconPlaceholder width={size} height={size} />}>
|
||||
<Icon width={size} height={size} />
|
||||
|
||||
@@ -64,6 +64,7 @@ const createProviderRow = (
|
||||
type: "providers",
|
||||
attributes: {
|
||||
provider: "aws",
|
||||
is_dynamic: false,
|
||||
uid,
|
||||
alias,
|
||||
status: "completed",
|
||||
|
||||
@@ -96,6 +96,7 @@ const disconnectedProviders: ProviderProps[] = [
|
||||
type: "providers",
|
||||
attributes: {
|
||||
provider: "aws",
|
||||
is_dynamic: false,
|
||||
uid: "123456789012",
|
||||
alias: "Production",
|
||||
status: "completed",
|
||||
|
||||
@@ -63,6 +63,7 @@ const providerRow: ProvidersProviderRow = {
|
||||
type: "providers",
|
||||
attributes: {
|
||||
provider: "aws",
|
||||
is_dynamic: false,
|
||||
uid: "123456789012",
|
||||
alias: "Production",
|
||||
status: "completed",
|
||||
|
||||
@@ -198,6 +198,11 @@ export function getColumnProviders(
|
||||
cloudProvider={provider.attributes.provider}
|
||||
entityAlias={provider.attributes.alias}
|
||||
entityId={provider.attributes.uid}
|
||||
nameAction={
|
||||
provider.attributes.is_dynamic ? (
|
||||
<Badge variant="info">Custom</Badge>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
</DataTableExpandableCell>
|
||||
);
|
||||
|
||||
@@ -141,6 +141,51 @@ const createRow = (hasSecret = false) =>
|
||||
},
|
||||
}) as unknown as Row<ProvidersTableRow>;
|
||||
|
||||
// A dynamic provider outside the hardcoded configurable set (read-only in the UI).
|
||||
const createDynamicRow = (hasSecret = true) =>
|
||||
({
|
||||
original: {
|
||||
id: "provider-dyn-1",
|
||||
rowType: PROVIDERS_ROW_TYPE.PROVIDER,
|
||||
type: "providers",
|
||||
attributes: {
|
||||
provider: "template",
|
||||
is_dynamic: true,
|
||||
uid: "template-account-0001",
|
||||
alias: "Template Plug-in",
|
||||
status: "completed",
|
||||
resources: 0,
|
||||
connection: {
|
||||
connected: true,
|
||||
last_checked_at: "2025-02-13T11:17:00Z",
|
||||
},
|
||||
scanner_args: {
|
||||
only_logs: false,
|
||||
excluded_checks: [],
|
||||
aws_retries_max_attempts: 3,
|
||||
},
|
||||
inserted_at: "2025-02-13T11:17:00Z",
|
||||
updated_at: "2025-02-13T11:17:00Z",
|
||||
created_by: {
|
||||
object: "user",
|
||||
id: "user-1",
|
||||
},
|
||||
},
|
||||
relationships: {
|
||||
secret: {
|
||||
data: hasSecret ? { id: "secret-1", type: "secrets" } : null,
|
||||
},
|
||||
provider_groups: {
|
||||
meta: {
|
||||
count: 0,
|
||||
},
|
||||
data: [],
|
||||
},
|
||||
},
|
||||
groupNames: [],
|
||||
},
|
||||
}) as unknown as Row<ProvidersTableRow>;
|
||||
|
||||
const createOrgRow = () =>
|
||||
({
|
||||
original: {
|
||||
@@ -258,6 +303,37 @@ describe("DataTableRowActions", () => {
|
||||
expect(screen.queryByText("Update Credentials")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("blocks provider-account actions (alias/credentials/delete) for a dynamic provider but keeps operational actions", async () => {
|
||||
// Given a dynamic provider outside the configurable set, with the advanced
|
||||
// schedule capability enabled (so Edit Scan Schedule can show).
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<DataTableRowActions
|
||||
row={createDynamicRow(true)}
|
||||
hasSelection={false}
|
||||
isRowSelected={false}
|
||||
testableProviderIds={[]}
|
||||
onClearSelection={vi.fn()}
|
||||
onOpenProviderWizard={vi.fn()}
|
||||
onOpenOrganizationWizard={vi.fn()}
|
||||
capability={SCAN_SCHEDULE_CAPABILITY.ADVANCED}
|
||||
/>,
|
||||
);
|
||||
|
||||
// When
|
||||
await user.click(screen.getByRole("button"));
|
||||
|
||||
// Then — provider-account CRUD is suppressed (can't add/edit/delete it)
|
||||
expect(screen.queryByText("Edit Provider Alias")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Add Credentials")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Update Credentials")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Delete Provider")).not.toBeInTheDocument();
|
||||
// ...but operational actions the provider exists for stay available
|
||||
expect(screen.getByText("Test Connection")).toBeInTheDocument();
|
||||
expect(screen.getByText("View Scan Jobs")).toBeInTheDocument();
|
||||
expect(screen.getByText("Edit Scan Schedule")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("navigates to the provider-filtered scan jobs from View Scan Jobs", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
@@ -449,6 +525,37 @@ describe("DataTableRowActions", () => {
|
||||
expect(item).toHaveAttribute("aria-disabled", "true");
|
||||
});
|
||||
|
||||
it("hides Edit Scan Configuration for dynamic providers in Prowler Cloud", async () => {
|
||||
// Given a dynamic provider in a Cloud tenant with scan configs available.
|
||||
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true");
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<DataTableRowActions
|
||||
row={createDynamicRow(true)}
|
||||
hasSelection={false}
|
||||
isRowSelected={false}
|
||||
testableProviderIds={[]}
|
||||
onClearSelection={vi.fn()}
|
||||
onOpenProviderWizard={vi.fn()}
|
||||
onOpenOrganizationWizard={vi.fn()}
|
||||
scanConfigs={[]}
|
||||
/>,
|
||||
);
|
||||
|
||||
// When
|
||||
await user.click(screen.getByRole("button"));
|
||||
|
||||
// Then — dynamic providers can't use a Scan Configuration, so the action is
|
||||
// absent entirely, not merely shown as "unavailable".
|
||||
expect(
|
||||
screen.queryByText("Edit Scan Configuration"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText("Scan Configuration unavailable"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders Update Credentials for provider rows with credentials", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
|
||||
@@ -39,6 +39,7 @@ import { getScanScheduleCapability } from "@/lib/schedules";
|
||||
import { isCloud } from "@/lib/shared/env";
|
||||
import { ORG_SETUP_PHASE, ORG_WIZARD_STEP } from "@/types/organizations";
|
||||
import { PROVIDER_WIZARD_MODE } from "@/types/provider-wizard";
|
||||
import { isConfigurableProvider } from "@/types/providers";
|
||||
import {
|
||||
isProvidersOrganizationRow,
|
||||
PROVIDERS_GROUP_KIND,
|
||||
@@ -308,14 +309,20 @@ export function DataTableRowActions({
|
||||
const isOrganizationRow = isProvidersOrganizationRow(rowData);
|
||||
const provider = isOrganizationRow ? null : rowData;
|
||||
const providerId = provider?.id ?? "";
|
||||
const providerType = provider?.attributes.provider ?? "aws";
|
||||
const providerType = provider?.attributes.provider ?? "";
|
||||
// Only predefined providers can be managed from the UI
|
||||
const canManageProvider = isConfigurableProvider(providerType);
|
||||
const providerUid = provider?.attributes.uid ?? "";
|
||||
const providerAlias = provider?.attributes.alias ?? null;
|
||||
const providerSecretId = provider?.relationships.secret.data?.id ?? null;
|
||||
const hasSecret = Boolean(provider?.relationships.secret.data);
|
||||
const isCloudProvider = isCloud() && Boolean(provider);
|
||||
// Dynamic providers have no config.yaml baseline, so a Scan Configuration
|
||||
// can't apply to them — hide the action entirely.
|
||||
const isDynamicProvider = Boolean(provider?.attributes.is_dynamic);
|
||||
const canManageScanConfig =
|
||||
isCloudProvider &&
|
||||
!isDynamicProvider &&
|
||||
scanConfigStatus === SCAN_CONFIGURATION_LIST_STATUS.AVAILABLE;
|
||||
const scheduleProvider: ScanScheduleProvider | undefined = provider
|
||||
? {
|
||||
@@ -583,11 +590,13 @@ export function DataTableRowActions({
|
||||
)}
|
||||
<div className="relative flex items-center justify-end gap-2">
|
||||
<ActionDropdown>
|
||||
<ActionDropdownItem
|
||||
icon={<Pencil />}
|
||||
label="Edit Provider Alias"
|
||||
onSelect={() => setIsEditOpen(true)}
|
||||
/>
|
||||
{canManageProvider && (
|
||||
<ActionDropdownItem
|
||||
icon={<Pencil />}
|
||||
label="Edit Provider Alias"
|
||||
onSelect={() => setIsEditOpen(true)}
|
||||
/>
|
||||
)}
|
||||
<ActionDropdownItem
|
||||
icon={<Timer />}
|
||||
label="View Scan Jobs"
|
||||
@@ -616,6 +625,7 @@ export function DataTableRowActions({
|
||||
/>
|
||||
)}
|
||||
{isCloudProvider &&
|
||||
!isDynamicProvider &&
|
||||
scanConfigStatus === SCAN_CONFIGURATION_LIST_STATUS.UNAVAILABLE && (
|
||||
<ActionDropdownItem
|
||||
icon={<SlidersHorizontal />}
|
||||
@@ -624,22 +634,24 @@ export function DataTableRowActions({
|
||||
disabled
|
||||
/>
|
||||
)}
|
||||
<ActionDropdownItem
|
||||
icon={<KeyRound />}
|
||||
label={hasSecret ? "Update Credentials" : "Add Credentials"}
|
||||
onSelect={() =>
|
||||
onOpenProviderWizard({
|
||||
providerId,
|
||||
providerType,
|
||||
providerUid,
|
||||
providerAlias,
|
||||
secretId: providerSecretId,
|
||||
mode: providerSecretId
|
||||
? PROVIDER_WIZARD_MODE.UPDATE
|
||||
: PROVIDER_WIZARD_MODE.ADD,
|
||||
})
|
||||
}
|
||||
/>
|
||||
{canManageProvider && (
|
||||
<ActionDropdownItem
|
||||
icon={<KeyRound />}
|
||||
label={hasSecret ? "Update Credentials" : "Add Credentials"}
|
||||
onSelect={() =>
|
||||
onOpenProviderWizard({
|
||||
providerId,
|
||||
providerType,
|
||||
providerUid,
|
||||
providerAlias,
|
||||
secretId: providerSecretId,
|
||||
mode: providerSecretId
|
||||
? PROVIDER_WIZARD_MODE.UPDATE
|
||||
: PROVIDER_WIZARD_MODE.ADD,
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<ActionDropdownItem
|
||||
icon={<Rocket />}
|
||||
label={loading ? "Testing..." : "Test Connection"}
|
||||
@@ -649,14 +661,16 @@ export function DataTableRowActions({
|
||||
}}
|
||||
disabled={!hasSecret || loading}
|
||||
/>
|
||||
<ActionDropdownDangerZone>
|
||||
<ActionDropdownItem
|
||||
icon={<Trash2 />}
|
||||
label="Delete Provider"
|
||||
destructive
|
||||
onSelect={() => setIsDeleteOpen(true)}
|
||||
/>
|
||||
</ActionDropdownDangerZone>
|
||||
{canManageProvider && (
|
||||
<ActionDropdownDangerZone>
|
||||
<ActionDropdownItem
|
||||
icon={<Trash2 />}
|
||||
label="Delete Provider"
|
||||
destructive
|
||||
onSelect={() => setIsDeleteOpen(true)}
|
||||
/>
|
||||
</ActionDropdownDangerZone>
|
||||
)}
|
||||
</ActionDropdown>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -14,7 +14,12 @@ import { ProviderTitleDocs } from "@/components/providers/workflow/provider-titl
|
||||
import { Button } from "@/components/shadcn";
|
||||
import { useToast } from "@/components/shadcn";
|
||||
import { Form } from "@/components/shadcn/form";
|
||||
import { addProviderFormSchema, ApiError, ProviderType } from "@/types";
|
||||
import {
|
||||
addProviderFormSchema,
|
||||
ApiError,
|
||||
KnownProviderType,
|
||||
ProviderType,
|
||||
} from "@/types";
|
||||
|
||||
import { RadioGroupProvider } from "../../radio-group-provider";
|
||||
|
||||
@@ -156,7 +161,7 @@ function applyBackStep({
|
||||
|
||||
setPrevStep((prev) => prev - 1);
|
||||
if (prevStep === 2) {
|
||||
form.setValue("providerType", undefined as unknown as ProviderType, {
|
||||
form.setValue("providerType", undefined as unknown as KnownProviderType, {
|
||||
shouldValidate: false,
|
||||
});
|
||||
setAwsMethod(null);
|
||||
|
||||
@@ -130,15 +130,17 @@ import {
|
||||
ACTION_ERROR_MESSAGES,
|
||||
ACTION_ERROR_STATUS,
|
||||
} from "@/lib/action-errors";
|
||||
import { ProviderProps } from "@/types";
|
||||
import { SCAN_SCHEDULE_CAPABILITY } from "@/types/schedules";
|
||||
|
||||
import { LaunchScanModal } from "./launch-scan-modal";
|
||||
|
||||
const provider = {
|
||||
const provider: ProviderProps = {
|
||||
id: "provider-1",
|
||||
type: "providers" as const,
|
||||
attributes: {
|
||||
provider: "aws" as const,
|
||||
is_dynamic: false,
|
||||
uid: "123456789012",
|
||||
alias: "Production",
|
||||
status: "completed" as const,
|
||||
|
||||
@@ -3,6 +3,7 @@ import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { useScansStore } from "@/store";
|
||||
import { ProviderProps } from "@/types";
|
||||
|
||||
import { ScansPageShell } from "./scans-page-shell";
|
||||
|
||||
@@ -102,12 +103,13 @@ vi.mock("@/components/providers/muted-findings-config-button", () => ({
|
||||
MutedFindingsConfigButton: () => <a href="/mutelist">Configure Mutelist</a>,
|
||||
}));
|
||||
|
||||
const providers = [
|
||||
const providers: ProviderProps[] = [
|
||||
{
|
||||
id: "provider-1",
|
||||
type: "providers" as const,
|
||||
attributes: {
|
||||
provider: "aws" as const,
|
||||
is_dynamic: false,
|
||||
uid: "123456789012",
|
||||
alias: "Production",
|
||||
status: "completed" as const,
|
||||
|
||||
@@ -177,6 +177,7 @@ describe("buildPendingScheduleRows", () => {
|
||||
type: "providers",
|
||||
attributes: {
|
||||
provider: "aws",
|
||||
is_dynamic: false,
|
||||
uid: `uid-${id}`,
|
||||
alias: `alias-${id}`,
|
||||
status: "completed",
|
||||
|
||||
@@ -16,7 +16,9 @@ import {
|
||||
OracleCloudProviderBadge,
|
||||
VercelProviderBadge,
|
||||
} from "@/components/icons/providers-badge";
|
||||
import { GenericProviderBadge } from "@/components/icons/providers-badge/generic-provider-badge";
|
||||
import { ProviderType } from "@/types";
|
||||
import { getProviderDisplayName } from "@/types/providers";
|
||||
|
||||
export const getProviderLogo = (provider: ProviderType) => {
|
||||
switch (provider) {
|
||||
@@ -53,7 +55,7 @@ export const getProviderLogo = (provider: ProviderType) => {
|
||||
case "okta":
|
||||
return <OktaProviderBadge width={35} height={35} />;
|
||||
default:
|
||||
return null;
|
||||
return <GenericProviderBadge width={35} height={35} />;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -92,6 +94,6 @@ export const getProviderName = (provider: ProviderType): string => {
|
||||
case "okta":
|
||||
return "Okta";
|
||||
default:
|
||||
return "Unknown Provider";
|
||||
return getProviderDisplayName(provider);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { filterEmptyValues, getFormValue } from "@/lib";
|
||||
import { ProviderType } from "@/types";
|
||||
import {
|
||||
isConfigurableProvider,
|
||||
type KnownProviderType,
|
||||
} from "@/types/providers";
|
||||
|
||||
import { ProviderCredentialFields } from "./provider-credential-fields";
|
||||
|
||||
@@ -462,7 +466,10 @@ export const buildSecretConfig = (
|
||||
const isServiceAccount =
|
||||
formData.get(ProviderCredentialFields.SERVICE_ACCOUNT_KEY) !== null;
|
||||
|
||||
const secretBuilders = {
|
||||
const secretBuilders: Record<
|
||||
KnownProviderType,
|
||||
() => { secretType: string; secret: unknown }
|
||||
> = {
|
||||
aws: () => ({
|
||||
secretType: isRole ? "role" : "static",
|
||||
secret: buildAWSSecret(formData, isRole),
|
||||
@@ -533,10 +540,9 @@ export const buildSecretConfig = (
|
||||
}),
|
||||
};
|
||||
|
||||
const builder = secretBuilders[providerType];
|
||||
if (!builder) {
|
||||
if (!isConfigurableProvider(providerType)) {
|
||||
throw new Error(`Unsupported provider type: ${providerType}`);
|
||||
}
|
||||
|
||||
return builder();
|
||||
return secretBuilders[providerType]();
|
||||
};
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
appendSanitizedProviderInFilters,
|
||||
appendSanitizedProviderTypeFilters,
|
||||
} from "./provider-filters";
|
||||
|
||||
const PROVIDER_TYPE_IN = "filter[provider_type__in]";
|
||||
const PROVIDER_IN = "filter[provider__in]";
|
||||
|
||||
const makeUrl = () => new URL("https://api.test/v1/providers");
|
||||
|
||||
describe("appendSanitizedProviderTypeFilters", () => {
|
||||
it("forwards a known provider type unchanged", () => {
|
||||
// Given
|
||||
const url = makeUrl();
|
||||
// When
|
||||
appendSanitizedProviderTypeFilters(url, { [PROVIDER_TYPE_IN]: "aws" });
|
||||
// Then
|
||||
expect(url.searchParams.get(PROVIDER_TYPE_IN)).toBe("aws");
|
||||
});
|
||||
|
||||
it("forwards a dynamic provider type verbatim (not dropped or replaced)", () => {
|
||||
// Given
|
||||
const url = makeUrl();
|
||||
// When
|
||||
appendSanitizedProviderTypeFilters(url, { [PROVIDER_TYPE_IN]: "template" });
|
||||
// Then
|
||||
expect(url.searchParams.get(PROVIDER_TYPE_IN)).toBe("template");
|
||||
});
|
||||
|
||||
it("injects no provider-type allowlist when nothing is selected", () => {
|
||||
// Given
|
||||
const url = makeUrl();
|
||||
// When
|
||||
appendSanitizedProviderTypeFilters(url, {});
|
||||
// Then
|
||||
expect(url.searchParams.has(PROVIDER_TYPE_IN)).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps a mixed known + dynamic selection intact", () => {
|
||||
// Given
|
||||
const url = makeUrl();
|
||||
// When
|
||||
appendSanitizedProviderTypeFilters(url, {
|
||||
[PROVIDER_TYPE_IN]: "aws,template",
|
||||
});
|
||||
// Then
|
||||
expect(url.searchParams.get(PROVIDER_TYPE_IN)).toBe("aws,template");
|
||||
});
|
||||
|
||||
it("excludes the search filter by default", () => {
|
||||
// Given
|
||||
const url = makeUrl();
|
||||
// When
|
||||
appendSanitizedProviderTypeFilters(url, {
|
||||
"filter[search]": "prod",
|
||||
[PROVIDER_TYPE_IN]: "template",
|
||||
});
|
||||
// Then
|
||||
expect(url.searchParams.has("filter[search]")).toBe(false);
|
||||
expect(url.searchParams.get(PROVIDER_TYPE_IN)).toBe("template");
|
||||
});
|
||||
});
|
||||
|
||||
describe("appendSanitizedProviderInFilters", () => {
|
||||
it("forwards a dynamic provider id verbatim", () => {
|
||||
// Given
|
||||
const url = makeUrl();
|
||||
// When
|
||||
appendSanitizedProviderInFilters(url, { [PROVIDER_IN]: "provider-uuid" });
|
||||
// Then
|
||||
expect(url.searchParams.get(PROVIDER_IN)).toBe("provider-uuid");
|
||||
});
|
||||
|
||||
it("injects no provider allowlist when nothing is selected", () => {
|
||||
// Given
|
||||
const url = makeUrl();
|
||||
// When
|
||||
appendSanitizedProviderInFilters(url, {});
|
||||
// Then
|
||||
expect(url.searchParams.has(PROVIDER_IN)).toBe(false);
|
||||
});
|
||||
});
|
||||
+8
-104
@@ -1,116 +1,25 @@
|
||||
import { PROVIDER_TYPES, type ProviderType } from "@/types/providers";
|
||||
|
||||
export type ProviderFilterValue = string | string[] | undefined;
|
||||
export type ProviderFilters = Record<string, ProviderFilterValue>;
|
||||
|
||||
interface AppendProviderFiltersOptions {
|
||||
ensuredInFilterKey?: string;
|
||||
excludedKeys?: string[];
|
||||
excludedKeyIncludes?: string[];
|
||||
}
|
||||
|
||||
type AppendSanitizedProviderTypeFiltersOptions = Omit<
|
||||
AppendProviderFiltersOptions,
|
||||
"ensuredInFilterKey"
|
||||
>;
|
||||
|
||||
const SUPPORTED_PROVIDER_TYPES_CSV = PROVIDER_TYPES.join(",");
|
||||
export const PROVIDER_IN_FILTER_KEY = "filter[provider__in]";
|
||||
export const PROVIDER_TYPE_IN_FILTER_KEY = "filter[provider_type__in]";
|
||||
|
||||
const PROVIDER_TYPE_IN_KEYS = new Set([
|
||||
PROVIDER_TYPE_IN_FILTER_KEY,
|
||||
"provider_type__in",
|
||||
]);
|
||||
|
||||
const PROVIDER_SINGLE_KEYS = new Set([
|
||||
"filter[provider_type]",
|
||||
"provider_type",
|
||||
]);
|
||||
|
||||
const toCsvString = (value: unknown): string => {
|
||||
if (Array.isArray(value)) return value.join(",");
|
||||
if (typeof value === "string") return value;
|
||||
return "";
|
||||
};
|
||||
|
||||
const isSupportedProviderType = (value: string): value is ProviderType =>
|
||||
(PROVIDER_TYPES as readonly string[]).includes(value);
|
||||
|
||||
export const sanitizeProviderTypesCsv = (value?: unknown): string => {
|
||||
const rawValue = toCsvString(value);
|
||||
if (!rawValue.trim()) return SUPPORTED_PROVIDER_TYPES_CSV;
|
||||
|
||||
const supportedProviderTypes = Array.from(
|
||||
new Set(
|
||||
rawValue
|
||||
.split(",")
|
||||
.map((providerType) => providerType.trim())
|
||||
.filter(isSupportedProviderType),
|
||||
),
|
||||
);
|
||||
|
||||
return supportedProviderTypes.length > 0
|
||||
? supportedProviderTypes.join(",")
|
||||
: SUPPORTED_PROVIDER_TYPES_CSV;
|
||||
};
|
||||
|
||||
export const sanitizeProviderType = (
|
||||
value?: unknown,
|
||||
): ProviderType | undefined => {
|
||||
const rawValue = toCsvString(value);
|
||||
if (!rawValue.trim()) return undefined;
|
||||
|
||||
return rawValue
|
||||
.split(",")
|
||||
.map((providerType) => providerType.trim())
|
||||
.find(isSupportedProviderType);
|
||||
};
|
||||
|
||||
export const sanitizeProviderFilters = (
|
||||
filters: ProviderFilters = {},
|
||||
ensuredInFilterKey?: string,
|
||||
): ProviderFilters => {
|
||||
const sanitizedFilters: ProviderFilters = { ...filters };
|
||||
|
||||
Object.keys(sanitizedFilters).forEach((key) => {
|
||||
if (PROVIDER_TYPE_IN_KEYS.has(key)) {
|
||||
sanitizedFilters[key] = sanitizeProviderTypesCsv(sanitizedFilters[key]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (PROVIDER_SINGLE_KEYS.has(key)) {
|
||||
const providerType = sanitizeProviderType(sanitizedFilters[key]);
|
||||
if (providerType) {
|
||||
sanitizedFilters[key] = providerType;
|
||||
} else {
|
||||
delete sanitizedFilters[key];
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (ensuredInFilterKey) {
|
||||
sanitizedFilters[ensuredInFilterKey] = sanitizeProviderTypesCsv(
|
||||
sanitizedFilters[ensuredInFilterKey],
|
||||
);
|
||||
}
|
||||
|
||||
return sanitizedFilters;
|
||||
};
|
||||
|
||||
export const appendSanitizedProviderFilters = (
|
||||
url: URL,
|
||||
filters: ProviderFilters = {},
|
||||
{
|
||||
ensuredInFilterKey,
|
||||
excludedKeys = ["filter[search]"],
|
||||
excludedKeyIncludes = [],
|
||||
}: AppendProviderFiltersOptions = {},
|
||||
): void => {
|
||||
const sanitizedFilters = sanitizeProviderFilters(filters, ensuredInFilterKey);
|
||||
const excludedKeysSet = new Set(excludedKeys);
|
||||
|
||||
Object.entries(sanitizedFilters).forEach(([key, value]) => {
|
||||
Object.entries(filters).forEach(([key, value]) => {
|
||||
if (
|
||||
value === undefined ||
|
||||
excludedKeysSet.has(key) ||
|
||||
@@ -123,22 +32,17 @@ export const appendSanitizedProviderFilters = (
|
||||
});
|
||||
};
|
||||
|
||||
// Named aliases so call sites read by intent (provider-type vs provider-id
|
||||
// filters). Both forward to appendSanitizedProviderFilters unchanged: the UI no
|
||||
// longer forces a provider allowlist, so neither injects a default filter.
|
||||
export const appendSanitizedProviderTypeFilters = (
|
||||
url: URL,
|
||||
filters: ProviderFilters = {},
|
||||
options: AppendSanitizedProviderTypeFiltersOptions = {},
|
||||
): void =>
|
||||
appendSanitizedProviderFilters(url, filters, {
|
||||
...options,
|
||||
ensuredInFilterKey: PROVIDER_TYPE_IN_FILTER_KEY,
|
||||
});
|
||||
options: AppendProviderFiltersOptions = {},
|
||||
): void => appendSanitizedProviderFilters(url, filters, options);
|
||||
|
||||
export const appendSanitizedProviderInFilters = (
|
||||
url: URL,
|
||||
filters: ProviderFilters = {},
|
||||
options: AppendSanitizedProviderTypeFiltersOptions = {},
|
||||
): void =>
|
||||
appendSanitizedProviderFilters(url, filters, {
|
||||
...options,
|
||||
ensuredInFilterKey: PROVIDER_IN_FILTER_KEY,
|
||||
});
|
||||
options: AppendProviderFiltersOptions = {},
|
||||
): void => appendSanitizedProviderFilters(url, filters, options);
|
||||
|
||||
@@ -46,7 +46,7 @@ export const createProviderDetailsMapping = (
|
||||
|
||||
return {
|
||||
[uid]: {
|
||||
provider: provider?.attributes?.provider || "aws",
|
||||
provider: provider?.attributes?.provider ?? "",
|
||||
uid: uid,
|
||||
alias: provider?.attributes?.alias ?? null,
|
||||
},
|
||||
@@ -65,7 +65,7 @@ export const createProviderDetailsMappingById = (
|
||||
|
||||
return {
|
||||
[id]: {
|
||||
provider: provider?.attributes?.provider || "aws",
|
||||
provider: provider?.attributes?.provider ?? "",
|
||||
uid: provider?.attributes?.uid || "",
|
||||
alias: provider?.attributes?.alias ?? null,
|
||||
},
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
getProviderDisplayName,
|
||||
humanizeProviderId,
|
||||
isConfigurableProvider,
|
||||
isKnownProviderType,
|
||||
} from "./providers";
|
||||
|
||||
describe("humanizeProviderId", () => {
|
||||
it("capitalizes a single-word id", () => {
|
||||
expect(humanizeProviderId("template")).toBe("Template");
|
||||
});
|
||||
|
||||
it("splits on hyphens and capitalizes each word", () => {
|
||||
expect(humanizeProviderId("local-template")).toBe("Local Template");
|
||||
});
|
||||
|
||||
it("splits on underscores", () => {
|
||||
expect(humanizeProviderId("foo_bar")).toBe("Foo Bar");
|
||||
});
|
||||
|
||||
it("collapses repeated separators and trims empties", () => {
|
||||
expect(humanizeProviderId("a--b__c")).toBe("A B C");
|
||||
});
|
||||
|
||||
it("returns an empty string for an empty id", () => {
|
||||
expect(humanizeProviderId("")).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getProviderDisplayName", () => {
|
||||
it("returns the configured label for a known provider", () => {
|
||||
expect(getProviderDisplayName("aws")).toBe("AWS");
|
||||
expect(getProviderDisplayName("gcp")).toBe("Google Cloud");
|
||||
});
|
||||
|
||||
it("is case-insensitive for known providers", () => {
|
||||
expect(getProviderDisplayName("AWS")).toBe("AWS");
|
||||
});
|
||||
|
||||
it("humanizes an unknown/dynamic provider id", () => {
|
||||
expect(getProviderDisplayName("template")).toBe("Template");
|
||||
expect(getProviderDisplayName("local-template")).toBe("Local Template");
|
||||
});
|
||||
|
||||
it("normalizes case for an unknown/dynamic provider id", () => {
|
||||
expect(getProviderDisplayName("AWS_THING")).toBe("Aws Thing");
|
||||
expect(getProviderDisplayName("Local-Template")).toBe("Local Template");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isKnownProviderType / isConfigurableProvider", () => {
|
||||
it("accepts a known provider", () => {
|
||||
expect(isKnownProviderType("aws")).toBe(true);
|
||||
expect(isConfigurableProvider("aws")).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects a dynamic/unknown provider", () => {
|
||||
expect(isKnownProviderType("template")).toBe(false);
|
||||
expect(isConfigurableProvider("template")).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects an empty provider value", () => {
|
||||
expect(isKnownProviderType("")).toBe(false);
|
||||
expect(isConfigurableProvider("")).toBe(false);
|
||||
});
|
||||
});
|
||||
+34
-6
@@ -19,9 +19,23 @@ export const PROVIDER_TYPES = [
|
||||
"okta",
|
||||
] as const;
|
||||
|
||||
export type ProviderType = (typeof PROVIDER_TYPES)[number];
|
||||
/** The closed set of provider types this UI build ships bespoke assets for. */
|
||||
export type KnownProviderType = (typeof PROVIDER_TYPES)[number];
|
||||
|
||||
export const PROVIDER_DISPLAY_NAMES: Record<ProviderType, string> = {
|
||||
// Autocomplete for predefined + open for dynamic providers
|
||||
export type ProviderType = KnownProviderType | (string & {});
|
||||
|
||||
/** Type guard: narrows an arbitrary provider value to the known set. */
|
||||
export const isKnownProviderType = (
|
||||
provider: string,
|
||||
): provider is KnownProviderType =>
|
||||
(PROVIDER_TYPES as readonly string[]).includes(provider);
|
||||
|
||||
export const isConfigurableProvider = (
|
||||
provider: string,
|
||||
): provider is KnownProviderType => isKnownProviderType(provider);
|
||||
|
||||
export const PROVIDER_DISPLAY_NAMES: Record<KnownProviderType, string> = {
|
||||
aws: "AWS",
|
||||
azure: "Azure",
|
||||
gcp: "Google Cloud",
|
||||
@@ -40,11 +54,24 @@ export const PROVIDER_DISPLAY_NAMES: Record<ProviderType, string> = {
|
||||
okta: "Okta",
|
||||
};
|
||||
|
||||
/**
|
||||
* Turns a raw provider id into a human-readable label: splits on `-`/`_` and
|
||||
* capitalizes each word (e.g. `template` → "Template", `local-template` →
|
||||
* "Local Template"). Used as the display fallback for dynamic providers
|
||||
*/
|
||||
export function humanizeProviderId(id: string): string {
|
||||
return id
|
||||
.split(/[-_]+/)
|
||||
.filter(Boolean)
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
export function getProviderDisplayName(providerId: string): string {
|
||||
return (
|
||||
PROVIDER_DISPLAY_NAMES[providerId.toLowerCase() as ProviderType] ||
|
||||
providerId
|
||||
);
|
||||
const normalized = providerId.toLowerCase();
|
||||
return isKnownProviderType(normalized)
|
||||
? PROVIDER_DISPLAY_NAMES[normalized]
|
||||
: humanizeProviderId(normalized);
|
||||
}
|
||||
|
||||
export interface ProviderProps {
|
||||
@@ -52,6 +79,7 @@ export interface ProviderProps {
|
||||
type: "providers";
|
||||
attributes: {
|
||||
provider: ProviderType;
|
||||
is_dynamic: boolean;
|
||||
uid: string;
|
||||
alias: string;
|
||||
status: "completed" | "pending" | "cancelled";
|
||||
|
||||
Reference in New Issue
Block a user