diff --git a/services/mam-api/src/routes/users.js b/services/mam-api/src/routes/users.js new file mode 100644 index 0000000..d94f84a --- /dev/null +++ b/services/mam-api/src/routes/users.js @@ -0,0 +1,117 @@ +/** + * 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;