/* ProdDash admin client. Everything here is server-wide: module enable/disable and admin config (written to config/modules.json, module re-inited on save), plus named layout management. Behind the admin PIN when one is set — a shell setting of its own, nothing to do with a module's PIN (Slack's send PIN, say). */ const securitySection = document.getElementById('security-section'); const loginCard = document.getElementById('login-card'); const loginStatus = document.getElementById('login-status'); const passcodeInput = document.getElementById('passcode'); const modulesSection = document.getElementById('modules-section'); const modulesList = document.getElementById('modules-list'); const layoutsSection = document.getElementById('layouts-section'); const layoutsList = document.getElementById('layouts-list'); const toastEl = document.getElementById('toast'); const availableSection = document.getElementById('available-section'); const availableList = document.getElementById('available-list'); const catalogStatus = document.getElementById('catalog-status'); const updateCard = document.getElementById('update-card'); const updateTitle = document.getElementById('update-title'); const updateCopy = document.getElementById('update-copy'); const updateStatus = document.getElementById('update-status'); const updateBtn = document.getElementById('update-btn'); const updateChanges = document.getElementById('update-changes'); const shellVersionEl = document.getElementById('shell-version'); const themeSection = document.getElementById('theme-section'); const themeSwatches = document.getElementById('theme-swatches'); const themeStatus = document.getElementById('theme-status'); /** The shell version the page loaded with — the update flow watches it change. */ let shellVersion = ''; let toastTimer = null; function toast(msg, isError = false) { toastEl.textContent = msg; toastEl.classList.toggle('error', isError); toastEl.hidden = false; clearTimeout(toastTimer); toastTimer = setTimeout(() => { toastEl.hidden = true; }, 3200); } async function api(path, opts = {}) { const res = await fetch(path, { ...opts, headers: { 'Content-Type': 'application/json', ...(opts.headers || {}) }, }); const body = await res.json().catch(() => ({})); if (!res.ok) { const err = new Error(body.error || `Request failed (${res.status})`); err.status = res.status; err.authRequired = Boolean(body.authRequired); throw err; } return body; } /* ── login ──────────────────────────────────────────────────────────── */ function showLogin() { loginCard.hidden = false; modulesSection.hidden = true; availableSection.hidden = true; layoutsSection.hidden = true; updateCard.hidden = true; themeSection.hidden = true; securitySection.hidden = true; passcodeInput.value = ''; passcodeInput.focus(); } let loggingIn = false; async function login() { if (loggingIn) return; loggingIn = true; loginStatus.textContent = ''; loginStatus.className = 'admin-status'; try { await api('/api/admin/login', { method: 'POST', body: JSON.stringify({ pin: passcodeInput.value }) }); passcodeInput.value = ''; loginCard.hidden = true; await loadState(); } catch (e) { passcodeInput.value = ''; loginStatus.textContent = e.message; loginStatus.className = 'admin-status error'; passcodeInput.focus(); } finally { loggingIn = false; } } document.getElementById('login-btn').addEventListener('click', login); passcodeInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') login(); }); // Six digits is the whole PIN, so there is nothing left to press. passcodeInput.addEventListener('input', () => { passcodeInput.value = passcodeInput.value.replace(/\D/g, '').slice(0, 6); if (passcodeInput.value.length === 6) login(); }); /* ── the admin PIN ───────────────────────────────────────────────────── */ const pinCurrentField = document.getElementById('pin-current-field'); const pinCurrent = document.getElementById('pin-current'); const pinNew = document.getElementById('pin-new'); const pinRepeat = document.getElementById('pin-repeat'); const pinSaveBtn = document.getElementById('pin-save'); const pinClearBtn = document.getElementById('pin-clear'); const pinStatus = document.getElementById('pin-status'); const pinStateEl = document.getElementById('pin-state'); for (const input of [pinCurrent, pinNew, pinRepeat]) { input.addEventListener('input', () => { input.value = input.value.replace(/\D/g, '').slice(0, 6); }); } /** Paint the section for "no PIN yet" or "one is set". */ function renderPin(state) { const set = Boolean(state.pinSet); const fromEnv = Boolean(state.pinFromEnv); securitySection.hidden = false; pinCurrentField.hidden = !set; pinClearBtn.hidden = !set; document.getElementById('pin-new-label').textContent = set ? 'New PIN' : 'PIN'; pinSaveBtn.textContent = set ? 'Change PIN' : 'Set PIN'; pinStateEl.textContent = fromEnv ? 'A passcode is set in this server’s environment (PRODDASH_PASSCODE) and takes precedence. Anything set here applies once that is removed.' : set ? 'Admin is locked. This page asks for the PIN, and so does every admin request.' : 'Admin is open to anyone on the network. Set a 6-digit PIN to lock it.'; } function pinMessage(text, bad = false) { pinStatus.textContent = text; pinStatus.className = 'admin-status' + (bad ? ' error' : ' ok'); } async function savePin(clearing = false) { pinStatus.textContent = ''; pinStatus.className = 'admin-status'; const current = pinCurrent.value; const next = clearing ? '' : pinNew.value; if (!clearing) { if (!/^\d{6}$/.test(next)) return pinMessage('The PIN must be exactly 6 digits.', true); if (next !== pinRepeat.value) return pinMessage('The two PINs are not the same.', true); } if (!pinCurrentField.hidden && !/^\d{6}$/.test(current)) { return pinMessage('Enter the current PIN to change it.', true); } pinSaveBtn.disabled = true; pinClearBtn.disabled = true; try { const body = await api('/api/admin/pin', { method: 'POST', body: JSON.stringify({ pin: next, current }) }); for (const i of [pinCurrent, pinNew, pinRepeat]) i.value = ''; pinMessage(body.pinSet ? 'PIN saved. It is asked for from the next visit.' : 'PIN removed — admin is open again.'); await loadState(); } catch (e) { pinMessage(e.message, true); } finally { pinSaveBtn.disabled = false; pinClearBtn.disabled = false; } } pinSaveBtn.addEventListener('click', () => savePin(false)); pinClearBtn.addEventListener('click', () => { if (confirm('Turn the admin PIN off? Anyone on the network could then change this server’s settings.')) savePin(true); }); pinRepeat.addEventListener('keydown', (e) => { if (e.key === 'Enter') savePin(false); }); /* ── module cards ───────────────────────────────────────────────────── */ /** moduleId -> { dot, healthEl, toggle } for cheap in-place status refreshes */ const liveEls = new Map(); function healthClass(mod) { if (!mod.enabled) return ''; if (mod.mountError) return 'error'; return mod.health ? mod.health.status : 'ok'; } function healthText(mod) { if (!mod.enabled) return 'Disabled — hidden from the dashboard picker, server routes unmounted.'; if (mod.mountError) return ''; if (mod.health) return mod.health.message || mod.health.status; return mod.hasServer ? 'Mounted.' : 'Client-only module (no server part).'; } function fieldFor(key, spec, value, passwordSet, moduleId) { const type = spec?.type || 'string'; const label = spec?.label || key; const field = document.createElement('label'); field.className = 'field' + (type === 'boolean' ? ' check' : '') + (type === 'switch' ? ' switch-field' : ''); let read; // For boolean/switch fields, the control other fields may depend on // (showWhen) — returned so the caller can wire visibility. let control = null; // For selects: the element (optionsDependsOn sources) and, with an // optionsRoute, a function that (re)loads the options. let selectEl = null; let refetch = null; if (type === 'switch') { // Same toggle as the module enable switch, inline with its label. const toggle = document.createElement('span'); toggle.className = 'switch'; const input = document.createElement('input'); input.type = 'checkbox'; input.checked = Boolean(value); const track = document.createElement('span'); track.className = 'track'; toggle.append(input, track); const text = document.createElement('span'); text.className = 'switch-label'; text.textContent = label; field.append(toggle, text); read = () => input.checked; control = input; } else if (type === 'boolean') { const input = document.createElement('input'); input.type = 'checkbox'; input.checked = Boolean(value); field.append(input, document.createTextNode(label)); read = () => input.checked; control = input; } else if (type === 'endpoint') { // One consistent "Host / IP : Port" control for every module's upstream. const span = document.createElement('span'); span.textContent = label; const row = document.createElement('span'); row.className = 'endpoint-row'; const host = document.createElement('input'); host.type = 'text'; host.className = 'ep-host'; host.placeholder = 'Host / IP'; host.autocomplete = 'off'; host.spellcheck = false; host.value = value?.host ? String(value.host) : ''; const sep = document.createElement('span'); sep.className = 'ep-sep'; sep.textContent = ':'; const port = document.createElement('input'); port.type = 'number'; port.className = 'ep-port'; port.placeholder = 'Port'; port.min = '1'; port.max = '65535'; port.value = value?.port ? String(value.port) : ''; row.append(host, sep, port); field.append(span, row); read = () => ({ host: host.value.trim(), port: Number(port.value) || 0 }); } else if (type === 'select' && (Array.isArray(spec.options) || spec.optionsRoute)) { const span = document.createElement('span'); span.textContent = label; const select = document.createElement('select'); const optValue = (opt) => String(typeof opt === 'object' ? opt.value : opt); const fill = (options) => { // Keep what the admin has picked but not yet saved across a refill // (a dependent select refetching), else the saved value. const saved = String(value ?? ''); const wanted = select.options.length ? select.value : saved; select.innerHTML = ''; // The saved value must stay selectable even when it isn't among the // live options (device unplugged, module route down). if (saved && !options.some((opt) => optValue(opt) === saved)) { options = [{ value: saved, label: `${saved} (saved)` }, ...options]; } // Nothing saved yet → a blank "choose" entry, unless the module already // offers a blank option of its own (an "Automatic" default, say). if (!saved && !options.some((opt) => optValue(opt) === '')) { options = [{ value: '', label: '— choose —' }, ...options]; } // Options may carry a `group` (a folder path, a device class): options // sharing one render under a single , in first-seen order. const groups = new Map(); for (const opt of options) { const o = document.createElement('option'); o.value = optValue(opt); o.textContent = String(typeof opt === 'object' ? (opt.label ?? opt.value) : opt); const g = typeof opt === 'object' && opt.group ? String(opt.group) : ''; if (!g) { select.appendChild(o); continue; } if (!groups.has(g)) { const og = document.createElement('optgroup'); og.label = g; groups.set(g, og); select.appendChild(og); } groups.get(g).appendChild(o); } select.value = options.some((opt) => optValue(opt) === wanted) ? wanted : saved; }; fill(Array.isArray(spec.options) ? spec.options : []); if (spec.optionsRoute && moduleId) { // Options discovered at runtime (audio devices, ports, sources…): // the module serves them from one of its own routes. With // optionsDependsOn the caller triggers the fetch (and re-fetches when // that other field changes), passing its value as a query parameter. refetch = (query) => { const qs = query && Object.keys(query).length ? `?${new URLSearchParams(query)}` : ''; return fetch(`/api/modules/${encodeURIComponent(moduleId)}${spec.optionsRoute}${qs}`) .then((res) => (res.ok ? res.json() : null)) .then((body) => { if (Array.isArray(body?.options)) fill(body.options); }) .catch(() => { /* keep the static/saved options */ }); }; if (!spec.optionsDependsOn) refetch(); } field.append(span, select); read = () => select.value; selectEl = select; } else if (type === 'multiselect' && (Array.isArray(spec.options) || spec.optionsRoute)) { // Several picks from one list — a switch per option, stored as an array // of values. A saved value the list no longer offers stays checked and // marked, so a channel the account has left isn't dropped in silence. field.classList.add('multiselect-field'); // The wrapper is a