/**
* ServerConfigDrawer — unified setup wizard for builtin MCP servers
* that require configuration (OAuth tokens, API keys, etc.).
*
* Three-phase flow:
* 1. Setup Guide — prerequisite steps with external links
* 2. Credential Form — dynamic fields from backend config schema
* 3. Connect & Verify — save, restart, health check
*
* Works with any `requires_config` type: GOOGLE_OAUTH, SLACK_TOKEN,
* GITHUB_TOKEN, NOTION_TOKEN, MS_GRAPH_TOKEN, MS_TEAMS_AUTH.
*/
import React, { useCallback, useEffect, useState } from 'react';
import { X, ArrowRight, ArrowLeft, Check, ExternalLink, Eye, EyeOff, Loader2, Key, Shield, AlertCircle, CheckCircle, Settings, ToggleLeft, ToggleRight, ChevronDown, RefreshCw, } from 'lucide-react';
import { getBuiltinSetupGuide } from './builtinSetupGuides';
// ── Step indicator ──────────────────────────────────────────────────────
function StepIndicator({ current, total }) {
return (
{Array.from({ length: total }, (_, i) => (
))}
{current + 1} / {total}
);
}
// ── Toggle switch ────────────────────────────────────────────────────────
function ToggleSwitch({ checked, onChange }) {
return ( onChange(!checked)} className="flex items-center gap-2 text-white/60 hover:text-white/80 transition-colors">
{checked ? ( ) : ( )}
{checked ? 'Enabled' : 'Disabled'}
);
}
// ── Main drawer ─────────────────────────────────────────────────────────
export function ServerConfigDrawer({ server, backendUrl, apiKey, onClose, onComplete }) {
const [state, setState] = useState('loading');
const [guideStep, setGuideStep] = useState(0);
const [config, setConfig] = useState(null);
const [fieldValues, setFieldValues] = useState({});
const [showSecrets, setShowSecrets] = useState({});
const [error, setError] = useState(null);
const [validationErrors, setValidationErrors] = useState([]);
const guide = getBuiltinSetupGuide(server.id);
const guideSteps = guide?.prerequisiteSteps ?? [];
const headers = { 'Content-Type': 'application/json' };
if (apiKey)
headers['x-api-key'] = apiKey;
// ── Load config schema from backend ────────────────────────────────
const loadConfig = useCallback(async () => {
setState('loading');
try {
const res = await fetch(`${backendUrl}/v1/agentic/servers/${encodeURIComponent(server.id)}/config`, { headers: apiKey ? { 'x-api-key': apiKey } : {} });
if (!res.ok)
throw new Error(`HTTP ${res.status}`);
const data = (await res.json());
setConfig(data);
// Initialize field values from current config
const initial = {};
for (const f of data.fields) {
// Don't pre-fill masked secrets
initial[f.key] = f.type === 'secret' && f.value?.includes('••') ? '' : (f.value || f.default || '');
}
setFieldValues(initial);
// If already configured, skip guide and go to credentials
if (data.configured && guideSteps.length === 0) {
setState('credentials');
}
else {
setState(guideSteps.length > 0 ? 'guide' : 'credentials');
}
}
catch (e) {
setError(e?.message || 'Failed to load config');
setState('error');
}
}, [backendUrl, apiKey, server.id, guideSteps.length]);
useEffect(() => {
void loadConfig();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// ── Field value change ─────────────────────────────────────────────
const setField = (key, value) => {
setFieldValues((prev) => ({ ...prev, [key]: value }));
setValidationErrors([]);
};
// ── Check if a conditional field should be visible ─────────────────
const isFieldVisible = (field) => {
if (!field.condition)
return true;
const [condKey, condVal] = field.condition.split('=');
return fieldValues[condKey] === condVal;
};
// ── Validate ───────────────────────────────────────────────────────
const validateLocally = () => {
if (!config)
return false;
const errors = [];
for (const f of config.fields) {
if (!isFieldVisible(f))
continue;
if (f.required && !fieldValues[f.key]?.trim()) {
errors.push(`${f.label} is required`);
}
}
setValidationErrors(errors);
return errors.length === 0;
};
// ── Save & restart ─────────────────────────────────────────────────
const handleSave = async () => {
if (!validateLocally())
return;
setState('saving');
setError(null);
try {
const res = await fetch(`${backendUrl}/v1/agentic/servers/${encodeURIComponent(server.id)}/config`, {
method: 'POST',
headers,
body: JSON.stringify({ fields: fieldValues }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
throw new Error(data.detail || `HTTP ${res.status}`);
}
setState('success');
}
catch (e) {
setError(e?.message || 'Save failed');
setState('error');
}
};
// ── Navigation ─────────────────────────────────────────────────────
const handleGuideNext = () => {
if (guideStep < guideSteps.length - 1) {
setGuideStep(guideStep + 1);
}
else {
setState('credentials');
}
};
const handleGuideBack = () => {
if (guideStep > 0) {
setGuideStep(guideStep - 1);
}
};
const handleBackToCredentials = () => {
setState('credentials');
setError(null);
setValidationErrors([]);
};
// ── Total steps for progress indicator ─────────────────────────────
const totalSteps = guideSteps.length + 1; // guide steps + credential step
const currentStep = state === 'guide' ? guideStep : guideSteps.length;
// ── Render: loading ────────────────────────────────────────────────
const renderLoading = () => ();
// ── Render: guide step ─────────────────────────────────────────────
const renderGuideStep = () => {
const step = guideSteps[guideStep];
if (!step)
return null;
return (
{/* Current step card */}
{/* Steps overview */}
{guideSteps.map((s, i) => (
{i < guideStep ? : i + 1}
{s.title}
))}
{/* Show credential step in overview */}
{guideSteps.length + 1}
Enter credentials
);
};
// ── Render: credential form ────────────────────────────────────────
const renderCredentials = () => {
if (!config)
return null;
const visibleFields = config.fields.filter(isFieldVisible);
return (
{/* Header info */}
{guide && (
Enter the credentials from the previous steps. Your credentials are stored locally
in the server's configuration file and are never sent to external services.
)}
{/* Validation errors */}
{validationErrors.length > 0 && (
{validationErrors.map((err, i) => (
))}
)}
{/* Dynamic fields */}
{visibleFields.map((field) => (
{field.type === 'secret' ? ( ) : field.type === 'toggle' ? ( ) : ( )}
{field.label}
{field.required && * }
{field.type === 'toggle' ? (
setField(field.key, v ? 'true' : 'false')}/>) : field.type === 'select' ? (
setField(field.key, e.target.value)} className="w-full bg-black/30 border border-white/10 rounded-xl px-4 py-3 pr-10 text-sm text-white appearance-none focus:outline-none focus:border-cyan-500/50 transition-colors">
{(field.options || []).map((opt) => (
{opt}
))}
) : (
setField(field.key, e.target.value)} placeholder={field.placeholder || ''} className="w-full bg-black/30 border border-white/10 rounded-xl px-4 py-3 pr-10 text-sm text-white placeholder-white/25 focus:outline-none focus:border-cyan-500/50 transition-colors font-mono"/>
{field.type === 'secret' && ( setShowSecrets((prev) => ({
...prev,
[field.key]: !prev[field.key],
}))} className="absolute right-3 top-1/2 -translate-y-1/2 text-white/30 hover:text-white/60 transition-colors">
{showSecrets[field.key] ? : }
)}
)}
{field.hint && ({field.hint}
)}
))}
);
};
// ── Render: saving ─────────────────────────────────────────────────
const renderSaving = () => (
Configuring {server.label}
Saving credentials and restarting server...
);
// ── Render: success ────────────────────────────────────────────────
const renderSuccess = () => (
{server.label} configured!
Credentials saved. {server.installed ? 'The server has been restarted with the new configuration.' : 'Click "Install" to start the server.'}
{
onComplete();
onClose();
}} className="flex items-center gap-2 px-6 py-2.5 bg-emerald-500 hover:bg-emerald-600 text-white text-sm font-medium rounded-xl transition-colors">
Done
);
// ── Render: error ──────────────────────────────────────────────────
const renderError = () => (
Configuration Failed
{error}
);
// ── Main render ────────────────────────────────────────────────────
const showGuide = state === 'guide';
const showCredentials = state === 'credentials';
const showFooter = showGuide || showCredentials;
return (
{/* Backdrop */}
{/* Panel */}
e.stopPropagation()}>
{/* Header */}
{guide?.title || `Configure ${server.label}`}
{guide?.subtitle && (
{guide.subtitle}
)}
{!guide && config?.requires_config && (
Requires: {config.requires_config}
)}
{/* Progress indicator */}
{showFooter && totalSteps > 1 && (
)}
{/* Body */}
{state === 'loading' && renderLoading()}
{showGuide && renderGuideStep()}
{showCredentials && renderCredentials()}
{state === 'saving' && renderSaving()}
{state === 'success' && renderSuccess()}
{state === 'error' && renderError()}
{/* Footer navigation */}
{showFooter && (
{ if (guideSteps.length > 0) {
setState('guide');
setGuideStep(guideSteps.length - 1);
} }} disabled={showGuide && guideStep === 0} className="flex items-center gap-1.5 px-3 py-2 text-sm text-white/50 hover:text-white/80 disabled:opacity-30 disabled:cursor-default transition-colors">
Back
{showGuide ? (
{guideStep < guideSteps.length - 1 ? 'Next' : 'Enter Credentials'}
) : (
Save & Connect
)}
)}
);
}