mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-05-06 08:47:18 +00:00
Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 08c77bea75 | |||
| f769b8b812 | |||
| 8213b46bd8 | |||
| 515fe1918d | |||
| 25c11eb6dd | |||
| 089f7e7d3c | |||
| a678a04850 | |||
| 8707b51b34 | |||
| 833882e67e |
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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>;
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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[]>;
|
||||
@@ -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";
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
+16
-3
@@ -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);
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 };
|
||||
@@ -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";
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -21,6 +21,7 @@ export type SubmenuProps = {
|
||||
active?: boolean;
|
||||
icon: IconComponent;
|
||||
disabled?: boolean;
|
||||
highlight?: boolean;
|
||||
onClick?: (event: MouseEvent<HTMLAnchorElement>) => void;
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user