History

Merge pull request #728 from RhysSullivan/codex/source-credential-composition

Refactor source credential edit surfaces

Rhys Sullivan committed 6 months ago

Commit f8fc3b42 parents c4891d2 87acc1f23 files · +1081 −420Browse files

Unified · Split

23 files
  1. packages/core/sdk/src/client.ts +0 −3
  2. packages/plugins/google-discovery/src/react/EditGoogleDiscoverySource.tsx +7 −3
  3. packages/plugins/google-discovery/src/react/source-plugin.ts +0 −3
  4. packages/plugins/graphql/src/react/AddGraphqlSource.tsx +2 −23
  5. packages/plugins/graphql/src/react/EditGraphqlSource.tsx +83 −47
  6. packages/plugins/graphql/src/react/GraphqlSourceFields.tsx +42 −0
  7. packages/plugins/graphql/src/react/GraphqlSourceSummary.tsx +71 −2
  8. packages/plugins/graphql/src/react/source-plugin.ts +0 −3
  9. packages/plugins/mcp/src/react/AddMcpSource.tsx +12 −121
  10. packages/plugins/mcp/src/react/EditMcpSource.tsx +93 −47
  11. packages/plugins/mcp/src/react/McpRemoteSourceFields.tsx +163 −0
  12. packages/plugins/mcp/src/react/McpSourceSummary.tsx +93 −0
  13. packages/plugins/mcp/src/react/source-plugin.tsx +4 −4
  14. packages/plugins/openapi/src/react/AddOpenApiSource.tsx +28 −67
  15. packages/plugins/openapi/src/react/EditOpenApiSource.tsx +32 −41
  16. packages/plugins/openapi/src/react/OpenApiSourceDetailsFields.tsx +108 −0
  17. packages/plugins/openapi/src/react/OpenApiSourceSummary.tsx +46 −47
  18. packages/react/src/pages/source-detail.tsx +0 −6
  19. packages/react/src/plugins/credential-target-scope.tsx +8 −3
  20. packages/react/src/plugins/source-credential-status-core.ts +78 −0
  21. packages/react/src/plugins/source-credential-status.test.ts +88 −0
  22. packages/react/src/plugins/source-credential-status.tsx +55 −0
  23. packages/react/src/plugins/source-oauth-connection.tsx +68 −0
packages/core/sdk/src/client.ts+0−3
@@ -122,9 +122,6 @@ export interface SourcePlugin { · 121 unmodified lines
122 readonly variant?: "badge" | "panel";122 readonly variant?: "badge" | "panel";
123 readonly onAction?: () => void;123 readonly onAction?: () => void;
124 }>;124 }>;
125 readonly signIn?: ComponentType<{
126 readonly sourceId: string;
127 }>;
128 readonly presets?: readonly SourcePreset[];125 readonly presets?: readonly SourcePreset[];
129 /** Trigger early download of the plugin's lazy component chunks (add/edit/etc.).126 /** Trigger early download of the plugin's lazy component chunks (add/edit/etc.).
130 * Call from the host on intent (hover/focus) so the chunks land before the127 * Call from the host on intent (hover/focus) so the chunks land before the
packages/plugins/google-discovery/src/react/EditGoogleDiscoverySource.tsx+7−3
@@ -5,6 +5,7 @@ import { Badge } from "@executor-js/react/components/badge"; · 4 unmodified lines
5import { Button } from "@executor-js/react/components/button";5import { Button } from "@executor-js/react/components/button";
6 6
7import { googleDiscoverySourceAtom } from "./atoms";7import { googleDiscoverySourceAtom } from "./atoms";
8import GoogleDiscoverySignInButton from "./GoogleDiscoverySignInButton";
8 9
9export default function EditGoogleDiscoverySource({10export default function EditGoogleDiscoverySource({
10 sourceId,11 sourceId,
@@ -63,9 +64,12 @@ export default function EditGoogleDiscoverySource({ · 52 unmodified lines
63 <p className="text-xs uppercase tracking-wider text-muted-foreground mb-1">64 <p className="text-xs uppercase tracking-wider text-muted-foreground mb-1">
64 Authentication65 Authentication
65 </p>66 </p>
66 <p className="text-sm font-medium text-foreground capitalize">67 <div className="flex min-h-9 items-center justify-between gap-3">
67 {authKind === "oauth2" ? "OAuth 2.0" : authKind}68 <p className="text-sm font-medium text-foreground capitalize">
68 </p>69 {authKind === "oauth2" ? "OAuth 2.0" : authKind}
70 </p>
71 {authKind === "oauth2" && <GoogleDiscoverySignInButton sourceId={sourceId} />}
72 </div>
69 </div>73 </div>
70 </div>74 </div>
71 )}75 )}
packages/plugins/google-discovery/src/react/source-plugin.ts+0−3
@@ -5,7 +5,6 @@ import { googleDiscoveryPresets } from "../sdk/presets"; · 4 unmodified lines
5const importAdd = () => import("./AddGoogleDiscoverySource");5const importAdd = () => import("./AddGoogleDiscoverySource");
6const importEdit = () => import("./EditGoogleDiscoverySource");6const importEdit = () => import("./EditGoogleDiscoverySource");
7const importSummary = () => import("./GoogleDiscoverySourceSummary");7const importSummary = () => import("./GoogleDiscoverySourceSummary");
8const importSignIn = () => import("./GoogleDiscoverySignInButton");
9 8
10export const googleDiscoverySourcePlugin: SourcePlugin = {9export const googleDiscoverySourcePlugin: SourcePlugin = {
11 key: "googleDiscovery",10 key: "googleDiscovery",
@@ -13,12 +12,10 @@ export const googleDiscoverySourcePlugin: SourcePlugin = { · 1 unmodified line
13 add: lazy(importAdd),12 add: lazy(importAdd),
14 edit: lazy(importEdit),13 edit: lazy(importEdit),
15 summary: lazy(importSummary),14 summary: lazy(importSummary),
16 signIn: lazy(importSignIn),
17 presets: googleDiscoveryPresets,15 presets: googleDiscoveryPresets,
18 preload: () => {16 preload: () => {
19 void importAdd();17 void importAdd();
20 void importEdit();18 void importEdit();
21 void importSummary();19 void importSummary();
22 void importSignIn();
23 },20 },
24};21};
packages/plugins/graphql/src/react/AddGraphqlSource.tsx+2−23
@@ -16,7 +16,6 @@ import { · 15 unmodified lines
16import {16import {
17 sourceDisplayNameFromUrl,17 sourceDisplayNameFromUrl,
18 slugifyNamespace,18 slugifyNamespace,
19 SourceIdentityFieldRows,
20 useSourceIdentity,19 useSourceIdentity,
21} from "@executor-js/react/plugins/source-identity";20} from "@executor-js/react/plugins/source-identity";
22import {21import {
@@ -33,16 +32,11 @@ import { · 10 unmodified lines
33import { useSecretPickerSecrets } from "@executor-js/react/plugins/use-secret-picker-secrets";32import { useSecretPickerSecrets } from "@executor-js/react/plugins/use-secret-picker-secrets";
34import { Button } from "@executor-js/react/components/button";33import { Button } from "@executor-js/react/components/button";
35import { FilterTabs } from "@executor-js/react/components/filter-tabs";34import { FilterTabs } from "@executor-js/react/components/filter-tabs";
36import {
37 CardStack,
38 CardStackContent,
39 CardStackEntryField,
40} from "@executor-js/react/components/card-stack";
41import { FloatActions } from "@executor-js/react/components/float-actions";35import { FloatActions } from "@executor-js/react/components/float-actions";
42import { Input } from "@executor-js/react/components/input";
43import { Spinner } from "@executor-js/react/components/spinner";36import { Spinner } from "@executor-js/react/components/spinner";
44import { addGraphqlSourceOptimistic } from "./atoms";37import { addGraphqlSourceOptimistic } from "./atoms";
45import { initialGraphqlCredentials } from "./defaults";38import { initialGraphqlCredentials } from "./defaults";
39import { GraphqlSourceFields } from "./GraphqlSourceFields";
46import type { GraphqlCredentialInput } from "../sdk/types";40import type { GraphqlCredentialInput } from "../sdk/types";
47 41
48const ErrorMessage = Schema.Struct({ message: Schema.String });42const ErrorMessage = Schema.Struct({ message: Schema.String });
@@ -181,22 +175,7 @@ export default function AddGraphqlSource(props: { · 132 unmodified lines
181 <div className="flex flex-1 flex-col gap-6">175 <div className="flex flex-1 flex-col gap-6">
182 <h1 className="text-xl font-semibold text-foreground">Add GraphQL Source</h1>176 <h1 className="text-xl font-semibold text-foreground">Add GraphQL Source</h1>
183 177
184 <CardStack>178 <GraphqlSourceFields endpoint={endpoint} onEndpointChange={setEndpoint} identity={identity} />
185 <CardStackContent className="border-t-0">
186 <CardStackEntryField
187 label="Endpoint"
188 hint="The endpoint will be introspected to discover available queries and mutations."
189 >
190 <Input
191 value={endpoint}
192 onChange={(e) => setEndpoint((e.target as HTMLInputElement).value)}
193 placeholder="https://api.example.com/graphql"
194 className="font-mono text-sm"
195 />
196 </CardStackEntryField>
197 <SourceIdentityFieldRows identity={identity} namePlaceholder="e.g. Shopify API" />
198 </CardStackContent>
199 </CardStack>
200 179
201 <HttpCredentialsEditor180 <HttpCredentialsEditor
202 credentials={credentials}181 credentials={credentials}
packages/plugins/graphql/src/react/EditGraphqlSource.tsx+83−47
@@ -2,40 +2,39 @@ import { useState } from "react"; · 1 unmodified line
2import { useAtomValue, useAtomSet } from "@effect/atom-react";2import { useAtomValue, useAtomSet } from "@effect/atom-react";
3import * as Exit from "effect/Exit";3import * as Exit from "effect/Exit";
4import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";4import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
5import { graphqlSourceAtom, graphqlSourceBindingsAtom, updateGraphqlSource } from "./atoms";5import {
6import { useScope } from "@executor-js/react/api/scope-context";6 graphqlSourceAtom,
7import { sourceWriteKeys } from "@executor-js/react/api/reactivity-keys";7 graphqlSourceBindingsAtom,
8 setGraphqlSourceBinding,
9 updateGraphqlSource,
10} from "./atoms";
11import { connectionsAtom } from "@executor-js/react/api/atoms";
12import { useScope, useScopeStack } from "@executor-js/react/api/scope-context";
13import { connectionWriteKeys, sourceWriteKeys } from "@executor-js/react/api/reactivity-keys";
8import { useSecretPickerSecrets } from "@executor-js/react/plugins/use-secret-picker-secrets";14import { useSecretPickerSecrets } from "@executor-js/react/plugins/use-secret-picker-secrets";
9import {15import {
10 HttpCredentialsEditor,16 HttpCredentialsEditor,
17 serializeHttpCredentials,
11 serializeScopedHttpCredentials,18 serializeScopedHttpCredentials,
12 type HttpCredentialsState,19 type HttpCredentialsState,
13} from "@executor-js/react/plugins/http-credentials";20} from "@executor-js/react/plugins/http-credentials";
14import {21import {
22 effectiveCredentialBindingForScope,
15 httpCredentialsFromConfiguredCredentialBindings,23 httpCredentialsFromConfiguredCredentialBindings,
16 initialCredentialTargetScope,24 initialCredentialTargetScope,
17} from "@executor-js/react/plugins/credential-bindings";25} from "@executor-js/react/plugins/credential-bindings";
18import {26import { slugifyNamespace, useSourceIdentity } from "@executor-js/react/plugins/source-identity";
19 SourceIdentityFields,27import { useCredentialTargetScope } from "@executor-js/react/plugins/credential-target-scope";
20 useSourceIdentity,
21} from "@executor-js/react/plugins/source-identity";
22import {
23 CredentialTargetScopeSelector,
24 useCredentialTargetScope,
25} from "@executor-js/react/plugins/credential-target-scope";
26import { Button } from "@executor-js/react/components/button";28import { Button } from "@executor-js/react/components/button";
27import { FilterTabs } from "@executor-js/react/components/filter-tabs";29import { FilterTabs } from "@executor-js/react/components/filter-tabs";
28import {30import { SourceOAuthConnectionControl } from "@executor-js/react/plugins/source-oauth-connection";
29 CardStack,
30 CardStackContent,
31 CardStackEntryField,
32} from "@executor-js/react/components/card-stack";
33import { Input } from "@executor-js/react/components/input";
34import { Badge } from "@executor-js/react/components/badge";31import { Badge } from "@executor-js/react/components/badge";
35import { ScopeId } from "@executor-js/sdk/core";32import { ScopeId } from "@executor-js/sdk/core";
33import { GraphqlSourceFields } from "./GraphqlSourceFields";
36import {34import {
37 GRAPHQL_OAUTH_CONNECTION_SLOT,35 GRAPHQL_OAUTH_CONNECTION_SLOT,
38 type GraphqlCredentialInput,36 type GraphqlCredentialInput,
37 GraphqlSourceBindingInput,
39 type GraphqlSourceBindingRef,38 type GraphqlSourceBindingRef,
40} from "../sdk/types";39} from "../sdk/types";
41import type { StoredGraphqlSource } from "../sdk/store";40import type { StoredGraphqlSource } from "../sdk/store";
@@ -54,14 +53,23 @@ function EditForm(props: { · 12 unmodified lines
54 onSave: () => void;53 onSave: () => void;
55}) {54}) {
56 const displayScope = useScope();55 const displayScope = useScope();
56 const scopeStack = useScopeStack();
57 const sourceScope = ScopeId.make(props.initial.scope);57 const sourceScope = ScopeId.make(props.initial.scope);
58 const { credentialTargetScope, setCredentialTargetScope, credentialScopeOptions } =58 const { credentialTargetScope, credentialScopeOptions } = useCredentialTargetScope({
59 useCredentialTargetScope({59 sourceScope,
60 sourceScope,60 initialTargetScope: initialCredentialTargetScope(sourceScope, props.bindings),
61 initialTargetScope: initialCredentialTargetScope(sourceScope, props.bindings),61 });
62 });62 const {
63 credentialTargetScope: oauthCredentialTargetScope,
64 setCredentialTargetScope: setOAuthCredentialTargetScope,
65 } = useCredentialTargetScope({
66 sourceScope,
67 initialTargetScope: initialCredentialTargetScope(sourceScope, props.bindings),
68 });
63 const doUpdate = useAtomSet(updateGraphqlSource, { mode: "promiseExit" });69 const doUpdate = useAtomSet(updateGraphqlSource, { mode: "promiseExit" });
70 const setBinding = useAtomSet(setGraphqlSourceBinding, { mode: "promise" });
64 const secretList = useSecretPickerSecrets();71 const secretList = useSecretPickerSecrets();
72 const connectionsResult = useAtomValue(connectionsAtom(displayScope));
65 73
66 const identity = useSourceIdentity({74 const identity = useSourceIdentity({
67 fallbackName: props.initial.name,75 fallbackName: props.initial.name,
@@ -84,6 +92,23 @@ function EditForm(props: { · 16 unmodified lines
84 const identityDirty = identity.name.trim() !== props.initial.name.trim();92 const identityDirty = identity.name.trim() !== props.initial.name.trim();
85 const metadataDirty = identityDirty || endpoint.trim() !== props.initial.endpoint.trim();93 const metadataDirty = identityDirty || endpoint.trim() !== props.initial.endpoint.trim();
86 const dirty = metadataDirty || credentialsDirty || authDirty;94 const dirty = metadataDirty || credentialsDirty || authDirty;
95 const oauth2 = props.initial.auth.kind === "oauth2" ? props.initial.auth : null;
96 const connections = AsyncResult.isSuccess(connectionsResult) ? connectionsResult.value : [];
97 const scopeRanks = new Map(scopeStack.map((scope, index) => [scope.id, index] as const));
98 const connectionBinding = oauth2
99 ? effectiveCredentialBindingForScope(
100 props.bindings,
101 oauth2.connectionSlot,
102 oauthCredentialTargetScope,
103 scopeRanks,
104 )
105 : null;
106 const boundConnectionId =
107 connectionBinding?.value.kind === "connection" ? connectionBinding.value.connectionId : null;
108 const isConnected =
109 boundConnectionId !== null &&
110 connections.some((connection) => connection.id === boundConnectionId);
111 const oauthRequestCredentials = serializeHttpCredentials(credentials);
87 112
88 const handleCredentialsChange = (next: HttpCredentialsState) => {113 const handleCredentialsChange = (next: HttpCredentialsState) => {
89 setCredentials(next);114 setCredentials(next);
@@ -164,31 +189,11 @@ function EditForm(props: { · 74 unmodified lines
164 </Badge>189 </Badge>
165 </div>190 </div>
166 191
167 <SourceIdentityFields identity={identity} namespaceReadOnly />192 <GraphqlSourceFields
168 193 endpoint={endpoint}
169 <CardStack>194 onEndpointChange={setEndpoint}
170 <CardStackContent className="border-t-0">195 identity={identity}
171 <CardStackEntryField label="Endpoint">196 namespaceReadOnly
172 <Input
173 value={endpoint}
174 onChange={(e) => {
175 setEndpoint((e.target as HTMLInputElement).value);
176 }}
177 placeholder="https://api.example.com/graphql"
178 className="font-mono text-sm"
179 />
180 </CardStackEntryField>
181 </CardStackContent>
182 </CardStack>
183
184 <CredentialTargetScopeSelector
185 value={credentialTargetScope}
186 options={credentialScopeOptions}
187 onChange={(targetScope) => {
188 setCredentialTargetScope(targetScope);
189 setCredentialsDirty(true);
190 }}
191 description="Choose where updated GraphQL credentials are saved."
192 />197 />
193 198
194 <HttpCredentialsEditor199 <HttpCredentialsEditor
@@ -224,6 +229,37 @@ function EditForm(props: { · 29 unmodified lines
224 )}229 )}
225 </section>230 </section>
226 231
232 {oauth2 && (
233 <SourceOAuthConnectionControl
234 popupName="graphql-oauth"
235 pluginId="graphql"
236 namespace={slugifyNamespace(props.initial.namespace) || "graphql"}
237 fallbackNamespace="graphql"
238 endpoint={endpoint.trim()}
239 tokenScope={oauthCredentialTargetScope}
240 onTokenScopeChange={setOAuthCredentialTargetScope}
241 credentialScopeOptions={credentialScopeOptions}
242 connectionId={boundConnectionId}
243 sourceLabel={`${identity.name.trim() || props.initial.namespace || "GraphQL"} OAuth`}
244 headers={oauthRequestCredentials.headers}
245 queryParams={oauthRequestCredentials.queryParams}
246 isConnected={isConnected}
247 onConnected={async (connectionId) => {
248 await setBinding({
249 params: { scopeId: oauthCredentialTargetScope },
250 payload: new GraphqlSourceBindingInput({
251 sourceId: props.sourceId,
252 sourceScope,
253 scope: oauthCredentialTargetScope,
254 slot: oauth2.connectionSlot,
255 value: { kind: "connection", connectionId },
256 }),
257 reactivityKeys: [...sourceWriteKeys, ...connectionWriteKeys],
258 });
259 }}
260 />
261 )}
262
227 {error && (263 {error && (
228 <div className="rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2">264 <div className="rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2">
229 <p className="text-sm text-destructive">{error}</p>265 <p className="text-sm text-destructive">{error}</p>
packages/plugins/graphql/src/react/GraphqlSourceFields.tsx+42−0
@@ -0,0 +1,42 @@
1import {
2 CardStack,
3 CardStackContent,
4 CardStackEntryField,
5} from "@executor-js/react/components/card-stack";
6import { Input } from "@executor-js/react/components/input";
7import {
8 SourceIdentityFieldRows,
9 type SourceIdentity,
10} from "@executor-js/react/plugins/source-identity";
11
12export function GraphqlSourceFields(props: {
13 readonly endpoint: string;
14 readonly onEndpointChange: (endpoint: string) => void;
15 readonly identity: SourceIdentity;
16 readonly endpointDisabled?: boolean;
17 readonly namespaceReadOnly?: boolean;
18}) {
19 return (
20 <CardStack>
21 <CardStackContent className="border-t-0">
22 <CardStackEntryField
23 label="Endpoint"
24 hint="The endpoint will be introspected to discover available queries and mutations."
25 >
26 <Input
27 value={props.endpoint}
28 onChange={(e) => props.onEndpointChange((e.target as HTMLInputElement).value)}
29 placeholder="https://api.example.com/graphql"
30 className="font-mono text-sm"
31 disabled={props.endpointDisabled}
32 />
33 </CardStackEntryField>
34 <SourceIdentityFieldRows
35 identity={props.identity}
36 namePlaceholder="e.g. Shopify API"
37 namespaceReadOnly={props.namespaceReadOnly}
38 />
39 </CardStackContent>
40 </CardStack>
41 );
42}
packages/plugins/graphql/src/react/GraphqlSourceSummary.tsx+71−2
@@ -1,7 +1,76 @@
1import { useAtomValue } from "@effect/atom-react";
2import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
3
4import { connectionsAtom } from "@executor-js/react/api/atoms";
5import { useScope, useScopeStack, useUserScope } from "@executor-js/react/api/scope-context";
6import {
7 SourceCredentialNotice,
8 SourceCredentialStatusBadge,
9 missingSourceCredentialLabels,
10 type SourceCredentialSlot,
11} from "@executor-js/react/plugins/source-credential-status";
12import { ScopeId } from "@executor-js/sdk/core";
13
14import { graphqlSourceAtom, graphqlSourceBindingsAtom } from "./atoms";
15import type { StoredGraphqlSource } from "../sdk/store";
16
17const sourceCredentialSlots = (source: StoredGraphqlSource): readonly SourceCredentialSlot[] => {
18 const slots: SourceCredentialSlot[] = [];
19 for (const [name, value] of Object.entries(source.headers)) {
20 if (typeof value !== "string") slots.push({ kind: "secret", slot: value.slot, label: name });
21 }
22 for (const [name, value] of Object.entries(source.queryParams)) {
23 if (typeof value !== "string") slots.push({ kind: "secret", slot: value.slot, label: name });
24 }
25 if (source.auth.kind === "oauth2") {
26 slots.push({
27 kind: "connection",
28 slot: source.auth.connectionSlot,
29 label: "OAuth sign-in",
30 });
31 }
32 return slots;
33};
34
1export default function GraphqlSourceSummary(props: {35export default function GraphqlSourceSummary(props: {
2 sourceId: string;36 sourceId: string;
3 variant?: "badge" | "panel";37 variant?: "badge" | "panel";
38 onAction?: () => void;
4}) {39}) {
5 if (props.variant === "panel") return null;40 const displayScope = useScope();
6 return <span>GraphQL · {props.sourceId}</span>;41 const userScope = useUserScope();
42 const scopeStack = useScopeStack();
43 const sourceResult = useAtomValue(graphqlSourceAtom(displayScope, props.sourceId));
44 const source =
45 AsyncResult.isSuccess(sourceResult) && sourceResult.value ? sourceResult.value : null;
46 const sourceScope = source ? ScopeId.make(source.scope) : displayScope;
47 const bindingsResult = useAtomValue(
48 graphqlSourceBindingsAtom(displayScope, props.sourceId, sourceScope),
49 );
50 const connectionsResult = useAtomValue(connectionsAtom(displayScope));
51
52 if (!source) return null;
53 const slots = sourceCredentialSlots(source as StoredGraphqlSource);
54 if (slots.length === 0) return null;
55 if (!AsyncResult.isSuccess(bindingsResult) || !AsyncResult.isSuccess(connectionsResult)) {
56 return props.variant === "panel" ? null : (
57 <SourceCredentialStatusBadge missing={["credentials"]} />
58 );
59 }
60
61 const scopeRanks = new Map(scopeStack.map((scope, index) => [scope.id, index] as const));
62 const liveConnectionIds = new Set(connectionsResult.value.map((connection) => connection.id));
63 const missing = missingSourceCredentialLabels({
64 slots,
65 bindings: bindingsResult.value,
66 targetScope: userScope,
67 scopeRanks,
68 liveConnectionIds,
69 });
70
71 if (props.variant === "panel") {
72 return <SourceCredentialNotice missing={missing} onAction={props.onAction} />;
73 }
74
75 return <SourceCredentialStatusBadge missing={missing} />;
7}76}
packages/plugins/graphql/src/react/source-plugin.ts+0−3
@@ -5,7 +5,6 @@ import { graphqlPresets } from "../sdk/presets"; · 4 unmodified lines
5const importAdd = () => import("./AddGraphqlSource");5const importAdd = () => import("./AddGraphqlSource");
6const importEdit = () => import("./EditGraphqlSource");6const importEdit = () => import("./EditGraphqlSource");
7const importSummary = () => import("./GraphqlSourceSummary");7const importSummary = () => import("./GraphqlSourceSummary");
8const importSignIn = () => import("./GraphqlSignInButton");
9 8
10export const graphqlSourcePlugin: SourcePlugin = {9export const graphqlSourcePlugin: SourcePlugin = {
11 key: "graphql",10 key: "graphql",
@@ -13,12 +12,10 @@ export const graphqlSourcePlugin: SourcePlugin = { · 1 unmodified line
13 add: lazy(importAdd),12 add: lazy(importAdd),
14 edit: lazy(importEdit),13 edit: lazy(importEdit),
15 summary: lazy(importSummary),14 summary: lazy(importSummary),
16 signIn: lazy(importSignIn),
17 presets: graphqlPresets,15 presets: graphqlPresets,
18 preload: () => {16 preload: () => {
19 void importAdd();17 void importAdd();
20 void importEdit();18 void importEdit();
21 void importSummary();19 void importSummary();
22 void importSignIn();
23 },20 },
24};21};
packages/plugins/mcp/src/react/AddMcpSource.tsx+12−121
@@ -10,22 +10,14 @@ import { · 9 unmodified lines
10 CardStack,10 CardStack,
11 CardStackContent,11 CardStackContent,
12 CardStackEntry,12 CardStackEntry,
13 CardStackEntryActions,
14 CardStackEntryContent,
15 CardStackEntryDescription,
16 CardStackEntryField,13 CardStackEntryField,
17 CardStackEntryMedia,
18 CardStackEntryTitle,
19} from "@executor-js/react/components/card-stack";14} from "@executor-js/react/components/card-stack";
20import { FieldError, FieldLabel } from "@executor-js/react/components/field";15import { FieldLabel } from "@executor-js/react/components/field";
21import { FilterTabs } from "@executor-js/react/components/filter-tabs";16import { FilterTabs } from "@executor-js/react/components/filter-tabs";
22import { FloatActions } from "@executor-js/react/components/float-actions";17import { FloatActions } from "@executor-js/react/components/float-actions";
23import { Input } from "@executor-js/react/components/input";18import { Input } from "@executor-js/react/components/input";
24import { Label } from "@executor-js/react/components/label";19import { Label } from "@executor-js/react/components/label";
25import { Badge } from "@executor-js/react/components/badge";20import { Spinner } from "@executor-js/react/components/spinner";
26import { Skeleton } from "@executor-js/react/components/skeleton";
27import { SourceFavicon } from "@executor-js/react/components/source-favicon";
28import { IOSSpinner, Spinner } from "@executor-js/react/components/spinner";
29import { Textarea } from "@executor-js/react/components/textarea";21import { Textarea } from "@executor-js/react/components/textarea";
30import {22import {
31 emptyHttpCredentials,23 emptyHttpCredentials,
@@ -37,7 +29,6 @@ import { · 5 unmodified lines
37import {29import {
38 sourceDisplayNameFromUrl,30 sourceDisplayNameFromUrl,
39 slugifyNamespace,31 slugifyNamespace,
40 SourceIdentityFieldRows,
41 SourceIdentityFields,32 SourceIdentityFields,
42 useSourceIdentity,33 useSourceIdentity,
43} from "@executor-js/react/plugins/source-identity";34} from "@executor-js/react/plugins/source-identity";
@@ -57,6 +48,7 @@ import { · 13 unmodified lines
57type RemoteAuthMode = "none" | "oauth2";48type RemoteAuthMode = "none" | "oauth2";
58import { sourceWriteKeys } from "@executor-js/react/api/reactivity-keys";49import { sourceWriteKeys } from "@executor-js/react/api/reactivity-keys";
59import { probeMcpEndpoint, addMcpSourceOptimistic } from "./atoms";50import { probeMcpEndpoint, addMcpSourceOptimistic } from "./atoms";
51import { McpRemoteSourceFields } from "./McpRemoteSourceFields";
60import { mcpPresets, type McpPreset } from "../sdk/presets";52import { mcpPresets, type McpPreset } from "../sdk/presets";
61import { MCP_OAUTH_CONNECTION_SLOT, type McpCredentialInput } from "../sdk/types";53import { MCP_OAUTH_CONNECTION_SLOT, type McpCredentialInput } from "../sdk/types";
62 54
@@ -599,116 +591,15 @@ export default function AddMcpSource(props: { · 536 unmodified lines
599 591
600 {transport === "remote" ? (592 {transport === "remote" ? (
601 <>593 <>
602 {/* Server info card (shown above URL input after probing) */}594 <McpRemoteSourceFields
603 {probe ? (595 url={state.url}
604 <CardStack>596 onUrlChange={(url) => dispatch({ type: "set-url", url })}
605 <CardStackContent className="border-t-0">597 identity={remoteIdentity}
606 <CardStackEntry>598 preview={probe}
607 <CardStackEntryMedia>599 probing={isProbing}
608 <SourceFavicon url={state.url} size={32} />600 error={probeError}
609 </CardStackEntryMedia>601 onRetry={handleProbe}
610 <CardStackEntryContent>602 />
611 <CardStackEntryTitle>{probe.serverName ?? probe.name}</CardStackEntryTitle>
612 <CardStackEntryDescription>
613 {probe.connected
614 ? `${probe.toolCount} tool${probe.toolCount !== 1 ? "s" : ""} available`
615 : "OAuth required to discover tools"}
616 </CardStackEntryDescription>
617 </CardStackEntryContent>
618 <CardStackEntryActions>
619 {probe.connected ? (
620 <Badge
621 variant="outline"
622 className="border-emerald-500/20 bg-emerald-500/10 text-[10px] text-emerald-600 dark:text-emerald-400"
623 >
624 Connected
625 </Badge>
626 ) : (
627 <Badge
628 variant="outline"
629 className="border-amber-500/20 bg-amber-500/10 text-[10px] text-amber-600 dark:text-amber-400"
630 >
631 OAuth required
632 </Badge>
633 )}
634 </CardStackEntryActions>
635 </CardStackEntry>
636 <SourceIdentityFieldRows identity={remoteIdentity} namePlaceholder="e.g. Linear" />
637 <CardStackEntryField label="Server URL">
638 <Input
639 value={state.url}
640 onChange={(e) =>
641 dispatch({
642 type: "set-url",
643 url: (e.target as HTMLInputElement).value,
644 })
645 }
646 placeholder="https://mcp.example.com"
647 className="w-full font-mono text-sm"
648 />
649 </CardStackEntryField>
650 </CardStackContent>
651 </CardStack>
652 ) : isProbing ? (
653 <CardStack>
654 <CardStackContent className="border-t-0">
655 <CardStackEntry>
656 <CardStackEntryMedia>
657 <Skeleton className="size-4 rounded" />
658 </CardStackEntryMedia>
659 <CardStackEntryContent>
660 <Skeleton className="h-4 w-40" />
661 <Skeleton className="mt-1 h-3 w-32" />
662 </CardStackEntryContent>
663 <CardStackEntryActions>
664 <Skeleton className="h-4 w-20 rounded-full" />
665 </CardStackEntryActions>
666 </CardStackEntry>
667 </CardStackContent>
668 </CardStack>
669 ) : null}
670
671 {!probe && (
672 <CardStack>
673 <CardStackContent className="border-t-0">
674 <CardStackEntryField label="Server URL">
675 <div className="relative">
676 <Input
677 value={state.url}
678 onChange={(e) =>
679 dispatch({
680 type: "set-url",
681 url: (e.target as HTMLInputElement).value,
682 })
683 }
684 placeholder="https://mcp.example.com"
685 className="w-full pr-9 font-mono text-sm"
686 aria-invalid={probeError ? true : undefined}
687 />
688 {isProbing && (
689 <div className="pointer-events-none absolute right-2 top-1/2 -translate-y-1/2">
690 <IOSSpinner className="size-4" />
691 </div>
692 )}
693 </div>
694 {probeError && (
695 <div className="mt-2 space-y-2">
696 <FieldError>{probeError}</FieldError>
697 <Button
698 type="button"
699 variant="outline"
700 size="sm"
701 onClick={handleProbe}
702 className="h-7 px-2 text-xs"
703 >
704 Try again
705 </Button>
706 </div>
707 )}
708 </CardStackEntryField>
709 </CardStackContent>
710 </CardStack>
711 )}
712 603
713 <HttpCredentialsEditor604 <HttpCredentialsEditor
714 credentials={remoteCredentials}605 credentials={remoteCredentials}
packages/plugins/mcp/src/react/EditMcpSource.tsx+93−47
@@ -2,37 +2,39 @@ import { useState } from "react"; · 1 unmodified line
2import { useAtomValue, useAtomSet } from "@effect/atom-react";2import { useAtomValue, useAtomSet } from "@effect/atom-react";
3import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";3import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
4import * as Exit from "effect/Exit";4import * as Exit from "effect/Exit";
5import { mcpSourceAtom, mcpSourceBindingsAtom, updateMcpSource } from "./atoms";
6import { useScope } from "@executor-js/react/api/scope-context";
7import { sourceWriteKeys } from "@executor-js/react/api/reactivity-keys";
8import {5import {
9 SourceIdentityFields,6 mcpSourceAtom,
10 useSourceIdentity,7 mcpSourceBindingsAtom,
11} from "@executor-js/react/plugins/source-identity";8 setMcpSourceBinding,
12import {9 updateMcpSource,
13 CredentialTargetScopeSelector,10} from "./atoms";
14 useCredentialTargetScope,11import { connectionsAtom } from "@executor-js/react/api/atoms";
15} from "@executor-js/react/plugins/credential-target-scope";12import { useScope, useScopeStack } from "@executor-js/react/api/scope-context";
13import { connectionWriteKeys, sourceWriteKeys } from "@executor-js/react/api/reactivity-keys";
14import { slugifyNamespace, useSourceIdentity } from "@executor-js/react/plugins/source-identity";
15import { useCredentialTargetScope } from "@executor-js/react/plugins/credential-target-scope";
16import { useSecretPickerSecrets } from "@executor-js/react/plugins/use-secret-picker-secrets";16import { useSecretPickerSecrets } from "@executor-js/react/plugins/use-secret-picker-secrets";
17import {17import {
18 HttpCredentialsEditor,18 HttpCredentialsEditor,
19 serializeHttpCredentials,
19 serializeScopedHttpCredentials,20 serializeScopedHttpCredentials,
20 type HttpCredentialsState,21 type HttpCredentialsState,
21} from "@executor-js/react/plugins/http-credentials";22} from "@executor-js/react/plugins/http-credentials";
22import {23import {
24 effectiveCredentialBindingForScope,
23 httpCredentialsFromConfiguredCredentialBindings,25 httpCredentialsFromConfiguredCredentialBindings,
24 initialCredentialTargetScope,26 initialCredentialTargetScope,
25} from "@executor-js/react/plugins/credential-bindings";27} from "@executor-js/react/plugins/credential-bindings";
28import { SourceOAuthConnectionControl } from "@executor-js/react/plugins/source-oauth-connection";
26import { Button } from "@executor-js/react/components/button";29import { Button } from "@executor-js/react/components/button";
27import {
28 CardStack,
29 CardStackContent,
30 CardStackEntryField,
31} from "@executor-js/react/components/card-stack";
32import { Input } from "@executor-js/react/components/input";
33import { Badge } from "@executor-js/react/components/badge";30import { Badge } from "@executor-js/react/components/badge";
34import { ScopeId } from "@executor-js/sdk/core";31import { ScopeId } from "@executor-js/sdk/core";
35import type { McpCredentialInput, McpSourceBindingRef } from "../sdk/types";32import { McpRemoteSourceFields } from "./McpRemoteSourceFields";
33import {
34 McpSourceBindingInput,
35 type McpCredentialInput,
36 type McpSourceBindingRef,
37} from "../sdk/types";
36import type { McpStoredSourceSchemaType } from "../sdk/stored-source";38import type { McpStoredSourceSchemaType } from "../sdk/stored-source";
37 39
38// ---------------------------------------------------------------------------40// ---------------------------------------------------------------------------
@@ -46,14 +48,23 @@ function RemoteEditForm(props: { · 7 unmodified lines
46 onSave: () => void;48 onSave: () => void;
47}) {49}) {
48 const displayScope = useScope();50 const displayScope = useScope();
51 const scopeStack = useScopeStack();
49 const sourceScope = ScopeId.make(props.initial.scope);52 const sourceScope = ScopeId.make(props.initial.scope);
50 const { credentialTargetScope, setCredentialTargetScope, credentialScopeOptions } =53 const { credentialTargetScope, credentialScopeOptions } = useCredentialTargetScope({
51 useCredentialTargetScope({54 sourceScope,
52 sourceScope,55 initialTargetScope: initialCredentialTargetScope(sourceScope, props.bindings),
53 initialTargetScope: initialCredentialTargetScope(sourceScope, props.bindings),56 });
54 });57 const {
58 credentialTargetScope: oauthCredentialTargetScope,
59 setCredentialTargetScope: setOAuthCredentialTargetScope,
60 } = useCredentialTargetScope({
61 sourceScope,
62 initialTargetScope: initialCredentialTargetScope(sourceScope, props.bindings),
63 });
55 const doUpdate = useAtomSet(updateMcpSource, { mode: "promiseExit" });64 const doUpdate = useAtomSet(updateMcpSource, { mode: "promiseExit" });
65 const setBinding = useAtomSet(setMcpSourceBinding, { mode: "promise" });
56 const secretList = useSecretPickerSecrets();66 const secretList = useSecretPickerSecrets();
67 const connectionsResult = useAtomValue(connectionsAtom(displayScope));
57 68
58 const identity = useSourceIdentity({69 const identity = useSourceIdentity({
59 fallbackName: props.initial.name,70 fallbackName: props.initial.name,
@@ -74,6 +85,23 @@ function RemoteEditForm(props: { · 14 unmodified lines
74 const identityDirty = identity.name.trim() !== props.initial.name.trim();85 const identityDirty = identity.name.trim() !== props.initial.name.trim();
75 const metadataDirty = identityDirty || endpoint.trim() !== props.initial.config.endpoint.trim();86 const metadataDirty = identityDirty || endpoint.trim() !== props.initial.config.endpoint.trim();
76 const dirty = metadataDirty || credentialsDirty;87 const dirty = metadataDirty || credentialsDirty;
88 const oauth2 = props.initial.config.auth.kind === "oauth2" ? props.initial.config.auth : null;
89 const connections = AsyncResult.isSuccess(connectionsResult) ? connectionsResult.value : [];
90 const scopeRanks = new Map(scopeStack.map((scope, index) => [scope.id, index] as const));
91 const connectionBinding = oauth2
92 ? effectiveCredentialBindingForScope(
93 props.bindings,
94 oauth2.connectionSlot,
95 oauthCredentialTargetScope,
96 scopeRanks,
97 )
98 : null;
99 const boundConnectionId =
100 connectionBinding?.value.kind === "connection" ? connectionBinding.value.connectionId : null;
101 const isConnected =
102 boundConnectionId !== null &&
103 connections.some((connection) => connection.id === boundConnectionId);
104 const oauthRequestCredentials = serializeHttpCredentials(credentials);
77 105
78 const handleCredentialsChange = (next: HttpCredentialsState) => {106 const handleCredentialsChange = (next: HttpCredentialsState) => {
79 setCredentials(next);107 setCredentials(next);
@@ -137,32 +165,17 @@ function RemoteEditForm(props: { · 57 unmodified lines
137 </Badge>165 </Badge>
138 </div>166 </div>
139 167
140 <SourceIdentityFields identity={identity} namespaceReadOnly />168 <McpRemoteSourceFields
141 169 url={endpoint}
142 {/* Endpoint */}170 onUrlChange={setEndpoint}
143 <CardStack>171 identity={identity}
144 <CardStackContent className="border-t-0">172 preview={{
145 <CardStackEntryField label="Endpoint">173 name: props.initial.name,
146 <Input174 serverName: props.initial.name,
147 value={endpoint}175 connected: true,
148 onChange={(e) => {176 toolCount: null,
149 setEndpoint((e.target as HTMLInputElement).value);
150 }}
151 placeholder="https://mcp.example.com"
152 className="font-mono text-sm"
153 />
154 </CardStackEntryField>
155 </CardStackContent>
156 </CardStack>
157
158 <CredentialTargetScopeSelector
159 value={credentialTargetScope}
160 options={credentialScopeOptions}
161 onChange={(targetScope) => {
162 setCredentialTargetScope(targetScope);
163 setCredentialsDirty(true);
164 }}177 }}
165 description="Choose where updated MCP credentials are saved."178 namespaceReadOnly
166 />179 />
167 180
168 <HttpCredentialsEditor181 <HttpCredentialsEditor
@@ -175,6 +188,39 @@ function RemoteEditForm(props: { · 6 unmodified lines
175 bindingScopeOptions={credentialScopeOptions}188 bindingScopeOptions={credentialScopeOptions}
176 />189 />
177 190
191 {oauth2 && (
192 <SourceOAuthConnectionControl
193 popupName="mcp-oauth"
194 pluginId="mcp"
195 namespace={slugifyNamespace(props.initial.namespace) || "mcp"}
196 fallbackNamespace="mcp"
197 endpoint={endpoint.trim()}
198 tokenScope={oauthCredentialTargetScope}
199 onTokenScopeChange={setOAuthCredentialTargetScope}
200 credentialScopeOptions={credentialScopeOptions}
201 connectionId={boundConnectionId}
202 sourceLabel={`${identity.name.trim() || props.initial.namespace || "MCP"} OAuth`}
203 headers={oauthRequestCredentials.headers}
204 queryParams={oauthRequestCredentials.queryParams}
205 isConnected={isConnected}
206 onConnected={async (connectionId) => {
207 await setBinding({
208 params: { scopeId: oauthCredentialTargetScope },
209 payload: new McpSourceBindingInput({
210 sourceId: props.sourceId,
211 sourceScope,
212 scope: oauthCredentialTargetScope,
213 slot: oauth2.connectionSlot,
214 value: { kind: "connection", connectionId },
215 }),
216 reactivityKeys: [...sourceWriteKeys, ...connectionWriteKeys],
217 });
218 }}
219 reconnectingLabel="Reconnecting…"
220 signingInLabel="Signing in…"
221 />
222 )}
223
178 {error && (224 {error && (
179 <div className="rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2">225 <div className="rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2">
180 <p className="text-sm text-destructive">{error}</p>226 <p className="text-sm text-destructive">{error}</p>
packages/plugins/mcp/src/react/McpRemoteSourceFields.tsx+163−0
@@ -0,0 +1,163 @@
1import { Badge } from "@executor-js/react/components/badge";
2import {
3 CardStack,
4 CardStackContent,
5 CardStackEntry,
6 CardStackEntryActions,
7 CardStackEntryContent,
8 CardStackEntryDescription,
9 CardStackEntryField,
10 CardStackEntryMedia,
11 CardStackEntryTitle,
12} from "@executor-js/react/components/card-stack";
13import { FieldError } from "@executor-js/react/components/field";
14import { Input } from "@executor-js/react/components/input";
15import { Skeleton } from "@executor-js/react/components/skeleton";
16import { SourceFavicon } from "@executor-js/react/components/source-favicon";
17import { IOSSpinner } from "@executor-js/react/components/spinner";
18import { Button } from "@executor-js/react/components/button";
19import {
20 SourceIdentityFieldRows,
21 type SourceIdentity,
22} from "@executor-js/react/plugins/source-identity";
23
24export type McpRemoteSourcePreview = {
25 readonly name: string;
26 readonly serverName: string | null;
27 readonly connected: boolean;
28 readonly toolCount: number | null;
29};
30
31export function McpRemoteSourceFields(props: {
32 readonly url: string;
33 readonly onUrlChange: (url: string) => void;
34 readonly identity: SourceIdentity;
35 readonly preview: McpRemoteSourcePreview | null;
36 readonly probing?: boolean;
37 readonly error?: string | null;
38 readonly onRetry?: () => void;
39 readonly namespaceReadOnly?: boolean;
40 readonly urlDisabled?: boolean;
41}) {
42 const previewDescription = props.preview
43 ? props.preview.connected
44 ? props.preview.toolCount === null
45 ? null
46 : `${props.preview.toolCount} tool${props.preview.toolCount !== 1 ? "s" : ""} available`
47 : "OAuth required to discover tools"
48 : null;
49
50 if (props.preview) {
51 return (
52 <CardStack>
53 <CardStackContent className="border-t-0">
54 <CardStackEntry>
55 <CardStackEntryMedia>
56 <SourceFavicon url={props.url} size={32} />
57 </CardStackEntryMedia>
58 <CardStackEntryContent>
59 <CardStackEntryTitle>
60 {props.preview.serverName ?? props.preview.name}
61 </CardStackEntryTitle>
62 {previewDescription ? (
63 <CardStackEntryDescription>{previewDescription}</CardStackEntryDescription>
64 ) : null}
65 </CardStackEntryContent>
66 <CardStackEntryActions>
67 {props.preview.connected ? (
68 <Badge
69 variant="outline"
70 className="border-emerald-500/20 bg-emerald-500/10 text-[10px] text-emerald-600 dark:text-emerald-400"
71 >
72 Connected
73 </Badge>
74 ) : (
75 <Badge
76 variant="outline"
77 className="border-amber-500/20 bg-amber-500/10 text-[10px] text-amber-600 dark:text-amber-400"
78 >
79 OAuth required
80 </Badge>
81 )}
82 </CardStackEntryActions>
83 </CardStackEntry>
84 <SourceIdentityFieldRows
85 identity={props.identity}
86 namePlaceholder="e.g. Linear"
87 namespaceReadOnly={props.namespaceReadOnly}
88 />
89 <CardStackEntryField label="Server URL">
90 <Input
91 value={props.url}
92 onChange={(e) => props.onUrlChange((e.target as HTMLInputElement).value)}
93 placeholder="https://mcp.example.com"
94 className="w-full font-mono text-sm"
95 disabled={props.urlDisabled}
96 />
97 </CardStackEntryField>
98 </CardStackContent>
99 </CardStack>
100 );
101 }
102
103 if (props.probing) {
104 return (
105 <CardStack>
106 <CardStackContent className="border-t-0">
107 <CardStackEntry>
108 <CardStackEntryMedia>
109 <Skeleton className="size-4 rounded" />
110 </CardStackEntryMedia>
111 <CardStackEntryContent>
112 <Skeleton className="h-4 w-40" />
113 <Skeleton className="mt-1 h-3 w-32" />
114 </CardStackEntryContent>
115 <CardStackEntryActions>
116 <Skeleton className="h-4 w-20 rounded-full" />
117 </CardStackEntryActions>
118 </CardStackEntry>
119 </CardStackContent>
120 </CardStack>
121 );
122 }
123
124 return (
125 <CardStack>
126 <CardStackContent className="border-t-0">
127 <CardStackEntryField label="Server URL">
128 <div className="relative">
129 <Input
130 value={props.url}
131 onChange={(e) => props.onUrlChange((e.target as HTMLInputElement).value)}
132 placeholder="https://mcp.example.com"
133 className="w-full pr-9 font-mono text-sm"
134 aria-invalid={props.error ? true : undefined}
135 disabled={props.urlDisabled}
136 />
137 {props.probing && (
138 <div className="pointer-events-none absolute right-2 top-1/2 -translate-y-1/2">
139 <IOSSpinner className="size-4" />
140 </div>
141 )}
142 </div>
143 {props.error && (
144 <div className="mt-2 space-y-2">
145 <FieldError>{props.error}</FieldError>
146 {props.onRetry && (
147 <Button
148 type="button"
149 variant="outline"
150 size="sm"
151 onClick={props.onRetry}
152 className="h-7 px-2 text-xs"
153 >
154 Try again
155 </Button>
156 )}
157 </div>
158 )}
159 </CardStackEntryField>
160 </CardStackContent>
161 </CardStack>
162 );
163}
packages/plugins/mcp/src/react/McpSourceSummary.tsx+93−0
@@ -0,0 +1,93 @@
1import { useAtomValue } from "@effect/atom-react";
2import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
3
4import { connectionsAtom } from "@executor-js/react/api/atoms";
5import { useScope, useScopeStack, useUserScope } from "@executor-js/react/api/scope-context";
6import {
7 SourceCredentialNotice,
8 SourceCredentialStatusBadge,
9 missingSourceCredentialLabels,
10 type SourceCredentialSlot,
11} from "@executor-js/react/plugins/source-credential-status";
12import { ScopeId } from "@executor-js/sdk/core";
13
14import { mcpSourceAtom, mcpSourceBindingsAtom } from "./atoms";
15import type { McpStoredSourceSchemaType } from "../sdk/stored-source";
16
17const sourceCredentialSlots = (
18 source: McpStoredSourceSchemaType,
19): readonly SourceCredentialSlot[] => {
20 if (source.config.transport !== "remote") return [];
21 const slots: SourceCredentialSlot[] = [];
22 for (const [name, value] of Object.entries(source.config.headers ?? {})) {
23 if (typeof value !== "string") slots.push({ kind: "secret", slot: value.slot, label: name });
24 }
25 for (const [name, value] of Object.entries(source.config.queryParams ?? {})) {
26 if (typeof value !== "string") slots.push({ kind: "secret", slot: value.slot, label: name });
27 }
28 const auth = source.config.auth;
29 if (auth.kind === "header") {
30 slots.push({
31 kind: "secret",
32 slot: auth.secretSlot,
33 label: auth.headerName,
34 });
35 }
36 if (auth.kind === "oauth2") {
37 if (auth.clientIdSlot) {
38 slots.push({ kind: "secret", slot: auth.clientIdSlot, label: "Client ID" });
39 }
40 if (auth.clientSecretSlot) {
41 slots.push({ kind: "secret", slot: auth.clientSecretSlot, label: "Client Secret" });
42 }
43 slots.push({
44 kind: "connection",
45 slot: auth.connectionSlot,
46 label: "OAuth sign-in",
47 });
48 }
49 return slots;
50};
51
52export default function McpSourceSummary(props: {
53 readonly sourceId: string;
54 readonly variant?: "badge" | "panel";
55 readonly onAction?: () => void;
56}) {
57 const displayScope = useScope();
58 const userScope = useUserScope();
59 const scopeStack = useScopeStack();
60 const sourceResult = useAtomValue(mcpSourceAtom(displayScope, props.sourceId));
61 const source =
62 AsyncResult.isSuccess(sourceResult) && sourceResult.value ? sourceResult.value : null;
63 const sourceScope = source ? ScopeId.make(source.scope) : displayScope;
64 const bindingsResult = useAtomValue(
65 mcpSourceBindingsAtom(displayScope, props.sourceId, sourceScope),
66 );
67 const connectionsResult = useAtomValue(connectionsAtom(displayScope));
68
69 if (!source) return null;
70 const slots = sourceCredentialSlots(source as McpStoredSourceSchemaType);
71 if (slots.length === 0) return null;
72 if (!AsyncResult.isSuccess(bindingsResult) || !AsyncResult.isSuccess(connectionsResult)) {
73 return props.variant === "panel" ? null : (
74 <SourceCredentialStatusBadge missing={["credentials"]} />
75 );
76 }
77
78 const scopeRanks = new Map(scopeStack.map((scope, index) => [scope.id, index] as const));
79 const liveConnectionIds = new Set(connectionsResult.value.map((connection) => connection.id));
80 const missing = missingSourceCredentialLabels({
81 slots,
82 bindings: bindingsResult.value,
83 targetScope: userScope,
84 scopeRanks,
85 liveConnectionIds,
86 });
87
88 if (props.variant === "panel") {
89 return <SourceCredentialNotice missing={missing} onAction={props.onAction} />;
90 }
91
92 return <SourceCredentialStatusBadge missing={missing} />;
93}
packages/plugins/mcp/src/react/source-plugin.tsx+4−4
@@ -4,11 +4,11 @@ import { mcpPresets } from "../sdk/presets"; · 3 unmodified lines
4 4
5const importAdd = () => import("./AddMcpSource");5const importAdd = () => import("./AddMcpSource");
6const importEdit = () => import("./EditMcpSource");6const importEdit = () => import("./EditMcpSource");
7const importSignIn = () => import("./McpSignInButton");7const importSummary = () => import("./McpSourceSummary");
8 8
9const LazyAddMcpSource = lazy(importAdd);9const LazyAddMcpSource = lazy(importAdd);
10const LazyEditMcpSource = lazy(importEdit);10const LazyEditMcpSource = lazy(importEdit);
11const LazyMcpSignInButton = lazy(importSignIn);11const LazyMcpSourceSummary = lazy(importSummary);
12 12
13type AddProps = ComponentProps<SourcePlugin["add"]>;13type AddProps = ComponentProps<SourcePlugin["add"]>;
14 14
@@ -41,12 +41,12 @@ export const createMcpSourcePlugin = (options?: McpSourcePluginOptions): SourceP · 26 unmodified lines
41 label: "MCP",41 label: "MCP",
42 add: AddWithFlag,42 add: AddWithFlag,
43 edit: LazyEditMcpSource,43 edit: LazyEditMcpSource,
44 signIn: LazyMcpSignInButton,44 summary: LazyMcpSourceSummary,
45 presets,45 presets,
46 preload: () => {46 preload: () => {
47 void importAdd();47 void importAdd();
48 void importEdit();48 void importEdit();
49 void importSignIn();49 void importSummary();
50 },50 },
51 };51 };
52};52};
packages/plugins/openapi/src/react/AddOpenApiSource.tsx+28−67
@@ -34,11 +34,7 @@ import { · 33 unmodified lines
34 type HeaderState,34 type HeaderState,
35} from "@executor-js/react/plugins/secret-header-auth";35} from "@executor-js/react/plugins/secret-header-auth";
36import { CredentialScopeDropdown } from "@executor-js/react/plugins/credential-target-scope";36import { CredentialScopeDropdown } from "@executor-js/react/plugins/credential-target-scope";
37import {37import { slugifyNamespace, useSourceIdentity } from "@executor-js/react/plugins/source-identity";
38 slugifyNamespace,
39 SourceIdentityFieldRows,
40 useSourceIdentity,
41} from "@executor-js/react/plugins/source-identity";
42import { useSecretPickerSecrets } from "@executor-js/react/plugins/use-secret-picker-secrets";38import { useSecretPickerSecrets } from "@executor-js/react/plugins/use-secret-picker-secrets";
43import { Button } from "@executor-js/react/components/button";39import { Button } from "@executor-js/react/components/button";
44import { CopyButton } from "@executor-js/react/components/copy-button";40import { CopyButton } from "@executor-js/react/components/copy-button";
@@ -47,27 +43,21 @@ import { · 2 unmodified lines
47 CollapsibleContent,43 CollapsibleContent,
48 CollapsibleTrigger,44 CollapsibleTrigger,
49} from "@executor-js/react/components/collapsible";45} from "@executor-js/react/components/collapsible";
50import { FreeformCombobox } from "@executor-js/react/components/combobox";
51import {46import {
52 CardStack,47 CardStack,
53 CardStackContent,48 CardStackContent,
54 CardStackEntry,
55 CardStackEntryContent,
56 CardStackEntryDescription,
57 CardStackEntryField,49 CardStackEntryField,
58 CardStackEntryTitle,
59} from "@executor-js/react/components/card-stack";50} from "@executor-js/react/components/card-stack";
60import { FieldLabel } from "@executor-js/react/components/field";51import { FieldLabel } from "@executor-js/react/components/field";
61import { FloatActions } from "@executor-js/react/components/float-actions";52import { FloatActions } from "@executor-js/react/components/float-actions";
62import { HelpTooltip } from "@executor-js/react/components/help-tooltip";53import { HelpTooltip } from "@executor-js/react/components/help-tooltip";
63import { Input } from "@executor-js/react/components/input";
64import { Label } from "@executor-js/react/components/label";54import { Label } from "@executor-js/react/components/label";
65import { Textarea } from "@executor-js/react/components/textarea";55import { Textarea } from "@executor-js/react/components/textarea";
66import { Checkbox } from "@executor-js/react/components/checkbox";56import { Checkbox } from "@executor-js/react/components/checkbox";
67import { SourceFavicon } from "@executor-js/react/components/source-favicon";
68import { RadioGroup, RadioGroupItem } from "@executor-js/react/components/radio-group";57import { RadioGroup, RadioGroupItem } from "@executor-js/react/components/radio-group";
69import { IOSSpinner, Spinner } from "@executor-js/react/components/spinner";58import { IOSSpinner, Spinner } from "@executor-js/react/components/spinner";
70import { addOpenApiSpecOptimistic, previewOpenApiSpec, setOpenApiSourceBinding } from "./atoms";59import { addOpenApiSpecOptimistic, previewOpenApiSpec, setOpenApiSourceBinding } from "./atoms";
60import { OpenApiSourceDetailsFields } from "./OpenApiSourceDetailsFields";
71import type { SpecPreview, HeaderPreset, OAuth2Preset } from "../sdk/preview";61import type { SpecPreview, HeaderPreset, OAuth2Preset } from "../sdk/preview";
72import {62import {
73 headerBindingSlot,63 headerBindingSlot,
@@ -936,61 +926,32 @@ export default function AddOpenApiSource(props: { · 862 unmodified lines
936 926
937 {/* ── Source information card (shown after analysis) ── */}927 {/* ── Source information card (shown after analysis) ── */}
938 {preview ? (928 {preview ? (
939 <CardStack>929 <OpenApiSourceDetailsFields
940 <CardStackContent className="border-t-0">930 title={Option.getOrElse(preview.title, () => "API")}
941 <CardStackEntry>931 description={`${Option.getOrElse(preview.version, () => "")}${
942 {resolvedBaseUrl && <SourceFavicon url={resolvedBaseUrl} size={16} />}932 Option.isSome(preview.version) ? " · " : ""
943 <CardStackEntryContent>933 }${preview.operationCount} operation${preview.operationCount !== 1 ? "s" : ""}${
944 <CardStackEntryTitle>934 preview.tags.length > 0
945 {Option.getOrElse(preview.title, () => "API")}935 ? ` · ${preview.tags.length} tag${preview.tags.length !== 1 ? "s" : ""}`
946 </CardStackEntryTitle>936 : ""
947 <CardStackEntryDescription>937 }`}
948 {Option.getOrElse(preview.version, () => "")}938 identity={identity}
949 {Option.isSome(preview.version) && " · "}939 baseUrl={resolvedBaseUrl}
950 {preview.operationCount} operation940 onBaseUrlChange={setBaseUrl}
951 {preview.operationCount !== 1 ? "s" : ""}941 baseUrlOptions={baseUrlOptions}
952 {preview.tags.length > 0 &&942 specUrl={specUrl}
953 ` · ${preview.tags.length} tag${preview.tags.length !== 1 ? "s" : ""}`}943 onSpecUrlChange={(value) => {
954 </CardStackEntryDescription>944 setSpecUrl(value);
955 </CardStackEntryContent>945 setPreview(null);
956 </CardStackEntry>946 setBaseUrl("");
957 <SourceIdentityFieldRows identity={identity} />947 setCustomHeaders([]);
958 <div className="grid grid-cols-1 md:grid-cols-2">948 setStrategy({ kind: "none" });
959 <CardStackEntryField label="Base URL">949 setOauth2AuthState(null);
960 <FreeformCombobox950 setOauth2Error(null);
961 value={resolvedBaseUrl}951 }}
962 onValueChange={setBaseUrl}952 faviconUrl={resolvedBaseUrl}
963 options={baseUrlOptions}953 baseUrlMissingMessage="A base URL is required to make requests."
964 placeholder="https://api.example.com"954 />
965 className="w-full"
966 inputClassName="font-mono text-sm"
967 />
968
969 {!resolvedBaseUrl && (
970 <p className="text-[11px] text-amber-600 dark:text-amber-400">
971 A base URL is required to make requests.
972 </p>
973 )}
974 </CardStackEntryField>
975 <CardStackEntryField label="Spec URL">
976 <Input
977 value={specUrl}
978 onChange={(e) => {
979 setSpecUrl((e.target as HTMLInputElement).value);
980 setPreview(null);
981 setBaseUrl("");
982 setCustomHeaders([]);
983 setStrategy({ kind: "none" });
984 setOauth2AuthState(null);
985 setOauth2Error(null);
986 }}
987 placeholder="https://api.example.com/openapi.json"
988 className="font-mono text-sm"
989 />
990 </CardStackEntryField>
991 </div>
992 </CardStackContent>
993 </CardStack>
994 ) : null}955 ) : null}
995 956
996 {analyzeError && (957 {analyzeError && (
packages/plugins/openapi/src/react/EditOpenApiSource.tsx+32−41
@@ -44,6 +44,7 @@ import { · 43 unmodified lines
44 setOpenApiSourceBinding,44 setOpenApiSourceBinding,
45 updateOpenApiSource,45 updateOpenApiSource,
46} from "./atoms";46} from "./atoms";
47import { OpenApiSourceDetailsFields } from "./OpenApiSourceDetailsFields";
47import {48import {
48 OPENAPI_OAUTH_CALLBACK_PATH,49 OPENAPI_OAUTH_CALLBACK_PATH,
49 OPENAPI_OAUTH_POPUP_NAME,50 OPENAPI_OAUTH_POPUP_NAME,
@@ -178,6 +179,16 @@ export default function EditOpenApiSource(props: { · 128 unmodified lines
178 const [oauth2EndpointsSaveState, setOAuth2EndpointsSaveState] = useState<179 const [oauth2EndpointsSaveState, setOAuth2EndpointsSaveState] = useState<
179 "idle" | "saving" | "saved"180 "idle" | "saving" | "saved"
180 >("idle");181 >("idle");
182 const editIdentity = useMemo(
183 () => ({
184 name,
185 namespace: props.sourceId,
186 setName,
187 setNamespace: () => {},
188 reset: () => {},
189 }),
190 [name, props.sourceId],
191 );
181 const sourceSaveSeq = useRef(0);192 const sourceSaveSeq = useRef(0);
182 const oauth2EndpointsSaveSeq = useRef(0);193 const oauth2EndpointsSaveSeq = useRef(0);
183 194
@@ -580,47 +591,27 @@ export default function EditOpenApiSource(props: { · 396 unmodified lines
580 <h1 className="text-xl font-semibold text-foreground">OpenAPI Source</h1>591 <h1 className="text-xl font-semibold text-foreground">OpenAPI Source</h1>
581 </div>592 </div>
582 593
583 <CardStack>594 <OpenApiSourceDetailsFields
584 <CardStackContent className="border-t-0">595 title="Source Details"
585 <CardStackEntry>596 description="Name and base URL save automatically."
586 <CardStackEntryContent>597 identity={editIdentity}
587 <CardStackEntryTitle>Source Details</CardStackEntryTitle>598 baseUrl={baseUrl}
588 <CardStackEntryDescription>599 onBaseUrlChange={setBaseUrl}
589 Name and base URL save automatically.600 specUrl={source.config.sourceUrl ?? ""}
590 </CardStackEntryDescription>601 onSpecUrlChange={() => {}}
591 </CardStackEntryContent>602 specUrlDisabled
592 {sourceSaveState !== "idle" && (603 namespaceReadOnly
593 <span className="text-xs text-muted-foreground">604 saveState={sourceSaveState}
594 {sourceSaveState === "saving" ? "Saving…" : "Saved"}605 footer={
595 </span>606 source.config.oauth2
596 )}607 ? `Authentication Template: OAuth2 ${source.config.oauth2.flow}`
597 </CardStackEntry>608 : Object.keys(source.config.headers ?? {}).length > 0
598 <CardStackEntryField label="Name">609 ? `Authentication Template: ${Object.keys(source.config.headers ?? {}).length} header binding${
599 <Input value={name} onChange={(e) => setName((e.target as HTMLInputElement).value)} />610 Object.keys(source.config.headers ?? {}).length === 1 ? "" : "s"
600 </CardStackEntryField>611 }`
601 <CardStackEntryField label="Base URL">612 : "Authentication Template: None"
602 <Input613 }
603 value={baseUrl}614 />
604 onChange={(e) => setBaseUrl((e.target as HTMLInputElement).value)}
605 className="font-mono text-sm"
606 />
607 </CardStackEntryField>
608 <CardStackEntry>
609 <CardStackEntryContent>
610 <CardStackEntryTitle>Authentication Template</CardStackEntryTitle>
611 <CardStackEntryDescription>
612 {source.config.oauth2
613 ? `OAuth2 ${source.config.oauth2.flow}`
614 : Object.keys(source.config.headers ?? {}).length > 0
615 ? `${Object.keys(source.config.headers ?? {}).length} header binding${
616 Object.keys(source.config.headers ?? {}).length === 1 ? "" : "s"
617 }`
618 : "None"}
619 </CardStackEntryDescription>
620 </CardStackEntryContent>
621 </CardStackEntry>
622 </CardStackContent>
623 </CardStack>
624 615
625 <CardStack>616 <CardStack>
626 <CardStackContent className="border-t-0">617 <CardStackContent className="border-t-0">
packages/plugins/openapi/src/react/OpenApiSourceDetailsFields.tsx+108−0
@@ -0,0 +1,108 @@
1import {
2 CardStack,
3 CardStackContent,
4 CardStackEntry,
5 CardStackEntryContent,
6 CardStackEntryDescription,
7 CardStackEntryField,
8 CardStackEntryTitle,
9} from "@executor-js/react/components/card-stack";
10import {
11 FreeformCombobox,
12 type FreeformComboboxOption,
13} from "@executor-js/react/components/combobox";
14import { Input } from "@executor-js/react/components/input";
15import { SourceFavicon } from "@executor-js/react/components/source-favicon";
16import {
17 SourceIdentityFieldRows,
18 type SourceIdentity,
19} from "@executor-js/react/plugins/source-identity";
20
21export function OpenApiSourceDetailsFields(props: {
22 readonly title: string;
23 readonly description?: string;
24 readonly identity: SourceIdentity;
25 readonly baseUrl: string;
26 readonly onBaseUrlChange: (value: string) => void;
27 readonly baseUrlOptions?: readonly FreeformComboboxOption[];
28 readonly specUrl?: string;
29 readonly onSpecUrlChange?: (value: string) => void;
30 readonly faviconUrl?: string;
31 readonly namespaceReadOnly?: boolean;
32 readonly specUrlDisabled?: boolean;
33 readonly saveState?: "idle" | "saving" | "saved";
34 readonly baseUrlMissingMessage?: string;
35 readonly footer?: string;
36}) {
37 const baseUrlOptions = props.baseUrlOptions ?? [];
38
39 return (
40 <CardStack>
41 <CardStackContent className="border-t-0">
42 <CardStackEntry>
43 {props.faviconUrl && <SourceFavicon url={props.faviconUrl} size={16} />}
44 <CardStackEntryContent>
45 <CardStackEntryTitle>{props.title}</CardStackEntryTitle>
46 {props.description && (
47 <CardStackEntryDescription>{props.description}</CardStackEntryDescription>
48 )}
49 </CardStackEntryContent>
50 {props.saveState && props.saveState !== "idle" && (
51 <span className="text-xs text-muted-foreground">
52 {props.saveState === "saving" ? "Saving…" : "Saved"}
53 </span>
54 )}
55 </CardStackEntry>
56 <SourceIdentityFieldRows
57 identity={props.identity}
58 namespaceReadOnly={props.namespaceReadOnly}
59 />
60 <div className="grid grid-cols-1 md:grid-cols-2">
61 <CardStackEntryField label="Base URL">
62 {baseUrlOptions.length > 0 ? (
63 <FreeformCombobox
64 value={props.baseUrl}
65 onValueChange={props.onBaseUrlChange}
66 options={baseUrlOptions}
67 placeholder="https://api.example.com"
68 className="w-full"
69 inputClassName="font-mono text-sm"
70 />
71 ) : (
72 <Input
73 value={props.baseUrl}
74 onChange={(e) => props.onBaseUrlChange((e.target as HTMLInputElement).value)}
75 placeholder="https://api.example.com"
76 className="font-mono text-sm"
77 />
78 )}
79
80 {props.baseUrlMissingMessage && !props.baseUrl && (
81 <p className="text-[11px] text-amber-600 dark:text-amber-400">
82 {props.baseUrlMissingMessage}
83 </p>
84 )}
85 </CardStackEntryField>
86 {props.specUrl !== undefined && props.onSpecUrlChange && (
87 <CardStackEntryField label="Spec URL">
88 <Input
89 value={props.specUrl}
90 onChange={(e) => props.onSpecUrlChange?.((e.target as HTMLInputElement).value)}
91 placeholder="https://api.example.com/openapi.json"
92 className="font-mono text-sm"
93 disabled={props.specUrlDisabled}
94 />
95 </CardStackEntryField>
96 )}
97 </div>
98 {props.footer && (
99 <CardStackEntry>
100 <CardStackEntryContent>
101 <CardStackEntryTitle>{props.footer}</CardStackEntryTitle>
102 </CardStackEntryContent>
103 </CardStackEntry>
104 )}
105 </CardStackContent>
106 </CardStack>
107 );
108}
packages/plugins/openapi/src/react/OpenApiSourceSummary.tsx+46−47
@@ -3,39 +3,23 @@ import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; · 2 unmodified lines
3 3
4import { connectionsAtom, sourceAtom } from "@executor-js/react/api/atoms";4import { connectionsAtom, sourceAtom } from "@executor-js/react/api/atoms";
5import { Badge } from "@executor-js/react/components/badge";5import { Badge } from "@executor-js/react/components/badge";
6import { Button } from "@executor-js/react/components/button";
7import { useScope, useScopeStack, useUserScope } from "@executor-js/react/api/scope-context";6import { useScope, useScopeStack, useUserScope } from "@executor-js/react/api/scope-context";
8import { ScopeId } from "@executor-js/sdk/core";7import { ScopeId } from "@executor-js/sdk/core";
8import {
9 SourceCredentialNotice,
10 SourceCredentialStatusBadge,
11 missingSourceCredentialLabels,
12 type SourceCredentialSlot,
13} from "@executor-js/react/plugins/source-credential-status";
9 14
10import { openApiSourceAtom, openApiSourceBindingsAtom } from "./atoms";15import { openApiSourceAtom, openApiSourceBindingsAtom } from "./atoms";
11import { effectiveBindingForScope, missingCredentialLabels } from "../sdk/credential-status";16import { effectiveBindingForScope } from "../sdk/credential-status";
12 17import { oauth2ClientSecretSlot, type StoredSourceSchemaType } from "../sdk/store";
13function ConnectedBadge() {
14 return (
15 <Badge
16 variant="outline"
17 className="border-green-500/30 bg-green-500/5 text-[10px] text-green-700 dark:text-green-400"
18 >
19 Connected
20 </Badge>
21 );
22}
23 18
24function OAuthBadge() {19function OAuthBadge() {
25 return <Badge variant="secondary">OAuth</Badge>;20 return <Badge variant="secondary">OAuth</Badge>;
26}21}
27 22
28function NeedsCredentialsBadge() {
29 return (
30 <Badge
31 variant="outline"
32 className="border-amber-500/40 bg-amber-500/10 text-[10px] text-amber-700 dark:text-amber-300"
33 >
34 Needs credentials
35 </Badge>
36 );
37}
38
39function CheckingCredentialsBadge() {23function CheckingCredentialsBadge() {
40 return (24 return (
41 <Badge25 <Badge
@@ -47,6 +31,36 @@ function CheckingCredentialsBadge() { · 5 unmodified lines
47 );31 );
48}32}
49 33
34const effectiveClientSecretSlot = (oauth2: {
35 readonly securitySchemeName: string;
36 readonly clientSecretSlot: string | null;
37}): string => oauth2.clientSecretSlot ?? oauth2ClientSecretSlot(oauth2.securitySchemeName);
38
39const sourceCredentialSlots = (source: StoredSourceSchemaType): readonly SourceCredentialSlot[] => {
40 const slots: SourceCredentialSlot[] = [];
41 for (const [name, value] of Object.entries(source.config.headers ?? {})) {
42 if (typeof value !== "string") slots.push({ kind: "secret", slot: value.slot, label: name });
43 }
44 for (const [name, value] of Object.entries(source.config.queryParams ?? {})) {
45 if (typeof value !== "string") slots.push({ kind: "secret", slot: value.slot, label: name });
46 }
47 const oauth2 = source.config.oauth2;
48 if (oauth2) {
49 slots.push({ kind: "secret", slot: oauth2.clientIdSlot, label: "Client ID" });
50 slots.push({
51 kind: "secret",
52 slot: effectiveClientSecretSlot(oauth2),
53 label: "Client Secret",
54 });
55 slots.push({
56 kind: "connection",
57 slot: oauth2.connectionSlot,
58 label: oauth2.flow === "clientCredentials" ? "OAuth client connection" : "OAuth sign-in",
59 });
60 }
61 return slots;
62};
63
50// The entry row already renders name + id + kind, so this summary64// The entry row already renders name + id + kind, so this summary
51// component only contributes extras — specifically, an OAuth status65// component only contributes extras — specifically, an OAuth status
52// badge when the source has OAuth2 configured. Non-OAuth sources66// badge when the source has OAuth2 configured. Non-OAuth sources
@@ -89,34 +103,19 @@ export default function OpenApiSourceSummary(props: { · 36 unmodified lines
89 const liveConnectionIds = new Set(connections.map((connection) => connection.id));103 const liveConnectionIds = new Set(connections.map((connection) => connection.id));
90 const scopeRanks = new Map(scopeStack.map((scope, index) => [scope.id, index] as const));104 const scopeRanks = new Map(scopeStack.map((scope, index) => [scope.id, index] as const));
91 const credentialTargetScope = userScope;105 const credentialTargetScope = userScope;
92 const missing = missingCredentialLabels(source, bindings, credentialTargetScope, scopeRanks, {106 const missing = missingSourceCredentialLabels({
107 slots: sourceCredentialSlots(source),
108 bindings,
109 targetScope: credentialTargetScope,
110 scopeRanks,
93 liveConnectionIds,111 liveConnectionIds,
94 });112 });
95 113
96 if (props.variant === "panel") {114 if (props.variant === "panel") {
97 if (missing.length === 0) return null;115 return <SourceCredentialNotice missing={missing} onAction={props.onAction} />;
98 return (
99 <div className="shrink-0 border-b border-amber-500/20 bg-amber-500/[0.06] px-4 py-3">
100 <div className="mx-auto flex max-w-4xl items-center justify-between gap-4">
101 <div className="min-w-0">
102 <div className="text-sm font-medium text-foreground">
103 This source needs your credentials before tools can run.
104 </div>
105 <div className="mt-0.5 truncate text-xs text-muted-foreground">
106 Missing: {missing.join(", ")}
107 </div>
108 </div>
109 {props.onAction && (
110 <Button size="sm" onClick={props.onAction}>
111 Add credentials
112 </Button>
113 )}
114 </div>
115 </div>
116 );
117 }116 }
118 117
119 if (missing.length > 0) return <NeedsCredentialsBadge />;118 if (missing.length > 0) return <SourceCredentialStatusBadge missing={missing} />;
120 119
121 if (!oauth2) return null;120 if (!oauth2) return null;
122 const connectionBinding = effectiveBindingForScope(121 const connectionBinding = effectiveBindingForScope(
@@ -131,7 +130,7 @@ export default function OpenApiSourceSummary(props: { · 8 unmodified lines
131 : null;130 : null;
132 131
133 if (connectionId && connections.some((connection) => connection.id === connectionId)) {132 if (connectionId && connections.some((connection) => connection.id === connectionId)) {
134 return <ConnectedBadge />;133 return <SourceCredentialStatusBadge missing={[]} />;
135 }134 }
136 135
137 return <OAuthBadge />;136 return <OAuthBadge />;
packages/react/src/pages/source-detail.tsx+0−6
@@ -166,12 +166,6 @@ export function SourceDetailPage(props: { namespace: string }) { · 165 unmodified lines
166 </div>166 </div>
167 167
168 <div className="flex shrink-0 items-center gap-2">168 <div className="flex shrink-0 items-center gap-2">
169 {editPlugin?.signIn && !editing && !confirmDelete && (
170 <Suspense fallback={null}>
171 <editPlugin.signIn sourceId={namespace} />
172 </Suspense>
173 )}
174
175 {canEdit && editPlugin && !editing && !confirmDelete && (169 {canEdit && editPlugin && !editing && !confirmDelete && (
176 <Button variant="outline" size="sm" onClick={() => setEditing(true)}>170 <Button variant="outline" size="sm" onClick={() => setEditing(true)}>
177 Edit171 Edit
packages/react/src/plugins/credential-target-scope.tsx+8−3
@@ -170,12 +170,15 @@ export function CredentialScopeDropdown(props: { · 169 unmodified lines
170 readonly onChange: (scope: ScopeId) => void;170 readonly onChange: (scope: ScopeId) => void;
171 readonly label?: string;171 readonly label?: string;
172 readonly help?: ReactNode;172 readonly help?: ReactNode;
173 readonly className?: string;
174 readonly triggerClassName?: string;
175 readonly size?: "sm" | "default";
173}) {176}) {
174 const label = props.label ?? "Used by";177 const label = props.label ?? "Used by";
175 if (props.options.length <= 1) return null;178 if (props.options.length <= 1) return null;
176 179
177 return (180 return (
178 <div className="space-y-1.5">181 <div className={props.className ?? "space-y-1.5"}>
179 <div className="flex items-center gap-1.5">182 <div className="flex items-center gap-1.5">
180 <FieldLabel className="text-[11px]">{label}</FieldLabel>183 <FieldLabel className="text-[11px]">{label}</FieldLabel>
181 <HelpTooltip label={label}>184 <HelpTooltip label={label}>
@@ -186,7 +189,7 @@ export function CredentialScopeDropdown(props: { · 4 unmodified lines
186 value={String(props.value)}189 value={String(props.value)}
187 onValueChange={(value) => props.onChange(ScopeId.make(value))}190 onValueChange={(value) => props.onChange(ScopeId.make(value))}
188 >191 >
189 <SelectTrigger className="w-full">192 <SelectTrigger className={props.triggerClassName ?? "w-full"} size={props.size}>
190 <SelectValue placeholder={label} />193 <SelectValue placeholder={label} />
191 </SelectTrigger>194 </SelectTrigger>
192 <SelectContent>195 <SelectContent>
@@ -230,7 +233,7 @@ export function CredentialUsageRow(props: { · 37 unmodified lines
230 }233 }
231 234
232 return (235 return (
233 <div className="grid gap-2 md:grid-cols-2">236 <div className="grid items-stretch gap-2 md:grid-cols-2">
234 <div className="min-w-0 space-y-2.5">{props.children}</div>237 <div className="min-w-0 space-y-2.5">{props.children}</div>
235 <CredentialScopeDropdown238 <CredentialScopeDropdown
236 value={props.value}239 value={props.value}
@@ -238,6 +241,8 @@ export function CredentialUsageRow(props: { · 1 unmodified line
238 onChange={props.onChange}241 onChange={props.onChange}
239 label={props.label}242 label={props.label}
240 help={props.help}243 help={props.help}
244 className="flex h-full flex-col space-y-1.5"
245 triggerClassName="w-full min-h-9 flex-1 data-[size=default]:h-full"
241 />246 />
242 </div>247 </div>
243 );248 );
packages/react/src/plugins/source-credential-status-core.ts+78−0
@@ -0,0 +1,78 @@
1import type { ConnectionId, CredentialBindingValue, ScopeId } from "@executor-js/sdk";
2
3export type SourceCredentialSlot =
4 | {
5 readonly kind: "secret";
6 readonly slot: string;
7 readonly label: string;
8 readonly optional?: boolean;
9 }
10 | {
11 readonly kind: "connection";
12 readonly slot: string;
13 readonly label: string;
14 readonly optional?: boolean;
15 };
16
17export type SourceCredentialBindingRef = {
18 readonly slot: string;
19 readonly scopeId: ScopeId;
20 readonly value: CredentialBindingValue;
21};
22
23const scopeRank = (ranks: ReadonlyMap<string, number>, scopeId: ScopeId): number =>
24 ranks.get(scopeId) ?? Number.MAX_SAFE_INTEGER;
25
26export const effectiveSourceCredentialBinding = (
27 rows: readonly SourceCredentialBindingRef[],
28 slot: string,
29 targetScope: ScopeId,
30 ranks: ReadonlyMap<string, number>,
31): SourceCredentialBindingRef | null =>
32 rows
33 .filter(
34 (row) => row.slot === slot && scopeRank(ranks, row.scopeId) >= scopeRank(ranks, targetScope),
35 )
36 .sort((a, b) => scopeRank(ranks, a.scopeId) - scopeRank(ranks, b.scopeId))[0] ?? null;
37
38const liveConnectionSet = (
39 values?: ReadonlySet<string> | readonly ConnectionId[],
40): ReadonlySet<string> | undefined => (values ? new Set(Array.from(values, String)) : undefined);
41
42export const missingSourceCredentialLabels = (input: {
43 readonly slots: readonly SourceCredentialSlot[];
44 readonly bindings: readonly SourceCredentialBindingRef[];
45 readonly targetScope: ScopeId;
46 readonly scopeRanks: ReadonlyMap<string, number>;
47 readonly liveConnectionIds?: ReadonlySet<string> | readonly ConnectionId[];
48}): string[] => {
49 const liveConnections = liveConnectionSet(input.liveConnectionIds);
50 const missing: string[] = [];
51
52 for (const slot of input.slots) {
53 if (slot.optional) continue;
54 const binding = effectiveSourceCredentialBinding(
55 input.bindings,
56 slot.slot,
57 input.targetScope,
58 input.scopeRanks,
59 );
60
61 if (slot.kind === "secret" && binding?.value.kind !== "secret") {
62 missing.push(slot.label);
63 continue;
64 }
65
66 if (slot.kind === "connection") {
67 if (binding?.value.kind !== "connection") {
68 missing.push(slot.label);
69 continue;
70 }
71 if (liveConnections && !liveConnections.has(binding.value.connectionId)) {
72 missing.push(slot.label);
73 }
74 }
75 }
76
77 return missing;
78};
packages/react/src/plugins/source-credential-status.test.ts+88−0
@@ -0,0 +1,88 @@
1import { describe, expect, it } from "@effect/vitest";
2import { ConnectionId, ScopeId, SecretId } from "@executor-js/sdk";
3
4import {
5 effectiveSourceCredentialBinding,
6 missingSourceCredentialLabels,
7 type SourceCredentialBindingRef,
8 type SourceCredentialSlot,
9} from "./source-credential-status-core";
10
11const userScope = ScopeId.make("user");
12const orgScope = ScopeId.make("org");
13const scopeRanks = new Map([
14 [userScope, 0],
15 [orgScope, 1],
16]);
17
18const slots: readonly SourceCredentialSlot[] = [
19 { kind: "secret", slot: "header:authorization", label: "Authorization" },
20 { kind: "connection", slot: "auth:oauth2:connection", label: "OAuth sign-in" },
21];
22
23const bindings = (scopeId: ScopeId): readonly SourceCredentialBindingRef[] => [
24 {
25 slot: "header:authorization",
26 scopeId,
27 value: {
28 kind: "secret",
29 secretId: SecretId.make(`${scopeId}-token`),
30 },
31 },
32 {
33 slot: "auth:oauth2:connection",
34 scopeId,
35 value: {
36 kind: "connection",
37 connectionId: ConnectionId.make(`${scopeId}-connection`),
38 },
39 },
40];
41
42describe("source credential status", () => {
43 it("treats inherited source-scope credentials as satisfying a user target", () => {
44 expect(
45 missingSourceCredentialLabels({
46 slots,
47 bindings: bindings(orgScope),
48 targetScope: userScope,
49 scopeRanks,
50 liveConnectionIds: [ConnectionId.make("org-connection")],
51 }),
52 ).toEqual([]);
53 });
54
55 it("does not let inner-scope credentials satisfy an outer target", () => {
56 expect(
57 missingSourceCredentialLabels({
58 slots,
59 bindings: bindings(userScope),
60 targetScope: orgScope,
61 scopeRanks,
62 }),
63 ).toEqual(["Authorization", "OAuth sign-in"]);
64 });
65
66 it("treats stale connection bindings as missing", () => {
67 expect(
68 missingSourceCredentialLabels({
69 slots,
70 bindings: bindings(userScope),
71 targetScope: userScope,
72 scopeRanks,
73 liveConnectionIds: [],
74 }),
75 ).toEqual(["OAuth sign-in"]);
76 });
77
78 it("prefers the inner exact binding over inherited credentials", () => {
79 const binding = effectiveSourceCredentialBinding(
80 [...bindings(orgScope), ...bindings(userScope)],
81 "header:authorization",
82 userScope,
83 scopeRanks,
84 );
85
86 expect(binding?.scopeId).toEqual(userScope);
87 });
88});
packages/react/src/plugins/source-credential-status.tsx+55−0
@@ -0,0 +1,55 @@
1import { Badge } from "../components/badge";
2import { Button } from "../components/button";
3export {
4 effectiveSourceCredentialBinding,
5 missingSourceCredentialLabels,
6 type SourceCredentialBindingRef,
7 type SourceCredentialSlot,
8} from "./source-credential-status-core";
9
10export function SourceCredentialStatusBadge(props: { readonly missing: readonly string[] }) {
11 if (props.missing.length === 0) {
12 return (
13 <Badge
14 variant="outline"
15 className="border-green-500/30 bg-green-500/5 text-[10px] text-green-700 dark:text-green-400"
16 >
17 Credentials ready
18 </Badge>
19 );
20 }
21
22 return (
23 <Badge
24 variant="outline"
25 className="border-amber-500/40 bg-amber-500/10 text-[10px] text-amber-700 dark:text-amber-300"
26 >
27 Credentials needed
28 </Badge>
29 );
30}
31
32export function SourceCredentialNotice(props: {
33 readonly missing: readonly string[];
34 readonly onAction?: () => void;
35}) {
36 if (props.missing.length === 0) return null;
37
38 return (
39 <div className="shrink-0 border-b border-border bg-muted/30 px-4 py-3">
40 <div className="mx-auto flex max-w-4xl items-center justify-between gap-4">
41 <div className="min-w-0">
42 <div className="text-sm font-medium text-foreground">Credentials need attention</div>
43 <div className="mt-0.5 truncate text-xs text-muted-foreground">
44 Missing {props.missing.join(", ")}
45 </div>
46 </div>
47 {props.onAction && (
48 <Button size="sm" variant="outline" onClick={props.onAction}>
49 Configure
50 </Button>
51 )}
52 </div>
53 </div>
54 );
55}
packages/react/src/plugins/source-oauth-connection.tsx+68−0
@@ -0,0 +1,68 @@
1import type { ConnectionId, ScopeId, SecretBackedValue } from "@executor-js/sdk";
2
3import {
4 CredentialControlField,
5 CredentialUsageRow,
6 type CredentialTargetScopeOption,
7} from "./credential-target-scope";
8import { SourceOAuthSignInButton } from "./oauth-sign-in";
9
10export function SourceOAuthConnectionControl(props: {
11 readonly popupName: string;
12 readonly pluginId: string;
13 readonly namespace: string;
14 readonly fallbackNamespace: string;
15 readonly endpoint: string;
16 readonly tokenScope: ScopeId;
17 readonly onTokenScopeChange: (scope: ScopeId) => void;
18 readonly credentialScopeOptions: readonly CredentialTargetScopeOption[];
19 readonly connectionId: string | null;
20 readonly sourceLabel: string;
21 readonly headers?: Record<string, SecretBackedValue>;
22 readonly queryParams?: Record<string, SecretBackedValue>;
23 readonly isConnected: boolean;
24 readonly onConnected: (connectionId: ConnectionId) => void | Promise<void>;
25 readonly disabled?: boolean;
26 readonly reconnectingLabel?: string;
27 readonly signingInLabel?: string;
28}) {
29 return (
30 <CredentialUsageRow
31 value={props.tokenScope}
32 options={props.credentialScopeOptions}
33 onChange={props.onTokenScopeChange}
34 label="Connection saved to"
35 help="Choose who can use the OAuth connection."
36 >
37 <CredentialControlField label="OAuth connection" help="Start the provider OAuth flow.">
38 <div className="flex min-h-9 items-center gap-2 rounded-md border border-border bg-muted/30 px-3 py-2">
39 {props.isConnected ? (
40 <span className="text-xs font-medium text-emerald-600 dark:text-emerald-400">
41 Connected
42 </span>
43 ) : (
44 <span className="text-xs text-muted-foreground">Not connected</span>
45 )}
46 <div className="ml-auto">
47 <SourceOAuthSignInButton
48 popupName={props.popupName}
49 pluginId={props.pluginId}
50 namespace={props.namespace}
51 fallbackNamespace={props.fallbackNamespace}
52 endpoint={props.endpoint}
53 tokenScope={props.tokenScope}
54 connectionId={props.connectionId}
55 sourceLabel={props.sourceLabel}
56 headers={props.headers}
57 queryParams={props.queryParams}
58 isConnected={props.isConnected}
59 onConnected={props.onConnected}
60 reconnectingLabel={props.reconnectingLabel}
61 signingInLabel={props.signingInLabel}
62 />
63 </div>
64 </div>
65 </CredentialControlField>
66 </CredentialUsageRow>
67 );
68}