Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7690ae1fad | ||
|
|
6dabd9d8a1 |
@@ -99,10 +99,19 @@ 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`. |
|
||||
| `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). |
|
||||
|
||||
**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
|
||||
branch — nothing else — so any Gitea or Forgejo works. Point `forge` at its
|
||||
base URL and `repo` at the owner/repo there:
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
+92
-2
@@ -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);
|
||||
|
||||
+36
-2
@@ -18,10 +18,11 @@
|
||||
|
||||
<main id="admin-main">
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
<div class="admin-status" id="login-status"></div>
|
||||
@@ -65,6 +66,39 @@
|
||||
<h1 class="admin-h1">Saved layouts</h1>
|
||||
<div id="layouts-list"></div>
|
||||
</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>
|
||||
|
||||
<div id="toast" hidden></div>
|
||||
|
||||
@@ -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_FORGE (overrides `forge`: "github", or a Gitea/Forgejo base
|
||||
* URL such as http://10.3.8.90:3000)
|
||||
* PRODDASH_DATA_DIR (where module config + layouts are kept; see below)
|
||||
@@ -1170,12 +1171,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$<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) {
|
||||
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);
|
||||
}
|
||||
@@ -1183,7 +1281,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
|
||||
@@ -1268,10 +1366,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`,
|
||||
@@ -1280,12 +1387,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,
|
||||
@@ -1466,7 +1611,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 {
|
||||
|
||||
Reference in New Issue
Block a user