mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-07-23 12:31:54 +00:00
chore(multi-tenant): delete last tenant from profile page (#11864)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user