This commit is contained in:
jpmvaz
2026-09-13 20:10:17 +01:00
parent 9b5cc30fb4
commit b192fb3f17
482 changed files with 0 additions and 48205 deletions
+157
View File
@@ -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;