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:
co-authored by
Claude Sonnet 4.6
parent
1166ea5b3f
commit
d14d767c75
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user