Add View As (impersonation) feature for Admin users
Allow Admin users to temporarily view the app as another user to verify permissions and team scoping without switching accounts. Backend: - Migration: add impersonate_user_id column to sessions table - requireAuth(): when impersonation is active, override req.user with target user's identity; store real admin identity in req.realUser - POST /api/auth/impersonate: start impersonation (Admin only, cannot impersonate self or other Admins) - POST /api/auth/stop-impersonate: end impersonation, revert to real user - GET /api/auth/me: returns impersonating flag and realUser when active - Audit logging on impersonate start/stop Frontend: - AuthContext: add impersonating, realUser state; startImpersonation() and stopImpersonation() helpers - ImpersonationBanner: fixed amber banner showing target user identity with Exit button - UserManagement: Eye icon button on each non-Admin user row to start View As (visible only to Admin, hidden for self and other Admins) - App.js: mount ImpersonationBanner at top of authenticated view
This commit is contained in:
@@ -195,9 +195,10 @@ function createAuthRouter(logAudit) {
|
||||
* GET /api/auth/me
|
||||
*
|
||||
* Returns the currently authenticated user based on the session cookie.
|
||||
* Clears the cookie and returns 401 if the session is expired or the account is disabled.
|
||||
* If impersonating, returns the impersonated user's identity with an
|
||||
* `impersonating` flag and the real admin user's info.
|
||||
*
|
||||
* @returns {object} 200 - { user: { id, username, email, group } }
|
||||
* @returns {object} 200 - { user: { id, username, email, group, teams }, impersonating?: boolean, realUser?: { id, username, group } }
|
||||
* @returns {object} 401 - { error: 'Not authenticated' } | { error: 'Session expired' } | { error: 'Account is disabled' }
|
||||
* @returns {object} 500 - { error: 'Failed to get user' }
|
||||
*/
|
||||
@@ -210,7 +211,8 @@ function createAuthRouter(logAudit) {
|
||||
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT s.*, u.id as user_id, u.username, u.email, u.user_group, u.bu_teams, u.is_active
|
||||
`SELECT s.*, s.impersonate_user_id,
|
||||
u.id as user_id, u.username, u.email, u.user_group, u.bu_teams, u.is_active
|
||||
FROM sessions s
|
||||
JOIN users u ON s.user_id = u.id
|
||||
WHERE s.session_id = $1 AND s.expires_at > NOW()`,
|
||||
@@ -229,6 +231,36 @@ function createAuthRouter(logAudit) {
|
||||
return res.status(401).json({ error: 'Account is disabled' });
|
||||
}
|
||||
|
||||
// If impersonating, return target user's identity
|
||||
if (session.impersonate_user_id) {
|
||||
const { rows: targetRows } = await pool.query(
|
||||
`SELECT id, username, email, user_group, bu_teams, is_active FROM users WHERE id = $1`,
|
||||
[session.impersonate_user_id]
|
||||
);
|
||||
const target = targetRows[0];
|
||||
|
||||
if (target && target.is_active) {
|
||||
return res.json({
|
||||
user: {
|
||||
id: target.id,
|
||||
username: target.username,
|
||||
email: target.email,
|
||||
group: target.user_group,
|
||||
teams: target.bu_teams ? target.bu_teams.split(',').filter(Boolean) : []
|
||||
},
|
||||
impersonating: true,
|
||||
realUser: {
|
||||
id: session.user_id,
|
||||
username: session.username,
|
||||
group: session.user_group
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// Target invalid — clear impersonation
|
||||
await pool.query(`UPDATE sessions SET impersonate_user_id = NULL WHERE session_id = $1`, [sessionId]);
|
||||
}
|
||||
}
|
||||
|
||||
res.json({
|
||||
user: {
|
||||
id: session.user_id,
|
||||
@@ -244,6 +276,133 @@ function createAuthRouter(logAudit) {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/auth/impersonate
|
||||
*
|
||||
* Start impersonating another user. Only Admin group can impersonate.
|
||||
* Cannot impersonate another Admin user.
|
||||
*
|
||||
* @body {number} userId - The ID of the user to impersonate
|
||||
* @returns {object} 200 - { message, user: { id, username, group, teams } }
|
||||
* @returns {object} 400 - { error } — cannot impersonate Admin or self
|
||||
* @returns {object} 403 - { error } — not Admin
|
||||
* @returns {object} 404 - { error } — target user not found
|
||||
* @returns {object} 500 - { error }
|
||||
*/
|
||||
router.post('/impersonate', requireAuth(), async (req, res) => {
|
||||
// Only the real user (not an impersonated identity) can start impersonation
|
||||
const realUser = req.realUser || req.user;
|
||||
|
||||
if (realUser.group !== 'Admin') {
|
||||
return res.status(403).json({ error: 'Only Admin users can impersonate.' });
|
||||
}
|
||||
|
||||
const { userId } = req.body;
|
||||
if (!userId || typeof userId !== 'number') {
|
||||
return res.status(400).json({ error: 'userId is required and must be a number.' });
|
||||
}
|
||||
|
||||
if (userId === realUser.id) {
|
||||
return res.status(400).json({ error: 'Cannot impersonate yourself.' });
|
||||
}
|
||||
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT id, username, email, user_group, bu_teams, is_active FROM users WHERE id = $1`,
|
||||
[userId]
|
||||
);
|
||||
const target = rows[0];
|
||||
|
||||
if (!target) {
|
||||
return res.status(404).json({ error: 'User not found.' });
|
||||
}
|
||||
|
||||
if (!target.is_active) {
|
||||
return res.status(400).json({ error: 'Cannot impersonate a disabled account.' });
|
||||
}
|
||||
|
||||
if (target.user_group === 'Admin') {
|
||||
return res.status(400).json({ error: 'Cannot impersonate another Admin user.' });
|
||||
}
|
||||
|
||||
// Set impersonation on the session
|
||||
const sessionId = req.cookies?.session_id;
|
||||
await pool.query(
|
||||
`UPDATE sessions SET impersonate_user_id = $1 WHERE session_id = $2`,
|
||||
[userId, sessionId]
|
||||
);
|
||||
|
||||
logAudit({
|
||||
userId: realUser.id,
|
||||
username: realUser.username,
|
||||
action: 'impersonate_start',
|
||||
entityType: 'user',
|
||||
entityId: String(userId),
|
||||
details: { target_username: target.username, target_group: target.user_group },
|
||||
ipAddress: req.ip
|
||||
});
|
||||
|
||||
res.json({
|
||||
message: `Now viewing as ${target.username}`,
|
||||
user: {
|
||||
id: target.id,
|
||||
username: target.username,
|
||||
email: target.email,
|
||||
group: target.user_group,
|
||||
teams: target.bu_teams ? target.bu_teams.split(',').filter(Boolean) : []
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Impersonate error:', err);
|
||||
res.status(500).json({ error: 'Failed to start impersonation.' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/auth/stop-impersonate
|
||||
*
|
||||
* Stop impersonating and revert to the real Admin identity.
|
||||
*
|
||||
* @returns {object} 200 - { message, user: { id, username, group, teams } }
|
||||
* @returns {object} 500 - { error }
|
||||
*/
|
||||
router.post('/stop-impersonate', requireAuth(), async (req, res) => {
|
||||
const sessionId = req.cookies?.session_id;
|
||||
|
||||
try {
|
||||
await pool.query(
|
||||
`UPDATE sessions SET impersonate_user_id = NULL WHERE session_id = $1`,
|
||||
[sessionId]
|
||||
);
|
||||
|
||||
const realUser = req.realUser || req.user;
|
||||
|
||||
logAudit({
|
||||
userId: realUser.id,
|
||||
username: realUser.username,
|
||||
action: 'impersonate_stop',
|
||||
entityType: 'user',
|
||||
entityId: null,
|
||||
details: null,
|
||||
ipAddress: req.ip
|
||||
});
|
||||
|
||||
res.json({
|
||||
message: 'Impersonation ended',
|
||||
user: {
|
||||
id: realUser.id,
|
||||
username: realUser.username,
|
||||
email: realUser.email,
|
||||
group: realUser.group,
|
||||
teams: realUser.teams
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Stop impersonate error:', err);
|
||||
res.status(500).json({ error: 'Failed to stop impersonation.' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/auth/profile
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user