Add user management with admin and viewer roles
- data/users.json store; existing single account migrates to first admin - Roles: admin (settings, import, users) and viewer (read-only, own password) - Live role/disabled lookup; per-user session epoch so password changes, resets and disabling sign that user out everywhere - Login brute-force lockout (5 fails -> 5 min), constant-time unknown-user check - /api/users CRUD with last-admin and self-lockout safeguards - Account page (own password + admin user management); role-aware header - Min password length 8; recovery token resets the primary admin and clears lockouts Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
b3f1061f5c
commit
1566ac2897
7 files changed
+442
-93
No files matched your search
@@ -0,0 +1,200 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
||||
<meta name="theme-color" content="#191510">
|
||||
<link rel="manifest" href="/dashboard.webmanifest">
|
||||
<title>Account</title>
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
:root {
|
||||
--ink: #1a1a1a; --ink-light: #5f5649; --paper: #f5f2ec; --paper-dark: #ece7db; --paper-darker: #ddd6c7;
|
||||
--border: #b9ae9b; --accent: #c25a2a; --cool: #2a6b8a; --ok: #2f8a4a;
|
||||
--mono: 'Segoe UI Rounded', 'Trebuchet MS', 'Avenir Next', sans-serif;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root { --ink: #ece7dc; --ink-light: #beb399; --paper: #191510; --paper-dark: #201c16; --paper-darker: #2b2620; --border: #3a342a; --accent: #e07848; --cool: #5ab0d8; --ok: #5cc27a; }
|
||||
}
|
||||
html { -webkit-text-size-adjust: 100%; }
|
||||
html, body { min-height: 100vh; min-height: 100dvh; background: var(--paper); color: var(--ink); font-family: var(--mono); font-size: 15px; }
|
||||
.header { display: flex; align-items: center; justify-content: space-between; gap: 0.6rem; flex-wrap: wrap; padding: calc(1rem + env(safe-area-inset-top)) calc(1.5rem + env(safe-area-inset-right)) 1rem calc(1.5rem + env(safe-area-inset-left)); border-bottom: 1px solid var(--border); background: var(--paper-dark); }
|
||||
.header h1 { font-size: 1.15rem; }
|
||||
.header .sub { font-size: 0.8rem; color: var(--ink-light); }
|
||||
.header-right { display: flex; gap: 0.6rem; }
|
||||
.nav-link { color: var(--ink-light); text-decoration: none; font-size: 0.8rem; font-weight: 600; padding: 0.4rem 0.8rem; min-height: 38px; display: inline-flex; align-items: center; border: 1px solid var(--border); border-radius: 8px; }
|
||||
.nav-link:hover { border-color: var(--accent); color: var(--accent); }
|
||||
.page { max-width: 860px; margin: 0 auto; padding: 1.5rem calc(1rem + env(safe-area-inset-right)) calc(4rem + env(safe-area-inset-bottom)) calc(1rem + env(safe-area-inset-left)); }
|
||||
.section { background: var(--paper-dark); border: 1px solid var(--border); border-radius: 14px; padding: 1.4rem; margin-bottom: 1.25rem; }
|
||||
.section-hdr { font-size: 0.7rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.09em; color: var(--ink-light); margin-bottom: 1rem; padding-bottom: 0.5rem; border-bottom: 1px solid var(--border); display: flex; justify-content: space-between; align-items: center; gap: 0.5rem; }
|
||||
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 0.9rem; margin-bottom: 0.9rem; }
|
||||
label { display: block; font-size: 0.8rem; color: var(--ink-light); margin-bottom: 0.3rem; }
|
||||
input, select { width: 100%; padding: 0.6rem 0.75rem; min-height: 44px; font: inherit; font-size: 16px; color: var(--ink); background: var(--paper); border: 1px solid var(--border); border-radius: 8px; }
|
||||
input:focus, select:focus { outline: 2px solid var(--accent); border-color: transparent; }
|
||||
button { font: inherit; font-size: 0.85rem; font-weight: 600; min-height: 44px; padding: 0 1.1rem; border-radius: 8px; border: 1px solid var(--border); background: var(--paper); color: var(--ink); cursor: pointer; touch-action: manipulation; }
|
||||
button:hover { border-color: var(--accent); }
|
||||
button.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
|
||||
button.danger { color: var(--accent); }
|
||||
button.small { min-height: 36px; padding: 0 0.7rem; font-size: 0.78rem; }
|
||||
.msg { font-size: 0.85rem; margin-left: 0.7rem; color: var(--ok); }
|
||||
.msg.err { color: var(--accent); }
|
||||
.hint { font-size: 0.78rem; color: var(--ink-light); line-height: 1.5; margin-top: 0.6rem; }
|
||||
.badge { display: inline-block; padding: 0.12rem 0.55rem; border-radius: 99px; font-size: 0.7rem; font-weight: 700; border: 1px solid var(--border); background: var(--paper); text-transform: uppercase; letter-spacing: 0.04em; }
|
||||
.badge.admin { color: var(--accent); border-color: var(--accent); }
|
||||
.badge.viewer { color: var(--cool); border-color: var(--cool); }
|
||||
.badge.off { color: var(--ink-light); }
|
||||
.users { display: grid; gap: 0.7rem; }
|
||||
.user { background: var(--paper); border: 1px solid var(--border); border-radius: 12px; padding: 0.8rem 0.95rem; display: flex; flex-wrap: wrap; align-items: center; gap: 0.6rem 1rem; }
|
||||
.user.disabled { opacity: 0.6; }
|
||||
.user .who { flex: 1 1 180px; min-width: 0; }
|
||||
.user .who b { display: block; overflow-wrap: anywhere; }
|
||||
.user .who span { font-size: 0.74rem; color: var(--ink-light); }
|
||||
.user .actions { display: flex; flex-wrap: wrap; gap: 0.4rem; }
|
||||
dialog { border: 1px solid var(--border); border-radius: 14px; background: var(--paper-dark); color: var(--ink); padding: 1.3rem; width: min(380px, 92vw); margin: auto; }
|
||||
dialog::backdrop { background: rgba(0,0,0,0.55); }
|
||||
dialog h3 { font-size: 1rem; margin-bottom: 0.8rem; }
|
||||
dialog .row { display: flex; gap: 0.5rem; justify-content: flex-end; margin-top: 1rem; }
|
||||
.hidden { display: none !important; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<div><h1>Account</h1><div class="sub" id="whoami">Loading…</div></div>
|
||||
<div class="header-right">
|
||||
<a href="/dashboard" class="nav-link">← Dashboard</a>
|
||||
<a href="/dashboard/logout" class="nav-link">Sign out</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="page">
|
||||
<div class="section">
|
||||
<div class="section-hdr">Change my password</div>
|
||||
<div class="grid">
|
||||
<div><label for="pwCurrent">Current password</label><input id="pwCurrent" type="password" autocomplete="current-password"></div>
|
||||
<div><label for="pwNew">New password (min 8 characters)</label><input id="pwNew" type="password" autocomplete="new-password"></div>
|
||||
</div>
|
||||
<button class="primary" id="pwBtn">Change password</button><span class="msg" id="pwMsg"></span>
|
||||
<p class="hint">Changing your password signs you out everywhere else.</p>
|
||||
</div>
|
||||
|
||||
<div id="adminArea" class="hidden">
|
||||
<div class="section">
|
||||
<div class="section-hdr"><span>Users</span><span id="userCount" class="hint" style="margin:0"></span></div>
|
||||
<div class="users" id="userList"></div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<div class="section-hdr">Add a user</div>
|
||||
<div class="grid">
|
||||
<div><label for="nuName">Username</label><input id="nuName" type="text" autocomplete="off" autocapitalize="none" spellcheck="false"></div>
|
||||
<div><label for="nuPass">Temporary password (min 8)</label><input id="nuPass" type="password" autocomplete="new-password"></div>
|
||||
<div><label for="nuRole">Role</label>
|
||||
<select id="nuRole"><option value="viewer">Viewer — read-only dashboard</option><option value="admin">Admin — full access + users</option></select></div>
|
||||
</div>
|
||||
<button class="primary" id="nuBtn">Create user</button><span class="msg" id="nuMsg"></span>
|
||||
<p class="hint"><b>Viewers</b> can see all station data, records and charts, and change their own password. <b>Admins</b> can also change station settings, import data, and manage users. The last active admin can't be removed, demoted or disabled.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<dialog id="dlg"><h3 id="dlgTitle"></h3><div id="dlgBody"></div><div class="row"><button id="dlgCancel">Cancel</button><button class="primary" id="dlgOk">OK</button></div></dialog>
|
||||
|
||||
<script>
|
||||
const $ = (id) => document.getElementById(id);
|
||||
let me = null;
|
||||
const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||
const when = (ts) => (ts ? new Date(ts).toLocaleDateString([], { month: 'short', day: 'numeric', year: 'numeric' }) : 'never');
|
||||
|
||||
async function api(url, method = 'GET', body) {
|
||||
const r = await fetch(url, { method, headers: body ? { 'Content-Type': 'application/json' } : undefined, body: body ? JSON.stringify(body) : undefined });
|
||||
if (r.status === 401) { location.href = '/dashboard/login'; throw new Error('Signed out'); }
|
||||
const data = await r.json().catch(() => ({}));
|
||||
if (!r.ok) throw new Error(data.error || 'Request failed');
|
||||
return data;
|
||||
}
|
||||
function flash(el, text, isErr) { el.textContent = text; el.className = 'msg' + (isErr ? ' err' : ''); setTimeout(() => { el.textContent = ''; }, 5000); }
|
||||
|
||||
// Small promise-based dialog (works on iOS where prompt()/confirm() are awkward)
|
||||
function ask(title, bodyHtml, okLabel = 'OK') {
|
||||
return new Promise((resolve) => {
|
||||
const dlg = $('dlg');
|
||||
$('dlgTitle').textContent = title; $('dlgBody').innerHTML = bodyHtml; $('dlgOk').textContent = okLabel;
|
||||
const done = (v) => { dlg.close(); resolve(v); };
|
||||
$('dlgOk').onclick = () => done(dlg.querySelector('input') ? dlg.querySelector('input').value : true);
|
||||
$('dlgCancel').onclick = () => done(null);
|
||||
dlg.oncancel = () => resolve(null);
|
||||
dlg.showModal();
|
||||
dlg.querySelector('input')?.focus();
|
||||
});
|
||||
}
|
||||
|
||||
$('pwBtn').addEventListener('click', async () => {
|
||||
const cur = $('pwCurrent').value, nw = $('pwNew').value;
|
||||
if (!cur || nw.length < 8) return flash($('pwMsg'), 'Enter your current password and a new one (min 8 characters)', true);
|
||||
try {
|
||||
await api('/api/dashboard/change-password', 'POST', { currentPassword: cur, newPassword: nw });
|
||||
$('pwCurrent').value = ''; $('pwNew').value = '';
|
||||
flash($('pwMsg'), 'Password changed');
|
||||
} catch (e) { flash($('pwMsg'), e.message, true); }
|
||||
});
|
||||
|
||||
async function loadUsers() {
|
||||
const users = await api('/api/users');
|
||||
$('userCount').textContent = `${users.length} user${users.length === 1 ? '' : 's'}`;
|
||||
$('userList').innerHTML = users.map((u) => {
|
||||
const self = u.username === me.username;
|
||||
return `<div class="user${u.disabled ? ' disabled' : ''}" data-u="${esc(u.username)}">
|
||||
<div class="who"><b>${esc(u.username)}${self ? ' (you)' : ''}</b><span>Added ${when(u.createdAt)} · last sign-in ${when(u.lastLogin)}</span></div>
|
||||
<div><span class="badge ${u.role}">${u.role}</span> ${u.disabled ? '<span class="badge off">disabled</span>' : ''}</div>
|
||||
<div class="actions">
|
||||
<button class="small" data-act="role">${u.role === 'admin' ? 'Make viewer' : 'Make admin'}</button>
|
||||
<button class="small" data-act="reset">Reset password</button>
|
||||
<button class="small" data-act="toggle">${u.disabled ? 'Enable' : 'Disable'}</button>
|
||||
<button class="small danger" data-act="delete">Delete</button>
|
||||
</div></div>`;
|
||||
}).join('');
|
||||
$('userList').querySelectorAll('.user').forEach((row) => {
|
||||
const name = row.dataset.u, u = users.find((x) => x.username === name);
|
||||
row.querySelectorAll('[data-act]').forEach((b) => b.addEventListener('click', () => act(b.dataset.act, u)));
|
||||
});
|
||||
}
|
||||
|
||||
async function act(kind, u) {
|
||||
try {
|
||||
if (kind === 'role') {
|
||||
const next = u.role === 'admin' ? 'viewer' : 'admin';
|
||||
if (!(await ask(`Make ${u.username} a ${next}?`, `<p class="hint">${next === 'admin' ? 'Admins can change settings and manage users.' : 'Viewers have read-only access to the dashboard.'}</p>`, 'Confirm'))) return;
|
||||
await api('/api/users/' + encodeURIComponent(u.username), 'PATCH', { role: next });
|
||||
} else if (kind === 'reset') {
|
||||
const pw = await ask(`Reset password for ${u.username}`, '<label for="rp">New password (min 8)</label><input id="rp" type="password" autocomplete="new-password">', 'Reset');
|
||||
if (pw == null) return;
|
||||
await api('/api/users/' + encodeURIComponent(u.username), 'PATCH', { password: pw });
|
||||
} else if (kind === 'toggle') {
|
||||
await api('/api/users/' + encodeURIComponent(u.username), 'PATCH', { disabled: !u.disabled });
|
||||
} else if (kind === 'delete') {
|
||||
if (!(await ask(`Delete ${u.username}?`, '<p class="hint">They will lose access immediately. This can\'t be undone.</p>', 'Delete'))) return;
|
||||
await api('/api/users/' + encodeURIComponent(u.username), 'DELETE');
|
||||
}
|
||||
await loadUsers();
|
||||
} catch (e) { await ask('Could not do that', `<p class="hint">${esc(e.message)}</p>`, 'OK'); }
|
||||
}
|
||||
|
||||
$('nuBtn').addEventListener('click', async () => {
|
||||
try {
|
||||
await api('/api/users', 'POST', { username: $('nuName').value.trim(), password: $('nuPass').value, role: $('nuRole').value });
|
||||
$('nuName').value = ''; $('nuPass').value = '';
|
||||
flash($('nuMsg'), 'User created');
|
||||
await loadUsers();
|
||||
} catch (e) { flash($('nuMsg'), e.message, true); }
|
||||
});
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
me = await api('/api/dashboard/me');
|
||||
$('whoami').innerHTML = `Signed in as <b>${esc(me.username)}</b> · ${me.role}`;
|
||||
if (me.role === 'admin') { $('adminArea').classList.remove('hidden'); await loadUsers(); }
|
||||
} catch {}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -48,6 +48,7 @@ button:hover { opacity: 0.88; }
|
||||
</div>
|
||||
<script>
|
||||
if (location.search.includes('error=1')) document.getElementById('err').style.display = 'block';
|
||||
if (location.search.includes('error=2')) { const e = document.getElementById('err'); e.textContent = 'Too many failed attempts. Try again in a few minutes.'; e.style.display = 'block'; }
|
||||
if (location.search.includes('recovered=1')) document.getElementById('ok').style.display = 'block';
|
||||
</script>
|
||||
</body>
|
||||
|
||||
@@ -38,12 +38,12 @@ button:hover { opacity: 0.88; }
|
||||
<code>Dashboard recovery token:</code><br>
|
||||
In CasaOS, open the app's log from the container details panel. The token changes daily.
|
||||
</div>
|
||||
<div id="err" class="error" style="display:none">Invalid token or password too short (min 6 chars).</div>
|
||||
<div id="err" class="error" style="display:none">Invalid token or password too short (min 8 chars). This resets the primary admin account.</div>
|
||||
<form method="POST" action="/dashboard/recover">
|
||||
<label>Recovery Token</label>
|
||||
<input name="token" type="text" autocomplete="off" spellcheck="false" autofocus required placeholder="Paste token from server logs">
|
||||
<label>New Password</label>
|
||||
<input name="password" type="password" autocomplete="new-password" required minlength="6">
|
||||
<input name="password" type="password" autocomplete="new-password" required minlength="8">
|
||||
<button type="submit">Set New Password</button>
|
||||
</form>
|
||||
<a href="/dashboard/login" class="back">← Back to sign in</a>
|
||||
|
||||
@@ -88,6 +88,7 @@ button.primary:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
<div><h1>Settings</h1></div>
|
||||
<div class="header-right">
|
||||
<a href="/dashboard" class="nav-link">← Dashboard</a>
|
||||
<a href="/dashboard/account" class="nav-link">Account</a>
|
||||
<a href="/dashboard/logout" class="nav-link">Sign out</a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -204,17 +205,12 @@ button.primary:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Change Password -->
|
||||
<!-- Account -->
|
||||
<div class="section">
|
||||
<div class="section-hdr">Change Password</div>
|
||||
<div class="settings-grid">
|
||||
<div><label>Current Password</label><input id="pwCurrent" type="password" autocomplete="current-password"></div>
|
||||
<div><label>New Password (min 6 chars)</label><input id="pwNew" type="password" autocomplete="new-password"></div>
|
||||
</div>
|
||||
<div class="settings-actions">
|
||||
<button class="primary" id="changePwBtn">Change Password</button>
|
||||
<span id="pwMsg" class="save-msg"></span>
|
||||
</div>
|
||||
<div class="section-hdr">Account & Users</div>
|
||||
<p class="hint" style="margin:0 0 0.85rem">Change your password and manage who can sign in (admin and viewer accounts).</p>
|
||||
<a class="export-link" href="/dashboard/account">Open account & users →</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@@ -413,28 +409,6 @@ async function pollBackfill() {
|
||||
|
||||
document.getElementById('backfillBtn').addEventListener('click', startBackfill);
|
||||
|
||||
// ── Change Password ───────────────────────────────────────────────────────────
|
||||
document.getElementById('changePwBtn').addEventListener('click', async () => {
|
||||
const cur = document.getElementById('pwCurrent')?.value;
|
||||
const nw = document.getElementById('pwNew')?.value;
|
||||
const msg = document.getElementById('pwMsg');
|
||||
if (!cur || !nw || nw.length < 6) { msg.textContent = 'Fill both fields (min 6 chars)'; msg.className = 'save-msg err'; return; }
|
||||
try {
|
||||
const r = await fetch('/api/dashboard/change-password', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ currentPassword: cur, newPassword: nw })
|
||||
});
|
||||
if (r.ok) {
|
||||
msg.textContent = 'Password changed'; msg.className = 'save-msg';
|
||||
document.getElementById('pwCurrent').value = '';
|
||||
document.getElementById('pwNew').value = '';
|
||||
} else {
|
||||
const e = await r.json();
|
||||
msg.textContent = e.error || 'Error'; msg.className = 'save-msg err';
|
||||
}
|
||||
setTimeout(() => msg.textContent = '', 4000);
|
||||
} catch { msg.textContent = 'Error'; msg.className = 'save-msg err'; }
|
||||
});
|
||||
|
||||
// ── Scroll shadow ─────────────────────────────────────────────────────────────
|
||||
window.addEventListener('scroll', () => {
|
||||
|
||||
@@ -30,12 +30,12 @@ button:hover { opacity: 0.88; }
|
||||
<div class="card">
|
||||
<h1>Create Dashboard Account</h1>
|
||||
<p class="sub">First-time setup — create your admin account</p>
|
||||
<div id="err" class="error" style="display:none">Password must be at least 6 characters.</div>
|
||||
<div id="err" class="error" style="display:none">Username must be 3–32 letters, numbers, . _ - and the password at least 8 characters.</div>
|
||||
<form method="POST" action="/dashboard/setup">
|
||||
<label>Username</label>
|
||||
<input name="username" type="text" autocomplete="username" autofocus required>
|
||||
<input name="username" pattern="[A-Za-z0-9._\-]{3,32}" autocapitalize="none" type="text" autocomplete="username" autofocus required>
|
||||
<label>Password</label>
|
||||
<input name="password" type="password" autocomplete="new-password" required minlength="6">
|
||||
<input name="password" type="password" autocomplete="new-password" required minlength="8">
|
||||
<button type="submit">Create Account</button>
|
||||
</form>
|
||||
<p class="note">This account protects your station dashboard. Store these credentials somewhere safe.</p>
|
||||
|
||||
@@ -549,6 +549,24 @@ function renderInsights() {
|
||||
});
|
||||
}
|
||||
|
||||
// ── Who's signed in: role-aware header links ─────────────────────────────────
|
||||
(async function roleAwareHeader() {
|
||||
try {
|
||||
const me = await getJSON('/api/dashboard/me');
|
||||
const right = document.querySelector('.header-right');
|
||||
if (!right) return;
|
||||
const settings = right.querySelector('a[href="/dashboard/settings"]');
|
||||
if (me.role !== 'admin' && settings) settings.remove();
|
||||
const acct = document.createElement('a');
|
||||
acct.href = '/dashboard/account'; acct.className = 'logout'; acct.textContent = me.role === 'admin' ? 'Account & Users' : 'Account';
|
||||
acct.title = `Signed in as ${me.username} (${me.role})`;
|
||||
(settings && me.role === 'admin' ? settings : right.querySelector('a[href="/dashboard/logout"]'))?.before(acct);
|
||||
const nm = $('stationName');
|
||||
if (nm && !nm.textContent) nm.textContent = me.username;
|
||||
window.DASH_USER = me;
|
||||
} catch {}
|
||||
})();
|
||||
|
||||
// ── Data refresh ─────────────────────────────────────────────────────────────
|
||||
async function refreshData() {
|
||||
try {
|
||||
|
||||
+212
-56
@@ -35,13 +35,14 @@ function parseCookies(req) {
|
||||
}).filter(([k]) => k));
|
||||
}
|
||||
|
||||
function signSession(username) {
|
||||
function signSession(username, epoch = 0) {
|
||||
const expiry = Date.now() + 30 * 24 * 60 * 60 * 1000;
|
||||
const payload = Buffer.from(`${username}|${expiry}`).toString('base64url');
|
||||
const payload = Buffer.from(`${username}|${expiry}|${epoch}`).toString('base64url');
|
||||
const sig = crypto.createHmac('sha256', SESSION_SECRET).update(payload).digest('base64url');
|
||||
return `${payload}.${sig}`;
|
||||
}
|
||||
|
||||
// Returns { username, epoch } for a valid, unexpired token (epoch 0 for pre-users cookies)
|
||||
function verifySession(token) {
|
||||
if (!token || typeof token !== 'string') return null;
|
||||
const dotIdx = token.lastIndexOf('.');
|
||||
@@ -53,9 +54,14 @@ function verifySession(token) {
|
||||
if (!crypto.timingSafeEqual(Buffer.from(sig + '='.repeat((4 - sig.length % 4) % 4), 'base64'), Buffer.from(expected + '='.repeat((4 - expected.length % 4) % 4), 'base64'))) return null;
|
||||
} catch { return null; }
|
||||
const decoded = Buffer.from(payload, 'base64url').toString();
|
||||
const [username, expiry] = decoded.split('|');
|
||||
const [username, expiry, epoch] = decoded.split('|');
|
||||
if (!username || !expiry || Date.now() > parseInt(expiry)) return null;
|
||||
return username;
|
||||
return { username, epoch: parseInt(epoch) || 0 };
|
||||
}
|
||||
|
||||
function sessionCookie(req, token) {
|
||||
const secure = req.secure || req.headers['x-forwarded-proto'] === 'https' ? '; Secure' : '';
|
||||
return `dsession=${token}; HttpOnly; Path=/; Max-Age=${30 * 24 * 3600}; SameSite=Lax${secure}`;
|
||||
}
|
||||
|
||||
function hashPassword(password, salt) {
|
||||
@@ -67,14 +73,91 @@ function getDailyRecoveryToken() {
|
||||
return crypto.createHmac('sha256', SESSION_SECRET).update('recover:' + day).digest('hex').slice(0, 16);
|
||||
}
|
||||
|
||||
// ── Users & roles ─────────────────────────────────────────────────────────────
|
||||
// data/users.json: { users: [{ username, salt, hash, role: 'admin'|'viewer', disabled, epoch, createdAt, lastLogin }] }
|
||||
// `epoch` is bumped on password change/reset/disable so that user's existing sessions stop working.
|
||||
const USERS_FILE = path.join(DATA_DIR, 'users.json');
|
||||
const PASSWORD_MIN = 8;
|
||||
const USERNAME_RE = /^[A-Za-z0-9._-]{3,32}$/;
|
||||
const ROLES = ['admin', 'viewer'];
|
||||
let usersStore = { users: [] };
|
||||
try { usersStore = JSON.parse(fs.readFileSync(USERS_FILE, 'utf8')); } catch {}
|
||||
if (!usersStore || !Array.isArray(usersStore.users)) usersStore = { users: [] };
|
||||
|
||||
function saveUsers() {
|
||||
try {
|
||||
fs.writeFileSync(USERS_FILE + '.tmp', JSON.stringify(usersStore, null, 2), { mode: 0o600 });
|
||||
fs.renameSync(USERS_FILE + '.tmp', USERS_FILE);
|
||||
} catch (e) { console.error('[auth] could not save users:', e.message); }
|
||||
}
|
||||
|
||||
// First run after upgrade: the existing single account becomes the first admin.
|
||||
if (!usersStore.users.length && dashAuth?.username && dashAuth.hash && dashAuth.salt) {
|
||||
usersStore.users.push({ username: dashAuth.username, salt: dashAuth.salt, hash: dashAuth.hash, role: 'admin', epoch: 0, createdAt: Date.now() });
|
||||
saveUsers();
|
||||
console.log(`[auth] migrated existing account "${dashAuth.username}" to admin`);
|
||||
}
|
||||
|
||||
const hasUsers = () => usersStore.users.length > 0;
|
||||
const findUser = (name) => usersStore.users.find(u => u.username.toLowerCase() === String(name || '').toLowerCase());
|
||||
const activeAdmins = () => usersStore.users.filter(u => u.role === 'admin' && !u.disabled);
|
||||
const publicUser = (u) => ({ username: u.username, role: u.role, disabled: !!u.disabled, createdAt: u.createdAt || null, lastLogin: u.lastLogin || null });
|
||||
|
||||
const DUMMY_SALT = crypto.randomBytes(16).toString('hex');
|
||||
function checkPassword(user, password) {
|
||||
const hash = hashPassword(String(password || ''), user ? user.salt : DUMMY_SALT); // always hash: no timing hint about unknown users
|
||||
if (!user) return false;
|
||||
try { return crypto.timingSafeEqual(Buffer.from(hash, 'hex'), Buffer.from(user.hash, 'hex')); } catch { return false; }
|
||||
}
|
||||
function setPassword(user, password) {
|
||||
user.salt = crypto.randomBytes(16).toString('hex');
|
||||
user.hash = hashPassword(password, user.salt);
|
||||
user.epoch = (user.epoch || 0) + 1; // signs out every existing session for this user
|
||||
}
|
||||
|
||||
// Simple brute-force guard: 5 failures within 15 min locks that username+IP for 5 min.
|
||||
const loginAttempts = new Map();
|
||||
const attemptKey = (req, username) => `${req.ip}|${String(username || '').toLowerCase()}`;
|
||||
function isLockedOut(key) {
|
||||
const a = loginAttempts.get(key);
|
||||
return !!(a && a.lockedUntil && a.lockedUntil > Date.now());
|
||||
}
|
||||
function noteLoginFailure(key) {
|
||||
const now = Date.now();
|
||||
const a = loginAttempts.get(key);
|
||||
const cur = a && now - a.first < 15 * 60 * 1000 ? a : { fails: 0, first: now };
|
||||
cur.fails++;
|
||||
if (cur.fails >= 5) cur.lockedUntil = now + 5 * 60 * 1000;
|
||||
loginAttempts.set(key, cur);
|
||||
}
|
||||
setInterval(() => { const now = Date.now(); for (const [k, a] of loginAttempts) if (now - a.first > 15 * 60 * 1000 && !(a.lockedUntil > now)) loginAttempts.delete(k); }, 10 * 60 * 1000).unref();
|
||||
|
||||
function clearAttemptsFor(username) {
|
||||
const suffix = '|' + String(username).toLowerCase();
|
||||
for (const k of loginAttempts.keys()) if (k.endsWith(suffix)) loginAttempts.delete(k);
|
||||
}
|
||||
|
||||
function currentUser(req) {
|
||||
const s = verifySession(parseCookies(req).dsession);
|
||||
if (!s) return null;
|
||||
const u = findUser(s.username);
|
||||
if (!u || u.disabled || (u.epoch || 0) !== s.epoch) return null;
|
||||
return u;
|
||||
}
|
||||
|
||||
function requireDashboardAuth(req, res, next) {
|
||||
const cookies = parseCookies(req);
|
||||
const user = verifySession(cookies.dsession);
|
||||
if (user) return next();
|
||||
const user = currentUser(req);
|
||||
if (user) { req.dashUser = user; return next(); }
|
||||
if (req.path.startsWith('/api/')) return res.status(401).json({ error: 'Unauthorized' });
|
||||
res.redirect('/dashboard/login');
|
||||
}
|
||||
|
||||
function requireAdmin(req, res, next) {
|
||||
if (req.dashUser?.role === 'admin') return next();
|
||||
if (req.path.startsWith('/api/')) return res.status(403).json({ error: 'Admin access required' });
|
||||
res.redirect('/dashboard');
|
||||
}
|
||||
|
||||
// ── Station data recording ────────────────────────────────────────────────────
|
||||
const STATION_DIR = path.join(DATA_DIR, 'station');
|
||||
if (!fs.existsSync(STATION_DIR)) fs.mkdirSync(STATION_DIR, { recursive: true });
|
||||
@@ -930,7 +1013,7 @@ function saveAmbientConfig() {
|
||||
const ambientCaches = new Map();
|
||||
|
||||
// Save keys server-side so all devices share them
|
||||
app.post('/api/ambient-config', requireDashboardAuth, (req, res) => {
|
||||
app.post('/api/ambient-config', requireDashboardAuth, requireAdmin, (req, res) => {
|
||||
const { apiKey, appKey } = req.body || {};
|
||||
if (!apiKey || !appKey) return res.status(400).json({ error: 'apiKey and appKey required' });
|
||||
ambientServerConfig = { apiKey, appKey };
|
||||
@@ -1250,44 +1333,46 @@ app.use('/dashboard', express.urlencoded({ extended: false }));
|
||||
|
||||
// Setup (first-time account creation)
|
||||
app.get('/dashboard/setup', (req, res) => {
|
||||
if (dashAuth) return res.redirect('/dashboard');
|
||||
if (hasUsers()) return res.redirect('/dashboard');
|
||||
res.sendFile(path.join(__dirname, 'public', 'dashboard-setup.html'));
|
||||
});
|
||||
|
||||
// First-run setup creates the initial admin account
|
||||
app.post('/dashboard/setup', (req, res) => {
|
||||
if (dashAuth) return res.redirect('/dashboard');
|
||||
if (hasUsers()) return res.redirect('/dashboard');
|
||||
const { username, password } = req.body || {};
|
||||
if (!username || !password || password.length < 6) {
|
||||
if (!USERNAME_RE.test(String(username || '')) || !password || password.length < PASSWORD_MIN) {
|
||||
return res.redirect('/dashboard/setup?error=1');
|
||||
}
|
||||
const salt = crypto.randomBytes(16).toString('hex');
|
||||
const hash = hashPassword(password, salt);
|
||||
dashAuth = { username, salt, hash };
|
||||
try { fs.writeFileSync(DASH_AUTH_FILE, JSON.stringify(dashAuth)); } catch {}
|
||||
const token = signSession(username);
|
||||
res.setHeader('Set-Cookie', `dsession=${token}; HttpOnly; Path=/; Max-Age=${30*24*3600}; SameSite=Lax`);
|
||||
const user = { username, role: 'admin', epoch: 0, createdAt: Date.now(), lastLogin: Date.now() };
|
||||
setPassword(user, password); user.epoch = 0;
|
||||
usersStore.users.push(user);
|
||||
saveUsers();
|
||||
dashAuth = dashAuth || {};
|
||||
res.setHeader('Set-Cookie', sessionCookie(req, signSession(user.username, user.epoch)));
|
||||
res.redirect('/dashboard');
|
||||
});
|
||||
|
||||
// Login
|
||||
app.get('/dashboard/login', (req, res) => {
|
||||
if (!dashAuth) return res.redirect('/dashboard/setup');
|
||||
const cookies = parseCookies(req);
|
||||
if (verifySession(cookies.dsession)) return res.redirect('/dashboard');
|
||||
if (!hasUsers()) return res.redirect('/dashboard/setup');
|
||||
if (currentUser(req)) return res.redirect('/dashboard');
|
||||
res.sendFile(path.join(__dirname, 'public', 'dashboard-login.html'));
|
||||
});
|
||||
|
||||
app.post('/dashboard/login', (req, res) => {
|
||||
if (!dashAuth) return res.redirect('/dashboard/setup');
|
||||
if (!hasUsers()) return res.redirect('/dashboard/setup');
|
||||
const { username, password } = req.body || {};
|
||||
if (!username || !password) return res.redirect('/dashboard/login?error=1');
|
||||
if (username !== dashAuth.username) return res.redirect('/dashboard/login?error=1');
|
||||
const hash = hashPassword(password, dashAuth.salt);
|
||||
let match = false;
|
||||
try { match = crypto.timingSafeEqual(Buffer.from(hash, 'hex'), Buffer.from(dashAuth.hash, 'hex')); } catch {}
|
||||
if (!match) return res.redirect('/dashboard/login?error=1');
|
||||
const token = signSession(username);
|
||||
res.setHeader('Set-Cookie', `dsession=${token}; HttpOnly; Path=/; Max-Age=${30*24*3600}; SameSite=Lax`);
|
||||
const key = attemptKey(req, username);
|
||||
if (isLockedOut(key)) return res.redirect('/dashboard/login?error=2');
|
||||
const user = findUser(username);
|
||||
const ok = checkPassword(user, password) && !user.disabled;
|
||||
if (!ok) { noteLoginFailure(key); return res.redirect('/dashboard/login?error=1'); }
|
||||
loginAttempts.delete(key);
|
||||
user.lastLogin = Date.now();
|
||||
saveUsers();
|
||||
res.setHeader('Set-Cookie', sessionCookie(req, signSession(user.username, user.epoch || 0)));
|
||||
res.redirect('/dashboard');
|
||||
});
|
||||
|
||||
@@ -1297,35 +1382,41 @@ app.get('/dashboard/logout', (_req, res) => {
|
||||
res.redirect('/dashboard/login');
|
||||
});
|
||||
|
||||
// Password recovery
|
||||
// Password recovery: the daily token (printed in the server log) resets the primary admin account
|
||||
app.get('/dashboard/recover', (_req, res) => {
|
||||
if (!dashAuth) return res.redirect('/dashboard/setup');
|
||||
if (!hasUsers()) return res.redirect('/dashboard/setup');
|
||||
res.sendFile(path.join(__dirname, 'public', 'dashboard-recover.html'));
|
||||
});
|
||||
|
||||
app.post('/dashboard/recover', (req, res) => {
|
||||
if (!dashAuth) return res.redirect('/dashboard/setup');
|
||||
if (!hasUsers()) return res.redirect('/dashboard/setup');
|
||||
const { token, password } = req.body || {};
|
||||
if (!token || !password || password.length < 6) return res.redirect('/dashboard/recover?error=1');
|
||||
if (!token || !password || password.length < PASSWORD_MIN) return res.redirect('/dashboard/recover?error=1');
|
||||
if (isLockedOut('recover|' + req.ip)) return res.redirect('/dashboard/recover?error=1');
|
||||
const expected = getDailyRecoveryToken();
|
||||
let match = false;
|
||||
try { match = crypto.timingSafeEqual(Buffer.from(token.trim()), Buffer.from(expected)); } catch {}
|
||||
if (!match) return res.redirect('/dashboard/recover?error=1');
|
||||
const salt = crypto.randomBytes(16).toString('hex');
|
||||
const hash = hashPassword(password, salt);
|
||||
dashAuth = { ...dashAuth, salt, hash };
|
||||
try { fs.writeFileSync(DASH_AUTH_FILE, JSON.stringify(dashAuth)); } catch {}
|
||||
try { match = crypto.timingSafeEqual(Buffer.from(String(token).trim()), Buffer.from(expected)); } catch {}
|
||||
if (!match) { noteLoginFailure('recover|' + req.ip); return res.redirect('/dashboard/recover?error=1'); }
|
||||
const admin = activeAdmins()[0] || usersStore.users.find(u => u.role === 'admin') || usersStore.users[0];
|
||||
admin.role = 'admin'; admin.disabled = false;
|
||||
setPassword(admin, password);
|
||||
clearAttemptsFor(admin.username);
|
||||
saveUsers();
|
||||
console.log(`[auth] recovery reset password for admin "${admin.username}"`);
|
||||
res.redirect('/dashboard/login?recovered=1');
|
||||
});
|
||||
|
||||
// Main dashboard
|
||||
app.get('/dashboard', requireDashboardAuth, (_req, res) => {
|
||||
if (!dashAuth) return res.redirect('/dashboard/setup');
|
||||
res.sendFile(path.join(__dirname, 'public', 'dashboard.html'));
|
||||
});
|
||||
|
||||
// Settings page
|
||||
app.get('/dashboard/settings', requireDashboardAuth, (_req, res) => {
|
||||
// Account page: everyone can change their own password; admins also manage users here
|
||||
app.get('/dashboard/account', requireDashboardAuth, (_req, res) => {
|
||||
res.sendFile(path.join(__dirname, 'public', 'dashboard-account.html'));
|
||||
});
|
||||
app.get('/dashboard/settings', requireDashboardAuth, requireAdmin, (_req, res) => {
|
||||
res.sendFile(path.join(__dirname, 'public', 'dashboard-settings.html'));
|
||||
});
|
||||
|
||||
@@ -1448,7 +1539,7 @@ async function runBackfill(daysBack) {
|
||||
console.log(`[backfill] complete — inserted ${inserted} records`);
|
||||
}
|
||||
|
||||
app.post('/api/station/backfill', requireDashboardAuth, (req, res) => {
|
||||
app.post('/api/station/backfill', requireDashboardAuth, requireAdmin, (req, res) => {
|
||||
if (backfillState.running) return res.json({ ok: false, error: 'Backfill already running' });
|
||||
const days = Math.min(Math.max(parseInt(req.body?.days || '30'), 1), 365);
|
||||
res.json({ ok: true, days });
|
||||
@@ -1488,7 +1579,7 @@ app.get('/api/station/daily', requireDashboardAuth, (req, res) => {
|
||||
});
|
||||
|
||||
// Re-scan raw readings into the records store (merge only; never lowers a record)
|
||||
app.post('/api/station/records/rebuild', requireDashboardAuth, (_req, res) => {
|
||||
app.post('/api/station/records/rebuild', requireDashboardAuth, requireAdmin, (_req, res) => {
|
||||
const scanned = rebuildRecordsFromFiles();
|
||||
res.json({ ok: true, scanned });
|
||||
});
|
||||
@@ -1623,18 +1714,83 @@ app.get('/api/conditions/public', (_req, res) => {
|
||||
|
||||
// ── Dashboard management APIs ─────────────────────────────────────────────────
|
||||
|
||||
// Change password
|
||||
// Who am I
|
||||
app.get('/api/dashboard/me', requireDashboardAuth, (req, res) => {
|
||||
res.json({ username: req.dashUser.username, role: req.dashUser.role });
|
||||
});
|
||||
|
||||
// Change own password (signs out other sessions, keeps this one)
|
||||
app.post('/api/dashboard/change-password', requireDashboardAuth, (req, res) => {
|
||||
const { currentPassword, newPassword } = req.body || {};
|
||||
if (!currentPassword || !newPassword || newPassword.length < 6)
|
||||
return res.status(400).json({ error: 'Invalid input' });
|
||||
const hash = hashPassword(currentPassword, dashAuth.salt);
|
||||
let match = false;
|
||||
try { match = crypto.timingSafeEqual(Buffer.from(hash, 'hex'), Buffer.from(dashAuth.hash, 'hex')); } catch {}
|
||||
if (!match) return res.status(403).json({ error: 'Current password incorrect' });
|
||||
const salt = crypto.randomBytes(16).toString('hex');
|
||||
dashAuth = { ...dashAuth, salt, hash: hashPassword(newPassword, salt) };
|
||||
try { fs.writeFileSync(DASH_AUTH_FILE, JSON.stringify(dashAuth)); } catch {}
|
||||
if (!currentPassword || !newPassword || newPassword.length < PASSWORD_MIN)
|
||||
return res.status(400).json({ error: `New password must be at least ${PASSWORD_MIN} characters` });
|
||||
const user = req.dashUser;
|
||||
const key = 'pw|' + user.username.toLowerCase();
|
||||
if (isLockedOut(key)) return res.status(429).json({ error: 'Too many attempts — try again in a few minutes' });
|
||||
if (!checkPassword(user, currentPassword)) { noteLoginFailure(key); return res.status(403).json({ error: 'Current password incorrect' }); }
|
||||
loginAttempts.delete(key);
|
||||
setPassword(user, newPassword);
|
||||
saveUsers();
|
||||
res.setHeader('Set-Cookie', sessionCookie(req, signSession(user.username, user.epoch)));
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// ── User management (admin only) ──────────────────────────────────────────────
|
||||
app.get('/api/users', requireDashboardAuth, requireAdmin, (_req, res) => {
|
||||
res.json(usersStore.users.map(publicUser));
|
||||
});
|
||||
|
||||
app.post('/api/users', requireDashboardAuth, requireAdmin, (req, res) => {
|
||||
const { username, password, role } = req.body || {};
|
||||
if (!USERNAME_RE.test(String(username || ''))) return res.status(400).json({ error: 'Username must be 3–32 characters: letters, numbers, . _ -' });
|
||||
if (!password || password.length < PASSWORD_MIN) return res.status(400).json({ error: `Password must be at least ${PASSWORD_MIN} characters` });
|
||||
if (!ROLES.includes(role)) return res.status(400).json({ error: 'Role must be admin or viewer' });
|
||||
if (findUser(username)) return res.status(409).json({ error: 'That username is already taken' });
|
||||
const user = { username, role, epoch: 0, createdAt: Date.now() };
|
||||
setPassword(user, password); user.epoch = 0;
|
||||
usersStore.users.push(user);
|
||||
saveUsers();
|
||||
console.log(`[auth] ${req.dashUser.username} created ${role} "${username}"`);
|
||||
res.status(201).json(publicUser(user));
|
||||
});
|
||||
|
||||
app.patch('/api/users/:username', requireDashboardAuth, requireAdmin, (req, res) => {
|
||||
const user = findUser(req.params.username);
|
||||
if (!user) return res.status(404).json({ error: 'User not found' });
|
||||
const { role, password, disabled } = req.body || {};
|
||||
const isSelf = user.username === req.dashUser.username;
|
||||
const losesAdmin = (role && role !== 'admin') || disabled === true;
|
||||
if (user.role === 'admin' && !user.disabled && losesAdmin) {
|
||||
if (isSelf) return res.status(400).json({ error: "You can't demote or disable your own account" });
|
||||
if (activeAdmins().length <= 1) return res.status(400).json({ error: 'There must be at least one active admin' });
|
||||
}
|
||||
if (role !== undefined) {
|
||||
if (!ROLES.includes(role)) return res.status(400).json({ error: 'Role must be admin or viewer' });
|
||||
user.role = role;
|
||||
}
|
||||
if (disabled !== undefined) {
|
||||
user.disabled = !!disabled;
|
||||
if (user.disabled) user.epoch = (user.epoch || 0) + 1;
|
||||
}
|
||||
if (password !== undefined) {
|
||||
if (typeof password !== 'string' || password.length < PASSWORD_MIN) return res.status(400).json({ error: `Password must be at least ${PASSWORD_MIN} characters` });
|
||||
setPassword(user, password);
|
||||
clearAttemptsFor(user.username);
|
||||
if (isSelf) res.setHeader('Set-Cookie', sessionCookie(req, signSession(user.username, user.epoch)));
|
||||
}
|
||||
saveUsers();
|
||||
console.log(`[auth] ${req.dashUser.username} updated "${user.username}"`);
|
||||
res.json(publicUser(user));
|
||||
});
|
||||
|
||||
app.delete('/api/users/:username', requireDashboardAuth, requireAdmin, (req, res) => {
|
||||
const user = findUser(req.params.username);
|
||||
if (!user) return res.status(404).json({ error: 'User not found' });
|
||||
if (user.username === req.dashUser.username) return res.status(400).json({ error: "You can't delete your own account" });
|
||||
if (user.role === 'admin' && !user.disabled && activeAdmins().length <= 1) return res.status(400).json({ error: 'There must be at least one active admin' });
|
||||
usersStore.users = usersStore.users.filter(u => u !== user);
|
||||
saveUsers();
|
||||
console.log(`[auth] ${req.dashUser.username} deleted "${user.username}"`);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
@@ -1642,7 +1798,7 @@ app.post('/api/dashboard/change-password', requireDashboardAuth, (req, res) => {
|
||||
app.get('/api/dashboard/station-name', requireDashboardAuth, (_req, res) => {
|
||||
res.json({ name: dashAuth?.stationName || 'Genesis 8:22' });
|
||||
});
|
||||
app.post('/api/dashboard/station-name', requireDashboardAuth, (req, res) => {
|
||||
app.post('/api/dashboard/station-name', requireDashboardAuth, requireAdmin, (req, res) => {
|
||||
const { name } = req.body || {};
|
||||
if (!name || typeof name !== 'string' || !name.trim()) return res.status(400).json({ error: 'Invalid name' });
|
||||
dashAuth = { ...dashAuth, stationName: name.trim().slice(0, 60) };
|
||||
@@ -1695,7 +1851,7 @@ app.get('/api/subscriptions', requireDashboardAuth, (_req, res) => {
|
||||
});
|
||||
|
||||
// Update thresholds on all subscriptions at once
|
||||
app.post('/api/subscriptions/thresholds', requireDashboardAuth, (req, res) => {
|
||||
app.post('/api/subscriptions/thresholds', requireDashboardAuth, requireAdmin, (req, res) => {
|
||||
const { thresholds } = req.body || {};
|
||||
if (!thresholds || typeof thresholds !== 'object') return res.status(400).json({ error: 'Invalid' });
|
||||
for (const sub of pushSubscriptions) sub.thresholds = { ...(sub.thresholds || {}), ...thresholds };
|
||||
@@ -1711,7 +1867,7 @@ app.get('/api/dashboard/location', requireDashboardAuth, (_req, res) => {
|
||||
return res.json({ lat: pushSubscriptions[0].lat, lon: pushSubscriptions[0].lon, fromSubscription: true });
|
||||
res.json(null);
|
||||
});
|
||||
app.post('/api/dashboard/location', requireDashboardAuth, (req, res) => {
|
||||
app.post('/api/dashboard/location', requireDashboardAuth, requireAdmin, (req, res) => {
|
||||
const { lat, lon } = req.body || {};
|
||||
if (!isValidCoord(lat, -90, 90) || !isValidCoord(lon, -180, 180))
|
||||
return res.status(400).json({ error: 'Invalid coordinates' });
|
||||
@@ -1805,7 +1961,7 @@ app.get('/api/dashboard/alert-history', requireDashboardAuth, (req, res) => {
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`Weather app running on port ${PORT}`);
|
||||
if (dashAuth) {
|
||||
if (hasUsers()) {
|
||||
console.log(`Dashboard recovery token (changes daily): ${getDailyRecoveryToken()}`);
|
||||
console.log(` Use at http://localhost:${PORT}/dashboard/recover`);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user