(null);
const isManualPassDetails =
@@ -189,11 +198,13 @@ export function FindingNoteModal({
handleOpenChange(false);
} catch (error) {
- setSubmitError(
- error instanceof Error
- ? error.message
- : "Could not update the note. Please try again.",
- );
+ setSubmitError({
+ message:
+ error instanceof Error
+ ? error.message
+ : "Could not update the note. Please try again.",
+ isUsageLimited: error instanceof UsageLimitError,
+ });
} finally {
setIsSubmitting(false);
}
@@ -380,7 +391,15 @@ export function FindingNoteModal({
{submitError && (
- {submitError}
+
+ {submitError.message}
+ {submitError.isUsageLimited && (
+ <>
+ {" "}
+
+ >
+ )}
+
)}
diff --git a/ui/components/findings/table/finding-triage-cells.test.tsx b/ui/components/findings/table/finding-triage-cells.test.tsx
index 15821fe59e..afe604be8e 100644
--- a/ui/components/findings/table/finding-triage-cells.test.tsx
+++ b/ui/components/findings/table/finding-triage-cells.test.tsx
@@ -80,6 +80,8 @@ beforeAll(() => {
});
});
+import { USAGE_LIMIT_MESSAGE, UsageLimitError } from "@/lib/action-errors";
+import { PRICING_URL } from "@/lib/external-urls";
import { useCloudUpgradeStore } from "@/store";
import { CLOUD_UPGRADE_FEATURE } from "@/types/cloud-upgrade";
import { FINDING_STATUS } from "@/types/components";
@@ -1280,6 +1282,37 @@ describe("finding triage cells", () => {
expect(statusControl).toHaveTextContent("Open");
});
+ it("should offer the plans when the usage limit refuses the status update", async () => {
+ // Given
+ const user = userEvent.setup();
+ const onTriageUpdateAction = vi
+ .fn()
+ .mockRejectedValue(new UsageLimitError());
+ render(
+ ,
+ );
+
+ // When
+ await user.click(screen.getByRole("combobox", { name: "Triage status" }));
+ await user.click(screen.getByRole("option", { name: "Remediating" }));
+
+ // Then
+ await waitFor(() => expect(toastMock).toHaveBeenCalled());
+ const toastOptions = toastMock.mock.calls[0][0];
+ expect(toastOptions.description).toBe(USAGE_LIMIT_MESSAGE);
+ render(<>{toastOptions.action}>);
+ expect(screen.getByRole("link", { name: "View plans" })).toHaveAttribute(
+ "href",
+ PRICING_URL,
+ );
+ });
+
it("should not confirm when moving between Mutelist shortcut statuses", async () => {
// Given
const user = userEvent.setup();
diff --git a/ui/components/findings/table/finding-triage-status-control.tsx b/ui/components/findings/table/finding-triage-status-control.tsx
index f29224d488..cca5700c4d 100644
--- a/ui/components/findings/table/finding-triage-status-control.tsx
+++ b/ui/components/findings/table/finding-triage-status-control.tsx
@@ -6,6 +6,8 @@ import { Button } from "@/components/shadcn";
import { Modal } from "@/components/shadcn/modal";
import { useToast } from "@/components/shadcn/toast/use-toast";
import { TriageStatusPicker } from "@/components/shared/triage/triage-status-picker";
+import { usageLimitToastAction } from "@/components/shared/usage-limit-message";
+import { USAGE_LIMIT_MESSAGE, UsageLimitError } from "@/lib/action-errors";
import { FINDING_STATUS } from "@/types/components";
import {
FINDING_TRIAGE_MANUAL_STATUS_VALUES,
@@ -93,11 +95,13 @@ export function FindingTriageStatusControl(
previousStatus: triage.status,
isMuted: triage.isMuted,
});
- } catch {
+ } catch (error) {
+ const isUsageLimited = error instanceof UsageLimitError;
toast({
variant: "destructive",
title: "Could not update triage status.",
- description: "Please try again.",
+ description: isUsageLimited ? USAGE_LIMIT_MESSAGE : "Please try again.",
+ ...(isUsageLimited && { action: usageLimitToastAction() }),
});
} finally {
setIsTableUpdating(false);
diff --git a/ui/components/findings/table/findings-group-drill-down.tsx b/ui/components/findings/table/findings-group-drill-down.tsx
index f530f8a6da..c0ead5180f 100644
--- a/ui/components/findings/table/findings-group-drill-down.tsx
+++ b/ui/components/findings/table/findings-group-drill-down.tsx
@@ -11,7 +11,6 @@ import { useSearchParams } from "next/navigation";
import {
loadFindingTriageDetail,
loadLatestFindingTriageNote,
- updateFindingTriage,
} from "@/actions/findings";
import { LoadingState } from "@/components/shadcn/spinner/loading-state";
import {
@@ -26,6 +25,7 @@ import {
} from "@/components/shadcn/table";
import { useFindingGroupResourceState } from "@/hooks/use-finding-group-resource-state";
import { cn, hasHistoricalFindingFilter } from "@/lib";
+import { updateFindingTriageOrThrow } from "@/lib/finding-triage-update";
import {
getFilteredFindingGroupDelta,
getFindingGroupImpactedCounts,
@@ -96,7 +96,7 @@ export function FindingsGroupDrillDown({
selectableRowCount,
findingTitle: group.checkTitle,
onTriageUpdateAction: (input) =>
- updateTriageOptimistically(input, updateFindingTriage),
+ updateTriageOptimistically(input, updateFindingTriageOrThrow),
onTriageNoteLoadAction: loadLatestFindingTriageNote,
onTriageDetailLoadAction: loadFindingTriageDetail,
});
diff --git a/ui/components/findings/table/inline-resource-container.tsx b/ui/components/findings/table/inline-resource-container.tsx
index 91d5640fac..e8ba93a9b9 100644
--- a/ui/components/findings/table/inline-resource-container.tsx
+++ b/ui/components/findings/table/inline-resource-container.tsx
@@ -12,7 +12,6 @@ import { useImperativeHandle, useRef, useState } from "react";
import {
loadFindingTriageDetail,
loadLatestFindingTriageNote,
- updateFindingTriage,
} from "@/actions/findings";
import { LighthouseContextContributor } from "@/components/lighthouse/context-contributor";
import { Skeleton } from "@/components/shadcn/skeleton/skeleton";
@@ -21,6 +20,7 @@ import { TableCell, TableRow } from "@/components/shadcn/table";
import { useFindingGroupResourceState } from "@/hooks/use-finding-group-resource-state";
import { useScrollHint } from "@/hooks/use-scroll-hint";
import { useScrollportPin } from "@/hooks/use-scrollport-pin";
+import { updateFindingTriageOrThrow } from "@/lib/finding-triage-update";
import { buildFindingResourceContext } from "@/lib/lighthouse/context/contributions";
import { cn } from "@/lib/utils";
import { SIDE_PANEL_TAB, useSidePanelStore } from "@/store/side-panel";
@@ -249,7 +249,7 @@ export function InlineResourceContainer({
drawer.openDrawer(rowIndex);
},
onTriageUpdateAction: (input) =>
- updateTriageOptimistically(input, updateFindingTriage),
+ updateTriageOptimistically(input, updateFindingTriageOrThrow),
onTriageNoteLoadAction: loadLatestFindingTriageNote,
onTriageDetailLoadAction: loadFindingTriageDetail,
});
diff --git a/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer-content.tsx b/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer-content.tsx
index f41b2dc3a0..fb23c5fd03 100644
--- a/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer-content.tsx
+++ b/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer-content.tsx
@@ -17,7 +17,6 @@ import {
loadFindingTriageDetail,
loadLatestFindingTriageNote,
type ResourceDrawerFinding,
- updateFindingTriage,
} from "@/actions/findings";
import {
requestPanelChatMessage,
@@ -81,6 +80,7 @@ import { TriageStatusBadge } from "@/components/shared/triage/triage-status-badg
import { useLighthouseCurrentContext } from "@/hooks/use-lighthouse-context";
import { getFailingForLabel, formatDuration } from "@/lib/date-utils";
import { shouldRefreshAfterTriageUpdate } from "@/lib/finding-triage";
+import { updateFindingTriageOrThrow } from "@/lib/finding-triage-update";
import { buildJiraActionLabel } from "@/lib/jira-dispatch-action";
import { createJiraDispatchPayload } from "@/lib/jira-dispatch-selection";
import { getRegionFlag } from "@/lib/region-flags";
@@ -365,7 +365,7 @@ export function ResourceDetailDrawerContent({
const showOverviewStatusExtended = Boolean(overviewStatusExtended);
const handleDrawerTriageUpdate = async (input: UpdateFindingTriageInput) => {
- const result = await updateFindingTriage(input);
+ const result = await updateFindingTriageOrThrow(input);
if (shouldRefreshAfterTriageUpdate(input)) {
onMuteComplete();
return result;
diff --git a/ui/components/issues/issue-triage.test.tsx b/ui/components/issues/issue-triage.test.tsx
index 70847e24b1..0213f2508e 100644
--- a/ui/components/issues/issue-triage.test.tsx
+++ b/ui/components/issues/issue-triage.test.tsx
@@ -2,6 +2,9 @@ import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
+import { USAGE_LIMIT_MESSAGE } from "@/lib/action-errors";
+import { PRICING_URL } from "@/lib/external-urls";
+
import { IssueTriage } from "./issue-triage";
const {
@@ -186,6 +189,30 @@ describe("IssueTriage", () => {
);
});
+ it("offers the plans when the usage limit refuses the save", async () => {
+ // Given
+ updateIssueTriageMock.mockResolvedValue({
+ error: USAGE_LIMIT_MESSAGE,
+ status: 402,
+ });
+ const user = userEvent.setup();
+ render();
+
+ // When
+ await user.click(screen.getByRole("combobox", { name: "Triage status" }));
+ await user.click(screen.getByRole("option", { name: "False Positive" }));
+ await user.click(screen.getByRole("button", { name: "Save triage" }));
+
+ // Then
+ await waitFor(() => expect(toastMock).toHaveBeenCalled());
+ const toastOptions = toastMock.mock.calls[0][0];
+ render(<>{toastOptions.action}>);
+ expect(screen.getByRole("link", { name: "View plans" })).toHaveAttribute(
+ "href",
+ PRICING_URL,
+ );
+ });
+
it("says so when the notes cannot be loaded, and loads them on retry", async () => {
const user = userEvent.setup();
getIssueNotesMock.mockResolvedValueOnce(undefined);
diff --git a/ui/components/issues/issue-triage.tsx b/ui/components/issues/issue-triage.tsx
index e181df7beb..fbda0d67d2 100644
--- a/ui/components/issues/issue-triage.tsx
+++ b/ui/components/issues/issue-triage.tsx
@@ -14,6 +14,8 @@ import { DateWithTime } from "@/components/shadcn/entities";
import { Textarea } from "@/components/shadcn/textarea/textarea";
import { useToast } from "@/components/shadcn/toast/use-toast";
import { TriageStatusPicker } from "@/components/shared/triage/triage-status-picker";
+import { usageLimitToastAction } from "@/components/shared/usage-limit-message";
+import { isUsageLimitError } from "@/lib/action-errors";
import {
FINDING_TRIAGE_MANUAL_STATUS_VALUES,
FINDING_TRIAGE_NOTE_MAX_LENGTH,
@@ -75,6 +77,7 @@ export const IssueTriage = ({ id, status }: IssueTriageProps) => {
variant: "destructive",
title: "Triage not saved",
description: result.error,
+ ...(isUsageLimitError(result) && { action: usageLimitToastAction() }),
});
return;
}
diff --git a/ui/components/overview/new-findings-table/table/latest-findings-table.tsx b/ui/components/overview/new-findings-table/table/latest-findings-table.tsx
index 757b5d8e2d..61f4a7be34 100644
--- a/ui/components/overview/new-findings-table/table/latest-findings-table.tsx
+++ b/ui/components/overview/new-findings-table/table/latest-findings-table.tsx
@@ -6,10 +6,10 @@ import type { ReactNode } from "react";
import {
loadFindingTriageDetail,
loadLatestFindingTriageNote,
- updateFindingTriage,
} from "@/actions/findings";
import { getStandaloneFindingColumns } from "@/components/findings/table/column-standalone-findings";
import { DataTable } from "@/components/shadcn/table";
+import { updateFindingTriageOrThrow } from "@/lib/finding-triage-update";
import type { FindingProps } from "@/types";
import type { FindingTriageUpdateHandler } from "@/types/findings-triage";
@@ -25,7 +25,7 @@ export function LatestFindingsTable({
const router = useRouter();
const handleTriageUpdate: FindingTriageUpdateHandler = async (input) => {
- const result = await updateFindingTriage(input);
+ const result = await updateFindingTriageOrThrow(input);
router.refresh();
return result;
};
diff --git a/ui/components/providers/organizations/org-launch-scan.test.tsx b/ui/components/providers/organizations/org-launch-scan.test.tsx
index 910d67d2d7..90cbbcfb93 100644
--- a/ui/components/providers/organizations/org-launch-scan.test.tsx
+++ b/ui/components/providers/organizations/org-launch-scan.test.tsx
@@ -8,6 +8,7 @@ import {
ACTION_ERROR_MESSAGES,
ACTION_ERROR_STATUS,
} from "@/lib/action-errors";
+import { PRICING_URL } from "@/lib/external-urls";
import { useOrgSetupStore } from "@/store/organizations/store";
import {
SCAN_JOBS_TAB,
@@ -248,6 +249,39 @@ describe("OrgLaunchScan", () => {
expect(launchOrganizationScansMock).not.toHaveBeenCalled();
expect(pushMock).not.toHaveBeenCalled();
expect(onClose).not.toHaveBeenCalled();
+ expect(
+ toastMock.mock.calls[0]?.[0].action.props.children.props.href,
+ ).toBe(PRICING_URL);
+ });
+
+ it("should offer the plans when the schedules saved but the limit refused the initial scans", async () => {
+ // Given
+ const onFooterChange = vi.fn();
+ launchOrganizationScansMock.mockResolvedValue({
+ error: ACTION_ERROR_API_MESSAGES[ACTION_ERROR_STATUS.PAYMENT_REQUIRED],
+ status: ACTION_ERROR_STATUS.PAYMENT_REQUIRED,
+ });
+ render(
+ ,
+ );
+
+ // When
+ await screen.findByText("Scan Schedule");
+ await act(async () => {
+ lastFooterConfig(onFooterChange)?.onAction?.();
+ });
+
+ // Then
+ await waitFor(() => expect(toastMock).toHaveBeenCalled());
+ expect(toastMock.mock.calls[0]?.[0].title).toBe("Scan schedules saved");
+ expect(
+ toastMock.mock.calls[0]?.[0].action.props.children.props.href,
+ ).toBe(PRICING_URL);
});
});
@@ -326,6 +360,37 @@ describe("OrgLaunchScan", () => {
toastMock.mock.calls[0]?.[0].action.props.children.props.href,
).toBe(`/scans?tab=${SCAN_JOBS_TAB.ACTIVE}`);
});
+
+ it("should offer the plans when the limit refuses the single scan", async () => {
+ // Given
+ const onFooterChange = vi.fn();
+ launchOrganizationScansMock.mockResolvedValue({
+ error: ACTION_ERROR_API_MESSAGES[ACTION_ERROR_STATUS.PAYMENT_REQUIRED],
+ status: ACTION_ERROR_STATUS.PAYMENT_REQUIRED,
+ });
+ render(
+ ,
+ );
+
+ // When
+ await act(async () => {
+ lastFooterConfig(onFooterChange)?.onAction?.();
+ });
+
+ // Then
+ await waitFor(() => expect(toastMock).toHaveBeenCalled());
+ expect(toastMock.mock.calls[0]?.[0].title).toBe(
+ "Unable to launch organization scan",
+ );
+ expect(
+ toastMock.mock.calls[0]?.[0].action.props.children.props.href,
+ ).toBe(PRICING_URL);
+ });
});
describe("when capability is BLOCKED", () => {
diff --git a/ui/components/providers/organizations/org-launch-scan.tsx b/ui/components/providers/organizations/org-launch-scan.tsx
index cb4c168173..9442821413 100644
--- a/ui/components/providers/organizations/org-launch-scan.tsx
+++ b/ui/components/providers/organizations/org-launch-scan.tsx
@@ -26,8 +26,15 @@ import {
} from "@/components/shadcn/select/select";
import { Spinner } from "@/components/shadcn/spinner/spinner";
import { TreeStatusIcon } from "@/components/shadcn/tree-view/tree-status-icon";
-import { UsageLimitMessage } from "@/components/shared/usage-limit-message";
-import { getActionErrorMessage, hasActionError } from "@/lib/action-errors";
+import {
+ UsageLimitMessage,
+ usageLimitToastAction,
+} from "@/components/shared/usage-limit-message";
+import {
+ getActionErrorMessage,
+ hasActionError,
+ isUsageLimitError,
+} from "@/lib/action-errors";
import {
buildScheduleUpdatePayload,
describeSchedulesBulkFailures,
@@ -173,6 +180,7 @@ export function OrgLaunchScan({
variant: "destructive",
title: "Unable to save scan schedules",
description: getActionErrorMessage(result),
+ action: isUsageLimitError(result) ? usageLimitToastAction() : undefined,
});
return;
}
@@ -203,6 +211,7 @@ export function OrgLaunchScan({
let initialScanFailureCount = 0;
let initialScanSuccessCount = 0;
let initialScanError: string | undefined;
+ let initialScanUsageLimited = false;
if (values.launchInitialScan) {
const scanResult = organizationId
@@ -212,6 +221,7 @@ export function OrgLaunchScan({
if ("error" in scanResult) {
initialScanFailureCount = updatedProviderIds.length;
initialScanError = getActionErrorMessage(scanResult);
+ initialScanUsageLimited = isUsageLimitError(scanResult);
} else {
initialScanSuccessCount = scanResult.data.length;
}
@@ -241,7 +251,10 @@ export function OrgLaunchScan({
? `${description} The initial organization scan could not be launched: ${initialScanError}`
: `${description} Initial scans failed for ${formatCandidateCount(initialScanFailureCount, noun)}.`
: description,
- action: (
+ // Over the limit, the plans unblock the scans; the scans page would not.
+ action: initialScanUsageLimited ? (
+ usageLimitToastAction()
+ ) : (
Go to scans
@@ -269,6 +282,9 @@ export function OrgLaunchScan({
variant: "destructive",
title: "Unable to launch organization scan",
description: getActionErrorMessage(result),
+ action: isUsageLimitError(result)
+ ? usageLimitToastAction()
+ : undefined,
});
return;
}
diff --git a/ui/components/providers/wizard/steps/connect/provider-connect-step.test.tsx b/ui/components/providers/wizard/steps/connect/provider-connect-step.test.tsx
index 08f6f36d3f..9c8ba2ee14 100644
--- a/ui/components/providers/wizard/steps/connect/provider-connect-step.test.tsx
+++ b/ui/components/providers/wizard/steps/connect/provider-connect-step.test.tsx
@@ -3,6 +3,8 @@ import userEvent from "@testing-library/user-event";
import { useState } from "react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { Toaster } from "@/components/shadcn/toast/Toaster";
+import { PRICING_URL } from "@/lib/external-urls";
import {
PROVIDER_FUNNEL_EVENT,
type ProviderFunnelDetail,
@@ -308,6 +310,50 @@ describe("ProviderConnectStep", () => {
vi.stubEnv("UI_CLOUD_ENABLED", "true");
});
+ it("offers the plans when the usage limit refuses the account", async () => {
+ // Given
+ addProvider.mockResolvedValueOnce({
+ errors: [
+ {
+ detail: "Your trial has ended.",
+ status: "402",
+ source: { pointer: "/data" },
+ code: "payment_required",
+ },
+ ],
+ status: 402,
+ });
+ const { onConnected, user } = renderStep();
+ render();
+ await user.click(
+ screen.getByRole("radio", { name: /Static access keys/ }),
+ );
+ await user.type(
+ screen.getByRole("textbox", { name: /Account ID/ }),
+ "210987654321",
+ );
+ await user.type(
+ screen.getByPlaceholderText("Enter the AWS Access Key ID"),
+ "AKIAEXAMPLE",
+ );
+ await user.type(
+ screen.getByPlaceholderText("Enter the AWS Secret Access Key"),
+ "secret-value",
+ );
+ await waitFor(() => expect(connectButton()).toBeEnabled());
+
+ // When
+ await user.click(connectButton());
+
+ // Then
+ expect(await screen.findByText("Your trial has ended.")).toBeVisible();
+ expect(screen.getByRole("link", { name: "View plans" })).toHaveAttribute(
+ "href",
+ PRICING_URL,
+ );
+ expect(onConnected).not.toHaveBeenCalled();
+ });
+
it("shows the refusal on the Account ID field and stays on the step", async () => {
// Given
addProvider.mockResolvedValueOnce({
diff --git a/ui/components/providers/wizard/steps/launch-step.test.tsx b/ui/components/providers/wizard/steps/launch-step.test.tsx
index 3d24500af5..c0503b758a 100644
--- a/ui/components/providers/wizard/steps/launch-step.test.tsx
+++ b/ui/components/providers/wizard/steps/launch-step.test.tsx
@@ -13,6 +13,7 @@ import { SCAN_JOBS_TAB } from "@/types";
import {
SCAN_SCHEDULE_CAPABILITY,
SCHEDULE_FREQUENCY,
+ type ScanScheduleCapability,
} from "@/types/schedules";
import { LaunchStep } from "./launch-step";
@@ -518,4 +519,88 @@ describe("LaunchStep", () => {
expect(lastFooterConfig(onFooterChange)?.actionDisabled).toBe(true);
});
});
+
+ describe("when the usage limit blocks the scan", () => {
+ const PRICING = "https://prowler.com/pricing";
+ const overLimit = {
+ error: ACTION_ERROR_API_MESSAGES[ACTION_ERROR_STATUS.PAYMENT_REQUIRED],
+ status: ACTION_ERROR_STATUS.PAYMENT_REQUIRED,
+ };
+ // The toast action wraps the link it opens.
+ const toastLink = (call = 0) =>
+ toastMock.mock.calls[call]?.[0].action?.props.children.props;
+
+ beforeEach(() => {
+ vi.stubEnv("UI_CLOUD_ENABLED", "true");
+ updateScheduleMock.mockResolvedValue({ data: { id: "provider-1" } });
+ scanOnDemandMock.mockResolvedValue(overLimit);
+ seedConnectedProvider();
+ });
+
+ async function launch(capability: ScanScheduleCapability) {
+ const onFooterChange = vi.fn();
+ render(
+ ,
+ );
+ await waitFor(() => expect(onFooterChange).toHaveBeenCalled());
+ await act(async () => {
+ lastFooterConfig(onFooterChange)?.onAction?.();
+ });
+ await waitFor(() => expect(toastMock).toHaveBeenCalled());
+ }
+
+ it("offers the pricing page in a new tab when a manual scan is refused", async () => {
+ // When
+ await launch(SCAN_SCHEDULE_CAPABILITY.MANUAL_ONLY);
+
+ // Then
+ expect(toastMock.mock.calls[0]?.[0].title).toBe("Unable to launch scan");
+ expect(toastLink()).toMatchObject({
+ href: PRICING,
+ target: "_blank",
+ rel: "noopener noreferrer",
+ children: "View plans",
+ });
+ });
+
+ it("offers the pricing page when the schedule saved but its first scan was refused", async () => {
+ // When
+ await launch(SCAN_SCHEDULE_CAPABILITY.ADVANCED);
+
+ // Then
+ expect(toastMock.mock.calls[0]?.[0].title).toBe("Scan schedule saved");
+ expect(toastLink()).toMatchObject({ href: PRICING, target: "_blank" });
+ });
+
+ it("offers the pricing page when the schedule itself was refused", async () => {
+ // Given
+ updateScheduleMock.mockResolvedValue(overLimit);
+
+ // When
+ await launch(SCAN_SCHEDULE_CAPABILITY.ADVANCED);
+
+ // Then
+ expect(scanOnDemandMock).not.toHaveBeenCalled();
+ expect(toastMock.mock.calls[0]?.[0].title).toBe(
+ "Unable to save scan schedule",
+ );
+ expect(toastLink()).toMatchObject({ href: PRICING, target: "_blank" });
+ });
+
+ it("keeps the plain error when a manual scan fails for another reason", async () => {
+ // Given
+ scanOnDemandMock.mockResolvedValue({ error: "Scanner unavailable" });
+
+ // When
+ await launch(SCAN_SCHEDULE_CAPABILITY.MANUAL_ONLY);
+
+ // Then
+ expect(toastMock.mock.calls[0]?.[0].action).toBeUndefined();
+ });
+ });
});
diff --git a/ui/components/providers/wizard/steps/launch-step.tsx b/ui/components/providers/wizard/steps/launch-step.tsx
index 862f7ec302..6316a6eb06 100644
--- a/ui/components/providers/wizard/steps/launch-step.tsx
+++ b/ui/components/providers/wizard/steps/launch-step.tsx
@@ -20,11 +20,15 @@ import {
} from "@/components/shadcn/radio-group/radio-group";
import { Spinner } from "@/components/shadcn/spinner/spinner";
import { TreeStatusIcon } from "@/components/shadcn/tree-view/tree-status-icon";
-import { UsageLimitMessage } from "@/components/shared/usage-limit-message";
+import {
+ UsageLimitMessage,
+ usageLimitToastAction,
+} from "@/components/shared/usage-limit-message";
import {
type ActionErrorResult,
getActionErrorMessage,
hasActionError,
+ isUsageLimitError,
} from "@/lib/action-errors";
import {
getScanScheduleCapability,
@@ -154,6 +158,9 @@ export function LaunchStep({
variant: "destructive",
title: "Unable to launch scan",
description: getActionErrorMessage(scanResult),
+ action: isUsageLimitError(scanResult)
+ ? usageLimitToastAction()
+ : undefined,
});
return;
}
@@ -191,6 +198,7 @@ export function LaunchStep({
variant: "destructive",
title: "Unable to save scan schedule",
description: result.message,
+ action: result.usageLimited ? usageLimitToastAction() : undefined,
});
return;
}
@@ -209,7 +217,8 @@ export function LaunchStep({
toast({
title: "Scan schedule saved",
description: `The schedule was saved, but the initial scan could not be launched: ${result.message}`,
- action: goToScans,
+ // Over the limit, the plans unblock the scan; the scans page would not.
+ action: result.usageLimited ? usageLimitToastAction() : goToScans,
});
return;
}
diff --git a/ui/components/resources/table/resource-detail-content.tsx b/ui/components/resources/table/resource-detail-content.tsx
index 2285ef4787..16341ba4fe 100644
--- a/ui/components/resources/table/resource-detail-content.tsx
+++ b/ui/components/resources/table/resource-detail-content.tsx
@@ -7,7 +7,6 @@ import { useState } from "react";
import {
loadFindingTriageDetail,
loadLatestFindingTriageNote,
- updateFindingTriage,
} from "@/actions/findings";
import { FloatingSelectionActions } from "@/components/findings/floating-selection-actions";
import { FindingDetailDrawer } from "@/components/findings/table";
@@ -36,6 +35,7 @@ import { ResourceMetadataPanel } from "@/components/shared/resource-metadata-pan
import { useFindingDetails } from "@/hooks/use-finding-details";
import { getGroupLabel } from "@/lib/categories";
import { shouldRefreshAfterTriageUpdate } from "@/lib/finding-triage";
+import { updateFindingTriageOrThrow } from "@/lib/finding-triage-update";
import { getRegionFlag } from "@/lib/region-flags";
import { ProviderType, ResourceProps } from "@/types";
import type { UpdateFindingTriageInput } from "@/types/findings-triage";
@@ -148,7 +148,7 @@ export const ResourceDetailContent = ({
};
const handleTriageUpdate = async (input: UpdateFindingTriageInput) => {
- const result = await updateFindingTriage(input);
+ const result = await updateFindingTriageOrThrow(input);
if (shouldRefreshAfterTriageUpdate(input)) {
setFindingsReloadNonce((value) => value + 1);
diff --git a/ui/components/scans/import-findings-modal.tsx b/ui/components/scans/import-findings-modal.tsx
index 2016a50641..1f1186dc2c 100644
--- a/ui/components/scans/import-findings-modal.tsx
+++ b/ui/components/scans/import-findings-modal.tsx
@@ -7,6 +7,8 @@ import { Button } from "@/components/shadcn/button/button";
import { FileUploadDropzone } from "@/components/shadcn/file-upload/file-upload-dropzone";
import { Modal } from "@/components/shadcn/modal/modal";
import { useToast } from "@/components/shadcn/toast/use-toast";
+import { ViewPlansLink } from "@/components/shared/usage-limit-message";
+import { ACTION_ERROR_STATUS } from "@/lib/action-errors";
import { type Ingestion, type IngestionResponse } from "@/types";
import {
@@ -63,6 +65,7 @@ interface FailedImportState {
file: File;
// Absent when the upload itself was rejected: no job, so no counters.
ingestion?: Ingestion;
+ usageLimited?: boolean;
}
interface InvalidImportState {
@@ -193,6 +196,7 @@ export function ImportFindingsModal() {
? await responseError(response, START_ERROR)
: START_ERROR,
file,
+ usageLimited: response?.status === ACTION_ERROR_STATUS.PAYMENT_REQUIRED,
});
return;
}
@@ -262,7 +266,15 @@ export function ImportFindingsModal() {
)}
{state.type === IMPORT_STATE.FAILED && (
<>
- {state.error}
+
+ {state.error}
+ {state.usageLimited && (
+ <>
+ {" "}
+
+ >
+ )}
+
{state.ingestion && (
Reported progress: {state.ingestion.processedRecords} of{" "}
diff --git a/ui/components/scans/launch-scan-modal.test.tsx b/ui/components/scans/launch-scan-modal.test.tsx
index 706b704425..c17b6a87ce 100644
--- a/ui/components/scans/launch-scan-modal.test.tsx
+++ b/ui/components/scans/launch-scan-modal.test.tsx
@@ -130,6 +130,7 @@ import {
ACTION_ERROR_MESSAGES,
ACTION_ERROR_STATUS,
} from "@/lib/action-errors";
+import { PRICING_URL } from "@/lib/external-urls";
import { ProviderProps } from "@/types";
import { SCAN_SCHEDULE_CAPABILITY } from "@/types/schedules";
@@ -386,6 +387,10 @@ describe("LaunchScanModal", () => {
ACTION_ERROR_API_MESSAGES[ACTION_ERROR_STATUS.PAYMENT_REQUIRED],
),
).not.toBeInTheDocument();
+ expect(screen.getByRole("link", { name: "View plans" })).toHaveAttribute(
+ "href",
+ PRICING_URL,
+ );
expect(toastMock).not.toHaveBeenCalled();
expect(refreshMock).not.toHaveBeenCalled();
expect(onOpenChange).not.toHaveBeenCalledWith(false);
@@ -536,6 +541,46 @@ describe("LaunchScanModal", () => {
);
});
+ it("offers the plans when the usage limit refuses the schedule", async () => {
+ const user = userEvent.setup();
+ updateScheduleMock.mockResolvedValueOnce({
+ error: ACTION_ERROR_API_MESSAGES[ACTION_ERROR_STATUS.PAYMENT_REQUIRED],
+ status: ACTION_ERROR_STATUS.PAYMENT_REQUIRED,
+ });
+ renderAdvanced();
+
+ await user.selectOptions(screen.getByLabelText("Providers"), provider.id);
+ await user.click(screen.getByRole("radio", { name: "On a schedule" }));
+ await user.click(screen.getByRole("button", { name: /save schedule/i }));
+
+ expect(
+ await screen.findByRole("link", { name: "View plans" }),
+ ).toHaveAttribute("href", PRICING_URL);
+ expect(toastMock).not.toHaveBeenCalled();
+ });
+
+ it("offers the plans when the schedule saved but the limit refused its first scan", async () => {
+ const user = userEvent.setup();
+ scanOnDemandMock.mockResolvedValueOnce({
+ error: ACTION_ERROR_API_MESSAGES[ACTION_ERROR_STATUS.PAYMENT_REQUIRED],
+ status: ACTION_ERROR_STATUS.PAYMENT_REQUIRED,
+ });
+ renderAdvanced();
+
+ await user.selectOptions(screen.getByLabelText("Providers"), provider.id);
+ await user.click(screen.getByRole("radio", { name: "On a schedule" }));
+ await user.click(
+ await screen.findByLabelText(
+ "Launch an initial scan now for immediate findings",
+ ),
+ );
+ await user.click(screen.getByRole("button", { name: /save schedule/i }));
+
+ await waitFor(() => expect(toastMock).toHaveBeenCalled());
+ const action = toastMock.mock.calls[0]?.[0].action;
+ expect(action.props.children.props.href).toBe(PRICING_URL);
+ });
+
it("disables Save Schedule until a provider is selected", async () => {
const user = userEvent.setup();
renderAdvanced();
diff --git a/ui/components/scans/launch-scan-modal.tsx b/ui/components/scans/launch-scan-modal.tsx
index 465321dc1a..82003027d5 100644
--- a/ui/components/scans/launch-scan-modal.tsx
+++ b/ui/components/scans/launch-scan-modal.tsx
@@ -25,8 +25,16 @@ import {
RadioGroupItem,
} from "@/components/shadcn/radio-group/radio-group";
import { toast, ToastAction } from "@/components/shadcn/toast";
-import { UsageLimitMessage } from "@/components/shared/usage-limit-message";
-import { getActionErrorMessage, hasActionError } from "@/lib/action-errors";
+import {
+ UsageLimitMessage,
+ usageLimitToastAction,
+} from "@/components/shared/usage-limit-message";
+import {
+ getActionErrorMessage,
+ hasActionError,
+ isUsageLimitError,
+ USAGE_LIMIT_ERROR_TYPE,
+} from "@/lib/action-errors";
import {
buildScheduleAttributesFromProvider,
getScanScheduleCapability,
@@ -200,7 +208,10 @@ function LaunchScanForm({
const result = await scanOnDemand(formData);
if (hasActionError(result)) {
- form.setError("root", { message: getActionErrorMessage(result) });
+ form.setError("root", {
+ type: isUsageLimitError(result) ? USAGE_LIMIT_ERROR_TYPE : "server",
+ message: getActionErrorMessage(result),
+ });
return;
}
@@ -238,7 +249,10 @@ function LaunchScanForm({
});
if (result.status === SAVE_SCHEDULE_STATUS.ERROR) {
- form.setError("root", { message: result.message });
+ form.setError("root", {
+ type: result.usageLimited ? USAGE_LIMIT_ERROR_TYPE : "server",
+ message: result.message,
+ });
return;
}
@@ -254,7 +268,10 @@ function LaunchScanForm({
: launched
? "The schedule was saved and the initial scan was launched."
: "The scan schedule was saved successfully.",
- action: (
+ // Over the limit, the plans unblock the scan; the schedule would not.
+ action: result.usageLimited ? (
+ usageLimitToastAction()
+ ) : (
View schedule
@@ -281,6 +298,8 @@ function LaunchScanForm({
const providerError = form.formState.errors.providerId?.message;
const aliasError = form.formState.errors.scanAlias?.message;
const rootError = form.formState.errors.root?.message;
+ const isRootUsageLimit =
+ form.formState.errors.root?.type === USAGE_LIMIT_ERROR_TYPE;
const isSubmitting =
form.formState.isSubmitting || scheduleForm.formState.isSubmitting;
const isScheduleLoading = scheduleLoad === SCHEDULE_LOAD_STATE.LOADING;
@@ -381,7 +400,12 @@ function LaunchScanForm({
/>
)}
- {rootError && {rootError}}
+ {rootError &&
+ (isRootUsageLimit ? (
+
+ ) : (
+ {rootError}
+ ))}
{
ACTION_ERROR_API_MESSAGES[ACTION_ERROR_STATUS.PAYMENT_REQUIRED],
),
).not.toBeInTheDocument();
+ expect(screen.getByRole("link", { name: "View plans" })).toHaveAttribute(
+ "href",
+ PRICING_URL,
+ );
});
it("uses the shared subscription error copy when removing is blocked", async () => {
@@ -382,6 +387,10 @@ describe("EditScanScheduleModal remove flow", () => {
ACTION_ERROR_API_MESSAGES[ACTION_ERROR_STATUS.PAYMENT_REQUIRED],
),
).not.toBeInTheDocument();
+ expect(screen.getByRole("link", { name: "View plans" })).toHaveAttribute(
+ "href",
+ PRICING_URL,
+ );
});
it("shows one logo per selected provider type in bulk mode", () => {
diff --git a/ui/components/scans/schedule/edit-scan-schedule-modal.tsx b/ui/components/scans/schedule/edit-scan-schedule-modal.tsx
index 2474305042..aab6cd215f 100644
--- a/ui/components/scans/schedule/edit-scan-schedule-modal.tsx
+++ b/ui/components/scans/schedule/edit-scan-schedule-modal.tsx
@@ -20,7 +20,13 @@ import { EntityInfo } from "@/components/shadcn/entities";
import { FormButtons } from "@/components/shadcn/form";
import { Modal } from "@/components/shadcn/modal";
import { toast } from "@/components/shadcn/toast";
-import { getActionErrorMessage, hasActionError } from "@/lib/action-errors";
+import { UsageLimitMessage } from "@/components/shared/usage-limit-message";
+import {
+ getActionErrorMessage,
+ hasActionError,
+ isUsageLimitError,
+ USAGE_LIMIT_ERROR_TYPE,
+} from "@/lib/action-errors";
import { runWithConcurrencyLimit } from "@/lib/concurrency";
import {
buildScheduleUpdatePayload,
@@ -134,7 +140,10 @@ function EditScanScheduleForm({
: await updateSchedule(targetProviderIds[0], payload);
if (hasActionError(result)) {
- form.setError("root", { message: getActionErrorMessage(result) });
+ form.setError("root", {
+ type: isUsageLimitError(result) ? USAGE_LIMIT_ERROR_TYPE : "server",
+ message: getActionErrorMessage(result),
+ });
return;
}
@@ -189,7 +198,12 @@ function EditScanScheduleForm({
const failedResult = results.find(hasActionError);
if (failedResult) {
- form.setError("root", { message: getActionErrorMessage(failedResult) });
+ form.setError("root", {
+ type: isUsageLimitError(failedResult)
+ ? USAGE_LIMIT_ERROR_TYPE
+ : "server",
+ message: getActionErrorMessage(failedResult),
+ });
return;
}
@@ -206,6 +220,8 @@ function EditScanScheduleForm({
const isSubmitting = form.formState.isSubmitting;
const rootError = form.formState.errors.root?.message;
+ const isRootUsageLimit =
+ form.formState.errors.root?.type === USAGE_LIMIT_ERROR_TYPE;
return (