"use client"; import { Row } from "@tanstack/react-table"; import { CalendarClock, KeyRound, Pencil, Rocket, SlidersHorizontal, 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 { revalidateProviders, startProviderConnectionChecks, } from "@/actions/providers/providers"; import { getSchedule } from "@/actions/schedules"; import { pollConnectionTasks } from "@/components/providers/organizations/org-account-selection.utils"; import { ORG_WIZARD_INTENT, OrgWizardInitialData, ProviderWizardInitialData, } from "@/components/providers/wizard/types"; import { EDIT_SCAN_SCHEDULE_STATE, EditScanScheduleModal, type EditScanScheduleState, } from "@/components/scans/schedule/edit-scan-schedule-modal"; import { useToast } from "@/components/shadcn"; import { ActionDropdown, ActionDropdownDangerZone, ActionDropdownItem, } from "@/components/shadcn/dropdown"; import { Modal } from "@/components/shadcn/modal"; import { getNodeLabel, organizationNameFallbackHint, } from "@/lib/organizations"; import { testProviderConnection } from "@/lib/provider-helpers"; import { getScanScheduleCapability } from "@/lib/schedules"; import { isCloud } from "@/lib/shared/env"; import { isOrgFlowType, ORG_SETUP_PHASE, ORG_WIZARD_STEP, OrgFlowType, } from "@/types/organizations"; import { PROVIDER_WIZARD_MODE } from "@/types/provider-wizard"; import { isConfigurableProvider } from "@/types/providers"; import { isProvidersOrganizationRow, PROVIDERS_GROUP_KIND, PROVIDERS_ROW_TYPE, ProvidersOrganizationRow, ProvidersTableRow, } from "@/types/providers-table"; import { SCAN_CONFIGURATION_LIST_STATUS, ScanConfigurationData, type ScanConfigurationListStatus, } from "@/types/scan-configurations"; import { SCAN_SCHEDULE_CAPABILITY, type ScanScheduleCapability, type ScanScheduleProvider, type ScheduleApiResponse, } from "@/types/schedules"; import { DeleteForm } from "../forms/delete-form"; import { DeleteOrganizationForm } from "../forms/delete-organization-form"; import { EditNameForm } from "../forms/edit-name-form"; import { ManageScanConfigModal } from "../scan-config/manage-scan-config-modal"; interface DataTableRowActionsProps { row: Row; /** Whether any rows in the table are currently selected */ hasSelection: boolean; /** Whether this specific row is selected */ 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; onOpenOrganizationWizard: (initialData: OrgWizardInitialData) => void; /** * All scan configurations in the tenant, used to associate/disassociate this * provider's config from the row menu (Cloud-only feature). Empty in OSS. */ scanConfigs?: ScanConfigurationData[]; scanConfigStatus?: ScanConfigurationListStatus; currentScanConfigId?: string | null; /** * 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[] { const ids: string[] = []; for (const row of rows) { if (row.rowType === PROVIDERS_ROW_TYPE.PROVIDER) { if (row.relationships.secret.data) { ids.push(row.id); } } else if (row.subRows) { ids.push(...collectTestableChildProviderIds(row.subRows)); } } 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); const isOrgKind = rowData.groupKind === PROVIDERS_GROUP_KIND.ORGANIZATION; const testIds = hasSelection ? testableProviderIds : childTestableIds; const testCount = testIds.length; const nodeLabel = getNodeLabel(rowData.orgType, rowData.kind); const entityLabel = isOrgKind ? "organization" : nodeLabel.toLowerCase(); // Blank falls back to the identifier, matching what creation does. A row with // no external id has nothing to fall back to, so there the name stays required. const nameFallback = rowData.externalId ?? ""; // Credential updates re-enter the organization wizard, so this needs an // organization type with an onboarding flow. const orgFlowType: OrgFlowType | null = isOrgFlowType(rowData.orgType) ? rowData.orgType : null; const openOrgWizardAt = ( organizationType: OrgFlowType, targetStep: OrgWizardInitialData["targetStep"], targetPhase: OrgWizardInitialData["targetPhase"], intent?: OrgWizardInitialData["intent"], ) => { onOpenOrganizationWizard({ organizationType, organizationId: rowData.id, organizationName: rowData.name, externalId: rowData.externalId ?? "", targetStep, targetPhase, intent, }); }; return ( <> {isOrgKind && ( (value.trim() ? null : "Name is required.") } setIsOpen={setIsEditNameOpen} onSave={(name) => updateOrganizationName(rowData.id, name.trim() || nameFallback) } /> )} 0 ? ` and cascade to its ${rowData.providerCount} ${ rowData.providerCount === 1 ? "provider" : "providers" }` : "" }.`} >
{isOrgKind && ( <> } label="Edit Organization Name" onSelect={() => setIsEditNameOpen(true)} /> {orgFlowType && ( } label="Update Credentials" onSelect={() => openOrgWizardAt( orgFlowType, ORG_WIZARD_STEP.SETUP, ORG_SETUP_PHASE.ACCESS, ORG_WIZARD_INTENT.EDIT_CREDENTIALS, ) } /> )} )} {isOrgKind && canEditSchedule && ( } label="Edit Scan Schedule" onSelect={() => onOpenScheduleEditor()} disabled={scheduleProviderCount === 0} /> )} } label={loading ? "Testing..." : `Test Connections (${testCount})`} onSelect={(e) => { e.preventDefault(); if (hasSelection) { onBulkTest(testableProviderIds); onClearSelection(); } else { onTestChildConnections(); } }} disabled={testCount === 0 || loading} /> } label={isOrgKind ? "Delete Organization" : `Delete ${nodeLabel}`} destructive onSelect={() => setIsDeleteOrgOpen(true)} />
); } export function DataTableRowActions({ row, hasSelection, isRowSelected, testableProviderIds, selectedScheduleProviderIds = [], selectedScheduleProviders = [], onClearSelection, onOpenProviderWizard, onOpenOrganizationWizard, scanConfigs = [], scanConfigStatus = SCAN_CONFIGURATION_LIST_STATUS.AVAILABLE, currentScanConfigId = null, 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 [isScanConfigOpen, setIsScanConfigOpen] = useState(false); const [loading, setLoading] = useState(false); const { toast } = useToast(); const router = useRouter(); const rowData = row.original; const isOrganizationRow = isProvidersOrganizationRow(rowData); const provider = isOrganizationRow ? null : rowData; const providerId = provider?.id ?? ""; const providerType = provider?.attributes.provider ?? ""; // Only predefined providers can manage credentials from the UI const canManageCredentials = isConfigurableProvider(providerType); const providerUid = provider?.attributes.uid ?? ""; const providerAlias = provider?.attributes.alias ?? null; const providerSecretId = provider?.relationships.secret.data?.id ?? null; const hasSecret = Boolean(provider?.relationships.secret.data); const isCloudProvider = isCloud() && Boolean(provider); // Dynamic providers have no config.yaml baseline, so a Scan Configuration // can't apply to them — hide the action entirely. const isDynamicProvider = Boolean(provider?.attributes.is_dynamic); const canManageScanConfig = isCloudProvider && !isDynamicProvider && scanConfigStatus === SCAN_CONFIGURATION_LIST_STATUS.AVAILABLE; const scheduleProvider: ScanScheduleProvider | undefined = provider ? { providerId, providerType, providerUid, providerAlias, } : undefined; const orgGroupKind = isOrganizationRow ? rowData.groupKind : null; 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; setLoading(true); // Dispatched and polled in batches: client-invoked server actions run one at a // time through Next's queue, so a loop here serializes whatever concurrency it // asks for. let succeeded = 0; let failed = 0; const pendingTaskIds: string[] = []; try { const outcomes = await startProviderConnectionChecks(ids); for (const id of ids) { const outcome = outcomes[id]; // No task id means nothing was ever tested, so it cannot count as passing. if (!outcome || outcome.error || !outcome.taskId) { failed += 1; continue; } pendingTaskIds.push(outcome.taskId); } await pollConnectionTasks(pendingTaskIds, { onSettled: (_taskId, result) => { if (result.success) { succeeded += 1; } else { failed += 1; } }, }); } catch { failed = ids.length - succeeded; } await revalidateProviders(); if (failed === 0) { toast({ title: "Connection test completed", description: `${succeeded} ${succeeded === 1 ? "provider" : "providers"} tested successfully.`, }); } else { toast({ variant: "destructive", title: "Connection test completed", description: `${succeeded} succeeded, ${failed} failed out of ${ids.length} providers.`, }); } setLoading(false); }; const handleTestConnection = async () => { if (hasSelection && isRowSelected) { // Bulk: test all selected providers await handleBulkTest(testableProviderIds); onClearSelection(); } else { // Single: test only this provider if (!providerId) return; setLoading(true); const result = await testProviderConnection(providerId); setLoading(false); if (!result.connected) { toast({ variant: "destructive", title: "Connection test failed", description: result.error ?? "Unknown error", }); } else { toast({ title: "Connection test completed", description: "Provider tested successfully.", }); } } }; const handleTestChildConnections = async () => { await handleBulkTest(childTestableIds); }; 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.", }); setIsScheduleOpen(true); return; } setScheduleState({ kind: EDIT_SCAN_SCHEDULE_STATE.LOADING }); setIsScheduleOpen(true); const response = (await getSchedule(targetProviderId)) 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 = testableProviderIds.length > 1 ? ` (${testableProviderIds.length})` : ""; const selectedScheduleProviderCount = selectedScheduleProviderIds.length; return ( <>
{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, ) } /> ); } // Provider row actions (unchanged) return ( <> {provider && ( { if (alias !== "" && alias.length < 3) { return "The alias must be empty or have at least 3 characters."; } if (alias === (providerAlias ?? "")) { return "The new alias must be different from the current one."; } return null; }} onSave={async (alias) => { const formData = new FormData(); formData.append("providerId", providerId); formData.append("providerAlias", alias); return updateProvider(formData); }} /> )} {provider && ( )} {canManageScanConfig && provider && ( router.refresh()} /> )}
} label="Edit Provider Alias" onSelect={() => setIsEditOpen(true)} /> } label="View Scan Jobs" onSelect={() => { // Same key the scans filter bar binds to (`provider__in`, by id) so // the provider is pre-selected and the filter works on every tab, // including Scheduled (whose endpoint only accepts provider id). const params = new URLSearchParams({ "filter[provider__in]": providerId, }); router.push(`/scans?${params.toString()}`); }} /> {canEditSchedule && ( } label="Edit Scan Schedule" onSelect={() => void openScheduleEditor()} /> )} {canManageScanConfig && ( } label="Edit Scan Configuration" onSelect={() => setIsScanConfigOpen(true)} /> )} {isCloudProvider && !isDynamicProvider && scanConfigStatus === SCAN_CONFIGURATION_LIST_STATUS.UNAVAILABLE && ( } label="Scan Configuration unavailable" description="Try again later." disabled /> )} {canManageCredentials && ( } label={hasSecret ? "Update Credentials" : "Add Credentials"} onSelect={() => onOpenProviderWizard({ providerId, providerType, providerUid, providerAlias, secretId: providerSecretId, mode: providerSecretId ? PROVIDER_WIZARD_MODE.UPDATE : PROVIDER_WIZARD_MODE.ADD, }) } /> )} } label={loading ? "Testing..." : "Test Connection"} onSelect={(e) => { e.preventDefault(); handleTestConnection(); }} disabled={!hasSecret || loading} /> } label="Delete Provider" destructive onSelect={() => setIsDeleteOpen(true)} />
); }