diff --git a/ui/actions/schedules/schedules.test.ts b/ui/actions/schedules/schedules.test.ts index 9e05799116..d676e12d67 100644 --- a/ui/actions/schedules/schedules.test.ts +++ b/ui/actions/schedules/schedules.test.ts @@ -1,6 +1,9 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { SCHEDULE_FREQUENCY } from "@/types/schedules"; +import { + SCHEDULE_FREQUENCY, + type ScheduleUpdatePayload, +} from "@/types/schedules"; const { fetchMock, @@ -30,9 +33,15 @@ vi.mock("@/lib/server-actions-helper", () => ({ handleApiResponse: handleApiResponseMock, })); -import { getSchedule, removeSchedule, updateSchedule } from "./schedules"; +import { + getSchedule, + removeSchedule, + updateSchedule, + updateSchedulesBulk, +} from "./schedules"; const PROVIDER_ID = "1795f636-37e6-42f6-b158-d4faaa64e0fc"; +const SECOND_PROVIDER_ID = "9b7fae7d-5e72-49fe-8b0b-5bff5930db1a"; const payload = { scan_enabled: true, @@ -61,6 +70,108 @@ describe("schedule write actions revalidate only on success", () => { expect(revalidatePathMock).toHaveBeenCalledWith("/providers"); }); + it("posts the JSON:API bulk schedule payload", async () => { + handleApiResponseMock.mockResolvedValue({ + data: { + type: "schedules-bulk", + attributes: { + updated: [PROVIDER_ID, SECOND_PROVIDER_ID], + failed: [], + }, + }, + }); + + await updateSchedulesBulk([PROVIDER_ID, SECOND_PROVIDER_ID], payload); + + expect(fetchMock).toHaveBeenCalledWith( + "https://api.example.com/api/v1/schedules/bulk", + expect.objectContaining({ + method: "POST", + headers: { Authorization: "Bearer token" }, + body: JSON.stringify({ + data: { + type: "schedules-bulk", + attributes: { + schedule: payload, + provider_ids: [PROVIDER_ID, SECOND_PROVIDER_ID], + }, + }, + }), + }), + ); + }); + + it("rejects invalid bulk provider ids without issuing a request", async () => { + expect( + await updateSchedulesBulk([PROVIDER_ID, "../users/me"], payload), + ).toEqual({ + error: "Invalid provider ids.", + }); + expect(await updateSchedulesBulk([], payload)).toEqual({ + error: "Invalid provider ids.", + }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("rejects invalid bulk schedule payloads without issuing a request", async () => { + const invalidPayload = { + ...payload, + scan_hour: "12", + } as unknown as ScheduleUpdatePayload; + + expect( + await updateSchedulesBulk( + [PROVIDER_ID, SECOND_PROVIDER_ID], + invalidPayload, + ), + ).toEqual({ + error: "Invalid schedule payload.", + }); + expect(getAuthHeadersMock).not.toHaveBeenCalled(); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("normalizes bulk auth header errors", async () => { + const authError = new Error("Session expired"); + getAuthHeadersMock.mockRejectedValue(authError); + + expect( + await updateSchedulesBulk([PROVIDER_ID, SECOND_PROVIDER_ID], payload), + ).toEqual({ error: "Failed" }); + expect(handleApiErrorMock).toHaveBeenCalledWith(authError); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("revalidates /scans and /providers after a partial bulk success", async () => { + handleApiResponseMock.mockResolvedValue({ + data: { + type: "schedules-bulk", + attributes: { + updated: [PROVIDER_ID], + failed: [{ provider_id: SECOND_PROVIDER_ID, error: "Denied" }], + }, + }, + }); + + const result = await updateSchedulesBulk( + [PROVIDER_ID, SECOND_PROVIDER_ID], + payload, + ); + + expect(result.data?.attributes?.updated).toEqual([PROVIDER_ID]); + expect(result.data?.attributes?.failed).toHaveLength(1); + expect(revalidatePathMock).toHaveBeenCalledWith("/scans"); + expect(revalidatePathMock).toHaveBeenCalledWith("/providers"); + }); + + it("does not revalidate when the bulk update returns an error result", async () => { + handleApiResponseMock.mockResolvedValue({ error: "Bulk rejected" }); + + await updateSchedulesBulk([PROVIDER_ID, SECOND_PROVIDER_ID], payload); + + expect(revalidatePathMock).not.toHaveBeenCalled(); + }); + it("does not revalidate when the update returns an error result", async () => { handleApiResponseMock.mockResolvedValue({ error: "Schedule rejected" }); diff --git a/ui/actions/schedules/schedules.ts b/ui/actions/schedules/schedules.ts index c8e0d9993f..6b197823eb 100644 --- a/ui/actions/schedules/schedules.ts +++ b/ui/actions/schedules/schedules.ts @@ -4,8 +4,13 @@ import { revalidatePath } from "next/cache"; import { z } from "zod"; import { apiBaseUrl, getAuthHeaders } from "@/lib"; +import { scheduleUpdatePayloadSchema } from "@/lib/schedules"; import { handleApiError, handleApiResponse } from "@/lib/server-actions-helper"; -import type { ScheduleProps, ScheduleUpdatePayload } from "@/types/schedules"; +import type { + ScheduleProps, + SchedulesBulkResponse, + ScheduleUpdatePayload, +} from "@/types/schedules"; // SSRF guard: the id is interpolated into the request URL, so only UUIDs pass. const providerIdSchema = z.uuid(); @@ -15,6 +20,13 @@ function parseProviderId(providerId: string): string | null { return parsed.success ? parsed.data : null; } +function parseProviderIds(providerIds: string[]): string[] | null { + if (providerIds.length === 0) return null; + + const ids = providerIds.map(parseProviderId); + return ids.every((id): id is string => id !== null) ? ids : null; +} + function revalidateScheduleViews() { revalidatePath("/scans"); revalidatePath("/providers"); @@ -104,6 +116,45 @@ export const updateSchedule = async ( } }; +export const updateSchedulesBulk = async ( + providerIds: string[], + payload: ScheduleUpdatePayload, +): Promise => { + const ids = parseProviderIds(providerIds); + if (!ids) return { error: "Invalid provider ids." }; + + const parsedPayload = scheduleUpdatePayloadSchema.safeParse(payload); + if (!parsedPayload.success) return { error: "Invalid schedule payload." }; + + try { + const headers = await getAuthHeaders({ contentType: true }); + const url = new URL(`${apiBaseUrl}/schedules/bulk`); + const body = { + data: { + type: "schedules-bulk", + attributes: { + schedule: parsedPayload.data, + provider_ids: ids, + }, + }, + }; + + const response = await fetch(url.toString(), { + method: "POST", + headers, + body: JSON.stringify(body), + }); + + const result = (await handleApiResponse(response)) as SchedulesBulkResponse; + if (!result?.error) { + revalidateScheduleViews(); + } + return result; + } catch (error) { + return handleApiError(error); + } +}; + export const removeSchedule = async (providerId: string) => { const id = parseProviderId(providerId); if (!id) return { error: "Invalid provider id." }; diff --git a/ui/app/(prowler)/providers/providers-page.utils.test.ts b/ui/app/(prowler)/providers/providers-page.utils.test.ts index b5dcd6d844..5727807d85 100644 --- a/ui/app/(prowler)/providers/providers-page.utils.test.ts +++ b/ui/app/(prowler)/providers/providers-page.utils.test.ts @@ -28,7 +28,10 @@ vi.mock("@/actions/schedules", () => schedulesActionsMock); import { SearchParamsProps } from "@/types"; import { ProvidersApiResponse } from "@/types/providers"; -import { ProvidersProviderRow } from "@/types/providers-table"; +import { + isProvidersOrganizationRow, + ProvidersProviderRow, +} from "@/types/providers-table"; import { SCHEDULE_FREQUENCY, type ScheduleAttributes, @@ -643,13 +646,73 @@ describe("buildProvidersTableRows", () => { // Then expect(rows).toHaveLength(1); - expect(rows[0].rowType).toBe(PROVIDERS_ROW_TYPE.ORGANIZATION); - expect(rows[0].subRows).toHaveLength(2); + const orgRow = rows[0]; + expect(isProvidersOrganizationRow(orgRow)).toBe(true); + if (!isProvidersOrganizationRow(orgRow)) { + throw new Error("Expected organization row"); + } + expect(orgRow.subRows).toHaveLength(2); expect( - rows[0].subRows?.every( + orgRow.subRows?.every( (row) => row.rowType === PROVIDERS_ROW_TYPE.PROVIDER, ), ).toBe(true); + expect(orgRow.providerIds).toEqual(["provider-1", "provider-2"]); + }); + + it("keeps organization relationship provider ids even when providers are not in the visible page", () => { + // Given + const providers = [ + toProviderRow(providersResponse.data[0], { + relationships: { + ...providersResponse.data[0].relationships, + organization: { + data: null, + }, + }, + }), + ]; + + // When + const rows = buildProvidersTableRows({ + providers, + organizations: [ + { + id: "org-1", + type: "organizations", + attributes: { + name: "Large Organization", + org_type: "aws", + external_id: "o-large", + metadata: {}, + root_external_id: "r-large", + }, + relationships: { + providers: { + data: [ + { type: "providers", id: "provider-1" }, + { type: "providers", id: "provider-not-in-page" }, + ], + }, + organizational_units: { + data: [], + }, + }, + }, + ], + organizationUnits: [], + isCloud: true, + }); + + // Then + expect(rows).toHaveLength(1); + const orgRow = rows[0]; + expect(isProvidersOrganizationRow(orgRow)).toBe(true); + if (!isProvidersOrganizationRow(orgRow)) { + throw new Error("Expected organization row"); + } + expect(orgRow.subRows).toHaveLength(1); + expect(orgRow.providerIds).toEqual(["provider-1", "provider-not-in-page"]); }); }); diff --git a/ui/app/(prowler)/providers/providers-page.utils.ts b/ui/app/(prowler)/providers/providers-page.utils.ts index a37d6eb510..32ce2a5321 100644 --- a/ui/app/(prowler)/providers/providers-page.utils.ts +++ b/ui/app/(prowler)/providers/providers-page.utils.ts @@ -183,6 +183,7 @@ const createOrganizationRow = ({ externalId, organizationId, parentExternalId, + providerIds, subRows, }: { externalId: string | null; @@ -191,6 +192,7 @@ const createOrganizationRow = ({ name: string; organizationId: string | null; parentExternalId: string | null; + providerIds: string[]; subRows: ProvidersTableRow[]; }): ProvidersOrganizationRow => ({ id, @@ -200,7 +202,8 @@ const createOrganizationRow = ({ externalId, organizationId, parentExternalId, - providerCount: countProviderRows(subRows), + providerCount: providerIds.length, + providerIds, subRows, }); @@ -234,14 +237,14 @@ function getProviderRowsByIds({ .filter((provider): provider is ProvidersProviderRow => Boolean(provider)); } -function countProviderRows(rows: ProvidersTableRow[]): number { - return rows.reduce((total, row) => { - if (row.rowType === PROVIDERS_ROW_TYPE.PROVIDER) { - return total + 1; - } +function dedupeIds(ids: string[]): string[] { + return Array.from(new Set(ids)); +} - return total + countProviderRows(row.subRows); - }, 0); +function collectOrganizationRowProviderIds( + rows: ProvidersOrganizationRow[], +): string[] { + return dedupeIds(rows.flatMap((row) => row.providerIds)); } function getOrganizationUnitRelationshipId( @@ -308,6 +311,13 @@ function buildOrganizationUnitRows({ ? providerRowsFromRelationships : (providersByOrganizationUnitId.get(organizationUnit.id) ?? []); const subRows = [...childOrganizationUnitRows, ...providerRows]; + const directProviderIds = + providerRowsFromRelationships.length > 0 + ? getRelationshipProviderIds(organizationUnit.relationships) + : providerRows.map((provider) => provider.id); + const childProviderIds = collectOrganizationRowProviderIds( + childOrganizationUnitRows, + ); return createOrganizationRow({ groupKind: PROVIDERS_GROUP_KIND.ORGANIZATION_UNIT, @@ -316,10 +326,13 @@ function buildOrganizationUnitRows({ externalId: organizationUnit.attributes.external_id, organizationId, parentExternalId: organizationUnit.attributes.parent_external_id, + providerIds: dedupeIds([...childProviderIds, ...directProviderIds]), subRows, }); }) - .filter((organizationUnitRow) => organizationUnitRow.subRows.length > 0); + .filter( + (organizationUnitRow) => organizationUnitRow.providerIds.length > 0, + ); } export function buildProvidersTableRows({ @@ -418,6 +431,12 @@ export function buildProvidersTableRows({ (provider) => !providersInOus.has(provider.id), ); const subRows = [...organizationProviders, ...organizationUnitRows]; + const directProviderIds = + organizationProvidersFromRelationships.length > 0 + ? getRelationshipProviderIds(organization.relationships) + : organizationProviders.map((provider) => provider.id); + const organizationUnitProviderIds = + collectOrganizationRowProviderIds(organizationUnitRows); return createOrganizationRow({ groupKind: PROVIDERS_GROUP_KIND.ORGANIZATION, @@ -426,10 +445,14 @@ export function buildProvidersTableRows({ externalId: organization.attributes.external_id, organizationId: organization.id, parentExternalId: organization.attributes.root_external_id, + providerIds: dedupeIds([ + ...directProviderIds, + ...organizationUnitProviderIds, + ]), subRows, }); }) - .filter((organizationRow) => organizationRow.subRows.length > 0); + .filter((organizationRow) => organizationRow.providerIds.length > 0); const assignedProviderIds = new Set(); diff --git a/ui/app/(prowler)/scans/page.tsx b/ui/app/(prowler)/scans/page.tsx index d945380f93..f50126719e 100644 --- a/ui/app/(prowler)/scans/page.tsx +++ b/ui/app/(prowler)/scans/page.tsx @@ -30,6 +30,7 @@ import { ScanProps, SearchParamsProps, } from "@/types"; +import type { ScanScheduleCapability } from "@/types/schedules"; const ACTIVE_SCAN_COUNT_PAGE_SIZE = 1; // Pending schedule rows are derived from provider schedules, but must honor the @@ -244,9 +245,11 @@ export default async function Scans({ const SSRDataTableScans = async ({ searchParams, providers, + scanScheduleCapability, }: { searchParams: SearchParamsProps; providers: ProviderProps[]; + scanScheduleCapability?: ScanScheduleCapability; }) => { const tab = getScanJobsTab(searchParams.tab); @@ -359,6 +362,7 @@ const SSRDataTableScans = async ({ meta={tableMeta} tab={tab} hasFilters={hasUserFilters} + scanScheduleCapability={scanScheduleCapability} /> ); }; diff --git a/ui/components/providers/organizations/org-launch-scan.test.tsx b/ui/components/providers/organizations/org-launch-scan.test.tsx index f7ef851a0c..9d98cef5af 100644 --- a/ui/components/providers/organizations/org-launch-scan.test.tsx +++ b/ui/components/providers/organizations/org-launch-scan.test.tsx @@ -1,22 +1,37 @@ import { act, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import type { ComponentProps } from "react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { useOrgSetupStore } from "@/store/organizations/store"; -import { SCAN_SCHEDULE_CAPABILITY } from "@/types/schedules"; +import { + SCAN_JOBS_TAB, + SCAN_SCHEDULE_CAPABILITY, + SCHEDULE_FREQUENCY, +} from "@/types"; import { OrgLaunchScan } from "./org-launch-scan"; -const { launchOrganizationScansMock, pushMock, toastMock } = vi.hoisted(() => ({ +const { + launchOrganizationScansMock, + pushMock, + toastMock, + updateSchedulesBulkMock, +} = vi.hoisted(() => ({ launchOrganizationScansMock: vi.fn(), pushMock: vi.fn(), toastMock: vi.fn(), + updateSchedulesBulkMock: vi.fn(), })); vi.mock("@/actions/scans/scans", () => ({ launchOrganizationScans: launchOrganizationScansMock, })); +vi.mock("@/actions/schedules/schedules", () => ({ + updateSchedulesBulk: updateSchedulesBulkMock, +})); + vi.mock("next/navigation", () => ({ useRouter: () => ({ push: pushMock, @@ -32,117 +47,381 @@ vi.mock("@/components/ui", () => ({ }), })); +const PROVIDER_IDS = ["provider-1", "provider-2"]; + +const lastFooterConfig = (onFooterChange: ReturnType) => + onFooterChange.mock.calls.at(-1)?.[0]; + describe("OrgLaunchScan", () => { beforeEach(() => { + vi.spyOn(Intl, "DateTimeFormat").mockReturnValue({ + resolvedOptions: () => ({ timeZone: "Europe/Madrid" }), + } as Intl.DateTimeFormat); sessionStorage.clear(); localStorage.clear(); launchOrganizationScansMock.mockReset(); pushMock.mockReset(); toastMock.mockReset(); + updateSchedulesBulkMock.mockReset(); + launchOrganizationScansMock.mockResolvedValue({ + successCount: 2, + failureCount: 0, + totalCount: 2, + errors: [], + }); + updateSchedulesBulkMock.mockResolvedValue({ + data: { + type: "schedules-bulk", + attributes: { + updated: PROVIDER_IDS, + failed: [], + }, + }, + }); useOrgSetupStore.getState().reset(); useOrgSetupStore .getState() .setOrganization("org-1", "My Organization", "o-abc123def4"); - useOrgSetupStore.getState().setCreatedProviderIds(["provider-1"]); + useOrgSetupStore.getState().setCreatedProviderIds(PROVIDER_IDS); }); - it("shows a success toast with an action linking to scans", async () => { - // Given - launchOrganizationScansMock.mockResolvedValue({ successCount: 1 }); - const onFooterChange = vi.fn(); + describe("when capability is ADVANCED", () => { + it("should save schedules through the bulk endpoint", async () => { + // Given + const onFooterChange = vi.fn(); - render( - , - ); + render( + , + ); - // When - await waitFor(() => { - expect(onFooterChange).toHaveBeenCalled(); - }); - const footerConfig = onFooterChange.mock.calls.at(-1)?.[0]; - await act(async () => { - footerConfig.onAction?.(); + // When + await screen.findByText("Scan Schedule"); + await act(async () => { + lastFooterConfig(onFooterChange)?.onAction?.(); + }); + + // Then + await waitFor(() => + expect(updateSchedulesBulkMock).toHaveBeenCalledTimes(1), + ); + expect(updateSchedulesBulkMock).toHaveBeenCalledWith( + PROVIDER_IDS, + expect.objectContaining({ + scan_enabled: true, + scan_frequency: SCHEDULE_FREQUENCY.DAILY, + scan_hour: expect.any(Number), + scan_timezone: "Europe/Madrid", + }), + ); + expect(launchOrganizationScansMock).not.toHaveBeenCalled(); + expect(pushMock).toHaveBeenCalledWith("/providers"); + expect( + toastMock.mock.calls[0]?.[0].action.props.children.props.href, + ).toBe(`/scans?tab=${SCAN_JOBS_TAB.SCHEDULED}`); }); - // Then - await waitFor(() => { - expect(toastMock).toHaveBeenCalledTimes(1); + it("should launch initial scans only for updated providers", async () => { + // Given + const user = userEvent.setup(); + const onFooterChange = vi.fn(); + updateSchedulesBulkMock.mockResolvedValue({ + data: { + type: "schedules-bulk", + attributes: { + updated: ["provider-2"], + failed: [{ id: "provider-1", error: "Denied" }], + }, + }, + }); + + render( + , + ); + + // When + await user.click( + await screen.findByRole("checkbox", { + name: /launch an initial scan now/i, + }), + ); + await act(async () => { + lastFooterConfig(onFooterChange)?.onAction?.(); + }); + + // Then + await waitFor(() => + expect(launchOrganizationScansMock).toHaveBeenCalledTimes(1), + ); + expect(launchOrganizationScansMock).toHaveBeenCalledWith( + ["provider-2"], + "single", + ); + expect( + toastMock.mock.calls[0]?.[0].action.props.children.props.href, + ).toBe(`/scans?tab=${SCAN_JOBS_TAB.ACTIVE}`); + }); + + it("should disable launch actions while schedule capability is loading", async () => { + // Given + const onFooterChange = vi.fn(); + + render( + , + ); + + // When + await screen.findByText("Loading scan options..."); + await waitFor(() => expect(onFooterChange).toHaveBeenCalled()); + await act(async () => { + lastFooterConfig(onFooterChange)?.onAction?.(); + }); + + // Then + expect(lastFooterConfig(onFooterChange)?.backDisabled).toBe(true); + expect(lastFooterConfig(onFooterChange)?.actionDisabled).toBe(true); + expect(updateSchedulesBulkMock).not.toHaveBeenCalled(); + expect(launchOrganizationScansMock).not.toHaveBeenCalled(); + }); + + it("should surface an error toast and stay on the wizard when the bulk update fails", async () => { + // Given + const onClose = vi.fn(); + const onFooterChange = vi.fn(); + updateSchedulesBulkMock.mockResolvedValue({ error: "Denied" }); + + render( + , + ); + + // When + await screen.findByText("Scan Schedule"); + await act(async () => { + lastFooterConfig(onFooterChange)?.onAction?.(); + }); + + // Then + await waitFor(() => + expect(toastMock).toHaveBeenCalledWith( + expect.objectContaining({ + variant: "destructive", + title: "Unable to save scan schedules", + }), + ), + ); + expect(launchOrganizationScansMock).not.toHaveBeenCalled(); + expect(pushMock).not.toHaveBeenCalled(); + expect(onClose).not.toHaveBeenCalled(); + }); + + it("should treat a fully-failed bulk response as an error without navigating away", async () => { + // Given + const onClose = vi.fn(); + const onFooterChange = vi.fn(); + updateSchedulesBulkMock.mockResolvedValue({ + data: { + type: "schedules-bulk", + attributes: { + updated: [], + failed: [ + { id: "provider-1", error: "Denied" }, + { id: "provider-2", error: "Denied" }, + ], + }, + }, + }); + + render( + , + ); + + // When + await screen.findByText("Scan Schedule"); + await act(async () => { + lastFooterConfig(onFooterChange)?.onAction?.(); + }); + + // Then + await waitFor(() => + expect(toastMock).toHaveBeenCalledWith( + expect.objectContaining({ + variant: "destructive", + title: "Unable to save scan schedules", + description: "The scan schedule could not be saved for 2 accounts.", + }), + ), + ); + expect(launchOrganizationScansMock).not.toHaveBeenCalled(); + expect(pushMock).not.toHaveBeenCalled(); + expect(onClose).not.toHaveBeenCalled(); + }); + + it("should describe partial failures in the success toast", async () => { + // Given + const onFooterChange = vi.fn(); + updateSchedulesBulkMock.mockResolvedValue({ + data: { + type: "schedules-bulk", + attributes: { + updated: ["provider-2"], + failed: [{ provider_id: "provider-1", error: "Denied" }], + }, + }, + }); + + render( + , + ); + + // When + await screen.findByText("Scan Schedule"); + await act(async () => { + lastFooterConfig(onFooterChange)?.onAction?.(); + }); + + // Then + await waitFor(() => expect(toastMock).toHaveBeenCalled()); + expect(toastMock).toHaveBeenCalledWith( + expect.objectContaining({ + title: "Scan schedules saved", + description: + "The schedule was saved for 1 account, but 1 account could not be updated.", + }), + ); }); - const toastPayload = toastMock.mock.calls[0]?.[0]; - expect(toastPayload.title).toBe("Scan Launched"); - expect(toastPayload.action).toBeDefined(); - expect(toastPayload.action.props.children.props.href).toBe("/scans"); }); - it("uses a single manual scan when schedules are unavailable", async () => { - // Given - launchOrganizationScansMock.mockResolvedValue({ successCount: 1 }); - const onFooterChange = vi.fn(); + describe("when capability is DAILY_LEGACY", () => { + it("should keep the legacy daily scheduling path", async () => { + // Given + const onFooterChange = vi.fn(); - render( - , - ); + render( + , + ); - // Then - expect( - screen.getByText(/scheduled scans are not available for trial accounts/i), - ).toBeInTheDocument(); - expect(screen.queryByRole("combobox")).not.toBeInTheDocument(); + // When + await screen.findByText( + "Select a Prowler scan schedule for these accounts.", + ); + await act(async () => { + lastFooterConfig(onFooterChange)?.onAction?.(); + }); - // When - await waitFor(() => { - expect(onFooterChange).toHaveBeenCalled(); + // Then + await waitFor(() => + expect(launchOrganizationScansMock).toHaveBeenCalledWith( + PROVIDER_IDS, + "daily", + ), + ); + expect(updateSchedulesBulkMock).not.toHaveBeenCalled(); + expect( + toastMock.mock.calls[0]?.[0].action.props.children.props.href, + ).toBe(`/scans?tab=${SCAN_JOBS_TAB.SCHEDULED}`); }); - const footerConfig = onFooterChange.mock.calls.at(-1)?.[0]; - await act(async () => { - footerConfig.onAction?.(); - }); - - // Then - await waitFor(() => { - expect(launchOrganizationScansMock).toHaveBeenCalledTimes(1); - }); - expect(launchOrganizationScansMock).toHaveBeenCalledWith( - ["provider-1"], - "single", - ); }); - it("blocks manual scans when the trial scan limit is reached", async () => { - // Given - const onFooterChange = vi.fn(); + describe("when capability is MANUAL_ONLY", () => { + it("should launch single scans without rendering schedule controls", async () => { + // Given + const onFooterChange = vi.fn(); - render( - , - ); + render( + , + ); - // When - await waitFor(() => { - expect(onFooterChange).toHaveBeenCalled(); - }); - const footerConfig = onFooterChange.mock.calls.at(-1)?.[0]; - await act(async () => { - footerConfig.onAction?.(); + // When + expect( + screen.getByText( + /scheduled scans are not available for trial accounts/i, + ), + ).toBeInTheDocument(); + expect(screen.queryByRole("combobox")).not.toBeInTheDocument(); + await act(async () => { + lastFooterConfig(onFooterChange)?.onAction?.(); + }); + + // Then + await waitFor(() => + expect(launchOrganizationScansMock).toHaveBeenCalledWith( + PROVIDER_IDS, + "single", + ), + ); + expect(updateSchedulesBulkMock).not.toHaveBeenCalled(); + expect( + toastMock.mock.calls[0]?.[0].action.props.children.props.href, + ).toBe(`/scans?tab=${SCAN_JOBS_TAB.ACTIVE}`); }); + }); - // Then - expect(screen.getByText(/reached your scan limit/i)).toBeInTheDocument(); - expect(footerConfig.actionDisabled).toBe(true); - expect(launchOrganizationScansMock).not.toHaveBeenCalled(); + describe("when capability is BLOCKED", () => { + it("should disable the action without calling scans or schedules", async () => { + // Given + const onFooterChange = vi.fn(); + + render( + , + ); + + // When + await waitFor(() => { + expect(lastFooterConfig(onFooterChange)?.actionDisabled).toBe(true); + }); + await act(async () => { + lastFooterConfig(onFooterChange)?.onAction?.(); + }); + + // Then + expect(screen.getByText(/reached your scan limit/i)).toBeInTheDocument(); + expect(updateSchedulesBulkMock).not.toHaveBeenCalled(); + expect(launchOrganizationScansMock).not.toHaveBeenCalled(); + }); }); }); diff --git a/ui/components/providers/organizations/org-launch-scan.tsx b/ui/components/providers/organizations/org-launch-scan.tsx index 6aede38b81..70b0cc46d3 100644 --- a/ui/components/providers/organizations/org-launch-scan.tsx +++ b/ui/components/providers/organizations/org-launch-scan.tsx @@ -1,15 +1,19 @@ "use client"; +import { zodResolver } from "@hookform/resolvers/zod"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { useEffect, useRef, useState } from "react"; +import { useForm, useWatch } from "react-hook-form"; import { launchOrganizationScans } from "@/actions/scans/scans"; +import { updateSchedulesBulk } from "@/actions/schedules/schedules"; import { AWSProviderBadge } from "@/components/icons/providers-badge"; import { WIZARD_FOOTER_ACTION_TYPE, WizardFooterConfig, } from "@/components/providers/wizard/steps/footer-controls"; +import { ScanScheduleFields } from "@/components/scans/schedule/scan-schedule-fields"; import { Select, SelectContent, @@ -20,11 +24,21 @@ import { import { Spinner } from "@/components/shadcn/spinner/spinner"; import { TreeStatusIcon } from "@/components/shadcn/tree-view/tree-status-icon"; import { ToastAction, useToast } from "@/components/ui"; +import { + buildScheduleUpdatePayload, + getScanScheduleCapability, + getScheduleFormDefaults, + scheduleFormSchema, +} from "@/lib/schedules"; +import { isCloud } from "@/lib/shared/env"; import { useOrgSetupStore } from "@/store/organizations/store"; import { + SCAN_JOBS_TAB, SCAN_SCHEDULE_CAPABILITY, type ScanScheduleCapability, -} from "@/types/schedules"; + type ScheduleFormValues, + type SchedulesBulkResponse, +} from "@/types"; import { TREE_ITEM_STATUS } from "@/types/tree"; interface OrgLaunchScanProps { @@ -32,12 +46,17 @@ interface OrgLaunchScanProps { onBack: () => void; onFooterChange: (config: WizardFooterConfig) => void; /** - * Schedule capability override. Prowler Cloud passes MANUAL_ONLY for trial - * tenants so organization onboarding cannot create recurring schedules. + * Schedule capability override. Prowler Cloud passes MANUAL_ONLY/BLOCKED for + * billing-limited tenants; OSS falls back to an environment-based capability. */ capability?: ScanScheduleCapability; /** Cloud-only manual scan quota signal. */ isScanLimitReached?: boolean; + /** + * Cloud-only loading state while billing is resolved into a schedule + * capability. OSS leaves it false. + */ + isScheduleCapabilityLoading?: boolean; } const SCAN_SCHEDULE = { @@ -47,30 +66,182 @@ const SCAN_SCHEDULE = { type ScanScheduleOption = (typeof SCAN_SCHEDULE)[keyof typeof SCAN_SCHEDULE]; +function getStringArray(value: unknown): string[] { + return Array.isArray(value) + ? value.filter((item): item is string => typeof item === "string") + : []; +} + +/** + * Providers whose schedule was actually saved. The backend reports successes + * under `updated`, populated only after each provider's schedule commits, so + * it already excludes failures — no client-side subtraction is needed. + */ +function getUpdatedProviderIds(result: SchedulesBulkResponse): string[] { + return getStringArray(result.data?.attributes?.updated); +} + +function getFailedCount(result: SchedulesBulkResponse): number { + const failed = result.data?.attributes?.failed; + return Array.isArray(failed) ? failed.length : 0; +} + +function formatAccountCount(count: number): string { + return `${count} account${count === 1 ? "" : "s"}`; +} + +function getScansHref(tab: (typeof SCAN_JOBS_TAB)[keyof typeof SCAN_JOBS_TAB]) { + return `/scans?tab=${tab}`; +} + export function OrgLaunchScan({ onClose, onBack, onFooterChange, capability, isScanLimitReached = false, + isScheduleCapabilityLoading = false, }: OrgLaunchScanProps) { const router = useRouter(); const { toast } = useToast(); const { organizationExternalId, createdProviderIds, reset } = useOrgSetupStore(); + const resolvedCapability = capability ?? getScanScheduleCapability(isCloud()); + const isAdvanced = resolvedCapability === SCAN_SCHEDULE_CAPABILITY.ADVANCED; + const isDailyLegacy = + resolvedCapability === SCAN_SCHEDULE_CAPABILITY.DAILY_LEGACY; + const isManualOnly = + resolvedCapability === SCAN_SCHEDULE_CAPABILITY.MANUAL_ONLY; + const isBlocked = + resolvedCapability === SCAN_SCHEDULE_CAPABILITY.BLOCKED || + (isManualOnly && isScanLimitReached); + const [isLaunching, setIsLaunching] = useState(false); const [scheduleOption, setScheduleOption] = useState( SCAN_SCHEDULE.DAILY, ); + const form = useForm({ + resolver: zodResolver(scheduleFormSchema), + defaultValues: getScheduleFormDefaults(), + }); + const launchInitialScan = useWatch({ + control: form.control, + name: "launchInitialScan", + }); const launchActionRef = useRef<() => void>(() => {}); - const isManualOnly = capability === SCAN_SCHEDULE_CAPABILITY.MANUAL_ONLY; + const effectiveScheduleOption = isManualOnly ? SCAN_SCHEDULE.SINGLE : scheduleOption; + const actionDisabled = + isLaunching || + isScheduleCapabilityLoading || + isBlocked || + createdProviderIds.length === 0; + const actionLabel = isAdvanced + ? isLaunching + ? launchInitialScan + ? "Saving and launching..." + : "Saving..." + : launchInitialScan + ? "Save and launch scan" + : "Save" + : isLaunching + ? "Launching scans..." + : "Launch scan"; - const handleLaunchScan = async () => { - if (isManualOnly && isScanLimitReached) { + const finishSuccess = () => { + reset(); + onClose(); + router.push("/providers"); + }; + + const handleAdvancedSchedule = form.handleSubmit(async (values) => { + if (actionDisabled || !isAdvanced) { + return; + } + + setIsLaunching(true); + + const result = await updateSchedulesBulk( + createdProviderIds, + buildScheduleUpdatePayload(values), + ); + + if (result.error) { + setIsLaunching(false); + toast({ + variant: "destructive", + title: "Unable to save scan schedules", + description: String(result.error), + }); + return; + } + + const updatedProviderIds = getUpdatedProviderIds(result); + const failedCount = getFailedCount(result); + + // No provider was actually updated (e.g. the endpoint returned 200 but every + // schedule failed). Surface it as an error and keep the wizard open to retry + // instead of navigating away with a misleading "saved for 0 accounts" toast. + if (updatedProviderIds.length === 0) { + setIsLaunching(false); + toast({ + variant: "destructive", + title: "Unable to save scan schedules", + description: + failedCount > 0 + ? `The scan schedule could not be saved for ${formatAccountCount(failedCount)}.` + : "The scan schedule could not be saved for any account.", + }); + return; + } + + let initialScanFailureCount = 0; + let initialScanSuccessCount = 0; + + if (values.launchInitialScan) { + const scanResult = await launchOrganizationScans( + updatedProviderIds, + SCAN_SCHEDULE.SINGLE, + ); + initialScanFailureCount = scanResult.failureCount; + initialScanSuccessCount = scanResult.successCount; + } + + setIsLaunching(false); + finishSuccess(); + + const updatedCount = updatedProviderIds.length; + const description = + failedCount > 0 + ? `The schedule was saved for ${formatAccountCount(updatedCount)}, but ${formatAccountCount(failedCount)} could not be updated.` + : `The scan schedule was saved for ${formatAccountCount(updatedCount)}.`; + const targetTab = + initialScanSuccessCount > 0 + ? SCAN_JOBS_TAB.ACTIVE + : SCAN_JOBS_TAB.SCHEDULED; + + toast({ + title: + values.launchInitialScan && initialScanFailureCount === 0 + ? "Scan schedules saved and initial scans launched" + : "Scan schedules saved", + description: + initialScanFailureCount > 0 + ? `${description} Initial scans failed for ${formatAccountCount(initialScanFailureCount)}.` + : description, + action: ( + + Go to scans + + ), + }); + }); + + const handleLegacyLaunch = async () => { + if (actionDisabled || isAdvanced) { return; } @@ -81,52 +252,58 @@ export function OrgLaunchScan({ effectiveScheduleOption, ); const successCount = result.successCount; + const targetTab = + effectiveScheduleOption === SCAN_SCHEDULE.SINGLE + ? SCAN_JOBS_TAB.ACTIVE + : SCAN_JOBS_TAB.SCHEDULED; setIsLaunching(false); - reset(); - onClose(); - router.push("/providers"); + finishSuccess(); toast({ title: "Scan Launched", description: effectiveScheduleOption === SCAN_SCHEDULE.DAILY - ? `Daily scan scheduled for ${successCount} account${successCount !== 1 ? "s" : ""}.` - : `Single scan launched for ${successCount} account${successCount !== 1 ? "s" : ""}.`, + ? `Daily scan scheduled for ${formatAccountCount(successCount)}.` + : `Single scan launched for ${formatAccountCount(successCount)}.`, action: ( - Go to scans + Go to scans ), }); }; launchActionRef.current = () => { - void handleLaunchScan(); + if (isAdvanced) { + void handleAdvancedSchedule(); + return; + } + void handleLegacyLaunch(); }; useEffect(() => { onFooterChange({ showBack: true, backLabel: "Back", - backDisabled: isLaunching, + backDisabled: isLaunching || isScheduleCapabilityLoading, onBack, showAction: true, - actionLabel: "Launch scan", - actionDisabled: - isLaunching || - createdProviderIds.length === 0 || - (isManualOnly && isScanLimitReached), + actionLabel, + actionDisabled, actionType: WIZARD_FOOTER_ACTION_TYPE.BUTTON, onAction: () => { launchActionRef.current(); }, }); }, [ + actionDisabled, + actionLabel, createdProviderIds.length, + isAdvanced, isLaunching, - isManualOnly, - isScanLimitReached, + isScheduleCapabilityLoading, + launchInitialScan, onBack, onFooterChange, ]); @@ -149,11 +326,17 @@ export function OrgLaunchScan({ - {isLaunching ? ( + {isLaunching || isScheduleCapabilityLoading ? (
-

Launching scans...

+

+ {isScheduleCapabilityLoading + ? "Loading scan options..." + : isAdvanced + ? "Saving scan schedules..." + : "Launching scans..."} +

) : ( @@ -177,20 +360,26 @@ export function OrgLaunchScan({

)} - {isManualOnly ? ( + {isBlocked ? ( +

+ You have reached your scan limit, so additional scans are not + available right now. +

+ ) : isAdvanced ? ( + + ) : isManualOnly ? (

Scheduled scans are not available for trial accounts. These accounts will run a one-time manual scan now.

- {isScanLimitReached && ( -

- You have reached your scan limit, so additional scans are not - available right now. -

- )}
- ) : ( + ) : isDailyLegacy ? (

Select a Prowler scan schedule for these accounts. @@ -215,7 +404,7 @@ export function OrgLaunchScan({

- )} + ) : null} )} diff --git a/ui/components/providers/providers-accounts-table.test.tsx b/ui/components/providers/providers-accounts-table.test.tsx new file mode 100644 index 0000000000..4fe66a6bd7 --- /dev/null +++ b/ui/components/providers/providers-accounts-table.test.tsx @@ -0,0 +1,306 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import type { MetaDataProps } from "@/types"; +import { + PROVIDERS_GROUP_KIND, + PROVIDERS_ROW_TYPE, + type ProvidersTableRow, +} from "@/types/providers-table"; +import { SCAN_SCHEDULE_CAPABILITY } from "@/types/schedules"; + +const { dataTableMockState, getColumnProvidersMock } = vi.hoisted(() => ({ + dataTableMockState: { + nextSelection: {} as Record, + }, + getColumnProvidersMock: vi.fn((..._args: unknown[]) => []), +})); + +vi.mock("@/components/ui/table", () => ({ + DataTable: ({ + onRowSelectionChange, + }: { + onRowSelectionChange?: (selection: Record) => void; + }) => ( +
+ +
+ ), +})); + +vi.mock("./table", () => ({ + getColumnProviders: (...args: unknown[]) => getColumnProvidersMock(...args), +})); + +import { + computeSelectedScheduleProviders, + ProvidersAccountsTable, +} from "./providers-accounts-table"; + +const metadata: MetaDataProps = { + pagination: { page: 1, pages: 1, count: 0, itemsPerPage: [10] }, + version: "latest", +}; + +const createProviderRow = ( + id: string, + uid = id, + alias: string | null = id, +): ProvidersTableRow => + ({ + id, + rowType: PROVIDERS_ROW_TYPE.PROVIDER, + type: "providers", + attributes: { + provider: "aws", + uid, + alias, + status: "completed", + resources: 0, + connection: { + connected: true, + last_checked_at: "2026-01-01T00:00:00Z", + }, + scanner_args: { + only_logs: false, + excluded_checks: [], + aws_retries_max_attempts: 3, + }, + inserted_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-01T00:00:00Z", + created_by: { + object: "user", + id: "user-1", + }, + }, + relationships: { + secret: { data: { id: `secret-${id}`, type: "secrets" } }, + provider_groups: { meta: { count: 0 }, data: [] }, + }, + groupNames: [], + hasSchedule: false, + }) as ProvidersTableRow; + +const providerOne = createProviderRow("provider-1", "111111111111", "Prod"); +const providerTwo = createProviderRow("provider-2", "222222222222", "Stage"); +const providerThree = createProviderRow("provider-3", "333333333333", "Dev"); + +const organizationRow: ProvidersTableRow = { + id: "org-1", + rowType: PROVIDERS_ROW_TYPE.ORGANIZATION, + groupKind: PROVIDERS_GROUP_KIND.ORGANIZATION, + name: "My AWS Organization", + externalId: "o-abc123def4", + parentExternalId: null, + organizationId: "org-1", + providerCount: 3, + providerIds: ["provider-1", "provider-2", "provider-hidden"], + subRows: [providerOne, providerTwo], +}; + +const organizationalUnitRow: ProvidersTableRow = { + id: "ou-1", + rowType: PROVIDERS_ROW_TYPE.ORGANIZATION, + groupKind: PROVIDERS_GROUP_KIND.ORGANIZATION_UNIT, + name: "Production OU", + externalId: "ou-abc123", + parentExternalId: "o-abc123def4", + organizationId: "org-1", + providerCount: 2, + providerIds: ["provider-2", "provider-hidden-ou"], + subRows: [providerTwo], +}; + +describe("ProvidersAccountsTable", () => { + beforeEach(() => { + vi.clearAllMocks(); + dataTableMockState.nextSelection = {}; + }); + + it("passes scan schedule capability to provider row action columns", () => { + // Given/When + render( + , + ); + + // Then + expect(screen.getByTestId("providers-data-table")).toBeInTheDocument(); + expect(getColumnProvidersMock).toHaveBeenCalledWith( + expect.any(Object), + [], + [], + [], + expect.any(Function), + expect.any(Function), + expect.any(Function), + SCAN_SCHEDULE_CAPABILITY.MANUAL_ONLY, + ); + }); + + describe("schedule provider selection", () => { + it("uses the selected provider id for provider rows", () => { + // Given + const rows = [providerOne, providerTwo]; + + // When + const result = computeSelectedScheduleProviders(rows, { "0": true }); + + // Then + expect(result.providerIds).toEqual(["provider-1"]); + expect(result.providers.map((provider) => provider.providerId)).toEqual([ + "provider-1", + ]); + }); + + it("uses every organization provider id when the organization is selected", () => { + // Given + const rows = [organizationRow]; + + // When + const result = computeSelectedScheduleProviders(rows, { "0": true }); + + // Then + expect(result.providerIds).toEqual([ + "provider-1", + "provider-2", + "provider-hidden", + ]); + expect(result.providers.map((provider) => provider.providerId)).toEqual([ + "provider-1", + "provider-2", + ]); + }); + + it("uses every organizational unit provider id when the OU is selected", () => { + // Given + const rows = [ + { + ...organizationRow, + subRows: [organizationalUnitRow], + }, + ]; + + // When + const result = computeSelectedScheduleProviders(rows, { "0.0": true }); + + // Then + expect(result.providerIds).toEqual(["provider-2", "provider-hidden-ou"]); + }); + + it("deduplicates provider ids when an organization and child provider are selected", () => { + // Given + const rows = [organizationRow]; + + // When + const result = computeSelectedScheduleProviders(rows, { + "0": true, + "0.0": true, + }); + + // Then + expect(result.providerIds).toEqual([ + "provider-1", + "provider-2", + "provider-hidden", + ]); + }); + + it("uses only selected child providers when an organization is partially selected", () => { + // Given + const rows = [organizationRow, providerThree]; + + // When + const result = computeSelectedScheduleProviders(rows, { + "0.1": true, + "1": true, + }); + + // Then + expect(result.providerIds).toEqual(["provider-2", "provider-3"]); + }); + }); + + it("passes selected provider ids to provider row action columns", async () => { + // Given + const user = userEvent.setup(); + dataTableMockState.nextSelection = { "0": true }; + render( + , + ); + + // When + await user.click(screen.getByRole("button", { name: "Apply selection" })); + + // Then + expect(getColumnProvidersMock).toHaveBeenLastCalledWith( + expect.any(Object), + ["provider-1"], + ["provider-1"], + [ + expect.objectContaining({ + providerId: "provider-1", + providerType: "aws", + providerUid: "111111111111", + providerAlias: "Prod", + }), + ], + expect.any(Function), + expect.any(Function), + expect.any(Function), + SCAN_SCHEDULE_CAPABILITY.ADVANCED, + ); + }); + + it("passes selected organization provider ids and visible providers to provider row action columns", async () => { + // Given + const user = userEvent.setup(); + dataTableMockState.nextSelection = { "0": true }; + render( + , + ); + + await user.click(screen.getByRole("button", { name: "Apply selection" })); + + // Then + expect(getColumnProvidersMock).toHaveBeenLastCalledWith( + expect.any(Object), + [], + ["provider-1", "provider-2", "provider-hidden"], + [ + expect.objectContaining({ providerId: "provider-1" }), + expect.objectContaining({ providerId: "provider-2" }), + ], + expect.any(Function), + expect.any(Function), + expect.any(Function), + SCAN_SCHEDULE_CAPABILITY.ADVANCED, + ); + }); +}); diff --git a/ui/components/providers/providers-accounts-table.tsx b/ui/components/providers/providers-accounts-table.tsx index 0c25b609d9..0f51e4dc66 100644 --- a/ui/components/providers/providers-accounts-table.tsx +++ b/ui/components/providers/providers-accounts-table.tsx @@ -1,7 +1,7 @@ "use client"; import { RowSelectionState } from "@tanstack/react-table"; -import { useEffect, useState } from "react"; +import { useState } from "react"; import type { OrgWizardInitialData, @@ -11,8 +11,13 @@ import { DataTable } from "@/components/ui/table"; import { MetaDataProps } from "@/types"; import { isProvidersOrganizationRow, + isProvidersProviderRow, ProvidersTableRow, } from "@/types/providers-table"; +import type { + ScanScheduleCapability, + ScanScheduleProvider, +} from "@/types/schedules"; import { getColumnProviders } from "./table"; @@ -20,6 +25,7 @@ interface ProvidersAccountsTableProps { isCloud: boolean; metadata?: MetaDataProps; rows: ProvidersTableRow[]; + scanScheduleCapability?: ScanScheduleCapability; onOpenProviderWizard: (initialData?: ProviderWizardInitialData) => void; onOpenOrganizationWizard: (initialData: OrgWizardInitialData) => void; } @@ -50,31 +56,132 @@ function computeTestableProviderIds( return ids; } -export function ProvidersAccountsTable({ +function toScanScheduleProvider( + row: ProvidersTableRow, +): ScanScheduleProvider | null { + if (!isProvidersProviderRow(row)) return null; + + return { + providerId: row.id, + providerType: row.attributes.provider, + providerUid: row.attributes.uid, + providerAlias: row.attributes.alias, + }; +} + +function appendUnique(target: string[], seen: Set, ids: string[]) { + for (const id of ids) { + if (seen.has(id)) continue; + seen.add(id); + target.push(id); + } +} + +function appendUniqueProvider( + target: ScanScheduleProvider[], + seen: Set, + provider: ScanScheduleProvider | null, +) { + if (!provider || seen.has(provider.providerId)) return; + seen.add(provider.providerId); + target.push(provider); +} + +function collectVisibleScheduleProviders(rows: ProvidersTableRow[]) { + const providers: ScanScheduleProvider[] = []; + const seen = new Set(); + + function walk(items: ProvidersTableRow[]) { + for (const item of items) { + appendUniqueProvider(providers, seen, toScanScheduleProvider(item)); + if (isProvidersOrganizationRow(item)) { + walk(item.subRows); + } + } + } + + walk(rows); + return providers; +} + +export interface SelectedScheduleProvidersResult { + providerIds: string[]; + providers: ScanScheduleProvider[]; +} + +export function computeSelectedScheduleProviders( + rows: ProvidersTableRow[], + rowSelection: RowSelectionState, +): SelectedScheduleProvidersResult { + const providerIds: string[] = []; + const providers: ScanScheduleProvider[] = []; + const seenProviderIds = new Set(); + const seenVisibleProviders = new Set(); + + function walk(items: ProvidersTableRow[], prefix: string) { + items.forEach((item, idx) => { + const key = prefix ? `${prefix}.${idx}` : `${idx}`; + const isSelected = rowSelection[key] === true; + + if (isProvidersOrganizationRow(item)) { + if (isSelected) { + appendUnique(providerIds, seenProviderIds, item.providerIds); + for (const provider of collectVisibleScheduleProviders( + item.subRows, + )) { + appendUniqueProvider(providers, seenVisibleProviders, provider); + } + return; + } + + walk(item.subRows, key); + return; + } + + if (isSelected) { + appendUnique(providerIds, seenProviderIds, [item.id]); + appendUniqueProvider( + providers, + seenVisibleProviders, + toScanScheduleProvider(item), + ); + } + }); + } + + walk(rows, ""); + + return { providerIds, providers }; +} + +function ProvidersAccountsTableContent({ isCloud, metadata, rows, + scanScheduleCapability, onOpenProviderWizard, onOpenOrganizationWizard, }: ProvidersAccountsTableProps) { const [rowSelection, setRowSelection] = useState({}); - // Reset selection when page changes - const currentPage = metadata?.pagination?.page; - useEffect(() => { - setRowSelection({}); - }, [currentPage]); - const testableProviderIds = computeTestableProviderIds(rows, rowSelection); + const selectedScheduleProviders = computeSelectedScheduleProviders( + rows, + rowSelection, + ); + const selectedScheduleProviderIds = selectedScheduleProviders.providerIds; const clearSelection = () => setRowSelection({}); const columns = getColumnProviders( rowSelection, testableProviderIds, + selectedScheduleProviderIds, + selectedScheduleProviders.providers, clearSelection, onOpenProviderWizard, onOpenOrganizationWizard, + scanScheduleCapability, ); return ( @@ -92,3 +199,9 @@ export function ProvidersAccountsTable({ /> ); } + +export function ProvidersAccountsTable(props: ProvidersAccountsTableProps) { + const currentPage = props.metadata?.pagination?.page ?? "none"; + + return ; +} diff --git a/ui/components/providers/providers-accounts-view.test.tsx b/ui/components/providers/providers-accounts-view.test.tsx index c8b3baf596..f888f7f4d8 100644 --- a/ui/components/providers/providers-accounts-view.test.tsx +++ b/ui/components/providers/providers-accounts-view.test.tsx @@ -5,8 +5,15 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import type { FilterOption, MetaDataProps, ProviderProps } from "@/types"; import type { ProvidersTableRow } from "@/types/providers-table"; +import { SCAN_SCHEDULE_CAPABILITY } from "@/types/schedules"; -const { refreshMock, replaceMock, searchParamsValue } = vi.hoisted(() => ({ +const { + providersAccountsTableSpy, + refreshMock, + replaceMock, + searchParamsValue, +} = vi.hoisted(() => ({ + providersAccountsTableSpy: vi.fn(), refreshMock: vi.fn(), replaceMock: vi.fn(), searchParamsValue: { current: "" }, @@ -49,7 +56,10 @@ vi.mock("@/components/providers/providers-filters", () => ({ })); vi.mock("@/components/providers/providers-accounts-table", () => ({ - ProvidersAccountsTable: () =>
Table
, + ProvidersAccountsTable: (props: { scanScheduleCapability?: string }) => { + providersAccountsTableSpy(props); + return
Table
; + }, })); vi.mock("@/components/providers/wizard", () => ({ @@ -123,6 +133,7 @@ const disconnectedProviders: ProviderProps[] = [ describe("ProvidersAccountsView", () => { afterEach(() => { vi.restoreAllMocks(); + providersAccountsTableSpy.mockClear(); searchParamsValue.current = ""; window.history.replaceState({}, "", "/"); }); @@ -285,6 +296,27 @@ describe("ProvidersAccountsView", () => { ).not.toBeInTheDocument(); }); + it("passes scan schedule capability to provider row actions", () => { + // Given/When + render( + , + ); + + // Then + expect(providersAccountsTableSpy).toHaveBeenCalledWith( + expect.objectContaining({ + scanScheduleCapability: SCAN_SCHEDULE_CAPABILITY.MANUAL_ONLY, + }), + ); + }); + it("opens the provider wizard from the normal Add Provider button", async () => { // Given const user = userEvent.setup(); diff --git a/ui/components/providers/providers-accounts-view.tsx b/ui/components/providers/providers-accounts-view.tsx index d1ff22a958..8a5d8f93fc 100644 --- a/ui/components/providers/providers-accounts-view.tsx +++ b/ui/components/providers/providers-accounts-view.tsx @@ -152,6 +152,7 @@ export function ProvidersAccountsView({ isCloud={isCloud} metadata={metadata} rows={rows} + scanScheduleCapability={scanScheduleCapability} onOpenProviderWizard={openProviderWizard} onOpenOrganizationWizard={openOrganizationWizard} /> diff --git a/ui/components/providers/table/column-providers.tsx b/ui/components/providers/table/column-providers.tsx index 7a0daf9b40..c88c61b78e 100644 --- a/ui/components/providers/table/column-providers.tsx +++ b/ui/components/providers/table/column-providers.tsx @@ -20,6 +20,10 @@ import { ProvidersProviderRow, ProvidersTableRow, } from "@/types/providers-table"; +import type { + ScanScheduleCapability, + ScanScheduleProvider, +} from "@/types/schedules"; import { LinkToScans } from "../link-to-scans"; import { DataTableRowActions } from "./data-table-row-actions"; @@ -103,9 +107,12 @@ function countSelectedLeaves(rows: Row[]): number { export function getColumnProviders( rowSelection: RowSelectionState, testableProviderIds: string[], + selectedScheduleProviderIds: string[], + selectedScheduleProviders: ScanScheduleProvider[], onClearSelection: () => void, onOpenProviderWizard: (initialData?: ProviderWizardInitialData) => void, onOpenOrganizationWizard: (initialData: OrgWizardInitialData) => void, + scanScheduleCapability?: ScanScheduleCapability, ): ColumnDef[] { return [ { @@ -317,9 +324,12 @@ export function getColumnProviders( hasSelection={hasSelection} isRowSelected={row.getIsSelected()} testableProviderIds={testableProviderIds} + selectedScheduleProviderIds={selectedScheduleProviderIds} + selectedScheduleProviders={selectedScheduleProviders} onClearSelection={onClearSelection} onOpenProviderWizard={onOpenProviderWizard} onOpenOrganizationWizard={onOpenOrganizationWizard} + capability={scanScheduleCapability} /> ); }, diff --git a/ui/components/providers/table/data-table-row-actions.test.tsx b/ui/components/providers/table/data-table-row-actions.test.tsx index dc0aeadf47..0fd6823851 100644 --- a/ui/components/providers/table/data-table-row-actions.test.tsx +++ b/ui/components/providers/table/data-table-row-actions.test.tsx @@ -9,6 +9,7 @@ import { PROVIDERS_ROW_TYPE, ProvidersTableRow, } from "@/types/providers-table"; +import { SCAN_SCHEDULE_CAPABILITY } from "@/types/schedules"; const { checkConnectionProviderMock, getScheduleMock, pushMock } = vi.hoisted( () => ({ @@ -55,13 +56,16 @@ vi.mock("@/components/scans/schedule/edit-scan-schedule-modal", () => ({ EditScanScheduleModal: ({ open, provider, + providers, }: { open: boolean; provider?: { providerId: string }; + providers?: { providerId: string }[]; }) => open ? (
- Editing schedule for {provider?.providerId} + Editing schedule for{" "} + {providers ? `${providers.length} providers` : provider?.providerId}
) : null, })); @@ -130,6 +134,7 @@ const createOrgRow = () => parentExternalId: null, organizationId: "org-1", providerCount: 3, + providerIds: ["provider-child-1", "provider-child-2"], subRows: [ { id: "provider-child-1", @@ -162,6 +167,7 @@ const createOuRow = () => parentExternalId: "o-abc123def4", organizationId: "org-1", providerCount: 2, + providerIds: ["provider-ou-child-1"], subRows: [ { id: "provider-ou-child-1", @@ -305,6 +311,56 @@ describe("DataTableRowActions", () => { ).toHaveTextContent("Editing schedule for provider-1"); }); + it("hides Edit Scan Schedule for manual-only Cloud provider rows", async () => { + // Given + vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true"); + const user = userEvent.setup(); + + render( + , + ); + + // When + await user.click(screen.getByRole("button")); + + // Then + expect(screen.queryByText("Edit Scan Schedule")).not.toBeInTheDocument(); + }); + + it("hides Edit Scan Schedule for blocked Cloud provider rows", async () => { + // Given + vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true"); + const user = userEvent.setup(); + + render( + , + ); + + // When + await user.click(screen.getByRole("button")); + + // Then + expect(screen.queryByText("Edit Scan Schedule")).not.toBeInTheDocument(); + }); + it("renders Update Credentials for provider rows with credentials", async () => { // Given const user = userEvent.setup(); @@ -351,6 +407,29 @@ describe("DataTableRowActions", () => { expect(screen.getByText("Delete Organization")).toBeInTheDocument(); }); + it("opens Edit Scan Schedule for AWS organization rows", async () => { + const user = userEvent.setup(); + render( + , + ); + + await user.click(screen.getByRole("button")); + await user.click(screen.getByText("Edit Scan Schedule")); + + expect( + screen.getByRole("dialog", { name: /edit scan schedule/i }), + ).toHaveTextContent("Editing schedule for 2 providers"); + }); + it("renders Delete Organization with destructive styling for org rows", async () => { const user = userEvent.setup(); render( @@ -436,6 +515,49 @@ describe("DataTableRowActions", () => { expect(screen.queryByText("Test Connections (1)")).not.toBeInTheDocument(); }); + it("shows bulk Edit Scan Schedule next to Test Connection for selected rows", async () => { + // Given + const user = userEvent.setup(); + render( + , + ); + + // When + await user.click(screen.getByRole("button")); + + // Then + expect(screen.getByText("Edit Scan Schedule (3)")).toBeInTheDocument(); + expect(screen.getByText("Test Connection (2)")).toBeInTheDocument(); + }); + it("does NOT render Edit Organization Name or Update Credentials for OU rows", async () => { const user = userEvent.setup(); render( diff --git a/ui/components/providers/table/data-table-row-actions.tsx b/ui/components/providers/table/data-table-row-actions.tsx index 44d7198e2e..a2c63e0cb7 100644 --- a/ui/components/providers/table/data-table-row-actions.tsx +++ b/ui/components/providers/table/data-table-row-actions.tsx @@ -24,7 +24,6 @@ import { EDIT_SCAN_SCHEDULE_STATE, EditScanScheduleModal, type EditScanScheduleState, - type ScanScheduleProvider, } from "@/components/scans/schedule/edit-scan-schedule-modal"; import { ActionDropdown, @@ -49,6 +48,7 @@ import { import { SCAN_SCHEDULE_CAPABILITY, type ScanScheduleCapability, + type ScanScheduleProvider, type ScheduleApiResponse, } from "@/types/schedules"; @@ -64,6 +64,10 @@ interface DataTableRowActionsProps { isRowSelected: boolean; /** IDs of all selected providers that have credentials (testable) */ testableProviderIds: string[]; + /** IDs of all selected providers that can receive schedule updates. */ + selectedScheduleProviderIds?: string[]; + /** Visible selected providers used as modal reference rows. */ + selectedScheduleProviders?: ScanScheduleProvider[]; /** Callback to clear the row selection after bulk operation */ onClearSelection: () => void; onOpenProviderWizard: (initialData?: ProviderWizardInitialData) => void; @@ -91,28 +95,56 @@ function collectTestableChildProviderIds(rows: ProvidersTableRow[]): string[] { return ids; } +function collectChildScheduleProviders( + rows: ProvidersTableRow[], +): ScanScheduleProvider[] { + const providers: ScanScheduleProvider[] = []; + + for (const row of rows) { + if (row.rowType === PROVIDERS_ROW_TYPE.PROVIDER) { + providers.push({ + providerId: row.id, + providerType: row.attributes.provider, + providerUid: row.attributes.uid, + providerAlias: row.attributes.alias, + }); + continue; + } + + providers.push(...collectChildScheduleProviders(row.subRows)); + } + + return providers; +} + interface OrgGroupDropdownActionsProps { rowData: ProvidersOrganizationRow; loading: boolean; + canEditSchedule: boolean; hasSelection: boolean; testableProviderIds: string[]; childTestableIds: string[]; + scheduleProviderCount: number; onClearSelection: () => void; onBulkTest: (ids: string[]) => Promise; onTestChildConnections: () => Promise; onOpenOrganizationWizard: (initialData: OrgWizardInitialData) => void; + onOpenScheduleEditor: () => void; } function OrgGroupDropdownActions({ rowData, loading, + canEditSchedule, hasSelection, testableProviderIds, childTestableIds, + scheduleProviderCount, onClearSelection, onBulkTest, onTestChildConnections, onOpenOrganizationWizard, + onOpenScheduleEditor, }: OrgGroupDropdownActionsProps) { const [isDeleteOrgOpen, setIsDeleteOrgOpen] = useState(false); const [isEditNameOpen, setIsEditNameOpen] = useState(false); @@ -191,6 +223,14 @@ function OrgGroupDropdownActions({ /> )} + {isOrgKind && canEditSchedule && ( + } + label="Edit Scan Schedule" + onSelect={() => onOpenScheduleEditor()} + disabled={scheduleProviderCount === 0} + /> + )} } label={loading ? "Testing..." : `Test Connections (${testCount})`} @@ -226,6 +266,8 @@ export function DataTableRowActions({ hasSelection, isRowSelected, testableProviderIds, + selectedScheduleProviderIds = [], + selectedScheduleProviders = [], onClearSelection, onOpenProviderWizard, onOpenOrganizationWizard, @@ -266,6 +308,10 @@ export function DataTableRowActions({ const childTestableIds = isOrganizationRow ? collectTestableChildProviderIds(rowData.subRows) : []; + const childScheduleProviders = isOrganizationRow + ? collectChildScheduleProviders(rowData.subRows) + : []; + const childScheduleProviderIds = isOrganizationRow ? rowData.providerIds : []; const handleBulkTest = async (ids: string[]) => { if (ids.length === 0) return; @@ -329,8 +375,17 @@ export function DataTableRowActions({ await handleBulkTest(childTestableIds); }; - const openScheduleEditor = async () => { - if (!providerId) { + const openScheduleEditor = async ( + targetProviders: ScanScheduleProvider[] = scheduleProvider + ? [scheduleProvider] + : [], + targetProviderIds: string[] = targetProviders.map( + (target) => target.providerId, + ), + ) => { + const targetProviderId = targetProviderIds[0]; + + if (!targetProviderId) { setScheduleState({ kind: EDIT_SCAN_SCHEDULE_STATE.ERROR, message: "Provider ID is not available.", @@ -342,7 +397,7 @@ export function DataTableRowActions({ setScheduleState({ kind: EDIT_SCAN_SCHEDULE_STATE.LOADING }); setIsScheduleOpen(true); - const response = (await getSchedule(providerId)) as + const response = (await getSchedule(targetProviderId)) as | ScheduleApiResponse | { error?: string }; @@ -367,38 +422,81 @@ export function DataTableRowActions({ if (hasSelection && isRowSelected) { const bulkCount = testableProviderIds.length > 1 ? ` (${testableProviderIds.length})` : ""; + const selectedScheduleProviderCount = selectedScheduleProviderIds.length; return ( -
- - } - label={loading ? "Testing..." : `Test Connection${bulkCount}`} - onSelect={(e) => { - e.preventDefault(); - handleTestConnection(); - }} - disabled={testableProviderIds.length === 0 || loading} - /> - -
+ <> + +
+ + {canEditSchedule && selectedScheduleProviderCount > 0 && ( + } + label={`Edit Scan Schedule (${selectedScheduleProviderCount})`} + onSelect={() => + void openScheduleEditor( + selectedScheduleProviders, + selectedScheduleProviderIds, + ) + } + /> + )} + } + label={loading ? "Testing..." : `Test Connection${bulkCount}`} + onSelect={(e) => { + e.preventDefault(); + handleTestConnection(); + }} + disabled={testableProviderIds.length === 0 || loading} + /> + +
+ ); } // Organization / Organization Unit row actions if (isProvidersOrganizationRow(rowData) && orgGroupKind) { return ( - + <> + + + void openScheduleEditor( + childScheduleProviders, + childScheduleProviderIds, + ) + } + /> + ); } diff --git a/ui/components/providers/wizard/provider-wizard-modal.tsx b/ui/components/providers/wizard/provider-wizard-modal.tsx index 8904b35e43..ec6f60fe64 100644 --- a/ui/components/providers/wizard/provider-wizard-modal.tsx +++ b/ui/components/providers/wizard/provider-wizard-modal.tsx @@ -8,6 +8,7 @@ import { OrgSetupForm } from "@/components/providers/organizations/org-setup-for import { Button } from "@/components/shadcn/button/button"; import { DialogHeader, DialogTitle } from "@/components/shadcn/dialog"; import { Modal } from "@/components/shadcn/modal"; +import { useScanScheduleCapability } from "@/hooks/use-scan-schedule-capability"; import { useScrollHint } from "@/hooks/use-scroll-hint"; import { advanceActiveTour, endActiveTour } from "@/lib/tours/use-driver-tour"; import { ORG_SETUP_PHASE, ORG_WIZARD_STEP } from "@/types/organizations"; @@ -89,6 +90,10 @@ export function ProviderWizardModal({ enabled: open, refreshToken: scrollHintRefreshToken, }); + const { + capability: resolvedScanScheduleCapability, + isScheduleCapabilityLoading, + } = useScanScheduleCapability(scanScheduleCapability); const docsDestination = getProviderWizardDocsDestination(docsLink); return ( @@ -200,8 +205,9 @@ export function ProviderWizardModal({ onBack={() => setCurrentStep(PROVIDER_WIZARD_STEP.TEST)} onClose={handleClose} onFooterChange={setFooterConfig} - capability={scanScheduleCapability} + capability={resolvedScanScheduleCapability} isScanLimitReached={isScanLimitReached} + isScheduleCapabilityLoading={isScheduleCapabilityLoading} /> )} @@ -255,8 +261,9 @@ export function ProviderWizardModal({ setOrgCurrentStep(ORG_WIZARD_STEP.VALIDATE); }} onFooterChange={setFooterConfig} - capability={scanScheduleCapability} + capability={resolvedScanScheduleCapability} isScanLimitReached={isScanLimitReached} + isScheduleCapabilityLoading={isScheduleCapabilityLoading} /> )} diff --git a/ui/components/providers/wizard/steps/launch-step.test.tsx b/ui/components/providers/wizard/steps/launch-step.test.tsx index cfa8e2e63d..22802e6424 100644 --- a/ui/components/providers/wizard/steps/launch-step.test.tsx +++ b/ui/components/providers/wizard/steps/launch-step.test.tsx @@ -295,6 +295,36 @@ describe("LaunchStep", () => { expect.objectContaining({ title: "Unable to save scan schedule" }), ); }); + + it("disables launch actions while schedule capability is loading", async () => { + // Given + const onFooterChange = vi.fn(); + seedConnectedProvider(); + + render( + , + ); + + // When + await screen.findByText("Loading scan options..."); + await waitFor(() => expect(onFooterChange).toHaveBeenCalled()); + await act(async () => { + lastFooterConfig(onFooterChange)?.onAction?.(); + }); + + // Then + expect(lastFooterConfig(onFooterChange)?.backDisabled).toBe(true); + expect(lastFooterConfig(onFooterChange)?.actionDisabled).toBe(true); + expect(scanOnDemandMock).not.toHaveBeenCalled(); + expect(updateScheduleMock).not.toHaveBeenCalled(); + expect(scheduleDailyMock).not.toHaveBeenCalled(); + }); }); describe("Prowler Cloud trial/onboarding (manual scan only)", () => { diff --git a/ui/components/providers/wizard/steps/launch-step.tsx b/ui/components/providers/wizard/steps/launch-step.tsx index db42d5be0e..7e2f0348dd 100644 --- a/ui/components/providers/wizard/steps/launch-step.tsx +++ b/ui/components/providers/wizard/steps/launch-step.tsx @@ -67,6 +67,11 @@ interface LaunchStepProps { * Cloud-only signal; never set in OSS. */ isScanLimitReached?: boolean; + /** + * Cloud-only loading state while billing is resolved into a schedule + * capability. OSS leaves it false. + */ + isScheduleCapabilityLoading?: boolean; } export function LaunchStep({ @@ -75,6 +80,7 @@ export function LaunchStep({ onFooterChange, capability: capabilityProp, isScanLimitReached = false, + isScheduleCapabilityLoading = false, }: LaunchStepProps) { const { toast } = useToast(); const { providerAlias, providerId, providerType, providerUid } = @@ -82,6 +88,7 @@ export function LaunchStep({ const capability = capabilityProp ?? getScanScheduleCapability(isCloud()); const isManualOnly = capability === SCAN_SCHEDULE_CAPABILITY.MANUAL_ONLY; const isAdvanced = capability === SCAN_SCHEDULE_CAPABILITY.ADVANCED; + const isBlocked = capability === SCAN_SCHEDULE_CAPABILITY.BLOCKED; const [isLaunching, setIsLaunching] = useState(false); const [mode, setMode] = useState( isAdvanced ? LAUNCH_MODE.SCHEDULE : LAUNCH_MODE.NOW, @@ -93,7 +100,12 @@ export function LaunchStep({ const isScheduleMode = isAdvanced && mode === LAUNCH_MODE.SCHEDULE; const isLimitBlocked = mode === LAUNCH_MODE.NOW && isScanLimitReached; - const isActionBlocked = isLaunching || !providerId || isLimitBlocked; + const isActionBlocked = + isLaunching || + isScheduleCapabilityLoading || + !providerId || + isBlocked || + isLimitBlocked; const launchInitialScan = useWatch({ control: form.control, name: "launchInitialScan", @@ -111,15 +123,21 @@ export function LaunchStep({ return launchInitialScan ? "Save and launch scan" : "Save"; })(); + useEffect(() => { + if (!isAdvanced && mode !== LAUNCH_MODE.NOW) { + setMode(LAUNCH_MODE.NOW); + } + }, [isAdvanced, mode]); + const launchOnDemandScan = async (): Promise<{ error?: unknown } | null> => { - if (!providerId) return null; + if (!providerId || isBlocked) return null; const formData = new FormData(); formData.set("providerId", providerId); return scanOnDemand(formData); }; const handleManualScan = async () => { - if (isScanLimitReached) { + if (isActionBlocked) { return; } @@ -150,7 +168,7 @@ export function LaunchStep({ }; const handleSaveSchedule = form.handleSubmit(async (values) => { - if (!providerId) { + if (!providerId || isBlocked || isScheduleCapabilityLoading) { return; } @@ -207,6 +225,10 @@ export function LaunchStep({ // always invokes the current closure without re-running on every render. const actionRef = useRef<() => void>(() => {}); actionRef.current = () => { + if (isBlocked || isScheduleCapabilityLoading) { + return; + } + if (!isScheduleMode) { void handleManualScan(); return; @@ -218,7 +240,7 @@ export function LaunchStep({ onFooterChange({ showBack: true, backLabel: "Back", - backDisabled: isLaunching, + backDisabled: isLaunching || isScheduleCapabilityLoading, onBack, showAction: true, actionLabel, @@ -229,6 +251,7 @@ export function LaunchStep({ }, [ isActionBlocked, isLaunching, + isScheduleCapabilityLoading, actionLabel, isScheduleMode, launchInitialScan, @@ -237,13 +260,17 @@ export function LaunchStep({ onFooterChange, ]); - if (isLaunching) { + if (isLaunching || isScheduleCapabilityLoading) { return (

- {!isScheduleMode ? "Launching scan..." : "Saving scan schedule..."} + {isScheduleCapabilityLoading + ? "Loading scan options..." + : !isScheduleMode + ? "Launching scan..." + : "Saving scan schedule..."}

@@ -284,7 +311,11 @@ export function LaunchStep({ aria-label="Scan mode" >