'use client' import { useState, useEffect, useCallback } from 'react' import { useTranslations } from 'next-intl' import { Button } from '@/components/ui/button' interface EnvVarInfo { redacted: string set: boolean } interface Integration { id: string name: string category: string categoryLabel: string envVars: Record status: 'connected' | 'partial' | 'not_configured' vaultItem: string | null testable: boolean recommendation?: string | null } interface Category { id: string label: string } export function IntegrationsPanel() { const t = useTranslations('integrations') const [integrations, setIntegrations] = useState([]) const [categories, setCategories] = useState([]) const [opAvailable, setOpAvailable] = useState(false) const [envPath, setEnvPath] = useState(null) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) const [activeCategory, setActiveCategory] = useState('ai') // Edits: integration id -> env var key -> new value const [edits, setEdits] = useState>({}) const [revealed, setRevealed] = useState>(new Set()) const [saving, setSaving] = useState(false) const [feedback, setFeedback] = useState<{ ok: boolean; text: string } | null>(null) const [testing, setTesting] = useState(null) // integration id being tested const [pulling, setPulling] = useState(null) // integration id being pulled const [pullingAll, setPullingAll] = useState(false) const [confirmRemove, setConfirmRemove] = useState<{ integrationId: string; keys: string[] } | null>(null) const showFeedback = (ok: boolean, text: string) => { setFeedback({ ok, text }) setTimeout(() => setFeedback(null), 3000) } const fetchIntegrations = useCallback(async () => { try { const res = await fetch('/api/integrations') if (res.status === 401 || res.status === 403) { setError('Admin access required') return } if (!res.ok) { setError('Failed to load integrations') return } const data = await res.json() setIntegrations(data.integrations || []) setCategories(data.categories || []) setOpAvailable(data.opAvailable ?? false) setEnvPath(data.envPath ?? null) if (data.categories?.[0]) { setActiveCategory(prev => { // Keep current if valid, otherwise default to first const ids = (data.categories as Category[]).map((c: Category) => c.id) return ids.includes(prev) ? prev : ids[0] }) } } catch { setError('Failed to load integrations') } finally { setLoading(false) } }, []) useEffect(() => { fetchIntegrations() }, [fetchIntegrations]) const handleEdit = (envKey: string, value: string) => { setEdits(prev => ({ ...prev, [envKey]: value })) } const cancelEdit = (envKey: string) => { setEdits(prev => { const next = { ...prev } delete next[envKey] return next }) } const toggleReveal = (envKey: string) => { setRevealed(prev => { const next = new Set(prev) if (next.has(envKey)) next.delete(envKey) else next.add(envKey) return next }) } const hasChanges = Object.keys(edits).length > 0 const handleSave = async () => { if (!hasChanges) return setSaving(true) try { const res = await fetch('/api/integrations', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ vars: edits }), }) const data = await res.json() if (res.ok) { showFeedback(true, `Saved ${data.count} variable${data.count === 1 ? '' : 's'}`) setEdits({}) setRevealed(new Set()) fetchIntegrations() } else { showFeedback(false, data.error || 'Failed to save') } } catch { showFeedback(false, 'Network error') } finally { setSaving(false) } } const handleDiscard = () => { setEdits({}) setRevealed(new Set()) } const handleRemove = async (envKeys: string[]) => { try { const res = await fetch(`/api/integrations?keys=${encodeURIComponent(envKeys.join(','))}`, { method: 'DELETE', }) const data = await res.json() if (res.ok) { showFeedback(true, `Removed ${data.count} variable${data.count === 1 ? '' : 's'}`) fetchIntegrations() } else { showFeedback(false, data.error || 'Failed to remove') } } catch { showFeedback(false, 'Network error') } } const handleTest = async (integrationId: string) => { setTesting(integrationId) try { const res = await fetch('/api/integrations', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'test', integrationId }), }) const data = await res.json() if (data.ok) { showFeedback(true, data.detail || 'Connection successful') } else { showFeedback(false, data.detail || data.error || 'Test failed') } } catch { showFeedback(false, 'Network error') } finally { setTesting(null) } } const handlePull = async (integrationId: string) => { setPulling(integrationId) try { const res = await fetch('/api/integrations', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'pull', integrationId }), }) const data = await res.json() if (data.ok) { showFeedback(true, data.detail || 'Pulled from 1Password') fetchIntegrations() } else { showFeedback(false, data.error || 'Pull failed') } } catch { showFeedback(false, 'Network error') } finally { setPulling(null) } } const handlePullAll = async () => { setPullingAll(true) try { const res = await fetch('/api/integrations', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'pull-all', category: activeCategory }), }) const data = await res.json() if (data.ok) { showFeedback(true, data.detail || 'Pulled from 1Password') fetchIntegrations() } else { showFeedback(false, data.error || 'Pull failed') } } catch { showFeedback(false, 'Network error') } finally { setPullingAll(false) } } const confirmAndRemove = (integrationId: string, keys: string[]) => { setConfirmRemove({ integrationId, keys }) } // Loading state if (loading) { return (
{t('loading')}
) } // Error state if (error) { return (
{error}
) } const filteredIntegrations = integrations.filter(i => i.category === activeCategory) const connectedCount = integrations.filter(i => i.status === 'connected').length return (
{/* Header */}

{t('title')}

{t('connectedCount', { connected: connectedCount, total: integrations.length })} {envPath && {envPath}}

{opAvailable && ( <> 1P CLI )} {hasChanges && ( )}
{/* Feedback */} {feedback && (
{feedback.text}
)} {/* Category tabs */}
{categories.map(cat => { const catIntegrations = integrations.filter(i => i.category === cat.id) const catConnected = catIntegrations.filter(i => i.status === 'connected').length return ( ) })}
{/* Integration cards */}
{filteredIntegrations.map(integration => ( handleTest(integration.id)} onPull={() => handlePull(integration.id)} onRemove={() => { const setKeys = Object.entries(integration.envVars) .filter(([, v]) => v.set) .map(([k]) => k) if (setKeys.length > 0) confirmAndRemove(integration.id, setKeys) }} /> ))} {filteredIntegrations.length === 0 && (
{t('noIntegrationsInCategory')}
)}
{/* Unsaved changes bar */} {hasChanges && (
{Object.keys(edits).length} unsaved change{Object.keys(edits).length === 1 ? '' : 's'}
)} {/* Remove confirmation dialog */} {confirmRemove && (

{t('removeTitle')}

{t('removeDescription', { target: confirmRemove.keys.length === 1 ? confirmRemove.keys[0] : String(confirmRemove.keys.length) })}

)}
) } // --------------------------------------------------------------------------- // Integration card component // --------------------------------------------------------------------------- function IntegrationCard({ integration, edits, revealed, opAvailable, testing, pulling, onEdit, onCancelEdit, onToggleReveal, onTest, onPull, onRemove, }: { integration: Integration edits: Record revealed: Set opAvailable: boolean testing: boolean pulling: boolean onEdit: (key: string, value: string) => void onCancelEdit: (key: string) => void onToggleReveal: (key: string) => void onTest: () => void onPull: () => void onRemove: () => void }) { const t = useTranslations('integrations') const statusColors = { connected: 'bg-green-500', partial: 'bg-amber-500', not_configured: 'bg-muted-foreground/30', } const statusLabels = { connected: 'Connected', partial: 'Partial', not_configured: 'Not configured', } const hasEdits = Object.keys(integration.envVars).some(k => edits[k] !== undefined) const hasSetVars = Object.values(integration.envVars).some(v => v.set) return (
{/* Card header */}
{integration.name} {statusLabels[integration.status]}
{/* Pull from 1Password */} {integration.vaultItem && opAvailable && ( )} {/* Test connection */} {integration.testable && hasSetVars && ( )} {/* Remove */} {hasSetVars && ( )}
{/* Env var rows */}
{Object.entries(integration.envVars).map(([envKey, info]) => { const isEditing = edits[envKey] !== undefined const isRevealed = revealed.has(envKey) return (
{envKey}
{isEditing ? ( onEdit(envKey, e.target.value)} placeholder="Enter value..." className="flex-1 px-2 py-1 text-xs bg-background border border-primary/50 rounded focus:border-primary focus:outline-none font-mono" autoComplete="off" data-1p-ignore /> ) : info.set ? ( {info.redacted} ) : ( {t('notSet')} )}
{/* Reveal toggle (only when editing) */} {isEditing && ( )} {/* Edit button */} {!isEditing && ( )} {/* Cancel edit */} {isEditing && ( )}
) })}
{integration.recommendation && (

{integration.recommendation}

{integration.id === 'x_twitter' && ( )}
)}
) } // --------------------------------------------------------------------------- // Inline SVG icons (matching nav-rail pattern: 16x16, stroke-based) // --------------------------------------------------------------------------- function EyeIcon() { return ( ) } function EyeOffIcon() { return ( ) } function EditIcon() { return ( ) } function XIcon() { return ( ) }