← back to Norma

components/api-registry/ApiRegistryModal.tsx

390 lines

'use client';

// Comprehensive admin-only API/Token/MCP registry modal. Lists every integration
// Norma talks to, whether it's configured, and step-by-step instructions for
// getting each key. Values never leak — server returns boolean set/missing only.

import { useEffect, useMemo, useState } from 'react';
import {
  Search, CheckCircle2, AlertCircle, MinusCircle, Copy, ExternalLink,
  ChevronDown, ChevronRight, Filter, Eye, Lock,
} from 'lucide-react';
import ResizableModal from '../shared/ResizableModal';

type Status = 'set' | 'missing' | 'partial';

interface Entry {
  id: string;
  name: string;
  category: string;
  purpose: string;
  required: boolean;
  envVars: string[];
  status: Status;
  signupUrl?: string;
  docsUrl?: string;
  scopes?: string[];
  pricing?: string;
  getSteps: string[];
  envVarStatus: Record<string, boolean>;
}

interface RegistryPayload {
  categories: string[];
  entries: Entry[];
  summary: {
    total: number;
    set: number;
    partial: number;
    missing: number;
    requiredMissing: number;
  };
}

function StatusBadge({ status, required }: { status: Status; required: boolean }) {
  if (status === 'set') {
    return (
      <span style={{
        display: 'inline-flex', alignItems: 'center', gap: 4,
        padding: '2px 8px', borderRadius: 999, fontSize: 11, fontWeight: 600,
        background: 'rgba(16,185,129,0.15)', color: '#34d399',
      }}>
        <CheckCircle2 size={12} /> Configured
      </span>
    );
  }
  if (status === 'partial') {
    return (
      <span style={{
        display: 'inline-flex', alignItems: 'center', gap: 4,
        padding: '2px 8px', borderRadius: 999, fontSize: 11, fontWeight: 600,
        background: 'rgba(245,158,11,0.15)', color: '#fbbf24',
      }}>
        <AlertCircle size={12} /> Partial
      </span>
    );
  }
  return (
    <span style={{
      display: 'inline-flex', alignItems: 'center', gap: 4,
      padding: '2px 8px', borderRadius: 999, fontSize: 11, fontWeight: 600,
      background: required ? 'rgba(244,63,94,0.18)' : 'rgba(160,160,180,0.12)',
      color: required ? '#fb7185' : '#9ca3af',
    }}>
      {required ? <AlertCircle size={12} /> : <MinusCircle size={12} />}
      {required ? 'Missing (required)' : 'Not configured'}
    </span>
  );
}

function EntryCard({ entry }: { entry: Entry }) {
  const [expanded, setExpanded] = useState(false);
  const [copiedVar, setCopiedVar] = useState<string | null>(null);

  const copyVar = (v: string) => {
    navigator.clipboard.writeText(v + '=');
    setCopiedVar(v);
    setTimeout(() => setCopiedVar(null), 1400);
  };

  return (
    <div className="card" style={{ padding: 0, overflow: 'hidden' }}>
      <button
        onClick={() => setExpanded(p => !p)}
        style={{
          width: '100%', padding: '14px 16px',
          display: 'flex', alignItems: 'flex-start', gap: 12,
          background: 'transparent', border: 'none', color: 'inherit',
          textAlign: 'left', cursor: 'pointer',
        }}
      >
        <div style={{ paddingTop: 2 }}>
          {expanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
        </div>
        <div style={{ flex: 1, minWidth: 0 }}>
          <div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap', marginBottom: 4 }}>
            <strong style={{ fontSize: 14, color: 'var(--color-text)' }}>{entry.name}</strong>
            <StatusBadge status={entry.status} required={entry.required} />
            {entry.required && (
              <span style={{
                fontSize: 10, fontWeight: 700, letterSpacing: '0.06em',
                color: '#fbbf24', textTransform: 'uppercase',
              }}>REQUIRED</span>
            )}
          </div>
          <p style={{ fontSize: 12, color: 'var(--color-text-secondary)', margin: 0, lineHeight: 1.4 }}>
            {entry.purpose}
          </p>
        </div>
      </button>

      {expanded && (
        <div style={{
          padding: '0 16px 16px 44px',
          borderTop: '1px solid var(--color-border)',
          background: 'rgba(0,0,0,0.12)',
        }}>
          {/* env vars */}
          {entry.envVars.length > 0 && (
            <div style={{ marginTop: 14 }}>
              <div style={{ fontSize: 11, fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.08em', color: 'var(--color-text-muted)', marginBottom: 6 }}>
                Env vars
              </div>
              <div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
                {entry.envVars.map(v => {
                  const isSet = entry.envVarStatus[v];
                  return (
                    <div key={v} style={{
                      display: 'flex', alignItems: 'center', gap: 8,
                      padding: '4px 8px', borderRadius: 4,
                      background: 'var(--color-surface-el)',
                      fontFamily: 'ui-monospace, Menlo, monospace', fontSize: 12,
                    }}>
                      {isSet
                        ? <CheckCircle2 size={12} color="#34d399" />
                        : <Lock size={12} color="#9ca3af" />}
                      <span style={{ color: isSet ? 'var(--color-text)' : 'var(--color-text-muted)', flex: 1 }}>{v}</span>
                      <button
                        onClick={() => copyVar(v)}
                        title={`Copy "${v}=" to clipboard`}
                        style={{
                          display: 'inline-flex', alignItems: 'center', gap: 4,
                          background: 'transparent', border: '1px solid var(--color-border)',
                          color: 'var(--color-text-muted)', padding: '2px 6px',
                          borderRadius: 4, fontSize: 10, cursor: 'pointer',
                        }}
                      >
                        <Copy size={10} />
                        {copiedVar === v ? 'copied' : 'copy'}
                      </button>
                    </div>
                  );
                })}
              </div>
            </div>
          )}

          {/* pricing */}
          {entry.pricing && (
            <div style={{ marginTop: 12, fontSize: 12, color: 'var(--color-text-secondary)' }}>
              <span style={{ fontWeight: 700, color: 'var(--color-text-muted)', textTransform: 'uppercase', letterSpacing: '0.08em', fontSize: 10 }}>Pricing · </span>
              {entry.pricing}
            </div>
          )}

          {/* scopes */}
          {entry.scopes && entry.scopes.length > 0 && (
            <div style={{ marginTop: 10 }}>
              <div style={{ fontSize: 10, fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.08em', color: 'var(--color-text-muted)', marginBottom: 4 }}>
                OAuth scopes
              </div>
              <div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
                {entry.scopes.map(s => (
                  <code key={s} style={{
                    fontSize: 10, padding: '2px 6px', borderRadius: 3,
                    background: 'var(--color-surface-el)', color: 'var(--color-text-secondary)',
                  }}>{s}</code>
                ))}
              </div>
            </div>
          )}

          {/* how to get */}
          <div style={{ marginTop: 14 }}>
            <div style={{ fontSize: 11, fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.08em', color: 'var(--color-text-muted)', marginBottom: 6 }}>
              How to get this
            </div>
            <ol style={{ margin: 0, paddingLeft: 20, color: 'var(--color-text-secondary)', fontSize: 12, lineHeight: 1.55 }}>
              {entry.getSteps.map((step, i) => (
                <li key={i} style={{ marginBottom: 4 }}>{step}</li>
              ))}
            </ol>
          </div>

          {/* links */}
          <div style={{ marginTop: 12, display: 'flex', gap: 8, flexWrap: 'wrap' }}>
            {entry.signupUrl && (
              <a
                href={entry.signupUrl}
                target="_blank"
                rel="noopener noreferrer"
                className="btn btn-primary btn-sm"
                style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}
              >
                <ExternalLink size={12} />
                Sign up / Get key
              </a>
            )}
            {entry.docsUrl && (
              <a
                href={entry.docsUrl}
                target="_blank"
                rel="noopener noreferrer"
                className="btn btn-secondary btn-sm"
                style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}
              >
                <ExternalLink size={12} />
                Docs
              </a>
            )}
          </div>
        </div>
      )}
    </div>
  );
}

export default function ApiRegistryModal({ onClose }: { onClose: () => void }) {
  const [data, setData] = useState<RegistryPayload | null>(null);
  const [loading, setLoading] = useState(true);
  const [err, setErr] = useState<string | null>(null);
  const [search, setSearch] = useState('');
  const [statusFilter, setStatusFilter] = useState<'all' | 'set' | 'missing' | 'required-missing'>('all');

  useEffect(() => {
    let alive = true;
    fetch('/api/registry', { credentials: 'same-origin' })
      .then(r => {
        if (!r.ok) throw new Error(r.status === 403 ? 'Admin only' : `HTTP ${r.status}`);
        return r.json();
      })
      .then(d => { if (alive) { setData(d as RegistryPayload); setLoading(false); } })
      .catch(e => { if (alive) { setErr(String(e.message || e)); setLoading(false); } });
    return () => { alive = false; };
  }, []);

  const filtered = useMemo(() => {
    if (!data) return [];
    const q = search.trim().toLowerCase();
    return data.entries.filter(e => {
      // Status filter
      if (statusFilter === 'set' && e.status !== 'set') return false;
      if (statusFilter === 'missing' && e.status === 'set') return false;
      if (statusFilter === 'required-missing' && !(e.required && e.status !== 'set')) return false;
      // Search
      if (!q) return true;
      const hay = [e.name, e.purpose, e.category, ...e.envVars, ...(e.scopes || [])].join(' ').toLowerCase();
      return hay.includes(q);
    });
  }, [data, search, statusFilter]);

  const grouped = useMemo(() => {
    if (!data) return new Map<string, Entry[]>();
    const m = new Map<string, Entry[]>();
    for (const cat of data.categories) m.set(cat, []);
    for (const e of filtered) {
      const list = m.get(e.category) || [];
      list.push(e);
      m.set(e.category, list);
    }
    // Drop empty categories
    for (const [k, v] of m) if (v.length === 0) m.delete(k);
    return m;
  }, [data, filtered]);

  return (
    <ResizableModal
      modalId="api-registry"
      title={
        <div>
          <div style={{ fontSize: 18, fontWeight: 700, color: 'var(--color-text)' }}>
            APIs, Tokens & MCP Servers
          </div>
          <div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginTop: 2 }}>
            Comprehensive admin view. Values never leave the server — set/missing only.
          </div>
        </div>
      }
      onClose={onClose}
      defaultWidth={920}
    >
      {/* Summary bar */}
      {data && (
        <div style={{
          padding: '10px 0', marginBottom: 12, borderBottom: '1px solid var(--color-border)',
          display: 'flex', gap: 16, fontSize: 12, color: 'var(--color-text-secondary)',
          flexWrap: 'wrap',
        }}>
          <span><strong style={{ color: 'var(--color-text)' }}>{data.summary.total}</strong> total</span>
          <span style={{ color: '#34d399' }}>● {data.summary.set} configured</span>
          <span style={{ color: '#fbbf24' }}>● {data.summary.partial} partial</span>
          <span style={{ color: '#fb7185' }}>● {data.summary.missing} missing</span>
          {data.summary.requiredMissing > 0 && (
            <span style={{ color: '#fb7185', fontWeight: 700 }}>
              ⚠ {data.summary.requiredMissing} REQUIRED missing
            </span>
          )}
        </div>
      )}

      {/* Controls */}
      <div style={{
        padding: '12px 0', display: 'flex', gap: 10, alignItems: 'center',
        borderBottom: '1px solid var(--color-border)', flexWrap: 'wrap', marginBottom: 16,
      }}>
        <div style={{ position: 'relative', flex: 1, minWidth: 220 }}>
          <Search size={14} style={{
            position: 'absolute', left: 10, top: '50%', transform: 'translateY(-50%)',
            color: 'var(--color-text-muted)',
          }} />
          <input
            className="input"
            placeholder="Search by name, env var, scope, purpose..."
            value={search}
            onChange={e => setSearch(e.target.value)}
            style={{ paddingLeft: 32, fontSize: 13 }}
          />
        </div>
        <select
          className="input"
          value={statusFilter}
          onChange={e => setStatusFilter(e.target.value as typeof statusFilter)}
          style={{ fontSize: 12, padding: '6px 10px', minWidth: 180 }}
        >
          <option value="all">All statuses</option>
          <option value="set">Configured only</option>
          <option value="missing">Missing / partial</option>
          <option value="required-missing">⚠ Required & missing</option>
        </select>
      </div>

      {/* Content */}
      <div>
        {loading && (
          <div style={{ textAlign: 'center', color: 'var(--color-text-muted)', padding: 60 }}>
            Loading registry...
          </div>
        )}
        {err && (
          <div style={{
            padding: 16, borderRadius: 'var(--radius-md)',
            background: 'rgba(244,63,94,0.12)', color: '#fb7185',
          }}>
            {err}
          </div>
        )}
        {!loading && !err && data && grouped.size === 0 && (
          <div style={{ textAlign: 'center', color: 'var(--color-text-muted)', padding: 60 }}>
            No integrations match.
          </div>
        )}
        {Array.from(grouped.entries()).map(([cat, list]) => (
          <div key={cat} style={{ marginBottom: 24 }}>
            <div style={{
              fontSize: 11, fontWeight: 700, textTransform: 'uppercase',
              letterSpacing: '0.12em', color: 'var(--color-text-muted)',
              marginBottom: 10, paddingBottom: 6,
              borderBottom: '1px solid var(--color-border)',
            }}>
              {cat} <span style={{ color: 'var(--color-text-muted)', fontWeight: 400 }}>({list.length})</span>
            </div>
            <div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
              {list.map(e => <EntryCard key={e.id} entry={e} />)}
            </div>
          </div>
        ))}
      </div>
    </ResizableModal>
  );
}