[object Object]

← 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

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 →