From c223a370c8923fa112ad06a2e8b1e63a597731d0 Mon Sep 17 00:00:00 2001
From: Mohamed Habib
Date: Thu, 3 Sep 2026 20:19:50 -0700
Subject: [PATCH] feat: add project deletion to dashboard
---
.../api-edge/src/managed_agents.test.ts | 53 ++++++++
.../api-edge/src/managed_agents.ts | 16 +++
web/src/components/app-shell-nav.ts | 6 +
web/src/components/app-shell.test.ts | 1 +
web/src/managed-agents/Detail.tsx | 1 +
web/src/managed-agents/Project.tsx | 19 ++-
.../managed-agents/ProjectSettings.test.ts | 16 +++
web/src/managed-agents/ProjectSettings.tsx | 128 ++++++++++++++++++
web/src/managed-agents/Secrets.tsx | 93 ++++++++++---
web/src/managed-agents/api.ts | 6 +
web/src/managed-agents/project-settings.ts | 6 +
11 files changed, 324 insertions(+), 21 deletions(-)
create mode 100644 web/src/managed-agents/ProjectSettings.test.ts
create mode 100644 web/src/managed-agents/ProjectSettings.tsx
create mode 100644 web/src/managed-agents/project-settings.ts
diff --git a/cloudflare-workers/api-edge/src/managed_agents.test.ts b/cloudflare-workers/api-edge/src/managed_agents.test.ts
index d52d6614c..050d54771 100644
--- a/cloudflare-workers/api-edge/src/managed_agents.test.ts
+++ b/cloudflare-workers/api-edge/src/managed_agents.test.ts
@@ -588,6 +588,59 @@ describe("managed agents proxy", () => {
expect(JSON.stringify(body)).not.toContain("private-account");
});
+ it("allows organization admins to delete projects", async () => {
+ const fetchSpy = vi.fn(
+ async (_request: URL | RequestInfo, init?: RequestInit) => {
+ expect(init?.method).toBe("DELETE");
+ return new Response(null, { status: 204 });
+ },
+ );
+ vi.stubGlobal("fetch", fetchSpy);
+
+ const response = await proxyManagedAgents(
+ new Request(
+ "https://app.opencomputer.dev/api/managed-agents/projects/prj_test",
+ { method: "DELETE" },
+ ),
+ {
+ OC_MANAGED_AGENTS_SECRET: "test-secret",
+ MANAGED_AGENTS_API_URL: "https://managedagents.test",
+ },
+ { orgID: "org_test", userID: "user_test", role: "admin" },
+ "/api/managed-agents",
+ );
+
+ expect(response.status).toBe(204);
+ expect(fetchSpy).toHaveBeenCalledOnce();
+ });
+
+ it("does not allow organization members to delete projects", async () => {
+ const fetchSpy = vi.fn();
+ vi.stubGlobal("fetch", fetchSpy);
+
+ const response = await proxyManagedAgents(
+ new Request(
+ "https://app.opencomputer.dev/api/managed-agents/projects/prj_test",
+ { method: "DELETE" },
+ ),
+ {
+ OC_MANAGED_AGENTS_SECRET: "test-secret",
+ MANAGED_AGENTS_API_URL: "https://managedagents.test",
+ },
+ { orgID: "org_test", userID: "user_test", role: "member" },
+ "/api/managed-agents",
+ );
+
+ expect(response.status).toBe(403);
+ await expect(response.json()).resolves.toEqual({
+ error: {
+ code: "forbidden",
+ message: "Organization admin role is required to delete a project.",
+ },
+ });
+ expect(fetchSpy).not.toHaveBeenCalled();
+ });
+
it("exposes public template provenance on a project overview", async () => {
vi.stubGlobal(
"fetch",
diff --git a/cloudflare-workers/api-edge/src/managed_agents.ts b/cloudflare-workers/api-edge/src/managed_agents.ts
index 5d1f868f4..a4852bb60 100644
--- a/cloudflare-workers/api-edge/src/managed_agents.ts
+++ b/cloudflare-workers/api-edge/src/managed_agents.ts
@@ -1123,6 +1123,7 @@ function isAllowedManagedAgentsRoute(method: string, suffix: string): boolean {
return true;
}
if (method === "GET" && /^\/projects\/[^/]+$/.test(suffix)) return true;
+ if (method === "DELETE" && /^\/projects\/[^/]+$/.test(suffix)) return true;
if (method === "GET" && /^\/projects\/[^/]+\/source-archive$/.test(suffix)) {
return true;
}
@@ -1474,6 +1475,21 @@ export async function proxyManagedAgents(
{ status: 503 },
);
}
+ if (
+ request.method.toUpperCase() === "DELETE" &&
+ /^\/projects\/[^/]+$/.test(suffix) &&
+ caller.role !== "admin"
+ ) {
+ return Response.json(
+ {
+ error: {
+ code: "forbidden",
+ message: "Organization admin role is required to delete a project.",
+ },
+ },
+ { status: 403 },
+ );
+ }
if (
request.method.toUpperCase() === "POST" &&
suffix === "/model-access/connections"
diff --git a/web/src/components/app-shell-nav.ts b/web/src/components/app-shell-nav.ts
index 13287faa8..f55e486bf 100644
--- a/web/src/components/app-shell-nav.ts
+++ b/web/src/components/app-shell-nav.ts
@@ -12,6 +12,7 @@ import {
Radio,
Rocket,
Send,
+ Settings2,
Webhook,
type LucideIcon,
} from 'lucide-react'
@@ -92,6 +93,11 @@ export function managedAgentsNav(options: {
label: 'BYOK',
icon: BrainCircuit,
},
+ {
+ to: `${projectPath}/settings`,
+ label: 'Settings',
+ icon: Settings2,
+ },
{
to: projectPath,
label: 'Debug playground',
diff --git a/web/src/components/app-shell.test.ts b/web/src/components/app-shell.test.ts
index e73230086..9dc408c06 100644
--- a/web/src/components/app-shell.test.ts
+++ b/web/src/components/app-shell.test.ts
@@ -27,6 +27,7 @@ describe('managed agents navigation', () => {
'Webhooks',
'Secrets',
'BYOK',
+ 'Settings',
'Debug playground',
])
expect(nav[1]?.items[nav[1].items.length - 1]?.to).toBe(
diff --git a/web/src/managed-agents/Detail.tsx b/web/src/managed-agents/Detail.tsx
index 5d6a39a46..993d1a380 100644
--- a/web/src/managed-agents/Detail.tsx
+++ b/web/src/managed-agents/Detail.tsx
@@ -1126,6 +1126,7 @@ export default function ManagedAgentDetail({
{activeTab === 'secrets' && project ? (
+
+
+
+ )
+ }
+
const agentId = selectedProjectAgentId(
projectAgentId
? `/projects/${encodeURIComponent(projectId)}/playground/${encodeURIComponent(projectAgentId)}`
diff --git a/web/src/managed-agents/ProjectSettings.test.ts b/web/src/managed-agents/ProjectSettings.test.ts
new file mode 100644
index 000000000..6c26d29ea
--- /dev/null
+++ b/web/src/managed-agents/ProjectSettings.test.ts
@@ -0,0 +1,16 @@
+import { describe, expect, it } from 'vitest'
+import { canConfirmProjectDeletion } from './project-settings'
+
+describe('canConfirmProjectDeletion', () => {
+ it('requires the exact project name', () => {
+ expect(canConfirmProjectDeletion('Incident Agent', 'Incident Agent')).toBe(
+ true,
+ )
+ expect(canConfirmProjectDeletion('incident agent', 'Incident Agent')).toBe(
+ false,
+ )
+ expect(canConfirmProjectDeletion('Incident Agent ', 'Incident Agent')).toBe(
+ false,
+ )
+ })
+})
diff --git a/web/src/managed-agents/ProjectSettings.tsx b/web/src/managed-agents/ProjectSettings.tsx
new file mode 100644
index 000000000..02ca42533
--- /dev/null
+++ b/web/src/managed-agents/ProjectSettings.tsx
@@ -0,0 +1,128 @@
+import { useRef, useState } from 'react'
+import { useMutation, useQueryClient } from '@tanstack/react-query'
+import { useNavigate } from 'react-router-dom'
+import { Loader2, Trash2 } from 'lucide-react'
+import { notifyError, notifySuccess } from '@/lib/errors'
+import {
+ Panel,
+ PanelContent,
+ PanelDescription,
+ PanelHeader,
+ PanelTitle,
+} from '@/components/panel'
+import { Button } from '@/components/ui/button'
+import {
+ AlertDialog,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from '@/components/ui/alert-dialog'
+import { Input } from '@/components/ui/input'
+import { Label } from '@/components/ui/label'
+import { deleteManagedProject } from './api'
+import { canConfirmProjectDeletion } from './project-settings'
+
+export function ManagedProjectSettings({
+ projectId,
+ projectName,
+}: {
+ projectId: string
+ projectName: string
+}) {
+ const navigate = useNavigate()
+ const queryClient = useQueryClient()
+ const [dialogOpen, setDialogOpen] = useState(false)
+ const [confirmation, setConfirmation] = useState('')
+ const confirmationInput = useRef(null)
+ const remove = useMutation({
+ mutationFn: () => deleteManagedProject(projectId),
+ onSuccess: () => {
+ queryClient.removeQueries({ queryKey: ['managed-project', projectId] })
+ void queryClient.invalidateQueries({ queryKey: ['managed-projects'] })
+ notifySuccess('Project deleted.')
+ void navigate('/', { replace: true })
+ },
+ onError: (error) => notifyError("Couldn't delete the project.", error),
+ })
+ const confirmed = canConfirmProjectDeletion(confirmation, projectName)
+
+ return (
+ <>
+
+
+
+
Delete project
+
+ Permanently remove this project and its agents, deployments,
+ sessions, credentials, triggers, and environment configuration.
+
+
+
+
+ {
+ setConfirmation('')
+ setDialogOpen(true)
+ requestAnimationFrame(() => confirmationInput.current?.focus())
+ }}
+ >
+ Delete project
+
+
+
+
+ {
+ if (remove.isPending) return
+ setDialogOpen(open)
+ if (!open) setConfirmation('')
+ }}
+ >
+
+
+ Delete {projectName}?
+
+ This cannot be undone. Active sessions will end, project access
+ tokens will stop working, and linked local checkouts will need to
+ link or create a project before deploying again.
+
+
+
+
+ Type {projectName} to confirm
+
+ setConfirmation(event.target.value)}
+ autoComplete="off"
+ />
+
+
+
+ Cancel
+
+ remove.mutate()}
+ >
+ {remove.isPending ? (
+
+ ) : (
+
+ )}
+ Delete project
+
+
+
+
+ >
+ )
+}
diff --git a/web/src/managed-agents/Secrets.tsx b/web/src/managed-agents/Secrets.tsx
index 95e7e9a0c..9ee2267f0 100644
--- a/web/src/managed-agents/Secrets.tsx
+++ b/web/src/managed-agents/Secrets.tsx
@@ -1,6 +1,6 @@
-import { type FormEvent, useState } from 'react'
+import { type FormEvent, useRef, useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
-import { KeyRound, Loader2, Trash2 } from 'lucide-react'
+import { KeyRound, Loader2, Pencil, Trash2 } from 'lucide-react'
import {
Panel,
PanelContent,
@@ -19,6 +19,7 @@ import {
getManagedProjectSecrets,
putAgentRuntimeVariable,
putManagedProjectSecret,
+ type ManagedProjectSecret,
} from './api'
type Environment = 'development' | 'production'
@@ -43,6 +44,8 @@ export function ManagedProjectSecrets({
const [value, setValue] = useState('')
const [origins, setOrigins] = useState('')
const [agentId, setAgentId] = useState('')
+ const [editing, setEditing] = useState(null)
+ const valueInput = useRef(null)
const queryKey = ['managed-project-secrets', projectId, environment]
const secrets = useQuery({
queryKey,
@@ -54,6 +57,8 @@ export function ManagedProjectSecrets({
setName('')
setValue('')
setOrigins('')
+ setAgentId('')
+ setEditing(null)
await queryClient.invalidateQueries({ queryKey })
notifySuccess('Secret saved.', 'Its value remains write-only.')
},
@@ -69,6 +74,27 @@ export function ManagedProjectSecrets({
})
const agentNames = new Map(agents.map((agent) => [agent.id, agent.name]))
+ function edit(secret: ManagedProjectSecret) {
+ setEditing(secret)
+ setName(secret.name)
+ setValue('')
+ setOrigins(
+ secret.allowedOrigins.includes('*')
+ ? ''
+ : secret.allowedOrigins.join(', '),
+ )
+ setAgentId(secret.agentId ?? '')
+ requestAnimationFrame(() => valueInput.current?.focus())
+ }
+
+ function cancelEdit() {
+ setEditing(null)
+ setName('')
+ setValue('')
+ setOrigins('')
+ setAgentId('')
+ }
+
function submit(event: FormEvent) {
event.preventDefault()
const normalizedName = name.trim().toUpperCase()
@@ -98,7 +124,9 @@ export function ManagedProjectSecrets({
-
Secrets
+
+ {editing ? `Edit ${editing.name}` : 'Secrets'}
+
Write-only values injected by the managed egress gateway for the
selected environment. Values never enter agent runtimes or logs.
@@ -118,6 +146,7 @@ export function ManagedProjectSecrets({
onChange={(event) => setName(event.target.value.toUpperCase())}
placeholder="GITHUB_TOKEN"
autoComplete="off"
+ disabled={Boolean(editing)}
/>
@@ -126,6 +155,7 @@ export function ManagedProjectSecrets({
id="secret-scope"
value={agentId}
onChange={(event) => setAgentId(event.target.value)}
+ disabled={Boolean(editing)}
className="border-input bg-background h-8 w-full rounded-md border px-2.5 text-sm outline-none"
>
Entire project
@@ -140,6 +170,7 @@ export function ManagedProjectSecrets({
Value
setValue(event.target.value)}
@@ -181,8 +212,19 @@ export function ManagedProjectSecrets({
) : (
)}
- Save secret
+ {editing ? 'Replace secret' : 'Save secret'}
+ {editing ? (
+
+ Cancel
+
+ ) : null}
@@ -245,22 +287,33 @@ export function ManagedProjectSecrets({
)}
-
- remove.mutate({
- projectId,
- environment: secret.environment,
- ...(secret.agentId ? { agentId: secret.agentId } : {}),
- name: secret.name,
- })
- }
- >
- Remove
-
+
+
edit(secret)}
+ >
+ Edit
+
+
+ remove.mutate({
+ projectId,
+ environment: secret.environment,
+ ...(secret.agentId ? { agentId: secret.agentId } : {}),
+ name: secret.name,
+ })
+ }
+ >
+ Remove
+
+
))}
diff --git a/web/src/managed-agents/api.ts b/web/src/managed-agents/api.ts
index c8385ad5e..a1123f0f5 100644
--- a/web/src/managed-agents/api.ts
+++ b/web/src/managed-agents/api.ts
@@ -627,6 +627,12 @@ export async function getManagedProject(projectId: string) {
)
}
+export async function deleteManagedProject(projectId: string) {
+ await apiFetch(`/managed-agents/projects/${encodeURIComponent(projectId)}`, {
+ method: 'DELETE',
+ })
+}
+
export async function getManagedModelAccessConnections() {
return (
await apiFetch(
diff --git a/web/src/managed-agents/project-settings.ts b/web/src/managed-agents/project-settings.ts
new file mode 100644
index 000000000..0db438aab
--- /dev/null
+++ b/web/src/managed-agents/project-settings.ts
@@ -0,0 +1,6 @@
+export function canConfirmProjectDeletion(
+ confirmation: string,
+ projectName: string,
+) {
+ return confirmation === projectName
+}