Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions apps/mesh/src/core/context-factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -513,11 +513,26 @@ async function authenticateRequest(
"organization.id as orgId",
"organization.slug as orgSlug",
"organization.name as orgName",
"organization.metadata as orgMetadata",
])
.where("member.userId", "=", userId)
.executeTakeFirst(),
);

if (membership?.orgMetadata) {
try {
const meta = JSON.parse(membership.orgMetadata) as Record<
string,
unknown
>;
if (meta.archived === true) {
throw new Error("Organization is archived");
}
} catch (e) {
if ((e as Error).message === "Organization is archived") throw e;
}
}

const role = membership?.role;
const organization = membership
? {
Expand Down Expand Up @@ -738,6 +753,7 @@ async function authenticateRequest(
id: string;
slug: string;
name: string;
metadata?: Record<string, unknown> | null;
members?: {
userId: string;
role?: string;
Expand All @@ -747,6 +763,10 @@ async function authenticateRequest(
} | null;

if (orgData) {
if (orgData.metadata?.archived === true) {
throw new Error("Organization is archived");
}

organization = {
id: orgData.id,
slug: orgData.slug,
Expand Down
19 changes: 12 additions & 7 deletions apps/mesh/src/tools/organization/delete.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
/**
* ORGANIZATION_DELETE Tool
*
* Delete an organization
* Soft-deletes an organization by flagging it as archived in metadata.
* Archived organizations are invisible to all API and UI surfaces.
*/

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

export const ORGANIZATION_DELETE = defineTool({
name: "ORGANIZATION_DELETE",
description: "Delete an organization.",
description: "Archive an organization (soft delete).",
annotations: {
title: "Delete Organization",
readOnlyHint: false,
Expand All @@ -28,14 +29,18 @@ export const ORGANIZATION_DELETE = defineTool({
}),

handler: async (input, ctx) => {
// Require authentication
requireAuth(ctx);

// Check authorization
await ctx.access.check();

// Delete organization via Better Auth
await ctx.boundAuth.organization.delete(input.id);
await ctx.boundAuth.organization.update({
organizationId: input.id,
data: {
metadata: {
archived: true,
archivedAt: new Date().toISOString(),
},
},
});

return {
success: true,
Expand Down
12 changes: 8 additions & 4 deletions apps/mesh/src/tools/organization/list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,16 +54,20 @@ export const ORGANIZATION_LIST = defineTool({
const organizations = await ctx.boundAuth.organization.list(userId);

// Convert dates to ISO strings for JSON Schema compatibility
// Filter out archived organizations
return {
organizations: organizations.map(
(org: (typeof organizations)[number]) => ({
organizations: organizations
.filter(
(org: (typeof organizations)[number]) =>
org.metadata?.archived !== true,
)
.map((org: (typeof organizations)[number]) => ({
...org,
createdAt:
org.createdAt instanceof Date
? org.createdAt.toISOString()
: org.createdAt,
}),
),
})),
};
},
});
15 changes: 12 additions & 3 deletions apps/mesh/src/tools/organization/organization-tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -375,7 +375,7 @@ describe("Organization Tools", () => {
});

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

Expand All @@ -386,10 +386,19 @@ describe("Organization Tools", () => {
ctx,
);

expect(mockAuth.api.deleteOrganization).toHaveBeenCalledWith({
body: { organizationId: "org_123" },
expect(mockAuth.api.updateOrganization).toHaveBeenCalledWith({
body: {
organizationId: "org_123",
data: {
metadata: expect.objectContaining({
archived: true,
archivedAt: expect.any(String),
}),
},
},
headers: expect.any(Headers),
});
expect(mockAuth.api.deleteOrganization).not.toHaveBeenCalled();

expect(result.success).toBe(true);
expect(result.id).toBe("org_123");
Expand Down
36 changes: 36 additions & 0 deletions apps/mesh/src/web/components/archived-org-screen.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { Button } from "@deco/ui/components/button.tsx";
import { Archive } from "@untitledui/icons";

export interface ArchivedOrgScreenProps {
orgName?: string;
}

export function ArchivedOrgScreen({ orgName }: ArchivedOrgScreenProps) {
const handleGoHome = () => {
window.location.href = "/";
};

return (
<div className="flex items-center justify-center min-h-screen bg-background">
<div className="flex flex-col items-center text-center space-y-4 max-w-sm px-6">
<div className="bg-muted p-3 rounded-full">
<Archive className="h-6 w-6 text-muted-foreground" />
</div>
<div className="space-y-2">
<h3 className="text-lg font-medium">Organization unavailable</h3>
<p className="text-sm text-muted-foreground">
{orgName ? (
<>
<strong>{orgName}</strong> has been deleted or is no longer
available.
</>
) : (
"This organization has been deleted or is no longer available."
)}
</p>
</div>
<Button onClick={handleGoHome}>Go to home</Button>
</div>
</div>
);
}
160 changes: 160 additions & 0 deletions apps/mesh/src/web/components/settings/delete-organization-section.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
import { LOCALSTORAGE_KEYS } from "@/web/lib/localstorage-keys";
import { KEYS } from "@/web/lib/query-keys";
import { track } from "@/web/lib/posthog-client";
import {
SELF_MCP_ALIAS_ID,
useMCPClient,
useProjectContext,
} from "@decocms/mesh-sdk";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@deco/ui/components/alert-dialog.tsx";
import { Button } from "@deco/ui/components/button.tsx";
import { Input } from "@deco/ui/components/input.tsx";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useNavigate } from "@tanstack/react-router";
import {
SettingsCard,
SettingsCardItem,
SettingsSection,
} from "@/web/components/settings/settings-section";
import { useState } from "react";
import { toast } from "sonner";

export function DeleteOrganizationSection() {
const { org } = useProjectContext();
const navigate = useNavigate();
const queryClient = useQueryClient();
const [confirmOpen, setConfirmOpen] = useState(false);
const [confirmName, setConfirmName] = useState("");

const selfClient = useMCPClient({
connectionId: SELF_MCP_ALIAS_ID,
orgId: org.id,
});

const deleteMutation = useMutation({
mutationFn: async () => {
const result = await selfClient.callTool({
name: "ORGANIZATION_DELETE",
arguments: { id: org.id },
});
if (result.isError) {
const content = result.content;
const text =
Array.isArray(content) &&
content[0]?.type === "text" &&
typeof content[0].text === "string"
? content[0].text
: "Failed to delete organization";
throw new Error(text);
}
},
onSuccess: () => {
track("organization_deleted", { organization_id: org.id });

// Drop the cached slug so homeRoute doesn't try to redirect us back here
if (localStorage.getItem(LOCALSTORAGE_KEYS.lastOrgSlug()) === org.slug) {
localStorage.removeItem(LOCALSTORAGE_KEYS.lastOrgSlug());
}

// Drop active-org caches that might still hold the archived org
queryClient.removeQueries({
queryKey: KEYS.activeOrganization(org.slug),
});
queryClient.invalidateQueries({ queryKey: KEYS.organizations() });

toast.success("Organization deleted");
// homeRoute redirects to next available org or onboarding
navigate({ to: "/" });
},
onError: (error) => {
toast.error(
error instanceof Error
? error.message
: "Failed to delete organization",
);
},
});

return (
<>
<SettingsSection
title="Danger Zone"
description="Irreversible actions that affect your entire organization."
>
<SettingsCard className="border-destructive/40">
<SettingsCardItem
title="Delete organization"
description="Permanently delete this organization and all of its data. This action cannot be undone."
action={
<Button
variant="destructive"
size="sm"
onClick={() => setConfirmOpen(true)}
disabled={deleteMutation.isPending}
>
Delete
</Button>
}
/>
</SettingsCard>
</SettingsSection>

<AlertDialog
open={confirmOpen}
onOpenChange={(open) => {
setConfirmOpen(open);
if (!open) setConfirmName("");
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Organization?</AlertDialogTitle>
<AlertDialogDescription asChild>
<div>
<p>
This will permanently delete all data associated with{" "}
<span className="font-medium text-foreground">
{org.name}
</span>
. This action cannot be undone.
</p>
<p className="mt-3 mb-1.5">
Type{" "}
<span className="font-medium text-foreground">
{org.name}
</span>{" "}
to confirm:
</p>
<Input
value={confirmName}
onChange={(e) => setConfirmName(e.target.value)}
placeholder={org.name}
autoFocus
/>
</div>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => deleteMutation.mutate()}
disabled={confirmName !== org.name || deleteMutation.isPending}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90 disabled:opacity-50"
>
{deleteMutation.isPending ? "Deleting…" : "Delete organization"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}
24 changes: 18 additions & 6 deletions apps/mesh/src/web/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -120,13 +120,19 @@ const homeRoute = createRoute({
// valid cached slug due to a transient API failure.
if (!orgs) return;

// Filter out archived organizations — they are soft-deleted and invisible to the UI
type OrgWithMeta = (typeof orgs)[number] & {
metadata?: { archived?: boolean } | null;
};
const activeOrgs = (orgs as OrgWithMeta[]).filter(
(o) => !o.metadata?.archived,
);

// Fast path: validate cached slug against current membership before redirecting.
// If stale (org deleted or user removed), clear it to prevent a redirect loop.
// If stale (org deleted/archived or user removed), clear it to prevent a redirect loop.
const lastOrgSlug = localStorage.getItem(LOCALSTORAGE_KEYS.lastOrgSlug());
if (lastOrgSlug) {
const slugIsValid = orgs.some(
(o: NonNullable<typeof orgs>[number]) => o.slug === lastOrgSlug,
);
const slugIsValid = activeOrgs.some((o) => o.slug === lastOrgSlug);
if (slugIsValid) {
throw redirect({
to: "/$org",
Expand All @@ -138,7 +144,7 @@ const homeRoute = createRoute({
}

// Redirect to first available org (every user gets a default org on signup)
const firstOrg = orgs[0];
const firstOrg = activeOrgs[0];
if (firstOrg) {
throw redirect({
to: "/$org",
Expand All @@ -157,7 +163,13 @@ const onboardingRoute = createRoute({
path: "/onboarding",
beforeLoad: async () => {
const { data: orgs } = await authClient.organization.list();
if (orgs && orgs.length > 0) {
type OrgWithMeta = NonNullable<typeof orgs>[number] & {
metadata?: { archived?: boolean } | null;
};
const activeOrgs = (orgs as OrgWithMeta[] | undefined)?.filter(
(o) => !o.metadata?.archived,
);
if (activeOrgs && activeOrgs.length > 0) {
throw redirect({ to: "/" });
}
},
Expand Down
Loading
Loading