1283 lines · 49.9 KB · TypeScript
RawDownload
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.