Merge branch 'master' into PROWLER-1403-enhancement-improve-ui-logging-for-better-observability-and-debugging

This commit is contained in:
Pedro Martín authored and GitHub committed 2026-04-23 14:05:51 +02:00
commit 9e1af95a71
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) {
+14 -2
View File
@@ -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(
+3 -4
View File
@@ -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&apos;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
View File
@@ -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)}