import { useCallback, useEffect, useRef, useState } from "react"; import { useAtomSet } from "@effect/atom-react"; import { Option } from "effect"; import { ConnectionId, ScopeId, SecretId } from "@executor-js/sdk/core"; import { startOAuth } from "@executor-js/react/api/atoms"; import { useScope, useUserScope } from "@executor-js/react/api/scope-context"; import { connectionWriteKeys, sourceWriteKeys } from "@executor-js/react/api/reactivity-keys"; // `addSpec` with an oauth2 payload persists a source row AND (for // clientCredentials) a freshly-minted Connection + owned secrets, // because the inline token exchange happens during `startOAuth`. // Invalidate both so the source-detail page opens into its connected // state without a refresh. const addSpecWriteKeys = [...sourceWriteKeys, ...connectionWriteKeys] as const; const bindingWriteKeys = [...sourceWriteKeys, ...connectionWriteKeys] as const; import { usePendingSources } from "@executor-js/react/api/optimistic"; import { HeadersList } from "@executor-js/react/plugins/headers-list"; import { HttpCredentialsEditor, emptyHttpCredentials, serializeHttpCredentials, type HttpCredentialsState, } from "@executor-js/react/plugins/http-credentials"; import { oauthCallbackUrl, useOAuthPopupFlow, type OAuthCompletionPayload, } from "@executor-js/react/plugins/oauth-sign-in"; import { CreatableSecretPicker, matchPresetKey, type HeaderState, } from "@executor-js/react/plugins/secret-header-auth"; import { slugifyNamespace, SourceIdentityFields, useSourceIdentity, } from "@executor-js/react/plugins/source-identity"; import { useSecretPickerSecrets } from "@executor-js/react/plugins/use-secret-picker-secrets"; import { Button } from "@executor-js/react/components/button"; import { CopyButton } from "@executor-js/react/components/copy-button"; import { Collapsible, CollapsibleContent, CollapsibleTrigger, } from "@executor-js/react/components/collapsible"; import { CardStack, CardStackContent, CardStackEntry, CardStackEntryContent, CardStackEntryDescription, CardStackEntryField, CardStackEntryTitle, } from "@executor-js/react/components/card-stack"; import { FieldLabel } from "@executor-js/react/components/field"; import { FloatActions } from "@executor-js/react/components/float-actions"; import { Input } from "@executor-js/react/components/input"; import { Label } from "@executor-js/react/components/label"; import { NativeSelect, NativeSelectOption } from "@executor-js/react/components/native-select"; import { Textarea } from "@executor-js/react/components/textarea"; import { Checkbox } from "@executor-js/react/components/checkbox"; import { SourceFavicon } from "@executor-js/react/components/source-favicon"; import { RadioGroup, RadioGroupItem } from "@executor-js/react/components/radio-group"; import { Skeleton } from "@executor-js/react/components/skeleton"; import { IOSSpinner, Spinner } from "@executor-js/react/components/spinner"; import { addOpenApiSpec, previewOpenApiSpec, setOpenApiSourceBinding } from "./atoms"; import type { SpecPreview, HeaderPreset, OAuth2Preset } from "../sdk/preview"; import { headerBindingSlot, oauth2ClientIdSlot, oauth2ClientSecretSlot, oauth2ConnectionSlot, } from "../sdk/store"; import { ConfiguredHeaderBinding, OAuth2Auth, OAuth2SourceConfig, type ServerInfo, type ServerVariable, } from "../sdk/types"; export const OPENAPI_OAUTH_POPUP_NAME = "openapi-oauth"; export const OPENAPI_OAUTH_CALLBACK_PATH = "/api/oauth/callback"; 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; }; export const openApiOAuthConnectionId = ( namespaceSlug: string, flow: OAuth2Preset["flow"], ): string => flow === "clientCredentials" ? `openapi-oauth2-app-${namespaceSlug || "default"}` : `openapi-oauth2-user-${namespaceSlug || "default"}`; /** * OpenAPI 3.x requires OAuth2 tokenUrl/authorizationUrl to be absolute, * but some specs ship relative paths like `/api/rest/v1/oauth/token`. * Resolve them against the source's chosen baseUrl so the backend can * fetch them directly and the absolute URL is what gets persisted on * OAuth2Auth. */ export function resolveOAuthUrl(url: string, baseUrl: string): string { if (!url) return url; try { new URL(url); return url; } catch { if (!baseUrl) return url; try { return new URL(url, baseUrl).toString(); } catch { return url; } } } export function inferOAuthIssuerUrl(authorizationUrl: string): string | null { try { return new URL(authorizationUrl).origin; } catch { return null; } } 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([]); const [specFetchCredentials, setSpecFetchCredentials] = useState(() => emptyHttpCredentials(), ); const [specFetchCredentialsOpen, setSpecFetchCredentialsOpen] = useState(false); const [runtimeCredentials, setRuntimeCredentials] = useState(() => emptyHttpCredentials(), ); // 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 [oauth2AuthState, setOauth2AuthState] = useState<{ readonly fingerprint: string; readonly auth: OAuth2Auth; } | null>(null); const [startingOAuth, setStartingOAuth] = useState(false); const [oauth2Error, setOauth2Error] = useState(null); // Submit const [adding, setAdding] = useState(false); const [addError, setAddError] = useState(null); const scopeId = useScope(); const userScope = useUserScope(); const doPreview = useAtomSet(previewOpenApiSpec, { mode: "promise" }); const doAdd = useAtomSet(addOpenApiSpec, { mode: "promise" }); const doStartOAuth = useAtomSet(startOAuth, { mode: "promise" }); const doSetBinding = useAtomSet(setOpenApiSourceBinding, { mode: "promise" }); const { beginAdd } = usePendingSources(); const secretList = useSecretPickerSecrets(); const oauth = useOAuthPopupFlow({ popupName: OPENAPI_OAUTH_POPUP_NAME, popupBlockedMessage: "OAuth popup was blocked by the browser", popupClosedMessage: "OAuth cancelled - popup was closed before completing the flow.", startErrorMessage: "Failed to start OAuth", }); // 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 configuredHeaders: Record = {}; const headerBindings: Array<{ slot: string; secretId: string }> = []; for (const ch of customHeaders) { if (!ch.name.trim()) continue; const slot = headerBindingSlot(ch.name.trim()); configuredHeaders[ch.name.trim()] = new ConfiguredHeaderBinding({ kind: "binding", slot, prefix: ch.prefix, }); if (ch.secretId) { headerBindings.push({ slot, secretId: ch.secretId }); } } const oauth2Presets: readonly OAuth2Preset[] = preview?.oauth2Presets ?? []; const oauth2RedirectUrl = oauthCallbackUrl(OPENAPI_OAUTH_CALLBACK_PATH); // Stable source id derivation. Matches the value `handleAdd` sends as // `namespace`, and is also the default credential key when the user // does not provide a more explicit shared connection id. const resolvedSourceId = slugifyNamespace(identity.namespace) || (preview ? Option.getOrElse(preview.title, () => "openapi") : "openapi"); const selectedOAuth2Preset: OAuth2Preset | null = strategy.kind === "oauth2" ? (oauth2Presets[strategy.presetIndex] ?? null) : null; const selectedOAuth2Fingerprint = selectedOAuth2Preset ? [ resolvedSourceId, resolvedBaseUrl, selectedOAuth2Preset.securitySchemeName, selectedOAuth2Preset.flow, selectedOAuth2Preset.tokenUrl, Option.getOrElse(selectedOAuth2Preset.authorizationUrl, () => ""), ].join("\n") : ""; const oauth2Auth = oauth2AuthState?.fingerprint === selectedOAuth2Fingerprint ? oauth2AuthState.auth : null; const configuredOAuth2 = strategy.kind === "oauth2" && selectedOAuth2Preset ? new OAuth2SourceConfig({ kind: "oauth2", securitySchemeName: selectedOAuth2Preset.securitySchemeName, flow: selectedOAuth2Preset.flow, tokenUrl: resolveOAuthUrl(selectedOAuth2Preset.tokenUrl, resolvedBaseUrl), authorizationUrl: selectedOAuth2Preset.flow === "authorizationCode" ? resolveOAuthUrl( Option.getOrElse(selectedOAuth2Preset.authorizationUrl, () => ""), resolvedBaseUrl, ) || null : null, clientIdSlot: oauth2ClientIdSlot(selectedOAuth2Preset.securitySchemeName), // Authorization-code specs can still be confidential clients // (Spotify is one example). Persist the slot even when the value is // deferred so the edit screen can collect the secret later. clientSecretSlot: oauth2ClientSecretSlot(selectedOAuth2Preset.securitySchemeName), connectionSlot: oauth2ConnectionSlot(selectedOAuth2Preset.securitySchemeName), scopes: [...oauth2SelectedScopes], }) : null; const hasHeaders = Object.keys(configuredHeaders).length > 0; const oauth2Busy = startingOAuth || oauth.busy; const canAdd = preview !== null && resolvedBaseUrl.length > 0; // ---- Handlers ---- const handleAnalyze = async () => { setAnalyzing(true); setAnalyzeError(null); setAddError(null); try { const credentials = serializeHttpCredentials(specFetchCredentials); const result = await doPreview({ params: { scopeId }, payload: { spec: specUrl, specFetchCredentials: credentials, }, }); 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") { setOauth2AuthState(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. setOauth2AuthState(null); }; const handleConnectOAuth2 = useCallback(async () => { if (!selectedOAuth2Preset || !oauth2ClientIdSecretId || !preview) return; oauth.cancel(); setOauth2Error(null); try { const displayName = identity.name.trim() || selectedOAuth2Preset.securitySchemeName; const tokenUrl = resolveOAuthUrl(selectedOAuth2Preset.tokenUrl, resolvedBaseUrl); if (selectedOAuth2Preset.flow === "clientCredentials") { // RFC 6749 §4.4: no user-interactive consent step. The client_secret // is mandatory; the backend exchanges tokens inline and returns a // completed OAuth2Auth we can attach to the source directly. if (!oauth2ClientSecretSecretId) { setOauth2Error("client_credentials requires a client secret"); return; } setStartingOAuth(true); const connectionId = openApiOAuthConnectionId(resolvedSourceId, selectedOAuth2Preset.flow); const response = await doStartOAuth({ params: { scopeId }, payload: { endpoint: tokenUrl, redirectUrl: tokenUrl, connectionId, tokenScope: scopeId as string, strategy: { kind: "client-credentials", tokenEndpoint: tokenUrl, clientIdSecretId: oauth2ClientIdSecretId, clientSecretSecretId: oauth2ClientSecretSecretId, scopes: [...oauth2SelectedScopes], }, pluginId: "openapi", identityLabel: `${displayName} OAuth`, }, }); setStartingOAuth(false); if (!response.completedConnection) { setOauth2Error("client_credentials flow did not mint a connection"); return; } setOauth2AuthState({ fingerprint: selectedOAuth2Fingerprint, auth: new OAuth2Auth({ kind: "oauth2", connectionId: response.completedConnection.connectionId, securitySchemeName: selectedOAuth2Preset.securitySchemeName, flow: "clientCredentials", tokenUrl, authorizationUrl: null, clientIdSecretId: oauth2ClientIdSecretId, clientSecretSecretId: oauth2ClientSecretSecretId, scopes: [...oauth2SelectedScopes], }), }); setOauth2Error(null); return; } const authorizationUrl = resolveOAuthUrl( Option.getOrElse(selectedOAuth2Preset.authorizationUrl, () => ""), resolvedBaseUrl, ); const issuerUrl = inferOAuthIssuerUrl(authorizationUrl); await oauth.openAuthorization({ run: async () => { const response = await doStartOAuth({ params: { scopeId }, payload: { endpoint: authorizationUrl, connectionId: openApiOAuthConnectionId(resolvedSourceId, selectedOAuth2Preset.flow), tokenScope: scopeId as string, redirectUrl: oauth2RedirectUrl, strategy: { kind: "authorization-code", authorizationEndpoint: authorizationUrl, tokenEndpoint: tokenUrl, issuerUrl, clientIdSecretId: oauth2ClientIdSecretId, clientSecretSecretId: oauth2ClientSecretSecretId ?? null, scopes: [...oauth2SelectedScopes], }, pluginId: "openapi", identityLabel: `${displayName} OAuth`, }, }); if (response.authorizationUrl === null) { throw new Error("Unexpected response flow from server"); } return { sessionId: response.sessionId, authorizationUrl: response.authorizationUrl, }; }, onSuccess: (result) => { setOauth2AuthState({ fingerprint: selectedOAuth2Fingerprint, auth: new OAuth2Auth({ kind: "oauth2", connectionId: result.connectionId, securitySchemeName: selectedOAuth2Preset.securitySchemeName, flow: "authorizationCode", tokenUrl, authorizationUrl, issuerUrl, clientIdSecretId: oauth2ClientIdSecretId, clientSecretSecretId: oauth2ClientSecretSecretId, scopes: [...oauth2SelectedScopes], }), }); setOauth2Error(null); }, onError: setOauth2Error, }); } catch (e) { setStartingOAuth(false); setOauth2Error(e instanceof Error ? e.message : "Failed to start OAuth"); } }, [ selectedOAuth2Preset, oauth2ClientIdSecretId, oauth2ClientSecretSecretId, oauth2SelectedScopes, oauth2RedirectUrl, resolvedBaseUrl, preview, doStartOAuth, scopeId, identity.name, resolvedSourceId, selectedOAuth2Fingerprint, oauth, ]); const handleCancelOAuth2 = useCallback(() => { oauth.cancel(); setStartingOAuth(false); setOauth2Error(null); }, [oauth]); const handleAdd = async () => { setAdding(true); setAddError(null); const namespace = resolvedSourceId; 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 { const result = await doAdd({ params: { scopeId }, payload: { spec: specUrl, specFetchCredentials: serializeHttpCredentials(specFetchCredentials), name: identity.name.trim() || undefined, namespace: slugifyNamespace(identity.namespace) || undefined, baseUrl: resolvedBaseUrl || undefined, ...(hasHeaders ? { headers: configuredHeaders } : {}), ...(Object.keys(serializeHttpCredentials(runtimeCredentials).queryParams).length > 0 ? { queryParams: serializeHttpCredentials(runtimeCredentials).queryParams } : {}), ...(configuredOAuth2 ? { oauth2: configuredOAuth2 } : {}), }, reactivityKeys: addSpecWriteKeys, }); const sourceId = result.namespace; const sourceScope = ScopeId.make(scopeId); const bindingScope = ScopeId.make(userScope); for (const binding of headerBindings) { await doSetBinding({ params: { scopeId }, payload: { sourceId, sourceScope, scope: bindingScope, slot: binding.slot, value: { kind: "secret", secretId: SecretId.make(binding.secretId), }, }, reactivityKeys: bindingWriteKeys, }); } if (configuredOAuth2 && oauth2ClientIdSecretId) { await doSetBinding({ params: { scopeId }, payload: { sourceId, sourceScope, scope: bindingScope, slot: configuredOAuth2.clientIdSlot, value: { kind: "secret", secretId: SecretId.make(oauth2ClientIdSecretId), }, }, reactivityKeys: bindingWriteKeys, }); } if (configuredOAuth2?.clientSecretSlot && oauth2ClientSecretSecretId) { await doSetBinding({ params: { scopeId }, payload: { sourceId, sourceScope, scope: bindingScope, slot: configuredOAuth2.clientSecretSlot, value: { kind: "secret", secretId: SecretId.make(oauth2ClientSecretSecretId), }, }, reactivityKeys: bindingWriteKeys, }); } if (configuredOAuth2 && oauth2Auth) { await doSetBinding({ params: { scopeId }, payload: { sourceId, sourceScope, scope: bindingScope, slot: configuredOAuth2.connectionSlot, value: { kind: "connection", connectionId: ConnectionId.make(oauth2Auth.connectionId), }, }, reactivityKeys: bindingWriteKeys, }); } 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 ── */}