feat(ui): per-provider scan schedule management gated by capability (#11521)

This commit is contained in:
Alejandro Bailo
2026-06-18 15:47:03 +02:00
committed by GitHub
parent 853610bbbf
commit 908d2ce766
63 changed files with 5539 additions and 953 deletions
@@ -1,21 +1,42 @@
"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, scheduleDaily } from "@/actions/scans";
import { scanOnDemand } from "@/actions/scans";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/shadcn/select/select";
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 {
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 {
@@ -23,51 +44,94 @@ import {
WizardFooterConfig,
} from "./footer-controls";
const SCAN_SCHEDULE = {
DAILY: "daily",
SINGLE: "single",
const LAUNCH_MODE = {
NOW: "now",
SCHEDULE: "schedule",
} as const;
type ScanScheduleOption = (typeof SCAN_SCHEDULE)[keyof typeof SCAN_SCHEDULE];
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;
}
export function LaunchStep({
onBack,
onClose,
onFooterChange,
capability: capabilityProp,
isScanLimitReached = false,
}: LaunchStepProps) {
const { toast } = useToast();
const { providerId } = useProviderWizardStore();
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 [isLaunching, setIsLaunching] = useState(false);
const [scheduleOption, setScheduleOption] = useState<ScanScheduleOption>(
SCAN_SCHEDULE.DAILY,
const [mode, setMode] = useState<LaunchMode>(
isAdvanced ? LAUNCH_MODE.SCHEDULE : LAUNCH_MODE.NOW,
);
const launchActionRef = useRef<() => void>(() => {});
const form = useForm<ScheduleFormValues>({
resolver: zodResolver(scheduleFormSchema),
defaultValues: getScheduleFormDefaults(),
});
const handleLaunchScan = async () => {
if (!providerId) {
const isScheduleMode = isAdvanced && mode === LAUNCH_MODE.SCHEDULE;
const isLimitBlocked = mode === LAUNCH_MODE.NOW && isScanLimitReached;
const isActionBlocked = isLaunching || !providerId || 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";
})();
const launchOnDemandScan = async (): Promise<{ error?: unknown } | null> => {
if (!providerId) return null;
const formData = new FormData();
formData.set("providerId", providerId);
return scanOnDemand(formData);
};
const handleManualScan = async () => {
if (isScanLimitReached) {
return;
}
setIsLaunching(true);
const formData = new FormData();
formData.set("providerId", providerId);
const result =
scheduleOption === SCAN_SCHEDULE.DAILY
? await scheduleDaily(formData)
: await scanOnDemand(formData);
const scanResult = await launchOnDemandScan();
if (result?.error) {
if (scanResult?.error) {
setIsLaunching(false);
toast({
variant: "destructive",
title: "Unable to launch scan",
description: String(result.error),
description: String(scanResult.error),
});
return;
}
@@ -75,11 +139,8 @@ export function LaunchStep({
setIsLaunching(false);
onClose();
toast({
title: "Scan Launched",
description:
scheduleOption === SCAN_SCHEDULE.DAILY
? "Daily scan scheduled successfully."
: "Single scan launched successfully.",
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>
@@ -88,8 +149,69 @@ export function LaunchStep({
});
};
launchActionRef.current = () => {
void handleLaunchScan();
const handleSaveSchedule = form.handleSubmit(async (values) => {
if (!providerId) {
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 (!isScheduleMode) {
void handleManualScan();
return;
}
void handleSaveSchedule();
};
useEffect(() => {
@@ -99,21 +221,30 @@ export function LaunchStep({
backDisabled: isLaunching,
onBack,
showAction: true,
actionLabel: isLaunching ? "Launching scans..." : "Launch scan",
actionDisabled: isLaunching || !providerId,
actionLabel,
actionDisabled: isActionBlocked,
actionType: WIZARD_FOOTER_ACTION_TYPE.BUTTON,
onAction: () => {
launchActionRef.current();
},
onAction: () => actionRef.current(),
});
}, [isLaunching, onBack, onFooterChange, providerId]);
}, [
isActionBlocked,
isLaunching,
actionLabel,
isScheduleMode,
launchInitialScan,
mode,
onBack,
onFooterChange,
]);
if (isLaunching) {
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">Launching scans...</p>
<p className="text-sm font-medium">
{!isScheduleMode ? "Launching scan..." : "Saving scan schedule..."}
</p>
</div>
</div>
);
@@ -121,13 +252,21 @@ export function LaunchStep({
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">Connection validated!</h3>
<h3 className="text-sm font-semibold">Account Connected!</h3>
</div>
<p className="text-text-neutral-secondary text-sm">
Choose how you want to launch scans for this provider.
Your account is connected to Prowler and ready to Scan!
</p>
{!providerId && (
@@ -136,28 +275,57 @@ export function LaunchStep({
</p>
)}
<div className="flex flex-col gap-4">
<p className="text-text-neutral-secondary text-sm">Scan schedule</p>
<Select
value={scheduleOption}
onValueChange={(value) =>
setScheduleOption(value as ScanScheduleOption)
}
disabled={isLaunching || !providerId}
<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"
>
<SelectTrigger className="w-full max-w-[376px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={SCAN_SCHEDULE.DAILY}>
Scan Daily (every 24 hours)
</SelectItem>
<SelectItem value={SCAN_SCHEDULE.SINGLE}>
Run a single scan (no recurring schedule)
</SelectItem>
</SelectContent>
</Select>
</div>
<label className="flex items-center gap-2 text-sm">
<RadioGroupItem value={LAUNCH_MODE.NOW} aria-label="Run now" />
Run now
</label>
<label className="flex items-center gap-2 text-sm">
<RadioGroupItem
value={LAUNCH_MODE.SCHEDULE}
aria-label="On a schedule"
disabled={!isAdvanced}
/>
On a schedule
{!isAdvanced &&
(isManualOnly ? (
<CloudFeatureBadge label="Requires subscription" size="sm" />
) : (
<CloudFeatureBadgeLink size="sm" />
))}
</label>
</RadioGroup>
</Field>
{!isAdvanced && (
<p className="text-text-neutral-secondary text-sm">
Scheduled scans are not available for this account. Run now to get
immediate findings.
</p>
)}
{isLimitBlocked && (
<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
/>
)}
</div>
);
}