diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f56d4b..d9f504c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,15 @@ Format: [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) ## [Unreleased] +## [1.0.6] - 2026-04-06 + +### Added +- `DELETE /api/tenants/:id` endpoint — delete workspaces (tenants) with cascade (projects, elements, snapshots) +- Workspace delete UI: inline confirm buttons in the workspace switcher panel + +### Fixed +- Project switch in browser did not load new project's elements — `switchProjectUI` now directly clears canvas and calls `loadExistingElements()` instead of relying on WS roundtrip + ## [1.0.5] - 2026-04-06 ### Added diff --git a/README.md b/README.md index 8f3dfff..74d7c67 100644 --- a/README.md +++ b/README.md @@ -758,6 +758,7 @@ The canvas server exposes a REST API alongside the WebSocket interface: | PUT | `/api/project/active` | Switch the active project | | DELETE | `/api/projects/:id` | Delete a project (cascades elements) | | GET | `/api/tenants` | List all tenants | +| DELETE | `/api/tenants/:id` | Delete a tenant (cascades projects and elements) | | GET | `/api/tenant/active` | Get the active tenant | | PUT | `/api/tenant/active` | Set the active tenant | | GET | `/api/settings/:key` | Read a setting | diff --git a/frontend/index.html b/frontend/index.html index 2fdc4fe..c0f4149 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -502,6 +502,12 @@ } .menu-create-btn:hover:not(:disabled) { background: #4c2fa8; } .menu-create-btn:disabled { opacity: 0.5; cursor: default; } + .tenant-row { + position: relative; + display: flex; + align-items: stretch; + } + .tenant-row:hover .project-delete-btn { opacity: 0.5; } .project-row { position: relative; display: flex; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 9c3873f..bc2e84a 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -133,6 +133,7 @@ function App(): JSX.Element { const [isCreatingProject, setIsCreatingProject] = useState(false) const newProjectInputRef = useRef(null) const [confirmDeleteProjectId, setConfirmDeleteProjectId] = useState(null) + const [confirmDeleteTenantId, setConfirmDeleteTenantId] = useState(null) // Keep refs in sync so closures (WebSocket handlers) always see latest values useEffect(() => { @@ -1241,6 +1242,16 @@ function App(): JSX.Element { if (data.success) { setActiveProject({ id: data.project.id, name: data.project.name }) setProjectMenuOpen(false) + // Clear canvas and load the new project's elements directly + // (don't rely on WS roundtrip which can race) + const api = excalidrawAPIRef.current + if (api) { + api.updateScene({ elements: [], captureUpdate: CaptureUpdateAction.NEVER }) + lastSyncedHashRef.current = '' + lastSeenHashRef.current = '' + lastSyncedElementsRef.current = new Map() + } + await loadExistingElements() showToast(`Switched to "${data.project.name}"`) } } catch (err) { @@ -1293,6 +1304,27 @@ function App(): JSX.Element { } } + const deleteTenantUI = async (tenantId: string) => { + try { + const res = await fetch(`/api/tenants/${tenantId}`, { + method: 'DELETE', + headers: tenantHeaders() + }) + const data = await res.json() + if (data.success) { + setConfirmDeleteTenantId(null) + setTenantList(prev => prev.filter(t => t.id !== tenantId)) + showToast('Workspace deleted') + } else { + showToast(data.error ?? 'Delete failed', 4000) + setConfirmDeleteTenantId(null) + } + } catch (err) { + console.error('Failed to delete tenant:', err) + setConfirmDeleteTenantId(null) + } + } + const syncToBackend = async (): Promise => { if (!excalidrawAPI || isSyncingRef.current) return @@ -1553,19 +1585,37 @@ function App(): JSX.Element {
{filtered.map(t => ( - +
+ {confirmDeleteTenantId === t.id ? ( +
+ Delete "{t.name}"? + + +
+ ) : ( + <> + + {activeTenant?.id !== t.id && ( + + )} + + )} +
))} {filtered.length === 0 &&
No matching workspaces
}
diff --git a/package.json b/package.json index 05b984c..b2bfb76 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "excalidraw-mcp-sentinel", - "version": "1.0.5", + "version": "1.0.6", "description": "Hardened, self-hosted Excalidraw MCP server with SQLite persistence, multi-tenancy, auto-sync, security middleware, and 369 tests", "main": "dist/index.js", "type": "module", diff --git a/src/db.ts b/src/db.ts index 25faf41..6b11cf5 100644 --- a/src/db.ts +++ b/src/db.ts @@ -524,6 +524,26 @@ export function listTenants(): Tenant[] { return db.prepare('SELECT * FROM tenants ORDER BY last_accessed_at DESC').all() as Tenant[]; } +export function deleteTenant(id: string): void { + if (id === activeTenantId) throw new Error('Cannot delete the active tenant — switch to another tenant first'); + const tenants = listTenants(); + if (tenants.length <= 1) throw new Error('Cannot delete the last tenant'); + const tenant = getTenantById(id); + if (!tenant) throw new Error(`Tenant "${id}" not found`); + // CASCADE: delete elements + element_versions for all projects in this tenant, then projects, then tenant + const projects = db.prepare('SELECT id FROM projects WHERE tenant_id = ?').all(id) as { id: string }[]; + const deleteElements = db.prepare('DELETE FROM elements WHERE project_id = ?'); + const deleteVersions = db.prepare('DELETE FROM element_versions WHERE element_id IN (SELECT id FROM elements WHERE project_id = ?)'); + const deleteSnapshots = db.prepare('DELETE FROM snapshots WHERE project_id = ?'); + for (const p of projects) { + deleteVersions.run(p.id); + deleteElements.run(p.id); + deleteSnapshots.run(p.id); + } + db.prepare('DELETE FROM projects WHERE tenant_id = ?').run(id); + db.prepare('DELETE FROM tenants WHERE id = ?').run(id); +} + // ── Projects ── export function createProject(name: string, description?: string, tenantId?: string): Project { diff --git a/src/server.ts b/src/server.ts index b72e6bf..5142a22 100644 --- a/src/server.ts +++ b/src/server.ts @@ -28,7 +28,7 @@ import { BroadcastResult } from './types.js'; import * as store from './db.js'; -import { initDb, listTenants as dbListTenants, getActiveTenant as dbGetActiveTenant, getTenantById, setActiveTenant as dbSetActiveTenant, getDefaultProjectForTenant, getProjectForTenant, getCurrentSyncVersion, getChangesSince, setActiveProject as dbSetActiveProject, getActiveProject as dbGetActiveProject, getActiveProjectId as dbGetActiveProjectId, getActiveTenantId as dbGetActiveTenantId, listProjects as dbListProjects, createProject as dbCreateProject, deleteProject as dbDeleteProject, getElementCountForProject as dbGetElementCountForProject } from './db.js'; +import { initDb, listTenants as dbListTenants, getActiveTenant as dbGetActiveTenant, getTenantById, setActiveTenant as dbSetActiveTenant, getDefaultProjectForTenant, getProjectForTenant, getCurrentSyncVersion, getChangesSince, setActiveProject as dbSetActiveProject, getActiveProject as dbGetActiveProject, getActiveProjectId as dbGetActiveProjectId, getActiveTenantId as dbGetActiveTenantId, listProjects as dbListProjects, createProject as dbCreateProject, deleteProject as dbDeleteProject, deleteTenant as dbDeleteTenant, getElementCountForProject as dbGetElementCountForProject } from './db.js'; import { z } from 'zod'; import WebSocket from 'ws'; @@ -1590,6 +1590,18 @@ app.put('/api/tenant/active', (req: Request, res: Response) => { } }); +app.delete('/api/tenants/:id', (req: Request, res: Response) => { + try { + const id = req.params.id as string; + dbDeleteTenant(id); + broadcast({ type: 'tenant_deleted', tenantId: id } as any); + res.json({ success: true, tenantId: id }); + } catch (error) { + logger.error('Error deleting tenant:', error); + res.status(400).json({ success: false, error: (error as Error).message }); + } +}); + app.get('/api/projects', (req: Request, res: Response) => { try { const projects = dbListProjects();