Skip to content

Commit db652c2

Browse files
feat: support deleting projects (#1107)
![CleanShot 2025-12-22 at 15 16 10](https://github.com/user-attachments/assets/75a4b918-3da2-4c72-916c-d0cd16c8ec8d) --------- Co-authored-by: Georges Haidar <[email protected]>
1 parent 5e93bb2 commit db652c2

File tree

59 files changed

+16928
-14564
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

59 files changed

+16928
-14564
lines changed

.speakeasy/out.openapi.yaml

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6492,6 +6492,101 @@ paths:
64926492
x-speakeasy-name-override: create
64936493
x-speakeasy-react-hook:
64946494
name: CreateProject
6495+
/rpc/projects.delete:
6496+
delete:
6497+
description: Delete a project by its ID
6498+
operationId: deleteProject
6499+
parameters:
6500+
- allowEmptyValue: true
6501+
description: The id of the project to delete
6502+
in: query
6503+
name: id
6504+
required: true
6505+
schema:
6506+
description: The id of the project to delete
6507+
format: uuid
6508+
type: string
6509+
- allowEmptyValue: true
6510+
description: API Key header
6511+
in: header
6512+
name: Gram-Key
6513+
schema:
6514+
description: API Key header
6515+
type: string
6516+
- allowEmptyValue: true
6517+
description: Session header
6518+
in: header
6519+
name: Gram-Session
6520+
schema:
6521+
description: Session header
6522+
type: string
6523+
responses:
6524+
"204":
6525+
description: No Content response.
6526+
"400":
6527+
content:
6528+
application/json:
6529+
schema:
6530+
$ref: '#/components/schemas/Error'
6531+
description: 'bad_request: request is invalid'
6532+
"401":
6533+
content:
6534+
application/json:
6535+
schema:
6536+
$ref: '#/components/schemas/Error'
6537+
description: 'unauthorized: unauthorized access'
6538+
"403":
6539+
content:
6540+
application/json:
6541+
schema:
6542+
$ref: '#/components/schemas/Error'
6543+
description: 'forbidden: permission denied'
6544+
"404":
6545+
content:
6546+
application/json:
6547+
schema:
6548+
$ref: '#/components/schemas/Error'
6549+
description: 'not_found: resource not found'
6550+
"409":
6551+
content:
6552+
application/json:
6553+
schema:
6554+
$ref: '#/components/schemas/Error'
6555+
description: 'conflict: resource already exists'
6556+
"415":
6557+
content:
6558+
application/json:
6559+
schema:
6560+
$ref: '#/components/schemas/Error'
6561+
description: 'unsupported_media: unsupported media type'
6562+
"422":
6563+
content:
6564+
application/json:
6565+
schema:
6566+
$ref: '#/components/schemas/Error'
6567+
description: 'invalid: request contains one or more invalidation fields'
6568+
"500":
6569+
content:
6570+
application/json:
6571+
schema:
6572+
$ref: '#/components/schemas/Error'
6573+
description: 'unexpected: an unexpected error occurred'
6574+
"502":
6575+
content:
6576+
application/json:
6577+
schema:
6578+
$ref: '#/components/schemas/Error'
6579+
description: 'gateway_error: an unexpected error occurred'
6580+
security:
6581+
- apikey_header_Gram-Key: []
6582+
- session_header_Gram-Session: []
6583+
- {}
6584+
summary: deleteProject projects
6585+
tags:
6586+
- projects
6587+
x-speakeasy-name-override: deleteById
6588+
x-speakeasy-react-hook:
6589+
name: DeleteProject
64956590
/rpc/projects.list:
64966591
get:
64976592
description: List all projects for an organization.

client/dashboard/src/pages/settings/Settings.tsx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import { useQueryClient } from "@tanstack/react-query";
2626
import { Check, CheckCircle2, Copy, Globe, Loader2, X } from "lucide-react";
2727
import { useEffect, useState } from "react";
2828
import { useCustomDomain } from "../mcp/MCPDetails";
29+
import { SettingsProjectsTable } from "./SettingsProjectsTable";
2930

3031
export default function Settings() {
3132
const organization = useOrganization();
@@ -254,7 +255,14 @@ export default function Settings() {
254255
<Page.Header.Breadcrumbs />
255256
</Page.Header>
256257
<Page.Body>
257-
<Stack direction="horizontal" justify="space-between" align="center">
258+
<SettingsProjectsTable />
259+
260+
<Stack
261+
direction="horizontal"
262+
justify="space-between"
263+
align="center"
264+
className="mt-8"
265+
>
258266
<Heading variant="h4">API Keys</Heading>
259267
<Button onClick={() => setIsCreateDialogOpen(true)}>
260268
New API Key
Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
1+
import { Button, Column, Icon, Stack, Table } from "@speakeasy-api/moonshine";
2+
import { Dialog } from "@/components/ui/dialog";
3+
import { Heading } from "@/components/ui/heading";
4+
import { Input } from "@/components/ui/input";
5+
import { Label } from "@/components/ui/label";
6+
import { SimpleTooltip } from "@/components/ui/tooltip";
7+
import { Type } from "@/components/ui/type";
8+
import { useOrganization, useProject } from "@/contexts/Auth";
9+
import { ProjectEntry } from "@gram/client/models/components";
10+
import { useDeleteProjectMutation } from "@gram/client/react-query/deleteProject";
11+
import {
12+
invalidateListProjects,
13+
useListProjectsSuspense,
14+
} from "@gram/client/react-query/listProjects";
15+
import { useQueryClient } from "@tanstack/react-query";
16+
import { useRef, useState } from "react";
17+
import { useNavigate } from "react-router";
18+
import { toast } from "sonner";
19+
20+
export function SettingsProjectsTable() {
21+
const organization = useOrganization();
22+
const currentProject = useProject();
23+
const navigate = useNavigate();
24+
const queryClient = useQueryClient();
25+
26+
const [projectToDelete, setProjectToDelete] = useState<ProjectEntry | null>(
27+
null,
28+
);
29+
const [confirmationInput, setConfirmationInput] = useState("");
30+
const isDeletingCurrentProject = useRef(false);
31+
32+
const { data: projectsData } = useListProjectsSuspense({
33+
organizationId: organization.id,
34+
});
35+
36+
const deleteProjectMutation = useDeleteProjectMutation({
37+
onSuccess: async () => {
38+
const shouldNavigate = isDeletingCurrentProject.current;
39+
40+
setProjectToDelete(null);
41+
setConfirmationInput("");
42+
isDeletingCurrentProject.current = false;
43+
44+
await invalidateListProjects(queryClient, [
45+
{
46+
organizationId: organization.id,
47+
},
48+
]);
49+
50+
toast.success("Project deleted successfully");
51+
52+
if (shouldNavigate) {
53+
// Navigate to the default project after deleting the current project
54+
navigate(`/${organization.slug}/default/settings`);
55+
}
56+
},
57+
onError: (error) => {
58+
console.error("Failed to delete project:", error);
59+
toast.error("Failed to delete project");
60+
isDeletingCurrentProject.current = false;
61+
},
62+
});
63+
64+
const handleCloseDeleteProjectDialog = () => {
65+
setProjectToDelete(null);
66+
setConfirmationInput("");
67+
};
68+
69+
const handleDeleteProject = () => {
70+
if (!projectToDelete) return;
71+
72+
// Track if we're deleting the current project for post-deletion navigation
73+
isDeletingCurrentProject.current =
74+
projectToDelete.slug === currentProject.slug;
75+
76+
deleteProjectMutation.mutate({
77+
request: {
78+
id: projectToDelete.id,
79+
},
80+
});
81+
};
82+
83+
const defaultProject = projectsData.projects.find(
84+
(p) => p.slug === "default",
85+
);
86+
87+
const projectColumns: Column<ProjectEntry>[] = [
88+
{
89+
key: "name",
90+
header: "Name",
91+
width: "1fr",
92+
render: (project: ProjectEntry) => (
93+
<Type variant="body">{project.name}</Type>
94+
),
95+
},
96+
{
97+
key: "slug",
98+
header: "Slug",
99+
width: "1fr",
100+
render: (project: ProjectEntry) => (
101+
<Type variant="body">{project.slug}</Type>
102+
),
103+
},
104+
{
105+
key: "actions",
106+
header: "",
107+
width: "80px",
108+
render: (project: ProjectEntry) => {
109+
const isDefault = project.slug === defaultProject?.slug;
110+
111+
const DeleteButton = () => (
112+
<Button
113+
variant="tertiary"
114+
size="sm"
115+
onClick={() => setProjectToDelete(project)}
116+
disabled={isDefault}
117+
className={isDefault ? "" : "hover:text-destructive"}
118+
>
119+
<Button.Text>
120+
<Icon name="trash-2" className="h-4 w-4" />
121+
</Button.Text>
122+
</Button>
123+
);
124+
125+
if (isDefault) {
126+
return (
127+
<SimpleTooltip tooltip="The default project cannot be deleted">
128+
<DeleteButton />
129+
</SimpleTooltip>
130+
);
131+
}
132+
133+
return <DeleteButton />;
134+
},
135+
},
136+
];
137+
138+
return (
139+
<>
140+
<Stack direction="horizontal" justify="space-between" align="center">
141+
<Heading variant="h4">Projects</Heading>
142+
</Stack>
143+
<Table
144+
columns={projectColumns}
145+
data={projectsData?.projects ?? []}
146+
rowKey={(row) => row.id}
147+
className="min-h-fit max-h-[500px] overflow-y-auto"
148+
noResultsMessage={
149+
<Stack
150+
gap={2}
151+
className="h-full p-4 bg-background"
152+
align="center"
153+
justify="center"
154+
>
155+
<Type variant="body">No projects yet</Type>
156+
</Stack>
157+
}
158+
/>
159+
160+
<Dialog
161+
open={!!projectToDelete}
162+
onOpenChange={(open) => !open && handleCloseDeleteProjectDialog()}
163+
>
164+
<Dialog.Content>
165+
<Dialog.Header>
166+
<Dialog.Title>Delete Project</Dialog.Title>
167+
</Dialog.Header>
168+
<div className="space-y-4 py-4">
169+
<Type variant="body">
170+
Are you sure you want to delete the project{" "}
171+
<code className="font-mono font-bold px-1 py-0.5 bg-muted rounded">
172+
{projectToDelete?.name}
173+
</code>
174+
? This action cannot be undone.
175+
</Type>
176+
177+
<div className="space-y-2">
178+
<Label htmlFor="confirm-project-name">
179+
Type the project name to confirm:
180+
</Label>
181+
<Input
182+
id="confirm-project-name"
183+
value={confirmationInput}
184+
onChange={setConfirmationInput}
185+
placeholder={projectToDelete?.name}
186+
autoComplete="off"
187+
autoFocus
188+
/>
189+
</div>
190+
191+
<div className="flex justify-end space-x-2">
192+
<Button
193+
variant="secondary"
194+
onClick={handleCloseDeleteProjectDialog}
195+
disabled={deleteProjectMutation.isPending}
196+
>
197+
Cancel
198+
</Button>
199+
<Button
200+
variant="destructive-primary"
201+
onClick={handleDeleteProject}
202+
disabled={
203+
confirmationInput.trim() !== projectToDelete?.name ||
204+
deleteProjectMutation.isPending
205+
}
206+
>
207+
{deleteProjectMutation.isPending
208+
? "Deleting..."
209+
: "Delete Project"}
210+
</Button>
211+
</div>
212+
</div>
213+
</Dialog.Content>
214+
</Dialog>
215+
</>
216+
);
217+
}

0 commit comments

Comments
 (0)