mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-07-04 19:21:51 +00:00
2c3980d4eb
Co-authored-by: Alejandro Bailo <59607668+alejandrobailo@users.noreply.github.com>
373 lines
11 KiB
TypeScript
373 lines
11 KiB
TypeScript
"use client";
|
|
|
|
import { zodResolver } from "@hookform/resolvers/zod";
|
|
import Link from "next/link";
|
|
import { useEffect, useRef, useState } from "react";
|
|
import { useForm, useWatch } from "react-hook-form";
|
|
|
|
import { scanOnDemand } from "@/actions/scans";
|
|
import {
|
|
SAVE_SCHEDULE_STATUS,
|
|
saveScheduleWithInitialScan,
|
|
} from "@/components/scans/schedule/save-schedule";
|
|
import { ScanScheduleFields } from "@/components/scans/schedule/scan-schedule-fields";
|
|
import { Field, FieldLabel } from "@/components/shadcn";
|
|
import {
|
|
RadioGroup,
|
|
RadioGroupItem,
|
|
} from "@/components/shadcn/radio-group/radio-group";
|
|
import { Spinner } from "@/components/shadcn/spinner/spinner";
|
|
import { TreeStatusIcon } from "@/components/shadcn/tree-view/tree-status-icon";
|
|
import {
|
|
CloudFeatureBadge,
|
|
CloudFeatureBadgeLink,
|
|
} from "@/components/shared/cloud-feature-badge";
|
|
import { ToastAction, useToast } from "@/components/ui";
|
|
import { EntityInfo } from "@/components/ui/entities";
|
|
import {
|
|
type ActionErrorResult,
|
|
getActionErrorMessage,
|
|
hasActionError,
|
|
} from "@/lib/action-errors";
|
|
import {
|
|
getScanScheduleCapability,
|
|
getScheduleFormDefaults,
|
|
scheduleFormSchema,
|
|
} from "@/lib/schedules";
|
|
import { isCloud } from "@/lib/shared/env";
|
|
import { useProviderWizardStore } from "@/store/provider-wizard/store";
|
|
import { SCAN_JOBS_TAB } from "@/types";
|
|
import {
|
|
SCAN_SCHEDULE_CAPABILITY,
|
|
type ScanScheduleCapability,
|
|
type ScheduleFormValues,
|
|
} from "@/types/schedules";
|
|
import { TREE_ITEM_STATUS } from "@/types/tree";
|
|
|
|
import {
|
|
WIZARD_FOOTER_ACTION_TYPE,
|
|
WizardFooterConfig,
|
|
} from "./footer-controls";
|
|
|
|
const LAUNCH_MODE = {
|
|
NOW: "now",
|
|
SCHEDULE: "schedule",
|
|
} as const;
|
|
|
|
type LaunchMode = (typeof LAUNCH_MODE)[keyof typeof LAUNCH_MODE];
|
|
|
|
interface LaunchStepProps {
|
|
onBack: () => void;
|
|
onClose: () => void;
|
|
onFooterChange: (config: WizardFooterConfig) => void;
|
|
/**
|
|
* Schedule capability override. Absent in Prowler OSS (defaults to a
|
|
* Cloud-vs-non-Cloud decision). The prowler-cloud overlay computes a
|
|
* billing-aware capability and injects it here so trial/onboarding accounts
|
|
* are limited to manual scans.
|
|
*/
|
|
capability?: ScanScheduleCapability;
|
|
/**
|
|
* When true, the manual scan action is disabled (account scan quota reached).
|
|
* Cloud-only signal; never set in OSS.
|
|
*/
|
|
isScanLimitReached?: boolean;
|
|
/**
|
|
* Cloud-only loading state while billing is resolved into a schedule
|
|
* capability. OSS leaves it false.
|
|
*/
|
|
isScheduleCapabilityLoading?: boolean;
|
|
}
|
|
|
|
export function LaunchStep({
|
|
onBack,
|
|
onClose,
|
|
onFooterChange,
|
|
capability: capabilityProp,
|
|
isScanLimitReached = false,
|
|
isScheduleCapabilityLoading = false,
|
|
}: LaunchStepProps) {
|
|
const { toast } = useToast();
|
|
const { providerAlias, providerId, providerType, providerUid } =
|
|
useProviderWizardStore();
|
|
const capability = capabilityProp ?? getScanScheduleCapability(isCloud());
|
|
const isManualOnly = capability === SCAN_SCHEDULE_CAPABILITY.MANUAL_ONLY;
|
|
const isAdvanced = capability === SCAN_SCHEDULE_CAPABILITY.ADVANCED;
|
|
const isDailyLegacy = capability === SCAN_SCHEDULE_CAPABILITY.DAILY_LEGACY;
|
|
const isBlocked = capability === SCAN_SCHEDULE_CAPABILITY.BLOCKED;
|
|
const canUseScheduleMode = isAdvanced || isDailyLegacy;
|
|
const [isLaunching, setIsLaunching] = useState(false);
|
|
const [mode, setMode] = useState<LaunchMode>(
|
|
canUseScheduleMode ? LAUNCH_MODE.SCHEDULE : LAUNCH_MODE.NOW,
|
|
);
|
|
const form = useForm<ScheduleFormValues>({
|
|
resolver: zodResolver(scheduleFormSchema),
|
|
defaultValues: getScheduleFormDefaults(),
|
|
});
|
|
|
|
const isScheduleMode = canUseScheduleMode && mode === LAUNCH_MODE.SCHEDULE;
|
|
const isLimitBlocked = mode === LAUNCH_MODE.NOW && isScanLimitReached;
|
|
const isActionBlocked =
|
|
isLaunching ||
|
|
isScheduleCapabilityLoading ||
|
|
!providerId ||
|
|
isBlocked ||
|
|
isLimitBlocked;
|
|
const launchInitialScan = useWatch({
|
|
control: form.control,
|
|
name: "launchInitialScan",
|
|
});
|
|
|
|
const actionLabel = (() => {
|
|
if (!isScheduleMode) {
|
|
return isLaunching ? "Launching scan..." : "Launch scan";
|
|
}
|
|
|
|
if (isLaunching) {
|
|
return launchInitialScan ? "Saving and launching..." : "Saving...";
|
|
}
|
|
|
|
return launchInitialScan ? "Save and launch scan" : "Save";
|
|
})();
|
|
|
|
useEffect(() => {
|
|
if (!canUseScheduleMode && mode !== LAUNCH_MODE.NOW) {
|
|
setMode(LAUNCH_MODE.NOW);
|
|
}
|
|
}, [canUseScheduleMode, mode]);
|
|
|
|
const launchOnDemandScan = async (): Promise<ActionErrorResult | null> => {
|
|
if (!providerId || isBlocked) return null;
|
|
const formData = new FormData();
|
|
formData.set("providerId", providerId);
|
|
return scanOnDemand(formData);
|
|
};
|
|
|
|
const handleManualScan = async () => {
|
|
if (isActionBlocked) {
|
|
return;
|
|
}
|
|
|
|
setIsLaunching(true);
|
|
const scanResult = await launchOnDemandScan();
|
|
|
|
if (hasActionError(scanResult)) {
|
|
setIsLaunching(false);
|
|
toast({
|
|
variant: "destructive",
|
|
title: "Unable to launch scan",
|
|
description: getActionErrorMessage(scanResult),
|
|
});
|
|
return;
|
|
}
|
|
|
|
setIsLaunching(false);
|
|
onClose();
|
|
toast({
|
|
title: "Scan launched",
|
|
description: "The scan was launched successfully.",
|
|
action: (
|
|
<ToastAction altText="Go to scans" asChild>
|
|
<Link href={`/scans?tab=${SCAN_JOBS_TAB.ACTIVE}`}>Go to scans</Link>
|
|
</ToastAction>
|
|
),
|
|
});
|
|
};
|
|
|
|
const handleSaveSchedule = form.handleSubmit(async (values) => {
|
|
if (!providerId || isBlocked || isScheduleCapabilityLoading) {
|
|
return;
|
|
}
|
|
|
|
setIsLaunching(true);
|
|
|
|
const result = await saveScheduleWithInitialScan({
|
|
providerId,
|
|
values,
|
|
useLegacyDaily: !isAdvanced,
|
|
});
|
|
|
|
setIsLaunching(false);
|
|
|
|
if (result.status === SAVE_SCHEDULE_STATUS.ERROR) {
|
|
toast({
|
|
variant: "destructive",
|
|
title: "Unable to save scan schedule",
|
|
description: result.message,
|
|
});
|
|
return;
|
|
}
|
|
|
|
onClose();
|
|
|
|
const launched = result.status === SAVE_SCHEDULE_STATUS.SAVED_AND_LAUNCHED;
|
|
const targetTab = launched ? SCAN_JOBS_TAB.ACTIVE : SCAN_JOBS_TAB.SCHEDULED;
|
|
const goToScans = (
|
|
<ToastAction altText="Go to scans" asChild>
|
|
<Link href={`/scans?tab=${targetTab}`}>Go to scans</Link>
|
|
</ToastAction>
|
|
);
|
|
|
|
if (result.status === SAVE_SCHEDULE_STATUS.SAVED_SCAN_FAILED) {
|
|
toast({
|
|
title: "Scan schedule saved",
|
|
description: `The schedule was saved, but the initial scan could not be launched: ${result.message}`,
|
|
action: goToScans,
|
|
});
|
|
return;
|
|
}
|
|
|
|
toast({
|
|
title: launched
|
|
? "Scan schedule saved and initial scan launched"
|
|
: "Scan schedule saved",
|
|
description: launched
|
|
? "The schedule was saved and the initial scan was launched."
|
|
: "The scan schedule was saved successfully.",
|
|
action: goToScans,
|
|
});
|
|
});
|
|
|
|
// Keep the latest action handler in a ref so the footer (synced via effect)
|
|
// always invokes the current closure without re-running on every render.
|
|
const actionRef = useRef<() => void>(() => {});
|
|
actionRef.current = () => {
|
|
if (isBlocked || isScheduleCapabilityLoading) {
|
|
return;
|
|
}
|
|
|
|
if (!isScheduleMode) {
|
|
void handleManualScan();
|
|
return;
|
|
}
|
|
void handleSaveSchedule();
|
|
};
|
|
|
|
useEffect(() => {
|
|
onFooterChange({
|
|
showBack: true,
|
|
backLabel: "Back",
|
|
backDisabled: isLaunching || isScheduleCapabilityLoading,
|
|
onBack,
|
|
showAction: true,
|
|
actionLabel,
|
|
actionDisabled: isActionBlocked,
|
|
actionType: WIZARD_FOOTER_ACTION_TYPE.BUTTON,
|
|
onAction: () => actionRef.current(),
|
|
});
|
|
}, [
|
|
isActionBlocked,
|
|
isLaunching,
|
|
isScheduleCapabilityLoading,
|
|
actionLabel,
|
|
isScheduleMode,
|
|
launchInitialScan,
|
|
mode,
|
|
onBack,
|
|
onFooterChange,
|
|
]);
|
|
|
|
if (isLaunching || isScheduleCapabilityLoading) {
|
|
return (
|
|
<div className="flex min-h-[320px] items-center justify-center">
|
|
<div className="flex items-center gap-3 py-2">
|
|
<Spinner className="size-6" />
|
|
<p className="text-sm font-medium">
|
|
{isScheduleCapabilityLoading
|
|
? "Loading scan options..."
|
|
: !isScheduleMode
|
|
? "Launching scan..."
|
|
: "Saving scan schedule..."}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="flex min-h-0 flex-1 flex-col gap-6">
|
|
{(providerId || providerUid) && (
|
|
<EntityInfo
|
|
cloudProvider={providerType ?? undefined}
|
|
entityAlias={providerAlias ?? providerUid ?? providerId ?? undefined}
|
|
entityId={providerUid ?? providerId ?? undefined}
|
|
/>
|
|
)}
|
|
|
|
<div className="flex items-center gap-3">
|
|
<TreeStatusIcon status={TREE_ITEM_STATUS.SUCCESS} className="size-6" />
|
|
<h3 className="text-sm font-semibold">Account Connected!</h3>
|
|
</div>
|
|
|
|
<p className="text-text-neutral-secondary text-sm">
|
|
Your account is connected to Prowler and ready to Scan!
|
|
</p>
|
|
|
|
{!providerId && (
|
|
<p className="text-text-error-primary text-sm">
|
|
Provider data is missing. Go back and test the connection again.
|
|
</p>
|
|
)}
|
|
|
|
<Field>
|
|
<FieldLabel>Mode</FieldLabel>
|
|
<RadioGroup
|
|
value={mode}
|
|
onValueChange={(value) => setMode(value as LaunchMode)}
|
|
className="flex flex-row flex-wrap gap-6"
|
|
aria-label="Scan mode"
|
|
>
|
|
<label className="flex items-center gap-2 text-sm">
|
|
<RadioGroupItem
|
|
value={LAUNCH_MODE.NOW}
|
|
aria-label="Run now"
|
|
disabled={isBlocked}
|
|
/>
|
|
Run now
|
|
</label>
|
|
<label className="flex items-center gap-2 text-sm">
|
|
<RadioGroupItem
|
|
value={LAUNCH_MODE.SCHEDULE}
|
|
aria-label="On a schedule"
|
|
disabled={!canUseScheduleMode}
|
|
/>
|
|
On a schedule
|
|
{!canUseScheduleMode &&
|
|
!isBlocked &&
|
|
(isManualOnly ? (
|
|
<CloudFeatureBadge label="Requires subscription" size="sm" />
|
|
) : (
|
|
<CloudFeatureBadgeLink size="sm" />
|
|
))}
|
|
</label>
|
|
</RadioGroup>
|
|
</Field>
|
|
|
|
{isManualOnly && !isBlocked && (
|
|
<p className="text-text-neutral-secondary text-sm">
|
|
Scheduled scans are not available for this account. Run now to get
|
|
immediate findings.
|
|
</p>
|
|
)}
|
|
|
|
{(isLimitBlocked || isBlocked) && (
|
|
<p className="text-text-error-primary text-sm">
|
|
You have reached your scan limit, so additional scans are not
|
|
available right now.
|
|
</p>
|
|
)}
|
|
|
|
{isScheduleMode && (
|
|
<ScanScheduleFields
|
|
form={form}
|
|
disabled={isLaunching || !providerId}
|
|
showLaunchInitialScan
|
|
showNextScheduledCopy
|
|
canUseAdvancedSchedule={isAdvanced}
|
|
showCloudUpgradeBadge={isDailyLegacy}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|