2549 lines · 102.4 KB · TypeScript
RawDownload
import { useCallback, useEffect, useMemo, useRef, useState } from "react";import { useAtomSet, useAtomValue } from "@effect/atom-react";import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";import * as Effect from "effect/Effect";import * as Exit from "effect/Exit";import * as Match from "effect/Match";import * as Option from "effect/Option";import * as Schema from "effect/Schema";import { ChevronDownIcon } from "lucide-react"; import {  ConnectionId,  ScopeId,  SecretId,  SetSourceCredentialBindingInput,} from "@executor-js/sdk/shared";import {  connectionIdentityAtom,  connectionsAtom,  setSourceCredentialBinding,  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 { Info, InfoDescription, InfoTitle } from "@executor-js/react/components/info";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 } from "./atoms";import { OpenApiSourceDetailsFields } from "./OpenApiSourceDetailsFields";import {  googleOpenApiPresets,  googleStandardUserOAuthPresets,  openApiPresets,} from "../sdk/presets";import { GOOGLE_BUNDLE_PRESET_ID } from "../sdk/google-presets";import type { SpecPreview, HeaderPreset, OAuth2Preset } from "../sdk/preview";import {  headerBindingSlot,  oauth2ClientIdSlot,  oauth2ClientSecretSlot,  oauth2ConnectionSlot,  queryParamBindingSlot,  specFetchHeaderBindingSlot,  specFetchQueryParamBindingSlot,} from "../sdk/source-contracts";import { OAuth2SourceConfig, type ServerInfo } from "../sdk/types";import { expandServerUrlOptions } from "../sdk/openapi-utils";import {  compactGoogleOAuthScopes,  filterGoogleUserConsentOAuthScopes,} from "../sdk/google-oauth-scopes";import { googleOAuthConsentBatches } from "../sdk/google-oauth-batches"; export const OPENAPI_OAUTH_POPUP_NAME = "openapi-oauth";export const OPENAPI_OAUTH_CALLBACK_PATH = "/api/oauth/callback";const GOOGLE_BUNDLE_BASE_URL = "https://www.googleapis.com/";const GOOGLE_ICON = "https://fonts.gstatic.com/s/i/productlogos/googleg/v6/192px.svg";const GOOGLE_BUNDLE_DEFAULT_PRESET = googleOpenApiPresets[0]!;const GOOGLE_STANDARD_SERVICE_IDS = googleStandardUserOAuthPresets.map((preset) => preset.id);const GOOGLE_BROAD_SELECTION_WARNING_THRESHOLD = 6; 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,  }); const googleOAuthErrorMessage =  "Google did not approve this permission request. Try selecting fewer services, or add sensitive services as separate Google sources."; const oauthErrorMessage = (providerLabel: string, message: string, details?: string): string => {  if (providerLabel !== "Google") return details ? `${message}: ${details}` : message;  const combined = `${message}\n${details ?? ""}`;  if (    /Authorization server returned error|Token exchange failed|invalid_grant|access_denied|Something went wrong|unknownerror/i.test(      combined,    )  ) {    return googleOAuthErrorMessage;  }  return details ? `${message}: ${details}` : 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;  }} const standardOidcIdentityScopes = ["openid", "email", "profile"] as const; const identityScopesForPreset = (  identityScopes: OAuth2Preset["identityScopes"],): readonly string[] => {  if (identityScopes === false) return [];  return identityScopes === "auto" ? standardOidcIdentityScopes : identityScopes;}; const resolvedOAuthScopes = (  apiScopes: Iterable<string>,  identityScopes: OAuth2Preset["identityScopes"],): string[] => {  const merged = new Set(apiScopes);  for (const scope of identityScopesForPreset(identityScopes)) merged.add(scope);  return [...merged];}; const splitOAuthScopes = (value: string | null): Set<string> =>  new Set(value?.split(/\s+/).filter(Boolean) ?? []); const mergeOAuthScopes = (...values: readonly Iterable<string>[]): string[] => {  const merged = new Set<string>();  for (const scopes of values) {    for (const scope of scopes) {      if (scope.trim()) merged.add(scope);    }  }  return [...merged];}; const missingOAuthScopesFromGranted = (  granted: ReadonlySet<string>,  requiredApiScopes: Iterable<string>,): readonly string[] => {  return [...requiredApiScopes].filter((scope) => !granted.has(scope));}; const isGoogleOAuthScope = (scope: string): boolean =>  scope === "https://mail.google.com/" ||  scope.startsWith("https://www.googleapis.com/auth/") ||  scope.startsWith("https://www.google.com/m8/feeds/"); const hasGoogleOAuthScope = (connection: { readonly oauthScope: string | null }): boolean =>  [...splitOAuthScopes(connection.oauthScope)].some(isGoogleOAuthScope); const isGoogleOAuthUrl = (url: string): boolean => {  if (!URL.canParse(url)) return false;  const host = new URL(url).hostname.toLowerCase();  return host === "accounts.google.com" || host === "oauth2.googleapis.com";}; const isGoogleOAuthTarget = (preset: OAuth2Preset, baseUrl: string, specUrl: string): boolean => {  if (isGoogleDiscoveryUrl(specUrl)) return true;  if (Object.keys(preset.scopes).some(isGoogleOAuthScope)) return true;  if (isGoogleOAuthUrl(resolveOAuthUrl(preset.tokenUrl, baseUrl))) return true;  const authorizationUrl = Option.getOrElse(preset.authorizationUrl, () => "");  return authorizationUrl ? isGoogleOAuthUrl(resolveOAuthUrl(authorizationUrl, baseUrl)) : false;}; const googleAuthorizationParams = (enabled: boolean): Record<string, string> | undefined =>  enabled    ? {        access_type: "offline",      }    : undefined; type OAuthConnectionChoice = {  readonly id: ConnectionId;  readonly scopeId: ScopeId;  readonly provider: string;  readonly identityLabel: string | null;  readonly oauthScope: string | null;  readonly missingApiScopes: readonly string[];}; type GoogleServicePreviewState =  | { readonly status: "loading" }  | {      readonly status: "success";      readonly preview: SpecPreview;      readonly baseUrl: string;    }  | { readonly status: "error"; readonly message: string }; type GoogleServiceAddItem = {  readonly preset: (typeof googleOpenApiPresets)[number];  readonly preview: SpecPreview;}; const scopesForGoogleServiceItem = (item: GoogleServiceAddItem): readonly string[] => {  const oauth2Preset = item.preview.oauth2Presets[0];  return filterGoogleUserConsentOAuthScopes(Object.keys(oauth2Preset?.scopes ?? {}));}; const specInputForAdd = (input: string) => {  const value = input.trim();  const parsed = Effect.runSyncExit(    Effect.try({      try: () => new URL(value),      catch: () => null,    }),  );  return Exit.isSuccess(parsed)    ? isGoogleDiscoveryUrl(value)      ? { kind: "googleDiscovery" as const, url: value }      : { kind: "url" as const, url: value }    : { kind: "blob" as const, value };}; const isGoogleDiscoveryUrl = (url: string): boolean => {  const trimmed = url.trim();  if (!URL.canParse(trimmed)) return false;  const parsed = new URL(trimmed);  const host = parsed.hostname.toLowerCase();  if (!host.endsWith("googleapis.com")) return false;  return parsed.pathname.includes("/discovery/") || parsed.pathname.includes("$discovery");}; const normalizePresetUrl = (url: string): string => {  const trimmed = url.trim();  if (!URL.canParse(trimmed)) return trimmed.replace(/\/$/, "");  const parsed = new URL(trimmed);  parsed.hash = "";  parsed.searchParams.sort();  return parsed.toString().replace(/\/$/, "");}; const googlePresetForSpec = (  presetId: string | undefined,  specUrl: string,): (typeof googleOpenApiPresets)[number] | null => {  const normalizedSpecUrl = normalizePresetUrl(specUrl);  const byUrl = googleOpenApiPresets.find(    (preset) => preset.url && normalizePresetUrl(preset.url) === normalizedSpecUrl,  );  if (byUrl) return byUrl;  const byId = presetId ? googleOpenApiPresets.find((preset) => preset.id === presetId) : undefined;  if (byId) return byId;  return null;}; const firstBaseUrlForPreview = (preview: SpecPreview): string => {  const firstServer = preview.servers[0];  return firstServer ? (expandServerUrlOptions(firstServer)[0] ?? "") : "";}; 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 =>  Match.value(s).pipe(    Match.when({ kind: "none" }, () => "none"),    Match.when({ kind: "custom" }, () => "custom"),    Match.when({ kind: "header" }, (sel) => `header:${sel.presetIndex}`),    Match.when({ kind: "oauth2" }, (sel) => `oauth2:${sel.presetIndex}`),    Match.exhaustive,  ); 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 oauth2ConfigForPreset = (input: {  readonly preset: OAuth2Preset;  readonly baseUrl: string;  readonly scopes: Iterable<string>;  readonly identityScopes: OAuth2Preset["identityScopes"];}): OAuth2SourceConfig =>  OAuth2SourceConfig.make({    kind: "oauth2",    securitySchemeName: input.preset.securitySchemeName,    flow: input.preset.flow,    tokenUrl: resolveOAuthUrl(input.preset.tokenUrl, input.baseUrl),    authorizationUrl:      input.preset.flow === "authorizationCode"        ? resolveOAuthUrl(            Option.getOrElse(input.preset.authorizationUrl, () => ""),            input.baseUrl,          ) || null        : null,    clientIdSlot: oauth2ClientIdSlot(input.preset.securitySchemeName),    clientSecretSlot: oauth2ClientSecretSlot(input.preset.securitySchemeName),    connectionSlot: oauth2ConnectionSlot(input.preset.securitySchemeName),    scopes: [...input.scopes],    identityScopes: input.identityScopes,  }); function OAuthConnectedAccount(props: {  readonly scopeId: ScopeId;  readonly connectionId: string;  readonly scopeSummary: string;  readonly sourceName: string;  readonly onSetSourceName: (name: string) => void;}) {  const identityResult = useAtomValue(    connectionIdentityAtom(props.scopeId, ConnectionId.make(props.connectionId)),  );  const identityResponse = AsyncResult.isSuccess(identityResult) ? identityResult.value : null;  const identity = identityResponse?.status === "available" ? identityResponse : null;  const accountLabel = identity?.email ?? identity?.name ?? identity?.username ?? null;  const sourceNameWithAccount =    accountLabel && !props.sourceName.includes(accountLabel)      ? `${props.sourceName} - ${accountLabel}`      : props.sourceName;  const accountIsInSourceName = accountLabel !== null && props.sourceName === sourceNameWithAccount;  return (    <div className="flex min-w-0 flex-1 items-center gap-3">      <div className="min-w-0 flex-1">        <div className="flex min-w-0 items-center gap-2">          {identity?.picture ? (            <img              src={identity.picture}              alt=""              referrerPolicy="no-referrer"              className="size-5 shrink-0 rounded-full"            />          ) : null}          <span className="min-w-0 truncate text-foreground">            {accountLabel ? `Connected as ${accountLabel}` : "Connected"}            <span className="text-muted-foreground"> · {props.scopeSummary}</span>          </span>        </div>        {accountLabel ? (          <div className="mt-0.5 truncate text-[11px] text-muted-foreground">            Source name: {sourceNameWithAccount}          </div>        ) : null}      </div>      {accountLabel ? (        <Button          variant="secondary"          size="sm"          className="h-6 shrink-0 px-2 text-[11px]"          disabled={accountIsInSourceName}          onClick={() => props.onSetSourceName(sourceNameWithAccount)}        >          {accountIsInSourceName ? "Name includes account" : "Add account name to source name"}        </Button>      ) : null}    </div>  );} function ExistingOAuthConnectionOption(props: {  readonly connection: OAuthConnectionChoice;  readonly selected: boolean;  readonly onSelect: () => void;  readonly providerLabel: string;}) {  const identityResult = useAtomValue(    connectionIdentityAtom(props.connection.scopeId, props.connection.id),  );  const identity =    AsyncResult.isSuccess(identityResult) && identityResult.value.status === "available"      ? identityResult.value      : null;  const label =    identity?.email ?? identity?.name ?? props.connection.identityLabel ?? props.connection.id;  const picture = identity?.picture;  const needsPermission = props.connection.missingApiScopes.length > 0;  return (    <Button      type="button"      variant="ghost"      onClick={props.onSelect}      className={`h-auto w-full justify-between gap-3 rounded-md border px-3 py-2 text-left transition-colors ${        props.selected          ? "border-primary/40 bg-primary/10"          : "border-border/60 bg-background hover:border-border hover:bg-muted/30"      }`}    >      <span className="flex min-w-0 items-center gap-2">        {picture ? (          <img            src={picture}            alt=""            referrerPolicy="no-referrer"            className="size-5 shrink-0 rounded-full"          />        ) : (          <span className="grid size-5 shrink-0 place-items-center rounded-full bg-muted text-[10px] text-muted-foreground">            {label.slice(0, 1).toUpperCase()}          </span>

Lines 1–500 of 2549.