Fix: Deletion Deadlocks (Owner, User)

This commit is contained in:
2026-03-03 23:23:18 +05:45
parent 7348ba916d
commit 44afd93c35
5 changed files with 108 additions and 43 deletions
+6 -3
View File
@@ -814,9 +814,12 @@ export const api = {
getById: (orgId: string, requestConfig?: RequestConfig) => getById: (orgId: string, requestConfig?: RequestConfig) =>
request<{ organization: Organization; member_count: number }>(`/organizations/${orgId}`, {}, true, requestConfig), request<{ organization: Organization; member_count: number }>(`/organizations/${orgId}`, {}, true, requestConfig),
// Delete an organization (owner only; must have no other members) // Delete an organization (owner only; pass confirm=true when other members exist)
deleteOrganization: (orgId: string, requestConfig?: RequestConfig) => deleteOrganization: (orgId: string, confirm?: boolean, requestConfig?: RequestConfig) =>
request<{ message: string }>(`/organizations/${orgId}`, { method: 'DELETE' }, true, requestConfig), request<{ message: string }>(`/organizations/${orgId}`, {
method: 'DELETE',
...(confirm ? { body: JSON.stringify({ confirm: true }) } : {}),
}, true, requestConfig),
// Get organization members // Get organization members
getMembers: (orgId: string, requestConfig?: RequestConfig) => getMembers: (orgId: string, requestConfig?: RequestConfig) =>
-3
View File
@@ -658,9 +658,6 @@ export default function DepartmentsPage() {
}) })
)} )}
</div> </div>
<div className="mt-2 text-xs text-muted-foreground">
Created {new Date(dept.created_at).toLocaleDateString()}
</div>
{/* Members toggle */} {/* Members toggle */}
<button <button
+21 -22
View File
@@ -46,7 +46,8 @@ export default function OrgOverviewPage() {
if (!selectedOrg) return; if (!selectedOrg) return;
setIsDeleting(true); setIsDeleting(true);
try { 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.` }); toast({ title: "Organization deleted", description: `"${selectedOrg.name}" has been deleted.` });
setDeleteDialogOpen(false); setDeleteDialogOpen(false);
// Refresh org list; context will auto-select next available org // Refresh org list; context will auto-select next available org
@@ -59,19 +60,11 @@ export default function OrgOverviewPage() {
navigate("/org-setup"); navigate("/org-setup");
} }
} catch (err) { } catch (err) {
if (err instanceof ApiError && err.type === "ORG_HAS_MEMBERS") { toast({
toast({ title: "Deletion failed",
title: "Cannot delete organization", description: err instanceof ApiError ? err.message : "An unexpected error occurred.",
description: "This organization still has other members. Transfer ownership or remove all members first.", variant: "destructive",
variant: "destructive", });
});
} else {
toast({
title: "Deletion failed",
description: err instanceof ApiError ? err.message : "An unexpected error occurred.",
variant: "destructive",
});
}
setDeleteDialogOpen(false); setDeleteDialogOpen(false);
} finally { } finally {
setIsDeleting(false); setIsDeleting(false);
@@ -220,17 +213,15 @@ export default function OrgOverviewPage() {
<div> <div>
<p className="text-sm font-medium text-destructive">Delete Organization</p> <p className="text-sm font-medium text-destructive">Delete Organization</p>
<p className="text-xs text-muted-foreground mt-0.5"> <p className="text-xs text-muted-foreground mt-0.5">
Permanently deletes this organization.{" "}
{memberCount > 1 {memberCount > 1
? `You must remove all ${memberCount - 1} other member${memberCount > 2 ? "s" : ""} first.` ? `Permanently deletes this organization and removes all ${memberCount - 1} other member${memberCount > 2 ? "s" : ""}. This action cannot be undone.`
: "This action cannot be undone."} : "Permanently deletes this organization. This action cannot be undone."}
</p> </p>
</div> </div>
<Button <Button
variant="destructive" variant="destructive"
size="sm" size="sm"
onClick={() => setDeleteDialogOpen(true)} onClick={() => setDeleteDialogOpen(true)}
disabled={memberCount > 1}
> >
<Trash2 className="w-4 h-4 mr-2" /> <Trash2 className="w-4 h-4 mr-2" />
Delete Delete
@@ -253,10 +244,18 @@ export default function OrgOverviewPage() {
data. This action <strong>cannot be undone</strong>. data. This action <strong>cannot be undone</strong>.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-3 text-sm text-destructive"> <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-3 text-sm text-destructive space-y-1">
<AlertTriangle className="w-4 h-4 inline mr-2" /> <p className="flex items-center gap-1 font-medium">
You are about to delete <strong>{org?.name}</strong>. All settings, <AlertTriangle className="w-4 h-4 inline mr-1" />
policies, OIDC clients, and CA configurations will be lost. 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> </div>
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={() => setDeleteDialogOpen(false)} disabled={isDeleting}> <Button variant="outline" onClick={() => setDeleteDialogOpen(false)} disabled={isDeleting}>
-3
View File
@@ -271,9 +271,6 @@ export default function PrincipalsPage() {
})} })}
</div> </div>
<div className="mt-2 text-xs text-muted-foreground">
Created {new Date(principal.created_at).toLocaleDateString()}
</div>
</div> </div>
<DropdownMenu> <DropdownMenu>
+81 -12
View File
@@ -1,5 +1,5 @@
import { useState, useEffect } from "react"; 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 { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
@@ -76,6 +76,7 @@ export default function ProfilePage() {
// Delete account dialog state // Delete account dialog state
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [isDeleting, setIsDeleting] = useState(false); const [isDeleting, setIsDeleting] = useState(false);
const [confirmEmail, setConfirmEmail] = useState("");
// Sync local name state with user data // Sync local name state with user data
useEffect(() => { useEffect(() => {
@@ -108,14 +109,23 @@ export default function ProfilePage() {
await api.users.deleteMe(); await api.users.deleteMe();
toast({ title: "Account deleted", description: "Your account has been deleted." }); toast({ title: "Account deleted", description: "Your account has been deleted." });
setDeleteDialogOpen(false); setDeleteDialogOpen(false);
setConfirmEmail("");
await logout(); await logout();
navigate("/login"); navigate("/login");
} catch (err) { } catch (err) {
if (err instanceof ApiError && err.type === "USER_IS_SOLE_OWNER") { 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({ toast({
title: "Cannot delete account", 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", variant: "destructive",
}); });
} else { } else {
@@ -325,7 +335,8 @@ export default function ProfilePage() {
<p className="text-sm font-medium text-destructive">Delete Account</p> <p className="text-sm font-medium text-destructive">Delete Account</p>
<p className="text-xs text-muted-foreground mt-0.5"> <p className="text-xs text-muted-foreground mt-0.5">
Permanently deletes your profile and all associated data. If you own 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> </p>
</div> </div>
<Button <Button
@@ -342,7 +353,13 @@ export default function ProfilePage() {
</div> </div>
{/* Delete account confirmation dialog */} {/* Delete account confirmation dialog */}
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}> <Dialog
open={deleteDialogOpen}
onOpenChange={(open) => {
setDeleteDialogOpen(open);
if (!open) setConfirmEmail("");
}}
>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle className="flex items-center gap-2 text-destructive"> <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>. permanently deleted. This action <strong>cannot be undone</strong>.
</DialogDescription> </DialogDescription>
</DialogHeader> </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"> <p className="flex items-center gap-2 font-medium">
<Building2 className="w-4 h-4" /> <Building2 className="w-4 h-4" />
Organization ownership check Organization ownership check
</p> </p>
<p> <p>
If you are the sole owner of any organization that has other members, If you own organizations with other members, you must{" "}
you must <strong>transfer ownership</strong> to another member or delete <strong>transfer ownership</strong> to another member first.
those organizations before proceeding. </p>
<p>
Organizations where you are the <strong>sole member</strong> will
be automatically deleted along with your account.
</p> </p>
</div> </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> <DialogFooter>
<Button variant="outline" onClick={() => setDeleteDialogOpen(false)} disabled={isDeleting}> <Button
variant="outline"
onClick={() => {
setDeleteDialogOpen(false);
setConfirmEmail("");
}}
disabled={isDeleting}
>
Cancel Cancel
</Button> </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" />} {isDeleting && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
Yes, delete my account Yes, permanently delete my account
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>