chore(multi-tenant): delete last tenant from profile page (#11864)

This commit is contained in:
Pepe Fagoaga
2026-07-07 10:02:36 +02:00
committed by GitHub
parent a48aa37f87
commit aa6de57430
7 changed files with 338 additions and 66 deletions
+12
View File
@@ -2,6 +2,18 @@
All notable changes to the **Prowler UI** are documented in this file.
## [1.33.0] (Prowler v5.33.0)
### 🚀 Added
- Owners can delete their last organization from the profile page [(#11864)](https://github.com/prowler-cloud/prowler/pull/11864)
### 🔄 Changed
- Organization row actions in the profile page are aligned in fixed columns and the Active indicator now sits next to the organization name [(#11864)](https://github.com/prowler-cloud/prowler/pull/11864)
---
## [1.32.1] (Prowler v5.32.1)
### 🐞 Fixed
+42
View File
@@ -3,6 +3,7 @@
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { signOut } from "@/auth.config";
import { apiBaseUrl, getAuthHeaders } from "@/lib/helper";
import { handleApiError, handleApiResponse } from "@/lib/server-actions-helper";
@@ -287,6 +288,47 @@ export async function deleteTenant(
}
}
export async function deleteTenantThenSignOut(
_prevState: DeleteTenantState | null,
formData: FormData,
): Promise<DeleteTenantState> {
const formDataObject = Object.fromEntries(formData);
const validatedData = deleteTenantSchema.safeParse(formDataObject);
if (!validatedData.success) {
return { error: "Invalid tenant ID" };
}
const { tenantId } = validatedData.data;
const headers = await getAuthHeaders({ contentType: false });
try {
const url = new URL(`${apiBaseUrl}/tenants/${tenantId}`);
const response = await fetch(url.toString(), {
method: "DELETE",
headers,
});
if (!response.ok) {
const errorData = await response.json().catch(() => null);
const errorDetail =
errorData?.errors?.[0]?.detail ||
`Failed to delete tenant: ${response.statusText}`;
throw new Error(errorDetail);
}
} catch (error) {
return handleApiError(error);
}
// Deleting the last tenant also removes the user account server-side, so
// there is no profile left to revalidate; close the session instead.
// signOut redirects by throwing, so it must stay outside the try/catch.
await signOut({ redirectTo: "/sign-in" });
// Unreachable: signOut always redirects. Present to satisfy the return type.
return { success: true };
}
interface SwitchThenDeleteSuccess {
success: true;
accessToken: string;
@@ -1,7 +1,9 @@
import { render, screen } from "@testing-library/react";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { deleteTenantThenSignOut } from "@/actions/users/tenants";
import { DeleteTenantForm } from "./delete-tenant-form";
const mockUpdate = vi.fn();
@@ -15,6 +17,7 @@ vi.mock("@/auth.config", () => ({
vi.mock("@/actions/users/tenants", () => ({
deleteTenant: vi.fn(),
deleteTenantThenSignOut: vi.fn(),
switchTenant: vi.fn(),
switchThenDeleteTenant: vi.fn(),
}));
@@ -28,6 +31,7 @@ const baseProps = {
tenantId: "tenant-1",
tenantName: "My Organization",
isActiveTenant: false,
isLastTenant: false,
availableTenants: [{ id: "tenant-2", name: "Other Org" }],
setIsOpen: vi.fn(),
};
@@ -87,4 +91,119 @@ describe("DeleteTenantForm", () => {
await user.click(screen.getByRole("button", { name: /cancel/i }));
expect(baseProps.setIsOpen).toHaveBeenCalledWith(false);
});
it("shows last-tenant warning and no target select when isLastTenant", () => {
render(
<DeleteTenantForm
{...baseProps}
isActiveTenant={true}
isLastTenant={true}
availableTenants={[]}
/>,
);
expect(screen.getByText(/close your session/i)).toBeInTheDocument();
expect(
screen.queryByText(/switch to after deletion/i),
).not.toBeInTheDocument();
});
it("last-tenant submit enables with name only and calls the delete-and-sign-out action", async () => {
const user = userEvent.setup();
// The action redirects server-side on success, so the promise never
// resolves with a value in the real flow; a NEXT_REDIRECT rejection is
// the closest observable behavior.
vi.mocked(deleteTenantThenSignOut).mockRejectedValue({
digest: "NEXT_REDIRECT;replace;/sign-in;303;",
});
render(
<DeleteTenantForm
{...baseProps}
isActiveTenant={true}
isLastTenant={true}
availableTenants={[]}
/>,
);
const submitBtn = screen.getByRole("button", { name: /delete/i });
expect(submitBtn).toBeDisabled();
await user.type(
screen.getByPlaceholderText("My Organization"),
"My Organization",
);
expect(submitBtn).toBeEnabled();
await user.click(submitBtn);
await waitFor(() => {
expect(deleteTenantThenSignOut).toHaveBeenCalled();
});
// A redirect is not a failure: no error toast
expect(mockToast).not.toHaveBeenCalledWith(
expect.objectContaining({ variant: "destructive" }),
);
});
it("last-tenant submit shows error and re-enables when delete fails", async () => {
const user = userEvent.setup();
vi.mocked(deleteTenantThenSignOut).mockResolvedValue({
error: "Delete failed",
});
render(
<DeleteTenantForm
{...baseProps}
isActiveTenant={true}
isLastTenant={true}
availableTenants={[]}
/>,
);
await user.type(
screen.getByPlaceholderText("My Organization"),
"My Organization",
);
await user.click(screen.getByRole("button", { name: /delete/i }));
await waitFor(() => {
expect(mockToast).toHaveBeenCalledWith(
expect.objectContaining({
variant: "destructive",
description: "Delete failed",
}),
);
});
// Submitting state is reset so the user is not stuck on a disabled button
expect(screen.getByRole("button", { name: /delete/i })).toBeEnabled();
});
it("last-tenant submit recovers when the action call itself fails", async () => {
const user = userEvent.setup();
vi.mocked(deleteTenantThenSignOut).mockRejectedValue(
new Error("network error"),
);
render(
<DeleteTenantForm
{...baseProps}
isActiveTenant={true}
isLastTenant={true}
availableTenants={[]}
/>,
);
await user.type(
screen.getByPlaceholderText("My Organization"),
"My Organization",
);
await user.click(screen.getByRole("button", { name: /delete/i }));
await waitFor(() => {
expect(mockToast).toHaveBeenCalledWith(
expect.objectContaining({ variant: "destructive" }),
);
});
expect(screen.getByRole("button", { name: /delete/i })).toBeEnabled();
});
});
@@ -10,7 +10,11 @@ import {
useState,
} from "react";
import { deleteTenant, switchThenDeleteTenant } from "@/actions/users/tenants";
import {
deleteTenant,
deleteTenantThenSignOut,
switchThenDeleteTenant,
} from "@/actions/users/tenants";
import { Input } from "@/components/shadcn/input/input";
import {
Select,
@@ -28,6 +32,7 @@ interface DeleteTenantFormProps {
tenantId: string;
tenantName: string;
isActiveTenant: boolean;
isLastTenant: boolean;
availableTenants: TenantOption[];
setIsOpen: Dispatch<SetStateAction<boolean>>;
}
@@ -36,6 +41,7 @@ export const DeleteTenantForm = ({
tenantId,
tenantName,
isActiveTenant,
isLastTenant,
availableTenants,
setIsOpen,
}: DeleteTenantFormProps) => {
@@ -48,7 +54,10 @@ export const DeleteTenantForm = ({
const [isSubmitting, setIsSubmitting] = useState(false);
const nameMatches = confirmName === tenantName;
const canSubmit = isActiveTenant
// Deleting the last tenant needs no switch target — there is nothing to
// switch to; the session is closed after deletion instead.
const needsSwitchTarget = isActiveTenant && !isLastTenant;
const canSubmit = needsSwitchTarget
? nameMatches && targetTenantId !== ""
: nameMatches;
@@ -71,6 +80,44 @@ export const DeleteTenantForm = ({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [deleteState]);
// Handle last-tenant delete: a single server action deletes the tenant and
// closes the session, since the API also removes users whose only tenant
// was the deleted one. On success it redirects to /sign-in server-side.
const handleLastTenantDelete = async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
setIsSubmitting(true);
const formData = new FormData(e.currentTarget);
try {
const result = await deleteTenantThenSignOut(null, formData);
if (result && "error" in result) {
toast({
variant: "destructive",
title: "Oops! Something went wrong",
description: result.error,
});
setIsSubmitting(false);
}
} catch (error) {
// The action redirects by throwing NEXT_REDIRECT — never a failure.
if (
error &&
typeof error === "object" &&
"digest" in error &&
String(error.digest).startsWith("NEXT_REDIRECT")
) {
return;
}
toast({
variant: "destructive",
title: "Oops! Something went wrong",
description: "The organization could not be deleted. Please try again.",
});
setIsSubmitting(false);
}
};
// Handle active-tenant delete: call server action directly to avoid
// React's RSC reconciliation unmounting this component before we can
// update the session with the new tokens.
@@ -115,12 +162,18 @@ export const DeleteTenantForm = ({
return (
<form
action={isActiveTenant ? undefined : deleteFormAction}
onSubmit={isActiveTenant ? handleActiveTenantDelete : undefined}
action={isActiveTenant || isLastTenant ? undefined : deleteFormAction}
onSubmit={
isLastTenant
? handleLastTenantDelete
: isActiveTenant
? handleActiveTenantDelete
: undefined
}
className="flex flex-col gap-4"
>
<input type="hidden" name="tenantId" value={tenantId} />
{isActiveTenant && targetTenantId && (
{needsSwitchTarget && targetTenantId && (
<input type="hidden" name="targetTenantId" value={targetTenantId} />
)}
@@ -137,7 +190,14 @@ export const DeleteTenantForm = ({
autoComplete="off"
/>
{isActiveTenant && (
{isLastTenant && (
<div className="text-text-error-primary text-sm">
This is your only organization. Deleting it will also remove your user
account and close your session.
</div>
)}
{needsSwitchTarget && (
<div className="flex flex-col gap-2">
<div className="text-sm">
This is your active organization. Select which organization to
@@ -15,6 +15,8 @@ vi.mock("@/actions/users/tenants", () => ({
switchTenant: vi.fn(),
updateTenantName: vi.fn(),
deleteTenant: vi.fn(),
deleteTenantThenSignOut: vi.fn(),
switchThenDeleteTenant: vi.fn(),
}));
vi.mock("@/components/ui", () => ({
@@ -133,7 +135,7 @@ describe("MembershipItem", () => {
expect(screen.getByRole("button", { name: /delete/i })).toBeInTheDocument();
});
it("hides Delete button when membershipCount === 1", () => {
it("shows Delete button when isOrgOwner and membershipCount === 1", () => {
render(
<MembershipItem
membership={baseMembership}
@@ -145,9 +147,7 @@ describe("MembershipItem", () => {
membershipCount={1}
/>,
);
expect(
screen.queryByRole("button", { name: /delete/i }),
).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: /delete/i })).toBeInTheDocument();
});
it("hides Delete button when not isOrgOwner", () => {
+89 -54
View File
@@ -2,7 +2,14 @@
import { useState } from "react";
import { Badge, Button, Card } from "@/components/shadcn";
import {
Badge,
Button,
Card,
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/shadcn";
import { InfoField } from "@/components/shadcn/info-field/info-field";
import { Modal } from "@/components/shadcn/modal";
import { DateWithTime } from "@/components/ui/entities";
@@ -33,7 +40,7 @@ export const MembershipItem = ({
const [isDeletingOpen, setIsDeletingOpen] = useState(false);
const isActiveTenant = tenantId === sessionTenantId;
const canDelete = isOrgOwner && membershipCount > 1;
const isLastTenant = membershipCount === 1;
return (
<>
@@ -56,75 +63,103 @@ export const MembershipItem = ({
open={isDeletingOpen}
onOpenChange={setIsDeletingOpen}
title="Delete organization"
description="This will permanently delete the organization and all its data. Users with no other organizations will lose access. This action cannot be undone."
description={
isLastTenant
? "This will permanently delete the organization and all its data. This action cannot be undone."
: "This will permanently delete the organization and all its data. Users with no other organizations will lose access. This action cannot be undone."
}
>
<DeleteTenantForm
tenantId={tenantId}
tenantName={tenantName}
isActiveTenant={isActiveTenant}
isLastTenant={isLastTenant}
availableTenants={availableTenants}
setIsOpen={setIsDeletingOpen}
/>
</Modal>
<Card variant="inner" className="p-2">
<div className="flex w-full flex-col gap-2 sm:flex-row sm:items-center sm:gap-4">
<Badge variant="secondary">{membership.attributes.role}</Badge>
<span className="flex w-16 shrink-0">
<Badge variant="secondary">{membership.attributes.role}</Badge>
</span>
<div className="flex flex-row flex-wrap gap-1 gap-x-4">
<InfoField label="Name" inline variant="transparent">
<span className="font-semibold whitespace-nowrap">
{tenantName}
</span>
<div className="flex min-w-0 flex-1 items-center gap-2">
<InfoField
label="Name"
inline
variant="transparent"
className="min-w-0 [&>div]:min-w-0"
>
<Tooltip>
<TooltipTrigger asChild>
<span className="block truncate font-semibold">
{tenantName}
</span>
</TooltipTrigger>
<TooltipContent>{tenantName}</TooltipContent>
</Tooltip>
</InfoField>
<InfoField label="Joined on" inline variant="transparent">
<DateWithTime
inline
showTime={false}
dateTime={membership.attributes.date_joined}
/>
</InfoField>
</div>
<div className="ml-auto flex items-center gap-2">
{isOrgOwner && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setIsEditOpen(true)}
>
Edit
</Button>
)}
{canDelete && (
<Button
type="button"
variant="ghost"
size="sm"
className="text-destructive hover:text-destructive"
onClick={() => setIsDeletingOpen(true)}
>
Delete
</Button>
)}
{isActiveTenant ? (
<Badge
variant="outline"
className="border-emerald-600 text-emerald-600"
>
{isActiveTenant && (
<Badge variant="success" className="shrink-0">
Active
</Badge>
) : (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setIsSwitchingOpen(true)}
>
Switch
</Button>
)}
</div>
<InfoField
label="Joined on"
inline
variant="transparent"
className="shrink-0"
>
<DateWithTime
inline
showTime={false}
dateTime={membership.attributes.date_joined}
/>
</InfoField>
{/* Fixed-width slots keep Edit/Delete/Switch aligned across rows */}
<div className="flex shrink-0 items-center gap-2 self-end sm:self-auto">
<span className="flex w-18 justify-center">
{isOrgOwner && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setIsEditOpen(true)}
>
Edit
</Button>
)}
</span>
<span className="flex w-18 justify-center">
{isOrgOwner && (
<Button
type="button"
variant="ghost"
size="sm"
className="text-text-error-primary"
onClick={() => setIsDeletingOpen(true)}
>
Delete
</Button>
)}
</span>
<span className="flex w-18 justify-center">
{!isActiveTenant && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setIsSwitchingOpen(true)}
>
Switch
</Button>
)}
</span>
</div>
</div>
</Card>
</>
@@ -11,6 +11,7 @@ import {
CardTitle,
} from "@/components/shadcn";
import { Modal } from "@/components/shadcn/modal";
import { CustomLink } from "@/components/ui/custom/custom-link";
import { CreateTenantForm } from "@/components/users/forms/create-tenant-form";
import { MembershipDetailData, TenantDetailData } from "@/types/users";
@@ -51,7 +52,10 @@ export const MembershipsCardClient = ({
<div className="flex flex-col gap-1">
<CardTitle>Organizations</CardTitle>
<p className="text-xs text-gray-500">
Organizations this user is associated with
Organizations this user is associated with.{" "}
<CustomLink href="https://docs.prowler.com/user-guide/tutorials/prowler-app-multi-tenant">
Learn more
</CustomLink>
</p>
</div>
<CardAction>