+
+
+ {isProviderFlow ? (
+
+ ) : (
+
setCurrentStep(PROVIDER_WIZARD_STEP.TEST)}
- onBack={() => setCurrentStep(PROVIDER_WIZARD_STEP.CONNECT)}
- onFooterChange={setFooterConfig}
- />
- )}
-
- {isProviderFlow && currentStep === PROVIDER_WIZARD_STEP.TEST && (
-
- setCurrentStep(PROVIDER_WIZARD_STEP.CREDENTIALS)
- }
- onFooterChange={setFooterConfig}
- />
- )}
-
- {isProviderFlow &&
- currentStep === PROVIDER_WIZARD_STEP.LAUNCH && (
- setCurrentStep(PROVIDER_WIZARD_STEP.TEST)}
- onClose={handleClose}
- onFooterChange={setFooterConfig}
- />
- )}
-
- {!isProviderFlow && orgCurrentStep === ORG_WIZARD_STEP.SETUP && (
- {
- setOrgCurrentStep(ORG_WIZARD_STEP.VALIDATE);
- }}
- onFooterChange={setFooterConfig}
- onPhaseChange={setOrgSetupPhase}
- initialPhase={orgSetupPhase}
- initialValues={
- orgInitialData
- ? {
- organizationName: orgInitialData.organizationName,
- awsOrgId: orgInitialData.externalId,
- }
- : undefined
- }
- intent={orgInitialData?.intent}
- />
- )}
-
- {!isProviderFlow &&
- orgCurrentStep === ORG_WIZARD_STEP.VALIDATE && (
- {
- setOrgCurrentStep(ORG_WIZARD_STEP.SETUP);
- setOrgSetupPhase(ORG_SETUP_PHASE.ACCESS);
- }}
- onNext={() => {
- setOrgCurrentStep(ORG_WIZARD_STEP.LAUNCH);
- }}
- onSkip={() => {
- setOrgCurrentStep(ORG_WIZARD_STEP.LAUNCH);
- }}
- onFooterChange={setFooterConfig}
- />
- )}
-
- {!isProviderFlow && orgCurrentStep === ORG_WIZARD_STEP.LAUNCH && (
- {
- setOrgCurrentStep(ORG_WIZARD_STEP.VALIDATE);
- }}
- onFooterChange={setFooterConfig}
- />
- )}
-
- {/* Sentinel element for IntersectionObserver scroll detection */}
-
-
-
- {showScrollHint && (
-
-
-
-
- Scroll to see more
-
-
-
+ />
)}
- {/* Inside the highlighted right section so the tour spotlight covers the Next
- button (no longer dimmed by the overlay). Aligned to the form width so Next
- stays at the right edge. */}
- {(resolvedFooterConfig.showBack ||
- resolvedFooterConfig.showSecondaryAction ||
- resolvedFooterConfig.showAction) && (
-
-
-
- {resolvedFooterConfig.showBack && (
-
- {resolvedFooterConfig.backLabel}
-
- )}
-
-
- {resolvedFooterConfig.showSecondaryAction && (
-
- {resolvedFooterConfig.secondaryActionLabel}
-
+
+
+
+
+
+ {isProviderFlow &&
+ currentStep === PROVIDER_WIZARD_STEP.CONNECT && (
+
{
+ setCurrentStep(PROVIDER_WIZARD_STEP.CREDENTIALS);
+ // Reaching credentials is the tour's handoff point: end it so the
+ // user continues on their own. No-op off-onboarding.
+ endActiveTour();
+ }}
+ onSelectOrganizations={openOrganizationsFlow}
+ onFooterChange={setFooterConfig}
+ onProviderTypeChange={(providerType) => {
+ // Picking a type reveals the account-detail inputs. Advance the tour
+ // to its wizard-body step, pinned beside the form. No-op off-onboarding.
+ if (providerType) advanceActiveTour();
+ setProviderTypeHint(providerType);
+ }}
+ />
)}
- {resolvedFooterConfig.showAction && (
- setCurrentStep(PROVIDER_WIZARD_STEP.TEST)}
+ onBack={() =>
+ setCurrentStep(PROVIDER_WIZARD_STEP.CONNECT)
}
- form={resolvedFooterConfig.actionFormId}
- disabled={resolvedFooterConfig.actionDisabled}
- onClick={
- resolvedFooterConfig.actionType ===
- WIZARD_FOOTER_ACTION_TYPE.BUTTON
- ? resolvedFooterConfig.onAction
+ onFooterChange={setFooterConfig}
+ />
+ )}
+
+ {isProviderFlow &&
+ currentStep === PROVIDER_WIZARD_STEP.TEST && (
+
+ setCurrentStep(PROVIDER_WIZARD_STEP.CREDENTIALS)
+ }
+ onFooterChange={setFooterConfig}
+ />
+ )}
+
+ {isProviderFlow &&
+ currentStep === PROVIDER_WIZARD_STEP.LAUNCH && (
+ setCurrentStep(PROVIDER_WIZARD_STEP.TEST)}
+ onClose={handleClose}
+ onFooterChange={setFooterConfig}
+ capability={resolvedScanScheduleCapability}
+ isScanLimitReached={isScanLimitReached}
+ isScheduleCapabilityLoading={isScheduleCapabilityLoading}
+ />
+ )}
+
+ {!isProviderFlow &&
+ orgCurrentStep === ORG_WIZARD_STEP.SETUP && (
+ {
+ setOrgCurrentStep(ORG_WIZARD_STEP.VALIDATE);
+ }}
+ onFooterChange={setFooterConfig}
+ onPhaseChange={setOrgSetupPhase}
+ initialPhase={orgSetupPhase}
+ initialValues={
+ orgInitialData
+ ? {
+ organizationName: orgInitialData.organizationName,
+ awsOrgId: orgInitialData.externalId,
+ }
: undefined
}
- >
- {resolvedFooterConfig.actionLabel}
-
+ intent={orgInitialData?.intent}
+ />
)}
+
+ {!isProviderFlow &&
+ orgCurrentStep === ORG_WIZARD_STEP.VALIDATE && (
+ {
+ setOrgCurrentStep(ORG_WIZARD_STEP.SETUP);
+ setOrgSetupPhase(ORG_SETUP_PHASE.ACCESS);
+ }}
+ onNext={() => {
+ setOrgCurrentStep(ORG_WIZARD_STEP.LAUNCH);
+ }}
+ onSkip={() => {
+ setOrgCurrentStep(ORG_WIZARD_STEP.LAUNCH);
+ }}
+ onFooterChange={setFooterConfig}
+ />
+ )}
+
+ {!isProviderFlow &&
+ orgCurrentStep === ORG_WIZARD_STEP.LAUNCH && (
+ {
+ setOrgCurrentStep(ORG_WIZARD_STEP.VALIDATE);
+ }}
+ onFooterChange={setFooterConfig}
+ capability={resolvedScanScheduleCapability}
+ isScanLimitReached={isScanLimitReached}
+ isScheduleCapabilityLoading={isScheduleCapabilityLoading}
+ />
+ )}
+
+ {/* Sentinel element for IntersectionObserver scroll detection */}
+
+
+
+ {showScrollHint && (
+
+
+
+
+ Scroll to see more
+
+
+ )}
+
+
+
+
+ {(resolvedFooterConfig.showBack ||
+ resolvedFooterConfig.showSecondaryAction ||
+ resolvedFooterConfig.showAction) && (
+
+
+
+ {resolvedFooterConfig.showBack && (
+
+ {resolvedFooterConfig.backLabel}
+
+ )}
+
+
+ {resolvedFooterConfig.showSecondaryAction && (
+
+ {resolvedFooterConfig.secondaryActionLabel}
+
+ )}
+
+ {resolvedFooterConfig.showAction && (
+
+ {resolvedFooterConfig.actionLabel}
+
+ )}
- )}
-
+
+ )}
);
diff --git a/ui/components/providers/wizard/steps/launch-step.test.tsx b/ui/components/providers/wizard/steps/launch-step.test.tsx
index ec6bbfe697..dfcf7da5fb 100644
--- a/ui/components/providers/wizard/steps/launch-step.test.tsx
+++ b/ui/components/providers/wizard/steps/launch-step.test.tsx
@@ -1,21 +1,37 @@
-import { act, render, waitFor } from "@testing-library/react";
+import { act, render, screen, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
import type { ComponentProps } from "react";
-import { beforeEach, describe, expect, it, vi } from "vitest";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import {
+ ACTION_ERROR_API_MESSAGES,
+ ACTION_ERROR_MESSAGES,
+ ACTION_ERROR_STATUS,
+} from "@/lib/action-errors";
import { useProviderWizardStore } from "@/store/provider-wizard/store";
import { SCAN_JOBS_TAB } from "@/types";
+import {
+ SCAN_SCHEDULE_CAPABILITY,
+ SCHEDULE_FREQUENCY,
+} from "@/types/schedules";
import { LaunchStep } from "./launch-step";
-const { scheduleDailyMock, scanOnDemandMock, toastMock } = vi.hoisted(() => ({
- scheduleDailyMock: vi.fn(),
- scanOnDemandMock: vi.fn(),
- toastMock: vi.fn(),
-}));
+const { scanOnDemandMock, scheduleDailyMock, toastMock, updateScheduleMock } =
+ vi.hoisted(() => ({
+ scanOnDemandMock: vi.fn(),
+ scheduleDailyMock: vi.fn(),
+ toastMock: vi.fn(),
+ updateScheduleMock: vi.fn(),
+ }));
vi.mock("@/actions/scans", () => ({
- scheduleDaily: scheduleDailyMock,
scanOnDemand: scanOnDemandMock,
+ scheduleDaily: scheduleDailyMock,
+}));
+
+vi.mock("@/actions/schedules", () => ({
+ updateSchedule: updateScheduleMock,
}));
vi.mock("@/components/shadcn", async (importOriginal) => ({
@@ -28,64 +44,404 @@ vi.mock("@/components/shadcn", async (importOriginal) => ({
}),
}));
+const seedConnectedProvider = () => {
+ useProviderWizardStore.setState({
+ providerId: "provider-1",
+ providerType: "gcp",
+ providerUid: "project-123",
+ mode: "add",
+ });
+};
+
+const lastFooterConfig = (onFooterChange: ReturnType
) =>
+ onFooterChange.mock.calls.at(-1)?.[0];
+
describe("LaunchStep", () => {
beforeEach(() => {
+ vi.spyOn(Intl, "DateTimeFormat").mockReturnValue({
+ resolvedOptions: () => ({ timeZone: "Europe/Madrid" }),
+ } as Intl.DateTimeFormat);
sessionStorage.clear();
localStorage.clear();
+ updateScheduleMock.mockReset();
scheduleDailyMock.mockReset();
scanOnDemandMock.mockReset();
toastMock.mockReset();
useProviderWizardStore.getState().reset();
});
- it("launches a daily scan and shows toast", async () => {
- // Given
- const onClose = vi.fn();
- const onFooterChange = vi.fn();
- useProviderWizardStore.setState({
- providerId: "provider-1",
- providerType: "gcp",
- providerUid: "project-123",
- mode: "add",
+ afterEach(() => {
+ vi.unstubAllEnvs();
+ });
+
+ describe("Prowler OSS (non-Cloud)", () => {
+ beforeEach(() => {
+ vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false");
+ scanOnDemandMock.mockResolvedValue({ data: { id: "scan-1" } });
});
- scheduleDailyMock.mockResolvedValue({ data: { id: "scan-1" } });
+ it("defaults to run now and locks schedule mode outside Cloud", async () => {
+ // Given
+ const onFooterChange = vi.fn();
+ seedConnectedProvider();
- render(
- ,
- );
+ render(
+ ,
+ );
- await waitFor(() => {
- expect(onFooterChange).toHaveBeenCalled();
+ // Then
+ expect(screen.getByText("Account Connected!")).toBeInTheDocument();
+ expect(screen.getByRole("radio", { name: "Run now" })).toBeChecked();
+ expect(
+ screen.getByRole("radio", { name: "On a schedule" }),
+ ).toBeDisabled();
+ expect(
+ screen.queryByRole("combobox", { name: /repeats/i }),
+ ).not.toBeInTheDocument();
+
+ await waitFor(() => expect(onFooterChange).toHaveBeenCalled());
+ expect(lastFooterConfig(onFooterChange)?.actionLabel).toBe("Launch scan");
});
- // When
- const initialFooterConfig = onFooterChange.mock.calls.at(-1)?.[0];
- await act(async () => {
- initialFooterConfig.onAction?.();
+ it("launches only an on-demand scan and never creates a legacy daily schedule", async () => {
+ // Given
+ const onClose = vi.fn();
+ const onFooterChange = vi.fn();
+ seedConnectedProvider();
+
+ render(
+ ,
+ );
+ await waitFor(() => expect(onFooterChange).toHaveBeenCalled());
+
+ // When
+ await act(async () => {
+ lastFooterConfig(onFooterChange)?.onAction?.();
+ });
+
+ // Then
+ await waitFor(() => expect(scanOnDemandMock).toHaveBeenCalledTimes(1));
+ const sentFormData = scanOnDemandMock.mock.calls[0]?.[0] as FormData;
+ expect(sentFormData.get("providerId")).toBe("provider-1");
+ expect(scheduleDailyMock).not.toHaveBeenCalled();
+ expect(updateScheduleMock).not.toHaveBeenCalled();
+ expect(onClose).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe("Prowler Cloud subscribed", () => {
+ beforeEach(() => {
+ vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true");
+ updateScheduleMock.mockResolvedValue({ data: { id: "provider-1" } });
});
- // Then
- await waitFor(() => {
- expect(scheduleDailyMock).toHaveBeenCalledTimes(1);
+ it("defaults to schedule mode and saves through the new schedule API", async () => {
+ // Given
+ const onClose = vi.fn();
+ const onFooterChange = vi.fn();
+ seedConnectedProvider();
+
+ render(
+ ,
+ );
+
+ // Then advanced cadence selector is enabled
+ expect(
+ screen.getByRole("radio", { name: "On a schedule" }),
+ ).toBeChecked();
+ expect(
+ screen.getByRole("combobox", { name: /repeats/i }),
+ ).not.toBeDisabled();
+ await waitFor(() => expect(onFooterChange).toHaveBeenCalled());
+
+ // When
+ await act(async () => {
+ lastFooterConfig(onFooterChange)?.onAction?.();
+ });
+
+ // Then
+ await waitFor(() => expect(updateScheduleMock).toHaveBeenCalledTimes(1));
+ expect(updateScheduleMock).toHaveBeenCalledWith(
+ "provider-1",
+ expect.objectContaining({
+ scan_enabled: true,
+ scan_frequency: SCHEDULE_FREQUENCY.DAILY,
+ scan_hour: expect.any(Number),
+ scan_timezone: "Europe/Madrid",
+ scan_day_of_week: null,
+ scan_day_of_month: null,
+ }),
+ );
+ expect(scheduleDailyMock).not.toHaveBeenCalled();
+ expect(scanOnDemandMock).not.toHaveBeenCalled();
+ expect(onClose).toHaveBeenCalledTimes(1);
+ const toastPayload = toastMock.mock.calls[0]?.[0];
+ expect(toastPayload.action.props.children.props.href).toBe(
+ `/scans?tab=${SCAN_JOBS_TAB.SCHEDULED}`,
+ );
});
- const sentFormData = scheduleDailyMock.mock.calls[0]?.[0] as FormData;
- expect(sentFormData.get("providerId")).toBe("provider-1");
- expect(onClose).toHaveBeenCalledTimes(1);
- expect(scanOnDemandMock).not.toHaveBeenCalled();
- expect(toastMock).toHaveBeenCalledWith(
- expect.objectContaining({
- title: "Scan Launched",
- }),
- );
- const toastPayload = toastMock.mock.calls[0]?.[0];
- expect(toastPayload.action.props.children.props.href).toBe(
- `/scans?tab=${SCAN_JOBS_TAB.ACTIVE}`,
- );
+ it("also launches an on-demand scan when the checkbox is checked", async () => {
+ // Given
+ const user = userEvent.setup();
+ const onFooterChange = vi.fn();
+ seedConnectedProvider();
+ scanOnDemandMock.mockResolvedValue({ data: { id: "scan-1" } });
+
+ render(
+ ,
+ );
+ await waitFor(() => expect(onFooterChange).toHaveBeenCalled());
+
+ // When
+ await user.click(
+ screen.getByRole("checkbox", { name: /launch an initial scan now/i }),
+ );
+ await act(async () => {
+ lastFooterConfig(onFooterChange)?.onAction?.();
+ });
+
+ // Then
+ await waitFor(() => expect(updateScheduleMock).toHaveBeenCalledTimes(1));
+ expect(scanOnDemandMock).toHaveBeenCalledTimes(1);
+ expect(scheduleDailyMock).not.toHaveBeenCalled();
+ const toastPayload = toastMock.mock.calls[0]?.[0];
+ expect(toastPayload.action.props.children.props.href).toBe(
+ `/scans?tab=${SCAN_JOBS_TAB.ACTIVE}`,
+ );
+ });
+
+ it("launches only an on-demand scan when run now is selected", async () => {
+ // Given
+ const user = userEvent.setup();
+ const onClose = vi.fn();
+ const onFooterChange = vi.fn();
+ seedConnectedProvider();
+ scanOnDemandMock.mockResolvedValue({ data: { id: "scan-1" } });
+
+ render(
+ ,
+ );
+ await waitFor(() => expect(onFooterChange).toHaveBeenCalled());
+
+ // When
+ await user.click(
+ screen.getByRole("radio", {
+ name: "Run now",
+ }),
+ );
+ await waitFor(() =>
+ expect(lastFooterConfig(onFooterChange)?.actionLabel).toBe(
+ "Launch scan",
+ ),
+ );
+ await act(async () => {
+ lastFooterConfig(onFooterChange)?.onAction?.();
+ });
+
+ // Then
+ await waitFor(() => expect(scanOnDemandMock).toHaveBeenCalledTimes(1));
+ expect(updateScheduleMock).not.toHaveBeenCalled();
+ expect(scheduleDailyMock).not.toHaveBeenCalled();
+ expect(onClose).toHaveBeenCalledTimes(1);
+ });
+
+ it("does not launch an initial scan when the schedule save fails", async () => {
+ // Given
+ const user = userEvent.setup();
+ const onFooterChange = vi.fn();
+ seedConnectedProvider();
+ updateScheduleMock.mockResolvedValue({ error: "Schedule failed" });
+
+ render(
+ ,
+ );
+ await waitFor(() => expect(onFooterChange).toHaveBeenCalled());
+
+ // When
+ await user.click(
+ screen.getByRole("checkbox", { name: /launch an initial scan now/i }),
+ );
+ await act(async () => {
+ lastFooterConfig(onFooterChange)?.onAction?.();
+ });
+
+ // Then
+ await waitFor(() => expect(updateScheduleMock).toHaveBeenCalledTimes(1));
+ expect(scanOnDemandMock).not.toHaveBeenCalled();
+ expect(toastMock).toHaveBeenCalledWith(
+ expect.objectContaining({ title: "Unable to save scan schedule" }),
+ );
+ });
+
+ it("disables launch actions while schedule capability is loading", async () => {
+ // Given
+ const onFooterChange = vi.fn();
+ seedConnectedProvider();
+
+ render(
+ ,
+ );
+
+ // When
+ await screen.findByText("Loading scan options...");
+ await waitFor(() => expect(onFooterChange).toHaveBeenCalled());
+ await act(async () => {
+ lastFooterConfig(onFooterChange)?.onAction?.();
+ });
+
+ // Then
+ expect(lastFooterConfig(onFooterChange)?.backDisabled).toBe(true);
+ expect(lastFooterConfig(onFooterChange)?.actionDisabled).toBe(true);
+ expect(scanOnDemandMock).not.toHaveBeenCalled();
+ expect(updateScheduleMock).not.toHaveBeenCalled();
+ expect(scheduleDailyMock).not.toHaveBeenCalled();
+ });
+ });
+
+ describe("Prowler Cloud trial/onboarding (manual scan only)", () => {
+ beforeEach(() => {
+ vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true");
+ scanOnDemandMock.mockResolvedValue({ data: { id: "scan-1" } });
+ });
+
+ it("defaults to run now, locks schedule mode, and only launches a manual scan", async () => {
+ // Given
+ const onClose = vi.fn();
+ const onFooterChange = vi.fn();
+ seedConnectedProvider();
+
+ render(
+ ,
+ );
+
+ // Then no schedule cadence selector is rendered
+ expect(
+ screen.queryByRole("combobox", { name: /repeats/i }),
+ ).not.toBeInTheDocument();
+ expect(screen.getByRole("radio", { name: "Run now" })).toBeChecked();
+ expect(
+ screen.getByRole("radio", { name: "On a schedule" }),
+ ).toBeDisabled();
+ expect(
+ screen.getByText(/scheduled scans are not available/i),
+ ).toBeInTheDocument();
+ await waitFor(() => expect(onFooterChange).toHaveBeenCalled());
+ expect(lastFooterConfig(onFooterChange)?.actionDisabled).toBe(false);
+ // The action launches a scan here, so it must not be labeled "Save".
+ expect(lastFooterConfig(onFooterChange)?.actionLabel).toBe("Launch scan");
+
+ // When
+ await act(async () => {
+ lastFooterConfig(onFooterChange)?.onAction?.();
+ });
+
+ // Then
+ await waitFor(() => expect(scanOnDemandMock).toHaveBeenCalledTimes(1));
+ expect(updateScheduleMock).not.toHaveBeenCalled();
+ expect(scheduleDailyMock).not.toHaveBeenCalled();
+ expect(onClose).toHaveBeenCalledTimes(1);
+ });
+
+ it("uses the shared subscription error copy when a manual scan is blocked", async () => {
+ // Given
+ const onClose = vi.fn();
+ const onFooterChange = vi.fn();
+ const rawError =
+ ACTION_ERROR_API_MESSAGES[ACTION_ERROR_STATUS.PAYMENT_REQUIRED];
+ seedConnectedProvider();
+ scanOnDemandMock.mockResolvedValue({
+ error: rawError,
+ status: ACTION_ERROR_STATUS.PAYMENT_REQUIRED,
+ });
+
+ render(
+ ,
+ );
+ await waitFor(() => expect(onFooterChange).toHaveBeenCalled());
+
+ // When
+ await act(async () => {
+ lastFooterConfig(onFooterChange)?.onAction?.();
+ });
+
+ // Then
+ await waitFor(() => expect(scanOnDemandMock).toHaveBeenCalledTimes(1));
+ expect(toastMock).toHaveBeenCalledWith(
+ expect.objectContaining({
+ variant: "destructive",
+ title: "Unable to launch scan",
+ description:
+ ACTION_ERROR_MESSAGES[ACTION_ERROR_STATUS.PAYMENT_REQUIRED],
+ }),
+ );
+ expect(toastMock.mock.calls[0]?.[0].description).not.toContain(rawError);
+ expect(onClose).not.toHaveBeenCalled();
+ });
+
+ it("disables the action and shows the limit copy when over limit", async () => {
+ // Given
+ const onFooterChange = vi.fn();
+ seedConnectedProvider();
+
+ render(
+ ,
+ );
+
+ // Then
+ expect(screen.getByText(/reached your scan limit/i)).toBeInTheDocument();
+ await waitFor(() => expect(onFooterChange).toHaveBeenCalled());
+ expect(lastFooterConfig(onFooterChange)?.actionDisabled).toBe(true);
+ });
});
});
diff --git a/ui/components/providers/wizard/steps/launch-step.tsx b/ui/components/providers/wizard/steps/launch-step.tsx
index 064d281b22..80355bf7d2 100644
--- a/ui/components/providers/wizard/steps/launch-step.tsx
+++ b/ui/components/providers/wizard/steps/launch-step.tsx
@@ -1,21 +1,47 @@
"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 { ToastAction, useToast } from "@/components/shadcn";
+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 { ToastAction, useToast } from "@/components/shadcn";
+import { EntityInfo } from "@/components/shadcn/entities";
+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 {
+ 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 {
@@ -23,51 +49,112 @@ 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;
+ /**
+ * 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 { 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 isBlocked = capability === SCAN_SCHEDULE_CAPABILITY.BLOCKED;
const [isLaunching, setIsLaunching] = useState(false);
- const [scheduleOption, setScheduleOption] = useState(
- SCAN_SCHEDULE.DAILY,
+ const [mode, setMode] = useState(
+ isAdvanced ? LAUNCH_MODE.SCHEDULE : LAUNCH_MODE.NOW,
);
- const launchActionRef = useRef<() => void>(() => {});
+ const form = useForm({
+ 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 ||
+ 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 (!isAdvanced && mode !== LAUNCH_MODE.NOW) {
+ setMode(LAUNCH_MODE.NOW);
+ }
+ }, [isAdvanced, mode]);
+
+ const launchOnDemandScan = async (): Promise => {
+ 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 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 (hasActionError(scanResult)) {
setIsLaunching(false);
toast({
variant: "destructive",
title: "Unable to launch scan",
- description: String(result.error),
+ description: getActionErrorMessage(scanResult),
});
return;
}
@@ -75,11 +162,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: (
Go to scans
@@ -88,32 +172,111 @@ export function LaunchStep({
});
};
- launchActionRef.current = () => {
- void handleLaunchScan();
+ 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 = (
+
+ Go to scans
+
+ );
+
+ 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,
+ backDisabled: isLaunching || isScheduleCapabilityLoading,
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,
+ isScheduleCapabilityLoading,
+ actionLabel,
+ isScheduleMode,
+ launchInitialScan,
+ mode,
+ onBack,
+ onFooterChange,
+ ]);
- if (isLaunching) {
+ if (isLaunching || isScheduleCapabilityLoading) {
return (
-
Launching scans...
+
+ {isScheduleCapabilityLoading
+ ? "Loading scan options..."
+ : !isScheduleMode
+ ? "Launching scan..."
+ : "Saving scan schedule..."}
+
);
@@ -121,13 +284,21 @@ export function LaunchStep({
return (
+ {(providerId || providerUid) && (
+
+ )}
+
-
Connection validated!
+ Account Connected!
- Choose how you want to launch scans for this provider.
+ Your account is connected to Prowler and ready to Scan!
{!providerId && (
@@ -136,28 +307,62 @@ export function LaunchStep({
)}
-
-
Scan schedule
-
- setScheduleOption(value as ScanScheduleOption)
- }
- disabled={isLaunching || !providerId}
+
+ Mode
+ setMode(value as LaunchMode)}
+ className="flex flex-row flex-wrap gap-6"
+ aria-label="Scan mode"
>
-
-