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
This commit is contained in:
2026-03-18 22:55:17 -06:00
parent 1277fc4aad
commit f827210a07
13 changed files with 978 additions and 66 deletions
+85
View File
@@ -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;
+18 -8
View File
@@ -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' });
+6 -4
View File
@@ -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 });
});
+114
View File
@@ -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;