asset_browser/auth/admin.js
setonc a558804026 Initial commit — asset browser web app
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 09:06:25 -04:00

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;