Fix: Deletion Deadlocks (Owner, User)
This commit is contained in:
+6
-3
@@ -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) =>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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}>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user