/** * 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;