import { useCallback, useEffect, useRef, useState } from "react"; import { useAtomSet } from "@effect-atom/atom-react"; import { Option } from "effect"; import { openOAuthPopup, type OAuthPopupResult } from "@executor/plugin-oauth2/react"; import { SecretPicker } from "@executor/react/plugins/secret-picker"; import { useScope } from "@executor/react/api/scope-context"; import { sourceWriteKeys } from "@executor/react/api/reactivity-keys"; import { usePendingSources } from "@executor/react/api/optimistic"; import { HeadersList } from "@executor/react/plugins/headers-list"; import { matchPresetKey, type HeaderState, } from "@executor/react/plugins/secret-header-auth"; import { slugifyNamespace, SourceIdentityFields, useSourceIdentity, } from "@executor/react/plugins/source-identity"; import { useSecretPickerSecrets } from "@executor/react/plugins/use-secret-picker-secrets"; import { Button } from "@executor/react/components/button"; import { CardStack, CardStackContent, CardStackEntry, CardStackEntryContent, CardStackEntryDescription, CardStackEntryField, CardStackEntryTitle, } from "@executor/react/components/card-stack"; import { FieldLabel } from "@executor/react/components/field"; import { FloatActions } from "@executor/react/components/float-actions"; import { Input } from "@executor/react/components/input"; import { Label } from "@executor/react/components/label"; import { NativeSelect, NativeSelectOption, } from "@executor/react/components/native-select"; import { Textarea } from "@executor/react/components/textarea"; import { Checkbox } from "@executor/react/components/checkbox"; import { SourceFavicon } from "@executor/react/components/source-favicon"; import { RadioGroup, RadioGroupItem } from "@executor/react/components/radio-group"; import { Skeleton } from "@executor/react/components/skeleton"; import { IOSSpinner, Spinner } from "@executor/react/components/spinner"; import { addOpenApiSpec, previewOpenApiSpec, startOpenApiOAuth, } from "./atoms"; import type { SpecPreview, HeaderPreset, OAuth2Preset } from "../sdk/preview"; import { OAuth2Auth, type HeaderValue, type ServerInfo, type ServerVariable, } from "../sdk/types"; const OPENAPI_OAUTH_CHANNEL = "executor:openapi-oauth-result"; const OPENAPI_OAUTH_POPUP_NAME = "openapi-oauth"; const substituteUrlVariables = (url: string, values: Record): string => { let out = url; for (const [name, value] of Object.entries(values)) { out = out.replaceAll(`{${name}}`, value); } return out; }; type StrategySelection = | { readonly kind: "none" } | { readonly kind: "custom" } | { readonly kind: "header"; readonly presetIndex: number } | { readonly kind: "oauth2"; readonly presetIndex: number }; const serializeStrategy = (s: StrategySelection): string => { switch (s.kind) { case "none": return "none"; case "custom": return "custom"; case "header": return `header:${s.presetIndex}`; case "oauth2": return `oauth2:${s.presetIndex}`; } }; const parseStrategy = (value: string): StrategySelection => { if (value === "none") return { kind: "none" }; if (value === "custom") return { kind: "custom" }; if (value.startsWith("header:")) { return { kind: "header", presetIndex: Number(value.slice("header:".length)) }; } if (value.startsWith("oauth2:")) { return { kind: "oauth2", presetIndex: Number(value.slice("oauth2:".length)) }; } return { kind: "none" }; }; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- function prefixForHeader(preset: HeaderPreset, headerName: string): string | undefined { const label = preset.label.toLowerCase(); if (headerName.toLowerCase() === "authorization") { if (label.includes("bearer")) return "Bearer "; if (label.includes("basic")) return "Basic "; } return undefined; } function entriesFromSpecPreset(preset: HeaderPreset): HeaderState[] { return preset.secretHeaders.map((headerName) => { const prefix = prefixForHeader(preset, headerName); return { name: headerName, secretId: null, prefix, presetKey: matchPresetKey(headerName, prefix), fromPreset: true, }; }); } // --------------------------------------------------------------------------- // Main component — single progressive form // --------------------------------------------------------------------------- export default function AddOpenApiSource(props: { onComplete: () => void; onCancel: () => void; initialUrl?: string; initialNamespace?: string; }) { // Spec input const [specUrl, setSpecUrl] = useState(props.initialUrl ?? ""); const [analyzing, setAnalyzing] = useState(false); const [analyzeError, setAnalyzeError] = useState(null); // After analysis const [preview, setPreview] = useState(null); // -1 means the user is entering a fully custom base URL (no server selected). const [selectedServerIndex, setSelectedServerIndex] = useState(-1); const [customBaseUrl, setCustomBaseUrl] = useState(""); // Variable selections for the currently selected server, keyed by variable name. const [variableSelections, setVariableSelections] = useState>({}); const identity = useSourceIdentity({ fallbackName: preview ? Option.getOrElse(preview.title, () => "") : "", fallbackNamespace: props.initialNamespace, }); // Auth const [strategy, setStrategy] = useState({ kind: "none" }); const [customHeaders, setCustomHeaders] = useState([]); // OAuth2 state (only populated while an oauth2 preset is selected) const [oauth2ClientIdSecretId, setOauth2ClientIdSecretId] = useState(null); const [oauth2ClientSecretSecretId, setOauth2ClientSecretSecretId] = useState( null, ); const [oauth2SelectedScopes, setOauth2SelectedScopes] = useState>(new Set()); const [oauth2Auth, setOauth2Auth] = useState(null); const [startingOAuth, setStartingOAuth] = useState(false); const [oauth2Error, setOauth2Error] = useState(null); const oauthCleanup = useRef<(() => void) | null>(null); // Submit const [adding, setAdding] = useState(false); const [addError, setAddError] = useState(null); const scopeId = useScope(); const doPreview = useAtomSet(previewOpenApiSpec, { mode: "promise" }); const doAdd = useAtomSet(addOpenApiSpec, { mode: "promise" }); const doStartOAuth = useAtomSet(startOpenApiOAuth, { mode: "promise" }); const { beginAdd } = usePendingSources(); const secretList = useSecretPickerSecrets(); // Keep the latest handleAnalyze in a ref so the debounced effect doesn't // need it as a dependency (it closes over fresh state). const handleAnalyzeRef = useRef<() => void>(() => {}); // Auto-analyze whenever the spec input changes, with a short debounce so // typing/pasting doesn't fire a request on every keystroke. useEffect(() => { const trimmed = specUrl.trim(); if (!trimmed) return; if (preview) return; const handle = setTimeout(() => { handleAnalyzeRef.current(); }, 400); return () => clearTimeout(handle); }, [specUrl, preview]); // ---- Derived state ---- const servers: readonly ServerInfo[] = preview?.servers ?? []; const selectedServer: ServerInfo | null = selectedServerIndex >= 0 ? (servers[selectedServerIndex] ?? null) : null; const serverVariables: Record = selectedServer ? Option.getOrElse( selectedServer.variables, () => ({}) as Record, ) : {}; const serverVariableEntries: Array<[string, ServerVariable]> = Object.entries(serverVariables); const resolvedBaseUrl = selectedServer !== null ? substituteUrlVariables(selectedServer.url, variableSelections) : customBaseUrl.trim(); // Helper used by analyze + server selection: build a default selection map // from a server's variable defaults. const defaultSelectionsFor = (server: ServerInfo): Record => { const vars: Record = Option.getOrElse( server.variables, () => ({}) as Record, ); const out: Record = {}; for (const [name, v] of Object.entries(vars)) out[name] = v.default; return out; }; const allHeaders: Record = {}; for (const ch of customHeaders) { if (ch.name.trim() && ch.secretId) { allHeaders[ch.name.trim()] = { secretId: ch.secretId, ...(ch.prefix ? { prefix: ch.prefix } : {}), }; } } const hasHeaders = Object.keys(allHeaders).length > 0; const customHeadersValid = customHeaders.every((ch) => ch.name.trim() && ch.secretId); const oauth2Presets: readonly OAuth2Preset[] = preview?.oauth2Presets ?? []; const selectedOAuth2Preset: OAuth2Preset | null = strategy.kind === "oauth2" ? (oauth2Presets[strategy.presetIndex] ?? null) : null; const oauth2Ready = strategy.kind !== "oauth2" || oauth2Auth !== null; const canAdd = preview !== null && resolvedBaseUrl.length > 0 && (customHeaders.length === 0 || customHeadersValid) && oauth2Ready; // ---- Handlers ---- const handleAnalyze = async () => { setAnalyzing(true); setAnalyzeError(null); setAddError(null); try { const result = await doPreview({ path: { scopeId }, payload: { spec: specUrl }, }); setPreview(result); const firstServer = result.servers[0]; if (firstServer) { setSelectedServerIndex(0); setVariableSelections(defaultSelectionsFor(firstServer)); setCustomBaseUrl(""); } else { setSelectedServerIndex(-1); setVariableSelections({}); setCustomBaseUrl(""); } const firstPreset = result.headerPresets[0]; if (firstPreset) { setStrategy({ kind: "header", presetIndex: 0 }); setCustomHeaders(entriesFromSpecPreset(firstPreset)); } else { setStrategy({ kind: "none" }); setCustomHeaders([]); } } catch (e) { setAnalyzeError(e instanceof Error ? e.message : "Failed to parse spec"); } finally { setAnalyzing(false); } }; handleAnalyzeRef.current = handleAnalyze; const selectStrategy = (next: StrategySelection) => { setStrategy(next); // Clear any stale OAuth grant whenever the strategy changes away from oauth2. if (next.kind !== "oauth2") { setOauth2Auth(null); setOauth2Error(null); } switch (next.kind) { case "none": setCustomHeaders([]); return; case "custom": { const userHeaders = customHeaders.filter((h) => !h.fromPreset); setCustomHeaders(userHeaders.length > 0 ? userHeaders : []); return; } case "header": { const preset = preview?.headerPresets[next.presetIndex]; if (!preset) return; const userHeaders = customHeaders.filter((h) => !h.fromPreset); setCustomHeaders([...entriesFromSpecPreset(preset), ...userHeaders]); return; } case "oauth2": { setCustomHeaders([]); const preset = preview?.oauth2Presets[next.presetIndex]; if (preset) { setOauth2SelectedScopes(new Set(Object.keys(preset.scopes))); } return; } } }; const handleHeadersChange = (next: HeaderState[]) => { setCustomHeaders(next); if (strategy.kind === "header" && next.every((h) => !h.fromPreset)) { setStrategy(next.length === 0 ? { kind: "none" } : { kind: "custom" }); } }; const toggleOAuth2Scope = (scope: string) => { setOauth2SelectedScopes((prev) => { const copy = new Set(prev); if (copy.has(scope)) copy.delete(scope); else copy.add(scope); return copy; }); // Changing scopes invalidates any previously-granted token. setOauth2Auth(null); }; const handleConnectOAuth2 = useCallback(async () => { if (!selectedOAuth2Preset || !oauth2ClientIdSecretId || !preview) return; oauthCleanup.current?.(); oauthCleanup.current = null; setStartingOAuth(true); setOauth2Error(null); try { const displayName = identity.name.trim() || selectedOAuth2Preset.securitySchemeName; const response = await doStartOAuth({ path: { scopeId }, payload: { displayName, securitySchemeName: selectedOAuth2Preset.securitySchemeName, flow: "authorizationCode", authorizationUrl: Option.getOrElse(selectedOAuth2Preset.authorizationUrl, () => ""), tokenUrl: selectedOAuth2Preset.tokenUrl, redirectUrl: `${window.location.origin}/api/openapi/oauth/callback`, clientIdSecretId: oauth2ClientIdSecretId, clientSecretSecretId: oauth2ClientSecretSecretId, scopes: [...oauth2SelectedScopes], }, }); oauthCleanup.current = openOAuthPopup({ url: response.authorizationUrl, popupName: OPENAPI_OAUTH_POPUP_NAME, channelName: OPENAPI_OAUTH_CHANNEL, onResult: (result: OAuthPopupResult) => { oauthCleanup.current = null; setStartingOAuth(false); if (result.ok) { // The popup payload is an OAuthPopupResult wrapping the // backend's OAuth2Auth descriptor; strip the envelope and // reconstruct the OAuth2Auth class instance for type safety. setOauth2Auth( new OAuth2Auth({ kind: "oauth2", securitySchemeName: result.securitySchemeName, flow: result.flow, tokenUrl: result.tokenUrl, clientIdSecretId: result.clientIdSecretId, clientSecretSecretId: result.clientSecretSecretId, accessTokenSecretId: result.accessTokenSecretId, refreshTokenSecretId: result.refreshTokenSecretId, tokenType: result.tokenType, expiresAt: result.expiresAt, scope: result.scope, scopes: [...result.scopes], }), ); setOauth2Error(null); } else { setOauth2Error(result.error); } }, onClosed: () => { // User closed the popup without completing the flow. oauthCleanup.current = null; setStartingOAuth(false); setOauth2Error("OAuth cancelled — popup was closed before completing the flow."); }, onOpenFailed: () => { oauthCleanup.current = null; setStartingOAuth(false); setOauth2Error("OAuth popup was blocked by the browser"); }, }); } catch (e) { setStartingOAuth(false); setOauth2Error(e instanceof Error ? e.message : "Failed to start OAuth"); } }, [ selectedOAuth2Preset, oauth2ClientIdSecretId, oauth2ClientSecretSecretId, oauth2SelectedScopes, preview, doStartOAuth, scopeId, identity.name, ]); const handleCancelOAuth2 = useCallback(() => { oauthCleanup.current?.(); oauthCleanup.current = null; setStartingOAuth(false); setOauth2Error(null); }, []); useEffect(() => () => oauthCleanup.current?.(), []); const handleAdd = async () => { setAdding(true); setAddError(null); const namespace = slugifyNamespace(identity.namespace) || (preview ? Option.getOrElse(preview.title, () => "openapi") : "openapi"); const displayName = identity.name.trim() || (preview ? Option.getOrElse(preview.title, () => namespace) : namespace); const placeholder = beginAdd({ id: namespace, name: displayName, kind: "openapi", url: resolvedBaseUrl || undefined, }); try { await doAdd({ path: { scopeId }, payload: { spec: specUrl, name: identity.name.trim() || undefined, namespace: slugifyNamespace(identity.namespace) || undefined, baseUrl: resolvedBaseUrl || undefined, ...(hasHeaders ? { headers: allHeaders } : {}), ...(oauth2Auth ? { oauth2: oauth2Auth } : {}), }, reactivityKeys: sourceWriteKeys, }); props.onComplete(); } catch (e) { setAddError(e instanceof Error ? e.message : "Failed to add source"); setAdding(false); } finally { placeholder.done(); } }; // ---- Render ---- return (

Add OpenAPI Source

{/* ── Spec input ── */}