Admin sits behind a 6-digit PIN

The passcode machinery existed but could only be set by hand in a config
file, so in practice every install left admin open to anyone on the
network. It is now a PIN you set from the admin page itself.

- Six digits, stored as a scrypt hash in this machine's proddash.json —
  never in the clear, since that file gets copied between machines.
- The unlock card takes the PIN and submits itself on the sixth digit.
  Five wrong tries from one address locks it out for 30 seconds, and the
  right PIN is refused during the lockout.
- Changing or turning off the PIN asks for the current one, so a browser
  left open on the admin page cannot be used to take the machine over.
  Whoever sets it keeps their session rather than being bounced to the
  card they just built.
- This is the shell's own PIN and guards /admin and the admin API only.
  Dashboards stay open, and it is unrelated to a module's PIN — Slack's
  4-8 digit send PIN still guards sending, separately and independently.
- Ways back in if it is forgotten: PRODDASH_PASSCODE in the environment
  (which overrides the PIN), or delete adminPin from proddash.json and
  restart. Both are in the README and on the page itself.
- The older free-text adminPasscode still works, so an existing install
  keeps running.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Waters Church
2026-09-16 10:46:41 -04:00
co-authored by Claude Opus 5
parent 095fe66118
commit 6dabd9d8a1
5 changed files with 338 additions and 14 deletions
+92 -2
View File
@@ -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);