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
+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 });
});