125 lines
4.7 KiB
JavaScript
Executable file
125 lines
4.7 KiB
JavaScript
Executable file
'use strict';
|
|
const express = require('express');
|
|
const bcrypt = require('bcryptjs');
|
|
const { getAllUsers, getUserById, createUser, updateUser } = require('./db');
|
|
const { requireRole } = require('./middleware');
|
|
const { getUsage: getSyncroUsage, getHistory: getSyncroHistory, getLimitHits: getSyncroLimitHits } = require('../syncroStats');
|
|
|
|
const router = express.Router();
|
|
|
|
const VALID_ROLES = ['superduperadmin', 'admin', 'tech', 'client'];
|
|
const ELEVATED_ROLES = ['superduperadmin', 'admin'];
|
|
|
|
// GET /admin/users
|
|
router.get('/users', (req, res) => {
|
|
res.json({ users: getAllUsers() });
|
|
});
|
|
|
|
// POST /admin/users — create user
|
|
router.post('/users', async (req, res) => {
|
|
const username = String(req.body?.username ?? '').trim().toLowerCase();
|
|
const name = String(req.body?.name ?? '').trim();
|
|
const company = String(req.body?.company ?? '').trim();
|
|
const syncroCustomerId = req.body?.syncro_customer_id != null ? parseInt(req.body.syncro_customer_id, 10) : null;
|
|
const role = String(req.body?.role ?? '');
|
|
const password = String(req.body?.password ?? '');
|
|
|
|
if (!username || !name || !role || !password) {
|
|
return res.status(400).json({ error: 'All fields are required.' });
|
|
}
|
|
if (!VALID_ROLES.includes(role)) {
|
|
return res.status(400).json({ error: 'Invalid role.' });
|
|
}
|
|
if (ELEVATED_ROLES.includes(role) && req.session.user.role !== 'superduperadmin') {
|
|
return res.status(403).json({ error: 'Only superduperadmin can assign admin-level roles.' });
|
|
}
|
|
if (password.length < 12) {
|
|
return res.status(400).json({ error: 'Password must be at least 12 characters.' });
|
|
}
|
|
|
|
const hash = await bcrypt.hash(password, 12);
|
|
try {
|
|
createUser(username, hash, name, role, company, syncroCustomerId);
|
|
res.json({ ok: true });
|
|
} catch (err) {
|
|
if (err.message?.includes('UNIQUE constraint')) {
|
|
return res.status(409).json({ error: 'Username already exists.' });
|
|
}
|
|
throw err;
|
|
}
|
|
});
|
|
|
|
// PATCH /admin/users/:id — update user fields
|
|
router.patch('/users/:id', async (req, res) => {
|
|
const id = parseInt(req.params.id, 10);
|
|
if (isNaN(id)) return res.status(400).json({ error: 'Invalid user ID.' });
|
|
|
|
const current = getUserById(id);
|
|
if (!current) return res.status(404).json({ error: 'User not found.' });
|
|
|
|
const isSelf = id === req.session.user.id;
|
|
|
|
// Nobody can change their own active status or role via the API
|
|
if (isSelf && 'active' in req.body) {
|
|
return res.status(403).json({ error: 'Cannot change your own active status.' });
|
|
}
|
|
|
|
const fields = {};
|
|
const { name, company, syncro_customer_id, role, active, password } = req.body ?? {};
|
|
|
|
if (name !== undefined) {
|
|
fields.name = String(name).trim();
|
|
if (!fields.name) return res.status(400).json({ error: 'Name cannot be empty.' });
|
|
}
|
|
|
|
if (company !== undefined) {
|
|
fields.company = String(company).trim();
|
|
}
|
|
|
|
if (syncro_customer_id !== undefined) {
|
|
fields.syncro_customer_id = syncro_customer_id != null ? parseInt(syncro_customer_id, 10) : null;
|
|
}
|
|
|
|
if (role !== undefined && !isSelf) {
|
|
if (!VALID_ROLES.includes(role)) return res.status(400).json({ error: 'Invalid role.' });
|
|
if (ELEVATED_ROLES.includes(role) && req.session.user.role !== 'superduperadmin') {
|
|
return res.status(403).json({ error: 'Only superduperadmin can assign admin-level roles.' });
|
|
}
|
|
fields.role = role;
|
|
}
|
|
|
|
if (active !== undefined) {
|
|
fields.active = active ? 1 : 0;
|
|
}
|
|
|
|
if (password !== undefined && password !== '') {
|
|
if (String(password).length < 12) {
|
|
return res.status(400).json({ error: 'Password must be at least 12 characters.' });
|
|
}
|
|
fields.password_hash = await bcrypt.hash(String(password), 12);
|
|
}
|
|
|
|
updateUser(id, fields);
|
|
res.json({ ok: true });
|
|
});
|
|
|
|
// GET /admin/syncro-usage — rolling 60s Syncro API request count + 7-day limit-hit count
|
|
router.get('/syncro-usage', (req, res) => {
|
|
const limit = 180;
|
|
res.json({ requests: getSyncroUsage(), limit, windowMs: 60_000, limitHits7d: getSyncroLimitHits(limit) });
|
|
});
|
|
|
|
// GET /admin/syncro-history?window=<ms> — per-minute buckets for the given window (max 7 days)
|
|
router.get('/syncro-history', (req, res) => {
|
|
const raw = parseInt(req.query.window ?? '3600000', 10);
|
|
const windowMs = isNaN(raw) ? 3_600_000 : Math.min(Math.max(raw, 60_000), 7 * 24 * 60 * 60 * 1000);
|
|
res.json({ buckets: getSyncroHistory(windowMs), windowMs });
|
|
});
|
|
|
|
// POST /admin/restart — exit cleanly; PM2 will restart the process
|
|
router.post('/restart', requireRole('superduperadmin'), (req, res) => {
|
|
res.json({ ok: true });
|
|
setTimeout(() => process.exit(0), 150);
|
|
});
|
|
|
|
module.exports = router;
|