Each user can now have ivanti_first_name and ivanti_last_name configured in User Management. The workflow sync queries all configured Ivanti identities and fetches workflows for each. The GET endpoint filters workflows to only show those belonging to the logged-in user's Ivanti identity. Users without an Ivanti identity see all workflows (admin fallback). If no users have identities configured, falls back to IVANTI_FIRST_NAME/ IVANTI_LAST_NAME from .env for backward compatibility. Changes: - Migration adds ivanti_first_name, ivanti_last_name to users table - Users route accepts and returns the new fields - User Management UI has Ivanti Identity input fields - Workflow sync iterates all configured user identities - Workflow GET filters by logged-in user's identity
401 lines
16 KiB
JavaScript
401 lines
16 KiB
JavaScript
// 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<Object>} 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<string>} 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;
|