248 lines · 8.5 KB · TypeScript
RawDownload
import { useCallback, useState } from "react";import { useAtomSet } from "@effect/atom-react"; import { useScope } from "@executor-js/react/api/scope-context";import { sourceWriteKeys } from "@executor-js/react/api/reactivity-keys";import { usePendingSources } from "@executor-js/react/api/optimistic";import {  HttpCredentialsEditor,  httpCredentialsValid,  serializeHttpCredentials,  type HttpCredentialsState,} from "@executor-js/react/plugins/http-credentials";import {  displayNameFromUrl,  slugifyNamespace,  SourceIdentityFields,  useSourceIdentity,} from "@executor-js/react/plugins/source-identity";import {  oauthCallbackUrl,  oauthConnectionId,  useOAuthPopupFlow,  type OAuthCompletionPayload,} from "@executor-js/react/plugins/oauth-sign-in";import { useSecretPickerSecrets } from "@executor-js/react/plugins/use-secret-picker-secrets";import { Button } from "@executor-js/react/components/button";import { FilterTabs } from "@executor-js/react/components/filter-tabs";import {  CardStack,  CardStackContent,  CardStackEntryField,} from "@executor-js/react/components/card-stack";import { FloatActions } from "@executor-js/react/components/float-actions";import { Input } from "@executor-js/react/components/input";import { Spinner } from "@executor-js/react/components/spinner";import { addGraphqlSource } from "./atoms";import { initialGraphqlCredentials } from "./defaults";import type { HeaderValue } from "../sdk/types"; type AuthMode = "none" | "oauth2"; export default function AddGraphqlSource(props: {  onComplete: () => void;  onCancel: () => void;  initialUrl?: string;}) {  const [endpoint, setEndpoint] = useState(props.initialUrl ?? "");  const identity = useSourceIdentity({    fallbackName: displayNameFromUrl(endpoint) ?? "",  });  const [credentials, setCredentials] = useState<HttpCredentialsState>(initialGraphqlCredentials);  const [adding, setAdding] = useState(false);  const [addError, setAddError] = useState<string | null>(null);  const [authMode, setAuthMode] = useState<AuthMode>("none");  const [tokens, setTokens] = useState<OAuthCompletionPayload | null>(null);   const scopeId = useScope();  const doAdd = useAtomSet(addGraphqlSource, { mode: "promise" });  const { beginAdd } = usePendingSources();  const secretList = useSecretPickerSecrets();  const oauth = useOAuthPopupFlow({    popupName: "graphql-oauth",    startErrorMessage: "Failed to start OAuth",  });   const canAdd =    endpoint.trim().length > 0 &&    httpCredentialsValid(credentials) &&    (authMode === "none" || tokens !== null) &&    !oauth.busy;   const sourceIdentity = useCallback(() => {    const trimmedEndpoint = endpoint.trim();    const namespace =      slugifyNamespace(identity.namespace) ||      slugifyNamespace(displayNameFromUrl(trimmedEndpoint) ?? "") ||      "graphql";    const displayName = identity.name.trim() || displayNameFromUrl(trimmedEndpoint) || namespace;    return { trimmedEndpoint, namespace, displayName };  }, [endpoint, identity.name, identity.namespace]);   const handleOAuth = useCallback(async () => {    if (!endpoint.trim() || !httpCredentialsValid(credentials)) return;    setAddError(null);    const { trimmedEndpoint, namespace, displayName } = sourceIdentity();    const { headers, queryParams } = serializeHttpCredentials(credentials);    await oauth.start({      payload: {        endpoint: trimmedEndpoint,        ...(Object.keys(headers).length > 0 ? { headers } : {}),        ...(Object.keys(queryParams).length > 0 ? { queryParams } : {}),        redirectUrl: oauthCallbackUrl(),        connectionId: oauthConnectionId({ pluginId: "graphql", namespace }),        strategy: { kind: "dynamic-dcr" },        pluginId: "graphql",        identityLabel: `${displayName} OAuth`,      },      onSuccess: (result) => {        setTokens({          connectionId: result.connectionId,          expiresAt: result.expiresAt,          scope: result.scope,        });      },      onError: setAddError,    });  }, [endpoint, credentials, oauth, sourceIdentity]);   const handleAdd = async () => {    setAdding(true);    setAddError(null);    const { headers: headerMap, queryParams } = serializeHttpCredentials(credentials);     const { trimmedEndpoint, namespace, displayName } = sourceIdentity();    const placeholder = beginAdd({      id: namespace,      name: displayName,      kind: "graphql",      url: trimmedEndpoint || undefined,    });    try {      await doAdd({        params: { scopeId },        payload: {          endpoint: trimmedEndpoint,          name: identity.name.trim() || undefined,          namespace: slugifyNamespace(identity.namespace) || undefined,          ...(Object.keys(headerMap).length > 0 ? { headers: headerMap } : {}),          ...(Object.keys(queryParams).length > 0            ? { queryParams: queryParams as Record<string, HeaderValue> }            : {}),          ...(authMode === "oauth2" && tokens            ? {                auth: {                  kind: "oauth2" as const,                  connectionId: tokens.connectionId,                },              }            : {}),        },        reactivityKeys: sourceWriteKeys,      });      props.onComplete();    } catch (e) {      setAddError(e instanceof Error ? e.message : "Failed to add source");      setAdding(false);    } finally {      placeholder.done();    }  };   return (    <div className="flex flex-1 flex-col gap-6">      <h1 className="text-xl font-semibold text-foreground">Add GraphQL Source</h1>       <CardStack>        <CardStackContent className="border-t-0">          <CardStackEntryField            label="Endpoint"            hint="The endpoint will be introspected to discover available queries and mutations."          >            <Input              value={endpoint}              onChange={(e) => setEndpoint((e.target as HTMLInputElement).value)}              placeholder="https://api.example.com/graphql"              className="font-mono text-sm"            />          </CardStackEntryField>        </CardStackContent>      </CardStack>       <SourceIdentityFields identity={identity} namePlaceholder="e.g. Shopify API" />       <HttpCredentialsEditor        credentials={credentials}        onChange={setCredentials}        existingSecrets={secretList}        sourceName={identity.name}        targetScope={scopeId}      />       <section className="space-y-2.5">        <div className="flex items-center justify-between gap-3">          <span className="text-sm font-medium text-foreground">Authentication</span>          <FilterTabs<AuthMode>            tabs={[              { value: "none", label: "None" },              { value: "oauth2", label: "OAuth" },            ]}            value={authMode}            onChange={(value) => {              setAuthMode(value);              setTokens(null);            }}          />        </div>         {authMode === "oauth2" && (          <div className="flex items-center gap-2 rounded-md border border-border bg-muted/30 px-3 py-2.5">            {tokens ? (              <span className="text-xs font-medium text-emerald-600 dark:text-emerald-400">                Authenticated              </span>            ) : (              <span className="text-xs text-muted-foreground">                Sign in before adding so Executor can introspect the schema.              </span>            )}            <Button              type="button"              variant="outline"              size="sm"              className="ml-auto h-7 px-2 text-xs"              onClick={() => void handleOAuth()}              disabled={!endpoint.trim() || !httpCredentialsValid(credentials) || oauth.busy}            >              {oauth.busy ? "Signing in..." : tokens ? "Reconnect" : "Sign in"}            </Button>          </div>        )}      </section>       {/* Error */}      {addError && (        <div className="rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2">          <p className="text-[12px] text-destructive">{addError}</p>        </div>      )}       <FloatActions>        <Button          variant="ghost"          onClick={() => {            oauth.cancel();            props.onCancel();          }}          disabled={adding}        >          Cancel        </Button>        <Button onClick={handleAdd} disabled={!canAdd || adding}>          {adding && <Spinner className="size-3.5" />}          {adding ? "Adding..." : "Add source"}        </Button>      </FloatActions>    </div>  );}