From 18443d9395bb50173d07b47d3415e467dcc4a4e9 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Thu, 14 May 2026 01:07:57 -0700 Subject: [PATCH] Share HTTP source credential state --- .../graphql/src/react/AddGraphqlSource.tsx | 84 ++--- .../graphql/src/react/EditGraphqlSource.tsx | 156 +++------ .../plugins/mcp/src/react/AddMcpSource.tsx | 96 ++---- .../plugins/mcp/src/react/EditMcpSource.tsx | 134 +++---- .../openapi/src/react/AddOpenApiSource.tsx | 204 ++++------- .../react/src/plugins/credential-bindings.tsx | 2 +- .../src/plugins/http-credential-state.tsx | 326 ++++++++++++++++++ .../react/src/plugins/http-credentials.tsx | 16 + .../src/plugins/source-oauth-connection.tsx | 67 +++- 9 files changed, 651 insertions(+), 434 deletions(-) create mode 100644 packages/react/src/plugins/http-credential-state.tsx diff --git a/packages/plugins/graphql/src/react/AddGraphqlSource.tsx b/packages/plugins/graphql/src/react/AddGraphqlSource.tsx index 3e076f693..5b3e2d509 100644 --- a/packages/plugins/graphql/src/react/AddGraphqlSource.tsx +++ b/packages/plugins/graphql/src/react/AddGraphqlSource.tsx @@ -7,12 +7,9 @@ import * as Schema from "effect/Schema"; import { useScope } from "@executor-js/react/api/scope-context"; import { sourceWriteKeys } from "@executor-js/react/api/reactivity-keys"; import { - HttpCredentials, - httpCredentialsValid, - serializeScopedHttpCredentials, - serializeHttpCredentials, - type HttpCredentialsState, -} from "@executor-js/react/plugins/http-credentials"; + HttpCredentialEditor, + useHttpCredentialEditorController, +} from "@executor-js/react/plugins/http-credential-state"; import { sourceDisplayNameFromUrl, slugifyNamespace, @@ -31,7 +28,6 @@ import { } from "@executor-js/react/plugins/credential-target-scope"; 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 { FloatActions } from "@executor-js/react/components/float-actions"; import { Spinner } from "@executor-js/react/components/spinner"; import { addGraphqlSourceOptimistic } from "./atoms"; @@ -59,7 +55,6 @@ export default function AddGraphqlSource(props: { const identity = useSourceIdentity({ fallbackName: sourceDisplayNameFromUrl(endpoint, "GraphQL") ?? "", }); - const [credentials, setCredentials] = useState(initialGraphqlCredentials); const [adding, setAdding] = useState(false); const [addError, setAddError] = useState(null); const [authMode, setAuthMode] = useState("none"); @@ -76,6 +71,14 @@ export default function AddGraphqlSource(props: { mode: "promiseExit", }); const secretList = useSecretPickerSecrets(); + const credentialEditor = useHttpCredentialEditorController({ + initialCredentials: initialGraphqlCredentials(), + targetScope: requestCredentialTargetScope, + existingSecrets: secretList, + sourceName: identity.name, + credentialScopeOptions, + bindingScopeOptions: credentialScopeOptions, + }); const oauth = useOAuthPopupFlow({ popupName: "graphql-oauth", startErrorMessage: "Failed to start OAuth", @@ -83,7 +86,7 @@ export default function AddGraphqlSource(props: { const canAdd = endpoint.trim().length > 0 && - httpCredentialsValid(credentials) && + credentialEditor.state.valid && (authMode === "none" || tokens !== null) && !oauth.busy; @@ -99,15 +102,13 @@ export default function AddGraphqlSource(props: { }, [endpoint, identity.name, identity.namespace]); const handleOAuth = useCallback(async () => { - if (!endpoint.trim() || !httpCredentialsValid(credentials)) return; + if (!endpoint.trim() || !credentialEditor.state.valid) 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 } : {}), + ...credentialEditor.serialized.requestFields, redirectUrl: oauthCallbackUrl(), connectionId: oauthConnectionId({ pluginId: "graphql", namespace }), tokenScope: oauthCredentialTargetScope, @@ -124,15 +125,12 @@ export default function AddGraphqlSource(props: { }, onError: setAddError, }); - }, [endpoint, credentials, oauth, sourceIdentity, oauthCredentialTargetScope]); + }, [endpoint, credentialEditor, oauth, sourceIdentity, oauthCredentialTargetScope]); const handleAdd = async () => { setAdding(true); setAddError(null); - const { headers: headerMap, queryParams } = serializeScopedHttpCredentials( - credentials, - requestCredentialTargetScope, - ); + const requestCredentials = credentialEditor.serialized.scopedFields(); const { trimmedEndpoint, namespace, displayName } = sourceIdentity(); const exit = await doAdd({ @@ -142,12 +140,7 @@ export default function AddGraphqlSource(props: { endpoint: trimmedEndpoint, name: displayName, namespace, - ...(Object.keys(headerMap).length > 0 ? { headers: headerMap } : {}), - ...(Object.keys(queryParams).length > 0 - ? { - queryParams: queryParams as Record, - } - : {}), + ...requestCredentials, credentialTargetScope: authMode === "oauth2" && tokens ? oauthCredentialTargetScope @@ -177,35 +170,24 @@ export default function AddGraphqlSource(props: { - - - - + + + + {/* Temporarily hidden while we revisit GraphQL OAuth discovery and UX. */}
-
- Authentication - - tabs={[ - { value: "none", label: "None" }, - { value: "oauth2", label: "OAuth" }, - ]} - value={authMode} - onChange={(value) => { - setAuthMode(value); - setTokens(null); - }} - /> -
+ { + setAuthMode(value === "oauth2" ? "oauth2" : "none"); + setTokens(null); + }} + > + + + {authMode === "oauth2" && ( void handleOAuth()} - disabled={!endpoint.trim() || !httpCredentialsValid(credentials) || oauth.busy} + disabled={!endpoint.trim() || !credentialEditor.state.valid || oauth.busy} > {oauth.busy ? "Signing in..." : tokens ? "Reconnect" : "Sign in"} diff --git a/packages/plugins/graphql/src/react/EditGraphqlSource.tsx b/packages/plugins/graphql/src/react/EditGraphqlSource.tsx index 199f23742..ee83050ae 100644 --- a/packages/plugins/graphql/src/react/EditGraphqlSource.tsx +++ b/packages/plugins/graphql/src/react/EditGraphqlSource.tsx @@ -3,33 +3,28 @@ import { useAtomValue, useAtomSet } from "@effect/atom-react"; import * as Exit from "effect/Exit"; import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; import { graphqlSourceAtom, updateGraphqlSource } from "./atoms"; -import { - connectionsAtom, - setSourceCredentialBinding, - sourceCredentialBindingsAtom, -} 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"; +import { sourceCredentialBindingsAtom } from "@executor-js/react/api/atoms"; +import { useScope } from "@executor-js/react/api/scope-context"; +import { sourceWriteKeys } from "@executor-js/react/api/reactivity-keys"; import { useSecretPickerSecrets } from "@executor-js/react/plugins/use-secret-picker-secrets"; import { - HttpCredentials, - serializeHttpCredentials, - serializeScopedHttpCredentials, - type HttpCredentialsState, -} from "@executor-js/react/plugins/http-credentials"; + HttpCredentialEditor, + useHttpCredentialEditorController, +} from "@executor-js/react/plugins/http-credential-state"; import { - effectiveCredentialBindingForScope, httpCredentialsFromConfiguredCredentialBindings, initialCredentialTargetScope, } from "@executor-js/react/plugins/credential-bindings"; import { slugifyNamespace, useSourceIdentity } from "@executor-js/react/plugins/source-identity"; import { useCredentialTargetScope } from "@executor-js/react/plugins/credential-target-scope"; import { Button } from "@executor-js/react/components/button"; -import { FilterTabs } from "@executor-js/react/components/filter-tabs"; -import { SourceOAuthConnectionControl } from "@executor-js/react/plugins/source-oauth-connection"; +import { + SourceOAuthConnectionControl, + useSourceOAuthConnectionBinding, +} from "@executor-js/react/plugins/source-oauth-connection"; import { Badge } from "@executor-js/react/components/badge"; import { ScopeId } from "@executor-js/sdk/core"; -import type { CredentialBindingRef } from "@executor-js/sdk/core"; +import type { SourceCredentialBindingRef } from "@executor-js/react/plugins/credential-bindings"; import { GraphqlSourceFields } from "./GraphqlSourceFields"; import { GRAPHQL_OAUTH_CONNECTION_SLOT, type GraphqlCredentialInput } from "../sdk/types"; import type { StoredGraphqlSource } from "../sdk/store"; @@ -44,11 +39,10 @@ type AuthMode = "none" | "oauth2"; function EditForm(props: { sourceId: string; initial: EditableSource; - bindings: readonly CredentialBindingRef[]; + bindings: readonly SourceCredentialBindingRef[]; onSave: () => void; }) { const displayScope = useScope(); - const scopeStack = useScopeStack(); const sourceScope = ScopeId.make(props.initial.scope); const { credentialTargetScope, credentialScopeOptions } = useCredentialTargetScope({ sourceScope, @@ -62,61 +56,47 @@ function EditForm(props: { initialTargetScope: initialCredentialTargetScope(sourceScope, props.bindings), }); const doUpdate = useAtomSet(updateGraphqlSource, { mode: "promiseExit" }); - const setBinding = useAtomSet(setSourceCredentialBinding, { mode: "promise" }); const secretList = useSecretPickerSecrets(); - const connectionsResult = useAtomValue(connectionsAtom(displayScope)); - const identity = useSourceIdentity({ fallbackName: props.initial.name, fallbackNamespace: props.initial.namespace, }); - const [endpoint, setEndpoint] = useState(props.initial.endpoint); - const [credentials, setCredentials] = useState(() => - httpCredentialsFromConfiguredCredentialBindings({ + const credentialEditor = useHttpCredentialEditorController({ + initialCredentials: httpCredentialsFromConfiguredCredentialBindings({ headers: props.initial.headers, queryParams: props.initial.queryParams, bindings: props.bindings, }), - ); + targetScope: credentialTargetScope, + existingSecrets: secretList, + sourceName: identity.name, + credentialScopeOptions, + bindingScopeOptions: credentialScopeOptions, + }); + + const [endpoint, setEndpoint] = useState(props.initial.endpoint); const [authMode, setAuthMode] = useState(props.initial.auth.kind); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); - const [credentialsDirty, setCredentialsDirty] = useState(false); const [authDirty, setAuthDirty] = useState(false); const identityDirty = identity.name.trim() !== props.initial.name.trim(); const metadataDirty = identityDirty || endpoint.trim() !== props.initial.endpoint.trim(); - const dirty = metadataDirty || credentialsDirty || authDirty; + const dirty = metadataDirty || credentialEditor.state.dirty || authDirty; const oauth2 = props.initial.auth.kind === "oauth2" ? props.initial.auth : null; - const connections = AsyncResult.isSuccess(connectionsResult) ? connectionsResult.value : []; - const scopeRanks = new Map(scopeStack.map((scope, index) => [scope.id, index] as const)); - const connectionBinding = oauth2 - ? effectiveCredentialBindingForScope( - props.bindings, - oauth2.connectionSlot, - oauthCredentialTargetScope, - scopeRanks, - ) - : null; - const boundConnectionId = - connectionBinding?.value.kind === "connection" ? connectionBinding.value.connectionId : null; - const isConnected = - boundConnectionId !== null && - connections.some((connection) => connection.id === boundConnectionId); - const oauthRequestCredentials = serializeHttpCredentials(credentials); - - const handleCredentialsChange = (next: HttpCredentialsState) => { - setCredentials(next); - setCredentialsDirty(true); - }; + const oauthConnection = useSourceOAuthConnectionBinding({ + pluginId: "graphql", + sourceId: props.sourceId, + sourceScope, + slotKey: oauth2?.connectionSlot ?? GRAPHQL_OAUTH_CONNECTION_SLOT, + targetScope: oauthCredentialTargetScope, + bindings: props.bindings, + }); const handleSave = async () => { setSaving(true); setError(null); - const { headers, queryParams } = serializeScopedHttpCredentials( - credentials, - credentialTargetScope, - ); + const { headers, queryParams } = credentialEditor.serialized.scoped; const payload: { sourceScope: ScopeId; name?: string; @@ -130,10 +110,10 @@ function EditForm(props: { name: metadataDirty ? identity.name.trim() || undefined : undefined, endpoint: metadataDirty ? endpoint.trim() || undefined : undefined, }; - if (credentialsDirty) { + if (credentialEditor.state.dirty) { payload.headers = headers; payload.queryParams = queryParams as Record; - payload.credentialTargetScope = credentialTargetScope; + payload.credentialTargetScope = credentialEditor.state.targetScope; } if (authDirty) { payload.auth = @@ -146,7 +126,7 @@ function EditForm(props: { : GRAPHQL_OAUTH_CONNECTION_SLOT, } : { kind: "none" }; - payload.credentialTargetScope = credentialTargetScope; + payload.credentialTargetScope = credentialEditor.state.targetScope; } const exit = await doUpdate({ params: { scopeId: displayScope, namespace: props.sourceId }, @@ -160,7 +140,7 @@ function EditForm(props: { return; } - setCredentialsDirty(false); + credentialEditor.actions.resetDirty(); setAuthDirty(false); props.onSave(); setSaving(false); @@ -191,35 +171,24 @@ function EditForm(props: { namespaceReadOnly /> - - - - + + + + {/* Temporarily hidden while we revisit GraphQL OAuth discovery and UX. */}
-
- Authentication - - tabs={[ - { value: "none", label: "None" }, - { value: "oauth2", label: "OAuth" }, - ]} - value={authMode} - onChange={(value) => { - setAuthMode(value); - setAuthDirty(true); - }} - /> -
+ { + setAuthMode(value === "oauth2" ? "oauth2" : "none"); + setAuthDirty(true); + }} + > + + + {authMode === "oauth2" && (

OAuth sign-in is available from the source header after saving. @@ -237,25 +206,12 @@ function EditForm(props: { tokenScope={oauthCredentialTargetScope} onTokenScopeChange={setOAuthCredentialTargetScope} credentialScopeOptions={credentialScopeOptions} - connectionId={boundConnectionId} + connectionId={oauthConnection.connectionId} sourceLabel={`${identity.name.trim() || props.initial.namespace || "GraphQL"} OAuth`} - headers={oauthRequestCredentials.headers} - queryParams={oauthRequestCredentials.queryParams} - isConnected={isConnected} - onConnected={async (connectionId) => { - await setBinding({ - params: { scopeId: oauthCredentialTargetScope }, - payload: { - targetScope: oauthCredentialTargetScope, - pluginId: "graphql", - sourceId: props.sourceId, - sourceScope, - slotKey: oauth2.connectionSlot, - value: { kind: "connection", connectionId }, - }, - reactivityKeys: [...sourceWriteKeys, ...connectionWriteKeys], - }); - }} + headers={credentialEditor.serialized.request.headers} + queryParams={credentialEditor.serialized.request.queryParams} + isConnected={oauthConnection.isConnected} + onConnected={oauthConnection.onConnected} /> )} diff --git a/packages/plugins/mcp/src/react/AddMcpSource.tsx b/packages/plugins/mcp/src/react/AddMcpSource.tsx index 36153bc57..d77d5fd49 100644 --- a/packages/plugins/mcp/src/react/AddMcpSource.tsx +++ b/packages/plugins/mcp/src/react/AddMcpSource.tsx @@ -12,19 +12,15 @@ import { CardStackContent, CardStackEntryField, } from "@executor-js/react/components/card-stack"; -import { FieldLabel } from "@executor-js/react/components/field"; -import { FilterTabs } from "@executor-js/react/components/filter-tabs"; 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 { Textarea } from "@executor-js/react/components/textarea"; +import { emptyHttpCredentials } from "@executor-js/react/plugins/http-credentials"; import { - emptyHttpCredentials, - HttpCredentials, - httpCredentialsValid, - serializeScopedHttpCredentials, - serializeHttpCredentials, -} from "@executor-js/react/plugins/http-credentials"; + HttpCredentialEditor, + useHttpCredentialEditorController, +} from "@executor-js/react/plugins/http-credential-state"; import { sourceDisplayNameFromUrl, slugifyNamespace, @@ -296,7 +292,6 @@ export default function AddMcpSource(props: { }); const [remoteAuthMode, setRemoteAuthMode] = useState("none"); - const [remoteCredentials, setRemoteCredentials] = useState(() => emptyHttpCredentials()); const probe = "probe" in state ? state.probe : null; const tokens = "tokens" in state ? state.tokens : null; @@ -305,12 +300,20 @@ export default function AddMcpSource(props: { fallbackName: sourceDisplayNameFromUrl(state.url, "MCP") ?? probe?.serverName ?? probe?.name ?? "", }); + const remoteCredentialEditor = useHttpCredentialEditorController({ + initialCredentials: emptyHttpCredentials(), + targetScope: requestCredentialTargetScope, + existingSecrets: secretList, + sourceName: remoteIdentity.name, + credentialScopeOptions, + bindingScopeOptions: credentialScopeOptions, + }); const isProbing = state.step === "probing"; const isAdding = state.step === "adding"; const isOAuthBusy = state.step === "oauth-starting" || state.step === "oauth-waiting" || oauth.busy; const canUseNone = probe?.requiresOAuth !== true || probe.supportsDynamicRegistration === false; - const remoteCredentialsComplete = httpCredentialsValid(remoteCredentials); + const remoteCredentialsComplete = remoteCredentialEditor.state.valid; const authReady = remoteAuthMode === "none" ? canUseNone : tokens !== null; const canAdd = Boolean(probe) && authReady && remoteCredentialsComplete && !isAdding && !isOAuthBusy; @@ -323,13 +326,11 @@ export default function AddMcpSource(props: { const handleProbe = useCallback(async () => { dispatch({ type: "probe-start" }); - const { headers, queryParams } = serializeHttpCredentials(remoteCredentials); const exit = await doProbe({ params: { scopeId }, payload: { endpoint: state.url.trim(), - ...(Object.keys(headers).length > 0 ? { headers } : {}), - ...(Object.keys(queryParams).length > 0 ? { queryParams } : {}), + ...remoteCredentialEditor.serialized.requestFields, }, }); if (Exit.isFailure(exit)) { @@ -341,7 +342,7 @@ export default function AddMcpSource(props: { } setRemoteAuthMode(exit.value.requiresOAuth ? "oauth2" : "none"); dispatch({ type: "probe-ok", probe: exit.value }); - }, [state.url, scopeId, doProbe, remoteCredentials]); + }, [state.url, scopeId, doProbe, remoteCredentialEditor]); // Keep the latest handleProbe in a ref so the debounced effect can call it // without depending on its identity (which changes every render). @@ -361,22 +362,16 @@ export default function AddMcpSource(props: { return () => clearTimeout(handle); }, [transport, state.step, state.url]); - const handleRemoteCredentialsChange = useCallback((next: typeof remoteCredentials) => { - setRemoteCredentials(next); - }, []); - const handleOAuth = useCallback(async () => { dispatch({ type: "oauth-start" }); const namespaceSlug = slugifyNamespace(remoteIdentity.namespace) || slugifyNamespace(probe?.namespace ?? "") || "mcp"; - const { headers, queryParams } = serializeHttpCredentials(remoteCredentials); await oauth.start({ payload: { endpoint: state.url.trim(), - ...(Object.keys(headers).length > 0 ? { headers } : {}), - ...(Object.keys(queryParams).length > 0 ? { queryParams } : {}), + ...remoteCredentialEditor.serialized.requestFields, redirectUrl: oauthCallbackUrl(), connectionId: oauthConnectionId({ pluginId: "mcp", @@ -401,7 +396,7 @@ export default function AddMcpSource(props: { dispatch({ type: "oauth-waiting", sessionId: result.sessionId }), onError: (error) => dispatch({ type: "oauth-fail", error }), }); - }, [state.url, remoteIdentity, probe, remoteCredentials, oauth, oauthCredentialTargetScope]); + }, [state.url, remoteIdentity, probe, remoteCredentialEditor, oauth, oauthCredentialTargetScope]); const handleCancelOAuth = useCallback(() => { oauth.cancel(); @@ -423,10 +418,7 @@ export default function AddMcpSource(props: { connectionSlot: MCP_OAUTH_CONNECTION_SLOT, } : { kind: "none" as const }; - const credentials = serializeScopedHttpCredentials( - remoteCredentials, - requestCredentialTargetScope, - ); + const requestCredentials = remoteCredentialEditor.serialized.scopedFields(); const displayName = remoteIdentity.name.trim() || probe.serverName || probe.name; const slugNamespace = slugifyNamespace(remoteIdentity.namespace); const exit = await doAdd({ @@ -442,12 +434,7 @@ export default function AddMcpSource(props: { remoteAuthMode === "oauth2" && tokens ? oauthCredentialTargetScope : requestCredentialTargetScope, - ...(Object.keys(credentials.headers).length > 0 - ? { headers: credentials.headers as Record } - : {}), - ...(Object.keys(credentials.queryParams).length > 0 - ? { queryParams: credentials.queryParams } - : {}), + ...requestCredentials, }, reactivityKeys: sourceWriteKeys, }); @@ -462,7 +449,7 @@ export default function AddMcpSource(props: { }, [ probe, remoteAuthMode, - remoteCredentials, + remoteCredentialEditor, remoteIdentity, tokens, state.url, @@ -579,37 +566,26 @@ export default function AddMcpSource(props: { onRetry={handleProbe} /> - - - - + + + + {/* Authentication */} {probe && (

-
- Authentication - - tabs={ - probe.requiresOAuth && probe.supportsDynamicRegistration - ? [{ value: "oauth2", label: "OAuth" }] - : [ - { value: "none", label: "None" }, - { value: "oauth2", label: "OAuth" }, - ] - } - value={remoteAuthMode} - onChange={setRemoteAuthMode} - /> -
+ { + setRemoteAuthMode(value === "oauth2" ? "oauth2" : "none"); + }} + > + {probe.requiresOAuth && probe.supportsDynamicRegistration ? null : ( + + )} + + {remoteAuthMode === "oauth2" && ( void; }) { const displayScope = useScope(); - const scopeStack = useScopeStack(); const sourceScope = ScopeId.make(props.initial.scope); const { credentialTargetScope, credentialScopeOptions } = useCredentialTargetScope({ sourceScope, @@ -58,59 +53,45 @@ function RemoteEditForm(props: { initialTargetScope: initialCredentialTargetScope(sourceScope, props.bindings), }); const doUpdate = useAtomSet(updateMcpSource, { mode: "promiseExit" }); - const setBinding = useAtomSet(setSourceCredentialBinding, { mode: "promise" }); const secretList = useSecretPickerSecrets(); - const connectionsResult = useAtomValue(connectionsAtom(displayScope)); + const credentialEditor = useHttpCredentialEditorController({ + initialCredentials: httpCredentialsFromConfiguredCredentialBindings({ + headers: props.initial.config.headers, + queryParams: props.initial.config.queryParams, + bindings: props.bindings, + }), + targetScope: credentialTargetScope, + existingSecrets: secretList, + sourceName: props.initial.name, + credentialScopeOptions, + bindingScopeOptions: credentialScopeOptions, + }); const identity = useSourceIdentity({ fallbackName: props.initial.name, fallbackNamespace: props.initial.namespace, }); const [endpoint, setEndpoint] = useState(props.initial.config.endpoint); - const [credentials, setCredentials] = useState(() => - httpCredentialsFromConfiguredCredentialBindings({ - headers: props.initial.config.headers, - queryParams: props.initial.config.queryParams, - bindings: props.bindings, - }), - ); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); - const [credentialsDirty, setCredentialsDirty] = useState(false); const identityDirty = identity.name.trim() !== props.initial.name.trim(); const metadataDirty = identityDirty || endpoint.trim() !== props.initial.config.endpoint.trim(); - const dirty = metadataDirty || credentialsDirty; + const dirty = metadataDirty || credentialEditor.state.dirty; const oauth2 = props.initial.config.auth.kind === "oauth2" ? props.initial.config.auth : null; - const connections = AsyncResult.isSuccess(connectionsResult) ? connectionsResult.value : []; - const scopeRanks = new Map(scopeStack.map((scope, index) => [scope.id, index] as const)); - const connectionBinding = oauth2 - ? effectiveCredentialBindingForScope( - props.bindings, - oauth2.connectionSlot, - oauthCredentialTargetScope, - scopeRanks, - ) - : null; - const boundConnectionId = - connectionBinding?.value.kind === "connection" ? connectionBinding.value.connectionId : null; - const isConnected = - boundConnectionId !== null && - connections.some((connection) => connection.id === boundConnectionId); - const oauthRequestCredentials = serializeHttpCredentials(credentials); - - const handleCredentialsChange = (next: HttpCredentialsState) => { - setCredentials(next); - setCredentialsDirty(true); - }; + const oauthConnection = useSourceOAuthConnectionBinding({ + pluginId: "mcp", + sourceId: props.sourceId, + sourceScope, + slotKey: oauth2?.connectionSlot ?? MCP_OAUTH_CONNECTION_SLOT, + targetScope: oauthCredentialTargetScope, + bindings: props.bindings, + }); const handleSave = async () => { setSaving(true); setError(null); - const { headers, queryParams } = serializeScopedHttpCredentials( - credentials, - credentialTargetScope, - ); + const { headers, queryParams } = credentialEditor.serialized.scoped; const payload: { sourceScope: ScopeId; name?: string; @@ -123,10 +104,10 @@ function RemoteEditForm(props: { name: metadataDirty ? identity.name.trim() || undefined : undefined, endpoint: metadataDirty ? endpoint.trim() || undefined : undefined, }; - if (credentialsDirty) { + if (credentialEditor.state.dirty) { payload.headers = headers; payload.queryParams = queryParams as Record; - payload.credentialTargetScope = credentialTargetScope; + payload.credentialTargetScope = credentialEditor.state.targetScope; } const exit = await doUpdate({ params: { scopeId: displayScope, namespace: props.sourceId }, @@ -138,7 +119,7 @@ function RemoteEditForm(props: { setSaving(false); return; } - setCredentialsDirty(false); + credentialEditor.actions.resetDirty(); setSaving(false); props.onSave(); }; @@ -174,18 +155,10 @@ function RemoteEditForm(props: { namespaceReadOnly /> - - - - + + + + {oauth2 && ( { - await setBinding({ - params: { scopeId: oauthCredentialTargetScope }, - payload: { - targetScope: oauthCredentialTargetScope, - pluginId: "mcp", - sourceId: props.sourceId, - sourceScope, - slotKey: oauth2.connectionSlot, - value: { kind: "connection", connectionId }, - }, - reactivityKeys: [...sourceWriteKeys, ...connectionWriteKeys], - }); - }} + headers={credentialEditor.serialized.request.headers} + queryParams={credentialEditor.serialized.request.queryParams} + isConnected={oauthConnection.isConnected} + onConnected={oauthConnection.onConnected} reconnectingLabel="Reconnecting…" signingInLabel="Signing in…" /> diff --git a/packages/plugins/openapi/src/react/AddOpenApiSource.tsx b/packages/plugins/openapi/src/react/AddOpenApiSource.tsx index b8ab6b1d1..7b51721db 100644 --- a/packages/plugins/openapi/src/react/AddOpenApiSource.tsx +++ b/packages/plugins/openapi/src/react/AddOpenApiSource.tsx @@ -18,14 +18,14 @@ import { connectionWriteKeys, sourceWriteKeys } from "@executor-js/react/api/rea const addSpecWriteKeys = [...sourceWriteKeys, ...connectionWriteKeys] as const; const bindingWriteKeys = [...sourceWriteKeys, ...connectionWriteKeys] as const; import { - HttpCredentials, - configuredCredentialMapFromRows, emptyHttpCredentials, matchHttpCredentialPreset, - serializeHttpCredentials, type HttpCredentialRow, - type HttpCredentialsState, } from "@executor-js/react/plugins/http-credentials"; +import { + HttpCredentialEditor, + useHttpCredentialEditorController, +} from "@executor-js/react/plugins/http-credential-state"; import { oauthCallbackUrl, useOAuthPopupFlow, @@ -54,7 +54,6 @@ 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 } from "./atoms"; import { OpenApiSourceDetailsFields } from "./OpenApiSourceDetailsFields"; @@ -211,14 +210,7 @@ export default function AddOpenApiSource(props: { // Auth const [strategy, setStrategy] = useState({ kind: "none" }); - const [customHeaders, setCustomHeaders] = useState([]); - const [specFetchCredentials, setSpecFetchCredentials] = useState(() => - emptyHttpCredentials(), - ); const [specFetchCredentialsOpen, setSpecFetchCredentialsOpen] = useState(false); - const [runtimeCredentials, setRuntimeCredentials] = useState(() => - emptyHttpCredentials(), - ); // OAuth2 state (only populated while an oauth2 preset is selected) const [oauth2ClientIdSecretId, setOauth2ClientIdSecretId] = useState(null); @@ -288,6 +280,35 @@ export default function AddOpenApiSource(props: { mode: "promiseExit", }); const secretList = useSecretPickerSecrets(); + const specFetchEditor = useHttpCredentialEditorController({ + initialCredentials: emptyHttpCredentials(), + targetScope: credentialTargetScope, + existingSecrets: secretList, + sourceName: identity.name, + }); + const customHeaderEditor = useHttpCredentialEditorController({ + initialCredentials: emptyHttpCredentials(), + targetScope: credentialTargetScope, + existingSecrets: secretList, + sourceName: identity.name, + credentialScopeOptions: initialCredentialScopeOptions, + bindingScopeOptions, + restrictSecretsToTargetScope: true, + onCredentialsChange: (next) => { + if (strategy.kind === "header" && next.headers.every((header) => !header.fromPreset)) { + setStrategy(next.headers.length === 0 ? { kind: "none" } : { kind: "custom" }); + } + }, + }); + const runtimeCredentialEditor = useHttpCredentialEditorController({ + initialCredentials: emptyHttpCredentials(), + targetScope: credentialTargetScope, + existingSecrets: secretList, + sourceName: identity.name, + credentialScopeOptions: initialCredentialScopeOptions, + bindingScopeOptions, + restrictSecretsToTargetScope: true, + }); const initialCredentialSecrets = useMemo( () => secretList.filter((secret) => secret.scopeId === String(credentialTargetScope)), [credentialTargetScope, secretList], @@ -331,18 +352,11 @@ export default function AddOpenApiSource(props: { const resolvedBaseUrl = baseUrl.trim(); - const headerCredentialConfig = configuredCredentialMapFromRows( - customHeaders, - credentialTargetScope, - headerBindingSlot, - ); + const headerCredentialConfig = customHeaderEditor.serialized.configuredHeaders(headerBindingSlot); const configuredHeaders = headerCredentialConfig.values as Record; const headerBindings = headerCredentialConfig.bindings; - const queryParamCredentialConfig = configuredCredentialMapFromRows( - runtimeCredentials.queryParams, - credentialTargetScope, - queryParamBindingSlot, - ); + const queryParamCredentialConfig = + runtimeCredentialEditor.serialized.configuredQueryParams(queryParamBindingSlot); const configuredQueryParams = queryParamCredentialConfig.values as Record< string, ConfiguredHeaderValue @@ -398,13 +412,16 @@ export default function AddOpenApiSource(props: { const hasHeaders = Object.keys(configuredHeaders).length > 0; const oauth2Busy = startingOAuth || oauth.busy; const canConnectOAuth2 = Boolean(oauth2ClientIdSecretId) && resolvedBaseUrl.length > 0; + const customHeaders = customHeaderEditor.state.credentials.headers; + const setCustomHeaders = customHeaderEditor.actions.setHeaders; + const runtimeQueryParams = runtimeCredentialEditor.state.credentials.queryParams; const hasIncompleteHeaderCredentials = strategy.kind !== "none" && strategy.kind !== "oauth2" && customHeaders.some( (header) => header.name.trim() && !header.secretId && !header.literalValue?.trim(), ); - const hasIncompleteQueryCredentials = runtimeCredentials.queryParams.some( + const hasIncompleteQueryCredentials = runtimeQueryParams.some( (param) => param.name.trim() && !param.secretId && !param.literalValue?.trim(), ); const willAddWithoutInitialCredentials = @@ -420,12 +437,11 @@ export default function AddOpenApiSource(props: { setAnalyzing(true); setAnalyzeError(null); setAddError(null); - const credentials = serializeHttpCredentials(specFetchCredentials); const exit = await doPreview({ params: { scopeId }, payload: { spec: specUrl, - specFetchCredentials: credentials, + specFetchCredentials: specFetchEditor.serialized.request, }, }); if (Exit.isFailure(exit)) { @@ -472,13 +488,13 @@ export default function AddOpenApiSource(props: { setCustomHeaders([]); }), Match.when({ kind: "custom" }, () => { - const userHeaders = customHeaders.filter((h) => !h.fromPreset); + const userHeaders = customHeaders.filter((header) => !header.fromPreset); setCustomHeaders(userHeaders.length > 0 ? userHeaders : []); }), Match.when({ kind: "header" }, (n) => { const preset = preview?.headerPresets[n.presetIndex]; if (!preset) return; - const userHeaders = customHeaders.filter((h) => !h.fromPreset); + const userHeaders = customHeaders.filter((header) => !header.fromPreset); setCustomHeaders([...entriesFromSpecPreset(preset), ...userHeaders]); }), Match.when({ kind: "oauth2" }, (n) => { @@ -492,17 +508,10 @@ export default function AddOpenApiSource(props: { ); }; - const handleHeadersChange = (next: HttpCredentialRow[]) => { - setCustomHeaders(next); - if (strategy.kind === "header" && next.every((h) => !h.fromPreset)) { - setStrategy(next.length === 0 ? { kind: "none" } : { kind: "custom" }); - } - }; - const setInitialCredentialScope = (targetScope: ScopeId) => { setCredentialTargetScope(targetScope); - setCustomHeaders((headers) => - headers.map((header) => ({ + setCustomHeaders( + customHeaders.map((header) => ({ ...header, targetScope, ...(header.secretScope && header.secretScope !== targetScope @@ -672,7 +681,7 @@ export default function AddOpenApiSource(props: { targetScope: scopeId, credentialTargetScope, spec: specUrl, - specFetchCredentials: serializeHttpCredentials(specFetchCredentials), + specFetchCredentials: specFetchEditor.serialized.request, name: displayName, namespace, baseUrl: resolvedBaseUrl || undefined, @@ -868,16 +877,10 @@ export default function AddOpenApiSource(props: { - - - - + + + + @@ -923,111 +926,46 @@ export default function AddOpenApiSource(props: { {preview && ( <>
- Authentication method - {/* RadioGroup always renders so the static Custom + None radios - stay visible for specs with no security schemes (e.g. MS Graph). - The preset .map() blocks below render nothing when their arrays - are empty. */} - selectStrategy(parseStrategy(value))} - className="gap-1.5" > {preview.headerPresets.map((preset, i) => { - const selected = strategy.kind === "header" && strategy.presetIndex === i; return ( - + value={`header:${i}`} + label={preset.label} + names={preset.secretHeaders} + /> ); })} {oauth2Presets.map((preset, i) => { - const selected = strategy.kind === "oauth2" && strategy.presetIndex === i; const scopeCount = Object.keys(preset.scopes).length; return ( - + value={`oauth2:${i}`} + label={preset.label} + scopeCount={scopeCount} + /> ); })} - - - + + + {/* Header-based auth input */} {strategy.kind !== "none" && strategy.kind !== "oauth2" && ( - handleHeadersChange(credentials.headers)} - existingSecrets={secretList} - sourceName={identity.name} - targetScope={credentialTargetScope} - credentialScopeOptions={initialCredentialScopeOptions} - bindingScopeOptions={bindingScopeOptions} - restrictSecretsToTargetScope - > - - + + + )} - - - + + + {/* OAuth2 configuration */} {selectedOAuth2Preset && ( diff --git a/packages/react/src/plugins/credential-bindings.tsx b/packages/react/src/plugins/credential-bindings.tsx index 9352e3f66..111f64af7 100644 --- a/packages/react/src/plugins/credential-bindings.tsx +++ b/packages/react/src/plugins/credential-bindings.tsx @@ -7,7 +7,7 @@ import { type QueryParamState, } from "./http-credentials"; -type ConfiguredCredentialValueLike = +export type ConfiguredCredentialValueLike = | string | { readonly slot: string; diff --git a/packages/react/src/plugins/http-credential-state.tsx b/packages/react/src/plugins/http-credential-state.tsx new file mode 100644 index 000000000..368ddae3f --- /dev/null +++ b/packages/react/src/plugins/http-credential-state.tsx @@ -0,0 +1,326 @@ +import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from "react"; +import { ScopeId } from "@executor-js/sdk"; + +import { Label } from "../components/label"; +import { RadioGroup, RadioGroupItem } from "../components/radio-group"; +import { cn } from "../lib/utils"; +import type { CredentialTargetScopeOption } from "./credential-target-scope"; +import { + configuredCredentialMapFromRows, + httpCredentialsValid, + HttpCredentials, + nonEmptyHttpCredentialFields, + serializeHttpCredentials, + serializeScopedHttpCredentials, + type HttpCredentialRow, + type HttpCredentialsState, + type SecretBackedValue, +} from "./http-credentials"; +import type { SecretPickerSecret } from "./secret-picker"; + +type SerializedHttpCredentials = ReturnType; +type SerializedScopedHttpCredentials = ReturnType; +type ConfiguredHttpCredentialMap = ReturnType; + +export type HttpCredentialEditorController = { + readonly state: { + readonly credentials: HttpCredentialsState; + readonly targetScope: ScopeId; + readonly dirty: boolean; + readonly valid: boolean; + }; + readonly actions: { + readonly setCredentials: (credentials: HttpCredentialsState) => void; + readonly setHeaders: (headers: readonly HttpCredentialRow[]) => void; + readonly setQueryParams: (queryParams: readonly HttpCredentialRow[]) => void; + readonly resetDirty: () => void; + }; + readonly meta: { + readonly existingSecrets: readonly SecretPickerSecret[]; + readonly sourceName?: string; + readonly credentialScopeOptions?: readonly CredentialTargetScopeOption[]; + readonly bindingScopeOptions?: readonly CredentialTargetScopeOption[]; + readonly restrictSecretsToTargetScope?: boolean; + }; + readonly serialized: { + readonly request: SerializedHttpCredentials; + readonly requestFields: { + readonly headers?: Record; + readonly queryParams?: Record; + }; + readonly scoped: SerializedScopedHttpCredentials; + readonly scopedFields: () => { + readonly headers?: Record; + readonly queryParams?: Record; + }; + readonly configuredHeaders: ( + slotForName: (name: string) => string, + ) => ConfiguredHttpCredentialMap; + readonly configuredQueryParams: ( + slotForName: (name: string) => string, + ) => ConfiguredHttpCredentialMap; + }; +}; + +export function useHttpCredentialEditorController(props: { + readonly initialCredentials: HttpCredentialsState; + readonly targetScope: ScopeId; + readonly existingSecrets: readonly SecretPickerSecret[]; + readonly sourceName?: string; + readonly credentialScopeOptions?: readonly CredentialTargetScopeOption[]; + readonly bindingScopeOptions?: readonly CredentialTargetScopeOption[]; + readonly restrictSecretsToTargetScope?: boolean; + readonly onCredentialsChange?: (credentials: HttpCredentialsState) => void; +}): HttpCredentialEditorController { + const { + initialCredentials, + targetScope, + existingSecrets, + sourceName, + credentialScopeOptions, + bindingScopeOptions, + restrictSecretsToTargetScope, + onCredentialsChange, + } = props; + const [credentials, setCredentialsState] = useState(initialCredentials); + const [dirty, setDirty] = useState(false); + + const setCredentials = useCallback( + (next: HttpCredentialsState) => { + setCredentialsState(next); + onCredentialsChange?.(next); + setDirty(true); + }, + [onCredentialsChange], + ); + const setHeaders = useCallback( + (headers: readonly HttpCredentialRow[]) => { + const next = { ...credentials, headers: [...headers] }; + setCredentialsState(next); + onCredentialsChange?.(next); + setDirty(true); + }, + [credentials, onCredentialsChange], + ); + const setQueryParams = useCallback( + (queryParams: readonly HttpCredentialRow[]) => { + const next = { ...credentials, queryParams: [...queryParams] }; + setCredentialsState(next); + onCredentialsChange?.(next); + setDirty(true); + }, + [credentials, onCredentialsChange], + ); + const resetDirty = useCallback(() => setDirty(false), []); + + const request = useMemo(() => serializeHttpCredentials(credentials), [credentials]); + const scoped = useMemo( + () => serializeScopedHttpCredentials(credentials, targetScope), + [credentials, targetScope], + ); + + const scopedFields = useCallback( + () => + nonEmptyHttpCredentialFields({ + headers: scoped.headers as Record, + queryParams: scoped.queryParams as Record, + }), + [scoped], + ); + const configuredHeaders = useCallback( + (slotForName: (name: string) => string) => + configuredCredentialMapFromRows(credentials.headers, targetScope, slotForName), + [credentials.headers, targetScope], + ); + const configuredQueryParams = useCallback( + (slotForName: (name: string) => string) => + configuredCredentialMapFromRows(credentials.queryParams, targetScope, slotForName), + [credentials.queryParams, targetScope], + ); + + return { + state: { + credentials, + targetScope, + dirty, + valid: httpCredentialsValid(credentials), + }, + actions: { + setCredentials, + setHeaders, + setQueryParams, + resetDirty, + }, + meta: { + existingSecrets, + sourceName, + credentialScopeOptions, + bindingScopeOptions, + restrictSecretsToTargetScope, + }, + serialized: { + request, + requestFields: nonEmptyHttpCredentialFields(request), + scoped, + scopedFields, + configuredHeaders, + configuredQueryParams, + }, + }; +} + +function HttpCredentialEditorProvider(props: { + readonly controller: HttpCredentialEditorController; + readonly children: ReactNode; +}) { + const { controller } = props; + return ( + + {props.children} + + ); +} + +const HttpCredentialAuthMethodsContext = createContext<{ readonly value: string } | null>(null); + +const useHttpCredentialAuthMethodsContext = () => { + const context = useContext(HttpCredentialAuthMethodsContext); + if (context) return context; + // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: React composition invariant + throw new Error( + "HttpCredentialEditor auth options must be rendered inside .", + ); +}; + +function HttpCredentialAuthRoot(props: { + readonly value: string; + readonly onValueChange: (value: string) => void; + readonly label?: ReactNode; + readonly children: ReactNode; + readonly className?: string; +}) { + return ( + +
+ {props.label ? ( +
{props.label}
+ ) : null} + + {props.children} + +
+
+ ); +} + +function HttpCredentialAuthOption(props: { + readonly value: string; + readonly label: ReactNode; + readonly detail?: ReactNode; + readonly className?: string; +}) { + const context = useHttpCredentialAuthMethodsContext(); + const selected = context.value === props.value; + return ( + + ); +} + +function HttpCredentialHeaderAuthOption(props: { + readonly value: string; + readonly label: ReactNode; + readonly names: readonly string[]; +}) { + return ( + 0 ? ( + {props.names.join(" · ")} + ) : undefined + } + /> + ); +} + +function HttpCredentialOAuthAuthOption(props: { + readonly value: string; + readonly label: ReactNode; + readonly scopeCount?: number; +}) { + return ( + + ); +} + +function HttpCredentialCustomAuthOption(props: { + readonly value?: string; + readonly label?: ReactNode; +}) { + return ( + + ); +} + +function HttpCredentialNoAuthOption(props: { + readonly value?: string; + readonly label?: ReactNode; +}) { + return ( + + ); +} + +export const HttpCredentialEditor = { + Provider: HttpCredentialEditorProvider, + Headers: HttpCredentials.Headers, + QueryParams: HttpCredentials.QueryParams, + Auth: { + Root: HttpCredentialAuthRoot, + Option: HttpCredentialAuthOption, + Header: HttpCredentialHeaderAuthOption, + OAuth: HttpCredentialOAuthAuthOption, + Custom: HttpCredentialCustomAuthOption, + None: HttpCredentialNoAuthOption, + }, +} as const; diff --git a/packages/react/src/plugins/http-credentials.tsx b/packages/react/src/plugins/http-credentials.tsx index f05f43daf..91d5fb80c 100644 --- a/packages/react/src/plugins/http-credentials.tsx +++ b/packages/react/src/plugins/http-credentials.tsx @@ -281,6 +281,22 @@ export const serializeScopedHttpCredentials = ( queryParams: serializeScopedQueryCredentials(credentials.queryParams, fallbackTargetScope), }); +export const nonEmptyHttpCredentialFields = < + HeaderValue, + QueryParamValue = HeaderValue, +>(credentials: { + readonly headers: Record; + readonly queryParams: Record; +}): { + readonly headers?: Record; + readonly queryParams?: Record; +} => ({ + ...(Object.keys(credentials.headers).length > 0 ? { headers: credentials.headers } : {}), + ...(Object.keys(credentials.queryParams).length > 0 + ? { queryParams: credentials.queryParams } + : {}), +}); + const rowValid = (row: HttpCredentialRow): boolean => { if (!row.name.trim()) return false; return Boolean(row.secretId || row.literalValue?.trim()); diff --git a/packages/react/src/plugins/source-oauth-connection.tsx b/packages/react/src/plugins/source-oauth-connection.tsx index d4cd42039..4744b4124 100644 --- a/packages/react/src/plugins/source-oauth-connection.tsx +++ b/packages/react/src/plugins/source-oauth-connection.tsx @@ -1,8 +1,17 @@ -import type { ReactNode } from "react"; +import { useCallback, useMemo, type ReactNode } from "react"; +import { useAtomValue, useAtomSet } from "@effect/atom-react"; +import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; import { CheckIcon } from "lucide-react"; import type { ConnectionId, ScopeId, SecretBackedValue } from "@executor-js/sdk"; +import { connectionsAtom, setSourceCredentialBinding } from "../api/atoms"; +import { connectionWriteKeys, sourceWriteKeys } from "../api/reactivity-keys"; +import { useScope, useScopeStack } from "../api/scope-context"; import { Spinner } from "../components/spinner"; +import { + effectiveCredentialBindingForScope, + type SourceCredentialBindingRef, +} from "./credential-bindings"; import { CredentialControlField, CredentialUsageRow, @@ -37,6 +46,61 @@ const statusLabel = (status: OAuthConnectionStatus): ReactNode => { return defaultStatusLabelByKind[status.kind]; }; +export function useSourceOAuthConnectionBinding(props: { + readonly pluginId: string; + readonly sourceId: string; + readonly sourceScope: ScopeId; + readonly slotKey: string; + readonly targetScope: ScopeId; + readonly bindings: readonly SourceCredentialBindingRef[]; +}) { + const displayScope = useScope(); + const scopeStack = useScopeStack(); + const connectionsResult = useAtomValue(connectionsAtom(displayScope)); + const setBinding = useAtomSet(setSourceCredentialBinding, { mode: "promise" }); + const scopeRanks = useMemo( + () => new Map(scopeStack.map((scope, index) => [scope.id, index] as const)), + [scopeStack], + ); + const connectionBinding = effectiveCredentialBindingForScope( + props.bindings, + props.slotKey, + props.targetScope, + scopeRanks, + ); + const connectionId = + connectionBinding?.value.kind === "connection" ? connectionBinding.value.connectionId : null; + const connections = AsyncResult.isSuccess(connectionsResult) ? connectionsResult.value : []; + const isConnected = + connectionId !== null && connections.some((connection) => connection.id === connectionId); + const onConnected = useCallback( + async (nextConnectionId: ConnectionId) => { + await setBinding({ + params: { scopeId: props.targetScope }, + payload: { + targetScope: props.targetScope, + pluginId: props.pluginId, + sourceId: props.sourceId, + sourceScope: props.sourceScope, + slotKey: props.slotKey, + value: { kind: "connection", connectionId: nextConnectionId }, + }, + reactivityKeys: [...sourceWriteKeys, ...connectionWriteKeys], + }); + }, + [ + props.pluginId, + props.sourceId, + props.sourceScope, + props.slotKey, + props.targetScope, + setBinding, + ], + ); + + return { connectionId, isConnected, onConnected }; +} + export function OAuthConnectionControl(props: { readonly tokenScope: ScopeId; readonly onTokenScopeChange: (scope: ScopeId) => void; @@ -92,7 +156,6 @@ export function SourceOAuthConnectionControl(props: { readonly queryParams?: Record; readonly isConnected: boolean; readonly onConnected: (connectionId: ConnectionId) => void | Promise; - readonly disabled?: boolean; readonly reconnectingLabel?: string; readonly signingInLabel?: string; }) {