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 { 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 { CreatableSecretPicker, 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 { CopyButton } from "@executor/react/components/copy-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"; export const OPENAPI_OAUTH_CHANNEL = "executor:openapi-oauth-result"; export const OPENAPI_OAUTH_POPUP_NAME = "openapi-oauth"; export const OPENAPI_OAUTH_CALLBACK_PATH = "/api/openapi/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; }; /** * Stable placeholder connection id used when saving a source without * completing the OAuth flow. OpenApiSignInButton rewrites `oauth2.connectionId` * to a freshly minted id on first sign-in, so this only needs to be * deterministic per-namespace and distinct from any real connection id * (real ids are `openapi-oauth2-${uuid}`). */ const openApiOAuthConnectionId = (namespaceSlug: string): string => `openapi-oauth2-pending-${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; } } } 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 oauth2RedirectUrl = typeof window !== "undefined" ? `${window.location.origin}${OPENAPI_OAUTH_CALLBACK_PATH}` : OPENAPI_OAUTH_CALLBACK_PATH; const selectedOAuth2Preset: OAuth2Preset | null = strategy.kind === "oauth2" ? (oauth2Presets[strategy.presetIndex] ?? null) : null; // OAuth is "ready to save" without completing the flow as long as we have // enough metadata to run sign-in later: a preset, a client id secret, and — // for clientCredentials — a client secret. Each user signs in via // OpenApiSignInButton on the source detail page; innermost-wins scope // shadowing resolves tokens per-user at invoke time. const oauth2DeferrableReady = selectedOAuth2Preset !== null && oauth2ClientIdSecretId !== null && (selectedOAuth2Preset.flow !== "clientCredentials" || oauth2ClientSecretSecretId !== null); const oauth2Ready = strategy.kind !== "oauth2" || oauth2Auth !== null || oauth2DeferrableReady; 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 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) { setStartingOAuth(false); setOauth2Error("client_credentials requires a client secret"); return; } const response = await doStartOAuth({ path: { scopeId }, payload: { displayName, securitySchemeName: selectedOAuth2Preset.securitySchemeName, flow: "clientCredentials", tokenUrl, clientIdSecretId: oauth2ClientIdSecretId, clientSecretSecretId: oauth2ClientSecretSecretId, scopes: [...oauth2SelectedScopes], }, }); setStartingOAuth(false); if (response.flow !== "clientCredentials") { setOauth2Error("Unexpected response flow from server"); return; } setOauth2Auth(response.auth); setOauth2Error(null); return; } const authorizationUrl = resolveOAuthUrl( Option.getOrElse(selectedOAuth2Preset.authorizationUrl, () => ""), resolvedBaseUrl, ); const response = await doStartOAuth({ path: { scopeId }, payload: { displayName, securitySchemeName: selectedOAuth2Preset.securitySchemeName, flow: "authorizationCode", authorizationUrl, tokenUrl, redirectUrl: oauth2RedirectUrl, clientIdSecretId: oauth2ClientIdSecretId, clientSecretSecretId: oauth2ClientSecretSecretId, scopes: [...oauth2SelectedScopes], }, }); if (response.flow !== "authorizationCode") { setStartingOAuth(false); setOauth2Error("Unexpected response flow from server"); return; } 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) { setOauth2Auth( new OAuth2Auth({ kind: "oauth2", connectionId: result.connectionId, securitySchemeName: result.securitySchemeName, flow: result.flow, tokenUrl: result.tokenUrl, authorizationUrl: result.authorizationUrl, clientIdSecretId: result.clientIdSecretId, clientSecretSecretId: result.clientSecretSecretId, 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, oauth2RedirectUrl, resolvedBaseUrl, 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, }); // If the user picked oauth2 but didn't complete sign-in, persist a // deferred OAuth2Auth with a stable placeholder connectionId so the // source lands with its OAuth config intact. OpenApiSignInButton // rewrites the pointer once a user actually signs in. let oauth2ToSave: OAuth2Auth | null = oauth2Auth; if ( !oauth2ToSave && strategy.kind === "oauth2" && selectedOAuth2Preset && oauth2ClientIdSecretId ) { const tokenUrl = resolveOAuthUrl( selectedOAuth2Preset.tokenUrl, resolvedBaseUrl, ); const authorizationUrl = selectedOAuth2Preset.flow === "authorizationCode" ? resolveOAuthUrl( Option.getOrElse(selectedOAuth2Preset.authorizationUrl, () => ""), resolvedBaseUrl, ) || null : null; oauth2ToSave = new OAuth2Auth({ kind: "oauth2", connectionId: openApiOAuthConnectionId(namespace), securitySchemeName: selectedOAuth2Preset.securitySchemeName, flow: selectedOAuth2Preset.flow, tokenUrl, authorizationUrl, clientIdSecretId: oauth2ClientIdSecretId, clientSecretSecretId: oauth2ClientSecretSecretId, scopes: [...oauth2SelectedScopes], }); } try { await doAdd({ path: { scopeId }, payload: { spec: specUrl, name: identity.name.trim() || undefined, namespace: slugifyNamespace(identity.namespace) || undefined, baseUrl: resolvedBaseUrl || undefined, ...(hasHeaders ? { headers: allHeaders } : {}), ...(oauth2ToSave ? { oauth2: oauth2ToSave } : {}), }, 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 ── */}