// User Management Routes (Admin only) const express = require('express'); const bcrypt = require('bcryptjs'); const pool = require('../db'); const { validateTeams } = require('../helpers/teams'); function createUsersRouter(requireAuth, requireGroup, logAudit) { const router = express.Router(); // All routes require Admin group router.use(requireAuth(), requireGroup('Admin')); /** * GET /api/users * * Returns all user accounts ordered by creation date (newest first). * * @returns {Array} 200 - Array of user objects * @returns {Object} 200[].id - User ID * @returns {string} 200[].username - Username * @returns {string} 200[].email - Email address * @returns {string} 200[].group - Permission group (Admin, Standard_User, Leadership, Read_Only) * @returns {string} 200[].bu_teams - Comma-separated BU team assignments * @returns {Array} 200[].teams - Parsed array of BU team assignments * @returns {boolean} 200[].is_active - Whether the account is active * @returns {string} 200[].created_at - ISO timestamp of account creation * @returns {string|null} 200[].last_login - ISO timestamp of last login * @returns {Object} 500 - { error: string } */ router.get('/', async (req, res) => { try { const { rows: users } = await pool.query( `SELECT id, username, email, user_group AS "group", bu_teams, is_active, created_at, last_login, ivanti_first_name, ivanti_last_name FROM users ORDER BY created_at DESC` ); // Parse bu_teams into teams array for each user const usersWithTeams = users.map(u => ({ ...u, teams: u.bu_teams ? u.bu_teams.split(',').filter(Boolean) : [] })); res.json(usersWithTeams); } catch (err) { console.error('Get users error:', err); res.status(500).json({ error: 'Failed to fetch users' }); } }); /** * GET /api/users/:id * * Returns a single user account by ID. * * @param {string} req.params.id - User ID * @returns {Object} 200 - User object with parsed teams array * @returns {Object} 404 - { error: 'User not found' } * @returns {Object} 500 - { error: string } */ router.get('/:id', async (req, res) => { try { const { rows } = await pool.query( `SELECT id, username, email, user_group AS "group", bu_teams, is_active, created_at, last_login, ivanti_first_name, ivanti_last_name FROM users WHERE id = $1`, [req.params.id] ); const user = rows[0]; if (!user) { return res.status(404).json({ error: 'User not found' }); } res.json({ ...user, teams: user.bu_teams ? user.bu_teams.split(',').filter(Boolean) : [] }); } catch (err) { console.error('Get user error:', err); res.status(500).json({ error: 'Failed to fetch user' }); } }); /** * POST /api/users * * Creates a new user account. * * @body {string} username - Required. Unique username * @body {string} email - Required. Unique email address * @body {string} password - Required. Plain-text password (hashed before storage) * @body {string} [group='Read_Only'] - Permission group (Admin, Standard_User, Leadership, Read_Only) * @body {string} [bu_teams=''] - Comma-separated BU team assignments (STEAM, ACCESS-ENG, ACCESS-OPS, INTELDEV) * @returns {Object} 201 - { message: string, user: { id, username, email, group, bu_teams, teams } } * @returns {Object} 400 - { error: string } if required fields missing or invalid group/teams * @returns {Object} 409 - { error: string } if username or email already exists * @returns {Object} 500 - { error: string } */ router.post('/', async (req, res) => { const { username, email, password, group, bu_teams } = req.body; const VALID_GROUPS = ['Admin', 'Standard_User', 'Leadership', 'Read_Only']; if (!username || !email || !password) { return res.status(400).json({ error: 'Username, email, and password are required' }); } const userGroup = group || 'Read_Only'; if (!VALID_GROUPS.includes(userGroup)) { return res.status(400).json({ error: 'Invalid group. Must be one of: Admin, Standard_User, Leadership, Read_Only' }); } // Validate bu_teams if provided const teamsStr = bu_teams || ''; if (teamsStr) { const teamsResult = validateTeams(teamsStr); if (!teamsResult.valid) { return res.status(400).json({ error: `Invalid team(s): ${teamsResult.invalid.join(', ')}. Must be one of: STEAM, ACCESS-ENG, ACCESS-OPS, INTELDEV` }); } } try { const passwordHash = await bcrypt.hash(password, 10); const { rows } = await pool.query( `INSERT INTO users (username, email, password_hash, user_group, bu_teams) VALUES ($1, $2, $3, $4, $5) RETURNING id`, [username, email, passwordHash, userGroup, teamsStr] ); const result = rows[0]; logAudit({ userId: req.user.id, username: req.user.username, action: 'user_create', entityType: 'user', entityId: String(result.id), details: { created_username: username, group: userGroup, bu_teams: teamsStr }, ipAddress: req.ip }); res.status(201).json({ message: 'User created successfully', user: { id: result.id, username, email, group: userGroup, bu_teams: teamsStr, teams: teamsStr ? teamsStr.split(',').filter(Boolean) : [] } }); } catch (err) { console.error('Create user error:', err); if (err.code === '23505') { // Postgres unique violation return res.status(409).json({ error: 'Username or email already exists' }); } res.status(500).json({ error: 'Failed to create user' }); } }); /** * PATCH /api/users/:id * * Updates one or more fields on an existing user account. Only provided fields are modified. * * @param {string} req.params.id - User ID to update * @body {string} [username] - New username * @body {string} [email] - New email address * @body {string} [password] - New plain-text password (hashed before storage) * @body {string} [group] - New permission group (Admin, Standard_User, Leadership, Read_Only) * @body {boolean} [is_active] - Whether the account is active (deactivation deletes sessions) * @body {string} [bu_teams] - Comma-separated BU team assignments (STEAM, ACCESS-ENG, ACCESS-OPS, INTELDEV) * @body {string} [ivanti_first_name] - Ivanti first name for finding correlation * @body {string} [ivanti_last_name] - Ivanti last name for finding correlation * @returns {Object} 200 - { message: 'User updated successfully' } * @returns {Object} 400 - { error: string } if invalid group, self-demotion, self-deactivation, invalid teams, or no fields provided * @returns {Object} 404 - { error: 'User not found' } * @returns {Object} 409 - { error: string } if username or email already exists * @returns {Object} 500 - { error: string } */ router.patch('/:id', async (req, res) => { const { username, email, password, group, is_active, bu_teams, ivanti_first_name, ivanti_last_name } = req.body; const VALID_GROUPS = ['Admin', 'Standard_User', 'Leadership', 'Read_Only']; const userId = req.params.id; // Validate group if provided if (group && !VALID_GROUPS.includes(group)) { return res.status(400).json({ error: 'Invalid group. Must be one of: Admin, Standard_User, Leadership, Read_Only' }); } // Prevent admin self-demotion if (String(userId) === String(req.user.id) && group && group !== 'Admin') { return res.status(400).json({ error: 'Cannot remove your own admin group' }); } // Prevent self-deactivation if (String(userId) === String(req.user.id) && is_active === false) { return res.status(400).json({ error: 'Cannot deactivate your own account' }); } // Validate bu_teams if provided if (typeof bu_teams === 'string') { if (bu_teams !== '') { const teamsResult = validateTeams(bu_teams); if (!teamsResult.valid) { return res.status(400).json({ error: `Invalid team(s): ${teamsResult.invalid.join(', ')}. Must be one of: STEAM, ACCESS-ENG, ACCESS-OPS, INTELDEV` }); } } } try { // Fetch current user record before update (needed for group change audit) const { rows: currentRows } = await pool.query( 'SELECT user_group, bu_teams FROM users WHERE id = $1', [userId] ); const currentUser = currentRows[0]; if (!currentUser) { return res.status(404).json({ error: 'User not found' }); } const updates = []; const values = []; let paramIndex = 1; if (username) { updates.push(`username = $${paramIndex++}`); values.push(username); } if (email) { updates.push(`email = $${paramIndex++}`); values.push(email); } if (password) { const passwordHash = await bcrypt.hash(password, 10); updates.push(`password_hash = $${paramIndex++}`); values.push(passwordHash); } if (group) { updates.push(`user_group = $${paramIndex++}`); values.push(group); } if (typeof is_active === 'boolean') { updates.push(`is_active = $${paramIndex++}`); values.push(is_active); } if (typeof bu_teams === 'string') { updates.push(`bu_teams = $${paramIndex++}`); values.push(bu_teams); } if (typeof ivanti_first_name === 'string') { updates.push(`ivanti_first_name = $${paramIndex++}`); values.push(ivanti_first_name.trim() || null); } if (typeof ivanti_last_name === 'string') { updates.push(`ivanti_last_name = $${paramIndex++}`); values.push(ivanti_last_name.trim() || null); } if (updates.length === 0) { return res.status(400).json({ error: 'No fields to update' }); } values.push(userId); await pool.query( `UPDATE users SET ${updates.join(', ')} WHERE id = $${paramIndex}`, values ); const updatedFields = {}; if (username) updatedFields.username = username; if (email) updatedFields.email = email; if (group) updatedFields.group = group; if (typeof is_active === 'boolean') updatedFields.is_active = is_active; if (password) updatedFields.password_changed = true; if (typeof bu_teams === 'string') updatedFields.bu_teams = bu_teams; if (typeof ivanti_first_name === 'string') updatedFields.ivanti_first_name = ivanti_first_name.trim() || null; if (typeof ivanti_last_name === 'string') updatedFields.ivanti_last_name = ivanti_last_name.trim() || null; logAudit({ userId: req.user.id, username: req.user.username, action: 'user_update', entityType: 'user', entityId: String(userId), details: updatedFields, ipAddress: req.ip }); // Log specific audit entry for group changes if (group && group !== currentUser.user_group) { logAudit({ userId: req.user.id, username: req.user.username, action: 'user_group_change', entityType: 'user', entityId: String(userId), details: { previous_group: currentUser.user_group, new_group: group }, ipAddress: req.ip }); } // Log specific audit entry for bu_teams changes if (typeof bu_teams === 'string' && bu_teams !== (currentUser.bu_teams || '')) { logAudit({ userId: req.user.id, username: req.user.username, action: 'user_teams_change', entityType: 'user', entityId: String(userId), details: { previous_teams: currentUser.bu_teams || '', new_teams: bu_teams }, ipAddress: req.ip }); } // If user was deactivated, delete their sessions if (is_active === false) { await pool.query('DELETE FROM sessions WHERE user_id = $1', [userId]); } res.json({ message: 'User updated successfully' }); } catch (err) { console.error('Update user error:', err); if (err.code === '23505') { // Postgres unique violation return res.status(409).json({ error: 'Username or email already exists' }); } res.status(500).json({ error: 'Failed to update user' }); } }); /** * DELETE /api/users/:id * * Deletes a user account and their associated sessions. Cannot delete your own account. * * @param {string} req.params.id - User ID to delete * @returns {Object} 200 - { message: 'User deleted successfully' } * @returns {Object} 400 - { error: string } if attempting self-deletion * @returns {Object} 404 - { error: 'User not found' } * @returns {Object} 500 - { error: string } */ router.delete('/:id', async (req, res) => { const userId = req.params.id; // Prevent self-deletion if (String(userId) === String(req.user.id)) { return res.status(400).json({ error: 'Cannot delete your own account' }); } try { // Look up the user before deleting const { rows: userRows } = await pool.query( 'SELECT username FROM users WHERE id = $1', [userId] ); const targetUser = userRows[0]; // Delete sessions first (foreign key) await pool.query('DELETE FROM sessions WHERE user_id = $1', [userId]); // Delete user const result = await pool.query('DELETE FROM users WHERE id = $1', [userId]); if (result.rowCount === 0) { return res.status(404).json({ error: 'User not found' }); } logAudit({ userId: req.user.id, username: req.user.username, action: 'user_delete', entityType: 'user', entityId: String(userId), details: { deleted_username: targetUser ? targetUser.username : 'unknown' }, ipAddress: req.ip }); res.json({ message: 'User deleted successfully' }); } catch (err) { console.error('Delete user error:', err); res.status(500).json({ error: 'Failed to delete user' }); } }); return router; } module.exports = createUsersRouter;