Fix: Deletion Deadlocks (Owner, User)
This commit is contained in:
+6
-3
@@ -814,9 +814,12 @@ export const api = {
|
||||
getById: (orgId: string, requestConfig?: RequestConfig) =>
|
||||
request<{ organization: Organization; member_count: number }>(`/organizations/${orgId}`, {}, true, requestConfig),
|
||||
|
||||
// Delete an organization (owner only; must have no other members)
|
||||
deleteOrganization: (orgId: string, requestConfig?: RequestConfig) =>
|
||||
request<{ message: string }>(`/organizations/${orgId}`, { method: 'DELETE' }, true, requestConfig),
|
||||
// Delete an organization (owner only; pass confirm=true when other members exist)
|
||||
deleteOrganization: (orgId: string, confirm?: boolean, requestConfig?: RequestConfig) =>
|
||||
request<{ message: string }>(`/organizations/${orgId}`, {
|
||||
method: 'DELETE',
|
||||
...(confirm ? { body: JSON.stringify({ confirm: true }) } : {}),
|
||||
}, true, requestConfig),
|
||||
|
||||
// Get organization members
|
||||
getMembers: (orgId: string, requestConfig?: RequestConfig) =>
|
||||
|
||||
@@ -658,9 +658,6 @@ export default function DepartmentsPage() {
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-2 text-xs text-muted-foreground">
|
||||
Created {new Date(dept.created_at).toLocaleDateString()}
|
||||
</div>
|
||||
|
||||
{/* Members toggle */}
|
||||
<button
|
||||
|
||||
@@ -46,7 +46,8 @@ export default function OrgOverviewPage() {
|
||||
if (!selectedOrg) return;
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
await api.organizations.deleteOrganization(selectedOrg.id);
|
||||
// If there are other members, pass confirm=true to acknowledge forced removal.
|
||||
await api.organizations.deleteOrganization(selectedOrg.id, memberCount > 1);
|
||||
toast({ title: "Organization deleted", description: `"${selectedOrg.name}" has been deleted.` });
|
||||
setDeleteDialogOpen(false);
|
||||
// Refresh org list; context will auto-select next available org
|
||||
@@ -59,19 +60,11 @@ export default function OrgOverviewPage() {
|
||||
navigate("/org-setup");
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && err.type === "ORG_HAS_MEMBERS") {
|
||||
toast({
|
||||
title: "Cannot delete organization",
|
||||
description: "This organization still has other members. Transfer ownership or remove all members first.",
|
||||
variant: "destructive",
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
title: "Deletion failed",
|
||||
description: err instanceof ApiError ? err.message : "An unexpected error occurred.",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
toast({
|
||||
title: "Deletion failed",
|
||||
description: err instanceof ApiError ? err.message : "An unexpected error occurred.",
|
||||
variant: "destructive",
|
||||
});
|
||||
setDeleteDialogOpen(false);
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
@@ -220,17 +213,15 @@ export default function OrgOverviewPage() {
|
||||
<div>
|
||||
<p className="text-sm font-medium text-destructive">Delete Organization</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Permanently deletes this organization.{" "}
|
||||
{memberCount > 1
|
||||
? `You must remove all ${memberCount - 1} other member${memberCount > 2 ? "s" : ""} first.`
|
||||
: "This action cannot be undone."}
|
||||
? `Permanently deletes this organization and removes all ${memberCount - 1} other member${memberCount > 2 ? "s" : ""}. This action cannot be undone.`
|
||||
: "Permanently deletes this organization. This action cannot be undone."}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => setDeleteDialogOpen(true)}
|
||||
disabled={memberCount > 1}
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
Delete
|
||||
@@ -253,10 +244,18 @@ export default function OrgOverviewPage() {
|
||||
data. This action <strong>cannot be undone</strong>.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-3 text-sm text-destructive">
|
||||
<AlertTriangle className="w-4 h-4 inline mr-2" />
|
||||
You are about to delete <strong>{org?.name}</strong>. All settings,
|
||||
policies, OIDC clients, and CA configurations will be lost.
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-3 text-sm text-destructive space-y-1">
|
||||
<p className="flex items-center gap-1 font-medium">
|
||||
<AlertTriangle className="w-4 h-4 inline mr-1" />
|
||||
You are about to delete <strong>{org?.name}</strong>.
|
||||
</p>
|
||||
<p>All settings, policies, OIDC clients, and CA configurations will be lost.</p>
|
||||
{memberCount > 1 && (
|
||||
<p className="font-medium">
|
||||
This will also remove all {memberCount - 1} other member
|
||||
{memberCount > 2 ? "s" : ""} from the organization.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDeleteDialogOpen(false)} disabled={isDeleting}>
|
||||
|
||||
@@ -271,9 +271,6 @@ export default function PrincipalsPage() {
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="mt-2 text-xs text-muted-foreground">
|
||||
Created {new Date(principal.created_at).toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DropdownMenu>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Mail, Upload, CheckCircle, AlertCircle, Loader2, Bell, AlertTriangle, Trash2, Building2 } from "lucide-react";
|
||||
import { Mail, Upload, CheckCircle, AlertCircle, Loader2, Bell, AlertTriangle, Trash2, Building2, TriangleAlert } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
@@ -76,6 +76,7 @@ export default function ProfilePage() {
|
||||
// Delete account dialog state
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [confirmEmail, setConfirmEmail] = useState("");
|
||||
|
||||
// Sync local name state with user data
|
||||
useEffect(() => {
|
||||
@@ -108,14 +109,23 @@ export default function ProfilePage() {
|
||||
await api.users.deleteMe();
|
||||
toast({ title: "Account deleted", description: "Your account has been deleted." });
|
||||
setDeleteDialogOpen(false);
|
||||
setConfirmEmail("");
|
||||
await logout();
|
||||
navigate("/login");
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && err.type === "USER_IS_SOLE_OWNER") {
|
||||
const orgs: string[] = (err.details?.organizations as string[]) ?? [];
|
||||
const details = err.details as {
|
||||
transfer_ownership?: string[];
|
||||
} | undefined;
|
||||
|
||||
const transferOrgs = details?.transfer_ownership ?? [];
|
||||
|
||||
toast({
|
||||
title: "Cannot delete account",
|
||||
description: `You are the sole owner of: ${orgs.join(", ")}. Transfer ownership or delete those organizations first.`,
|
||||
description:
|
||||
transferOrgs.length > 0
|
||||
? `You are the owner of ${transferOrgs.join(", ")} and other members exist. Transfer ownership to another member before deleting your account.`
|
||||
: "You own organizations with other members. Transfer ownership first.",
|
||||
variant: "destructive",
|
||||
});
|
||||
} else {
|
||||
@@ -325,7 +335,8 @@ export default function ProfilePage() {
|
||||
<p className="text-sm font-medium text-destructive">Delete Account</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Permanently deletes your profile and all associated data. If you own
|
||||
any organizations you must transfer ownership or delete them first.
|
||||
organizations with other members, transfer ownership first. Sole-member
|
||||
organizations are deleted automatically.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
@@ -342,7 +353,13 @@ export default function ProfilePage() {
|
||||
</div>
|
||||
|
||||
{/* Delete account confirmation dialog */}
|
||||
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<Dialog
|
||||
open={deleteDialogOpen}
|
||||
onOpenChange={(open) => {
|
||||
setDeleteDialogOpen(open);
|
||||
if (!open) setConfirmEmail("");
|
||||
}}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2 text-destructive">
|
||||
@@ -354,24 +371,76 @@ export default function ProfilePage() {
|
||||
permanently deleted. This action <strong>cannot be undone</strong>.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="rounded-lg border border-amber-300 bg-amber-50 dark:border-amber-700 dark:bg-amber-950/40 p-3 text-sm text-amber-800 dark:text-amber-300 space-y-1">
|
||||
|
||||
{/* Org ownership warning */}
|
||||
<div className="rounded-lg border border-amber-300 bg-amber-50 dark:border-amber-700 dark:bg-amber-950/40 p-3 text-sm text-amber-800 dark:text-amber-300 space-y-2">
|
||||
<p className="flex items-center gap-2 font-medium">
|
||||
<Building2 className="w-4 h-4" />
|
||||
Organization ownership check
|
||||
</p>
|
||||
<p>
|
||||
If you are the sole owner of any organization that has other members,
|
||||
you must <strong>transfer ownership</strong> to another member or delete
|
||||
those organizations before proceeding.
|
||||
If you own organizations with other members, you must{" "}
|
||||
<strong>transfer ownership</strong> to another member first.
|
||||
</p>
|
||||
<p>
|
||||
Organizations where you are the <strong>sole member</strong> will
|
||||
be automatically deleted along with your account.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* What will be deleted */}
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-3 text-sm text-destructive space-y-1">
|
||||
<p className="font-medium flex items-center gap-2">
|
||||
<TriangleAlert className="w-4 h-4" />
|
||||
The following will be permanently deleted:
|
||||
</p>
|
||||
<ul className="list-disc list-inside space-y-0.5 text-destructive/80 pl-1">
|
||||
<li>Your profile and account data</li>
|
||||
<li>All SSH keys and active certificates</li>
|
||||
<li>All linked accounts (Google, GitHub, etc.)</li>
|
||||
<li>All active sessions</li>
|
||||
<li>All passkeys and MFA methods</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Email confirmation input */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="confirm-email" className="text-sm">
|
||||
Type your email address{" "}
|
||||
<span className="font-mono font-semibold text-foreground">
|
||||
{user.email}
|
||||
</span>{" "}
|
||||
to confirm:
|
||||
</Label>
|
||||
<Input
|
||||
id="confirm-email"
|
||||
type="email"
|
||||
placeholder={user.email}
|
||||
value={confirmEmail}
|
||||
onChange={(e) => setConfirmEmail(e.target.value)}
|
||||
disabled={isDeleting}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDeleteDialogOpen(false)} disabled={isDeleting}>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setDeleteDialogOpen(false);
|
||||
setConfirmEmail("");
|
||||
}}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={handleDeleteAccount} disabled={isDeleting}>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleDeleteAccount}
|
||||
disabled={isDeleting || confirmEmail.trim().toLowerCase() !== user.email.toLowerCase()}
|
||||
>
|
||||
{isDeleting && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
|
||||
Yes, delete my account
|
||||
Yes, permanently delete my account
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
Reference in New Issue
Block a user