← back to Norma
components/ImpersonateMenu.tsx
326 lines
'use client';
/**
* ImpersonateMenu — admin-only dropdown that lets the real admin pick another
* user from the tier_credentials list and assume their session. Pairs with the
* /api/admin/impersonate backend (GET state, POST {username}, DELETE exit).
*
* Visibility: only rendered when the live session role === 'admin'. While
* impersonating, the banner at the top of the screen offers a one-click exit.
*
* Renders nothing for non-admin sessions. Safe to mount globally.
*/
import { useState, useEffect, useCallback, useRef } from 'react';
import { ChevronDown, UserCheck, X, RefreshCw, Eye } from 'lucide-react';
type UserRole = 'admin' | 'staff' | 'intern' | 'pulse';
interface UserRow {
id: string;
username: string;
role: UserRole;
full_name: string | null;
display_name: string | null;
email: string | null;
is_active: boolean;
}
interface SessionInfo {
authenticated: boolean;
user?: string;
role?: UserRole;
fullName?: string | null;
email?: string | null;
}
interface ImpState {
currentUser?: string;
currentRole?: UserRole;
impersonating?: boolean;
}
const ROLE_LABEL: Record<UserRole, string> = {
admin: 'Admin', staff: 'User', intern: 'Intern', pulse: 'Pulse',
};
const ROLE_COLOR: Record<UserRole, string> = {
admin: '#dc2626',
staff: '#4f46e5',
intern: '#b45309',
pulse: '#0a7c59',
};
interface Props {
/**
* 'banner' — renders ONLY when impersonating (orange "Acting as" bar)
* 'dropdown' — renders ONLY for non-impersonating admins (header "View as…" button)
* 'both' — default, renders whichever is appropriate
*/
variant?: 'banner' | 'dropdown' | 'both';
}
export default function ImpersonateMenu({ variant = 'both' }: Props = {}) {
const [session, setSession] = useState<SessionInfo | null>(null);
const [imp, setImp] = useState<ImpState | null>(null);
const [users, setUsers] = useState<UserRow[]>([]);
const [open, setOpen] = useState(false);
const [working, setWorking] = useState(false);
const [errMsg, setErrMsg] = useState('');
const menuRef = useRef<HTMLDivElement | null>(null);
// Fetch session + impersonation state on mount
const refresh = useCallback(async () => {
try {
const [sRes, iRes] = await Promise.all([
fetch('/api/auth/session'),
fetch('/api/admin/impersonate'),
]);
const s = sRes.ok ? await sRes.json() : null;
const i = iRes.ok ? await iRes.json() : null;
setSession(s);
setImp(i);
} catch { /* non-fatal */ }
}, []);
// Fetch user list lazily — only when dropdown opens AND role is admin
const loadUsers = useCallback(async () => {
try {
const r = await fetch('/api/settings/tier-credentials');
if (!r.ok) return;
const data = await r.json();
setUsers((data.credentials || []) as UserRow[]);
} catch { /* non-fatal */ }
}, []);
useEffect(() => { refresh(); }, [refresh]);
useEffect(() => {
if (open && session?.role === 'admin' && users.length === 0) {
loadUsers();
}
}, [open, session?.role, users.length, loadUsers]);
// Close on outside click
useEffect(() => {
if (!open) return;
const handler = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
setOpen(false);
}
};
window.addEventListener('mousedown', handler);
return () => window.removeEventListener('mousedown', handler);
}, [open]);
// Start impersonating
async function startImpersonate(username: string) {
setWorking(true);
setErrMsg('');
try {
const r = await fetch('/api/admin/impersonate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username }),
});
if (!r.ok) {
const j = await r.json().catch(() => ({}));
throw new Error(j.error || `Failed (${r.status})`);
}
// Cookie swap happened; hard-refresh so every component re-reads session
window.location.reload();
} catch (err) {
setErrMsg((err as Error).message);
setWorking(false);
}
}
// Exit impersonation
async function exitImpersonate() {
setWorking(true);
setErrMsg('');
try {
const r = await fetch('/api/admin/impersonate', { method: 'DELETE' });
if (!r.ok) {
const j = await r.json().catch(() => ({}));
throw new Error(j.error || `Failed (${r.status})`);
}
window.location.reload();
} catch (err) {
setErrMsg((err as Error).message);
setWorking(false);
}
}
// -------- Banner (renders ABOVE the rest of the chrome when impersonating) --------
if (variant === 'dropdown' && imp?.impersonating) return null;
if (variant === 'banner' && !imp?.impersonating) return null;
if (imp?.impersonating) {
return (
<div
style={{
position: 'sticky',
top: 0,
zIndex: 100,
backgroundColor: '#b45309',
color: '#fff',
padding: '8px 16px',
fontSize: 13,
fontWeight: 600,
display: 'flex',
alignItems: 'center',
gap: 12,
borderBottom: '2px solid #92400e',
}}
>
<Eye size={16} />
<span>
Impersonating <strong>{session?.fullName || session?.user || imp.currentUser}</strong>
{imp.currentRole && (
<span style={{ opacity: 0.85, marginLeft: 8 }}>· role: {ROLE_LABEL[imp.currentRole]}</span>
)}
</span>
<button
type="button"
onClick={exitImpersonate}
disabled={working}
style={{
marginLeft: 'auto',
backgroundColor: 'rgba(255,255,255,0.18)',
border: '1px solid rgba(255,255,255,0.35)',
color: '#fff',
padding: '4px 12px',
borderRadius: 6,
fontSize: 12,
fontWeight: 700,
cursor: working ? 'wait' : 'pointer',
display: 'inline-flex',
alignItems: 'center',
gap: 6,
}}
>
{working ? <RefreshCw size={12} className="animate-spin" /> : <X size={12} />}
Exit impersonation
</button>
</div>
);
}
// -------- Dropdown (admin only) --------
if (session?.role !== 'admin') return null;
return (
<div ref={menuRef} style={{ position: 'relative' }}>
<button
type="button"
onClick={() => setOpen((o) => !o)}
className="btn btn-ghost btn-sm"
title="View as another user"
style={{ gap: 6 }}
>
<UserCheck size={14} />
<span className="hidden sm:inline">View as…</span>
<ChevronDown size={12} />
</button>
{open && (
<div
style={{
position: 'absolute',
top: 'calc(100% + 6px)',
right: 0,
zIndex: 90,
width: 320,
maxHeight: 460,
overflowY: 'auto',
backgroundColor: 'var(--color-surface)',
border: '1px solid var(--color-border)',
borderRadius: 10,
boxShadow: '0 12px 36px rgba(0,0,0,0.35)',
}}
>
<div
style={{
padding: '10px 14px',
borderBottom: '1px solid var(--color-border)',
fontSize: 11,
fontWeight: 700,
textTransform: 'uppercase',
letterSpacing: '0.06em',
color: 'var(--color-text-muted)',
}}
>
View as another user
</div>
{errMsg && (
<div style={{ padding: '8px 14px', fontSize: 12, color: '#dc2626', background: 'rgba(220,38,38,0.08)' }}>
{errMsg}
</div>
)}
{users.length === 0 ? (
<div style={{ padding: 16, fontSize: 13, color: 'var(--color-text-muted)', textAlign: 'center' }}>
<RefreshCw size={14} className="animate-spin" style={{ display: 'inline', marginRight: 6 }} />
Loading users...
</div>
) : (
<ul style={{ listStyle: 'none', margin: 0, padding: 4 }}>
{users
.filter((u) => u.username !== session?.user)
.map((u) => (
<li key={u.id}>
<button
type="button"
onClick={() => startImpersonate(u.username)}
disabled={working || u.is_active === false}
style={{
width: '100%',
textAlign: 'left',
background: 'none',
border: 'none',
padding: '8px 10px',
borderRadius: 6,
cursor: u.is_active === false ? 'not-allowed' : 'pointer',
color: 'var(--color-text)',
display: 'flex',
alignItems: 'center',
gap: 8,
opacity: u.is_active === false ? 0.45 : 1,
}}
onMouseEnter={(e) => { (e.currentTarget as HTMLButtonElement).style.backgroundColor = 'var(--color-surface-el)'; }}
onMouseLeave={(e) => { (e.currentTarget as HTMLButtonElement).style.backgroundColor = 'transparent'; }}
>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 13, fontWeight: 600, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{u.full_name || u.display_name || u.username}
</div>
<div style={{ fontSize: 11, color: 'var(--color-text-muted)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{u.email || u.username}
</div>
</div>
<span
style={{
fontSize: 10,
fontWeight: 700,
padding: '2px 8px',
borderRadius: 999,
backgroundColor: `${ROLE_COLOR[u.role]}20`,
color: ROLE_COLOR[u.role],
flexShrink: 0,
}}
>
{ROLE_LABEL[u.role]}
</span>
</button>
</li>
))}
</ul>
)}
<div style={{ padding: '8px 14px', fontSize: 11, color: 'var(--color-text-muted)', borderTop: '1px solid var(--color-border)' }}>
Your real admin session is preserved. Exit any time via the orange banner.
</div>
</div>
)}
</div>
);
}