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.
This commit is contained in:
2026-03-31 10:21:49 -06:00
parent 444e24a191
commit fc114d0ec6
10 changed files with 378 additions and 12 deletions
+3
View File
@@ -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'));
+25
View File
@@ -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;
+13
View File
@@ -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
);
+55
View File
@@ -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;
+39
View File
@@ -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;
+11 -6
View File
@@ -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 });
+39
View File
@@ -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: `<p>Click the link below to reset your password. This link expires in 1 hour.</p><p><a href="${resetLink}">${resetLink}</a></p><p>If you did not request this, ignore this email.</p>`,
});
}
module.exports = { getSmtpSettings, sendPasswordReset };