feat(topology): add personal/global scope to lab templates

Labs can now be marked as Personal or Global when creating or editing.
Personal topologies are visible only to the owner and admins; others
cannot see, book, or edit them. Global topologies are visible to all
but editable only by the creator, admins, or legacy (migrated) labs.

- DB: idempotent ALTER TABLE adds scope + ownerId columns to labs
- API: POST sets ownerId from JWT; PUT/DELETE enforce ownership (403 for
  unauthorized edits; legacy ownerId='' remains freely editable)
- Types: LabTemplate extended with scope and ownerId fields
- LabTemplates UI: sectioned list (My / Global / Others' Personal),
  Personal/Global toggle in form, Lock/Globe badges on cards,
  edit+delete buttons hidden for non-owners
- BookingCalendar: personal labs filtered from selects/quick booking,
  optgroup grouping for Global vs Personal in topology dropdown
- Light mode: add missing bg-slate-950/50 and border-slate-800/50
  overrides so the Global badge renders correctly
This commit is contained in:
Brückner
2026-06-10 15:51:53 +02:00
parent cb36caff2e
commit 08a4df5503
7 changed files with 229 additions and 85 deletions

View File

@ -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. // Seed default settings — INSERT OR IGNORE writes a key only if it is absent.
const DEFAULT_SETTINGS: Record<string, string> = { const DEFAULT_SETTINGS: Record<string, string> = {
azure_enabled: 'false', azure_enabled: 'false',

View File

@ -526,6 +526,8 @@ async function startServer() {
deviceIds: JSON.parse(r.deviceIds), topology: JSON.parse(r.topology), deviceIds: JSON.parse(r.deviceIds), topology: JSON.parse(r.topology),
semaphoreSetupTemplateId: r.semaphoreSetupTemplateId || '', semaphoreSetupTemplateId: r.semaphoreSetupTemplateId || '',
semaphoreTeardownTemplateId: r.semaphoreTeardownTemplateId || '', semaphoreTeardownTemplateId: r.semaphoreTeardownTemplateId || '',
scope: (r.scope === 'personal' ? 'personal' : 'global') as 'global' | 'personal',
ownerId: r.ownerId ?? '',
})); }));
res.json(labs); res.json(labs);
} catch (err: any) { } catch (err: any) {
@ -535,40 +537,54 @@ async function startServer() {
app.post('/api/labs', requireAuth, (req, res) => { app.post('/api/labs', requireAuth, (req, res) => {
try { 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)) { if (!name || !deviceIds || !Array.isArray(deviceIds)) {
return res.status(400).json({ error: 'Missing name or associated device configurations.' }); 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"); const id = uid("lab");
db.prepare(`INSERT INTO labs (id, name, description, contactPerson, location, deviceIds, topology, semaphoreSetupTemplateId, semaphoreTeardownTemplateId) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`) 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 || ''); .run(id, name, description || '', contactPerson || '', location || '', JSON.stringify(deviceIds), JSON.stringify(topology || []), semaphoreSetupTemplateId || '', semaphoreTeardownTemplateId || '', safeScope, ownerId);
addLog('maintenance', addLog('maintenance',
`Released a new lab template profile "${name}" (${location || 'Staging Area'}) for engineering teams.`, `Released a new lab template profile "${name}" (${location || 'Staging Area'}) for engineering teams.`,
{ userId: req.user!.userId }); { userId: req.user!.userId });
const r = db.prepare('SELECT * FROM labs WHERE id = ?').get(id) as any; 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) { } catch (err: any) {
res.status(500).json({ error: err.message }); 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 { try {
const id = req.params.id; 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 = ?`) const existing = db.prepare('SELECT ownerId, scope FROM labs WHERE id = ?').get(id) as any;
.run(name, description, contactPerson, location, JSON.stringify(deviceIds), JSON.stringify(topology), semaphoreSetupTemplateId || '', semaphoreTeardownTemplateId || '', id); 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', addLog('maintenance',
`Modified the active topology mapping schema for the "${name}" lab template.`, `Modified the active topology mapping schema for the "${name}" lab template.`,
{ userId: req.user!.userId }); { userId: req.user!.userId });
const r = db.prepare('SELECT * FROM labs WHERE id = ?').get(id) as any; 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) { } catch (err: any) {
res.status(500).json({ error: err.message }); 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; 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.' }); 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('DELETE FROM labs WHERE id = ?').run(id);
db.prepare(`UPDATE bookings SET status = 'cancelled' WHERE labId = ? AND status = 'upcoming'`).run(id); db.prepare(`UPDATE bookings SET status = 'cancelled' WHERE labId = ? AND status = 'upcoming'`).run(id);

View File

@ -299,7 +299,7 @@ export default function App() {
}; };
// Lab handlers // Lab handlers
const handleAddLab = async (newLab: Omit<LabTemplate, 'id'>) => { const handleAddLab = async (newLab: Omit<LabTemplate, 'id' | 'ownerId'>) => {
try { try {
const res = await authFetch('/api/labs', { method: 'POST', body: JSON.stringify(newLab) }); const res = await authFetch('/api/labs', { method: 'POST', body: JSON.stringify(newLab) });
if (res.ok) { if (res.ok) {
@ -597,6 +597,7 @@ export default function App() {
<LabTemplates <LabTemplates
labs={labs} labs={labs}
devices={devices} devices={devices}
currentUser={currentUser!}
onAddLab={handleAddLab} onAddLab={handleAddLab}
onUpdateLab={handleUpdateLab} onUpdateLab={handleUpdateLab}
onDeleteLab={handleDeleteLab} onDeleteLab={handleDeleteLab}

View File

@ -1,4 +1,4 @@
import React, { useState, useMemo } from 'react'; import React, { useState, useMemo, useEffect } from 'react';
import { Booking, LabTemplate, Device, User } from '../types'; import { Booking, LabTemplate, Device, User } from '../types';
import { import {
Calendar, Zap, CheckCircle2, AlertCircle, AlertTriangle, ChevronLeft, ChevronRight, Database, Calendar, Zap, CheckCircle2, AlertCircle, AlertTriangle, ChevronLeft, ChevronRight, Database,
@ -172,11 +172,23 @@ export default function BookingCalendar({
return { startMs: start.getTime(), endMs: end.getTime(), start, end }; return { startMs: start.getTime(), endMs: end.getTime(), start, end };
}, [quickDuration]); }, [quickDuration]);
const bookableLabs = useMemo(() => 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). // 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.length > 0 &&
lab.deviceIds.every(dId => !isDeviceBooked(dId, quickWindow.startMs, quickWindow.endMs)) lab.deviceIds.every(dId => !isDeviceBooked(dId, quickWindow.startMs, quickWindow.endMs))
), [labs, devices, bookings, quickWindow]); ), [bookableLabs, devices, bookings, quickWindow]);
const availableDevices = useMemo(() => devices.filter(dev => const availableDevices = useMemo(() => devices.filter(dev =>
!isDeviceBooked(dev.id, quickWindow.startMs, quickWindow.endMs) !isDeviceBooked(dev.id, quickWindow.startMs, quickWindow.endMs)
@ -576,9 +588,20 @@ export default function BookingCalendar({
onChange={(e) => setSelectedLabId(e.target.value)} 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" 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 && (
<optgroup label="Global Topologies">
{bookableLabs.filter(l => l.scope === 'global').map((l) => (
<option key={l.id} value={l.id}>{l.name} ({l.location})</option> <option key={l.id} value={l.id}>{l.name} ({l.location})</option>
))} ))}
</optgroup>
)}
{bookableLabs.filter(l => l.scope === 'personal').length > 0 && (
<optgroup label="My Personal Topologies">
{bookableLabs.filter(l => l.scope === 'personal').map((l) => (
<option key={l.id} value={l.id}>{l.name} ({l.location})</option>
))}
</optgroup>
)}
</select> </select>
</div> </div>
) : ( ) : (

View File

@ -4,17 +4,18 @@
*/ */
import React, { useState } from 'react'; import React, { useState } from 'react';
import { LabTemplate, Device, TopologyLink } from '../types'; import { LabTemplate, Device, TopologyLink, User } from '../types';
import TopologyPanel from './TopologyPanel'; import TopologyPanel from './TopologyPanel';
import { import {
Server, Plus, Edit3, Trash, User, MapPin, Server, Plus, Edit3, Trash, User as UserIcon, MapPin,
Layers, ChevronRight, Save, X, Check, Pencil, Terminal, Layers, ChevronRight, X, Check, Pencil, Terminal, Lock, Globe,
} from 'lucide-react'; } from 'lucide-react';
interface LabTemplatesProps { interface LabTemplatesProps {
labs: LabTemplate[]; labs: LabTemplate[];
devices: Device[]; devices: Device[];
onAddLab: (lab: Omit<LabTemplate, 'id'>) => void; currentUser: User;
onAddLab: (lab: Omit<LabTemplate, 'id' | 'ownerId'>) => void;
onUpdateLab: (lab: LabTemplate) => void; onUpdateLab: (lab: LabTemplate) => void;
onDeleteLab: (id: string) => void; onDeleteLab: (id: string) => void;
onOpenDeviceDetails: (device: Device) => void; onOpenDeviceDetails: (device: Device) => void;
@ -23,6 +24,7 @@ interface LabTemplatesProps {
export default function LabTemplates({ export default function LabTemplates({
labs, labs,
devices, devices,
currentUser,
onAddLab, onAddLab,
onUpdateLab, onUpdateLab,
onDeleteLab, onDeleteLab,
@ -49,6 +51,7 @@ export default function LabTemplates({
deviceIds: string[]; deviceIds: string[];
semaphoreSetupTemplateId: string; semaphoreSetupTemplateId: string;
semaphoreTeardownTemplateId: string; semaphoreTeardownTemplateId: string;
scope: 'global' | 'personal';
}>({ }>({
name: '', name: '',
description: '', description: '',
@ -57,6 +60,7 @@ export default function LabTemplates({
deviceIds: [], deviceIds: [],
semaphoreSetupTemplateId: '', semaphoreSetupTemplateId: '',
semaphoreTeardownTemplateId: '', semaphoreTeardownTemplateId: '',
scope: 'global',
}); });
// Calculate filtered devices associated with selected lab // Calculate filtered devices associated with selected lab
@ -75,6 +79,7 @@ export default function LabTemplates({
deviceIds: [], deviceIds: [],
semaphoreSetupTemplateId: '', semaphoreSetupTemplateId: '',
semaphoreTeardownTemplateId: '', semaphoreTeardownTemplateId: '',
scope: 'global',
}); });
setIsEditing(true); setIsEditing(true);
}; };
@ -91,6 +96,7 @@ export default function LabTemplates({
deviceIds: [...lab.deviceIds], deviceIds: [...lab.deviceIds],
semaphoreSetupTemplateId: lab.semaphoreSetupTemplateId || '', semaphoreSetupTemplateId: lab.semaphoreSetupTemplateId || '',
semaphoreTeardownTemplateId: lab.semaphoreTeardownTemplateId || '', semaphoreTeardownTemplateId: lab.semaphoreTeardownTemplateId || '',
scope: lab.scope ?? 'global',
}); });
setIsEditing(true); setIsEditing(true);
}; };
@ -137,20 +143,107 @@ export default function LabTemplates({
topology: tempLinks, topology: tempLinks,
semaphoreSetupTemplateId: formData.semaphoreSetupTemplateId, semaphoreSetupTemplateId: formData.semaphoreSetupTemplateId,
semaphoreTeardownTemplateId: formData.semaphoreTeardownTemplateId, semaphoreTeardownTemplateId: formData.semaphoreTeardownTemplateId,
scope: formData.scope,
}; };
if (formMode === 'add') { if (formMode === 'add') {
onAddLab(savedLabData); onAddLab(savedLabData);
} else if (formMode === 'edit' && formData.id) { } else if (formMode === 'edit' && formData.id) {
const existing = labs.find(l => l.id === formData.id);
onUpdateLab({ onUpdateLab({
...savedLabData, ...savedLabData,
id: formData.id id: formData.id,
ownerId: existing?.ownerId ?? '',
}); });
} }
setIsEditing(false); 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 (
<div
key={lab.id}
onClick={() => 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'
}`}
>
<div className="flex justify-between items-start">
<h3 className="font-bold text-sm text-white">{lab.name}</h3>
<div className="flex gap-1.5" onClick={e => e.stopPropagation()}>
{editable && (
<>
<button
onClick={() => handleOpenEdit(lab)}
className="text-slate-400 hover:text-indigo-400 p-0.5"
title="Edit template configuration"
>
<Edit3 className="w-3.5 h-3.5" />
</button>
<button
onClick={() => {
if (confirm(`Are you sure you want to delete lab template "${lab.name}"? Active/completed scheduling logs are retained in the system.`)) {
onDeleteLab(lab.id);
}
}}
className="text-slate-400 hover:text-rose-400 p-0.5"
title="Delete template"
>
<Trash className="w-3.5 h-3.5" />
</button>
</>
)}
</div>
</div>
<p className="text-xs text-slate-400 mt-1 line-clamp-2 leading-relaxed">
{lab.description}
</p>
<div className="mt-3 pt-3 border-t border-slate-800/80 grid grid-cols-2 gap-1 text-[10px] text-slate-350">
<div className="flex items-center gap-1">
<UserIcon className="w-3.5 h-3.5 text-slate-500 shrink-0" />
<span className="truncate">{lab.contactPerson}</span>
</div>
<div className="flex items-center gap-1">
<MapPin className="w-3.5 h-3.5 text-slate-500 shrink-0" />
<span className="truncate">{lab.location}</span>
</div>
</div>
<div className="mt-2.5 flex items-center justify-between">
<div className="flex items-center gap-1.5">
<span className="text-[10px] font-mono text-emerald-400 bg-emerald-950/50 px-2 py-0.5 rounded border border-emerald-900/50">
{lab.deviceIds.length} connected devices
</span>
{lab.scope === 'personal' ? (
<span className="text-[10px] font-mono text-indigo-400 bg-indigo-950/50 px-2 py-0.5 rounded border border-indigo-900/50 flex items-center gap-1">
<Lock className="w-2.5 h-2.5" /> Personal
</span>
) : (
<span className="text-[10px] font-mono text-slate-400 bg-slate-950/50 px-2 py-0.5 rounded border border-slate-800/50 flex items-center gap-1">
<Globe className="w-2.5 h-2.5" /> Global
</span>
)}
</div>
<ChevronRight className={`w-4 h-4 text-slate-500 transition-transform ${isSelected ? 'translate-x-1 text-emerald-400' : ''}`} />
</div>
</div>
);
};
return ( return (
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6" id="lab-templates-container"> <div className="grid grid-cols-1 lg:grid-cols-12 gap-6" id="lab-templates-container">
@ -174,68 +267,29 @@ export default function LabTemplates({
</button> </button>
</div> </div>
{/* Labs templates list */} {/* Labs templates list — sectioned */}
<div className="space-y-3 flex-1 overflow-y-auto max-h-[550px] pr-1"> <div className="space-y-3 flex-1 overflow-y-auto max-h-[550px] pr-1">
{labs.map((lab) => { {myPersonalLabs.length > 0 && (
const isSelected = selectedLab?.id === lab.id; <>
return ( <p className="text-[10px] font-mono uppercase tracking-widest text-indigo-400 px-1">My Topologies</p>
<div {myPersonalLabs.map(renderLabCard)}
key={lab.id} </>
onClick={() => setSelectedLab(lab)} )}
className={`p-4 rounded-xl border transition-all cursor-pointer relative ${ {globalLabs.length > 0 && (
isSelected <>
? 'bg-slate-900 border-emerald-500' <p className="text-[10px] font-mono uppercase tracking-widest text-slate-500 px-1 mt-2">Global Topologies</p>
: 'bg-slate-900/40 border-slate-800 hover:border-slate-700 hover:bg-slate-900/60' {globalLabs.map(renderLabCard)}
}`} </>
> )}
<div className="flex justify-between items-start"> {othersPersonal.length > 0 && (
<h3 className="font-bold text-sm text-white">{lab.name}</h3> <>
<div className="flex gap-1.5" onClick={e => e.stopPropagation()}> <p className="text-[10px] font-mono uppercase tracking-widest text-slate-500 px-1 mt-2">Others' Personal</p>
<button {othersPersonal.map(renderLabCard)}
onClick={() => handleOpenEdit(lab)} </>
className="text-slate-400 hover:text-indigo-400 p-0.5" )}
title="Edit template configuration" {labs.length === 0 && (
> <p className="text-xs text-slate-500 text-center py-8">No topology templates yet.</p>
<Edit3 className="w-3.5 h-3.5" /> )}
</button>
<button
onClick={() => {
if (confirm(`Are you sure you want to delete lab template "${lab.name}"? Active/completed scheduling logs are retained in the system.`)) {
onDeleteLab(lab.id);
}
}}
className="text-slate-400 hover:text-rose-400 p-0.5"
title="Delete template"
>
<Trash className="w-3.5 h-3.5" />
</button>
</div>
</div>
<p className="text-xs text-slate-400 mt-1 line-clamp-2 leading-relaxed">
{lab.description}
</p>
<div className="mt-3 pt-3 border-t border-slate-800/80 grid grid-cols-2 gap-1 text-[10px] text-slate-350">
<div className="flex items-center gap-1">
<User className="w-3.5 h-3.5 text-slate-500 shrink-0" />
<span className="truncate">{lab.contactPerson}</span>
</div>
<div className="flex items-center gap-1">
<MapPin className="w-3.5 h-3.5 text-slate-500 shrink-0" />
<span className="truncate">{lab.location}</span>
</div>
</div>
<div className="mt-2.5 flex items-center justify-between">
<span className="text-[10px] font-mono text-emerald-400 bg-emerald-950/50 px-2 py-0.5 rounded border border-emerald-900/50">
{lab.deviceIds.length} connected devices
</span>
<ChevronRight className={`w-4 h-4 text-slate-500 transition-transform ${isSelected ? 'translate-x-1 text-emerald-400' : ''}`} />
</div>
</div>
);
})}
</div> </div>
</div> </div>
@ -254,7 +308,7 @@ export default function LabTemplates({
</div> </div>
<div className="flex items-center gap-2.5"> <div className="flex items-center gap-2.5">
<div className="bg-slate-900 p-2 rounded-lg border border-slate-805 text-[10px] flex items-center gap-1"> <div className="bg-slate-900 p-2 rounded-lg border border-slate-805 text-[10px] flex items-center gap-1">
<User className="w-3.5 h-3.5 text-slate-400" /> <UserIcon className="w-3.5 h-3.5 text-slate-400" />
<div> <div>
<p className="text-slate-400 leading-none">Primary Contact</p> <p className="text-slate-400 leading-none">Primary Contact</p>
<p className="text-white font-semibold mt-0.5">{selectedLab.contactPerson}</p> <p className="text-white font-semibold mt-0.5">{selectedLab.contactPerson}</p>
@ -389,6 +443,35 @@ export default function LabTemplates({
</div> </div>
</div> </div>
{/* Scope toggle */}
<div className="border-t border-slate-800 pt-3">
<label className="block text-slate-300 font-semibold mb-1.5">Visibility</label>
<div className="grid grid-cols-2 gap-1.5">
<button
type="button"
onClick={() => setFormData({ ...formData, scope: 'global' })}
className={`py-2 rounded-lg text-xs font-semibold border transition-all flex items-center justify-center gap-1.5 ${
formData.scope === 'global'
? 'bg-slate-800 border-slate-500 text-slate-200'
: 'bg-slate-950 border-slate-800 text-slate-400 hover:text-white'
}`}
>
<Globe className="w-3.5 h-3.5" /> Global — visible to all
</button>
<button
type="button"
onClick={() => setFormData({ ...formData, scope: 'personal' })}
className={`py-2 rounded-lg text-xs font-semibold border transition-all flex items-center justify-center gap-1.5 ${
formData.scope === 'personal'
? 'bg-indigo-900/30 border-indigo-500 text-indigo-300'
: 'bg-slate-950 border-slate-800 text-slate-400 hover:text-white'
}`}
>
<Lock className="w-3.5 h-3.5" /> Personal — only you
</button>
</div>
</div>
{/* Hardware checklist */} {/* Hardware checklist */}
<div className="border-t border-slate-800 pt-3"> <div className="border-t border-slate-800 pt-3">
<label className="block text-slate-300 font-bold mb-1.5">1. Assign Dedicated Hardware Nodes</label> <label className="block text-slate-300 font-bold mb-1.5">1. Assign Dedicated Hardware Nodes</label>

View File

@ -109,6 +109,7 @@
:root.light .bg-slate-950\/20, :root.light .bg-slate-950\/20,
:root.light .bg-slate-950\/30, :root.light .bg-slate-950\/30,
:root.light .bg-slate-950\/40, :root.light .bg-slate-950\/40,
:root.light .bg-slate-950\/50,
:root.light .bg-slate-950\/60, :root.light .bg-slate-950\/60,
:root.light .bg-slate-950\/80 { :root.light .bg-slate-950\/80 {
background-color: rgba(241, 245, 249, 0.85) !important; background-color: rgba(241, 245, 249, 0.85) !important;
@ -730,6 +731,12 @@
/* ── Missing border opacity variants ─────────────────────────────── */ /* ── 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 */ /* slate-700 with opacity */
:root.light .border-slate-700\/40, :root.light .border-slate-700\/40,
:root.light .border-slate-700\/50, :root.light .border-slate-700\/50,

View File

@ -36,6 +36,8 @@ export interface LabTemplate {
topology: TopologyLink[]; topology: TopologyLink[];
semaphoreSetupTemplateId?: string; semaphoreSetupTemplateId?: string;
semaphoreTeardownTemplateId?: string; semaphoreTeardownTemplateId?: string;
scope: 'global' | 'personal';
ownerId: string;
} }
export interface Booking { export interface Booking {