feat(ui): add organization bulk scan scheduling (#11643)

This commit is contained in:
Alejandro Bailo
2026-06-22 16:21:35 +02:00
committed by GitHub
parent 45cfe4e411
commit 5ee8b9680d
34 changed files with 2253 additions and 247 deletions
+113 -2
View File
@@ -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" });
+52 -1
View File
@@ -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<SchedulesBulkResponse> => {
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." };
@@ -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"]);
});
});
@@ -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<string>();
+4
View File
@@ -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}
/>
);
};
@@ -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<typeof vi.fn>) =>
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(
<OrgLaunchScan
onClose={vi.fn()}
onBack={vi.fn()}
onFooterChange={onFooterChange}
/>,
);
render(
<OrgLaunchScan
onClose={vi.fn()}
onBack={vi.fn()}
onFooterChange={onFooterChange}
capability={SCAN_SCHEDULE_CAPABILITY.ADVANCED}
/>,
);
// 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(
<OrgLaunchScan
onClose={vi.fn()}
onBack={vi.fn()}
onFooterChange={onFooterChange}
capability={SCAN_SCHEDULE_CAPABILITY.ADVANCED}
/>,
);
// 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(
<OrgLaunchScan
onClose={vi.fn()}
onBack={vi.fn()}
onFooterChange={onFooterChange}
capability={SCAN_SCHEDULE_CAPABILITY.ADVANCED}
isScheduleCapabilityLoading
/>,
);
// 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(
<OrgLaunchScan
onClose={onClose}
onBack={vi.fn()}
onFooterChange={onFooterChange}
capability={SCAN_SCHEDULE_CAPABILITY.ADVANCED}
/>,
);
// 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(
<OrgLaunchScan
onClose={onClose}
onBack={vi.fn()}
onFooterChange={onFooterChange}
capability={SCAN_SCHEDULE_CAPABILITY.ADVANCED}
/>,
);
// 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(
<OrgLaunchScan
onClose={vi.fn()}
onBack={vi.fn()}
onFooterChange={onFooterChange}
capability={SCAN_SCHEDULE_CAPABILITY.ADVANCED}
/>,
);
// 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(
<OrgLaunchScan
onClose={vi.fn()}
onBack={vi.fn()}
onFooterChange={onFooterChange}
capability={SCAN_SCHEDULE_CAPABILITY.MANUAL_ONLY}
/>,
);
render(
<OrgLaunchScan
onClose={vi.fn()}
onBack={vi.fn()}
onFooterChange={onFooterChange}
capability={SCAN_SCHEDULE_CAPABILITY.DAILY_LEGACY}
/>,
);
// 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(
<OrgLaunchScan
onClose={vi.fn()}
onBack={vi.fn()}
onFooterChange={onFooterChange}
capability={SCAN_SCHEDULE_CAPABILITY.MANUAL_ONLY}
isScanLimitReached
/>,
);
render(
<OrgLaunchScan
onClose={vi.fn()}
onBack={vi.fn()}
onFooterChange={onFooterChange}
capability={SCAN_SCHEDULE_CAPABILITY.MANUAL_ONLY}
/>,
);
// 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(
<OrgLaunchScan
onClose={vi.fn()}
onBack={vi.fn()}
onFooterChange={onFooterChange}
capability={SCAN_SCHEDULE_CAPABILITY.BLOCKED}
/>,
);
// 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();
});
});
});
@@ -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<ScanScheduleOption>(
SCAN_SCHEDULE.DAILY,
);
const form = useForm<ScheduleFormValues>({
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: (
<ToastAction altText="Go to scans" asChild>
<Link href={getScansHref(targetTab)}>Go to scans</Link>
</ToastAction>
),
});
});
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: (
<ToastAction altText="Go to scans" asChild>
<Link href="/scans">Go to scans</Link>
<Link href={getScansHref(targetTab)}>Go to scans</Link>
</ToastAction>
),
});
};
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({
</div>
</div>
{isLaunching ? (
{isLaunching || isScheduleCapabilityLoading ? (
<div className="flex min-h-[220px] items-center justify-center">
<div className="flex items-center gap-3 py-2">
<Spinner className="size-6" />
<p className="text-sm font-medium">Launching scans...</p>
<p className="text-sm font-medium">
{isScheduleCapabilityLoading
? "Loading scan options..."
: isAdvanced
? "Saving scan schedules..."
: "Launching scans..."}
</p>
</div>
</div>
) : (
@@ -177,20 +360,26 @@ export function OrgLaunchScan({
</p>
)}
{isManualOnly ? (
{isBlocked ? (
<p className="text-text-error-primary text-sm">
You have reached your scan limit, so additional scans are not
available right now.
</p>
) : isAdvanced ? (
<ScanScheduleFields
form={form}
disabled={isLaunching}
showLaunchInitialScan
showNextScheduledCopy
/>
) : isManualOnly ? (
<div className="flex flex-col gap-3">
<p className="text-text-neutral-secondary text-sm">
Scheduled scans are not available for trial accounts. These
accounts will run a one-time manual scan now.
</p>
{isScanLimitReached && (
<p className="text-text-error-primary text-sm">
You have reached your scan limit, so additional scans are not
available right now.
</p>
)}
</div>
) : (
) : isDailyLegacy ? (
<div className="flex flex-col gap-4">
<p className="text-text-neutral-secondary text-sm">
Select a Prowler scan schedule for these accounts.
@@ -215,7 +404,7 @@ export function OrgLaunchScan({
</SelectContent>
</Select>
</div>
)}
) : null}
</div>
)}
</div>
@@ -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<string, boolean>,
},
getColumnProvidersMock: vi.fn((..._args: unknown[]) => []),
}));
vi.mock("@/components/ui/table", () => ({
DataTable: ({
onRowSelectionChange,
}: {
onRowSelectionChange?: (selection: Record<string, boolean>) => void;
}) => (
<div data-testid="providers-data-table">
<button
type="button"
onClick={() => onRowSelectionChange?.(dataTableMockState.nextSelection)}
>
Apply selection
</button>
</div>
),
}));
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(
<ProvidersAccountsTable
isCloud
metadata={metadata}
rows={[]}
scanScheduleCapability={SCAN_SCHEDULE_CAPABILITY.MANUAL_ONLY}
onOpenProviderWizard={vi.fn()}
onOpenOrganizationWizard={vi.fn()}
/>,
);
// 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(
<ProvidersAccountsTable
isCloud
metadata={metadata}
rows={[providerOne]}
scanScheduleCapability={SCAN_SCHEDULE_CAPABILITY.ADVANCED}
onOpenProviderWizard={vi.fn()}
onOpenOrganizationWizard={vi.fn()}
/>,
);
// 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(
<ProvidersAccountsTable
isCloud
metadata={metadata}
rows={[organizationRow]}
scanScheduleCapability={SCAN_SCHEDULE_CAPABILITY.ADVANCED}
onOpenProviderWizard={vi.fn()}
onOpenOrganizationWizard={vi.fn()}
/>,
);
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,
);
});
});
@@ -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<string>, ids: string[]) {
for (const id of ids) {
if (seen.has(id)) continue;
seen.add(id);
target.push(id);
}
}
function appendUniqueProvider(
target: ScanScheduleProvider[],
seen: Set<string>,
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<string>();
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<string>();
const seenVisibleProviders = new Set<string>();
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<RowSelectionState>({});
// 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 <ProvidersAccountsTableContent key={currentPage} {...props} />;
}
@@ -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: () => <div data-testid="providers-table">Table</div>,
ProvidersAccountsTable: (props: { scanScheduleCapability?: string }) => {
providersAccountsTableSpy(props);
return <div data-testid="providers-table">Table</div>;
},
}));
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(
<ProvidersAccountsView
isCloud
filters={filters}
metadata={metadata}
providers={disconnectedProviders}
rows={rows}
scanScheduleCapability={SCAN_SCHEDULE_CAPABILITY.MANUAL_ONLY}
/>,
);
// 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();
@@ -152,6 +152,7 @@ export function ProvidersAccountsView({
isCloud={isCloud}
metadata={metadata}
rows={rows}
scanScheduleCapability={scanScheduleCapability}
onOpenProviderWizard={openProviderWizard}
onOpenOrganizationWizard={openOrganizationWizard}
/>
@@ -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<ProvidersTableRow>[]): 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<ProvidersTableRow>[] {
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}
/>
);
},
@@ -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 ? (
<div role="dialog" aria-label="Edit Scan Schedule">
Editing schedule for {provider?.providerId}
Editing schedule for{" "}
{providers ? `${providers.length} providers` : provider?.providerId}
</div>
) : 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(
<DataTableRowActions
row={createRow(true)}
hasSelection={false}
isRowSelected={false}
testableProviderIds={[]}
onClearSelection={vi.fn()}
onOpenProviderWizard={vi.fn()}
onOpenOrganizationWizard={vi.fn()}
capability={SCAN_SCHEDULE_CAPABILITY.MANUAL_ONLY}
/>,
);
// 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(
<DataTableRowActions
row={createRow(true)}
hasSelection={false}
isRowSelected={false}
testableProviderIds={[]}
onClearSelection={vi.fn()}
onOpenProviderWizard={vi.fn()}
onOpenOrganizationWizard={vi.fn()}
capability={SCAN_SCHEDULE_CAPABILITY.BLOCKED}
/>,
);
// 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(
<DataTableRowActions
row={createOrgRow()}
hasSelection={false}
isRowSelected={false}
testableProviderIds={[]}
onClearSelection={vi.fn()}
onOpenProviderWizard={vi.fn()}
onOpenOrganizationWizard={vi.fn()}
capability={SCAN_SCHEDULE_CAPABILITY.ADVANCED}
/>,
);
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(
<DataTableRowActions
row={createOrgRow()}
hasSelection={true}
isRowSelected={true}
testableProviderIds={["provider-child-1", "provider-standalone"]}
selectedScheduleProviderIds={[
"provider-child-1",
"provider-child-2",
"provider-standalone",
]}
selectedScheduleProviders={[
{
providerId: "provider-child-1",
providerType: "aws",
providerUid: "111",
providerAlias: null,
},
{
providerId: "provider-standalone",
providerType: "aws",
providerUid: "999",
providerAlias: "Standalone",
},
]}
onClearSelection={vi.fn()}
onOpenProviderWizard={vi.fn()}
onOpenOrganizationWizard={vi.fn()}
capability={SCAN_SCHEDULE_CAPABILITY.ADVANCED}
/>,
);
// 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(
@@ -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<void>;
onTestChildConnections: () => Promise<void>;
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 && (
<ActionDropdownItem
icon={<CalendarClock />}
label="Edit Scan Schedule"
onSelect={() => onOpenScheduleEditor()}
disabled={scheduleProviderCount === 0}
/>
)}
<ActionDropdownItem
icon={<Rocket />}
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 (
<div className="relative flex items-center justify-end gap-2">
<ActionDropdown>
<ActionDropdownItem
icon={<Rocket />}
label={loading ? "Testing..." : `Test Connection${bulkCount}`}
onSelect={(e) => {
e.preventDefault();
handleTestConnection();
}}
disabled={testableProviderIds.length === 0 || loading}
/>
</ActionDropdown>
</div>
<>
<EditScanScheduleModal
open={isScheduleOpen}
onOpenChange={setIsScheduleOpen}
providers={selectedScheduleProviders}
providerIds={selectedScheduleProviderIds}
targetName="Selected providers"
state={scheduleState}
onSaved={onClearSelection}
/>
<div className="relative flex items-center justify-end gap-2">
<ActionDropdown>
{canEditSchedule && selectedScheduleProviderCount > 0 && (
<ActionDropdownItem
icon={<CalendarClock />}
label={`Edit Scan Schedule (${selectedScheduleProviderCount})`}
onSelect={() =>
void openScheduleEditor(
selectedScheduleProviders,
selectedScheduleProviderIds,
)
}
/>
)}
<ActionDropdownItem
icon={<Rocket />}
label={loading ? "Testing..." : `Test Connection${bulkCount}`}
onSelect={(e) => {
e.preventDefault();
handleTestConnection();
}}
disabled={testableProviderIds.length === 0 || loading}
/>
</ActionDropdown>
</div>
</>
);
}
// Organization / Organization Unit row actions
if (isProvidersOrganizationRow(rowData) && orgGroupKind) {
return (
<OrgGroupDropdownActions
rowData={rowData}
loading={loading}
hasSelection={hasSelection}
testableProviderIds={testableProviderIds}
childTestableIds={childTestableIds}
onClearSelection={onClearSelection}
onBulkTest={handleBulkTest}
onTestChildConnections={handleTestChildConnections}
onOpenOrganizationWizard={onOpenOrganizationWizard}
/>
<>
<EditScanScheduleModal
open={isScheduleOpen}
onOpenChange={setIsScheduleOpen}
providers={childScheduleProviders}
providerIds={childScheduleProviderIds}
targetName={rowData.name}
targetId={rowData.externalId ?? undefined}
state={scheduleState}
/>
<OrgGroupDropdownActions
rowData={rowData}
loading={loading}
canEditSchedule={canEditSchedule}
hasSelection={hasSelection}
testableProviderIds={testableProviderIds}
childTestableIds={childTestableIds}
scheduleProviderCount={childScheduleProviderIds.length}
onClearSelection={onClearSelection}
onBulkTest={handleBulkTest}
onTestChildConnections={handleTestChildConnections}
onOpenOrganizationWizard={onOpenOrganizationWizard}
onOpenScheduleEditor={() =>
void openScheduleEditor(
childScheduleProviders,
childScheduleProviderIds,
)
}
/>
</>
);
}
@@ -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}
/>
)}
@@ -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(
<LaunchStep
onBack={vi.fn()}
onClose={vi.fn()}
onFooterChange={onFooterChange}
capability={SCAN_SCHEDULE_CAPABILITY.ADVANCED}
isScheduleCapabilityLoading
/>,
);
// 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)", () => {
@@ -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<LaunchMode>(
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 (
<div className="flex min-h-[320px] items-center justify-center">
<div className="flex items-center gap-3 py-2">
<Spinner className="size-6" />
<p className="text-sm font-medium">
{!isScheduleMode ? "Launching scan..." : "Saving scan schedule..."}
{isScheduleCapabilityLoading
? "Loading scan options..."
: !isScheduleMode
? "Launching scan..."
: "Saving scan schedule..."}
</p>
</div>
</div>
@@ -284,7 +311,11 @@ export function LaunchStep({
aria-label="Scan mode"
>
<label className="flex items-center gap-2 text-sm">
<RadioGroupItem value={LAUNCH_MODE.NOW} aria-label="Run now" />
<RadioGroupItem
value={LAUNCH_MODE.NOW}
aria-label="Run now"
disabled={isBlocked}
/>
Run now
</label>
<label className="flex items-center gap-2 text-sm">
@@ -295,6 +326,7 @@ export function LaunchStep({
/>
On a schedule
{!isAdvanced &&
!isBlocked &&
(isManualOnly ? (
<CloudFeatureBadge label="Requires subscription" size="sm" />
) : (
@@ -304,14 +336,14 @@ export function LaunchStep({
</RadioGroup>
</Field>
{!isAdvanced && (
{!isAdvanced && !isBlocked && (
<p className="text-text-neutral-secondary text-sm">
Scheduled scans are not available for this account. Run now to get
immediate findings.
</p>
)}
{isLimitBlocked && (
{(isLimitBlocked || isBlocked) && (
<p className="text-text-error-primary text-sm">
You have reached your scan limit, so additional scans are not
available right now.
@@ -432,6 +432,28 @@ describe("LaunchScanModal", () => {
expect(getScheduleMock).not.toHaveBeenCalled();
});
it("hides schedule mode but allows manual scans in MANUAL_ONLY", async () => {
const user = userEvent.setup();
render(
<LaunchScanModal
open
onOpenChange={vi.fn()}
providers={[provider]}
capability={SCAN_SCHEDULE_CAPABILITY.MANUAL_ONLY}
/>,
);
expect(screen.queryByRole("radio")).not.toBeInTheDocument();
await user.selectOptions(screen.getByLabelText("Providers"), provider.id);
await user.click(screen.getByRole("button", { name: /launch scan/i }));
await waitFor(() => expect(scanOnDemandMock).toHaveBeenCalledTimes(1));
expect(getScheduleMock).not.toHaveBeenCalled();
expect(updateScheduleMock).not.toHaveBeenCalled();
});
it("hides the mode selector and blocks over-limit accounts in MANUAL_ONLY", () => {
render(
<LaunchScanModal
@@ -449,5 +471,31 @@ describe("LaunchScanModal", () => {
screen.getByRole("button", { name: /launch scan/i }),
).toBeDisabled();
});
it("blocks scans and schedules in BLOCKED", async () => {
const user = userEvent.setup();
render(
<LaunchScanModal
open
onOpenChange={vi.fn()}
providers={[provider]}
capability={SCAN_SCHEDULE_CAPABILITY.BLOCKED}
/>,
);
expect(screen.queryByRole("radio")).not.toBeInTheDocument();
expect(screen.getByText(/reached your scan limit/i)).toBeInTheDocument();
await user.selectOptions(screen.getByLabelText("Providers"), provider.id);
await user.click(screen.getByRole("button", { name: /launch scan/i }));
expect(
screen.getByRole("button", { name: /launch scan/i }),
).toBeDisabled();
expect(scanOnDemandMock).not.toHaveBeenCalled();
expect(getScheduleMock).not.toHaveBeenCalled();
expect(updateScheduleMock).not.toHaveBeenCalled();
});
});
});
+14 -8
View File
@@ -109,7 +109,10 @@ function LaunchScanForm({
const isAdvanced = capability === SCAN_SCHEDULE_CAPABILITY.ADVANCED;
const isManualOnly = capability === SCAN_SCHEDULE_CAPABILITY.MANUAL_ONLY;
const isScheduleMode = mode === LAUNCH_MODE.SCHEDULE;
const isBlocked =
capability === SCAN_SCHEDULE_CAPABILITY.BLOCKED ||
(isManualOnly && isScanLimitReached);
const isScheduleMode = isAdvanced && mode === LAUNCH_MODE.SCHEDULE;
// useWatch, not form.watch: form.watch re-renders are dropped by React Compiler memoization.
const providerId = useWatch({ control: form.control, name: "providerId" });
@@ -152,11 +155,14 @@ function LaunchScanForm({
};
const handleModeChange = (nextMode: string) => {
if (nextMode === LAUNCH_MODE.SCHEDULE && !isAdvanced) return;
setMode(nextMode as LaunchMode);
if (nextMode === LAUNCH_MODE.SCHEDULE) void loadSchedule(providerId);
};
const launchNow = form.handleSubmit(async ({ providerId, scanAlias }) => {
if (isBlocked) return;
const formData = new FormData();
formData.set("providerId", providerId);
const trimmedAlias = scanAlias?.trim();
@@ -192,6 +198,8 @@ function LaunchScanForm({
});
const saveSchedule = async () => {
if (isBlocked || !isAdvanced) return;
const providerValid = await form.trigger("providerId");
if (!providerValid) return;
@@ -233,6 +241,8 @@ function LaunchScanForm({
const onSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (isBlocked) return;
if (isScheduleMode) {
void saveSchedule();
return;
@@ -246,7 +256,6 @@ function LaunchScanForm({
const isSubmitting =
form.formState.isSubmitting || scheduleForm.formState.isSubmitting;
const isScheduleLoading = scheduleLoad === SCHEDULE_LOAD_STATE.LOADING;
const isLimitBlocked = isManualOnly && isScanLimitReached;
return (
// min-w-0: let this dialog grid item shrink so a long provider UID truncates instead of widening the modal
@@ -273,7 +282,7 @@ function LaunchScanForm({
{providerError && <FieldError>{providerError}</FieldError>}
</Field>
{!isManualOnly && (
{!isManualOnly && !isBlocked && (
<Field>
<FieldLabel>Mode</FieldLabel>
<RadioGroup
@@ -299,7 +308,7 @@ function LaunchScanForm({
</Field>
)}
{isLimitBlocked && (
{isBlocked && (
<p className="text-text-error-primary text-sm">
You have reached your scan limit, so additional scans are not
available right now.
@@ -355,10 +364,7 @@ function LaunchScanForm({
}
loadingText={isScheduleMode ? "Saving..." : "Launching..."}
isDisabled={
isSubmitting ||
!providers.length ||
isScheduleLoading ||
isLimitBlocked
isSubmitting || !providers.length || isScheduleLoading || isBlocked
}
rightIcon={<Rocket className="size-4" />}
/>
@@ -2,13 +2,19 @@ import { render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
const { refreshMock, removeScheduleMock, toastMock, updateScheduleMock } =
vi.hoisted(() => ({
refreshMock: vi.fn(),
removeScheduleMock: vi.fn(),
toastMock: vi.fn(),
updateScheduleMock: vi.fn(),
}));
const {
refreshMock,
removeScheduleMock,
toastMock,
updateScheduleMock,
updateSchedulesBulkMock,
} = vi.hoisted(() => ({
refreshMock: vi.fn(),
removeScheduleMock: vi.fn(),
toastMock: vi.fn(),
updateScheduleMock: vi.fn(),
updateSchedulesBulkMock: vi.fn(),
}));
vi.mock("next/navigation", () => ({
useRouter: () => ({ refresh: refreshMock }),
@@ -17,14 +23,44 @@ vi.mock("next/navigation", () => ({
vi.mock("@/actions/schedules", () => ({
removeSchedule: removeScheduleMock,
updateSchedule: updateScheduleMock,
updateSchedulesBulk: updateSchedulesBulkMock,
}));
vi.mock("@/components/ui/toast", () => ({
toast: toastMock,
}));
vi.mock("@/components/icons/providers-badge/provider-type-icon", () => ({
ProviderTypeIconStack: ({ items }: { items: Array<{ type: string }> }) => (
<div data-testid="provider-type-icon-stack">
{items.map((item) => (
<span key={item.type}>{item.type}</span>
))}
</div>
),
}));
vi.mock("@/components/ui/entities", () => ({
EntityInfo: () => null,
EntityInfo: ({
badge,
cloudProvider,
entityAlias,
icon,
}: {
badge?: string;
cloudProvider?: string;
entityAlias?: string;
icon?: React.ReactNode;
}) => (
<div data-testid="entity-info">
{cloudProvider && (
<span data-testid="single-cloud-provider">{cloudProvider}</span>
)}
{icon}
{entityAlias && <span>{entityAlias}</span>}
{badge && <span>{badge}</span>}
</div>
),
}));
vi.mock("@/components/shadcn/modal", () => ({
@@ -58,6 +94,32 @@ const provider = {
providerAlias: "Production",
};
const organizationProviders = [
provider,
{
providerId: "p2",
providerType: "aws" as const,
providerUid: "210987654321",
providerAlias: "Staging",
},
];
const multiCloudProviders = [
provider,
{
providerId: "p2",
providerType: "azure" as const,
providerUid: "azure-subscription",
providerAlias: "Azure Prod",
},
{
providerId: "p3",
providerType: "aws" as const,
providerUid: "210987654321",
providerAlias: "AWS Staging",
},
];
const schedule: ScheduleProps = {
type: "schedules",
id: "p1",
@@ -89,6 +151,7 @@ describe("EditScanScheduleModal remove flow", () => {
beforeEach(() => {
vi.clearAllMocks();
removeScheduleMock.mockResolvedValue({ success: true });
updateSchedulesBulkMock.mockResolvedValue({ success: true });
});
it("asks for confirmation before removing the schedule", async () => {
@@ -162,4 +225,89 @@ describe("EditScanScheduleModal remove flow", () => {
expect(removeScheduleMock).not.toHaveBeenCalled();
});
it("saves organization schedules through the bulk endpoint", async () => {
const user = userEvent.setup();
render(
<EditScanScheduleModal
open
onOpenChange={vi.fn()}
providers={organizationProviders}
targetName="My AWS Organization"
targetId="o-abc123def4"
state={{ kind: EDIT_SCAN_SCHEDULE_STATE.LOADED, schedule }}
/>,
);
await user.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() =>
expect(updateSchedulesBulkMock).toHaveBeenCalledWith(
["p1", "p2"],
expect.objectContaining({
scan_enabled: true,
scan_frequency: "DAILY",
scan_hour: 4,
}),
),
);
expect(updateScheduleMock).not.toHaveBeenCalled();
expect(toastMock).toHaveBeenCalledWith(
expect.objectContaining({
description: "The scan schedule was updated for 2 providers.",
}),
);
});
it("uses explicit provider ids for organization bulk schedules", async () => {
const user = userEvent.setup();
render(
<EditScanScheduleModal
open
onOpenChange={vi.fn()}
providers={[organizationProviders[0]]}
providerIds={["p1", "p2", "p3"]}
targetName="My AWS Organization"
targetId="o-abc123def4"
state={{ kind: EDIT_SCAN_SCHEDULE_STATE.LOADED, schedule }}
/>,
);
await user.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() =>
expect(updateSchedulesBulkMock).toHaveBeenCalledWith(
["p1", "p2", "p3"],
expect.objectContaining({
scan_enabled: true,
scan_frequency: "DAILY",
scan_hour: 4,
}),
),
);
expect(toastMock).toHaveBeenCalledWith(
expect.objectContaining({
description: "The scan schedule was updated for 3 providers.",
}),
);
});
it("shows one logo per selected provider type in bulk mode", () => {
render(
<EditScanScheduleModal
open
onOpenChange={vi.fn()}
providers={multiCloudProviders}
targetName="Selected providers"
state={{ kind: EDIT_SCAN_SCHEDULE_STATE.LOADED, schedule }}
/>,
);
const stack = screen.getByTestId("provider-type-icon-stack");
expect(stack).toHaveTextContent("aws");
expect(stack).toHaveTextContent("azure");
expect(
screen.queryByTestId("single-cloud-provider"),
).not.toBeInTheDocument();
});
});
@@ -6,12 +6,21 @@ import { useRouter } from "next/navigation";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { removeSchedule, updateSchedule } from "@/actions/schedules";
import {
removeSchedule,
updateSchedule,
updateSchedulesBulk,
} from "@/actions/schedules";
import {
ProviderTypeIconStack,
type ProviderTypeIconStackItem,
} from "@/components/icons/providers-badge/provider-type-icon";
import { Button, FieldError } from "@/components/shadcn";
import { Modal } from "@/components/shadcn/modal";
import { EntityInfo } from "@/components/ui/entities";
import { FormButtons } from "@/components/ui/form";
import { toast } from "@/components/ui/toast";
import { runWithConcurrencyLimit } from "@/lib/concurrency";
import {
buildScheduleUpdatePayload,
getScheduleFormValues,
@@ -19,17 +28,13 @@ import {
scheduleFormSchema,
} from "@/lib/schedules";
import type { ProviderType, ScheduleProps } from "@/types";
import type { ScheduleFormValues } from "@/types/schedules";
import type {
ScanScheduleProvider,
ScheduleFormValues,
} from "@/types/schedules";
import { ScanScheduleFields } from "./scan-schedule-fields";
export interface ScanScheduleProvider {
providerId: string;
providerType: ProviderType;
providerUid: string;
providerAlias: string | null;
}
export const EDIT_SCAN_SCHEDULE_STATE = {
LOADING: "loading",
LOADED: "loaded",
@@ -45,22 +50,56 @@ export type EditScanScheduleState =
| { kind: typeof EDIT_SCAN_SCHEDULE_STATE.ERROR; message: string };
interface EditScanScheduleFormProps {
provider: ScanScheduleProvider;
provider?: ScanScheduleProvider;
providers?: ScanScheduleProvider[];
providerIds?: string[];
targetName?: string;
targetId?: string;
schedule: ScheduleProps | null;
onClose: () => void;
onSaved?: () => void;
}
interface EditScanScheduleModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
provider?: ScanScheduleProvider;
providers?: ScanScheduleProvider[];
providerIds?: string[];
targetName?: string;
targetId?: string;
state: EditScanScheduleState;
onSaved?: () => void;
}
function getBulkProviderTypeIconItems(
providers: ScanScheduleProvider[],
): ProviderTypeIconStackItem[] {
const seen = new Set<ProviderType>();
const items: ProviderTypeIconStackItem[] = [];
for (const provider of providers) {
if (seen.has(provider.providerType)) continue;
seen.add(provider.providerType);
items.push({
key: provider.providerType,
type: provider.providerType,
tooltip: provider.providerType,
});
}
return items;
}
function EditScanScheduleForm({
provider,
providers,
providerIds,
targetName,
targetId,
schedule,
onClose,
onSaved,
}: EditScanScheduleFormProps) {
const router = useRouter();
const [isConfirmRemoveOpen, setIsConfirmRemoveOpen] = useState(false);
@@ -72,12 +111,23 @@ function EditScanScheduleForm({
const hasSchedule = schedule
? isScheduleConfigured(schedule.attributes)
: false;
const targetProviders = providers ?? (provider ? [provider] : []);
const targetProviderIds =
providerIds ?? targetProviders.map((target) => target.providerId);
const referenceProvider = targetProviders[0];
const isBulk = providers !== undefined || providerIds !== undefined;
const bulkProviderTypeIconItems = isBulk
? getBulkProviderTypeIconItems(targetProviders)
: [];
const providerCountLabel = `${targetProviderIds.length} provider${
targetProviderIds.length === 1 ? "" : "s"
}`;
const onSubmit = form.handleSubmit(async (values) => {
const result = await updateSchedule(
provider.providerId,
buildScheduleUpdatePayload(values),
);
const payload = buildScheduleUpdatePayload(values);
const result = isBulk
? await updateSchedulesBulk(targetProviderIds, payload)
: await updateSchedule(targetProviderIds[0], payload);
if (result?.error) {
form.setError("root", { message: String(result.error) });
@@ -86,27 +136,38 @@ function EditScanScheduleForm({
toast({
title: "Scan schedule saved",
description: "The scan schedule was updated successfully.",
description: isBulk
? `The scan schedule was updated for ${providerCountLabel}.`
: "The scan schedule was updated successfully.",
});
onSaved?.();
onClose();
router.refresh();
});
const handleRemove = async () => {
setIsRemoving(true);
const result = await removeSchedule(provider.providerId);
const results = await runWithConcurrencyLimit(
targetProviderIds,
10,
(providerId) => removeSchedule(providerId),
);
setIsRemoving(false);
setIsConfirmRemoveOpen(false);
if (result?.error) {
form.setError("root", { message: String(result.error) });
const failedResult = results.find((result) => result?.error);
if (failedResult?.error) {
form.setError("root", { message: String(failedResult.error) });
return;
}
toast({
title: "Scan schedule removed",
description: "The scan schedule was removed successfully.",
description: isBulk
? `The scan schedule was removed for ${providerCountLabel}.`
: "The scan schedule was removed successfully.",
});
onSaved?.();
onClose();
router.refresh();
};
@@ -116,11 +177,30 @@ function EditScanScheduleForm({
return (
<form onSubmit={onSubmit} className="flex flex-col gap-8">
<EntityInfo
cloudProvider={provider.providerType}
entityAlias={provider.providerAlias ?? provider.providerUid}
entityId={provider.providerUid}
/>
{referenceProvider && (
<EntityInfo
cloudProvider={isBulk ? undefined : referenceProvider.providerType}
icon={
isBulk && bulkProviderTypeIconItems.length > 0 ? (
<ProviderTypeIconStack
items={bulkProviderTypeIconItems}
max={bulkProviderTypeIconItems.length}
size={35}
className="flex-wrap"
/>
) : undefined
}
entityAlias={
isBulk
? (targetName ?? providerCountLabel)
: (referenceProvider.providerAlias ??
referenceProvider.providerUid)
}
entityId={isBulk ? targetId : referenceProvider.providerUid}
idLabel={isBulk ? "ID" : "UID"}
badge={isBulk ? providerCountLabel : undefined}
/>
)}
<ScanScheduleFields
form={form}
@@ -154,7 +234,11 @@ function EditScanScheduleForm({
open={isConfirmRemoveOpen}
onOpenChange={setIsConfirmRemoveOpen}
title="Are you absolutely sure?"
description="This action cannot be undone. The scan schedule for this provider will be removed and scans will no longer run automatically."
description={
isBulk
? `This action cannot be undone. The scan schedule for these ${providerCountLabel} will be removed and scans will no longer run automatically.`
: "This action cannot be undone. The scan schedule for this provider will be removed and scans will no longer run automatically."
}
>
<div className="flex w-full justify-end gap-4">
<Button
@@ -186,9 +270,21 @@ export function EditScanScheduleModal({
open,
onOpenChange,
provider,
providers,
providerIds,
targetName,
targetId,
state,
onSaved,
}: EditScanScheduleModalProps) {
const close = () => onOpenChange(false);
const hasTarget = Boolean(
provider || providers?.length || providerIds?.length,
);
const keyPrefix =
provider?.providerId ??
providerIds?.join(":") ??
providers?.map((item) => item.providerId).join(":");
return (
<Modal
@@ -214,12 +310,17 @@ export function EditScanScheduleModal({
</div>
)}
{state.kind === EDIT_SCAN_SCHEDULE_STATE.LOADED && provider && (
{state.kind === EDIT_SCAN_SCHEDULE_STATE.LOADED && hasTarget && (
<EditScanScheduleForm
key={`${provider.providerId}-${state.schedule?.attributes.scan_hour ?? "none"}`}
key={`${keyPrefix}-${state.schedule?.attributes.scan_hour ?? "none"}`}
provider={provider}
providers={providers}
providerIds={providerIds}
targetName={targetName}
targetId={targetId}
schedule={state.schedule}
onClose={close}
onSaved={onSaved}
/>
)}
</Modal>
@@ -59,10 +59,16 @@ vi.mock("@/components/ui/table", () => ({
}));
vi.mock("./scan-jobs-row-actions", () => ({
ScanJobsRowActions: () => <button type="button" />,
ScanJobsRowActions: ({ capability }: { capability?: string }) => (
<button type="button">{capability ?? "no-capability"}</button>
),
}));
import { SCAN_JOBS_TAB, type ScanJobsTab } from "@/types";
import {
SCAN_SCHEDULE_CAPABILITY,
type ScanScheduleCapability,
} from "@/types/schedules";
import { getScanJobsColumns } from "./scan-jobs-columns";
@@ -107,9 +113,11 @@ const renderCell = (
columnId: string,
scan: ScanProps,
tab: ScanJobsTab = SCAN_JOBS_TAB.COMPLETED,
capability?: ScanScheduleCapability,
) => {
const column = getScanJobsColumns({
tab,
capability,
}).find((item) => item.id === columnId);
const cell = column?.cell as
| ((context: CellContext<ScanProps, unknown>) => React.ReactNode)
@@ -201,4 +209,19 @@ describe("getScanJobsColumns", () => {
expect(screen.getByText("Schedule")).toBeInTheDocument();
expect(screen.queryByText("Scheduled")).not.toBeInTheDocument();
});
it("passes scan schedule capability to row actions", () => {
renderCell(
"actions",
makeCompletedScan(),
SCAN_JOBS_TAB.COMPLETED,
SCAN_SCHEDULE_CAPABILITY.MANUAL_ONLY,
);
expect(
screen.getByRole("button", {
name: SCAN_SCHEDULE_CAPABILITY.MANUAL_ONLY,
}),
).toBeVisible();
});
});
+28 -12
View File
@@ -7,6 +7,7 @@ import { DataTableColumnHeader } from "@/components/ui/table";
import { StatusBadge } from "@/components/ui/table/status-badge";
import { formatLocalDate, formatLocalTimeWithZone } from "@/lib/date-utils";
import { SCAN_JOBS_TAB, type ScanJobsTab, type ScanProps } from "@/types";
import type { ScanScheduleCapability } from "@/types/schedules";
import { formatScanDuration } from "../scans.utils";
import {
@@ -20,6 +21,7 @@ import { ScanJobsRowActions } from "./scan-jobs-row-actions";
interface GetScanJobsColumnsOptions {
tab: ScanJobsTab;
capability?: ScanScheduleCapability;
}
const accountColumn: ColumnDef<ScanProps> = {
@@ -116,12 +118,16 @@ const resourcesColumn: ColumnDef<ScanProps> = {
enableSorting: false,
};
const actionsColumn: ColumnDef<ScanProps> = {
const actionsColumn = (
capability?: ScanScheduleCapability,
): ColumnDef<ScanProps> => ({
id: "actions",
header: ({ column }) => <DataTableColumnHeader column={column} title="" />,
cell: ({ row }) => <ScanJobsRowActions scan={row.original} />,
cell: ({ row }) => (
<ScanJobsRowActions scan={row.original} capability={capability} />
),
enableSorting: false,
};
});
const durationColumn: ColumnDef<ScanProps> = {
id: "duration",
@@ -132,7 +138,9 @@ const durationColumn: ColumnDef<ScanProps> = {
enableSorting: false,
};
const activeColumns = (): ColumnDef<ScanProps>[] => [
const activeColumns = (
capability?: ScanScheduleCapability,
): ColumnDef<ScanProps>[] => [
accountColumn,
scanInfoColumn,
{
@@ -156,10 +164,12 @@ const activeColumns = (): ColumnDef<ScanProps>[] => [
),
enableSorting: false,
},
actionsColumn,
actionsColumn(capability),
];
const completedColumns = (): ColumnDef<ScanProps>[] => [
const completedColumns = (
capability?: ScanScheduleCapability,
): ColumnDef<ScanProps>[] => [
accountColumn,
scanInfoColumn,
resourcesColumn,
@@ -185,22 +195,28 @@ const completedColumns = (): ColumnDef<ScanProps>[] => [
),
cell: ({ row }) => renderDateCell(row.original.attributes.completed_at),
},
actionsColumn,
actionsColumn(capability),
];
const scheduledColumns = (): ColumnDef<ScanProps>[] => [
const scheduledColumns = (
capability?: ScanScheduleCapability,
): ColumnDef<ScanProps>[] => [
accountColumn,
scanInfoColumn,
scheduledScanScheduleColumn,
nextScanColumn,
lastScanColumn,
actionsColumn,
actionsColumn(capability),
];
export function getScanJobsColumns(
options: GetScanJobsColumnsOptions,
): ColumnDef<ScanProps>[] {
if (options.tab === SCAN_JOBS_TAB.SCHEDULED) return scheduledColumns();
if (options.tab === SCAN_JOBS_TAB.ACTIVE) return activeColumns();
return completedColumns();
if (options.tab === SCAN_JOBS_TAB.SCHEDULED) {
return scheduledColumns(options.capability);
}
if (options.tab === SCAN_JOBS_TAB.ACTIVE) {
return activeColumns(options.capability);
}
return completedColumns(options.capability);
}
@@ -3,6 +3,7 @@ import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { ScanProps } from "@/types";
import { SCAN_SCHEDULE_CAPABILITY } from "@/types/schedules";
import { ScanJobsRowActions } from "./scan-jobs-row-actions";
@@ -187,6 +188,52 @@ describe("ScanJobsRowActions", () => {
).not.toBeInTheDocument();
});
it("hides Edit Scan Schedule for manual-only Cloud scan rows", async () => {
// Given
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true");
const user = userEvent.setup();
render(
<ScanJobsRowActions
scan={makeScan()}
capability={SCAN_SCHEDULE_CAPABILITY.MANUAL_ONLY}
/>,
);
// When
await user.click(
screen.getByRole("button", { name: /open actions menu/i }),
);
// Then
expect(
screen.queryByRole("menuitem", { name: /edit scan schedule/i }),
).not.toBeInTheDocument();
});
it("hides Edit Scan Schedule for blocked Cloud scan rows", async () => {
// Given
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true");
const user = userEvent.setup();
render(
<ScanJobsRowActions
scan={makeScan()}
capability={SCAN_SCHEDULE_CAPABILITY.BLOCKED}
/>,
);
// When
await user.click(
screen.getByRole("button", { name: /open actions menu/i }),
);
// Then
expect(
screen.queryByRole("menuitem", { name: /edit scan schedule/i }),
).not.toBeInTheDocument();
});
it("links completed scans to compliance from the actions menu", async () => {
// Given
const user = userEvent.setup();
@@ -23,7 +23,6 @@ import {
EDIT_SCAN_SCHEDULE_STATE,
EditScanScheduleModal,
type EditScanScheduleState,
type ScanScheduleProvider,
} from "@/components/scans/schedule/edit-scan-schedule-modal";
import {
ActionDropdown,
@@ -38,6 +37,7 @@ import type { ProviderType, ScanProps, ScheduleApiResponse } from "@/types";
import {
SCAN_SCHEDULE_CAPABILITY,
type ScanScheduleCapability,
type ScanScheduleProvider,
} from "@/types/schedules";
interface ScanJobsRowActionsProps {
@@ -2,9 +2,14 @@ import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { SCAN_JOBS_TAB, type ScanProps } from "@/types";
import { SCAN_SCHEDULE_CAPABILITY } from "@/types/schedules";
import { ScanJobsTable } from "./scan-jobs-table";
const { getScanJobsColumnsMock } = vi.hoisted(() => ({
getScanJobsColumnsMock: vi.fn((_options: unknown) => []),
}));
vi.mock("@/components/ui/table", () => ({
DataTable: ({ data }: { data: ScanProps[] }) => (
<div data-testid="scan-jobs-data-table">{data.length}</div>
@@ -12,7 +17,7 @@ vi.mock("@/components/ui/table", () => ({
}));
vi.mock("./scan-jobs-columns", () => ({
getScanJobsColumns: () => [],
getScanJobsColumns: (options: unknown) => getScanJobsColumnsMock(options),
}));
vi.mock("../auto-refresh", () => ({
@@ -96,4 +101,20 @@ describe("ScanJobsTable", () => {
screen.queryByTestId("no-scans-empty-state"),
).not.toBeInTheDocument();
});
it("passes scan schedule capability to scan job columns", () => {
render(
<ScanJobsTable
data={[]}
tab={SCAN_JOBS_TAB.ACTIVE}
hasFilters
scanScheduleCapability={SCAN_SCHEDULE_CAPABILITY.MANUAL_ONLY}
/>,
);
expect(getScanJobsColumnsMock).toHaveBeenCalledWith({
tab: SCAN_JOBS_TAB.ACTIVE,
capability: SCAN_SCHEDULE_CAPABILITY.MANUAL_ONLY,
});
});
});
@@ -3,6 +3,7 @@
import { DataTable } from "@/components/ui/table";
import type { MetaDataProps, ScanJobsTab, ScanProps } from "@/types";
import { SCAN_JOBS_TAB } from "@/types";
import type { ScanScheduleCapability } from "@/types/schedules";
import { AutoRefresh } from "../auto-refresh";
import { NoScansEmptyState } from "../no-scans-empty-state";
@@ -13,6 +14,7 @@ interface ScanJobsTableProps {
meta?: MetaDataProps;
tab: ScanJobsTab;
hasFilters?: boolean;
scanScheduleCapability?: ScanScheduleCapability;
}
const REFRESHING_STATES = ["available", "executing"] as const;
@@ -22,13 +24,17 @@ export function ScanJobsTable({
meta,
tab,
hasFilters = false,
scanScheduleCapability,
}: ScanJobsTableProps) {
const hasRefreshingScan = data.some((scan) =>
REFRESHING_STATES.includes(
scan.attributes.state as (typeof REFRESHING_STATES)[number],
),
);
const columns = getScanJobsColumns({ tab });
const columns = getScanJobsColumns({
tab,
capability: scanScheduleCapability,
});
const showEmptyState = data.length === 0 && !hasFilters;
return (
@@ -162,6 +162,7 @@ describe("DataTable", () => {
expect(actionsCell).toHaveClass("right-0");
expect(actionsCell).toHaveClass("z-20");
expect(actionsCell).toHaveClass("bg-bg-neutral-secondary");
expect(actionsCell).toHaveClass("last:rounded-r-none!");
expect(actionsCell).not.toHaveClass("border-l");
expect(actionsCell).toHaveClass("before:bg-gradient-to-r");
expect(actionsCell).toHaveClass("before:from-transparent");
+1 -1
View File
@@ -46,7 +46,7 @@ type DataTableRowAttributes = {
const DEFAULT_COLUMN_SIZE = 150;
const ACTIONS_COLUMN_ID = "actions";
const STICKY_ACTION_COLUMN_CLASS = "sticky right-0 z-20 min-w-12";
const STICKY_ACTION_CELL_CLASS = `${STICKY_ACTION_COLUMN_CLASS} overflow-visible bg-bg-neutral-secondary before:pointer-events-none before:absolute before:inset-y-0 before:-left-8 before:w-8 before:bg-gradient-to-r before:from-transparent before:to-bg-neutral-secondary before:content-[''] group-hover:bg-bg-neutral-tertiary group-hover:before:to-bg-neutral-tertiary group-data-[state=selected]:bg-bg-neutral-tertiary group-data-[state=selected]:before:to-bg-neutral-tertiary`;
const STICKY_ACTION_CELL_CLASS = `${STICKY_ACTION_COLUMN_CLASS} last:rounded-r-none! overflow-visible bg-bg-neutral-secondary before:pointer-events-none before:absolute before:inset-y-0 before:-left-8 before:w-8 before:bg-gradient-to-r before:from-transparent before:to-bg-neutral-secondary before:content-[''] group-hover:bg-bg-neutral-tertiary group-hover:before:to-bg-neutral-tertiary group-data-[state=selected]:bg-bg-neutral-tertiary group-data-[state=selected]:before:to-bg-neutral-tertiary`;
const getStickyActionColumnClassName = (
columnId: string,
@@ -0,0 +1,53 @@
import { renderHook } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { SCAN_SCHEDULE_CAPABILITY } from "@/types/schedules";
import { useScanScheduleCapability } from "./use-scan-schedule-capability";
describe("useScanScheduleCapability", () => {
afterEach(() => {
vi.unstubAllEnvs();
});
it("returns DAILY_LEGACY for OSS without loading", () => {
// Given
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false");
// When
const { result } = renderHook(() => useScanScheduleCapability());
// Then
expect(result.current).toEqual({
capability: SCAN_SCHEDULE_CAPABILITY.DAILY_LEGACY,
isScheduleCapabilityLoading: false,
});
});
it("returns ADVANCED for Cloud env without loading", () => {
// Given
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true");
// When
const { result } = renderHook(() => useScanScheduleCapability());
// Then
expect(result.current).toEqual({
capability: SCAN_SCHEDULE_CAPABILITY.ADVANCED,
isScheduleCapabilityLoading: false,
});
});
it("honors explicit capability overrides", () => {
// Given / When
const { result } = renderHook(() =>
useScanScheduleCapability(SCAN_SCHEDULE_CAPABILITY.MANUAL_ONLY),
);
// Then
expect(result.current).toEqual({
capability: SCAN_SCHEDULE_CAPABILITY.MANUAL_ONLY,
isScheduleCapabilityLoading: false,
});
});
});
+17
View File
@@ -0,0 +1,17 @@
import { getScanScheduleCapability } from "@/lib/schedules";
import { isCloud } from "@/lib/shared/env";
import type { ScanScheduleCapability } from "@/types/schedules";
interface UseScanScheduleCapabilityResult {
capability: ScanScheduleCapability;
isScheduleCapabilityLoading: boolean;
}
export function useScanScheduleCapability(
capabilityOverride?: ScanScheduleCapability,
): UseScanScheduleCapabilityResult {
return {
capability: capabilityOverride ?? getScanScheduleCapability(isCloud()),
isScheduleCapabilityLoading: false,
};
}
+10
View File
@@ -29,6 +29,16 @@ export const scheduleFormSchema = z.object({
launchInitialScan: z.boolean(),
});
export const scheduleUpdatePayloadSchema = z.object({
scan_enabled: z.boolean(),
scan_frequency: z.enum(SCHEDULE_FREQUENCY),
scan_hour: z.number().int().min(0).max(23),
scan_timezone: z.string().min(1),
scan_interval_hours: z.number().int().min(SCAN_INTERVAL_HOURS_MIN).nullable(),
scan_day_of_week: z.number().int().min(0).max(6).nullable(),
scan_day_of_month: z.number().int().min(1).max(28).nullable(),
});
/**
* Default scan-schedule capability for the current environment.
*
+1
View File
@@ -67,6 +67,7 @@ export interface ProvidersOrganizationRow {
parentExternalId: string | null;
organizationId: string | null;
providerCount: number;
providerIds: string[];
subRows: ProvidersTableRow[];
}
+40 -3
View File
@@ -1,4 +1,4 @@
import type { ProviderProps } from "./providers";
import type { ProviderProps, ProviderType } from "./providers";
export const SCHEDULE_FREQUENCY = {
DAILY: "DAILY",
@@ -26,17 +26,21 @@ export const SCHEDULE_WEEKDAY_LABELS = [
* the runtime environment (Cloud vs non-Cloud); the prowler-cloud overlay
* computes a billing-aware capability and injects it via the `capability` prop.
*
* - `ADVANCED`: full scheduling through the new `/schedules/{providerId}` API
* (Prowler Cloud, subscribed/paid).
* - `ADVANCED`: full scheduling through the new schedules API —
* `/schedules/{providerId}` for a single provider, `/schedules/bulk` for the
* organization flow (Prowler Cloud, subscribed/paid).
* - `DAILY_LEGACY`: Prowler OSS / non-Cloud. Only the legacy `Daily` schedule
* (`/schedules/daily`) plus optional on-demand scans are allowed.
* - `MANUAL_ONLY`: Prowler Cloud trial/onboarding. No schedules at all, only a
* manual on-demand scan subject to the account quota.
* - `BLOCKED`: Prowler Cloud account over the scan limit. No scan or schedule
* action is available.
*/
export const SCAN_SCHEDULE_CAPABILITY = {
ADVANCED: "ADVANCED",
DAILY_LEGACY: "DAILY_LEGACY",
MANUAL_ONLY: "MANUAL_ONLY",
BLOCKED: "BLOCKED",
} as const;
export type ScanScheduleCapability =
@@ -87,6 +91,39 @@ export interface ScheduleUpdatePayload {
scan_day_of_month: number | null;
}
/** Per-provider failure, as returned by `/schedules/bulk`: `{ id, error }`. */
export interface SchedulesBulkFailure {
id: string;
error: string;
}
export interface SchedulesBulkAttributes {
/** Provider ids whose schedule was committed (already excludes failures). */
updated?: string[];
failed?: SchedulesBulkFailure[];
}
export interface SchedulesBulkData {
type: "schedules-bulk";
id?: string;
attributes?: SchedulesBulkAttributes;
}
export interface SchedulesBulkResponse {
data?: SchedulesBulkData;
error?: unknown;
errors?: unknown;
status?: number;
}
/** Minimal provider identity needed to render and target schedule actions. */
export interface ScanScheduleProvider {
providerId: string;
providerType: ProviderType;
providerUid: string;
providerAlias: string | null;
}
export interface ScheduleFormValues {
frequency: ScheduleFrequency;
hour: number;