From e5506a0c231c2444c7a305b6a95bbd49c4f88161 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Fri, 8 May 2026 14:13:47 -0700 Subject: [PATCH 1/6] Refactor source credential edit surfaces --- packages/core/sdk/src/client.ts | 3 - .../src/react/EditGoogleDiscoverySource.tsx | 10 +- .../src/react/source-plugin.ts | 3 - .../graphql/src/react/AddGraphqlSource.tsx | 25 +-- .../graphql/src/react/EditGraphqlSource.tsx | 108 ++++++++---- .../graphql/src/react/GraphqlSourceFields.tsx | 42 +++++ .../src/react/GraphqlSourceSummary.tsx | 73 +++++++- .../graphql/src/react/source-plugin.ts | 3 - .../plugins/mcp/src/react/AddMcpSource.tsx | 131 ++------------- .../plugins/mcp/src/react/EditMcpSource.tsx | 120 +++++++++---- .../mcp/src/react/McpRemoteSourceFields.tsx | 157 ++++++++++++++++++ .../mcp/src/react/McpSourceSummary.tsx | 93 +++++++++++ .../plugins/mcp/src/react/source-plugin.tsx | 8 +- .../openapi/src/react/AddOpenApiSource.tsx | 94 ++++------- .../openapi/src/react/EditOpenApiSource.tsx | 73 ++++---- .../src/react/OpenApiSourceDetailsFields.tsx | 105 ++++++++++++ .../src/react/OpenApiSourceSummary.tsx | 93 +++++------ packages/react/src/pages/source-detail.tsx | 6 - .../plugins/source-credential-status-core.ts | 79 +++++++++ .../plugins/source-credential-status.test.ts | 88 ++++++++++ .../src/plugins/source-credential-status.tsx | 55 ++++++ .../src/plugins/source-oauth-connection.tsx | 68 ++++++++ 22 files changed, 1058 insertions(+), 379 deletions(-) create mode 100644 packages/plugins/graphql/src/react/GraphqlSourceFields.tsx create mode 100644 packages/plugins/mcp/src/react/McpRemoteSourceFields.tsx create mode 100644 packages/plugins/mcp/src/react/McpSourceSummary.tsx create mode 100644 packages/plugins/openapi/src/react/OpenApiSourceDetailsFields.tsx create mode 100644 packages/react/src/plugins/source-credential-status-core.ts create mode 100644 packages/react/src/plugins/source-credential-status.test.ts create mode 100644 packages/react/src/plugins/source-credential-status.tsx create mode 100644 packages/react/src/plugins/source-oauth-connection.tsx diff --git a/packages/core/sdk/src/client.ts b/packages/core/sdk/src/client.ts index 4f9e2269f..d52cec093 100644 --- a/packages/core/sdk/src/client.ts +++ b/packages/core/sdk/src/client.ts @@ -122,9 +122,6 @@ export interface SourcePlugin { readonly variant?: "badge" | "panel"; readonly onAction?: () => void; }>; - readonly signIn?: ComponentType<{ - readonly sourceId: string; - }>; readonly presets?: readonly SourcePreset[]; /** Trigger early download of the plugin's lazy component chunks (add/edit/etc.). * Call from the host on intent (hover/focus) so the chunks land before the diff --git a/packages/plugins/google-discovery/src/react/EditGoogleDiscoverySource.tsx b/packages/plugins/google-discovery/src/react/EditGoogleDiscoverySource.tsx index 8ff9f5db5..04c50d8ac 100644 --- a/packages/plugins/google-discovery/src/react/EditGoogleDiscoverySource.tsx +++ b/packages/plugins/google-discovery/src/react/EditGoogleDiscoverySource.tsx @@ -5,6 +5,7 @@ import { Badge } from "@executor-js/react/components/badge"; import { Button } from "@executor-js/react/components/button"; import { googleDiscoverySourceAtom } from "./atoms"; +import GoogleDiscoverySignInButton from "./GoogleDiscoverySignInButton"; export default function EditGoogleDiscoverySource({ sourceId, @@ -63,9 +64,12 @@ export default function EditGoogleDiscoverySource({

Authentication

-

- {authKind === "oauth2" ? "OAuth 2.0" : authKind} -

+
+

+ {authKind === "oauth2" ? "OAuth 2.0" : authKind} +

+ {authKind === "oauth2" && } +
)} diff --git a/packages/plugins/google-discovery/src/react/source-plugin.ts b/packages/plugins/google-discovery/src/react/source-plugin.ts index 39643e63c..6f0ace74e 100644 --- a/packages/plugins/google-discovery/src/react/source-plugin.ts +++ b/packages/plugins/google-discovery/src/react/source-plugin.ts @@ -5,7 +5,6 @@ import { googleDiscoveryPresets } from "../sdk/presets"; const importAdd = () => import("./AddGoogleDiscoverySource"); const importEdit = () => import("./EditGoogleDiscoverySource"); const importSummary = () => import("./GoogleDiscoverySourceSummary"); -const importSignIn = () => import("./GoogleDiscoverySignInButton"); export const googleDiscoverySourcePlugin: SourcePlugin = { key: "googleDiscovery", @@ -13,12 +12,10 @@ export const googleDiscoverySourcePlugin: SourcePlugin = { add: lazy(importAdd), edit: lazy(importEdit), summary: lazy(importSummary), - signIn: lazy(importSignIn), presets: googleDiscoveryPresets, preload: () => { void importAdd(); void importEdit(); void importSummary(); - void importSignIn(); }, }; diff --git a/packages/plugins/graphql/src/react/AddGraphqlSource.tsx b/packages/plugins/graphql/src/react/AddGraphqlSource.tsx index a300bc952..8d2ea5e97 100644 --- a/packages/plugins/graphql/src/react/AddGraphqlSource.tsx +++ b/packages/plugins/graphql/src/react/AddGraphqlSource.tsx @@ -16,7 +16,6 @@ import { import { sourceDisplayNameFromUrl, slugifyNamespace, - SourceIdentityFieldRows, useSourceIdentity, } from "@executor-js/react/plugins/source-identity"; import { @@ -33,16 +32,11 @@ import { 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 { addGraphqlSourceOptimistic } from "./atoms"; import { initialGraphqlCredentials } from "./defaults"; +import { GraphqlSourceFields } from "./GraphqlSourceFields"; import type { GraphqlCredentialInput } from "../sdk/types"; const ErrorMessage = Schema.Struct({ message: Schema.String }); @@ -181,22 +175,7 @@ export default function AddGraphqlSource(props: {

Add GraphQL Source

- - - - setEndpoint((e.target as HTMLInputElement).value)} - placeholder="https://api.example.com/graphql" - className="font-mono text-sm" - /> - - - - + void; }) { const displayScope = useScope(); + const scopeStack = useScopeStack(); const sourceScope = ScopeId.make(props.initial.scope); const { credentialTargetScope, setCredentialTargetScope, credentialScopeOptions } = useCredentialTargetScope({ sourceScope, initialTargetScope: initialCredentialTargetScope(sourceScope, props.bindings), }); + const { + credentialTargetScope: oauthCredentialTargetScope, + setCredentialTargetScope: setOAuthCredentialTargetScope, + } = useCredentialTargetScope({ + sourceScope, + initialTargetScope: initialCredentialTargetScope(sourceScope, props.bindings), + }); const doUpdate = useAtomSet(updateGraphqlSource, { mode: "promiseExit" }); + const setBinding = useAtomSet(setGraphqlSourceBinding, { mode: "promise" }); const secretList = useSecretPickerSecrets(); + const connectionsResult = useAtomValue(connectionsAtom(displayScope)); const identity = useSourceIdentity({ fallbackName: props.initial.name, @@ -84,6 +96,23 @@ function EditForm(props: { const identityDirty = identity.name.trim() !== props.initial.name.trim(); const metadataDirty = identityDirty || endpoint.trim() !== props.initial.endpoint.trim(); const dirty = metadataDirty || credentialsDirty || 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); @@ -164,22 +193,12 @@ function EditForm(props: {
- - - - - - { - setEndpoint((e.target as HTMLInputElement).value); - }} - placeholder="https://api.example.com/graphql" - className="font-mono text-sm" - /> - - - + + {oauth2 && ( + { + await setBinding({ + params: { scopeId: oauthCredentialTargetScope }, + payload: new GraphqlSourceBindingInput({ + sourceId: props.sourceId, + sourceScope, + scope: oauthCredentialTargetScope, + slot: oauth2.connectionSlot, + value: { kind: "connection", connectionId }, + }), + reactivityKeys: [...sourceWriteKeys, ...connectionWriteKeys], + }); + }} + /> + )} + {error && (

{error}

diff --git a/packages/plugins/graphql/src/react/GraphqlSourceFields.tsx b/packages/plugins/graphql/src/react/GraphqlSourceFields.tsx new file mode 100644 index 000000000..9cffb193e --- /dev/null +++ b/packages/plugins/graphql/src/react/GraphqlSourceFields.tsx @@ -0,0 +1,42 @@ +import { + CardStack, + CardStackContent, + CardStackEntryField, +} from "@executor-js/react/components/card-stack"; +import { Input } from "@executor-js/react/components/input"; +import { + SourceIdentityFieldRows, + type SourceIdentity, +} from "@executor-js/react/plugins/source-identity"; + +export function GraphqlSourceFields(props: { + readonly endpoint: string; + readonly onEndpointChange: (endpoint: string) => void; + readonly identity: SourceIdentity; + readonly endpointDisabled?: boolean; + readonly namespaceReadOnly?: boolean; +}) { + return ( + + + + props.onEndpointChange((e.target as HTMLInputElement).value)} + placeholder="https://api.example.com/graphql" + className="font-mono text-sm" + disabled={props.endpointDisabled} + /> + + + + + ); +} diff --git a/packages/plugins/graphql/src/react/GraphqlSourceSummary.tsx b/packages/plugins/graphql/src/react/GraphqlSourceSummary.tsx index 81cd27503..5e47539de 100644 --- a/packages/plugins/graphql/src/react/GraphqlSourceSummary.tsx +++ b/packages/plugins/graphql/src/react/GraphqlSourceSummary.tsx @@ -1,7 +1,76 @@ +import { useAtomValue } from "@effect/atom-react"; +import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; + +import { connectionsAtom } from "@executor-js/react/api/atoms"; +import { useScope, useScopeStack, useUserScope } from "@executor-js/react/api/scope-context"; +import { + SourceCredentialNotice, + SourceCredentialStatusBadge, + missingSourceCredentialLabels, + type SourceCredentialSlot, +} from "@executor-js/react/plugins/source-credential-status"; +import { ScopeId } from "@executor-js/sdk/core"; + +import { graphqlSourceAtom, graphqlSourceBindingsAtom } from "./atoms"; +import type { StoredGraphqlSource } from "../sdk/store"; + +const sourceCredentialSlots = (source: StoredGraphqlSource): readonly SourceCredentialSlot[] => { + const slots: SourceCredentialSlot[] = []; + for (const [name, value] of Object.entries(source.headers)) { + if (typeof value !== "string") slots.push({ kind: "secret", slot: value.slot, label: name }); + } + for (const [name, value] of Object.entries(source.queryParams)) { + if (typeof value !== "string") slots.push({ kind: "secret", slot: value.slot, label: name }); + } + if (source.auth.kind === "oauth2") { + slots.push({ + kind: "connection", + slot: source.auth.connectionSlot, + label: "OAuth sign-in", + }); + } + return slots; +}; + export default function GraphqlSourceSummary(props: { sourceId: string; variant?: "badge" | "panel"; + onAction?: () => void; }) { - if (props.variant === "panel") return null; - return GraphQL · {props.sourceId}; + const displayScope = useScope(); + const userScope = useUserScope(); + const scopeStack = useScopeStack(); + const sourceResult = useAtomValue(graphqlSourceAtom(displayScope, props.sourceId)); + const source = + AsyncResult.isSuccess(sourceResult) && sourceResult.value ? sourceResult.value : null; + const sourceScope = source ? ScopeId.make(source.scope) : displayScope; + const bindingsResult = useAtomValue( + graphqlSourceBindingsAtom(displayScope, props.sourceId, sourceScope), + ); + const connectionsResult = useAtomValue(connectionsAtom(displayScope)); + + if (!source) return null; + const slots = sourceCredentialSlots(source as StoredGraphqlSource); + if (slots.length === 0) return null; + if (!AsyncResult.isSuccess(bindingsResult) || !AsyncResult.isSuccess(connectionsResult)) { + return props.variant === "panel" ? null : ( + + ); + } + + const scopeRanks = new Map(scopeStack.map((scope, index) => [scope.id, index] as const)); + const liveConnectionIds = new Set(connectionsResult.value.map((connection) => connection.id)); + const missing = missingSourceCredentialLabels({ + slots, + bindings: bindingsResult.value, + targetScope: userScope, + scopeRanks, + liveConnectionIds, + }); + + if (props.variant === "panel") { + return ; + } + + return ; } diff --git a/packages/plugins/graphql/src/react/source-plugin.ts b/packages/plugins/graphql/src/react/source-plugin.ts index 787465b95..9f7fc6f1d 100644 --- a/packages/plugins/graphql/src/react/source-plugin.ts +++ b/packages/plugins/graphql/src/react/source-plugin.ts @@ -5,7 +5,6 @@ import { graphqlPresets } from "../sdk/presets"; const importAdd = () => import("./AddGraphqlSource"); const importEdit = () => import("./EditGraphqlSource"); const importSummary = () => import("./GraphqlSourceSummary"); -const importSignIn = () => import("./GraphqlSignInButton"); export const graphqlSourcePlugin: SourcePlugin = { key: "graphql", @@ -13,12 +12,10 @@ export const graphqlSourcePlugin: SourcePlugin = { add: lazy(importAdd), edit: lazy(importEdit), summary: lazy(importSummary), - signIn: lazy(importSignIn), presets: graphqlPresets, preload: () => { void importAdd(); void importEdit(); void importSummary(); - void importSignIn(); }, }; diff --git a/packages/plugins/mcp/src/react/AddMcpSource.tsx b/packages/plugins/mcp/src/react/AddMcpSource.tsx index 12374be58..9f315d0a6 100644 --- a/packages/plugins/mcp/src/react/AddMcpSource.tsx +++ b/packages/plugins/mcp/src/react/AddMcpSource.tsx @@ -10,22 +10,15 @@ import { CardStack, CardStackContent, CardStackEntry, - CardStackEntryActions, CardStackEntryContent, - CardStackEntryDescription, CardStackEntryField, - CardStackEntryMedia, - CardStackEntryTitle, } from "@executor-js/react/components/card-stack"; -import { FieldError, FieldLabel } from "@executor-js/react/components/field"; +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 { Label } from "@executor-js/react/components/label"; -import { Badge } from "@executor-js/react/components/badge"; -import { Skeleton } from "@executor-js/react/components/skeleton"; -import { SourceFavicon } from "@executor-js/react/components/source-favicon"; -import { IOSSpinner, Spinner } from "@executor-js/react/components/spinner"; +import { Spinner } from "@executor-js/react/components/spinner"; import { Textarea } from "@executor-js/react/components/textarea"; import { emptyHttpCredentials, @@ -57,6 +50,7 @@ import { type RemoteAuthMode = "none" | "oauth2"; import { sourceWriteKeys } from "@executor-js/react/api/reactivity-keys"; import { probeMcpEndpoint, addMcpSourceOptimistic } from "./atoms"; +import { McpRemoteSourceFields } from "./McpRemoteSourceFields"; import { mcpPresets, type McpPreset } from "../sdk/presets"; import { MCP_OAUTH_CONNECTION_SLOT, type McpCredentialInput } from "../sdk/types"; @@ -599,116 +593,15 @@ export default function AddMcpSource(props: { {transport === "remote" ? ( <> - {/* Server info card (shown above URL input after probing) */} - {probe ? ( - - - - - - - - {probe.serverName ?? probe.name} - - {probe.connected - ? `${probe.toolCount} tool${probe.toolCount !== 1 ? "s" : ""} available` - : "OAuth required to discover tools"} - - - - {probe.connected ? ( - - Connected - - ) : ( - - OAuth required - - )} - - - - - - dispatch({ - type: "set-url", - url: (e.target as HTMLInputElement).value, - }) - } - placeholder="https://mcp.example.com" - className="w-full font-mono text-sm" - /> - - - - ) : isProbing ? ( - - - - - - - - - - - - - - - - - ) : null} - - {!probe && ( - - - -
- - dispatch({ - type: "set-url", - url: (e.target as HTMLInputElement).value, - }) - } - placeholder="https://mcp.example.com" - className="w-full pr-9 font-mono text-sm" - aria-invalid={probeError ? true : undefined} - /> - {isProbing && ( -
- -
- )} -
- {probeError && ( -
- {probeError} - -
- )} -
-
-
- )} + dispatch({ type: "set-url", url })} + identity={remoteIdentity} + preview={probe} + probing={isProbing} + error={probeError} + onRetry={handleProbe} + /> void; }) { const displayScope = useScope(); + const scopeStack = useScopeStack(); const sourceScope = ScopeId.make(props.initial.scope); const { credentialTargetScope, setCredentialTargetScope, credentialScopeOptions } = useCredentialTargetScope({ sourceScope, initialTargetScope: initialCredentialTargetScope(sourceScope, props.bindings), }); + const { + credentialTargetScope: oauthCredentialTargetScope, + setCredentialTargetScope: setOAuthCredentialTargetScope, + } = useCredentialTargetScope({ + sourceScope, + initialTargetScope: initialCredentialTargetScope(sourceScope, props.bindings), + }); const doUpdate = useAtomSet(updateMcpSource, { mode: "promiseExit" }); + const setBinding = useAtomSet(setMcpSourceBinding, { mode: "promise" }); const secretList = useSecretPickerSecrets(); + const connectionsResult = useAtomValue(connectionsAtom(displayScope)); const identity = useSourceIdentity({ fallbackName: props.initial.name, @@ -74,6 +89,23 @@ function RemoteEditForm(props: { const identityDirty = identity.name.trim() !== props.initial.name.trim(); const metadataDirty = identityDirty || endpoint.trim() !== props.initial.config.endpoint.trim(); const dirty = metadataDirty || credentialsDirty; + 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); @@ -137,23 +169,18 @@ function RemoteEditForm(props: {
- - - {/* Endpoint */} - - - - { - setEndpoint((e.target as HTMLInputElement).value); - }} - placeholder="https://mcp.example.com" - className="font-mono text-sm" - /> - - - + + {oauth2 && ( + { + await setBinding({ + params: { scopeId: oauthCredentialTargetScope }, + payload: new McpSourceBindingInput({ + sourceId: props.sourceId, + sourceScope, + scope: oauthCredentialTargetScope, + slot: oauth2.connectionSlot, + value: { kind: "connection", connectionId }, + }), + reactivityKeys: [...sourceWriteKeys, ...connectionWriteKeys], + }); + }} + reconnectingLabel="Reconnecting…" + signingInLabel="Signing in…" + /> + )} + {error && (

{error}

diff --git a/packages/plugins/mcp/src/react/McpRemoteSourceFields.tsx b/packages/plugins/mcp/src/react/McpRemoteSourceFields.tsx new file mode 100644 index 000000000..dbe987907 --- /dev/null +++ b/packages/plugins/mcp/src/react/McpRemoteSourceFields.tsx @@ -0,0 +1,157 @@ +import { Badge } from "@executor-js/react/components/badge"; +import { + CardStack, + CardStackContent, + CardStackEntry, + CardStackEntryActions, + CardStackEntryContent, + CardStackEntryDescription, + CardStackEntryField, + CardStackEntryMedia, + CardStackEntryTitle, +} from "@executor-js/react/components/card-stack"; +import { FieldError } from "@executor-js/react/components/field"; +import { Input } from "@executor-js/react/components/input"; +import { Skeleton } from "@executor-js/react/components/skeleton"; +import { SourceFavicon } from "@executor-js/react/components/source-favicon"; +import { IOSSpinner } from "@executor-js/react/components/spinner"; +import { Button } from "@executor-js/react/components/button"; +import { + SourceIdentityFieldRows, + type SourceIdentity, +} from "@executor-js/react/plugins/source-identity"; + +export type McpRemoteSourcePreview = { + readonly name: string; + readonly serverName: string | null; + readonly connected: boolean; + readonly toolCount: number | null; +}; + +export function McpRemoteSourceFields(props: { + readonly url: string; + readonly onUrlChange: (url: string) => void; + readonly identity: SourceIdentity; + readonly preview: McpRemoteSourcePreview | null; + readonly probing?: boolean; + readonly error?: string | null; + readonly onRetry?: () => void; + readonly namespaceReadOnly?: boolean; + readonly urlDisabled?: boolean; +}) { + if (props.preview) { + return ( + + + + + + + + + {props.preview.serverName ?? props.preview.name} + + + {props.preview.connected + ? `${props.preview.toolCount} tool${props.preview.toolCount !== 1 ? "s" : ""} available` + : "OAuth required to discover tools"} + + + + {props.preview.connected ? ( + + Connected + + ) : ( + + OAuth required + + )} + + + + + props.onUrlChange((e.target as HTMLInputElement).value)} + placeholder="https://mcp.example.com" + className="w-full font-mono text-sm" + disabled={props.urlDisabled} + /> + + + + ); + } + + if (props.probing) { + return ( + + + + + + + + + + + + + + + + + ); + } + + return ( + + + +
+ props.onUrlChange((e.target as HTMLInputElement).value)} + placeholder="https://mcp.example.com" + className="w-full pr-9 font-mono text-sm" + aria-invalid={props.error ? true : undefined} + disabled={props.urlDisabled} + /> + {props.probing && ( +
+ +
+ )} +
+ {props.error && ( +
+ {props.error} + {props.onRetry && ( + + )} +
+ )} +
+
+
+ ); +} diff --git a/packages/plugins/mcp/src/react/McpSourceSummary.tsx b/packages/plugins/mcp/src/react/McpSourceSummary.tsx new file mode 100644 index 000000000..cdae6d083 --- /dev/null +++ b/packages/plugins/mcp/src/react/McpSourceSummary.tsx @@ -0,0 +1,93 @@ +import { useAtomValue } from "@effect/atom-react"; +import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; + +import { connectionsAtom } from "@executor-js/react/api/atoms"; +import { useScope, useScopeStack, useUserScope } from "@executor-js/react/api/scope-context"; +import { + SourceCredentialNotice, + SourceCredentialStatusBadge, + missingSourceCredentialLabels, + type SourceCredentialSlot, +} from "@executor-js/react/plugins/source-credential-status"; +import { ScopeId } from "@executor-js/sdk/core"; + +import { mcpSourceAtom, mcpSourceBindingsAtom } from "./atoms"; +import type { McpStoredSourceSchemaType } from "../sdk/stored-source"; + +const sourceCredentialSlots = ( + source: McpStoredSourceSchemaType, +): readonly SourceCredentialSlot[] => { + if (source.config.transport !== "remote") return []; + const slots: SourceCredentialSlot[] = []; + for (const [name, value] of Object.entries(source.config.headers ?? {})) { + if (typeof value !== "string") slots.push({ kind: "secret", slot: value.slot, label: name }); + } + for (const [name, value] of Object.entries(source.config.queryParams ?? {})) { + if (typeof value !== "string") slots.push({ kind: "secret", slot: value.slot, label: name }); + } + const auth = source.config.auth; + if (auth.kind === "header") { + slots.push({ + kind: "secret", + slot: auth.secretSlot, + label: auth.headerName, + }); + } + if (auth.kind === "oauth2") { + if (auth.clientIdSlot) { + slots.push({ kind: "secret", slot: auth.clientIdSlot, label: "Client ID" }); + } + if (auth.clientSecretSlot) { + slots.push({ kind: "secret", slot: auth.clientSecretSlot, label: "Client Secret" }); + } + slots.push({ + kind: "connection", + slot: auth.connectionSlot, + label: "OAuth sign-in", + }); + } + return slots; +}; + +export default function McpSourceSummary(props: { + readonly sourceId: string; + readonly variant?: "badge" | "panel"; + readonly onAction?: () => void; +}) { + const displayScope = useScope(); + const userScope = useUserScope(); + const scopeStack = useScopeStack(); + const sourceResult = useAtomValue(mcpSourceAtom(displayScope, props.sourceId)); + const source = + AsyncResult.isSuccess(sourceResult) && sourceResult.value ? sourceResult.value : null; + const sourceScope = source ? ScopeId.make(source.scope) : displayScope; + const bindingsResult = useAtomValue( + mcpSourceBindingsAtom(displayScope, props.sourceId, sourceScope), + ); + const connectionsResult = useAtomValue(connectionsAtom(displayScope)); + + if (!source) return null; + const slots = sourceCredentialSlots(source as McpStoredSourceSchemaType); + if (slots.length === 0) return null; + if (!AsyncResult.isSuccess(bindingsResult) || !AsyncResult.isSuccess(connectionsResult)) { + return props.variant === "panel" ? null : ( + + ); + } + + const scopeRanks = new Map(scopeStack.map((scope, index) => [scope.id, index] as const)); + const liveConnectionIds = new Set(connectionsResult.value.map((connection) => connection.id)); + const missing = missingSourceCredentialLabels({ + slots, + bindings: bindingsResult.value, + targetScope: userScope, + scopeRanks, + liveConnectionIds, + }); + + if (props.variant === "panel") { + return ; + } + + return ; +} diff --git a/packages/plugins/mcp/src/react/source-plugin.tsx b/packages/plugins/mcp/src/react/source-plugin.tsx index 42570442a..bbac00d39 100644 --- a/packages/plugins/mcp/src/react/source-plugin.tsx +++ b/packages/plugins/mcp/src/react/source-plugin.tsx @@ -4,11 +4,11 @@ import { mcpPresets } from "../sdk/presets"; const importAdd = () => import("./AddMcpSource"); const importEdit = () => import("./EditMcpSource"); -const importSignIn = () => import("./McpSignInButton"); +const importSummary = () => import("./McpSourceSummary"); const LazyAddMcpSource = lazy(importAdd); const LazyEditMcpSource = lazy(importEdit); -const LazyMcpSignInButton = lazy(importSignIn); +const LazyMcpSourceSummary = lazy(importSummary); type AddProps = ComponentProps; @@ -41,12 +41,12 @@ export const createMcpSourcePlugin = (options?: McpSourcePluginOptions): SourceP label: "MCP", add: AddWithFlag, edit: LazyEditMcpSource, - signIn: LazyMcpSignInButton, + summary: LazyMcpSourceSummary, presets, preload: () => { void importAdd(); void importEdit(); - void importSignIn(); + void importSummary(); }, }; }; diff --git a/packages/plugins/openapi/src/react/AddOpenApiSource.tsx b/packages/plugins/openapi/src/react/AddOpenApiSource.tsx index d571a560e..7be12e9fe 100644 --- a/packages/plugins/openapi/src/react/AddOpenApiSource.tsx +++ b/packages/plugins/openapi/src/react/AddOpenApiSource.tsx @@ -34,11 +34,7 @@ import { type HeaderState, } from "@executor-js/react/plugins/secret-header-auth"; import { CredentialScopeDropdown } from "@executor-js/react/plugins/credential-target-scope"; -import { - slugifyNamespace, - SourceIdentityFieldRows, - useSourceIdentity, -} from "@executor-js/react/plugins/source-identity"; +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"; @@ -47,15 +43,10 @@ import { CollapsibleContent, CollapsibleTrigger, } from "@executor-js/react/components/collapsible"; -import { FreeformCombobox } from "@executor-js/react/components/combobox"; import { CardStack, CardStackContent, - CardStackEntry, - CardStackEntryContent, - CardStackEntryDescription, CardStackEntryField, - CardStackEntryTitle, } from "@executor-js/react/components/card-stack"; import { FieldLabel } from "@executor-js/react/components/field"; import { FloatActions } from "@executor-js/react/components/float-actions"; @@ -64,10 +55,10 @@ import { Input } from "@executor-js/react/components/input"; 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 { SourceFavicon } from "@executor-js/react/components/source-favicon"; import { RadioGroup, RadioGroupItem } from "@executor-js/react/components/radio-group"; import { IOSSpinner, Spinner } from "@executor-js/react/components/spinner"; import { addOpenApiSpecOptimistic, previewOpenApiSpec, setOpenApiSourceBinding } from "./atoms"; +import { OpenApiSourceDetailsFields } from "./OpenApiSourceDetailsFields"; import type { SpecPreview, HeaderPreset, OAuth2Preset } from "../sdk/preview"; import { headerBindingSlot, @@ -936,61 +927,32 @@ export default function AddOpenApiSource(props: { {/* ── Source information card (shown after analysis) ── */} {preview ? ( - - - - {resolvedBaseUrl && } - - - {Option.getOrElse(preview.title, () => "API")} - - - {Option.getOrElse(preview.version, () => "")} - {Option.isSome(preview.version) && " · "} - {preview.operationCount} operation - {preview.operationCount !== 1 ? "s" : ""} - {preview.tags.length > 0 && - ` · ${preview.tags.length} tag${preview.tags.length !== 1 ? "s" : ""}`} - - - - -
- - - - {!resolvedBaseUrl && ( -

- A base URL is required to make requests. -

- )} -
- - { - setSpecUrl((e.target as HTMLInputElement).value); - setPreview(null); - setBaseUrl(""); - setCustomHeaders([]); - setStrategy({ kind: "none" }); - setOauth2AuthState(null); - setOauth2Error(null); - }} - placeholder="https://api.example.com/openapi.json" - className="font-mono text-sm" - /> - -
-
-
+ "API")} + description={`${Option.getOrElse(preview.version, () => "")}${ + Option.isSome(preview.version) ? " · " : "" + }${preview.operationCount} operation${preview.operationCount !== 1 ? "s" : ""}${ + preview.tags.length > 0 + ? ` · ${preview.tags.length} tag${preview.tags.length !== 1 ? "s" : ""}` + : "" + }`} + identity={identity} + baseUrl={resolvedBaseUrl} + onBaseUrlChange={setBaseUrl} + baseUrlOptions={baseUrlOptions} + specUrl={specUrl} + onSpecUrlChange={(value) => { + setSpecUrl(value); + setPreview(null); + setBaseUrl(""); + setCustomHeaders([]); + setStrategy({ kind: "none" }); + setOauth2AuthState(null); + setOauth2Error(null); + }} + faviconUrl={resolvedBaseUrl} + baseUrlMissingMessage="A base URL is required to make requests." + /> ) : null} {analyzeError && ( diff --git a/packages/plugins/openapi/src/react/EditOpenApiSource.tsx b/packages/plugins/openapi/src/react/EditOpenApiSource.tsx index 32d80f706..7a6387b26 100644 --- a/packages/plugins/openapi/src/react/EditOpenApiSource.tsx +++ b/packages/plugins/openapi/src/react/EditOpenApiSource.tsx @@ -44,6 +44,7 @@ import { setOpenApiSourceBinding, updateOpenApiSource, } from "./atoms"; +import { OpenApiSourceDetailsFields } from "./OpenApiSourceDetailsFields"; import { OPENAPI_OAUTH_CALLBACK_PATH, OPENAPI_OAUTH_POPUP_NAME, @@ -178,6 +179,16 @@ export default function EditOpenApiSource(props: { const [oauth2EndpointsSaveState, setOAuth2EndpointsSaveState] = useState< "idle" | "saving" | "saved" >("idle"); + const editIdentity = useMemo( + () => ({ + name, + namespace: props.sourceId, + setName, + setNamespace: () => {}, + reset: () => {}, + }), + [name, props.sourceId], + ); const sourceSaveSeq = useRef(0); const oauth2EndpointsSaveSeq = useRef(0); @@ -580,47 +591,27 @@ export default function EditOpenApiSource(props: {

OpenAPI Source

- - - - - Source Details - - Name and base URL save automatically. - - - {sourceSaveState !== "idle" && ( - - {sourceSaveState === "saving" ? "Saving…" : "Saved"} - - )} - - - setName((e.target as HTMLInputElement).value)} /> - - - setBaseUrl((e.target as HTMLInputElement).value)} - className="font-mono text-sm" - /> - - - - Authentication Template - - {source.config.oauth2 - ? `OAuth2 ${source.config.oauth2.flow}` - : Object.keys(source.config.headers ?? {}).length > 0 - ? `${Object.keys(source.config.headers ?? {}).length} header binding${ - Object.keys(source.config.headers ?? {}).length === 1 ? "" : "s" - }` - : "None"} - - - - - + {}} + specUrlDisabled + namespaceReadOnly + saveState={sourceSaveState} + footer={ + source.config.oauth2 + ? `Authentication Template: OAuth2 ${source.config.oauth2.flow}` + : Object.keys(source.config.headers ?? {}).length > 0 + ? `Authentication Template: ${Object.keys(source.config.headers ?? {}).length} header binding${ + Object.keys(source.config.headers ?? {}).length === 1 ? "" : "s" + }` + : "Authentication Template: None" + } + /> diff --git a/packages/plugins/openapi/src/react/OpenApiSourceDetailsFields.tsx b/packages/plugins/openapi/src/react/OpenApiSourceDetailsFields.tsx new file mode 100644 index 000000000..86c04e977 --- /dev/null +++ b/packages/plugins/openapi/src/react/OpenApiSourceDetailsFields.tsx @@ -0,0 +1,105 @@ +import { + CardStack, + CardStackContent, + CardStackEntry, + CardStackEntryContent, + CardStackEntryDescription, + CardStackEntryField, + CardStackEntryTitle, +} from "@executor-js/react/components/card-stack"; +import { FreeformCombobox } from "@executor-js/react/components/combobox"; +import { Input } from "@executor-js/react/components/input"; +import { SourceFavicon } from "@executor-js/react/components/source-favicon"; +import { + SourceIdentityFieldRows, + type SourceIdentity, +} from "@executor-js/react/plugins/source-identity"; + +export function OpenApiSourceDetailsFields(props: { + readonly title: string; + readonly description?: string; + readonly identity: SourceIdentity; + readonly baseUrl: string; + readonly onBaseUrlChange: (value: string) => void; + readonly baseUrlOptions?: readonly string[]; + readonly specUrl?: string; + readonly onSpecUrlChange?: (value: string) => void; + readonly faviconUrl?: string; + readonly namespaceReadOnly?: boolean; + readonly specUrlDisabled?: boolean; + readonly saveState?: "idle" | "saving" | "saved"; + readonly baseUrlMissingMessage?: string; + readonly footer?: string; +}) { + const baseUrlOptions = props.baseUrlOptions ?? []; + + return ( + + + + {props.faviconUrl && } + + {props.title} + {props.description && ( + {props.description} + )} + + {props.saveState && props.saveState !== "idle" && ( + + {props.saveState === "saving" ? "Saving…" : "Saved"} + + )} + + +
+ + {baseUrlOptions.length > 0 ? ( + + ) : ( + props.onBaseUrlChange((e.target as HTMLInputElement).value)} + placeholder="https://api.example.com" + className="font-mono text-sm" + /> + )} + + {props.baseUrlMissingMessage && !props.baseUrl && ( +

+ {props.baseUrlMissingMessage} +

+ )} +
+ {props.specUrl !== undefined && props.onSpecUrlChange && ( + + props.onSpecUrlChange?.((e.target as HTMLInputElement).value)} + placeholder="https://api.example.com/openapi.json" + className="font-mono text-sm" + disabled={props.specUrlDisabled} + /> + + )} +
+ {props.footer && ( + + + {props.footer} + + + )} +
+
+ ); +} diff --git a/packages/plugins/openapi/src/react/OpenApiSourceSummary.tsx b/packages/plugins/openapi/src/react/OpenApiSourceSummary.tsx index b7d1d8ff5..cdcdd7d15 100644 --- a/packages/plugins/openapi/src/react/OpenApiSourceSummary.tsx +++ b/packages/plugins/openapi/src/react/OpenApiSourceSummary.tsx @@ -3,39 +3,23 @@ import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; import { connectionsAtom, sourceAtom } from "@executor-js/react/api/atoms"; import { Badge } from "@executor-js/react/components/badge"; -import { Button } from "@executor-js/react/components/button"; import { useScope, useScopeStack, useUserScope } from "@executor-js/react/api/scope-context"; import { ScopeId } from "@executor-js/sdk/core"; +import { + SourceCredentialNotice, + SourceCredentialStatusBadge, + missingSourceCredentialLabels, + type SourceCredentialSlot, +} from "@executor-js/react/plugins/source-credential-status"; import { openApiSourceAtom, openApiSourceBindingsAtom } from "./atoms"; -import { effectiveBindingForScope, missingCredentialLabels } from "../sdk/credential-status"; - -function ConnectedBadge() { - return ( - - Connected - - ); -} +import { effectiveBindingForScope } from "../sdk/credential-status"; +import { oauth2ClientSecretSlot, type StoredSourceSchemaType } from "../sdk/store"; function OAuthBadge() { return OAuth; } -function NeedsCredentialsBadge() { - return ( - - Needs credentials - - ); -} - function CheckingCredentialsBadge() { return ( oauth2.clientSecretSlot ?? oauth2ClientSecretSlot(oauth2.securitySchemeName); + +const sourceCredentialSlots = (source: StoredSourceSchemaType): readonly SourceCredentialSlot[] => { + const slots: SourceCredentialSlot[] = []; + for (const [name, value] of Object.entries(source.config.headers ?? {})) { + if (typeof value !== "string") slots.push({ kind: "secret", slot: value.slot, label: name }); + } + for (const [name, value] of Object.entries(source.config.queryParams ?? {})) { + if (typeof value !== "string") slots.push({ kind: "secret", slot: value.slot, label: name }); + } + const oauth2 = source.config.oauth2; + if (oauth2) { + slots.push({ kind: "secret", slot: oauth2.clientIdSlot, label: "Client ID" }); + slots.push({ + kind: "secret", + slot: effectiveClientSecretSlot(oauth2), + label: "Client Secret", + }); + slots.push({ + kind: "connection", + slot: oauth2.connectionSlot, + label: oauth2.flow === "clientCredentials" ? "OAuth client connection" : "OAuth sign-in", + }); + } + return slots; +}; + // The entry row already renders name + id + kind, so this summary // component only contributes extras — specifically, an OAuth status // badge when the source has OAuth2 configured. Non-OAuth sources @@ -89,34 +103,19 @@ export default function OpenApiSourceSummary(props: { const liveConnectionIds = new Set(connections.map((connection) => connection.id)); const scopeRanks = new Map(scopeStack.map((scope, index) => [scope.id, index] as const)); const credentialTargetScope = userScope; - const missing = missingCredentialLabels(source, bindings, credentialTargetScope, scopeRanks, { + const missing = missingSourceCredentialLabels({ + slots: sourceCredentialSlots(source), + bindings, + targetScope: credentialTargetScope, + scopeRanks, liveConnectionIds, }); if (props.variant === "panel") { - if (missing.length === 0) return null; - return ( -
-
-
-
- This source needs your credentials before tools can run. -
-
- Missing: {missing.join(", ")} -
-
- {props.onAction && ( - - )} -
-
- ); + return ; } - if (missing.length > 0) return ; + if (missing.length > 0) return ; if (!oauth2) return null; const connectionBinding = effectiveBindingForScope( @@ -131,7 +130,7 @@ export default function OpenApiSourceSummary(props: { : null; if (connectionId && connections.some((connection) => connection.id === connectionId)) { - return ; + return ; } return ; diff --git a/packages/react/src/pages/source-detail.tsx b/packages/react/src/pages/source-detail.tsx index 6adf30513..6626c0aa5 100644 --- a/packages/react/src/pages/source-detail.tsx +++ b/packages/react/src/pages/source-detail.tsx @@ -166,12 +166,6 @@ export function SourceDetailPage(props: { namespace: string }) {
- {editPlugin?.signIn && !editing && !confirmDelete && ( - - - - )} - {canEdit && editPlugin && !editing && !confirmDelete && ( + )} +
+ + ); +} diff --git a/packages/react/src/plugins/source-oauth-connection.tsx b/packages/react/src/plugins/source-oauth-connection.tsx new file mode 100644 index 000000000..c39285423 --- /dev/null +++ b/packages/react/src/plugins/source-oauth-connection.tsx @@ -0,0 +1,68 @@ +import type { ConnectionId, ScopeId, SecretBackedValue } from "@executor-js/sdk"; + +import { + CredentialControlField, + CredentialUsageRow, + type CredentialTargetScopeOption, +} from "./credential-target-scope"; +import { SourceOAuthSignInButton } from "./oauth-sign-in"; + +export function SourceOAuthConnectionControl(props: { + readonly popupName: string; + readonly pluginId: string; + readonly namespace: string; + readonly fallbackNamespace: string; + readonly endpoint: string; + readonly tokenScope: ScopeId; + readonly onTokenScopeChange: (scope: ScopeId) => void; + readonly credentialScopeOptions: readonly CredentialTargetScopeOption[]; + readonly connectionId: string | null; + readonly sourceLabel: string; + readonly headers?: Record; + readonly queryParams?: Record; + readonly isConnected: boolean; + readonly onConnected: (connectionId: ConnectionId) => void | Promise; + readonly disabled?: boolean; + readonly reconnectingLabel?: string; + readonly signingInLabel?: string; +}) { + return ( + + +
+ {props.isConnected ? ( + + Connected + + ) : ( + Not connected + )} +
+ +
+
+
+
+ ); +} From 46ca2542c56479196b7ea1a6e7bb457bac78e096 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Fri, 8 May 2026 14:44:20 -0700 Subject: [PATCH 2/6] Remove redundant edit credential target selector --- .../graphql/src/react/EditGraphqlSource.tsx | 24 ++++--------------- .../plugins/mcp/src/react/EditMcpSource.tsx | 24 ++++--------------- 2 files changed, 10 insertions(+), 38 deletions(-) diff --git a/packages/plugins/graphql/src/react/EditGraphqlSource.tsx b/packages/plugins/graphql/src/react/EditGraphqlSource.tsx index fbb90b87c..52932d493 100644 --- a/packages/plugins/graphql/src/react/EditGraphqlSource.tsx +++ b/packages/plugins/graphql/src/react/EditGraphqlSource.tsx @@ -24,10 +24,7 @@ import { initialCredentialTargetScope, } from "@executor-js/react/plugins/credential-bindings"; import { slugifyNamespace, useSourceIdentity } from "@executor-js/react/plugins/source-identity"; -import { - CredentialTargetScopeSelector, - useCredentialTargetScope, -} from "@executor-js/react/plugins/credential-target-scope"; +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"; @@ -58,11 +55,10 @@ function EditForm(props: { const displayScope = useScope(); const scopeStack = useScopeStack(); const sourceScope = ScopeId.make(props.initial.scope); - const { credentialTargetScope, setCredentialTargetScope, credentialScopeOptions } = - useCredentialTargetScope({ - sourceScope, - initialTargetScope: initialCredentialTargetScope(sourceScope, props.bindings), - }); + const { credentialTargetScope, credentialScopeOptions } = useCredentialTargetScope({ + sourceScope, + initialTargetScope: initialCredentialTargetScope(sourceScope, props.bindings), + }); const { credentialTargetScope: oauthCredentialTargetScope, setCredentialTargetScope: setOAuthCredentialTargetScope, @@ -200,16 +196,6 @@ function EditForm(props: { namespaceReadOnly /> - { - setCredentialTargetScope(targetScope); - setCredentialsDirty(true); - }} - description="Choose where updated GraphQL credentials are saved." - /> - - { - setCredentialTargetScope(targetScope); - setCredentialsDirty(true); - }} - description="Choose where updated MCP credentials are saved." - /> - Date: Fri, 8 May 2026 14:45:36 -0700 Subject: [PATCH 3/6] Hide unknown MCP edit tool count --- .../mcp/src/react/McpRemoteSourceFields.tsx | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/packages/plugins/mcp/src/react/McpRemoteSourceFields.tsx b/packages/plugins/mcp/src/react/McpRemoteSourceFields.tsx index dbe987907..81e930743 100644 --- a/packages/plugins/mcp/src/react/McpRemoteSourceFields.tsx +++ b/packages/plugins/mcp/src/react/McpRemoteSourceFields.tsx @@ -39,6 +39,14 @@ export function McpRemoteSourceFields(props: { readonly namespaceReadOnly?: boolean; readonly urlDisabled?: boolean; }) { + const previewDescription = props.preview + ? props.preview.connected + ? props.preview.toolCount === null + ? null + : `${props.preview.toolCount} tool${props.preview.toolCount !== 1 ? "s" : ""} available` + : "OAuth required to discover tools" + : null; + if (props.preview) { return ( @@ -51,11 +59,9 @@ export function McpRemoteSourceFields(props: { {props.preview.serverName ?? props.preview.name} - - {props.preview.connected - ? `${props.preview.toolCount} tool${props.preview.toolCount !== 1 ? "s" : ""} available` - : "OAuth required to discover tools"} - + {previewDescription ? ( + {previewDescription} + ) : null} {props.preview.connected ? ( From 36adc680847da0facc8a5efe8f70c1146d5e0f5a Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Fri, 8 May 2026 14:46:58 -0700 Subject: [PATCH 4/6] Match OAuth credential scope control sizing --- packages/react/src/plugins/credential-target-scope.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/react/src/plugins/credential-target-scope.tsx b/packages/react/src/plugins/credential-target-scope.tsx index 34a334d3d..30843615a 100644 --- a/packages/react/src/plugins/credential-target-scope.tsx +++ b/packages/react/src/plugins/credential-target-scope.tsx @@ -170,6 +170,8 @@ export function CredentialScopeDropdown(props: { readonly onChange: (scope: ScopeId) => void; readonly label?: string; readonly help?: ReactNode; + readonly triggerClassName?: string; + readonly size?: "sm" | "default"; }) { const label = props.label ?? "Used by"; if (props.options.length <= 1) return null; @@ -186,7 +188,7 @@ export function CredentialScopeDropdown(props: { value={String(props.value)} onValueChange={(value) => props.onChange(ScopeId.make(value))} > - + @@ -238,6 +240,8 @@ export function CredentialUsageRow(props: { onChange={props.onChange} label={props.label} help={props.help} + triggerClassName="w-fit min-w-28" + size="sm" /> ); From 29454e8cba739f81a9933c2d4ae6cf3d05402b21 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Fri, 8 May 2026 14:48:18 -0700 Subject: [PATCH 5/6] Expand OAuth credential scope dropdown --- packages/react/src/plugins/credential-target-scope.tsx | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/react/src/plugins/credential-target-scope.tsx b/packages/react/src/plugins/credential-target-scope.tsx index 30843615a..cd5404ae8 100644 --- a/packages/react/src/plugins/credential-target-scope.tsx +++ b/packages/react/src/plugins/credential-target-scope.tsx @@ -170,6 +170,7 @@ export function CredentialScopeDropdown(props: { readonly onChange: (scope: ScopeId) => void; readonly label?: string; readonly help?: ReactNode; + readonly className?: string; readonly triggerClassName?: string; readonly size?: "sm" | "default"; }) { @@ -177,7 +178,7 @@ export function CredentialScopeDropdown(props: { if (props.options.length <= 1) return null; return ( -
+
{label} @@ -232,7 +233,7 @@ export function CredentialUsageRow(props: { } return ( -
+
{props.children}
); From 87acc1f8e47e1bf1e6647eac255a8de09f047964 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Fri, 8 May 2026 15:00:37 -0700 Subject: [PATCH 6/6] Fix source credential CI issues --- packages/plugins/mcp/src/react/AddMcpSource.tsx | 2 -- packages/plugins/openapi/src/react/AddOpenApiSource.tsx | 1 - .../openapi/src/react/OpenApiSourceDetailsFields.tsx | 7 +++++-- .../react/src/plugins/source-credential-status-core.ts | 3 +-- 4 files changed, 6 insertions(+), 7 deletions(-) diff --git a/packages/plugins/mcp/src/react/AddMcpSource.tsx b/packages/plugins/mcp/src/react/AddMcpSource.tsx index 9f315d0a6..dd0fd5078 100644 --- a/packages/plugins/mcp/src/react/AddMcpSource.tsx +++ b/packages/plugins/mcp/src/react/AddMcpSource.tsx @@ -10,7 +10,6 @@ import { CardStack, CardStackContent, CardStackEntry, - CardStackEntryContent, CardStackEntryField, } from "@executor-js/react/components/card-stack"; import { FieldLabel } from "@executor-js/react/components/field"; @@ -30,7 +29,6 @@ import { import { sourceDisplayNameFromUrl, slugifyNamespace, - SourceIdentityFieldRows, SourceIdentityFields, useSourceIdentity, } from "@executor-js/react/plugins/source-identity"; diff --git a/packages/plugins/openapi/src/react/AddOpenApiSource.tsx b/packages/plugins/openapi/src/react/AddOpenApiSource.tsx index 7be12e9fe..90a55c5cc 100644 --- a/packages/plugins/openapi/src/react/AddOpenApiSource.tsx +++ b/packages/plugins/openapi/src/react/AddOpenApiSource.tsx @@ -51,7 +51,6 @@ import { 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 { Input } from "@executor-js/react/components/input"; import { Label } from "@executor-js/react/components/label"; import { Textarea } from "@executor-js/react/components/textarea"; import { Checkbox } from "@executor-js/react/components/checkbox"; diff --git a/packages/plugins/openapi/src/react/OpenApiSourceDetailsFields.tsx b/packages/plugins/openapi/src/react/OpenApiSourceDetailsFields.tsx index 86c04e977..e5c1f275b 100644 --- a/packages/plugins/openapi/src/react/OpenApiSourceDetailsFields.tsx +++ b/packages/plugins/openapi/src/react/OpenApiSourceDetailsFields.tsx @@ -7,7 +7,10 @@ import { CardStackEntryField, CardStackEntryTitle, } from "@executor-js/react/components/card-stack"; -import { FreeformCombobox } from "@executor-js/react/components/combobox"; +import { + FreeformCombobox, + type FreeformComboboxOption, +} from "@executor-js/react/components/combobox"; import { Input } from "@executor-js/react/components/input"; import { SourceFavicon } from "@executor-js/react/components/source-favicon"; import { @@ -21,7 +24,7 @@ export function OpenApiSourceDetailsFields(props: { readonly identity: SourceIdentity; readonly baseUrl: string; readonly onBaseUrlChange: (value: string) => void; - readonly baseUrlOptions?: readonly string[]; + readonly baseUrlOptions?: readonly FreeformComboboxOption[]; readonly specUrl?: string; readonly onSpecUrlChange?: (value: string) => void; readonly faviconUrl?: string; diff --git a/packages/react/src/plugins/source-credential-status-core.ts b/packages/react/src/plugins/source-credential-status-core.ts index 64f6db7b7..e4ac7e15b 100644 --- a/packages/react/src/plugins/source-credential-status-core.ts +++ b/packages/react/src/plugins/source-credential-status-core.ts @@ -37,8 +37,7 @@ export const effectiveSourceCredentialBinding = ( const liveConnectionSet = ( values?: ReadonlySet | readonly ConnectionId[], -): ReadonlySet | undefined => - !values ? undefined : Array.isArray(values) ? new Set(values.map(String)) : values; +): ReadonlySet | undefined => (values ? new Set(Array.from(values, String)) : undefined); export const missingSourceCredentialLabels = (input: { readonly slots: readonly SourceCredentialSlot[];