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

@ -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);