From 49cd0ae4f65b9dd3da34d3b24866e0d8708d0fe9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Br=C3=BCckner?= Date: Wed, 10 Jun 2026 10:22:39 +0200 Subject: [PATCH 01/10] feat(caddy): optional root redirect per route MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a redirect_path column to the caddy table and an optional 'root redirect' field in the route form. When set, buildCaddyfile emits 'redir / ' so the bare host (e.g. checkmk.domain.local/) redirects to a sub-path (e.g. /monitoring/check_mk/) while every other path still passes through to the backend — the safe pattern for apps like CheckMK that bake their site path into absolute URLs. Defensive ALTER TABLE keeps existing databases working. --- ARCHITECTURE.md | 17 ++++++++++------- server-db.ts | 29 +++++++++++++++++------------ server.ts | 18 ++++++++++++------ src/components/Settings.tsx | 18 +++++++++++++++--- 4 files changed, 54 insertions(+), 28 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 39e3b75..91ec25c 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -277,12 +277,13 @@ CREATE TABLE IF NOT EXISTS settings ( ); CREATE TABLE IF NOT EXISTS caddy ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - hostname TEXT NOT NULL, - upstream TEXT NOT NULL, - tls INTEGER NOT NULL DEFAULT 1, - compress INTEGER NOT NULL DEFAULT 1, - created_at TEXT DEFAULT (datetime('now')) + id INTEGER PRIMARY KEY AUTOINCREMENT, + hostname TEXT NOT NULL, + upstream TEXT NOT NULL, + tls INTEGER NOT NULL DEFAULT 1, + compress INTEGER NOT NULL DEFAULT 1, + redirect_path TEXT NOT NULL DEFAULT '', -- optional 'redir / ' for the bare root + created_at TEXT DEFAULT (datetime('now')) ); ``` @@ -463,7 +464,9 @@ Manual: POST /api/semaphore/trigger/{bookingId} body { type: 'setup'|'teardown ``` buildCaddyfile(): { local_certs } # global block - per custom route { [encode] [tls internal] reverse_proxy { … } } + per custom route { [encode] [tls internal] [redir / ] reverse_proxy { … } } + redirect_path set → `redir / ` redirects only the bare root '/' + (other paths pass through; e.g. CheckMK served at //check_mk/) every reverse_proxy block carries standard forwarding headers: header_up X-Forwarded-Proto {scheme} header_up X-Real-IP {remote_host} diff --git a/server-db.ts b/server-db.ts index 9aefbb5..cc560d5 100644 --- a/server-db.ts +++ b/server-db.ts @@ -86,15 +86,19 @@ db.exec(` ); CREATE TABLE IF NOT EXISTS caddy ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - hostname TEXT NOT NULL, - upstream TEXT NOT NULL, - tls INTEGER NOT NULL DEFAULT 1, - compress INTEGER NOT NULL DEFAULT 1, - created_at TEXT DEFAULT (datetime('now')) + id INTEGER PRIMARY KEY AUTOINCREMENT, + hostname TEXT NOT NULL, + upstream TEXT NOT NULL, + tls INTEGER NOT NULL DEFAULT 1, + compress INTEGER NOT NULL DEFAULT 1, + redirect_path TEXT NOT NULL DEFAULT '', + created_at TEXT DEFAULT (datetime('now')) ); `); +// redirect_path was added later; ensure it exists on databases created before it. +try { db.exec("ALTER TABLE caddy ADD COLUMN redirect_path TEXT NOT NULL DEFAULT ''"); } catch { /* column already exists */ } + // Seed default settings — INSERT OR IGNORE writes a key only if it is absent. const DEFAULT_SETTINGS: Record = { azure_enabled: 'false', @@ -139,6 +143,7 @@ export interface CaddyRoute { upstream: string; tls: number; compress: number; + redirect_path: string; created_at: string; } @@ -146,16 +151,16 @@ export function getCaddyRoutes(): CaddyRoute[] { return db.prepare('SELECT * FROM caddy ORDER BY id ASC').all() as CaddyRoute[]; } -export function addCaddyRoute(hostname: string, upstream: string, tls: boolean, compress: boolean): CaddyRoute { +export function addCaddyRoute(hostname: string, upstream: string, tls: boolean, compress: boolean, redirectPath = ''): CaddyRoute { const { lastInsertRowid } = db.prepare( - 'INSERT INTO caddy (hostname, upstream, tls, compress) VALUES (?, ?, ?, ?)' - ).run(hostname, upstream, tls ? 1 : 0, compress ? 1 : 0); + 'INSERT INTO caddy (hostname, upstream, tls, compress, redirect_path) VALUES (?, ?, ?, ?, ?)' + ).run(hostname, upstream, tls ? 1 : 0, compress ? 1 : 0, redirectPath); return db.prepare('SELECT * FROM caddy WHERE id = ?').get(lastInsertRowid) as CaddyRoute; } -export function updateCaddyRoute(id: number, hostname: string, upstream: string, tls: boolean, compress: boolean): CaddyRoute { - db.prepare('UPDATE caddy SET hostname = ?, upstream = ?, tls = ?, compress = ? WHERE id = ?') - .run(hostname, upstream, tls ? 1 : 0, compress ? 1 : 0, id); +export function updateCaddyRoute(id: number, hostname: string, upstream: string, tls: boolean, compress: boolean, redirectPath = ''): CaddyRoute { + db.prepare('UPDATE caddy SET hostname = ?, upstream = ?, tls = ?, compress = ?, redirect_path = ? WHERE id = ?') + .run(hostname, upstream, tls ? 1 : 0, compress ? 1 : 0, redirectPath, id); return db.prepare('SELECT * FROM caddy WHERE id = ?').get(id) as CaddyRoute; } diff --git a/server.ts b/server.ts index 493a440..4ef13db 100644 --- a/server.ts +++ b/server.ts @@ -87,6 +87,12 @@ function buildCaddyfile(): string { lines.push(`${route.hostname} {`); if (route.compress) lines.push(' encode zstd gzip'); if (route.tls) lines.push(' tls internal'); + if (route.redirect_path) { + // Redirect only the bare root ('/') to the given path — other paths pass + // through to the backend unchanged (e.g. CheckMK at //check_mk/). + const target = route.redirect_path.startsWith('/') ? route.redirect_path : `/${route.redirect_path}`; + lines.push(` redir / ${target}`); + } lines.push(` reverse_proxy ${route.upstream} {`); // Standard forwarding headers for every backend. Caddy already sets the // X-Forwarded-* family and the Host header by default; these make them @@ -1200,13 +1206,13 @@ async function startServer() { app.post('/api/caddy/routes', requireAuth, async (req, res) => { try { if (!IS_CADDY_MANAGER) return res.status(403).json({ error: 'Caddy is managed by another instance.' }); - const { hostname, upstream, tls, compress } = req.body as { - hostname?: string; upstream?: string; tls?: boolean; compress?: boolean; + const { hostname, upstream, tls, compress, redirectPath } = req.body as { + hostname?: string; upstream?: string; tls?: boolean; compress?: boolean; redirectPath?: string; }; if (!hostname || !upstream) return res.status(400).json({ error: 'hostname and upstream are required.' }); if (getCaddyRoutes().some(r => r.hostname === hostname.trim())) return res.status(409).json({ error: `Route for ${hostname.trim()} already exists.` }); - const route = addCaddyRoute(hostname.trim(), upstream.trim(), tls !== false, compress !== false); + const route = addCaddyRoute(hostname.trim(), upstream.trim(), tls !== false, compress !== false, (redirectPath ?? '').trim()); db.prepare('INSERT INTO logs (id, timestamp, type, message, deviceId, userId) VALUES (?, ?, ?, ?, ?, ?)') .run(uid('log'), new Date().toISOString(), 'system', `Caddy route added: ${hostname.trim()} → ${upstream.trim()}`, null, req.user!.userId); @@ -1222,11 +1228,11 @@ async function startServer() { if (!IS_CADDY_MANAGER) return res.status(403).json({ error: 'Caddy is managed by another instance.' }); const id = Number(req.params.id); if (!id) return res.status(400).json({ error: 'Invalid route id.' }); - const { hostname, upstream, tls, compress } = req.body as { - hostname?: string; upstream?: string; tls?: boolean; compress?: boolean; + const { hostname, upstream, tls, compress, redirectPath } = req.body as { + hostname?: string; upstream?: string; tls?: boolean; compress?: boolean; redirectPath?: string; }; if (!hostname || !upstream) return res.status(400).json({ error: 'hostname and upstream are required.' }); - const route = updateCaddyRoute(id, hostname.trim(), upstream.trim(), tls !== false, compress !== false); + const route = updateCaddyRoute(id, hostname.trim(), upstream.trim(), tls !== false, compress !== false, (redirectPath ?? '').trim()); db.prepare('INSERT INTO logs (id, timestamp, type, message, deviceId, userId) VALUES (?, ?, ?, ?, ?, ?)') .run(uid('log'), new Date().toISOString(), 'system', `Caddy route updated: ${hostname.trim()} → ${upstream.trim()}`, null, req.user!.userId); diff --git a/src/components/Settings.tsx b/src/components/Settings.tsx index 3a34263..13bb65d 100644 --- a/src/components/Settings.tsx +++ b/src/components/Settings.tsx @@ -35,6 +35,7 @@ interface CaddyRoute { upstream: string; tls: number; compress: number; + redirect_path: string; } interface DbInfo { @@ -200,12 +201,14 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) { const [newUpstream, setNewUpstream] = useState(''); const [newTls, setNewTls] = useState(true); const [newCompress, setNewCompress] = useState(true); + const [newRedirect, setNewRedirect] = useState(''); const [editingRouteId, setEditingRouteId] = useState(null); const [editHostname, setEditHostname] = useState(''); const [editUpstream, setEditUpstream] = useState(''); const [editTls, setEditTls] = useState(true); const [editCompress, setEditCompress] = useState(true); + const [editRedirect, setEditRedirect] = useState(''); const [savingRoute, setSavingRoute] = useState(false); const [dbInfo, setDbInfo] = useState(null); @@ -381,7 +384,7 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) { try { const res = await authFetch('/api/caddy/routes', { method: 'POST', - body: JSON.stringify({ hostname: newHostname.trim(), upstream: newUpstream.trim(), tls: newTls, compress: newCompress }), + body: JSON.stringify({ hostname: newHostname.trim(), upstream: newUpstream.trim(), tls: newTls, compress: newCompress, redirectPath: newRedirect.trim() }), }); if (!res.ok) { const d = await res.json(); @@ -392,6 +395,7 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) { setNewUpstream(''); setNewTls(true); setNewCompress(true); + setNewRedirect(''); await loadCaddyRoutes(); } catch { setError('Network error adding route.'); @@ -420,6 +424,7 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) { setEditUpstream(r.upstream); setEditTls(r.tls === 1); setEditCompress(r.compress === 1); + setEditRedirect(r.redirect_path || ''); } function handleEditCancel() { @@ -432,7 +437,7 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) { try { const res = await authFetch(`/api/caddy/routes/${id}`, { method: 'PUT', - body: JSON.stringify({ hostname: editHostname.trim(), upstream: editUpstream.trim(), tls: editTls, compress: editCompress }), + body: JSON.stringify({ hostname: editHostname.trim(), upstream: editUpstream.trim(), tls: editTls, compress: editCompress, redirectPath: editRedirect.trim() }), }); if (!res.ok) { const d = await res.json(); @@ -965,6 +970,7 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) { {caddyRoutes.map((r: CaddyRoute) => (
{editingRouteId === r.id ? ( +
@@ -997,6 +1003,8 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
+ +
) : (
@@ -1005,6 +1013,7 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) { {r.upstream} {r.tls ? TLS : null} {r.compress ? GZ : null} + {r.redirect_path ? ↳ {r.redirect_path} : null}
+ +
)}
From 515052fbdadaa0c9b85e3dab6572bda0f3690f45 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Br=C3=BCckner?= Date: Wed, 10 Jun 2026 14:43:31 +0200 Subject: [PATCH 02/10] refactor: replace CADDY_MANAGER with DEPLOY_ENV for instance-role awareness DEPLOY_ENV=production now marks the primary instance globally - used for Caddy ownership, the Dev/Prod header badge, and Caddy UI gating. Removes build-time VITE_DEPLOY_ENV/import.meta.env.DEV from the header in favour of the runtime API response (isProduction field in /api/auth/config). --- deploy/proxmox-ghostgrid.sh | 4 ++-- server.ts | 20 ++++++++++---------- src/App.tsx | 4 +++- src/components/Header.tsx | 6 ++++-- src/components/Settings.tsx | 30 +++++++++++++++--------------- 5 files changed, 34 insertions(+), 30 deletions(-) diff --git a/deploy/proxmox-ghostgrid.sh b/deploy/proxmox-ghostgrid.sh index d6bd8fe..73a7ac0 100644 --- a/deploy/proxmox-ghostgrid.sh +++ b/deploy/proxmox-ghostgrid.sh @@ -190,8 +190,8 @@ msg_info "Creating .env file for each instance" for d in "${APP_DIR}" "${DEV_DIR}"; do SECRET="$(openssl rand -hex 32)" run "printf 'JWT_SECRET=\"%s\"\n' '${SECRET}' > ${d}/.env && chown ghostgrid:ghostgrid ${d}/.env && chmod 600 ${d}/.env" - # Only the production instance owns the shared Caddy (one Caddy per container). - [[ "$d" == "${APP_DIR}" ]] && run "printf 'CADDY_MANAGER=true\n' >> ${d}/.env" + # Only the production instance owns Caddy and shows "Production" in the UI. + [[ "$d" == "${APP_DIR}" ]] && run "printf 'DEPLOY_ENV=production\n' >> ${d}/.env" done msg_ok ".env files created (main + dev)" diff --git a/server.ts b/server.ts index 4ef13db..e54b966 100644 --- a/server.ts +++ b/server.ts @@ -15,10 +15,10 @@ const uid = (prefix: string) => `${prefix}-${Date.now()}-${Math.random().toStrin const JWT_SECRET = process.env.JWT_SECRET || 'ghostgrid-dev-secret-change-in-production'; const JWT_EXPIRY = '24h'; -// One Caddy serves the whole container; only the instance with CADDY_MANAGER=true -// owns it (pushes config, seeds routes, accepts route edits). The other instance -// must never push — POST /load replaces the entire config and would clobber it. -const IS_CADDY_MANAGER = process.env.CADDY_MANAGER === 'true'; +// DEPLOY_ENV=production marks the primary instance: it owns Caddy (pushes config, +// seeds routes, accepts route edits) and shows "Production" in the UI. The dev +// instance must never push to Caddy — POST /load replaces the entire config. +const IS_PRODUCTION = process.env.DEPLOY_ENV === 'production'; interface JwtPayload { userId: string; @@ -153,7 +153,7 @@ function importCaddyfileRoutes(userId?: string): void { } async function pushCaddyConfig(): Promise { - if (!IS_CADDY_MANAGER) return; + if (!IS_PRODUCTION) return; if (getSetting('caddy_enabled') !== 'true') return; const adminUrl = getSetting('caddy_admin_url') || 'http://localhost:2019'; const body = buildCaddyfile(); @@ -180,7 +180,7 @@ async function startServer() { console.log('[Init] Default admin user created — login: admin@ghostgrid.local / admin'); } - if (IS_CADDY_MANAGER && getSetting('caddy_enabled') === 'true' && getCaddyRoutes().length === 0) { + if (IS_PRODUCTION && getSetting('caddy_enabled') === 'true' && getCaddyRoutes().length === 0) { importCaddyfileRoutes(); } @@ -277,7 +277,7 @@ async function startServer() { effectiveRedirectUri, checkmkEnabled: getSetting('checkmk_enabled') === 'true', checkmkBaseUrl: cmkApiUrl.replace(/\/api\/.*$/, ''), - caddyManaged: IS_CADDY_MANAGER, + isProduction: IS_PRODUCTION, }); }); @@ -1205,7 +1205,7 @@ async function startServer() { app.post('/api/caddy/routes', requireAuth, async (req, res) => { try { - if (!IS_CADDY_MANAGER) return res.status(403).json({ error: 'Caddy is managed by another instance.' }); + if (!IS_PRODUCTION) return res.status(403).json({ error: 'Caddy is managed by another instance.' }); const { hostname, upstream, tls, compress, redirectPath } = req.body as { hostname?: string; upstream?: string; tls?: boolean; compress?: boolean; redirectPath?: string; }; @@ -1225,7 +1225,7 @@ async function startServer() { app.put('/api/caddy/routes/:id', requireAuth, async (req, res) => { try { - if (!IS_CADDY_MANAGER) return res.status(403).json({ error: 'Caddy is managed by another instance.' }); + if (!IS_PRODUCTION) return res.status(403).json({ error: 'Caddy is managed by another instance.' }); const id = Number(req.params.id); if (!id) return res.status(400).json({ error: 'Invalid route id.' }); const { hostname, upstream, tls, compress, redirectPath } = req.body as { @@ -1245,7 +1245,7 @@ async function startServer() { app.delete('/api/caddy/routes/:id', requireAuth, (req, res) => { try { - if (!IS_CADDY_MANAGER) return res.status(403).json({ error: 'Caddy is managed by another instance.' }); + if (!IS_PRODUCTION) return res.status(403).json({ error: 'Caddy is managed by another instance.' }); const id = Number(req.params.id); if (!id) return res.status(400).json({ error: 'Invalid route id.' }); const existing = getCaddyRouteById(id); diff --git a/src/App.tsx b/src/App.tsx index 0c520ba..1b1df52 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -55,6 +55,7 @@ export default function App() { const [inventoryHighlightDevice, setInventoryHighlightDevice] = useState(null); const [checkmkEnabled, setCheckmkEnabled] = useState(false); const [checkmkBaseUrl, setCheckmkBaseUrl] = useState(''); + const [isProduction, setIsProduction] = useState(false); useEffect(() => { const root = document.documentElement; @@ -143,7 +144,7 @@ export default function App() { if (bookingsRes.ok) setBookings(await bookingsRes.json()); if (logsRes.ok) setLogs(await logsRes.json()); if (linksRes.ok) setLinks(await linksRes.json()); - if (configRes.ok) { const cfg = await configRes.json(); setCheckmkEnabled(!!cfg.checkmkEnabled); setCheckmkBaseUrl(cfg.checkmkBaseUrl || ''); } + if (configRes.ok) { const cfg = await configRes.json(); setCheckmkEnabled(!!cfg.checkmkEnabled); setCheckmkBaseUrl(cfg.checkmkBaseUrl || ''); setIsProduction(!!cfg.isProduction); } } catch (err) { console.error('[App] Failed to load data:', err); } finally { @@ -484,6 +485,7 @@ export default function App() { theme={theme} onThemeToggle={() => setTheme(t => t === 'dark' ? 'light' : 'dark')} onLogout={handleLogout} + isProduction={isProduction} />
diff --git a/src/components/Header.tsx b/src/components/Header.tsx index 4a94187..a22abb1 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -11,6 +11,7 @@ interface HeaderProps { theme: 'dark' | 'light'; onThemeToggle: () => void; onLogout: () => void; + isProduction: boolean; } export default function Header({ @@ -22,6 +23,7 @@ export default function Header({ theme, onThemeToggle, onLogout, + isProduction, }: HeaderProps) { const [showMailInbox, setShowMailInbox] = useState(false); const [showBellDropdown, setShowBellDropdown] = useState(false); @@ -63,8 +65,8 @@ export default function Header({ {/* System Indicator */}
- - System: {(import.meta.env.VITE_DEPLOY_ENV === 'dev' || import.meta.env.DEV) ? 'Development' : 'Production'} + + System: {isProduction ? 'Production' : 'Development'}
{/* Mail Inbox */} diff --git a/src/components/Settings.tsx b/src/components/Settings.tsx index 13bb65d..2fbf0d1 100644 --- a/src/components/Settings.tsx +++ b/src/components/Settings.tsx @@ -193,7 +193,7 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) { const [caddyEnabled, setCaddyEnabled] = useState(false); const [caddyManaged, setCaddyManaged] = useState(true); - const [caddyAdminUrl, setCaddyAdminUrl] = useState('http://localhost:2019'); + const [caddyAdminUrl, setCaddyAdminUrl] = useState('http://127.0.0.1:2019'); const [caddyStatus, setCaddyStatus] = useState<'unknown' | 'available' | 'unavailable'>('unknown'); const [caddyRoutes, setCaddyRoutes] = useState([]); const [addingRoute, setAddingRoute] = useState(false); @@ -225,7 +225,7 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) { .then(r => r.json()) .then(d => { if (d.effectiveRedirectUri) setEffectiveRedirectUri(d.effectiveRedirectUri); - setCaddyManaged(d.caddyManaged !== false); + setCaddyManaged(d.isProduction !== false); }) .catch(() => {}); }, []); @@ -265,7 +265,7 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) { setSemaphoreApiToken(''); setSemaphoreProjectId(data.semaphore_project_id || ''); setCaddyEnabled(data.caddy_enabled === 'true'); - setCaddyAdminUrl(data.caddy_admin_url || 'http://localhost:2019'); + setCaddyAdminUrl(data.caddy_admin_url || 'http://127.0.0.1:2019'); } catch { setError('Network error loading settings.'); } finally { @@ -350,7 +350,7 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) { return; } const templates = await res.json() as any[]; - setSemaphoreTestResult(`Connected — ${templates.length} task template${templates.length !== 1 ? 's' : ''} found.`); + setSemaphoreTestResult(`Connected - ${templates.length} task template${templates.length !== 1 ? 's' : ''} found.`); } catch { setSemaphoreTestResult('Error: Network error connecting to Semaphore.'); } finally { @@ -371,7 +371,7 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) { const res = await authFetch('/api/caddy/routes'); if (res.ok) setCaddyRoutes(await res.json()); } catch {} - // Status check runs separately — purely informational, never blocks the list + // Status check runs separately - purely informational, never blocks the list authFetch('/api/caddy/status') .then(res => res.ok ? res.json() : null) .then(s => setCaddyStatus(s?.available ? 'available' : 'unavailable')) @@ -570,7 +570,7 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
)} - {/* Section tabs — switch between Integrations and System to keep the page light */} + {/* Section tabs - switch between Integrations and System to keep the page light */}
{([ { id: 'integrations', label: 'Integrations', icon: }, @@ -748,7 +748,7 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) { />
- + - - } /> + + } /> {/* Route list */} {caddyEnabled && (
- Prefix the upstream with https:// for TLS backends (e.g. Semaphore) — the certificate is not verified. + Prefix the upstream with https:// for TLS backends - the certificate is not verified. {caddyStatus === 'unavailable' && (

- Caddy Admin API not reachable — routes will be applied when Caddy starts. + Caddy Admin API not reachable - routes will be applied when Caddy starts.

)} @@ -1003,7 +1003,7 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
- +
) : (
@@ -1071,7 +1071,7 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) { {addingRoute ? 'Adding…' : 'Add'}
- +
)} @@ -1095,7 +1095,7 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {

- {dbInfo ? formatBytes(dbInfo.sizeBytes) : '—'} + {dbInfo ? formatBytes(dbInfo.sizeBytes) : '-'}

{dbInfo ? new Date(dbInfo.lastModified).toLocaleDateString() : 'Loading…'} @@ -1172,7 +1172,7 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {

- Import overwrites the entire database — this cannot be undone. + Import overwrites the entire database - this cannot be undone.

) : ( 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 { From 84bad8c0e6ba391843510e8e27a2dde47cf265f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Br=C3=BCckner?= Date: Wed, 10 Jun 2026 16:05:08 +0200 Subject: [PATCH 06/10] feat(auth): admin role management with logbook entries --- server.ts | 23 ++++++++++++++++ src/App.tsx | 12 ++++++++ src/components/LoginPage.tsx | 4 +-- src/components/UserDirectory.tsx | 47 ++++++++++++++++++++++++++++++-- 4 files changed, 81 insertions(+), 5 deletions(-) diff --git a/server.ts b/server.ts index ed74b44..8f625a6 100644 --- a/server.ts +++ b/server.ts @@ -405,6 +405,29 @@ async function startServer() { db.prepare('UPDATE users SET name = COALESCE(?, name), email = COALESCE(?, email) WHERE id = ?') .run(name ?? null, email ?? null, id); const updated = db.prepare('SELECT id, name, role, email FROM users WHERE id = ?').get(id) as User; + const changes: string[] = []; + if (name && name !== existing.name) changes.push(`name "${existing.name}" → "${name}"`); + if (email && email !== existing.email) changes.push(`email "${existing.email}" → "${email}"`); + if (changes.length > 0) { + addLog('system', `User ${updated.email} updated: ${changes.join(', ')}.`, { userId: req.user!.userId }); + } + res.json(updated); + } catch (err: any) { + res.status(500).json({ error: err.message }); + } + }); + + app.patch('/api/users/:id/role', requireAuth, requireAdmin, (req, res) => { + try { + const id = req.params.id; + const { role } = req.body as { role: string }; + const safeRole = role?.toLowerCase() === 'admin' ? 'admin' : 'User'; + const existing = db.prepare('SELECT id, name, email, role FROM users WHERE id = ?').get(id) as User | undefined; + if (!existing) return res.status(404).json({ error: 'User not found.' }); + if (existing.role === safeRole) return res.json(existing); + db.prepare('UPDATE users SET role = ? WHERE id = ?').run(safeRole, id); + const updated = db.prepare('SELECT id, name, role, email FROM users WHERE id = ?').get(id) as User; + addLog('system', `User ${updated.email} role changed to ${safeRole}.`, { userId: req.user!.userId }); res.json(updated); } catch (err: any) { res.status(500).json({ error: err.message }); diff --git a/src/App.tsx b/src/App.tsx index b788d7d..69e9803 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -361,6 +361,17 @@ export default function App() { } catch (err: any) { throw err; } }; + const handleSetUserRole = async (id: string, role: string) => { + try { + const res = await authFetch(`/api/users/${id}/role`, { method: 'PATCH', body: JSON.stringify({ role }) }); + if (res.ok) { + const updated: User = await res.json(); + setUsers(prev => prev.map(u => u.id === id ? updated : u)); + if (updated.id === currentUser?.id) setCurrentUser(updated); + } else { const d = await res.json(); throw new Error(d.error); } + } catch (err: any) { throw err; } + }; + // Quick-link handlers (shared link dashboard) const handleAddLink = async (newLink: Omit) => { try { @@ -620,6 +631,7 @@ export default function App() { bookings={bookings} onDeleteUser={handleDeleteUser} onUpdateUser={handleUpdateUser} + onSetRole={handleSetUserRole} /> )} {activeTab === 'logs' && ( diff --git a/src/components/LoginPage.tsx b/src/components/LoginPage.tsx index 9d50d8a..31be28e 100644 --- a/src/components/LoginPage.tsx +++ b/src/components/LoginPage.tsx @@ -102,7 +102,7 @@ export default function LoginPage({ onLogin, onNavigateToRegister, authError }: value={email} onChange={e => setEmail(e.target.value)} className="w-full bg-slate-900 border border-slate-700 rounded-lg px-3 py-2.5 text-sm text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-cyan-500/50 focus:border-cyan-500/50 transition-all" - placeholder="user@airit.rocks" + placeholder="" />
@@ -119,7 +119,7 @@ export default function LoginPage({ onLogin, onNavigateToRegister, authError }: value={password} onChange={e => setPassword(e.target.value)} className="w-full bg-slate-900 border border-slate-700 rounded-lg px-3 py-2.5 pr-10 text-sm text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-cyan-500/50 focus:border-cyan-500/50 transition-all" - placeholder="••••••••" + placeholder="" />
+ {roleError && ( +
+ + {roleError} + +
+ )} + {/* Search */}
@@ -240,7 +264,10 @@ export default function UserDirectory({ users, currentUser, bookings, onDeleteUs
- Operator + {user.role.toLowerCase() === 'admin' + ? Admin + : User + }
@@ -256,6 +283,20 @@ export default function UserDirectory({ users, currentUser, bookings, onDeleteUs {/* Action buttons */}
+ {isCurrentUserAdmin && !isMe && ( + + )}