v_2.3
This commit is contained in:
+157
@@ -0,0 +1,157 @@
|
||||
'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;
|
||||
Reference in New Issue
Block a user