Compare commits

...

9 Commits

Author SHA1 Message Date
alejandrobailo 08c77bea75 Merge remote-tracking branch 'origin/master' into feat/PROWLER-1418-custom-alerts-oss 2026-05-06 10:08:55 +02:00
alejandrobailo f769b8b812 test(ui): prune low-value alert tests 2026-05-05 16:36:59 +02:00
alejandrobailo 8213b46bd8 fix(ui): preserve alert finding filters 2026-05-05 16:36:49 +02:00
Alejandro Bailo 515fe1918d feat(ui): gate alerts navigation and public pages 2026-05-05 16:07:37 +02:00
Alejandro Bailo 25c11eb6dd feat(ui): seed custom alerts from findings filters 2026-05-05 16:03:17 +02:00
Alejandro Bailo 089f7e7d3c feat(ui): add custom alerts management UI 2026-05-05 15:59:32 +02:00
Alejandro Bailo a678a04850 feat(ui): add custom alerts contracts and actions 2026-05-05 15:54:09 +02:00
Alejandro Bailo 8707b51b34 refactor(ui): add shared filter and table foundation 2026-05-05 15:49:58 +02:00
alejandrobailo 833882e67e chore: initialize custom alerts oss stack 2026-05-05 15:24:07 +02:00
67 changed files with 6724 additions and 130 deletions
@@ -0,0 +1,98 @@
import { render, screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
const navigationMocks = vi.hoisted(() => ({
notFound: vi.fn(() => {
throw new Error("NEXT_NOT_FOUND");
}),
}));
vi.mock("next/navigation", () => ({
notFound: navigationMocks.notFound,
}));
vi.mock("@/app/(auth)/alerts/_components/alert-public-action", () => ({
ALERT_PUBLIC_ACTIONS: {
CONFIRM: "confirm",
UNSUBSCRIBE: "unsubscribe",
},
AlertPublicAction: ({
action,
token,
}: {
action: string;
token: string | null;
}) => (
<div>
<span>Public alerts action</span>
<span>{action}</span>
<span>{token}</span>
</div>
),
}));
import AlertsConfirmPage from "../confirm/page";
import AlertsUnsubscribePage from "../unsubscribe/page";
const unreadableSearchParams = {
then: () => {
throw new Error("search params should not be read");
},
} as unknown as Promise<{ token?: string }>;
describe("alerts public pages", () => {
beforeEach(() => {
delete process.env.NEXT_PUBLIC_IS_CLOUD_ENV;
navigationMocks.notFound.mockClear();
});
it("should not render the confirm page when Cloud is disabled", async () => {
// Given / When / Then
await expect(
AlertsConfirmPage({ searchParams: unreadableSearchParams }),
).rejects.toThrow("NEXT_NOT_FOUND");
expect(navigationMocks.notFound).toHaveBeenCalledOnce();
});
it("should not render the unsubscribe page when Cloud is disabled", async () => {
// Given / When / Then
await expect(
AlertsUnsubscribePage({ searchParams: unreadableSearchParams }),
).rejects.toThrow("NEXT_NOT_FOUND");
expect(navigationMocks.notFound).toHaveBeenCalledOnce();
});
it("should render the confirm action when Cloud is enabled", async () => {
// Given
process.env.NEXT_PUBLIC_IS_CLOUD_ENV = "true";
// When
render(
await AlertsConfirmPage({
searchParams: Promise.resolve({ token: "confirm-token" }),
}),
);
// Then
expect(screen.getByText("Public alerts action")).toBeInTheDocument();
expect(screen.getByText("confirm")).toBeInTheDocument();
expect(screen.getByText("confirm-token")).toBeInTheDocument();
});
it("should render the unsubscribe action when Cloud is enabled", async () => {
// Given
process.env.NEXT_PUBLIC_IS_CLOUD_ENV = "true";
// When
render(
await AlertsUnsubscribePage({
searchParams: Promise.resolve({ token: "unsubscribe-token" }),
}),
);
// Then
expect(screen.getByText("Public alerts action")).toBeInTheDocument();
expect(screen.getByText("unsubscribe")).toBeInTheDocument();
expect(screen.getByText("unsubscribe-token")).toBeInTheDocument();
});
});
@@ -0,0 +1,234 @@
"use client";
import { CheckCircle2, Loader2, XCircleIcon } from "lucide-react";
import Link from "next/link";
import { useState } from "react";
import {
confirmRecipient,
unsubscribeRecipient,
} from "@/app/(prowler)/alerts/_actions";
import type { AlertPublicResponse } from "@/app/(prowler)/alerts/_types";
import { Button, Card, CardContent } from "@/components/shadcn";
// NOT FOR THE MVP: this UI supports public confirm/unsubscribe email links.
// The MVP assumes recipients belong to the tenant and are already confirmed.
export const ALERT_PUBLIC_ACTIONS = {
CONFIRM: "confirm",
UNSUBSCRIBE: "unsubscribe",
} as const;
export type AlertPublicActionKind =
(typeof ALERT_PUBLIC_ACTIONS)[keyof typeof ALERT_PUBLIC_ACTIONS];
interface AlertPublicActionProps {
action: AlertPublicActionKind;
token: string | null;
idleTitle: string;
idleDescription: string;
ctaLabel: string;
}
interface AlertPublicResultProps {
variant: "success" | "error";
title: string;
description: string;
primaryHref?: string;
primaryLabel?: string;
supportHref?: string;
}
const runners: Record<
AlertPublicActionKind,
(token: string) => Promise<AlertPublicResponse>
> = {
confirm: confirmRecipient,
unsubscribe: unsubscribeRecipient,
};
const AlertPublicResult = ({
variant,
title,
description,
primaryHref,
primaryLabel,
supportHref = "https://prowler.com/contact",
}: AlertPublicResultProps) => (
<main className="flex min-h-screen items-center justify-center p-6">
<Card variant="base" padding="lg" className="w-full max-w-md">
<CardContent className="flex flex-col items-center gap-5 p-0 text-center">
<div
className={
variant === "success"
? "bg-prowler-green-medium/10 flex h-14 w-14 items-center justify-center rounded-full"
: "flex h-14 w-14 items-center justify-center rounded-full bg-rose-500/10"
}
>
{variant === "success" ? (
<CheckCircle2 className="text-prowler-green-medium h-7 w-7" />
) : (
<XCircleIcon className="h-7 w-7 text-rose-500" />
)}
</div>
<div className="flex flex-col gap-2">
<h1 className="text-xl font-semibold text-gray-900 dark:text-gray-100">
{title}
</h1>
<p className="max-w-sm text-sm text-gray-600 dark:text-gray-300">
{description}
</p>
</div>
<div className="flex flex-wrap items-center justify-center gap-2">
{primaryHref && primaryLabel && (
<Button asChild>
<Link href={primaryHref}>{primaryLabel}</Link>
</Button>
)}
<Button asChild variant="outline">
<Link href={supportHref} target="_blank" rel="noopener noreferrer">
Contact support
</Link>
</Button>
</div>
</CardContent>
</Card>
</main>
);
const renderResult = (
action: AlertPublicActionKind,
result: AlertPublicResponse,
): AlertPublicResultProps => {
switch (result.state) {
case "confirmed":
return {
variant: "success",
title: "You're confirmed",
description:
"This address now receives Prowler Cloud alerts based on your team's alerts.",
primaryHref: "https://prowler.com",
primaryLabel: "Open Prowler Cloud",
};
case "already_confirmed":
return {
variant: "success",
title: "Already confirmed",
description:
"Nothing to do, this address is already subscribed to Prowler Cloud alerts.",
};
case "unsubscribed":
return {
variant: "success",
title: "You're unsubscribed",
description:
"We won't send you any more alert digests at this address. Pending notifications have been cancelled.",
};
case "already_unsubscribed":
return {
variant: "success",
title: "Already unsubscribed",
description:
"This address is already unsubscribed from Prowler Cloud alerts.",
};
case "cannot_confirm":
return {
variant: "error",
title: "This address can't be confirmed",
description:
"Earlier this address unsubscribed or stopped receiving deliveries. Ask your team to re-add it from the Prowler Cloud admin or contact support.",
};
case "superseded":
return {
variant: "error",
title: "Link superseded",
description:
"A newer confirmation email has been issued for this address. Open the most recent invitation and use that link instead.",
};
case "missing_token":
return {
variant: "error",
title: "Link is missing the token",
description:
"Open the original link from your email so the URL includes the token issued by Prowler Cloud.",
};
case "invalid_token":
return {
variant: "error",
title: "Link is invalid or expired",
description: `This ${action} link is no longer valid. Ask your team to resend the email.`,
};
case "not_found":
return {
variant: "error",
title: "Recipient not found",
description:
"We couldn't locate the recipient referenced by this link. It may have been removed.",
};
case "network_error":
default:
return {
variant: "error",
title: "We couldn't reach the server",
description:
result.message ||
"Try again in a few seconds. If this keeps happening, contact support.",
};
}
};
export const AlertPublicAction = ({
action,
token,
idleTitle,
idleDescription,
ctaLabel,
}: AlertPublicActionProps) => {
const [pending, setPending] = useState(false);
const [result, setResult] = useState<AlertPublicResponse | null>(null);
if (!token) {
const view = renderResult(action, {
state: "missing_token",
message: "Token query parameter is missing.",
});
return <AlertPublicResult {...view} />;
}
if (result) {
const view = renderResult(action, result);
return <AlertPublicResult {...view} />;
}
const handleClick = async () => {
setPending(true);
const next = await runners[action](token);
setPending(false);
setResult(next);
};
return (
<main className="flex min-h-screen items-center justify-center p-6">
<Card variant="base" padding="lg" className="w-full max-w-md">
<CardContent className="flex flex-col items-center gap-5 p-0 text-center">
<div className="flex flex-col gap-2">
<h1 className="text-xl font-semibold text-gray-900 dark:text-gray-100">
{idleTitle}
</h1>
<p className="max-w-sm text-sm text-gray-600 dark:text-gray-300">
{idleDescription}
</p>
</div>
<Button onClick={handleClick} disabled={pending}>
{pending ? (
<>
<Loader2 className="size-4 animate-spin" />
Working...
</>
) : (
ctaLabel
)}
</Button>
</CardContent>
</Card>
</main>
);
};
+32
View File
@@ -0,0 +1,32 @@
import { notFound } from "next/navigation";
import {
ALERT_PUBLIC_ACTIONS,
AlertPublicAction,
} from "@/app/(auth)/alerts/_components/alert-public-action";
import { isAlertsEnabled } from "@/app/(prowler)/alerts/_lib/env";
// NOT FOR THE MVP: tenant-owned recipients are treated as already confirmed.
// Keep only if we reintroduce public recipient consent links.
interface AlertsConfirmPageProps {
searchParams: Promise<{ token?: string }>;
}
export default async function AlertsConfirmPage({
searchParams,
}: AlertsConfirmPageProps) {
if (!isAlertsEnabled()) {
notFound();
}
const { token } = await searchParams;
return (
<AlertPublicAction
action={ALERT_PUBLIC_ACTIONS.CONFIRM}
token={token ?? null}
idleTitle="Confirm your Prowler Cloud alerts subscription"
idleDescription="Click the button below to confirm this email address. After confirming, alert digests for the alerts your team picked you for will start arriving here."
ctaLabel="Confirm subscription"
/>
);
}
+32
View File
@@ -0,0 +1,32 @@
import { notFound } from "next/navigation";
import {
ALERT_PUBLIC_ACTIONS,
AlertPublicAction,
} from "@/app/(auth)/alerts/_components/alert-public-action";
import { isAlertsEnabled } from "@/app/(prowler)/alerts/_lib/env";
// NOT FOR THE MVP: recipient changes are managed inside the tenant product.
// Keep only if alert emails need public unsubscribe links.
interface AlertsUnsubscribePageProps {
searchParams: Promise<{ token?: string }>;
}
export default async function AlertsUnsubscribePage({
searchParams,
}: AlertsUnsubscribePageProps) {
if (!isAlertsEnabled()) {
notFound();
}
const { token } = await searchParams;
return (
<AlertPublicAction
action={ALERT_PUBLIC_ACTIONS.UNSUBSCRIBE}
token={token ?? null}
idleTitle="Unsubscribe from Prowler Cloud alerts"
idleDescription="Click the button below to stop receiving alert digests at this email address. Pending notifications already in flight will be cancelled."
ctaLabel="Unsubscribe"
/>
);
}
@@ -138,4 +138,13 @@ describe("AccountsSelector", () => {
screen.getByText("Production AWS").closest("[data-value]"),
).toHaveAttribute("data-keywords", expect.stringContaining("123456789012"));
});
it("disables select all when every account is already shown", () => {
render(<AccountsSelector providers={providers} />);
expect(
screen.getByRole("option", { name: /select all accounts/i }),
).toHaveAttribute("aria-disabled", "true");
expect(screen.getByText("All selected")).toBeInTheDocument();
});
});
@@ -171,18 +171,23 @@ export function AccountsSelector({
<div
role="option"
aria-selected={selectedIds.length === 0}
aria-disabled={selectedIds.length === 0}
aria-label="Select all accounts (clears current selection to show all)"
tabIndex={0}
className="text-text-neutral-secondary flex w-full cursor-pointer items-center gap-3 rounded-lg px-4 py-3 text-sm font-semibold hover:bg-slate-200 dark:hover:bg-slate-700/50"
onClick={() => handleMultiValueChange([])}
className="text-text-neutral-secondary flex w-full cursor-pointer items-center gap-3 rounded-lg px-4 py-3 text-sm font-semibold hover:bg-slate-200 aria-disabled:cursor-not-allowed aria-disabled:opacity-50 dark:hover:bg-slate-700/50"
onClick={() => {
if (selectedIds.length === 0) return;
handleMultiValueChange([]);
}}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
if (selectedIds.length === 0) return;
handleMultiValueChange([]);
}
}}
>
Select All
{selectedIds.length === 0 ? "All selected" : "Select All"}
</div>
{visibleProviders.map((p) => {
const id = p.id;
@@ -135,4 +135,13 @@ describe("ProviderTypeSelector", () => {
expect.stringContaining("Amazon Web Services"),
);
});
it("disables select all when every provider is already shown", () => {
render(<ProviderTypeSelector providers={providers} />);
expect(
screen.getByRole("option", { name: /select all providers/i }),
).toHaveAttribute("aria-disabled", "true");
expect(screen.getByText("All selected")).toBeInTheDocument();
});
});
@@ -295,18 +295,23 @@ export const ProviderTypeSelector = ({
<div
role="option"
aria-selected={selectedTypes.length === 0}
aria-disabled={selectedTypes.length === 0}
aria-label="Select all providers (clears current selection to show all)"
tabIndex={0}
className="text-text-neutral-secondary flex w-full cursor-pointer items-center gap-3 rounded-lg px-4 py-3 text-sm font-semibold hover:bg-slate-200 dark:hover:bg-slate-700/50"
onClick={() => handleMultiValueChange([])}
className="text-text-neutral-secondary flex w-full cursor-pointer items-center gap-3 rounded-lg px-4 py-3 text-sm font-semibold hover:bg-slate-200 aria-disabled:cursor-not-allowed aria-disabled:opacity-50 dark:hover:bg-slate-700/50"
onClick={() => {
if (selectedTypes.length === 0) return;
handleMultiValueChange([]);
}}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
if (selectedTypes.length === 0) return;
handleMultiValueChange([]);
}
}}
>
Select All
{selectedTypes.length === 0 ? "All selected" : "Select All"}
</div>
{availableTypes.map((providerType) => (
<MultiSelectItem
@@ -0,0 +1,167 @@
"use server";
import * as Sentry from "@sentry/nextjs";
import { apiBaseUrl, getAuthHeaders } from "@/lib";
import { buildAlertsDisabledResult, isAlertsEnabled } from "../_lib/env";
import {
buildSuccessResult,
buildUnexpectedError,
mapJsonApiErrorToAction,
} from "../_lib/error-mapping";
import type { AlertsActionResult } from "../_types";
export interface AlertsRequestOptions {
method?: "GET" | "POST" | "PATCH" | "DELETE" | "OPTIONS";
query?: URLSearchParams | Record<string, string | string[] | undefined>;
body?: unknown;
contentType?: boolean;
attachAuth?: boolean;
cache?: RequestCache;
signal?: AbortSignal;
/**
* Override the `Accept` / `Content-Type` headers. Useful for endpoints that
* use plain `JSONRenderer`/`JSONParser` instead of JSON:API renderers (e.g.
* the alerts dry-run endpoints `/preview` and `/{id}/test`).
*/
acceptOverride?: string;
contentTypeOverride?: string;
}
const isPairArray = (
value: unknown,
): value is ReadonlyArray<readonly [string, string]> =>
Array.isArray(value) &&
value.every(
(entry) =>
Array.isArray(entry) && entry.length >= 2 && typeof entry[0] === "string",
);
const buildUrl = (
path: string,
query: AlertsRequestOptions["query"],
): string => {
if (!apiBaseUrl) {
throw new Error("NEXT_PUBLIC_API_BASE_URL is not configured.");
}
const url = new URL(`${apiBaseUrl}${path}`);
if (!query) return url.toString();
// Real URLSearchParams (RSC → action call within the same process).
if (query instanceof URLSearchParams) {
query.forEach((value, key) => url.searchParams.append(key, value));
return url.toString();
}
// Serialized URLSearchParams shape (client → server action crosses the
// boundary; Next.js converts URLSearchParams to its [[k, v], ...] form).
if (isPairArray(query)) {
for (const [key, value] of query) {
url.searchParams.append(key, String(value ?? ""));
}
return url.toString();
}
for (const [key, value] of Object.entries(query)) {
if (value === undefined) continue;
if (Array.isArray(value)) {
for (const v of value) url.searchParams.append(key, v);
continue;
}
url.searchParams.set(key, value);
}
return url.toString();
};
const safeJson = async (response: Response): Promise<unknown> => {
const text = await response.text().catch(() => "");
if (!text) return null;
try {
return JSON.parse(text);
} catch {
return null;
}
};
export const alertsRequest = async <T>(
path: string,
options: AlertsRequestOptions = {},
): Promise<AlertsActionResult<T>> => {
if (!isAlertsEnabled()) {
return buildAlertsDisabledResult<T>();
}
const {
method = "GET",
query,
body,
contentType = method !== "GET" && method !== "DELETE",
attachAuth = true,
cache,
signal,
acceptOverride,
contentTypeOverride,
} = options;
try {
const baseHeaders = attachAuth
? await getAuthHeaders({ contentType })
: ({
Accept: "application/vnd.api+json",
...(contentType
? { "Content-Type": "application/vnd.api+json" }
: {}),
} as Record<string, string>);
const headers: Record<string, string> = { ...baseHeaders };
if (acceptOverride) headers.Accept = acceptOverride;
if (contentTypeOverride) headers["Content-Type"] = contentTypeOverride;
const url = buildUrl(path, query);
const response = await fetch(url, {
method,
headers,
body: body === undefined ? undefined : JSON.stringify(body),
cache: cache ?? "no-store",
signal,
});
if (!response.ok) {
const parsedBody = (await safeJson(response)) as Parameters<
typeof mapJsonApiErrorToAction
>[1];
const error = mapJsonApiErrorToAction(
response.status,
parsedBody,
response.headers.get("retry-after"),
);
Sentry.addBreadcrumb({
category: "alerts.request",
message: `${method} ${path} failed`,
level: "warning",
data: {
status: response.status,
code: error.code,
retry_after_seconds: error.retryAfterSeconds,
},
});
return { ok: false, error };
}
if (response.status === 204) {
return buildSuccessResult(undefined as T, null);
}
const parsed = (await safeJson(response)) as Parameters<
typeof mapJsonApiErrorToAction
>[1] & { data?: unknown };
return buildSuccessResult((parsed ?? null) as T, parsed);
} catch (error) {
Sentry.captureException(error, {
tags: { error_source: "alerts.request", method },
level: "error",
});
return {
ok: false,
error: buildUnexpectedError(
error instanceof Error ? error.message : "Unexpected error.",
),
};
}
};
@@ -0,0 +1,295 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@/lib", () => ({
apiBaseUrl: "https://api.test/api/v1",
getAuthHeaders: vi.fn(async () => ({
Accept: "application/vnd.api+json",
Authorization: "Bearer test-token",
"Content-Type": "application/vnd.api+json",
})),
}));
vi.mock("next/cache", () => ({
revalidatePath: vi.fn(),
unstable_cache: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
}));
vi.mock("@sentry/nextjs", () => ({
addBreadcrumb: vi.fn(),
captureException: vi.fn(),
}));
import {
ALERT_AGGREGATE_OPS,
ALERT_ERROR_CODES,
ALERT_TRIGGER_KINDS,
} from "../_types";
import {
createAlert,
deleteAlert,
disableAlert,
enableAlert,
listAlerts,
previewAlertCondition,
updateAlert,
} from "./alerts";
const mockFetchOnce = (
status: number,
body: unknown,
headers: Record<string, string> = {},
) => {
const response = new Response(body === null ? null : JSON.stringify(body), {
status,
headers: {
"Content-Type": "application/vnd.api+json",
...headers,
},
});
vi.stubGlobal(
"fetch",
vi.fn(async () => response),
);
};
const captureFetchArgs = (status: number, body: unknown) => {
const calls: Array<{ url: string; init: RequestInit }> = [];
const fetchMock = vi.fn(async (url: RequestInfo, init?: RequestInit) => {
calls.push({ url: url.toString(), init: init ?? {} });
return new Response(body === null ? null : JSON.stringify(body), {
status,
headers: { "Content-Type": "application/vnd.api+json" },
});
});
vi.stubGlobal("fetch", fetchMock);
return calls;
};
beforeEach(() => {
vi.unstubAllGlobals();
process.env.NEXT_PUBLIC_IS_CLOUD_ENV = "true";
});
afterEach(() => {
vi.clearAllMocks();
});
describe("listAlerts", () => {
it("returns a controlled error without fetching when alerts are disabled", async () => {
process.env.NEXT_PUBLIC_IS_CLOUD_ENV = "false";
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
const result = await listAlerts();
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.code).toBe(ALERT_ERROR_CODES.FORBIDDEN);
expect(result.error.status).toBe(403);
}
expect(fetchMock).not.toHaveBeenCalled();
});
it("returns the parsed list payload on success", async () => {
mockFetchOnce(200, { data: [], meta: { pagination: { count: 0 } } });
const result = await listAlerts(
new URLSearchParams("filter[enabled]=true"),
);
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data.data).toEqual([]);
expect(result.data.meta?.pagination?.count).toBe(0);
}
});
it("forwards searchParams as query string", async () => {
const calls = captureFetchArgs(200, { data: [] });
await listAlerts(new URLSearchParams("filter[trigger]=daily"));
expect(calls[0].url).toContain("filter%5Btrigger%5D=daily");
});
});
describe("createAlert", () => {
it("posts a JSON:API envelope and returns the new alert", async () => {
const calls = captureFetchArgs(201, {
data: {
id: "alert-1",
type: "alert-rules",
attributes: { name: "n", trigger: "after_scan" },
},
});
const result = await createAlert({
name: "Daily critical",
trigger: ALERT_TRIGGER_KINDS.AFTER_SCAN,
condition: {
op: ALERT_AGGREGATE_OPS.ANY,
filter: { severity: ["critical"] },
},
});
expect(result.ok).toBe(true);
expect(calls[0].init.method).toBe("POST");
const body = JSON.parse((calls[0].init.body as string) ?? "{}");
expect(body.data.type).toBe("alert-rules");
expect(body.data.attributes.schema_version).toBe(1);
});
it("surfaces JSON:API validation errors with the API code", async () => {
mockFetchOnce(400, {
errors: [
{
code: "unknown_filter_field",
detail: "Unknown filter field 'foo'.",
source: { pointer: "/data/attributes/condition/filter/foo" },
},
],
});
const result = await createAlert({
name: "x",
trigger: ALERT_TRIGGER_KINDS.AFTER_SCAN,
condition: {
op: ALERT_AGGREGATE_OPS.ANY,
filter: { severity: ["high"] },
},
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.code).toBe(ALERT_ERROR_CODES.UNKNOWN_FILTER_FIELD);
}
});
it("sends an empty recipient list when provided", async () => {
const calls = captureFetchArgs(201, {
data: {
id: "alert-1",
type: "alert-rules",
attributes: { name: "n", trigger: "after_scan" },
},
});
await createAlert({
name: "No recipients yet",
trigger: ALERT_TRIGGER_KINDS.AFTER_SCAN,
condition: {
op: ALERT_AGGREGATE_OPS.ANY,
filter: { severity: ["critical"] },
},
recipientEmails: [],
});
const body = JSON.parse((calls[0].init.body as string) ?? "{}");
expect(body.data.attributes.recipient_emails).toEqual([]);
});
});
describe("updateAlert", () => {
it("PATCHes the alert with the id in the URL", async () => {
const calls = captureFetchArgs(200, {
data: { id: "alert-1", type: "alert-rules", attributes: {} },
});
const result = await updateAlert("alert-1", {
name: "Updated",
trigger: ALERT_TRIGGER_KINDS.DAILY,
condition: {
op: ALERT_AGGREGATE_OPS.ANY,
filter: { severity: ["critical"] },
},
});
expect(result.ok).toBe(true);
expect(calls[0].url).toContain("/alerts/rules/alert-1");
expect(calls[0].init.method).toBe("PATCH");
});
});
describe("deleteAlert", () => {
it("returns ok on 204 without body", async () => {
const calls = captureFetchArgs(204, null);
const result = await deleteAlert("alert-1");
expect(result.ok).toBe(true);
expect(calls[0].init.method).toBe("DELETE");
});
});
describe("enable / disable", () => {
it("PATCHes enabled true to the alert rule endpoint", async () => {
const calls = captureFetchArgs(200, {
data: { id: "alert-1", type: "alert-rules", attributes: {} },
});
await enableAlert("alert-1");
expect(calls[0].url).toMatch(/\/alerts\/rules\/alert-1$/);
expect(calls[0].init.method).toBe("PATCH");
const body = JSON.parse((calls[0].init.body as string) ?? "{}");
expect(body).toEqual({
data: {
type: "alert-rules",
id: "alert-1",
attributes: { enabled: true },
},
});
});
it("PATCHes enabled false to the alert rule endpoint", async () => {
const calls = captureFetchArgs(200, {
data: { id: "alert-1", type: "alert-rules", attributes: {} },
});
await disableAlert("alert-1");
expect(calls[0].url).toMatch(/\/alerts\/rules\/alert-1$/);
expect(calls[0].init.method).toBe("PATCH");
const body = JSON.parse((calls[0].init.body as string) ?? "{}");
expect(body).toEqual({
data: {
type: "alert-rules",
id: "alert-1",
attributes: { enabled: false },
},
});
});
});
describe("previewAlertCondition", () => {
it("posts to /preview and forwards trigger", async () => {
const calls = captureFetchArgs(200, { data: { attributes: {} } });
await previewAlertCondition({
condition: {
op: ALERT_AGGREGATE_OPS.ANY,
filter: { severity: ["critical"] },
},
trigger: ALERT_TRIGGER_KINDS.DAILY,
});
expect(calls[0].url).toMatch(/\/alerts\/rules\/preview$/);
expect(calls[0].init.method).toBe("POST");
const body = JSON.parse((calls[0].init.body as string) ?? "{}");
expect(body.trigger).toBe("daily");
});
it("unwraps JSON:API preview attributes into the preview model", async () => {
mockFetchOnce(200, {
data: {
type: "alert-rule-previews",
id: "preview",
attributes: {
would_fire: true,
summary: {
finding_count_total: 7,
top_severity: "critical",
},
sample_finding_ids: [],
evaluation_failed: false,
duration_ms: 42,
},
},
});
const result = await previewAlertCondition({
condition: {
op: ALERT_AGGREGATE_OPS.ANY,
filter: { severity: ["critical"] },
},
});
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data.would_fire).toBe(true);
expect(result.data.summary.finding_count_total).toBe(7);
expect(result.data.duration_ms).toBe(42);
}
});
});
+257
View File
@@ -0,0 +1,257 @@
"use server";
import * as Sentry from "@sentry/nextjs";
import { revalidatePath } from "next/cache";
import {
ALERT_SCHEMA_VERSION,
type AlertCondition,
type AlertPreviewResponse,
type AlertRule,
type AlertsActionResult,
type AlertTriggerKind,
} from "../_types";
import { alertsRequest } from "./_request";
const ALERT_RULES_API_PATH = "/alerts/rules";
const ALERTS_BASE_PATH = "/alerts";
const revalidateAlertsBase = () => {
revalidatePath(ALERTS_BASE_PATH);
};
const revalidateAlert = (alertId: string) => {
revalidatePath(`${ALERTS_BASE_PATH}/${alertId}`);
};
const breadcrumb = (
category: string,
message: string,
data?: Record<string, unknown>,
) => {
Sentry.addBreadcrumb({ category, message, level: "info", data });
};
export interface AlertsListResponse {
data: AlertRule[];
meta?: {
pagination?: { count: number; pages: number; page: number };
};
}
export const listAlerts = async (
searchParams?: URLSearchParams,
): Promise<AlertsActionResult<AlertsListResponse>> =>
alertsRequest<AlertsListResponse>(ALERT_RULES_API_PATH, {
method: "GET",
query: searchParams,
});
export const getAlert = async (
alertId: string,
): Promise<AlertsActionResult<{ data: AlertRule }>> =>
alertsRequest<{ data: AlertRule }>(`${ALERT_RULES_API_PATH}/${alertId}`, {
method: "GET",
});
export interface AlertPayload {
name: string;
description?: string;
enabled?: boolean;
trigger: AlertTriggerKind;
condition: AlertCondition;
/**
* List of recipient email addresses. The API resolves them to existing
* `AlertRecipient` rows or creates new pending ones with confirmation
* emails. Recipient IDs are NOT used by the rule write path.
*/
recipientEmails?: string[];
}
const buildRuleEnvelope = (payload: AlertPayload, alertId?: string) => ({
data: {
type: "alert-rules",
...(alertId ? { id: alertId } : {}),
attributes: {
name: payload.name,
description: payload.description ?? "",
enabled: payload.enabled ?? true,
trigger: payload.trigger,
condition: payload.condition,
schema_version: ALERT_SCHEMA_VERSION,
...(payload.recipientEmails !== undefined
? { recipient_emails: payload.recipientEmails }
: {}),
},
},
});
const buildEnabledEnvelope = (alertId: string, enabled: boolean) => ({
data: {
type: "alert-rules",
id: alertId,
attributes: { enabled },
},
});
export const createAlert = async (
payload: AlertPayload,
): Promise<AlertsActionResult<{ data: AlertRule }>> => {
const result = await alertsRequest<{ data: AlertRule }>(
ALERT_RULES_API_PATH,
{
method: "POST",
body: buildRuleEnvelope(payload),
},
);
if (result.ok) {
breadcrumb("alerts.create", "Created alert", {
alertId: result.data?.data?.id,
});
revalidateAlertsBase();
}
return result;
};
export const updateAlert = async (
alertId: string,
payload: AlertPayload,
): Promise<AlertsActionResult<{ data: AlertRule }>> => {
const result = await alertsRequest<{ data: AlertRule }>(
`${ALERT_RULES_API_PATH}/${alertId}`,
{
method: "PATCH",
body: buildRuleEnvelope(payload, alertId),
},
);
if (result.ok) {
breadcrumb("alerts.update", "Updated alert", { alertId });
revalidateAlertsBase();
revalidateAlert(alertId);
}
return result;
};
export const deleteAlert = async (
alertId: string,
): Promise<AlertsActionResult<undefined>> => {
const result = await alertsRequest<undefined>(
`${ALERT_RULES_API_PATH}/${alertId}`,
{
method: "DELETE",
},
);
if (result.ok) {
breadcrumb("alerts.delete", "Deleted alert", { alertId });
revalidateAlertsBase();
}
return result;
};
export const enableAlert = async (
alertId: string,
): Promise<AlertsActionResult<{ data: AlertRule }>> => {
const result = await alertsRequest<{ data: AlertRule }>(
`${ALERT_RULES_API_PATH}/${alertId}`,
{
method: "PATCH",
body: buildEnabledEnvelope(alertId, true),
},
);
if (result.ok) {
breadcrumb("alerts.enable", "Enabled alert", { alertId });
revalidateAlertsBase();
revalidateAlert(alertId);
}
return result;
};
export const disableAlert = async (
alertId: string,
): Promise<AlertsActionResult<{ data: AlertRule }>> => {
const result = await alertsRequest<{ data: AlertRule }>(
`${ALERT_RULES_API_PATH}/${alertId}`,
{
method: "PATCH",
body: buildEnabledEnvelope(alertId, false),
},
);
if (result.ok) {
breadcrumb("alerts.disable", "Disabled alert", { alertId });
revalidateAlertsBase();
revalidateAlert(alertId);
}
return result;
};
interface AlertPreviewEnvelope {
data?: {
type?: "alert-rule-previews";
id?: string;
attributes?: Partial<AlertPreviewResponse>;
};
meta?: Record<string, unknown>;
}
const isAlertPreviewEnvelope = (
value: AlertPreviewResponse | AlertPreviewEnvelope,
): value is AlertPreviewEnvelope =>
"data" in value &&
typeof value.data === "object" &&
value.data !== null &&
"attributes" in value.data;
const normalizePreviewResponse = (
value: AlertPreviewResponse | AlertPreviewEnvelope,
): AlertPreviewResponse => {
const attributes = isAlertPreviewEnvelope(value)
? value.data?.attributes
: value;
if (!attributes) {
return {
would_fire: false,
summary: { finding_count_total: 0 },
sample_finding_ids: [],
evaluation_failed: true,
last_error: "Preview response is missing attributes.",
};
}
const summary = attributes.summary ?? { finding_count_total: 0 };
return {
would_fire: attributes.would_fire ?? false,
summary,
sample_finding_ids:
attributes.sample_finding_ids ?? summary.top_findings ?? [],
evaluation_failed: attributes.evaluation_failed ?? false,
last_error: attributes.last_error,
summary_fallback: attributes.summary_fallback,
duration_ms: attributes.duration_ms,
};
};
export const previewAlertCondition = async (payload: {
condition: AlertCondition;
trigger?: AlertTriggerKind;
}): Promise<AlertsActionResult<AlertPreviewResponse>> => {
const result = await alertsRequest<
AlertPreviewResponse | AlertPreviewEnvelope
>(`${ALERT_RULES_API_PATH}/preview`, {
method: "POST",
body: {
condition: payload.condition,
trigger: payload.trigger,
},
acceptOverride: "application/vnd.api+json, application/json",
contentTypeOverride: "application/json",
});
breadcrumb(
result.ok ? "alerts.preview" : "alerts.preview.failed",
"Previewed alert condition",
{ ok: result.ok },
);
if (!result.ok) return result;
return { ...result, data: normalizePreviewResponse(result.data) };
};
@@ -0,0 +1,3 @@
export * from "./alerts";
export * from "./public";
export * from "./recipients";
@@ -0,0 +1,105 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@/lib", () => ({
apiBaseUrl: "https://api.test/api/v1",
getAuthHeaders: vi.fn(async () => ({
Accept: "application/vnd.api+json",
Authorization: "Bearer test-token",
})),
}));
vi.mock("next/cache", () => ({
revalidatePath: vi.fn(),
}));
vi.mock("@sentry/nextjs", () => ({
addBreadcrumb: vi.fn(),
captureException: vi.fn(),
}));
import { confirmRecipient, unsubscribeRecipient } from "./public";
const captureFetchArgs = (status: number, body: unknown) => {
const calls: Array<{ url: string; init: RequestInit }> = [];
const fetchMock = vi.fn(async (url: RequestInfo, init?: RequestInit) => {
calls.push({ url: url.toString(), init: init ?? {} });
return new Response(body === null ? null : JSON.stringify(body), {
status,
headers: { "Content-Type": "application/json" },
});
});
vi.stubGlobal("fetch", fetchMock);
return calls;
};
beforeEach(() => {
vi.unstubAllGlobals();
process.env.NEXT_PUBLIC_IS_CLOUD_ENV = "true";
});
afterEach(() => {
vi.clearAllMocks();
});
describe("confirmRecipient", () => {
it("returns a controlled response without fetching when alerts are disabled", async () => {
process.env.NEXT_PUBLIC_IS_CLOUD_ENV = "false";
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
const result = await confirmRecipient("token-123");
expect(result.state).toBe("network_error");
expect(result.message).toMatch(/Prowler Cloud/i);
expect(fetchMock).not.toHaveBeenCalled();
});
it("does NOT attach Authorization header (public endpoint)", async () => {
const calls = captureFetchArgs(200, {
state: "confirmed",
message: "Recipient confirmed.",
});
await confirmRecipient("token-123");
const headers = (calls[0].init.headers ?? {}) as Record<string, string>;
expect(headers.Authorization).toBeUndefined();
expect(calls[0].url).toContain("/alerts/recipients/confirm");
expect(calls[0].url).toContain("token=token-123");
});
it("returns the API state on a 200 response", async () => {
captureFetchArgs(200, {
state: "already_confirmed",
message: "Already confirmed.",
});
const result = await confirmRecipient("token-123");
expect(result.state).toBe("already_confirmed");
expect(result.message).toBe("Already confirmed.");
});
it("surfaces invalid_token state from a 400 response", async () => {
captureFetchArgs(400, {
state: "invalid_token",
message: "Token is malformed.",
});
const result = await confirmRecipient("bad-token");
expect(result.state).toBe("invalid_token");
});
it("folds malformed bodies into network_error", async () => {
captureFetchArgs(500, "not-json");
const result = await confirmRecipient("token-123");
expect(result.state).toBe("network_error");
});
});
describe("unsubscribeRecipient", () => {
it("hits /unsubscribe with the token and returns the API state", async () => {
const calls = captureFetchArgs(200, {
state: "unsubscribed",
message: "Unsubscribed.",
});
const result = await unsubscribeRecipient("token-xyz");
expect(result.state).toBe("unsubscribed");
expect(calls[0].url).toContain("/alerts/recipients/unsubscribe");
});
});
@@ -0,0 +1,65 @@
"use server";
import { apiBaseUrl } from "@/lib";
import {
buildAlertsDisabledPublicResponse,
isAlertsEnabled,
} from "../_lib/env";
import type { AlertPublicResponse } from "../_types";
// NOT FOR THE MVP: public confirm/unsubscribe endpoints are only needed for
// recipient consent links. MVP tenant recipients are already confirmed.
const PUBLIC_PATH = "/alerts/recipients";
const _call = async (
endpoint: "confirm" | "unsubscribe",
token: string,
): Promise<AlertPublicResponse> => {
if (!isAlertsEnabled()) {
return buildAlertsDisabledPublicResponse();
}
if (!apiBaseUrl) {
return {
state: "network_error",
message: "API base URL is not configured.",
};
}
try {
const url = `${apiBaseUrl}${PUBLIC_PATH}/${endpoint}?token=${encodeURIComponent(token)}`;
const response = await fetch(url, {
method: "GET",
headers: { Accept: "application/json" },
cache: "no-store",
});
const body = (await response
.json()
.catch(() => null)) as AlertPublicResponse | null;
if (body && typeof body === "object" && "state" in body) {
return body;
}
return {
state: "network_error",
message: `Unexpected response from server (HTTP ${response.status}).`,
};
} catch (err) {
return {
state: "network_error",
message:
err instanceof Error ? err.message : "Could not reach the server.",
};
}
};
export async function confirmRecipient(
token: string,
): Promise<AlertPublicResponse> {
return _call("confirm", token);
}
export async function unsubscribeRecipient(
token: string,
): Promise<AlertPublicResponse> {
return _call("unsubscribe", token);
}
@@ -0,0 +1,69 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@/lib", () => ({
apiBaseUrl: "https://api.test/api/v1",
getAuthHeaders: vi.fn(async () => ({
Accept: "application/vnd.api+json",
Authorization: "Bearer test-token",
"Content-Type": "application/vnd.api+json",
})),
}));
import { listAlertRecipients } from "./recipients";
const captureFetchArgs = (status: number, body: unknown) => {
const calls: Array<{ url: string; init: RequestInit }> = [];
const fetchMock = vi.fn(async (url: RequestInfo, init?: RequestInit) => {
calls.push({ url: url.toString(), init: init ?? {} });
return new Response(body === null ? null : JSON.stringify(body), {
status,
headers: { "Content-Type": "application/vnd.api+json" },
});
});
vi.stubGlobal("fetch", fetchMock);
return calls;
};
beforeEach(() => {
vi.unstubAllGlobals();
process.env.NEXT_PUBLIC_IS_CLOUD_ENV = "true";
});
afterEach(() => {
vi.clearAllMocks();
});
describe("listAlertRecipients", () => {
it("returns a controlled error without fetching when alerts are disabled", async () => {
process.env.NEXT_PUBLIC_IS_CLOUD_ENV = "false";
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
const result = await listAlertRecipients();
expect(result.ok).toBe(false);
expect(fetchMock).not.toHaveBeenCalled();
});
it("returns the parsed list payload", async () => {
const calls = captureFetchArgs(200, {
data: [
{
id: "1",
type: "alert-recipients",
attributes: { email: "a@b.test", status: "pending" },
},
],
meta: { pagination: { count: 1, page: 1, pages: 1 } },
});
const result = await listAlertRecipients(
new URLSearchParams("filter[status]=pending"),
);
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data.data).toHaveLength(1);
expect(result.data.data[0].attributes.email).toBe("a@b.test");
}
expect(calls[0].url).toContain("filter%5Bstatus%5D=pending");
});
});
@@ -0,0 +1,21 @@
"use server";
import type { AlertRecipient, AlertsActionResult } from "../_types";
import { alertsRequest } from "./_request";
const RECIPIENTS_PATH = "/alerts/recipients";
export interface AlertRecipientsListResponse {
data: AlertRecipient[];
meta?: {
pagination?: { count: number; pages: number; page: number };
};
}
export const listAlertRecipients = async (
searchParams?: URLSearchParams,
): Promise<AlertsActionResult<AlertRecipientsListResponse>> =>
alertsRequest<AlertRecipientsListResponse>(RECIPIENTS_PATH, {
method: "GET",
query: searchParams,
});
@@ -0,0 +1,662 @@
import { render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type { ReactNode } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
ALERT_AGGREGATE_OPS,
ALERT_BOOLEAN_OPS,
ALERT_RECIPIENT_STATUS,
ALERT_TRIGGER_KINDS,
type AlertCondition,
type AlertRecipient,
type AlertRule,
} from "@/app/(prowler)/alerts/_types";
import type { ProviderProps } from "@/types/providers";
import { AlertFormModal } from "../alert-form-modal";
const recipientsActionMocks = vi.hoisted(() => ({
listAlertRecipients: vi.fn(),
}));
const alertsActionMocks = vi.hoisted(() => ({
previewAlertCondition: vi.fn(),
}));
vi.mock(
"@/app/(prowler)/alerts/_actions/recipients",
() => recipientsActionMocks,
);
vi.mock("@/app/(prowler)/alerts/_actions", () => alertsActionMocks);
vi.mock(
"@/components/compliance/compliance-header/compliance-scan-info",
() => ({
ComplianceScanInfo: () => <span>Scan</span>,
}),
);
vi.mock("@/components/ui/entities/entity-info", () => ({
EntityInfo: ({
entityAlias,
entityId,
}: {
entityAlias?: string;
entityId?: string;
}) => <span>{entityAlias ?? entityId}</span>,
}));
vi.mock("next-auth/react", () => ({
useSession: () => ({ data: null, status: "unauthenticated" }),
}));
vi.mock("next/navigation", () => ({
usePathname: () => "/alerts",
useRouter: () => ({ replace: vi.fn(), push: vi.fn(), refresh: vi.fn() }),
useSearchParams: () => new URLSearchParams(),
}));
vi.mock("@/components/shadcn/modal", () => ({
Modal: ({
open,
title,
description,
className,
onOpenAutoFocus,
children,
}: {
open: boolean;
title?: string;
description?: string;
className?: string;
onOpenAutoFocus?: (event: Event) => void;
children: ReactNode;
}) =>
open ? (
<div
role="dialog"
aria-label={title}
aria-description={description}
className={className}
data-allows-open-auto-focus={String(Boolean(onOpenAutoFocus))}
>
{children}
</div>
) : null,
}));
class ResizeObserverMock {
observe = vi.fn();
unobserve = vi.fn();
disconnect = vi.fn();
}
global.ResizeObserver = ResizeObserverMock;
Element.prototype.scrollIntoView = vi.fn();
const mockProviders: ProviderProps[] = [
{
id: "provider-aws-1",
type: "providers",
attributes: {
provider: "aws",
uid: "123456789012",
alias: "Production AWS",
status: "completed",
resources: 42,
connection: {
connected: true,
last_checked_at: "2026-04-30T00:00:00Z",
},
scanner_args: {
only_logs: false,
excluded_checks: [],
aws_retries_max_attempts: 3,
},
inserted_at: "2026-04-30T00:00:00Z",
updated_at: "2026-04-30T00:00:00Z",
created_by: { object: "users", id: "user-1" },
},
relationships: {
secret: { data: null },
provider_groups: { meta: { count: 0 }, data: [] },
},
},
{
id: "provider-gcp-1",
type: "providers",
attributes: {
provider: "gcp",
uid: "prowler-prod-project",
alias: "Production GCP",
status: "completed",
resources: 21,
connection: {
connected: true,
last_checked_at: "2026-04-30T00:00:00Z",
},
scanner_args: {
only_logs: false,
excluded_checks: [],
aws_retries_max_attempts: 3,
},
inserted_at: "2026-04-30T00:00:00Z",
updated_at: "2026-04-30T00:00:00Z",
created_by: { object: "users", id: "user-1" },
},
relationships: {
secret: { data: null },
provider_groups: { meta: { count: 0 }, data: [] },
},
},
];
const createRecipient = (
id: string,
email: string,
status: AlertRecipient["attributes"]["status"],
): AlertRecipient => ({
id,
type: "alert-recipients",
attributes: {
email,
status,
inserted_at: "2026-04-30T00:00:00Z",
updated_at: "2026-04-30T00:00:00Z",
},
relationships: { rules: { data: [] } },
});
const confirmedRecipient = createRecipient(
"recipient-confirmed",
"security@example.com",
ALERT_RECIPIENT_STATUS.CONFIRMED,
);
const pendingRecipient = createRecipient(
"recipient-pending",
"pending@example.com",
ALERT_RECIPIENT_STATUS.PENDING,
);
const createEditingAlert = (
overrides: Partial<AlertRule["attributes"]> = {},
): AlertRule => ({
id: "alert-1",
type: "alert-rules",
attributes: {
name: "Existing alert",
description: "Existing description",
enabled: true,
trigger: ALERT_TRIGGER_KINDS.AFTER_SCAN,
condition: {
op: ALERT_AGGREGATE_OPS.COUNT_GTE,
filter: { severity: ["critical"] },
value: 1,
},
schema_version: 1,
recipient_emails: ["security@example.com"],
inserted_at: "2026-04-30T00:00:00Z",
updated_at: "2026-04-30T00:00:00Z",
...overrides,
},
});
const mockRecipientsList = () => {
recipientsActionMocks.listAlertRecipients.mockResolvedValue({
ok: true,
data: {
data: [confirmedRecipient, pendingRecipient],
meta: { pagination: { page: 1, pages: 1, count: 2 } },
},
});
};
const renderCreateModal = (
props: Partial<React.ComponentProps<typeof AlertFormModal>> = {},
) =>
render(
<AlertFormModal
open
defaultFrequency={ALERT_TRIGGER_KINDS.AFTER_SCAN}
onOpenChange={vi.fn()}
onSubmit={vi.fn()}
{...props}
/>,
);
const getVisibleFilterTrigger = (label: string): HTMLButtonElement => {
const trigger = screen
.getAllByRole("combobox")
.find(
(element) =>
element.textContent?.includes(label) &&
!element.closest('[aria-hidden="true"]'),
);
expect(trigger).toBeDefined();
return trigger as HTMLButtonElement;
};
describe("AlertFormModal", () => {
beforeEach(() => {
recipientsActionMocks.listAlertRecipients.mockReset();
recipientsActionMocks.listAlertRecipients.mockReturnValue(
new Promise(() => {}),
);
alertsActionMocks.previewAlertCondition.mockReset();
});
it("should render the simplified alert form without preview, delivery settings, or nested recipient management", () => {
// Given / When
renderCreateModal({
providers: mockProviders,
uniqueRegions: ["us-east-1", "europe-west1"],
uniqueServices: ["iam", "cloudsql"],
uniqueCategories: ["identity-security"],
uniqueGroups: ["prod"],
});
// Then
expect(screen.getByRole("dialog", { name: "Create Alert" })).toBeVisible();
expect(screen.getByLabelText(/^name$/i)).toBeVisible();
expect(screen.getByLabelText(/^description$/i)).toBeVisible();
expect(screen.getByLabelText(/^frequency$/i)).toBeVisible();
expect(screen.getByLabelText(/^recipients$/i)).toBeVisible();
expect(screen.getAllByRole("combobox")).toHaveLength(2);
expect(screen.queryByText("Alert criteria")).not.toBeInTheDocument();
expect(screen.queryByText(/delivery settings/i)).not.toBeInTheDocument();
expect(
screen.queryByLabelText(/notification method/i),
).not.toBeInTheDocument();
expect(screen.queryByText(/run preview/i)).not.toBeInTheDocument();
expect(
screen.queryByRole("button", { name: /manage recipients/i }),
).not.toBeInTheDocument();
expect(screen.queryByText("Production AWS")).not.toBeInTheDocument();
expect(screen.queryByText(/resource type/i)).not.toBeInTheDocument();
expect(screen.queryByText(/^date$/i)).not.toBeInTheDocument();
});
it("should provide accessible dialog description and allow initial focus when editing", () => {
// Given / When
renderCreateModal({
editingAlert: createEditingAlert(),
});
// Then
const dialog = screen.getByRole("dialog", { name: "Edit Alert" });
expect(dialog).toHaveAccessibleDescription(
"Update recipients, frequency, and finding filters for this alert.",
);
expect(dialog).toHaveAttribute("data-allows-open-auto-focus", "true");
});
it("should show selected Findings filters as chips while keeping criteria controls hidden", () => {
// Given / When
renderCreateModal({
initialFindingsFilters: {},
selectedFindingsFilterChips: [
{ key: "filter[status__in]", label: "Status", value: "FAIL" },
{ key: "filter[muted]", label: "Muted", value: "false" },
],
});
// Then
expect(
screen.getByRole("region", { name: /active filters/i }),
).toHaveTextContent("Status: FAIL");
expect(
screen.getByRole("region", { name: /active filters/i }),
).toHaveTextContent("Muted: false");
expect(screen.queryByText("All Provider")).not.toBeInTheDocument();
expect(screen.queryByText(/run preview/i)).not.toBeInTheDocument();
});
it("should list tenant recipients with status and submit selected emails", async () => {
// Given
const user = userEvent.setup();
const onSubmit = vi
.fn()
.mockResolvedValue({ ok: true, alertId: "alert-1" });
mockRecipientsList();
renderCreateModal({ onSubmit });
// When
await user.type(screen.getByLabelText(/^name$/i), "Critical alerts");
await user.click(getVisibleFilterTrigger("Select emails"));
expect((await screen.findAllByText("Confirmed")).at(-1)).toBeVisible();
expect(screen.getAllByText("Pending").at(-1)).toBeVisible();
const recipientOptions = await screen.findAllByText("pending@example.com");
const visibleRecipientOption = recipientOptions.at(-1);
expect(visibleRecipientOption).toBeDefined();
await user.click(visibleRecipientOption as HTMLElement);
await user.click(screen.getByRole("button", { name: /^create$/i }));
// Then
expect(screen.getAllByText("pending@example.com").at(-1)).toBeVisible();
await waitFor(() =>
expect(onSubmit).toHaveBeenCalledWith(
expect.objectContaining({
frequency: ALERT_TRIGGER_KINDS.AFTER_SCAN,
recipientEmails: ["pending@example.com"],
}),
null,
),
);
const recipientsParams = recipientsActionMocks.listAlertRecipients.mock
.calls[0][0] as URLSearchParams;
expect(recipientsParams.get("filter[status]")).toBeNull();
expect(recipientsParams.get("page[size]")).toBe("100");
});
it("should submit the configured alert frequency", async () => {
// Given
const user = userEvent.setup();
const onSubmit = vi
.fn()
.mockResolvedValue({ ok: true, alertId: "alert-1" });
mockRecipientsList();
renderCreateModal({
defaultFrequency: ALERT_TRIGGER_KINDS.DAILY,
onSubmit,
});
// When
await user.type(screen.getByLabelText(/^name$/i), "Daily alerts");
expect(
screen.getByRole("combobox", { name: /frequency/i }),
).toHaveTextContent("Daily digest");
await user.click(getVisibleFilterTrigger("Select emails"));
const recipientOptions = await screen.findAllByText("security@example.com");
const visibleRecipientOption = recipientOptions.at(-1);
expect(visibleRecipientOption).toBeDefined();
await user.click(visibleRecipientOption as HTMLElement);
await user.click(screen.getByRole("button", { name: /^create$/i }));
// Then
await waitFor(() =>
expect(onSubmit).toHaveBeenCalledWith(
expect.objectContaining({
frequency: ALERT_TRIGGER_KINDS.DAILY,
}),
null,
),
);
});
it("should allow submitting without selected recipients", async () => {
// Given
const user = userEvent.setup();
const onSubmit = vi
.fn()
.mockResolvedValue({ ok: true, alertId: "alert-1" });
mockRecipientsList();
renderCreateModal({ onSubmit });
// When
await user.type(screen.getByLabelText(/^name$/i), "Critical alerts");
await user.click(screen.getByRole("button", { name: /^create$/i }));
// Then
await waitFor(() =>
expect(onSubmit).toHaveBeenCalledWith(
expect.objectContaining({
recipientEmails: [],
}),
null,
),
);
expect(
screen.queryByText(/select at least one recipient/i),
).not.toBeInTheDocument();
});
it("should reset form defaults when opening a different alert", () => {
// Given
const { rerender } = render(
<AlertFormModal
open
defaultFrequency={ALERT_TRIGGER_KINDS.AFTER_SCAN}
editingAlert={createEditingAlert({ name: "First alert" })}
onOpenChange={vi.fn()}
onSubmit={vi.fn()}
/>,
);
// When
rerender(
<AlertFormModal
open
defaultFrequency={ALERT_TRIGGER_KINDS.AFTER_SCAN}
editingAlert={createEditingAlert({
name: "Second alert",
updated_at: "2026-05-01T00:00:00Z",
})}
onOpenChange={vi.fn()}
onSubmit={vi.fn()}
/>,
);
// Then
expect(screen.getByLabelText(/^name$/i)).toHaveValue("Second alert");
});
it("should render the shared Findings batch filter controls for an existing alert", async () => {
// Given
const user = userEvent.setup();
mockRecipientsList();
renderCreateModal({
editingAlert: createEditingAlert({
condition: {
op: ALERT_BOOLEAN_OPS.AND,
children: [
{
op: ALERT_AGGREGATE_OPS.COUNT_GTE,
filter: { severity: ["critical"] },
value: 1,
},
{
op: ALERT_AGGREGATE_OPS.COUNT_GTE,
filter: { provider_type: ["aws"] },
value: 1,
},
],
},
}),
providers: mockProviders,
uniqueRegions: ["us-east-1", "europe-west1"],
uniqueServices: ["iam", "cloudsql"],
uniqueResourceTypes: ["AWS::IAM::User"],
uniqueCategories: ["identity-security"],
uniqueGroups: ["prod"],
});
// When
await user.click(screen.getByRole("button", { name: /more filters/i }));
// Then
const recipientsTrigger = screen.getByLabelText(/^recipients$/i);
const filtersHeading = screen.getByRole("heading", { name: /^filters$/i });
expect(filtersHeading).toBeVisible();
expect(
recipientsTrigger.compareDocumentPosition(filtersHeading) &
Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();
expect(filtersHeading.closest('[data-slot="card"]')).toBeVisible();
expect(screen.getAllByText("Amazon Web Services")[0]).toBeVisible();
expect(screen.getByText("All accounts")).toBeVisible();
expect(screen.getByText("All Status")).toBeVisible();
expect(screen.getByText("All Delta")).toBeVisible();
expect(screen.getByText("All Resource Type")).toBeVisible();
expect(screen.queryByLabelText(/^severity$/i)).not.toBeInTheDocument();
});
it("should save edited filters as a normalized simple condition", async () => {
// Given
const user = userEvent.setup();
const onSubmit = vi
.fn()
.mockResolvedValue({ ok: true, alertId: "alert-1" });
mockRecipientsList();
renderCreateModal({
editingAlert: createEditingAlert(),
providers: mockProviders,
onSubmit,
});
// When
await user.click(screen.getByRole("button", { name: /more filters/i }));
await user.click(screen.getByLabelText(/provider type/i));
const providerOptions = await screen.findAllByText("Google Cloud Platform");
const visibleProviderOption = providerOptions.at(-1);
expect(visibleProviderOption).toBeDefined();
await user.click(visibleProviderOption as HTMLElement);
await user.click(screen.getByRole("button", { name: /^save$/i }));
// Then
await waitFor(() =>
expect(onSubmit).toHaveBeenCalledWith(
expect.objectContaining({
filterGroup: expect.objectContaining({
children: expect.arrayContaining([
{ kind: "filter", field: "providers", values: ["gcp"] },
]),
}),
}),
null,
),
);
});
it("should preview the edited alert using current unsaved filters", async () => {
// Given
const user = userEvent.setup();
alertsActionMocks.previewAlertCondition.mockResolvedValue({
ok: true,
data: {
would_fire: true,
summary: {
finding_count_total: 7,
top_severity: "critical",
},
sample_finding_ids: [],
evaluation_failed: false,
duration_ms: 42,
},
});
mockRecipientsList();
renderCreateModal({
editingAlert: createEditingAlert(),
providers: mockProviders,
});
// When
await user.click(screen.getByRole("button", { name: /more filters/i }));
await user.click(screen.getByLabelText(/provider type/i));
const providerOptions = await screen.findAllByText("Google Cloud Platform");
const visibleProviderOption = providerOptions.at(-1);
expect(visibleProviderOption).toBeDefined();
await user.click(visibleProviderOption as HTMLElement);
await user.click(screen.getByRole("button", { name: /^test$/i }));
// Then
await waitFor(() =>
expect(alertsActionMocks.previewAlertCondition).toHaveBeenCalledWith(
expect.objectContaining({
condition: expect.objectContaining({
op: ALERT_BOOLEAN_OPS.AND,
children: expect.arrayContaining([
expect.objectContaining({
filter: { provider_type: ["gcp"] },
value: 1,
}),
]),
}),
trigger: ALERT_TRIGGER_KINDS.AFTER_SCAN,
}),
),
);
const previewStatus = await screen.findByText(/would fire/i);
expect(previewStatus).toBeVisible();
const previewCard = previewStatus.closest('[data-slot="card"]');
expect(previewCard).toBeInTheDocument();
const previewCardQueries = within(previewCard as HTMLElement);
expect(previewCardQueries.getByText(/^findings$/i)).toBeVisible();
expect(previewCardQueries.getByText("7")).toBeVisible();
expect(previewCardQueries.getByText(/^top severity$/i)).toBeVisible();
expect(previewCardQueries.getByText("Critical")).toBeVisible();
expect(previewCardQueries.getByText(/^duration$/i)).toBeVisible();
expect(previewCardQueries.getByText(/42 ms/i)).toBeVisible();
});
it("should render preview errors inline in edit mode", async () => {
// Given
const user = userEvent.setup();
alertsActionMocks.previewAlertCondition.mockResolvedValue({
ok: false,
error: { detail: "Invalid condition" },
});
mockRecipientsList();
renderCreateModal({ editingAlert: createEditingAlert() });
// When
await user.click(screen.getByRole("button", { name: /^test$/i }));
// Then
expect(await screen.findByText(/invalid condition/i)).toBeVisible();
});
it("should hydrate advanced edit mode filters and normalize them on save", async () => {
// Given
const user = userEvent.setup();
const advancedCondition: AlertCondition = {
op: ALERT_BOOLEAN_OPS.NOT,
child: {
op: ALERT_AGGREGATE_OPS.COUNT_GTE,
filter: { severity: ["critical"] },
value: 1,
},
};
const onSubmit = vi
.fn()
.mockResolvedValue({ ok: true, alertId: "alert-1" });
mockRecipientsList();
renderCreateModal({
editingAlert: createEditingAlert({
condition: advancedCondition,
recipient_emails: ["security@example.com"],
}),
onSubmit,
});
// When
await user.click(screen.getByRole("button", { name: /^save$/i }));
// Then
await waitFor(() =>
expect(onSubmit).toHaveBeenCalledWith(
expect.objectContaining({
name: "Existing alert",
recipientEmails: ["security@example.com"],
filterGroup: expect.objectContaining({
children: [
{
kind: "filter",
field: "checkSeverities",
values: ["critical"],
},
],
}),
}),
null,
),
);
expect(
screen.queryByText(/advanced condition preserved/i),
).not.toBeInTheDocument();
});
});
@@ -0,0 +1,166 @@
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type { ReactNode } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
ALERT_AGGREGATE_OPS,
ALERT_TRIGGER_KINDS,
type AlertRule,
} from "@/app/(prowler)/alerts/_types";
import { AlertsManager } from "../alerts-manager";
const actionMocks = vi.hoisted(() => ({
deleteAlert: vi.fn(),
disableAlert: vi.fn(),
enableAlert: vi.fn(),
updateAlert: vi.fn(),
}));
const routerMocks = vi.hoisted(() => ({
refresh: vi.fn(),
replace: vi.fn(),
push: vi.fn(),
}));
const toastMock = vi.hoisted(() => vi.fn());
vi.mock("@/app/(prowler)/alerts/_actions", () => actionMocks);
vi.mock("@/lib", () => ({
cn: (...classes: Array<string | false | null | undefined>) =>
classes.filter(Boolean).join(" "),
}));
vi.mock("next/navigation", () => ({
usePathname: () => "/alerts",
useRouter: () => routerMocks,
useSearchParams: () => new URLSearchParams(),
}));
vi.mock("@/components/ui", () => ({
useToast: () => ({ toast: toastMock }),
}));
vi.mock("@/components/shadcn", () => ({
Button: ({
children,
disabled,
onClick,
variant,
}: {
children: ReactNode;
disabled?: boolean;
onClick?: () => void;
variant?: string;
}) => (
<button
type="button"
disabled={disabled}
onClick={onClick}
data-variant={variant}
>
{children}
</button>
),
}));
vi.mock("../alert-form-modal", () => ({
AlertFormModal: () => null,
}));
vi.mock("../alerts-empty-state", () => ({
AlertsEmptyState: () => <div>No alerts</div>,
}));
const makeAlert = (enabled: boolean): AlertRule => ({
id: enabled ? "enabled-alert" : "disabled-alert",
type: "alert-rules",
attributes: {
name: enabled ? "Enabled alert" : "Disabled alert",
description: "",
enabled,
trigger: ALERT_TRIGGER_KINDS.AFTER_SCAN,
condition: {
op: ALERT_AGGREGATE_OPS.ANY,
filter: { severity: ["critical"] },
},
schema_version: 1,
recipient_emails: [],
inserted_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
},
});
const renderManager = (alerts: AlertRule[]) =>
render(
<AlertsManager
alerts={alerts}
loadError={null}
providers={[]}
completedScanIds={[]}
scanDetails={[]}
uniqueRegions={[]}
uniqueServices={[]}
uniqueResourceTypes={[]}
uniqueCategories={[]}
uniqueGroups={[]}
/>,
);
describe("AlertsManager", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("shows a success toast after disabling an alert", async () => {
// Given
const user = userEvent.setup();
const alert = makeAlert(true);
actionMocks.disableAlert.mockResolvedValue({
ok: true,
data: { data: alert },
});
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({
title: "Alert disabled",
description: "Enabled alert",
}),
);
});
it("shows a success toast after enabling an alert", async () => {
// Given
const user = userEvent.setup();
const alert = makeAlert(false);
actionMocks.enableAlert.mockResolvedValue({
ok: true,
data: { data: alert },
});
renderManager([alert]);
// When
await user.click(
screen.getByRole("button", { name: /actions for disabled alert/i }),
);
await user.click(screen.getByRole("menuitem", { name: /enable/i }));
// Then
await waitFor(() =>
expect(toastMock).toHaveBeenCalledWith({
title: "Alert enabled",
description: "Disabled alert",
}),
);
});
});
@@ -0,0 +1,218 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type { ReactNode } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
ALERT_AGGREGATE_OPS,
ALERT_TRIGGER_KINDS,
type AlertRule,
} from "@/app/(prowler)/alerts/_types";
import { AlertsTable } from "../alerts-table";
const navigationMocks = vi.hoisted(() => ({
routerPush: vi.fn(),
currentSearch: "",
}));
vi.mock("next/navigation", () => ({
usePathname: () => "/alerts",
useRouter: () => ({ push: navigationMocks.routerPush }),
useSearchParams: () => new URLSearchParams(navigationMocks.currentSearch),
}));
vi.mock("@/components/ui/table/data-table", () => ({
DataTable: ({
columns,
data,
metadata,
}: {
columns: {
id?: string;
size?: number;
minSize?: number;
cell?: (context: { row: { original: AlertRule } }) => ReactNode;
}[];
data: AlertRule[];
metadata?: { pagination?: { count?: number } };
}) => (
<div>
{metadata?.pagination?.count !== undefined && (
<span>{metadata.pagination.count} Total Entries</span>
)}
<table>
<thead>
<tr>
{columns.map((column) => (
<th
key={column.id}
data-testid={`column-${column.id}`}
data-size={column.size}
data-min-size={column.minSize}
>
<button
type="button"
onClick={() =>
navigationMocks.routerPush(`/alerts?sort=${column.id}`, {
scroll: false,
})
}
>
{column.id === "enabled" ? "Status" : column.id}
</button>
</th>
))}
</tr>
</thead>
<tbody>
{data.map((alert) => (
<tr key={alert.id}>
{columns.map((column) => (
<td key={`${alert.id}-${column.id}`}>
{column.cell?.({ row: { original: alert } })}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
),
}));
vi.mock("@/components/ui/table/data-table-column-header", () => ({
DataTableColumnHeader: ({ title }: { title: string }) => <span>{title}</span>,
}));
interface AlertRuleOverrides extends Partial<Omit<AlertRule, "attributes">> {
attributes?: Partial<AlertRule["attributes"]>;
}
const makeRule = (overrides: AlertRuleOverrides = {}): AlertRule => ({
id: overrides.id ?? "alert-1",
type: "alert-rules",
attributes: {
name: "Critical findings",
description: "Notify security",
enabled: true,
trigger: ALERT_TRIGGER_KINDS.AFTER_SCAN,
condition: {
op: ALERT_AGGREGATE_OPS.ANY,
filter: { severity: ["critical"] },
},
schema_version: 1,
recipient_emails: ["security@example.com"],
inserted_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
...overrides.attributes,
},
});
describe("AlertsTable", () => {
beforeEach(() => {
navigationMocks.currentSearch = "";
navigationMocks.routerPush.mockClear();
});
it("should render alert rows with dropdown actions and shared pagination", () => {
// Given / When
render(
<AlertsTable
alerts={[makeRule()]}
meta={{ pagination: { page: 1, pages: 2, count: 12 }, version: "1" }}
mutatingId={null}
onEdit={vi.fn()}
onToggleEnabled={vi.fn()}
onDelete={vi.fn()}
/>,
);
// Then
expect(
screen.getByRole("cell", { name: /critical findings/i }),
).toBeVisible();
expect(
screen.getByRole("button", { name: /actions for critical findings/i }),
).toBeVisible();
expect(
screen.queryByRole("button", { name: /edit critical findings/i }),
).not.toBeInTheDocument();
expect(screen.getByText(/12 total entries/i)).toBeVisible();
expect(screen.getByTestId("column-actions")).toHaveAttribute(
"data-size",
"72",
);
expect(screen.getByTestId("column-name")).toHaveAttribute(
"data-size",
"320",
);
expect(
screen.queryByRole("button", { name: /run preview|test/i }),
).not.toBeInTheDocument();
expect(
screen.queryByRole("link", { name: /critical findings/i }),
).not.toBeInTheDocument();
});
it("should truncate long descriptions in the name column", () => {
// Given
const description =
"This alert description is intentionally long enough to overflow the alerts table if it is not constrained by the cell renderer.";
// When
render(
<AlertsTable
alerts={[makeRule({ attributes: { description } })]}
mutatingId={null}
onEdit={vi.fn()}
onToggleEnabled={vi.fn()}
onDelete={vi.fn()}
/>,
);
// Then
expect(screen.getByText(description)).toHaveClass("truncate");
expect(screen.getByText(description).parentElement).toHaveClass(
"max-w-[320px]",
);
expect(screen.getByText(description)).toHaveAttribute("title", description);
});
it("should call row action callbacks for edit, toggle, and delete", async () => {
// Given
const user = userEvent.setup();
const alert = makeRule({ id: "alert-enabled" });
const onEdit = vi.fn();
const onToggleEnabled = vi.fn();
const onDelete = vi.fn();
render(
<AlertsTable
alerts={[alert]}
mutatingId={null}
onEdit={onEdit}
onToggleEnabled={onToggleEnabled}
onDelete={onDelete}
/>,
);
// When
await user.click(
screen.getByRole("button", { name: /actions for critical findings/i }),
);
await user.click(screen.getByRole("menuitem", { name: /edit/i }));
await user.click(
screen.getByRole("button", { name: /actions for critical findings/i }),
);
await user.click(screen.getByRole("menuitem", { name: /disable/i }));
await user.click(
screen.getByRole("button", { name: /actions for critical findings/i }),
);
await user.click(screen.getByRole("menuitem", { name: /delete/i }));
// Then
expect(onEdit).toHaveBeenCalledWith(alert);
expect(onToggleEnabled).toHaveBeenCalledWith(alert);
expect(onDelete).toHaveBeenCalledWith(alert);
});
});
@@ -0,0 +1,265 @@
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type { ComponentProps, ReactNode } from "react";
import { describe, expect, it, vi } from "vitest";
import type { AlertCondition } from "@/app/(prowler)/alerts/_types";
import type {
AlertFormSubmitResult,
AlertFormValues,
} from "@/app/(prowler)/alerts/_types/alert-form";
const routerMocks = vi.hoisted(() => ({
push: vi.fn(),
refresh: vi.fn(),
}));
const actionMocks = vi.hoisted(() => ({
createAlert: vi.fn(),
}));
const toastMock = vi.hoisted(() => vi.fn());
vi.mock("next/navigation", () => ({
useRouter: () => routerMocks,
}));
vi.mock("@/components/ui", () => ({
ToastAction: ({
asChild,
children,
...props
}: ComponentProps<"button"> & {
asChild?: boolean;
children?: ReactNode;
}) => (asChild ? children : <button {...props}>{children}</button>),
useToast: () => ({ toast: toastMock }),
}));
vi.mock("@/app/(prowler)/alerts/_actions", () => ({
createAlert: actionMocks.createAlert,
}));
vi.mock("@/app/(prowler)/alerts/_components/alert-form-modal", () => ({
AlertFormModal: ({
open,
initialFindingsFilters,
selectedFindingsFilterChips,
defaultName,
onSubmit,
}: {
open: boolean;
initialFindingsFilters?: Record<string, string | string[]>;
selectedFindingsFilterChips?: Array<{
label: string;
displayValue?: string;
value: string;
}>;
defaultName?: string;
onSubmit: (
values: AlertFormValues,
advancedCondition: AlertCondition | null,
) => Promise<AlertFormSubmitResult>;
}) =>
open ? (
<div role="dialog" aria-label="Create alert">
<output data-testid="initial-filters">
{JSON.stringify(initialFindingsFilters)}
</output>
<output data-testid="selected-filter-chips">
{(selectedFindingsFilterChips ?? [])
.map((chip) => `${chip.label}:${chip.displayValue ?? chip.value}`)
.join("|")}
</output>
<button
type="button"
onClick={() =>
onSubmit(
{
name: defaultName ?? "Findings filter alert",
description: "",
method: "email",
frequency: "after_scan",
filterGroup: { operator: "all", children: [] },
severities: [],
deltas: [],
providerTypes: [],
providerIds: [],
checkIds: [],
categories: [],
regions: [],
services: [],
resourceGroups: [],
findingGroupIds: [],
resourceTypes: [],
recipientEmails: ["security@example.com"],
enabled: true,
},
null,
)
}
>
Submit mock alert
</button>
</div>
) : null,
}));
import { SeedFromFindingsButton } from "../seed-from-findings-button";
describe("SeedFromFindingsButton", () => {
it("should explain why creating an alert is disabled when no real filters are applied", async () => {
// Given
const user = userEvent.setup();
render(<SeedFromFindingsButton filterBag={{ sort: "-inserted_at" }} />);
// When
const button = screen.getByRole("button", {
name: /Create Alert/i,
});
const tooltipTrigger = button.parentElement;
expect(tooltipTrigger).not.toBeNull();
await user.hover(tooltipTrigger as HTMLElement);
// Then
expect(button).toBeDisabled();
expect(
await screen.findAllByText(/at least one findings filter/i),
).not.toHaveLength(0);
});
it("should enable creation from the first real filter, including unsupported backend filters", () => {
// Given / When
render(
<SeedFromFindingsButton
filterBag={{
"filter[status__in]": "FAIL",
"filter[muted]": "false",
"filter[scan__in]": "11111111-1111-1111-1111-111111111111",
}}
/>,
);
// Then
expect(
screen.getByRole("button", { name: /Create Alert/i }),
).not.toBeDisabled();
});
it("should open the modal in Findings and keep unsupported filters out of the payload seed", async () => {
// Given
const user = userEvent.setup();
render(
<SeedFromFindingsButton
filterBag={{
"filter[status__in]": "FAIL",
"filter[muted]": "false",
"filter[scan__in]": "11111111-1111-1111-1111-111111111111",
"filter[severity__in]": "critical,high",
}}
/>,
);
// When
await user.click(screen.getByRole("button", { name: /Create Alert/i }));
// Then
expect(screen.getByRole("dialog", { name: /create alert/i })).toBeVisible();
expect(routerMocks.push).not.toHaveBeenCalled();
expect(screen.getByTestId("selected-filter-chips")).toHaveTextContent(
/status:fail/i,
);
expect(screen.getByTestId("selected-filter-chips")).toHaveTextContent(
/muted:false/i,
);
expect(screen.getByTestId("initial-filters")).toHaveTextContent(
"filter[severity__in]",
);
expect(screen.getByTestId("initial-filters")).not.toHaveTextContent(
"filter[status__in]",
);
expect(screen.getByTestId("initial-filters")).not.toHaveTextContent(
"filter[muted]",
);
expect(screen.getByTestId("initial-filters")).not.toHaveTextContent(
"filter[scan__in]",
);
});
it("should create the alert through the existing alert action from the modal", async () => {
// Given
const user = userEvent.setup();
actionMocks.createAlert.mockResolvedValue({
ok: true,
data: {
data: {
id: "alert-1",
attributes: { name: "Findings filter alert" },
},
},
});
render(
<SeedFromFindingsButton
filterBag={{ "filter[severity__in]": "critical" }}
/>,
);
// When
await user.click(screen.getByRole("button", { name: /Create Alert/i }));
await user.click(
screen.getByRole("button", { name: /submit mock alert/i }),
);
// Then
await waitFor(() =>
expect(actionMocks.createAlert).toHaveBeenCalledWith(
expect.objectContaining({
name: "Findings filter alert",
trigger: "after_scan",
recipientEmails: ["security@example.com"],
}),
),
);
expect(routerMocks.refresh).toHaveBeenCalled();
expect(toastMock).toHaveBeenCalledWith(
expect.objectContaining({
title: "Alert created",
action: expect.anything(),
}),
);
});
it("should add a toast action to navigate to alerts after creating an alert", async () => {
// Given
const user = userEvent.setup();
actionMocks.createAlert.mockResolvedValue({
ok: true,
data: {
data: {
id: "alert-1",
attributes: { name: "Findings filter alert" },
},
},
});
render(
<SeedFromFindingsButton
filterBag={{ "filter[severity__in]": "critical" }}
/>,
);
// When
await user.click(screen.getByRole("button", { name: /Create Alert/i }));
await user.click(
screen.getByRole("button", { name: /submit mock alert/i }),
);
// Then
await waitFor(() => expect(toastMock).toHaveBeenCalled());
const toastAction = toastMock.mock.calls[0][0].action;
render(toastAction);
expect(screen.getByRole("link", { name: /view alerts/i })).toHaveAttribute(
"href",
"/alerts",
);
});
});
@@ -0,0 +1,663 @@
"use client";
import { useState } from "react";
import { previewAlertCondition } from "@/app/(prowler)/alerts/_actions";
import { listAlertRecipients } from "@/app/(prowler)/alerts/_actions/recipients";
import {
ALERT_TRIGGER_KINDS,
type AlertCondition,
type AlertPreviewResponse,
type AlertRecipient,
type AlertRule,
type AlertTriggerKind,
} from "@/app/(prowler)/alerts/_types";
import type { FilterChip } from "@/components/filters/filter-summary-strip";
import { FilterSummaryStrip } from "@/components/filters/filter-summary-strip";
import { FindingsFilterBatchControls } from "@/components/findings/findings-filters";
import {
Badge,
Button,
Card,
CardContent,
Field,
FieldError,
FieldLabel,
Input,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Separator,
Skeleton,
Textarea,
} from "@/components/shadcn";
import { Modal } from "@/components/shadcn/modal";
import {
MultiSelect,
MultiSelectContent,
MultiSelectItem,
MultiSelectSelectAll,
MultiSelectSeparator,
MultiSelectTrigger,
MultiSelectValue,
} from "@/components/shadcn/select/multiselect";
import { useMountEffect } from "@/hooks/use-mount-effect";
import type { ScanEntity } from "@/types";
import type { ProviderProps } from "@/types/providers";
import {
buildAlertCondition,
getAlertFormDefaults,
getAlertFormDefaultsFromFindingsFilters,
getEmptyAlertFormDefaults,
getFindingsFiltersFromAlertCondition,
} from "../_lib/alert-adapter";
import { alertFormSchema } from "../_lib/alert-form-schema";
import type {
AlertFormFindingFilterBag,
AlertFormSubmitResult,
AlertFormValues,
} from "../_types/alert-form";
import { ALERT_NOTIFICATION_METHODS } from "../_types/alert-form";
interface AlertFormModalProps {
open: boolean;
defaultFrequency: AlertTriggerKind;
providers?: ProviderProps[];
completedScanIds?: string[];
scanDetails?: { [key: string]: ScanEntity }[];
uniqueRegions?: string[];
uniqueServices?: string[];
uniqueResourceTypes?: string[];
uniqueCategories?: string[];
uniqueGroups?: string[];
editingAlert?: AlertRule | null;
initialFindingsFilters?: AlertFormFindingFilterBag | null;
selectedFindingsFilterChips?: FilterChip[];
defaultName?: string;
onOpenChange: (open: boolean) => void;
onSubmit: (
values: AlertFormValues,
advancedCondition: AlertCondition | null,
) => Promise<AlertFormSubmitResult>;
}
interface FormErrors {
name?: string;
recipientEmails?: string;
root?: string;
}
const normalizeEmail = (email: string): string => email.trim().toLowerCase();
const getRecipientEmails = (selectedEmails: Set<string>): string[] =>
Array.from(selectedEmails);
const ALERT_FREQUENCY_OPTIONS = [
{
value: ALERT_TRIGGER_KINDS.AFTER_SCAN,
label: "After each scan",
},
{
value: ALERT_TRIGGER_KINDS.DAILY,
label: "Daily digest",
},
{
value: ALERT_TRIGGER_KINDS.BOTH,
label: "After each scan and daily",
},
] as const;
const serializeFindingFilters = (
filters: AlertFormFindingFilterBag | null,
): string => {
if (!filters) return "none";
return Object.entries(filters)
.sort(([leftKey], [rightKey]) => leftKey.localeCompare(rightKey))
.map(([key, value]) => {
const serializedValue = Array.isArray(value) ? value.join(",") : value;
return `${key}:${serializedValue}`;
})
.join("|");
};
const getAlertFormModalResetKey = ({
open,
defaultFrequency,
editingAlert,
initialFindingsFilters,
}: Pick<
AlertFormModalProps,
"open" | "defaultFrequency" | "editingAlert" | "initialFindingsFilters"
>): string =>
[
open ? "open" : "closed",
editingAlert?.id ?? "create",
editingAlert?.attributes.updated_at ?? "",
defaultFrequency,
serializeFindingFilters(initialFindingsFilters ?? null),
].join("|");
const allowInitialDialogFocus = () => undefined;
const uniqueValues = (values: string[]): string[] =>
Array.from(new Set(values));
interface PreviewState {
status: "success" | "error";
data?: AlertPreviewResponse;
error?: string;
}
const formatPreviewNumber = (value: number): string =>
new Intl.NumberFormat("en-US").format(value);
const getPreviewSeverityLabel = (severity: string): string =>
severity.charAt(0).toUpperCase() + severity.slice(1);
const PreviewSummarySkeleton = () => (
<Card variant="inner" padding="sm">
<CardContent className="flex flex-col gap-3">
<div className="flex items-center justify-between gap-3">
<Skeleton className="h-5 w-28" />
<Skeleton className="h-5 w-20 rounded-full" />
</div>
<Separator />
<div className="grid gap-3 sm:grid-cols-3">
<div className="flex flex-col gap-2">
<Skeleton className="h-3 w-16" />
<Skeleton className="h-5 w-12" />
</div>
<div className="flex flex-col gap-2">
<Skeleton className="h-3 w-20" />
<Skeleton className="h-5 w-16" />
</div>
<div className="flex flex-col gap-2">
<Skeleton className="h-3 w-14" />
<Skeleton className="h-5 w-10" />
</div>
</div>
</CardContent>
</Card>
);
const PreviewSummary = ({ preview }: { preview: PreviewState }) => {
if (preview.status === "error") {
return (
<Card variant="danger" padding="sm">
<CardContent className="flex flex-col gap-2">
<div className="flex items-center justify-between gap-3">
<span className="text-text-neutral-primary text-sm font-medium">
Test result
</span>
<Badge variant="tag">Error</Badge>
</div>
<Separator />
<p className="text-destructive text-sm">{preview.error}</p>
</CardContent>
</Card>
);
}
const data = preview.data;
if (!data) return null;
const totalFindings = data.summary.finding_count_total ?? 0;
const topSeverity = data.summary.top_severity ?? "none";
const duration = data.duration_ms === undefined ? null : data.duration_ms;
const statusLabel = data.would_fire ? "Would fire" : "Would not fire";
return (
<Card variant="inner" padding="sm">
<CardContent className="flex flex-col gap-3">
<div className="flex items-center justify-between gap-3">
<span className="text-text-neutral-primary text-sm font-medium">
Test result
</span>
<Badge variant="tag">{statusLabel}</Badge>
</div>
<Separator />
<div className="grid gap-3 text-sm sm:grid-cols-3">
<div className="flex flex-col gap-1">
<span className="text-text-neutral-secondary text-xs">
Findings
</span>
<span className="text-text-neutral-primary font-medium">
{formatPreviewNumber(totalFindings)}
</span>
</div>
<div className="flex flex-col gap-1">
<span className="text-text-neutral-secondary text-xs">
Top severity
</span>
<span className="text-text-neutral-primary font-medium">
{getPreviewSeverityLabel(topSeverity)}
</span>
</div>
<div className="flex flex-col gap-1">
<span className="text-text-neutral-secondary text-xs">
Duration
</span>
<span className="text-text-neutral-primary font-medium">
{duration === null ? "-" : `${formatPreviewNumber(duration)} ms`}
</span>
</div>
</div>
</CardContent>
</Card>
);
};
const normalizeFindingsFilterKey = (filterKey: string): string =>
filterKey.startsWith("filter[") ? filterKey : `filter[${filterKey}]`;
interface AlertRecipientsSelectProps {
selectedEmails: Set<string>;
onValuesChange: (emails: string[]) => void;
}
interface RecipientOption {
email: string;
status?: AlertRecipient["attributes"]["status"];
}
const getRecipientStatusLabel = (
status: AlertRecipient["attributes"]["status"],
): string => status.charAt(0).toUpperCase() + status.slice(1);
const getRecipientOptions = (
recipients: AlertRecipient[],
selectedEmails: string[],
): RecipientOption[] => {
const options = new Map<string, RecipientOption>();
recipients.forEach((recipient) => {
const email = normalizeEmail(recipient.attributes.email);
if (!email) return;
options.set(email, { email, status: recipient.attributes.status });
});
selectedEmails.forEach((email) => {
const normalizedEmail = normalizeEmail(email);
if (!normalizedEmail || options.has(normalizedEmail)) return;
options.set(normalizedEmail, { email: normalizedEmail });
});
return Array.from(options.values()).sort((left, right) =>
left.email.localeCompare(right.email),
);
};
const AlertRecipientsSelect = ({
selectedEmails,
onValuesChange,
}: AlertRecipientsSelectProps) => {
const [recipients, setRecipients] = useState<AlertRecipient[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useMountEffect(() => {
const params = new URLSearchParams();
params.set("page[size]", "100");
params.set("sort", "email");
listAlertRecipients(params).then((result) => {
setLoading(false);
if (result.ok) {
setRecipients(result.data.data);
setError(null);
return;
}
setRecipients([]);
setError(result.error.detail);
});
});
const selectedValues = Array.from(selectedEmails);
const options = getRecipientOptions(recipients, selectedValues);
return (
<div className="flex flex-col gap-2">
<MultiSelect values={selectedValues} onValuesChange={onValuesChange}>
<MultiSelectTrigger
id="alert-recipients"
aria-label="Recipients"
size="default"
>
<MultiSelectValue
placeholder={loading ? "Loading recipients" : "Select emails"}
/>
</MultiSelectTrigger>
<MultiSelectContent
search={{
placeholder: "Search recipients...",
emptyMessage: "No confirmed recipients found.",
}}
width="wide"
>
<MultiSelectSelectAll
mode="select"
values={options.map((option) => option.email)}
>
Select All
</MultiSelectSelectAll>
<MultiSelectSeparator />
{options.map((option) => (
<MultiSelectItem
key={option.email}
value={option.email}
badgeLabel={option.email}
keywords={[option.email, option.status ?? ""]}
>
<span className="truncate">{option.email}</span>
{option.status && (
<Badge variant="tag">
{getRecipientStatusLabel(option.status)}
</Badge>
)}
</MultiSelectItem>
))}
</MultiSelectContent>
</MultiSelect>
{error && <p className="text-destructive text-xs">{error}</p>}
</div>
);
};
export const AlertFormModal = (props: AlertFormModalProps) => {
const resetKey = getAlertFormModalResetKey(props);
return <AlertFormModalContent key={resetKey} {...props} />;
};
const AlertFormModalContent = ({
open,
defaultFrequency,
providers = [],
completedScanIds = [],
scanDetails = [],
uniqueRegions = [],
uniqueServices = [],
uniqueResourceTypes = [],
uniqueCategories = [],
uniqueGroups = [],
editingAlert = null,
initialFindingsFilters = null,
selectedFindingsFilterChips = [],
defaultName = "Findings filter alert",
onOpenChange,
onSubmit,
}: AlertFormModalProps) => {
const defaults = editingAlert
? getAlertFormDefaults(editingAlert)
: initialFindingsFilters
? getAlertFormDefaultsFromFindingsFilters(
initialFindingsFilters,
defaultFrequency,
)
: getEmptyAlertFormDefaults(defaultFrequency);
const initialName = editingAlert
? defaults.name
: defaults.name || defaultName;
// Local state needed: user edits are buffered until the modal form is submitted.
const [name, setName] = useState(initialName);
const [description, setDescription] = useState(defaults.description);
const [frequency, setFrequency] = useState<AlertTriggerKind>(
defaults.frequency,
);
const [pendingFilters, setPendingFilters] = useState<
Record<string, string[]>
>(
editingAlert
? getFindingsFiltersFromAlertCondition(editingAlert.attributes.condition)
: {},
);
const [selectedRecipientEmails, setSelectedRecipientEmails] = useState(
() => new Set(defaults.recipientEmails.map(normalizeEmail)),
);
const [enabled] = useState(defaults.enabled);
const [errors, setErrors] = useState<FormErrors>({});
const [saving, setSaving] = useState(false);
const [previewLoading, setPreviewLoading] = useState(false);
const [preview, setPreview] = useState<PreviewState | null>(null);
const submitLabel = editingAlert ? "Save" : "Create";
const setRecipientEmails = (emails: string[]) =>
setSelectedRecipientEmails(
new Set(emails.map(normalizeEmail).filter(Boolean)),
);
const setPendingFilter = (filterKey: string, values: string[]) => {
setPendingFilters((current) => ({
...current,
[normalizeFindingsFilterKey(filterKey)]: uniqueValues(values),
}));
setPreview(null);
};
const getPendingFilterValue = (filterKey: string): string[] =>
pendingFilters[normalizeFindingsFilterKey(filterKey)] ?? [];
const buildCurrentValues = (): AlertFormValues => {
const filterDefaults = getAlertFormDefaultsFromFindingsFilters(
pendingFilters,
frequency,
);
return {
name,
description,
method: ALERT_NOTIFICATION_METHODS.EMAIL,
frequency,
filterGroup: filterDefaults.filterGroup,
severities: filterDefaults.severities,
deltas: filterDefaults.deltas,
providerTypes: filterDefaults.providerTypes,
providerIds: filterDefaults.providerIds,
checkIds: filterDefaults.checkIds,
categories: filterDefaults.categories,
regions: filterDefaults.regions,
services: filterDefaults.services,
resourceGroups: filterDefaults.resourceGroups,
findingGroupIds: filterDefaults.findingGroupIds,
resourceTypes: filterDefaults.resourceTypes,
recipientEmails: getRecipientEmails(selectedRecipientEmails),
enabled,
};
};
const handlePreview = async () => {
if (!editingAlert) return;
const values = buildCurrentValues();
const parsed = alertFormSchema.safeParse(values);
if (!parsed.success) {
setPreview({
status: "error",
error: "Fix alert fields before running test.",
});
return;
}
setPreviewLoading(true);
const result = await previewAlertCondition({
condition: buildAlertCondition(parsed.data.filterGroup),
trigger: frequency,
});
setPreviewLoading(false);
if (!result.ok) {
setPreview({ status: "error", error: result.error.detail });
return;
}
if (result.data.evaluation_failed) {
setPreview({
status: "error",
error: result.data.last_error ?? "Preview evaluation failed.",
});
return;
}
setPreview({ status: "success", data: result.data });
};
const handleSubmit = async () => {
const values = buildCurrentValues();
const parsed = alertFormSchema.safeParse(values);
if (!parsed.success) {
const fieldErrors = parsed.error.flatten().fieldErrors;
setErrors({
name: fieldErrors.name?.[0],
recipientEmails: fieldErrors.recipientEmails?.[0],
});
return;
}
setSaving(true);
const result = await onSubmit(parsed.data, null);
setSaving(false);
if (result.ok) {
setErrors({});
onOpenChange(false);
return;
}
setErrors({ root: result.error ?? "Could not save alert." });
};
return (
<Modal
open={open}
onOpenChange={onOpenChange}
title={editingAlert ? "Edit Alert" : "Create Alert"}
description={
editingAlert
? "Update recipients, frequency, and finding filters for this alert."
: "Create an alert from the current Findings filters."
}
onOpenAutoFocus={allowInitialDialogFocus}
size={editingAlert ? "5xl" : "xl"}
className={
editingAlert
? "minimal-scrollbar max-h-[calc(100vh-2rem)] overflow-y-auto"
: undefined
}
>
<div className="flex flex-col gap-4">
<FilterSummaryStrip chips={selectedFindingsFilterChips} />
<Field>
<FieldLabel htmlFor="alert-name">Name</FieldLabel>
<Input
id="alert-name"
aria-label="Name"
value={name}
onChange={(event) => setName(event.target.value)}
/>
{errors.name && <FieldError>{errors.name}</FieldError>}
</Field>
<Field>
<FieldLabel htmlFor="alert-description">Description</FieldLabel>
<Textarea
id="alert-description"
aria-label="Description"
textareaSize="lg"
value={description}
onChange={(event) => setDescription(event.target.value)}
/>
</Field>
<Field>
<FieldLabel htmlFor="alert-frequency">Frequency</FieldLabel>
<Select
value={frequency}
onValueChange={(value) => {
setFrequency(value as AlertTriggerKind);
setPreview(null);
}}
>
<SelectTrigger id="alert-frequency" aria-label="Frequency">
<SelectValue placeholder="Select frequency" />
</SelectTrigger>
<SelectContent width="wide" className="z-[60]">
{ALERT_FREQUENCY_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
<Field>
<FieldLabel htmlFor="alert-recipients">Recipients</FieldLabel>
<AlertRecipientsSelect
selectedEmails={selectedRecipientEmails}
onValuesChange={setRecipientEmails}
/>
{errors.recipientEmails && (
<FieldError>{errors.recipientEmails}</FieldError>
)}
</Field>
{editingAlert && (
<div className="flex flex-col gap-3">
<Card variant="inner" padding="sm">
<CardContent className="flex flex-col gap-3">
<h3 className="text-text-neutral-primary text-sm font-medium">
Filters
</h3>
<FindingsFilterBatchControls
providers={providers}
completedScanIds={completedScanIds}
scanDetails={scanDetails}
uniqueRegions={uniqueRegions}
uniqueServices={uniqueServices}
uniqueResourceTypes={uniqueResourceTypes}
uniqueCategories={uniqueCategories}
uniqueGroups={uniqueGroups}
appliedFilters={{}}
pendingFilters={pendingFilters}
changedFilters={pendingFilters}
setPending={setPendingFilter}
getFilterValue={getPendingFilterValue}
showSummaries={false}
/>
</CardContent>
</Card>
{(previewLoading || preview) && (
<div className="pt-1">
{previewLoading ? (
<PreviewSummarySkeleton />
) : (
preview && <PreviewSummary preview={preview} />
)}
</div>
)}
</div>
)}
{errors.root && (
<div className="text-destructive text-sm">{errors.root}</div>
)}
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
{editingAlert && (
<Button
variant="outline"
onClick={handlePreview}
disabled={previewLoading || saving}
>
{previewLoading ? "Running..." : "Test"}
</Button>
)}
<Button onClick={handleSubmit} disabled={saving}>
{submitLabel}
</Button>
</div>
</div>
</Modal>
);
};
@@ -0,0 +1,29 @@
import { BellRing, TagIcon } from "lucide-react";
import Link from "next/link";
import { Button, Card, CardContent } from "@/components/shadcn";
export const AlertsEmptyState = () => (
<Card variant="base" padding="lg">
<CardContent className="flex flex-col items-center gap-4 text-center">
<div className="bg-button-primary/10 flex h-14 w-14 items-center justify-center rounded-full">
<BellRing className="text-button-primary h-7 w-7" aria-hidden="true" />
</div>
<div className="flex flex-col gap-1">
<h3 className="text-text-neutral-primary text-lg font-semibold">
No alerts yet
</h3>
<p className="text-text-neutral-secondary max-w-md text-sm">
Create alerts from Findings page to notify selected recipients when
matching findings appear.
</p>
</div>
<Button asChild size="sm">
<Link href="/findings?filter[muted]=false&filter[status__in]=FAIL">
<TagIcon size={14} aria-hidden="true" />
Go to Findings
</Link>
</Button>
</CardContent>
</Card>
);
@@ -0,0 +1,216 @@
"use client";
import { useRouter } from "next/navigation";
import { useState, useTransition } from "react";
import {
deleteAlert,
disableAlert,
enableAlert,
updateAlert,
} from "@/app/(prowler)/alerts/_actions";
import {
ALERT_TRIGGER_KINDS,
type AlertCondition,
type AlertRule,
} from "@/app/(prowler)/alerts/_types";
import { Button } from "@/components/shadcn";
import { Modal } from "@/components/shadcn/modal";
import { useToast } from "@/components/ui";
import type { MetaDataProps } from "@/types";
import type { ScanEntity } from "@/types";
import type { ProviderProps } from "@/types/providers";
import { toAlertPayload } from "../_lib/alert-adapter";
import type {
AlertFormSubmitResult,
AlertFormValues,
} from "../_types/alert-form";
import { AlertFormModal } from "./alert-form-modal";
import { AlertsEmptyState } from "./alerts-empty-state";
import { AlertsTable } from "./alerts-table";
interface AlertsManagerProps {
alerts: AlertRule[];
meta?: MetaDataProps;
loadError: string | null;
providers: ProviderProps[];
completedScanIds: string[];
scanDetails: { [key: string]: ScanEntity }[];
uniqueRegions: string[];
uniqueServices: string[];
uniqueResourceTypes: string[];
uniqueCategories: string[];
uniqueGroups: string[];
initialEditingAlert?: AlertRule | null;
}
export const AlertsManager = ({
alerts,
meta,
loadError,
providers,
completedScanIds,
scanDetails,
uniqueRegions,
uniqueServices,
uniqueResourceTypes,
uniqueCategories,
uniqueGroups,
initialEditingAlert = null,
}: AlertsManagerProps) => {
const router = useRouter();
const { toast } = useToast();
const [, startTransition] = useTransition();
const [modalOpen, setModalOpen] = useState(Boolean(initialEditingAlert));
const [editingAlert, setEditingRule] = useState<AlertRule | null>(
initialEditingAlert,
);
const [mutatingId, setMutatingId] = useState<string | null>(null);
const [pendingDelete, setPendingDelete] = useState<AlertRule | null>(null);
const refresh = () => startTransition(() => router.refresh());
const closeModal = (open: boolean) => {
setModalOpen(open);
if (!open) {
setEditingRule(null);
router.replace("/alerts");
}
};
const submitAlert = async (
values: AlertFormValues,
advancedCondition: AlertCondition | null,
): Promise<AlertFormSubmitResult> => {
if (!editingAlert) {
return { ok: false, error: "Create alerts from Findings." };
}
const payload = toAlertPayload(values, advancedCondition);
const result = await updateAlert(editingAlert.id, payload);
if (!result.ok) return { ok: false, error: result.error.detail };
toast({
title: "Alert updated",
description: result.data.data.attributes.name,
});
refresh();
return { ok: true, alertId: result.data.data.id };
};
const toggleAlert = async (alert: AlertRule) => {
setMutatingId(alert.id);
const result = alert.attributes.enabled
? await disableAlert(alert.id)
: await enableAlert(alert.id);
setMutatingId(null);
if (!result.ok) {
toast({
variant: "destructive",
title: "Alert update failed",
description: result.error.detail,
});
return;
}
toast({
title: alert.attributes.enabled ? "Alert disabled" : "Alert enabled",
description: result.data.data.attributes.name,
});
refresh();
};
const confirmDelete = async () => {
if (!pendingDelete) return;
setMutatingId(pendingDelete.id);
const result = await deleteAlert(pendingDelete.id);
setMutatingId(null);
if (!result.ok) {
toast({
variant: "destructive",
title: "Alert delete failed",
description: result.error.detail,
});
return;
}
setPendingDelete(null);
refresh();
};
return (
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div className="flex max-w-3xl flex-col gap-2">
<h1 className="text-text-neutral-primary text-2xl font-semibold">
Alerts
</h1>
<p className="text-text-neutral-secondary text-sm">
Manage alerts for finding conditions.
</p>
</div>
</div>
{loadError && (
<div className="border-destructive/40 bg-destructive/10 text-destructive rounded-md border p-4 text-sm">
Failed to load alerts: {loadError}
</div>
)}
{alerts.length === 0 && !loadError ? (
<AlertsEmptyState />
) : (
<AlertsTable
alerts={alerts}
meta={meta}
mutatingId={mutatingId}
onEdit={(alert) => {
setEditingRule(alert);
setModalOpen(true);
}}
onToggleEnabled={toggleAlert}
onDelete={setPendingDelete}
/>
)}
<AlertFormModal
key={editingAlert?.id ?? "edit"}
open={modalOpen}
defaultFrequency={ALERT_TRIGGER_KINDS.AFTER_SCAN}
providers={providers}
completedScanIds={completedScanIds}
scanDetails={scanDetails}
uniqueRegions={uniqueRegions}
uniqueServices={uniqueServices}
uniqueResourceTypes={uniqueResourceTypes}
uniqueCategories={uniqueCategories}
uniqueGroups={uniqueGroups}
editingAlert={editingAlert}
onOpenChange={closeModal}
onSubmit={submitAlert}
/>
<Modal
open={Boolean(pendingDelete)}
onOpenChange={(open) => !open && setPendingDelete(null)}
title="Delete alert"
description={
pendingDelete
? `Delete "${pendingDelete.attributes.name}"? This alert will stop evaluating.`
: ""
}
size="md"
>
<div className="flex justify-end gap-2 pt-4">
<Button variant="outline" onClick={() => setPendingDelete(null)}>
Cancel
</Button>
<Button
variant="destructive"
disabled={mutatingId === pendingDelete?.id}
onClick={confirmDelete}
>
Delete alert
</Button>
</div>
</Modal>
</div>
);
};
@@ -0,0 +1,171 @@
"use client";
import type { ColumnDef } from "@tanstack/react-table";
import { PencilIcon, PowerIcon, TrashIcon } from "lucide-react";
import type { AlertRule } from "@/app/(prowler)/alerts/_types";
import {
ActionDropdown,
ActionDropdownDangerZone,
ActionDropdownItem,
} from "@/components/shadcn/dropdown";
import { DataTable } from "@/components/ui/table/data-table";
import { DataTableColumnHeader } from "@/components/ui/table/data-table-column-header";
import type { MetaDataProps } from "@/types";
interface AlertsTableProps {
alerts: AlertRule[];
meta?: MetaDataProps;
mutatingId: string | null;
onEdit: (alert: AlertRule) => void;
onToggleEnabled: (alert: AlertRule) => void;
onDelete: (alert: AlertRule) => void;
}
const TRIGGER_LABELS = {
after_scan: "After each scan",
daily: "Daily digest",
both: "After scan and daily",
} as const satisfies Record<AlertRule["attributes"]["trigger"], string>;
const formatRecipients = (alert: AlertRule): string => {
const recipients = alert.attributes.recipient_emails ?? [];
if (recipients.length === 0) return "No recipients";
if (recipients.length === 1) return recipients[0];
return `${recipients[0]} +${recipients.length - 1} more`;
};
interface GetAlertsTableColumnsOptions {
mutatingId: string | null;
onEdit: (alert: AlertRule) => void;
onToggleEnabled: (alert: AlertRule) => void;
onDelete: (alert: AlertRule) => void;
}
const getAlertsTableColumns = ({
mutatingId,
onEdit,
onToggleEnabled,
onDelete,
}: GetAlertsTableColumnsOptions): ColumnDef<AlertRule>[] => [
{
id: "name",
size: 320,
minSize: 280,
accessorFn: (alert) => alert.attributes.name,
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Name" param="name" />
),
cell: ({ row }) => {
const alert = row.original;
return (
<div className="flex w-[320px] max-w-[320px] min-w-0 flex-col gap-1">
<span className="truncate font-medium">{alert.attributes.name}</span>
{alert.attributes.description && (
<span
className="text-text-neutral-secondary block w-full truncate text-xs"
title={alert.attributes.description}
>
{alert.attributes.description}
</span>
)}
</div>
);
},
},
{
id: "enabled",
size: 140,
minSize: 120,
accessorFn: (alert) => (alert.attributes.enabled ? "Enabled" : "Disabled"),
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Status" param="enabled" />
),
cell: ({ row }) =>
row.original.attributes.enabled ? "Enabled" : "Disabled",
},
{
id: "trigger",
size: 190,
minSize: 170,
accessorFn: (alert) => TRIGGER_LABELS[alert.attributes.trigger],
header: ({ column }) => (
<DataTableColumnHeader
column={column}
title="Frequency"
param="trigger"
/>
),
cell: ({ row }) => TRIGGER_LABELS[row.original.attributes.trigger],
},
{
id: "recipients",
size: 220,
minSize: 180,
accessorFn: (alert) => formatRecipients(alert),
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Recipients" />
),
cell: ({ row }) => formatRecipients(row.original),
},
{
id: "actions",
size: 72,
minSize: 64,
enableSorting: false,
cell: ({ row }) => {
const alert = row.original;
const isMutating = mutatingId === alert.id;
const enabled = alert.attributes.enabled;
const toggleLabel = enabled ? "Disable" : "Enable";
return (
<div className="flex items-center justify-end">
<ActionDropdown ariaLabel={`Actions for ${alert.attributes.name}`}>
<ActionDropdownItem
icon={<PencilIcon />}
label="Edit"
onSelect={() => onEdit(alert)}
/>
<ActionDropdownItem
icon={<PowerIcon />}
label={toggleLabel}
disabled={isMutating}
onSelect={() => onToggleEnabled(alert)}
/>
<ActionDropdownDangerZone>
<ActionDropdownItem
icon={<TrashIcon />}
label="Delete"
destructive
disabled={isMutating}
onSelect={() => onDelete(alert)}
/>
</ActionDropdownDangerZone>
</ActionDropdown>
</div>
);
},
},
];
export const AlertsTable = ({
alerts,
meta,
mutatingId,
onEdit,
onToggleEnabled,
onDelete,
}: AlertsTableProps) => (
<DataTable
columns={getAlertsTableColumns({
mutatingId,
onEdit,
onToggleEnabled,
onDelete,
})}
data={alerts}
metadata={meta}
showSearch
searchPlaceholder="Search alerts"
/>
);
@@ -0,0 +1 @@
export * from "./seed-from-findings-button";
@@ -0,0 +1,167 @@
"use client";
import { BellPlusIcon } from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { createAlert } from "@/app/(prowler)/alerts/_actions";
import { AlertFormModal } from "@/app/(prowler)/alerts/_components/alert-form-modal";
import { toAlertPayload } from "@/app/(prowler)/alerts/_lib/alert-adapter";
import {
canSeedAlertFromFindingsFilters,
toPortableAlertFilterBag,
} from "@/app/(prowler)/alerts/_lib/seeding";
import {
ALERT_TRIGGER_KINDS,
type AlertCondition,
type AlertsFilterBag,
} from "@/app/(prowler)/alerts/_types";
import type {
AlertFormSubmitResult,
AlertFormValues,
} from "@/app/(prowler)/alerts/_types/alert-form";
import { buildFindingsFilterChips } from "@/components/findings/findings-filters.utils";
import {
Button,
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/shadcn";
import { ToastAction, useToast } from "@/components/ui";
import type { ScanEntity } from "@/types";
import type { ProviderProps } from "@/types/providers";
const DISABLED_FILTER_TOOLTIP =
"Apply at least one Findings filter to create an alert from filters.";
interface SeedFromFindingsButtonProps {
filterBag: AlertsFilterBag;
providers?: ProviderProps[];
scans?: Array<{ [scanId: string]: ScanEntity }>;
uniqueRegions?: string[];
uniqueServices?: string[];
uniqueResourceTypes?: string[];
uniqueCategories?: string[];
uniqueGroups?: string[];
className?: string;
size?: "sm" | "default";
defaultName?: string;
}
const toChipFilterMap = (
filterBag: AlertsFilterBag,
): Record<string, string[]> =>
Object.fromEntries(
Object.entries(filterBag)
.filter(([key]) => key.startsWith("filter["))
.map(([key, value]) => [
key,
(Array.isArray(value) ? value : value.split(","))
.map((entry) => entry.trim())
.filter(Boolean),
])
.filter(([, values]) => values.length > 0),
);
export const SeedFromFindingsButton = ({
filterBag,
providers = [],
scans = [],
uniqueRegions = [],
uniqueServices = [],
uniqueResourceTypes = [],
uniqueCategories = [],
uniqueGroups = [],
className,
size = "sm",
defaultName = "Findings filter alert",
}: SeedFromFindingsButtonProps) => {
const router = useRouter();
const { toast } = useToast();
const [modalOpen, setModalOpen] = useState(false);
const canSeedFromFilters = canSeedAlertFromFindingsFilters(filterBag);
const portableFilterBag = toPortableAlertFilterBag(filterBag);
const selectedFindingsFilterChips = buildFindingsFilterChips(
toChipFilterMap(filterBag),
{ providers, scans, includeMuted: true },
);
const handleClick = () => {
if (!canSeedFromFilters) return;
setModalOpen(true);
};
const submitAlert = async (
values: AlertFormValues,
advancedCondition: AlertCondition | null,
): Promise<AlertFormSubmitResult> => {
const result = await createAlert(toAlertPayload(values, advancedCondition));
if (!result.ok) return { ok: false, error: result.error.detail };
toast({
title: "Alert created",
description: result.data.data.attributes.name,
action: (
<ToastAction altText="View alerts" asChild>
<Link href="/alerts">View Alerts</Link>
</ToastAction>
),
});
router.refresh();
return { ok: true, alertId: result.data.data.id };
};
const button = (
<Button
size={size}
variant="default"
onClick={handleClick}
disabled={!canSeedFromFilters}
className={className}
>
<BellPlusIcon size={14} />
Create Alert
</Button>
);
if (canSeedFromFilters) {
return (
<>
{button}
<AlertFormModal
open={modalOpen}
defaultFrequency={ALERT_TRIGGER_KINDS.AFTER_SCAN}
providers={providers}
uniqueRegions={uniqueRegions}
uniqueServices={uniqueServices}
uniqueResourceTypes={uniqueResourceTypes}
uniqueCategories={uniqueCategories}
uniqueGroups={uniqueGroups}
initialFindingsFilters={portableFilterBag}
selectedFindingsFilterChips={selectedFindingsFilterChips}
defaultName={defaultName}
onOpenChange={setModalOpen}
onSubmit={submitAlert}
/>
</>
);
}
return (
<Tooltip>
<TooltipTrigger asChild>
<span
className="inline-flex"
tabIndex={0}
title={DISABLED_FILTER_TOOLTIP}
>
{button}
</span>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-xs">
{DISABLED_FILTER_TOOLTIP}
</TooltipContent>
</Tooltip>
);
};
@@ -0,0 +1,402 @@
import { describe, expect, it } from "vitest";
import {
ALERT_AGGREGATE_OPS,
ALERT_BOOLEAN_OPS,
ALERT_TRIGGER_KINDS,
type AlertCondition,
type AlertLeafFilter,
type AlertRule,
} from "@/app/(prowler)/alerts/_types";
import type { AlertFormValues } from "../../_types/alert-form";
import {
buildAlertCondition,
getAlertFormDefaults,
getAlertFormDefaultsFromFindingsFilters,
getFindingsFiltersFromAlertCondition,
toAlertPayload,
} from "../alert-adapter";
const baseValues = {
name: " Critical findings ",
description: " Notify security ",
method: "email",
frequency: ALERT_TRIGGER_KINDS.DAILY,
filterGroup: {
operator: "all",
children: [
{
kind: "filter",
field: "checkSeverities",
values: ["critical", "high"],
},
{ kind: "filter", field: "type", values: ["new"] },
{ kind: "filter", field: "providers", values: ["aws"] },
{ kind: "filter", field: "accounts", values: ["provider-1"] },
{ kind: "filter", field: "checks", values: ["iam_user_no_mfa"] },
{ kind: "filter", field: "regions", values: ["us-east-1"] },
{ kind: "filter", field: "services", values: ["iam"] },
{ kind: "filter", field: "categories", values: ["identity-security"] },
{ kind: "filter", field: "resourceGroups", values: ["prod"] },
{ kind: "filter", field: "resourceTypes", values: ["AWS::IAM::User"] },
{
kind: "filter",
field: "resources",
values: ["arn:aws:iam::123:user/alice"],
},
{ kind: "filter", field: "checkStatuses", values: ["FAIL"] },
],
},
severities: ["critical", "high"],
deltas: ["new"],
providerTypes: ["aws"],
providerIds: ["provider-1"],
checkIds: ["iam_user_no_mfa"],
categories: ["identity-security"],
regions: ["us-east-1"],
services: ["iam"],
resourceGroups: ["prod"],
findingGroupIds: [],
resourceTypes: ["AWS::IAM::User"],
recipientEmails: [" Security@Example.COM ", "ops@example.com"],
enabled: true,
} satisfies AlertFormValues;
const advancedCondition: AlertCondition = {
op: "not",
child: { op: ALERT_AGGREGATE_OPS.ANY, filter: { severity: ["critical"] } },
};
const countFilter = (filter: AlertLeafFilter) => ({
op: ALERT_AGGREGATE_OPS.COUNT_GTE,
filter,
value: 1,
});
const existingRule = {
id: "alert-1",
type: "alert-rules",
attributes: {
name: "Existing alert",
description: "Existing description",
enabled: false,
trigger: ALERT_TRIGGER_KINDS.BOTH,
condition: {
op: ALERT_AGGREGATE_OPS.ANY,
filter: { severity: ["medium", "low"] },
},
schema_version: 1,
recipient_emails: ["alerts@example.com"],
inserted_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
},
} satisfies AlertRule;
describe("simple alert adapter", () => {
describe("payload mapping", () => {
it("should map simple form values to the existing create payload contract", () => {
// Given / When
const payload = toAlertPayload(baseValues);
// Then
expect(payload).toEqual({
name: "Critical findings",
description: "Notify security",
enabled: true,
trigger: ALERT_TRIGGER_KINDS.DAILY,
condition: {
op: ALERT_BOOLEAN_OPS.AND,
children: [
countFilter({ severity: ["critical", "high"] }),
countFilter({ delta: ["new"] }),
countFilter({ provider_type: ["aws"] }),
countFilter({ provider_id: ["provider-1"] }),
countFilter({ check_id: ["iam_user_no_mfa"] }),
countFilter({ resource_regions: ["us-east-1"] }),
countFilter({ resource_services: ["iam"] }),
countFilter({ categories: ["identity-security"] }),
countFilter({ resource_groups: ["prod"] }),
countFilter({ resource_types: ["AWS::IAM::User"] }),
countFilter({ resource_uid: ["arn:aws:iam::123:user/alice"] }),
],
},
recipientEmails: ["security@example.com", "ops@example.com"],
});
expect(payload).not.toHaveProperty("method");
});
it("should normalize an edited alert to a simple condition instead of preserving an advanced condition", () => {
// Given / When
const payload = toAlertPayload(baseValues, advancedCondition);
// Then
expect(payload.condition).not.toBe(advancedCondition);
expect(payload.condition).toEqual(
expect.objectContaining({
op: ALERT_BOOLEAN_OPS.AND,
}),
);
expect(payload.trigger).toBe(ALERT_TRIGGER_KINDS.DAILY);
});
it("should build supported Findings-equivalent filters without Date or Status", () => {
// Given / When
const condition = buildAlertCondition({
operator: "all",
children: [
{ kind: "filter", field: "providers", values: ["gcp"] },
{ kind: "filter", field: "accounts", values: ["provider-2"] },
{ kind: "filter", field: "checkStatuses", values: ["FAIL"] },
{ kind: "filter", field: "checkSeverities", values: ["medium"] },
{ kind: "filter", field: "resources", values: ["resource-1"] },
{ kind: "filter", field: "regions", values: ["global"] },
{ kind: "filter", field: "services", values: ["compute"] },
{ kind: "filter", field: "categories", values: ["forensics"] },
{ kind: "filter", field: "resourceGroups", values: ["prod"] },
{ kind: "filter", field: "type", values: ["new"] },
{
kind: "group",
operator: "any",
children: [
{ kind: "filter", field: "checks", values: ["gcp_check"] },
{ kind: "filter", field: "regions", values: ["europe-west1"] },
],
},
],
});
// Then
expect(condition).toEqual({
op: ALERT_BOOLEAN_OPS.AND,
children: [
countFilter({ provider_type: ["gcp"] }),
countFilter({ provider_id: ["provider-2"] }),
countFilter({ severity: ["medium"] }),
countFilter({ resource_uid: ["resource-1"] }),
countFilter({ resource_regions: ["global"] }),
countFilter({ resource_services: ["compute"] }),
countFilter({ categories: ["forensics"] }),
countFilter({ resource_groups: ["prod"] }),
countFilter({ delta: ["new"] }),
{
op: ALERT_BOOLEAN_OPS.OR,
children: [
countFilter({ check_id: ["gcp_check"] }),
countFilter({ resource_regions: ["europe-west1"] }),
],
},
],
});
expect(JSON.stringify(condition)).not.toContain("status");
expect(JSON.stringify(condition)).not.toContain("muted");
expect(JSON.stringify(condition)).not.toContain("inserted_at");
});
it("should prefill modal defaults from active Findings filters and drop unsupported fields", () => {
// Given / When
const defaults = getAlertFormDefaultsFromFindingsFilters({
"filter[provider_type__in]": "aws,gcp",
"filter[provider_id__in]": "provider-1",
"filter[severity__in]": "critical,high",
"filter[delta__in]": "new",
"filter[region__in]": "us-east-1",
"filter[service__in]": "iam",
"filter[category__in]": "identity-security",
"filter[resource_groups__in]": "prod",
"filter[check_id__in]": "iam_user_no_mfa",
"filter[finding_group_id]": "finding-group-1",
"filter[resource_uid__in]": "arn:aws:iam::123:user/alice",
"filter[status__in]": "FAIL",
"filter[resource_type__in]": "AWS::IAM::User",
"filter[inserted_at]": "2026-01-01",
"filter[muted]": "false",
});
// Then
expect(defaults.filterGroup.children).toEqual([
{ kind: "filter", field: "providers", values: ["aws", "gcp"] },
{ kind: "filter", field: "accounts", values: ["provider-1"] },
{
kind: "filter",
field: "checkSeverities",
values: ["critical", "high"],
},
{ kind: "filter", field: "type", values: ["new"] },
{ kind: "filter", field: "regions", values: ["us-east-1"] },
{ kind: "filter", field: "services", values: ["iam"] },
{
kind: "filter",
field: "categories",
values: ["identity-security"],
},
{ kind: "filter", field: "resourceGroups", values: ["prod"] },
{ kind: "filter", field: "checks", values: ["iam_user_no_mfa"] },
{
kind: "filter",
field: "findingGroups",
values: ["finding-group-1"],
},
{
kind: "filter",
field: "resourceTypes",
values: ["AWS::IAM::User"],
},
{
kind: "filter",
field: "resources",
values: ["arn:aws:iam::123:user/alice"],
},
]);
expect(JSON.stringify(defaults.filterGroup)).not.toContain("inserted_at");
expect(JSON.stringify(defaults.filterGroup)).not.toContain("status");
expect(JSON.stringify(defaults.filterGroup)).not.toContain("muted");
});
it("should preserve finding group filters when seeding an alert from findings", () => {
// Given
const defaults = getAlertFormDefaultsFromFindingsFilters({
"filter[finding_group_id]": "group-1",
});
// When
const payload = toAlertPayload({
...baseValues,
filterGroup: defaults.filterGroup,
findingGroupIds: defaults.findingGroupIds,
});
// Then
expect(payload.condition).toEqual(
countFilter({ finding_group_id: ["group-1"] }),
);
});
it("should keep ALL type as an unbounded delta while NEW maps to delta=new", () => {
// Given / When
const condition = buildAlertCondition({
operator: "any",
children: [
{ kind: "filter", field: "type", values: ["all"] },
{ kind: "filter", field: "type", values: ["new"] },
],
});
// Then
expect(condition).toEqual({
...countFilter({ delta: ["new"] }),
});
});
it("should build a broad threshold-one condition when no portable filter remains", () => {
// Given / When
const condition = buildAlertCondition({
operator: "all",
children: [],
});
// Then
expect(condition).toEqual(
countFilter({
severity: ["critical", "high", "medium", "low", "informational"],
}),
);
});
});
describe("edit defaults", () => {
it("should hydrate simple defaults from an existing severity-only alert", () => {
// Given / When
const defaults = getAlertFormDefaults(existingRule);
// Then
expect(defaults).toEqual({
name: "Existing alert",
description: "Existing description",
method: "email",
frequency: ALERT_TRIGGER_KINDS.BOTH,
filterGroup: {
operator: "all",
children: [
{
kind: "filter",
field: "checkSeverities",
values: ["medium", "low"],
},
],
},
severities: ["medium", "low"],
deltas: [],
providerTypes: [],
providerIds: [],
checkIds: [],
categories: [],
regions: [],
services: [],
resourceGroups: [],
findingGroupIds: [],
resourceTypes: [],
recipientEmails: ["alerts@example.com"],
enabled: false,
advancedCondition: null,
});
});
it("should extract supported filters from advanced conditions and allow simple edits", () => {
// Given
const alert = {
...existingRule,
attributes: {
...existingRule.attributes,
condition: {
op: ALERT_BOOLEAN_OPS.NOT,
child: {
op: ALERT_BOOLEAN_OPS.AND,
children: [
countFilter({ severity: ["critical"] }),
countFilter({ provider_type: ["aws"] }),
countFilter({ status: ["FAIL"] } as AlertLeafFilter),
],
},
},
},
} satisfies AlertRule;
// When
const defaults = getAlertFormDefaults(alert);
// Then
expect(defaults.filterGroup.children).toEqual([
{
kind: "filter",
field: "checkSeverities",
values: ["critical"],
},
{ kind: "filter", field: "providers", values: ["aws"] },
]);
expect(defaults.advancedCondition).toBeNull();
});
it("should expose every editable alert condition as pending Findings filters", () => {
// Given
const condition = {
op: ALERT_BOOLEAN_OPS.AND,
children: [
countFilter({ check_id: ["iam_user_no_mfa"] }),
countFilter({ resource_uid: ["arn:aws:iam::123:user/alice"] }),
countFilter({ finding_group_id: ["finding-group-1"] }),
],
} satisfies AlertCondition;
// When
const filters = getFindingsFiltersFromAlertCondition(condition);
// Then
expect(filters).toEqual({
"filter[check_id__in]": ["iam_user_no_mfa"],
"filter[resource_uid__in]": ["arn:aws:iam::123:user/alice"],
"filter[finding_group_id]": ["finding-group-1"],
});
});
});
});
@@ -0,0 +1,103 @@
import { describe, expect, it } from "vitest";
import { ALERT_ERROR_CODES } from "../../_types";
import {
buildSuccessResult,
buildUnexpectedError,
isThrottled,
mapJsonApiErrorToAction,
} from "../error-mapping";
describe("mapJsonApiErrorToAction", () => {
it("maps a JSON:API validation error with a known code", () => {
const error = mapJsonApiErrorToAction(
400,
{
errors: [
{
code: "unknown_filter_field",
detail: "Unknown filter field 'foo'.",
source: { pointer: "/data/attributes/condition/filter/foo" },
},
],
},
null,
);
expect(error.code).toBe(ALERT_ERROR_CODES.UNKNOWN_FILTER_FIELD);
expect(error.detail).toBe("Unknown filter field 'foo'.");
expect(error.source?.pointer).toContain("foo");
expect(error.status).toBe(400);
});
it("falls back to status-based code when API code is unknown", () => {
const error = mapJsonApiErrorToAction(
404,
{ errors: [{ detail: "Not found." }] },
null,
);
expect(error.code).toBe(ALERT_ERROR_CODES.NOT_FOUND);
});
it("parses Retry-After in seconds for throttled responses", () => {
const error = mapJsonApiErrorToAction(429, null, "42");
expect(error.code).toBe(ALERT_ERROR_CODES.THROTTLED);
expect(error.retryAfterSeconds).toBe(42);
});
it("collects seeding warnings from meta", () => {
const error = mapJsonApiErrorToAction(
400,
{
errors: [{ code: "unknown_operator", detail: "bad op" }],
meta: { warnings: ["non_portable_date_filter", "garbage_warning"] },
},
null,
);
expect(error.warnings).toEqual(["non_portable_date_filter"]);
});
it("returns UNKNOWN with status fallback for unrecognised 5xx", () => {
const error = mapJsonApiErrorToAction(500, null, null);
expect(error.code).toBe(ALERT_ERROR_CODES.UNKNOWN);
expect(error.status).toBe(500);
});
});
describe("buildSuccessResult", () => {
it("returns ok=true with no warnings when meta has none", () => {
const result = buildSuccessResult({ id: "1" }, null);
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data).toEqual({ id: "1" });
expect(result.warnings).toBeUndefined();
}
});
it("filters meta.warnings to known seeding warnings", () => {
const result = buildSuccessResult(
{ id: "1" },
{ meta: { warnings: ["pagination_not_supported", "totally_made_up"] } },
);
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.warnings).toEqual(["pagination_not_supported"]);
}
});
});
describe("isThrottled", () => {
it("identifies a throttled action result", () => {
const result = {
ok: false as const,
error: buildUnexpectedError(),
};
expect(isThrottled(result)).toBe(false);
const throttled = {
ok: false as const,
error: { code: ALERT_ERROR_CODES.THROTTLED, detail: "x" },
};
expect(isThrottled(throttled)).toBe(true);
});
});
@@ -0,0 +1,104 @@
import { describe, expect, it } from "vitest";
import {
canSeedAlertFromFindingsFilters,
toPortableAlertFilterBag,
} from "../seeding";
describe("canSeedAlertFromFindingsFilters", () => {
it("should accept status, muted, and scan filters as real Findings filters", () => {
// Given
const filterBag = {
"filter[status__in]": "FAIL",
"filter[muted]": "false",
"filter[scan__in]": "scan-1",
};
// When
const result = canSeedAlertFromFindingsFilters(filterBag);
// Then
expect(result).toBe(true);
});
it("should reject sort and pagination without a real filter", () => {
// Given
const filterBag = {
sort: "-inserted_at",
page: "2",
pageSize: "50",
};
// When
const result = canSeedAlertFromFindingsFilters(filterBag);
// Then
expect(result).toBe(false);
});
it("should accept finding group id filters because the backend treats them as portable", () => {
// Given
const filterBag = {
"filter[finding_group_id]": "group-1",
};
// When
const result = canSeedAlertFromFindingsFilters(filterBag);
// Then
expect(result).toBe(true);
});
it("should accept at least one supported portable finding filter", () => {
// Given
const filterBag = {
"filter[status__in]": "FAIL",
"filter[severity__in]": "critical,high",
};
// When
const result = canSeedAlertFromFindingsFilters(filterBag);
// Then
expect(result).toBe(true);
});
});
describe("toPortableAlertFilterBag", () => {
it("should keep backend-compatible filters and drop UI-only filters", () => {
// Given
const filterBag = {
"filter[status__in]": "FAIL",
"filter[muted]": "false",
"filter[scan__in]": "scan-1",
"filter[severity__in]": "critical,high",
"filter[region__in]": "us-east-1",
sort: "-inserted_at",
page: "2",
};
// When
const result = toPortableAlertFilterBag(filterBag);
// Then
expect(result).toEqual({
"filter[severity__in]": "critical,high",
"filter[region__in]": "us-east-1",
});
});
it("should return an empty bag when only unsupported filters are selected", () => {
// Given
const filterBag = {
"filter[status__in]": "FAIL",
"filter[muted]": "false",
"filter[scan__in]": "scan-1",
};
// When
const result = toPortableAlertFilterBag(filterBag);
// Then
expect(result).toEqual({});
});
});
@@ -0,0 +1,634 @@
import type { AlertPayload } from "@/app/(prowler)/alerts/_actions/alerts";
import {
ALERT_AGGREGATE_OPS,
ALERT_BOOLEAN_OPS,
ALERT_DELTA_VALUES,
ALERT_SEVERITY_VALUES,
ALERT_TRIGGER_KINDS,
type AlertCondition,
type AlertDelta,
type AlertLeafFilter,
type AlertProviderType,
type AlertRule,
type AlertSeverity,
} from "@/app/(prowler)/alerts/_types";
import {
ALERT_FILTER_FIELDS,
ALERT_FILTER_OPERATORS,
ALERT_NOTIFICATION_METHODS,
type AlertFormDefaults,
type AlertFormFilterGroup,
type AlertFormFilterItem,
type AlertFormFilterNode,
type AlertFormFindingFilterBag,
type AlertFormValues,
} from "../_types/alert-form";
const DEFAULT_SEVERITIES: AlertSeverity[] = [...ALERT_SEVERITY_VALUES];
const normalizeStringValues = (values: string[]): string[] =>
values.map((value) => value.trim()).filter(Boolean);
const createFilterNode = (
field: AlertFormFilterItem["field"],
values: string[],
): AlertFormFilterItem => ({ kind: "filter", field, values });
const getStringArrayFilterValue = (
filter: AlertLeafFilter,
field: keyof AlertLeafFilter,
): string[] => {
const value = filter[field];
return Array.isArray(value) ? value : [];
};
const normalizeRecipientEmails = (emails: string[]): string[] =>
emails
.map((email) => email.trim().toLowerCase())
.filter((email) => email.length > 0);
const filterItemToLeafFilter = (
item: AlertFormFilterItem,
): AlertLeafFilter | null => {
const normalized = normalizeStringValues(item.values);
if (normalized.length === 0) return null;
switch (item.field) {
case ALERT_FILTER_FIELDS.PROVIDERS:
return { provider_type: normalized as AlertProviderType[] };
case ALERT_FILTER_FIELDS.ACCOUNTS:
return { provider_id: normalized };
case ALERT_FILTER_FIELDS.CHECK_SEVERITIES:
return { severity: normalized as AlertSeverity[] };
case ALERT_FILTER_FIELDS.RESOURCES:
return { resource_uid: normalized };
case ALERT_FILTER_FIELDS.RESOURCE_TYPES:
return { resource_types: normalized };
case ALERT_FILTER_FIELDS.REGIONS:
return { resource_regions: normalized };
case ALERT_FILTER_FIELDS.SERVICES:
return { resource_services: normalized };
case ALERT_FILTER_FIELDS.CATEGORIES:
return { categories: normalized };
case ALERT_FILTER_FIELDS.RESOURCE_GROUPS:
return { resource_groups: normalized };
case ALERT_FILTER_FIELDS.FINDING_GROUPS:
return { finding_group_id: normalized };
case ALERT_FILTER_FIELDS.TYPE: {
const deltas = normalized.filter((value) =>
ALERT_DELTA_VALUES.includes(value as AlertDelta),
) as AlertDelta[];
return deltas.length > 0 ? { delta: deltas } : null;
}
case ALERT_FILTER_FIELDS.CHECKS:
return { check_id: normalized };
case ALERT_FILTER_FIELDS.CHECK_STATUSES:
case ALERT_FILTER_FIELDS.ACCOUNT_TAGS:
case ALERT_FILTER_FIELDS.DATA_DATE_WINDOW:
case ALERT_FILTER_FIELDS.INCLUDE_MUTED_FINDINGS:
return null;
}
};
const buildConditionFromNode = (
node: AlertFormFilterNode,
): AlertCondition | null => {
if (node.kind === "filter") {
const filter = filterItemToLeafFilter(node);
return filter
? { op: ALERT_AGGREGATE_OPS.COUNT_GTE, filter, value: 1 }
: null;
}
return buildConditionFromGroup(node);
};
const buildConditionFromGroup = (
group: AlertFormFilterGroup,
): AlertCondition => {
const children = group.children
.map(buildConditionFromNode)
.filter((condition): condition is AlertCondition => condition !== null);
if (children.length === 0) {
return {
op: ALERT_AGGREGATE_OPS.COUNT_GTE,
filter: { severity: DEFAULT_SEVERITIES },
value: 1,
};
}
if (children.length === 1) return children[0];
return group.operator === ALERT_FILTER_OPERATORS.ANY
? { op: ALERT_BOOLEAN_OPS.OR, children }
: { op: ALERT_BOOLEAN_OPS.AND, children };
};
const legacyValuesToFilterGroup = (
values: Partial<AlertFormValues>,
): AlertFormFilterGroup => ({
operator: ALERT_FILTER_OPERATORS.ALL,
children: [
createFilterNode(
ALERT_FILTER_FIELDS.CHECK_SEVERITIES,
values.severities ?? DEFAULT_SEVERITIES,
),
createFilterNode(ALERT_FILTER_FIELDS.TYPE, values.deltas ?? []),
createFilterNode(ALERT_FILTER_FIELDS.PROVIDERS, values.providerTypes ?? []),
createFilterNode(ALERT_FILTER_FIELDS.ACCOUNTS, values.providerIds ?? []),
createFilterNode(ALERT_FILTER_FIELDS.CHECKS, values.checkIds ?? []),
createFilterNode(
ALERT_FILTER_FIELDS.RESOURCE_TYPES,
values.resourceTypes ?? [],
),
createFilterNode(ALERT_FILTER_FIELDS.REGIONS, values.regions ?? []),
createFilterNode(ALERT_FILTER_FIELDS.SERVICES, values.services ?? []),
createFilterNode(ALERT_FILTER_FIELDS.CATEGORIES, values.categories ?? []),
createFilterNode(
ALERT_FILTER_FIELDS.RESOURCE_GROUPS,
values.resourceGroups ?? [],
),
createFilterNode(
ALERT_FILTER_FIELDS.FINDING_GROUPS,
values.findingGroupIds ?? [],
),
createFilterNode(ALERT_FILTER_FIELDS.RESOURCES, []),
],
});
const isAlertFormFilterGroup = (
values: AlertFormFilterGroup | Partial<AlertFormValues>,
): values is AlertFormFilterGroup =>
"children" in values && "operator" in values;
export const buildAlertCondition = (
values: AlertFormFilterGroup | Partial<AlertFormValues>,
): AlertCondition => {
const group = isAlertFormFilterGroup(values)
? values
: (values.filterGroup ?? legacyValuesToFilterGroup(values));
return buildConditionFromGroup(group);
};
const ALERT_FILTER_FIELDS_ALLOWED = new Set<keyof AlertLeafFilter>([
"severity",
"delta",
"provider_type",
"provider_id",
"check_id",
"resource_regions",
"resource_services",
"resource_types",
"categories",
"resource_groups",
"finding_group_id",
"resource_uid",
]);
const isAlertFormFilter = (condition: AlertCondition): boolean => {
if (
condition.op !== ALERT_AGGREGATE_OPS.ANY &&
condition.op !== ALERT_AGGREGATE_OPS.COUNT_GTE
) {
return false;
}
if (condition.op === ALERT_AGGREGATE_OPS.COUNT_GTE && condition.value !== 1) {
return false;
}
return Object.keys(condition.filter).every((field) =>
ALERT_FILTER_FIELDS_ALLOWED.has(field as keyof AlertLeafFilter),
);
};
const mergeLeafFilters = (filters: AlertLeafFilter[]): AlertLeafFilter => {
const merged: AlertLeafFilter = {};
filters.forEach((filter) => {
Object.entries(filter).forEach(([field, value]) => {
if (!Array.isArray(value)) return;
const key = field as keyof AlertLeafFilter;
const current = Array.isArray(merged[key]) ? merged[key] : [];
merged[key] = Array.from(new Set([...current, ...value]));
});
});
return merged;
};
const getSimpleFilterFromCondition = (
condition: AlertCondition,
): AlertLeafFilter | null => {
if (
isAlertFormFilter(condition) &&
(condition.op === ALERT_AGGREGATE_OPS.ANY ||
condition.op === ALERT_AGGREGATE_OPS.COUNT_GTE)
) {
return condition.filter;
}
if (condition.op !== ALERT_BOOLEAN_OPS.AND) return null;
const childFilters = condition.children.map((child) =>
isAlertFormFilter(child) &&
(child.op === ALERT_AGGREGATE_OPS.ANY ||
child.op === ALERT_AGGREGATE_OPS.COUNT_GTE)
? child.filter
: null,
);
if (childFilters.some((filter) => filter === null)) return null;
return mergeLeafFilters(childFilters as AlertLeafFilter[]);
};
const pickAlertFormFilterFields = (
filter: AlertLeafFilter,
): AlertLeafFilter | null => {
const simpleFilter: AlertLeafFilter = {};
Object.entries(filter).forEach(([field, value]) => {
if (
!ALERT_FILTER_FIELDS_ALLOWED.has(field as keyof AlertLeafFilter) ||
!Array.isArray(value) ||
value.length === 0
) {
return;
}
simpleFilter[field as keyof AlertLeafFilter] = value;
});
return Object.keys(simpleFilter).length > 0 ? simpleFilter : null;
};
const getPortableFiltersFromCondition = (
condition: AlertCondition,
): AlertLeafFilter[] => {
if ("filter" in condition) {
const simpleFilter = pickAlertFormFilterFields(condition.filter);
return simpleFilter ? [simpleFilter] : [];
}
if ("child" in condition) {
return getPortableFiltersFromCondition(condition.child);
}
return condition.children.flatMap(getPortableFiltersFromCondition);
};
const getEditableFilterFromCondition = (
condition: AlertCondition,
): AlertLeafFilter | null =>
getSimpleFilterFromCondition(condition) ??
(() => {
const portableFilters = getPortableFiltersFromCondition(condition);
return portableFilters.length > 0
? mergeLeafFilters(portableFilters)
: null;
})();
const filterToSimpleGroup = (
filter: AlertLeafFilter,
): AlertFormFilterGroup => ({
operator: ALERT_FILTER_OPERATORS.ALL,
children: [
createFilterNode(
ALERT_FILTER_FIELDS.CHECK_SEVERITIES,
getStringArrayFilterValue(filter, "severity"),
),
createFilterNode(
ALERT_FILTER_FIELDS.TYPE,
getStringArrayFilterValue(filter, "delta"),
),
createFilterNode(
ALERT_FILTER_FIELDS.PROVIDERS,
getStringArrayFilterValue(filter, "provider_type"),
),
createFilterNode(
ALERT_FILTER_FIELDS.ACCOUNTS,
getStringArrayFilterValue(filter, "provider_id"),
),
createFilterNode(
ALERT_FILTER_FIELDS.CHECKS,
getStringArrayFilterValue(filter, "check_id"),
),
createFilterNode(
ALERT_FILTER_FIELDS.REGIONS,
getStringArrayFilterValue(filter, "resource_regions"),
),
createFilterNode(
ALERT_FILTER_FIELDS.SERVICES,
getStringArrayFilterValue(filter, "resource_services"),
),
createFilterNode(
ALERT_FILTER_FIELDS.CATEGORIES,
getStringArrayFilterValue(filter, "categories"),
),
createFilterNode(
ALERT_FILTER_FIELDS.RESOURCE_GROUPS,
getStringArrayFilterValue(filter, "resource_groups"),
),
createFilterNode(
ALERT_FILTER_FIELDS.FINDING_GROUPS,
getStringArrayFilterValue(filter, "finding_group_id"),
),
createFilterNode(
ALERT_FILTER_FIELDS.RESOURCES,
getStringArrayFilterValue(filter, "resource_uid"),
),
createFilterNode(
ALERT_FILTER_FIELDS.RESOURCE_TYPES,
getStringArrayFilterValue(filter, "resource_types"),
),
].filter((node) => node.values.length > 0),
});
export const toAlertPayload = (
values: AlertFormValues,
_existingCondition?: AlertCondition | null,
): AlertPayload => ({
name: values.name.trim(),
description: values.description.trim(),
enabled: values.enabled,
trigger: values.frequency,
condition: buildAlertCondition(values.filterGroup),
recipientEmails: normalizeRecipientEmails(values.recipientEmails),
});
export const getEmptyAlertFormDefaults = (
frequency: AlertFormValues["frequency"] = ALERT_TRIGGER_KINDS.AFTER_SCAN,
): AlertFormDefaults => ({
name: "",
description: "",
method: ALERT_NOTIFICATION_METHODS.EMAIL,
frequency,
filterGroup: {
operator: ALERT_FILTER_OPERATORS.ALL,
children: [
createFilterNode(
ALERT_FILTER_FIELDS.CHECK_SEVERITIES,
DEFAULT_SEVERITIES,
),
],
},
severities: DEFAULT_SEVERITIES,
deltas: [],
providerTypes: [],
providerIds: [],
checkIds: [],
categories: [],
regions: [],
services: [],
resourceGroups: [],
findingGroupIds: [],
resourceTypes: [],
recipientEmails: [],
enabled: true,
advancedCondition: null,
});
export const getAlertFormDefaults = (alert: AlertRule): AlertFormDefaults => {
const simpleFilter = getEditableFilterFromCondition(
alert.attributes.condition,
);
const simpleSeverities = Array.isArray(simpleFilter?.severity)
? (simpleFilter.severity as AlertSeverity[])
: null;
return {
name: alert.attributes.name,
description: alert.attributes.description,
method: ALERT_NOTIFICATION_METHODS.EMAIL,
frequency: alert.attributes.trigger,
filterGroup: simpleFilter
? filterToSimpleGroup(simpleFilter)
: getEmptyAlertFormDefaults(alert.attributes.trigger).filterGroup,
severities: simpleSeverities ?? DEFAULT_SEVERITIES,
deltas: (simpleFilter?.delta ?? []) as AlertDelta[],
providerTypes: (simpleFilter?.provider_type ?? []) as AlertProviderType[],
providerIds: getStringArrayFilterValue(simpleFilter ?? {}, "provider_id"),
checkIds: getStringArrayFilterValue(simpleFilter ?? {}, "check_id"),
categories: getStringArrayFilterValue(simpleFilter ?? {}, "categories"),
regions: getStringArrayFilterValue(simpleFilter ?? {}, "resource_regions"),
services: getStringArrayFilterValue(
simpleFilter ?? {},
"resource_services",
),
resourceGroups: getStringArrayFilterValue(
simpleFilter ?? {},
"resource_groups",
),
findingGroupIds: getStringArrayFilterValue(
simpleFilter ?? {},
"finding_group_id",
),
resourceTypes: getStringArrayFilterValue(
simpleFilter ?? {},
"resource_types",
),
recipientEmails: alert.attributes.recipient_emails ?? [],
enabled: alert.attributes.enabled,
advancedCondition: null,
};
};
const FINDINGS_FILTER_KEY_TO_SIMPLE_FIELD: Record<
string,
AlertFormFilterItem["field"]
> = {
provider_type: ALERT_FILTER_FIELDS.PROVIDERS,
provider_type__in: ALERT_FILTER_FIELDS.PROVIDERS,
"provider_type.in": ALERT_FILTER_FIELDS.PROVIDERS,
provider_id: ALERT_FILTER_FIELDS.ACCOUNTS,
provider_id__in: ALERT_FILTER_FIELDS.ACCOUNTS,
"provider_id.in": ALERT_FILTER_FIELDS.ACCOUNTS,
severity: ALERT_FILTER_FIELDS.CHECK_SEVERITIES,
severity__in: ALERT_FILTER_FIELDS.CHECK_SEVERITIES,
"severity.in": ALERT_FILTER_FIELDS.CHECK_SEVERITIES,
delta: ALERT_FILTER_FIELDS.TYPE,
delta__in: ALERT_FILTER_FIELDS.TYPE,
"delta.in": ALERT_FILTER_FIELDS.TYPE,
region: ALERT_FILTER_FIELDS.REGIONS,
region__in: ALERT_FILTER_FIELDS.REGIONS,
resource_regions: ALERT_FILTER_FIELDS.REGIONS,
resource_regions__in: ALERT_FILTER_FIELDS.REGIONS,
"resource_regions.in": ALERT_FILTER_FIELDS.REGIONS,
service: ALERT_FILTER_FIELDS.SERVICES,
service__in: ALERT_FILTER_FIELDS.SERVICES,
resource_services: ALERT_FILTER_FIELDS.SERVICES,
resource_services__in: ALERT_FILTER_FIELDS.SERVICES,
"resource_services.in": ALERT_FILTER_FIELDS.SERVICES,
category: ALERT_FILTER_FIELDS.CATEGORIES,
category__in: ALERT_FILTER_FIELDS.CATEGORIES,
categories: ALERT_FILTER_FIELDS.CATEGORIES,
categories__in: ALERT_FILTER_FIELDS.CATEGORIES,
"categories.in": ALERT_FILTER_FIELDS.CATEGORIES,
resource_groups: ALERT_FILTER_FIELDS.RESOURCE_GROUPS,
resource_groups__in: ALERT_FILTER_FIELDS.RESOURCE_GROUPS,
"resource_groups.in": ALERT_FILTER_FIELDS.RESOURCE_GROUPS,
finding_group_id: ALERT_FILTER_FIELDS.FINDING_GROUPS,
finding_group_id__in: ALERT_FILTER_FIELDS.FINDING_GROUPS,
"finding_group_id.in": ALERT_FILTER_FIELDS.FINDING_GROUPS,
check_id: ALERT_FILTER_FIELDS.CHECKS,
check_id__in: ALERT_FILTER_FIELDS.CHECKS,
"check_id.in": ALERT_FILTER_FIELDS.CHECKS,
resource_uid: ALERT_FILTER_FIELDS.RESOURCES,
resource_uid__in: ALERT_FILTER_FIELDS.RESOURCES,
"resource_uid.in": ALERT_FILTER_FIELDS.RESOURCES,
resource_type: ALERT_FILTER_FIELDS.RESOURCE_TYPES,
resource_type__in: ALERT_FILTER_FIELDS.RESOURCE_TYPES,
resource_types: ALERT_FILTER_FIELDS.RESOURCE_TYPES,
resource_types__in: ALERT_FILTER_FIELDS.RESOURCE_TYPES,
"resource_types.in": ALERT_FILTER_FIELDS.RESOURCE_TYPES,
};
const SIMPLE_FIELD_TO_FINDINGS_FILTER: Partial<
Record<keyof AlertLeafFilter, string>
> = {
provider_type: "filter[provider_type__in]",
provider_id: "filter[provider_id__in]",
severity: "filter[severity__in]",
delta: "filter[delta]",
resource_regions: "filter[region__in]",
resource_services: "filter[service__in]",
resource_types: "filter[resource_type__in]",
categories: "filter[category__in]",
resource_groups: "filter[resource_groups__in]",
check_id: "filter[check_id__in]",
finding_group_id: "filter[finding_group_id]",
resource_uid: "filter[resource_uid__in]",
};
const unwrapFindingsFilterKey = (rawKey: string): string => {
if (rawKey.startsWith("filter[") && rawKey.endsWith("]")) {
return rawKey.slice("filter[".length, -1);
}
return rawKey;
};
const splitFindingsFilterValues = (
value: AlertFormFindingFilterBag[string],
): string[] => {
const values = Array.isArray(value) ? value : [value];
return normalizeStringValues(
values.flatMap((entry) => String(entry).split(",")),
);
};
const uniqueValues = (values: string[]): string[] =>
Array.from(new Set(values));
export const getFindingsFiltersFromAlertCondition = (
condition: AlertCondition,
): Record<string, string[]> => {
if ("filter" in condition) {
return Object.entries(condition.filter).reduce<Record<string, string[]>>(
(filters, [field, value]) => {
const filterKey =
SIMPLE_FIELD_TO_FINDINGS_FILTER[field as keyof AlertLeafFilter];
if (!filterKey || !Array.isArray(value)) return filters;
filters[filterKey] = uniqueValues([
...(filters[filterKey] ?? []),
...value,
]);
return filters;
},
{},
);
}
if ("child" in condition) {
return getFindingsFiltersFromAlertCondition(condition.child);
}
return condition.children.reduce<Record<string, string[]>>(
(filters, child) => {
const childFilters = getFindingsFiltersFromAlertCondition(child);
Object.entries(childFilters).forEach(([filterKey, values]) => {
filters[filterKey] = uniqueValues([
...(filters[filterKey] ?? []),
...values,
]);
});
return filters;
},
{},
);
};
const getFieldValuesFromFindingsFilters = (
filterBag: AlertFormFindingFilterBag,
): Partial<Record<AlertFormFilterItem["field"], string[]>> => {
const fieldValues: Partial<Record<AlertFormFilterItem["field"], string[]>> =
{};
Object.entries(filterBag).forEach(([rawKey, rawValue]) => {
const field =
FINDINGS_FILTER_KEY_TO_SIMPLE_FIELD[unwrapFindingsFilterKey(rawKey)];
if (!field) return;
const values = splitFindingsFilterValues(rawValue);
if (values.length === 0) return;
fieldValues[field] = [...(fieldValues[field] ?? []), ...values];
});
return fieldValues;
};
const FINDINGS_FILTER_FIELD_ORDER = [
ALERT_FILTER_FIELDS.PROVIDERS,
ALERT_FILTER_FIELDS.ACCOUNTS,
ALERT_FILTER_FIELDS.CHECK_SEVERITIES,
ALERT_FILTER_FIELDS.TYPE,
ALERT_FILTER_FIELDS.REGIONS,
ALERT_FILTER_FIELDS.SERVICES,
ALERT_FILTER_FIELDS.CATEGORIES,
ALERT_FILTER_FIELDS.RESOURCE_GROUPS,
ALERT_FILTER_FIELDS.CHECKS,
ALERT_FILTER_FIELDS.FINDING_GROUPS,
ALERT_FILTER_FIELDS.RESOURCE_TYPES,
ALERT_FILTER_FIELDS.RESOURCES,
] as const;
export const getAlertFormDefaultsFromFindingsFilters = (
filterBag: AlertFormFindingFilterBag,
frequency: AlertFormValues["frequency"] = ALERT_TRIGGER_KINDS.AFTER_SCAN,
): AlertFormDefaults => {
const fieldValues = getFieldValuesFromFindingsFilters(filterBag);
const children = FINDINGS_FILTER_FIELD_ORDER.flatMap((field) => {
const values = fieldValues[field] ?? [];
return values.length > 0 ? [createFilterNode(field, values)] : [];
});
const defaults = getEmptyAlertFormDefaults(frequency);
return {
...defaults,
filterGroup: {
operator: ALERT_FILTER_OPERATORS.ALL,
children: children.length > 0 ? children : defaults.filterGroup.children,
},
severities: (fieldValues[ALERT_FILTER_FIELDS.CHECK_SEVERITIES] ??
defaults.severities) as AlertFormValues["severities"],
deltas: (fieldValues[ALERT_FILTER_FIELDS.TYPE] ??
defaults.deltas) as AlertFormValues["deltas"],
providerTypes: (fieldValues[ALERT_FILTER_FIELDS.PROVIDERS] ??
defaults.providerTypes) as AlertFormValues["providerTypes"],
providerIds:
fieldValues[ALERT_FILTER_FIELDS.ACCOUNTS] ?? defaults.providerIds,
checkIds: fieldValues[ALERT_FILTER_FIELDS.CHECKS] ?? defaults.checkIds,
regions: fieldValues[ALERT_FILTER_FIELDS.REGIONS] ?? defaults.regions,
categories:
fieldValues[ALERT_FILTER_FIELDS.CATEGORIES] ?? defaults.categories,
services: fieldValues[ALERT_FILTER_FIELDS.SERVICES] ?? defaults.services,
resourceGroups:
fieldValues[ALERT_FILTER_FIELDS.RESOURCE_GROUPS] ??
defaults.resourceGroups,
findingGroupIds:
fieldValues[ALERT_FILTER_FIELDS.FINDING_GROUPS] ??
defaults.findingGroupIds,
resourceTypes:
fieldValues[ALERT_FILTER_FIELDS.RESOURCE_TYPES] ?? defaults.resourceTypes,
};
};
@@ -0,0 +1,67 @@
import { z } from "zod";
import {
ALERT_DELTA_VALUES,
ALERT_PROVIDER_TYPE_VALUES,
ALERT_SEVERITY_VALUES,
ALERT_TRIGGER_KIND_VALUES,
} from "@/app/(prowler)/alerts/_types";
import {
ALERT_FILTER_FIELDS,
ALERT_FILTER_OPERATORS,
ALERT_NOTIFICATION_METHODS,
} from "../_types/alert-form";
const alertFilterItemSchema = z.object({
kind: z.literal("filter"),
field: z.enum(Object.values(ALERT_FILTER_FIELDS)),
values: z.array(z.string().trim()).default([]),
});
type AlertFormFilterNodeSchema =
| z.infer<typeof alertFilterItemSchema>
| {
kind: "group";
operator: (typeof ALERT_FILTER_OPERATORS)[keyof typeof ALERT_FILTER_OPERATORS];
children: AlertFormFilterNodeSchema[];
};
const alertFilterNodeSchema: z.ZodType<AlertFormFilterNodeSchema> = z.lazy(() =>
z.union([
alertFilterItemSchema,
z.object({
kind: z.literal("group"),
operator: z.enum(Object.values(ALERT_FILTER_OPERATORS)),
children: z.array(alertFilterNodeSchema),
}),
]),
);
export const alertFormSchema = z.object({
name: z.string().trim().min(1, { error: "Name is required." }).max(120),
description: z.string().trim().max(2000).default(""),
method: z.literal(ALERT_NOTIFICATION_METHODS.EMAIL),
frequency: z.enum(ALERT_TRIGGER_KIND_VALUES),
filterGroup: z.object({
operator: z.enum(Object.values(ALERT_FILTER_OPERATORS)),
children: z.array(alertFilterNodeSchema).min(1),
}),
severities: z.array(z.enum(ALERT_SEVERITY_VALUES)).default([]),
deltas: z.array(z.enum(ALERT_DELTA_VALUES)).default([]),
providerTypes: z.array(z.enum(ALERT_PROVIDER_TYPE_VALUES)).default([]),
providerIds: z.array(z.string().trim().min(1)).default([]),
checkIds: z.array(z.string().trim().min(1)).default([]),
categories: z.array(z.string().trim().min(1)).default([]),
regions: z.array(z.string().trim().min(1)).default([]),
services: z.array(z.string().trim().min(1)).default([]),
resourceGroups: z.array(z.string().trim().min(1)).default([]),
findingGroupIds: z.array(z.string().trim().min(1)).default([]),
resourceTypes: z.array(z.string().trim().min(1)).default([]),
recipientEmails: z
.array(z.email({ error: "Enter a valid email address." }))
.default([]),
enabled: z.boolean(),
});
export type AlertFormSchemaValues = z.infer<typeof alertFormSchema>;
+25
View File
@@ -0,0 +1,25 @@
import {
ALERT_ERROR_CODES,
type AlertPublicResponse,
type AlertsActionResult,
} from "../_types";
const ALERTS_DISABLED_MESSAGE =
"Custom alerts are only available in Prowler Cloud.";
export const isAlertsEnabled = () =>
process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true";
export const buildAlertsDisabledResult = <T>(): AlertsActionResult<T> => ({
ok: false,
error: {
code: ALERT_ERROR_CODES.FORBIDDEN,
detail: ALERTS_DISABLED_MESSAGE,
status: 403,
},
});
export const buildAlertsDisabledPublicResponse = (): AlertPublicResponse => ({
state: "network_error",
message: ALERTS_DISABLED_MESSAGE,
});
@@ -0,0 +1,112 @@
import {
ALERT_ERROR_CODES,
ALERT_SEEDING_WARNINGS,
type AlertsActionError,
type AlertsActionErrorSource,
type AlertsActionResult,
type AlertSeedingWarning,
type AlertsErrorCode,
} from "../_types";
interface JsonApiErrorObject {
status?: string | number;
code?: string;
detail?: string;
source?: AlertsActionErrorSource;
meta?: { code?: string; warnings?: string[] };
}
interface JsonApiErrorBody {
errors?: JsonApiErrorObject[];
detail?: string;
message?: string;
meta?: { warnings?: string[] };
}
const KNOWN_ERROR_CODES = new Set<string>(Object.values(ALERT_ERROR_CODES));
const KNOWN_WARNINGS = new Set<string>(Object.values(ALERT_SEEDING_WARNINGS));
const STATUS_FALLBACK_CODES: Record<number, AlertsErrorCode> = {
401: ALERT_ERROR_CODES.FORBIDDEN,
403: ALERT_ERROR_CODES.FORBIDDEN,
404: ALERT_ERROR_CODES.NOT_FOUND,
409: ALERT_ERROR_CODES.CONFLICT,
429: ALERT_ERROR_CODES.THROTTLED,
};
const pickCode = (raw: string | undefined): AlertsErrorCode | undefined => {
if (!raw) return undefined;
return KNOWN_ERROR_CODES.has(raw) ? (raw as AlertsErrorCode) : undefined;
};
const pickWarning = (raw: string): AlertSeedingWarning | undefined =>
KNOWN_WARNINGS.has(raw) ? (raw as AlertSeedingWarning) : undefined;
const collectWarnings = (
body: JsonApiErrorBody | null,
): AlertSeedingWarning[] =>
(body?.meta?.warnings ?? [])
.map((w) => pickWarning(w))
.filter((w): w is AlertSeedingWarning => w !== undefined);
const parseRetryAfter = (header: string | null): number | undefined => {
if (!header) return undefined;
const seconds = Number(header);
if (Number.isFinite(seconds) && seconds >= 0) return seconds;
const date = Date.parse(header);
if (Number.isNaN(date)) return undefined;
return Math.max(0, Math.round((date - Date.now()) / 1000));
};
export const mapJsonApiErrorToAction = (
status: number,
body: JsonApiErrorBody | null,
retryAfterHeader: string | null,
): AlertsActionError => {
const firstError = body?.errors?.[0];
const detail =
firstError?.detail ||
body?.detail ||
body?.message ||
"Custom alerts request failed.";
const apiCode =
pickCode(firstError?.code) ||
pickCode(firstError?.meta?.code) ||
pickCode((body as { code?: string } | null)?.code);
const fallback = STATUS_FALLBACK_CODES[status] ?? ALERT_ERROR_CODES.UNKNOWN;
const code: AlertsErrorCode = apiCode ?? fallback;
const warnings = collectWarnings(body);
return {
code,
detail,
source: firstError?.source,
status,
retryAfterSeconds:
code === ALERT_ERROR_CODES.THROTTLED
? parseRetryAfter(retryAfterHeader)
: undefined,
warnings: warnings.length > 0 ? warnings : undefined,
};
};
export const buildSuccessResult = <T>(
data: T,
body: JsonApiErrorBody | null,
): AlertsActionResult<T> => {
const warnings = collectWarnings(body);
return warnings.length > 0
? { ok: true, data, warnings }
: { ok: true, data };
};
export const isThrottled = (
result: AlertsActionResult<unknown>,
): result is { ok: false; error: AlertsActionError } =>
!result.ok && result.error.code === ALERT_ERROR_CODES.THROTTLED;
export const buildUnexpectedError = (
detail = "Unexpected error.",
): AlertsActionError => ({
code: ALERT_ERROR_CODES.UNKNOWN,
detail,
});
+102
View File
@@ -0,0 +1,102 @@
import type { AlertsFilterBag } from "../_types";
const PORTABLE_FINDINGS_FILTER_KEYS = [
"severity",
"severity.in",
"severity__in",
"delta",
"delta.in",
"delta__in",
"check_id",
"check_id.in",
"check_id__in",
"finding_group_id",
"finding_group_id.in",
"finding_group_id__in",
"categories",
"categories.in",
"categories__in",
"category",
"category__in",
"resource_regions",
"resource_regions.in",
"resource_regions__in",
"region",
"region__in",
"resource_services",
"resource_services.in",
"resource_services__in",
"service",
"service__in",
"resource_types",
"resource_types.in",
"resource_types__in",
"resource_type",
"resource_type__in",
"resource_groups",
"resource_groups.in",
"resource_groups__in",
"resource_uid",
"resource_uid.in",
"resource_uid__in",
"provider_id",
"provider_id.in",
"provider_id__in",
"provider_type",
"provider_type.in",
"provider_type__in",
] as const;
const PORTABLE_FINDINGS_FILTER_KEY_SET = new Set<string>(
PORTABLE_FINDINGS_FILTER_KEYS,
);
const NON_FILTER_QUERY_KEYS = new Set(["sort", "page", "pageSize"]);
const unwrapFilterKey = (rawKey: string): string => {
if (rawKey.startsWith("filter[") && rawKey.endsWith("]")) {
return rawKey.slice("filter[".length, -1);
}
return rawKey;
};
const isFilterKey = (rawKey: string): boolean =>
rawKey.startsWith("filter[") && rawKey.endsWith("]");
const hasSeedableFilterValue = (value: AlertsFilterBag[string]): boolean => {
const values = Array.isArray(value) ? value : [value];
return values.some((entry) =>
entry
.split(",")
.map((part) => part.trim())
.some(Boolean),
);
};
/**
* Product invariant for the Findings entry point: any visible filter can open
* the alert modal, but only backend-portable filters are sent as rule criteria.
*/
export const canSeedAlertFromFindingsFilters = (
filterBag: AlertsFilterBag,
): boolean =>
Object.entries(filterBag).some(([rawKey, value]) => {
if (!isFilterKey(rawKey) || NON_FILTER_QUERY_KEYS.has(rawKey)) return false;
return hasSeedableFilterValue(value);
});
export const toPortableAlertFilterBag = (
filterBag: AlertsFilterBag,
): AlertsFilterBag =>
Object.fromEntries(
Object.entries(filterBag).filter(([rawKey, value]) => {
const key = unwrapFilterKey(rawKey);
return (
PORTABLE_FINDINGS_FILTER_KEY_SET.has(key) &&
hasSeedableFilterValue(value)
);
}),
);
@@ -0,0 +1,108 @@
import type {
AlertCondition,
AlertDelta,
AlertProviderType,
AlertSeverity,
AlertTriggerKind,
} from "@/app/(prowler)/alerts/_types";
export const ALERT_FILTER_OPERATORS = {
ALL: "all",
ANY: "any",
} as const;
export type AlertFormFilterOperator =
(typeof ALERT_FILTER_OPERATORS)[keyof typeof ALERT_FILTER_OPERATORS];
export const ALERT_FILTER_FIELDS = {
PROVIDERS: "providers",
ACCOUNTS: "accounts",
CHECK_STATUSES: "checkStatuses",
CHECK_SEVERITIES: "checkSeverities",
RESOURCES: "resources",
RESOURCE_TYPES: "resourceTypes",
REGIONS: "regions",
SERVICES: "services",
CATEGORIES: "categories",
RESOURCE_GROUPS: "resourceGroups",
FINDING_GROUPS: "findingGroups",
ACCOUNT_TAGS: "accountTags",
TYPE: "type",
DATA_DATE_WINDOW: "dataDateWindow",
INCLUDE_MUTED_FINDINGS: "includeMutedFindings",
CHECKS: "checks",
} as const;
export type AlertFormFilterField =
(typeof ALERT_FILTER_FIELDS)[keyof typeof ALERT_FILTER_FIELDS];
export const ALERT_FINDING_TYPES = {
NEW: "new",
ALL: "all",
} as const;
export type AlertFormFindingType =
(typeof ALERT_FINDING_TYPES)[keyof typeof ALERT_FINDING_TYPES];
export interface AlertFormFilterItem {
kind: "filter";
field: AlertFormFilterField;
values: string[];
}
export interface AlertFormFilterGroup {
kind?: "group";
operator: AlertFormFilterOperator;
children: AlertFormFilterNode[];
}
export type AlertFormFilterNode =
| AlertFormFilterItem
| (AlertFormFilterGroup & { kind: "group" });
export const ALERT_NOTIFICATION_METHODS = {
EMAIL: "email",
} as const;
export type AlertNotificationMethod =
(typeof ALERT_NOTIFICATION_METHODS)[keyof typeof ALERT_NOTIFICATION_METHODS];
export const ALERT_NOTIFICATION_METHOD_OPTIONS = [
{
value: ALERT_NOTIFICATION_METHODS.EMAIL,
label: "Email",
},
] as const;
export interface AlertFormValues {
name: string;
description: string;
method: AlertNotificationMethod;
frequency: AlertTriggerKind;
filterGroup: AlertFormFilterGroup;
severities: AlertSeverity[];
deltas: AlertDelta[];
providerTypes: AlertProviderType[];
providerIds: string[];
checkIds: string[];
categories: string[];
regions: string[];
services: string[];
resourceGroups: string[];
findingGroupIds: string[];
resourceTypes: string[];
recipientEmails: string[];
enabled: boolean;
}
export interface AlertFormDefaults extends AlertFormValues {
advancedCondition: AlertCondition | null;
}
export interface AlertFormSubmitResult {
ok: boolean;
alertId?: string;
error?: string;
}
export type AlertFormFindingFilterBag = Record<string, string | string[]>;
+369
View File
@@ -0,0 +1,369 @@
import { FINDING_DELTA } from "@/types/components";
import { PROVIDER_TYPES, type ProviderType } from "@/types/providers";
import { SEVERITY_LEVELS, type SeverityLevel } from "@/types/severities";
// Canonical DSL vocabulary and resource types for the Alerts UI.
// Mirrors api/src/backend/alerts/dsl.py — every constant declared here MUST
// match the API. Drift is a bug; reviewers compare the two files when either
// changes.
// ---- operator vocabulary -------------------------------------------------
export const ALERT_BOOLEAN_OPS = {
AND: "and",
OR: "or",
NOT: "not",
} as const;
export type AlertBooleanOp =
(typeof ALERT_BOOLEAN_OPS)[keyof typeof ALERT_BOOLEAN_OPS];
export const ALERT_AGGREGATE_OPS = {
COUNT_GTE: "count_gte",
COUNT_LTE: "count_lte",
ANY: "any",
NONE: "none",
} as const;
export type AlertAggregateOp =
(typeof ALERT_AGGREGATE_OPS)[keyof typeof ALERT_AGGREGATE_OPS];
export const ALERT_BOOLEAN_OP_VALUES = Object.values(
ALERT_BOOLEAN_OPS,
) as readonly AlertBooleanOp[];
export const ALERT_AGGREGATE_OP_VALUES = Object.values(
ALERT_AGGREGATE_OPS,
) as readonly AlertAggregateOp[];
// ---- filter field vocabulary --------------------------------------------
export const ALERT_FILTER_FIELDS = {
SEVERITY: "severity",
DELTA: "delta",
CHECK_ID: "check_id",
FINDING_GROUP_ID: "finding_group_id",
CATEGORIES: "categories",
RESOURCE_REGIONS: "resource_regions",
RESOURCE_SERVICES: "resource_services",
RESOURCE_TYPES: "resource_types",
RESOURCE_UID: "resource_uid",
RESOURCE_GROUPS: "resource_groups",
PROVIDER_ID: "provider_id",
PROVIDER_TYPE: "provider_type",
} as const;
export type AlertFilterField =
(typeof ALERT_FILTER_FIELDS)[keyof typeof ALERT_FILTER_FIELDS];
export const ALERT_FILTER_FIELD_VALUES = Object.values(
ALERT_FILTER_FIELDS,
) as readonly AlertFilterField[];
// Field-kind classification — drives the value editor rendered by the
// condition builder and the runtime type checks in the validator.
export const ALERT_FILTER_FIELD_KIND = {
ENUM_LIST: "enum_list",
BOOLEAN: "boolean",
UUID_LIST: "uuid_list",
STRING_LIST: "string_list",
} as const;
export type AlertFilterFieldKind =
(typeof ALERT_FILTER_FIELD_KIND)[keyof typeof ALERT_FILTER_FIELD_KIND];
export const ALERT_FILTER_FIELD_KIND_BY_FIELD: Readonly<
Record<AlertFilterField, AlertFilterFieldKind>
> = {
severity: ALERT_FILTER_FIELD_KIND.ENUM_LIST,
delta: ALERT_FILTER_FIELD_KIND.ENUM_LIST,
provider_type: ALERT_FILTER_FIELD_KIND.ENUM_LIST,
provider_id: ALERT_FILTER_FIELD_KIND.UUID_LIST,
check_id: ALERT_FILTER_FIELD_KIND.STRING_LIST,
finding_group_id: ALERT_FILTER_FIELD_KIND.STRING_LIST,
categories: ALERT_FILTER_FIELD_KIND.STRING_LIST,
resource_regions: ALERT_FILTER_FIELD_KIND.STRING_LIST,
resource_services: ALERT_FILTER_FIELD_KIND.STRING_LIST,
resource_types: ALERT_FILTER_FIELD_KIND.STRING_LIST,
resource_uid: ALERT_FILTER_FIELD_KIND.STRING_LIST,
resource_groups: ALERT_FILTER_FIELD_KIND.STRING_LIST,
};
// Closed enums for fields whose values are bounded. Builder uses them to
// render multi-selects; validator uses them to reject out-of-range inputs.
export const ALERT_SEVERITY_VALUES = SEVERITY_LEVELS;
export type AlertSeverity = SeverityLevel;
export const ALERT_DELTA_VALUES = [
FINDING_DELTA.NEW,
FINDING_DELTA.CHANGED,
] as const;
export type AlertDelta = (typeof ALERT_DELTA_VALUES)[number];
// Mirrors api.models.Provider.ProviderChoices. Keep in sync if a new
// platform is registered there; the API rejects values outside this set.
export const ALERT_PROVIDER_TYPE_VALUES = PROVIDER_TYPES;
export type AlertProviderType = ProviderType;
export const ALERT_ENUM_VALUES_BY_FIELD = {
severity: ALERT_SEVERITY_VALUES,
delta: ALERT_DELTA_VALUES,
provider_type: ALERT_PROVIDER_TYPE_VALUES,
} as const;
// Forbidden filter fields — the validator rejects these with a clear error
// instead of "unknown field". Mirrors `dsl.py::FORBIDDEN_FILTER_FIELDS`.
export const ALERT_FORBIDDEN_FILTER_FIELDS = [
"inserted_at",
"inserted_at__gte",
"inserted_at__lte",
"inserted_at__date",
"updated_at",
"updated_at__gte",
"updated_at__lte",
"first_seen_at",
"first_seen_at__gte",
"first_seen_at__lte",
"search",
"sort",
"page",
"page[number]",
"page[size]",
"include",
] as const;
export type AlertForbiddenFilterField =
(typeof ALERT_FORBIDDEN_FILTER_FIELDS)[number];
// ---- limits --------------------------------------------------------------
export const ALERT_SCHEMA_VERSION = 1 as const;
export const ALERT_MAX_DEPTH = 5 as const;
export const ALERT_MAX_NODES = 100 as const;
export const ALERT_AGGREGATE_VALUE_MIN = 1 as const;
export const ALERT_AGGREGATE_VALUE_MAX = 1_000_000 as const;
// ---- triggers ------------------------------------------------------------
export const ALERT_TRIGGER_KINDS = {
AFTER_SCAN: "after_scan",
DAILY: "daily",
BOTH: "both",
} as const;
export type AlertTriggerKind =
(typeof ALERT_TRIGGER_KINDS)[keyof typeof ALERT_TRIGGER_KINDS];
export const ALERT_TRIGGER_KIND_VALUES = Object.values(
ALERT_TRIGGER_KINDS,
) as readonly AlertTriggerKind[];
// ---- recipient lifecycle -------------------------------------------------
export const ALERT_RECIPIENT_STATUS = {
PENDING: "pending",
CONFIRMED: "confirmed",
UNSUBSCRIBED: "unsubscribed",
BOUNCED: "bounced",
} as const;
export type AlertRecipientStatus =
(typeof ALERT_RECIPIENT_STATUS)[keyof typeof ALERT_RECIPIENT_STATUS];
export const ALERT_RECIPIENT_STATUS_VALUES = Object.values(
ALERT_RECIPIENT_STATUS,
) as readonly AlertRecipientStatus[];
// ---- error codes ---------------------------------------------------------
export const ALERT_ERROR_CODES = {
UNKNOWN_OPERATOR: "unknown_operator",
UNKNOWN_FILTER_FIELD: "unknown_filter_field",
FORBIDDEN_FILTER_FIELD: "forbidden_filter_field",
INVALID_VALUE: "invalid_value",
INVALID_SHAPE: "invalid_shape",
CROSS_TENANT_REFERENCE: "cross_tenant_reference",
CONDITION_TOO_COMPLEX: "condition_too_complex",
UNKNOWN_SCHEMA_VERSION: "unknown_schema_version",
THROTTLED: "throttled",
NOT_FOUND: "not_found",
FORBIDDEN: "forbidden",
CONFLICT: "conflict",
INVALID_TOKEN: "invalid_token",
EXPIRED_TOKEN: "expired_token",
UNKNOWN: "unknown",
} as const;
export type AlertsErrorCode =
(typeof ALERT_ERROR_CODES)[keyof typeof ALERT_ERROR_CODES];
// ---- seeding warnings ----------------------------------------------------
export const ALERT_SEEDING_WARNINGS = {
NON_PORTABLE_DATE_FILTER: "non_portable_date_filter",
TEXT_SEARCH_NOT_PORTABLE: "text_search_not_portable",
ORDERING_NOT_SUPPORTED: "ordering_not_supported",
PAGINATION_NOT_SUPPORTED: "pagination_not_supported",
SIDELOADING_NOT_SUPPORTED: "sideloading_not_supported",
UNKNOWN_OR_NON_PORTABLE_FIELD: "unknown_or_non_portable_field",
INVALID_VALUE_DROPPED: "invalid_value_dropped",
} as const;
export type AlertSeedingWarning =
(typeof ALERT_SEEDING_WARNINGS)[keyof typeof ALERT_SEEDING_WARNINGS];
// ---- discriminated condition union --------------------------------------
// Leaf filter is a partial mapping from a whitelisted field name to its
// validated value. Kept loose at the type level (the Zod schema in
// ./lib/schemas.ts does the strict per-kind validation).
export type AlertLeafFilterValue = string[] | boolean;
export type AlertLeafFilter = Partial<
Record<AlertFilterField, AlertLeafFilterValue>
>;
export interface AlertConditionAnd {
op: typeof ALERT_BOOLEAN_OPS.AND;
children: AlertCondition[];
}
export interface AlertConditionOr {
op: typeof ALERT_BOOLEAN_OPS.OR;
children: AlertCondition[];
}
export interface AlertConditionNot {
op: typeof ALERT_BOOLEAN_OPS.NOT;
child: AlertCondition;
}
export interface AlertConditionCountGte {
op: typeof ALERT_AGGREGATE_OPS.COUNT_GTE;
filter: AlertLeafFilter;
value: number;
}
export interface AlertConditionCountLte {
op: typeof ALERT_AGGREGATE_OPS.COUNT_LTE;
filter: AlertLeafFilter;
value: number;
}
export interface AlertConditionAny {
op: typeof ALERT_AGGREGATE_OPS.ANY;
filter: AlertLeafFilter;
}
export interface AlertConditionNone {
op: typeof ALERT_AGGREGATE_OPS.NONE;
filter: AlertLeafFilter;
}
export type AlertConditionGroup =
| AlertConditionAnd
| AlertConditionOr
| AlertConditionNot;
export type AlertConditionLeaf =
| AlertConditionCountGte
| AlertConditionCountLte
| AlertConditionAny
| AlertConditionNone;
export type AlertCondition = AlertConditionGroup | AlertConditionLeaf;
// ---- resource attribute shapes ------------------------------------------
export interface AlertRuleAttributes {
name: string;
description: string;
enabled: boolean;
trigger: AlertTriggerKind;
condition: AlertCondition;
schema_version: number;
/**
* Emails of the recipients attached to the rule. The API exposes the
* relationship as a list of email strings (write- and read-side via the
* `recipient_emails` attribute), not as a JSON:API relationships block.
*/
recipient_emails?: string[];
created_by?: string | null;
inserted_at: string;
updated_at: string;
}
export interface AlertRecipientAttributes {
email: string;
status: AlertRecipientStatus;
confirmation_sent_at?: string | null;
confirmation_expires_at?: string | null;
confirmed_at?: string | null;
unsubscribed_at?: string | null;
last_bounce_at?: string | null;
inserted_at: string;
updated_at: string;
}
export interface AlertPreviewSummary {
finding_count_total?: number;
counts_by_severity?: Record<string, number>;
top_severity?: string;
top_findings?: string[];
deep_link_filter_hint?: Record<string, unknown>;
}
export interface AlertPreviewResponse {
would_fire: boolean;
summary: AlertPreviewSummary;
sample_finding_ids?: string[];
evaluation_failed: boolean;
last_error?: string | null;
summary_fallback?: boolean;
duration_ms?: number;
}
// ---- JSON:API envelopes --------------------------------------------------
export interface JsonApiRelationshipRef {
id: string;
type: string;
}
export interface JsonApiRelationship {
data: JsonApiRelationshipRef | JsonApiRelationshipRef[] | null;
}
export interface AlertRule {
id: string;
type: "alert-rules";
attributes: AlertRuleAttributes;
relationships?: {
recipients?: JsonApiRelationship;
last_event?: JsonApiRelationship;
};
}
export interface AlertRecipient {
id: string;
type: "alert-recipients";
attributes: AlertRecipientAttributes;
relationships?: {
rules?: JsonApiRelationship;
};
}
// ---- typed action result -------------------------------------------------
export interface AlertsActionErrorSource {
pointer?: string;
parameter?: string;
}
export interface AlertsActionError {
code: AlertsErrorCode;
detail: string;
source?: AlertsActionErrorSource;
status?: number;
retryAfterSeconds?: number;
warnings?: AlertSeedingWarning[];
}
export type AlertsActionResult<T> =
| { ok: true; data: T; warnings?: AlertSeedingWarning[] }
| { ok: false; error: AlertsActionError };
// ---- seeding payloads ----------------------------------------------------
export type AlertsFilterBag = Record<string, string | string[]>;
export type { AlertPublicResponse } from "./public";
+25
View File
@@ -0,0 +1,25 @@
// Public confirm / unsubscribe contract for Alerts public pages.
// NOT FOR THE MVP: keep only if public recipient consent links are required.
// Mirrors the API's ``{state, message}`` JSON contract from the public
// alert recipient endpoints. ``network_error`` is UI-side only: the
// fetch wrapper folds connection failures into the same shape so the
// caller has a single switch to render.
export const ALERT_PUBLIC_STATES = {
CONFIRMED: "confirmed",
ALREADY_CONFIRMED: "already_confirmed",
CANNOT_CONFIRM: "cannot_confirm",
UNSUBSCRIBED: "unsubscribed",
ALREADY_UNSUBSCRIBED: "already_unsubscribed",
MISSING_TOKEN: "missing_token",
INVALID_TOKEN: "invalid_token",
SUPERSEDED: "superseded",
NOT_FOUND: "not_found",
NETWORK_ERROR: "network_error",
} as const;
export type AlertPublicState =
(typeof ALERT_PUBLIC_STATES)[keyof typeof ALERT_PUBLIC_STATES];
export interface AlertPublicResponse {
state: AlertPublicState;
message: string;
}
+118
View File
@@ -0,0 +1,118 @@
import { notFound } from "next/navigation";
import { getLatestMetadataInfo } from "@/actions/findings";
import { getProviders } from "@/actions/providers";
import { getScans } from "@/actions/scans";
import { getAlert, listAlerts } from "@/app/(prowler)/alerts/_actions";
import { AlertsManager } from "@/app/(prowler)/alerts/_components/alerts-manager";
import { isAlertsEnabled } from "@/app/(prowler)/alerts/_lib/env";
import { ContentLayout } from "@/components/ui";
import { createScanDetailsMapping } from "@/lib";
import type { MetaDataProps, ScanEntity, ScanProps } from "@/types";
interface AlertsPageProps {
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
}
const getParamValue = (
params: Awaited<AlertsPageProps["searchParams"]>,
key: string,
): string | undefined => {
const value = params[key];
return Array.isArray(value) ? value[0] : value;
};
const toAlertsSearchParams = (
resolvedSearchParams: Awaited<AlertsPageProps["searchParams"]>,
): URLSearchParams => {
const page = Number.parseInt(
getParamValue(resolvedSearchParams, "page") ?? "1",
10,
);
const pageSize = Number.parseInt(
getParamValue(resolvedSearchParams, "pageSize") ?? "20",
10,
);
const sort = getParamValue(resolvedSearchParams, "sort") ?? "-inserted_at";
const search = getParamValue(resolvedSearchParams, "filter[search]") ?? "";
const enabledFilter = getParamValue(resolvedSearchParams, "filter[enabled]");
const triggerFilter = getParamValue(resolvedSearchParams, "filter[trigger]");
const params = new URLSearchParams();
params.set("page[number]", String(page));
params.set("page[size]", String(pageSize));
params.set("sort", sort);
if (search) params.set("filter[search]", search);
if (enabledFilter) params.set("filter[enabled]", enabledFilter);
if (triggerFilter) params.set("filter[trigger]", triggerFilter);
return params;
};
export default async function AlertsPage({ searchParams }: AlertsPageProps) {
if (!isAlertsEnabled()) {
notFound();
}
const resolvedSearchParams = await searchParams;
const editAlertId = getParamValue(resolvedSearchParams, "edit");
const [result, providersData, scansData, metadataInfoData, editResult] =
await Promise.all([
listAlerts(toAlertsSearchParams(resolvedSearchParams)),
getProviders({ pageSize: 50 }),
getScans({ pageSize: 50 }),
getLatestMetadataInfo({}),
editAlertId ? getAlert(editAlertId) : Promise.resolve(null),
]);
const alerts = result.ok ? result.data.data : [];
const apiMeta = result.ok ? result.data.meta : undefined;
const loadError = !result.ok ? result.error.detail : null;
const uniqueRegions = metadataInfoData?.data?.attributes?.regions || [];
const uniqueServices = metadataInfoData?.data?.attributes?.services || [];
const uniqueResourceTypes =
metadataInfoData?.data?.attributes?.resource_types || [];
const uniqueCategories = metadataInfoData?.data?.attributes?.categories || [];
const uniqueGroups = metadataInfoData?.data?.attributes?.groups || [];
const scans = scansData && "data" in scansData ? scansData.data : [];
const completedScans = scans?.filter(
(scan: ScanProps) =>
scan.attributes.state === "completed" &&
scan.attributes.unique_resource_count > 1,
);
const completedScanIds =
completedScans?.map((scan: ScanProps) => scan.id) || [];
const scanDetails = createScanDetailsMapping(
completedScans || [],
providersData,
) as { [uid: string]: ScanEntity }[];
const editingAlert =
editResult && editResult.ok ? editResult.data.data : null;
const meta: MetaDataProps | undefined = apiMeta?.pagination
? {
pagination: {
page: apiMeta.pagination.page,
pages: apiMeta.pagination.pages,
count: apiMeta.pagination.count,
},
version: "1",
}
: undefined;
return (
<ContentLayout title="Alerts" icon="lucide:bell-ring">
<AlertsManager
alerts={alerts}
meta={meta}
loadError={loadError}
providers={providersData?.data || []}
completedScanIds={completedScanIds}
scanDetails={scanDetails}
uniqueRegions={uniqueRegions}
uniqueServices={uniqueServices}
uniqueResourceTypes={uniqueResourceTypes}
uniqueCategories={uniqueCategories}
uniqueGroups={uniqueGroups}
initialEditingAlert={editingAlert}
/>
</ContentLayout>
);
}
@@ -3,6 +3,7 @@
import { ColumnDef } from "@tanstack/react-table";
import { Check, Minus } from "lucide-react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useRef } from "react";
import {
RadioGroup,
@@ -243,6 +244,8 @@ export const ScanListTable = ({ scans }: ScanListTableProps) => {
const endIndex = startIndex + pageSize;
const paginatedScans = scans.slice(startIndex, endIndex);
const suppressNextPageResetRef = useRef(false);
const pushWithParams = (nextParams: Record<string, string>) => {
const params = new URLSearchParams(searchParams.toString());
@@ -257,9 +260,18 @@ export const ScanListTable = ({ scans }: ScanListTableProps) => {
pushWithParams({ scanId });
};
const handlePaginationChange = (nextPage: number, nextPageSize: number) => {
const handlePageChange = (page: number) => {
if (suppressNextPageResetRef.current && page === 1) {
suppressNextPageResetRef.current = false;
return;
}
pushWithParams({ scanPage: page.toString() });
};
const handlePageSizeChange = (nextPageSize: number) => {
suppressNextPageResetRef.current = true;
pushWithParams({
scanPage: nextPage.toString(),
scanPage: "1",
scanPageSize: nextPageSize.toString(),
});
};
@@ -276,7 +288,8 @@ export const ScanListTable = ({ scans }: ScanListTableProps) => {
metadata={buildMetadata(scans.length, currentPage, totalPages)}
controlledPage={currentPage}
controlledPageSize={pageSize}
onPaginationChange={handlePaginationChange}
onPageChange={handlePageChange}
onPageSizeChange={handlePageSizeChange}
onRowClick={(row) => {
if (row.original.attributes.graph_data_ready) {
handleSelectScan(row.original.id);
+2 -4
View File
@@ -166,7 +166,6 @@ export default async function Compliance({
>
<SSRComplianceGrid
searchParams={resolvedSearchParams}
scanId={selectedScanId}
selectedScan={selectedScanData}
/>
</Suspense>
@@ -180,13 +179,12 @@ export default async function Compliance({
const SSRComplianceGrid = async ({
searchParams,
scanId,
selectedScan,
}: {
searchParams: SearchParamsProps;
scanId: string | null;
selectedScan?: ScanEntity;
}) => {
const scanId = searchParams.scanId?.toString() || "";
const regionFilter = searchParams["filter[region__in]"]?.toString() || "";
// Only fetch compliance data if we have a valid scanId
@@ -249,7 +247,7 @@ const SSRComplianceGrid = async ({
<ComplianceOverviewPanel>
<ComplianceOverviewGrid
frameworks={frameworks}
scanId={scanId ?? ""}
scanId={scanId}
selectedScan={selectedScan}
latestCisIds={latestCisIds}
/>
+17
View File
@@ -8,6 +8,8 @@ import {
import { getLatestMetadataInfo, getMetadataInfo } from "@/actions/findings";
import { getProviders } from "@/actions/providers";
import { getScan, getScans } from "@/actions/scans";
import { SeedFromFindingsButton } from "@/app/(prowler)/alerts/_components";
import { isAlertsEnabled } from "@/app/(prowler)/alerts/_lib/env";
import { FindingsFilters } from "@/components/findings/findings-filters";
import {
FindingsGroupTable,
@@ -80,6 +82,7 @@ export default async function Findings({
completedScans || [],
providersData,
) as { [uid: string]: ScanEntity }[];
const showAlertsControls = isAlertsEnabled();
return (
<ContentLayout title="Findings" icon="lucide:tag">
@@ -94,6 +97,20 @@ export default async function Findings({
uniqueResourceTypes={uniqueResourceTypes}
uniqueCategories={uniqueCategories}
uniqueGroups={uniqueGroups}
trailingControls={
showAlertsControls ? (
<SeedFromFindingsButton
filterBag={filters}
providers={providersData?.data || []}
scans={scanDetails}
uniqueRegions={uniqueRegions}
uniqueServices={uniqueServices}
uniqueResourceTypes={uniqueResourceTypes}
uniqueCategories={uniqueCategories}
uniqueGroups={uniqueGroups}
/>
) : undefined
}
/>
</div>
<Suspense fallback={<SkeletonTableFindings />}>
@@ -55,7 +55,7 @@ describe("ApplyFiltersButton", () => {
// ── No changes ───────────────────────────────────────────────────────────
describe("when hasChanges is false", () => {
it("should render the Apply Filters button as disabled", () => {
it("should render the Apply Changes button as disabled", () => {
// Given / When
render(
<ApplyFiltersButton
@@ -68,7 +68,7 @@ describe("ApplyFiltersButton", () => {
// Then
const applyButton = screen.getByRole("button", {
name: "Apply Filters",
name: "Apply Changes",
});
expect(applyButton).toBeDisabled();
});
@@ -92,7 +92,7 @@ describe("ApplyFiltersButton", () => {
).not.toBeInTheDocument();
});
it("should show 'Apply Filters' label without count", () => {
it("should show 'Apply Changes' label without count", () => {
// Given / When
render(
<ApplyFiltersButton
@@ -105,7 +105,7 @@ describe("ApplyFiltersButton", () => {
// Then
expect(
screen.getByRole("button", { name: "Apply Filters" }),
screen.getByRole("button", { name: "Apply Changes" }),
).toBeInTheDocument();
});
});
@@ -113,7 +113,7 @@ describe("ApplyFiltersButton", () => {
// ── Has changes ──────────────────────────────────────────────────────────
describe("when hasChanges is true", () => {
it("should render the Apply Filters button as enabled", () => {
it("should render the Apply Changes button as enabled", () => {
// Given / When
render(
<ApplyFiltersButton
@@ -126,7 +126,7 @@ describe("ApplyFiltersButton", () => {
// Then
const applyButton = screen.getByRole("button", {
name: "Apply Filters (2)",
name: "Apply Changes (2)",
});
expect(applyButton).not.toBeDisabled();
});
@@ -144,11 +144,11 @@ describe("ApplyFiltersButton", () => {
// Then
expect(
screen.getByRole("button", { name: "Apply Filters (3)" }),
screen.getByRole("button", { name: "Apply Changes (3)" }),
).toBeInTheDocument();
});
it("should show 'Apply Filters' (without count) when changeCount is 0 but hasChanges is true", () => {
it("should show 'Apply Changes' (without count) when changeCount is 0 but hasChanges is true", () => {
// Given — hasChanges=true but changeCount=0 (edge case)
render(
<ApplyFiltersButton
@@ -161,7 +161,7 @@ describe("ApplyFiltersButton", () => {
// Then
expect(
screen.getByRole("button", { name: "Apply Filters" }),
screen.getByRole("button", { name: "Apply Changes" }),
).toBeInTheDocument();
});
@@ -186,7 +186,7 @@ describe("ApplyFiltersButton", () => {
// ── onApply interaction ──────────────────────────────────────────────────
describe("onApply", () => {
it("should call onApply when the Apply Filters button is clicked", async () => {
it("should call onApply when the Apply Changes button is clicked", async () => {
// Given
const user = userEvent.setup();
const onApply = vi.fn();
@@ -203,7 +203,7 @@ describe("ApplyFiltersButton", () => {
// When
await user.click(
screen.getByRole("button", { name: "Apply Filters (1)" }),
screen.getByRole("button", { name: "Apply Changes (1)" }),
);
// Then
@@ -226,7 +226,7 @@ describe("ApplyFiltersButton", () => {
);
// When
await user.click(screen.getByRole("button", { name: "Apply Filters" }));
await user.click(screen.getByRole("button", { name: "Apply Changes" }));
// Then — disabled button should not fire
expect(onApply).not.toHaveBeenCalled();
@@ -8,7 +8,7 @@ export interface ApplyFiltersButtonProps {
hasChanges: boolean;
/** Number of filter keys that have pending changes */
changeCount: number;
/** Called when the user clicks "Apply Filters" */
/** Called when the user clicks "Apply Changes" */
onApply: () => void;
/** Called when the user clicks the discard (Undo) action */
onDiscard: () => void;
@@ -17,7 +17,7 @@ export interface ApplyFiltersButtonProps {
}
/**
* Displays an "Apply Filters" button with an optional discard action.
* Displays an "Apply Changes" button with an optional discard action.
*
* - Shows the count of pending changes when `hasChanges` is true.
* - The apply button is disabled (and visually muted) when there are no changes.
@@ -32,12 +32,12 @@ export const ApplyFiltersButton = ({
className,
}: ApplyFiltersButtonProps) => {
const label =
changeCount > 0 ? `Apply Filters (${changeCount})` : "Apply Filters";
changeCount > 0 ? `Apply Changes (${changeCount})` : "Apply Changes";
return (
<div className={cn("flex items-center gap-2", className)}>
<Button
variant="link"
variant="default"
size="sm"
disabled={!hasChanges}
onClick={onApply}
@@ -1,7 +1,10 @@
import type { ReactNode } from "react";
import { cn } from "@/lib/utils";
interface BatchFiltersLayoutProps {
controls: ReactNode;
controlsClassName?: string;
expandedFilters?: ReactNode;
expandedFiltersVisible?: boolean;
appliedSummary?: ReactNode;
@@ -14,6 +17,7 @@ interface BatchFiltersLayoutProps {
export const BatchFiltersLayout = ({
controls,
controlsClassName,
expandedFilters,
expandedFiltersVisible = true,
appliedSummary,
@@ -26,7 +30,7 @@ export const BatchFiltersLayout = ({
<div className="flex flex-col gap-3">
<div
data-testid={`${testIdPrefix}-filter-controls`}
className="flex flex-wrap items-center gap-4"
className={cn("flex flex-wrap items-center gap-4", controlsClassName)}
>
{controls}
</div>
@@ -14,8 +14,8 @@ export interface ClearFiltersButtonProps {
ariaLabel?: string;
/** Show the count of active filters */
showCount?: boolean;
/** Use link style (text only, no button background) */
variant?: "link" | "default";
/** Button visual variant */
variant?: "link" | "default" | "outline";
/**
* Optional callback for batch mode. When provided, this is called INSTEAD
* of pushing URL params directly. Useful for clearing pending filter state
@@ -32,10 +32,10 @@ export interface ClearFiltersButtonProps {
}
export const ClearFiltersButton = ({
text = "Clear all filters",
text = "Clear All",
ariaLabel = "Reset",
showCount = false,
variant = "link",
variant = "outline",
onClear,
pendingCount,
}: ClearFiltersButtonProps) => {
@@ -80,7 +80,7 @@ export const ClearFiltersButton = ({
return null;
}
const displayText = showCount ? `Clear Filters (${displayCount})` : text;
const displayText = showCount ? `Clear All (${displayCount})` : text;
return (
<Button
@@ -70,7 +70,10 @@ export const FilterSummaryStrip = ({
<Tooltip key={`${chip.key}-${chip.values?.join("|") ?? chip.value}`}>
<Badge
variant="tag"
className="flex max-w-[280px] min-w-0 items-center gap-1 overflow-hidden pr-1"
className={cn(
"flex max-w-[280px] min-w-0 items-center gap-1 overflow-hidden",
onRemove && "pr-1",
)}
>
<TooltipTrigger asChild>
<span className="text-text-neutral-primary min-w-0 flex-1 truncate text-xs">
+83 -29
View File
@@ -1,6 +1,7 @@
"use client";
import { ChevronDown } from "lucide-react";
import type { ReactNode } from "react";
import { useState } from "react";
import { AccountsSelector } from "@/app/(prowler)/_overview/_components/accounts-selector";
@@ -16,7 +17,7 @@ import {
} from "@/components/filters/filter-summary-strip";
import { Button } from "@/components/shadcn";
import { ExpandableSection } from "@/components/ui/expandable-section";
import { DataTableFilterCustom } from "@/components/ui/table";
import { DataTableFilterCustom } from "@/components/ui/table/data-table-filter-custom";
import { useFilterBatch } from "@/hooks/use-filter-batch";
import { getCategoryLabel, getGroupLabel } from "@/lib/categories";
import { FilterType, ScanEntity } from "@/types";
@@ -38,6 +39,22 @@ interface FindingsFiltersProps {
uniqueResourceTypes: string[];
uniqueCategories: string[];
uniqueGroups: string[];
trailingControls?: ReactNode;
}
interface FindingsFilterBatchControlsProps extends FindingsFiltersProps {
appliedFilters: Record<string, string[]>;
pendingFilters: Record<string, string[]>;
changedFilters: Record<string, string[]>;
setPending: (filterKey: string, values: string[]) => void;
applyAll?: () => void;
discardAll?: () => void;
clearAndApply?: () => void;
removeAppliedAndApply?: (filterKey: string, value?: string) => void;
hasChanges?: boolean;
changeCount?: number;
getFilterValue: (filterKey: string) => string[];
showSummaries?: boolean;
}
const countVisibleFilterKeys = (filters: Record<string, string[]>): number =>
@@ -47,7 +64,10 @@ const countVisibleFilterKeys = (filters: Record<string, string[]>): number =>
return true;
}).length;
export const FindingsFilters = ({
const FILTER_CONTROL_COLUMN_CLASS =
"min-w-0 flex-none basis-full sm:basis-[calc((100%_-_0.75rem)/2)] lg:basis-[calc((100%_-_1.5rem)/3)] xl:basis-[calc((100%_-_2.25rem)/4)] 2xl:basis-[calc((100%_-_3rem)/5)]";
export const FindingsFilterBatchControls = ({
providers,
completedScanIds,
scanDetails,
@@ -56,25 +76,22 @@ export const FindingsFilters = ({
uniqueResourceTypes,
uniqueCategories,
uniqueGroups,
}: FindingsFiltersProps) => {
trailingControls,
appliedFilters,
pendingFilters,
changedFilters,
setPending,
applyAll,
discardAll,
clearAndApply,
removeAppliedAndApply,
hasChanges = false,
changeCount = 0,
getFilterValue,
showSummaries = true,
}: FindingsFilterBatchControlsProps) => {
const [isExpanded, setIsExpanded] = useState(false);
const {
appliedFilters,
pendingFilters,
changedFilters,
setPending,
applyAll,
discardAll,
clearAndApply,
removeAppliedAndApply,
hasChanges,
changeCount,
getFilterValue,
} = useFilterBatch({
defaultParams: { "filter[muted]": "false" },
});
// Custom filters for the expandable section (removed Provider - now using AccountsSelector)
const customFilters = [
...filterFindings.map((filter) => ({
@@ -196,11 +213,11 @@ export const FindingsFilters = ({
const appliedSummary = (
<FilterSummaryStrip
chips={appliedFilterChips}
onRemove={removeAppliedAndApply}
onRemove={removeAppliedAndApply ?? (() => undefined)}
trailingContent={
<ClearFiltersButton
showCount
onClear={clearAndApply}
onClear={clearAndApply ?? (() => undefined)}
pendingCount={appliedCount}
/>
}
@@ -215,8 +232,8 @@ export const FindingsFilters = ({
<ApplyFiltersButton
hasChanges={hasChanges}
changeCount={changeCount}
onApply={applyAll}
onDiscard={discardAll}
onApply={applyAll ?? (() => undefined)}
onDiscard={discardAll ?? (() => undefined)}
/>
}
/>
@@ -225,16 +242,17 @@ export const FindingsFilters = ({
return (
<BatchFiltersLayout
testIdPrefix="findings"
controlsClassName="gap-3"
controls={
<>
<div className="min-w-[200px] flex-1 md:max-w-[280px]">
<div className={FILTER_CONTROL_COLUMN_CLASS}>
<ProviderTypeSelector
providers={providers}
onBatchChange={setPending}
selectedValues={getFilterValue("filter[provider_type__in]")}
/>
</div>
<div className="min-w-[200px] flex-1 md:max-w-[280px]">
<div className={FILTER_CONTROL_COLUMN_CLASS}>
<AccountsSelector
providers={providers}
onBatchChange={setPending}
@@ -256,14 +274,50 @@ export const FindingsFilters = ({
/>
</Button>
)}
{trailingControls}
</>
}
expandedFilters={expandedFilters}
expandedFiltersVisible={isExpanded}
appliedSummary={appliedSummary}
pendingSummary={pendingSummary}
showAppliedRow={showAppliedRow}
showPendingRow={showPendingRow}
appliedSummary={showSummaries ? appliedSummary : null}
pendingSummary={showSummaries ? pendingSummary : null}
showAppliedRow={showSummaries && showAppliedRow}
showPendingRow={showSummaries && showPendingRow}
/>
);
};
export const FindingsFilters = (props: FindingsFiltersProps) => {
const {
appliedFilters,
pendingFilters,
changedFilters,
setPending,
applyAll,
discardAll,
clearAndApply,
removeAppliedAndApply,
hasChanges,
changeCount,
getFilterValue,
} = useFilterBatch({
defaultParams: { "filter[muted]": "false" },
});
return (
<FindingsFilterBatchControls
{...props}
appliedFilters={appliedFilters}
pendingFilters={pendingFilters}
changedFilters={changedFilters}
setPending={setPending}
applyAll={applyAll}
discardAll={discardAll}
clearAndApply={clearAndApply}
removeAppliedAndApply={removeAppliedAndApply}
hasChanges={hasChanges}
changeCount={changeCount}
getFilterValue={getFilterValue}
/>
);
};
@@ -121,6 +121,7 @@ export const FILTER_KEY_LABELS: Record<FilterParam, string> = {
interface BuildFindingsFilterChipsOptions {
providers?: ProviderProps[];
scans?: Array<{ [scanId: string]: ScanEntity }>;
includeMuted?: boolean;
}
/**
@@ -140,7 +141,7 @@ export function buildFindingsFilterChips(
Object.entries(pendingFilters).forEach(([key, values]) => {
if (!values || values.length === 0) return;
if (key === "filter[muted]") return;
if (key === "filter[muted]" && !options.includeMuted) return;
const label = FILTER_KEY_LABELS[key as FilterParam] ?? key;
const visibleValues = values;
@@ -36,6 +36,9 @@ interface ResourcesFiltersProps {
const countVisibleFilterKeys = (filters: Record<string, string[]>): number =>
Object.values(filters).filter((values) => values.length > 0).length;
const FILTER_CONTROL_COLUMN_CLASS =
"min-w-0 flex-none basis-full sm:basis-[calc((100%_-_0.75rem)/2)] lg:basis-[calc((100%_-_1.5rem)/3)] xl:basis-[calc((100%_-_2.25rem)/4)] 2xl:basis-[calc((100%_-_3rem)/5)]";
export const ResourcesFilters = ({
providers,
uniqueRegions,
@@ -164,16 +167,17 @@ export const ResourcesFilters = ({
return (
<BatchFiltersLayout
testIdPrefix="resources"
controlsClassName="gap-3"
controls={
<>
<div className="min-w-[200px] flex-1 md:max-w-[280px]">
<div className={FILTER_CONTROL_COLUMN_CLASS}>
<ProviderTypeSelector
providers={providers}
onBatchChange={setPending}
selectedValues={getFilterValue("filter[provider_type__in]")}
/>
</div>
<div className="min-w-[200px] flex-1 md:max-w-[280px]">
<div className={FILTER_CONTROL_COLUMN_CLASS}>
<AccountsSelector
providers={providers}
onBatchChange={setPending}
@@ -402,10 +402,14 @@ export const ResourceDetailContent = ({
}}
controlledPage={currentPage}
controlledPageSize={pageSize}
onPaginationChange={(nextPage, nextPageSize) => {
onPageChange={(page) => {
setRowSelection({});
setCurrentPage(nextPage);
setPageSize(nextPageSize);
setCurrentPage(page);
}}
onPageSizeChange={(size) => {
setRowSelection({});
setCurrentPage(1);
setPageSize(size);
}}
isLoading={findingsLoading}
/>
+6 -6
View File
@@ -1,6 +1,6 @@
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { ComponentProps } from "react";
import type { ComponentProps } from "react";
import { cn } from "@/lib/utils";
@@ -10,17 +10,17 @@ const buttonVariants = cva(
variants: {
variant: {
default:
"bg-button-primary text-black hover:bg-button-primary-hover active:bg-button-primary-press focus-visible:ring-button-primary/50",
"border border-transparent bg-button-primary text-black hover:bg-button-primary-hover active:bg-button-primary-press focus-visible:ring-button-primary/50",
secondary:
"bg-button-secondary text-white hover:bg-button-secondary/90 active:bg-button-secondary-press focus-visible:ring-button-secondary/50 dark:text-black",
"border border-transparent bg-button-secondary text-white hover:bg-button-secondary/90 active:bg-button-secondary-press focus-visible:ring-button-secondary/50 dark:text-black",
tertiary:
"bg-button-tertiary text-white hover:bg-button-tertiary-hover active:bg-button-tertiary-active focus-visible:ring-button-tertiary/50",
"border border-transparent bg-button-tertiary text-white hover:bg-button-tertiary-hover active:bg-button-tertiary-active focus-visible:ring-button-tertiary/50",
destructive:
"bg-bg-fail text-white hover:bg-bg-fail/90 active:bg-bg-fail/80 focus-visible:ring-bg-fail/50",
"border border-transparent bg-bg-fail text-white hover:bg-bg-fail/90 active:bg-bg-fail/80 focus-visible:ring-bg-fail/50",
outline:
"border border-border-neutral-secondary bg-bg-neutral-secondary hover:bg-bg-neutral-tertiary active:bg-border-neutral-tertiary text-text-neutral-primary focus-visible:ring-border-neutral-tertiary/50",
ghost:
"text-text-neutral-primary hover:bg-bg-neutral-tertiary active:bg-border-neutral-secondary focus-visible:ring-border-neutral-secondary/50",
"border border-transparent text-text-neutral-primary hover:bg-bg-neutral-tertiary active:bg-border-neutral-secondary focus-visible:ring-border-neutral-secondary/50",
link: "text-button-tertiary underline-offset-4 hover:text-button-tertiary-hover disabled:bg-transparent",
// Menu variant like secondary but more padding and the back is almost transparent
menu: "backdrop-blur-xl bg-white/60 dark:bg-white/5 border border-white/80 dark:border-white/10 text-text-neutral-primary dark:text-white shadow-lg hover:bg-white/70 dark:hover:bg-white/10 hover:border-white/90 dark:hover:border-white/30 active:bg-white/80 dark:active:bg-white/15 active:scale-[0.98] focus-visible:ring-button-primary/50 transition-all duration-200",
+36
View File
@@ -0,0 +1,36 @@
import { cn } from "@/lib/utils";
function Field({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="field"
className={cn("flex flex-col gap-1.5", className)}
{...props}
/>
);
}
function FieldLabel({ className, ...props }: React.ComponentProps<"label">) {
return (
<label
data-slot="field-label"
className={cn(
"text-text-neutral-tertiary text-xs font-light tracking-tight",
className,
)}
{...props}
/>
);
}
function FieldError({ className, ...props }: React.ComponentProps<"p">) {
return (
<p
data-slot="field-error"
className={cn("text-text-error-primary max-w-full text-xs", className)}
{...props}
/>
);
}
export { Field, FieldError, FieldLabel };
+2
View File
@@ -9,10 +9,12 @@ export * from "./checkbox/checkbox";
export * from "./combobox";
export * from "./drawer";
export * from "./dropdown/dropdown";
export * from "./field/field";
export * from "./info-field";
export * from "./input/input";
export * from "./progress";
export * from "./search-input/search-input";
export * from "./section/section";
export * from "./select/multiselect";
export * from "./select/select";
export * from "./separator/separator";
+1 -1
View File
@@ -11,7 +11,7 @@ const inputVariants = cva(
variants: {
variant: {
default:
"border-border-input-primary bg-bg-input-primary dark:bg-input/30 hover:bg-bg-neutral-secondary dark:hover:bg-input/50 focus:border-border-input-primary-press focus:ring-1 focus:ring-inset focus:ring-border-input-primary-press placeholder:text-text-neutral-tertiary",
"border-border-input-primary bg-bg-input-primary dark:bg-input/30 hover:bg-bg-neutral-tertiary dark:hover:bg-input/50 focus:border-border-input-primary-press focus:ring-1 focus:ring-inset focus:ring-border-input-primary-press placeholder:text-text-neutral-tertiary",
ghost:
"border-transparent bg-transparent hover:bg-bg-neutral-tertiary focus:bg-bg-neutral-tertiary placeholder:text-text-neutral-tertiary",
},
+65
View File
@@ -0,0 +1,65 @@
import { cn } from "@/lib/utils";
function Section({ className, ...props }: React.ComponentProps<"section">) {
return (
<section
data-slot="section"
className={cn("flex flex-col gap-3", className)}
{...props}
/>
);
}
function SectionHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="section-header"
className={cn("flex flex-col gap-1", className)}
{...props}
/>
);
}
function SectionTitle({ className, ...props }: React.ComponentProps<"h3">) {
return (
<h3
data-slot="section-title"
className={cn(
"text-md text-default-foreground leading-9 font-bold",
className,
)}
{...props}
/>
);
}
function SectionDescription({
className,
...props
}: React.ComponentProps<"p">) {
return (
<p
data-slot="section-description"
className={cn("text-default-500 text-sm", className)}
{...props}
/>
);
}
function SectionContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="section-content"
className={cn("flex flex-col gap-3", className)}
{...props}
/>
);
}
export {
Section,
SectionContent,
SectionDescription,
SectionHeader,
SectionTitle,
};
@@ -6,6 +6,7 @@ import {
MultiSelect,
MultiSelectContent,
MultiSelectItem,
MultiSelectSelectAll,
MultiSelectTrigger,
MultiSelectValue,
} from "./multiselect";
@@ -180,6 +181,39 @@ describe("MultiSelect", () => {
expect(screen.getByPlaceholderText("Search accounts...")).toHaveValue("");
});
it("closes the dropdown when clicking outside", async () => {
// Given
const user = userEvent.setup();
render(
<div>
<MultiSelect values={[]} onValuesChange={() => {}}>
<MultiSelectTrigger>
<MultiSelectValue placeholder="Select accounts" />
</MultiSelectTrigger>
<MultiSelectContent
search={{
placeholder: "Search accounts...",
emptyMessage: "No accounts found.",
}}
>
<MultiSelectItem value="aws-prod">Production AWS</MultiSelectItem>
</MultiSelectContent>
</MultiSelect>
<button type="button">Outside target</button>
</div>,
);
// When
await user.click(screen.getByRole("combobox"));
expect(screen.getByPlaceholderText("Search accounts...")).toBeVisible();
await user.click(screen.getByRole("button", { name: /outside target/i }));
// Then
expect(
screen.queryByPlaceholderText("Search accounts..."),
).not.toBeInTheDocument();
});
it("uses a normalized dropdown width instead of growing with the longest item", async () => {
const user = userEvent.setup();
@@ -204,4 +238,79 @@ describe("MultiSelect", () => {
);
expect(screen.getByRole("dialog")).toHaveClass("max-w-[24rem]");
});
it("keeps the legacy clear-all behavior by default", async () => {
const user = userEvent.setup();
const onValuesChange = vi.fn();
render(
<MultiSelect values={["aws-prod"]} onValuesChange={onValuesChange}>
<MultiSelectTrigger>
<MultiSelectValue placeholder="Select accounts" />
</MultiSelectTrigger>
<MultiSelectContent search={false}>
<MultiSelectSelectAll>Select All</MultiSelectSelectAll>
<MultiSelectItem value="aws-prod">Production AWS</MultiSelectItem>
<MultiSelectItem value="azure-dev">Development Azure</MultiSelectItem>
</MultiSelectContent>
</MultiSelect>,
);
await user.click(screen.getByRole("combobox"));
await user.click(screen.getByRole("button", { name: /select all/i }));
expect(onValuesChange).toHaveBeenCalledWith([]);
});
it("disables the legacy select all action when no filter is selected", async () => {
const user = userEvent.setup();
const onValuesChange = vi.fn();
render(
<MultiSelect values={[]} onValuesChange={onValuesChange}>
<MultiSelectTrigger>
<MultiSelectValue placeholder="Select accounts" />
</MultiSelectTrigger>
<MultiSelectContent search={false}>
<MultiSelectSelectAll>Select All</MultiSelectSelectAll>
<MultiSelectItem value="aws-prod">Production AWS</MultiSelectItem>
<MultiSelectItem value="azure-dev">Development Azure</MultiSelectItem>
</MultiSelectContent>
</MultiSelect>,
);
await user.click(screen.getByRole("combobox"));
expect(
screen.getByRole("button", { name: /all selected/i }),
).toBeDisabled();
});
it("selects every provided option when select mode is enabled", async () => {
const user = userEvent.setup();
const onValuesChange = vi.fn();
render(
<MultiSelect values={[]} onValuesChange={onValuesChange}>
<MultiSelectTrigger>
<MultiSelectValue placeholder="Select accounts" />
</MultiSelectTrigger>
<MultiSelectContent search={false}>
<MultiSelectSelectAll
mode="select"
values={["aws-prod", "azure-dev"]}
>
Select All
</MultiSelectSelectAll>
<MultiSelectItem value="aws-prod">Production AWS</MultiSelectItem>
<MultiSelectItem value="azure-dev">Development Azure</MultiSelectItem>
</MultiSelectContent>
</MultiSelect>,
);
await user.click(screen.getByRole("combobox"));
await user.click(screen.getByRole("button", { name: /select all/i }));
expect(onValuesChange).toHaveBeenCalledWith(["aws-prod", "azure-dev"]);
});
});
+62 -9
View File
@@ -42,6 +42,7 @@ type MultiSelectContextType = {
setOpen: (open: boolean) => void;
selectedValues: Set<string>;
toggleValue: (value: string) => void;
setValues: (values: string[]) => void;
items: Map<string, ReactNode>;
onItemAdded: (value: string, label: ReactNode) => void;
onValuesChange?: (values: string[]) => void;
@@ -53,19 +54,31 @@ export function MultiSelect({
values,
defaultValues,
onValuesChange,
open: controlledOpen,
onOpenChange,
}: {
children: ReactNode;
values?: string[];
defaultValues?: string[];
onValuesChange?: (values: string[]) => void;
open?: boolean;
onOpenChange?: (open: boolean) => void;
}) {
const [open, setOpen] = useState(false);
const [internalOpen, setInternalOpen] = useState(false);
const [internalValues, setInternalValues] = useState(
new Set<string>(values ?? defaultValues),
);
const open = controlledOpen ?? internalOpen;
const selectedValues = values ? new Set(values) : internalValues;
const [items, setItems] = useState<Map<string, ReactNode>>(new Map());
function setOpen(nextOpen: boolean) {
if (controlledOpen === undefined) {
setInternalOpen(nextOpen);
}
onOpenChange?.(nextOpen);
}
function toggleValue(value: string) {
const getNewSet = (prev: Set<string>) => {
const newSet = new Set(prev);
@@ -80,6 +93,12 @@ export function MultiSelect({
onValuesChange?.(Array.from(getNewSet(selectedValues)));
}
function setValues(nextValues: string[]) {
const nextSet = new Set(nextValues);
setInternalValues(nextSet);
onValuesChange?.(Array.from(nextSet));
}
const onItemAdded = useCallback((value: string, label: ReactNode) => {
setItems((prev) => {
if (prev.get(value) === label) return prev;
@@ -94,12 +113,13 @@ export function MultiSelect({
setOpen,
selectedValues,
toggleValue,
setValues,
items,
onItemAdded,
onValuesChange,
}}
>
<Popover open={open} onOpenChange={setOpen} modal={true}>
<Popover open={open} onOpenChange={setOpen} modal={false}>
{children}
</Popover>
</MultiSelectContext>
@@ -426,11 +446,16 @@ export function MultiSelectSeparator({
export function MultiSelectSelectAll({
className,
children = "Select All",
mode = "clear",
values,
...props
}: Omit<ComponentPropsWithoutRef<"button">, "children"> & {
children?: ReactNode;
mode?: "clear" | "select";
values?: string[];
}) {
const { selectedValues, onValuesChange } = useMultiSelectContext();
const { items, selectedValues, setValues, onValuesChange } =
useMultiSelectContext();
if (!onValuesChange) {
return null;
@@ -438,12 +463,39 @@ export function MultiSelectSelectAll({
const hasSelections = selectedValues.size > 0;
if (!hasSelections) {
return null;
if (mode === "clear") {
const handleClearAll = () => {
onValuesChange?.([]);
};
const label = hasSelections ? children : "All selected";
return (
<button
type="button"
data-slot="multiselect-select-all"
className={cn(
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-bg-button-secondary text-bg-button-secondary flex w-full cursor-pointer items-center justify-between gap-3 rounded-lg px-4 py-3 text-sm outline-hidden select-none hover:bg-slate-200 dark:hover:bg-slate-700/50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-5",
hasSelections && "text-destructive hover:text-destructive",
!hasSelections && "cursor-not-allowed opacity-50",
"font-semibold",
className,
)}
disabled={!hasSelections}
onClick={handleClearAll}
{...props}
>
<span className="flex min-w-0 flex-1 items-center gap-2">{label}</span>
</button>
);
}
const handleClearAll = () => {
onValuesChange?.([]);
const itemValues = values ?? Array.from(items.keys());
const hasItems = itemValues.length > 0;
const allSelected =
hasItems && itemValues.every((value) => selectedValues.has(value));
const handleSelectAll = () => {
setValues(itemValues);
};
return (
@@ -452,11 +504,12 @@ export function MultiSelectSelectAll({
data-slot="multiselect-select-all"
className={cn(
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-bg-button-secondary text-bg-button-secondary flex w-full cursor-pointer items-center justify-between gap-3 rounded-lg px-4 py-3 text-sm outline-hidden select-none hover:bg-slate-200 dark:hover:bg-slate-700/50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-5",
hasSelections && "text-destructive hover:text-destructive",
allSelected && "cursor-not-allowed opacity-50",
"font-semibold",
className,
)}
onClick={handleClearAll}
disabled={!hasItems || allSelected}
onClick={handleSelectAll}
{...props}
>
<span className="flex min-w-0 flex-1 items-center gap-2">{children}</span>
+12 -3
View File
@@ -58,7 +58,7 @@ function SelectTrigger({
data-slot="select-trigger"
data-size={size}
className={cn(
"border-border-input-primary bg-bg-input-primary text-bg-button-secondary data-[placeholder]:text-bg-button-secondary [&_svg:not([class*='text-'])]:text-bg-button-secondary aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 focus-visible:border-border-input-primary-press focus-visible:ring-border-input-primary-press flex w-full items-center justify-between gap-2 rounded-lg border px-4 py-3 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-1 focus-visible:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-[52px] data-[size=sm]:h-10 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 dark:focus-visible:ring-slate-400 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-6",
"group border-border-input-primary bg-bg-input-primary text-bg-button-secondary data-[placeholder]:text-bg-button-secondary [&_svg:not([class*='text-'])]:text-bg-button-secondary aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive hover:bg-bg-neutral-tertiary active:bg-border-neutral-tertiary dark:bg-input/30 dark:hover:bg-input/50 focus-visible:border-border-input-primary-press focus-visible:ring-border-input-primary-press flex w-full items-center justify-between gap-2 overflow-hidden rounded-lg border px-4 py-3 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-1 focus-visible:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-50 has-[>svg]:px-3 data-[size=default]:h-[52px] data-[size=sm]:h-10 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 dark:focus-visible:ring-slate-400 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-6",
className,
)}
{...props}
@@ -67,7 +67,7 @@ function SelectTrigger({
<SelectPrimitive.Icon asChild>
<ChevronDownIcon
className={cn(
"text-bg-button-secondary",
"text-bg-button-secondary shrink-0 opacity-70 transition-transform duration-200 group-data-[state=open]:rotate-180",
iconSize === "sm" ? "size-4" : "size-6",
)}
aria-hidden="true"
@@ -82,8 +82,16 @@ function SelectContent({
children,
position = "popper",
align = "start",
width = "default",
...props
}: ComponentProps<typeof SelectPrimitive.Content>) {
}: ComponentProps<typeof SelectPrimitive.Content> & {
width?: "default" | "wide";
}) {
const widthClasses =
width === "wide"
? "w-[min(max(var(--radix-select-trigger-width),24rem),calc(100vw-2rem))] max-w-[32rem]"
: undefined;
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
@@ -92,6 +100,7 @@ function SelectContent({
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 border-border-input-primary bg-bg-input-primary relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-lg border",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
widthClasses,
className,
)}
position={position}
+1 -1
View File
@@ -11,7 +11,7 @@ const textareaVariants = cva(
variants: {
variant: {
default:
"border-border-input-primary bg-bg-input-primary dark:bg-input/30 hover:bg-bg-neutral-secondary dark:hover:bg-input/50 focus:border-border-input-primary-press focus:ring-1 focus:ring-inset focus:ring-border-input-primary-press placeholder:text-text-neutral-tertiary",
"border-border-input-primary bg-bg-input-primary dark:bg-input/30 hover:bg-bg-neutral-tertiary dark:hover:bg-input/50 focus:border-border-input-primary-press focus:ring-1 focus:ring-inset focus:ring-border-input-primary-press placeholder:text-text-neutral-tertiary",
ghost:
"border-transparent bg-transparent hover:bg-bg-neutral-tertiary focus:bg-bg-neutral-tertiary placeholder:text-text-neutral-tertiary",
},
@@ -43,6 +43,7 @@ export function BreadcrumbNavigation({
const generateAutoBreadcrumbs = (): CustomBreadcrumbItem[] => {
const pathIconMapping: Record<string, string | ReactNode> = {
"/integrations": "lucide:puzzle",
"/alerts": "lucide:bell-ring",
"/providers": "lucide:cloud",
"/users": "lucide:users",
"/compliance": "lucide:shield-check",
+10 -1
View File
@@ -19,6 +19,7 @@ interface SubmenuItemProps {
active?: boolean;
target?: string;
disabled?: boolean;
highlight?: boolean;
onClick?: (event: MouseEvent<HTMLAnchorElement>) => void;
}
@@ -29,6 +30,7 @@ export const SubmenuItem = ({
active,
target,
disabled,
highlight,
onClick,
}: SubmenuItemProps) => {
const pathname = usePathname();
@@ -72,7 +74,14 @@ export const SubmenuItem = ({
<span className="mr-2">
<Icon size={16} />
</span>
<p className="max-w-[170px] truncate">{label}</p>
<p className="flex max-w-[170px] items-center truncate">
<span>{label}</span>
{highlight && (
<span className="ml-2 rounded-sm bg-emerald-500 px-1.5 py-0.5 text-[10px] font-semibold text-white">
NEW
</span>
)}
</p>
</Link>
</Button>
);
@@ -1,6 +1,7 @@
"use client";
import { useSearchParams } from "next/navigation";
import { useState } from "react";
import { ComplianceScanInfo } from "@/components/compliance/compliance-header/compliance-scan-info";
import { ClearFiltersButton } from "@/components/filters/clear-filters-button";
@@ -73,6 +74,7 @@ export const DataTableFilterCustom = ({
}: DataTableFilterCustomProps) => {
const { updateFilter } = useUrlFilters();
const searchParams = useSearchParams();
const [openFilterKey, setOpenFilterKey] = useState<string | null>(null);
const buildSearchConfig = (filter: FilterOption) => {
const label = filter.labelCheckboxGroup.toLowerCase();
@@ -235,6 +237,8 @@ export const DataTableFilterCustom = ({
return (
<MultiSelect
key={filter.key}
open={openFilterKey === filter.key}
onOpenChange={(open) => setOpenFilterKey(open ? filter.key : null)}
values={selectedValues}
onValuesChange={(values) => pushDropdownFilter(filter, values)}
>
@@ -28,13 +28,14 @@ interface DataTablePaginationProps {
paramPrefix?: string;
/*
* Controlled mode: receive all three props together (parent contract is
* enforced at the DataTable boundary). Useful for tables in drawers/modals
* to avoid triggering page re-renders when paginating.
* Controlled mode: Use these props to manage pagination via React state
* instead of URL params. Useful for tables in drawers/modals to avoid
* triggering page re-renders when paginating.
*/
controlledPage?: number;
controlledPageSize?: number;
onPaginationChange?: (page: number, pageSize: number) => void;
onPageChange?: (page: number) => void;
onPageSizeChange?: (pageSize: number) => void;
}
const NAV_BUTTON_STYLES = {
@@ -50,26 +51,22 @@ export function DataTablePagination({
paramPrefix = "",
controlledPage,
controlledPageSize,
onPaginationChange,
onPageChange,
onPageSizeChange,
}: DataTablePaginationProps) {
const pathname = usePathname();
const searchParams = useSearchParams();
const router = useRouter();
// Determine if we're in controlled mode. The discriminated union on
// `DataTable`'s ControlledPaginationProps guarantees `controlledPageSize`
// is defined here whenever the other two are.
const isControlled =
controlledPage !== undefined &&
controlledPageSize !== undefined &&
onPaginationChange !== undefined;
// Determine if we're in controlled mode
const isControlled = controlledPage !== undefined && onPageChange;
// Determine param names based on prefix
const pageParam = paramPrefix ? `${paramPrefix}Page` : "page";
const pageSizeParam = paramPrefix ? `${paramPrefix}PageSize` : "pageSize";
const initialPageSize = isControlled
? String(controlledPageSize)
? String(controlledPageSize ?? 10)
: (searchParams.get(pageSizeParam) ?? "10");
const [selectedPageSize, setSelectedPageSize] = useState(initialPageSize);
@@ -115,7 +112,7 @@ export function DataTablePagination({
// Handle page navigation for controlled mode
const handlePageChange = (pageNumber: number) => {
if (isControlled) {
onPaginationChange(pageNumber, controlledPageSize);
onPageChange(pageNumber);
} else {
const url = createPageUrl(pageNumber);
if (disableScroll) {
@@ -144,7 +141,8 @@ export function DataTablePagination({
setSelectedPageSize(value);
if (isControlled) {
onPaginationChange(1, parseInt(value, 10));
onPageSizeChange?.(parseInt(value, 10));
onPageChange(1); // Reset to first page
return;
}
+32 -22
View File
@@ -41,25 +41,7 @@ import { FilterOption, MetaDataProps } from "@/types";
*/
const DEFAULT_COLUMN_SIZE = 150;
/*
* Controlled pagination: pass all three props together or none. Modeled as a
* discriminated union so TypeScript prevents passing `onPaginationChange`
* without `controlledPageSize`, which would otherwise silently emit a default
* page size on every navigation.
*/
type ControlledPaginationProps =
| {
controlledPage: number;
controlledPageSize: number;
onPaginationChange: (page: number, pageSize: number) => void;
}
| {
controlledPage?: undefined;
controlledPageSize?: undefined;
onPaginationChange?: undefined;
};
type DataTableProviderProps<TData, TValue> = {
interface DataTableProviderProps<TData, TValue> {
columns: ColumnDef<TData, TValue>[];
data: TData[];
metadata?: MetaDataProps;
@@ -85,6 +67,28 @@ type DataTableProviderProps<TData, TValue> = {
/** Prefix for URL params to avoid conflicts (e.g., "findings" -> "findingsPage") */
paramPrefix?: string;
/*
* Controlled Mode Props
* ---------------------
* By default, DataTable uses URL params for pagination/search (via paramPrefix).
* This causes Next.js page re-renders on every interaction.
*
* For tables inside drawers/modals, use controlled mode instead:
* - Pass controlledPage, controlledPageSize, controlledSearch as state values
* - Pass onPageChange, onPageSizeChange, onSearchChange as state setters
* - This keeps state local, avoiding URL changes and unnecessary page re-renders
*
* Example:
* const [page, setPage] = useState(1);
* const [search, setSearch] = useState("");
* <DataTable
* controlledPage={page}
* onPageChange={setPage}
* controlledSearch={search}
* onSearchChange={setSearch}
* isLoading={isLoading}
* />
*/
controlledSearch?: string;
onSearchChange?: (value: string) => void;
/**
@@ -92,6 +96,10 @@ type DataTableProviderProps<TData, TValue> = {
* Use this alongside onSearchChange to implement "search on Enter" behavior.
*/
onSearchCommit?: (value: string) => void;
controlledPage?: number;
controlledPageSize?: number;
onPageChange?: (page: number) => void;
onPageSizeChange?: (pageSize: number) => void;
/** Show loading state with opacity overlay (for controlled mode) */
isLoading?: boolean;
/** Custom placeholder text for the search input */
@@ -106,7 +114,7 @@ type DataTableProviderProps<TData, TValue> = {
header?: ReactNode;
/** Optional content rendered in the toolbar before the total entries count. */
toolbarRightContent?: ReactNode;
} & ControlledPaginationProps;
}
export function DataTable<TData, TValue>({
columns,
@@ -129,7 +137,8 @@ export function DataTable<TData, TValue>({
onSearchCommit,
controlledPage,
controlledPageSize,
onPaginationChange,
onPageChange,
onPageSizeChange,
isLoading = false,
searchPlaceholder,
renderAfterRow,
@@ -344,7 +353,8 @@ export function DataTable<TData, TValue>({
paramPrefix={paramPrefix}
controlledPage={controlledPage}
controlledPageSize={controlledPageSize}
onPaginationChange={onPaginationChange}
onPageChange={onPageChange}
onPageSizeChange={onPageSizeChange}
/>
)}
</div>
+55
View File
@@ -0,0 +1,55 @@
import { afterEach, describe, expect, it } from "vitest";
import { getMenuList } from "./menu-list";
const findMenu = (label: string) =>
getMenuList({ pathname: "/alerts" })
.flatMap((group) => group.menus)
.find((menu) => menu.label === label);
const findSubmenu = (label: string) =>
getMenuList({ pathname: "/alerts" })
.flatMap((group) => group.menus)
.flatMap((menu) => menu.submenus ?? [])
.find((submenu) => submenu.label === label);
describe("getMenuList", () => {
afterEach(() => {
delete process.env.NEXT_PUBLIC_IS_CLOUD_ENV;
});
it("should hide Alerts in OSS when Cloud is disabled", () => {
// Given / When
const alerts = findSubmenu("Alerts");
// Then
expect(alerts).toBeUndefined();
});
it("should show Alerts as new under Configuration when Cloud is enabled", () => {
// Given
process.env.NEXT_PUBLIC_IS_CLOUD_ENV = "true";
// When
const alerts = findSubmenu("Alerts");
// Then
expect(alerts).toEqual(
expect.objectContaining({
href: "/alerts",
active: true,
highlight: true,
}),
);
});
it("should remove the new highlight from Attack Paths", () => {
// Given / When
const attackPaths = findMenu("Attack Paths");
// Then
expect(attackPaths).toEqual(
expect.not.objectContaining({ highlight: true }),
);
});
});
+12 -1
View File
@@ -1,4 +1,5 @@
import {
BellRing,
CloudCog,
Cog,
GitBranch,
@@ -74,7 +75,6 @@ export const getMenuList = ({ pathname }: MenuListOptions): GroupProps[] => {
label: "Attack Paths",
icon: GitBranch,
active: pathname.startsWith("/attack-paths"),
highlight: true,
},
],
},
@@ -108,6 +108,17 @@ export const getMenuList = ({ pathname }: MenuListOptions): GroupProps[] => {
icon: Settings,
submenus: [
{ href: "/providers", label: "Providers", icon: CloudCog },
...(process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true"
? [
{
href: "/alerts",
label: "Alerts",
icon: BellRing,
active: pathname.startsWith("/alerts"),
highlight: true,
},
]
: []),
{
href: "/mutelist",
label: "Mutelist",
+1
View File
@@ -21,6 +21,7 @@ export type SubmenuProps = {
active?: boolean;
icon: IconComponent;
disabled?: boolean;
highlight?: boolean;
onClick?: (event: MouseEvent<HTMLAnchorElement>) => void;
};