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:
@ -299,7 +299,7 @@ export default function App() {
|
||||
};
|
||||
|
||||
// Lab handlers
|
||||
const handleAddLab = async (newLab: Omit<LabTemplate, 'id'>) => {
|
||||
const handleAddLab = async (newLab: Omit<LabTemplate, 'id' | 'ownerId'>) => {
|
||||
try {
|
||||
const res = await authFetch('/api/labs', { method: 'POST', body: JSON.stringify(newLab) });
|
||||
if (res.ok) {
|
||||
@ -597,6 +597,7 @@ export default function App() {
|
||||
<LabTemplates
|
||||
labs={labs}
|
||||
devices={devices}
|
||||
currentUser={currentUser!}
|
||||
onAddLab={handleAddLab}
|
||||
onUpdateLab={handleUpdateLab}
|
||||
onDeleteLab={handleDeleteLab}
|
||||
|
||||
@ -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 {
|
||||
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 };
|
||||
}, [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).
|
||||
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) => (
|
||||
<option key={l.id} value={l.id}>{l.name} ({l.location})</option>
|
||||
))}
|
||||
{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>
|
||||
))}
|
||||
</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>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@ -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<LabTemplate, 'id'>) => void;
|
||||
currentUser: User;
|
||||
onAddLab: (lab: Omit<LabTemplate, 'id' | 'ownerId'>) => 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 (
|
||||
<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 (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6" id="lab-templates-container">
|
||||
|
||||
|
||||
{/* LEFT COLUMN: Lab List */}
|
||||
<div className="lg:col-span-4 bg-[#1E293B] border border-slate-800 rounded-xl p-5 flex flex-col h-full font-sans" id="labs-list-section">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
@ -174,68 +267,29 @@ export default function LabTemplates({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Labs templates list */}
|
||||
{/* Labs templates list — sectioned */}
|
||||
<div className="space-y-3 flex-1 overflow-y-auto max-h-[550px] pr-1">
|
||||
{labs.map((lab) => {
|
||||
const isSelected = selectedLab?.id === lab.id;
|
||||
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()}>
|
||||
<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">
|
||||
<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>
|
||||
);
|
||||
})}
|
||||
{myPersonalLabs.length > 0 && (
|
||||
<>
|
||||
<p className="text-[10px] font-mono uppercase tracking-widest text-indigo-400 px-1">My Topologies</p>
|
||||
{myPersonalLabs.map(renderLabCard)}
|
||||
</>
|
||||
)}
|
||||
{globalLabs.length > 0 && (
|
||||
<>
|
||||
<p className="text-[10px] font-mono uppercase tracking-widest text-slate-500 px-1 mt-2">Global Topologies</p>
|
||||
{globalLabs.map(renderLabCard)}
|
||||
</>
|
||||
)}
|
||||
{othersPersonal.length > 0 && (
|
||||
<>
|
||||
<p className="text-[10px] font-mono uppercase tracking-widest text-slate-500 px-1 mt-2">Others' Personal</p>
|
||||
{othersPersonal.map(renderLabCard)}
|
||||
</>
|
||||
)}
|
||||
{labs.length === 0 && (
|
||||
<p className="text-xs text-slate-500 text-center py-8">No topology templates yet.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -254,7 +308,7 @@ export default function LabTemplates({
|
||||
</div>
|
||||
<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">
|
||||
<User className="w-3.5 h-3.5 text-slate-400" />
|
||||
<UserIcon className="w-3.5 h-3.5 text-slate-400" />
|
||||
<div>
|
||||
<p className="text-slate-400 leading-none">Primary Contact</p>
|
||||
<p className="text-white font-semibold mt-0.5">{selectedLab.contactPerson}</p>
|
||||
@ -389,6 +443,35 @@ export default function LabTemplates({
|
||||
</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 */}
|
||||
<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>
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -36,6 +36,8 @@ export interface LabTemplate {
|
||||
topology: TopologyLink[];
|
||||
semaphoreSetupTemplateId?: string;
|
||||
semaphoreTeardownTemplateId?: string;
|
||||
scope: 'global' | 'personal';
|
||||
ownerId: string;
|
||||
}
|
||||
|
||||
export interface Booking {
|
||||
|
||||
Reference in New Issue
Block a user