feat(tenants): add workspace delete UI and fix project switch element loading (#11)

- Add DELETE /api/tenants/:id endpoint with cascade (projects, elements, snapshots)
- Add delete buttons with inline confirm in workspace switcher panel
- Fix project switch not loading elements: switchProjectUI now directly clears
  canvas and calls loadExistingElements() instead of relying on WS roundtrip

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Maxime Roy (new.blacc)
2026-04-06 14:37:11 +02:00
committed by GitHub
co-authored by Claude Sonnet 4.6
parent 1166ea5b3f
commit d14d767c75
7 changed files with 113 additions and 15 deletions
+9
View File
@@ -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
+1
View File
@@ -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 |
+6
View File
@@ -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;
+63 -13
View File
@@ -133,6 +133,7 @@ function App(): JSX.Element {
const [isCreatingProject, setIsCreatingProject] = useState<boolean>(false)
const newProjectInputRef = useRef<HTMLInputElement | null>(null)
const [confirmDeleteProjectId, setConfirmDeleteProjectId] = useState<string | null>(null)
const [confirmDeleteTenantId, setConfirmDeleteTenantId] = useState<string | null>(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<void> => {
if (!excalidrawAPI || isSyncingRef.current) return
@@ -1553,19 +1585,37 @@ function App(): JSX.Element {
</div>
<div className="menu-list">
{filtered.map(t => (
<button
key={t.id}
className={`menu-item ${activeTenant?.id === t.id ? 'menu-item-active' : ''}`}
onClick={() => switchTenant(t.id)}
>
<span className="menu-item-name">{t.name}</span>
<span className="menu-item-path" title={t.workspace_path}>
{t.workspace_path.length > 40
? '...' + t.workspace_path.slice(-37)
: t.workspace_path}
</span>
{activeTenant?.id === t.id && <span className="menu-item-check"></span>}
</button>
<div key={t.id} className="tenant-row">
{confirmDeleteTenantId === t.id ? (
<div className="project-delete-confirm">
<span className="project-delete-msg">Delete "{t.name}"?</span>
<button className="project-delete-yes" onClick={() => deleteTenantUI(t.id)}>Delete</button>
<button className="project-delete-no" onClick={() => setConfirmDeleteTenantId(null)}>Cancel</button>
</div>
) : (
<>
<button
className={`menu-item ${activeTenant?.id === t.id ? 'menu-item-active' : ''}`}
onClick={() => switchTenant(t.id)}
>
<span className="menu-item-name">{t.name}</span>
<span className="menu-item-path" title={t.workspace_path}>
{t.workspace_path.length > 40
? '...' + t.workspace_path.slice(-37)
: t.workspace_path}
</span>
{activeTenant?.id === t.id && <span className="menu-item-check"></span>}
</button>
{activeTenant?.id !== t.id && (
<button
className="project-delete-btn"
title="Delete workspace"
onClick={e => { e.stopPropagation(); setConfirmDeleteTenantId(t.id) }}
>×</button>
)}
</>
)}
</div>
))}
{filtered.length === 0 && <div className="menu-empty">No matching workspaces</div>}
</div>
+1 -1
View File
@@ -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",
+20
View File
@@ -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 {
+13 -1
View File
@@ -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();