diff --git a/README.md b/README.md
index 8856226..a76cf4e 100644
--- a/README.md
+++ b/README.md
@@ -99,9 +99,18 @@ with this machine's shell settings — the same keys as the checked-in
| Key | Meaning |
| --- | --- |
| `port` | Port ProdDash serves on (default `24500`). The macOS launcher writes this one, so the port it is set to holds however ProdDash is started. |
-| `adminPasscode` | If set, the admin page asks for this shared passcode. Leave `""` for none — it's a plain-HTTP LAN tool. |
+| `adminPin` | The admin PIN, as a scrypt hash. **Set it in the admin page's Admin PIN section**, not by hand — 6 digits, and only the hash is stored. Absent means admin is open to anyone on the network. |
+| `adminPasscode` | An older free-text passcode. Still honoured, so an existing install keeps working; new installs should use `adminPin`. |
| `theme` | The dashboard palette: `booth` (default), `harbor`, `graphite`, `ember` or `daylight`. The admin page's **Theme** section writes this one — see [Themes](#themes). |
+**Locked out?** Delete `adminPin` from the data directory's `proddash.json`
+and restart, or start the server with `PRODDASH_PASSCODE` set — that passcode
+overrides the PIN and always gets you in.
+
+Only `/admin` and the admin API are behind the PIN. Dashboards stay open, so a
+wall display needs no PIN, and it is unrelated to a module's own PIN (Slack's
+4–8 digit send PIN, say), which guards that module's actions only.
+
Environment variables override both files: `PORT`, `PRODDASH_PASSCODE`, and
`PRODDASH_DATA_DIR` to put the data directory somewhere else (a relative path
is taken from the app folder; `PRODDASH_DATA_DIR=config` restores the old
diff --git a/public/admin/admin.css b/public/admin/admin.css
index 0ce9b0b..7eec5df 100644
--- a/public/admin/admin.css
+++ b/public/admin/admin.css
@@ -512,3 +512,49 @@ a.btn { text-decoration: none; }
color: var(--accent);
}
#theme-status { margin-top: 4px; }
+
+/* ── the admin PIN ── */
+
+/* Digits, evenly spaced, so six of them read as a PIN rather than a word. */
+.pin-input {
+ font-variant-numeric: tabular-nums;
+ letter-spacing: 0.35em;
+ text-align: center;
+ max-width: 9em;
+}
+.pin-form {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: flex-end;
+ gap: 12px;
+}
+.pin-field {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ font-size: 12px;
+ color: var(--muted);
+}
+.pin-field[hidden] { display: none; }
+.pin-field input {
+ padding: 8px 10px;
+ font-size: 14px;
+ color: var(--text);
+ background: var(--bg);
+ border: 1px solid var(--border);
+ border-radius: 7px;
+}
+.pin-field input:focus { outline: none; border-color: var(--accent); }
+.pin-actions {
+ display: flex;
+ gap: 8px;
+ margin-left: auto;
+}
+.pin-note { margin-top: 12px; }
+.pin-note code {
+ font-size: 0.92em;
+ padding: 1px 5px;
+ border-radius: 4px;
+ background: var(--bg);
+ border: 1px solid var(--border);
+}
diff --git a/public/admin/admin.js b/public/admin/admin.js
index dfc1d02..a76cbb1 100644
--- a/public/admin/admin.js
+++ b/public/admin/admin.js
@@ -2,8 +2,10 @@
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. Guarded by the shared passcode when one is set. */
+ 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');
@@ -62,20 +64,30 @@ function showLogin() {
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({ passcode: passcodeInput.value }) });
+ 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;
}
}
@@ -83,6 +95,83 @@ 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.';
+ if (state.dataDir) document.getElementById('pin-file').textContent = state.dataDir + '/proddash.json';
+}
+
+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 ───────────────────────────────────────────────────── */
@@ -856,6 +945,7 @@ async function loadState() {
for (const mod of state.modules || []) {
modulesList.appendChild(buildModuleCard(mod));
}
+ renderPin(state);
loadTheme();
loadLayouts();
loadCatalog(false);
diff --git a/public/admin/index.html b/public/admin/index.html
index ecccb6c..a327283 100644
--- a/public/admin/index.html
+++ b/public/admin/index.html
@@ -18,10 +18,11 @@
+
+
+ Admin PIN
+
+
+
+
Only this page and the admin API ask for it — dashboards stay
+ open, and it is nothing to do with a module's own PIN, such as Slack's send PIN. Forgotten?
+ Remove adminPin from proddash.json in the settings
+ folder and restart.
+
+
+
diff --git a/server.js b/server.js
index 4c77a57..d937b24 100644
--- a/server.js
+++ b/server.js
@@ -12,7 +12,8 @@
* Configure via config/proddash.json (defaults, in the repo), an optional
* proddash.json in the data directory (this machine's overrides), or env vars:
* PORT (default: value in proddash.json, else 24500)
- * PRODDASH_PASSCODE (overrides adminPasscode in proddash.json)
+ * PRODDASH_PASSCODE (a passcode that overrides the stored admin PIN —
+ * also the way back in if the PIN is ever forgotten)
* PRODDASH_DATA_DIR (where module config + layouts are kept; see below)
*/
'use strict';
@@ -1130,12 +1131,109 @@ function restartServer(reason) {
/* ── admin API ──────────────────────────────────────────────────────── */
+/* Admin is behind a 6-digit PIN, set in the admin page and kept in this
+ machine's proddash.json as a scrypt hash — never in the clear, because that
+ file gets copied between machines. A dashboard needs no PIN; only /admin
+ and the admin API do. Two ways back in if it is ever forgotten: set
+ PRODDASH_PASSCODE in the environment, or delete "adminPin" from
+ proddash.json in the data directory and restart. `adminPasscode` (the older
+ free-text setting) still works so an existing install keeps running. */
+const PIN_RE = /^\d{6}$/;
+const PIN_MAX_WRONG = 5;
+const PIN_LOCKOUT_MS = 30000;
const ADMIN_PASSCODE = String(process.env.PRODDASH_PASSCODE || shellConfig.adminPasscode || '');
-/** Session token: regenerating it every boot is fine — admins just re-enter the passcode. */
+/** Session token: regenerating it every boot is fine — admins just re-enter the PIN. */
const adminToken = crypto.randomBytes(24).toString('hex');
+/** The stored PIN, as "scrypt$$"; '' when admin is open. */
+let adminPinHash = String(shellConfig.adminPin || '');
+const pinAttempts = new Map(); // ip → { count, lockedUntil }
+
+function hashPin(pin, salt = crypto.randomBytes(16).toString('hex')) {
+ return `scrypt$${salt}$${crypto.scryptSync(String(pin), salt, 32).toString('hex')}`;
+}
+
+/** Constant-time check of a PIN against the stored hash. */
+function pinMatches(given, stored) {
+ const parts = String(stored || '').split('$');
+ if (parts.length !== 3 || parts[0] !== 'scrypt') return false;
+ const expected = Buffer.from(parts[2], 'hex');
+ const actual = crypto.scryptSync(String(given), parts[1], expected.length);
+ return expected.length === actual.length && crypto.timingSafeEqual(expected, actual);
+}
+
+/** Is a credential required at all? */
+function adminLocked() {
+ return Boolean(ADMIN_PASSCODE || adminPinHash);
+}
+
+/** True when `given` is the credential in force (the env passcode, or the PIN). */
+function credentialOk(given) {
+ const s = String(given ?? '');
+ if (!s) return false;
+ if (ADMIN_PASSCODE) {
+ const a = Buffer.from(s);
+ const b = Buffer.from(ADMIN_PASSCODE);
+ if (a.length === b.length && crypto.timingSafeEqual(a, b)) return true;
+ }
+ return adminPinHash ? pinMatches(s, adminPinHash) : false;
+}
+
+function clientIp(req) {
+ return String(req.socket?.remoteAddress || 'unknown');
+}
+
+/** Seconds of lockout left for this address, or 0. */
+function lockoutLeft(ip) {
+ const a = pinAttempts.get(ip);
+ if (!a || !a.lockedUntil) return 0;
+ const left = a.lockedUntil - Date.now();
+ if (left <= 0) {
+ pinAttempts.delete(ip);
+ return 0;
+ }
+ return Math.ceil(left / 1000);
+}
+
+function noteWrongPin(ip) {
+ const a = pinAttempts.get(ip) || { count: 0, lockedUntil: 0 };
+ a.count += 1;
+ if (a.count >= PIN_MAX_WRONG) {
+ a.count = 0;
+ a.lockedUntil = Date.now() + PIN_LOCKOUT_MS;
+ console.warn(`[proddash] ${PIN_MAX_WRONG} wrong admin PINs from ${ip} — locked out for ${PIN_LOCKOUT_MS / 1000} s`);
+ }
+ pinAttempts.set(ip, a);
+}
+
+/**
+ * Store a new PIN (6 digits), or '' to open admin up again. Keeps every other
+ * key in this machine's proddash.json. Throws an httpError the route reports.
+ */
+function setAdminPin(pin) {
+ const next = String(pin ?? '');
+ if (next && !PIN_RE.test(next)) throw httpError(400, 'The PIN must be exactly 6 digits.');
+ const file = path.join(DATA_DIR, 'proddash.json');
+ let existing = {};
+ if (fs.existsSync(file)) {
+ existing = readJson(file, null);
+ if (!existing || typeof existing !== 'object' || Array.isArray(existing)) {
+ throw httpError(500, `${file} is not a JSON object — fix it by hand before changing the PIN.`);
+ }
+ }
+ const hash = next ? hashPin(next) : '';
+ const out = { ...existing };
+ if (hash) out.adminPin = hash;
+ else delete out.adminPin;
+ writeJson(file, out);
+ adminPinHash = hash;
+ shellConfig.adminPin = hash;
+ pinAttempts.clear();
+ console.log(`[proddash] admin PIN ${hash ? 'set' : 'removed — admin is open again'}`);
+ return { pinSet: Boolean(hash) };
+}
function isAuthed(req) {
- if (!ADMIN_PASSCODE) return true;
+ if (!adminLocked()) return true;
const cookies = String(req.headers.cookie || '');
return cookies.split(';').some((c) => c.trim() === 'proddash_admin=' + adminToken);
}
@@ -1143,7 +1241,7 @@ function isAuthed(req) {
/** Guard a state-changing admin route. Returns true when the request was refused. */
function refuseAdminWrite(req, res) {
if (!isAuthed(req)) {
- sendJson(res, 401, { error: 'Enter the admin passcode first.', authRequired: true });
+ sendJson(res, 401, { error: 'Enter the admin PIN first.', authRequired: true });
return true;
}
// Requiring application/json forces a CORS preflight that a hostile page
@@ -1228,10 +1326,19 @@ async function handleAdminApi(req, res, urlPath) {
} catch {
return sendJson(res, 400, { error: 'Malformed request.' });
}
- if (!ADMIN_PASSCODE) return sendJson(res, 200, { ok: true, authRequired: false });
- if (String(body.passcode || '') !== ADMIN_PASSCODE) {
- return sendJson(res, 403, { error: 'Wrong passcode.' });
+ if (!adminLocked()) return sendJson(res, 200, { ok: true, authRequired: false });
+ const ip = clientIp(req);
+ const left = lockoutLeft(ip);
+ if (left) return sendJson(res, 429, { error: `Too many wrong PINs — try again in ${left} s.`, retryAfter: left });
+ // `passcode` is what the older admin page sent; `pin` is what this one does.
+ if (!credentialOk(body.pin ?? body.passcode)) {
+ noteWrongPin(ip);
+ const now = lockoutLeft(ip);
+ return sendJson(res, 403, now
+ ? { error: `Too many wrong PINs — try again in ${now} s.`, retryAfter: now }
+ : { error: 'Wrong PIN.' });
}
+ pinAttempts.delete(ip);
res.writeHead(200, {
'Content-Type': 'application/json; charset=utf-8',
'Set-Cookie': `proddash_admin=${adminToken}; Path=/; SameSite=Lax; HttpOnly`,
@@ -1240,12 +1347,50 @@ async function handleAdminApi(req, res, urlPath) {
return res.end(JSON.stringify({ ok: true }));
}
+ if (urlPath === '/api/admin/pin' && req.method === 'POST') {
+ if (refuseAdminWrite(req, res)) return;
+ let body = {};
+ try {
+ body = JSON.parse((await readBody(req)) || '{}');
+ } catch {
+ return sendJson(res, 400, { error: 'Malformed request.' });
+ }
+ // Changing a credential re-asserts it, so a browser left open on the
+ // admin page can't be used to take the machine over.
+ if (adminPinHash) {
+ const ip = clientIp(req);
+ const left = lockoutLeft(ip);
+ if (left) return sendJson(res, 429, { error: `Too many wrong PINs — try again in ${left} s.`, retryAfter: left });
+ if (!pinMatches(String(body.current ?? ''), adminPinHash)) {
+ noteWrongPin(ip);
+ return sendJson(res, 403, { error: 'That is not the current PIN.' });
+ }
+ pinAttempts.delete(ip);
+ }
+ try {
+ const result = setAdminPin(body.pin);
+ // Whoever just set it has proved they are allowed, so hand them the
+ // session rather than bouncing them to the unlock card they just built.
+ res.writeHead(200, {
+ 'Content-Type': 'application/json; charset=utf-8',
+ 'Set-Cookie': `proddash_admin=${adminToken}; Path=/; SameSite=Lax; HttpOnly`,
+ 'Cache-Control': 'no-store',
+ });
+ return res.end(JSON.stringify(result));
+ } catch (err) {
+ return sendJson(res, err.status || 500, { error: err.message || 'Could not save the PIN.' });
+ }
+ }
+
if (urlPath === '/api/admin/state' && req.method === 'GET') {
if (!isAuthed(req)) {
return sendJson(res, 401, { authRequired: true, authed: false });
}
return sendJson(res, 200, {
- authRequired: Boolean(ADMIN_PASSCODE),
+ authRequired: adminLocked(),
+ // The env passcode can't be changed from here; the page says so.
+ pinSet: Boolean(adminPinHash),
+ pinFromEnv: Boolean(ADMIN_PASSCODE),
authed: true,
version: SHELL_VERSION,
dataDir: DATA_DIR,
@@ -1426,7 +1571,7 @@ async function handleLayoutsApi(req, res, urlPath) {
}
if (!m[2] && req.method === 'DELETE') {
- if (!isAuthed(req)) return sendJson(res, 401, { error: 'Enter the admin passcode first.', authRequired: true });
+ if (!isAuthed(req)) return sendJson(res, 401, { error: 'Enter the admin PIN first.', authRequired: true });
try {
fs.unlinkSync(layoutPath(name));
} catch {