From d850349a1c6bed7d5bb74ba712be3ceb0c619d30 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pedro=20Mart=C3=ADn?= Date: Mon, 29 Jun 2026 13:36:38 +0200 Subject: [PATCH] feat(ui): rename scan configuration endpoint (#11710) --- ui/CHANGELOG.md | 4 +- ui/actions/scan-configs/index.ts | 1 - ui/actions/scan-configurations/index.ts | 1 + .../scan-configurations.ts} | 150 ++++++++++-------- ui/app/(prowler)/scan-config/page.tsx | 34 ---- .../scan-configuration-editor.tsx} | 117 +++++++------- .../scan-configurations-columns.tsx} | 16 +- .../scan-configurations-manager.tsx} | 109 +++++++------ ui/app/(prowler)/scan-configurations/page.tsx | 39 +++++ ui/lib/menu-list.test.ts | 14 +- ui/lib/menu-list.ts | 6 +- ui/lib/yaml.ts | 16 +- ui/types/formSchemas.ts | 21 +-- ui/types/scan-configs.ts | 53 ------- ui/types/scan-configurations.ts | 55 +++++++ 15 files changed, 331 insertions(+), 305 deletions(-) delete mode 100644 ui/actions/scan-configs/index.ts create mode 100644 ui/actions/scan-configurations/index.ts rename ui/actions/{scan-configs/scan-configs.ts => scan-configurations/scan-configurations.ts} (59%) delete mode 100644 ui/app/(prowler)/scan-config/page.tsx rename ui/app/(prowler)/{scan-config/_components/scan-config-editor.tsx => scan-configurations/_components/scan-configuration-editor.tsx} (75%) rename ui/app/(prowler)/{scan-config/_components/scan-configs-columns.tsx => scan-configurations/_components/scan-configurations-columns.tsx} (83%) rename ui/app/(prowler)/{scan-config/_components/scan-configs-manager.tsx => scan-configurations/_components/scan-configurations-manager.tsx} (66%) create mode 100644 ui/app/(prowler)/scan-configurations/page.tsx delete mode 100644 ui/types/scan-configs.ts create mode 100644 ui/types/scan-configurations.ts diff --git a/ui/CHANGELOG.md b/ui/CHANGELOG.md index fe0c6cce14..e77057779d 100644 --- a/ui/CHANGELOG.md +++ b/ui/CHANGELOG.md @@ -6,8 +6,8 @@ All notable changes to the **Prowler UI** are documented in this file. ### 🚀 Added -- Add `Scan Config` 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-config`) to create, edit, and manage scan configs with live YAML validation against the server JSON Schema (only available in Prowler Cloud) [(#11695)](https://github.com/prowler-cloud/prowler/pull/11695) +- 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) diff --git a/ui/actions/scan-configs/index.ts b/ui/actions/scan-configs/index.ts deleted file mode 100644 index 2c80b2f490..0000000000 --- a/ui/actions/scan-configs/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./scan-configs"; diff --git a/ui/actions/scan-configurations/index.ts b/ui/actions/scan-configurations/index.ts new file mode 100644 index 0000000000..d9631969df --- /dev/null +++ b/ui/actions/scan-configurations/index.ts @@ -0,0 +1 @@ +export * from "./scan-configurations"; diff --git a/ui/actions/scan-configs/scan-configs.ts b/ui/actions/scan-configurations/scan-configurations.ts similarity index 59% rename from ui/actions/scan-configs/scan-configs.ts rename to ui/actions/scan-configurations/scan-configurations.ts index ace7f2814e..1ecdb3256b 100644 --- a/ui/actions/scan-configs/scan-configs.ts +++ b/ui/actions/scan-configurations/scan-configurations.ts @@ -5,20 +5,21 @@ import { revalidatePath } from "next/cache"; import { z } from "zod"; import { apiBaseUrl, getAuthHeaders } from "@/lib/helper"; -import { scanConfigFormSchema } from "@/types/formSchemas"; +import { scanConfigurationFormSchema } from "@/types/formSchemas"; import { - DeleteScanConfigActionState, - ScanConfigActionState, - ScanConfigData, - ScanConfigErrors, - ScanConfigRequestBody, -} from "@/types/scan-configs"; + DeleteScanConfigurationActionState, + ScanConfigurationActionState, + ScanConfigurationData, + ScanConfigurationErrors, + ScanConfigurationRequestBody, +} from "@/types/scan-configurations"; -const SCAN_CONFIG_PATH = "/scan-config"; +const SCAN_CONFIGURATION_PATH = "/scan-configurations"; -// Scan Config IDs are UUIDs. Validate before interpolating into request URLs so -// a malformed/crafted value can't inject path segments (SSRF / path injection). -const scanConfigIdSchema = z.uuid(); +// 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(); const parseConfiguration = (value: string): Record => { // Backend (YamlOrJsonField) accepts either a YAML string or a JSON object. @@ -37,10 +38,10 @@ const collectProviderIds = (formData: FormData): string[] => { .filter(Boolean); }; -export const createScanConfig = async ( - _prevState: ScanConfigActionState, +export const createScanConfiguration = async ( + _prevState: ScanConfigurationActionState, formData: FormData, -): Promise => { +): Promise => { const headers = await getAuthHeaders({ contentType: true }); const formDataObject = { name: formData.get("name"), @@ -48,7 +49,7 @@ export const createScanConfig = async ( provider_ids: collectProviderIds(formData), }; - const validated = scanConfigFormSchema.safeParse(formDataObject); + const validated = scanConfigurationFormSchema.safeParse(formDataObject); if (!validated.success) { const fieldErrors = validated.error.flatten().fieldErrors; return { @@ -75,10 +76,10 @@ export const createScanConfig = async ( } try { - const url = new URL(`${apiBaseUrl}/scan-configs`); - const bodyData: ScanConfigRequestBody = { + const url = new URL(`${apiBaseUrl}/scan-configurations`); + const bodyData: ScanConfigurationRequestBody = { data: { - type: "scan-configs", + type: "scan-configurations", attributes: { name, configuration: parsedConfig, @@ -97,11 +98,11 @@ export const createScanConfig = async ( const detail = errorData?.errors?.[0]?.detail || errorData?.message || - `Failed to create Scan Config: ${response.statusText}`; + `Failed to create Scan Configuration: ${response.statusText}`; const pointer = errorData?.errors?.[0]?.source?.pointer as | string | undefined; - const errors: ScanConfigErrors = {}; + const errors: ScanConfigurationErrors = {}; if (pointer?.includes("name")) errors.name = detail; else if (pointer?.includes("configuration")) errors.configuration = detail; @@ -111,35 +112,37 @@ export const createScanConfig = async ( } const data = await response.json(); - revalidatePath(SCAN_CONFIG_PATH); + revalidatePath(SCAN_CONFIGURATION_PATH); return { - success: "Scan Config created successfully!", - data: data.data as ScanConfigData, + success: "Scan Configuration created successfully!", + data: data.data as ScanConfigurationData, }; } catch (error) { - console.error("Error creating Scan Config:", error); + console.error("Error creating Scan Configuration:", error); return { errors: { general: error instanceof Error ? error.message - : "Error creating Scan Config. Please try again.", + : "Error creating Scan Configuration. Please try again.", }, }; } }; -export const updateScanConfig = async ( - _prevState: ScanConfigActionState, +export const updateScanConfiguration = async ( + _prevState: ScanConfigurationActionState, formData: FormData, -): Promise => { +): Promise => { const id = formData.get("id"); if (!id) { - return { errors: { general: "Scan Config ID is required for update" } }; + return { + errors: { general: "Scan Configuration ID is required for update" }, + }; } - const idResult = scanConfigIdSchema.safeParse(String(id)); + const idResult = scanConfigurationIdSchema.safeParse(String(id)); if (!idResult.success) { - return { errors: { general: "Invalid Scan Config ID" } }; + return { errors: { general: "Invalid Scan Configuration ID" } }; } const validId = idResult.data; const headers = await getAuthHeaders({ contentType: true }); @@ -149,7 +152,7 @@ export const updateScanConfig = async ( provider_ids: collectProviderIds(formData), }; - const validated = scanConfigFormSchema.safeParse(formDataObject); + const validated = scanConfigurationFormSchema.safeParse(formDataObject); if (!validated.success) { const fieldErrors = validated.error.flatten().fieldErrors; return { @@ -176,10 +179,10 @@ export const updateScanConfig = async ( } try { - const url = new URL(`${apiBaseUrl}/scan-configs/${validId}`); - const bodyData: ScanConfigRequestBody = { + const url = new URL(`${apiBaseUrl}/scan-configurations/${validId}`); + const bodyData: ScanConfigurationRequestBody = { data: { - type: "scan-configs", + type: "scan-configurations", id: validId, attributes: { name, @@ -199,35 +202,35 @@ export const updateScanConfig = async ( const detail = errorData?.errors?.[0]?.detail || errorData?.message || - `Failed to update Scan Config: ${response.statusText}`; + `Failed to update Scan Configuration: ${response.statusText}`; return { errors: { general: detail } }; } const data = await response.json(); - revalidatePath(SCAN_CONFIG_PATH); + revalidatePath(SCAN_CONFIGURATION_PATH); return { - success: "Scan Config updated successfully!", - data: data.data as ScanConfigData, + success: "Scan Configuration updated successfully!", + data: data.data as ScanConfigurationData, }; } catch (error) { - console.error("Error updating Scan Config:", error); + console.error("Error updating Scan Configuration:", error); return { errors: { general: error instanceof Error ? error.message - : "Error updating Scan Config. Please try again.", + : "Error updating Scan Configuration. Please try again.", }, }; } }; -export const getScanConfigSchema = async (): Promise | null> => { const headers = await getAuthHeaders({ contentType: false }); - const url = new URL(`${apiBaseUrl}/scan-configs/schema`); + const url = new URL(`${apiBaseUrl}/scan-configurations/schema`); try { const response = await fetch(url.toString(), { method: "GET", @@ -235,7 +238,7 @@ export const getScanConfigSchema = async (): Promise => { +export const listScanConfigurations = async (): Promise< + ScanConfigurationData[] +> => { const headers = await getAuthHeaders({ contentType: false }); - const url = new URL(`${apiBaseUrl}/scan-configs`); + const url = new URL(`${apiBaseUrl}/scan-configurations`); try { const response = await fetch(url.toString(), { @@ -259,23 +264,28 @@ export const listScanConfigs = async (): Promise => { headers, }); if (!response.ok) { - throw new Error(`Failed to list Scan Configs: ${response.statusText}`); + throw new Error( + `Failed to list Scan Configurations: ${response.statusText}`, + ); } const json = await response.json(); - return (json.data || []) as ScanConfigData[]; + return (json.data || []) as ScanConfigurationData[]; } catch (error) { - console.error("Error listing Scan Configs:", error); - return []; + // Re-throw so callers can distinguish a fetch/auth failure from an empty + // result. Collapsing errors into `[]` would render a false "no scan + // configurations" state and overwrite the table on a failed refresh. + console.error("Error listing Scan Configurations:", error); + throw error; } }; -export const getScanConfig = async ( +export const getScanConfiguration = async ( id: string, -): Promise => { - const idResult = scanConfigIdSchema.safeParse(id); +): Promise => { + const idResult = scanConfigurationIdSchema.safeParse(id); if (!idResult.success) return undefined; const headers = await getAuthHeaders({ contentType: false }); - const url = new URL(`${apiBaseUrl}/scan-configs/${idResult.data}`); + const url = new URL(`${apiBaseUrl}/scan-configurations/${idResult.data}`); try { const response = await fetch(url.toString(), { @@ -284,28 +294,30 @@ export const getScanConfig = async ( }); if (!response.ok) return undefined; const json = await response.json(); - return json.data as ScanConfigData; + return json.data as ScanConfigurationData; } catch (error) { - console.error("Error fetching Scan Config:", error); + console.error("Error fetching Scan Configuration:", error); return undefined; } }; -export const deleteScanConfig = async ( - _prevState: DeleteScanConfigActionState, +export const deleteScanConfiguration = async ( + _prevState: DeleteScanConfigurationActionState, formData: FormData, -): Promise => { +): Promise => { const headers = await getAuthHeaders({ contentType: true }); const id = formData.get("id"); if (!id) { - return { errors: { general: "Scan Config ID is required for deletion" } }; + return { + errors: { general: "Scan Configuration ID is required for deletion" }, + }; } - const idResult = scanConfigIdSchema.safeParse(String(id)); + const idResult = scanConfigurationIdSchema.safeParse(String(id)); if (!idResult.success) { - return { errors: { general: "Invalid Scan Config ID" } }; + return { errors: { general: "Invalid Scan Configuration ID" } }; } try { - const url = new URL(`${apiBaseUrl}/scan-configs/${idResult.data}`); + const url = new URL(`${apiBaseUrl}/scan-configurations/${idResult.data}`); const response = await fetch(url.toString(), { method: "DELETE", headers, @@ -314,19 +326,19 @@ export const deleteScanConfig = async ( const errorData = await response.json().catch(() => ({})); throw new Error( errorData.errors?.[0]?.detail || - `Failed to delete Scan Config: ${response.statusText}`, + `Failed to delete Scan Configuration: ${response.statusText}`, ); } - revalidatePath(SCAN_CONFIG_PATH); - return { success: "Scan Config deleted successfully!" }; + revalidatePath(SCAN_CONFIGURATION_PATH); + return { success: "Scan Configuration deleted successfully!" }; } catch (error) { - console.error("Error deleting Scan Config:", error); + console.error("Error deleting Scan Configuration:", error); return { errors: { general: error instanceof Error ? error.message - : "Error deleting Scan Config. Please try again.", + : "Error deleting Scan Configuration. Please try again.", }, }; } diff --git a/ui/app/(prowler)/scan-config/page.tsx b/ui/app/(prowler)/scan-config/page.tsx deleted file mode 100644 index 273e1d8a69..0000000000 --- a/ui/app/(prowler)/scan-config/page.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import { redirect } from "next/navigation"; - -import { getAllProviders } from "@/actions/providers"; -import { getScanConfigSchema, listScanConfigs } from "@/actions/scan-configs"; -import { ContentLayout } from "@/components/ui"; -import { isCloud } from "@/lib/shared/env"; - -import { ScanConfigsManager } from "./_components/scan-configs-manager"; - -export default async function ScanConfigPage() { - // Scan Config is a Prowler Cloud-only feature; the OSS API has no - // /scan-configs endpoints, so guard the route before hitting them. - if (!isCloud()) { - redirect("/"); - } - - const [configs, providersResponse, schema] = await Promise.all([ - listScanConfigs(), - getAllProviders({}), - getScanConfigSchema(), - ]); - - const richProviders = providersResponse?.data ?? []; - - return ( - - - - ); -} diff --git a/ui/app/(prowler)/scan-config/_components/scan-config-editor.tsx b/ui/app/(prowler)/scan-configurations/_components/scan-configuration-editor.tsx similarity index 75% rename from ui/app/(prowler)/scan-config/_components/scan-config-editor.tsx rename to ui/app/(prowler)/scan-configurations/_components/scan-configuration-editor.tsx index 0c50be25a1..e74115a58b 100644 --- a/ui/app/(prowler)/scan-config/_components/scan-config-editor.tsx +++ b/ui/app/(prowler)/scan-configurations/_components/scan-configuration-editor.tsx @@ -5,7 +5,10 @@ import { useRef } from "react"; import { useForm } from "react-hook-form"; import { z } from "zod"; -import { createScanConfig, updateScanConfig } from "@/actions/scan-configs"; +import { + createScanConfiguration, + updateScanConfiguration, +} from "@/actions/scan-configurations"; import { AccountsSelector } from "@/app/(prowler)/_overview/_components/accounts-selector"; import { Button, @@ -21,44 +24,44 @@ import { CustomLink } from "@/components/ui/custom/custom-link"; import { fontMono } from "@/config/fonts"; import { convertToYaml, - defaultScanConfigYaml, - validateScanConfigPayload, + defaultScanConfigurationYaml, + validateScanConfigurationPayload, } from "@/lib/yaml"; -import { scanConfigFormSchema } from "@/types/formSchemas"; +import { scanConfigurationFormSchema } from "@/types/formSchemas"; import { ProviderProps } from "@/types/providers"; -import { ScanConfigData } from "@/types/scan-configs"; +import { ScanConfigurationData } from "@/types/scan-configurations"; -interface ScanConfigEditorProps { +interface ScanConfigurationEditorProps { open: boolean; onClose: (saved: boolean) => void; richProviders: ProviderProps[]; - existingConfigs: ScanConfigData[]; - config: ScanConfigData | null; + existingConfigs: ScanConfigurationData[]; + config: ScanConfigurationData | null; schema: Record | null; } -interface ScanConfigFormProps { +interface ScanConfigurationFormProps { onClose: (saved: boolean) => void; richProviders: ProviderProps[]; - existingConfigs: ScanConfigData[]; - config: ScanConfigData | null; + existingConfigs: ScanConfigurationData[]; + config: ScanConfigurationData | null; schema: Record | null; } // `provider_ids` has a zod `.default([])`, so the resolver's input and output // types differ — type the form with both so RHF and zodResolver line up. -type ScanConfigFormInput = z.input; -type ScanConfigFormValues = z.output; +type ScanConfigurationFormInput = z.input; +type ScanConfigurationFormValues = z.output; const MAX_ERRORS_SHOWN = 10; -function ScanConfigForm({ +function ScanConfigurationForm({ onClose, richProviders, existingConfigs, config, schema, -}: ScanConfigFormProps) { +}: ScanConfigurationFormProps) { const isEdit = !!config; const { toast } = useToast(); const errorPanelRef = useRef(null); @@ -66,8 +69,12 @@ function ScanConfigForm({ // The form is remounted every time the modal opens (Radix unmounts the // dialog content on close), so deriving the defaults from `config` here is // enough to reset the form — no `useEffect` needed. - const form = useForm({ - resolver: zodResolver(scanConfigFormSchema), + const form = useForm< + ScanConfigurationFormInput, + unknown, + ScanConfigurationFormValues + >({ + resolver: zodResolver(scanConfigurationFormSchema), defaultValues: config ? { name: config.attributes.name, @@ -84,7 +91,7 @@ function ScanConfigForm({ // 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() - ? validateScanConfigPayload(configText, schema) + ? validateScanConfigurationPayload(configText, schema) : { isValid: true, errors: [] }; // A provider can only be attached to one config at a time. We exclude @@ -104,16 +111,9 @@ function ScanConfigForm({ const lockedCount = richProviders.length - selectableProviders.length; const onSubmit = form.handleSubmit(async (values) => { - // zod validates name length and YAML *syntax*; richer schema violations - // (ranges/enums) surface through `yamlValidation` and must block here too. + // 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) { - toast({ - variant: "destructive", - title: "Cannot save", - description: `${yamlValidation.errors.length} validation ${ - yamlValidation.errors.length === 1 ? "error" : "errors" - } in the configuration. Fix them before saving.`, - }); errorPanelRef.current?.scrollIntoView({ behavior: "smooth", block: "center", @@ -133,18 +133,22 @@ function ScanConfigForm({ try { const result = config - ? await updateScanConfig(null, formData) - : await createScanConfig(null, formData); + ? await updateScanConfiguration(null, formData) + : await createScanConfiguration(null, formData); if (result?.success) { toast({ - title: isEdit ? "Scan Config updated" : "Scan Config created", + title: isEdit + ? "Scan Configuration updated" + : "Scan Configuration created", description: result.success, }); onClose(true); return; } + // Field-level errors render inline next to each input; only a general + // error (no field to anchor it to) falls back to a toast. const errors = result?.errors || {}; if (errors.name) form.setError("name", { message: errors.name }); if (errors.configuration) @@ -157,16 +161,6 @@ function ScanConfigForm({ title: "Oops! Something went wrong", description: errors.general, }); - } else if (errors.configuration || errors.name || errors.provider_ids) { - toast({ - variant: "destructive", - title: "Validation failed", - description: - errors.configuration || - errors.name || - errors.provider_ids || - "Please review the form.", - }); } } catch (e) { toast({ @@ -186,9 +180,9 @@ function ScanConfigForm({ return (
- Name + Name - Configuration (YAML) + + Configuration (YAML) +

Follows the structure of{" "}