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:
+78
-44
@@ -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'));
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
@@ -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 };
|
||||
@@ -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
@@ -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' });
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user