dragonflight/services/mam-api/src/routes/users.js

117 lines
4.7 KiB
JavaScript

/**
* User management routes (admin-only when AUTH_ENABLED=true)
*
* GET /api/v1/users — list all users
* POST /api/v1/users — create user
* GET /api/v1/users/:id — get user
* PATCH /api/v1/users/:id — update user (display_name, role, password)
* DELETE /api/v1/users/:id — delete user
*/
import express from 'express';
import bcrypt from 'bcrypt';
import pool from '../db/pool.js';
import { requireAuth, requireAdmin } from '../middleware/auth.js';
const router = express.Router();
router.use(requireAuth, requireAdmin);
const VALID_ROLES = ['admin', 'editor', 'viewer'];
// ── List ──────────────────────────────────────────────────────
router.get('/', async (_req, res, next) => {
try {
const { rows } = await pool.query(
`SELECT u.id, u.username, u.display_name, u.role, u.created_at,
COUNT(ug.group_id)::int AS group_count
FROM users u
LEFT JOIN user_groups ug ON ug.user_id = u.id
GROUP BY u.id
ORDER BY u.created_at`
);
res.json(rows);
} catch (err) { next(err); }
});
// ── Create ────────────────────────────────────────────────────
router.post('/', async (req, res, next) => {
try {
const { username, password, display_name, role = 'editor' } = req.body;
if (!username || !password)
return res.status(400).json({ error: 'username and password required' });
if (password.length < 8)
return res.status(400).json({ error: 'Password must be ≥ 8 characters' });
if (!VALID_ROLES.includes(role))
return res.status(400).json({ error: `role must be one of: ${VALID_ROLES.join(', ')}` });
const hash = await bcrypt.hash(password, 12);
const { rows } = await pool.query(
`INSERT INTO users (username, password_hash, display_name, role)
VALUES ($1, $2, $3, $4)
RETURNING id, username, display_name, role, created_at`,
[username.trim().toLowerCase(), hash, display_name || username, role]
);
res.status(201).json(rows[0]);
} catch (err) {
if (err.code === '23505') return res.status(409).json({ error: 'Username already exists' });
next(err);
}
});
// ── Get ───────────────────────────────────────────────────────
router.get('/:id', async (req, res, next) => {
try {
const { rows } = await pool.query(
`SELECT id, username, display_name, role, created_at FROM users WHERE id = $1`,
[req.params.id]
);
if (!rows.length) return res.status(404).json({ error: 'User not found' });
res.json(rows[0]);
} catch (err) { next(err); }
});
// ── Update ────────────────────────────────────────────────────
router.patch('/:id', async (req, res, next) => {
try {
const { display_name, role, password } = req.body;
const sets = []; const vals = [];
if (display_name !== undefined) {
sets.push(`display_name = $${sets.length + 1}`);
vals.push(display_name);
}
if (role !== undefined) {
if (!VALID_ROLES.includes(role))
return res.status(400).json({ error: `role must be one of: ${VALID_ROLES.join(', ')}` });
sets.push(`role = $${sets.length + 1}`);
vals.push(role);
}
if (password) {
if (password.length < 8)
return res.status(400).json({ error: 'Password must be ≥ 8 characters' });
const hashed = await bcrypt.hash(password, 12);
sets.push(`password_hash = $${sets.length + 1}`);
vals.push(hashed);
}
if (!sets.length) return res.status(400).json({ error: 'Nothing to update' });
vals.push(req.params.id);
const { rows } = await pool.query(
`UPDATE users SET ${sets.join(', ')} WHERE id = $${vals.length}
RETURNING id, username, display_name, role, created_at`,
vals
);
if (!rows.length) return res.status(404).json({ error: 'User not found' });
res.json(rows[0]);
} catch (err) { next(err); }
});
// ── Delete ────────────────────────────────────────────────────
router.delete('/:id', async (req, res, next) => {
try {
const { rowCount } = await pool.query('DELETE FROM users WHERE id = $1', [req.params.id]);
if (!rowCount) return res.status(404).json({ error: 'User not found' });
res.json({ message: 'User deleted' });
} catch (err) { next(err); }
});
export default router;