"use client"; import { zodResolver } from "@hookform/resolvers/zod"; import { CloudCog, Loader2, Rocket } from "lucide-react"; import Link from "next/link"; import { useRouter, useSearchParams } from "next/navigation"; import { useRef, useState } from "react"; import { useForm, useWatch } from "react-hook-form"; import { z } from "zod"; import { scanOnDemand } from "@/actions/scans"; import { getSchedule } from "@/actions/schedules"; import { AccountsSelector } from "@/app/(prowler)/_overview/_components/accounts-selector"; import { Badge, Field, FieldError, FieldLabel, Input, } from "@/components/shadcn"; import { FormButtons } from "@/components/shadcn/form"; import { Modal } from "@/components/shadcn/modal"; import { RadioGroup, RadioGroupItem, } from "@/components/shadcn/radio-group/radio-group"; import { toast, ToastAction } from "@/components/shadcn/toast"; import { UsageLimitMessage } from "@/components/shared/usage-limit-message"; import { getActionErrorMessage, hasActionError } from "@/lib/action-errors"; import { buildScheduleAttributesFromProvider, getScanScheduleCapability, getScheduleFormDefaults, getScheduleFormValues, scheduleFormSchema, } from "@/lib/schedules"; import { isCloud } from "@/lib/shared/env"; import { SCAN_JOBS_TAB } from "@/types"; import type { ProviderProps } from "@/types/providers"; import { SCAN_SCHEDULE_CAPABILITY, type ScanScheduleCapability, type ScheduleApiResponse, type ScheduleFormValues, } from "@/types/schedules"; import { scanAliasSchema } from "./scan-alias-validation"; import { getScanJobsTab } from "./scans.utils"; import { SAVE_SCHEDULE_STATUS, saveScheduleWithInitialScan, } from "./schedule/save-schedule"; import { ScanScheduleFields } from "./schedule/scan-schedule-fields"; const launchScanSchema = z.object({ providerId: z.string().min(1, "Select a provider to launch a scan."), scanAlias: scanAliasSchema.optional(), }); type LaunchScanFormValues = z.infer; const LAUNCH_MODE = { NOW: "now", SCHEDULE: "schedule", } as const; type LaunchMode = (typeof LAUNCH_MODE)[keyof typeof LAUNCH_MODE]; const SCHEDULE_LOAD_STATE = { IDLE: "idle", LOADING: "loading", LOADED: "loaded", ERROR: "error", } as const; type ScheduleLoadState = (typeof SCHEDULE_LOAD_STATE)[keyof typeof SCHEDULE_LOAD_STATE]; interface LaunchScanModalProps { open: boolean; onOpenChange: (open: boolean) => void; providers: ProviderProps[]; /** Cloud overlay seam; defaults to the environment-resolved capability. */ capability?: ScanScheduleCapability; isScanLimitReached?: boolean; } interface LaunchScanFormProps { providers: ProviderProps[]; onClose: () => void; capability: ScanScheduleCapability; isScanLimitReached: boolean; } function LaunchScanForm({ providers, onClose, capability, isScanLimitReached, }: LaunchScanFormProps) { const router = useRouter(); const searchParams = useSearchParams(); const form = useForm({ resolver: zodResolver(launchScanSchema), defaultValues: { providerId: "", scanAlias: "" }, }); const scheduleForm = useForm({ resolver: zodResolver(scheduleFormSchema), defaultValues: getScheduleFormDefaults(), }); const [mode, setMode] = useState(LAUNCH_MODE.NOW); const [scheduleLoad, setScheduleLoad] = useState( SCHEDULE_LOAD_STATE.IDLE, ); // Guards against out-of-order responses when switching providers quickly. const requestedProviderRef = useRef(""); const isAdvanced = capability === SCAN_SCHEDULE_CAPABILITY.ADVANCED; const isDailyLegacy = capability === SCAN_SCHEDULE_CAPABILITY.DAILY_LEGACY; const canUseScheduleMode = isAdvanced || isDailyLegacy; const isManualOnly = capability === SCAN_SCHEDULE_CAPABILITY.MANUAL_ONLY; const isBlocked = capability === SCAN_SCHEDULE_CAPABILITY.BLOCKED || (isManualOnly && isScanLimitReached); const isScheduleMode = canUseScheduleMode && mode === LAUNCH_MODE.SCHEDULE; // useWatch, not form.watch: form.watch re-renders are dropped by React Compiler memoization. const providerId = useWatch({ control: form.control, name: "providerId" }); const activeTab = getScanJobsTab(searchParams.get("tab") ?? undefined); const shouldShowActiveTabAction = activeTab !== SCAN_JOBS_TAB.ACTIVE; const disconnectedProviderIds = providers .filter((provider) => provider.attributes.connection.connected !== true) .map((provider) => provider.id); const getProviderScheduleAttributes = (id: string) => { const selectedProvider = providers.find((provider) => provider.id === id); return selectedProvider ? buildScheduleAttributesFromProvider(selectedProvider.attributes) : undefined; }; const loadSchedule = async (id: string) => { requestedProviderRef.current = id; if (!id) { setScheduleLoad(SCHEDULE_LOAD_STATE.IDLE); return; } const providerScheduleAttributes = getProviderScheduleAttributes(id); if (providerScheduleAttributes) { scheduleForm.reset(getScheduleFormValues(providerScheduleAttributes)); setScheduleLoad(SCHEDULE_LOAD_STATE.LOADED); return; } setScheduleLoad(SCHEDULE_LOAD_STATE.LOADING); const response = (await getSchedule(id)) as | ScheduleApiResponse | { error?: string }; if (requestedProviderRef.current !== id) return; if (!response || ("error" in response && response.error)) { setScheduleLoad(SCHEDULE_LOAD_STATE.ERROR); return; } scheduleForm.reset( getScheduleFormValues( "data" in response ? response.data?.attributes : null, ), ); setScheduleLoad(SCHEDULE_LOAD_STATE.LOADED); }; const handleProviderChange = (id: string) => { form.setValue("providerId", id, { shouldValidate: true }); if (isScheduleMode && isAdvanced) void loadSchedule(id); }; const handleModeChange = (nextMode: string) => { if (nextMode === LAUNCH_MODE.SCHEDULE && !canUseScheduleMode) return; setMode(nextMode as LaunchMode); if (nextMode === LAUNCH_MODE.SCHEDULE && isAdvanced) { void loadSchedule(providerId); } }; const launchNow = form.handleSubmit(async ({ providerId, scanAlias }) => { if (isBlocked) return; const formData = new FormData(); formData.set("providerId", providerId); const trimmedAlias = scanAlias?.trim(); if (trimmedAlias) { formData.set("scanName", trimmedAlias); } const result = await scanOnDemand(formData); if (hasActionError(result)) { form.setError("root", { message: getActionErrorMessage(result) }); return; } if (result?.errors && result.errors.length > 0) { form.setError("root", { message: String(result.errors[0]?.detail ?? "Failed to launch scan."), }); return; } toast({ title: "Scan launched", description: "The scan was launched successfully.", action: shouldShowActiveTabAction ? ( View scan ) : undefined, }); onClose(); router.refresh(); }); const saveSchedule = async () => { if (isBlocked || !canUseScheduleMode) return; const providerValid = await form.trigger("providerId"); if (!providerValid) return; await scheduleForm.handleSubmit(async (values) => { const result = await saveScheduleWithInitialScan({ providerId: form.getValues("providerId"), values, useLegacyDaily: isDailyLegacy, }); if (result.status === SAVE_SCHEDULE_STATUS.ERROR) { form.setError("root", { message: result.message }); return; } const launched = result.status === SAVE_SCHEDULE_STATUS.SAVED_AND_LAUNCHED; toast({ title: launched ? "Scan schedule saved and initial scan launched" : "Scan schedule saved", description: result.status === SAVE_SCHEDULE_STATUS.SAVED_SCAN_FAILED ? `The schedule was saved, but the initial scan could not be launched: ${result.message}` : launched ? "The schedule was saved and the initial scan was launched." : "The scan schedule was saved successfully.", action: ( View schedule ), }); onClose(); router.refresh(); })(); }; const onSubmit = (event: React.FormEvent) => { event.preventDefault(); if (isBlocked) return; if (isScheduleMode) { void saveSchedule(); return; } void launchNow(); }; const providerError = form.formState.errors.providerId?.message; const aliasError = form.formState.errors.scanAlias?.message; const rootError = form.formState.errors.root?.message; const isSubmitting = form.formState.isSubmitting || scheduleForm.formState.isSubmitting; const isScheduleLoading = scheduleLoad === SCHEDULE_LOAD_STATE.LOADING; return ( // min-w-0: let this dialog grid item shrink so a long provider UID truncates instead of widening the modal
Select the provider you would like to scan
Providers handleProviderChange(values.at(-1) ?? "") } selectedValues={providerId ? [providerId] : []} closeOnSelect placeholder="Select a Provider" emptySelectionLabel="No provider selected" clearSelectionLabel="Clear provider selection" /> {providerError && {providerError}} {!isManualOnly && !isBlocked && ( Mode )} {!isScheduleMode && ( Alias (optional) {aliasError && {aliasError}} )} {isBlocked && } {isScheduleMode && isScheduleLoading && (
Loading scan schedule...
)} {isScheduleMode && scheduleLoad === SCHEDULE_LOAD_STATE.ERROR && ( Failed to load the current scan schedule. Saving will overwrite it. )} {isScheduleMode && !isScheduleLoading && ( )} {rootError && {rootError}} } /> ); } export function LaunchScanModal({ open, onOpenChange, providers, capability, isScanLimitReached = false, }: LaunchScanModalProps) { const resolvedCapability = capability ?? getScanScheduleCapability(isCloud()); return ( onOpenChange(false)} capability={resolvedCapability} isScanLimitReached={isScanLimitReached} /> ); }