← back to Norma
feat(impersonate): wire the View-as dropdown + Acting-as banner into AppShell
1c1e1df6ce9472f6f1688db4a664b9581e81ea73 · 2026-05-20 14:30:44 -0700 · Steve Abrams
Backend was already shipped at /api/admin/impersonate (GET state,
POST {username} to start, DELETE to exit; preserves the real admin's
session in `norma-imp-by` cookie). This adds the UI:
- components/ImpersonateMenu.tsx
- variant="dropdown" — renders a "View as…" button in the AppShell
header, admin-only, lists every user with role chip + email; click
POSTs and hard-reloads
- variant="banner" — renders an amber sticky bar at the very top of
the chrome while impersonating, shows "Impersonating <user> · role
+ Exit impersonation" button (DELETE + hard-reload)
- Single component, two render modes so the same fetch + state logic
powers both
- components/AppShell.tsx — mounts <ImpersonateMenu variant="banner" />
above the InboxThemeBanner (sticky-top), and <ImpersonateMenu
variant="dropdown" /> in the header next to <ApiRegistryButton/>.
Smoke-verified end-to-end:
- admin → POST {ryan} → session reports user=ryan role=intern
- /api/admin/impersonate GET while impersonating reports impersonating:true
- DELETE → session restored to admin
Files touched
M components/AppShell.tsxA components/ImpersonateMenu.tsx
Diff
commit 1c1e1df6ce9472f6f1688db4a664b9581e81ea73
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Wed May 20 14:30:44 2026 -0700
feat(impersonate): wire the View-as dropdown + Acting-as banner into AppShell
Backend was already shipped at /api/admin/impersonate (GET state,
POST {username} to start, DELETE to exit; preserves the real admin's
session in `norma-imp-by` cookie). This adds the UI:
- components/ImpersonateMenu.tsx
- variant="dropdown" — renders a "View as…" button in the AppShell
header, admin-only, lists every user with role chip + email; click
POSTs and hard-reloads
- variant="banner" — renders an amber sticky bar at the very top of
the chrome while impersonating, shows "Impersonating <user> · role
+ Exit impersonation" button (DELETE + hard-reload)
- Single component, two render modes so the same fetch + state logic
powers both
- components/AppShell.tsx — mounts <ImpersonateMenu variant="banner" />
above the InboxThemeBanner (sticky-top), and <ImpersonateMenu
variant="dropdown" /> in the header next to <ApiRegistryButton/>.
Smoke-verified end-to-end:
- admin → POST {ryan} → session reports user=ryan role=intern
- /api/admin/impersonate GET while impersonating reports impersonating:true
- DELETE → session restored to admin
---
components/AppShell.tsx | 6 +
components/ImpersonateMenu.tsx | 325 +++++++++++++++++++++++++++++++++++++++++
2 files changed, 331 insertions(+)
diff --git a/components/AppShell.tsx b/components/AppShell.tsx
index d731017..e703dbd 100644
--- a/components/AppShell.tsx
+++ b/components/AppShell.tsx
@@ -82,6 +82,7 @@ import GmailCRMTab from './gmail/GmailCRMTab';
import AgeThemeSlider from './AgeThemeSlider';
import ApiRegistryButton from './api-registry/ApiRegistryButton';
import InboxThemeBanner from './InboxThemeBanner';
+import ImpersonateMenu from './ImpersonateMenu';
import PriceTrackerTab from './price-tracker/PriceTrackerTab';
import SocialTab from './social/SocialTab';
@@ -514,6 +515,9 @@ function Shell() {
{/* ── Right: Header + Content ──────────────────────────────────────── */}
<div className="flex flex-col flex-1 min-w-0">
+ {/* ── Impersonation banner (shows ONLY while impersonating) ──────── */}
+ <ImpersonateMenu variant="banner" />
+
{/* ── Gmail CRM theme banner (very top — prev/next/random/dropdown) ─ */}
<InboxThemeBanner />
@@ -653,6 +657,8 @@ function Shell() {
</span>
)}
{/* Inbox theme switcher moved to the top banner (see <InboxThemeBanner /> above). */}
+ {/* Admin: View-as another user (impersonation) */}
+ {role === 'admin' && <ImpersonateMenu variant="dropdown" />}
{/* APIs & Tokens registry — admin only */}
{role === 'admin' && <ApiRegistryButton variant="ghost" />}
<button
diff --git a/components/ImpersonateMenu.tsx b/components/ImpersonateMenu.tsx
new file mode 100644
index 0000000..79f4a7d
--- /dev/null
+++ b/components/ImpersonateMenu.tsx
@@ -0,0 +1,325 @@
+'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>
+ );
+}
← e284feb session 2026-05-20: log roles+perms+modal-fanout+gmail-per-u
·
back to Norma
·
tasks: mark #52 (admin impersonation UI) DONE adb6d64 →