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. + +
+
+ + + +
+ + { + 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. + + +
+ + setConfirmation(event.target.value)} + autoComplete="off" + /> +
+ + + Cancel + + + +
+
+ + ) +} 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" > @@ -140,6 +170,7 @@ export function ManagedProjectSecrets({ setValue(event.target.value)} @@ -181,8 +212,19 @@ export function ManagedProjectSecrets({ ) : ( )} - Save secret + {editing ? 'Replace secret' : 'Save secret'} + {editing ? ( + + ) : null}
@@ -245,22 +287,33 @@ export function ManagedProjectSecrets({

)} - +
+ + +
))} 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 +}