mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-07-04 19:21:51 +00:00
334 lines
11 KiB
TypeScript
334 lines
11 KiB
TypeScript
"use client";
|
|
|
|
import { zodResolver } from "@hookform/resolvers/zod";
|
|
import { useRef } from "react";
|
|
import { useForm } from "react-hook-form";
|
|
import { z } from "zod";
|
|
|
|
import {
|
|
createScanConfiguration,
|
|
updateScanConfiguration,
|
|
} from "@/actions/scan-configurations";
|
|
import { AccountsSelector } from "@/app/(prowler)/_overview/_components/accounts-selector";
|
|
import {
|
|
Button,
|
|
Field,
|
|
FieldError,
|
|
FieldLabel,
|
|
Input,
|
|
Textarea,
|
|
} from "@/components/shadcn";
|
|
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,
|
|
} from "@/lib/yaml";
|
|
import { scanConfigurationFormSchema } from "@/types/formSchemas";
|
|
import { ProviderProps } from "@/types/providers";
|
|
import { ScanConfigurationData } from "@/types/scan-configurations";
|
|
|
|
interface ScanConfigurationEditorProps {
|
|
open: boolean;
|
|
onClose: (saved: boolean) => void;
|
|
richProviders: ProviderProps[];
|
|
existingConfigs: ScanConfigurationData[];
|
|
config: ScanConfigurationData | null;
|
|
schema: Record<string, unknown> | null;
|
|
}
|
|
|
|
interface ScanConfigurationFormProps {
|
|
onClose: (saved: boolean) => void;
|
|
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
|
|
// types differ — type the form with both so RHF and zodResolver line up.
|
|
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();
|
|
const errorPanelRef = useRef<HTMLDivElement | null>(null);
|
|
|
|
// 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<
|
|
ScanConfigurationFormInput,
|
|
unknown,
|
|
ScanConfigurationFormValues
|
|
>({
|
|
resolver: zodResolver(scanConfigurationFormSchema),
|
|
defaultValues: config
|
|
? {
|
|
name: config.attributes.name,
|
|
configuration: convertToYaml(config.attributes.configuration || ""),
|
|
provider_ids: config.attributes.providers || [],
|
|
}
|
|
: { name: "", configuration: "", provider_ids: [] },
|
|
});
|
|
|
|
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: [] };
|
|
|
|
// 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;
|
|
|
|
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) {
|
|
errorPanelRef.current?.scrollIntoView({
|
|
behavior: "smooth",
|
|
block: "center",
|
|
});
|
|
return;
|
|
}
|
|
|
|
const formData = new FormData();
|
|
formData.append("name", values.name.trim());
|
|
formData.append("configuration", values.configuration);
|
|
values.provider_ids.forEach((pid) => {
|
|
formData.append("provider_ids", pid);
|
|
});
|
|
if (config) {
|
|
formData.append("id", config.id);
|
|
}
|
|
|
|
try {
|
|
const result = config
|
|
? await updateScanConfiguration(null, formData)
|
|
: await createScanConfiguration(null, formData);
|
|
|
|
if (result?.success) {
|
|
toast({
|
|
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)
|
|
form.setError("configuration", { message: errors.configuration });
|
|
if (errors.provider_ids)
|
|
form.setError("provider_ids", { message: errors.provider_ids });
|
|
if (errors.general) {
|
|
toast({
|
|
variant: "destructive",
|
|
title: "Oops! Something went wrong",
|
|
description: errors.general,
|
|
});
|
|
}
|
|
} catch (e) {
|
|
toast({
|
|
variant: "destructive",
|
|
title: "Oops! Something went wrong",
|
|
description:
|
|
e instanceof Error ? e.message : "Unexpected error. Please retry.",
|
|
});
|
|
}
|
|
});
|
|
|
|
const isSubmitting = form.formState.isSubmitting;
|
|
const nameError = form.formState.errors.name?.message;
|
|
const configError = form.formState.errors.configuration?.message;
|
|
const providersError = form.formState.errors.provider_ids?.message;
|
|
|
|
return (
|
|
<form onSubmit={onSubmit} className="flex flex-col gap-5">
|
|
<Field>
|
|
<FieldLabel htmlFor="scan-configuration-name">Name</FieldLabel>
|
|
<Input
|
|
id="scan-configuration-name"
|
|
placeholder="e.g. stricter-iam-aws"
|
|
aria-invalid={!!nameError}
|
|
{...form.register("name")}
|
|
/>
|
|
{nameError && <FieldError>{nameError}</FieldError>}
|
|
</Field>
|
|
|
|
<Field>
|
|
<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>
|
|
<Textarea
|
|
id="scan-configuration-yaml"
|
|
placeholder={defaultScanConfigurationYaml}
|
|
rows={14}
|
|
aria-invalid={!!configError || !yamlValidation.isValid}
|
|
className={fontMono.className + " text-sm"}
|
|
{...form.register("configuration")}
|
|
/>
|
|
<div aria-live="polite" className="mt-1" ref={errorPanelRef}>
|
|
{yamlValidation.errors.length === 0 && configText.trim() ? (
|
|
<p className="text-tiny text-text-success-primary">
|
|
Configuration valid
|
|
</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>
|
|
)}
|
|
</div>
|
|
</Field>
|
|
|
|
<Field>
|
|
<FieldLabel>Attach to providers</FieldLabel>
|
|
<p className="text-default-500 text-tiny">
|
|
Pick the cloud providers that should use this configuration on their
|
|
next scan.
|
|
{lockedCount > 0 && (
|
|
<>
|
|
{" "}
|
|
{lockedCount}{" "}
|
|
{lockedCount === 1 ? "provider is" : "providers are"} hidden
|
|
because they are already attached to another Scan Configuration.
|
|
</>
|
|
)}
|
|
</p>
|
|
{selectableProviders.length === 0 ? (
|
|
<p className="text-default-500 text-tiny italic">
|
|
{richProviders.length === 0
|
|
? "No providers available in this tenant."
|
|
: "All providers are already attached to other Scan Configurations."}
|
|
</p>
|
|
) : (
|
|
<AccountsSelector
|
|
providers={selectableProviders}
|
|
onBatchChange={(_filterKey, values) =>
|
|
form.setValue("provider_ids", values, { shouldValidate: true })
|
|
}
|
|
selectedValues={selectedProviders}
|
|
search={{
|
|
placeholder: "Search providers...",
|
|
emptyMessage: "No providers found.",
|
|
}}
|
|
/>
|
|
)}
|
|
{providersError && <FieldError>{providersError}</FieldError>}
|
|
</Field>
|
|
|
|
<div className="flex w-full justify-end gap-3">
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="lg"
|
|
onClick={() => onClose(false)}
|
|
disabled={isSubmitting}
|
|
>
|
|
Cancel
|
|
</Button>
|
|
<Button type="submit" size="lg" disabled={isSubmitting}>
|
|
{isSubmitting ? "Saving..." : isEdit ? "Update" : "Save"}
|
|
</Button>
|
|
</div>
|
|
</form>
|
|
);
|
|
}
|
|
|
|
export function ScanConfigurationEditor({
|
|
open,
|
|
onClose,
|
|
richProviders,
|
|
existingConfigs,
|
|
config,
|
|
schema,
|
|
}: ScanConfigurationEditorProps) {
|
|
const isEdit = !!config;
|
|
|
|
return (
|
|
<Modal
|
|
open={open}
|
|
onOpenChange={(o) => {
|
|
if (!o) onClose(false);
|
|
}}
|
|
title={isEdit ? "Edit Scan Configuration" : "New Scan Configuration"}
|
|
size="2xl"
|
|
>
|
|
<ScanConfigurationForm
|
|
key={config?.id ?? "new"}
|
|
onClose={onClose}
|
|
richProviders={richProviders}
|
|
existingConfigs={existingConfigs}
|
|
config={config}
|
|
schema={schema}
|
|
/>
|
|
</Modal>
|
|
);
|
|
}
|