+ ) : null,
+}));
+
vi.mock("@/components/ui", () => ({
useToast: () => ({ toast: vi.fn() }),
}));
@@ -143,6 +177,23 @@ const createOuRow = () =>
}) as unknown as Row;
describe("DataTableRowActions", () => {
+ afterEach(() => {
+ vi.unstubAllEnvs();
+ });
+
+ beforeEach(() => {
+ getScheduleMock.mockResolvedValue({
+ data: {
+ type: "schedules",
+ id: "provider-1",
+ attributes: { scan_hour: null },
+ relationships: {
+ provider: { data: { type: "providers", id: "provider-1" } },
+ },
+ },
+ });
+ });
+
it("renders Add Credentials for provider rows without credentials", async () => {
// Given
const user = userEvent.setup();
@@ -163,12 +214,97 @@ describe("DataTableRowActions", () => {
// Then
expect(screen.getByText("Edit Provider Alias")).toBeInTheDocument();
+ // Advanced schedule editing is gated to Prowler Cloud subscribed accounts.
+ expect(screen.queryByText("Edit Scan Schedule")).not.toBeInTheDocument();
expect(screen.getByText("Add Credentials")).toBeInTheDocument();
expect(screen.getByText("Test Connection")).toBeInTheDocument();
expect(screen.getByText("Delete Provider")).toBeInTheDocument();
expect(screen.queryByText("Update Credentials")).not.toBeInTheDocument();
});
+ it("navigates to the provider-filtered scan jobs from View Scan Jobs", async () => {
+ // Given
+ const user = userEvent.setup();
+ render(
+ ,
+ );
+
+ // When
+ await user.click(screen.getByRole("button"));
+ await user.click(screen.getByText("View Scan Jobs"));
+
+ // Then: navigates with the key the scans filter bar binds to
+ // (provider_uid__in), URL-encoded, so the provider is pre-selected.
+ expect(pushMock).toHaveBeenCalledWith(
+ "/scans?filter%5Bprovider_uid__in%5D=111111111111",
+ );
+ });
+
+ it("URL-encodes provider UIDs that contain unsafe characters (e.g. GitHub)", async () => {
+ // Given a GitHub provider whose UID is a URL.
+ const user = userEvent.setup();
+ const row = createRow(true);
+ (
+ row.original as unknown as { attributes: { uid: string } }
+ ).attributes.uid = "https://github.com/prowler-cloud/prowler";
+
+ render(
+ ,
+ );
+
+ // When
+ await user.click(screen.getByRole("button"));
+ await user.click(screen.getByText("View Scan Jobs"));
+
+ // Then the ':' and '/' are encoded instead of leaking into the URL raw.
+ expect(pushMock).toHaveBeenCalledWith(
+ "/scans?filter%5Bprovider_uid__in%5D=https%3A%2F%2Fgithub.com%2Fprowler-cloud%2Fprowler",
+ );
+ });
+
+ it("opens Edit Scan Schedule for Prowler Cloud subscribed provider rows", async () => {
+ // Given
+ vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true");
+ const user = userEvent.setup();
+
+ render(
+ ,
+ );
+
+ // When
+ await user.click(screen.getByRole("button"));
+ await user.click(screen.getByText("Edit Scan Schedule"));
+
+ // Then
+ expect(
+ screen.getByRole("dialog", { name: /edit scan schedule/i }),
+ ).toHaveTextContent("Editing schedule for provider-1");
+ });
+
it("renders Update Credentials for provider rows with credentials", async () => {
// Given
const user = userEvent.setup();
diff --git a/ui/components/providers/table/data-table-row-actions.tsx b/ui/components/providers/table/data-table-row-actions.tsx
index 9b531f272a..44d7198e2e 100644
--- a/ui/components/providers/table/data-table-row-actions.tsx
+++ b/ui/components/providers/table/data-table-row-actions.tsx
@@ -1,16 +1,31 @@
"use client";
import { Row } from "@tanstack/react-table";
-import { KeyRound, Pencil, Rocket, Trash2 } from "lucide-react";
+import {
+ CalendarClock,
+ KeyRound,
+ Pencil,
+ Rocket,
+ Timer,
+ Trash2,
+} from "lucide-react";
+import { useRouter } from "next/navigation";
import { useState } from "react";
import { updateOrganizationName } from "@/actions/organizations/organizations";
import { updateProvider } from "@/actions/providers";
+import { getSchedule } from "@/actions/schedules";
import {
ORG_WIZARD_INTENT,
OrgWizardInitialData,
ProviderWizardInitialData,
} from "@/components/providers/wizard/types";
+import {
+ EDIT_SCAN_SCHEDULE_STATE,
+ EditScanScheduleModal,
+ type EditScanScheduleState,
+ type ScanScheduleProvider,
+} from "@/components/scans/schedule/edit-scan-schedule-modal";
import {
ActionDropdown,
ActionDropdownDangerZone,
@@ -20,6 +35,8 @@ import { Modal } from "@/components/shadcn/modal";
import { useToast } from "@/components/ui";
import { runWithConcurrencyLimit } from "@/lib/concurrency";
import { testProviderConnection } from "@/lib/provider-helpers";
+import { getScanScheduleCapability } from "@/lib/schedules";
+import { isCloud } from "@/lib/shared/env";
import { ORG_SETUP_PHASE, ORG_WIZARD_STEP } from "@/types/organizations";
import { PROVIDER_WIZARD_MODE } from "@/types/provider-wizard";
import {
@@ -29,6 +46,11 @@ import {
ProvidersOrganizationRow,
ProvidersTableRow,
} from "@/types/providers-table";
+import {
+ SCAN_SCHEDULE_CAPABILITY,
+ type ScanScheduleCapability,
+ type ScheduleApiResponse,
+} from "@/types/schedules";
import { DeleteForm } from "../forms/delete-form";
import { DeleteOrganizationForm } from "../forms/delete-organization-form";
@@ -46,6 +68,13 @@ interface DataTableRowActionsProps {
onClearSelection: () => void;
onOpenProviderWizard: (initialData?: ProviderWizardInitialData) => void;
onOpenOrganizationWizard: (initialData: OrgWizardInitialData) => void;
+ /**
+ * Schedule capability override. Absent in OSS (defaults to a Cloud-vs-non-Cloud
+ * decision). The prowler-cloud overlay injects a billing-aware capability so
+ * only subscribed Cloud accounts can open the advanced schedule editor (which
+ * talks to the new schedule API).
+ */
+ capability?: ScanScheduleCapability;
}
function collectTestableChildProviderIds(rows: ProvidersTableRow[]): string[] {
@@ -200,11 +229,20 @@ export function DataTableRowActions({
onClearSelection,
onOpenProviderWizard,
onOpenOrganizationWizard,
+ capability,
}: DataTableRowActionsProps) {
+ const canEditSchedule =
+ (capability ?? getScanScheduleCapability(isCloud())) ===
+ SCAN_SCHEDULE_CAPABILITY.ADVANCED;
const [isEditOpen, setIsEditOpen] = useState(false);
+ const [isScheduleOpen, setIsScheduleOpen] = useState(false);
+ const [scheduleState, setScheduleState] = useState({
+ kind: EDIT_SCAN_SCHEDULE_STATE.LOADING,
+ });
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
const [loading, setLoading] = useState(false);
const { toast } = useToast();
+ const router = useRouter();
const rowData = row.original;
const isOrganizationRow = isProvidersOrganizationRow(rowData);
@@ -215,6 +253,14 @@ export function DataTableRowActions({
const providerAlias = provider?.attributes.alias ?? null;
const providerSecretId = provider?.relationships.secret.data?.id ?? null;
const hasSecret = Boolean(provider?.relationships.secret.data);
+ const scheduleProvider: ScanScheduleProvider | undefined = provider
+ ? {
+ providerId,
+ providerType,
+ providerUid,
+ providerAlias,
+ }
+ : undefined;
const orgGroupKind = isOrganizationRow ? rowData.groupKind : null;
const childTestableIds = isOrganizationRow
@@ -283,6 +329,40 @@ export function DataTableRowActions({
await handleBulkTest(childTestableIds);
};
+ const openScheduleEditor = async () => {
+ if (!providerId) {
+ setScheduleState({
+ kind: EDIT_SCAN_SCHEDULE_STATE.ERROR,
+ message: "Provider ID is not available.",
+ });
+ setIsScheduleOpen(true);
+ return;
+ }
+
+ setScheduleState({ kind: EDIT_SCAN_SCHEDULE_STATE.LOADING });
+ setIsScheduleOpen(true);
+
+ const response = (await getSchedule(providerId)) as
+ | ScheduleApiResponse
+ | { error?: string };
+
+ if (!response || ("error" in response && response.error)) {
+ setScheduleState({
+ kind: EDIT_SCAN_SCHEDULE_STATE.ERROR,
+ message:
+ response && "error" in response && response.error
+ ? response.error
+ : "Failed to load scan schedule.",
+ });
+ return;
+ }
+
+ setScheduleState({
+ kind: EDIT_SCAN_SCHEDULE_STATE.LOADED,
+ schedule: "data" in response ? response.data : null,
+ });
+ };
+
// When this row is part of the selection, only show "Test Connection"
if (hasSelection && isRowSelected) {
const bulkCount =
@@ -364,6 +444,12 @@ export function DataTableRowActions({
)}
+
setIsEditOpen(true)}
/>
+ }
+ label="View Scan Jobs"
+ onSelect={() => {
+ // Use the same key the scans filter bar binds to
+ // (`provider_uid__in`) so the provider is pre-selected in the UI,
+ // and URLSearchParams to encode UIDs that contain URL-unsafe chars
+ // (e.g. GitHub UIDs like https://github.com/org/repo).
+ const params = new URLSearchParams({
+ "filter[provider_uid__in]": providerUid,
+ });
+ router.push(`/scans?${params.toString()}`);
+ }}
+ />
+ {canEditSchedule && (
+ }
+ label="Edit Scan Schedule"
+ onSelect={() => void openScheduleEditor()}
+ />
+ )}
}
label={hasSecret ? "Update Credentials" : "Add Credentials"}
diff --git a/ui/components/providers/wizard/provider-wizard-modal.tsx b/ui/components/providers/wizard/provider-wizard-modal.tsx
index 36c5058120..8904b35e43 100644
--- a/ui/components/providers/wizard/provider-wizard-modal.tsx
+++ b/ui/components/providers/wizard/provider-wizard-modal.tsx
@@ -15,6 +15,7 @@ import {
PROVIDER_WIZARD_MODE,
PROVIDER_WIZARD_STEP,
} from "@/types/provider-wizard";
+import type { ScanScheduleCapability } from "@/types/schedules";
import { useProviderWizardController } from "./hooks/use-provider-wizard-controller";
import {
@@ -40,6 +41,10 @@ interface ProviderWizardModalProps {
initialData?: ProviderWizardInitialData;
orgInitialData?: OrgWizardInitialData;
refreshOnClose?: boolean;
+ /** Cloud overlay seam; defaults to the environment-resolved capability. */
+ scanScheduleCapability?: ScanScheduleCapability;
+ /** Cloud-only manual scan quota signal. */
+ isScanLimitReached?: boolean;
}
export function ProviderWizardModal({
@@ -48,6 +53,8 @@ export function ProviderWizardModal({
initialData,
orgInitialData,
refreshOnClose,
+ scanScheduleCapability,
+ isScanLimitReached,
}: ProviderWizardModalProps) {
const {
backToProviderFlow,
@@ -107,234 +114,246 @@ export function ProviderWizardModal({
-
-
- {isProviderFlow ? (
-
- ) : (
-
- )}
-
-
-
- {/* Anchors the add-provider tour's final step to the whole right section —
- the form inputs AND the footer — so the spotlight covers the Next button
- instead of leaving it under the overlay. The popover still pins to the left
- of this section, beside the inputs. */}
-
-
-
- {isProviderFlow &&
- currentStep === PROVIDER_WIZARD_STEP.CONNECT && (
- {
- setCurrentStep(PROVIDER_WIZARD_STEP.CREDENTIALS);
- // Reaching credentials is the tour's handoff point: end it so the
- // user continues on their own. No-op off-onboarding.
- endActiveTour();
- }}
- onSelectOrganizations={openOrganizationsFlow}
- onFooterChange={setFooterConfig}
- onProviderTypeChange={(providerType) => {
- // Picking a type reveals the account-detail inputs. Advance the tour
- // to its wizard-body step, pinned beside the form. No-op off-onboarding.
- if (providerType) advanceActiveTour();
- setProviderTypeHint(providerType);
- }}
- />
+ {/* Anchors the add-provider tour's final step to the wizard content and
+ footer, keeping the real form controls clickable under the overlay. */}
+
- {/* Inside the highlighted right section so the tour spotlight covers the Next
- button (no longer dimmed by the overlay). Aligned to the form width so Next
- stays at the right edge. */}
- {(resolvedFooterConfig.showBack ||
- resolvedFooterConfig.showSecondaryAction ||
- resolvedFooterConfig.showAction) && (
-