feat(ui): add cloud-gated custom alerts (#11003)

This commit is contained in:
Alejandro Bailo
2026-05-08 10:36:43 +02:00
committed by GitHub
parent a59192e6f5
commit 4216a3e23a
85 changed files with 6217 additions and 284 deletions
+1
View File
@@ -172,6 +172,7 @@ export const getUserByMe = async (accessToken: string) => {
manage_scans: userRole.attributes.manage_scans || false,
manage_integrations: userRole.attributes.manage_integrations || false,
manage_billing: userRole.attributes.manage_billing || false,
manage_alerts: userRole.attributes.manage_alerts || false,
unlimited_visibility: userRole.attributes.unlimited_visibility || false,
};
+109
View File
@@ -0,0 +1,109 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const {
fetchMock,
getAuthHeadersMock,
handleApiErrorMock,
handleApiResponseMock,
} = vi.hoisted(() => ({
fetchMock: vi.fn(),
getAuthHeadersMock: vi.fn(),
handleApiErrorMock: vi.fn(),
handleApiResponseMock: vi.fn(),
}));
vi.mock("next/cache", () => ({
revalidatePath: vi.fn(),
}));
vi.mock("next/navigation", () => ({
redirect: vi.fn(),
}));
vi.mock("@/lib", () => ({
apiBaseUrl: "https://api.example.com/api/v1",
getAuthHeaders: getAuthHeadersMock,
}));
vi.mock("@/lib/server-actions-helper", () => ({
handleApiError: handleApiErrorMock,
handleApiResponse: handleApiResponseMock,
}));
import { addRole, updateRole } from "./roles";
const lastRequestBody = () => {
const call = fetchMock.mock.calls.at(-1);
if (!call) throw new Error("fetch was not called");
const [, init] = call;
return JSON.parse(String((init as RequestInit).body));
};
const makeRoleFormData = () => {
const formData = new FormData();
formData.set("name", "Alert manager");
formData.set("manage_users", "false");
formData.set("manage_account", "false");
formData.set("manage_billing", "false");
formData.set("manage_providers", "false");
formData.set("manage_integrations", "false");
formData.set("manage_scans", "false");
formData.set("manage_alerts", "true");
formData.set("unlimited_visibility", "false");
return formData;
};
describe("role actions", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.stubGlobal("fetch", fetchMock);
getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" });
handleApiResponseMock.mockResolvedValue({ data: { id: "role-1" } });
handleApiErrorMock.mockReturnValue({ error: "Unexpected error" });
fetchMock.mockResolvedValue(
new Response(JSON.stringify({ data: { id: "role-1" } }), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
);
});
afterEach(() => {
vi.unstubAllEnvs();
});
it("includes manage_alerts when creating a role in Prowler Cloud", async () => {
// Given
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true");
// When
await addRole(makeRoleFormData());
// Then
expect(lastRequestBody().data.attributes.manage_alerts).toBe(true);
});
it("omits manage_alerts when creating a role outside Prowler Cloud", async () => {
// Given
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false");
// When
await addRole(makeRoleFormData());
// Then
expect(lastRequestBody().data.attributes).not.toHaveProperty(
"manage_alerts",
);
});
it("includes manage_alerts when updating a role in Prowler Cloud", async () => {
// Given
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true");
// When
await updateRole(makeRoleFormData(), "role-1");
// Then
expect(lastRequestBody().data.attributes.manage_alerts).toBe(true);
});
});
+6 -2
View File
@@ -107,10 +107,12 @@ export const addRole = async (formData: FormData) => {
},
};
// Conditionally include manage_billing for cloud environment
// Conditionally include Prowler Cloud permissions.
if (process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true") {
payload.data.attributes.manage_billing =
formData.get("manage_billing") === "true";
payload.data.attributes.manage_alerts =
formData.get("manage_alerts") === "true";
}
// Add provider groups relationships only if there are items
@@ -162,10 +164,12 @@ export const updateRole = async (formData: FormData, roleId: string) => {
},
};
// Conditionally include manage_billing for cloud environments
// Conditionally include Prowler Cloud permissions.
if (process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true") {
payload.data.attributes.manage_billing =
formData.get("manage_billing") === "true";
payload.data.attributes.manage_alerts =
formData.get("manage_alerts") === "true";
}
// Add provider groups relationships only if there are items
@@ -0,0 +1,138 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { confirmAlertRecipient } from "./confirm-alert-recipient";
const fetchMock = vi.fn();
const lastFetchCall = (): { url: string; init: RequestInit } => {
const call = fetchMock.mock.calls.at(-1);
if (!call) throw new Error("fetch was not called");
const [url, init] = call;
return { url: String(url), init: (init ?? {}) as RequestInit };
};
describe("confirmAlertRecipient", () => {
beforeEach(() => {
vi.stubGlobal("fetch", fetchMock);
vi.stubEnv("NEXT_PUBLIC_API_BASE_URL", "https://api.example.com/api/v1");
fetchMock.mockResolvedValue(
new Response(
JSON.stringify({
state: "confirmed",
message:
"Your subscription has been confirmed. You will receive alert digests at this address.",
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
),
);
});
afterEach(() => {
vi.unstubAllEnvs();
vi.unstubAllGlobals();
vi.clearAllMocks();
});
it("calls the public confirmation endpoint without auth headers", async () => {
// When
const result = await confirmAlertRecipient("token-1");
// Then
expect(result).toEqual({
ok: true,
state: "confirmed",
message:
"Your subscription has been confirmed. You will receive alert digests at this address.",
});
const { url, init } = lastFetchCall();
expect(url).toBe(
"https://api.example.com/api/v1/alerts/recipients/confirm?token=token-1",
);
expect(init).toEqual({
headers: { Accept: "application/json" },
cache: "no-store",
});
});
it("returns the API message for invalid tokens", async () => {
// Given
fetchMock.mockResolvedValueOnce(
new Response(
JSON.stringify({
state: "invalid_token",
message: "This link is invalid or has expired.",
}),
{ status: 400, headers: { "Content-Type": "application/json" } },
),
);
// When
const result = await confirmAlertRecipient("expired-token");
// Then
expect(result).toEqual({
ok: false,
state: "invalid_token",
message: "This link is invalid or has expired.",
});
});
it("returns the API message for missing tokens", async () => {
// Given
fetchMock.mockResolvedValueOnce(
new Response(
JSON.stringify({
state: "missing_token",
message: "This link is missing a token.",
}),
{ status: 400, headers: { "Content-Type": "application/json" } },
),
);
// When
const result = await confirmAlertRecipient();
// Then
expect(result).toEqual({
ok: false,
state: "missing_token",
message: "This link is missing a token.",
});
expect(lastFetchCall().url).toBe(
"https://api.example.com/api/v1/alerts/recipients/confirm",
);
});
it("returns the fallback message when the API base URL is missing", async () => {
// Given
vi.stubEnv("NEXT_PUBLIC_API_BASE_URL", "");
// When
const result = await confirmAlertRecipient("token-1");
// Then
expect(result).toEqual({
ok: false,
state: "missing_api_base_url",
message:
"We could not process this confirmation link. Please try again later.",
});
expect(fetchMock).not.toHaveBeenCalled();
});
it("returns the fallback message when the request fails", async () => {
// Given
fetchMock.mockRejectedValueOnce(new Error("network down"));
// When
const result = await confirmAlertRecipient("token-1");
// Then
expect(result).toEqual({
ok: false,
state: "network_error",
message:
"We could not process this confirmation link. Please try again later.",
});
});
});
@@ -0,0 +1,79 @@
interface AlertConfirmApiResponse {
state?: string;
message?: string;
}
interface AlertConfirmResult {
ok: boolean;
state: string;
message: string;
}
const FALLBACK_CONFIRM_ERROR =
"We could not process this confirmation link. Please try again later.";
const toMessage = (payload: unknown): string | null => {
if (
typeof payload === "object" &&
payload !== null &&
"message" in payload &&
typeof payload.message === "string"
) {
return payload.message;
}
return null;
};
const toState = (payload: unknown): string => {
if (
typeof payload === "object" &&
payload !== null &&
"state" in payload &&
typeof payload.state === "string"
) {
return payload.state;
}
return "unknown";
};
export const confirmAlertRecipient = async (
token?: string,
): Promise<AlertConfirmResult> => {
const apiBaseUrl = process.env.NEXT_PUBLIC_API_BASE_URL;
if (!apiBaseUrl) {
return {
ok: false,
state: "missing_api_base_url",
message: FALLBACK_CONFIRM_ERROR,
};
}
const url = new URL(`${apiBaseUrl}/alerts/recipients/confirm`);
if (token) {
url.searchParams.set("token", token);
}
try {
const response = await fetch(url.toString(), {
headers: {
Accept: "application/json",
},
cache: "no-store",
});
const payload = (await response.json()) as AlertConfirmApiResponse;
return {
ok: response.ok,
state: toState(payload),
message: toMessage(payload) ?? FALLBACK_CONFIRM_ERROR,
};
} catch {
return {
ok: false,
state: "network_error",
message: FALLBACK_CONFIRM_ERROR,
};
}
};
@@ -0,0 +1,81 @@
import { render, screen } from "@testing-library/react";
import { ReactNode } from "react";
import { describe, expect, it, vi } from "vitest";
import AlertsConfirmPage from "./page";
const confirmAlertRecipientMock = vi.hoisted(() => vi.fn());
vi.mock("./confirm-alert-recipient", () => ({
confirmAlertRecipient: confirmAlertRecipientMock,
}));
vi.mock("@/components/auth/oss/auth-layout", () => ({
AuthLayout: ({ title, children }: { title: string; children: ReactNode }) => (
<section aria-label={title}>{children}</section>
),
}));
vi.mock("@/components/shadcn", () => ({
Button: ({ children }: { children: ReactNode }) => <div>{children}</div>,
}));
vi.mock("next/link", () => ({
default: ({ children, href }: { children: ReactNode; href: string }) => (
<a href={href}>{children}</a>
),
}));
describe("AlertsConfirmPage", () => {
it("shows the API message after confirming the alert recipient", async () => {
// Given
confirmAlertRecipientMock.mockResolvedValueOnce({
ok: true,
state: "confirmed",
message:
"Your subscription has been confirmed. You will receive alert digests at this address.",
});
// When
render(
await AlertsConfirmPage({
searchParams: Promise.resolve({ token: "token-1" }),
}),
);
// Then
expect(confirmAlertRecipientMock).toHaveBeenCalledWith("token-1");
expect(screen.getByLabelText("Subscription confirmed")).toBeInTheDocument();
expect(
screen.getByText(
"Your subscription has been confirmed. You will receive alert digests at this address.",
),
).toBeVisible();
expect(
screen.getByRole("link", { name: "Continue to Prowler" }),
).toHaveAttribute("href", "/");
});
it("shows the subscription link title when confirmation fails", async () => {
// Given
confirmAlertRecipientMock.mockResolvedValueOnce({
ok: false,
state: "invalid_token",
message: "This link is invalid or has expired.",
});
// When
render(
await AlertsConfirmPage({
searchParams: Promise.resolve({ token: ["expired-token"] }),
}),
);
// Then
expect(confirmAlertRecipientMock).toHaveBeenCalledWith("expired-token");
expect(screen.getByLabelText("Subscription link")).toBeInTheDocument();
expect(
screen.getByText("This link is invalid or has expired."),
).toBeVisible();
});
});
+40
View File
@@ -0,0 +1,40 @@
import Link from "next/link";
import { AuthLayout } from "@/components/auth/oss/auth-layout";
import { Button } from "@/components/shadcn";
import { confirmAlertRecipient } from "./confirm-alert-recipient";
interface AlertsConfirmPageProps {
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
}
const getParamValue = (
params: Awaited<AlertsConfirmPageProps["searchParams"]>,
key: string,
): string | undefined => {
const value = params[key];
return Array.isArray(value) ? value[0] : value;
};
export default async function AlertsConfirmPage({
searchParams,
}: AlertsConfirmPageProps) {
const resolvedSearchParams = await searchParams;
const token = getParamValue(resolvedSearchParams, "token");
const result = await confirmAlertRecipient(token);
const title = result.ok ? "Subscription confirmed" : "Subscription link";
return (
<AuthLayout title={title}>
<div className="flex flex-col gap-4">
<p className="text-text-neutral-secondary text-sm leading-6">
{result.message}
</p>
<Button variant="outline" className="w-full" asChild>
<Link href="/">Continue to Prowler</Link>
</Button>
</div>
</AuthLayout>
);
}
@@ -0,0 +1,58 @@
import { render, screen } from "@testing-library/react";
import { ReactNode } from "react";
import { describe, expect, it, vi } from "vitest";
import AlertsUnsubscribePage from "./page";
const unsubscribeAlertRecipientMock = vi.hoisted(() => vi.fn());
vi.mock("./unsubscribe-alert-recipient", () => ({
unsubscribeAlertRecipient: unsubscribeAlertRecipientMock,
}));
vi.mock("@/components/auth/oss/auth-layout", () => ({
AuthLayout: ({ title, children }: { title: string; children: ReactNode }) => (
<section aria-label={title}>{children}</section>
),
}));
vi.mock("@/components/shadcn", () => ({
Button: ({ children }: { children: ReactNode }) => <div>{children}</div>,
}));
vi.mock("next/link", () => ({
default: ({ children, href }: { children: ReactNode; href: string }) => (
<a href={href}>{children}</a>
),
}));
describe("AlertsUnsubscribePage", () => {
it("shows a neutral link back to the app after unsubscribing", async () => {
// Given
unsubscribeAlertRecipientMock.mockResolvedValueOnce({
ok: true,
state: "unsubscribed",
message:
"You have been unsubscribed. You will not receive further alerts at this address.",
});
// When
render(
await AlertsUnsubscribePage({
searchParams: Promise.resolve({ token: "token-1" }),
}),
);
// Then
expect(unsubscribeAlertRecipientMock).toHaveBeenCalledWith("token-1");
expect(screen.getByLabelText("Unsubscribed")).toBeInTheDocument();
expect(
screen.getByText(
"You have been unsubscribed. You will not receive further alerts at this address.",
),
).toBeVisible();
expect(
screen.getByRole("link", { name: "Continue to Prowler" }),
).toHaveAttribute("href", "/");
});
});
+40
View File
@@ -0,0 +1,40 @@
import Link from "next/link";
import { AuthLayout } from "@/components/auth/oss/auth-layout";
import { Button } from "@/components/shadcn";
import { unsubscribeAlertRecipient } from "./unsubscribe-alert-recipient";
interface AlertsUnsubscribePageProps {
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
}
const getParamValue = (
params: Awaited<AlertsUnsubscribePageProps["searchParams"]>,
key: string,
): string | undefined => {
const value = params[key];
return Array.isArray(value) ? value[0] : value;
};
export default async function AlertsUnsubscribePage({
searchParams,
}: AlertsUnsubscribePageProps) {
const resolvedSearchParams = await searchParams;
const token = getParamValue(resolvedSearchParams, "token");
const result = await unsubscribeAlertRecipient(token);
const title = result.ok ? "Unsubscribed" : "Subscription link";
return (
<AuthLayout title={title}>
<div className="flex flex-col gap-4">
<p className="text-text-neutral-secondary text-sm leading-6">
{result.message}
</p>
<Button variant="outline" className="w-full" asChild>
<Link href="/">Continue to Prowler</Link>
</Button>
</div>
</AuthLayout>
);
}
@@ -0,0 +1,105 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { unsubscribeAlertRecipient } from "./unsubscribe-alert-recipient";
const fetchMock = vi.fn();
const lastFetchCall = (): { url: string; init: RequestInit } => {
const call = fetchMock.mock.calls.at(-1);
if (!call) throw new Error("fetch was not called");
const [url, init] = call;
return { url: String(url), init: (init ?? {}) as RequestInit };
};
describe("unsubscribeAlertRecipient", () => {
beforeEach(() => {
vi.stubGlobal("fetch", fetchMock);
vi.stubEnv("NEXT_PUBLIC_API_BASE_URL", "https://api.example.com/api/v1");
fetchMock.mockResolvedValue(
new Response(
JSON.stringify({
state: "unsubscribed",
message:
"You have been unsubscribed. You will not receive further alerts at this address.",
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
),
);
});
afterEach(() => {
vi.unstubAllEnvs();
vi.unstubAllGlobals();
vi.clearAllMocks();
});
it("calls the public unsubscribe endpoint without auth headers", async () => {
// When
const result = await unsubscribeAlertRecipient("token-1");
// Then
expect(result).toEqual({
ok: true,
state: "unsubscribed",
message:
"You have been unsubscribed. You will not receive further alerts at this address.",
});
const { url, init } = lastFetchCall();
expect(url).toBe(
"https://api.example.com/api/v1/alerts/recipients/unsubscribe?token=token-1",
);
expect(init).toEqual({
headers: { Accept: "application/json" },
cache: "no-store",
});
});
it("returns the API message for invalid tokens", async () => {
// Given
fetchMock.mockResolvedValueOnce(
new Response(
JSON.stringify({
state: "invalid_token",
message: "This link is invalid or has expired.",
}),
{ status: 400, headers: { "Content-Type": "application/json" } },
),
);
// When
const result = await unsubscribeAlertRecipient("expired-token");
// Then
expect(result).toEqual({
ok: false,
state: "invalid_token",
message: "This link is invalid or has expired.",
});
});
it("returns the API message for missing tokens", async () => {
// Given
fetchMock.mockResolvedValueOnce(
new Response(
JSON.stringify({
state: "missing_token",
message: "This link is missing a token.",
}),
{ status: 400, headers: { "Content-Type": "application/json" } },
),
);
// When
const result = await unsubscribeAlertRecipient();
// Then
expect(result).toEqual({
ok: false,
state: "missing_token",
message: "This link is missing a token.",
});
expect(lastFetchCall().url).toBe(
"https://api.example.com/api/v1/alerts/recipients/unsubscribe",
);
});
});
@@ -0,0 +1,79 @@
interface AlertUnsubscribeApiResponse {
state?: string;
message?: string;
}
interface AlertUnsubscribeResult {
ok: boolean;
state: string;
message: string;
}
const FALLBACK_UNSUBSCRIBE_ERROR =
"We could not process this unsubscribe link. Please try again later.";
const toMessage = (payload: unknown): string | null => {
if (
typeof payload === "object" &&
payload !== null &&
"message" in payload &&
typeof payload.message === "string"
) {
return payload.message;
}
return null;
};
const toState = (payload: unknown): string => {
if (
typeof payload === "object" &&
payload !== null &&
"state" in payload &&
typeof payload.state === "string"
) {
return payload.state;
}
return "unknown";
};
export const unsubscribeAlertRecipient = async (
token?: string,
): Promise<AlertUnsubscribeResult> => {
const apiBaseUrl = process.env.NEXT_PUBLIC_API_BASE_URL;
if (!apiBaseUrl) {
return {
ok: false,
state: "missing_api_base_url",
message: FALLBACK_UNSUBSCRIBE_ERROR,
};
}
const url = new URL(`${apiBaseUrl}/alerts/recipients/unsubscribe`);
if (token) {
url.searchParams.set("token", token);
}
try {
const response = await fetch(url.toString(), {
headers: {
Accept: "application/json",
},
cache: "no-store",
});
const payload = (await response.json()) as AlertUnsubscribeApiResponse;
return {
ok: response.ok,
state: toState(payload),
message: toMessage(payload) ?? FALLBACK_UNSUBSCRIBE_ERROR,
};
} catch {
return {
ok: false,
state: "network_error",
message: FALLBACK_UNSUBSCRIBE_ERROR,
};
}
};
@@ -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,224 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const {
fetchMock,
getAuthHeadersMock,
handleApiErrorMock,
handleApiResponseMock,
} = vi.hoisted(() => ({
fetchMock: vi.fn(),
getAuthHeadersMock: vi.fn(),
handleApiErrorMock: vi.fn(),
handleApiResponseMock: vi.fn(),
}));
vi.mock("@/lib", () => ({
apiBaseUrl: "https://api.test/api/v1",
getAuthHeaders: getAuthHeadersMock,
getErrorMessage: (error: unknown) =>
error instanceof Error ? error.message : String(error),
}));
vi.mock("@/lib/server-actions-helper", () => ({
handleApiError: handleApiErrorMock,
handleApiResponse: handleApiResponseMock,
}));
import { ALERT_AGGREGATE_OPS, ALERT_TRIGGER_KINDS } from "../_types";
import {
createAlert,
deleteAlert,
disableAlert,
enableAlert,
listAlerts,
previewAlertCondition,
seedAlertRule,
updateAlert,
} from "./alerts";
const lastFetchCall = (): { url: string; init: RequestInit } => {
const call = fetchMock.mock.calls.at(-1);
if (!call) throw new Error("fetch was not called");
const [url, init] = call;
return { url: String(url), init: (init ?? {}) as RequestInit };
};
beforeEach(() => {
vi.clearAllMocks();
vi.stubGlobal("fetch", fetchMock);
fetchMock.mockResolvedValue(
new Response(JSON.stringify({ data: [] }), {
status: 200,
headers: { "Content-Type": "application/vnd.api+json" },
}),
);
getAuthHeadersMock.mockResolvedValue({
Accept: "application/vnd.api+json",
Authorization: "Bearer test-token",
"Content-Type": "application/vnd.api+json",
});
handleApiResponseMock.mockResolvedValue({ data: [] });
handleApiErrorMock.mockReturnValue({ error: "Unexpected error." });
});
describe("listAlerts", () => {
it("returns whatever handleApiResponse returns", async () => {
handleApiResponseMock.mockResolvedValue({
data: [],
meta: { pagination: { count: 0 } },
});
const result = await listAlerts({ "filter[enabled]": "true" });
expect(result).toEqual({ data: [], meta: { pagination: { count: 0 } } });
});
it("forwards searchParams as query string", async () => {
await listAlerts({ "filter[trigger]": "daily" });
expect(lastFetchCall().url).toContain("filter%5Btrigger%5D=daily");
});
it("delegates network errors to handleApiError", async () => {
fetchMock.mockRejectedValueOnce(new Error("boom"));
handleApiErrorMock.mockReturnValueOnce({ error: "boom" });
const result = await listAlerts();
expect(handleApiErrorMock).toHaveBeenCalled();
expect(result).toEqual({ error: "boom" });
});
});
describe("createAlert", () => {
it("posts a JSON:API envelope with schema_version", async () => {
handleApiResponseMock.mockResolvedValue({
data: {
id: "alert-1",
type: "alert-rules",
attributes: { name: "n", trigger: "after_scan" },
},
});
await createAlert({
name: "Daily critical",
trigger: ALERT_TRIGGER_KINDS.AFTER_SCAN,
condition: {
op: ALERT_AGGREGATE_OPS.ANY,
filter: { severity: ["critical"] },
},
});
const { init } = lastFetchCall();
expect(init.method).toBe("POST");
const body = JSON.parse(init.body as string);
expect(body.data.type).toBe("alert-rules");
expect(body.data.attributes.schema_version).toBe(1);
});
it("sends an empty recipient list when provided", async () => {
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(lastFetchCall().init.body as string);
expect(body.data.attributes.recipient_emails).toEqual([]);
});
});
describe("seedAlertRule", () => {
it("posts a JSON:API seeding envelope to /seed", async () => {
const filterBag = {
"filter[severity__in]": "critical",
"filter[sort]": "-severity",
};
await seedAlertRule(filterBag);
const { url, init } = lastFetchCall();
expect(url).toMatch(/\/alerts\/rules\/seed$/);
expect(init.method).toBe("POST");
expect(JSON.parse(init.body as string)).toEqual({
data: {
type: "alert-rule-seedings",
attributes: { filter_bag: filterBag },
},
});
});
});
describe("updateAlert", () => {
it("PATCHes the alert with the id in the URL", async () => {
await updateAlert("alert-1", {
name: "Updated",
trigger: ALERT_TRIGGER_KINDS.DAILY,
condition: {
op: ALERT_AGGREGATE_OPS.ANY,
filter: { severity: ["critical"] },
},
});
const { url, init } = lastFetchCall();
expect(url).toContain("/alerts/rules/alert-1");
expect(init.method).toBe("PATCH");
});
});
describe("deleteAlert", () => {
it("issues a DELETE against the alert id", async () => {
handleApiResponseMock.mockResolvedValue({ success: true, status: 204 });
await deleteAlert("alert-1");
const { init } = lastFetchCall();
expect(init.method).toBe("DELETE");
});
});
describe("enable / disable", () => {
it("PATCHes enabled true to the alert rule endpoint", async () => {
await enableAlert("alert-1");
const { url, init } = lastFetchCall();
expect(url).toMatch(/\/alerts\/rules\/alert-1$/);
expect(init.method).toBe("PATCH");
expect(JSON.parse(init.body as string)).toEqual({
data: {
type: "alert-rules",
id: "alert-1",
attributes: { enabled: true },
},
});
});
it("PATCHes enabled false to the alert rule endpoint", async () => {
await disableAlert("alert-1");
const { url, init } = lastFetchCall();
expect(url).toMatch(/\/alerts\/rules\/alert-1$/);
expect(init.method).toBe("PATCH");
expect(JSON.parse(init.body as string)).toEqual({
data: {
type: "alert-rules",
id: "alert-1",
attributes: { enabled: false },
},
});
});
});
describe("previewAlertCondition", () => {
it("posts a JSON:API preview envelope to /preview", async () => {
const condition = {
op: ALERT_AGGREGATE_OPS.ANY,
filter: { severity: ["critical"] },
};
await previewAlertCondition({ condition });
const { url, init } = lastFetchCall();
expect(url).toMatch(/\/alerts\/rules\/preview$/);
expect(init.method).toBe("POST");
expect(init.headers).toEqual(
expect.objectContaining({
Accept: "application/vnd.api+json",
"Content-Type": "application/vnd.api+json",
}),
);
expect(JSON.parse(init.body as string)).toEqual({
data: {
type: "alert-rule-previews",
attributes: { condition },
},
});
});
});
+214
View File
@@ -0,0 +1,214 @@
"use server";
import { apiBaseUrl, getAuthHeaders } from "@/lib";
import { handleApiError, handleApiResponse } from "@/lib/server-actions-helper";
import {
ALERT_SCHEMA_VERSION,
type AlertCondition,
type AlertTriggerKind,
} from "../_types";
const ALERT_RULES_API_PATH = "/alerts/rules";
const ALERTS_REVALIDATE_PATH = "/alerts";
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 },
},
});
const buildSeedEnvelope = (filterBag: Record<string, string | string[]>) => ({
data: {
type: "alert-rule-seedings",
attributes: { filter_bag: filterBag },
},
});
export const listAlerts = async (
searchParams?: Record<string, string | undefined>,
) => {
const headers = await getAuthHeaders({ contentType: false });
const url = new URL(`${apiBaseUrl}${ALERT_RULES_API_PATH}`);
if (searchParams) {
for (const [key, value] of Object.entries(searchParams)) {
if (value !== undefined && value !== "") {
url.searchParams.append(key, value);
}
}
}
try {
const response = await fetch(url.toString(), { headers });
return handleApiResponse(response);
} catch (error) {
return handleApiError(error);
}
};
export const getAlert = async (alertId: string) => {
const headers = await getAuthHeaders({ contentType: false });
const url = new URL(`${apiBaseUrl}${ALERT_RULES_API_PATH}/${alertId}`);
try {
const response = await fetch(url.toString(), { headers });
return handleApiResponse(response);
} catch (error) {
return handleApiError(error);
}
};
export const seedAlertRule = async (
filterBag: Record<string, string | string[]>,
) => {
const headers = await getAuthHeaders({ contentType: true });
const url = new URL(`${apiBaseUrl}${ALERT_RULES_API_PATH}/seed`);
try {
const response = await fetch(url.toString(), {
method: "POST",
headers,
body: JSON.stringify(buildSeedEnvelope(filterBag)),
});
return handleApiResponse(response);
} catch (error) {
return handleApiError(error);
}
};
export const createAlert = async (payload: AlertPayload) => {
const headers = await getAuthHeaders({ contentType: true });
const url = new URL(`${apiBaseUrl}${ALERT_RULES_API_PATH}`);
try {
const response = await fetch(url.toString(), {
method: "POST",
headers,
body: JSON.stringify(buildRuleEnvelope(payload)),
});
return handleApiResponse(response, ALERTS_REVALIDATE_PATH);
} catch (error) {
return handleApiError(error);
}
};
export const updateAlert = async (alertId: string, payload: AlertPayload) => {
const headers = await getAuthHeaders({ contentType: true });
const url = new URL(`${apiBaseUrl}${ALERT_RULES_API_PATH}/${alertId}`);
try {
const response = await fetch(url.toString(), {
method: "PATCH",
headers,
body: JSON.stringify(buildRuleEnvelope(payload, alertId)),
});
return handleApiResponse(response, ALERTS_REVALIDATE_PATH);
} catch (error) {
return handleApiError(error);
}
};
export const deleteAlert = async (alertId: string) => {
const headers = await getAuthHeaders({ contentType: false });
const url = new URL(`${apiBaseUrl}${ALERT_RULES_API_PATH}/${alertId}`);
try {
const response = await fetch(url.toString(), {
method: "DELETE",
headers,
});
return handleApiResponse(response, ALERTS_REVALIDATE_PATH);
} catch (error) {
return handleApiError(error);
}
};
export const enableAlert = async (alertId: string) => {
const headers = await getAuthHeaders({ contentType: true });
const url = new URL(`${apiBaseUrl}${ALERT_RULES_API_PATH}/${alertId}`);
try {
const response = await fetch(url.toString(), {
method: "PATCH",
headers,
body: JSON.stringify(buildEnabledEnvelope(alertId, true)),
});
return handleApiResponse(response, ALERTS_REVALIDATE_PATH);
} catch (error) {
return handleApiError(error);
}
};
export const disableAlert = async (alertId: string) => {
const headers = await getAuthHeaders({ contentType: true });
const url = new URL(`${apiBaseUrl}${ALERT_RULES_API_PATH}/${alertId}`);
try {
const response = await fetch(url.toString(), {
method: "PATCH",
headers,
body: JSON.stringify(buildEnabledEnvelope(alertId, false)),
});
return handleApiResponse(response, ALERTS_REVALIDATE_PATH);
} catch (error) {
return handleApiError(error);
}
};
export const previewAlertCondition = async (payload: {
condition: AlertCondition;
}) => {
const headers = await getAuthHeaders({ contentType: true });
const url = new URL(`${apiBaseUrl}${ALERT_RULES_API_PATH}/preview`);
try {
const response = await fetch(url.toString(), {
method: "POST",
headers,
body: JSON.stringify({
data: {
type: "alert-rule-previews",
attributes: { condition: payload.condition },
},
}),
});
return handleApiResponse(response);
} catch (error) {
return handleApiError(error);
}
};
@@ -0,0 +1,2 @@
export * from "./alerts";
export * from "./recipients";
@@ -0,0 +1,70 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const {
fetchMock,
getAuthHeadersMock,
handleApiErrorMock,
handleApiResponseMock,
} = vi.hoisted(() => ({
fetchMock: vi.fn(),
getAuthHeadersMock: vi.fn(),
handleApiErrorMock: vi.fn(),
handleApiResponseMock: vi.fn(),
}));
vi.mock("@/lib", () => ({
apiBaseUrl: "https://api.test/api/v1",
getAuthHeaders: getAuthHeadersMock,
getErrorMessage: (error: unknown) =>
error instanceof Error ? error.message : String(error),
}));
vi.mock("@/lib/server-actions-helper", () => ({
handleApiError: handleApiErrorMock,
handleApiResponse: handleApiResponseMock,
}));
import { listAlertRecipients } from "./recipients";
beforeEach(() => {
vi.clearAllMocks();
vi.stubGlobal("fetch", fetchMock);
fetchMock.mockResolvedValue(
new Response(JSON.stringify({ data: [] }), {
status: 200,
headers: { "Content-Type": "application/vnd.api+json" },
}),
);
getAuthHeadersMock.mockResolvedValue({
Accept: "application/vnd.api+json",
Authorization: "Bearer test-token",
});
handleApiResponseMock.mockResolvedValue({ data: [] });
handleApiErrorMock.mockReturnValue({ error: "Unexpected error." });
});
describe("listAlertRecipients", () => {
it("returns whatever handleApiResponse returns", async () => {
handleApiResponseMock.mockResolvedValue({
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({
"filter[status]": "pending",
});
expect(result.data).toHaveLength(1);
expect(result.data[0].attributes.email).toBe("a@b.test");
});
it("forwards searchParams as query string", async () => {
await listAlertRecipients({ "filter[status]": "pending" });
const [url] = fetchMock.mock.calls.at(-1) ?? [""];
expect(String(url)).toContain("filter%5Bstatus%5D=pending");
});
});
@@ -0,0 +1,28 @@
"use server";
import { apiBaseUrl, getAuthHeaders } from "@/lib";
import { handleApiError, handleApiResponse } from "@/lib/server-actions-helper";
const RECIPIENTS_PATH = "/alerts/recipients";
export const listAlertRecipients = async (
searchParams?: Record<string, string | undefined>,
) => {
const headers = await getAuthHeaders({ contentType: false });
const url = new URL(`${apiBaseUrl}${RECIPIENTS_PATH}`);
if (searchParams) {
for (const [key, value] of Object.entries(searchParams)) {
if (value !== undefined && value !== "") {
url.searchParams.append(key, value);
}
}
}
try {
const response = await fetch(url.toString(), { headers });
return handleApiResponse(response);
} catch (error) {
return handleApiError(error);
}
};
@@ -0,0 +1,755 @@
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(),
seedAlertRule: 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({
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();
alertsActionMocks.seedAlertRule.mockReset();
alertsActionMocks.seedAlertRule.mockResolvedValue({
data: {
attributes: {
condition: {
op: ALERT_AGGREGATE_OPS.ANY,
filter: { provider_type: ["gcp"] },
},
schema_version: 1,
warnings: [],
},
},
});
});
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({
seededCondition: {
op: ALERT_AGGREGATE_OPS.ANY,
filter: { severity: ["critical"] },
},
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"],
}),
),
);
const recipientsParams = recipientsActionMocks.listAlertRecipients.mock
.calls[0][0] as Record<string, string>;
expect(recipientsParams["filter[status]"]).toBeUndefined();
expect(recipientsParams["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,
}),
),
);
});
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: [],
}),
),
);
expect(
screen.queryByText(/select at least one recipient/i),
).not.toBeInTheDocument();
});
it("should render backend submit errors with the design error color", async () => {
// Given
const user = userEvent.setup();
const onSubmit = vi.fn().mockResolvedValue({
ok: false,
error: "Backend validation failed",
});
mockRecipientsList();
renderCreateModal({ onSubmit });
// When
await user.type(screen.getByLabelText(/^name$/i), "Critical alerts");
await user.click(screen.getByRole("button", { name: /^create$/i }));
// Then
const errorMessage = await screen.findByText("Backend validation failed");
expect(errorMessage).toHaveClass("text-text-error-primary");
});
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
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"],
});
// 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();
const filterControls = screen.getByTestId("findings-filter-controls");
const alertEditGrid = filterControls.querySelector(".grid");
expect(alertEditGrid).toHaveClass("xl:grid-cols-3", "2xl:grid-cols-3");
expect(alertEditGrid).not.toHaveClass("xl:grid-cols-4", "2xl:grid-cols-5");
expect(screen.getAllByText("Amazon Web Services")[0]).toBeVisible();
expect(screen.getByText("All accounts")).toBeVisible();
expect(within(filterControls).getByText("All Delta")).toBeVisible();
expect(within(filterControls).getByText("All Resource Type")).toBeVisible();
expect(
screen.queryByTestId("findings-expanded-filters"),
).not.toBeInTheDocument();
expect(
screen.queryByRole("button", { name: /more filters/i }),
).not.toBeInTheDocument();
expect(screen.queryByText("All Status")).not.toBeInTheDocument();
expect(screen.queryByText("Scan ID")).not.toBeInTheDocument();
expect(screen.queryByText(/^date$/i)).not.toBeInTheDocument();
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.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(alertsActionMocks.seedAlertRule).toHaveBeenCalled(),
);
expect(alertsActionMocks.seedAlertRule).toHaveBeenCalledWith(
expect.objectContaining({
"filter[provider_type__in]": ["gcp"],
}),
);
await waitFor(() =>
expect(onSubmit).toHaveBeenCalledWith(
expect.objectContaining({
condition: expect.objectContaining({
filter: { provider_type: ["gcp"] },
}),
}),
),
);
});
it("should preview the edited alert using current unsaved filters", async () => {
// Given
const user = userEvent.setup();
alertsActionMocks.previewAlertCondition.mockResolvedValue({
data: {
attributes: {
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.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.seedAlertRule).toHaveBeenCalledWith(
expect.objectContaining({
"filter[provider_type__in]": ["gcp"],
}),
),
);
await waitFor(() =>
expect(alertsActionMocks.previewAlertCondition).toHaveBeenCalledWith(
expect.objectContaining({
condition: expect.objectContaining({
filter: { provider_type: ["gcp"] },
}),
}),
),
);
const previewHeading = await screen.findByText("Test result");
expect(previewHeading).toBeVisible();
const previewCard = previewHeading.closest('[data-slot="card"]');
expect(previewCard).toBeInTheDocument();
const previewCardQueries = within(previewCard as HTMLElement);
expect(
previewCardQueries.getByText(
"It found 7 findings, including Critical severity.",
),
).toBeVisible();
expect(
previewCardQueries.queryByText(/^findings$/i),
).not.toBeInTheDocument();
expect(
previewCardQueries.queryByText(/^top severity$/i),
).not.toBeInTheDocument();
expect(
previewCardQueries.queryByText(/^duration$/i),
).not.toBeInTheDocument();
expect(previewCardQueries.queryByText(/42 ms/i)).not.toBeInTheDocument();
expect(
previewCardQueries.queryByText("Would fire"),
).not.toBeInTheDocument();
expect(
previewCardQueries.queryByText("Would not fire"),
).not.toBeInTheDocument();
});
it("should explain when the edited alert has no matching findings", async () => {
// Given
const user = userEvent.setup();
alertsActionMocks.previewAlertCondition.mockResolvedValue({
data: {
attributes: {
summary: {
finding_count_total: 0,
},
sample_finding_ids: [],
evaluation_failed: false,
},
},
});
mockRecipientsList();
renderCreateModal({ editingAlert: createEditingAlert() });
// When
await user.click(screen.getByRole("button", { name: /^test$/i }));
// Then
expect(
await screen.findByText(
"These filters did not match any findings for the latest scan.",
),
).toBeVisible();
expect(screen.queryByText("Would fire")).not.toBeInTheDocument();
expect(screen.queryByText("Would not fire")).not.toBeInTheDocument();
});
it("should render preview errors inline in edit mode", async () => {
// Given
const user = userEvent.setup();
alertsActionMocks.previewAlertCondition.mockResolvedValue({
error: "Invalid condition",
});
mockRecipientsList();
renderCreateModal({ editingAlert: createEditingAlert() });
// When
await user.click(screen.getByRole("button", { name: /^test$/i }));
// Then
const errorMessage = await screen.findByText(/invalid condition/i);
expect(errorMessage).toBeVisible();
expect(errorMessage).toHaveClass("text-text-error-primary");
});
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" });
alertsActionMocks.seedAlertRule.mockResolvedValue({
data: {
attributes: {
condition: {
op: ALERT_AGGREGATE_OPS.ANY,
filter: { severity: ["critical"] },
},
schema_version: 1,
warnings: [],
},
},
});
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"],
condition: {
op: ALERT_AGGREGATE_OPS.ANY,
filter: { severity: ["critical"] },
},
}),
),
);
expect(
screen.queryByText(/advanced condition preserved/i),
).not.toBeInTheDocument();
});
});
@@ -0,0 +1,305 @@
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { isValidElement, 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(),
currentSearch: "",
}));
const toastMock = vi.hoisted(() => vi.fn());
vi.mock("@/app/(prowler)/alerts/_actions", () => actionMocks);
vi.mock("next/link", () => ({
default: ({
children,
href,
className,
}: {
children: ReactNode;
href: string;
className?: string;
}) => (
<a href={href} className={className}>
{children}
</a>
),
}));
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(routerMocks.currentSearch),
}));
vi.mock("@/components/ui", () => ({
useToast: () => ({ toast: toastMock }),
}));
vi.mock("@/components/shadcn", () => ({
Button: ({
asChild,
children,
disabled,
onClick,
variant,
}: {
asChild?: boolean;
children: ReactNode;
disabled?: boolean;
onClick?: () => void;
variant?: string;
}) => {
if (asChild && isValidElement(children)) {
return <span data-variant={variant}>{children}</span>;
}
return (
<button
type="button"
disabled={disabled}
onClick={onClick}
data-variant={variant}
>
{children}
</button>
);
},
}));
vi.mock("../alert-form-modal", () => ({
AlertFormModal: ({
open,
editingAlert,
onOpenChange,
}: {
open: boolean;
editingAlert?: AlertRule | null;
onOpenChange: (open: boolean) => void;
}) =>
open ? (
<div
role="dialog"
aria-label={editingAlert ? "Edit Alert" : "Create Alert"}
>
<button type="button" onClick={() => onOpenChange(false)}>
Close modal
</button>
{editingAlert?.attributes.name}
</div>
) : 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();
routerMocks.currentSearch = "";
});
it("links to Findings from the alerts description", () => {
// Given
renderManager([]);
// When
const findingsLink = screen.getByRole("link", { name: "Findings" });
// Then
expect(findingsLink).toHaveAttribute(
"href",
"/findings?filter[muted]=false&filter[status__in]=FAIL",
);
expect(findingsLink.closest("[data-variant='link']")).toBeInTheDocument();
expect(screen.getByRole("link", { name: "here." })).toHaveAttribute(
"href",
"https://docs.prowler.com/user-guide/tutorials/prowler-app",
);
expect(screen.getByText(/get notified when findings match/i)).toBeVisible();
});
it("opens the edit modal for an initial editing alert", () => {
// Given
const alert = makeAlert(true);
// When
render(
<AlertsManager
alerts={[alert]}
loadError={null}
providers={[]}
completedScanIds={[]}
scanDetails={[]}
uniqueRegions={[]}
uniqueServices={[]}
uniqueResourceTypes={[]}
uniqueCategories={[]}
uniqueGroups={[]}
initialEditingAlert={alert}
/>,
);
// Then
expect(
screen.getByRole("dialog", { name: /edit alert/i }),
).toHaveTextContent("Enabled alert");
});
it("adds the edit alert id to the URL when opening the edit modal", async () => {
// Given
const user = userEvent.setup();
const alert = makeAlert(true);
routerMocks.currentSearch = "page=2&filter[enabled]=true";
renderManager([alert]);
// When
await user.click(
screen.getByRole("button", { name: /actions for enabled alert/i }),
);
await user.click(screen.getByRole("menuitem", { name: /edit/i }));
// Then
expect(routerMocks.replace).toHaveBeenCalledWith(
"/alerts?page=2&filter%5Benabled%5D=true&edit=enabled-alert",
{ scroll: false },
);
expect(
screen.getByRole("dialog", { name: /edit alert/i }),
).toHaveTextContent("Enabled alert");
});
it("removes only the edit alert id from the URL when closing the edit modal", async () => {
// Given
const user = userEvent.setup();
const alert = makeAlert(true);
routerMocks.currentSearch = "page=2&edit=enabled-alert";
render(
<AlertsManager
alerts={[alert]}
loadError={null}
providers={[]}
completedScanIds={[]}
scanDetails={[]}
uniqueRegions={[]}
uniqueServices={[]}
uniqueResourceTypes={[]}
uniqueCategories={[]}
uniqueGroups={[]}
initialEditingAlert={alert}
/>,
);
// When
await user.click(screen.getByRole("button", { name: /close modal/i }));
// Then
expect(routerMocks.replace).toHaveBeenCalledWith("/alerts?page=2", {
scroll: false,
});
});
it("shows a success toast after disabling an alert", async () => {
// Given
const user = userEvent.setup();
const alert = makeAlert(true);
actionMocks.disableAlert.mockResolvedValue({ 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({ 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,253 @@
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-01T10:00:00Z",
updated_at: "2026-01-02T11:30: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.getByTestId("column-inserted_at")).toHaveAttribute(
"data-size",
"170",
);
expect(screen.getByTestId("column-updated_at")).toHaveAttribute(
"data-size",
"170",
);
expect(screen.getByText("Jan 01, 2026")).toBeVisible();
expect(screen.getByText("Jan 02, 2026")).toBeVisible();
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);
});
it("should edit the alert directly when clicking the alert name", async () => {
// Given
const user = userEvent.setup();
const alert = makeRule();
const onEdit = vi.fn();
render(
<AlertsTable
alerts={[alert]}
mutatingId={null}
onEdit={onEdit}
onToggleEnabled={vi.fn()}
onDelete={vi.fn()}
/>,
);
// When
await user.click(screen.getByRole("button", { name: "Critical findings" }));
// Then
expect(onEdit).toHaveBeenCalledWith(alert);
expect(screen.queryByRole("menuitem", { name: /edit/i })).toBeNull();
expect(screen.queryByRole("menuitem", { name: /disable/i })).toBeNull();
expect(screen.queryByRole("menuitem", { name: /delete/i })).toBeNull();
});
});
@@ -0,0 +1,384 @@
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type { ComponentProps, ReactNode } from "react";
import { afterEach, 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(),
seedAlertRule: 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,
seedAlertRule: actionMocks.seedAlertRule,
}));
vi.mock("@/app/(prowler)/alerts/_components/alert-form-modal", () => ({
AlertFormModal: ({
open,
seededCondition,
selectedFindingsFilterChips,
defaultName,
onSubmit,
}: {
open: boolean;
seededCondition?: AlertCondition | null;
selectedFindingsFilterChips?: Array<{
label: string;
displayValue?: string;
value: string;
}>;
defaultName?: string;
onSubmit: (values: AlertFormValues) => Promise<AlertFormSubmitResult>;
}) =>
open ? (
<div role="dialog" aria-label="Create alert">
<output data-testid="seeded-condition">
{JSON.stringify(seededCondition)}
</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",
condition: seededCondition ?? {
op: "any",
filter: { severity: ["critical"] },
},
recipientEmails: ["security@example.com"],
enabled: true,
})
}
>
Submit mock alert
</button>
</div>
) : null,
}));
import { SeedFromFindingsButton } from "../seed-from-findings-button";
describe("SeedFromFindingsButton", () => {
afterEach(() => {
vi.clearAllMocks();
});
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();
expect(screen.getByRole("button", { name: /Create Alert/i })).toHaveClass(
"h-10",
);
});
it("should add all severities when Findings only has non-portable default filters", async () => {
// Given
const user = userEvent.setup();
const seededCondition: AlertCondition = {
op: "any",
filter: {
severity: ["critical", "high", "medium", "low", "informational"],
},
};
actionMocks.seedAlertRule.mockResolvedValue({
data: {
attributes: {
condition: seededCondition,
schema_version: 1,
warnings: [],
},
},
});
const filterBag = {
"filter[status__in]": "FAIL",
"filter[muted]": "false",
"filter[scan__in]": "11111111-1111-1111-1111-111111111111",
};
render(<SeedFromFindingsButton filterBag={filterBag} />);
// When
await user.click(screen.getByRole("button", { name: /Create Alert/i }));
// Then
await waitFor(() =>
expect(actionMocks.seedAlertRule).toHaveBeenCalledWith({
...filterBag,
"filter[severity__in]": [
"critical",
"high",
"medium",
"low",
"informational",
],
}),
);
expect(screen.getByRole("dialog", { name: /create alert/i })).toBeVisible();
expect(screen.getByTestId("seeded-condition")).toHaveTextContent(
"severity",
);
});
it("should seed from the full Findings filter bag before opening the modal", async () => {
// Given
const user = userEvent.setup();
const seededCondition: AlertCondition = {
op: "any",
filter: { severity: ["critical", "high"] },
};
actionMocks.seedAlertRule.mockResolvedValue({
data: {
attributes: {
condition: seededCondition,
schema_version: 1,
warnings: [],
},
},
});
const filterBag = {
"filter[status__in]": "FAIL",
"filter[muted]": "false",
"filter[scan__in]": "11111111-1111-1111-1111-111111111111",
"filter[severity__in]": "critical,high",
};
render(<SeedFromFindingsButton filterBag={filterBag} />);
// When
await user.click(screen.getByRole("button", { name: /Create Alert/i }));
// Then
await waitFor(() =>
expect(actionMocks.seedAlertRule).toHaveBeenCalledWith(filterBag),
);
expect(screen.getByRole("dialog", { name: /create alert/i })).toBeVisible();
expect(routerMocks.push).not.toHaveBeenCalled();
expect(screen.getByTestId("selected-filter-chips")).toHaveTextContent(
/severity:\+2/i,
);
expect(screen.getByTestId("seeded-condition")).toHaveTextContent(
"severity",
);
expect(screen.getByTestId("selected-filter-chips")).not.toHaveTextContent(
/status/i,
);
});
it("should create the alert through the existing alert action from the modal", async () => {
// Given
const user = userEvent.setup();
const seededCondition: AlertCondition = {
op: "any",
filter: { severity: ["critical"] },
};
actionMocks.seedAlertRule.mockResolvedValue({
data: {
attributes: {
condition: seededCondition,
schema_version: 1,
warnings: [],
},
},
});
actionMocks.createAlert.mockResolvedValue({
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",
condition: seededCondition,
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.seedAlertRule.mockResolvedValue({
data: {
attributes: {
condition: { op: "any", filter: { severity: ["critical"] } },
schema_version: 1,
warnings: [],
},
},
});
actionMocks.createAlert.mockResolvedValue({
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",
);
});
it("should show a toast and keep the modal closed when seed fails", async () => {
// Given
const user = userEvent.setup();
actionMocks.seedAlertRule.mockResolvedValue({
error: "invalid_shape",
});
render(
<SeedFromFindingsButton
filterBag={{ "filter[severity__in]": "critical" }}
/>,
);
// When
await user.click(screen.getByRole("button", { name: /Create Alert/i }));
// Then
await waitFor(() =>
expect(toastMock).toHaveBeenCalledWith(
expect.objectContaining({
variant: "destructive",
title: "Alert seed failed",
}),
),
);
expect(
screen.queryByRole("dialog", { name: /create alert/i }),
).not.toBeInTheDocument();
});
it("should render disabled as a Cloud-only feature in OSS", () => {
// Given
render(
<SeedFromFindingsButton
filterBag={{ "filter[severity__in]": "critical" }}
isCloudEnabled={false}
/>,
);
// When
const button = screen.getByRole("button", { name: /Create Alert/i });
// Then
expect(button).toBeDisabled();
expect(button.className).not.toContain("min-w");
expect(button).not.toHaveClass("justify-start");
const pricingLink = screen.getByRole("link", {
name: /available in prowler cloud/i,
});
expect(pricingLink).toHaveAttribute("href", "https://prowler.com/pricing");
expect(pricingLink).toHaveClass("whitespace-nowrap");
expect(pricingLink).toHaveTextContent("Available in Prowler Cloud");
expect(pricingLink.closest("button")).toBeNull();
expect(screen.queryByRole("tooltip")).not.toBeInTheDocument();
expect(actionMocks.seedAlertRule).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,613 @@
"use client";
import { useState } from "react";
import {
previewAlertCondition,
seedAlertRule,
} 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,
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 {
getAlertFormDefaults,
getEmptyAlertFormDefaults,
getFindingsFiltersFromAlertCondition,
} from "../_lib/alert-adapter";
import { alertFormSchema } from "../_lib/alert-form-schema";
import type {
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;
seededCondition?: AlertCondition | null;
selectedFindingsFilterChips?: FilterChip[];
defaultName?: string;
onOpenChange: (open: boolean) => void;
onSubmit: (values: AlertFormValues) => 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 ALERT_SEED_ERROR = "Apply at least one alert-compatible Findings filter.";
const serializeCondition = (condition: AlertCondition | null): string =>
condition ? JSON.stringify(condition) : "none";
const getAlertFormModalResetKey = ({
open,
defaultFrequency,
editingAlert,
seededCondition,
}: Pick<
AlertFormModalProps,
"open" | "defaultFrequency" | "editingAlert" | "seededCondition"
>): string =>
[
open ? "open" : "closed",
editingAlert?.id ?? "create",
editingAlert?.attributes.updated_at ?? "",
defaultFrequency,
serializeCondition(seededCondition ?? 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 getPreviewMessage = (data: AlertPreviewResponse): string => {
const totalFindings = data.summary.finding_count_total ?? 0;
if (totalFindings === 0) {
return "These filters did not match any findings for the latest scan.";
}
const findingLabel = totalFindings === 1 ? "finding" : "findings";
const topSeverity = data.summary.top_severity;
const severityClause = topSeverity
? `, including ${getPreviewSeverityLabel(topSeverity)} severity`
: "";
return `It found ${formatPreviewNumber(totalFindings)} ${findingLabel}${severityClause}.`;
};
const PreviewSummarySkeleton = () => (
<Card variant="inner" padding="sm">
<CardContent className="flex flex-col gap-2">
<div className="flex items-center justify-between gap-2">
<Skeleton className="h-5 w-28" />
<Skeleton className="h-5 w-20 rounded-full" />
</div>
<Skeleton className="h-4 w-full" />
</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-2">
<span className="text-text-neutral-primary text-sm font-medium">
Test result
</span>
<Badge variant="tag">Error</Badge>
</div>
<p className="text-text-error-primary text-sm">{preview.error}</p>
</CardContent>
</Card>
);
}
const data = preview.data;
if (!data) return null;
return (
<Card variant="inner" padding="sm">
<CardContent className="flex flex-col gap-2">
<span className="text-text-neutral-primary text-sm font-medium">
Test result
</span>
<p className="text-text-neutral-secondary text-sm">
{getPreviewMessage(data)}
</p>
</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(() => {
listAlertRecipients({
"page[size]": "100",
sort: "email",
}).then((result) => {
setLoading(false);
if (result?.error) {
setRecipients([]);
setError(result.error);
return;
}
setRecipients(result.data);
setError(null);
});
});
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-text-error-primary 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,
seededCondition = null,
selectedFindingsFilterChips = [],
defaultName = "Findings filter alert",
onOpenChange,
onSubmit,
}: AlertFormModalProps) => {
const defaults = editingAlert
? getAlertFormDefaults(editingAlert)
: getEmptyAlertFormDefaults(defaultFrequency, seededCondition ?? undefined);
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 [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 = (condition: AlertCondition): AlertFormValues => ({
name,
description,
method: ALERT_NOTIFICATION_METHODS.EMAIL,
frequency,
condition,
recipientEmails: getRecipientEmails(selectedRecipientEmails),
enabled: defaults.enabled,
});
const handlePreview = async () => {
if (!editingAlert) return;
const seedResult = await seedAlertRule(pendingFilters);
if (seedResult?.error) {
setPreview({
status: "error",
error: ALERT_SEED_ERROR,
});
return;
}
const values = buildCurrentValues(seedResult.data.attributes.condition);
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: parsed.data.condition,
});
setPreviewLoading(false);
if (result?.error) {
setPreview({ status: "error", error: result.error });
return;
}
const previewData = result.data.attributes as AlertPreviewResponse;
if (previewData.evaluation_failed) {
setPreview({
status: "error",
error: previewData.last_error ?? "Preview evaluation failed.",
});
return;
}
setPreview({ status: "success", data: previewData });
};
const handleSubmit = async () => {
const seedResult = editingAlert
? await seedAlertRule(pendingFilters)
: null;
if (seedResult?.error) {
setErrors({ root: ALERT_SEED_ERROR });
return;
}
const values = buildCurrentValues(
seedResult?.data.attributes.condition ?? defaults.condition,
);
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);
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}
variant="alerts-edit"
/>
</CardContent>
</Card>
{(previewLoading || preview) && (
<div className="pt-1">
{previewLoading ? (
<PreviewSummarySkeleton />
) : (
preview && <PreviewSummary preview={preview} />
)}
</div>
)}
</div>
)}
{errors.root && (
<div className="text-text-error-primary 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,262 @@
"use client";
import { Info } from "lucide-react";
import Link from "next/link";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useState, useTransition } from "react";
import {
deleteAlert,
disableAlert,
enableAlert,
updateAlert,
} from "@/app/(prowler)/alerts/_actions";
import {
ALERT_TRIGGER_KINDS,
type AlertRule,
} from "@/app/(prowler)/alerts/_types";
import { Button } from "@/components/shadcn";
import { Modal } from "@/components/shadcn/modal";
import { useToast } from "@/components/ui";
import { DOCS_URLS } from "@/lib/external-urls";
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;
}
const ALERTS_FINDINGS_HREF =
"/findings?filter[muted]=false&filter[status__in]=FAIL";
export const AlertsManager = ({
alerts,
meta,
loadError,
providers,
completedScanIds,
scanDetails,
uniqueRegions,
uniqueServices,
uniqueResourceTypes,
uniqueCategories,
uniqueGroups,
initialEditingAlert = null,
}: AlertsManagerProps) => {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
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 replaceEditParam = (alertId: string | null) => {
const params = new URLSearchParams(searchParams.toString());
if (alertId) {
params.set("edit", alertId);
} else {
params.delete("edit");
}
const queryString = params.toString();
router.replace(queryString ? `${pathname}?${queryString}` : pathname, {
scroll: false,
});
};
const closeModal = (open: boolean) => {
setModalOpen(open);
if (!open) {
setEditingRule(null);
replaceEditParam(null);
}
};
const submitAlert = async (
values: AlertFormValues,
): Promise<AlertFormSubmitResult> => {
if (!editingAlert) {
return { ok: false, error: "Create alerts from Findings." };
}
const payload = toAlertPayload(values);
const result = await updateAlert(editingAlert.id, payload);
if (result?.error) return { ok: false, error: result.error };
toast({
title: "Alert updated",
description: result.data.attributes.name,
});
refresh();
return { ok: true, alertId: result.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?.error) {
toast({
variant: "destructive",
title: "Alert update failed",
description: result.error,
});
return;
}
toast({
title: alert.attributes.enabled ? "Alert disabled" : "Alert enabled",
description: result.data.attributes.name,
});
refresh();
};
const confirmDelete = async () => {
if (!pendingDelete) return;
setMutatingId(pendingDelete.id);
const result = await deleteAlert(pendingDelete.id);
setMutatingId(null);
if (result?.error) {
toast({
variant: "destructive",
title: "Alert delete failed",
description: result.error,
});
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">
<div className="text-text-neutral-secondary flex flex-wrap items-center gap-1 text-sm">
<Info className="size-4 shrink-0" />
<span>
Get notified when findings match the conditions you define.
</span>
<span>To create an alert, go to</span>
<Button
variant="link"
size="link-sm"
className="h-auto p-0"
asChild
>
<Link href={ALERTS_FINDINGS_HREF}>Findings</Link>
</Button>
<span>.</span>
<span>Learn more about configuring the Alerts</span>
<Button
variant="link"
size="link-sm"
className="h-auto p-0"
asChild
>
<a
href={DOCS_URLS.ALERTS}
target="_blank"
rel="noopener noreferrer"
>
<span>here.</span>
</a>
</Button>
</div>
</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);
replaceEditParam(alert.id);
}}
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,241 @@
"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 { DateWithTime } from "@/components/ui/entities";
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 AlertActionsItems = ({
alert,
isMutating,
onEdit,
onToggleEnabled,
onDelete,
}: {
alert: AlertRule;
isMutating: boolean;
onEdit: (alert: AlertRule) => void;
onToggleEnabled: (alert: AlertRule) => void;
onDelete: (alert: AlertRule) => void;
}) => {
const enabled = alert.attributes.enabled;
const toggleLabel = enabled ? "Disable" : "Enable";
return (
<>
<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>
</>
);
};
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">
<button
type="button"
className="hover:text-button-tertiary block w-full min-w-0 truncate text-left font-medium transition-colors"
onClick={() => onEdit(alert)}
>
{alert.attributes.name}
</button>
{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: "inserted_at",
size: 170,
minSize: 150,
accessorFn: (alert) => alert.attributes.inserted_at,
header: ({ column }) => (
<DataTableColumnHeader
column={column}
title="Created at"
param="inserted_at"
/>
),
cell: ({ row }) => (
<div className="w-[150px]">
<DateWithTime dateTime={row.original.attributes.inserted_at} />
</div>
),
},
{
id: "updated_at",
size: 170,
minSize: 150,
accessorFn: (alert) => alert.attributes.updated_at,
header: ({ column }) => (
<DataTableColumnHeader
column={column}
title="Updated at"
param="updated_at"
/>
),
cell: ({ row }) => (
<div className="w-[150px]">
<DateWithTime dateTime={row.original.attributes.updated_at} />
</div>
),
},
{
id: "actions",
size: 72,
minSize: 64,
enableSorting: false,
cell: ({ row }) => {
const alert = row.original;
const isMutating = mutatingId === alert.id;
return (
<div className="flex items-center justify-end">
<ActionDropdown ariaLabel={`Actions for ${alert.attributes.name}`}>
<AlertActionsItems
alert={alert}
isMutating={isMutating}
onEdit={onEdit}
onToggleEnabled={onToggleEnabled}
onDelete={onDelete}
/>
</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,260 @@
"use client";
import { BellPlusIcon } from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { createAlert, seedAlertRule } from "@/app/(prowler)/alerts/_actions";
import { AlertFormModal } from "@/app/(prowler)/alerts/_components/alert-form-modal";
import {
getFindingsFiltersFromAlertCondition,
toAlertPayload,
} from "@/app/(prowler)/alerts/_lib/alert-adapter";
import {
ALERT_SEVERITY_VALUES,
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 { CloudFeatureBadgeLink } from "@/components/shared/cloud-feature-badge";
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.";
const ALERT_SEED_ERROR = "Apply at least one alert-compatible Findings filter.";
const NON_FILTER_QUERY_KEYS = new Set(["sort", "page", "pageSize"]);
const ALERT_COMPATIBLE_FILTER_KEYS = new Set([
"filter[provider_type__in]",
"filter[provider_id__in]",
"filter[severity__in]",
"filter[delta]",
"filter[region__in]",
"filter[service__in]",
"filter[resource_type__in]",
"filter[category__in]",
"filter[resource_groups__in]",
"filter[check_id__in]",
"filter[finding_group_id]",
"filter[resource_uid__in]",
]);
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" | "lg";
defaultName?: string;
isCloudEnabled?: boolean;
}
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),
);
const hasFindingFilterValue = (filterBag: AlertsFilterBag): boolean =>
Object.entries(filterBag).some(([rawKey, value]) => {
if (!rawKey.startsWith("filter[") || NON_FILTER_QUERY_KEYS.has(rawKey)) {
return false;
}
const values = Array.isArray(value) ? value : [value];
return values.some((entry) =>
entry
.split(",")
.map((part) => part.trim())
.some(Boolean),
);
});
const hasAlertCompatibleFilterValue = (filterBag: AlertsFilterBag): boolean =>
Object.entries(filterBag).some(([rawKey, value]) => {
if (!ALERT_COMPATIBLE_FILTER_KEYS.has(rawKey)) return false;
const values = Array.isArray(value) ? value : [value];
return values.some((entry) =>
entry
.split(",")
.map((part) => part.trim())
.some(Boolean),
);
});
const withDefaultAlertSeedFilters = (
filterBag: AlertsFilterBag,
): AlertsFilterBag => {
if (hasAlertCompatibleFilterValue(filterBag)) return filterBag;
return {
...filterBag,
"filter[severity__in]": [...ALERT_SEVERITY_VALUES],
};
};
export const SeedFromFindingsButton = ({
filterBag,
providers = [],
scans = [],
uniqueRegions = [],
uniqueServices = [],
uniqueResourceTypes = [],
uniqueCategories = [],
uniqueGroups = [],
className,
size = "lg",
defaultName = "Findings filter alert",
isCloudEnabled = true,
}: SeedFromFindingsButtonProps) => {
const router = useRouter();
const { toast } = useToast();
const [modalOpen, setModalOpen] = useState(false);
const [seeding, setSeeding] = useState(false);
const [seededCondition, setSeededCondition] = useState<AlertCondition | null>(
null,
);
const [selectedFindingsFilterChips, setSelectedFindingsFilterChips] =
useState(() =>
buildFindingsFilterChips(toChipFilterMap(filterBag), {
providers,
scans,
}),
);
const canSeedFromFilters = hasFindingFilterValue(filterBag);
const handleClick = async () => {
if (!isCloudEnabled || !canSeedFromFilters) return;
setSeeding(true);
const result = await seedAlertRule(withDefaultAlertSeedFilters(filterBag));
setSeeding(false);
if (result?.error) {
toast({
variant: "destructive",
title: "Alert seed failed",
description: ALERT_SEED_ERROR,
});
return;
}
const condition = result.data.attributes.condition as AlertCondition;
setSeededCondition(condition);
setSelectedFindingsFilterChips(
buildFindingsFilterChips(
getFindingsFiltersFromAlertCondition(condition),
{ providers, scans },
),
);
setModalOpen(true);
};
const submitAlert = async (
values: AlertFormValues,
): Promise<AlertFormSubmitResult> => {
const result = await createAlert(toAlertPayload(values));
if (result?.error) return { ok: false, error: result.error };
toast({
title: "Alert created",
description: result.data.attributes.name,
action: (
<ToastAction altText="View alerts" asChild>
<Link href="/alerts">View Alerts</Link>
</ToastAction>
),
});
router.refresh();
return { ok: true, alertId: result.data.id };
};
const button = (
<Button
size={size}
variant="default"
onClick={handleClick}
disabled={!isCloudEnabled || !canSeedFromFilters || seeding}
className={className}
>
<BellPlusIcon size={14} />
{seeding ? "Preparing Alert" : "Create Alert"}
</Button>
);
if (isCloudEnabled && canSeedFromFilters) {
return (
<>
{button}
{seededCondition && (
<AlertFormModal
open={modalOpen}
defaultFrequency={ALERT_TRIGGER_KINDS.AFTER_SCAN}
providers={providers}
uniqueRegions={uniqueRegions}
uniqueServices={uniqueServices}
uniqueResourceTypes={uniqueResourceTypes}
uniqueCategories={uniqueCategories}
uniqueGroups={uniqueGroups}
seededCondition={seededCondition}
selectedFindingsFilterChips={selectedFindingsFilterChips}
defaultName={defaultName}
onOpenChange={setModalOpen}
onSubmit={submitAlert}
/>
)}
</>
);
}
if (!isCloudEnabled) {
return (
<span className="relative inline-flex" tabIndex={0}>
{button}
<span className="absolute top-0 right-0 z-10 translate-x-1/3 -translate-y-1/2">
<CloudFeatureBadgeLink />
</span>
</span>
);
}
return (
<Tooltip>
<TooltipTrigger asChild>
<span className="relative inline-flex" tabIndex={0}>
{button}
</span>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-xs">
{DISABLED_FILTER_TOOLTIP}
</TooltipContent>
</Tooltip>
);
};
@@ -0,0 +1,111 @@
import { describe, expect, it } from "vitest";
import {
ALERT_AGGREGATE_OPS,
ALERT_BOOLEAN_OPS,
ALERT_TRIGGER_KINDS,
type AlertCondition,
type AlertRule,
} from "@/app/(prowler)/alerts/_types";
import type { AlertFormValues } from "../../_types/alert-form";
import {
getAlertFormDefaults,
getFindingsFiltersFromAlertCondition,
toAlertPayload,
} from "../alert-adapter";
const condition: AlertCondition = {
op: ALERT_AGGREGATE_OPS.ANY,
filter: { severity: ["critical", "high"] },
};
const baseValues = {
name: " Critical findings ",
description: " Notify security ",
method: "email",
frequency: ALERT_TRIGGER_KINDS.DAILY,
condition,
recipientEmails: [" Security@Example.COM ", "ops@example.com"],
enabled: true,
} satisfies AlertFormValues;
const existingRule = {
id: "alert-1",
type: "alert-rules",
attributes: {
name: "Existing alert",
description: "Existing description",
enabled: false,
trigger: ALERT_TRIGGER_KINDS.BOTH,
condition,
schema_version: 1,
recipient_emails: ["alerts@example.com"],
inserted_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
},
} satisfies AlertRule;
const countFilter = (filter: Record<string, string[]>) => ({
op: ALERT_AGGREGATE_OPS.COUNT_GTE,
filter,
value: 1,
});
describe("simple alert adapter", () => {
it("should map form values to the existing create payload contract without translating filters", () => {
// Given / When
const payload = toAlertPayload(baseValues);
// Then
expect(payload).toEqual({
name: "Critical findings",
description: "Notify security",
enabled: true,
trigger: ALERT_TRIGGER_KINDS.DAILY,
condition,
recipientEmails: ["security@example.com", "ops@example.com"],
});
expect(payload.condition).toBe(condition);
expect(payload).not.toHaveProperty("method");
});
it("should hydrate defaults from an existing alert without reshaping the condition", () => {
// Given / When
const defaults = getAlertFormDefaults(existingRule);
// Then
expect(defaults).toEqual({
name: "Existing alert",
description: "Existing description",
method: "email",
frequency: ALERT_TRIGGER_KINDS.BOTH,
condition,
recipientEmails: ["alerts@example.com"],
enabled: false,
});
});
it("should expose editable alert condition fields as pending Findings filters", () => {
// Given
const editableCondition = {
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"] }),
countFilter({ status: ["FAIL"] }),
],
} satisfies AlertCondition;
// When
const filters = getFindingsFiltersFromAlertCondition(editableCondition);
// 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,123 @@
import type { AlertPayload } from "@/app/(prowler)/alerts/_actions/alerts";
import {
ALERT_AGGREGATE_OPS,
ALERT_SEVERITY_VALUES,
ALERT_TRIGGER_KINDS,
type AlertCondition,
type AlertLeafFilter,
type AlertRule,
} from "@/app/(prowler)/alerts/_types";
import {
ALERT_NOTIFICATION_METHODS,
type AlertFormValues,
} from "../_types/alert-form";
const DEFAULT_CONDITION: AlertCondition = {
op: ALERT_AGGREGATE_OPS.COUNT_GTE,
filter: { severity: [...ALERT_SEVERITY_VALUES] },
value: 1,
};
const normalizeRecipientEmails = (emails: string[]): string[] =>
emails
.map((email) => email.trim().toLowerCase())
.filter((email) => email.length > 0);
export const toAlertPayload = (values: AlertFormValues): AlertPayload => ({
name: values.name.trim(),
description: values.description.trim(),
enabled: values.enabled,
trigger: values.frequency,
condition: values.condition,
recipientEmails: normalizeRecipientEmails(values.recipientEmails),
});
export const getEmptyAlertFormDefaults = (
frequency: AlertFormValues["frequency"] = ALERT_TRIGGER_KINDS.AFTER_SCAN,
condition: AlertCondition = DEFAULT_CONDITION,
): AlertFormValues => ({
name: "",
description: "",
method: ALERT_NOTIFICATION_METHODS.EMAIL,
frequency,
condition,
recipientEmails: [],
enabled: true,
});
export const getAlertFormDefaults = (alert: AlertRule): AlertFormValues => ({
name: alert.attributes.name,
description: alert.attributes.description,
method: ALERT_NOTIFICATION_METHODS.EMAIL,
frequency: alert.attributes.trigger,
condition: alert.attributes.condition,
recipientEmails: alert.attributes.recipient_emails ?? [],
enabled: alert.attributes.enabled,
});
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 uniqueValues = (values: string[]): string[] =>
Array.from(new Set(values));
const addFilterValues = (
filters: Record<string, string[]>,
field: keyof AlertLeafFilter,
value: AlertLeafFilter[keyof AlertLeafFilter],
): Record<string, string[]> => {
const filterKey = SIMPLE_FIELD_TO_FINDINGS_FILTER[field];
if (!filterKey || !Array.isArray(value)) return filters;
filters[filterKey] = uniqueValues([...(filters[filterKey] ?? []), ...value]);
return filters;
};
export const getFindingsFiltersFromAlertCondition = (
condition: AlertCondition,
): Record<string, string[]> => {
if ("filter" in condition) {
return Object.entries(condition.filter).reduce<Record<string, string[]>>(
(filters, [field, value]) =>
addFilterValues(
filters,
field as keyof AlertLeafFilter,
value as AlertLeafFilter[keyof AlertLeafFilter],
),
{},
);
}
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;
},
{},
);
};
@@ -0,0 +1,25 @@
import { z } from "zod";
import {
ALERT_TRIGGER_KIND_VALUES,
type AlertCondition,
} from "@/app/(prowler)/alerts/_types";
import { ALERT_NOTIFICATION_METHODS } from "../_types/alert-form";
const alertConditionSchema = z.custom<AlertCondition>(
(value) => typeof value === "object" && value !== null,
"Alert condition is required.",
);
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),
condition: alertConditionSchema,
recipientEmails: z
.array(z.email({ error: "Enter a valid email address." }))
.default([]),
enabled: z.boolean(),
});
@@ -0,0 +1,27 @@
import type {
AlertCondition,
AlertTriggerKind,
} from "@/app/(prowler)/alerts/_types";
export const ALERT_NOTIFICATION_METHODS = {
EMAIL: "email",
} as const;
export type AlertNotificationMethod =
(typeof ALERT_NOTIFICATION_METHODS)[keyof typeof ALERT_NOTIFICATION_METHODS];
export interface AlertFormValues {
name: string;
description: string;
method: AlertNotificationMethod;
frequency: AlertTriggerKind;
condition: AlertCondition;
recipientEmails: string[];
enabled: boolean;
}
export interface AlertFormSubmitResult {
ok: boolean;
alertId?: string;
error?: string;
}
+216
View File
@@ -0,0 +1,216 @@
import { SEVERITY_LEVELS } 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 const ALERT_AGGREGATE_OPS = {
COUNT_GTE: "count_gte",
COUNT_LTE: "count_lte",
ANY: "any",
NONE: "none",
} as const;
// ---- 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];
// Closed enum for severity, the only filter field whose values are bounded
// and consumed by the seed flow.
export const ALERT_SEVERITY_VALUES = SEVERITY_LEVELS;
// ---- limits --------------------------------------------------------------
export const ALERT_SCHEMA_VERSION = 1 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];
// ---- 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 {
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;
};
}
// ---- seeding payloads ----------------------------------------------------
export type AlertsFilterBag = Record<string, string | string[]>;
+119
View File
@@ -0,0 +1,119 @@
import { redirect } 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 { 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"]>,
): Record<string, string> => {
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: Record<string, string> = {
"page[number]": String(page),
"page[size]": String(pageSize),
sort,
};
if (search) params["filter[search]"] = search;
if (enabledFilter) params["filter[enabled]"] = enabledFilter;
if (triggerFilter) params["filter[trigger]"] = triggerFilter;
return params;
};
export default async function AlertsPage({ searchParams }: AlertsPageProps) {
if (process.env.NEXT_PUBLIC_IS_CLOUD_ENV !== "true") {
redirect("/");
}
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 hasError = result && "error" in result;
const alerts = !hasError ? result.data : [];
const apiMeta = !hasError ? result.meta : undefined;
const loadError = hasError ? result.error : 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 && !("error" in editResult) ? editResult.data : null;
const meta: MetaDataProps | undefined = apiMeta?.pagination
? {
pagination: {
page: apiMeta.pagination.page,
pages: apiMeta.pagination.pages,
count: apiMeta.pagination.count,
},
version: "1",
}
: undefined;
return (
<ContentLayout title="Alerts" icon="lucide:bell-ring">
<AlertsManager
alerts={alerts}
meta={meta}
loadError={loadError}
providers={providersData?.data || []}
completedScanIds={completedScanIds}
scanDetails={scanDetails}
uniqueRegions={uniqueRegions}
uniqueServices={uniqueServices}
uniqueResourceTypes={uniqueResourceTypes}
uniqueCategories={uniqueCategories}
uniqueGroups={uniqueGroups}
initialEditingAlert={editingAlert}
/>
</ContentLayout>
);
}
@@ -3,6 +3,7 @@
import { ColumnDef } from "@tanstack/react-table";
import { Check, Minus } from "lucide-react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useRef } from "react";
import {
RadioGroup,
@@ -243,6 +244,8 @@ export const ScanListTable = ({ scans }: ScanListTableProps) => {
const endIndex = startIndex + pageSize;
const paginatedScans = scans.slice(startIndex, endIndex);
const suppressNextPageResetRef = useRef(false);
const pushWithParams = (nextParams: Record<string, string>) => {
const params = new URLSearchParams(searchParams.toString());
@@ -257,9 +260,18 @@ export const ScanListTable = ({ scans }: ScanListTableProps) => {
pushWithParams({ scanId });
};
const handlePaginationChange = (nextPage: number, nextPageSize: number) => {
const handlePageChange = (page: number) => {
if (suppressNextPageResetRef.current && page === 1) {
suppressNextPageResetRef.current = false;
return;
}
pushWithParams({ scanPage: page.toString() });
};
const handlePageSizeChange = (nextPageSize: number) => {
suppressNextPageResetRef.current = true;
pushWithParams({
scanPage: nextPage.toString(),
scanPage: "1",
scanPageSize: nextPageSize.toString(),
});
};
@@ -276,7 +288,8 @@ export const ScanListTable = ({ scans }: ScanListTableProps) => {
metadata={buildMetadata(scans.length, currentPage, totalPages)}
controlledPage={currentPage}
controlledPageSize={pageSize}
onPaginationChange={handlePaginationChange}
onPageChange={handlePageChange}
onPageSizeChange={handlePageSizeChange}
onRowClick={(row) => {
if (row.original.attributes.graph_data_ready) {
handleSelectScan(row.original.id);
+2 -4
View File
@@ -166,7 +166,6 @@ export default async function Compliance({
>
<SSRComplianceGrid
searchParams={resolvedSearchParams}
scanId={selectedScanId}
selectedScan={selectedScanData}
/>
</Suspense>
@@ -180,13 +179,12 @@ export default async function Compliance({
const SSRComplianceGrid = async ({
searchParams,
scanId,
selectedScan,
}: {
searchParams: SearchParamsProps;
scanId: string | null;
selectedScan?: ScanEntity;
}) => {
const scanId = searchParams.scanId?.toString() || "";
const regionFilter = searchParams["filter[region__in]"]?.toString() || "";
// Only fetch compliance data if we have a valid scanId
@@ -249,7 +247,7 @@ const SSRComplianceGrid = async ({
<ComplianceOverviewPanel>
<ComplianceOverviewGrid
frameworks={frameworks}
scanId={scanId ?? ""}
scanId={scanId}
selectedScan={selectedScan}
latestCisIds={latestCisIds}
/>
+15
View File
@@ -8,6 +8,7 @@ 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 { FindingsFilters } from "@/components/findings/findings-filters";
import {
FindingsGroupTable,
@@ -80,6 +81,7 @@ export default async function Findings({
completedScans || [],
providersData,
) as { [uid: string]: ScanEntity }[];
const alertsEnabled = process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true";
return (
<ContentLayout title="Findings" icon="lucide:tag">
@@ -94,6 +96,19 @@ export default async function Findings({
uniqueResourceTypes={uniqueResourceTypes}
uniqueCategories={uniqueCategories}
uniqueGroups={uniqueGroups}
trailingControls={
<SeedFromFindingsButton
filterBag={filters}
providers={providersData?.data || []}
scans={scanDetails}
uniqueRegions={uniqueRegions}
uniqueServices={uniqueServices}
uniqueResourceTypes={uniqueResourceTypes}
uniqueCategories={uniqueCategories}
uniqueGroups={uniqueGroups}
isCloudEnabled={alertsEnabled}
/>
}
/>
</div>
<Suspense fallback={<SkeletonTableFindings />}>
+1
View File
@@ -54,6 +54,7 @@ const DEFAULT_PERMISSIONS: RolePermissionAttributes = {
manage_scans: false,
manage_integrations: false,
manage_billing: false,
manage_alerts: false,
unlimited_visibility: false,
};
@@ -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">
+194 -96
View File
@@ -1,6 +1,7 @@
"use client";
import { ChevronDown } from "lucide-react";
import type { ReactNode } from "react";
import { useState } from "react";
import { AccountsSelector } from "@/app/(prowler)/_overview/_components/accounts-selector";
@@ -16,7 +17,7 @@ import {
} from "@/components/filters/filter-summary-strip";
import { Button } from "@/components/shadcn";
import { ExpandableSection } from "@/components/ui/expandable-section";
import { DataTableFilterCustom } from "@/components/ui/table";
import { DataTableFilterCustom } from "@/components/ui/table/data-table-filter-custom";
import { useFilterBatch } from "@/hooks/use-filter-batch";
import { getCategoryLabel, getGroupLabel } from "@/lib/categories";
import { FilterType, ScanEntity } from "@/types";
@@ -38,6 +39,23 @@ interface FindingsFiltersProps {
uniqueResourceTypes: string[];
uniqueCategories: string[];
uniqueGroups: string[];
trailingControls?: ReactNode;
variant?: "default" | "alerts-edit";
}
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 +65,11 @@ 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)]";
const FILTER_GRID_ITEM_CLASS = "min-w-0";
export const FindingsFilterBatchControls = ({
providers,
completedScanIds,
scanDetails,
@@ -56,35 +78,36 @@ export const FindingsFilters = ({
uniqueResourceTypes,
uniqueCategories,
uniqueGroups,
}: FindingsFiltersProps) => {
trailingControls,
appliedFilters,
pendingFilters,
changedFilters,
setPending,
applyAll,
discardAll,
clearAndApply,
removeAppliedAndApply,
hasChanges = false,
changeCount = 0,
getFilterValue,
showSummaries = true,
variant = "default",
}: FindingsFilterBatchControlsProps) => {
const [isExpanded, setIsExpanded] = useState(false);
const {
appliedFilters,
pendingFilters,
changedFilters,
setPending,
applyAll,
discardAll,
clearAndApply,
removeAppliedAndApply,
hasChanges,
changeCount,
getFilterValue,
} = useFilterBatch({
defaultParams: { "filter[muted]": "false" },
});
const isAlertsEdit = variant === "alerts-edit";
// Custom filters for the expandable section (removed Provider - now using AccountsSelector)
const customFilters = [
...filterFindings.map((filter) => ({
...filter,
labelFormatter: (value: string) =>
getFindingsFilterDisplayValue(`filter[${filter.key}]`, value, {
providers,
scans: scanDetails,
}),
})),
...filterFindings
.filter((filter) => !isAlertsEdit || filter.key !== FilterType.STATUS)
.map((filter) => ({
...filter,
labelFormatter: (value: string) =>
getFindingsFilterDisplayValue(`filter[${filter.key}]`, value, {
providers,
scans: scanDetails,
}),
})),
{
key: FilterType.REGION,
labelCheckboxGroup: "Regions",
@@ -117,19 +140,27 @@ export const FindingsFilters = ({
labelFormatter: getGroupLabel,
index: 6,
},
{
key: FilterType.SCAN,
labelCheckboxGroup: "Scan ID",
values: completedScanIds,
width: "wide" as const,
valueLabelMapping: scanDetails,
labelFormatter: (value: string) =>
getFindingsFilterDisplayValue(`filter[${FilterType.SCAN}]`, value, {
providers,
scans: scanDetails,
}),
index: 7,
},
...(isAlertsEdit
? []
: [
{
key: FilterType.SCAN,
labelCheckboxGroup: "Scan ID",
values: completedScanIds,
width: "wide" as const,
valueLabelMapping: scanDetails,
labelFormatter: (value: string) =>
getFindingsFilterDisplayValue(
`filter[${FilterType.SCAN}]`,
value,
{
providers,
scans: scanDetails,
},
),
index: 7,
},
]),
];
const hasCustomFilters = customFilters.length > 0;
@@ -172,35 +203,76 @@ export const FindingsFilters = ({
? pendingDateValues[0]
: undefined;
const expandedFilters = hasCustomFilters ? (
<ExpandableSection isExpanded={isExpanded} contentClassName="pt-0">
<DataTableFilterCustom
gridClassName="gap-3"
filters={customFilters}
prependElement={
<CustomDatePicker
onBatchChange={(filterKey, value) =>
setPending(filterKey, value ? [value] : [])
}
value={pendingDateValue}
/>
}
hideClearButton
mode={DATA_TABLE_FILTER_MODE.BATCH}
const providerTypeControl = (className: string) => (
<div className={className}>
<ProviderTypeSelector
providers={providers}
onBatchChange={setPending}
getFilterValue={getFilterValue}
selectedValues={getFilterValue("filter[provider_type__in]")}
/>
</ExpandableSection>
</div>
);
const accountsControl = (className: string) => (
<div className={className}>
<AccountsSelector
providers={providers}
onBatchChange={setPending}
selectedValues={getFilterValue("filter[provider_id__in]")}
selectedProviderTypes={getFilterValue("filter[provider_type__in]")}
/>
</div>
);
const alertEditFilterGrid = hasCustomFilters ? (
<DataTableFilterCustom
gridClassName="w-full gap-3 xl:grid-cols-3 2xl:grid-cols-3"
filters={customFilters}
prependElement={
<>
{providerTypeControl(FILTER_GRID_ITEM_CLASS)}
{accountsControl(FILTER_GRID_ITEM_CLASS)}
</>
}
hideClearButton
mode={DATA_TABLE_FILTER_MODE.BATCH}
onBatchChange={setPending}
getFilterValue={getFilterValue}
/>
) : null;
const expandedFilters =
hasCustomFilters && !isAlertsEdit ? (
<ExpandableSection isExpanded={isExpanded} contentClassName="pt-0">
<DataTableFilterCustom
gridClassName="gap-3"
filters={customFilters}
prependElement={
isAlertsEdit ? undefined : (
<CustomDatePicker
onBatchChange={(filterKey, value) =>
setPending(filterKey, value ? [value] : [])
}
value={pendingDateValue}
/>
)
}
hideClearButton
mode={DATA_TABLE_FILTER_MODE.BATCH}
onBatchChange={setPending}
getFilterValue={getFilterValue}
/>
</ExpandableSection>
) : null;
const appliedSummary = (
<FilterSummaryStrip
chips={appliedFilterChips}
onRemove={removeAppliedAndApply}
onRemove={removeAppliedAndApply ?? (() => undefined)}
trailingContent={
<ClearFiltersButton
showCount
onClear={clearAndApply}
onClear={clearAndApply ?? (() => undefined)}
pendingCount={appliedCount}
/>
}
@@ -215,8 +287,8 @@ export const FindingsFilters = ({
<ApplyFiltersButton
hasChanges={hasChanges}
changeCount={changeCount}
onApply={applyAll}
onDiscard={discardAll}
onApply={applyAll ?? (() => undefined)}
onDiscard={discardAll ?? (() => undefined)}
/>
}
/>
@@ -225,45 +297,71 @@ export const FindingsFilters = ({
return (
<BatchFiltersLayout
testIdPrefix="findings"
controlsClassName="gap-3"
controls={
<>
<div className="min-w-[200px] flex-1 md:max-w-[280px]">
<ProviderTypeSelector
providers={providers}
onBatchChange={setPending}
selectedValues={getFilterValue("filter[provider_type__in]")}
/>
</div>
<div className="min-w-[200px] flex-1 md:max-w-[280px]">
<AccountsSelector
providers={providers}
onBatchChange={setPending}
selectedValues={getFilterValue("filter[provider_id__in]")}
selectedProviderTypes={getFilterValue(
"filter[provider_type__in]",
)}
/>
</div>
{hasCustomFilters && (
<Button
variant="outline"
size="lg"
onClick={() => setIsExpanded(!isExpanded)}
>
{isExpanded ? "Less Filters" : "More Filters"}
<ChevronDown
className={`size-4 transition-transform duration-300 ${isExpanded ? "rotate-180" : "rotate-0"}`}
/>
</Button>
)}
</>
isAlertsEdit ? (
alertEditFilterGrid
) : (
<>
{providerTypeControl(FILTER_CONTROL_COLUMN_CLASS)}
{accountsControl(FILTER_CONTROL_COLUMN_CLASS)}
{hasCustomFilters && (
<Button
variant="outline"
size="lg"
onClick={() => setIsExpanded(!isExpanded)}
>
{isExpanded ? "Less Filters" : "More Filters"}
<ChevronDown
className={`size-4 transition-transform duration-300 ${isExpanded ? "rotate-180" : "rotate-0"}`}
/>
</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;
@@ -0,0 +1,28 @@
import { render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { AwsMethodSelector } from "./aws-method-selector";
describe("AwsMethodSelector", () => {
afterEach(() => {
vi.unstubAllEnvs();
});
it("links the OSS AWS Organizations badge to pricing", () => {
// Given
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false");
// When
render(
<AwsMethodSelector
onSelectSingle={vi.fn()}
onSelectOrganizations={vi.fn()}
/>,
);
// Then
expect(
screen.getByRole("link", { name: /available in prowler cloud/i }),
).toHaveAttribute("href", "https://prowler.com/pricing");
});
});
@@ -3,6 +3,7 @@
import { Ban, Box, Boxes } from "lucide-react";
import { RadioCard } from "@/components/providers/radio-card";
import { CloudFeatureBadgeLink } from "@/components/shared/cloud-feature-badge";
interface AwsMethodSelectorProps {
onSelectSingle: () => void;
@@ -33,29 +34,8 @@ export function AwsMethodSelector({
onClick={onSelectOrganizations}
disabled={!isCloudEnv}
>
{!isCloudEnv && <CtaBadge />}
{!isCloudEnv && <CloudFeatureBadgeLink />}
</RadioCard>
</div>
);
}
function CtaBadge() {
return (
<a
href="https://prowler.com/pricing"
target="_blank"
rel="noopener noreferrer"
className="flex h-[52px] shrink-0 items-center justify-center rounded-lg px-4 py-3 transition-opacity hover:opacity-90"
style={{
backgroundImage:
"linear-gradient(112deg, rgb(46, 229, 155) 3.5%, rgb(98, 223, 240) 98.8%)",
}}
>
<div className="flex items-center gap-1.5">
<span className="text-primary-foreground text-sm leading-6 font-bold">
Available in Prowler Cloud
</span>
</div>
</a>
);
}
@@ -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}
/>
@@ -0,0 +1,102 @@
import { render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { AddRoleForm } from "./add-role-form";
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn() }),
}));
vi.mock("@/actions/roles/roles", () => ({
addRole: vi.fn(),
}));
vi.mock("@/lib", () => ({
cn: (...classes: Array<string | false | null | undefined>) =>
classes.filter(Boolean).join(" "),
getErrorMessage: (error: unknown) => String(error),
permissionFormFields: [
{
field: "manage_users",
label: "Invite and Manage Users",
description:
"Allows inviting new users and managing existing user details",
},
{
field: "manage_account",
label: "Manage Account",
description: "Provides access to account settings and RBAC configuration",
},
{
field: "unlimited_visibility",
label: "Unlimited Visibility",
description:
"Provides complete visibility across all the providers and its related resources",
},
{
field: "manage_providers",
label: "Manage Providers",
description:
"Allows configuration and management of provider connections",
},
{
field: "manage_integrations",
label: "Manage Integrations",
description:
"Allows configuration and management of third-party integrations",
},
{
field: "manage_scans",
label: "Manage Scans",
description: "Allows launching and configuring scans security scans",
},
{
field: "manage_alerts",
label: "Manage Alerts",
description: "Allows creating and managing custom alerts",
},
{
field: "manage_billing",
label: "Manage Billing",
description: "Provides access to billing settings and invoices",
},
],
}));
vi.mock("@/components/shadcn/select/enhanced-multi-select", () => ({
EnhancedMultiSelect: () => <div data-testid="group-select" />,
}));
vi.mock("@/components/ui", () => ({
useToast: () => ({ toast: vi.fn() }),
}));
describe("AddRoleForm", () => {
afterEach(() => {
vi.unstubAllEnvs();
});
it("shows Manage Alerts in Prowler Cloud", () => {
// Given
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true");
// When
render(<AddRoleForm groups={[]} />);
// Then
expect(screen.getByText("Manage Alerts")).toBeInTheDocument();
expect(screen.getByText("Manage Billing")).toBeInTheDocument();
});
it("hides Manage Alerts outside Prowler Cloud", () => {
// Given
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false");
// When
render(<AddRoleForm groups={[]} />);
// Then
expect(screen.queryByText("Manage Alerts")).not.toBeInTheDocument();
expect(screen.queryByText("Manage Billing")).not.toBeInTheDocument();
});
});
@@ -28,6 +28,12 @@ export const AddRoleForm = ({
}) => {
const { toast } = useToast();
const router = useRouter();
const isCloudEnvironment = process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true";
const visiblePermissionFormFields = permissionFormFields.filter(
(permission) =>
!["manage_billing", "manage_alerts"].includes(permission.field) ||
isCloudEnvironment,
);
const form = useForm<FormValues>({
resolver: zodResolver(addRoleFormSchema),
@@ -39,8 +45,9 @@ export const AddRoleForm = ({
manage_scans: false,
unlimited_visibility: false,
groups: [],
...(process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true" && {
...(isCloudEnvironment && {
manage_billing: false,
manage_alerts: false,
}),
},
});
@@ -63,17 +70,8 @@ export const AddRoleForm = ({
const isLoading = form.formState.isSubmitting;
const onSelectAllChange = (checked: boolean) => {
const permissions = [
"manage_users",
"manage_account",
"manage_billing",
"manage_providers",
"manage_integrations",
"manage_scans",
"unlimited_visibility",
];
permissions.forEach((permission) => {
form.setValue(permission as keyof FormValues, checked, {
visiblePermissionFormFields.forEach(({ field }) => {
form.setValue(field as keyof FormValues, checked, {
shouldValidate: true,
shouldDirty: true,
shouldTouch: true,
@@ -95,9 +93,10 @@ export const AddRoleForm = ({
String(values.unlimited_visibility),
);
// Conditionally append manage_account and manage_billing
if (process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true") {
// Conditionally append Prowler Cloud permissions.
if (isCloudEnvironment) {
formData.append("manage_billing", String(values.manage_billing));
formData.append("manage_alerts", String(values.manage_alerts));
}
if (values.groups && values.groups.length > 0) {
@@ -166,7 +165,7 @@ export const AddRoleForm = ({
{/* Select All Checkbox */}
<Checkbox
isSelected={permissionFormFields.every((perm) =>
isSelected={visiblePermissionFormFields.every((perm) =>
form.watch(perm.field as keyof FormValues),
)}
onChange={(e) => onSelectAllChange(e.target.checked)}
@@ -181,13 +180,8 @@ export const AddRoleForm = ({
{/* Permissions Grid */}
<div className="grid grid-cols-2 gap-4">
{permissionFormFields
.filter(
(permission) =>
permission.field !== "manage_billing" ||
process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true",
)
.map(({ field, label, description }) => (
{visiblePermissionFormFields.map(
({ field, label, description }) => (
<div key={field} className="flex items-center gap-2">
<Checkbox
{...form.register(field as keyof FormValues)}
@@ -212,7 +206,8 @@ export const AddRoleForm = ({
</div>
</Tooltip>
</div>
))}
),
)}
</div>
</div>
<Divider className="my-4" />
@@ -41,6 +41,13 @@ export const EditRoleForm = ({
}) => {
const { toast } = useToast();
const router = useRouter();
const isCloudEnvironment = process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true";
const visiblePermissionFormFields = permissionFormFields.filter(
(permission) =>
!["manage_billing", "manage_alerts"].includes(permission.field) ||
isCloudEnvironment,
);
const form = useForm<FormValues>({
resolver: zodResolver(editRoleFormSchema),
defaultValues: {
@@ -69,17 +76,8 @@ export const EditRoleForm = ({
const isLoading = form.formState.isSubmitting;
const onSelectAllChange = (checked: boolean) => {
const permissions = [
"manage_users",
"manage_account",
"manage_billing",
"manage_providers",
"manage_integrations",
"manage_scans",
"unlimited_visibility",
];
permissions.forEach((permission) => {
form.setValue(permission as keyof FormValues, checked, {
visiblePermissionFormFields.forEach(({ field }) => {
form.setValue(field as keyof FormValues, checked, {
shouldValidate: true,
shouldDirty: true,
shouldTouch: true,
@@ -102,8 +100,9 @@ export const EditRoleForm = ({
updatedFields.manage_scans = values.manage_scans;
updatedFields.unlimited_visibility = values.unlimited_visibility;
if (process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true") {
if (isCloudEnvironment) {
updatedFields.manage_billing = values.manage_billing;
updatedFields.manage_alerts = values.manage_alerts;
}
if (
@@ -186,7 +185,7 @@ export const EditRoleForm = ({
{/* Select All Checkbox */}
<Checkbox
isSelected={permissionFormFields.every((perm) =>
isSelected={visiblePermissionFormFields.every((perm) =>
form.watch(perm.field as keyof FormValues),
)}
onChange={(e) => onSelectAllChange(e.target.checked)}
@@ -201,13 +200,8 @@ export const EditRoleForm = ({
{/* Permissions Grid */}
<div className="grid grid-cols-2 gap-4">
{permissionFormFields
.filter(
(permission) =>
permission.field !== "manage_billing" ||
process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true",
)
.map(({ field, label, description }) => (
{visiblePermissionFormFields.map(
({ field, label, description }) => (
<div key={field} className="flex items-center gap-2">
<Checkbox
{...form.register(field as keyof FormValues)}
@@ -232,7 +226,8 @@ export const EditRoleForm = ({
</div>
</Tooltip>
</div>
))}
),
)}
</div>
</div>
<Divider className="my-4" />
+6 -6
View File
@@ -1,6 +1,6 @@
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { ComponentProps } from "react";
import type { ComponentProps } from "react";
import { cn } from "@/lib/utils";
@@ -10,17 +10,17 @@ const buttonVariants = cva(
variants: {
variant: {
default:
"bg-button-primary text-black hover:bg-button-primary-hover active:bg-button-primary-press focus-visible:ring-button-primary/50",
"border border-transparent bg-button-primary text-black hover:bg-button-primary-hover active:bg-button-primary-press focus-visible:ring-button-primary/50",
secondary:
"bg-button-secondary text-white hover:bg-button-secondary/90 active:bg-button-secondary-press focus-visible:ring-button-secondary/50 dark:text-black",
"border border-transparent bg-button-secondary text-white hover:bg-button-secondary/90 active:bg-button-secondary-press focus-visible:ring-button-secondary/50 dark:text-black",
tertiary:
"bg-button-tertiary text-white hover:bg-button-tertiary-hover active:bg-button-tertiary-active focus-visible:ring-button-tertiary/50",
"border border-transparent bg-button-tertiary text-white hover:bg-button-tertiary-hover active:bg-button-tertiary-active focus-visible:ring-button-tertiary/50",
destructive:
"bg-bg-fail text-white hover:bg-bg-fail/90 active:bg-bg-fail/80 focus-visible:ring-bg-fail/50",
"border border-transparent bg-bg-fail text-white hover:bg-bg-fail/90 active:bg-bg-fail/80 focus-visible:ring-bg-fail/50",
outline:
"border border-border-neutral-secondary bg-bg-neutral-secondary hover:bg-bg-neutral-tertiary active:bg-border-neutral-tertiary text-text-neutral-primary focus-visible:ring-border-neutral-tertiary/50",
ghost:
"text-text-neutral-primary hover:bg-bg-neutral-tertiary active:bg-border-neutral-secondary focus-visible:ring-border-neutral-secondary/50",
"border border-transparent text-text-neutral-primary hover:bg-bg-neutral-tertiary active:bg-border-neutral-secondary focus-visible:ring-border-neutral-secondary/50",
link: "text-button-tertiary underline-offset-4 hover:text-button-tertiary-hover disabled:bg-transparent",
// Menu variant like secondary but more padding and the back is almost transparent
menu: "backdrop-blur-xl bg-white/60 dark:bg-white/5 border border-white/80 dark:border-white/10 text-text-neutral-primary dark:text-white shadow-lg hover:bg-white/70 dark:hover:bg-white/10 hover:border-white/90 dark:hover:border-white/30 active:bg-white/80 dark:active:bg-white/15 active:scale-[0.98] focus-visible:ring-button-primary/50 transition-all duration-200",
+36
View File
@@ -0,0 +1,36 @@
import { cn } from "@/lib/utils";
function Field({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="field"
className={cn("flex flex-col gap-1.5", className)}
{...props}
/>
);
}
function FieldLabel({ className, ...props }: React.ComponentProps<"label">) {
return (
<label
data-slot="field-label"
className={cn(
"text-text-neutral-tertiary text-xs font-light tracking-tight",
className,
)}
{...props}
/>
);
}
function FieldError({ className, ...props }: React.ComponentProps<"p">) {
return (
<p
data-slot="field-error"
className={cn("text-text-error-primary max-w-full text-xs", className)}
{...props}
/>
);
}
export { Field, FieldError, FieldLabel };
+2
View File
@@ -9,10 +9,12 @@ export * from "./checkbox/checkbox";
export * from "./combobox";
export * from "./drawer";
export * from "./dropdown/dropdown";
export * from "./field/field";
export * from "./info-field";
export * from "./input/input";
export * from "./progress";
export * from "./search-input/search-input";
export * from "./section/section";
export * from "./select/multiselect";
export * from "./select/select";
export * from "./separator/separator";
+1 -1
View File
@@ -11,7 +11,7 @@ const inputVariants = cva(
variants: {
variant: {
default:
"border-border-input-primary bg-bg-input-primary dark:bg-input/30 hover:bg-bg-neutral-secondary dark:hover:bg-input/50 focus:border-border-input-primary-press focus:ring-1 focus:ring-inset focus:ring-border-input-primary-press placeholder:text-text-neutral-tertiary",
"border-border-input-primary bg-bg-input-primary dark:bg-input/30 hover:bg-bg-neutral-tertiary dark:hover:bg-input/50 focus:border-border-input-primary-press focus:ring-1 focus:ring-inset focus:ring-border-input-primary-press placeholder:text-text-neutral-tertiary",
ghost:
"border-transparent bg-transparent hover:bg-bg-neutral-tertiary focus:bg-bg-neutral-tertiary placeholder:text-text-neutral-tertiary",
},
+65
View File
@@ -0,0 +1,65 @@
import { cn } from "@/lib/utils";
function Section({ className, ...props }: React.ComponentProps<"section">) {
return (
<section
data-slot="section"
className={cn("flex flex-col gap-3", className)}
{...props}
/>
);
}
function SectionHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="section-header"
className={cn("flex flex-col gap-1", className)}
{...props}
/>
);
}
function SectionTitle({ className, ...props }: React.ComponentProps<"h3">) {
return (
<h3
data-slot="section-title"
className={cn(
"text-md text-default-foreground leading-9 font-bold",
className,
)}
{...props}
/>
);
}
function SectionDescription({
className,
...props
}: React.ComponentProps<"p">) {
return (
<p
data-slot="section-description"
className={cn("text-default-500 text-sm", className)}
{...props}
/>
);
}
function SectionContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="section-content"
className={cn("flex flex-col gap-3", className)}
{...props}
/>
);
}
export {
Section,
SectionContent,
SectionDescription,
SectionHeader,
SectionTitle,
};
@@ -6,6 +6,7 @@ import {
MultiSelect,
MultiSelectContent,
MultiSelectItem,
MultiSelectSelectAll,
MultiSelectTrigger,
MultiSelectValue,
} from "./multiselect";
@@ -180,6 +181,39 @@ describe("MultiSelect", () => {
expect(screen.getByPlaceholderText("Search accounts...")).toHaveValue("");
});
it("closes the dropdown when clicking outside", async () => {
// Given
const user = userEvent.setup();
render(
<div>
<MultiSelect values={[]} onValuesChange={() => {}}>
<MultiSelectTrigger>
<MultiSelectValue placeholder="Select accounts" />
</MultiSelectTrigger>
<MultiSelectContent
search={{
placeholder: "Search accounts...",
emptyMessage: "No accounts found.",
}}
>
<MultiSelectItem value="aws-prod">Production AWS</MultiSelectItem>
</MultiSelectContent>
</MultiSelect>
<button type="button">Outside target</button>
</div>,
);
// When
await user.click(screen.getByRole("combobox"));
expect(screen.getByPlaceholderText("Search accounts...")).toBeVisible();
await user.click(screen.getByRole("button", { name: /outside target/i }));
// Then
expect(
screen.queryByPlaceholderText("Search accounts..."),
).not.toBeInTheDocument();
});
it("uses a normalized dropdown width instead of growing with the longest item", async () => {
const user = userEvent.setup();
@@ -204,4 +238,79 @@ describe("MultiSelect", () => {
);
expect(screen.getByRole("dialog")).toHaveClass("max-w-[24rem]");
});
it("keeps the legacy clear-all behavior by default", async () => {
const user = userEvent.setup();
const onValuesChange = vi.fn();
render(
<MultiSelect values={["aws-prod"]} onValuesChange={onValuesChange}>
<MultiSelectTrigger>
<MultiSelectValue placeholder="Select accounts" />
</MultiSelectTrigger>
<MultiSelectContent search={false}>
<MultiSelectSelectAll>Select All</MultiSelectSelectAll>
<MultiSelectItem value="aws-prod">Production AWS</MultiSelectItem>
<MultiSelectItem value="azure-dev">Development Azure</MultiSelectItem>
</MultiSelectContent>
</MultiSelect>,
);
await user.click(screen.getByRole("combobox"));
await user.click(screen.getByRole("button", { name: /select all/i }));
expect(onValuesChange).toHaveBeenCalledWith([]);
});
it("disables the legacy select all action when no filter is selected", async () => {
const user = userEvent.setup();
const onValuesChange = vi.fn();
render(
<MultiSelect values={[]} onValuesChange={onValuesChange}>
<MultiSelectTrigger>
<MultiSelectValue placeholder="Select accounts" />
</MultiSelectTrigger>
<MultiSelectContent search={false}>
<MultiSelectSelectAll>Select All</MultiSelectSelectAll>
<MultiSelectItem value="aws-prod">Production AWS</MultiSelectItem>
<MultiSelectItem value="azure-dev">Development Azure</MultiSelectItem>
</MultiSelectContent>
</MultiSelect>,
);
await user.click(screen.getByRole("combobox"));
expect(
screen.getByRole("button", { name: /all selected/i }),
).toBeDisabled();
});
it("selects every provided option when select mode is enabled", async () => {
const user = userEvent.setup();
const onValuesChange = vi.fn();
render(
<MultiSelect values={[]} onValuesChange={onValuesChange}>
<MultiSelectTrigger>
<MultiSelectValue placeholder="Select accounts" />
</MultiSelectTrigger>
<MultiSelectContent search={false}>
<MultiSelectSelectAll
mode="select"
values={["aws-prod", "azure-dev"]}
>
Select All
</MultiSelectSelectAll>
<MultiSelectItem value="aws-prod">Production AWS</MultiSelectItem>
<MultiSelectItem value="azure-dev">Development Azure</MultiSelectItem>
</MultiSelectContent>
</MultiSelect>,
);
await user.click(screen.getByRole("combobox"));
await user.click(screen.getByRole("button", { name: /select all/i }));
expect(onValuesChange).toHaveBeenCalledWith(["aws-prod", "azure-dev"]);
});
});
+62 -9
View File
@@ -42,6 +42,7 @@ type MultiSelectContextType = {
setOpen: (open: boolean) => void;
selectedValues: Set<string>;
toggleValue: (value: string) => void;
setValues: (values: string[]) => void;
items: Map<string, ReactNode>;
onItemAdded: (value: string, label: ReactNode) => void;
onValuesChange?: (values: string[]) => void;
@@ -53,19 +54,31 @@ export function MultiSelect({
values,
defaultValues,
onValuesChange,
open: controlledOpen,
onOpenChange,
}: {
children: ReactNode;
values?: string[];
defaultValues?: string[];
onValuesChange?: (values: string[]) => void;
open?: boolean;
onOpenChange?: (open: boolean) => void;
}) {
const [open, setOpen] = useState(false);
const [internalOpen, setInternalOpen] = useState(false);
const [internalValues, setInternalValues] = useState(
new Set<string>(values ?? defaultValues),
);
const open = controlledOpen ?? internalOpen;
const selectedValues = values ? new Set(values) : internalValues;
const [items, setItems] = useState<Map<string, ReactNode>>(new Map());
function setOpen(nextOpen: boolean) {
if (controlledOpen === undefined) {
setInternalOpen(nextOpen);
}
onOpenChange?.(nextOpen);
}
function toggleValue(value: string) {
const getNewSet = (prev: Set<string>) => {
const newSet = new Set(prev);
@@ -80,6 +93,12 @@ export function MultiSelect({
onValuesChange?.(Array.from(getNewSet(selectedValues)));
}
function setValues(nextValues: string[]) {
const nextSet = new Set(nextValues);
setInternalValues(nextSet);
onValuesChange?.(Array.from(nextSet));
}
const onItemAdded = useCallback((value: string, label: ReactNode) => {
setItems((prev) => {
if (prev.get(value) === label) return prev;
@@ -94,12 +113,13 @@ export function MultiSelect({
setOpen,
selectedValues,
toggleValue,
setValues,
items,
onItemAdded,
onValuesChange,
}}
>
<Popover open={open} onOpenChange={setOpen} modal={true}>
<Popover open={open} onOpenChange={setOpen} modal={false}>
{children}
</Popover>
</MultiSelectContext>
@@ -426,11 +446,16 @@ export function MultiSelectSeparator({
export function MultiSelectSelectAll({
className,
children = "Select All",
mode = "clear",
values,
...props
}: Omit<ComponentPropsWithoutRef<"button">, "children"> & {
children?: ReactNode;
mode?: "clear" | "select";
values?: string[];
}) {
const { selectedValues, onValuesChange } = useMultiSelectContext();
const { items, selectedValues, setValues, onValuesChange } =
useMultiSelectContext();
if (!onValuesChange) {
return null;
@@ -438,12 +463,39 @@ export function MultiSelectSelectAll({
const hasSelections = selectedValues.size > 0;
if (!hasSelections) {
return null;
if (mode === "clear") {
const handleClearAll = () => {
onValuesChange?.([]);
};
const label = hasSelections ? children : "All selected";
return (
<button
type="button"
data-slot="multiselect-select-all"
className={cn(
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-bg-button-secondary text-bg-button-secondary flex w-full cursor-pointer items-center justify-between gap-3 rounded-lg px-4 py-3 text-sm outline-hidden select-none hover:bg-slate-200 dark:hover:bg-slate-700/50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-5",
hasSelections && "text-destructive hover:text-destructive",
!hasSelections && "cursor-not-allowed opacity-50",
"font-semibold",
className,
)}
disabled={!hasSelections}
onClick={handleClearAll}
{...props}
>
<span className="flex min-w-0 flex-1 items-center gap-2">{label}</span>
</button>
);
}
const handleClearAll = () => {
onValuesChange?.([]);
const itemValues = values ?? Array.from(items.keys());
const hasItems = itemValues.length > 0;
const allSelected =
hasItems && itemValues.every((value) => selectedValues.has(value));
const handleSelectAll = () => {
setValues(itemValues);
};
return (
@@ -452,11 +504,12 @@ export function MultiSelectSelectAll({
data-slot="multiselect-select-all"
className={cn(
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-bg-button-secondary text-bg-button-secondary flex w-full cursor-pointer items-center justify-between gap-3 rounded-lg px-4 py-3 text-sm outline-hidden select-none hover:bg-slate-200 dark:hover:bg-slate-700/50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-5",
hasSelections && "text-destructive hover:text-destructive",
allSelected && "cursor-not-allowed opacity-50",
"font-semibold",
className,
)}
onClick={handleClearAll}
disabled={!hasItems || allSelected}
onClick={handleSelectAll}
{...props}
>
<span className="flex min-w-0 flex-1 items-center gap-2">{children}</span>
+12 -3
View File
@@ -58,7 +58,7 @@ function SelectTrigger({
data-slot="select-trigger"
data-size={size}
className={cn(
"border-border-input-primary bg-bg-input-primary text-bg-button-secondary data-[placeholder]:text-bg-button-secondary [&_svg:not([class*='text-'])]:text-bg-button-secondary aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 focus-visible:border-border-input-primary-press focus-visible:ring-border-input-primary-press flex w-full items-center justify-between gap-2 rounded-lg border px-4 py-3 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-1 focus-visible:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-[52px] data-[size=sm]:h-10 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 dark:focus-visible:ring-slate-400 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-6",
"group border-border-input-primary bg-bg-input-primary text-bg-button-secondary data-[placeholder]:text-bg-button-secondary [&_svg:not([class*='text-'])]:text-bg-button-secondary aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive hover:bg-bg-neutral-tertiary active:bg-border-neutral-tertiary dark:bg-input/30 dark:hover:bg-input/50 focus-visible:border-border-input-primary-press focus-visible:ring-border-input-primary-press flex w-full items-center justify-between gap-2 overflow-hidden rounded-lg border px-4 py-3 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-1 focus-visible:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-50 has-[>svg]:px-3 data-[size=default]:h-[52px] data-[size=sm]:h-10 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 dark:focus-visible:ring-slate-400 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-6",
className,
)}
{...props}
@@ -67,7 +67,7 @@ function SelectTrigger({
<SelectPrimitive.Icon asChild>
<ChevronDownIcon
className={cn(
"text-bg-button-secondary",
"text-bg-button-secondary shrink-0 opacity-70 transition-transform duration-200 group-data-[state=open]:rotate-180",
iconSize === "sm" ? "size-4" : "size-6",
)}
aria-hidden="true"
@@ -82,8 +82,16 @@ function SelectContent({
children,
position = "popper",
align = "start",
width = "default",
...props
}: ComponentProps<typeof SelectPrimitive.Content>) {
}: ComponentProps<typeof SelectPrimitive.Content> & {
width?: "default" | "wide";
}) {
const widthClasses =
width === "wide"
? "w-[min(max(var(--radix-select-trigger-width),24rem),calc(100vw-2rem))] max-w-[32rem]"
: undefined;
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
@@ -92,6 +100,7 @@ function SelectContent({
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 border-border-input-primary bg-bg-input-primary relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-lg border",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
widthClasses,
className,
)}
position={position}
+1 -1
View File
@@ -11,7 +11,7 @@ const textareaVariants = cva(
variants: {
variant: {
default:
"border-border-input-primary bg-bg-input-primary dark:bg-input/30 hover:bg-bg-neutral-secondary dark:hover:bg-input/50 focus:border-border-input-primary-press focus:ring-1 focus:ring-inset focus:ring-border-input-primary-press placeholder:text-text-neutral-tertiary",
"border-border-input-primary bg-bg-input-primary dark:bg-input/30 hover:bg-bg-neutral-tertiary dark:hover:bg-input/50 focus:border-border-input-primary-press focus:ring-1 focus:ring-inset focus:ring-border-input-primary-press placeholder:text-text-neutral-tertiary",
ghost:
"border-transparent bg-transparent hover:bg-bg-neutral-tertiary focus:bg-bg-neutral-tertiary placeholder:text-text-neutral-tertiary",
},
@@ -0,0 +1,90 @@
import type { CSSProperties } from "react";
import { cn } from "@/lib/utils";
interface MenuFeatureBadgeProps {
label?: string;
variant?: "cloud" | "new";
size?: "default" | "sm";
className?: string;
}
const FEATURE_BADGE_STYLE: Record<
NonNullable<MenuFeatureBadgeProps["variant"]>,
CSSProperties | undefined
> = {
cloud: {
backgroundImage:
"linear-gradient(112deg, rgb(46, 229, 155) 3.5%, rgb(98, 223, 240) 98.8%)",
},
new: undefined,
};
const FEATURE_BADGE_VARIANT_CLASS: Record<
NonNullable<MenuFeatureBadgeProps["variant"]>,
string
> = {
cloud: "text-primary-foreground",
new: "bg-emerald-500 text-white",
};
const FEATURE_BADGE_SIZE_CLASS: Record<
NonNullable<MenuFeatureBadgeProps["size"]>,
string
> = {
default: "h-6 rounded-lg px-2 text-xs leading-5",
sm: "h-5 rounded-md px-1.5 text-[10px] leading-4",
};
export const MenuFeatureBadge = ({
label,
variant = "cloud",
size = "default",
className,
}: MenuFeatureBadgeProps) => (
<span
className={cn(
"inline-flex shrink-0 items-center justify-center font-bold whitespace-nowrap",
FEATURE_BADGE_VARIANT_CLASS[variant],
FEATURE_BADGE_SIZE_CLASS[size],
className,
)}
style={FEATURE_BADGE_STYLE[variant]}
>
{label}
</span>
);
export const CloudFeatureBadge = ({
label = "Available in Prowler Cloud",
size,
className,
}: Omit<MenuFeatureBadgeProps, "variant">) => (
<MenuFeatureBadge
label={label}
variant="cloud"
size={size}
className={className}
/>
);
interface CloudFeatureBadgeLinkProps
extends Omit<MenuFeatureBadgeProps, "variant"> {
href?: string;
}
export const CloudFeatureBadgeLink = ({
href = "https://prowler.com/pricing",
label,
size,
className,
}: CloudFeatureBadgeLinkProps) => (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="shrink-0 whitespace-nowrap transition-opacity hover:opacity-90"
>
<CloudFeatureBadge label={label} size={size} className={className} />
</a>
);
@@ -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",
+7 -3
View File
@@ -9,6 +9,7 @@ import {
TooltipContent,
TooltipTrigger,
} from "@/components/shadcn/tooltip";
import { MenuFeatureBadge } from "@/components/shared/cloud-feature-badge";
import { cn } from "@/lib/utils";
import { IconComponent } from "@/types";
@@ -61,9 +62,12 @@ export const MenuItem = ({
<p className="flex max-w-[200px] 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>
<MenuFeatureBadge
label="New"
variant="new"
size="sm"
className="ml-2"
/>
)}
</p>
)}
@@ -0,0 +1,46 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { SubmenuItem } from "./submenu-item";
vi.mock("next/navigation", () => ({
usePathname: () => "/",
}));
const TestIcon = ({ size = 16 }: { size?: number }) => (
<svg aria-hidden="true" height={size} width={size} />
);
describe("SubmenuItem", () => {
it("should show the cloud-only tooltip for disabled cloud menu items", async () => {
// Given
const user = userEvent.setup();
render(
<SubmenuItem
href="/alerts"
label="Alerts"
icon={TestIcon}
disabled
highlight
cloudOnly
/>,
);
// When
const button = screen.getByRole("button", { name: /alerts/i });
expect(button).toHaveAttribute("aria-disabled", "true");
expect(button).toHaveClass(
"cursor-not-allowed",
"text-text-neutral-tertiary",
);
await user.hover(button.parentElement as HTMLElement);
// Then
expect(screen.getByText("New")).toHaveClass("h-5", "text-[10px]");
expect(screen.queryByText("Cloud")).not.toBeInTheDocument();
expect(
await screen.findAllByText("Available in Prowler Cloud"),
).not.toHaveLength(0);
});
});
+52 -1
View File
@@ -10,6 +10,7 @@ import {
TooltipContent,
TooltipTrigger,
} from "@/components/shadcn/tooltip";
import { MenuFeatureBadge } from "@/components/shared/cloud-feature-badge";
import { IconComponent } from "@/types";
interface SubmenuItemProps {
@@ -19,6 +20,8 @@ interface SubmenuItemProps {
active?: boolean;
target?: string;
disabled?: boolean;
highlight?: boolean;
cloudOnly?: boolean;
onClick?: (event: MouseEvent<HTMLAnchorElement>) => void;
}
@@ -29,6 +32,8 @@ export const SubmenuItem = ({
active,
target,
disabled,
highlight,
cloudOnly,
onClick,
}: SubmenuItemProps) => {
const pathname = usePathname();
@@ -56,6 +61,42 @@ export const SubmenuItem = ({
);
}
if (disabled) {
const tooltip = cloudOnly
? "Available in Prowler Cloud"
: `${label} is unavailable.`;
return (
<Tooltip>
<TooltipTrigger asChild>
<span
className="group mt-1 inline-flex w-[calc(100%-12px)]"
tabIndex={0}
>
<Button
variant="menu-inactive"
className="text-text-neutral-tertiary w-full cursor-not-allowed justify-start py-1"
aria-disabled="true"
tabIndex={-1}
type="button"
>
<span className="mr-2">
<Icon size={16} />
</span>
<p className="flex max-w-[170px] items-center gap-2 truncate">
<span>{label}</span>
{highlight && (
<MenuFeatureBadge label="New" variant="new" size="sm" />
)}
</p>
</Button>
</span>
</TooltipTrigger>
<TooltipContent side="right">{tooltip}</TooltipContent>
</Tooltip>
);
}
return (
<Button
variant={isActive ? "menu-active" : "menu-inactive"}
@@ -72,7 +113,17 @@ 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 && (
<MenuFeatureBadge
label="New"
variant="new"
size="sm"
className="ml-2"
/>
)}
</p>
</Link>
</Button>
);
@@ -1,6 +1,7 @@
"use client";
import { useSearchParams } from "next/navigation";
import { useState } from "react";
import { ComplianceScanInfo } from "@/components/compliance/compliance-header/compliance-scan-info";
import { ClearFiltersButton } from "@/components/filters/clear-filters-button";
@@ -73,6 +74,7 @@ export const DataTableFilterCustom = ({
}: DataTableFilterCustomProps) => {
const { updateFilter } = useUrlFilters();
const searchParams = useSearchParams();
const [openFilterKey, setOpenFilterKey] = useState<string | null>(null);
const buildSearchConfig = (filter: FilterOption) => {
const label = filter.labelCheckboxGroup.toLowerCase();
@@ -235,6 +237,8 @@ export const DataTableFilterCustom = ({
return (
<MultiSelect
key={filter.key}
open={openFilterKey === filter.key}
onOpenChange={(open) => setOpenFilterKey(open ? filter.key : null)}
values={selectedValues}
onValuesChange={(values) => pushDropdownFilter(filter, values)}
>
@@ -28,13 +28,14 @@ interface DataTablePaginationProps {
paramPrefix?: string;
/*
* Controlled mode: receive all three props together (parent contract is
* enforced at the DataTable boundary). Useful for tables in drawers/modals
* to avoid triggering page re-renders when paginating.
* Controlled mode: Use these props to manage pagination via React state
* instead of URL params. Useful for tables in drawers/modals to avoid
* triggering page re-renders when paginating.
*/
controlledPage?: number;
controlledPageSize?: number;
onPaginationChange?: (page: number, pageSize: number) => void;
onPageChange?: (page: number) => void;
onPageSizeChange?: (pageSize: number) => void;
}
const NAV_BUTTON_STYLES = {
@@ -50,26 +51,22 @@ export function DataTablePagination({
paramPrefix = "",
controlledPage,
controlledPageSize,
onPaginationChange,
onPageChange,
onPageSizeChange,
}: DataTablePaginationProps) {
const pathname = usePathname();
const searchParams = useSearchParams();
const router = useRouter();
// Determine if we're in controlled mode. The discriminated union on
// `DataTable`'s ControlledPaginationProps guarantees `controlledPageSize`
// is defined here whenever the other two are.
const isControlled =
controlledPage !== undefined &&
controlledPageSize !== undefined &&
onPaginationChange !== undefined;
// Determine if we're in controlled mode
const isControlled = controlledPage !== undefined && onPageChange;
// Determine param names based on prefix
const pageParam = paramPrefix ? `${paramPrefix}Page` : "page";
const pageSizeParam = paramPrefix ? `${paramPrefix}PageSize` : "pageSize";
const initialPageSize = isControlled
? String(controlledPageSize)
? String(controlledPageSize ?? 10)
: (searchParams.get(pageSizeParam) ?? "10");
const [selectedPageSize, setSelectedPageSize] = useState(initialPageSize);
@@ -115,7 +112,7 @@ export function DataTablePagination({
// Handle page navigation for controlled mode
const handlePageChange = (pageNumber: number) => {
if (isControlled) {
onPaginationChange(pageNumber, controlledPageSize);
onPageChange(pageNumber);
} else {
const url = createPageUrl(pageNumber);
if (disableScroll) {
@@ -144,7 +141,8 @@ export function DataTablePagination({
setSelectedPageSize(value);
if (isControlled) {
onPaginationChange(1, parseInt(value, 10));
onPageSizeChange?.(parseInt(value, 10));
onPageChange(1); // Reset to first page
return;
}
+32 -22
View File
@@ -41,25 +41,7 @@ import { FilterOption, MetaDataProps } from "@/types";
*/
const DEFAULT_COLUMN_SIZE = 150;
/*
* Controlled pagination: pass all three props together or none. Modeled as a
* discriminated union so TypeScript prevents passing `onPaginationChange`
* without `controlledPageSize`, which would otherwise silently emit a default
* page size on every navigation.
*/
type ControlledPaginationProps =
| {
controlledPage: number;
controlledPageSize: number;
onPaginationChange: (page: number, pageSize: number) => void;
}
| {
controlledPage?: undefined;
controlledPageSize?: undefined;
onPaginationChange?: undefined;
};
type DataTableProviderProps<TData, TValue> = {
interface DataTableProviderProps<TData, TValue> {
columns: ColumnDef<TData, TValue>[];
data: TData[];
metadata?: MetaDataProps;
@@ -85,6 +67,28 @@ type DataTableProviderProps<TData, TValue> = {
/** Prefix for URL params to avoid conflicts (e.g., "findings" -> "findingsPage") */
paramPrefix?: string;
/*
* Controlled Mode Props
* ---------------------
* By default, DataTable uses URL params for pagination/search (via paramPrefix).
* This causes Next.js page re-renders on every interaction.
*
* For tables inside drawers/modals, use controlled mode instead:
* - Pass controlledPage, controlledPageSize, controlledSearch as state values
* - Pass onPageChange, onPageSizeChange, onSearchChange as state setters
* - This keeps state local, avoiding URL changes and unnecessary page re-renders
*
* Example:
* const [page, setPage] = useState(1);
* const [search, setSearch] = useState("");
* <DataTable
* controlledPage={page}
* onPageChange={setPage}
* controlledSearch={search}
* onSearchChange={setSearch}
* isLoading={isLoading}
* />
*/
controlledSearch?: string;
onSearchChange?: (value: string) => void;
/**
@@ -92,6 +96,10 @@ type DataTableProviderProps<TData, TValue> = {
* Use this alongside onSearchChange to implement "search on Enter" behavior.
*/
onSearchCommit?: (value: string) => void;
controlledPage?: number;
controlledPageSize?: number;
onPageChange?: (page: number) => void;
onPageSizeChange?: (pageSize: number) => void;
/** Show loading state with opacity overlay (for controlled mode) */
isLoading?: boolean;
/** Custom placeholder text for the search input */
@@ -106,7 +114,7 @@ type DataTableProviderProps<TData, TValue> = {
header?: ReactNode;
/** Optional content rendered in the toolbar before the total entries count. */
toolbarRightContent?: ReactNode;
} & ControlledPaginationProps;
}
export function DataTable<TData, TValue>({
columns,
@@ -129,7 +137,8 @@ export function DataTable<TData, TValue>({
onSearchCommit,
controlledPage,
controlledPageSize,
onPaginationChange,
onPageChange,
onPageSizeChange,
isLoading = false,
searchPlaceholder,
renderAfterRow,
@@ -344,7 +353,8 @@ export function DataTable<TData, TValue>({
paramPrefix={paramPrefix}
controlledPage={controlledPage}
controlledPageSize={controlledPageSize}
onPaginationChange={onPaginationChange}
onPageChange={onPageChange}
onPageSizeChange={onPageSizeChange}
/>
)}
</div>
+1 -1
View File
@@ -63,7 +63,7 @@ const ToastAction = React.forwardRef<
<ToastPrimitives.Action
ref={ref}
className={cn(
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border border-slate-200 bg-transparent px-3 text-sm font-medium transition-colors group-[.destructive]:border-slate-100/40 hover:bg-slate-100 group-[.destructive]:hover:border-red-500/30 group-[.destructive]:hover:bg-red-500 group-[.destructive]:hover:text-slate-50 focus:ring-1 focus:ring-slate-950 focus:outline-none group-[.destructive]:focus:ring-red-500 disabled:pointer-events-none disabled:opacity-50 dark:border-slate-800 dark:group-[.destructive]:border-slate-800/40 dark:hover:bg-slate-800 dark:group-[.destructive]:hover:border-red-900/30 dark:group-[.destructive]:hover:bg-red-900 dark:group-[.destructive]:hover:text-slate-50 dark:focus:ring-slate-300 dark:group-[.destructive]:focus:ring-red-900",
"inline-flex h-8 shrink-0 items-center justify-center self-end rounded-md border border-slate-200 bg-transparent px-3 text-sm font-medium transition-colors group-[.destructive]:border-slate-100/40 hover:bg-slate-100 group-[.destructive]:hover:border-red-500/30 group-[.destructive]:hover:bg-red-500 group-[.destructive]:hover:text-slate-50 focus:ring-1 focus:ring-slate-950 focus:outline-none group-[.destructive]:focus:ring-red-500 disabled:pointer-events-none disabled:opacity-50 dark:border-slate-800 dark:group-[.destructive]:border-slate-800/40 dark:hover:bg-slate-800 dark:group-[.destructive]:hover:border-red-900/30 dark:group-[.destructive]:hover:bg-red-900 dark:group-[.destructive]:hover:text-slate-50 dark:focus:ring-slate-300 dark:group-[.destructive]:focus:ring-red-900",
className,
)}
{...props}
@@ -0,0 +1,55 @@
import { render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { RoleData, RoleDetail } from "@/types/users";
import { RoleItem } from "./role-item";
const role = {
id: "role-1",
type: "roles",
} satisfies RoleData;
const roleDetail = {
id: "role-1",
type: "roles",
attributes: {
name: "Cloud admin",
manage_users: false,
manage_account: false,
manage_providers: false,
manage_scans: false,
manage_integrations: false,
manage_billing: false,
manage_alerts: true,
unlimited_visibility: false,
},
} satisfies RoleDetail;
describe("RoleItem", () => {
afterEach(() => {
vi.unstubAllEnvs();
});
it("shows Manage Alerts in Prowler Cloud role details", () => {
// Given
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true");
// When
render(<RoleItem role={role} roleDetail={roleDetail} />);
// Then
expect(screen.getByText("Manage Alerts")).toBeInTheDocument();
});
it("hides Manage Alerts outside Prowler Cloud role details", () => {
// Given
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false");
// When
render(<RoleItem role={role} roleDetail={roleDetail} />);
// Then
expect(screen.queryByText("Manage Alerts")).not.toBeInTheDocument();
});
});
+1
View File
@@ -13,6 +13,7 @@ export function useAuth() {
manage_scans: false,
manage_integrations: false,
manage_billing: false,
manage_alerts: false,
unlimited_visibility: false,
};
+1
View File
@@ -6,6 +6,7 @@ export const DOCS_URLS = {
"https://docs.prowler.com/user-guide/tutorials/prowler-app#step-8:-analyze-the-findings",
AWS_ORGANIZATIONS:
"https://docs.prowler.com/user-guide/tutorials/prowler-cloud-aws-organizations",
ALERTS: "https://docs.prowler.com/user-guide/tutorials/prowler-app", // TODO: Update this URL to the Alerts documentation
ATTACK_PATHS_CUSTOM_QUERIES:
"https://docs.prowler.com/user-guide/tutorials/prowler-app-attack-paths#writing-custom-opencypher-queries",
} as const;
+5
View File
@@ -399,6 +399,11 @@ export const permissionFormFields: PermissionInfo[] = [
label: "Manage Scans",
description: "Allows launching and configuring scans security scans",
},
{
field: "manage_alerts",
label: "Manage Alerts",
description: "Allows creating and managing custom alerts",
},
{
field: "manage_billing",
+63
View File
@@ -0,0 +1,63 @@
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 show Alerts as disabled Cloud-only in OSS when Cloud is disabled", () => {
// Given / When
const alerts = findSubmenu("Alerts");
// Then
expect(alerts).toEqual(
expect.objectContaining({
href: "/alerts",
disabled: true,
cloudOnly: true,
highlight: true,
active: false,
}),
);
});
it("should show Alerts as new under Configuration when Cloud is enabled", () => {
// Given
process.env.NEXT_PUBLIC_IS_CLOUD_ENV = "true";
// When
const alerts = findSubmenu("Alerts");
// Then
expect(alerts).toEqual(
expect.objectContaining({
href: "/alerts",
active: true,
highlight: true,
}),
);
});
it("should remove the new highlight from Attack Paths", () => {
// Given / When
const attackPaths = findMenu("Attack Paths");
// Then
expect(attackPaths).toEqual(
expect.not.objectContaining({ highlight: true }),
);
});
});
+12 -1
View File
@@ -1,4 +1,5 @@
import {
BellRing,
CloudCog,
Cog,
GitBranch,
@@ -32,6 +33,8 @@ interface MenuListOptions {
}
export const getMenuList = ({ pathname }: MenuListOptions): GroupProps[] => {
const isCloudEnv = process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true";
return [
{
groupLabel: "",
@@ -74,7 +77,6 @@ export const getMenuList = ({ pathname }: MenuListOptions): GroupProps[] => {
label: "Attack Paths",
icon: GitBranch,
active: pathname.startsWith("/attack-paths"),
highlight: true,
},
],
},
@@ -108,6 +110,15 @@ export const getMenuList = ({ pathname }: MenuListOptions): GroupProps[] => {
icon: Settings,
submenus: [
{ href: "/providers", label: "Providers", icon: CloudCog },
{
href: "/alerts",
label: "Alerts",
icon: BellRing,
active: isCloudEnv && pathname.startsWith("/alerts"),
highlight: true,
disabled: !isCloudEnv,
cloudOnly: !isCloudEnv,
},
{
href: "/mutelist",
label: "Mutelist",
+50
View File
@@ -0,0 +1,50 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import type { RolePermissionAttributes } from "@/types/users";
import { getRolePermissions } from "./permissions";
const attributes = {
manage_users: false,
manage_account: false,
manage_providers: false,
manage_scans: false,
manage_integrations: false,
manage_billing: false,
manage_alerts: true,
unlimited_visibility: false,
} satisfies RolePermissionAttributes;
describe("getRolePermissions", () => {
afterEach(() => {
vi.unstubAllEnvs();
});
it("includes Manage Alerts in Prowler Cloud when role attributes provide it", () => {
// Given
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true");
// When
const permissions = getRolePermissions(attributes);
// Then
expect(permissions).toContainEqual({
key: "manage_alerts",
label: "Manage Alerts",
enabled: true,
});
});
it("hides Manage Alerts outside Prowler Cloud", () => {
// Given
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false");
// When
const permissions = getRolePermissions(attributes);
// Then
expect(
permissions.some((permission) => permission.key === "manage_alerts"),
).toBe(false);
});
});
+11
View File
@@ -30,6 +30,8 @@ export const isUserOwnerAndHasManageAccount = (
* @returns The permissions for the user role
*/
export const getRolePermissions = (attributes: RolePermissionAttributes) => {
const isCloudEnvironment = process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true";
const permissions = [
{
key: "manage_users",
@@ -57,6 +59,15 @@ export const getRolePermissions = (attributes: RolePermissionAttributes) => {
label: "Manage Integrations",
enabled: attributes.manage_integrations,
},
...(isCloudEnvironment
? [
{
key: "manage_alerts",
label: "Manage Alerts",
enabled: attributes.manage_alerts ?? false,
},
]
: []),
{
key: "unlimited_visibility",
label: "Unlimited Visibility",
-1
View File
@@ -78,7 +78,6 @@
"@types/js-yaml": "4.0.9",
"@uiw/react-codemirror": "4.25.8",
"ai": "5.0.109",
"alert": "6.0.2",
"class-variance-authority": "0.7.1",
"clsx": "2.1.1",
"cmdk": "1.1.1",
-14
View File
@@ -182,9 +182,6 @@ importers:
ai:
specifier: 5.0.109
version: 5.0.109(zod@4.1.11)
alert:
specifier: 6.0.2
version: 6.0.2(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
class-variance-authority:
specifier: 0.7.1
version: 0.7.1
@@ -5635,12 +5632,6 @@ packages:
ajv@8.18.0:
resolution: {integrity: sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==}
alert@6.0.2:
resolution: {integrity: sha512-Oi8u2HRNN6mzpjgKGii2Uuf9iOhyfbeUAHH/5MwnVmC8DS9GrEBjZBFpoavkNj+ZKnBr/Lqx+6YKLDKrggKfPA==}
peerDependencies:
react: 19.2.5
react-dom: 19.2.5
ansi-regex@5.0.1:
resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
engines: {node: '>=8'}
@@ -16663,11 +16654,6 @@ snapshots:
json-schema-traverse: 1.0.0
require-from-string: 2.0.2
alert@6.0.2(react-dom@19.2.5(react@19.2.5))(react@19.2.5):
dependencies:
react: 19.2.5
react-dom: 19.2.5(react@19.2.5)
ansi-regex@5.0.1: {}
ansi-regex@6.2.2: {}
+4
View File
@@ -21,6 +21,8 @@ export type SubmenuProps = {
active?: boolean;
icon: IconComponent;
disabled?: boolean;
highlight?: boolean;
cloudOnly?: boolean;
onClick?: (event: MouseEvent<HTMLAnchorElement>) => void;
};
@@ -458,6 +460,7 @@ export interface InvitationProps {
manage_providers?: boolean;
manage_integrations?: boolean;
manage_scans?: boolean;
manage_alerts?: boolean;
permission_state?: PermissionState;
};
};
@@ -482,6 +485,7 @@ export interface Role {
manage_providers: boolean;
manage_integrations: boolean;
manage_scans: boolean;
manage_alerts?: boolean;
unlimited_visibility: boolean;
permission_state: PermissionState;
inserted_at: string;
+2
View File
@@ -13,6 +13,7 @@ export const addRoleFormSchema = z.object({
manage_providers: z.boolean().default(false),
manage_integrations: z.boolean().default(false),
manage_scans: z.boolean().default(false),
manage_alerts: z.boolean().default(false),
unlimited_visibility: z.boolean().default(false),
groups: z.array(z.string()).optional(),
});
@@ -25,6 +26,7 @@ export const editRoleFormSchema = z.object({
manage_providers: z.boolean().default(false),
manage_integrations: z.boolean().default(false),
manage_scans: z.boolean().default(false),
manage_alerts: z.boolean().default(false),
unlimited_visibility: z.boolean().default(false),
groups: z.array(z.string()).optional(),
});
+2
View File
@@ -63,6 +63,7 @@ export type PermissionKey =
| "manage_scans"
| "manage_integrations"
| "manage_billing"
| "manage_alerts"
| "unlimited_visibility";
export type RolePermissionAttributes = Pick<
@@ -89,6 +90,7 @@ export interface RoleDetail {
manage_scans: boolean;
manage_integrations: boolean;
manage_billing?: boolean;
manage_alerts?: boolean;
unlimited_visibility: boolean;
permission_state?: string;
inserted_at?: string;