import { useCallback, useEffect, useMemo, useRef, useState } from "react";import { useAtomSet } from "@effect/atom-react";import * as Exit from "effect/Exit";import * as Option from "effect/Option";import * as Schema from "effect/Schema"; import { ConnectionId, ScopeId, SecretId } from "@executor-js/sdk/core";import { startOAuth } from "@executor-js/react/api/atoms";import { useScope, useScopeStack } 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 { 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 { CredentialScopeDropdown } from "@executor-js/react/plugins/credential-target-scope";import { slugifyNamespace, 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, CardStackEntryField,} 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 { HelpTooltip } from "@executor-js/react/components/help-tooltip";import { Label } from "@executor-js/react/components/label";import { Textarea } from "@executor-js/react/components/textarea";import { Checkbox } from "@executor-js/react/components/checkbox";import { RadioGroup, RadioGroupItem } from "@executor-js/react/components/radio-group";import { IOSSpinner, Spinner } from "@executor-js/react/components/spinner";import { addOpenApiSpecOptimistic, previewOpenApiSpec, setOpenApiSourceBinding } from "./atoms";import { OpenApiSourceDetailsFields } from "./OpenApiSourceDetailsFields";import type { SpecPreview, HeaderPreset, OAuth2Preset } from "../sdk/preview";import { headerBindingSlot, oauth2ClientIdSlot, oauth2ClientSecretSlot, oauth2ConnectionSlot, queryParamBindingSlot,} from "../sdk/store";import { ConfiguredHeaderBinding, OAuth2SourceConfig, OpenApiSourceBindingInput, type ServerInfo,} from "../sdk/types";import { expandServerUrlOptions } from "../sdk/openapi-utils"; export const OPENAPI_OAUTH_POPUP_NAME = "openapi-oauth";export const OPENAPI_OAUTH_CALLBACK_PATH = "/api/oauth/callback"; const ErrorMessage = Schema.Struct({ message: Schema.String });const decodeErrorMessage = Schema.decodeUnknownOption(ErrorMessage); const errorMessageFromExit = (exit: Exit.Exit<unknown, unknown>, fallback: string): string => Option.match(Option.flatMap(Exit.findErrorOption(exit), decodeErrorMessage), { onNone: () => fallback, onSome: ({ message }) => message, }); 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 * OAuth2SourceConfig. */export function resolveOAuthUrl(url: string, baseUrl: string): string { if (!url) return url; // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: URL constructor normalizes provider metadata URLs try { new URL(url); return url; } catch { if (!baseUrl) return url; // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: URL constructor resolves relative provider metadata URLs try { return new URL(url, baseUrl).toString(); } catch { return url; } }} export function inferOAuthIssuerUrl(authorizationUrl: string): string | null { // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: URL constructor normalizes provider metadata URLs 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, }; });} const secretStorageDescription = (label: string): string => label === "Personal" ? "Only you can use this secret." : "Everyone in the organization can use this secret."; // ---------------------------------------------------------------------------// 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<string | null>(null); // After analysis const [preview, setPreview] = useState<SpecPreview | null>(null); const [baseUrl, setBaseUrl] = useState(""); const identity = useSourceIdentity({ fallbackName: preview ? Option.getOrElse(preview.title, () => "") : "", fallbackNamespace: props.initialNamespace, }); // Auth const [strategy, setStrategy] = useState<StrategySelection>({ kind: "none" }); const [customHeaders, setCustomHeaders] = useState<HeaderState[]>([]); const [specFetchCredentials, setSpecFetchCredentials] = useState<HttpCredentialsState>(() => emptyHttpCredentials(), ); const [specFetchCredentialsOpen, setSpecFetchCredentialsOpen] = useState(false); const [runtimeCredentials, setRuntimeCredentials] = useState<HttpCredentialsState>(() => emptyHttpCredentials(), ); // OAuth2 state (only populated while an oauth2 preset is selected) const [oauth2ClientIdSecretId, setOauth2ClientIdSecretId] = useState<string | null>(null); const [oauth2ClientSecretSecretId, setOauth2ClientSecretSecretId] = useState<string | null>(null); const [oauth2ClientIdScope, setOauth2ClientIdScope] = useState<ScopeId | null>(null); const [oauth2ClientSecretScope, setOauth2ClientSecretScope] = useState<ScopeId | null>(null); const [oauth2SelectedScopes, setOauth2SelectedScopes] = useState<Set<string>>(new Set()); const [oauth2AuthState, setOauth2AuthState] = useState<{ readonly fingerprint: string; readonly auth: { readonly connectionId: string }; } | null>(null); const [startingOAuth, setStartingOAuth] = useState(false); const [oauth2Error, setOauth2Error] = useState<string | null>(null); // Submit const [adding, setAdding] = useState(false); const [addError, setAddError] = useState<string | null>(null); const scopeId = useScope(); const scopeStack = useScopeStack(); const credentialScopeOptions = useMemo( () => scopeStack.map((entry, index) => ({ scopeId: entry.id, label: index === 0 ? "Personal" : entry.name || "Organization", description: secretStorageDescription( index === 0 ? "Personal" : entry.name || "Organization", ), })), [scopeStack], ); const defaultCredentialTargetScope = credentialScopeOptions[credentialScopeOptions.length - 1]?.scopeId ?? scopeId; const defaultOAuthTokenTargetScope = credentialScopeOptions[0]?.scopeId ?? scopeId; const [credentialTargetScope, setCredentialTargetScope] = useState<ScopeId>( defaultCredentialTargetScope, ); const [oauthTokenTargetScope, setOAuthTokenTargetScope] = useState<ScopeId>( defaultOAuthTokenTargetScope, ); const bindingScopeOptions = useMemo( () => credentialScopeOptions.map((option) => ({ ...option })), [credentialScopeOptions], ); useEffect(() => { if (!credentialScopeOptions.some((option) => option.scopeId === credentialTargetScope)) { setCredentialTargetScope(defaultCredentialTargetScope); } }, [credentialScopeOptions, credentialTargetScope, defaultCredentialTargetScope]); useEffect(() => { if (!credentialScopeOptions.some((option) => option.scopeId === oauthTokenTargetScope)) { setOAuthTokenTargetScope(defaultOAuthTokenTargetScope); } }, [credentialScopeOptions, defaultOAuthTokenTargetScope, oauthTokenTargetScope]); const initialCredentialScopeOption = credentialScopeOptions.find((option) => option.scopeId === credentialTargetScope) ?? credentialScopeOptions[0]; const initialCredentialScopeOptions = initialCredentialScopeOption ? [initialCredentialScopeOption] : []; const doPreview = useAtomSet(previewOpenApiSpec, { mode: "promiseExit" }); const doAdd = useAtomSet(addOpenApiSpecOptimistic(scopeId), { mode: "promiseExit", }); const doStartOAuth = useAtomSet(startOAuth, { mode: "promiseExit" }); const doSetBinding = useAtomSet(setOpenApiSourceBinding, { mode: "promiseExit", }); const secretList = useSecretPickerSecrets(); const initialCredentialSecrets = useMemo( () => secretList.filter((secret) => secret.scopeId === String(credentialTargetScope)), [credentialTargetScope, secretList], ); const oauth = useOAuthPopupFlow<OAuthCompletionPayload>({ 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 expandServerOptions = (server: ServerInfo) => { return expandServerUrlOptions(server).map((value) => ({ value, label: value, })); }; const servers: readonly ServerInfo[] = preview?.servers ?? []; const baseUrlOptions = Array.from( new Map(servers.flatMap(expandServerOptions).map((option) => [option.value, option])).values(), ); const resolvedBaseUrl = baseUrl.trim(); const configuredHeaders: Record<string, ConfiguredHeaderBinding> = {}; const headerBindings: Array<{ slot: string; secretId: string; scope: ScopeId; secretScope: ScopeId; }> = []; const configuredQueryParams: Record<string, string | ConfiguredHeaderBinding> = {}; const queryParamBindings: Array<{ slot: string; secretId: string; scope: ScopeId; secretScope: ScopeId; }> = []; 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, scope: ch.targetScope ?? credentialTargetScope, secretScope: ch.secretScope ?? ch.targetScope ?? credentialTargetScope, }); } } for (const param of runtimeCredentials.queryParams) { const name = param.name.trim(); if (!name) continue; if (param.secretId) { const slot = queryParamBindingSlot(name); configuredQueryParams[name] = new ConfiguredHeaderBinding({ kind: "binding", slot, prefix: param.prefix, }); queryParamBindings.push({ slot, secretId: param.secretId, scope: param.targetScope ?? credentialTargetScope, secretScope: param.secretScope ?? param.targetScope ?? credentialTargetScope, }); continue; } if (param.literalValue?.trim()) { configuredQueryParams[name] = param.literalValue.trim(); } } 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 canConnectOAuth2 = Boolean(oauth2ClientIdSecretId) && resolvedBaseUrl.length > 0; const hasIncompleteHeaderCredentials = strategy.kind !== "none" && strategy.kind !== "oauth2" && customHeaders.some((header) => header.name.trim() && !header.secretId); const hasIncompleteQueryCredentials = runtimeCredentials.queryParams.some( (param) => param.name.trim() && !param.secretId && !param.literalValue?.trim(), ); const willAddWithoutInitialCredentials = Boolean(selectedOAuth2Preset && !oauth2Auth) || hasIncompleteHeaderCredentials || hasIncompleteQueryCredentials; const canAdd = preview !== null && resolvedBaseUrl.length > 0; // ---- Handlers ---- const handleAnalyze = async () => { setAnalyzing(true); setAnalyzeError(null); setAddError(null); const credentials = serializeHttpCredentials(specFetchCredentials); const exit = await doPreview({ params: { scopeId }, payload: { spec: specUrl, specFetchCredentials: credentials, }, }); if (Exit.isFailure(exit)) { setAnalyzeError(errorMessageFromExit(exit, "Failed to parse spec")); setAnalyzing(false); return; } const result = exit.value; setPreview(result); const firstServer = result.servers[0]; setBaseUrl(firstServer ? (expandServerOptions(firstServer)[0]?.value ?? "") : ""); const firstPreset = result.headerPresets[0]; if (firstPreset) { setStrategy({ kind: "header", presetIndex: 0 }); setCustomHeaders(entriesFromSpecPreset(firstPreset)); } else if (result.oauth2Presets[0]) { setStrategy({ kind: "oauth2", presetIndex: 0 }); setCustomHeaders([]); setOauth2SelectedScopes(new Set(Object.keys(result.oauth2Presets[0].scopes))); } else { // No header presets — default to "custom" so the headers editor is // visible immediately. Specs with no `security` block (e.g. Microsoft // Graph) would otherwise leave the user staring at just the // Authentication heading with no way to add headers. setStrategy({ kind: "custom" }); setCustomHeaders([]); }Lines 1–500 of 1283. Load more
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useAtomSet } from "@effect/atom-react"; import * as Exit from "effect/Exit"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import { ConnectionId, ScopeId, SecretId } from "@executor-js/sdk/core"; import { startOAuth } from "@executor-js/react/api/atoms"; import { useScope, useScopeStack } 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 { 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 { CredentialScopeDropdown } from "@executor-js/react/plugins/credential-target-scope"; import { slugifyNamespace, 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, CardStackEntryField, } 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 { HelpTooltip } from "@executor-js/react/components/help-tooltip"; import { Label } from "@executor-js/react/components/label"; import { Textarea } from "@executor-js/react/components/textarea"; import { Checkbox } from "@executor-js/react/components/checkbox"; import { RadioGroup, RadioGroupItem } from "@executor-js/react/components/radio-group"; import { IOSSpinner, Spinner } from "@executor-js/react/components/spinner"; import { addOpenApiSpecOptimistic, previewOpenApiSpec, setOpenApiSourceBinding } from "./atoms"; import { OpenApiSourceDetailsFields } from "./OpenApiSourceDetailsFields"; import type { SpecPreview, HeaderPreset, OAuth2Preset } from "../sdk/preview"; import { headerBindingSlot, oauth2ClientIdSlot, oauth2ClientSecretSlot, oauth2ConnectionSlot, queryParamBindingSlot, } from "../sdk/store"; import { ConfiguredHeaderBinding, OAuth2SourceConfig, OpenApiSourceBindingInput, type ServerInfo, } from "../sdk/types"; import { expandServerUrlOptions } from "../sdk/openapi-utils"; export const OPENAPI_OAUTH_POPUP_NAME = "openapi-oauth"; export const OPENAPI_OAUTH_CALLBACK_PATH = "/api/oauth/callback"; const ErrorMessage = Schema.Struct({ message: Schema.String }); const decodeErrorMessage = Schema.decodeUnknownOption(ErrorMessage); const errorMessageFromExit = (exit: Exit.Exit<unknown, unknown>, fallback: string): string => Option.match(Option.flatMap(Exit.findErrorOption(exit), decodeErrorMessage), { onNone: () => fallback, onSome: ({ message }) => message, }); 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 * OAuth2SourceConfig. */ export function resolveOAuthUrl(url: string, baseUrl: string): string { if (!url) return url; // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: URL constructor normalizes provider metadata URLs try { new URL(url); return url; } catch { if (!baseUrl) return url; // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: URL constructor resolves relative provider metadata URLs try { return new URL(url, baseUrl).toString(); } catch { return url; } } } export function inferOAuthIssuerUrl(authorizationUrl: string): string | null { // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: URL constructor normalizes provider metadata URLs 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, }; }); } const secretStorageDescription = (label: string): string => label === "Personal" ? "Only you can use this secret." : "Everyone in the organization can use this secret."; // --------------------------------------------------------------------------- // 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<string | null>(null); // After analysis const [preview, setPreview] = useState<SpecPreview | null>(null); const [baseUrl, setBaseUrl] = useState(""); const identity = useSourceIdentity({ fallbackName: preview ? Option.getOrElse(preview.title, () => "") : "", fallbackNamespace: props.initialNamespace, }); // Auth const [strategy, setStrategy] = useState<StrategySelection>({ kind: "none" }); const [customHeaders, setCustomHeaders] = useState<HeaderState[]>([]); const [specFetchCredentials, setSpecFetchCredentials] = useState<HttpCredentialsState>(() => emptyHttpCredentials(), ); const [specFetchCredentialsOpen, setSpecFetchCredentialsOpen] = useState(false); const [runtimeCredentials, setRuntimeCredentials] = useState<HttpCredentialsState>(() => emptyHttpCredentials(), ); // OAuth2 state (only populated while an oauth2 preset is selected) const [oauth2ClientIdSecretId, setOauth2ClientIdSecretId] = useState<string | null>(null); const [oauth2ClientSecretSecretId, setOauth2ClientSecretSecretId] = useState<string | null>(null); const [oauth2ClientIdScope, setOauth2ClientIdScope] = useState<ScopeId | null>(null); const [oauth2ClientSecretScope, setOauth2ClientSecretScope] = useState<ScopeId | null>(null); const [oauth2SelectedScopes, setOauth2SelectedScopes] = useState<Set<string>>(new Set()); const [oauth2AuthState, setOauth2AuthState] = useState<{ readonly fingerprint: string; readonly auth: { readonly connectionId: string }; } | null>(null); const [startingOAuth, setStartingOAuth] = useState(false); const [oauth2Error, setOauth2Error] = useState<string | null>(null); // Submit const [adding, setAdding] = useState(false); const [addError, setAddError] = useState<string | null>(null); const scopeId = useScope(); const scopeStack = useScopeStack(); const credentialScopeOptions = useMemo( () => scopeStack.map((entry, index) => ({ scopeId: entry.id, label: index === 0 ? "Personal" : entry.name || "Organization", description: secretStorageDescription( index === 0 ? "Personal" : entry.name || "Organization", ), })), [scopeStack], ); const defaultCredentialTargetScope = credentialScopeOptions[credentialScopeOptions.length - 1]?.scopeId ?? scopeId; const defaultOAuthTokenTargetScope = credentialScopeOptions[0]?.scopeId ?? scopeId; const [credentialTargetScope, setCredentialTargetScope] = useState<ScopeId>( defaultCredentialTargetScope, ); const [oauthTokenTargetScope, setOAuthTokenTargetScope] = useState<ScopeId>( defaultOAuthTokenTargetScope, ); const bindingScopeOptions = useMemo( () => credentialScopeOptions.map((option) => ({ ...option })), [credentialScopeOptions], ); useEffect(() => { if (!credentialScopeOptions.some((option) => option.scopeId === credentialTargetScope)) { setCredentialTargetScope(defaultCredentialTargetScope); } }, [credentialScopeOptions, credentialTargetScope, defaultCredentialTargetScope]); useEffect(() => { if (!credentialScopeOptions.some((option) => option.scopeId === oauthTokenTargetScope)) { setOAuthTokenTargetScope(defaultOAuthTokenTargetScope); } }, [credentialScopeOptions, defaultOAuthTokenTargetScope, oauthTokenTargetScope]); const initialCredentialScopeOption = credentialScopeOptions.find((option) => option.scopeId === credentialTargetScope) ?? credentialScopeOptions[0]; const initialCredentialScopeOptions = initialCredentialScopeOption ? [initialCredentialScopeOption] : []; const doPreview = useAtomSet(previewOpenApiSpec, { mode: "promiseExit" }); const doAdd = useAtomSet(addOpenApiSpecOptimistic(scopeId), { mode: "promiseExit", }); const doStartOAuth = useAtomSet(startOAuth, { mode: "promiseExit" }); const doSetBinding = useAtomSet(setOpenApiSourceBinding, { mode: "promiseExit", }); const secretList = useSecretPickerSecrets(); const initialCredentialSecrets = useMemo( () => secretList.filter((secret) => secret.scopeId === String(credentialTargetScope)), [credentialTargetScope, secretList], ); const oauth = useOAuthPopupFlow<OAuthCompletionPayload>({ 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 expandServerOptions = (server: ServerInfo) => { return expandServerUrlOptions(server).map((value) => ({ value, label: value, })); }; const servers: readonly ServerInfo[] = preview?.servers ?? []; const baseUrlOptions = Array.from( new Map(servers.flatMap(expandServerOptions).map((option) => [option.value, option])).values(), ); const resolvedBaseUrl = baseUrl.trim(); const configuredHeaders: Record<string, ConfiguredHeaderBinding> = {}; const headerBindings: Array<{ slot: string; secretId: string; scope: ScopeId; secretScope: ScopeId; }> = []; const configuredQueryParams: Record<string, string | ConfiguredHeaderBinding> = {}; const queryParamBindings: Array<{ slot: string; secretId: string; scope: ScopeId; secretScope: ScopeId; }> = []; 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, scope: ch.targetScope ?? credentialTargetScope, secretScope: ch.secretScope ?? ch.targetScope ?? credentialTargetScope, }); } } for (const param of runtimeCredentials.queryParams) { const name = param.name.trim(); if (!name) continue; if (param.secretId) { const slot = queryParamBindingSlot(name); configuredQueryParams[name] = new ConfiguredHeaderBinding({ kind: "binding", slot, prefix: param.prefix, }); queryParamBindings.push({ slot, secretId: param.secretId, scope: param.targetScope ?? credentialTargetScope, secretScope: param.secretScope ?? param.targetScope ?? credentialTargetScope, }); continue; } if (param.literalValue?.trim()) { configuredQueryParams[name] = param.literalValue.trim(); } } 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 canConnectOAuth2 = Boolean(oauth2ClientIdSecretId) && resolvedBaseUrl.length > 0; const hasIncompleteHeaderCredentials = strategy.kind !== "none" && strategy.kind !== "oauth2" && customHeaders.some((header) => header.name.trim() && !header.secretId); const hasIncompleteQueryCredentials = runtimeCredentials.queryParams.some( (param) => param.name.trim() && !param.secretId && !param.literalValue?.trim(), ); const willAddWithoutInitialCredentials = Boolean(selectedOAuth2Preset && !oauth2Auth) || hasIncompleteHeaderCredentials || hasIncompleteQueryCredentials; const canAdd = preview !== null && resolvedBaseUrl.length > 0; // ---- Handlers ---- const handleAnalyze = async () => { setAnalyzing(true); setAnalyzeError(null); setAddError(null); const credentials = serializeHttpCredentials(specFetchCredentials); const exit = await doPreview({ params: { scopeId }, payload: { spec: specUrl, specFetchCredentials: credentials, }, }); if (Exit.isFailure(exit)) { setAnalyzeError(errorMessageFromExit(exit, "Failed to parse spec")); setAnalyzing(false); return; } const result = exit.value; setPreview(result); const firstServer = result.servers[0]; setBaseUrl(firstServer ? (expandServerOptions(firstServer)[0]?.value ?? "") : ""); const firstPreset = result.headerPresets[0]; if (firstPreset) { setStrategy({ kind: "header", presetIndex: 0 }); setCustomHeaders(entriesFromSpecPreset(firstPreset)); } else if (result.oauth2Presets[0]) { setStrategy({ kind: "oauth2", presetIndex: 0 }); setCustomHeaders([]); setOauth2SelectedScopes(new Set(Object.keys(result.oauth2Presets[0].scopes))); } else { // No header presets — default to "custom" so the headers editor is // visible immediately. Specs with no `security` block (e.g. Microsoft // Graph) would otherwise leave the user staring at just the // Authentication heading with no way to add headers. setStrategy({ kind: "custom" }); setCustomHeaders([]); }