diff --git a/ui/CHANGELOG.md b/ui/CHANGELOG.md index 8e44db2eb2..1baddf169a 100644 --- a/ui/CHANGELOG.md +++ b/ui/CHANGELOG.md @@ -4,6 +4,10 @@ All notable changes to the **Prowler UI** are documented in this file. ## [1.31.0] (Prowler UNRELEASED) +### 🚀 Added + +- Controlled `402` and `403` Server Action error messages for alert seed and mutation flows [(#11629)](https://github.com/prowler-cloud/prowler/pull/11629) + ### 🐞 Fixed - Radio button no longer shifts vertically when selected [(#11608)](https://github.com/prowler-cloud/prowler/pull/11608) diff --git a/ui/app/(prowler)/alerts/_components/__tests__/alert-form-modal.test.tsx b/ui/app/(prowler)/alerts/_components/__tests__/alert-form-modal.test.tsx index f23fafb948..026ba439f1 100644 --- a/ui/app/(prowler)/alerts/_components/__tests__/alert-form-modal.test.tsx +++ b/ui/app/(prowler)/alerts/_components/__tests__/alert-form-modal.test.tsx @@ -14,6 +14,7 @@ import { } from "@/app/(prowler)/alerts/_types"; import type { ProviderProps } from "@/types/providers"; +import { ALERTS_PERMISSION_ERROR } from "../../_lib/alert-errors"; import { AlertFormModal } from "../alert-form-modal"; const recipientsActionMocks = vi.hoisted(() => ({ @@ -726,6 +727,28 @@ describe("AlertFormModal", () => { ).toHaveLength(1); }); + it("should show the manage alerts permission error when save seed is forbidden", async () => { + // Given + const user = userEvent.setup(); + alertsActionMocks.seedAlertRule.mockResolvedValue({ + error: "You do not have permission to perform this action.", + status: 403, + }); + mockRecipientsList(); + renderCreateModal({ editingAlert: createEditingAlert() }); + + // When + await user.click(screen.getByRole("button", { name: /^save$/i })); + + // Then + expect(await screen.findByText(ALERTS_PERMISSION_ERROR)).toBeVisible(); + expect( + screen.queryByText( + "Apply at least one alert-compatible Findings filter.", + ), + ).not.toBeInTheDocument(); + }); + it("should hydrate advanced edit mode filters and normalize them on save", async () => { // Given const user = userEvent.setup(); diff --git a/ui/app/(prowler)/alerts/_components/__tests__/alerts-manager.test.tsx b/ui/app/(prowler)/alerts/_components/__tests__/alerts-manager.test.tsx index 0230dccfa3..c8a6c9fb61 100644 --- a/ui/app/(prowler)/alerts/_components/__tests__/alerts-manager.test.tsx +++ b/ui/app/(prowler)/alerts/_components/__tests__/alerts-manager.test.tsx @@ -13,6 +13,7 @@ import type { AlertFormValues, } from "@/app/(prowler)/alerts/_types/alert-form"; +import { ALERTS_PERMISSION_ERROR } from "../../_lib/alert-errors"; import { AlertsManager } from "../alerts-manager"; const actionMocks = vi.hoisted(() => ({ @@ -316,11 +317,7 @@ describe("AlertsManager", () => { await user.click(screen.getByRole("button", { name: /submit alert/i })); // Then - expect( - await screen.findByText( - "You don't have permission to manage alerts. Ask an administrator to update your role.", - ), - ).toBeVisible(); + expect(await screen.findByText(ALERTS_PERMISSION_ERROR)).toBeVisible(); expect(toastMock).not.toHaveBeenCalled(); }); @@ -346,6 +343,32 @@ describe("AlertsManager", () => { ); }); + it("shows a manage alerts permission toast for disable 403 errors", async () => { + // Given + const user = userEvent.setup(); + const alert = makeAlert(true); + actionMocks.disableAlert.mockResolvedValue({ + error: "You do not have permission to perform this action.", + status: 403, + }); + renderManager([alert]); + + // When + await user.click( + screen.getByRole("button", { name: /actions for enabled alert/i }), + ); + await user.click(screen.getByRole("menuitem", { name: /disable/i })); + + // Then + await waitFor(() => + expect(toastMock).toHaveBeenCalledWith({ + variant: "destructive", + title: "Alert update failed", + description: ALERTS_PERMISSION_ERROR, + }), + ); + }); + it("shows a success toast after enabling an alert", async () => { // Given const user = userEvent.setup(); diff --git a/ui/app/(prowler)/alerts/_components/alert-form-modal.tsx b/ui/app/(prowler)/alerts/_components/alert-form-modal.tsx index 12d7a85df9..8a9effd890 100644 --- a/ui/app/(prowler)/alerts/_components/alert-form-modal.tsx +++ b/ui/app/(prowler)/alerts/_components/alert-form-modal.tsx @@ -54,6 +54,7 @@ import { getEmptyAlertFormDefaults, getFindingsFiltersFromAlertCondition, } from "../_lib/alert-adapter"; +import { getAlertMutationError } from "../_lib/alert-errors"; import { alertFormSchema } from "../_lib/alert-form-schema"; import type { AlertFormSubmitResult, @@ -408,7 +409,7 @@ const AlertFormModalContent = ({ if (seedResult?.error) { setPreview({ status: "error", - error: ALERT_SEED_ERROR, + error: getAlertMutationError(seedResult, ALERT_SEED_ERROR), }); return; } @@ -452,7 +453,9 @@ const AlertFormModalContent = ({ : null; if (seedResult?.error) { setPreview(null); - setErrors({ root: ALERT_SEED_ERROR }); + setErrors({ + root: getAlertMutationError(seedResult, ALERT_SEED_ERROR), + }); return; } diff --git a/ui/app/(prowler)/alerts/_components/alerts-manager.tsx b/ui/app/(prowler)/alerts/_components/alerts-manager.tsx index e438727c6f..e29a0651c2 100644 --- a/ui/app/(prowler)/alerts/_components/alerts-manager.tsx +++ b/ui/app/(prowler)/alerts/_components/alerts-manager.tsx @@ -24,6 +24,7 @@ import type { ScanEntity } from "@/types"; import type { ProviderProps } from "@/types/providers"; import { toAlertPayload } from "../_lib/alert-adapter"; +import { getAlertMutationError } from "../_lib/alert-errors"; import type { AlertFormSubmitResult, AlertFormValues, @@ -49,8 +50,6 @@ interface AlertsManagerProps { const ALERTS_FINDINGS_HREF = "/findings?filter[muted]=false&filter[status__in]=FAIL"; -const ALERTS_PERMISSION_ERROR = - "You don't have permission to manage alerts. Ask an administrator to update your role."; export const AlertsManager = ({ alerts, @@ -113,7 +112,7 @@ export const AlertsManager = ({ if (result?.error) { return { ok: false, - error: result.status === 403 ? ALERTS_PERMISSION_ERROR : result.error, + error: getAlertMutationError(result), }; } toast({ @@ -134,7 +133,7 @@ export const AlertsManager = ({ toast({ variant: "destructive", title: "Alert update failed", - description: result.error, + description: getAlertMutationError(result), }); return; } @@ -154,7 +153,7 @@ export const AlertsManager = ({ toast({ variant: "destructive", title: "Alert delete failed", - description: result.error, + description: getAlertMutationError(result), }); return; } diff --git a/ui/app/(prowler)/alerts/_lib/alert-errors.ts b/ui/app/(prowler)/alerts/_lib/alert-errors.ts new file mode 100644 index 0000000000..a921338066 --- /dev/null +++ b/ui/app/(prowler)/alerts/_lib/alert-errors.ts @@ -0,0 +1,25 @@ +import { + ACTION_ERROR_STATUS, + getActionErrorMessage, +} from "@/lib/action-errors"; + +export const ALERTS_PERMISSION_ERROR = + "You don't have permission to manage alerts. Ask an administrator to update your role."; + +interface AlertActionErrorResult { + error: string; + status?: number; +} + +export const getAlertMutationError = ( + result: AlertActionErrorResult, + fallback = result.error, +): string => + getActionErrorMessage( + { ...result, error: fallback }, + { + messages: { + [ACTION_ERROR_STATUS.FORBIDDEN]: ALERTS_PERMISSION_ERROR, + }, + }, + ); diff --git a/ui/lib/action-errors.test.ts b/ui/lib/action-errors.test.ts new file mode 100644 index 0000000000..5731d90a4c --- /dev/null +++ b/ui/lib/action-errors.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from "vitest"; + +import { + ACTION_ERROR_MESSAGES, + ACTION_ERROR_STATUS, + getActionErrorMessage, +} from "./action-errors"; + +describe("getActionErrorMessage", () => { + it("should use the default permission error for forbidden responses", () => { + // Given + const result = { + error: "You do not have permission to perform this action.", + status: ACTION_ERROR_STATUS.FORBIDDEN, + }; + + // When + const message = getActionErrorMessage(result); + + // Then + expect(message).toBe(ACTION_ERROR_MESSAGES[ACTION_ERROR_STATUS.FORBIDDEN]); + }); + + it("should use the default subscription error for payment-required responses", () => { + // Given + const result = { + error: "Payment required.", + status: ACTION_ERROR_STATUS.PAYMENT_REQUIRED, + }; + + // When + const message = getActionErrorMessage(result); + + // Then + expect(message).toBe( + ACTION_ERROR_MESSAGES[ACTION_ERROR_STATUS.PAYMENT_REQUIRED], + ); + }); + + it("should use a feature override for handled statuses", () => { + // Given + const result = { + error: "You do not have permission to perform this action.", + status: ACTION_ERROR_STATUS.FORBIDDEN, + }; + const override = "You don't have permission to manage alerts."; + + // When + const message = getActionErrorMessage(result, { + messages: { + [ACTION_ERROR_STATUS.FORBIDDEN]: override, + }, + }); + + // Then + expect(message).toBe(override); + }); + + it("should keep the API error for unhandled statuses", () => { + // Given + const result = { + error: "Apply at least one alert-compatible Findings filter.", + status: 400, + }; + + // When + const message = getActionErrorMessage(result); + + // Then + expect(message).toBe(result.error); + }); +}); diff --git a/ui/lib/action-errors.ts b/ui/lib/action-errors.ts new file mode 100644 index 0000000000..74ae4803fa --- /dev/null +++ b/ui/lib/action-errors.ts @@ -0,0 +1,43 @@ +export const ACTION_ERROR_STATUS = { + PAYMENT_REQUIRED: 402, + FORBIDDEN: 403, +} as const; + +export type ActionErrorStatus = + (typeof ACTION_ERROR_STATUS)[keyof typeof ACTION_ERROR_STATUS]; + +export const ACTION_ERROR_MESSAGES = { + [ACTION_ERROR_STATUS.PAYMENT_REQUIRED]: + "Your subscription doesn't allow this action. Upgrade your plan or contact an administrator.", + [ACTION_ERROR_STATUS.FORBIDDEN]: + "You don't have permission to perform this action. Ask an administrator to update your role.", +} as const satisfies Record; + +interface ActionErrorResult { + error?: string; + status?: number; +} + +interface GetActionErrorMessageOptions { + messages?: Partial>; + fallback?: string; +} + +const isActionErrorStatus = ( + status: number | undefined, +): status is ActionErrorStatus => + status === ACTION_ERROR_STATUS.PAYMENT_REQUIRED || + status === ACTION_ERROR_STATUS.FORBIDDEN; + +export const getActionErrorMessage = ( + result: ActionErrorResult, + options: GetActionErrorMessageOptions = {}, +): string => { + if (isActionErrorStatus(result.status)) { + return ( + options.messages?.[result.status] ?? ACTION_ERROR_MESSAGES[result.status] + ); + } + + return result.error ?? options.fallback ?? "Oops! Something went wrong."; +};