mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-11-10 11:51:29 +00:00
Merge branch 'master' into PROWLER-1403-enhancement-improve-ui-logging-for-better-observability-and-debugging
This commit is contained in:
114 files changed
+7672
-526
No files matched your search
@@ -10,7 +10,7 @@ import {
|
||||
} from "@/components/findings/table";
|
||||
import { Accordion } from "@/components/ui/accordion/Accordion";
|
||||
import { DataTable } from "@/components/ui/table";
|
||||
import { createDict } from "@/lib";
|
||||
import { createDict, FINDINGS_DEFAULT_SORT, MUTED_FILTER } from "@/lib";
|
||||
import { getComplianceMapper } from "@/lib/compliance/compliance-mapper";
|
||||
import { Requirement } from "@/types/compliance";
|
||||
import { FindingProps, FindingsResponse } from "@/types/components";
|
||||
@@ -34,12 +34,16 @@ export const ClientAccordionContent = ({
|
||||
const pageNumber = searchParams.get("page") || "1";
|
||||
const complianceId = searchParams.get("complianceId");
|
||||
const openFindingId = searchParams.get("id");
|
||||
const defaultSort = "severity,status,-inserted_at";
|
||||
const sort = searchParams.get("sort") || defaultSort;
|
||||
const sort = searchParams.get("sort") || FINDINGS_DEFAULT_SORT;
|
||||
const loadedPageRef = useRef<string | null>(null);
|
||||
const loadedSortRef = useRef<string | null>(null);
|
||||
const loadedMutedRef = useRef<string | null>(null);
|
||||
const isExpandedRef = useRef(false);
|
||||
const region = searchParams.get("filter[region__in]") || "";
|
||||
// Respect the user's muted preference from the URL; default to EXCLUDE
|
||||
// so the requirement view stays consistent with every other findings
|
||||
// surface in the app (findings page, resource drawer, overview widgets).
|
||||
const mutedFilter = searchParams.get("filter[muted]") || MUTED_FILTER.EXCLUDE;
|
||||
|
||||
useEffect(() => {
|
||||
async function loadFindings() {
|
||||
@@ -49,10 +53,12 @@ export const ClientAccordionContent = ({
|
||||
requirement.status !== "No findings" &&
|
||||
(loadedPageRef.current !== pageNumber ||
|
||||
loadedSortRef.current !== sort ||
|
||||
loadedMutedRef.current !== mutedFilter ||
|
||||
!isExpandedRef.current)
|
||||
) {
|
||||
loadedPageRef.current = pageNumber;
|
||||
loadedSortRef.current = sort;
|
||||
loadedMutedRef.current = mutedFilter;
|
||||
isExpandedRef.current = true;
|
||||
|
||||
try {
|
||||
@@ -62,7 +68,7 @@ export const ClientAccordionContent = ({
|
||||
filters: {
|
||||
"filter[check_id__in]": checkIds.join(","),
|
||||
"filter[scan]": scanId,
|
||||
"filter[muted]": "false",
|
||||
"filter[muted]": mutedFilter,
|
||||
...(region && { "filter[region__in]": region }),
|
||||
},
|
||||
page: parseInt(pageNumber, 10),
|
||||
@@ -101,7 +107,15 @@ export const ClientAccordionContent = ({
|
||||
}
|
||||
|
||||
loadFindings();
|
||||
}, [requirement, scanId, pageNumber, sort, region, disableFindings]);
|
||||
}, [
|
||||
requirement,
|
||||
scanId,
|
||||
pageNumber,
|
||||
sort,
|
||||
region,
|
||||
mutedFilter,
|
||||
disableFindings,
|
||||
]);
|
||||
|
||||
const renderDetails = () => {
|
||||
if (!complianceId) {
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/shadcn/tooltip";
|
||||
import { getReportTypeForFramework } from "@/lib/compliance/compliance-report-types";
|
||||
import { getReportTypeForCompliance } from "@/lib/compliance/compliance-report-types";
|
||||
import {
|
||||
getScoreIndicatorClass,
|
||||
type ScoreColorVariant,
|
||||
@@ -31,6 +31,13 @@ interface ComplianceCardProps {
|
||||
complianceId: string;
|
||||
id: string;
|
||||
selectedScan?: ScanEntity;
|
||||
/**
|
||||
* True when this compliance_id is the highest CIS version for its provider.
|
||||
* Only the latest CIS variant per provider has a PDF generated by the
|
||||
* backend, so older variants must not expose the PDF download button.
|
||||
* Ignored for non-CIS frameworks.
|
||||
*/
|
||||
isLatestCisForProvider?: boolean;
|
||||
}
|
||||
|
||||
export const ComplianceCard: React.FC<ComplianceCardProps> = ({
|
||||
@@ -41,6 +48,7 @@ export const ComplianceCard: React.FC<ComplianceCardProps> = ({
|
||||
scanId,
|
||||
complianceId,
|
||||
id,
|
||||
isLatestCisForProvider = false,
|
||||
}) => {
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
@@ -112,7 +120,11 @@ export const ComplianceCard: React.FC<ComplianceCardProps> = ({
|
||||
presentation="dropdown"
|
||||
scanId={scanId}
|
||||
complianceId={complianceId}
|
||||
reportType={getReportTypeForFramework(title)}
|
||||
reportType={getReportTypeForCompliance(
|
||||
title,
|
||||
complianceId,
|
||||
isLatestCisForProvider,
|
||||
)}
|
||||
disabled={hasRegionFilter}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -6,15 +6,16 @@ import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { downloadComplianceCsvMock, downloadComplianceReportPdfMock } =
|
||||
vi.hoisted(() => ({
|
||||
const { downloadComplianceCsvMock, downloadCompliancePdfMock } = vi.hoisted(
|
||||
() => ({
|
||||
downloadComplianceCsvMock: vi.fn(),
|
||||
downloadComplianceReportPdfMock: vi.fn(),
|
||||
}));
|
||||
downloadCompliancePdfMock: vi.fn(),
|
||||
}),
|
||||
);
|
||||
|
||||
vi.mock("@/lib/helper", () => ({
|
||||
downloadComplianceCsv: downloadComplianceCsvMock,
|
||||
downloadComplianceReportPdf: downloadComplianceReportPdfMock,
|
||||
downloadCompliancePdf: downloadCompliancePdfMock,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui", () => ({
|
||||
@@ -124,7 +125,7 @@ describe("ComplianceDownloadContainer", () => {
|
||||
"compliance-1",
|
||||
{},
|
||||
);
|
||||
expect(downloadComplianceReportPdfMock).toHaveBeenCalledWith(
|
||||
expect(downloadCompliancePdfMock).toHaveBeenCalledWith(
|
||||
"scan-1",
|
||||
"threatscore",
|
||||
{},
|
||||
|
||||
@@ -15,10 +15,7 @@ import {
|
||||
} from "@/components/shadcn/tooltip";
|
||||
import { toast } from "@/components/ui";
|
||||
import type { ComplianceReportType } from "@/lib/compliance/compliance-report-types";
|
||||
import {
|
||||
downloadComplianceCsv,
|
||||
downloadComplianceReportPdf,
|
||||
} from "@/lib/helper";
|
||||
import { downloadComplianceCsv, downloadCompliancePdf } from "@/lib/helper";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface ComplianceDownloadContainerProps {
|
||||
@@ -61,7 +58,7 @@ export const ComplianceDownloadContainer = ({
|
||||
if (!reportType || isDownloadingPdf) return;
|
||||
setIsDownloadingPdf(true);
|
||||
try {
|
||||
await downloadComplianceReportPdf(scanId, reportType, toast);
|
||||
await downloadCompliancePdf(scanId, reportType, toast);
|
||||
} finally {
|
||||
setIsDownloadingPdf(false);
|
||||
}
|
||||
|
||||
@@ -11,12 +11,19 @@ interface ComplianceOverviewGridProps {
|
||||
frameworks: ComplianceOverviewData[];
|
||||
scanId: string;
|
||||
selectedScan?: ScanEntity;
|
||||
/**
|
||||
* Subset of compliance_ids that represent the latest CIS variant per
|
||||
* provider. Only those cards expose the PDF download button, matching
|
||||
* the backend's latest-only CIS PDF generation.
|
||||
*/
|
||||
latestCisIds?: ReadonlySet<string>;
|
||||
}
|
||||
|
||||
export const ComplianceOverviewGrid = ({
|
||||
frameworks,
|
||||
scanId,
|
||||
selectedScan,
|
||||
latestCisIds,
|
||||
}: ComplianceOverviewGridProps) => {
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
|
||||
@@ -61,6 +68,7 @@ export const ComplianceOverviewGrid = ({
|
||||
complianceId={id}
|
||||
id={id}
|
||||
selectedScan={selectedScan}
|
||||
isLatestCisForProvider={latestCisIds?.has(id) ?? false}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -4,12 +4,7 @@ import { useSearchParams } from "next/navigation";
|
||||
|
||||
import { Checkbox } from "@/components/shadcn";
|
||||
import { useUrlFilters } from "@/hooks/use-url-filters";
|
||||
|
||||
// Constants for muted filter URL values
|
||||
const MUTED_FILTER_VALUES = {
|
||||
EXCLUDE: "false",
|
||||
INCLUDE: "include",
|
||||
} as const;
|
||||
import { MUTED_FILTER } from "@/lib";
|
||||
|
||||
/** Batch mode: caller controls both the checked state and the notification callback (all-or-nothing). */
|
||||
interface CustomCheckboxMutedFindingsBatchProps {
|
||||
@@ -53,7 +48,7 @@ export const CustomCheckboxMutedFindings = ({
|
||||
const includeMuted =
|
||||
checkedProp !== undefined
|
||||
? checkedProp
|
||||
: mutedFilterValue === MUTED_FILTER_VALUES.INCLUDE;
|
||||
: mutedFilterValue === MUTED_FILTER.INCLUDE;
|
||||
|
||||
const handleMutedChange = (checked: boolean | "indeterminate") => {
|
||||
const isChecked = checked === true;
|
||||
@@ -62,7 +57,7 @@ export const CustomCheckboxMutedFindings = ({
|
||||
// Batch mode: notify caller instead of navigating
|
||||
onBatchChange(
|
||||
"muted",
|
||||
isChecked ? MUTED_FILTER_VALUES.INCLUDE : MUTED_FILTER_VALUES.EXCLUDE,
|
||||
isChecked ? MUTED_FILTER.INCLUDE : MUTED_FILTER.EXCLUDE,
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -71,10 +66,10 @@ export const CustomCheckboxMutedFindings = ({
|
||||
navigateWithParams((params) => {
|
||||
if (isChecked) {
|
||||
// Include muted: set special value (API will ignore invalid value and show all)
|
||||
params.set("filter[muted]", MUTED_FILTER_VALUES.INCLUDE);
|
||||
params.set("filter[muted]", MUTED_FILTER.INCLUDE);
|
||||
} else {
|
||||
// Exclude muted: apply filter to show only non-muted
|
||||
params.set("filter[muted]", MUTED_FILTER_VALUES.EXCLUDE);
|
||||
params.set("filter[muted]", MUTED_FILTER.EXCLUDE);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,48 +1,26 @@
|
||||
import {
|
||||
FAIL_FILTER_VALUE,
|
||||
includesMutedFindings,
|
||||
splitCsvFilterValues,
|
||||
} from "@/lib/findings-filters";
|
||||
import { FindingGroupRow } from "@/types";
|
||||
|
||||
function parseStatusFilterValue(statusFilterValue?: string): string[] {
|
||||
if (!statusFilterValue) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return statusFilterValue
|
||||
.split(",")
|
||||
.map((status) => status.trim().toUpperCase())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
export function isFailOnlyStatusFilter(
|
||||
filters: Record<string, string | string[] | undefined>,
|
||||
): boolean {
|
||||
const directStatusValues = parseStatusFilterValue(
|
||||
typeof filters["filter[status]"] === "string"
|
||||
? filters["filter[status]"]
|
||||
: undefined,
|
||||
// Normalise both `filter[status]` and `filter[status__in]` CSV forms
|
||||
// and uppercase so "fail", "Fail" etc. still match the wire value.
|
||||
const direct = splitCsvFilterValues(filters["filter[status]"]).map((s) =>
|
||||
s.toUpperCase(),
|
||||
);
|
||||
|
||||
if (directStatusValues.length > 0) {
|
||||
return directStatusValues.length === 1 && directStatusValues[0] === "FAIL";
|
||||
if (direct.length > 0) {
|
||||
return direct.length === 1 && direct[0] === FAIL_FILTER_VALUE;
|
||||
}
|
||||
|
||||
const multiStatusValues = parseStatusFilterValue(
|
||||
typeof filters["filter[status__in]"] === "string"
|
||||
? filters["filter[status__in]"]
|
||||
: undefined,
|
||||
const multi = splitCsvFilterValues(filters["filter[status__in]"]).map((s) =>
|
||||
s.toUpperCase(),
|
||||
);
|
||||
|
||||
return multiStatusValues.length === 1 && multiStatusValues[0] === "FAIL";
|
||||
}
|
||||
|
||||
function includesMutedFindings(
|
||||
filters: Record<string, string | string[] | undefined>,
|
||||
): boolean {
|
||||
const mutedFilter = filters["filter[muted]"];
|
||||
|
||||
if (Array.isArray(mutedFilter)) {
|
||||
return mutedFilter.includes("include");
|
||||
}
|
||||
|
||||
return mutedFilter === "include";
|
||||
return multi.length === 1 && multi[0] === FAIL_FILTER_VALUE;
|
||||
}
|
||||
|
||||
export function getFilteredFindingGroupResourceCount(
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useEffect, useState } from "react";
|
||||
import { Rectangle, ResponsiveContainer, Sankey, Tooltip } from "recharts";
|
||||
|
||||
import { PROVIDER_BADGE_BY_NAME } from "@/components/icons/providers-badge";
|
||||
import { applyFailNonMutedFilters } from "@/lib";
|
||||
import { initializeChartColors } from "@/lib/charts/colors";
|
||||
import { PROVIDER_DISPLAY_NAMES } from "@/types/providers";
|
||||
import { SEVERITY_FILTER_MAP } from "@/types/severities";
|
||||
@@ -463,8 +464,7 @@ export function SankeyChart({
|
||||
if (severityFilter) {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.set("filter[severity__in]", severityFilter);
|
||||
params.set("filter[status__in]", "FAIL");
|
||||
params.set("filter[muted]", "false");
|
||||
applyFailNonMutedFilters(params);
|
||||
router.push(`/findings?${params.toString()}`);
|
||||
}
|
||||
};
|
||||
@@ -484,8 +484,7 @@ export function SankeyChart({
|
||||
}
|
||||
|
||||
params.set("filter[severity__in]", severityFilter);
|
||||
params.set("filter[status__in]", "FAIL");
|
||||
params.set("filter[muted]", "false");
|
||||
applyFailNonMutedFilters(params);
|
||||
router.push(`/findings?${params.toString()}`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
import Link from "next/link";
|
||||
|
||||
import {
|
||||
FAIL_FILTER_VALUE,
|
||||
NEW_DELTA_FILTER_VALUE,
|
||||
} from "@/lib/findings-filters";
|
||||
import { FINDING_GROUPS_FILTERED_SORT } from "@/lib/findings-sort";
|
||||
|
||||
const FINDINGS_LINK_HREF = `/findings?sort=${FINDING_GROUPS_FILTERED_SORT}&filter[status__in]=${FAIL_FILTER_VALUE}&filter[delta]=${NEW_DELTA_FILTER_VALUE}`;
|
||||
|
||||
export const LinkToFindings = () => {
|
||||
return (
|
||||
<Link
|
||||
href="/findings?sort=-severity,-last_seen_at&filter[status__in]=FAIL&filter[delta]=new"
|
||||
href={FINDINGS_LINK_HREF}
|
||||
aria-label="Go to Findings page"
|
||||
className="text-button-tertiary hover:text-button-tertiary-hover text-sm font-medium transition-colors"
|
||||
>
|
||||
|
||||
@@ -153,7 +153,7 @@ describe("useProviderWizardController", () => {
|
||||
expect(onOpenChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("moves to launch step after a successful connection test in update mode", async () => {
|
||||
it("closes the wizard after a successful connection test in update mode", async () => {
|
||||
// Given
|
||||
const onOpenChange = vi.fn();
|
||||
const { result } = renderHook(() =>
|
||||
@@ -181,9 +181,10 @@ describe("useProviderWizardController", () => {
|
||||
result.current.handleTestSuccess();
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(result.current.currentStep).toBe(PROVIDER_WIZARD_STEP.LAUNCH);
|
||||
expect(onOpenChange).not.toHaveBeenCalled();
|
||||
// Then: credential rotation never surfaces the launch/schedule step
|
||||
expect(onOpenChange).toHaveBeenCalledWith(false);
|
||||
expect(refreshMock).toHaveBeenCalledTimes(1);
|
||||
expect(result.current.currentStep).not.toBe(PROVIDER_WIZARD_STEP.LAUNCH);
|
||||
});
|
||||
|
||||
it("does not override launch footer config in the controller", () => {
|
||||
|
||||
@@ -197,6 +197,12 @@ export function useProviderWizardController({
|
||||
};
|
||||
|
||||
const handleTestSuccess = () => {
|
||||
if (
|
||||
useProviderWizardStore.getState().mode === PROVIDER_WIZARD_MODE.UPDATE
|
||||
) {
|
||||
handleClose();
|
||||
return;
|
||||
}
|
||||
setCurrentStep(PROVIDER_WIZARD_STEP.LAUNCH);
|
||||
};
|
||||
|
||||
@@ -234,6 +240,7 @@ export function useProviderWizardController({
|
||||
handleTestSuccess,
|
||||
isOrgDirectEntry,
|
||||
isProviderFlow,
|
||||
mode,
|
||||
modalTitle,
|
||||
openOrganizationsFlow,
|
||||
orgCurrentStep,
|
||||
|
||||
@@ -10,7 +10,10 @@ import { DialogHeader, DialogTitle } from "@/components/shadcn/dialog";
|
||||
import { Modal } from "@/components/shadcn/modal";
|
||||
import { useScrollHint } from "@/hooks/use-scroll-hint";
|
||||
import { ORG_SETUP_PHASE, ORG_WIZARD_STEP } from "@/types/organizations";
|
||||
import { PROVIDER_WIZARD_STEP } from "@/types/provider-wizard";
|
||||
import {
|
||||
PROVIDER_WIZARD_MODE,
|
||||
PROVIDER_WIZARD_STEP,
|
||||
} from "@/types/provider-wizard";
|
||||
|
||||
import { useProviderWizardController } from "./hooks/use-provider-wizard-controller";
|
||||
import {
|
||||
@@ -23,7 +26,12 @@ import { WIZARD_FOOTER_ACTION_TYPE } from "./steps/footer-controls";
|
||||
import { LaunchStep } from "./steps/launch-step";
|
||||
import { TestConnectionStep } from "./steps/test-connection-step";
|
||||
import type { OrgWizardInitialData, ProviderWizardInitialData } from "./types";
|
||||
import { WizardStepper } from "./wizard-stepper";
|
||||
import { PROVIDER_WIZARD_STEPS, WizardStepper } from "./wizard-stepper";
|
||||
|
||||
const UPDATE_MODE_WIZARD_STEPS = PROVIDER_WIZARD_STEPS.slice(
|
||||
0,
|
||||
PROVIDER_WIZARD_STEP.LAUNCH,
|
||||
);
|
||||
|
||||
interface ProviderWizardModalProps {
|
||||
open: boolean;
|
||||
@@ -47,6 +55,7 @@ export function ProviderWizardModal({
|
||||
handleTestSuccess,
|
||||
isOrgDirectEntry,
|
||||
isProviderFlow,
|
||||
mode,
|
||||
modalTitle,
|
||||
openOrganizationsFlow,
|
||||
orgCurrentStep,
|
||||
@@ -97,7 +106,14 @@ export function ProviderWizardModal({
|
||||
<div className="mt-6 flex min-h-0 flex-1 flex-col overflow-hidden lg:mt-8 lg:flex-row">
|
||||
<div className="mb-4 box-border w-full shrink-0 lg:mb-0 lg:w-[328px]">
|
||||
{isProviderFlow ? (
|
||||
<WizardStepper currentStep={currentStep} />
|
||||
<WizardStepper
|
||||
currentStep={currentStep}
|
||||
steps={
|
||||
mode === PROVIDER_WIZARD_MODE.UPDATE
|
||||
? UPDATE_MODE_WIZARD_STEPS
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<WizardStepper
|
||||
currentStep={orgCurrentStep}
|
||||
|
||||
@@ -7,17 +7,18 @@ import { ProwlerShort } from "@/components/icons/prowler/ProwlerIcons";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { IconComponent, IconSvgProps } from "@/types/components";
|
||||
|
||||
interface WizardStepperProps {
|
||||
currentStep: number;
|
||||
stepOffset?: number;
|
||||
}
|
||||
|
||||
interface StepConfig {
|
||||
label: string;
|
||||
description: string;
|
||||
icon: IconComponent;
|
||||
}
|
||||
|
||||
interface WizardStepperProps {
|
||||
currentStep: number;
|
||||
stepOffset?: number;
|
||||
steps?: StepConfig[];
|
||||
}
|
||||
|
||||
const STEPS: StepConfig[] = [
|
||||
{
|
||||
label: "Link a Cloud Provider",
|
||||
@@ -43,18 +44,21 @@ const STEPS: StepConfig[] = [
|
||||
},
|
||||
];
|
||||
|
||||
export const PROVIDER_WIZARD_STEPS = STEPS;
|
||||
|
||||
export function WizardStepper({
|
||||
currentStep,
|
||||
stepOffset = 0,
|
||||
steps = STEPS,
|
||||
}: WizardStepperProps) {
|
||||
const activeVisualStep = Math.max(
|
||||
0,
|
||||
Math.min(currentStep + stepOffset, STEPS.length - 1),
|
||||
Math.min(currentStep + stepOffset, steps.length - 1),
|
||||
);
|
||||
|
||||
return (
|
||||
<nav aria-label="Wizard progress" className="flex flex-col gap-0">
|
||||
{STEPS.map((step, index) => {
|
||||
{steps.map((step, index) => {
|
||||
const isComplete = index < activeVisualStep;
|
||||
const isActive = index === activeVisualStep;
|
||||
const isInactive = index > activeVisualStep;
|
||||
@@ -67,7 +71,7 @@ export function WizardStepper({
|
||||
isActive={isActive}
|
||||
icon={step.icon}
|
||||
/>
|
||||
{index < STEPS.length - 1 && (
|
||||
{index < steps.length - 1 && (
|
||||
<StepConnector isComplete={isComplete} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -38,6 +38,15 @@ const cardVariants = cva("", {
|
||||
},
|
||||
});
|
||||
|
||||
// Neutral surface + colored top bar; reads well in both light and dark modes.
|
||||
const accentBarByVariant: Record<CardVariant, string> = {
|
||||
[CardVariant.default]: "",
|
||||
[CardVariant.fail]: "bg-bg-fail-primary",
|
||||
[CardVariant.pass]: "bg-bg-pass-primary",
|
||||
[CardVariant.warning]: "bg-bg-warning-primary",
|
||||
[CardVariant.info]: "bg-bg-data-info",
|
||||
};
|
||||
|
||||
export interface ResourceStatsCardProps
|
||||
extends Omit<React.HTMLAttributes<HTMLDivElement>, "color">,
|
||||
VariantProps<typeof cardVariants> {
|
||||
@@ -66,6 +75,11 @@ export interface ResourceStatsCardProps
|
||||
// Vertical accent line color (optional, auto-determined from variant)
|
||||
accentColor?: string;
|
||||
|
||||
// Horizontal top accent bar. When set, the card renders on a neutral surface
|
||||
// with a colored bar across the top using design tokens. Prefer this over
|
||||
// `variant` when the surface needs to read well in both light and dark modes.
|
||||
accent?: CardVariant;
|
||||
|
||||
// Sub-statistics array (flexible items)
|
||||
stats?: StatItem[];
|
||||
|
||||
@@ -82,6 +96,7 @@ export const ResourceStatsCard = ({
|
||||
badge,
|
||||
label,
|
||||
accentColor,
|
||||
accent,
|
||||
stats = [],
|
||||
variant = CardVariant.default,
|
||||
size = "md",
|
||||
@@ -93,7 +108,14 @@ export const ResourceStatsCard = ({
|
||||
// Resolve size to ensure it's not null (CVA can return null but we need a defined value)
|
||||
const resolvedSize = size || "md";
|
||||
|
||||
// If containerless, render without outer wrapper
|
||||
// `accent` takes precedence: it forces a neutral surface and a colored top bar,
|
||||
// so the card reads well in both themes regardless of `variant`.
|
||||
const resolvedVariant = accent ? CardVariant.default : variant;
|
||||
const accentClassName = accent ? accentBarByVariant[accent] : "";
|
||||
|
||||
// If containerless, render without outer wrapper. `accent` is ignored in this
|
||||
// mode because the caller supplies the container; consumers that need the
|
||||
// accent bar can render it themselves or drop containerless.
|
||||
if (containerless) {
|
||||
return (
|
||||
<div
|
||||
@@ -129,9 +151,25 @@ export const ResourceStatsCard = ({
|
||||
<Card
|
||||
ref={ref}
|
||||
variant="inner"
|
||||
className={cn(cardVariants({ variant, size }), "flex-col", className)}
|
||||
className={cn(
|
||||
cardVariants({ variant: resolvedVariant, size }),
|
||||
"flex-col",
|
||||
accent &&
|
||||
"border-border-neutral-secondary bg-bg-neutral-secondary relative overflow-hidden",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{accent && (
|
||||
<span
|
||||
aria-hidden
|
||||
data-slot="resource-stats-card-accent"
|
||||
className={cn(
|
||||
"absolute inset-x-0 top-0 h-1 rounded-t-[inherit]",
|
||||
accentClassName,
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{header && <ResourceStatsCardHeader {...header} size={resolvedSize} />}
|
||||
{emptyState ? (
|
||||
<div className="flex h-[51px] w-full flex-col items-center justify-center">
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
"use client";
|
||||
|
||||
import { Dispatch, SetStateAction, useTransition } from "react";
|
||||
|
||||
import { removeUserFromTenant } from "@/actions/users/users";
|
||||
import { DeleteIcon } from "@/components/icons";
|
||||
import { Button } from "@/components/shadcn";
|
||||
import { useToast } from "@/components/ui";
|
||||
|
||||
interface ExpelUserFormProps {
|
||||
userId: string;
|
||||
userName?: string;
|
||||
tenantId: string;
|
||||
setIsOpen: Dispatch<SetStateAction<boolean>>;
|
||||
}
|
||||
|
||||
export const ExpelUserForm = ({
|
||||
userId,
|
||||
userName,
|
||||
tenantId,
|
||||
setIsOpen,
|
||||
}: ExpelUserFormProps) => {
|
||||
const { toast } = useToast();
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const handleExpel = () => {
|
||||
startTransition(async () => {
|
||||
const formData = new FormData();
|
||||
formData.append("userId", userId);
|
||||
formData.append("tenantId", tenantId);
|
||||
|
||||
const data = await removeUserFromTenant(formData);
|
||||
|
||||
if (!data || !("success" in data) || data.success !== true) {
|
||||
const detail =
|
||||
data && "errors" in data && data.errors?.[0]?.detail
|
||||
? data.errors[0].detail
|
||||
: "Failed to expel the user";
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Oops! Something went wrong",
|
||||
description: detail,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
toast({
|
||||
title: "User expelled",
|
||||
description: `${userName ?? "The user"} was removed from this organization.`,
|
||||
});
|
||||
setIsOpen(false);
|
||||
});
|
||||
};
|
||||
|
||||
const displayName = userName ?? "this user";
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<p className="text-sm">
|
||||
<span className="font-semibold">{displayName}</span> will lose access to
|
||||
this organization. If they don't belong to any other organization,
|
||||
their account will be permanently deleted.
|
||||
</p>
|
||||
|
||||
<div className="flex w-full justify-end gap-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="lg"
|
||||
onClick={() => setIsOpen(false)}
|
||||
disabled={isPending}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
size="lg"
|
||||
onClick={handleExpel}
|
||||
disabled={isPending}
|
||||
>
|
||||
{!isPending && <DeleteIcon size={24} aria-hidden="true" />}
|
||||
{isPending ? "Expelling…" : "Expel user"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from "./delete-form";
|
||||
export * from "./edit-form";
|
||||
export * from "./edit-tenant-form";
|
||||
export * from "./expel-user-form";
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { Row } from "@tanstack/react-table";
|
||||
import { Pencil, Trash2 } from "lucide-react";
|
||||
import { Pencil, Trash2, UserMinus } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
import {
|
||||
@@ -11,24 +11,51 @@ import {
|
||||
} from "@/components/shadcn/dropdown";
|
||||
import { Modal } from "@/components/shadcn/modal";
|
||||
|
||||
import { DeleteForm, EditForm } from "../forms";
|
||||
import { DeleteForm, EditForm, ExpelUserForm } from "../forms";
|
||||
|
||||
interface DataTableRowActionsProps<UserProps> {
|
||||
interface UserRowRole {
|
||||
name?: string;
|
||||
}
|
||||
|
||||
interface UserRowAttributes {
|
||||
name?: string;
|
||||
email?: string;
|
||||
company_name?: string;
|
||||
role?: UserRowRole;
|
||||
}
|
||||
|
||||
interface UserRowData {
|
||||
id: string;
|
||||
attributes?: UserRowAttributes;
|
||||
canBeExpelled?: boolean;
|
||||
currentTenantId?: string;
|
||||
}
|
||||
|
||||
interface DataTableRowActionsProps<UserProps extends UserRowData> {
|
||||
row: Row<UserProps>;
|
||||
roles?: { id: string; name: string }[];
|
||||
}
|
||||
|
||||
export function DataTableRowActions<UserProps>({
|
||||
export function DataTableRowActions<UserProps extends UserRowData>({
|
||||
row,
|
||||
roles,
|
||||
}: DataTableRowActionsProps<UserProps>) {
|
||||
const [isEditOpen, setIsEditOpen] = useState(false);
|
||||
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
|
||||
const userId = (row.original as { id: string }).id;
|
||||
const userName = (row.original as any).attributes?.name;
|
||||
const userEmail = (row.original as any).attributes?.email;
|
||||
const userCompanyName = (row.original as any).attributes?.company_name;
|
||||
const userRole = (row.original as any).attributes?.role?.name;
|
||||
const [isExpelOpen, setIsExpelOpen] = useState(false);
|
||||
const userId = row.original.id;
|
||||
const userName = row.original.attributes?.name;
|
||||
const userEmail = row.original.attributes?.email;
|
||||
const userCompanyName = row.original.attributes?.company_name;
|
||||
const userRole = row.original.attributes?.role?.name;
|
||||
|
||||
// Expel gate is resolved server-side against the active tenant's membership
|
||||
// role (owner vs member), mirroring the backend rule in
|
||||
// TenantMembersViewSet.destroy. The row is only expel-eligible when the
|
||||
// current user is an owner of the active tenant and the row is not theirs.
|
||||
const canExpelUser =
|
||||
row.original.canBeExpelled === true && !!row.original.currentTenantId;
|
||||
const currentTenantId = row.original.currentTenantId;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -55,17 +82,39 @@ export function DataTableRowActions<UserProps>({
|
||||
>
|
||||
<DeleteForm userId={userId} setIsOpen={setIsDeleteOpen} />
|
||||
</Modal>
|
||||
{canExpelUser && currentTenantId && (
|
||||
<Modal
|
||||
open={isExpelOpen}
|
||||
onOpenChange={setIsExpelOpen}
|
||||
title="Expel user from this organization"
|
||||
>
|
||||
<ExpelUserForm
|
||||
userId={userId}
|
||||
userName={userName}
|
||||
tenantId={currentTenantId}
|
||||
setIsOpen={setIsExpelOpen}
|
||||
/>
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
<div className="relative flex items-center justify-end gap-2">
|
||||
<ActionDropdown>
|
||||
<ActionDropdownItem
|
||||
icon={<Pencil />}
|
||||
icon={<Pencil aria-hidden="true" />}
|
||||
label="Edit User"
|
||||
onSelect={() => setIsEditOpen(true)}
|
||||
/>
|
||||
<ActionDropdownDangerZone>
|
||||
{canExpelUser && (
|
||||
<ActionDropdownItem
|
||||
icon={<UserMinus aria-hidden="true" />}
|
||||
label="Expel from organization"
|
||||
destructive
|
||||
onSelect={() => setIsExpelOpen(true)}
|
||||
/>
|
||||
)}
|
||||
<ActionDropdownItem
|
||||
icon={<Trash2 />}
|
||||
icon={<Trash2 aria-hidden="true" />}
|
||||
label="Delete User"
|
||||
destructive
|
||||
onSelect={() => setIsDeleteOpen(true)}
|
||||
|
||||
Reference in new issue
Block a user