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 @@
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 };