diff --git a/app/public/dashboard-account.html b/app/public/dashboard-account.html new file mode 100644 index 0000000..99798e3 --- /dev/null +++ b/app/public/dashboard-account.html @@ -0,0 +1,200 @@ + + + + + + + +Account + + + +
+

Account

Loading…
+
+ ← Dashboard + Sign out +
+
+ +
+
+
Change my password
+
+
+
+
+ +

Changing your password signs you out everywhere else.

+
+ + +
+ +

+ + + + diff --git a/app/public/dashboard-login.html b/app/public/dashboard-login.html index 512f837..fbe5aa7 100644 --- a/app/public/dashboard-login.html +++ b/app/public/dashboard-login.html @@ -48,6 +48,7 @@ button:hover { opacity: 0.88; } diff --git a/app/public/dashboard-recover.html b/app/public/dashboard-recover.html index fa83c80..d13bcaf 100644 --- a/app/public/dashboard-recover.html +++ b/app/public/dashboard-recover.html @@ -38,12 +38,12 @@ button:hover { opacity: 0.88; } Dashboard recovery token:
In CasaOS, open the app's log from the container details panel. The token changes daily. - +
- +
← Back to sign in diff --git a/app/public/dashboard-settings.html b/app/public/dashboard-settings.html index e036866..5fc55d7 100644 --- a/app/public/dashboard-settings.html +++ b/app/public/dashboard-settings.html @@ -88,6 +88,7 @@ button.primary:disabled { opacity: 0.5; cursor: not-allowed; }

Settings

← Dashboard + Account Sign out
@@ -204,17 +205,12 @@ button.primary:disabled { opacity: 0.5; cursor: not-allowed; } - +
-
Change Password
-
-
-
-
-
- - -
+
Account & Users
+

Change your password and manage who can sign in (admin and viewer accounts).

+ Open account & users → +
@@ -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', () => { diff --git a/app/public/dashboard-setup.html b/app/public/dashboard-setup.html index ce81b21..c085ff7 100644 --- a/app/public/dashboard-setup.html +++ b/app/public/dashboard-setup.html @@ -30,12 +30,12 @@ button:hover { opacity: 0.88; }

Create Dashboard Account

First-time setup — create your admin account

- +
- + - +

This account protects your station dashboard. Store these credentials somewhere safe.

diff --git a/app/public/dashboard-ui.js b/app/public/dashboard-ui.js index 4f99fd9..3cb5870 100644 --- a/app/public/dashboard-ui.js +++ b/app/public/dashboard-ui.js @@ -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 { diff --git a/app/server.js b/app/server.js index dc01678..fdb57b9 100644 --- a/app/server.js +++ b/app/server.js @@ -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`); }