diff --git a/ui/actions/schedules/schedules.test.ts b/ui/actions/schedules/schedules.test.ts
index 9e05799116..d676e12d67 100644
--- a/ui/actions/schedules/schedules.test.ts
+++ b/ui/actions/schedules/schedules.test.ts
@@ -1,6 +1,9 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
-import { SCHEDULE_FREQUENCY } from "@/types/schedules";
+import {
+ SCHEDULE_FREQUENCY,
+ type ScheduleUpdatePayload,
+} from "@/types/schedules";
const {
fetchMock,
@@ -30,9 +33,15 @@ vi.mock("@/lib/server-actions-helper", () => ({
handleApiResponse: handleApiResponseMock,
}));
-import { getSchedule, removeSchedule, updateSchedule } from "./schedules";
+import {
+ getSchedule,
+ removeSchedule,
+ updateSchedule,
+ updateSchedulesBulk,
+} from "./schedules";
const PROVIDER_ID = "1795f636-37e6-42f6-b158-d4faaa64e0fc";
+const SECOND_PROVIDER_ID = "9b7fae7d-5e72-49fe-8b0b-5bff5930db1a";
const payload = {
scan_enabled: true,
@@ -61,6 +70,108 @@ describe("schedule write actions revalidate only on success", () => {
expect(revalidatePathMock).toHaveBeenCalledWith("/providers");
});
+ it("posts the JSON:API bulk schedule payload", async () => {
+ handleApiResponseMock.mockResolvedValue({
+ data: {
+ type: "schedules-bulk",
+ attributes: {
+ updated: [PROVIDER_ID, SECOND_PROVIDER_ID],
+ failed: [],
+ },
+ },
+ });
+
+ await updateSchedulesBulk([PROVIDER_ID, SECOND_PROVIDER_ID], payload);
+
+ expect(fetchMock).toHaveBeenCalledWith(
+ "https://api.example.com/api/v1/schedules/bulk",
+ expect.objectContaining({
+ method: "POST",
+ headers: { Authorization: "Bearer token" },
+ body: JSON.stringify({
+ data: {
+ type: "schedules-bulk",
+ attributes: {
+ schedule: payload,
+ provider_ids: [PROVIDER_ID, SECOND_PROVIDER_ID],
+ },
+ },
+ }),
+ }),
+ );
+ });
+
+ it("rejects invalid bulk provider ids without issuing a request", async () => {
+ expect(
+ await updateSchedulesBulk([PROVIDER_ID, "../users/me"], payload),
+ ).toEqual({
+ error: "Invalid provider ids.",
+ });
+ expect(await updateSchedulesBulk([], payload)).toEqual({
+ error: "Invalid provider ids.",
+ });
+ expect(fetchMock).not.toHaveBeenCalled();
+ });
+
+ it("rejects invalid bulk schedule payloads without issuing a request", async () => {
+ const invalidPayload = {
+ ...payload,
+ scan_hour: "12",
+ } as unknown as ScheduleUpdatePayload;
+
+ expect(
+ await updateSchedulesBulk(
+ [PROVIDER_ID, SECOND_PROVIDER_ID],
+ invalidPayload,
+ ),
+ ).toEqual({
+ error: "Invalid schedule payload.",
+ });
+ expect(getAuthHeadersMock).not.toHaveBeenCalled();
+ expect(fetchMock).not.toHaveBeenCalled();
+ });
+
+ it("normalizes bulk auth header errors", async () => {
+ const authError = new Error("Session expired");
+ getAuthHeadersMock.mockRejectedValue(authError);
+
+ expect(
+ await updateSchedulesBulk([PROVIDER_ID, SECOND_PROVIDER_ID], payload),
+ ).toEqual({ error: "Failed" });
+ expect(handleApiErrorMock).toHaveBeenCalledWith(authError);
+ expect(fetchMock).not.toHaveBeenCalled();
+ });
+
+ it("revalidates /scans and /providers after a partial bulk success", async () => {
+ handleApiResponseMock.mockResolvedValue({
+ data: {
+ type: "schedules-bulk",
+ attributes: {
+ updated: [PROVIDER_ID],
+ failed: [{ provider_id: SECOND_PROVIDER_ID, error: "Denied" }],
+ },
+ },
+ });
+
+ const result = await updateSchedulesBulk(
+ [PROVIDER_ID, SECOND_PROVIDER_ID],
+ payload,
+ );
+
+ expect(result.data?.attributes?.updated).toEqual([PROVIDER_ID]);
+ expect(result.data?.attributes?.failed).toHaveLength(1);
+ expect(revalidatePathMock).toHaveBeenCalledWith("/scans");
+ expect(revalidatePathMock).toHaveBeenCalledWith("/providers");
+ });
+
+ it("does not revalidate when the bulk update returns an error result", async () => {
+ handleApiResponseMock.mockResolvedValue({ error: "Bulk rejected" });
+
+ await updateSchedulesBulk([PROVIDER_ID, SECOND_PROVIDER_ID], payload);
+
+ expect(revalidatePathMock).not.toHaveBeenCalled();
+ });
+
it("does not revalidate when the update returns an error result", async () => {
handleApiResponseMock.mockResolvedValue({ error: "Schedule rejected" });
diff --git a/ui/actions/schedules/schedules.ts b/ui/actions/schedules/schedules.ts
index c8e0d9993f..6b197823eb 100644
--- a/ui/actions/schedules/schedules.ts
+++ b/ui/actions/schedules/schedules.ts
@@ -4,8 +4,13 @@ import { revalidatePath } from "next/cache";
import { z } from "zod";
import { apiBaseUrl, getAuthHeaders } from "@/lib";
+import { scheduleUpdatePayloadSchema } from "@/lib/schedules";
import { handleApiError, handleApiResponse } from "@/lib/server-actions-helper";
-import type { ScheduleProps, ScheduleUpdatePayload } from "@/types/schedules";
+import type {
+ ScheduleProps,
+ SchedulesBulkResponse,
+ ScheduleUpdatePayload,
+} from "@/types/schedules";
// SSRF guard: the id is interpolated into the request URL, so only UUIDs pass.
const providerIdSchema = z.uuid();
@@ -15,6 +20,13 @@ function parseProviderId(providerId: string): string | null {
return parsed.success ? parsed.data : null;
}
+function parseProviderIds(providerIds: string[]): string[] | null {
+ if (providerIds.length === 0) return null;
+
+ const ids = providerIds.map(parseProviderId);
+ return ids.every((id): id is string => id !== null) ? ids : null;
+}
+
function revalidateScheduleViews() {
revalidatePath("/scans");
revalidatePath("/providers");
@@ -104,6 +116,45 @@ export const updateSchedule = async (
}
};
+export const updateSchedulesBulk = async (
+ providerIds: string[],
+ payload: ScheduleUpdatePayload,
+): Promise => {
+ const ids = parseProviderIds(providerIds);
+ if (!ids) return { error: "Invalid provider ids." };
+
+ const parsedPayload = scheduleUpdatePayloadSchema.safeParse(payload);
+ if (!parsedPayload.success) return { error: "Invalid schedule payload." };
+
+ try {
+ const headers = await getAuthHeaders({ contentType: true });
+ const url = new URL(`${apiBaseUrl}/schedules/bulk`);
+ const body = {
+ data: {
+ type: "schedules-bulk",
+ attributes: {
+ schedule: parsedPayload.data,
+ provider_ids: ids,
+ },
+ },
+ };
+
+ const response = await fetch(url.toString(), {
+ method: "POST",
+ headers,
+ body: JSON.stringify(body),
+ });
+
+ const result = (await handleApiResponse(response)) as SchedulesBulkResponse;
+ if (!result?.error) {
+ revalidateScheduleViews();
+ }
+ return result;
+ } catch (error) {
+ return handleApiError(error);
+ }
+};
+
export const removeSchedule = async (providerId: string) => {
const id = parseProviderId(providerId);
if (!id) return { error: "Invalid provider id." };
diff --git a/ui/app/(prowler)/providers/providers-page.utils.test.ts b/ui/app/(prowler)/providers/providers-page.utils.test.ts
index b5dcd6d844..5727807d85 100644
--- a/ui/app/(prowler)/providers/providers-page.utils.test.ts
+++ b/ui/app/(prowler)/providers/providers-page.utils.test.ts
@@ -28,7 +28,10 @@ vi.mock("@/actions/schedules", () => schedulesActionsMock);
import { SearchParamsProps } from "@/types";
import { ProvidersApiResponse } from "@/types/providers";
-import { ProvidersProviderRow } from "@/types/providers-table";
+import {
+ isProvidersOrganizationRow,
+ ProvidersProviderRow,
+} from "@/types/providers-table";
import {
SCHEDULE_FREQUENCY,
type ScheduleAttributes,
@@ -643,13 +646,73 @@ describe("buildProvidersTableRows", () => {
// Then
expect(rows).toHaveLength(1);
- expect(rows[0].rowType).toBe(PROVIDERS_ROW_TYPE.ORGANIZATION);
- expect(rows[0].subRows).toHaveLength(2);
+ const orgRow = rows[0];
+ expect(isProvidersOrganizationRow(orgRow)).toBe(true);
+ if (!isProvidersOrganizationRow(orgRow)) {
+ throw new Error("Expected organization row");
+ }
+ expect(orgRow.subRows).toHaveLength(2);
expect(
- rows[0].subRows?.every(
+ orgRow.subRows?.every(
(row) => row.rowType === PROVIDERS_ROW_TYPE.PROVIDER,
),
).toBe(true);
+ expect(orgRow.providerIds).toEqual(["provider-1", "provider-2"]);
+ });
+
+ it("keeps organization relationship provider ids even when providers are not in the visible page", () => {
+ // Given
+ const providers = [
+ toProviderRow(providersResponse.data[0], {
+ relationships: {
+ ...providersResponse.data[0].relationships,
+ organization: {
+ data: null,
+ },
+ },
+ }),
+ ];
+
+ // When
+ const rows = buildProvidersTableRows({
+ providers,
+ organizations: [
+ {
+ id: "org-1",
+ type: "organizations",
+ attributes: {
+ name: "Large Organization",
+ org_type: "aws",
+ external_id: "o-large",
+ metadata: {},
+ root_external_id: "r-large",
+ },
+ relationships: {
+ providers: {
+ data: [
+ { type: "providers", id: "provider-1" },
+ { type: "providers", id: "provider-not-in-page" },
+ ],
+ },
+ organizational_units: {
+ data: [],
+ },
+ },
+ },
+ ],
+ organizationUnits: [],
+ isCloud: true,
+ });
+
+ // Then
+ expect(rows).toHaveLength(1);
+ const orgRow = rows[0];
+ expect(isProvidersOrganizationRow(orgRow)).toBe(true);
+ if (!isProvidersOrganizationRow(orgRow)) {
+ throw new Error("Expected organization row");
+ }
+ expect(orgRow.subRows).toHaveLength(1);
+ expect(orgRow.providerIds).toEqual(["provider-1", "provider-not-in-page"]);
});
});
diff --git a/ui/app/(prowler)/providers/providers-page.utils.ts b/ui/app/(prowler)/providers/providers-page.utils.ts
index a37d6eb510..32ce2a5321 100644
--- a/ui/app/(prowler)/providers/providers-page.utils.ts
+++ b/ui/app/(prowler)/providers/providers-page.utils.ts
@@ -183,6 +183,7 @@ const createOrganizationRow = ({
externalId,
organizationId,
parentExternalId,
+ providerIds,
subRows,
}: {
externalId: string | null;
@@ -191,6 +192,7 @@ const createOrganizationRow = ({
name: string;
organizationId: string | null;
parentExternalId: string | null;
+ providerIds: string[];
subRows: ProvidersTableRow[];
}): ProvidersOrganizationRow => ({
id,
@@ -200,7 +202,8 @@ const createOrganizationRow = ({
externalId,
organizationId,
parentExternalId,
- providerCount: countProviderRows(subRows),
+ providerCount: providerIds.length,
+ providerIds,
subRows,
});
@@ -234,14 +237,14 @@ function getProviderRowsByIds({
.filter((provider): provider is ProvidersProviderRow => Boolean(provider));
}
-function countProviderRows(rows: ProvidersTableRow[]): number {
- return rows.reduce((total, row) => {
- if (row.rowType === PROVIDERS_ROW_TYPE.PROVIDER) {
- return total + 1;
- }
+function dedupeIds(ids: string[]): string[] {
+ return Array.from(new Set(ids));
+}
- return total + countProviderRows(row.subRows);
- }, 0);
+function collectOrganizationRowProviderIds(
+ rows: ProvidersOrganizationRow[],
+): string[] {
+ return dedupeIds(rows.flatMap((row) => row.providerIds));
}
function getOrganizationUnitRelationshipId(
@@ -308,6 +311,13 @@ function buildOrganizationUnitRows({
? providerRowsFromRelationships
: (providersByOrganizationUnitId.get(organizationUnit.id) ?? []);
const subRows = [...childOrganizationUnitRows, ...providerRows];
+ const directProviderIds =
+ providerRowsFromRelationships.length > 0
+ ? getRelationshipProviderIds(organizationUnit.relationships)
+ : providerRows.map((provider) => provider.id);
+ const childProviderIds = collectOrganizationRowProviderIds(
+ childOrganizationUnitRows,
+ );
return createOrganizationRow({
groupKind: PROVIDERS_GROUP_KIND.ORGANIZATION_UNIT,
@@ -316,10 +326,13 @@ function buildOrganizationUnitRows({
externalId: organizationUnit.attributes.external_id,
organizationId,
parentExternalId: organizationUnit.attributes.parent_external_id,
+ providerIds: dedupeIds([...childProviderIds, ...directProviderIds]),
subRows,
});
})
- .filter((organizationUnitRow) => organizationUnitRow.subRows.length > 0);
+ .filter(
+ (organizationUnitRow) => organizationUnitRow.providerIds.length > 0,
+ );
}
export function buildProvidersTableRows({
@@ -418,6 +431,12 @@ export function buildProvidersTableRows({
(provider) => !providersInOus.has(provider.id),
);
const subRows = [...organizationProviders, ...organizationUnitRows];
+ const directProviderIds =
+ organizationProvidersFromRelationships.length > 0
+ ? getRelationshipProviderIds(organization.relationships)
+ : organizationProviders.map((provider) => provider.id);
+ const organizationUnitProviderIds =
+ collectOrganizationRowProviderIds(organizationUnitRows);
return createOrganizationRow({
groupKind: PROVIDERS_GROUP_KIND.ORGANIZATION,
@@ -426,10 +445,14 @@ export function buildProvidersTableRows({
externalId: organization.attributes.external_id,
organizationId: organization.id,
parentExternalId: organization.attributes.root_external_id,
+ providerIds: dedupeIds([
+ ...directProviderIds,
+ ...organizationUnitProviderIds,
+ ]),
subRows,
});
})
- .filter((organizationRow) => organizationRow.subRows.length > 0);
+ .filter((organizationRow) => organizationRow.providerIds.length > 0);
const assignedProviderIds = new Set();
diff --git a/ui/app/(prowler)/scans/page.tsx b/ui/app/(prowler)/scans/page.tsx
index d945380f93..f50126719e 100644
--- a/ui/app/(prowler)/scans/page.tsx
+++ b/ui/app/(prowler)/scans/page.tsx
@@ -30,6 +30,7 @@ import {
ScanProps,
SearchParamsProps,
} from "@/types";
+import type { ScanScheduleCapability } from "@/types/schedules";
const ACTIVE_SCAN_COUNT_PAGE_SIZE = 1;
// Pending schedule rows are derived from provider schedules, but must honor the
@@ -244,9 +245,11 @@ export default async function Scans({
const SSRDataTableScans = async ({
searchParams,
providers,
+ scanScheduleCapability,
}: {
searchParams: SearchParamsProps;
providers: ProviderProps[];
+ scanScheduleCapability?: ScanScheduleCapability;
}) => {
const tab = getScanJobsTab(searchParams.tab);
@@ -359,6 +362,7 @@ const SSRDataTableScans = async ({
meta={tableMeta}
tab={tab}
hasFilters={hasUserFilters}
+ scanScheduleCapability={scanScheduleCapability}
/>
);
};
diff --git a/ui/components/providers/organizations/org-launch-scan.test.tsx b/ui/components/providers/organizations/org-launch-scan.test.tsx
index f7ef851a0c..9d98cef5af 100644
--- a/ui/components/providers/organizations/org-launch-scan.test.tsx
+++ b/ui/components/providers/organizations/org-launch-scan.test.tsx
@@ -1,22 +1,37 @@
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 { useOrgSetupStore } from "@/store/organizations/store";
-import { SCAN_SCHEDULE_CAPABILITY } from "@/types/schedules";
+import {
+ SCAN_JOBS_TAB,
+ SCAN_SCHEDULE_CAPABILITY,
+ SCHEDULE_FREQUENCY,
+} from "@/types";
import { OrgLaunchScan } from "./org-launch-scan";
-const { launchOrganizationScansMock, pushMock, toastMock } = vi.hoisted(() => ({
+const {
+ launchOrganizationScansMock,
+ pushMock,
+ toastMock,
+ updateSchedulesBulkMock,
+} = vi.hoisted(() => ({
launchOrganizationScansMock: vi.fn(),
pushMock: vi.fn(),
toastMock: vi.fn(),
+ updateSchedulesBulkMock: vi.fn(),
}));
vi.mock("@/actions/scans/scans", () => ({
launchOrganizationScans: launchOrganizationScansMock,
}));
+vi.mock("@/actions/schedules/schedules", () => ({
+ updateSchedulesBulk: updateSchedulesBulkMock,
+}));
+
vi.mock("next/navigation", () => ({
useRouter: () => ({
push: pushMock,
@@ -32,117 +47,381 @@ vi.mock("@/components/ui", () => ({
}),
}));
+const PROVIDER_IDS = ["provider-1", "provider-2"];
+
+const lastFooterConfig = (onFooterChange: ReturnType) =>
+ onFooterChange.mock.calls.at(-1)?.[0];
+
describe("OrgLaunchScan", () => {
beforeEach(() => {
+ vi.spyOn(Intl, "DateTimeFormat").mockReturnValue({
+ resolvedOptions: () => ({ timeZone: "Europe/Madrid" }),
+ } as Intl.DateTimeFormat);
sessionStorage.clear();
localStorage.clear();
launchOrganizationScansMock.mockReset();
pushMock.mockReset();
toastMock.mockReset();
+ updateSchedulesBulkMock.mockReset();
+ launchOrganizationScansMock.mockResolvedValue({
+ successCount: 2,
+ failureCount: 0,
+ totalCount: 2,
+ errors: [],
+ });
+ updateSchedulesBulkMock.mockResolvedValue({
+ data: {
+ type: "schedules-bulk",
+ attributes: {
+ updated: PROVIDER_IDS,
+ failed: [],
+ },
+ },
+ });
useOrgSetupStore.getState().reset();
useOrgSetupStore
.getState()
.setOrganization("org-1", "My Organization", "o-abc123def4");
- useOrgSetupStore.getState().setCreatedProviderIds(["provider-1"]);
+ useOrgSetupStore.getState().setCreatedProviderIds(PROVIDER_IDS);
});
- it("shows a success toast with an action linking to scans", async () => {
- // Given
- launchOrganizationScansMock.mockResolvedValue({ successCount: 1 });
- const onFooterChange = vi.fn();
+ describe("when capability is ADVANCED", () => {
+ it("should save schedules through the bulk endpoint", async () => {
+ // Given
+ const onFooterChange = vi.fn();
- render(
- ,
- );
+ render(
+ ,
+ );
- // When
- await waitFor(() => {
- expect(onFooterChange).toHaveBeenCalled();
- });
- const footerConfig = onFooterChange.mock.calls.at(-1)?.[0];
- await act(async () => {
- footerConfig.onAction?.();
+ // When
+ await screen.findByText("Scan Schedule");
+ await act(async () => {
+ lastFooterConfig(onFooterChange)?.onAction?.();
+ });
+
+ // Then
+ await waitFor(() =>
+ expect(updateSchedulesBulkMock).toHaveBeenCalledTimes(1),
+ );
+ expect(updateSchedulesBulkMock).toHaveBeenCalledWith(
+ PROVIDER_IDS,
+ expect.objectContaining({
+ scan_enabled: true,
+ scan_frequency: SCHEDULE_FREQUENCY.DAILY,
+ scan_hour: expect.any(Number),
+ scan_timezone: "Europe/Madrid",
+ }),
+ );
+ expect(launchOrganizationScansMock).not.toHaveBeenCalled();
+ expect(pushMock).toHaveBeenCalledWith("/providers");
+ expect(
+ toastMock.mock.calls[0]?.[0].action.props.children.props.href,
+ ).toBe(`/scans?tab=${SCAN_JOBS_TAB.SCHEDULED}`);
});
- // Then
- await waitFor(() => {
- expect(toastMock).toHaveBeenCalledTimes(1);
+ it("should launch initial scans only for updated providers", async () => {
+ // Given
+ const user = userEvent.setup();
+ const onFooterChange = vi.fn();
+ updateSchedulesBulkMock.mockResolvedValue({
+ data: {
+ type: "schedules-bulk",
+ attributes: {
+ updated: ["provider-2"],
+ failed: [{ id: "provider-1", error: "Denied" }],
+ },
+ },
+ });
+
+ render(
+ ,
+ );
+
+ // When
+ await user.click(
+ await screen.findByRole("checkbox", {
+ name: /launch an initial scan now/i,
+ }),
+ );
+ await act(async () => {
+ lastFooterConfig(onFooterChange)?.onAction?.();
+ });
+
+ // Then
+ await waitFor(() =>
+ expect(launchOrganizationScansMock).toHaveBeenCalledTimes(1),
+ );
+ expect(launchOrganizationScansMock).toHaveBeenCalledWith(
+ ["provider-2"],
+ "single",
+ );
+ expect(
+ toastMock.mock.calls[0]?.[0].action.props.children.props.href,
+ ).toBe(`/scans?tab=${SCAN_JOBS_TAB.ACTIVE}`);
+ });
+
+ it("should disable launch actions while schedule capability is loading", async () => {
+ // Given
+ const onFooterChange = vi.fn();
+
+ 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(updateSchedulesBulkMock).not.toHaveBeenCalled();
+ expect(launchOrganizationScansMock).not.toHaveBeenCalled();
+ });
+
+ it("should surface an error toast and stay on the wizard when the bulk update fails", async () => {
+ // Given
+ const onClose = vi.fn();
+ const onFooterChange = vi.fn();
+ updateSchedulesBulkMock.mockResolvedValue({ error: "Denied" });
+
+ render(
+ ,
+ );
+
+ // When
+ await screen.findByText("Scan Schedule");
+ await act(async () => {
+ lastFooterConfig(onFooterChange)?.onAction?.();
+ });
+
+ // Then
+ await waitFor(() =>
+ expect(toastMock).toHaveBeenCalledWith(
+ expect.objectContaining({
+ variant: "destructive",
+ title: "Unable to save scan schedules",
+ }),
+ ),
+ );
+ expect(launchOrganizationScansMock).not.toHaveBeenCalled();
+ expect(pushMock).not.toHaveBeenCalled();
+ expect(onClose).not.toHaveBeenCalled();
+ });
+
+ it("should treat a fully-failed bulk response as an error without navigating away", async () => {
+ // Given
+ const onClose = vi.fn();
+ const onFooterChange = vi.fn();
+ updateSchedulesBulkMock.mockResolvedValue({
+ data: {
+ type: "schedules-bulk",
+ attributes: {
+ updated: [],
+ failed: [
+ { id: "provider-1", error: "Denied" },
+ { id: "provider-2", error: "Denied" },
+ ],
+ },
+ },
+ });
+
+ render(
+ ,
+ );
+
+ // When
+ await screen.findByText("Scan Schedule");
+ await act(async () => {
+ lastFooterConfig(onFooterChange)?.onAction?.();
+ });
+
+ // Then
+ await waitFor(() =>
+ expect(toastMock).toHaveBeenCalledWith(
+ expect.objectContaining({
+ variant: "destructive",
+ title: "Unable to save scan schedules",
+ description: "The scan schedule could not be saved for 2 accounts.",
+ }),
+ ),
+ );
+ expect(launchOrganizationScansMock).not.toHaveBeenCalled();
+ expect(pushMock).not.toHaveBeenCalled();
+ expect(onClose).not.toHaveBeenCalled();
+ });
+
+ it("should describe partial failures in the success toast", async () => {
+ // Given
+ const onFooterChange = vi.fn();
+ updateSchedulesBulkMock.mockResolvedValue({
+ data: {
+ type: "schedules-bulk",
+ attributes: {
+ updated: ["provider-2"],
+ failed: [{ provider_id: "provider-1", error: "Denied" }],
+ },
+ },
+ });
+
+ render(
+ ,
+ );
+
+ // When
+ await screen.findByText("Scan Schedule");
+ await act(async () => {
+ lastFooterConfig(onFooterChange)?.onAction?.();
+ });
+
+ // Then
+ await waitFor(() => expect(toastMock).toHaveBeenCalled());
+ expect(toastMock).toHaveBeenCalledWith(
+ expect.objectContaining({
+ title: "Scan schedules saved",
+ description:
+ "The schedule was saved for 1 account, but 1 account could not be updated.",
+ }),
+ );
});
- const toastPayload = toastMock.mock.calls[0]?.[0];
- expect(toastPayload.title).toBe("Scan Launched");
- expect(toastPayload.action).toBeDefined();
- expect(toastPayload.action.props.children.props.href).toBe("/scans");
});
- it("uses a single manual scan when schedules are unavailable", async () => {
- // Given
- launchOrganizationScansMock.mockResolvedValue({ successCount: 1 });
- const onFooterChange = vi.fn();
+ describe("when capability is DAILY_LEGACY", () => {
+ it("should keep the legacy daily scheduling path", async () => {
+ // Given
+ const onFooterChange = vi.fn();
- render(
- ,
- );
+ render(
+ ,
+ );
- // Then
- expect(
- screen.getByText(/scheduled scans are not available for trial accounts/i),
- ).toBeInTheDocument();
- expect(screen.queryByRole("combobox")).not.toBeInTheDocument();
+ // When
+ await screen.findByText(
+ "Select a Prowler scan schedule for these accounts.",
+ );
+ await act(async () => {
+ lastFooterConfig(onFooterChange)?.onAction?.();
+ });
- // When
- await waitFor(() => {
- expect(onFooterChange).toHaveBeenCalled();
+ // Then
+ await waitFor(() =>
+ expect(launchOrganizationScansMock).toHaveBeenCalledWith(
+ PROVIDER_IDS,
+ "daily",
+ ),
+ );
+ expect(updateSchedulesBulkMock).not.toHaveBeenCalled();
+ expect(
+ toastMock.mock.calls[0]?.[0].action.props.children.props.href,
+ ).toBe(`/scans?tab=${SCAN_JOBS_TAB.SCHEDULED}`);
});
- const footerConfig = onFooterChange.mock.calls.at(-1)?.[0];
- await act(async () => {
- footerConfig.onAction?.();
- });
-
- // Then
- await waitFor(() => {
- expect(launchOrganizationScansMock).toHaveBeenCalledTimes(1);
- });
- expect(launchOrganizationScansMock).toHaveBeenCalledWith(
- ["provider-1"],
- "single",
- );
});
- it("blocks manual scans when the trial scan limit is reached", async () => {
- // Given
- const onFooterChange = vi.fn();
+ describe("when capability is MANUAL_ONLY", () => {
+ it("should launch single scans without rendering schedule controls", async () => {
+ // Given
+ const onFooterChange = vi.fn();
- render(
- ,
- );
+ render(
+ ,
+ );
- // When
- await waitFor(() => {
- expect(onFooterChange).toHaveBeenCalled();
- });
- const footerConfig = onFooterChange.mock.calls.at(-1)?.[0];
- await act(async () => {
- footerConfig.onAction?.();
+ // When
+ expect(
+ screen.getByText(
+ /scheduled scans are not available for trial accounts/i,
+ ),
+ ).toBeInTheDocument();
+ expect(screen.queryByRole("combobox")).not.toBeInTheDocument();
+ await act(async () => {
+ lastFooterConfig(onFooterChange)?.onAction?.();
+ });
+
+ // Then
+ await waitFor(() =>
+ expect(launchOrganizationScansMock).toHaveBeenCalledWith(
+ PROVIDER_IDS,
+ "single",
+ ),
+ );
+ expect(updateSchedulesBulkMock).not.toHaveBeenCalled();
+ expect(
+ toastMock.mock.calls[0]?.[0].action.props.children.props.href,
+ ).toBe(`/scans?tab=${SCAN_JOBS_TAB.ACTIVE}`);
});
+ });
- // Then
- expect(screen.getByText(/reached your scan limit/i)).toBeInTheDocument();
- expect(footerConfig.actionDisabled).toBe(true);
- expect(launchOrganizationScansMock).not.toHaveBeenCalled();
+ describe("when capability is BLOCKED", () => {
+ it("should disable the action without calling scans or schedules", async () => {
+ // Given
+ const onFooterChange = vi.fn();
+
+ render(
+ ,
+ );
+
+ // When
+ await waitFor(() => {
+ expect(lastFooterConfig(onFooterChange)?.actionDisabled).toBe(true);
+ });
+ await act(async () => {
+ lastFooterConfig(onFooterChange)?.onAction?.();
+ });
+
+ // Then
+ expect(screen.getByText(/reached your scan limit/i)).toBeInTheDocument();
+ expect(updateSchedulesBulkMock).not.toHaveBeenCalled();
+ expect(launchOrganizationScansMock).not.toHaveBeenCalled();
+ });
});
});
diff --git a/ui/components/providers/organizations/org-launch-scan.tsx b/ui/components/providers/organizations/org-launch-scan.tsx
index 6aede38b81..70b0cc46d3 100644
--- a/ui/components/providers/organizations/org-launch-scan.tsx
+++ b/ui/components/providers/organizations/org-launch-scan.tsx
@@ -1,15 +1,19 @@
"use client";
+import { zodResolver } from "@hookform/resolvers/zod";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useEffect, useRef, useState } from "react";
+import { useForm, useWatch } from "react-hook-form";
import { launchOrganizationScans } from "@/actions/scans/scans";
+import { updateSchedulesBulk } from "@/actions/schedules/schedules";
import { AWSProviderBadge } from "@/components/icons/providers-badge";
import {
WIZARD_FOOTER_ACTION_TYPE,
WizardFooterConfig,
} from "@/components/providers/wizard/steps/footer-controls";
+import { ScanScheduleFields } from "@/components/scans/schedule/scan-schedule-fields";
import {
Select,
SelectContent,
@@ -20,11 +24,21 @@ import {
import { Spinner } from "@/components/shadcn/spinner/spinner";
import { TreeStatusIcon } from "@/components/shadcn/tree-view/tree-status-icon";
import { ToastAction, useToast } from "@/components/ui";
+import {
+ buildScheduleUpdatePayload,
+ getScanScheduleCapability,
+ getScheduleFormDefaults,
+ scheduleFormSchema,
+} from "@/lib/schedules";
+import { isCloud } from "@/lib/shared/env";
import { useOrgSetupStore } from "@/store/organizations/store";
import {
+ SCAN_JOBS_TAB,
SCAN_SCHEDULE_CAPABILITY,
type ScanScheduleCapability,
-} from "@/types/schedules";
+ type ScheduleFormValues,
+ type SchedulesBulkResponse,
+} from "@/types";
import { TREE_ITEM_STATUS } from "@/types/tree";
interface OrgLaunchScanProps {
@@ -32,12 +46,17 @@ interface OrgLaunchScanProps {
onBack: () => void;
onFooterChange: (config: WizardFooterConfig) => void;
/**
- * Schedule capability override. Prowler Cloud passes MANUAL_ONLY for trial
- * tenants so organization onboarding cannot create recurring schedules.
+ * Schedule capability override. Prowler Cloud passes MANUAL_ONLY/BLOCKED for
+ * billing-limited tenants; OSS falls back to an environment-based capability.
*/
capability?: ScanScheduleCapability;
/** Cloud-only manual scan quota signal. */
isScanLimitReached?: boolean;
+ /**
+ * Cloud-only loading state while billing is resolved into a schedule
+ * capability. OSS leaves it false.
+ */
+ isScheduleCapabilityLoading?: boolean;
}
const SCAN_SCHEDULE = {
@@ -47,30 +66,182 @@ const SCAN_SCHEDULE = {
type ScanScheduleOption = (typeof SCAN_SCHEDULE)[keyof typeof SCAN_SCHEDULE];
+function getStringArray(value: unknown): string[] {
+ return Array.isArray(value)
+ ? value.filter((item): item is string => typeof item === "string")
+ : [];
+}
+
+/**
+ * Providers whose schedule was actually saved. The backend reports successes
+ * under `updated`, populated only after each provider's schedule commits, so
+ * it already excludes failures — no client-side subtraction is needed.
+ */
+function getUpdatedProviderIds(result: SchedulesBulkResponse): string[] {
+ return getStringArray(result.data?.attributes?.updated);
+}
+
+function getFailedCount(result: SchedulesBulkResponse): number {
+ const failed = result.data?.attributes?.failed;
+ return Array.isArray(failed) ? failed.length : 0;
+}
+
+function formatAccountCount(count: number): string {
+ return `${count} account${count === 1 ? "" : "s"}`;
+}
+
+function getScansHref(tab: (typeof SCAN_JOBS_TAB)[keyof typeof SCAN_JOBS_TAB]) {
+ return `/scans?tab=${tab}`;
+}
+
export function OrgLaunchScan({
onClose,
onBack,
onFooterChange,
capability,
isScanLimitReached = false,
+ isScheduleCapabilityLoading = false,
}: OrgLaunchScanProps) {
const router = useRouter();
const { toast } = useToast();
const { organizationExternalId, createdProviderIds, reset } =
useOrgSetupStore();
+ const resolvedCapability = capability ?? getScanScheduleCapability(isCloud());
+ const isAdvanced = resolvedCapability === SCAN_SCHEDULE_CAPABILITY.ADVANCED;
+ const isDailyLegacy =
+ resolvedCapability === SCAN_SCHEDULE_CAPABILITY.DAILY_LEGACY;
+ const isManualOnly =
+ resolvedCapability === SCAN_SCHEDULE_CAPABILITY.MANUAL_ONLY;
+ const isBlocked =
+ resolvedCapability === SCAN_SCHEDULE_CAPABILITY.BLOCKED ||
+ (isManualOnly && isScanLimitReached);
+
const [isLaunching, setIsLaunching] = useState(false);
const [scheduleOption, setScheduleOption] = useState(
SCAN_SCHEDULE.DAILY,
);
+ const form = useForm({
+ resolver: zodResolver(scheduleFormSchema),
+ defaultValues: getScheduleFormDefaults(),
+ });
+ const launchInitialScan = useWatch({
+ control: form.control,
+ name: "launchInitialScan",
+ });
const launchActionRef = useRef<() => void>(() => {});
- const isManualOnly = capability === SCAN_SCHEDULE_CAPABILITY.MANUAL_ONLY;
+
const effectiveScheduleOption = isManualOnly
? SCAN_SCHEDULE.SINGLE
: scheduleOption;
+ const actionDisabled =
+ isLaunching ||
+ isScheduleCapabilityLoading ||
+ isBlocked ||
+ createdProviderIds.length === 0;
+ const actionLabel = isAdvanced
+ ? isLaunching
+ ? launchInitialScan
+ ? "Saving and launching..."
+ : "Saving..."
+ : launchInitialScan
+ ? "Save and launch scan"
+ : "Save"
+ : isLaunching
+ ? "Launching scans..."
+ : "Launch scan";
- const handleLaunchScan = async () => {
- if (isManualOnly && isScanLimitReached) {
+ const finishSuccess = () => {
+ reset();
+ onClose();
+ router.push("/providers");
+ };
+
+ const handleAdvancedSchedule = form.handleSubmit(async (values) => {
+ if (actionDisabled || !isAdvanced) {
+ return;
+ }
+
+ setIsLaunching(true);
+
+ const result = await updateSchedulesBulk(
+ createdProviderIds,
+ buildScheduleUpdatePayload(values),
+ );
+
+ if (result.error) {
+ setIsLaunching(false);
+ toast({
+ variant: "destructive",
+ title: "Unable to save scan schedules",
+ description: String(result.error),
+ });
+ return;
+ }
+
+ const updatedProviderIds = getUpdatedProviderIds(result);
+ const failedCount = getFailedCount(result);
+
+ // No provider was actually updated (e.g. the endpoint returned 200 but every
+ // schedule failed). Surface it as an error and keep the wizard open to retry
+ // instead of navigating away with a misleading "saved for 0 accounts" toast.
+ if (updatedProviderIds.length === 0) {
+ setIsLaunching(false);
+ toast({
+ variant: "destructive",
+ title: "Unable to save scan schedules",
+ description:
+ failedCount > 0
+ ? `The scan schedule could not be saved for ${formatAccountCount(failedCount)}.`
+ : "The scan schedule could not be saved for any account.",
+ });
+ return;
+ }
+
+ let initialScanFailureCount = 0;
+ let initialScanSuccessCount = 0;
+
+ if (values.launchInitialScan) {
+ const scanResult = await launchOrganizationScans(
+ updatedProviderIds,
+ SCAN_SCHEDULE.SINGLE,
+ );
+ initialScanFailureCount = scanResult.failureCount;
+ initialScanSuccessCount = scanResult.successCount;
+ }
+
+ setIsLaunching(false);
+ finishSuccess();
+
+ const updatedCount = updatedProviderIds.length;
+ const description =
+ failedCount > 0
+ ? `The schedule was saved for ${formatAccountCount(updatedCount)}, but ${formatAccountCount(failedCount)} could not be updated.`
+ : `The scan schedule was saved for ${formatAccountCount(updatedCount)}.`;
+ const targetTab =
+ initialScanSuccessCount > 0
+ ? SCAN_JOBS_TAB.ACTIVE
+ : SCAN_JOBS_TAB.SCHEDULED;
+
+ toast({
+ title:
+ values.launchInitialScan && initialScanFailureCount === 0
+ ? "Scan schedules saved and initial scans launched"
+ : "Scan schedules saved",
+ description:
+ initialScanFailureCount > 0
+ ? `${description} Initial scans failed for ${formatAccountCount(initialScanFailureCount)}.`
+ : description,
+ action: (
+
+ Go to scans
+
+ ),
+ });
+ });
+
+ const handleLegacyLaunch = async () => {
+ if (actionDisabled || isAdvanced) {
return;
}
@@ -81,52 +252,58 @@ export function OrgLaunchScan({
effectiveScheduleOption,
);
const successCount = result.successCount;
+ const targetTab =
+ effectiveScheduleOption === SCAN_SCHEDULE.SINGLE
+ ? SCAN_JOBS_TAB.ACTIVE
+ : SCAN_JOBS_TAB.SCHEDULED;
setIsLaunching(false);
- reset();
- onClose();
- router.push("/providers");
+ finishSuccess();
toast({
title: "Scan Launched",
description:
effectiveScheduleOption === SCAN_SCHEDULE.DAILY
- ? `Daily scan scheduled for ${successCount} account${successCount !== 1 ? "s" : ""}.`
- : `Single scan launched for ${successCount} account${successCount !== 1 ? "s" : ""}.`,
+ ? `Daily scan scheduled for ${formatAccountCount(successCount)}.`
+ : `Single scan launched for ${formatAccountCount(successCount)}.`,
action: (
- Go to scans
+ Go to scans
),
});
};
launchActionRef.current = () => {
- void handleLaunchScan();
+ if (isAdvanced) {
+ void handleAdvancedSchedule();
+ return;
+ }
+ void handleLegacyLaunch();
};
useEffect(() => {
onFooterChange({
showBack: true,
backLabel: "Back",
- backDisabled: isLaunching,
+ backDisabled: isLaunching || isScheduleCapabilityLoading,
onBack,
showAction: true,
- actionLabel: "Launch scan",
- actionDisabled:
- isLaunching ||
- createdProviderIds.length === 0 ||
- (isManualOnly && isScanLimitReached),
+ actionLabel,
+ actionDisabled,
actionType: WIZARD_FOOTER_ACTION_TYPE.BUTTON,
onAction: () => {
launchActionRef.current();
},
});
}, [
+ actionDisabled,
+ actionLabel,
createdProviderIds.length,
+ isAdvanced,
isLaunching,
- isManualOnly,
- isScanLimitReached,
+ isScheduleCapabilityLoading,
+ launchInitialScan,
onBack,
onFooterChange,
]);
@@ -149,11 +326,17 @@ export function OrgLaunchScan({
- {isLaunching ? (
+ {isLaunching || isScheduleCapabilityLoading ? (
-
Launching scans...
+
+ {isScheduleCapabilityLoading
+ ? "Loading scan options..."
+ : isAdvanced
+ ? "Saving scan schedules..."
+ : "Launching scans..."}
+
) : (
@@ -177,20 +360,26 @@ export function OrgLaunchScan({
)}
- {isManualOnly ? (
+ {isBlocked ? (
+
+ You have reached your scan limit, so additional scans are not
+ available right now.
+
+ ) : isAdvanced ? (
+
+ ) : isManualOnly ? (
Scheduled scans are not available for trial accounts. These
accounts will run a one-time manual scan now.
- {isScanLimitReached && (
-
- You have reached your scan limit, so additional scans are not
- available right now.
-
- )}
- ) : (
+ ) : isDailyLegacy ? (
Select a Prowler scan schedule for these accounts.
@@ -215,7 +404,7 @@ export function OrgLaunchScan({
- )}
+ ) : null}
)}
diff --git a/ui/components/providers/providers-accounts-table.test.tsx b/ui/components/providers/providers-accounts-table.test.tsx
new file mode 100644
index 0000000000..4fe66a6bd7
--- /dev/null
+++ b/ui/components/providers/providers-accounts-table.test.tsx
@@ -0,0 +1,306 @@
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+import type { MetaDataProps } from "@/types";
+import {
+ PROVIDERS_GROUP_KIND,
+ PROVIDERS_ROW_TYPE,
+ type ProvidersTableRow,
+} from "@/types/providers-table";
+import { SCAN_SCHEDULE_CAPABILITY } from "@/types/schedules";
+
+const { dataTableMockState, getColumnProvidersMock } = vi.hoisted(() => ({
+ dataTableMockState: {
+ nextSelection: {} as Record,
+ },
+ getColumnProvidersMock: vi.fn((..._args: unknown[]) => []),
+}));
+
+vi.mock("@/components/ui/table", () => ({
+ DataTable: ({
+ onRowSelectionChange,
+ }: {
+ onRowSelectionChange?: (selection: Record) => void;
+ }) => (
+
+
+
+ ),
+}));
+
+vi.mock("./table", () => ({
+ getColumnProviders: (...args: unknown[]) => getColumnProvidersMock(...args),
+}));
+
+import {
+ computeSelectedScheduleProviders,
+ ProvidersAccountsTable,
+} from "./providers-accounts-table";
+
+const metadata: MetaDataProps = {
+ pagination: { page: 1, pages: 1, count: 0, itemsPerPage: [10] },
+ version: "latest",
+};
+
+const createProviderRow = (
+ id: string,
+ uid = id,
+ alias: string | null = id,
+): ProvidersTableRow =>
+ ({
+ id,
+ rowType: PROVIDERS_ROW_TYPE.PROVIDER,
+ type: "providers",
+ attributes: {
+ provider: "aws",
+ uid,
+ alias,
+ status: "completed",
+ resources: 0,
+ connection: {
+ connected: true,
+ last_checked_at: "2026-01-01T00:00:00Z",
+ },
+ scanner_args: {
+ only_logs: false,
+ excluded_checks: [],
+ aws_retries_max_attempts: 3,
+ },
+ inserted_at: "2026-01-01T00:00:00Z",
+ updated_at: "2026-01-01T00:00:00Z",
+ created_by: {
+ object: "user",
+ id: "user-1",
+ },
+ },
+ relationships: {
+ secret: { data: { id: `secret-${id}`, type: "secrets" } },
+ provider_groups: { meta: { count: 0 }, data: [] },
+ },
+ groupNames: [],
+ hasSchedule: false,
+ }) as ProvidersTableRow;
+
+const providerOne = createProviderRow("provider-1", "111111111111", "Prod");
+const providerTwo = createProviderRow("provider-2", "222222222222", "Stage");
+const providerThree = createProviderRow("provider-3", "333333333333", "Dev");
+
+const organizationRow: ProvidersTableRow = {
+ id: "org-1",
+ rowType: PROVIDERS_ROW_TYPE.ORGANIZATION,
+ groupKind: PROVIDERS_GROUP_KIND.ORGANIZATION,
+ name: "My AWS Organization",
+ externalId: "o-abc123def4",
+ parentExternalId: null,
+ organizationId: "org-1",
+ providerCount: 3,
+ providerIds: ["provider-1", "provider-2", "provider-hidden"],
+ subRows: [providerOne, providerTwo],
+};
+
+const organizationalUnitRow: ProvidersTableRow = {
+ id: "ou-1",
+ rowType: PROVIDERS_ROW_TYPE.ORGANIZATION,
+ groupKind: PROVIDERS_GROUP_KIND.ORGANIZATION_UNIT,
+ name: "Production OU",
+ externalId: "ou-abc123",
+ parentExternalId: "o-abc123def4",
+ organizationId: "org-1",
+ providerCount: 2,
+ providerIds: ["provider-2", "provider-hidden-ou"],
+ subRows: [providerTwo],
+};
+
+describe("ProvidersAccountsTable", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ dataTableMockState.nextSelection = {};
+ });
+
+ it("passes scan schedule capability to provider row action columns", () => {
+ // Given/When
+ render(
+ ,
+ );
+
+ // Then
+ expect(screen.getByTestId("providers-data-table")).toBeInTheDocument();
+ expect(getColumnProvidersMock).toHaveBeenCalledWith(
+ expect.any(Object),
+ [],
+ [],
+ [],
+ expect.any(Function),
+ expect.any(Function),
+ expect.any(Function),
+ SCAN_SCHEDULE_CAPABILITY.MANUAL_ONLY,
+ );
+ });
+
+ describe("schedule provider selection", () => {
+ it("uses the selected provider id for provider rows", () => {
+ // Given
+ const rows = [providerOne, providerTwo];
+
+ // When
+ const result = computeSelectedScheduleProviders(rows, { "0": true });
+
+ // Then
+ expect(result.providerIds).toEqual(["provider-1"]);
+ expect(result.providers.map((provider) => provider.providerId)).toEqual([
+ "provider-1",
+ ]);
+ });
+
+ it("uses every organization provider id when the organization is selected", () => {
+ // Given
+ const rows = [organizationRow];
+
+ // When
+ const result = computeSelectedScheduleProviders(rows, { "0": true });
+
+ // Then
+ expect(result.providerIds).toEqual([
+ "provider-1",
+ "provider-2",
+ "provider-hidden",
+ ]);
+ expect(result.providers.map((provider) => provider.providerId)).toEqual([
+ "provider-1",
+ "provider-2",
+ ]);
+ });
+
+ it("uses every organizational unit provider id when the OU is selected", () => {
+ // Given
+ const rows = [
+ {
+ ...organizationRow,
+ subRows: [organizationalUnitRow],
+ },
+ ];
+
+ // When
+ const result = computeSelectedScheduleProviders(rows, { "0.0": true });
+
+ // Then
+ expect(result.providerIds).toEqual(["provider-2", "provider-hidden-ou"]);
+ });
+
+ it("deduplicates provider ids when an organization and child provider are selected", () => {
+ // Given
+ const rows = [organizationRow];
+
+ // When
+ const result = computeSelectedScheduleProviders(rows, {
+ "0": true,
+ "0.0": true,
+ });
+
+ // Then
+ expect(result.providerIds).toEqual([
+ "provider-1",
+ "provider-2",
+ "provider-hidden",
+ ]);
+ });
+
+ it("uses only selected child providers when an organization is partially selected", () => {
+ // Given
+ const rows = [organizationRow, providerThree];
+
+ // When
+ const result = computeSelectedScheduleProviders(rows, {
+ "0.1": true,
+ "1": true,
+ });
+
+ // Then
+ expect(result.providerIds).toEqual(["provider-2", "provider-3"]);
+ });
+ });
+
+ it("passes selected provider ids to provider row action columns", async () => {
+ // Given
+ const user = userEvent.setup();
+ dataTableMockState.nextSelection = { "0": true };
+ render(
+ ,
+ );
+
+ // When
+ await user.click(screen.getByRole("button", { name: "Apply selection" }));
+
+ // Then
+ expect(getColumnProvidersMock).toHaveBeenLastCalledWith(
+ expect.any(Object),
+ ["provider-1"],
+ ["provider-1"],
+ [
+ expect.objectContaining({
+ providerId: "provider-1",
+ providerType: "aws",
+ providerUid: "111111111111",
+ providerAlias: "Prod",
+ }),
+ ],
+ expect.any(Function),
+ expect.any(Function),
+ expect.any(Function),
+ SCAN_SCHEDULE_CAPABILITY.ADVANCED,
+ );
+ });
+
+ it("passes selected organization provider ids and visible providers to provider row action columns", async () => {
+ // Given
+ const user = userEvent.setup();
+ dataTableMockState.nextSelection = { "0": true };
+ render(
+ ,
+ );
+
+ await user.click(screen.getByRole("button", { name: "Apply selection" }));
+
+ // Then
+ expect(getColumnProvidersMock).toHaveBeenLastCalledWith(
+ expect.any(Object),
+ [],
+ ["provider-1", "provider-2", "provider-hidden"],
+ [
+ expect.objectContaining({ providerId: "provider-1" }),
+ expect.objectContaining({ providerId: "provider-2" }),
+ ],
+ expect.any(Function),
+ expect.any(Function),
+ expect.any(Function),
+ SCAN_SCHEDULE_CAPABILITY.ADVANCED,
+ );
+ });
+});
diff --git a/ui/components/providers/providers-accounts-table.tsx b/ui/components/providers/providers-accounts-table.tsx
index 0c25b609d9..0f51e4dc66 100644
--- a/ui/components/providers/providers-accounts-table.tsx
+++ b/ui/components/providers/providers-accounts-table.tsx
@@ -1,7 +1,7 @@
"use client";
import { RowSelectionState } from "@tanstack/react-table";
-import { useEffect, useState } from "react";
+import { useState } from "react";
import type {
OrgWizardInitialData,
@@ -11,8 +11,13 @@ import { DataTable } from "@/components/ui/table";
import { MetaDataProps } from "@/types";
import {
isProvidersOrganizationRow,
+ isProvidersProviderRow,
ProvidersTableRow,
} from "@/types/providers-table";
+import type {
+ ScanScheduleCapability,
+ ScanScheduleProvider,
+} from "@/types/schedules";
import { getColumnProviders } from "./table";
@@ -20,6 +25,7 @@ interface ProvidersAccountsTableProps {
isCloud: boolean;
metadata?: MetaDataProps;
rows: ProvidersTableRow[];
+ scanScheduleCapability?: ScanScheduleCapability;
onOpenProviderWizard: (initialData?: ProviderWizardInitialData) => void;
onOpenOrganizationWizard: (initialData: OrgWizardInitialData) => void;
}
@@ -50,31 +56,132 @@ function computeTestableProviderIds(
return ids;
}
-export function ProvidersAccountsTable({
+function toScanScheduleProvider(
+ row: ProvidersTableRow,
+): ScanScheduleProvider | null {
+ if (!isProvidersProviderRow(row)) return null;
+
+ return {
+ providerId: row.id,
+ providerType: row.attributes.provider,
+ providerUid: row.attributes.uid,
+ providerAlias: row.attributes.alias,
+ };
+}
+
+function appendUnique(target: string[], seen: Set, ids: string[]) {
+ for (const id of ids) {
+ if (seen.has(id)) continue;
+ seen.add(id);
+ target.push(id);
+ }
+}
+
+function appendUniqueProvider(
+ target: ScanScheduleProvider[],
+ seen: Set,
+ provider: ScanScheduleProvider | null,
+) {
+ if (!provider || seen.has(provider.providerId)) return;
+ seen.add(provider.providerId);
+ target.push(provider);
+}
+
+function collectVisibleScheduleProviders(rows: ProvidersTableRow[]) {
+ const providers: ScanScheduleProvider[] = [];
+ const seen = new Set();
+
+ function walk(items: ProvidersTableRow[]) {
+ for (const item of items) {
+ appendUniqueProvider(providers, seen, toScanScheduleProvider(item));
+ if (isProvidersOrganizationRow(item)) {
+ walk(item.subRows);
+ }
+ }
+ }
+
+ walk(rows);
+ return providers;
+}
+
+export interface SelectedScheduleProvidersResult {
+ providerIds: string[];
+ providers: ScanScheduleProvider[];
+}
+
+export function computeSelectedScheduleProviders(
+ rows: ProvidersTableRow[],
+ rowSelection: RowSelectionState,
+): SelectedScheduleProvidersResult {
+ const providerIds: string[] = [];
+ const providers: ScanScheduleProvider[] = [];
+ const seenProviderIds = new Set();
+ const seenVisibleProviders = new Set();
+
+ function walk(items: ProvidersTableRow[], prefix: string) {
+ items.forEach((item, idx) => {
+ const key = prefix ? `${prefix}.${idx}` : `${idx}`;
+ const isSelected = rowSelection[key] === true;
+
+ if (isProvidersOrganizationRow(item)) {
+ if (isSelected) {
+ appendUnique(providerIds, seenProviderIds, item.providerIds);
+ for (const provider of collectVisibleScheduleProviders(
+ item.subRows,
+ )) {
+ appendUniqueProvider(providers, seenVisibleProviders, provider);
+ }
+ return;
+ }
+
+ walk(item.subRows, key);
+ return;
+ }
+
+ if (isSelected) {
+ appendUnique(providerIds, seenProviderIds, [item.id]);
+ appendUniqueProvider(
+ providers,
+ seenVisibleProviders,
+ toScanScheduleProvider(item),
+ );
+ }
+ });
+ }
+
+ walk(rows, "");
+
+ return { providerIds, providers };
+}
+
+function ProvidersAccountsTableContent({
isCloud,
metadata,
rows,
+ scanScheduleCapability,
onOpenProviderWizard,
onOpenOrganizationWizard,
}: ProvidersAccountsTableProps) {
const [rowSelection, setRowSelection] = useState({});
- // Reset selection when page changes
- const currentPage = metadata?.pagination?.page;
- useEffect(() => {
- setRowSelection({});
- }, [currentPage]);
-
const testableProviderIds = computeTestableProviderIds(rows, rowSelection);
+ const selectedScheduleProviders = computeSelectedScheduleProviders(
+ rows,
+ rowSelection,
+ );
+ const selectedScheduleProviderIds = selectedScheduleProviders.providerIds;
const clearSelection = () => setRowSelection({});
const columns = getColumnProviders(
rowSelection,
testableProviderIds,
+ selectedScheduleProviderIds,
+ selectedScheduleProviders.providers,
clearSelection,
onOpenProviderWizard,
onOpenOrganizationWizard,
+ scanScheduleCapability,
);
return (
@@ -92,3 +199,9 @@ export function ProvidersAccountsTable({
/>
);
}
+
+export function ProvidersAccountsTable(props: ProvidersAccountsTableProps) {
+ const currentPage = props.metadata?.pagination?.page ?? "none";
+
+ return ;
+}
diff --git a/ui/components/providers/providers-accounts-view.test.tsx b/ui/components/providers/providers-accounts-view.test.tsx
index c8b3baf596..f888f7f4d8 100644
--- a/ui/components/providers/providers-accounts-view.test.tsx
+++ b/ui/components/providers/providers-accounts-view.test.tsx
@@ -5,8 +5,15 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import type { FilterOption, MetaDataProps, ProviderProps } from "@/types";
import type { ProvidersTableRow } from "@/types/providers-table";
+import { SCAN_SCHEDULE_CAPABILITY } from "@/types/schedules";
-const { refreshMock, replaceMock, searchParamsValue } = vi.hoisted(() => ({
+const {
+ providersAccountsTableSpy,
+ refreshMock,
+ replaceMock,
+ searchParamsValue,
+} = vi.hoisted(() => ({
+ providersAccountsTableSpy: vi.fn(),
refreshMock: vi.fn(),
replaceMock: vi.fn(),
searchParamsValue: { current: "" },
@@ -49,7 +56,10 @@ vi.mock("@/components/providers/providers-filters", () => ({
}));
vi.mock("@/components/providers/providers-accounts-table", () => ({
- ProvidersAccountsTable: () => Table
,
+ ProvidersAccountsTable: (props: { scanScheduleCapability?: string }) => {
+ providersAccountsTableSpy(props);
+ return Table
;
+ },
}));
vi.mock("@/components/providers/wizard", () => ({
@@ -123,6 +133,7 @@ const disconnectedProviders: ProviderProps[] = [
describe("ProvidersAccountsView", () => {
afterEach(() => {
vi.restoreAllMocks();
+ providersAccountsTableSpy.mockClear();
searchParamsValue.current = "";
window.history.replaceState({}, "", "/");
});
@@ -285,6 +296,27 @@ describe("ProvidersAccountsView", () => {
).not.toBeInTheDocument();
});
+ it("passes scan schedule capability to provider row actions", () => {
+ // Given/When
+ render(
+ ,
+ );
+
+ // Then
+ expect(providersAccountsTableSpy).toHaveBeenCalledWith(
+ expect.objectContaining({
+ scanScheduleCapability: SCAN_SCHEDULE_CAPABILITY.MANUAL_ONLY,
+ }),
+ );
+ });
+
it("opens the provider wizard from the normal Add Provider button", async () => {
// Given
const user = userEvent.setup();
diff --git a/ui/components/providers/providers-accounts-view.tsx b/ui/components/providers/providers-accounts-view.tsx
index d1ff22a958..8a5d8f93fc 100644
--- a/ui/components/providers/providers-accounts-view.tsx
+++ b/ui/components/providers/providers-accounts-view.tsx
@@ -152,6 +152,7 @@ export function ProvidersAccountsView({
isCloud={isCloud}
metadata={metadata}
rows={rows}
+ scanScheduleCapability={scanScheduleCapability}
onOpenProviderWizard={openProviderWizard}
onOpenOrganizationWizard={openOrganizationWizard}
/>
diff --git a/ui/components/providers/table/column-providers.tsx b/ui/components/providers/table/column-providers.tsx
index 7a0daf9b40..c88c61b78e 100644
--- a/ui/components/providers/table/column-providers.tsx
+++ b/ui/components/providers/table/column-providers.tsx
@@ -20,6 +20,10 @@ import {
ProvidersProviderRow,
ProvidersTableRow,
} from "@/types/providers-table";
+import type {
+ ScanScheduleCapability,
+ ScanScheduleProvider,
+} from "@/types/schedules";
import { LinkToScans } from "../link-to-scans";
import { DataTableRowActions } from "./data-table-row-actions";
@@ -103,9 +107,12 @@ function countSelectedLeaves(rows: Row[]): number {
export function getColumnProviders(
rowSelection: RowSelectionState,
testableProviderIds: string[],
+ selectedScheduleProviderIds: string[],
+ selectedScheduleProviders: ScanScheduleProvider[],
onClearSelection: () => void,
onOpenProviderWizard: (initialData?: ProviderWizardInitialData) => void,
onOpenOrganizationWizard: (initialData: OrgWizardInitialData) => void,
+ scanScheduleCapability?: ScanScheduleCapability,
): ColumnDef[] {
return [
{
@@ -317,9 +324,12 @@ export function getColumnProviders(
hasSelection={hasSelection}
isRowSelected={row.getIsSelected()}
testableProviderIds={testableProviderIds}
+ selectedScheduleProviderIds={selectedScheduleProviderIds}
+ selectedScheduleProviders={selectedScheduleProviders}
onClearSelection={onClearSelection}
onOpenProviderWizard={onOpenProviderWizard}
onOpenOrganizationWizard={onOpenOrganizationWizard}
+ capability={scanScheduleCapability}
/>
);
},
diff --git a/ui/components/providers/table/data-table-row-actions.test.tsx b/ui/components/providers/table/data-table-row-actions.test.tsx
index dc0aeadf47..0fd6823851 100644
--- a/ui/components/providers/table/data-table-row-actions.test.tsx
+++ b/ui/components/providers/table/data-table-row-actions.test.tsx
@@ -9,6 +9,7 @@ import {
PROVIDERS_ROW_TYPE,
ProvidersTableRow,
} from "@/types/providers-table";
+import { SCAN_SCHEDULE_CAPABILITY } from "@/types/schedules";
const { checkConnectionProviderMock, getScheduleMock, pushMock } = vi.hoisted(
() => ({
@@ -55,13 +56,16 @@ vi.mock("@/components/scans/schedule/edit-scan-schedule-modal", () => ({
EditScanScheduleModal: ({
open,
provider,
+ providers,
}: {
open: boolean;
provider?: { providerId: string };
+ providers?: { providerId: string }[];
}) =>
open ? (
- Editing schedule for {provider?.providerId}
+ Editing schedule for{" "}
+ {providers ? `${providers.length} providers` : provider?.providerId}
) : null,
}));
@@ -130,6 +134,7 @@ const createOrgRow = () =>
parentExternalId: null,
organizationId: "org-1",
providerCount: 3,
+ providerIds: ["provider-child-1", "provider-child-2"],
subRows: [
{
id: "provider-child-1",
@@ -162,6 +167,7 @@ const createOuRow = () =>
parentExternalId: "o-abc123def4",
organizationId: "org-1",
providerCount: 2,
+ providerIds: ["provider-ou-child-1"],
subRows: [
{
id: "provider-ou-child-1",
@@ -305,6 +311,56 @@ describe("DataTableRowActions", () => {
).toHaveTextContent("Editing schedule for provider-1");
});
+ it("hides Edit Scan Schedule for manual-only Cloud provider rows", async () => {
+ // Given
+ vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true");
+ const user = userEvent.setup();
+
+ render(
+ ,
+ );
+
+ // When
+ await user.click(screen.getByRole("button"));
+
+ // Then
+ expect(screen.queryByText("Edit Scan Schedule")).not.toBeInTheDocument();
+ });
+
+ it("hides Edit Scan Schedule for blocked Cloud provider rows", async () => {
+ // Given
+ vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true");
+ const user = userEvent.setup();
+
+ render(
+ ,
+ );
+
+ // When
+ await user.click(screen.getByRole("button"));
+
+ // Then
+ expect(screen.queryByText("Edit Scan Schedule")).not.toBeInTheDocument();
+ });
+
it("renders Update Credentials for provider rows with credentials", async () => {
// Given
const user = userEvent.setup();
@@ -351,6 +407,29 @@ describe("DataTableRowActions", () => {
expect(screen.getByText("Delete Organization")).toBeInTheDocument();
});
+ it("opens Edit Scan Schedule for AWS organization rows", async () => {
+ const user = userEvent.setup();
+ render(
+ ,
+ );
+
+ await user.click(screen.getByRole("button"));
+ await user.click(screen.getByText("Edit Scan Schedule"));
+
+ expect(
+ screen.getByRole("dialog", { name: /edit scan schedule/i }),
+ ).toHaveTextContent("Editing schedule for 2 providers");
+ });
+
it("renders Delete Organization with destructive styling for org rows", async () => {
const user = userEvent.setup();
render(
@@ -436,6 +515,49 @@ describe("DataTableRowActions", () => {
expect(screen.queryByText("Test Connections (1)")).not.toBeInTheDocument();
});
+ it("shows bulk Edit Scan Schedule next to Test Connection for selected rows", async () => {
+ // Given
+ const user = userEvent.setup();
+ render(
+ ,
+ );
+
+ // When
+ await user.click(screen.getByRole("button"));
+
+ // Then
+ expect(screen.getByText("Edit Scan Schedule (3)")).toBeInTheDocument();
+ expect(screen.getByText("Test Connection (2)")).toBeInTheDocument();
+ });
+
it("does NOT render Edit Organization Name or Update Credentials for OU rows", async () => {
const user = userEvent.setup();
render(
diff --git a/ui/components/providers/table/data-table-row-actions.tsx b/ui/components/providers/table/data-table-row-actions.tsx
index 44d7198e2e..a2c63e0cb7 100644
--- a/ui/components/providers/table/data-table-row-actions.tsx
+++ b/ui/components/providers/table/data-table-row-actions.tsx
@@ -24,7 +24,6 @@ import {
EDIT_SCAN_SCHEDULE_STATE,
EditScanScheduleModal,
type EditScanScheduleState,
- type ScanScheduleProvider,
} from "@/components/scans/schedule/edit-scan-schedule-modal";
import {
ActionDropdown,
@@ -49,6 +48,7 @@ import {
import {
SCAN_SCHEDULE_CAPABILITY,
type ScanScheduleCapability,
+ type ScanScheduleProvider,
type ScheduleApiResponse,
} from "@/types/schedules";
@@ -64,6 +64,10 @@ interface DataTableRowActionsProps {
isRowSelected: boolean;
/** IDs of all selected providers that have credentials (testable) */
testableProviderIds: string[];
+ /** IDs of all selected providers that can receive schedule updates. */
+ selectedScheduleProviderIds?: string[];
+ /** Visible selected providers used as modal reference rows. */
+ selectedScheduleProviders?: ScanScheduleProvider[];
/** Callback to clear the row selection after bulk operation */
onClearSelection: () => void;
onOpenProviderWizard: (initialData?: ProviderWizardInitialData) => void;
@@ -91,28 +95,56 @@ function collectTestableChildProviderIds(rows: ProvidersTableRow[]): string[] {
return ids;
}
+function collectChildScheduleProviders(
+ rows: ProvidersTableRow[],
+): ScanScheduleProvider[] {
+ const providers: ScanScheduleProvider[] = [];
+
+ for (const row of rows) {
+ if (row.rowType === PROVIDERS_ROW_TYPE.PROVIDER) {
+ providers.push({
+ providerId: row.id,
+ providerType: row.attributes.provider,
+ providerUid: row.attributes.uid,
+ providerAlias: row.attributes.alias,
+ });
+ continue;
+ }
+
+ providers.push(...collectChildScheduleProviders(row.subRows));
+ }
+
+ return providers;
+}
+
interface OrgGroupDropdownActionsProps {
rowData: ProvidersOrganizationRow;
loading: boolean;
+ canEditSchedule: boolean;
hasSelection: boolean;
testableProviderIds: string[];
childTestableIds: string[];
+ scheduleProviderCount: number;
onClearSelection: () => void;
onBulkTest: (ids: string[]) => Promise;
onTestChildConnections: () => Promise;
onOpenOrganizationWizard: (initialData: OrgWizardInitialData) => void;
+ onOpenScheduleEditor: () => void;
}
function OrgGroupDropdownActions({
rowData,
loading,
+ canEditSchedule,
hasSelection,
testableProviderIds,
childTestableIds,
+ scheduleProviderCount,
onClearSelection,
onBulkTest,
onTestChildConnections,
onOpenOrganizationWizard,
+ onOpenScheduleEditor,
}: OrgGroupDropdownActionsProps) {
const [isDeleteOrgOpen, setIsDeleteOrgOpen] = useState(false);
const [isEditNameOpen, setIsEditNameOpen] = useState(false);
@@ -191,6 +223,14 @@ function OrgGroupDropdownActions({
/>
>
)}
+ {isOrgKind && canEditSchedule && (
+ }
+ label="Edit Scan Schedule"
+ onSelect={() => onOpenScheduleEditor()}
+ disabled={scheduleProviderCount === 0}
+ />
+ )}
}
label={loading ? "Testing..." : `Test Connections (${testCount})`}
@@ -226,6 +266,8 @@ export function DataTableRowActions({
hasSelection,
isRowSelected,
testableProviderIds,
+ selectedScheduleProviderIds = [],
+ selectedScheduleProviders = [],
onClearSelection,
onOpenProviderWizard,
onOpenOrganizationWizard,
@@ -266,6 +308,10 @@ export function DataTableRowActions({
const childTestableIds = isOrganizationRow
? collectTestableChildProviderIds(rowData.subRows)
: [];
+ const childScheduleProviders = isOrganizationRow
+ ? collectChildScheduleProviders(rowData.subRows)
+ : [];
+ const childScheduleProviderIds = isOrganizationRow ? rowData.providerIds : [];
const handleBulkTest = async (ids: string[]) => {
if (ids.length === 0) return;
@@ -329,8 +375,17 @@ export function DataTableRowActions({
await handleBulkTest(childTestableIds);
};
- const openScheduleEditor = async () => {
- if (!providerId) {
+ const openScheduleEditor = async (
+ targetProviders: ScanScheduleProvider[] = scheduleProvider
+ ? [scheduleProvider]
+ : [],
+ targetProviderIds: string[] = targetProviders.map(
+ (target) => target.providerId,
+ ),
+ ) => {
+ const targetProviderId = targetProviderIds[0];
+
+ if (!targetProviderId) {
setScheduleState({
kind: EDIT_SCAN_SCHEDULE_STATE.ERROR,
message: "Provider ID is not available.",
@@ -342,7 +397,7 @@ export function DataTableRowActions({
setScheduleState({ kind: EDIT_SCAN_SCHEDULE_STATE.LOADING });
setIsScheduleOpen(true);
- const response = (await getSchedule(providerId)) as
+ const response = (await getSchedule(targetProviderId)) as
| ScheduleApiResponse
| { error?: string };
@@ -367,38 +422,81 @@ export function DataTableRowActions({
if (hasSelection && isRowSelected) {
const bulkCount =
testableProviderIds.length > 1 ? ` (${testableProviderIds.length})` : "";
+ const selectedScheduleProviderCount = selectedScheduleProviderIds.length;
return (
-
-
- }
- label={loading ? "Testing..." : `Test Connection${bulkCount}`}
- onSelect={(e) => {
- e.preventDefault();
- handleTestConnection();
- }}
- disabled={testableProviderIds.length === 0 || loading}
- />
-
-
+ <>
+
+
+
+ {canEditSchedule && selectedScheduleProviderCount > 0 && (
+ }
+ label={`Edit Scan Schedule (${selectedScheduleProviderCount})`}
+ onSelect={() =>
+ void openScheduleEditor(
+ selectedScheduleProviders,
+ selectedScheduleProviderIds,
+ )
+ }
+ />
+ )}
+ }
+ label={loading ? "Testing..." : `Test Connection${bulkCount}`}
+ onSelect={(e) => {
+ e.preventDefault();
+ handleTestConnection();
+ }}
+ disabled={testableProviderIds.length === 0 || loading}
+ />
+
+
+ >
);
}
// Organization / Organization Unit row actions
if (isProvidersOrganizationRow(rowData) && orgGroupKind) {
return (
-
+ <>
+
+
+ void openScheduleEditor(
+ childScheduleProviders,
+ childScheduleProviderIds,
+ )
+ }
+ />
+ >
);
}
diff --git a/ui/components/providers/wizard/provider-wizard-modal.tsx b/ui/components/providers/wizard/provider-wizard-modal.tsx
index 8904b35e43..ec6f60fe64 100644
--- a/ui/components/providers/wizard/provider-wizard-modal.tsx
+++ b/ui/components/providers/wizard/provider-wizard-modal.tsx
@@ -8,6 +8,7 @@ import { OrgSetupForm } from "@/components/providers/organizations/org-setup-for
import { Button } from "@/components/shadcn/button/button";
import { DialogHeader, DialogTitle } from "@/components/shadcn/dialog";
import { Modal } from "@/components/shadcn/modal";
+import { useScanScheduleCapability } from "@/hooks/use-scan-schedule-capability";
import { useScrollHint } from "@/hooks/use-scroll-hint";
import { advanceActiveTour, endActiveTour } from "@/lib/tours/use-driver-tour";
import { ORG_SETUP_PHASE, ORG_WIZARD_STEP } from "@/types/organizations";
@@ -89,6 +90,10 @@ export function ProviderWizardModal({
enabled: open,
refreshToken: scrollHintRefreshToken,
});
+ const {
+ capability: resolvedScanScheduleCapability,
+ isScheduleCapabilityLoading,
+ } = useScanScheduleCapability(scanScheduleCapability);
const docsDestination = getProviderWizardDocsDestination(docsLink);
return (
@@ -200,8 +205,9 @@ export function ProviderWizardModal({
onBack={() => setCurrentStep(PROVIDER_WIZARD_STEP.TEST)}
onClose={handleClose}
onFooterChange={setFooterConfig}
- capability={scanScheduleCapability}
+ capability={resolvedScanScheduleCapability}
isScanLimitReached={isScanLimitReached}
+ isScheduleCapabilityLoading={isScheduleCapabilityLoading}
/>
)}
@@ -255,8 +261,9 @@ export function ProviderWizardModal({
setOrgCurrentStep(ORG_WIZARD_STEP.VALIDATE);
}}
onFooterChange={setFooterConfig}
- capability={scanScheduleCapability}
+ capability={resolvedScanScheduleCapability}
isScanLimitReached={isScanLimitReached}
+ isScheduleCapabilityLoading={isScheduleCapabilityLoading}
/>
)}
diff --git a/ui/components/providers/wizard/steps/launch-step.test.tsx b/ui/components/providers/wizard/steps/launch-step.test.tsx
index cfa8e2e63d..22802e6424 100644
--- a/ui/components/providers/wizard/steps/launch-step.test.tsx
+++ b/ui/components/providers/wizard/steps/launch-step.test.tsx
@@ -295,6 +295,36 @@ describe("LaunchStep", () => {
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)", () => {
diff --git a/ui/components/providers/wizard/steps/launch-step.tsx b/ui/components/providers/wizard/steps/launch-step.tsx
index db42d5be0e..7e2f0348dd 100644
--- a/ui/components/providers/wizard/steps/launch-step.tsx
+++ b/ui/components/providers/wizard/steps/launch-step.tsx
@@ -67,6 +67,11 @@ interface LaunchStepProps {
* 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({
@@ -75,6 +80,7 @@ export function LaunchStep({
onFooterChange,
capability: capabilityProp,
isScanLimitReached = false,
+ isScheduleCapabilityLoading = false,
}: LaunchStepProps) {
const { toast } = useToast();
const { providerAlias, providerId, providerType, providerUid } =
@@ -82,6 +88,7 @@ export function LaunchStep({
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 [mode, setMode] = useState(
isAdvanced ? LAUNCH_MODE.SCHEDULE : LAUNCH_MODE.NOW,
@@ -93,7 +100,12 @@ export function LaunchStep({
const isScheduleMode = isAdvanced && mode === LAUNCH_MODE.SCHEDULE;
const isLimitBlocked = mode === LAUNCH_MODE.NOW && isScanLimitReached;
- const isActionBlocked = isLaunching || !providerId || isLimitBlocked;
+ const isActionBlocked =
+ isLaunching ||
+ isScheduleCapabilityLoading ||
+ !providerId ||
+ isBlocked ||
+ isLimitBlocked;
const launchInitialScan = useWatch({
control: form.control,
name: "launchInitialScan",
@@ -111,15 +123,21 @@ export function LaunchStep({
return launchInitialScan ? "Save and launch scan" : "Save";
})();
+ useEffect(() => {
+ if (!isAdvanced && mode !== LAUNCH_MODE.NOW) {
+ setMode(LAUNCH_MODE.NOW);
+ }
+ }, [isAdvanced, mode]);
+
const launchOnDemandScan = async (): Promise<{ error?: unknown } | null> => {
- if (!providerId) return null;
+ if (!providerId || isBlocked) return null;
const formData = new FormData();
formData.set("providerId", providerId);
return scanOnDemand(formData);
};
const handleManualScan = async () => {
- if (isScanLimitReached) {
+ if (isActionBlocked) {
return;
}
@@ -150,7 +168,7 @@ export function LaunchStep({
};
const handleSaveSchedule = form.handleSubmit(async (values) => {
- if (!providerId) {
+ if (!providerId || isBlocked || isScheduleCapabilityLoading) {
return;
}
@@ -207,6 +225,10 @@ export function LaunchStep({
// 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;
@@ -218,7 +240,7 @@ export function LaunchStep({
onFooterChange({
showBack: true,
backLabel: "Back",
- backDisabled: isLaunching,
+ backDisabled: isLaunching || isScheduleCapabilityLoading,
onBack,
showAction: true,
actionLabel,
@@ -229,6 +251,7 @@ export function LaunchStep({
}, [
isActionBlocked,
isLaunching,
+ isScheduleCapabilityLoading,
actionLabel,
isScheduleMode,
launchInitialScan,
@@ -237,13 +260,17 @@ export function LaunchStep({
onFooterChange,
]);
- if (isLaunching) {
+ if (isLaunching || isScheduleCapabilityLoading) {
return (
- {!isScheduleMode ? "Launching scan..." : "Saving scan schedule..."}
+ {isScheduleCapabilityLoading
+ ? "Loading scan options..."
+ : !isScheduleMode
+ ? "Launching scan..."
+ : "Saving scan schedule..."}
@@ -284,7 +311,11 @@ export function LaunchStep({
aria-label="Scan mode"
>