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:
@@ -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": {
|
||||
|
||||
@@ -43,6 +43,39 @@
|
||||
</div>
|
||||
<div id="login-error" class="wizard-error" hidden></div>
|
||||
<button id="btn-login-submit" class="btn-primary" style="width:100%;margin-top:8px">Sign In</button>
|
||||
<div style="text-align:center;margin-top:8px">
|
||||
<a href="#" id="link-forgot-password" style="font-size:12px;color:var(--text-muted)">Forgot password?</a>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Forgot password -->
|
||||
<div id="login-forgot-section" hidden>
|
||||
<h2>Reset Password</h2>
|
||||
<p class="login-sub">Enter your email and we'll send a reset link.</p>
|
||||
<div class="form-group">
|
||||
<label for="forgot-email">Email</label>
|
||||
<input type="email" id="forgot-email" autocomplete="email">
|
||||
</div>
|
||||
<div id="forgot-error" class="wizard-error" hidden></div>
|
||||
<div id="forgot-success" class="import-result" hidden></div>
|
||||
<button id="btn-forgot-submit" class="btn-primary" style="width:100%;margin-top:8px">Send Reset Link</button>
|
||||
<div style="text-align:center;margin-top:8px">
|
||||
<a href="#" id="link-back-to-login" style="font-size:12px;color:var(--text-muted)">Back to Sign In</a>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Reset password (arrived via emailed link) -->
|
||||
<div id="login-reset-section" hidden>
|
||||
<h2>Set New Password</h2>
|
||||
<div class="form-group">
|
||||
<label for="reset-password">New Password <span class="field-hint">(min 10 chars, include a digit or symbol)</span></label>
|
||||
<input type="password" id="reset-password" autocomplete="new-password">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="reset-password2">Confirm Password</label>
|
||||
<input type="password" id="reset-password2" autocomplete="new-password">
|
||||
</div>
|
||||
<div id="reset-error" class="wizard-error" hidden></div>
|
||||
<div id="reset-success" class="import-result" hidden></div>
|
||||
<button id="btn-reset-submit" class="btn-primary" style="width:100%;margin-top:8px">Set Password</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -52,6 +85,7 @@
|
||||
<span class="header-brand" id="company-name">ezcheck</span>
|
||||
<select id="account-switcher" class="account-switcher" title="Switch account"></select>
|
||||
<button id="btn-account-settings" class="btn-header-icon" title="Account settings" data-admin-only>⚙</button>
|
||||
<button id="btn-add-account" class="btn-header-icon" title="Add checking account" data-admin-only>+</button>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<span class="header-info">Next check: <strong id="current-check-no">—</strong><button id="btn-set-check-no" class="btn-header-inline" title="Set next check number" data-admin-only>✎</button></span>
|
||||
@@ -620,6 +654,10 @@
|
||||
<label for="uf-username">Username</label>
|
||||
<input type="text" id="uf-username" autocapitalize="none">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="uf-email">Email <span class="field-hint">(for password reset)</span></label>
|
||||
<input type="email" id="uf-email" autocomplete="email">
|
||||
</div>
|
||||
<div class="form-group required">
|
||||
<label for="uf-password">Password <span class="field-hint" id="uf-password-hint">(min 10 chars, include a digit or symbol)</span></label>
|
||||
<input type="password" id="uf-password" autocomplete="new-password">
|
||||
@@ -643,6 +681,44 @@
|
||||
<button id="btn-cancel-user-edit" class="btn-ghost" hidden>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- SMTP settings (admin only) -->
|
||||
<div id="smtp-settings-section" style="margin-top:16px;border-top:1px solid var(--border);padding-top:16px">
|
||||
<h3 style="font-size:13px;font-weight:600;margin-bottom:10px">Email Settings (SMTP)</h3>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="smtp-host">SMTP Host</label>
|
||||
<input type="text" id="smtp-host" placeholder="smtp.example.com">
|
||||
</div>
|
||||
<div class="form-group" style="max-width:90px">
|
||||
<label for="smtp-port">Port</label>
|
||||
<input type="number" id="smtp-port" value="587" min="1" max="65535">
|
||||
</div>
|
||||
<div class="form-group" style="max-width:140px">
|
||||
<label for="smtp-secure">Encryption</label>
|
||||
<select id="smtp-secure">
|
||||
<option value="0">STARTTLS (587)</option>
|
||||
<option value="1">SSL/TLS (465)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="smtp-user">Username</label>
|
||||
<input type="text" id="smtp-user" autocomplete="off">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="smtp-pass">Password <span class="field-hint" id="smtp-pass-hint"></span></label>
|
||||
<input type="password" id="smtp-pass" autocomplete="new-password">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="smtp-from">From Address</label>
|
||||
<input type="email" id="smtp-from" placeholder="ezcheck@example.com">
|
||||
</div>
|
||||
</div>
|
||||
<div id="smtp-error" class="wizard-error" hidden></div>
|
||||
<div id="smtp-success" class="import-result" hidden></div>
|
||||
<button id="btn-save-smtp" class="btn-secondary" style="margin-top:8px">Save Email Settings</button>
|
||||
</div>
|
||||
<!-- Change own password -->
|
||||
<div style="margin-top:16px;border-top:1px solid var(--border);padding-top:16px">
|
||||
<h3 style="font-size:13px;font-weight:600;margin-bottom:10px">Change My Password</h3>
|
||||
|
||||
+116
-6
@@ -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();
|
||||
|
||||
@@ -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'));
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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 });
|
||||
|
||||
@@ -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 };
|
||||
Reference in New Issue
Block a user