Compare commits

..
Author SHA1 Message Date
Waters ChurchandClaude Opus 5 7690ae1fad Merge main into the admin PIN branch
Both sides added to the same two places — the environment-variable list in
server.js and the settings table in the README — because the forge work and
the PIN work each documented a new setting. Everything from both is kept.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 18:57:27 -04:00
Michael McGary 1c8cd63cd0 Merge pull request #20 from michaelmcgary2008/claude/shell-1-5-0
Shell 1.5.0
2026-09-16 18:25:02 -04:00
Waters ChurchandClaude Opus 5 af6322d079 Shell 1.5.0: updates can come from your own git server
The forge setting is a new capability, so the version moves — which is
also how a machine already running 1.4.0 is offered the update at all
(an archive install compares versions, having no commit of its own).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 18:24:57 -04:00
Michael McGary 168f717398 Merge pull request #19 from michaelmcgary2008/claude/self-hosted-forge
Updates can come from your own git server
2026-09-16 18:23:38 -04:00
Waters ChurchandClaude Opus 5 6dabd9d8a1 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>
2026-09-16 10:46:41 -04:00
6 changed files with 340 additions and 16 deletions
+10 -1
View File
@@ -99,10 +99,19 @@ with this machine's shell settings — the same keys as the checked-in
| Key | Meaning | | 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. | | `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`. |
| `forge` | Where updates and module installs are fetched from. Absent or `"github"` uses GitHub; a Gitea or Forgejo base URL such as `"http://10.3.8.90:3000"` uses a server of your own. Set it per machine in the data directory, alongside `repo`. | | `forge` | Where updates and module installs are fetched from. Absent or `"github"` uses GitHub; a Gitea or Forgejo base URL such as `"http://10.3.8.90:3000"` uses a server of your own. Set it per machine in the data directory, alongside `repo`. |
| `theme` | The dashboard palette: `booth` (default), `harbor`, `graphite`, `ember` or `daylight`. The admin page's **Theme** section writes this one — see [Themes](#themes). | | `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.
**Updating from your own git server.** ProdDash fetches one `tar.gz` of the **Updating from your own git server.** ProdDash fetches one `tar.gz` of the
branch — nothing else — so any Gitea or Forgejo works. Point `forge` at its branch — nothing else — so any Gitea or Forgejo works. Point `forge` at its
base URL and `repo` at the owner/repo there: base URL and `repo` at the owner/repo there:
+2 -2
View File
@@ -1,8 +1,8 @@
{ {
"name": "proddash", "name": "proddash",
"version": "1.4.0", "version": "1.5.0",
"private": true, "private": true,
"description": "Modular production dashboard for Waters Church — zero-dependency Node server + vanilla client", "description": "Modular production dashboard for Waters Church \u2014 zero-dependency Node server + vanilla client",
"license": "UNLICENSED", "license": "UNLICENSED",
"engines": { "engines": {
"node": ">=18" "node": ">=18"
+46
View File
@@ -512,3 +512,49 @@ a.btn { text-decoration: none; }
color: var(--accent); color: var(--accent);
} }
#theme-status { margin-top: 4px; } #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);
}
+92 -2
View File
@@ -2,8 +2,10 @@
Everything here is server-wide: module enable/disable and admin config Everything here is server-wide: module enable/disable and admin config
(written to config/modules.json, module re-inited on save), plus named (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 loginCard = document.getElementById('login-card');
const loginStatus = document.getElementById('login-status'); const loginStatus = document.getElementById('login-status');
const passcodeInput = document.getElementById('passcode'); const passcodeInput = document.getElementById('passcode');
@@ -62,20 +64,30 @@ function showLogin() {
layoutsSection.hidden = true; layoutsSection.hidden = true;
updateCard.hidden = true; updateCard.hidden = true;
themeSection.hidden = true; themeSection.hidden = true;
securitySection.hidden = true;
passcodeInput.value = '';
passcodeInput.focus(); passcodeInput.focus();
} }
let loggingIn = false;
async function login() { async function login() {
if (loggingIn) return;
loggingIn = true;
loginStatus.textContent = ''; loginStatus.textContent = '';
loginStatus.className = 'admin-status'; loginStatus.className = 'admin-status';
try { 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 = ''; passcodeInput.value = '';
loginCard.hidden = true; loginCard.hidden = true;
await loadState(); await loadState();
} catch (e) { } catch (e) {
passcodeInput.value = '';
loginStatus.textContent = e.message; loginStatus.textContent = e.message;
loginStatus.className = 'admin-status error'; 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) => { passcodeInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') login(); 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 ───────────────────────────────────────────────────── */ /* ── module cards ───────────────────────────────────────────────────── */
@@ -856,6 +945,7 @@ async function loadState() {
for (const mod of state.modules || []) { for (const mod of state.modules || []) {
modulesList.appendChild(buildModuleCard(mod)); modulesList.appendChild(buildModuleCard(mod));
} }
renderPin(state);
loadTheme(); loadTheme();
loadLayouts(); loadLayouts();
loadCatalog(false); loadCatalog(false);
+36 -2
View File
@@ -18,10 +18,11 @@
<main id="admin-main"> <main id="admin-main">
<section class="admin-card" id="login-card" hidden> <section class="admin-card" id="login-card" hidden>
<h2>Admin passcode</h2> <h2>Admin PIN</h2>
<p class="admin-copy">This page changes the server-wide configuration for every dashboard in the building.</p> <p class="admin-copy">This page changes the server-wide configuration for every dashboard in the building.</p>
<div class="login-row"> <div class="login-row">
<input type="password" id="passcode" placeholder="Passcode" autocomplete="current-password"> <input type="password" id="passcode" class="pin-input" inputmode="numeric" autocomplete="off"
maxlength="6" placeholder="••••••" aria-label="Admin PIN">
<button class="btn primary" id="login-btn">Unlock</button> <button class="btn primary" id="login-btn">Unlock</button>
</div> </div>
<div class="admin-status" id="login-status"></div> <div class="admin-status" id="login-status"></div>
@@ -65,6 +66,39 @@
<h1 class="admin-h1">Saved layouts</h1> <h1 class="admin-h1">Saved layouts</h1>
<div id="layouts-list"></div> <div id="layouts-list"></div>
</section> </section>
<section id="security-section" hidden>
<h1 class="admin-h1">Admin PIN</h1>
<p class="admin-copy" id="pin-state"></p>
<div class="admin-card">
<div class="pin-form">
<label class="pin-field" id="pin-current-field" hidden>
<span>Current PIN</span>
<input type="password" id="pin-current" class="pin-input" inputmode="numeric"
autocomplete="off" maxlength="6" placeholder="••••••">
</label>
<label class="pin-field">
<span id="pin-new-label">New PIN</span>
<input type="password" id="pin-new" class="pin-input" inputmode="numeric"
autocomplete="off" maxlength="6" placeholder="6 digits">
</label>
<label class="pin-field">
<span>Repeat</span>
<input type="password" id="pin-repeat" class="pin-input" inputmode="numeric"
autocomplete="off" maxlength="6" placeholder="6 digits">
</label>
<div class="pin-actions">
<button class="btn primary" id="pin-save">Set PIN</button>
<button class="btn" id="pin-clear" hidden>Turn off</button>
</div>
</div>
<p class="admin-copy pin-note">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 <code>adminPin</code> from <code id="pin-file">proddash.json</code> in the settings
folder and restart.</p>
<div class="admin-status" id="pin-status"></div>
</div>
</section>
</main> </main>
<div id="toast" hidden></div> <div id="toast" hidden></div>
+154 -9
View File
@@ -12,7 +12,8 @@
* Configure via config/proddash.json (defaults, in the repo), an optional * Configure via config/proddash.json (defaults, in the repo), an optional
* proddash.json in the data directory (this machine's overrides), or env vars: * proddash.json in the data directory (this machine's overrides), or env vars:
* PORT (default: value in proddash.json, else 24500) * 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_FORGE (overrides `forge`: "github", or a Gitea/Forgejo base * PRODDASH_FORGE (overrides `forge`: "github", or a Gitea/Forgejo base
* URL such as http://10.3.8.90:3000) * URL such as http://10.3.8.90:3000)
* PRODDASH_DATA_DIR (where module config + layouts are kept; see below) * PRODDASH_DATA_DIR (where module config + layouts are kept; see below)
@@ -1170,12 +1171,109 @@ function restartServer(reason) {
/* ── admin API ──────────────────────────────────────────────────────── */ /* ── 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 || ''); 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'); const adminToken = crypto.randomBytes(24).toString('hex');
/** The stored PIN, as "scrypt$<salt>$<hash>"; '' 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) { function isAuthed(req) {
if (!ADMIN_PASSCODE) return true; if (!adminLocked()) return true;
const cookies = String(req.headers.cookie || ''); const cookies = String(req.headers.cookie || '');
return cookies.split(';').some((c) => c.trim() === 'proddash_admin=' + adminToken); return cookies.split(';').some((c) => c.trim() === 'proddash_admin=' + adminToken);
} }
@@ -1183,7 +1281,7 @@ function isAuthed(req) {
/** Guard a state-changing admin route. Returns true when the request was refused. */ /** Guard a state-changing admin route. Returns true when the request was refused. */
function refuseAdminWrite(req, res) { function refuseAdminWrite(req, res) {
if (!isAuthed(req)) { 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; return true;
} }
// Requiring application/json forces a CORS preflight that a hostile page // Requiring application/json forces a CORS preflight that a hostile page
@@ -1268,10 +1366,19 @@ async function handleAdminApi(req, res, urlPath) {
} catch { } catch {
return sendJson(res, 400, { error: 'Malformed request.' }); return sendJson(res, 400, { error: 'Malformed request.' });
} }
if (!ADMIN_PASSCODE) return sendJson(res, 200, { ok: true, authRequired: false }); if (!adminLocked()) return sendJson(res, 200, { ok: true, authRequired: false });
if (String(body.passcode || '') !== ADMIN_PASSCODE) { const ip = clientIp(req);
return sendJson(res, 403, { error: 'Wrong passcode.' }); 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, { res.writeHead(200, {
'Content-Type': 'application/json; charset=utf-8', 'Content-Type': 'application/json; charset=utf-8',
'Set-Cookie': `proddash_admin=${adminToken}; Path=/; SameSite=Lax; HttpOnly`, 'Set-Cookie': `proddash_admin=${adminToken}; Path=/; SameSite=Lax; HttpOnly`,
@@ -1280,12 +1387,50 @@ async function handleAdminApi(req, res, urlPath) {
return res.end(JSON.stringify({ ok: true })); 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 (urlPath === '/api/admin/state' && req.method === 'GET') {
if (!isAuthed(req)) { if (!isAuthed(req)) {
return sendJson(res, 401, { authRequired: true, authed: false }); return sendJson(res, 401, { authRequired: true, authed: false });
} }
return sendJson(res, 200, { 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, authed: true,
version: SHELL_VERSION, version: SHELL_VERSION,
dataDir: DATA_DIR, dataDir: DATA_DIR,
@@ -1466,7 +1611,7 @@ async function handleLayoutsApi(req, res, urlPath) {
} }
if (!m[2] && req.method === 'DELETE') { 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 { try {
fs.unlinkSync(layoutPath(name)); fs.unlinkSync(layoutPath(name));
} catch { } catch {