1064 lines · 40.8 KB · TypeScript
RawDownload
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, string>): 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<string | null>(null);   // After analysis  const [preview, setPreview] = useState<SpecPreview | null>(null);  // -1 means the user is entering a fully custom base URL (no server selected).  const [selectedServerIndex, setSelectedServerIndex] = useState<number>(-1);  const [customBaseUrl, setCustomBaseUrl] = useState("");  // Variable selections for the currently selected server, keyed by variable name.  const [variableSelections, setVariableSelections] = useState<Record<string, string>>({});  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[]>([]);   // 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 [oauth2SelectedScopes, setOauth2SelectedScopes] = useState<Set<string>>(new Set());  const [oauth2Auth, setOauth2Auth] = useState<OAuth2Auth | null>(null);  const [startingOAuth, setStartingOAuth] = useState(false);  const [oauth2Error, setOauth2Error] = useState<string | null>(null);  const oauthCleanup = useRef<(() => void) | null>(null);   // Submit  const [adding, setAdding] = useState(false);  const [addError, setAddError] = useState<string | null>(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<string, ServerVariable> = selectedServer    ? Option.getOrElse(        selectedServer.variables,        () => ({}) as Record<string, ServerVariable>,      )    : {};  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<string, string> => {    const vars: Record<string, ServerVariable> = Option.getOrElse(      server.variables,      () => ({}) as Record<string, ServerVariable>,    );    const out: Record<string, string> = {};    for (const [name, v] of Object.entries(vars)) out[name] = v.default;    return out;  };   const allHeaders: Record<string, HeaderValue> = {};  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<OAuth2Auth>({        url: response.authorizationUrl,        popupName: OPENAPI_OAUTH_POPUP_NAME,        channelName: OPENAPI_OAUTH_CHANNEL,        onResult: (result: OAuthPopupResult<OAuth2Auth>) => {          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;

Lines 1–500 of 1064.