mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-07-23 20:42:02 +00:00
fix(ui): harden multi-scan compliance flows
Scope cross-account catalogs to active filters and resolve representative scans per provider type. Reuse shared actions, PDF handlers, accordion builders, detail shells, and Design System variants while adding request timeouts, accessible status breakdowns, and direct regression coverage.
This commit is contained in:
@@ -0,0 +1,192 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const {
|
||||
captureExceptionMock,
|
||||
fetchMock,
|
||||
getAuthHeadersMock,
|
||||
handleApiResponseMock,
|
||||
} = vi.hoisted(() => ({
|
||||
captureExceptionMock: vi.fn(),
|
||||
fetchMock: vi.fn(),
|
||||
getAuthHeadersMock: vi.fn(),
|
||||
handleApiResponseMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib", () => ({
|
||||
apiBaseUrl: "https://api.test/api/v1",
|
||||
GENERIC_SERVER_ERROR_MESSAGE: "Generic server error.",
|
||||
getAuthHeaders: getAuthHeadersMock,
|
||||
getErrorMessage: (error: unknown) =>
|
||||
error instanceof Error ? error.message : String(error),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/server-actions-helper", () => ({
|
||||
handleApiResponse: handleApiResponseMock,
|
||||
}));
|
||||
|
||||
vi.mock("@sentry/nextjs", () => ({
|
||||
captureException: captureExceptionMock,
|
||||
}));
|
||||
|
||||
import {
|
||||
generateCrossAccountPdf,
|
||||
getCrossAccountComplianceOverview,
|
||||
getCrossAccountPdfBinary,
|
||||
getLatestCrossAccountPdf,
|
||||
} from "./cross-account";
|
||||
|
||||
const jsonResponse = (body: unknown, status = 200) =>
|
||||
new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/vnd.api+json" },
|
||||
});
|
||||
|
||||
const lastFetchCall = () => {
|
||||
const call = fetchMock.mock.calls.at(-1);
|
||||
if (!call) throw new Error("fetch was not called");
|
||||
return {
|
||||
init: call[1] as RequestInit,
|
||||
url: new URL(String(call[0])),
|
||||
};
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
fetchMock.mockResolvedValue(jsonResponse({ data: null }));
|
||||
getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer test-token" });
|
||||
handleApiResponseMock.mockResolvedValue({ data: null });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("cross-account compliance actions", () => {
|
||||
it("loads the overview with its identity and account filters", async () => {
|
||||
const payload = { data: { id: "cis_2.0_aws" } };
|
||||
handleApiResponseMock.mockResolvedValue(payload);
|
||||
|
||||
const result = await getCrossAccountComplianceOverview({
|
||||
complianceId: "cis_2.0_aws",
|
||||
providerType: "aws",
|
||||
filters: {
|
||||
scanIds: ["scan-1", "scan-2"],
|
||||
providerIds: "provider-1,provider-2",
|
||||
providerGroups: "group-1",
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toEqual({ status: "success", response: payload });
|
||||
const { init, url } = lastFetchCall();
|
||||
expect(url.pathname).toBe("/api/v1/cross-account-compliance-overviews");
|
||||
expect(url.searchParams.get("filter[compliance_id]")).toBe("cis_2.0_aws");
|
||||
expect(url.searchParams.get("filter[provider_type]")).toBe("aws");
|
||||
expect(url.searchParams.get("filter[scan__in]")).toBe("scan-1,scan-2");
|
||||
expect(url.searchParams.get("filter[provider_id__in]")).toBe(
|
||||
"provider-1,provider-2",
|
||||
);
|
||||
expect(url.searchParams.get("filter[provider_groups__in]")).toBe("group-1");
|
||||
expect(init.signal).toBeInstanceOf(AbortSignal);
|
||||
});
|
||||
|
||||
it("aborts a stalled request and reports the network failure", async () => {
|
||||
vi.useFakeTimers();
|
||||
let requestSignal: AbortSignal | undefined;
|
||||
fetchMock.mockImplementation(
|
||||
(_input: RequestInfo | URL, init?: RequestInit) =>
|
||||
new Promise((_resolve, reject) => {
|
||||
requestSignal = init?.signal ?? undefined;
|
||||
requestSignal?.addEventListener("abort", () => {
|
||||
reject(requestSignal?.reason ?? new Error("aborted"));
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
const request = getCrossAccountComplianceOverview({
|
||||
complianceId: "cis_2.0_aws",
|
||||
providerType: "aws",
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(30_000);
|
||||
|
||||
await expect(request).resolves.toEqual({
|
||||
status: "load-error",
|
||||
message:
|
||||
"Could not load cross-provider compliance data. Try again later.",
|
||||
});
|
||||
expect(requestSignal?.aborted).toBe(true);
|
||||
expect(captureExceptionMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("starts PDF generation through the shared task protocol", async () => {
|
||||
fetchMock.mockResolvedValue(
|
||||
jsonResponse({ data: { type: "tasks", id: "task-1" } }, 202),
|
||||
);
|
||||
|
||||
const result = await generateCrossAccountPdf({
|
||||
complianceId: "cis_2.0_aws",
|
||||
providerType: "aws",
|
||||
filters: { scanIds: ["scan-1"] },
|
||||
reportName: "aws-report.pdf",
|
||||
});
|
||||
|
||||
expect(result).toEqual({ taskId: "task-1" });
|
||||
const { init, url } = lastFetchCall();
|
||||
expect(init.method).toBe("POST");
|
||||
expect(init.signal).toBeInstanceOf(AbortSignal);
|
||||
expect(url.pathname).toBe("/api/v1/cross-account-compliance-overviews/pdf");
|
||||
expect(url.searchParams.get("report_name")).toBe("aws-report.pdf");
|
||||
});
|
||||
|
||||
it("retrieves a completed PDF with its response filename", async () => {
|
||||
fetchMock.mockResolvedValue(
|
||||
new Response(Buffer.from("pdf-bytes"), {
|
||||
headers: {
|
||||
"Content-Disposition": 'attachment; filename="aws-report.pdf"',
|
||||
"Content-Type": "application/pdf",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await getCrossAccountPdfBinary("task-1");
|
||||
|
||||
expect(result).toEqual({
|
||||
success: true,
|
||||
data: Buffer.from("pdf-bytes").toString("base64"),
|
||||
filename: "aws-report.pdf",
|
||||
});
|
||||
expect(lastFetchCall().init.signal).toBeInstanceOf(AbortSignal);
|
||||
});
|
||||
|
||||
it("returns the latest matching PDF descriptor", async () => {
|
||||
fetchMock.mockResolvedValue(
|
||||
jsonResponse({
|
||||
data: {
|
||||
id: "task-9",
|
||||
attributes: {
|
||||
completed_at: "2026-07-01T10:00:00Z",
|
||||
result: { filename: "latest.pdf" },
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await getLatestCrossAccountPdf({
|
||||
complianceId: "cis_2.0_aws",
|
||||
providerType: "aws",
|
||||
filters: { providerIds: "provider-1" },
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
taskId: "task-9",
|
||||
filename: "latest.pdf",
|
||||
completedAt: "2026-07-01T10:00:00Z",
|
||||
});
|
||||
const { init, url } = lastFetchCall();
|
||||
expect(init.signal).toBeInstanceOf(AbortSignal);
|
||||
expect(url.pathname).toBe(
|
||||
"/api/v1/cross-account-compliance-overviews/pdf/latest",
|
||||
);
|
||||
expect(url.searchParams.get("filter[provider_id__in]")).toBe("provider-1");
|
||||
});
|
||||
});
|
||||
@@ -1,87 +1,23 @@
|
||||
"use server";
|
||||
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
|
||||
import type { ScanBinaryResult } from "@/actions/scans/scans";
|
||||
import {
|
||||
apiBaseUrl,
|
||||
GENERIC_SERVER_ERROR_MESSAGE,
|
||||
getAuthHeaders,
|
||||
getErrorMessage,
|
||||
} from "@/lib";
|
||||
import { hasActionError } from "@/lib/action-errors";
|
||||
import { handleApiResponse } from "@/lib/server-actions-helper";
|
||||
import { SentryErrorSource, SentryErrorType } from "@/sentry";
|
||||
import { apiBaseUrl } from "@/lib";
|
||||
|
||||
import {
|
||||
generateAggregatedCompliancePdf,
|
||||
getAggregatedComplianceOverview,
|
||||
getAggregatedCompliancePdfBinary,
|
||||
getLatestAggregatedCompliancePdf,
|
||||
} from "../_lib/aggregated-compliance-actions";
|
||||
import type {
|
||||
CrossAccountApiFilters,
|
||||
CrossAccountOverviewResponse,
|
||||
CrossAccountOverviewResult,
|
||||
LatestCrossProviderPdf,
|
||||
} from "../_types";
|
||||
import {
|
||||
CROSS_PROVIDER_OVERVIEW_LOAD_ERROR_MESSAGE,
|
||||
CROSS_PROVIDER_OVERVIEW_RESULT_STATUS,
|
||||
} from "../_types";
|
||||
|
||||
const CROSS_ACCOUNT_API_PATH = "/cross-account-compliance-overviews";
|
||||
|
||||
/** Error payload shapes the PDF endpoints emit (JSON:API or plain). */
|
||||
interface PdfEndpointErrorBody {
|
||||
errors?: Array<{ detail?: string }>;
|
||||
error?: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
/** Cross-account sibling of the cross-provider action module's helper: a
|
||||
* user-safe message from a failed PDF endpoint response, with 5xx reported
|
||||
* to Sentry. `operation` must be a STATIC route template. */
|
||||
const getPdfEndpointErrorMessage = async (
|
||||
response: Response,
|
||||
fallbackMessage: string,
|
||||
operation: string,
|
||||
): Promise<string> => {
|
||||
const contentType = response.headers.get("content-type")?.toLowerCase() || "";
|
||||
const errorData: PdfEndpointErrorBody | null = contentType.includes(
|
||||
"text/html",
|
||||
)
|
||||
? null
|
||||
: await response.json().catch(() => null);
|
||||
|
||||
if (response.status >= 500) {
|
||||
Sentry.captureException(
|
||||
new Error(
|
||||
`Cross-account PDF request failed (${response.status}) at ${operation}`,
|
||||
),
|
||||
{
|
||||
tags: {
|
||||
api_error: true,
|
||||
status_code: response.status.toString(),
|
||||
error_type: SentryErrorType.SERVER_ERROR,
|
||||
error_source: SentryErrorSource.SERVER_ACTION,
|
||||
},
|
||||
level: "error",
|
||||
contexts: {
|
||||
api_response: {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
operation,
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
return GENERIC_SERVER_ERROR_MESSAGE;
|
||||
}
|
||||
|
||||
return (
|
||||
errorData?.errors?.[0]?.detail ||
|
||||
errorData?.error ||
|
||||
errorData?.message ||
|
||||
fallbackMessage
|
||||
);
|
||||
};
|
||||
|
||||
/** Appends the required identity params + shared filters to a request URL. */
|
||||
const applyCrossAccountParams = (
|
||||
url: URL,
|
||||
complianceId: string,
|
||||
@@ -91,30 +27,30 @@ const applyCrossAccountParams = (
|
||||
url.searchParams.set("filter[compliance_id]", complianceId);
|
||||
url.searchParams.set("filter[provider_type]", providerType);
|
||||
|
||||
if (filters?.scanIds && filters.scanIds.length > 0) {
|
||||
if (filters?.scanIds?.length) {
|
||||
url.searchParams.set("filter[scan__in]", filters.scanIds.join(","));
|
||||
}
|
||||
const paramMap = {
|
||||
|
||||
const params = {
|
||||
"filter[provider_id__in]": filters?.providerIds,
|
||||
"filter[provider_groups__in]": filters?.providerGroups,
|
||||
};
|
||||
for (const [key, value] of Object.entries(paramMap)) {
|
||||
if (value && value.trim().length > 0) {
|
||||
url.searchParams.set(key, value);
|
||||
}
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
if (value?.trim()) url.searchParams.set(key, value);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Aggregate a regular (per-provider) compliance framework across the latest
|
||||
* completed scan of every visible account of one provider type (Prowler
|
||||
* Cloud only — the OSS API has no such endpoint).
|
||||
*
|
||||
* When `filters.scanIds` is omitted the API auto-selects the latest COMPLETED
|
||||
* scan per account. Non-2xx responses are returned as structured action
|
||||
* errors so callers can reuse the app-wide 402/403 handlers, mirroring
|
||||
* `getCrossProviderComplianceOverview`.
|
||||
*/
|
||||
const buildCrossAccountUrl = (
|
||||
suffix: string,
|
||||
complianceId: string,
|
||||
providerType: string,
|
||||
filters?: CrossAccountApiFilters,
|
||||
) => {
|
||||
const url = new URL(`${apiBaseUrl}${CROSS_ACCOUNT_API_PATH}${suffix}`);
|
||||
applyCrossAccountParams(url, complianceId, providerType, filters);
|
||||
return url;
|
||||
};
|
||||
|
||||
export const getCrossAccountComplianceOverview = async ({
|
||||
complianceId,
|
||||
providerType,
|
||||
@@ -124,41 +60,13 @@ export const getCrossAccountComplianceOverview = async ({
|
||||
providerType: string;
|
||||
filters?: CrossAccountApiFilters;
|
||||
}): Promise<CrossAccountOverviewResult> => {
|
||||
const headers = await getAuthHeaders({ contentType: false });
|
||||
const url = new URL(`${apiBaseUrl}${CROSS_ACCOUNT_API_PATH}`);
|
||||
applyCrossAccountParams(url, complianceId, providerType, filters);
|
||||
|
||||
try {
|
||||
const response = await fetch(url.toString(), { headers });
|
||||
const responseData = await handleApiResponse(response);
|
||||
|
||||
if (hasActionError(responseData)) {
|
||||
return {
|
||||
status: CROSS_PROVIDER_OVERVIEW_RESULT_STATUS.ACTION_ERROR,
|
||||
result: responseData,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: CROSS_PROVIDER_OVERVIEW_RESULT_STATUS.SUCCESS,
|
||||
response: responseData as CrossAccountOverviewResponse,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Error fetching cross-account compliance overview:", error);
|
||||
return {
|
||||
status: CROSS_PROVIDER_OVERVIEW_RESULT_STATUS.LOAD_ERROR,
|
||||
message: CROSS_PROVIDER_OVERVIEW_LOAD_ERROR_MESSAGE,
|
||||
};
|
||||
}
|
||||
const url = buildCrossAccountUrl("", complianceId, providerType, filters);
|
||||
return getAggregatedComplianceOverview<CrossAccountOverviewResponse>(
|
||||
url,
|
||||
`GET ${CROSS_ACCOUNT_API_PATH}`,
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Trigger ad-hoc generation of the combined cross-account compliance PDF.
|
||||
* Mirrors `generateCrossProviderPdf`: returns the async task id so the
|
||||
* caller can track it and download via {@link getCrossAccountPdfBinary}.
|
||||
* Pass the exact `scanIds` currently on screen so the report matches the
|
||||
* displayed data instead of racing a scan completing in between.
|
||||
*/
|
||||
export const generateCrossAccountPdf = async ({
|
||||
complianceId,
|
||||
providerType,
|
||||
@@ -168,45 +76,16 @@ export const generateCrossAccountPdf = async ({
|
||||
complianceId: string;
|
||||
providerType: string;
|
||||
filters?: CrossAccountApiFilters;
|
||||
/** Optional download filename; sanitized server-side. */
|
||||
reportName?: string;
|
||||
}): Promise<{ taskId: string } | { error: string }> => {
|
||||
const headers = await getAuthHeaders({ contentType: false });
|
||||
const url = new URL(`${apiBaseUrl}${CROSS_ACCOUNT_API_PATH}/pdf`);
|
||||
applyCrossAccountParams(url, complianceId, providerType, filters);
|
||||
const url = buildCrossAccountUrl("/pdf", complianceId, providerType, filters);
|
||||
if (reportName) url.searchParams.set("report_name", reportName);
|
||||
|
||||
try {
|
||||
const response = await fetch(url.toString(), { method: "POST", headers });
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
await getPdfEndpointErrorMessage(
|
||||
response,
|
||||
"Unable to start PDF generation. Contact support if the issue continues.",
|
||||
`POST ${CROSS_ACCOUNT_API_PATH}/pdf`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const json = await response.json();
|
||||
const taskId = json?.data?.id;
|
||||
if (!taskId) {
|
||||
throw new Error("Unexpected response starting PDF generation.");
|
||||
}
|
||||
|
||||
return { taskId };
|
||||
} catch (error) {
|
||||
return { error: getErrorMessage(error) };
|
||||
}
|
||||
return generateAggregatedCompliancePdf(
|
||||
url,
|
||||
`POST ${CROSS_ACCOUNT_API_PATH}/pdf`,
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetch the finished cross-account PDF for a task started by
|
||||
* {@link generateCrossAccountPdf}. Same 202-pending / 2xx-binary / error-JSON
|
||||
* protocol (and shared `ScanBinaryResult` shape) as the cross-provider
|
||||
* download action.
|
||||
*/
|
||||
export const getCrossAccountPdfBinary = async (
|
||||
taskId: string,
|
||||
): Promise<ScanBinaryResult> => {
|
||||
@@ -215,53 +94,16 @@ export const getCrossAccountPdfBinary = async (
|
||||
return { error: "Invalid task identifier." };
|
||||
}
|
||||
|
||||
const headers = await getAuthHeaders({ contentType: false });
|
||||
const url = new URL(
|
||||
`${apiBaseUrl}${CROSS_ACCOUNT_API_PATH}/pdf/${encodeURIComponent(safeTaskId)}`,
|
||||
);
|
||||
|
||||
try {
|
||||
const response = await fetch(url.toString(), { headers });
|
||||
|
||||
if (response.status === 202) {
|
||||
const json = await response.json();
|
||||
return {
|
||||
pending: true,
|
||||
state: json?.data?.attributes?.state,
|
||||
taskId: json?.data?.id,
|
||||
};
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
await getPdfEndpointErrorMessage(
|
||||
response,
|
||||
"Unable to retrieve the compliance PDF report. Contact support if the issue continues.",
|
||||
`GET ${CROSS_ACCOUNT_API_PATH}/pdf/{taskId}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const contentDisposition =
|
||||
response.headers.get("content-disposition") || "";
|
||||
const filenameMatch = contentDisposition.match(/filename="?([^";]+)"?/i);
|
||||
const filename = filenameMatch?.[1] || "cross-account-compliance.pdf";
|
||||
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
const base64 = Buffer.from(arrayBuffer).toString("base64");
|
||||
|
||||
return { success: true, data: base64, filename };
|
||||
} catch (error) {
|
||||
return { error: getErrorMessage(error) };
|
||||
}
|
||||
return getAggregatedCompliancePdfBinary({
|
||||
url,
|
||||
operation: `GET ${CROSS_ACCOUNT_API_PATH}/pdf/{taskId}`,
|
||||
defaultFilename: "cross-account-compliance.pdf",
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Check whether a cross-account PDF already exists for the given filters —
|
||||
* same degrade-to-`null` contract as `getLatestCrossProviderPdf` (404 means
|
||||
* "not generated yet"; the report goes stale the moment any account
|
||||
* completes a new scan).
|
||||
*/
|
||||
export const getLatestCrossAccountPdf = async ({
|
||||
complianceId,
|
||||
providerType,
|
||||
@@ -271,36 +113,14 @@ export const getLatestCrossAccountPdf = async ({
|
||||
providerType: string;
|
||||
filters?: CrossAccountApiFilters;
|
||||
}): Promise<LatestCrossProviderPdf | null> => {
|
||||
const headers = await getAuthHeaders({ contentType: false });
|
||||
const url = new URL(`${apiBaseUrl}${CROSS_ACCOUNT_API_PATH}/pdf/latest`);
|
||||
applyCrossAccountParams(url, complianceId, providerType, filters);
|
||||
|
||||
try {
|
||||
const response = await fetch(url.toString(), { headers });
|
||||
|
||||
if (response.status === 404) return null;
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
await getPdfEndpointErrorMessage(
|
||||
response,
|
||||
"Unable to check for an existing PDF report.",
|
||||
`GET ${CROSS_ACCOUNT_API_PATH}/pdf/latest`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const json = await response.json();
|
||||
const taskId = json?.data?.id;
|
||||
if (!taskId) return null;
|
||||
|
||||
return {
|
||||
taskId,
|
||||
filename: json?.data?.attributes?.result?.filename,
|
||||
completedAt: json?.data?.attributes?.completed_at,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Error checking for an existing cross-account PDF:", error);
|
||||
return null;
|
||||
}
|
||||
const url = buildCrossAccountUrl(
|
||||
"/pdf/latest",
|
||||
complianceId,
|
||||
providerType,
|
||||
filters,
|
||||
);
|
||||
return getLatestAggregatedCompliancePdf(
|
||||
url,
|
||||
`GET ${CROSS_ACCOUNT_API_PATH}/pdf/latest`,
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,119 +1,54 @@
|
||||
"use server";
|
||||
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
|
||||
import type { ScanBinaryResult } from "@/actions/scans/scans";
|
||||
import {
|
||||
apiBaseUrl,
|
||||
GENERIC_SERVER_ERROR_MESSAGE,
|
||||
getAuthHeaders,
|
||||
getErrorMessage,
|
||||
} from "@/lib";
|
||||
import { hasActionError } from "@/lib/action-errors";
|
||||
import { handleApiResponse } from "@/lib/server-actions-helper";
|
||||
import { SentryErrorSource, SentryErrorType } from "@/sentry";
|
||||
import { apiBaseUrl } from "@/lib";
|
||||
|
||||
import {
|
||||
generateAggregatedCompliancePdf,
|
||||
getAggregatedComplianceOverview,
|
||||
getAggregatedCompliancePdfBinary,
|
||||
getLatestAggregatedCompliancePdf,
|
||||
} from "../_lib/aggregated-compliance-actions";
|
||||
import type {
|
||||
CrossProviderApiFilters,
|
||||
CrossProviderOverviewResponse,
|
||||
CrossProviderOverviewResult,
|
||||
LatestCrossProviderPdf,
|
||||
} from "../_types";
|
||||
import {
|
||||
CROSS_PROVIDER_OVERVIEW_LOAD_ERROR_MESSAGE,
|
||||
CROSS_PROVIDER_OVERVIEW_RESULT_STATUS,
|
||||
} from "../_types";
|
||||
|
||||
const CROSS_PROVIDER_API_PATH = "/cross-provider-compliance-overviews";
|
||||
|
||||
/** Error payload shapes the PDF endpoints emit (JSON:API or plain). */
|
||||
interface PdfEndpointErrorBody {
|
||||
errors?: Array<{ detail?: string }>;
|
||||
error?: string;
|
||||
message?: string;
|
||||
}
|
||||
const applyCrossProviderParams = (
|
||||
url: URL,
|
||||
complianceId: string,
|
||||
filters?: CrossProviderApiFilters,
|
||||
) => {
|
||||
url.searchParams.set("filter[compliance_id]", complianceId);
|
||||
|
||||
/**
|
||||
* Extracts a user-safe message from a failed PDF endpoint response and, for
|
||||
* unexpected failures, reports it to Sentry. `operation` must be a STATIC
|
||||
* route template (e.g. `GET .../pdf/{taskId}`) — never the
|
||||
* resolved URL, which would carry the task id or a user-typed report name.
|
||||
*/
|
||||
const getPdfEndpointErrorMessage = async (
|
||||
response: Response,
|
||||
fallbackMessage: string,
|
||||
operation: string,
|
||||
): Promise<string> => {
|
||||
const contentType = response.headers.get("content-type")?.toLowerCase() || "";
|
||||
const errorData: PdfEndpointErrorBody | null = contentType.includes(
|
||||
"text/html",
|
||||
)
|
||||
? null
|
||||
: await response.json().catch(() => null);
|
||||
|
||||
// These endpoints bypass handleApiResponse (binary/task protocol), so
|
||||
// server failures would otherwise go unmonitored.
|
||||
if (response.status >= 500) {
|
||||
Sentry.captureException(
|
||||
new Error(
|
||||
`Cross-provider PDF request failed (${response.status}) at ${operation}`,
|
||||
),
|
||||
{
|
||||
tags: {
|
||||
api_error: true,
|
||||
status_code: response.status.toString(),
|
||||
error_type: SentryErrorType.SERVER_ERROR,
|
||||
error_source: SentryErrorSource.SERVER_ACTION,
|
||||
},
|
||||
level: "error",
|
||||
contexts: {
|
||||
api_response: {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
operation,
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
return GENERIC_SERVER_ERROR_MESSAGE;
|
||||
}
|
||||
|
||||
return (
|
||||
errorData?.errors?.[0]?.detail ||
|
||||
errorData?.error ||
|
||||
errorData?.message ||
|
||||
fallbackMessage
|
||||
);
|
||||
};
|
||||
|
||||
/** Appends the shared cross-provider filter params to a request URL. */
|
||||
const applyFilters = (url: URL, filters?: CrossProviderApiFilters) => {
|
||||
if (!filters) return;
|
||||
|
||||
if (filters.scanIds && filters.scanIds.length > 0) {
|
||||
if (filters?.scanIds?.length) {
|
||||
url.searchParams.set("filter[scan__in]", filters.scanIds.join(","));
|
||||
}
|
||||
|
||||
const paramMap = {
|
||||
"filter[provider_type__in]": filters.providerTypes,
|
||||
"filter[provider_id__in]": filters.providerIds,
|
||||
"filter[provider_groups__in]": filters.providerGroups,
|
||||
const params = {
|
||||
"filter[provider_type__in]": filters?.providerTypes,
|
||||
"filter[provider_id__in]": filters?.providerIds,
|
||||
"filter[provider_groups__in]": filters?.providerGroups,
|
||||
};
|
||||
for (const [key, value] of Object.entries(paramMap)) {
|
||||
if (value && value.trim().length > 0) {
|
||||
url.searchParams.set(key, value);
|
||||
}
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
if (value?.trim()) url.searchParams.set(key, value);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Aggregate a universal compliance framework across one scan per compatible
|
||||
* provider (Prowler Cloud only — the OSS API has no such endpoint).
|
||||
*
|
||||
* When `filters.scanIds` is omitted the API auto-selects the latest COMPLETED
|
||||
* scan per compatible provider. Non-2xx responses are returned as structured
|
||||
* action errors so callers can reuse the app-wide 402/403 handlers.
|
||||
*/
|
||||
const buildCrossProviderUrl = (
|
||||
suffix: string,
|
||||
complianceId: string,
|
||||
filters?: CrossProviderApiFilters,
|
||||
) => {
|
||||
const url = new URL(`${apiBaseUrl}${CROSS_PROVIDER_API_PATH}${suffix}`);
|
||||
applyCrossProviderParams(url, complianceId, filters);
|
||||
return url;
|
||||
};
|
||||
|
||||
export const getCrossProviderComplianceOverview = async ({
|
||||
complianceId,
|
||||
filters,
|
||||
@@ -121,45 +56,13 @@ export const getCrossProviderComplianceOverview = async ({
|
||||
complianceId: string;
|
||||
filters?: CrossProviderApiFilters;
|
||||
}): Promise<CrossProviderOverviewResult> => {
|
||||
const headers = await getAuthHeaders({ contentType: false });
|
||||
const url = new URL(`${apiBaseUrl}${CROSS_PROVIDER_API_PATH}`);
|
||||
url.searchParams.set("filter[compliance_id]", complianceId);
|
||||
applyFilters(url, filters);
|
||||
|
||||
try {
|
||||
const response = await fetch(url.toString(), { headers });
|
||||
const responseData = await handleApiResponse(response);
|
||||
|
||||
if (hasActionError(responseData)) {
|
||||
return {
|
||||
status: CROSS_PROVIDER_OVERVIEW_RESULT_STATUS.ACTION_ERROR,
|
||||
result: responseData,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: CROSS_PROVIDER_OVERVIEW_RESULT_STATUS.SUCCESS,
|
||||
response: responseData as CrossProviderOverviewResponse,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Error fetching cross-provider compliance overview:", error);
|
||||
return {
|
||||
status: CROSS_PROVIDER_OVERVIEW_RESULT_STATUS.LOAD_ERROR,
|
||||
message: CROSS_PROVIDER_OVERVIEW_LOAD_ERROR_MESSAGE,
|
||||
};
|
||||
}
|
||||
const url = buildCrossProviderUrl("", complianceId, filters);
|
||||
return getAggregatedComplianceOverview<CrossProviderOverviewResponse>(
|
||||
url,
|
||||
`GET ${CROSS_PROVIDER_API_PATH}`,
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Trigger ad-hoc generation of the combined cross-provider compliance PDF.
|
||||
*
|
||||
* The PDF is built asynchronously by a backend task: this returns the task id
|
||||
* so the caller can poll it and then download via
|
||||
* {@link getCrossProviderPdfBinary}. Pass the exact `scanIds` currently on
|
||||
* screen (`attributes.scan_ids`) so the report matches the displayed data
|
||||
* instead of re-resolving "latest scan per provider", which could race a scan
|
||||
* completing in between.
|
||||
*/
|
||||
export const generateCrossProviderPdf = async ({
|
||||
complianceId,
|
||||
filters,
|
||||
@@ -167,109 +70,34 @@ export const generateCrossProviderPdf = async ({
|
||||
}: {
|
||||
complianceId: string;
|
||||
filters?: CrossProviderApiFilters;
|
||||
/** Optional download filename; sanitized server-side. */
|
||||
reportName?: string;
|
||||
}): Promise<{ taskId: string } | { error: string }> => {
|
||||
const headers = await getAuthHeaders({ contentType: false });
|
||||
const url = new URL(`${apiBaseUrl}${CROSS_PROVIDER_API_PATH}/pdf`);
|
||||
url.searchParams.set("filter[compliance_id]", complianceId);
|
||||
applyFilters(url, filters);
|
||||
const url = buildCrossProviderUrl("/pdf", complianceId, filters);
|
||||
if (reportName) url.searchParams.set("report_name", reportName);
|
||||
|
||||
try {
|
||||
const response = await fetch(url.toString(), { method: "POST", headers });
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
await getPdfEndpointErrorMessage(
|
||||
response,
|
||||
"Unable to start PDF generation. Contact support if the issue continues.",
|
||||
`POST ${CROSS_PROVIDER_API_PATH}/pdf`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const json = await response.json();
|
||||
const taskId = json?.data?.id;
|
||||
if (!taskId) {
|
||||
throw new Error("Unexpected response starting PDF generation.");
|
||||
}
|
||||
|
||||
return { taskId };
|
||||
} catch (error) {
|
||||
return { error: getErrorMessage(error) };
|
||||
}
|
||||
return generateAggregatedCompliancePdf(
|
||||
url,
|
||||
`POST ${CROSS_PROVIDER_API_PATH}/pdf`,
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetch the finished cross-provider PDF for a task started by
|
||||
* {@link generateCrossProviderPdf}. Speaks the same 202-pending /
|
||||
* 2xx-binary / error-JSON protocol as the per-scan report endpoints, so it
|
||||
* returns the shared {@link ScanBinaryResult} shape and callers can reuse the
|
||||
* existing download plumbing unchanged.
|
||||
*/
|
||||
export const getCrossProviderPdfBinary = async (
|
||||
taskId: string,
|
||||
): Promise<ScanBinaryResult> => {
|
||||
// The task id reaches the URL path: constrain it to the task-id charset
|
||||
// (UUIDs) so a crafted value cannot smuggle `/`, `..` or a host.
|
||||
const safeTaskId = taskId.trim();
|
||||
if (!/^[A-Za-z0-9_-]+$/.test(safeTaskId)) {
|
||||
return { error: "Invalid task identifier." };
|
||||
}
|
||||
|
||||
const headers = await getAuthHeaders({ contentType: false });
|
||||
const url = new URL(
|
||||
`${apiBaseUrl}${CROSS_PROVIDER_API_PATH}/pdf/${encodeURIComponent(safeTaskId)}`,
|
||||
);
|
||||
|
||||
try {
|
||||
const response = await fetch(url.toString(), { headers });
|
||||
|
||||
if (response.status === 202) {
|
||||
const json = await response.json();
|
||||
return {
|
||||
pending: true,
|
||||
state: json?.data?.attributes?.state,
|
||||
taskId: json?.data?.id,
|
||||
};
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
await getPdfEndpointErrorMessage(
|
||||
response,
|
||||
"Unable to retrieve the compliance PDF report. Contact support if the issue continues.",
|
||||
`GET ${CROSS_PROVIDER_API_PATH}/pdf/{taskId}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const contentDisposition =
|
||||
response.headers.get("content-disposition") || "";
|
||||
const filenameMatch = contentDisposition.match(/filename="?([^";]+)"?/i);
|
||||
const filename = filenameMatch?.[1] || "cross-provider-compliance.pdf";
|
||||
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
const base64 = Buffer.from(arrayBuffer).toString("base64");
|
||||
|
||||
return { success: true, data: base64, filename };
|
||||
} catch (error) {
|
||||
return { error: getErrorMessage(error) };
|
||||
}
|
||||
return getAggregatedCompliancePdfBinary({
|
||||
url,
|
||||
operation: `GET ${CROSS_PROVIDER_API_PATH}/pdf/{taskId}`,
|
||||
defaultFilename: "cross-provider-compliance.pdf",
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Check whether a cross-provider PDF already exists for the given filters so
|
||||
* the UI can offer "Download" immediately instead of forcing a re-generate.
|
||||
*
|
||||
* 404 means "not generated yet" — a normal state, returned as `null` rather
|
||||
* than an error. The backend only matches reports built from the exact scan
|
||||
* set the filters resolve to, so a report goes stale (→ `null`) as soon as a
|
||||
* contributing provider completes a new scan. Failures also degrade to
|
||||
* `null`: this is an optional availability check and the caller's fallback
|
||||
* (show "Generate") is always safe.
|
||||
*/
|
||||
export const getLatestCrossProviderPdf = async ({
|
||||
complianceId,
|
||||
filters,
|
||||
@@ -277,39 +105,9 @@ export const getLatestCrossProviderPdf = async ({
|
||||
complianceId: string;
|
||||
filters?: CrossProviderApiFilters;
|
||||
}): Promise<LatestCrossProviderPdf | null> => {
|
||||
const headers = await getAuthHeaders({ contentType: false });
|
||||
const url = new URL(`${apiBaseUrl}${CROSS_PROVIDER_API_PATH}/pdf/latest`);
|
||||
url.searchParams.set("filter[compliance_id]", complianceId);
|
||||
applyFilters(url, filters);
|
||||
|
||||
try {
|
||||
const response = await fetch(url.toString(), { headers });
|
||||
|
||||
if (response.status === 404) return null;
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
await getPdfEndpointErrorMessage(
|
||||
response,
|
||||
"Unable to check for an existing PDF report.",
|
||||
`GET ${CROSS_PROVIDER_API_PATH}/pdf/latest`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const json = await response.json();
|
||||
const taskId = json?.data?.id;
|
||||
if (!taskId) return null;
|
||||
|
||||
return {
|
||||
taskId,
|
||||
filename: json?.data?.attributes?.result?.filename,
|
||||
completedAt: json?.data?.attributes?.completed_at,
|
||||
};
|
||||
} catch (error) {
|
||||
// Degraded on purpose, but logged: without this a systematically failing
|
||||
// endpoint would be indistinguishable from "never generated".
|
||||
console.error("Error checking for an existing cross-provider PDF:", error);
|
||||
return null;
|
||||
}
|
||||
const url = buildCrossProviderUrl("/pdf/latest", complianceId, filters);
|
||||
return getLatestAggregatedCompliancePdf(
|
||||
url,
|
||||
`GET ${CROSS_PROVIDER_API_PATH}/pdf/latest`,
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import Image from "next/image";
|
||||
import type { ComponentProps, ReactNode } from "react";
|
||||
|
||||
import {
|
||||
ClientAccordionWrapper,
|
||||
RequirementsStatusCard,
|
||||
TopFailedSectionsCard,
|
||||
} from "@/components/compliance";
|
||||
import type { AccordionItemProps } from "@/components/shadcn/accordion/Accordion";
|
||||
import { Card } from "@/components/shadcn/card/card";
|
||||
import type { RequirementsTotals } from "@/types/compliance";
|
||||
|
||||
interface AggregatedComplianceDetailProps {
|
||||
compliancetitle: string;
|
||||
logoPath?: ComponentProps<typeof Image>["src"];
|
||||
title: ReactNode;
|
||||
description: ReactNode;
|
||||
reportAction: ReactNode;
|
||||
filters: ReactNode;
|
||||
totals: RequirementsTotals;
|
||||
coverage: ReactNode;
|
||||
topFailed: ComponentProps<typeof TopFailedSectionsCard>;
|
||||
accordionItems: AccordionItemProps[];
|
||||
initialExpandedKeys: string[];
|
||||
}
|
||||
|
||||
export const AggregatedComplianceDetail = ({
|
||||
compliancetitle,
|
||||
logoPath,
|
||||
title,
|
||||
description,
|
||||
reportAction,
|
||||
filters,
|
||||
totals,
|
||||
coverage,
|
||||
topFailed,
|
||||
accordionItems,
|
||||
initialExpandedKeys,
|
||||
}: AggregatedComplianceDetailProps) => (
|
||||
<div className="flex flex-col gap-8">
|
||||
<Card variant="base" padding="lg">
|
||||
<div className="flex w-full flex-col gap-4">
|
||||
<div className="flex w-full items-center justify-between gap-4">
|
||||
<div className="flex min-w-0 items-center gap-4">
|
||||
{logoPath && (
|
||||
<div className="relative h-12 w-12 shrink-0">
|
||||
<Image
|
||||
src={logoPath}
|
||||
alt={`${compliancetitle} logo`}
|
||||
fill
|
||||
sizes="48px"
|
||||
className="border-border-neutral-secondary bg-bg-neutral-primary rounded-lg border object-contain"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
{title}
|
||||
{description}
|
||||
</div>
|
||||
</div>
|
||||
<div className="shrink-0">{reportAction}</div>
|
||||
</div>
|
||||
{filters}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 xl:grid-cols-[minmax(280px,400px)_minmax(280px,360px)_1fr]">
|
||||
<RequirementsStatusCard
|
||||
pass={totals.pass}
|
||||
fail={totals.fail}
|
||||
manual={totals.manual}
|
||||
/>
|
||||
{coverage}
|
||||
<TopFailedSectionsCard {...topFailed} />
|
||||
</div>
|
||||
|
||||
<ClientAccordionWrapper
|
||||
items={accordionItems}
|
||||
defaultExpandedKeys={initialExpandedKeys}
|
||||
scrollToKey={initialExpandedKeys[0]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,85 @@
|
||||
import Image from "next/image";
|
||||
import type { KeyboardEventHandler, ReactNode } from "react";
|
||||
|
||||
import { getComplianceIcon } from "@/components/icons/compliance/IconCompliance";
|
||||
import { Card, CardContent } from "@/components/shadcn/card/card";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/shadcn/tooltip";
|
||||
|
||||
interface AggregatedFrameworkCardProps {
|
||||
frameworkTitle: string;
|
||||
formattedTitle: string;
|
||||
ariaLabel: string;
|
||||
onActivate: () => void;
|
||||
subtitle: ReactNode;
|
||||
tooltip?: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const AggregatedFrameworkCard = ({
|
||||
frameworkTitle,
|
||||
formattedTitle,
|
||||
ariaLabel,
|
||||
onActivate,
|
||||
subtitle,
|
||||
tooltip,
|
||||
children,
|
||||
}: AggregatedFrameworkCardProps) => {
|
||||
const handleKeyDown: KeyboardEventHandler<HTMLDivElement> = (event) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
onActivate();
|
||||
}
|
||||
};
|
||||
const logo = getComplianceIcon(frameworkTitle);
|
||||
const title = (
|
||||
<h4 className="truncate text-sm leading-5 font-bold">{formattedTitle}</h4>
|
||||
);
|
||||
|
||||
return (
|
||||
<Card
|
||||
variant="base"
|
||||
padding="md"
|
||||
interactive
|
||||
onClick={onActivate}
|
||||
role="button"
|
||||
aria-label={ariaLabel}
|
||||
tabIndex={0}
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
<CardContent>
|
||||
<div className="flex w-full flex-col gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
{logo && (
|
||||
<div className="border-border-neutral-secondary bg-bg-neutral-primary flex h-10 w-10 min-w-10 shrink-0 items-center justify-center rounded-md border">
|
||||
<Image
|
||||
src={logo}
|
||||
alt={`${frameworkTitle} logo`}
|
||||
width={32}
|
||||
height={32}
|
||||
sizes="32px"
|
||||
className="h-8 w-8 object-contain"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
{tooltip ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{title}</TooltipTrigger>
|
||||
<TooltipContent>{tooltip}</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
title
|
||||
)}
|
||||
{subtitle}
|
||||
</div>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
"use client";
|
||||
|
||||
import { ClientAccordionContent } from "@/components/compliance/compliance-accordion/client-accordion-content";
|
||||
import type { CheckProviderTypesMap, Requirement } from "@/types/compliance";
|
||||
|
||||
interface AggregatedRequirementContentProps {
|
||||
requirement: Requirement;
|
||||
framework: string;
|
||||
scanIds: string[];
|
||||
emptyMessage: string;
|
||||
checkProviders?: CheckProviderTypesMap;
|
||||
}
|
||||
|
||||
export const AggregatedRequirementContent = ({
|
||||
requirement,
|
||||
framework,
|
||||
scanIds,
|
||||
emptyMessage,
|
||||
checkProviders,
|
||||
}: AggregatedRequirementContentProps) => {
|
||||
if (scanIds.length === 0) return <p className="text-sm">{emptyMessage}</p>;
|
||||
|
||||
return (
|
||||
<ClientAccordionContent
|
||||
requirement={requirement}
|
||||
scanIds={scanIds}
|
||||
framework={framework}
|
||||
checkProviders={checkProviders}
|
||||
disableFindings={requirement.check_ids.length === 0}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -1,17 +0,0 @@
|
||||
interface ComplianceSectionHeaderProps {
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
/** Shared heading for the Multiple Scans tab's framework sections
|
||||
* ("Across provider types" / "Across providers"), so both explain their
|
||||
* aggregation axis with one consistent look. */
|
||||
export const ComplianceSectionHeader = ({
|
||||
title,
|
||||
description,
|
||||
}: ComplianceSectionHeaderProps) => (
|
||||
<div className="flex flex-col gap-1">
|
||||
<h3 className="text-sm font-semibold">{title}</h3>
|
||||
<p className="text-text-neutral-secondary text-xs">{description}</p>
|
||||
</div>
|
||||
);
|
||||
@@ -1,19 +1,11 @@
|
||||
import { Info } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
|
||||
import { getAllProviderGroups } from "@/actions/manage-groups/manage-groups";
|
||||
import { getAllProviders } from "@/actions/providers";
|
||||
import {
|
||||
ClientAccordionWrapper,
|
||||
RequirementsStatusCard,
|
||||
TopFailedSectionsCard,
|
||||
} from "@/components/compliance";
|
||||
import { getComplianceIcon } from "@/components/icons/compliance/IconCompliance";
|
||||
import { ProviderTypeIcon } from "@/components/icons/providers-badge/provider-type-icon";
|
||||
import { Alert, AlertDescription } from "@/components/shadcn/alert";
|
||||
import { Card } from "@/components/shadcn/card/card";
|
||||
import { getComplianceMapper } from "@/lib/compliance/compliance-mapper";
|
||||
import type { Framework, RequirementsTotals } from "@/types/compliance";
|
||||
import {
|
||||
type KnownProviderType,
|
||||
PROVIDER_DISPLAY_NAMES,
|
||||
@@ -23,6 +15,10 @@ import {
|
||||
getCrossAccountComplianceOverview,
|
||||
getLatestCrossAccountPdf,
|
||||
} from "../_actions/cross-account";
|
||||
import {
|
||||
getAggregatedInitialExpandedKeys,
|
||||
getAggregatedRequirementsTotals,
|
||||
} from "../_lib/aggregated-compliance-detail";
|
||||
import { toCrossAccountAccordionItems } from "../_lib/cross-account-accordion";
|
||||
import {
|
||||
buildAccountExtrasMap,
|
||||
@@ -32,6 +28,7 @@ import {
|
||||
import { parseCrossAccountFilters } from "../_lib/cross-account-frameworks";
|
||||
import { CROSS_PROVIDER_OVERVIEW_RESULT_STATUS } from "../_types";
|
||||
|
||||
import { AggregatedComplianceDetail } from "./aggregated-compliance-detail";
|
||||
import { CrossProviderErrorAlert } from "./cross-provider-error-alert";
|
||||
import type {
|
||||
CrossProviderAccountOption,
|
||||
@@ -132,14 +129,7 @@ export const CrossAccountDetail = async ({
|
||||
}),
|
||||
);
|
||||
|
||||
const totals: RequirementsTotals = data.reduce(
|
||||
(acc: RequirementsTotals, framework: Framework) => ({
|
||||
pass: acc.pass + framework.pass,
|
||||
fail: acc.fail + framework.fail,
|
||||
manual: acc.manual + framework.manual,
|
||||
}),
|
||||
{ pass: 0, fail: 0, manual: 0 },
|
||||
);
|
||||
const totals = getAggregatedRequirementsTotals(data);
|
||||
const accordionItems = toCrossAccountAccordionItems(
|
||||
data,
|
||||
extras,
|
||||
@@ -148,18 +138,11 @@ export const CrossAccountDetail = async ({
|
||||
);
|
||||
const topFailedResult = mapper.getTopFailedSections(data);
|
||||
|
||||
// Same `${framework.name}-${category.name}` key scheme as the per-scan
|
||||
// detail, so ?section= deep links (e.g. from Top Failed Sections) work.
|
||||
const initialExpandedKeys: string[] = [];
|
||||
if (targetSection) {
|
||||
const candidates = new Set(
|
||||
data.map((framework: Framework) => `${framework.name}-${targetSection}`),
|
||||
);
|
||||
const match = accordionItems.find((item) => candidates.has(item.key));
|
||||
if (match) {
|
||||
initialExpandedKeys.push(match.key);
|
||||
}
|
||||
}
|
||||
const initialExpandedKeys = getAggregatedInitialExpandedKeys(
|
||||
data,
|
||||
accordionItems,
|
||||
targetSection,
|
||||
);
|
||||
|
||||
const logoPath = getComplianceIcon(compliancetitle);
|
||||
|
||||
@@ -180,79 +163,52 @@ export const CrossAccountDetail = async ({
|
||||
).map((group) => ({ id: group.id, name: group.attributes.name }));
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-8">
|
||||
{/* Header card — same structure as the cross-provider detail: identity
|
||||
row (logo + context), filters below. */}
|
||||
<Card variant="base" className="w-full gap-4 p-4 md:p-5">
|
||||
<div className="flex w-full flex-col gap-4">
|
||||
<div className="flex w-full items-center justify-between gap-4">
|
||||
<div className="flex min-w-0 items-center gap-4">
|
||||
{logoPath && (
|
||||
<div className="relative h-12 w-12 shrink-0">
|
||||
<Image
|
||||
src={logoPath}
|
||||
alt={`${compliancetitle} logo`}
|
||||
fill
|
||||
className="rounded-lg border border-gray-300 bg-white object-contain p-0"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
<span className="truncate text-sm font-medium">
|
||||
{attrs.name || compliancetitle.split("-").join(" ")}
|
||||
</span>
|
||||
<p className="text-text-neutral-tertiary flex items-center gap-1.5 text-xs">
|
||||
<ProviderTypeIcon type={providerType} size={14} />
|
||||
{PROVIDER_DISPLAY_NAMES[providerType]} ·{" "}
|
||||
{attrs.accounts.length}{" "}
|
||||
{attrs.accounts.length === 1 ? "account" : "accounts"}{" "}
|
||||
aggregated · {attrs.scan_ids.length}{" "}
|
||||
{attrs.scan_ids.length === 1 ? "scan" : "scans"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="shrink-0">
|
||||
<CrossProviderPdfButton
|
||||
complianceId={complianceId}
|
||||
providerType={providerType}
|
||||
filters={{ ...filters, scanIds: attrs.scan_ids }}
|
||||
latestPdf={latestPdf}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* No providerTypes: the type select is meaningless here — the
|
||||
provider type is fixed by the framework being viewed. */}
|
||||
<CrossProviderFilters
|
||||
providerAccounts={providerAccounts}
|
||||
providerGroups={providerGroups}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 xl:grid-cols-[minmax(280px,400px)_minmax(280px,360px)_1fr]">
|
||||
<RequirementsStatusCard
|
||||
pass={totals.pass}
|
||||
fail={totals.fail}
|
||||
manual={totals.manual}
|
||||
<AggregatedComplianceDetail
|
||||
compliancetitle={compliancetitle}
|
||||
logoPath={logoPath}
|
||||
title={
|
||||
<span className="truncate text-sm font-medium">
|
||||
{attrs.name || compliancetitle.split("-").join(" ")}
|
||||
</span>
|
||||
}
|
||||
description={
|
||||
<p className="text-text-neutral-tertiary flex items-center gap-1.5 text-xs">
|
||||
<ProviderTypeIcon type={providerType} size={14} />
|
||||
{PROVIDER_DISPLAY_NAMES[providerType]} · {attrs.accounts.length}{" "}
|
||||
{attrs.accounts.length === 1 ? "account" : "accounts"} aggregated ·{" "}
|
||||
{attrs.scan_ids.length}{" "}
|
||||
{attrs.scan_ids.length === 1 ? "scan" : "scans"}
|
||||
</p>
|
||||
}
|
||||
reportAction={
|
||||
<CrossProviderPdfButton
|
||||
complianceId={complianceId}
|
||||
providerType={providerType}
|
||||
filters={{ ...filters, scanIds: attrs.scan_ids }}
|
||||
latestPdf={latestPdf}
|
||||
/>
|
||||
}
|
||||
filters={
|
||||
<CrossProviderFilters
|
||||
providerAccounts={providerAccounts}
|
||||
providerGroups={providerGroups}
|
||||
/>
|
||||
}
|
||||
totals={totals}
|
||||
coverage={
|
||||
<ProviderCoverageCard
|
||||
rows={coverageRows}
|
||||
title="Account Coverage"
|
||||
emptyMessage="No scanned accounts for this framework yet."
|
||||
/>
|
||||
<TopFailedSectionsCard
|
||||
sections={topFailedResult.items}
|
||||
dataType={topFailedResult.type}
|
||||
prepopulated={topFailedResult.prepopulated}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ClientAccordionWrapper
|
||||
items={accordionItems}
|
||||
defaultExpandedKeys={initialExpandedKeys}
|
||||
scrollToKey={initialExpandedKeys[0]}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
topFailed={{
|
||||
sections: topFailedResult.items,
|
||||
dataType: topFailedResult.type,
|
||||
prepopulated: topFailedResult.prepopulated,
|
||||
}}
|
||||
accordionItems={accordionItems}
|
||||
initialExpandedKeys={initialExpandedKeys}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
|
||||
import { getComplianceIcon } from "@/components/icons/compliance/IconCompliance";
|
||||
import { ProviderTypeIcon } from "@/components/icons/providers-badge/provider-type-icon";
|
||||
import { Card, CardContent } from "@/components/shadcn/card/card";
|
||||
import { PROVIDER_DISPLAY_NAMES } from "@/types/providers";
|
||||
|
||||
import { buildCrossAccountDetailHref } from "../_lib/cross-account-frameworks";
|
||||
import type { CrossAccountFrameworkEntry } from "../_types";
|
||||
|
||||
import { AggregatedFrameworkCard } from "./aggregated-framework-card";
|
||||
|
||||
/**
|
||||
* Card for a regular per-provider framework in the Cross-Provider tab's
|
||||
* "across accounts" section. Deliberately lightweight — no roll-up numbers:
|
||||
@@ -40,56 +39,26 @@ export const CrossAccountFrameworkCard = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<Card
|
||||
variant="base"
|
||||
padding="md"
|
||||
className="relative cursor-pointer transition-shadow hover:shadow-md"
|
||||
onClick={navigateToDetail}
|
||||
role="button"
|
||||
aria-label={`${formattedTitle} across ${PROVIDER_DISPLAY_NAMES[providerType]} providers`}
|
||||
tabIndex={0}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
navigateToDetail();
|
||||
}
|
||||
}}
|
||||
<AggregatedFrameworkCard
|
||||
frameworkTitle={title}
|
||||
formattedTitle={formattedTitle}
|
||||
ariaLabel={`${formattedTitle} across ${PROVIDER_DISPLAY_NAMES[providerType]} providers`}
|
||||
onActivate={navigateToDetail}
|
||||
subtitle={
|
||||
<small className="text-text-neutral-secondary truncate text-xs">
|
||||
View across providers
|
||||
</small>
|
||||
}
|
||||
>
|
||||
<CardContent className="p-0">
|
||||
<div className="flex w-full flex-col gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
{getComplianceIcon(title) && (
|
||||
<div className="flex h-10 w-10 min-w-10 shrink-0 items-center justify-center rounded-md border border-gray-300 bg-white">
|
||||
<Image
|
||||
src={getComplianceIcon(title)}
|
||||
alt={`${title} logo`}
|
||||
width={32}
|
||||
height={32}
|
||||
className="h-8 w-8 object-contain"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<h4 className="truncate text-sm leading-5 font-bold">
|
||||
{formattedTitle}
|
||||
</h4>
|
||||
<small className="text-text-neutral-secondary truncate text-xs">
|
||||
View across providers
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="inline-flex items-center gap-1.5 text-xs">
|
||||
<ProviderTypeIcon type={providerType} size={16} />
|
||||
{PROVIDER_DISPLAY_NAMES[providerType]}
|
||||
</span>
|
||||
<span className="text-text-neutral-secondary text-xs whitespace-nowrap">
|
||||
{accountCount} providers
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="inline-flex items-center gap-1.5 text-xs">
|
||||
<ProviderTypeIcon type={providerType} size={16} />
|
||||
{PROVIDER_DISPLAY_NAMES[providerType]}
|
||||
</span>
|
||||
<span className="text-text-neutral-secondary text-xs whitespace-nowrap">
|
||||
{accountCount} providers
|
||||
</span>
|
||||
</div>
|
||||
</AggregatedFrameworkCard>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -164,4 +164,94 @@ describe("CrossAccountOverviewSection", () => {
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
expect(getCompliancesOverview).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("scopes provider counts to the active account and group filters", async () => {
|
||||
// Given: three AWS providers exist, but only two match the active filters.
|
||||
vi.mocked(getAllProviders).mockImplementation(async ({ filters } = {}) => {
|
||||
const isFiltered =
|
||||
filters?.["filter[id__in]"] === "aws-1,aws-2" &&
|
||||
filters?.["filter[provider_groups__in]"] === "group-1";
|
||||
|
||||
return providersResponse(
|
||||
isFiltered
|
||||
? [
|
||||
{ id: "aws-1", type: "aws" },
|
||||
{ id: "aws-2", type: "aws" },
|
||||
]
|
||||
: [
|
||||
{ id: "aws-1", type: "aws" },
|
||||
{ id: "aws-2", type: "aws" },
|
||||
{ id: "aws-3", type: "aws" },
|
||||
],
|
||||
);
|
||||
});
|
||||
vi.mocked(getScans).mockResolvedValue(
|
||||
scansFor([{ id: "scan-1", providerId: "aws-1" }]),
|
||||
);
|
||||
vi.mocked(getCompliancesOverview).mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
id: "cis_2.0_aws",
|
||||
attributes: { framework: "CIS", version: "2.0" },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// When
|
||||
await renderSection({
|
||||
"filter[provider_id__in]": "aws-1,aws-2",
|
||||
"filter[provider_groups__in]": "group-1",
|
||||
});
|
||||
|
||||
// Then: the overview count matches the same provider set as the detail.
|
||||
expect(screen.getByText("1 framework · 2 providers")).toBeInTheDocument();
|
||||
expect(screen.queryByText(/3 providers/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("loads one representative completed scan for every eligible provider type", async () => {
|
||||
// Given: two eligible types whose representative scans must be resolved
|
||||
// independently, regardless of how many other scans the tenant has.
|
||||
vi.mocked(getAllProviders).mockResolvedValue(
|
||||
providersResponse([
|
||||
{ id: "aws-1", type: "aws" },
|
||||
{ id: "aws-2", type: "aws" },
|
||||
{ id: "gcp-1", type: "gcp" },
|
||||
{ id: "gcp-2", type: "gcp" },
|
||||
]),
|
||||
);
|
||||
vi.mocked(getScans).mockImplementation(async ({ filters }) => {
|
||||
const providerType = (
|
||||
filters as Record<string, string | undefined> | undefined
|
||||
)?.["filter[provider_type]"];
|
||||
if (providerType === "aws") {
|
||||
return scansFor([{ id: "scan-aws", providerId: "aws-1" }]);
|
||||
}
|
||||
if (providerType === "gcp") {
|
||||
return scansFor([{ id: "scan-gcp", providerId: "gcp-1" }]);
|
||||
}
|
||||
return scansFor([]);
|
||||
});
|
||||
vi.mocked(getCompliancesOverview).mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
id: "framework-1",
|
||||
attributes: { framework: "Framework", version: "1.0" },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// When
|
||||
await renderSection();
|
||||
|
||||
// Then
|
||||
expect(screen.getByText("AWS")).toBeInTheDocument();
|
||||
expect(screen.getByText("Google Cloud")).toBeInTheDocument();
|
||||
expect(getScans).toHaveBeenCalledTimes(2);
|
||||
expect(getCompliancesOverview).toHaveBeenCalledWith({
|
||||
scanId: "scan-aws",
|
||||
});
|
||||
expect(getCompliancesOverview).toHaveBeenCalledWith({
|
||||
scanId: "scan-gcp",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,7 +4,14 @@ import { getScans } from "@/actions/scans";
|
||||
import { ProviderTypeIcon } from "@/components/icons/providers-badge/provider-type-icon";
|
||||
import type { AccordionItemProps } from "@/components/shadcn/accordion/Accordion";
|
||||
import { Accordion } from "@/components/shadcn/accordion/Accordion";
|
||||
import type { ScanProps, SearchParamsProps } from "@/types";
|
||||
import {
|
||||
Section,
|
||||
SectionContent,
|
||||
SectionDescription,
|
||||
SectionHeader,
|
||||
SectionTitle,
|
||||
} from "@/components/shadcn/section/section";
|
||||
import type { SearchParamsProps } from "@/types";
|
||||
import type { ComplianceOverviewData } from "@/types/compliance";
|
||||
import {
|
||||
isKnownProviderType,
|
||||
@@ -15,7 +22,6 @@ import {
|
||||
import { CROSS_PROVIDER_FRAMEWORKS } from "../_lib/cross-provider-frameworks";
|
||||
import type { CrossAccountFrameworkEntry } from "../_types";
|
||||
|
||||
import { ComplianceSectionHeader } from "./compliance-section-header";
|
||||
import { CrossAccountFrameworkCard } from "./cross-account-framework-card";
|
||||
|
||||
/** Only provider types with at least this many accounts get cross-account
|
||||
@@ -27,9 +33,9 @@ const MIN_ACCOUNTS = 2;
|
||||
* for every provider type with 2+ accounts, lists the regular (per-provider)
|
||||
* frameworks that can be viewed aggregated across that type's accounts.
|
||||
*
|
||||
* The framework list per type comes from the latest completed scan of any
|
||||
* account of that type (frameworks are a property of the provider type, not
|
||||
* of the account). Universal frameworks are excluded — they already have
|
||||
* The framework list per type comes from a completed scan of any account of
|
||||
* that type (frameworks are a property of the provider type, not of the
|
||||
* account). Universal frameworks are excluded — they already have
|
||||
* their own cross-provider cards above. Renders nothing when no provider
|
||||
* type qualifies, keeping the tab unchanged for single-account tenants.
|
||||
* Best-effort by design: a type whose scan or framework list fails to load
|
||||
@@ -40,88 +46,95 @@ export const CrossAccountOverviewSection = async ({
|
||||
}: {
|
||||
searchParams: SearchParamsProps;
|
||||
}) => {
|
||||
const providerFilters = {
|
||||
"filter[provider_type__in]":
|
||||
searchParams["filter[provider_type__in]"]?.toString(),
|
||||
"filter[id__in]": searchParams["filter[provider_id__in]"]?.toString(),
|
||||
"filter[provider_groups__in]":
|
||||
searchParams["filter[provider_groups__in]"]?.toString(),
|
||||
};
|
||||
const providerTypeFilter =
|
||||
searchParams["filter[provider_type__in]"]
|
||||
?.toString()
|
||||
.split(",")
|
||||
.filter(Boolean) ?? [];
|
||||
|
||||
const [providersData, scansData] = await Promise.all([
|
||||
getAllProviders(),
|
||||
getScans({
|
||||
filters: { "filter[state]": "completed" },
|
||||
pageSize: 50,
|
||||
fields: { scans: "name,completed_at,provider" },
|
||||
include: "provider",
|
||||
}),
|
||||
]);
|
||||
providerFilters["filter[provider_type__in]"]?.split(",").filter(Boolean) ??
|
||||
[];
|
||||
const providersData = await getAllProviders({ filters: providerFilters });
|
||||
|
||||
const accountCounts = new Map<KnownProviderType, number>();
|
||||
const providerIdsByType = new Map<KnownProviderType, string[]>();
|
||||
for (const provider of providersData?.data || []) {
|
||||
const type = provider.attributes.provider;
|
||||
if (!isKnownProviderType(type)) continue;
|
||||
accountCounts.set(type, (accountCounts.get(type) ?? 0) + 1);
|
||||
providerIdsByType.set(type, [
|
||||
...(providerIdsByType.get(type) ?? []),
|
||||
provider.id,
|
||||
]);
|
||||
}
|
||||
|
||||
const eligibleTypes = Array.from(accountCounts.entries())
|
||||
.filter(([type, count]) => {
|
||||
if (count < MIN_ACCOUNTS) return false;
|
||||
return (
|
||||
providerTypeFilter.length === 0 || providerTypeFilter.includes(type)
|
||||
);
|
||||
})
|
||||
.filter(
|
||||
([type, count]) =>
|
||||
count >= MIN_ACCOUNTS &&
|
||||
(providerTypeFilter.length === 0 || providerTypeFilter.includes(type)),
|
||||
)
|
||||
.map(([type]) => type)
|
||||
.sort();
|
||||
|
||||
if (eligibleTypes.length === 0) return null;
|
||||
|
||||
// Latest completed scan per eligible type (the API returns scans newest
|
||||
// first). One representative scan per type is enough to enumerate the
|
||||
// type's frameworks.
|
||||
const latestScanByType = new Map<KnownProviderType, string>();
|
||||
for (const scan of (scansData?.data || []) as ScanProps[]) {
|
||||
const providerId = scan.relationships?.provider?.data?.id;
|
||||
if (!providerId) continue;
|
||||
const providerData = scansData.included?.find(
|
||||
(item: { type: string; id: string }) =>
|
||||
item.type === "providers" && item.id === providerId,
|
||||
);
|
||||
const type = providerData?.attributes?.provider;
|
||||
if (!isKnownProviderType(type)) continue;
|
||||
if (!eligibleTypes.includes(type) || latestScanByType.has(type)) continue;
|
||||
latestScanByType.set(type, scan.id);
|
||||
}
|
||||
// Resolve one representative scan independently for every eligible type.
|
||||
// A single global scans page can omit less-recent provider types on tenants
|
||||
// with a large scan history.
|
||||
const scansByType = await Promise.all(
|
||||
eligibleTypes.map(async (type) => {
|
||||
const scansData = await getScans({
|
||||
filters: {
|
||||
"filter[state]": "completed",
|
||||
"filter[provider_type]": type,
|
||||
"filter[provider__in]": (providerIdsByType.get(type) ?? []).join(","),
|
||||
},
|
||||
pageSize: 1,
|
||||
fields: { scans: "name" },
|
||||
});
|
||||
const scanId = scansData?.data?.[0]?.id;
|
||||
return scanId ? ([type, scanId] as const) : null;
|
||||
}),
|
||||
);
|
||||
const representativeScanByType = new Map(
|
||||
scansByType.filter((entry) => entry !== null),
|
||||
);
|
||||
|
||||
const universalIds = new Set(
|
||||
CROSS_PROVIDER_FRAMEWORKS.map((entry) => entry.complianceId),
|
||||
);
|
||||
|
||||
const entriesByType = await Promise.all(
|
||||
Array.from(latestScanByType.entries()).map(async ([type, scanId]) => {
|
||||
const compliancesData = await getCompliancesOverview({ scanId });
|
||||
const frameworks: ComplianceOverviewData[] = Array.isArray(
|
||||
compliancesData?.data,
|
||||
)
|
||||
? compliancesData.data
|
||||
: [];
|
||||
Array.from(representativeScanByType.entries()).map(
|
||||
async ([type, scanId]) => {
|
||||
const compliancesData = await getCompliancesOverview({ scanId });
|
||||
const frameworks: ComplianceOverviewData[] = Array.isArray(
|
||||
compliancesData?.data,
|
||||
)
|
||||
? compliancesData.data
|
||||
: [];
|
||||
|
||||
return frameworks
|
||||
.filter(
|
||||
(compliance) =>
|
||||
compliance.attributes.framework !== "ProwlerThreatScore" &&
|
||||
!universalIds.has(compliance.id),
|
||||
)
|
||||
.map(
|
||||
(compliance): CrossAccountFrameworkEntry => ({
|
||||
complianceId: compliance.id,
|
||||
title: compliance.attributes.framework,
|
||||
version: compliance.attributes.version,
|
||||
providerType: type,
|
||||
accountCount: accountCounts.get(type) ?? 0,
|
||||
}),
|
||||
)
|
||||
.sort((a, b) => a.title.localeCompare(b.title));
|
||||
}),
|
||||
return frameworks
|
||||
.filter(
|
||||
(compliance) =>
|
||||
compliance.attributes.framework !== "ProwlerThreatScore" &&
|
||||
!universalIds.has(compliance.id),
|
||||
)
|
||||
.map(
|
||||
(compliance): CrossAccountFrameworkEntry => ({
|
||||
complianceId: compliance.id,
|
||||
title: compliance.attributes.framework,
|
||||
version: compliance.attributes.version,
|
||||
providerType: type,
|
||||
accountCount: accountCounts.get(type) ?? 0,
|
||||
}),
|
||||
)
|
||||
.sort((a, b) => a.title.localeCompare(b.title));
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
const groups = entriesByType
|
||||
@@ -160,12 +173,18 @@ export const CrossAccountOverviewSection = async ({
|
||||
});
|
||||
|
||||
return (
|
||||
<section className="flex flex-col gap-4">
|
||||
<ComplianceSectionHeader
|
||||
title="Across providers"
|
||||
description="Single-provider frameworks aggregated across every provider of the same type, using each provider's latest completed scan. Expand a provider type to browse its frameworks."
|
||||
/>
|
||||
<Accordion items={accordionItems} selectionMode="multiple" />
|
||||
</section>
|
||||
<Section>
|
||||
<SectionHeader>
|
||||
<SectionTitle>Across providers</SectionTitle>
|
||||
<SectionDescription>
|
||||
Single-provider frameworks aggregated across every provider of the
|
||||
same type, using each provider's latest completed scan. Expand a
|
||||
provider type to browse its frameworks.
|
||||
</SectionDescription>
|
||||
</SectionHeader>
|
||||
<SectionContent>
|
||||
<Accordion items={accordionItems} selectionMode="multiple" />
|
||||
</SectionContent>
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { ClientAccordionContent } from "@/components/compliance/compliance-accordion/client-accordion-content";
|
||||
import type { Requirement } from "@/types/compliance";
|
||||
|
||||
import type {
|
||||
@@ -8,6 +7,8 @@ import type {
|
||||
CrossAccountRequirementExtras,
|
||||
} from "../_types";
|
||||
|
||||
import { AggregatedRequirementContent } from "./aggregated-requirement-content";
|
||||
|
||||
interface CrossAccountRequirementContentProps {
|
||||
/** The requirement as produced by the framework mapper (roll-up level). */
|
||||
requirement: Requirement;
|
||||
@@ -34,15 +35,6 @@ export const CrossAccountRequirementContent = ({
|
||||
(account) => extras.accounts[account.id],
|
||||
);
|
||||
|
||||
if (contributingAccounts.length === 0) {
|
||||
return (
|
||||
<p className="text-sm">
|
||||
No account scan contributed to this requirement with the current
|
||||
filters.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
const scanIds = Array.from(
|
||||
new Set(
|
||||
contributingAccounts.flatMap(
|
||||
@@ -52,11 +44,11 @@ export const CrossAccountRequirementContent = ({
|
||||
);
|
||||
|
||||
return (
|
||||
<ClientAccordionContent
|
||||
<AggregatedRequirementContent
|
||||
requirement={requirement}
|
||||
scanIds={scanIds}
|
||||
framework={framework}
|
||||
disableFindings={requirement.check_ids.length === 0}
|
||||
emptyMessage="No account scan contributed to this requirement with the current filters."
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,23 +1,19 @@
|
||||
import { Info } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
|
||||
import { getAllProviderGroups } from "@/actions/manage-groups/manage-groups";
|
||||
import { getAllProviders } from "@/actions/providers";
|
||||
import {
|
||||
ClientAccordionWrapper,
|
||||
RequirementsStatusCard,
|
||||
TopFailedSectionsCard,
|
||||
} from "@/components/compliance";
|
||||
import { getComplianceIcon } from "@/components/icons/compliance/IconCompliance";
|
||||
import { Alert, AlertDescription } from "@/components/shadcn/alert";
|
||||
import { Card } from "@/components/shadcn/card/card";
|
||||
import { getComplianceMapper } from "@/lib/compliance/compliance-mapper";
|
||||
import type { Framework, RequirementsTotals } from "@/types/compliance";
|
||||
|
||||
import {
|
||||
getCrossProviderComplianceOverview,
|
||||
getLatestCrossProviderPdf,
|
||||
} from "../_actions/cross-provider";
|
||||
import {
|
||||
getAggregatedInitialExpandedKeys,
|
||||
getAggregatedRequirementsTotals,
|
||||
} from "../_lib/aggregated-compliance-detail";
|
||||
import { toCrossProviderAccordionItems } from "../_lib/cross-provider-accordion";
|
||||
import {
|
||||
buildRequirementExtrasMap,
|
||||
@@ -30,6 +26,7 @@ import {
|
||||
} from "../_lib/cross-provider-frameworks";
|
||||
import { CROSS_PROVIDER_OVERVIEW_RESULT_STATUS } from "../_types";
|
||||
|
||||
import { AggregatedComplianceDetail } from "./aggregated-compliance-detail";
|
||||
import { CrossProviderErrorAlert } from "./cross-provider-error-alert";
|
||||
import type {
|
||||
CrossProviderAccountOption,
|
||||
@@ -114,14 +111,7 @@ export const CrossProviderDetail = async ({
|
||||
const extras = buildRequirementExtrasMap(attrs);
|
||||
const providerBreakdown = computeProviderBreakdown(attrs);
|
||||
|
||||
const totals: RequirementsTotals = data.reduce(
|
||||
(acc: RequirementsTotals, framework: Framework) => ({
|
||||
pass: acc.pass + framework.pass,
|
||||
fail: acc.fail + framework.fail,
|
||||
manual: acc.manual + framework.manual,
|
||||
}),
|
||||
{ pass: 0, fail: 0, manual: 0 },
|
||||
);
|
||||
const totals = getAggregatedRequirementsTotals(data);
|
||||
const accordionItems = toCrossProviderAccordionItems(
|
||||
data,
|
||||
extras,
|
||||
@@ -129,18 +119,11 @@ export const CrossProviderDetail = async ({
|
||||
);
|
||||
const topFailedResult = mapper.getTopFailedSections(data);
|
||||
|
||||
// Same `${framework.name}-${category.name}` key scheme as the per-scan
|
||||
// detail, so ?section= deep links (e.g. from Top Failed Sections) work.
|
||||
const initialExpandedKeys: string[] = [];
|
||||
if (targetSection) {
|
||||
const candidates = new Set(
|
||||
data.map((framework: Framework) => `${framework.name}-${targetSection}`),
|
||||
);
|
||||
const match = accordionItems.find((item) => candidates.has(item.key));
|
||||
if (match) {
|
||||
initialExpandedKeys.push(match.key);
|
||||
}
|
||||
}
|
||||
const initialExpandedKeys = getAggregatedInitialExpandedKeys(
|
||||
data,
|
||||
accordionItems,
|
||||
targetSection,
|
||||
);
|
||||
|
||||
const catalogEntry = CROSS_PROVIDER_FRAMEWORKS.find(
|
||||
(entry) => entry.complianceId === complianceId,
|
||||
@@ -169,74 +152,47 @@ export const CrossProviderDetail = async ({
|
||||
).map((group) => ({ id: group.id, name: group.attributes.name }));
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-8">
|
||||
{/* Header card — same structure as the per-scan detail: identity row
|
||||
(logo + context) with the report action top-right, filters below
|
||||
(lighthouse-settings card pattern). */}
|
||||
<Card variant="base" className="w-full gap-4 p-4 md:p-5">
|
||||
<div className="flex w-full flex-col gap-4">
|
||||
<div className="flex w-full items-center justify-between gap-4">
|
||||
<div className="flex min-w-0 items-center gap-4">
|
||||
{logoPath && (
|
||||
<div className="relative h-12 w-12 shrink-0">
|
||||
<Image
|
||||
src={logoPath}
|
||||
alt={`${compliancetitle} logo`}
|
||||
fill
|
||||
className="rounded-lg border border-gray-300 bg-white object-contain p-0"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="truncate text-sm font-medium">
|
||||
{attrs.name || compliancetitle.split("-").join(" ")}
|
||||
</span>
|
||||
<CrossProviderHubLink complianceId={complianceId} />
|
||||
</div>
|
||||
<p className="text-text-neutral-tertiary text-xs">
|
||||
{attrs.providers.length} of {compatibleTypes.length}{" "}
|
||||
compatible providers scanned · {attrs.scan_ids.length}{" "}
|
||||
{attrs.scan_ids.length === 1 ? "scan" : "scans"} aggregated
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="shrink-0">
|
||||
<CrossProviderPdfButton
|
||||
complianceId={complianceId}
|
||||
filters={{ ...filters, scanIds: attrs.scan_ids }}
|
||||
latestPdf={latestPdf}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CrossProviderFilters
|
||||
providerTypes={compatibleTypes}
|
||||
providerAccounts={providerAccounts}
|
||||
providerGroups={providerGroups}
|
||||
/>
|
||||
<AggregatedComplianceDetail
|
||||
compliancetitle={compliancetitle}
|
||||
logoPath={logoPath}
|
||||
title={
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="truncate text-sm font-medium">
|
||||
{attrs.name || compliancetitle.split("-").join(" ")}
|
||||
</span>
|
||||
<CrossProviderHubLink complianceId={complianceId} />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 xl:grid-cols-[minmax(280px,400px)_minmax(280px,360px)_1fr]">
|
||||
<RequirementsStatusCard
|
||||
pass={totals.pass}
|
||||
fail={totals.fail}
|
||||
manual={totals.manual}
|
||||
}
|
||||
description={
|
||||
<p className="text-text-neutral-tertiary text-xs">
|
||||
{attrs.providers.length} of {compatibleTypes.length} compatible
|
||||
providers scanned · {attrs.scan_ids.length}{" "}
|
||||
{attrs.scan_ids.length === 1 ? "scan" : "scans"} aggregated
|
||||
</p>
|
||||
}
|
||||
reportAction={
|
||||
<CrossProviderPdfButton
|
||||
complianceId={complianceId}
|
||||
filters={{ ...filters, scanIds: attrs.scan_ids }}
|
||||
latestPdf={latestPdf}
|
||||
/>
|
||||
<ProviderCoverageCard breakdown={providerBreakdown} />
|
||||
<TopFailedSectionsCard
|
||||
sections={topFailedResult.items}
|
||||
dataType={topFailedResult.type}
|
||||
prepopulated={topFailedResult.prepopulated}
|
||||
}
|
||||
filters={
|
||||
<CrossProviderFilters
|
||||
providerTypes={compatibleTypes}
|
||||
providerAccounts={providerAccounts}
|
||||
providerGroups={providerGroups}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ClientAccordionWrapper
|
||||
items={accordionItems}
|
||||
defaultExpandedKeys={initialExpandedKeys}
|
||||
scrollToKey={initialExpandedKeys[0]}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
totals={totals}
|
||||
coverage={<ProviderCoverageCard breakdown={providerBreakdown} />}
|
||||
topFailed={{
|
||||
sections: topFailedResult.items,
|
||||
dataType: topFailedResult.type,
|
||||
prepopulated: topFailedResult.prepopulated,
|
||||
}}
|
||||
accordionItems={accordionItems}
|
||||
initialExpandedKeys={initialExpandedKeys}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,27 +1,23 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
|
||||
import { getComplianceIcon } from "@/components/icons/compliance/IconCompliance";
|
||||
import { ProviderTypeIcon } from "@/components/icons/providers-badge/provider-type-icon";
|
||||
import { Card, CardContent } from "@/components/shadcn/card/card";
|
||||
import { Progress } from "@/components/shadcn/progress";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/shadcn/tooltip";
|
||||
import {
|
||||
getScoreIndicatorClass,
|
||||
type ScoreColorVariant,
|
||||
} from "@/lib/compliance/score-utils";
|
||||
import type { ScoreColorVariant } from "@/lib/compliance/score-utils";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { PROVIDER_DISPLAY_NAMES } from "@/types/providers";
|
||||
|
||||
import { buildCrossProviderDetailHref } from "../_lib/cross-provider-frameworks";
|
||||
import type { CrossProviderFrameworkSummary } from "../_types";
|
||||
|
||||
import { AggregatedFrameworkCard } from "./aggregated-framework-card";
|
||||
|
||||
export const CrossProviderFrameworkCard = ({
|
||||
complianceId,
|
||||
title,
|
||||
@@ -60,103 +56,66 @@ export const CrossProviderFrameworkCard = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<Card
|
||||
variant="base"
|
||||
padding="md"
|
||||
className="relative cursor-pointer transition-shadow hover:shadow-md"
|
||||
onClick={navigateToDetail}
|
||||
role="button"
|
||||
aria-label={formattedTitle}
|
||||
tabIndex={0}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
navigateToDetail();
|
||||
}
|
||||
}}
|
||||
<AggregatedFrameworkCard
|
||||
frameworkTitle={title}
|
||||
formattedTitle={formattedTitle}
|
||||
ariaLabel={formattedTitle}
|
||||
onActivate={navigateToDetail}
|
||||
tooltip={description}
|
||||
subtitle={
|
||||
<small className="truncate">
|
||||
<span className="mr-1 text-xs font-semibold">
|
||||
{requirementsPassed} / {totalRequirements}
|
||||
</span>
|
||||
Passing Requirements
|
||||
</small>
|
||||
}
|
||||
>
|
||||
<CardContent className="p-0">
|
||||
<div className="flex w-full flex-col gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
{getComplianceIcon(title) && (
|
||||
<div className="flex h-10 w-10 min-w-10 shrink-0 items-center justify-center rounded-md border border-gray-300 bg-white">
|
||||
<Image
|
||||
src={getComplianceIcon(title)}
|
||||
alt={`${title} logo`}
|
||||
width={32}
|
||||
height={32}
|
||||
className="h-8 w-8 object-contain"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<h4 className="truncate text-sm leading-5 font-bold">
|
||||
{formattedTitle}
|
||||
</h4>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{description}</TooltipContent>
|
||||
</Tooltip>
|
||||
<small className="truncate">
|
||||
<span className="mr-1 text-xs font-semibold">
|
||||
{requirementsPassed} / {totalRequirements}
|
||||
</span>
|
||||
Passing Requirements
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between gap-3 text-xs">
|
||||
<span className="text-text-neutral-secondary font-medium tracking-wider">
|
||||
Score:
|
||||
</span>
|
||||
<span className="text-text-neutral-secondary">
|
||||
{ratingPercentage}%
|
||||
</span>
|
||||
</div>
|
||||
<Progress
|
||||
aria-label="Cross-provider compliance score"
|
||||
value={ratingPercentage}
|
||||
className="border-border-neutral-secondary h-2.5 border drop-shadow-sm"
|
||||
indicatorClassName={getScoreIndicatorClass(
|
||||
getRatingVariant(ratingPercentage),
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{providerBreakdown.map((entry) => (
|
||||
<Tooltip key={entry.provider}>
|
||||
<TooltipTrigger asChild>
|
||||
<span
|
||||
data-testid={`provider-chip-${entry.provider}`}
|
||||
data-unscanned={entry.unscanned || undefined}
|
||||
className={cn(
|
||||
"inline-flex items-center",
|
||||
entry.unscanned && "opacity-35 grayscale",
|
||||
)}
|
||||
>
|
||||
<ProviderTypeIcon type={entry.provider} size={18} />
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{PROVIDER_DISPLAY_NAMES[entry.provider]}
|
||||
{entry.unscanned
|
||||
? " — no completed scan yet"
|
||||
: ` — ${entry.score}% passing`}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
))}
|
||||
</div>
|
||||
<span className="text-text-neutral-secondary text-xs whitespace-nowrap">
|
||||
{requirementsFailed} failed · {requirementsManual} manual
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between gap-3 text-xs">
|
||||
<span className="text-text-neutral-secondary font-medium tracking-wider">
|
||||
Score:
|
||||
</span>
|
||||
<span className="text-text-neutral-secondary">
|
||||
{ratingPercentage}%
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Progress
|
||||
aria-label="Cross-provider compliance score"
|
||||
value={ratingPercentage}
|
||||
variant={getRatingVariant(ratingPercentage)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{providerBreakdown.map((entry) => (
|
||||
<Tooltip key={entry.provider}>
|
||||
<TooltipTrigger asChild>
|
||||
<span
|
||||
data-testid={`provider-chip-${entry.provider}`}
|
||||
data-unscanned={entry.unscanned || undefined}
|
||||
className={cn(
|
||||
"inline-flex items-center",
|
||||
entry.unscanned && "opacity-35 grayscale",
|
||||
)}
|
||||
>
|
||||
<ProviderTypeIcon type={entry.provider} size={18} />
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{PROVIDER_DISPLAY_NAMES[entry.provider]}
|
||||
{entry.unscanned
|
||||
? " — no completed scan yet"
|
||||
: ` — ${entry.score}% passing`}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
))}
|
||||
</div>
|
||||
<span className="text-text-neutral-secondary text-xs whitespace-nowrap">
|
||||
{requirementsFailed} failed · {requirementsManual} manual
|
||||
</span>
|
||||
</div>
|
||||
</AggregatedFrameworkCard>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -3,6 +3,13 @@ import { AlertTriangle, Info } from "lucide-react";
|
||||
import { getAllProviderGroups } from "@/actions/manage-groups/manage-groups";
|
||||
import { getAllProviders } from "@/actions/providers";
|
||||
import { Alert, AlertDescription } from "@/components/shadcn/alert";
|
||||
import {
|
||||
Section,
|
||||
SectionContent,
|
||||
SectionDescription,
|
||||
SectionHeader,
|
||||
SectionTitle,
|
||||
} from "@/components/shadcn/section/section";
|
||||
import { SearchParamsProps } from "@/types";
|
||||
import type { KnownProviderType } from "@/types/providers";
|
||||
|
||||
@@ -16,7 +23,6 @@ import {
|
||||
import type { CrossProviderFrameworkSummary } from "../_types";
|
||||
import { CROSS_PROVIDER_OVERVIEW_RESULT_STATUS } from "../_types";
|
||||
|
||||
import { ComplianceSectionHeader } from "./compliance-section-header";
|
||||
import { CrossProviderErrorAlert } from "./cross-provider-error-alert";
|
||||
import type {
|
||||
CrossProviderAccountOption,
|
||||
@@ -181,20 +187,25 @@ export const CrossProviderOverview = async ({
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<section className="flex flex-col gap-4">
|
||||
<ComplianceSectionHeader
|
||||
title="Across provider types"
|
||||
description="Universal frameworks aggregated across every compatible provider type, using the latest completed scan of each provider."
|
||||
/>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4">
|
||||
{summaries.map((summary) => (
|
||||
<CrossProviderFrameworkCard
|
||||
key={summary.complianceId}
|
||||
{...summary}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
<Section>
|
||||
<SectionHeader>
|
||||
<SectionTitle>Across provider types</SectionTitle>
|
||||
<SectionDescription>
|
||||
Universal frameworks aggregated across every compatible provider
|
||||
type, using the latest completed scan of each provider.
|
||||
</SectionDescription>
|
||||
</SectionHeader>
|
||||
<SectionContent>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4">
|
||||
{summaries.map((summary) => (
|
||||
<CrossProviderFrameworkCard
|
||||
key={summary.complianceId}
|
||||
{...summary}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</SectionContent>
|
||||
</Section>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -30,6 +30,7 @@ const {
|
||||
generateAccountPdfMock,
|
||||
trackAndPollMock,
|
||||
downloadPdfMock,
|
||||
downloadAccountPdfMock,
|
||||
toastMock,
|
||||
storeState,
|
||||
} = vi.hoisted(() => ({
|
||||
@@ -37,6 +38,7 @@ const {
|
||||
generateAccountPdfMock: vi.fn(),
|
||||
trackAndPollMock: vi.fn(),
|
||||
downloadPdfMock: vi.fn(),
|
||||
downloadAccountPdfMock: vi.fn(),
|
||||
toastMock: vi.fn(),
|
||||
storeState: {
|
||||
tasks: {} as Record<
|
||||
@@ -70,7 +72,7 @@ vi.mock("../_lib/cross-provider-pdf", () => ({
|
||||
vi.mock("../_lib/cross-account-pdf", () => ({
|
||||
CROSS_ACCOUNT_PDF_TASK_KIND: "cross-account-pdf",
|
||||
buildCrossAccountPdfTaskScope: vi.fn(() => "account-scope-1"),
|
||||
downloadCrossAccountPdf: vi.fn(),
|
||||
downloadCrossAccountPdf: downloadAccountPdfMock,
|
||||
crossAccountPdfHandler: { onReady: vi.fn(), onError: vi.fn() },
|
||||
}));
|
||||
|
||||
@@ -247,6 +249,27 @@ describe("CrossProviderPdfButton", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("downloads the latest report through the cross-account action", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<CrossProviderPdfButton
|
||||
{...props}
|
||||
providerType="aws"
|
||||
latestPdf={{ taskId: "task-acc-7", filename: "aws-latest.pdf" }}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /report/i }));
|
||||
await user.click(
|
||||
await screen.findByRole("menuitem", { name: /download latest/i }),
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(downloadAccountPdfMock).toHaveBeenCalledWith("task-acc-7"),
|
||||
);
|
||||
expect(downloadPdfMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not offer a completed report from a different filter scope", async () => {
|
||||
// Given
|
||||
storeState.tasks = {
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { ClientAccordionContent } from "@/components/compliance/compliance-accordion/client-accordion-content";
|
||||
import type { Requirement } from "@/types/compliance";
|
||||
import { PROVIDER_TYPES } from "@/types/providers";
|
||||
|
||||
import { invertCheckIdsByProvider } from "../_lib/cross-provider-adapter";
|
||||
import type { CrossProviderRequirementExtras } from "../_types";
|
||||
|
||||
import { AggregatedRequirementContent } from "./aggregated-requirement-content";
|
||||
|
||||
interface CrossProviderRequirementContentProps {
|
||||
/** The requirement as produced by the framework mapper (roll-up level). */
|
||||
requirement: Requirement;
|
||||
@@ -31,15 +32,6 @@ export const CrossProviderRequirementContent = ({
|
||||
(type) => extras.providers[type],
|
||||
);
|
||||
|
||||
if (contributingTypes.length === 0) {
|
||||
return (
|
||||
<p className="text-sm">
|
||||
No provider scan contributed to this requirement with the current
|
||||
filters.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
const scanIds = Array.from(
|
||||
new Set(
|
||||
contributingTypes.flatMap((type) => extras.scanIdsByProvider[type] ?? []),
|
||||
@@ -47,12 +39,12 @@ export const CrossProviderRequirementContent = ({
|
||||
);
|
||||
|
||||
return (
|
||||
<ClientAccordionContent
|
||||
<AggregatedRequirementContent
|
||||
requirement={requirement}
|
||||
scanIds={scanIds}
|
||||
framework={framework}
|
||||
checkProviders={invertCheckIdsByProvider(extras.checkIdsByProvider)}
|
||||
disableFindings={requirement.check_ids.length === 0}
|
||||
emptyMessage="No provider scan contributed to this requirement with the current filters."
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -3,10 +3,7 @@
|
||||
import { ProviderTypeIcon } from "@/components/icons/providers-badge/provider-type-icon";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/shadcn";
|
||||
import { Progress } from "@/components/shadcn/progress";
|
||||
import {
|
||||
getScoreColor,
|
||||
getScoreIndicatorClass,
|
||||
} from "@/lib/compliance/score-utils";
|
||||
import { getScoreColor } from "@/lib/compliance/score-utils";
|
||||
import type { KnownProviderType } from "@/types/providers";
|
||||
import { PROVIDER_DISPLAY_NAMES } from "@/types/providers";
|
||||
|
||||
@@ -57,43 +54,47 @@ export const ProviderCoverageCard = ({
|
||||
}));
|
||||
|
||||
return (
|
||||
<Card variant="base" className="flex h-full min-h-[372px] flex-col">
|
||||
<CardHeader>
|
||||
<CardTitle>{title}</CardTitle>
|
||||
</CardHeader>
|
||||
{/* Capped + scrollable so a long list never stretches the sibling
|
||||
chart cards in the same grid row. */}
|
||||
<CardContent className="minimal-scrollbar flex max-h-[300px] flex-col gap-4 overflow-y-auto">
|
||||
{resolvedRows.length === 0 && (
|
||||
<p className="text-text-neutral-secondary text-sm">{emptyMessage}</p>
|
||||
)}
|
||||
{resolvedRows.map((entry) => (
|
||||
<div key={entry.key} data-testid={`coverage-row-${entry.key}`}>
|
||||
<div className="flex items-center justify-between gap-3 text-sm">
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<ProviderTypeIcon type={entry.iconType} size={18} />
|
||||
<span className="truncate">{entry.label}</span>
|
||||
</span>
|
||||
<span className="text-text-neutral-secondary text-xs">
|
||||
{entry.score}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1.5 flex items-center gap-3">
|
||||
<Progress
|
||||
aria-label={`${entry.label} passing score`}
|
||||
value={entry.score}
|
||||
className="border-border-neutral-secondary h-2 border"
|
||||
indicatorClassName={getScoreIndicatorClass(
|
||||
getScoreColor(entry.score),
|
||||
)}
|
||||
/>
|
||||
<span className="text-text-neutral-tertiary text-xs whitespace-nowrap">
|
||||
{entry.pass}/{entry.pass + entry.fail} · {entry.manual} manual
|
||||
</span>
|
||||
</div>
|
||||
<Card variant="base">
|
||||
<div className="flex min-h-[340px] flex-col gap-6">
|
||||
<CardHeader>
|
||||
<CardTitle>{title}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{/* Capped + scrollable so a long list never stretches the sibling
|
||||
chart cards in the same grid row. */}
|
||||
<div className="minimal-scrollbar flex max-h-[300px] flex-col gap-4 overflow-y-auto">
|
||||
{resolvedRows.length === 0 && (
|
||||
<p className="text-text-neutral-secondary text-sm">
|
||||
{emptyMessage}
|
||||
</p>
|
||||
)}
|
||||
{resolvedRows.map((entry) => (
|
||||
<div key={entry.key} data-testid={`coverage-row-${entry.key}`}>
|
||||
<div className="flex items-center justify-between gap-3 text-sm">
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<ProviderTypeIcon type={entry.iconType} size={18} />
|
||||
<span className="truncate">{entry.label}</span>
|
||||
</span>
|
||||
<span className="text-text-neutral-secondary text-xs">
|
||||
{entry.score}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1.5 flex items-center gap-3">
|
||||
<Progress
|
||||
aria-label={`${entry.label} passing score`}
|
||||
value={entry.score}
|
||||
variant={getScoreColor(entry.score)}
|
||||
/>
|
||||
<span className="text-text-neutral-tertiary text-xs whitespace-nowrap">
|
||||
{entry.pass}/{entry.pass + entry.fail} · {entry.manual}{" "}
|
||||
manual
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</CardContent>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { RequirementStatusSummary } from "./requirement-status-summary";
|
||||
|
||||
describe("RequirementStatusSummary", () => {
|
||||
it("exposes the complete breakdown from a keyboard-accessible trigger", async () => {
|
||||
const user = userEvent.setup();
|
||||
const entries = Array.from({ length: 13 }, (_, index) => ({
|
||||
key: `account-${index}`,
|
||||
label: `Account ${index + 1}`,
|
||||
status: index === 0 ? ("FAIL" as const) : ("PASS" as const),
|
||||
}));
|
||||
|
||||
render(<RequirementStatusSummary entries={entries} />);
|
||||
|
||||
const trigger = screen.getByRole("button", {
|
||||
name: "Show status breakdown for 13 providers",
|
||||
});
|
||||
trigger.focus();
|
||||
expect(trigger).toHaveFocus();
|
||||
|
||||
await user.click(trigger);
|
||||
|
||||
expect(screen.getByText("Account 1")).toBeVisible();
|
||||
expect(screen.getByText("Account 13")).toBeVisible();
|
||||
expect(screen.queryByText(/more/)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -2,23 +2,25 @@
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { Button } from "@/components/shadcn/button/button";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/shadcn/popover";
|
||||
import { ScrollArea } from "@/components/shadcn/scroll-area";
|
||||
import {
|
||||
type FindingStatus,
|
||||
StatusFindingBadge,
|
||||
} from "@/components/shadcn/table/status-finding-badge";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/shadcn/tooltip";
|
||||
|
||||
import type { CrossProviderStatus } from "../_types";
|
||||
|
||||
export interface RequirementStatusEntry {
|
||||
key: string;
|
||||
/** Short label shown in the hover breakdown list. */
|
||||
/** Short label shown in the breakdown popover. */
|
||||
label: string;
|
||||
/** Optional icon rendered before the label in the breakdown list. */
|
||||
/** Optional icon rendered before the label in the breakdown popover. */
|
||||
icon?: ReactNode;
|
||||
status: CrossProviderStatus;
|
||||
}
|
||||
@@ -26,15 +28,14 @@ export interface RequirementStatusEntry {
|
||||
/** Statuses in triage order — failures first, evidence last. */
|
||||
const STATUS_ORDER: readonly CrossProviderStatus[] = ["FAIL", "MANUAL", "PASS"];
|
||||
|
||||
/** Hover breakdown stays glanceable; beyond this it points at the row's
|
||||
* drill-down instead of becoming a scrolling list inside a tooltip. */
|
||||
const MAX_BREAKDOWN_ROWS = 12;
|
||||
const SCROLLABLE_BREAKDOWN_MIN_ROWS = 13;
|
||||
|
||||
/**
|
||||
* Aggregated per-status counts for a requirement row whose column axis has
|
||||
* too many members to chip inline (many accounts of one provider type, or
|
||||
* many provider types). Constant footprint regardless of N: one count badge
|
||||
* per status present, with the full per-member breakdown on hover.
|
||||
* per status present, with the full per-member breakdown in an accessible
|
||||
* popover.
|
||||
*/
|
||||
export const RequirementStatusSummary = ({
|
||||
entries,
|
||||
@@ -46,15 +47,35 @@ export const RequirementStatusSummary = ({
|
||||
count: entries.filter((entry) => entry.status === status).length,
|
||||
})).filter(({ count }) => count > 0);
|
||||
|
||||
const breakdown = entries.slice(0, MAX_BREAKDOWN_ROWS);
|
||||
const hidden = entries.length - breakdown.length;
|
||||
const breakdownList = (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{entries.map((entry) => (
|
||||
<span
|
||||
key={entry.key}
|
||||
className="flex items-center justify-between gap-3"
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-1.5">
|
||||
{entry.icon}
|
||||
<span className="max-w-48 truncate text-xs">{entry.label}</span>
|
||||
</span>
|
||||
<StatusFindingBadge
|
||||
status={entry.status as FindingStatus}
|
||||
size="sm"
|
||||
/>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="bare"
|
||||
size="link-xs"
|
||||
aria-label={`Show status breakdown for ${entries.length} providers`}
|
||||
data-testid="requirement-status-summary"
|
||||
className="flex shrink-0 items-center gap-2"
|
||||
>
|
||||
{counts.map(({ status, count }) => (
|
||||
<span key={status} className="inline-flex items-center gap-1">
|
||||
@@ -64,32 +85,15 @@ export const RequirementStatusSummary = ({
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{breakdown.map((entry) => (
|
||||
<span
|
||||
key={entry.key}
|
||||
className="flex items-center justify-between gap-3"
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-1.5">
|
||||
{entry.icon}
|
||||
<span className="max-w-48 truncate text-xs">{entry.label}</span>
|
||||
</span>
|
||||
<StatusFindingBadge
|
||||
status={entry.status as FindingStatus}
|
||||
size="sm"
|
||||
/>
|
||||
</span>
|
||||
))}
|
||||
{hidden > 0 && (
|
||||
<span className="text-text-neutral-tertiary text-xs">
|
||||
+{hidden} more — expand the requirement for the full breakdown
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end">
|
||||
{entries.length >= SCROLLABLE_BREAKDOWN_MIN_ROWS ? (
|
||||
<ScrollArea size="md">{breakdownList}</ScrollArea>
|
||||
) : (
|
||||
breakdownList
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -21,8 +21,17 @@ vi.mock(
|
||||
() => ({
|
||||
// Render the row name so parity assertions can compare the per-scan
|
||||
// accordion's visible titles against the cross-account builder's.
|
||||
ComplianceAccordionRequirementTitle: ({ name }: { name: string }) => (
|
||||
<span>{name}</span>
|
||||
ComplianceAccordionRequirementTitle: ({
|
||||
name,
|
||||
type,
|
||||
}: {
|
||||
name: string;
|
||||
type: string;
|
||||
}) => (
|
||||
<>
|
||||
{type && <span>{type}</span>}
|
||||
<span>{name}</span>
|
||||
</>
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { ComplianceAccordionRequirementTitle } from "@/components/compliance/compliance-accordion/compliance-accordion-requeriment-title";
|
||||
import { ComplianceAccordionTitle } from "@/components/compliance/compliance-accordion/compliance-accordion-title";
|
||||
import type { AccordionItemProps } from "@/components/shadcn/accordion/Accordion";
|
||||
import type { FindingStatus } from "@/components/shadcn/table/status-finding-badge";
|
||||
import type { Control, Framework, Requirement } from "@/types/compliance";
|
||||
|
||||
interface AggregatedComplianceAccordionOptions<TExtras> {
|
||||
data: Framework[];
|
||||
extras: Map<string, TExtras>;
|
||||
renderStatus: (extras: TExtras) => ReactNode;
|
||||
renderContent: (
|
||||
requirement: Requirement,
|
||||
extras: TExtras,
|
||||
itemKey: string,
|
||||
) => ReactNode;
|
||||
missingBreakdownMessage: string;
|
||||
}
|
||||
|
||||
/** Shared mapper-driven hierarchy for provider- and account-axis compliance.
|
||||
* Axis adapters supply only the status summary and lazy findings content. */
|
||||
export const toAggregatedComplianceAccordionItems = <TExtras,>({
|
||||
data,
|
||||
extras,
|
||||
renderStatus,
|
||||
renderContent,
|
||||
missingBreakdownMessage,
|
||||
}: AggregatedComplianceAccordionOptions<TExtras>): AccordionItemProps[] => {
|
||||
const requirementItem = (
|
||||
requirement: Requirement,
|
||||
itemKey: string,
|
||||
rowTitle: string,
|
||||
): AccordionItemProps => {
|
||||
const requirementExtras = extras.get(requirement.name as string);
|
||||
const requirementType =
|
||||
typeof requirement.type === "string" ? requirement.type : "";
|
||||
|
||||
return {
|
||||
key: itemKey,
|
||||
title: (
|
||||
<ComplianceAccordionRequirementTitle
|
||||
type={requirementType}
|
||||
name={rowTitle}
|
||||
status={requirement.status as FindingStatus}
|
||||
invalidConfig={requirement.invalid_config}
|
||||
statusContent={
|
||||
requirementExtras ? renderStatus(requirementExtras) : undefined
|
||||
}
|
||||
/>
|
||||
),
|
||||
content: requirementExtras ? (
|
||||
renderContent(requirement, requirementExtras, itemKey)
|
||||
) : (
|
||||
<p key={`content-${itemKey}`} className="text-sm">
|
||||
{missingBreakdownMessage}
|
||||
</p>
|
||||
),
|
||||
items: [],
|
||||
};
|
||||
};
|
||||
|
||||
const controlItems = (
|
||||
control: Control,
|
||||
categoryName: string,
|
||||
baseKey: string,
|
||||
): AccordionItemProps[] => {
|
||||
const groupLabel =
|
||||
control.label && control.label !== categoryName
|
||||
? control.label
|
||||
: undefined;
|
||||
|
||||
if (groupLabel && control.requirements.length > 1) {
|
||||
return [
|
||||
{
|
||||
key: baseKey,
|
||||
title: (
|
||||
<ComplianceAccordionTitle
|
||||
label={groupLabel}
|
||||
pass={control.pass}
|
||||
fail={control.fail}
|
||||
manual={control.manual}
|
||||
/>
|
||||
),
|
||||
content: "",
|
||||
items: control.requirements.map((requirement, requirementIndex) =>
|
||||
requirementItem(
|
||||
requirement,
|
||||
`${baseKey}-req-${requirementIndex}`,
|
||||
requirement.name as string,
|
||||
),
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
return control.requirements.map((requirement, requirementIndex) =>
|
||||
requirementItem(
|
||||
requirement,
|
||||
`${baseKey}-req-${requirementIndex}`,
|
||||
(groupLabel ?? requirement.name) as string,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const categoryItems = (frameworkData: Framework): AccordionItemProps[] =>
|
||||
frameworkData.categories.map((category) => ({
|
||||
key: `${frameworkData.name}-${category.name}`,
|
||||
title: (
|
||||
<ComplianceAccordionTitle
|
||||
label={category.name}
|
||||
pass={category.pass}
|
||||
fail={category.fail}
|
||||
manual={category.manual}
|
||||
isParentLevel={data.length === 1}
|
||||
/>
|
||||
),
|
||||
content: "",
|
||||
items: category.controls.flatMap((control, controlIndex) =>
|
||||
controlItems(
|
||||
control,
|
||||
category.name,
|
||||
`${frameworkData.name}-${category.name}-c${controlIndex}`,
|
||||
),
|
||||
),
|
||||
}));
|
||||
|
||||
const frameworkItems = (frameworkData: Framework): AccordionItemProps[] => {
|
||||
const directRequirements =
|
||||
(frameworkData as { requirements?: Requirement[] }).requirements ?? [];
|
||||
if (directRequirements.length > 0) {
|
||||
return directRequirements.map((requirement, requirementIndex) =>
|
||||
requirementItem(
|
||||
requirement,
|
||||
`${frameworkData.name}-req-${requirementIndex}`,
|
||||
requirement.name as string,
|
||||
),
|
||||
);
|
||||
}
|
||||
return categoryItems(frameworkData);
|
||||
};
|
||||
|
||||
if (data.length > 1) {
|
||||
return data.map((frameworkData) => ({
|
||||
key: frameworkData.name,
|
||||
title: (
|
||||
<ComplianceAccordionTitle
|
||||
label={frameworkData.name}
|
||||
pass={frameworkData.pass}
|
||||
fail={frameworkData.fail}
|
||||
manual={frameworkData.manual}
|
||||
isParentLevel
|
||||
/>
|
||||
),
|
||||
content: "",
|
||||
items: frameworkItems(frameworkData),
|
||||
}));
|
||||
}
|
||||
|
||||
return data.flatMap(frameworkItems);
|
||||
};
|
||||
@@ -0,0 +1,296 @@
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
|
||||
import type { ScanBinaryResult } from "@/actions/scans/scans";
|
||||
import {
|
||||
GENERIC_SERVER_ERROR_MESSAGE,
|
||||
getAuthHeaders,
|
||||
getErrorMessage,
|
||||
} from "@/lib";
|
||||
import { hasActionError, type ActionErrorResult } from "@/lib/action-errors";
|
||||
import { handleApiResponse } from "@/lib/server-actions-helper";
|
||||
import { SentryErrorSource, SentryErrorType } from "@/sentry";
|
||||
|
||||
import type { LatestCrossProviderPdf } from "../_types";
|
||||
import {
|
||||
CROSS_PROVIDER_OVERVIEW_LOAD_ERROR_MESSAGE,
|
||||
CROSS_PROVIDER_OVERVIEW_RESULT_STATUS,
|
||||
} from "../_types";
|
||||
|
||||
const AGGREGATED_COMPLIANCE_REQUEST_TIMEOUT_MS = 30_000;
|
||||
|
||||
interface PdfEndpointErrorBody {
|
||||
errors?: Array<{ detail?: string }>;
|
||||
error?: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
type AggregatedComplianceOverviewResult<TResponse> =
|
||||
| {
|
||||
status: typeof CROSS_PROVIDER_OVERVIEW_RESULT_STATUS.SUCCESS;
|
||||
response: TResponse;
|
||||
}
|
||||
| {
|
||||
status: typeof CROSS_PROVIDER_OVERVIEW_RESULT_STATUS.ACTION_ERROR;
|
||||
result: ActionErrorResult;
|
||||
}
|
||||
| {
|
||||
status: typeof CROSS_PROVIDER_OVERVIEW_RESULT_STATUS.LOAD_ERROR;
|
||||
message: string;
|
||||
};
|
||||
|
||||
const captureRequestFailure = (
|
||||
error: unknown,
|
||||
operation: string,
|
||||
timedOut: boolean,
|
||||
) => {
|
||||
const capturedError =
|
||||
error instanceof Error ? error : new Error(getErrorMessage(error));
|
||||
Sentry.captureException(capturedError, {
|
||||
tags: {
|
||||
error_source: SentryErrorSource.SERVER_ACTION,
|
||||
error_type: SentryErrorType.SERVER_ACTION_ERROR,
|
||||
request_timed_out: timedOut,
|
||||
},
|
||||
level: "error",
|
||||
contexts: {
|
||||
api_request: { operation },
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/** Fetch wrapper used by every aggregated-compliance endpoint. It keeps the
|
||||
* static route template in telemetry, aborts stalled upstream requests, and
|
||||
* always clears its timer once the request settles. */
|
||||
const fetchAggregatedCompliance = async (
|
||||
url: URL,
|
||||
init: RequestInit,
|
||||
operation: string,
|
||||
): Promise<Response> => {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => {
|
||||
controller.abort(
|
||||
new Error(
|
||||
`Aggregated compliance request timed out after ${AGGREGATED_COMPLIANCE_REQUEST_TIMEOUT_MS}ms`,
|
||||
),
|
||||
);
|
||||
}, AGGREGATED_COMPLIANCE_REQUEST_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
return await fetch(url.toString(), {
|
||||
...init,
|
||||
signal: controller.signal,
|
||||
});
|
||||
} catch (error) {
|
||||
captureRequestFailure(error, operation, controller.signal.aborted);
|
||||
throw error;
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
};
|
||||
|
||||
const getPdfEndpointErrorMessage = async (
|
||||
response: Response,
|
||||
fallbackMessage: string,
|
||||
operation: string,
|
||||
): Promise<string> => {
|
||||
const contentType = response.headers.get("content-type")?.toLowerCase() || "";
|
||||
const errorData: PdfEndpointErrorBody | null = contentType.includes(
|
||||
"text/html",
|
||||
)
|
||||
? null
|
||||
: await response.json().catch(() => null);
|
||||
|
||||
if (response.status >= 500) {
|
||||
Sentry.captureException(
|
||||
new Error(
|
||||
`Aggregated compliance PDF request failed (${response.status}) at ${operation}`,
|
||||
),
|
||||
{
|
||||
tags: {
|
||||
api_error: true,
|
||||
status_code: response.status.toString(),
|
||||
error_type: SentryErrorType.SERVER_ERROR,
|
||||
error_source: SentryErrorSource.SERVER_ACTION,
|
||||
},
|
||||
level: "error",
|
||||
contexts: {
|
||||
api_response: {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
operation,
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
return GENERIC_SERVER_ERROR_MESSAGE;
|
||||
}
|
||||
|
||||
return (
|
||||
errorData?.errors?.[0]?.detail ||
|
||||
errorData?.error ||
|
||||
errorData?.message ||
|
||||
fallbackMessage
|
||||
);
|
||||
};
|
||||
|
||||
export const getAggregatedComplianceOverview = async <TResponse>(
|
||||
url: URL,
|
||||
operation: string,
|
||||
): Promise<AggregatedComplianceOverviewResult<TResponse>> => {
|
||||
const headers = await getAuthHeaders({ contentType: false });
|
||||
|
||||
try {
|
||||
const response = await fetchAggregatedCompliance(
|
||||
url,
|
||||
{ headers },
|
||||
operation,
|
||||
);
|
||||
const responseData = await handleApiResponse(response);
|
||||
|
||||
if (hasActionError(responseData)) {
|
||||
return {
|
||||
status: CROSS_PROVIDER_OVERVIEW_RESULT_STATUS.ACTION_ERROR,
|
||||
result: responseData,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: CROSS_PROVIDER_OVERVIEW_RESULT_STATUS.SUCCESS,
|
||||
response: responseData as TResponse,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Error fetching aggregated compliance overview:", error);
|
||||
return {
|
||||
status: CROSS_PROVIDER_OVERVIEW_RESULT_STATUS.LOAD_ERROR,
|
||||
message: CROSS_PROVIDER_OVERVIEW_LOAD_ERROR_MESSAGE,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export const generateAggregatedCompliancePdf = async (
|
||||
url: URL,
|
||||
operation: string,
|
||||
): Promise<{ taskId: string } | { error: string }> => {
|
||||
const headers = await getAuthHeaders({ contentType: false });
|
||||
|
||||
try {
|
||||
const response = await fetchAggregatedCompliance(
|
||||
url,
|
||||
{ method: "POST", headers },
|
||||
operation,
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
await getPdfEndpointErrorMessage(
|
||||
response,
|
||||
"Unable to start PDF generation. Contact support if the issue continues.",
|
||||
operation,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const json = await response.json();
|
||||
const taskId = json?.data?.id;
|
||||
if (!taskId) {
|
||||
throw new Error("Unexpected response starting PDF generation.");
|
||||
}
|
||||
|
||||
return { taskId };
|
||||
} catch (error) {
|
||||
return { error: getErrorMessage(error) };
|
||||
}
|
||||
};
|
||||
|
||||
export const getAggregatedCompliancePdfBinary = async ({
|
||||
url,
|
||||
operation,
|
||||
defaultFilename,
|
||||
}: {
|
||||
url: URL;
|
||||
operation: string;
|
||||
defaultFilename: string;
|
||||
}): Promise<ScanBinaryResult> => {
|
||||
const headers = await getAuthHeaders({ contentType: false });
|
||||
|
||||
try {
|
||||
const response = await fetchAggregatedCompliance(
|
||||
url,
|
||||
{ headers },
|
||||
operation,
|
||||
);
|
||||
|
||||
if (response.status === 202) {
|
||||
const json = await response.json();
|
||||
return {
|
||||
pending: true,
|
||||
state: json?.data?.attributes?.state,
|
||||
taskId: json?.data?.id,
|
||||
};
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
await getPdfEndpointErrorMessage(
|
||||
response,
|
||||
"Unable to retrieve the compliance PDF report. Contact support if the issue continues.",
|
||||
operation,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const contentDisposition =
|
||||
response.headers.get("content-disposition") || "";
|
||||
const filenameMatch = contentDisposition.match(/filename="?([^";]+)"?/i);
|
||||
const filename = filenameMatch?.[1] || defaultFilename;
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: Buffer.from(arrayBuffer).toString("base64"),
|
||||
filename,
|
||||
};
|
||||
} catch (error) {
|
||||
return { error: getErrorMessage(error) };
|
||||
}
|
||||
};
|
||||
|
||||
export const getLatestAggregatedCompliancePdf = async (
|
||||
url: URL,
|
||||
operation: string,
|
||||
): Promise<LatestCrossProviderPdf | null> => {
|
||||
const headers = await getAuthHeaders({ contentType: false });
|
||||
|
||||
try {
|
||||
const response = await fetchAggregatedCompliance(
|
||||
url,
|
||||
{ headers },
|
||||
operation,
|
||||
);
|
||||
|
||||
if (response.status === 404) return null;
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
await getPdfEndpointErrorMessage(
|
||||
response,
|
||||
"Unable to check for an existing PDF report.",
|
||||
operation,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const json = await response.json();
|
||||
const taskId = json?.data?.id;
|
||||
if (!taskId) return null;
|
||||
|
||||
return {
|
||||
taskId,
|
||||
filename: json?.data?.attributes?.result?.filename,
|
||||
completedAt: json?.data?.attributes?.completed_at,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Error checking for an aggregated compliance PDF:", error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { AccordionItemProps } from "@/components/shadcn/accordion/Accordion";
|
||||
import type { Framework, RequirementsTotals } from "@/types/compliance";
|
||||
|
||||
export const getAggregatedRequirementsTotals = (
|
||||
data: Framework[],
|
||||
): RequirementsTotals =>
|
||||
data.reduce(
|
||||
(totals, framework) => ({
|
||||
pass: totals.pass + framework.pass,
|
||||
fail: totals.fail + framework.fail,
|
||||
manual: totals.manual + framework.manual,
|
||||
}),
|
||||
{ pass: 0, fail: 0, manual: 0 },
|
||||
);
|
||||
|
||||
export const getAggregatedInitialExpandedKeys = (
|
||||
data: Framework[],
|
||||
accordionItems: AccordionItemProps[],
|
||||
targetSection?: string,
|
||||
): string[] => {
|
||||
if (!targetSection) return [];
|
||||
|
||||
const candidates = new Set(
|
||||
data.map((framework) => `${framework.name}-${targetSection}`),
|
||||
);
|
||||
const match = accordionItems.find((item) => candidates.has(item.key));
|
||||
return match ? [match.key] : [];
|
||||
};
|
||||
@@ -0,0 +1,90 @@
|
||||
"use client";
|
||||
|
||||
import type { ScanBinaryResult } from "@/actions/scans/scans";
|
||||
import { toast, ToastAction } from "@/components/shadcn/toast";
|
||||
import { downloadFile } from "@/lib/helper";
|
||||
import type { TaskKindHandler } from "@/store/task-watcher/store";
|
||||
|
||||
const normalizeScopeValue = (value: string | string[] | undefined) => {
|
||||
if (Array.isArray(value)) return [...value].sort();
|
||||
return (
|
||||
value
|
||||
?.split(",")
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
.sort()
|
||||
.join(",") ?? ""
|
||||
);
|
||||
};
|
||||
|
||||
export const buildAggregatedCompliancePdfTaskScope = (
|
||||
values: Record<string, string | string[] | undefined>,
|
||||
): string =>
|
||||
JSON.stringify(
|
||||
Object.fromEntries(
|
||||
Object.entries(values).map(([key, value]) => [
|
||||
key,
|
||||
normalizeScopeValue(value),
|
||||
]),
|
||||
),
|
||||
);
|
||||
|
||||
export const downloadAggregatedCompliancePdf = async ({
|
||||
taskId,
|
||||
getPdfBinary,
|
||||
axisLabel,
|
||||
}: {
|
||||
taskId: string;
|
||||
getPdfBinary: (taskId: string) => Promise<ScanBinaryResult>;
|
||||
axisLabel: string;
|
||||
}): Promise<void> => {
|
||||
try {
|
||||
const result = await getPdfBinary(taskId);
|
||||
await downloadFile(
|
||||
result,
|
||||
"application/pdf",
|
||||
`The ${axisLabel} compliance PDF has been downloaded successfully.`,
|
||||
toast,
|
||||
);
|
||||
} catch {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Download failed",
|
||||
description: "Could not fetch the report. Please try again later.",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const createAggregatedCompliancePdfHandler = ({
|
||||
axisLabel,
|
||||
downloadPdf,
|
||||
}: {
|
||||
axisLabel: string;
|
||||
downloadPdf: (taskId: string) => Promise<void>;
|
||||
}): TaskKindHandler => ({
|
||||
onReady: (task) => {
|
||||
toast({
|
||||
title: "Compliance report ready",
|
||||
description: task.meta.reportLabel
|
||||
? `The ${task.meta.reportLabel} ${axisLabel} PDF has been generated.`
|
||||
: `The ${axisLabel} compliance PDF has been generated.`,
|
||||
action: (
|
||||
<ToastAction
|
||||
altText="Download report"
|
||||
onClick={() => downloadPdf(task.taskId)}
|
||||
>
|
||||
Download
|
||||
</ToastAction>
|
||||
),
|
||||
});
|
||||
},
|
||||
onError: (task) => {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Report generation failed",
|
||||
description:
|
||||
task.error ||
|
||||
`The ${axisLabel} PDF could not be generated. Try again later.`,
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -1,12 +1,5 @@
|
||||
import { ComplianceAccordionTitle } from "@/components/compliance/compliance-accordion/compliance-accordion-title";
|
||||
import type { AccordionItemProps } from "@/components/shadcn/accordion/Accordion";
|
||||
import { InfoTooltip } from "@/components/shadcn/info-field/info-field";
|
||||
import {
|
||||
type FindingStatus,
|
||||
StatusFindingBadge,
|
||||
} from "@/components/shadcn/table/status-finding-badge";
|
||||
import { INVALID_CONFIG_NOTE } from "@/lib/compliance/commons";
|
||||
import type { Control, Framework, Requirement } from "@/types/compliance";
|
||||
import type { Framework } from "@/types/compliance";
|
||||
|
||||
import { CrossAccountRequirementContent } from "../_components/cross-account-requirement-content";
|
||||
import { RequirementAccountChips } from "../_components/requirement-account-chips";
|
||||
@@ -15,206 +8,32 @@ import type {
|
||||
CrossAccountRequirementExtras,
|
||||
} from "../_types";
|
||||
|
||||
/**
|
||||
* Accordion assembly for the cross-account detail — the account-axis sibling
|
||||
* of `toCrossProviderAccordionItems` (same section key scheme, so `?section=`
|
||||
* deep links behave identically). Each requirement's status is shown once,
|
||||
* on the title row: via the per-account chips when a breakdown exists, or a
|
||||
* single roll-up badge as a fallback. `extras` is the map produced by
|
||||
* `buildAccountExtrasMap`, keyed by the mapper-composed requirement name.
|
||||
*
|
||||
* Row titles and hierarchy mirror what each mapper's OWN per-scan
|
||||
* `toAccordionItems` renders, so a framework looks the same here as in the
|
||||
* Single Scan view:
|
||||
* - CSA/CIS-Controls/DORA-style mappers put every requirement (already
|
||||
* richly named `id - title`) under one control labeled like its category
|
||||
* → flatten to requirement rows.
|
||||
* - The CIS mapper creates one control PER requirement whose label carries
|
||||
* the full `id - description` while `requirement.name` is the bare id
|
||||
* → use the control label as the row title.
|
||||
* - ENS-style mappers group frameworks (marcos) above categories and
|
||||
* several requirements under a labeled control → keep both extra levels,
|
||||
* like per-scan does, and show each requirement's type chip
|
||||
* (requisito/recomendación/refuerzo).
|
||||
*/
|
||||
import { toAggregatedComplianceAccordionItems } from "./aggregated-compliance-accordion";
|
||||
|
||||
export const toCrossAccountAccordionItems = (
|
||||
data: Framework[],
|
||||
extras: Map<string, CrossAccountRequirementExtras>,
|
||||
framework: string,
|
||||
accountMeta: CrossAccountAccountRef[],
|
||||
): AccordionItemProps[] => {
|
||||
const requirementItem = (
|
||||
requirement: Requirement,
|
||||
itemKey: string,
|
||||
rowTitle: string,
|
||||
): AccordionItemProps => {
|
||||
const requirementExtras = extras.get(requirement.name as string);
|
||||
const requirementType =
|
||||
typeof requirement.type === "string" ? requirement.type : "";
|
||||
|
||||
return {
|
||||
key: itemKey,
|
||||
title: (
|
||||
<div className="flex w-full items-center justify-between gap-3">
|
||||
{/* Same left-side composition as the per-scan
|
||||
ComplianceAccordionRequirementTitle (type chip + name +
|
||||
invalid-config note); only the right side differs (per-account
|
||||
chips instead of one status badge). */}
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
{requirementType && (
|
||||
<span className="bg-button-primary/10 text-button-primary rounded-md px-2 py-0.5 text-xs font-medium">
|
||||
{requirementType}
|
||||
</span>
|
||||
)}
|
||||
<span className="min-w-0 truncate">{rowTitle}</span>
|
||||
{requirement.invalid_config && (
|
||||
<InfoTooltip content={INVALID_CONFIG_NOTE} />
|
||||
)}
|
||||
</div>
|
||||
{requirementExtras ? (
|
||||
<RequirementAccountChips
|
||||
accounts={requirementExtras.accounts}
|
||||
accountMeta={accountMeta}
|
||||
/>
|
||||
) : (
|
||||
<StatusFindingBadge status={requirement.status as FindingStatus} />
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
// Explicit key on the content element, matching the per-scan mappers
|
||||
// (csa.tsx et al.): these elements travel to the client inside the
|
||||
// serialized `items` array, where React's Flight layer warns about
|
||||
// un-keyed elements ("Each child in a list…").
|
||||
content: requirementExtras ? (
|
||||
<CrossAccountRequirementContent
|
||||
key={`content-${itemKey}`}
|
||||
requirement={requirement}
|
||||
extras={requirementExtras}
|
||||
accountMeta={accountMeta}
|
||||
framework={framework}
|
||||
/>
|
||||
) : (
|
||||
<p key={`content-${itemKey}`} className="text-sm">
|
||||
No per-account breakdown is available for this requirement.
|
||||
</p>
|
||||
),
|
||||
items: [],
|
||||
};
|
||||
};
|
||||
|
||||
const controlItems = (
|
||||
control: Control,
|
||||
categoryName: string,
|
||||
baseKey: string,
|
||||
): AccordionItemProps[] => {
|
||||
// A label that just repeats the category (the flat-mapper convention)
|
||||
// carries no information; a distinct one is the mapper's richer title.
|
||||
const groupLabel =
|
||||
control.label && control.label !== categoryName
|
||||
? control.label
|
||||
: undefined;
|
||||
|
||||
if (groupLabel && control.requirements.length > 1) {
|
||||
// ENS-style group: keep the control as its own accordion level, the
|
||||
// way that mapper's per-scan toAccordionItems renders it.
|
||||
return [
|
||||
{
|
||||
key: baseKey,
|
||||
title: (
|
||||
<ComplianceAccordionTitle
|
||||
label={groupLabel}
|
||||
pass={control.pass}
|
||||
fail={control.fail}
|
||||
manual={control.manual}
|
||||
/>
|
||||
),
|
||||
content: "",
|
||||
items: control.requirements.map((requirement, reqIndex) =>
|
||||
requirementItem(
|
||||
requirement,
|
||||
`${baseKey}-req-${reqIndex}`,
|
||||
requirement.name as string,
|
||||
),
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
return control.requirements.map((requirement, reqIndex) =>
|
||||
requirementItem(
|
||||
requirement,
|
||||
`${baseKey}-req-${reqIndex}`,
|
||||
// Single-requirement controls (CIS style) carry the full
|
||||
// `id - description` in the control label while requirement.name is
|
||||
// the bare id — prefer the richer title, like the per-scan view.
|
||||
(groupLabel ?? requirement.name) as string,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const categoryItems = (frameworkData: Framework): AccordionItemProps[] =>
|
||||
frameworkData.categories.map((category) => ({
|
||||
key: `${frameworkData.name}-${category.name}`,
|
||||
title: (
|
||||
<ComplianceAccordionTitle
|
||||
label={category.name}
|
||||
pass={category.pass}
|
||||
fail={category.fail}
|
||||
manual={category.manual}
|
||||
isParentLevel={data.length === 1}
|
||||
/>
|
||||
),
|
||||
content: "",
|
||||
// The control index participates in the key: a category can hold
|
||||
// several controls whose requirement lists all start at index 0, so
|
||||
// keying on the requirement index alone collides across controls
|
||||
// (React "two children with the same key").
|
||||
items: category.controls.flatMap((control, controlIndex) =>
|
||||
controlItems(
|
||||
control,
|
||||
category.name,
|
||||
`${frameworkData.name}-${category.name}-c${controlIndex}`,
|
||||
),
|
||||
),
|
||||
}));
|
||||
|
||||
const frameworkItems = (frameworkData: Framework): AccordionItemProps[] => {
|
||||
// Flat generic-mapper structure (e.g. GDPR): requirements hang directly
|
||||
// off the framework with no categories — that mapper's per-scan
|
||||
// toAccordionItems renders them as top-level rows, so mirror it.
|
||||
const directRequirements =
|
||||
(frameworkData as { requirements?: Requirement[] }).requirements ?? [];
|
||||
if (directRequirements.length > 0) {
|
||||
return directRequirements.map((requirement, reqIndex) =>
|
||||
requirementItem(
|
||||
requirement,
|
||||
`${frameworkData.name}-req-${reqIndex}`,
|
||||
requirement.name as string,
|
||||
),
|
||||
);
|
||||
}
|
||||
return categoryItems(frameworkData);
|
||||
};
|
||||
|
||||
// Multi-framework data (ENS marcos: Operacional, Organizativo…) keeps the
|
||||
// framework as the top accordion level, exactly like that mapper's own
|
||||
// per-scan toAccordionItems; single-framework data starts at categories.
|
||||
if (data.length > 1) {
|
||||
return data.map((frameworkData) => ({
|
||||
key: frameworkData.name,
|
||||
title: (
|
||||
<ComplianceAccordionTitle
|
||||
label={frameworkData.name}
|
||||
pass={frameworkData.pass}
|
||||
fail={frameworkData.fail}
|
||||
manual={frameworkData.manual}
|
||||
isParentLevel={true}
|
||||
/>
|
||||
),
|
||||
content: "",
|
||||
items: frameworkItems(frameworkData),
|
||||
}));
|
||||
}
|
||||
|
||||
return data.flatMap(frameworkItems);
|
||||
};
|
||||
): AccordionItemProps[] =>
|
||||
toAggregatedComplianceAccordionItems({
|
||||
data,
|
||||
extras,
|
||||
renderStatus: (requirementExtras) => (
|
||||
<RequirementAccountChips
|
||||
accounts={requirementExtras.accounts}
|
||||
accountMeta={accountMeta}
|
||||
/>
|
||||
),
|
||||
renderContent: (requirement, requirementExtras, itemKey) => (
|
||||
<CrossAccountRequirementContent
|
||||
key={`content-${itemKey}`}
|
||||
requirement={requirement}
|
||||
extras={requirementExtras}
|
||||
accountMeta={accountMeta}
|
||||
framework={framework}
|
||||
/>
|
||||
),
|
||||
missingBreakdownMessage:
|
||||
"No per-account breakdown is available for this requirement.",
|
||||
});
|
||||
|
||||
@@ -1,85 +1,37 @@
|
||||
"use client";
|
||||
|
||||
import { toast, ToastAction } from "@/components/shadcn/toast";
|
||||
import { downloadFile } from "@/lib/helper";
|
||||
import type { TaskKindHandler } from "@/store/task-watcher/store";
|
||||
|
||||
import { getCrossAccountPdfBinary } from "../_actions/cross-account";
|
||||
import type { CrossAccountApiFilters } from "../_types";
|
||||
|
||||
import {
|
||||
buildAggregatedCompliancePdfTaskScope,
|
||||
createAggregatedCompliancePdfHandler,
|
||||
downloadAggregatedCompliancePdf,
|
||||
} from "./aggregated-compliance-pdf";
|
||||
|
||||
export const CROSS_ACCOUNT_PDF_TASK_KIND = "cross-account-pdf";
|
||||
|
||||
const normalizeCommaSeparatedFilter = (value?: string): string =>
|
||||
value
|
||||
?.split(",")
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
.sort()
|
||||
.join(",") ?? "";
|
||||
|
||||
/** Stable identity for the exact cross-account view a PDF represents. */
|
||||
export const buildCrossAccountPdfTaskScope = (
|
||||
complianceId: string,
|
||||
providerType: string,
|
||||
filters: CrossAccountApiFilters,
|
||||
): string =>
|
||||
JSON.stringify({
|
||||
buildAggregatedCompliancePdfTaskScope({
|
||||
complianceId,
|
||||
providerType,
|
||||
scanIds: [...(filters.scanIds ?? [])].sort(),
|
||||
providerIds: normalizeCommaSeparatedFilter(filters.providerIds),
|
||||
providerGroups: normalizeCommaSeparatedFilter(filters.providerGroups),
|
||||
scanIds: filters.scanIds,
|
||||
providerIds: filters.providerIds,
|
||||
providerGroups: filters.providerGroups,
|
||||
});
|
||||
|
||||
/** Fetches the finished cross-account PDF and hands it to the browser —
|
||||
* same never-rejects contract as `downloadCrossProviderPdf`. */
|
||||
export const downloadCrossAccountPdf = async (
|
||||
taskId: string,
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const result = await getCrossAccountPdfBinary(taskId);
|
||||
await downloadFile(
|
||||
result,
|
||||
"application/pdf",
|
||||
"The cross-account compliance PDF has been downloaded successfully.",
|
||||
toast,
|
||||
);
|
||||
} catch {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Download failed",
|
||||
description: "Could not fetch the report. Please try again later.",
|
||||
});
|
||||
}
|
||||
};
|
||||
export const downloadCrossAccountPdf = (taskId: string): Promise<void> =>
|
||||
downloadAggregatedCompliancePdf({
|
||||
taskId,
|
||||
getPdfBinary: getCrossAccountPdfBinary,
|
||||
axisLabel: "cross-account",
|
||||
});
|
||||
|
||||
/** Completion handler for cross-account PDF generation tasks, fired by the
|
||||
* generic task watcher — survives navigation and hard reloads like its
|
||||
* cross-provider sibling. */
|
||||
export const crossAccountPdfHandler: TaskKindHandler = {
|
||||
onReady: (task) => {
|
||||
toast({
|
||||
title: "Compliance report ready",
|
||||
description: task.meta.reportLabel
|
||||
? `The ${task.meta.reportLabel} cross-account PDF has been generated.`
|
||||
: "The cross-account compliance PDF has been generated.",
|
||||
action: (
|
||||
<ToastAction
|
||||
altText="Download report"
|
||||
onClick={() => downloadCrossAccountPdf(task.taskId)}
|
||||
>
|
||||
Download
|
||||
</ToastAction>
|
||||
),
|
||||
});
|
||||
},
|
||||
onError: (task) => {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Report generation failed",
|
||||
description:
|
||||
task.error ||
|
||||
"The cross-account PDF could not be generated. Try again later.",
|
||||
});
|
||||
},
|
||||
};
|
||||
export const crossAccountPdfHandler = createAggregatedCompliancePdfHandler({
|
||||
axisLabel: "cross-account",
|
||||
downloadPdf: downloadCrossAccountPdf,
|
||||
});
|
||||
|
||||
@@ -1,201 +1,31 @@
|
||||
import { ComplianceAccordionTitle } from "@/components/compliance/compliance-accordion/compliance-accordion-title";
|
||||
import type { AccordionItemProps } from "@/components/shadcn/accordion/Accordion";
|
||||
import { InfoTooltip } from "@/components/shadcn/info-field/info-field";
|
||||
import {
|
||||
type FindingStatus,
|
||||
StatusFindingBadge,
|
||||
} from "@/components/shadcn/table/status-finding-badge";
|
||||
import { INVALID_CONFIG_NOTE } from "@/lib/compliance/commons";
|
||||
import type { Control, Framework, Requirement } from "@/types/compliance";
|
||||
import type { Framework } from "@/types/compliance";
|
||||
|
||||
import { CrossProviderRequirementContent } from "../_components/cross-provider-requirement-content";
|
||||
import { RequirementProviderChips } from "../_components/requirement-provider-chips";
|
||||
import type { CrossProviderRequirementExtras } from "../_types";
|
||||
|
||||
/**
|
||||
* Accordion assembly for the cross-provider detail. Mirrors the per-scan
|
||||
* mappers' `toAccordionItems` (same section key scheme, so `?section=` deep
|
||||
* links behave identically) but swaps the per-scan findings content for the
|
||||
* per-provider fan-out. Each requirement's status is shown once, on the same
|
||||
* row as the name and the expand chevron: via the per-provider chips when a
|
||||
* breakdown exists, or a single roll-up badge as a fallback. `extras` is the
|
||||
* map produced by `buildRequirementExtrasMap`, keyed by the mapper-composed
|
||||
* requirement name.
|
||||
*
|
||||
* Row titles and hierarchy mirror what each mapper's OWN per-scan
|
||||
* `toAccordionItems` renders (see the cross-account sibling for the full
|
||||
* rationale): flat mappers keep requirement rows, single-requirement
|
||||
* controls with a distinct label (CIS style) use that richer label as the
|
||||
* row title, labeled multi-requirement controls (ENS style) keep the
|
||||
* control as a nested accordion level, and multi-framework data keeps the
|
||||
* framework as the top level.
|
||||
*/
|
||||
import { toAggregatedComplianceAccordionItems } from "./aggregated-compliance-accordion";
|
||||
|
||||
export const toCrossProviderAccordionItems = (
|
||||
data: Framework[],
|
||||
extras: Map<string, CrossProviderRequirementExtras>,
|
||||
framework: string,
|
||||
): AccordionItemProps[] => {
|
||||
const requirementItem = (
|
||||
requirement: Requirement,
|
||||
itemKey: string,
|
||||
rowTitle: string,
|
||||
): AccordionItemProps => {
|
||||
const requirementExtras = extras.get(requirement.name as string);
|
||||
const requirementType =
|
||||
typeof requirement.type === "string" ? requirement.type : "";
|
||||
|
||||
return {
|
||||
key: itemKey,
|
||||
title: (
|
||||
<div className="flex w-full items-center justify-between gap-3">
|
||||
{/* Same left-side composition as the per-scan
|
||||
ComplianceAccordionRequirementTitle (type chip + name +
|
||||
invalid-config note); only the right side differs (per-provider
|
||||
chips instead of one status badge). */}
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
{requirementType && (
|
||||
<span className="bg-button-primary/10 text-button-primary rounded-md px-2 py-0.5 text-xs font-medium">
|
||||
{requirementType}
|
||||
</span>
|
||||
)}
|
||||
<span className="min-w-0 truncate">{rowTitle}</span>
|
||||
{requirement.invalid_config && (
|
||||
<InfoTooltip content={INVALID_CONFIG_NOTE} />
|
||||
)}
|
||||
</div>
|
||||
{requirementExtras ? (
|
||||
<RequirementProviderChips providers={requirementExtras.providers} />
|
||||
) : (
|
||||
<StatusFindingBadge status={requirement.status as FindingStatus} />
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
// Explicit key on the content element, matching the per-scan mappers
|
||||
// (csa.tsx et al.): these elements travel to the client inside the
|
||||
// serialized `items` array, where React's Flight layer warns about
|
||||
// un-keyed elements ("Each child in a list…").
|
||||
content: requirementExtras ? (
|
||||
<CrossProviderRequirementContent
|
||||
key={`content-${itemKey}`}
|
||||
requirement={requirement}
|
||||
extras={requirementExtras}
|
||||
framework={framework}
|
||||
/>
|
||||
) : (
|
||||
<p key={`content-${itemKey}`} className="text-sm">
|
||||
No per-provider breakdown is available for this requirement.
|
||||
</p>
|
||||
),
|
||||
items: [],
|
||||
};
|
||||
};
|
||||
|
||||
const controlItems = (
|
||||
control: Control,
|
||||
categoryName: string,
|
||||
baseKey: string,
|
||||
): AccordionItemProps[] => {
|
||||
const groupLabel =
|
||||
control.label && control.label !== categoryName
|
||||
? control.label
|
||||
: undefined;
|
||||
|
||||
if (groupLabel && control.requirements.length > 1) {
|
||||
return [
|
||||
{
|
||||
key: baseKey,
|
||||
title: (
|
||||
<ComplianceAccordionTitle
|
||||
label={groupLabel}
|
||||
pass={control.pass}
|
||||
fail={control.fail}
|
||||
manual={control.manual}
|
||||
/>
|
||||
),
|
||||
content: "",
|
||||
items: control.requirements.map((requirement, reqIndex) =>
|
||||
requirementItem(
|
||||
requirement,
|
||||
`${baseKey}-req-${reqIndex}`,
|
||||
requirement.name as string,
|
||||
),
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
return control.requirements.map((requirement, reqIndex) =>
|
||||
requirementItem(
|
||||
requirement,
|
||||
`${baseKey}-req-${reqIndex}`,
|
||||
(groupLabel ?? requirement.name) as string,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const categoryItems = (frameworkData: Framework): AccordionItemProps[] =>
|
||||
frameworkData.categories.map((category) => ({
|
||||
key: `${frameworkData.name}-${category.name}`,
|
||||
title: (
|
||||
<ComplianceAccordionTitle
|
||||
label={category.name}
|
||||
pass={category.pass}
|
||||
fail={category.fail}
|
||||
manual={category.manual}
|
||||
isParentLevel={data.length === 1}
|
||||
/>
|
||||
),
|
||||
content: "",
|
||||
// The control index participates in the key: a category can hold
|
||||
// several controls whose requirement lists all start at index 0, so
|
||||
// keying on the requirement index alone collides across controls
|
||||
// (React "two children with the same key").
|
||||
items: category.controls.flatMap((control, controlIndex) =>
|
||||
controlItems(
|
||||
control,
|
||||
category.name,
|
||||
`${frameworkData.name}-${category.name}-c${controlIndex}`,
|
||||
),
|
||||
),
|
||||
}));
|
||||
|
||||
const frameworkItems = (frameworkData: Framework): AccordionItemProps[] => {
|
||||
// Flat generic-mapper structure (e.g. GDPR): requirements hang directly
|
||||
// off the framework with no categories — that mapper's per-scan
|
||||
// toAccordionItems renders them as top-level rows, so mirror it.
|
||||
const directRequirements =
|
||||
(frameworkData as { requirements?: Requirement[] }).requirements ?? [];
|
||||
if (directRequirements.length > 0) {
|
||||
return directRequirements.map((requirement, reqIndex) =>
|
||||
requirementItem(
|
||||
requirement,
|
||||
`${frameworkData.name}-req-${reqIndex}`,
|
||||
requirement.name as string,
|
||||
),
|
||||
);
|
||||
}
|
||||
return categoryItems(frameworkData);
|
||||
};
|
||||
|
||||
// Multi-framework data (ENS marcos: Operacional, Organizativo…) keeps the
|
||||
// framework as the top accordion level, exactly like that mapper's own
|
||||
// per-scan toAccordionItems; single-framework data starts at categories.
|
||||
if (data.length > 1) {
|
||||
return data.map((frameworkData) => ({
|
||||
key: frameworkData.name,
|
||||
title: (
|
||||
<ComplianceAccordionTitle
|
||||
label={frameworkData.name}
|
||||
pass={frameworkData.pass}
|
||||
fail={frameworkData.fail}
|
||||
manual={frameworkData.manual}
|
||||
isParentLevel={true}
|
||||
/>
|
||||
),
|
||||
content: "",
|
||||
items: frameworkItems(frameworkData),
|
||||
}));
|
||||
}
|
||||
|
||||
return data.flatMap(frameworkItems);
|
||||
};
|
||||
): AccordionItemProps[] =>
|
||||
toAggregatedComplianceAccordionItems({
|
||||
data,
|
||||
extras,
|
||||
renderStatus: (requirementExtras) => (
|
||||
<RequirementProviderChips providers={requirementExtras.providers} />
|
||||
),
|
||||
renderContent: (requirement, requirementExtras, itemKey) => (
|
||||
<CrossProviderRequirementContent
|
||||
key={`content-${itemKey}`}
|
||||
requirement={requirement}
|
||||
extras={requirementExtras}
|
||||
framework={framework}
|
||||
/>
|
||||
),
|
||||
missingBreakdownMessage:
|
||||
"No per-provider breakdown is available for this requirement.",
|
||||
});
|
||||
|
||||
@@ -1,91 +1,36 @@
|
||||
"use client";
|
||||
|
||||
import { toast, ToastAction } from "@/components/shadcn/toast";
|
||||
import { downloadFile } from "@/lib/helper";
|
||||
import type { TaskKindHandler } from "@/store/task-watcher/store";
|
||||
|
||||
import { getCrossProviderPdfBinary } from "../_actions/cross-provider";
|
||||
import type { CrossProviderApiFilters } from "../_types";
|
||||
|
||||
import {
|
||||
buildAggregatedCompliancePdfTaskScope,
|
||||
createAggregatedCompliancePdfHandler,
|
||||
downloadAggregatedCompliancePdf,
|
||||
} from "./aggregated-compliance-pdf";
|
||||
|
||||
export const CROSS_PROVIDER_PDF_TASK_KIND = "cross-provider-pdf";
|
||||
|
||||
const normalizeCommaSeparatedFilter = (value?: string): string =>
|
||||
value
|
||||
?.split(",")
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
.sort()
|
||||
.join(",") ?? "";
|
||||
|
||||
/** Stable identity for the exact cross-provider view a PDF represents. */
|
||||
export const buildCrossProviderPdfTaskScope = (
|
||||
complianceId: string,
|
||||
filters: CrossProviderApiFilters,
|
||||
): string =>
|
||||
JSON.stringify({
|
||||
buildAggregatedCompliancePdfTaskScope({
|
||||
complianceId,
|
||||
scanIds: [...(filters.scanIds ?? [])].sort(),
|
||||
providerTypes: normalizeCommaSeparatedFilter(filters.providerTypes),
|
||||
providerIds: normalizeCommaSeparatedFilter(filters.providerIds),
|
||||
providerGroups: normalizeCommaSeparatedFilter(filters.providerGroups),
|
||||
scanIds: filters.scanIds,
|
||||
providerTypes: filters.providerTypes,
|
||||
providerIds: filters.providerIds,
|
||||
providerGroups: filters.providerGroups,
|
||||
});
|
||||
|
||||
/** Fetches the finished cross-provider PDF and hands it to the browser,
|
||||
* reusing the shared base64→blob download + toast handling. Never rejects:
|
||||
* it is fired from toast actions and dropdown items whose rejections would
|
||||
* otherwise vanish unhandled. */
|
||||
export const downloadCrossProviderPdf = async (
|
||||
taskId: string,
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const result = await getCrossProviderPdfBinary(taskId);
|
||||
await downloadFile(
|
||||
result,
|
||||
"application/pdf",
|
||||
"The cross-provider compliance PDF has been downloaded successfully.",
|
||||
toast,
|
||||
);
|
||||
} catch {
|
||||
// The action catches API failures itself; this guards the server-action
|
||||
// RPC (e.g. a network drop between browser and Next server).
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Download failed",
|
||||
description: "Could not fetch the report. Please try again later.",
|
||||
});
|
||||
}
|
||||
};
|
||||
export const downloadCrossProviderPdf = (taskId: string): Promise<void> =>
|
||||
downloadAggregatedCompliancePdf({
|
||||
taskId,
|
||||
getPdfBinary: getCrossProviderPdfBinary,
|
||||
axisLabel: "cross-provider",
|
||||
});
|
||||
|
||||
/**
|
||||
* Completion handler for cross-provider PDF generation tasks. Fired by the
|
||||
* generic task watcher (`@/store/task-watcher`) whenever a tracked task of
|
||||
* this kind settles — including after client-side navigation (module-scope
|
||||
* poll loop) or a hard reload (persisted store + `TaskPollingWatcher`).
|
||||
*/
|
||||
export const crossProviderPdfHandler: TaskKindHandler = {
|
||||
onReady: (task) => {
|
||||
toast({
|
||||
title: "Compliance report ready",
|
||||
description: task.meta.reportLabel
|
||||
? `The ${task.meta.reportLabel} cross-provider PDF has been generated.`
|
||||
: "The cross-provider compliance PDF has been generated.",
|
||||
action: (
|
||||
<ToastAction
|
||||
altText="Download report"
|
||||
onClick={() => downloadCrossProviderPdf(task.taskId)}
|
||||
>
|
||||
Download
|
||||
</ToastAction>
|
||||
),
|
||||
});
|
||||
},
|
||||
onError: (task) => {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Report generation failed",
|
||||
description:
|
||||
task.error ||
|
||||
"The cross-provider PDF could not be generated. Try again later.",
|
||||
});
|
||||
},
|
||||
};
|
||||
export const crossProviderPdfHandler = createAggregatedCompliancePdfHandler({
|
||||
axisLabel: "cross-provider",
|
||||
downloadPdf: downloadCrossProviderPdf,
|
||||
});
|
||||
|
||||
+12
-4
@@ -1,5 +1,11 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { Badge } from "@/components/shadcn/badge/badge";
|
||||
import { InfoTooltip } from "@/components/shadcn/info-field/info-field";
|
||||
import { FindingStatus, StatusFindingBadge } from "@/components/shadcn/table";
|
||||
import {
|
||||
type FindingStatus,
|
||||
StatusFindingBadge,
|
||||
} from "@/components/shadcn/table/status-finding-badge";
|
||||
import { INVALID_CONFIG_NOTE } from "@/lib/compliance/commons";
|
||||
|
||||
interface ComplianceAccordionRequirementTitleProps {
|
||||
@@ -7,6 +13,7 @@ interface ComplianceAccordionRequirementTitleProps {
|
||||
name: string;
|
||||
status: FindingStatus;
|
||||
invalidConfig?: boolean;
|
||||
statusContent?: ReactNode;
|
||||
}
|
||||
|
||||
export const ComplianceAccordionRequirementTitle = ({
|
||||
@@ -14,19 +21,20 @@ export const ComplianceAccordionRequirementTitle = ({
|
||||
name,
|
||||
status,
|
||||
invalidConfig = false,
|
||||
statusContent,
|
||||
}: ComplianceAccordionRequirementTitleProps) => {
|
||||
return (
|
||||
<div className="flex w-full items-center justify-between gap-2">
|
||||
<div className="flex w-5/6 items-center gap-2">
|
||||
{type && (
|
||||
<span className="bg-button-primary/10 text-button-primary rounded-md px-2 py-0.5 text-xs font-medium">
|
||||
<Badge variant="tag" size="sm">
|
||||
{type}
|
||||
</span>
|
||||
</Badge>
|
||||
)}
|
||||
<span>{name}</span>
|
||||
{invalidConfig && <InfoTooltip content={INVALID_CONFIG_NOTE} />}
|
||||
</div>
|
||||
<StatusFindingBadge status={status} />
|
||||
{statusContent ?? <StatusFindingBadge status={status} />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { CardHeader } from "./card";
|
||||
import { Card, CardHeader } from "./card";
|
||||
|
||||
describe("CardHeader", () => {
|
||||
it("does not add vertical margin by default", () => {
|
||||
@@ -15,3 +15,15 @@ describe("CardHeader", () => {
|
||||
expect(header).not.toHaveClass("mb-6");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Card", () => {
|
||||
it("provides the shared interactive treatment without call-site classes", () => {
|
||||
render(<Card interactive>Framework</Card>);
|
||||
|
||||
expect(screen.getByText("Framework")).toHaveClass(
|
||||
"cursor-pointer",
|
||||
"transition-shadow",
|
||||
"hover:shadow-md",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -31,6 +31,10 @@ const cardVariants = cva("flex flex-col gap-6 rounded-xl border", {
|
||||
xl: "p-8",
|
||||
none: "p-0",
|
||||
},
|
||||
interactive: {
|
||||
true: "cursor-pointer transition-shadow hover:shadow-md",
|
||||
false: "",
|
||||
},
|
||||
},
|
||||
compoundVariants: [
|
||||
{
|
||||
@@ -57,6 +61,7 @@ const cardVariants = cva("flex flex-col gap-6 rounded-xl border", {
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
padding: "default",
|
||||
interactive: false,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -64,11 +69,17 @@ interface CardProps
|
||||
extends React.ComponentProps<"div">,
|
||||
VariantProps<typeof cardVariants> {}
|
||||
|
||||
function Card({ className, variant, padding, ...props }: CardProps) {
|
||||
function Card({
|
||||
className,
|
||||
variant,
|
||||
padding,
|
||||
interactive,
|
||||
...props
|
||||
}: CardProps) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card"
|
||||
className={cn(cardVariants({ variant, padding }), className)}
|
||||
className={cn(cardVariants({ variant, padding, interactive }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { Progress } from "./progress";
|
||||
|
||||
describe("Progress", () => {
|
||||
it("provides semantic indicator variants", () => {
|
||||
render(<Progress aria-label="Score" value={75} variant="warning" />);
|
||||
|
||||
expect(
|
||||
screen.getByRole("progressbar", { name: "Score" }).firstChild,
|
||||
).toHaveClass("bg-bg-warning");
|
||||
});
|
||||
});
|
||||
@@ -7,12 +7,21 @@ import { cn } from "@/lib/utils";
|
||||
|
||||
interface ProgressProps extends ComponentProps<typeof ProgressPrimitive.Root> {
|
||||
indicatorClassName?: string;
|
||||
variant?: "default" | "success" | "warning" | "danger";
|
||||
}
|
||||
|
||||
const indicatorVariants = {
|
||||
default: "bg-button-primary",
|
||||
success: "bg-bg-pass",
|
||||
warning: "bg-bg-warning",
|
||||
danger: "bg-bg-fail",
|
||||
} as const;
|
||||
|
||||
function Progress({
|
||||
className,
|
||||
value = 0,
|
||||
indicatorClassName,
|
||||
variant = "default",
|
||||
...props
|
||||
}: ProgressProps) {
|
||||
const normalizedValue = value ?? 0;
|
||||
@@ -30,7 +39,8 @@ function Progress({
|
||||
<ProgressPrimitive.Indicator
|
||||
data-slot="progress-indicator"
|
||||
className={cn(
|
||||
"bg-button-primary h-full w-full flex-1 transition-all",
|
||||
"h-full w-full flex-1 transition-all",
|
||||
indicatorVariants[variant],
|
||||
indicatorClassName,
|
||||
)}
|
||||
style={{ transform: `translateX(-${100 - normalizedValue}%)` }}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { ScrollArea } from "./scroll-area";
|
||||
|
||||
describe("ScrollArea", () => {
|
||||
it("provides a medium viewport height", () => {
|
||||
render(<ScrollArea size="md">Content</ScrollArea>);
|
||||
|
||||
expect(
|
||||
screen.getByText("Content").parentElement?.parentElement,
|
||||
).toHaveClass("h-72");
|
||||
});
|
||||
});
|
||||
@@ -5,13 +5,22 @@ import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface ScrollAreaProps
|
||||
extends React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root> {
|
||||
size?: "auto" | "md";
|
||||
}
|
||||
|
||||
const ScrollArea = React.forwardRef<
|
||||
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
ScrollAreaProps
|
||||
>(({ className, children, size = "auto", ...props }, ref) => (
|
||||
<ScrollAreaPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn("relative overflow-hidden", className)}
|
||||
className={cn(
|
||||
"relative overflow-hidden",
|
||||
size === "md" && "h-72",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
|
||||
|
||||
Reference in New Issue
Block a user