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:
@@ -13,7 +13,8 @@ function requireAuth() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const { rows } = await pool.query(
|
const { rows } = await pool.query(
|
||||||
`SELECT s.*, u.id as user_id, u.username, u.email, u.role, 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.role, u.user_group, u.bu_teams, u.is_active
|
||||||
FROM sessions s
|
FROM sessions s
|
||||||
JOIN users u ON s.user_id = u.id
|
JOIN users u ON s.user_id = u.id
|
||||||
WHERE s.session_id = $1 AND s.expires_at > NOW()`,
|
WHERE s.session_id = $1 AND s.expires_at > NOW()`,
|
||||||
@@ -30,8 +31,8 @@ function requireAuth() {
|
|||||||
return res.status(401).json({ error: 'Account is disabled' });
|
return res.status(401).json({ error: 'Account is disabled' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Attach user to request
|
// Store the real admin identity (always the session owner)
|
||||||
req.user = {
|
req.realUser = {
|
||||||
id: session.user_id,
|
id: session.user_id,
|
||||||
username: session.username,
|
username: session.username,
|
||||||
email: session.email,
|
email: session.email,
|
||||||
@@ -40,6 +41,35 @@ function requireAuth() {
|
|||||||
teams: session.bu_teams ? session.bu_teams.split(',').filter(Boolean) : []
|
teams: session.bu_teams ? session.bu_teams.split(',').filter(Boolean) : []
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// If impersonating, load the target user's identity
|
||||||
|
if (session.impersonate_user_id) {
|
||||||
|
const { rows: targetRows } = await pool.query(
|
||||||
|
`SELECT id, username, email, role, user_group, bu_teams, is_active FROM users WHERE id = $1`,
|
||||||
|
[session.impersonate_user_id]
|
||||||
|
);
|
||||||
|
const target = targetRows[0];
|
||||||
|
|
||||||
|
if (target && target.is_active) {
|
||||||
|
req.user = {
|
||||||
|
id: target.id,
|
||||||
|
username: target.username,
|
||||||
|
email: target.email,
|
||||||
|
role: target.role,
|
||||||
|
group: target.user_group,
|
||||||
|
teams: target.bu_teams ? target.bu_teams.split(',').filter(Boolean) : []
|
||||||
|
};
|
||||||
|
req.impersonating = true;
|
||||||
|
} else {
|
||||||
|
// Target user no longer valid — clear impersonation and use real user
|
||||||
|
await pool.query(`UPDATE sessions SET impersonate_user_id = NULL WHERE session_id = $1`, [sessionId]);
|
||||||
|
req.user = req.realUser;
|
||||||
|
req.impersonating = false;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
req.user = req.realUser;
|
||||||
|
req.impersonating = false;
|
||||||
|
}
|
||||||
|
|
||||||
next();
|
next();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Auth middleware error:', err);
|
console.error('Auth middleware error:', err);
|
||||||
|
|||||||
26
backend/migrations/add_session_impersonation.js
Normal file
26
backend/migrations/add_session_impersonation.js
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
// Migration: Add impersonate_user_id column to sessions table
|
||||||
|
// Allows Admin users to temporarily view the app as another user.
|
||||||
|
// When set, requireAuth() overrides req.user with the target user's identity.
|
||||||
|
|
||||||
|
const pool = require('../db');
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
console.log('[Migration] add_session_impersonation: starting...');
|
||||||
|
|
||||||
|
// Add impersonate_user_id column (nullable FK to users)
|
||||||
|
await pool.query(`
|
||||||
|
ALTER TABLE sessions
|
||||||
|
ADD COLUMN IF NOT EXISTS impersonate_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL
|
||||||
|
`);
|
||||||
|
|
||||||
|
console.log('[Migration] add_session_impersonation: column added.');
|
||||||
|
console.log('[Migration] add_session_impersonation: done.');
|
||||||
|
await pool.end();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run directly if invoked as a script
|
||||||
|
if (require.main === module) {
|
||||||
|
run().catch(err => { console.error(err); process.exit(1); });
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = run;
|
||||||
@@ -33,6 +33,7 @@ const POSTGRES_MIGRATIONS = [
|
|||||||
'add_ivanti_findings_ipv6_columns.js',
|
'add_ivanti_findings_ipv6_columns.js',
|
||||||
'add_user_ivanti_identity.js',
|
'add_user_ivanti_identity.js',
|
||||||
'add_atlas_known_column.js',
|
'add_atlas_known_column.js',
|
||||||
|
'add_session_impersonation.js',
|
||||||
];
|
];
|
||||||
|
|
||||||
async function runAll() {
|
async function runAll() {
|
||||||
|
|||||||
@@ -195,9 +195,10 @@ function createAuthRouter(logAudit) {
|
|||||||
* GET /api/auth/me
|
* GET /api/auth/me
|
||||||
*
|
*
|
||||||
* Returns the currently authenticated user based on the session cookie.
|
* 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} 401 - { error: 'Not authenticated' } | { error: 'Session expired' } | { error: 'Account is disabled' }
|
||||||
* @returns {object} 500 - { error: 'Failed to get user' }
|
* @returns {object} 500 - { error: 'Failed to get user' }
|
||||||
*/
|
*/
|
||||||
@@ -210,7 +211,8 @@ function createAuthRouter(logAudit) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const { rows } = await pool.query(
|
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
|
FROM sessions s
|
||||||
JOIN users u ON s.user_id = u.id
|
JOIN users u ON s.user_id = u.id
|
||||||
WHERE s.session_id = $1 AND s.expires_at > NOW()`,
|
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' });
|
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({
|
res.json({
|
||||||
user: {
|
user: {
|
||||||
id: session.user_id,
|
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
|
* GET /api/auth/profile
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import AuditLog from './components/AuditLog';
|
|||||||
import NvdSyncModal from './components/NvdSyncModal';
|
import NvdSyncModal from './components/NvdSyncModal';
|
||||||
import NavDrawer from './components/NavDrawer';
|
import NavDrawer from './components/NavDrawer';
|
||||||
import AdminScopeToggle from './components/AdminScopeToggle';
|
import AdminScopeToggle from './components/AdminScopeToggle';
|
||||||
|
import ImpersonationBanner from './components/ImpersonationBanner';
|
||||||
import VulnerabilityTriagePage from './components/pages/ReportingPage';
|
import VulnerabilityTriagePage from './components/pages/ReportingPage';
|
||||||
import KnowledgeBasePage from './components/pages/KnowledgeBasePage';
|
import KnowledgeBasePage from './components/pages/KnowledgeBasePage';
|
||||||
import ExportsPage from './components/pages/ExportsPage';
|
import ExportsPage from './components/pages/ExportsPage';
|
||||||
@@ -75,6 +76,7 @@ export default function App() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-intel-darkest grid-bg p-6 relative overflow-hidden fade-in">
|
<div className="min-h-screen bg-intel-darkest grid-bg p-6 relative overflow-hidden fade-in">
|
||||||
|
<ImpersonationBanner />
|
||||||
<NavDrawer
|
<NavDrawer
|
||||||
isOpen={navOpen}
|
isOpen={navOpen}
|
||||||
onClose={() => setNavOpen(false)}
|
onClose={() => setNavOpen(false)}
|
||||||
|
|||||||
69
frontend/src/components/ImpersonationBanner.js
Normal file
69
frontend/src/components/ImpersonationBanner.js
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Eye, X } from 'lucide-react';
|
||||||
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ImpersonationBanner — renders a fixed banner at the top of the viewport
|
||||||
|
* when an Admin is viewing the app as another user. Shows who is being
|
||||||
|
* impersonated and provides a button to exit.
|
||||||
|
*/
|
||||||
|
export default function ImpersonationBanner() {
|
||||||
|
const { impersonating, user, realUser, stopImpersonation } = useAuth();
|
||||||
|
|
||||||
|
if (!impersonating) return null;
|
||||||
|
|
||||||
|
const handleStop = async () => {
|
||||||
|
await stopImpersonation();
|
||||||
|
// Force page reload to reset all state to admin's view
|
||||||
|
window.location.reload();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
position: 'fixed',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
zIndex: 9999,
|
||||||
|
background: 'linear-gradient(90deg, #D97706 0%, #B45309 100%)',
|
||||||
|
color: '#FFF',
|
||||||
|
padding: '0.5rem 1rem',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: '0.75rem',
|
||||||
|
fontFamily: "'JetBrains Mono', monospace",
|
||||||
|
fontSize: '0.8rem',
|
||||||
|
fontWeight: 600,
|
||||||
|
boxShadow: '0 2px 8px rgba(0,0,0,0.3)'
|
||||||
|
}}>
|
||||||
|
<Eye style={{ width: '16px', height: '16px', flexShrink: 0 }} />
|
||||||
|
<span>
|
||||||
|
Viewing as: <strong>{user?.username}</strong> ({user?.group}, teams: {user?.teams?.join(', ') || 'none'})
|
||||||
|
{realUser && <span style={{ opacity: 0.8 }}> — logged in as {realUser.username}</span>}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={handleStop}
|
||||||
|
style={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '0.35rem',
|
||||||
|
padding: '0.3rem 0.6rem',
|
||||||
|
background: 'rgba(255,255,255,0.2)',
|
||||||
|
border: '1px solid rgba(255,255,255,0.4)',
|
||||||
|
borderRadius: '0.25rem',
|
||||||
|
color: '#FFF',
|
||||||
|
fontSize: '0.75rem',
|
||||||
|
fontWeight: 600,
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'background 0.15s'
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => e.currentTarget.style.background = 'rgba(255,255,255,0.35)'}
|
||||||
|
onMouseLeave={e => e.currentTarget.style.background = 'rgba(255,255,255,0.2)'}
|
||||||
|
>
|
||||||
|
<X style={{ width: '12px', height: '12px' }} />
|
||||||
|
Exit
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -9,7 +9,7 @@
|
|||||||
// - JSX that uses the imported lucide-react icons (X, Plus, Edit2, Trash2, etc.)
|
// - JSX that uses the imported lucide-react icons (X, Plus, Edit2, Trash2, etc.)
|
||||||
// - The ConfirmModal integration for delete/group-change confirmations
|
// - The ConfirmModal integration for delete/group-change confirmations
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { X, Plus, Edit2, Trash2, Loader, AlertCircle, CheckCircle, User, Mail, Shield } from 'lucide-react';
|
import { X, Plus, Edit2, Trash2, Loader, AlertCircle, CheckCircle, User, Mail, Shield, Eye } from 'lucide-react';
|
||||||
import { useAuth } from '../contexts/AuthContext';
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
import ConfirmModal from './ConfirmModal';
|
import ConfirmModal from './ConfirmModal';
|
||||||
|
|
||||||
@@ -170,7 +170,7 @@ const styles = {
|
|||||||
|
|
||||||
|
|
||||||
export default function UserManagement({ onClose }) {
|
export default function UserManagement({ onClose }) {
|
||||||
const { user: currentUser } = useAuth();
|
const { user: currentUser, startImpersonation } = useAuth();
|
||||||
const [users, setUsers] = useState([]);
|
const [users, setUsers] = useState([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
@@ -665,6 +665,20 @@ export default function UserManagement({ onClose }) {
|
|||||||
</td>
|
</td>
|
||||||
<td style={styles.tdRight}>
|
<td style={styles.tdRight}>
|
||||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '0.25rem' }}>
|
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '0.25rem' }}>
|
||||||
|
{currentUser.group === 'Admin' && user.id !== currentUser.id && user.group !== 'Admin' && (
|
||||||
|
<button
|
||||||
|
onClick={async () => {
|
||||||
|
const result = await startImpersonation(user.id);
|
||||||
|
if (result.success) window.location.reload();
|
||||||
|
}}
|
||||||
|
style={styles.actionBtn}
|
||||||
|
title={`View as ${user.username}`}
|
||||||
|
onMouseEnter={e => { e.currentTarget.style.color = '#D97706'; e.currentTarget.style.background = 'rgba(217,119,6,0.1)'; }}
|
||||||
|
onMouseLeave={e => { e.currentTarget.style.color = '#94A3B8'; e.currentTarget.style.background = 'none'; }}
|
||||||
|
>
|
||||||
|
<Eye style={{ width: '1rem', height: '1rem' }} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={() => handleEdit(user)}
|
onClick={() => handleEdit(user)}
|
||||||
style={styles.actionBtn}
|
style={styles.actionBtn}
|
||||||
|
|||||||
@@ -31,6 +31,10 @@ export function AuthProvider({ children }) {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
|
|
||||||
|
// Impersonation state
|
||||||
|
const [impersonating, setImpersonating] = useState(false);
|
||||||
|
const [realUser, setRealUser] = useState(null);
|
||||||
|
|
||||||
// Admin scope — array of currently selected teams for filtering
|
// Admin scope — array of currently selected teams for filtering
|
||||||
// null = not initialized yet (will default to user's teams after login)
|
// null = not initialized yet (will default to user's teams after login)
|
||||||
const [adminScope, setAdminScope] = useState(loadAdminScope);
|
const [adminScope, setAdminScope] = useState(loadAdminScope);
|
||||||
@@ -45,6 +49,14 @@ export function AuthProvider({ children }) {
|
|||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
setUser(data.user);
|
setUser(data.user);
|
||||||
|
// Handle impersonation state from backend
|
||||||
|
if (data.impersonating) {
|
||||||
|
setImpersonating(true);
|
||||||
|
setRealUser(data.realUser);
|
||||||
|
} else {
|
||||||
|
setImpersonating(false);
|
||||||
|
setRealUser(null);
|
||||||
|
}
|
||||||
// Initialize admin scope to user's teams if not yet set
|
// Initialize admin scope to user's teams if not yet set
|
||||||
if (adminScope === null && data.user?.teams?.length > 0) {
|
if (adminScope === null && data.user?.teams?.length > 0) {
|
||||||
const initial = data.user.teams;
|
const initial = data.user.teams;
|
||||||
@@ -53,6 +65,8 @@ export function AuthProvider({ children }) {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
setUser(null);
|
setUser(null);
|
||||||
|
setImpersonating(false);
|
||||||
|
setRealUser(null);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Auth check error:', err);
|
console.error('Auth check error:', err);
|
||||||
@@ -198,6 +212,43 @@ export function AuthProvider({ children }) {
|
|||||||
canExport,
|
canExport,
|
||||||
isAdmin,
|
isAdmin,
|
||||||
isAuthenticated: !!user,
|
isAuthenticated: !!user,
|
||||||
|
// Impersonation
|
||||||
|
impersonating,
|
||||||
|
realUser,
|
||||||
|
startImpersonation: async (userId) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE}/auth/impersonate`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify({ userId })
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
if (!response.ok) return { success: false, error: data.error };
|
||||||
|
setUser(data.user);
|
||||||
|
setImpersonating(true);
|
||||||
|
setRealUser({ id: user.id, username: user.username, group: user.group });
|
||||||
|
return { success: true };
|
||||||
|
} catch (err) {
|
||||||
|
return { success: false, error: err.message };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
stopImpersonation: async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE}/auth/stop-impersonate`, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include'
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
if (!response.ok) return { success: false, error: data.error };
|
||||||
|
setUser(data.user);
|
||||||
|
setImpersonating(false);
|
||||||
|
setRealUser(null);
|
||||||
|
return { success: true };
|
||||||
|
} catch (err) {
|
||||||
|
return { success: false, error: err.message };
|
||||||
|
}
|
||||||
|
},
|
||||||
// Multi-BU tenancy
|
// Multi-BU tenancy
|
||||||
hasTeams,
|
hasTeams,
|
||||||
isTeamMember,
|
isTeamMember,
|
||||||
|
|||||||
Reference in New Issue
Block a user