feat(ui): improve scan config ux (#11731)

Co-authored-by: alejandrobailo <alejandrobailo94@gmail.com>
This commit is contained in:
Pedro Martín
2026-07-01 15:45:38 +02:00
committed by GitHub
parent 301d13a4b9
commit 69321418a3
32 changed files with 1319 additions and 317 deletions
-3
View File
@@ -6,9 +6,6 @@ All notable changes to the **Prowler UI** are documented in this file.
### 🚀 Added
- Add `Scan Configuration` menu item under the Configuration menu (only available in Prowler Cloud) [(#11695)](https://github.com/prowler-cloud/prowler/pull/11695)
- Scan configuration management page (`/scan-configurations`) to create, edit, and manage scan configurations with live YAML validation against the server JSON Schema (only available in Prowler Cloud) [(#11695)](https://github.com/prowler-cloud/prowler/pull/11695)
- Surface an "invalid scan configuration" note on compliance requirements that fail solely because the applied scan config does not meet them [(#11695)](https://github.com/prowler-cloud/prowler/pull/11695)
- Filter the Overview, Findings, Resources, Scans, and Providers views by provider group [(#11659)](https://github.com/prowler-cloud/prowler/pull/11659)
- CIS Controls v8.1 compliance support, including its detail view and report mapping [(#11700)](https://github.com/prowler-cloud/prowler/pull/11700)
@@ -14,13 +14,17 @@ import {
ScanConfigurationRequestBody,
} from "@/types/scan-configurations";
const SCAN_CONFIGURATION_PATH = "/scan-configurations";
const SCAN_CONFIGURATION_PATH = "/scans/config";
// Scan Configuration IDs are UUIDs. Validate before interpolating into request
// URLs so a malformed/crafted value can't inject path segments (SSRF / path
// injection).
const scanConfigurationIdSchema = z.uuid();
// Provider IDs are UUIDs too. Validate the whole array at the action boundary so
// a malformed/crafted id fails here instead of relying on API-side validation.
const providerIdsSchema = z.array(z.uuid());
const parseConfiguration = (value: string): Record<string, unknown> => {
// Backend (YamlOrJsonField) accepts either a YAML string or a JSON object.
// We parse client-side so failures surface as form errors, not 500s.
@@ -38,6 +42,48 @@ const collectProviderIds = (formData: FormData): string[] => {
.filter(Boolean);
};
interface ApiErrorSource {
pointer?: string;
}
interface ApiError {
detail?: string;
title?: string;
source?: ApiErrorSource;
}
// Route each JSON:API error to the matching form field via its `source.pointer`
// so it renders inline next to the offending input. Only errors we can't anchor
// to a field fall back to `general` (surfaced as a toast). Shared by create and
// update so both flows present validation errors identically — otherwise a
// config error shows inline on create but as a toast on update.
const mapApiErrorsToFields = (
errorData: { errors?: ApiError[]; message?: string } | null | undefined,
fallbackMessage: string,
): ScanConfigurationErrors => {
const apiErrors = Array.isArray(errorData?.errors) ? errorData!.errors! : [];
if (apiErrors.length === 0) {
return { general: errorData?.message || fallbackMessage };
}
const errors: ScanConfigurationErrors = {};
const append = (key: keyof ScanConfigurationErrors, detail: string) => {
errors[key] = errors[key] ? `${errors[key]}\n${detail}` : detail;
};
for (const err of apiErrors) {
const detail = err?.detail || err?.title || fallbackMessage;
const pointer = err?.source?.pointer;
if (pointer?.includes("name")) append("name", detail);
else if (pointer?.includes("configuration"))
append("configuration", detail);
else if (pointer?.includes("provider_ids")) append("provider_ids", detail);
else append("general", detail);
}
return errors;
};
export const createScanConfiguration = async (
_prevState: ScanConfigurationActionState,
formData: FormData,
@@ -95,20 +141,12 @@ export const createScanConfiguration = async (
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
const detail =
errorData?.errors?.[0]?.detail ||
errorData?.message ||
`Failed to create Scan Configuration: ${response.statusText}`;
const pointer = errorData?.errors?.[0]?.source?.pointer as
| string
| undefined;
const errors: ScanConfigurationErrors = {};
if (pointer?.includes("name")) errors.name = detail;
else if (pointer?.includes("configuration"))
errors.configuration = detail;
else if (pointer?.includes("provider_ids")) errors.provider_ids = detail;
else errors.general = detail;
return { errors };
return {
errors: mapApiErrorsToFields(
errorData,
`Failed to create Scan Configuration: ${response.statusText}`,
),
};
}
const data = await response.json();
@@ -199,11 +237,12 @@ export const updateScanConfiguration = async (
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
const detail =
errorData?.errors?.[0]?.detail ||
errorData?.message ||
`Failed to update Scan Configuration: ${response.statusText}`;
return { errors: { general: detail } };
return {
errors: mapApiErrorsToFields(
errorData,
`Failed to update Scan Configuration: ${response.statusText}`,
),
};
}
const data = await response.json();
@@ -225,30 +264,67 @@ export const updateScanConfiguration = async (
}
};
export const getScanConfigurationSchema = async (): Promise<Record<
string,
unknown
> | null> => {
const headers = await getAuthHeaders({ contentType: false });
const url = new URL(`${apiBaseUrl}/scan-configurations/schema`);
// Attach/detach providers on a scan configuration without touching its name or
// YAML — a partial PATCH of `provider_ids` only. Used by the provider row to
// associate/disassociate a config (editing the config itself lives in the Scan
// Config view). The backend's `(tenant, provider)` uniqueness means attaching a
// provider here moves it off any other config automatically.
export const setScanConfigurationProviders = async (
configId: string,
providerIds: string[],
): Promise<ScanConfigurationActionState> => {
const idResult = scanConfigurationIdSchema.safeParse(configId);
if (!idResult.success) {
return { errors: { general: "Invalid Scan Configuration ID" } };
}
const validId = idResult.data;
const providerIdsResult = providerIdsSchema.safeParse(providerIds);
if (!providerIdsResult.success) {
return { errors: { provider_ids: "Invalid provider ID" } };
}
const validProviderIds = providerIdsResult.data;
const headers = await getAuthHeaders({ contentType: true });
try {
const url = new URL(`${apiBaseUrl}/scan-configurations/${validId}`);
// Partial update: only provider_ids (name/configuration are optional on the
// backend update serializer), so we don't type this as the full request body.
const bodyData = {
data: {
type: "scan-configurations" as const,
id: validId,
attributes: { provider_ids: validProviderIds },
},
};
const response = await fetch(url.toString(), {
method: "GET",
method: "PATCH",
headers,
body: JSON.stringify(bodyData),
});
if (!response.ok) {
throw new Error(
`Failed to fetch Scan Configuration schema: ${response.statusText}`,
);
const errorData = await response.json().catch(() => ({}));
return {
errors: mapApiErrorsToFields(
errorData,
`Failed to update Scan Configuration: ${response.statusText}`,
),
};
}
const json = await response.json();
const schema = json?.data?.attributes?.schema as
| Record<string, unknown>
| undefined;
return schema ?? null;
revalidatePath(SCAN_CONFIGURATION_PATH);
revalidatePath("/providers");
return { success: "Scan Configuration updated successfully!" };
} catch (error) {
console.error("Error fetching Scan Configuration schema:", error);
return null;
console.error("Error updating Scan Configuration providers:", error);
return {
errors: {
general:
error instanceof Error
? error.message
: "Error updating Scan Configuration. Please try again.",
},
};
}
};
+9
View File
@@ -43,4 +43,13 @@ describe("providers page", () => {
expect(source).toContain("NEXT_PUBLIC_IS_CLOUD_ENV");
expect(source).toContain("{isCloudEnvironment && <CliImportBanner");
});
it("does not collapse scan config loading failures into an empty list", () => {
const currentDir = path.dirname(fileURLToPath(import.meta.url));
const pagePath = path.join(currentDir, "page.tsx");
const source = readFileSync(pagePath, "utf8");
expect(source).toContain("SCAN_CONFIGURATION_LIST_STATUS.UNAVAILABLE");
expect(source).not.toContain("catch {\n return [];");
});
});
+31 -5
View File
@@ -1,5 +1,6 @@
import { Suspense } from "react";
import { listScanConfigurations } from "@/actions/scan-configurations";
import { ProvidersAccountsView } from "@/components/providers";
import { SkeletonTableProviders } from "@/components/providers/table";
import { CliImportBanner } from "@/components/scans";
@@ -7,6 +8,10 @@ import { Skeleton } from "@/components/shadcn/skeleton/skeleton";
import { ContentLayout } from "@/components/ui";
import { FilterTransitionWrapper } from "@/contexts";
import { SearchParamsProps } from "@/types";
import {
SCAN_CONFIGURATION_LIST_STATUS,
type ScanConfigurationListState,
} from "@/types/scan-configurations";
import { ProviderGroupsContent } from "./provider-groups-content";
import { ProviderPageTabs } from "./provider-page-tabs";
@@ -103,24 +108,45 @@ const ProviderGroupsFallback = () => {
);
};
const loadScanConfigs = async (
isCloud: boolean,
): Promise<ScanConfigurationListState> => {
if (!isCloud) {
return { status: SCAN_CONFIGURATION_LIST_STATUS.AVAILABLE, data: [] };
}
try {
return {
status: SCAN_CONFIGURATION_LIST_STATUS.AVAILABLE,
data: await listScanConfigurations(),
};
} catch (error) {
console.error("Error loading provider scan configurations:", error);
return { status: SCAN_CONFIGURATION_LIST_STATUS.UNAVAILABLE, data: [] };
}
};
const ProvidersTabContent = async ({
searchParams,
}: {
searchParams: SearchParamsProps;
}) => {
const providersView = await loadProvidersAccountsViewData({
searchParams,
isCloud: process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true",
});
const isCloud = process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true";
const [providersView, scanConfigsState] = await Promise.all([
loadProvidersAccountsViewData({ searchParams, isCloud }),
loadScanConfigs(isCloud),
]);
return (
<ProvidersAccountsView
isCloud={process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true"}
isCloud={isCloud}
filters={providersView.filters}
providers={providersView.providers}
providerGroups={providersView.providerGroups}
metadata={providersView.metadata}
rows={providersView.rows}
scanConfigs={scanConfigsState.data}
scanConfigStatus={scanConfigsState.status}
/>
);
};
@@ -21,11 +21,10 @@ import {
import { Modal } from "@/components/shadcn/modal";
import { useToast } from "@/components/ui";
import { CustomLink } from "@/components/ui/custom/custom-link";
import { fontMono } from "@/config/fonts";
import {
convertToYaml,
defaultScanConfigurationYaml,
validateScanConfigurationPayload,
validateYaml,
} from "@/lib/yaml";
import { scanConfigurationFormSchema } from "@/types/formSchemas";
import { ProviderProps } from "@/types/providers";
@@ -37,7 +36,6 @@ interface ScanConfigurationEditorProps {
richProviders: ProviderProps[];
existingConfigs: ScanConfigurationData[];
config: ScanConfigurationData | null;
schema: Record<string, unknown> | null;
}
interface ScanConfigurationFormProps {
@@ -45,7 +43,6 @@ interface ScanConfigurationFormProps {
richProviders: ProviderProps[];
existingConfigs: ScanConfigurationData[];
config: ScanConfigurationData | null;
schema: Record<string, unknown> | null;
}
// `provider_ids` has a zod `.default([])`, so the resolver's input and output
@@ -53,14 +50,11 @@ interface ScanConfigurationFormProps {
type ScanConfigurationFormInput = z.input<typeof scanConfigurationFormSchema>;
type ScanConfigurationFormValues = z.output<typeof scanConfigurationFormSchema>;
const MAX_ERRORS_SHOWN = 10;
function ScanConfigurationForm({
onClose,
richProviders,
existingConfigs,
config,
schema,
}: ScanConfigurationFormProps) {
const isEdit = !!config;
const { toast } = useToast();
@@ -87,12 +81,13 @@ function ScanConfigurationForm({
const configText = form.watch("configuration") || "";
const selectedProviders = form.watch("provider_ids") || [];
// Real-time validation against the server schema (ranges/enums). Kept out of
// form state because it's derived purely from the current YAML text — skip it
// while the field is empty so we don't flag an error before the user types.
const yamlValidation = configText.trim()
? validateScanConfigurationPayload(configText, schema)
: { isValid: true, errors: [] };
// Mirror the Mutelist editor: the client validates YAML *syntax* live (that it
// parses to a mapping); the API validates the configuration values
// (ranges/enums) on save and returns them inline. Skip while empty so we don't
// flag an error before the user types.
const yamlSyntax = configText.trim()
? 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
@@ -111,9 +106,9 @@ function ScanConfigurationForm({
const lockedCount = richProviders.length - selectableProviders.length;
const onSubmit = form.handleSubmit(async (values) => {
// The inline panel already lists every schema/syntax error in real time, so
// we don't duplicate them in a toast — just bring the panel into view.
if (yamlValidation.errors.length > 0) {
// Block on a YAML syntax error (the inline message already explains it); the
// API validates the values on save and returns any errors inline.
if (!yamlSyntax.isValid) {
errorPanelRef.current?.scrollIntoView({
behavior: "smooth",
block: "center",
@@ -194,64 +189,64 @@ function ScanConfigurationForm({
<FieldLabel htmlFor="scan-configuration-yaml">
Configuration (YAML)
</FieldLabel>
<p className="text-default-500 text-tiny">
Follows the structure of{" "}
<CustomLink
size="sm"
href="https://github.com/prowler-cloud/prowler/blob/master/prowler/config/config.yaml"
>
prowler/config/config.yaml
</CustomLink>
. Allowed ranges and enums come from the server schema; invalid values
are listed below in real time.
</p>
<ul className="text-default-500 text-tiny mb-1 list-disc pl-5">
<li>
Follows the structure of{" "}
<CustomLink
size="xs"
href="https://docs.prowler.com/user-guide/cli/tutorials/configuration_file"
>
prowler/config/config.yaml
</CustomLink>
; include only the keys you want to override.
</li>
<li>The configuration is validated on save.</li>
<li>
Learn more about configuring scans{" "}
<CustomLink
size="xs"
href="https://docs.prowler.com/user-guide/tutorials/prowler-app-scan-configuration"
>
here
</CustomLink>
.
</li>
</ul>
<Textarea
id="scan-configuration-yaml"
placeholder={defaultScanConfigurationYaml}
rows={14}
aria-invalid={!!configError || !yamlValidation.isValid}
className={fontMono.className + " text-sm"}
{...form.register("configuration")}
aria-invalid={!!configError || !yamlSyntax.isValid}
font="mono"
{...form.register("configuration", {
// A server-side validation error becomes stale the moment the user
// edits the YAML — clear it so it can't linger next to the live
// client-side syntax check.
onChange: () => form.clearErrors("configuration"),
})}
/>
<div aria-live="polite" className="mt-1" ref={errorPanelRef}>
{yamlValidation.errors.length === 0 && configText.trim() ? (
<div
aria-live="polite"
className="mt-1 flex flex-col gap-1"
ref={errorPanelRef}
>
{!yamlSyntax.isValid ? (
<FieldError>{`Invalid YAML format: ${yamlSyntax.error}`}</FieldError>
) : configText.trim() && !configError ? (
<p className="text-tiny text-text-success-primary">
Configuration valid
Valid YAML format
</p>
) : yamlValidation.errors.length > 0 ? (
<div className="border-border-error rounded-md border bg-red-50 p-3 dark:bg-red-950/50">
<p className="text-tiny text-text-error-primary mb-1 font-medium">
{yamlValidation.errors.length} validation{" "}
{yamlValidation.errors.length === 1 ? "error" : "errors"}:
</p>
<ul className="text-default-700 text-tiny list-disc space-y-1 pl-5">
{yamlValidation.errors
.slice(0, MAX_ERRORS_SHOWN)
.map((err, idx) => (
<li key={`${err.path}-${idx}`}>
<code className="text-tiny">{err.path}</code>:{" "}
<span>{err.message}</span>
</li>
))}
{yamlValidation.errors.length > MAX_ERRORS_SHOWN && (
<li>
+ {yamlValidation.errors.length - MAX_ERRORS_SHOWN} more
</li>
)}
</ul>
</div>
) : null}
{configError && (
<FieldError className="mt-1">{configError}</FieldError>
)}
{configError && <FieldError multiline>{configError}</FieldError>}
</div>
</Field>
<Field>
<FieldLabel>Attach to providers</FieldLabel>
<FieldLabel>Attach to providers (optional)</FieldLabel>
<p className="text-default-500 text-tiny">
Pick the cloud providers that should use this configuration on their
next scan.
Pick the providers that should use this configuration on their next
scan. You can save it without any and attach providers later it just
won&apos;t apply to a scan until one is attached.
{lockedCount > 0 && (
<>
{" "}
@@ -274,6 +269,12 @@ function ScanConfigurationForm({
form.setValue("provider_ids", values, { shouldValidate: true })
}
selectedValues={selectedProviders}
// Here an empty selection means "no providers attached" (the field
// is optional), not the filter default of "all providers". Override
// the filter-oriented labels so the control reads correctly.
placeholder="No providers selected"
emptySelectionLabel="No providers selected"
clearSelectionLabel="Clear selection"
search={{
placeholder: "Search providers...",
emptyMessage: "No providers found.",
@@ -293,7 +294,11 @@ function ScanConfigurationForm({
>
Cancel
</Button>
<Button type="submit" size="lg" disabled={isSubmitting}>
<Button
type="submit"
size="lg"
disabled={isSubmitting || !yamlSyntax.isValid}
>
{isSubmitting ? "Saving..." : isEdit ? "Update" : "Save"}
</Button>
</div>
@@ -307,7 +312,6 @@ export function ScanConfigurationEditor({
richProviders,
existingConfigs,
config,
schema,
}: ScanConfigurationEditorProps) {
const isEdit = !!config;
@@ -319,6 +323,7 @@ export function ScanConfigurationEditor({
}}
title={isEdit ? "Edit Scan Configuration" : "New Scan Configuration"}
size="2xl"
scrollable
>
<ScanConfigurationForm
key={config?.id ?? "new"}
@@ -326,7 +331,6 @@ export function ScanConfigurationEditor({
richProviders={richProviders}
existingConfigs={existingConfigs}
config={config}
schema={schema}
/>
</Modal>
);
@@ -3,7 +3,11 @@
import { ColumnDef } from "@tanstack/react-table";
import { Pencil, Trash2 } from "lucide-react";
import { Button } from "@/components/shadcn";
import {
ActionDropdown,
ActionDropdownDangerZone,
ActionDropdownItem,
} from "@/components/shadcn/dropdown";
import { DateWithTime } from "@/components/ui/entities";
import { DataTableColumnHeader } from "@/components/ui/table";
import { ScanConfigurationData } from "@/types/scan-configurations";
@@ -61,26 +65,24 @@ export const createScanConfigurationsColumns = (
id: "actions",
header: () => null,
cell: ({ row }) => (
<div className="flex justify-end gap-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => onEdit(row.original)}
aria-label={`Edit ${row.original.attributes.name}`}
<div className="relative flex items-center justify-end gap-2">
<ActionDropdown
ariaLabel={`Open actions menu for ${row.original.attributes.name}`}
>
<Pencil className="size-4" />
Edit
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => onDelete(row.original)}
aria-label={`Delete ${row.original.attributes.name}`}
>
<Trash2 className="size-4" />
</Button>
<ActionDropdownItem
icon={<Pencil />}
label="Edit"
onSelect={() => onEdit(row.original)}
/>
<ActionDropdownDangerZone>
<ActionDropdownItem
icon={<Trash2 />}
label="Delete"
destructive
onSelect={() => onDelete(row.original)}
/>
</ActionDropdownDangerZone>
</ActionDropdown>
</div>
),
enableSorting: false,
@@ -28,13 +28,11 @@ const FILTER_CONTROL_COLUMN_CLASS =
interface ScanConfigurationsManagerProps {
initialConfigs: ScanConfigurationData[];
richProviders: ProviderProps[];
schema: Record<string, unknown> | null;
}
export function ScanConfigurationsManager({
initialConfigs,
richProviders,
schema,
}: ScanConfigurationsManagerProps) {
const [configs, setConfigs] =
useState<ScanConfigurationData[]>(initialConfigs);
@@ -172,27 +170,31 @@ export function ScanConfigurationsManager({
onClear={clearFilters}
/>
)}
<Button size="lg" onClick={openCreate} className="md:ml-auto">
<Plus className="size-4" />
New Scan Configuration
</Button>
<div className="md:ml-auto">
<Button size="lg" onClick={openCreate}>
<Plus className="size-4" />
New Scan Configuration
</Button>
</div>
</>
}
/>
</div>
{noMatchForProvider ? (
<Card variant="base" className="p-8 text-center">
<p className="text-default-700 text-sm font-medium">
{providerFilter.length === 1
? "No Scan Configuration is attached to this provider."
: "No Scan Configuration is attached to any of the selected providers."}
</p>
<p className="text-default-500 mt-1 text-sm">
The next scan{providerFilter.length === 1 ? "" : "s"} will use the
built-in defaults shipped with Prowler. Attach a Scan Configuration
from the editor to override them.
</p>
<Card variant="base" padding="xl">
<div className="text-center">
<p className="text-default-700 text-sm font-medium">
{providerFilter.length === 1
? "No Scan Configuration is attached to this provider."
: "No Scan Configuration is attached to any of the selected providers."}
</p>
<p className="text-default-500 mt-1 text-sm">
The next scan{providerFilter.length === 1 ? "" : "s"} will use the
built-in defaults shipped with Prowler. Attach a Scan
Configuration from the editor to override them.
</p>
</div>
</Card>
) : (
<DataTable
@@ -215,7 +217,6 @@ export function ScanConfigurationsManager({
richProviders={richProviders}
existingConfigs={configs}
config={editingConfig}
schema={schema}
/>
<Modal
@@ -0,0 +1,18 @@
import { readFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
describe("scan config page", () => {
it("does not block SSR on the full providers crawl", () => {
// Given
const currentDir = path.dirname(fileURLToPath(import.meta.url));
const source = readFileSync(path.join(currentDir, "page.tsx"), "utf8");
// Then
expect(source).not.toContain("getAllProviders");
expect(source).toContain("getProviders({ pageSize: 100 })");
expect(source).toContain("throw new Error");
});
});
@@ -1,10 +1,7 @@
import { redirect } from "next/navigation";
import { getAllProviders } from "@/actions/providers";
import {
getScanConfigurationSchema,
listScanConfigurations,
} from "@/actions/scan-configurations";
import { getProviders } from "@/actions/providers";
import { listScanConfigurations } from "@/actions/scan-configurations";
import { ContentLayout } from "@/components/ui";
import { isCloud } from "@/lib/shared/env";
@@ -19,20 +16,22 @@ export default async function ScanConfigPage() {
// A failure here propagates to the `(prowler)/error.tsx` boundary instead of
// rendering a false "no scan configurations" empty table during SSR.
const [configs, providersResponse, schema] = await Promise.all([
const [configs, providersResponse] = await Promise.all([
listScanConfigurations(),
getAllProviders({}),
getScanConfigurationSchema(),
getProviders({ pageSize: 100 }),
]);
const richProviders = providersResponse?.data ?? [];
if (!providersResponse) {
throw new Error("Failed to load Providers for Scan Configuration.");
}
const richProviders = providersResponse.data;
return (
<ContentLayout title="Scan Configuration" icon="lucide:sliders">
<ContentLayout title="Configuration" icon="lucide:sliders">
<ScanConfigurationsManager
initialConfigs={configs}
richProviders={richProviders}
schema={schema}
/>
</ContentLayout>
);
+1 -1
View File
@@ -221,7 +221,7 @@ export default async function Scans({
return (
<ContentLayout
title="Scan Jobs"
title="Scans"
icon="lucide:timer"
onboardingAction={onboardingAction}
>
@@ -8,6 +8,10 @@ import {
PROVIDERS_ROW_TYPE,
type ProvidersTableRow,
} from "@/types/providers-table";
import {
SCAN_CONFIGURATION_LIST_STATUS,
type ScanConfigurationData,
} from "@/types/scan-configurations";
import { SCAN_SCHEDULE_CAPABILITY } from "@/types/schedules";
const { dataTableMockState, getColumnProvidersMock } = vi.hoisted(() => ({
@@ -40,6 +44,7 @@ vi.mock("./table", () => ({
import {
computeSelectedScheduleProviders,
createScanConfigIdByProviderId,
ProvidersAccountsTable,
} from "./providers-accounts-table";
@@ -90,6 +95,17 @@ const createProviderRow = (
const providerOne = createProviderRow("provider-1", "111111111111", "Prod");
const providerTwo = createProviderRow("provider-2", "222222222222", "Stage");
const providerThree = createProviderRow("provider-3", "333333333333", "Dev");
const scanConfig: ScanConfigurationData = {
type: "scan-configurations",
id: "config-1",
attributes: {
inserted_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
name: "Strict AWS",
configuration: {},
providers: ["provider-1"],
},
};
const organizationRow: ProvidersTableRow = {
id: "org-1",
@@ -147,9 +163,44 @@ describe("ProvidersAccountsTable", () => {
expect.any(Function),
expect.any(Function),
SCAN_SCHEDULE_CAPABILITY.MANUAL_ONLY,
[],
SCAN_CONFIGURATION_LIST_STATUS.AVAILABLE,
expect.any(Map),
);
});
it("passes populated scan configs to provider row action columns", () => {
// Given/When
render(
<ProvidersAccountsTable
isCloud
metadata={metadata}
rows={[]}
scanConfigs={[scanConfig]}
scanScheduleCapability={SCAN_SCHEDULE_CAPABILITY.MANUAL_ONLY}
onOpenProviderWizard={vi.fn()}
onOpenOrganizationWizard={vi.fn()}
/>,
);
// Then
const call = getColumnProvidersMock.mock.calls.at(-1);
expect(call?.[8]).toEqual([scanConfig]);
expect(call?.[9]).toBe(SCAN_CONFIGURATION_LIST_STATUS.AVAILABLE);
expect(call?.[10]).toBeInstanceOf(Map);
expect((call?.[10] as Map<string, string>).get("provider-1")).toBe(
"config-1",
);
});
it("precomputes scan config ids by provider id once for row actions", () => {
// Given/When
const lookup = createScanConfigIdByProviderId([scanConfig]);
// Then
expect(lookup.get("provider-1")).toBe("config-1");
});
describe("schedule provider selection", () => {
it("uses the selected provider id for provider rows", () => {
// Given
@@ -268,6 +319,9 @@ describe("ProvidersAccountsTable", () => {
expect.any(Function),
expect.any(Function),
SCAN_SCHEDULE_CAPABILITY.ADVANCED,
[],
SCAN_CONFIGURATION_LIST_STATUS.AVAILABLE,
expect.any(Map),
);
});
@@ -301,6 +355,9 @@ describe("ProvidersAccountsTable", () => {
expect.any(Function),
expect.any(Function),
SCAN_SCHEDULE_CAPABILITY.ADVANCED,
[],
SCAN_CONFIGURATION_LIST_STATUS.AVAILABLE,
expect.any(Map),
);
});
});
@@ -14,6 +14,11 @@ import {
isProvidersProviderRow,
ProvidersTableRow,
} from "@/types/providers-table";
import {
SCAN_CONFIGURATION_LIST_STATUS,
ScanConfigurationData,
type ScanConfigurationListStatus,
} from "@/types/scan-configurations";
import type {
ScanScheduleCapability,
ScanScheduleProvider,
@@ -26,6 +31,10 @@ interface ProvidersAccountsTableProps {
metadata?: MetaDataProps;
rows: ProvidersTableRow[];
scanScheduleCapability?: ScanScheduleCapability;
/** All scan configurations in the tenant, for the provider row's associate/
* disassociate action (Cloud-only). */
scanConfigs?: ScanConfigurationData[];
scanConfigStatus?: ScanConfigurationListStatus;
onOpenProviderWizard: (initialData?: ProviderWizardInitialData) => void;
onOpenOrganizationWizard: (initialData: OrgWizardInitialData) => void;
}
@@ -154,11 +163,27 @@ export function computeSelectedScheduleProviders(
return { providerIds, providers };
}
export function createScanConfigIdByProviderId(
scanConfigs: ScanConfigurationData[],
): Map<string, string> {
const lookup = new Map<string, string>();
for (const config of scanConfigs) {
for (const providerId of config.attributes.providers) {
lookup.set(providerId, config.id);
}
}
return lookup;
}
function ProvidersAccountsTableContent({
isCloud,
metadata,
rows,
scanScheduleCapability,
scanConfigs,
scanConfigStatus = SCAN_CONFIGURATION_LIST_STATUS.AVAILABLE,
onOpenProviderWizard,
onOpenOrganizationWizard,
}: ProvidersAccountsTableProps) {
@@ -170,6 +195,9 @@ function ProvidersAccountsTableContent({
rowSelection,
);
const selectedScheduleProviderIds = selectedScheduleProviders.providerIds;
const scanConfigIdByProviderId = createScanConfigIdByProviderId(
scanConfigs ?? [],
);
const clearSelection = () => setRowSelection({});
@@ -182,6 +210,9 @@ function ProvidersAccountsTableContent({
onOpenProviderWizard,
onOpenOrganizationWizard,
scanScheduleCapability,
scanConfigs ?? [],
scanConfigStatus,
scanConfigIdByProviderId,
);
return (
@@ -30,6 +30,10 @@ import {
import type { FilterOption, MetaDataProps, ProviderProps } from "@/types";
import type { ProviderGroup } from "@/types/components";
import type { ProvidersTableRow } from "@/types/providers-table";
import type {
ScanConfigurationData,
ScanConfigurationListStatus,
} from "@/types/scan-configurations";
import type { ScanScheduleCapability } from "@/types/schedules";
const addProviderFlow = getFlowById("add-provider")!;
@@ -56,6 +60,10 @@ interface ProvidersAccountsViewProps {
rows: ProvidersTableRow[];
/** Cloud overlay seam for provider-creation scan launch. */
scanScheduleCapability?: ScanScheduleCapability;
/** All scan configurations in the tenant, for the provider row's associate/
* disassociate action (Cloud-only). */
scanConfigs?: ScanConfigurationData[];
scanConfigStatus?: ScanConfigurationListStatus;
isScanLimitReached?: boolean;
}
@@ -67,6 +75,8 @@ export function ProvidersAccountsView({
providerGroups = [],
rows,
scanScheduleCapability,
scanConfigs,
scanConfigStatus,
isScanLimitReached,
}: ProvidersAccountsViewProps) {
const pathname = usePathname();
@@ -157,6 +167,8 @@ export function ProvidersAccountsView({
metadata={metadata}
rows={rows}
scanScheduleCapability={scanScheduleCapability}
scanConfigs={scanConfigs}
scanConfigStatus={scanConfigStatus}
onOpenProviderWizard={openProviderWizard}
onOpenOrganizationWizard={openOrganizationWizard}
/>
@@ -0,0 +1,257 @@
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import type { ScanConfigurationData } from "@/types/scan-configurations";
import { ManageScanConfigModal } from "./manage-scan-config-modal";
const { setScanConfigurationProvidersMock, toastMock } = vi.hoisted(() => ({
setScanConfigurationProvidersMock: vi.fn(),
toastMock: vi.fn(),
}));
vi.mock("@/actions/scan-configurations", () => ({
setScanConfigurationProviders: setScanConfigurationProvidersMock,
}));
vi.mock("@/components/ui", () => ({
useToast: () => ({ toast: toastMock }),
}));
vi.mock("@/components/ui/custom/custom-link", () => ({
CustomLink: ({ children }: { children: React.ReactNode }) => (
<span>{children}</span>
),
}));
// Radix Select relies on pointer-capture and scrollIntoView, which jsdom does
// not implement. Polyfill them so the dropdown can open in tests.
beforeAll(() => {
Object.defineProperty(HTMLElement.prototype, "hasPointerCapture", {
configurable: true,
value: vi.fn(() => false),
});
Object.defineProperty(HTMLElement.prototype, "setPointerCapture", {
configurable: true,
value: vi.fn(),
});
Object.defineProperty(HTMLElement.prototype, "releasePointerCapture", {
configurable: true,
value: vi.fn(),
});
Object.defineProperty(HTMLElement.prototype, "scrollIntoView", {
configurable: true,
value: vi.fn(),
});
});
const makeConfig = (
id: string,
name: string,
providers: string[],
): ScanConfigurationData => ({
type: "scan-configurations",
id,
attributes: {
inserted_at: "2025-01-01T00:00:00Z",
updated_at: "2025-01-01T00:00:00Z",
name,
configuration: {},
providers,
},
});
const renderModal = (
overrides: Partial<React.ComponentProps<typeof ManageScanConfigModal>> = {},
) => {
const onOpenChange = vi.fn();
const onSaved = vi.fn();
const props: React.ComponentProps<typeof ManageScanConfigModal> = {
open: true,
onOpenChange,
providerId: "provider-1",
providerLabel: "AWS App Account",
scanConfigs: [
makeConfig("config-a", "Config A", []),
makeConfig("config-b", "Config B", []),
],
currentConfigId: null,
onSaved,
...overrides,
};
render(<ManageScanConfigModal {...props} />);
return { onOpenChange, onSaved, props };
};
const openSelectAndChoose = async (
user: ReturnType<typeof userEvent.setup>,
optionName: RegExp,
) => {
await user.click(
screen.getByRole("combobox", { name: /scan configuration/i }),
);
await user.click(await screen.findByRole("option", { name: optionName }));
};
const clickSave = (user: ReturnType<typeof userEvent.setup>) =>
user.click(screen.getByRole("button", { name: /^save$/i }));
describe("ManageScanConfigModal", () => {
beforeEach(() => {
vi.clearAllMocks();
setScanConfigurationProvidersMock.mockResolvedValue({
success: "Scan Configuration updated successfully!",
});
});
it("has an accessible label on the configuration select", () => {
renderModal();
expect(
screen.getByRole("combobox", { name: /scan configuration/i }),
).toBeInTheDocument();
});
it("attaches the provider to the chosen configuration", async () => {
// Given an unattached provider.
const user = userEvent.setup();
const { onOpenChange, onSaved } = renderModal({ currentConfigId: null });
// When the user picks "Config A" and saves.
await openSelectAndChoose(user, /^config a$/i);
await clickSave(user);
// Then the provider is added to that config's provider list.
await waitFor(() =>
expect(setScanConfigurationProvidersMock).toHaveBeenCalledWith(
"config-a",
["provider-1"],
),
);
expect(onSaved).toHaveBeenCalledTimes(1);
expect(onOpenChange).toHaveBeenCalledWith(false);
});
it("detaches the provider when Default is selected", async () => {
// Given a provider currently attached to Config A (alongside another).
const user = userEvent.setup();
const { onOpenChange, onSaved } = renderModal({
currentConfigId: "config-a",
scanConfigs: [
makeConfig("config-a", "Config A", ["provider-1", "provider-2"]),
makeConfig("config-b", "Config B", []),
],
});
// When the user switches back to Default and saves.
await openSelectAndChoose(user, /^default$/i);
await clickSave(user);
// Then only this provider is dropped — the rest stay attached.
await waitFor(() =>
expect(setScanConfigurationProvidersMock).toHaveBeenCalledWith(
"config-a",
["provider-2"],
),
);
expect(onSaved).toHaveBeenCalledTimes(1);
expect(onOpenChange).toHaveBeenCalledWith(false);
});
it("moves the provider to another configuration", async () => {
// Given a provider attached to Config A.
const user = userEvent.setup();
renderModal({
currentConfigId: "config-a",
scanConfigs: [
makeConfig("config-a", "Config A", ["provider-1"]),
makeConfig("config-b", "Config B", []),
],
});
// When the user picks Config B and saves.
await openSelectAndChoose(user, /^config b$/i);
await clickSave(user);
// Then the provider is attached to Config B (the backend detaches it from A).
await waitFor(() =>
expect(setScanConfigurationProvidersMock).toHaveBeenCalledWith(
"config-b",
["provider-1"],
),
);
});
it("does not call the action when the selection is unchanged", async () => {
// Given a provider already attached to Config A.
const user = userEvent.setup();
const { onOpenChange } = renderModal({
currentConfigId: "config-a",
scanConfigs: [makeConfig("config-a", "Config A", ["provider-1"])],
});
// When the user saves without changing the selection.
await clickSave(user);
// Then no request is sent, and the modal just closes.
expect(setScanConfigurationProvidersMock).not.toHaveBeenCalled();
expect(onOpenChange).toHaveBeenCalledWith(false);
});
it("surfaces a destructive toast and keeps the modal open on failure", async () => {
// Given the action returns a field error.
const user = userEvent.setup();
setScanConfigurationProvidersMock.mockResolvedValue({
errors: { general: "Boom" },
});
const { onOpenChange, onSaved } = renderModal({ currentConfigId: null });
// When the user attaches and saves.
await openSelectAndChoose(user, /^config a$/i);
await clickSave(user);
// Then the error is toasted and the modal stays open for a retry.
await waitFor(() =>
expect(toastMock).toHaveBeenCalledWith(
expect.objectContaining({
variant: "destructive",
description: "Boom",
}),
),
);
expect(onSaved).not.toHaveBeenCalled();
expect(onOpenChange).not.toHaveBeenCalled();
});
it("resets a cancelled selection when the modal is reopened", async () => {
// Given a provider with no attached config.
const user = userEvent.setup();
const baseProps: React.ComponentProps<typeof ManageScanConfigModal> = {
open: true,
onOpenChange: vi.fn(),
providerId: "provider-1",
providerLabel: "AWS App Account",
scanConfigs: [
makeConfig("config-a", "Config A", []),
makeConfig("config-b", "Config B", []),
],
currentConfigId: null,
onSaved: vi.fn(),
};
const { rerender } = render(<ManageScanConfigModal {...baseProps} />);
// When the user picks Config A but closes the modal without saving.
await openSelectAndChoose(user, /^config a$/i);
rerender(<ManageScanConfigModal {...baseProps} open={false} />);
// And reopens it for the same (still unattached) provider.
rerender(<ManageScanConfigModal {...baseProps} open />);
// Then the selection falls back to Default — the stale choice is gone, so
// saving without touching the dropdown sends nothing.
await clickSave(user);
expect(setScanConfigurationProvidersMock).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,222 @@
"use client";
import { useState } from "react";
import { setScanConfigurationProviders } from "@/actions/scan-configurations";
import { Button } from "@/components/shadcn";
import { Modal } from "@/components/shadcn/modal";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/shadcn/select/select";
import { useToast } from "@/components/ui";
import { CustomLink } from "@/components/ui/custom/custom-link";
import { ScanConfigurationData } from "@/types/scan-configurations";
// Sentinel for the "Default" option: detaches the provider so its scans fall
// back to Prowler's built-in SDK defaults. Select values must be non-empty
// strings, so we can't use "".
const DEFAULT_VALUE = "__default__";
interface ManageScanConfigModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
providerId: string;
providerLabel: string;
scanConfigs: ScanConfigurationData[];
/** The config this provider is currently attached to, if any. */
currentConfigId: string | null;
/** Called after a successful associate/disassociate so the parent can refresh. */
onSaved: () => void;
}
type ManageScanConfigFormProps = Omit<ManageScanConfigModalProps, "open">;
export function ManageScanConfigModal({
open,
onOpenChange,
...formProps
}: ManageScanConfigModalProps) {
return (
<Modal
open={open}
onOpenChange={onOpenChange}
title="Scan Configuration"
size="md"
>
{/* Only mount the form while the modal is open so a fresh instance is
created on every reopen — its selection always initializes from the
provider's current config, never from a stale, cancelled selection.
The key resets it again if the attached config changes mid-open. */}
{open && (
<ManageScanConfigForm
key={`${formProps.providerId}:${formProps.currentConfigId ?? DEFAULT_VALUE}`}
onOpenChange={onOpenChange}
{...formProps}
/>
)}
</Modal>
);
}
function ManageScanConfigForm({
onOpenChange,
providerId,
providerLabel,
scanConfigs,
currentConfigId,
onSaved,
}: ManageScanConfigFormProps) {
const { toast } = useToast();
const [selected, setSelected] = useState<string>(
currentConfigId ?? DEFAULT_VALUE,
);
const [isSaving, setIsSaving] = useState(false);
const handleSave = async () => {
// No change — nothing to do.
if (selected === (currentConfigId ?? DEFAULT_VALUE)) {
onOpenChange(false);
return;
}
const reportError = (description: string) => {
toast({
variant: "destructive",
title: "Oops! Something went wrong",
description,
});
};
setIsSaving(true);
try {
let result;
if (selected === DEFAULT_VALUE) {
// Detach: drop this provider from its current config.
if (!currentConfigId) {
onOpenChange(false);
return;
}
const current = scanConfigs.find((c) => c.id === currentConfigId);
// Bail if we don't have the current config loaded: sending a full
// provider_ids replacement off a synthetic empty list would clear every
// other provider attached to this configuration.
if (!current) {
reportError(
"This scan configuration is no longer available. Refresh and try again.",
);
return;
}
const next = current.attributes.providers.filter(
(id) => id !== providerId,
);
result = await setScanConfigurationProviders(currentConfigId, next);
} else {
// Attach: add this provider to the chosen config. The backend moves it
// off any other config automatically (one config per provider).
const target = scanConfigs.find((c) => c.id === selected);
// Same guard as the detach path: never replace provider_ids based on a
// config we don't actually have.
if (!target) {
reportError(
"This scan configuration is no longer available. Refresh and try again.",
);
return;
}
const next = Array.from(
new Set([...target.attributes.providers, providerId]),
);
result = await setScanConfigurationProviders(selected, next);
}
if (result?.success) {
toast({
title: "Scan Configuration updated",
description: result.success,
});
onSaved();
onOpenChange(false);
} else {
reportError(
result?.errors?.general ||
result?.errors?.provider_ids ||
"Failed to update the Scan Configuration. Please try again.",
);
}
} catch {
// An invocation-level failure (transport/framework) rejects instead of
// returning an error object — surface it instead of failing silently.
reportError("Failed to update the Scan Configuration. Please try again.");
} finally {
setIsSaving(false);
}
};
return (
<div className="flex flex-col gap-4">
<p className="text-default-500 text-tiny">
Choose the scan configuration to apply to{" "}
<strong>{providerLabel}</strong> on its next scan, or leave default. To
create or edit configurations, go to{" "}
<CustomLink size="xs" href="/scans/config" target="_self">
Scan Config
</CustomLink>
.
</p>
{/* Always show the dropdown with Default — even with no custom configs,
the provider can fall back to Prowler's SDK defaults. */}
<div className="flex flex-col gap-1">
<Select value={selected} onValueChange={setSelected}>
<SelectTrigger aria-label="Scan configuration">
<SelectValue placeholder="Default" />
</SelectTrigger>
<SelectContent>
<SelectItem value={DEFAULT_VALUE}>Default</SelectItem>
{scanConfigs.map((c) => (
<SelectItem key={c.id} value={c.id}>
{c.attributes.name}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-default-500 text-tiny">
<strong>Default</strong>
{
" uses Prowler's scan configuration baseline. Read more about it in the "
}
<CustomLink
size="xs"
href="https://docs.prowler.com/user-guide/tutorials/prowler-app-scan-configuration"
>
documentation
</CustomLink>
.
</p>
</div>
<div className="flex w-full justify-end gap-3">
<Button
type="button"
variant="ghost"
size="lg"
onClick={() => onOpenChange(false)}
disabled={isSaving}
>
Cancel
</Button>
<Button
type="button"
size="lg"
onClick={handleSave}
disabled={isSaving}
>
{isSaving ? "Saving..." : "Save"}
</Button>
</div>
</div>
);
}
@@ -20,6 +20,11 @@ import {
ProvidersProviderRow,
ProvidersTableRow,
} from "@/types/providers-table";
import {
SCAN_CONFIGURATION_LIST_STATUS,
ScanConfigurationData,
type ScanConfigurationListStatus,
} from "@/types/scan-configurations";
import type {
ScanScheduleCapability,
ScanScheduleProvider,
@@ -113,6 +118,9 @@ export function getColumnProviders(
onOpenProviderWizard: (initialData?: ProviderWizardInitialData) => void,
onOpenOrganizationWizard: (initialData: OrgWizardInitialData) => void,
scanScheduleCapability?: ScanScheduleCapability,
scanConfigs: ScanConfigurationData[] = [],
scanConfigStatus: ScanConfigurationListStatus = SCAN_CONFIGURATION_LIST_STATUS.AVAILABLE,
scanConfigIdByProviderId: ReadonlyMap<string, string> = new Map(),
): ColumnDef<ProvidersTableRow>[] {
return [
{
@@ -315,6 +323,9 @@ export function getColumnProviders(
),
cell: ({ row }) => {
const hasSelection = Object.values(rowSelection).some(Boolean);
const currentScanConfigId = isProvidersOrganizationRow(row.original)
? null
: (scanConfigIdByProviderId.get(row.original.id) ?? null);
return (
<DataTableRowActions
@@ -327,6 +338,9 @@ export function getColumnProviders(
onClearSelection={onClearSelection}
onOpenProviderWizard={onOpenProviderWizard}
onOpenOrganizationWizard={onOpenOrganizationWizard}
scanConfigs={scanConfigs}
scanConfigStatus={scanConfigStatus}
currentScanConfigId={currentScanConfigId}
capability={scanScheduleCapability}
/>
);
@@ -9,6 +9,7 @@ import {
PROVIDERS_ROW_TYPE,
ProvidersTableRow,
} from "@/types/providers-table";
import type { ScanConfigurationData } from "@/types/scan-configurations";
import { SCAN_SCHEDULE_CAPABILITY } from "@/types/schedules";
const { checkConnectionProviderMock, getScheduleMock, pushMock } = vi.hoisted(
@@ -47,6 +48,22 @@ vi.mock("../forms/edit-name-form", () => ({
EditNameForm: () => null,
}));
vi.mock("../scan-config/manage-scan-config-modal", () => ({
ManageScanConfigModal: ({
open,
currentConfigId,
}: {
open: boolean;
currentConfigId: string | null;
}) =>
open ? (
<div
data-testid="manage-scan-config-modal"
data-current-config-id={currentConfigId ?? ""}
/>
) : null,
}));
vi.mock("@/components/scans/schedule/edit-scan-schedule-modal", () => ({
EDIT_SCAN_SCHEDULE_STATE: {
LOADING: "loading",
@@ -182,6 +199,18 @@ const createOuRow = () =>
},
}) as unknown as Row<ProvidersTableRow>;
const scanConfig: ScanConfigurationData = {
type: "scan-configurations",
id: "config-1",
attributes: {
inserted_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
name: "Strict AWS",
configuration: {},
providers: ["provider-1"],
},
};
describe("DataTableRowActions", () => {
afterEach(() => {
vi.unstubAllEnvs();
@@ -361,6 +390,64 @@ describe("DataTableRowActions", () => {
expect(screen.queryByText("Edit Scan Schedule")).not.toBeInTheDocument();
});
it("opens scan config management with the precomputed current config id", async () => {
// Given
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true");
const user = userEvent.setup();
render(
<DataTableRowActions
row={createRow(true)}
hasSelection={false}
isRowSelected={false}
testableProviderIds={[]}
onClearSelection={vi.fn()}
onOpenProviderWizard={vi.fn()}
onOpenOrganizationWizard={vi.fn()}
scanConfigs={[scanConfig]}
currentScanConfigId="config-1"
/>,
);
// When
await user.click(screen.getByRole("button"));
await user.click(screen.getByText("Edit Scan Configuration"));
// Then
expect(screen.getByTestId("manage-scan-config-modal")).toHaveAttribute(
"data-current-config-id",
"config-1",
);
});
it("shows scan config management as unavailable when scan configs failed to load", async () => {
// Given
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true");
const user = userEvent.setup();
render(
<DataTableRowActions
row={createRow(true)}
hasSelection={false}
isRowSelected={false}
testableProviderIds={[]}
onClearSelection={vi.fn()}
onOpenProviderWizard={vi.fn()}
onOpenOrganizationWizard={vi.fn()}
scanConfigStatus="unavailable"
/>,
);
// When
await user.click(screen.getByRole("button"));
// Then
const item = screen
.getByText("Scan Configuration unavailable")
.closest("[role='menuitem']");
expect(item).toHaveAttribute("aria-disabled", "true");
});
it("renders Update Credentials for provider rows with credentials", async () => {
// Given
const user = userEvent.setup();
@@ -6,6 +6,7 @@ import {
KeyRound,
Pencil,
Rocket,
SlidersHorizontal,
Timer,
Trash2,
} from "lucide-react";
@@ -45,6 +46,11 @@ import {
ProvidersOrganizationRow,
ProvidersTableRow,
} from "@/types/providers-table";
import {
SCAN_CONFIGURATION_LIST_STATUS,
ScanConfigurationData,
type ScanConfigurationListStatus,
} from "@/types/scan-configurations";
import {
SCAN_SCHEDULE_CAPABILITY,
type ScanScheduleCapability,
@@ -55,6 +61,7 @@ import {
import { DeleteForm } from "../forms/delete-form";
import { DeleteOrganizationForm } from "../forms/delete-organization-form";
import { EditNameForm } from "../forms/edit-name-form";
import { ManageScanConfigModal } from "../scan-config/manage-scan-config-modal";
interface DataTableRowActionsProps {
row: Row<ProvidersTableRow>;
@@ -72,6 +79,13 @@ interface DataTableRowActionsProps {
onClearSelection: () => void;
onOpenProviderWizard: (initialData?: ProviderWizardInitialData) => void;
onOpenOrganizationWizard: (initialData: OrgWizardInitialData) => void;
/**
* All scan configurations in the tenant, used to associate/disassociate this
* provider's config from the row menu (Cloud-only feature). Empty in OSS.
*/
scanConfigs?: ScanConfigurationData[];
scanConfigStatus?: ScanConfigurationListStatus;
currentScanConfigId?: string | null;
/**
* Schedule capability override. Absent in OSS (defaults to a Cloud-vs-non-Cloud
* decision). The prowler-cloud overlay injects a billing-aware capability so
@@ -271,6 +285,9 @@ export function DataTableRowActions({
onClearSelection,
onOpenProviderWizard,
onOpenOrganizationWizard,
scanConfigs = [],
scanConfigStatus = SCAN_CONFIGURATION_LIST_STATUS.AVAILABLE,
currentScanConfigId = null,
capability,
}: DataTableRowActionsProps) {
const canEditSchedule =
@@ -282,6 +299,7 @@ export function DataTableRowActions({
kind: EDIT_SCAN_SCHEDULE_STATE.LOADING,
});
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
const [isScanConfigOpen, setIsScanConfigOpen] = useState(false);
const [loading, setLoading] = useState(false);
const { toast } = useToast();
const router = useRouter();
@@ -295,6 +313,10 @@ export function DataTableRowActions({
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);
const canManageScanConfig =
isCloudProvider &&
scanConfigStatus === SCAN_CONFIGURATION_LIST_STATUS.AVAILABLE;
const scheduleProvider: ScanScheduleProvider | undefined = provider
? {
providerId,
@@ -548,6 +570,17 @@ export function DataTableRowActions({
provider={scheduleProvider}
state={scheduleState}
/>
{canManageScanConfig && provider && (
<ManageScanConfigModal
open={isScanConfigOpen}
onOpenChange={setIsScanConfigOpen}
providerId={providerId}
providerLabel={providerAlias || providerUid}
scanConfigs={scanConfigs}
currentConfigId={currentScanConfigId}
onSaved={() => router.refresh()}
/>
)}
<div className="relative flex items-center justify-end gap-2">
<ActionDropdown>
<ActionDropdownItem
@@ -575,6 +608,22 @@ export function DataTableRowActions({
onSelect={() => void openScheduleEditor()}
/>
)}
{canManageScanConfig && (
<ActionDropdownItem
icon={<SlidersHorizontal />}
label="Edit Scan Configuration"
onSelect={() => setIsScanConfigOpen(true)}
/>
)}
{isCloudProvider &&
scanConfigStatus === SCAN_CONFIGURATION_LIST_STATUS.UNAVAILABLE && (
<ActionDropdownItem
icon={<SlidersHorizontal />}
label="Scan Configuration unavailable"
description="Try again later."
disabled
/>
)}
<ActionDropdownItem
icon={<KeyRound />}
label={hasSecret ? "Update Credentials" : "Add Credentials"}
+1
View File
@@ -28,6 +28,7 @@ const cardVariants = cva("flex flex-col gap-6 rounded-xl border", {
sm: "px-3 py-2",
md: "px-4 py-3",
lg: "px-5 py-4",
xl: "p-8",
none: "p-0",
},
},
+13 -2
View File
@@ -25,11 +25,22 @@ function FieldLabel({ className, ...props }: React.ComponentProps<"label">) {
);
}
function FieldError({ className, ...props }: React.ComponentProps<"p">) {
function FieldError({
className,
multiline = false,
...props
}: React.ComponentProps<"p"> & {
/** Preserve newlines for multi-line messages (e.g. server validation lists). */
multiline?: boolean;
}) {
return (
<p
data-slot="field-error"
className={cn("text-text-error-primary max-w-full text-xs", className)}
className={cn(
"text-text-error-primary max-w-full text-xs",
multiline && "whitespace-pre-wrap",
className,
)}
{...props}
/>
);
+9
View File
@@ -33,6 +33,13 @@ interface ModalProps {
size?: ModalSize;
className?: string;
onOpenAutoFocus?: (event: Event) => void;
/**
* Cap the dialog at 90dvh and scroll overflowing content, instead of
* letting it grow past the viewport. Opt-in per modal (e.g. for content
* whose height depends on user input) rather than a DS-wide default, so
* existing modals keep their current sizing.
*/
scrollable?: boolean;
}
export const Modal = ({
@@ -44,6 +51,7 @@ export const Modal = ({
size = "xl",
className,
onOpenAutoFocus = preventInitialAutoFocus,
scrollable = false,
}: ModalProps) => {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
@@ -51,6 +59,7 @@ export const Modal = ({
onOpenAutoFocus={onOpenAutoFocus}
className={cn(
"border-text-neutral-tertiary bg-bg-neutral-secondary rounded-[24px] border shadow-[0_0_200px_0_rgba(15,44,46,0.50)]",
scrollable && "max-h-[90dvh] overflow-y-auto",
SIZE_CLASSES[size],
className,
)}
@@ -0,0 +1,17 @@
import { readFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
describe("Textarea", () => {
it("does not import Next font loaders from the shared primitive", () => {
// Given
const currentDir = path.dirname(fileURLToPath(import.meta.url));
const source = readFileSync(path.join(currentDir, "textarea.tsx"), "utf8");
// Then
expect(source).not.toContain("@/config/fonts");
expect(source).not.toContain("next/font");
});
});
+30 -15
View File
@@ -1,7 +1,7 @@
"use client";
import { cva, type VariantProps } from "class-variance-authority";
import { ComponentProps, forwardRef } from "react";
import type { ComponentProps, Ref } from "react";
import { cn } from "@/lib/utils";
@@ -20,31 +20,46 @@ const textareaVariants = cva(
sm: "min-h-12 px-3 py-2 text-xs",
lg: "min-h-24 px-5 py-4",
},
font: {
sans: "",
mono: "font-mono",
},
},
defaultVariants: {
variant: "default",
textareaSize: "default",
font: "sans",
},
},
);
export interface TextareaProps
extends Omit<ComponentProps<"textarea">, "size">,
VariantProps<typeof textareaVariants> {}
VariantProps<typeof textareaVariants> {
ref?: Ref<HTMLTextAreaElement>;
}
const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(
({ className, variant, textareaSize, ...props }, ref) => {
return (
<textarea
ref={ref}
data-slot="textarea"
className={cn(textareaVariants({ variant, textareaSize, className }))}
{...props}
/>
);
},
);
export const Textarea = ({
className,
variant,
textareaSize,
font,
ref,
...props
}: TextareaProps) => {
return (
<textarea
ref={ref}
data-slot="textarea"
className={cn(
textareaVariants({ variant, textareaSize, font }),
className,
)}
{...props}
/>
);
};
Textarea.displayName = "Textarea";
export { Textarea, textareaVariants };
export { textareaVariants };
@@ -1,10 +1,14 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import { BreadcrumbNavigation } from "./breadcrumb-navigation";
const navigationMock = vi.hoisted(() => ({
pathname: "/findings",
}));
vi.mock("next/navigation", () => ({
usePathname: () => "/findings",
usePathname: () => navigationMock.pathname,
useSearchParams: () => new URLSearchParams(),
}));
@@ -22,6 +26,10 @@ vi.mock("@heroui/breadcrumbs", () => ({
}));
describe("BreadcrumbNavigation", () => {
afterEach(() => {
navigationMock.pathname = "/findings";
});
it("renders the title action next to the current breadcrumb title", () => {
// Given / When
render(
@@ -40,4 +48,25 @@ describe("BreadcrumbNavigation", () => {
screen.getByRole("button", { name: "Start product tour" }),
).toBeInTheDocument();
});
it("does not render icons for secondary breadcrumb items", () => {
// Given
navigationMock.pathname = "/scans/config";
// When
render(
<BreadcrumbNavigation
mode="auto"
title="Configuration"
icon="lucide:sliders"
/>,
);
// Then
expect(screen.getByLabelText("lucide:timer")).toBeInTheDocument();
expect(screen.queryByLabelText("lucide:sliders")).not.toBeInTheDocument();
expect(
screen.getByRole("heading", { name: "Configuration" }),
).toBeInTheDocument();
});
});
@@ -50,7 +50,7 @@ export function BreadcrumbNavigation({
"/users": "lucide:users",
"/compliance": "lucide:shield-check",
"/findings": "lucide:search",
"/scans": "lucide:activity",
"/scans": "lucide:timer",
"/roles": "lucide:key",
"/resources": "lucide:database",
"/lighthouse": <LighthouseIcon />,
@@ -108,16 +108,20 @@ export function BreadcrumbNavigation({
return path;
};
const renderTitleWithIcon = (titleText: string, isLink: boolean = false) => (
const renderTitleWithIcon = (
titleText: string,
isLink: boolean = false,
showIcon: boolean = true,
) => (
<div className="flex items-center gap-2">
{typeof icon === "string" ? (
{showIcon && typeof icon === "string" ? (
<Icon
className="text-text-neutral-primary"
height={24}
icon={icon}
width={24}
/>
) : icon ? (
) : showIcon && icon ? (
<div className="flex h-8 w-8 items-center justify-center *:h-full *:w-full">
{icon}
</div>
@@ -151,13 +155,15 @@ export function BreadcrumbNavigation({
{breadcrumbItems.map((breadcrumb, index) => (
<BreadcrumbItem key={breadcrumb.path || index}>
{breadcrumb.isLast && showTitle && title ? (
renderTitleWithIcon(title)
renderTitleWithIcon(title, false, index === 0)
) : breadcrumb.isClickable && breadcrumb.path ? (
<Link
href={buildNavigationUrl(breadcrumb.path)}
className="flex cursor-pointer items-center gap-2"
>
{breadcrumb.icon && typeof breadcrumb.icon === "string" ? (
{index === 0 &&
breadcrumb.icon &&
typeof breadcrumb.icon === "string" ? (
<Icon
aria-hidden="true"
className="text-text-neutral-primary"
@@ -165,7 +171,7 @@ export function BreadcrumbNavigation({
icon={breadcrumb.icon}
width={24}
/>
) : breadcrumb.icon ? (
) : index === 0 && breadcrumb.icon ? (
<div className="flex h-6 w-6 items-center justify-center *:h-full *:w-full">
{breadcrumb.icon}
</div>
@@ -179,7 +185,9 @@ 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"
>
{breadcrumb.icon && typeof breadcrumb.icon === "string" ? (
{index === 0 &&
breadcrumb.icon &&
typeof breadcrumb.icon === "string" ? (
<Icon
aria-hidden="true"
className="text-text-neutral-primary"
@@ -187,7 +195,7 @@ export function BreadcrumbNavigation({
icon={breadcrumb.icon}
width={24}
/>
) : breadcrumb.icon ? (
) : index === 0 && breadcrumb.icon ? (
<div className="flex h-6 w-6 items-center justify-center *:h-full *:w-full">
{breadcrumb.icon}
</div>
@@ -198,7 +206,9 @@ export function BreadcrumbNavigation({
</button>
) : (
<div className="flex items-center gap-2">
{breadcrumb.icon && typeof breadcrumb.icon === "string" ? (
{index === 0 &&
breadcrumb.icon &&
typeof breadcrumb.icon === "string" ? (
<Icon
aria-hidden="true"
className="text-default-500"
@@ -206,7 +216,7 @@ export function BreadcrumbNavigation({
icon={breadcrumb.icon}
width={24}
/>
) : breadcrumb.icon ? (
) : index === 0 && breadcrumb.icon ? (
<div className="flex h-6 w-6 items-center justify-center *:h-full *:w-full">
{breadcrumb.icon}
</div>
@@ -43,4 +43,34 @@ describe("SubmenuItem", () => {
await screen.findAllByText("Available in Prowler Cloud"),
).not.toHaveLength(0);
});
it("should render disabled Scan config menu items like disabled Alerts", async () => {
// Given
const user = userEvent.setup();
render(
<SubmenuItem
href="/scans/config"
label="Scan"
icon={TestIcon}
disabled
highlight
cloudOnly
/>,
);
// When
const button = screen.getByRole("button", { name: /scan/i });
await user.hover(button.parentElement as HTMLElement);
// Then
expect(button).toHaveAttribute("aria-disabled", "true");
expect(button).toHaveClass(
"cursor-not-allowed",
"text-text-neutral-tertiary",
);
expect(screen.getByText("New")).toHaveClass("h-5", "text-[10px]");
expect(
await screen.findAllByText("Available in Prowler Cloud"),
).not.toHaveLength(0);
});
});
+14 -8
View File
@@ -92,14 +92,14 @@ describe("getMenuList", () => {
);
});
it("should show Scan Configuration as disabled Cloud-only in OSS when Cloud is disabled", () => {
it("should show Scan as disabled Cloud-only in OSS when Cloud is disabled", () => {
// Given / When
const scanConfig = findSubmenu("Scan Configuration");
const scanConfig = findSubmenu("Scan");
// Then
expect(scanConfig).toEqual(
expect.objectContaining({
href: "/scan-configurations",
href: "/scans/config",
disabled: true,
cloudOnly: true,
highlight: true,
@@ -108,24 +108,30 @@ describe("getMenuList", () => {
);
});
it("should show Scan Configuration as new under Configuration when Cloud is enabled", () => {
it("should show Scan as new under Configuration when Cloud is enabled", () => {
// Given
process.env.NEXT_PUBLIC_IS_CLOUD_ENV = "true";
// When
const scanConfig = getMenuList({ pathname: "/scan-configurations" })
.flatMap((group) => group.menus)
const menus = getMenuList({ pathname: "/scans/config" }).flatMap(
(group) => group.menus,
);
const scanConfig = menus
.flatMap((menu) => menu.submenus ?? [])
.find((submenu) => submenu.label === "Scan Configuration");
.find((submenu) => submenu.label === "Scan");
const scans = menus.find((menu) => menu.label === "Scans");
// Then
expect(scanConfig).toEqual(
expect.objectContaining({
href: "/scan-configurations",
href: "/scans/config",
active: true,
highlight: true,
}),
);
// The top-level Scans item uses an exact-match active rule, so it must stay
// inactive on the `/scans/config` sub-route.
expect(scans).toEqual(expect.objectContaining({ active: false }));
});
it("should remove the new highlight from Attack Paths", () => {
+17 -5
View File
@@ -98,6 +98,19 @@ export const getMenuList = ({
},
],
},
{
groupLabel: "",
menus: [
{
href: "/scans",
label: "Scans",
icon: Timer,
// Exact match so it isn't also marked active on the `/scans/config`
// sub-route (mirrors the top-level Lighthouse entry).
active: pathname === "/scans",
},
],
},
{
groupLabel: "",
menus: [
@@ -133,17 +146,15 @@ export const getMenuList = ({
active: pathname === "/mutelist",
},
{
href: "/scan-configurations",
label: "Scan Configuration",
href: "/scans/config",
label: "Scan",
icon: SlidersHorizontal,
active: isCloudEnv && pathname.startsWith("/scan-configurations"),
active: isCloudEnv && pathname.startsWith("/scans/config"),
highlight: true,
disabled: !isCloudEnv,
cloudOnly: !isCloudEnv,
},
{ href: "/scans", label: "Scan Jobs", icon: Timer },
{ href: "/integrations", label: "Integrations", icon: Puzzle },
{ href: "/roles", label: "Roles", icon: UserCog },
{ href: "/lighthouse/config", label: "Lighthouse AI", icon: Cog },
],
defaultOpen: true,
@@ -160,6 +171,7 @@ export const getMenuList = ({
submenus: [
{ href: "/users", label: "Users", icon: User },
{ href: "/invitations", label: "Invitations", icon: Mail },
{ href: "/roles", label: "Roles", icon: UserCog },
],
defaultOpen: false,
},
+58
View File
@@ -0,0 +1,58 @@
import { describe, expect, it } from "vitest";
import { validateYaml } from "./yaml";
// The Scan Configuration editor (like the Mutelist editor) validates only YAML
// *syntax* on the client; the API validates the configuration values
// (ranges/enums) on create/update. These cover the syntax check the editor and
// `scanConfigurationFormSchema` rely on.
describe("validateYaml", () => {
it("accepts a mapping with provider sections", () => {
// When
const result = validateYaml("aws:\n max_unused_access_keys_days: 45");
// Then
expect(result.isValid).toBe(true);
});
it("accepts a key with no value yet (the `aws:` typing state)", () => {
// When — `aws:` parses to { aws: null }, still a mapping
const result = validateYaml("aws:");
// Then
expect(result.isValid).toBe(true);
});
it("rejects malformed YAML with a syntax error", () => {
// When — unmatched bracket is invalid flow syntax
const result = validateYaml("aws: [1, 2");
// Then
expect(result.isValid).toBe(false);
expect(result.error).toBeTruthy();
});
it("rejects a top-level list (config must be a mapping)", () => {
// When
const result = validateYaml("- aws\n- azure");
// Then
expect(result.isValid).toBe(false);
});
it("rejects empty content", () => {
// When
const result = validateYaml("");
// Then
expect(result.isValid).toBe(false);
});
it("rejects a scalar (not a mapping)", () => {
// When — a bare word parses to the string "aws", not a mapping
const result = validateYaml("aws");
// Then
expect(result.isValid).toBe(false);
});
});
+31 -109
View File
@@ -1,4 +1,3 @@
import Ajv, { type ErrorObject, type ValidateFunction } from "ajv";
import yaml from "js-yaml";
import { mutedFindingsConfigFormSchema } from "@/types/formSchemas";
@@ -275,116 +274,39 @@ Mutelist:
Tags:
- "Name=aws-controltower-VPC"`;
export interface ScanConfigurationValidationError {
path: string;
message: string;
}
export interface ScanConfigurationValidationResult {
isValid: boolean;
errors: ScanConfigurationValidationError[];
}
// Compile the JSON Schema with ajv at most once per schema reference.
const _ajv = new Ajv({ allErrors: true, strict: false });
const _validatorCache = new WeakMap<object, ValidateFunction>();
const getValidator = (schema: Record<string, unknown>): ValidateFunction => {
const cached = _validatorCache.get(schema);
if (cached) return cached;
const compiled = _ajv.compile(schema);
_validatorCache.set(schema, compiled);
return compiled;
};
const formatAjvPath = (err: ErrorObject): string => {
// instancePath is a JSON Pointer (/aws/max_unused_access_keys_days or /aws/ec2_high_risk_ports/1).
// Convert to a dotted form with list indices as [n].
const raw = err.instancePath.replace(/^\//, "");
if (!raw) {
const extra = (err.params as { additionalProperty?: string })
?.additionalProperty;
return extra ? extra : "<root>";
}
return raw
.split("/")
.map((piece) => piece.replace(/~1/g, "/").replace(/~0/g, "~"))
.reduce<string>((acc, piece) => {
if (/^\d+$/.test(piece)) return `${acc}[${piece}]`;
return acc ? `${acc}.${piece}` : piece;
}, "");
};
/**
* Validate a YAML string against the aggregated Scan Configuration JSON Schema.
*
* If `schema` is null (e.g. backend unreachable), it falls back to only
* checking that the YAML parses to a mapping — so the user is never blocked
* from saving when the schema endpoint is down.
*/
export const validateScanConfigurationPayload = (
val: string,
schema: Record<string, unknown> | null,
): ScanConfigurationValidationResult => {
const yamlCheck = validateYaml(val);
if (!yamlCheck.isValid) {
return {
isValid: false,
errors: [{ path: "<root>", message: `Invalid YAML: ${yamlCheck.error}` }],
};
}
let parsed: unknown;
try {
parsed = yaml.load(val);
} catch (e) {
return {
isValid: false,
errors: [
{
path: "<root>",
message: e instanceof Error ? e.message : "Failed to parse YAML",
},
],
};
}
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
return {
isValid: false,
errors: [
{
path: "<root>",
message:
"YAML must be a mapping with provider sections (aws, azure, ...).",
},
],
};
}
if (!schema) {
return { isValid: true, errors: [] };
}
const validate = getValidator(schema);
if (validate(parsed)) {
return { isValid: true, errors: [] };
}
const errors = (validate.errors ?? []).map((err) => ({
path: formatAjvPath(err),
message: err.message ?? "Invalid value",
}));
return { isValid: false, errors };
};
export const defaultScanConfigurationYaml = `# Scan Configuration overrides the per-tenant defaults documented in
# prowler/config/config.yaml. Add only the keys you want to override.
# Allowed ranges and enums are described by the server-side JSON Schema
# served at /api/v1/scan-configurations/schema; invalid values are flagged below.
export const defaultScanConfigurationYaml = `# Override Prowler's per-tenant defaults below.
# Keep only the keys you want to change; the rest
# use the built-in defaults from config.yaml.
# Values are validated on save.
aws:
mute_non_default_regions: false
max_unused_access_keys_days: 45
max_console_access_days: 45
max_unused_sagemaker_access_days: 90
max_security_group_rules: 50
# azure:
# php_latest_version: "8.2"
azure:
defender_attack_path_minimal_risk_level: "High"
php_latest_version: "8.2"
python_latest_version: "3.12"
java_latest_version: "17"
vm_backup_min_daily_retention_days: 7
gcp:
mig_min_zones: 2
max_snapshot_age_days: 90
max_unused_account_days: 180
storage_min_retention_days: 90
secretmanager_max_rotation_days: 90
kubernetes:
audit_log_maxbackup: 10
audit_log_maxsize: 100
audit_log_maxage: 30
m365:
sign_in_frequency: 4
recommended_mailtips_large_audience_threshold: 25
audit_log_age: 90
`;
+14 -6
View File
@@ -723,11 +723,10 @@ export const mutedFindingsConfigFormSchema = z.object({
id: z.string().optional(),
});
// The editor owns content validation: live YAML syntax + schema (ranges/enums)
// checks run through `validateScanConfigurationPayload(yamlString, schema)` in
// `lib/yaml.ts` and surface in a single inline panel. Here we only enforce the
// form-level shape (a name and a non-empty configuration) so we don't render the
// same YAML error twice.
// Mirrors the Mutelist contract: the client only validates YAML *syntax* (that
// it parses to a mapping). The actual configuration validation (ranges, enums)
// is performed by the API on create/update and surfaced inline — see
// `validate_configuration` in the backend serializer.
export const scanConfigurationFormSchema = z.object({
name: z
.string()
@@ -737,7 +736,16 @@ export const scanConfigurationFormSchema = z.object({
configuration: z
.string()
.trim()
.min(1, { error: "Configuration is required" }),
.min(1, { error: "Configuration is required" })
.superRefine((val, ctx) => {
const yamlValidation = validateYaml(val);
if (!yamlValidation.isValid) {
ctx.addIssue({
code: "custom",
message: `Invalid YAML format: ${yamlValidation.error}`,
});
}
}),
provider_ids: z.array(z.uuid()).optional().default([]),
id: z.string().optional(),
});
+13
View File
@@ -12,6 +12,19 @@ export interface ScanConfigurationData {
attributes: ScanConfigurationAttributes;
}
export const SCAN_CONFIGURATION_LIST_STATUS = {
AVAILABLE: "available",
UNAVAILABLE: "unavailable",
} as const;
export type ScanConfigurationListStatus =
(typeof SCAN_CONFIGURATION_LIST_STATUS)[keyof typeof SCAN_CONFIGURATION_LIST_STATUS];
export interface ScanConfigurationListState {
status: ScanConfigurationListStatus;
data: ScanConfigurationData[];
}
export interface ScanConfigurationListResponse {
data: ScanConfigurationData[];
}