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:
@@ -8,6 +8,7 @@ import AuditLog from './components/AuditLog';
|
||||
import NvdSyncModal from './components/NvdSyncModal';
|
||||
import NavDrawer from './components/NavDrawer';
|
||||
import AdminScopeToggle from './components/AdminScopeToggle';
|
||||
import ImpersonationBanner from './components/ImpersonationBanner';
|
||||
import VulnerabilityTriagePage from './components/pages/ReportingPage';
|
||||
import KnowledgeBasePage from './components/pages/KnowledgeBasePage';
|
||||
import ExportsPage from './components/pages/ExportsPage';
|
||||
@@ -75,6 +76,7 @@ export default function App() {
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-intel-darkest grid-bg p-6 relative overflow-hidden fade-in">
|
||||
<ImpersonationBanner />
|
||||
<NavDrawer
|
||||
isOpen={navOpen}
|
||||
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.)
|
||||
// - The ConfirmModal integration for delete/group-change confirmations
|
||||
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 ConfirmModal from './ConfirmModal';
|
||||
|
||||
@@ -170,7 +170,7 @@ const styles = {
|
||||
|
||||
|
||||
export default function UserManagement({ onClose }) {
|
||||
const { user: currentUser } = useAuth();
|
||||
const { user: currentUser, startImpersonation } = useAuth();
|
||||
const [users, setUsers] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
@@ -665,6 +665,20 @@ export default function UserManagement({ onClose }) {
|
||||
</td>
|
||||
<td style={styles.tdRight}>
|
||||
<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
|
||||
onClick={() => handleEdit(user)}
|
||||
style={styles.actionBtn}
|
||||
|
||||
@@ -31,6 +31,10 @@ export function AuthProvider({ children }) {
|
||||
const [loading, setLoading] = useState(true);
|
||||
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
|
||||
// null = not initialized yet (will default to user's teams after login)
|
||||
const [adminScope, setAdminScope] = useState(loadAdminScope);
|
||||
@@ -45,6 +49,14 @@ export function AuthProvider({ children }) {
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
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
|
||||
if (adminScope === null && data.user?.teams?.length > 0) {
|
||||
const initial = data.user.teams;
|
||||
@@ -53,6 +65,8 @@ export function AuthProvider({ children }) {
|
||||
}
|
||||
} else {
|
||||
setUser(null);
|
||||
setImpersonating(false);
|
||||
setRealUser(null);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Auth check error:', err);
|
||||
@@ -198,6 +212,43 @@ export function AuthProvider({ children }) {
|
||||
canExport,
|
||||
isAdmin,
|
||||
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
|
||||
hasTeams,
|
||||
isTeamMember,
|
||||
|
||||
Reference in New Issue
Block a user