diff --git a/ui/CHANGELOG.md b/ui/CHANGELOG.md index 7761d6c54d..08459d0c8f 100644 --- a/ui/CHANGELOG.md +++ b/ui/CHANGELOG.md @@ -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 diff --git a/ui/actions/users/tenants.ts b/ui/actions/users/tenants.ts index b43b467d93..79723debaa 100644 --- a/ui/actions/users/tenants.ts +++ b/ui/actions/users/tenants.ts @@ -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 { + 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; diff --git a/ui/components/users/forms/delete-tenant-form.test.tsx b/ui/components/users/forms/delete-tenant-form.test.tsx index d92134f99e..3161c9d8b6 100644 --- a/ui/components/users/forms/delete-tenant-form.test.tsx +++ b/ui/components/users/forms/delete-tenant-form.test.tsx @@ -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( + , + ); + 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( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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(); + }); }); diff --git a/ui/components/users/forms/delete-tenant-form.tsx b/ui/components/users/forms/delete-tenant-form.tsx index 79bbc7f0bc..3ae8256ab4 100644 --- a/ui/components/users/forms/delete-tenant-form.tsx +++ b/ui/components/users/forms/delete-tenant-form.tsx @@ -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>; } @@ -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) => { + 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 (
- {isActiveTenant && targetTenantId && ( + {needsSwitchTarget && targetTenantId && ( )} @@ -137,7 +190,14 @@ export const DeleteTenantForm = ({ autoComplete="off" /> - {isActiveTenant && ( + {isLastTenant && ( +
+ This is your only organization. Deleting it will also remove your user + account and close your session. +
+ )} + + {needsSwitchTarget && (
This is your active organization. Select which organization to diff --git a/ui/components/users/profile/membership-item.test.tsx b/ui/components/users/profile/membership-item.test.tsx index 54006a0afc..2ccb951039 100644 --- a/ui/components/users/profile/membership-item.test.tsx +++ b/ui/components/users/profile/membership-item.test.tsx @@ -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( { 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", () => { diff --git a/ui/components/users/profile/membership-item.tsx b/ui/components/users/profile/membership-item.tsx index 1d6f7d02d6..15039c4e39 100644 --- a/ui/components/users/profile/membership-item.tsx +++ b/ui/components/users/profile/membership-item.tsx @@ -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." + } >
- {membership.attributes.role} + + {membership.attributes.role} + -
- - - {tenantName} - +
+ + + + + {tenantName} + + + {tenantName} + - - - -
- -
- {isOrgOwner && ( - - )} - {canDelete && ( - - )} - {isActiveTenant ? ( - + {isActiveTenant && ( + Active - ) : ( - )}
+ + + + + + {/* Fixed-width slots keep Edit/Delete/Switch aligned across rows */} +
+ + {isOrgOwner && ( + + )} + + + {isOrgOwner && ( + + )} + + + {!isActiveTenant && ( + + )} + +
diff --git a/ui/components/users/profile/memberships-card-client.tsx b/ui/components/users/profile/memberships-card-client.tsx index cab2a3f924..6edf01560e 100644 --- a/ui/components/users/profile/memberships-card-client.tsx +++ b/ui/components/users/profile/memberships-card-client.tsx @@ -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 = ({
Organizations

- Organizations this user is associated with + Organizations this user is associated with.{" "} + + Learn more +