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