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:
@@ -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;
|
||||
Reference in New Issue
Block a user