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:
Jordan Ramos
2026-06-24 12:53:05 -06:00
parent 11d9fec3ec
commit 8c789ce765
8 changed files with 360 additions and 8 deletions

View 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>
);
}

View File

@@ -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}