From f827210a0770c38f013f718f8a90552ea256dd12 Mon Sep 17 00:00:00 2001 From: Steve Dogiakos Date: Wed, 18 Mar 2026 22:55:17 -0600 Subject: [PATCH] Implement user authentication and role-based access control Three-tier user model: admin (all accounts, all actions), editor (assigned accounts, read/write), viewer (assigned accounts, read-only). Backend: - express-session with custom SQLite session store (no extra packages) - bcryptjs for password hashing - src/middleware/auth.js: requireAuth, requireAdmin, requireEditor, canAccessAccount helpers - src/routes/auth.js: login, logout, /me, setup-needed, change-password - src/routes/users.js: full CRUD + account assignments (admin only) - All API routes protected; /api/accounts filtered by user access; write routes gated by requireEditor; admin-only routes locked down Frontend: - Login overlay (full-page) with first-run admin-setup flow - Role-based UI: admin-only elements hidden for non-admins; edit/delete and PDF buttons hidden for viewers; account switcher shows only accessible accounts for non-admins - Users modal (admin only): user list with role badges, create/edit/delete users, set account access via checkboxes - Change-password section available to all logged-in users - apiFetch redirects to login on 401 --- package-lock.json | 64 ++++++++ package.json | 2 + public/css/style.css | 45 ++++++ public/index.html | 112 +++++++++++++- public/js/app.js | 335 +++++++++++++++++++++++++++++++++++++++- src/app.js | 122 +++++++++------ src/db/schema.sql | 22 +++ src/lib/SessionStore.js | 54 +++++++ src/middleware/auth.js | 53 +++++++ src/routes/auth.js | 85 ++++++++++ src/routes/checks.js | 26 +++- src/routes/deposits.js | 10 +- src/routes/users.js | 114 ++++++++++++++ 13 files changed, 978 insertions(+), 66 deletions(-) create mode 100644 src/lib/SessionStore.js create mode 100644 src/middleware/auth.js create mode 100644 src/routes/auth.js create mode 100644 src/routes/users.js diff --git a/package-lock.json b/package-lock.json index b24a243..d8751c1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,8 +8,10 @@ "name": "ezcheck", "version": "0.1.0", "dependencies": { + "bcryptjs": "^3.0.3", "better-sqlite3": "^9.4.3", "express": "^4.18.3", + "express-session": "^1.19.0", "multer": "^2.1.1", "pdfkit": "^0.15.0" }, @@ -129,6 +131,15 @@ ], "license": "MIT" }, + "node_modules/bcryptjs": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz", + "integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==", + "license": "BSD-3-Clause", + "bin": { + "bcrypt": "bin/bcrypt" + } + }, "node_modules/better-sqlite3": { "version": "9.6.0", "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-9.6.0.tgz", @@ -717,6 +728,29 @@ "url": "https://opencollective.com/express" } }, + "node_modules/express-session": { + "version": "1.19.0", + "resolved": "https://registry.npmjs.org/express-session/-/express-session-1.19.0.tgz", + "integrity": "sha512-0csaMkGq+vaiZTmSMMGkfdCOabYv192VbytFypcvI0MANrp+4i/7yEkJ0sbAEhycQjntaKGzYfjfXQyVb7BHMA==", + "license": "MIT", + "dependencies": { + "cookie": "~0.7.2", + "cookie-signature": "~1.0.7", + "debug": "~2.6.9", + "depd": "~2.0.0", + "on-headers": "~1.1.0", + "parseurl": "~1.3.3", + "safe-buffer": "~5.2.1", + "uid-safe": "~2.1.5" + }, + "engines": { + "node": ">= 0.8.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/file-uri-to-path": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", @@ -1679,6 +1713,15 @@ "node": ">= 0.8" } }, + "node_modules/on-headers": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", + "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -1821,6 +1864,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/random-bytes": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/random-bytes/-/random-bytes-1.0.0.tgz", + "integrity": "sha512-iv7LhNVO047HzYR3InF6pUcUsPQiHTM1Qal51DcGSuZFBil1aBBWG5eHPNek7bvILMaYJ/8RU1e8w1AMdHmLQQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/range-parser": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", @@ -2345,6 +2397,18 @@ "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", "license": "MIT" }, + "node_modules/uid-safe": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.1.5.tgz", + "integrity": "sha512-KPHm4VL5dDXKz01UuEd88Df+KzynaohSL9fBh096KWAxSKZQDI2uBrVqtvRM4rwrIrRRKsdLNML/lnaaVSRioA==", + "license": "MIT", + "dependencies": { + "random-bytes": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/undefsafe": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", diff --git a/package.json b/package.json index ae7b43b..8da1b68 100644 --- a/package.json +++ b/package.json @@ -9,8 +9,10 @@ "migrate": "node migrations/import-mdb.js" }, "dependencies": { + "bcryptjs": "^3.0.3", "better-sqlite3": "^9.4.3", "express": "^4.18.3", + "express-session": "^1.19.0", "multer": "^2.1.1", "pdfkit": "^0.15.0" }, diff --git a/public/css/style.css b/public/css/style.css index ce048ad..be7df09 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -42,6 +42,8 @@ header { .header-info { font-size: 12px; color: rgba(255,255,255,0.7); } .header-info strong { color: #fff; } .header-left { display: flex; align-items: center; gap: 10px; } +.header-right { display: flex; align-items: center; gap: 10px; } +.header-username { font-size: 12px; color: rgba(255,255,255,0.7); } .account-switcher { background: rgba(255,255,255,0.15); @@ -760,3 +762,46 @@ input[type="file"] { flex-wrap: wrap; } .dep-pdf-btns { display: flex; gap: 6px; } + +/* ── Login overlay ── */ +.login-overlay { + position: fixed; + inset: 0; + background: var(--header-bg); + display: flex; + align-items: center; + justify-content: center; + z-index: 2000; +} +.login-overlay.hidden { display: none; } +.login-card { + background: #fff; + border-radius: 8px; + padding: 32px; + width: 360px; + box-shadow: 0 8px 32px rgba(0,0,0,0.35); +} +.login-logo { + font-size: 22px; + font-weight: 700; + color: var(--header-bg); + margin-bottom: 20px; + letter-spacing: -0.5px; +} +.login-card h2 { font-size: 16px; font-weight: 600; margin-bottom: 4px; } +.login-sub { font-size: 12px; color: var(--text-muted); margin-bottom: 16px; } + +/* ── User management ── */ +.account-checkboxes { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 4px; } +.account-checkbox-label { + display: flex; + align-items: center; + gap: 5px; + font-size: 12px; + background: var(--bg); + border: 1px solid var(--border); + border-radius: 4px; + padding: 3px 8px; + cursor: pointer; +} +.account-checkbox-label:hover { border-color: var(--primary); } diff --git a/public/index.html b/public/index.html index 7d6bd99..830b3c4 100644 --- a/public/index.html +++ b/public/index.html @@ -7,13 +7,58 @@ + +
+ +
+
ezcheck - + +
+
+ Next check: + + +
- Next check:
@@ -559,6 +604,69 @@ + + + + diff --git a/public/js/app.js b/public/js/app.js index 34fcd5c..d3c30bc 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -5,7 +5,7 @@ const state = { account: null, accounts: [], activeAccountId: parseInt(localStorage.getItem('activeAccountId'), 10) || null, - filterStatus: '', // '' = all, '0' = unprinted, '1' = printed + filterStatus: '', filterPayee: '', filterDateFrom: '', filterDateTo: '', @@ -13,6 +13,7 @@ const state = { sortDir: 'desc', selected: new Set(), editingId: null, + user: null, // { id, username, role } }; // ── API helpers ────────────────────────────────────────────────────────────── @@ -21,19 +22,315 @@ async function apiFetch(method, path, body) { const opts = { method, headers: { 'Content-Type': 'application/json' } }; if (body !== undefined) opts.body = JSON.stringify(body); const res = await fetch(path, opts); + if (res.status === 401) { showLoginOverlay(); return null; } if (res.status === 204) return null; const data = await res.json(); if (!res.ok) throw new Error(data.error || res.statusText); return data; } +// ── Auth ────────────────────────────────────────────────────────────────────── + +function showLoginOverlay() { + document.getElementById('login-overlay').classList.remove('hidden'); +} + +function hideLoginOverlay() { + document.getElementById('login-overlay').classList.add('hidden'); +} + +async function checkAuth() { + // Is there already a session? + const res = await fetch('/api/auth/me'); + if (res.ok) { + state.user = await res.json(); + hideLoginOverlay(); + applyRoleUI(); + return true; + } + // No session — check if this is first-run (no users at all) + 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; + } else { + document.getElementById('login-setup-section').hidden = true; + document.getElementById('login-form-section').hidden = false; + } + showLoginOverlay(); + return false; +} + +async function submitLogin() { + const username = document.getElementById('login-username').value.trim(); + const password = document.getElementById('login-password').value; + const errEl = document.getElementById('login-error'); + const btn = document.getElementById('btn-login-submit'); + errEl.hidden = true; + btn.disabled = true; + btn.textContent = 'Signing in…'; + try { + state.user = await apiFetch('POST', '/api/auth/login', { username, password }); + if (!state.user) return; // 401 already handled by apiFetch + hideLoginOverlay(); + applyRoleUI(); + await loadAccounts(); + } catch (err) { + errEl.textContent = err.message; + errEl.hidden = false; + } finally { + btn.disabled = false; + btn.textContent = 'Sign In'; + } +} + +async function submitSetup() { + const username = document.getElementById('setup-username').value.trim(); + const password = document.getElementById('setup-password').value; + const password2 = document.getElementById('setup-password2').value; + const errEl = document.getElementById('setup-error'); + const btn = document.getElementById('btn-setup-submit'); + errEl.hidden = true; + if (password !== password2) { errEl.textContent = 'Passwords do not match.'; errEl.hidden = false; return; } + btn.disabled = true; + btn.textContent = 'Creating…'; + try { + state.user = await apiFetch('POST', '/api/auth/setup', { username, password }); + hideLoginOverlay(); + applyRoleUI(); + await loadAccounts(); + } catch (err) { + errEl.textContent = err.message; + errEl.hidden = false; + } finally { + btn.disabled = false; + btn.textContent = 'Create Admin & Sign In'; + } +} + +async function logout() { + await fetch('/api/auth/logout', { method: 'POST' }); + state.user = null; + state.checks = []; + state.accounts = []; + state.account = null; + state.activeAccountId = null; + document.getElementById('login-username').value = ''; + document.getElementById('login-password').value = ''; + document.getElementById('login-error').hidden = true; + document.getElementById('login-setup-section').hidden = true; + document.getElementById('login-form-section').hidden = false; + showLoginOverlay(); +} + +// Hide/show elements based on role +function applyRoleUI() { + const role = state.user ? state.user.role : 'viewer'; + const isAdmin = role === 'admin'; + const isEditor = role === 'admin' || role === 'editor'; + + document.getElementById('header-username').textContent = state.user ? state.user.username : ''; + + // Admin-only elements + document.querySelectorAll('[data-admin-only]').forEach(el => { el.hidden = !isAdmin; }); + + // Editor+ elements (hide for viewers) + document.querySelectorAll('[data-editor-only]').forEach(el => { el.hidden = !isEditor; }); + + // Users button (admin only) + document.getElementById('btn-users').hidden = !isAdmin; +} + +// ── User management ──────────────────────────────────────────────────────────── + +let usersState = { users: [], editingId: null }; + +function openUsersModal() { + document.getElementById('user-form-error').hidden = true; + document.getElementById('users-overlay').classList.add('open'); + document.getElementById('users-modal').classList.add('open'); + loadUsers(); + renderUfAccountCheckboxes(); +} + +function closeUsersModal() { + document.getElementById('users-overlay').classList.remove('open'); + document.getElementById('users-modal').classList.remove('open'); + cancelUserEdit(); +} + +async function loadUsers() { + try { + usersState.users = await apiFetch('GET', '/api/users'); + renderUsersList(); + } catch (err) { + document.getElementById('users-list').innerHTML = + `

${escHtml(err.message)}

`; + } +} + +function roleBadge(role) { + const colors = { admin: '#2563eb', editor: '#16a34a', viewer: '#6b7280' }; + return `${role}`; +} + +function renderUsersList() { + const el = document.getElementById('users-list'); + const { users } = usersState; + if (!users.length) { el.innerHTML = '

No users.

'; return; } + + el.innerHTML = ` + + + ${users.map(u => { + const isSelf = u.id === state.user.id; + const accountsLabel = u.role === 'admin' + ? 'All accounts' + : (u.accounts.length ? u.accounts.map(aid => { + const a = state.accounts.find(x => x.id === aid); + return escHtml(a ? (a.company1 || `Account ${a.id}`) : `#${aid}`); + }).join(', ') : 'None'); + return ` + + + + + `; + }).join('')} +
UsernameRoleAccount Access
${escHtml(u.username)}${isSelf ? ' (you)' : ''}${roleBadge(u.role)}${accountsLabel} + + ${!isSelf ? `` : ''} +
`; +} + +function renderUfAccountCheckboxes() { + const role = document.getElementById('uf-role').value; + const group = document.getElementById('uf-accounts-group'); + group.hidden = role === 'admin'; + const container = document.getElementById('uf-accounts-checkboxes'); + const currentAccounts = usersState.editingId + ? (usersState.users.find(u => u.id === usersState.editingId) || {}).accounts || [] + : []; + container.innerHTML = state.accounts.map(a => + `` + ).join(''); +} + +function startUserEdit(userId) { + const u = usersState.users.find(x => x.id === userId); + if (!u) return; + usersState.editingId = userId; + document.getElementById('user-form-title').textContent = `Edit User: ${u.username}`; + document.getElementById('uf-username').value = u.username; + document.getElementById('uf-password').value = ''; + document.getElementById('uf-password-hint').textContent = '(leave blank to keep)'; + document.getElementById('uf-role').value = u.role; + document.getElementById('btn-save-user').textContent = 'Save Changes'; + document.getElementById('btn-cancel-user-edit').hidden = false; + document.getElementById('user-form-error').hidden = true; + renderUfAccountCheckboxes(); + document.getElementById('uf-username').scrollIntoView({ behavior: 'smooth', block: 'nearest' }); +} + +function cancelUserEdit() { + usersState.editingId = null; + document.getElementById('user-form-title').textContent = 'Add User'; + document.getElementById('uf-username').value = ''; + document.getElementById('uf-password').value = ''; + document.getElementById('uf-password-hint').textContent = '(min 8 chars)'; + document.getElementById('uf-role').value = 'viewer'; + document.getElementById('btn-save-user').textContent = 'Add User'; + document.getElementById('btn-cancel-user-edit').hidden = true; + document.getElementById('user-form-error').hidden = true; + renderUfAccountCheckboxes(); +} + +async function saveUser() { + const errEl = document.getElementById('user-form-error'); + const btn = document.getElementById('btn-save-user'); + errEl.hidden = true; + const username = document.getElementById('uf-username').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')) + .map(cb => parseInt(cb.value, 10)); + + if (!username) { errEl.textContent = 'Username required.'; errEl.hidden = false; return; } + if (!usersState.editingId && !password) { errEl.textContent = 'Password required.'; errEl.hidden = false; return; } + + btn.disabled = true; + const origText = btn.textContent; + btn.textContent = 'Saving…'; + try { + const body = { username, role, accounts }; + if (password) body.password = password; + if (usersState.editingId) { + await apiFetch('PUT', `/api/users/${usersState.editingId}`, body); + } else { + await apiFetch('POST', '/api/users', body); + } + cancelUserEdit(); + await loadUsers(); + } catch (err) { + errEl.textContent = err.message; + errEl.hidden = false; + } finally { + btn.disabled = false; + btn.textContent = origText; + } +} + +async function deleteUser(userId) { + const u = usersState.users.find(x => x.id === userId); + if (!u) return; + if (!confirm(`Delete user "${u.username}"? This cannot be undone.`)) return; + try { + await apiFetch('DELETE', `/api/users/${userId}`); + if (usersState.editingId === userId) cancelUserEdit(); + await loadUsers(); + } catch (err) { + alert('Delete failed: ' + err.message); + } +} + +async function changeOwnPassword() { + const errEl = document.getElementById('cp-error'); + const successEl = document.getElementById('cp-success'); + const btn = document.getElementById('btn-change-password'); + errEl.hidden = true; + successEl.hidden = true; + const current = document.getElementById('cp-current').value; + const next = document.getElementById('cp-new').value; + const confirm2 = document.getElementById('cp-confirm').value; + if (next !== confirm2) { errEl.textContent = 'New passwords do not match.'; errEl.hidden = false; return; } + btn.disabled = true; + try { + await apiFetch('POST', '/api/auth/change-password', { current_password: current, new_password: next }); + document.getElementById('cp-current').value = ''; + document.getElementById('cp-new').value = ''; + document.getElementById('cp-confirm').value = ''; + successEl.hidden = false; + } catch (err) { + errEl.textContent = err.message; + errEl.hidden = false; + } finally { + btn.disabled = false; + } +} + // ── Data loading ───────────────────────────────────────────────────────────── async function loadAccounts() { try { state.accounts = await apiFetch('GET', '/api/accounts'); + if (!state.accounts) return; // 401 redirect handled by apiFetch if (state.accounts.length === 0) { - openWizard(); + // Only admins can create accounts; non-admins just see an empty state + if (state.user && state.user.role === 'admin') openWizard(); return; } // Use stored account or default to first @@ -133,14 +430,19 @@ function renderRow(c) { }) : '—'; - const checkbox = ``; + const checkbox = isEditor + ? `` + : ``; const statusBadge = printed ? 'Printed' : 'Unprinted'; - const actions = `` + - ``; + const isEditor = state.user && (state.user.role === 'admin' || state.user.role === 'editor'); + const actions = isEditor + ? `` + + `` + : ''; return ` ${checkbox} @@ -1180,7 +1482,7 @@ function escHtml(str) { // ── Initialization ─────────────────────────────────────────────────────────── -function init() { +async function init() { // Column sort document.querySelectorAll('thead th.sortable').forEach(th => { th.addEventListener('click', () => { @@ -1354,8 +1656,25 @@ function init() { document.getElementById('btn-qbo-checks-cancel').addEventListener('click', closeQboImport); document.getElementById('btn-qbo-deposits-cancel').addEventListener('click', closeQboImport); - // Initial data load - loadAccounts(); + // Auth event listeners + document.getElementById('btn-login-submit').addEventListener('click', submitLogin); + document.getElementById('btn-setup-submit').addEventListener('click', submitSetup); + document.getElementById('btn-logout').addEventListener('click', logout); + document.getElementById('login-password').addEventListener('keydown', e => { if (e.key === 'Enter') submitLogin(); }); + document.getElementById('setup-password2').addEventListener('keydown', e => { if (e.key === 'Enter') submitSetup(); }); + + // User management + document.getElementById('btn-users').addEventListener('click', openUsersModal); + document.getElementById('btn-close-users').addEventListener('click', closeUsersModal); + document.getElementById('users-overlay').addEventListener('click', closeUsersModal); + document.getElementById('btn-save-user').addEventListener('click', saveUser); + document.getElementById('btn-cancel-user-edit').addEventListener('click', cancelUserEdit); + document.getElementById('uf-role').addEventListener('change', renderUfAccountCheckboxes); + document.getElementById('btn-change-password').addEventListener('click', changeOwnPassword); + + // Initial auth check → loads app if already signed in + const authed = await checkAuth(); + if (authed) await loadAccounts(); } document.addEventListener('DOMContentLoaded', init); diff --git a/src/app.js b/src/app.js index 0155f57..f153055 100644 --- a/src/app.js +++ b/src/app.js @@ -1,36 +1,80 @@ 'use strict'; -const express = require('express'); -const path = require('path'); -const fs = require('fs'); -const os = require('os'); +const express = require('express'); +const path = require('path'); +const fs = require('fs'); +const os = require('os'); +const crypto = require('crypto'); const { execFileSync } = require('child_process'); -const multer = require('multer'); +const multer = require('multer'); +const session = require('express-session'); -const app = express(); +const db = require('./db/database'); +const { requireAuth, requireAdmin, requireEditor, canAccessAccount } = require('./middleware/auth'); + +const app = express(); const upload = multer({ dest: os.tmpdir() }); -app.use(express.json()); +// ── Session store (SQLite-backed, no extra packages) ────────────────────────── +const SessionStore = require('./lib/SessionStore'); + +const SESSION_SECRET = process.env.SESSION_SECRET || + (() => { console.warn('[warn] SESSION_SECRET not set — using random secret (sessions reset on restart)'); return crypto.randomBytes(32).toString('hex'); })(); + +app.use(session({ + store: new SessionStore(db), + secret: SESSION_SECRET, + resave: false, + saveUninitialized: false, + cookie: { httpOnly: true, sameSite: 'lax', maxAge: 7 * 24 * 60 * 60 * 1000 }, // 7 days +})); + +app.use(express.json({ limit: '10mb' })); app.use(express.static(path.join(__dirname, '../public'))); -// Routes -app.use('/api/checks', require('./routes/checks')); -app.use('/api/pdf', require('./routes/pdf')); -app.use('/api/deposits', require('./routes/deposits')); -app.use('/api/deposit-pdf', require('./routes/deposit-pdf')); +// ── Auth routes (public — no requireAuth) ───────────────────────────────────── +app.use('/api/auth', require('./routes/auth')); -// GET /api/accounts - list all accounts (id + display name) +// ── All routes below require authentication ─────────────────────────────────── +app.use('/api', requireAuth); + +// ── User management (admin only) ────────────────────────────────────────────── +app.use('/api/users', require('./routes/users')); + +// ── Check routes ────────────────────────────────────────────────────────────── +app.use('/api/checks', require('./routes/checks')); + +// ── PDF (editor+) ───────────────────────────────────────────────────────────── +app.use('/api/pdf', requireEditor, require('./routes/pdf')); + +// ── Deposits ────────────────────────────────────────────────────────────────── +app.use('/api/deposits', require('./routes/deposits')); +app.use('/api/deposit-pdf', requireEditor, require('./routes/deposit-pdf')); + +// ── QBO import (editor+) ────────────────────────────────────────────────────── +app.use('/api/qbo-import', requireEditor, require('./routes/qbo-import')); + +// ── Accounts list — filtered by role ───────────────────────────────────────── app.get('/api/accounts', (req, res) => { - const db = require('./db/database'); - const accounts = db.prepare( - 'SELECT id, company1, bank_name, current_check_no FROM account ORDER BY id ASC' - ).all(); + let accounts; + if (req.session.role === 'admin') { + accounts = db.prepare( + 'SELECT id, company1, bank_name, current_check_no FROM account ORDER BY id ASC' + ).all(); + } else { + accounts = db.prepare(` + SELECT a.id, a.company1, a.bank_name, a.current_check_no + FROM account a + JOIN user_accounts ua ON ua.account_id = a.id + WHERE ua.user_id = ? + ORDER BY a.id ASC + `).all(req.session.userId); + } res.json(accounts); }); -// PUT /api/account/:id - update account settings -app.put('/api/account/:id', (req, res) => { - const db = require('./db/database'); +// ── Account settings (admin only) ───────────────────────────────────────────── +app.put('/api/account/:id', requireAdmin, (req, res) => { const account = db.prepare('SELECT id FROM account WHERE id = ?').get(req.params.id); if (!account) return res.status(404).json({ error: 'Account not found.' }); @@ -74,9 +118,11 @@ app.put('/api/account/:id', (req, res) => { ).get(req.params.id)); }); -// GET /api/account/:id - get full account by id +// GET /api/account/:id — any authenticated user with access app.get('/api/account/:id', (req, res) => { - const db = require('./db/database'); + if (!canAccessAccount(req.session, parseInt(req.params.id, 10))) { + return res.status(403).json({ error: 'Access denied.' }); + } const account = db.prepare( 'SELECT id, bank_name, bank_info1, bank_info2, bank_info3, transit_code, ' + 'routing_number, account_number, current_check_no, ' + @@ -86,9 +132,8 @@ app.get('/api/account/:id', (req, res) => { res.json(account); }); -// PUT /api/account/:id/check-no - override the next check number -app.put('/api/account/:id/check-no', (req, res) => { - const db = require('./db/database'); +// PUT /api/account/:id/check-no (admin only) +app.put('/api/account/:id/check-no', requireAdmin, (req, res) => { const account = db.prepare('SELECT id FROM account WHERE id = ?').get(req.params.id); if (!account) return res.status(404).json({ error: 'Account not found.' }); @@ -97,33 +142,30 @@ app.put('/api/account/:id/check-no', (req, res) => { return res.status(400).json({ error: 'Next check number must be a positive integer.' }); } - // current_check_no is the last-used number; next check will be current_check_no + 1 db.prepare("UPDATE account SET current_check_no = ?, updated_at = datetime('now') WHERE id = ?") .run(next - 1, req.params.id); res.json({ next_check_no: next }); }); -// DELETE /api/account/:id - delete account and all associated data -app.delete('/api/account/:id', (req, res) => { - const db = require('./db/database'); +// DELETE /api/account/:id (admin only) +app.delete('/api/account/:id', requireAdmin, (req, res) => { const account = db.prepare('SELECT id FROM account WHERE id = ?').get(req.params.id); if (!account) return res.status(404).json({ error: 'Account not found.' }); db.transaction(() => { - // deposit_items deleted via ON DELETE CASCADE from deposits db.prepare('DELETE FROM deposits WHERE account_id = ?').run(req.params.id); db.prepare('DELETE FROM checks WHERE account_id = ?').run(req.params.id); db.prepare('DELETE FROM layout_fields WHERE account_id = ?').run(req.params.id); + db.prepare('DELETE FROM user_accounts WHERE account_id = ?').run(req.params.id); db.prepare('DELETE FROM account WHERE id = ?').run(req.params.id); })(); res.status(204).end(); }); -// POST /api/account/setup - create a new account (wizard) -app.post('/api/account/setup', (req, res) => { - const db = require('./db/database'); +// POST /api/account/setup (admin only — creates a new checking account) +app.post('/api/account/setup', requireAdmin, (req, res) => { const { company1, company2, company3, company4, bank_name, bank_info1, bank_info2, transit_code, @@ -167,16 +209,9 @@ app.post('/api/account/setup', (req, res) => { res.status(201).json({ success: true, accountId: result.lastInsertRowid }); }); -// TODO: Add basic auth or simple password gate for any network-exposed deployment - -// TODO: Add deposit slip support -- deposits table, PDF generation, ledger, and slide-in entry form - -app.use('/api/qbo-import', require('./routes/qbo-import')); - -// .mdb import endpoint — always creates a new account -app.post('/api/import', upload.single('mdbfile'), (req, res) => { +// .mdb import (admin only) +app.post('/api/import', requireAdmin, upload.single('mdbfile'), (req, res) => { if (!req.file) return res.status(400).json({ error: 'No file uploaded.' }); - const db = require('./db/database'); const tmpPath = req.file.path; try { const output = execFileSync( @@ -184,7 +219,6 @@ app.post('/api/import', upload.single('mdbfile'), (req, res) => { [path.join(__dirname, '../migrations/import-mdb.js'), '--file', tmpPath], { encoding: 'utf8', timeout: 120000, env: process.env } ); - // Grab the newly created account (highest id) const newAccount = db.prepare('SELECT id, company1 FROM account ORDER BY id DESC LIMIT 1').get(); res.json({ success: true, log: output, newAccountId: newAccount ? newAccount.id : null }); } catch (err) { @@ -197,7 +231,7 @@ app.post('/api/import', upload.single('mdbfile'), (req, res) => { } }); -// Catch-all: serve index.html for client-side routing +// Catch-all: serve index.html app.get('*', (req, res) => { res.sendFile(path.join(__dirname, '../public/index.html')); }); diff --git a/src/db/schema.sql b/src/db/schema.sql index a687c93..920810c 100644 --- a/src/db/schema.sql +++ b/src/db/schema.sql @@ -97,3 +97,25 @@ CREATE TABLE IF NOT EXISTS deposit_items ( CREATE INDEX IF NOT EXISTS idx_deposits_account ON deposits(account_id); CREATE INDEX IF NOT EXISTS idx_deposit_items ON deposit_items(deposit_id); + +CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL UNIQUE COLLATE NOCASE, + password_hash TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'viewer' CHECK(role IN ('admin','editor','viewer')), + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS user_accounts ( + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + account_id INTEGER NOT NULL REFERENCES account(id) ON DELETE CASCADE, + PRIMARY KEY (user_id, account_id) +); + +CREATE TABLE IF NOT EXISTS sessions ( + sid TEXT PRIMARY KEY, + sess TEXT NOT NULL, + expired INTEGER NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_sessions_expired ON sessions(expired); diff --git a/src/lib/SessionStore.js b/src/lib/SessionStore.js new file mode 100644 index 0000000..b169f1e --- /dev/null +++ b/src/lib/SessionStore.js @@ -0,0 +1,54 @@ +'use strict'; + +const { Store } = require('express-session'); + +// SQLite-backed session store using the existing better-sqlite3 db instance. +// No additional npm packages required. +class SessionStore extends Store { + constructor(db) { + super(); + this.db = db; + // Purge expired sessions every 10 minutes + setInterval(() => { + try { db.prepare('DELETE FROM sessions WHERE expired < ?').run(Date.now()); } catch (_) {} + }, 10 * 60 * 1000).unref(); + } + + get(sid, cb) { + try { + const row = this.db.prepare('SELECT sess, expired FROM sessions WHERE sid = ?').get(sid); + if (!row) return cb(null, null); + if (Date.now() > row.expired) { + this.destroy(sid, () => {}); + return cb(null, null); + } + cb(null, JSON.parse(row.sess)); + } catch (e) { cb(e); } + } + + set(sid, sess, cb) { + try { + const maxAge = (sess.cookie && sess.cookie.maxAge) + ? sess.cookie.maxAge * 1000 + : 7 * 24 * 60 * 60 * 1000; + const expired = Date.now() + maxAge; + this.db.prepare( + 'INSERT OR REPLACE INTO sessions (sid, sess, expired) VALUES (?, ?, ?)' + ).run(sid, JSON.stringify(sess), expired); + cb(null); + } catch (e) { cb(e); } + } + + destroy(sid, cb) { + try { + this.db.prepare('DELETE FROM sessions WHERE sid = ?').run(sid); + cb(null); + } catch (e) { cb(e); } + } + + touch(sid, sess, cb) { + this.set(sid, sess, cb); + } +} + +module.exports = SessionStore; diff --git a/src/middleware/auth.js b/src/middleware/auth.js new file mode 100644 index 0000000..0d5b122 --- /dev/null +++ b/src/middleware/auth.js @@ -0,0 +1,53 @@ +'use strict'; + +const db = require('../db/database'); + +function requireAuth(req, res, next) { + if (!req.session || !req.session.userId) { + return res.status(401).json({ error: 'Not authenticated.' }); + } + next(); +} + +function requireAdmin(req, res, next) { + if (!req.session || req.session.role !== 'admin') { + return res.status(403).json({ error: 'Admin access required.' }); + } + next(); +} + +// Blocks viewers; allows admin and editor +function requireEditor(req, res, next) { + if (!req.session || req.session.role === 'viewer') { + return res.status(403).json({ error: 'Write access required.' }); + } + next(); +} + +// Returns true if the current session user can access the given account +function canAccessAccount(session, accountId) { + if (!session || !session.userId) return false; + if (session.role === 'admin') return true; + const row = db.prepare( + 'SELECT 1 FROM user_accounts WHERE user_id = ? AND account_id = ?' + ).get(session.userId, accountId); + return !!row; +} + +// Middleware factory — resolves accountId via a callback on req, then checks access +function requireAccountAccess(getAccountId) { + return (req, res, next) => { + if (!req.session || !req.session.userId) { + return res.status(401).json({ error: 'Not authenticated.' }); + } + if (req.session.role === 'admin') return next(); + const accountId = parseInt(getAccountId(req), 10); + if (!accountId) return next(); // route handler will deal with missing param + if (!canAccessAccount(req.session, accountId)) { + return res.status(403).json({ error: 'Access denied.' }); + } + next(); + }; +} + +module.exports = { requireAuth, requireAdmin, requireEditor, requireAccountAccess, canAccessAccount }; diff --git a/src/routes/auth.js b/src/routes/auth.js new file mode 100644 index 0000000..3fd9806 --- /dev/null +++ b/src/routes/auth.js @@ -0,0 +1,85 @@ +'use strict'; + +const express = require('express'); +const router = express.Router(); +const bcrypt = require('bcryptjs'); +const db = require('../db/database'); + +// GET /api/auth/setup-needed — true when no users exist (first-run) +router.get('/setup-needed', (req, res) => { + const { n } = db.prepare('SELECT COUNT(*) AS n FROM users').get(); + res.json({ setupNeeded: n === 0 }); +}); + +// POST /api/auth/setup — create the first admin (only works when no users exist) +router.post('/setup', async (req, res) => { + const { n } = db.prepare('SELECT COUNT(*) AS n FROM users').get(); + if (n > 0) return res.status(409).json({ error: 'Setup already complete.' }); + + const { username, password } = req.body; + if (!username || !password) return res.status(400).json({ error: 'Username and password required.' }); + if (password.length < 8) return res.status(400).json({ error: 'Password must be at least 8 characters.' }); + + const hash = await bcrypt.hash(password, 12); + const result = db.prepare( + "INSERT INTO users (username, password_hash, role) VALUES (?, ?, 'admin')" + ).run(username.trim(), hash); + + req.session.userId = result.lastInsertRowid; + req.session.username = username.trim(); + req.session.role = 'admin'; + + res.status(201).json({ id: result.lastInsertRowid, username: username.trim(), role: 'admin' }); +}); + +// POST /api/auth/login +router.post('/login', async (req, res) => { + const { username, password } = req.body; + if (!username || !password) return res.status(400).json({ error: 'Username and password required.' }); + + const user = db.prepare('SELECT * FROM users WHERE username = ? COLLATE NOCASE').get(username.trim()); + if (!user) return res.status(401).json({ error: 'Invalid username or password.' }); + + const match = await bcrypt.compare(password, user.password_hash); + if (!match) return res.status(401).json({ error: 'Invalid username or password.' }); + + req.session.userId = user.id; + req.session.username = user.username; + req.session.role = user.role; + + res.json({ id: user.id, username: user.username, role: user.role }); +}); + +// POST /api/auth/logout +router.post('/logout', (req, res) => { + req.session.destroy(() => res.status(204).end()); +}); + +// GET /api/auth/me +router.get('/me', (req, res) => { + if (!req.session || !req.session.userId) { + return res.status(401).json({ error: 'Not authenticated.' }); + } + res.json({ id: req.session.userId, username: req.session.username, role: req.session.role }); +}); + +// POST /api/auth/change-password — any logged-in user can change their own password +router.post('/change-password', async (req, res) => { + if (!req.session || !req.session.userId) return res.status(401).json({ error: 'Not authenticated.' }); + + const { current_password, new_password } = req.body; + if (!current_password || !new_password) return res.status(400).json({ error: 'Both fields required.' }); + if (new_password.length < 8) return res.status(400).json({ error: 'New password must be at least 8 characters.' }); + + const user = db.prepare('SELECT * FROM users WHERE id = ?').get(req.session.userId); + const match = await bcrypt.compare(current_password, user.password_hash); + if (!match) return res.status(401).json({ error: 'Current password is incorrect.' }); + + const hash = await bcrypt.hash(new_password, 12); + db.prepare("UPDATE users SET password_hash = ?, updated_at = datetime('now') WHERE id = ?") + .run(hash, req.session.userId); + + res.json({ ok: true }); +}); + +module.exports = router; diff --git a/src/routes/checks.js b/src/routes/checks.js index 845592e..c6ce481 100644 --- a/src/routes/checks.js +++ b/src/routes/checks.js @@ -3,11 +3,21 @@ const express = require('express'); const router = express.Router(); const db = require('../db/database'); +const { requireEditor, canAccessAccount } = require('../middleware/auth'); + +// Helper: resolve account_id from a check id (for edit/delete access checks) +function checkAccountId(checkId) { + const row = db.prepare('SELECT account_id FROM checks WHERE id = ?').get(checkId); + return row ? row.account_id : null; +} // TODO: Add ledger reporting -- date range filter, payee search, total amount display, CSV export // GET /api/checks?account_id=X - list checks for an account, newest first router.get('/', (req, res) => { + if (!canAccessAccount(req.session, parseInt(req.query.account_id, 10))) { + return res.status(403).json({ error: 'Access denied.' }); + } const { after, printed, account_id } = req.query; if (!account_id) return res.status(400).json({ error: 'account_id query param required' }); @@ -36,8 +46,8 @@ router.get('/:id', (req, res) => { // TODO: Add payee address book -- store and recall payee name + address lines, autocomplete on new check form -// POST /api/checks - create a new check -router.post('/', (req, res) => { +// POST /api/checks - create a new check (editor+) +router.post('/', requireEditor, (req, res) => { const { account_id, payee, amount, check_date, memo, note1, note2, payee_address1, payee_address2, payee_address3, payee_address4 } = req.body; @@ -75,8 +85,8 @@ router.post('/', (req, res) => { res.status(201).json(db.prepare('SELECT * FROM checks WHERE id = ?').get(newId)); }); -// PUT /api/checks/:id - update a check -router.put('/:id', (req, res) => { +// PUT /api/checks/:id - update a check (editor+) +router.put('/:id', requireEditor, (req, res) => { const check = db.prepare('SELECT * FROM checks WHERE id = ?').get(req.params.id); if (!check) return res.status(404).json({ error: 'Check not found' }); @@ -105,16 +115,16 @@ router.put('/:id', (req, res) => { res.json(db.prepare('SELECT * FROM checks WHERE id = ?').get(req.params.id)); }); -// DELETE /api/checks/:id -router.delete('/:id', (req, res) => { +// DELETE /api/checks/:id (editor+) +router.delete('/:id', requireEditor, (req, res) => { const check = db.prepare('SELECT * FROM checks WHERE id = ?').get(req.params.id); if (!check) return res.status(404).json({ error: 'Check not found' }); db.prepare('DELETE FROM checks WHERE id = ?').run(req.params.id); res.status(204).send(); }); -// POST /api/checks/mark-printed -router.post('/mark-printed', (req, res) => { +// POST /api/checks/mark-printed (editor+) +router.post('/mark-printed', requireEditor, (req, res) => { const { ids } = req.body; if (!Array.isArray(ids) || ids.length === 0) { return res.status(400).json({ error: 'ids array required' }); diff --git a/src/routes/deposits.js b/src/routes/deposits.js index 1ceb120..a233dff 100644 --- a/src/routes/deposits.js +++ b/src/routes/deposits.js @@ -3,6 +3,7 @@ const express = require('express'); const router = express.Router(); const db = require('../db/database'); +const { requireEditor, canAccessAccount } = require('../middleware/auth'); // Helper: fetch deposit with items function getDepositWithItems(id) { @@ -18,6 +19,7 @@ function getDepositWithItems(id) { router.get('/', (req, res) => { const { account_id } = req.query; if (!account_id) return res.status(400).json({ error: 'account_id is required.' }); + if (!canAccessAccount(req.session, parseInt(account_id, 10))) return res.status(403).json({ error: 'Access denied.' }); const deposits = db.prepare(` SELECT d.*, COUNT(di.id) AS item_count, @@ -40,7 +42,7 @@ router.get('/:id', (req, res) => { }); // POST /api/deposits -router.post('/', (req, res) => { +router.post('/', requireEditor, (req, res) => { const { account_id, deposit_date, currency, coin, cash_back, items } = req.body; if (!account_id) return res.status(400).json({ error: 'account_id is required.' }); if (!deposit_date) return res.status(400).json({ error: 'deposit_date is required.' }); @@ -84,7 +86,7 @@ router.post('/', (req, res) => { }); // PUT /api/deposits/:id -router.put('/:id', (req, res) => { +router.put('/:id', requireEditor, (req, res) => { const existing = db.prepare('SELECT id FROM deposits WHERE id = ?').get(req.params.id); if (!existing) return res.status(404).json({ error: 'Deposit not found.' }); @@ -127,7 +129,7 @@ router.put('/:id', (req, res) => { }); // DELETE /api/deposits/:id -router.delete('/:id', (req, res) => { +router.delete('/:id', requireEditor, (req, res) => { const existing = db.prepare('SELECT id FROM deposits WHERE id = ?').get(req.params.id); if (!existing) return res.status(404).json({ error: 'Deposit not found.' }); // deposit_items deleted via ON DELETE CASCADE @@ -136,7 +138,7 @@ router.delete('/:id', (req, res) => { }); // PATCH /api/deposits/:id/mark-printed -router.patch('/:id/mark-printed', (req, res) => { +router.patch('/:id/mark-printed', requireEditor, (req, res) => { db.prepare('UPDATE deposits SET printed = 1 WHERE id = ?').run(req.params.id); res.json({ ok: true }); }); diff --git a/src/routes/users.js b/src/routes/users.js new file mode 100644 index 0000000..a9f8385 --- /dev/null +++ b/src/routes/users.js @@ -0,0 +1,114 @@ +'use strict'; + +const express = require('express'); +const router = express.Router(); +const bcrypt = require('bcryptjs'); +const db = require('../db/database'); +const { requireAuth, requireAdmin } = require('../middleware/auth'); + +// All /api/users routes require admin +router.use(requireAuth, requireAdmin); + +function userWithAccounts(id) { + const user = db.prepare('SELECT id, username, role, created_at FROM users WHERE id = ?').get(id); + if (!user) return null; + user.accounts = db.prepare('SELECT account_id FROM user_accounts WHERE user_id = ?') + .all(id).map(r => r.account_id); + return user; +} + +// GET /api/users +router.get('/', (req, res) => { + const users = db.prepare('SELECT id, username, role, created_at FROM users ORDER BY id ASC').all(); + users.forEach(u => { + u.accounts = db.prepare('SELECT account_id FROM user_accounts WHERE user_id = ?') + .all(u.id).map(r => r.account_id); + }); + res.json(users); +}); + +// POST /api/users +router.post('/', async (req, res) => { + const { username, password, role, accounts } = 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.' }); + if (password.length < 8) return res.status(400).json({ error: 'Password must be at least 8 characters.' }); + + const hash = await bcrypt.hash(password, 12); + + let userId; + try { + const result = db.prepare( + 'INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?)' + ).run(username.trim(), hash, role); + userId = result.lastInsertRowid; + } catch (err) { + if (err.message.includes('UNIQUE')) return res.status(409).json({ error: 'Username already taken.' }); + throw err; + } + + if (role !== 'admin' && Array.isArray(accounts) && accounts.length > 0) { + const stmt = db.prepare('INSERT OR IGNORE INTO user_accounts (user_id, account_id) VALUES (?, ?)'); + accounts.forEach(aid => stmt.run(userId, aid)); + } + + res.status(201).json(userWithAccounts(userId)); +}); + +// PUT /api/users/:id +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; + + if (role && !['admin', 'editor', 'viewer'].includes(role)) { + return res.status(400).json({ error: 'Invalid role.' }); + } + + if (username && username.trim() !== '') { + try { + db.prepare("UPDATE users SET username = ?, updated_at = datetime('now') WHERE id = ?") + .run(username.trim(), req.params.id); + } catch (err) { + if (err.message.includes('UNIQUE')) return res.status(409).json({ error: 'Username already taken.' }); + throw err; + } + } + + if (role) { + db.prepare("UPDATE users SET role = ?, updated_at = datetime('now') WHERE id = ?") + .run(role, req.params.id); + } + + if (password) { + if (password.length < 8) return res.status(400).json({ error: 'Password must be at least 8 characters.' }); + const hash = await bcrypt.hash(password, 12); + db.prepare("UPDATE users SET password_hash = ?, updated_at = datetime('now') WHERE id = ?") + .run(hash, req.params.id); + } + + if (Array.isArray(accounts)) { + db.prepare('DELETE FROM user_accounts WHERE user_id = ?').run(req.params.id); + const effectiveRole = role || user.role; + if (effectiveRole !== 'admin' && accounts.length > 0) { + const stmt = db.prepare('INSERT OR IGNORE INTO user_accounts (user_id, account_id) VALUES (?, ?)'); + accounts.forEach(aid => stmt.run(req.params.id, aid)); + } + } + + res.json(userWithAccounts(req.params.id)); +}); + +// DELETE /api/users/:id +router.delete('/:id', (req, res) => { + if (parseInt(req.params.id, 10) === req.session.userId) { + return res.status(400).json({ error: 'Cannot delete your own account.' }); + } + const user = db.prepare('SELECT id FROM users WHERE id = ?').get(req.params.id); + if (!user) return res.status(404).json({ error: 'User not found.' }); + db.prepare('DELETE FROM users WHERE id = ?').run(req.params.id); + res.status(204).end(); +}); + +module.exports = router;