From fc114d0ec6fc692ccabcf123d6197efef7ab954b Mon Sep 17 00:00:00 2001 From: Steve Dogiakos Date: Tue, 31 Mar 2026 10:21:49 -0600 Subject: [PATCH] feat: add password reset, SMTP settings, and Add Account button Password reset: users with a registered email can request a reset link from the login screen. A one-hour signed token is emailed via SMTP; clicking the link opens a set-new-password form. Tokens are hashed (SHA-256) before storage and invalidated after use. SMTP settings: admin-only panel in the Users modal lets admins configure host, port, encryption, credentials, and from address. Settings persisted in a new key-value settings table. The SMTP password is never returned to the client. Users: email field added to the create/edit form and stored in a new users.email column. Email is used for password reset lookup. Add Account: admins now have a + button in the header that opens the existing setup wizard to add additional checking accounts. Schema: adds password_reset_tokens and settings tables with automatic runtime migrations for existing databases. --- package.json | 1 + public/index.html | 76 ++++++++++++++++++++++ public/js/app.js | 122 +++++++++++++++++++++++++++++++++-- src/app.js | 3 + src/db/database.js | 25 +++++++ src/db/schema.sql | 13 ++++ src/routes/auth.js | 55 ++++++++++++++++ src/routes/settings.js | 39 +++++++++++ src/routes/users.js | 17 +++-- src/services/emailService.js | 39 +++++++++++ 10 files changed, 378 insertions(+), 12 deletions(-) create mode 100644 src/routes/settings.js create mode 100644 src/services/emailService.js diff --git a/package.json b/package.json index bbc8824..2847747 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "express": "^4.18.3", "express-session": "^1.19.0", "multer": "^2.1.1", + "nodemailer": "^6.9.14", "pdfkit": "^0.15.0" }, "devDependencies": { diff --git a/public/index.html b/public/index.html index 519007c..803f950 100644 --- a/public/index.html +++ b/public/index.html @@ -43,6 +43,39 @@ +
+ Forgot password? +
+ + + + + @@ -52,6 +85,7 @@ ezcheck +
Next check: @@ -620,6 +654,10 @@
+
+ + +
@@ -643,6 +681,44 @@
+ +
+

Email Settings (SMTP)

+
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + + +

Change My Password

diff --git a/public/js/app.js b/public/js/app.js index c289799..b0d99ec 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -40,7 +40,18 @@ function hideLoginOverlay() { document.getElementById('login-overlay').classList.add('hidden'); } +function showLoginSection(section) { + ['login-setup-section', 'login-form-section', 'login-forgot-section', 'login-reset-section'] + .forEach(id => { document.getElementById(id).hidden = id !== section; }); +} + async function checkAuth() { + // Password reset link detection + if (location.hash.startsWith('#reset?')) { + showLoginSection('login-reset-section'); + showLoginOverlay(); + return false; + } // Is there already a session? const res = await fetch('/api/auth/me'); if (res.ok) { @@ -53,11 +64,9 @@ async function checkAuth() { const setup = await fetch('/api/auth/setup-needed'); const { setupNeeded } = await setup.json(); if (setupNeeded) { - document.getElementById('login-setup-section').hidden = false; - document.getElementById('login-form-section').hidden = true; + showLoginSection('login-setup-section'); } else { - document.getElementById('login-setup-section').hidden = true; - document.getElementById('login-form-section').hidden = false; + showLoginSection('login-form-section'); } showLoginOverlay(); return false; @@ -154,6 +163,7 @@ function openUsersModal() { document.getElementById('users-modal').classList.add('open'); loadUsers(); renderUfAccountCheckboxes(); + if (state.user && state.user.role === 'admin') loadSmtpSettings(); } function closeUsersModal() { @@ -236,6 +246,7 @@ function startUserEdit(userId) { usersState.editingId = userId; document.getElementById('user-form-title').textContent = `Edit User: ${u.username}`; document.getElementById('uf-username').value = u.username; + document.getElementById('uf-email').value = u.email || ''; document.getElementById('uf-password').value = ''; document.getElementById('uf-password-hint').textContent = '(leave blank to keep)'; document.getElementById('uf-role').value = u.role; @@ -250,8 +261,9 @@ function cancelUserEdit() { usersState.editingId = null; document.getElementById('user-form-title').textContent = 'Add User'; document.getElementById('uf-username').value = ''; + document.getElementById('uf-email').value = ''; document.getElementById('uf-password').value = ''; - document.getElementById('uf-password-hint').textContent = '(min 8 chars)'; + document.getElementById('uf-password-hint').textContent = '(min 10 chars, include a digit or symbol)'; document.getElementById('uf-role').value = 'viewer'; document.getElementById('btn-save-user').textContent = 'Add User'; document.getElementById('btn-cancel-user-edit').hidden = true; @@ -264,6 +276,7 @@ async function saveUser() { const btn = document.getElementById('btn-save-user'); errEl.hidden = true; const username = document.getElementById('uf-username').value.trim(); + const email = document.getElementById('uf-email').value.trim(); const password = document.getElementById('uf-password').value; const role = document.getElementById('uf-role').value; const accounts = Array.from(document.querySelectorAll('input[name="uf-account"]:checked')) @@ -280,7 +293,7 @@ async function saveUser() { const origText = btn.textContent; btn.textContent = 'Saving…'; try { - const body = { username, role, accounts }; + const body = { username, email, role, accounts }; if (password) body.password = password; if (usersState.editingId) { await apiFetch('PUT', `/api/users/${usersState.editingId}`, body); @@ -1505,6 +1518,91 @@ function escHtml(str) { .replace(/"/g, '"'); } +// ── Forgot / Reset password ────────────────────────────────────────────────── + +async function submitForgotPassword() { + const errEl = document.getElementById('forgot-error'); + const successEl = document.getElementById('forgot-success'); + const btn = document.getElementById('btn-forgot-submit'); + errEl.hidden = true; successEl.hidden = true; + const email = document.getElementById('forgot-email').value.trim(); + if (!email) { errEl.textContent = 'Email is required.'; errEl.hidden = false; return; } + btn.disabled = true; + try { + const data = await apiFetch('POST', '/api/auth/forgot-password', { email }); + if (data) { successEl.textContent = data.message; successEl.hidden = false; } + } catch (err) { + errEl.textContent = err.message; errEl.hidden = false; + } finally { + btn.disabled = false; + } +} + +async function submitResetPassword() { + const errEl = document.getElementById('reset-error'); + const successEl = document.getElementById('reset-success'); + const btn = document.getElementById('btn-reset-submit'); + errEl.hidden = true; successEl.hidden = true; + const password = document.getElementById('reset-password').value; + const password2 = document.getElementById('reset-password2').value; + if (password !== password2) { errEl.textContent = 'Passwords do not match.'; errEl.hidden = false; return; } + const token = new URLSearchParams(location.hash.slice(location.hash.indexOf('?'))).get('token'); + if (!token) { errEl.textContent = 'No reset token found.'; errEl.hidden = false; return; } + btn.disabled = true; + try { + await apiFetch('POST', '/api/auth/reset-password', { token, new_password: password }); + successEl.textContent = 'Password updated. You can now sign in.'; + successEl.hidden = false; + btn.disabled = true; + history.replaceState(null, '', '/'); + setTimeout(() => showLoginSection('login-form-section'), 2000); + } catch (err) { + errEl.textContent = err.message; errEl.hidden = false; + btn.disabled = false; + } +} + +// ── SMTP Settings ───────────────────────────────────────────────────────────── + +async function loadSmtpSettings() { + try { + const s = await apiFetch('GET', '/api/settings/smtp'); + if (!s) return; + document.getElementById('smtp-host').value = s.host; + document.getElementById('smtp-port').value = s.port; + document.getElementById('smtp-secure').value = s.secure ? '1' : '0'; + document.getElementById('smtp-user').value = s.user; + document.getElementById('smtp-from').value = s.from; + document.getElementById('smtp-pass-hint').textContent = s.has_password ? '(leave blank to keep)' : ''; + } catch (_) {} +} + +async function saveSmtpSettings() { + const errEl = document.getElementById('smtp-error'); + const successEl = document.getElementById('smtp-success'); + const btn = document.getElementById('btn-save-smtp'); + errEl.hidden = true; successEl.hidden = true; + btn.disabled = true; + try { + await apiFetch('PUT', '/api/settings/smtp', { + host: document.getElementById('smtp-host').value.trim(), + port: document.getElementById('smtp-port').value, + secure: document.getElementById('smtp-secure').value === '1', + user: document.getElementById('smtp-user').value.trim(), + pass: document.getElementById('smtp-pass').value, + from: document.getElementById('smtp-from').value.trim(), + }); + successEl.textContent = 'Saved.'; successEl.hidden = false; + document.getElementById('smtp-pass').value = ''; + await loadSmtpSettings(); + setTimeout(() => { successEl.hidden = true; }, 3000); + } catch (err) { + errEl.textContent = err.message; errEl.hidden = false; + } finally { + btn.disabled = false; + } +} + // ── Initialization ─────────────────────────────────────────────────────────── async function init() { @@ -1688,6 +1786,14 @@ async function init() { document.getElementById('login-password').addEventListener('keydown', e => { if (e.key === 'Enter') submitLogin(); }); document.getElementById('setup-password2').addEventListener('keydown', e => { if (e.key === 'Enter') submitSetup(); }); + // Forgot / reset password + document.getElementById('link-forgot-password').addEventListener('click', e => { e.preventDefault(); showLoginSection('login-forgot-section'); }); + document.getElementById('link-back-to-login').addEventListener('click', e => { e.preventDefault(); showLoginSection('login-form-section'); }); + document.getElementById('btn-forgot-submit').addEventListener('click', submitForgotPassword); + document.getElementById('forgot-email').addEventListener('keydown', e => { if (e.key === 'Enter') submitForgotPassword(); }); + document.getElementById('btn-reset-submit').addEventListener('click', submitResetPassword); + document.getElementById('reset-password2').addEventListener('keydown', e => { if (e.key === 'Enter') submitResetPassword(); }); + // User management document.getElementById('btn-users').addEventListener('click', openUsersModal); document.getElementById('btn-close-users').addEventListener('click', closeUsersModal); @@ -1696,6 +1802,10 @@ async function init() { document.getElementById('btn-cancel-user-edit').addEventListener('click', cancelUserEdit); document.getElementById('uf-role').addEventListener('change', renderUfAccountCheckboxes); document.getElementById('btn-change-password').addEventListener('click', changeOwnPassword); + document.getElementById('btn-save-smtp').addEventListener('click', saveSmtpSettings); + + // Add checking account + document.getElementById('btn-add-account').addEventListener('click', openWizard); // Initial auth check → loads app if already signed in const authed = await checkAuth(); diff --git a/src/app.js b/src/app.js index e0a5fd7..3f3df53 100644 --- a/src/app.js +++ b/src/app.js @@ -58,6 +58,9 @@ app.use('/api', requireAuth); // ── User management (admin only) ────────────────────────────────────────────── app.use('/api/users', require('./routes/users')); +// ── App settings (admin only) ───────────────────────────────────────────────── +app.use('/api/settings', require('./routes/settings')); + // ── Check routes ────────────────────────────────────────────────────────────── app.use('/api/checks', require('./routes/checks')); diff --git a/src/db/database.js b/src/db/database.js index e49be8e..d03a8b8 100644 --- a/src/db/database.js +++ b/src/db/database.js @@ -122,4 +122,29 @@ db.exec(` CREATE INDEX IF NOT EXISTS idx_layout_account ON layout_fields(account_id); `); +// Migration: add email column to users +const usersInfo = db.prepare('PRAGMA table_info(users)').all(); +if (!usersInfo.some(c => c.name === 'email')) { + db.exec('ALTER TABLE users ADD COLUMN email TEXT'); +} + +// Migration: create password_reset_tokens table +db.exec(` + CREATE TABLE IF NOT EXISTS password_reset_tokens ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token_hash TEXT NOT NULL, + expires_at TEXT NOT NULL, + used_at TEXT + ) +`); + +// Migration: create settings table +db.exec(` + CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT + ) +`); + module.exports = db; diff --git a/src/db/schema.sql b/src/db/schema.sql index cb74e99..3aa2216 100644 --- a/src/db/schema.sql +++ b/src/db/schema.sql @@ -120,3 +120,16 @@ CREATE TABLE IF NOT EXISTS sessions ( expired INTEGER NOT NULL ); CREATE INDEX IF NOT EXISTS idx_sessions_expired ON sessions(expired); + +CREATE TABLE IF NOT EXISTS password_reset_tokens ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token_hash TEXT NOT NULL, + expires_at TEXT NOT NULL, + used_at TEXT +); + +CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT +); diff --git a/src/routes/auth.js b/src/routes/auth.js index 55e9026..dbfc35c 100644 --- a/src/routes/auth.js +++ b/src/routes/auth.js @@ -3,6 +3,7 @@ const express = require('express'); const router = express.Router(); const bcrypt = require('bcryptjs'); +const crypto = require('crypto'); const db = require('../db/database'); // ── Password validation ─────────────────────────────────────────────────────── @@ -145,5 +146,59 @@ router.post('/change-password', async (req, res) => { res.json({ ok: true }); }); +// POST /api/auth/forgot-password — always 200 to avoid user enumeration +router.post('/forgot-password', async (req, res) => { + const { email } = req.body; + if (!email) return res.status(400).json({ error: 'Email is required.' }); + + const user = db.prepare('SELECT id FROM users WHERE email = ? COLLATE NOCASE').get(email.trim()); + if (user) { + const token = crypto.randomBytes(32).toString('hex'); + const tokenHash = crypto.createHash('sha256').update(token).digest('hex'); + const expiresAt = new Date(Date.now() + 60 * 60 * 1000).toISOString(); + + db.transaction(() => { + db.prepare('DELETE FROM password_reset_tokens WHERE user_id = ?').run(user.id); + db.prepare('INSERT INTO password_reset_tokens (user_id, token_hash, expires_at) VALUES (?, ?, ?)').run(user.id, tokenHash, expiresAt); + })(); + + const baseUrl = `${req.protocol}://${req.get('host')}`; + const resetLink = `${baseUrl}/#reset?token=${token}`; + + try { + const { sendPasswordReset } = require('../services/emailService'); + await sendPasswordReset(email.trim(), resetLink); + } catch (err) { + console.error('[password-reset] Failed to send email:', err.message); + } + } + + res.json({ ok: true, message: 'If that email is on file, a reset link has been sent.' }); +}); + +// POST /api/auth/reset-password — validates token, updates password +router.post('/reset-password', async (req, res) => { + const { token, new_password } = req.body; + if (!token || !new_password) return res.status(400).json({ error: 'Token and new password are required.' }); + + const pwErr = validatePassword(new_password); + if (pwErr) return res.status(400).json({ error: pwErr }); + + const tokenHash = crypto.createHash('sha256').update(token).digest('hex'); + const row = db.prepare('SELECT * FROM password_reset_tokens WHERE token_hash = ? AND used_at IS NULL').get(tokenHash); + + if (!row || new Date(row.expires_at) < new Date()) { + return res.status(400).json({ error: 'Invalid or expired reset link.' }); + } + + const hash = await bcrypt.hash(new_password, 12); + db.transaction(() => { + db.prepare("UPDATE users SET password_hash = ?, updated_at = datetime('now') WHERE id = ?").run(hash, row.user_id); + db.prepare("UPDATE password_reset_tokens SET used_at = datetime('now') WHERE id = ?").run(row.id); + })(); + + res.json({ ok: true }); +}); + module.exports = router; module.exports.validatePassword = validatePassword; diff --git a/src/routes/settings.js b/src/routes/settings.js new file mode 100644 index 0000000..d220c62 --- /dev/null +++ b/src/routes/settings.js @@ -0,0 +1,39 @@ +'use strict'; + +const express = require('express'); +const router = express.Router(); +const db = require('../db/database'); +const { requireAdmin } = require('../middleware/auth'); + +router.use(requireAdmin); + +// GET /api/settings/smtp +router.get('/smtp', (req, res) => { + const rows = db.prepare("SELECT key, value FROM settings WHERE key LIKE 'smtp_%'").all(); + const s = Object.fromEntries(rows.map(r => [r.key.replace('smtp_', ''), r.value || ''])); + res.json({ + host: s.host || '', + port: s.port || '587', + secure: s.secure === '1', + user: s.user || '', + from: s.from || '', + has_password: !!(rows.find(r => r.key === 'smtp_pass') || {}).value, + }); +}); + +// PUT /api/settings/smtp +router.put('/smtp', (req, res) => { + const { host, port, secure, user, pass, from } = req.body; + const upsert = db.prepare('INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)'); + db.transaction(() => { + upsert.run('smtp_host', host || ''); + upsert.run('smtp_port', String(parseInt(port, 10) || 587)); + upsert.run('smtp_secure', secure ? '1' : '0'); + upsert.run('smtp_user', user || ''); + if (pass !== undefined && pass !== '') upsert.run('smtp_pass', pass); + upsert.run('smtp_from', from || ''); + })(); + res.json({ ok: true }); +}); + +module.exports = router; diff --git a/src/routes/users.js b/src/routes/users.js index b37a636..b573fd2 100644 --- a/src/routes/users.js +++ b/src/routes/users.js @@ -11,7 +11,7 @@ const { validatePassword } = require('./auth'); router.use(requireAuth, requireAdmin); function userWithAccounts(id) { - const user = db.prepare('SELECT id, username, role, created_at FROM users WHERE id = ?').get(id); + const user = db.prepare('SELECT id, username, email, role, created_at FROM users WHERE id = ?').get(id); if (!user) return null; user.accounts = db.prepare('SELECT account_id, role FROM user_accounts WHERE user_id = ?').all(id); return user; @@ -19,7 +19,7 @@ function userWithAccounts(id) { // GET /api/users router.get('/', (req, res) => { - const users = db.prepare('SELECT id, username, role, created_at FROM users ORDER BY id ASC').all(); + const users = db.prepare('SELECT id, username, email, role, created_at FROM users ORDER BY id ASC').all(); users.forEach(u => { u.accounts = db.prepare('SELECT account_id, role FROM user_accounts WHERE user_id = ?').all(u.id); }); @@ -28,7 +28,7 @@ router.get('/', (req, res) => { // POST /api/users router.post('/', async (req, res) => { - const { username, password, role, accounts } = req.body; + const { username, password, role, accounts, email } = req.body; if (!username || !password) return res.status(400).json({ error: 'Username and password required.' }); if (!['admin', 'editor', 'viewer'].includes(role)) return res.status(400).json({ error: 'Invalid role.' }); const pwErr = validatePassword(password); @@ -39,8 +39,8 @@ router.post('/', async (req, res) => { let userId; try { const result = db.prepare( - 'INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?)' - ).run(username.trim(), hash, role); + 'INSERT INTO users (username, password_hash, role, email) VALUES (?, ?, ?, ?)' + ).run(username.trim(), hash, role, email ? email.trim() : null); userId = result.lastInsertRowid; } catch (err) { if (err.message.includes('UNIQUE')) return res.status(409).json({ error: 'Username already taken.' }); @@ -60,7 +60,7 @@ router.put('/:id', async (req, res) => { const user = db.prepare('SELECT id, role FROM users WHERE id = ?').get(req.params.id); if (!user) return res.status(404).json({ error: 'User not found.' }); - const { username, password, role, accounts } = req.body; + const { username, password, role, accounts, email } = req.body; if (role && !['admin', 'editor', 'viewer'].includes(role)) { return res.status(400).json({ error: 'Invalid role.' }); @@ -81,6 +81,11 @@ router.put('/:id', async (req, res) => { .run(role, req.params.id); } + if (email !== undefined) { + db.prepare("UPDATE users SET email = ?, updated_at = datetime('now') WHERE id = ?") + .run(email ? email.trim() : null, req.params.id); + } + if (password) { const pwErr = validatePassword(password); if (pwErr) return res.status(400).json({ error: pwErr }); diff --git a/src/services/emailService.js b/src/services/emailService.js new file mode 100644 index 0000000..f36129a --- /dev/null +++ b/src/services/emailService.js @@ -0,0 +1,39 @@ +'use strict'; + +const nodemailer = require('nodemailer'); +const db = require('../db/database'); + +function getSmtpSettings() { + const rows = db.prepare("SELECT key, value FROM settings WHERE key LIKE 'smtp_%'").all(); + const s = Object.fromEntries(rows.map(r => [r.key, r.value || ''])); + return { + host: s.smtp_host || '', + port: parseInt(s.smtp_port, 10) || 587, + secure: s.smtp_secure === '1', + user: s.smtp_user || '', + pass: s.smtp_pass || '', + from: s.smtp_from || '', + }; +} + +async function sendPasswordReset(toEmail, resetLink) { + const s = getSmtpSettings(); + if (!s.host || !s.from) { + throw new Error('SMTP is not configured. Ask an admin to configure email settings.'); + } + const transporter = nodemailer.createTransport({ + host: s.host, + port: s.port, + secure: s.secure, + auth: s.user ? { user: s.user, pass: s.pass } : undefined, + }); + await transporter.sendMail({ + from: s.from, + to: toEmail, + subject: 'ezcheck Password Reset', + text: `Click the link below to reset your password. This link expires in 1 hour.\n\n${resetLink}\n\nIf you did not request this, ignore this email.`, + html: `

Click the link below to reset your password. This link expires in 1 hour.

${resetLink}

If you did not request this, ignore this email.

`, + }); +} + +module.exports = { getSmtpSettings, sendPasswordReset };