Skip to content

Commit f3cd757

Browse files
rafavallsclaude
andauthored
feat(settings): add delete organization in danger zone (#3246)
* feat(settings): add delete organization option in danger zone Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(organization): soft-delete orgs and block archived access everywhere Replaces hard delete with an archive flag in org metadata. Archived orgs are filtered from ORGANIZATION_LIST, blocked at the auth/routing layer in context-factory and the UI router, and the onboarding redirect skips them. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(organization): friendly screen for archived orgs and post-archive redirect - Add ArchivedOrgScreen so any URL under an archived org shows "Organization unavailable" instead of a broken shell - shell-layout: detect archived orgs, render the screen, and clear the cached slug so /home doesn't bounce back - shell-layout: skip persisting lastOrgSlug for archived orgs on setActive - delete-organization-section: route through ORGANIZATION_DELETE MCP tool (the actual archive flow) instead of authClient.organization.delete (which would hard-delete and bypass the soft-delete logic) - delete-organization-section: require typing the org name to confirm; on success, clear lastOrgSlug, drop the activeOrganization cache, and navigate to / so homeRoute redirects to the next active org or onboarding Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(organization): assert archive metadata update instead of hard delete ORGANIZATION_DELETE was changed to soft-delete via metadata.archived, but the test still asserted the old deleteOrganization call path. Update the test to assert updateOrganization is called with the archive metadata payload, and that deleteOrganization is never called. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 2b716fa commit f3cd757

9 files changed

Lines changed: 287 additions & 22 deletions

File tree

apps/mesh/src/core/context-factory.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -529,6 +529,7 @@ async function authenticateRequest(
529529
"organization.id as orgId",
530530
"organization.slug as orgSlug",
531531
"organization.name as orgName",
532+
"organization.metadata as orgMetadata",
532533
])
533534
.where("member.userId", "=", userId);
534535

@@ -545,6 +546,20 @@ async function authenticateRequest(
545546
return base.executeTakeFirst();
546547
});
547548

549+
if (membership?.orgMetadata) {
550+
try {
551+
const meta = JSON.parse(membership.orgMetadata) as Record<
552+
string,
553+
unknown
554+
>;
555+
if (meta.archived === true) {
556+
throw new Error("Organization is archived");
557+
}
558+
} catch (e) {
559+
if ((e as Error).message === "Organization is archived") throw e;
560+
}
561+
}
562+
548563
const role = membership?.role;
549564
const organization = membership
550565
? {
@@ -830,6 +845,7 @@ async function authenticateRequest(
830845
id: string;
831846
slug: string;
832847
name: string;
848+
metadata?: Record<string, unknown> | null;
833849
members?: {
834850
userId: string;
835851
role?: string;
@@ -839,6 +855,10 @@ async function authenticateRequest(
839855
} | null;
840856

841857
if (orgData) {
858+
if (orgData.metadata?.archived === true) {
859+
throw new Error("Organization is archived");
860+
}
861+
842862
organization = {
843863
id: orgData.id,
844864
slug: orgData.slug,

apps/mesh/src/tools/organization/delete.ts

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
/**
22
* ORGANIZATION_DELETE Tool
33
*
4-
* Delete an organization
4+
* Soft-deletes an organization by flagging it as archived in metadata.
5+
* Archived organizations are invisible to all API and UI surfaces.
56
*/
67

78
import { z } from "zod";
@@ -10,7 +11,7 @@ import { requireAuth } from "../../core/mesh-context";
1011

1112
export const ORGANIZATION_DELETE = defineTool({
1213
name: "ORGANIZATION_DELETE",
13-
description: "Delete an organization.",
14+
description: "Archive an organization (soft delete).",
1415
annotations: {
1516
title: "Delete Organization",
1617
readOnlyHint: false,
@@ -28,14 +29,18 @@ export const ORGANIZATION_DELETE = defineTool({
2829
}),
2930

3031
handler: async (input, ctx) => {
31-
// Require authentication
3232
requireAuth(ctx);
33-
34-
// Check authorization
3533
await ctx.access.check();
3634

37-
// Delete organization via Better Auth
38-
await ctx.boundAuth.organization.delete(input.id);
35+
await ctx.boundAuth.organization.update({
36+
organizationId: input.id,
37+
data: {
38+
metadata: {
39+
archived: true,
40+
archivedAt: new Date().toISOString(),
41+
},
42+
},
43+
});
3944

4045
return {
4146
success: true,

apps/mesh/src/tools/organization/list.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -54,16 +54,20 @@ export const ORGANIZATION_LIST = defineTool({
5454
const organizations = await ctx.boundAuth.organization.list(userId);
5555

5656
// Convert dates to ISO strings for JSON Schema compatibility
57+
// Filter out archived organizations
5758
return {
58-
organizations: organizations.map(
59-
(org: (typeof organizations)[number]) => ({
59+
organizations: organizations
60+
.filter(
61+
(org: (typeof organizations)[number]) =>
62+
org.metadata?.archived !== true,
63+
)
64+
.map((org: (typeof organizations)[number]) => ({
6065
...org,
6166
createdAt:
6267
org.createdAt instanceof Date
6368
? org.createdAt.toISOString()
6469
: org.createdAt,
65-
}),
66-
),
70+
})),
6771
};
6872
},
6973
});

apps/mesh/src/tools/organization/organization-tools.test.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -396,7 +396,7 @@ describe("Organization Tools", () => {
396396
});
397397

398398
describe("ORGANIZATION_DELETE", () => {
399-
it("should delete organization", async () => {
399+
it("should soft-delete organization by archiving via metadata", async () => {
400400
const mockAuth = createMockAuth();
401401
const ctx = createMockContext(mockAuth);
402402

@@ -407,10 +407,19 @@ describe("Organization Tools", () => {
407407
ctx,
408408
);
409409

410-
expect(mockAuth.api.deleteOrganization).toHaveBeenCalledWith({
411-
body: { organizationId: "org_123" },
410+
expect(mockAuth.api.updateOrganization).toHaveBeenCalledWith({
411+
body: {
412+
organizationId: "org_123",
413+
data: {
414+
metadata: expect.objectContaining({
415+
archived: true,
416+
archivedAt: expect.any(String),
417+
}),
418+
},
419+
},
412420
headers: expect.any(Headers),
413421
});
422+
expect(mockAuth.api.deleteOrganization).not.toHaveBeenCalled();
414423

415424
expect(result.success).toBe(true);
416425
expect(result.id).toBe("org_123");
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { Button } from "@deco/ui/components/button.tsx";
2+
import { Archive } from "@untitledui/icons";
3+
4+
export interface ArchivedOrgScreenProps {
5+
orgName?: string;
6+
}
7+
8+
export function ArchivedOrgScreen({ orgName }: ArchivedOrgScreenProps) {
9+
const handleGoHome = () => {
10+
window.location.href = "/";
11+
};
12+
13+
return (
14+
<div className="flex items-center justify-center min-h-screen bg-background">
15+
<div className="flex flex-col items-center text-center space-y-4 max-w-sm px-6">
16+
<div className="bg-muted p-3 rounded-full">
17+
<Archive className="h-6 w-6 text-muted-foreground" />
18+
</div>
19+
<div className="space-y-2">
20+
<h3 className="text-lg font-medium">Organization unavailable</h3>
21+
<p className="text-sm text-muted-foreground">
22+
{orgName ? (
23+
<>
24+
<strong>{orgName}</strong> has been deleted or is no longer
25+
available.
26+
</>
27+
) : (
28+
"This organization has been deleted or is no longer available."
29+
)}
30+
</p>
31+
</div>
32+
<Button onClick={handleGoHome}>Go to home</Button>
33+
</div>
34+
</div>
35+
);
36+
}
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
import { LOCALSTORAGE_KEYS } from "@/web/lib/localstorage-keys";
2+
import { KEYS } from "@/web/lib/query-keys";
3+
import { track } from "@/web/lib/posthog-client";
4+
import {
5+
SELF_MCP_ALIAS_ID,
6+
useMCPClient,
7+
useProjectContext,
8+
} from "@decocms/mesh-sdk";
9+
import {
10+
AlertDialog,
11+
AlertDialogAction,
12+
AlertDialogCancel,
13+
AlertDialogContent,
14+
AlertDialogDescription,
15+
AlertDialogFooter,
16+
AlertDialogHeader,
17+
AlertDialogTitle,
18+
} from "@deco/ui/components/alert-dialog.tsx";
19+
import { Button } from "@deco/ui/components/button.tsx";
20+
import { Input } from "@deco/ui/components/input.tsx";
21+
import { useMutation, useQueryClient } from "@tanstack/react-query";
22+
import { useNavigate } from "@tanstack/react-router";
23+
import {
24+
SettingsCard,
25+
SettingsCardItem,
26+
SettingsSection,
27+
} from "@/web/components/settings/settings-section";
28+
import { useState } from "react";
29+
import { toast } from "sonner";
30+
31+
export function DeleteOrganizationSection() {
32+
const { org } = useProjectContext();
33+
const navigate = useNavigate();
34+
const queryClient = useQueryClient();
35+
const [confirmOpen, setConfirmOpen] = useState(false);
36+
const [confirmName, setConfirmName] = useState("");
37+
38+
const selfClient = useMCPClient({
39+
connectionId: SELF_MCP_ALIAS_ID,
40+
orgId: org.id,
41+
});
42+
43+
const deleteMutation = useMutation({
44+
mutationFn: async () => {
45+
const result = await selfClient.callTool({
46+
name: "ORGANIZATION_DELETE",
47+
arguments: { id: org.id },
48+
});
49+
if (result.isError) {
50+
const content = result.content;
51+
const text =
52+
Array.isArray(content) &&
53+
content[0]?.type === "text" &&
54+
typeof content[0].text === "string"
55+
? content[0].text
56+
: "Failed to delete organization";
57+
throw new Error(text);
58+
}
59+
},
60+
onSuccess: () => {
61+
track("organization_deleted", { organization_id: org.id });
62+
63+
// Drop the cached slug so homeRoute doesn't try to redirect us back here
64+
if (localStorage.getItem(LOCALSTORAGE_KEYS.lastOrgSlug()) === org.slug) {
65+
localStorage.removeItem(LOCALSTORAGE_KEYS.lastOrgSlug());
66+
}
67+
68+
// Drop active-org caches that might still hold the archived org
69+
queryClient.removeQueries({
70+
queryKey: KEYS.activeOrganization(org.slug),
71+
});
72+
queryClient.invalidateQueries({ queryKey: KEYS.organizations() });
73+
74+
toast.success("Organization deleted");
75+
// homeRoute redirects to next available org or onboarding
76+
navigate({ to: "/" });
77+
},
78+
onError: (error) => {
79+
toast.error(
80+
error instanceof Error
81+
? error.message
82+
: "Failed to delete organization",
83+
);
84+
},
85+
});
86+
87+
return (
88+
<>
89+
<SettingsSection
90+
title="Danger Zone"
91+
description="Irreversible actions that affect your entire organization."
92+
>
93+
<SettingsCard className="border-destructive/40">
94+
<SettingsCardItem
95+
title="Delete organization"
96+
description="Permanently delete this organization and all of its data. This action cannot be undone."
97+
action={
98+
<Button
99+
variant="destructive"
100+
size="sm"
101+
onClick={() => setConfirmOpen(true)}
102+
disabled={deleteMutation.isPending}
103+
>
104+
Delete
105+
</Button>
106+
}
107+
/>
108+
</SettingsCard>
109+
</SettingsSection>
110+
111+
<AlertDialog
112+
open={confirmOpen}
113+
onOpenChange={(open) => {
114+
setConfirmOpen(open);
115+
if (!open) setConfirmName("");
116+
}}
117+
>
118+
<AlertDialogContent>
119+
<AlertDialogHeader>
120+
<AlertDialogTitle>Delete Organization?</AlertDialogTitle>
121+
<AlertDialogDescription asChild>
122+
<div>
123+
<p>
124+
This will permanently delete all data associated with{" "}
125+
<span className="font-medium text-foreground">
126+
{org.name}
127+
</span>
128+
. This action cannot be undone.
129+
</p>
130+
<p className="mt-3 mb-1.5">
131+
Type{" "}
132+
<span className="font-medium text-foreground">
133+
{org.name}
134+
</span>{" "}
135+
to confirm:
136+
</p>
137+
<Input
138+
value={confirmName}
139+
onChange={(e) => setConfirmName(e.target.value)}
140+
placeholder={org.name}
141+
autoFocus
142+
/>
143+
</div>
144+
</AlertDialogDescription>
145+
</AlertDialogHeader>
146+
<AlertDialogFooter>
147+
<AlertDialogCancel>Cancel</AlertDialogCancel>
148+
<AlertDialogAction
149+
onClick={() => deleteMutation.mutate()}
150+
disabled={confirmName !== org.name || deleteMutation.isPending}
151+
className="bg-destructive text-destructive-foreground hover:bg-destructive/90 disabled:opacity-50"
152+
>
153+
{deleteMutation.isPending ? "Deleting…" : "Delete organization"}
154+
</AlertDialogAction>
155+
</AlertDialogFooter>
156+
</AlertDialogContent>
157+
</AlertDialog>
158+
</>
159+
);
160+
}

apps/mesh/src/web/index.tsx

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -120,13 +120,19 @@ const homeRoute = createRoute({
120120
// valid cached slug due to a transient API failure.
121121
if (!orgs) return;
122122

123+
// Filter out archived organizations — they are soft-deleted and invisible to the UI
124+
type OrgWithMeta = (typeof orgs)[number] & {
125+
metadata?: { archived?: boolean } | null;
126+
};
127+
const activeOrgs = (orgs as OrgWithMeta[]).filter(
128+
(o) => !o.metadata?.archived,
129+
);
130+
123131
// Fast path: validate cached slug against current membership before redirecting.
124-
// If stale (org deleted or user removed), clear it to prevent a redirect loop.
132+
// If stale (org deleted/archived or user removed), clear it to prevent a redirect loop.
125133
const lastOrgSlug = localStorage.getItem(LOCALSTORAGE_KEYS.lastOrgSlug());
126134
if (lastOrgSlug) {
127-
const slugIsValid = orgs.some(
128-
(o: NonNullable<typeof orgs>[number]) => o.slug === lastOrgSlug,
129-
);
135+
const slugIsValid = activeOrgs.some((o) => o.slug === lastOrgSlug);
130136
if (slugIsValid) {
131137
throw redirect({
132138
to: "/$org",
@@ -138,7 +144,7 @@ const homeRoute = createRoute({
138144
}
139145

140146
// Redirect to first available org (every user gets a default org on signup)
141-
const firstOrg = orgs[0];
147+
const firstOrg = activeOrgs[0];
142148
if (firstOrg) {
143149
throw redirect({
144150
to: "/$org",
@@ -157,7 +163,13 @@ const onboardingRoute = createRoute({
157163
path: "/onboarding",
158164
beforeLoad: async () => {
159165
const { data: orgs } = await authClient.organization.list();
160-
if (orgs && orgs.length > 0) {
166+
type OrgWithMeta = NonNullable<typeof orgs>[number] & {
167+
metadata?: { archived?: boolean } | null;
168+
};
169+
const activeOrgs = (orgs as OrgWithMeta[] | undefined)?.filter(
170+
(o) => !o.metadata?.archived,
171+
);
172+
if (activeOrgs && activeOrgs.length > 0) {
161173
throw redirect({ to: "/" });
162174
}
163175
},

0 commit comments

Comments
 (0)