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
+76
View File
@@ -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
View File
@@ -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, '&quot;');
}
// ── 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();