diff --git a/server-db.ts b/server-db.ts index bdb0dd0..9d6c7e2 100644 --- a/server-db.ts +++ b/server-db.ts @@ -99,6 +99,10 @@ db.exec(` ); `); +// Idempotent migrations — SQLite throws on duplicate column; the catch makes them safe to re-run. +try { db.prepare("ALTER TABLE labs ADD COLUMN scope TEXT NOT NULL DEFAULT 'global'").run(); } catch {} +try { db.prepare("ALTER TABLE labs ADD COLUMN ownerId TEXT NOT NULL DEFAULT ''").run(); } catch {} + // Seed default settings — INSERT OR IGNORE writes a key only if it is absent. const DEFAULT_SETTINGS: Record = { azure_enabled: 'false', diff --git a/server.ts b/server.ts index a1a7ebf..ed74b44 100644 --- a/server.ts +++ b/server.ts @@ -526,6 +526,8 @@ async function startServer() { deviceIds: JSON.parse(r.deviceIds), topology: JSON.parse(r.topology), semaphoreSetupTemplateId: r.semaphoreSetupTemplateId || '', semaphoreTeardownTemplateId: r.semaphoreTeardownTemplateId || '', + scope: (r.scope === 'personal' ? 'personal' : 'global') as 'global' | 'personal', + ownerId: r.ownerId ?? '', })); res.json(labs); } catch (err: any) { @@ -535,40 +537,54 @@ async function startServer() { app.post('/api/labs', requireAuth, (req, res) => { try { - const { name, description, contactPerson, location, deviceIds, topology, semaphoreSetupTemplateId, semaphoreTeardownTemplateId } = req.body; + const { name, description, contactPerson, location, deviceIds, topology, semaphoreSetupTemplateId, semaphoreTeardownTemplateId, scope } = req.body; if (!name || !deviceIds || !Array.isArray(deviceIds)) { return res.status(400).json({ error: 'Missing name or associated device configurations.' }); } + const safeScope: 'global' | 'personal' = scope === 'personal' ? 'personal' : 'global'; + const ownerId = req.user!.userId; const id = uid("lab"); - db.prepare(`INSERT INTO labs (id, name, description, contactPerson, location, deviceIds, topology, semaphoreSetupTemplateId, semaphoreTeardownTemplateId) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`) - .run(id, name, description || '', contactPerson || '', location || '', JSON.stringify(deviceIds), JSON.stringify(topology || []), semaphoreSetupTemplateId || '', semaphoreTeardownTemplateId || ''); + db.prepare(`INSERT INTO labs (id, name, description, contactPerson, location, deviceIds, topology, semaphoreSetupTemplateId, semaphoreTeardownTemplateId, scope, ownerId) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`) + .run(id, name, description || '', contactPerson || '', location || '', JSON.stringify(deviceIds), JSON.stringify(topology || []), semaphoreSetupTemplateId || '', semaphoreTeardownTemplateId || '', safeScope, ownerId); addLog('maintenance', `Released a new lab template profile "${name}" (${location || 'Staging Area'}) for engineering teams.`, { userId: req.user!.userId }); const r = db.prepare('SELECT * FROM labs WHERE id = ?').get(id) as any; - res.status(201).json({ id: r.id, name: r.name, description: r.description, contactPerson: r.contactPerson, location: r.location, deviceIds: JSON.parse(r.deviceIds), topology: JSON.parse(r.topology), semaphoreSetupTemplateId: r.semaphoreSetupTemplateId || '', semaphoreTeardownTemplateId: r.semaphoreTeardownTemplateId || '' }); + res.status(201).json({ id: r.id, name: r.name, description: r.description, contactPerson: r.contactPerson, location: r.location, deviceIds: JSON.parse(r.deviceIds), topology: JSON.parse(r.topology), semaphoreSetupTemplateId: r.semaphoreSetupTemplateId || '', semaphoreTeardownTemplateId: r.semaphoreTeardownTemplateId || '', scope: r.scope, ownerId: r.ownerId }); } catch (err: any) { res.status(500).json({ error: err.message }); } }); - app.put('/api/labs/:id', requireAuth, (req, res) => { + app.put('/api/labs/:id', requireAuth, async (req, res) => { try { const id = req.params.id; - const { name, description, contactPerson, location, deviceIds, topology, semaphoreSetupTemplateId, semaphoreTeardownTemplateId } = req.body; + const { name, description, contactPerson, location, deviceIds, topology, semaphoreSetupTemplateId, semaphoreTeardownTemplateId, scope } = req.body; - db.prepare(`UPDATE labs SET name = ?, description = ?, contactPerson = ?, location = ?, deviceIds = ?, topology = ?, semaphoreSetupTemplateId = ?, semaphoreTeardownTemplateId = ? WHERE id = ?`) - .run(name, description, contactPerson, location, JSON.stringify(deviceIds), JSON.stringify(topology), semaphoreSetupTemplateId || '', semaphoreTeardownTemplateId || '', id); + const existing = db.prepare('SELECT ownerId, scope FROM labs WHERE id = ?').get(id) as any; + if (!existing) return res.status(404).json({ error: 'Lab template not found.' }); + + const reqUser = db.prepare('SELECT role FROM users WHERE id = ?').get(req.user!.userId) as any; + const isAdmin = reqUser?.role?.toLowerCase() === 'admin'; + const isOwner = existing.ownerId === req.user!.userId; + const isLegacy = existing.ownerId === ''; + if (!isOwner && !isAdmin && !isLegacy) { + return res.status(403).json({ error: 'You do not have permission to edit this topology.' }); + } + + const safeScope: 'global' | 'personal' = scope === 'personal' ? 'personal' : 'global'; + db.prepare(`UPDATE labs SET name = ?, description = ?, contactPerson = ?, location = ?, deviceIds = ?, topology = ?, semaphoreSetupTemplateId = ?, semaphoreTeardownTemplateId = ?, scope = ? WHERE id = ?`) + .run(name, description, contactPerson, location, JSON.stringify(deviceIds), JSON.stringify(topology), semaphoreSetupTemplateId || '', semaphoreTeardownTemplateId || '', safeScope, id); addLog('maintenance', `Modified the active topology mapping schema for the "${name}" lab template.`, { userId: req.user!.userId }); const r = db.prepare('SELECT * FROM labs WHERE id = ?').get(id) as any; - res.json({ id: r.id, name: r.name, description: r.description, contactPerson: r.contactPerson, location: r.location, deviceIds: JSON.parse(r.deviceIds), topology: JSON.parse(r.topology), semaphoreSetupTemplateId: r.semaphoreSetupTemplateId || '', semaphoreTeardownTemplateId: r.semaphoreTeardownTemplateId || '' }); + res.json({ id: r.id, name: r.name, description: r.description, contactPerson: r.contactPerson, location: r.location, deviceIds: JSON.parse(r.deviceIds), topology: JSON.parse(r.topology), semaphoreSetupTemplateId: r.semaphoreSetupTemplateId || '', semaphoreTeardownTemplateId: r.semaphoreTeardownTemplateId || '', scope: r.scope, ownerId: r.ownerId }); } catch (err: any) { res.status(500).json({ error: err.message }); } @@ -580,6 +596,14 @@ async function startServer() { const lab = db.prepare('SELECT * FROM labs WHERE id = ?').get(id) as any; if (!lab) return res.status(404).json({ error: 'Lab template not found.' }); + const reqUser = db.prepare('SELECT role FROM users WHERE id = ?').get(req.user!.userId) as any; + const isAdmin = reqUser?.role?.toLowerCase() === 'admin'; + const isOwner = lab.ownerId === req.user!.userId; + const isLegacy = lab.ownerId === ''; + if (!isOwner && !isAdmin && !isLegacy) { + return res.status(403).json({ error: 'You do not have permission to delete this topology.' }); + } + db.prepare('DELETE FROM labs WHERE id = ?').run(id); db.prepare(`UPDATE bookings SET status = 'cancelled' WHERE labId = ? AND status = 'upcoming'`).run(id); diff --git a/src/App.tsx b/src/App.tsx index 1b1df52..b788d7d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -299,7 +299,7 @@ export default function App() { }; // Lab handlers - const handleAddLab = async (newLab: Omit) => { + const handleAddLab = async (newLab: Omit) => { try { const res = await authFetch('/api/labs', { method: 'POST', body: JSON.stringify(newLab) }); if (res.ok) { @@ -597,6 +597,7 @@ export default function App() { labs.filter(l => + l.scope === 'global' || + l.ownerId === currentUser.id || + currentUser.role?.toLowerCase() === 'admin' + ), [labs, currentUser.id, currentUser.role]); + + useEffect(() => { + if (selectedLabId && !bookableLabs.find(l => l.id === selectedLabId)) { + setSelectedLabId(bookableLabs[0]?.id || ''); + } + }, [bookableLabs]); + // A lab is quick-bookable when every device is free (regardless of online status). - const availableLabs = useMemo(() => labs.filter(lab => + const availableLabs = useMemo(() => bookableLabs.filter(lab => lab.deviceIds.length > 0 && lab.deviceIds.every(dId => !isDeviceBooked(dId, quickWindow.startMs, quickWindow.endMs)) - ), [labs, devices, bookings, quickWindow]); + ), [bookableLabs, devices, bookings, quickWindow]); const availableDevices = useMemo(() => devices.filter(dev => !isDeviceBooked(dev.id, quickWindow.startMs, quickWindow.endMs) @@ -576,9 +588,20 @@ export default function BookingCalendar({ onChange={(e) => setSelectedLabId(e.target.value)} className="w-full bg-slate-950 text-slate-100 border border-slate-800 rounded p-2 focus:outline-none focus:border-indigo-500" > - {labs.map((l) => ( - - ))} + {bookableLabs.filter(l => l.scope === 'global').length > 0 && ( + + {bookableLabs.filter(l => l.scope === 'global').map((l) => ( + + ))} + + )} + {bookableLabs.filter(l => l.scope === 'personal').length > 0 && ( + + {bookableLabs.filter(l => l.scope === 'personal').map((l) => ( + + ))} + + )} ) : ( diff --git a/src/components/LabTemplates.tsx b/src/components/LabTemplates.tsx index d1e35fc..42ac372 100644 --- a/src/components/LabTemplates.tsx +++ b/src/components/LabTemplates.tsx @@ -4,25 +4,27 @@ */ import React, { useState } from 'react'; -import { LabTemplate, Device, TopologyLink } from '../types'; +import { LabTemplate, Device, TopologyLink, User } from '../types'; import TopologyPanel from './TopologyPanel'; import { - Server, Plus, Edit3, Trash, User, MapPin, - Layers, ChevronRight, Save, X, Check, Pencil, Terminal, + Server, Plus, Edit3, Trash, User as UserIcon, MapPin, + Layers, ChevronRight, X, Check, Pencil, Terminal, Lock, Globe, } from 'lucide-react'; interface LabTemplatesProps { labs: LabTemplate[]; devices: Device[]; - onAddLab: (lab: Omit) => void; + currentUser: User; + onAddLab: (lab: Omit) => void; onUpdateLab: (lab: LabTemplate) => void; onDeleteLab: (id: string) => void; - onOpenDeviceDetails: (device: Device) => void; + onOpenDeviceDetails: (device: Device) => void; } export default function LabTemplates({ labs, devices, + currentUser, onAddLab, onUpdateLab, onDeleteLab, @@ -49,6 +51,7 @@ export default function LabTemplates({ deviceIds: string[]; semaphoreSetupTemplateId: string; semaphoreTeardownTemplateId: string; + scope: 'global' | 'personal'; }>({ name: '', description: '', @@ -57,6 +60,7 @@ export default function LabTemplates({ deviceIds: [], semaphoreSetupTemplateId: '', semaphoreTeardownTemplateId: '', + scope: 'global', }); // Calculate filtered devices associated with selected lab @@ -75,6 +79,7 @@ export default function LabTemplates({ deviceIds: [], semaphoreSetupTemplateId: '', semaphoreTeardownTemplateId: '', + scope: 'global', }); setIsEditing(true); }; @@ -91,6 +96,7 @@ export default function LabTemplates({ deviceIds: [...lab.deviceIds], semaphoreSetupTemplateId: lab.semaphoreSetupTemplateId || '', semaphoreTeardownTemplateId: lab.semaphoreTeardownTemplateId || '', + scope: lab.scope ?? 'global', }); setIsEditing(true); }; @@ -137,23 +143,110 @@ export default function LabTemplates({ topology: tempLinks, semaphoreSetupTemplateId: formData.semaphoreSetupTemplateId, semaphoreTeardownTemplateId: formData.semaphoreTeardownTemplateId, + scope: formData.scope, }; if (formMode === 'add') { onAddLab(savedLabData); } else if (formMode === 'edit' && formData.id) { + const existing = labs.find(l => l.id === formData.id); onUpdateLab({ ...savedLabData, - id: formData.id + id: formData.id, + ownerId: existing?.ownerId ?? '', }); } setIsEditing(false); }; + const isAdmin = currentUser.role?.toLowerCase() === 'admin'; + const canEdit = (lab: LabTemplate) => isAdmin || lab.ownerId === currentUser.id || lab.ownerId === ''; + + const myPersonalLabs = labs.filter(l => l.scope === 'personal' && l.ownerId === currentUser.id); + const globalLabs = labs.filter(l => l.scope === 'global'); + const othersPersonal = isAdmin ? labs.filter(l => l.scope === 'personal' && l.ownerId !== currentUser.id) : []; + + const renderLabCard = (lab: LabTemplate) => { + const isSelected = selectedLab?.id === lab.id; + const editable = canEdit(lab); + return ( +
setSelectedLab(lab)} + className={`p-4 rounded-xl border transition-all cursor-pointer relative ${ + isSelected + ? 'bg-slate-900 border-emerald-500' + : 'bg-slate-900/40 border-slate-800 hover:border-slate-700 hover:bg-slate-900/60' + }`} + > +
+

{lab.name}

+
e.stopPropagation()}> + {editable && ( + <> + + + + )} +
+
+ +

+ {lab.description} +

+ +
+
+ + {lab.contactPerson} +
+
+ + {lab.location} +
+
+ +
+
+ + {lab.deviceIds.length} connected devices + + {lab.scope === 'personal' ? ( + + Personal + + ) : ( + + Global + + )} +
+ +
+
+ ); + }; + return (
- + {/* LEFT COLUMN: Lab List */}
@@ -174,68 +267,29 @@ export default function LabTemplates({
- {/* Labs templates list */} + {/* Labs templates list — sectioned */}
- {labs.map((lab) => { - const isSelected = selectedLab?.id === lab.id; - return ( -
setSelectedLab(lab)} - className={`p-4 rounded-xl border transition-all cursor-pointer relative ${ - isSelected - ? 'bg-slate-900 border-emerald-500' - : 'bg-slate-900/40 border-slate-800 hover:border-slate-700 hover:bg-slate-900/60' - }`} - > -
-

{lab.name}

-
e.stopPropagation()}> - - -
-
- -

- {lab.description} -

- -
-
- - {lab.contactPerson} -
-
- - {lab.location} -
-
- -
- - {lab.deviceIds.length} connected devices - - -
-
- ); - })} + {myPersonalLabs.length > 0 && ( + <> +

My Topologies

+ {myPersonalLabs.map(renderLabCard)} + + )} + {globalLabs.length > 0 && ( + <> +

Global Topologies

+ {globalLabs.map(renderLabCard)} + + )} + {othersPersonal.length > 0 && ( + <> +

Others' Personal

+ {othersPersonal.map(renderLabCard)} + + )} + {labs.length === 0 && ( +

No topology templates yet.

+ )}
@@ -254,7 +308,7 @@ export default function LabTemplates({
- +

Primary Contact

{selectedLab.contactPerson}

@@ -389,6 +443,35 @@ export default function LabTemplates({
+ {/* Scope toggle */} +
+ +
+ + +
+
+ {/* Hardware checklist */}
diff --git a/src/index.css b/src/index.css index a7971a8..d76719a 100644 --- a/src/index.css +++ b/src/index.css @@ -109,6 +109,7 @@ :root.light .bg-slate-950\/20, :root.light .bg-slate-950\/30, :root.light .bg-slate-950\/40, +:root.light .bg-slate-950\/50, :root.light .bg-slate-950\/60, :root.light .bg-slate-950\/80 { background-color: rgba(241, 245, 249, 0.85) !important; @@ -730,6 +731,12 @@ /* ── Missing border opacity variants ─────────────────────────────── */ +/* slate-800 with opacity */ +:root.light .border-slate-800\/50, +:root.light .border-slate-800\/40 { + border-color: var(--border) !important; +} + /* slate-700 with opacity */ :root.light .border-slate-700\/40, :root.light .border-slate-700\/50, diff --git a/src/types.ts b/src/types.ts index 5b6221d..2570d0d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -36,6 +36,8 @@ export interface LabTemplate { topology: TopologyLink[]; semaphoreSetupTemplateId?: string; semaphoreTeardownTemplateId?: string; + scope: 'global' | 'personal'; + ownerId: string; } export interface Booking {