diff --git a/server-db.ts b/server-db.ts index ec3660c..80dcd7a 100644 --- a/server-db.ts +++ b/server-db.ts @@ -96,7 +96,7 @@ const _defaultSettings: [string, string][] = [ ['azure_client_id', ''], ['azure_tenant_id', ''], ['azure_client_secret', ''], - ['azure_redirect_uri', ''], + ['azure_allowed_group', ''], ['checkmk_api_url', ''], ['checkmk_api_secret', ''], ['checkmk_sync_interval_ms', '60000'], diff --git a/server.ts b/server.ts index 4871e5b..422dc8d 100644 --- a/server.ts +++ b/server.ts @@ -164,7 +164,7 @@ async function startServer() { return res.redirect('/?auth_error=Azure+login+not+configured'); } const appUrl = process.env.APP_URL || `http://localhost:${PORT}`; - const redirectUri = getSetting('azure_redirect_uri') || `${appUrl}/api/auth/azure/callback`; + const redirectUri = `${appUrl}/api/auth/azure/callback`; try { const authCodeUrl = await msalClient.getAuthCodeUrl({ scopes: ['openid', 'profile', 'email'], @@ -192,7 +192,7 @@ async function startServer() { return res.redirect('/?auth_error=Azure+login+not+configured'); } const appUrl = process.env.APP_URL || `http://localhost:${PORT}`; - const redirectUri = getSetting('azure_redirect_uri') || `${appUrl}/api/auth/azure/callback`; + const redirectUri = `${appUrl}/api/auth/azure/callback`; try { const result = await msalClient.acquireTokenByCode({ code: String(code), @@ -204,6 +204,13 @@ async function startServer() { if (!email) { return res.redirect('/?auth_error=No+email+returned+by+Microsoft'); } + const allowedGroup = getSetting('azure_allowed_group'); + if (allowedGroup) { + const claims = result.idTokenClaims as { groups?: string[] } | undefined; + if (!claims?.groups?.includes(allowedGroup)) { + return res.redirect('/?auth_error=Not+a+member+of+the+required+group'); + } + } let user = db.prepare('SELECT id, name, role, email FROM users WHERE email = ?').get(email) as User | undefined; if (!user) { const id = uid("u"); @@ -258,6 +265,41 @@ async function startServer() { } }); + app.put('/api/users/:id', requireAuth, (req, res) => { + try { + const id = req.params.id; + const { name, email } = req.body as { name?: string; email?: string }; + if (!name && !email) return res.status(400).json({ error: 'Nothing to update.' }); + const existing = db.prepare('SELECT id, name, email FROM users WHERE id = ?').get(id) as User | undefined; + if (!existing) return res.status(404).json({ error: 'User not found.' }); + if (email && email !== existing.email) { + const dupe = db.prepare('SELECT id FROM users WHERE email = ? AND id != ?').get(email, id); + if (dupe) return res.status(409).json({ error: 'Email already in use.' }); + } + 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; + res.json(updated); + } catch (err: any) { + res.status(500).json({ error: err.message }); + } + }); + + app.delete('/api/users/:id', requireAuth, (req, res) => { + try { + const id = req.params.id; + if (id === req.user!.userId) return res.status(400).json({ error: 'You cannot delete your own account.' }); + const existing = db.prepare('SELECT id FROM users WHERE id = ?').get(id); + if (!existing) return res.status(404).json({ error: 'User not found.' }); + const count = (db.prepare('SELECT COUNT(*) as n FROM users').get() as { n: number }).n; + if (count <= 1) return res.status(400).json({ error: 'Cannot delete the last user.' }); + db.prepare('DELETE FROM users WHERE id = ?').run(id); + res.json({ success: true }); + } catch (err: any) { + res.status(500).json({ error: err.message }); + } + }); + // ------------------------------------------------------------- // RESTFUL API: Devices / Inventory // ------------------------------------------------------------- @@ -656,13 +698,20 @@ async function startServer() { .all() as { id: string; hostname: string; checkMkUrl: string }[]; for (const dev of rows) { try { - // TODO(checkmk): query the host's hard state from the CheckMK API using the - // automation secret, map 0 (UP) -> 'online' and anything else -> 'offline': - // const res = await fetch(`${CHECKMK_API_URL}/objects/host/${host}/actions/...`, - // { headers: { Authorization: `Bearer automation ${CHECKMK_API_SECRET}` } }); - // const state = (await res.json()).extensions.state === 0 ? 'online' : 'offline'; - // db.prepare('UPDATE devices SET status = ?, lastCheckedAt = ? WHERE id = ?') - // .run(state, new Date().toISOString(), dev.id); + const cmkHost = (() => { + try { return new URL(dev.checkMkUrl).searchParams.get('host') ?? dev.hostname; } + catch { return dev.hostname; } + })(); + const res = await fetch( + `${CHECKMK_API_URL}/objects/host/${encodeURIComponent(cmkHost)}`, + { headers: { Authorization: `Bearer automation ${CHECKMK_API_SECRET}`, Accept: 'application/json' } } + ); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const data = await res.json(); + const hardState: number = data?.extensions?.state ?? -1; + const newStatus = hardState === 0 ? 'online' : 'offline'; + db.prepare('UPDATE devices SET status = ?, lastCheckedAt = ? WHERE id = ?') + .run(newStatus, new Date().toISOString(), dev.id); } catch (err) { console.error(`[CheckMK] Status sync failed for ${dev.hostname}:`, err); } diff --git a/src/App.tsx b/src/App.tsx index 995af59..ce64c23 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -338,6 +338,25 @@ export default function App() { } catch (err) { console.error('[App] Error adding log:', err); } }; + // User handlers + const handleDeleteUser = async (id: string) => { + try { + const res = await authFetch(`/api/users/${id}`, { method: 'DELETE' }); + if (res.ok) setUsers(prev => prev.filter(u => u.id !== id)); + else { const d = await res.json(); throw new Error(d.error); } + } catch (err: any) { throw err; } + }; + + const handleUpdateUser = async (id: string, name: string, email: string) => { + try { + const res = await authFetch(`/api/users/${id}`, { method: 'PUT', body: JSON.stringify({ name, email }) }); + if (res.ok) { + const updated = await res.json(); + setUsers(prev => prev.map(u => u.id === id ? updated : u)); + } 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 { @@ -590,6 +609,8 @@ export default function App() { users={users} currentUser={currentUser} bookings={bookings} + onDeleteUser={handleDeleteUser} + onUpdateUser={handleUpdateUser} /> )} {activeTab === 'logs' && ( diff --git a/src/components/Settings.tsx b/src/components/Settings.tsx index 3307512..de94a07 100644 --- a/src/components/Settings.tsx +++ b/src/components/Settings.tsx @@ -3,7 +3,7 @@ import { authFetch } from '../lib/auth'; import { User } from '../types'; import { Shield, Activity, Save, CheckCircle, AlertCircle, Eye, EyeOff, - Settings2, ExternalLink, KeyRound, Globe, Clock, ChevronRight, + Settings2, KeyRound, Globe, Clock, ChevronRight, Copy, Users, } from 'lucide-react'; const SECRET_SENTINEL = '__SET__'; @@ -13,7 +13,7 @@ interface RawSettings { azure_client_id: string; azure_tenant_id: string; azure_client_secret: string; - azure_redirect_uri: string; + azure_allowed_group: string; checkmk_api_url: string; checkmk_api_secret: string; checkmk_sync_interval_ms: string; @@ -28,7 +28,7 @@ function Label({ children }: { children: React.ReactNode }) { } function Hint({ children }: { children: React.ReactNode }) { - return

{children}

; + return

{children}

; } function ConfiguredBadge() { @@ -68,7 +68,7 @@ function Input({ value, onChange, placeholder, monospace, icon }: { return (
{icon && ( - + {icon} )} @@ -92,7 +92,7 @@ function SecretInput({ value, onChange, alreadySet, show, onToggleShow }: { }) { return (
- + {show ? : } @@ -119,7 +119,7 @@ function SectionCard({ accentColor, children }: { children: React.ReactNode; }) { return ( -
+
{children} @@ -133,6 +133,7 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) { const [saving, setSaving] = useState(false); const [error, setError] = useState(''); const [successMsg, setSuccessMsg] = useState(''); + const [copied, setCopied] = useState(false); const [effectiveRedirectUri, setEffectiveRedirectUri] = useState(''); @@ -141,7 +142,7 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) { const [azureTenantId, setAzureTenantId] = useState(''); const [azureClientSecret, setAzureClientSecret] = useState(''); const [azureSecretSet, setAzureSecretSet] = useState(false); - const [azureRedirectUri, setAzureRedirectUri] = useState(''); + const [azureAllowedGroup, setAzureAllowedGroup] = useState(''); const [showAzureSecret, setShowAzureSecret] = useState(false); const [checkmkApiUrl, setCheckmkApiUrl] = useState(''); @@ -175,7 +176,7 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) { setAzureTenantId(data.azure_tenant_id || ''); setAzureSecretSet(data.azure_client_secret === SECRET_SENTINEL); setAzureClientSecret(''); - setAzureRedirectUri(data.azure_redirect_uri || ''); + setAzureAllowedGroup(data.azure_allowed_group || ''); setCheckmkApiUrl(data.checkmk_api_url || ''); setCheckmkSecretSet(data.checkmk_api_secret === SECRET_SENTINEL); setCheckmkApiSecret(''); @@ -195,7 +196,7 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) { azure_enabled: azureEnabled ? 'true' : 'false', azure_client_id: azureClientId, azure_tenant_id: azureTenantId, - azure_redirect_uri: azureRedirectUri, + azure_allowed_group: azureAllowedGroup, checkmk_api_url: checkmkApiUrl, checkmk_sync_interval_ms: checkmkSyncInterval, }; @@ -222,6 +223,14 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) { } } + function copyRedirectUri() { + if (!effectiveRedirectUri) return; + navigator.clipboard.writeText(effectiveRedirectUri).then(() => { + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }).catch(() => {}); + } + if (loading) { return (
@@ -261,7 +270,6 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) { {/* ── Microsoft Entra ID ── */} - {/* Card header */}
@@ -280,7 +288,6 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {

OAuth 2.0 SSO for organizational accounts

- {/* Toggle */}
{azureEnabled ? 'ENABLED' : 'DISABLED'} @@ -297,7 +304,6 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
- {/* Fields */}
@@ -317,26 +323,38 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) { onToggleShow={() => setShowAzureSecret(v => !v)} /> - + } + icon={} /> - {effectiveRedirectUri && ( -
- -
-

Register this URI in Azure Portal

-

{effectiveRedirectUri}

-

App registrations → Authentication → Redirect URIs

-
-
- )}
+ + {/* Redirect URI – read-only */} + {effectiveRedirectUri && azureEnabled && ( +
+
+

Required Redirect URI

+

{effectiveRedirectUri}

+

Register this in Azure Portal → App registrations → Authentication → Redirect URIs

+
+ +
+ )} {/* ── CheckMK ── */} @@ -403,7 +421,7 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) { +
+
+ {error && ( +
+ + {error} +
+ )} +
+ + setName(e.target.value)} + required + 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" + /> +
+
+ + setEmail(e.target.value)} + required + 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 font-mono" + /> +
+
+ + +
+
+
+
+ ); +} + +export default function UserDirectory({ users, currentUser, bookings, onDeleteUser, onUpdateUser }: UserDirectoryProps) { const [search, setSearch] = useState(''); + const [editingUser, setEditingUser] = useState(null); + const [deletingId, setDeletingId] = useState(null); const bookingCount = useMemo(() => { const map = new Map(); @@ -63,6 +147,16 @@ export default function UserDirectory({ users, currentUser, bookings }: UserDire ); }, [users, search]); + async function handleDelete(id: string) { + setDeletingId(id); + try { await onDeleteUser(id); } finally { setDeletingId(null); } + } + + async function handleSaveEdit(name: string, email: string) { + if (!editingUser) return; + await onUpdateUser(editingUser.id, name, email); + } + return (
@@ -77,9 +171,8 @@ export default function UserDirectory({ users, currentUser, bookings }: UserDire Registered Operators

-Everyone with an account on this box. booking counts come straight from the shared reservation pool - no shadow IT here. + Everyone with an account on this box. Booking counts come straight from the shared reservation pool.

-
@@ -99,12 +192,12 @@ Everyone with an account on this box. booking counts come straight from the shar {/* Search */}
- + setSearch(e.target.value)} + onChange={e => setSearch(e.target.value)} className="w-full bg-[#1E293B] text-slate-100 border border-slate-800 rounded-xl pl-9 pr-4 py-2 text-xs focus:outline-none focus:border-emerald-600" />
@@ -118,6 +211,7 @@ Everyone with an account on this box. booking counts come straight from the shar const isMe = user.id === currentUser.id; const total = bookingCount.get(user.id) ?? 0; const active = activeCount.get(user.id) ?? 0; + const isDeleting = deletingId === user.id; return (
-
+
Operator -
- - - {total} - - - - {active} - +
+
+ + + {total} + + + + {active} + +
+ + {/* Action buttons */} +
+ + {!isMe && ( + + )} +
@@ -164,6 +283,14 @@ Everyone with an account on this box. booking counts come straight from the shar })}
)} + + {editingUser && ( + setEditingUser(null)} + onSave={handleSaveEdit} + /> + )}
); }