321 lines · 11.8 KB · TypeScript
RawDownload
import { useMemo, useState } from "react";import { useAtomValue, useAtomSet } from "@effect/atom-react";import * as Exit from "effect/Exit";import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";import { graphqlSourceAtom, graphqlSourceBindingsAtom } from "./atoms";import {  configureSource,  connectionsAtom,  setSourceCredentialBinding,} from "@executor-js/react/api/atoms";import { useScope, useUserScope } from "@executor-js/react/api/scope-context";import { connectionWriteKeys, sourceWriteKeys } from "@executor-js/react/api/reactivity-keys";import { useSecretPickerSecrets } from "@executor-js/react/plugins/use-secret-picker-secrets";import {  httpCredentialsFromConfiguredCredentialBindings,  serializeHttpCredentials,  type HttpCredentialsState,} from "@executor-js/react/plugins/http-credentials";import { slugifyNamespace, useSourceIdentity } from "@executor-js/react/plugins/source-identity";import { useCredentialTargetScope } from "@executor-js/react/plugins/credential-target-scope";import {  useSourceCredentialBindingScopes,  useSourceCredentialBindingWriter,} from "@executor-js/react/plugins/source-credential-bindings";import { Button } from "@executor-js/react/components/button";import {  CardStack,  CardStackContent,  CardStackEntry,  CardStackEntryContent,  CardStackEntryDescription,  CardStackEntryTitle,} from "@executor-js/react/components/card-stack";import { FilterTabs } from "@executor-js/react/components/filter-tabs";import {  SourceOAuthConnectionControl,  sourceOAuthConnectionUiState,} from "@executor-js/react/plugins/source-oauth-connection";import { Badge } from "@executor-js/react/components/badge";import { type CredentialBindingRef, ScopeId } from "@executor-js/sdk/shared";import {  SecretCredentialSlotBindings,  secretCredentialSlotsFromHttpConfig,} from "@executor-js/react/plugins/credential-slot-bindings";import { GraphqlSourceFields } from "./GraphqlSourceFields";import type { GraphqlSourceAuthInput } from "../sdk/types";import type { StoredGraphqlSource } from "../sdk/store"; type EditableSource = StoredGraphqlSource;type AuthMode = "none" | "oauth2"; // ---------------------------------------------------------------------------// Edit form// --------------------------------------------------------------------------- function EditForm(props: {  sourceId: string;  initial: EditableSource;  bindings: readonly CredentialBindingRef[];  onSave: () => void;}) {  const displayScope = useScope();  const userScope = useUserScope();  const sourceScope = ScopeId.make(props.initial.scope);  const {    credentialTargetScope: oauthCredentialTargetScope,    setCredentialTargetScope: setOAuthCredentialTargetScope,  } = useCredentialTargetScope({    sourceScope,    initialTargetScope: userScope,  });  const doConfigure = useAtomSet(configureSource, { mode: "promiseExit" });  const setConnectionBinding = useAtomSet(setSourceCredentialBinding, { mode: "promise" });  const secretList = useSecretPickerSecrets();  const connectionsResult = useAtomValue(connectionsAtom(userScope));   const identity = useSourceIdentity({    fallbackName: props.initial.name,    fallbackNamespace: props.initial.namespace,  });  const [endpoint, setEndpoint] = useState(props.initial.endpoint);  const credentials = useMemo<HttpCredentialsState>(    () =>      httpCredentialsFromConfiguredCredentialBindings({        headers: props.initial.headers,        queryParams: props.initial.queryParams,        bindings: props.bindings,      }),    [props.bindings, props.initial.headers, props.initial.queryParams],  );  const [authMode, setAuthMode] = useState<AuthMode>(props.initial.auth.kind);  const [saving, setSaving] = useState(false);  const [error, setError] = useState<string | null>(null);  const [authDirty, setAuthDirty] = useState(false);  const { busyKey, setSecretBinding, clearBinding } = useSourceCredentialBindingWriter({    displayScope,    source: { id: props.sourceId, scope: sourceScope },    onError: setError,  });   const identityDirty = identity.name.trim() !== props.initial.name.trim();  const metadataDirty = identityDirty || endpoint.trim() !== props.initial.endpoint.trim();  const dirty = metadataDirty || authDirty;  const oauth2 = props.initial.auth.kind === "oauth2" ? props.initial.auth : null;  const connections = AsyncResult.isSuccess(connectionsResult) ? connectionsResult.value : [];  const { credentialScopeOptions, secretBindingScopes, scopeRanks } =    useSourceCredentialBindingScopes({ sourceScope });  const secretSlots = secretCredentialSlotsFromHttpConfig({    headers: props.initial.headers,    queryParams: props.initial.queryParams,  });  const oauthConnectionState = oauth2    ? sourceOAuthConnectionUiState({        bindings: props.bindings,        connectionSlot: oauth2.connectionSlot,        tokenScope: oauthCredentialTargetScope,        scopeRanks,        credentialScopeOptions,        connections,      })    : null;  const oauthRequestCredentials = serializeHttpCredentials(credentials);   const handleSave = async () => {    setSaving(true);    setError(null);    const config: {      name?: string;      endpoint?: string;      auth?: GraphqlSourceAuthInput;    } = {      name: metadataDirty ? identity.name.trim() || undefined : undefined,      endpoint: metadataDirty ? endpoint.trim() || undefined : undefined,    };    if (authDirty) {      config.auth = authMode === "oauth2" ? { oauth2: {} } : { kind: "none" };    }    const exit = await doConfigure({      params: { scopeId: displayScope },      payload: {        source: { id: props.sourceId, scope: sourceScope },        scope: sourceScope,        type: "graphql",        config,      },      reactivityKeys: sourceWriteKeys,    });     if (Exit.isFailure(exit)) {      setError("Failed to update source");      setSaving(false);      return;    }     setAuthDirty(false);    props.onSave();    setSaving(false);  };   return (    <div className="space-y-6">      <div>        <h1 className="text-xl font-semibold text-foreground">Edit GraphQL Source</h1>        <p className="mt-1 text-sm text-muted-foreground">          Update the endpoint and authentication headers for this source.        </p>      </div>       <div className="flex items-center gap-3 rounded-lg border border-border bg-card px-4 py-3">        <div className="min-w-0 flex-1">          <p className="truncate text-sm font-semibold text-card-foreground">{props.sourceId}</p>        </div>        <Badge variant="secondary" className="text-xs">          GraphQL        </Badge>      </div>       <GraphqlSourceFields        endpoint={endpoint}        onEndpointChange={setEndpoint}        identity={identity}        namespaceReadOnly      />       {secretSlots.length > 0 && (        <CardStack>          <CardStackContent className="border-t-0">            <CardStackEntry>              <CardStackEntryContent>                <CardStackEntryTitle>Request credentials</CardStackEntryTitle>                <CardStackEntryDescription>                  Headers and query parameters sent with every GraphQL request.                </CardStackEntryDescription>              </CardStackEntryContent>            </CardStackEntry>            <SecretCredentialSlotBindings              slots={secretSlots}              bindingScopes={secretBindingScopes}              bindingRows={props.bindings}              scopeRanks={scopeRanks}              secrets={secretList}              sourceId={props.sourceId}              sourceName={identity.name}              credentialScopeOptions={credentialScopeOptions}              busyKey={busyKey}              onSetSecretBinding={setSecretBinding}              onClearBinding={clearBinding}            />          </CardStackContent>        </CardStack>      )}       {/* Temporarily hidden while we revisit GraphQL OAuth discovery and UX. */}      <section className="hidden 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);              setAuthDirty(true);            }}          />        </div>        {authMode === "oauth2" && (          <p className="text-xs text-muted-foreground">            OAuth sign-in is available from the source header after saving.          </p>        )}      </section>       {oauth2 && oauthConnectionState && (        <SourceOAuthConnectionControl          popupName="graphql-oauth"          pluginId="graphql"          namespace={slugifyNamespace(props.initial.namespace) || "graphql"}          fallbackNamespace="graphql"          endpoint={endpoint.trim()}          tokenScope={oauthCredentialTargetScope}          onTokenScopeChange={setOAuthCredentialTargetScope}          credentialScopeOptions={credentialScopeOptions}          connectionId={oauthConnectionState.connectionId}          sourceLabel={`${identity.name.trim() || props.initial.namespace || "GraphQL"} OAuth`}          headers={oauthRequestCredentials.headers}          queryParams={oauthRequestCredentials.queryParams}          isConnected={oauthConnectionState.isConnected}          buttonIsConnected={oauthConnectionState.buttonIsConnected}          statusLabel={oauthConnectionState.statusLabel}          onConnected={async (connectionId) => {            await setConnectionBinding({              params: { scopeId: oauthCredentialTargetScope },              payload: {                scope: oauthCredentialTargetScope,                source: { id: props.sourceId, scope: sourceScope },                slotKey: oauth2.connectionSlot,                value: { kind: "connection", connectionId },              },              reactivityKeys: [...sourceWriteKeys, ...connectionWriteKeys],            });          }}          signInLabel={oauthConnectionState.signInLabel}        />      )}       {error && (        <div className="rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2">          <p className="text-sm text-destructive">{error}</p>        </div>      )}       <div className="flex items-center justify-between border-t border-border pt-4">        <Button variant="ghost" onClick={props.onSave}>          Cancel        </Button>        <Button onClick={handleSave} disabled={!dirty || saving}>          {saving ? "Saving…" : "Save changes"}        </Button>      </div>    </div>  );} // ---------------------------------------------------------------------------// Main component// --------------------------------------------------------------------------- export default function EditGraphqlSource(props: { sourceId: string; onSave: () => void }) {  const scopeId = useScope();  const userScope = useUserScope();  const sourceResult = useAtomValue(graphqlSourceAtom(scopeId, props.sourceId));  const source =    AsyncResult.isSuccess(sourceResult) && sourceResult.value ? sourceResult.value : null;  const sourceScope = source ? ScopeId.make(source.scope) : scopeId;  const bindingsResult = useAtomValue(    graphqlSourceBindingsAtom(userScope, props.sourceId, sourceScope),  );   if (!AsyncResult.isSuccess(sourceResult) || !source || !AsyncResult.isSuccess(bindingsResult)) {    return (      <div className="space-y-6">        <div>          <h1 className="text-xl font-semibold text-foreground">Edit GraphQL Source</h1>          <p className="mt-1 text-sm text-muted-foreground">Loading configuration…</p>        </div>      </div>    );  }   return (    <EditForm      sourceId={props.sourceId}      initial={source as EditableSource}      bindings={bindingsResult.value}      onSave={props.onSave}    />  );}