158 lines
6.6 KiB
JavaScript
158 lines
6.6 KiB
JavaScript
'use strict';
|
|
const express = require('express');
|
|
const bcrypt = require('bcryptjs');
|
|
const { db } = require('../db');
|
|
const { log } = require('../lib/audit');
|
|
const { requireAuth, requireAdmin } = require('../middleware/auth');
|
|
const { normaliseEmail, validateEmail, validateUsername, validatePassword } = require('../lib/validate');
|
|
|
|
const router = express.Router();
|
|
router.use(requireAuth, requireAdmin);
|
|
|
|
const PAGE = 'Data Management';
|
|
|
|
function publicUser(u) {
|
|
const email = (u.email || '').trim();
|
|
return {
|
|
id: u.id,
|
|
username: u.username,
|
|
email,
|
|
role: u.role,
|
|
mfa_enabled: !!u.mfa_enabled,
|
|
created_at: u.created_at,
|
|
// Databases created before this release could hold accounts with no
|
|
// address. Flag them so an administrator can put one in.
|
|
email_missing: !email,
|
|
};
|
|
}
|
|
|
|
/** Number of administrators, used to stop the last admin being removed. */
|
|
function adminCount() {
|
|
return db.prepare("SELECT COUNT(*) c FROM users WHERE role = 'admin'").get().c;
|
|
}
|
|
|
|
// ---- list -------------------------------------------------------------
|
|
router.get('/', (req, res) => {
|
|
const users = db.prepare('SELECT * FROM users ORDER BY role DESC, username').all().map(publicUser);
|
|
res.json({ users, missing_email: users.filter((u) => u.email_missing).length });
|
|
});
|
|
|
|
// ---- create -----------------------------------------------------------
|
|
router.post('/', (req, res) => {
|
|
const body = req.body || {};
|
|
const username = body.username === undefined ? '' : String(body.username).trim();
|
|
const email = normaliseEmail(body.email); // mandatory
|
|
const password = body.password === undefined ? '' : String(body.password);
|
|
const role = body.role;
|
|
|
|
const err = validateUsername(username) || validateEmail(email) || validatePassword(password);
|
|
if (err) return res.status(400).json({ error: err });
|
|
if (role !== 'admin' && role !== 'user') return res.status(400).json({ error: 'Role must be "admin" or "user".' });
|
|
|
|
try {
|
|
const hash = bcrypt.hashSync(password, 12);
|
|
const info = db
|
|
.prepare('INSERT INTO users (username, email, password_hash, role) VALUES (?, ?, ?, ?)')
|
|
.run(username, email, hash, role);
|
|
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(info.lastInsertRowid);
|
|
log(req, 'USER_CREATED', PAGE, `Created ${role} "${user.username}" (${user.email})`);
|
|
res.json({ ok: true, user: publicUser(user) });
|
|
} catch (e) {
|
|
if (String(e.message || '').includes('UNIQUE')) {
|
|
return res.status(409).json({ error: 'That username or email is already in use.' });
|
|
}
|
|
throw e;
|
|
}
|
|
});
|
|
|
|
// ---- update (email, role, password reset) -----------------------------
|
|
router.patch('/:id', (req, res) => {
|
|
const id = Number(req.params.id);
|
|
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(id);
|
|
if (!user) return res.status(404).json({ error: 'User not found.' });
|
|
|
|
const { email, role, password } = req.body || {};
|
|
|
|
// Email is mandatory: when the field is supplied it must be a valid address,
|
|
// so an existing account can never be left without one.
|
|
if (email !== undefined) {
|
|
const emailErr = validateEmail(email);
|
|
if (emailErr) return res.status(400).json({ error: emailErr });
|
|
}
|
|
if (password !== undefined && password !== '') {
|
|
const pwErr = validatePassword(password);
|
|
if (pwErr) return res.status(400).json({ error: pwErr });
|
|
}
|
|
|
|
// Never let the last administrator demote themselves out of existence.
|
|
if (role && role !== user.role && user.role === 'admin' && adminCount() <= 1) {
|
|
return res.status(400).json({ error: 'This is the only administrator — promote someone else first.' });
|
|
}
|
|
if (role && role !== 'admin' && role !== 'user') {
|
|
return res.status(400).json({ error: 'Role must be "admin" or "user".' });
|
|
}
|
|
|
|
const changes = [];
|
|
try {
|
|
const newEmail = normaliseEmail(email);
|
|
if (email !== undefined && newEmail !== user.email) {
|
|
db.prepare('UPDATE users SET email = ? WHERE id = ?').run(newEmail, id);
|
|
changes.push(`email → ${newEmail}`);
|
|
}
|
|
if (role && role !== user.role) {
|
|
db.prepare('UPDATE users SET role = ? WHERE id = ?').run(role, id);
|
|
changes.push(`role → ${role}`);
|
|
}
|
|
if (password) {
|
|
db.prepare('UPDATE users SET password_hash = ? WHERE id = ?').run(bcrypt.hashSync(password, 12), id);
|
|
changes.push('password reset');
|
|
}
|
|
} catch (e) {
|
|
if (String(e.message || '').includes('UNIQUE')) {
|
|
return res.status(409).json({ error: 'That email is already in use.' });
|
|
}
|
|
throw e;
|
|
}
|
|
|
|
if (changes.length) log(req, 'USER_UPDATED', PAGE, `Updated "${user.username}": ${changes.join(', ')}`);
|
|
res.json({ ok: true, user: publicUser(db.prepare('SELECT * FROM users WHERE id = ?').get(id)) });
|
|
});
|
|
|
|
// ---- turn off MFA (recovery: user lost their authenticator) -----------
|
|
router.post('/:id/mfa/reset', (req, res) => {
|
|
const id = Number(req.params.id);
|
|
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(id);
|
|
if (!user) return res.status(404).json({ error: 'User not found.' });
|
|
db.prepare('UPDATE users SET mfa_enabled = 0, mfa_secret = NULL WHERE id = ?').run(id);
|
|
log(req, 'USER_MFA_RESET', PAGE, `Two-factor authentication reset for "${user.username}"`);
|
|
res.json({ ok: true });
|
|
});
|
|
|
|
// ---- delete -----------------------------------------------------------
|
|
router.delete('/:id', (req, res) => {
|
|
const id = Number(req.params.id);
|
|
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(id);
|
|
if (!user) return res.status(404).json({ error: 'User not found.' });
|
|
if (id === req.session.user.id) {
|
|
return res.status(400).json({ error: 'You cannot delete the account you are signed in with.' });
|
|
}
|
|
if (user.role === 'admin' && adminCount() <= 1) {
|
|
return res.status(400).json({ error: 'This is the only administrator and cannot be deleted.' });
|
|
}
|
|
// Detach the user from everything that references them, in one transaction.
|
|
// Content they published is KEPT (ownership is simply cleared) and the audit
|
|
// log is untouched — log entries store the username as text, so history
|
|
// survives the account being removed.
|
|
db.transaction(() => {
|
|
db.prepare('DELETE FROM access_requests WHERE user_id = ?').run(id);
|
|
db.prepare('UPDATE access_requests SET decided_by = NULL WHERE decided_by = ?').run(id);
|
|
db.prepare('UPDATE folders SET created_by = NULL WHERE created_by = ?').run(id);
|
|
db.prepare('UPDATE files SET uploaded_by = NULL WHERE uploaded_by = ?').run(id);
|
|
db.prepare('DELETE FROM users WHERE id = ?').run(id);
|
|
})();
|
|
log(req, 'USER_DELETED', PAGE, `Deleted user "${user.username}" (content and audit history retained)`);
|
|
res.json({ ok: true });
|
|
});
|
|
|
|
module.exports = router;
|