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